(點選上方公眾號,可快速關註)
來源:宮城 ,
zeeyang.com/2018/04/03/cross-platform-architecture%20design-and-pluggable/
背景
我們在提出開發跨平臺元件之前, iOS 和 Android 客戶端分別使用一套長連線元件,需要雙倍的人力開發和維護;在產品需求調整上,為了在實現細節上保持一致性也具有一定的難度;Web 端與客戶端長連線的形式不同,前者使用 WebSocket,後者使用 Socket ,無形中也增加了後端的維護成本。為瞭解決這些問題,我們基於 WebSocket 協議開發了一套跨平臺的長連線元件。
架構介紹
元件自上而下分為五層:
-
Native 層:負責業務請求封裝和資料解析,與原生進行互動
-
Chat 層:負責提供底層通訊使用的 c 介面,包含連線、讀寫和關閉
-
Websocket 層:實現 websocket 協議及維護心跳
-
TLS 層 :基於 mbedTLS 實現 TLS 協議及資料加解密
-
TCP 層:基於 libuv 實現 TCP 連線和資料的讀寫
整體架構如下圖所示:
TCP 層
TCP 層我們是基於 libuv 進行開發, libuv 是一個非同步 I/O 庫,並且支援了多個平臺( Linux ,Windows 和 Darwin ),一開始主要應用於開發 Node.js ,後來逐漸在其他專案也開始使用。檔案、 網路和管道 等操作是 I/O 操作 ,libuv 為此抽象出了相關的介面,底層使用各平臺上最優的 I/O 模型實現。
它的核心是提供了一個 event loop ,每個 event loop 包含了六個階段:
-
timers 階段:這個階段執行 timer( setTimeout 、 setInterval )的回呼
-
I/O callbacks 階段:執行一些系統呼叫錯誤,比如網路通訊的錯誤回呼
-
idle , prepare 階段:僅 node 內部使用
-
poll 階段:獲取新的 I/O 事件, 適當的條件下 node 將阻塞在這裡
-
check 階段:執行 setImmediate() 的回呼
-
close callbacks 階段:執行 socket 的 close 事件回呼
TLS 層
mbedTLS(前身PolarSSL)是實現了一套易用的加解密演演算法和 SSL / TLS 庫。TLS 以及前身 SSL 是傳輸層安全協議,給網路通訊提供安全和資料完整性的保障,所以它能很好的解決資料明文和劫持篡改的問題。並且其分為記錄層和傳輸層,記錄層用來確定傳輸層資料的封裝格式,傳輸層則用於資料傳輸,而在傳輸之前,通訊雙方需要經過握手,其包含了雙方身份驗證,協商加密演演算法,交換加密金鑰。
Websocket 層
Websocket 層包含了對協議的實現和心跳的維護。
其最新的協議是 13 RFC 6455。協議的實現分為握手,資料傳送/讀取,關閉連線。
握手
握手要從請求頭去理解。
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 連線失效,協議定義了以下值:
-
%x0 表示連續的幀
-
%x1 表示 text 幀
-
%x2 表示二進位制幀
-
%x3-7 預留給非控制幀
-
%x8 表示關閉連線幀
-
%x9 表示 ping
-
%xA 表示 pong
-
%xB-F 預留給控制幀
連續幀是和 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個位元組
Playload len 則表示 Extension data 與 Application data 的和
Masking-key 是在 MASK 設定成1之後,隨機生成的4位元組長度的資料,然後和 Payload Data做異或運算
Payload Data 就是我們傳送的資料
而資料的傳送和讀取就是對幀的封裝和解析。
關閉連線
關閉連線分為兩種:服務端發起關閉和客戶端主動關閉。
服務端跟客戶端的處理基本一致,以服務端為例:
服務端發起關閉的時候,會客戶端傳送一個關閉幀,客戶端在接收到幀的時候透過解析出幀的opcode來判斷是否是關閉幀,然後同樣向服務端再傳送一個關閉幀作為回應。
Chat 層
Chat 層比較簡單,只是提供一些通用的連線、讀寫資料和斷開介面和回呼,同時維護一個 loop 用於重連。
Native 層
這一層負責和原生進行互動,由於元件是用 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 進行跨平臺的初衷,所以我們就實施了外掛化的改造,最重要的目的是為瞭解耦,同時也為了提高元件的靈活性,實現可插拔(冷插拔)。
解耦
首先我們要對四層結構的職責進行明確
-
Worker :提供業務介面和回呼
-
WebSocket :負責 WebSocket 握手,封裝/解析幀資料和維護心跳
-
TLS :負責 TLS 握手和資料的加解密
-
TCP:TCP 連線和資料的讀寫
以及整理出結構間的執行呼叫:
其中 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 進行原生方法呼叫。
但這樣的定向設計完全不符合後期可能會有新增協議解析的預期,所以我們進行了外掛化改造,其三個核心點是結構體改造、雙向連結串列和函式指標。
我們透過將外掛行為抽象出一個結構體,利用雙向連結串列將前後外掛系結在一起,使用函式指標呼叫具體外掛的函式或回呼。
這樣做的優點是使得外掛之間不存在耦合關係,只需保持邏輯順序上的關係,同時透過修改外掛的註冊提高了靈活性,使得元件具有可插拔性(冷插拔)。
但在新增元件中我們需要實現所有的介面和回呼,如果數量多的話,這還真是一件比較繁瑣的事情。
看完本文有收穫?請轉發分享給更多人
關註「ImportNew」,提升Java技能