Sanic:一款号称 Python 中性能最高的异步 web 框架,全方位介绍 Sanic

楔子

这次我们来介绍一个 web 框架:Sanic,它既是一个 web 框架,同时也是一个 web 服务器。关于框架,首先浮现在脑海中的就是 Flask、Django 之类的,但它们都是同步框架,而现在是一个高并发的时代,并发量是在构建服务时必须考量的一个指标。所以我们自然就想到了 Python 中的异步框架,而提到异步框架,那么就必须要提  Sanic、FastAPI,这两个异步框架都很优秀,但是 Sanic 的表现要更加出色,使用 Sanic 构建的应用程序足以比肩 Nodejs。如果你再对 Sanic 在路由处理方面使用 C 语言做一些重构,那么并发性能可以和 Go 相媲美。

那么 Sanic 为什么这么快呢?肯定是有原因的,首先它是一个异步框架,使用了 Python 中的协程。而提到协程必然少不了事件循环,而事件循环的构建依赖于 asyncio 这个库,但 asyncio 构建的事件循环效率是不高的,所以有一个第三方库 uvloop。uvloop 使用 Cython 编写,基于 libuv,它可以让 asyncio 变得更快。当然即便我们不开发 Sanic 服务,也可以使用 uvloop 来替换 asyncio 内部的事件循环。

import asyncio
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

只需要以上几步,便可以让 asyncio.get_event_loop() 构建一个速度更快的事件循环。但是有一点需要注意,uvloop 不支持 Windows 系统,但是这并不妨碍我们在 Windows 上开发 Sanic 服务,因为内部虽然会导入 uvloop,但是做了一个异常捕获,所以导入失败话,会使用原生的 asyncio 事件循环。因此即使你用 Windows,安装 Sanic 也没有问题,因为 uvloop 不会自动安装。但如果是在 Linux 上,uvloop 会自动帮我们安装。

此外在解析 json 的时候,使用的不是标准库中的 json,而是 ujson,ujson 解析数据也要比标准库的 json 快很多。当然 ujson 也不会在安装 Sanic 的时候自动安装,也需要我们手动安装,但它是支持 Windows 平台的。

而在语法特性层面,Sanic 提供了和 Flask 类似的路由注册模式,即可以通过装饰器的模式,而且 Sanic 内部也有 "蓝图" 这个概念,用于对业务进行逻辑分离。如果你会 Flask 的话,那么很容易上手 Sanic,因为开发模式非常类似,当然不熟悉 Flask 也没有关系,因为 Sanic 本身和 Flask 一样简单。

下面我们来安装 Sanic,直接 pip install sanic 即可,安装之后我们就来学习如何使用 Sanic 框架编写我们的 web 服务。

请求与响应

我们来看看编写一个简单的 web 服务是什么样子的。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)

# 创建一个 Sanic 实例对象, Sanic 是 web 服务的入口类, 它等价于 flask.Flask
# 然后里面传递一个字符串, 为我们的应用起一个名字
app = Sanic("sanic_service")

# 这里通过 @app.get("路由") 的方式和一个视图函数进行绑定
# 视图函数中的 request 参数会接收一个 sanic.request.Request 实例对象, 它包含了接收到的请求中的所有信息
# 比如我们想获取 url 中的查询参数, 那么就需要通过这里的 request 获取, 这个参数在请求到来时会自动传递
@app.get("/index")
async def index(request: request.Request):
    # 这个参数我们暂时先不用, 但是一定要有
    # 这里直接返回一个字符串
    return response.text("hello world")


# host: 监听的IP, port: 监听的端口, auto_reload: 修改代码之后是否自动重启
app.run(host="127.0.0.1", port=8888)

怎么样,是不是很简单呢?整个过程和 Flask 框架非常类似,这里我们需要提一下里面的 response,这是一个模块。我们返回的时候不能直接返回一个字符串,而是要返回一个 response.HTTPResponse 对象,所以需要通过 response.text 函数进行包装,我们来看一下源码 sanic/response.py。

def text(
    body, status=200, headers=None, content_type="text/plain; charset=utf-8"
):
    if not isinstance(body, str):
        raise TypeError(
            f"Bad body type. Expected str, got {type(body).__name__})"
        )

    return HTTPResponse(
        body, status=status, headers=headers, content_type=content_type
    )

我们看到底层直接调用了 HTTPResponse,所以我们也可以这么做,除了 response.text,还有 response.json、response.html、response.raw 等等。它们无一例外都调用了 HTTPResponse,只不过 content_type 自动帮你设置好了而已。

def raw(
    body, status=200, headers=None, content_type="application/octet-stream"
):
    return HTTPResponse(
        body=body,
        status=status,
        headers=headers,
        content_type=content_type,
    )


def html(body, status=200, headers=None):
    if hasattr(body, "__html__"):
        body = body.__html__()
    elif hasattr(body, "_repr_html_"):
        body = body._repr_html_()
    return HTTPResponse(
        body,
        status=status,
        headers=headers,
        content_type="text/html; charset=utf-8",
    )

我们看到没什么好神奇的,返回都是 HTTPResponse 对象,可以设置状态码、响应头、响应类型,而 text、json、html 等函数也是如此,只不过自动帮你设置了返回的状态码,以及指定了一个合适的响应类型。如果是使用 HTTPResponse,则需要我们自己指定,就这么简单。

指定配置

Sanic 也提供了和 Flask 相似的方式,来加载配置。

app = Sanic("sanic_service")
# app.config 是一个 Config 对象, 可以存储相应的配置, 有以下几种加载方式
app.config.user_name = "古明地觉"  # 通过属性查找的方式设置
app.config["password"] = "地灵殿"  # 通过字典的方式设置
app.config.update_config({"gender": "female", "sister": "古明地恋"})  # 使用 update 的方式, 字节传入一个字典
app.config.update_config("config.py")  # 还可以从文件中加载配置
# 还可以通过环境变量的方式, 会从当前系统中寻找 PATH 对应的环境变量, 但必须是 ${PATH} 的形式
app.config.update_config("${PATH}/config.py")  
# 甚至里面还可以接收其它任意的对象, 会读取对应的属性字典 __dict__
# 当然还可以使用 app.config.from_pyfile 以及 app.config.from_object, 但是要被移除了, 所以不推荐使用了
# 但是说实话, 上面的那些难道还不够用吗?

然后我们也可以获取指定的属性,来看一下:

app = Sanic("sanic_service")
app.config.user_name = "古明地觉" 
app.config["password"] = "地灵殿"  

print(app.config.user_name)  # 古明地觉
print(app.config["user_name"])  # 古明地觉
print(app.config.get("password"))  # 地灵殿
print(app.config.get("不存在"))  # None
print(app.config.get("不存在", "xxx"))  # xxx
# 还有 app.config.keys()、values()、items() 等等, 可以自己查看

当然这里也有一个约定,就是配置的名称要大写,但这无关紧要。另外如果你打印 app.config.keys() 会发现有很多很多 key,这是因为 Sanic 内部本身有很多开箱即用的配置,直接就在 app.config 里面了,下面我们就来罗列一下。

以 WEBSOCKET 开头的配置都是针对于 websocket 连接的,然后重点解释一下里面的几个 TIMEOUT。

REQUEST_TIMEOUT:

请求与 Sanic 服务端建立 TCP 连接到 Sanic 服务端接收到整个 HTTP 请求直接的时间间隔,如果花费的时间超过了 REQUEST_TIMEOUT(单位秒),这会被视为客户端错误,因此 Sanic 会生成 HTTP 408 响应并将其发送给客户端。如果你访问你服务的请求会都需要传递非常大的有效负载、或上传的请求非常慢,那么不妨将这个值设置的高一些。

RESPONSE_TIMEOUT:

Sanic 服务端将 HTTP 请求传递给应用程序,到客户端接收到 HTTP 响应之间的时间间隔。如果花费的时间超过 RESPONSE_TIMEOUT 值(单位秒),这被视为服务器错误,Sanic 会生成 HTTP 503 响应并将其发送到客户端。如果你的应用程序可能有非常复杂的逻辑,或者有很高的 IO 等等,那么请将此参数的值设置的高一些。

KEEP_ALIVE_TIMEOUT:

Keep-Alive 是 HTTP 1.1 中客户端(通常是web浏览器应用程序)可以设置的一个头部信息,当该参数为真时,那么会告诉服务器,你在把响应发给我之后先别急着关闭连接,可能我一会还要找你。这允许客户端重用现有的 TCP 连接来发送后续的 HTTP 请求,并确保客户端和服务器的网络通信更有效。

但服务端表示不能你说不断开就不断开啊,这得我说了算,因此 app.config  中的 KEEP_ALIVE 参数就是做这件事的。如果设置为 False,那么会在发送响应之后立即关闭连接,而不管客户端的 Keep_Alive 字段。

如果是 True 的话,那么会遵循客户端的指示,但如果客户端一直不回信,也不能一直傻等着,所以 KEEP_ALIVE_TIMEOUT 就是等待的最大时长,默认是 5s。

请求:request.Request

下面我们来看一下请求,我们说请求会被封装成一个 Request 对象传递给视图函数的第一个参数,那么它都有哪些属性或者方法呢?基本上你能想到的,它都拥有。我们随便挑几个吧:

  • request.url: 请求的 url
  • request.path: 请求的 url 去掉 http://ip:port 之后的部分
  • request.method: 请求的方法, GET、POST、PUT等等
  • request.headers: 请求时所携带的头部信息
  • request.cookies: 请求时所携带的cookie, 一个 CookieJar 对象
  • request.content_type: 请求的类型
  • request.query_args: 所有的查询参数, 以列表嵌套元组的形式; 比如?a=1&b=2&a=3, 那么结果就是 [('a', '1'), ('b', '2'), ('a', '3')], 不常用, 等价于 request.get_query_args()
  • request.args: 非常常用, 等价于 request.get_args(), 返回一个 request.RequestParameters 对象; 比如?a=1&b=2&a=3, 那么结果就是 {'a': ['1', '3'], 'b': ['2']}; RequestParameters 继承自 dict, 我们可以调用 get、keys、values等方法; 但是注意 get 获取的时候只会返回第一个值, 比如这里的 request.args.get("a") 只会返回 '1', 如果想返回一个列表, 那么需要调用 getlist

和 Flask 是不是很相似呢?基本都是一样的,当然 request 还有很多其它属性,可以自己查看,基本上都是相同的东西。而且还有表单上传、文件上传等等,这些等介绍到时候再说吧,下面我们针对上面的属性来随便挑几个举例说明一下。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.get("/index")
async def index(request: request.Request):
    lst = []
    lst.append(f"请求的url: {request.url}")
    lst.append(f"请求的path: {request.path}")
    lst.append(f"请求的方法: {request.method}")
    lst.append(f"请求的查询参数: {request.query_args}")
    lst.append(f"请求的查询参数: {request.args}")
    lst.append(f"a: {request.args.get('a')}, "
               f"b: {request.args.get('b')}, "
               f"a_lst: {request.args.getlist('a')}, "
               f"c: {request.args.get('c')}, "
               f"c: {request.args.get('c', 'xxx')}")
    return response.text("\n".join(lst))


app.run(host="127.0.0.1", port=8888)

我们在浏览器中输入:http://localhost:8888/index?a=1&b=2&a=3,得到如下内容。

响应

相应我们之前已经提到了,Sanic 使用 sanic.response 这个子模块提供响应的,该模块可以生成多种格式的 HTTP 响应,包括纯文本(Plain Text)、HTML、JSON、文件(File)、数据流(Streaming)、文件流(File Streaming)、重定向(Redirect)、原生数据(Raw)。对应的响应函数如下:

  • response.text()
  • response.html()
  • response.json()
  • response.raw()
  • response.file(), 异步调用
  • response.file_stream(), 异步调用
  • response.stream()
  • response.redirect()

所有返回的响应都是一个 HTTPResponse 对象(或 StreamingHTTPResponse对象),而且这两个类都派生自 BaseHTTPResponse。

正如之前说的,大多数情况下,我们的 web 应用返回的都是 HTTPResponse 对象,这包括纯文本(Plain Text)、HTML、JSON、文件(File)、重定向(Redirect)、原生数据(Raw),它们的不同,主要体现在这个类的初始化参数 content_type 上面。下面我们看一下它们的用法:

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.get("/text")
async def text(request: request.Request):
    # 里面传入一个字符串即可, 至于状态码、响应头可以根据自身需求决定是否设置
    return response.text("text")

@app.get("/json")
async def json(request: request.Request):
    # 第一个参数传入一个字典即可
    return response.json({"name": "古明地觉", "age": 16}, ensure_ascii=False)

@app.get("/html")
async def html(request: request.Request):
    # 里面也接收一个字符串, 如果是文件的话, 可以先读取出来
    # 当然还可以使用 jinja2 进行渲染
    return response.html("<h3>古明地觉</h3>")

@app.get("/file")
async def file(request: request.Request):
    # 由于调用 response.file 会得到一个协程, 因此我们需要使用 await
    # 接收如下参数:
    """
    location: 要返回的文件的路径, 必传参数
    status: 成功响应时的 http 状态码 200, 正常返回的话无需修改
    mime_type: 如果为空, 内部会通过 mimetypes 模块的 guess_type 函数进行推断
    headers: 自定义 http 响应头
    filename: 文件名, 如果传递了会写入到响应头 headers 的 Content-Disposition 中
    """
    return await response.file("1.txt")

@app.get("/raw")
async def raw(request: request.Request):
    # 和 text 非常类似, 但是它第一个参数接收的不是字符串、而是字节串
    return response.raw(b"komeiji satori")

@app.get("/stream")
async def stream(request: request.Request):
    # Sanic 返回流数据给浏览器, 不是一次性把所有数据返回, 而是一部分一部分地返回
    # 参数如下:
    """
    streaming_fn: 用于写流响应数据的协程函数
    status: 状态码, 默认 200
    headers: 自定义的 http 响应头
    content_type: 默认是纯文本的 content_type, 可以根据实际情况进行修改
    """
    # 定义协程函数
    async def streaming_fn(res):
        await res.write("你好呀\n")
        import asyncio
        await asyncio.sleep(3)
        await res.write("古明地觉")
    # 但是浏览器访问的时候, 并不是先看到 "你好呀\n", 然后 3s 后看到 "古明地觉"
    # 而是 3s 之后一起显示, 因为浏览器上面一旦显示内容, 就代表这一次请求已经结束了
    # 所以是整体一起显示的
    return response.stream(streaming_fn)

@app.get("/file_stream")
async def file_stream(request: request.Request):
    # 从名字上看, 这是 file 和 stream 的结合
    # file_stream 也是返回文件, 但它是一边读一边返回, 每次返回一定大小的数据
    # 而 file 则是一次性返回所有数据, 很明显当文件过大时, file_stream 可以大大节省内存
    """
    参数相比 response.file 会多一个 chunk_size, 表示每次读取的文件数据大小, 默认 4096
    如果是 file_stream, 你会发现响应头中少了 Content-Length, 因为流的形式每次只是返回一个 chunk, 无法得知整个文件的大小
    但是多了一个 Transfer-Encoding
    """
    return await response.file_stream("1.txt")

@app.get("/redirect")
async def redirect(request: request.Request):
    # 会跳转到路由 /text
    return response.redirect("/text")

app.run(host="127.0.0.1", port=8888)

用法非常简单,可以自己测试一下。

响应内容、响应码、响应头、响应类型我们已经知道该怎么设置了,但是 cookie 呢?我们貌似没有相应的参数位让我们设置 cookie 啊。确实如此,所以我们需要先获取 HTTPResponse 对象,然后设置完 cookie 之后再返回。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.get("/cookie")
async def cookie(request: request.Request):
    res = response.text("text")
    # 获取 cookie
    print(request.cookies.get("test"))

    # 设置完 cookie 之后再返回
    res.cookies["name"] = "古明地觉"
    # 如果想指定 cookie 存活的秒数, 那么可以这么做
    res.cookies["name"]["max-age"] = 5
    """
    expire(类型:datetime):cookie在客户端的浏览器到期时间
    path(类型:string):cookie应用的URL子集。默认为/
    comment(类型:string):注释(metadata)
    domain(类型:string):指定cookie有效的域, 明确指定的域必须始终以点开头
    max-age(类型:数字):cookie存活的秒数
    secure(类型:boolean):指定cookie是否只通过HTTPS发送
    httponly(类型:boolean):指定cookie是否能被Javascript读取
    """
    
    # 删除 cookie, 直接 del 即可
    del res.cookies["name"]
    return res


app.run(host="127.0.0.1", port=8888, auto_reload=True)

其它类型的请求

我们目前只介绍了 GET 请求,而其它的请求也同样很简单。

@app.get
@app.post
@app.put
@app.options
@app.head
@app.patch
@app.delete

什么类型的请求,就调用什么的方法去装饰,而且以上都调用了 app.route。

app.get("/index")  # 等价于 app.route("/index", methods=["GET"])
app.post("/index")  # 等价于 app.route("/index", methods=["POST"])
app.put("/index")  # 等价于 app.route("/index", methods=["PUT"])
......

所以如果我们想定义一个视图函数,同时支持多种请求,那么就通过调用 app.route 去装饰即可,methods 里面写上需要支持的请求类型。然后请求到来时通过 reques.method 来判断到底哪一种请求,不同的请求执行不同的逻辑。

async def index(request: request.Request):
    if request.method == "GET":
        ...
    elif request.method == "POST":
        ...

整体没什么难度,下面我们来重点说一下如何获取表单元素,以及 json 数据,由于这些请求都是调用的 app.route,所以我们就通过 app.route 来注册路由。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)
import aiofiles

app = Sanic("sanic_service")

@app.route("/login", methods=["GET", "POST"])
async def login(request: request.Request):
    # 如果是 get 请求直接渲染表单页面
    if request.method == "GET":
        return response.html(
            await (await aiofiles.open("login.html", encoding="utf-8")).read()
        )
    # 如果是 post 请求, 则获取表单参数
    elif request.method == "POST":
        # 我们可以通过 request.form 拿到请求的表单内容, 返回的也是一个 RequestParameters 对象
        # 如果多个 input 标签设置了相同的 name, 那么也可以通过 getlist 获取一个列表
        # 不过这种情况不多见
        username = request.form.get("username")
        password = request.form.get("password")
        if username == "komeiji_satori" and password == "satoti":
            return response.html("<h3>欢迎来到古明地觉的避难小屋</h3>")
        else:
            return response.html("<h3>用户名或密码错误</h3>")


app.run(host="127.0.0.1", port=8888, auto_reload=True)

点击 ok 就会提交表单,然后执行 post 请求对应的逻辑。

我们平常在用 Python 发请求的时候经常会使用第三方库 requests,而 request 里面 post 方法也可以实现这里的表单上传。如果给 data 传递一个字典的话,那么里面的 key 就相当于表单元素的 name,value 就相当于往输入框里面填入的值,我们举个栗子:

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.post("/")
async def test(request: request.Request):
    select = request.form.get("select")
    where = request.form.get("where")
    return response.text(f"form: {request.form} \nselect: {select!r}, where: {where!r}")

app.run(host="127.0.0.1", port=8888, auto_reload=True)

然后我们用 requests 发请求,注意:这里我用的是 jupyter 演示的,而该服务默认的监听端口也是 8888,因此我们这个端口名起得不是很好。当然这都无所谓了,我这里的 jupyter 自动监听了其它端口。

怎么样,和使用 html 进行表单上传是不是一样呢?那问题来了,如果我们给 data 参数传递的不是一个字典呢?如果传递的是一个普通的字符串呢?

我们看到 form 表单就是一个空字典,因为我们 data 参数是一个字符串,它无法模拟 html 中的表单。那么问题了,这些字符串要怎么接受呢?答案是通过 body 属性。

@app.post("/")
async def test(request: request.Request):
    # 会返回一个字节串, 也就是 bytes 对象
    data = request.body
    return response.raw(data)

使用 requests 模拟请求:

通过 body 即可以字节串的形式接受数据,而且即便我们通过表单上传,request.body 也是可以获取数据的,是以 url 中查询字符串的形式。事实上,request.from 就是通过 request.body 解析得到的。

同理我们还可以上传 json 数据,而在 Sanic 中我们也可以通过 request.json 获取用户上传的数据。

@app.post("/")
async def test(request: request.Request):
    # 直接通过 request.json 即可获取相应的数据, 会得到一个字典
    json = request.json
    return response.text(f"keys: {tuple(json.keys())}, values: {tuple(json.values())}")

然后通过 requests 发请求:

其实无论是 request.form 还是 request.json,它们都是根据 request.body 解析得到的。假设传递的数据是一个字典 {"a": 1, "b": 2},无论是通过 data 传递、还是通过 json 传递,Sanic 中的 request.body 永远都是 b'a=1&b=2'。它是 Sanic 服务端真正接收的数据,而 request.form 和 request.json 都是按照各自的规则对 request.body 进行解析得到的。如果解析失败,request.form 得到的是空字典,request.json 会直接返回错误。

比较简单,下面来看一看文件上传,根据使用 Flask 的经验,我们猜测获取用户上传的文件内容应该是通过 request.files,事实上也确实如此。request.files 返回的仍是一个 RequestParameters 对象,而我们说 RequestParameters 继承自字典,那么显然 key 对应的是表单元组中的 name,value 是一个列表、列表里面就是用户上传的文件(包含文件名、文件内容等信息)。

@app.post("/")
async def test(request: request.Request):
    files = request.files
    # 得到的是一个 request.File 对象, 它是一个 namedtuple
    # 只有三个成员: "type", "body", "name", 显然代表的含义不需要我多说
    file = files.get("樱花庄")
    return response.text(f"上传的文件名: {file.name}, 文件内容: {str(file.body, 'utf-8')}, 文件类型: {file.type}")

老规矩,还是用 requests 模块模拟一下:

如果是上传多个文件,并且 name 还相同,那么就将 get 换成 getlist 即可。

路由

下面来介绍一下 Sanic 中的路由,假设有一个路由是:/book/book_id,表示通过 book_id 来查询相应的书籍。而这个 book_id 显然是由用户输入的 url 来获取的,那么它就应该是动态可变的,不能写死,否则只能查看某一本书了。

@app.get("/book/<book_id>")
async def test(request: request.Request,
               book_id):
    books = {"python": "这是 Python 书籍",
            "golang": "这是 Golang 书籍",
            "C": "这是 C 书籍"}
    if (book := books.get(book_id.lower())) is not None:
        res = book
    else:
        res = "不存在此书籍"
    return response.text(res)

我们看到只需要将路径中的请求参数使用 <> 括起来即可,然后请求参数会作为关键字参数传递给路由函数。

我们看到只需要将路径中的请求参数使用 <> 括起来即可,然后请求参数会作为关键字参数传递给路由函数。

语法和 Flask 是一样的,但是问题来了,我们说在 Flask 中可以给请求参数指定类型,那么在 Sanic 中可不可以呢?答案是可以的,语法和 Flask 一模一样。

@app.get("/girl/<age1>/<age2:int>")
async def test(request: request.Request,
               age1,
               age2):

    return response.text(f"age1 is {type(age1)}, age2 is {type(age2)}")

我们只需要在参数后面加上 :类型 即可,冒号两边不要有空格。注意:如果不加类型,那么获取到的都是字符串,如果加了类型,那么会进行转化。

age1 和 age2 接收的值是相同的,但前者是字符串,后者是整数。那么问题来了,我们都可以给请求参数指定哪些类型呢?

  • int: 整数
  • number: 浮点数
  • alpha: 只含有大小写字母的字符串
  • uuid: uuid
  • string: 任意字符串, 不指定 :类型 时的默认行为, 注意字符串中不包含 /
  • path: 任意字符串, 和 string 的区别是可以包含 /; 比如: /<index>, 如果访问 /字符串 的话, index 的值就是 "字符串"; 但如果是 /字符串1/字符串2 的话, 就会报错, 因为路由无法匹配; 而 /<index:path> 的话就不一样了, 如果访问 /字符串1/字符串2, 那么 index 的值就是 "字符串1/字符串2"
  • 任何形式的正则表达式

其实,:类型 就是通过正则表达式匹配的,具体可以看 sanic/router.py 中 REGEX_TYPES 的定义:

REGEX_TYPES = {
    "string": (str, r"[^/]+"),
    "int": (int, r"-?\d+"),
    "number": (float, r"-?(?:\d+(?:\.\d*)?|\.\d+)"),
    "alpha": (str, r"[A-Za-z]+"),
    "path": (str, r"[^/].*?"),
    "uuid": (
        uuid.UUID,
        r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-"
        r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}",
    ),
}

显然字典的 key 就是我们说的类型,而 value 中第二个元素就是要匹配的正则表达式,如果匹配了,那么再根据 value 的第一个元素进行转化。所以你想到了什么?是不是我们也可以往里面加点东西啊,我们也可以做一些骚操作之类的。举个栗子:

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response,
    router
)

app = Sanic("sanic_service")
router.REGEX_TYPES["马保国"] = (str, r"[^/]*不讲武德[^/]*")

@app.get("/<很快啊:马保国>")
async def test(request: request.Request,
               很快啊):

    return response.text(很快啊)

那么我们在访问的时候,请求参数就必须包含 "不讲武德" 这个 4 个字符才可以,否则是访问不到这个路由的。

注意:只是为了娱乐,工作中不要这么干,否则会被骂的。

url_for

说完了请求参数,然后我们再来说说 Sanic 中的 url_for,没错和 Flask 中的 url_for 作用是类似的。所以我说如果会 Flask,那么学习 Sanic 是分分钟的事情,因为 Sanic 就是按照 Flask 的风格进行设计的。

@app.get("/index")
async def index(request: request.Request):
    cookies = request.cookies
    if cookies.get("session") is not None:
        return response.text("欢迎来到我的主页")
    else:
        # 这里的第一个参数 "error_handler", 对应的是视图函数名(这里其实不太准确, 至于为什么后面说)
        # 关键字参数 error_msg, 就是函数中接收的参数
        url = app.url_for("error_handler", error_msg="你还没有登录呢")
        return response.redirect(url)


@app.get("/<error_msg>")
def error_handler(request: request.Request,
                  error_msg):
    return response.text(error_msg)

使用 url_for 需要注意以下几点:

  • 1. 传递给 url_for 的关键字参数如果不是请求参数, 那么会加入到查询字符串中
url = app.url_for("error_handler", error_msg="你还没有登录呢", a=1, b=2)
# 显然 error_handler 函数没有参数 a、b, 那么该 url 等价于 /你还没有登录呢?a=1&b=2
  • 2. 多值参数也可以传递给 url_for
url = app.url_for("error_handler", error_msg="你还没有登录呢", a=[1, 2], b=2)
# 该 url 等价于 /你还没有登录呢?a=1&a=2&b=2
  • 3. 有些特殊参数传递给 url_for 会具有特殊的意义, 比如: _anchor、_external、_schema、_server
url = app.url_for("error_handler", error_msg="你还没有登录呢", _anchor="mashiro")
# 该 url 等价于 /你还没有登录呢#mashiro, 没错, 相当于锚点
# 至于其它的没太大用, 有兴趣可以自己了解一下
  • 4. 所以有效的请求参数必须传递 url_for 来建立 url, 如果有的请求参数没有提供、或者类型不匹配, 那么会产生 URLBuilldError 异常
url = app.url_for("error_handler", a=1, b=2)
# 此时就会报错, 因为 error_msg 没有传递, 而它是一个路径中的请求参数
# 如果它规定了类型是 int, 而我们传递的是一个无法转成 int 的字符串, 那么也会报错
# 当然如果规定的是 string, 但我们传递的是整型则不会报错, 因为任何整型都可以转成字符串

下面我们来访问一下:

Websocket 路由

Sanic 也是支持 Websocket 路由的,并且实现方式非常简单。

from sanic import (
    Sanic,
    request,
    response
)
from sanic.websocket import WebSocketConnection

app = Sanic("sanic_service")
# 通过调用 app.websocket 进行装饰
@app.websocket("/ws")
async def websocket(request: request.Request,
                    ws: WebSocketConnection):
    # ws 参数也会自动传递, 会接收一个 websocket 连接
    while True:
        # 接收信息
        recv = await ws.recv()
        # 发送信息
        await ws.send(f"接收到信息: {recv!r}")

然后我们通过浏览器来进行访问:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <script>
        ws = new WebSocket("ws://localhost:8888/ws");
 
        //如果连接成功, 会打印下面这句话, 否则不会打印
        ws.onopen = function () {
            console.log('连接成功')
        };
 
        //接收数据, 服务端有数据过来, 会执行
        ws.onmessage = function (event) {
            console.log(event)
        };
 
        //服务端主动断开连接, 会执行.
        //客户端主动断开的话, 不执行
        ws.onclose = function () {  }
 
    </script>
</body>
</html>

直接用 Chrome 打开这个 html,然后 F12、或者右键点击 "检查",再点击 Console 进入控制台即可看到信息。

websocket 是一个长连接,当然我们也可以通过 await ws.close() 关闭 websocket 连接。

websocket 也有相应的配置参数,我们可以通过 app.config 获取,至于参数都有哪些我们之前介绍配置参数的时候已经说过了。

url 中结尾的斜杠

假设我们有一个路由:@app.get("/get"),那么我们即可以通过访问 /get 访问,也可以通过 /get/ 访问,这个可以自己测试一下。如果我们希望严格匹配,也就是说 url 必须和路由完全匹配该怎么做呢?

@app.get("/index", strict_slashes=True)  # /index 可以访问, /index/ 则不行
@app.get("/index/", strict_slashes=True)  # /index/ 可以访问, /index 则不行
@app.get("/index")  # /index、/index/ 均可
@app.get("/index/")  # /index、/index/ 均可

自定义路由名称

路由是有一个 name 属性的,如果我们没有指定,那么默认使用视图函数的名字作为路由的名字,但我们也可以自己指定。

之前我们在介绍 url_for 的时候,说第一个参数接收的是视图函数名其实是不准确的,应该说接收的是路由的名字,因为 url_for 是定位到指定的路由,然后再访问路由对应的视图函数。而默认情况下,路由的名字和函数名是一样的。

@app.get("/index")
async def index(request: request.Request):
    cookies = request.cookies
    if cookies.get("session") is not None:
        return response.text("欢迎来到我的主页")
    else:
        # 这里的第一个参数就不能指定 "error_handler" 了
        # 因为路由不叫这个名字, 而是叫我们指定的名字 "error"
        url = app.url_for("error", error_msg="你还没有登录呢")
        return response.redirect(url)


@app.get("/<error_msg>", name="error")
def error_handler(request: request.Request,
                  error_msg):
    return response.text(error_msg)

可以看到这个 name 基本上是为了 url_for,并且还有一个注意点。我们知道同一种类型的请求,路由应该是唯一的,但是不同类型的请求,路由可以不唯一。比如:get请求、post请求,它们的视图函数都可以是 /index,那么这个时候如果使用 url_for 跳转的话,那么要跳转到哪一个请求呢?一种方式是改变视图函数名,再有就是手动指定 name 也能解决这一冲突。

为静态目录(文件)建立 URL

Sanic 也支持为静态目录建立 URL,也就是用户可以直接访问目录中的文件。

app = Sanic("sanic_service")
app.static("/static", "../bg")

只需要使用 app.static 即可,第一个参数是 URL,第二个参数是目录。当我们访问 /static/1.png 的时候,就会返回 ../bg 目录中的 1.png 文件。

除了为一个静态目录建立建立 url,我们也可以为一个静态文件建立 url,直接将第二个参数指定为某个文件的具体位置即可。不过不常用,因为这意味着只能访问那一个文件。

而 url_for 也可以定位到静态目录的 url,举个栗子:

app.static("/static", "../bg")
# 如果使用 url_for 的话, 第一个参数固定叫 static, 然后通过 filename 参数指定文件名
app.url_for("static", filename="2.png")  # 等价于 /static/2.png

# 静态路由也是可以指定 name 属性的
app.static("/files", "../files", name="my_files")
app.url_for("static", name="my_files", filename="3.png")  # 等价于 /files/3.png

# 如果指向的是一个静态文件的话, 就无需 filename 参数了, 因为指向的就是某一个确定的文件
app.static("/best", "../bg/best.png", name="best")
app.url_for("static", name="best")  # 等价于/best, 会直接访问 ../bg/best.png
# 如果第二个参数不是具体的文件, 而 ../bg 这个目录, 然后还这么做的话, 那么就会读取指定的目录
# 而这显然会报出权限错误, 目录显然无法像文件那样被打开
# 不过个人觉得更智能的做法时, 如果是一个目录, 那么就显示该目录下的所有文件; 然后可以点击文件名来查看指定文件内容, 就像我们传递了 filename 参数一样
# 这是一个比较智能的做法, 但是 Sanic 显然没有这么做, 它只是按照打开文件的方式打开一个目录。

如果我们提供的文件非常大的话,那么可以选择使用流文件来替代文件下载:

app.static("/static", "../bg", stream_large_files=True)

当 stream_large_files 设置为 True 时,那么 Sanic 将会使用 file_stream() 替代 file() 来提供静态文件访问。它默认的块大小是 1kb,当然我们也可以给这个参数传递一个具体的数值来显式地指定块大小。

版本控制

版本控制是什么呢?有时候我们的服务会升级,通过在路径的开头增加 /v1、/v2、/v3 等等来表示这是第几代的 url,而 Sanic 也提供了非常简洁的做法。

@app.get("/index", version=4)
def index(request: request.Request):
    return response.text("这是第四代 URL")

我们只需要加上一个 version 参数即可,这里设置的值为 4,那么以后就需要通过 /v4/index 来进行访问了,也是说会增加一个 v{version} 前缀。

自定义 404 页面

当用户访问了一个不存在的页面,我们也可以自定义错误信息,而不是使用默认的。

from sanic.exceptions import NotFound

app = Sanic("sanic_service")

@app.exception(NotFound)
async def not_fount(request, exception):
    return response.text(f"不存在该url")

Sanic 中间件

中间件,显然这个概念已经很熟悉了,就是请求或者响应的中间给你偷偷加点逻辑。Sanic 的中间件有两种类型:request 和 response,都是通过 app.middleware 进行装饰的,如果你想加请求中间件,那么参数就设置为 "request";响应中间件,参数设置为 "response"。

@app.middleware("request")
def request_midd1(request: request.Request):
    # 为请求设置一个请求头
    request.headers["name"] = "satori"


@app.middleware("request")
def request_midd2(request: request.Request):
    # 为请求设置一个cookie
    request.cookies["session"] = "abcdefg"


@app.middleware("response")
def response_midd1(request: request.Request,
                   response: response.HTTPResponse):
    # 注意: 响应中间件的视图函数需要接收两个参数, 一个 request、一个 response
    # 为返回的内容设置一个头部信息
    response.headers["name"] = "mashiro"


@app.middleware("response")
def response_midd2(request: request.Request,
                   response: response.HTTPResponse):
    # 注意: 响应中间件的视图函数需要接收两个参数, 一个 request、一个 response
    # 为返回的内容设置一个cookie
    response.cookies["session"] = "ABCDEFG"


@app.get("/index")
def index(request: request.Request):
    return response.text(f"嘿嘿, 帮你设置了请求头: {request.headers['name']}, 帮你设置了cookie: {request.cookies['session']}")

然后我们来测试一下:

首先请求在访问视图函数之前先经过两个请求中间件,会将 Request 对象传递给请求中间件中的 request,在视图函数访问完毕之后会返回一个 response,然后这个 response 会再作为参数传递给两个响应中间件中的 response。

使用方法非常简单,middleware("request") 等价于 Flask 中的 before_request,middleware("response") 则等价于 after_request。而且实现逻辑也很简单,Flask 是将请求中间件、响应中间件分别各自放在一个列表中,响应经过视图函数之前,先遍历请求中间件列表,经过视图函数之后,再遍历响应中间件列表。而 Sanic 的实现逻辑与之类似,只不过它是将中间件放在了一个双端队列中。

那么问题来了,如果定义了多个请求中间件、响应中间件,那么它们的执行顺序是怎么样的呢?这里直接说结论,就不演示了,你可以在中间件中加一个 print 语句,看看打印的内容即可判断出顺序。

注意:如果在响应中间件中返回了 HTTPResponse 对象,那么后续的响应中间件将不会再执行;如果在请求中间件中返回了 HTTPResponse 对象,那么后续所有的请求中间件 + 视图函数 + 响应中间件 都将不会执行。

所以根据这个思路,我们还可以用中间件实现一些拦截器之类的。

监听器

Sanic 还提供了一些监听器(listener)允许我们在应用程序的生命周期内的多个时间点运行一些指定的逻辑。

如果你想在服务开始时执行一些初始化代码,或者服务结束后执行一些资源回收相关的代码,那么可以使用如下监听器:(和中间件一样,也是通过字符串参数进行分类的,因为监听器本质上也可以看做是一种中间件)

  • @app.listener("before_server_start"): 在服务开始之前执行一些代码
  • @app.listener("after_server_start"): 在服务开始之后执行一些代码
  • @app.listener("before_server_stop"): 在服务结束之前执行一些代码
  • @app.listener("after_server_stop"): 在服务结束之后执行一些代码

这些中间件(视图函数)接收两个参数,分别是 app、loop;app 就是我们应用程序的 app(Sanic 实例对象),loop 则是事件循环。

当然注册监听器,除了使用这种装饰器的方式,还可以使用另一种方式。

async def setup(ap, loop):
    app.config["db"] = await create_async_engine()
    
app.register_listener(setup, "before_server_start")
# 等价于 @app.listener("before_server_start")

安排一个任务后台执行

如果你想安排一个任务在事件循环中后台执行,那么 Sanic 同样提供了很好的途径。

async background_task(app):
    await xxx()
    
app.add_task(background_task(app))

BluePrint:蓝图

我们说 Sanic 是可以基于 Flask 的风格而设计,到这里算是 "实锤" 了。显然 Sanic 的蓝图作用的 Flask 中的蓝图是一样的,针对大型程序,目的是把一个大型应用分成不同的逻辑模块,这样更便于管理,而每一个模块就对应一个蓝图。比如一个在线商城,有处理下单的服务的 blueprint,有处理退款服务的 blueprint,有处理评价的 blueprint 等等。

我们创建蓝图通过实例化 Blueprint 来创建,而蓝图的使用方式和 Sanic 实例对象 app 的使用方式完全一样,我们来看一下。

我们定义一个 comment.py ,表示评价服务相关的逻辑。

from sanic import Blueprint, response

# 给蓝图指定一个名字
bp = Blueprint("comment_blueprint")

@bp.get("/comment/<cid>")
async def get_comment_by_id(request,
                            cid):
    return response.text(f"获取 id 为 {cid} 的评价")

怎么样,使用方式和 app 是不是一样的呢?第一个参数同样会接收一个 request.Request 对象,但是注意:此时还不能直接运行,我们还需要将蓝图(这里的 bp)注册到 app 中。

from comment import bp

app = Sanic("sanic_service")
app.blueprint(bp)

注册之后就可以访问了。

但是很明显,对于不同的功能模块,路由应该有着不同的前缀。比如:评价模块都是以 /comment 开头,订单模块都是以 /order 开头等等。

from sanic import Blueprint, response

# 加上一个 url_prefix, 以后使用 bp 注册的路由一律都需要加上 /comment 前缀
bp = Blueprint("comment_blueprint", url_prefix="/comment")

# 这里直接写 /<id> 即可, 但是访问的时候需要通过 /comment/id 的方式来访问
@bp.get("/<id>")
async def get_comment_by_id(request,
                            id):
    return response.text(f"获取 id 为 {id} 的评价")

但是很明显,对于不同的功能模块,路由应该有着不同的前缀。比如:评价模块都是以 /comment 开头,订单模块都是以 /order 开头等等。

多个蓝图也是同样的道理。

# comment.py
from sanic import Blueprint, response
bp = Blueprint("comment_blueprint", url_prefix="/comment")

@bp.get("/<id>")
async def get_comment_by_id(request,
                            id):
    return response.text(f"获取 id 为 {id} 的评价")


# order.py
from sanic import Blueprint, response
bp = Blueprint("order_blueprint", url_prefix="/order")

@bp.get("/<id>")
async def get_order_by_id(request,
                          id):
    return response.text(f"获取 id 为 {id} 的订单")

这里有两个蓝图,分别定义在不同的文件中,如果想要它们生效,那么必须要注册到 app 中。

from comment import bp as comment_bp
from order import bp as order_bp

app = Sanic("sanic_service")
app.blueprint(comment_bp)
app.blueprint(order_bp)

然后我们可以通过 /comment/id 访问评论,/order/id 访问订单。

此外蓝图还可以嵌套。

如果我们希望 comment 和 order 都具备相同的 api 呢?

from sanic import Blueprint
from comment import bp as comment_bp
from order import bp as order_bp
app = Sanic("sanic_service")
# 将多个放在 group 中, 并指定一个公共前缀即可
app.blueprint(Blueprint.group(comment_bp, order_bp, url_prefix="/apis"))

然后我们再想访问的话,就必须加上 /apis ,否则是访问不到的。

怎么样,到目前为止感觉还算不错吧,毕竟还是很容易的,而且在里面个人还看到 Golang 里面 Gin 框架的影子。

而我们说 bp 和 app 本质上一样的,所以 app 里面有的方法 bp 也有,只不过 bp 最后需要注册到 app 中。

websocket 路由

可以通过 @bp.websocket 装饰器、或者 bp.add_websocket_route 方法进行注册。

中间件

我们使用 bp 也一样可以注册中间件,语法和 app 注册是一样的。

当然,我们说多个 bp 可以组成一个 group,而这个蓝图组也是可以注册中间件的,注册的中间件会作用组内的在每一个蓝图上。

异常

我们也可以通过 bp 来注册一个全局异常,比如只定义 404。

API 版本控制

在实例化 Blueprint 的时候传递 version 参数即可,然后会在原来的 url 基础之上加上 v{version}

url_for

如果我们是对蓝图注册的路由使用 url_for,那么切记 name 一定是 "蓝图名.路由名" 的形式。因为不同的蓝图可能注册了相同的路由(或者说路由的名称一致),那么这个时候 url_for 要找哪一个呢?显然此时需要指定蓝图的名称,当然如果不指定那么会去找 app 定义的路由。

Sanic 中的异常

视图函数在处理过程中可以抛出异常,它们会自动被 Sanic 处理,异常以一个文本信息作为第一个参数,同时可以把状态码作为第二个参数包含在 HTTP 响应中返回给浏览器。

为了抛出异常,只需要使用 raise 抛出 sanic.exceptions 子模块中的异常即可。

from sanic.exceptions import ServerError
app = Sanic("sanic_service")

@app.get("/go_die")
async def go_die(request: request.Request):
    raise ServerError("我狗带了", status_code=500)

我们也可以使用 abort 函数。

from sanic.exceptions import abort
app = Sanic("sanic_service")


@app.get("/go_die")
async def go_die(request: request.Request):
    # 本质上还是调用的 sanic.exceptions.ServerError
    raise abort(500, "我狗带了")

异常既然发生了,那么我们是不是应该处理它呢?

为了覆盖 Sanic 对异常的默认处理,我们可以使用 @app.exception(exc1, exc2, exc3, ...) 装饰器,该装饰器接收任意个异常。被装饰的异常处理函数会自动接收两个参数,分别是 request.Request 对象和发生的异常。

@app.exception(Exception)
async def go_die(request: request.Request,
                 exception):
    if isinstance(exception, NotFound):
        return response.text("该 url 找不到")
    else:
        return response.text(f"服务内部挂掉了, 出现了错误: {exception}")


# 如果发生的异常能够被 @app.exception 中指定的异常所捕获, 那么就会走 go_die 视图函数
@app.get("/index")
async def index(request: request.Request):
    1 / 0

我们来测试一下:

但是实际开发中一般很少这么做,而是出现错误直接捕获,然后返回一个 json,里面的响应的错误信息。

当然我们还可以通过下面这种方式增加异常处理函数:

async def go_die(request: request.Request,
                 exception):
    if isinstance(exception, NotFound):
        return response.text("该 url 找不到")
    else:
        return response.text(f"服务内部挂掉了, 出现了错误: {exception}")

app.error_handler.add(Exception, go_die)

观察源码的话,会发现基本都是一个套路。如果我们希望能够在默认的异常处理中增加更多的功能,那么也可以继承 Sanic 的默认处理器 ErrorHandler。

from sanic.handlers import ErrorHandler

class UDErrorHandler(ErrorHandler):
    
    def default(self, request, exception):
        """自己的逻辑处理"""
        # 最后执行父类的 default 方法
        return super().default(request, exception)

虽然 Sanic 提供了很多异常处理机制,但是个人几乎不怎么用。就我所在公司而言,也是按照前后端分离模式,我只需要返回一个 json 字符串给前端即可。json 字符串中有一个我们自己定义的状态码,前端可以根据这个状态码判断请求是否成功;如果成功,里面会有一个 data 字段表示数据,否则会有一个 error_msg 表示错误信息。而我个人而言,基本上在可能会出现错误的地方会进行检测,如果出现问题(如参数没有传递、类型错误等等),就直接把错误信息返回了,个人更习惯这么做。像自带的异常处理机制,我只用自定义 404,针对访问一个不存在的路由而设置报错信息,因为此时和程序的逻辑是没有关系的,程序中压根就没有这个路由。

Sanic 中最常见的异常还是 NotFound,或者加上一个 ServerError,其它的不怎么用,有兴趣的话可以参考源码 sanic/exceptions.py。

视图类

Flask 中提供了视图类,同理 Sanic 中也是有的,并且使用方法完全一致,只不过内部方法是异步的。

我们只需要继承 Sanic 中的 HTTPMethodView,然后就可以为每一个 HTTP 请求类型实现一个方法,相当于实现了在同一路由节点分别处理不同的 HTTP 请求,下面来看一下。

from sanic.views import HTTPMethodView
app = Sanic("sanic_service")

class MyView(HTTPMethodView):

    async def get(self, request):
        return response.text("我是 get 请求")
    
    async def post(self, request):
        return response.text("我是 post 请求")
    
    async def put(self, request):
        return response.text("我是 put 请求")
    
    async def delete(self, request):
        return response.text("我是 delete 请求")

# 注册路由, 有两种方式
app.add_route(MyView.as_view(), "/index")  # 这种方式也是用于视图函数的注册
# 或者通过 app.route("/index")(MyView.as_view()), 这不正是 @app.route("index") 干的事情嘛
app.run(host="127.0.0.1", port=8887)

然后 /index 这个路由同时支持 get、post、put、delete 四种请求,所以如果我们需要定义一个支持多种请求类型的路由,那么不妨使用视图类的方式。可以不使用视图函数、然后通过 request.method 判断的方式。

当然,如果你需要请求参数,那么把视图类里面的方法当成视图函数一样操作即可。

class MyView(HTTPMethodView):

    async def get(self, request, name):
        return response.text(f"hello {name}")

app.add_route(MyView.as_view(), "/index/<name>")

当然,我们也可以给视图类增加装饰器。

def deco(func):
    @wraps(func)
    def inner(*args, **kwargs):
        return response.text("抱歉其实我不是 get 请求")
    return inner


class MyView(HTTPMethodView):
    decorators = [deco]

    def get(self, request):
        return response.text(f"我是 get 请求")

Flask 中也是这么做的,我们通过装饰器的模式改变了返回的结果,因为返回值我们装饰器内部的 inner 函数的返回值。

此时我们做了一些额外的操作,当然个人觉得不是很常用。注意:这种装饰方式会在调用 as_view 时被应用,所以它是作用在所以的请求类型上。如果我们只想给某个单独的请求类型加上装饰器呢?很简单,直接在对应的方法上 @deco 即可,并且装饰器可以同时嵌套多层。并且 decorators 接收的是一个列表,说明它里面也可以接收多个装饰器。那么顺序是怎么样的呢?我们来测试一下:

def deco1(func):
    @wraps(func)
    def inner(*args, **kwargs):
        return response.text("我是 deco1")
    return inner


def deco2(func):
    @wraps(func)
    def inner(*args, **kwargs):
        return response.text("我是 deco2")
    return inner


class MyView(HTTPMethodView):
    decorators = [deco1, deco2]

    def get(self, request):
        return response.text(f"我是 get 请求")

我们看到结果是字符串 "deco2",说明列表中的装饰器是按照顺序从下往上装饰的,相当于:

    @deco2
    @deco1
    async def get(self, request):
        pass

如果我们希望通过 url_for 来找到对应的视图类,那么类名就是 url_for 中的路由名称。

@app.get("/index")
async def index(request):
    url = app.url_for("MyView")
    return response.redirect(url)

class MyView(HTTPMethodView):

    def get(self, request):
        return response.text(f"我是 get 请求")


app.add_route(MyView.as_view(), "/index") 

除了 HTTPMethodView,我们还可以使用 CompositionView 将处理函数移到类的外面。

from sanic.views import CompositionView
app = Sanic("sanic_service")

view = CompositionView()
view.add(["GET"], lambda request: response.text("你好呀"))
view.add(["POST", "PUT"], lambda request: response.html("<h3>我不好</h3>"))

app.add_route(view, "/")

觉得没有太大意义,完全可以通过定义视图函数的方式。

不过 url_for 目前还不能针对 CompositionView 创建 URL。

Sanic streaming

我们之前在 Sanic 的响应中介绍了流式响应,也就是 response.stream,但是流式请求也是可以的。

@app.post("/index1", stream=True)
async def index1(request):
    async def streaming(streaming_response: response.StreamingHTTPResponse):
        while True:
            # 读取数据
            body = await request.stream.read()
            if body is None:
                break
            # 写入数据
            await streaming_response.write(body)
    # 调用 response.stream, 接收带有一个 StreamingHTTPResponse 对象参数的协程函数, 用以写操作
    return response.stream(streaming, content_type="application/octet-stream")

@app.post("/index2", stream=True)
async def index2(request):
    # 简单粗暴, 直接将数据读出来, 拼接在一起
    # 当然由于涉及字符串的拼接, 所以效率不高, 可以写入到缓存中, 或者 append 到数组中再 join 
    result = b""
    while True:
        body = await request.stream.read()
        if body is None:
            break
        result += body
    return response.raw(result)

我们来测试一下,这里我将端口改成了 8887,因为我先打开的 jupyter,已经把 8888 端口占用了:

这种做法非常有用,尤其是我们从外部服务(数据库)中获取内容返回给客户端的时候,就可以采用这种流式的方式来获取数据。

但是注意:只有 post、put、patch 方法有 stream 参数,其它没有。假设是 GET 请求,那么 request.stream 返回的是一个 None。

ipv6 和 SSL

Sanic 不仅支持 ipv4,还支持 ipv6,我们来看一下。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
import socket
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.get("/index")
async def index(request: request.Request):
    return response.text("你好呀")


sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.bind(("::", 8887))
app.run(sock=sock, auto_reload=True)

此外 Sanic 还提供了加密访问 https,可以通过传递 SSLContext 实现。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
import ssl
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.get("/index")
async def index(request: request.Request):
    return response.text("你好呀")


context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain("/root/cert", "/root/keyfile")
app.run(host="127.0.01", port=8887, auto_reload=True, ssl=context)
# 或者直接传递一个字典也可以, ssl={"cert": "/root/cert", "key": "/root/keyfile"}

关于证书和秘钥,更详细的内容可以网上搜索,这里不再赘述了。

Sanic 的部署

关于 Sanic 的部署,我们目前使用的都是自带的 app.run,当然这么做是没有问题的。因为 Sanic 不仅是一个 web 框架,它还是一个 web 服务器。但除了 app.run 之外,我们还可以通过 Gunicorn。

app.run

先来看看 app.run,也就是内置的 webserver,尽管我们一直都在用,但是这里面都有哪些参数、以及它们代表的含义你都知道吗?下面就来罗列一下。

  • host(默认为 127.0.0.1): 服务器运行的主机地址
  • port(默认为 8000): 服务器监听的端口
  • debug(默认为False): 是否开启 debug 模式, 会让服务器变慢
  • auto_reload(默认为None): 是否自动重启, 如果开启 debug, 那么 auto_reload 会自动开启
  • ssl(默认为None): 开启 SSL 加密
  • sock(默认为None): 服务器可以创建来自该 socket 的连接
  • workers(默认为1): 需要创建的工作进程的个数
  • loop(默认为None): 一个 asyncio 兼容的事件循环, 如果为 None, Sanic 将创建自己的事件循环; 首先尝试使用 uvloop, 如果环境中不存在, 将退化使用 asyncio 自带的事件循环;
  • protocol(默认为HttpProtocol): asyncio.Protocol 的子类
  • access_log(默认为True): 开启请求处理的日志(显著降低server的速度)

这里我们说一下里面的 workers 参数,默认情况下 Sanic 只使用一个 CPU 核心在主进程中进行监听,为了提高性能,我们应该利用多核。

import os

print(os.cpu_count())  # 12

建议进程数和内核数保持一致,这样 Sanic 将自动启动多个进程并在它们之间路由请求。

通过 Gunicorn 运行

Gunicorn(Green Unicorn)是用于 Unix 的 WSGI HTTP 服务器,它是从 Ruby 的 Unicorn 项目移植过来的。

首先是安装,直接  pip install gunicorn 即可,然后我们来通过 gunicorn 启动 Sanic 服务。我这里的 Sanic 服务的 py 文件名叫 sanic_service.py,里面的应用程序叫 app,那么启动方式为:

gunicorn sanic_service:app --bind 0.0.0.0:8887 --daemon --workers=5 --worker-class sanic.worker.GunicornWorker

关于 Gunicorn 的内容不在我们的范围内,可以网上搜索一下,这里我们直接解释一下里面的几个参数。

  • --bind: 绑定的ip:port
  • --daemon: 后台运行
  • --workers: 工作进程数量, 由于 Gunicorn 是一个 pre-fork worker 模式, 也就是在指定 gunicorn 启动的时候, 主进程会预先 fork 出指定数量的 worker 进程; 在处理请求时, gunicorn 依靠操作系统提供负载均衡, 通常推荐的 worker 数量是: 2 * 核心数 + 1
  • --worker-class: 工作进程类型, 包括 sync(默认行为)、eventlet、gevent、tornado、gthread、gaiohttp等等; 这个 Sanic 也提供了相应的 GunicornWorker, 即: sanic.worker.GunicornWorker

除此之外还有其它的一些参数:

  • --backlog 1000: 最大挂起的连接数
  • --log-level LEVEL: 输出的日志等级, 可选参数有: debug、info、warning、error、critical

尽管 Gunicorn 可以在 Windows 上安装,但是运行的时候会报错,因为找不到 fnctl 这个模块,这个模块是 Linux 上的 Python 自带的。所以我们就在 Linux 上部署 Gunicorn 项目吧,同时使用 uvloop,在 Linux 上安装 Sanic 的时候会自动安装 uvloop。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)

app = Sanic("sanic_service")

@app.get("/index")
async def index(request: request.Request):
    return response.text("你好呀")

就是这么一个简单的 py 文件,大家可能注意到,这里我们没有写 app.run,因为我们使用 Gunicorn 启动,所以不需要 app.run 了。

目前我已经在我的阿里云上部署成功了,我们来看一下:

然后我们在 Windows 上访问一下:

是可以成功提供服务的。

另外,为了提高性能,我们可以设置环境变量 env SANIC_ACCESS_LOG="False" 来禁止写入日志,或者指定 app.config.ACCESS_LOG 等于 False。

一般情况下,推荐使用 Gunicorn 进行部署,当然前面还可以再搭一个 nginx。

Sanic 的周边

我们知道 Flask 虽然本身短小精悍,但是它非常容易扩展,拥有丰富的第三方组件,而且有一个说话叫堆满插件的 Flask 等同于 Django。而 Sanic 作为一款也比较成熟的框架,也出现了很多周边,但是我不想介绍太多,有兴趣可以自己去了解,这里我只提一个,那就是 sanic_cors。因为现在前后端分离是主流,如果直接部署的话,那么前端在访问的时候很容易产生跨域问题。

而解决跨域,sanic_cors 帮我们很好的解决了这一点,我们来看一下。首先是安装:pip install sanic_cors。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)
from sanic_cors import CORS

app = Sanic("sanic_service")
# 只需要这一步, 即可解决跨域问题
CORS(app)

@app.get("/index")
async def index(request: request.Request):
    return response.text("你好呀")

这种方式,会使得所有路由上的所有域都能解决跨域问题。但是你还可以指定一些参数,限制只能指定的资源进行跨域。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)
from sanic_cors import CORS

app = Sanic("sanic_service")
CORS(app, resources={r"/api/*": {"origins": "*"}})

@app.get("/index")
async def index(request: request.Request):
    return response.text("你好呀")

如果我们只想给某个指定的路由进行跨域,也是可以的。

# -*- coding:utf-8 -*-
# @Author: komeiji satori
from sanic import (
    Sanic,
    request,
    response
)
from sanic_cors import cross_origin

app = Sanic("sanic_service")

@app.get("/index")
@cross_origin(app)
async def index(request: request.Request):
    return response.text("你好呀")

小结

以上我们就算是对 Sanic 进行了全方位的介绍,个人觉得这个框架效率还是很高的,但是很明显,它目前还不适合开发大型项目。如果你只是做一些中小型的 web 服务,负责提供一些 HTTP 接口,那么 Sanic 完全适合你。但是 Sanic 如果真用在大型项目上,会有无数的坑要踩,如果真的要采用 Sanic 作为你们公司的主框架,那么一定要通读内部的源码(代码量非常少),并做好魔改的准备。

posted @ 2021-01-20 23:07  古明地盆  阅读(14120)  评论(5编辑  收藏  举报