使用 Webflux 发起远程调用,并熟练掌握如何搭建一套基于 Nacos 的服务治理方案。
添加 Nacos 依赖项和配置信息
在开始写代码之前,你需要将以下依赖项添加到 pom.xml 文件中。
<!-- Nacos服务发现组件 -->
<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>
<!-- webflux服务调用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
后面两个依赖项你应该是第一回见到,我来向你简单介绍一下。
spring-cloud-starter-loadbalancer:Spring Cloud 御用负载均衡组件 Loadbalancer,用来代替已经进入维护状态的 Netflix Ribbon 组件。我会在下一课带你深入了解 Loadbalancer 的功能,今天我们只需要简单了解下它的用法就可以了;
spring-boot-starter-webflux:Webflux 是 Spring Boot 提供的响应式编程框架,响应式编程是基于异步和事件驱动的非阻塞程序。Webflux 实现了 Reactive Streams 规范,内置了丰富的响应式编程特性。今天我将用 Webflux 组件中一个叫做 WebClient 的小工具发起远程服务调用。
添加 WebClient 对象
为了可以用 WebClient 发起远程调用,你还需要在 Spring 上下文中构造一个 WebClient 对象。标准的做法是创建一个 Configuration 类,并在这个类中通过 @Bean 注解创建需要的对象。
声明 WebClient 的 Builder 对象。
// Configuration注解声明配置类
@org.springframework.context.annotation.Configuration
public class Configuration {
// 注册Bean并添加负载均衡功能
@Bean
@LoadBalanced
public WebClient.Builder register() {
return WebClient.builder();
}
}
虽然上面的代码没几行,但我足足用了三个注解,这些注解各有用途。
@Configuration 注解:定义一个配置类。在 Configuration 类中定义的 @Bean 注解方法会被 AnnotationConfigApplicationContext 或者 AnnotationConfigWebApplicationContext 扫描并在上下文中进行构建;
@Bean 注解:声明一个受 Spring 容器托管的 Bean;
@LoadBalanced 注解:为 WebClient.Build 构造器注入特殊的 Filter,实现负载均衡功能,我在下一课会详细解释负载均衡的知识点。今天咱就好读书不求甚解就可以了,只需要知道这个注解的作用是在远程调用发起之前选定目标服务器地址。
使用 WebClient 发起远程方法调用
首先,我们将 Configuration 类中声明的 WebClient 的 Builder 对象注入到服务中
@Autowired
private WebClient.Builder webClientBuilder;
本地调用替换为 WebClient 远程调用。下面是改造之前的代码。
CouponTemplateInfo templateInfo = templateService.loadTemplateInfo(request.getCouponTemplateId());
远程接口调用的代码改造可以通过 WebClient 提供的“链式编程”轻松实现,下面是代码的完整实现。
Info Info = webClientBuilder.build()
.get()
.uri("http://serv/template/getTemplate?id=" + request.getId())
.retrieve()
.bodyToMono(Info.class)
.block();
在这段代码中,我们应用了几个关键方法发起远程调用。
get:指明了 Http Method 是 GET,如果是其他请求类型则使用对应的 post、put、patch、delete 等方法;
uri:指定了访问的请求地址;
retrieve + bodyToMono:指定了 Response 的返回格式;
block:发起一个阻塞调用,在远程服务没有响应之前,当前线程处于阻塞状态。
Nacos 在背后会通过服务发现机制,帮你获取到目标服务的所有可用节点列表。然后,WebClient 会通过负载均衡过滤器,从列表中选取一个节点进行调用,整个流程对开发人员都是透明的、无感知的。
你可以看到,在代码中我使用了 retrieve + bodyToMono 的方式接收 Response 响应,并将其转换为 Info 对象。在这个过程中,我只接收了 Response 返回的 Body 内容,并没有对 Response 中包含的其它字段进行处理。
如果你需要获取完整的 Response,包括 Http status、headers 等额外数据,就可以使用 retrieve + toEntity 的方式,获取包含完整 Response 信息的 ResponseEntity 对象。示例如下,你可以自己在项目中尝试这种调用方式,体验下 toEntity 和 bodyToMono 的不同之处。
Mono<ResponseEntity<Info>> entityMono = client.get()
.uri("http://serv/template/xxxx")
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.toEntity(Info.class);
WebClient 使用了一种链式编程的风格来构造请求对象,链式编程就是我们熟悉的 Builder 建造者模式。仔细观察你会发现,大部分开源应用都在使用这种设计模式简化对象的构建。如果你需要在自己的项目中使用 Builder 模式,你可以借助 Lombok 组件的 @Builder 注解来实现。如果你对此感兴趣,可以自行了解 Lombok 组件的相关用法。
Map<Long, Info> templateMap = webClientBuilder.build().get()
.uri("http://serv/template/getBatch?ids=" + Ids)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<Long, Info>>() {})
.block();
由于方法的返回值不是一个标准的 Json 对象,而是 Map 类型,因此你需要构造一个 ParameterizedTypeReference 实例丢给 WebClient,告诉它应该将 Response 转化成什么类型。
Cart checkoutInfo = webClientBuilder.build()
.post()
.uri("http://serv/calculator/checkout")
.bodyValue(order)
.retrieve()
.bodyToMono(Cart.class)
.block();
和前面几处改造不同的是,这是一个 POST 请求,因此在使用 webClient 构造器的时候我调用了 post 方法;除此之外,它还需要接收订单的完整信息作为请求参数,因此我这里调用了 bodyValue 方法,将封装好的 Order 对象塞了进去。
如果你是以集群模式启动了多台 Nacos 服务器,那么即便你在实战项目中只配置了一个 Nacos URL,并没有使用虚拟 IP 搭建单独的集群地址,注册信息也会传播到 Nacos 集群中的所有节点。
现在,动手搭建一套基于 Nacos 的服务治理方案对你而言一定不是难事儿了。动手能力是有了,但我们也不能仅仅满足于学会使用一套技术,你必须要深入到技术的具体实现方案,才能从中汲取到养分,为你将来的技术方案设计提供参考。
那么接下来,就让我带你去了解一下 Nacos 服务发现的底层实现,学习一下 Client 端是通过什么途径从 Nacos Server 获取服务注册表的。
Nacos 服务发现底层实现
Nacos Client 通过一种主动轮询的机制从 Nacos Server 获取服务注册信息,包括地址列表、group 分组、cluster 名称等一系列数据。简单来说,Nacos Client 会开启一个本地的定时任务,每间隔一段时间,就尝试从 Nacos Server 查询服务注册表,并将最新的注册信息更新到本地。这种方式也被称之为“Pull”模式,即客户端主动从服务端拉取的模式。
负责拉取服务的任务是 UpdateTask 类,它实现了 Runnable 接口。Nacos 以开启线程的方式调用 UpdateTask 类中的 run 方法,触发本地的服务发现查询请求。
UpdateTask 这个类隐藏得非常深,它是 HostReactor的一个内部类,我带你看一下经过详细注释的代码走读:
public class UpdateTask implements Runnable {
// ....省略部分代码
// 获取服务列表
@Override
public void run() {
long delayTime = DEFAULT_DELAY;
try {
// 根据service name获取到当前服务的信息,包括服务器地址列表
ServiceInfo serviceObj = serviceInfoMap
.get(ServiceInfo.getKey(serviceName, clusters));
// 如果为空,则重新拉取最新的服务列表
if (serviceObj == null) {
updateService(serviceName, clusters);
return;
}
// 如果时间戳<=上次更新的时间,则进行更新操作
if (serviceObj.getLastRefTime() <= lastRefTime) {
updateService(serviceName, clusters);
serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
} else {
// 如果serviceObj的refTime更晚,
// 则表示服务通过主动push机制已被更新,这时我们只进行刷新操作
refreshOnly(serviceName, clusters);
}
// 刷新服务的更新时间
lastRefTime = serviceObj.getLastRefTime();
// 如果订阅被取消,则停止更新任务
if (!notifier.isSubscribed(serviceName, clusters) && !futureMap
.containsKey(ServiceInfo.getKey(serviceName, clusters))) {
// abort the update task
NAMING_LOGGER.info("update task is stopped, service:" + serviceName + ", clusters:" + clusters);
return;
}
// 如果没有可供调用的服务列表,则统计失败次数+1
if (CollectionUtils.isEmpty(serviceObj.getHosts())) {
incFailCount();
return;
}
// 设置延迟一段时间后进行查询
delayTime = serviceObj.getCacheMillis();
// 将失败查询次数重置为0
resetFailCount();
} catch (Throwable e) {
incFailCount();
NAMING_LOGGER.warn("[NA] failed to update serviceName: " + serviceName, e);
} finally {
// 设置下一次查询任务的触发时间
executor.schedule(this, Math.min(delayTime << failCount, DEFAULT_DELAY * 60), TimeUnit.MILLISECONDS);
}
}
}
在 UpdateTask 的源码中,它通过调用 updateService 方法实现了服务查询和本地注册表更新,在每次任务执行结束的时候,在结尾处它通过 finally 代码块设置了下一次 executor 查询的时间,周而复始循环往复。
以上,就是 Nacos 通过 UpdateTask 来查询服务端注册表的底层原理了。
那么现在我就要考考你了,你知道 UpdateTask 是在什么阶段由哪一个类首次触发的吗?我已经把这个藤交到你手上了,希望你能顺藤摸瓜,顺着 UpdateTask 类,从源码层面找到它的上游调用方,理清整个服务发现链路的流程。