ComposedErManager —— 同进程多 engine 组合
ComposedErManager 把多个自洽的 ErManager(各自焊死自己的 engine/session)
组合成一个查询代理。组合体产出的 resolver 能在一次 resolve 里跨过不同数据库
的实体,全程在单进程内,无 HTTP 桥。
它是 federation 的同进程对偶:
- federation(specs/012,见 federation.zh.md):跨进程组合, 跨服务关系走 HTTP。
- ComposedErManager(specs/019):在单一进程内组合,跨 engine 关系走进程内 DataLoader(用户提供的闭包)。
blog engine shop engine
User ── posts ── Post Order ── items ── OrderItem
│ ▲
└─ orders ──── 跨 engine 边 ────────────┘ (User.id → Order.user_id)
工作原理
- 按 entity 委托 —— 每个实体只属于一个 member。组合体把
has_entity/get_relationships/get_loader_for_entity路由到所属 member;成员实体集必须 互斥(重复注册在构造时报错)。 - 跨 engine 关系在组合体层声明 —— 跨 engine 关系(
User → orders → Order)声明在ComposedErManager上,不在任一 member 的实体上。成员保持自洽:单独使用时对这条边 无感。这条边的 loader 是用户提供的闭包,内部打开目标 engine 的 session。 - resolve 透明 ——
composed.create_resolver()产出单一 resolver;resolve()经 跨 engine DataLoader 扇出。调用方看到一棵扁平的树。
组合 + 声明跨 engine 边
from sqlmodel import select
from nexusx import ComposedErManager, ErManager, Relationship
blog_er = ErManager(session_factory=blog_sf, entities=[User, Post])
shop_er = ErManager(session_factory=shop_sf, entities=[Order, OrderItem])
async def orders_by_user(user_ids: list[int]) -> list[list[Order]]:
async with shop_sf() as s: # 用目标 engine 的 session
result = await s.exec(select(Order).where(Order.user_id.in_(user_ids)))
by: dict[int, list[Order]] = {}
for o in result.all():
by.setdefault(o.user_id, []).append(o)
return [by.get(uid, []) for uid in user_ids]
composed = ComposedErManager(
members=[blog_er, shop_er],
cross_relationships=[
(User, Relationship(
fk="id", target=list[Order], name="orders", loader=orders_by_user,
)),
],
)
跨 engine 边在组合体层声明一次。User 与 Order 互不引用。
跨 engine 查询
γ —— DTO 树(Resolver):
class OrderDTO(DefineSubset):
__subset__ = (Order, ("id", "total"))
class UserDTO(DefineSubset):
__subset__ = (User, ("id", "name"))
orders: list[OrderDTO] = []
Resolver = composed.create_resolver()
resolved = await Resolver().resolve([UserDTO(id=1, name="Alice")])
# Alice.orders 透明地在 shop engine 上 resolve
β —— GraphQL handler(注入组合体,US3):
handler = GraphQLHandler(er_manager=composed, entities=[User, Post, Order, OrderItem])
# @query 入口各自取自己的 session;一次查询跨 blog → shop。
与 federation 对比
| federation(012) | ComposedErManager(019) | |
|---|---|---|
| 范围 | 跨进程 | 单进程内 |
| 跨边传输 | HTTP(每个服务一条嵌套 gql) | 进程内 DataLoader(闭包) |
| 成员单位 | 一个 nexusx service(独立 app/进程) | 一个 ErManager(独立 engine/session) |
| 装配 | 启动时 mount(await handler.federate(...)) |
构造(ComposedErManager(members=...)) |
| 适用场景 | 服务独立部署 | 单服务、多数据库 |
Voyager 分组与配色(022)
多 engine 组合后,Voyager 的 ER 图和 UseCase 页会按 member 分 cluster:给
member 设 service_name,它的实体(以及注册进 dto_classes 的 DTO)就归入以
该名字为标签的独立分组;再设 color,分组会带上背景色填充和同色边框:
blog_er = ErManager(
session_factory=blog_async_session,
entities=[CmUser, CmPost],
service_name="blog", # cluster 标签(组合体内必须唯一,重名构造期报错)
color="#E3F2FD", # 可选,建议浅色 #RRGGBB
)
shop_er = ErManager(
session_factory=shop_async_session,
entities=[CmOrder, CmOrderItem],
service_name="shop",
color="#FFF3E0",
)
composed = ComposedErManager(members=[blog_er, shop_er], ...)
要点:
- opt-in:不设
service_name的 member 回落现状(按 Python module 分组);不设color的 member 只分组、不填色(白底)。颜色不设自动调色板,完全由你声明。 color依赖service_name生效——只设颜色不设名字时颜色被忽略。- 归属优先级:federation 物化类型按远端 service 聚簇(dashed)> member 按
service_name聚簇(rounded)> Python module。两级可叠加,互不串扰。 - Route 节点(UseCaseService 方法)不参与 member 分组——service 层组织方式与 数据源归属是两个正交维度。
- 单体 ErManager(非组合)完全不受影响,设了
color也不产生任何变化。
已知限制:service_name 若与本地某个真实 Python module 名前缀相同(如
service_name=blog 而存在模块 blogging.models),该模块的 cluster 会被
member 颜色误命中——取名时避开既有 module 前缀即可。
设计原则
| 决策 | 原因 |
|---|---|
| member 是自洽的 ErManager | 各自独占一个 engine;可独立复用 |
| 跨边在组合体层声明 | member 保持纯粹;跨边是组合的属性,不属于任一 member(DD-02) |
| mutating 操作留在 member | federate / initialize / add_virtual_entities 在 member 上做,从不在组合体上(FR-013)—— 组合体只查询 |
LoaderRegistry Protocol |
组合体满足与 ErManager 相同的查询契约,故 create_resolver / GraphQLHandler 注入无需特殊处理 |
可运行 demo
demo/composed_er_manager/ 跑 blog + shop 双 engine 示例:
打开 http://localhost:8030/graphql,跨 engine 查询:
posts 在 blog engine 内 resolve;orders 在同一查询里跳到 shop engine。
参见:Federation(跨进程对偶)、自定义关系。