Skip to content

Commit f1f8d3b

Browse files
committed
update readme
1 parent 55d17b5 commit f1f8d3b

File tree

10 files changed

+91
-56
lines changed

10 files changed

+91
-56
lines changed

readme-cn.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ GraphQL 虽好, 但完整引入 GraphQL 对架构的影响不小, schema 定义
9898
> 现在很多前端直接使用 GraphQL 来组合查询, 从职位划分来说等于插手了一部分后端的工作. 把GraphQL 放在 client 和 server 之间并不是一个理想的定位. 就像把 SQL 查询暴露给 client 一样. (数据的处理分散在多个环节不利于项目维护.)
9999
100100

101-
总体来说, GraphQL 在提供视图数据方面, 有查询灵活度高的优点, 但存在获取的数据后期调整比较麻烦, 以及架构侵入较大等缺点. 比如 GraphQL 获取到多层数据后要做层级聚合统计, 就需要重新便利一遍树状数据来处理. 框架本身没有设计合适的下层数据处理完之后触发回调的钩子. (这恰恰是对视图调整很有用的)
101+
总体来说, GraphQL 在提供视图数据方面, 有查询灵活度高的优点, 但存在获取的数据后期调整比较麻烦, 以及架构侵入较大等缺点. 比如 GraphQL 获取到多层数据后要做层级聚合统计, 就需要重新遍历一遍树状数据来处理. 框架本身没有设计合适的下层数据处理完之后触发回调的钩子. (这恰恰是对视图调整很有用的)
102102

103103
在这里我们总结一下, 从数据获取到生成视图需要哪几个步骤.
104104

@@ -168,10 +168,10 @@ async def main():
168168

169169
核心概念就是在获取`根数据`之后, 结合描述好的视图结构, 交给 `Resolver` 来填充所有的数据.
170170

171-
罗列一下, 这套开发模式有以下这些能力:
171+
罗列一下, 这套开发模式有以下这些优点:
172172

173173
- 查询
174-
- 可以方便的描述组合体数据的 schema, 然后resolve出完整数据, 定义方式简单 (借助 pydantic or dataclass)
174+
- 用申明式的方式描述数据和查询, 直观且容易修改
175175
- 可以读取全局参数, 可以跨层级向下传递数据
176176
- 任意层级, 任意类型.
177177
- 架构简单, 各个 service 仅需提供通用的 loader, 用于数据拼装

resolve-vs-graphql-cn.md

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,35 @@
22

33
### GraphQL
44

5-
graphql 优点
5+
优点
66

77
- client 可以根据查询动态获取数据
8+
- 申明式的查询描述
89
- introspection 可以看到所有字段
910
- 强类型
10-
- 前后端分离的场景开发体验较好, 对前端友好
1111

12-
graphql 缺点
12+
缺点
1313

1414
- 缺少层级之间进行数据二次处理的能力. 比如上层无法在下层数据获取后进行额外操作.
15-
- 如果是全栈开发,后端和写 query 有重复劳动
16-
- 前后端需要引入 graphql 相关框架
17-
- cache, authority, rate limit 控制起来不容易
18-
- 对后端不太友好
15+
- 查询体(文本)转换成具体类型不方便
16+
- 框架对整个项目的影响较大
1917

2018
### Pydantic-resolve
2119

2220
pydantic-resolve 优点:
2321

24-
- 提供了各种数据组合的能力
22+
- 申明式的查询描述
2523
- 强类型(pydantic)
26-
- 提供了 hook 来修改数据
27-
- 和 restful 接口无缝衔接,平滑过度
28-
- 非常适合全栈开发,自己实现好接口之后直接生成 ts sdk
29-
- cache, authority, rate 之类的功能不受影响
30-
- 接口独立,容易做性能调试
31-
- 对前后端都很友好
24+
- 提供了每层获取数据后进行处理的hook
25+
- 对项目基本没有侵入性 (需要 async 支持)
26+
- 一站式视图数据生成, 直接满足前端展示的所有需求
3227

3328
pydantic-resolve 缺点:
3429

35-
- 查看组合类型需要通过 OpenAPI 查看 response 信息
36-
- 前后端分离的情况,没有后端一个万能接口来得方便
37-
- Friend -> Friend 的 graph 描述不方便
30+
- 一个个单一入口, 没有GraphQL 阅读体验完整.
3831

3932

40-
### 使用 GraphQL 正确的姿势
33+
### 使用 GraphQL 的姿势
4134

4235
graphql 的使用思路有两种
4336

@@ -47,7 +40,10 @@ graphql 的使用思路有两种
4740
用户熟悉这种固定的ER,查询到规范的数据之后自己再做二次加工。换言之你哪怕有定制化的需求,也只能自己想办法处理,不可能向他们提出这种要求。
4841

4942
另一种是面向业务,构建的是业务层的查询接口
50-
这种场景并不适合graphql,他对查询的数据定制化要求高,意味着通过通用接口拿到的数据往往需要根据业务做再加工,而且也会和后端商量,定制一些面向专用页面的graphql 接口。这也会慢慢变成常态,因为很多数据并不适合暴露到前端做二次处理。
51-
对于一整套业务,graphql 这样一个灵活的中间层反而对业务的整体清晰度造成了影响。
43+
这种场景并不适合graphql,他对查询的数据定制化要求高,意味着通过通用接口拿到的数据往往需要根据业务做再加工,而且也会和后端商量,定制一些面向专用页面的graphql 接口。
5244

53-
结论就是,graphql 适合稳定的,轻业务概念的数据的组合查询。而不适合面向具体业务,高度定制化的场景。
45+
这会慢慢变成常态,导致很多不通用的查询出现. 并且很多数据并不适合暴露到前端做二次处理。
46+
47+
对于一整套业务流,graphql 这样一个灵活的中间层反而对垂直业务的整体清晰度造成了影响。
48+
49+
结论就是,graphql 适合稳定的,轻"具体"业务概念的数据的组合查询。而不适合面向具体业务,高度定制化的场景。

src/router/sample_1/readme-cn.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class Sample1TeamDetail(tms.Team):
126126
访问: `http://localhost:8000/sample_1/teams-with-detail`
127127

128128

129-
### 一种优化思路
129+
### 另一种数据加载情景
130130

131131
`get_teams_with_detail_2` 描述了另一种场景, 假如我们利用了一些 ORM 的外键查询, 提前获取到了 team + sprints 级别的数据, 那我可以以这个数据为基础继续向下 resolve.
132132

@@ -199,7 +199,7 @@ async def team_to_sprint_loader(team_ids: list[int]):
199199
return build_list(sprints, team_ids, lambda u: u.team_id) # to list
200200
```
201201

202-
可以看到 1:1 的关系查询 id 是目标的主键, 查询非常简单, 因此可复最方便
202+
可以看到 1:1 的关系查询 id 是目标的主键, 查询非常简单, 复用最方便
203203

204204
而 1:N 的查询需要有对应的关系表 (parent_id -> id) 来确定,所以复用情况取决于 parent_id。
205205

@@ -253,7 +253,7 @@ class Sample1TeamDetail(tms.Team):
253253
至此, Dataloader 的使用介绍完毕.
254254

255255

256-
## 其他想法
256+
## 聊聊DataLoader
257257

258258
对于使用过 `graphene` 或者 `strawberry` 之类 graphql 框架的开发, dataloader 是一个很熟悉的东西.
259259

src/router/sample_2/readme-cn.md

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
### 过滤
1+
## 为 Loader 提供过滤
22

3-
进入 `sample_2`.
3+
进入 `sample_2`. 为 1:N 的 loader 提供额外的过滤功能.
44

5-
考虑这么一种场景, 需要列出 Team 中 level 为 senior (或者其他值) 的 members, 因此 loader 需要提供添加过滤条件的手段.
5+
考虑这么一种场景, 需要列出 Team 中 level 为 senior (或者其他值) 的 members, 那么 loader 需要提供添加过滤条件的手段.
66

77
我们可以这么做, 在 `src.services.user.loader` 中添加 `UserByLevelLoader`, 它有一个类属性 `level`.
88

9-
在初始化 loader 之后, 通过设置 `self.level` 就能实现功能, 现在问题是怎么为 `self.level` 赋值.
9+
在初始化 loader 之后, 通过设置 `self.level` 就能实现功能
10+
11+
12+
```python
13+
loader = UserByLevelLoader()
14+
loader.level = 'senior'
15+
```
16+
17+
于是问题是如何在Resolver中为 `self.level` 赋值.
1018

11-
> 一个 loader 实例的 filter 字段值是不可改变的. 不同的 filter 组合需要对应到各自的 loader 实例
1219

1320
```python
1421

1522
# team -> user (level filter)
1623
class UserByLevelLoader(DataLoader):
17-
level: str = ''
24+
level: str = '' # filter
1825

1926
async def batch_load_fn(self, team_ids: list[int]):
2027
async with db.async_session() as session:
@@ -29,6 +36,8 @@ class UserByLevelLoader(DataLoader):
2936
return [dct.get(team_id, []) for team_id in team_ids]
3037
```
3138

39+
> 一个 loader 实例的 filter 字段值是不可改变的.
40+
3241
这个参数可以从 Resolver 中传入, `loader_filters` 中指定要设置参数的 DataLoader 子类和具体参数, 在内部执行时就会进行赋值.
3342

3443
```python
@@ -42,14 +51,16 @@ teams = await Resolver(loader_filters={
4251
return teams
4352
```
4453

45-
顺带说一下, 如果需要使用 loader 多次, 比如同时查询 level senior 和 junior 的两组 members, 因为 `pydantic-resolve` 中是对每一个 DataLoader类生成实例的, 所以无法对同一个 DataLoader 做复用.
54+
### 相同的Loader 使用不同的filter
55+
56+
顺带说一下, 如果需要使用 loader 多次, 比如同时查询 level senior 和 junior 的两组 members, 因为 `pydantic-resolve` 中是对每一个 DataLoader类生成实例的, 所以无法对同一个 DataLoader 传递不同参数.
4657

4758
解决方法是对 DataLoader 做一次拷贝之后变成新的 DataLoader 来使用.
4859

4960
```python
5061
# schema.py
5162
def copy_class(name, Kls):
52-
return type(name, Kls.__bases__, dict(Kls.__dict__))
63+
return type(name, Kls.__bases__, dict(Kls.__dict__)) # provide in pydantic2_resolve
5364

5465
SeniorMemberLoader = copy_class('SeniorMemberLoader', ul.UserByLevelLoader)
5566
JuniorMemberLoader = copy_class('JuniorMemberLoader', ul.UserByLevelLoader)
@@ -96,3 +107,22 @@ async def get_teams_with_detail_of_multiple_level(session: AsyncSession = Depend
96107
return teams
97108
```
98109

110+
### 简便方式
111+
112+
如果使用了多个loader 并且参数都相同的话, 可以使用 `global_loader_filter` 参数来统一提供参数.
113+
114+
```python
115+
await Resolver(loader_filters={
116+
LoaderA: {'level': 'senior'},
117+
LoaderB: {'level': 'senior'},
118+
LoaderC: {'level': 'senior'},
119+
LoaderD: {'level': 'senior'},
120+
LoaderE: {'level': 'senior', 'other': 'value'}}).resolve(data)
121+
```
122+
123+
可以简化成
124+
```python
125+
await Resolver(
126+
global_loader_filter={'level': 'senior'},
127+
loader_filters={LoaderE: {'other': 'value'}}).resolve(data)
128+
```

src/router/sample_3/readme-cn.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
### 向子孙节点暴露字段信息.
1+
### 向子孙节点提供字段信息.
22

33
进入 `sample_3`.
44

5-
我想让 task 有一个 full_name 字段, 直接包含所有上层的前缀.
5+
我想让 task 有一个 full_name 字段, 里面包含所有上层的name作为前缀.
66

77
比如 team_a -> sprint_a -> story_a -> task_a, 那么 task_a 的 full_name就是 `team_a/sprint_a/story_a/task_a`
88

9-
我们可以通过给 schema 设置 `__pydantic_resolve_expose__ = {'name': 'team_name'}` 这样的方式, 给自己的某个字段取别名, 然后暴露给自己所有的子孙节点.
9+
我们可以通过给 schema 设置 `__pydantic_resolve_expose__ = {'name': 'team_name'}` 这样的方式, 为自己的某个字段取别名, 然后让自己所有的子孙节点可以读取到.
1010

1111
> 别名需要保证全局 (整个Resolve 接收的 schema) 唯一. 否则 pydantic-reslove 会抛出错误.
1212
@@ -31,4 +31,4 @@ class Sample3TaskDetail(ts.Task):
3131
return f"{team}/{sprint}/{story}/{self.name}"
3232
```
3333

34-
通过这种方式, 我们可以访问任意层级的祖先数据, 这给数据处理带来了巨大的便利. 可以满足各种 UI 展示上的需求.
34+
通过这种方式, 我们可以访问任意层级的祖先数据, 这给数据处理带来了巨大的便利. 可以满足各种视图构建的需求.

src/router/sample_4/readme-cn.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## 后处理钩子
22

3-
进入 `sample_4`
3+
进入 `sample_4`. 介绍如何在数据获取之后进行额外处理.
44

55
这次我想在 team, sprint, story 上面添加 task_count 字段, 来统计每一层包含的 task 总数.
66

@@ -71,9 +71,9 @@ class Sample4TeamDetail(tms.Team):
7171

7272
## 隐藏/过滤字段
7373

74-
在上个案例中, 可能会有一个疑问, 这些task_count 有一些层级, 如果我不像返回回去呢? 我想将它在返回中屏蔽掉该怎么做?
74+
在上个案例中, 可能会有一种需求. 比如有一些层级的 task_count 我不想显示, 想将它在返回中屏蔽掉该怎么做?
7575

76-
对于这种对外隐藏字段的需求, 可以使用 model_config 装饰器来实现.
76+
对于这种对外隐藏字段的需求, 可以使用 model_config 装饰器搭配`Field(exclude=True)`来实现.
7777

7878
```python
7979
@model_config()
@@ -91,6 +91,6 @@ class Sample4StoryDetail(ss.Story):
9191

9292
在pydantic 中,如果exclude=True, 则会在输出中屏蔽该字段, 但是 schema 中依然能看到这个字段.
9393

94-
如果搭配了 `model_config` 就可以保证在 schema properties 中也屏蔽该字段.
94+
搭配了 `model_config` 就可以保证在 schema properties 中也屏蔽该字段.
9595

9696
可以将代码中的注释移除后测试.

src/router/sample_5/readme-cn.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
## 利用 Context 和 Schema 实现复用.
1+
## 利用 Context 实现查询复用.
2+
3+
### 面向页面提供视图数据
24

35
进入 `sample_5`
46

@@ -29,10 +31,12 @@ async def get_page_info(session: AsyncSession = Depends(db.get_session)):
2931

3032
router 里面只要初始化一下, 剩下的交给 Resolver 就好了.
3133

32-
> 到这里, 你也许会发现, 定义 schema 的过程和使用 GraphQL 手写查询体的体验是很相似的, 区别是 Resolver 处理的 schema 还需要自己选择 loader 和 schema. 配置多了点, 但是自由度和功能多了许多.
34+
> 到这里, 你也许会发现, 定义 schema 的过程和使用 GraphQL 编写Query的实现是很相似的.
3335
>
3436
> 一个小的最佳实践: resolve_method 中不要自己写业务查询逻辑, 要调用 servcie 中封装好的 query 方法. 这样可以保持 schema 的简洁和拼装的清晰. schema 中要么调用 query, 要么调用 loader, 用配置+组合的思考方式来定义 schema.
3537
38+
### Context 帮助提取参数
39+
3640
让我们更进一步, 让 router 可以接收一个 `team_id` 参数, 然后 teams 变成 team, 这时就可以通过 context 来传递参数了.
3741
context 是一个保留参数, 在所有 resolve 和 post 方法中都可以使用它来获取 Resolver 中定义的参数.
3842

src/router/sample_6/readme-cn.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
既然提到了 GraphQL, 在查询体中可以选择需要的字段, 那么在 Resolver 里面怎么做呢? 以 Sprint 为例, 我不想让 status 字段显示出来.
66

7-
需要分两步, 第一步是去拷贝一下 Sprint 里面要的字段, 第二步是添加 `@ensure_subset` 装饰器, 它会检查字段是否和 Sprint 中的名字,类型一致 (避免修改了 Sprint 之后, 其他复制的字段出现不一致, 这样就会给出错误提醒. )
7+
需要分两步, 第一步是去拷贝一下 Sprint 里面要的字段, 第二步是添加 `@ensure_subset` 装饰器, 它会检查字段是否和 Sprint 中的名字,类型一致
88

9-
> 当前的实现要手动复制字段还是有点啰嗦的, 下个阶段计划只需要在装饰器中提供字段名字列表.
9+
> 避免修改了 Sprint 之后, 其他复制的字段出现不一致, 否则会给出错误提醒.
1010
1111
```python
1212
@ensure_subset(sps.Sprint)
@@ -15,7 +15,6 @@ class Sample6SprintDetail(BaseModel):
1515

1616
id: int
1717
name: str
18-
# status: str
1918
team_id: int
2019

2120
stories: list[Sample6StoryDetail] = []

src/router/sample_7/readme-cn.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ router 中使用 `add_single_to_loader` 来处理 `prime` 逻辑
2626

2727
模拟预先获取 users 信息, 然后加入 loader, 再提供给 `Sample7TaskDetail` 使用。
2828

29-
> 如果注释 `add_single_to_loader`方法, 会发现所有的 user 都是 None
3029

3130
```python
3231
def add_single_to_loader(loader, items, get_key):
@@ -49,8 +48,9 @@ async def get_tasks(session: AsyncSession = Depends(db.get_session)):
4948
tasks = await Resolver(loader_instances={UserLoader: user_loader}).resolve(tasks)
5049
return tasks
5150
```
51+
> 如果注释 `add_single_to_loader`方法, 会发现所有的 user 都是 None
5252
53-
第二个稍微复杂一些的例子, 从 user[1] 开始, 层层寻找 user 拥有的 story, story 归属的 sprint, sprint 归属的 team, 然后从 Teams 开始层层往下展示。
53+
第二个稍微复杂一些的例子, 从 user[1] 开始, 层层寻找 user 拥有的 story, story 归属的 sprint, sprint 归属的 team, 然后反向从 Teams 开始层层往下展示。
5454

5555
```python
5656
@route.get('/user/stat', response_model=list[Sample7TeamDetail])

src/services/sprint/readme-cn.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
## service 的测试
22

3+
在组合模式中, 借助query 和 loader, 我们可以大幅简化测试内容.
4+
5+
本例中 `src/services/conftest.py``src/services/sprint/tests/conftest` 两个文件一起定义了pytest所需的fixtures.
6+
37
可以看到该目录下有两个测试文件
48

59
- `test_loader.py`
610
- `test_query.py`
711

8-
这两个文件构成了 service integration test 的基础。
12+
这两个文件构成了 service 测试的基石,
13+
14+
对于没有session传入的 loader 案例, 通过简单的 mock 也能搞掂.
15+
16+
只要保证所有的 query 和 loader 的测试正确,那么 router 层拼接的 schema 对象就是稳定可靠的。
917

10-
只要保证所有的 query 和 loader 的测试正确,那么 router 层基于他们拼接的 schema 对象就是稳定可靠的。
18+
> pydantic-resolve 通过充分的测试保证组合过程是可靠的.
1119
12-
于是,router 层的 API 测试就没有必要了
20+
于是,router 层的 API 测试就没有必要去写了
1321

14-
> 除非你在 router 层又写了容易出错的额外代码
15-
>
16-
> 如果存在, 请对其单独进行测试覆盖。
22+
> 除非你在 router 层又写了容易出错的额外代码, 如果存在,请对其单独进行测试覆盖。

0 commit comments

Comments
 (0)