要给Nacos的Udp通信功能点个赞

网络 通信技术
Nacos在服务注册功能中使用到了UDP的通信方式,主要功能就是用来辅助服务实例变化时对客户端进行通知。然而,对于大多数使用Nacos的程序员来说,可能还不知道这个功能,更别说灵活运用了。

[[421857]]

本文转载自微信公众号「程序新视界」,作者二师兄。转载本文请联系程序新视界公众号。

学习不用那么功利,二师兄带你从更高维度轻松阅读源码~

Nacos在服务注册功能中使用到了UDP的通信方式,主要功能就是用来辅助服务实例变化时对客户端进行通知。然而,对于大多数使用Nacos的程序员来说,可能还不知道这个功能,更别说灵活运用了。

看完整个源码的实现,还是要为这一功能点个赞的,可以说非常巧妙和实用。但在实现上有一些不足,文末会进行指出。

本篇文章就带大家从源码层面来分析一下Nacos 2.0中是如何基于UDP协议来实现服务实例变更的通知。

UDP通知基本原理

在分析源码之前,先来从整体上看一下Nacos中UDP的实现原理。

Nacos UDP基本原理

我们知道,UDP协议通信是双向的,没有所谓的客户端和服务端,因此在客户端和服务器端都会开启UDP的监听。客户端是单独开启一个线程来处理UDP消息的。当采用HTTP协议与注册中心通信时,,在客户端调用服务订阅接口时,会将客户端的UPD信息(IP和端口)上送到注册中心,注册中心以PushClient对象来进行封装和存储。

当注册中心有实例变化时,会发布一个ServiceChangeEvent事件,注册中心监听到这个事件之后,会遍历存储的PushClient,基于UDP协议对客户端进行通知。客户端接收到UDP通知,即可更新本地缓存的实例列表。

前面我们已经知道,基于HTTP协议进行服务注册时,会有一个实例更新的时间差,因为是通过客户端定时拉取服务器中的实例列表。如果拉取太频繁,注册中心压力比较大,如果拉取的周期比较长,实例的变化又没办法快速感知到。而UDP协议的通知,恰恰弥补了这一缺点,所以说,要为基于UDP通知这个功能点个赞。

下面就来看看源码层面是如何实现的。

客户端UDP通知监听与处理

客户端在实例化NamingHttpClientProxy时,在其构造方法中会初始化PushReceiver。

  1. public NamingHttpClientProxy(String namespaceId, SecurityProxy securityProxy, ServerListManager serverListManager, 
  2.         Properties properties, ServiceInfoHolder serviceInfoHolder) { 
  3.     // ... 
  4.     // 构建BeatReactor 
  5.     this.beatReactor = new BeatReactor(this, properties); 
  6.     // 构建UDP端口监听 
  7.     this.pushReceiver = new PushReceiver(serviceInfoHolder); 
  8.     // ... 

PushReceiver的构造方法,如下:

  1. public PushReceiver(ServiceInfoHolder serviceInfoHolder) { 
  2.     try { 
  3.         // 持有ServiceInfoHolder引用 
  4.         this.serviceInfoHolder = serviceInfoHolder; 
  5.         // 获取UDP端口 
  6.         String udpPort = getPushReceiverUdpPort(); 
  7.         // 根据端口情况,构建DatagramSocket,如果未设置端口,则采用随机端口 
  8.         if (StringUtils.isEmpty(udpPort)) { 
  9.             this.udpSocket = new DatagramSocket(); 
  10.         } else { 
  11.             this.udpSocket = new DatagramSocket(new InetSocketAddress(Integer.parseInt(udpPort))); 
  12.         } 
  13.         // 创建只有一个线程的ScheduledExecutorService 
  14.         this.executorService = new ScheduledThreadPoolExecutor(1, new ThreadFactory() { 
  15.             @Override 
  16.             public Thread newThread(Runnable r) { 
  17.                 Thread thread = new Thread(r); 
  18.                 thread.setDaemon(true); 
  19.                 thread.setName("com.alibaba.nacos.naming.push.receiver"); 
  20.                 return thread; 
  21.             } 
  22.         }); 
  23.  
  24.         // 执行线程,PushReceiver实现了Runnable接口 
  25.         this.executorService.execute(this); 
  26.     } catch (Exception e) { 
  27.         NAMING_LOGGER.error("[NA] init udp socket failed", e); 
  28.     } 

PushReceiver的构造方法做了以下操作:

  • 第一、持有ServiceInfoHolder对象引用;
  • 第二、获取UDP端口;
  • 第三、实例化DatagramSocket对象,用于发送和接收Socket数据;
  • 第四,创建线程池,并执行PushReceiver(实现了Runnable接口);

既然PushReceiver实现了Runnable接口,run方法肯定是需要重新实现的:

  1. @Override 
  2. public void run() { 
  3.     while (!closed) { 
  4.         try { 
  5.              
  6.             // byte[] is initialized with 0 full filled by default 
  7.             byte[] buffer = new byte[UDP_MSS]; 
  8.             // 创建DatagramPacket用于存储接收到的报文 
  9.             DatagramPacket packet = new DatagramPacket(buffer, buffer.length); 
  10.             // 接收报文,在未接收到报文时会进行线程阻塞 
  11.             udpSocket.receive(packet); 
  12.             // 将报文转换为json格式 
  13.             String json = new String(IoUtils.tryDecompress(packet.getData()), UTF_8).trim(); 
  14.             NAMING_LOGGER.info("received push data: " + json + " from " + packet.getAddress().toString()); 
  15.             // 将json格式的报文转换为PushPacket对象 
  16.             PushPacket pushPacket = JacksonUtils.toObj(json, PushPacket.class); 
  17.             String ack; 
  18.             // 如果符合条件,则调用ServiceInfoHolder进行接收报文处理,并返回应答报文 
  19.             if (PUSH_PACKAGE_TYPE_DOM.equals(pushPacket.type) || PUSH_PACKAGE_TYPE_SERVICE.equals(pushPacket.type)) { 
  20.                 serviceInfoHolder.processServiceInfo(pushPacket.data); 
  21.                  
  22.                 // send ack to server 
  23.                 ack = "{\"type\": \"push-ack\"" + ", \"lastRefTime\":\"" + pushPacket.lastRefTime + "\", \"data\":" 
  24.                         + "\"\"}"
  25.             } else if (PUSH_PACKAGE_TYPE_DUMP.equals(pushPacket.type)) { 
  26.                 // dump data to server 
  27.                 ack = "{\"type\": \"dump-ack\"" + ", \"lastRefTime\": \"" + pushPacket.lastRefTime + "\", \"data\":" 
  28.                         + "\"" + StringUtils.escapeJavaScript(JacksonUtils.toJson(serviceInfoHolder.getServiceInfoMap())) 
  29.                         + "\"}"
  30.             } else { 
  31.                 // do nothing send ack only 
  32.                 ack = "{\"type\": \"unknown-ack\"" + ", \"lastRefTime\":\"" + pushPacket.lastRefTime 
  33.                         + "\", \"data\":" + "\"\"}"
  34.             } 
  35.             // 发送应答报文 
  36.             udpSocket.send(new DatagramPacket(ack.getBytes(UTF_8), ack.getBytes(UTF_8).length, 
  37.                     packet.getSocketAddress())); 
  38.         } catch (Exception e) { 
  39.             if (closed) { 
  40.                 return
  41.             } 
  42.             NAMING_LOGGER.error("[NA] error while receiving push data", e); 
  43.         } 
  44.     } 

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方法实现:

  1. @Override 
  2. public ServiceInfo queryInstancesOfService(String serviceName, String groupName, String clusters, int udpPort, 
  3.         boolean healthyOnly) throws NacosException { 
  4.     final Map<String, String> params = new HashMap<String, String>(8); 
  5.     params.put(CommonParams.NAMESPACE_ID, namespaceId); 
  6.     params.put(CommonParams.SERVICE_NAME, NamingUtils.getGroupedName(serviceName, groupName)); 
  7.     params.put(CLUSTERS_PARAM, clusters); 
  8.     // 获取UDP端口 
  9.     params.put(UDP_PORT_PARAM, String.valueOf(udpPort)); 
  10.     params.put(CLIENT_IP_PARAM, NetUtils.localIP()); 
  11.     params.put(HEALTHY_ONLY_PARAM, String.valueOf(healthyOnly)); 
  12.     String result = reqApi(UtilAndComs.nacosUrlBase + "/instance/list", params, HttpMethod.GET); 
  13.     if (StringUtils.isNotEmpty(result)) { 
  14.         return JacksonUtils.toObj(result, ServiceInfo.class); 
  15.     } 
  16.     return new ServiceInfo(NamingUtils.getGroupedName(serviceName, groupName), clusters); 

但查看源码会发现,查询实例列表、查询单个健康实例、查询所有实例、订阅的更新任务UpdateTask中,UDP端口传递的参数值均为0。只有HTTP协议的订阅接口取值为PushReceiver中的UDP端口号。

  1. @Override 
  2. public ServiceInfo subscribe(String serviceName, String groupName, String clusters) throws NacosException { 
  3.     return queryInstancesOfService(serviceName, groupName, clusters, pushReceiver.getUdpPort(), false); 

在上面的代码中我们已经知道PushReceiver中有一个getPushReceiverUdpPort的方法:

  1. public static String getPushReceiverUdpPort() { 
  2.     return System.getenv(PropertyKeyConst.PUSH_RECEIVER_UDP_PORT); 

很明显,UDP的端口是通过环境变量设置的,对应的key为“push.receiver.udp.port”。

而在1.4.2版本中,HostReactor中的NamingProxy成员变量的queryList方法也会传递UDP端口:

  1. public void updateService(String serviceName, String clusters) throws NacosException { 
  2.     ServiceInfo oldService = getServiceInfo0(serviceName, clusters); 
  3.     try { 
  4.         String result = serverProxy.queryList(serviceName, clusters, pushReceiver.getUdpPort(), false); 
  5.         if (StringUtils.isNotEmpty(result)) { 
  6.             processServiceJson(result); 
  7.         } 
  8.     } finally { 
  9.         // ... 
  10.     } 

关于1.4.2版本中的实现,大家自行看源码即可,这里不再展开。

完成了客户端UDP基本信息的传递,再来看看服务器端是如何接收和存储这些信息的。

UDP服务存储

服务器端在获取实例列表的接口中,对UDP端口进行了处理。

  1. @GetMapping("/list"
  2. @Secured(parser = NamingResourceParser.class, action = ActionTypes.READ
  3. public Object list(HttpServletRequest request) throws Exception { 
  4.     // ... 
  5.     // 如果没有获得UDP端口信息,则默认端口为0 
  6.     int udpPort = Integer.parseInt(WebUtils.optional(request, "udpPort""0")); 
  7.     // ... 
  8.     // 客户端的IP、UDP端口封装到Subscriber对象中 
  9.     Subscriber subscriber = new Subscriber(clientIP + ":" + udpPort, agent, app, clientIP, namespaceId, serviceName, 
  10.             udpPort, clusters); 
  11.     return getInstanceOperator().listInstance(namespaceId, serviceName, subscriber, clusters, healthyOnly); 

在getInstanceOperator()方法中会获得当前采用的哪个协议,然后选择对应的处理类:

  1. /** 
  2.  * 判断并返回采用V1版本或V2版本的操作服务 
  3.  * @return V1:Jraft协议(服务器端);V2:gRpc协议(客户端) 
  4.  */ 
  5. private InstanceOperator getInstanceOperator() { 
  6.     return upgradeJudgement.isUseGrpcFeatures() ? instanceServiceV2 : instanceServiceV1; 

这里具体的实现类为InstanceOperatorServiceImpl:

  1. @Override 
  2. public ServiceInfo listInstance(String namespaceId, String serviceName, Subscriber subscriber, String cluster, 
  3.         boolean healthOnly) throws Exception { 
  4.     ClientInfo clientInfo = new ClientInfo(subscriber.getAgent()); 
  5.     String clientIP = subscriber.getIp(); 
  6.     ServiceInfo result = new ServiceInfo(serviceName, cluster); 
  7.     Service service = serviceManager.getService(namespaceId, serviceName); 
  8.     long cacheMillis = switchDomain.getDefaultCacheMillis(); 
  9.     // now try to enable the push 
  10.     try { 
  11.         // 处理支持UDP协议的客户端信息 
  12.         if (subscriber.getPort() > 0 && pushService.canEnablePush(subscriber.getAgent())) { 
  13.             subscriberServiceV1.addClient(namespaceId, serviceName, cluster, subscriber.getAgent(), 
  14.                     new InetSocketAddress(clientIP, subscriber.getPort()), pushDataSource, StringUtils.EMPTY, 
  15.                     StringUtils.EMPTY); 
  16.             cacheMillis = switchDomain.getPushCacheMillis(serviceName); 
  17.         } 
  18.     } catch (Exception e) { 
  19.         // ... 
  20.     } 
  21.     // ... 

当UDP端口大于0,且agent参数定义的客户端支持UDP,则将对应的客户端信息封装到InetSocketAddress对象中,然后放入NamingSubscriberServiceV1Impl中(该类已经被废弃,看后续如何调整该方法实现)。

在NamingSubscriberServiceV1Impl中,会将对应的参数封装为PushClient,存放在Map当中。

  1. public void addClient(String namespaceId, String serviceName, String clusters, String agent, 
  2.         InetSocketAddress socketAddr, DataSource dataSource, String tenant, String app) { 
  3.      
  4.     PushClient client = new PushClient(namespaceId, serviceName, clusters, agent, socketAddr, dataSource, tenant, 
  5.             app); 
  6.     addClient(client); 

addClient方法会将PushClient信息存放到ConcurrentMap

  1. private final ConcurrentMap<String, ConcurrentMap<String, PushClient>> clientMap = new ConcurrentHashMap<>(); 
  2.  
  3. public void addClient(PushClient client) { 
  4.         // client is stored by key 'serviceName' because notify event is driven by serviceName change 
  5.         String serviceKey = UtilsAndCommons.assembleFullServiceName(client.getNamespaceId(), client.getServiceName()); 
  6.         ConcurrentMap<String, PushClient> clients = clientMap.get(serviceKey); 
  7.         if (clients == null) { 
  8.             clientMap.putIfAbsent(serviceKey, new ConcurrentHashMap<>(1024)); 
  9.             clients = clientMap.get(serviceKey); 
  10.         } 
  11.          
  12.         PushClient oldClient = clients.get(client.toString()); 
  13.         if (oldClient != null) { 
  14.             oldClient.refresh(); 
  15.         } else { 
  16.             PushClient res = clients.putIfAbsent(client.toString(), client); 
  17.            // ... 
  18.         } 
  19.     } 

此时,UDP的IP、端口信息已经封装到PushClient当中,并存储在NamingSubscriberServiceV1Impl的成员变量当中。

注册中心的UDP通知

当服务端发现某个实例发生了变化,比如主动注销了,会发布一个ServiceChangeEvent事件,UdpPushService会监听到该事件,并进行业务处理。

在UdpPushService的onApplicationEvent方法中,会根据PushClient的具体情况进行移除或发送UDP通知。onApplicationEvent中核心逻辑代码如下:

  1. ConcurrentMap<String, PushClient> clients = subscriberServiceV1.getClientMap() 
  2.         .get(UtilsAndCommons.assembleFullServiceName(namespaceId, serviceName)); 
  3. if (MapUtils.isEmpty(clients)) { 
  4.     return
  5.  
  6. Map<String, Object> cache = new HashMap<>(16); 
  7. long lastRefTime = System.nanoTime(); 
  8. for (PushClient client : clients.values()) { 
  9.     // 移除僵尸客户端 
  10.     if (client.zombie()) { 
  11.         Loggers.PUSH.debug("client is zombie: " + client); 
  12.         clients.remove(client.toString()); 
  13.         Loggers.PUSH.debug("client is zombie: " + client); 
  14.         continue
  15.     } 
  16.      
  17.     AckEntry ackEntry; 
  18.     String key = getPushCacheKey(serviceName, client.getIp(), client.getAgent()); 
  19.     byte[] compressData = null
  20.     Map<String, Object> data = null
  21.     if (switchDomain.getDefaultPushCacheMillis() >= 20000 && cache.containsKey(key)) { 
  22.         org.javatuples.Pair pair = (org.javatuples.Pair) cache.get(key); 
  23.         compressData = (byte[]) (pair.getValue0()); 
  24.         data = (Map<String, Object>) pair.getValue1(); 
  25.     } 
  26.      
  27.     // 封装AckEntry对象 
  28.     if (compressData != null) { 
  29.         ackEntry = prepareAckEntry(client, compressData, data, lastRefTime); 
  30.     } else { 
  31.         ackEntry = prepareAckEntry(client, prepareHostsData(client), lastRefTime); 
  32.         if (ackEntry != null) { 
  33.             cache.put(key, new org.javatuples.Pair<>(ackEntry.getOrigin().getData(), ackEntry.getData())); 
  34.         } 
  35.     } 
  36.     // 通过UDP通知其他客户端 
  37.     udpPush(ackEntry); 

事件处理的核心逻辑是就是先判断PushClient的状态信息,如果已经是僵尸客户端,则移除。然后将发送UDP的报文信息和接收客户端的信息封装为AckEntry对象,然后调用udpPush方法,进行UDP消息的发送。

注册中心的UDP接收

在看客户端源码的时候,我们看到客户端不仅会接收UDP请求,而且还会进行应答。那么注册中心怎么接收应答呢?也在UdpPushService类中,该类内部的静态代码块初始化一个UDP的DatagramSocket,用来接收消息:

  1. static { 
  2.     try { 
  3.         udpSocket = new DatagramSocket(); 
  4.         Receiver receiver = new Receiver(); 
  5.         Thread inThread = new Thread(receiver); 
  6.         inThread.setDaemon(true); 
  7.         inThread.setName("com.alibaba.nacos.naming.push.receiver"); 
  8.         inThread.start(); 
  9.     } catch (SocketException e) { 
  10.         Loggers.SRV_LOG.error("[NACOS-PUSH] failed to init push service"); 
  11.     } 

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可能会存在的问题。如果这篇文章对你有帮助,关注或点赞都可以。

 

责任编辑:武晓燕 来源: 程序新视界
相关推荐

2010-07-06 15:10:05

UDP协议

2010-06-21 17:51:50

UDP协议

2016-12-02 15:23:42

锐捷互联网技术核心交换机

2021-02-04 07:54:40

JS工具扩展运算符

2022-02-08 15:37:22

微软Windows 11

2014-09-16 17:00:02

UDP

2023-03-01 08:15:10

NginxNacos

2020-08-03 08:10:52

UDPTCP通信

2010-06-09 14:36:44

TCP与UDP协议

2020-05-11 17:00:30

点赞MySQLRedis

2023-10-17 17:13:14

内存程序源码

2010-06-09 14:42:21

UDP协议TCP协议

2022-04-29 14:51:22

iOS苹果电池

2020-07-02 16:40:40

MySQLRedis数据库

2022-11-27 08:08:42

2010-06-09 11:38:37

传输层通信协议

2015-08-24 10:31:14

Windows 10功能

2022-07-01 07:41:03

ZookeeperEurekaConsul

2020-01-10 15:15:53

Redis点赞数据库

2017-10-17 12:24:05

AndroidJavaUDP协议
点赞
收藏

51CTO技术栈公众号