Netty編程之基於Netty的私有協議設計與開發

通信協議從廣義上來區分,可以分爲公有協議和私有協議。由於私有協議的靈活性,它往往會在某個公司或者組織內部使用,按需定製,因因如此,升級起來會非常方便,靈活性較好。本博客基於《Netty 權威指南》,設計並實現私有協議。

1. 什麼是私有協議

私有協議本質上是廠商內部發展和採用的標準,除非授權,其他廠商一般無權使用該協議。私有協議也稱非標準協議,就是未經國際或國家標準化組織採納或批准,由某個企業自己制訂,協議實現細節不願公開,只在企業自己生產的設備之間使用的協議。私有協議具有封閉性、壟斷性、排他性等特點。如果網上大量存在私有(非標準)協議,現行網絡或用戶一旦使用了它,後進入的廠家設備就必須跟着使用這種非標準協議,才能夠互連互通,否則根本不可能進入現行網絡。這樣,使用非標準協議的廠家就實現了壟斷市場的願望。

在傳統的Java應用中,通常使用以下4種方式進行跨節點通信。

  • 通過RMI進行遠程服務調用;
  • 通過Java的Socket+Java序列化的方式進行跨節點調用;
  • 利用一些開源的RPC框架進行遠程服務調用,例如Facebook的Thrift,Apache的Avro等;
  • 利用標準的公有協議進行跨節點服務調用,例如HTTP+XML、RESTful+JSON或者WebService。

跨節點的遠程服務調用,除了鏈路層的物理連接外,還需要對請求和響應消息進行編解碼。在請求和應答消息本身以外,也需要攜帶一些其他控制和管理類指令,例如鏈路建立的握手請求和響應消息、鏈路檢測的心跳消息等。當這些功能組合到一起之後,就會形成私有協議。

2. Netty私有協議功能設計與開發

2.1. Netty私有協議功能

本博客中介紹的基於Netty的私有協議主要有以下5個功能:

  • 基於Netty的NIO通信框架,提供高性能的異步通信能力;
  • 提供消息的編解碼框架,可以實現POJO的序列化和反序列化;
  • 提供基於IP地址的白名單接入認證機制;
  • 鏈路的有效性校驗機制;
  • 鏈路的斷連重連機制。

2.2. Netty私有協議通信模型

本文設計的私有協議通信過程如下:

  1. Netty協議棧客戶端發送握手請求消息,攜帶節點ID等有效身份認證信息;
  2. Netty協議棧服務端對握手請求消息進行合法性校驗,包括節點ID有效性校驗、節點重複登錄校驗和IP地址合法性校驗,校驗通過後,返回登錄成功的握手應答消息;
  3. 鏈路建立成功之後,客戶端發送業務消息;
  4. 鏈路成功之後,服務端發送心跳消息;
  5. 鏈路建立成功之後,客戶端發送心跳消息;
  6. 鏈路建立成功之後,服務端發送業務消息;
  7. 服務端退出時,服務端關閉連接,客戶端感知對方關閉連接後,被動關閉客戶端連接。

本文的私有協議通信模型如下圖所示:
在這裏插入圖片描述

2.3. Netty消息定義與實現

本文的消息定義與博客中第二個案例定義相同,在這裏就不再介紹。

2.4. 握手和安全驗證設計與實現

2.4.1. 功能設計

考慮到安全,鏈路建立需要通過基於IP地址或者號段的黑白名單安全認證機制,作爲陽曆,本協議使用IP地址的安全認證機制,如果有多個IP,通過逗號進行分割。

客戶端與服務器鏈路建立成功之後,由客戶端發送握手請求消息,握手請求消息的定義如下:

  • 消息頭的type字段值爲3;
  • 可選附件個數爲0;
  • 消息體爲空;
  • 握手消息的長度爲22個字節。

服務端接受客戶端的握手請求消息之後,如果IP校驗中國,返回握手成功的應答消息給客戶端,應用層鏈路建立成功之後,握手應答消息定義如下:

  • 消息頭的type字段值爲4;
  • 可選附件個數爲0;
  • 消息體爲byte類型的結果,0表示認證成功,-1表示認證失敗

鏈路建立連接之後,客戶端和服務端就可以互相發送消息。下面將分客戶端和服務端分別介紹實現代碼。

2.4.2. 客戶端實現代碼

package netty.protocol.client;

import io.netty.channel.*;

import netty.protocol.MessageType;
import netty.protocol.struct.Header;
import netty.protocol.struct.NettyMessage;

/**
 * created by LMR on 2020/5/23
 */
public class LoginAuthReqHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
    //客戶端激活時就向服務端發送連接請求
        ctx.writeAndFlush(buildLoginReq());
    }
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        NettyMessage message = (NettyMessage) msg;

        // 如果是握手應答消息,需要判斷是否認證成功
        if (message.getHeader() != null && message.getHeader().getType() == MessageType.LOGIN_RESP.value()) {
            byte loginResult = (byte) message.getBody();
            if (loginResult != (byte) 0) {
                // 握手失敗,關閉連接
                ctx.close();
            } else {
                System.out.println("Login is ok : " + message);
                //傳遞給下一個handler
                ctx.fireChannelRead(msg);
            }
        } else
        //傳遞給下一個handler
            ctx.fireChannelRead(msg);
    }
	//構建消息
    private NettyMessage buildLoginReq() {
        NettyMessage message = new NettyMessage();
        Header header = new Header();
        header.setType(MessageType.LOGIN_REQ.value());
        message.setHeader(header);
        return message;
    }

    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        ctx.fireExceptionCaught(cause);
    }
}

2.4.3. 服務端實現代碼

package netty.protocol.server;

import io.netty.channel.*;

import java.net.InetSocketAddress;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import netty.protocol.MessageType;
import netty.protocol.struct.Header;
import netty.protocol.struct.NettyMessage;

/**
 1. created by LMR on 2020/5/23
 */
public class LoginAuthRespHandler extends ChannelInboundHandlerAdapter {


    private Map<String, Boolean> nodeCheck = new ConcurrentHashMap<String, Boolean>();
    private String[] whitekList = {"127.0.0.1", "192.168.100.155", "171.128.110.115"};

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        NettyMessage message = (NettyMessage) msg;

        // 如果是握手請求消息,處理,其它消息透傳
        if (message.getHeader() != null && message.getHeader().getType() == MessageType.LOGIN_REQ.value()) {
        //獲取請求的ip地址
            String nodeIndex = ctx.channel().remoteAddress().toString();
            NettyMessage loginResp = null;
            // 重複登陸,拒絕
            if (nodeCheck.containsKey(nodeIndex)) {
                loginResp = buildResponse((byte) -1);
            } else {
                InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress();
                String ip = address.getAddress().getHostAddress();
                boolean isOK = false;
                for (String WIP : whitekList) {
                    if (WIP.equals(ip)) {
                        isOK = true;
                        break;
                    }
                }
                loginResp = isOK ? buildResponse((byte) 0) : buildResponse((byte) -1);
                if (isOK)
                    nodeCheck.put(nodeIndex, true);
            }
            System.out.println("The login response is : " + loginResp + " body [" + loginResp.getBody() + "]");
            ctx.writeAndFlush(loginResp);
        } else {
            ctx.fireChannelRead(msg);
        }
    }

    private NettyMessage buildResponse(byte result) {
        NettyMessage message = new NettyMessage();
        Header header = new Header();
        header.setType(MessageType.LOGIN_RESP.value());
        message.setHeader(header);
        message.setBody(result);
        return message;
    }

    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        nodeCheck.remove(ctx.channel().remoteAddress().toString());// 刪除緩存
        ctx.close();
        ctx.fireExceptionCaught(cause);
    }
}

相較於客戶端的diamagnetic,服務端現得複雜得多,這是由於我們需要在服務端進行安全認證。與上面設計得一樣,我們設置了重複登陸檢查和白名單檢查。成功連接標識爲0,失敗則標識爲-1。

2.5. 心跳機制設計與實現

2.5.1 功能設計

心跳檢測是爲了防止網絡狀況波動,網絡通信失敗,影響正常得業務。具體的設計思路如下:

  1. 連續時間T沒有讀寫消息時,客戶端主動發送心跳信息給服務端;
  2. 如果下一個週期T到來時,客戶端還沒有收到服務端發送來得心跳消息或者讀寫消息,則認爲心跳失敗,進行計數;
  3. 客戶端如果收到消息,則將心跳失敗計數置0.如果連續N次沒有收到服務端應答,則關閉鏈路,等待一段時間再發起重連;
  4. 服務端連續T時間沒有收到消息,失敗計數加1,收到消息就置0;
  5. 服務端連續N次沒有收到消息,則關閉鏈路,釋放資源,等待客戶端重連。

2.5.2. 客戶端代碼

package netty.protocol.client;

import io.netty.channel.ChannelHandlerContext;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import netty.protocol.MessageType;
import netty.protocol.struct.Header;
import netty.protocol.struct.NettyMessage;
import io.netty.channel.ChannelInboundHandlerAdapter;

/**
 * created by LMR on 2020/5/23
 */
public class HeartBeatReqHandler extends ChannelInboundHandlerAdapter {


    private volatile ScheduledFuture<?> heartBeat;

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        NettyMessage message = (NettyMessage) msg;
        // 握手成功,主動發送心跳消息
        if (message.getHeader() != null && message.getHeader().getType() == MessageType.LOGIN_RESP.value()) {
            heartBeat = ctx.executor().scheduleAtFixedRate(new HeartBeatTask(ctx), 0, 5000, TimeUnit.MILLISECONDS);
        } else if (message.getHeader() != null && message.getHeader().getType() == MessageType.HEARTBEAT_RESP.value()) {
            System.out.println("Client receive server heart beat message : ---> " + message);
        } else
            ctx.fireChannelRead(msg);
    }

    private class HeartBeatTask implements Runnable {
        private final ChannelHandlerContext ctx;

        public HeartBeatTask(final ChannelHandlerContext ctx) {
            this.ctx = ctx;
        }

        @Override
        public void run() {
            NettyMessage heatBeat = buildHeatBeat();
            System.out.println("Client send heart beat messsage to server : ---> " + heatBeat);
            ctx.writeAndFlush(heatBeat);
        }

        private NettyMessage buildHeatBeat() {
            NettyMessage message = new NettyMessage();
            Header header = new Header();
            header.setType(MessageType.HEARTBEAT_REQ.value());
            message.setHeader(header);
            return message;
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
            throws Exception {
        cause.printStackTrace();
        if (heartBeat != null) {
            heartBeat.cancel(true);
            heartBeat = null;
        }
        ctx.fireExceptionCaught(cause);
    }
}

在客戶端代碼中如果登陸驗證成功,會創建一個線程來定時發送心跳消息。在HeartBeatTask 得run方法中,會創建一個心跳NettyMessage消息,用於心跳驗證,然後調用傳入得ChannelHandlerContext 對象將消息傳遞給服務端。

2.5.3. 服務端代碼

package netty.protocol.server;

import io.netty.channel.ChannelHandlerContext;
import netty.protocol.MessageType;
import netty.protocol.struct.Header;
import netty.protocol.struct.NettyMessage;
import io.netty.channel.ChannelInboundHandlerAdapter;


/**
 * created by LMR on 2020/5/23
 */
public class HeartBeatRespHandler extends ChannelInboundHandlerAdapter {


    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        NettyMessage message = (NettyMessage) msg;
        // 返回心跳應答消息
        if (message.getHeader() != null && message.getHeader().getType() == MessageType.HEARTBEAT_REQ.value()) {
            System.out.println("Receive client heart beat message : ---> " + message);
            NettyMessage heartBeat = buildHeatBeat();
            System.out.println("Send heart beat response message to client : ---> " + heartBeat);
            ctx.writeAndFlush(heartBeat);
        } else
            ctx.fireChannelRead(msg);
    }

    private NettyMessage buildHeatBeat() {
        NettyMessage message = new NettyMessage();
        Header header = new Header();
        header.setType(MessageType.HEARTBEAT_RESP.value());
        message.setHeader(header);
        return message;
    }

}

服務端得新體哦啊檢測十分簡單,接收到心跳消息之後,構造心跳應答消息返回,並打印接受和發送得心跳消息。

心跳超時得實現,我們直接利用Netty得ReadTimeouthandler機制來實現,當一定週期內(默認50s)沒有讀取到對方得任何消息時,需要主動關閉鏈路,如果是客戶端需要自己主動重連。如果是服務端則釋放資源等待客戶端重連。

2.6. 斷連重連

重連機制主要是在客戶端實現,當發現連接斷開是就需要進行重連,在這裏我們是客戶端啓動類使用一個線程池來重新連接

 executor.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        TimeUnit.SECONDS.sleep(1);
                        try {
                            connect(NettyConstant.PORT, NettyConstant.REMOTEIP);// 發起重連操作
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });

2.7. 啓動類實現

2.7.1. 服務端啓動類

package netty.protocol.server;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

import java.io.IOException;

import io.netty.handler.timeout.ReadTimeoutHandler;
import netty.protocol.codec.NettyMessageDecoder;
import netty.protocol.codec.NettyMessageEncoder;

/**
 * created by LMR on 2020/5/23
 */
public class NettyServer {

    public static void main(String[] args) throws Exception {
        new NettyServer().bind(8080);
    }

    public void bind(int port) throws Exception {
        // 配置服務端的NIO線程組
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 100)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    public void initChannel(SocketChannel ch)
                            throws IOException {
                        ch.pipeline().addLast(
                                //自定義消息解碼器
                                new NettyMessageDecoder(1024 * 1024, 4, 4));
                        //自定義消息編碼器
                        ch.pipeline().addLast(new NettyMessageEncoder());
                        //處理超時
                        ch.pipeline().addLast("readTimeoutHandler", new ReadTimeoutHandler(50));
                        //用戶驗證
                        ch.pipeline().addLast(new LoginAuthRespHandler());
                        ch.pipeline().addLast("ServerHandler", new ServerHandler());
                        //心跳檢測
                        ch.pipeline().addLast("HeartBeatHandler", new HeartBeatRespHandler());


                    }
                });

        // 綁定端口,同步等待成功
        b.bind(port).sync();
        System.out.println("Netty server start ok : " + port);
    }

}

2.7.2. 客戶端啓動類

package netty.protocol.client;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.timeout.ReadTimeoutHandler;

import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import netty.protocol.NettyConstant;
import netty.protocol.codec.NettyMessageDecoder;
import netty.protocol.codec.NettyMessageEncoder;


/**
 * created by LMR on 2020/5/23
 */
public class NettyClient {

    private ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

    EventLoopGroup group = new NioEventLoopGroup();

    public void connect(int port, String host) throws Exception {
        // 配置客戶端NIO線程組
        try {
            Bootstrap b = new Bootstrap();
            b.group(group).channel(NioSocketChannel.class)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch)
                                throws Exception {
                            ch.pipeline().addLast(new NettyMessageDecoder(1024 * 1024, 4, 4));
                            ch.pipeline().addLast("MessageEncoder", new NettyMessageEncoder());
                            ch.pipeline().addLast("readTimeoutHandler", new ReadTimeoutHandler(50));
                            ch.pipeline().addLast("LoginAuthHandler", new LoginAuthReqHandler());
                            ch.pipeline().addLast("ClientHandler", new ClientHandler());
                            ch.pipeline().addLast("HeartBeatHandler", new HeartBeatReqHandler());

                        }
                    });
            // 發起異步連接操作
            ChannelFuture future = b.connect(new InetSocketAddress(host, port), new InetSocketAddress(NettyConstant.LOCALIP, NettyConstant.LOCAL_PORT)).sync();
            // 當對應的channel關閉的時候,就會返回對應的channel。
            future.channel().closeFuture().sync();
        } finally {
            // 所有資源釋放完成之後,清空資源,再次發起重連操作
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        TimeUnit.SECONDS.sleep(1);
                        try {
                            connect(NettyConstant.PORT, NettyConstant.REMOTEIP);// 發起重連操作
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    }

    public static void main(String[] args) throws Exception {
        new NettyClient().connect(NettyConstant.PORT, NettyConstant.REMOTEIP);
    }

}

在客戶端與服務端啓動類中的消息編解碼器以及客戶端和服務端的處理類均在博客中有講解,在這裏我們就不再進行重複介紹。

2.8. 運行截圖

服務端截圖:
在這裏插入圖片描述
客戶端截圖:
在這裏插入圖片描述
由於截圖時間不一致,所以心跳消息不一致。



參考書籍和博客:
《Netty權威指南》

如果喜歡的話希望點贊收藏,關注我,將不間斷更新博客。

希望熱愛技術的小夥伴私聊,一起學習進步

來自於熱愛編程的小白

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