由于每个微服务都有不同的地址或端口,入口不同,在与前端联调的时候发现了一些问题:
-
请求不同数据时要访问不同的入口,需要维护多个入口地址,麻烦
-
前端无法调用nacos,无法实时更新服务列表
单体架构时只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,这就存在一些问题:
-
每个微服务都需要编写登录校验、用户信息获取的功能吗?
-
当微服务之间调用时,该如何传递用户信息?
1.网关路由
1.1.认识网关
什么是网关?
顾明思议,网关就是网 络的关 口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由 和 转发 以及数据安全的校验。
现在,微服务网关就起到同样的作用。前端请求不能直接访问微服务,而是要请求网关:
-
网关可以做安全控制,也就是登录身份校验,校验通过才放行
-
通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去

在SpringCloud当中,提供了两种网关实现方案:
-
Netflix Zuul:早期实现,目前已经淘汰
-
SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
课堂中我们以SpringCloudGateway为例来讲解,官方网站:
https://spring.io/projects/spring-cloud-gateway#learn
1.2.快速入门
接下来,我们先看下如何利用网关实现请求路由。由于网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能。大概步骤如下:
-
创建网关微服务
-
引入SpringCloudGateway、NacosDiscovery依赖
-
编写启动类
-
配置网关路由
1.创建网关模块,并引入gateway和nacos依赖,编写启动类
<?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>
2.编写网关配置,路由id(唯一),路由目标地址,匹配路径
这里每个路由id都是唯一的;uri: lb://item-service,lb代表这里采用负载均衡(默认轮询),item-service代表服务实例名称,这里会根据服务名称从注册中心获取所有对应服务实例然后采用负载均衡挑选一个实例;predicates:代表匹配后端接口的路径,如果有多个Controller路径,用逗号隔开。

- 前端请求:
http://localhost:8080/items/1001
- 匹配路由:item-service
- 转发 URL:
http://item-service实例/items/1001
1.3.路由属性
我们在配置项中配置的每一个路由,其实都是一个RouteDefinition类,有4个属性:id、uri、predicates、filters。
四个属性含义如下:
-
id
:路由的唯一标示 -
predicates
:路由断言,其实就是匹配条件 -
filters
:路由过滤条件,后面讲 -
uri
:路由目标地址,lb://
代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。
过滤器:
这里适用AddRequestHeader为例,为当前请求添加一个请求头:
在想要添加过滤器的路由下方添加filter配置:
意思是:向请求头添加一个truth,内容是xxxx
然后在item的接口处使用@RequestHeader获取这个请求头字段:
测试:
控制台输出

如果想要添加公共的过滤器,使用跟routes同级的default-filters,在其下添加过滤器配置即可。
predicates
路由断言:SpringCloudGateway中支持的断言类型有很多:
名称 | 说明 | 示例 |
---|---|---|
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 | 权重处理 |
2.网关登录校验
2.1.鉴权思路分析
我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:
-
每个微服务都需要知道JWT的秘钥,不安全
-
每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
-
只需要在网关和用户服务保存秘钥
-
只需要在网关开发登录校验功能
此时,登录校验的流程如图:
不过,这里存在几个问题:
-
网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
-
网关校验JWT之后,如何将用户信息传递给微服务?
-
微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?
2.2.网关过滤器
登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway
内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway
内部工作的基本原理。

如图所示:
-
客户端请求进入网关后由
HandlerMapping
对请求做判断,找到与当前请求匹配的路由规则(Route
),然后将请求交给WebHandler
去处理。 -
WebHandler
则会加载当前路由下需要执行的过滤器链(Filter chain
),然后按照顺序逐一执行过滤器(后面称为**Filter
**)。 -
图中
Filter
被虚线分为左右两部分,是因为Filter
内部的逻辑分为pre
和post
两部分,分别会在请求路由到微服务之前 和之后被执行。 -
只有所有
Filter
的pre
逻辑都依次顺序执行通过后,请求才会被路由到微服务。 -
微服务返回结果后,再倒序执行
Filter
的post
逻辑。 -
最终把响应结果返回。
如图中所示,最终请求转发是有一个名为NettyRoutingFilter
的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter
之前,这就符合我们的需求了!

那么,该如何实现一个网关过滤器呢?
网关过滤器链中的过滤器有两种:
-
GatewayFilter
:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route
. -
GlobalFilter
:全局过滤器,作用范围是所有路由,不可配置。
2.3 自定义GlobalFilter
创建自己的GlobaFilter很简单,直接实现GlobalFilter即可,而且也无法设置动态参数:
java
@Component
public class MyGlobalFilter implements GlobalFilter, Ordered {
//继承Ordered是为了对filter进行排序,ordered越小filter越先执行
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//todo 模拟登录校验逻辑
ServerHttpRequest request =exchange.getRequest();
HttpHeaders headers = request.getHeaders();
System.out.println("headers = " + headers);
//放行
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
2.4.登录校验
利用自定义GlobalFilter
来完成登录校验。
2.4.1.JWT工具
登录校验需要用到JWT,而且JWT的加密需要秘钥和加密工具。这些在hm-service
中已经有了,我们直接拷贝过来:
具体作用如下:
-
AuthProperties
:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问 -
JwtProperties
:定义与JWT工具有关的属性,比如秘钥文件位置 -
SecurityConfig
:工具的自动装配 -
JwtTool
:JWT工具,其中包含了校验和解析token
的功能 -
hmall.jks
:秘钥文件
其中AuthProperties
和JwtProperties
所需的属性要在application.yaml
中配置:
XML
hm:
jwt:
location: classpath:hmall.jks # 秘钥地址
alias: hmall # 秘钥别名
password: hmall123 # 秘钥文件密码
tokenTTL: 30m # 登录有效期
auth:
excludePaths: # 无需登录校验的路径
- /search/**
- /users/login
- /items/**
2.4.2.登录校验过滤器
接下来,我们定义一个登录校验的过滤器:
和自定义GlobalFilter步骤一致,先继承GlobalFilter和Ordered,前者用来写执行服务前需要完成的功能,后者规定当前Filter位于网关过滤器串的位置,数字越小位置越靠前。
1.获取Request对象
2.判断请求路径是否需要进行登录校验。这里通过在yml配置项中定义不需要进行登录校验的路径,但并非使用使用正则表达式,而是使用Ant模式来定义路径:
这种模式需要使用Spring提供的 AntPathMatcher 类来完成请求路径和无需登录校验路径的匹配功能。
3.获取token
4.校验并解析token。当解析token失败正常应该结束请求并抛出异常,但是抛出异常前端响应码为500,这和登录失败响应码401不一致,所以这里我们要实现结束请求并设置响应码401;通过exchange获取Response对象,设置响应码然后结束请求。
TODO:5.传递用户信息
6.放行
java
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final AuthProperties authProperties;
private final JwtTool jwtTool;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();//spring 提供的路径匹配器
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1.获取Request对象
ServerHttpRequest request = exchange.getRequest();
//2.判断请求路径是否需要做登录校验
if(isExclude(request.getPath().toString())){
// 放行
return chain.filter(exchange);
}
//3.获取token
List<String> headers = request.getHeaders().get("authorization");
String token = null;
Long userId = null;
if (headers != null && !headers.isEmpty()){
token = headers.get(0);
}
//4.校验并解析token
try {
userId = jwtTool.parseToken(token);
}
catch (UnauthorizedException e){
//如果校验失败,我们希望终止请求,但是直接抛出异常前端接受状态码为500,我们希望体现出是因为登录校验失败而终止请求,
//所以这里应该是拦截,设置响应状态码为401
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//TODO 5.传递用户信息
System.out.println("userId"+userId);
//6.放行
return chain.filter(exchange);
}
private boolean isExclude(String path) {
for (String excludePath : authProperties.getExcludePaths()) {
if (antPathMatcher.match(excludePath,path)){
return true;
}
}
return false;
}
@Override
public int getOrder() {
return 0;
}
}
此时,如果没有包含token的请求就会返回401:

2.5 网关传递用户信息
现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?
由于网关发送请求到微服务依然采用的是Http
请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。
据图流程图如下:
因此,接下来我们要做的事情有:
-
改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
-
编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行
在网关的登录校验过滤器中,把获取到的用户写入请求头
需求:修改gateway模块中的登录校验拦截器,在校验成功后保存用户到下游请求的请求头中。
提示:要修改转发到微服务的请求,需要用到ServerWebExchange类提供的API,示例如下:
1.在网关过滤器执行登录校验完成后,将用获取到的用户信息写进请求头中。

此时微服务接收到的请求中,请求头就会包含"user-info"这个字段的信息。测试可知:
2.在Common包下编写拦截器获取用户信息。之所以放在Common包下,因为如果正常写应该是每个微服务都要写一个拦截器,很麻烦,但是每个微服务都引用了Common包,所以可以直接在Common包下写。
继承HandlerInterceptor,重写两个方法。UserContext是自定义使用了ThreadLocal的包装类。
这是基于SpringMvc实现的拦截器,所以要添加进拦截器组中。
3.配置拦截器生效。其他微服务启动并不会扫描到Common包下的MvcConfig,用户信息拦截器也自然不会生效,这里运用了SpringBoot自动装配原理:在不同包下的配置类,要想被扫到,就必须在resource目录下的META-INF下定义一个文件去记录这些配置类:spring.factories。
4.网关服务中并没有SpringMvc相关依赖,所以要在Mvc配置类上加限制条件:

此时在网关服务中,这个配置项就不会生效。
2.6.OpenFeign传递用户
为使用OpenFeign的请求加上包含用户信息的请求头。
加上请求头之后,所有请求会先被Common包的拦截器拦截,并获取用户信息,使用ThreadLocal存储。
前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。
但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:
下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!

由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?
这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor
我们只需要实现这个接口,然后实现apply方法,利用RequestTemplate
类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。
java
public interface RequestInterceptor {
/**
* Called for every request.
* Add data using methods on the supplied {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}

由于这些请求都是OpenFeign发出的,所以拦截器就放在api包下,借助RequestTemplate
将用户信息存入请求头中:

这里之所以能使用UserContext获取userId,是因为:
- 用户请求到达
order-service
,由线程 A 处理。 - 线程 A 中通过 Feign 调用
cart-service
时,RequestInterceptor
的apply
方法仍在线程 A 中执行。 - 因此,
UserContext.getUser()
(本质是从 ThreadLocal 获取)能拿到线程 A 中存储的 userId。
同时需要注意,其他微服务启动类上的启动OpenFeign注解,要标注DefaultFeignConfig,这样才能扫描到。

2.7 总结

3.配置管理
到目前为止我们已经解决了微服务相关的几个问题:
-
微服务远程调用
-
微服务注册、发现
-
微服务请求路由、负载均衡
-
微服务登录用户信息传递
不过,现在依然还有几个问题需要解决:
-
网关路由在配置文件中写死了,如果变更必须重启微服务
-
某些业务配置在配置文件中写死了,每次修改都要重启服务
-
每个微服务都有很多重复的配置,维护成本高
这些问题都可以通过统一的配置管理器服务 解决。而Nacos不仅仅具备注册中心功能,也具备配置管理的功能:
微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。
网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置。
3.1.配置共享
我们可以把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。分为两步:
-
在Nacos中添加共享配置
-
微服务拉取配置
3.1.1.添加共享配置
以cart-service为例,我们看看有哪些配置是重复的,可以抽取的:
首先是jdbc相关配置:然后是日志配置:
然后是swagger以及OpenFeign的配置:
我们在nacos控制台分别添加这些配置。
首先是jdbc相关配置,在配置管理
->配置列表
中点击+
新建一个配置:在弹出的表单中填写信息:
其中详细的配置如下:
java
spring:
datasource:
url: jdbc:mysql://${hm.db.host:192.168.150.101}:${hm.db.port:3306}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${hm.db.un:root}
password: ${hm.db.pw:123}
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
global-config:
db-config:
update-strategy: not_null
id-type: auto
注意这里的jdbc的相关参数并没有写死,例如:
-
数据库ip
:通过${hm.db.host:192.168.150.101}
配置了默认值为192.168.150.101
,同时允许通过${hm.db.host}
来覆盖默认值 -
数据库端口
:通过${hm.db.port:3306}
配置了默认值为3306
,同时允许通过${hm.db.port}
来覆盖默认值 -
数据库database
:可以通过${hm.db.database}
来设定,无默认值
然后是统一的日志配置,命名为shared-log.``yaml
,配置内容如下:
java
logging:
level:
com.hmall: debug
pattern:
dateformat: HH:mm:ss:SSS
file:
path: "logs/${spring.application.name}"
然后是统一的swagger配置,命名为shared-swagger.yaml
,配置内容如下:
java
knife4j:
enable: true
openapi:
title: ${hm.swagger.title:黑马商城接口文档}
description: ${hm.swagger.description:黑马商城接口文档}
email: ${hm.swagger.email:zhanghuyi@itcast.cn}
concat: ${hm.swagger.concat:虎哥}
url: https://www.itcast.cn
version: v1.0.0
group:
default:
group-name: default
api-rule: package
api-rule-resources:
- ${hm.swagger.package}
注意,这里的swagger相关配置我们没有写死,例如:
-
title
:接口文档标题,我们用了${hm.swagger.title}
来代替,将来可以有用户手动指定 -
email
:联系人邮箱,我们用了${hm.swagger.email:``zhanghuyi@itcast.cn``}
,默认值是zhanghuyi@itcast.cn
,同时允许用户利用${hm.swagger.email}
来覆盖。
3.1.2.拉取共享配置
将拉取到的共享配置与本地的application.yaml
配置合并,完成项目上下文的初始化。
不过,需要注意的是,读取Nacos配置是SpringCloud上下文(ApplicationContext
)初始化时处理的,发生在项目的引导阶段。然后才会初始化SpringBoot上下文,去读取application.yaml
。
也就是说引导阶段,application.yaml
文件尚未读取,根本不知道nacos 地址,该如何去加载nacos中的配置文件呢?
SpringCloud在初始化上下文的时候会先读取一个名为**bootstrap.yaml
** (或者bootstrap.properties
)的文件,如果我们将nacos地址配置到bootstrap.yaml
中,那么在项目引导阶段就可以读取nacos中的配置了。
因此,微服务整合Nacos配置管理的步骤如下:
1)引入依赖:
在cart-service模块引入依赖:
java
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
2)新建bootstrap.yaml
在cart-service中的resources目录新建一个bootstrap.yaml文件:内容如下:
java
spring:
application:
name: cart-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.150.101:8848 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
3)修改application.yaml
由于一些配置挪到了bootstrap.yaml,因此application.yaml需要修改为:
java
server:
port: 8082
feign:
okhttp:
enabled: true # 开启OKHttp连接池支持
hm:
swagger:
title: 购物车服务接口文档
package: com.hmall.cart.controller
db:
database: hm-cart
重启服务,发现所有配置都生效了。
3.2.配置热更新
配置热更新: 当修改配置文件中的配置时,微服务无需重启即可使配置生效。
前提条件:
1.nacos中要有一个与微服务名有关的配置文件。
2.微服务中要以特定方式读取需要热更新的配置属性。推荐方法1。

**案例:**例如购物车业务,购物车数量有一个上限,默认是10,对应代码如下:
现在这里购物车是写死的固定值,我们应该将其配置在配置文件中,方便后期修改。
这就要用到Nacos的配置热更新能力了,分为两步:
-
在Nacos中添加配置
-
在微服务读取配置
3.2.1.添加配置到Nacos
首先,我们在nacos中添加一个配置文件,将购物车的上限数量添加到配置中:注意文件的dataId格式:
java
[服务名]-[spring.active.profile].[后缀名]
文件名称由三部分组成:
-
服务名
:我们是购物车服务,所以是cart-service
-
spring.active.profile
:就是spring boot中的spring.active.profile
,可以省略,则所有profile共享该配置 -
后缀名
:例如yaml
这里我们直接使用cart-service.yaml
这个名称,则不管是dev还是local环境都可以共享该配置。
配置内容如下:
java
hm:
cart:
maxAmount: 1 # 购物车商品数量上限
3.2.2.配置热更新
接着,我们在微服务中读取配置,实现配置热更新。
在cart-service
中新建一个属性读取类:
java
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")
public class CartProperties {
private Integer maxAmount;
}
接着,在业务中使用该属性加载类:测试,向购物车中添加多个商品:
我们在nacos控制台,将购物车上限配置为5:
无需重启,再次测试购物车功能:
加入成功。