zoukankan      html  css  js  c++  java
  • 互联网服务应用协议设计

    互联网服务应用协议设计-借鉴备忘

    0 我们为什么需要自己设计协议:

    在互联网后台开发中,稍微复杂一些的业务,服务是必要的,进而协议也是必要的。那么我们是否可以复用已有的协议呢?主要是因为现在已有的协议都没有能完全match互联网后台开发的需求,存在这样或那样的问题。

    1 协议设计的原则:

    解析效率:互联网业务具有高并发的特点,解析效率决定了使用协议的CPU成本;

    编码长度:信息编码出来的长度,编码长度决定了使用协议的网络带宽及存储成本;

    易于实现:互联网业务需要一个轻量级的协议,而不是大而全的,CORBA这种重量级的协议就不太适合,易于实现决定了使用协议的开发成本和学习成本;

    可读性:编码后的数据的可读性决定了使用协议的调试及维护成本;

    兼容性:互联网的需求具有灵活多变的特点,协议会经常升级,使用协议的双方是否可以独立升级协议、增减协议中的字段是非常重要的。兼容性决定了持续开发时的开发成本;

    2 协议设计需要解决的问题:

    1) 序列化/反序列化

    2) 判断包的完整性

     2.1 序列化/反序列化:

    序列化我们常称之为编码,或者打包,反序列化常称之为解码,或者解包。常用的序列化/反序列化方式主要有以下几种:

    1)  TLV编码及其变体(后面统称为TLV编码):Protobuf/thrift/ASN BER都属于这种。

    TLV编码基本原理是每个字段打一个二进制包,每个包包含tag、length、value 3个部分:

    tag: 一般占用1个字节,表示数据类型,有的编码方式(Protobuf/thrift)中tag包含字段的id,有的编码方式(ASN BER)不包含字段的id。包含字段id的序列化方式,id是字段的标志,协议可以灵活的增删字段,只要保证字段id唯一,就能兼容解析,非常适合互联网开发。

    length:一个整数,表示后面数据块的长度,Protobuf/thrift的序列化不包含length字段,因为大部分数据类型的长度都可以根据tag中的类型信息可以得到。

    value:真正的数据内容。

    举个tag包含id的序列化方式打包解包的例子(只是举个例子说明原理,实际上Protobuf等协议都做了比较巧妙的实现,比如varint、ZigZag编码来尽量减少编码长度):

    协议包括2个字段, name字段的id为0,类型为1(string);age字段的id为1,类型为2(unsigned int     )

    字段id

    字段类型

    字段名

    0

    string

    name

    1

    unsigned int

    age

    需要传输的数据:

    name = "xxx"

    age = 18

    序列化之后大约是

    字段类型(tag的一部分)

     字段id(tag的一部分)

    字段值(value)

    0x01

    0x00

    xxx

    0x02

    0x01

    0x12

    反序列化的时候,逐步解析字节流,先解析字段类型和字段id,再根据字段类型解析出后面的数据内容,得到了一个id和值的映射关系

    0 : "xxx"

    1 : 18

    根据协议,id=0的字段表示name,id=1的字段表示age,反序列化之后,就知道传过来的数据是

    name = "xxx",age = 18了

    如果协议做了升级,增加了1个字段“gender”,删除一个已经没有意义的字段age,协议变成

    0 string name

    2 string gender

    需要传输的数据:

    name = "xxx"

    gender = "male"

    发送方升级了协议,序列化之后大约是

    字段类型

    字段id

    字段值

    0x01

    0x00

    xxx

    0x01

    0x02

    male

    反序列化之后,得到了一个id和值的映射关系

    0 : "xxx"

    2 : "male"

    反序列化的一方由于没有升级协议,不知道id=2的字段什么意思,直接忽略,没找到id=1的age字段,那么使用默认值,这样单方的升级,完全不影响协议的解析,协议是具有兼容性的。

    举个tag不包含id的序列化方式打包解包的例子

    如果tag中没有字段id,那么字段所在的位置决定字段的含义

    协议包括2个字段, 第1个字段name,类型为1(string);第2个字段age类型为2(unsigned int )

    字段类型

    字段名

    string

    name

    unsigned int

    age

    需要传输的数据:

    name = "xxx"

    age = 18

    序列化之后大约是

    字段类型

    字段值

    0x01

    xxx

    0x02

    0x12

    反序列化程序解析出第1个字段是字符串xxx,第二个字段是整数18,根据协议,第1个字段是name,第2个字段是age,这时反序列化程序就知道了name是xxx,age是18

    但是相比上面有id的序列化方式,这种方式有个明显的缺陷:一方升级了协议时,另一方很可能需要升级协议才行,协议不具有兼容性。比如协议做了升级,增加了一个字段gender,删除一个已经没有意义的字段age,协议变成

    string name

    string gender

    需要传输的数据:

    name = "xxx"

    gender = "male"

    发送方升级了协议,序列化之后大约是

    字段类型

    字段值

    0x01

    xxx

    0x01

    male

    这时接收方如果不升级协议就完全无法理解协议的含义

    可以看出tag包含ID的序列化方式(Protobuf/thrift)兼容性和灵活性方面优于不包含ID的方式(asn-ber)

    TLV编码的特点是:

    解析效率高:主要是因为不需要转义字符

    编码长度低:主要是因为元数据占用的空间很少

    不易于实现:但是有很多开源的工具,根据IDL自动生成代码,提高开发效率

    兼容性高:协议双方可以独立升级   

    可读性差:二进制协议,肉眼很难识别

    2) 文本流编码:xml/json都属于这种。

    基本原理是把每个字段打一个字符串形式的包,通过键值对(key-value)的方式存储数据,key是字段的名字,用于区分不同的字段(对比上面TLV编码采用id的方式标志一个字段),特殊字符特别是非文本字符需要做适当转义,转义为xml/json的合法字符。xml的解析效率低于json,而编码长度高于json,json作为序列化的方式一般是优于xml的。

    同样是上面的协议:

    序列化的结果大概是

    <p><name>xxx</name><age>18</age></p>

    或者

    {name:xxx,age:18}

      文本流编码的特点是:解析效率低,编码长度高,易于实现,可扩展性高,可读性好

     

    3) 固定结构编码:

    基本原理是,协议约定了传输字段类型和字段含义,和TLV的方式类似,但是没有了tag和len,只有value

    同样是上面的协议:

    序列化的结果大概是

    xxx 0x00 0x12

    反序列化的时候,根据协议中约定的字段位置、字段类型和字段含义,逐个解出相应的字段

    固定结构编码如果协议升级了又需要保证兼容性,那么可以在协议中增加一个“版本号”字段,然后根据版本号决定如何序列化和反序列化,这样可以保证协议的兼容性。但是这样会导致代码非常混乱和让人费解

    固定结构编码解析效率、编码长度、易于实现、可读性方面略微优于TLV方式,但是灵活性和兼容性非常差,如果不使用版本号判断就不能单方增删字段,不能单方修改字段数据类型,甚至,把协议中的short int字段改成int,反序列化就可能会出错,因此除了业务逻辑非常固定的场景外不推荐使用。

    4) 内存dump:

    基本原理是,把内存中的数据直接输出,不做任何序列化操作。反序列化的时候,直接还原内存。

    一般我们声明c++的结构如下即可

           #pragma pack (1)
    
           struct
    
           {
    
                  char name[64];
    
                  unsigned int age;
    
           };
    
           #pragma pack ()      

    这种方式适合c/c++语言,单机进程间交换数据。这是一种简单高效的协议,特别适合通过共享内存交换数据的场景。但是不具有通用性,不适合跨越语言和机器,本文不再讨论这种编码方式

    如果没有特别的必要,自己发明一种序列化方式一般是费力不讨好的,有重复造轮子的嫌疑,所以我们在成熟序列化方式中选择一种即可。

    综上,我们可以看出,如果我们想设计一个具有通用性,可以用于分布式环境,适合互联网后台开发,能传递复杂数据,具有很好的灵活性和兼容性的协议,常用的序列化方式是TLV编码和字符流编码2种。那么根据不重复造轮子的原则,可选的编码方式就只有Protobuf、thrift 和 json 3种了。我们对比一下这3种编码方式。

    序列化方式对比

    Protobuf/thrift VS json

    根据google的测评结果,Protobuf/thrift 效率高于 json, 而可读性弱于json。解析效率大概比json高1倍。这个具体的倍数关系我没测试过,存疑,而且不同的程序使用的json库不一样,还是应该以实测结果为准。

    参考http://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking

    Protobuf VS thrift

    Protobuf 效率和编码长度略有优势,文档比thrift丰富

    thrift 内建的数据类型更多(有map和set)

    thrift官方比Protobuf支持更多的编程语言,并有RPC框架,但是Protobuf有很多第三方的支持,同样提供了多种语言的支持和RPC框架的实现

    参考http://code.google.com/p/protobuf/wiki/ThirdPartyAddOns

    参考http://blog.mirthlab.com/2009/06/01/thrift-vs-protocol-bufffers-vs-json/

    个人比较倾向于Protobuf,主要是考虑到文档和第三方支持多,目前使用的更广泛。

    至此,我们就选定了2种序列化方式Protobuf和json,如果并发度非常高,数据量非常大,使用Protobuf,否则使用json.

      2.2 判断包的完整性:   

    一般有两种方法:

    1) 在序列化后的buffer前面增加一个采用固定结构编码的头部,头部长度和结构固定,其中有个字段存储包总长度。收包时,先接收固定字节数的头部,解出这个包完整长度,按此长度接收包体。

    2) 在序列化后的buffer前面增加一个字符流的头部,其中有个字段存储包总长度,根据特殊字符(比如根据 或者)判断头部的完整性。这样通常比1要麻烦一些,http、memcached和radis采用的是这种方式。收包的时候,先判断已收到的数据中是否包含结束符,收到结束符后解析包头,解出这个包完整长度,按此长度接收包体。      

    至此,我们已经得到了一个协议框架,采用这个协议框架,再根据业务需要约定字段含义,就可以得到一个具体的协议,可以用于把一个机器上的消息,发送到另一个机器,并让对方完全理解消息的含义。但是如果这就是这个协议框架的全部,那这个协议就太弱了,因为如果一个程序只知道协议框架而不知道协议的字段内容,那它除了可以收包和发包外,做不了任何事情,而在客户端和服务之间搭建一个代理层,来做容灾、监控、统计、路由、认证等等事情是一种常见的架构模式,这样这些公共的处理逻辑就不用每个服务都做一次了,服务可以专注于业务,而把这些逻辑交由代理层来做。换句话说,我们需要为协议框架增加一个头部,并约定一些所有业务都可以使用的公共字段。

    3 协议头部:

    那么头部中可以增加哪些字段呢?这个取决于你希望代理帮你做哪些事情。通常以下字段是可以考虑的:

    seq                     //消息序列号,可以用于排查问题,也可以用于某些IO模型中包的解析

    protocol version       //协议版本号,可以用于协议的兼容

    request useragent    //请求者机器环境,包括操作系统、客户端版本等等信息

    request user ip        //请求者ip

    request user id        //请求者id

    client ip               //客户端ip

    client id               //客户端业务id

    server ip                     //服务ip

    server id                     //服务id

    server server cmd     //服务命令字

    retcode                     //返回码

    有了这些字段,代理层就能完全监控到服务的访问情况,并生成报表

    4  自己设计协议:

    有了上面的理论,我们就可以真正的设计协议了。下面设计的这个协议可以应用于互联网后台服务的绝大部分场景,协议中把一个包分为3个部分:

    包头的第1部分:固定8字节:协议标志(2字节) 包头长度(2字节)  包体长度(4字节)

    包头的第2部分:这部分主要是前面第4点提到的公共头部,包括seq等字段,采用Protobuf序列化,包头的字段是可以增删的,即使没有任何字段,也不影响数据传递,但是可能影响你的代理做的工作;

    包体:采用Protobuf序列化,具体内容取决于业务。

    5  一些常用的协议:

    http协议:http协议是我们最常见的协议,我们是否可以采用http协议作为互联网后台的协议呢?这个一般是不适当的,主要是考虑到以下2个原因:

    1) http协议只是一个框架,没有指定包体的序列化方式,所以还需要配合其他序列化的方式使用才能传递业务逻辑数据。

    2) http协议解析效率低,而且比较复杂(不知道有没有人觉得http协议简单,其实不是http协议简单,而是http大家比较熟悉而已)

    有些情况下是可以使用http协议的:

    1) 对公网用户api,http协议的穿透性最好,所以最适合;

    2) 效率要求没那么高的场景;

    3) 希望提供更多人熟悉的接口,比如新浪微、腾讯博提供的开放接口,就是http的;

    memcache的协议

    基本原理是:先发送字符流,以 作为结束标志,字符流中不允许存在特殊字符。

    再发送一个数据包,可以包含任何字符,数据包的长度已经在前面的字符流中指定。

    memcache的协议并没有包含业务数据序列化和反序列化的部分,只有包头和一个buffer,是一种适合于业务逻辑简单场景下的协议。参考:http://www.ccvita.com/306.html

    redis协议:

    基本原理是:先发送一个字符串表示参数个数,然后再逐个发送参数,每个参数发送的时候,先发送一个字符串表示参数的数据长度,再发送参数的内容。

    redis的协议和memcache类似,但是memcached只能带一个二进制字段,redis可以带多个

    参考:http://www.redisdoc.com/en/latest/topic/protocol.html

  • 相关阅读:
    java+opencv实现图像灰度化
    java实现高斯平滑
    hdu 3415 单调队列
    POJ 3368 Frequent values 线段树区间合并
    UVA 11795 Mega Man's Mission 状态DP
    UVA 11552 Fewest Flops DP
    UVA 10534 Wavio Sequence DP LIS
    UVA 1424 uvalive 4256 Salesmen 简单DP
    UVA 1099 uvalive 4794 Sharing Chocolate 状态DP
    UVA 1169uvalive 3983 Robotruck 单调队列优化DP
  • 原文地址:https://www.cnblogs.com/claresun/p/4844138.html
Copyright © 2011-2022 走看看