基於 Netty 的自定義幀高可靠性讀取方案

雪中亮發表於2018-01-17

「部落格搬家」 原地址: 簡書 原發表時間: 2017-03-26

本文采用 Netty 這一最流行的 Java NIO 框架,作為 Java 伺服器通訊部分的基礎框架,探索使用一個通道、一臺伺服器對多個客戶端提供服務。

完成客戶端 - 伺服器通訊,需要基於 TCP 協議之上,自定義一套簡單的通訊協議,其中資料交換方式需要使用自定義幀。為實現以上方案,本文采用 Netty 框架實現 Java 伺服器的通訊部分。

Netty 是由 JBoss 提供的一個 Java開源 框架。Netty 提供非同步的、事件驅動的網路應用程式框架和工具,用以快速開發高效能、高可靠性的網路伺服器和客戶端程式。

也就是說,Netty 是一個基於 NIO 的客戶、伺服器端程式設計框架,使用Netty 可以確保你快速和簡單的開發出一個網路應用,例如實現了某種協議的客戶,服務端應用。Netty 相當簡化和流線化了網路應用的程式設計開發過程,例如,TCP 和 UDP 的 socket 服務開發。

本專案的硬體裝置叢集使用 CAN 匯流排作為通訊協議,硬體裝置產生的資料和工作人員的控制指令均由伺服器後端應用程式處理並儲存。由於伺服器並未原生支援 CAN 匯流排,故為了方便起見,使用「CAN轉乙太網」模組作為 CAN 協議和 TCP 協議交換的中介,以謀求實現的簡單化。專案總體架構圖如下圖所示:

專案總體架構圖

CAN - bus,即控制器區域網,是國際上應用最廣泛的現場匯流排之一。

作為一種技術先進、可靠性高、功能完善、成本合理的遠端網路通訊控制方式,CAN - bus 已經被廣泛應用到各個自動化控制系統中。從高速的網路到低價位的多路接線都可以使用 CAN - bus。例如,在汽車電子、自動控制、智慧大廈、電路系統、安防監控等領域。

1. Netty 框架的學習

以下提供幾篇不錯的文章,幫助大家學習 Netty 這一頗受矚目的框架。

  1. 《Netty in Action》中文版 - 併發程式設計網
  2. Essential Netty in Action -《Netty 實戰(精髓)》
  3. Netty 4.x User Guide 中文翻譯《Netty 4.x 使用者指南》

2. Bootstrapping 伺服器方案

以下程式碼是 Bootstrapping 伺服器的實現方案:

public class KyServer{
  private SuccessfulListener launchListener;
  private SuccessfulListener finishListener;
  private NioEventLoopGroup group;

  public void start() {
    new Thread(() -> {
      group = new NioEventLoopGroup();
      ServerBootstrap bootstrap = new ServerBootstrap();
      bootstrap.group(group)
          .channel(NioServerSocketChannel.class)
          .childHandler(new ServerChannelInitializerTest());
      ChannelFuture channelFuture = bootstrap.bind(new InetSocketAddress(30232));
      channelFuture.addListener(
          (ChannelFutureListener) future -> startListenerHandle(future, launchListener));
    }).start();
  }

  private void startListenerHandle(Future future, SuccessfulListener listener) {
    if (!future.isSuccess()) future.cause().printStackTrace();
    if (listener != null) listener.onSuccess(future.isSuccess());
  }

  public void setLaunchSuccessfulListener(
      SuccessfulListener successfulListener) {
    this.launchListener = successfulListener;
  }

  public void setFinishSuccessfulListener(
      SuccessfulListener finishListener) {
    this.finishListener = finishListener;
  }

  public void shutdown() {
    if (group != null) {
      Future<?> futureShutdown = group.shutdownGracefully();
      futureShutdown.addListener(future -> startListenerHandle(future, finishListener));
    }
  }
}
複製程式碼

2.1 Bootstrapping 伺服器的設計要點

  • 建立一個 ServerBootstrap 例項來啟動和繫結伺服器
  • 建立並且分配一個 NioEventLoopgroup 例項來處理 event,比如接受新的連線和讀/寫資料
  • 指定本地 InetSocketAddress 到伺服器繫結的埠
  • 用 ChannelHandler 例項來初始化 Channel
  • 呼叫 ServerBootstrap.bind() 來繫結伺服器

2.2 伺服器監聽器的設計「觀察者模式」

首先在該類中設定成員變數:

private SuccessfulListener launchListener;
private SuccessfulListener finishListener;
複製程式碼

而後新增該變數的 set 方法,以及監聽器的處理方法:

private void startListenerHandle(Future future, SuccessfulListener listener) {
    if (!future.isSuccess()) future.cause().printStackTrace();
    if (listener != null) listener.onSuccess(future.isSuccess());
}

public void setLaunchSuccessfulListener(SuccessfulListener successfulListener) {
    this.launchListener = successfulListener;
}

public void setFinishSuccessfulListener(SuccessfulListener finishListener) {
    this.finishListener = finishListener;
}
複製程式碼

在伺服器啟動監聽時,執行如下程式碼

ChannelFuture channelFuture = bootstrap.bind(new InetSocketAddress(30232));
channelFuture.addListener(future -> startListenerHandle(future, launchListener));
複製程式碼

在外部關閉伺服器時,執行該方法:

public void shutdown() {
    if (group != null) {
        Future<?> futureShutdown = group.shutdownGracefully();
        futureShutdown.addListener(future -> startListenerHandle(future, finishListener));
    }
}
複製程式碼

通過如上方法,外部操作者可以方便得知伺服器是否啟動成功以及是否結束成功,使用觀察者模式,完美實現了對伺服器啟動及關閉的監聽。

3. 伺服器業務邏輯的實現

首先使用初始化方法 ServerChannelInitializer 完成所有 ChannelHandlerChannel 的繫結操作:

public class ServerChannelInitializer extends ChannelInitializer<NioSocketChannel> {

    @Override
    protected void initChannel(NioSocketChannel ch) {
        ch.pipeline().addLast(new LoggingHandler("NO1"));
        byte head = 0x11;
        ch.pipeline().addLast(new FrameIdentifierChannelInboundHandler(head));
        ch.pipeline().addLast(new ShowByteBufAsFrameInBoundHandler());
    }
}
複製程式碼

3.1 輸入資料處理方案

3.1.1 自定義幀方案

自定義幀包括「幀標識位」、「資料長度」、「資料體」,如下圖所示,:

自定義幀示意圖

  • 幀標識位:0x11。
  • 資料長度:兩個位元組,可表示資料部分大小最大為 2 ^ 16 - 1 。
  • 資料體:實際有用的資料。

3.1.2 輸入資料處理器

以下為輸入資料的第一個處理器,可以保證無論 TCP 幀經歷怎樣的粘包、拆包,均可以準確提取每一個自定義幀的資料部分。

/**
 * 入端自定義幀提取處理器
 * 將資料流提取出完整的自定義幀並傳入下一個處理器
 */
public class FrameIdentifierChannelInboundHandler extends SimpleChannelInboundHandler<ByteBuf> {
  private byte[] frameHead;
  private int frameHeadLength;
  private int frameBodyLength;
  private FrameReceivedEnum frameStatus = FrameReceivedEnum.READY;
  private ByteBuf holdByteBuf = Unpooled.buffer(1024);

  FrameIdentifierChannelInboundHandler(byte... frameHead) {
    this();
    this.frameHead = frameHead;
    frameHeadLength = frameHead.length;
  }

  @Override
  protected void channelRead0 (ChannelHandlerContext ctx, ByteBuf msg) {
    //資料讀入本地buffer
    holdByteBuf.writeBytes(msg);
    while (true) {
      //若讀取狀態為: 開始讀取
      if (frameStatus == FrameReceivedEnum.READY) {
        if (!matchFrameHead(holdByteBuf)) {
          holdByteBuf.clear();
          break;
        }
      }

      //若讀取狀態為: 幀長讀取
      //資料體完全包含在 buffer 內,則可通過此狀態
      if (frameStatus == FrameReceivedEnum.READING_LENGTH) {
        if (holdByteBuf.readableBytes() <= 1) break;
        //無符號 short 需要用 int 型引用
        int currentFrameLength = holdByteBuf.getUnsignedShort(holdByteBuf.readerIndex());

        //可讀byte數為長度計數(2)+資料體長度; 所以當前幀長+2 <= 可讀幀長
        if (currentFrameLength + 2 <= holdByteBuf.readableBytes()) {
          frameBodyLength = holdByteBuf.readUnsignedShort();
          frameStatus = FrameReceivedEnum.READING_BODY;
        } else {
          break;
        }
      }

      //若讀取狀態為: 資料體讀取
      //預設資料體完全包含在buffer內,否則丟擲異常
      if (frameStatus == FrameReceivedEnum.READING_BODY) {
        if (frameBodyLength == 0) {
          frameStatus = FrameReceivedEnum.READY;
          frameBodyLength = -1;
          holdByteBuf.discardReadBytes();
        } else if (frameBodyLength > 0) {
          ByteBuf returnBuf = Unpooled.buffer(frameBodyLength);
          holdByteBuf.readBytes(returnBuf);
          frameStatus = FrameReceivedEnum.READY;
          //    ctx.fireChannelRead(returnBuf);
          ctx.writeAndFlush(returnBuf);
          frameBodyLength = -1;
          holdByteBuf.discardReadBytes();
        } else {
          throw new FrameLoadException("自定義幀長度計數異常");
        }
      } else {
        throw new FrameLoadException("自定義幀讀取異常");
      }
    }
  }

  private boolean matchFrameHead(ByteBuf byteBuf) {
    while (true) {
      if (byteBuf.readableBytes() < frameHeadLength) {
        return false;
      }
      if (frameHead[0] == byteBuf.readByte()) {
        frameStatus = FrameReceivedEnum.READING_LENGTH;
        return true;
      }
    }
  }
}
複製程式碼

以下為第二個輸入資料處理器,可將前一處理器的結果「優雅」列印到控制檯上並原樣傳送至客戶端:

public class ShowByteBufAsFrameInBoundHandler extends SimpleChannelInboundHandler<ByteBuf> {
  
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        System.out.println(ByteBufUtil.prettyHexDump(byteBuf));
        ctx.pipeline().writeAndFlush(Unpooled.copiedBuffer(msg));
    }
}   
複製程式碼

4. 參考連結

  1. CAN 轉乙太網裝置介紹
  2. Netty - 百度百科
  3. 《Netty in Action》中文版 - 併發程式設計網

相關文章