分层设计
以Apache Thrift这个轻量级远程调用框架为例
自顶向下的各个分层
-
编解码层(紫+绿)
编解码层作用就是客户端和服务端通过一个IDL文件生成不同语言
其中IDL数据格式有以下三种
-
特定语言的格式:
这种编码方便,可以用很少的额外代码实现内存对象的保存与恢复。但是与特定语言深度绑定,其他语言很难读取。所以存储或传输数据就和这个语言绑定了,易引发安全和兼容性问题。 -
文本格式:
这种具有人类可读性,但是数字的编码有很多歧义之处。如:XML和CSV不能区分数字和字符串;JSON虽能区分,但是不区分整数和浮点数,而且不能指定精度。在处理大量数据时没有强制模型约束,实际上只能采用文档方式约定,从而带来调试不便,而且JSON的序列化和反序列化采用反射机制导致性能较差。
-
二进制编码:
具备跨语言和高性能优点,常见有Thrift框架的BinaryProtocol,Protobuf...(其中有TLV编码或Varint编码实现)
-
BinaryProtocol用的就是TLV编码,左下角为其IDL文件。
TLV编码结构简单清晰,扩展性好,但是由于增加了Type和Length两个冗余信息,有额外的内存开销,特别是在大部分字段都是基本类型的情况下有较大的空间浪费得。
该怎么选择
-
兼容性
- 由于系统更新周期快,需要支持自动增加新的字段,而且不影响老的服务,这将提高系统的灵活性
-
通用性
-
技术层面
序列化协议是否支持跨平台、跨语言
-
流行程度
序列化和反序列化需要多方参与,很少人用的协议意味着学习成本,而且流行性低的协议缺乏稳定成熟的跨语言、跨平台的公共包。
-
-
性能
- 空间开销
序列化需在原数据上加描述字段,便于反序列化解析。若序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的的额外空间开销意味着高昂的成本。
- 时间开销
复杂的序列化协议会导致较长的解析时间
-
协议层(浅粉)
协议是双方确定的交流语义
比如:假设一个字符串传输的协议,允许客户端发一个字符串,服务端收到后。首先发一个4字节的消息总长度,然后再发送1字节的字符集charset长度,接下来就是消息的payload,字符集名称和字符串正文。
-
特殊结束符:过于简单,对于一个协议单元必须要全部读入才能够进行处理,除此之外必须要防止用户传输的数据不同结束符相同,否则就会出现紊乱(HTTP 协议头就是以回车(CR)加换行(LD)符号序列结尾。)
-
变长协议:一般都是自定义协议,有 header 和 payload 组成,会以定长加不定长的部分组成,其中定长的部分需要描述不定长的内容长度,使用比较广泛
协议构造
协议解析
-
网络通信层(粉)
套接字编程中的客户端必须知道两个信息: 服务器的 IP 地址,以及端口号
简单流程
socket函数创建一个套接字,bind将一个套接字绑定到一个地址上。
listen监听进来的连接backlog的含义有点复杂,简单描述:
指定挂起的连接队列的长度,当客户端连接的时候,
服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,
内核维护挂起的连接队列,backlog则指定这个队列的长度,
accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。
如果队列未满,客户端调用connect马上成功,
如果满了可能会阻塞等待队列未满(实际上在Linux中测试并不是这样的结果,
这个后面再专门来研究)。Linux的backlog默认是128,
通常情况下,我们也指定为128即可。
connect 客户端向服务器发起连接,accept 接收一个连接请求,
如果没有连接则会一直阻塞直到有连接进来。得到客户端的fd之后,
就可以调用read,write函数和客户端通讯,读写方式和其他I/O类似
read 从fd读数据,socket默认是阻塞模式的,如果对方没有写数据,read会一直阻塞着
write fd写数据,socket默认是阻塞模式的,如果对方没有写数据,write会一直阻塞着
socket 关闭套接字,当另一端socket关闭后,
这一端读写的情况:尝试去读会得到一个EOF,并返回0。
尝试去写会触发一个SIGPIPE信号,并返回-1和errno=EPIPE,
SIGPIPE的默认行为是终止程序,所以通常我们应该忽略这个信号,
避免程序终止。如果这一端不去读写,我们可能没有办法知道对端的socket关闭了尝试
网络库
实际开发时候,用网络库操作
- 提供易用API
- 封装底层Socket API
- 连接管理和事件分发
- 功能
- 协议支持:TCP、UDP、UDS等
- 优雅退出、异常处理
- 性能
- 应用层buffer减少copy
- 高性能定时器、对象池等