咱们在提出开发跨平台组件以前, iOS 和 Android 客户端分别使用一套长链接组件,须要双倍的人力开发和维护;在产品需求调整上,为了在实现细节上保持一致性也具备必定的难度;Web 端与客户端长链接的形式不一样,前者使用 WebSocket ,后者使用 Socket ,无形中也增长了后端的维护成本。为了解决这些问题,咱们基于 WebSocket 协议开发了一套跨平台的长链接组件。html
组件自上而下分为五层:前端
mbedTLS
实现 TLS 协议及数据加解密libuv
实现 TCP 链接和数据的读写总体架构以下图所示:node
TCP 层咱们是基于 libuv 进行开发, libuv 是一个异步 I/O 库,而且支持了多个平台( Linux ,Windows 和 Darwin ),一开始主要应用于开发 Node.js ,后来逐渐在其余项目也开始使用。文件、 网络和管道 等操做是 I/O 操做 ,libuv 为此抽象出了相关的接口,底层使用各平台上最优的 I/O 模型实现。webpack
它的核心是提供了一个 event loop
,每一个 event loop 包含了六个阶段:c++
setTimeout
、 setInterval
)的回调setImmediate()
的回调socket
的 close
事件回调mbedTLS(前身PolarSSL)是实现了一套易用的加解密算法和 SSL / TLS 库。TLS 以及前身 SSL 是传输层安全协议,给网络通讯提供安全和数据完整性的保障,因此它能很好的解决数据明文和劫持篡改的问题。而且其分为记录层和传输层,记录层用来肯定传输层数据的封装格式,传输层则用于数据传输,而在传输以前,通讯双方须要通过握手,其包含了双方身份验证
,协商加密算法
,交换加密密钥
。git
Websocket 层包含了对协议的实现和心跳的维护。github
其最新的协议是 13 RFC 6455。协议的实现分为握手,数据发送/读取,关闭链接。web
握手要从请求头去理解。算法
WebSocket 首先发起一个 HTTP 请求,在请求头加上 Upgrade
字段,该字段用于改变 HTTP 协议版本或者是换用其余协议,这里咱们把 Upgrade
的值设为 websocket
,将它升级为 WebSocket 协议。后端
同时要注意 Sec-WebSocket-Key
字段,它由客户端生成并发给服务端,用于证实服务端接收到的是一个可受信的链接握手,能够帮助服务端排除自身接收到的由非 WebSocket 客户端发起的链接,该值是一串随机通过 base64
编码的字符串。
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
复制代码
收到请求后,服务端也会作一次响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
复制代码
里面重要的是 Sec-WebSocket-Accept
,服务端经过从客户端请求头中读取 Sec-WebSocket-Key
与一串全局惟一的标识字符串(俗称魔串)“258EAFA5-E914-47DA- 95CA-C5AB0DC85B11”作拼接,生成长度为160位的 SHA-1
字符串,而后进行 base64
编码,做为 Sec-WebSocket-Accept
的值回传给客户端,客户端再去解析这个值,与本身加密编码后的字符串进行比较。
处理握手 HTTP 响应解析的时候,能够用 http-paser ,解析方式也比较简单,就是对头信息的逐字读取再处理,具体处理你能够看一下它的状态机实现。解析完成后你须要对其内容进行解析,看返回是否正确,同时去管理你的握手状态。
数据的处理须要用帧协议图来讲明:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
复制代码
首先咱们来看看数字的含义,数字表示位,0-7表示有8位,等于1个字节。
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
复制代码
因此若是要组装一个帧数据能够这样子:
char *rev = (rev *)malloc(4);
rev[0] = (char)(0x81 & 0xff);
rev[1] = 126 & 0x7f;
rev[2] = 1;
rev[3] = 0;
复制代码
ok,了解了帧数据的样子,咱们反过来去理解值对应的帧字段。
首先0x81
是什么,这个是十六进制数据,转换成二进制就是1000 0001
, 是一个字节的长度,也就是这一段里面每一位的值:
0 1 2 3 4 5 6 7 8
+-+-+-+-+-------+
|F|R|R|R| opcode|
|I|S|S|S| (4) |
|N|V|V|V| |
| |1|2|3| |
+-+-+-+-+-------+
复制代码
FIN
表示该帧是否是消息的最后一帧,1表示结束,0表示还有下一帧。
RSV1, RSV2, RSV3
必须为0,除非扩展协商定义了一个非0的值,若是没有定义非0值,且收到了非0的 RSV
,那么 WebSocket 的链接会失效,建议是断开链接。
opcode
用来描述 Payload data
的定义,若是收到了一个未知的 opcode
,一样会使 WebSocket 链接失效,协议定义了如下值:
连续帧是和 FIN 值相关联的,它代表可能因为消息分片的缘由,将本来一个帧的数据分为多个帧,这时候前一帧的 opcode 就是0,FIN 也是0,最后一帧的 opcode 就再也不是0,FIN 就是1了。
再能够看到 opcode 预留了非控制帧和控制帧,这两个又是什么?
控制帧表示 WebSocket 的状态信息,像是定义的分片,关闭链接,ping和pong。
非控制帧就是数据帧,像是 text 帧,二进制帧。
0xff
做用就是取出须要的二进制值。
下面再来看126
,126则表示的是 Payload len
,也就是 Payload 的长度:
8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-------------+-------------------------------+
|M| Payload len | Extended payload length |
|A| (7) | (16/64) |
|S| | (if payload len==126/127) |
|K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
复制代码
MASK
表示Playload data
是否要加掩码,若是设成1,则须要赋值 Masking-key
。全部从客户端发到服务端的帧都要加掩码
Playload len
表示 Payload 的长度,这里分为三种状况
长度小于126,则只须要7位
长度是126,则须要额外2个字节的大小,也就是 Extended payload length
长度是127,则须要额外8个字节的大小,也就是 Extended payload length
+ Extended payload length continued
,Extended payload length
是2个字节,Extended payload length continued
是6个字节
Extended Playload len
则表示 Extension data
与 Application data
的和
Masking-key
是在 MASK
设置成1以后,随机生成的4字节长度的数据,而后和 Payload Data
作异或运算
Payload Data
就是咱们发送的数据
而数据的发送和读取就是对帧的封装和解析。
关闭链接分为两种:服务端发起关闭和客户端主动关闭。
服务端跟客户端的处理基本一致,以服务端为例:
服务端发起关闭的时候,会客户端发送一个关闭帧,客户端在接收到帧的时候经过解析出帧的opcode来判断是不是关闭帧,而后一样向服务端再发送一个关闭帧做为回应。
Chat 层比较简单,只是提供一些通用的链接、读写数据和断开接口和回调,同时维护一个 loop 用于重连。
这一层负责和原生进行交互,因为组件是用 c 代码编写的,因此为了调用原生方法,Android 采用 JNI 的方式,iOS 采用 runtime 的方式来实现。
JNI :
JNIEXPORT void JNICALL Java_com_youzan_mobile_im_network_Channel_nativeDisconnect(JNIEnv *env, jobject jobj) {
jclass clazz = env->GetObjectClass(jobj);
jfieldID fieldID = env->GetFieldID(clazz, CONTEXT_VARIABLE, "J");
context *c = (context *) env->GetLongField(jobj, fieldID);
im_close(c);
}
复制代码
runtime:
void sendData(int cId, int mId, int version, int mv, const char *req_id, const char *data {
context *ctx = (context *)objc_msgSend(g_obj, sel_registerName("ctx"));
send_request(ctx, cId, mId, version, mv, req_id, data);
}
复制代码
在实现了一套跨端长链接组件以后,最近咱们又完成了其插件化的改造,为何要作这样的改造呢?因为业务环境复杂和运维的相关限制,有的业务方能够配置 TLS 组成 WSS;有的业务方不能配置,只能以明文 WebSocket 的方式传输;有的业务方甚至连 WebSocket 的承载也不要,转而使用自定义的协议。随着对接的业务方增多,咱们没办法进行为他们一必定制。咱们当初设计的结构是 Worker (负责和业务层通讯) -> WebSocket -> TLS -> TCP ,这四层结构是耦合在一块儿的,这时候若是须要剔除 TLS 或者扩展一个新的功能,就会改动至关多的代码。基于以上几点,咱们发现,原先的定向设计彻底不符合要求,为了接下来可能会有新增协议解析的预期,同时又不改变使用 libuv 进行跨平台的初衷,因此咱们就实施了插件化的改造,最重要的目的是为了解耦
,同时也为了提升组件的灵活性,实现可插拔(冷插拔)
。
首先咱们要对四层结构的职责进行明确
以及整理出结构间的执行调用:
其中 connect 包含了链接
和握手
两个过程。在完成链路层链接后,咱们认为协议层握手完成,才算是真正的链接成功。
一样的,数据读写、链接关闭、链接销毁和重置都会严格按照结构的顺序依次调用。
解耦完成以后咱们发现对于接口的调用都是显式的,好比 Worker send data 中调用 WebSocket send data , WebSocket send data 中又调用 TLS send data ,这样的显式调用是由于咱们知道这些接口是可用的,但在插件化中某个插件可能没有被使用,这样接口的调用会在某一层中断而致使整个组件的不可用。
因此咱们首先考虑到的是抽象出一个结构体,将插件的接口及回调统一,而后利用函数指针
实现插件方法的调用,如下是对函数指针声明:
/* handle */
typedef int (*node_init)(dul_node_t *node, map_t params);
typedef void (*node_conn)(dul_node_t *node);
typedef void (*node_write_data)(dul_node_t *node, const char *payload, unsigned long long payload_size, void *params);
typedef int (*node_read_data)(dul_node_t *node, void *params, char *payload, uint64_t size);
typedef void (*node_close)(dul_node_t *node);
typedef void (*node_destroy)(dul_node_t *node);
typedef void (*node_reset)(dul_node_t *node);
/* callback */
typedef void (*node_conn_cb)(dul_node_t *node, int status);
typedef void (*node_write_cb)(dul_node_t *node, int status);
typedef int (*node_recv_cb)(dul_node_t *node, void *params, uv_buf_t *buf, ssize_t size);
typedef void (*node_close_cb)(dul_node_t *node);
复制代码
但若是仅仅声明这些函数指针,在使用时还必须知道插件的结构体类型才能调用到函数的实现,这样插件之间仍然是耦合的。因此咱们必须将插件提早关联起来,经过结构体指针来寻找上一个或者下一个插件,OK,这样就很容易联想到双向链表
正好可以知足咱们的需求。因此加上 pre
、 next
以及一些必要参数后,最终咱们整理的结构体为:
typedef struct dul_node_s {
// 前、后插件
dul_node_t *pre;
dul_node_t *next;
// 必要参数
char *host;
int port;
map_t params;
node_init init;
node_conn conn;
node_write_data write_data;
node_read_data read_data;
node_close close;
node_destroy destroy;
node_reset reset;
node_conn_cb conn_cb;
node_write_cb write_cb;
node_recv_cb recv_cb;
node_close_cb close_cb;
} dul_node_t;
复制代码
接着咱们再对原有的结构体进行调整,将结构体前面的成员调整为 dul_node_s
结构体的成员,后面再加上本身的成员。这样在插件初始化的时候统一以 dul_node_s
结构体初始化,而在用到具体某一个插件时咱们进行结构体类型强转便可,这里有点像继承里父类和子类的概念。
在插件使用前咱们按需配置好用到的插件,但若是把插件接口直接暴露给业务方来配置,就须要让业务方接触到 C 代码,这点比较难以控制。基于这个缘由,咱们讨论了一下,想到前端里面 webpack
对于插件配置的相关操做,因而咱们查阅了 webpack 的相关文档,最终咱们仿照这个方式实现了咱们的插件配置:"ws?path=/!tls!uv"
。不一样插件以 !
分割,经过循环将插件依次建立:
void separate_loaders(tokenizer_t *tokenizer, char *loaders, context *c) {
char *outer_ptr = NULL;
char *p = strtok_r(loaders, "!", &outer_ptr);
dul_node_t *pre_loader = (dul_node_t *)c;
while (p) {
pre_loader = processor_loader(tokenizer, p, pre_loader);
p = strtok_r(NULL, "!", &outer_ptr);
}
}
复制代码
单个插件所须要额外的 params 以 query string
形式拼接,在插件建立中用 ?
分割出来 ,以 kv 形式放入到一个 hashmap 中。再根据插件的名称调用对应的初始化方法,并根据传入的 pre_loader
绑定双向链表的先后关系:
void (*oper_func[])(dul_node_t **) = {
ws_alloc,
tls_alloc,
uv_alloc,
};
char const *loaders[] = {
"ws", "tls", "uv"
};
dul_node_t *processor_loader(tokenizer_t *tokenizer, const char *loader, dul_node_t *pre_loader) {
char *p = loader;
char *inner_ptr = NULL;
/* params 提取组装 */
p = strtok_r(p, "?", &inner_ptr);
dul_node_t *node = NULL;
map_t params = hashmap_new();
params_parser(inner_ptr, params);
/* 这里采用转移表,进行插件初始化 */
while (strcmp(loaders[sqe], p) != 0) {
sqe++;
}
oper_func[sqe](&node);
if (node == NULL) {
return NULL;
}
node->init(node, params);
hashmap_free(params);
// 双向链表先后关系绑定
pre_loader->next = node;
node->pre = pre_loader;
return node;
}
/* params string 解析 */
void params_parser(char *query, map_t params) {
char *outer_ptr = NULL;
char *p = strtok_r(query, "&", &outer_ptr);
while (p) {
char *inner_ptr = NULL;
char *key = strtok_r(p, "=", &inner_ptr);
hashmap_put(params, key, inner_ptr);
p = strtok_r(NULL, "&", &outer_ptr);
}
}
复制代码
Tips:随着插件的增长,对应初始化的代码也会愈来愈多,并且都是重复代码,为了减小这部分工做,咱们能够采起宏来定义函数。后续若是增长一个插件,只须要在底下加一行 LOADER_ALLOC(zim_xx, xx)
便可。
#define LOADER_ALLOC(type, name) \ void name##_alloc(dul_node_t **ctx) { \ type##_t **loader = (type##_t **)ctx; \ (*loader) = malloc(sizeof(type##_t)); \ (*loader)->init = &name##_init; \ (*loader)->next = NULL; \ (*loader)->pre = NULL; \ }
LOADER_ALLOC(websocket, ws);
LOADER_ALLOC(zim_tls, tls);
LOADER_ALLOC(zim_uv, uv);
复制代码
再回到一开始咱们思考接口调用的问题,因为有了函数指针变量,咱们就须要在插件的初始化中把函数的地址存储在这些变量中:
int ws_init(dul_node_t *ctx, map_t params) {
websocket_t *ws = (websocket_t *)ctx;
bzero(ws, sizeof(websocket_t));
// 省略中间初始化过程
ws->init = &ws_init;
ws->conn = &ws_connect;
ws->close = &ws_close;
ws->destroy = &ws_destroy;
ws->reset = &ws_reset;
ws->write_data = &ws_send;
ws->read_data = &ws_read;
ws->conn_cb = &ws_conn_cb;
ws->write_cb = &ws_send_cb;
ws->recv_cb = &ws_recv_cb;
ws->close_cb = &ws_close_cb;
return OK;
}
复制代码
对比接口先后调用的方式,前者须要知道下一个 connect 函数,并进行显式调用,若是在 TLS 和 TCP 中新增一层,就须要改动 connect 函数的调用。但后者彻底没有这个顾虑,不管是新增仍是删除插件,它均可以经过指针找到对应的结构体,调用其 connect 函数,插件内部无需任何改动,岂不妙哉。
/* 改造前 */
int tls_ws_connect(tls_ws_t *handle, tls_ws_conn_cb conn_cb, tls_ws_close_cb close_cb) {
...
return uv_tls_connect(tls,
handle->host,
handle->port,
on__tls_connect);
}
/* 改造后 */
static void tls_connect(dul_node_t *ctx) {
zim_tls_t *tls = (zim_tls_t *)ctx;
...
if (tls->next && tls->next->conn) {
tls->next->host = tls->host;
tls->next->port = tls->port;
tls->next->conn(tls->next);
}
}
复制代码
基于改造后组件,新增插件只须要改动三处,以日志插件为例:
在头文件中定义 zim_log_s
结构体(这里没有额外的成员):
typedef struct zim_log_s zim_log_t;
struct zim_log_s {
dul_node_t *pre;
dul_node_t *next;
char *host;
int port;
map_t params;
node_init init;
node_conn conn;
node_write_data write_data;
node_read_data read_data;
node_close close;
node_destroy destroy;
node_reset reset;
node_conn_cb conn_cb;
node_write_cb write_cb;
node_recv_cb recv_cb;
node_close_cb close_cb;
};
复制代码
在实现文件中实现接口及回调,注意:即便接口或回调内没有额外的操做,仍然须要实现,例如此处的 log_conn_cb 和 log_connect ,不然上一个插件或下一个插件在日志层调用时会中断:
/* callback */
void log_conn_cb(dul_node_t *ctx, int status) {
zim_log_t *log = (zim_log_t *)ctx;
if (log->pre && log->pre->conn_cb) {
log->pre->conn_cb(log->pre, status);
}
}
/* 省略中间直接回调 */
int log_recv_cb(dul_node_t *ctx, void *params, uv_buf_t *buf, ssize_t size) {
/* 收集接收到的数据 */
recv_data_from_server(buf->base, params, size);
/* 继续向上一层插件回调接收到的数据 */
zim_log_t *log = (zim_log_t *)ctx;
if (log->pre && log->pre->recv_cb) {
log->pre->recv_cb(log->pre, opcode, buf, size);
}
return OK;
}
/* log hanlder */
int log_init(dul_node_t *ctx, map_t params) {
zim_log_t *log = (zim_log_t *)ctx;
bzero(log, sizeof(zim_log_t));
log->init = &log_init;
log->conn = &log_connect;
log->write_data = &log_write;
log->read_data = &log_read;
log->close = &log_close;
log->destroy = &log_destroy;
log->reset = &log_reset;
log->conn_cb = &log_conn_cb;
log->write_cb = &log_write_cb;
log->recv_cb = &log_recv_cb;
log->close_cb = &log_close_cb;
return OK;
}
static void log_connect(dul_node_t *ctx) {
zim_log_t *log = (zim_log_t *)ctx;
if (log->next && log->next->conn) {
log->next->host = log->host;
log->next->port = log->port;
log->next->conn(log->next);
}
}
/* 省略中间直接调用 */
static void log_write(dul_node_t *ctx, const char *payload, unsigned long long payload_size, void *params) {
/* 收集发送数据 */
send_data_to_server(payload, payload_size, params);
/* 继续往下一层插件写入数据 */
zim_log_t *log = (zim_log_t *)ctx;
if (log->next && log->next->write_data) {
log->next->write_data(log->next, payload, payload_size, flags);
}
}
复制代码
LOADER_ALLOC(zim_log, log);
void (*oper_func[])(dul_node_t **) = {
ws_alloc,
tls_alloc,
uv_alloc,
log_alloc,
};
char const *loaders[] = {
"ws", "tls", "uv", "log"
};
复制代码
/* 增长日志前 */
char loaders[] = "ws?path=/!tls!uv";
context_init(c, "127.0.0.1", 443, "", "", "", "", NULL, loaders);
/* 增长日志后 */
char loaders[] = "log!ws?path=/!log!tls!uv";
context_init(c, "127.0.0.1", 443, "", "", "", "", NULL, loaders);
复制代码
咱们从新运行程序,就能发现日志功能已经成功的配置上去,可以将接受和发送的数据上报:
回顾一下跨平台长链接组件的设计,咱们使用 libuv 和 mbedtls 分别实现 TCP 和 TLS ,参照 WebSocket 协议实现了其握手及数据读写,同时抽象出通讯接口及回调,为了和原生层交互,iOS 和 Android 分别采用 runtime 消息发送和 JNI 进行原生方法调用。
但这样的定向设计彻底不符合后期可能会有新增协议解析的预期,因此咱们进行了插件化改造,其三个核心点是结构体改造
、双向链表
和函数指针
。
咱们经过将插件行为抽象出一个结构体,利用双向链表将先后插件绑定在一块儿,使用函数指针调用具体插件的函数或回调。
这样作的优势是使得插件之间不存在耦合关系,只需保持逻辑顺序上的关系,同时经过修改插件的注册提升了灵活性,使得组件具备可插拔性(冷插拔)。
但在新增组件中咱们须要实现全部的接口和回调,若是数量多的话,这还真是一件比较繁琐的事情。