Todo List 程序介紹
我們將要編寫的 Todo List 程序包含四個頁面,分別是注冊頁面、登錄頁面、首頁、編輯頁面。以下分別為四個頁面的截圖。
注冊頁面:
注冊
登錄頁面:
登錄
首頁:
首頁
編輯頁面:
編輯
程序頁面非常簡潔,甚至有些 Low。但這足夠我們學習開發 Web 服務器程序原理,頁面樣式的問題并不是我們本次學習的重點,所以讀者不必糾結于此。
Todo List 程序功能大概分為兩個部分,一部分是 todo 管理,包含增刪改查基礎功能;另一部分是用戶管理,包含注冊和登錄功能。
初識 MVC
介紹了 Todo List 程序的頁面和功能,接下來我們就要思考如何設計程序。
以客戶端通過瀏覽器向服務器發送一個獲取應用首頁的請求為例,來分析下服務器在收到這個請求后都需要做哪些事情:
- 首先服務器需要對請求數據進行解析,發現客戶端是要獲取應用首頁。
- 然后找到代表首頁的 HTML 文件,讀取 HTML 文件中的內容。
- 最后將 HTML 內容組裝成符合 HTTP 規范的數據進行返回。
這是一個較為理想的情況,因為 HTML 頁面內容是固定的,我們不需要對其進行其他處理,直接返回給瀏覽器即可。通常我們管這種頁面叫靜態頁面。
但實際情況中,Todo List 程序首頁內容并不是一成不變的,而是動態變化的。首頁 HTML 文件中只定義基礎結構,具體的 todo 數據需要動態填充進去。所以一個更加完整的服務器處理請求的過程應該像下面這樣:
- 首先服務器需要對請求數據進行解析,發現客戶端是要獲取應用首頁。
- 然后從數據庫中讀取 todo 數據。
- 接著找到代表首頁的 HTML 文件,讀取 HTML 文件中的內容。
- 再將 todo 數據動態添加到 HTML 內容中。
- 最后將處理好的 HTML 內容組裝成符合 HTTP 規范的數據進行返回。
現在已經知道了服務器處理請求的完整過程,我們就可以設計服務器程序了。試想一下,如果 Todo List 程序都像 Hello World 程序一樣把代碼都寫在一個 Python 文件中也不是不可以。但這樣的代碼顯然不具備良好的擴展性和可維護性。那么更好的設計模式是什么呢?
其實對于 Web 服務器程序的設計,業界早已達成了一個普遍的共識,那就是 MVC
模式:
M(Model):模型,用來存儲和處理 Web 應用數據。
V(View):視圖,格式化顯示 Web 應用頁面。
C(Controller):控制器,負責基礎邏輯,如從模型層讀取數據并將數據填充到視圖層,然后返回響應。
通過 MVC
的分層結構,能夠讓 Web 應用設計更加清晰,可以很容易的構建可擴展、易維護的代碼。模型層,說直白些其實就是用來讀寫數據庫的 Python 代碼,新增 todo 的時候,可以通過模型層的代碼將數據保存到數據庫中,訪問首頁時需要展示所有已保存的 todo,這時可以通過模型層的代碼從數據庫中讀取所有 todo。視圖層,可以將其簡單的理解為 HTML 模板文件的集合。控制器起到粘合的作用,它將從模型層讀取過來的數據填充到視圖層并返回給瀏覽器,或者將瀏覽器通過 HTML 頁面提交過來的數據解析出來再通過模型層寫入數據庫中。
我畫了一個示例圖,來幫助你理解 MVC
模式。圖中標注了瀏覽器發起一個請求到獲得響應,中間經歷的完整過程。
還是以客戶端請求 Todo List 程序首頁為例,一個完整的請求過程如下:
- 瀏覽器發起請求。
- 請求到達服務器后首先進入控制器,然后控制器從模型獲取 todo 數據。
- 模型操作數據庫,查詢 todo 數據。
- 數據庫返回 todo 數據。
- 模型將從數據庫中查詢的 todo 數據返回給控制器,控制器暫時將數據保存在內存中。
- 控制器從視圖中獲取首頁 HTML 模板。
- 控制器將從模型查出來的 todo 數據填充到首頁 HTML 模板中,并組裝成符合 HTTP 規范的數據。
- 服務器返回響應。
其實 MVC
是一個宏觀上的分層,具體細節部分還需要根據我們設計程序的粒度來進行處理,比如有些邏輯既可以寫在控制器層,也可以寫在模型層,甚至我們還可以在 MVC
的基礎上擴展更多的分層。這些都需要結合具體的業務邏輯來決定。
構建 Todo List 程序
學習了 MVC
模式,我們就可以根據 MVC
模式來試著構建 Todo List 程序了。
Todo List 程序分兩部分:todo 管理、用戶管理。在項目初期,我們肯定不會考慮的太過全面。所以可以先不考慮用戶管理功能部分的實現,先只考慮如何實現 todo 管理功能。
Todo List 程序目錄結構設計如下:
todo_list
├── server.py
└── todo ├── \_\_init\_\_.py ├── config.py ├── controllers.py ├── db │ └── todo.json ├── models.py ├── templates │ ├── edit.html │ └── index.html └── utils.py
這里以 todo_list/
作為程序的根目錄,根目錄下包含 server.py
文件和 todo/
目錄。其中 server.py
主要功能就是作為一個 Web Server
來接收請求和返回響應,它是 Todo List 程序的入口和出口。而 todo/
目錄則是 Todo List 程序處理業務邏輯的核心。
todo/
目錄下的 __init__.py
將 todo/
文件夾標記為一個 Python 包。 config.py
用于存儲一些項目的基礎配置。utils.py
是一個工具集,里面可以定義一些供其他模塊調用的類和方法。db/
目錄作為 Todo List 程序存儲數據的目錄,db/todo.json
用來存儲所有的 todo 內容。剩下還有兩個 .py
文件和一個目錄沒有介紹,相信你已經猜到了 models.py
、templates/
、controllers.py
分別對應了 MVC
模式中的模型、視圖、控制器。models.py
中編寫操作 todo 數據的代碼,templates/
目錄用來存放 HTML 模板文件,templates/index.html
是首頁,templates/edit.html
是編輯頁面,controllers.py
編寫負責程序控制的基礎邏輯代碼。
我們對項目的目錄結構有了一個概覽,這里我要強調一下 db/
目錄的作用。我們在開發整個 Todo List 程序的過程中都不會使用實際的數據庫程序,項目中所有需要存儲的數據都保存在 db/
目錄下的文件中。在開發 Web 程序時,需要用到數據庫的目的就是為了存儲數據,對于 Todo List 程序來說使用文件同樣能滿足需求,同時能夠照顧到對數據庫不了解的讀者。
Todo List 首頁開發
Todo List 程序目錄結構構建完成后就可以動手開發程序了,我們可以從一個請求經歷的過程來著手。
一個請求發送到服務器,首先服務器需要有一個能夠接收請求的入口,在程序根目錄 todo_list/
下的 server.py
就是這個入口。server.py
文件代碼如下:
\# todo_list/server.pyimport socket
import threadingfrom todo.config import HOST, PORT, BUFFER_SIZEdef process_connection(client): """處理客戶端請求""" \# 接收請求報文數據 request_bytes = b'' while True: chunk = client.recv(BUFFER_SIZE) request_bytes += chunk if len(chunk) < BUFFER_SIZE: break\# 請求報文 request_message = request_bytes.decode('utf-8') print(f'request_message: {request_message}')\# TODO: 解析請求 \# TODO: 返回響應\# 關閉連接 client.close()def main(): """入口函數""" with socket.socket() as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((HOST, PORT)) s.listen(5) print(f'running on http://{HOST}:{PORT}')while True: client, address = s.accept() print(f'client address: {address}') \# 創建新的線程來處理客戶端連接 t = threading.Thread(target=process_connection, args=(client,)) t.start()
server.py
程序幾乎就是之前實現的多線程版 Hello World 服務器程序照搬過來的。為了程序代碼更加清晰,這里將服務器的 IP 地址、端口、接收請求的緩沖區大小定義為變量寫在了配置文件 todo/config.py
中,所以需要在 server.py
文件頂部從配置文件中導入 HOST
、PORT
、BUFFER_SIZE
。在 main
函數中,實例化 socket
對象部分的代碼也有所改變,這里采用了 with
語句來實例化 socket
對象,這樣能夠保證任何情況下退出程序時 socket
都能夠被正確關閉。此處 with
語句的用法可以類比文件操作時的 with
語句。處理客戶端連接請求的 process_connection
函數內部基本邏輯沒有改變,其中有兩個 TODO
注釋表示解析請求和返回響應的功能暫未實現。
從 server.py
入口程序接收到客戶端的請求以后,需要解析請求報文,并根據解析出來的請求報文來決定如何處理請求并返回響應。所以接下來我們需要編寫解析請求的代碼。
不過在這之前,我先給出 todo/config.py
配置文件的代碼,畢竟之后還會用到:
\# todo_list/todo/config.pyimport os\# todo/ 目錄絕對路徑
BASE_DIR = os.path.dirname(os.path.abspath(\_\_file\_\_))\# IP
HOST = '127.0.0.1'
\# 端口
PORT = 8000\# 緩沖大小
BUFFER_SIZE = 1024
配置文件中除了包含前面介紹過的表示 IP 地址、端口、接收請求的緩沖區大小的幾個變量,還有一個 BASE_DIR
變量用來表示 todo/
目錄的絕對路徑,方便在程序中獲取項目路徑。
現在來看下如何解析請求,我們可以定義一個 Request
類用來專門解析請求報文,代碼寫在 todo/utils.py
文件中:
\# todo_list/todo/utils.pyclass Request(object): """請求類"""def \_\_init\_\_(self, request_message): method, path, headers = self.parse_data(request_message) self.method = method \# 請求方法 GET、POST self.path = path \# 請求路徑 /index self.headers = headers \# 請求頭 {'Host': '127.0.0.1:8000'}def parse_data(self, data): """解析請求報文數據""" \# 用請求報文中的第一個 '\\r\\n\\r\\n' 做分割,將得到請求頭和請求體 \# 請求體暫時用不到先不處理 header, body = data.split('\\r\\n\\r\\n', 1) method, path, headers = self.\_parse_header(header) return method, path, headersdef \_parse_header(self, data): """解析請求頭""" \# 拆分請求行和請求首部 request_line, request_header = data.split('\\r\\n', 1)\# 請求行拆包 'GET /index HTTP/1.1' -> \['GET', '/index', 'HTTP/1.1'\] \# 因為 HTTP 版本號沒什么用,所以用一個下劃線 _ 變量來接收 method, path, _ = request_line.split()\# 解析請求首部所有的鍵值對,組裝成字典 headers = {} for header in request_header.split('\\r\\n'): k, v = header.split(': ', 1) headers\[k\] = vreturn method, path, headers
Request
類的初始化方法 __init__
接收請求報文字符串作為參數。在其內部調用 parse_data
方法將請求報文字符串解析成我們需要的結構化數據。
解析完請求報文,我們需要根據請求報文信息來判斷如何返回響應。基礎邏輯判斷部分的代碼可以寫在 todo/controllers.py
中:
\# todo_list/todo/controllers.pyfrom todo.utils import render_templatedef index(): """首頁視圖函數""" return render_template('index.html')
定義在控制器層的函數也叫視圖函數,因為它們通常返回視圖層的 HTML 內容。index
視圖函數用來處理請求首頁的邏輯,它返回 render_template
函數的調用結果,render_template
函數的作用是將 HTML 內容讀取成字符串并返回,其定義如下:
\# todo_list/todo/utils.pyimport osfrom todo.config import BASE_DIRdef render_template(template): """讀取 HTML 內容""" \# 讀取 'todo_list/todo/templates' 目錄下的 HTML 文件內容 template_dir = os.path.join(BASE_DIR, 'templates') path = os.path.join(template_dir, template) with open(path, 'r', encoding='utf-8') as f: html = f.read() return html
在 todo/controllers.py
文件底部還定義了一個 routes
字典,字典的鍵為請求路徑,值為一個元組,元組的第一個元素作為處理請求的函數,第二個元素是一個列表,里面定義處理請求的函數所允許的請求方法。index
視圖函數能夠同時匹配兩個路徑:/
、/index
,因為這兩個路徑通常都代表首頁。
\# todo_list/todo/controllers.pyroutes = { '/': (index, \['GET'\]), '/index': (index, \['GET'\]),
}
讀取出 HTML 內容以后,我們就可以構造響應報文并返回給瀏覽器了。在 utils.py
文件下,編寫一個 Response
類用來構造響應:
\# todo_list/todo/utils.pyclass Response(object): """響應類"""\# 根據狀態碼獲取原因短語 reason_phrase = { 200: 'OK', 405: 'METHOD NOT ALLOWED', }def \_\_init\_\_(self, body, headers=None, status=200): \# 默認響應首部字段,指定響應內容的類型為 HTML \_headers = { 'Content-Type': 'text/html; charset=utf-8', }if headers is not None: \_headers.update(headers) self.headers = \_headers \# 響應頭 self.body = body \# 響應體 self.status = status \# 狀態碼def \_\_bytes\_\_(self): """構造響應報文""" \# 狀態行 'HTTP/1.1 200 OK\\r\\n' header = f'HTTP/1.1 {self.status} {self.reason_phrase.get(self.status, "")}\\r\\n' \# 響應首部 header += ''.join(f'{k}: {v}\\r\\n' for k, v in self.headers.items()) \# 空行 blank_line = '\\r\\n' \# 響應體 body = self.bodyresponse_message = header + blank_line + body return response_message.encode('utf-8')
Response
類的初始化方法 __init__
接收三個參數,分別為響應體、響應首部字段、狀態碼。其中響應體為 str
類型,首頁的響應體實際上就是 index.html
文件內容。響應首部字段為 dict
類型,在構造響應報文時,所有的響應首部字段最終按照 HTTP 規范拼接到一起作為響應首部。狀態碼為數值類型,目前只考慮了狀態碼為 200
正常響應和 405
請求方法不被允許。
需要注意的是,Response
類定義了 __bytes__
魔法方法作為構造響應報文的方法。當使用 Python 內置的 bytes
方法轉換 Response
實例對象時(bytes(Response())
),會自動調用 __bytes__
魔法方法。
從解析請求到構造響應報文的代碼現在已經基本編寫完成。接下來我們將整個處理請求的流程串聯起來,回到 server.py
文件,繼續完善代碼:
\# todo_list/server.pyimport socket
import threadingfrom todo.config import HOST, PORT, BUFFER_SIZE
from todo.utils import Request, Response
from todo.controllers import routesdef process_connection(client): """處理客戶端請求""" \# 接收請求報文數據 request_bytes = b'' while True: chunk = client.recv(BUFFER_SIZE) request_bytes += chunk if len(chunk) < BUFFER_SIZE: break\# 請求報文 request_message = request_bytes.decode('utf-8') print(f'request_message: {request_message}')\# 解析請求報文,構造請求對象 request = Request(request_message) \# 根據請求對象構造響應報文 response_bytes = make_response(request) \# 返回響應 client.sendall(response_bytes)\# 關閉連接 client.close()def make_response(request, headers=None): """構造響應報文""" \# 默認狀態碼為 200 status = 200 \# 獲取匹配當前請求路徑的處理函數和函數所接收的請求方法 \# request.path 等于 '/' 或 '/index' 時,routes.get(request.path) 將返回 (index, \['GET'\]) route, methods = routes.get(request.path)\# 如果請求方法不被允許,返回 405 狀態碼 if request.method not in methods: status = 405 data = 'Method Not Allowed' else: \# 請求首頁時 route 實際上就是我們在 controllers.py 中定義的 index 視圖函數 data = route()\# 獲取響應報文 response = Response(data, headers=headers, status=status) response_bytes = bytes(response) print(f'response_bytes: {response_bytes}')return response_bytesdef main(): """入口函數""" with socket.socket() as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((HOST, PORT)) s.listen(5) print(f'running on http://{HOST}:{PORT}')while True: client, address = s.accept() print(f'client address: {address}') \# 創建新的線程來處理客戶端連接 t = threading.Thread(target=process_connection, args=(client,)) t.start()if \_\_name\_\_ == '\_\_main\_\_': main()
首先完成之前未寫完的 process_connection
函數。將原來標記 TODO
注釋的地方替換成了如下代碼:
\# 解析請求報文,構造請求對象
request = Request(request_message)
\# 根據請求對象構造響應報文
response_bytes = make_response(request)
\# 返回響應
client.sendall(response_bytes)
新增了一個 make_response
函數,方便用來根據請求對象構造響應報文。函數中我寫了比較詳細的注釋,你可以根據注釋內容讀懂代碼邏輯。
最后給出首頁 todo/templates/index.html
的 HTML 代碼:
<!--todo_list/todo/templates/index.html--><!DOCTYPE html>
<html>
<head> <meta charset="UTF-8"> <title>Todo List</title> <style> \* { margin: 0; padding: 0; } ul { list-style: none; } a { text-decoration: none; outline: none; color: #000000; } h1 { margin: 20px auto; } .container { display: flex; justify-content: center; align-items: center; } .container ul { width: 100%; max-width: 600px; } .container ul li { height: 40px; line-height: 40px; margin-bottom: 4px; padding: 0 6px; display: flex; justify-content: space-between; background-color: #d2d2d2; } </style>
</head>
<body>
<h1 class="container">Todo List</h1>
<div class="container"> <ul> <li> <div>Hello World</div> </li> </ul>
</div>
</body>
</html>
HTML 代碼比較簡單,其中頂部寫了一些基礎的 CSS 樣式,都很容易看懂,這里不再講解。
接下來在終端中,進入 todo_list/
目錄下,使用 Python 運行 server.py
文件,看到如下打印結果說明程序已經正常啟動:
運行 Todo List 服務器
打開瀏覽器,地址欄輸入 http://127.0.0.1:8000/
或者 http://127.0.0.1:8000/index
,你將看到 Todo List 程序首頁:
Todo List 首頁
至此,Todo List 程序首頁初步完成。不過我想很多讀者看到這里會產生疑惑,說好的 MVC
呢,目前為止我們并沒有編寫一行模型層的代碼,并且首頁的 HTML 內容也不是動態填充的。沒錯,為了能夠盡快讓 Todo List 程序跑起來,我有意的避開了這兩個問題,下一章我們再來解決這兩個問題。
本章源碼:chapter3
原文出處: https://jianghushinian.cn