{"id":21459959,"url":"https://github.com/allmonday/composition-oriented-development-pattern","last_synced_at":"2025-07-15T02:31:57.469Z","repository":{"id":211357572,"uuid":"728730289","full_name":"allmonday/composition-oriented-development-pattern","owner":"allmonday","description":"Make API Development Fun Again!  MADFA!","archived":false,"fork":false,"pushed_at":"2024-01-28T14:13:09.000Z","size":493,"stargazers_count":13,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2024-01-28T15:31:37.955Z","etag":null,"topics":["bff-api","composable","pydantic-resolve"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/allmonday.png","metadata":{"files":{"readme":"readme-cn.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2023-12-07T15:11:17.000Z","updated_at":"2024-01-28T15:31:40.579Z","dependencies_parsed_at":"2023-12-08T02:26:21.583Z","dependency_job_id":"19348546-e522-41de-96e3-2e7dd70fb48a","html_url":"https://github.com/allmonday/composition-oriented-development-pattern","commit_stats":null,"previous_names":["allmonday/composable-development-pattern","allmonday/composition-oriented-development-pattern"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/allmonday/composition-oriented-development-pattern","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/allmonday%2Fcomposition-oriented-development-pattern","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/allmonday%2Fcomposition-oriented-development-pattern/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/allmonday%2Fcomposition-oriented-development-pattern/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/allmonday%2Fcomposition-oriented-development-pattern/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/allmonday","download_url":"https://codeload.github.com/allmonday/composition-oriented-development-pattern/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/allmonday%2Fcomposition-oriented-development-pattern/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":265390900,"owners_count":23757605,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bff-api","composable","pydantic-resolve"],"created_at":"2024-11-23T06:37:21.118Z","updated_at":"2025-07-15T02:31:56.438Z","avatar_url":"https://github.com/allmonday.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 面向组合的 API 开发模式\n\n构建面向视图的数据时, 不可避免会出现数据拼接的需求.\n\n```json\n[\n  {\n    \"id\": 1,\n    \"team\": \"a\",\n    \"members\": [\n      {\n        \"id\": 1,\n        \"name\": \"kikodo\",\n        \"task\": [\n          {\n            \"id\": 1,\n            \"name\": \"complete tutorial\"\n          }\n        ]\n      }\n    ],\n  }\n]\n```\n\n根据视图数据的复杂程度, 拼接的难度会有很大的差异.\n\n在处理手段上来讲, 通常的做法是手动循环拼接. \n\n```python\n# 伪代码\ntask_map = group_by_member_id(tasks)\nmember_map = group_by_team_id(members)\n\nfor m in members:\n  m.tasks = task_map[m.id]\nfor t in teams:\n  t.members = member_map[t.id]\n...\n```\n\n\u003e 或者在一些特定场景下借助 ORM 来处理relationship 关系的查询.\n\n过程式的数据处理对调整和阅读都不友好, 循环和拼接容易产生不通用和不易维护的代码. 添加和修改字段也很麻烦.\n\n另外这种处理放在不同的分层, 影响也不同. 比如常用的 controller - service - model 三层:\n\n- 如果由 model 来处理, 那么 model 层需要做修改, controller 和 service 都要跟着调整\n- 如果由 service 处理, 那么 service 的查询就会充满视图细节, 不容易复用, service 就变得僵化了\n- 如果由 controller 处理, 影响面较小, 但这种命令式的拼接本身不直观, 不好维护\n- 如果让 client 处理, 那会是个灾难.\n\n\u003e 面向组合模式将会在 controller 层解决这个拼接问题.\n\nGraphQL 带来的通过声明描述数据结构是一个好的方向, Graph Query Language, 作为一种声明式的查询语言, 服务于一系列关系型资源的获取和组合.  其查询语句能展示期望的数据结构. \n\n```gql\n{\n  project(name: \"GraphQL\") {\n    tagline\n  }\n}\n```\n\n背后的Query定义也是用申明的方式来描述(可能用来查询的)数据结构.\n\n```js\n// schema first\nvar schema = buildSchema(`\n  type Query {\n    hello: String\n  }\n`)\n\nvar rootValue = {\n  hello: () =\u003e {\n    return \"Hello world!\"\n  },\n}\n```\n\n借用 code first 的 graphene 的会更加形象一些.\n\n```python\n# code first\nfrom graphene import ObjectType, String, Schema\n\nclass Query(ObjectType):\n    hello = String(first_name=String(default_value=\"stranger\"))\n\n    def resolve_hello(root, info, first_name):\n        return f'Hello {first_name}!'\n```\n\n一段复杂的GraphQL query 查询结果和一段复杂的 ORM 查询是类似的. 只是GraphQL 借助dataloader更擅长关联数据. 可以更加直观地构造多层的视图数据. (代价是额外的查询开销)\n\nGraphQL 虽好, 但完整引入 GraphQL 对架构的影响不小, schema 定义之类的都要跟随调整. 而且其自身也存在一系列的问题, 例如:\n- 无法描述尺寸不确定的递归结构\n- key 不确定的 Dict 结构\n- Query 比较复杂的话, 性能问题不容易优化.\n- 数据的后处理不方便\n- 要使用它定义的一套类型\n\n\n\u003e 个人认为, 从定位上来说, GraphQL 用来给项目的前端提供数据其实是一种错误的用法, 它的定位和 SQL 是类似的, 为服务端获取数据提供便利. 这样就可以不用考虑权限, 限速等接口层问题. \n\u003e\n\u003e 现在很多前端直接使用 GraphQL 来组合查询, 从职位划分来说等于插手了一部分后端的工作. 把GraphQL 放在 client 和 server 之间并不是一个理想的定位. 就像把 SQL 查询暴露给 client 一样. (数据的处理分散在多个环节不利于项目维护.)\n\n\n总体来说, GraphQL 在提供视图数据方面, 有查询灵活度高的优点, 但存在获取的数据后期调整比较麻烦, 以及架构侵入较大等缺点. 比如 GraphQL 获取到多层数据后要做层级聚合统计, 就需要重新遍历一遍树状数据来处理. 框架本身没有设计合适的下层数据处理完之后触发回调的钩子. (这恰恰是对视图调整很有用的)\n\n在这里我们暂停总结一下, 看看从数据获取到生成视图需要哪几个步骤.\n\n1. 查询: 来源可能是多组数据, 也可能已经是嵌套数据\n2. 数据拼接: (optinal), 根据 1 来决定是否需要 (GraphQL可以很大程度跳过这一步.)\n3. 业务转换: 对查到的数据做调整, 比如计算数组长度, 额外过滤, 业务转换等等, 构造出前端直接可以使用的数据. (GraphQL不擅长这块)\n\n思考后会发现, 相较 GraphQL 灵活的查询, 在处理视图数据的时候, GraphQL 最大的优势恰恰是他申明式的数据描述方式.\n\n以 graphene-python 为例, Query可以灵活选择所需的字段\n\n```python\nfrom graphene import ObjectType, String, Schema\n\nclass Query(ObjectType):\n    hello = String(first_name=String(default_value=\"stranger\"))\n    goodbye = String()\n\n    def resolve_hello(root, info, first_name):\n        return f'Hello {first_name}!'\n\n    def resolve_goodbye(root, info):\n        return 'See ya!'\nschema = Schema(query=Query)\n\nquery_with_argument = '{ hello(firstName: \"GraphQL\") }'\nresult = schema.execute(query_with_argument)\nprint(result.data['hello'])\n```\n\n那如果我省去查询, 而是直接将 Query 变成一个固定的业务的视图数据描述?\n\n借助 pydantic 强大的类型转换和检查的功能, 我们可以这样来实现申明式的数据结构描述.\n\n```python\nfrom pydantic import BaseModel\nfrom pydantic_resolve import Resolver\n\nclass HelloGoodByeView(BaseModel):\n  hello: str = ''\n  def resolve_hello(self, context):\n    return f\"Hello {context['first_name']}\"\n\n  goodbye: str = ''\n  def resolve_goodbye(self):\n    return 'See ya'\n\n  def post_goodbye(self):\n    return 'See ya soon'  # will alter the self.goodbye.\n\nasync def main():\n  hgv = HelloGoodByeView()\n  return await Resolver(context={'first_name': 'tangkikodo'}).resolve(hgv)\n```\n\n这样, 我们就获得了一个专用的视图数据描述, 并且获得了期望的数据.\n\n**把大而全的单一查询入口, 替换成了一个个小巧灵活的定制化 schema 描述.**\n\n结合那三个步骤, `pydantic-resolve` 可以做到:\n\n1. 查询. 可以层层查询 (dataloader), 也可以一次性从树状数据加载 (GraphQL or ORM 查询结果).\n2. ~~数据拼接~~ \n3. 业务转换: 利用post_method, expose, exclude 等方法, 可以在每一层灵活处理数据\n\n## 什么是面向组合的模式?\n\n面向组合的开发模式就是在**声明式描述期望视图结构**的基础上, 逐步扩展出来的一套面向视图数据查询的开发模式. \n\n它在架构上的优势是, 通过申明式的 schema 定义, 让数据拼接过程变得更加直接和容易调整. \n\n它让 service 层的核心数据查询保持简洁, 使用独立的数据loader来组合数据, 避免了拼接逻辑侵入到 service 或者 controller 的情况.\n\n它的核心概念是: \n\n1. 定义好视图结构schema(从根数据的结构向下扩展)\n2. 获取`根数据`(树干), 转换成schema\n3. 让`Resolver`遍历解析出来所有的数据 (树枝,树叶).\n\nresolve过程包含了 forward fetch, backward change 和 exclude fields 三个环节.\n\n![](./static/concept.png)\n\n\n\u003e 根数据本身当然也可以是嵌套的结构\n\n罗列一下, 这套开发模式有以下这些优点:\n\n- 查询\n  - 用申明式的方式描述数据和查询, 直观且容易修改\n  - 简化`根数据`的查询, 避免复杂sql 语句对可读性的影响.\n  - 可以读取全局参数, 可以跨层级向下传递数据\n  - 任意层级, 任意类型.\n  - 架构简单, 各个 service 仅需提供通用的 loader, 用于数据拼装\n- 调整 \n  - 每一层都有后处理数据的能力\n  - 可以挑选字段, 可以隐藏字段\n  - 直接满足前端所需的复杂结构 (而且比client端调整更方便)\n- 性能\n  - 避免 N+1 查询相关的性能问题\n  - 对优化友好, 重构的依赖阻碍小.\n- 其他\n  - 借助OpenAPI, 前端对后端操作简化为sdk 方法调用.\n  - 借助typescript 让前后端调整变得易如反掌\n\n\u003e 可以很容易联想到, 我们获得了单个 API 提供一个page 所需数据的能力, 这会让前后端接口关系变得更简单.\n\u003e \n\u003e 就像前端通过 GraphQL 实现的那样, 而且更简单, 数据直接可用, 不用写额外查询, 直接一个简单请求就行.\n\n下图简单的展示了组合模式的关系, 分为 service 和 router(controller) 两个部分.\n\n- service 负责一个个具体业务对象 `schema`, 对外提供业务`query` 以及通用的数据 `loader`. \n- router 负责声明面向组合的视图 `schema`\n\n![](./static/explain2.png)\n\n本 repo 会通过一系列的例子, 结合 `pydantic2-resolve` 和一些约定, 来介绍这么一套面向组合的 API 开发模式.\n\n- https://github.com/allmonday/pydantic2-resolve\n\n\n\n## 示例：搭建 Mini JIRA API\n\n```mermaid\n---\ntitle: Mini JIRA\n---\n\nerDiagram\n    Team ||--o{ Sprint : one_to_many\n    Team ||--o{ User : one_to_many\n    Sprint ||--o{ Story : one_to_many\n    Story ||--o{ Task : one_to_many\n    Story ||--|| User : one_to_one\n    Task ||--|| User : one_to_one\n\n    Team {\n      int id\n      string name\n    }\n    Sprint {\n      int id\n      string name\n    }\n    Story {\n      int id\n      int sprint_id\n      string name\n      int owner_id\n    }\n    Task {\n      int id\n      int story_id\n      string name\n      int owner_id\n    }\n    User {\n      int id\n      string name\n    }\n```\n\nMini jira 包含了常见的敏捷开发中的各种概念和其之间的关系.\n\n我们将通过各种 `router/schema` 来描述并获得我们期望的数据结构, 这个过程将非常简洁.\n\n比如下例中, 通过定义 Sample1StoryDetail 来生成 story -\u003e task -\u003e user 这样的多层数据.\n\n只需要描述好 Task 要扩展的字段, Story 要扩展的字段, 然后 Resolver 就会帮你处理完后续的所有事情.\n\n```python\nfrom typing import Optional\nfrom pydantic_resolve import LoaderDepend as LD\n\n# loaders\nimport src.services.task.loader as tl\nimport src.services.user.loader as ul\n\n# schemas\nimport src.services.story.schema as ss\nimport src.services.task.schema as ts\n\n# compose together\nclass Sample1TaskDetail(ts.Task):\n    user: Optional[us.User] = None\n    def resolve_user(self, loader=LD(ul.user_batch_loader)):\n        return loader.load(self.owner_id)\n\nclass Sample1StoryDetail(ss.Story):\n    tasks: list[Sample1TaskDetail] = []\n    def resolve_tasks(self, loader=LD(tl.story_to_task_loader)):\n        return loader.load(self.id)\n\n    owner: Optional[us.User] = None\n    def resolve_owner(self, loader=LD(ul.user_batch_loader)):\n        return loader.load(self.owner_id)\n\n# query\n@route.get('/stories-with-detail', response_model=List[Sample1StoryDetail])\nasync def get_stories_with_detail(session: AsyncSession = Depends(db.get_session)):\n    stories = await sq.get_stories(session)\n    stories = [Sample1StoryDetail.model_validate(t) for t in stories]\n    stories = await Resolver().resolve(stories)\n    return stories\n```\n\noutput:\n\n```json\n[\n  {\n    \"id\": 1,\n    \"name\": \"deliver a MVP\",\n    \"owner_id\": 1,\n    \"sprint_id\": 1,\n    \"tasks\": [\n      {\n        \"id\": 1,\n        \"name\": \"mvp tech design\",\n        \"owner_id\": 2,\n        \"story_id\": 1,\n        \"user\": {\n          \"id\": 2,\n          \"name\": \"Eric\",\n          \"level\": \"junior\"\n        }\n      },\n      {\n        \"id\": 2,\n        \"name\": \"implementation\",\n        \"owner_id\": 2,\n        \"story_id\": 1,\n        \"user\": {\n          \"id\": 2,\n          \"name\": \"Eric\",\n          \"level\": \"junior\"\n        }\n      },\n      {\n        \"id\": 3,\n        \"name\": \"tests\",\n        \"owner_id\": 2,\n        \"story_id\": 1,\n        \"user\": {\n          \"id\": 2,\n          \"name\": \"Eric\",\n          \"level\": \"junior\"\n        }\n      },\n      {\n        \"id\": 4,\n        \"name\": \"code review\",\n        \"owner_id\": 2,\n        \"story_id\": 1,\n        \"user\": {\n          \"id\": 2,\n          \"name\": \"Eric\",\n          \"level\": \"junior\"\n        }\n      }\n    ],\n    \"owner\": {\n      \"id\": 1,\n      \"name\": \"John\",\n      \"level\": \"senior\"\n    }\n  }\n]\n```\n\n具体请参看 router 下的一系列 sample_x 或者滚动到底部阅读文档.\n\n\n\n## 执行代码\n\n```shell\npython -m venv venv\nsource venv/bin/activate\npip install -r requirement.txt\nuvicorn src.main:app --port=8000 --reload\n# http://localhost:8000/docs\n```\n\n可以在 swagger 中执行查看每个 API 的返回值\n\n## 功能介绍\n\n- [Example 1: 多层嵌套结构的构建](./src/router/sample_1/readme-cn.md)\n- [Example 2: Loader 的进阶用法](./src/router/sample_2/readme-cn.md)\n- [Example 3: 跨层级数据获取](./src/router/sample_3/readme-cn.md)\n- [Example 4: 每层数据的后处理](./src/router/sample_4/readme-cn.md)\n- [Example 5: 利用 Context 和 Schema 实现复用](./src/router/sample_5/readme-cn.md)\n- [Example 6: 挑选字段](./src/router/sample_6/readme-cn.md)\n- [Example 7: 直接操作 Loader 实例](./src/router/sample_7/readme-cn.md)\n- [更灵活的测试: 用service测试代替 API 测试](./src/services/sprint/readme-cn.md)\n- [其他: 和 GraphQL 比较](./resolve-vs-graphql-cn.md)\n- [使用openapi codegen和前端集成](./fe-demo/readme-cn.md)\n\n\n## 总结\n\n走完了所有的Example 之后, 你也许会发现, 尽管我们在 router 的schema 中组合出来了各种结构的数据, services 目录下的文件始终保持了相当程度的简洁和稳定, 这是组合模式最大的优点, 分离了业务中的稳定和不稳定的部分.\n\n每个 service 模块通过暴露自己的:\n- query, 业务查询\n- mutation, 业务操作\n- schema, 拼装类型\n- loader, 关联数据\n\n就能满足 router 层视图 schema 的所有需要.\n\n这感觉就像, 盒子中的积木虽然有无数种组合可能, 我们只需要挑选组装出我们所需的那一种.\n\nquery 可以专注在**主数据**的查询上, 这样降低了查询的复杂度, 进而使得核心业务逻辑的可维护性得到提升. 测试也更加容易覆盖.\n\nloader 提供各种关联数据, 既可以从数据库查询, 也可以走RPC调用, 为后续架构调整保留了弹性. 于是从单体架构转变为微服务的过程会减少许多阻力.\n\n使用这样的组合功能, 将各个 service 提供的数据, 用申明的方式组织起来, 在提升开发效率的同时, 最大程度保证了业务代码的可读和可维护. \n\n另外, 每一个接口都可以自己按需继承 service 的 schema, 这保证了类型互相之间充分的独立性, 也为每个接口后续的优化提供了充分的空间, 不用担心改动会影响到其他接口. 可以在需求稳定之后, 重写一些重要的接口. 这样既能在前期摸索阶段快速实现功能, 也能在后期巩固阶段游刃有余.\n\n在测试方面, 只要 service 级别实现充分的测试覆盖, 那么 router 层许多的功能组合是完全不用写测试的. 这无疑减少了许多工作量. (数据源可靠 + 组合过程靠可 =\u003e 生成的视图数据可靠)\n\n最后需要说明一下, pydantic-resolve 负责了按层级展开的数据获取和调整的过程, 使得开发只需要专注在每一层如何获取子数据, 以及如何在获取子数据之后进行修改. 所以平时冗长的for 循环展开都消失不见了, 在减少了代码噪音之后, 我们就能更加专注在核心功能上了.\n\n\n最后依然使用这张示意图来作为结尾.\n\n![](./static/concept.png)\n\n最后的最后, 读到这里, 您可能发现, 这套模式, 看起来还挺适合 BFF 的 :)\n\u003e 当然, python 不是 BFF 的主流选择, 这个是个硬伤, 也许未来可以开发一套nodejs 版本的.\n\n完.\n\nEnjoy.","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fallmonday%2Fcomposition-oriented-development-pattern","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fallmonday%2Fcomposition-oriented-development-pattern","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fallmonday%2Fcomposition-oriented-development-pattern/lists"}