本章不会直接分析Netty源码,而是通过使用Netty的能力实现一个自定义协议的服务器和客户端。通过这样的实践,可以更深刻地理解Netty的相关代码,同时可以了解,在设计实现自定义协议的过程中需要解决的一些关键问题。

  本周章涉及到的代码可以从github上下载: https://github.com/brandonlyg/tinytransport.git

设计协议

  本章要设计的协议是基于TCP的应用层协议。在设计一个协议之前需要先回答以下几个问题:

  • 使用场景是什么?
  • 这个协议有哪些功能?
  • 性能上有什么要求?
  • 对网络带宽有什么要求?
  • 安全上有哪些要求?  

  接下来依次回答这些问题:

  

  使用场景

  在可信任的内部网络中,不同进程之间高速交换消息。

  功能

  • 在客户端和服务器进行消息交换。
  • 发送消息然后异步接收响应。
  • 客户端和服务器之间可以保持长连接。
  • 传输大量的数据。

  性能

  数据包的提取性能接近内存copy。

  

  扩展性

  可以通过扩展header字段,进而扩展协议的功能。

  带宽

  尽量少的冗余数据,占用尽量小的带宽。

  

  安全

  由于是在可信任的内网中交互消息,没有特别端安全性要求。

  这些问题的答案,就是整个协议的设计要求。下面就按照这些设计要求来设计一套完整的协议,具体类容包括以下两个部分:

  • 数据包的格式。
  • 客户端和服务器端消息的交互规则。

数据包格式的设计

  设计自己的数据包格式之前,我们先来回顾以下LengthFieldBasedFrameDecoder能够处理的数据包格式:

  | header | contentLength | conent |

  这个类把header的设计留给了子类,现在我们的注意力只需要集中在header字段上即可。下面是header设计:

  | begin | version | cmd | contentType | compression | sequenceId | resCode |

  整个数据包的格式就是:

  | begin | version | cmd | contentType | compression | sequenceId | resCode | contentLength | content |

  现在来看一下这个数据包能实现哪些设计要求。

  begin

  类型: 32位无符号整数(uint32),这字段是一个常量,用来准确第定位到数据包的开始位置,这样就能更准确地分离出数据包,进而保证了“客户端和服务器端进行消息交换”。它的设计还要平衡数据包提取性能和准确性。严格来说,数据包中只能有一个begin,形式化描述如下:

  1. 设一个数据包P的长度是L,P(i)表示数据包中任意一个Byte,begin=0XADEF4BC9(这个值可以任意选择,尽量不选择有意义的数字)。

  2. 设反序列化一个uint32的算法是ui=deserUint32(i), i>=0 && i < L。

  3. 必须满足: deserUint32(0) == begin, 且deserUint32(i) != begin, i > 0 && i < L。

  要在(1)(2)两个前提条件下满足第(3)点,需要设计一个转义符EC=0xFF, 对P中除begin以外的部分进行转义,转义算法是:

  如果deserUint32(i)==begin或P(i)==EC,  在P(i)前面插入EC。

  找到begin的算法是:

  如果deserUint32(i)==begin且P(i-1)!=EC。

  逆转义算法是:

  如果P(i)==EC, P(i+1)==EC或deserUint32(i+1)==begin,  删除P(i)。

  以上使用转义符的方案,虽然能够准确地找到begin,但算法复杂度是O(L),显然不能满足“接近内存copy"这个要求。但是如果不使用转义符,就可以达到这个性能要求。如果仔细计算一下begin重复的概率就会发现, 它的重复概率只有1/0x100000000,如果再结合length字段一起检查数据包的正确性,得到错误数据包的概率就会更低。不使用转义符,以极小的出错概率换取性能大幅提升是一笔合适的买卖。

  总的来说,begin可以满足两个设计要求: 消息交换,数据包的提取性能接近内存copy。

  

  version

  类型:uint8。协议的版本号,这个字段用来满足“扩展性”要求。每个version对应一种不同的header结构,换言之,知道了版本号,就知道怎样解析header。 

  cmd

  类型: uint8。这个字段用来定义不同数据包的功能。可以使用这个字段定义心跳数据包,使用心跳数据包让"服务器和客户端保持长连接"。此外业务层可使用这个字段定义自己需要的数据包。

  contentType

  类型: uint8。这个字段是content的类型。使用这个字段可以在content数据交给业务层之前,对他进行一下特殊的处理。用户可以定义自己的的消息类型。它可以加"消息交换"的能力。

  

  compression

  类型: uint8。 压缩算法。这个字段可以用来表示content使用的压缩算法。通过使用适当的压缩算法,压缩满足"传输大量数据"和"带宽"的要求。

  

  sequenceId

  类型: uint32。这个字段是数据包的唯一序列号。只需要保证在一个socket连接建立-断开周期内保证它的唯一性即可。使用这个ID,可以实现“发送消息然后异步接收响应”。

  

  resCode

  类型: uint8。响应数据包的状态码,用来在响应数据包中附带异常信息。  

  至此数据包的格式已经设计完毕。接下来设计必要的交互规则。

协议交互规则设计

  使用心跳保持长连接

  cmd: PING(0x01), PONG(0x02)。客户端连接到服务器之后,每隔一段时间发送一个PING包,服务器端收到之后立即响应PONG包。服务器端在一个超时时间后没有收到PING就认为TCP连接不可用,主动端开。客户端在发送PING之后,经过一个超时时间后没有收到PONG就认为连接不可用,重新建立连接。

 

  消息的请求和响应

  cmd: REQUEST(0x10), RESPONSE(0x02)。客户端使用REQUEST包向服务器发送请求,服务使用RESPONSE包响应。请求和响应的sequenceId一致。

  

  推送消息

  cmd: PUSH(0x20)。使用PUSH向对方推送消息,不需要响应。

代码分析

  这个轻量级的客户端和服务器框架在架构上分为4个部分:

  • 数据包: Frame, FrameDecoder, FrameEncoder, FrameGzipCodec。
  • 消息: FMessage, FrameToMessageDecoder, MessageToFrameEncode, FMessageHandler, FMessageTrait, FMTraits。
  • 客户端框架: TcpConnector, TcpClient。
  • 服务器端框架: TcpServer。

  由于前面已经详细讲解了设计原理,这里只重点分析一下关键代码。

  Frame

  Frame是数据包类型,它的主要功能是数据包的序列化(encode方法)和反序列化(decode)。

  序列化方法:

 /**
* 把Frame对象编码成数据包
* @param out
*/
public void encode(ByteBuf out){
out.writeInt(BEGIN);
out.writeByte(header.getVersion());
out.writeByte(header.getCmd().getValue());
out.writeByte(header.getContentType());
out.writeByte(header.getCompression());
out.writeInt(header.getSequenceId());
out.writeByte(header.getResCode()); int contentLength = 0;
if(null != content){
contentLength = content.readableBytes();
}
if(contentLength > MAX_CONTENT_LENGTH){
throw new TooLongFrameException("content too long. contentLength:"+contentLength);
}
out.writeShort(contentLength);
if(null != content){
out.writeBytes(content);
}
}

  6-12行,序列化header中除contentLength的其他字段。

  14-21行,序列化contentLength字段。

  22-24行,序列content。

  反序列化方法

 /**
* 从数据包解码得到Frame
* @param in 一个完整的数据包
* @return Frame对象
*/
public static Frame decode(ByteBuf in){
if(in.readableBytes() < HEADER_LENGTH){
throw new CorruptedFrameException("pack length less than header length("+HEADER_LENGTH+")");
} //得到header
Header header = new Header();
in.readInt();
header.setVersion(in.readByte());
header.setCmd(Command.valueOf(in.readByte() & 0xFF));
header.setContentType((byte)(in.readByte() & 0xFF));
header.setCompression((byte)(in.readByte() & 0xFF));
header.setSequenceId(in.readInt());
header.setResCode((byte)(in.readByte() & 0xFF)); //读出content
int contentLength = in.readShort() & 0xFFFF;
if(in.readableBytes() != contentLength){
throw new CorruptedFrameException("content is not match."+in.readableBytes() + "-" + contentLength);
} ByteBuf content = contentLength > 0 ? in.retainedSlice(in.readerIndex(), contentLength) : null;
in.skipBytes(contentLength); //创建Frame对象
Frame frame = new Frame();
frame.setHeader(header);
frame.setContent(content); if(null != content) content.release(); return frame;
}

  这段代码,注释已经比较清晰了,这里就不再多说。

  FrameDecoder

   这个类继承了LengthFieldBasedFrameDecoder,所以只需要很少的代码就可以从Byte流中分离出数据包。

     public FrameDecoder(){
super(Frame.MAX_LENGTH, Frame.HEADER_LENGTH - 2, 2);
} @Override
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
//找到begin位置
int start = in.readerIndex();
int begin = in.getInt(start + 0);
if(begin != Frame.BEGIN){
dropFailedData(in);
} //解码得到Frame对象
ByteBuf dataPack = null;
try{
dataPack = (ByteBuf)super.decode(ctx, in);
Frame frame = Frame.decode(dataPack);
return frame;
}finally {
if(null != dataPack){
dataPack.release();
}
}
}

  2行,设置了数据包的最大长度Frame.MAX_LENGTH, 数据包header除contentLength之外的长度Frame.HEADER_LENGTH-2, contentLength字段的长度。这样,只要正确地找到数据包的开始位置就能LengthFieldBasedFrameDecoder就能帮助我们把数据包提取出来。

  8-12行,确定数据包的开始位置。

  17-18行,提取数据包,并把数据包反序列化成Frame。

  FMessageTrait

  为了能够灵活地处理FMessage的content, 框架中定义了FMessageTrait接口,可以使用不同个FMessageTrait实现处理不同的content类型。

 /**
* FMessage消息特征接口,根据不同的contentType进行Frame和FMessage之间的转换
*/
public interface FMessageTrait { /**
* 得到匹配的contentType
* @return contentType的值
*/
int getContentType(); /**
* 把FMessage转换成Frame
* @param fmsg
* @return
* @throws EncoderException
*/
Frame encode(FMessage fmsg) throws EncoderException; /**
* 把Frame转换成FMessage
* @param frame
* @return
* @throws DecoderException
*/
FMessage decode(Frame frame) throws DecoderException;
}

  FrameToMessageDecoder和MessageToFrameEncoder使用FMessageTrait进行FMessage和Frame之间的转换。

 /**
* 把Frame转换成FMessage
*/
@ChannelHandler.Sharable
public class FrameToMessageDecoder extends MessageToMessageDecoder<Frame> { private Map<Integer, FMessageTrait> fmTraits = new HashMap<>(); public void addFMessageTrait(FMessageTrait trait){
fmTraits.put(trait.getContentType(), trait);
} @Override
protected void decode(ChannelHandlerContext ctx, Frame frame, List<Object> out) throws Exception {
int contentType = frame.getHeader().getContentType();
FMessageTrait trait = fmTraits.get(contentType);
if(null == trait){
throw new EncoderException("can't find trait. contentType:"+contentType);
} FMessage fmsg = trait.decode(frame);
out.add(fmsg);
}
}

  10-12行,把FMessageTrait放入map中。构建contentType-FMessageTrait之间的映射。

  17行,从map中得到FMessageTrait。

  22行,使用FMessageTrait把Frame转换成FMessage。

  MessageToFrameEncoder的实现类似。不同的是在22处调用FMessageTrait的encode方法把FMessage转换成Frame。

  FMTraits中给出了几种常见的FMessageTrait实现:

  • FMTraitBytes:  处理byte array类型的content。
  • FMTraitString: 处理String类型的content。
  • FMTraitJson: 处理Json格式是content。
  • FMTraitProtobuf: 处理protobuf格式的content。

  他们都有一个共同的祖先AbstractFMTrait, 这个抽象类实现FMessageTrait的encode和decode方法,定义了两个抽象方法encodeContent和decodeContent,子类只需专注于content的处理就可以了。

  下面以FMTraitBytes为例,讲解一下FMessageTrait的具体实现。FMTraitBytes处理的FMessage类型要求conent是byte[]类型。

     public static final int BYTES = 0x01;
public static final FMessageTrait FMTBytes = new FMTraitBytes();
public static class FMTraitBytes extends AbstractFMTrait {
protected int contentType; public FMTraitBytes(){
this(BYTES);
} public FMTraitBytes(int contentType){
this.contentType = contentType;
} @Override
public int getContentType() {
return contentType;
} @Override
protected ByteBuf encodeContent(FMessage fmsg) throws EncoderException{
byte[] bytes = (byte[])fmsg.getContent(); ByteBuf buf = null;
if(null != bytes && bytes.length > 0){
buf = ByteBufAllocator.DEFAULT.buffer(bytes.length);
buf.writeBytes(bytes);
} return buf;
} @Override
protected Object decodeContent(Frame frame) throws DecoderException {
ByteBuf buf = frame.getContent();
byte[] bytes = null;
if(null != buf && buf.readableBytes() > 0){
bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
} return bytes;
}
}

  6-17行,实现了contentType的设置和获取。

  21-29行,把FMessage的content转换成ByteBuf。

  34-42行, 发Frame的content转换成byte[]。

  FMessageHandler

  这是一个专门用来处理FMessage的ChannelInboundHandler。channelRead0方法负责把不同cmd的FMessage派发到专用方法处理,这些方法有:

  • onPing: 收到PING, 会自动响应一个PONG。
  • onPong: 收到PONG。
  • onRequest: 收到REQUEST。
  • onResponse: 收到RESPONSE。
  • onPush: 收到PUSH。

  客户端框架

  TcpConnector功能是发起连接,它的主要功能集中在以下三个方法中。

    public void addFMessageTrait(FMessageTrait trait){
fmEncoder.addFMessageTrait(trait);
fmDecoder.addFMessageTrait(trait);
} public TcpClient connect(InetSocketAddress address) throws Exception{
ChannelFuture future = bootstrap.connect(address);
Channel channel = future.channel(); TcpClient client = new TcpClient(channel, workerElg.next());
channel.attr(TcpClient.CLIENT).set(client); future.sync(); return client;
}  protected void doInitChannel(SocketChannel ch) throws Exception {
ChannelPipeline pl = ch.pipeline(); pl.addLast(H_FRAME_DECODER, new FrameDecoder());
pl.addLast(H_FRAME_ENCODER, frameEncoder); pl.addLast(H_READ_TIMEOUT, new ReadTimeoutHandler(readTimeout, TimeUnit.SECONDS)); pl.addLast(H_FM_DECODER, fmDecoder);
pl.addLast(H_FM_ENCODER, fmEncoder); pl.addLast(H_FM_HANDLER, clientHandler);
}

  addFMessageTrait设置FMessageTrait,开发者可以根据需要定制FMessage的处理能力,FMTraitBytes会默认添加。

  connect用来发起连接,创建TcpClient对象。

  doInitChannel初始化Channel, 开发者可以覆盖这个方法,定制channel的ChannelHandler。

  另外,TcpConnector内部实现了一个FMessageHandler的派生类ClientHandler。这个类的channelActive方法中启动一个定时任务定时发送PING。onResponse方法负责调用TcpClient的onResponse方法。

  TcpClient是客户端连接对象,它主要有两个方法:

  public boolean send(FMessage msg);

  public Promise<FMessage> send(FMessage msg, TimeUnit timeUnit, long timeout);

  第一个不处理响应。第二个可以异步数量响应。

  另外还有一个给TcpConnector使用的onResponse方法,用来触发第二个send返回Promise对象的回调。

  服务器端框架

  TcpServer是服务器端框架,它比较简单。开发者只需要覆盖doInitChannel,添加自己的ChannelHandler,就可以实现服务器端的定制。  

  

  

  

  

netty源码解解析(4.0)-20 ChannelHandler: 自己实现一个自定义协议的服务器和客户端的更多相关文章

  1. netty源码解解析(4.0)-17 ChannelHandler: IdleStateHandler实现

    io.netty.handler.timeout.IdleStateHandler功能是监测Channel上read, write或者这两者的空闲状态.当Channel超过了指定的空闲时间时,这个Ha ...

  2. netty源码解解析(4.0)-18 ChannelHandler: codec--编解码框架

    编解码框架和一些常用的实现位于io.netty.handler.codec包中. 编解码框架包含两部分:Byte流和特定类型数据之间的编解码,也叫序列化和反序列化.不类型数据之间的转换. 下图是编解码 ...

  3. netty源码解解析(4.0)-19 ChannelHandler: codec--常用编解码实现

    数据包编解码过程中主要的工作就是:在编码过程中进行序列化,在解码过程中从Byte流中分离出数据包然后反序列化.在MessageToByteEncoder中,已经解决了序列化之后的问题,ByteToMe ...

  4. netty源码解解析(4.0)-16 ChannelHandler概览

    本章开始分析ChannelHandler实现代码.ChannelHandler是netty为开发者提供的实现定制业务的主要接口,开发者在使用netty时,最主要的工作就是实现自己的ChannelHan ...

  5. netty源码解解析(4.0)-11 Channel NIO实现-概览

      结构设计 Channel的NIO实现位于io.netty.channel.nio包和io.netty.channel.socket.nio包中,其中io.netty.channel.nio是抽象实 ...

  6. netty源码解解析(4.0)-10 ChannelPipleline的默认实现--事件传递及处理

    事件触发.传递.处理是DefaultChannelPipleline实现的另一个核心能力.在前面在章节中粗略地讲过了事件的处理流程,本章将会详细地分析其中的所有关键细节.这些关键点包括: 事件触发接口 ...

  7. netty源码解解析(4.0)-4 线程模型-概览

    netty线程体系概览 netty的高并发能力很大程度上由它的线程模型决定的,netty定义了两种类型的线程: I/O线程: EventLoop, EventLoopGroup.一个EventLoop ...

  8. netty源码解解析(4.0)-15 Channel NIO实现:写数据

    写数据是NIO Channel实现的另一个比较复杂的功能.每一个channel都有一个outboundBuffer,这是一个输出缓冲区.当调用channel的write方法写数据时,这个数据被一系列C ...

  9. netty源码解解析(4.0)-2 Chanel的接口设计

    全名: io.netty.channel.Channel Channel内部定义了一个Unsafe类型,Channel定义了对外提供的方法,Unsafe定义了具体实现.我把Channel定义的的方法分 ...

随机推荐

  1. 牛客第三场 J LRU management

    起初看到这道题的时候,草草就放过去了,开了另一道题,结果开题不顺利,总是感觉差一点就可以做出来,以至于一直到最后都没能看这道题qaq 题意:类似于操作系统上讲的LRU算法,有两个操作,0操作代表访问其 ...

  2. 单调栈&单调队列

    最近打了三场比赛疯狂碰到单调栈和单调队列的题目,第一,二两场每场各一个单调栈,第三场就碰到单调队列了.于是乎就查各种博客,找单调栈,单调队列的模板题去做,搞着搞着发现其实这两个其实是一回事,只不过利用 ...

  3. T-SQL 小全

    --====================================================== ----数据库概念:创建.删除.使用数据库 ----================= ...

  4. 分布式ID系列(2)——UUID适合做分布式ID吗

    UUID的生成策略: UUID的方式能生成一串唯一随机32位长度数据,它是无序的一串数据,按照开放软件基金会(OSF)制定的标准计算,UUID的生成用到了以太网卡地址.纳秒级时间.芯片ID码和许多可能 ...

  5. 【原创】原来你竟然是这样的Chrome?!Firefox笑而不语

    书接上文 上一篇文章<[原创]用事实说话,Firefox 的性能是 Chrome 的 2 倍,Edge 的 4 倍,IE11 的 6 倍!>,我们对比了不同浏览器下FineUIPro一个页 ...

  6. Of efficiency and methodology

    There are only too many articles and books which pertains to the discussion of efficiency and method ...

  7. American daily English notes (enlarged edition): A review

    Life English is the most pragmatic kind of English when one wants to associate with foreigner friend ...

  8. Linux curl 表单登录或提交与cookie使用

    本文主要讲解通过curl 实现表单提交登录.单独的表单提交与表单登录都差不多,因此就不单独说了. 说明:针对curl表单提交实现登录,不是所有网站都适用,原因是有些网站后台做了限制或有其他校验.我们不 ...

  9. python+unittest框架第四天unittest之断言(一)

    unittest中的测试断言分两天总结,hhh其实内容不多,就是懒~ 断言的作用是什么?  答:设置测试断言以后,能帮助我们判断测试用例执行结果. 我们先看下unittest支持的断言有哪些: 对上面 ...

  10. vim基础命令,查找和替换

    vim 基本命令查找和替换 vim简单的命令用着还好.比如插入,删除,查询.但替换就用的比较少.所以,还是需要用的时候拿出来对照者看. 使用vim编辑文件: vim xxx 进入之后的界面叫做命令模式 ...