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 
# 默认端口:8848

Nacos 启动成功后,微服务(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: true

4.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中配置,针对所有FeignClient生效。
@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内部工作的基本原理。

  1. 客户端请求进入网关后,由HandlerMapping对请求做判断(HandlerMapping的默认实现是RoutePredicateHandlerMapping),找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler(默认实现是FilteringWebHandler)去处理;
  2. WebHandler会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(Filter);
  3. Filter内部的逻辑分为pre和post两部分,分别会在请求路由到微服务之前和之后被执行;
  4. 只有所有Filter的pre逻辑都依次顺序执行通过后,请求才会被路由到微服务;
  5. 微服务返回结果后,再倒序执行Filter的post逻辑;
  6. 最终把响应结果返回。

如图中所示,最终请求转发是有一个名为 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 扫描到,目前 MvcConfigcom.hmall.common 包里,而微服务所在的包是 com.hmall.cartcom.hmall.item 等。包名不一样,所以微服务默认扫描不到 com.hmall.common 包下的类。

与 SpringBoot 启动类在不同包下的,要想被扫描到,必须在 resources/META-INFO 文件夹内定义一个文件,来记录这些类,这个文件名叫 spring.factories。新版本的SpringBoot中,此机制有变,参考本站:Spring 零碎知识记录

hm-common
├── java
│   └── com.hmall.common
└── resources
    └── META-INF/spring.factories

spring.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.namespring.active.profilefile-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,可以看到添加商品成功了。

 

 

 

 

This article was updated on