nginx子請求併發處理
子請求並非http協議標準的實現,可以說是nginx所特有的設計, 為什麼需要子請求呢? 一般認為這主要是為了提高nginx內部對單個客戶端請求處理的併發能力。如果客戶端的某個主請求訪問了多個資源(例如通過ssi功能包含了a.html, b.hmtl、c.html三個資源), 那麼對每一處資源訪問建立一個子請求並讓它們同時進行,效率自然會更高。 所謂的子請求,並不是由客戶端直接發起的,它是由nginx伺服器在處理客戶端請求時,根據自身邏輯需要而內部建立的新請求。因此子請求只在nginx伺服器內部進行處理,不會與客戶端進行互動。
圖:子請求示意圖
一、nginx子請求資料結構
(1)樹加連結串列結構
先來整體看下nginx為了支援子請求功能,而設計的資料結構。子請求幾乎具備主請求的所有特徵,比如有對應完整的ngx_http_request_t結構物件。並且子請求本身也可以發起新的子請求,這是一個巢狀過程。根據子請求的特徵,即子請求可以遞迴的發起子請求(樹型結構), 以及同一個請求可以發起多個子請求(連結串列結構), 因此按照樹加連結串列的形式對它們進行組織是自然而然的事情。先來看下這個樹加連結串列結構:
圖: 樹加連結串列結構
對於原始請求,也就是主請求r, 建立了sub1, sub2兩個子請求, 而sub2這個子請求本身又建立了兩個子請求sub2_1, sub2_2。從圖中可以看出子請求之間是通過next指標相互連線構成一個連結串列, 而父請求與第一個子請求是通過postponed相互連線構成一個樹型結構。因此,樹加連結串列的資料結構是這麼得來的。因為子請求sub1是最先可以返回響應資料給客戶端的請求,如果其他請求完成了與後端伺服器通訊,產生的響應資料都需要進行快取。對於圖中的r_data是主請求執行完後要發給客戶端的響應資料,被快取起來。sub2_data則是sub2子請求產生的響應資料,被快取起來。 sub2_1_data則是sub2_1子請求產生的響應資料,被快取起來。 sub2_2_data則是sub2_2子請求產生的響應資料,被快取起來。nginx伺服器如果對請求產生的資料需要進行快取,則也會建立一個節點,這個節點用來存放請求產生的資料,而不是存放請求, 然後加入到連結串列的最末尾。 那什麼情況下請求產生的資料需要快取呢? 因為一個主請求可以建立多個子請求,這些子請求並行與後端伺服器通訊。但並一定先建立的子請求就一定會最先處理完與後端伺服器的通訊,因為這是一個非同步過程, 是有可能最後建立的子請求卻最先完成與後端伺服器的通訊。nginx伺服器會記錄哪個子請求是可以最先返回響應資料給客戶端瀏覽器的,因此只要不是這個可以最先返回響應資料給客戶端的子請求完成了與後端伺服器的通訊,這些子請求產生的資料都需要進行快取。
看下nginx伺服器怎麼維護這個樹加連結串列的資料結構的:
-
struct ngx_http_request_s
-
{
-
//如果是子請求則指向父請求,如果是父請求則為NULL
-
ngx_http_request_t * parent;
-
//指向第一個子請求,構成一顆樹結構
-
ngx_http_postponed_request_t * postponed;
-
}
-
struct ngx_http_postponed_request_s
-
{
-
//指向當前這個請求
-
ngx_http_request_t *request;
-
//完成與後端伺服器通訊後,如果這個請求是不最前面的可以與客戶端互動的請求,
-
//則這個請求產生的響應資料會快取到out緩衝區中
-
ngx_chain_t *out;
-
//指向下一個子請求,構成一個連結串列
-
ngx_http_postponed_request_t *next;
-
};
nginx伺服器就是使用上面的這個資料結構構成了一個樹加連結串列的結構。除了這個樹加連結串列的結構外, nginx伺服器還維護了一個單項鍊表結構,目的是為了排程各個子請求進行處理。在ngx_http_run_posted_requests函式被事件機制呼叫時,將會遍歷每一個子請求,包括孫子請求。因此每一個子請求都會被排程執行,子請求在11個http請求階段中,都會呼叫相應的http模組共同完成一個子請求。這和原始請求的處理過程是一樣的。也可以看出子請求是原始請求的一個派生,可以執行和原始請求一樣的操作。
(2)單項鍊表結構
圖: 原始請求維護的子請求單鏈表
對於樹加連結串列結構中的每一個子請求節點(注意: 不包括資料節點,例如r_data,這些資料節點是不會加入到原始請求的連結串列末尾), 都會加入到主請求,也就是原始請求的posted_requests連結串列末尾。為什麼要這麼做呢? 就是為了在這事件迴圈中,遍歷這個連結串列可以排程所有的子請求進行處理,使得每一個子請求都有機會被執行。如果讀者對這塊邏輯不是很清楚也不要著急,這裡只是一個框架結構,讓大家知道nginx是如何維護這些資料結構,如何對這些資料結構進行處理稍後將會分析。
-
struct ngx_http_request_s
-
{
-
//這個指標只對原始請求有效,其它請求則會空。
-
//如果是原始請求,則指向第一個子請求連結串列節點
-
ngx_http_posted_request_t * posted_requests;
-
}
-
//子請求連結串列節點
-
struct ngx_http_posted_request_s
-
{
-
ngx_http_request_t *request; //指向子請求
-
ngx_http_posted_request_t *next; //指向下一個子請求
-
};
看下nginx伺服器是如何維護這個單鏈表的。http請求結構中有一個posted_requests指標,這個指標只對原始請求有效,其它的請求不會用到這個欄位,也就是把posted_requests設定為null。而如果是原始請求,則會創接子請求連結串列。構成這樣的一個單鏈表,後續有事件到時,可以排程所有的子請求進行處理,使得每一個子請求都有機會排程執行。
(3)子請求輸出順序
原始請求建立的各個子請求,以及子請求本身也可以建立子請求。那這些子請求處理完成後,總要把響應資料發給客戶端瀏覽器吧! 在傳送響應給客戶端瀏覽器時,這些子請求、以及原始請求總有一個先後順序。先後順序的規則就是: 樹的後序遍歷操作的結果。以圖: 樹加連結串列結構為例來說明各個請求的輸出響應資料給客戶端瀏覽器的先後順序。
sub2_1_data是sub2_1子請求產生的響應資料; sub2_2_data是sub2_2子請求產生的響應資料; sub2_data則是sub2子請求產生的響應資料; r_data是原始請求產生的響應資料。這些都是葉子節點,可以直接輸出資料。如果不好理解,可以換一種角度來理解。假設把這些請求產生的資料儲存到子請求本身中,而不是重新建立一個節點並插入到postponed連結串列末尾,則輸出順序為:
不知這樣有沒更好理解子請求、以及原始請求的輸出順序? 子請求產生的資料節點與子請求本身節點對應關係如下:
二、子請求的建立
在對子請求有了整體的認識後,下面來看下nginx是如何建立子請求的。建立子請求的過程其實就是在建立前面介紹的樹加連結串列結構,以及構成一個原始請求維護的單項鍊表結構。下面按場景來分析子請求的建立過程。
(1)建立子請求時,子請求會複用原始請求的成員。例如新建立的子請求會複用原始請求的包體緩衝區, http請求的版本號資訊等、以及http請求的uri引數。
-
//建立一個子請求,使用連結串列構造一顆樹形結構
-
//將子請求新增到原始請求的連結串列模組,這樣原始請求可以知道所有子請求,包括孫子請求
-
ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
-
ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
-
ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
-
{
-
sr->request_body = r->request_body;
-
//子請求方法只能是get
-
sr->method = NGX_HTTP_GET;
-
sr->http_version = r->http_version;
-
sr->request_line = r->request_line;
-
sr->uri = *uri;
-
}
(2)建立子請求時,會設定子請求的讀事件回撥,以及寫事件回撥。因為子請求並不直接跟客戶端互動,所有不需要處理子請求的讀事件方法,因此讀事件方法設定為不做任何事情的空函式。而寫事件回撥會被設為:ngx_http_handler, 這樣在子請求被排程執行時,可以呼叫介入11個階段的http模組進行處理。
-
ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
-
ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
-
ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
-
{
-
//指向父請求
-
sr->parent = r;
-
sr->post_subrequest = ps;
-
//子請求不跟客戶端互動,因此不需要讀取客戶端事件
-
sr->read_event_handler = ngx_http_request_empty_handler;
-
//呼叫各個http模組協同處理這個請求
-
sr->write_event_handler = ngx_http_handler;
-
}
(3)建立子請求時,將子請求新增到父請求的postponed連結串列中,構成一個樹+連結串列組成的資料結構。
-
ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
-
ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
-
ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
-
{
-
pr = ngx_palloc(r->pool, sizeof(ngx_http_postponed_request_t));
-
pr->request = sr;
-
pr->out = NULL;
-
pr->next = NULL;
-
//將子請求新增到連結串列,構成一個樹+連結串列結構
-
if (r->postponed)
-
{
-
for (p = r->postponed; p->next; p = p->next)
-
{
-
}
-
p->next = pr;
-
}
-
else
-
{
-
r->postponed = pr;
-
}
-
}
(4)最後將子請求加入到原始請求你的posted_requests連結串列中,這樣原始請求就知道了所有的子請求,包括孫子請求。後續將會呼叫ngx_http_run_posted_requests遍歷所有的子請求,排程各個子請求進行處理。
-
//將子請求加入到原始請求連結串列的末尾。這樣原始請求就知道了所有的子請求,包括孫子請求
-
ngx_int_t ngx_http_post_request(ngx_http_request_t *r, ngx_http_posted_request_t *pr)
-
{
-
ngx_http_posted_request_t **p;
-
if (pr == NULL)
-
{
-
pr = ngx_palloc(r->pool, sizeof(ngx_http_posted_request_t));
-
}
-
pr->request = r;
-
pr->next = NULL;
-
for (p = &r->main->posted_requests; *p; p = &(*p)->next)
-
{
-
}
-
*p = pr;
-
return NGX_OK;
-
}
(5)有個疑問? 在建立子請求時,nginx伺服器是怎麼知道哪一個請求是最前面的請求,也就是最先發送響應資料給客戶端的那個請求? 答案是nginx使用連線物件ngx_connection_s的data成員, 使用data成員指向最先發送響應資料給客戶端的那個請求。 來看個例子, 假設原始請求開始的時候建立了兩個子請求sub1,sub2, 則最先發送響應資料給客戶端的是子請求sub1
而現在子請求sub1又建立了一個子請求sub1_1, 則此時最先發送響應資料給客戶端的子請求為sub1_1。 也就是說此時c->data指向了sub1_1。
來看下程式碼的實現過程。原始請求建立子請求sub1, sub2時, c->data指向的是sub1這個子請求。而子請求本身又可以建立子請求,這是一個遞迴的過程。因此sub1這個子請求建立sub1_1子請求時, 此時c->data指向了sub1_1這個子請求。因此sub1_1這個子請求是可以最先給客戶端傳送響應資料的請求, 其它請求則需要快取,等待sub1_1子請求結束。
-
ngx_int_t ngx_http_subrequest(ngx_http_request_t *r,
-
ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
-
ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
-
{
-
//複用data存放最前面的請求,這個請求的資料可以直接傳送給客戶端。其它的子請求需要快取資料,等待
-
//最前面的請求結束。如果r為子請求,r沒有子請求了,且r為最前面的請求。則最前面的請求將會被切換為
-
//這個剛剛建立的r的子請求
-
if (c->data == r && r->postponed == NULL)
-
{
-
c->data = sr;
-
}
-
}
三、子請求的排程執行
在建立完子請求後,那這些子請求什麼時候被排程執行呢? 當原始請求已經啟動並且執行完成後,會檢視是否有待處理的子請求,當然也包括孫子請求,有的話逐個排程這些子請求,開始子請求的處理。這些子請求,包括孫子請求都是原始請求的posted_requests單項鍊表中的一個節點。函式ngx_http_run_posted_requests會遍歷這個單項鍊表,逐個排程各個子請求。當子請求排程執行後,會從單向連結串列中刪除。如果後續子請求產生的資料被快取了,還是會重新加回到這個單項鍊表中,這樣可以再次觸發這個子請求。
-
static void ngx_http_process_request(ngx_http_request_t *r)
-
{
-
//呼叫各個http模組協同處理這個原始請求
-
ngx_http_handler(r);
-
//排程執行所有子請求
-
ngx_http_run_posted_requests(c);
-
}
-
//處理子請求,處理完後將從佇列中刪除
-
void ngx_http_run_posted_requests(ngx_connection_t *c)
-
{
-
//迴圈處理所有的子請求
-
for ( ;; )
-
{
-
r = c->data;
-
pr = r->main->posted_requests;
-
//指向下一個子請求
-
r->main->posted_requests = pr->next;
-
r = pr->request;
-
//在函式ngx_http_handler設定為ngx_http_core_run_phases
-
r->write_event_handler(r);
-
}
-
}
那子請求被排程執行時,會做些什麼呢? 在建立子請求時,已經把讀事件回撥設定為不做任何事件的ngx_http_request_empty_handler, 而把寫事件回撥設定為: ngx_http_handler。在子請求排程執行時,會呼叫這個函式進行處理。
-
ngx_int_t ngx_http_subrequest(ngx_http_request_t *r, ngx_str_t *uri, ngx_str_t *args, ngx_http_request_t **psr,
-
ngx_http_post_subrequest_t *ps, ngx_uint_t flags)
-
{
-
sr->read_event_handler = ngx_http_request_empty_handler;//子請求不跟客戶端互動,因此不需要讀取客戶端事件
-
sr->write_event_handler = ngx_http_handler; //呼叫各個http模組協同處理這個請求
-
}
現在看下ngx_http_handler這個函式做了些什麼? 這個函式其實就是為了排程介入11個請求階段的各個http模組進行處理。這和原始請求的處理過程是一樣的。在前面的文章已經詳細分析過了,如果不是很清楚可以參考nginx處理http請求這篇文章。
-
void ngx_http_handler(ngx_http_request_t *r)
-
{
-
r->write_event_handler = ngx_http_core_run_phases;
-
ngx_http_core_run_phases(r);
-
}
-
//呼叫各個http模組協同處理這個請求
-
void ngx_http_core_run_phases(ngx_http_request_t *r)
-
{
-
ph = cmcf->phase_engine.handlers;
-
//呼叫各個http模組的checker方法,使得各個http模組可以介入http請求
-
while (ph[r->phase_handler].checker)
-
{
-
rc = ph[r->phase_handler].checker(r, &ph[r->phase_handler]);
-
//返回NGX_OK,則會把控制全交由給事件模組
-
if (rc == NGX_OK)
-
{
-
return;
-
}
-
}
-
}
四、子請求的快取
那什麼情況下請求產生的資料需要快取呢? 因為一個主請求可以建立多個子請求,這些子請求並行與後端伺服器通訊。但並一定先建立的子請求就一定會處理完與後端伺服器的通訊過程,因為這是一個非同步過程。nginx伺服器會記錄哪個子請求是可以最先返回資料給客戶端瀏覽器的,因此只要不是這個可以最先返回資料給客戶端的子請求完成了與後端伺服器的通訊,這些子請求產生的資料都需要進行快取。子請求處理完成時,呼叫過濾器模組提供的方法,把響應資料傳送給客戶端瀏覽器。但執行到ngx_http_postpone_filter_module過濾模組時,會判斷是否需要對子請求產生的資料進行快取。 ngx_http_postpone_filter函式就是負責快取各個子請求產生的響應資料的,當然了,如果是最前面的請求,則會立馬傳送響應資料給客戶端瀏覽器,而不會進行快取。還是以圖: 樹加連結串列結構為例進行說明。
假設最開始時,主請求完成處理後,傳送響應資料給客戶端瀏覽器。而由於主請求不是最前面可以傳送響應資料給客戶端瀏覽器的請求,因此主請求產生的響應資料將會快取。nginx做法是建立一個子節點r_data,用來存放原始請求產生的資料,並把這個子節點掛載到原始請求r的postponed連結串列的末尾。
現在假設子請求sub2_1也完成了處理,傳送響應資料給客戶端瀏覽器。而由於sub2_1不是最前面可以傳送響應資料給客戶端瀏覽器的子請求,因此該請求產生的響應資料將會快取。同樣的,nginx將會建立一個子節點sub2_1_data, 快取sub2_1產生的響應資料,並加入到sub2_1子請求的postponed連結串列的末尾。
現在假設子請求sub2也完成了處理,傳送響應資料給客戶端瀏覽器。而由於sub2不是最前面可以傳送響應資料給客戶端瀏覽器的子請求,因此該請求產生的響應資料將會快取。同樣的,nginx將會建立一個子節點sub2_data, 快取sub2產生的響應資料,並加入到sub2子請求的postponed連結串列的末尾。
現在假設子請求sub2_2也完成了處理,傳送響應資料給客戶端瀏覽器。而由於sub2_2不是最前面可以傳送響應資料給客戶端瀏覽器的子請求,因此該請求產生的響應資料將會快取。同樣的,nginx將會建立一個子節點sub2_2_data,快取sub2_2產生的響應資料,並加入到sub2_2子請求的postponed連結串列的末尾。
最後,sub1子請求處理完成,傳送響應資料給客戶端瀏覽器。而由於sub1是最前面可以傳送響應資料給客戶端瀏覽器的子請求,因此ngx_http_postpone_filter函式會直接將sub1子請求產生的資料傳送給客戶端瀏覽器,而不會進行快取。
如果理解了nginx伺服器是如何快取子請求產出的響應資料的過程,現在分析原始碼就簡單多了。
-
static ngx_int_t ngx_http_postpone_filter(ngx_http_request_t *r, ngx_chain_t *in)
-
{
-
//當前請求不是最前面的請求,則需要把當前請求的資料快取起來
-
if (r != c->data)
-
{
-
if (in)
-
{
-
ngx_http_postpone_filter_add(r, in);
-
return NGX_OK;
-
}
-
return NGX_OK;
-
}
-
//*********執行到這裡,說明當前請求就是最前面的請求***********//
-
//這個最前面的請求沒有子請求了,也就是後續遍歷結束,左孩子,右孩子都沒有了。
-
//可以把內容直接輸出給客戶端
-
if (r->postponed == NULL)
-
{
-
if (in || c->buffered)
-
{
-
return ngx_http_next_filter(r->main, in);
-
}
-
return NGX_OK;
-
}
-
return NGX_OK;
-
}
五、子請求結束處理
在最前面的子請求結束時,也就是最先可以傳送響應資料給客戶端瀏覽器的那個子請求。這個子請求結束時,那最新可以傳送響應資料給客戶端瀏覽器的子請求會切換成哪一個子請求呢? 還是以圖: 樹加連結串列結構為例進行說明。
開始時sub1是最先可以傳送響應資料給客戶端瀏覽器的子請求。這個子請求結束時,會將c->data指向原始請求r, 此時原始請求就變成了最先可以傳送響應資料給客戶端瀏覽器的請求。
原始請求變成了最先可以傳送響應資料給客戶端瀏覽器的請求。但原始請求不是葉子節點,因此需要遞迴的在原始請求的子請求連結串列中繼續查詢最先可以傳送響應資料給客戶端瀏覽器的請求。此時原始請求r的第一子請求sub2就變成了最先可以傳送響應資料給客戶端瀏覽器的請求。其實這也就是對一顆樹進行後續遍歷的操作。
sub2子請求原始請求變成了最先可以傳送響應資料給客戶端瀏覽器的請求。但sub2子請求也不是葉子節點,因此需要遞迴的在sub2請求的子請求連結串列中繼續查詢最先可以傳送響應資料給客戶端瀏覽器的請求。此時sub2的第一個子請求sub2_1就變成了最先可以傳送響應資料給客戶端瀏覽器的請求。其實這也就是對以sub2為根節點的樹進行後續遍歷的操作。
sub2_1子請求變成了最先可以傳送響應資料給客戶端瀏覽器的請求。但sub2_1子請求仍然不是葉子節點,因此需要遞迴的在sub2_1請求的子請求連結串列中繼續查詢最先可以傳送響應資料給客戶端瀏覽器的請求。其實這也就是對以sub2_1為根節點的樹進行後續遍歷的操作。此時sub2_1的第一子請求sub2_1_data就變成了最先可以傳送響應資料給客戶端瀏覽器的請求。而這個sub2_1_data是一個葉子節點了,存放了sub2_1子請求的響應資料。因此sub2_1_data是最先可以傳送響應資料給客戶端瀏覽器的請求,可以直接把響應資料發給客戶端瀏覽器。
理解了最前面的子請求結束後,如何查詢到下一個最先可以傳送響應資料給客戶端瀏覽器的子請求的處理過程,現在分析程式碼就簡單多了。
-
//由各個http模組呼叫的,釋放http請求的函式
-
void ngx_http_finalize_request(ngx_http_request_t *r, ngx_int_t rc)
-
{
-
//說明是子請求
-
if (r != r->main)
-
{
-
//執行到這裡,說明子請求的所有資料都發送完成了
-
pr = r->parent;
-
if (r == c->data)
-
{
-
r->done = 1;
-
//postponed連結串列頭指向下一個節點
-
if (pr->postponed && pr->postponed->request == r)
-
{
-
pr->postponed = pr->postponed->next;
-
}
-
//父請求設定為最前面的請求,也就是可以最先發送響應資料給客戶端的請求
-
c->data = pr;
-
}
-
//將子請求的父請求重新新增到連結串列,目的是為了使得請求再次被排程執行
-
ngx_http_post_request(pr, NULL);
-
return;
-
}
-
}
說白了ngx_http_finalize_request處理的事情就是在子請求結束時,將最先可以傳送響應資料給客戶端瀏覽器的子請求設定為父請求。而ngx_http_postpone_filter函式中的這個do while迴圈就是為了遞迴查詢到葉子節點,使得這個葉子節點成為最先可以傳送響應資料給客戶端瀏覽器的子請求。nginx執行這兩個操作,就可以查詢到最先發送響應資料給客戶端瀏覽器的子請求。這兩個操作時密切配合的。
-
static ngx_int_t ngx_http_postpone_filter(ngx_http_request_t *r, ngx_chain_t *in)
-
{
-
do
-
{
-
pr = r->postponed;
-
//pr還有子請求,則下面需要查詢到最前面的請求
-
if (pr->request)
-
{
-
r->postponed = pr->next;
-
c->data = pr->request;
-
//將這個階段重新加入到原始請求的子請求連結串列中。為什麼要這麼做呢?因為子請求資料已經產生了,子請求
-
//排程執行時已經從原始請求的子請求連結串列中刪除。這樣這個子請求就不會被排程執行了。
-
return ngx_http_post_request(pr->request, NULL);
-
}
-
//說明pr是一個數據節點,沒有子請求了,是一個葉子節點,可以直接輸出資料
-
if (pr->out == NULL)
-
{
-
}
-
else
-
{
-
if (ngx_http_next_filter(r->main, pr->out) == NGX_ERROR)
-
{
-
return NGX_ERROR;
-
}
-
}
-
r->postponed = pr->next;
-
} while (r->postponed);
-
}
到此為止,子請求的併發處理已經分析完了。現在做個總結,對於子請求的併發處理需要掌握以下幾個內容:
(1) nginx為了支援子請求功能使用了樹+連結串列的資料結構,以及在原始請求中使用了一個單項鍊表post_requests維護了所有的子請求,包括孫子請求。
(2) 子請求是如何建立的,建立過程做了哪些事情。在建立過程是如何找到最先可以傳送響應資料給客戶端瀏覽器的子請求。
(3) nginx是如何排程各個子請求進行處理的。
(4) nginx子請求處理完成後是如何進行快取的。
(5) 最前面的請求結束後,又是如何找到最先可以傳送響應資料給客戶端瀏覽器的子請求。
(6) nginx的子請求被排程執行時,會從原始請求的post_requests單項鍊表中移除,那為什麼有些情況,這個被移除的子請求會再次插入到原始請求的post_requests單項鍊表的末尾呢? 這是為了使得這個子請求再次被http框架排程執行,將剩餘的響應資料傳送給客戶端瀏覽器。
--------------------- 本文來自 ApeLife 的CSDN 部落格 ,全文地址請點選:https://blog.csdn.net/apelife/article/details/75003346?utm_source=copy