SpringCloud

较为详细地介绍了SpringCloud的使用

本篇文章基于黑马商城介绍SpringCloud

如有需要可以下载我的源码:IAmYuanyu/SpringCloudLearning-hmall: 黑马SpringCloud学习中所使用的黑马商城项目

其他资料:https://www.123865.com/s/SQzhTd-BhXld

认识微服务

单体架构

单体架构(monolithic structure):顾名思义,整个项目中所有功能模块都在一个工程中开发;项目部署时需要对所有模块一起编译、打包;项目的架构设计、开发模式都非常简单。

img

当项目规模较小时,这种模式上手快,部署、运维也都很方便,因此早期很多小型项目都采用这种模式。

但随着项目的业务规模越来越大,团队开发人员也不断增加,单体架构就呈现出越来越多的问题:

  • 团队协作成本高:试想一下,你们团队数十个人同时协作开发同一个项目,由于所有模块都在一个项目中,不同模块的代码之间物理边界越来越模糊。最终要把功能合并到一个分支,你绝对会陷入到解决冲突的泥潭之中。
  • 系统发布效率低:任何模块变更都需要发布整个系统,而系统发布过程中需要多个模块之间制约较多,需要对比各种文件,任何一处出现问题都会导致发布失败,往往一次发布需要数十分钟甚至数小时。
  • 系统可用性差:单体架构各个功能模块是作为一个服务部署,相互之间会互相影响,一些热点功能会耗尽系统资源,导致其它服务低可用。

微服务

微服务架构,首先是服务化,就是将单体架构中的功能模块从单体应用中拆分出来,独立部署为多个服务。同时要满足下面的一些特点:

  • 单一职责:一个微服务负责一部分业务功能,并且其核心数据不依赖于其它模块。
  • 团队自治:每个微服务都有自己独立的开发、测试、发布、运维人员,团队人员规模不超过10人(2张披萨能喂饱)
  • 服务自治:每个微服务都独立打包部署,访问自己独立的数据库。并且要做好服务隔离,避免对其它服务产生影响

例如,黑马商城项目,我们就可以把商品、用户、购物车、交易等模块拆分,交给不同的团队去开发,并独立部署:

img

那么,单体架构存在的问题有没有解决呢?

  • 团队协作成本高?
    • 由于服务拆分,每个服务代码量大大减少,参与开发的后台人员在1~3名,协作成本大大降低
  • 系统发布效率低?
    • 每个服务都是独立部署,当有某个服务有代码变更时,只需要打包部署该服务即可
  • 系统可用性差?
    • 每个服务独立部署,并且做好服务隔离,使用自己的服务器资源,不会影响到其它服务。

综上所述,微服务架构解决了单体架构存在的问题,特别适合大型互联网项目的开发,因此被各大互联网公司普遍采用。大家以前可能听说过分布式架构,分布式就是服务拆分的过程,其实微服务架构正式分布式架构的一种最佳实践的方案。

当然,微服务架构虽然能解决单体架构的各种问题,但在拆分的过程中,还会面临很多其它问题。比如:

  • 如果出现跨服务的业务该如何处理?
  • 页面请求到底该访问哪个服务?
  • 如何实现各个服务之间的服务隔离?

这些问题,将在后续的学习中逐一解答。

SpringCloud

微服务拆分以后碰到的各种问题都有对应的解决方案和微服务组件,而SpringCloud框架可以说是目前Java领域最全面的微服务组件的集合了。

img

而且SpringCloud依托于SpringBoot的自动装配能力,大大降低了其项目搭建、组件使用的成本。对于没有自研微服务组件能力的中小型企业,使用SpringCloud全家桶来实现微服务开发可以说是最合适的选择了!

微服务拆分

将黑马商城这个单体项目拆分为微服务项目,并解决其中出现的各种问题。

熟悉黑马商城

黑马商城项目的基本结构:

img

启动该项目,测试效果时需要修改数据库连接参数,在application-local.yaml中:

1
2
3
4
hm:
  db:
    host: 192.168.19.129 # 修改为你自己的虚拟机IP地址
    pw: 123 # 修改为docker中的MySQL密码

数据库的环境搭建见我项目源码中的README.md文件

同时配置启动项激活的是local环境:

img

登录

登录业务流程:

image-20251214155758500

登录入口在com.hmall.controller.UserController中的login方法:

img

搜索商品

在首页搜索框输入关键字,点击搜索即可进入搜索列表页面:

img

该页面会调用接口:/search/list,对应的服务端入口在com.hmall.controller.SearchController中的search方法:

img

这里目前是利用数据库实现了简单的分页查询。

购物车

在搜索到的商品列表中,点击按钮加入购物车,即可将商品加入购物车:

img

加入成功后即可进入购物车列表页,查看自己购物车商品列表:

img

同时这里还可以对购物车实现修改、删除等操作。

相关功能全部在com.hmall.controller.CartController中:

img

其中,查询购物车列表时,由于要判断商品最新的价格和状态,所以还需要查询商品信息,业务流程如下:

image-20251214160451314

下单

在购物车页面点击结算按钮,会进入订单结算页面:

img

点击提交订单,会提交请求到服务端,服务端做3件事情:

  • 创建一个新的订单
  • 扣减商品库存
  • 清理购物车中商品

业务入口在com.hmall.controller.OrderController中的createOrder方法:

img

支付

下单完成后会跳转到支付页面,目前只支持余额支付

img

在选择余额支付这种方式后,会发起请求到服务端,服务端会立刻创建一个支付流水单,并返回支付流水单号到前端。

当用户输入用户密码,然后点击确认支付时,页面会发送请求到服务端,而服务端会做几件事情:

  • 校验用户密码
  • 扣减余额
  • 修改支付流水状态
  • 修改交易订单状态

请求入口在com.hmall.controller.PayController中:

img

服务拆分原则

服务拆分一定要考虑几个问题:

  • 什么时候拆?
  • 如何拆?

什么时候拆

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

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

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

而对于一些大型项目,在立项之初目的就很明确,为了长远考虑,在架构设计时就直接选择微服务架构。虽然前期投入较多,但后期就少了拆分服务的烦恼(前难后易)。

怎么拆

之前我们说过,微服务拆分时粒度要小,这其实是拆分的目标。具体可以从两个角度来分析:

  • 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高。
  • 低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖,或者依赖接口的稳定性要强。

高内聚首先是单一职责,但不能说一个微服务就一个接口,而是要保证微服务内部业务的完整性为前提。目标是当我们要修改某个业务时,最好就只修改当前微服务,这样变更的成本更低。

一旦微服务做到了高内聚,那么服务之间的耦合度自然就降低了。

当然,微服务之间不可避免的会有或多或少的业务交互,比如下单时需要查询商品数据。这个时候我们不能在订单服务直接查询商品数据库,否则就导致了数据耦合。而应该由商品服务对应暴露接口,并且一定要保证微服务对外接口的稳定性(即:尽量保证接口外观不变)。虽然出现了服务间调用,但此时无论你如何在商品服务做内部修改,都不会影响到订单微服务,服务间的耦合度就降低了。

明确了拆分目标,接下来就是拆分方式了。我们在做服务拆分时一般有两种方式:

  • 纵向拆分
  • 横向拆分

所谓纵向拆分,就是按照项目的功能模块来拆分。例如黑马商城中,就有用户管理功能、订单管理功能、购物车功能、商品管理功能、支付功能等。那么按照功能模块将他们拆分为一个个服务,就属于纵向拆分。这种拆分模式可以尽可能提高服务的内聚性。

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

当然,由于黑马商城并不是一个完整的项目,其中的短信发送、风控管理并没有实现,这里就不再考虑了。而其它的业务按照纵向拆分,可以分为以下几个微服务:

  • 用户服务
  • 商品服务
  • 订单服务
  • 购物车服务
  • 支付服务

拆分购物车、商品服务

接下来,我们先把商品管理功能、购物车功能抽取为两个独立服务。

一般微服务项目有两种不同的工程结构:

  • 完全解耦:每一个微服务都创建为一个独立的工程(Project),甚至可以使用不同的开发语言来开发,项目完全解耦。
    • 优点:服务之间耦合度低
    • 缺点:每个项目都有自己的独立仓库,管理起来比较麻烦
  • Maven聚合:整个项目为一个Project,然后每个微服务是其中的一个Module
    • 优点:项目代码集中,管理和运维方便
    • 缺点:服务之间耦合,编译时间较长

商品服务

在hmall中创建module:

image-20251214172412265

在创建的item-service中导入依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?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>item-service</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    
    <dependencies>
        <!--common-->
        <dependency>
            <groupId>com.heima</groupId>
            <artifactId>hm-common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!--mybatis-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <!--单元测试-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</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>

先创建几个必然会用到的目录:

image-20251214173234668

编写启动类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.hmall.item;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("com.hmall.item.mapper")
@SpringBootApplication
public class ItemApplication {
    public static void main(String[] args) {
        SpringApplication.run(ItemApplication.class, args);
    }
}

将原本hm-service里的配置文件拷贝到item-service

image-20251214173408026

修改其中application.yaml文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
server:
  port: 8081
spring:
  application:
    name: item-service # 微服务名称
  profiles:
    active: dev
  datasource:
    url: jdbc:mysql://${hm.db.host}:3307/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:
  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

为商品服务创建一个新的database:

注意:在企业开发的生产环境中,每一个微服务都应该有自己的独立数据库服务,即再在Docker中创建一个MySQL,而不仅仅是在当前MySQL中新增一个database,此处演示用database来代替

执行资料中的hm-item.sql脚本(资料在文章最开头)

image-20251214174832074

将hm-service中与商品管理有关的代码拷贝到item-service

导入时,其中部分文件的import路径需要修改成当前目录

IDEA设置自动导入唯一声明

image-20251214180110238

导完参考目录:

image-20251214180512352

注意,其中的service实现类中有一段代码需要修改(第28行左右)

1
2
3
4
5
// 原本
String sqlStatement = "com.hmall.mapper.ItemMapper.updateStock";

// 应该改为
String sqlStatement = "com.hmall.item.mapper.ItemMapper.updateStock";

现在就可以启动测试了,启动前修改默认激活的配置

image-20251214180902171

服务菜单(Alt+8呼出)中若没有ItemApplication就刷新一下Maven或手动添加

接着,启动item-service,访问商品微服务的swagger接口文档:http://localhost:8081/doc.html

然后测试其中的根据id批量查询商品这个接口:

img

测试参数:100002672302,100002624500,100002533430,结果如下:

img

说明商品微服务抽取成功了。

购物车服务

操作与商品服务类似,此处仅列出其中部分

修改application.yaml文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
server:
  port: 8082
spring:
  application:
    name: cart-service # 微服务名称
  profiles:
    active: dev
  datasource:
    url: jdbc:mysql://${hm.db.host}:3307/hm-cart?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:
  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.cart.controller

复制service实现类时,其中的代码会报错,因为里面的代码设计到了其他模块的调用,现在先注释掉,在之后解决

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
/**
 * <p>
 * 订单详情表 服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2023-05-05
 */
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {

    // private final IItemService itemService;

    @Override
    public void addItem2Cart(CartFormDTO cartFormDTO) {
        // 1.获取登录用户
        Long userId = UserContext.getUser();

        // 2.判断是否已经存在
        if(checkItemExists(cartFormDTO.getItemId(), userId)){
            // 2.1.存在,则更新数量
            baseMapper.updateNum(cartFormDTO.getItemId(), userId);
            return;
        }
        // 2.2.不存在,判断是否超过购物车数量
        checkCartsFull(userId);

        // 3.新增购物车条目
        // 3.1.转换PO
        Cart cart = BeanUtils.copyBean(cartFormDTO, Cart.class);
        // 3.2.保存当前用户
        cart.setUserId(userId);
        // 3.3.保存到数据库
        save(cart);
    }

    @Override
    public List<CartVO> queryMyCarts() {
        // 1.查询我的购物车列表
        List<Cart> carts = lambdaQuery().eq(Cart::getUserId, 1L/* TODO UserContext.getUser() */).list();
        if (CollUtils.isEmpty(carts)) {
            return CollUtils.emptyList();
        }

        // 2.转换VO
        List<CartVO> vos = BeanUtils.copyList(carts, CartVO.class);

        // 3.处理VO中的商品信息
        handleCartItems(vos);

        // 4.返回
        return vos;
    }

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

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

    private void checkCartsFull(Long userId) {
        int count = lambdaQuery().eq(Cart::getUserId, userId).count();
        if (count >= 10) {
            throw new BizIllegalException(StrUtil.format("用户购物车课程不能超过{}", 10));
        }
    }

    private boolean checkItemExists(Long itemId, Long userId) {
        int count = lambdaQuery()
                .eq(Cart::getUserId, userId)
                .eq(Cart::getItemId, itemId)
                .count();
        return count > 0;
    }
}

参考目录:

image-20251214204000343

拆分后启动CartApplication(记得改配置),访问swagger文档页面:http://localhost:8082/doc.html

我们测试其中的查询我的购物车列表接口:

img

无需填写参数,直接访问:

img

注意到,其中与商品有关的几个字段值都为空。这就是因为刚才注释掉了查询商品信息的相关代码。

接下来将说明如何在cart-service服务中实现对item-service服务的查询

服务调用

在拆分的时候,发现一个问题:就是购物车业务中需要查询商品信息,但商品信息查询的逻辑全部迁移到了item-service服务,导致无法查询。

最终结果就是查询到的购物车数据不完整,因此要想解决这个问题,就必须改造其中的代码,把原本的本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。

因此,现在查询购物车列表的流程变成了这样:

image-20251214204236908

代码中需要变化的就是这一步:

img

那么问题来了:该如何跨服务调用,准确的说,如何在cart-service中获取item-service服务中的提供的商品数据呢?

前端向服务端查询数据,其实就是从浏览器远程查询服务端数据。比如刚才通过Swagger测试商品查询接口,就是向http://localhost:8081/items这个接口发起的请求:

img

而这种查询就是通过http请求的方式来完成的,不仅仅可以实现远程查询,还可以实现新增、删除等各种远程请求。

假如在cart-service中能模拟浏览器,发送http请求到item-service,不就实现了跨微服务的远程调用

那么该如何用Java代码发送Http的请求呢?

RestTemplate

Spring提供了一个RestTemplate的API,可以方便的实现Http请求的发送。

其中提供了大量的方法,方便发送Http请求,例如:

img

可以看到常见的Get、Post、Put、Delete请求都支持,如果请求参数比较复杂,还可以使用exchange方法来构造请求。

cart-service服务中定义一个配置类:

img

先将RestTemplate注册为一个Bean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.hmall.cart.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RemoteCallConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

远程调用

CartServiceImpl中要用到ItemDTO,所以先将ItemDTO复制到cart-service模块,之后再优化

修改CartServiceImpl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {

    private final RestTemplate restTemplate;

    @Override
    public void addItem2Cart(CartFormDTO cartFormDTO) {
        /* 没变化 */
    }

    @Override
    public List<CartVO> queryMyCarts() {
        /* 没变化 */
    }

    private void handleCartItems(List<CartVO> vos) {
        // 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", CollUtil.join(itemIds, ",")) // 请求参数
        );
        // 2.2 解析响应
        if (!response.getStatusCode().is2xxSuccessful()) {
            // 查询失败,直接结束
            return;
        }
        List<ItemDTO> items = response.getBody();

       /* 没变化 */
    }

    @Override
    public void removeByItemIds(Collection<Long> itemIds) {
        /* 没变化 */
    }
}

重启cart-service,再次测试查询购物车列表接口:

img

可以发现,所有商品相关数据都已经查询到了。

在这个过程中,item-service提供了查询接口,cart-service利用Http请求调用该接口。因此item-service可以称为服务的提供者,而cart-service则称为服务的消费者或服务调用者。

总结

什么时候需要拆分微服务?

  • 如果是创业型公司,最好先用单体架构快速迭代开发,验证市场运作模型,快速试错。当业务跑通以后,随着业务规模扩大、人员规模增加,再考虑拆分微服务。
  • 如果是大型企业,有充足的资源,可以在项目开始之初就搭建微服务架构。

如何拆分?

  • 首先要做到高内聚、低耦合
  • 从拆分方式来说,有横向拆分和纵向拆分两种。纵向就是按照业务功能模块,横向则是拆分通用性业务,提高复用性

服务拆分之后,不可避免的会出现跨微服务的业务,此时微服务之间就需要进行远程调用。微服务之间的远程调用被称为RPC,即远程过程调用。RPC的实现方式有很多,比如:

  • 基于Http协议
  • 基于Dubbo协议

前面使用的是Http方式,这种方式不关心服务提供者的具体技术实现,只要对外暴露Http接口即可,更符合微服务的需要。

Java发送http请求可以使用Spring提供的RestTemplate,使用的基本步骤如下:

  • 注册RestTemplate到Spring容器
  • 调用RestTemplate的API发送请求,常见方法有:
    • getForObject:发送Get请求并返回指定类型对象
    • PostForObject:发送Post请求并返回指定类型对象
    • put:发送PUT请求
    • delete:发送Delete请求
    • exchange:发送任意类型请求,返回ResponseEntity

服务注册和发现

在前面实现了微服务拆分,并且通过Http请求实现了跨微服务的远程调用。不过这种手动发送Http请求的方式存在一些问题。

试想一下,假如商品微服务被调用较多,为了应对更高的并发,我们进行了多实例部署,如图:

image-20251214212200320

此时,每个item-service的实例其IP或端口不同,问题来了:

  • item-service这么多实例,cart-service如何知道每一个实例的地址?
  • http请求要写url地址,cart-service服务到底该调用哪个实例呢?
  • 如果在运行过程中,某一个item-service实例宕机,cart-service依然在调用该怎么办?
  • 如果并发太高,item-service临时多部署了N台实例,cart-service如何知道新实例的地址?

为了解决上述问题,就必须引入注册中心的概念了,所以接下来分析下注册中心的原理。

注册中心原理

在微服务远程调用的过程中,包括两个角色:

  • 服务提供者:提供接口供其它微服务访问,比如item-service
  • 服务消费者:调用其它微服务提供的接口,比如cart-service

在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:

img

流程如下:

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

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

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

Nacos注册中心

目前开源的注册中心框架有很多,国内比较常见的有:

  • Eureka:Netflix公司出品,目前被集成在SpringCloud当中,一般用于Java应用
  • Nacos:Alibaba公司出品,目前被集成在SpringCloudAlibaba中,一般用于Java应用
  • Consul:HashiCorp公司出品,目前集成在SpringCloud中,不限制微服务语言

以上几种注册中心都遵循SpringCloud中的API规范,因此在业务开发使用上没有太大差异。由于Nacos是国内产品,中文文档比较丰富,而且同时具备配置管理功能(后面会学习),因此在国内使用较多,接下来会Nacos为例来学习。

此处基于Docker来部署Nacos的注册中心,首先要准备MySQL数据库表,用来存储Nacos的数据。

将资料中的SQL文件导入到Docker中的MySQL容器中:(和前面拆分服务时创建新database一样)

img

最终表结构如下:

img

然后修改资料中的nacos/custom.env文件,其中MYSQL_SERVICE_HOST也就是mysql地址,需要修改为你自己的虚拟机IP地址,其他内容也根据自己情况修改

image-20251214214954706

然后将资料中的nacos目录和nacos.tar上传至虚拟机的/root目录

在虚拟机/root目录执行docker load -i nacos.tar

nacos.tar为nacos的镜像,不传也行,只不过后面下载可能会比较慢

进入root目录,然后执行下面的docker命令:

1
2
3
4
5
6
7
8
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 ps查看是否成功部署

也可以执行docker logs -f nacos查看nacos输出的日志

可以在浏览器输入http://192.168.19.129:8848/nacos/查看控制台

注意将192.168.19.129替换为你自己的虚拟机IP地址

首次访问会跳转到登录页,账号密码都是nacos

img

服务注册

接下来,我们把item-service注册到Nacos,步骤如下:

  • 引入依赖
  • 配置Nacos地址
  • 重启

添加依赖

item-servicepom.xml中添加依赖:

1
2
3
4
5
<!--nacos 服务注册发现-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

配置Nacos地址

item-serviceapplication.yml中添加nacos地址配置:

1
2
3
4
5
6
spring:
  application:
    name: item-service # 服务名称
  cloud:
    nacos:
      server-addr: 192.168.19.129:8848 # nacos地址

启动服务实例

为了测试一个服务多个实例的情况,再配置一个item-service的部署实例:

image-20251214222042231

image-20251214221954260

启动新部署的实例和重启原本的ItemApplication,然后重新打开Nacos控制台应该就能看见服务了

image-20251214222446720

服务发现

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

  • 引入依赖
  • 配置Nacos地址
  • 发现并调用服务

引入依赖

cart-service中的pom.xml中添加下面的依赖:

1
2
3
4
5
<!--nacos 服务注册发现-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

可以发现,这里Nacos的依赖于服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者。

因此,等一会儿cart-service启动,同样会注册到Nacos

配置Nacos地址

cart-serviceapplication.yml中添加nacos地址配置:

1
2
3
4
5
6
spring:
  application:
    name: item-service # 服务名称
  cloud:
    nacos:
      server-addr: 192.168.19.129:8848 # nacos地址

发现并调用服务

接下来,服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。

因此,服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:

  • 随机
  • 轮询
  • IP的hash
  • 最近最少访问

这里选择最简单的随机负载均衡。

对CartServiceImpl进行修改

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@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 = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
        // 2.查询商品
        // 2.1 根据服务器名称获取服务的实例列表
        List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
        if (CollUtils.isEmpty(instances)) {
            return;
        }
        // 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>>() {}, // 响应类型
                Map.of("ids", CollUtil.join(itemIds, ",")) // 请求参数
        );
        // 2.4 解析响应
        if (!response.getStatusCode().is2xxSuccessful()) {
            // 查询失败,直接结束
            return;
        }
        List<ItemDTO> items = response.getBody();

        if (CollUtils.isEmpty(items)) {
            return;
        }
        
        /* 原本的代码 */
}

重启CartApplication,打开Nacos控制台可以看见对应服务,接下来测试查询购物车列表接口,观察ItemApplication和ItemApplication2的运行日志(可以再关闭其中一个进一步观察),发现确实实现了服务发现功能

OpenFeign

前面利用Nacos实现了服务的治理,利用RestTemplate实现了服务的远程调用。但是远程调用的代码太复杂了:

img

而且这种调用方式,与原本的本地方法调用差异太大,编程时的体验也不统一,一会儿远程调用,一会儿本地调用。

因此,我们必须想办法改变远程调用的开发模式,让远程调用像本地方法调用一样简单。而这就要用到OpenFeign组件了。

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

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

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

快速入门

以cart-service中的查询购物车为例

引入依赖

cart-service服务的pom.xml中引入OpenFeign的依赖和loadBalancer依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!--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>

启用OpenFeign

cart-serviceCartApplication启动类上添加注解,启动OpenFeign功能:

img

编写OpenFeign客户端

cart-service中,定义一个新的接口,编写Feign客户端:

其中代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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.List;

@FeignClient("item-service")
public interface ItemClient {

    @GetMapping("/items")
    List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}

这里只需要声明接口,无需实现方法。接口中的几个关键信息:

  • @FeignClient("item-service") :声明服务名称
  • @GetMapping :声明请求方式
  • @GetMapping("/items") :声明请求路径
  • @RequestParam("ids") Collection<Long> ids :声明请求参数
  • List<ItemDTO> :返回值类型

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

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

使用FeignClient

cart-servicecom.hmall.cart.service.impl.CartServiceImpl中改造代码,直接调用ItemClient的方法:

img

feign替我们完成了服务拉取、负载均衡、发送http请求的所有工作

而且,这里不再需要RestTemplate了,还省去了RestTemplate的注册。

使用连接池

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

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

因此通常会使用带有连接池的客户端来代替默认的HttpURLConnection。

接下来将使用OK Http替换原本的HttpURLConnection

引入依赖

cart-servicepom.xml中引入依赖:

1
2
3
4
5
<!--OK http 的依赖 -->
<dependency>
  <groupId>io.github.openfeign</groupId>
  <artifactId>feign-okhttp</artifactId>
</dependency>

开启连接池

cart-serviceapplication.yml配置文件中开启Feign的连接池功能:

1
2
3
feign:
  okhttp:
    enabled: true # 开启OKHttp功能

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

验证

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

img

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

img

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

最佳实践

将来要把与下单有关的业务抽取为一个独立微服务:trade-service,不过现在先来看一下hm-service中原本与下单有关的业务逻辑。

入口在com.hmall.controller.OrderControllercreateOrder方法,然后调用了IOrderService中的createOrder方法。

由于下单时前端提交了商品id,为了计算订单总价,需要查询商品信息:

img

也就是说,如果拆分了交易微服务(trade-service),它也需要远程调用item-service中的根据id批量查询商品功能。这个需求与cart-service中是一样的。

因此,就需要在trade-service中再次定义ItemClient接口,这不是重复编码吗? 有什么办法能加避免重复编码呢?

思路分析

避免重复编码的办法就是抽取。不过这里有两种抽取思路:

  • 思路1:抽取到微服务之外的公共module
  • 思路2:每个微服务自己抽取一个module

如图:

img

方案1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。

方案2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。

此处使用方案1

抽取Feign客户端

hmall下定义一个新的module,命名为hm-api

img

其pom文件导入如下依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?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>

然后把ItemDTO和ItemClient都从cart-service中剪切过来,最终结构如下:

img

然后重新导入一下CartServiceImpl中import爆红的部分

现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了。

在cart-service中导入hm-api

1
2
3
4
5
6
<!-- hm-api -->
<dependency>
    <groupId>com.heima</groupId>
    <artifactId>hm-api</artifactId>
    <version>1.0.0</version>
</dependency>

此时可以删除cart-service中的openFeign和负载均衡器的依赖了

扫描包

重启项目,发现报错了:

img

这里是因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient,所以报错了。

解决办法很简单,在cart-service的启动类上添加声明即可,两种方式:

  • 方式1:声明扫描包:
1
@EnableFeignClients(basePackages = "com.hmall.api.client")

img

  • 方式2:声明要用的FeignClient
1
@EnableFeignClients(clients = {ItemClient.class})

img

日志配置

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

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

Feign默认的日志级别就是NONE,所以默认看不到请求日志

定义日志级别

在hm-api模块下新建一个配置类,定义Feign的日志级别:

img

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package com.hmall.api.config;

import feign.Logger;
import org.springframework.context.annotation.Bean;

public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.FULL;
    }
}

配置

接下来,要让日志级别生效,还需要配置这个类。有两种方式:

  • 局部生效:在某个FeignClient中配置,只对当前FeignClient生效
1
@FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)
  • 全局生效:在启动类上的@EnableFeignClients中配置,针对所有FeignClient生效。
1
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)

日志格式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
17:35:32:148 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] ---> GET http://item-service/items?ids=100000006163 HTTP/1.1
17:35:32:148 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] ---> END HTTP (0-byte body)
17:35:32:278 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] <--- HTTP/1.1 200  (127ms)
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] connection: keep-alive
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] content-type: application/json
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] date: Fri, 26 May 2023 09:35:32 GMT
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] keep-alive: timeout=60
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] transfer-encoding: chunked
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] 
17:35:32:280 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] [{"id":100000006163,"name":"巴布豆(BOBDOG)柔薄悦动婴儿拉拉裤XXL码80片(15kg以上)","price":67100,"stock":10000,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t23998/350/2363990466/222391/a6e9581d/5b7cba5bN0c18fb4f.jpg!q70.jpg.webp","category":"拉拉裤","brand":"巴布豆","spec":"{}","sold":11,"commentCount":33343434,"isAD":false,"status":2}]
17:35:32:281 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] <--- END HTTP (369-byte body)

拆分剩余三个模块

拆分的具体步骤此处就省略了,可以参考我的源码

网关路由

认识网关

顾明思议,网关就是络的口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验

更通俗的来讲,网关就像是以前园区传达室的大爷。

  • 外面的人要想进入园区,必须经过大爷的认可,如果你是不怀好意的人,肯定被直接拦截。
  • 外面的人要传话或送信,要找大爷。大爷帮你带给目标人。

img

现在,微服务网关就起到同样的作用。前端请求不能直接访问微服务,而是要请求网关:

  • 网关可以做安全控制,也就是登录身份校验,校验通过才放行
  • 通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去

img

在SpringCloud当中,提供了两种网关实现方案:

  • Netflix Zuul:早期实现,目前已经淘汰
  • SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强

接下来以SpringCloudGateway为例

快速入门

接下来先看下如何利用网关实现请求路由。由于网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能。大概步骤如下:

  • 创建网关微服务
  • 引入SpringCloudGateway、NacosDiscovery依赖
  • 编写启动类
  • 配置网关路由

创建项目

在hmall下创建一个新的module,命名为hm-gateway,作为网关微服务:

img

引入依赖

hm-gateway模块的pom.xml文件中引入依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>hm-gateway</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    
    <dependencies>
        <!--common-->
        <dependency>
            <groupId>com.heima</groupId>
            <artifactId>hm-common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!--nacos discovery-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--负载均衡-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

启动类

hm-gateway模块的com.hmall.gateway包下新建一个启动类:

img

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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);
    }
}

配置路由

接下来,在hm-gateway模块的resources目录新建一个application.yaml文件,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server:
  port: 8080
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 192.168.19.129:8848
    gateway:
      routes:
        - id: item # 路由规则id,自定义,唯一
          uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
          predicates: # 路由断言,判断当前请求是否符合下方规则,符合则路由到目标服务
            - Path=/items/**,/search/** # 这里是以请求路径作为判断规则
        - id: cart
          uri: lb://cart-service
          predicates:
            - Path=/carts/**
        - id: user
          uri: lb://user-service
          predicates:
            - Path=/users/**,/addresses/**
        - id: trade
          uri: lb://trade-service
          predicates:
            - Path=/orders/**
        - id: pay
          uri: lb://pay-service
          predicates:
            - Path=/pay-orders/**

测试

启动GatewayApplication,以 http://localhost:8080 拼接微服务接口路径来测试。例如:

http://localhost:8080/items/page?pageNo=1&pageSize=1

img

此时,启动UserApplication、CartApplication,然后打开前端页面,发现相关功能都可以正常访问了

如果响应500访问失败,并且控制台提示NoSuchFieldError: HighGo ,就修改父工程(hmall)的pom文件中的MybatisPlus的版本为3.5.3

image-20251216195543446

修改后com.hmall.cart.service.impl.CartServiceImpl中的checkCartsFull方法和checkItemExists方法里面可能会报错,把count的数据类型改为Long即可

image-20251216195747913

然后重启所有服务应该就能正常访问了

路由属性

路由规则的定义语法如下:

1
2
3
4
5
6
7
8
spring:
  cloud:
    gateway:
      routes:
        - id: item
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**

其中routes对应的类型如下:

img

是一个集合,也就是说可以定义很多路由规则。集合中的RouteDefinition就是具体的路由规则定义,其中常见的属性如下:

img

四个属性含义如下:

  • id:路由的唯一标示
  • predicates:路由断言,其实就是匹配条件
  • filters:路由过滤条件,后面讲
  • uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。

其中的predicates,也就是路由断言。SpringCloudGateway中支持的断言类型有很多:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些header - Header=X-Request-Id, \d+
Host 请求必须是访问某个host(域名) - Host=**.somehost.org,**.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack或者- Query=name
RemoteAddr 请求者的ip必须是指定范围 - RemoteAddr=192.168.1.1/24
weight 权重处理 - Weight=group1,2
XForwarded Remote Addr 基于请求的来源 IP 做判断 - XForwardedRemoteAddr=192.168.1.1/24

还有filters,也就是过滤器,网关中提供了33种路由过滤器,此处仅展示部分

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

过滤器可以配置在routes下级作为某个路由的过滤器,也可以配置在routes同级的位置作为全局默认过滤器

image-20251216205912664

网关登录校验

单体架构时只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做登录校验,这显然不可取。

鉴权思路分析

此项目的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:

  • 每个微服务都需要知道JWT的秘钥,不安全
  • 每个微服务重复编写登录校验代码、权限校验代码,麻烦

既然网关是所有微服务的入口,一切请求都需要先经过网关。那么完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:

  • 只需要在网关和用户服务保存秘钥
  • 只需要在网关开发登录校验功能

此时,登录校验的流程如图:

image-20251216203905873

不过,这里存在几个问题:

  • 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
  • 网关校验JWT之后,如何将用户信息传递给微服务?
  • 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?

这些问题将在接下来一一解决

网关过滤器

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

image-20251216204514944

如图所示:

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

如图中所示,最终请求转发是有一个名为NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前,这就符合需求了!

而用户信息也可以通过请求头传递,现在前面的问题就有解决思路了

image-20251216205337194

自定义过滤器

那么,该如何实现一个网关过滤器呢?

网关过滤器链中的过滤器有两种:

  • GatewayFilter:路由过滤器,作用于任意指定的路由,可以配置自定义参数;默认不生效,要配置到路由后才生效
  • GlobalFilter:全局过滤器,作用范围是所有路由;声明后自动生效

注意:过滤器链之外还有一种过滤器,HttpHeadersFilter,用来处理传递到下游微服务的请求头

例如:org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter可以传递代理请求原本的host头到下游微服务。

其实GatewayFilterGlobalFilter这两种过滤器的方法签名完全一致,都是:

1
2
3
4
5
6
7
/**
 * 处理请求并将其传递给下一个过滤器
 * @param exchange 当前请求的上下文,包含整个过滤器链内共享数据,如request、response等各种数据
 * @param chain 过滤器链,基于它向下传递请求
 * @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
 */
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

自定义GlobalFilter

首先自定义一个过滤器类实现GlobalFilter,在hm-gateway模块的com.hmall.gateway包下新建一个filters包,在其中创建一个AuthGlobalFilter类

1
public class AuthGlobalFilter implements GlobalFilter {}

在编写过滤器内容之前,要确保自定义的过滤器的执行顺序在NettyRoutingFilter之前

找到NettyRoutingFilter,发现它除了实现类GlobalFilter还实现了Ordered接口,而

Ordered就是用来排序的。Ordered接口要求重新一个getOrder()方法,以其返回值作为排序顺序(spring中大多排序都是如此实现的),返回值越大,优先级越低,返回值越小,优先级越高(-2147483647是最高优先级)。可以看见NettyRoutingFilter的执行优先级是最低的

image-20251216211605699

所以,只需要让自定义的过滤器也实现Ordered接口并且优先级比NettyRoutingFilter高即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // TODO 登录校验逻辑
        // 放行
        return chain.filter(exchange);
    }
    
    @Override
    public int getOrder() {
        return 0;
    }
}

具体的登录校验逻辑在后续实现

自定义GatewayFilter

要自定义GatewayFilter比较复杂,且一般在开发中使用较少,此处直接复制黑马官方文档内容

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@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配置中这样使用:

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

另外,这种过滤器还可以支持动态配置参数,不过实现起来比较复杂,示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@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文件中使用:

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

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

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

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

登录校验

接下来就利用自定义GlobalFilter来完成登录校验

JWT工具

登录校验需要用到JWT,而且JWT的加密需要秘钥和加密工具。这些在hm-service中已经有了,直接拷贝过来:

img

其中AuthProperties复制过来后,需要在该类的上方加上@Component注解

具体作用如下:

  • AuthProperties:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问
  • JwtProperties:定义与JWT工具有关的属性,比如秘钥文件位置
  • SecurityConfig:工具的自动装配
  • JwtTool:JWT工具,其中包含了校验和解析token的功能
  • hmall.jks:秘钥文件

其中AuthPropertiesJwtProperties所需的属性要在application.yaml中配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
hm:
  jwt:
    location: classpath:hmall.jks # 秘钥地址
    alias: hmall # 秘钥别名
    password: hmall123 # 秘钥文件密码
    tokenTTL: 30m # 登录有效期
  auth:
    excludePaths: # 无需登录校验的路径
      - /search/**
      - /users/login
      - /items/**

登录校验过滤器

重新回到前面定义的AuthGlobalFilter中开始编写登录校验逻辑

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.hmall.gateway.filters;

import cn.hutool.core.text.AntPathMatcher;
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> tokenList = request.getHeaders().get("Authorization");
        if (tokenList != null && !tokenList.isEmpty()) {
            token = tokenList.get(0); // token就只有一个,所以直接取第一个
        }

        // 4. 校验并解析token
        Long userId = null;
        try {
            userId = jwtTool.parseToken(token);
        } catch (Exception e) {
            // 拦截并设置响应状态码为401
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return response.setComplete(); // setComplete()表示响应完成
        }

        // TODO 一会再实现 5. 传递用户信息
        System.out.println("userId = " + userId);

        // 6. 放行
        return chain.filter(exchange);
    }

    // 判断是否需要登录拦截
    private boolean isExclude(String path) {
        // 遍历可放行路径,判断是否与传入路径(当前请求访问路径)匹配
        // 可放行路径中含有通配符,使用AntPathMatcher进行匹配
        for (String pathPattern : authProperties.getExcludePaths()) {
            if (antPathMatcher.match(pathPattern, path)) {
                return true;
            }
        }
        return false;
    }

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

重启测试,会发现访问/items开头的路径,未登录状态下不会被拦截:

img

访问其他路径则,未登录状态下请求会被拦截,并且返回401状态码:

img

网关传递用户信息

现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?

由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。

据图流程图如下:

img

因此,接下来要做的事情有:

  • 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
  • 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行

保存用户到请求头

首先,修改登录校验拦截器的处理逻辑,保存用户信息到请求头中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
    /* 原本的代码 */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        /* 原本的代码 */
        // 5. 传递用户信息
        String userInfo = userId.toString();
        // 创建新的ServerWebExchange,并设置用户信息
        ServerWebExchange serverWebExchange = exchange.mutate()
                .request(builder -> builder.header("user-info", userInfo))
                .build();

        // 6. 放行
        return chain.filter(serverWebExchange);
    }
    /* 原本的代码 */
}

配置拦截器获取用户

在hm-common中已经有一个用于保存登录用户的ThreadLocal工具:

img

其中已经提供了保存和获取用户的方法:

img

接下来只需要编写拦截器,获取用户信息并保存到UserContext,然后放行即可。

由于每个微服务都有获取登录用户的需求,因此拦截器直接写在hm-common中,并写好自动装配。这样微服务只需要引入hm-common就可以直接具备拦截器功能,无需重复编写。

hm-common模块下定义一个拦截器:

img

具体代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.hmall.common.interceptor;

import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class UserInfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的用户信息
        String userInfo = request.getHeader("user-info");
        // 2.判断是否为空
        if (StrUtil.isNotBlank(userInfo)) {
            // 不为空,保存到ThreadLocal
            UserContext.setUser(Long.valueOf(userInfo));
        }
        // 3.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserContext.removeUser();
    }
}

接着在hm-common模块下编写SpringMVC的配置类,配置刚刚定义的拦截器:

image-20251217180046625

具体代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.hmall.common.config;

import com.hmall.common.interceptor.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInfoInterceptor());
    }
}

不过,需要注意的是,这个配置类默认是不会生效的,因为它所在的包是com.hmall.common.config,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效。

基于SpringBoot的自动装配原理,我们要将其添加到resources目录下的META-INF/spring.factories文件中:

img

内容如下:

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

解决拦截器配置类在网关报错

此时重启服务,却发现网关报错了

image-20251217181102715

提示WebMvcConfigurer找不到,而WebMvcConfigurer是刚刚定义的SpringMVC配置类MvcConfig的实现接口

这是因为WebMvcConfigurer属于SpringMVC包下,底层是基于Servlet的阻塞式编程,而网关底层是基于WebFlux的响应式编程,所以网关里面根本不包含SpringMVC,当然也就不存在WebMvcConfigurer,自然就会报错

那么如何使MvcConfig只在微服务中生效而不在网关中生效呢?

可以基于SpringBoot的自动装配原理,给MvcConfig配置类加个条件使得其只在微服务中生效,那么什么条件能实现这种效果呢?

可以在MvcConfig类上加一个@ConditionalOnClass(DispatcherServlet.class),使得MvcConfig只在DispatcherServlet类存在时生效,而DispatcherServlet是SpringMVC的核心API,只要有SpringMVC就一定存在。而网关没有SpringMVC也就没有DispatcherServlet类,正好符合了要求

所以现在的MvcConfig类如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.hmall.common.config;

import com.hmall.common.interceptor.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInfoInterceptor());
    }
}

现在就能正常启动了

恢复购物车代码

之前无法获取登录用户,所以把购物车服务的登录用户写死了,现在需要恢复到原来的样子。

找到cart-service模块的com.hmall.cart.service.impl.CartServiceImpl

img

修改其中的queryMyCarts方法:

img

OpenFeign传递用户信息

前端发起的请求都会经过网关再到微服务,由于之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。

但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:

img

下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!

由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头

微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?

这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor

1
2
3
4
5
6
7
8
public interface RequestInterceptor {

  /**
   * Called for every request. 
   * Add data using methods on the supplied {@link RequestTemplate}.
   */
  void apply(RequestTemplate template);
}

其中的RequestTemplate类中提供了一些方法可以修改请求头,只需要实现这个接口,然后实现apply方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中。这样一来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。

由于FeignClient全部都是在hm-api模块,因此我们在hm-api模块的com.hmall.api.config.DefaultFeignConfig中编写这个拦截器:

img

com.hmall.api.config.DefaultFeignConfig中添加一个Bean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
    return new RequestInterceptor() {
        @Override
        public void apply(RequestTemplate template) {
            // 获取登录用户
            // 网关传递用户信息给了被调用的微服务,这个微服务就可以拿到用户信息从而传递给它要调用到的下一个微服务
            Long userId = UserContext.getUser();
            if(userId == null) {
                // 如果为空则直接跳过
                return;
            }
            // 如果不为空则放入请求头中,传递给下游微服务
            template.header("user-info", userId.toString());
        }
    };
}

因为UserContext定义在hm-common中,所以要在hm-api中导入对应依赖

1
2
3
4
5
6
<!-- 通用工具类 -->
<dependency>
    <groupId>com.heima</groupId>
    <artifactId>hm-common</artifactId>
    <version>1.0.0</version>
</dependency>

要想DefaultFeignConfig类的配置生效,要在使用到OpenFeign的微服务的启动类上的@EnableFeignClients注解中加上defaultConfiguration = DefaultFeignConfig.class

如,交易服务的启动类应为:

1
2
3
4
@MapperScan("com.hmall.pay.mapper")
@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = DefaultFeignConfig.class)
@SpringBootApplication
public class PayApplication

这些在之前拆分的时候都应该写好了,这里以防万一提醒一下

现在微服务之间通过OpenFeign调用时也会传递登录用户信息了。

配置管理

到目前为止已经解决了微服务相关的几个问题:

  • 微服务远程调用
  • 微服务注册、发现
  • 微服务请求路由、负载均衡
  • 微服务登录用户信息传递

不过,现在依然还有几个问题需要解决:

  • 网关路由在配置文件中写死了,如果变更必须重启
  • 某些业务配置在配置文件中写死了,每次修改都要重启服务
  • 每个微服务都有很多重复的配置,维护成本高

这些问题都可以通过统一的配置管理器服务解决。而Nacos不仅仅具备注册中心功能,也具备配置管理的功能:

img

微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。

网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置。

配置共享

可以把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。分为两步:

  • 在Nacos中添加共享配置
  • 微服务拉取配置

添加共享配置

以cart-service为例,我们看看有哪些配置是重复的,可以抽取的:

首先是jdbc相关配置:

img

确认好配置信息后在nacos控制台分别添加这些配置。

首先是jdbc相关配置,在配置管理->配置列表中点击+新建一个配置:

img

在弹出的表单中填写信息:

image-20251217211843979

其中详细的配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
spring:
  datasource:
    url: jdbc:mysql://${hm.db.host:192.168.19.129}:${hm.db.port:3307}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: ${hm.db.un:root}
    password: ${hm.db.pw:123}
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto

注意这里的jdbc的相关参数并没有写死,例如:${hm.db.host:192.168.19.129}表示读取hm.db.host的配置信息,如果为空则默认取192.168.19.129,其他同理

然后是统一的日志配置,命名为shared-log.yaml,配置内容如下:

image-20251217212241431

1
2
3
4
5
6
7
logging:
  level:
    com.hmall: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"

然后是统一的swagger配置,命名为shared-swagger.yaml,配置内容如下:

image-20251217212526263

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
knife4j:
  enable: true
  openapi:
    title: ${hm.swagger.title:黑马商城接口文档}
    description: ${hm.swagger.desc:黑马商城接口文档}
    email: ${hm.swagger.email:zhanghuyi@itcast.cn}
    concat: ${hm.swagger.concat:虎哥}
    url: https://www.itcast.cn
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - ${hm.swagger.package}

拉取共享配置

接下来要在微服务拉取共享配置。将拉取到的共享配置与本地的application.yaml配置合并,完成项目上下文的初始化。

不过,需要注意的是,读取Nacos配置是SpringCloud上下文(ApplicationContext)初始化时处理的,发生在项目的引导阶段。然后才会初始化SpringBoot上下文,去读取application.yaml

也就是说引导阶段,application.yaml文件尚未读取,根本不知道nacos 地址,该如何去加载nacos中的配置文件呢?

SpringCloud在初始化上下文的时候会先读取一个名为bootstrap.yaml(或者bootstrap.properties)的文件,如果我们将nacos地址配置到bootstrap.yaml中,那么在项目引导阶段就可以读取nacos中的配置了。

img

因此,微服务整合Nacos配置管理的步骤如下:

1)引入依赖:

在cart-service模块引入依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  <!--nacos配置管理-->
  <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
  </dependency>
  <!--读取bootstrap文件-->
  <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-bootstrap</artifactId>
  </dependency>

2)新建bootstrap.yaml

在cart-service中的resources目录新建一个bootstrap.yaml文件:

img

内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
spring:
  application:
    name: cart-service # 服务名称
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr: 192.168.19.129 # nacos地址
      config:
        file-extension: yaml # 文件后缀名(刚刚在nacos控制台新建配置时选择的配置格式)
        shared-configs: # 共享配置
          - dataId: shared-jdbc.yaml # 共享mybatis配置
          - dataId: shared-log.yaml # 共享日志配置
          - dataId: shared-swagger.yaml # 共享swagger配置

3)修改application.yaml

由于一些配置挪到了bootstrap.yaml,因此cart-service的application.yaml需要修改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
server:
  port: 8082
feign:
  okhttp:
    enabled: true # 开启OKHttp功能
hm:
  db:
    database: hm-cart
    swagger:
      title: "黑马商城购物车接口文档"
      desc: "黑马商城购物车接口文档"
      package: com.hmall.cart.controller

重启服务,发现所有配置都生效了。

配置热更新

有很多的业务相关参数,将来可能会根据实际情况临时调整。例如购物车业务,购物车数量有一个上限,默认是10,对应代码如下:

img

现在这里购物车是写死的固定值,我们应该将其配置在配置文件中,方便后期修改。

但现在的问题是,即便写在配置文件中,修改了配置还是需要重新打包、重启服务才能生效。能不能不用重启,直接生效呢?

这就要用到Nacos的配置热更新能力了,分为两步:

  • 在Nacos中添加配置
  • 在微服务读取配置

添加配置到Nacos

首先在nacos中添加一个配置文件,将购物车的上限数量添加到配置中:

img

注意文件的dataId格式:

1
[服务名]-[spring.active.profile].[后缀名]

文件名称由三部分组成:

  • 服务名:这里是购物车服务,所以是cart-service
  • spring.active.profile:就是spring boot中的spring.active.profile(环境变量,配置如dev、local等环境),可以省略,则所有profile共享该配置
  • 后缀名:例如yaml

这里使用cart-service.yaml这个名称,则不管是dev还是local环境都可以共享该配置。

配置内容如下:

1
2
3
hm:
  cart:
    maxAmount: 1 # 购物车商品数量上限

提交配置,在控制台能看到新添加的配置:

img

配置热更新

接着在微服务中读取配置,实现配置热更新。

cart-service中新建一个属性读取类:

img

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.hmall.cart.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
    private Integer maxAmount;
}

接着,在业务中使用该属性加载类:

img

测试,向购物车中添加多个商品:

img

我们在nacos控制台,将购物车上限配置为5:

img

无需重启,再次测试购物车功能:

img

加入成功!

无需重启服务,配置热更新就生效了!

动态路由

网关的路由配置全部是在项目启动时由org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator加载,并且一经加载就会缓存到内存中的路由表内(一个Map),不会改变。也不会监听路由变更,所以,无法利用前面的配置热更新来实现路由更新。

因此,必须监听Nacos的配置变更,然后手动把最新的路由更新到路由表中。这里有两个难点:

  • 如何监听Nacos配置变更?
  • 如何把路由信息更新到路由表?

监听Nacos配置变更

在Nacos官网中给出了手动监听Nacos配置变更的SDK:https://nacos.io/zh-cn/docs/sdk.html

如果希望 Nacos 推送配置变更,可以使用 Nacos 动态监听配置接口来实现。

1
public void addListener(String dataId, String group, Listener listener)

请求参数说明:

参数名 参数类型 描述
dataId string 配置 ID,保证全局唯一性,只允许英文字符和 4 种特殊字符("."、":"、"-"、"_")。不超过 256 字节。
group string 配置分组,一般是默认的DEFAULT_GROUP。
listener Listener 监听器,配置变更进入监听器的回调函数。

官方示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
String serverAddr = "{serverAddr}";
String dataId = "{dataId}";
String group = "{group}";
// 1.创建ConfigService,连接Nacos
Properties properties = new Properties();
properties.put("serverAddr", serverAddr);
ConfigService configService = NacosFactory.createConfigService(properties);
// 2.读取配置
String content = configService.getConfig(dataId, group, 5000);
// 3.添加配置监听器
configService.addListener(dataId, group, new Listener() {
        @Override
        public void receiveConfigInfo(String configInfo) {
        	// 配置变更的通知处理
            System.out.println("recieve1:" + configInfo);
        }
        @Override
        public Executor getExecutor() {
            // 创建线程池实现异步处理
        	return null;
        }
});

这里核心的步骤有2步:

  • 创建ConfigService,目的是连接到Nacos
  • 添加配置监听器,编写配置变更的通知处理逻辑

由于前面采用了spring-cloud-starter-alibaba-nacos-config自动装配,因此ConfigService已经在com.alibaba.cloud.nacos.NacosConfigAutoConfiguration中自动创建好了:

img

NacosConfigManager中是负责管理Nacos的ConfigService的,具体代码如下:

img

因此,只要我们拿到NacosConfigManager就等于拿到了ConfigService,第一步就实现了。

第二步,编写监听器。虽然官方提供的SDK是ConfigService中的addListener,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的API是这个:

1
2
3
4
5
6
String getConfigAndSignListener(
    String dataId, // 配置文件id
    String group, // 配置组,走默认
    long timeoutMs, // 读取配置的超时时间
    Listener listener // 监听器
) throws NacosException;

既可以配置监听器,并且会根据dataId和group读取配置并返回。这样就可以在项目启动时先更新一次路由,后续随着配置变更通知到监听器,完成路由更新。

更新路由

实现动态路由需要用到更新路由,不过建议先看下面的实现动态路由部分,看到其中的更新路由部分再回头来看这里,逻辑会更清晰

更新路由要用到org.springframework.cloud.gateway.route.RouteDefinitionWriter这个接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package org.springframework.cloud.gateway.route;

import reactor.core.publisher.Mono;

/**
 * @author Spencer Gibb
 */
public interface RouteDefinitionWriter {
    /**
     * 更新路由到路由表,如果路由id重复,则会覆盖旧的路由
     */
     Mono<Void> save(Mono<RouteDefinition> route);
    /**
     * 根据路由id删除某个路由
     */
     Mono<Void> delete(Mono<String> routeId);
}

这里更新的路由,也就是RouteDefinition,包含下列常见字段:

  • id:路由id
  • predicates:路由匹配规则
  • filters:路由过滤器
  • uri:路由目的地

之后保存到Nacos的配置也要符合这个对象结构,之后以JSON来保存,格式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "id": "item",
  "uri": "lb://item-service",
  "predicates": [{
    "name": "Path",
    "args": {
        "_genkey_0":"/items/**",
        "_genkey_1":"/search/**"
    }
  }],
  "filters": []
}

以上JSON配置就等同于之前在网关的application.yaml中配置的:

1
2
3
4
5
6
7
8
spring:
  cloud:
    gateway:
      routes:
        - id: item # 路由规则id,自定义,唯一
          uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
          predicates: # 路由断言,判断当前请求是否符合下方规则,符合则路由到目标服务
            - Path=/items/**,/search/** # 这里是以请求路径作为判断规则

实现动态路由

首先在网关gateway引入依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!--统一配置管理-->
<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>

然后在网关gatewayresources目录创建bootstrap.yaml文件,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 192.168.19.129
      config:
        file-extension: yaml
        shared-configs:
          - dataId: shared-log.yaml # 共享日志配置

接着,修改gatewayresources目录下的application.yml,把之前的路由移除,最终内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
server:
  port: 8080 # 端口
hm:
  jwt:
    location: classpath:hmall.jks # 秘钥地址
    alias: hmall # 秘钥别名
    password: hmall123 # 秘钥文件密码
    tokenTTL: 30m # 登录有效期
  auth:
    excludePaths: # 无需登录校验的路径
      - /search/**
      - /users/login
      - /items/**

然后,在gateway中定义配置监听器:

image-20251217225302467

其代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package com.hmall.gateway.routers;

import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.nacos.NacosConfigManager;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import javax.annotation.PostConstruct;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;

@Slf4j
@RequiredArgsConstructor
@Component
public class DynamicRouteLoader {

    private final NacosConfigManager nacosConfigManager;
    private final RouteDefinitionWriter routeDefinitionWriter;

    private final String dataId = "gateway-routes.json";
    private final String group = "DEFAULT_GROUP";
    private final Set<String> routeIds = new HashSet<>(); // 存储路由ID,用Set确保不重复

    @PostConstruct // 在Bean初始化之后执行
    public void initRouteConfigListener() throws NacosException {
        // 1. 项目启动时,先拉取一次配置,并添加配置监听器
        String configInfo = nacosConfigManager.getConfigService()
                .getConfigAndSignListener(dataId, group, 5000, new Listener() {
                    @Override
                    public Executor getExecutor() {
                        return null;
                    }

                    @Override
                    public void receiveConfigInfo(String configInfo) {
                        // 2. 监听到配置更新,更新到路由表
                        updateRouteConfig(configInfo);
                    }
                });
        // 3. 首次启动时,第一次读取到的配置也需要更新到路由表
        updateRouteConfig(configInfo);
    }

    // 更新路由表方法
    public void updateRouteConfig(String configInfo) {
        log.debug("监听到路由配置变更,{}", configInfo);
        // 1. 解析配置信息,转为RouteDefinition
        List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);

        // 2. 删除原本所有的路由表
        for (String routeId : routeIds) {
            routeDefinitionWriter.delete(Mono.just(routeId)).subscribe();
        }
        routeIds.clear(); // 清空路由ID集合

        // 3. 更新新的路由表
        for (RouteDefinition routeDefinition : routeDefinitions) {
            // 3.1. 更新路由信息
            routeDefinitionWriter.save(Mono.just(routeDefinition)).subscribe();
            // 3.2. 保存路由ID,便于下一次更新时删除
            routeIds.add(routeDefinition.getId());
        }
    }
}

重启网关,任意访问一个接口,比如 http://localhost:8080/search/list?pageNo=1&pageSize=1:

img

发现是404,无法访问,因为刚刚在网关application.yml中把路由信息删除了。

接下来,我们直接在Nacos控制台添加路由,路由文件名为gateway-routes.json(和DynamicRouteLoader类中定义的dataId保持一致),类型为json

img

配置内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
[
    {
        "id": "item",
        "predicates": [
            {
                "name": "Path",
                "args": {
                    "_genkey_0": "/items/**",
                    "_genkey_1": "/search/**"
                }
            }
        ],
        "filters": [],
        "uri": "lb://item-service"
    },
    {
        "id": "cart",
        "predicates": [
            {
                "name": "Path",
                "args": {
                    "_genkey_0": "/carts/**"
                }
            }
        ],
        "filters": [],
        "uri": "lb://cart-service"
    },
    {
        "id": "user",
        "predicates": [
            {
                "name": "Path",
                "args": {
                    "_genkey_0": "/users/**",
                    "_genkey_1": "/addresses/**"
                }
            }
        ],
        "filters": [],
        "uri": "lb://user-service"
    },
    {
        "id": "trade",
        "predicates": [
            {
                "name": "Path",
                "args": {
                    "_genkey_0": "/orders/**"
                }
            }
        ],
        "filters": [],
        "uri": "lb://trade-service"
    },
    {
        "id": "pay",
        "predicates": [
            {
                "name": "Path",
                "args": {
                    "_genkey_0": "/pay-orders/**"
                }
            }
        ],
        "filters": [],
        "uri": "lb://pay-service"
    }
]

无需重启网关,稍等几秒钟后(更新需要一定时间,别急),再次访问刚才的地址:

img

发现网关路由成功了!

本站于2025年3月26日建立
使用 Hugo 构建
主题 StackJimmy 设计