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

在我的应用程序中,我曾经对自己想出的模式很满意。我知道它有一些缺陷,比如从FastAPI的控制器开始,通过多个层级进行属性钻取。我在路由层创建用户的上下文对象,同样也创建数据库会话等。我基本上是把这些内容一直往下传递,直到领域层的策略类,只是为了回答"我是否被允许做某事"这个问题。
所以我的代码看起来是这样的...
改进前:属性钻取噩梦 🙄
💡
注意context是如何通过多个层传递的,只是为了在最终的策略检查中使用。这是典型的属性钻取 - 通过不需要这些属性的组件传递属性,只是为了让深层嵌套的组件能够访问它们。
  1. # controller.py
  2. @router.post("/projects")
  3. async def create_project(
  4. data: ProjectRequest,
  5. db: Session = Depends(get_db),
  6. context: Context = Depends(get_context)
  7. ):
  8. return await project_service.create_project(data, context)

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

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

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

  32. # policies/project_policy.py
  33. class ProjectPolicy:
  34. def check_create_permission(self, context: Context):
  35. # 终于!我们在这里实际使用了context
  36. if context.user.role == "admin":
  37. return True
  38. if context.user.organization.plan == "enterprise":
  39. return True
  40. return False
python
灵感来源
然后我偶然在YouTube上看到了DHH关于"Writing Software Well"的播放列表。他提到了全局变量,实际上代码来自Basecamp,是用Ruby编写的。他展示了Ruby中一个非常重要的Current概念。
声明一下:我不太清楚Ruby中的current具体是什么样子,也不知道它是如何工作的。但我试图在我的应用程序中复制相同的功能,这样我就可以删除大量代码,基本上使测试变得更容易。
解决方案
关键在于,在请求的最开始,因为我们是在请求级别而不是整个运行时的全局级别考虑这个概念,只在请求级别。因为单个请求可以基本上分配给单个用户。
所以想法是在请求的最开始创建Python中称为上下文变量的东西。
  1. # context.py
  2. from contextvars import ContextVar
  3. from typing import Optional
  4. from pydantic import BaseModel
  5. from fastapi import Request

  6. class Context(BaseModel):
  7. """
  8. 我们的'当前'上下文 - 类似于Ruby的Current模式。
  9. 包含已认证的用户和常用的引用。
  10. """
  11. issuer_id: str
  12. issuer_role: UserRole
  13. issuer_email: str
  14. issuer_status: UserStatus
  15. organization_id: Optional[str] = None
  16. organization_name: Optional[str] = None
  17. organization_member_role: Optional[OrganizationMemberRole] = None
  18. organization_member_status: Optional[OrganizationMemberStatus] = None
  19. feature_flags: dict[ConfigKey, FeatureFlagConfig] = {}
  20. config: UserConfig
  21. client_info: dict = {}

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

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

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

  32. @classmethod
  33. def for_user(cls, user: UserSchema) -> Self:
  34. organization_id = user.organization.id if user.organization else None
  35. organization_member_role = user.organization.role if user.organization else None
  36. return cls(
  37. issuer_id=user.id,
  38. issuer_role=user.role,
  39. issuer_status=user.status,
  40. issuer_email=user.email,
  41. organization_id=organization_id,
  42. organization_name=user.organization.name if user.organization else None,
  43. organization_member_role=organization_member_role,
  44. config=UserConfig(),
  45. )

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

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

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

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

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

  61. # 全局Current实例
  62. Current = _ContextAttributes()
python
这整个类的实例被设置为Python中的上下文变量。为什么使用上下文变量?如果我们使用异步FastAPI,上下文变量是线程安全的。所以如果同一个实例异步地接收另一个进程,另一个请求,我们不会覆盖这个全局变量。这就是原因。
Python中的上下文变量专门为异步/并发代码设计。每个异步任务都获得自己的上下文副本,防止请求之间的竞争条件和数据泄漏。Current单例模式包装了ContextVar以提供清晰的接口。
现在让我们看看这如何简化我们的代码:
改进后:简洁明了 🎉
  1. # middleware.py
  2. from fastapi import Request
  3. from starlette.middleware.base import BaseHTTPMiddleware

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

  9. Current.set(context)

  10. try:
  11. response = await call_next(request)
  12. return response
  13. finally:
  14. Current.clear()

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

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

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

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

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

  37. await audit_log.log_creation(project)
  38. await event_bus.publish(ProjectCreated(project))
  39. return project

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

  45. if context.is_admin():
  46. return True
  47. if context.has_feature_flag(ConfigKey.ENTERPRISE_PROJECTS):
  48. return True
  49. if context.organization_member_role == OrganizationMemberRole.OWNER:
  50. return True
  51. return False
python
现在要做的简单事情就是停止传递这个上下文,因为我们已经设置好了。只需要在需要的时候访问它。当它在策略中的最后需要时。当只是检查谁是在请求的人时。这只是代码的10%。所以我们不需要通过所有层把它钻到最底层。
结论
实施这种模式后:
服务和仓储层的代码减少40%
测试设置减少一半 - 不再需要模拟链
就是这样。一个简单的技巧基本上删除了大量代码,它是可维护的,让一切变得更容易,在一些上下文魔法背后隐藏了一些复杂性。
感谢DHH的这个播放列表。我认为整个系列都值得一看。