学习目标
- 能够说出用什么实现的网关以及实现了哪些功能
- 能够创建网关工程实现路由功能
- 能够使用网关内置过滤器StripPrefix
- 能够定义全局过滤器并测试通过
- 能够实现全局身份校验过滤器
- 能够在微服务实现用户身份拦截器
- 能够说出网关鉴权具体的实现步骤
- 能够实现商城项目前后端联调
- 能够将微服务配置文件在Nacos统一管理
- 能够说出微服务配置文件的加载顺序
- 能够将微服务配置文件抽取公共配置
- 能够说出Nacos配置热更新方案
1 网关路由
1.1.认识网关
1.1.1 问题分析
目前为止我们已将黑马商城单体项目重构为微服务架构,今天的目标是前后端联调,下边思考几个问题:
1.1.1.1 前端面对多个后端入口
项目采用单体架构时前端通过nginx负载均衡访问后端服务,如下图:
同一个服务部署多份仅端口不同,且部署在不同地域(北上广深)的方式,一般称:水平复制、异地容灾
项目采用微服务架构时原来的黑马商城分成了购物车服务、交易服务、支付服务等多个服务,前端面对多个后端入口 ,如下图:
前端面对多个后端入口不方便前端开发,效率低下。仍然可以采用nginx去解决【注意不是最佳】,如下图:
在nginx中创建多个upstream ,例如:
http { upstream item_services { server 127.0.0.1:8081 weight=3; # 分配较高权重 server 127.0.0.1:8082 weight=2; # 分配中等权重 server 127.0.0.1:8083 weight=1; # 分配较低权重 } upstream carts_services { server 127.0.0.1:7081 weight=3; # 分配较高权重 server 127.0.0.1:7082 weight=2; # 分配中等权重 server 127.0.0.1:7083 weight=1; # 分配较低权重 } .... server { listen 80; # 监听 80 端口,也可以根据需要更改 server_name localhost; # 更改为你的域名或 IP 地址 location /items/ { # 这里可以根据需要调整路径前缀 proxy_pass http://item_services; } location /carts/ { # 这里可以根据需要调整路径前缀 proxy_pass http://carts_services; } .... }
1.1.1.2 用户身份校验放在哪?
单体架构时我们只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,这就存在一些问题:每个微服务都需要编写身份校验、用户信息获取的接口,非常麻烦。
用户身份校验最好放在一个统一的地方,放在上图中nginx的位置上上最合适,那nginx的作用如下:
1.请求路由,根据请求路径将请求转发到不同的应用服务器。
2.负载均衡,通过负载均衡算法将请求转发到不同的应用服务器。
3.用户身份鉴权,校验用户身份及用户的权限。
1.1.2 认识网关
Nginx目前扮演的角色就是:网关,什么是网关?
顾明思议,网关就是网络的关口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验。
我们现在要根据需求使用Java在网关实现路由转发和用户身份认证的功能:
- 根据请求Url路由到具体的微服务
- 校验用户的token,取出token中的用户信息。
- 从nacos中取出服务实例进行负载均衡。
但 在nginx中进行java编程是非常困难的,所以我们需要一个使用java开发的网关。
AI:java微服务网关
- Netflix Zuul:早期实现,目前已经淘汰
- Spring Cloud Gateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
课堂中我们以Spring Cloud Gateway为例来讲解,如下图:
前端请求网关根据请求路径路由到微服务,网关从nacos获取微服务实例地址将请求转发到具体的微服务实例上。生产环境中网关也是集群部署,在网关前边通过nginx进行负载均衡,如下图:
为什么这里需要Naocs?
答:网关怎么根据用户访问路径:http://baidu.com/carts/findAll,决定找到carts购物车服务呢?
此时就会用到服务注册与发现的知识点,而能帮我们实现这个功能的无疑nacos就可以做到。
1.1.3. 面试题
说说Spring Cloud五大组件?
你们项目网关用什么实现,实现了什么功能?
1.2. 实现网关路由
接下来,我们先看下如何利用网关实现请求路由。由于网关本身也是一个独立的微服务,因此也需要创建一个模块,大概步骤如下:
AI:Spring Cloud Gateway实现路由
- 创建网关微服务
- 引入Spring Cloud Gateway、NacosDiscovery依赖
- 编写启动类
- 配置网关路由
1.2.1. 创建网关工程
首先,我们要在hmall下创建一个新的module,命名为hm-gateway,作为网关微服务:
1.2.2 引入依赖
在hm-gateway模块的pom.xml文件中引入依赖:
<?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-parent</artifactId> <groupId>com.hmall</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.hmall</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 启动类
在hm-gateway模块的com.hmall.gateway包下新建一个启动类:
代码如下:
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); } }
1.2.4 配置路由
接下来,在hm-gateway模块的resources目录新建一个application.yaml文件,内容如下:
server: port: 8080 spring: application: name: gateway cloud: nacos: server-addr: 192.168.101.68:8848 gateway: routes: - id: item uri: lb://item-service predicates: - Path=/items/**
这里配置nacos地址,网关需要从nacos获取微服务的实例地址。
路由规则routes包括四个属性,定义语法如下:
id:路由的唯一标示,自定义即可,但要保证全局唯一predicates:路由断言,Predicates 是用于判断请求是否满足特定条件的组件。filters:路由过滤条件,稍后讲解。uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。
application.yaml文件完整内容如下:
server: port: 8080 spring: application: name: gateway cloud: nacos: server-addr: 192.168.101.68: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.5 测试
启动GatewayApplication,通过网关请求微服务, http://localhost:8080是网关的根路径,根据网关路由的配置请求具体的URL。
例如:要访问商品服务需要URL以/items开头,访问交易服务需要以/orders开头.
下边访问商品查询的接口地址:
http://localhost:8080/items/page?pageNo=1&pageSize=1
启动网关服务、商品服务,访问此链接。
1.2.6.路由断言(了解)
路由规则的定义语法如下:
spring: cloud: gateway: routes: - id: item uri: lb://item-service predicates: - Path=/items/**,/search/**
这里我们重点关注predicates,也就是路由断言。Spring Cloud Gateway中支持的断言类型有很多:
名称 |
说明 |
示例 |
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 |
权重处理 |
拿Header举例,Header是根据请求头的内容来控制网关访问的。
Header需要两个参数header和regexp(正则表达式),也可以理解为Key和Value,匹配请求携带信息。
修改网关的配置文件application.yml,如下:
Header中包括X-Request-Id才可正常访问,X-Request-Id 并不是HTTP标准的一部分,常用用于标识一次请求的ID。
重启网关,访问http://localhost:8080/items/page?pageNo=1&pageSize=1,不可以正常访问,因为没有添加X-Request-Id请求头。
下边我们用idea的httpclient插件测试,使用该插件可以方便的添加http请求头信息。
创建一个请求:
在下边的界面中输入请求url并设置X-Request-Id请求头
点击“运行”可以正常访问。
如果IDEA没有HTTP client插件请自行安装
1.3. 小结
我们使用Spring Cloud Gateway创建网关实现了路由、负载均衡的功能。
- 网关路由
在application.yaml中配置网关路由,格式如下:
- id: product uri: lb://item-service predicates: - Path=/product/** filters:
id: 路由规则id,自定义,唯一
uri: 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: 路由断言,判断当前请求是否符合当前规则,符合则转发到目标服务
Path:符合请求路径执行此路由
filters: 网关过滤器(稍后讲解)
- 负载均衡
请求首先到达网关,网关从nacos获取服务实例列表,通过Spring Cloud LoadBalancer负载均衡器选取一个服务实例,请求转发到该服务实例上。
2 网关鉴权(了解)
2.1.认识网关鉴权
单体架构时我们只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做身份校验,这显然不可取。
我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到密钥。如果每个微服务都去做身份校验,这就存在着两大问题:
- 每个微服务都需要知道JWT的密钥,不安全。
- 每个微服务重复编写身份校验代码、权限校验代码,代码重复不易维护。
既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把身份校验的工作放到网关去做,这样之前说的问题就解决了:
- 只需要在网关和用户服务保存秘钥
- 只需要在网关开发身份校验功能
网关鉴权是指在网关对请求进行身份验证的过程。这个过程确保只有经过授权的用户或设备才能访问特定的服务或资源。下图是网关鉴权的流程图:
流程如下:
- 用户登录成功生成token并存储在前端
- 前端携带token访问网关
- 网关解析token中的用户信息,网关将请求转发到微服务,转发时携带用户信息
- 微服务从http头信息获取用户信息
- 微服务之间远程调用使用内部接口(无状态接口,即后端微服务集群都不做权限校验)
网关鉴权除了验证token的合法性还有一层含义是校验用户的权限,通常校验用户的权限不放在网关而是放在微服务去实现,因为具体的接口在微服务,在token中包括了用户的权限字符串,微服务接收到权限字符串通过spring security框架进行拦截实现,具体的方案就是在controller接口上通过下边的注解实现:
hasAuthority('authority'): 检查是否有指定的权限(authority),这通常与数据库中的权限字符串对应,可通过AI学习spring security框架(当然我们中州就已经学了,所以这块就很明晰):
AI:spring security授权注解有哪些
AI:@PreAuthorize("hasRole('ADMIN')") 除了hasRole还有哪些
这些问题将在接下来几节一一解决。
除了上述网关鉴权,微服务集群还有很多通用的方案,笔者大致罗列,感兴趣的自行搜索学习:
- 集中式认证与授权(如 OAuth2 + JWT、SSO单点登录)。
- API 网关统一校验。
- 服务间调用的权限校验(如 mTLS、服务令牌)。
- 基于角色的访问控制(RBAC)。
- 基于属性的访问控制(ABAC)。
- 分布式权限校验(如 Casbin)。
- 自定义权限校验。
- 混合方案。
2.2.网关内置过滤器
2.2.1 认识网关过滤器
网关鉴权必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做身份校验,就必须了解Gateway内部工作的基本原理。
如图所示:
- 客户端请求进入网关后由
HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler去处理。 WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为Filter)。- 图中
Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为pre和post两部分,分别会在请求路由到微服务之前和之后被执行。 - 只有所有
Filter的pre逻辑都依次顺序执行通过后,请求才会被路由到微服务。 - 微服务返回结果后,再倒序执行
Filter的post逻辑。 - 最终把响应结果返回。
如图中所示,最终请求转发是有一个名为NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现身份校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前,这就符合我们的需求了!
那么,该如何实现一个网关过滤器呢?
网关过滤器链中的过滤器有两种:
GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.GlobalFilter:全局过滤器,作用范围是所有路由,不可配置。
其实GatewayFilter和GlobalFilter这两种过滤器的方法签名完全一致:
/** * 处理请求并将其传递给下一个过滤器 * @param exchange 当前请求的上下文,其中包含request、response等各种数据 * @param chain 过滤器链,基于它向下传递请求 * @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。 */ Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
FilteringWebHandler在处理请求时,会将GlobalFilter装饰为GatewayFilter,然后放到过滤器链中,排序以后依次执行。
2.2.2 内置过滤器
Gateway中内置了很多的GatewayFilter,详情可以参考官方文档,见下表:
过滤器工厂 |
作用 |
参数 |
AddRequestHeader |
为原始请求添加Header |
Header的名称及值 |
AddRequestParameter |
为原始请求添加请求参数 |
参数名称及值 |
AddResponseHeader |
为原始响应添加Header |
Header的名称及值 |
DedupeResponseHeader |
剔除响应头中重复的值 |
需要去重的Header名称及去重策略 |
Hystrix |
为路由引入Hystrix的断路器保护 |
HystrixCommand的名称 |
FallbackHeaders |
为fallbackUri的请求头中添加具体的异常信息 |
Header的名称 |
PrefixPath |
为请求添加一个 preserveHostHeader=true的属性,路由过滤器会检查该属性以决定是否要发送原始的Host |
无 |
RequestRateLimiter |
用于对请求限流,限流算法为令牌桶 |
keyResolver、rateLimiter、statusCode、denyEmptyKey、emptyKeyStatus |
RedirectTo |
将原始请求重定向到指定的URL |
http状态码及重定向的url |
RemoveHopByHopHeadersFilter |
为原始请求删除IETF组织规定的一系列Header |
默认就会启用,可以通过配置指定仅删除哪些Header |
RemoveResponseHeader |
为原始请求删除某个Header |
Header名称 |
RemoveRequestHeader |
为原始请求删除某个Header |
Header名称 |
RewritePath |
重写原始的请求路径 |
原始路径正则表达式以及重写后路径的正则表达式 |
RewriteResponseHeader |
重写原始响应中的某个Header |
Header名称,值的正则表达式,重写后的值 |
SaveSession |
在转发请求之前,强制执行websession::save操作 |
无 |
secureHeaders |
为原始响应添加一系列起安全作用的响应头 |
无,支持修改这些安全响应头的值 |
SetPath |
修改原始的请求路径 |
修改后的路径 |
SetResponseHeader |
修改原始响应中某个Header的值 |
Header名称,修改后的值 |
SetStatus |
修改原始响应的状态码 |
HTTP状态码,可以是数字,也可以是字符串 |
StripPrefix |
用于截断原始请求的路径 |
使用数字表示要截断的路径的数量 |
Retry |
针对不同的响应进行重试 |
retries、statuses、methods、series |
RequestSize |
设置允许接收最大请求包的大小。如果请求包大小超过设置的值,则返回413 Payload Too Large |
请求包大小,单位为字节,默认值为5M |
ModifyRequestBody |
在转发请求之前修改原始请求体内容 |
修改后的请求体内容 |
ModifyResponseBody |
修改原始响应体的内容 |
修改后的响应体内容 |
内置过滤器有很多,具体在工作中根据需求去使用即可。
下边演示一个非常有用的过滤器 StripPrefix,它的作用:移除路径前缀。
比如:为了路径的统一,我们规定所有请求以/product/开头的全部路由到商品服务,
网关的路由配置如下
- id: product uri: lb://item-service predicates: - Path=/product/**
比如访问商品分页查询接口,访问网关的路径为:/product/items/page, 实际的下游服务器地址是 /items/page,如果使用上边的路由配置是无法实现的。
原因是:
请求:http://localhost:8080/product/items/page?pageNo=1&pageSize=1
路由到:http://localhost:8081/product/items/page?pageNo=1&pageSize=1
正确的应该路由到:http://localhost:8081/items/page?pageNo=1&pageSize=1
可以使用StripPrefix过滤器实现
下边配置一个新的路由:
- id: product uri: lb://item-service predicates: - Path=/product/** filters: - StripPrefix=1
StripPrefix=1表示去除一级路径前缀
使用StripPrefix=1后
请求:http://localhost:8080/product/items/page?pageNo=1&pageSize=1
路径到:http://localhost:8081/items/page?pageNo=1&pageSize=1(正确)
如果配置StripPrefix=2,最终转发到商品服务的路径为/page?pageNo=1&pageSize=1(此路径在商品服务不存在)
2.3. 自定义过滤器
无论是GatewayFilter还是GlobalFilter都支持自定义,只不过编码方式、使用方式略有差别。
2.3.1 自定义GlobalFilter
2.3.2.1 编码实现
自定义GlobalFilter简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数,我们在gateway中实现:
package com.hmall.gateway.filter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Component @Slf4j public class PrintAnyGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 编写过滤器逻辑 log.info("打印全局过滤器"); // 放行 return chain.filter(exchange); // 拦截 // ServerHttpResponse response = exchange.getResponse(); // response.setRawStatusCode(401); // return response.setComplete(); } @Override public int getOrder() { // 过滤器执行顺序,值越小,优先级越高 return 0; } }
全局过滤器不用在路由中配置。请自行测试。
2.3.2 自定义GatewayFilter(自学)
2.3.2.1 定义GatewayFilter
自定义GatewayFilter不是直接实现GatewayFilter,而是继承AbstractGatewayFilterFactory。最简单的方式是这样的:
注意:该类的名称一定要以GatewayFilterFactory为后缀!
package com.hmall.gateway.filter; import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.OrderedGatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; /** * @author Mr.M * @version 1.0 * @description 第一个网关过滤器 * @date 2024/8/7 14:41 */ @Component @Slf4j public class FirstFilterGatewayFilterFactory 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(); log.info("请求路径:{}",request.getPath()); log.info("网关过滤器FirstFilterGatewayFilterFactory执行啦..."); //放行 return chain.filter(exchange); //拦截 返回401状态码 //exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); //return exchange.getResponse().setComplete(); } }; } }
2.3.2.2 配置过滤器
下边的配置仅在product路由中有效
- id: product uri: lb://item-service predicates: - Path=/product/** filters: - StripPrefix=1 - FirstFilter # 此处直接以自定义的GatewayFilterFactory类名称前缀类声明过滤器
配置完成重启网关,访问http://localhost:8080/product/items/page?pageSize=1&pageNo=1 ,观察控制台打出“请求路径...”日志,说明过滤器成功执行。
下边的配置在所有路由中都有效:
spring: cloud: gateway: default-filters: - FirstFilter # 此处直接以自定义的GatewayFilterFactory类名称前缀类声明过滤器
请自行测试。
2.3.3 小结
两种自定义过滤器的方式:
GatewayFilter:路由过滤器
作用范围比较灵活,可以是任意指定的路由Route.
继承AbstractGatewayFilterFactory,并在路由配置中指定过滤器。
过滤器的名称规则:以GatewayFilterFactory作为后缀。
GlobalFilter:全局过滤器
作用范围是所有路由,不可配置。
实现GlobalFilter接口。
实现Ordered 接口可以指定过滤器顺序,实现getOrder()方法,返回值越小优先级越好。
2.4.身份校验过滤器
接下来,我们就利用自定义GlobalFilter来完成身份校验。
2.4.1 JWT工具类
身份校验需要用到JWT,而且JWT的加密需要秘钥和加密工具。这些在单体项目hm-service中已经有了,我们直接拷贝过来:
具体作用如下:
AuthProperties:配置身份校验需要拦截的路径,因为不是所有的路径都需要登录才能访问
- 这个刚复制进去会报错,先不用管,执行后续步骤后自己就没了
JwtProperties:定义与JWT工具有关的属性,比如秘钥文件位置SecurityConfig:工具的自动装配JwtTool:JWT工具,其中包含了校验和解析token的功能hmall.jks:秘钥文件
2.4.2 配置白名单
其中AuthProperties和JwtProperties所需的属性要在application.yaml中配置。
hm: jwt: location: classpath:hmall.jks # 秘钥地址 alias: hmall # 秘钥别名 password: hmall123 # 秘钥文件密码 tokenTTL: 30m # 登录有效期 auth: excludePaths: # 无需身份校验的路径 - /search/** - /users/login - /items/**
excludePaths配置白名单地址,即无需身份校验的路径。
2.4.3 身份校验过滤器
接下来,我们定义一个身份校验的过滤器:
代码如下:
package com.hmall.gateway.filter; import com.hmall.common.exception.UnauthorizedException; import com.hmall.common.utils.CollUtils; import com.hmall.gateway.config.AuthProperties; import com.hmall.gateway.util.JwtTool; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import java.util.List; @Slf4j @Component @RequiredArgsConstructor @EnableConfigurationProperties(AuthProperties.class) public class AuthGlobalFilter implements GlobalFilter, Ordered { private final JwtTool jwtTool; private final AuthProperties authProperties; private final AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 获取Request ServerHttpRequest request = exchange.getRequest(); //请求路径 String path = request.getPath().toString(); //白名单 List<String> excludePaths = authProperties.getExcludePaths(); //判断当前请求路径是否是白名单,使用antPathMatcher进行匹配 for (String excludePath : excludePaths) { boolean match = antPathMatcher.match(excludePath, path); if (match) { return chain.filter(exchange); } } //获取请求头的token String token = exchange.getRequest().getHeaders().getFirst("authorization"); if(token==null){ //如果token为空则返回401错误 ServerHttpResponse response = exchange.getResponse(); response.setRawStatusCode(401); return response.setComplete(); } //如果token不为空则校验token Long userId = null; try { userId = jwtTool.parseToken(token); } catch (Exception e) { //返回token无效的错误 ServerHttpResponse response = exchange.getResponse(); response.setRawStatusCode(401); return response.setComplete(); } // TODO 5.如果有效,传递用户信息 log.info("userId:{}",userId); // 6.放行 return chain.filter(exchange); } @Override public int getOrder() { return 0; } }
2.4.4 测试
重启网关进行测试。
由于“/items/** ” 在excludePaths中配置,所以访问/items开头的路径在未登录状态下不会被拦截:
http://localhost:8080/items/page?pageNo=1&pageSize=1
访问/carts路径在未登录状态下请求会被拦截,并且返回401状态码:
首先通过user-service的swagger文档测试登录接口,拿到token,如下图:
接下来使用httpclient插件进行测试,在请求头中添加token再测试,可以正常访问。
2.4.5 小结
网关身份校验过滤器怎么实现?
我们项目中网关身份校验过滤器使用Spring cloud Gateway的GlobalFilter实现,GlobalFilter是一种全局过滤器。
实现过程如下:
- 配置密钥、白名单 等相关信息。
- 编写身份校验过滤器,实现GlobalFilter接口。
- 首先判断请求地址是否是白名单地址,如果是则放行
- 取出http头中的token,然后校验token的合法性
- 如果token合法则将token中的用户信息向下传给微服务
- 如果token不合法则拒绝访问。
2.5 网关传递用户信息
2.5.1. 思路分析
现在,网关已经可以完成身份校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?
由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。流程图如下:
因此,接下来我们要做的事情有:
- 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
- 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行
2.5.2. 网关解析用户信息并保存
首先,我们修改身份校验拦截器的处理逻辑,保存用户信息到请求头中:
2.5.3. 微服务获取用户信息
2.5.3.1 ThreadLocal工具类
在当前微服务工程hm-common中已经有一个用于保存登录用户的ThreadLocal工具:
屏蔽返回固定用户id的代码,返回从threadLocal中获取的userId,代码如下:
2.5.3.2 编写拦截器
接下来,我们只需要编写拦截器,获取用户信息并保存到UserContext,然后放行即可。
由于每个微服务都有获取登录用户的需求,因此拦截器我们直接写在hm-common中,并写好自动装配。这样微服务只需要引入hm-common就可以直接具备拦截器功能,无需重复编写。
我们在hm-common模块下定义一个拦截器:
具体代码如下:
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(); } }
注意ThreadLocal内存泄漏问题
AI: ThreadLocal数据用完一定要移除,可以避免内存泄漏
当一个线程长时间运行并且没有清除 ThreadLocal 中的引用时,即使应用程序不再需要该数据,垃圾回收器也无法回收这些对象,因为 ThreadLocal 仍然持有对它们的强引用。这可能会导致内存泄漏。
为了避免这种情况,可以在数据使用完毕后显式地从 ThreadLocal 中移除引用。这可以通过调用 ThreadLocal 的 remove() 方法来实现。
2.5.3.3 配置拦截器
接着在hm-common模块下编写SpringMVC的配置类,配置登录拦截器:
具体代码如下:
package com.hmall.common.config; import com.hmall.common.interceptor.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文件中:
内容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.hmall.common.config.MyBatisConfig,\ com.hmall.common.config.JsonConfig,\ com.hmall.common.config.MvcConfig
2.5.3.4 测试
重启购物车服务。
重新进行用户登录拿到最新的token。
在拦截器中打断点,请求/carts 观察是否可以获取用户id
2.5.4. 购物车获取用户信息
2.5.4.1 编码实现
之前我们无法获取登录用户,所以把购物车服务的登录用户写死了,现在需要恢复到原来的样子。
找到cart-service模块的com.hmall.cart.service.impl.CartServiceImpl:
修改其中的queryMyCarts方法:
2.5.4.2 测试
使用httpclient测试通过网关访问查询购物车接口,并且在请求头中带上token
在用户身份拦截器中打断点
在查询购物车接口中打断点
测试到此说明网关已成功将用户信息传入下游服务中。
2.6. Feign接口传递用户
2.6.1. 解决方案
2.6.1.1 问题描述
前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:
下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!
2.6.1.2 方案1:OpenFeign拦截器
微服务之间调用是基于OpenFeign来实现的,在进行OpenFeign调用时可以将用户信息放在http头中传递,如下图:
借助Feign中提供的一个拦截器接口:feign.RequestInterceptor可以实现Feign拦截器
public interface RequestInterceptor { /** * Called for every request. * Add data using methods on the supplied {@link RequestTemplate}. */ void apply(RequestTemplate template); }
我们只需要实现这个接口,然后实现apply方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中。这样一来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息到微服务,微服务通过拦截器将用户id保存在ThreadLocal中。代码如下:
public class FeignInterceptorConfig { @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()); } }; } }
通过代码可知:方案1就是在feign远程调用前在http头中添加用户信息,请求到达微服务由微服务拦截器解析出http头中的user-info放入ThreadLocal。
2.6.1.3 方案2: 单独编写清理购物车接口
现在我们讨论的是如何在接口中传递用户信息,这需要先了解有状态接口和无状态接口的区别。
有状态接口会在多个请求之间保持一些状态信息。比如:用户会话,服务器需要存储有关用户会话的信息,并且可以在后续的请求中使用这些信息。举例:
用户端下单接口是有状态的接口,因为需要知道当前下单的用户,用户的身份是否合法。下图中的接口都是有状态接口
后台商品管理的接口都是有状态的接口,因为需要知道当前去修改商品信息的用户的身份是否合法,是否有权限去修改。
无状态接口在每次请求之间不会保留任何关于前一次请求的信息,也就是说,每一次请求都是独立的,并且包含该请求所需的所有信息。举例下图中的接口都是无状态接口:
微服务之间的远程调用接口应该设计为有状态接口还是无状态接口?
我们抽取的微服务具有单一职责,通用性特点,要想保证通用性在设计接口时要尽量设计为无状态的,如果设计为有状态接口其它服务去调用你时需要考虑给你传递状态信息,比如:接口在执行前需要校验用户的权限,那别人是不是要给你传递当前用户的权限信息,你的接口功能是清理购物车,只要传入用户id和商品id就可以清理此用户购物车中的商品,但别人还需要额外传入用户的权限,这样接口通用性是很差的。
所以,针对微服务之间调用的接口进行单独定义,这些接口不通过网关路由仅限微服务之间调用,所以它们叫内部接口,通常内部接口都设计为无状态,这样更能保证通用性。
那些提供给用户访问通过网关路由转发的接口叫外部接口,外部接口要根据需求设计有状态或无状态。
方案1就是调用有状态接口的思路,它通过feign拦截器去传递用户信息,保证接口的状态,虽然当前也能实现需求但扩展性差。下边的代码并不适应其它场景,比如:有一个定时任务需要调用feign接口,定时任务是后台程序定时执行并没有经过微服务的拦截器,所以是无法从ThreadLocal中获取用户Id的。
这个才是很多时候,我们内部接口设计为无状态的原因:无法满足定时任务的场景
public class FeignInterceptorConfig { @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()); } }; } }
所以,我们需要再编写一个清理购物车的无状态接口供其它微服务调用。
2.6.2. 清理购物车无状态接口
开发无状态接口无需依赖用户会话信息,清理购物车接口需要知道清理哪个用户的购物车即可,接口参数会入用户id即可。
2.6.2.1 定义service
定义新接口:
定义新接口并修改原有接口实现: