停止FastAPI中的属性钻取:使用请求级全局变量

在我的应用程序中,我曾经对自己想出的模式很满意。我知道它有一些缺陷,列如从FastAPI的控制器开始,通过多个层级进行属性钻取。我在路由层创建用户的上下文对象,同样也创建数据库会话等。我基本上是把这些内容一直往下传递,直到领域层的策略类,只是为了回答”我是否被允许做某事”这个问题。

所以我的代码看起来是这样的…

改善前:属性钻取噩梦

注意context是如何通过多个层传递的,只是为了在最终的策略检查中使用。这是典型的属性钻取 – 通过不需要这些属性的组件传递属性,只是为了让深层嵌套的组件能够访问它们。

# controller.py
@router.post("/projects")
async def create_project(
    data: ProjectRequest,
    db: Session = Depends(get_db),
    context: Context = Depends(get_context)
):
    return await project_service.create_project(data, context)

# services/project_service.py
async def create_project(data: ProjectRequest, context: Context):
    # 执行一些业务逻辑
    await can_create_project(context).unwrap()  # <-- 如果禁止则抛出异常
    return await project_repository.create(data, context)

async def can_create_project(context: Context) -> Result[bool, BaseError]:
    if not await project_policy.check_create_permission(context):
        return Result.err(
            error=BadRequestError(
                type=ErrorType.PROJECT_CREATION_FORBIDDEN,
                description='用户缺乏创建项目的权限',
            )
        )
    return Result.ok(True)

# repositories/project_repository.py
async def create(data: ProjectRequest, context: Context):
    # 仍在拖着context到处传递...
    project = Project(**data.dict())
    context.db.add(project)
    # 更多的钻取!
    await audit_log.log_creation(project, context)  
    await event_bus.publish(ProjectCreated(project, context))
    return project

# policies/project_policy.py
class ProjectPolicy:
    def check_create_permission(self, context: Context):
        # 终于!我们在这里实际使用了context
        if context.user.role == "admin":
            return True
        if context.user.organization.plan == "enterprise":
            return True
        return False

灵感来源

然后我偶然在YouTube上看到了DHH关于”Writing Software Well”的播放列表。他提到了全局变量,实际上代码来自Basecamp,是用Ruby编写的。他展示了Ruby中一个超级重大的Current概念。

声明一下:我不太清楚Ruby中的current具体是什么样子,也不知道它是如何工作的。但我尝试在我的应用程序中复制一样的功能,这样我就可以删除大量代码,基本上使测试变得更容易。

解决方案

关键在于,在请求的最开始,由于我们是在请求级别而不是整个运行时的全局级别思考这个概念,只在请求级别。由于单个请求可以基本上分配给单个用户。

所以想法是在请求的最开始创建Python中称为上下文变量的东西。

# context.py
from contextvars import ContextVar
from typing import Optional
from pydantic import BaseModel
from fastapi import Request

class Context(BaseModel):
    """
    我们的'当前'上下文 - 类似于Ruby的Current模式。
    包含已认证的用户和常用的引用。
    """
    issuer_id: str
    issuer_role: UserRole
    issuer_email: str
    issuer_status: UserStatus
    organization_id: Optional[str] = None
    organization_name: Optional[str] = None
    organization_member_role: Optional[OrganizationMemberRole] = None
    organization_member_status: Optional[OrganizationMemberStatus] = None
    feature_flags: dict[ConfigKey, FeatureFlagConfig] = {}
    config: UserConfig
    client_info: dict = {}

    def is_admin(self) -> bool:
        return self.issuer_role == UserRole.ADMIN

    def has_feature_flag(self, key: ConfigKey) -> bool:
        return self.config.check_ff(key)

    def verify_is_admin(self) -> None:
        if self.issuer_role != UserRole.ADMIN:
            raise ForbiddenError(
                type=ErrorType.UNAUTHORIZED,
                description='您无权访问此资源',
            )

    @classmethod
    def for_user(cls, user: UserSchema) -> Self:
        organization_id = user.organization.id if user.organization else None
        organization_member_role = user.organization.role if user.organization else None
        return cls(
            issuer_id=user.id,
            issuer_role=user.role,
            issuer_status=user.status,
            issuer_email=user.email,
            organization_id=organization_id,
            organization_name=user.organization.name if user.organization else None,
            organization_member_role=organization_member_role,
            config=UserConfig(),
        )

class _ContextAttributes:
    _context: ContextVar[Optional[Context]] = ContextVar('current_context', default=None)

    @property
    def context(self) -> Context:
        ctx = self._context.get()
        if ctx is None:
            raise RuntimeError('没有可用的上下文 - 不在请求范围内')
        return ctx

    @property
    def user_id(self) -> str:
        return self.context.issuer_id

    def set(self, context: Context):
        self._context.set(context)

    def clear(self):
        self._context.set(None)

# 全局Current实例
Current = _ContextAttributes()

这整个类的实例被设置为Python中的上下文变量。为什么使用上下文变量?如果我们使用异步FastAPI,上下文变量是线程安全的。所以如果同一个实例异步地接收另一个进程,另一个请求,我们不会覆盖这个全局变量。这就是缘由。

Python中的上下文变量专门为异步/并发代码设计。每个异步任务都获得自己的上下文副本,防止请求之间的竞争条件和数据泄漏。Current单例模式包装了ContextVar以提供清晰的接口。

目前让我们看看这如何简化我们的代码:

改善后:简洁明了

# middleware.py
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

class CurrentContextMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        user = await get_current_user_from_request(request)
        context = Context.for_user(user)
        context.set_client_info(request)

        Current.set(context)

        try:
            response = await call_next(request)
            return response
        finally:
            Current.clear()

# controller.py
@router.post("/projects")
async def create_project(data: ProjectRequest):
    return await project_service.create_project(data)

# services/project_service.py
async def create_project(data: ProjectRequest):
    await can_create_project().unwrap()  # 如果禁止仍会抛出异常
    return await project_repository.create(data)

async def can_create_project() -> Result[bool, BaseError]:
    if not await project_policy.check_create_permission():
        return Result.err(
            error=BadRequestError(
                type=ErrorType.PROJECT_CREATION_FORBIDDEN,
                description='用户缺乏创建项目的权限',
            )
        )
    return Result.ok(True)

# repositories/project_repository.py
async def create(data: ProjectRequest):
    context = Current.context

    project = Project(**data.dict())
    context.db_session.add(project)

    await audit_log.log_creation(project)  
    await event_bus.publish(ProjectCreated(project))
    return project

# policies/project_policy.py
class ProjectPolicy:
    def check_create_permission(self):
        # 需要时直接访问当前上下文!
        context = Current.context

        if context.is_admin():
            return True
        if context.has_feature_flag(ConfigKey.ENTERPRISE_PROJECTS):
            return True
        if context.organization_member_role == OrganizationMemberRole.OWNER:
            return True
        return False

目前要做的简单事情就是停止传递这个上下文,由于我们已经设置好了。只需要在需要的时候访问它。当它在策略中的最后需要时。当只是检查谁是在请求的人时。这只是代码的10%。所以我们不需要通过所有层把它钻到最底层。

结论

实施这种模式后:

• 服务和仓储层的代码减少40%

测试设置减少一半 – 不再需要模拟链

就是这样。一个简单的技巧基本上删除了大量代码,它是可维护的,让一切变得更容易,在一些上下文魔法背后隐藏了一些复杂性。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
胜芳缘的头像 - 鹿快
评论 抢沙发

请登录后发表评论

    暂无评论内容