学习不用那么功利,二师兄带你从更高维度轻松阅读源码~
Nacos在服务注册功能中使用到了UDP的通信方式,主要功能就是用来辅助服务实例变化时对客户端进行通知。然而,对于大多数使用Nacos的程序员来说,可能还不知道这个功能,更别说灵活运用了。
看完整个源码的实现,还是要为这一功能点个赞的,可以说非常巧妙和实用。但在实现上有一些不足,文末会进行指出。
本篇文章就带大家从源码层面来分析一下Nacos 2.0中是如何基于UDP协议来实现服务实例变更的通知。
UDP通知基本原理
在分析源码之前,先来从整体上看一下Nacos中UDP的实现原理。
我们知道,UDP协议通信是双向的,没有所谓的客户端和服务端,因此在客户端和服务器端都会开启UDP的监听。客户端是单独开启一个线程来处理UDP消息的。当采用HTTP协议与注册中心通信时,,在客户端调用服务订阅接口时,会将客户端的UPD信息(IP和端口)上送到注册中心,注册中心以PushClient对象来进行封装和存储。
当注册中心有实例变化时,会发布一个ServiceChangeEvent事件,注册中心监听到这个事件之后,会遍历存储的PushClient,基于UDP协议对客户端进行通知。客户端接收到UDP通知,即可更新本地缓存的实例列表。
前面我们已经知道,基于HTTP协议进行服务注册时,会有一个实例更新的时间差,因为是通过客户端定时拉取服务器中的实例列表。如果拉取太频繁,注册中心压力比较大,如果拉取的周期比较长,实例的变化又没办法快速感知到。而UDP协议的通知,恰恰弥补了这一缺点,所以说,要为基于UDP通知这个功能点个赞。
下面就来看看源码层面是如何实现的。
客户端UDP通知监听与处理
客户端在实例化NamingHttpClientProxy时,在其构造方法中会初始化PushReceiver。
代码语言:javascript复制public NamingHttpClientProxy(String namespaceId, SecurityProxy securityProxy, ServerListManager serverListManager,
Properties properties, ServiceInfoHolder serviceInfoHolder) {
// ...
// 构建BeatReactor
this.beatReactor = new BeatReactor(this, properties);
// 构建UDP端口监听
this.pushReceiver = new PushReceiver(serviceInfoHolder);
// ...
}
PushReceiver的构造方法,如下:
代码语言:javascript复制public PushReceiver(ServiceInfoHolder serviceInfoHolder) {
try {
// 持有ServiceInfoHolder引用
this.serviceInfoHolder = serviceInfoHolder;
// 获取UDP端口
String udpPort = getPushReceiverUdpPort();
// 根据端口情况,构建DatagramSocket,如果未设置端口,则采用随机端口
if (StringUtils.isEmpty(udpPort)) {
this.udpSocket = new DatagramSocket();
} else {
this.udpSocket = new DatagramSocket(new InetSocketAddress(Integer.parseInt(udpPort)));
}
// 创建只有一个线程的ScheduledExecutorService
this.executorService = new ScheduledThreadPoolExecutor(1, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setDaemon(true);
thread.setName("com.alibaba.nacos.naming.push.receiver");
return thread;
}
});
// 执行线程,PushReceiver实现了Runnable接口
this.executorService.execute(this);
} catch (Exception e) {
NAMING_LOGGER.error("[NA] init udp socket failed", e);
}
}
PushReceiver的构造方法做了以下操作:
- 第一、持有ServiceInfoHolder对象引用;
- 第二、获取UDP端口;
- 第三、实例化DatagramSocket对象,用于发送和接收Socket数据;
- 第四,创建线程池,并执行PushReceiver(实现了Runnable接口);
既然PushReceiver实现了Runnable接口,run方法肯定是需要重新实现的:
代码语言:javascript复制@Override
public void run() {
while (!closed) {
try {
// byte[] is initialized with 0 full filled by default
byte[] buffer = new byte[UDP_MSS];
// 创建DatagramPacket用于存储接收到的报文
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
// 接收报文,在未接收到报文时会进行线程阻塞
udpSocket.receive(packet);
// 将报文转换为json格式
String json = new String(IoUtils.tryDecompress(packet.getData()), UTF_8).trim();
NAMING_LOGGER.info("received push data: " json " from " packet.getAddress().toString());
// 将json格式的报文转换为PushPacket对象
PushPacket pushPacket = JacksonUtils.toObj(json, PushPacket.class);
String ack;
// 如果符合条件,则调用ServiceInfoHolder进行接收报文处理,并返回应答报文
if (PUSH_PACKAGE_TYPE_DOM.equals(pushPacket.type) || PUSH_PACKAGE_TYPE_SERVICE.equals(pushPacket.type)) {
serviceInfoHolder.processServiceInfo(pushPacket.data);
// send ack to server
ack = "{"type": "push-ack"" ", "lastRefTime":"" pushPacket.lastRefTime "", "data":"
"""}";
} else if (PUSH_PACKAGE_TYPE_DUMP.equals(pushPacket.type)) {
// dump data to server
ack = "{"type": "dump-ack"" ", "lastRefTime": "" pushPacket.lastRefTime "", "data":"
""" StringUtils.escapeJavaScript(JacksonUtils.toJson(serviceInfoHolder.getServiceInfoMap()))
""}";
} else {
// do nothing send ack only
ack = "{"type": "unknown-ack"" ", "lastRefTime":"" pushPacket.lastRefTime
"", "data":" """}";
}
// 发送应答报文
udpSocket.send(new DatagramPacket(ack.getBytes(UTF_8), ack.getBytes(UTF_8).length,
packet.getSocketAddress()));
} catch (Exception e) {
if (closed) {
return;
}
NAMING_LOGGER.error("[NA] error while receiving push data", e);
}
}
}
PushReceiver#run方法主要处理了以下操作:
- 第一、构建DatagramPacket用于接收报文数据;
- 第二、通过DatagramSocket#receive方法阻塞等待报文的到来;
- 第三、DatagramSocket#receive接收到报文之后,方法继续执行;
- 第四、解析JSON格式的报文为PushPacket对象;
- 第五、判断报文类型,调用ServiceInfoHolder#processServiceInfo处理接收到的报文信息,在该方法中会将PushPacket转化为ServiceInfo对象;
- 第六、封装ACK信息(即应答报文信息);
- 第七、通过DatagramSocket发送应答报文;
上面我们看到了Nacos客户端是如何基于UDP进行报文的监听和处理的,但并未找到客户端是如何将UDP信息上送给注册中心的。下面我们就来梳理一下,上送UDP信息的逻辑。
客户端上送UDP信息
在NamingHttpClientProxy中存储了UDP_PORT_PARAM,即UDP的端口参数信息。
UDP端口信息通过实例查询类接口进行传递,比如:查询实例列表、查询单个健康实例、查询所有实例、订阅接口、订阅的更新任务UpdateTask等接口。在这些方法中都调用了NamingClientProxy#queryInstancesOfService方法。
NamingHttpClientProxy中的queryInstancesOfService方法实现:
代码语言:javascript复制@Override
public ServiceInfo queryInstancesOfService(String serviceName, String groupName, String clusters, int udpPort,
boolean healthyOnly) throws NacosException {
final Map<String, String> params = new HashMap<String, String>(8);
params.put(CommonParams.NAMESPACE_ID, namespaceId);
params.put(CommonParams.SERVICE_NAME, NamingUtils.getGroupedName(serviceName, groupName));
params.put(CLUSTERS_PARAM, clusters);
// 获取UDP端口
params.put(UDP_PORT_PARAM, String.valueOf(udpPort));
params.put(CLIENT_IP_PARAM, NetUtils.localIP());
params.put(HEALTHY_ONLY_PARAM, String.valueOf(healthyOnly));
String result = reqApi(UtilAndComs.nacosUrlBase "/instance/list", params, HttpMethod.GET);
if (StringUtils.isNotEmpty(result)) {
return JacksonUtils.toObj(result, ServiceInfo.class);
}
return new ServiceInfo(NamingUtils.getGroupedName(serviceName, groupName), clusters);
}
但查看源码会发现,查询实例列表、查询单个健康实例、查询所有实例、订阅的更新任务UpdateTask中,UDP端口传递的参数值均为0。只有HTTP协议的订阅接口取值为PushReceiver中的UDP端口号。
代码语言:javascript复制@Override
public ServiceInfo subscribe(String serviceName, String groupName, String clusters) throws NacosException {
return queryInstancesOfService(serviceName, groupName, clusters, pushReceiver.getUdpPort(), false);
}
在上面的代码中我们已经知道PushReceiver中有一个getPushReceiverUdpPort的方法:
代码语言:javascript复制public static String getPushReceiverUdpPort() {
return System.getenv(PropertyKeyConst.PUSH_RECEIVER_UDP_PORT);
}
很明显,UDP的端口是通过环境变量设置的,对应的key为“push.receiver.udp.port”。
而在1.4.2版本中,HostReactor中的NamingProxy成员变量的queryList方法也会传递UDP端口:
代码语言:javascript复制public void updateService(String serviceName, String clusters) throws NacosException {
ServiceInfo oldService = getServiceInfo0(serviceName, clusters);
try {
String result = serverProxy.queryList(serviceName, clusters, pushReceiver.getUdpPort(), false);
if (StringUtils.isNotEmpty(result)) {
processServiceJson(result);
}
} finally {
// ...
}
}
关于1.4.2版本中的实现,大家自行看源码即可,这里不再展开。
完成了客户端UDP基本信息的传递,再来看看服务器端是如何接收和存储这些信息的。
UDP服务存储
服务器端在获取实例列表的接口中,对UDP端口进行了处理。
代码语言:javascript复制@GetMapping("/list")
@Secured(parser = NamingResourceParser.class, action = ActionTypes.READ)
public Object list(HttpServletRequest request) throws Exception {
// ...
// 如果没有获得UDP端口信息,则默认端口为0
int udpPort = Integer.parseInt(WebUtils.optional(request, "udpPort", "0"));
// ...
// 客户端的IP、UDP端口封装到Subscriber对象中
Subscriber subscriber = new Subscriber(clientIP ":" udpPort, agent, app, clientIP, namespaceId, serviceName,
udpPort, clusters);
return getInstanceOperator().listInstance(namespaceId, serviceName, subscriber, clusters, healthyOnly);
}
在getInstanceOperator()方法中会获得当前采用的哪个协议,然后选择对应的处理类:
代码语言:javascript复制/**
* 判断并返回采用V1版本或V2版本的操作服务
* @return V1:Jraft协议(服务器端);V2:gRpc协议(客户端)
*/
private InstanceOperator getInstanceOperator() {
return upgradeJudgement.isUseGrpcFeatures() ? instanceServiceV2 : instanceServiceV1;
}
这里具体的实现类为InstanceOperatorServiceImpl:
代码语言:javascript复制@Override
public ServiceInfo listInstance(String namespaceId, String serviceName, Subscriber subscriber, String cluster,
boolean healthOnly) throws Exception {
ClientInfo clientInfo = new ClientInfo(subscriber.getAgent());
String clientIP = subscriber.getIp();
ServiceInfo result = new ServiceInfo(serviceName, cluster);
Service service = serviceManager.getService(namespaceId, serviceName);
long cacheMillis = switchDomain.getDefaultCacheMillis();
// now try to enable the push
try {
// 处理支持UDP协议的客户端信息
if (subscriber.getPort() > 0 && pushService.canEnablePush(subscriber.getAgent())) {
subscriberServiceV1.addClient(namespaceId, serviceName, cluster, subscriber.getAgent(),
new InetSocketAddress(clientIP, subscriber.getPort()), pushDataSource, StringUtils.EMPTY,
StringUtils.EMPTY);
cacheMillis = switchDomain.getPushCacheMillis(serviceName);
}
} catch (Exception e) {
// ...
}
// ...
}
当UDP端口大于0,且agent参数定义的客户端支持UDP,则将对应的客户端信息封装到InetSocketAddress对象中,然后放入NamingSubscriberServiceV1Impl中(该类已经被废弃,看后续如何调整该方法实现)。
在NamingSubscriberServiceV1Impl中,会将对应的参数封装为PushClient,存放在Map当中。
代码语言:javascript复制public void addClient(String namespaceId, String serviceName, String clusters, String agent,
InetSocketAddress socketAddr, DataSource dataSource, String tenant, String app) {
PushClient client = new PushClient(namespaceId, serviceName, clusters, agent, socketAddr, dataSource, tenant,
app);
addClient(client);
}
addClient方法会将PushClient信息存放到ConcurrentMap<String, ConcurrentMap<String, PushClient>>当中:
代码语言:javascript复制private final ConcurrentMap<String, ConcurrentMap<String, PushClient>> clientMap = new ConcurrentHashMap<>();
public void addClient(PushClient client) {
// client is stored by key 'serviceName' because notify event is driven by serviceName change
String serviceKey = UtilsAndCommons.assembleFullServiceName(client.getNamespaceId(), client.getServiceName());
ConcurrentMap<String, PushClient> clients = clientMap.get(serviceKey);
if (clients == null) {
clientMap.putIfAbsent(serviceKey, new ConcurrentHashMap<>(1024));
clients = clientMap.get(serviceKey);
}
PushClient oldClient = clients.get(client.toString());
if (oldClient != null) {
oldClient.refresh();
} else {
PushClient res = clients.putIfAbsent(client.toString(), client);
// ...
}
}
此时,UDP的IP、端口信息已经封装到PushClient当中,并存储在NamingSubscriberServiceV1Impl的成员变量当中。
注册中心的UDP通知
当服务端发现某个实例发生了变化,比如主动注销了,会发布一个ServiceChangeEvent事件,UdpPushService会监听到该事件,并进行业务处理。
在UdpPushService的onApplicationEvent方法中,会根据PushClient的具体情况进行移除或发送UDP通知。onApplicationEvent中核心逻辑代码如下:
代码语言:javascript复制ConcurrentMap<String, PushClient> clients = subscriberServiceV1.getClientMap()
.get(UtilsAndCommons.assembleFullServiceName(namespaceId, serviceName));
if (MapUtils.isEmpty(clients)) {
return;
}
Map<String, Object> cache = new HashMap<>(16);
long lastRefTime = System.nanoTime();
for (PushClient client : clients.values()) {
// 移除僵尸客户端
if (client.zombie()) {
Loggers.PUSH.debug("client is zombie: " client);
clients.remove(client.toString());
Loggers.PUSH.debug("client is zombie: " client);
continue;
}
AckEntry ackEntry;
String key = getPushCacheKey(serviceName, client.getIp(), client.getAgent());
byte[] compressData = null;
Map<String, Object> data = null;
if (switchDomain.getDefaultPushCacheMillis() >= 20000 && cache.containsKey(key)) {
org.javatuples.Pair pair = (org.javatuples.Pair) cache.get(key);
compressData = (byte[]) (pair.getValue0());
data = (Map<String, Object>) pair.getValue1();
}
// 封装AckEntry对象
if (compressData != null) {
ackEntry = prepareAckEntry(client, compressData, data, lastRefTime);
} else {
ackEntry = prepareAckEntry(client, prepareHostsData(client), lastRefTime);
if (ackEntry != null) {
cache.put(key, new org.javatuples.Pair<>(ackEntry.getOrigin().getData(), ackEntry.getData()));
}
}
// 通过UDP通知其他客户端
udpPush(ackEntry);
}
事件处理的核心逻辑是就是先判断PushClient的状态信息,如果已经是僵尸客户端,则移除。然后将发送UDP的报文信息和接收客户端的信息封装为AckEntry对象,然后调用udpPush方法,进行UDP消息的发送。
注册中心的UDP接收
在看客户端源码的时候,我们看到客户端不仅会接收UDP请求,而且还会进行应答。那么注册中心怎么接收应答呢?也在UdpPushService类中,该类内部的静态代码块初始化一个UDP的DatagramSocket,用来接收消息:
代码语言:javascript复制static {
try {
udpSocket = new DatagramSocket();
Receiver receiver = new Receiver();
Thread inThread = new Thread(receiver);
inThread.setDaemon(true);
inThread.setName("com.alibaba.nacos.naming.push.receiver");
inThread.start();
} catch (SocketException e) {
Loggers.SRV_LOG.error("[NACOS-PUSH] failed to init push service");
}
}
Receiver是一个内部类,实现了Runnable接口,在其run方法中主要就是接收报文信息,然后进行报文消息的判断,根据判断结果,操作本地Map中数据。
UDP设计不足
文章最开始就写到,UDP的设计非常棒,即弥补了HTTP定时拉取的不足,又不至于太影响性能。但目前Nacos在UDP方面有一些不足,也可能是个人的吹毛求疵吧。
第一,文档中没有明确说明UDP的功能如何使用,这导致很多使用者在使用时并不知道UDP功能的存在,以及使用的限制条件。
第二,对云服务不友好。客户端的UDP端口可以自定义,但服务器端的UDP端口是随机获取到。在云服务中,即便是内网服务,UDP端口也是被防火墙限制的。如果服务端的UDP端口是随机获取(客户端默认也是),那么UDP的通信将直接被防火墙拦截掉,而用户根本看不到任何异常(UDP协议不关注客户端是否收到消息)。
至于这两点,说起来算是瑕不掩瑜,读完源码或读过我这篇文章的朋友大概已经知道怎么用了。后续可以给官方提一个Issue,看看是否可以改进。
小结
本文重点从三个方面讲解的Nacos基于UDP的服务实例变更通知:
第一,客户端监听UDP端口,当接收注册中心发来的服务实例变化,可以及时的更新本地的实例缓存;
第二,客户端通过订阅接口,将自身的UDP信息发送给注册中心,注册中心进行存储;
第三,注册中心中实例发生了变化,通过事件机制,将变更信息通过UDP协议发送给客户端。
经过本篇文章,想必你不仅了解了Nacos中UDP协议的通知机制。同时,也开拓了一个新的思路,即如何使用UDP,在什么场景下使用UDP,以及在云服务中使用UDP可能会存在的问题。
博主简介:《SpringBoot技术内幕》技术图书作者,酷爱钻研技术,写技术干货文章。