1. 服务拆分方式
- 纵向拆分:按照业务模块拆分
- 横向拆分:抽取公共服务,提高复用性
2. 两种工程结构
2.1 独立 Project
每个服务为一个独立的 Project。
2.2 Maven 聚合
每个服务为一个 Module。
3. 远程调用
Spring 提供了一个 RestTemplate,可以方便地进行 HTTP 请求的发送。
-
先将RestTemplate注册为一个Bean:
@Configuration
public class RemoteCallConfig {@Bean public RestTemplate restTemplate() { return new RestTemplate(); }}
-
RestTemplate的exchange方法:
public
ResponseEntity exchange(
String url, // 请求地址
HttpMethod method, // 请求方法 (GET/POST/PUT/DELETE 等)
HttpEntity<?> requestEntity,// 请求实体(含 Headers + Body)
ClassresponseType, // 响应要转换的目标类型
Object... uriVariables // URL 中的路径参数(可变参数)
)
发送请求时,注入restTemplate然后调用exchange方法设置好参数,然后就可以得到response
4. 服务治理
但是用 RestTemplate 时,一个服务可能有多个实例,或者一个服务挂了需要切换到其他实例,这个时候如果用 RestTemplate 远程调用,就得改 RestTemplate 调用的 IP 和端口,会很麻烦,这就涉及到服务治理问题。
这时候就需要一个注册中心,服务将信息注册到注册中心,然后调用者直接在注册中心查询自己需要的服务,然后调用。
4.1 Nacos 注册中心
Nacos 本身也有一些数据需要被存储,所以得配置数据库。nacos.env 是 Nacos 的配置文件,在里面进行数据源的部署。
4.2 服务注册
- 引入 Nacos Discovery 依赖
xml
spring-cloud-starter-alibaba-nacos-discovery
- 配置 Nacos 地址
yaml
spring:
application:
name: 服务名称
cloud:
nacos:
server-addr: nacos地址
4.3 服务发现
另外,服务发现需要用到一个工具 DiscoveryClient,Spring Cloud 已经帮我们自动装配,我们可以直接注入使用。
java
private final DiscoveryClient discoveryClient;
private void handleCartItems(List<CartVO> vos) {
// 1. 根据服务名称,拉取服务的实例列表
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
// 2. 负载均衡,挑选一个实例
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
// 3. 获取实例的IP和端口
URI uri = instance.getUri();
// ... 略
}
然后取出 URI 就可以调用了(URI 包含 IP 和端口)。
5. OpenFeign
5.1 快速入门
OpenFeign 是一个声明式的 HTTP 客户端,基于常见注解实现 HTTP 请求。
- 引入依赖,包括 OpenFeign 和负载均衡组件
xml
spring-cloud-starter-openfeign
spring-cloud-starter-loadbalancer
- 通过
@EnableFeignClients注解启动 OpenFeign 功能 - 编写 FeignClient
java
@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>。我们只需要直接调用这个方法,即可实现远程调用了。
5.2 使用 FeignClient
注入接口,然后调用接口方法即可。
java
private ItemClient itemClient;
List<ItemDTO> items = itemClient.queryItemByIds(List.of(1, 2, 3));
5.3 连接池
Feign 底层发起 HTTP 请求,依赖于其它的框架。其底层支持的 HTTP 客户端实现包括:
HttpURLConnection:默认实现,不支持连接池Apache HttpClient:支持连接池OKHttp:支持连接池
因此我们通常会使用带有连接池的客户端来代替默认的 HttpURLConnection。比如,我们使用 OKHttp。
- 引入依赖
xml
feign-okhttp
- 开启连接池功能
yaml
feign:
okhttp:
enabled: true
重启服务,连接池就生效了。
5.4 优化方案
当一个服务的实体类和业务一直被其他服务重复调用时,其他服务就要重复地写接口,非常麻烦。
两种方案:
- 将这个服务进行拆分,将它的这些实体类、Client 接口、业务拆分为模块放到该服务的模块下,到时候谁需要就在 pom 中引入即可
- 将这些通用的实体类、Client 统一写到一个模块,到时候谁需要谁就引入
怎么选择呢?
前面说到微服务架构有两种:
- 一种是每个服务是一个独立的 Project,这种就可以使用方案一
- 第二种就是 Maven 聚合结构,使用方案二
注意引入模块后,一些不在启动类包下的 Client 无法被扫描到,也就无法加入 IOC 容器。解决办法很简单,在启动类上添加声明即可,两种方式:
- 方式1:声明扫描包
java
@EnableFeignClients("要扫描的client包路径")
- 方式2:声明要用的 FeignClient
java
@EnableFeignClients(clients = {client类})
5.5 日志
OpenFeign 只会在 FeignClient 所在包的日志级别为 DEBUG 时,才会输出日志。而且其日志级别有 4 级:
- NONE:不记录任何日志信息,这是默认值
- BASIC:仅记录请求的方法,URL 以及响应状态码和执行时间
- HEADERS:在 BASIC 的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据
Feign 默认的日志级别就是 NONE,所以默认我们看不到请求日志。
定义日志级别
在模块下新建一个配置类,定义 Feign 的日志级别:
java
import feign.Logger;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLogLevel() {
return Logger.Level.FULL;
}
}
配置
接下来,要让日志级别生效,还需要配置这个类。有两种方式:
- 局部生效:在某个 FeignClient 中配置,只对当前 FeignClient 生效
java
@FeignClient(value = "", configuration = DefaultFeignConfig.class)
- 全局生效 :在
@EnableFeignClients中配置,针对所有 FeignClient 生效
java
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)
6. 网关
网络的关口,负责请求的路由、转发、身份校验。
6.1 Spring Cloud Gateway
快速入门
- 引入依赖
xml
spring-cloud-starter-gateway
- 编写启动类
- 配置路由规则
yaml
spring:
application:
name: gateway
cloud:
nacos:
server-addr:
gateway:
routes:
- id: item # 路由规则id,自定义,唯一
uri: lb://服务名 # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path= # 这里是以请求路径作为判断规则
6.2 路由属性
- id:路由唯一标识
- uri:路由目标地址
- predicates:路由断言,判断请求是否符合当前路由
- filters:路由过滤器,对请求或响应做特殊处理
6.3 断言类型
Spring Cloud Gateway 中支持的断言类型有很多:
| 名称 | 说明 | 示例 |
|---|---|---|
| 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 | 权重处理 | --- |
6.4 网关工作流程
- 客户端请求进入网关后由
HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler去处理。 WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为 Filter)。- Filter 分为两部分,是因为 Filter 内部的逻辑分为 pre 和 post 两部分,分别会在请求路由到微服务之前和之后被执行。
- 只有所有 Filter 的 pre 逻辑都依次顺序执行通过后,请求才会被路由到微服务。
- 微服务返回结果后,再倒序执行 Filter 的 post 逻辑。
- 最终把响应结果返回。
最终请求转发是有一个名为 NettyRoutingFilter 的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter 之前,这就符合我们的需求了!
6.5 自定义过滤器
网关过滤器链中的过滤器有两种:
- GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由 Route
- GlobalFilter:全局过滤器,作用范围是所有路由,不可配置
java
@Component
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 编写过滤器逻辑
System.out.println("未登录,无法访问");
// 放行
// return chain.filter(exchange);
// 拦截
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(401);
return response.setComplete();
}
@Override
public int getOrder() {
// 过滤器执行顺序,值越小,优先级越高
return 0;
}
}
7. 微服务获取用户
现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?
由于网关发送请求到微服务依然采用的是 HTTP 请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用 Spring MVC 的拦截器来实现登录用户信息获取,并存入 ThreadLocal,方便后续使用。
因此,接下来我们要做两件事:
- 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
- 编写微服务拦截器,拦截请求获取用户信息,保存到
ThreadLocal后放行
使用 exchange 的 mutate 方法可以改变下游的请求信息,然后就可以在这加入请求头,将用户信息加入请求头。
java
String userInfo = userId.toString();
ServerWebExchange ex = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo))
.build();
// 放行
return chain.filter(ex);
编写拦截器后注意两件事:
- 其他微服务扫描不到拦截器的配置类,为了让配置生效要将配置类放入
spring.factories - Spring Cloud Gateway 不引入 Spring MVC,所以 Gateway 服务不能扫描该配置,解决办法很简单:Spring MVC 有通用的类文件
DispatcherServlet,而 Gateway 没有,就可以在配置类上加入条件注解@ConditionalOnClass(DispatcherServlet.class),这样 Gateway 就不会加载该配置了
8. OpenFeign 传递用户
前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。
但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。
由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
微服务之间调用是基于 OpenFeign 来实现的,并不是我们自己发送的请求。我们如何才能让每一个由 OpenFeign 发起的请求自动携带登录用户信息呢?
这里要借助 Feign 中提供的一个拦截器接口:feign.RequestInterceptor
我们只需要实现这个接口,然后实现 apply 方法,利用 RequestTemplate 类来添加请求头,将用户信息保存到请求头中。这样一来,每次 OpenFeign 发起请求的时候都会调用该方法,传递用户信息。
9. 配置管理
微服务重复配置过多,维护成本高,引入配置管理服务。
配置更改后服务要重启。
配置管理能实现快速更改配置,同时实现配置热更新。
9.1 配置共享
我们可以把微服务共享的配置抽取到 Nacos 中统一管理,这样就不需要每个微服务都重复配置了。分为两步:
- 在 Nacos 中添加共享配置。因为正常流程下,Nacos 共享配置在
application.yaml之前加载,这就会导致 application 配置里 Nacos 地址无法被读取,也就无法加载 Nacos 中的配置 - 微服务拉取配置(
bootstrap.yaml)。bootstrap.yaml的加载比application.yaml要早
步骤:
- 引入依赖
xml
spring-cloud-starter-alibaba-nacos-config
spring-cloud-starter-bootstrap
- 新建
bootstrap.yaml文件
yaml
spring:
application:
name: # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
9.2 配置热更新
修改配置后微服务无需重启就能使配置生效。
- Nacos 中要有一个与微服务名有关的配置。dataId 格式:
[服务名]-[spring.active.profile].[后缀名] - 微服务读取配置,如让一个 Properties 类加注解
@ConfigurationProperties(prefix = "")
9.3 动态路由
网关的路由配置全部是在项目启动时由 org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator 在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个 Map),不会改变。也不会监听路由变更,所以,我们无法利用配置热更新来实现路由更新。
步骤:
- 引入依赖
xml
spring-cloud-starter-alibaba-nacos-config
spring-cloud-starter-bootstrap
-
编写
bootstrap.yaml文件,修改 Gateway 的resources目录下的application.yml,把之前的路由移除 -
定义配置监听器:
java
@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:
json
{
"id": "",
"predicates": [{
"name": "",
"args": {}
}],
"filters": [],
"uri": ""
}
和之前 YAML 文件差不多,只是格式变化了,因为 JSON 格式更容易读取为 String,也就是上面要得到的 configInfo。