fastapi header 处理
Dependencies
最近在实现 SetTraceId 的中间件, 想法是在 ASGI Middleware 中, 在 scope 里检查 headers, 并写入一个 TraceId. 然后在 dependiences 中, 强制检查 X-Request-Trace-Id 这个标头.
大致代码如下
from contextvars import ContextVar
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from utils.snowflake import Snowflake
TRACE_ID: ContextVar[bytes] = ContextVar("TraceId")
SEND_VAR: ContextVar[Send] = ContextVar('Send callable')
REQ_KEY = b"X-Request-Trace-Id"
RES_KEY = b"X-Response-Trace-Id"
class SetTraceIdMiddleware:
"""为请求和响应添加 traceid"""
def __init__(
self,
app: ASGIApp,
**options,
) -> None:
self.app = app
async def send(self, message: Message):
_send = SEND_VAR.get()
if message['type'] == 'http.response.start':
headers = message['headers']
for key, _ in headers:
if key == RES_KEY:
break
else:
headers.append((RES_KEY, TRACE_ID.get()))
await _send(message)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
for k, v in scope['headers']:
if k == REQ_KEY:
break
else:
global TRACE_ID
trace_id = str(Snowflake().generate()).encode('ascii')
TRACE_ID.set(trace_id)
scope['headers'].append((REQ_KEY, trace_id))
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
global
SEND_VAR.set(send)
await self.app(scope, receive, self.send)
这样调用方可以传入 TraceId, 也可以由服务在中间件里生成一个.
未预料到的现象
from fastapi import Header, Request
async def check_x_request_trace_id(
req: Request,
trace_id: int = Header(None,
alias='X-Request-Trace-Id',
title='全局请求链路跟踪id',
description='不存在时, 中间件会自动分配'),
):
print('headers', trace_id, req.scope['headers'])
但是结果不如人意. 在上面的代码中, trace_id 实际是 None, 而访问 scope 对象中的 headers, 又是实际存在由中间件写入的 X-Request-Trace-Id 标头的.
从结果上看, check_x_request_trace_id 的调用是发生在用户中间件之后.
那么 fastapi 内 dependencies 为 check_x_request_trace_id绑定参数的行为发生在中间件之前, 也可能使用了一个深拷贝的 scope.headers 对象.
接下来就需要深入源码去寻找答案了.
FastAPI 调用链
根据 FastAPI.__call__和 Starlette.__call__ 可知, 在 FastAPI 中, ASGI Application 的调用顺序如下
ServerErrorMiddleware、user_middleware、ExceptionMiddlewareRouterRoute
传递给 FastAPI 的dependencies 会随着 APIRouter 向下传递到 APIRoute, 而 APIRoute 就是最终匹配到并会执行 endpoint.
而 fastapi.routing.APIRouter 并没有实现__call__, 而是继承了starlette.routing.Router 的 __call__ 实现.
同样, fastapi.routing.APIRoute 的 __call__方法也是继承自 starlette.routing.BaseRoute.
APIRoute 调用链
对于APIRoute的调用链如下:
- APIRoute.call
- starlette.routing.BaseRoute.call
- starlette.routing.Route.handle
- APIRoute.app
APIRoute.app实际上是调用了 fastapi.routing.get_route_handler 并经过starlette.routing.request_response装饰器来获得的可调用对象并传入 根据 scope 实例化的 request 对象来最终向上响应.
更明确的说, fastapi.routing.get_request_handler.app 就是最终真正去执行请求并向上响应的函数.
Dependant 依赖传递
通过APIRoute实例化源码可以发现, dependencies 和 endpoint 都会被注入到 APIRoute.dependant对象中.
而APIRoute.dependant对象会被传递到 fastapi.routing.get_request_handler.app 内, 并通过fastapi.dependencies.utils.solve_dependencies 对请求和dependencies进行解析, 拿到endpoint 和 dependencies 依赖的请求参数.
solve_dependencies 获得依赖值
通过阅读 fastapi.dependencies.utils.solve_dependencies的源代码发现, solve_dependencies 会遍历并逐个调用被注入到的dependant中的dependencies.
在 solve_dependencies中会分次调用request_params_to_args, 去获得对应 HTTP 请求中的params,query,headers,cookies
并且调用 request_body_to_args 去获得 body 参数的值.
在上述的调用中, 对应的 HTTP 请求参数都是通过 fastapi.routing.get_request_handler.app 的首个 scope 对象实例化生成.
所以要解决最初的问题, 就需要找到 fastapi.routing.get_request_handler.app 对象的 scope 参数是如何具体传递的
反思
在阅读上面的源码后, 基本可以推翻一开始的预测, APIRoute.app的调用顺序发生在中间件处理之后, 而 scope 对象也是由上层一步步传递下来.
那么问题大概就出现在 solve_dependencies 中从 request_params_to_args 解析 headers 参数的部分.
FastAPI 中实际的 Request 对象是从 starlette.requests.Request 导入的, 为了分析 headers 的解析逻辑, 下一步需要从这出发.
starlette 对 headers 的解析规则
starlette.requests.Request 的基本行为都是继承自 starlette.requests.HTTPConnection.
而 headers是以 scope 作为参数的传递给 starlette.datastructures.Headers的实例化对象.
starlette.datastructures.Headers 对象保存了 score["headers"]对象
通过阅读源代码发现, starlette.datastructures.Headers 在读取元素的时候, 会将 key 转为小写, 再从 _list 里遍历读取.
结论
那么最终结果就非常明确了, 需要在中间件里写入标头的时候, 需要写入小写的 header name.
在做对应的修改后, 结果也正如预期.
而在asgi-specs-http-connection-scope中对于 headers 也的确有关于应该是小写的建议, 但不是强制的.
如果 starlette.datastructures.Headers 在构造 _list 对象的时候将 scope 内的 headers 全部小写处理, 或者在读取的时候做大小写适配, 应该是一种更好的策略.
补充
contextvars使用注意
FastAPI 并没有通过 asyncio.Loop.call_later 这种 API 来创建一个后台的协程去调用中间件或路由函数.
所以在FastAPI中, 所有中间件和路由函数的 contextvars.Context 上下文是共用当前线程而没有隔离的.
上面提到的中间件示例, 当存在并发时会有脏数据.