详解 Python 中速度最快的 ASGI 框架 blacksheep,让你的程序快速闪电

楔子

最近在我的交流群里面,大家聊到了 Python 的异步框架,并有人给出了一个网站的 benchmark。


Python 异步框架还真不少,其中大家最熟悉的莫过于 FastAPI,只是它的并发量其实没有想象中的那么高。但宣传的很到位,加上生态不错,之前一直是我的第一选择。不过排名第一的 blacksheep 框架吸引了我的注意,这玩意我之前压根就没听说过,为了搞清楚它并发量为什么这么高,于是安装了一下,结果发现大部分代码都是基于 Cython 编写的。最关键的是,它在使用上和 FastAPI 具有很高的相似性,所以本次就来聊一聊这个 blacksheep 框架,看看它的用法。

使用之前先安装:直接 pip install 'blacksheep[full]' uvicorn 即可。

blacksheep 要求 Python 版本不低于 3.8。

路由注册

我们来使用 blacksheep 编写一个简单的应用程序:

from blacksheep import Application
import uvicorn

# 和 FastAPI 一样,先创建一个 app
app = Application()

# 通过装饰器的方式注册路由
# 如果 methods 参数不指定,默认为 ["GET"],表示只接收 GET 请求
@app.route("/index", methods=["GET"])
async def index():
    return "Hello World"

# 在 Windows 中必须加上 if __name__ == "__main__"
# 否则会抛出 RuntimeError: This event loop is already running
if __name__ == "__main__":
    # 启动服务,因为我们这个文件叫做 main.py
    # 所以需要启动 main.py 里面的 app
    # 第一个参数 "main:app" 就表示这个含义
    # 然后是 host 和 port 表示监听的 ip 和端口
    uvicorn.run("main:app", host="0.0.0.0", port=5555)

整个过程很简单,然后我们在浏览器中输入 localhost:5555/index 就会显示相应的输出。不难发现,代码和 FastAPI 非常相似,就连启动方式也是一样,因为它们都是 ASGI 框架,都可以搭配 ASGI 服务器 uvicorn。

需要注意:如果你是通过 app 注册路由,那么只能用 @app.route,我们看一下它的源码。

因此我们在代码中也可以这么注册:

from blacksheep import Application
import uvicorn

app = Application()

async def index():
    return "Hello World"

app.router.add("GET", "/index", index)

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=5555)

效果是一样的,可以通过 @app.route 的方式注册路由,也可以先通过 app.router 拿到路由器,然后调用它的 add 方法。

另外一个视图函数也可以支持多种请求,比如:

app = Application()

@app.route("/index", methods=["GET", "POST"])
async def index():
    return "Hello World"

# 如果不使用装饰器的话,也可以手动注册
"""
app.router.add("GET", "/index", index)
app.router.add("POST", "/index", index)
"""

然后路由注册还有几种方式:

所以上面的代码还可以这么改:

app = Application()

async def index():
    return "Hello World"

app.router.add_get("/index", index)
app.router.add_post("/index", index)
# 还有 add_put、add_delete 等等

这么注册也是可以的,当然不管是装饰器,还是 add_get、add_post 等等,它们都是调用了 app.router.add 方法。

总结一下:通过实例化 Application 得到 app 对象,该对象内部有一个 router 属性,也就是路由器,它是一个 Router 对象,保存了所有注册的路由(URL 到视图函数的映射)。

从源码中不难看出,每一个路由就是一个 Route 对象,里面包含了 URL 和视图函数。这些 Route 对象都保存在 app.router(路由器)的 routes 属性中,该属性是一个字典。字典的 key 为请求方法,比如 b"GET"、b"POST" 等等,value 则为一系列的路由。

关于这里的 routes 为什么会是一个字典,估计有人会好奇,这里解释一下。首先在 FastAPI 中,app 对象内部同样有一个 router 属性,它也是一个 Router 对象(即路由器),Router 对象内部同样有一个 routes 属性(保存了所有的路由)。

from fastapi import FastAPI
from blacksheep import Application

fastapi_app = FastAPI()
blacksheep_app = Application()

# 每个路由都是一个 Route 对象
# 所有的路由都会保存在 app.router(路由器)的 routes 属性中
print(fastapi_app.router.routes.__class__)
print(blacksheep_app.router.routes.__class__)
"""
<class 'list'>
<class 'collections.defaultdict'>
"""

但 FastAPI 使用一个列表来保存所有的路由,每个路由里面包含了请求方法、URL、视图函数,当请求到来时,会依次遍历整个列表来进行匹配。而 blacksheep 使用一个字典来保存所有的路由,其中 key 为请求方法,相当于在注册的时候按照请求方法将路由进行了分组。比如有 10 个路由监听 GET 请求,10 个路由监听 POST 请求,这样当客户端发出 GET 请求时,只需从监听 GET 请求的 10 个路由中匹配即可。

显然 blacksheep 这种做法的效率就很高,路由匹配的效率对框架有着很深的影响。

不过别急,还没结束,注册路由还有一种方式:

app = Application()

# 不支持 @app.get,必须使用 @app.router.get
@app.router.get("/index")
async def index():
    return "Hello World"

这么注册也是可以的,当然它本质上也是调用了 app.router.add 方法,个人觉得 blacksheep 这一点设计就有些不太方便了。在 FastAPI 中,可以直接使用 @app.get,本质上等价于 @app.router.get,但 blacksheep 则要求我们必须使用 @app.router.get。

总结一下目前注册路由的几种方式:

from blacksheep import Application

app = Application()

# 方式一
@app.route("/index", methods=["GET"])
async def index():
    pass

# 方式二
@app.router.get("/index")
async def index():
    pass

# 方式三
async def index():
    pass

app.router.add_get("/index", index)

# 方式四
async def index():
    pass

app.router.add("GET", "/index", index)

方法一、方法二、方法三,本质上都是方法四的语法糖,它们在执行之后会创建一个路由(Route 对象),然后添加进 app.router(路由器)的 routes 属性中。

动态路径

我们上面的路径都是写死的,如果想动态声明路径参数该怎么做呢?非常简单,和 FastAPI 一样。

from blacksheep import Application
import uvicorn

app = Application()

@app.router.get("/items/{item_id}")
async def get_item(item_id):
    return {"item_id": item_id}

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=5555)

整体非常简单,路由里面的路径参数可以放任意个,只是 {} 里面的参数必须要在视图函数的参数中出现。但是问题来了,我们好像没有规定类型啊,如果我们希望某个路径参数只能接收指定的类型要怎么做呢?

@app.router.get("/items/{item_id}")
async def get_item(item_id: int):
    # 此时的 item_id 要求一个整型
    # 准确的说是一个能够转成整型的字符串
    return {"item_id": item_id}

如果我们传递的值无法转成整型的话,那么会进行提示:告诉我们 item_id 接收的值不是一个有效的整型,可以看到给的提示信息还是非常清晰的。所以通过 Python 的类型声明,blacksheep 提供了数据校验的功能,当校验不通过的时候会清楚地指出没有通过的原因。在我们开发和调试的时候,这个功能非常有用。

路径中包含 /

假设我们有这样一个路由:/files/{file_path},而用户传递的 file_path 中显然是可以带 / 的。假设 file_path 是 /root/test.py,那么路由就变成了 /files//root/test.py,显然这是有问题的。

那么为了防止解析出错,我们可以这么做:

# 声明 file_path 的类型为 path,这样它会被当成一个整体
# 但要注意:在 FastAPI 里面是 {file_path:path},这里刚好相反
@app.router.get("/files/{path:file_path}")
async def get_file(file_path: str):
    return {"file_path": file_path}

然后来访问一下:

结果没有问题,如果不将 file_path 的格式指定为 path,那么解析的时候就会找不到指定的路由。

自定义匹配器

假设有这样一个路由,/phone/{phone_number},我们希望限制 phone_number 必须是一个以 185 开头、且长度为 11 的整数,该怎么做呢?

from blacksheep import Application, Route
import uvicorn

app = Application()

# 路由本质上就是一个 Route 对象
# 该对象有一个类属性 value_patterns,是一个字典
# 将自定义匹配器注册进去,然后就可以在路径参数里面使用了
Route.value_patterns["number_format"] = r"185\d{8}"

@app.router.get("/phone/{number_format:phone_number}")
async def get_file(phone_number: str):
    return {"phone_number": phone_number}

我们测试一下:

手机号长度不对,那么会提示路由找不到。

查询参数

查询参数在 blacksheep 中依旧可以通过类型注解的方式进行声明,如果函数定义了不属于路径参数的参数时,它们将会被解释为查询参数。

from blacksheep import Application
import uvicorn

app = Application()

@app.router.get("/users/{user_id}")
async def get_user(user_id: str, name: str, age: int):
    """
    我们在函数中定义了 user_id、name、age 三个参数
    显然 user_id 和 路径参数中的 user_id 对应
    然后 name 和 age 会被解释成查询参数
    这三个参数的顺序没有要求,但一般都是路径参数在前,查询参数在后
    """
    return {"user_id": user_id, "name": name, "age": age}

注意:name 和 age 没有默认值,这意味着它们是必须要传递的,否则报错。

我们看到当不传递 name 和 age 的时候,会直接提示你相关的错误信息。如果我们希望用户可以不传递的话,那么必须要指定一个默认值。

@app.router.get("/users/{user_id}")
async def get_user(user_id: str,
                   name: str = "无名氏",
                   age: int = 0):
    return {"user_id": user_id, "name": name, "age": age}

另外要注意:不管是路径参数还是查询参数,一旦规定了类型,那么传递的时候,就必须传指定类型的值。假设给这里的 age 传递了一个 "abc",那么是通不过的,因为它要求的是整型。

多个路径和查询参数

前面说过,可以定义任意个路径参数,只要动态的路径参数 {} 在函数的参数中都出现即可。当然查询参数也可以是任意个,blacksheep 可以处理的很好。

from typing import Optional
from blacksheep import Application
import uvicorn

app = Application()

@app.router.get("/postgres/{schema}/v1/{table}")
async def get_data(schema: str,
                   table: str,
                   select: str = "*",
                   where: Optional[str] = None,
                   limit: Optional[int] = None,
                   offset: Optional[int] = None):
    """
    标准格式是:路径参数按照顺序在前,查询参数在后
    但 blacksheep 对顺序本身是没有什么要求的
    """
    query = f"select {select} from {schema}.{table}"
    if where:
        query += f" where {where}"
    if limit:
        query += f" limit {limit}"
    if offset:
        query += f" offset {offset}"
    return {"query": query}

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=5555)

同一个查询参数出现多次

如果我们指定了 name=satori&name=marisa,那么如何才能将这两个 name 都拿到呢?

from typing import List
from blacksheep import Application
import uvicorn

app = Application()

@app.router.get("/users")
async def get_user(name: List[str]):
    return {"name": name}

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=5555)

当查询参数的类型为 List[T] 时,即使不传也没关系,会返回空列表。

请求的载体:Request 对象

任何一个请求都对应一个 Request 对象,请求的所有信息都在这个 Request 对象中,blacksheep 也不例外。

from blacksheep import Application, Request
import uvicorn

app = Application()

@app.router.get("/girl/{user_id}")
async def read_info(request: Request, user_id: str):
    """
    路径参数(user_id)其实可以不用定义,因为我们定义了 request: Request
    那么请求相关的所有信息都会进入到这个 Request 对象中
    
    当然定义了也无所谓,路径参数、查询参数可以和 Request 对象一起使用
    """
    # 路径参数(一个字典)
    path_params = request.route_values
    # 查询参数(一个字典)
    query_params = request.query

    return {"path_params": path_params,
            "query_params": query_params}

通过 Request 对象可以获取请求相关的所有信息,我们之前参数传递不对的时候,blacksheep 会自动帮我们返回错误信息。但通过 Request 我们就可以自己进行解析、自己指定返回的错误信息了。

结果没有问题,并且对于查询参数来说,得到的始终是一个列表。

获取请求头

通过 Request 对象,可以拿到客户端的请求头。

@app.router.get("/get_headers")
async def get_headers(request: Request):
    # 返回一个 Headers 对象,该对象是 Cython 实现的
    headers = request.headers
    print(headers.keys())
    """
    (b'host', b'user-agent', b'accept-encoding', b'accept', b'connection')
    """
    print(headers.values)
    """
    [(b'host', b'localhost:5555'), (b'user-agent', b'python-requests/2.28.1'), 
     (b'accept-encoding', b'gzip, deflate'), (b'accept', b'*/*'), 
     (b'connection', b'keep-alive')]
    """
    print(b"host" in headers)
    """
    True
    """
    # 获取的时候,key 一律小写,并且是字节串形式,并且返回的 value 也是字节串
    # 注意:headers.get_first 在 key 不存在的时候,会返回 None
    # 所以应该确保 key 存在之后,再进行 decode
    return {"User-Agent": headers.get_first(b"user-agent").decode("utf-8")}

Headers 对象还有很多其它方法,可以自己看一下。

通过 Request 对象,也可以拿到客户端传来的 cookie。

@app.router.get("/get_cookies")
async def get_cookies(request: Request):
    # 返回一个字典
    cookies = request.cookies
    return cookies

比较简单,没什么可说的。

获取客户端的其它信息

基于 Request 对象,也可以获取客户端的一些其它信息,比如请求方法、客户端 IP 等等。

@app.router.get("/get_extra_message")
async def get_extra_message(request: Request):
    return {
        "请求的主机": request.host,
        "请求方法": request.method,
        # request.url 返回一个 URL 对象,里面有如下属性
        # schema、host、port、path、query、fragment
        # 分别对应一个 URL 的不同部分
        "请求的 URL": str(request.url),
        "请求的 URL 的路径": request.path,
        "客户端 IP": request.client_ip,
    }

Request 对象还有其它的一些方法,我们后续介绍 POST 请求的时候再聊。

响应的载体:Response 对象

既然有 Request,那么必然会有 Response,虽然我们之前都是直接返回字符串和字典,但 blacksheep 实际上会帮我们转成一个 Response 对象。

该对象接收三个属性,分别是状态码、响应头和 Content 对象,而 Content 是一个普通的静态类。

该类包含三个属性,分别是响应类型、响应体、响应体的长度,但实际上第三个属性不需要传。

from blacksheep import (
    Application, Request,
    Response, Content,
    Cookie
)
import uvicorn
import orjson

app = Application()

@app.router.get("/get_info")
async def get_info(request: Request):
    data = {"name": "古明地觉", "age": 17}
    content = orjson.dumps(data)
    response = Response(
        200,
        [(b"ping", b"pong"), (b"token", b"0315abcde")],
        Content(b"application/json", content)
    )
    # 响应头也可以单独添加
    response.add_header(b"x-man", b"GOOD")
    """
    # 也可以移除某个响应头
    response.remove_header(b"name")
    """
    # 设置 cookie
    response.set_cookie(Cookie("session_id", "abcdefg"))
    # 也可以调用 set_cookies 同时设置多个 cookie,传一个列表即可
    """
    # 也可以移除某个 cookie
    response.remove_cookie("name")
    """
    return response

通过 Response 我们可以实现请求头、状态码、cookie 的自定义。然后注意一下 cookie,我们在设置 cookie 的时候,需要传入一个 Cookie 对象,它接收如下参数:

  • name:cookie 的名称
  • value:cookie 值
  • expire:过期时间
  • domain:cookie 的所属域名
  • path:cookie 的作用路径
  • http_only:是否限制 JavaScript 访问 cookie
  • secure:是否仅在 HTTPS(或本地主机)的情况下发送 cookie
  • max_age:指定多少秒后 cookie 过期

获取请求体

如何获取请求头、查询参数等等我们已经知道了,下面来看看如何获取请求体。

from blacksheep import (
    Application, Request,
)
import uvicorn

app = Application()

@app.router.post("/get_info")
async def get_info(request: Request):
    data = await request.read()
    print(data)

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

我们说过 Request 对象是请求的载体,它包含了请求的所有信息,代码中的 data 便是请求体,并且是最原始的字节流形式。而它长什么样子呢?

首先在使用 requests 模块发送 post 请求的时候,数据可以通过 data 参数传递、也可以通过 json 参数传输。

所以 await request.read() 得到的就是最原始的字节流,除了它之外还有 await request.json(),它在内部依旧会获取字节流,只不过获取之后会自动 loads 成字典。

因此使用 await request.json() 也侧面要求,我们在发送请求的时候必须使用 json 参数传递,否则无法正确解析。

@app.router.post("/get_info")
async def get_info(request: Request):
    try:
        return orjson.loads(await request.read())
    except orjson.JSONDecodeError:
        return {"error": "请传递一个 JSON"}

从 Request 对象解析出请求体之后,我们手动转成了字典,如果你对字段有要求的话,那么可以再单独进行判断。

Form 表单

我们调用 requests.post 发请求,如果参数通过 data 传递的话,则相当于提交了一个 form 表单,那么在 blacksheep 中可以通过 await request.form() 进行获取,注意:内部同样会先调用 await request.read()。

@app.router.post("/get_info")
async def get_info(request: Request):
    # await request.form() 会得到一个字典
    # 直接通过字典获取表单元素即可
    return await request.form()

文件上传

然后是文件上传功能,blacksheep 如何接收用户的文件上传呢?

from blacksheep import (
    Application, Request
)
import uvicorn

app = Application()

@app.router.post("/upload-files")
async def upload_files(request: Request):
    result = []
    # 返回一个列表,列表里面是 FormPart 对象
    files = await request.files()
    for file in files:
        result.append(
            {"name": file.name.decode("utf-8"),
             "file_name": file.file_name.decode("utf-8"),
             "content_type": file.content_type,
             # file.data 是文件内容,这里返回长度
             "data": len(file.data)}
        )
    return result

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

此时我们就实现了文件上传,当然文件上传并不影响我们处理表单,可以自己试一下同时处理文件和表单。

分块读取请求体

前面说了,获取请求体有以下几种方式:

  • await request.read():读取原始的字节流
  • await request.form():将字节流按照表单的方式进行解析,返回一个字典
  • await request.json():将字节流按照 JSON 的方式进行解析,返回一个字典

不管是按照表单方式解析,还是按照 JSON 格式解析,都需要先读取字节流。但如果字节流比较大怎么办,或者说请求体的数据量比较大该怎么办?所以 blacksheep 支持分块获取。

from blacksheep import (
    Application, Request
)
import uvicorn
from io import BytesIO

app = Application()

@app.router.post("/stream")
async def stream(request: Request):
    buf = BytesIO()
    async for chunk in request.stream():
        buf.write(chunk)
    return buf.getvalue().decode("utf-8")

实现的效果和 await request.read() 是一样的,但当请求体很庞大时,该方式不会阻塞事件循环。

返回静态资源

我们本地有一个目录,并希望用户可以直接访问该目录下的文件,要如何实现呢?

from blacksheep import (
    Application, Request
)
import uvicorn

app = Application()

# 浏览器输入:localhost:5555/static/images/1.png
# 会返回指定目录下的 1.png 文件
app.serve_files("/Users/satori/Downloads/images",
                root_path="static/images",
                discovery=True)

# 浏览器输入:localhost:5555/static/videos/1.mp4
# 会返回指定目录下的 1.mp4 文件
app.serve_files("/Users/satori/Downloads/videos",
                root_path="static/videos",
                discovery=True)

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

非常简单,注意里面的 discover 参数,默认为 False。如果指定为 True,那么通过 /static/images 则可以查看对应目录的文件列表,这样可以让用户知道目录下存在哪些文件。

也可以点击指定的文件名,查看文件。

但是注意:并不是所有类型的文件都是可访问的,只有以下扩展名的文件,才可以访问。

from blacksheep.server.files import get_default_extensions
import uvicorn

print(get_default_extensions())
"""
{'.txt', '.js', '.css', '.jpeg', '.eot', '.woff', 
 '.ico', '.mp4', '.mp3', '.woff2', '.jpg', '.ttf', 
 '.html', '.svg', '.png'}

显然这些扩展名对应的文件有时候是不够的,比如 .gif 文件就不支持,如果我希望它还能显示 .gif 文件该怎么办呢?

from blacksheep import (
    Application, Request
)
from blacksheep.server.files import get_default_extensions
import uvicorn

app = Application()

app.serve_files("/Users/satori/Downloads/images",
                root_path="static/images",
                discovery=True,
                extensions={*get_default_extensions(), ".gif"})

此时 .gif 文件也会包含在内。

自定义 404

如果用户访问的 URL 不存在,那么自定义一个 404 是一个非常不错的选择。

from blacksheep import (
    Application, Request, Response,
    Content
)
import uvicorn
import orjson

app = Application()

@app.router.get("/")
async def index():
    return "Hello World"

def not_found():
    data = orjson.dumps(
        {"error": "你要找的页面去火星了"}
    )
    return Response(
        404, None, Content(b"application/json", data)
    )
# 当路由不存在时,会返回 not_found()
app.router.fallback = not_found

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

对于任何一个网站来说,优雅地设置 404 都是有必要的。

不同种类的响应

我们上面介绍了 Response,视图函数返回的都是一个 Response 对象。

返回 JSON

data = orjson.dumps({"k": "v"})
Response(
    200, None, 
    Content(b"application/json", data)
)

返回 HTML

data = "<h1>你好, 世界</h1>"
Response(
    200, None,
    # 需要指定编码,否则可能出现乱码
    Content(b"text/html; charset=utf-8", data.encode("utf-8"))
)

返回纯文本

data = "<h1>你好, 世界</h1>"
Response(
    200, None,
    # 此时 <h1> 不再是标签,而是会被当成文本的一部分
    Content(b"text/plain; charset=utf-8", data.encode("utf-8"))
)

以上是三种最常见的响应,针对响应,手动构建 Response 还是不太方便的,于是 blacksheep 提供了几个函数。

相信这几个函数的作用一目了然,所以在返回响应的时候,还可以这么做。

from blacksheep import text, html, json, pretty_json

# 返回纯文本
text("...")
# 返回 HTML
html("<h1>...</h1>")
# 返回 JSON
json({"k": "v"})
# 返回美化格式的 JSON
pretty_json({"k": "v"})

就我个人而言,还是习惯手动构建 Response 对象。

然后除了以上几种响应之外,还有其它类型的响应,我们来继续看。

返回重定向

重定向分为暂时性重定向和永久性重定向。

  • 暂时性重定向:状态码为 302,比如我们要发表评论,但是没有登录,那么此时就会被重定向到登录页面;
  • 永久性重定向:状态码为 301,比如我们访问一个已经废弃的域名,会被永久性重定向到新的域名;
from blacksheep import (
    Application, Request,
    redirect
)
import uvicorn

app = Application()

@app.router.get("/login")
async def login():
    return "请输入用户名和密码"

@app.router.get("/index")
async def index(request: Request):
    query_params = request.query
    # 因为同一个查询参数可以指定多次,所以获取的是一个列表
    username = query_params.get("username", [None])[0]
    password = query_params.get("password", [None])[0]
    if username != "satori" or password != "123456":
        # 也可以指定一个绝对路径 URL
        # 比如 https://www.bilibili.com
        return redirect("/login")
    return "Hello World"

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

通过 redirect 即可实现重定向,并且是暂时性重定向,如果想实现永久性重定向,那么可以使用另一个函数。

from blacksheep import moved_permanently

非常简单,重定向也是返回一个 Response 对象,并且重定向本质上就是设置一个 header。

当然啦,在 blacksheep 里面内置了非常多的函数,帮助我们构造不同种类的 Response,我们看一下源代码。

# 文件名 blacksheep/server/responses.py
# 里面的函数直接通过 from blcaksheep import 进行导入即可

def status_code(status: int = 200, message: Any = None) -> Response:
    if not message:
        return Response(status)
    return Response(status, content=_optional_content(message))

# 返回一个状态码为 200 的 Response 对象
# 作为客户端最希望看到的 HTTP/1.1 200 OK
def ok(message: Any = None) -> Response:
    return status_code(200, message)

# 创建一个状态码为 201 的 Response 对象
# HTTP/1.1 201 Created,表示服务端收到请求并创建了新资源
def created(message: Any = None, location: AnyStr = "") -> Response:
    return Response(
        201,
        [(b"Location", _ensure_bytes(location))] if location else [],
        content=_optional_content(message) if message else None,
    )

# 创建一个状态码为 202 的 Response 对象
# HTTP/1.1 202 Accepted,表示服务端已经收到请求,但处理尚未完成
# 因此当任务在后台异步处理、并且尚未完毕时,那么就可以返回这个状态码
def accepted(message: Any = None) -> Response:
    return status_code(202, message)

# 创建一个状态码为 204 的 Response 对象
# HTTP/1.1 204 No Content,表示请求已经执行成功,但没有内容
# 浏览器在看到 204 时,不会刷新页面,也不会导向其它页面
# 因此当服务端不需要额外的数据响应,只返回是否成功时,那么可以使用 204 状态码
def no_content() -> Response:
    return Response(204)

# 创建一个状态码为 304 的 Response 对象
# HTTP/1.1 Not Modified,表示 GET 请求的内容没有发生变化(相比之前)
# 那么浏览器就会从缓存中读取
def not_modified() -> Response:
    return Response(304)

# 创建一个状态码为 401 的 Response 对象
# HTTP/1.1 401 Authentication,表示客户端请求未被授权
# 比如服务端需要一个 Token,但是客户端没有传,那么就可以返回 401
def unauthorized(message: Any = None) -> Response:
    return status_code(401, message)

# 创建一个状态码为 403 的 Response 对象
# HTTP/1.1 403 Forbidden,表示服务端理解了本次请求,但拒绝提供服务
# 当客户端没有权限访问,或者资源不存在时,可以返回 403
def forbidden(message: Any = None) -> Response:
    return status_code(403, message)

# 创建一个状态码为 400 的 Response 对象
# HTTP/1.1 400 Bad Request,表示请求语法错误,服务端无法正常读取
def bad_request(message: Any = None) -> Response:
    return status_code(400, message)

# 创建一个状态码为 404 的 Response 对象
# HTTP/1.1 404 Not Found,这个应该不需要我说了,就是找不到请求的网页
def not_found(message: Any = None) -> Response:
    return status_code(404, message)

# 创建一个状态码为 301 的 Response 对象
# HTTP/1.1 301 Moved Permanently,永久性重定向
def moved_permanently(location: AnyStr) -> Response:
    return Response(301, [(b"Location", _ensure_bytes(location))])

# 创建一个状态码为 302 的 Response 对象
# HTTP/1.1 301 Moved Temporarily,暂时性重定向
def redirect(location: AnyStr) -> Response:
    return Response(302, [(b"Location", _ensure_bytes(location))])

# 创建一个状态码为 303 的 Response 对象
# HTTP/1.1 303 See Other,依旧是重定向,但针对 POST 等非幂等请求
# 表示请求已被处理,客户端可以使用 GET 去访问 Location 里面的 URI
def see_other(location: AnyStr) -> Response:
    return Response(303, [(b"Location", _ensure_bytes(location))])

# 创建一个状态码为 307 的 Response 对象,和 303 类似
# HTTP/1.1 307 Temporary Redirect,依旧是重定向,但针对 POST 等非幂等请求
# 表示请求未被处理,客户端应该向 Location 里面的 URI 重新发起 POST 请求
# 总结:303 和 307 之所以存在,就是因为 POST 请求的非幂等性所引起的
# 但大部分时候,我们用的都是 301 和 302
def temporary_redirect(location: AnyStr) -> Response:
    return Response(307, [(b"Location", _ensure_bytes(location))])

# 创建一个状态码为 307 的 Response 对象,和 301 类似
# 但 308 状态码不允许浏览器将原本为 POST 的请求重定向到 GET 请求上。
def permanent_redirect(location: AnyStr) -> Response:
    return Response(308, [(b"Location", _ensure_bytes(location))])

# 下面几个前面说过了,都是创建状态码为 200 的 Response 对象
# 但是 Content-Type 不一样
def text(value: str, status: int = 200) -> Response:
    ...

def html(value: str, status: int = 200) -> Response:
    ...

def json(data: Any, status: int = 200) -> Response:
    ...

def pretty_json(
    data: Any,
    status: int = 200,
    indent: int = 4,
) -> Response:
    ...

总的来说,请求种类还是蛮多的,但它们返回的都是 Response 对象,因为它是响应的载体。当然啦,如果你熟悉 HTTP 状态码的含义,那么你也可以手动创建 Response 对象并返回,而不需要去记这些函数,当然它们的出现确实会简化一些工作。

返回字节流

假设我们有一坨字节流,如何将它返回呢?

from blacksheep import (
    Application, Request,
    Response, Content
)
import uvicorn

app = Application()

async def content():
    for i in range(1, 5):
        yield f"{i} chunk bytes ".encode("utf-8")

@app.router.get("/get_content")
async def get_content(request: Request):
    data = b"".join([chunk async for chunk in content()])
    return Response(
        200, None,
        Content(b"application/octet-stream", data)
    )

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

非常简单,和返回其它响应类似,只需要指定 Content-Type 即可。

返回文件

返回文件的话,依旧通过 Response 对象,但介绍之前我们先额外补充一些内容。我们知道 Chrome 可以显示图片、音频、视频,但它们本质上都是字节流,Chrome 在拿到字节流的时候,怎么知道字节流是哪种类型呢?不用想,显然要通过 Content-Type。

# 我们可以返回图片、音频、视频,以字节流的形式
# 但光有字节流还不够,我们还要告诉 Chrome
# 拿到这坨字节流之后,应该要如何解析
# 此时需要通过响应头里面的 Content-Type 指定
Response(
    200, None,
    # png 图片:"image/png"
    # mp3 音频:"audio/mp3"
    # mp4 视频:"video/mp4"
    Content(b"image/png", b"*** bytes data ***")
)

通过 Content-Type,Chrome 就知道该如何解析了,至于不同格式的文件会对应哪一种 Content-Type,标准库也提供了一个模块帮我们进行判断。

from mimetypes import guess_type

# 根据文件后缀进行判断
print(guess_type("1.png")[0])
print(guess_type("1.jpg")[0])
print(guess_type("1.mp3")[0])
print(guess_type("1.mp4")[0])
print(guess_type("1.wav")[0])
print(guess_type("1.flv")[0])
print(guess_type("1.pdf")[0])
"""
image/png
image/jpeg
audio/mpeg
video/mp4
audio/x-wav
video/x-flv
application/pdf
"""

只要是 Chrome 支持的文件格式,通过返回文件的字节流,然后指定正确的Content-Type,都可以正常显示在页面上。然后不知道你是否留意过,Chrome 有时候获取完数据之后,并没有显示在页面上,而是直接下载下来了。

那这是怎么做到的呢?

from blacksheep import (
    Application, Request,
    Response, Content
)
import uvicorn

app = Application()

@app.router.get("/file1")
async def get_file1():
    # 读取字节流(任何类型的文件都可以)
    with open("/Users/satori/Downloads/1.jpg", "rb") as f:
        data = f.read()
    # 返回的时候通过 Content-Type 告诉 Chrome 文件类型
    # 尽管 Chrome 比较智能,会自动判断,但最好还是指定一下
    return Response(
        200, None,
        # 返回的字节流是 jpg 格式
        Content(b"image/jpeg", data))
    # Chrome 在拿到字节流时会直接将图片渲染在页面上

@app.router.get("/file2")
async def get_file2():
    with open("main.py", "rb") as f:
        data = f.read()
    # 在响应头中指定 Content-Disposition
    # 意思就是告诉 Chrome,你不要解析了,直接下载下来
    # filename 后面跟的就是文件下载之后的文件名
    return Response(
        # 既然都下载下来了,也就不需要 Chrome 解析了
        200, [(b"Content-Disposition", b"attachment; filename=main.py")],
        # 将响应类型指定为 application/octet-stream
        # 表示让 Chrome 以二进制格式直接下载
        Content(b"application/octet-stream", data)
    )

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

访问 localhost:5555/file1 会获取图片并展示在页面上;

访问 localhost:5555/file2 会获取 main.py 的内容,并以文件的形式下载下来;

所以即使返回的内容是纯文本,也是可以下载下来的。以上就是返回文件的方式,直接将文件读取进来,然后指定好响应类型即可。

中间件

中间件在 web 开发中可以说是非常常见了,说白了中间件就是一个函数或者一个类。

在请求进入视图函数之前,会先经过中间件(被称为请求中间件),在里面我们可以对请求进行一些预处理,或者实现一个拦截器等等;同理当视图函数返回响应之后,也会经过中间件(被称为响应中间件),在里面我们也可以对响应进行一些润色。

blacksheep 也支持像 Flask 一样自定义中间件,在 Flask 里面有请求中间件和响应中间件,但在 blacksheep 里面这两者合二为一了,我们看一下用法。

from blacksheep import (
    Application, Request,
    Response, Content
)
import uvicorn
import orjson

app = Application()

@app.router.get("/")
async def view_func(request: Request):
    return {"name": "古明地觉"}

async def middleware(request: Request, handler):
    # 请求到来时会先经过这里的中间件
    if request.headers.get("ping", "") != "pong":
        result = orjson.dumps({"error": "请求头中缺少指定字段"})
        response = Response(
            200, None,
            Content(b"application/json", result)
        )
        # 当请求头中缺少 "ping": "pong"
        # 在中间件这一步就直接返回了,就不会再往下走了
        # 所以此时相当于实现了一个拦截器
        return response
    # 如果条件满足,则执行await handler(request),关键是这里的 handler
    # 如果该中间件后面还有中间件,那么 handler 就是下一个中间件
    # 如果没有,那么 handler 就是对应的视图函数
    # 这里显然是视图函数,因此执行之后会拿到视图函数返回的 Response 对象
    response: Response = await handler(request)
    # 我们对 response 做一些润色,比如设置一个响应头
    # 所以我们看到在 blacksheep 中,请求中间件和响应中间件合在一起了
    response.set_header(b"status", b"success")
    return response

# 将中间件添加进去
app.middlewares.append(middleware)

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

测试结果也印证了我们的结论。当然啦,中间件可以有很多个,它们会按照顺序执行。

async def middleware1(request: Request, handler):
    print("A")
    # 执行下一个中间件(或视图函数)
    response: Response = await handler(request)
    print("B")
    return response

async def middleware2(request: Request, handler):
    print("C")
    # 执行下一个中间件(或视图函数)
    response: Response = await handler(request)
    print("D")
    return response

# 将中间件添加进去
app.middlewares.append(middleware1)
app.middlewares.append(middleware2)

我们发个请求,看看打印顺序:

所以 await handler(request) 的上半部分相当于请求中间件,下半部分相当于响应中间件,整个过程就类似于如下:

中间件如果通过上面的方式注册,那么会作用在每一个请求上面,但我们有时候只需要对某些请求使用中间件,该怎么做呢?很简单,使用装饰器就行了。

from functools import wraps
from blacksheep import (
    Application, Request
)
from blacksheep.server.normalization import ensure_response
from typing import Dict
import uvicorn

app = Application()

def add_response_header(headers: Dict[str, str]):
    def decorator(handler):
        @wraps(handler)
        async def wrapped(*args, **kwargs):
            # 注意:此处拿到的就是视图函数的返回值
            # 如果视图函数返回的不是 Response 对象,那么这里不会自动包装
            # 因此调用 ensure_response,将其转成 Response 对象
            response = ensure_response(await handler(*args, **kwargs))
            for key, val in headers.items():
                response.set_header(key.encode("utf-8"), val.encode("utf-8"))
            return response
        return wrapped
    return decorator

@app.router.get("/")
@add_response_header({"Token": "abcdefg"})
# 这里的 view_func 就是 add_response_header 里面的 wrapped
async def view_func(request: Request):
    return {"name": "古明地觉"}

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

以上就通过装饰器的方式实现了响应头的添加,虽然它不是中间件,但在最小范围内实现了中间件的效果。

最后,我们在定义视图函数的时候,应该使用 async def,将其定义成一个协程函数,而不是普通函数,否则使用异步框架就没有意义了。但如果就是有这么一个 def 定义的普通函数,我们上面那个装饰器要如何兼容呢?

from functools import wraps
import inspect
from blacksheep import (
    Application, Request
)
from blacksheep.server.normalization import ensure_response
from typing import Dict
import uvicorn

app = Application()

def add_response_header(headers: Dict[str, str]):
    def decorator(handler):
        @wraps(handler)
        async def wrapped(*args, **kwargs):
            # 如果 handler 是一个协程函数
            if inspect.iscoroutinefunction(handler):
                response = ensure_response(await handler(*args, **kwargs))
            # 说明是普通函数
            else:
                response = ensure_response(handler(*args, **kwargs))
            for key, val in headers.items():
                response.set_header(key.encode("utf-8"), val.encode("utf-8"))
            return response
        return wrapped
    return decorator

@app.router.get("/async_func")
@add_response_header({"Token": "abcdefg"})
async def view_func(request: Request):
    return {"name": "古明地觉"}

@app.router.get("/normal_func")
@add_response_header({"Token": "1234567"})
async def view_func(request: Request):
    return {"name": "古明地恋"}

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

结果一切正常,没有问题。

WebSocket

如果使用 HTTP 协议,服务端永远是被动的,客户端如果不发消息,服务端永远不回复,像极了你和喜欢的人在微信上聊天的时候(不是),因为 HTTP 一个半双工协议。但很多时候,由于客户端不知道数据是否准备好,所以只能不断地轮询服务端,这无疑是一种资源浪费,也会给服务端造成压力。那么有没有这样一种技术,在客户端和服务端建立连接之后,也能让服务端向客户端推送数据呢?

为了满足这种场景,WebSocket 便诞生了,它是一种全双工协议,允许客户端和服务端主动发起请求。主要用于实时应用程序,聊天应用程序等等。

举个例子,我们需要一个实时计数器来显示网站上当前有多少用户,看看使用 HTTP 和 WebSocket 的区别。

然后来看看 blacksheep 如何实现 WebSocket。

from blacksheep import Application, WebSocket
import uvicorn

app = Application()

@app.router.ws("/ws/{client_id}")
async def ws(websocket: WebSocket, client_id: str):
    pass

# 或者也可以这么注册
"""
app.router.add_ws("/ws/{client_id}", ws)
# add_ws 方法本质上也是调用了 add 方法
app.router.add("GET_WS", "/ws/{client_id}", ws)
"""

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

后续便通过 websocket 参数进行通信。注意:blacksheep 和 FastAPI 一样,非常依赖类型注解。所以在定义参数的时候,应当指明参数的类型为 WebSocket,这样框架就知道该参数是负责和客户端进行通信的。

注意:使用 WebSocket 功能之前需要安装相关的库,直接 pip install websockets。

接下来就是调用 accept() 方法监听客户端连接:

@app.router.ws("/ws/{client_id}")
async def ws(websocket: WebSocket, client_id: str):
    # accept 方法接收两个参数:headers 和 subprotocol
    # headers 表示响应头,subprotocol 表示应用程序接受的子协议
    # 它们将和握手响应一起发送给客户端
    await websocket.accept()  # 这里不需要指定
    print(f"客户端{client_id} 来连接啦")

一旦连接建立成功,那么就可以和客户端收发消息了,方法有以下几种:

  • receive_bytes 和 send_bytes
  • receive_text 和 send_text
  • receive_json 和 send_json

几个方法都见名知意,我们测试一下:

from blacksheep import Application, WebSocket
import uvicorn

app = Application()

@app.router.ws("/ws/{client_id}")
async def ws(websocket: WebSocket, client_id: str):
    await websocket.accept()
    await websocket.send_text(f"客户端{client_id} 来连接啦")
    while True:
        msg = await websocket.receive_text()
        await websocket.send_text(f"收到客户端发送的消息: {msg}")

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

启动服务端,然后编写一个 HTML 去连接服务端:

<!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:5555/ws/001");

        //如果连接成功, 会打印下面这句话, 否则不会打印
        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 文件:

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

import asyncio
from blacksheep import (
    Application,
    WebSocket,
    WebSocketDisconnectError
)
import uvicorn
import orjson

app = Application()

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])

@app.router.ws("/ws")
async def deal_ws_conn(ws: WebSocket):
    user_counter = UserCounter()
    await user_counter.on_connect(ws)
    while True:
        try:
            data = await user_counter.on_receive(ws)
        except WebSocketDisconnectError:
            return await user_counter.on_disconnect(ws)
        await user_counter.on_send(ws, f"收到客户端发送的消息: {data}")

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

我们将 WebSocket 的通信逻辑写在了一个类中,然后在视图函数里面将其实例化,然后来打开刚才的 HTML。注意:我们将路由给改了,所以 HTML 中访问了地址别忘记也改一下。

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

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

认证(Authentication)

通俗地讲,认证就是验证当前用户的身份是否合法的过程。比如指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功。

像用户名密码登录、邮箱发送登录链接、手机接收验证码等等都属于互联网中的常见认证方式,只要你能收到验证码,就默认你是账号的主人。认证主要是为了保护系统的隐私数据与资源。

而一旦认证通过,那么一定时间内就不用再认证了(银行转账等高度敏感操作除外),这时就可以将用户信息保存在会话中。会话是系统为了保存当前用户的登录状态所提供的机制,实现方式通常是 Session 或 Token。

使用 Session 验证

HTTP 是无状态的协议,对于事务处理没有记忆能力,客户端和服务端完成会话时,服务端不会保存任何会话信息。也就是说每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次请求的发送者和这一次的发送者是不是同一个人。

所以服务器与浏览器为了进行会话跟踪(知道是谁在访问),就必须主动地去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器,而这个状态就需要 Cookie 和 Session 共同去实现。

那 Cookie 是什么呢?Cookie 本质上就是服务器发送给用户浏览器的一小块数据,一旦用户认证成功,那么浏览器就会收到服务器发送的 Cookie,然后将其并保存在本地。当浏览器下次再向同一服务器发起请求时,会携带该 Cookie(放在请求头中),并发送给服务器。服务器拿到 Cookie 后,会检测 Cookie 是否过期,如果没有过期再通过 Cookie 判断用户的信息。

注意:Cookie 是不可跨域的,每个 Cookie 都会绑定单一的域名,无法在别的域名下获取使用。一级域名和二级域名之间是允许共享使用的(靠的是 domain)。

以上就是 Cookie,但是问题来了,我们能把一些敏感信息存在 Cookie 里面吗?把用户名、密码都放在 Cookie 里面,然后浏览器发请求的时候再读取 Cookie 里面的内容,验证用户的合法性,可以这么做吗?显然是不能的,因为这样太不安全了,所以就有了 Session。

注意:Session 它并不是真实存在的,而是我们引入的一个抽象概念,它的实现依赖于 Cookie。因为我们不能把敏感信息放在 Cookie 里面,所以最好的方式就是直接放在服务端,用户的这些敏感信息可以看成是一个 Session,我们可以用一个映射保存起来。然后为每一个 Session 都创建一个与之关联的 SessionID,举个例子:

{
    "不重复的 sessionID-1": {"user_name": "xx1", 
                           "password": "yy1", 
                           "phone": 135...},
    "不重复的 sessionID-2": {"user_name": "xx2", 
                           "password": "yy2",
                           "phone": 136...},
    ...
}

使用映射保存是最方便的,这些敏感信息我们存在服务端,然后将 SessionID 返回即可,那么这个 SessionID 放在哪里呢?没错,放在 Cookie 中,所以 Session 的实现要依赖于 Cookie。我们以用户登录来模拟一下整个过程:

  • 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session;
  • 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器;
  • 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名;
  • 当用户第二次访问服务器的时候,浏览器会自动判断此域名下是否存在 Cookie 信息,如果存在,会将 Cookie 信息也发送给服务端;服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作;

根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来「验证用户登录状态」。

虽然 Session 看起来很美好,但是存在痛点,因为上述情况能正常工作是因为我们假设 Server 是单机的。但实际在生产上,为了保障高可用,一般服务器至少需要两台机器,通过负载均衡的方式来决定请求到底该打到哪台机器上。

假设第一次的登录请求打到了 A 机器,A 机器生成了 Session 并在 Cookie 里添加 SessionId 返回给了浏览器。

那么问题来了:下次如果请求(比如添加购物车)打到了 B 或者 C,由于 Session 是在 A 机器生成的,此时的 B、C 是找不到 Session 的,因此就会发生无法添加购物车的错误,就得重新登录了,此时该怎么办呢?解决方式主要有以下三种:

Session 复制

A 生成 Session 后复制到 B、C,这样每台机器都有一份 Session,无论添加购物车的请求打到哪台机器,由于 Session 都能找到,所以不会有问题。这种方式虽然可行,但缺点也很明显:

  • 同样的一份 Session 保存了多份,数据冗余;
  • 如果节点少还好,但如果节点多的话,特别是像阿里,微信这种由于 DAU 上亿,可能需要部署成千上万台机器,这样由于节点增多导致复制造成的性能消耗也会很大;

Session 粘黏

这种方式是让每个客户端请求只打到固定的一台机器上,比如浏览器登录请求打到 A 机器后,后续所有的添加购物车的请求也都打到 A 机器上。Nginx 的 sticky 模块可以支持这种方式,支持按 ip 或 cookie 粘连等等,比如按 ip 粘连:

这样的话每个 client 请求到达 Nginx 后,只要它的 ip 不变,根据 ip hash 算出来的值就会打到固定的机器上,也就不存在 Session 找不到的问题了。当然这种方式的缺点也是很明显,对应的机器挂了怎么办?此外,这种方式还会造成一个问题,那就是 Nginx 会变得有状态。

Session 共享

这种方式也是目前各大公司普遍采用的方案,将 Session 保存在 Redis,Memcached 等中间件中,请求到来时,各个机器去这些中间件取一下 Session 即可。

缺点其实也不难发现,就是每个请求都要去 Redis 取一下 Session,多了一次内部连接,消耗了一点性能。另外为了保证 Redis 的高可用,必须做集群。当然了,对于大部分公司来说,Redis 集群基本都会部署,所以这方案可以说是首选了。

使用 Token 认证

通过上文分析,我们知道通过在服务端共享 Session 的方式可以完成用户的身份定位,但是背后也有一个小小的瑕疵:搞个校验机制我还得搭个 Redis 集群?大厂确实 Redis 用得比较普遍,但对于小厂来说可能它的业务量还未达到用 Redis 的程度,所以有没有其它不用 Server 存储 Session 的用户身份校验机制呢,答案是使用 Token。

基于 Token 的鉴权机制类似于 http 协议,也是无状态的,它不需要在服务端保留用户的认证信息或者会话信息。这就意味着基于 Token 认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

  • 用户使用用户名密码来请求服务器;
  • 服务器验证用户的信息;
  • 认证通过后,发送给用户一个 Token;
  • 客户端存储 Token,并在后续的请求中附上这个 Token 值;
  • 服务端验证 Token 值,并返回数据;

这个 Token 必须要在每次请求时都传递给服务端,它应该保存在请求头里。 如果保存在 Cookie 里面,服务端还要支持 CORS(跨来源资源共享)策略。

而基于 Token 认证一般采用 JWT,它由三段信息构成,将这三段信息用 . 连接起来就构成了 JWT 字符串。就像如下这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

第一部分我们称之为头部(Header)、第二部分我们称之为载荷(Payload)、第三部分我们称之为签名(Signature),下面来分别介绍。

Header

JWT 的头部承载两部分信息:

  • 声明类型,这里是 JWT;
  • 声明加密的算法 通常直接使用 HMAC SHA256;
{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行 base64 编码(可以对称解码),构成了第一部分。

import base64
import orjson

header = {
    'typ': 'JWT',
    'alg': 'HS256'
}

print(
    base64.urlsafe_b64encode(orjson.dumps(header)).decode()
)  # eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

Payload

载荷就是存放有效信息的地方,说人话就是用户的一些信息,里面可以放任意内容。我们同样采用 base64 编码,就得到了 JWT 的第二部分。注意:不要放一些敏感内容,因为 base64 是可以对称解码的。

import base64
import orjson

payload = {
    'name': '最上川',
    'phone': '12345678901',
    'user_id': '001',
    'admin': False,
}

print(
    base64.urlsafe_b64encode(orjson.dumps(payload)).decode()
)  # eyJuYW1lIjoi5pyA5LiK5bedIiwicGhvbmUiOiIxMjM0NT......

除了我们添加的一些声明之外,JWT 还提供了一些标准声明(建议但不强制使用):

  • iss:JWT 签发者;
  • sub:JWT 所面向的用户;
  • aud:接收 JWT 的一方;
  • exp:JWT 的过期时间,这个过期时间必须要大于签发时间;
  • nbf:定义在什么时间之前,该 JWT 都是不可用的;
  • iat:JWT 的签发时间;
  • jti:JWT的唯一身份标识,主要用来作为一次性 Token,从而回避重放攻击;

Signature

JWT 的第三部分是一个签名信息,这个签名信息由三部分组成:

  • base64 之后的 Header;
  • base64 之后的 Payload;
  • secret;

首先将 base64 编码后的 Header 和 base64 编码后的 Payload 使用 . 连接起来,然后通过 Header 中声明的加密方式进行加盐,再进行 base64 编码,然后就构成了 JWT 的第三部分。我们举个例子:

import base64
import hmac
import orjson

header = {
    'typ': 'JWT',
    'alg': 'HS256'
}

payload = {
    'name': '最上川',
    'phone': '12345678901',
    'user_id': '001',
    'admin': False,
}

# 得到 JWT 的第一部分,和 JWT 的第二部分
jwt_one = base64.urlsafe_b64encode(
    orjson.dumps(header))
jwt_two = base64.urlsafe_b64encode(
    orjson.dumps(payload))
# 计算 JWT 第三部分
# 指定一个密钥,这里就叫 b"secret"
jwt_three = base64.urlsafe_b64encode(
    hmac.new(b"secret", jwt_one + b"." + jwt_two, "SHA256").digest()
).decode()
print(jwt_three)  # 4oRUd9Diyp_C0W_LD1of0MxzIuRXvfSroUR_VhP-dSQ=

再将这三部分用 . 连接起来,得到一个字符串,即可构成最终的 JWT。当用户登录成功之后,服务端会生成 JWT 然后交给浏览器保存。

后续当 Server 收到浏览器传过来的 Token 时,它会首先取出 Token 中的 Header + Payload,根据密钥生成签名,然后再与 Token 中的签名比对,如果成功则说明签名是合法的,即 Token 是合法的。然后根据 Payload 中保存的信息,我们便可得知该用户是谁,避免了像 Session 那样要从 Redis 获取的开销。

你会发现这种方式确实很妙,只要 Server 保证密钥不泄露,那么生成的 Token 就是安全的。因为伪造 Token 的话在签名验证环节是无法通过的,可以判定出 Token 的合法性。

注意:secret(密钥)是保存在服务器端的,JWT 的生成也是在服务器端的,Secret 是用来进行 JWT 的签发和 JWT 的验证。所以,它就是你服务端的私钥,在任何场景都不应该流露出去。否则一旦客户端得知这个 Secret,那就意味着客户端可以自我签发 JWT。

通过这种方式就有效地避免了 Token 必须保存在 Server 的弊端,即使是集群也没关系,只要节点使用的 Secret 相同即可。不过需要注意的是,Token 一旦由 Server 生成,直到过期之前它都是有效的,并且无法让 Token 失效,除非在 Server 端为 Token 设立一个黑名单,在校验 Token 前先过一遍此黑名单,如果在黑名单里则此 Token 失效。

但一旦这样做的话,那就意味着黑名单必须保存在 Server,这又回到了 Session 的模式,那直接用 Session 不香吗。所以一般的做法是当客户端登出,或者 Token 过期时,直接在本地移除 Token,下次登录再重新生成 Token。而判断 Token 是否过期,可以在生成 JWT 的时候,在里面加上时间信息,拿到 Token 时再进行比对。

另外需要注意的是 Token 一般是放在 Header 的 Authorization 自定义头里,不是放在 Cookie 里,这主要是为了解决跨域不能共享 Cookie 的问题。

# 一般会加上一个 Bearer 标注
Authorization: Bearer <Token>

总结:Token 解决什么问题(为什么要用 Token)?

  • 1)完全由应用管理,可以避开同源策略;
  • 2)支持跨域访问,Cookie 不支持。Cookie 跨站是不能共享的,这样的话如果你要实现多应用(多系统)的单点登录(SSO),使用 Cookie 来做的话就很困难了。但如果用 Token 来实现 SSO 会非常简单,只要在 header 中的 Authorize 字段(或其他自定义)加上 Token 即可完成所有跨域站点的认证;
  • 3)Token 是无状态的,可以在多个服务器间共享,因为服务端不需要保存 Token,只需要对它进行解析即可;
  • 4)Token 可以避免 CSRF 攻击(跨站请求攻击);
  • 5)易于扩展,在移动端的原生请求是没有 Cookie 之说的,而 Sessionid 依赖于 Cookie,所以 SessionID 就不能用 Cookie 来传了。如果用 Token 的话,由于它是随着 header 的 Authorization 传过来的,也就不存在此问题,换句话说 Token 天生支持移动平台,可扩展性好;

下面来看看如何在 blacksheep 中实现 Token 认证。

import asyncio
from blacksheep import (
    Application, Request
)
# 认证和鉴权相关的逻辑被打包在一个单独的库 guardpost 里面
from guardpost.asynchronous.authentication import (
    AuthenticationHandler, Identity
)

import uvicorn

app = Application()

class AuthHandler(AuthenticationHandler):
    """
    定义一个类,继承 AuthenticationHandler
    并重写里面的 authenticate 方法
    """
    async def authenticate(self, request: Request):
        # 获取 Token
        token = request.headers.get_first(b"Authorization")
        if token is None:
            request.identity = None
        else:
            # 基于 token 判断请求者的身份
            # 将相关数据保存在 request.identity 属性中
            request.identity = Identity(
                {"id": "001", "username": "satori"}
            )

@app.router.get("/")
async def for_anybody(request: Request):
    # 用户发请求时会先获取 Token 进行鉴权
    # 在 authenticate 里面判断 Token 是否合法
    # 如果 Token 不合法,那么 request.identity 为 None
    # Token 合法,request.identity 就是用户的具体信息
    identity = request.identity
    if identity is None:
        return {"error": "Token 不合法"}
    else:
        # 可以通过 indentity.claims 拿到传入的字典
        # 也可以直接像字典一样访问,如果 key 不存在,则返回 None
        return {"message": f"{identity['username']}, 欢迎光临"}

# 将认证逻辑加进去
app.use_authentication().add(AuthHandler())

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

以上就是认证,这里我们只是检测是否有 Token,但实际生产中肯定要对 Token 进行解析,然后获取用户信息,可以试着引入 jwt 测试一下。

授权(Authorization)

授权,简单来讲就是谁(who)对什么(what)进行了什么操作(how),和认证不同,认证是确认用户的合法性,以及让服务端知道你是谁,而授权则是为了更细粒度地对资源进行权限上的划分。所以授权是在认证通过后,控制不同的用户访问不同的资源

并且授权是双向的,可以是用户给服务端授权,也可以是服务端给用户授权。

  • 用户给服务端授权:比如你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限);你在登录微信小程序时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)。
  • 服务端给用户授权:比如你想追一个很火的剧,但被告知必须是 VIP 才能观看,于是你充钱成为了 VIP,那么服务端便会给你授予观看该剧(访问该资源)的权限。

而实现授权的方式业界通常使用 RBAC,这个 R 有两种解读:第一种是基于角色的访问控制,即 Role-Based Access Control;第二种是基于资源(权限)的访问控制,系统设计时定义好某项操作的权限标识,即 Resource-Based Access Control。

认证和授权,这两个单词的英文比较像,因此注意别混淆了。

from blacksheep import (
    Application, Request
)
from guardpost.asynchronous.authentication import (
    AuthenticationHandler, Identity
)

import uvicorn

app = Application()

class AuthHandler(AuthenticationHandler):
    async def authenticate(self, request: Request):
        token = request.headers.get_first(b"Authorization")
        if token is None:
            request.identity = None
        # 这里为了举例,我们直接硬编码
        # 但生产上,这一步应该要判断用户是谁,以及它的相关权限
        elif token == b"Bear 66666":
            request.identity = Identity(
                {"is_vip": True}
            )
        else:
            request.identity = Identity(
                {"is_vip": False}
            )

app.use_authentication().add(AuthHandler())

@app.router.get("/")
async def index(request: Request):
    identity = request.identity
    if identity is None:
        return "未获取到 Token,给你 480p"
    elif identity["is_vip"] is False:
        return "获取到 Token,但不是 vip,给你 1080p"
    else:
        return "获取到 Token,是 vip,给你 4k"

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

认证和授权一定要分清,认证是让服务端知道你是谁,而授权则是用户是否能够拥有访问某个资源的权限,授权是在认证之后发生的。其实这两者的区别很好理解,主要是它们的英文名字太像了。

解决跨域问题

随着前后端分离的流行,后端程序员和前端程序员的分工变得更加明确,后端只需要提供相应的接口、返回指定的 JSON 数据,剩下的交给前端去做。因此数据接入变得更加方便,但也涉及到了安全问题。

所以浏览器为了安全起见,设置了同源策略,要求前端和后端必须是同源的。而协议、域名以及端口,只要有一个不同,那么就是不同源的。比如下面都是不同的源:

  • http://localhost
  • https://localhost
  • http://localhost:8080

即使它们都是 localhost,但是它们使用了不同的协议或端口,所以它们是不同的源。如果前端和后端不同源,那么前端里面的 JavaScript 代码将无法和后端通信,此时我们就说出现了跨域。而 CORS 则是专门负责解决跨域的,让前后端即使不同源,也能进行数据访问。

假设你的前端运行在 localhost:8080,并且尝试与 localhost:5555 进行通信。然后浏览器会向后端发送一个 HTTP OPTIONS 请求,后端会返回适当的 headers 来对这个源进行授权。所以后端必须有一个「允许的源」列表,如果前端对应的源是被允许的,浏览器才会允许前端向后端发请求,否则就会出现跨域失败。

app = Application()

app.use_cors(
    # 允许跨域请求的 HTTP 方法列表
    # ["*"] 表示支持所有请求
    allow_methods=["GET", "POST"],
    # 允许跨域请求的 HTTP 请求头列表
    # 可以使用 ["*"] 表示允许所有的请求头
    # 当然下面几个请求头总是被允许的
    # Accept、Accept-Language、Content-Language、Content-Type
    allow_headers=["*"],
    # 允许跨域的源列表,例如 ["http://localhost:8080"]
    # ["*"] 表示允许任何源
    allow_origins=["*"],
    # 跨域请求是否支持 cookie,默认是 False
    # 如果为 True,allow_origins 必须为具体的源,不可以是 ["*"]
    allow_credentials=False,
    # 可以被浏览器访问的响应头, 一般很少指定
    expose_headers=["*"],
    # 设定浏览器缓存 CORS 响应的最长时间,单位是秒,一般也很少指定
    max_age=1000
)

以上即可解决跨域问题。

所以过程很简单,就是浏览器检测到前后端不同源时,会先向后端发送一个 OPTIONS 请求。然后从后端返回的响应的 headers 里面,获取上述几个字段,判断前端所在的源是否被允许,如果允许则发请求,如果不允许则跨域失败。

对于大部分路由来说,我们基本不会做限制。但如果有那么几个路由非常重要,比如涉及到支付,我们希望请求只能从指定的源发出,该怎么做呢?

from blacksheep import (
    Application, Request
)

import uvicorn

app = Application()

# 全局 CORS 规则,作用于所有请求
# 默认情况下,全部允许通过
app.use_cors(
    allow_methods=["*"],
    allow_headers=["*"],
    allow_origins=["*"],
)

# 但有些请求访问的资源非常重要,我们需要做一些限制
# 创建一个新的 CORS 规则,名称为 allow_some
app.add_cors_policy(
    "allow_some",
    # 只允许 POST 请求
    allow_methods=["POST"],
    # 源必须是 http://127.0.0.1
    allow_origins=["http://127.0.0.1"],
)

# 规则可以创建多个,如果 allow_* 相关参数不指定
# 那么表示拒绝所有请求
app.add_cors_policy(
    "deny"
)

# 访问没有限制
@app.router.get("/")
async def index():
    return "index"

# 单独应用跨域规则,此时只有源和请求方法正确,才能访问
# 如果是 @app.cors("deny"),那么永远也无法访问
@app.cors("allow_some")
@app.router.post("/payment")
async def payment():
    return "支付相关, 很重要的接口"

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

比较简单,可以使用 jQuery 发请求测试一下。

挂载 Application 对象

我们当前所有的代码都写在一个文件里面,文件名也一直叫 main.py。但生产上,我们的视图函数会有很多个,并组织在不同的文件中,那么这时候路由要如何绑定呢?一个最容易想到的办法是,将所有的视图函数都导入进来,然后通过 app.router.add 注册。

但这不够优雅,最好的办法是每个文件都有一个自己的 app,然后绑定路由,最后只需要注册这些 app 即可。举个例子,在我当前的目录下,有一个启动文件 main.py 和一个目录 views(里面若干文件),具体内容如下:

代码很简单,视图函数根据业务划分在了不同的文件中,每个文件都有自己独立的 app。然后在 main.py 中,将这些子 app 挂载到全局的 app 当中即可,并且还需要指定一个 path,至于它的作用则不言而喻。

结果没有任何问题,如果不需要给子 app 里面的 URL 增加额外的前缀,那么在挂载的时候,第一个参数指定为 "/" 即可。

注:如果存在多个路由绑定的 URL 相同,那么服务启动时会报错。

但是需要注意,里面有一个陷阱:

from blacksheep import (
    Application, Request, 
    redirect, text
)
import uvicorn

app = Application()
child = Application()

@child.router.get("/login")
async def login(request: Request):
    return text("请登录")

@child.router.get("/index")
async def index(request: Request):
    return redirect("/login")

app.mount("/child", child)

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

如果访问 /child/index 会提示资源找不到,因为 redirect 里面的路径是绝对路径,所以会跳转到 /login,而不是 /child/login。但显然我们当前并没有绑定 /login 的路由,而修改办法也很简单,改成相对路径即可。

@child.router.get("/index")
async def index(request: Request):
    # 表示跳转到相对 /child 的路径
    # 因此跳转地址是 /child/login,而不是 /login
    return redirect("./login")

以上就是挂载 Application 对象,类似于 Flask 的蓝图,都是用来对项目进行结构划分的。

视图函数的参数和返回值问题

注意:在使用子 app 创建路由时,视图函数必须接收一个 Request 对象,然后显式地返回一个 Response 对象。什么意思呢?我们先来回顾一下视图函数的参数。

from blacksheep import (
    Application, Request
)
from typing import List
import uvicorn

app = Application()

@app.router.get("/{name}/{age}/{gender}")
async def index(name: str, age: int, gender: str,
                address: str, hobby: List[str],
                request: Request):
    """
    name、age、gender 是路径参数,address、hobby 会被解释成查询参数
    而 request 的类型是 Request,所以它是请求的载体

    blacksheep 会对视图函数的参数进行解析,生成一份依赖树
    有了相关依赖,才能像静态语言一样对参数类型进行要求
    而实现这一点的就是类型注解,比如参数中的 request,它现在是请求的载体
    可如果将它的类型从 Request 改成 str,那么它就会变成普通的查询参数
    """
    return {
        "name": name, "age": age, "gender": gender,
        "address": address, "hobby": hobby,
        "path_params": request.route_values,
        "query_params": request.query
    }

name、age、gender 是路径参数,然后非路径参数、并且类型是以下之一,则会被解释成查询参数。

int, str, bool, float, uuid.UUID, datetime.date, datetime.datetime, List[T], Set[T]

所以 address、hobby 会被解释成查询参数。而 request 的类型是 Request,因此它是请求的载体。

blacksheep 会对视图函数的参数进行解析,生成一份依赖树,有了相关依赖,才能像静态语言一样对参数类型进行要求。而实现这一点的就是类型注解,比如参数中的 request,它现在是请求的载体,可如果将它的类型从 Request 改成 str,那么它就会变成普通的查询参数

请求相关的全部信息都在 Request 对象中,其实只要定义这一个参数就足够了。但路径参数和查询参数也可以直接定义在参数中,这样使用起来会更方便,并且还可以限制类型。

比如查询参数,如果通过 Request 对象获取,那么拿到的始终是一个列表,相当于将类型指定为 List[T]。但如果将类型指定为 T,那么拿到的就是一个标量。

但如果是在子 app 中就需要注意了,子 app 创建路由时的视图函数只能接收一个 Request 对象,然后返回一个 Response 对象。

from blacksheep import (
    Application, Request,
    json
)
import uvicorn

app = Application()
child = Application()

@child.router.get("/users/{user_id}")
async def get_user(request: Request,
                   user_id: int):
    return json({"user_id": user_id})

app.mount("/", child)

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

这里是用子 app 创建的路由,按照之前的理解 user_id 肯定是路径参数,那么结果是不是这样呢?我们发个请求测试一下。

我们看到 user_id 这个参数并没有传递,所以如果你使用子 app、或者说被挂载的 app 创建路由,那么视图函数在执行时只会接收到一个 Request 对象。如果还定义了其它参数,那么这些参数不会被解析,因此执行时会出现 TypeError:参数不够。但由于 Request 对象里面已经包含了请求的全部信息,其实有它就够了,比如上面的例子就可以这么改。

@child.router.get("/users/{user_id}")
async def get_user(request: Request):
    user_id = request.route_values["user_id"]
    if not user_id.isdigit():
        return json({"error": "user_id 必须是数字"})
    return json({"user_id": user_id})

然后是返回值的问题,这里必须显式地返回一个 Response 对象,不会自动包装。

所以除了参数和返回值之外,其它的就没有什么区别了,因为不管是 app 还是 child,它们都是 Application 对象,只是 child 被挂载到了 app 上面。但如果我希望一个路由,不管是子 app 创建的,还是启动应用程序对应的主 app 创建的,在解析视图函数参数的时候,都一视同仁。不要只传一个 request,而是将所有参数都解析了,这该怎么实现呢。

from blacksheep import (
    Application
)
import uvicorn

app = Application()
child = Application()

@child.router.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"user_id": user_id}

# 将该属性设置为 True 即可
# 此时参数和返回值不再受到限制,所有的视图函数的处理逻辑都一样
app.mount_registry.auto_events = True
app.mount("/", child)

此时访问 /users/001 就不再有问题了,如果不将 auto_events 设置为 True,那么视图函数执行时依旧会收到一个 Request 对象,但 user_id 的类型却不是 Request,所以请求会失败。但这里设置成了 True,那么和主 app 创建的路由一样,对应的视图函数会被全面解析:user_id 会被解析成路径参数,至于 Request 对象,由于没有哪个参数的类型是 Request,所以会忽略掉。

最后返回值也不再强制要求是一个 Response 对象,如果不是,会自动包装。

依赖注入

blacksheep 还支持依赖注入,我们自定义的类也可以作为视图函数的参数。举个例子:

import random
from blacksheep import (
    Application, Request,
)
import uvicorn

app = Application()

class GenerateRandom:

    def __init__(self):
        self.number = random.randint(1, 100)

@app.router.get("/get_random")
async def index(request: Request,
                gn: GenerateRandom):
    return {"number": gn.number}

# Application 对象公开了一个 services 属性,可以将类型注册为服务
# 当视图函数的参数签名引用了注册的类型时,该类型的实例会在调用视图函数时自动注入
app.services.add_exact_scoped(GenerateRandom)

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

请求触发时,会自动将类实例化并作为参数传递,并且是每次请求都会实例化。但在某些场景下,我们只需要实例化一次即可,那么该怎么做呢?

# 非常简单,使用 add_exact_singleton 方法来注册即可
# 这样只有在第一次请求的时候实例化,后续就一直使用第一次创建的实例
app.services.add_exact_singleton(GenerateRandom)

其它代码不变,我们重新测试一下:

生成的随机数都是一样的,因为只有第一次请求的时候创建了实例,后续的 9 次请求用的也都是同一个实例。

  • app.services.add_exact_scoped 注册的类型,每次请求的时候都会实例化
  • app.services.add_exact_singleton 注册的类型,只有第一次请求的时候会实例化

但是这两个方法都没有办法加参数,所以还有一种方式:

import random
from blacksheep import (
    Application, Request,
)
import uvicorn

app = Application()

class GenerateRandom:

    def __init__(self, a, b):
        self.number = random.randint(a, b)

@app.router.get("/get_random")
async def index(request: Request,
                gn: GenerateRandom):
    return {"number": gn.number}

# 直接添加一个实例进去
app.services.add_instance(GenerateRandom(1, 100))

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

这种模式也是单例的,后续会从 services 中查找类型为 GenerateRandom 的实例,这种做法比 add_exact_singleton 更常用。

那么问题来了,这种依赖注入的逻辑有什么用呢?毫无疑问,肯定是数据共享,特别是数据库连接池。假设我们创建了一个数据库连接池,那么如何在视图函数中使用它呢?

import random
from blacksheep import (
    Application, Request,
)
from sqlalchemy.ext.asyncio import (
    create_async_engine, AsyncEngine
)
from sqlalchemy.engine.url import URL
from sqlalchemy import text
import uvicorn

app = Application()

# 创建一个数据库引擎,它一般会定义在单独的文件中
def create_database_engine():
    engine = create_async_engine(
        URL.create("mysql+asyncmy", username="root", password="123456",
                   host="82.157.146.194", port=3306, database="mysql")
    )
    return engine

# 最普遍的做法是导入 engine,然后直接用
# 而通过依赖注入,我们可以实现的更简单一些
@app.router.get("/get_data")
async def index(request: Request,
                engine: AsyncEngine):
    # sqlalchemy 中的异步引擎的类型是 AsyncEngine
    # 将它注册在 services 中,这里就可以直接用了
    query = text("SELECT name, age, address FROM girl")
    # 引擎维护了一个连接池,通过 async with 取出一个连接
    # 执行完毕之后,再放回连接池
    async with engine.connect() as conn:
        rows = await conn.execute(query)
    # 获取返回的字段名
    columns = rows.keys()
    # 遍历 rows,将字段名和每一行数据拼接成字典,然后返回
    results = [dict(zip(columns, row)) for row in rows]
    return results

app.services.add_instance(create_database_engine())

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

访问成功,所以通过依赖注入的方式,不管引擎是在什么地方创建的,只要它注册在了 services 中,那么在视图函数里面就可以直接用。而像数据库连接这种,它应该在应用程序启动的时候就创建好,所以 blacksheep 提供了两个触发器列表,分别表示在程序启动和关闭时执行的一系列操作。

from blacksheep import (
    Application, Request,
)
from sqlalchemy.ext.asyncio import (
    create_async_engine, AsyncEngine
)
from sqlalchemy.engine.url import URL
from sqlalchemy import text
import uvicorn

app = Application()

# 最普遍的做法是导入 engine,然后直接用
# 而通过依赖注入,我们可以实现的更简单一些
@app.router.get("/get_data")
async def index(request: Request,
                engine: AsyncEngine):
    return str(engine)

async def create_database_engine(app: Application):
    print("我要创建引擎啦")
    engine = create_async_engine(
        URL.create("mysql+asyncmy", username="root", password="123456",
                   host="82.157.146.194", port=3306, database="mysql")
    )
    # 将引擎注册进去
    app.services.add_instance(engine)

async def dispose_database_engine(app: Application):
    # 通过 app.services.add_instance 将实例注册进去之后
    # 在视图函数中只要指定好类型,那么就会自动取出来,这没有问题
    # 但如果不在视图函数中呢?所以 app 提供了一个 service_provider
    # 注册在 services 里面的实例,都会保存在 service_provider 中
    
    # 我们直接像访问字典一样获取实例即可,那么问题来了,key 是啥?
    # 很简单,我们在注册实例(假设叫 obj)的时候,会执行以下操作
    """
    app.service_provider[type(obj)] = obj
    app.service_provider[type(obj).__name__] = obj
    app.service_provider[type(obj).__name__.lower()] = obj
    """
    engine: AsyncEngine
    # 所以可以这么获取
    engine = app.service_provider[AsyncEngine]
    engine = app.service_provider["AsyncEngine"]
    engine = app.service_provider["asyncengine"]
    # 以上几个 engine 都指向同一个对象
    # 最后,如果是驼峰命名法,那么还可以这么获取
    engine = app.service_provider["async_engine"]
    # 方式比较多,我们以类型作为 key 去获取实例即可
    
    # 关闭引擎,并关闭底层连接池
    await engine.dispose()
    
    print("引擎关闭啦")

# app.on_start 和 app.on_event 都是 ApplicationEvent 实例
# 前者保存了程序启动时要执行逻辑,后者保存了程序关闭时要执行的逻辑
app.on_start += create_database_engine
app.on_stop += dispose_database_engine

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

我们再解释一下代码逻辑,首先是 app.on_start 和 app.on_stop,它们负责保存程序启动和关闭时的执行逻辑。估计有人看到 += 这种操作有些别扭,我个人也对这种设计感到不适应,看一下源码就知道了。

+= 对应的魔法方法是 __iadd__,重写之后在里面执行列表的 append;-= 则是移除,执行 remove,所以本质上还是使用列表来存储的。

当程序启动时,会遍历 app.on_start 里面的列表,然后依次执行;同理当程序关闭时,会遍历 app.on_stop 里面的列表,并依次执行。并且执行的时候使用了 await 表达式,证明我们添加的必须是 awaitable 对象,一般都是协程。此外,执行时还额外传递了一个 self.context,也就是 app,因此我们定义的协程要接收一个 app 对象。

  • 在 create_database_engine 协程中我们创建了引擎,并注册成服务,因此在程序启动时它就创建好了。而一旦注册,那么就会被保存了 service_provider 中。可以像字典一样操作它,value 是实例本身,key 可以实例的类型、实例的类型的名称(并且除了本身的名字之外,还可以是全小写等等)
  • 在 dispose_database_engine 协程中我们获取了引擎,并关闭它和它内部连接池,在程序退出时会执行

而在视图函数中,我们也定义了一个 engine 参数。那么当请求访问时,会根据类型注解去 service_provider 中查找对应的 value,并在调用函数时传递给它。而在函数中,我们将它转成字符串返回。

访问没有问题,然后再来看看终端的输出。

整个过程没有任何问题,以后当你有实例对象需要被多个视图函数依赖时,便可以将它注册到 services 中。这样在使用的时候就不需要来回导入了,直接放在参数里面即可。特别是数据库、Redis 连接这种,非常适合通过依赖注入的方式。

但这里估计有人想到了,如果注册了多个引擎会怎么样呢?在 service_provider 中,是以类型作为 key 的,由于多个引擎的类型相同,那么显然会发生冲突,因此程序在启动时会报错。

那么这个时候应该怎么做呢?很简单,自定义一个类,将多个引擎放在同一个类里面不就可以了吗。

from blacksheep import (
    Application, Request,
)
import uvicorn

app = Application()

class AsyncEngineManager(dict):
    """
    将多个引擎保存一个类的实例中,这里简单继承一个字典
    添加、删除、查询等逻辑,直接通过父类实现

    你也可以手动管理引擎,并根据自身场景,实现更多功能
    """

# ApplicationEvent 还实现了 __call__,所以还可以通过装饰器的方式
# @app.on_start
async def create_database_engine(app: Application):
    engine_manager = AsyncEngineManager()
    engine_manager["master"] = "我是连接主节点的引擎"
    engine_manager["replica1"] = "我是连接从节点1的引擎"
    engine_manager["replica2"] = "我是连接从节点2的引擎"
    engine_manager["replica3"] = "我是连接从节点3的引擎"
    app.services.add_instance(engine_manager)

async def dispose_database_engine(app: Application):
    # 引擎关闭逻辑
    ...

app.on_start += create_database_engine
app.on_stop += dispose_database_engine

@app.router.get("/get_data")
async def index(engine_manager: AsyncEngineManager):
    return engine_manager

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

结果正常,但是还有一个隐藏的问题,就是挂载 Application 的时候。

from blacksheep import (
    Application, Request,
    json
)
import uvicorn

class AsyncEngineManager(dict):
    ...

async def create_database_engine(app: Application):
    engine_manager = AsyncEngineManager()
    engine_manager["master"] = "我是连接主节点的引擎"
    engine_manager["replica1"] = "我是连接从节点1的引擎"
    engine_manager["replica2"] = "我是连接从节点2的引擎"
    engine_manager["replica3"] = "我是连接从节点3的引擎"
    app.services.add_instance(engine_manager)

app = Application()
child = Application()

app.on_start += create_database_engine

@child.router.get("/get_data")
async def index1(engine_manager: AsyncEngineManager):
    return engine_manager
  
@app.router.get("/get_data")
async def index2(engine_manager: AsyncEngineManager):
    return engine_manager  

# 子 app 注册的路由,视图函数如果想接收 Request 对象之外的其它参数
# 那么别忘记将这里的 auto_events 设置为 True
app.mount_registry.auto_events = True
app.mount("/child", child)

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

总共两个路由,一个是 child.router 创建的,一个是 app.router 创建的。由于将 auto_events 设置为 True,那么视图函数的处理逻辑是一样的,我们发请求测试一下。

结果让人意外,访问子 app 创建的路由时,返回了一个 400 错误。至于原因也很简单,执行视图函数之前,肯定要先确定参数 engine_manager 是什么。由于参数类型是 AsyncEngineManager,那么会以该类型作为 key,去 service_provider 中查找 value。

相信你已经明白原因了:

  • 访问 /get_data,会从 app.service_provider 中查找
  • 访问 /child/get_data,会从 child.service_provider 中查找

但很明显,实例并没有在 child.services 中注册,因此我们需要注册一下。

from blacksheep import (
    Application
)
import uvicorn

class AsyncEngineManager(dict):
    ...

async def create_database_engine(app: Application):
    engine_manager = AsyncEngineManager()
    engine_manager["master"] = "我是连接主节点的引擎"
    engine_manager["replica1"] = "我是连接从节点1的引擎"
    engine_manager["replica2"] = "我是连接从节点2的引擎"
    engine_manager["replica3"] = "我是连接从节点3的引擎"
    app.services.add_instance(engine_manager)
    # app.mount_registry.mounted_apps 是一个列表,因为子 app 可以挂载多个
    # 列表里面存放的是 Route 对象,而它的 handler 属性被设置成了子 app
    for route in app.mount_registry.mounted_apps:
        route.handler.services.add_instance(engine_manager)

app = Application()
child = Application()

app.on_start += create_database_engine

@child.router.get("/get_data")
async def index1(engine_manager: AsyncEngineManager):
    return engine_manager

@app.router.get("/get_data")
async def index2(engine_manager: AsyncEngineManager):
    return engine_manager

app.mount_registry.auto_events = True
app.mount("/child", child)

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

此时访问就都没有问题了,因此在注册共享实例时,别忘记也要注册进子 app 的 services 中。

ASGI

介绍 ASGI 之前需要先说一说什么是 WSGI,另外可能有人分不清 WSGI、uwsgi、uWSGI、Nginx 之间的区别,我们来总结一下:

  • WSGI:全称是 Web Server Gateway Interface(Web 服务器网关接口),它不是服务器、Python 模块、框架、API 或者任何软件,只是一种描述 Web 服务器和 Web 应用程序(使用 Web 框架,如 Django、Flask 编写的程序)进行通信的规范、协议。由于任何一个框架编写的服务都必须运行在 Web 服务器上,所以这两者必须遵循相同的通信规范,而这个规范就是 WSGI。另外这些框架本身自带了一个小型 Web 服务器,但只用于开发和测试。
  • uWSGI:一个实现了 WSGI 协议的 Web 服务器,所以我们把使用 Web 框架编写好的服务部署在 uWSGI 服务器上是可以直接对外提供服务的。当然 WSGI 服务器除了 uWSGI 之外,还有 Gunicorn 等等,它们不仅实现了 WSGI 协议,还实现了 uwsgi 协议和 HTTP 协议。
  • 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 诞生于一个支离破碎的 Web 应用框架,在 WSGI 之前,选择一个框架可能会限制 Web 服务器的可用接口的种类,因为两者之间没有标准化的接口,而 WSGI 通过提供一个简单的 API 让 Web 服务器与 Python 框架对话来解决这个问题。2004 年,随着 PEP-333 的出现,现已成为 Web 应用程序部署的实际标准。

WSGI 规范的核心是一个简单的 Python 函数,下面是我们可以构建的最简单的 WSGI 应用程序。

# main.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 协议所规范。

然后我们打印 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,可以看到输出的消息。

此时我们就了解了 ASGI 以及它与 WSGI 的区别。然后 uvicorn 是一个 ASGI 服务器(实现了 ASGI 协议),我们还需要一个 ASGI 框架,而不是在文件里面只写一个 application 协程(当然 ASGI 框架也是在此之上一点点搭起来的)。至于 ASGI 框架有很多,像 Sanic、FastAPI,还有我们此刻在学习的 blacksheep,它们都是 ASGI 框架,实现了 ASGI 协议。至于用这些框架编写的服务,也必须运行在实现 ASGI 协议的服务器上,而 ASGI 服务器有 uvicorn、hypercorn 等等。

只要框架和服务器都实现相同的协议,那么便可以部署应用程序。比如把 blacksheep 里面的 app 换成 FastAPI 里面的 app,依旧可以跑在 uvicorn 上面,甚至连启动方式都不需要变。因为 blacksheep 和 FastAPI 都是 ASGI 框架,它们当然都可以跑在 ASGI 服务器上。

交互式文档

通过相关配置,可以让 blacksheep 提供一个类似于 Swagger 的交互式文档,

from blacksheep import (
    Application, Request
)
from blacksheep.server.openapi.v3 import OpenAPIHandler
from openapidocs.v3 import Info
import uvicorn

app = Application()

docs = OpenAPIHandler(
    info=Info(title="Example API", version="0.0.1"),
    # 以下几个参数都是默认的,可以自由调整
    # ui_path="/docs",
    # json_spec_path="/openapi.json",
    # yaml_spec_path="/openapi.yaml",
    # preferred_format=Format.JSON,
    # anonymous_access=True
)
docs.bind_app(app)

@app.router.get("/index")
async def index(request: Request):
    pass

@app.router.get("/login")
async def login(request: Request):
    pass

@app.router.post("/post")
async def post(request: Request):
    pass

if __name__ == '__main__':
    uvicorn.run("main:app", port=5555)

我们输入 localhost:5555/docs 即可进入。

注意一下左上角的 /openapi.json,可以点进去,会发现里面包含了我们定义的路由信息。

这个和 FastAPI 还是挺相似的。

Application 对象

再来看一看 Application,它是用来创建 app 的,负责处理应用程序的生命周期(启动、工作、停止)、路由、web 请求,异常等等。

我们来看看异常,blacksheep 会捕获请求处理期间发生的任何未处理的异常,并产生 HTTP 500 响应。要在实践中看到这一点,像下面这样启动一个应用程序:

@app.router.get("/")
def crash_test():
    raise Exception("崩溃啦")

如果访问 /,那么会产生一个 500 异常,并且异常的细节对客户端是隐藏的,不然可能有安全风险。然而在开发和调查问题时,如果能直接从失败的 web 请求中获取错误细节,则是很有用的。因此我们可以开启错误详细信息:

from blacksheep import (
    Application, Request
)
import uvicorn

app = Application(show_error_details=True)

@app.router.get("/")
def crash_test():
    raise Exception("崩溃啦")

if __name__ == '__main__':
    uvicorn.run("main:app", port=5555)

此时服务器产生的 500 错误就会暴露给客户端,包含异常的详细信息和完整的堆栈跟踪。不过只建议在开发的时候这么做,生产上一定不要这样,太粗糙了。

如果不指定 show_error_details 为 True,那么显示的就是一行文字:Internal server error.

然后 app 还有一个 exceptions_handlers 属性,这是一个字典,我们可以将异常和 handler 组成键值对存进去,如果视图函数出现异常,就会去 exceptions_handlers 中寻找和指定异常匹配的 handler。

from blacksheep import (
    Application, Request, Response,
    Content
)
import uvicorn
import orjson

app = Application(show_error_details=False)

# handler 格式如下,self 是 app
# request 就是请求的载体,exc 则是发生的异常
async def indexerror_handler(
        self, request: Request, exc: IndexError
) -> Response:
    errors = orjson.dumps({"error": "出现了 IndexError",
                           "exc": str(exc),
                           "index": request.query["index"][0]})
    return Response(404, None, Content(b"application/json", errors))

class CustomException(Exception):
    pass

async def custom_exception_handler(
        self, request: Request, exc: CustomException
) -> Response:
    errors = orjson.dumps({"error": "出现了 CustomException",
                           "exc": str(exc)})
    return Response(404, None, Content(b"application/json", errors))

# 如果视图函数执行出现 IndexError,那么就去执行 indexerror_handler
# 里面的 self 是 app,request 就是本次请求对应的 Request 对象,exc 就是异常
app.exceptions_handlers[IndexError] = indexerror_handler
# 如果视图函数执行出现 CustomException,那么就去执行 custom_exception_handler
app.exceptions_handlers[CustomException] = custom_exception_handler

@app.router.get("/index_error")
def raise_index_error(index: int):
    if index > 5:
        raise IndexError("索引越界啦")

@app.router.get("/custom_exception")
def raise_custom_exception():
    raise CustomException("自定义异常")

if __name__ == '__main__':
    uvicorn.run("main:app", port=5555)

这个设计还是挺不错的,如果有很多的视图函数,它们的内部可能出现相同的异常,那么就可以将异常检测这部分逻辑抽离成一个单独的 handler。然后在视图函数里面直接 raise 即可,会统一执行异常对应的 handler,而不需要在每个视图函数里面将异常检测逻辑重复一遍。

当然啦,将异常和 handler 进行绑定还可以使用装饰器的方式。

@app.exception_handler(IndexError)
async def indexerror_handler(
        self, request: Request, exc: IndexError
) -> Response:
    errors = orjson.dumps({"error": "出现了 IndexError",
                           "exc": str(exc),
                           "index": request.query["index"][0]})
    return Response(404, None, Content(b"application/json", errors))

和我们直接使用字典添加是一样的,看一下源码就清楚了。

然后 Application 对象还暴露了三种事件:分别是 on_start、after_start、on_stop,这三者都接收一个 app 参数。除了 after_start 之外,剩下两个我们已经说过了,而 after_start 是在 on_start 之后发生的,它们关系如下。

在工作中我们一般只用 on_start 和 on_stop,after_start 发生在 on_start 之后,此时路由已经设置完毕。

@app.after_start
async def after_start_print_routes(app: Application) -> None:
    print(app.router.routes)

比如在 after_start 中,我们可以记录所有已存在的路由。

uvicorn 部署 blacksheep 服务

目前我们算是介绍了 blacksheep 的绝大部分内容,最后再来看看 blacksheep 服务的部署。其实部署很简单,直接 uvicorn.run 即可,但是这里面有很多的参数,我主要是想要介绍这些参数。

  • app:应用程序 Application 对象
  • host:监听的 IP,默认 127.0.0.1
  • port:监听的端口,默认 8000
  • uds:绑定的 unix domain socket,一般不用
  • fd:从指定的文件描述符中绑定 socket
  • reload:文件发生变化时,是否自动重启,默认为 False。如果指定为 True,程序对文件进行监视,当文件发生变更,自动重启
  • reload_dirs:哪个目录的文件发生变化,自动重启,默认是当前目录(前提是 reload 参数为 True)
  • reload_include:一个列表,包含一系列全局模式,表示哪些类型的文件变化了,自动重启,默认只有 *.py 文件
  • reload_exclude:一个列表,包含一系列全局模式,表示让应用程序忽略掉指定类型的文件,它们是否发生变化,和应用程序是否重启没有关系
  • reload_delay:应用程序每隔多少秒检查一次,文件是否发生变更
  • workers:工作进程数,默认会读取环境变量 $$WEB_CONCURRENCY,没有的话默认为 1
  • loop:事件循环实现,可选项为 auto|asyncio|uvloop|iocp,选项 auto 表示根据当前环境选择一个最合适的事件循环
  • http:HTTP 协议实现,可选项为 auto|h11|httptools
  • ws:WebSocket 协议实现,可选项为 auto|none|websockets|wsproto
  • ws_max_size:WebSocket 消息的最大字节数,默认 16777216
  • ws_ping_interval:WebSocket 连接每隔多久 ping 一次,默认 20.0
  • ws_ping_timeout:WebSocket 在 ping 的时候的延迟,默认 20.0
  • ws_per_message_deflate:WebSocket 消息是否开启压缩,默认为 True
  • lifespan:lefespan 实现,可选项为 auto|on|off,默认为 auto
  • interface:应用程序接口使用的协议,可选项为 auto|ASGI3|ASGI2|WSGI,默认为 auto
  • env_file:指定环境变量配置文件
  • log_config:日志格式配置文件,支持 .ini、.json、.yaml
  • log_level:日志等级,默认为 info
  • access_log:是否记录日志,默认为 True
  • use_colors:是否带颜色输出日志信息,默认为 True
  • proxy_headers:是否使用 X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port 填充远程地址信息,默认为 True
  • server_header:服务端是否返回默认的头部信息,默认为 True
  • date_header:服务端是否返回的头部信息中是否包含 Date 字段,默认为 True
  • forwarded_allow_ips:一个列表,指定受信任的代理 IP
  • limit_concurrency:并发的最大数量
  • limit_max_requests:能 hold 的最大请求数
  • backlog:全连接队列的长度,就是 socket.listen 里面的 backlog,默认 2048
  • timeout_keep_alive:保持长连接的最大时间,默认 5 秒,如果 5 秒内没有收到消息,则关闭 Keep-Alive 连接
  • timeout_graceful_shutdown:等待正常关闭的最大时长
  • ssl_keyfile、ssl_certfile、ssl_keyfile_password、ssl_version、ssl_cert_reqs、ssl_ca_certs、ssl_ciphers:用于配置 SSL
  • headers:一个列表,里面是 'key:value' 格式的键值对,会作为默认的 HTTP 响应头返回

有兴趣可以试一下这些参数,看看将参数设置为不同的值,blacksheep 会有什么表现。

然后我们启动的时候,除了可以像下面这样在代码中调用 uvicorn.run 之外:

from blacksheep import Application
import uvicorn

app = Application(show_error_details=False)

@app.router.get("/index")
def index():
    return "Hello World"

if __name__ == '__main__':
    uvicorn.run("main:app", port=5555)

还可以通过命令行的方式启动,我们修改一下代码:

from blacksheep import Application
import uvicorn

app = Application(show_error_details=False)

@app.router.get("/index")
def index():
    return "Hello World"

此时就不需要 uvicorn.run 了,我们通过命令行来启动。

程序启动成功,可以发请求测试一下。注意:如果是通过命令行的方式启动,那么参数要将参数中的 _ 替换成 -,比如 reload_dirs 要改成 --reload-dirs。

小结

总的来说,blacksheep 在使用设计上和 FastAPI 各有千秋,但它的速度是真的快,至于能不能放在生产上使用,则看你对该框架的熟悉程度。总之就生态来说,blacksheep 是比不上 FastAPI 的。另外我们也清楚,性能的瓶颈基本不在框架上面,而是取决于数据库,所以在使用 ASGI 框架的时候,还要搭配一个支持协程的驱动以及 ORM。

驱动的话推荐 asyncmy, asyncpg 等等,而 ORM 这里我推荐 SQLAlchemy(1.4 版本开始支持协程)。

posted @ 2023-05-25 19:59  古明地盆  阅读(2223)  评论(0编辑  收藏  举报