理解 Python 後端技術:從 CGI 談到 WSGI, uWSGI 與 uwsgi

Posted on  May 29, 2024  in  Python 程式設計 - 中階  by  Amo Chen  ‐ 9 min read

開發 Flask, Django 等後端應用時,最後一定會遇到部署的問題,這些框架多半告訴我們要使用 WSGI HTTP server 部署,例如 gunicorn, uWSGI 等等都是一時之選。

但你有想過為什麼要使用 WSGI HTTP server 嗎?你理解什麼是 WSGI 嗎?跟 uWSGI 之間又有什麼差異?(這些其實也是面試 Python 後端工程師時常見的問題)

本文將從 CGI 開始,一路認識 WSGI, uWSGI 以及 uwsgi, 把各種常見的問題都釐清!

本文環境

$ pip install gunicorn
$ brew install nginx

CGI (Common Gateway Interface) 簡介

我們都知道 Apache2, Nginx, Lighttpd 等這些 Web 伺服器,能夠跟各種程式語言開發的後端伺服協作,做到動態網頁的功能,那它們之間具體是怎麼溝通協作的呢?

通常談到溝通協作這件事,就會有所謂的「協定 / Protocol 」或「介面 / Interface 」,簡單來說,都是規定雙方要用哪些方式溝通、用什麼格式傳送資料、呼叫什麼方法達到目的等等。

而 Web 伺服器與其他程式語言之間的溝通方式,最早是 1990 年早期 1 項稱為 CGI (Common Gateway Interface) 的介面。

這個介面規定 Web 伺服器在收到 request 時,如果要將 request 交由其他程式(或稱外部程式 / CGI script / CGI program )處理,就需要建立 1 個新的 process 執行該程式,該程式處理完 request 之後,就必須將 response 輸出到 STDOUT, Web 伺服器就會把這個 STDOUT 回應給使用者。如果這個外部程式需要接收資料,則可以透過讀取 STDIN 得到資料,如果是需要讀取 request 相關資料,則是透過讀取環境變數取得,這些環境變數會在 Web 伺服器建立新的 process 時一併建立好。

CGI 的出現,使得各種程式語言都能夠與 Web 伺服器進行協作,提供動態網頁的功能,譬如 PHP 預設可以使用 CGI 模式與 Web 伺服器協作。

以圖表示的話,應該能夠更清楚 Web Server, CGI 與其他程式的關係,居中協調的介面就是 CGI , Web Server 與外部程式都必須實作 CGI 才能夠順利溝通:

cgi-overview.png

做 1 個簡單的 Python CGI 程式

知道 CGI 大致樣貌之後,試看看做個會回傳 Hello, World 的 Python Script 吧!

查閱 RFC 3875: The Common Gateway Interface (CGI) Version 1.1 之後,可以知道最簡單的回應格式為:

Content-Type: <Content Type>\n\n
<Response Body>

加上 CGI 是透過 STDOUT 回傳資料,所以只需要用到 print() 就可以做 1 個超簡單的 Python CGI 程式:

hello.cgi

#!/usr/bin/env python3

print(
    "Content-Type: text/html"
    "\n\n"
    "<html><body><h1>Hello, World!</h1></body></html>"
)

搞定 CGI 程式之後,我們還需要 1 個 Web Server 驗證一下 2 者是否能夠協作成功。

為了方便,我們使用 macOS 內建的 Apache2(如果沒有 macOS 也沒有關係,本章節僅是為了說明 CGI 如何運作), 首先將 hello.cgi 放到 /Library/WebServer/CGI-Executables 資料夾內,並且賦予它可執行的權限,因為 Web Server 需要執行該程式才能取得回應:

$ sudo mv hello.cgi /Library/WebServer/CGI-Executables/
$ sudo chmod +x /Library/WebServer/CGI-Executables/hello.cgi

p.s. /Library/WebServer/CGI-Executables/ 是 macOS Apache2 預設放 CGI 程式的地方,預設也有開啟 CGI 模組

接著,編輯 macOS Apache2 的設定檔(本文使用 Vim 編輯器,各位可以換成自己慣用的編輯器):

$ sudo vim /etc/apache2/httpd.conf

搜尋字串 <Directory "/Library/WebServer/CGI-Executables"> 後,會發現以下設定:

<Directory "/Library/WebServer/CGI-Executables">
    AllowOverride None
    Options None
    Require all granted
</Directory>

我們需要將 Options None 改為 Options +ExecCGI 後存檔,以賦予 Apache2 執行 CGI 程式的能力:

<Directory "/Library/WebServer/CGI-Executables">
    AllowOverride None
    Options +ExecCGI
    Require all granted
</Directory>

最後,使用以下指令啟動 Apache2:

$ sudo apachectl start

p.s. 停止的指令為 sudo apachectl stop

打開瀏覽器輸入網址 http://127.0.0.1/cgi-bin/hello.cgi 查看以 Python 寫成的 CGI 程式否能夠運作,正常的話將會看到以下畫面:

cgi-hello-world.png

這就是 CGI 的威能!賦予 Web Server 執行各種程式、各種程式語言達到協作的能力!

加碼 — 從環境變數取的 request 的內容

前文提到 CGI 協定規定 request 的內容會放在環境變數中,因此我們也可以在 Python CGI script 中使用環境變數讀到 request 的內容,可以使用的環境變數也都記錄在 RFC 3875: The Common Gateway Interface (CGI) Version 1.1 中,包含:

  1. CONTENT_TYPE
  2. SCRIPT_NAME
  3. QUERY_STRING
  4. REQUEST_METHOD
  5. 其他

p.s. 如果有寫過 Flask, Django 的話,應該都會對這些字串感到熟悉吧?

我們可以將 hello.cgi 改成下列程式碼,取得 request 的內容:

#!/usr/bin/env python3

import os

print(
    "Content-Type: text/html"
    "\n\n"
    "<html><body>"
    "<h1>Hello, World!</h1>"
    f"<p>REQUEST_METHOD: {os.getenv('REQUEST_METHOD')}</p>"
    f"<p>SCRIPT_NAME: {os.getenv('SCRIPT_NAME')}</p>"
    "</body></html>"
)

再打開瀏覽器輸入網址 http://127.0.0.1/cgi-bin/hello.cgi ,運作正常的話將可以看到以下畫面:

cgi-env.png

至此,你應該已經知道 CGI 具體如何運作了!說 CGI 此技術解放 Web Server 產生動態網頁的能力也不為過!

疑?那 WSGI 與 uWSGI 又是什麼?

實際上, CGI 作為最早出現的溝通介面,其最致命的缺陷就是效能差。

原因在於每次接到 1 個新的 request, 就得建立 1 個新的 process 進行處理,這種做法天生就無法處理大量 requests, 這也是為什麼我們現在少使用 CGI 的緣故。

為了解決 CGI 的效能問題,開始出現各種不同的 Interfaces 的替代方案,例如 Nginx 的 FastCGI 。

而 Python 也在 PEP-333 提出 WSGI (Web Server Gateway Interface),同樣也是可以讓 Web Server 與 Web 應用(application)與框架(framework)溝通協作的 Interface 。

This PEP, therefore, proposes a simple and universal interface between web servers and web applications or frameworks: the Python Web Server Gateway Interface (WSGI).

注意, WSGI 只是 1 份規格(specification)而已,而 uWSGI 則是有實作 WSGI 這份規格的 project

我們同樣以圖表示 WSGI, Server 與 Application 之間的關係:

wsgi-overview.png

注意,上圖中的 Web Server 變成 gunicorn, uWSGI 等有實作 WSGI 規格的 HTTP server!

它們從 Client 端的角度來看是 Web Server, Clients 可以用 HTTP 協定進行溝通,但它們與外部程式溝通則是使用 WSGI, 接下來我們會以 WSGI HTTP server 稱呼這些實作 WSGI 規格的 Web servers 。

再小結一次:

  1. WSGI 是規格,規定 Web Server 與 Application 之間的溝通方式
  2. uWSGI 是有實作 WSGI 規格的 Project
  3. WSGI HTTP server 是有實作 WSGI 的 Web server

WSGI 寫了些什麼?

首先,在 2003 年提出 WSGI 時,當時沒有任何 Python Web 應用/框架支援 WSGI, 因此 WSGI 在設計時就考慮到,必須讓未來所有 Python Web 應用/框架都能輕易實作 WSGI, 所以「簡單實作」是 WSGI 的 1 大特點!

WSGI 規格中的規定主要分為 2 端:

  1. Application / Framework Side
  2. Server / Gateway Side

如果要開發像 Flask, Django 之類的 Framework 或 Application, 只要遵守 Application / Framework Side 的規定就好。

如果要開發像 gunicorn, uWSGI 之類的 WSGI HTTP server, 則是遵守 Server / Gateway Side 的規定。

以下會分開進行解釋。

Application / Framework Side

Application / Framework Side 規定應用或者框架如何定義符合 WSGI 規範,而且能夠被 Server Side 呼叫的 object (或稱 callable object),該 object 可以是 1 個 function, method, class 或者是有實作 __call__ 方法的 instance 。

The application object is simply a callable object that accepts two arguments. The term “object” should not be misconstrued as requiring an actual object instance: a function, method, class, or instance with a __call__ method are all acceptable for use as an application object.

Application / Framework Side 也是一般開發者最熟悉的,開發 Flask, Django 等應用都是在做 Application / Framework Side 的事情,只是因為 Flask, Django 包裝得很好,所以我們不知道它們實際遵循 WSGI 規格。

Application / Framework Side 規定 callable object 必須接受 2 個參數,分別是 environstart_response

environ 代表從 WSGI server side 傳進來的 request 內容, environ 是 1 個 dictionary, 裡面存著與 CGI 環境變數相容的 key 與 value, 例如 CONTENT_TYPE , SCRIPT_NAME 等等,除此之外,還有 WSGI 獨有的 key 與 value, 例如 wsgi.version , wsgi.input 等等,詳細可以閱讀文件 environ Variables

p.s. 與 CGI 相容的好處是可以讓 CGI programs 很容易進行改寫

p.s. 這也是為什麼 Flask, Django 等都能看到 CONTENT_TYPE, SCRIPT_NAME 等 keys 的原因

start_response 則是 1 個 callable object, 呼叫 start_response 代表可以開始回應 HTTP response, start_response 的形式如下所示,接受 2 個參數與 1 個 optional 參數:

start_response(status, response_headers, exc_info=None)
  • status 是我們熟知的 200 OK , 404 Not Found 等字串。
  • response_headers 是我們要回傳的 HTTP headers, 是 1 個 list of tuples, 每 1 個 tuple 格式為 (header_name, header_value) ,其中 header_name 必須是合法的 HTTP header 。
  • exc_info 主要用於錯誤處理,預設為 None 。如果在呼叫 start_response() 之前程式有任何錯誤,可以傳入與 sys.exc_info() 相同格式的值,或者直接放 sys.exc_info() 的回傳值也可以。

最後, Application / Framework Side 規定 callable object 必須回傳 iterable bytes 作為回應的內容,也就是 HTTP response 的 body 。

知道 WSGI 規定的 application 要怎麼寫之後,我們就可以做 1 個像前文 CGI 範例同樣簡單的 Python WSGI Application:

app.py

def app(environ, start_response):
    print(f'{environ=}')
    start_response("200 OK", [('Content-Type', 'text/html'),])
    return [
        b"<html>",
        b"<body>",
        b"<h1>Hello, WSGI</h1>",
        b"</body>",
        b"</html>",
    ]

接著,我們使用 Python WSGI HTTP Server — gunicorn 做為我們的 Web server, 讓 gunicorn 呼叫我們寫的 WSGI application:

$ gunicorn -w 1 app:app

p.s. -w 1 則代表只需要 1 個 worker process

p.s. app:app 代表呼叫 app.py 裡的 app callable object

成功之後,可以看到類似以下的訊息,訊息中可以看到 gunicorn 正在監聽 8000 port:

[2024-05-28 23:19:44 +0800] [87015] [INFO] Starting gunicorn 22.0.0
[2024-05-28 23:19:44 +0800] [87015] [INFO] Listening at: http://127.0.0.1:8000 (87015)
[2024-05-28 23:19:44 +0800] [87015] [INFO] Using worker: sync
[2024-05-28 23:19:44 +0800] [87090] [INFO] Booting worker with pid: 87090

我們可以使用瀏覽器打開網址 http://127.0.0.1:8000

成功的話,可以看到以下畫面,代表我們成功透過 WSGI HTTP server 呼叫我們寫的 Python WSGI application 囉:

hello-wsgi.png

知道 Application Side 是怎麼回事之後,進一步看看一些 Python Web Framework 怎麼做的,我們以 Django 為例, Django 也在 WSGIHandler 中實作 WSGI application, 它的運作邏輯也十分類似前述的 app.py

django-wsgi-handler.png

而負責建立 WSGIHandler 的地方就在 get_wsgi_application() 裡,這也是為什麼使用指令 django-admin startproject <project name> 建立 1 個專案之後,專案資料夾裡有個 wsgi.py 會呼叫 get_wsgi_application() 的原因,目的就是為了建立 1 個 callable object 準備給 WSGI HTTP server 使用:

$ django-admin startproject mysite
$ tree -L 3 mysite
mysite
├── manage.py
└── mysite
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

wsgi.py 的內容:

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')

application = get_wsgi_application()

上述程式碼裡的 application 就是 WSGI 需要的 callable object, 所以我們可以透過 gunicorn 指令,告訴 guincorn 去呼叫名稱為 application 的 callable object 處理 requests:

$ cd mysite
$ gunicorn -w 1 mysite.wsgi:application

p.s. mysite.wsgi:application 代表呼叫 mysite/wsgi.py 裡的 application object, 之所以使用 . 分隔是因為 mysite 是個 Python module

讀到此處,大家應該都能理解為什麼 Flask, Django 等文件都提到應該使用 gunicorn, uWSGI 等 WSGI HTTP server 進行部署,因為我們開發的 Python 程式可以透過 WSGI 進行呼叫。

WSGI Application 與 WSGI HTTP Server 分開的好處,可以讓開發者專注在開發應用,而 WSGI HTTP server 就專注在執行效能與如何處理更多 requests ,而且開發者可以不用為了特別的 Web Server 開發特定的 application 邏輯,只要雙方都遵守 WSGI ,那就可以任意搭配,例如我們寫的 Django 應用,可以選擇搭配 uWSGI 也可以選擇 gunicorn, 完全不需要為了換 Web Server 而改程式碼。

只要對 WSGI application side 理解夠清楚,之後不管使用什麼 WSGI HTTP server 都可以,因為只要告訴 WSGI HTTP server 我們的 WSGI callable object 在哪裡就行!

Server / Gateway Side

不僅 Application / Framework Side, WSGI 也有規定 Server / Gateway 怎麼呼叫應用(Application)或框架(Framework),以及如何回應 HTTP response 給使用者等等。

WSGI Server Side 的重點在於:

  1. environ 整理好,準備呼叫 application 時使用
  2. 提供 start_response callable object, 同樣準備呼叫 application 時使用
  3. 取得呼叫 application 之後的回傳值,並回應給使用者

有興趣的話,可以看 PEP-333 The Server/Gateway Side ,該章節有提供 1 個 CGI 版的 WSGI server, 它的程式邏輯主要就是做上述所提到的 3 件事,同樣的模式也可以在 gunicorn 的 source code 中看到:

gunicorn-worker.png

這就是 Sever / Gateway Side 的所有規定!

為什麼部署 WSGI application 還需要 Nginx?

目前為止,我們所談到的架構如下圖所示:

wsgi-overview.png

但實際上,我們很可能會遇到在 WSGI HTTP server 前面還加了 Nginx, Apache2 等 Web Server 的情況,如下圖所示:

frontend-web-server.png

這是因為 Nginx, Apache2 等 Web Server 仍有其強項,例如處理靜態檔案(static files)、負載平衡、反向代理(reverse proxy)、快取(cache)等能力,不僅可以減小 WSGI HTTP server 的壓力,還可以提升整體效能,透過妥善利用各自的強項,也讓 WSGI HTTP server 不必重新發明輪胎又實作一遍 Nginx, Apache2 等已經具備的功能,也因此上圖在現今的後端架構相當常見。

等等,我還看到有個 uwsgi (全小寫)

如果選擇使用 uWSGI 作為 WSGI HTTP server 的話,或者使用 Nginx 作為更前端的 Web Server 的話,就會看到有個 uwsgi (全小寫) 的關鍵字。

uwsgi (全小寫) 實際上是 1 個協定(protocol), uwsgi是 uWSGI 所使用的特殊協定,官方也承認這是個不好的名字,很容易造成混淆⋯⋯。

uWSGI natively speaks HTTP, FastCGI, SCGI and its specific protocol named “uwsgi” (yes, wrong naming choice). The best performing protocol is obviously uwsgi, already supported by nginx and Cherokee (while various Apache modules are available).

uwsgi 協定是 1 個用來傳送各種類型的 binary 資料的協定,用於 Nginx, Apache2 這些有提供支援 uwsgi 協定的 Web server, 是 Web Server 與 uWSGI 之間的溝通協定,如下圖紅色部分所示:

uwsgi-protocol.png

uWSGI 支援 HTTP, FastCGI, SCGI 與 uwsgi 等協定,作為 Web Server 與 uWSGI 之間的溝通方式,其中 uwsgi 是效能最好的,也因此你可以用 --socket 參數讓 uWSGI 使用 uwsgi 協定:

$ uwsgi --socket 127.0.0.1:3031 --wsgi-file app.py --master --processes 4 --threads 2

然後在 Nginx 或其他 Web Sever 設定與 uWSGI 溝通時使用 uwsgi 協定,舉 Nginx 為例,設定如下:

location / {
    include uwsgi_params;
    uwsgi_pass 127.0.0.1:3031;
}

這就是 uwsgi (全小寫) 與 uWSGI 的差異!

總結

本文從 CGI 一路談到 WSGI 與 uWSGI, uwsgi 等經常會看到的技術名詞,實際用 Python 實作過一遍,而且探訪了幾個實際 WSGI application / framework 與 WSGI HTTP server 的原始碼,以期讓彼此能夠真正了解這些名詞的來龍去脈,並提升對相關技術的理解程度,提高掌握力!

以上!

Enjoy!

References

Neutrino’s Blog: 簡明 CGI 原理與實作

RFC 3875: The Common Gateway Interface (CGI) Version 1.1

How to deploy with WSGI | Django documentation

PEP-333 - Python Web Server Gateway Interface v1.0

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!