V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
tangkikodo
V2EX  ›  Python

如果你爱用 FastAPI, 那么这个轮子可能有用处。

  •  
  •   tangkikodo · 2023-04-01 15:14:26 +08:00 · 2928 次点击
    这是一个创建于 631 天前的主题,其中的信息可能已经有所发展或是发生改变。

    防止走失先贴链接。pydantic-resolve

    这样一个场景, 前提是 RESTful

    以论坛为例,有个接口返回帖子(posts)信息,然后呢,来了新需求,说需要显示帖子的 author 信息。

    这时候会有两种做法:

    1. 在 posts 的 query 中 join 查询 author 信息,在返回 post 中添加诸如 author_id, author_name 之类的字段。 {'post': 'v2ex', 'author_name': 'tangkikodo'}
    2. 根据 posts 的 ids , 单独查询 author 列表,然后把 author 对象循环添加到 post 对象中。 {'post':'v2ex', 'author': {'name': 'tangkikod'}}

    在方法 1 中,需要修改 query , 还需要修改 post 的 schema. 如果未来要加新的,例如用户头像的话,需要修改两处。

    方法 2 需要手动做一次拼接。而之后增减字段都是在 author 自己的部分修改。

    所以相对来说方法 2 在未来的可维护性会比较好。用嵌套对象的方式可以更好的扩展和维护。

    然而需求总是会变化,突然来了一个新的且奇怪的需求,要在 author 信息中添加数据,显示他最近浏览过的帖子。

    [
      {
        "id": 1,
        "post": "v2ex",
        "author": {
          "name": "tangkikodo",
          "recent_views": [
            {
              "id": 2,
              "post": "v3ex"
            },
            {
              "id": 3,
              "post": "v4ex"
            }
          ]
        }
      }
    ]
    
    

    那这个时候该怎么弄呢?血压是不是有点上来了。

    根据之前的方法 2 , 通常的想法是在获取到 authors 信息后, 再关联查找 author 的 recent_posts, 拼接回 authors, 再将 authors 拼接回 posts 。

    反正想想就挺麻烦的对吧。如果你此时血压有点高,那请继续往下看。

    那,有别的办法么? 这里有个小轮子也许能帮忙。

    https://github.com/allmonday/pydantic-resolve

    以刚才的例子,要做的事情分两步:

    1 , 定义 dataloader ,前半部分是从数据库查询,后半部分是将数据转成 pydantic 对象后返回。 伪代码,看个大概意思就好。

    
    class AuthorLoader(DataLoader):
        async def batch_load_fn(self, author_ids):
            async with async_session() as session:
                res = await session.execute(select(Author).where(Author.id.in_(author_ids)))
                rows = res.scalars().all()
    
                dct = defaultdict(list)
                for row in rows:
                    dct[row.author_id] = AuthorSchema.from_orm(row)
                return [dct.get(k, None) for k in author_ids]
    
    class RecentViewPostLoader(DataLoader):
        async def batch_load_fn(self, view_ids):
            async with async_session() as session:
                res = await session.execute(select(Post)  # join 浏览中间表
                    .join(PostVist, PostVisit.post_id == Post.id)
                    .where(PostVisit.user_id.in_(view_ids)
                    .where(PostVisit.created_at < some_timestamp)))
                rows = res.scalars().all()
    
                dct = defaultdict(list)
                for row in rows:
                    dct[row.view_id].append(PostSchema.from_orm(row))
                return [dct.get(k, []) for k in view_ids]
    
    1. 定义 schema
    class RecentPostSchema(BaseModel):
        id: int
        name: str
    
        class Config:
            orm_mode = True
    
    class AuthorSchema(BaseModel):
        id: int
        name: str
        img_url: str
    
        recent_views: Tuple[RecentPostSchema, ...] = tuple()
        def resolve_recent_views(self, loader=LoaderDepend(RecentViewPostLoader)):  # <=== 核心操作
            return loader.load(self.id)
        
        class Config:
            orm_mode = True
    
    class PostSchema(BaseModel):
        id: int
        author_id: int
        name: str
    
        author: Optional[AuthorSchema] = None
        def resolve_author(self, loader=LoaderDepend(AuthorLoader)):   # <=== 核心操作
             return loader.load(self.author_id)
    
        class Config:
            orm_mode = True
    
    

    然后呢?

    然后就没有了,接下来只要做个 post 的查询, 再简单地...resolve 一下,任务就做好了。

    
    posts = (await session.execute(select(Post))).scalars().all()
    posts = [PostSchema.from_orm(p) for p in tasks]
    results = await Resolver().resolve(posts)
    

    在拆分了 loader 和 schema 之后,对数据地任意操作都很简单,添加新字段只要三步:

    1. 新建 schema,
    2. 新建 loader (如果需要的话)
    3. 新建 resolver 方法。

    就完事了。如果说这方法有啥缺点的话。。必须用 async await 可能算一个。。

    真实可测的例子可以看这个 demo: link

    谢谢。

    这个工具受到了 graphql 很大的启发,如果这个小轮子可以帮到忙的话,我会感到很开心。 :)

    第 1 条附言  ·  2023-04-07 23:19:48 +08:00

    添加了一些新功能:

    1. 更好的DataLoader支持,不用手动去初始化DataLoader, 通过简单的LoaderDepend 注入就能轻松使用loader
    2. 提供了loader_filters 选项,可以让Dataloader 接收到默认参数之外的查询条件。

    综合以上两个功能之后,1.0相关的功能就基本齐全了。

    完整样例 6_sqlalchemy_loaderdepend_global_filter.py

    3 条回复    2023-04-07 23:27:12 +08:00
    tangkikodo
        1
    tangkikodo  
    OP
       2023-04-01 15:31:08 +08:00
    其实不用 FastAPI 这个库也能解决不少问题。
    只是如果搭配 FastAPI, response_model 再外加 基于 openapi.json 的 client 生成。 前后端开发体验直接飞升~

    ref:
    https://fastapi.tiangolo.com/advanced/generate-clients/
    ohayoo
        2
    ohayoo  
       2023-04-01 22:36:21 +08:00 via Android
    好,找时间试试
    tangkikodo
        3
    tangkikodo  
    OP
       2023-04-07 23:27:12 +08:00
    罗列了一些和其他方案相比优缺点的比较。

    https://github.com/allmonday/pydantic-resolve/blob/master/doc/compare-cn.md
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2743 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 42ms · UTC 08:08 · PVG 16:08 · LAX 00:08 · JFK 03:08
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.