深浅模式
在单体项目中,所有功能都在一个项目里。购物车想查询商品信息时,通常可以直接调用商品相关的 Service 方法。因为购物车模块和商品模块都在同一个项目、同一个 JVM 进程里。
但拆成微服务后,情况就变了。假设系统被拆成了两个服务:
text
cart-service:购物车服务
item-service:商品服务购物车服务只负责购物车相关业务,商品服务只负责商品相关业务。此时购物车服务中已经没有 itemService 可以直接调用了。
如果购物车页面需要展示商品名称、价格、图片等信息,就必须让购物车服务去请求商品服务。
例如在商城项目里,购物车服务只记录展示用户加入购物车的商品 id 和数量。购物车数据中只有:
text
商品 id:1,数量:2
商品 id:2,数量:1购物车服务知道“用户买了哪个商品、买了几个”,但它不知道“这个商品叫什么、多少钱、图片是什么”。
这些信息不能随便塞进购物车服务里,否则会带来数据冗余和维护问题。
比如商品价格发生变化,如果购物车服务也保存了一份商品价格,那么商品服务改价后,购物车服务里的旧价格也要同步更新。否则用户看到的价格就可能和商品实际价格不一致。
所以更合理的设计是:
购物车服务只保存购物车行为数据,需要展示商品详情时,再根据商品 id 去商品服务查询。
这样每个服务只维护自己负责的数据,边界更清楚。
大致流程是:
text
用户查看购物车
↓
cart-service 查询购物车记录
↓
cart-service 拿到商品 id 列表
↓
cart-service 远程调用 item-service
↓
item-service 返回商品信息
↓
cart-service 组装购物车展示结果服务拆分之后,数据不一定在当前服务里,当前服务需要通过网络去找别的服务拿数据。
RestTemplate
Spring 提供了一个 RestTemplate 工具,可以方便地发送 HTTP 请求。它的作用可以简单理解为:
在 Java 代码里帮我们访问另一个服务的接口。
比如商品服务提供了一个接口:
text
GET http://localhost:8081/items?ids=1,2,3这个接口可以根据商品 id 列表查询商品信息。那么购物车服务就可以使用 RestTemplate 去请求这个接口,从而拿到商品数据。
注入 RestTemplate
使用 RestTemplate 之前,通常先把它交给 Spring 容器管理。可以编写一个配置类:
java
@Configuration
public class RemoteCallConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}这样以后在需要远程调用的地方,就可以直接注入使用:
java
private final RestTemplate restTemplate;
public CartService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}发起远程调用
假设商品服务提供了一个批量查询接口:
text
GET http://localhost:8081/items?ids=1,2,3购物车服务可以先从购物车记录中取出商品 id,然后调用商品服务查询商品详情:
java
public void queryCartItems() {
List<Long> ids = List.of(1L, 2L, 3L);
String url = "http://localhost:8081/items?ids={ids}";
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
url,
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<ItemDTO>>() {},
Map.of("ids", ids)
);
List<ItemDTO> items = response.getBody();
if (items == null || items.isEmpty()) {
return;
}
Map<Long, ItemDTO> itemMap = items.stream()
.collect(Collectors.toMap(ItemDTO::getId, item -> item));
}这段代码的核心不是记住每一个参数,而是理解它做了什么:
text
购物车服务准备商品 id
↓
拼出商品服务的请求地址
↓
使用 RestTemplate 发送 GET 请求
↓
拿到商品服务返回的商品列表
↓
转成 Map,方便后续根据商品 id 匹配购物车数据其中 itemMap 的作用是让后续查找商品信息更方便。
比如原本要根据商品 id 查找商品信息,如果每次都遍历 items,会比较麻烦。转成 Map 之后,就可以直接这样获取:
java
ItemDTO item = itemMap.get(itemId);exchange 方法说明
exchange 是 RestTemplate 中比较通用的请求方法,适合处理各种 HTTP 请求。
它的大致结构是:
java
ResponseEntity<T> response = restTemplate.exchange(
url,
method,
requestEntity,
responseType,
uriVariables
);几个参数可以这样理解。
url表示请求路径,也就是要访问哪个接口。method表示请求方式。requestEntity表示请求实体,GET 请求一般不需要请求体。responseType表示返回值类型。
如果返回的是普通对象,可以直接写:
java
ItemDTO.class但如果返回的是集合,比如:
java
List<ItemDTO>就不能简单写成 List.class,否则 Java 不知道 List 里面具体是什么类型。
这时可以使用:
java
new ParameterizedTypeReference<List<ItemDTO>>() {}它的作用就是告诉 RestTemplate:这次返回的不是普通 List,而是 List<ItemDTO>
uriVariables表示路径中的参数值。它会把 url 中的{ids}替换成真正的商品 id 参数。
getForEntity
如果想先用更简单的方式体验远程调用,也可以使用 getForEntity。
例如:
java
String url = "http://localhost:8081/items?ids=1,2,3";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String body = response.getBody();这段代码表示:
text
发送一个 GET 请求
↓
把商品服务返回的数据先当成字符串接收
↓
后续再手动转换成 Java 对象如果项目中有 JSON 工具类,可以再把字符串转成集合:
java
List<ItemDTO> items = JsonUtils.toList(body, ItemDTO.class);
if (items == null || items.isEmpty()) {
return;
}
Map<Long, ItemDTO> itemMap = items.stream()
.collect(Collectors.toMap(ItemDTO::getId, item -> item));这种写法更直观,适合理解远程调用的基本过程。不过在实际开发中,如果已经知道返回值类型,使用 exchange 或其他更明确的方法会更方便。
注册中心
在上一节远程调用中,购物车服务调用商品服务时,是直接把商品服务的地址写在代码里的:
java
String url = "http://localhost:8081/items?ids=1,2,3";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);这种写法在在真实项目里肯定不合适。
服务地址写死在源码里,一旦商品服务的 IP 或端口发生变化,购物车服务也要跟着修改代码、重新打包、重新部署。而且,真实项目中一个服务往往不止部署一份。比如商品服务为了抗住更高并发,可能会同时部署多个实例:
text
item-service 实例 1:192.168.1.10:8081
item-service 实例 2:192.168.1.11:8081
item-service 实例 3:192.168.1.12:8081这时购物车服务就会遇到新的问题:它应该调用哪一个商品服务?如果某个商品服务宕机了,调用方又该怎么知道?如果后续新增了商品服务实例,调用方又该怎么发现?
如果这些地址都靠程序员手动维护,服务越多,系统就越乱。微服务不是把一个大泥潭拆成一堆小泥潭,所以这里就需要注册中心来统一管理服务地址。
注册中心要解决的核心问题是:
服务之间不再写死地址,通过服务名动态找到对方。
注册中心如何让服务找到彼此
注册中心本身也是一个独立服务,它的作用可以先简单理解成一张“服务地址表”。服务提供者启动时,会把自己的服务名、IP、端口等信息注册到注册中心。
比如商品服务启动后,会告诉注册中心:
text
我是 item-service
我的地址是 192.168.1.10
我的端口是 8081注册中心收到后,就会把这条信息保存起来。可以粗略理解成内部维护了一个 Map:
text
item-service:
192.168.1.10:8081
192.168.1.11:8081
192.168.1.12:8081
cart-service:
192.168.1.20:8082这里主要涉及两个角色。

- 服务提供者:被别人调用的服务。比如商品服务提供商品查询接口,它是服务提供者。
- 服务调用者:需要调用别人接口的服务。比如购物车服务需要查询商品信息,它是服务调用者。
服务调用者不会再自己记住商品服务的具体地址,而是根据服务名去注册中心查询。大致流程是:
text
item-service 启动
↓
向注册中心注册自己的服务名、IP、端口
↓
cart-service 调用商品服务
↓
根据 item-service 这个服务名,从注册中心获取实例列表
↓
从多个实例中选择一个
↓
发起远程调用这样一来,购物车服务只需要知道自己要调用的是 item-service,不用关心它具体部署在哪台机器、哪个端口。
心跳续约与服务剔除
服务在运行过程中可能会宕机、重启,也可能因为网络异常暂时不可用。如果注册中心一直保存旧地址,调用方就可能继续调用一个已经失效的服务实例。
所以服务提供者需要定时向注册中心发送心跳,告诉注册中心自己还活着。
比如商品服务会隔一段时间向注册中心发送一次请求,大致意思是:
text
我是 item-service 的某个实例。我现在还在线,可以继续被调用。这个过程就叫心跳续约。
注册中心收到心跳后,就会认为这个服务实例仍然可用,并刷新它的存活状态。
如果注册中心连续一段时间没有收到某个实例的心跳,就会认为这个实例可能已经不可用,然后把它从可用服务列表中剔除。
剔除之后,服务列表就会发生变化。
原来可能是:
text
item-service:
192.168.1.10:8081
192.168.1.11:8081
192.168.1.12:8081如果 192.168.1.11:8081 这个实例长时间没有心跳,注册中心就会把它移除:
text
item-service:
192.168.1.10:8081
192.168.1.12:8081这样服务调用者后续再获取 item-service 的实例列表时,就不会继续拿到那个已经失效的地址。
如果调用者本地缓存了服务列表,注册中心也可以在服务实例变化时通知调用者更新缓存。这样调用者手里的服务列表会尽量保持最新,后续负载均衡时也能避开不可用实例。
Nacos
早期 Spring Cloud 中常见的注册中心是 Eureka。
不过现在国内项目中,Nacos 使用得更多。Nacos 是阿里巴巴开源的组件,已经整合进 Spring Cloud Alibaba 体系。
Nacos 不仅可以作为注册中心,也可以作为配置中心。我们先只关注它的服务注册与发现能力。
使用 Nacos 作为注册中心
继续使用前面的例子:
text
cart-service:购物车服务
item-service:商品服务现在要做的是:
text
item-service 启动后注册到 Nacos
cart-service 调用 item-service 时,从 Nacos 获取服务实例首先需要启动一个 Nacos 服务端。
Nacos 默认控制台地址通常是:
text
http://localhost:8848/nacosNacos 启动后,它就是一个独立的注册中心服务。其他微服务启动时,会连接到 Nacos,并把自己注册进去。
- 引入依赖
要让服务注册到 Nacos,需要引入 Nacos Discovery 依赖。
注意,服务提供者和服务调用者都要引入。
也就是说,item-service 和 cart-service 都需要。
xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>具体版本通常交给 Spring Cloud Alibaba 的依赖管理统一控制,不建议随便单独指定版本。
微服务项目里,Spring Boot、Spring Cloud、Spring Cloud Alibaba 的版本要匹配,否则很容易出现依赖冲突或启动失败。
配置 Nacos 地址
以 item-service 为例,在配置文件中添加:
yaml
server:
port: 8081
spring:
application:
name: item-service
cloud:
nacos:
server-addr: localhost:8848这里最重要的是两个配置。
spring.application.name 表示当前服务注册到 Nacos 时使用的服务名。
yaml
spring:
application:
name: item-service其他服务以后就可以通过 item-service 这个名字找到商品服务。
spring.cloud.nacos.server-addr 表示 Nacos 注册中心的地址。
yaml
spring:
cloud:
nacos:
server-addr: localhost:8848cart-service 也需要类似配置,只是服务名和端口不同:
yaml
server:
port: 8082
spring:
application:
name: cart-service
cloud:
nacos:
server-addr: localhost:8848配置完成后,启动这两个服务,就可以在 Nacos 控制台中看到它们的注册信息。
从注册中心获取服务实例
现在回到购物车服务调用商品服务的代码。
之前的写法是:
java
String url = "http://localhost:8081/items?ids=1,2,3";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);这段代码的问题是地址写死了。
接入 Nacos 后,可以通过 DiscoveryClient 根据服务名获取实例列表。
先注入 DiscoveryClient:
java
private final DiscoveryClient discoveryClient;
private final RestTemplate restTemplate;
public CartService(DiscoveryClient discoveryClient, RestTemplate restTemplate) {
this.discoveryClient = discoveryClient;
this.restTemplate = restTemplate;
}然后根据服务名获取商品服务实例:
java
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");这里的 "item-service" 对应的就是商品服务配置中的服务名:
yaml
spring:
application:
name: item-service因为商品服务可能部署多个实例,所以返回值是一个 List。
拿到实例列表后,可以先随机选择一个实例进行调用:
java
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
if (instances == null || instances.isEmpty()) {
throw new RuntimeException("商品服务暂时不可用");
}
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
String url = instance.getUri() + "/items?ids=" + CollUtil.join(itemIds, ",");
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String body = response.getBody();
List<ItemDTO> items = JSONUtil.toList(body, ItemDTO.class);这段代码的流程是:
text
从 Nacos 获取 item-service 的实例列表
↓
从实例列表中随机选一个
↓
拼接真正的请求地址
↓
使用 RestTemplate 发起 HTTP 请求
↓
把返回结果转成商品列表和写死 localhost:8081 相比,现在已经灵活很多了。
商品服务到底部署在哪个 IP、哪个端口,不再由购物车服务自己写死,而是交给 Nacos 维护。
负载均衡算法
当一个服务存在多个实例时,调用方需要决定这次请求交给哪一个实例处理。
这个过程就是负载均衡。
常见的负载均衡算法有几种。
随机算法,就是从服务实例列表中随机选择一个实例。
它实现简单,适合入门理解。不过短时间内可能不够均匀,有些实例可能连续被选中。
java
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));轮询算法,就是按照顺序依次调用每个实例。
text
第 1 次调用:实例 A
第 2 次调用:实例 B
第 3 次调用:实例 C
第 4 次调用:实例 A轮询是非常常见的负载均衡策略。
如果每个实例的配置差不多,请求处理能力也差不多,轮询通常就够用了。
加权轮询是在轮询基础上增加权重。
比如实例 A 的机器配置更高,可以设置更大的权重,让它承担更多请求:
text
实例 A:权重 5
实例 B:权重 1这样实例 A 被选中的次数会更多。
最少连接算法会优先选择当前连接数较少的实例。
它的思路是:谁现在更空闲,就让谁处理新的请求。这个策略适合请求处理时间差异比较大的场景,但实现和统计成本会更高。
哈希算法会根据某个固定值计算调用哪个实例。
比如根据用户 id 做哈希,让同一个用户的请求尽量落到同一个实例上。这种方式适合某些需要请求落点相对稳定的场景。
入门阶段重点掌握随机和轮询即可。
随机好理解,轮询最常用。其他算法先知道名字和大概适用场景就行,不需要一开始钻太深。
当前写法的问题
使用 Nacos 后,我们解决了服务地址写死的问题。
但现在的调用代码依然很麻烦:
java
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
if (instances == null || instances.isEmpty()) {
throw new RuntimeException("商品服务暂时不可用");
}
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
String url = instance.getUri() + "/items?ids=" + CollUtil.join(itemIds, ",");
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String body = response.getBody();
List<ItemDTO> items = JSONUtil.toList(body, ItemDTO.class);每次远程调用,都要手动做很多事:
text
获取服务实例列表
判断服务是否可用
选择一个服务实例
拼接请求地址
发起 HTTP 请求
解析返回结果这说明注册中心只是解决了“服务去哪找”的问题,但还没有让远程调用本身变得简单。
我们真正想要的是:调用商品服务时,最好像调用本地方法一样简单。
这就可以继续引出 OpenFeign。
远程调用:OpenFeign
上一节中,我们使用 Nacos 实现了服务注册与发现,又使用 RestTemplate 完成了远程调用。
但现在的调用代码还是有点复杂。
购物车服务想调用商品服务,居然要自己获取实例、自己选实例、自己拼 URL、自己发送请求、自己解析结果。能跑是能跑,但写起来不够优雅。
OpenFeign 要解决的就是这个问题。
它可以把远程 HTTP 调用包装成接口方法调用。
也就是说,我们只需要定义一个 Java 接口,然后在接口方法上写清楚要调用哪个 HTTP 接口,剩下的请求发送、服务发现、负载均衡等操作,交给 OpenFeign 处理。
简单来说:
RestTemplate 是自己拼请求去调用别人;OpenFeign 是写一个接口,让它帮我们调用别人。
引入依赖
使用 OpenFeign,需要先引入依赖。
在调用方服务中引入即可。
比如这里是 cart-service 要调用 item-service,所以主要在 cart-service 中引入 OpenFeign。
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>如果当前项目使用的是较新的 Spring Cloud 体系,通常还需要引入负载均衡依赖:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>这是因为 OpenFeign 调用服务名时,需要负载均衡组件从多个服务实例中选择一个。
如果商品服务只有一个实例,好像看不出负载均衡的作用。
但只要 item-service 部署多个实例,负载均衡就会负责决定这次请求交给哪一个实例。
开启 OpenFeign
引入依赖后,需要在启动类上开启 OpenFeign 支持。
比如 cart-service 的启动类:
java
@SpringBootApplication
@EnableFeignClients
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}@EnableFeignClients 的作用是扫描项目中的 Feign 客户端接口,并为这些接口创建代理对象。
也就是说,我们后面写的 ItemClient 接口,并不需要自己手动实现。
Spring 会根据注解信息,帮我们生成一个代理对象,用来真正发起远程调用。
编写 Feign 客户端
购物车服务要调用商品服务,所以可以在 cart-service 中新建一个 client 包,然后创建 ItemClient 接口。
为什么这个接口写在 cart-service 里?
因为它是购物车服务用来调用商品服务的客户端。
从购物车服务的视角看,ItemClient 就像一个“商品服务的本地代理”。
java
@FeignClient("item-service")
public interface ItemClient {
@GetMapping("/items")
List<ItemDTO> queryItemByIds(@RequestParam("ids") List<Long> ids);
}这里有几个关键点。
@FeignClient("item-service") 表示这个客户端要调用的服务名是 item-service。
这个名字对应的是商品服务配置中的服务名:
yaml
spring:
application:
name: item-service也就是说,OpenFeign 会根据 item-service 这个服务名,去注册中心获取商品服务实例。
@GetMapping("/items") 表示要调用商品服务中的 /items 接口。
这个路径要和商品服务中真实提供的接口路径保持一致。
假设商品服务中原本有这样的方法:
java
@GetMapping("/items")
public List<ItemDTO> queryItemByIds(@RequestParam("ids") List<Long> ids) {
// 查询商品信息
}那么 Feign 客户端中就可以写一个对应的方法声明:
java
@GetMapping("/items")
List<ItemDTO> queryItemByIds(@RequestParam("ids") List<Long> ids);注意,Feign 客户端里只写接口方法声明,不写方法体。
因为它不是用来处理请求的 Controller,而是用来发起请求的客户端。
使用 Feign 客户端
有了 ItemClient 之后,原来那些 RestTemplate 和 DiscoveryClient 的代码就可以不要了。
原来的字段可以删掉:
java
private final RestTemplate restTemplate;
private final DiscoveryClient discoveryClient;改成注入 ItemClient:
java
private final ItemClient itemClient;
public CartService(ItemClient itemClient) {
this.itemClient = itemClient;
}然后调用商品服务时,直接写:
java
List<ItemDTO> items = itemClient.queryItemByIds(itemIds);完整一点可以这样:
java
public void queryCartItems(List<Long> itemIds) {
List<ItemDTO> items = itemClient.queryItemByIds(itemIds);
if (items == null || items.isEmpty()) {
return;
}
Map<Long, ItemDTO> itemMap = items.stream()
.collect(Collectors.toMap(ItemDTO::getId, item -> item));
}现在代码就清爽很多了。
原来是这样:
java
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
String url = instance.getUri() + "/items?ids=" + CollUtil.join(itemIds, ",");
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String body = response.getBody();
List<ItemDTO> items = JSONUtil.toList(body, ItemDTO.class);现在变成:
java
List<ItemDTO> items = itemClient.queryItemByIds(itemIds);这就是 OpenFeign 的意义。
它把远程调用隐藏在接口方法后面,让我们写业务代码时更像是在调用本地方法。
OpenFeign 和负载均衡
OpenFeign 本身负责把接口方法转换成 HTTP 请求。
但如果一个服务有多个实例,还需要负载均衡组件决定调用哪一个实例。
比如 Nacos 中有三个商品服务实例:
text
item-service:
192.168.1.10:8081
192.168.1.11:8081
192.168.1.12:8081当购物车服务调用:
java
itemClient.queryItemByIds(itemIds);底层大致会经历这些步骤:
text
根据 @FeignClient("item-service") 找到服务名
↓
从注册中心获取 item-service 的实例列表
↓
负载均衡组件选择一个实例
↓
拼接真实请求地址
↓
发送 HTTP 请求
↓
把响应结果转换成 List<ItemDTO>这些步骤不需要我们手动写,OpenFeign 和负载均衡组件会帮我们完成。
默认情况下,常见策略是轮询。
也就是多个服务实例之间按顺序分配请求。
这样一来,远程调用代码既保留了微服务的动态发现能力,又避免了手动拼接地址的麻烦。
小结一下这两步的关系
Nacos 解决的是:
text
我去哪找 item-service?OpenFeign 解决的是:
text
我怎么更优雅地调用 item-service?注册中心让服务地址不再写死。
OpenFeign 让远程调用不再像手搓 HTTP 请求。
所以这一段的学习顺序应该是:
text
RestTemplate 写死地址
↓
发现地址写死不合适
↓
引入 Nacos 注册中心
↓
通过 DiscoveryClient 动态获取服务实例
↓
发现调用代码还是太麻烦
↓
引入 OpenFeign 简化远程调用这样微服务远程调用这条线就比较清楚了。

评论