Microsoft Graph 导航属性(Navigation Property)设计模式:用强类型关系建模资源关联
Microsoft Graph 导航属性(Navigation Property)设计模式:用强类型关系建模资源关联
导读:导航属性(Navigation Property)是 Microsoft Graph API 设计中用于在资源之间显式建模关系的核心模式,它基于 OData CSDL 规范,将资源间的关联从弱类型的外键字段升级为强类型的可寻址路径段。本文以仓库文档 graph/patterns/navigation-property.md 为主体,结合 graph/GuidelinesGraph.md 等配套指南,系统讲解导航属性的定义方式、包含(Containment)语义、$expand 与 $ref 用法,以及多对一、一对一、一对多三种关系场景的建模与 HTTP 实操。读完本文,你将掌握如何在 CSDL 元数据中声明导航属性,并能在实际 Graph 风格 API 中完成关联实体的检索、引用、创建绑定与解除绑定等完整生命周期操作。
一、问题:为什么外键属性不足以表达资源关系
在 API 设计中,资源之间往往存在有价值的关联关系,例如"用户有经理""订单属于客户""发票包含明细条目"。早期 REST API 通常用外键字段隐式表达这种关系——在某个资源的表示中放一个 id 值,并用命名约定(如 userId)暗示目标类型。
但这种方式存在明显缺陷:
- 弱类型:
userId只是一个字符串字段,API 消费者无法从模型层面确认它指向哪种实体、是否可空、是否唯一; - 遍历成本高:客户端需要先拿到主资源,再根据外键手工构造第二个请求去获取关联资源,关联资源的发现(Discovery)并非显而易见;
- 信息割裂:外键本身不携带关联资源的任何结构化信息,SDK 生成、文档自动化和可视化工具都难以利用它。
从仓库的命名规范 graph/articles/naming.md 第 5 节可以看到,外键字段的命名约定是"关系名 + Id 后缀"(如 subscriptionId),内容为被引用资源的规范标识。这种字段在导航属性出现后应当只作为补充或历史兼容手段,而不是首选。
二、解决方案:CSDL 导航属性与 URL 路径段
导航属性是 CSDL 规范 中定义的一种特殊属性:它在实体模型中声明一个指向关联实体的引用。在 HTTP API 中,该属性名会翻译为一个路径段(path segment),直接拼接在主资源的 URL 之后,即可访问关联资源的表示。
这一机制带来两个直接收益:
- 客户端无需额外信息即可构造关联 URL:不用再记忆"如何根据 userId 拼出 manager 的 URL";
- 客户端不必先取主资源:如果只关心关联资源,直接请求导航路径即可,减少一次往返。
以下两个示例分别代表最常见的两种基数关系:
/user/{userId}/manager—— 多对一关系(每个用户只有一个经理)/user/{userId}/messages—— 一对多关系(每个用户有多条消息)
这些关系可以在 CSDL 中这样声明:
<EntityType Name="user">
<NavigationProperty Name="manager" Type="user" ContainsTarget="false" >
<NavigationProperty Name="messages" Type="user" ContainsTarget="true" >
</EntityType>
其中 ContainsTarget 属性控制包含语义:
ContainsTarget="true":目标实体由源实体"包含",即目标的生命周期完全依附于源实体(例如发票下的明细条目);ContainsTarget="false":目标实体独立存在,生命周期与源实体无关(例如用户与其经理);该值也是属性缺省时的默认值。
与仓库主指南的呼应
在 graph/GuidelinesGraph.md 的 "Query support" 一节,官方指南明确要求:SHOULD 支持 /entityTypeCollection/{id}?$expand=navProp1 查询选项,用于展开实体上的导航属性。这意味着 $expand 不是可选实验特性,而是 Microsoft Graph 各 workload 在实现查询能力时应遵循的基线要求。同时,"URLs" 一节指出,Microsoft Graph 中资源间的关系正是通过 OData 的单例(singleton)、实体集(entity set)、实体、复杂类型和导航属性来支持的。
三、核心能力:$expand 与 $ref
1. $expand:一次往返同时获取源与目标实体
导航属性默认不会随实体表示自动返回,除非服务显式配置。API 消费者可以在支持的场景下使用 OData expand 查询参数,让关联实体嵌套进主实体,一次请求拿全两个对象:
GET /users/{id}?select=id,displayName&expand=manager(select=id,displayName)
这一能力直接对应主指南中 "SHOULD support $expand" 的要求,也呼应了 "SHOULD avoid using multiple round trips to complete a single logical operation"(避免多个往返完成单一逻辑操作)的行为建模规则。
2. $ref:只取关联资源的 URL
为导航属性实现 $ref 支持后,调用方可以只拿到关联资源的 URL 而不需要它的全部属性。例如 /user/23/manager/$ref 返回 @odata.id 指向的引用地址。这在"客户端只需要标识关联资源、不需要其全部属性"的场景(如建立或核对关系)中非常有用。
注意:原文档明确提示,当前实现中
$ref结果返回的 base URL 存在不正确的情况,客户端处理这些 URL 时需要将其转换为 Graph URL。
四、适用场景:三种关系基数建模
导航属性是模型层表达关系的主要工具。Microsoft Graph 主指南 graph/GuidelinesGraph.md 的资源建模小节强调:实体类型(EntityType)声明键属性以唯一标识对象,适合建模可寻址资源;而资源间的关系则优先用导航属性表达。下面按基数逐一展开。
1. 多对一(Many-to-one)
推荐用导航属性替代 Id 字段来引用关联实体。原因很直接:外键 Id 需要客户端做两次往返才能取到关联实体详情;导航属性一次往返即可。多对一关系**永远是非包含(non-contained)**的,因为目标的生命周期不可能依赖源实体。
<EntityType Name="order">
<NavigationProperty Name="customer" Type="customer" ContainsTarget="false" >
</EntityType>
2. 零或一对一(Zero-or-one-to-one)
这类导航属性可作为一种结构组织机制,将实体的属性按语义分组——用法类似复杂类型(complex type),关键区别在于:导航属性的目标在获取源实体时默认不返回。当源与目标的数据来自不同的后端 API 时,优先选择导航属性而非复杂类型属性(复杂类型必须随父实体一起序列化返回,难以跨服务拆分)。
这些关系必须被包含(contained):
<EntityType Name="invoice">
<NavigationProperty Name="paymentDetails" Type="paymentInfo" ContainsTarget="true" >
</EntityType>
3. 一对多(One-to-many)
子资源中带有父 Id 属性时,可以在父资源上声明一个子资源集合类型的导航属性。如果需要,也可以在子资源上反向声明指向父资源的导航属性——通常不是必须的(父 URL 本就是子 URL 的前缀),它的主要价值在于:检索子资源时用 expand 展开父资源属性,一次请求同时拿到两者:
/invoice/{invoiceId}/items/{itemId}?expand=parentInvoice(select=invoiceDate,Customer)
<EntityType Name="invoice">
<NavigationProperty Name="items" Type="invoiceItem" ContainsTarget="true" >
</EntityType>
<EntityType Name="invoiceItem">
<NavigationProperty Name="parentInvoice" Type="invoice" ContainsTarget="false" >
</EntityType>
一对多关系可以是包含的,也可以是非包含的。
4. 仓库实践佐证:core types 场景
graph/articles/coreTypes.md 给出了一个非常具体的应用案例:user、group、device 是 Microsoft Graph 的核心类型(core types),任何 workload 都不得轻易向其添加新的结构化属性(如给 user 直接加 accountNumber、routingNumber 字段)。正确的做法是新建一个实体类型,然后通过导航属性把关系挂上去,共有三种选项:
- 选项 1:在核心类型上添加指向新类型的包含型导航属性(
ContainsTarget="true"),新类型作为核心类型的子部分; - 选项 2:新类型放入独立实体集,在核心类型上加指向它的非包含导航属性;
- 选项 3:新类型放入独立实体集,并在新类型上加导航回核心类型的导航属性(如
bankAccountDetail.user)。
这套做法把"加字段"演化为"加关系",正是导航属性模式在真实 Graph API 演进中的核心价值:既避免污染高频核心类型,又让数据一致性更好——因为服务端不再需要存储重复数据,也就无需定期刷新副本。
5. 仓库实践佐证:从复杂类型集合迁移到导航属性
graph/articles/collections.md 第 11 节展示了另一种重要演进:当某个复杂类型集合(如 application.keyCredentials)需要支持"删除单个元素"等场景时,官方推荐将其升级为实体类型集合的导航属性(keyCredentials_v2,ContainsTarget="true"),使客户端能用 keyId 作为键对单个元素发起 DELETE /applications/{applicationId}/keyCredentials_v2/{some_keyId}。该节同时详细分析了从结构化属性迁移到导航属性带来的三个语义变化:
$select导航属性时按 OData 标准必须同时返回导航链接@odata.navigationLink;- 结构化属性默认随实体返回,迁移为导航属性后如需保持原行为必须自动展开(auto-expand);
- 原先通过
PATCH整体替换集合的更新语义必须保留。
这些细节说明导航属性与结构化属性在序列化、查询和更新行为上的差异,是模型演进时评估破坏性变更的关键依据。
五、跨命名空间的导航属性
导航属性并不局限于同一 schema 内。在 graph/patterns/namespace.md 的示例中,cloudCommunications 实体通过导航属性关联到另一个命名空间 microsoft.graph.callRecords 下的 callRecord 类型:
<Schema Namespace="microsoft.graph" ...>
<EntityContainer Name="defaultContainer">
<Singleton Name="communications" Type="microsoft.graph.cloudCommunications" />
</EntityContainer>
<EntityType Name="cloudCommunications">
<Key>
<PropertyRef Name="id" />
</Key>
<Property Name="id" Type="Edm.String" Nullable="false" />
<NavigationProperty Name="callRecords" Type="Collection(microsoft.graph.callRecords.callRecord)" ContainsTarget="true" />
</EntityType>
</Schema>
注意这里的类型引用使用完全限定名(命名空间 + 类型名),且命名空间之间不允许循环引用——因为许多面向对象语言不支持命名空间循环。这也是导航属性与命名空间模式协同使用时的常见约束。
六、局限与注意事项
原文档明确列出当前 Microsoft Graph 实现中的若干注意点:
- 跨后端服务(cross-workload)导航:部分场景有自动支持,部分场景存在限制;虽然限制正在逐步消除,但设计时仍需确认特定场景是否被支持;
- 默认不返回:实体表示中定义的导航属性默认不会随实体返回,除非服务显式配置,消费者需借助
expand主动获取; $ref的 base URL 问题:如前文所述,当前$ref返回的 URL 需要客户端转换为 Graph URL 才能处理;- 强类型收益:与弱类型外键相比,强类型导航属性支持自动生成文档与可视化、支持 SDK 生成、支持部分自动化客户端代码生成,并避免服务端重复存储数据,从而提升跨 API 的数据一致性。
此外,从仓库的弃用规范 graph/articles/deprecation.md 可知,导航属性本身也是可以标注弃用的模型元素——通过 Org.OData.Core.V1.Revisions 注解标记 Kind: Deprecated,并随响应返回 Deprecation / Sunset 响应头。这意味着导航属性的整个生命周期(引入、演进、弃用)都有完整的管理规范。
七、完整 HTTP 示例:关联关系生命周期
以下示例全部来自原文档,展示以 user.manager 为代表的多对一导航属性在真实 HTTP 交互中的五种操作。
1. 检索关联实体
GET /users/{id}/manager?$select=id,displayName
200 OK
Content-Type: application/json
{
"id": "6b3ee805-c449-46a8-aac8-8ff9cff5d213",
"displayName": "Bob Boyce"
}
该导航属性对应的 CSDL 声明如下(ContainsTarget 显式设为 false,此即缺省值):
<EntityType Name="user">
<NavigationProperty Name="manager" Type="graph.user" ContainsTarget="false" >
</EntityType>
2. 检索关联实体的引用(仅返回 URL)
GET /users/{id}/manager/$ref
200 OK
Content-Type: application/json
{
"@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/6b3ee805-c449-46a8-aac8-8ff9cff5d213/Microsoft.DirectoryServices.User"
}
3. 检索实体并展开关联实体(单次往返)
GET /users/{id}?select=id,displayName&expand=manager(select=id,displayName)
200 OK
Content-Type: application/json
{
"id": "3f057904-f936-4bf0-9fcc-c1e6f84289d8",
"displayName": "Jim James",
"manager": {
"@odata.type": "#microsoft.graph.user",
"id": "6b3ee805-c449-46a8-aac8-8ff9cff5d213",
"displayName": "Bob Boyce"
}
}
注意 expand 参数中同样可以嵌套 select,以控制展开部分返回的属性子集;展开的实体带有 @odata.type 注解,用于在类型可能歧义时明确实体类型(这也符合主指南 graph/GuidelinesGraph.md 中 "MUST include @odata.type annotations when the type is ambiguous" 的规则)。
4. 创建实体时绑定关联引用
创建新用户,并同时绑定一个已存在的经理:
POST /users
Content-Type: application/json
{
"displayName": "Bob",
"manager@odata.bind": "https://graph.microsoft.com/v1.0/users/{managerId}"
}
201 Created
5. 更新实体的关联引用
将用户实体的经理关系更新为某个已存在的经理:
PATCH /users/{id}
Content-Type: application/json
{
"displayName": "Bob",
"manager@odata.bind": "https://graph.microsoft.com/v1.0/users/{managerId}"
}
204 No Content
6. 解除关联引用与删除关联实体
只解除用户与经理的关系(经理实体本身保留):
DELETE /users/{id}/manager/$ref
204 No Content
直接删除被关联的经理实体(注意:删除 $ref 只是切断关系,删除导航路径本身会删除目标实体):
DELETE /users/{id}/manager
204 No Content
操作语义小结
| 操作 | 方法 | 语义 |
|---|---|---|
| 检索关联实体 | GET /users/{id}/manager |
返回经理实体的表示 |
| 检索引用 | GET /users/{id}/manager/$ref |
只返回关联实体的 @odata.id |
| 展开关联 | GET /users/{id}?$expand=manager |
主实体与经理一次往返返回 |
| 创建时绑定 | POST /users(带 manager@odata.bind) |
新实体与已有实体建立关系 |
| 更新绑定 | PATCH /users/{id}(带 manager@odata.bind) |
更新关系指向 |
| 解除关系 | DELETE /users/{id}/manager/$ref |
切断关系,保留目标实体 |
| 删除目标 | DELETE /users/{id}/manager |
删除目标实体本身 |
八、总结:何时采用导航属性
综合原文档与仓库配套指南,可以提炼出如下决策要点:
- 多对一关系:优先使用导航属性而非外键 Id 字段,换取单次往返与强类型收益;
- 跨后端服务的数据拆分:源与目标来自不同后端 API 时,用导航属性(而不是复杂类型)建模一对一关系;
- 集合关系:父实体上的子资源集合用
Collection(T)导航属性声明,必要时在子资源上反向声明父导航配合$expand; - 核心类型演进:面对
user/group/device等核心类型时,以"新建实体 + 导航属性"替代"加字段",参考 graph/articles/coreTypes.md 的三种选项; - 模型演进:当结构化集合需要独立寻址与逐项操作时,可参考 graph/articles/collections.md 将其迁移为包含型导航属性集合,但需同步处理序列化、
$select与更新语义的变化。
导航属性是整个 Microsoft Graph 资源模型的地基之一:它统一了 URL 构造、关系遍历、查询展开与引用管理,让 API 从"返回字段"进化到"描述关系"。在设计新 API 或评审既有模型时,可对照本模式(模式模板参见 graph/patterns/PatternDescriptionTemplate.md)与其他关联模式(如 facets.md、subtypes.md、upsert.md)做系统性评估。