ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Nhost Constellation 远程关系(Remote Relationships)深度解析:跨连接器 GraphQL 关联的规划与解析

2026/9/16 12:03:12 拓冰建站 浏览量
Nhost Constellation 远程关系(Remote Relationships)深度解析:跨连接器 GraphQL 关联的规划与解析 Nhost Constellation 远程关系Remote Relationships深度解析跨连接器 GraphQL 关联的规划与解析【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost本文以 services/constellation/docs/developers/remote-relationships.md 为骨架结合 Constellation 控制器controller、规划器planner与解析器resolver的源码实现系统讲解跨数据库db→db、数据库到远程 Schemadb→rs、远程 Schema 到数据库rs→db三类跨连接器 GraphQL 关系的元数据配置、编译期规划、运行期解析、连接键去重与幻影字段phantom field清理机制。读完本文你将理解 Constellation 如何在连接器彼此无感知的前提下完成跨源关联并掌握to_source/to_remote_schema元数据编写方法与扩展新解析策略的完整路径。一、背景什么是 Constellation 的远程关系Constellation 是 Nhost 开源的 GraphQL 联邦层位于 services/constellation它把多个数据源SQL 数据库、远程 GraphQL Schema合并成一个统一 GraphQL 端点。当一次查询需要跨越连接器边界获取数据时——例如主库里的users表要关联认证库auth_db里的用户资料或者数据库表要关联一个远程微服务暴露的getProduct字段——就需要远程关系Remote Relationships机制来把多个数据源的结果拼接到一起。在深入之前建议先阅读配套文档 query-execution.md 了解查询执行的完整管线再对照controller/planner与controller/resolver两个包的 godoc 阅读本文。支持的关系类型一览类型源Source目标Target解析策略Resolver strategydb→dbSQL 数据库另一个 SQL 数据库DatabaseResolverWHERE col INdb→rsSQL 数据库远程 GraphQL SchemaSchemaResolver别名化字段rs→db远程 SchemaSQL 数据库DatabaseResolverrs→rs远程 Schema远程 Schema不支持db→db (aggregate)SQL 数据库另一个 SQL 数据库groupedaggregate.Executor无 resolver一个关键前提是同一数据库内部的关系本地对象/数组关系永远不会进入规划器——它们由connector/sql/graphql/queries直接编译进单条 SQL 语句。规划器planner只在关系跨越连接器边界时才会介入。这也是整个架构设计的出发点连接器不需要知道远程关系的存在。二、元数据配置三种关系的 YAML 写法关系定义采用 Hasura 风格的 YAML 元数据由metadata/convert.go解析为原生类型metadata.ObjectRelationship/ArrayRelationship/RemoteRelationship。2.1 db→db使用to_sourceremote_relationships: - name: user definition: to_source: source: auth_db table: { schema: auth, name: users } field_mapping: { user_id: id } # 本地列: 远程列 relationship_type: object # 或 arraysource目标连接器名称即另一个数据库table目标表包含schema与namefield_mapping本地列: 远程列的映射是后续生成WHERE col IN (...)的 join 依据relationship_typeobject一对一或array一对多。2.2 db→rs使用to_remote_schemaremote_relationships: - name: inventory definition: to_remote_schema: remote_schema: inventory_service lhs_fields: [product_id] remote_field: getProduct: arguments: id: $product_id # $ 前缀 引用源字段值remote_schema目标远程 Schema 名称lhs_fields参与连接的源字段本地侧字段列表remote_field远程字段路径arguments中$product_id表示把父行中product_id的值作为参数传入。$前缀是源字段引用的标记由 metadata/convert.go 中的strings.CutPrefix(argValue, $)识别见该文件 L240-L243 附近。2.3 rs→db远程 Schema 类型上的to_sourceremote_relationships: - type_name: ExternalUser name: local_profile definition: to_source: source: default table: { schema: public, name: profiles } field_mapping: { userId: user_id } # 远程字段: 本地列 relationship_type: object这条配置写在remote_schemas.yaml中挂在远程 Schema 的某个类型ExternalUser上方向与 db→db 相反field_mapping左侧是远程字段右侧是本地列。元数据加载完成后控制器在构建状态时通过controller/controller.go中的buildPlannerRelationships内部调用buildDBRelMetadata/buildRSRelationships把这些元数据降级为规划器使用的planner.RelationshipMetadata。三、架构工作发生在哪三层远程关系系统横跨三个层次其核心架构决策是连接器不做远程关系检测。规划器产出一个干净的、按连接器切分的操作CleanOperation连接器像执行单源查询一样执行它所有跨连接器的推理都集中在controller/planner编译期与controller/resolver运行期。┌──────────────────────────────────────────────────────────────────────┐ │ Controller (controller/controller.go) │ │ • buildPlannerRelationships: 把元数据扁平化为 []*RelationshipMetadata │ │ • 每个 state 持有 QueryPlanner 与 RemoteRelationshipResolver │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ QueryPlanner (controller/planner/*) │ │ • Analyzer: 检测远程关系、收集 phantom 列 │ │ • ASTTransformer: 剥离关系字段、过滤 fragment │ │ • injectPhantomFields: 原地改写 CleanOperation │ │ • 输出: QueryPlan { PrimaryQueries, RemoteQueries } │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ Connectors (sql, remoteschema) │ │ • Connector.Execute 接收规划器的 CleanOperation │ │ • 连接器在运行期对跨连接器关系完全无感知 │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ Resolver (controller/resolver/*) │ │ • BuildRemoteQueriesFromPlan: plan 父结果 → RemoteQuery │ │ • RemoteRelationshipResolver.Resolve: 执行、拼接、清理 │ │ • 各策略: DatabaseResolver / SchemaResolver / AggregateInfo │ └──────────────────────────────────────────────────────────────────────┘四、规划阶段Planning Phase编译期发现与改写QueryPlanner.Plancontroller/planner/planner.go 的Plan方法按根字段分组处理先用groupFieldsByConnector把根选择按所属连接器分组再对每组执行分析 → 转换 → 注入三步。核心流程代码如下analyzer : newAnalyzer(connectorName, schema, relationships, operation.Operation, fragments) subOp : BuildSubOperation(operation, fields) analysis : analyzer.analyzeOperation(subOp) transformer : transform.NewTransformer( schema, toRemoteRelationships(relationships), connectorName, p.typeToConnectors, ) transformResult : transformer.Transform(subOp, fragments) injectPhantomFields(transformResult.CleanOperation, analysis.PhantomFields) plan.PrimaryQueries append(plan.PrimaryQueries, PrimaryQuery{...}) plan.RemoteQueries append(plan.RemoteQueries, analysis.RemoteQueries...)Transformer 收到p.typeToConnectorsmap[string][]string这样结构上完全相同的对象类型可以与拥有它的每个连接器保持关联——这是后面 fragment 过滤正确性的关键。4.1 Analyzer检测远程关系并收集幻影列controller/planner/analyzer.go递归遍历选择集始终跟踪当前的typeName与jsonpath.Path。每当遇到一个字段满足(typeName, fieldName)命中RelationshipMetadata且IsRemotetrue时把JoinMapping中的连接列加入幻影集合neededPhantoms构建一个RemoteQueryPlan记录源路径、目标连接器、输出别名、用户的Selection以及解析策略类型当RemoteFieldPath非空时用ResolverKindSchema否则ResolverKindDatabase继续遍历非关系字段从而能发现嵌套的跨连接器关系。Analyzer 会展开*ast.FragmentSpread与*ast.InlineFragment因此定义在 fragment 目标类型上的关系也能被捕获。具体的查找表relationshipLookup以TypeName.fieldName为键见 analyzer.go 的newAnalyzer。4.2 幻影字段规格PhantomFieldSpec检测到关系后analyzer 把neededPhantoms与用户已用自己响应键选择的字段collectOwnResponseKeyFields做比较。被别名化的用户字段仍占用响应键所以要用collectResponseKeys决定内部别名避免注入的幻影字段与用户响应形状冲突。剩余的字段记录为一个PhantomFieldSpectype PhantomFieldSpec struct { Path jsonpath.Path // 例如 [users, profile] Fields []string // 例如 [department_id] Aliases map[string]string // 冲突幻影字段的可选内部响应键 ForRelationship string }这个 spec 被所有共享同一源路径的RemoteQueryPlan引用这样解析器之后就能查出哪些幻影字段属于哪个关系、被别名的幻影该读哪个内部响应键。4.3 AST Transformer剥离与过滤controller/planner/transform/transform.go遍历同一个子操作产出深拷贝的CleanOperation与CleanFragments剥离所有(typeName, fieldName)命中远程关系的字段过滤TypeCondition不属于当前连接器即t.connectorName不在t.typeToConnectors[typeName]中的 fragment。因为typeToConnectors是map[string][]string共享类型上的 fragment 会为每个拥有该类型的连接器保留丢弃引用了已被过滤或剥离后为空的 fragment 的 fragment spread。由于 transformer 总是返回全新的 AST调用方可以安全地改写结果而不影响规划器共享的树。4.4 幻影注入Phantom InjectioninjectPhantomFieldscontroller/planner/ast_transformer.go中transform.InjectPhantomFields原地改写clean操作——安全因为它已经是克隆——把每个幻影字段名按 analyzer 记录的路径添加进去。于是连接器收到的是一个自包含的操作包含了解析器 join 所需的全部列。注入时若与用户响应键冲突会生成_constellation_phantom_前缀的内部别名makePhantomAlias见 analyzer.go。五、运行阶段Runtime Phase执行、拼接与清理连接器返回结果后Controller.resolveRemoteRelationshipscontroller/resolve.go驱动解析管线。解析器包的包注释resolver/remote_relationship_resolver.go将这条管线概括为五步Build构建远端操作→ Execute执行→ Extract提取→ Stitch拼接→ Strip清理。5.1 物化原始 JSONSQL 连接器在响应快路径上默认返回jsontext.Value原始 JSON。解析器需要遍历 map所以resolver.UnmarshalRawResults会一次性把它们物化为嵌套的map[string]any/[]any。5.2 构建 RemoteQuery 对象BuildRemoteQueriesFromPlancontroller/resolver/remote_query_builder.go调用extractJoinArgumentsFromPlan用jsonpath.Path.ToRows沿源路径收集每一行父数据构建去重的RemoteJoinArgument以源列排序后按|拼接为哈希键。任何源值为 null 的行被跳过没有 join 目标选择解析策略IsArrayAggregate的 plan 完全绕过 Resolver携带AggregateInfo载荷ResolverKindSchema的 plan 使用SchemaResolver其余全部使用DatabaseResolver目标表名通过Connector.GetTypeName(schema.table)解析以支持自定义类型名返回[]*RemoteQuery。注意当所有父行的 join 值都为 null 时plan 会得到零个JoinArguments这些查询在进入解析循环前就被过滤掉了len(rq.joinArguments) 0才保留。5.3 执行与拼接RemoteRelationshipResolver.Resolve对每个待处理查询调用executeAndStitch全部完成后调用removeAllLocalPhantomFieldsfor _, rq : range pendingQueries { r.executeAndStitch(ctx, results, rq, fragments, variables, role, sessionVariables, logger) } r.removeAllLocalPhantomFields(results, pendingQueries)executeAndStitch做五件事聚合快路径——若rq.AggregateInfo ! nil直接分发到目标连接器的groupedaggregate.Executor见下文第七节构建远程操作rq.Resolver.BuildOperation(rq)解析变量引用resolveVariableReferences远程操作是独立的查询没有变量定义所以$stats这类引用必须替换为字面值过滤 fragmentcollectReferencedFragments只保留远程操作引用的 fragment其余 fragment 可能携带只存在于源 Schema 上的类型执行并依次ExtractResults→BuildResultLookup→stitchResults。拼接完成后立即从远程结果中移除远程幻影字段。5.4 最终清理RemovePhantomFieldsFromPlan在每次请求后无条件运行——即使没有任何远程关系真正触发例如所有 join 键都是 null。这保证了任何幻影列都不会泄漏进响应。六、解析策略详解6.1 DatabaseResolver——db→db 与 rs→dbcontroller/resolver/database_resolver.go针对目标 SQL 连接器构建单条 GraphQL 操作在连接列上加WHERE _in过滤query { users(where: { id: { _in: [u1, u2] } }) { id displayName } }源码细节见 database_resolver.go若用户没请求目标连接列BuildOperation会把它作为幻影加入选择集必要时生成_constellation_remote_phantom_前缀的内部别名并记录到rq.remotePhantomFieldsBuildResultLookup按排序后的连接列值建键stitchResults遍历源行原地写入匹配结果用户给连接列取别名时如userId: idbuildColumnAliasMap记录该别名查找时优先使用别名而非原始列名数组关系还支持用户自定义where参数通过andMergeWhereValues把生成的_in过滤与用户过滤用_and合并值去重使用joinValueDedupKeyfmt.Sprintf(%#v, v)生成 AST 字面值时用valueKindForType推断IntValue/FloatValue/BooleanValue/StringValue。6.2 SchemaResolver——db→rscontroller/resolver/schema_resolver.go不能使用WHERE _in因为远程 Schema 没有这个概念。它改为对每个唯一 join 参数发一个别名化字段query { _0: getProduct(id: p1) { name price } _1: getProduct(id: p2) { name price } }buildRemoteFieldFromPathRecursive沿RemoteFieldPath递归构建嵌套调用形状$field参数值用当前RemoteJoinArgument的父值替换strings.CutPrefix(argValue, $)识别字段引用见 schema_resolver.go 的buildRemoteFieldArguments元数据路径未设置的参数会从用户的源字段参数中合并mergeSourceFieldArgumentsExtractResults按索引把每个别名结果取回BuildResultLookup按排序后的LHSFields建键供stitchResults匹配父行。6.3 拼接的公共逻辑remoteQuery.stitchResultsresolver/remote_query.go对两种策略是相同的只有取键/取结果不同数组关系matches nil时置空数组[]对象关系取第一个匹配否则置null。6.4 AggregateInfo无 resolver——跨库分组聚合当 db→db 数组关系暴露其_aggregate兄弟字段如User上的posts_aggregate时规划器会额外产出一个IsArrayAggregatetrue的RemoteQueryPlan。BuildRemoteQueriesFromPlan完全跳过 resolver产出携带AggregateInfo目标表标识 join 映射的RemoteQuery。executeAndStitchAggregatecontroller/resolver/aggregate_resolver.go随后取唯一的连接列当前仅支持单列 join多列会返回errAggregateMultiColumnJoinUnsupported把目标连接器断言为groupedaggregate.Executor——只有 SQL 连接器实现它目标是远程 Schema 时调用失败并返回errAggregateConnectorNotSupported以去重后的 join 值调用ExecuteGroupedAggregate连接器返回以 join 值为键的map[string]any把每个键对应的聚合结果写入对应父行。父行的 join 键在结果中没有条目时会得到一个形状与用户选择匹配的零值{aggregate: {...}, nodes: []}emptyAggregateForSelectioncount填 0数值/极值聚合按列填null。分组聚合的 SQL 构建器位于connector/sql/graphql/queries/groupedaggregate/执行器适配器在connector/groupedaggregate/。注意由于聚合需要 SQLGROUP BY无法用 GraphQL 表达所以这条路径不走 GraphQL 操作管线不构建 AST、不经过connector.Connector接口。七、幻影字段Phantom Fields详解存在两种幻影列生命周期不同幻影类型添加方所在路径移除方本地源侧Planner 在连接器执行前通过injectPhantomFields注入源结果上的PhantomFieldSpec.Path所有远程查询结束后RemoteRelationshipResolver.removeAllLocalPhantomFields远程目标侧Resolver 通过DatabaseResolver.BuildOperation添加记录进rq.RemotePhantomFields远程连接器返回的每一行结果顶部Resolver 在拼接后立即removePhantomFieldsFromRemoteResults双重保险RemovePhantomFieldsFromPlan在Resolve之后运行无论对应关系是否真的触发例如所有 join 键都是 null 导致关系被跳过都会清掉所有本应是本地幻影的字段防止幻影泄漏。removeAllLocalPhantomFields还按路径去重两个共享同一源路径的关系不会互相冲突。八、连接键去重排序为何至关重要无论是在规划期buildJoinArguments还是在查找期BuildResultLookup键的构造方式都一致按字母序排序源/LHS 列名读取每个值用fmt.Sprintf(%v, val)转换用|拼接。排序是关键——如果不排序通过路径遍历产生的父行与等价的 join 参数可能因为底层 map 迭代顺序不确定而哈希出不同的键。任何 join 键值为 null 的行都会被跳过它们不可能匹配远程结果而且关系没返回值与null 进 null 出在语义上没有区别。九、嵌套路径与数组导航远程关系可以位于任意深度包括数组内部query { games { homeTeam { department { name } # Team 上的 rs→db 关系 } } }路径导航通过internal/jsonpathservices/constellation/internal/jsonpath/path.go透明地同时处理对象与数组Path.ToRows(results)遍历时压平数组返回目标路径上的每个 map——用于收集 join 参数Path.ForEach(results, fn)对目标路径上的每个 map 调用fn——用于stitchResults写回结果Path.Delete(results, fields...)从路径上的每个 map 删除键——用于幻影清理。因此像games.homeTeam这样的路径会在所有 games 的 homeTeam map 上扇出解析器无需为数组做任何特判。十、当前限制仅查询Query。订阅中的远程关系会被Controller.execute拒绝SQL 构建器对变更Mutation没有远程关系支持。rs→rs 不支持。buildRSRelationships只消费远程 Schema 元数据上的to_source定义。聚合 join 必须是单列。多列聚合 join 返回errAggregateMultiColumnJoinUnsupported。聚合目标必须是 SQL 连接器。远程 Schema 不暴露groupedaggregate.Executor。null join 键被跳过。如果所有父行的 join 键都是 null关系被丢弃不发远程查询此时对象关系通过默认拼接得到null数组关系得到[]。十一、扩展新增一种关系解析策略文档给出了完整的六步扩展路径以把 REST 端点作为远程为例元数据——扩展 metadata/table.go 与 metadata/convert.go让新定义解析进RelationshipUsing.ManualConfigurationRelationshipMetadata——扩展controller/controller.go的buildDBRelMetadata或buildRSRelationships填充新标识字段并设置IsRemotetruePlanner 类型——若新策略需要特殊处理在analyzer.buildRemoteQueryPlan中新增ResolverKind与分发逻辑Resolver——在controller/resolver/实现RemoteQueryResolver接口BuildOperation、ExtractResults、BuildResultLookup、GetJoinKeyFromParent四个方法每个方法都拿到*RemoteQuery并基于其JoinArguments/SourceField工作Builder——扩展controller/resolver/remote_query_builder.go的buildRemoteQueryFromPlan按ResolverType选择新解析器测试——先在controller/resolver/写黑盒测试再在integration/写集成测试。AggregateInfo是绕过 resolver 接口策略的先行范例——当新策略无法适配四方法契约时照抄该模式即可。十二、文件速查表文件职责metadata/table.go解析remote_relationships:块metadata/remote_schema.go解析远程 Schema 上的remote_relationships:仅to_sourcemetadata/convert.go把 Hasura YAML 降级为原生类型controller/controller.gobuildPlannerRelationships、buildDBRelMetadata、buildRSRelationshipscontroller/planner/planner.go按连接器的规划循环controller/planner/analyzer.go远程关系检测、幻影字段收集controller/planner/ast_transformer.go字段剥离、fragment 过滤、幻影注入controller/planner/types.goQueryPlan、RemoteQueryPlan、RelationshipMetadata、PhantomFieldSpeccontroller/resolver/remote_query_builder.goBuildRemoteQueriesFromPlan、join 参数提取controller/resolver/remote_relationship_resolver.goResolve循环、幻影清理controller/resolver/remote_query.goRemoteQuery、RemoteQueryResolver接口、拼接逻辑controller/resolver/database_resolver.godb→db、rs→dbWHERE _incontroller/resolver/schema_resolver.godb→rs别名化字段controller/resolver/aggregate_resolver.go跨库分组聚合controller/resolver/fragments.gocollectReferencedFragmentscontroller/resolver/variable_resolution.go解析远程操作中的$varconnector/groupedaggregate/SQL 连接器实现的Executor接口internal/jsonpath/path.go嵌套路径导航integration/query_remote_relationships_test.go端到端覆盖十三、小结Constellation 的远程关系机制把跨源关联这一复杂问题清晰地切分为编译期规划与运行期解析两个阶段规划器负责检测关系、注入幻影列、剥离远程字段、过滤 fragment让每个连接器执行干干净净的单源操作解析器则按DatabaseResolver/SchemaResolver/AggregateInfo三种策略批量拉取远端数据、按键拼接回父行并在两端清理幻影字段。整个设计的关键收益是连接器层零改动、零感知新的数据源类型只需实现标准的Connector接口即可被联邦进来而新增一种关系策略也有明确、可复制的六步路径可循。对于希望在统一 GraphQL 层上联邦多个数据库与微服务的场景这套实现提供了值得借鉴的完整范本。【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考