• 作者:老汪软件技巧
  • 发表时间:2024-11-24 04:03
  • 浏览量:

引言

在项目中记录请求信息并计算请求处理时间,是实现请求日志追踪的关键。然而,在 FastAPI 中直接通过 Middleware 读取请求体(body)会导致请求体无法被多次读取,进而在路由函数中引发异常。为了解决这个问题,FastAPI 推荐使用 APIRoute。通过 APIRoute,可以安全地读取请求体数据,并在读取后继续完成其他处理,而不会影响后续路由函数的正常执行。

基于 APIRoute 的特性,我们可以封装一个自定义的路由类,实现请求和响应的日志记录,并计算每个请求的处理时间。这种方式既能满足记录需求,又能确保应用的稳定性和性能。

Middleware 封装请求日志

首先想到的都是web中间件,每次请求都会先过中间件。封装如下


from py_tools.logging import logger
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
class LoggingMiddleware(BaseHTTPMiddleware):
    """
    日志中间件
    记录请求参数信息、计算响应时间
    """
    async def dispatch(self, request: Request, call_next) -> Response:
        start_time = time.perf_counter()
        # 打印请求信息
        logger.info(f"--> {request.method} {request.url.path} {request.client.host}")
        if request.query_params:
            logger.info(f"--> Query Params: {request.query_params}")
        if "application/json" in request.headers.get("Content-Type", ""):
            try:
                # starlette 中间件中不能读取请求数据,否则会进入循环等待 需要特殊处理或者换APIRoute实现
                body = await request.json()
                logger.info(f"--> Body: {body}")
            except Exception as e:
                logger.warning(f"Failed to parse JSON body: {e}")
        # 执行请求获取响应
        response = await call_next(request)
        # 计算响应时间
        process_time = time.perf_counter() - start_time
        response.headers["X-Response-Time"] = f"{process_time:.2f}s"
        logger.info(f"<-- {response.status_code} {request.url.path} (took: {process_time:.2f}s)\n")
        return response

这中间主要就是在请求处理前日志记录下请求路由信息、请求参数等,请求处理后记录下请求处理耗时。日志打印效果如下:

在中间件直接读取body出现问题了,注册用户请求读取body一直在等待,刷新下界面就400响应了

网上有一种解决方案,包装 receive 函数


from py_tools.logging import logger
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
class LoggingMiddleware(BaseHTTPMiddleware):
    """
    日志中间件
    记录请求参数信息、计算响应时间
    """
    async def set_body(self, request: Request):
        receive_ = await request._receive()
        async def receive():
            return receive_
        request._receive = receive
    async def dispatch(self, request: Request, call_next) -> Response:
        start_time = time.perf_counter()
        # 打印请求信息
        logger.info(f"--> {request.method} {request.url.path} {request.client.host}")
        if request.query_params:
            logger.info(f"--> Query Params: {request.query_params}")

_日志接口实现方式最合理_产业基金深入解析

if "application/json" in request.headers.get("Content-Type", ""): await self.set_body(request) try: # starlette 中间件中不能读取请求数据,否则会进入循环等待 需要特殊处理或者换APIRoute实现 body = await request.json() logger.info(f"--> Body: {body}") except Exception as e: logger.warning(f"Failed to parse JSON body: {e}") # 执行请求获取响应 response = await call_next(request) # 计算响应时间 process_time = time.perf_counter() - start_time response.headers["X-Response-Time"] = f"{process_time:.2f}s" logger.info(f"<-- {response.status_code} {request.url.path} (took: {process_time:.2f}s)\n") return response

直接看看效果

ok,这样就解决,但这样不太好,官方也不推荐。

APIRoute 封装请求日志

官方也说明了不能在中间件直接读取 body 数据,可以使用 APIRoute 处理。在 APIRouter 中指定 route_class 参数来设置 APIRoute,然后重写 APIRoute 的 get_route_handler 方法即可。

官方文档:/zh/how-to/c…

代码如下

class LoggingAPIRoute(APIRoute):
    def get_route_handler(self) -> Callable:
        original_route_handler = super().get_route_handler()
        async def log_route_handler(request: Request) -> Response:
            """日志记录请求信息与处理耗时"""
            req_log_info = f"--> {request.method} {request.url.path} {request.client.host}:{request.client.port}"
            if request.query_params:
                req_log_info += f"\n--> Query Params: {request.query_params}"
            if "application/json" in request.headers.get("Content-Type", ""):
                try:
                    json_body = await request.json()
                    req_log_info += f"\n--> json_body: {json_body}"
                except Exception:
                    logger.exception("Failed to parse JSON body")
            logger.info(req_log_info)
            start_time = time.perf_counter()
            response: Response = await original_route_handler(request)
            process_time = time.perf_counter() - start_time
            response.headers["X-Response-Time"] = str(process_time)
            resp_log_info = f"<-- {response.status_code} {request.url.path} (took: {process_time:.5f}s)"
            logger.info(resp_log_info)  # 处理大量并发请求时,记录请求日志信息会影响服务性能,可以用nginx代替
            return response
        return log_route_handler

体验效果

APIRouter 需要指定下 route_class ,不然没有效果

改进代码,采用 BaseAPIRouter 继承 APIRouter 重写初始化方法,参数控制设置默认的 APIRoute,构建路由信息时改用 BaseAPIRouter 就不用为每一个 router 单独设置 APIRoute

class BaseAPIRouter(fastapi.APIRouter):
    def __init__(self, *args, api_log=settings.server_access_log, **kwargs):
        super().__init__(*args, **kwargs)
        if api_log:
            # 开启api请求日志信息
            self.route_class = LoggingAPIRoute

这里多了一个 api_log 参数来控制是否开启api的请求日志信息。

直接使用 BaseAPIRouter 管理路由即可为每个 Router 添加日志处理类

再次体验效果如下

大功告成。

注意:最好在开发调试时开启,生产时请求访问、响应日志的信息最好不要用业务服务器记录,在高并发的情况下这会影响服务器的性能,可以借助 Nginx 来处理 access_log 这样性能会比较好,业务服务器一般记录业务的日志。

Github 源代码

TaskFlow 项目集成 py-tools 来进行敏捷开发,快速搭建项目。

具体的封装设计代码请看:/HuiDBK/Task…