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 <T> ResponseEntity<T> exchange(
String url, // 请求地址
HttpMethod method, // 请求方法 (GET/POST/PUT/DELETE 等)
HttpEntity<?> requestEntity,// 请求实体(含 Headers + Body)
Class<T> responseType, // 响应要转换的目标类型
Object… uriVariables // URL 中的路径参数(可变参数)
)
发送请求时,注入restTemplate然后调用exchange方法设置好参数,然后就可以得到response
4. 服务治理
但是用 RestTemplate 时,一个服务可能有多个实例,或者一个服务挂了需要切换到其他实例,这个时候如果用 RestTemplate 远程调用,就得改 RestTemplate 调用的 IP 和端口,会很麻烦,这就涉及到服务治理问题。
这时候就需要一个注册中心,服务将信息注册到注册中心,然后调用者直接在注册中心查询自己需要的服务,然后调用。
4.1 Nacos 注册中心
Nacos 本身也有一些数据需要被存储,所以得配置数据库。nacos.env 是 Nacos 的配置文件,在里面进行数据源的部署。
4.2 服务注册
spring-cloud-starter-alibaba-nacos-discovery
spring:
application:
name: 服务名称
cloud:
nacos:
server-addr: nacos地址
4.3 服务发现
另外,服务发现需要用到一个工具 DiscoveryClient,Spring Cloud 已经帮我们自动装配,我们可以直接注入使用。
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 请求。
spring-cloud-starter-openfeign
spring-cloud-starter-loadbalancer
@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
注入接口,然后调用接口方法即可。
private ItemClient itemClient;
List<ItemDTO> items = itemClient.queryItemByIds(List.of(1, 2, 3));
5.3 连接池
Feign 底层发起 HTTP 请求,依赖于其它的框架。其底层支持的 HTTP 客户端实现包括:
- HttpURLConnection:默认实现,不支持连接池
- Apache HttpClient:支持连接池
- OKHttp:支持连接池
因此我们通常会使用带有连接池的客户端来代替默认的 HttpURLConnection。比如,我们使用 OKHttp。
feign-okhttp
feign:
okhttp:
enabled: true
重启服务,连接池就生效了。
5.4 优化方案
当一个服务的实体类和业务一直被其他服务重复调用时,其他服务就要重复地写接口,非常麻烦。
两种方案:
怎么选择呢?
前面说到微服务架构有两种:
- 一种是每个服务是一个独立的 Project,这种就可以使用方案一
- 第二种就是 Maven 聚合结构,使用方案二
注意引入模块后,一些不在启动类包下的 Client 无法被扫描到,也就无法加入 IOC 容器。解决办法很简单,在启动类上添加声明即可,两种方式:
- 方式1:声明扫描包
@EnableFeignClients("要扫描的client包路径")
- 方式2:声明要用的 FeignClient
@EnableFeignClients(clients = {client类})
5.5 日志
OpenFeign 只会在 FeignClient 所在包的日志级别为 DEBUG 时,才会输出日志。而且其日志级别有 4 级:
- NONE:不记录任何日志信息,这是默认值
- BASIC:仅记录请求的方法,URL 以及响应状态码和执行时间
- HEADERS:在 BASIC 的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据
Feign 默认的日志级别就是 NONE,所以默认我们看不到请求日志。
定义日志级别
在模块下新建一个配置类,定义 Feign 的日志级别:
import feign.Logger;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLogLevel() {
return Logger.Level.FULL;
}
}
配置
接下来,要让日志级别生效,还需要配置这个类。有两种方式:
- 局部生效:在某个 FeignClient 中配置,只对当前 FeignClient 生效
@FeignClient(value = "", configuration = DefaultFeignConfig.class)
- 全局生效:在 @EnableFeignClients 中配置,针对所有 FeignClient 生效
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)
6. 网关
网络的关口,负责请求的路由、转发、身份校验。
6.1 Spring Cloud Gateway
快速入门
spring-cloud-starter-gateway
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 网关工作流程
最终请求转发是有一个名为 NettyRoutingFilter 的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter 之前,这就符合我们的需求了!
6.5 自定义过滤器
网关过滤器链中的过滤器有两种:
- GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由 Route
- GlobalFilter:全局过滤器,作用范围是所有路由,不可配置
@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 方法可以改变下游的请求信息,然后就可以在这加入请求头,将用户信息加入请求头。
String userInfo = userId.toString();
ServerWebExchange ex = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo))
.build();
// 放行
return chain.filter(ex);
编写拦截器后注意两件事:
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 要早
步骤:
spring-cloud-starter-alibaba-nacos-config
spring-cloud-starter-bootstrap
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 配置热更新
修改配置后微服务无需重启就能使配置生效。
9.3 动态路由
网关的路由配置全部是在项目启动时由 org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator 在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个 Map),不会改变。也不会监听路由变更,所以,我们无法利用配置热更新来实现路由更新。
步骤:
spring-cloud-starter-alibaba-nacos-config
spring-cloud-starter-bootstrap
编写 bootstrap.yaml 文件,修改 Gateway 的 resources 目录下的 application.yml,把之前的路由移除
定义配置监听器:
@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": "",
"predicates": [{
"name": "",
"args": {}
}],
"filters": [],
"uri": ""
}
和之前 YAML 文件差不多,只是格式变化了,因为 JSON 格式更容易读取为 String,也就是上面要得到的 configInfo。


