前言

在上一篇文章中简单的介绍了 Yar 的基本功能,今天我们来了解一下 Yar 的通信协议。

通信协议是服务端与客户端之间进行数据交换的一种约定,只有遵循这种约定才能进行通信。

Yar 支持两种数据传输方式:HTTP、TCP。

  • HTTP:使用 curl 传输 HTTP 协议数据。
  • TCP:使用 socket 传输 TCP 协议数据。

HTTP 本身就是一种服务端与客户端通信的协议,Yar 为什么还要自定义通信协议呢?

为什么要自定义通信协议?

Yar 直接使用 HTTP 协议进行数据传输是没有任何问题的,因为 HTTP 协议是建立在 TCP 协议之上的应用层协议,其协议本身已经清楚的定义了数据的边界,能够保证客户端/服务端正常地收发每一个数据包。

但是,Yar 数据传输方式除了 HTTP 协议以外还支持 TCP 协议,当 Yar 使用 TCP 协议传输数据时,就必须自己定义一个基于 TCP 协议的应用层协议了。

因为 TCP 是一个基于字节流的传输层通信协议,重点在于它是一个流式协议,也就是说数据与数据之间没有明确的边界,它只需要保证数据能够高效、可靠地将数据从一端传输到另一端。

可以把 TCP 想像成一根水管,数据是水管中的水,传输就是水在水管中从一端到另一端的过程。从水管一端接水的时候,你只知道桶里已经接了多少水,但是你根本不知道水管中还有多少水,只能一直等着。如果水突然停了,你也分不清数据到底是被分为多次发送,还是发送完了。

所以自定义通信协议主要目的是为了让接水的人知道本次接水的动作什么时候结束,也就是让客户端/服务端能够辨别出每一个数据包的边界。

这也是网友们常常提到的 TCP「粘包」问题,其实跟 TCP 一点关系都没有,这个问题是由于程序员在设计通信协议时,没有定义清楚数据之间的边界导致的。

常见的协议格式

有以下几种常见的协议格式:

  • 特殊字符作为边界
  • 显式编码数据长度
  • 固定数据包大小

特殊字符作为边界

这种方式是在协议中设置特殊字符作为数据的边界,比如 \r、\n、\0 等等。

HTTP 就是一个很好的例子,HTTP 将 \r\n 作为数据的边界。如果是 GET 请求,当读取到两个连续的 \r\n 时,说明一个完整的 HTTP 数据包结束了。

代码实现可以参考我之前写的《浅入浅出 HTTP》中 GET 请求的部分。

显式编码数据长度

显式编码数据长度就是在协议中提前将要发送的数据长度告诉接收方。

接收方在读取数据的时候,就可以先读取发送的数据长度,然后开始一直读取数据,当已读取的数据长度等于发送的数据长度时,停止读取数据。

HTTP 协议除了使用特殊字符作为边界,还使用了显式编码数据长度这种方式。当 HTTP 请求为 POST 或其它需要 body 的方式时,就会读取请求头中的 Content-Length 字段,来确定 body 的长度。

代码实现可以参考我之前写的《浅入浅出 HTTP》中 POST 请求的部分,也可以阅读《使用 Workerman 接入 Bilibili 直播弹幕协议》中对于协议声明及处理。

固定数据包长度

固定数据包长度的方式跟前一种有点像,但是丢弃了数据头部的长度字段,而是将数据包设为固定长度,属于前者的阉割版本。

这样接收方用规定的数据包长度去接收每一个数据包即可。不过这种方式用的较少,一般用来传输某些固定大小的指令时才会用到。

Yar 协议格式

Yar 的通信协议分为 header 和 body 两个部分,其中,body 由 packager_name(编码方式名称)和 payload(数据)组成。

下面分别对这几部分进行说明。

header 部分

header 中存储了 Yar 通信协议的基本信息,比如请求 ID、协议版本、调用方等等。

当然,最重要就是存储了 body 部分数据的长度。所以 Yar 使用的是显式编码数据长度方式。

Yar 定义了 _yar_header 结构体,用来存储 header 的信息。

1
2
3
4
5
6
7
8
9
10
11
// source: yar_protocol.h

typedef struct _yar_header {
uint32_t id; // 4 字节
uint16_t version; // 2 字节
uint32_t magic_num; // 4 字节
uint32_t reserved; // 4 字节
unsigned char provider[32];// 32 字节
unsigned char token[32]; // 32 字节
uint32_t body_len; // 4 字节
} __attribute__ ((packed)) yar_header_t;

结构体中字段说明:

  • id:唯一请求 ID。
  • version:协议版本,默认为 0。
  • magic_num:用于验证请求有效性的特殊值,默认为 0x80DFEC60。
  • reserved:预留参数,可以用来存储一些请求参数。
  • provider:客户端的名字。
  • token:预留参数,可以用来做 API key 验证。
  • body_len: 用于存储 body 的长度。

header 的总长度为 82 字节(设置了内存对齐)。

packager_name 部分

package_name 就是编码方式的名称。因为 Yar 支持多种消息编码方式,所以在 header 后面预留了 8 个字节,用于存储编码方式的名称,比如 JSON、MSGPACK、PHP。

服务端在收到消息后,就可以知道 payload 是通过哪种方式进行编码的,然后就可以使用对应的方式进行解码。

注意:编码方式名称的 8 个字节是固定的。

payload 部分

payload 是 RPC 传输的数据,数据分为两种:请求数据和响应数据。payload 的数据长度是动态变化的,可以通过 header 中的 body_len 得知。

请求数据

Yar 定义了一个 yar_request_t 结构体,用于在 Yar 内部传递请求数据。

1
2
3
4
5
6
7
8
// source: yar_request.h

typedef struct _yar_request {
zend_ulong id;
zend_string *method;
zval parameters;
zval options;
} yar_request_t;

yar_request_t 结构体字段说明:

  • id: 请求 ID。
  • method:被调用的方法。
  • parameters:参数。
  • options:可选项,例如请求超时时间、编码方式名称等,该参数不会被打包传输。

在发送请求前会调用 php_yar_request_pack 函数,将 yar_request_t 中的请求 ID、被调用的方法、参数存储一个 hash 字典中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// source: yar_request.c

zend_string *php_yar_request_pack(yar_request_t *request, char **msg) {
zval rv;
zend_array req;
zend_string *payload;
char *packager_name = NULL;

// 尝试从 options 中取出设置的编码方式名称
if (IS_ARRAY == Z_TYPE(request->options)) {
zval *pzval;
if ((pzval = zend_hash_index_find(Z_ARRVAL(request->options), YAR_OPT_PACKAGER)) && IS_STRING == Z_TYPE_P(pzval)) {
packager_name = Z_STRVAL_P(pzval);
}
}

// 初始化一个 hash 字典
zend_hash_init(&req, 8, NULL, NULL, 0);

// 将请求 ID 赋值给临时变量 rv
ZVAL_LONG(&rv, request->id);
// 以字符 i 作为 key,将 rv 中的请求 ID 存储到 hash 字典中
zend_hash_add(&req, ZSTR_CHAR('i'), &rv);

// 将被调用的方法赋值给临时变量 rv
ZVAL_STR(&rv, request->method);
// 以字符 m 作为 key,将 rv 中的请求 ID 存储到 hash 字典中
zend_hash_add(&req, ZSTR_CHAR('m'), &rv);

// 如果参数的类型为数组,则以字符 p 作为 key,将参数存储到 hash 字典中;
// 否则初始化一个空字典存储到 hash 字典中
if (IS_ARRAY == Z_TYPE(request->parameters)) {
zend_hash_add(&req, ZSTR_CHAR('p'), &request->parameters);
} else {
zend_array empty_arr;
zend_hash_init(&empty_arr, 0, NULL, NULL, 0);
ZVAL_ARR(&rv, &empty_arr);
zend_hash_add(&req, ZSTR_CHAR('p'), &rv);
}

// 调用编码器将 hash 字典进行编码处理
ZVAL_ARR(&rv, &req);
if (!(payload = php_yar_packager_pack(packager_name, &rv, msg))) {
zend_hash_destroy(&req);
return NULL;
}

// 释放 hash 字典
zend_hash_destroy(&req);

// 返回编码后的数据
return payload;
}

上面的 hash 字典是 PHP 数组的底层实现,所以请求数据实际上是一个数组,数组的 key 是字段的首字母:

1
2
3
4
5
$request = [
"i" => "123" , // 请求 ID
"m" => "login" , // 被调用的方法
"p" => ["her-cat", "123456"], // 参数
];

假设使用 JSON 方式对请求数据进行编码,那么编码过后得到的 JSON 字符串就是 payload 的内容:

1
{"i":"123","m":"login","p":["her-cat","123456"]}

包含请求数据的 RPC 通信协议格式:

服务端收到请求数据后,使用 JSON 方式进行解码就可以得到 $request 这样的数组。

响应数据

同样,对于响应数据,Yar 也定义了一个 yar_response_t 结构体。

1
2
3
4
5
6
7
typedef struct _yar_response {
long id;
int status;
zend_string *out;
zval err;
zval retval;
} yar_response_t;

yar_response_t 结构体字段说明:

  • id:同请求 ID。
  • status:状态,用于判断被调用的方法执行是否成功。
  • out:服务端输出的内容。
  • err: 错误或异常信息,通过 status 可以知道 err 的内容类型。
  • retval:被调用方法的返回值。

status 字段有以下几种值:

1
2
3
4
5
6
7
8
9
#define YAR_ERR_OKEY      		0x0     // 执行成功
#define YAR_ERR_PACKAGER 0x1 // 编码相关错误
#define YAR_ERR_PROTOCOL 0x2 // 协议相关错误
#define YAR_ERR_REQUEST 0x4 // 请求相关错误
#define YAR_ERR_OUTPUT 0x8 // 输出相关错误
#define YAR_ERR_TRANSPORT 0x10 // 数据传输相关错误
#define YAR_ERR_FORBIDDEN 0x20 // 不允许访问
#define YAR_ERR_EXCEPTION 0x40 // 发生异常
#define YAR_ERR_EMPTY_RESPONSE 0x80 // 响应内容为空

在执行被调用方法完成后,会调用 php_yar_server_response 函数将 yar_response_t 中的数据存储到一个 hash 字典中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static void php_yar_server_response(yar_request_t *request, yar_response_t *response, char *pkg_name) {
zval rv;
char *err_msg;
zend_array ret;
zend_string *payload;
yar_header_t header = {0};

zend_hash_init(&ret, 8, NULL, NULL, 0);

ZVAL_LONG(&rv, response->id);
zend_hash_add(&ret, ZSTR_CHAR('i'), &rv);
ZVAL_LONG(&rv, response->status);
zend_hash_add(&ret, ZSTR_CHAR('s'), &rv);

if (response->out && ZSTR_LEN(response->out)) {
ZVAL_STR(&rv, response->out);
zend_hash_add(&ret, ZSTR_CHAR('o'), &rv);
}
if (!Z_ISUNDEF(response->retval)) {
zend_hash_add(&ret, ZSTR_CHAR('r'), &response->retval);
}
if (!Z_ISUNDEF(response->err)) {
zend_hash_add(&ret, ZSTR_CHAR('e'), &response->err);
}


// ...省略部分代码
}

与请求数据的处理逻辑差不多,最终会得到一个存储响应数据的数组,类似于下面这样:

1
2
3
4
5
6
7
$response = [
"i" => "123", // 请求 ID
"s" => "0" , // 状态
"r" => "success", // 返回值
"o" => "", // 输出
"e" => "", // 错误或异常
];

使用 JSON 方式进行编码后得到 payload:

1
{"i":"123","s":"0","r":"success","o":"","e":""}

包含响应数据的 RPC 通信协议格式:

客户端收到响应数据后,使用 JSON 方式解码得到的也是这样一个数组。

通过状态判断执行结果,如果执行失败,获取错误或异常信息并抛出;执行成功则将返回值返回给调用方。

总结

通过本文,让我们对于 Yar 的通信协议有了一定的了解。先解释了为什么 Yar 需要自定义通信协议,并列举了常见的协议格式,然后详细地介绍了 Yar 通信协议中的内容及作用。