如何编写Python Web框架(一)

如何编写Python Web框架(一)

译文:

原文链接: How to write a Python web framework. Part I.

作者: Jahongir Rahmonov

“不要重新发明轮子”是我们每天听到的最常见的咒语之一。但是如果我想了解更多有关车轮的信息怎么办?如果我想学习如何制作这个该死的车轮怎么办?我认为为了学习而重新发明它是一个好主意。因此,在这些系列中,我们将编写自己的Python Web框架,以了解在Flask,Django和其他框架中如何完成所有这些魔术。

在本系列的第一部分中,我们将构建框架中最重要的部分。最后,我们将有请求处理程序(request handlers)(类似Django 视图 views)和路由(routing):既有简单(如/books/)请求也有参数化(如/greet/{name})请求。

在我开始做新事物之前,我想考虑最终结果。在这种情况下,在一天结束时,我们希望能够在生产中使用此框架,因此我们希望我们的框架由快速,轻量级的生产级应用程序服务器提供服务。在过去的几年里,我一直在我的所有项目中使用gunicorn,我对结果非常满意。那么,让我们一起来用gunicorn吧。

Gunicorn是一个WSGI HTTP服务器,因此它需要应用程序的特定入口点。如果你不知道什么WSGI是什么, 可以阅读这篇文章,我会等待。否则,你无法理解这篇博文的大部分内容。

您是否了解了WSGI是什么?如果了解了。那我们就继续吧。

要与WSGI兼容,我们需要一个可调用的对象(函数或类),它需要两个参数(environstart_response)并返回一个WSGI兼容的响应。那么,让我们开始写代码。

:notebook: 译者注:

编程环境: Linux或MacOS (windows系统不适用该教程)

想一个框架的名称并创建具有该名称的文件夹。我把它命名为bumbo

mkdir bumbo

进入此文件夹,创建一个虚拟环境并激活它:

cd bumbo
python3.6 -m venv venv
source venv/bin/activate

现在,创建一个名为app.py 的文件,我们将在这个文件里存储我们的gunicorn入口点:

touch app.py

在这个app.py内部,让我们编写一个简单的函数来查看它是否可以和gunicorn一起工作:

# app.py

def app(environ, start_response):
    response_body = b"Hello, World!"
    status = "200 OK"
    start_response(status, headers=[])
    return iter([response_body])

如上所述,这个可调用的入口点接收两个参数。其中之一environ是存储有关请求的各种信息,例如请求方法,URL,查询参数等。第二个start_response顾名思义是开始响应的。现在,让我们尝试用gunicorn运行此代码。对于gunicorn安装和运行如下:

pip install gunicorn
gunicorn app:app

第一个app是我们创建的文件,第二个app是我们刚刚编写的函数的名称。如果一切都很好,您将在输出中看到如下内容:

[2019-02-09 17:58:56 +0500] [30962] [INFO] Starting gunicorn 19.9.0
[2019-02-09 17:58:56 +0500] [30962] [INFO] Listening at: http://127.0.0.1:8000 (30962)
[2019-02-09 17:58:56 +0500] [30962] [INFO] Using worker: sync
[2019-02-09 17:58:56 +0500] [30966] [INFO] Booting worker with pid: 30966

如果您看到此内容,请打开浏览器并转到http://localhost:8000。你应该看到我们的老朋友:Hello, World!信息。真棒!

现在,让我们将这个函数变成一个类,因为我们需要很多辅助方法,并且它们更容易在类中编写。创建一个api.py文件:

touch api.py

在此文件中,创建以下API类。我会解释一下它的作用:

# api.py

class API:
    def __call__(self, environ, start_response):
        response_body = b"Hello, World!"
        status = "200 OK"
        start_response(status, headers=[])
        return iter([response_body])

现在,删除app.py里面的所有内容并编写以下内容:

# app.py
from api import API

app = API()

重新启动gunicorn并在浏览器中检查结果。它应该和以前一样,因为我们只是简单地将我们的app函数改为一个被调用的类API并在调用此类实例时覆盖它的__call__方法:

app = API()
app()   #  this is where __call__ is called

现在我们创建了我们的类,我希望使代码更加优雅,因为所有这些字节(b"Hello World")和start_response似乎让我感到困惑。值得庆幸的是,有一个名为WebOb的酷包,它通过包装WSGI请求环境和响应状态,标题和正文来为HTTP请求和响应提供对象。通过使用这个包,我们可以通过此包中提供的类传递environstart_response,而不必自己处理。在我们继续之前,我建议你看一下WebOb文档来理解我在说什么以及WebOb更多的API 。

以下是我们将如何重构此代码。首先,安装WebOb

pip install webob

api.py文件开头导入RequestResponse类:

# api.py
from webob import Request, Response

...

现在我们可以在__call__方法中使用它们:

# api.py
from webob import Request, Response

class API:
    def __call__(self, environ, start_response):
        request = Request(environ)

        response = Response()
        response.text = "Hello, World!"

        return response(environ, start_response)

看起来好多了!重新启动gunicorn,您应该看到与以前相同的结果。最好的部分是我不必解释这里正在做什么。这一切都是不言自明的。我们正在创建一个请求,一个响应,然后返回该响应。真棒!我必须注意到request这里还没有使用,因为我们没有对它做任何事情。所以,让我们利用这个机会来使用请求对象。另外,让我们将response创建重构为它自己的方法。我们稍后会看到为什么这么做会更好:

# api.py
from webob import Request, Response

class API:
    def __call__(self, environ, start_response):
        request = Request(environ)

        response = self.handle_request(request)

        return response(environ, start_response)

    def handle_request(self, request):
        user_agent = request.environ.get("HTTP_USER_AGENT", "No User Agent Found")

        response = Response()
        response.text = f"Hello, my friend with this user agent: {user_agent}"

        return response

重启你的gunicorn,你应该在浏览器中看到这条新消息。你看见了吗?酷。我们继续。

此时,我们以相同的方式处理所有请求。无论我们收到什么请求,我们只返回在handle_request方法中创建的相同响应。最终,我们希望它是动态的。也就是说,我们希望提供的来自/home/的请求不同于来自/about/的。

为此,在app.py内部,让我们创建两个处理这两个请求的方法:

# app.py
from api.py import API

app = API()


def home(request, response):
    response.text = "Hello from the HOME page"


def about(request, response):
    response.text = "Hello from the ABOUT page"

现在,我们需要以某种方式将这两种方法与上述路径联系起来:/home//about/。我喜欢Flask的做法,看起来像这样:

# app.py
from api.py import API

app = API()


@app.route("/home")
def home(request, response):
    response.text = "Hello from the HOME page"


@app.route("/about")
def about(request, response):
    response.text = "Hello from the ABOUT page"

你觉得怎么样?看起来不错?然后让我们实现这个bad boy吧!

如您所见,该route方法是一个装饰器,接受一个路径并包装方法。实施起来应该不会太难:

# api.py

class API:
    def __init__(self):
        self.routes = {}

    def route(self, path):
        def wrapper(handler):
            self.routes[path] = handler
            return handler

        return wrapper

    ...

这是我们在这里所做的。在该__init__方法中,在被调用的self.routes的地方我们简单地定义了一个dict,我们将路径存储为键, 处理程序handlers作为值。它看起来像这样:

print(self.routes)

{
    "/home": <function home at 0x1100a70c8>,
    "/about": <function about at 0x1101a80c3>
}

在该route方法中,我们将路径作为参数,并且在装饰器方法中,只需将self.routes路径作为键放在字典中,将处理程序作为值。

在这一点上,我们有所有的拼图。我们有处理程序和与之关联的路径。现在,当一个请求进来时,我们需要检查它的path,找到一个合适的处理程序,调用该处理程序并返回一个适当的响应。我们这样做:

# api.py
from webob import Request, Response

class API:
    ...

    def handle_request(self, request):
        response = Response()

        for path, handler in self.routes.items():
            if path == request.path:
                handler(request, response)
                return response

    ...

不是太难了,是吗?我们简单地迭代self.routes,将路径与请求的路径进行比较,如果存在匹配,则调用与该路径关联的处理程序。

重新启动gunicorn并在浏览器中尝试这些路径。首先,访问http://localhost:8000/home/,然后去http://localhost:8000/about/。您应该看到相应的消息。很酷,对吗?

下一步,我们可以回答“如果找不到路径会怎么样?”的问题。让我们创建一个返回“Not found.”的简单HTTP响应的方法。状态代码为404:

# api.py
from webob import Request, Response

class API:
    ...

    def default_response(self, response):
        response.status_code = 404
        response.text = "Not found."

    ...

现在,让我们在我们的handle_request方法中使用它:

# api.py
from webob import Request, Response

class API:
    ...

    def handle_request(self, request):
        response = Response()

        for path, handler in self.routes.items():
            if path == request.path:
                handler(request, response)
                return response

        self.default_response(response)
        return response

    ...

重新启动gunicorn并尝试一些不存在的路由。你应该看到这个可爱的“Not found.” 页。现在,为了便于阅读,让我们重构一下找到自己方法的处理程序:

# api.py
from webob import Request, Response

class API:
    ...

    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            if path == request_path:
                return handler

    ...

就像之前一样,它只是迭代self.route,将路径与请求路径进行比较,如果路径相同则返回对应处理程序。如果没有找到处理程序,则返回None。现在,我们可以在我们的handle_request方法中使用它:

# api.py
from webob import Request, Response

class API:
    ...

    def handle_request(self, request):
        response = Response()

        handler = self.find_handler(request_path=request.path)

        if handler is not None:
            handler(request, response)
        else:
            self.default_response(response)

        return response

    ...

我认为它看起来好多了,并且非常容易解释。重启gunicorn,看看一切都像以前一样有效。

此时,我们有路由和处理程序。它非常棒,但我们的路径很简单。它们不支持url路径中的关键字参数。如果我们想拥有@app.route("/hello/{person_name}")这条路径并且能够在我们的处理程序中使用person_name这样的内容:

def say_hello(request, response, person_name):
    response.text = f"Hello, {person_name}"

为此,如果有人访问/hello/Matthew/,我们需要能够将/hello/{person_name}/路径与已注册的路径匹配并找到适当的处理程序。值得庆幸的是,已经有一个名为parse的包正确地为我们做了。让我们继续安装它:

pip install parse

让我们试一下:

>>> from parse import parse
>>> result = parse("Hello, {name}", "Hello, Matthew")
>>> print(result.named)
{'name': 'Matthew'}

如您所见,它解析了字符串Hello, Matthew,并能够识别出Matthew是与我们提供的字符串{name}相对应的字符串。

让我们在我们的find_handler方法中使用它,不仅可以找到与路径对应的方法,还可以找到提供的关键字参数:

# api.py
from webob import Request, Response
from parse import parse

class API:
    ...

    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            parse_result = parse(path, request_path)
            if parse_result is not None:
                return handler, parse_result.named

        return None, None

    ...

我们仍在迭代self.routes,现在不是比较请求路径的路径,而是尝试解析它,如果有结果,我们将处理程序和关键字参数作为字典返回。现在,我们可以在handle_request内部使用这个将这些参数传递给处理程序,如下所示:

# api.py
from webob import Request, Response
from parse import parse

class API:
    ...

    def handle_request(self, request):
        response = Response()

        handler, kwargs = self.find_handler(request_path=request.path)

        if handler is not None:
            handler(request, response, **kwargs)
        else:
            self.default_response(response)

        return response

    ...

唯一的变化是,我们得到了两个handlerkwargsself.find_handler,并传递一个kwargs像这样的处理**kwargs

让我们用这种类型的路径编写一个处理程序并试一试:

# app.py
...

@app.route("/hello/{name}")
def greeting(request, response, name):
    response.text = f"Hello, {name}"

...

重启你的gunicorn访问http://localhost:8000/hello/Matthew/。你应该有这个美妙的信息: Hello, Matthew。太棒了吧?再添加几个这样的处理程序。您还可以指出给定参数的类型。例如,您可以将处理程序内的@app.route("/tell/{age:d}")参数age作为数字。

结论

这是一个漫长的旅程,但我认为这很棒。我写这篇文章时亲自学到了很多东西。如果你喜欢这篇博文,请在评论中告诉我们我们应该在框架中实现的其他功能。我在考虑基于类的处理程序,支持模板和静态文件。

Fight on!

这里查看第二部分

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,837评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,551评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,417评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,448评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,524评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,554评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,569评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,316评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,766评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,077评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,240评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,912评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,560评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,176评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,425评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,114评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,114评论 2 352

推荐阅读更多精彩内容

  • 谈论WEB编程的时候常说天天在写CGI,那么CGI是什么呢?可能很多时候并不会去深究这些基础概念,再比如除了CGI...
    __七把刀__阅读 2,195评论 2 11
  • typora-copy-images-to: ipic [TOC] 快速开始 在安装Sanic之前,让我们一起来看...
    君惜丶阅读 14,098评论 3 18
  • 快速开始 在安装Sanic之前,让我们一起来看看Python在支持异步的过程中,都经历了哪些比较重大的更新。 首先...
    hugoren阅读 19,546评论 0 23
  • [TOC]一直想做源码阅读这件事,总感觉难度太高时间太少,可望不可见。最近正好时间充裕,决定试试做一下,并记录一下...
    何柯君阅读 7,179评论 3 98
  • 2017,钱我想这么花 作为一个学生党,我没有什么收入,对于是否能够找到兼职也是未知因素,所以我是一个单纯靠父母给...
    圆弧苏酥阅读 307评论 2 2