基於netty的通訊協議的設計思考

序:本文分析了dubbo,rocketmq,以及我們自己項目中的通訊協議的設計與使用。
其中dubbo的協議分傳輸與業務兩個層次,有點類似於以http當傳輸工具,響應體內還有返回code與值的設計,相應的handler處理器也是兩層嵌套。rocketmq由於被用戶的消息佔用了響應體,自己的業務數據都放在了請求頭的設計。我們的協議特別增加了安全方面的數據,放在請求頭。
本文分析了這些內容,探討設計一個通訊協議考慮哪些內容,進行如何取捨,提供一個思路。

一、dubbo協議的設計

協議比如說是dubbo,傳輸可能是:mina、netty、grizzy,序列化可能是:dubbo、hessian2、java、json 。那麼這一切都是如何從業務過程中關聯起來,並且如何設計的呢?

1. 從rpc到remote

一個接口方法的動態實現了遠程方法調用,讓調用者感覺與本地調用一樣。

既然是遠程調用,涉及到用什麼協議把調用數據發過去,以及接收方按這個協議解析出請求,進行處理後,用這個協議再返回結果。

協議比如說是dubbo,對於rpc要做的事情有:

  • 調用方要做的事情有:產生一個包含調用數據的DubboInvoker,並且要用一個傳輸工具把這個發過去。
  • 被調用方應該早就做好的事情:把自己所擁有的接口與實現,分別轉換爲DubboInvoker:service(key:value),存在map中,另外應該啓動好了一個傳輸的服務端來接收DubboInvoker,

那remote-傳輸工具要做的事情就是:

  • 調用方把DubboInvoker,編碼成指定的dubbo協議格式。
  • 被調用方把數據流,轉換成DubboInvoker。DubboInvoker就是一個java對象,包含了遠程調用的方法參數等信息的類。

2. remote

DubboInvoker只是要傳輸的內容,通常我們會給快遞的內容裝進一個有基本信息的盒子,所以傳輸的是一個包含了head與body的信息,body就是DubboInvoker。

傳輸可以是:mina、netty、grizzy。就是幾家快遞公司,不同的是東西會轉成byte[],收到了要再轉回來。傳輸層可以包括轉換與傳輸兩個過程。

比如三種傳輸工具都可以從外部給一個編碼方式進去。比如就是dubbo協議吧。你給它們DubboInvoker,它們就會用編碼器轉換/傳輸/再反轉換:

//mina服務端啓動時加載一個編碼適配器,帶着編碼方式。
acceptor.getFilterChain().addLast("codec", new ProtocolCodecFilter(new MinaCodecAdapter(getCodec(), getUrl(), this)));

//Grizzly服務端啓動時,也加載一個編碼適配器,帶着編碼方式。
 filterChainBuilder.add(new GrizzlyCodecAdapter(getCodec(), getUrl(), this));
 
//Netty4服務端啓動時,也加載一個編碼適配器,帶着編碼方式。
protected void initChannel(NioSocketChannel ch) throws Exception {
NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyServer.this);
ch.pipeline()//.addLast("logging",new LoggingHandler(LogLevel.INFO))//for debug
.addLast("decoder", adapter.getDecoder())
.addLast("encoder", adapter.getEncoder())
.addLast("handler", nettyServerHandler);
}

3. 協議是怎麼回事

rpcf就是怎麼把DubboInvoker包裝成一個request的body,並把request的body怎麼轉成DubboInvoker的過程的規則。但是,又不完全是這樣,傳輸工具的客戶端與服務器也要有消息交流的需求,比如心跳啊。這樣要區分不同類型的消息,就要有一個消息頭,裏面至少要有類型,如果是心跳,就可能沒有body,或者body裏是其它對象。

所以,有些消息只要解析出頭,所以可分head/body兩次解析。RPC的傳輸只是傳輸的一部分,當然也只是協議的一部分。我們又從另一個角度找出了公共部分。

協議的頭部是滿足多種消息的傳輸使用,而且對這幾個工具是無區別的,可以定義在remote模塊的公共包中,不可能放在netty4或者mina中吧。如果頭部如果發現類型是DubboInvoker,再按格式解析body,這個與rpc密切相關,只能放在rpc-dubbo模塊的包裏了。我傳輸工具只解析頭部進行處理,如果是DubboInvoker對象,給你這rpc這層再處理。

傳輸工具肯定有一個exchangeHandler,而且還持有一個rpcHandler,如果自己能處理的消息,就不用rpcHandler了。

//exchangeHandler Received處理Request對象,根據類型,特殊時交給內部持有的rpcHandler處理。可以暫時認爲是rpcHandler,可能會被包裝一下,插入點其它事情。
if (message instanceof Request) {
            // handle request.
            Request request = (Request) message;
            if (request.isEvent()) {
                handlerEvent(channel, request);
            } else {
                if (request.isTwoWay()) {
                    Response response = handleRequest(exchangeChannel, request);//裏面用了handler
                    channel.send(response);
                } else {
                    handler.received(exchangeChannel, request.getData());//直接用了handler
                }
            }
        } 

//---------------------------------------------------
//DubboProtocol.java裏會有new ExchangeHandlerAdapter() {...},就是上面的handler。它的received處理的已經是Invocation了。
        @Override
        public void received(Channel channel, Object message) throws RemotingException {
            if (message instanceof Invocation) {
                reply((ExchangeChannel) channel, message);
            } else {
                super.received(channel, message);
            }
        }

所以,我認爲dubbo協議是對request的head與body的規範,分別用在remote與rpc層。

public class DubboCodec extends ExchangeCodec implements Codec2

在這裏插入圖片描述

magic:類似java字節碼文件裏的魔數,用來判斷是不是dubbo協議的數據包。魔數是常量0xdabb,用於判斷報文的開始。
flag:標誌位, 一共8個地址位。低四位用來表示消息體數據用的序列化工具的類型(默認hessian),高四位中,第一位爲1表示是request請求,第二位爲1表示雙向傳輸(即有返回response),第三位爲1表示是心跳ping事件。
status:狀態位, 設置請求響應狀態,dubbo定義了一些響應的類型。具體類型見
  com.alibaba.dubbo.remoting.exchange.Response
invoke id:消息id, long 類型。每一個請求的唯一識別id(由於採用異步通訊的方式,用來把請求request和返回的response對應上)
body length:消息體 body 長度, int 類型,即記錄Body Content有多少個字節。

在這裏插入圖片描述

4. 序列化

序列化支持:dubbo、hessian2、java、json。只是協議中的相關的類轉成byte[]的格式的規範。

//DubboCodec.java的方法中可以看到,序列化是另一回事,從url中配置的。看到header在remote中已經解析好了,給rpc部分直接用。
//可是,應該可以給一個只不過body沒解析的Request對象過來吧????
//應該不處理heatbeat與event了吧?????
@Override
protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {
    byte flag = header[2], proto = (byte) (flag & SERIALIZATION_MASK);
    Serialization s = CodecSupport.getSerialization(channel.getUrl(), proto);
    ...
        
            Request req = new Request(id);
            req.setVersion(Version.getProtocolVersion());
            req.setTwoWay((flag & FLAG_TWOWAY) != 0);
...
            try {
                Object data;
                if (req.isHeartbeat()) {
                    //decodeHeartbeatData已經deprecated了,按說這裏也不會出現了。
                    data = decodeHeartbeatData(channel, deserialize(s, channel.getUrl(), is));
                } else if (req.isEvent()) {
                    data = decodeEventData(channel, deserialize(s, channel.getUrl(), is));
                } else {
                    DecodeableRpcInvocation inv;
...//這裏是RpcInvocation了,後面給了body裏。
                        inv = new DecodeableRpcInvocation(channel, req,
                                new UnsafeByteArrayInputStream(readMessageData(is)), proto);
...
                    data = inv;
                }
                req.setData(data);

5. 設計重點

  • head與body嚴格分傳輸與rpc層次。可以對比業務請求包裝在http中的情況。http返回200時,才解析業務返回中的響應碼與數據。restful正好相反,讓你把http當成應用協議。
  • 消息類型:很重要,放在了flag中的高四位中。兩種業務,一種心跳。
  • 消息id:異步時,返回消息按這個找請求消息,所以也必須。http就沒有這個。
  • 序列化類型:如果是統一的,就沒必要。這裏的可配置的,接受端按這個序列化body中的數據。http中也有body格式說明的設計。
  • 消息長度:對於tcp處理粘包,拆包很重要。netty可以有定長,分隔,也有基於長度的方式。
  • status:響應類型,也很重要,可能會不解析body。

二、 rocketmq的協議設計

1. RocketMq的特點

  • 所有的傳輸都是自己用的,不給外部用。不象dubbo重點是遠程RPC,要兼容很多東西。所以選擇最常用,單一的方式進行消息傳輸。
  • 並且它不是RPC傳輸,所以body部分不是協議重點。
  • RocketMq的網絡通信是基於netty4.x實現的,這下傳輸工具是確定的。
  • 傳輸什麼樣都消息都是確定數目與類型的,比如同步主備數據,比如獲取namesvr數據。

協議格式

​ 1 2 3 4

​ 1、4個字節的int型數據來存儲2、3、4的總長度

​ 2、4個字節的int型數據來存儲報文頭部的字節長度等於3的長度

​ 3、存儲報文頭部的數據

​ 4、存儲報文體的數據

2. 協議頭的設計與使用

2.1 消息的結構與發送業務

以發送客戶端的消息隊列消息爲例,客戶的消息是byte[]類型,但還包含發到哪個隊列,哪個主題,時間等屬性。

//RemotingCommand.java
//協議頭部(傳輸的消息)設計如下:
private int code;
private LanguageCode language = LanguageCode.JAVA;
private int version = 0;
private int opaque = requestId.getAndIncrement();
private int flag = 0;
private String remark;
private HashMap<String, String> extFields;//customHeader中的內容轉成string,string。序列化這裏的內容。而CommandCustomHeader由消息的使用方,根據消息的code進行強轉類型。
private transient CommandCustomHeader customHeader;//不進行序列化

private SerializeType serializeTypeCurrentRPC = serializeTypeConfigInThisServer;

private transient byte[] body;
//向消息隊列發消息
public SendResult sendMessage(
    final String addr,
    final String brokerName,
    final Message msg,
    final SendMessageRequestHeader requestHeader,//
    final long timeoutMillis,
    final CommunicationMode communicationMode,
    final SendCallback sendCallback,
    final TopicPublishInfo topicPublishInfo,
    final MQClientInstance instance,
    final int retryTimesWhenSendFailed,
    final SendMessageContext context,
    final DefaultMQProducerImpl producer
) throws RemotingException, MQBrokerException, InterruptedException {
    long beginStartTime = System.currentTimeMillis();
    RemotingCommand request = null;
...
        request = RemotingCommand.createRequestCommand(RequestCode.SEND_MESSAGE, requestHeader);
...
    request.setBody(msg.getBody());//消息生產者的消息,作爲通訊消息的body,這個解碼由最終用戶來定了。
    switch (communicationMode) {
        case ONEWAY:
            this.remotingClient.invokeOneway(addr, request, timeoutMillis);
            return null;
            }
     ...
     }

//-----------------------------------------------------
//RemotingCommand.createRequestCommand時,設計這兩個要消息頭。
        cmd.setCode(code);//消息的業務功能類型,確定數目的類型。
        cmd.customHeader = customHeader;//與這個類型相關的業務數據對象,與code一一對應。


//SendMessageRequestHeader都是簡單的類型,設置customHeader包含:
SendMessageRequestHeader requestHeader = new SendMessageRequestHeader();
requestHeader.setProducerGroup(this.defaultMQProducer.getProducerGroup());
requestHeader.setTopic(msg.getTopic());
requestHeader.setDefaultTopic(this.defaultMQProducer.getCreateTopicKey());
requestHeader.setDefaultTopicQueueNums(this.defaultMQProducer.getDefaultTopicQueueNums());
requestHeader.setQueueId(mq.getQueueId());
requestHeader.setSysFlag(sysFlag);
...
    

2.2 消息的傳輸與codec

NettyDecoder繼承自netty的長度區分數據包,方法中得到完整數據包後,就轉成RemotingCommand。

public class NettyDecoder extends LengthFieldBasedFrameDecoder
    @Override
    public Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
        ByteBuf frame = null;
        try {
            frame = (ByteBuf) super.decode(ctx, in);//用super方法,得到正確拆TCP包後的數據幀
            if (null == frame) {
                return null;
            }
            ByteBuffer byteBuffer = frame.nioBuffer();
            return RemotingCommand.decode(byteBuffer);//具體解析有效果的數據幀,轉成RemotingCommand
        } catch (Exception e) {
            ...
        } finally {
           ...
        }
        return null;
    }
    

//RemotingCommand.decode()中,主要是解析頭部


//headerDecode有兩種序列化方式。
    private static RemotingCommand headerDecode(byte[] headerData, SerializeType type) {
        switch (type) {
            case JSON:
                RemotingCommand resultJson = RemotingSerializable.decode(headerData, RemotingCommand.class);
                resultJson.setSerializeTypeCurrentRPC(type);
                return resultJson;
            case ROCKETMQ:
                RemotingCommand resultRMQ = RocketMQSerializable.rocketMQProtocolDecode(headerData);
                resultRMQ.setSerializeTypeCurrentRPC(type);
                return resultRMQ;
            default:
                break;
        }
        return null;
    }

encode的時候,由使用RemotingCommand方法,進行 extFields與customHeader之間的轉換,所以netty解析出來的半成品,要設置setSerializeTypeCurrentRPC,這樣使用方可以解析extFields與customHeader。

2.3 netty傳輸工具的使用

netty的協議,主要就是處理業務的,不涉及到其它不相關的方面,比如不涉及心跳。

public void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline()
                        .addLast(defaultEventExecutorGroup, HANDSHAKE_HANDLER_NAME,
                            new HandshakeHandler(TlsSystemConfig.tlsMode))
                        .addLast(defaultEventExecutorGroup,
                            new NettyEncoder(),
                            new NettyDecoder(),
                            new IdleStateHandler(0, 0, nettyServerConfig.getServerChannelMaxIdleTimeSeconds()),
                            new NettyConnectManageHandler(),
                            new NettyServerHandler()
                        );
                }

//class HandshakeHandler extends SimpleChannelInboundHandler<ByteBuf>//TlsMode
//public class IdleStateHandler extends ChannelDuplexHandler
//連接管理:class NettyConnectManageHandler extends ChannelDuplexHandler
//真正處理業務:class NettyServerHandler extends SimpleChannelInboundHandler<RemotingCommand>

3. 設計重點

  • 消息是內部使用,明確的類型與參數。所以有業務類型code與業務屬性extFields兩個部分,是一一對應的。

  • opaque:是請求id,有異步請求必須,要等結果回來按id找請求。

  • serializeTypeCurrentRPC:因爲給業務繼續進行extFields的解析,所以設計的

  • language與version:有語言與版本要求時使用

  • extFields:

    這個字段不通的請求/響應不一樣,完全自定義。數據結構上是java的hashmap。在Java的每個RemotingCammand中,其實都帶有一個CommandCustomHeader的屬性成員,可以認爲他是一個強類型的extFields,再最後傳輸的時候,這個CommandCustomHeader會被忽略,而傳輸前會把其中的所有字段全部都原封不動塞到extFields中,以作傳輸。

  • remark:附帶的文本信息。常見的如存放一些broker/nameserver返回的一些異常信息,方便開發人員定位問題。

  • flag: 按位(bit)解釋。

    第0位標識是這次通信是request還是response,0標識request, 1 標識response。第1位標識是否是oneway請求,1標識oneway。應答方在處理oneway請求的時候,不會做出響應,請求方也無序等待應答方響應。

三、我們項目的協議設計

1. 從dubbo與rocketmq看設計協議

兩個協議的差別還是比較在的,再對比一下dubbo的字段:

magic:類似java字節碼文件裏的魔數,用來判斷是不是dubbo協議的數據包。魔數是常量0xdabb,用於判斷報文的開始。
flag:標誌位, 一共8個地址位。低四位用來表示消息體數據用的序列化工具的類型(默認hessian),高四位中,第一位爲1表示是request請求,第二位爲1表示雙向傳輸(即有返回response),第三位爲1表示是心跳ping事件。
status:狀態位, 設置請求響應狀態,dubbo定義了一些響應的類型。具體類型見
  com.alibaba.dubbo.remoting.exchange.Response
invoke id:消息id, long 類型。每一個請求的唯一識別id(由於採用異步通訊的方式,用來把請求request和返回的response對應上)
body length:消息體 body 長度, int 類型,即記錄Body Content有多少個字節。

同時考慮一下http這樣的最常見協議的設計,請求頭的設計思考:

  • id: 消息的id,異步必須使用。
  • 如果包含心跳,消息類型要區分心跳與業務。rocketmq業務種類是第二層的具體業務裏的區分。
  • 請求還是響應,oneway還是twoway的標識。
  • 序列化類型,如果netty中完全解析了,就不用了。
  • magic,語言,版本適當考慮。
  • 響應時,對應的狀態,比如http的200,500等一般放在頭。
  • 消息長度。一般用netty,還是用它的基於長度的切分吧。頭部是不是定長,要不要使用頭部長度。body長度要不要?

2. 我們的api式消息協議

我們的要求是協議的充分安全,參考api接口的設計,我們每個客戶端都有名有姓的,要進行登陸操作,還要有心跳操作,根據這些業務要求,通用的數據要求,在協議頭中體現。

業務請求就是action,業務參數放在body中,服務端有一個action與業務serive對象的map,收到action調用service。

協議體就設計一個Object,我們把主要的信息都放頭部,下面只介紹頭。

另外對這些參數進行了類似api接口簽名的操作。對body也用密鑰進行了加密。

//協議頭的字段
public static final int VCODE= 0x202;//magic碼
private int versionCode = VCODE;//版本號
private int length;//消息總長度
private String sessionID;//會話ID,一個登錄成功的客戶端,消息之間有sessionId確認。
private byte type;// 消息類型。見後面說明
private String appKey;// 客戶端key
private String sign; //簽名。MiddleMsg中的部分參數進行簽名。類似於api請求中的簽名。
//static String sign(String appSecret, MiddleMsg msg)
private long timestamp;//時間戳
private String action; //請求的業務操作
private String msgId ; //消息id
private int status; //消息狀態
private Map<String, Object> attachment = new HashMap<String, Object>(); // 附加參數
//netty處理的pipeline
new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch)
        throws IOException {
        ch.pipeline().addLast(new MessageDecoder(2000*1024 * 1024, 4, 4));//基於長度的。
        ch.pipeline().addLast(new MessageEncoder());
        //ch.pipeline().addLast("readTimeoutHandler",new ReadTimeoutHandler(120));
        ch.pipeline().addLast(new LoginAuthRespHandler());
        ch.pipeline().addLast("HeartBeatHandler",new HeartBeatRespHandler());
        ch.pipeline().addLast("handler", new ServiceHandler());
    }
//type說明:
CLIENT_REQ((byte) 0),  //客戶端請求 request
SERVICE_RESP((byte) 1), // response
ONE_WAY((byte) 2),  // 單線路
LOGIN_REQ((byte) 3), // 登錄請求
LOGIN_RESP((byte) 4), // 登錄響應
HEARTBEAT_REQ((byte) 5), // 心跳類型
HEARTBEAT_RESP((byte) 6), // 心跳響應
SERVICE_PUSH((byte) 7), //服務端推送
CLIENT_RESP_FOR_SERVICE_PUSH((byte) 8); //客戶端的服務器回調

3. 密鑰的交換

在LoginAuthRespHandler中,

一部分是基礎校驗:先檢測IP,再檢測用戶名appKey。

上面通過了,如果是登錄請求,再檢測消息簽名。正常登錄成功後,產生一個sessionId,同時對應產生一個密鑰管理類,初始爲配置,裏面同時產生下一步的密鑰。下一次密鑰作爲返回消息體返回。

客戶端定時心跳,每次心跳得到下一次要用的密鑰,存起來。但當前的業務消息,始終是查當前的密鑰用。

四、協議設計的思考

4.1 總體分析

遠程傳輸的數據,有安全的要求,有請求/響應的區分,還有業務數據,通常用netty時,如何決定通訊協議呢?

rocketmq:在decode/encode之前有一個HandshakeHandler,這屬於在協議之外的處理,decode/encode之內的纔是協議中的內容。消息的特點是更注重業務的處理,所以很多業務的數據都放在了協議頭。再者終端用戶的數據也可能複雜,所以不適合把rocketmq自己的業務數據放在body中混合在一起了。body純放置終端用戶的消息隊列中的消息。netty消息handler中,只按head就把數據扔給業務了,不方便統一解析,每個業務自己解析body了。

dubbo:所有處理都在decode/encode之中,它由於兼容多種傳輸工具,傳輸層部分數據與業務rpc層rpcInvocation的數據嚴格分開,所以body中就是純rpc層數據了。由於支持配置的序列化協議,必須要在頭中。傳輸的響應碼和響應文本放頭部,這樣傳輸層不要看body數據了。rpc層的請求與響應都放body中。一個嵌套的handler分別處理傳輸與rpc的數據。

我們的協議:考慮對安全重視,所以要在頭部解析出安全處理要求的數據,專門設計了一個pipeline中的handler處理安全,成功的才fire到後面處理。業務數據就肯定不安排在head中了,都放在body中。只有action是對具體業務處理的確定,放head中。

4.2 結論

功能上首先有個層次劃分。比如看的的傳輸層,業務數據層,終端用戶數據層,安全層。合理的安排到協議的設計中去。

響應頭一般有請求/響應/心跳類型的標識,一般有oneway/twoday的標識,一般異步有id(一般AtomicInteger,不用uuid)的要求。

至於magicCode/語言/協議版本/序列化看情況選擇,序列化一般就定一種吧。

另外總長度要有,用netty提供的基於長度的拆包用,頭長度也有,拆包後要分head/body,分別解析出來。

如果有業務還象我們分安全業務與處理業務,安全業務可以提升到head中,增加相關的字段。pipeline中也在真正業務處理器前,增加一個安全處理器。

有點象應用中servlet中的filter,還有spring中的方法攔截器的設計一樣,通用的東西要單獨分出來。不過分了請求/響應/心跳類型,分了安全層,還有業務層,等於三個層,但協議只有head,body兩層。把三層的東西放二層裏,只能前兩層的放head了,否則可以考慮增加一個neck(脖子)。

發佈了38 篇原創文章 · 獲贊 6 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章