用 Python 撸一个 Web 服务器-第3章:使用 MVC 构建程序

简介: 用 Python 撸一个 Web 服务器-第3章:使用 MVC 构建程序

Todo List 程序介绍

我们将要编写的 Todo List 程序包含四个页面,分别是注册页面、登录页面、首页、编辑页面。以下分别为四个页面的截图。

注册页面:

注册

登录页面:

登录

首页:

首页

编辑页面:

编辑

程序页面非常简洁,甚至有些 Low。但这足够我们学习开发 Web 服务器程序原理,页面样式的问题并不是我们本次学习的重点,所以读者不必纠结于此。

Todo List 程序功能大概分为两个部分,一部分是 todo 管理,包含增删改查基础功能;另一部分是用户管理,包含注册和登录功能。

初识 MVC

介绍了 Todo List 程序的页面和功能,接下来我们就要思考如何设计程序。

以客户端通过浏览器向服务器发送一个获取应用首页的请求为例,来分析下服务器在收到这个请求后都需要做哪些事情:

  1. 首先服务器需要对请求数据进行解析,发现客户端是要获取应用首页。
  2. 然后找到代表首页的 HTML 文件,读取 HTML 文件中的内容。
  3. 最后将 HTML 内容组装成符合 HTTP 规范的数据进行返回。

这是一个较为理想的情况,因为 HTML 页面内容是固定的,我们不需要对其进行其他处理,直接返回给浏览器即可。通常我们管这种页面叫静态页面。

但实际情况中,Todo List 程序首页内容并不是一成不变的,而是动态变化的。首页 HTML 文件中只定义基础结构,具体的 todo 数据需要动态填充进去。所以一个更加完整的服务器处理请求的过程应该像下面这样:

  1. 首先服务器需要对请求数据进行解析,发现客户端是要获取应用首页。
  2. 然后从数据库中读取 todo 数据。
  3. 接着找到代表首页的 HTML 文件,读取 HTML 文件中的内容。
  4. 再将 todo 数据动态添加到 HTML 内容中。
  5. 最后将处理好的 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 程序首页为例,一个完整的请求过程如下:

  1. 浏览器发起请求。
  2. 请求到达服务器后首先进入控制器,然后控制器从模型获取 todo 数据。
  3. 模型操作数据库,查询 todo 数据。
  4. 数据库返回 todo 数据。
  5. 模型将从数据库中查询的 todo 数据返回给控制器,控制器暂时将数据保存在内存中。
  6. 控制器从视图中获取首页 HTML 模板。
  7. 控制器将从模型查出来的 todo 数据填充到首页 HTML 模板中,并组装成符合 HTTP 规范的数据。
  8. 服务器返回响应。

其实 MVC 是一个宏观上的分层,具体细节部分还需要根据我们设计程序的粒度来进行处理,比如有些逻辑既可以写在控制器层,也可以写在模型层,甚至我们还可以在 MVC 的基础上扩展更多的分层。这些都需要结合具体的业务逻辑来决定。

构建 Todo List 程序

学习了 MVC 模式,我们就可以根据 MVC 模式来试着构建 Todo List 程序了。

Todo List 程序分两部分:todo 管理、用户管理。在项目初期,我们肯定不会考虑的太过全面。所以可以先不考虑用户管理功能部分的实现,先只考虑如何实现 todo 管理功能。

Todo List 程序目录结构设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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__.pytodo/ 文件夹标记为一个 Python 包。 config.py 用于存储一些项目的基础配置。utils.py 是一个工具集,里面可以定义一些供其他模块调用的类和方法。db/ 目录作为 Todo List 程序存储数据的目录,db/todo.json 用来存储所有的 todo 内容。剩下还有两个 .py 文件和一个目录没有介绍,相信你已经猜到了 models.pytemplates/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 文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# todo_list/server.py
import socket
import threading
from todo.config import HOST, PORT, BUFFER_SIZE
defprocess_connection(client):
"""处理客户端请求"""
# 接收请求报文数据
    request_bytes = b''
whileTrue:
        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()
defmain():
"""入口函数"""
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}')
whileTrue:
            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 文件顶部从配置文件中导入 HOSTPORTBUFFER_SIZE。在 main 函数中,实例化 socket 对象部分的代码也有所改变,这里采用了 with 语句来实例化 socket 对象,这样能够保证任何情况下退出程序时 socket 都能够被正确关闭。此处 with 语句的用法可以类比文件操作时的 with 语句。处理客户端连接请求的 process_connection 函数内部基本逻辑没有改变,其中有两个 TODO 注释表示解析请求和返回响应的功能暂未实现。

server.py 入口程序接收到客户端的请求以后,需要解析请求报文,并根据解析出来的请求报文来决定如何处理请求并返回响应。所以接下来我们需要编写解析请求的代码。

不过在这之前,我先给出 todo/config.py 配置文件的代码,毕竟之后还会用到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# todo_list/todo/config.py
import 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 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# todo_list/todo/utils.py
classRequest(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'}
defparse_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, headers
def_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] = v
return method, path, headers

Request 类的初始化方法 __init__ 接收请求报文字符串作为参数。在其内部调用 parse_data 方法将请求报文字符串解析成我们需要的结构化数据。

解析完请求报文,我们需要根据请求报文信息来判断如何返回响应。基础逻辑判断部分的代码可以写在 todo/controllers.py 中:

1
2
3
4
5
6
7
8
# todo_list/todo/controllers.py
from todo.utils import render_template
defindex():
"""首页视图函数"""
return render_template('index.html')

定义在控制器层的函数也叫视图函数,因为它们通常返回视图层的 HTML 内容。index 视图函数用来处理请求首页的逻辑,它返回 render_template 函数的调用结果,render_template 函数的作用是将 HTML 内容读取成字符串并返回,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# todo_list/todo/utils.py
import os
from todo.config import BASE_DIR
defrender_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,因为这两个路径通常都代表首页。

1
2
3
4
5
6
# todo_list/todo/controllers.py
routes = {
'/': (index, ['GET']),
'/index': (index, ['GET']),
}

读取出 HTML 内容以后,我们就可以构造响应报文并返回给浏览器了。在 utils.py 文件下,编写一个 Response 类用来构造响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# todo_list/todo/utils.py
classResponse(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 isnotNone:
            _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.body
        response_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 文件,继续完善代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# todo_list/server.py
import socket
import threading
from todo.config import HOST, PORT, BUFFER_SIZE
from todo.utils import Request, Response
from todo.controllers import routes
defprocess_connection(client):
"""处理客户端请求"""
# 接收请求报文数据
    request_bytes = b''
whileTrue:
        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()
defmake_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 notin 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_bytes
defmain():
"""入口函数"""
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}')
whileTrue:
            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 注释的地方替换成了如下代码:

1
2
3
4
5
6
# 解析请求报文,构造请求对象
request = Request(request_message)
# 根据请求对象构造响应报文
response_bytes = make_response(request)
# 返回响应
client.sendall(response_bytes)

新增了一个 make_response 函数,方便用来根据请求对象构造响应报文。函数中我写了比较详细的注释,你可以根据注释内容读懂代码逻辑。

最后给出首页 todo/templates/index.html 的 HTML 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!--todo_list/todo/templates/index.html-->
<!DOCTYPE html>
<html>
<head>
<metacharset="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;
        }
.containerul {
            width: 100%;
            max-width: 600px;
        }
.containerulli {
            height: 40px;
            line-height: 40px;
            margin-bottom: 4px;
            padding: 0 6px;
            display: flex;
            justify-content: space-between;
background-color: #d2d2d2;
        }
</style>
</head>
<body>
<h1class="container">Todo List</h1>
<divclass="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

相关文章
|
2月前
|
人工智能 JavaScript API
零基础构建MCP服务器:TypeScript/Python双语言实战指南
作为一名深耕技术领域多年的博主摘星,我深刻感受到了MCP(Model Context Protocol)协议在AI生态系统中的革命性意义。MCP作为Anthropic推出的开放标准,正在重新定义AI应用与外部系统的交互方式,它不仅解决了传统API集成的复杂性问题,更为开发者提供了一个统一、安全、高效的连接框架。在过去几个月的实践中,我发现许多开发者对MCP的概念理解透彻,但在实际动手构建MCP服务器时却遇到了各种技术壁垒。从环境配置的细节问题到SDK API的深度理解,从第一个Hello World程序的调试到生产环境的部署优化,每一个环节都可能成为初学者的绊脚石。因此,我决定撰写这篇全面的实
534 67
零基础构建MCP服务器:TypeScript/Python双语言实战指南
|
1月前
|
人工智能 自然语言处理 安全
Python构建MCP服务器:从工具封装到AI集成的全流程实践
MCP协议为AI提供标准化工具调用接口,助力模型高效操作现实世界。
392 1
|
1月前
|
人工智能 JavaScript 前端开发
用 Go 语言轻松构建 MCP 服务器
本文介绍了使用 Go 语言构建 MCP 服务器的完整过程,涵盖创建服务器实例、注册工具、资源和提示词,以及通过 stdio 和 sse 模式启动服务的方法,帮助开发者快速集成 LLM 应用与外部系统。
|
2月前
|
前端开发 Java API
Spring Cloud Gateway Server Web MVC报错“Unsupported transfer encoding: chunked”解决
本文解析了Spring Cloud Gateway中出现“Unsupported transfer encoding: chunked”错误的原因,指出该问题源于Feign依赖的HTTP客户端与服务端的`chunked`传输编码不兼容,并提供了具体的解决方案。通过规范Feign客户端接口的返回类型,可有效避免该异常,提升系统兼容性与稳定性。
187 0
|
2月前
|
安全 Linux 网络安全
Python极速搭建局域网文件共享服务器:一行命令实现HTTPS安全传输
本文介绍如何利用Python的http.server模块,通过一行命令快速搭建支持HTTPS的安全文件下载服务器,无需第三方工具,3分钟部署,保障局域网文件共享的隐私与安全。
566 0
|
2月前
|
Windows
Windows下版本控制器(SVN)-验证是否安装成功+配置版本库+启动服务器端程序
Windows下版本控制器(SVN)-验证是否安装成功+配置版本库+启动服务器端程序
117 2
|
3月前
|
Windows
Windows下版本控制器(SVN)-启动服务器端程序
Windows下版本控制器(SVN)-启动服务器端程序
116 4
|
4月前
|
人工智能 安全 Shell
Jupyter MCP服务器部署实战:AI模型与Python环境无缝集成教程
Jupyter MCP服务器基于模型上下文协议(MCP),实现大型语言模型与Jupyter环境的无缝集成。它通过标准化接口,让AI模型安全访问和操作Jupyter核心组件,如内核、文件系统和终端。本文深入解析其技术架构、功能特性及部署方法。MCP服务器解决了传统AI模型缺乏实时上下文感知的问题,支持代码执行、变量状态获取、文件管理等功能,提升编程效率。同时,严格的权限控制确保了安全性。作为智能化交互工具,Jupyter MCP为动态计算环境与AI模型之间搭建了高效桥梁。
317 2
Jupyter MCP服务器部署实战:AI模型与Python环境无缝集成教程
|
3月前
|
监控 Ubuntu 安全
Ubuntu系统下构建FTP服务器的步骤
记住,时不时的巡视(监控)农场,更新工具(软件和安全性更新),以及恰当的维护同样重要,这样你的FTP农场才能长久繁荣。
70 4

推荐镜像

更多