微服务基础学习
微服务认识
单体架构:整个项目中所有功能模块都在一个工程中开发;项目部署时需要对所有模块编译、打包;项目架构设计、开发模式都非常简单。当项目规模较小时,这种模式上手快,部署、运维也都很方便,因此大多数小型项目都采用这种模式。
但随着项目的业务规模越来越大,团队开发人员也不断增加,单体架构就会呈现出很多问题:
- 团队协作成本高:当一个团队数十人同时协作开发同一个项目,由于所有模块都在一个项目中,不同模块的代码之间物理边界越来越模糊,最终要把功能合并到一个分支,不可避免会陷入到解决冲突的泥潭之中。
- 系统发布效率低:任何模块变更都需要发布整个系统,而系统发布过程中由于对各模块之间制约较多,需要对比各种文件,任何一处出现问题都会导致发布失败。
- 系统可用性差:单体架构各个功能模块是作为一个服务部署,相互之间会影响,一些热点功能会耗尽系统资源,导致其它服务低可用。
微服务架构:项目服务化,就是将单体架构中的功能模块从单体应用中拆分出来,独立部署为多个服务。同时要满足下面的一些特点:
- 单一职责:一个微服务负责一部分业务功能,并且其核心数据不依赖于其它模块。
- 团队自治:每个微服务都有自己独立的开发、测试、发布、运维,代码量减少,团队规模小,协作成本大大降低。
- 服务自治:每个微服务都独立部署,访问自己独立的数据库。并且要做好服务隔离,避免对其它服务产生影响。
微服务拆分
目标:高内聚、低耦合。
方式:纵向拆分(按照项目的功能模块来拆分)、横向拆分(看各个功能模块之间有没有公共的业务部分,如果有则将其抽取出来作为通用服务)
拆分后的问题:拆分后,某些数据在不同服务,无法直接调用本地方法查询数据
解决方法:通过发送http请求,实现远程调用
服务注册和发现
服务治理中的三个角色:
- 服务提供者:暴露服务接口,供其它服务调用
- 服务消费者:调用其它服务提供的接口
- 注册中心:记录并监控微服务各实例状态,推送服务变更信息
服务注册流程:
- 服务启动时会注册自己的服务信息(服务名、IP、端口)到注册中心
- 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)
- 调用则自己对实例列表进行负载均衡,挑选一个实例
- 调用者向该实例发起远程调用
当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?
- 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)
- 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除
- 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例000列表
- 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表
服务注册
首先需要搭建好Nacos
注册中心
服务注册步骤:
-
引入
nacos discovery
依赖:<!--nacos 服务注册发现--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>
-
配置Nacos地址:
spring: application: name: item-service # 服务名称 cloud: nacos: server-addr: 192.168.100.128:8848 # nacos地址
服务发现
消费者需要连接Nacos
以拉取和订阅服务,因此服务发现的前两步与服务注册一样,然后再加上服务调用即可:
-
引入
nacos discovery
依赖 -
配置
Nacos
地址 -
服务发现
private final DiscoveryClient discoveryClient; private void handleCartItems(List<CartVO> vos) { // TODO 1.获取商品id Set<Long> ids = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); //2.查询商品信息 //2.1获取服务列表 List<ServiceInstance> instances = discoveryClient.getInstances("item-service"); //2.2负载均衡,获取服务实例 ServiceInstance serviceInstance = instances.get(RandomUtil.randomInt(instances.size())); ......
OpenFeign
利用Nacos
实现了服务的自治,但是在实现服务的远程调用时,远程调用的代码太复杂了。并且这种调用方式与原本的本地方法调用差异太大,编程时的体验也不统一,一会远程调用,一会本地调用。
OpenFeign组件,可以让远程调用像本地调用一样简单,其中,远程调用的关键点主要在四点:
- 请求方法
- 请求路径
- 请求参数
- 返回值类型
OpenFeign
利用SpringMVC
的相关注解来声明上述四个参数,然后基于动态代理帮我们生成远程调用的代码,无需手动再编写。
入门
使用步骤:
-
引入依赖:
<!--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
:在启动类上加上
@EnableFeignClients
注解@EnableFeignClients @MapperScan("com.hmall.cart.mapper") @SpringBootApplication public class CartApplication { public static void main(String[] args) { SpringApplication.run(CartApplication.class, args); } }
-
编写
OpenFeign
客户端:@FeignClient("item-service") public interface ItemClient { @GetMapping("/items") List<ItemDTO> queryItems(@RequestParam(value = "ids") Collection<Long> ids); }
只需要声明接口,无需实现方法。接口中的几个关键信息:
@FeignClient("item-service")
:声明服务名称@GetMapping
:声明请求方式@GetMapping("/items")
:声明请求路径@RequestParam(value = "ids") Collection<Long> ids
:声明请求参数List<ItemDTO>
:返回值类型
有了上述信息,
OpenFeign
就可以利用动态代理帮我们实现这个方法,并且向http://item-service/items
发送一个GET
请求,携带ids为请求参数,并自动将返回值处理为List<ItemDTO>
,只需要直接调用这个方法,即可实现远程调用。 -
使用
OpenFeign
客户端:private final ItemClient itemClient; private void handleCartItems(List<CartVO> vos) { // TODO 1.获取商品id Set<Long> ids = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); //2.查询商品信息 List<ItemDTO> items = itemClient.queryItems(ids); ......
OpenFeign替我们完成了服务拉取、负载均衡、发送http请求的所有工作,简单许多。
连接池
OpenFeign
对Http
请求做了包装,其底层还是发起Http
请求,依赖于其它的框架,这些框架可以自己选择,包括以下三种:
HttpURLConnection
:默认实现,不支持连接层Apache HttpClient
:支持连接池OKHttp
:支持连接池
因此通常会使用带有连接池的客户端来代替默认的HttpURLConnection
。
例如OKHttp
使用步骤:
-
引入依赖:
<!--OK http 的依赖 --> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>
-
开启连接池:
在
application
配置文件中开启Feign
的连接池功能feign: okhttp: enabled: true #开启OKHttp连接池支持
这样连接池就生效了。
最佳实践
当多个微服务模块都需要远程调用某个模块的一个功能时,都需要定义这个功能接口,这就导致了大量的重复编码。避免重复编码最简单的方法就是抽取,这里提供了两种抽取思路:
- 思路1:抽取到微服务之外的公共
module
- 思路2:每个微服务自己抽取一个
module
方案1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高;方案2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。
日志配置
OpenFeign
只会在FeignClient
所在包的日志界别为DENUG
时,才会输出日志。而且其日志级别有4级:
NONE
:不记录任何日志信息,这是默认值。BASIC
:仅记录请求的方法,URL以及响应状态码和执行时间。HEADERS
:在BASIC的基础上,额外记录了请求和响应的头信息FULL
:记录所有所有请求和响应的明细,包括头信息、请求体、元数据。
默认为NONE
。
创建配置类,定义日志级别:
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.BASIC;
}
}
要让日志级别生效,还需要额外配置这个类,由两种方式:
-
局部生效:在某个
FeignClient
中配置,支队当前FeignClient
生效@FeignClient(name = "item-service", configuration = DefaultFeignConfig.class)
-
全局生效:在
@EnableFeignClients
中配置,针对所有FeignClient
生效@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = DefaultFeignConfig.class)
网关
网关:网关就是网络的关口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验。前端请求不能直接访问微服务,而是要请求网关:
- 网关可以做安全控制,也就是登录身份校验,校验通过才放行
- 通过认证后,网关再根据请求判断应该访问那个微服务,将请求转发过去
网关路由
由于每个微服务都有不同的地址或端口,入口不同,请求不同数据时需要访问不同的入口,需要维护多个入口地址,很麻烦,并且前端无法调用nacos
,无法实时更新服务列表,所以可以使用网关路由来解决这个问题,由于网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能,大致步骤如下:
-
创建网关微服务
-
引入
SpringCloudGateway
、NacosDiscovery
依赖<!--网关--> <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>
-
编写启动类
-
配置网关路由
server: port: 8080 spring: application: name: gateway cloud: nacos: server-addr: 192.168.100.128:8848 gateway: routes: - id: user-service # 自定义唯一路由规则id uri: lb://user-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务实例 predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务 - Path=/users/** # 这里是以请求路径作为判断规则 - id: item-service uri: lb://item-service predicates: - Path=/items/**, /search/** - id: cart-service uri: lb://cart-service predicates: - Path=/carts/** - id: trade-service uri: lb://trade-service predicates: - Path=/orders/** - id: pay-service uri: lb://pay-service predicates: - Path=/pays/**
路由规则中常见的四个属性如下:
id
:路由的唯一标识predicates
:路由断言,也就是匹配条件filters
:路由过滤条件uri
:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,然后负载均衡选择一个访问
SpringCloudGateway
中支持的断言类型很多,参考官方文档:Spring Cloud Gateway
单体架构时只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息,而微服务拆分后,每个微服务都独立部署,不再共享数据,如果每个微服务都要自己做登录校验,显然不可取。
假设当前的登录时基于JWT
来实现的,校验JWT
的算法复杂,而且需要用到秘钥,如果每个微服务都去做登录校验,就存在两大问题:
- 每个微服务都需要知道
JWT
的秘钥,不安全 - 每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关,完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
- 只需要在网关和用户服务保存秘钥
- 只需要在网关开发登录校验功能
网关登录校验
网关过滤器
登录校验必须在请求到微服务之前做,而弯管的请求转发是Gateway内部代码实现的,所以需要了解Gateway内部工作的基本原理。
如图所示:
- 客户端请求进入网关后由
HandlerMapping
对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler
去处理。 WebHandler
则会加载当前路由下需要执行的过滤器链(Filter chain
),然后按照顺序逐一执行过滤器(Filter
)。- 图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为
pre
和post
两个部分,分别会在请求路由到微服务之前和之后被执行。 - 只有所有
Filter
的pre
逻辑都依次顺序执行通过后,请求才会被路由到微服务。 - 微服务返回结果后,再倒序执行Filter的post逻辑。
- 最终把响应结果返回。
如图中所示,最终请求转发是由一个名为NettyRoutingFilter
的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序中最靠后的一个,所以只需要定义一个过滤器,在其中实现登录校验逻辑,并且在NettyRoutingFilter
之前执行。
网关过滤器链中的过滤器有两种:
GatewayFilter
:路由过滤器,作用范围比较灵活,可以是任意指定的路由RouteGolbalFilter
:全局过滤器,作用范围是所有路由,不可配置
FilteringWebHandler
在处理请求时,会将GlobalFilter
装饰为GatewayFilter
,然后放到用一个过滤器链中,排序以后依次执行。
Gateway中内置了很多的GatewayFilter
,详情参考官方文档:[Spring Cloud Gateway Gateway内置的GatewayFilter
过滤器使用起来非常简单,无需编码,只要在yaml
文件中简答配置即可,而且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route。
自定义GatewayFilter
自定义GatewayFilter
不是直接实现GatewayFilter
,而是实现AbstractGatewayFilterFactory
,最简单的方式:
package com.hmall.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class PrintMessageGatewayFilterFactory 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("请求路径:" + request.getPath());
// 放行
return chain.filter(exchange);
}
};
}
}
注意:该类的名称一定要以
GatewayFIlterFactory
为后缀!
然后在yaml
中配置使用:
spring:
cloud:
gateway:
default-filters:
- PrintMessage # 此处直接以自定义的GatewayFilterFactory类名称前缀类声明过滤器
另外,这种过滤器还可以支持动态配置参数,实现起来相对比较麻烦,示例:
package com.hmall.gateway.filter;
import lombok.Data;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
public class PrintMessageGatewayFilterFactory extends AbstractGatewayFilterFactory<PrintMessageGatewayFilterFactory.Config> {
@Override
public GatewayFilter apply(Config config) {
return new OrderedGatewayFilter(new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求
ServerHttpRequest request = exchange.getRequest();
// 执行过滤器逻辑
// 获取并打印config值
System.out.println(config.getFirstMessage());
System.out.println(config.getSecondMessage());
System.out.println(config.getThirdMessage());
// 放行
return chain.filter(exchange);
}
}, 100) {};
}
// 自定义配置属性,成员变量名称很重要,后面会用到
@Data
static class Config {
private String firstMessage;
private String secondMessage;
private String thirdMessage;
}
// 将变量名称依次返回,顺序很重要,将来读取参数时需要按顺序获取
@Override
public List<String> shortcutFieldOrder() {
return List.of("firstMessage", "secondMessage", "thirdMessage");
}
// 获取当前配置类的类型
@Override
public Class<Config> getConfigClass() {
return Config.class;
}
}
然后在yaml
文件中使用:
spring:
cloud:
gateway:
default-filters:
- PrintMessage=1,2,3 # 注意,这里多个参数以","隔开,将来会按照shortcutFieldOrder()方法返回的参数顺序依次复制
上面这种配置方式参数必须严格按照shortcutFieldOrder()
方法的返回参数顺序来赋值;还有一种用法,无需按照这个顺序,就是手动指定参数名:
spring:
cloud:
gateway:
default-filters:
- name: PrintMessage
args: # 手动指定参数名,无需按照参数顺序
firstMessage: 1
secondMessage: 2
thirdMessage: 3
自定义GlobalFilter
自定义GlobalFilter
则简单很多,直接实现GlobalFilter
即可,而且也无法设置动态参数。
示例:利用自定义GlobalFilter
来完成登录校验
package com.hmall.gateway.filter;
import com.hmall.common.exception.UnauthorizedException;
import com.hmall.gateway.config.AuthProperties;
import com.hmall.gateway.utils.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.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.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final JwtTool jwtTool;
private final AuthProperties authProperties;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
/**
* 身份校验过滤器
* @param exchange
* @param chain
* @return
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求
ServerHttpRequest request = exchange.getRequest();
// 判断请求路径是否需要拦截
if (isExcludeUrl(request.getPath().toString())) {
// 放行
return chain.filter(exchange);
}
// 获取token
String token = null;
List<String> headers = request.getHeaders().get("Authorization");
if (headers != null && headers.size() > 0) {
token = headers.get(0);
}
Long userId = null;
// 解析token
try {
userId = jwtTool.parseToken(token);
} catch(UnauthorizedException e) {
// token无效,返回401
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token解析成功,传递用户信息(通过请求头)
String userInfo = userId.toString();
ServerWebExchange ex = exchange.mutate()
.request(r -> r.header("user-info", userInfo))
.build();
// 携带请求头放行
return chain.filter(ex);
}
/**
* 判断请求路径是否需要拦截
* @param string
* @return
*/
private boolean isExcludeUrl(String string) {
for (String path : authProperties.getExcludePaths()) {
if (antPathMatcher.match(path, string)) {
return true;
}
}
return false;
}
/**
* 过滤器优先级,越小优先级越高
* @return
*/
@Override
public int getOrder() {
return 0;
}
}
微服务获取用户
在自定义的网关过滤器,我们将用户信息保存在了请求头中,然后根据路由传递给微服务,然后因为每个微服务都有common
模块依赖,所以只需要在common
模块中编写拦截器获取请求中登录用户的信息,避免在每个微服务中重复编写。具体步骤:
-
定义拦截器
-
编写
SpringMVC
的配置类,配置拦截器 -
由于该配置类(假设为
com.hmall.common.config.MvcConfig
)所在包与其它微服务的扫描包不一致,所以无法被扫描到,因此无法生效,基于SpringBoot
的自动装配原理,需要将其添加到resources
目录下的META-INF/spring.factories
文件中org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.hmall.common.config.MvcConfig
后续微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
之前我们微服务之间调用是基于OpenFeign
来实现的,其中Feign中提供了一个拦截器接口:feign.RequestInterceptor
,只需要实现这个接口,实现apply方法,利用RequestTemplate
类来添加请求头,将用户信息保存到请求头中,这样,每次OpenFeign
发起请求的时候都会调用该方法,传递用户信息。
@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
调用时也会传递登录用户信息了。
配置管理
现在依然还有几个问题需要解决:
- 网关路由在配置文件中写死了,如果变更必须重启微服务
- 某些业务配置在配置文件中写死了,每次修改都要重启服务
- 每个微服务都有很多重复的配置,维护成本高
这些问题都可以通过统一的配置管理器服务解决。而Nacos
不仅仅具备注册中心功能,也具备配置管理的功能:微服务共享的配置可以统一交给Nacos
保存和管理,在Nacos
控制台修改配置后,Nacos
会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。
网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置。
配置共享
我们可以把微服务共享的配置抽取到Nacos
中统一管理,这样就不需要每个微服务都重复配置了。分为两步:
- 在
Nacos
中添加共享配置 - 微服务拉取配置
比如添加jdbc
相关配置,首先在nacos
控制台的配置管理的配置列表中点击+新建一个配置,然后在弹出的表单中填写信息:
注意这里的jdbc
的相关参数并没有写死,例如:
- 数据库
ip
:通过${hm.db.host:192.168.100.128}
配置了默认值为192.168.100.128
,同时允许通过${hm.db/host}
来覆盖默认值 - 数据库database:通过
${hm.db.database}
来设定,无默认值
接下来,就需要在微服务拉取共享配置,将拉取到共享配置与本地的application.yaml
配置合并,完成项目上下文的初始化。
不过,需要注意的是,读取Nacos
配置是SpringCloud
上下文(ApplicationContext
)初始化时处理的,发生在项目的引导阶段。然后才会初始化SpringBoot
上下文,去读取application.yaml
。也就是说引导阶段,application.yaml
文件尚未读取,根本不知道nacos
地址,该如何去加载nacos
中的配置文件呢?
SpringCloud
在初始化上下文的时候会先读取一个名为bootstrap.yaml
(或者bootstrap.properties
)的文件,如果我们将nacos
地址配置到bootstrap.yaml
中,那么在项目引导阶段就可以读取nacos
中的配置了。
因此,微服务整合Nacos
配置管理的步骤如下:
-
引入依赖:
<!--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>
-
新建
bootstrap.yaml
文件:spring: application: name: cart-service # 服务名称 profiles: active: dev cloud: nacos: server-addr: 192.168.100.128 # nacos地址 config: file-extension: yaml # 文件后缀名 shared-configs: # 共享配置 - dataId: shared-jdbc.yaml # 共享mybatis配置
-
修改
application.yaml
文件,由于一些配置挪到了boostrap.yaml
文件和共享配置中,所以将之前的部分配置移除。
配置热更新
有很多的业务相关参数,将来可能会根据实际情况临时调整。例如购物车业务,购物车数量有一个上限,这个上限值应该将其配置在配置文件中,方便后期修改,但是即便写在配置文件中,修改了配置还是需要重新打包、重启服务才能生效。这里就可以用到Nacos
的配置热更新能力了,分为两步:
- 在
Nacos
中添加配置 - 在微服务读取配置
首先,我们在nacos中添加一个配置文件,将购物车的上限数量添加到配置中,注意dataId的格式:
[服务名]-[spring.active.profile].[后缀名]
文件名称由三部分组成:
- 服务名:微服务名称
spring.active.profile
:就是SpringBoot
中的spring.active.profile
,可以省略,则所有profile共享该配置- 后缀名:例如
yaml
然后在微服务中读取该配置,并创建一个配置属性读取类即可。之后无需重启服务,配置热更新就生效了。
动态路由
网关的路由配置全部是在项目启动时由org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator
在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个Map),不会改变。也不会监听路由变更,所以,无法利用上述的配置热更新来实现路由更新。
在Nacos
官网中给出了手动监听Nacos
配置变更的SDK
:Java SDK
核心的步骤有2步:
- 创建
ConfigService
,目的是连接到Nacos
- 添加配置监听器,编写配置变更的通知处理逻辑
由于我们采用了spring-cloud-starter-alibaba-nacos-config
自动装配,因此ConfigService
已经在com.alibaba.cloud.nacos.NacosConfigAutoConfiguration
中自动创建好了。
因此,只要我们拿到NacosConfigManager
就等于拿到了ConfigService
,第一步就实现了。
第二步,编写监听器。虽然官方提供的SD
是ConfigService
中的addListener
,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的API
是这个:
String getConfigAndSignListener(
String dataId, // 配置文件id
String group, // 配置组,走默认
long timeoutMs, // 读取配置的超时时间
Listener listener // 监听器
) throws NacosException;
既可以配置监听器,并且会根据dataId
和group
读取配置并返回。我们就可以在项目启动时先更新一次路由,后续随着配置变更通知到监听器,完成路由更新。
更新路由要用到org.springframework.cloud.gateway.route.RouteDefinitionWriter
这个接口,这里更新的路由,也就是RouteDefinition
,之前我们见过,包含下列常见字段:
- id:路由id
- predicates:路由匹配规则
- filters:路由过滤器
- uri:路由目的地
首先, 我们在网关gateway
引入依赖:
<!--统一配置管理-->
<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>
然后,在gateway
中定义配置监听器:
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 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 routeDefinitionWriter;
private final NacosConfigManager nacosConfigManager;
private final static String dataId = "shared-route.json"; //json格式方便解析
private final static String group = "DEFAULT_GROUP";
private final static 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) {
// 更新路由配置
updateRouteConfig(configInfo);
}
});
// 第一次加载时就更新路由配置
updateRouteConfig(configInfo);
}
// 更新路由配置
private void updateRouteConfig(String configInfo) {
log.info("监听到路由配置更新:{}", configInfo);
// 解析路由配置
List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
// 删除之前的路由信息
for (String routeId : routeIds) {
routeDefinitionWriter.delete(Mono.just(routeId)).subscribe();
}
routeIds.clear();
if (routeDefinitions == null || routeDefinitions.isEmpty()) {
// 无路由信息
return;
}
// 添加路由信息
for (RouteDefinition routeDefinition : routeDefinitions) {
routeDefinitionWriter.save(Mono.just(routeDefinition)).subscribe();
routeIds.add(routeDefinition.getId());
}
}
}
重启网关,我们就可以直接在Nacos
控制台修改对应路由配置文件,无需重启就能自动更新最新路由配置文件了。