Spring Cloud

笔记介绍

学习视频,课程思路是基于之前的单体项目【以黑马商城为例】,调整为微服务项目进行学习。高效使用。

微服务是一种软件架构风格,它是以专注于单一职责的很多小型项目为基础,组合出复杂的大型应用。这样更有利于大项目的开发。

image-20251026153704799

项目导入

Docker部署MySQL

Docker - 韩熙隐ario - 博客园。在这篇笔记中已经提到了部署MySQL。这里不多解释。

把准备好的配置文件和初始化脚本放到root目录。

然后创建一个通用网络:

docker network create hm-net

使用下面的命令来安装MySQL:

docker run -d \
  --name mysql \
  -p 3306:3306 \
  -e TZ=Asia/Shanghai \
  -e MYSQL_ROOT_PASSWORD=123 \
  -v /root/mysql/data:/var/lib/mysql \
  -v /root/mysql/conf:/etc/mysql/conf.d \
  -v /root/mysql/init:/docker-entrypoint-initdb.d \
  --network hm-net\
  mysql

然后可以查看进程是否正常运行。

后端

直接打开项目,设置修改配置文件数据库的host。设置运行configuration type中的Active profiles项填成local即可【详见:SpringBoot3大事件 - 韩熙隐ario - 博客园】。启动之后就可以跑通了。

前端

讲前端项目资料放到一个非中文、不包含特殊字符的目录下。打开命令行输入start nginx.exe即可。可以在任务管理器中看到其进程是否运行。

image-20251104215941554

然后就成了。可以输入网址进行测试。

认识微服务

单体架构

将业务功能的所有功能集中在一个项目中开发,达成一个包部署。

image-20251105201352710

优点:

  • 架构简单
  • 部署成本低

缺点:

  • 团队协作成本高【随着功能增加,开发人员变多且代码存在耦合,会很容易导致冲突
  • 系统发布效率低【代码打包的时间越来越长,编译运行可能需要几十分钟】
  • 系统可用性差【功能都部署在一起。不协调,没有应对好功能的特点(如是否重点,并发量需求等)。用户体验很差】

所以,单体架构适合开发功能相对简单,规模较小的项目。

微服务

微服务架构,是服务化思想指导下的一套最佳实践架构方案。服务化,就是把单体架构中的功能模块拆分为多个独立项目。

image-20251105202840146

每个功能作为项目

  • 粒度小【按业务来看,负责一块完整的功能,做到单一职责】
  • 团队自治【每个项目,都有一个完整的团队开发】【相当与拆成了对各项目】
  • 服务自治【部署的时候,分别编译打包,分别部署】【也方便了后续的更新了,哪里更新了动哪里】【数据库也可以才开了,功能模块之间相互影响小】

缺点:

  • 对应跨模块的复杂业务开发难度大
  • 项目结构复杂,不易部署。运维发展的高

如何克服缺点呢?当然是用后面要学的技术栈了。

Spring Cloud

Spring Cloud是目前全球使用最广泛的微服务框架。官网地址:https://spring.io/projects/spring-cloud。
Spring Cloud集成了各种微服务功能组件,并基于Spring Boot实现了这些组件的自动装配,从而提供了良好的开箱即用体验:

image-20251105205741897

在官网可以看到有一堆组件。

image-20251105205942420

这是为了解决微服务开发中各种各样的问题。Spring Cloud其实是个集成的,是个完整的技术栈。很多是第三方开发的,被Spring Cloud集成了。其中,有的甚至比Spring Cloud还早,当Spring Cloud有了才火起来的。有了Spring Cloud,使第三方组件的使用组件的难度大大降低了,得到了推广。

Spring Cloud基于Spring Boot实现了微服务组件的自动装配,从而提供了良好的开箱即用体验。但对于Spring Boot的版本也有要求:

Spring Cloud版本 Spring Boot版本
2022.0.x aka Kilburn 3.0.x
2021.0.x aka Jubilee 2.6.x, 2.7.x (Starting with 2021.0.3)
2020.0.x aka Ilford 2.4.x, 2.5.x (Starting with 2020.0.3)
Hoxton 2.2.x, 2.3.x (Starting with SR5)
Greenwich 2.1.x
Finchley 2.0.x
Edgware 1.5.x
Dalston 1.5.x

因为Spring Boot 3用的是jdk17,但国内基本没人用。所以这里学2021版的Spring Cloud

我们将来对于各组件的版本不需要指定。已经被spring-cloud-dependencies定义了。直接引入即可。

tips

Spring Cloud是标准的制定者,定义了微服务组件的标准,定义了大量的接口和规范,这些微服务组件都实现了组件并遵守规范。所以各组在使用上差别就不大了,也可能就依赖不同。所以,别看有这么多组件,也是很好掌握的。

微服务拆分

将单体项目拆分为微服务架构,同时学习服务拆分的原则。

项目熟悉

具体细节可在官方文档查看:day03-微服务01 - 飞书云文档

image-20251105213610512

在登录阶段,通过拦截器拿到用户信息。这里加了一个线程区域

基于tomcat,进入tomcat的每个请求,如果不自己创建线程的话。默认是只有一个线程域来处理的。因此,在拦截器中存入当前线程的线程域,后续业务用到用户信息的话,随时都可以基于 usercontext从线程域中取出用户信息。这样会很方便。

image-20251105213857157

image-20251105214012546

服务拆分原则

什么时候拆分

  • 创业型项目:先采用单体架构,快速开发,快速试错。随着规模扩大,逐渐拆分。【这样可以快速的实现自己的想法,兵贵神速。同时,开发成本低,试错成本低。只不过后面拆的时候麻烦点】

    一般情况下,对于一个初创的项目,首先要做的是验证项目的可行性。因此这一阶段的首要任务是敏捷开发,快速产出生产可用的产品,投入市场做验证。为了达成这一目的,该阶段项目架构往往会比较简单,很多情况下会直接采用单体架构,这样开发成本比较低,可以快速产出结果,一旦发现项目不符合市场,损失较小。

    如果这一阶段采用复杂的微服务架构,投入大量的人力和时间成本用于架构设计,最终发现产品不符合市场需求,等于全部做了无用功。

    所以,对于大多数小型项目来说,一般是先采用单体架构,随着用户规模扩大、业务复杂后再逐渐拆分为微服务架构。这样初期成本会比较低,可以快速试错。但是,这么做的问题就在于后期做服务拆分时,可能会遇到很多代码耦合带来的问题,拆分比较困难(前易后难

  • 明确的大型项目:资金充足,目标明确,可以直接选择微服务架构,避免后续拆分的麻烦。【比如大厂想开发某个业务】

如何拆分

目标有两点:

  • 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高【比如某业务的升级,对其他服务基本不会产生什么影响】。【做到高内聚,自然就低耦合了】
  • 低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖。

从拆分方式来说,一般包含两种方式:【两种方式结合这来用最好。下面主要讲垂直拆分】

  • 纵向拆分【垂直】:按照业务模块来拆分【分布式的思想。】

  • 横向拆分【水平】:抽取公共服务,提高复用性

    例如用户登录是需要发送消息通知,记录风控数据,下单时也要发送短信,记录风控数据。因此消息发送、风控数据记录就是通用的业务功能,因此可以将他们分别抽取为公共服务:消息中心服务、风控管理服务。这样可以提高业务的复用性,避免重复开发。同时通用业务一般接口稳定性较强,也不会使服务之间过分耦合。

拆分服务

工程结构有两种:

  • 独立Project

    每个微服务都是一个Project,在idea中是独立的窗口、独立的文件夹。一般我们会创建一个空文件夹。把所有的Project都放进去管理。从项目结构来讲它们是分离的。而且代码仓库git也是分离的。【适合特别大型的项目】

    • 优点:完全解耦,没有联系。
    • 缺点:但这样结构松散,不易管理。
  • Maven聚合

    每个微服务都是一个Module,每个Module都是独立做打包部署的。运行的时候是独立分开的,只不过代码是放到一起的。【适合中小型项目。下面主要讲这个】

    • 项目代码集中,管理和运维方便
    • 服务之间耦合,编译时间较长

这里可以就在原来的项目父工程基础上拆,优点如下:

  • 可以直接使用已经引用的依赖
  • 容易拷贝代码

然后可以开始拆分了

  1. 可以新建module了【以商品管理为例】

    image-20251106213005881

  2. 把直接去的依赖和build代码复制过去。对于不需要或不确定是否需要的依赖去除。后面用到在加也可以。另外,能继承父工程的也来也要去除。随后导入依赖。

  3. 创建包和启动类。

    image-20251106214421042

  4. 配置文件

    基本上都是先复制,再修改

    image-20251106215705045

    • mapper文件【这里本来是空的,先不管了】

    • yaml文件

      server:
        port: 8081 #这里也要区分,不同微服务端口不能冲突
      spring:
        application:
          name: item-service #每个服务都要有一个微服务名称
        profiles:
          active: dev #默认就好
        datasource: #每个微服务都要独立。在docker中都要对应一个容器,创建不同的mysql示例。所有这里地址会变。【课程为了简单,用不同数据库模拟,所以下面只改数据库名即可】
          url: jdbc:mysql://${hm.db.host}:3306/hm-item?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
          driver-class-name: com.mysql.cj.jdbc.Driver
          username: root
          password: ${hm.db.pw}
      mybatis-plus:
        configuration:
          default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
        global-config:
          db-config:
            update-strategy: not_null
            id-type: auto
      logging:
        level:
          com.hmall: debug #com.hmall包下的所有类产生的类都是debug
        pattern:
          dateformat: HH:mm:ss:SSS #日期文件
        file:
          path: "logs/${spring.application.name}" #这样每个服务的日志输出会在不同目录下
      knife4j: #是swagger接口文档的配置
        enable: true
        openapi:
          title: 黑马商城商品管理接口文档
          description: "黑马商城接口文档"
          email: zhanghuyi@itcast.cn
          concat: 虎哥
          url: https://www.itcast.cn
          version: v1.0.0
          group:
            default:
              group-name: default
              api-rule: package
              api-rule-resources:
                - com.hmall.item.controller #Swagger扫描到Controller,会把Controller接口信息作为接口文档信息
      
  5. 拷贝有关的domain实体【在整个过程中允许出错。拷漏了,后面用到了再补嘛】

  6. 拷贝有关的mapper

  7. 拷贝有关service接口和实现类。要注意代码中的硬编码路径。虽然格式没问题,但需要改

    String sqlStatement = "com.hmall.item.mapper.ItemMapper.updateStock";
    
  8. 拷贝有关的controller

尝试运行

因为后面微服务可能很多。我们这里不建议直接找启动类文件去启动。可以在IDEA的services模块中统一管理。然后就可以想启动哪个就启动哪个了,进行配置了。

image-20251107101620015

如果发现启动类没被识别到,有两种方式添加

  • reload运行maven,大概率会识别到

  • 手动添加

    1. 先复制已有的启动类配置

      image-20251107101807278

    2. 进行配置

      image-20251107101938376

拆完商品接着拆购物车管理。步骤基本一致。但是有部分代码耦合,需要知道一下

  • 在CartServiceImpl中注入了IItemService【因为查询购物车时,会根据商品信息计算价格波动等信息】。格式上就出错了,因为已经不在一起了【现在先注释。下面远程调用处理这个问题】

    private final IItemService itemService;
    
    ……
        
    private void handleCartItems(List<CartVO> vos) {
        // TODO 1.获取商品id
        Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
        // 2.查询商品
        List<ItemDTO> items = itemService.queryItemByIds(itemIds);
        if (CollUtils.isEmpty(items)) {
            return;
        }
        // 3.转为 id 到 item的map
        Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
        // 4.写入vo
        for (CartVO v : vos) {
            ItemDTO item = itemMap.get(v.getItemId());
            if (item == null) {
                continue;
            }
            v.setNewPrice(item.getPrice());
            v.setStatus(item.getStatus());
            v.setStock(item.getStock());
        }
        
        ……
        
    }
    
  • 此外,再queryMyCarts方法中。用到了用户拦截器,这里也是没有的,先写死

     List<Cart> carts = lambdaQuery().eq(Cart::getUserId, 1L /* TODO UserContext.getUser()*/).list();
    

远程调用

再拆分过程中发现,一些服务之间是需要数据传输的【比如查询】,相比之前,现在不同服务之间物理上分开了,不能像之前那样直接注入调用。但网络上可以是相通的。我们去通过网络请求数据。

image-20251107212704212

那就需要我通过Java代码从一个服务向另一个服务发送网络请求。Spring给我们提供了一个RestTemplate工具,可以方便的实现Http请求的发送。使用步骤如下:

  1. 注入RestTemplate到Spring容器

    image-20251107212958264

  2. 发起远程调用

    image-20251107213025405

接着我们就可以解决上面的问题了

  1. 首先我们取消注释发现缺少ItemDTO类。我们这里直接加到服务中【微服务之间重复了,不太好,后面会优化】

  2. 注入RestTemplate到Spring容器。我们知道Spring Boot的启动类也是一个配置类,可以写一些简单的配置。我们可以直接再启动类中注入。这样再任何地方都可以注入使用了。

    public class CartApplication {
        public static void main(String[] args) {
            SpringApplication.run(CartApplication.class, args);
        }
    	//注入RestTemplate到Spring容器
        @Bean
        public RestTemplate restTemplate() {
            return new RestTemplate();
        }
    }
    
  3. 在service中注入,并调用请求和解析相应

    @RequiredArgsConstructor
    public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {
    
        private final RestTemplate restTemplate;
        
        ……
        
        private void handleCartItems(List<CartVO> vos) {
            // TODO 1.获取商品id
            Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
            // 2.查询商品
    //        List<ItemDTO> items = itemService.queryItemByIds(itemIds);
            //2.1 利用RestTemplate发起http请求,得到http的响应
            ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
                    "http://localhost:8081/items?ids={ids}",
                    HttpMethod.GET,
                    null,
                    new ParameterizedTypeReference<List<ItemDTO>>() {
                    },
                    Map.of("ids", CollUtils.join(itemIds, ","))
            );
            //2.2解析相应
            if (!response.getStatusCode().is2xxSuccessful()) {
                //查询失败,直接结束
                return;
            }
            List<ItemDTO> items = response.getBody();
    
            if (CollUtils.isEmpty(items)) {
                return;
            }
            
            ……
                
        }
    }
    

    其中有几个要注意的点:

    • 这里用的是lombok注解的方式注入的RestTemplate。优势之间已经接触过了。Mybatis-Plus - 韩熙隐ario - 博客园

    • RestTemplate不仅有exchange方法。还有其他的简单方法。对于简单的需求是可以使用的。

    • 指定返回值类型时有点难。

      • 直接写List<ItemDTO>是不行的,字节码中式没有泛型的。
      • 写List也不可以,也要知道泛型。

      我们这里使用了参数类型引用,就相当于一个泛型的引用。传一个对象。可以用反射拿到对象上的泛型。

    • 这里Map的值,要求是字符串,这里用到了hutool包的集合转字符串功能。

然后测试ak。

服务治理

在上一章我们实现了微服务拆分,并且通过Http请求实现了跨微服务的远程调用。不过这种手动发送Http请求的方式存在一些问题。试想一下,假如微服务被调用较多,为了应对更高的并发,我们进行了多实例部署,如图:

image-20251114192931584

但这也遇到了很多问题:

  • 写代码的时候不知道后面部署多少实例,并不知道每个实例地址。
  • 如何服务消费者,如何确定调用哪个服务。写死也不好,实现不了负载均衡。
  • 如果在运行过程中,某一个item-service实例宕机,cart-service依然在调用该怎么办?
  • 如果并发太高,item-service临时多部署了N台实例,cart-service如何知道新实例的地址?

这些问题就是服务治理问题。我们用注册中心解决。

注册中心的原理

任何一个微服务既可以时服务调用者也可以是服务提供者。像极了人生。像是一个中介中心。这里就是注册中心。负载均衡算法,随机和轮询【概率均衡】、加权轮询【这样重点用硬件较好的】或哈希等等很多种。现在由注册中心负责这些工作,所有知道注册中心就好。

image-20251114193730819

具体流程如下

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

当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?

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

Nacos注册中心

这是国内主流的注册中心,当然还有其他注册中心,比如Eureka、Consul等。用它的原因是国产的,手册是中文的,好理解。同时功能是很强大的。还是上面说的,这些组件使用上来说差别不大。先学一个就好了。

我们重点看安装和部署。注册中心也是一个独立的服务。将来所有的微服务会跟它关联。

我们基于Docker来部署Nacos的注册中心,首先我们要准备MySQL数据库表【这里用mysql数据源】,用来存储Nacos的数据。由于是Docker部署,所以大家需要将资料中的SQL文件导入到你Docker中的MySQL容器中:

image-20251114202111149

然后,我们这里用docker部署nacos。首先我们写一下配置文件。配置文件主要配置了一些与mysql连接的信息。要在其中的nacos/custom.env文件中配置。

image-20251114202605124

然后上传配置文件【这里也可以nacos镜像】至虚拟机,准备部署。

然后加载上传的镜像

docker load -i nacos.tar

然后创建并运行镜像

docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server:v2.1.0-slim

然后可以通过如下命令查看运行日志

docker logs -f nacos

注意:这里如果报错没有配置数据源的话,可能是网络的问题,需要把nacos容器加入mysql所在的网络。

然后就可以在浏览器访问nacos可视化界面了http://192.168.40.134:8848/nacos/。账号密码就是数据库中user信息。这里账号密码都是nacos。

image-20251115094752608

image-20251115205132718

服务注册

那么我们可以在代码中进行把服务提供者注册到Nacos中去。

服务注册步骤如下:

  1. 引入nacos discovery依赖:

    <!--nacos 服务注册发现-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    
  2. 配置Nacos地址:

    spring:
      application:
        name: item-service # 服务名称
      cloud:
        nacos:
          server-addr: 192.168.40.134:8848 # nacos地址
    

然后运行代码,刷新nacos就可以看到了。

此外,还可以在idea中运行多个服务实例:

  1. 首先复制启动项配置

    image-20251115211916474

  2. 修改配置项。因为是在同一台机器上运行,所以端口要保持唯一性。

    !image-20251115212304323

  3. 然后运行即可。

运行以后,可以在nacos中心看到相关记录

image-20251115212538067

image-20251115212554374

服务发现

服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:

  1. 引入依赖【和之前一样】

    <!--nacos 服务注册发现-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    
  2. 配置Nacos地址【和之前一样】

    spring:
      application:
        name: item-service # 服务名称
      cloud:
        nacos:
          server-addr: 192.168.40.134:8848 # nacos地址
    
  3. 发现并调用服务

    接下来,服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。这里可以常见的负载均衡算法挑选。

    另外,服务发现需要用到一个工具,DiscoveryClient,Spring Cloud已经帮我们自动装配,我们可以直接注入使用,我们就可以对原来的远程调用做修改了,之前调用时我们需要写死服务提供者的IP和端口。我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用。实例里面有对应服务的信息。我们可以用其中的地址信息【getUri】完成我们的需求。

    private final DiscoveryClient discoveryClient;
    
    ……
    
        //2.1 发现item-service服务的实例列表
        List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
        //2.2 负载均衡,挑一个实例
        ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
        //2.3 利用RestTemplate发起http请求,得到http的响应
        ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
                instance.getUri() + "/items?ids={ids}",
                HttpMethod.GET,
                null,
                new ParameterizedTypeReference<List<ItemDTO>>() {
                },
    
  4. 然后我们可以发请求,看看哪个服务打印日志了,检测正常。

OpneFeign

快速入门

通过上面代码可以看出,跨服服务发送一个请求还是挺麻烦的。OpenFeign可以让我们的代码更加简单优雅。

OpenFeign是一个声明式的http客户端,是SpringCloud在Eureka公司开源的Feign基础上改造而来。官方地址:https://github.com/OpenFeign/feign
其作用就是基于SpringMVC的常见注解,帮我们优雅的实现http请求的发送。

在使用之前,我们先分析一下我们前面发送请求需要的信息,OpneFeign想必也会用到,有助于我们更好理解

image-20251117213456974

其实远程调用的关键点就在于四个:

  • 请求方式
  • 请求路径
  • 请求参数
  • 返回值类型

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

使用步骤:

以cart-service中的查询我的购物车为例。因此下面的操作都是在cart-service中进行。

  1. 依赖引入,包括OpenFeign和负载均衡组件SpringCloudLoadBalancer【负载均衡早期用Ribbon,现在用到最新版loadbalancer】

    <!--openFeign-->
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
    <!--负载均衡器-->
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-loadbalancer</artifactId>
    </dependency>
    
  2. 通过@EnableFeignClients注解,启用OpenFeign功能

    @EnableFeignClients
    @SpringBootApplication
    public class CartApplication {略}
    
  3. 编写FeignClient。其实是个接口,起作用就是代替之前写的主要代码。

    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")
        List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
    }
    
    • 用注解告诉本接口位OpenFeign客户端,并指定了服务的名称。将来就可以根据服务名称从注册中心中拉去服务列表。
    • 负载均衡已经通过依赖引入,接着就可以拿到一个实例了。也就知道uri了
    • 然后需要知道请求方式,请求参数,返回值类型。这是通过定义的方法告诉的。方法上的注解指明请求方式和请求路径。【这里用的springMVC的注解是为了降低学习成本】
    • 请求参数也是用注解声明@RequestParam,将来就会拼到请求路径后面
    • 然后基于方法的返回值指明所需要的返回值类型。

    有了上述信息,OpenFeign就可以利用动态代理帮我们实现这个方法,并且向http://item-service/items发送一个GET请求,携带ids为请求参数,并自动将返回值处理为List<ItemDTO>

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

  4. 使用FeignClient,实现远程调用

    private final ItemClient itemClient;
    
    private void handleCartItems(List<CartVO> vos) {
            // TODO 1.获取商品id
            Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
            List<ItemDTO> items = itemClient.queryItemByIds(itemIds);
    

连接池

下面我们再深入了解一下实现原理【可以使用断点debug进行分析】,并进行一些优化。

Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:

  • HttpURLConnection:默认实现,不支持连接池
  • Apache HttpClient :支持连接池
  • OKHttp:支持连接池

因此我们通常会使用带有连接池的客户端来代替默认的HttpURLConnection。比如,我们使用OK Http.

使用的只有两个步骤就可以了。

  1. 引入依赖。在cart-servicepom.xml中引入依赖:

    <!--OK http 的依赖 -->
    <dependency>
      <groupId>io.github.openfeign</groupId>
      <artifactId>feign-okhttp</artifactId>
    </dependency>
    
  2. 开启连接池。在cart-serviceapplication.yml配置文件中开启Feign的连接池功能:

    feign:
      okhttp:
        enabled: true # 开启OKHttp功能
    

重启服务,连接池就生效了。

验证:

我们可以打断点验证连接池是否生效,在org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient中的execute方法中打断点:

image-20251118211043525

Debug方式启动cart-service,请求一次查询我的购物车方法,进入断点:

image-20251118211104564

可以发现这里底层的实现已经改为OkHttpClient

最佳实践

有没有实现的最佳方式,所有要请求其他服务的都需要定义clinet接口吗,是不是重复了呢。我们还可以继续优化。

其实实现方式有多种,都还不错。主要方式有如下集中

  • 既然重复了,那都别写了。有服务提供者来写【因为提供的服务提供的人最清晰】。再服务下方不直接写java代码了,编程了pom项目,而是写三个模块

    image-20251118215520534

    • dto:放实体类。这样服务消费者也不需要重复复制实体类了。
    • api:放api接口,可以报feign客户端放进去
    • biz:写服务的所有业务代码

    接下来直接把前两个的模块坐标,引入到服务消费者项目中即可。

    缺点:项目结构太复杂了,微服务又要拆模块了。【这种适合大项目,那种把每个微服务拆成单个项目的拆分方式。加点模块也会很合理】【推荐】

  • 不管是提供者还是消费者,都不要写了。我们重新开个项目api。其中定义三个包

    image-20251118215541502

    • client:写整个项目每个微服务想要暴露的客户端
    • config
    • dto:所有需要公共访问的dto。

    然后涉及到的模块直接引用依赖即可。

    缺点:代码耦合度增加了。【这种适合中型的,那种把每个微服务拆成单个模块的拆分方式。因为已经够多了】

所以根据拆分情况,这里用第二种方式学习

  1. 新进api模块

  2. 引入相关依赖。比如openFeign和负载均衡器依赖【这样服务消费者的地方进不用引了。因为最后引api会带着走】。注意:这里没引okhttp依赖,可以让服务请求者自主选择连接方式。

    <?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">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>com.heima</groupId>
            <artifactId>hmall</artifactId>
            <version>1.0.0</version>
        </parent>
    
        <artifactId>hm-api</artifactId>
    
        <properties>
            <maven.compiler.source>11</maven.compiler.source>
            <maven.compiler.target>11</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <dependencies>
            <!--openFeign-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-openfeign</artifactId>
            </dependency>
            <!--负载均衡器-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-loadbalancer</artifactId>
            </dependency>
            <!-- swagger 注解依赖 -->
            <dependency>
                <groupId>io.swagger</groupId>
                <artifactId>swagger-annotations</artifactId>
                <version>1.6.6</version>
                <scope>compile</scope>
            </dependency>
        </dependencies>
    
    </project>
    
  3. com.hmall.api下创建client和dto包。并把客户端和实体类移过来。之前在服务消费者那就可以删除了

  4. 可以发现cart模块业务代码报错了,我们把api模块引入,就不报错了。

    <!--hm-api-->
    <dependency>
        <groupId>com.heima</groupId>
        <artifactId>hm-api</artifactId>
        <version>1.0.0</version>
    </dependency>
    
  5. 然后我们运行发现报错了。这里因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient,所以报错了。解决办法很简单,在cart-service的启动类上添加声明即可,两种方式:

    • 声明扫描包

      @MapperScan("com.hmall.cart.mapper")
      @EnableFeignClients(basePackages = "com.hmall.api.client")
      @SpringBootApplication
      public class CartApplication {略}
      
    • 声明要用的FeignClient

      @MapperScan("com.hmall.cart.mapper")
      @EnableFeignClients(clients = ItemClient.class)
      @SpringBootApplication
      public class CartApplication {略}
      

日志输出

OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。而且其日志级别有4级:

  • NONE:不记录任何日志信息,这是默认值。
  • BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
  • HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

由于Feign默认的日志级别就是NONE,所以默认我们看不到请求日志。只能看到一些sql日志。

调整日志级别的具体方法:

  1. 要自定义日志级别需要声明一个类型为Logger.Level的Bean,在其中定义日志级别:【可以放到api模块,避免重复定义】

    package com.hmall.api.config;
    
    import feign.Logger;
    import org.springframework.context.annotation.Bean;
    
    public class DefaultFeignConfig {
        @Bean
        public Logger.Level feignLoggerLevel() {
            return Logger.Level.FULL;
        }
    }
    
  2. 但此时这个Bean并未生效,要想配置某个FeignClient的日志,可以在@FeignClient注解中声明:

    @FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)
    
  3. 如果想要全局配置,让所有FeignClient都按照这个日志配置,则需要在@EnableFeignClients注解中声明:

    @EnableFeignClients(clients = {ItemClient.class}, defaultConfiguration = DefaultFeignConfig.class)
    

平时开发没报错的时候不用开启日志。否则太乱了,影响心情。

合并其他模块,详情见飞书文档:微服务拆分作业参考 - 飞书云文档。主要包含一下三个模块

  • 用户服务
  • 交易服务
  • 支付服务

tips:

  • 关于用户登录加密方式不尽相同,知道个大概就行了。不用太懂。

  • 实体类和api中引入的实体类重复了就可以不要其他服务的实体类了,直接删掉。

  • 写client接口时,可以直接参照对应请求的controller接口。直接复制即可。再稍作修改,比如补全请求地址去除public,有时参数类型和返回值根据情况改动即可。注意:要理解接口内核。对应的一定要有controller接口。没有的话也要写出来。因为是要通过请求的方式传数据的。

  • 如果一个服务消费者中要引入多个服务提供者时。可以通过扫描包的方式声明要用的FeignClient。

    @EnableFeignClients(basePackages = "com.hmall.api.client")
    

需要注意的是,再这个过程中,有很多地方都需要当前登录的用户信息。但目前只有user服务有。其他服务如何获取登录的用户信息是个问题。另外,目前有多个微服务,每个微服务都有自己的端口。我们前端应该如何进行对应访问,也是一个问题。那下面我们将进行解决。

网关

根据上面所说确实存在问题:

  1. 前端如何请求后端
  2. 各微服务如何获取登录用户信息。密钥如何管理,避免泄露和重复。

那网关就可以很好的解决上面的问题。相当于小区的保安管理整个小区(整个微服务集群)。这样前端只知道网关地址就行了。

image-20251119190204699

网关就是网络的关口,负责请求的路由、转发、身份校验

  • 路由:判断又哪个服务处理
  • 转发:将请求转发到具体服务,【这里的服务可能有多个实例。网关也可以看作一个服务,它会从注册中心管理获取微服务信息,然后负载均衡进行转发】
  • 身份校验

以后微服务地址也不用暴露给前端了,对微服务也是一种保护。对于前端和开发单体架构时没区别,小区就相当于黑盒。

在Spring Cloud中网关的实现包括两种:

Spring Cloud Gateway:【响应式编程更加强大。现为主流,这里主要学习这个】

  • Spring官方出品
  • 基于WebFlux响应式编程
  • 无需调优即可获得优异性能

Netfilx Zuul:

  • Netflix出品
  • 基于Servlet的阻塞式编程
  • 需要调优才能获得与SpringCloudGateway类似的性能

网关路由

快速入门

网关转发的动作(服务拉去,负载均衡,转发)是与业务无关的事情,可以由网关自动完成。 但网关的路由,即哪个请求由哪个服务去处理,这就和我们的业务有关了,这就需要开发人员配置路由规则了。

如何创建网关,分为一下四个步骤。

  • 创建网关微服务
  • 引入SpringCloudGateway、NacosDiscovery依赖
  • 编写启动类
  • 配置网关路由【可以看出,前三步和创建模块相同,这里重点关注如何配置】

predicatse路由规则,可以配置多个。等号左边是规则的名字,后面是规则。

具体实例如下:

  • 创建hm-gateway模块

  • 引入依赖

    <?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">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>com.heima</groupId>
            <artifactId>hmall</artifactId>
            <version>1.0.0</version>
        </parent>
    
        <artifactId>hm-gateway</artifactId>
    
        <properties>
            <maven.compiler.source>11</maven.compiler.source>
            <maven.compiler.target>11</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </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>
    
  • 编写启动类

    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);
        }
    }
    
  • 配置路由规则

    server:
      port: 8080
    spring:
      application:
        name: gateway
      cloud:
        nacos:
          server-addr: 192.168.150.101:8848
        gateway:
          routes:
            - id: item # 路由规则id,自定义,唯一
              uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
              predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
                - Path=/items/**,/search/** # 这里是以请求路径作为判断规则.等号左边是规则的名字,后面是规则。,如果有多个controller路径,可以用逗号分割。或直接写多行。
            - 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/**
    
  • 测试,用8080端口访问接口。相应正常,查看控制台,负载均衡正常。

路由属性

上面的内容在官网也是可以找到的,进行详细学习。Spring Cloud Gateway

网关路由对应的Java类型是RouteDefinition,其中常见的属性有:

  • id:路由唯一标示
  • uri:路由目标地址
  • predicates:路由断言,判断请求是否符合当前路由。【提供了有很多种,上节课只用了一种。下面进行详细介绍】
  • filters:路由过滤器,对请求或响应做特殊处理。【】

路由断言

Spring提供了12种基本的RoutePredicateFactory实现:

名称 说明 示例
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 权重处理 - Weight=group1, 2
XForwarded Remote Addr 基于请求的来源IP做判断 - XForwardedRemoteAddr=192.168.1.1/24

路由过滤器

网关中提供了33种路由过滤器,每种过滤器都有独特的作用。

名称 说明 示例
AddRequestHeader 给当前请求添加一个请求头 AddrequestHeader=headerName,headerValue
RemoveRequestHeader 移除请求中的一个请求头 RemoveRequestHeader=headerName
AddResponseHeader 给响应结果中添加一个响应头 AddResponseHeader=headerName,headerValue
RemoveResponseHeader 从响应结果中移除有一个响应头 RemoveResponseHeader=headerName
RewritePath 请求路径重写 RewritePath=/red/?(?<segment>.*), /$\{segment}
StripPrefix 去除请求路径中的N段前缀 StripPrefix=1,则路径/a/b转发时只保留/b
...

实例:

- id: pay
  uri: lb://pay-service
  predicates:
    - Path=/pay-orders/**
  filters:
    - AddResponseHeader=headerName, hello world

如果所有请求都要设置,那么可以进行统一设置【与routes同级】

default-filters: AddResponseHeader=headerName, hello world

网关登录校验

登录授权不用变,路由转发到user服务,给jwt密钥即可。但是验证需要网关了,同时给其他服务当前登录用户信息。

image-20251120154210726

网关也是服务,把信息放到请求头传递给其他微服务再合适不过了。

微服务之间的请求是openfeign的请求。而网关和微服务之间的请求是网关内置的请求。实现方式上有差别。

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

image-20251120154146511

可以看出,我们应该把校验过滤器放在NettyRoutingFilter之前的pre中。

下面我们将关注下面三个问题

  • 如何在网关转发之前做登录校验?
  • 网关如何将用户信息传递给微服务?
  • 如何在微服务之间传递用户信息?

自定义过滤器

网关过滤器有两种,分别是:

  • GatewayFilter:路由过滤器,作用于任意指定的路由;默认不生效,要配置到路由后生效。【就是上面在配置中学到的Filter】
  • GlobalFilter:全局过滤器,作用范围是所有路由;声明后自动生效。
    两种过滤器的过滤方法签名完全一致:

image-20251120204243674

GlobalFilter

其中有两个参数:

  • ServerWebExchange exchange

    请求上下文,包含整个过滤器链内共享数据,例如request、response等

  • GatewayFilterChain chain

    过滤器链。当前过滤器执行完后,要调用过滤器链中的下一个过滤器

方法中编写的都是pre逻辑

Mono,网关采用非阻塞式的编程,通过利用Mono定义回调函数。这样就不用等待了,将来返回结果有了,在调用回调函数,执行post逻辑。其实在实际开发中,大多数情况下基本不用post,用到了再到官网学习即可。

放行记得传递上下文,实现上下文信息

实例代码:

package com.hmall.gateway.filters;

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.http.server.reactive.ServerHttpRequest;
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 模拟登录校验逻辑
        //1.获取请求
        ServerHttpRequest request = exchange.getRequest();
        // 2.过滤器业务处理
        HttpHeaders headers = request.getHeaders();
        System.out.println("headers:" + headers);
        //3.放行,并传递上下文,实现上下文共享
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        // 过滤器执行顺序,值越小,优先级越高。比NettyRoutingFilter这个int范围中最大的即可。可通过源码看出(通过搜索定位)
        return 0;
    }
}

GatewayFilter

GatewayFilter在使用的过程中可以自由的去指定作用的范围,配置自定义参数,使用更加的灵活,但是要自定义一个GatewayFilter的话就比较麻烦了,我们日常开发中,要自定义过滤器,大部分都是选择GlobalFilter。所以不常用。

需要继承一个工厂。过滤器随参数变化而变化,每次变化都会有一个新的过滤器对象,因此就需要这个工厂类,来读取配置,创建一个定制化的过滤器对象(apply方法)。

自定义GatewayFilter不是直接实现GatewayFilter,而是实现AbstractGatewayFilterFactory。最简单的方式是这样的:

image-20251124153910308

@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    @Override
    public GatewayFilter apply(Object config) {
        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                // 获取请求
                ServerHttpRequest request = exchange.getRequest();
                // 编写过滤器逻辑
                System.out.println("过滤器执行了");
                // 放行
                return chain.filter(exchange);
            }
        };
    }
}

注意:该类的名称一定要以GatewayFilterFactory为后缀!前缀就是过滤器名字。过滤器可以无参。

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

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

可以装饰类(OrderedGatewayFilter)结合匿名类来控制生效顺序,当然也可以不用匿名类,没通过实现getOrder方法控制生效顺序。另外,这种过滤器还可以支持动态配置参数,不过实现起来比较复杂,示例:

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

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

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

}

然后在yaml文件中使用:

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

上面这种配置方式参数必须严格按照shortcutFieldOrder()方法的返回参数名顺序来赋值。

还有一种用法,无需按照这个顺序,就是手动指定参数名:

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

实现登录校验

我们日常开发中,一般是通过GlobalFilter的方式自定义过滤器。

在这个项目中,是通过jwt实现登录的,无论是jwt的token生成和校验,都是需要用到密钥和工具类的。保存密钥本事的jks文件也是加密的,需要密码。

image-20251124171624788

配置信息示例如下:

hm:
  jwt:
    location: classpath:hmall.jks
    alias: hmall
    password: hmall123 #解析密钥文件的密码
    tokenTTL: 30m
  auth:
    excludePaths: #相当于白名单
      - /search/**
      - /users/login
      - /items/**
      - /hi

配置文件的信息,通过属性类去读取。

其中JwtProperties和AuthProperties的类就是分别负责读取jwt和auth的属性值【用注释指定前缀区分】,代码如下:

JwtProperties

@Data
@ConfigurationProperties(prefix = "hm.jwt")
public class JwtProperties {
    private Resource location;
    private String password;
    private String alias;
    private Duration tokenTTL = Duration.ofMinutes(10);
}

AuthProperties

@Data
@Component
@ConfigurationProperties(prefix = "hm.auth")
public class AuthProperties {
    private List<String> includePaths;
    private List<String> excludePaths;
}

SecurityConfig

@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public KeyPair keyPair(JwtProperties properties){
        // 获取秘钥工厂
        KeyStoreKeyFactory keyStoreKeyFactory =
                new KeyStoreKeyFactory(
                        properties.getLocation(),
                        properties.getPassword().toCharArray());
        //读取钥匙对
        return keyStoreKeyFactory.getKeyPair(
                properties.getAlias(),
                properties.getPassword().toCharArray());
    }
}

JwtTool工具

package com.hmall.gateway.utils;

import cn.hutool.core.exceptions.ValidateException;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTValidator;
import cn.hutool.jwt.signers.JWTSigner;
import cn.hutool.jwt.signers.JWTSignerUtil;
import com.hmall.common.exception.UnauthorizedException;
import org.springframework.stereotype.Component;

import java.security.KeyPair;
import java.time.Duration;
import java.util.Date;

@Component
public class JwtTool {
    private final JWTSigner jwtSigner;

    public JwtTool(KeyPair keyPair) {
        this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);
    }

    /**
     * 创建 access-token
     *
     * @param userId 用户信息
     * @return access-token
     */
    public String createToken(Long userId, Duration ttl) {
        // 1.生成jws
        return JWT.create()
                .setPayload("user", userId)
                .setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
                .setSigner(jwtSigner)
                .sign();
    }

    /**
     * 解析token
     *
     * @param token token
     * @return 解析刷新token得到的用户信息
     */
    public Long parseToken(String token) {
        // 1.校验token是否为空
        if (token == null) {
            throw new UnauthorizedException("未登录");
        }
        // 2.校验并解析jwt
        JWT jwt;
        try {
            jwt = JWT.of(token).setSigner(jwtSigner);
        } catch (Exception e) {
            throw new UnauthorizedException("无效的token", e);
        }
        // 2.校验jwt是否有效
        if (!jwt.verify()) {
            // 验证失败
            throw new UnauthorizedException("无效的token");
        }
        // 3.校验是否过期
        try {
            JWTValidator.of(jwt).validateDate();
        } catch (ValidateException e) {
            throw new UnauthorizedException("token已经过期");
        }
        // 4.数据格式校验
        Object userPayload = jwt.getPayload("user");
        if (userPayload == null) {
            // 数据为空
            throw new UnauthorizedException("无效的token");
        }

        // 5.数据解析
        try {
           return Long.valueOf(userPayload.toString());
        } catch (RuntimeException e) {
            // 数据格式有误
            throw new UnauthorizedException("无效的token");
        }
    }
}

最后我们就可以写AuthGlobalFilter了

示例代码如下【传递用户信息省略了,后面补齐】

package com.hmall.gateway.filters;

import cn.hutool.core.text.AntPathMatcher;
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.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;
    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @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
        String token = null;
        List<String> headers = request.getHeaders().get("authorization");//返回列表,一个键可能对应多个值
        if (headers != null || !headers.isEmpty()) {
            token = headers.get(0);
        }
        // 4.校验并解析token
        Long userId = null;
        try {
            userId = jwtTool.parseToken(token);
        } catch (UnauthorizedException e) {
            //终止请求,可以抛异常,但页面状态码为500,可以更具体一点,通过设置http状态码终止请求
            //拦截,设置相应状态码
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return response.setComplete(); //complete,终止了
        }
        // TODO 5.传递用户信息
        System.out.println("userId" + userId);
        // 6.放行
        return chain.filter(exchange);
    }

    private boolean isExclude(String path) {
        for (String pathPattern : authProperties.getExcludePaths()) {
            //由于路径匹配比较特殊,这里用spring提供的AntPathMatcher
            if (antPathMatcher.match(pathPattern, path)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

网关传递用户

光验证还不够,还需要向下传递用户信息。

image-20251124203019970

微服务中的业务有很多,需要用户信息的业务有很多,不可能每个业务都写一遍,所以在微服务中,在业务之前获取用户信息,可以通过spring MVC的拦截器。放到TreadLocal中

要实现这个工程有一下步骤:

  1. 在网关的登录校验过滤器中,把获取到的用户写入请求头

    在上面的过滤器中修改一下5、6步骤即可

    // 5.传递用户信息
    String userInfo = userId.toString();
    ServerWebExchange swe = exchange.mutate()
            .request(builder -> builder.header("user-info", userInfo))
            .build();
    // 6.放行
    return chain.filter(swe);
    

    其中,mutate突变,对请求做处理,用builder对请求中的各种信息做修改。请求头的名字可以随便写,跟微服务开发中约定好就行。两边对上就行,可以写个常量。返回一个新的exchange。放行新的exchange

  2. 在hm-common中编写SpringMVC拦截器,获取登录用户

    由于每个微服务都可能有获取登录用户的需求,因此我们直接在hm-common模块定义拦截器,这样微服务只需要引入依赖即可生效,无需重复编写。

    在hm-common中已经有一个用于保存登录用户的ThreadLocal工具UserContext.其中已经提供了保存和获取用户的方法.只需要编写拦截器,获取用户信息并保存到UserContext,然后放行即可。这里的api略有不同。我们这里的拦截器不做拦截,只做信息获取就行了。

    public class UserInfoInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            //存用户
            // 1.获取用户信息
            String userInfo = request.getParameter("user-info");
            // 2.判断请求是否获取了用户,如果有,存入ThreadLocal
            if (StrUtil.isNotBlank(userInfo)) {
                UserContext.setUser(Long.valueOf(userInfo));
            }
            // 3.放行
            return true;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            //清理用户
            UserContext.removeUser();
        }
    }
    

    拦截器要配置才能生效。默认拦截所有的路径。这里创建配置类

    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());
        }
    }
    

    最后还要让微服务扫描到这个报下才能生效。学过springboot自动装配的原理可以知道,这里在resources/META-INF下的factories配置文件(这是老版本的配置文件)中加入扫描路径才行

    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.hmall.common.config.MyBatisConfig,\
      com.hmall.common.config.MvcConfig,\
      com.hmall.common.config.JsonConfig
    

    运行了一些发现报错了。这里网关也引入common,但网关中没有不是springmvc,而是非阻塞式响应式,基于webflix编程。没有mvcConfig就报错了。跟springboot自动装配的原理,可以给配置了加条件,进行有选择的装配。网关相比其他微服务,没有springmvc。我们根据这一点做有条件的装配

    @Configuration
    @ConditionalOnClass({DispatcherServlet.class}) //有条件的进行装配
    public class MvcConfig implements WebMvcConfigurer {
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            //这里默认对所有路径都生效,所以不用加.addPathPatterns("/**")了
            registry.addInterceptor(new UserInfoInterceptor());
        }
    }
    

思考:为什么不能直接把用户信息存到网关中?

参考:网关是存到请求头里了啊,跨服务没法使用threadlocal的,这个是因为所有服务都引用common所以在这里写一个拦截器存相当于全局的了。要理解common与网关的不同。common模块可以说是一个依赖包,引入就可以了

UserContext原理介绍

  1. ThreadLocal 核心原理

    ThreadLocal 为每个线程创建独立的变量副本,不同线程之间互不干扰。

  2. 为什么能准确存储"当前用户"

    • Web服务器(如Tomcat)为每个请求分配独立的线程
    • 同一个请求在整个处理过程中都在同一个线程中执行
    • ThreadLocal 将用户ID与当前线程绑定
  3. 线程安全

    • 每个线程独立存储,天然线程安全
    • 不需要同步锁,性能好

总之,ThreadLocal + 拦截器的组合实现了:

  • 请求级别的数据隔离
  • 优雅的上下文传递
  • 代码的简洁性
  • 良好的性能

可以这样理解:

  • 每个请求都会触发拦截器
  • 拦截器负责解析并设置用户信息到当前线程
  • 整个请求链路中都可以直接获取用户信息
  • 请求结束后必须清理,避免内存泄漏和用户信息混乱

OpenFeign传递用户

上面我们讲的是网关传递用户,但是对于复杂的业务,调用了比较长可以出现微服务直接的调用。

微服务项目中的很多业务要多个微服务共同合作完成,而这个过程中也需要传递登录用户信息,例如:

image-20251125105013911

这就与网关和微服务之间的传递不同了。

如果直接请求的话,第三方微服务是没有用户信息的,因为这是微服务之间的请求,没有通过网关,在现有的代码中,没有对请求头中加入用户信息,所以第三方微服务拿不到用户信息。

我们之前是通过OpenFeign实现微服务之间的请求的。OpenFeign中提供了一个拦截器接口,所有由OpenFeign发起的请求都会先调用拦截器处理请求,其中的RequestTemplate类中提供了一些方法可以让我们修改请求头。

拦截器放在api模块就行,只要用到了请求的逻辑,引入api模块,也就用了这个拦截器了。因为操作简单,没有必要重新定义一个拦截器了。可以用匿名内部类实现【这里放到了DefaultFeignConfig,当然也可以定义一个新的配置类实现】。

public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.NONE;
    }

    @Bean
    public RequestInterceptor userInfoRequestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                Long userId = UserContext.getUser();
                if (userId != null) {
                    template.header("user-info", userId.toString());
                }
            }
        };
    }
}

其中用到了UserContext。可以导一下common包。最后别忘了加注解,让配置类生效。

配置管理

有了上面的知识,其实已经具备了微服务开发的能力了。其他的小问题,不解决也能开发,解决了会更加便捷,是一个锦上添花的过程。配置管理就是这个过程。

我们还存在一下问题

  • 微服务重复配置过多,维护成本高

    我们在整个过着中,我们微服务的配置比较多,而且很多都是重复的。比如mysql数据库的配置、日志和swagger接口文档的配置等等。如果微服务数量成千上万的话,修改起来很繁琐。可以引入配置管理组件。对于通用的配置可以交给它配置。共享效果

  • 业务配置经常变动,每次修改都要重启服务

    登录超时时间,次数上限。数量上限等等。即便写道配置文件中,服务也要重启,用户体验不好。

  • 网关路由配置写死,如果变更要重启网关

    网关请求路由变更的话,重启的话,也很不好。

    以上都可以交给配置管理,监听配置变动,推送变更消息到对应服务。这样无需重启,立刻生效

image-20251125112422172

综上,配置管理主要有两个亮点

  • 共享效果
  • 无需重启,立即生效。实现配置热更新。

惊喜的是我们nacos还可以配置管理服务。降低了学习成本

image-20251125112530594

配置共享

未完待续……

posted @ 2025-11-05 21:51  韩熙隐ario  阅读(9)  评论(0)    收藏  举报