Python 现代开发学习笔记

(十七)把请求处理组合成完整应用

1. 应用对象保存配置,入口按请求调用

现在各个职责已经有对应函数,把它们组成一个 BookApp:

# 项目文件:web_project/src/webbook/app.py
# WSGI入口组合请求、路由、事务和响应;数据库异常回滚,客户端收到可识别的失败状态。
import logging
import sqlite3
import time
from pathlib import Path

from .cors import CorsPolicy
from .database import connect
from .http import Problem, Request, Response, one_value, read_form, read_json
from .render import render_home, render_login
from .routing import route
from .security import (
    authenticate,
    load_session,
    new_session,
    require_csrf,
    require_user,
    rotate_session,
    session_cookie,
)
from .service import as_book, create_book, finish_book, list_books, owned_book
from .validation import form_book, validate_book

STATIC = Path(__file__).resolve().parent / "static"
logger = logging.getLogger(__name__)


class BookApp:
    def __init__(
        self,
        database,
        origin="http://127.0.0.1:8767",
        cors_origins=None,
        secure_cookie=False,
    ):
        self.database, self.origin = database, origin
        self.cors = CorsPolicy(set(cors_origins or []))
        self.secure_cookie = secure_cookie
        self.now = time.time

实例保存数据库位置、应用来源、跨源许可与 Cookie 配置,now 提供当前时间。实际请求调用 __call__,沿用第四章可调用对象的知识:服务器可以像调用函数一样调用这个应用实例。

# 续写同一文件:web_project/src/webbook/app.py
    # 先处理预检和静态文件,再分发业务请求;异常也统一转换成可发送的响应。
    def __call__(self, environ, start_response):
        request = None
        try:
            request = Request.read(environ)
            response = self.cors.preflight(request)
            if response is None:
                handler, parameters, _ = route(request.method, request.path)
                if handler == "asset":
                    name = parameters["name"]
                    kind = "text/css" if name.endswith("css") else "text/javascript"
                    response = Response.text(
                        (STATIC / name).read_text(encoding="utf-8"),
                        kind=kind + "; charset=utf-8",
                    )
                else:
                    response = self.handle(request, handler, parameters)
        except Problem as error:
            response = self.problem_response(
                request, error, environ.get("PATH_INFO", "")
            )
        except sqlite3.Error:
            logger.exception("书籍请求的数据库操作失败")
            response = self.problem_response(
                request, Problem(500, "本次操作未完成,请稍后重试")
            )
        if request is not None:
            self.cors.decorate(request, response)
        return response.send(start_response)

先读取请求并处理预检,再匹配路由;静态资源读出文件,业务请求进入 handle。已知 Problem 转为对应响应;数据库异常记录完整服务器日志,再交回不包含内部路径的 500。

# 续写同一文件:web_project/src/webbook/app.py
    # API 返回 JSON 错误对象,页面返回可读的 HTML 提示;两者保留相同状态码。
    def problem_response(self, request, error, fallback_path=""):
        path = request.path if request is not None else fallback_path
        if path.startswith("/api/"):
            return Response.json(
                {"error": error.message, "fields": error.fields},
                error.status,
                error.headers,
            )
        from html import escape

        return Response.text(
            f'<h1>{error.status}</h1><p>{escape(error.message)}</p><a href="/">返回书架</a>',
            error.status,
            headers=error.headers,
        )

API 错误用 JSON 保留 error 与 fields,页面错误用 HTML 显示说明;解析请求时失败,也按原来的路径选择响应形式。

应用先解析预检匹配路径,静态文件直接形成响应,业务进入会话事务,成功提交或失败回滚后再发回字节

2. 会话、动作与事务在一条请求中结束

# 续写同一文件:web_project/src/webbook/app.py
    def handle(self, request, handler, parameters):
        now = int(self.now())
        with connect(self.database) as connection:
            session = load_session(connection, request.headers.get("cookie", ""), now)
            fresh = session is None
            session = session or new_session(connection, now)
            if handler in {"home", "login_page", "session_info"}:
                response = self.read_action(connection, session, handler)
            elif handler == "login":
                response, updated = self.login_action(connection, request, session, now)
                fresh = fresh or updated.sid != session.sid
                session = updated
            elif handler == "logout":
                require_user(session)
                form = read_form(request)
                require_csrf(
                    request,
                    session,
                    one_value(form, "csrf_token"),
                    self.cors.origins | {self.origin},
                )
                session = rotate_session(connection, session, now)
                fresh, response = True, Response.redirect("/")
            else:
                require_user(session)
                response = self.book_action(
                    connection, request, session, handler, parameters
                )
        # 事务已经提交,才把新会话编号交回浏览器。
        if fresh:
            response.headers.append(session_cookie(session, now, self.secure_cookie))
        return response

handle 在本次连接中取得会话,再按动作处理页面、登录、退出或书籍。响应对象先形成,事务退出完成提交,随后才把新会话 Cookie 放进响应;若数据库操作失败,异常先让事务回滚,再由外层入口处理。

# 续写同一文件:web_project/src/webbook/app.py
    # 读取页面时只查当前账户的书籍;登录成功后替换会话,再跳转回书架。
    def read_action(self, connection, session, handler):
        if handler == "login_page":
            return Response.text(render_login(session))
        if handler == "session_info":
            return Response.json(
                {"username": session.username, "csrf_token": session.csrf}
            )
        books = (
            list_books(connection, session.user_id)
            if session.user_id is not None
            else []
        )
        return Response.text(render_home(session, books))

    def login_action(self, connection, request, session, now):
        form = read_form(request)
        require_csrf(
            request,
            session,
            one_value(form, "csrf_token"),
            self.cors.origins | {self.origin},
        )
        try:
            user = authenticate(
                connection, one_value(form, "username"), one_value(form, "password")
            )
        except Problem as error:
            response = Response.text(
                render_login(session, error.message, one_value(form, "username")),
                error.status,
            )
            return response, session
        return Response.redirect("/"), rotate_session(connection, session, now, user)

read_action 只读取页面或会话信息。login_action 先核对表单令牌,再核对密码;失败时保留原会话并显示账号,成功时交回新会话与重定向。

3. 两种输入最终进入同一个新增动作

# 续写同一文件:web_project/src/webbook/app.py
    # 先按操作识别表单或 JSON,再核对 CSRF、校验数据并调用书籍服务。
    def book_action(self, connection, request, session, handler, parameters):
        if handler == "book_list":
            flag = one_value(request.query, "finished", "all")
            if flag not in {"all", "0", "1"}:
                raise Problem(422, "finished查询应为all、0或1")
            books = list_books(
                connection, session.user_id, None if flag == "all" else int(flag)
            )
            return Response.json({"items": books})
        if handler == "book_detail":
            return Response.json(
                as_book(owned_book(connection, parameters["book_id"], session.user_id))
            )
        form = read_form(request) if handler == "create_form" else None
        token = (
            one_value(form, "csrf_token")
            if form is not None
            else request.headers.get("x-csrf-token", "")
        )
        require_csrf(request, session, token, self.cors.origins | {self.origin})
        if handler == "finish":
            return Response.json(
                finish_book(connection, session.user_id, parameters["book_id"])
            )
        try:
            draft = (
                form_book(form)
                if form is not None
                else validate_book(read_json(request))
            )
            book = create_book(connection, session.user_id, draft)
        except Problem as error:
            if form is None:
                raise
            values = {
                key: one_value(form, key) for key in ("title", "tags", "finished")
            }
            message = error.message + ";" + ";".join(error.fields.values())
            return Response.text(
                render_home(
                    session, list_books(connection, session.user_id), message, values
                ),
                error.status,
            )
        if form is not None:
            return Response.redirect("/")
        return Response.json(book, 201, [("Location", f"/api/books/{book['id']}")])

列表查询与详情读取各自处理自己的输入;写入先核对会话令牌。表单走 form_book,JSON 走 read_json 与 validate_book,随后都调用 create_book。

表单校验失败时把输入与字段说明放回页面,让读者修改;JSON 失败则继续交给统一错误响应。成功后,表单返回 303,API 返回 201 与 Location。两种响应形式共用同一条书籍记录。

4. 命令入口连接服务器与应用

# 项目文件:web_project/src/webbook/cli.py
# 初始化演示账号与采集CSV;开发服务器把HTTP请求转换成WSGI调用。
import argparse
import logging
from pathlib import Path
from wsgiref.simple_server import make_server

from .app import BookApp
from .database import initialize


def main(argv=None):
    parser = argparse.ArgumentParser(description="Web共同基础:阅读书架")
    parser.add_argument("action", choices=["init", "serve"], nargs="?", default="serve")
    parser.add_argument("--db", type=Path, default=Path("data/library.sqlite3"))
    parser.add_argument("--csv", type=Path, default=Path("data/books.csv"))
    parser.add_argument("--port", type=int, default=8767)
    parser.add_argument("--cors-origin", action="append", default=[])
    args = parser.parse_args(argv)
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    if args.action == "init":
        initialize(args.db, args.csv)
        print("已初始化alice、bob和采集书目:", args.db)
        return 0
    origin = f"http://127.0.0.1:{args.port}"
    application = BookApp(args.db, origin, args.cors_origin)
    with make_server("127.0.0.1", args.port, application) as server:
        print("打开 " + origin, flush=True)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            print("服务器已停止")
    return 0

init 建立账号和采集记录,serve 构造 BookApp,再把实例交给 make_server。origin 从本次启动配置取得,写入来源核对不会随任意 Host 请求头改变。

# 项目文件:web_project/src/webbook/__init__.py
# 导入只定义包,不启动服务器、不创建数据库。
"""Web应用共同基础练习。"""
# 项目文件:web_project/src/webbook/__main__.py
# 模块入口和web-books调用同一个main。
from .cli import main

raise SystemExit(main())

导入模块只取得定义,调用命令才运行初始化或启动服务器。数据库文件保存记录与会话,重开应用仍能从同一份文件读取。

笔记正文与全部题解 · Ctrl / ⌘ K