基於Netty的數採邊緣實現

背景介紹

在設備數據採集方面,我們已經探索出一套成熟的做法即在設備側部署一個邊緣程序,通過定時拉取的機制到工控機如PLC的指定位置獲取數據。

但是對於機器人來說,不同型號的機器人控制設備都有自己不同程度的封裝,我們需要嚴格按照機器人設備的數據開放方式進行定製化實現纔有可能正確的獲取到數據。

就我們這次研究的FANUC機器人來說,它的數據是通過內置的Socket服務端發送出來的。因此我們的數採方案是在邊緣端部署一個相應的Socket客戶端與之進行通信,獲取數據並進行後續處理。 

技術方案

由於團隊的主技術棧是Java 所以我們決定選用Netty作爲構建網絡通信客戶端的框架。至於爲什麼使用Netty而不是原生的Java NIO,不是本文討論的重點,可以簡單粗暴的認爲Netty就是比原生的Java NIO優秀。

基於對需求的理解,我們的技術方案由以下技術細節組成:


image.png

結合上圖所示總結一下,多客戶端體現了邊緣端管理維度的需求,斷線重連機制是通訊維度的要求,數據解析和粘包處理是數據處理維度,更加接近實際業務處理。 

技術細節

基於技術方案的分解,我們從技術細節入手,結合代碼實現逐步完成一個數採邊緣端程序。

多客戶端

Netty中啓動一個客戶端簡單到只需要三句話,雖然鏈式調用的一句話確實有點長。原始的Netty客戶端建立過程如下:

Bootstrap bootstrap = new Bootstrap();
EventLoopGroup workerGroup = new NioEventLoopGroup();
bootstrap.group(workerGroup)
    .channel(NioSocketChannel.class) 
    .handler(new ClientIniterHandler())
    .connect(ip, port).addListener();


考慮到多客戶端求,我們對Netty進行了封裝,封裝後的客戶端更加易於管理。       

image.png

Starter是啓動類,負責讀取配置文件並初始化客戶端。其中clientMap作爲全局客戶端的管理者,管理地址和客戶端的映射關係,數據結構爲Map<String, NettyClient>

具體代碼如下:

File[] files = FileUtil.getPropertiesFileArr();
for (int i = 0; i < files.length; i++) {
    Properties properties = PropertiesUtil.initProperties(files[i]);
    NettyClient client = new NettyClient(properties);
    clientMap.put(properties.getProperty("server.ip")+":"+properties.getProperty("server.port"),client);
    client.run();
}

NettyClient是封裝後的客戶端,通過配置文件構造,將配置注入到屬性中。

DataHandler是數據處理類,主要負責報文解析和數據發送後臺數據的工作。

每個NettyClient聚合一個DataHandler用於數據處理,避免了併發問題符合Netty串行處理的設計思想。 

斷線重連

針對斷線重連功能我們聚焦兩個關注點

    1, 首次連接失敗的重試

首次連接我們通過對Connect事件加入Future Listener實現,在Future Listener中監聽isSuccess標識位可以獲取到連接狀態,從而確定下一步動作。

加入retryTime字段的控制,當重試次數達到一定次數後降低重試的頻率,一定程度上節省資源。代碼如下:    

  bootstrap.connect(ip, port).addListener((ChannelFuture futureListener)->{
  final EventLoop eventLoop = futureListener.channel().eventLoop();
   if (!futureListener.isSuccess()) {                                                     
      // 10s秒之後重連
      retryTimes ++;
      if(retryTimes <= 10) {
        eventLoop.schedule(() ->  doConnect(new Bootstrap(), eventLoop), 10, TimeUnit.SECONDS);
      }else {
        //超過10次後改成1分鐘重試
         eventLoop.schedule(() ->   doConnect(new Bootstrap(), eventLoop), 1, TimeUnit.MINUTES);                                                                  
      }               
   } else {
      retryTimes = 0;
      LOG.info("客戶端[{}]與服務器連接成功",id);
   }
});

2, 非首次連接的斷線重連

當連接過程中發生斷線會觸發Inactive事件,在Client HandlerchannelInactive監聽中進行處理即可

  final EventLoop eventLoop = ctx.channel().eventLoop();
  //獲取配置信息                
  NettyClient client = getNettyClientByCtx(ctx);
  if(client != null) {
     client.doConnect(new Bootstrap(), eventLoop);
  }

16進制解析

在服務端和客戶端通信的過程中通常採用字節流,Netty在接收端通過Pipeline加入編解碼器的方式完成字節流和對象之間的轉換。具體代碼如下:

ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast("decoder", new StringDecoder());

這裏的StringDecoder是字符串解碼器,字節流通過解碼器的解析就可以轉換成String類型,從而方便後續的處理。 

我們遇到的問題是服務端發送的是16進制數據,因此我們需要的是一個16進制解碼器,下面是我們對於兩種處理過程的比較。

image.png


由此可以看出和一般的處理差異在於服務端多了16進制編碼過程,導致客戶端也要相應的增加16進制的解碼器,考慮實現方便我們將16進制解碼器和字符串解碼器合併成一個HexDecoder

ClientInitHander中加入HexDecoder替換原來的StringDecoder

pipeline.addLast("decoder", new HexDecoder());

HexDecoderDecode方法主要就是做了兩件事情

  1. 字節到HexString 

  2. HexStringString

具體實現如下:

protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
    String HEXES = "0123456789ABCDEF";
    byte[] req = new byte[msg.readableBytes()];
    msg.readBytes(req);
    final StringBuilder hex = new StringBuilder(2 * req.length); 
    for (int i = 0; i < req.length; i++) {
       byte b = req[i];
       hex.append(HEXES.charAt((b & 0xF0) >> 4))
            .append(HEXES.charAt((b & 0x0F)));
    }
    out.add(ClientUtil.hexStr2Str(hex.toString()));
}
public static String hexStr2Str(String hexStr) {
    String str = "0123456789ABCDEF";
    char[] hexs = hexStr.toCharArray();
    byte[] bytes = new byte[hexStr.length() / 2];
    int n;
    for (int i = 0; i < bytes.length; i++) {
      n = str.indexOf(hexs[2 * i]) * 16;
      n += str.indexOf(hexs[2 * i + 1]);
      bytes[i] = (byte) (n & 0xff);
    }
    return new String(bytes);
}

              

通過以上的實現16進制數據的解析問題是解決了,我們不禁要好奇一下服務器爲啥要弄成16進制傳輸呢?

結合網上的討論,下面兩點是我比較認可的

1, 機器人內部的數據都是二進制01形式的,二進制和十六進制之間轉換比較容易

2, 相對十進制來說,十六進制的數據可讀性更強,如Ox1064,很容易的看出高四位是0001而這個位置一般會放一些標誌位。 

粘包處理

粘包是指客戶端讀取的報文不是一個完整的報文,大多數情況會和拆包結對出現。

舉個例子,我們的報文結構是

<Robot><Item1></Item1></Robot>

當出現粘包的情況時某一次收到的報文就可能是:

<Robot><Item1></Item1></Robot>< Robot><Item1>

而下一次收到的報文就可能是:

</Item1></Robot><Robot><Item1></Item1></Robot>

粘包的出現由兩方面因素構成

image.png


針對粘包現象,Netty提供了三種解決思路

image.png


瞭解了三種解決思路之後,我們發現固定長度和長度位兩種方案對於服務端都需要改造,而分隔符的解決思路很好的契合了我們這種結構性很強的報文。最終我們選用了DelimiterBasedFrameDecoder,在ChannelInitHander中將Decoder加入Pipeline即可:

ChannelPipeline pipeline = socketChannel.pipeline();
ByteBuf delimiter = Unpooled.wrappedBuffer("</Robot>".getBytes());
pipeline.addLast("delimiter", new DelimiterBasedFrameDecoder(1024, false, delimiter));

DelimiterBasedFrameDecoder需要傳入三個參數。

第一個參數1024是定義的最大字節長度,當報文長度超出1024,則丟棄該段報文,這個參數根據實際情況調整;

第二個參數false表示分隔符不被忽略也就是說分隔符也是作爲報文的一部分需要讀取。

第三個參數是傳入的分隔符,我們選擇結束符</Robot>作爲分隔符。

 

通過DelimiterBasedFrameDecoder的引入,我們的報文粘包拆包問題得到了解決。通過分隔符確保在客戶端讀緩衝區的數據是按照一個完整報文的分段被客戶端讀取,這種方式規避了服務端改動的複雜性,也避免了服務端和客戶端同時修改的不一致性,在數據結構性很強的時候優先選擇使用。 

總結展望

在邊緣數採的實現中,我們通過基於配置的客戶端初始化方案,可以在一個邊緣端管理多個客戶端程序。通過斷線重連機制確保了連接的可用性。16進制數據解析和粘包處理確保可以接收到正確的報文進行後續處理。

通過以上技術細節的實現,最終我們基於Netty搭建了一套符合FANUC機器人數採要求的邊緣數採客戶端。 


在收到數據之後我們面臨的第一個問題是數據預處理,10Hz的數據如何確保一秒鐘就是收到10條數據,在後續的文章中繼續探討數據重採樣機制。

[End]


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章