JAVA和Nginx 教程大全

网站首页 > 精选教程 正文

Spring Cloud 时代:这些常用组件你都清楚吗?

wys521 2024-10-17 15:52:25 精选教程 20 ℃ 0 评论

聊到 Spring Cloud 大家的第一反应可能是:在用啊!绝大多数情况都是架构师或者前同事给出一个框,开启新项目的时候,大家照着写就行,或者在原有的基础改改,今天抛砖引玉和大家聊一聊我对 Spring Cloud 的一点儿认识。

通过本文你将了解到:

  1. 没有 Spring Cloud,服务之间要怎么协作?
  2. Feign 到底帮我们做了什么?
  3. 没有 Eureka 行不行?
  4. Ribbon 还能做什么?
  5. Hystrix 为啥靠谱?
  6. Zuul 网关解决了什么问题?

Spring Cloud 全家桶作为微服务的解决方案是一个不错的选择。

没有 Spring Cloud 洪荒时代,服务之间要怎么协作?

回忆一下我们经历的洪荒时代,我经历的有两种:

  1. 基于 HTTP 的服务间通讯
  2. 使用 Dubbo + ZooKeeper,基于 RPC 的服务调用(当然也可以 HTTP 调用)
  3. 基于消息总线的服务间通讯

先说第一个,堆地址简单清晰,能通就有数据,高级一点儿的通过线程池 + 超时控制调用结束,请求是域名,NG 负责负载均衡。

再说第二个,如果有过驻场开发经验的同学就知道有多蛋疼,ZK 不能暴露在外网,服务间调试只有泪水。

Spring Cloud 一出来,我们就紧紧地拥抱了她,到死不愿分开。

行,回归正题吧。

用户下单通过交易服务下单,在这个过程中,主要有这些步骤:

  1. 同步调用用户服务,确定用户状态
  2. 同步调用库存服务,调整库存状态
  3. 同步调用商品服务,确定商品信息
  4. 同步调用道具服务,调整道具状态
  5. 异步调用积分服务,进行积分扣减
  6. 异步调用通知服务,进行消息提醒

一个交易服务需要调用这么多服务,不同的服务不同的团队开发和维护,大家的接口风格又不一一样,要么就是好不容易上线了,有的团队进行接口升级,得,还是得改代码。

好在 Feign 出现了。

Feign 到底帮我们做了什么?

https://github.com/OpenFeign/feign

Feign 的主要功能是提供了一种可以像 Java 之间方法调用的方式来进行 HTTP 服务之间的调用,它主要屏蔽了 HTTP 接口请求方式的复杂性。举例来说,有的是 GET 请求的接口,有的是 POST,有的接口 Header 需要使用特定的属性值……

这里以对接实时段子(一个开放接口)为例:

public interface JokeApi {
 /**
 * 以注解的形式封装HTTP请求方式
 */
 @RequestLine("GET /getJoke?page={page}&count={count}&type=video")
 List<Joke> query(@Param("page") Integer page, @Param("count") Integer count);
 }

 @Data
 public class Joke {
 private String text;
 private String header;
 private String video;
 }

 public static void main(String[] args) {
 JokeApi jokeApi = Feign.builder()
 .decoder((response, type) -> {
 //自定义解码器,可以根据type编写通用的解码器
 log.info("response-{}", response);
 log.info("type-{}", type);
 String body = IOUtils.toString(response.body().asInputStream());
 String result = JSON.toJSONString(JSONPath.eval(body, "$.result"));
 return JSON.parseArray(result, Joke.class);
 })
 .target(JokeApi.class, "https://api.apiopen.top");
 List<Joke> jokes = jokeApi.query(1, 2);
 for (Joke joke : jokes) {
 log.info("joke-{}", joke);
 }
 }

通过这个例子可以看到 HTTP 接口

https://api.apiopen.top/getJoke?page=1&count=2&type=video

转换成了 Java 接口 JokeApi,可以像调用普通方法一样实现 HTTP 接口调用。

总结一下:Feign 实现了 HTTP 请求的高级封装,可以让我们像方法调用一样实现 HTTP 调用。

没有 Eureka 行不行?

https://github.com/Netflix/eureka

Eureka 主要实现了服务定位、服务续约、服务下线的功能。

Eureka 分为客户端 eureka-client 和服务端 eureka-server。

  • eureka-server:通过相互注册形成集群环境,并以服务名称为核心,维护服务的具体信息,主要包括服务真实的 IP 地址、服务端口、服务名称等信息,可以通过 RESTFul 接口修改服务的覆盖状态实现服务下线。
  • eureka-client::维护 eureka-server 集群的地址实现服务注册,通过定时发送 HTTP 请求的方式实现服务健康状况上报,服务端通过维护服务的最近上报时间计算服务的状态,必要时进行服务下线。

最核心的需要维护服务的地址信息和实时状态,这也是 ZooKeeper、Redis 等组件也可以作为注册中心的原因。

Eureka 维护的服务信息

### 获取eureka全部的服务信息(以json格式返回)
curl -X GET http://localhost:8761/eureka/apps \
 -H 'Accept: application/json' \
 -H 'Content-Type: application/json'

### 获取eureka全部的服务信息(以xml格式返回)
curl -X GET http://localhost:8761/eureka/apps \
 -H 'Accept: application/json' \
 -H 'Content-Type: application/xml'

获取单个服务所有的实例信息(k24-order 为服务的名字)

curl -X GET http://localhost:8761/eureka/apps/k24-order \
 -H 'Accept: application/json' \
 -H 'Content-Type: application/json'

示例返回值:

{
 "application": {
 "name": "K24-ORDER",
 "instance": [
 {
 "instanceId": "192.168.0.105:k24-order:9200",
 "app": "K24-ORDER",
 "ipAddr": "192.168.0.105",
 "status": "UP",
 "port": {
 "#34;: 9200,
 "@enabled": "true"
 },
 ... 省略
 }
 ]
 }
}
  • application.name:服务的名字
  • application.instance:服务的实例
  • application.instance.instanceId:instanceId 服务的唯一标记(默认是服务 IP + 服务名字 + 端口号)
  • application.instance.app:服务的名字
  • application.instance.ipAddr:服务的 IP 地址
  • application.instance.port:服务的端口号
  • application.instance.status:服务的状态

通过这个接口,我们可以看到,其实 Eureka 帮我们完成了服务名到实例地址的转换。简单来说,通过 Eureka 我们可以根据服务名获取到服务具体的地址信息。

服务优雅下线思路

当我们需要优雅关闭某个服务时可以用到 Eureka 的 API 实现服务下线,主要步骤分享给大家。

1. 修改服务为 OUT_OF_SERVICE:

put /eureka/apps/appID/instanceID/status?value=OUT_OF_SERVICE

2. 服务休眠 1 分钟(也可以调用 Spring Boot 服务的远程关闭接口)。

3. 重新发版,服务上线。

注意的问题:

需要修改 Eureka 的服务剔除时间,确保服务重新发版的时间大于剔除时间。

## eureka server至上一次收到client的心跳之后,等待下一次心跳的超时时间,在这个时间内若没收到下一次心跳,则将移除该instance ,默认90秒
eureka.instance.leaseExpirationDurationInSeconds=5

总结一下:Eureka 通过客户端上报、服务端检测方式实现服务的发现、续约、剔除、消费。它的主要实现了通过服务名定位到具体服务地址功能。

Ribbon 还能做什么?

https://github.com/Netflix/ribbon

Ribbon 的最大价值就是以决策者的身份出现在服务之间的调用方。这么说可能会很抽象,而且这个组件大家也更加陌生,主要 Ribbon 和 Hystrix 一起集成在 Feign 中使用,Ribbon 决定服务的具体实例,Hystrix 控制对具体服务实例的调用。

Ribbon 以观察者的身份出现在服务调用的调用方,会记录服务的信息,比如实例数量、断路器开关数、活动请求数等信息,为实现不同的负载策略提供计算依据。主要的负载策略:

| 序号 | 负载均衡策略 | 说明 | | :-: | --- | --- | | 1 | BestAvailableRule | 选择最小请求数量的服务器 | | 2 | RoundRobinRule | 以 RandonRobin 方法轮询选择服务器 | | 3 | RetryRule | 和其他负载均衡策略共用,进行重试 | | 4 | WeightedResponseTimeRule | 根据响应时间做权重,响应时间越短,权重越高 | | 5 | ZoneAvoidanceRule | 根据区域服务器的整体情况实现轮询 |

//创建BaseLoadBalancer
BaseLoadBalancer loadBalancer = LoadBalancerBuilder.newBuilder()
 .buildFixedServerListLoadBalancer(
 Arrays.asList(new Server("https","www.baidu.com",80),
 new Server("https","www.sojson.com",80)));
//http请求
final OkHttpClient client = new OkHttpClient();
RetryHandler retryHandler = new DefaultLoadBalancerRetryHandler();

//请求百度网站首页
for (int i = 0; i < 3; i++) {
 String da = LoadBalancerCommand.<String>builder()
 .withLoadBalancer(loadBalancer) //设置BaseLoadBalancer
 .withRetryHandler(retryHandler) //设置重试测试,默认不重试
 .build().submit(server -> {
 String url = String.format("http://%s:%s", server.getHost(), server.getPort());
 log.info("url-{}", url);
 Request request = new Request.Builder().url(url).build();
 try (Response response = client.newCall(request).execute()) {
 return Observable.just(response.body().string());
 } catch (Exception e) {
 log.error("请求-{}-异常", url, e);
 return Observable.error(e);
 }
 }).toBlocking().first();
 log.info("da:{}", da);

}
log.info("LoadBalancerStats-{}", loadBalancer.getLoadBalancerStats());//调用loadBalancer.getLoadBalancerStats()获取Server的信息,用来进行负载计算
[
 Server: www.sojson.com: 80;
 Zone: UNKNOWN;
 Total Requests: 0;
 Successive connection failure: 0;
 Total blackout seconds: 0;
 Last connection made: Tue Nov 12 01: 04: 57 CST 2019;
 First connection made: Tue Nov 12 01: 04: 56 CST 2019;
 Active Connections: 2;
 total failure count in last(1000) msecs: 0;
 average resp time: 0.0;90 percentile resp time: 0.0;
 95 percentile resp time: 0.0;
 min resp time: 0.0;
 max resp time: 0.0;
 stddev resp time: 0.0
],
[
 Server: www.baidu.com: 80;
 Zone: UNKNOWN;Total Requests: 0;
 Successive connection failure: 0;
 Total blackout seconds: 0;
 Last connection made: Tue Nov 12 01: 04: 57 CST 2019;
 First connection made: Tue Nov 12 01: 04: 57 CST 2019;
 Active Connections: 1;
 total failure count in last(1000) msecs: 0;
 average resp time: 0.0;
 90 percentile resp time: 0.0;
 95 percentile resp time: 0.0;
 min resp time: 0.0;
 max resp time: 0.0;
 stddev resp time: 0.0
]

看到这里,相信 Ribbon 的基本功能大家都清晰了,主要是通过 Observable 获取服务实例的实时状态,为各种负载策略计算提供依据,通过 Ribbon 我们可以获取到服务的实时状态,这些数据可以用来实现:

  1. 实时的监控系统
  2. 自定义负载均衡策略的计算基础
  3. 压测指标分析
  4. 等等

结合 Eureka 服务,当调用订单服务时,使用 Ribbon 的负载均衡策略可灵活的实现服务的分发,实现服务的高可用。

总结一下:Ribbon 的出现了,解决了服务多实例负载的问题,通过 Eureka 我们可以使用服务的名称获取到对应的地址,但是具体分发到那个实例可以使用 Ribbon 灵活的实现。Ribbon 基于收集实例的性能指标,通过不同的负载均衡策略确定服务的具体实例。

Hystrix 为啥靠谱?

https://github.com/Netflix/Hystrix

Hystrix——熔断器,这个大概是大家说的最多的组件。

Hystrix 到底是作用在调用方还是作用在业务方?比如订单服务调用服务库存服务。订单服务就是调用方,库存服务就是业务方。

这里和大家聊一个现象。

1. 订单服务调用用户服务

hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=3000

2. 交易服务调用用户服务

hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=2000

熔断都生效的情况下,如果用户服务的处理时间是 2500 毫秒,不考虑网络损耗。交易服务调用用户将失败。但是订单服务正常,线下大家可以修改参数测试验证。

通过这个实验现象我们确定 Hystrix 是作用在调用方,通过保护调用方来保护业务方,防止服务雪崩。

在服务雪崩的模型中,一个服务出现异常,导致调用方频繁重试,将这种系统不稳定向其他服务传播,导致整个系统被拖垮。熔断器出现之后。在调用方通过线程池和信号量限制了服务单位时间内的调用数量和服务放的响应时间。当服务放出现异常,不能正常处理的时候,触发服务降级,达到防止服务雪崩发生的目的。

处理流程



Hystrix 整个工作流如下:

  1. 构造一个 HystrixCommand 或 HystrixObservableCommand 对象,用于封装请求,并在构造方法配置请求被执行需要的参数;
  2. 执行命令;
  3. 判断是否使用缓存响应请求,若缓启用且缓存可用,使用缓存响应请求;
  4. 判断熔断器是否打开,如果打开,跳到第 8 步;
  5. 判断线程池/队列/信号量是否已满,若已满,跳到第 8 步;
  6. 执行 HystrixObservableCommand.construct() 或 HystrixCommand.run(),如果执行失败或者超时,跳到第 8 步;否则,跳到第 9 步;
  7. 统计熔断器监控指标;
  8. 执行 Fallback;
  9. 正常响应请求。

第 7 步和 Ribbon 类似,它也是通过统计服务指标的方式进行决策,Ribbon 是判断到底使用哪个具体的实例,Hystrix 是判断熔断器是否打开

容错实现

Hystrix 是通过线程隔离实现容错的。主要方式有两种,一种是线程池,一种是信号量。

| 实现方式 | 是否相同线程 | 是否支持异步 | 是否支持超时 | 是否熔断支持 | 是否支持限流 | | --- | :-: | :-: | :-: | :-: | :-: | | 信号量 | 是 | 否 | 否 | 是 | 是 | | 线程池 | 否 | 是 | 是 | 是 | 是 |

这里说一说信号量和线程池最本质的区别:服务调用线程和执行外部服务的线程不是同一个。

在信号量模式中,没有线程切换。具体来说就是,当订单模块调用库存模块在线程 threadA 中进行。

  • 使用信号量时:服务的计数减少,直接在 threadA 中调用外部服务,只是信号量的数量减少,线程还是那个线程。
  • 是否线程池时:线程池中的可用线程减少,真正执行外部服务调用的线程不是 threadA,而是线程池中的线程,threadA 持有执行线程的 Future 的,因此可以支持超时。

熔断和限流:

  • 信号量通过单位时间内信号的数量,来控制并发实现限流。
  • 线程池通过线程池的容量,来控制实现限流。

Zuul 网关解决了什么问题?

https://github.com/Netflix/zuul

Zuul——网关,谈到网关大家都很熟悉,一般的平台都会有一个网关,作为外部流量的出入口。

最常见的使用 Nginx 通过代理方式实现。

server {
 server_name www.xxx.com;
 root /var/www/html/xxx/;
 location /dev-api/ {
 proxy_pass http://127.0.0.1:9700/api/;
 }
 location /dev-user/ {
 proxy_pass http://127.0.0.1:9800/user/;
 }
 location /dev-order/ {
 proxy_pass http://127.0.0.1:9900/order/;
 }
 }

前后端分离的项目中,前段需要访问的服务,前段使用不用的前缀隔开,后端通过前段的请求名称实现服务的分发

上面的配置暴露了很多问题:

  1. 服务需要在内部各自实现鉴权
  2. 服务无法动态扩容
  3. 新增服务时需要修改 Nginx 配置信息

Zuul 网关 Eureka 获取服务的注册信息实现请求分发,结合内部的 filter 实现请求过滤。

通过 filter 实现统一鉴权:

public class UserZuulFilter extends ZuulFilter {

 @Autowired
 private PPowerHandleService PPowerHandleService;

 private static Set<String> NO_TOKEN_SET = new HashSet<>();
 private static Set<String> UN_POWER_SET = new HashSet<>();

 static {
 NO_TOKEN_SET.addAll(Arrays.asList(

 ));

 UN_POWER_SET.addAll(Arrays.asList(

 ));
 }

 @Override
 public String filterType() {
 return "pre";
 }

 @Override
 public int filterOrder() {
 return 4;
 }

 @Override
 public boolean shouldFilter() {
 return true;
 }

 @Override
 public Object run() throws ZuulException {
 RequestContext ctx = RequestContext.getCurrentContext();
 HttpServletRequest request = ctx.getRequest();
 try {
 String method = request.getMethod();
 String url = request.getRequestURI();
 String token = request.getHeader("token");
 log.info("网关-{}-{}-{}", method, url, token);
 if (!RequestMethod.POST.name().equals(method.toUpperCase())) {
 log.error("网关-请求异常-仅支持POST方法-{}", url);
 doError();
 }
 if(UN_POWER_SET.contains(url)){
 log.error("网关-请求异常-不允许外部访问-{}", url);
 doError();
 }
 //读取请求信息
 InputStream in = request.getInputStream();
 String body = StreamUtils.copyToString(in, Charset.forName("UTF-8"));
 if (StringUtils.isBlank(body)) {
 body = "{}";
 }
 log.info("网关-{}-{}-{}-{}",
 method, url, token, StringUtils.normalizeSpace(body));
 BusinessQuery businessQuery = JSON.parseObject(body, BusinessQuery.class);
 if (Objects.isNull(businessQuery)) {
 businessQuery = new BusinessQuery();
 }
 //修改请求信息
 businessQuery.setUser(Optional.empty());
 String taskCode = "TK" + System.nanoTime();
 businessQuery.setTaskCode(taskCode);
 Boolean tokenAble = !NO_TOKEN_SET.contains(url);
 if (tokenAble && StringUtils.isBlank(token)) {
 log.error("网关-请求异常-请携带认证信息访问-{}", url);
 doError();
 return null;
 }
 if (!StringUtils.isBlank(token)) {
 BusinessQuery<Map<String, String>> userLoginInfoQuery =
 new BusinessQuery<>();
 Map<String, String> map = new HashMap<>();
 map.put("token", token);
 userLoginInfoQuery.setQuery(map);
 BusinessResult res =
 PPowerHandleService.handle("ZuulUserLoginInfo", userLoginInfoQuery);
 Boolean successAble = BusinessResults.isSuccess(res);
 if (!successAble && tokenAble) {
 log.error("网关-请求异常-认证信息失效-{}", url);
 doError();
 return null;
 }
 if (successAble) {
 businessQuery.setUser(Optional.of(res.getData()));
 }
 }
 Optional<String> jsonBodyStringOptional = JsonUtils.
 toJSONString(businessQuery);
 if (!jsonBodyStringOptional.isPresent()) {
 log.error("网关-请求异常-参数序列化失败-{}", businessQuery);
 doError();
 }
 //重写请求信息
 final byte[] reqBodyBytes = jsonBodyStringOptional.
 get().getBytes(Charset.forName("UTF-8"));
 ctx.setRequest(new HttpServletRequestWrapper(request) {
 @Override
 public ServletInputStream getInputStream() throws IOException {
 return new ServletInputStreamWrapper(reqBodyBytes);
 }

 @Override
 public int getContentLength() {
 return reqBodyBytes.length;
 }

 @Override
 public long getContentLengthLong() {
 return reqBodyBytes.length;
 }
 });

 } catch (Exception e) {
 log.error("网关-处理异常", e);
 doError();
 }
 return null;
 }

Zuul 的出现解决了外部请求转发到具体服务的问题。即使用 Zuul 之后 Nginx 变成:

server {
 server_name www.xxx.com;
 root /var/www/html/xxx/;
 location /backend/ {
 proxy_pass http://127.0.0.1:9000/backend/; //zuul服务的访问地址
 }
 }

总结一下:Zuul 的出现简化了请求分发,通过内部过滤器实现请求和响应的重写,真正实现了 Web 服务的门户。

总结

在分布式系统中,绕不开的问题就是服务名对应的服务地址,这是服务之间调用的基础。

在 Spring Cloud 实现中:

  1. Eureka 维护了服务的具体的地址信息,解决服务在哪儿的问题
  2. Ribbon 解决了多个实例的负载问题,解决具体使用哪个实例的问题
  3. Feign 解决了服务之间请求方式多样化的问题,解决怎么调用的问题
  4. Hystrix 解决了依赖服务之间处理数量和响应时间的问题,解决执行状况问题
  5. Zuul 解决请求分发问题,解决请求谁处理的问题



Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表