ERD 驱动开发
ERD - Entity Relationship Diagram 实体关系图
对后端程序员来说这是一个很熟悉的概念, 很多数据库工具都有 ERD 可视化的功能。
ERD 本身可以是一个更加抽象的概念, 可以脱离具体数据库实现。它描述了实体和实体之间的关系, 所以许多产品经理也会用 ERD 来描述产品的核心数据关系。
因此对 ERD 是贯穿产品设计和产品实现的一个重要工具, 如果 ERD 的结构可以在所有环节中维持清晰, 那就能让产品整体更加可维护和可扩展。
当 ERD 和 pydantic-resolve 结合在一起, 就能实现 3 - 5 倍开发效率的提升, 以及 50% 代码量的减少。
我们先从已有的一些开发手段讲起, 说说他们的能力和局限。
---
title: User and Post
---
erDiagram
User ||--o{ Post : "has many"
从 SQL,ORM,到 GraphQL
SQL
关系型数据库可以存储有相关性的数据, 但是通过 SQL 获取对象和关联对象并不方便。
使用 SQL 做 join 查询的时候, 如果关联的是一对多的表,那么就会引起笛卡尔积数量增加的情况。
简单的查询无法生成嵌套的关联数据 ( 虽然有拼装 json 的奇技淫巧, 但那个的可维护性比较糟糕 ), 或者通过应用层做父节点去重。
所以 SQL 的结果是一张二维表, 关联的数据只能转换成聚合计算的结果来展示。
select user.name, count(*) as post_count from user join post on user.id = post.user_id groupby user.id
ORM
如果需要获取关联信息的话, 就会使用到 ORM, 在 ORM 中定义 relationship 之后, 就能获取到关联的对象。
以常用的 sqlalchemy 为例。
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String)
# 定义 relationship
posts = relationship("Post", back_populates="user")
class Post(Base):
__tablename__ = 'posts'
id = Column(Integer, primary_key=True)
title = Column(String)
user_id = Column(Integer, ForeignKey('users.id'))
# 定义 relationship
user = relationship("User", back_populates="posts")
rows = session.query(User).options(joinedload(User.posts)).all()
对于关联数据的获取, 可以通过多种 lazy 选项来调整, select 在对 User 循环时会引起 N+1 查询, joined, subquery 则会提前将数据查询加载好。 这些选项的调整是需要开发去关注来避免性能问题的。
但同时 ORM 也伴随了一些局限, 如果有些数据不是在数据库中,比如需要从某个第三方 API, 或者本地文件中获取,就没法享受到自动关联的便利了。
GraphQL
GraphQL 的出现提供了一种 “新的” 思路, 它的 schema 就是一种容易贴近 ERD 描述的方式。
此外它抽象了 DataLoader 的概念, 使用 async def batch_loadn_fn(keys)
的通用格式来定义输入参数和返回数据, 用户可以自己决定实现方式。
以数据库为例, 可以使用 where ..in ..
来批量查找。
然后将获取的数据在代码中 groupby post.user_id
的逻辑做聚合。
如果是第三方 API 的话, 只要简单发起一次异步调用。
async def batch_load_fn(user_ids):
posts = await get_posts_by_user_ids(user_ids)
return build_list(posts, user_ids, lambda x: x.user_id)
GraphQL 的这个机制实现了这样一种和具体实现无关的通用接口, 这也为内部优化预留了充足的空间。
但 DataLoader 的威力在 GraphQL 体系下是被限制了的。
最常见的场景是 DataLoader 默认只能通过 keys, 也就是单一的外键来关联数据, 如果要对资源做额外过滤, 会很难做。
用查询来举例, (1, 2, 3, 4)
是传入的 keys, where
条件则没有合适的手段来设置。
从设置参数的角度来看, keys 是一个个 User 对象通过 loader.load(key)
提供的, 而 where
条件是则是直接针对 loader 做的配置。
GraphQL 本身没有方便的手段来提供通用的 where
参数, 这点比较遗憾。
GraphQL 对数据的描述已经非常贴近 ERD 了,可惜这个架构比较庞大, 存在太多框架的约束, 提供灵活的查询接口也是一把双刃剑。
我们希望找到一个更加简洁的方式。
使用 Pydantic 来定义 ERD
pydantic 就是一个优秀的候选人, 我们可以用它来定义 Entity 和 Relationship。
class User(BaseModel):
id: int
name: str
class Post(BaseModel):
id: int
user_id: int
title: str
class PostLoader(DataLoader):
async def batch_load_fn(self, user_ids):
posts = await get_posts_by_user_ids(user_ids)
return build_list(posts, user_ids, lambda x: x.user_id)
使用 pydantic 来定义 User, Post 的结构, 非常简洁清晰。 可以作为脱离持久层的抽象表达。
而 User 和 Post 的关联由 DataLoader 来定义。 具体的实现交由 get_post_by_user_ids
来负责实现。
比如一个 session.query(UserModel).all()
的查询, 或者 aiohttp
的远程请求。
Usr 和 Post 的关系并未限定只能有一种 DataLoader 来描述,实际上可以定义多种 DataLoader 根据实际场景来选用。
---
title:
---
erDiagram
User ||..o{ Post : "PostLoader"
User ||..o{ Post : "AnotherLoader"
使用虚线来表示他们之间 “可以” 发生的关联。
建立关联
现在我们假设有个业务需求, 要给 User 和 Post 建立关联。
可以通过继承 User 来复用 User 字段, 通过定义 posts
来联系数据。
---
title: UserWithPostsForSpecificBusiness
---
erDiagram
User ||--o{ Post : "PostLoader"
class UserWithPostsForSpecificBusiness(User):
posts: List[Post] = []
def resolve_posts(self, loader=LoaderDepend(PostLoader)):
return loader.load(self.id)
这样 UserWithPostsForSpecificBusiness
就是面向特定业务需求所建立的 User 和 Post 关联固定组合体。
顺带一提, 就如 "数据加载器" 中所提到的, DataLoader 是允许提供额外参数的。
可维护代码的诀窍: 使业务 ERD 和代码中的结构定义维持一致
现在我们得到了一个业务需求 ERD 结构高度一致的代码, 并且这段代码是专供的。
也即是说, ERD 定义了一系列 Entity 和所有可能的 Relationship, 而关系的真正建立是取决于实际业务需求的。
两个结构完全一样的 class, 可以拥有不相同的名字, 代表服务于不同的需求。
class UserWithPostsForSpecificBusinessA(User):
posts: List[Post] = []
def resolve_posts(self, loader=LoaderDepend(PostLoader)):
return loader.load(self.id)
class UserWithPostsForSpecificBusinessB(User):
posts: List[Post] = []
def resolve_posts(self, loader=LoaderDepend(PostLoader)):
return loader.load(self.id)
假设 UserWithPostsForSpecificBusinessA
的需求发生了变更, 需要只加载每个 user 最近的 3 条 posts
那只需要创建好新的 DataLoader 然后替换进去即可。( UserWithPostsForSpecificBusinessB 则完全不受影响 )
class UserWithPostsForSpecificBusinessA(User):
posts: List[Post] = []
def resolve_posts(self, loader=LoaderDepend(LatestThreePostLoader)):
return loader.load(self.id)
最终, 我们实现了目标, 让代码侧的结构与产品设计侧的 ERD 结构保持高度的一致, 这使得后续的变更和调整变得更容易。
更多案例
---
title: Business A ERD
---
erDiagram
User ||--o{ Post : "PostLoader"
Post ||--o{ Comment : "CommentLoader"
Post ||--o{ Like : "LikeLoader"
class BizAPost(Post):
comments: List[Comment] = []
def resolve_comments(self, loader=LoaderDepend(CommentLoader)):
return loader.load(self.id)
likes: List[Like] = []
def resolve_likes(self, loader=LoaderDepend(LikeLoader)):
return loader.load(self.id)
class BizAUser(User):
posts: List[BizAPost] = []
def resolve_posts(self, loader=LoaderDepend(PostLoader)):
return loader.load(self.id)