Microsoft Graph Viewpoint 模式:为共享资源建模单用户上下文(Viewpoint Pattern 设计指南)

原创2026-09-30 13:27:29245 阅读
文章标签:API设计

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、清空未读计数、触发通知等。原文给出两条可选路径:

  1. OData action:适合更新伴随明显副作用、涉及服务端复杂业务逻辑的场景;
  2. 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 模式为"共享资源 + 个体用户上下文"这一高频协作场景提供了简洁、一致、可查询的建模范式:

  1. 建模:在共享实体上声明 viewpoint 结构属性(通常是复杂类型),用 isHidden、lastMessageReadDateTime 等字段表达用户个体状态;
  2. 读取:以用户上下文(delegated)调用集合接口,每个元素自动携带该用户的 viewpoint;以应用权限调用时该属性为 null;
  3. 更新:简单状态变更用 PATCH,伴随副作用的变更用绑定 action(POST 调用);
  4. 扩展:服务端计算的视点属性通过 Org.OData.Core.V1.Computed 注解向 SDK 与文档工具声明语义。

无论你是 Microsoft 365 服务的 API 生产者,还是在 Graph 之上构建协作应用的客户端开发者,遵循这一模式都能让"同一资源、千人千面"的状态处理变得可预期、可维护。仓库中的 graph/patterns/viewpoint.md 是权威原文,配合 graph/GuidelinesGraph.md 的总纲阅读,可以完整理解其在 Microsoft Graph API 设计体系中的位置。

登录后查看全文
api-guidelines