《asyncio 系列》9. 使用基于 asyncio 实现的异步框架构建 Web 应用程序

楔子

Web 应用程序为我们今天在 Internet 上使用的大多数网站提供支持,如果你曾在拥有互联网业务的公司担任过开发人员,那么你可能在职业生涯的某个阶段编写过 Web 应用程序。在同步 Python 的世界中,这意味着你使用过 Flask、Bottle 或非常流行的 Django 之类的框架。除了新版本的 Django,这些 Web 框架并不是为开箱即用的 asyncio 而构建的。因此当 Web 应用程序执行可以并行化的工作时,例如查询数据库或调用其他 API,我们没有多线程或 multiprocessing 之外的选项,这意味着需要探索与 asyncio 兼容的新框架。

在本篇文章中,我们将学习一些流行的支持异步的 Web 框架。

使用 aiohttp 创建 REST API

之前我们使用 aiohttp 作为 HTTP 客户端向 web 应用程序发出数千个并发 web 请求,但 aiohttp 不仅支持作为 HTTP 客户端,它也具有创建 asyncio-ready Web 应用程序服务器的功能。

什么是 REST

REST 是代表性状态转移(representational state transfer)的缩写,它是现代 Web 应用程序开发中广泛使用的规范,尤其是和具有 React、Vue 等框架的单页应用程序结合使用时。REST 提供了一种独立于客户端技术的无状态、结构化方式来设计 Web API。REST API 应该能够和从手机到浏览器的任意数量的客户端进行互操作,并且只需要更改数据的客户端表示即可。

REST 中的关键概念是资源,资源通常是可以用名词表示的任何内容。例如客户、产品或账户就可以是 RESTful 资源,让我们看一下几个 REST API,从而更好地理解它们:

  • customers
  • customers/
  • customers/{id}/favorites

这里有三个 REST API 端点,第一个端点 customers 代表一组客户,作为这个 API 的使用者,我们希望它返回一个客户列表(这可能是分页的,因为可能是一个很大的集合)。第二个端点代表一个客户,并将 id 作为参数,如果使用整数 id 唯一标识客户,则调用 customers/1 将提供 id 为1 的客户的数据。最后一个端点是子实例的示例,客户可以拥有很多喜欢的产品,收藏列表成为客户的子实体,调用 customers/1/favorites 将返回 id 为 1 的客户的收藏列表。

调用 REST API 一般返回 JSON,这是典型用法,尽管我们可以选择任何适合的数据格式。REST API 有时可以通过 HTTP 标头的设定来支持多种数据表示。关于 REST 的更多细节就不深入讨论了,但阅读有关 REST 的论文能帮助我们更好地理解概念,感兴趣可以打开 http://mng.bz/1jAg 获取更多信息

aiohttp 服务器基础知识

让我们开始使用 aiohttp 创建一个简单的 hello world 风格的 API,我们将从创建个简单的 GET 端点开始,调用 /time 返回月、日和当前时间。

aiohttp 在 Web 模块中提供 Web 服务器的功能,一旦导入它,就可以使用 RouteTableDef 定义端点(更多被称为路由)。RouteTableDef 提供了一个装饰器,让我们可以指定请求类型(GET、POST 等)和表示路由名称的字符串,该装饰器所装饰的协程会在路由匹配之后执行,并将数据返回给客户端。

import datetime
import orjson
from aiohttp import web
from aiohttp.web_request import Request
from aiohttp.web_response import Response

routes = web.RouteTableDef()

@routes.get("/time")
async def time(request: Request) -> Response:
    now = datetime.datetime.now()
    result = {
        "month": now.month,
        "day": now.day,
        "time": str(now.time())
    }
    return Response(body=orjson.dumps(result), content_type="application/json")

app = web.Application()
app.add_routes(routes)
web.run_app(app, port=9999)

**比较简单,运行之后可在 Web 浏览器中访问 http://localhost:9999/time ,我们测试一下。

结这表明我们已成功地使用 aiohttp 创建了第一个路由,你可能从代码中注意到的一件事是,time 协程有一个名为 request 的参数。虽然不需要在这个例子中使用它,但它很快就会变得重要,因为此数据结构包含有关客户端发送的 Web 请求的信息,如正文、查询参数等。

@routes.get("/time")
async def time(request: Request) -> Response:
    result = {
        "request.scheme(使用的协议)": request.scheme,
        "request.secure(协议是否是 https)": request.secure,
        "request.method(请求方法)": request.method,
        "request.version(HTTP 协议版本)": str(request.version),
        "request.host(客户端请求的主机名)": request.host,
        "request.remote(发起请求的客户端的 IP 地址)": request.remote,
        "request.url(客户端请求的 URL)": str(request.url),
        "request.path(客户端请求的路径,不带查询参数)": request.path,
        "request.path_qs(客户端请求的路径,带查询参数)": request.path_qs,
        "request.query(查询参数,像字典一样操作即可)": str(request.query),
        "request.query(查询参数,一个字符串)": request.query_string,
        "request.headers(请求头,像字典一样操作即可)": str(request.headers.__class__),
        "request.keep_alive(是否开启了长连接)": request.keep_alive,
        "request.cookies(cookie 信息,像字典一样操作即可)": str(request.cookies.__class__),
        "request.content(原始的字节流信息,针对 POST 和 PUT)": (await request.content.read()).decode("utf-8"),

    }
    return Response(body=orjson.dumps(result), content_type="application/json")

我们测试一下:

具体也可以自己测试一下,Request 说完了,然后是 Response。每一个视图函数返回的都是一个 Response 对象,支持如下参数:

里面的参数应该不需要解释,见名知意。

连接到数据库并返回结果

虽然可通过 /time 路由了解基础知识,但大多数 Web 应用程序并非如此简单。我们通常需要连接到 Postgres 或 Redis 等数据库,且可能需要与其他 REST API通信(如查询或更新使用的供应商 API)。

为了解如何实现这一点,我们举一个电商的例子,具体任务是设计一个 REST API 从数据库获取现有产品,并创建新产品。

而需要做的第一件事就是创建与数据库的连接,由于希望应用程序可以并发支持许多用户,因此使用连接池而不是单个连接是最有意义的。于是现在问题就变成了:要在哪里创建和存储连接池,以便应用程序的视图函数可以使用呢?

要回答在哪里存储连接池的问题,首先需要回答更广泛的问题:共享数据应该存储在 aiohttp 应用程序中的什么位置,然后将使用此机制来保存对连接池的引用。很简单,为了存储共享数据,可以使用 aiohttp 的 Application 类实例充当字典。比如有一些共享字典,我们希望所有路由都可以访问,那么便可将其存储在 Application 对象中,如下所示:

app = web.Application()
app["shared_dict"] = {"key": "value"}

现在可通过执行 app["shared_dict"] 来访问共享字典,接下来,需要弄清楚如何从路由中访问应用程序(app)。将 app 设为全局是一种方式,但 aiohttp 通过 Request 类提供了更好的方法,路由获得的每个请求都可以通过 request.app 引用应用程序实例,从而能够轻松访问任何共享数据。例如获取共享字典,并将其作为响应返回,如下所示:

@routes.get("/")
async def get_data(request: Request) -> Response:
    shared_data = request.app["shared_dict"]
    return Response(body=orjson.dumps(shared_data), 
                    content_type="application/json")

后续我们将使用这个方式来存储和检索数据库连接池,现在要决定创建连接池的最佳位置。当创建应用程序实例(app)时,不能轻易做到这一点,因为这发生在所有协程定义之外,而且不能使用所需的 await 表达式。

aiohttp 在应用程序实例上提供了一个信号处理程序,用于处理类似于 on_startup 的设置任务,可将其看作启动应用程序时将执行的协程列表,通过调用 app.on_startup.append(coroutine) 来添加在启动时运行的协程。每个附加到 on_startup 的协程都只有一个参数:Application 实例。一旦实例化了数据库池,就可将数据库池存储在传递给这个协程的应用程序实例中。

此外还需要考虑当 Web 应用程序关闭时会发生什么,我们希望在关闭应用程序时主动关闭和清理数据库连接。否则可能会留下空闲的连接,给数据库带来不必要的压力。因此 aiohttp 还提供了第二个信号处理程序:on_cleanup,该处理程序中的协程将在应用程序关闭时运行,这使我们能够轻松地关闭连接池。它的行为类似于 on_startup 处理程序,因为我们只是用特定的协程调用 append。

import datetime
import orjson
from aiohttp import web
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlalchemy.engine.url import URL
from sqlalchemy import text, Table, Column, MetaData
from sqlalchemy.dialects.mysql import INTEGER, VARCHAR

routes = web.RouteTableDef()

async def create_database_engine(app: web.Application):
    # 创建数据库池(在 sqlalchemy 中称为引擎),并将其存储在应用程序实例中。
    engine = create_async_engine(
        URL.create("mysql+asyncmy", username="root", password="123456",
                   host="82.157.146.194", port=3306, database="mysql")
    )
    app["engine"] = engine

async def destroy_database_engine(app: web.Application):
    engine: AsyncEngine = app["engine"]
    # 关闭引擎以及连接池,并清理所有的连接
    await engine.dispose()

@routes.get("/girl")
async def get_girls(request: Request) -> Response:
    # 获取引擎
    engine: AsyncEngine = request.app["engine"]
    # 构建查询 SQL
    query = text("SELECT name, age, address FROM girl")
    # 从池子里面拿出一个连接,执行查询
    async with engine.connect() as conn:
        rows = await conn.execute(query)
    # 拼接成字典
    keys = rows.keys()
    results = [dict(zip(keys, row)) for row in rows]
    return Response(body=orjson.dumps(results), content_type="application/json")

@routes.post("/girl")
async def post_girls(request: Request) -> Response:
    table = Table("girl", MetaData(),
                  Column("id", INTEGER, primary_key=True, autoincrement=True),
                  Column("name", VARCHAR(255)),
                  Column("age", INTEGER),
                  Column("address", VARCHAR(255)))
    engine: AsyncEngine = request.app["engine"]
    # 获取请求体,必须是一个 JSON
    content = await request.content.read()
    try:
        data = orjson.loads(content)
    except orjson.JSONDecodeError:
        return Response(body=orjson.dumps({"error": "请求体必须是一个 JSON"}),
                        content_type="application/json")
    # 需要对 data 里面的字段做一个检测,这里就不检测了
    query = table.insert().values(data)
    async with engine.connect() as conn:
        await conn.execute(query)
        await conn.commit()

    return Response(body=orjson.dumps({"message": "数据添加成功"}),
                    content_type="application/json")

app = web.Application()
# 应用程序(app)启动时会调用 create_database_engine 执行,以 app 为参数
# 在里面我们创建引擎,并保存在 app 中
app.on_startup.append(create_database_engine)
# 应用程序关闭时会调用 destroy_database_engine,在里面我们释放引擎
app.on_cleanup.append(destroy_database_engine)
app.add_routes(routes)
web.run_app(app, port=9999)

我们来测试一下:

import requests

print(
    requests.get("http://localhost:9999/girl").json()
)  # []
print(
    requests.post("http://localhost:9999/girl",
                  json={"name": "古明地觉", "age": 17, "address": "地灵殿"}).json()
)  # {'message': '数据添加成功'}
print(
    requests.get("http://localhost:9999/girl").json()
)  # [{'name': '古明地觉', 'age': 17, 'address': '地灵殿'}]

结果没有问题。

比较 aiohttp 和 Flask

使用 aiohttp 和支持 asyncio 的 Web 框架为我们提供了异步操作数据库的优势,那除了使用 asyncio 库,aiohttp 之类的异步框架与 Flask 之类的同步框架相比有什么优势吗?

虽然它高度依赖于服务器配置、数据库硬件和其他因素,但基于 asyncio 的应用程序可以用更少的资源获得更好的吞吐量。在同步框架中,每个请求处理程序从头到尾不间断地运行。但在异步框架中,当 await 表达式暂停执行时,它们让框架有机会处理其他工作,从而提高效率。

为了测试这一点,我们将刚才的代码使用 Flask 重新编写一下(代码就不贴了),只重写 get 请求。然后运行应用程序,Flask 带有一个开发服务器,但它还没达到生产水平,只用于测试,所以不能进行公平的比较,特别是它只运行一个进程(这意味着一次只能外理一个请求)。因此需要使用一个生产 WSGI 服务器来测试这一点,本例中使用 Gunicorn,你也可选择其他服务器。

gunicorn -w 8 main:app

执行之后,你会看到有 8 个 worker 启动,这意味着会创建 8 个和数据库的连接,可同时处理 8 个请求。

然后我们使用 wrk 工具进行测试,该工具的使用方式可以通过 https://github.com/wg/wrk 查看。具体测试过程不贴了,直接说结论:使用 aiohttp 每秒能处理 1500 个请求,大约是 Flask 的三倍。更重要的是,aiohttp 只使用了一个进程,而 Flask 则需要八个进程才能达到 aiohttp 三分之一的并发量。当然啦,我们也可以在 aiohttp 的前面再搭一层 Nginx,并启动更多工作进程来进一步提高 aiohttp 的性能。

我们现在知道了如何使用 aiohttp 构建支持数据库的 Web 应用程序的基础知识,在 Web 应用领域,aiohttp 的特别之处在于它本身就是一个 Web 服务器,它不符合 WSGI,可独立存在。

补充:可能有人分不清 WSGI、uwsgi、uWSGI、Nginx 之间的区别,我们来总结一下:

  • WSGI 的全称是 Web Server Gateway Interface(Web服务器网关接口),它不是服务器、Python 模块、框架、API 或者任何软件,只是一种描述 Web 服务器和 Web 应用程序(使用 Web 框架,如 Django、Flask 编写的程序)进行通信的规范、协议。使用任何一个框架在编写完服务的时候都必须运行在 Web 服务器上,所以这两者必须遵循相同的通信规范,而这个规范就是 WSGI。另外这些框架本身自带了一个小型 Web 服务器,但只用于开发和测试
  • uWSGI 是一个 Web 服务器,它实现了 WSGI、uwsgi、HTTP 等协议,所以我们把使用 Web 框架编写好的服务部署在 uWSGI 服务器上是可以直接对外提供服务的
  • Nginx 同样是一个 Web 服务器,但它相比 uWSGI 可以提供更多的功能,比如反向代理、负载均衡、缓存静态资源、对 HTTP 请求更加友好,这些都是 uWSGI 所不具备、或者不擅长的。所以我们在将 Web 服务部署在 uWSGI 之后,还要在前面再搭一层 Nginx。此时 uWSGI 就不再暴露 HTTP 服务了,而是暴露 TCP 服务,因为它是和 Nginx 进行通信,使用 TCP 会更快一些,Nginx 来对外暴露 HTTP 服务
  • uwsgi 是 Nginx 和 uWSGI 通信所采用的协议,我们说 uWSGI 是和 Nginx 对接,Nginx 接收到用户请求时,如果是请求的是静态资源、那么会直接返回;请求的是动态资源,那么会将请求转发给 uWSGI,然后再由 uWSGI 调用相应的 Web 服务进行处理,处理完毕之后将结果交给 Nginx,Nginx 再返回给客户端。而 uWSGI 和 Nginx 之所以能交互,也正是因为它们都支持 uwsgi 协议,Nginx 中 HttpUwsgiModule 的作用就是与 uWSGI 服务器进行交互。

说回 WSGI,我们说 aiohttp 不符合 WSGI,但它符合 ASGI。下面让我们了解 ASGI 的工作原理,并看看如何将 aiohttp 与一个名为 Starlette 的符合 ASGI 的框架一起使用。

异步服务器网关接口

当前面的示例中使用 Flask 时,我们通过 Gunicorn WSGI 服务器为应用程序提供服务,WSGI 是一种将 Web 请求转发到 Web 框架(如 Flask 或 Django)的标准化方法。虽然有许多 WSGI 服务器,但它们并非旨在支持异步工作负载,因为 WSGI 规范早于 asyncio 出现。而随着异步 Web 应用程序的使用越来越广泛,一种从服务器抽象框架的方法被证明是必要的,因此创建了异步服务器网关接口(Asynchronous Server Gateway Interface,ASGI)。ASGI 是互联网领域中一个较新的概念,但已经有若干支持它的流行框架,包括 Django、FastAPI 等等。

ASGI 与 WSGI 比较

WSGI 诞生于一个支离破碎的 Web 应用框架,在 WSGI 之前,选择一个框架可能会限制 Web 服务器的可用接口的种类,因为两者之间没有标准化的接口,而 WSGI 通过提供一个简单的 API 让 Web 服务器与 Python 框架对话来解决这个问题。2004 年,随着 PEP-333 的出现,现已成为 Web 应用程序部署的实际标准。

然而当涉及异步工作负载时,WSGI 就不起作用了。WSGI 规范的核心是一个简单的 Python 函数,下面是我们可以构建的最简单的 WSGI 应用程序。

# mian.py
def application(env, start_response):
    print(env)
    start_response("200 OK", [("Content-Type", "text/html")])
    return [b"WSGI hello!"]

可通过 gunicorn main 来运行这个应用程序,并通过 http://127.0.0.1:8000 进行测试。

注:gunicorn 不支持 Windows。

可能有人好奇,我们只需要定义一个 application 函数就可以启动了吗?答案是的,WSGI 规范要求 Python 应用程序必须实现一个名为 application 的可调用对象,这个可调用对象需要接受两个参数:一个是包含 HTTP 请求信息的字典对象,另一个是一个用于发送响应的回调函数。应用程序需要从请求字典中获取请求信息,然后使用回调函数将响应返回。

而服务器会自动调用 application,这是双方约定好的,而这便由 WSGI 协议所规范。

[root@satori ~]# curl http://127.0.0.1:8000
WSGI hello!

然后我们看打印 env 输出了什么?

里面是一些请求信息,但整个过程也如我们看到的那样,没有地方让我们使用等待。此外,WSGI 只支持请求/响应生命周期,这意味着它不能与长生命周期的连接协议(如 WebSocket)一起工作。

ASGI 通过重新设计 API,使用协程解决了这个问题,它是 Python 异步 Web 应用程序和服务器之间的接口规范,是对 WSGI 规范的扩展和补充,旨在解决 WSGI 无法有效处理异步 IO 操作的问题。与 WSGI 不同,ASGI 支持异步的应用程序和服务器,可以处理非阻塞式 IO 操作,包括长轮询、WebSockets、HTTP/2 等。它提供了一种标准化的接口,使得 Web 服务器和 Python 框架之间能以异步方式进行通信。

ASGI 还规定了一些标准化的环境变量和协议,以确保不同的 Web 服务器和 Python 框架可以协同工作。与 WSGI 类似,ASGI 允许开发人员选择不同的 Web 服务器和 Python 框架,并且它们仍然可以互相兼容。

总之 ASGI 和 WSGI 干的事情是一样的,但 ASGI 在 WSGI 之上做了很多的扩展,让我们将上面的 WSGI 示例转换为 ASGI。

async def application(scope, receive, send):
    await send(
        {"type": "http.response.start",
         "status": 200,
         "headers": [(b"Content-Type", b"text/html")]}
    )
    await send({"type": "http.response.body", "body": b"ASGI hello!"})

ASGI 应用程序函数具有三个参数:作用域字典、接收数据的 receive 协程和发送数据的 send 协程。在我们的示例中,发送 HTTP 响应的开头,然后是正文。

那么现在如何为上述应用程序提供服务呢?和 WSGI 服务器 gunicorn 一样,我们显然需要一个 ASGI 服务器。目前最流行的 ASGI 服务器是 Uvicorn,它建立在 uvloop 和 httptools 之上,其中 uvloop 是 asyncio 事件循环的快速 C 实现(后续详细介绍),httptools 是一个高性能的 HTTP 解析库。可通过运行 pip install uvicorn 命令来安装 uvicorn。

uvicorn 支持 Windows,如果是 Windows,会将事件循环退化为 asyncio。而 Linux 平台,则是会使用 uvloop 提供的高性能事件循环。

然后通过 uvicorn main:application 来运行应用程序。

如果访问 http://localhost:8000,可以看到输出的消息。虽然这里直接使用 uvicorn 进行测试,但最好将 uvicorn 与 gunicorn 一起使用,因为 gunicorn 会在 worker 崩溃时自动重启,在后续我们会见到。

现在我们了解了 ASGI 的基础知识以及它与 WSGI 的比较,但学到的东西非常初级。uvicorn 是一个 ASGI 服务器,我们还需要一个 ASGI 框架,而不是在文件里面只写一个 application 协程(当然 ASGI 框架也是在此之上一点点搭起来的)。目前的 ASGI 框架有很多,目前流行的 ASGI 框架是 Starlette 和 FastAPI,其中 FastAPI 是构建在 Starlette 之上的。

ASGI 和 FastAPI

FastAPI 是一个轻量级的 ASGI 框架/工具包,是用 Python 构建异步 Web 服务的理想选择,它提供了非常优秀的性能、WebSocket 支持等,你可在 https://fastapi.tiangolo.com/ 上面查看相关文档。让我们看看如何使用它来实现简单的 REST 和 WebSocket 端点,首先用下面的命令安装它:pip install 'fastapi[all]'

使用 FastAPI 的 REST 端点

我们将之前使用 aiohttp 编写例子,用 FastAPI 重写一下。

from functools import partial
import orjson
from fastapi import FastAPI, APIRouter
from fastapi.requests import Request
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlalchemy.engine.url import URL
from sqlalchemy import text, Table, Column, MetaData
from sqlalchemy.dialects.mysql import INTEGER, VARCHAR
import uvicorn

router = APIRouter()

async def create_database_engine(app: FastAPI):
    engine = create_async_engine(
        URL.create("mysql+asyncmy", username="root", password="123456",
                   host="82.157.146.194", port=3306, database="mysql")
    )
    # 实例化 FastAPI 即可创建一个 app 实例,该实例内部提供了一个 state 属性
    # 专门用来存储自定义的共享属性
    app.state.engine = engine

async def destroy_database_engine(app: FastAPI):
    engine: AsyncEngine = app.state.engine
    await engine.dispose()

# 可以直接通过 @app.get 进行注册,但如果路由很多,而且在不同的目录和文件中,导入 app 就不太方便
# 因此我们也可以实例化 APIRouter 进行路由注册,每个文件都有自己的 router = APIRouter()
# 然后再统一通过 app.include_router(router) 将路由包含在 app 当中,整个过程就类似于 Flask 的蓝图
# 其实 app 内部也是实例化了 APIRouter 对象,@app.get 等价于 @app.router.get
@router.get("/girl")
async def get_girls(request: Request) -> Response:
    engine: AsyncEngine = request.app.state.engine
    query = text("SELECT name, age, address FROM girl")
    async with engine.connect() as conn:
        rows = await conn.execute(query)
    keys = rows.keys()
    results = [dict(zip(keys, row)) for row in rows]
    return Response(content=orjson.dumps(results), media_type="application/json")

@router.post("/girl")
async def post_girls(request: Request) -> Response:
    table = Table("girl", MetaData(),
                  Column("id", INTEGER, primary_key=True, autoincrement=True),
                  Column("name", VARCHAR(255)),
                  Column("age", INTEGER),
                  Column("address", VARCHAR(255)))
    engine: AsyncEngine = request.app.state.engine
    # 在 FastAPI 中,请求体通过 await request.body() 获取
    content = await request.body()
    try:
        data = orjson.loads(content)
    except orjson.JSONDecodeError:
        return Response(content=orjson.dumps({"error": "请求体必须是一个 JSON"}),
                        media_type="application/json")

    query = table.insert().values(data)
    async with engine.connect() as conn:
        await conn.execute(query)
        await conn.commit()

    return Response(content=orjson.dumps({"message": "数据添加成功"}),
                    media_type="application/json")

app = FastAPI()
# 注意:FastAPI 在调用 create_database_engine 时不会自动传递 app,因此需要使用偏函数
app.router.on_startup.append(partial(create_database_engine, app))
app.router.on_shutdown.append(partial(destroy_database_engine, app))
app.include_router(router)

if __name__ == "__main__":
    uvicorn.run("main:app")

整个过程和 aiohttp 是很相似的,但也有一些细微的差异,我们来测试一下:

import requests

print(requests.get("http://localhost:8000/girl").json())  # []

print(
    requests.post("http://localhost:8000/girl",
                  json={"name": "魔理沙", "age": 18, "address": "魔法森林"}).json()
)  # {'message': '数据添加成功'}

print(requests.get("http://localhost:8000/girl").json())
"""
[{'name': '古明地觉', 'age': 17, 'address': '地灵殿'},
 {'name': '魔理沙', 'age': 18, 'address': '魔法森林'}]
"""

整个过程没有任何问题,另外异步框架我非常推荐 FastAPI。

WebSocket 与 FastAPI

在传统的 HTTP 请求中,客户端向服务器发送一个请求,服务器返回一个响应,然后结束事务。如果想构建一个无需用户刷新即可更新的网页怎么办?例如,可能需要一个实时计数器来显示网站上当前有多少用户,虽然可通过轮询的方式执行此操作,比如每隔几秒访问一次端点,用最新结果更新页面。但这种做法给 Web 服务器带来了额外的负载,每个请求和响应周期都需要额外的时间和资源。因为用户数可能不会在请求之间发生变化,从而导致系统因没有新信息而承受压力(可以通过缓存来缓解这种情况,但重点仍然存在,并且缓存引入了其他复杂性和开销)。HTTP 轮询就相当于汽车后座上的孩子反复询问:我们到地方了吗?

WebSocket 提供了 HTTP 轮询的另一种选择,与 HTTP 那样的请求/响应周期不同,我们可以建立一个持久套接字,然后通过该持久套接字自由发送数据。这个套接字是双向的,这意味着既可向服务器发送数据,也可从服务器接收数据,而不必每次都经历整个 HTTP 请求的生命周期。所以我们不需要重复请求,从而避免了创建额外的负载,并避免了接收到陈旧的数据。

FastAPI 使用易于理解的方法为 WebSocket 提供了开箱即用的支持,为了解这一点,我们来构建一个简单的 WebSocket 端点。

from fastapi import FastAPI
from fastapi.websockets import WebSocket, WebSocketDisconnect
import uvicorn

app = FastAPI()

@app.websocket("/ws")
async def ws(websocket: WebSocket):
    await websocket.accept()
    while True:
        # websocket.receive_bytes()
        # websocket.receive_json()
        try:
            data = await websocket.receive_text()
        except WebSocketDisconnect:
            return
        await websocket.send_text(f"收到来自客户端的回复: {data}")

# 这里补充一下 FastAPI 中,路由注册的几种方式,@app.get 本质上是一个语法糖
# 方法一:
"""
@app.get("/index")
async def index():
    pass

# 如果是 WebSocket,那么就通过 @app.ws("/ws") 注册
"""

# 方法二:
"""
async def index():
    pass
    
app.add_api_route("/index", index, methods=["GET"]) 
# 如果是 WebSocket,那么就通过 app.add_api_websocket_route("/ws", ws) 注册
"""

# 方法三:
"""
async def index(request: Request):
    return Response()

from starlette.routing import Route, WebSocketRoute
route = Route("/index", index, methods=["GET"], name="路由名字")
# 如果是 WebSocket,那么通过 ws_router = WebSocketRoute("/ws", ws)
# app.routes 是一个列表,保存了所有的路由(route,绑定的 URL 到视图函数的映射)
# 直接将新创建的路由 append 进去
app.routes.append(route)    
"""
# FastAPI 只是相当于在 Starlette 的基础上套了一层壳,让我们使用起来更方便了
# 但里面的路由匹配,请求响应都是直接用的 Starlette,数据验证则是基于 Pydantic
# 所以一个路由就是一个 starlette.routing.Route 对象,我们可以直接基于 Route 对象创建
# 但直接用 Route 对象创建(方法三)的话,会有一个问题,就是视图函数必须精确接收一个 Request 对象,返回一个 Response 对象
# 而前两种方式要求则不那么严格,FastAPI 会帮我们处理好参数以及返回值相关的问题

if __name__ == "__main__":
    uvicorn.run("main:app")

服务端代码编写完毕,非常简单,就是客户端发一句服务端回一句,但连接是长连接,不会断开。我们再编写一个前端页面,和后端的 WebSocket 进行通信。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <div id="box">
        <input style="width: 400px;" type="text" id="message">
        <input type="submit" id="submit" value="发送">
        <p>服务端返回的信息会展示在下方</p>
        <label>
            <textarea style="width: 400px; height: 200px;"></textarea>
        </label>
    </div>
    <script>
        ws = new WebSocket("ws://localhost:8000/ws");

        //如果连接成功, 会打印下面这句话, 否则不会打印
        ws.onopen = function () {
            let textarea = document.querySelector("#box textarea")
            textarea.value = "和服务端成功建立 WebSocket 连接\n"
        };

        //接收数据, 服务端有数据过来, 会执行
        ws.onmessage = function (event) {
            let textarea = document.querySelector("#box textarea")
            textarea.value += event.data + "\n"
        };

        //发送数据
        let submit = document.querySelector("#box #submit")
        submit.onclick = function () {
            let value = document.querySelector("#box #message").value
            ws.send(value)
        }
        //服务端主动断开连接, 会执行
        //客户端主动断开的话, 不执行
        ws.onclose = function () {  }

    </script>
</body>
</html>

用浏览器打开 html 文件,测试一下:

结果正常,没有任何问题,还是蛮有趣的。目前我们已经了解了 FastAPI 中的 WebSocket,下面来实现一个复杂一点功能。我们计划保留所有已连接客户端 WebSocket 连接,当新客户端连接时,会将它们添加到列表中,并将新的用户计数发送给列表中的所有客户端。当客户端断开连接时,会将它们从列表中删除,并更新所有客户端,使它们具有最新的在线计数。还将添加一些基本的错误处理,如果发送消息导致异常发生,我们将从列表中删除客户端。

import asyncio
from fastapi import FastAPI, APIRouter
from fastapi.websockets import WebSocket, WebSocketDisconnect
from fastapi.requests import Request
from fastapi.responses import Response
import uvicorn
import orjson

class UserCounter:

    clients = []

    async def on_connect(self, ws: WebSocket):
        await ws.accept()
        print(f"连接已成功建立")
        # 当新客户端连接时,将其添加到 clients 中
        self.clients.append(ws)
        # 并通知其它用户新的在线计数
        await self._send_count()

    async def on_disconnect(self, ws: WebSocket):
        # 当客户端断开连接时,将其从 clients 中移除
        self.clients.remove(ws)
        # 并通知其它用户新的在线计数
        await self._send_count()

    async def on_receive(self, ws: WebSocket):
        # 当客户端发消息过来时,执行此方法
        return await ws.receive_text()

    async def on_send(self, ws: WebSocket, message: str):
        # 向客户端发消息时,执行此方法
        await ws.send_text(message)

    async def _send_count(self):
        count = len(self.clients)
        if count == 0:
            return
        message = f"当前在线人数: {count}"
        task_to_client = {asyncio.create_task(self.on_send(ws, message)): ws for ws in self.clients}
        done, pending = await asyncio.wait(task_to_client)
        # 任务正常结束和出现异常都表示任务完成,如果要是出现异常,那么就将连接移除
        for task in done:
            if task.exception() is not None:
                self.clients.remove(task_to_client[task])

    async def deal_ws_conn(self, ws: WebSocket):
        await self.on_connect(ws)
        while True:
            try:
                data = await self.on_receive(ws)
            except WebSocketDisconnect:
                return await self.on_disconnect(ws)
            await self.on_send(ws, f"收到来自客户端的回复: {data}")

app = FastAPI()
app.add_api_websocket_route("/ws", UserCounter().deal_ws_conn)

if __name__ == "__main__":
    uvicorn.run("main:app")

FastAPI 不支持 CBV,这里我们相当于曲线救国,定义了一个类,但绑定的时候用的还是函数。来测试一下,我们打开刚才编写的 HTML,连续打开 3 个页面:

功能没有问题,每来一个连接就广播一条消息,把最新的在线人数发给所有连接。

但需要注意的是,uvicorn.run 在启动应用的时候,可以传递一个 worker 参数,表示工作进程的数量,默认为 1。由于当前只有一个 worker,所以数据是没有问题的,但如果使用多个 worker,那么每个 worker将有自己的套接字列表,此时再按照之前的方式更新就不对了。正确的做法应该是将计数存储在 Redis 中,然后监视相应的 key,如果变化就广播给所有连接。

小结

在本篇文章中,我们学习了如下内容:

  • 创建基本的 RESTful API,这些 API 使用 aiohttp 和 sqlalchemy 连接到数据库;
  • 使用 FastAPI 创建符合 ASGI 的 Web 应用程序;
  • 使用带有 FastAPI 的 WebSocket 来构建具有最新信息的 Web 应用程序,而不需要 HTTP 轮询;
posted @ 2023-05-09 23:04  古明地盆  阅读(793)  评论(0编辑  收藏  举报