Chapter 0. 基本概念

  • 微服务是一种软件架构风格。专注于单一职责的小型业务为基础,组成复杂大型应用。
  • 需要解决的问题:服务拆分、远程调用(RPC)、服务治理(可用性与调度)、请求路由、身份认证、配置管 理、分布式事务(一致性问题)、异步通信……
  • 优点和特征:粒度小、单服务开发便捷,团队自治,服务自治,系统耦合性低;
  • 缺点:跨模块开发难度大,运维成本高;

对比而言,单体架构:

  • 优点:架构简单、部署成本低(适用于开发功能相对简单、规模较小的项目);
  • 缺点:团队协作成本高,系统发布效率低、系统可用性差(软件可靠性差);
  • 对应框架:Spring Cloud,全球范围广泛使用的微服务框架;
    • 服务注册发现组件:Eureka、Nacos、Consul……
    • 服务远程调用(RPC);OpenFeign、Dubbo……
    • 服务链路监控:Zipkin、Sleuth……
    • 统一配置管理:Spring Cloud Config、Nacos……
    • 统一网关路由:Spring Cloud Gateway、Zuul……
    • 流控、降级、保护:Hystix、Sentinel……

Chapter 1. 基于实践学习:将单体架构拆分

1.1 基本思路

  • 拆分时机:
    • 创业项目:先使用单体架构,快速开发、试错;规模扩大后再拆分;
    • 确定的大型项目:资金充足,目标明确,直接选择微服务架构;
  • 拆分原则:
    • 高内聚:每个微服务的职责尽量单一,包含的业务相互关联度高、完整度高
    • 低耦合:每个微服务的功能要相对独立,尽量减少对其他微服务的依赖;
  • 拆分方式:
    • 纵向拆分:按业务模块拆分(用户认证管理、订单管理……);
    • 横向拆分:抽取公共服务,提高复用性(用户登录、订单管理中有公共服务,例如风控、日志服务);

具体如何拆分何种模式?

  • 独立 Project:一个单体架构的项目拆分后,将每个 service 放在一个独立 Project 内;

    优点:相当解耦,关联度更低;

    缺点:对于小型项目而言仓库管理臃肿费力;

    适用:大型项目,每个 service 的业务逻辑相当复杂;

  • Maven 聚合:一个单体项目拆分后,所有 service 都存放在一个 project 中,这个 project 专门用于管理依赖,每个 service 都是 project 的一个 module;

    适用:中小型项目,敏捷开发;

1.2 远程调用 RPC:以 Spring RestTemplate 为例

拆分的问题:经常会遇到一个服务依赖另一个服务的情况。这可以直接通过服务间远程调用(RPC)来完成。

远程调用 RPC 更像是一种软件协议,能让一个程序和本地执行流程一样,在远端执行一段代码

在软件工程原理的 23 个设计模式中,更像是利用了 Proxy 设计模式。调用者无需关心远程信息交互的具体细节,只需按照接口像调用本地服务一样即可完成目标。

在 Spring 中,一种轻量级的远程调用的方法是,使用接口:RestTemplate,调用它就相当于在 Java 代码中向 RESTful API 发送网络请求并且获取回复。

1
2
3
4
5
6
7
public <T> ResponseEntity<T> exchange(
String url,
HttpMethod method,
@Nullable HttpEntity<?> requestEntity, /* request body */
Class<T> responseType,
Map<String, ?> uriVariables
);

规避类型擦除:new ParameterizedTypeReference<T>() {}

但是有一些不可避免的问题:

  • 若代码中写死 url,则 负载均衡、可用性轮换等等策略全部失效;
  • 若业务逻辑中的远程调用的部分较多,则 RestTemplate 对象散落各处不方便维护;
  • ……

于是我们需要更严格、复杂的架构将服务间的远程调用管理起来。一种思路就是 “发布-订阅者模式”;

1.3 微服务的注册与发现机制

1.3.1 发布-订阅模式(架构)

发布-订阅者模式(pub-sub pattern);

The Pub/Sub (Publisher/Subscriber) model is a messaging pattern used in software architecture to facilitate asynchronous communication between different components or systems. In this model, publishers produce messages that are then consumed by subscribers.

发布-订阅模型,作为一种消息传递模式,用在一些软件架构中,来实现不同组件、系统间的异步通信

  • subscribers:消息的获取方,publishers:消息生产方;

  • topics:一种订阅频道 / 分类(channels / categories);

    发布者可以按照消息的业务含义为消息打上话题标签(并且无需知道订阅者的特性),而订阅者可以按照 topics 来订阅、获取部分感兴趣的消息(并且无需知道发布者的特性);

    Topics help categorize messages and enable targeted message delivery to interested subscribers.

  • message brokers:一种中间件/中间人(intermediaries),负责在 publishers 和 subscribers 间使用合适的策略传递消息(调度队列、延时……);

    • message brokers 可以按 topics 维护一些消息队列;
    • 确保消息发送给正确的订阅者,并且提供额外的特性:消息持久化、可扩展性、可靠性
  • message:可以是任何形式的数据信息(text, JSON, binary, etc);

  • subscription:订阅代表了 subscriber 和某个 topics 建立的一个关联。它定义了:

    • 订阅者订阅了什么 topic 的消息;
    • 订阅设置,例如是否事务、是否有订阅内容保证(at-most-once / at-least-once)等等;

发布-订阅模型的理论过程:

  1. 发布者创建并发送消息给 pub/sub system,并且根据消息内容类型放入指定 topic 或者说 channel 中;
  2. 订阅者向 pub/sub system 表达需要订阅某个/些 topic 的意愿,有信息就会收到;
  3. message brokers 根据收到发布者的 topic 对收到的消息分类,再根据所有 topics 的订阅情况 forward 给所有订阅了这个 topic 的 subscribers;

  4. 以上过程全部异步,发布者不需要关心订阅者的状态即可发布;订阅者无需关心发布者的状态即可接受消息。

发布-订阅模型的优势:

  • decoupling & scalability:将消息生产方和消息接受方解耦,不仅无需关心对方状态和交互细节,而且 scalable,便捷地增减 publishers 和 subscribers 的数量而无需影响现有组件;
  • asynchronous communication:异步通信能力;
  • event-driven architecture:发布者和订阅者互不耦合,但发布者的事件行为可以影响订阅者;
  • dynamic subscription:允许运行时动态更换订阅,去耦合,全面的灵活性;

发布-订阅模型的适用场景:

  • 消息队列系统;
  • 需要构建 scalable web app 的时候,尤其是在线文档、实时更新的场景;
  • 微服务架构间的远程通信;
  • ……

发布-订阅模型不应该使用的场景:

  • 对交互时延有极强需求的场景,例如游戏;
  • 低复杂度的交互场景,例如系统只有两个组件间的交互,贸然引入会增大不必要的复杂度;
  • ……

回到微服务远程调用的主题上。为了确保服务远程调用的灵活性、可用性,我们可以借鉴 发布-订阅者模式,通过注册、发现、订阅的流程,动态调度服务的访问方式。

这样既可以有效地、统一地管理远程访问,提升可维护性,又可以便捷地进行调度,充分利用服务资源。

1.3.2 注册中心

在微服务架构中,规避微服务间直接远程调用缺陷的一种方式就是引入注册中心机制,借鉴发布-订阅模式,引入注册中心后的主要步骤如下:

  1. 服务发布者向注册中心注册服务信息(提供何种服务,即 topic,还有地址在哪里);
  2. 服务订阅者向注册中心订阅感兴趣的服务。此时注册中心可以将当前可用的发布者信息告诉订阅者;
  3. 订阅者(或者注册中心)可以进行负载均衡,选择一个发布者向其请求服务(远程调用)。

由于我们利用了发布-订阅模式,所以即便是已经获取服务列表的订阅者,也能从注册中心实时获取当前发布者的可用情况。例如:

假设订阅者从注册中心获取了 3 个可能的服务发布者,但是一段时间后其中一个服务提供方 A 宕机。

这个时候 A 不再通过注册中心的 health check(heart beat),注册中心认为 A 不再有效,于是向所有订阅了 A 服务所在 topic 的所有订阅者推送变更。

这就保证了订阅者订阅列表的有效性。创建了新的服务实例也是如此!实现了 scalable service,随意增减服务实例数量、负载均衡。


在代码方面,我们知道在 Spring Cloud 中,注册中心有很多实现,例如 Alibaba 的 Nacos,Netflix 的 Eureka。我们就以其中的 Nacos 为例。我们只需要将注册中心部署在固定 IP 的服务器上即可。

配置好 Nacos,在注册服务客户端(也就是提供服务方)引入 nacos 注册发现服务,还需要对 Spring 进行配置,指定 registry server 的地址和需要的服务名。最后启动这个服务实例即可完成注册!

而在服务调用端,需要在项目中引入 Nacos Client,它实现了 Spring Cloud 的 DiscoveryClient 接口。

我们直接注入 DiscoverClient,使用如下方法:

  • DiscoverClient.getInstances(String serviceName) -> List<ServiceInstance>serviceName 是服务提供方在 Nacos 中注册的服务名;
  • 而我们可以通过 ServiceInstance 获取 uri / host / port 信息,手动写负载均衡或其他处理工作。

注:Nacos 需要导入依赖 spring-cloud-starter-alibaba-nacos-discovery,并且进行配置:

1
2
3
spring:
cloud:
nacos.server-addr: ...

1.3.3 更优雅的远程调用:OpenFeign

初步认识 OpenFeign

但是这里有个问题,我们通过 DiscoverClient 获取可用服务列表,然后再处理一系列可能的异常,然后还要手写 RestTemplate 进行远程调用,最后才能访问服务!

如此繁琐的远程调用,我们应该进行封装!好在同样有框架能够更轻松地帮助我们完成远程调用:OpenFeign;

注:我们还要对负载均衡算法进行封装。使用 Spring cloud load balancer 就能解决问题。

引入 OpenFeign 的方法如下:

  • 引入 OpenFeign 依赖;

  • 在 Springboot Application 启动类注解 @EnableFeignClients 启用 OpenFeign 的特性;

  • 定义接口,用于指定要远程调用的服务名、远程调用服务名的 endpoint。举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    @FeignClient(value = "item-service")    /* 需要向注册中心申请的服务名 */
    public interface ItemClient {

    /* 告诉 OpenFeign 需要连接远程服务的 endpoint 相当于指定 RestTemplate 的 uri 和 HttpMethod */
    @GetMapping("/items")
    /* 告诉 OpenFeign 的传入参数、请求体信息,以及服务返回 JSON 对应的类型,相当于指定 RestTemplate 的 RequestEntity,responseType,uriVariables */
    List<ItemDto> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
    }

这样相当于实现了 proxy 设计模式(这个 interface 对应 Spring 生成的实例就是 proxy),在调用远程服务就像调用本地服务一样简单!

OpenFeign 连接池优化

OpenFeign 的底层实现非常类似我们手动使用 DiscoverClient,并且考虑负载均衡、服务异常的情况。

感兴趣的同学可以步进调试观察其底层行为。

值得注意的是,OpenFeign 底层默认的远程调用的方式是利用 HttpURLConnection 类(位于 FeignBlockingLoadBalancerClientdelegate 成员变量),而这个 Java 内置类比较底层,不支持连接池,不像 apache 的 HttpClientOkHttp,可能造成资源利用率较低。

好在我们可以为 OpenFeign 的底层请求更换具有连接池的连接类,这样可以减小创建和销毁连接的开销,有助于提升程序性能。只需导入依赖相应依赖(例如 feign-okhttp),并且在 application.yaml/properties 中配置开启即可(例如 feign.okhttp.enabled = true);

注意,是在 OpenFeign 调用方模块设定才有效!

OpenFeign 最佳实践

到这里仍然没有结束,我们还需要知道 OpenFeign 框架的最佳实践。

上面介绍引入 OpenFeign 的方法实际上是有严重问题的。我们发现,当单体架构的程序被拆成微服务后,有可能多个服务依赖同一个微服务,难不成所有用到的微服务都要定义一遍像 ItemClient 一样的接口?

我们知道,代码重复是大忌,这不仅降低了可维护性(增添维护人员心智负担)、可扩展性(修改繁琐,不对修改开发),还降低了代码可读性和正确性。

正确使用 OpenFeign 的方案有几种:

  • 直接交给服务提供方编写 Feign Client,将服务提供方拆成 3 个模块:

    • DTO 中包含向服务调用方返回的数据类型;
    • api 中包含 FeignClient 接口以供调用;
    • blz 中包含原先的代码业务逻辑;

    最后其他服务只需引用该服务为依赖即可。

    优点:代码逻辑更合理,易于维护,耦合度更低;

    缺点:项目结构更复杂、编写麻烦;

  • 创建一个不属于任何一个微服务模块的、同级的 api module,专门管理 FeignClient,主要包含:

    • client:所有微服务想要向外暴露的 FeignClient
    • config:所有微服务的配置类;
    • dto:所有微服务想要返回的数据类型;

    优点:项目结构清晰,无需改动原先微服务;

    缺点:代码耦合度增加,每个微服务模块都需要引入该模块为依赖(而前一种方法只需引入需要用的模块就行),所有微服务都需要和 api 一起开发;

    但是如果以这种方式创建 FeignClient,没法完成依赖注入(因为 Spring Application 没法自主扫描本包以外的 bean),就需要手动指定接口类型,并将其纳入 IoC Container 中。两种方法:

    1
    2
    3
    4
    5
    6
    /* 在 SpringApplication 启动类启用 FeignClient 时显示指定 bean 类型 */

    /* 方法一:为 Spring 指定 bean 扫描的包名(精确到子包) */
    @EnableFeignClients(basePackages = <package name>)
    /* 方法二:利用反射手动指定 bean 对应接口的 Class 类型 */
    @EnableFeignClients(clients = { <classname>.class })

OpenFeign 日志管理

OpenFeign 框架默认情况下仅在 FeignClient 所在包配置的日志级别为 DEBUG 时才会输出日志,并且自身的日志级别是 NONE(不输出),故需要我们手动配置。

注:OpenFeign 自身的日志级别有 4 种:NONE / BASIC / HEADERS / FULL

定义 OpenFeign 的日志级别需要完成两件事:

  1. 定义配置类,例如:

    1
    2
    3
    4
    5
    6
    7
    /* 没有 @Configuration / @Service 等注解,该 @Bean 不会被纳入 IoC Container */
    public class FeignConfig {
    @Bean
    public Logger.Level feignLogLevel() {
    return Logger.Level.FULL;
    }
    }
  2. 将配置类配置给指定 / 全局的 FeignClient

    1
    2
    3
    4
    5
    /* 指定 FeignClient */
    @FeignClient(value = "...", configuration = FeignConfig.class)

    /* 全局 FeignClient 默认 */
    @EnableFeignClients(defaultConfiguration = FeignConfig.class)

1.4 微服务网关

在大致拆好微服务后,有个问题就随之出现:前端应该如何访问后端服务?难道前端还需要动态获取各个服务的地址?

肯定不能这样,我们的期望是前后端的解耦,就是说单体架构和微服务架构下,前端是无需改变的,只需要向固定的地址发送不同请求就能得到对应的响应,这就需要一个中间层来完成这个任务。

这个能将不同服务转发给某个符合条件的微服务的中间层就是网关。网关不仅能完成前面的 forward 的功能,还能配合注册中心进行负载均衡。

功能:

  • 请求路由(路径针对什么微服务?);
  • 转发(帮忙将 HTTP 请求 forward 给某个动态地址的实例);
  • 身份校验(检查请求的 Authorization 是否合法);

这样,我们就不需要在每个微服务中进行身份校验等繁琐工作了。更安全的是,后端微服务甚至不需要向外暴露端口了,只需暴露网关,大大增强安全性。

此外,引入网关后,后端实现了封装和解耦,在前端看来这和单体架构别无二致。

1.4.1 微服务网关框架:Spring Cloud Gateway

Spring Cloud Gateway Netflix Zuul
基于 WebFlux 响应式编程 基于 Servlet 阻塞式编程
无需调优即有很好性能 需要调优才有接近Spring Cloud Gateway 的性能
正常维护 更新较慢

基于上述特点,我们以 Spring Cloud Gateway 为例。

它的使用很简单:导入依赖、编写启动类、编写配置。

Spring Cloud Gateway 配置示例

1
2
3
4
5
6
7
8
9
10
11
12
spring:
cloud:
gateway:
routes: # 路由规则列表
- id: <Route ID> # 独一无二的路由 ID
uri: lb://<service name> # load balance,到指定服务
predicates: # 筛选断言条件(列表)
- Path=/xx/** # 支持通配符
filters:
- [...]=[...]
default-filters:
- [...]=[...]

众所周知,application.yaml 中的配置内容相当于 XML Bean,都在向 Spring 框架的类型中填写初始化属性罢了。这里 spring.cloud.gateway.routes 对应的是 Collection<RouteDefinition> 类型。

其中,predicates 属性可取以下值:

Name Description Example
After 是某个时间点后的请求 After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些cookie Cookie=chocolate, ch.p
Header 请求必须包含某些header Header=X-Request-Id, \d+
Host 请求必须是访问某个host(域名) Host=**.somehost.org,**.anotherhost.org
Method 请求方式必须是指定方式 Method=GET,POST
Path 请求路径必须符合指定规则 Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 Query=name, Jack 或者 Query=name
RemoteAddr 请求者的ip必须是指定范围 RemoteAddr=192.168.1.1/24
Weight 权重处理 Weight=group1, 2
X-Forwarded Remote Addr 基于请求的来源IP做判断 XForwardedRemoteAddr=192.168.1.1/24

filters 可取以下值:

Name Description Example
AddRequestHeader 给当前请求添加一个请求头 AddrequestHeader=headerName,headerValue
RemoveRequestHeader 移除请求中的一个请求头 RemoveRequestHeader=headerName
AddResponseHeader 给响应结果中添加一个响应头 AddResponseHeader=headerName,headerValue
RemoveResponseHeader 从响应结果中移除有一个响应头 RemoveResponseHeader=headerName
RewritePath 请求路径重写(ant path 语法) RewritePath=/red/?(?\<segment\ .*), /\\{segment}
StripPrefix 去除请求路径中的N段前缀 StripPrefix=1,则路径/a/b转发时只保留/b
…… …… ……

一共有 33 种,详见官网。

值得注意的是,filter 是基于 router 生效的、作用于 router 的。Spring Cloud 中的 filter 分为两种:

  • GlobalFilter:全局过滤器,作用于所有路由,在声明该过滤器后无需激活即可生效;
  • GatewayFilter:路由过滤器,共 33 种,作用于指定路由,默认不生效。上面的在配置文件中写的 filters 就是这个过滤器!

Spring Cloud Gateway 过滤机制 以及 Spring Security 对比

这里只介绍了网关 forward 的配置,那么如果想对网关做更进一步的配置(例如身份验证),应该怎么操作?

首先需要了解 Spring Cloud Gateway 的底层机制。

这里的思路几乎和 Spring Security 的过滤器链一模一样(责任链模式)。

因此我们想加入身份验证功能,就需要在 Filter 的 PRE 部分定义检查逻辑。如果符合条件,则允许通过 NettyRoutingFilter 进行转发;否则抛出异常立即拒绝请求。

现在做身份验证的思路就非常清楚了:自定义一个 Filter 类,最好像 Spring Security 的 OncePerRequestFilter 一样每次请求仅通过一次,插入到 NettyRoutingFilter 之前,就能完成任务。

但是有几点和之前的微服务架构截然不同!!!

考虑第一点:网关如何向微服务传递当前登录用户的信息?

注意到现在网关、各个微服务都是独立的服务了,和单体架构不同,我们不能通过保存在类似于 SecurityContext 这样的单线程上下文(ThreadLocal)中,把网关中检查的用户信息传给 forward 的目标服务了。

回忆一下,网关向微服务 forward 实际上已经是一次新的 HTTP 请求了,而且我们之前在 Gateway 的 filters 参数中看到,gateway 可以配置额外添加请求头信息。因此不难想到,我们可以通过在网关的自定义 filter 中加入关于用户信息的请求头就能解决这个问题!

但再考虑第二个问题:既然网关和微服务间通过传递请求头来完成用户信息传递,那微服务之间相互调用也很频繁,它们默认还是原来的请求方式(Nacos + OpenFeign),没有请求头,难道要更改原来的代码,每个微服务请求 Client Proxy 时还要主动记录和传递用户信息?

是的!不过幸好 OpenFeign 有另一套方法帮我们加上这个请求头,所以不必担心。

下面我们来认识一下如何自定义 filter,并且完成身份认证的功能。

自定义 Global Filter

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface GlobalFilter {
/**
* 定义 PRE 部分的 Gateway Global Filter
*
* @param exchange 经过该 filter 时,过滤器链的请求上下文,包括 request、response、前面的 filter 写入的信息;
* @param chain 过滤器链中下一条要执行的 filter
*
* @return 类似 JavaScript 中的回调函数。采用了 WebFlux 的非阻塞的、响应式接口,因为 PRE 和 POST 间时间可能很长,所以实际上 POST 部分 filter 是通过定义这个回调的行为来完成的。
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
/* 合理的做法是,和 Spring Security 类似,返回 chain.filter(exchange),将 filter 链委托给下一级 */
/* 如果想要阻止请求(例如未认证),那么请拿到 response,set response code,并且返回一个 response.setComplete() 标识请求已经回复(拒绝) */
}

还需要注意的是:

  • global filter 需要继承于 Ordered 接口;
  • 并且重写 int getOrder() 方法,这样能为 filter 安排插入顺序。注意:返回的整型越小优先级越高,并且 NettyRoutingFilter 的 order 是最大整型,因此任意一个不是 INT_MAX 的 order 都会让 filter 排在它的前面;
  • 最后需要使用 @Component 纳入 Spring IoC Container 管理;
  • 最后还要选一个配置类,使用 @Bean 提供一个 GlobalFilter 实例!!

自定义 Gateway Filter

这就是我们自定义写在 filters 配置文件中的 filter。本部分为进阶功能,一般使用不到。

我们需要继承于 AbstractGatewayFilterFactory<Object>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
public class PrintAnyGatewayFilterFactory implements AbtractGatewayFilterFactory<Config> {
/* config 类型请使用你想要的,例如 List 或者自定义类型 */
@Override
public GatewayFilter apply(Config config) {
return new GatewayFilter() {
@Override
public Mono<void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
/* same as Global Filter */
}
};
}

/* 定义传给 config 的数据类型 */
@Data // lombok
public static class Config {
private String a;
private String b;
private String c;
}

/* 定义配置参数缩写名(类似设定命令行参数缩写) */
@Override
public List<String> shortcutFieldOrder() {
return List.of("a", "b", "c");
}

/* 使用构造函数告诉 AbstractGatewayFilterFactory config 的配置类型 */
public PrintAnyGatewayFilterFactory() {
super(Config.class);
}
}

上面的 Config 类型可以换成任何的自定义类型,以完成参数配置需求。如果不需要任何参数,直接使用 Object 类型,后面 3 个函数也就没有必要了。

这样前缀 PrintAny(去除 GatewayFilterFactory)就是配置名,我们就能自定义 filters 参数了:

1
2
3
4
5
6
7
8
spring:
cloud:
gateway:
routes:
- id: ...
uri: ...
predicates: ...
filters: PrintAny: <config> # 需要和自己定义的 config 类型匹配

此外,对于 GatewayFilter 如果想指定顺序,请使用 OrderedGatewayFilter 包装:

1
2
3
4
5
6
7
8
9
@Override
public GatewayFilter apply(Object config) {
return new OrderedGatewayFilter(new GatewayFilter() {
@Override
public Mono<void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
/* same as Global Filter */
}
}, /* order */ 10);
}

总结一下,GatewayFilter 的定义使用了抽象工厂模式,满足了多样化定制需求。

Spring Cloud Gateway 传递用户信息

现在我们来解决之前提到的两个问题:

  • 网关如何向微服务传递当前登录用户的信息?
  • 微服务之间相互调用如何使用 OpenFeign 传递用户信息?

对第一个问题,我们采用如下思路:

这样我们微服务中的业务在本微服务内就沿用之前的 context 方案,无需更改,只需要修改进入微服务的拦截器即可;

先看如何更改 GlobalFilter 的请求内容:

1
2
3
4
5
/* ServerWebExchange.mutate() 方法可以返回已初始化的对象的 builder 以供修改 */
exchange.mutate()
/* 传递修改 request 的 build 方法 */
.request(builder -> builder.head("info", info))
.build();

对于新建拦截器,我们不必在每个微服务中都写一遍,只需在共同依赖的模块中写入即可。

1
2
3
4
5
6
7
8
9
10
11
public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/* 识别信息并保存到 context,不作拦截 */
}

@Override
public boolean afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/* 请求结束后,请销毁 context */
}
}

记得把 Interceptor 配置到 Spring 中,在每次请求前进行:

1
2
3
4
5
6
7
8
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(/* 拦截器实例 */);
/* .addPathPattern() 可以选择作用的 path pattern,不写就是作用全部 */
}
}

但是还需要注意,这里的 Bean 现在没法被其他用到的模块扫描到,我们需要在这个模块的 resources/META_INF/spring.factories 中,配置:

1
2
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
<package name>.MvcConfig

这样使得在该项目中, Spring 默认会将这个类加入 IoC Container。

注意:在 Spring 3.x 以后,已经全面取消 spring.factories 中的 org.springframework.boot.autoconfigure.EnableAutoConfiguration 键的作用了。

在 Spring 3.x 的项目中,上面的配置是无效的,Spring 是不会扫描并且自动装配你指定的类的。

你需要在 META-INT/spring/ 目录下新建一个文件 ``

但是需要注意,这里采用的 WebMvcConfigurer 只能在微服务中生效,不能在 Gateway 中生效(因为 Gateway 是 WebFlux 非阻塞式接口,不能引入 Spring MVC 的接口),所以我们需要条件装配:

1
2
/* 在 MvcConfig 前面的加入注解 */
@ConditionalOnClass(DispatcherServlet.class) /* 该类是 Spring MVC 的核心 API */

对于第二个问题,我们只需要对 OpenFeign 的请求进行定义:让每次 OpenFeign 触发微服务间调用时,都带上一个自定义的请求头,就像网关传给微服务一样。这里使用 OpenFeign 给的接口:

1
2
3
4
public interface RequestInterceptor {

void apply(RequestTemplate template);
}

这同样要定义在所有微服务中都依赖的模块中(因为是配置,所以用 bean 注入):

1
2
3
4
5
6
7
8
9
10
/* 在某个配置 Feign 的类中 */
public RequestInterceptor userInfoInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
/* 配置请求头 */
template.header(<header name str>, <context> /* 位于同线程,可用 */);
}
}
}

当然,这个配置类需要显式配置给 OpenFeign(就像之前配置 Feign 日志的全局配置一样):

1
@EnableFeignClients(value = ..., defaultConfiguration = 该类名)

Chapter 2. 微服务理论

2.1 微服务雪崩

在微服务相互调用中,服务提供者出现故障或阻塞。并且:

  • 服务调用者没有做好异常处理,导致自身故障;
  • 或者访问连接一直保持 / 请求速度大于处理速率,致使请求不断堆积在 tomcat 中导致资源耗尽;

最终,调用链中的所有服务级联失败,导致整个集群故障。

解决微服务雪崩的思路主要如下:

  1. 尝试避免出现故障 / 阻塞;
    • 保证代码的健壮性;
    • 保证网络畅通;
    • 能应对较高的并发请求;
    • 微服务保护:保护服务提供方;
  2. 局部出现故障 / 阻塞后,及时做好预备方案(积极有效的错误处理);
    • 微服务保护:保护服务调用方;

2.2 微服务保护

为了应对微服务雪崩,我们有许多解决方案。其中,微服务保护是在业务逻辑代码层面以外的一种重要方案。

微服务保护有以下一些思路:

  • 请求限流:保护服务提供方。限制访问微服务的请求的并发量,避免服务因流量激增出现故障(应对访问模式:spike 型);

  • 线程隔离(舱壁模式):保护服务消费方。通过限定每个业务能使用的线程数量而将故障业务隔离,避免故障扩散;

  • 快速失败 和 服务熔断:

    • 快速失败:给业务编写一个调用失败时的处理的逻辑,称为 fallback。当调用出现故障(比如无线程可用)时,按照失败处理逻辑执行业务并返回,而不是直接抛出异常;
    • 断路器统计请求的异常比例或慢调用比例,如果超出阈值,则认为某个微服务业务所对应的所有实例都不可用,熔断该业务,则拦截该接口的请求。熔断期间,所有请求均 fallback 为快速失败逻辑;

以上微服务保护的策略可以使用 Sentinel / Hystrix 框架完成。

Metrics or Feature Sentinel Hystrix
Belong to Spring Cloud Alibaba Spring Cloud Netflix
Thread Isolation 信号量隔离 线程池隔离/信号量隔离
Fuse Policy 基于慢调用比例或异常比例 基于异常比率
Traffic Limiting 基于 QPS,支持流量整形 支持
Fallback 支持 支持
Configuration Method 基于控制台,重启后失效 基于注解或配置文件,永久生效

想了解微服务保护框架具体如何使用,请参见官网样例或官方文档。

2.3 微服务分布式事务

在分布式系统中,如果一个业务需要多个服务合作完成,而且每一个服务都有事务,多个事务必须同时成功或失败,这样的事务就是分布式事务。其中的每个服务的事务就是一个分支事务。整个业务称为全局事务

除了微服务雪崩的问题外,微服务设计中还存在一个重难点:如何保证微服务数据 ACID 的性质(如何正确进行分布式事务)。

以一个商品订单服务为例:

如果最终库存服务失败,那么虽然订单服务可能可以识别到错误并且回滚,但是购物车服务与库存服务间没有关系,极有可能不会回滚,造成数据的不一致性。这就是没有保证分布式事务一致性。

那么我们应该如何保证微服务流程的一致性?

这个时候需要引入一个分布式事务的协调组件,让各个子事务(分支事务)感知到彼此的事务状态,根据总体的事务状态进行判断,协调全局事务的提交或回滚,这样就能保证事务状态和数据的一致性。

这个分布式事务的协调组件被称为:事务协调者(Transaction Coordinator,TC)

除了事务协调者,还需要有一个组件,用于定义单个全局事务的范围(从哪个子事务开始,到哪个子事务结束)。这个组件就被称为:事务管理器(Transaction Manager,TM)

有了 TC 和 TM,就能准确地定义一个全局事务;

为了进一步从业务逻辑中解耦,我们额外增添一个组件用于说明某个子事务的事务状态。它的作用是,向 TC 注册子事务,并且报告子事务的事务状态。这就被称为:资源管理器(Resource Manager,RM);