7ceea825cdfa3cb45f53911d50dd8800
雕虫晓技(九) Netty与私有协议框架

1.前言

【本文示例源码下载】

在本系列的前一篇,说了 Android 与数据流的斗争,主要是 Android 前端自身处理方案。这一篇则是涉及一些前后端方面的数据传输的问题。

通常来说,Android 和服务端之间的数据传输都会采用标准协议规范,且大多数是基于 HTTP 协议的,例如在Android端最常用的 Retrofit,则是 RESTful 风格的一套网络框架。虽然这是我们最常用的框架之一,但是很多人对该框架了解并不是特别深入,只知道用它可以和服务器进行交互,但是对于它在网络交互中到底处于哪一位置则比较模糊,下面就带大家看一下:

+=================================+
|    协议    | ---> |   对应的工具  |
+=================================+
      |                    |
      ∇                    ∇
+-----------+      +--------------+
|  RESTful  | ---> |   Retrofit   |
+-----------+      +--------------+
      |                    |
      ∇                    ∇
+-----------+      +--------------+
|   HTTP    | ---> |    OkHttp    | 
+-----------+      +--------------+
     |                     |
     ∇                     ∇
+-----------+       +-------------+
|  TCP/IP   | --->  | Socket+Okio |
+-----------+       +-------------+
     |                     |
     ∇                     ∇
+-----------+       +-------------+
| 更底层协议  | --->  |  更底层工具  |
+-----------+       +-------------+

在上面,左侧是对应的一些协议规范,右侧则是对这些协议规范实现的相关工具,当然任何一套规范都有多种实现工具可以用,上面只是在 Android 平台最常用的一套实现方案而已。

相信学过计算机网络相关的同学都知道“OSI网络七层模型”和“TCP/IP五层模型”,我们的网络正是建立在这些模型之上的,而这些模型实际上是一套又一套的规范。 这些规范与语言无关,与平台设备无关,不论你用什么设备,使用什么语言进行开发,只要遵守这一套规范就可以接入现有的网络。正因如此,我们现在的各种设备才可以通过网络进行相互的通信,交流。

2.标准协议与私有协议

上面是一些标准协议,即一种公开的,大家都采用的一种协议,在目前的工作中,我们大部分情况也会采用标准协议,因为标准协议都会有成熟的库可以用,可以快速的进行业务开发,而不用纠结各种底层通信的各种问题。

2.1 私有协议适用场景

凡事都有例外,标准协议固然好,但在某些特定的场景下却不一定合适。

性能限制:我们需要和一些智能设备(物联网设备)直接进行通信,受限于这些设备的性能功耗等问题,无法承载部分标准协议库过大的内存消耗。
实时性要求:又或者我们本身需要传输的数据就很简单,而且需要较高的实时性,而部分标准协议每一次传输都需要携带很多的冗余内容,这明显会降低数据解析速度。
安全性:还有另外一个原因则是为了安全,私有协议固然也可能会被逆向破解,但由于私有协议的保密性,破解起来会更加麻烦,也更耗费时间,如果发现被破解了,更新一下协议规范就可以直接让之前的破解失效。

2.2 私有协议缺陷

当然,私有协议也并非全是好处,不然目前也不可能是标准协议的天下了。首先还是安全性,其次开发速度,当然第三方对接也是大问题。
安全性:私有协议虽然因为规范保密而让其显得“更安全”,但规范一旦被泄露,安全性也就无从谈起了。部分协议设计者觉得协议是私有的,就在安全设计方面稍为欠缺了一点考虑,导致协议规范一旦被泄露,内容也就跟着被泄露了。相比之下,标准协议固然都是使用同一套标准,但是其安全性却更高一筹,对于需要保密的内容,在你知道协议规范的前提下依旧是难以破解的。
开发速度: 私有协议就意味着没有标准库可以使用,需要完全自己进行开发和解析。这样无疑会降低前期的开发速度。而开发速度对于企业来说则意味着大量的人力成本,对于部分企业而言,是不愿意承担这样的成本的。
第三方对接: 现在很少有企业会单独运作,多多少少都会和其他的企业有所业务往来,因此双方的部分系统就需要考虑对接问题,如果双方企业均采用私有协议,无疑还是会加大对接的成本,对企业来说可能并不是一件好事。

3. 私有协议开发

作为一名 Android 程序员,虽然用到私有协议的机会可能比较少,但是也有碰到需要用的时候,既然用到了那也不能虚,毕竟技术学习永无止境。我最近在公司也就遇上的需要用到私有协议的地方,因此也对私有协议了解了一下,学习了一下如何封装私有协议。

封装协议不比调用网络库,自己封装协议需要考虑的东西又很多,例如:数据包格式,数据包的拆包和封包,如何保持长连接,掉线如何自动重连,多个线程之间通信的处理方案,如何与前端隔离,即调用过程透明化。这么说吧,封装一个协议很简单,但是如果想要封装好则比较困难的。下面带大家实现一个自定义协议,并将其封装起来。

这里主要使用到了 Netty 框架和 RxJava 相关技术,有关 Netty 相关的技术个人推荐看 《Netty 实战》这本书,当然自己去搜索网络博客也是可以的,对于 Netty 的基本使用方法,不在本文范围之内。

3.1 私有协议规范(C0DE协议)

由于是简单教程向,协议自然也不能设计的太复杂,下面带大家实现一个我自定义的 C0DE 协议。 是 C0DE,不是 CODE,里面是数字 0。

3.1.1 基本包结构

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC0帧类型确认码内容长度内容,可能没有CRC 校验码0xDE
  • 0xC0:表示一帧的开始
  • 帧类型:表示该帧的功能(不可以与帧头、帧尾重复)
  • 确认码:该帧的唯一标记,用于区分不同的帧,每一帧的确认码都应该不同,服务端给客户的响应,确认码应与客户端发送的确认码相同。
  • 内容长度:内容区域的长度,可能为0
  • 内容:存放的内容,长度不定。
  • CRC校验:使用 CRC-16/XMODEM 标准进行校验,校验范围:帧类型、确认码、内容长度和内容。用于验证内容的完整性。
  • 0xDE:表示一帧的结束。

转义:

为了防止内容区域出现于帧头、帧尾相同的内容,导致无法准确的获取一帧的内容,所以设立了的转义规则,在帧头和帧尾之间遇到特殊字段都需要进行转义。防止出现冲突。

  • 0xC0 -> 0xAD 0x00
  • 0xDE -> 0xAD 0x01
  • 0xAD -> 0xAD 0x02

注意: 该帧结构没有设计加密,是明文格式,在实际运用中需要对内容区域进行加密,加密方案则需要前后端采用统一的标准。

3.1.2 心跳命令

由于网络环境比较复杂,如果客户端和服务端长时间没有联系的话,就会可能被中间的传输设备默认进行断开,如果需要保持长连接的话,就需要发送一些空数据包作为心跳数据,以避免被中间设备断开。

客户端发送帧:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC00x00确认码0CRC 校验码0xDE

服务端响应帧:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC00x00确认码0CRC 校验码0xDE

3.1.3 2233命令

2233 由客户端发送一个 22 命令,服务端收到后回复一个 33,该命令没有内容。

客户端发送帧:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC00x22确认码0CRC 校验码0xDE

服务端响应帧:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC00x33确认码0CRC 校验码0xDE

3.1.2 内容命令

客户端发送一段内容,服务端收到后返回另一段内容,例如:当客户端发送内容为 Fu 时,服务端收到会返回一个 ck.

客户端发送帧:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC00x01确认码内容长度内容CRC 校验码0xDE

服务端响应帧:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0x010x01确认码内容长度内容CRC 校验码0xDE

最终定义了 3 条指令,下面就看如何将这些指令封装起来。

3.2 协议封装

这里我们使用 Netty(4.0.56版本) 来做服务端与客户端,其中客户端与以调用者之间则使用 RxJava(2.1.16版本),使用 intelliJ IEDA 作为开发工具。由于是使用 Java 语言进行开发的,你可以将其直接移植到 Android 项目中,而不用更改代码内容。

你可以在文初或者文末下载到相关的 IntelliJ 工程代码,本文限于篇幅并不会将所有的代码内容都讲解到,如果有疑惑,可以去直接查看源代码。

有人可能会疑惑,Netty 主要是用 java 开发的,那么服务端和 Android 都是用 java 就可以了,但是如果需要在 iOS 端使用怎么办?协议本身就是与平台和语言无关的,iOS 上也有网络交互逻辑,只需要按照协议规范发送数据就可以了,不是必须使用 Netty 框架。当然,由于我本身对 iOS 开发了解有限,因此本文也就没有 iOS 相关的内容了。

既然是要将私有协议封装起来,那么就要有一定的结构,最终设计出来的结构如下:

服务端:

服务端设计的结构比较简单,网络数据流在经过解码器之后转化为 Packet,各个 Packet 通过 Netty 分发到对应的 Handler 进行处理,Handler 处理结束后,将需要发送到内容封装成 Packet 发送,Packet 通过 Encoder 转换为 byte[], 然后通过网络送到客户端。

客户端:

绿色的线条表示发送的数据经过的主要路径。

橙色线条表述服务器返回结果数据经过的主要路径。

可以看出,客户端的设计相对来说要复杂更多,这是因为客户端需要考虑到对图形化界面对支持和调用的透明化(即上层调用者完全无需知道底层的实现方案和逻辑)。除此之外,由于网络的不确定性,接受到的返回结果顺序未必和发送顺序一致,因此就需要对接收到的结果进行甄别,判断是那一次请求的返回结果(Frame里面的确认码就是用于区分返回结果属于哪一请求的)。因此,客户端的逻辑设计就变得更加复杂,不过得益于 Netty 良好的设计,这种复杂程度还是可以接受的。

上层调用者只需关心 API 层都提供了哪些方法可以使用,而 API 调用层只和 Service 有限的接口进行交互,最终和服务器交互的一切细节都被隐藏在 Service 中。

Service 不仅负责两个输入输出队列的基本管理,还需要负责保持与服务端的长连接,以及断线重连机制。尽管需要处理的内容稍微有点多,但是在 Netty 框架强力的支持下,只用了不到 400 行代码就实现了所有的功能。

管理总览先看这么多,下面看一下里面的部分实现细节。

3.2.1 工具类

项目中主要是工具类有两个,一个是 CRC 校验工具,另一个则是 byte 数组和其他数据相互转换的工具。

CRCUtils: CRC 校验存在多个不同的标准,因此服务端与客户端使用标准必须统一,我这里采用了 CRC-16/XMODEM 标准。关于 CRC 校验工具的代码网上随处可见,我这里也只是根据网络代码简单封装了一下,具体可以看项目中。

**ByteUtils: ** 由于通过网络传输的数据都是 byte 数组,所有的数据都绕不开数据转换过程,这里也只是简单的封装了一些常用的转换方法,这些方法都是随处可以查到的,详情依旧见项目中。

3.2.2 基础数据包

由于我们最终发送的任何数据都是 byte[] 我可以直接把需要发送的数据直接按照规范写到 byte[] 然后发送出去,但是呢,这样直接写数据显然是很不直观的,例如:C0 00 00 00 00 01 00 00 00 00 AA 51 DE 就是一个简单的心跳数据包,但是谁能一眼看出来这是个心跳包呢?这样显然是不合适的。因此需要将其封装为数据包,如用 GHeartPacket 表示心跳数据包,需要心跳数据的时候直接创建一个 GHeartPacket 发送,这样会直观很多,而且不容易出错。

在封装之前,先观察一下协议的基本结构:

帧头内容帧尾
1 byte1 byte4 byte4 byte-2 byte1 byte
0xC0帧类型确认码内容长度内容,可能没有CRC 校验码0xDE

首先,帧头和帧尾是固定不变的,内容长度 和 CRC校验码是计算出来的,实际上我们各种数据包主要变化的内容就是 帧类型,确认码和内容而已,因此我们可以将不变的或者可以计算得出的内容抽象出来,作为基础的数据包,而最终的数据包,继承自该基础数据包,并提供变动的内容即可。

基础的数据包提供一个 getFrameBytes() 的抽象方法,该方法最终生成的数据就是通过网络发送的数据,同时提供数据打包,数据转义和反转义功能,最终看起来是这样子:

/**
 * 基础发送数据包
 * 基本的帧结构
 * +----------+----------+--------------------------------------------------------
 * |  大小    |  固定值   |  摘要
 * +----------+----------+--------------------------------------------------------
 * | 1 bytes  | 0xC0     |  帧起始符
 * | 1 bytes  |          |  帧类型
 * | 4 bytes  |          |  确认码
 * | 4 bytes  |          |  内容长度
 * |          |          |  内容
 * | 2 bytes  |          |  校验码 CRC 校验
 * | 1 bytes  | 0xDE     |  帧结束符
 * +----------+----------+--------------------------------------------------------
 * 加密范围:帧类型+确认码+内容长度+内容.
 */
public abstract class Packet {
    //--- 通用数据 ---
    public static final byte HEAD = (byte) 0xC0;    // 帧头
    public static final byte TAIL = (byte) 0xDE;    // 帧尾

    private static final byte REVISE_CODE = (byte) 0xAD;    // 转义码
    private static final byte REVISE_HEAD = (byte) 0x00;    // 头部
    private static final byte REVISE_TAIL = (byte) 0x01;    // 尾部
    private static final byte REVISE_SELF = (byte) 0x02;    // 自身
    //--- 通用数据结束 ---

    private int mCode;  // 响应吗,一帧的唯一标识符号

    public Packet() {
    }

    /**
     * 创建一帧同时设置响应码
     *
     * @param code 响应码
     */
    public Packet(int code) {
        mCode = code;
    }

    /**
     * 获取帧数据(byte[]), 该数据可以直接通过 TCP 协议进行发送.
     *
     * @return 帧数据
     */
    public abstract byte[] getFrameBytes();

    /**
     * 原始数据转换到帧数据,CRC校验,头部和尾部以及相关信息.
     *
     * @param type 帧类型
     * @param code 确认码
     * @param data 原始数据
     * @return 帧数据
     */
    public static byte[] packet(byte type, int code, @Nullable byte[] data) {
        int data_len = 0;
        if (null != data) {
            data_len = data.length;
        }
        int total_len = data_len + 11;                  // 总长度
        ByteBuffer buffer = ByteBuffer.allocate(total_len);   // 分配一个合适大小的区域
        buffer.put(type);                               // 添加帧类型
        buffer.putInt(code);                            // 添加响应码
        buffer.putInt(data_len);                        // 数据长度
        if (null != data) {
            buffer.put(data);                           // 添加数据
        }

        // 获取 CRC 数据
        buffer.flip();                                  // 准备读取数据
        buffer.mark();                                  // mark 指针位置, 防止读取后该部分数据被清除
        byte[] crc_data = new byte[buffer.limit()];     // 分配空间(之前所有的数据都参与 CRC 校验)
        buffer.get(crc_data);                           // 获取数据
        buffer.reset();                                 // 重置指针位置
        buffer.compact();                               // 切换到写状态
        short crc = CRCUtils.getCRC(crc_data);          // 计算 CRC
        buffer.putShort(crc);                           // 添加 CRC

        // 数据转义
        byte[] content = revise((byte[]) buffer.flip().array());

        // 添加头尾
        ByteBuffer frame = ByteBuffer.allocate(content.length + 2);
        frame.put(HEAD);
        frame.put(content);
        frame.put(TAIL);

        return (byte[]) frame.flip().array();          // 返回最终结果
    }

    /**
     * 转义
     *
     * @param raw 原始数据
     * @return 转义后数据
     * 0xC0 -> 0xAD 0x00
     * 0xDE -> 0xAD 0x01
     * 0xAD -> 0xAD 0x02
     */
    public static byte[] revise(byte[] raw) {
        ByteBuffer temp = ByteBuffer.allocate(raw.length * 2);
        for (byte b : raw) {
            if (b == HEAD) {
                temp.put(REVISE_CODE).put(REVISE_HEAD);
            } else if (b == TAIL) {
                temp.put(REVISE_CODE).put(REVISE_TAIL);
            } else if (b == REVISE_CODE) {
                temp.put(REVISE_CODE).put(REVISE_SELF);
            } else {
                temp.put(b);
            }
        }

        int ret_len = temp.position();
        byte[] ret = new byte[ret_len];
        temp.flip();
        temp.get(ret);
        return ret;
    }

    /**
     * 还原,反转义
     *
     * @param raw 转义后的数据
     * @return 原始数据
     * 0xAD 0x00 -> 0xC0
     * 0xAD 0x01 -> 0xDE
     * 0xAD 0x02 -> 0xAD
     * @throws Exception 发现不符合转义要求的数据,抛出异常,表明转义失败
     */
    public static byte[] revert(byte[] raw) throws Exception {
        ByteBuffer temp = ByteBuffer.allocate(raw.length);
        for (int i = 0; i < raw.length; i++) {
            Byte b = raw[i];
            if (b == REVISE_CODE) {
                i++;
                byte type = raw[i]; // 此处发生越界异常
                if (type == REVISE_HEAD) {
                    temp.put(HEAD);
                } else if (type == REVISE_TAIL) {
                    temp.put(TAIL);
                } else if (type == REVISE_SELF) {
                    temp.put(REVISE_CODE);
                } else {
                    throw new RuntimeException("revert error!");
                }
            } else {
                temp.put(b);
            }
        }

        int ret_len = temp.position();
        byte[] ret = new byte[ret_len];
        temp.flip();
        temp.get(ret);
        return ret;
    }


    public int getCode() {
        if (0 == mCode) {
            mCode = CodeUtils.getCode();
        }
        return mCode;
    }

    public void setCode(int code) {
        mCode = code;
    }

    //--- 接收或者发送时间 ---
    private long time = System.currentTimeMillis();

    public void updateTime() {
        time = System.currentTimeMillis();
    }

    public long getTime() {
        return time;
    }
}

通过数据抽象处理后,其他的数据包就比较容易实现了,例如:

心跳数据包:

top Created with Sketch.