咱們在提出開發跨平臺組件以前, 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 進行原生方法調用。
但這樣的定向設計徹底不符合後期可能會有新增協議解析的預期,因此咱們進行了插件化改造,其三個核心點是結構體改造
、雙向鏈表
和函數指針
。
咱們經過將插件行爲抽象出一個結構體,利用雙向鏈表將先後插件綁定在一塊兒,使用函數指針調用具體插件的函數或回調。
這樣作的優勢是使得插件之間不存在耦合關係,只需保持邏輯順序上的關係,同時經過修改插件的註冊提升了靈活性,使得組件具備可插拔性(冷插拔)。
但在新增組件中咱們須要實現全部的接口和回調,若是數量多的話,這還真是一件比較繁瑣的事情。