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

当项目规模较小时,这种模式上手快,部署、运维也都很方便,因此早期很多小型项目都采用这种模式
但随着项目的业务规模越来越大,团队开发人员也不断增加,单体架构就呈现出越来越多的问题:
- 团队协作成本高:试想一下,你们团队数十个人同时协作开发同一个项目,由于所有模块都在一个项目中,不同模块的代码之间物理边界越来越模糊。最终要把功能合并到一个分支,你绝对会陷入到解决冲突的泥潭之中
- 系统发布效率低:任何模块变更都需要发布整个系统,而系统发布过程中需要多个模块之间制约较多,需要对比各种文件,任何一处出现问题都会导致发布失败,往往一次发布需要数十分钟甚至数小时
- 系统可用性差:单体架构各个功能模块是作为一个服务部署,相互之间会互相影响,一些热点功能会耗尽系统资源,导致其它服务低可用
微服务架构
微服务架构,首先是服务化,就是**将单体架构中的功能模块从单体应用中拆分出来,独立部署为多个服务**。同时要满足下面的一些特点:
- 单一职责:一个微服务负责一部分业务功能,并且其核心数据不依赖于其它模块。
- 团队自治:每个微服务都有自己独立的开发、测试、发布、运维人员,团队人员规模不超过10人(2张披萨能喂饱)
- 服务自治:每个微服务都独立打包部署,访问自己独立的数据库。并且要做好服务隔离,避免对其它服务产生影响
以黑马商场为例,将把商品、用户、购物车、交易等模块拆分,交给不同的团队去开发,并独立部署:

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

而且SpringCloud依托于SpringBoot的自动装配能力,大大降低了其项目搭建、组件使用的成本。对于没有自研微服务组件能力的中小型企业,使用SpringCloud全家桶来实现微服务开发可以说是最合适的选择了
官网:Spring Cloud
SpringCloud版本:
| 分支 |
首次发布 |
开源支持结束 |
企业支持结束 |
Spring Boot 版本 |
| 2025.1.x |
2025-11 |
2026-12 |
2027-12 |
3.5.x |
| 2025.0.x |
2025-05 |
2026-06 |
2032-06 |
3.5.x |
| 2024.0.x |
2024-11 |
2025-12 |
2026-12 |
3.4.x |
| 2023.0.x |
2023-12 |
2025-06 |
2026-06 |
3.3.x, 3.2.x |
| 2022.0.x |
2022-12 |
2024-06 |
2025-06 |
3.0.x, 3.1.x |
| 2021.0.x |
2021-12 |
2023-06 |
2029-06 |
2.6.x, 2.7.x |
| 2020.0.x |
2020-12 |
2022-05 |
2023-08 |
2.4.x, 2.5.x |
| Hoxton.x |
2019-11 |
2021-05 |
2022-08 |
2.2.x, 2.3.x |
| Greenwich.x |
2018-10 |
2019-10 |
2021-01 |
2.1.x |
| Finchley.x |
2018-03 |
2019-03 |
2020-06 |
2.0.x |
Spring Cloud 2021.0.x以及Spring Boot 2.7.x版本属于目前主流版本
Alibaba的微服务产品SpringCloudAlibaba目前也成为了SpringCloud组件中的一员,微服务系列教程中也会使用其中的部分组件
父工程hmall中已经配置了SpringCloud以及SpringCloudAlibaba的依赖:

对应的版本:

微服务拆分
熟悉黑马商城
黑马商城项目的基本结构:

登录
登录业务流程:

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

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

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

这里目前是利用数据库实现了简单的分页查询
购物车
在搜索到的商品列表中,点击按钮加入购物车,即可将商品加入购物车:

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

同时这里还可以对购物车实现修改、删除等操作。
相关功能全部在com.hmall.controller.CartController中:

其中,查询购物车列表时,由于要判断商品最新的价格和状态,所以还需要查询商品信息,业务流程如下:
下单
在购物车页面点击结算按钮,会进入订单结算页面:

点击提交订单,会提交请求到服务端,服务端做3件事情:
业务入口在com.hmall.controller.OrderController中的createOrder方法:

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

在选择余额支付这种方式后,会发起请求到服务端,服务端会立刻创建一个支付流水单,并返回支付流水单号到前端。
当用户输入用户密码,然后点击确认支付时,页面会发送请求到服务端,而服务端会做几件事情:
- 校验用户密码
- 扣减余额
- 修改支付流水状态
- 修改交易订单状态
请求入口在com.hmall.controller.PayController中:

参考上述办法分别改造hm-cart和hm-item两个微服务模块
服务拆分原则
服务拆分一定要考虑几个问题:
什么时候拆
一般情况下,对于一个初创的项目,首先要做的是验证项目的可行性。因此这一阶段的首要任务是敏捷开发,快速产出生产可用的产品,投入市场做验证。为了达成这一目的,该阶段项目架构往往会比较简单,很多情况下会直接采用单体架构,这样开发成本比较低,可以快速产出结果,一旦发现项目不符合市场,损失较小
如果这一阶段采用复杂的微服务架构,投入大量的人力和时间成本用于架构设计,最终发现产品不符合市场需求,等于全部做了无用功
所以,对于大多数小型项目来说,一般是先采用单体架构,随着用户规模扩大、业务复杂后再逐渐拆分为微服务架构。这样初期成本会比较低,可以快速试错。但是,这么做的问题就在于后期做服务拆分时,可能会遇到很多代码耦合带来的问题,拆分比较困难(前易后难)
而对于一些大型项目,在立项之初目的就很明确,为了长远考虑,在架构设计时就直接选择微服务架构。虽然前期投入较多,但后期就少了拆分服务的烦恼(前难后易)
怎么拆
微服务拆分时粒度要小,这其实是拆分的目标。具体可以从两个角度来分析:
- 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高
- 低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖,或者依赖接口的稳定性要强
高内聚首先是单一职责,但不能说一个微服务就一个接口,而是要保证微服务内部业务的完整性为前提。目标是当修改某个业务时,最好就只修改当前微服务,这样变更的成本更低
一旦微服务做到了高内聚,那么服务之间的耦合度自然就降低了
当然,微服务之间不可避免的会有或多或少的业务交互,比如下单时需要查询商品数据。这个时候我们不能在订单服务直接查询商品数据库,否则就导致了数据耦合。而应该由商品服务对应暴露接口,并且一定要保证微服务对外接口的稳定性(即:尽量保证接口外观不变)。虽然出现了服务间调用,但此时无论你如何在商品服务做内部修改,都不会影响到订单微服务,服务间的耦合度就降低了
明确了拆分目标,接下来就是拆分方式了。服务拆分一般有两种方式:
所谓纵向拆分,就是**按照项目的功能模块来拆分**。例如黑马商城中,就有用户管理功能、订单管理功能、购物车功能、商品管理功能、支付功能等。那么按照功能模块将他们拆分为一个个服务,就属于纵向拆分。这种拆分模式可以尽可能提高服务的内聚性
而横向拆分,是看**各个功能模块之间有没有公共的业务部分**,如果有将其抽取出来作为通用服务。例如用户登录是需要发送消息通知,记录风控数据,下单时也要发送短信,记录风控数据。因此消息发送、风控数据记录就是通用的业务功能,因此可以将他们分别抽取为公共服务:消息中心服务、风控管理服务。这样可以提高业务的复用性,避免重复开发。同时通用业务一般接口稳定性较强,也不会使服务之间过分耦合
由于黑马商城并不是一个完整的项目,其中的短信发送、风控管理并没有实现,这里就不再考虑了。而其它的业务按照纵向拆分,可以分为以下几个微服务:
- 用户服务
- 商品服务
- 订单服务
- 购物车服务
- 支付服务
拆分服务
先把商品管理功能、购物车功能抽取为两个独立服务。
一般微服务项目有两种不同的工程结构:
- 完全解耦:每一个微服务都创建为一个独立的工程,甚至可以使用不同的开发语言来开发,项目完全解耦。
- 优点:服务之间耦合度低
- 缺点:每个项目都有自己的独立仓库,管理起来比较麻烦
- Maven聚合:整个项目为一个Project,然后每个微服务是其中的一个Module
- 优点:项目代码集中,管理和运维方便
- 缺点:服务之间耦合,编译时间较长
由于案例不涉及过度复杂的代码以及为了方便快速上手,所以选择Maven聚合的方式
商品服务
在hmall中创建module:

选择maven模块,并设定JDK版本为11:

商品模块,起名为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
| <?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> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <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>
|
编写启动类:

代码如下:
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中拷贝:

其中,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}: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 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
|
剩下的application-dev.yaml和application-local.yaml直接从hm-service拷贝即可。
然后拷贝hm-service中与商品管理有关的代码到item-service,如图:

这里有一个地方的代码需要改动,就是ItemServiceImpl中的deductStock方法:

这也是因为ItemMapper的所在包发生了变化,因此这里代码必须修改包路径。
最后,还要导入数据库表。默认的数据库连接的是虚拟机,在你docker数据库执行课前资料提供的SQL文件:

最终,会在数据库创建一个名为hm-item的database,将来的每一个微服务都会有自己的一个database:

注意:在企业开发的生产环境中,每一个微服务都应该有自己的独立数据库服务
接下来,就可以启动测试了,在启动前我们要配置一下启动项,让默认激活的配置为local而不是dev:

在打开的编辑框填写active profiles:

接着,启动item-service,访问商品微服务的swagger接口文档:http://localhost:8081/doc.html
然后测试其中的根据id批量查询商品这个接口:

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

说明商品微服务抽取成功了
购物车服务
与商品服务类似,在hmall下创建一个新的module,起名为cart-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
| <?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>cart-service</artifactId>
<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties>
<dependencies> <dependency> <groupId>com.heima</groupId> <artifactId>hm-common</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <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>
|
然后是启动类:
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.hmall.cart;
import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.hmall.cart.mapper") @SpringBootApplication public class CartApplication { public static void main(String[] args) { SpringApplication.run(CartApplication.class, args); } }
|
然后是配置文件,同样可以拷贝自item-service,不过其中的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}:3306/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 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
|
最后,把hm-service中的与购物车有关功能拷贝过来,最终的项目结构如下:

特别注意的是com.hmall.cart.service.impl.CartServiceImpl,其中有两个地方需要处理:
- 需要获取登录用户信息,但登录校验功能目前没有复制过来,先写死固定用户id
- 查询购物车时需要查询商品信息,而商品信息不在当前服务,需要先将这部分代码注释

对这部分代码做如下修改:
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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
| package com.hmall.cart.service.impl;
import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.hmall.cart.domain.dto.CartFormDTO; import com.hmall.cart.domain.po.Cart; import com.hmall.cart.domain.vo.CartVO; import com.hmall.cart.mapper.CartMapper; import com.hmall.cart.service.ICartService; import com.hmall.common.exception.BizIllegalException; import com.hmall.common.utils.BeanUtils; import com.hmall.common.utils.CollUtils; import com.hmall.common.utils.UserContext; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service;
import java.util.Collection; import java.util.List;
@Service @RequiredArgsConstructor public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {
@Override public void addItem2Cart(CartFormDTO cartFormDTO) { Long userId = UserContext.getUser();
if (checkItemExists(cartFormDTO.getItemId(), userId)) { baseMapper.updateNum(cartFormDTO.getItemId(), userId); return; } checkCartsFull(userId);
Cart cart = BeanUtils.copyBean(cartFormDTO, Cart.class); cart.setUserId(userId); save(cart); }
@Override public List<CartVO> queryMyCarts() { List<Cart> carts = lambdaQuery().eq(Cart::getUserId, 1L ).list(); if (CollUtils.isEmpty(carts)) { return CollUtils.emptyList(); } List<CartVO> vos = BeanUtils.copyList(carts, CartVO.class); handleCartItems(vos); return vos; }
private void handleCartItems(List<CartVO> vos) {
}
@Override public void removeByItemIds(Collection<Long> itemIds) { QueryWrapper<Cart> queryWrapper = new QueryWrapper<Cart>(); queryWrapper.lambda() .eq(Cart::getUserId, UserContext.getUser()) .in(Cart::getItemId, itemIds); 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; } }
|
最后,还是要导入数据库表,在本地数据库直接执行课前资料对应的SQL文件:

在数据库中会出现名为hm-cart的database,以及其中的cart表,代表购物车:

接下来,就可以测试了。不过在启动前,同样要配置启动项的active profile为local:

然后启动CartApplication,访问swagger文档页面:http://localhost:8082/doc.html
测试其中的查询我的购物车列表接口:

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

与商品有关的几个字段值都为空,这就是因为刚才注释掉了查询购物车时,查询商品信息的相关代码
服务调用
在拆分的时候,发现一个问题:就是购物车业务中需要查询商品信息,但商品信息查询的逻辑全部迁移到了item-service服务,导致我们无法查询。
最终结果就是查询到的购物车数据不完整,因此要想解决这个问题,我们就必须改造其中的代码,把原本本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。
因此,现在查询购物车列表的流程变成了这样:
代码中需要变化的就是这一步:

那么问题来了:如何在cart-service中获取item-service服务中的提供的商品数据呢?
参考前端向服务端查询数据,就是向http://localhost:8081/items这个接口发起的请求:

而这种查询就是通过http请求的方式来完成的,不仅仅可以实现远程查询,还可以实现新增、删除等各种远程请求
所以,现在要做的就是在Java代码中发送Http请求
RestTemplate
Spring提供了一个RestTemplate的API,可以方便的实现Http请求的发送
org.springframework.web.client public class RestTemplate
extends InterceptingHttpAccessor
implements RestOperations
-—————————————————————————————————————
同步客户端执行HTTP请求,在底层HTTP客户端库(如JDK HttpURLConnection、Apache HttpComponents等)上公开一个简单的模板方法API。RestTemplate通过HTTP方法为常见场景提供了模板,此外还提供了支持不太常见情况的通用交换和执行方法。 RestTemplate通常用作共享组件。然而,它的配置不支持并发修改,因此它的配置通常是在启动时准备的。如果需要,您可以在启动时创建多个不同配置的RestTemplate实例。如果这些实例需要共享HTTP客户端资源,它们可以使用相同的底层ClientHttpRequestFactory。 注意:从5.0开始,这个类处于维护模式,只有对更改和错误的小请求才会被接受。请考虑使用org.springframework.web.react .client. webclient,它有更现代的API,支持同步、异步和流场景。
-—————————————————————————————————————
自: 3.0 参见: HttpMessageConverter, RequestCallback, ResponseExtractor, ResponseErrorHandler
其中提供了大量的方法,方便我们发送Http请求,例如:

可以看到常见的Get、Post、Put、Delete请求都支持,如果请求参数比较复杂,还可以使用exchange方法来构造请求
在使用RestTemplate前,先要将其注册为Bean。在cart-service服务中定义一个配置类:

将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(); } }
|
远程调用
接下来,修改cart-service中的com.hmall.cart.service.impl.CartServiceImpl的handleCartItems方法,发送http请求到item-service:

可以看到,利用RestTemplate发送http请求与前端ajax发送请求非常相似,都包含四部分信息:
- ① 请求方式
- ② 请求路径
- ③ 请求参数
- ④ 返回值类型
handleCartItems方法的完整代码如下:
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
| private void handleCartItems(List<CartVO> vos) { Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); 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, ",")) ); if(!response.getStatusCode().is2xxSuccessful()){ return; } List<ItemDTO> items = response.getBody(); if (CollUtils.isEmpty(items)) { return; } Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity())); 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()); } }
|
现在重启cart-service,再次测试查询我的购物车列表接口:

可以发现,所有商品相关数据都已经查询到了
在这个过程中,item-service提供了查询接口,cart-service利用Http请求调用该接口。因此item-service可以称为服务的**提供者,而cart-service则称为服务的消费者或服务调用者**
总结
什么时候需要拆分微服务?
- 如果是创业型公司,最好先用单体架构快速迭代开发,验证市场运作模型,快速试错。当业务跑通以后,随着业务规模扩大、人员规模增加,再考虑拆分微服务。
- 如果是大型企业,有充足的资源,可以在项目开始之初就搭建微服务架构。
如何拆分?
- 首先要做到高内聚、低耦合
- 从拆分方式来说,有横向拆分和纵向拆分两种。纵向就是按照业务功能模块,横向则是拆分通用性业务,提高复用性
服务拆分之后,不可避免的会出现跨微服务的业务,此时微服务之间就需要进行远程调用。微服务之间的远程调用被称为RPC,即远程过程调用。RPC的实现方式有很多,比如:
案例中使用的是Http方式,这种方式不关心服务提供者的具体技术实现,只要对外暴露Http接口即可,更符合微服务的需要。
Java发送http请求可以使用Spring提供的RestTemplate,使用的基本步骤如下:
- 注册RestTemplate到Spring容器
- 调用RestTemplate的API发送请求,常见方法有:
- getForObject:发送Get请求并返回指定类型对象
- PostForObject:发送Post请求并返回指定类型对象
- put:发送PUT请求
- delete:发送Delete请求
- exchange:发送任意类型请求,返回ResponseEntity
Nacos注册中心
服务注册和发现
在上一章实现了微服务拆分,并且通过Http请求实现了跨微服务的远程调用。不过这种手动发送Http请求的方式存在一些问题
试想一下,假如商品微服务被调用较多,为了应对更高的并发,进行了多实例部署,如图
此时,每个item-service的实例其IP或端口不同,问题来了:
- item-service这么多实例,cart-service如何知道每一个实例的地址?
- http请求要写url地址,
cart-service服务到底该调用哪个实例呢?
- 如果在运行过程中,某一个
item-service实例宕机,cart-service依然在调用该怎么办?
- 如果并发太高,
item-service临时多部署了N台实例,cart-service如何知道新实例的地址?
注册中心原理
在微服务远程调用的过程中,包括两个角色:
- 服务提供者:提供接口供其它微服务访问,比如
item-service
- 服务消费者:调用其它微服务提供的接口,比如
cart-service
在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:

流程如下:
- 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心
- 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)
- 调用者自己对实例列表负载均衡,挑选一个实例
- 调用者向该实例发起远程调用
当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?
- 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)
- 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除
- 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例列表
- 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表
Nacos注册中心
目前开源的注册中心框架有很多,国内比较常见的有:
- Eureka:Netflix公司出品,目前被集成在SpringCloud当中,一般用于Java应用
- **Nacos**:Alibaba公司出品,目前被集成在SpringCloudAlibaba中,一般用于Java应用
- Consul:HashiCorp公司出品,目前集成在SpringCloud中,不限制微服务语言
以上几种注册中心都遵循SpringCloud中的API规范,因此在业务开发使用上没有太大差异。由于Nacos是国内产品,中文文档比较丰富,而且同时具备配置管理功能(后面会学习),因此在国内使用较多
官方网站:Nacos官网| Nacos 配置中心 | Nacos 下载| Nacos 官方社区 | Nacos 官网
可以通过基于Docker来部署Nacos的注册中心,首先要准备MySQL数据库表,用来存储Nacos的数据。由于是Docker部署,所以需要将资料中的SQL文件导入到自己Docker中的MySQL容器中:

最终表结构如下:

然后,找到课前资料下的nacos文件夹:

其中的nacos/custom.env文件中,有一个MYSQL_SERVICE_HOST也就是mysql地址,需要修改为自己的虚拟机IP地址:

然后,将课前资料中的nacos目录上传至虚拟机的/root目录。
进入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
|
启动完成后,访问下面地址:http://192.168.150.101:8848/nacos/,注意将`192.168.150.101`替换为自己的虚拟机IP地址。
首次访问会跳转到登录页,账号密码都是nacos

服务注册
接下来,把item-service注册到Nacos,步骤如下:
添加依赖
在item-service的pom.xml中添加依赖:
1 2 3 4 5
| <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>
|
配置Nacos
在item-service的application.yml中添加nacos地址配置:
1 2 3 4 5 6
| spring: application: name: item-service cloud: nacos: server-addr: 192.168.2.20:8848
|
启动服务实例
为了测试一个服务多个实例的情况,再配置一个item-service的部署实例:
然后配置启动项,注意重命名并且配置新的端口,避免冲突:
重启item-service的两个实例:

访问nacos控制台,可以发现服务注册成功:

服务发现
服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:
引入依赖
服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖
在cart-service中的pom.xml中添加下面的依赖:
1 2 3 4 5
| <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>
|
可以发现,这里Nacos的依赖于服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者
因此,等一会儿cart-service启动,同样会注册到Nacos
配置Nacos地址
在cart-service的application.yml中添加nacos地址配置:
1 2 3 4
| spring: cloud: nacos: server-addr: 192.168.150.101:8848
|
发现并调用服务
接下来,服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址
因此,服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:
这里可以选择最简单的随机负载均衡
另外,服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经自动装配,可以直接注入使用:

接下来,对原来的远程调用做修改,之前调用时是写死服务提供者的IP和端口:

但现在不需要了,通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用:

OpenFeign
快速入门
OpenFeign是一个声明式的http客户端,是SpringCloud在Eureka公司开源的Feign基础上改造而来
官方地址:GitHub - OpenFeign/feign: Feign makes writing java http clients easier
其作用就是基于SpringMVC的常见注解,优雅的实现http请求的发送
上一章,利用Nacos实现了服务的治理,通过RestTemplate实现了服务的远程调用。但是远程调用的代码太复杂了:

而且这种调用方式,与原本的本地方法调用差异太大,编程时的体验也不统一,一会儿远程调用,一会儿本地调用
若是要让远程调用像本地方法调用一样简单,就要用到OpenFeign组件了
其实远程调用的关键点就在于四个:
引入依赖
在cart-service服务的pom.xml中引入OpenFeign的依赖和loadBalancer依赖:
1 2 3 4 5 6 7 8 9 10
| <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>
|
早期负载均衡使用的是SpringCloud中的Ribbon
启用OpenFeign
接下来,在cart-service的CartApplication启动类上添加注解,启动OpenFeign功能:

编写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> :返回值类型
通过@FeignClient注解,标明当前接口为Feign客户端,后续交给OpenFeign处理,注解中的value参数表示从Nacos中拉取的实例名称
接口中方法实际就是调用远程接口,且方法的风格与Controller层相似,阅读、编写的体验更好
有了上述信息,OpenFeign就可以利用动态代理实现这个方法,并且向http://item-service/items发送一个GET请求,携带ids为请求参数,并自动将返回值处理为List<ItemDTO>
使用FeignClient
最后,在cart-service的com.hmall.cart.service.impl.CartServiceImpl中改造代码,直接调用ItemClient的方法:

连接池
Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:
- HttpURLConnection:默认实现,不支持连接池
- Apache HttpClient :支持连接池
- OKHttp:支持连接池
引入依赖
在cart-service的pom.xml中引入依赖:
1 2 3 4 5
| <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>
|
开启连接池
在cart-service的application.yml配置文件中开启Feign的连接池功能:
1 2 3
| feign: okhttp: enabled: true
|
重启服务,连接池就生效了
验证
可以打断点验证连接池是否生效,在org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient中的execute方法中打断点:

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

可以发现这里底层的实现已经改为OkHttpClient
最佳实践
把与下单有关的业务抽取为一个独立微服务:trade-service,先来看一下hm-service中原本与下单有关的业务逻辑。
入口在com.hmall.controller.OrderController的createOrder方法,然后调用了IOrderService中的createOrder方法。
由于下单时前端提交了商品id,为了计算订单总价,需要查询商品信息:

也就是说,如果拆分了交易微服务(trade-service),它也需要远程调用item-service中的根据id批量查询商品功能。这个需求与cart-service中是一样的,问题在于如何可以避免重复编码呢?
思路分析
避免重复编码的办法就是抽取,不过这里有两种抽取思路:
- 思路1:抽取到微服务之外的公共module
- 思路2:每个微服务自己抽取一个module
如图:
方案1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高
方案2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低
由于item-service已经创建好,无法继续拆分,因此这里采用方案1
抽取Feign客户端
在hmall下定义一个新的module,命名为hm-api
其依赖如下:
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
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>hmall</artifactId> <groupId>com.heima</groupId> <version>1.0.0</version> </parent> <modelVersion>4.0.0</modelVersion>
<artifactId>hm-api</artifactId>
<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties>
<dependencies> <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> <dependency> <groupId>io.swagger</groupId> <artifactId>swagger-annotations</artifactId> <version>1.6.6</version> <scope>compile</scope> </dependency> </dependencies> </project>
|
然后把ItemDTO和ItemClient都拷贝过来,最终结构如下:

现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了
扫描包
接下来,在cart-service的pom.xml中引入hm-api模块:
1 2 3 4 5 6
| <dependency> <groupId>com.heima</groupId> <artifactId>hm-api</artifactId> <version>1.0.0</version> </dependency>
|
删除cart-service中原来的ItemDTO和ItemClient,重启项目,发现报错了:

这里因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient,所以报错了
解决办法很简单,在cart-service的启动类上添加声明即可,两种方式:


日志配置
OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。而且其日志级别有4级:
- NONE:不记录任何日志信息,这是默认值
- BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
- HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
Feign默认的日志级别就是NONE,所以默认情况下看不到请求日志
定义日志级别
在hm-api模块下新建一个配置类,定义Feign的日志级别:

代码如下:
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)
|
Gateway服务网关
将黑马商城拆分为5个微服务:
- 用户服务
- 商品服务
- 购物车服务
- 交易服务
- 支付服务
由于每个微服务都有不同的地址或端口,入口不同,所以与前端联调的时候发现了一些问题:
- 请求不同数据时要访问不同的入口,需要维护多个入口地址,麻烦
- 前端无法调用nacos,无法实时更新服务列表
单体架构时只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,这就存在一些问题:
- 每个微服务都需要编写登录校验、用户信息获取的功能吗?
- 当微服务之间调用时,该如何传递用户信息?
网关路由
认识网关
网关就是网络的关口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验
现在,微服务网关就起到同样的作用。前端请求不能直接访问微服务,而是要请求网关:
- 网关可以做安全控制,也就是登录身份校验,校验通过才放行
- 通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去

在SpringCloud当中,提供了两种网关实现方案:
- Netflix Zuul:早期实现,目前已经淘汰
- SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
基于以上对比,教程选用SpringCloudGateWay网关实现方案,官网:Spring Cloud Gateway
快速入门
接下来,先看下如何利用网关实现请求路由。由于网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能。大概步骤如下:
- 创建网关微服务
- 引入SpringCloudGateway、NacosDiscovery依赖
- 编写启动类
- 配置网关路由
创建项目
首先,在hmall下创建一个新的module,命名为hm-gateway,作为网关微服务:

引入依赖
在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
| <?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> <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> <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包下新建一个启动类:

代码如下:
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.150.101:8848 gateway: routes: - id: item uri: lb://item-service 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
此时,启动UserApplication、CartApplication,然后打开前端页面,发现相关功能都可以正常访问了
路由过滤
路由规则的定义语法如下:
1 2 3 4 5 6 7 8
| spring: cloud: gateway: routes: - id: item uri: lb://item-service predicates: - Path=/items/**,/search/**
|
其中routes对应的类型如下:

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

四个属性含义如下:
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 |
权重处理 |
|
网关中还提供了33中路由过滤器,每种过滤器都有独特的作用
| 名称 |
说明 |
实例 |
| AddRequestHeader |
给当前请求添加一个请求头 |
AddrequestHeader=headerName,headerValue |
| RemoveRequestHeader |
移除请求中的一个请求头 |
RemoveRequestHeader=headerName |
| AddResponseHeader |
给响应结果中添加一个响应头 |
AddreponseHeader=headerName,headerValue |
| RemoveResponseHeader |
从响应结果中移除一个响应头 |
RemoveResponseHeader=headerName |
| RewritePath |
请求路径重写 |
RewritePath=/red/?(<segment>.*),/$\{segment} |
| StripPrefix |
去除请求路径中的N段前缀 |
StripPrefix=1,则路径/a/b转发时只保留/b |
网关登录校验
单体架构时只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做登录校验,这显然不可取
鉴权思路分析
当前登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:
- 每个微服务都需要知道JWT的秘钥,不安全
- 每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关。那么完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
- 只需要在网关和用户服务保存秘钥
- 只需要在网关开发登录校验功能
此时,登录校验的流程如图:
不过,这里存在几个问题:
- 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
- 网关校验JWT之后,如何将用户信息传递给微服务?
- 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?
网关过滤器
登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理
如图所示:
- 客户端请求进入网关后由
HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(**Route**),然后将请求交给WebHandler去处理
WebHandler则会加载当前路由下需要执行的过滤器链(**Filter chain),然后按照顺序逐一执行过滤器(后面称为Filter**)
- 图中
Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为pre和post两部分,分别会在请求路由到微服务之前和之后被执行
- 只有所有
Filter的pre逻辑都依次顺序执行通过后,请求才会被路由到微服务
- 微服务返回结果后,再倒序执行
Filter的post逻辑
- 最终把响应结果返回
如图中所示,最终请求转发是有一个名为**NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前,这就符合现在的需求了!AddRequestHeaderGatewayFilterFacotry**
那么,该如何实现一个网关过滤器呢?
网关过滤器链中的过滤器有两种:
- **
GatewayFilter**:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.
- **
GlobalFilter**:全局过滤器,作用范围是所有路由,不可配置
注意:过滤器链之外还有一种过滤器,HttpHeadersFilter,用来处理传递到下游微服务的请求头。例如org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter可以传递代理请求原本的host头到下游微服务
其实GatewayFilter和GlobalFilter这两种过滤器的方法签名完全一致:
1 2 3 4 5 6 7
|
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
|
FilteringWebHandler在处理请求时,**会将GlobalFilter装饰为GatewayFilter**,然后放到同一个过滤器链中,排序以后依次执行
Gateway中内置了很多的GatewayFilter,详情可以参考官方文档:Spring Cloud Gateway
Gateway内置的GatewayFilter过滤器使用起来非常简单,无需编码,只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route.
例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。
使用的使用只需要在application.yaml中这样配置:
1 2 3 4 5 6 7 8 9 10
| spring: cloud: gateway: routes: - id: test_route uri: lb://test-service predicates: -Path=/test/** filters: - AddRequestHeader=key, value
|
如果想要让过滤器作用于所有的路由,则可以这样配置:
1 2 3 4 5 6 7 8 9 10
| spring: cloud: gateway: default-filters: - AddRequestHeader=key, value routes: - id: test_route uri: lb://test-service predicates: -Path=/test/**
|
自定义过滤器
无论是GatewayFilter还是GlobalFilter都支持自定义,只不过编码方式、使用方式略有差别
自定义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
|
另外,这种过滤器还可以支持动态配置参数,不过实现起来比较复杂,示例:
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 extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {
@Override public GatewayFilter apply(Config config) { return new OrderedGatewayFilter(new GatewayFilter() { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { 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"); } @Override public Class<Config> getConfigClass() { return Config.class; }
}
|
假设配置文件中有如下配置:
1 2 3 4 5
| spring: cloud: gateway: default-filters: - PringAny:1,2,3
|
那么拦截器就会获取到1、2、3的值,并且将其赋值给变量a、b、c,最后打印出对应的值
自定义GlobalFilter
自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Component public class PrintAnyGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { System.out.println("未登录,无法访问");
ServerHttpResponse response = exchange.getResponse(); response.setRawStatusCode(401); return response.setComplete(); }
@Override public int getOrder() { return 0; } }
|
登录校验
对于登录校验操作,不涉及过多复杂的内容,所以可以选择GlobalFilter即可
JWT工具
登录校验需要用到JWT,而且JWT的加密需要秘钥和加密工具

具体作用如下:
AuthProperties:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问
JwtProperties:定义与JWT工具有关的属性,比如秘钥文件位置
SecurityConfig:工具的自动装配
JwtTool:JWT工具,其中包含了校验和解析token的功能
hmall.jks:秘钥文件
其中AuthProperties和JwtProperties所需的属性要在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/**
|
登录校验过滤器
定义一个登录校验的过滤器:

代码如下:
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
| package com.hmall.gateway.filter;
import com.hmall.common.exception.UnauthorizedException; import com.hmall.common.utils.CollUtils; import com.hmall.gateway.config.AuthProperties; import com.hmall.gateway.util.JwtTool; import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono;
import java.util.List;
@Component @RequiredArgsConstructor @EnableConfigurationProperties(AuthProperties.class) public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final JwtTool jwtTool;
private final AuthProperties authProperties;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); if(isExclude(request.getPath().toString())){ return chain.filter(exchange); } String token = null; List<String> headers = request.getHeaders().get("authorization"); if (!CollUtils.isEmpty(headers)) { token = headers.get(0); } Long userId = null; try { userId = jwtTool.parseToken(token); } catch (UnauthorizedException e) { ServerHttpResponse response = exchange.getResponse(); response.setRawStatusCode(401); return response.setComplete(); }
System.out.println("userId = " + userId); return chain.filter(exchange); }
private boolean isExclude(String antPath) { for (String pathPattern : authProperties.getExcludePaths()) { if(antPathMatcher.match(pathPattern, antPath)){ return true; } } return false; }
@Override public int getOrder() { return 0; } }
|
重启测试,会发现访问/items开头的路径,未登录状态下不会被拦截:

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

网关传递用户信息
现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?
由于网关发送请求到微服务依然采用的是Http请求,因此可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。
据图流程图如下:

保存用户到请求头
首先修改登录校验拦截器的处理逻辑,保存用户信息到请求头中:
拦截器获取用户
在hm-common中已经有一个用于保存登录用户的ThreadLocal工具:

其中已经提供了保存和获取用户的方法:
接下来,只需要编写拦截器,获取用户信息并保存到UserContext,然后放行即可
由于每个微服务都有获取登录用户的需求,因此拦截器我们直接写在hm-common中,并写好自动装配。这样微服务只需要引入hm-common就可以直接具备拦截器
功能,无需重复编写
在hm-common模块下定义一个拦截器:

具体代码如下:
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 { String userInfo = request.getHeader("user-info"); if (StrUtil.isNotBlank(userInfo)) { UserContext.setUser(Long.valueOf(userInfo)); } return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.removeUser(); } }
|
接着在hm-common模块下编写SpringMVC的配置类,配置登录拦截器:

具体代码如下:
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.interceptors.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()); } }
|
@ConditionalOnClass(DispatcherServlet.class)注解表示当存在SpringMvc组件时,自动装配才会生效
这是因为hm-gateway是基于Spring WebFlux而非SpringMVC,直接启动网关会出现FileNotFoundException: WebMvcConfigurer.class问题。所以需要将拦截器配置排除在生效范围之外
不过,需要注意的是,这个配置类默认是不会生效的,因为它所在的包是com.hmall.common.config,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效。
基于SpringBoot的自动装配原理,将其添加到resources目录下的META-INF/spring.factories文件中:

内容如下:
1 2 3
| org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.hmall.common.config.MyBatisConfig,\ com.hmall.common.config.MvcConfig
|
OpenFeign传递用户信息
微服务项目中很多业务要多个微服务共同合作完成,而这个过程也需要传递登录用户信息,例如:

下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁
由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头
微服务之间调用是基于OpenFeign来实现的,如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?
这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor
1 2 3 4 5 6 7 8
| public interface RequestInterceptor {
void apply(RequestTemplate template); }
|
实现这个接口,然后实现apply方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息
由于FeignClient全部都是在hm-api模块,因此在hm-api模块的com.hmall.api.config.DefaultFeignConfig中编写这个拦截器:

在com.hmall.api.config.DefaultFeignConfig中添加一个Bean:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @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()); } }; }
|
这样微服务之间通过OpenFeign调用时也会传递登录用户信息了
细节补充:
- 以上的方式是在配置类中以Bean的形式实现拦截器,所以其他的微服务模块要在启动类中添加
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)注解才能生效(注意该配置类不需要添加@Configuration)
- 另外一种方式:
- 创建一个拦截器类,并实现RequestInterceptor接口
- 利用自动装配使这个类能在其他模块中获取到Bean
Nacos配置中心
配置管理
到目前为止已经解决了微服务相关的几个问题:
- 微服务远程调用
- 微服务注册、发现
- 微服务请求路由、负载均衡
- 微服务登录用户信息传递
不过,现在依然还有几个问题需要解决:
- 网关路由在配置文件中写死了,如果变更必须重启微服务
- 某些业务配置在配置文件中写死了,每次修改都要重启服务
- 每个微服务都有很多重复的配置,维护成本高
这些问题都可以通过统一的配置管理器服务解决。而Nacos不仅仅具备注册中心功能,也具备配置管理的功能:
微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新
网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置
配置共享
把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。分为两步:
添加共享配置
以cart-service为例,寻找哪些配置是重复的,可以抽取的:
首先是jdbc相关配置:
然后是日志配置:
然后是swagger以及OpenFeign的配置:

在nacos控制台分别添加这些配置,首先是jdbc相关配置,在配置管理->配置列表中点击+新建一个配置:

其中详细的配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| spring: datasource: url: jdbc:mysql://${hm.db.host:192.168.150.101}:${hm.db.port:3306}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: ${hm.db.un:root} password: ${hm.db.pw: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的相关参数并没有写死,例如:
数据库ip:通过${hm.db.host:192.168.150.101}配置了默认值为192.168.150.101,同时允许通过${hm.db.host}来覆盖默认值
数据库端口:通过${hm.db.port:3306}配置了默认值为3306,同时允许通过${hm.db.port}来覆盖默认值
数据库database:可以通过${hm.db.database}来设定,无默认值
然后是统一的日志配置,命名为shared-log.yaml,配置内容如下:
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,配置内容如下:
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.description:黑马商城接口文档} 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中的配置了

因此,微服务整合Nacos配置管理的步骤如下:
1)引入依赖:
在cart-service模块引入依赖:
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>
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>
|
2)新建bootstrap.yaml
在cart-service中的resources目录新建一个bootstrap.yaml文件:

内容如下:
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.2.20 config: file-extension: yaml shared-configs: - dataId: shared-jdbc.yaml - dataId: shared-log.yaml - dataId: shared-swagger.yaml
|
3)修改application.yaml
由于一些配置挪到了bootstrap.yaml,因此application.yaml需要修改为:
1 2 3 4 5 6 7 8 9 10 11
| server: port: 8082 feign: okhttp: enabled: true hm: swagger: title: 购物车服务接口文档 package: com.hmall.cart.controller db: database: hm-cart
|
配置热更新
有很多的业务相关参数,将来可能会根据实际情况临时调整。例如购物车业务,购物车数量有一个上限,默认是10,对应代码如下:

现在这里购物车是写死的固定值,但实际应该将其配置在配置文件中,方便后期修改。
但现在的问题是,即便写在配置文件中,修改了配置还是需要重新打包、重启服务才能生效。能不能不用重启,直接生效呢?
这就要用到Nacos的配置热更新能力了,分为两步:
添加配置到Nacos
首先,要在nacos中添加一个配置文件,将购物车的上限数量添加到配置中:

注意文件的dataId格式(否则无法加载热更新配置文件):
1
| [服务名]-[spring.active.profile].[后缀名]
|
文件名称由三部分组成:
- **
服务名**:购物车服务,所以是cart-service
spring.active.profile:就是spring boot中的spring.active.profile,可以省略,则所有profile共享该配置
- **
后缀名**:例如yaml

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

配置热更新
在cart-service中新建一个属性读取类:

代码如下:
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; }
|
以@Value注解的方式读取配置信息,还需要在类上加上@RefreshScope注解才能具备热更新特性
接着,在业务中使用该属性加载类:

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

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

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

动态路由
网关的路由配置全部是在项目启动时由org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个Map),不会改变,也不会监听路由变更。所以,无法利用置热更新来实现路由更新
因此,必须监听Nacos的配置变更,然后手动把最新的路由更新到路由表中。这里有两个难点:
- 如何监听Nacos配置变更?
- 如何把路由信息更新到路由表?
监听Nacos配置变更
在Nacos官网中给出了手动监听Nacos配置变更的SDK:Java SDK
如果希望 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
| String serverAddr = "{serverAddr}"; String dataId = "{dataId}"; String group = "{group}";
Properties properties = new Properties(); properties.put("serverAddr", serverAddr); ConfigService configService = NacosFactory.createConfigService(properties);
String content = configService.getConfig(dataId, group, 5000);
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中自动创建好了:
NacosConfigManager中是负责管理Nacos的ConfigService的,具体代码如下:
因此,只要拿到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 18
| package org.springframework.cloud.gateway.route;
import reactor.core.publisher.Mono;
public interface RouteDefinitionWriter {
Mono<Void> save(Mono<RouteDefinition> route);
Mono<Void> delete(Mono<String> routeId);
}
|
这里更新的路由,也就是RouteDefinition,之前见过,包含下列常见字段:
- id:路由id
- predicates:路由匹配规则
- filters:路由过滤器
- uri:路由目的地
将来保存到Nacos的配置也要符合这个对象结构,以JSON来保存,格式如下:
1 2 3 4 5 6 7 8 9
| { "id": "item", "predicates": [{ "name": "Path", "args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"} }], "filters": [], "uri": "lb://item-service" }
|
以上JSON配置就等同于:
1 2 3 4 5 6 7 8
| spring: cloud: gateway: routes: - id: item uri: lb://item-service 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>
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>
|
然后在网关gateway的resources目录创建bootstrap.yaml文件,内容如下:
1 2 3 4 5 6 7 8 9 10
| spring: application: name: gateway cloud: nacos: server-addr: 192.168.150.101 config: file-extension: yaml shared-configs: - dataId: shared-log.yaml
|
接着,修改gateway的resources目录下的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中定义配置监听器:

其代码如下:
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
| package com.hmall.gateway.route;
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 com.hmall.common.utils.CollUtils; 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 @Component @RequiredArgsConstructor public class DynamicRouteLoader {
private final RouteDefinitionWriter writer; private final NacosConfigManager nacosConfigManager;
private final String dataId = "gateway-routes.json"; private final String group = "DEFAULT_GROUP"; private final Set<String> routeIds = new HashSet<>();
@PostConstruct public void initRouteConfigListener() throws NacosException { String configInfo = nacosConfigManager.getConfigService() .getConfigAndSignListener(dataId, group, 5000, new Listener() { @Override public Executor getExecutor() { return null; }
@Override public void receiveConfigInfo(String configInfo) { updateConfigInfo(configInfo); } }); updateConfigInfo(configInfo); }
private void updateConfigInfo(String configInfo) { log.debug("监听到路由配置变更,{}", configInfo); List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class); for (String routeId : routeIds) { writer.delete(Mono.just(routeId)).subscribe(); } routeIds.clear(); if (CollUtils.isEmpty(routeDefinitions)) { return; } routeDefinitions.forEach(routeDefinition -> { writer.save(Mono.just(routeDefinition)).subscribe(); routeIds.add(routeDefinition.getId()); }); } }
|
重启网关,任意访问一个接口,比如 http://localhost:8080/search/list?pageNo=1&pageSize=1:

因为现在还没有在Nacos中添加路由,所以访问不到是正常的
接下来,直接在Nacos控制台添加路由,路由文件名为gateway-routes.json,类型为json:

配置内容如下:
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
| [ { "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" } ]
|
无需重启网关,稍等几秒钟后,再次访问刚才的地址:
