
SpringCloud 微服务
特别鸣谢:黑马程序员 — 虎哥《SpringCloud微服务开发与实战》课程
Note:学完之后记得参考 RuoYi-Cloud 的思路
一、拆分原则
1.1 拆分目标
- 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高;
- 低耦合:每个微服务的功能要相互独立,尽量减少对其他微服务的依赖。
1.2 拆分方式
- 纵向拆分:按照业务模块来拆分
- 横向拆分:抽取公共服务,提高复用性
二、服务治理
服务治理中的三个角色分别是什么?
- 服务提供者:暴露服务接口,供其他服务调用;
- 服务消费者:调用其他服务提供的接口;
- 注册中心:记录并监控微服务各实例状态,推送服务变更信息。
消费者如何知道提供者的地址?
- 服务提供者会在启动时注册自己的信息到注册中心,消费者可以从注册中心订阅和拉取服务信息。
消费者如何得知服务状态变更?
- 服务提供者通过心跳机制向注册中心报告自己的健康状态,当心跳异常时,注册中心会将异常服务剔除,并通知订阅了该服务的消费者。
当服务提供者有多个实例时,消费者应该选择哪一个?
- 消费者可以用过负载均衡算法,从多个实例中选择一个。
负载均衡方式:随机、轮询、加权轮询、哈希
三、Nacos 注册中心
Nacos 时目前国内企业中占比最多的注册中心组件,是阿里巴巴的产品,目前已经加入SpringCloudAlibaba中。
3.1 快速启动
下载好压缩包,解压,进入bin目录:
# 以单机模式启动
starup.cmd -m standalone # Windows
sh startup.sh -m standalone # Linux
# 默认端口:8848Nacos 启动成功后,微服务(Springboot)需要访问,就得引入依赖。
3.2 服务注册
① 引入依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>② 配置 Nacos 地址
spring:
application:
name: item-service # 微服务名称
cloud:
nacos:
server-addr: 127.0.0.1:8848③ Idea 模拟单个服务多实例
在 idea -> service 选中要多开的实例,右键 -> 复制配置。由于使用的是同一个 application.yml配置文件,因此为了避免端口冲突,需要添加虚拟机参数:
-Dserver.port=8084④ 验证注册结果
启动多个实例后,进入nacos控制台:服务管理 -> 服务列表
3.3 服务发现与负载均衡
消费者需要连接nacos拉取和订阅服务,因此服务发现的前两步与2.1.2服务注册是一样的。这里只多了一步服务发现。
① 引入 nacos discovery 依赖;② 配置nacos地址
③服务发现
/**
* @author 虎哥
* @since 2023-05-05
*/
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart>
implements ICartService {
private final RestTemplate restTemplate;
private final DiscoveryClient discoveryClient;
private void handleCartItems(List<CartVO> vos) {
// 1.获取商品id
Set<Long> itemIds = ...;
// 2. 根据服务名称,获取服务的实例列表
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
if (CollUtils.isEmpty(instances)) {
return;
}
// 2.2 手写负载均衡,从实例列表中挑选一个实例
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
// 2.1 利用 restTemplate 查询商品
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
instance.getUri() + "/items?ids={ids}",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<ItemDTO>>() {
},
Map.of("ids", StringUtils.collectionToDelimitedString(itemIds, ","))
);
// 2.2 解析响应
if (!response.getStatusCode().is2xxSuccessful()) {
// 查询失败,直接结束
return;
}
List<ItemDTO> items = response.getBody();
if (CollUtils.isEmpty(items)) {
return;
}
...
}
}额外解释:

虎哥说这段代码可以跟面试官吹水,会手写负载均衡😂 但上述代码略显复杂,更优雅的方式是使用OpenFeign。
四、OpenFeign
4.1 快速入门
OpenFeign 是一个声明式的 http 客户端,是 SpringCloud 在 Eureka 公司开源的 Feign 基础上改造而来。官方地址:OpenFeign/feign: Feign makes writing java http clients easier
4.1.1 引入依赖
在需要调用其他服务的消费者中引入依赖
负载均衡早期用的是 Ribbon,现在用的都是 Loadbalancer
<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>4.1.2 启用OpenFeign
package com.hmall.cart;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients <---- 加上这条注解!
@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}4.1.3 编写Feign客户端
① 新建 client 包,和包下的类 ItemClient
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.Collection;
import java.util.List;
@FeignClient("item-service") // 指定要连接的服务(服务提供者)
public interface ItemClient {
@GetMapping("/items") // 服务提供者暴露的路径,ids是发送请求时的传参
List<ItemDTO> queryItemsByIds(@RequestParam("ids") Collection<Long> ids);
}OpenFeign 通过注解,反射得到请求路径和参数,上面的注解,一个都不能少。
② Service 层发起远程调用
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart>
implements ICartService {
// 加了 final 之后,必须初始化,用 @RequiredArgsConstructor 即可注入
private final ItemClient itemClient;
private void handleCartItems(List<CartVO> vos) {
// 1.获取商品id
Set<Long> itemIds = ....;
// 2.查询商品
// List<ItemDTO> items = itemService.queryItemByIds(itemIds);
// OpenFeign 直接替代上一行原始的 itemService 方式
List<ItemDTO> items = itemClient.queryItemsByIds(itemIds);
if (CollUtils.isEmpty(items)) {
return;
}
...
}
}4.2 OpenFeign 开启线程池
OpenFeign 底层发起 HTTP 请求需要依赖其他的框架。包括以下三种:
- HttpURLConnection: 默认实现,不支持连接池
- Apache HttpClient: 支持连接池
- OKHttp:支持连接池
源码坐标:FeignBlockingLoadBalancerClient类中的 delegate 成员变量。
以 OKHttp 为例,开启线程池方法如下:
4.2.1 引入依赖
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>4.2.2 修改配置:application.yml
feign:
okhttp:
enabled: true4.3 最佳实践
分析快速入门中的问题:每个需要调用 item-service 微服务的微服务(如 cart-service 和 order-service),都要编写一份 ItemClient 接口,以及一份 ItemDTO 实体类,出现了代码冗余。
- 思路1:抽取到微服务之外的公共module
- 思路2:每个微服务自己抽取一个module

- 方案1 抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。其他所有服务涉及到远程调用,就都来找这一个服务耦合度肯定高;
- 方案2 抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。
由于item-service已经创建好,继续拆分太麻烦,因此这里采用方案1
4.3.1 抽取 Feign 客户端
① 在hmall下定义一个新的module,命名为hm-api,依赖如下:
<?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-api</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!-- Open Feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- Load Balancer -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- Swagger 注解依赖 -->
<dependency>
<groupId>io.swagger</groupId>
<artifactId>swagger-annotations</artifactId>
<version>1.6.6</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>② 把ItemDTO和ItemClient都拷贝到hm-api下

现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了。
由于 hm-api 模块并不会启动,它只是个依赖包,所以不存在 hm-api 模块挂了的情况!因此可以放心依赖 hm-api 。
4.3.2 扫描包
① 在 cart-service 中添加 hm-api 的依赖,以便调用 hm-api 的服务
<!-- hm-api 模块 -->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-api</artifactId>
<version>1.0.0</version>
</dependency>删除 cart-service 中原来的 ItemDTO 和 ItemClient,重启项目,发现报错了:
*************************** APPLICATION FAILED TO START ***************************
Description: Parameter 0 of constructor in com.hmall.cart.service.impl.CartServiceImpl
required a bean of type 'com.hmall.api.client.ItemClient' that could not be found.这里因为 ItemClient 现在定义到了 com.hmall.api.client 包下,而 cart-service 的启动类定义在 com.hmall.cart 包下,扫描不到 ItemClient,所以报错了。
当定义的 FeignClient 不在 SpringBootApplication 的扫描包范围时,这些 FeignClient 无法使用。有两种方式解决,即在 cart-service 启动类上加:
方式一:指定扫描包
@EnableFeignClients(basePackages = "com.hmall.api.client")方式二:指定 Client 类
@EnableFeignClients(clients ={ItemClient.class, OtherClient.class, ...})此处的场景是 cart-service 调用 hm-api 的 ItemClient 服务。启动类如下:
package com.hmall.cart;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients
@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}4.4 日志配置
一般也不开启 Feign 的日志配置,只有在需要调试 Feign 的时候才开启日志,因为日志输出的内容有很多,输出时会影响性能。
OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。
# cart-service 的配置文件
logging:
level:
com.hmall: debug # com.hmall.api.client 显然在此包下,第一个要求满足
# 也可以单独为 feign client 设置日志级别(可选)
com.hmall.api.client: debug而且OpenFeign自身的日志级别有4级:
- NONE:不记录任何日志信息,这是默认值。
- BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
- HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
Feign默认的日志级别就是NONE,所以默认我们看不到请求日志。
4.4.1 定义日志级别
package com.hmall.api.config;
import feign.Logger;
import org.springframework.context.annotation.Bean;
// 注意!这里不用 @Configuration 注解
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
}4.4.2 配置日志范围
由于我们没有给上述配置类加 @Configuration 注解,要让日志级别生效,还需要配置一下,有两种方式:
① 局部生效:在某个FeignClient中配置,只对当前FeignClient生效
@FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)这个全局生效是加在启动类上面的:
package com.hmall.cart;
import com.hmall.api.config.DefaultFeignConfig;
import ...
@EnableFeignClients(
basePackages = "com.hmall.api.client",
defaultConfiguration = DefaultFeignConfig.class)
@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}五、网关路由
网关的作用:
① 身份校验:登录身份校验,校验通过才放行
② 请求转发:根据请求判断应该访问哪个微服务,将请求转发过去
在SpringCloud当中,提供了两种网关实现方案:
- Netflix Zuul:早期实现,目前已经淘汰
- SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
5.1 快速入门
准备工作:新建 hm-gateway 模块
5.1.1 引入依赖
<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>5.1.2 创建启动类
准备工作:新建包 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);
}
}5.1.3 创建 application.yml
server:
port: 8080 # 作为所有请求的入口
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 127.0.0.1:8848
gateway:
routes:
# 唯一路由id,没有特殊需求,一般用服务名(对应服务的spring.application.name)
- id: item-service
uri: lb://item-service # 路由转发地址,lb://为负载均衡,后面跟服务名
predicates: # 路由地址断言
# - Path=/items/**
# - Path=/search/**
# 上面两条可以简化为一条:
- Path=/items/**, /search/**
- id: user-service
uri: lb://user-service
predicates:
- Path=/addresses/**, /users/**
- id: cart-service
uri: lb://cart-service
predicates:
- Path=/carts/**
- id: pay-service
uri: lb://pay-service
predicates:
- Path=/pay-orders/**
- id: trade-service
uri: lb://trade-service
predicates:
- Path=/orders/**
5.1.4 效果测试
商品服务运行在8081上,和8083上
访问 http://localhost:8081/search/list,正常访问。
访问 http://localhost:8080/search/list,走了网关,也是正常访问。
查看后台日志,8081和8083均分担了请求,可见负载均衡也生效了
5.2 路由过滤
网关路由对应的Java类是RouteDefinition,其常见的属性有:
- id: 路由唯一标识;
- uri: 路由目标地址;
- predicates: 路由断言,判断请求路径是否符合当前路由;
- filters: 路由过滤器,对请求或响应做特殊处理。
5.2.1 predicates 路由断言:

官网有详细的示例: Route Predicate Factories :: Spring Cloud Gateway
5.2.2 filters 路由过滤器

官网有更多的过滤器:GatewayFilter Factories :: Spring Cloud Gateway
1️⃣ 专属于某个服务的 Filter 设置
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 127.0.0.1:8848
gateway:
routes:
# 唯一路由id,没有特殊需求,一般用服务名(对应服务的spring.application.name)
- id: item-service
uri: lb://item-service # 路由转发地址,lb://为负载均衡,后面跟服务名
predicates: # 路由地址断言
# - Path=/items/**
# - Path=/search/**
# 上面两条可以简化为一条:
- Path=/items/**, /search/**
filters: # <-------- 配置专属于 item-service 的 filter
- AddRequestHeader=truth,一键三连!获取请求头参数 truth:
@ApiOperation("分页查询商品")
@GetMapping("/page")
public PageDTO<ItemDTO> queryItemByPage(PageQuery query,
@RequestHeader(value = "truth", required = false) String truth) {
System.out.println("truth = " + truth);
// 1.分页查询
Page<Item> result = itemService.page(query.toMpPage("update_time", false));
// 2.封装并返回
return PageDTO.of(result, ItemDTO.class);
}执行结果:
10:32:46:704 DEBUG 51552 --- [nio-8081-exec-1] ... selectPage : <== Total: 20
truth = I am the "truth" header! # 一键三连!显示乱码,没空研究,换成了这个2️⃣ 全局 Filter 设置
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 127.0.0.1:8848
gateway:
default-filters: # 我是全局 Filter !所有的服务都得过我这关
- AddRequestHeader=truth, I am the "truth" header!
routes:
- id: item-service
uri: lb://item-service
predicates:
- Path=/items/**, /search/**5.3 网关登录校验
我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:① 每个微服务都需要知道JWT的秘钥,不安全② 每个微服务重复编写登录校验代码、权限校验代码,麻烦
更合理的鉴权流程如下:

5.3.1 网关过滤器
登录校验必须在请求转发到微服务之前做,而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理。

- 客户端请求进入网关后,由
HandlerMapping对请求做判断(HandlerMapping的默认实现是RoutePredicateHandlerMapping),找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler(默认实现是FilteringWebHandler)去处理; - WebHandler会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(Filter);
- Filter内部的逻辑分为pre和post两部分,分别会在请求路由到微服务之前和之后被执行;
- 只有所有Filter的pre逻辑都依次顺序执行通过后,请求才会被路由到微服务;
- 微服务返回结果后,再倒序执行Filter的post逻辑;
- 最终把响应结果返回。
如图中所示,最终请求转发是有一个名为
NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前,这就符合我们的需求了!
查看NettyRoutingFilter是怎么实现最后执行的?分析如下:
public class NettyRoutingFilter implements GlobalFilter, Ordered {
...
}Ordered 接口位于 Spring 核心包下:
package org.springframework.core;
public interface Ordered {
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
/**
* Get the order value of this object.
*/
int getOrder();
}
可见 值越小,优先级越高;值越大,优先级越低
NettyRoutingFilter 实现了 Ordered 接口的 getOrder() 方法:
public class NettyRoutingFilter implements GlobalFilter, Ordered {
public static final int ORDER = Ordered.LOWEST_PRECEDENCE;
@Override
public int getOrder() {
return ORDER;
}
...
}5.3.2 自定义过滤器
- GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.
- GlobalFilter:全局过滤器,作用范围是所有路由,声明后自动生效。
注意:过滤器链之外还有一种过滤器,HttpHeadersFilter,用来处理传递到下游微服务的请求头。例如:org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter可以传递代理请求原本的host头到下游微服务。
其实GatewayFilter和GlobalFilter这两种过滤器的方法签名完全一致:
/**
* 处理请求并将其传递给下一个过滤器
* @param exchange 当前请求的上下文,其中包含request、response等各种数据
* @param chain 过滤器链,基于它向下传递请求
* @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);创建自定义 GlobalFilter,并实现 Ordered 接口,以保证它在NettyRoutingFilter之前执行:
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.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class MyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// TODO 模拟登录校验逻辑
HttpHeaders headers = exchange.getRequest().getHeaders();
System.out.println("headers = " + headers);
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}5.3.3 实现登录校验
这里仅给出网关实现登录校验的核心逻辑,不展示jwt解析细节
package com.hmall.gateway.filter;
import com.hmall.common.exception.UnauthorizedException;
import com.hmall.gateway.config.AuthProperties;
import com.hmall.gateway.utils.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
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;
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final AuthProperties authProperties;
private final JwtTool jwtTool;
// 专门用于匹配路径的工具,注意!它是 org.springframework.util.AntPathMatcher
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 在这里添加全局过滤逻辑
// 1、获取 request
ServerHttpRequest request = exchange.getRequest();
// 2、判断是否需要做登录拦截
if (isExcludePath(request.getPath().toString())) {
// 放行
return chain.filter(exchange);
}
// 3、获取 token
String token = null;
List<String> authorization = request.getHeaders().get("Authorization");
if (authorization != null && !authorization.isEmpty()) {
token = authorization.get(0);
}
// 4、校验并解析 token
Long userId = null;
try {
userId = jwtTool.parseToken(token);
} catch (UnauthorizedException e) {
// 拦截,设置响应状态码为401(注意拦截逻辑的写法!)
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
System.out.println("userId = " + userId);
// TODO 5、传递用户信息
// 6、放行
return chain.filter(exchange);
}
private boolean isExcludePath(String path) {
for (String excludePath : authProperties.getExcludePaths()) {
if (antPathMatcher.match(excludePath, path)) {
return true;
}
}
return false;
}
@Override
public int getOrder() {
return 0;
}
}5.3.4 网关传递用户
完善5.3.3中的 —— TODO 5、传递用户信息
// TODO 5、传递用户信息
String userInfo = userId.toString();
// 因为 ServerWebExchange 不支持直接改 header,
// 所以需要使用 mutate() 方法重新建一个 exchange 来修改 request
ServerWebExchange webExchange = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo)).build();
// 6、放行
/*return chain.filter(exchange);*/
return chain.filter(webExchange);改造 CartController 验证请求头是否添加成功:
@Api(tags = "购物车相关接口")
@RestController
@RequestMapping("/carts")
@RequiredArgsConstructor
public class CartController {
private final ICartService cartService;
@ApiOperation("查询购物车列表")
@GetMapping
public List<CartVO> queryMyCarts(
@RequestHeader(value = "user-info", required = false) String userInfo) {
System.out.println("userInfo = " + userInfo);
return cartService.queryMyCarts();
}
...
}
控制台打印出了用户信息:
userInfo = 1
20:11:21:472 DEBUG 41416 --- [nio-8082-exec-2] c.h.cart.mapper...在每个微服务中接收到用户信息,有三种方案:
(1) 在每个 controller 上用 RequestHeader 接收 user-info
❌ 太麻烦,不现实
(2) 在每个微服务里写 Filter,将用户信息保存在对应微服务的 ThreadLocal里
❌ 虽然可行,但随着微服务数量增多,依然有大量的代码重复
(3) 将 Filter 写入所有微服务依赖的 common 包里
✅ 可行
下面我们来实现方案 (3),将 Filter 写入所有微服务依赖的 common 包里
① 在 hm-common 模块下新建包 interceptor,创建 UserInfoInterceptor 类
注意:这里是不需要做拦截的,因为在网关以及校验过用户是否合法了
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、判断是否有这个用户,如果有,则保存到 ThreadLocal 中
if (StrUtil.isNotBlank(userInfo)) {
UserContext.setUser(Long.valueOf(userInfo));
}
// 3、放行
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 清理用户
UserContext.removeUser();
}
}但是此时拦截器并未生效!需要配置一下才行。
② 创建一个 MvcConfig:
package com.hmall.common.config;
import com.hmall.common.interceptor.UserInfoInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 默认拦截所有路径,所以不用写 ...addPathPatterns("/**")
registry.addInterceptor(new UserInfoInterceptor());
}
}注意!!上面的注解是 @Configuration,实现的是 WebMvcConfigurer!
但是这个时候,它还是没有生效。因为配置类要想生效,必须被 Spring 扫描到,目前 MvcConfig 在 com.hmall.common 包里,而微服务所在的包是 com.hmall.cart、com.hmall.item 等。包名不一样,所以微服务默认扫描不到 com.hmall.common 包下的类。
与 SpringBoot 启动类在不同包下的,要想被扫描到,必须在 resources/META-INFO 文件夹内定义一个文件,来记录这些类,这个文件名叫 spring.factories。新版本的SpringBoot中,此机制有变,参考本站:Spring 零碎知识记录
hm-common
├── java
│ └── com.hmall.common
└── resources
└── META-INF/spring.factoriesspring.factories 文件内容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hmall.common.config.MyBatisConfig,\
com.hmall.common.config.MvcConfig,\ # 新增这一行
com.hmall.common.config.JsonConfig我们直接启动一手 GatewayApplication,哎?报错了
Caused by: java.io.FileNotFoundException: class path resource
[org/springframework/web/servlet/config/annotation/WebMvcConfigurer.class]
cannot be opened because it does not exist原来是因为网关也引了 hm-common 模块:
<!-- pom.xml (hm-gateway) -->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-common</artifactId>
<version>1.0.0</version>
</dependency>原来是因为,hm-common 模块的 MvcConfig 用到了 WebMvcConfigurer 接口,而我们的 spring-cloud-starter-gateway 是基于 webflux 技术的,所以没有 spring-web 的依赖:

但是我们的其余的微服务模块,例如 user-service、item-service 都是基于 springmvc 的,所以解决问题的思路是:
如何让 hm-common 模块的 MvcConfig 只在微服务中生效,在网关里不生效?
涉及知识点:SpringBoot自动装配原理,使用条件配置来指定生效规则。
分析:网关和微服务的主要区别是“是否有Spring MVC”,这可以作为生效条件
我们可以用 @Conditional 注解,而且 Spring MVC 的核心是 DispatcherServlet:
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 默认拦截所有路径,所以不用写 ...addPathPatterns("/**")
registry.addInterceptor(new UserInfoInterceptor());
}
}
只有当类路径中存在 DispatcherServlet 类时,才会加载并注册 MvcConfig 配置类
通过上述配置,拦截器已经可以正常工作了。在需要使用用户信息的地方,使用:
// 1.获取登录用户
Long userId = UserContext.getUser();5.3.5 OpenFeign 传递用户
微服务项目中的很多业务需要多个微服务共同合作完成,因此需要在微服务之间互相传递用户信息,例如一次购买业务:

以结算后清除购物车为例,交易服务调用购物车服务,要求其清理购物车。以下是购物车服务处理清空购物车的逻辑:
package com.hmall.cart.service.impl;
import ...
/**
* @author 虎哥
* @since 2023-05-05
*/
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart>
implements ICartService {
...
@Override
public void removeByItemIds(Collection<Long> itemIds) {
// 1.构建删除条件,userId和itemId
QueryWrapper<Cart> queryWrapper = new QueryWrapper<Cart>();
queryWrapper.lambda()
.eq(Cart::getUserId, UserContext.getUser())
.in(Cart::getItemId, itemIds);
// 2.删除
remove(queryWrapper);
}
}
注意到用户ID依然是使用 UserContext.getUser() 来从 ThreadLocal 获取的。可是经过上面的网关+Filter的方案,用户信息只存在于网关直接传递到的微服务里面(这次是交易服务),因此在购物车服务里用 ThreadLocal 肯定是获取不到用户信息的。
OpenFeign 中提供了一个拦截器接口,所有由 OpenFeign 发起的请求都会经过拦截器
public interface RequestInterceptor {
/**
* Called for every request. Add data using methods on the supplied
* {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}其中 apply() 的入参 RequestTemplate 类,提供了一些方法可以让我们修改请求头。为了避免重复编写拦截器,因此放在 hm-api 中是最合适的。
<!-- hm-api 的 pom.xml,以便使用 UserContext -->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-common</artifactId>
<version>1.0.0</version>
</dependency>由于拦截器很简单,就不新开一个文件了(也可以新开),这里用匿名内部类的方式实现
package com.hmall.api.config;
import com.hmall.common.utils.UserContext;
import feign.Logger;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
@Bean
public RequestInterceptor userInfoInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
Long userId = UserContext.getUser();
if (userId != null) {
template.header("user-info", userId.toString());
}
}
};
}
}
思考:为什么这里 UserContext.getUser() 可以获取到用户信息?
因为网关服务将用户信息通过请求头传递给交易服务,然后交易服务再去调用购物车或其他服务。所以交易服务是有用户信息的,而交易服务调用 hm-api 包下的 FeignClients 又与交易服务本身是在同一线程下的(因为hm-api、hm-common不作为服务运行,只是一个代码包,会被打包一份给每一个微服务)。
简言之,直接接收网关服务的请求头信息的所有服务,都是有用户信息的,这些服务的 ThreadLocal里面必然是有用户信息的。
我们的 OpenFeign 客户端想要生效,还需要在对应微服务的启动类上加注解:
package com.hmall.trade;
import com.hmall.api.config.DefaultFeignConfig;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients(
basePackages = "com.hmall.api.client",
defaultConfiguration = DefaultFeignConfig.class)
@MapperScan("com.hmall.trade.mapper")
@SpringBootApplication
public class TradeApplication {
public static void main(String[] args) {
SpringApplication.run(TradeApplication.class, args);
}
}接下来,由于所有微服务依赖的 common 包里,配置了 UserInfoInterceptor,并且已经生效,所以打入每个微服务的请求,都会被解析请求头,然后将用户信息写入对应微服务的 ThreadLocal 里。
这里 OpenFeign 拦截器功能类似于网关的 GlobalFilter,即:
- 网关的
GlobalFilter:解析 jwt,获得用户信息,加到请求头上 --> 传递给UserInfoInterceptor,存入ThreadLocal; - OpenFeign 拦截器:从
ThreadLocal获得用户信息,加到请求头上 --> 传递给UserInfoInterceptor,存入ThreadLocal;
这一个闭环,保证了每个微服务的 ThreadLocal 里都有当前正在操作的用户信息。

六、配置管理
问题分析:我们现在有很多的微服务,这些微服务都有各种各样的配置文件,但是这些配置文件存在很多重复的地方,例如MySQL数据库配置、日志配置、swagger接口文档配置等。简言之,微服务重复配置过多,维护成本高。
因此我们需要一个配置管理服务,用来管理一些微服务之间通用的配置。
配置管理还可以解决以下两个问题:
- 业务配置经常变动,每次修改都要重启服务;
- 网关路由配置写死,如果变更,需要重启网关,此时所有服务不可用。
综上所述,配置管理的两大核心功能:① 配置共享 ② 配置信息热更新
我们将在下文使用 Nacos 提供的配置管理服务。
6.1 配置共享
6.1.1 添加配置到 Nacos
添加一些共享配置到 Nacos 中,包括:jdbc、MybatisPlus、日志、Swagger、OpenFeign 等配置。
坐标:Nacos 控制台 --> 配置管理 --> 配置列表 --> [+] 创建配置
# Data ID: shared-log.yaml
# 描述:日志共享配置
logging:
level:
com.hmall: debug
pattern:
dateformat: HH:mm:ss:SSS
file:
path: "logs/${spring.application.name}"# Data ID: shared-swagger.yaml
# 描述:共享的swagger配置
knife4j:
enable: true
openapi:
title: ${hm.swagger.title:黑马商城接口文档}
description: ${hm.swagger.desc:黑马商城接口文档描述}
email: zhanghuyi@itcast.cn
concat: 虎哥
url: https://www.itcast.cn
version: v1.0.0
group:
default:
group-name: default
api-rule: package
# api扫描的包
api-rule-resources:
- ${hm.swagger.package}# Data ID: shared-jdbc.yaml
# 描述:jdbc的共享配置文件
spring:
datasource: # 每个微服务可以有 独立的数据库(独享MySQL实例),但考虑成本,这里使用不同的数据库来做
url: jdbc:mysql://${hm.db.host:127.0.0.1}:${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:root}
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
global-config:
db-config:
update-strategy: not_null
id-type: auto其中:${hm.swagger.title:黑马商城接口文档} 是一种从 bootstrap.yml 中读取配置的变量,hm.swagger.title 是变量名,: 后面放的是默认值。
6.1.2 微服务从 Nacos 拉取配置
① 基于 Nacos Config 拉取共享配置,代替微服务的本地配置。读取配置流程如下:

① 引入依赖
<!--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>② 为 cart-serivce 新建 bootstrap.yml
spring:
application:
name: cart-service # 微服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 127.0.0.1:8848
config:
file-extension: yaml
shared-configs:
- data-id: shared-jdbc.yaml
- data-id: shared-log.yaml
- data-id: shared-swagger.yaml③ 去除 application.yml 中重复的配置,并给 shared-xxxx.yml 中的变量赋值:
server:
port: 8082
feign:
okhttp:
enabled: true
hm:
db:
database: hm-cart
swagger:
title: "黑马商城 | 购物车服务 - 接口文档"
package: com.hmall.cart.controller启动测试,可以看到 cart-service 正确读取了 shared-xxxx.yml :
Located property source: [
BootstrapPropertySource {name='bootstrapProperties-cart-service-local.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-cart-service.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-cart-service,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-shared-swagger.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-shared-log.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-shared-jdbc.yaml,DEFAULT_GROUP'}
]6.2 配置热更新
描述:当修改配置文件中的配置时,微服务无需重启即可使配置文件生效。
6.2.1 热更新的生效条件
① nacos 中要有一个与微服务名有关的配置文件,即:
[spring.application.name]-[spring.active.profile].[file-extension]
# 例如:cart-service-local.yaml我们注意到:
Located property source: [
BootstrapPropertySource {name='bootstrapProperties-cart-service-local.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-cart-service.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-cart-service,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-shared-swagger.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-shared-log.yaml,DEFAULT_GROUP'},
BootstrapPropertySource {name='bootstrapProperties-shared-jdbc.yaml,DEFAULT_GROUP'}
]前三行,加载了这种文件名格式的配置文件,即使这个文件还没有创建。为什么会有这样的加载行为呢?我们来回顾之前的 bootstrap.yml
spring:
application:
name: cart-service # 微服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 127.0.0.1:8848
config:
file-extension: yaml
shared-configs:
- data-id: shared-jdbc.yaml
- data-id: shared-log.yaml
- data-id: shared-swagger.yaml可以看到其中恰好有:spring.application.name、spring.active.profile、file-extension 三个配置。
② 微服务中要以特定的方式读取需要热更新的配置属性
package com.hmall.cart.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component // 使得 ConfigurationProperties 生效
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
private Integer maxItems;
}或者使用 @RefreshScope 和 @Value("${hm.cart.item}") 组合的方式:
package com.hmall.cart.config;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
@Data
@RefreshScope // 必须要加上这个 org.springframework.cloud.context... 下的注解
public class CartProperties {
@Value("${hm.cart.maxItems}")
private Integer maxItems;
}
推荐使用第一种方式。
6.2.2 配置案例
需求:购物车的容量限制目前是写死在业务中的,将其改为读取配置文件属性,并交给 Nacos 管理,实现热更新。
之前的购物车容量是写死的,不能超过10:
package com.hmall.cart.service.impl;
import ...
/**
* @author 虎哥
* @since 2023-05-05
*/
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart>
implements ICartService {
...
private void checkCartsFull(Long userId) {
int count = lambdaQuery().eq(Cart::getUserId, userId).count();
if (count >= 10) {
throw new BizIllegalException(StrUtil.format("购物车容量不能超过{}", 10));
}
}
}① 为了实现热更新,我们新建一个类:CartPorperties
package com.hmall.cart.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component // 使得 ConfigurationProperties 生效
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
private Integer maxItems;
}② 在业务层注入属性
package com.hmall.cart.service.impl;
import ...
/**
* @author 虎哥
* @since 2023-05-05
*/
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart>
implements ICartService {
private final CartProperties cartProperties;
...
private void checkCartsFull(Long userId) {
int count = lambdaQuery().eq(Cart::getUserId, userId).count();
if (count >= cartProperties.getMaxItems()) {
throw new BizIllegalException(
StrUtil.format("购物车容量不能超过{}",
cartProperties.getMaxItems())
);
}
}
}③ nacos 中添加配置:
# Data ID: cart-service.yaml
# 购物车的配置文件
# 文件名格式:[spring.application.name]-[spring.active.profile].[file-extension]
# 不指定 [spring.active.profile] 时,任何环境下都生效
hm:
cart:
maxItems: 1④ 重启 cart-service,使得修改的代码生效
⑤ 运行测试,报了空指针异常,检查后原来是 nacos 配置没点发布。发布后测试成功:
com.hmall.common.exception.BizIllegalException: 购物车容量不能超过1
at com.hmall.cart.service.impl.CartServiceImpl.checkCartsFull...⑥ 去 nacos 控制台修改配置并发布:
# Data ID: cart-service.yaml
hm:
cart:
maxItems: 10⑦ 再次加购商品,不用重启 cart-service,可以看到添加商品成功了。
