the site subtitle

我的架构之路(五)-RPC的本质TCP/IP协议与序列化

2021.10.11

回顾上篇,我们说微服务架构实际上就是将原本独立且完整的单体架构拆分成多个业务能力单一且独立的服务,而在一个完整的产品体系中,必须要多个服务协作通信才能完成,那么如何解决服务间的通信,毫无疑问TCP/IP协议。

TCP/IP协议

在讲TCP/IP协议之前,我觉得有必要先说明一下OSI七层模型和TCP/IP四层模型的区别,两者是不一样的。

OSI七层模型

OSI模型(Open System Interconnection Model)是一个由国际标准化组织提出的概念模型,试图提供一个使各种不同的计算机和网络在世界范围内实现互联的标准框架。
OSI分物理层/数据链路层/网络层/传输层/会话层/表示层/应用层,口诀物数网传会表应,如下图(改图来自网络)所示:
image.png

TCP/IP模型

TCP/IP模型分为四层,分别是物理链路层/网络层/传输层/应用层,如下图(改图来自网络)所示:
image.png

OSI七层模型和TCP/IP四层模型的关系

OSI七层网络模型TCP/IP四层概念模型对应网络协议
应用层应用层HTTP、TFTP, FTP, NFS, WAIS、SMTP
表示层应用层Telnet, Rlogin, SNMP, Gopher
会话层应用层SMTP, DNS
传输层传输层TCP, UDP
网络层网络层IP, ICMP, ARP, RARP, AKP, UUCP
数据链路层数据链路层FDDI, Ethernet, Arpanet, PDN, SLIP, PPP
物理层数据链路层IEEE 802.1A, IEEE 802.2到IEEE 802.11

TCP/IP数据传输过程

在TCP/IP四层概念模型里面,应用层可以是我们自己去实现的,也可以直接使用现有协议,例如HTTP协议。
image.png

首先客户端像服务器发送数据先将数据报文在应用层加上应用程协议头,然后到传输层加上TCP或者UDP头,然后到网络层加上IP头(目标服务器的IP),最后到数据链路层加上MAC头(目标服务器MAC地址)

TCP连接

一次正常的请求与响应成为一个TCP连接,而每一个TCP连接都需要有三次握手和四次挥手。
在讲解TCP三次握手和四次挥手之前,先来了解一下TCP的标志位,便于更好的理解后面的内容;TCP在其协议头中使用大量的标志位或者说1位(bit)布尔域来控制连接状态。
标志位

  • SYN:创建一个连接
  • FIN:终结一个连接
  • ACK:确认接收到的数据
  • RST:连接重置
  • PSH:急迫比特
  • URG:紧急指针

前三个比较重要,也很常见,后面三个知道有这么个东西就好。

序列号和确认号
序列号(SequenceNumber,简称seq):源主机发送数据段时对数据的排列顺序,以便于接收方能按顺序接受数据,提高了数据在传输过程中的可靠性(有的数据必须按顺序传送和接受,如语音IP)
确认号(AcknowledgmentNumber,简称ack):目的主机在接受到数据后反馈给源主机的信息,告诉源主机数据已接收

三次握手

为什么需要三次握手,以A同学给B同学打电话举个例子

  1. A同学使用电话拨打B同学的手机,这里可能会出现很多种情况,比如没带手机/没电/没话费了等等,总是就是呼叫不成功,A同学就会不断的尝试。
  2. B同学接到了A同学的电话,并回应了一句“您好”,那这时候也有可能会有其他情况,比如对方刚好干其他事去了或者手机没电了,这个时候B同学并没有收到A同学的回应,无奈之下B同学只好挂断了电话。
  3. A同学发现电话拨通了,然后说了句“你好,B同学,我是A同学”,回复这一句表示A同学已经收到了B同学的接听,下面可以开始聊天了。

从上面的例子可以发现,一次有效的通话需要三次交互方能保证本次通话是正常切有效的,任何一次出现问题都不能保证通话的顺畅。反观TCP协议也是如此,如下图(该图来自网络)所示:
image.png

四次挥手

如下图(该图来自网络)所示:
image.png

四次挥手和三次握手不同,它主要为了解决数据响应的完整性。我们知道一次TCP连接后我们可以不断的像服务器发送数据,直到发送结束,这个时候服务器才开始处理业务,然后才响应,那么一个连接的关系需要有一下四个步骤:

  1. 假设客户端数据已经发送完毕,像服务器发送了一个关闭连接的请求
  2. 服务器端收到了客户端的关闭请求,并响应,表示服务器已经接受了客户端的关闭请求。
  3. 服务器可能会有未发送完的数据,当所有数据都发送完毕后,服务器向客户端发送结束请求。
  4. 客户端收到服务器端发送的接受请求后,向服务器发送确认关闭请求。至此,TCP连接才会真正关闭。

序列化

序列化就是将数据(Java对象/字符串)转换为可传输等二进制数据,而反序列化就是将二进制数据转换为数据(Java对象/字符串)。

什么是二进制?
我们知道,计算机能处理的数据只能是二进制,也就是101010101等这类数据,计算机数据处理的最小单位是字节,一个字节占8位(bit),每一位只能是1或者0表示,也就是说,一个字节能表示的数字只能有256种情况。在Java中,byte的取值范围说-128 ~ 127,也就是256。所以一个byte占用一个字节,也就是8bit。

byte和二进制的关系?
其实byte就是将二进制数据转换为10进制过后的数据,为了方便查看。好,那么是不是说11101010转换为十进制为234?下面来进行分析:
我们知道,byte的取值范围是-128 ~ 127,这个234怎么能存放到byte中?
其实,对于计算机而言,8个bit位中,会将第一位(最高位)作为符号位,最高位为0表示正数,最高位为1表示负数,当为负数时,将后面位的数据取反,也就是1变成0,0变成1,然后+1;好了知道其原理,我们就来计算一下11101010转换为byte的值等于多少:

  1. 11101010 最高位为1,表示负数
  2. 将后7位分别取反,得到0010101
  3. 将取反后的数据加1,得到0010110
  4. 将0010110转换为10进制,得到22
  5. 因为是负数,所以得到-22

上面的例子分别讲述了计算机的原码/反码/补码。

什么是原码/反码/补码?

  • 原码:原码就是机器码。1的原码是0000 0001,-1的原码是1000 0001。因为最高位是符号位,所以他的取值范围是[1111 1111,01111 1111] ,即[-127,127]。
  • 反码:正数的反码是其本身。负数的反码是其原码除符号位以外其余各位按位取反。-5的反码是1111 1010。
  • 补码:正数的补码是其本身。负数的补码是其反码+1。例如-5的补码是1111 1011。

字符串是如何转化为字节数组的?
在计算机中,每一个字符都对应有一个编码,比如说Ascii/Unicode,而Java中采用的是Unicode编码集,因为Unicode编码支持所有语言,需要注意的是Unicode是编码集,而不是编码,常见的Unicode编码集的编码有UTF-8/UTF-16/UTF-32等等,我们用的就是UTF-8,将一个字符串转换为字节就是将一个字符转换为字符对应的编码进行存储。

但是值得注意的是,在UTF-8编码中,一个汉字通常占3个字节(但也有特殊情况下会占用4个字节),那么UTF-8是如何将一个汉字转换成字符数组的?
首先,每一个汉字在UTF-8中都对应着一个编码,下面以“中”字作为例子讲解:

public static void main(String[] args) {
      byte[] bytes = "中".getBytes(StandardCharsets.UTF_8);
      for (int i = 0; i < bytes.length; i++) {
          System.out.println(bytes[i]);
      }
}

字符“中”的UTF-8编码为\u4e2d,该编码是16进制,假设编码值为X,则UTF-8计算byte数组的算法如下:
bytes[0] = (byte)(224 | X >> 12);
bytes[1] = (byte)(128 | X >> 6 & 63);
bytes[2] = (byte)(128 | X & 63);

根据上面这个计算公式,可以得到三个元素的数组,分别是:
bytes[0] = (byte)(224 | 4e2d >> 12);
bytes[0] = (byte)(128 | 4e2d >> 6 & 63);
bytes[0] = (byte)(128 | 4e2d & 63);

将4e2d转换为2进制,结果为:1001110 00101101,这里由于每一个字节占用8位,需要在最前面补一个0,得到01001110 00101101

  • 元素0的计算方式如下:
    1. 将0100111000101101右移动12位,得到0000000000000100
    2. 224的二进制:0000000011100000
    3. 或预算(0000000011100000 | 0000000000000100)结果为0000000011100100
    4. 取11100100,从上面的值,最高位为符号位,所以,将除高位后的所有值取反后+1,得到0011011 + 1 = 0011100,转10进制为28
    5. 最终得到的值为-28
  • 元素1的计算方式如下:
    1. 将0100111000101101右移动6位,得到0000000100110111
    2. 63转二进制:0000000000111111
    3. 与运算(0000000100110111 & 0000000000111111)结果为0000000000111000
    4. 128转二进制:0000000010000000
    5. 或运算(0000000010000000 | 0000000000111000)结果为0000000010111000
    6. 取10111000,将除高位后的所有值取反后+1,得到1000111 + 1 = 1001000,转10进制为72
    7. 最终得到的值为-72
  • 元素2的计算方式如下:
    1. 63转二进制:0000000000111111
    2. 与运算(0100111000101101 & 0000000000111111)的结果为0000000000101101
    3. 128转二进制:0000000010000000
    4. 或运算(0000000010000000 | 0000000000101101)的结果为0000000010101101
    5. 取10101101,将除高位后的所有值取反后+1,得到1010010 + 1 = 1010011,转10进制为83
    6. 最终得到的值为-83

所以,汉字“中”转换为二进制数组得到的结果为[-28,-72,-83]

图片是如何在计算机中存储和传输的?
我们知道,图片是由很多个像素点组成的,而每一个像素点,都是由三原色(RGB,红绿蓝)组成,而每一种颜色的取值范围是0-255,也就是说,一种颜色占用一个字节(二进制中占8位,刚好不超过256),所以如果说是黑白图片,一个像素占用一个字节,如果是彩色图片,一个像素需要占用3个字节(因为有三种颜色混合形成一个像素)

与其说图片在计算机中如何存储,不如说图片是怎么转换成二进制流的。如上面所说,将图片的每一个像素转换成二进制数据

序列化技术选型

市面上的序列化框架有很多,例如:Hassian/kyro/protobuf,我们应该如何进行选择,评判一个框架的好坏或者是否适用当前环境我认为主要有以下两个评判标准

  • 压缩比:将一个对象或者数据压缩得越小越好
  • 序列化耗时:在执行序列化和反序列化时所耗费的时间越短越好

protobuf

使用protobuf之前,首先要安装protoc工具,当然也可以使用maven插件,安装很简单,从github上下载对应版本的包到本地,配置其环境变量即可。
下面做一个protobuf序列号的简单demo作为演示:

  1. 编写proto文件user.proto,语法也很简单,自行百度
package xyz.james.proto;

option java_package = "xyz.james.proto";
option java_outer_classname = "UserProto";

message User {
    required int64 userId = 1;
    optional string username = 2;
}
  1. 执行protoc user.proto --java_out=./src将生成的文件输出到项目src目录下。
  2. 添加maven依赖
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.21.7</version>
</dependency>
  1. 序列化及反序列化
public static void main(String[] args) {
        UserProto.User.Builder userBuilder = UserProto.User.newBuilder()
                .setUserId(1)
                .setUsername("zhangsan");
        UserProto.User user = userBuilder.build();
        System.out.println(user);
        byte[] bytes = user.toByteArray();
        System.out.println(Arrays.toString(bytes));
        try {
            UserProto.User user1 = UserProto.User.parseFrom(bytes);
            System.out.println(user1);
        } catch (InvalidProtocolBufferException e) {
            e.printStackTrace();
        }
    }

最终会输入如下结果:

userId: 1
username: "zhangsan"

[8, 1, 18, 8, 122, 104, 97, 110, 103, 115, 97, 110]
userId: 1
username: "zhangsan"

由此可见,使用protobuf作为序列号及反序列化能极大程度上压缩数据,使网络传输更轻便快捷,对性能有很大提升。

参考

https://blog.csdn.net/gudejundd/article/details/90575217
https://www.zhihu.com/question/20451870