序:本文分析了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(脖子)。