Microsoft Graph Viewpoint 模式:为共享资源建模单用户上下文(Viewpoint Pattern 设计指南)
Microsoft Graph Viewpoint 模式:为共享资源建模单用户上下文(Viewpoint Pattern 设计指南)
导读
本文深度解析 Microsoft Graph API 设计模式库中的 Viewpoint(视点)模式,其核心目标是为"同一份共享资源在不同用户眼中拥有不同状态"这一经典场景提供标准化的建模方案:通过在 API 实体类型上引入一个结构化的 viewpoint 属性,将"这个资源对当前用户意味着什么"(已读、已删除、已加标记、已隐藏等)以服务端持久化的方式表达出来。读完本文,你将掌握 viewpoint 属性在 CSDL 元数据中的定义方式、如何为指定用户读取带视点的资源集合、如何用 OData action 与 PATCH 两种途径更新视点,以及该模式在应用权限下的取值语义、计算属性注解等关键设计取舍。本文内容以仓库中的 graph/patterns/viewpoint.md 为主体,并结合 graph/GuidelinesGraph.md 及其模式目录中与之协同的模式文档进行展开。
一、模式要解决的问题:共享资源的"用户态"分化
在组织化的协作场景中,大量资源是被多用户共享的:一个团队网站、一条群组消息、一个聊天会话……但这些共享资源在不同的用户视角下可能呈现不同的状态。原文给出的典型例子是:
- 用户 1 已经阅读并删除了某条消息;
- 用户 2 却还没有看到过这条消息。
同一个共享条目,在"个体上下文(individual context)"中被呈现时,就产生了必须区分的状态差异。如果把这些状态直接建模在共享资源本身(例如把 message 实体的 isRead、isDeleted 布尔属性直接全局化),则不同用户的状态会互相覆盖,根本无法表达"对谁而言已读"。这就是 Viewpoint 模式要解决的核心矛盾:共享的数据 + 个体的视图状态。
值得注意的是,graph/GuidelinesGraph.md 明确将 Viewpoint 模式 收录进"Recommended API design patterns"推荐模式目录,其定位是"Model user specific properties for a shared resource",即它是 Microsoft Graph API 评审中用于达成一致的正式模式之一,而不是随意约定的临时做法。
二、解决方案:用 viewpoint 结构化属性建模单用户上下文
2.1 模式的核心思想
Viewpoint 模式的解决方案非常明确:在 API 实体类型上增加一个名为 viewpoint 的结构属性(structural property),用它承载"该共享资源针对当前调用用户的个性化状态"。
例如,viewpoint 属性可以指示一条消息对于给定用户而言是否:
- 已读(read)
- 已删除(deleted)
- 已标记(flagged)
这个命名约定是跨服务一致的:无论开发者调用的是 Microsoft 365 中的哪个服务、哪个功能,只要看到 viewpoint 这个结构化属性,就表示它代表"类型特定的用户上下文(type specific user context)"。一致性命名带来的直接收益是 SDK 生成、文档工具和开发者认知成本的降低。
2.2 状态由谁维护:服务端持有,客户端变简单
模式强调该设计简化了 API 客户端逻辑:
- 状态转移细节(何时从未读变为已读、何时被删除)被隐藏在服务端;
- 状态持久化(state persistency)由服务端完成;
- 客户端无需自行维护跨设备的状态同步。
从模式设计哲学看,这与 graph/GuidelinesGraph.md 中"API-first design approach"一致:先定义接口契约,让客户端基于契约工作,实现细节由后端服务承担。原文同时指出,为支持针对用户状态的查询,viewpoint 属性应当支持过滤(should support filtering),即客户端应能通过 OData $filter 基于视点字段筛选资源,这与 graph/articles/collections.md 中关于 $filter 的通用查询能力要求是衔接的。
2.3 客户端侧的备选方案及其缺陷
原文还对比了另一种设计思路:把用户状态存在客户端本地。这种做法的缺陷在现实场景中很明显:
- 一个用户可能拥有多台设备(PC、手机、平板……);
- 跨设备同步这些状态成本高、容易不一致。
因此客户端本地存储只适合极简场景,作为共享资源的通用建模方案并不合适——服务端集中管理才是 Viewpoint 模式的默认选择。
三、关键设计要点与取舍(Issues and Considerations)
3.1 应用权限下 viewpoint 为 null
由于 viewpoint 反映的是某一个具体用户的上下文,当 API 以**应用权限(application permissions)**调用、而非以用户身份(delegated permissions)调用时,不存在"当前用户"这一概念,此时 viewpoint 属性值为 null。
这一语义对所有实现者都是硬约束:客户端在应用权限模式下读取该属性时,必须做好空值处理;这同时与仓库中 graph/articles/nullable.md 对可空属性的定义相呼应——可空仅表示属性值可能为 null,并不意味着创建实体时该属性可缺省。
3.2 计算型 viewpoint:使用 OData 注解
某些情况下,viewpoint 不必显式存储,而是由服务端在请求时计算得出。此时 API 生产者(producer)应添加 OData 注解,为下游工具(SDK 生成器、文档生成器等)提供额外信息。原文给出的注解示例如下:
<Annotations Target="microsoft.graph.approvalItem/viewPoint">
<Annotation Term="Org.OData.Core.V1.Computed" Bool="true" />
</Annotations>
Org.OData.Core.V1.Computed 注解向工具链声明:该属性的值由服务端计算产生、不应期望客户端写入。这与仓库中 graph/patterns/default-properties.md 的做法形成对照——那里同样通过注解与 $select 机制告诉调用方响应中省略了哪些属性,说明注解是 Microsoft Graph 元数据(CSDL)中传递"属性行为语义"的标准手段。
3.3 更新带副作用:优先考虑 OData action
更新 viewpoint 往往不只是"改一个字段",而可能触发副作用(side effect)——例如把聊天标记为已读可能同时更新 lastMessageReadDateTime、清空未读计数、触发通知等。原文给出两条可选路径:
- OData action:适合更新伴随明显副作用、涉及服务端复杂业务逻辑的场景;
- PATCH 方法:适合仅做简单字段更新的用户场景。
这与仓库中 graph/patterns/operations.md 的判断标准完全一致:当一个变更"无法用 HTTP 方法有效建模、或涉及多个资源的复合业务逻辑"时,应使用函数(无副作用)或 action(有副作用)来建模。原文示例中的 markChatReadForUser action 正是这种"副作用型操作"的落地体现。
四、实战示例一:在 CSDL 中定义 viewpoint 属性
原文以 chat 实体(一个或多个参与者之间的 chatMessage 集合)为例,演示完整的类型定义。chatViewpoint 是一个复杂类型(ComplexType),作为 chat 实体上的一个结构属性出现——注意这里正是 graph/GuidelinesGraph.md 所提倡的:复杂类型适合表示实体的复合属性,而实体类型才拥有可寻址的键。
<ComplexType Name="chatViewpoint" >
<Property Name="isHidden" Type="Edm.Boolean" />
<Property Name="lastMessageReadDateTime" Type="Edm.DateTimeOffset" />
</ComplexType>
<EntityType Name="chat" BaseType="graph.entity" >
<Property Name="chatType" Type="graph.chatType" Nullable="false" />
<Property Name="createdDateTime" Type="Edm.DateTimeOffset" />
<Property Name="lastUpdatedDateTime" Type="Edm.DateTimeOffset" />
<Property Name="topic" Type="Edm.String" />
<Property Name="viewpoint" Type="graph.chatViewpoint" />
...
<NavigationProperty Name="tabs" Type="Collection(graph.teamsTab)" ContainsTarget="true" />
</EntityType>
对定义细节的进一步解读:
chatViewpoint复杂类型中isHidden(Edm.Boolean)表示当前用户是否隐藏了该聊天,lastMessageReadDateTime(Edm.DateTimeOffset)表示当前用户最后读取消息的时间点;- 命名遵循 graph/GuidelinesGraph.md 的命名规范:属性名使用小驼峰(lower camel case),时间属性以
DateTime后缀结尾; chat实体的chatType属性标记为Nullable="false",而viewpoint属性未标记非空——按照 3.1 节语义,正是因为它依赖"当前用户上下文",在应用权限下必须允许为 null;- 复杂类型(而非实体类型)的定位意味着
chatViewpoint没有独立键、不能通过 URI 单独寻址,它始终作为chat实体的组成部分出现。
五、实战示例二:为指定用户读取带视点的资源
定义完成后,客户端即可在"用户上下文"中读取集合,每个元素都会携带针对该用户的 viewpoint。原文示例:
GET https://graph.microsoft.com/v1.0/users/8b081ef6-4792-4def-b2c9-c363a1bf41d5/chats
HTTP/1.1 200 OK
Content-type: application/json
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#chats",
"@odata.count": 3,
"value": [
{
"id": "19:meeting_MjdhNjM4YzUtYzExZi00OTFkLTkzZTAtNTVlNmZmMDhkNGU2@thread.v2",
"topic": "Meeting chat sample",
"createdDateTime": "2020-12-08T23:53:05.801Z",
"lastUpdatedDateTime": "2022-12-08T23:58:32.511Z",
"chatType": "meeting",
"viewpoint":{
"lastMessageReadDateTime": "2021-03-28T21:10:00.000Z"
}
},
{
"id": "19:561082c0f3f847a58069deb8eb300807@thread.v2",
"topic": "Group chat sample",
"createdDateTime": "2020-12-03T19:41:07.054Z",
"lastUpdatedDateTime": "2020-12-08T23:53:11.012Z",
"chatType": "group",
"viewpoint":{
"lastMessageReadDateTime": "0000-01-01T00:00:00.000Z"
}
}
]
}
解读该响应:
id是聊天的稳定标识(符合 graph/articles/collections.md 中"集合元素必须可由唯一 id 寻址、id 为字符串"的要求);- 每个
chat元素都携带一个viewpoint对象,只出现有值的字段(isHidden为 false 时未序列化,符合按需返回的常见做法); - 第一个聊天的
lastMessageReadDateTime为真实时间(该用户读过),第二个群聊的值是0000-01-01T00:00:00.000Z(该用户从未读过——这正是一个"类型特定用户上下文"状态的典型表达); @odata.count与@odata.context遵循 OData v4 约定,帮助客户端理解响应结构。
正是这种"同一聊天对不同用户返回不同 viewpoint"的响应形态,让客户端可以用完全一致的代码逻辑处理个性化展示(例如未读角标、隐藏列表项),而无需自行跟踪跨设备状态。
六、实战示例三:用 OData action 更新视点(副作用场景)
当更新 viewpoint 伴随副作用时,推荐使用绑定在实体上的 OData action。原文以"将聊天标记为当前用户已读"为例:
POST https://graph.microsoft.com/beta/chats/19:7d898072-792c-4006-bb10-5ca9f2590649_8ea0e38b-efb3-4757-924a-5f94061cf8c2@unq.gbl.spaces/markChatReadForUser
{
"user": {
"id" : "d864e79f-a516-4d0f-9fee-0eeb4d61fdc2",
"tenantId": "2a690434-97d9-4eed-83a6-f5f13600199a"
}
}
服务端响应成功状态码且不带负载:
HTTP/1.1 204 No Content
对这次调用的技术要点拆解:
- 使用
POST动词调用 action,符合 graph/patterns/operations.md 中"调用 action 必须使用 POST、调用函数必须使用 GET"的硬性要求; - action 名称
markChatReadForUser本身就是"操作"语义而非"资源"语义,因为该动作除了更新viewpoint.lastMessageReadDateTime,还会联动未读计数等隐藏状态,属于典型的服务端副作用操作; - 请求体通过
user对象显式指定目标用户(id+tenantId),这是因为该操作可能需要以某用户身份执行或代表某用户执行,这与 3.1 节"应用权限下无当前用户上下文"的语义形成闭环——当没有隐式的"当前用户"时,就需要显式传递用户身份; - 返回
204 No Content表明操作完成且无需返回实体内容,是副作用型 action 的常见响应形态。
七、实战示例四:用 PATCH 方法更新视点(简单更新场景)
对于只涉及简单字段更新的场景,PATCH 可能是比 action 更合适的方式。原文给出了一个纯示意性(不代表真实 API)的例子——为用户把某个 topic 的 viewpoint 标记为"已复核":
PATCH https://graph.microsoft.com/beta/sampleTopics/19:7d898072-792c-4006-bb10-5ca9f259
{
"title": "Announcements: Changes to PowerPoint and Word to open files faster",
...
"viewpoint": {
"isReviewed" : "true"
}
}
服务端响应成功状态码且不带负载:
HTTP/1.1 204 No Content
需要特别说明的是:
- 该例中的
sampleTopics与isReviewed均为示意字段,原文明确标注"this example does not represent an actual API, but only an illustration",读者不应照搬为真实 Graph API; - 语义上的要点是:当视点更新不伴随复杂副作用时,
PATCH允许客户端只提交viewpoint子对象中的变更字段(这里只改isReviewed,title等其余字段不受影响),符合 graph/GuidelinesGraph.md 中"必须用 PATCH 编辑可更新资源"的行为建模规范; - 相比 action,
PATCH路径更轻量:无额外参数结构、直接按属性路径寻址,适合客户端对"一个布尔型视点标记"这类简单状态翻转的场景。
八、选型指南:action 还是 PATCH?
综合原文与仓库模式库,可以总结出如下决策线索(这也是实现者在 API 评审中需要向审查组说明的理由):
| 维度 | OData action(如 markChatReadForUser) |
PATCH 方法 |
|---|---|---|
| 副作用 | 更新可能联动多个隐藏状态(未读计数、通知等) | 仅更新提交的字段 |
| 参数 | 可携带结构化参数(如目标 user 对象) |
直接在请求体属性中表达 |
| 适用场景 | 复杂业务逻辑、需要服务端权威计算 | 简单字段翻转、客户端直达 |
| 依据 | graph/patterns/operations.md | graph/GuidelinesGraph.md 行为建模规则 |
判断口诀:"改一个字段用 PATCH,改出一个连锁反应用 action"——这正是 Viewpoint 模式留给实现者的核心决策点。
九、与相邻模式的协同关系
Viewpoint 模式并非孤立存在,它与仓库模式库中的多个模式互补:
- Operations 模式:为
markChatReadForUser这类副作用型视点更新提供 action/函数建模规范,两者的判断标准(副作用、服务端逻辑)完全一致; - Default properties 模式:当
viewpoint为计算型或开销较大时,可结合$select与实例注解控制默认响应,避免为所有共享资源默认计算视点; - Nullable 属性指南:支撑"应用权限下
viewpoint为 null"以及复杂类型中各字段可空性的语义理解; - 集合指南:支撑视点模式下按用户读取聊天集合时的 id、分页、
$filter等查询行为。
在 graph/GuidelinesGraph.md 的模式总表中,Viewpoint 模式被明确列为"Model user specific properties for a shared resource",与 Alternate key、Change tracking、Evolvable enums 等模式并列,共同构成 Microsoft Graph 服务的 API 设计工具箱。
十、总结
Viewpoint 模式为"共享资源 + 个体用户上下文"这一高频协作场景提供了简洁、一致、可查询的建模范式:
- 建模:在共享实体上声明
viewpoint结构属性(通常是复杂类型),用isHidden、lastMessageReadDateTime等字段表达用户个体状态; - 读取:以用户上下文(delegated)调用集合接口,每个元素自动携带该用户的
viewpoint;以应用权限调用时该属性为 null; - 更新:简单状态变更用
PATCH,伴随副作用的变更用绑定 action(POST调用); - 扩展:服务端计算的视点属性通过
Org.OData.Core.V1.Computed注解向 SDK 与文档工具声明语义。
无论你是 Microsoft 365 服务的 API 生产者,还是在 Graph 之上构建协作应用的客户端开发者,遵循这一模式都能让"同一资源、千人千面"的状态处理变得可预期、可维护。仓库中的 graph/patterns/viewpoint.md 是权威原文,配合 graph/GuidelinesGraph.md 的总纲阅读,可以完整理解其在 Microsoft Graph API 设计体系中的位置。