当前位置:   article > 正文

三万字长文让你彻底掌握 FastAPI

三万字长文让你彻底掌握 fastapi

随着 Python 的发展,与协程相关的 Web 框架也层出不穷,其中最受欢迎的莫过于 FastAPI。相比其它的协程框架,FastAPI 要更加的成熟,社区也更加的活跃。

那么 FastAPI 都有哪些特点呢?

  • 快速:拥有非常高的性能,归功于 Starlette 和 Pydantic;Starlette 用于路由匹配,Pydantic 用于数据验证;

  • 开发效率:功能开发效率提升 200% 到 300%;

  • 减少 bug:减少 40% 的因为开发者粗心导致的错误;

  • 智能:内部的类型注解非常完善,IDE 可处处自动补全;

  • 简单:框架易于使用,文档易于阅读;

  • 简短:使代码重复最小化,通过不同的参数声明实现丰富的功能;

  • 健壮:可以编写出线上使用的代码,并且会自动生成交互式文档;

  • 标准化:兼容 API 相关开放标准;

FastAPI 最大的特点就是它使用了 Python 的类型注解,我们后面会详细说,下面来安装一下 FastAPI。

使用 FastAPI 需要 Python 版本大于等于 3.6

安装很简单,直接 pip install fastapi 即可,并且会自动安装 Starlette 和 Pydantic。然后还要 pip install uvicorn,因为 uvicorn 是运行相关应用程序的服务器。或者一步到位:pip install fastapi[all],会将所有依赖全部安装。

请求与响应

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

  1. from fastapi import FastAPI
  2. import uvicorn
  3. # 类似于 app = Flask(__name__)
  4. app = FastAPI()
  5. # 绑定路由和视图函数
  6. @app.get("/")
  7. async def index():
  8.     return {"name""古明地觉"}
  9. # 在 Windows 中必须加上 if __name__ == "__main__"
  10. # 否则会抛出 RuntimeError: This event loop is already running
  11. if __name__ == "__main__":
  12.     # 启动服务,因为我们这个文件叫做 main.py
  13.     # 所以需要启动 main.py 里面的 app
  14.     # 第一个参数 "main:app" 就表示这个含义
  15.     # 然后是 host 和 port 表示监听的 ip 和端口
  16.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

整个过程显然很简单,然后我们在浏览器中输入 localhost:5555 就会显示相应的输出。注意这里的视图函数,里面返回了一个字典,当然除了字典,其它的数据类型也是可以的,举个例子:

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/int")
  5. async def index1():
  6.     return 666
  7. @app.get("/str")
  8. async def index2():
  9.     return "古明地觉"
  10. @app.get("/bytes")
  11. async def index3():
  12.     return b"satori"
  13. @app.get("/tuple")
  14. async def index4():
  15.     return ("古明地觉""古明地恋""雾雨魔理沙")
  16. @app.get("/list")
  17. async def index5():
  18.     return [{"name""古明地觉""age"17}, 
  19.             {"name""古明地恋""age"16}]
  20. if __name__ == "__main__":
  21.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

这里我们直接使用 requests 发请求,测试一下。

b48a2271964443d049a1e7a1eac9cafa.png

可以看到基本上都是支持的,只不过元组自动转成列表返回了。并且当前的路径是写死的,如果我们想动态声明路径参数该怎么做呢?

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/items/{item_id}")
  5. async def get_item(item_id):
  6.     """
  7.     和 Flask 不同,Flask 是使用 <>
  8.     而 FastAPI 使用 {}
  9.     """
  10.     return {"item_id": item_id}
  11. if __name__ == "__main__":
  12.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

0683719b0b4277b35b7dd94036cabe20.png

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

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/items/{item_id}")
  5. async def get_item(item_id: int):
  6.     """
  7.     和 Flask 不同,Flask 定义类型是在路由当中
  8.     也就是在 <> 里面,变量和类型通过 : 分隔
  9.     
  10.     而 FastAPI 是使用类型注解的方式
  11.     此时的 item_id 要求一个整型
  12.     准确的说是一个能够转成整型的字符串
  13.     """
  14.     return {"item_id": item_id}
  15. if __name__ == "__main__":
  16.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

b1db8e844214298da2ccbb82ce741d77.png

如果我们传递的值无法转成整型的话,那么会进行提示:告诉我们 value 不是一个有效的整型,可以看到给的提示信息还是非常清晰的。

所以通过 Python 的类型声明,FastAPI 提供了数据校验的功能,当校验不通过的时候会清楚地指出没有通过的原因。在我们开发和调试的时候,这个功能非常有用。

a66a8ff5779da643f194c0c532877cb2.png

交互式文档

FastAPI 会自动提供一个类似于 Swagger 的交互式文档,我们输入 localhost:5555/docs 即可进入。

42728516752b8c15b02a19fb6704c8ad.png

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

e58dfd3eba58b070ca68d29d35da8385.png浏览器的话,由于我这里没有安装解析 JSON 的插件,所以看起来很不舒服。因此推荐大家安装一个 JSON Viewer 插件,查看 JSON 数据时会很方便。

至于 localhost:5555/docs 页面本身,我们也是可以进行设置的:

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI(title="测试文档",
  4.               description="这是一个简单的 demo",
  5.               docs_url="/my_docs",
  6.               openapi_url="/my_openapi")
  7. @app.get("/items/{item_id}")
  8. async def get_item(item_id: int):
  9.     return {"item_id": item_id}
  10. if __name__ == "__main__":
  11.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

然后我们再重新进入,此时在浏览器里就需要输入 localhost:5555/my_docs:

18fdff045284895da3ce59eabc062d43.png

整体没什么难度,我们还可以指定其它参数,比如 version 等等,可以自己试试。该页面主要用来测试自己编写的 API 服务,不过个人更喜欢使用 requests 发请求。

5cbdf5dd747718c42b845bb3a8216e40.png

路由顺序

我们在定义路由的时候需要注意一下顺序,举个例子:

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/users/me")
  5. async def read_user_me():
  6.     return {"user_id""the current user"}
  7. @app.get("/users/{user_id}")
  8. async def read_user(user_id: int):
  9.     return {"user_id": user_id}
  10. if __name__ == "__main__":
  11.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

因为路径匹配是按照顺序进行的,所以这里要保证 /users/me 在 /users/{user_id} 的前面,否则的话只会匹配到 /users/{user_id},这样的话访问 /users/me 就会解析错误,因为字符串 "me" 无法解析成整型。

ad547293264e086f433e0779ac641eb9.png

使用枚举

我们可以将某个路径参数通过类型注解的方式声明为指定的类型(准确的说是可以转成指定的类型,因为默认都是字符串),但如果我们希望它只能是规定的几个值之一,该怎么做呢?

  1. from enum import Enum
  2. from fastapi import FastAPI
  3. import uvicorn
  4. app = FastAPI()
  5. class Name(str, Enum):
  6.     satori = "古明地觉"
  7.     koishi = "古明地恋"
  8.     marisa = "雾雨魔理沙"
  9. @app.get("/users/{user_name}")
  10. async def get_user(user_name: Name):
  11.     return {"user_id": user_name}
  12. if __name__ == "__main__":
  13.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

通过枚举的方式可以实现这一点,我们来测试一下:

09bb19d90e65243a4a0f3b665fb7b4fd.png

结果和我们期望的是一样的,可以再来看看 docs 生成的文档:

f7c3a8b2401c17a8dcacb53a463bb99e.png

可用的值都有哪些,也自动提示给我们了。

314244f28f628b7a37f2ccb1bbac0583.png

路径中包含 /

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

那么为了防止解析出错,我们需要做一个类似于 Flask 的操作:

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. # 声明 file_path 的类型为 path
  5. # 这样它会被当成一个整体
  6. @app.get("/files/{file_path:path}")
  7. async def get_file(file_path: str):
  8.     return {"file_path": file_path}
  9. if __name__ == "__main__":
  10.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

然后来访问一下:

bf1ee5e6e112fb0c108103b82c2fa3cd.png

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

02c20dbe901f81ddd671972ee28411a4.png

查询参数

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

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/users/{user_id}")
  5. async def get_user(user_id: str, name: str, age: int):
  6.     """
  7.     我们在函数中定义了 user_id、name、age 三个参数
  8.     显然 user_id 和 路径参数中的 user_id 对应
  9.     然后 name 和 age 会被解释成查询参数
  10.     这三个参数的顺序没有要求,但一般都是路径参数在前,查询参数在后
  11.     """
  12.     return {"user_id": user_id, "name": name, "age": age}
  13. if __name__ == "__main__":
  14.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

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

30f58df18d2c131c5f3aceba4e37105f.png

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

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/users/{user_id}")
  5. async def get_user(user_id: str, 
  6.                    name: str = "UNKNOWN"
  7.                    age: int = 0):
  8.     return {"user_id": user_id, "name": name, "age": age}
  9. if __name__ == "__main__":
  10.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

9f4b6ff8536ec3a28f4285847a8f638e.png

这里使用了默认值,并且对于查询参数,由于它们指定了类型,所以我们也要传递正确类型的数据。假设给这里的 age 传递了一个 "abc",那么是通不过的,因为它要求的是整型。

另外默认值的类型和指定的类型还可以不相同。

  1. from fastapi import FastAPI
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/users/{user_id}")
  5. async def get_user(user_id: str,
  6.                    name: str = "UNKNOWN",
  7.                    age: int = "蛤蛤蛤"):
  8.     return {"user_id": user_id, "name": name, "age": age}
  9. if __name__ == "__main__":
  10.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

这里的 age 需要接收一个整型,但默认值却是一个字符串,那么此时会有什么情况发生呢?我们来试一下:

de590c849d4e99d62867aa10bcd1d070.png

我们看到,传递的 age 依旧需要整型,只不过在不传的时候会使用字符串类型的默认值。所以指定的类型和默认值类型不相同,也是允许的,只不过这么做显然是不合理的。

此外我们还可以指定多个类型,比如让 user_id 按照整型解析、解析不成功时退化为字符串。

  1. from typing import Union, Optional
  2. from fastapi import FastAPI
  3. import uvicorn
  4. app = FastAPI()
  5. @app.get("/users/{user_id}")
  6. async def get_user(user_id: Union[int, str],
  7.                    name: Optional[str] = None):
  8.     """
  9.     通过 Union 来声明一个混合类型,int 在前、str 在后
  10.     会先按照 int 解析,解析失败再变成 str
  11.     然后是 name,它表示字符串类型、但默认值为 None(不是字符串)
  12.     那么应该声明为 Optional[str]
  13.     """
  14.     return {"user_id": user_id, "name": name}
  15. if __name__ == "__main__":
  16.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

253737bfaf4eedc9c8c7dc4b2b0c9003.png

所以 FastAPI 的设计还是非常不错的,通过 Python 的类型注解来实现参数类型的限定可以说是非常巧妙的,因此这也需要我们熟练掌握 Python 的类型注解。

这里补充一下,我当前的 Python 版本是 3.8,如果你用的是 3.10,那么类型注解还有不同的写法:

  1. >>> from typing import Union, Optional
  2. # Optional[str] 和 str | None 等价
  3. >>> name: Optional[str] = "古明地觉"
  4. >>> name: str | None = "古明地觉"
  5. # Union[int, str] 和 int | str 等价
  6. >>> age: Union[int, str] = 17
  7. >>> age: int | str = 17

这种写法在 3.10 才开始正式引入,但通过 from __future__ import annotations 也可以在 3.9 里面使用,而 3.8 是不支持的。

fea9444a9447958f9de9d9b34e2fef4c.gif

布尔类型自动转换


对于布尔类型,FastAPI 支持自动转换,举个例子:

  1. @app.get("/{flag}")
  2. async def get_flag(flag: bool):
  3.     return {"flag": flag}

970558cd3c9478bc7efda1f93e2dc70b.png

9fcdfb9a0cb7c403923903817eb90df6.gif

多个路径和查询参数

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

  1. @app.get("/postgres/{schema}/v1/{table}")
  2. async def get_data(schema: str,
  3.                    table: str,
  4.                    select: str = "*",
  5.                    where: Optional[str] = None,
  6.                    limit: Optional[int] = None,
  7.                    offset: Optional[int] = None):
  8.     """
  9.     标准格式是:路径参数按照顺序在前,查询参数在后
  10.     但 FastAPI 对顺序本身是没有什么要求的
  11.     """
  12.     query = f"select {select} from {schema}.{table}"
  13.     if where:
  14.         query += f" where {where}"
  15.     if limit:
  16.         query += f" limit {limit}"
  17.     if offset:
  18.         query += f" offset {offset}"
  19.     return {"query": query}

然后使用 requests 测试一下:

8e2b6cedabc145b12bf84006f8e96216.png

7bb878c4c6f70ff281a6850e25e7e6f1.gif

Depends

这个老铁比较特殊,它是用来做什么的呢?我们来看一下:

  1. from typing import Optional
  2. from fastapi import FastAPI, Depends
  3. import uvicorn
  4. app = FastAPI()
  5. async def common_parameters(
  6.         select: str = "*",
  7.         skip: int = 0,
  8.         limit: int = 100):
  9.     return {"select"select"skip": skip, "limit": limit}
  10. @app.get("/items/")
  11. async def read_items(
  12.         commons: dict = Depends(common_parameters)):
  13.     # common_parameters 接收三个参数:q、skip、limit
  14.     # 因此会从请求中解析出 q、skip、limit 并传给 common_parameters
  15.     # 然后将 common_parameters 的返回值赋给 commons
  16.     # 但如果解析不到某个参数,那么会判断函数中参数是否有默认值
  17.     # 没有的话就会返回错误
  18.     return commons
  19. @app.get("/users/")
  20. async def read_users(
  21.         commons: dict = Depends(common_parameters)):
  22.     return commons
  23. if __name__ == "__main__":
  24.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

我们来测试一下:

396dfa189e8f2b96534a2f67c692b864.png

所以 Depends 能够很好地实现依赖注入,而且这里特意写了两个路由,就是想表明它们是彼此独立的。因此当有共享的逻辑、或者共享的数据库连接、增强安全性、身份验证、角色权限等需求时,会非常的实用。

36e41d76855ad5e9db03ba2bd110c3cc.png

数据校验(针对查询参数)

FastAPI 支持我们进行更加智能的数据校验,比如一个字符串,我们希望用户在传递的时候只能传递长度为 6 到 15 的字符串该怎么做呢?

  1. from typing import Optional
  2. from fastapi import FastAPI, Query
  3. import uvicorn
  4. app = FastAPI()
  5. @app.get("/user")
  6. async def check_length(
  7.     # 默认值为 None,应该声明为 Optional[str],当然声明 str 也是可以的
  8.     # 只不过声明为 str,那么默认值应该也是 str
  9.     # 所以如果允许一个类型的值为空,那么更规范的做法应该是声明为 Optional[类型]
  10.     password: Optional[str] = Query(None, min_length=6, max_length=15)
  11. ):
  12.     return {"password": password}
  13. if __name__ == "__main__":
  14.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

password 是可选的,但传递的时候必须传字符串、而且还是长度在 6 到 15 之间的字符串。所以在声明默认值的时候 None 和 Query(None) 是等价的,只不过 Query 还支持对参数进行额外的限制。

4c27480a144e9f4369c6cc6002c12f8b.png

Query 里面除了限制最小长度和最大长度,还有其它的功能:

  1. @app.get("/user")
  2. async def check_length(
  3.     password: str = Query("satori", min_length=6
  4.                           max_length=15, regex=r"^satori")
  5. ):
  6.     """
  7.     此时 password 的默认值为 'satori',并且传递的时候也必须要以 'satori' 开头
  8.     但值得注意的是 password 后面的类型注解是 str,不再是 Optional[str]
  9.     因为默认值不是 None 了,当然这里即使写成 Optional[str] 也是没有什么影响的
  10.     """
  11.     return {"password": password}

61bf6fbf3ada2c7a2d156c6b48e5312d.png

38f76f67159ba9051cd9bbbb1d233ab3.gif

声明查询参数为必传参数

如果我们想让某个查询参数为必传参数,只需要不给它默认值就行了。

  1. @app.get("/user")
  2. async def check_length(password: str):
  3.     return {"password": password}

函数里面的参数,要么是路径参数、要么是查询参数。显然 password 是一个查询参数,通过不指定默认值,我们即可实现它是一个必传参数。也就是在 URL 中,必须通过 ?password=xxx 的方式进行传递。

虽然目的很简单,但我们发现此时无法对 password 进行限制了,比如希望它的长度是 6 到 15。那么问题来了,如何才能两者兼顾呢?

  1. @app.get("/user")
  2. async def check_length(
  3.     password: str = Query(..., min_length=6,
  4.                           max_length=15)
  5. ):  
  6.     # 我们知道 Query 的第一个参数是 password 的默认值
  7.     # 但如果将 Query 的第一个参数换成 ...
  8.     # 那么 FastAPI 就不会再将它当成是默认值了
  9.     # 而是对 password 起一个限定作用,表示它是必传参数
  10.     return {"password": password}

ccaabe7a8f8270753b3fca234c710307.png

... 是 Python 的一个特殊的对象,可以了解一下,在 Numpy 里面也可以使用它。

最后再补充一点,我们也可以不使用 Query,将 password 的长度限制逻辑写在函数体里面也是一样的。

ff2d8c94f817b3d47f12783df6274387.gif

同时获取多个相同的查询参数

如果我们指定了 a=1&a=2,那么在获取 a 的时候,会得到什么呢?

  1. from typing import List
  2. from fastapi import FastAPI, Query
  3. import uvicorn
  4. app = FastAPI()
  5. @app.get("/items")
  6. async def read_items(
  7.         a1: str = Query(...),
  8.         a2: List[str] = Query(...),
  9.         b: List[str] = Query(...)
  10. ):
  11.     return {"a1": a1, "a2": a2, "b": b}

我们访问一下,看看结果:

5eb68b6ff2762fc937d3374cfbf40ebd.png

首先 a2 和 b 都是列表,会获取所有的值,但 a1 只获取了最后一个值。

另外可能有人觉得代码有点啰嗦,在函数声明中可不可以这样写呢?

  1. @app.get("/items")
  2. async def read_items(
  3.         a1: str,
  4.         a2: List[str],
  5.         b: List[str]
  6. ):
  7.     return {"a1": a1, "a2": a2, "b": b}

我们将 Query(...) 去掉了,因为它没有对参数做其它的限制,只是表示参数是一个必传参数。而不指定 Query(...),那么本身就是一个必传参数,所以完全可以把 Query(...) 去掉。

这种做法,对于 a1 来说是可行的,但对于 a2 和 b 来说不行。对于类型为 list 的查询参数,我们必须要显式的加上 Query(...) 来表示必传参数。如果允许为 None(或者有默认值)的话,那么应该这么写:

  1. @app.get("/items")
  2. async def read_items(
  3.     a1: str,
  4.     a2: Optional[List[str]] = Query(None),
  5.     b: List[str] = Query(["1""嘿嘿"])
  6. ):
  7.     return {"a1": a1, "a2": a2, "b": b}

05ceab6ae12533c449ba9bb9c80b7014.png

26434f397f505fa3b5480ab567324f26.gif

给参数起别名

问题来了,假设我们定义的查询参数名叫 item-query,那么由于它要体现在函数参数中,而这显然不符合 Python 变量的命名规范,这个时候要怎么做呢?

  1. @app.get("/items")
  2. async def read_items(
  3.     # 三个查询参数,分别是 item-query、@@@@、$$$$
  4.     # 但它们不符合 Python 变量的命名规范
  5.     # 于是要为它们起别名
  6.     item1: Optional[str] = Query(None, alias="item-query"),
  7.     item2: str = Query("哈哈", alias="@@@@"),
  8.     # item3 是必传的
  9.     item3: str = Query(..., alias="$$$$")  
  10. ):
  11.     return {"item-query": item1, "@@@@": item2, "$$$$": item3}

11442cc8a72c3f000d23fa025559bcd1.png

b48c38740f653414eec8d2ab51d9c3eb.gif

数值检测

Query 不仅仅支持对字符串的校验,还支持对数值的校验,里面可以传递 gt, ge, lt, le 等参数,相信这几个参数不用说你也知道是干什么的,我们举例说明:

  1. @app.get("/items")
  2. async def read_items(
  3.     # item1 必须大于 5
  4.     item1: int = Query(..., gt=5),
  5.     # item2 必须小于等于 7
  6.     item2: int = Query(..., le=7),
  7.     # item3 必须等于 10
  8.     item3: int = Query(..., ge=10, le=10)
  9. ):
  10.     return {"item1": item1, 
  11.             "item2": item2, 
  12.             "item3": item3}

63f137420ceff6ed41f4cad7259e7920.png

Query 还是比较强大的 ,当然内部还有一些其它的参数是针对 docs 交互文档的,有兴趣可以自己了解一下。

ef3706361fdafcc2f20c086719b6e7e0.png

数据校验(针对路径参数)

对查询参数进行数据校验使用的是 Query,对路径参数进行数据校验使用的是 Path,两者的使用方式一模一样,没有任何区别。

  1. from fastapi import FastAPI, Path
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/items/{item-id}")
  5. async def read_items(
  6.     item_id: int = Path(..., alias="item-id")
  7. ):
  8.     return {"item-id": item_id}

路径参数是必须的,它是路径的一部分,所以我们应该使用 ... 将其标记为必传参数。当然即使不标记也无所谓,就算指定了默认值也用不上,因为路径参数不指定压根就匹配不到相应的路由。至于一些其它的校验,和查询参数一模一样,所以这里不再赘述了。

不过我们之前说过,路径参数应该在查询参数的前面,尽管 FastAPI 没有这个要求,但是这样写明显更舒服一些。不过问题来了,如果路径参数需要指定别名,但是某一个查询参数不需要,这个时候就会出现问题:

  1. @app.get("/items/{item-id}")
  2. async def read_items(
  3.     q: str,
  4.     item_id: int = Path(..., alias="item-id")
  5. ):
  6.     return {"item_id": item_id, "q": q}

显然此时 Python 的语法就决定了 item_id 必须放在 q 的后面,当然这么做是完全没有问题的,FastAPI 对参数的先后顺序没有任何要求,因为它是通过参数的名称、类型和默认值声明来检测参数,而不在乎参数的顺序。但如果我们就要让 item_id 在 q 的前面要怎么做呢?

  1. @app.get("/items/{item-id}")
  2. async def read_items(
  3.     *,
  4.     item_id: int = Path(..., alias="item-id"),
  5.     q: str,
  6. ):
  7.     return {"item_id": item_id, "q": q}

此时就没有问题了,通过将第一个参数设置为 *,使得 item_id 和 q 都必须通过关键字参数传递,所以此时默认参数在非默认参数之前也是允许的。当然我们也不需要担心 FastAPI 传参的问题,你可以认为它所有的参数都是通过关键字参数的方式传递的。

fb5c857e5fde2276e91b8e2658c9242c.png

请求的载体:Request 对象

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

  1. from fastapi import FastAPI, Request
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/girl/{user_id}")
  5. async def read_info(user_id: str,
  6.                     request: Request):
  7.     """
  8.     路径参数必须要体现在函数参数中
  9.     但是查询参数可以不写了
  10.     因为我们定义了 request: Request
  11.     那么请求相关的所有信息都会进入到这个 Request 对象中
  12.     """
  13.     header = request.headers  # 请求头
  14.     method = request.method  # 请求方法
  15.     cookies = request.cookies  # cookies
  16.     query_params = request.query_params  # 查询参数
  17.     return {"name": query_params.get("name"), 
  18.             "age": query_params.get("age"), 
  19.             "hobby": query_params.getlist("hobby")}
  20. if __name__ == "__main__":
  21.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

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

7a29a99cbe4626da0497b14eecbf004e.png

FastAPI 重度依赖 Python 的类型注解,假设 request 参数的类型是 str,那么 FastAPI 就会认为 request 是一个普通的查询参数。但这里 request 的类型是 Request,那么 FastAPI 就知道它代表整个请求,于是会自动将请求的载体 Request 对象赋值给参数 request。

而通过 request,我们可以拿到所有的请求参数,非常方便。只是数据校验这一步就必须由我们手动做了,比如这里 name 没有做校验,客户端传递任何值都是合法的,并且不传递的话也会返回 None。但手动校验的好处就是自由程度要更高一些,当参数不合法时,我们可以自定制返回的错误信息,之前的错误信息都是 FastAPI 内部预定义好的。

eea42541af38bd8dcd34360878b5a735.png

响应的载体:Response 对象

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

Response 内部接收如下参数:

  • content:返回的数据;

  • status_code:状态码;

  • headers:返回的响应头;

  • media_type:响应类型(就是响应头里面的 Content-Type,这里单独作为一个参数出现了,其实通过 headers 参数设置也是可以的);

  • background:接收一个任务,Response 在返回之后会自动异步执行(这里先不做介绍,后面会说);

举个例子:

  1. from fastapi import FastAPI, Request, Response
  2. import uvicorn
  3. import orjson
  4. app = FastAPI()
  5. @app.get("/girl/{user_id}")
  6. async def read_info(user_id: str,
  7.                     request: Request):
  8.     # 查询参数
  9.     query_params = request.query_params
  10.     data = {"name": query_params.get("name"),
  11.             "age": query_params.get("age"),
  12.             "hobby": query_params.getlist("hobby")}
  13.     # 实例化一个 Response 对象
  14.     response = Response(
  15.         # content,手动转成 json
  16.         orjson.dumps(data),
  17.         # status_code,状态码
  18.         201,
  19.         # headers,响应头
  20.         {"Token""xxx"},
  21.         # media_type,就是 HTML 中的 Content-Type
  22.         # content 只是一坨字节流,需要告诉客户端响应类型
  23.         # 这样客户端才能正确的解析
  24.         "application/json",
  25.     )
  26.     # 拿到 response 的时候,还可以单独对响应头和 cookie进行设置
  27.     response.headers["ping"] = "pong"
  28.     # 设置 cookie 的话,通过 response.set_cookie
  29.     response.set_cookie("SessionID""abc123456")
  30.     # 也可以通过 response.delete_cookie 删除 cookie
  31.     return response

4597167ef92e66ab7f24addfc34474cc.png

通过 Response 我们可以实现请求头、状态码、cookie 的自定义。另外除了 Response 之外还有很多其它类型的响应,比如:

  • FileResponse:用于返回文件;

  • HTMLResponse:用于返回 HTML;

  • PlainTextResponse:用于返回纯文本;

  • JSONResponse:用于返回 JSON;

  • RedirectResponse:用于重定向;

  • StreamingResponse:用于返回二进制流;

它们都继承了 Response,只不过会自动帮你设置响应类型,举个例子:

  1. from fastapi import FastAPI
  2. from fastapi.responses import Response, HTMLResponse
  3. import uvicorn
  4. app = FastAPI()
  5. @app.get("/index")
  6. async def index():
  7.     response1 = HTMLResponse("<h1>你好呀</h1>")
  8.     response2 = Response("<h1>你好呀</h1>"
  9.                          media_type="text/html")
  10.     # 以上两者是等价的,在 HTMLResponse 里面
  11.     # 会自动将 media_type 设置成 text/html
  12.     return response1

另外我们在开头说过,FastAPI 的请求与响应相关的功能,实际上是基于 starlette。

032a88dc1a7a34b8014e722e884351b1.png

请求载体 Request 和响应载体 Response 都是直接从 starlette 里面导入的。

2febb269351fc92f8cfd4a8e05aa3bda.png

其它类型的请求与响应

FastAPI 除了 GET 请求之外,还支持其它类型,比如:POST, PUT, DELETE, OPTIONS, HEAD, PATCH, TRACE 等等。而常见的也就 GET, POST, PUT, DELETE,介绍完了 GET,我们来说一说其它类型的请求。

显然对于 POST、PUT 等类型的请求,我们必须要能够解析出请求体。

a52a05f6987d89e20334d16cf40d99ce.gif

Model

在 FastAPI 中,请求体可以看成是 Model 对象,举个例子:

  1. from typing import Optional
  2. from fastapi import FastAPI, Response
  3. from pydantic import BaseModel
  4. import orjson
  5. import uvicorn
  6. app = FastAPI()
  7. class Girl(BaseModel):
  8.     """
  9.     数据验证是通过 pydantic 实现的
  10.     我们需要从中导入 BaseModel,然后继承它
  11.     """
  12.     name: str
  13.     age: Optional[str] = None
  14.     length: float
  15. @app.post("/girl")
  16. async def read_info(girl: Girl):
  17.     # girl 就是我们接收的请求体,它需要通过 json 来传递
  18.     # 并且这个 json 要有上面的三个字段(age 可以没有)
  19.     # 通过 girl.xxx 的方式我们可以获取和修改内部的所有属性
  20.     data = {"姓名": girl.name, "年龄": girl.age,
  21.             "身高": girl.length}
  22.     return Response(
  23.         orjson.dumps(data),
  24.         media_type="application/json"
  25.     )
  26. if __name__ == "__main__":
  27.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

我们访问一下:

618d784b131ccc26f033b434f0a7454a.png

除了使用 pydantic,我们还可以手动验证:

  1. @app.post("/girl")
  2. async def read_info(request: Request):
  3.     # 是一个协程,所以需要 await
  4.     data = await request.body()

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

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

0f5da1a58de095e53f64ace15d0ff0de.png

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

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

  1. @app.post("/girl")
  2. async def read_info(request: Request):
  3.     data = await request.body()
  4.     try:
  5.         # 解析成字典
  6.         data = orjson.loads(data)
  7.     except orjson.JSONDecodeError:
  8.         result = {"error""请传递 JSON"}
  9.         return Response(
  10.             orjson.dumps(result),
  11.             status_code=404,
  12.             media_type="application/json"
  13.         )
  14.     result = {"name": data.get("name"),
  15.               "age": data.get("age"),
  16.               "length": data.get("length")}
  17.     return Response(
  18.         orjson.dumps(result),
  19.         media_type="Application/json"
  20.     )

49cf42a6046ed808b8df71d7bf2996b7.png

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

就我个人而言,基本很少使用 pydantic 做数据验证,一般都是手动解析数据、进行验证。当数据不合法时,返回自定义的错误信息。

685df6abcb0405f761c71d9b16387bf1.gif

路径参数、查询参数、请求体

这几种不同的参数,我们可以混合在一起:

  1. from typing import Optional
  2. from fastapi import FastAPI
  3. from pydantic import BaseModel
  4. import uvicorn
  5. app = FastAPI()
  6. class Girl(BaseModel):
  7.     name: str
  8.     age: Optional[str] = None
  9.     length: float
  10. @app.post("/girl/{user_id}")
  11. async def read_info(user_id,
  12.                 q: str,
  13.                 girl: Girl):
  14. # user_id:路径参数,q:查询参数,girl:请求体
  15.     return {"user_id": user_id,
  16.             "q": q, 
  17.             **dict(girl)}

5c12b75e44cba0cbd20d951ac5c856f7.png

里面同时指定了路径参数、查询参数和请求体,FastAPI 依然是可以正确区分的,当然我们也可以使用 Request 对象。

  1. @app.post("/girl/{user_id}")
  2. async def read_info(user_id,
  3.                 request: Request):
  4.     # user_id 是路径参数,它一定要出现在视图函数中
  5.     # 并且没有限制类型,那么 user_id 可以是任意类型
  6.     # 然后查询参数和请求体,可以通过 request 获取
  7.     q = request.query_params.get("q")
  8.     # 请求体应该是一个 JSON
  9.     data = await request.json()
  10.     return {"user_id": user_id, "q": q, **data}

发请求的话,返回的内容是一样的。

所以对于服务端而言,解析有两种方式。一种是体现在函数参数中,如果参数不对,FastAPI 会自动检测到,然后抛出预定义错误;而另一种则是使用 Request 对象,此时请求相关的全部信息都会被封装到这个对象中,然后我们手动解析,当参数不合法时,可以自定义返回的错误信息,可控性更高。

特别是当 JSON 的字段非常多的时候,定义 Model 比较麻烦,用 Request 对象会方便一些。举个例子:

77840a13ecf9cb5efabbb335526ee008.png

如果发送的 JSON 里面有很多字段,每个字段的值的类型还不同,以及还包含 JSON 的嵌套等等。那么再通过定义 Model 的方式就很麻烦了,而通过 Request 拿到字节流之后再解析,就会方便很多。

072fc0753a659c35aca42193b0863c66.gif

Form 表单

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

  1. @app.post("/girl")
  2. async def read_info(request: Request):
  3.     form = await request.form()
  4.     return {"name": form.get("name"),
  5.             "age": form.getlist("age")}

cb275cded7ba91edf367affb80c224c3.png

而对于表单提交,FastAPI 还提供了另一种方式。

  1. from fastapi import FastAPI, Form
  2. import uvicorn
  3. app = FastAPI()
  4. @app.post("/user")
  5. async def get_user(username: str = Form(...),
  6.                    password: str = Form(...)):
  7.     return {"username": username, 
  8.             "password": password}
  9. if __name__ == "__main__":
  10.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

ccae032bd84264ec01a769e77da52696.png

像 Form 表单,查询参数、路径参数等等,都可以和 Request 对象一起使用,像上面的例子。如果再多定义一个参数 request: Request,那么仍然可以通过 await request.form() 拿到相关的表单信息。

  1. @app.post("/user")
  2. async def get_user(*,
  3.                    username: str = Form(...),
  4.                    password: str = Form(...),
  5.                    request: Request):
  6.     form = await request.form()
  7.     return {"username": username,
  8.             "password": password}
  9.     # 两个 return 是等价的
  10.     return {"username": form.get("username"),
  11.             "password": form.get("password")}

所以如果你觉得某个参数不适合类型注解,那么可以单独通过 Request 对象进行解析,因为它是请求的载体,请求相关的一切信息都在里面。

a56f62b65745b966061eb11275073bfd.gif

文件上传

然后是文件上传功能,FastAPI 如何接收用户的文件上传呢?首先如果想支持文件上传,必须要安装一个包 python-multipart,直接用 pip 安装即可。

  1. from fastapi import FastAPI, File, UploadFile
  2. import uvicorn
  3. app = FastAPI()
  4. @app.post("/file1")
  5. async def file1(file: bytes = File(...)):
  6.     # 此时会以字节流的形式拿到文件的具体内容
  7.     return {"文件长度"len(file)}
  8. @app.post("/file2")
  9. async def file2(file: UploadFile = File(...)):
  10.     # 会拿到文件句柄
  11.     # 通过 await file.read() 可拿到文件内容
  12.     return {"文件名": file.filename,
  13.             "文件长度"len(await file.read())}
  14. if __name__ == "__main__":
  15.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

a7fd507b5e67be6b108d67a752a24309.png

所以我们可以直接获取字节流,或者获取文件句柄。但如果是多个文件上传要怎么做呢?

  1. from typing import List
  2. from fastapi import FastAPI, UploadFile, File
  3. import uvicorn
  4. app = FastAPI()
  5. @app.post("/file")
  6. async def file(files: List[UploadFile] = File(...)):
  7.     """
  8.     指定类型为列表即可
  9.     """
  10.     return [{"文件名": f.filename,
  11.              "文件长度"len(await f.read())}
  12.             for f in files]
  13. if __name__ == "__main__":
  14.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

bad3959f44b4210c02c2fddf7763b182.png

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

8b85912d6be040d4d39dd1f66658f7ab.png

返回静态资源

再来看看 FastAPI 如何返回静态资源,首先我们需要安装 aiofiles,直接 pip 安装即可。

  1. from fastapi import FastAPI
  2. from fastapi.staticfiles import StaticFiles
  3. import uvicorn
  4. app = FastAPI()
  5. # name 参数只是起一个名字,FastAPI 内部使用
  6. app.mount("/static",
  7.           StaticFiles(directory=r"/Users/satori/Downloads/pics"),
  8.           name="static")
  9. if __name__ == "__main__":
  10.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

浏览器输入:localhost:5555/static/1.png,那么会返回指定目录下的 1.png 文件。

4d36c2c73bb8467983c04078ccafa85d.png

APIRouter

APIRouter 类似于 Flask 的蓝图,可以更好地组织大型项目,举个例子:

b8d5e57e3f3f1c61683e9df11cc81e9e.png

在当前的工程目录中有一个 app 目录和一个 main.py,其中 app 目录里面有一个 app01.py,然后来看看它们是如何组织的。

  1. # app/app01.py
  2. from fastapi import APIRouter
  3. router = APIRouter(prefix="/router")
  4. # 以后访问的时候要通过 /router/v1 来访问
  5. @router.get("/v1")
  6. async def v1():
  7.     return {"message""hello world"}
  8. # main.py
  9. from fastapi import FastAPI
  10. from app.app01 import router
  11. import uvicorn
  12. app = FastAPI()
  13. # 将 router 注册到 app 中
  14. # 相当于 Flask 里面的 register_blueprint
  15. app.include_router(router)
  16. if __name__ == "__main__":
  17.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

然后在外界便可以通过 /router/v1 的方式来访问。

错误处理

错误处理也是一个不可忽视的点,错误有很多种,比如:

  • 客户端没有足够的权限执行此操作;

  • 客户端没有访问某个资源的权限;

  • 客户端尝试访问一个不存在的资源;

  • ······

这个时候我们应该将错误通知给相应的客户端,这个客户端可以浏览器、代码程序、IoT 设备等等。

但是就我个人而言,更倾向于使用 Response 对象,将里面的 status_code 设置为 404,然后在返回的 json 中指定错误信息。不过 FastAPI 内部也提供了一些异常类:

  1. from fastapi import FastAPI, HTTPException
  2. import uvicorn
  3. app = FastAPI()
  4. @app.get("/items/{item_id}")
  5. async def read_item(item_id: str):
  6.     if item_id != "foo":
  7.         # 里面还可以传入 headers 设置响应头
  8.         raise HTTPException(status_code=404
  9.                             detail="item 没有发现")
  10.     return {"item""bar"}

1a6e8438ffb7a2a09882451fa1223cb4.png

HTTPException 是一个普通的 Python 异常类(继承了 Exception),它携带了 API 的相关信息。并且既然是异常,那么我们不能 return、而是要 raise。

个人觉得这个不是很常用,至少我本人很少用这种方式返回错误,因为它能够携带的信息太少了。

f8279e101efd9bdbbc3423ff9f259913.png

自定义异常

上面使用的 HTTPException 是 FastAPI 内部提供的异常类,我们也可以自定义,但是定义完异常之后,还要定义一个 handler,将异常和 handler 绑定在一起,然后引发该异常的时候就会触发相应的 handler。

  1. from fastapi import FastAPI, Request
  2. from fastapi.responses import Response
  3. import uvicorn
  4. import orjson
  5. app = FastAPI()
  6. class ASCIIException(Exception):
  7.     """什么也不做"""
  8. # 通过装饰器的方式
  9. # 将 ASCIIException 和 ascii_exception_handler 绑定在一起
  10. @app.exception_handler(ASCIIException)
  11. async def ascii_exception_handler(request: Request, 
  12.                                   exc: ASCIIException):
  13.     """
  14.     当引发 ASCIIException 的时候,
  15.     会触发 ascii_exception_handler 的执行
  16.     同时会将 request 和 exception 传过去
  17.     """
  18.     return Response(
  19.         orjson.dumps({"code"404
  20.                       "message""必须传递 ascii 字符串"}),
  21.         status_code=404
  22.     )
  23. @app.get("/items/{item_id}")
  24. async def read_item(item_id: str):
  25.     if not item_id.isascii():
  26.         raise ASCIIException
  27.     return {"item": f"get {item_id}"}

a6f0324b083959e1ff3a37e41c2114bf.png

还是很简单的,另外关于 Request 和 Response,我们除了可以通过 fastapi 导入,还可以通过 starlette 导入,因为 fastapi 的路由映射是通过 starlette 来实现的。当然我们直接从 fastapi 里面导入即可。

fe483549d0e114fa9e8c893869edb626.png

自定义 404

当访问一个不存在的 URL,我们应该提示用户,比如:您要找到页面去火星了。

  1. from fastapi import FastAPI
  2. from fastapi.responses import Response
  3. from fastapi.exceptions import StarletteHTTPException
  4. import uvicorn
  5. import orjson
  6. app = FastAPI()
  7. @app.exception_handler(StarletteHTTPException)
  8. async def not_found(request, exc):
  9.     return Response(
  10.         orjson.dumps(
  11.             {"code"404,
  12.              "message": f"您要找的页面 {request.url} 去火星了。。。"}),
  13.         status_code=404
  14.     )

113e9486264e1eefd2879614c34d4e97.png

此时访问一个不存在的 URL 时,就会返回我们自定义的 JSON 字符串。而参数 request,就是请求对应的 Request 对象,为了方便 IDE 提示,定义的时候可以加上一个类型注解。

后台任务

如果处理请求的时候需要执行一个耗时任务,那么可以将其放在后台执行,而 FastAPI 已经帮我们做好了这一步。来看一下:

  1. import time
  2. from fastapi import FastAPI, BackgroundTasks
  3. from fastapi.responses import Response
  4. import uvicorn
  5. import orjson
  6. app = FastAPI()
  7. def send_email(email: str, message: str = ""):
  8.     """发送邮件,假设耗时三秒"""
  9.     time.sleep(3)
  10.     print(f"三秒之后邮件发送给 {email!r}, "
  11.           f"邮件信息: {message!r}")
  12. @app.get("/user/{email}")
  13. async def order(email: str, bg_tasks: BackgroundTasks):
  14.     """
  15.     这里需要多定义一个参数
  16.     此时任务就被添加到后台,当 Response 对象返回之后触发
  17.     """
  18.     # 可以添加任意多个任务
  19.     bg_tasks.add_task(send_email, email, message="这是一封邮件")
  20.     return Response(
  21.         orjson.dumps({"message""邮件发送成功"})
  22.     )
  23.     # 我们在之前介绍 Response 的时候说过,里面有一个参数 background
  24.     # 所以我们还可以这么做
  25.     """
  26.     bg_tasks = BackgroundTasks() # 不在参数中定义 bg_tasks
  27.     bg_tasks.add_task(send_email, email, message="这是一封邮件")
  28.     return Response(
  29.         orjson.dumps({"message": "邮件发送成功"}),
  30.         background=bg_tasks
  31.     )
  32.     """

27316298c424a072949fdf02560a1416.png

调用之后会立刻返回,然后我们看一下终端,会打印出如下信息:

b6c52ea8df4a7dd9c4d797f5eeaad4ba.png

所以此时任务是被后台执行了的,注意:任务是在响应返回之后才后台执行。

而后台任务的实现原理也很简单,FastAPI 会将我们添加的任务依次丢到线程池里面运行,看一下源码就知道了,实现比想象中要简单很多。

690e7344ebb4a2fe1b9370362e6e8a01.png

所以有些设计用起来感觉挺神奇的,但是看具体实现的话,会发现简单到不行。

中间件

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

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

9c1f8a84f02f27b0c7b46ecd6b9b3459.png

自定义中间件

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

  1. from fastapi import FastAPI, Request, Response
  2. import uvicorn
  3. import orjson
  4. app = FastAPI()
  5. @app.get("/")
  6. async def view_func(request: Request):
  7.     return {"name""古明地觉"}
  8. @app.middleware("http")
  9. async def middleware(request: Request, call_next):
  10.     """
  11.     定义一个协程函数,然后使用 @app.middleware("http") 装饰
  12.     即可得到中间件
  13.     """
  14.     # 请求到来时会先经过这里的中间件
  15.     if request.headers.get("ping""") != "pong":
  16.         response = Response(
  17.             content=orjson.dumps({"error""请求头中缺少指定字段"}),
  18.             media_type="application/json",
  19.             status_code=404)
  20.         # 当请求头中缺少 "ping""pong"
  21.         # 在中间件这一步就直接返回了,就不会再往下走了
  22.         # 所以此时相当于实现了一个拦截器
  23.         return response
  24.     # 如果条件满足,则执行await call_next(request),关键是这里的 call_next
  25.     # 如果该中间件后面还有中间件,那么 call_next 就是下一个中间件;
  26.     # 如果没有,那么 call_next 就是对应的视图函数
  27.     # 这里显然是视图函数,因此执行之后会拿到视图函数返回的 Response 对象
  28.     response: Response = await call_next(request)
  29.     # 我们对 response 做一些润色,比如设置一个响应头
  30.     # 所以我们看到在 FastAPI 中,请求中间件和响应中间件合在一起了
  31.     response.headers["status"] = "success"
  32.     return response

我们可以测试一下:

12a974ea8314146e71c262468b52c9a7.png

测试结果也印证了我们的结论。

ebe7ae87115155884b1c6e0e1c3f1936.png

内置的中间件

通过自定义中间件,我们可以在不修改视图函数的情况下,实现功能的扩展。但是除了自定义中间件之外,FastAPI 还提供了很多内置的中间件。

  1. from fastapi import FastAPI
  2. from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
  3. from starlette.middleware.trustedhost import TrustedHostMiddleware
  4. from starlette.middleware.gzip import GZipMiddleware
  5. import uvicorn
  6. app = FastAPI()
  7. # 要求请求协议必须是 https 或者 wss,如果不是,则自动跳转
  8. app.add_middleware(HTTPSRedirectMiddleware)
  9. # 请求中必须包含 Host 字段,为防止 HTTP 主机报头攻击
  10. # 并且添加中间件的时候,还可以指定一个 allowed_hosts,那么它是干什么的呢?
  11. # 假设我们有服务 a.example.com, b.example.com, c.example.com
  12. # 但我们不希望用户访问 c.example.com,就可以像下面这么设置
  13. app.add_middleware(TrustedHostMiddleware,
  14.                    # 如果指定为 ["*"],或者不指定 allow_hosts,则表示无限制
  15.                    allowed_hosts=["a.example.com""b.example.com"])
  16. # 如果用户的请求头的 Accept-Encoding 字段包含 gzip
  17. # 那么 FastAPI 会使用 GZip 算法压缩
  18. # minimum_size=1000 表示当大小不超过 1000 字节的时候就不压缩了
  19. app.add_middleware(GZipMiddleware, minimum_size=1000)

除了这些,还有其它的一些内置的中间件,可以自己查看一下,不过不是很常用。

984a8267171f8dbb6f0908359782b576.png

CORS

CORS(跨域资源共享)过于重要,我们需要单独拿出来说。

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

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

  • http://localhost

  • https://localhost

  • http://localhost:8080

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

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

  1. from fastapi import FastAPI
  2. from fastapi.middleware.cors import CORSMiddleware
  3. import uvicorn
  4. app = FastAPI()
  5. app.add_middleware(
  6.     CORSMiddleware,
  7.     # 允许跨域的源列表,例如 ["http://localhost:8080"]
  8.     # ["*"] 表示允许任何源
  9.     allow_origins=["*"],
  10.     # 跨域请求是否支持 cookie,默认是 False
  11.     # 如果为 True,allow_origins 必须为具体的源,不可以是 ["*"]
  12.     allow_credentials=False,
  13.     # 允许跨域请求的 HTTP 方法列表,默认是 ["GET"]
  14.     allow_methods=["*"],
  15.     # 允许跨域请求的 HTTP 请求头列表,默认是 []
  16.     # 可以使用 ["*"] 表示允许所有的请求头
  17.     # 当然下面几个请求头总是被允许的
  18.     # Accept、Accept-Language、Content-Language、Content-Type
  19.     allow_headers=["*"],
  20.     # 可以被浏览器访问的响应头, 默认是 [],一般很少指定
  21.     # expose_headers=["*"]
  22.     # 设定浏览器缓存 CORS 响应的最长时间,单位是秒
  23.     # 默认为 600,一般也很少指定
  24.     # max_age=1000
  25. )

以上即可解决跨域问题。

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

FastAPI 的其它操作

下面看一些 FastAPI 的其它操作,相当于是对前面内容的一个补充。

6b379eb94b5cbed57795145126df7d8c.png

其它种类的响应

我们前面介绍了如何返回不同格式的响应数据:

  1. # 返回 JSON 数据(返回字典会自动转成 JSON)
  2. Response(orjson.dumps({"k""v"}),
  3.          media_type="application/json",
  4.          status_code=200,
  5.          headers={"k""v"})
  6. # 返回 HTML
  7. Response("<h1>古明地觉</h1>",
  8.          media_type="text/html",
  9.          status_code=200,
  10.          headers={"k""v"})
  11. # 返回纯文本(此时 <h1> 不再是标签)
  12. Response("<h1>古明地觉</h1>",
  13.          media_type="text/plain",
  14.          status_code=200,
  15.          headers={"k""v"})

但还剩下几种响应,我们再单独说一下。

0687672ffae9b1970f3f3849b4e68866.gif

返回重定向


  1. from fastapi import FastAPI
  2. from fastapi.responses import RedirectResponse
  3. import uvicorn
  4. app = FastAPI()
  5. @app.get("/index")
  6. async def index():
  7.     return RedirectResponse("https://www.bilibili.com")

页面访问 /index 会跳转到 bilibili。

8fc0cd22b8d121242fdc9a8418a39587.gif

返回字节流


  1. from fastapi import FastAPI
  2. from fastapi.responses import StreamingResponse
  3. import uvicorn
  4. app = FastAPI()
  5. async def some_video():
  6.     for i in range(5):
  7.         yield f"video {i} bytes ".encode("utf-8")
  8. @app.get("/index")
  9. async def index():
  10.     return StreamingResponse(some_video())

bc50f5c41b78f8ca54b5540f80c265ed.png

如果有文件对象,那么也是可以直接返回的。

  1. from fastapi import FastAPI
  2. from fastapi.responses import StreamingResponse
  3. import uvicorn
  4. app = FastAPI()
  5. @app.get("/index")
  6. async def index():
  7.     return StreamingResponse(open("main.py", encoding="utf-8"))
  8. if __name__ == "__main__":
  9.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

c1dbb1100bbc446aeddcdfaf048292f8.png

64de2102cf9ba52151201e4adba10256.gif

返回文件

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

  1. # 我们可以返回图片、音频、视频,以字节流的形式
  2. # 但光有字节流还不够,我们还要告诉 Chrome
  3. # 拿到这坨字节流之后,应该要如何解析
  4. # 此时需要通过响应头里面的 Content-Type 指定
  5. Response(
  6.     b"picture | audio | video bytes data",
  7.     # png 图片:"image/png"
  8.     # mp3 音频:"audio/mp3"
  9.     # mp4 视频:"video/mp4"
  10.     media_type="image/png"
  11. )

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

  1. from mimetypes import guess_type
  2. # 根据文件后缀进行判断
  3. print(guess_type("1.png")[0])
  4. print(guess_type("1.jpg")[0])
  5. print(guess_type("1.mp3")[0])
  6. print(guess_type("1.mp4")[0])
  7. print(guess_type("1.wav")[0])
  8. print(guess_type("1.flv")[0])
  9. print(guess_type("1.pdf")[0])
  10. """
  11. image/png
  12. image/jpeg
  13. audio/mpeg
  14. video/mp4
  15. audio/x-wav
  16. video/x-flv
  17. application/pdf
  18. """

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

那这是怎么做到的呢?

  1. @app.get("/file1")
  2. async def get_file1():
  3.     # 读取字节流(任何类型的文件都可以)
  4.     with open("/Users/satori/Downloads/1.jpg""rb") as f:
  5.         data = f.read()
  6.     # 返回的时候通过 Content-Type 告诉 Chrome 文件类型
  7.     # 尽管 Chrome 比较智能,会自动判断,但最好还是指定一下
  8.     return Response(data,
  9.                     # 返回的字节流是 jpg 格式
  10.                     media_type="image/jpeg")
  11.     # Chrome 在拿到字节流时会直接将图片渲染在页面上
  12. @app.get("/file2")
  13. async def get_file2():
  14.     with open("main.py""rb") as f:
  15.         data = f.read()
  16.     # 在响应头中指定 Content-Disposition
  17.     # 意思就是告诉 Chrome,你不要解析了,直接下载下来
  18.     # filename 后面跟的就是文件下载之后的文件名
  19.     return Response(
  20.         data,
  21.         # 既然都下载下来了,也就不需要 Chrome 解析了
  22.         # 将响应类型指定为 application/octet-stream
  23.         # 表示让 Chrome 以二进制格式直接下载
  24.         media_type="application/octet-stream",
  25.         headers={"Content-Disposition""attachment; filename=main.py"})

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

d0e35ca3c7aead8b21bf96a779f6b4bc.png

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

97b71fbe385909a30a9dbc8c01bbaad8.png

所以即使返回的内容是纯文本,也是可以下载下来的。

了解完上述内容之后,再看 FileResponse 就简单多了。

f100f0d1ef177f1126de97484dc6d4ba.png

它默认是将文件下载下来,path 是文件路径,filename 是下载之后的文件名。如果你不想文件下载下来,而是直接显示在页面上,那么推荐使用 Response。

ef64c3c1996342aeda726bc186c3a477.png

HTTP 验证

如果当用户访问某个请求的时候,我们希望其输入用户名和密码来确认身份的话该怎么做呢?

  1. from fastapi import FastAPI, Depends
  2. from fastapi.security import HTTPBasic, HTTPBasicCredentials
  3. import uvicorn
  4. app = FastAPI()
  5. security = HTTPBasic()
  6. @app.get("/index")
  7. async def index(
  8.     credentials: HTTPBasicCredentials = Depends(security)
  9. ):
  10.     username = credentials.username
  11.     password = credentials.password
  12.     if username != "satori" or password != "123456":
  13.         return {"error""用户名密码错误"}
  14.     return {"username": credentials.username, 
  15.             "password": credentials.password}
  16. if __name__ == "__main__":
  17.     uvicorn.run("main:app", host="0.0.0.0", port=5555)

访问 /index 页面之后,会提示输入用户名密码。

27173458791974d2cb284cdbdbc84942.png

我们也可以用 requests 发请求。

e69cc98a5e3f547c6031f9a273534b27.png

输入完毕之后,用户名密码会保存在 credentials 里面,我们可以通过 username 和 password 字段取出来进行验证。

441c409ee8122d290ccf63d027ca6b55.png

WebSocket

然后再来看看 FastAPI 如何实现 websocket:

  1. from fastapi import FastAPI
  2. from fastapi.websockets import WebSocket
  3. import uvicorn
  4. app = FastAPI()
  5. @app.websocket("/ws")
  6. async def ws(websocket: WebSocket):
  7.     await websocket.accept() # 等待建立连接
  8.     while True:
  9.         # websocket.receive_bytes()
  10.         # websocket.receive_json()
  11.         data = await websocket.receive_text()
  12.         await websocket.send_text(f"收到来自客户端的回复: {data}")

我们通过浏览器进行通信:

e8c31f19e09202ab209f7c3311167651.png

FastAPI 的部署

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

  1. def run(app, **kwargs):
  2.     config = Config(app, **kwargs)
  3.     server = Server(config=config)
  4.     ...
  5.     ...

我们看到 app 和 **kwargs 都传递给了 Config,所以我们只需要看 Config 里面都有哪些参数即可。这里选出一部分:

e4d6d67e423431044fe8d5025a0e41b6.png

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

小结

总的来说,FastAPI 是一款非常成熟的协程框架,完全可以放在生产上使用。另外我们也清楚,性能的瓶颈基本不在框架上面,而是取决于数据库,所以在使用 FastAPI 的时候,还要搭配一个支持协程的驱动以及 ORM。

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

最后 FastAPI 还有一些第三方组件,比如后台管理、接口限流等等,有兴趣可以了解一下。

-------- End --------

推荐

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/天景科技苑/article/detail/819533
推荐阅读
相关标签