随着项目推进,我们即将进入核心且复杂的服务端注册环节。代码量会逐渐增多,建议大家做好心理准备,并亲手实践。本项目的完整源代码我已开源至 GitHub,欢迎大家自取学习与参考:
https://github.com/CoderLeixiaoshuai/easy-rpc
温馨提示:对于这类实战项目,强烈建议跟随文章节奏,自己动手思考和敲代码。完整跟下来,你一定会收获颇丰!在 云栈社区 中,也有许多开发者分享类似的 开源实战 项目心得,可以相互交流。
这一节,我们的核心目标是实现服务端的关键业务逻辑。概括来说,主要完成两件事:
- 第一,扫描代码中标记了
@ServiceExpose 注解的类,并将服务接口信息注册到注册中心。
- 第二,启动网络通信服务器(Netty Server),开始监听指定端口,以便客户端能够连接并发送请求。
初始化RPC服务端
我们继续在 DefaultRpcListener 这个自定义监听器中添加核心逻辑。
public class DefaultRpcListener implements ApplicationListener<ContextRefreshedEvent> {
// ……省略其他代码
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
final ApplicationContext applicationContext = event.getApplicationContext();
// 如果是 root application context 就开始执行
if (applicationContext.getParent() == null) {
// 初始化 rpc 服务端
initRpcServer(applicationContext);
}
}
// ……省略其他代码
}
上面的代码通过 event 对象获取了 Spring 的 Bean 容器 ApplicationContext,并将其作为参数传递给 initRpcServer 方法。接下来,我们实现这个方法:
private void initRpcServer(ApplicationContext applicationContext) {
// 1.1 扫描服务端 @ServiceExpose 注解,并将服务接口信息注册到注册中心
final Map<String, Object> beans = applicationContext.getBeansWithAnnotation(ServiceExpose.class);
if (beans.size() == 0) {
// 没发现注解
return;
}
for (Object beanObj : beans.values()) {
// 注册服务实例接口信息
registerInstanceInterfaceInfo(beanObj);
}
// 1.2 启动网络通信服务器,开始监听指定端口
// TODO
}
代码中,我们利用 ApplicationContext 提供的 getBeansWithAnnotation 方法,可以非常方便地根据指定注解查找到所有的 Bean 对象集合。
你是否还记得,在定义 @ServiceExpose 注解时,我们特意在其声明前加上了 @Component 注解?这样,Spring 在启动时就会将该类初始化成一个 Bean 放入容器。当调用 applicationContext.getBeansWithAnnotation(ServiceExpose.class) 时,Spring 会遍历所有 Bean,如果发现其对应类上存在 @ServiceExpose 注解,就会将其加入到返回的列表中。
拿到所有符合条件的 Bean 对象 Map 后,我们需要逐个进行服务注册,对外暴露接口。这部分逻辑封装在 registerInstanceInterfaceInfo 方法中:
private void registerInstanceInterfaceInfo(Object beanObj) {
final Class<?>[] interfaces = beanObj.getClass().getInterfaces();
if (interfaces.length <= 0) {
// 注解类未实现接口
return;
}
// 暂时只考虑实现了一个接口的场景
Class<?> interfaceClazz = interfaces[0];
String interfaceName = interfaceClazz.getName();
String ip = getLocalAddress();
Integer port = rpcProperties.getExposePort();
String serviceName = rpcProperties.getServiceName();
try {
// 注册服务
serviceRegistry.register(new InstanceInterfaceInfo(serviceName, interfaceName, ip, port, interfaceClazz, beanObj));
} catch (Exception e) {
logger.error(“Fail to register service: {}”, e.getMessage());
}
}
首先,代码会判断使用了 @ServiceExpose 注解的类是否实现了接口。如果未实现任何接口,则忽略这个 Bean。加入这个限制,是因为后续我们计划在客户端使用 JDK 的动态代理功能,而 JDK 原生的动态代理要求被代理类必须实现接口。如果你打算使用 CGLIB 等动态代理技术,此处可以放宽限制。
为了使逻辑简单明了,代码中暂时只考虑类实现了一个接口的场景。如果你的业务需要实现多个接口,可以在此处进行扩展或增加相应的约束规则。
紧接着,我们需要将服务接口暴露给客户端,暴露的信息包括:接口全限定名、服务实例 IP、服务实例端口等。我们将这些信息封装在一个 InstanceInterfaceInfo 对象中,然后调用服务注册器的方法进行注册:
// 注册服务
serviceRegistry.register(xxx);
这里的 serviceRegistry 是我们接下来要定义和实现的核心组件。
引入三方依赖
服务注册意味着需要将服务信息存储到一个可靠的地方,以便客户端查询。为了保证可靠性,这个“地方”通常不是本地存储,而是引入第三方的注册中心。
业界流行的注册中心非常多,为了满足不同开发者的学习兴趣,本 RPC 项目计划同时适配两种主流注册中心:Zookeeper 和 Nacos。当然,如果你对其他如 Eureka、Consul 等注册中心感兴趣,也可以参照本文的模式自行扩展,这本身就是一个极佳的锻炼机会。
正式使用前,需要引入相应的客户端依赖。我们尽量选择官方或广泛认可的 SDK。
与 Zookeeper 交互,我们可以引入 zkclient:
<!-- Zookeeper 客户端 -->
<dependency>
<groupId>com.101tec</groupId>
<artifactId>zkclient</artifactId>
<version>0.10</version>
</dependency>
对于 Nacos,则直接引入官方提供的 nacos-client:
<!-- nacos 客户端 -->
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>2.0.3</version>
</dependency>
定义服务注册接口
在日常编码中,养成面向接口编程的习惯至关重要,这能极大增强代码的可扩展性和可维护性。这正是 后端 & 架构 设计思想中强调的一点。
根据需求,服务注册的核心功能就是 注册服务。因此,我们可以定义一个名为 ServiceRegistry 的接口,其中包含一个 register 方法:
public interface ServiceRegistry {
/**
* 注册服务信息
*
* @param serviceInterfaceInfo 待注册的服务(接口)信息
* @throws Exception 异常
*/
void register(ServiceInterfaceInfo serviceInterfaceInfo) throws Exception;
}
服务向注册中心注册时,需要传递的内容我们用一个 InstanceInterfaceInfo 类来封装。注意,为了清晰区分,我们将前文提到的 ServiceInterfaceInfo 更名为 InstanceInterfaceInfo,以更准确地表示“服务实例的接口信息”。
@Data
public class InstanceInterfaceInfo {
/**
* 服务名(接口全限定名)
*/
private String serviceName;
/**
* 实例 id,每个服务实例不一样
*/
private String instanceId;
/**
* 服务实例 ip 地址,每个实例不一样
*/
private String ip;
/**
* 服务端口号,每个实例一样
*/
private Integer port;
/**
* 实现该接口 bean 对象对应的class 对象,用于后续反射调用
*/
private Class<?> clazz;
/**
* 实现该接口的 bean 对象,用于后续反射调用
*/
private Object obj;
}
Zookeeper 实现服务注册
接口定义完成后,我们开始编写实现。首先尝试用 Zookeeper 来实现服务注册功能。创建一个新类实现前面定义的 ServiceRegistry 接口:
public class ZookeeperServiceRegistry implements ServiceRegistry {
@Override
public void register(InstanceInterfaceInfo instanceInterfaceInfo) throws Exception {
// TODO
}
}
接下来,重写 register 方法。主要功能是调用 Zookeeper 客户端 API 创建 服务(永久)节点 和 实例(临时)节点。
通常,一个服务会部署多个实例,并且会根据业务流量动态扩缩容。因此,服务节点应该是一个永久节点,只需创建一次;而每个服务实例对应的节点应是临时节点,这样当实例故障下线时,节点会自动删除,便于客户端感知。
// ZookeeperServiceRegistry.java
@Override
public void register(InstanceInterfaceInfo instanceInterfaceInfo) throws Exception {
logger.info(“Registering service: {}”, instanceInterfaceInfo);
// 创建 ZK 永久节点(服务节点)
String serviceName = instanceInterfaceInfo.getServiceName();
String servicePath = “/com/leixiaoshuai/easyrpc/service/” + serviceName;
if (!zkClient.exists(servicePath)) {
zkClient.createPersistent(servicePath, true);
logger.info(“Created node: {}”, servicePath);
}
// 创建 ZK 临时节点(实例节点)
String uri = JSON.toJSONString(instanceInterfaceInfo);
uri = URLEncoder.encode(uri, “UTF-8”);
String uriPath = servicePath + “/” + uri;
if (zkClient.exists(uriPath)) {
zkClient.delete(uriPath);
}
zkClient.createEphemeral(uriPath);
logger.info(“Created ephemeral node: {}”, uriPath);
}
为了简化演示,这里直接将接口名与固定路径拼接作为永久节点路径;将每个实例的接口信息 JSON 序列化并 URL 编码后,作为临时节点名称。这并非生产环境的最佳实践,你可以基于此进行优化,例如使用更清晰的节点结构。
Nacos 实现服务注册
除了 Zookeeper,我们同样可以实现一个 Nacos 版本。首先创建对应的类:
public class NacosServiceRegistry implements ServiceRegistry {
@Override
public void register(InstanceInterfaceInfo instanceInterfaceInfo) throws Exception {
// TODO
}
}
接着编写构造方法。NacosServiceRegistry 类实例化时,需要连接 Nacos 服务端。
// NacosServiceRegistry.java
public NacosServiceRegistry(String serverList) throws NacosException {
// 使用工厂类创建注册中心对象,构造参数为 Nacos Server 的地址
naming = NamingFactory.createNamingService(serverList);
// 打印 Nacos Server 的运行状态
logger.info(“Nacos server status: {}”, naming.getServerStatus());
}
获得 NamingService 实例后,就可以调用其服务注册接口完成注册了。
// NacosServiceRegistry.java
@Override
public void register(InstanceInterfaceInfo instanceInterfaceInfo) throws Exception {
// 注册当前服务实例
naming.registerInstance(instanceInterfaceInfo.getServiceName(), buildInstance(instanceInterfaceInfo));
}
private Instance buildInstance(InstanceInterfaceInfo instanceInterfaceInfo) {
// 构建 Nacos 实例对象
Instance instance = new Instance();
instance.setIp(instanceInterfaceInfo.getIp());
instance.setPort(instanceInterfaceInfo.getPort());
// TODO 可以添加更多元数据 (metadata)
return instance;
}
NamingService 类提供了丰富的服务治理方法,感兴趣的话可以深入探索其 API。
代码结构
本小节,我们在项目中新建了 server.registry 包,定义了一个服务注册接口 ServiceRegistry,并基于主流的 Zookeeper 和 Nacos 注册中心完成了实现。具体的代码结构如下:
├── easy-rpc-spring-boot-starter
├── pom.xml
├── src
│ └── main
│ ├── java
│ │ └── com
│ │ └── leixiaoshuai
│ │ └── easyrpc
│ │ ├── annotation
│ │ │ ├── ServiceExpose.java
│ │ │ └── ServiceReference.java
│ │ ├── common
│ │ │ └── InstanceInterfaceInfo.java
│ │ ├── listener
│ │ │ └── DefaultRpcListener.java
│ │ └── server
│ │ └── registry
│ │ ├── NacosServiceRegistry.java
│ │ ├── ServiceRegistry.java
│ │ └── ZookeeperServiceRegistry.java
│ └── resources
└── target
小结
我们实现的自定义监听器,本质上是 Spring Boot 注解驱动机制的核心。当 Spring 完成所有 Bean 的初始化工作后,监听器收到事件并开始执行:
- 扫描所有 Bean 对象,识别出标记了
@ServiceExpose 注解的类,将其加入到待处理列表。
- 逐个处理这些 Bean,提取服务接口信息,并将其注册到我们选定的注册中心。
服务注册通常依赖于第三方注册中心组件,本文选取了业界流行的 Zookeeper 和 Nacos,分别实现了业务代码。通过面向接口的设计,未来扩展其他注册中心将非常方便。
完成服务接口信息注册后,下一步就是启动网络通信服务器(Netty Server),监听端口。客户端从注册中心获取到服务端暴露的接口信息后,就能据此发起网络通信了。关于网络通信服务器的启动逻辑,我们将在下一小节详细介绍。
项目完整的源代码我已经开源到 GitHub 了,欢迎大家查看、学习甚至参与贡献。