在本系列之前的博客文章中,我们开始编写自己的 Python 框架并实现以下功能:
- WSGI 兼容
- 请求处理程序
- 路由:简单和参数化
- 检查重复的路径
- 基于类的处理程序
- 单元测试
在这部分中,我们将为列表添加一些很棒的功能:
- 测试客户端
- 添加路径的替代方式(如类似 Django 的实现)
- 支持模板
测试客户端
在 「译文」如何编写 Python Web 框架(二) ,我们编写了几个单元测试。但是,当我们需要向处理程序发送 HTTP 请求时,我们停止了,因为我们没有可以执行此操作的测试客户端。我们先添加一个。
到目前为止,在 Python 中发送 HTTP 请求最流行的方式是 Kenneth Reitz 的Requests
库。但是,为了能够在单元测试中使用它,我们应该始终启动并运行我们的应用程序(即在运行测试之前启动 gunicorn)。原因是 Requests
只附带一个 Transport Adaptter: HTTPAdapter。这违背了单元测试的目的。单元测试应该是自我维持的。对我们来说幸运的是,Sean Brant编写了一个 WSGI Transport Adapter,用于 创建测试客户端。让我们先编写代码再进行讨论。
❗ 译者注:
先安装 2 个库:
pip install requests pip install requests-wsgi-adapter SHELL |
将以下方法添加到 api.py
主类 API
中:
# api.py ... from requests import Session as RequestsSession from wsgiadapter import WSGIAdapter as RequestsWSGIAdapter class API: ... def test_session(self, base_url="http://testserver"): session = RequestsSession() session.mount(prefix=base_url, adapter=RequestsWSGIAdapter(self)) return session ... PYTHON |
如此 处所述 ,要使用 Requests WSGI Adapter,我们需要将其 mount 到 Session 对象。这样,使用test_session
, 其 URL 以给定前缀开头的任何请求都将使用给定的 RequestsWSGIAdapter。太好了,现在我们可以用test_session
来创建一个测试客户端。创建一个 conftest.py
文件并将api
fixture 移动到此文件,使其如下所示:
# conftest.py import pytest from api import API @pytest.fixture def api(): return API() PYTHON |
此文件的 pytest
默认情况下会查找 fixture 。现在,让我们在这里创建测试客户端 fixture :
# conftest.py ... @pytest.fixture def client(api): return api.test_session() PYTHON |
我们的 client
需要 api
fixture 并返回我们之前编写的内容test_session
。现在我们可以在单元测试中使用这个client
fixture 。让我们直接进入test_bumbo.py
文件并编写一个单元测试,测试是否 client
可以发送请求:
# test_bumbo.py ... def test_bumbo_test_client_can_send_requests(api, client): RESPONSE_TEXT = "THIS IS COOL" @api.route("/hey") def cool(req, resp): resp.text = RESPONSE_TEXT assert client.get("http://testserver/hey").text == RESPONSE_TEXT PYTHON |
运行单元测试 pytest test_bumbo.py
并观察。我们看到所有的测试都通过了。让我们为最重要的部分添加几个单元测试:
# test_bumbo.py ... def test_parameterized_route(api, client): @api.route("/{name}") def hello(req, resp, name): resp.text = f"hey {name}" assert client.get("http://testserver/matthew").text == "hey matthew" assert client.get("http://testserver/ashley").text == "hey ashley" PYTHON |
这个测试我们在 url 中发送的参数是否正常工作。
# test_bumbo.py ... def test_default_404_response(client): response = client.get("http://testserver/doesnotexist") assert response.status_code == 404 assert response.text == "Not found." PYTHON |
这个测试如果请求被发送到不存在的路由,则返回 404(未找到)响应。
剩下的我会留给你。如果您需要任何帮助,请尝试编写更多测试并在评论中告诉我。以下是单元测试的一些想法:
- 测试基于类的处理程序 GET 请求是否正常运行
- 测试基于类的处理程序 POST 请求是否正常运行
- 测试如果使用无效的请求方法,基于类的处理程序返回响应
Method Not Allowed.
- 测试是否正确返回状态码
添加路径的替代方式
现在,这是添加路径的方式:
@api.route("/home") def handler(req, resp): resp.text = "YOLO" PYTHON |
也就是说,路由被添加为装饰器,就像在 Flask 中一样。有些人可能喜欢 Django 注册网址的方式。所以,让我们给他们这样添加路径的选择:
def handler(req, resp): resp.text = "YOLO" def handler2(req, resp): resp.text = "YOLO2" api.add_route("/home", handler) api.add_route("/about", handler2) PYTHON |
add_route
方法应该做两件事。检查路径是否已经注册,如果没有,则注册:
# api.py class API: ... def add_route(self, path, handler): assert path not in self.routes, "Such route already exists." self.routes[path] = handler PYTHON |
很简单。这段代码看起来很熟悉吗?这是因为我们已经在 route
装饰器中编写了这样的代码。我们现在可以遵循 DRY 原则并在 route
装饰器中使用 add_route
方法:
# api.py class API: ... def add_route(self, path, handler): assert path not in self.routes, "Such route already exists." self.routes[path] = handler def route(self, pattern): def wrapper(handler): self.add_route(pattern, handler) return handler return wrapper PYTHON |
让我们添加一个单元测试来检查它是否正常工作:
# test_bumbo.py def test_alternative_route(api, client): response_text = "Alternative way to add a route" def home(req, resp): resp.text = response_text api.add_route("/alternative", home) assert client.get("http://testserver/alternative").text == response_text PYTHON |
运行您的测试,您将看到所有测试都通过。
模板支持
当我实现新的东西时,我喜欢做一些叫做 README 驱动的开发。这是一种技术,您可以在实施之前记下 API 是什么样子。让我们来实现。假设我们要在我们的处理程序中使用此模板:
<html> <header> <title>{{ title }}</title> </header> <body> The name of the framework is {{ name }} </body> </html> HTML |
{{ title }}
和 {{ name }}
是从处理程序发送的变量,这是处理程序的样子:
api = API(templates_dir="templates") @api.route("/template") def handler(req, resp): resp.body = api.template("index.html", context={"title": "Awesome Framework", "name": "Alcazar"}) PYTHON |
我希望它尽可能简单,所以我只需要一个方法,将模板名和上下文作为参数,并用给定的参数呈现该模板。另外,我们希望模板目录可以像上面一样配置。
通过设计 API,我们现在可以实现它。
对于模板支持,我认为 Jinja2 是最佳选择。它是一个现代的,设计师友好的 Python 模板语言,模仿 Django 的模板。所以,如果你知道 Django, 那么使用 Jinja2 应该感觉一样。
Jinja2
使用称为模板 Environment
的中心对象。我们将在应用程序初始化和借助此 Environment 加载模板的基础上配置此环境。以下是如何创建和配置一个:
from jinja2 import Environment, FileSystemLoader templates_env = Environment(loader=FileSystemLoader(os.path.abspath("templates"))) PYTHON |
FileSystemLoader
从文件系统加载模板。此加载程序可以在文件系统上的文件夹中查找模板,并且是加载它们的首选方法。它将模板目录的路径作为参数。现在我们可以这样使用templates_env
:
templates_env.get_template("index.html").render({"title": "Awesome Framework", "name": "Alcazar"}) PYTHON |
既然我们了解了 Jinja2 中的所有工作原理,那么我们就将其添加到我们自己的框架中。首先,让我们安装 jinja2:
pip install Jinja2 MIPSASM |
然后,在我们的 API
类的 __init__
方法中创建Environment
对象:
# api.py from jinja2 import Environment, FileSystemLoader import os class API: def __init__(self, templates_dir="templates"): self.routes = {} self.templates_env = Environment(loader=FileSystemLoader(os.path.abspath(templates_dir))) ... PYTHON |
我们做了几乎与上面相同的事情,除了我们为 templates_dir
提供了一个默认值,templates
以便用户不必写它。现在我们有了实现我们之前设计的 template
方法的所有方法:
# api.py class API: def template(self, template_name, context=None): if context is None: context = {} return self.templates_env.get_template(template_name).render(**context) PYTHON |
我认为这里没有必要解释任何事情。你唯一想知道的是为什么我给了 context
一个默认值 None
,检查它是否是None
,然后将值设置为空字典{}
。你可能会说我可以在声明中给它默认值{}
。但是dict
它是一个可变对象,在 Python 中将可变对象设置为默认值是一种不好的做法。在这里 阅读更多相关信息。
随着一切准备就绪,我们可以创建模板和处理程序。首先,创建 templates
文件夹:
mkdir templates SHELL |
通过执行 touch templates/index.html
创建文件 index.html
并将以下内容放入:
<html> <header> <title>{{ title }}</title> </header> <body> <h1>The name of the framework is {{ name }}</h1> </body> </html> HTML |
现在我们可以在我们的 app.py
创建处理程序:
# app.py @app.route("/template") def template_handler(req, resp): resp.body = app.template("index.html", context={"name": "Alcazar", "title": "Best Framework"}) PYTHON |
就是这样(好吧,差不多)。启动 gunicorn
然后访问 http://localhost:8000/template
。你会看到一个大大的Internal Server Error
。那是因为resp.body
期望 bytes, 而我们的 template
方法返回一个 unicode 字符串。因此,我们需要对其进行编码:
# app.py @api.route("/template") def template_handler(req, resp): resp.body = app.template("index.html", context={"name": "Alcazar", "title": "Best Framework"}).encode() PYTHON |
重新启动 gunicorn,你将看到我们的模板的所有荣耀。在后续的文章中,我们将不再需要 encode
并使我们的 API 更漂亮。
结论
我们在这篇文章中实现了三个新功能:
- 测试客户端
- 添加路径的替代方式(如 Django 的实现方式)
- 支持模板
请务必在评论中告诉我们应该在本系列中实现的其他功能。对于下一部分,我们肯定会添加对静态文件的支持,但我不确定我们应该添加哪些其他功能。
稍微提醒一下,这个系列是基于我为学习目的而编写的 Alcazar 框架。如果你喜欢这个系列, 请在这儿 查看博客中的内容,一定要通过 star 该 repo 来表达你的喜爱。
Fight on!