spring cloud 分布式
一、拆分思想
1、服务拆分原则
- 什么时候拆?
- 对于大多数小型项目来说,一般是先采用单体架构,随着用户规模扩大、业务复杂后再逐渐拆分为微服务架构。
- 而对于一些大型项目,在立项之初目的就很明确,为了长远考虑,在架构设计时就直接选择微服务架构。
- 怎么拆?
- 粒度要小
- 高内聚
- 低耦合
2、拆分方式
- 纵向拆分:所谓纵向拆分,就是按照项目的功能模块来拆分。
- 横向拆分:而横向拆分,是看各个功能模块之间有没有公共的业务部分,如果有将其抽取出来作为通用服务。
二、服务调用
1、注册中心原理
在微服务远程调用的过程中,包括两个角色:
- 服务提供者:提供接口供其它微服务访问
- 服务消费者:调用其它微服务提供的接口
注册中心、服务提供者、服务消费者三者间关系如下:
流程如下:
- 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心
- 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)
- 调用者自己对实例列表负载均衡,挑选一个实例
- 调用者向该实例发起远程调用
2、Nacos注册中心
-
我们基于Docker来部署Nacos的注册中心,首先我们要准备MySQL数据库表,用来存储Nacos的数据。
-
编写
nacos/custom.env
文件中,有一个MYSQL_SERVICE_HOST也就是mysql地址,需要修改为自己的虚拟机IP地址PREFER_HOST_MODE=hostname MODE=standalone SPRING_DATASOURCE_PLATFORM=mysql MYSQL_SERVICE_HOST=192.168.179.137 MYSQL_SERVICE_DB_NAME=nacos MYSQL_SERVICE_PORT=3306 MYSQL_SERVICE_USER=root MYSQL_SERVICE_PASSWORD=123 MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
-
进入root目录,然后执行下面的docker命令
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
3、服务注册
-
添加依赖,在
单体服务
的pom.xml
中添加依赖:<!--nacos 服务注册发现--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>
-
配置Nacos,在
单体服务
的application.yml
中添加nacos地址配置:spring: application: name: item-service # 服务名称 cloud: nacos: server-addr: 192.168.150.101:8848 # nacos地址
4、服务发现
-
引入依赖
<!--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.150.101:8848 # nacos地址
-
发现并调用服务
- 根据微服务名称获取到服务列表
- 手写负载均衡,从服务列表挑选一个实例
- 获取请求路径,利用RestTemplate发起http请求,得到HTTP的响应。
5、服务发现工具OpenFeign
-
引入依赖:一个
openfeign
,一个负载均衡器和性能调优,底层选择okhttp
框架(另一个为Apache HttpClient )<!--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> <!--OK http 的依赖 --> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>
-
开启连接池
feign: okhttp: enabled: true # 开启OKHttp功能
-
启用OpenFeign:在启动类上添加注解
@EnableFeignClients
并指定扫描包@EnableFeignClients(basePackages = "com.hmall.api.client") @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> queryItemByIds(@RequestParam("ids") Collection<Long> ids); }
-
这里只需要声明接口,无需实现方法。接口中的几个关键信息:
@FeignClient("item-service")
:声明服务名称@GetMapping
:声明请求方式@GetMapping("/items")
:声明请求路径@RequestParam("ids") Collection<Long> ids
:声明请求参数List<ItemDTO>
:返回值类型
-
示例:在service注入后,调用
ItemClient
。@Service @RequiredArgsConstructor public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService { private final ItemClient itemClient; /*省略代码*/ private void handleCartItems(List<CartVO> vos) { Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet()); //在service注入后,调用ItemClient List<ItemDTO> items = itemClient.queryItemByIds(itemIds); if (CollUtils.isEmpty(items)) { return; } /*省略代码*/ } /*省略代码*/ }
三、路由网关
1、网关
- 网关可以做安全控制,也就是登录身份校验,校验通过才放行
- 通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去
2、网关实现方式
SpringCloudGateway
:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
3、网关服务编写
-
网关本身也是一个独立的微服务,因此也需要创建一个模块开发功能。
-
创建网关微服务
-
引入SpringCloudGateway、NacosDiscovery依赖
<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>
-
编写启动类
-
配置网关路由
server: port: 8080 spring: application: name: gateway cloud: nacos: server-addr: 192.168.150.101:8848 gateway: routes: #可以配多个规则 - id: item # 路由规则id,自定义,唯一 uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表 predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务 - Path=/items/**,/search/** # 这里是以请求路径作为判断规则 default-filters: - AddRequestHeader=hhhCss, blue
四个属性含义如下:
id
:路由的唯一标示predicates
:路由断言,其实就是匹配条件filters
:路由过滤条件。default-filters
:全局过滤条件。uri
:路由目标地址,lb://
代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。
4、网关登录校验实现
- 原来的单体springboot项目,登录校验是在拦截器里实现的。但是在spring cloud中,每一个服务在调用时都需要登录校验,也就意味着,需要些多个拦截器,很麻烦。
- 解决思路:我们可以在网关去进行登录校验,解析token后,将user信息添加到请求头,然后在每个微服务里用拦截器将用户信息提取出来。
- 这之间存在三个问题:
- 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
- 网关校验JWT之后,如何将用户信息传递给微服务?
- 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?
4.1、网关过滤器
-
Gateway
内部工作的基本原理 -
如图中所示,最终请求转发是有一个名为
NettyRoutingFilter
的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter
之前,这就符合我们的需求了! -
网关过滤器链中的过滤器有两种:
GatewayFilter
:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route
.GlobalFilter
:全局过滤器,作用范围是所有路由,不可配置。
4.2、网关登录校验
- 利用自定义
GlobalFilter
来完成登录校验。
4.2.1、JWT工具
-
拷贝登录校验需要用到JWT,而且JWT的加密需要秘钥和加密工具。
AuthProperties
:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问JwtProperties
:定义与JWT工具有关的属性,比如秘钥文件位置SecurityConfig
:工具的自动装配JwtTool
:JWT工具,其中包含了校验和解析token
的功能hmall.jks
:秘钥文件
-
其中
AuthProperties
和JwtProperties
所需的属性要在application.yaml
中配置:hm: jwt: location: classpath:hmall.jks # 秘钥地址 alias: hmall # 秘钥别名 password: hmall123 # 秘钥文件密码 tokenTTL: 30m # 登录有效期 auth: excludePaths: # 无需登录校验的路径 - /search/** - /users/login - /items/**
4.2.2、登录校验过滤器
@Component
@EnableConfigurationProperties(AuthProperties.class)
@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> authorization = request.getHeaders().get("authorization");
if (authorization != null && !authorization.isEmpty()){
token = authorization.get(0);
}
//4、校验解析token
Long userId = null;
try {
userId = jwtTool.parseToken(token);
} catch (UnauthorizedException e){
//拦截设置响应状态码,为401
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.setComplete();
}
//5、传递用户信息
String userInfo = userId.toString();
ServerWebExchange swe = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo))
.build();
//6、放行
return chain.filter(swe);
}
private boolean isExclude(String path) {
List<String> excludePaths = authProperties.getExcludePaths();
for (String excludePath : excludePaths) {
if (antPathMatcher.match(excludePath,path)) {
return true;
}
}
return false;
}
@Override
public int getOrder() {
return 0;
}
}
-
首先要继承
GlobalFilter
和Ordered
接口- GlobalFilter:public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain),用于登录校验的逻辑编写
- Ordered:public int getOrder() {return 0;},过滤器的执行顺序排序,越小优先级越高。比
NettyRoutingFilter
高就行,NettyRoutingFilter
是转发路由的,所以优先级要在它前面。
-
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
spring提供的一个路径匹配工具antPathMatcher.match(excludePath,path),用来匹配放行路径
-
最后调用
exchange.mutate()
的方法将userId
添加到请求头
生成新的路由上下文
,最后将新的路由上下文
传递给下一个过滤器
。
4.3、微服务获取用户信息
- 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行
- 由于每个微服务都需要从请求头拿到用户信息,所以我们吧拦截器配置到common模块,因为所以微服务引入了common模块,所以写一个都可以生效。
-
拷贝ThreadLocal工具
-
编写拦截器,获取用户信息并保存到
UserContext
,然后放行即可。 -
UserInfoInterceptor
拦截器代码: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(); } }
-
编写
SpringMVC
的配置类,配置登录拦截器,网关也引入了common包,但是网关是响应式编程没有SpringMvc。所以要加一个条件注解,否则网关会报错。@Configuration @ConditionalOnClass(DispatcherServlet.class) public class MvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UserInfoInterceptor()); } }
-
基于SpringBoot的自动装配原理,我们要将其添加到
resources
目录下的META-INF/spring.factories
文件中(新版本是META-INF/spring/下的.import文件)org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.hmall.common.config.MyBatisConfig,\ com.hmall.common.config.MvcConfig,\
4.4、微服务之间OpenFeign传递用户
-
OpenFeign传递用户:由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
-
微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。
-
所以要借助Feign中提供的一个拦截器接口:
feign.RequestInterceptor
public interface RequestInterceptor { /** * Called for every request. * Add data using methods on the supplied {@link RequestTemplate}. */ void apply(RequestTemplate template); }
-
我们只需要实现这个接口,然后实现apply方法,利用
RequestTemplate
类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。@Configuration public class RequestInterceptorConfig { @Bean public RequestInterceptor userInfoRequestInterceptor(){ return new RequestInterceptor() { @Override public void apply(RequestTemplate requestTemplate) { Long userId = UserContext.getUser(); if (userId != null) { requestTemplate.header("user-info", userId.toString()); } } }; } }
-
基于SpringBoot的自动装配原理,我们要将其添加到
resources
目录下的META-INF/spring.factories
文件中(新版本是META-INF/spring/下的.import文件)org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.hmall.api.config.RequestInterceptorConfig
四、配置管理
-
现在依然还有几个问题需要解决:
- 网关路由在配置文件中写死了,如果变更必须重启微服务
- 某些业务配置在配置文件中写死了,每次修改都要重启服务
- 每个微服务都有很多重复的配置,维护成本高
-
微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。
1、共享配置
- 我们可以把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。分为两步:
- 在Nacos中添加共享配置
- 微服务拉取配置
1.1、在Nacos中添加共享配置
-
进入nacos后台的配置列表
-
点击加号,进行配置
-
注意需要改变的相关参数不要写死,例如:
数据库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}
来设定,无默认值
1.2、拉取共享配置
- SpringCloud在初始化上下文的时候会先读取一个名为
bootstrap.yaml
(或者bootstrap.properties
)的文件,如果我们将nacos地址配置到bootstrap.yaml
中,那么在项目引导阶段就可以读取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,注意这里面已经定义的数据,就不要在后续的application.yaml文件中在配置了。
spring: application: name: cart-service # 服务名称 profiles: active: dev cloud: nacos: server-addr: 192.168.150.101 # nacos地址 config: file-extension: yaml # 文件后缀名 shared-configs: # 共享配置 - dataId: shared-jdbc.yaml # 共享mybatis配置 - dataId: shared-log.yaml # 共享日志配置 - dataId: shared-swagger.yaml # 共享日志配置
-
编写application.yaml
server: port: 8082 feign: okhttp: enabled: true # 开启OKHttp连接池支持 hm: swagger: title: 购物车服务接口文档 package: com.hmall.cart.controller db: database: hm-cart
2、配置热更新
- 非常容易简单理解,用到Nacos的配置热更新能力了,分为两步:
- 在Nacos中添加配置
- 在微服务读取配置
2.1、添加配置到Nacos
-
首先,我们在nacos中添加一个配置文件
-
注意文件的dataId格式:[服务名]-[spring.active.profile].[文件后缀名]
-
spring.active.profile
,可以省略,则所有profile(环境)共享该配置
2.2、在微服务读取配置
-
使用属性类,来读取数据即可。(一定要是属性类读取)
@Data @Component @ConfigurationProperties(prefix = "hm.cart") public class CartProperties { private Integer maxAmount; }
-
最后在需要使用的地方,将属性类注入,即可完成配置文件热部署。
五、动态路由
- 监听Nacos的配置变更,然后手动把最新的路由更新到路由表中。这里有两个难点:
- 如何监听Nacos配置变更?
- 如何把路由信息更新到路由表?
1、监听Nacos配置变更
-
NacosConfigAutoConfiguration
中自动创建好了NacosConfigManager
,而NacosConfigManager
中是负责管理Nacos
的ConfigService
的 -
因此,只要我们拿到
NacosConfigManager
就等于拿到了ConfigService
。 -
编写监听器。虽然官方提供的SDK是ConfigService中的addListener,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的API是这个:
String getConfigAndSignListener( String dataId, // 配置文件id String group, // 配置组,走默认 long timeoutMs, // 读取配置的超时时间 Listener listener // 监听器 ) throws NacosException;
-
既可以配置监听器,并且会根据dataId和group读取配置并返回。我们就可以在项目启动时先更新一次路由,后续随着配置变更通知到监听器,完成路由更新。
2、更新路由信息
-
更新路由要用到
org.springframework.cloud.gateway.route.RouteDefinitionWriter
这个接口: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来保存,以便配置读取转换成RouteDefinition,格式如下:
{ "id": "item", "predicates": [{ "name": "Path", "args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"} }], "filters": [], "uri": "lb://item-service" }
3、实现动态路由
-
首先, 我们在网关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
的resources
目录创建bootstrap.yaml
文件,拉取配置spring: application: name: gateway cloud: nacos: server-addr: 192.168.150.101 #nacos地址 config: file-extension: yaml shared-configs: - dataId: shared-log.yaml # 共享日志配置
-
然后,在
gateway
中定义配置监听器:@Slf4j @Component @RequiredArgsConstructor public class DynamicRouteLoader { private final RouteDefinitionWriter writer; private final NacosConfigManager nacosConfigManager; // 路由配置文件的id和分组 private final String dataId = "gateway-routes.json"; private final String group = "DEFAULT_GROUP"; // 保存更新过的路由id private final Set<String> routeIds = new HashSet<>(); @PostConstruct 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) { updateConfigInfo(configInfo); } }); // 2.首次启动时,更新一次配置 updateConfigInfo(configInfo); } private void updateConfigInfo(String configInfo) { log.debug("监听到路由配置变更,{}", configInfo); // 1.反序列化 List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class); // 2.更新前先清空旧路由 // 2.1.清除旧路由 for (String routeId : routeIds) { writer.delete(Mono.just(routeId)).subscribe(); } routeIds.clear(); // 2.2.判断是否有新的路由要更新 if (CollUtils.isEmpty(routeDefinitions)) { // 无新路由配置,直接结束 return; } // 3.更新路由 routeDefinitions.forEach(routeDefinition -> { // 3.1.更新路由 writer.save(Mono.just(routeDefinition)).subscribe(); // 3.2.记录路由id,方便将来删除 routeIds.add(routeDefinition.getId()); }); } }
-
我们直接在Nacos控制台添加路由,路由文件名为
gateway-routes.json
,类型为json
-
配置内容如下:
[ { "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" } ]
-
无需重启网关,稍等几秒钟后,再次访问地址。