本章不会直接分析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. web设计_4_可扩展的行

    不要指定横向页面组件的高度,要让它们能够在纵向自由扩展. 常见的包含文章正文或大段文字的区域,应该适应任何篇幅和大小的文字. 但是例如文章标题.登陆信息栏等也要考虑文字内容数量及高度的变化. 例如:下 ...

  2. 二进制文件安装安装flannel

    二进制文件安装安装flannel overlay网络简介 覆盖网络就是应用层网络,它是面向应用层的,不考虑或很少考虑网络层,物理层的问题. 详细说来,覆盖网络是指建立在另一个网络上的网络.该网络中的结 ...

  3. ByteBuf

    ByteBuf readerIndex ,读索引 writerIndex ,写索引 capacity ,当前容量 maxCapacity ,最大容量,当 writerIndex 写入超过 capaci ...

  4. 个人使用的lilypond第一个模板

    手残非要用lilypond打谱真是…… 可是lilypond又能满足各种细节标记和谱文混排,这是musescore达不到的 所以还是开这个坑,希望能逐渐自己有能力编写自己的音乐教材 个人用Fresco ...

  5. [转载]使用Java操作Mongodb

    HelloWorld程序 学习任何程序的第一步,都是编写HelloWorld程序,我们也不例外,看下如何通过Java编写一个HelloWorld的程序. 首先,要通过Java操作Mongodb,必须先 ...

  6. OSGi Bundle之Hello World

    开发一个简单的Hello World的OSGi Bundle(OSGi绑定包) 在OSGi中,软件是以Bundle的形式发布的.一个Bundle由Java类和其它资源构成,它可为其它的Bundle提供 ...

  7. 精准测试与开源工具Jacoco的覆盖率能力大PK

    导读:本文根据实际使用情况,简要分析了精准测试和类Jacoco等传统白盒工具在设计理念.功能和应用场景的异同点,并阐述了覆盖率技术如何在新型企业开发体系中,发挥应有的重要作用. 覆盖率技术可以说是测试 ...

  8. JavaFx应用 星之小说下载器

    星之小说下载器 说明: 需要jdk环境 目前只支持铅笔小说网,后续添加更多书源,还有安卓版,敬请期待. 喜欢的话,不妨打赏一波! 软件交流QQ群:690380139 断点下载暂未实现,小说下载途中,一 ...

  9. 使用CefSharp在.NET中嵌入Chromium

    使用CefSharp可以在.NET轻松的嵌入Html,不用担心WPF与Winform 控件与它的兼容性问题,CefSharp大部分的代码是C#,它可以在VB或者其他.NET平台语言中来进行使用. 近几 ...

  10. PKI机制总结

    PKI,全称是Public Key Infrastructure,可译为公钥基础设施.它是因特网中节点通信的安全保障机制,HTTPS中的‘S’就来源于PKI. 要去学习一个技术,首先要从它的源头考虑— ...