通常跟微服务相对的是单体应用,即将所有功能都打包成在一个独立单元的应用程序。从单体应用到微服务并不是一蹴而就的,这是一个逐渐演变的过程。

演变

几年前,的超时管理后台。

我们整理一下功能清单:

  • 网站
    • 用户注册、登录功能
    • 商品展示
    • 下单
  • 管理后台
    • 用户管理
    • 商品管理
    • 订单管理

总体架构图如下:

随后,增加了促销管理模块和移动端

这一阶段存在很多不合理的地方:

  • 网站和移动端应用有很多相同业务逻辑的重复代码
  • 数据有时候通过数据库共享,有时候通过接口调用传输。接口调用关系杂乱
  • 单个应用为了给其他应用提供接口,渐渐地越改越大,包含了很多本来就不属于它的逻辑。应用边界模糊,功能归属混乱。
  • 管理后台在一开始的设计中保障级别较低。加入数据分析和促销管理相关功能后出现性能瓶颈,影响了其他应用。
  • 数据库表结构被多个应用依赖,无法重构和优化。
  • 所有应用都在一个数据库上操作,数据库出现性能瓶颈。特别是数据分析跑起来的时候,数据库性能急剧下降。
  • 开发、测试、部署、维护愈发困难。即使只改动一个小功能,也需要整个应用一起发布。有时候发布会不小心带上了一些未经测试的代码,或者修改了一个功能后,另一个意想不到的地方出错了。为了减轻发布可能产生的问题的影响和线上业务停顿的影响,所有应用都要在凌晨三四点执行发布。发布后为了验证应用正常运行,还得盯到第二天白天的用户高峰期……
  • 团队出现推诿扯皮现象。关于一些公用的功能应该建设在哪个应用上的问题常常要争论很久,最后要么干脆各做各的,或者随便放个地方但是都不维护。

抽离公共组件

公共服务:

  • 用户服务
  • 商品服务
  • 促销服务
  • 订单服务
  • 数据分析服务

各个应用后台只需从这些服务获取所需的数据,从而删去了大量冗余的代码,就剩个轻薄的控制层和前端。这一阶段的架构如下:

这个阶段只是将服务分开了,数据库依然是共用的,所以一些烟囱式系统的缺点仍然存在:

  1. 数据库成为性能瓶颈,并且有单点故障的风险。
  2. 数据管理趋向混乱。即使一开始有良好的模块化设计,随着时间推移,总会有一个服务直接从数据库取另一个服务的数据的现象。
  3. 数据库表结构可能被多个服务依赖,牵一发而动全身,很难调整。

因此,可以把数据库也拆了——

完全拆分后各个服务可以采用异构的技术。比如数据分析服务可以使用数据仓库作为持久化层,以便于高效地做一些统计计算;商品服务和促销服务访问频率比较大,因此加入了缓存机制等。

故障定位

微服务架构整个应用分散成多个服务,定位故障点非常困难。小明一个台机器一台机器地查看日志,一个服务一个服务地手工调用。经过十几分钟的查找,小明终于定位到故障点:促销服务由于接收的请求量太大而停止响应了。其他服务都直接或间接地会调用促销服务,于是也跟着宕机了。在微服务架构中,一个服务故障可能会产生雪崩效用,导致整个系统故障

  • 微服务架构整个应用分散成多个服务,定位故障点非常困难。
  • 稳定性下降。服务数量变多导致其中一个服务出现故障的概率增大,并且一个服务故障可能导致整个系统挂掉。事实上,在大访问量的生产场景下,故障总是会出现的。
  • 服务数量非常多,部署、管理的工作量很大。
  • 开发方面:如何保证各个服务在持续开发的情况下仍然保持协同合作。
  • 测试方面:服务拆分后,几乎所有功能都会涉及多个服务。原本单个程序的测试变为服务间调用的测试。测试变得更加复杂。

  • 监控:

  • 链路跟踪:记录每个用户请求时,微服务内部产生了多少服务调用,及其调用关系。

  • 网关 - 权限控制,服务治理
    • 拆分成微服务后,出现大量的服务,大量的接口,使得整个调用关系乱糟糟的。为了应对这些情况,微服务的调用需要一个把关的东西,也就是网关。在调用者和被调用者中间加一层网关,每次调用时进行权限校验。另外,网关也可以作为一个提供服务接口文档的平台
    • 粒度问题
      • 最粗粒度的方案是整个微服务一个网关,微服务外部通过网关访问微服务,微服务内部则直接调用
      • 最细粒度则是所有调用,不管是微服务内部调用或者来自外部的调用,都必须通过网关。折中的方案是按照业务领域将微服务分成几个区,区内直接调用,区间通过网关调用。
      • 粗粒度网关调用
  • 动态扩容:
    • 最粗暴的(也是最常用的)故障处理策略就是冗余。一般来说,一个服务都会部署多个实例,这样一来能够分担压力提高性能,二来即使一个实例挂了其他实例还能响应。
    • 首先,需要部署一个服务发现服务,它提供所有已注册服务的地址信息的服务。然后各个应用服务在启动时自动将自己注册到服务发现服务上。并且应用服务启动后会实时(定期)从服务发现服务同步各个应用服务的地址列表到本地。服务发现服务也会定期检查应用服务的健康状态,去掉不健康的实例地址。 这样新增实例时只需要部署新实例,实例下线时直接关停服务即可,服务发现会自动检查服务实例的增减。
    • 也可以用于负载均衡
  • 熔断、降级、限流:
    • 熔断
    • 降级:当下游服务停止工作后,如果该服务并非核心业务,则上游服务应该降级,以保证核心业务不中断。比如网上超市下单界面有一个推荐商品凑单的功能,当推荐模块挂了后,下单功能不能一起挂掉,只需要暂时关闭推荐功能即可。
    • 限流:一个服务挂掉后,上游服务或者用户一般会习惯性地重试访问。这导致一旦服务恢复正常,很可能因为瞬间网络流量过大又立刻挂掉,在棺材里重复着仰卧起坐。因此服务需要能够自我保护——限流。
      • 限流策略有很多
        • 最简单的比如当单位时间内请求数过多时,丢弃多余的请求
        • 另外,也可以考虑分区限流。仅拒绝来自产生大量请求的服务的请求。
          • 例如商品服务和订单服务都需要访问促销服务,商品服务由于代码问题发起了大量请求,促销服务则只限制来自商品服务的请求,来自订单服务的请求则正常响应。

基础

微服务的拆分

  • 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高。
  • 低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖,或者依赖接口的稳定性要强。

  • 拆分方式

    • 纵向拆分:按照项目的功能模块来拆分
    • 横向拆分:各个功能模块之间有没有公共的业务部分,如果有将其抽取出来作为通用服务
  • 工程结构

    • 完全解耦:每一个微服务都创建为一个独立的工程,甚至可以使用不同的开发语言来开发,项目完全解耦。
    • Maven聚合:整个项目为一个Project,然后每个微服务是其中的一个Module(服务之间会耦合,编译时间长)
  • 拆分方式
    • 也就是这个服务相关得springboot工程搬过来,包括application

服务调用

购物车业务中需要查询商品业务得信息,但商品信息查询的逻辑全部迁移到了item-service服务,导致我们无法查询。

把原本本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。

PRC例子

我们可以使用Spring提供的RestTemplate进行调用

ResTemplate

在config下面定义RemoteCallConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.hmall.cart.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RemoteCallConfig {

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

随后,编写代码进行查询(实质就是发送Http请求)

发送RestTemplate

  • 包含四部分信息:
    • ① 请求方式
      • getForObject:发送Get请求并返回指定类型对象
      • PostForObject:发送Post请求并返回指定类型对象
      • put:发送PUT请求
      • delete:发送Delete请求
      • exchange:发送任意类型请求,返回ResponseEntity
    • ② 请求路径
    • ③ 请求参数
    • ④ 返回值类型

在这个过程中,item-service提供了查询接口,cart-service利用Http请求调用该接口。因此item-service可以称为服务的提供者,而cart-service则称为服务的消费者或服务调用者。

服务注册与发现:注册中心

上一章节介绍的“RestTemplate”进行服务调用的方法存在一些问题。

试想一下,假如商品微服务被调用较多,为了应对更高的并发,我们进行了多实例部署,如图

多实例部署例子

此时,每个item-service的实例其IP或端口不同,问题来了:

  • item-service这么多实例,cart-service如何知道每一个实例的地址?
  • http请求要写url地址,cart-service服务到底该调用哪个实例呢?
  • 如果在运行过程中,某一个item-service实例宕机,cart-service依然在调用该怎么办?
  • 如果并发太高,item-service临时多部署了N台实例,cart-service如何知道新实例的地址?

注册中心Naco

注册中心、服务提供者、服务消费者三者间关系如下:
注册中心服务提供者服务消费者关系图

  • 流程如下:

    • 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心
    • 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)
    • 调用者自己对实例列表负载均衡,挑选一个实例
    • 调用者向该实例发起远程调用
  • 当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?

    • 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)
    • 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除
    • 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例列表
    • 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表

可以基于Docker来部署Nacos的注册中心

服务注册:服务提供者注册服务

接下来,我们把item-service注册到Nacos,步骤如下:

  • 引入依赖
    • item-servicepom.xml中添加依赖:
1
2
3
4
5
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
  • 配置Nacos地址
    • item-serviceapplication.yml中添加nacos地址配置:
1
2
3
4
5
6
spring:
application:
name: item-service # 服务名称
cloud:
nacos:
server-addr: 192.168.150.101:8848 # nacos地址
  • 重启
    • 为了测试一个服务多个实例的情况,我们再配置一个item-service的部署实例:
    • 然后配置启动项,注意重命名并且配置新的端口,避免冲突
    • 重启item-service的两个实例:
    • 访问nacos控制台,可以发现服务注册成功(一行数据,其中实例数=2)

复制配置项

修改配置项(特别是端口)

服务发现:服务消费者发现服务

注:任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者

步骤如下:

  • 引入依赖

    • 服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。
      1
      2
      3
      4
      5
      <!--nacos 服务注册发现-->
      <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
      </dependency>
  • 配置Nacos地址

    • cart-serviceapplication.yml中添加nacos地址配置:
1
2
3
4
spring:
cloud:
nacos:
server-addr: 192.168.150.101:8848
  • 发现并调用服务
    • 服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用
    • 我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用

注入DiscoveryClient

发现服务实例并调用

OpenFeign

OpenFeign就利用SpringMVC的相关注解来声明远程调用得4个参数,然后基于动态代理帮我们生成远程调用的代码,而无需我们手动再编写

快速入门

cart-service服务的pom.xml中引入OpenFeign的依赖和loadBalancer依赖:

1
2
3
4
5
6
7
8
9
10
<!--openFeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--负载均衡器-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

接下来,我们在cart-serviceCartApplication启动类上添加注解 @EnableFeignClients,启动OpenFeign功能:

启动OpenFeign

编写OpenFeign客户端:在cart-service中,定义一个新的接口,编写Feign客户端,其中代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.hmall.cart.client;

import com.hmall.cart.domain.dto.ItemDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@FeignClient("item-service")
public interface ItemClient {

@GetMapping("/items")
List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}

这里只需要声明接口,无需实现方法。接口中的几个关键信息

  • @FeignClient("item-service") :声明服务名称
  • @GetMapping :声明请求方式
  • @GetMapping("/items") :声明请求路径
  • @RequestParam("ids") Collection<Long> ids :声明请求参数
  • List<ItemDTO> :返回值类型

我们只需要直接调用这个方法,即可实现远程调用了。

调用OpenFeign

网关技术

微服务拆分后,每个微服务都独立部署,这就存在一些问题:

  • 每个微服务都需要编写登录校验、用户信息获取的功能吗?
  • 当微服务之间调用时,该如何传递用户信息?

网关路由

网关就是络的口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的 路由和转发 以及 数据安全的校验。

网关的通俗表述

网关图形化表述

SpringCloudGateway为例

快速入门

创建网关

首先,我们要在hmall下创建一个新的module,命名为hm-gateway,作为网关微服务

hm-gateway模块的pom.xml文件中引入依赖:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>hmall</artifactId>
<groupId>com.heima</groupId>
<version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>hm-gateway</artifactId>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!--common-->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-common</artifactId>
<version>1.0.0</version>
</dependency>
<!--网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos discovery-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

编写启动类代码,如下:

1
2
3
4
5
6
7
8
9
10
11
package com.hmall.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

配置路由

接下来,在hm-gateway模块的resources目录新建一个application.yaml文件,内容如下:

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
server:
port: 8080
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 192.168.150.101:8848
gateway:
routes:
- id: item # 路由规则id,自定义,唯一
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则
- id: cart
uri: lb://cart-service
predicates:
- Path=/carts/**
- id: user
uri: lb://user-service
predicates:
- Path=/users/**,/addresses/**
- id: trade
uri: lb://trade-service
predicates:
- Path=/orders/**
- id: pay
uri: lb://pay-service
predicates:
- Path=/pay-orders/**

路由过滤

路由规则的定义语法如下:

1
2
3
4
5
6
7
8
spring:
cloud:
gateway:
routes:
- id: item
uri: lb://item-service
predicates:
- Path=/items/**,/search/**

网关登录校验

微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做登录校验,这显然不可取。

我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,(1)每个微服务都需要知道JWT的秘钥,不安全(2)每个微服务重复编写登录校验代码、权限校验代码,麻烦

既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:

  • 只需要在网关和用户服务保存秘钥
  • 只需要在网关开发登录校验功能

网关登录校验流程

网关过滤器

网关过滤器

最终请求转发是有一个名为NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter之前,这就符合我们的需求了

网关过滤器链中的过滤器有两种:

  • GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.
  • GlobalFilter:全局过滤器,作用范围是所有路由,不可配置。

两种过滤器的方法

1
2
3
4
5
6
7
/**
* 处理请求并将其传递给下一个过滤器
* @param exchange 当前请求的上下文,其中包含request、response等各种数据
* @param chain 过滤器链,基于它向下传递请求
* @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

Gateway内置的GatewayFilter过滤器使用起来非常简单,无需编码只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route.

内置过滤器的使用

例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。

使用的使用只需要在application.yaml中这样配置:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
routes:
- id: test_route
uri: lb://test-service
predicates:
-Path=/test/**
filters:
- AddRequestHeader=key, value # 逗号之前是请求头的key,逗号之后是value

如果想要让过滤器作用于所有的路由,则可以这样配置:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
default-filters: # default-filters下的过滤器可以作用于所有路由
- AddRequestHeader=key, value
routes:
- id: test_route
uri: lb://test-service
predicates:
-Path=/test/**

自定义过滤器的定义

自定义GatewayFilter

自定义GatewayFilter不是直接实现GatewayFilter,而是实现AbstractGatewayFilterFactory

【普通使用】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
@Override
public GatewayFilter apply(Object config) {
return new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求
ServerHttpRequest request = exchange.getRequest();
// 编写过滤器逻辑
System.out.println("过滤器执行了");
// 放行
return chain.filter(exchange);
}
};
}
}

注意:该类的名称一定要以GatewayFilterFactory为后缀!

然后在yaml配置中这样使用:

1
2
3
4
5
spring:
cloud:
gateway:
default-filters:
- PrintAny # 此处直接以自定义的GatewayFilterFactory类名称前缀类声明过滤器

【动态参数】

动态配置参数:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46

@Component
public class PrintAnyGatewayFilterFactory // 父类泛型是内部类的Config类型
extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {

@Override
public GatewayFilter apply(Config config) {
// OrderedGatewayFilter是GatewayFilter的子类,包含两个参数:
// - GatewayFilter:过滤器
// - int order值:值越小,过滤器执行优先级越高
return new OrderedGatewayFilter(new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取config值
String a = config.getA();
String b = config.getB();
String c = config.getC();
// 编写过滤器逻辑
System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("c = " + c);
// 放行
return chain.filter(exchange);
}
}, 100);
}

// 自定义配置属性,成员变量名称很重要,下面会用到
@Data
static class Config{
private String a;
private String b;
private String c;
}
// 将变量名称依次返回,顺序很重要,将来读取参数时需要按顺序获取
@Override
public List<String> shortcutFieldOrder() {
return List.of("a", "b", "c");
}
// 返回当前配置类的类型,也就是内部的Config
@Override
public Class<Config> getConfigClass() {
return Config.class;
}

}

然后在yaml文件中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 方式1,需要和参数保持顺序一致
spring:
cloud:
gateway:
default-filters:
- PrintAny=1,2,3 # 注意,这里多个参数以","隔开,将来会按照shortcutFieldOrder()方法返回的参数顺序依次复制

# 方式2:手动指定参数
spring:
cloud:
gateway:
default-filters:
- name: PrintAny
args: # 手动指定参数名,无需按照参数顺序
a: 1
b: 2
c: 3

网关自定义过滤器动态配置参数

自定义GlobalFilter

自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 编写过滤器逻辑
System.out.println("未登录,无法访问");
// 放行
// return chain.filter(exchange);

// 拦截
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(401);
return response.setComplete();
}

@Override
public int getOrder() {
// 过滤器执行顺序,值越小,优先级越高
return 0;
}
}

网关获取用户信息

由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。

网关获取用户信息的步骤

因此,我们需要:

  • 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
  • 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行

【保存用户到请求头】
保存用户到请求头

【定义拦截器】

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
package com.hmall.common.interceptor;

import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的用户信息
String userInfo = request.getHeader("user-info");
// 2.判断是否为空
if (StrUtil.isNotBlank(userInfo)) {
// 不为空,保存到ThreadLocal
UserContext.setUser(Long.valueOf(userInfo));
}
// 3.放行
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserContext.removeUser();
}
}

【配置登录拦截器】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.hmall.common.config;

import com.hmall.common.interceptors.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor());
}
}

【配置包扫描】

需要注意的是,这个配置类默认是不会生效的,因为它所在的包是com.hmall.common.config,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效。

基于SpringBoot的自动装配原理,我们要将其添加到resources目录下的META-INF/spring.factories文件中。内容如下:

1
2
3
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hmall.common.config.MyBatisConfig,\
com.hmall.common.config.MvcConfig

OpenFeign传递用户信息

适用于微服务之间传递用户信息。

上面讲的是正常前端发出请求,经过网关,传递给微服务A
这节讲的是 前端发出请求,经过网关,传递给微服务A,微服务A要调用微服务B。其中,A—B之间用户信息的传递

微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?

这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor

1
2
3
4
5
6
7
8
public interface RequestInterceptor {

/**
* Called for every request.
* Add data using methods on the supplied {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}

com.hmall.api.config.DefaultFeignConfig中添加一个Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 获取登录用户
Long userId = UserContext.getUser();
if(userId == null) {
// 如果为空则直接跳过
return;
}
// 如果不为空则放入请求头中,传递给下游微服务
template.header("user-info", userId.toString());
}
};
}

配置共享技术

Nacos不仅仅具备注册中心功能,也具备配置管理的功能:
Nacos配置中心

微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新

配置共享

读取Nacos配置是SpringCloud上下文(ApplicationContext)初始化时处理的,发生在项目的引导阶段。然后才会初始化SpringBoot上下文,去读取application.yaml

也就是说引导阶段,application.yaml文件尚未读取,根本不知道nacos 地址,该如何去加载nacos中的配置文件呢?

SpringCloud在初始化上下文的时候会先读取一个名为bootstrap.yaml(或者bootstrap.properties)的文件,如果我们将nacos地址配置到bootstrap.yaml中,那么在项目引导阶段就可以读取nacos中的配置了。
配置拉取流程

配置热更新

以“购物车商品数量上限更新”为例子——

在Nacos配置内容如下:

1
2
3
hm:
cart:
maxAmount: 1 # 购物车商品数量上限

代码添加属性读取类:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.hmall.cart.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
// 标识我们读取的配置项的前缀是`hm.cart`
public class CartProperties {
private Integer maxAmount;
}

业务中加载属性读取类

业务中加载属性读取类

MQ消息队列

概念

同步调用

同步调用业务举例

  • 扩展性差
  • 业务性能:因为是类似于串行调用,所以需要一直等
  • 级联失败:
    • 由于我们是基于OpenFeign调用交易服务、通知服务。当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。这其实就是同步调用的级联失败问题。
    • 但是,我们假设用户余额充足,扣款已经成功,此时我们应该确保支付流水单更新为已支付,确保交易成功。毕竟收到手里的钱没道理再退回去吧。不能因为短信通知、更新订单状态失败而回滚整个事务

异步调用

异步调用业务举例

不管后期增加了多少消息订阅者,作为支付服务来讲,执行问扣减余额、更新支付流水状态后,发送消息即可。业务耗时仅仅是这三部分业务耗时,仅仅100ms,大大提高了业务性能。

RabbitMQ与基础

RabbitMQ架构图

SpringAMQP与快速入门

基础模型

Spring的官方刚好基于RabbitMQ提供了这样一套消息收发的模板工具:SpringAMQP

SpringAMQP提供了三个功能:

  • 自动声明队列、交换机及其绑定关系
  • 基于注解的监听器模式,异步接收消息
  • 封装了RabbitTemplate工具,用于发送消息

首先,为项目工程安装依赖。

【消息发送】

publisher服务的application.yml中添加配置:

1
2
3
4
5
6
7
spring:
rabbitmq:
host: 192.168.150.101 # 你的虚拟机IP
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码

然后在publisher服务中编写测试类SpringAmqpTest,并利用RabbitTemplate实现消息发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.itheima.publisher.amqp;

import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class SpringAmqpTest {

@Autowired
private RabbitTemplate rabbitTemplate;

@Test
public void testSimpleQueue() {
// 队列名称
String queueName = "simple.queue";
// 消息
String message = "hello, spring amqp!";
// 发送消息
rabbitTemplate.convertAndSend(queueName, message);
}
}

【消息接收】

consumer服务的application.yml中添加配置:

1
2
3
4
5
6
7
spring:
rabbitmq:
host: 192.168.150.101 # 你的虚拟机IP
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码

然后在consumer服务的com.itheima.consumer.listener包中新建一个类SpringRabbitListener,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.itheima.consumer.listener;

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
public class SpringRabbitListener {
// 利用RabbitListener来声明要监听的队列信息
// 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
// 可以看到方法体中接收的就是消息体的内容
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
System.out.println("spring 消费者接收到消息:【" + msg + "】");
}
}

WorkQueues模型

让多个消费者绑定到一个队列,共同消费队列中的消息。
类似于一个生产者、多个消费者。食物只可被一个消费者消费

可用于负载均衡。

【能者多劳】

默认情况下,消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。

修改consumer服务的application.yml文件,添加配置,实现能者多劳,可以有效避免消息积压问题。:

1
2
3
4
5
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
  • 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理
  • 通过设置prefetch来控制消费者预取的消息数量

Fanout(扇出)交换机——广播

一个发送者,多个消费者(已经绑定队列),交换机把消息发送给绑定过的所有队列

Fanout交换机设定

【消息发送】

需要指明交换机名称

1
2
3
4
5
6
7
8
@Test
public void testFanoutExchange() {
// 交换机名称
String exchangeName = "hmall.fanout";
// 消息
String message = "hello, everyone!";
rabbitTemplate.convertAndSend(exchangeName, "", message);
}

【消息接收】
在consumer服务的SpringRabbitListener中添加两个方法,作为消费者,基本一致

1
2
3
4
5
6
7
8
9
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}

@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}

Direct交换机

根据路由消息的关键字(分类字/Key)来判定发送给哪个(些)通道

消息接收者绑定并配置其key

【消息发送】

convertAndSend的第二个参数就是这个消息的key

1
2
3
4
5
6
7
8
9
@Test
public void testSendDirectExchange() {
// 交换机名称
String exchangeName = "hmall.direct";
// 消息
String message = "红色警报!日本乱排核废水,导致海洋生物变异,惊现哥斯拉!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "red", message);
}

由于使用的red这个key,所以两个消费者都收到了消息

Topic交换机

效果与Direct交换器完全类似,另外Topic类型Exchange可以让队列在绑定BindingKey 的时候使用通配符

  • 规则:
    • 关键词:
      • BindingKey 一般都是有一个或多个单词组成
      • 多个单词之间以.分割,例如: item.insert
    • 通配符规则:
      • #:匹配一个或多个词,0个词似乎也算(item.#:能够匹配item.spu.insert 或者 item.spu
      • *:匹配不多不少恰好1个词(item.*:只能匹配item.spu

【消息发送】

1
2
3
4
5
6
7
8
9
10
11
12
/**
* topicExchange
*/
@Test
public void testSendTopicExchange() {
// 交换机名称
String exchangeName = "hmall.topic";
// 消息
String message = "喜报!孙悟空大战哥斯拉,胜!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}

声明队列和交换机

队列和交换机是程序员定义的,项目上线又要交给运维去创建。为避免交接错误,推荐的做法是由程序启动时检查队列和交换机是否存在,如果不存在自动创建。

  • SpringAMQP提供了一个Queue类,用来创建队列

  • SpringAMQP还提供了一个Exchange接口,来表示所有不同类型的交换机
    SpringAMQP提供的Exchange

  • 而在绑定队列和交换机时,则需要使用BindingBuilder来创建Binding对象

【实战】基于注解的创建和声明

Direct模式——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red", "blue"}
))
public void listenDirectQueue1(String msg){
System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}

@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red", "yellow"}
))
public void listenDirectQueue2(String msg){
System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}

Topic模式——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue1"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
key = "china.#"
))
public void listenTopicQueue1(String msg){
System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
}

@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue2"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
key = "#.news"
))
public void listenTopicQueue2(String msg){
System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
}

消息转换器

Spring的消息发送代码接收的消息体是一个Object,我们希望将其转换为JSON

因此可以使用JSON方式来做序列化和反序列化。

【依赖引入】

publisherconsumer两个服务中都引入依赖:

1
2
3
4
5
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.10</version>
</dependency>

注意,如果项目中引入了spring-boot-starter-web依赖,则无需再次引入Jackson依赖。

【启动类改造】

配置消息转换器,在publisherconsumer两个服务的启动类中添加一个Bean即可:

1
2
3
4
5
6
7
8
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jackson2JsonMessageConverter.setCreateMessageIds(true);
return jackson2JsonMessageConverter;
}

【消费者改造】

我们在consumer服务中定义一个新的消费者,publisher是用Map发送,那么消费者也一定要用Map接收,格式如下:

1
2
3
4
@RabbitListener(queues = "object.queue")
public void listenSimpleQueueMessage(Map<String, Object> msg) throws InterruptedException {
System.out.println("消费者接收到object.queue消息:【" + msg + "】");
}

MQ进阶

TBD


[1] https://www.cnblogs.com/skabyy/p/11396571.html