Skip to content

远程调用


在单体项目中,所有功能都在一个项目里。购物车想查询商品信息时,通常可以直接调用商品相关的 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 方法说明

exchangeRestTemplate 中比较通用的请求方法,适合处理各种 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 官网

使用 Nacos 作为注册中心

继续使用前面的例子:

text
cart-service:购物车服务
item-service:商品服务

现在要做的是:

text
item-service 启动后注册到 Nacos
cart-service 调用 item-service 时,从 Nacos 获取服务实例

首先需要启动一个 Nacos 服务端。

Nacos 默认控制台地址通常是:

text
http://localhost:8848/nacos

Nacos 启动后,它就是一个独立的注册中心服务。其他微服务启动时,会连接到 Nacos,并把自己注册进去。

  1. 引入依赖

要让服务注册到 Nacos,需要引入 Nacos Discovery 依赖。

注意,服务提供者和服务调用者都要引入。
也就是说,item-servicecart-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:8848

cart-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 之后,原来那些 RestTemplateDiscoveryClient 的代码就可以不要了。

原来的字段可以删掉:

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 简化远程调用

这样微服务远程调用这条线就比较清楚了。

评论