Bitwarden server 推送通知系统深度解析:从 PushType 枚举到四种 IPushEngine 投递引擎
2026/9/13 11:43:58 网站建设 项目流程

Bitwarden server 推送通知系统深度解析:从 PushType 枚举到四种 IPushEngine 投递引擎

【免费下载链接】serverBitwarden infrastructure/backend (API, database, Docker, etc).项目地址: https://gitcode.com/GitHub_Trending/ser/server

本文基于 Bitwarden server 仓库中 Push 模块设计文档 及其源码实现,完整讲解这套面向终端用户设备的推送通知框架:如何构造并发送一条PushNotification<T>、如何安全地扩展新的通知类型、四种IPushEngine投递引擎(Azure Notification Hub、Azure Queue、Relay、Notifications API)各自的适用场景与依赖注入注册条件,以及自托管与云托管两种部署形态下的完整通知投递链路。读完后你将在当前仓库中具备"新增一种推送通知类型"所需的完整实操能力,并理解每条通知从 API 容器最终抵达手机或浏览器客户端的底层路径。

1. Push 是什么:核心定位与消息模型

Push 是 Bitwarden server 中用于向终端用户设备发送信息包(packet)的功能。它的典型用途是告诉设备"有新的信息需要主动拉取"或"你发起的某个请求刚刚被接受"。例如密码库条目被创建/更新/删除、组织密钥变更、登录被请求(Auth Request)、账户需要登出等,服务端都会通过这些通知让各端客户端及时同步。

整个框架的消息模型由以下几个核心类型构成,全部位于src/Core/Platform/Push/目录:

类型文件职责
PushNotification<T>PushNotification.cs承载一条通知的全部信息,泛型T为负载类型
PushTypePushType.cs通知类型枚举(byte),决定客户端侧路由到哪个处理器
NotificationTargetNotificationTarget.cs通知目标枚举:User/Organization/Installation
NotificationInfoAttributeNotificationInfoAttribute.cs标注每个PushType的负责团队与预期负载类型
IPushNotificationServiceIPushNotificationService.cs对外统一的发送入口
IPushEngineIPushEngine.cs各具体投递通道的引擎抽象

NotificationTarget定义了三种可用目标:

  • User:通知目标是单个用户,TargetId填用户 ID;
  • Organization:通知目标是组织内所有用户,TargetId填组织 ID;
  • Installation:通知目标是该安装(installation)下的所有组织及组织内所有用户,TargetId填 installation ID。

2. 发送一条推送:IPushNotificationService.PushAsync用法

日常使用中,只需注入 IPushNotificationService 并调用其PushAsync方法,传入一个PushNotification<T>。以向某用户的所有设备发送"请求已被接受"通知为例:

// This would send a notification to all the devices of the given `userId`. await pushNotificationService.PushAsync(new PushNotification<MyPayload> { Type = PushType.MyNotificationType, Target = NotificationTarget.User, TargetId = userId, Payload = new MyPayload { Message = "Request accepted", }, ExcludeCurrentContext = false, });

结合 PushNotification.cs 的源码,各字段含义如下:

  • Type(required)PushType枚举值。它用于把通知路由到客户端对应的处理器,务必让负载类型与PushType关联的预期类型一致——这一点由PushType成员上的[NotificationInfo]特性以"强制文档"的方式约束(见下文第 4 节)。
  • Target/TargetId(required):目标实体类型及其 ID。PushNotification内部通过GetTargetWhen(NotificationTarget)辅助方法按目标类型取值:例如目标为User时取TargetId作为用户 ID,目标为Organization时作为组织 ID,这在 Relay 引擎构造请求体时被用到(见 RelayPushEngine.cs)。
  • Payload(required):随通知发送的负载,会被 JSON 序列化,因此负载类型必须可 JSON 往返(roundtrip)。
  • ExcludeCurrentContext(required):为true时通知不携带当前上下文标识符,这意味着"该通知可能恰恰由发起它的那个设备触发处理"。各引擎在ExcludeCurrentContexttrue时会从ICurrentContext中读取DeviceIdentifier写入通知(见 AzureQueuePushEngine.cs 的GetContextIdentifier),客户端据此排除自己。
  • ClientType(可选):通知应发往的客户端类型,为null时推断为ClientType.All
  • NonMobileOnly(可选,临时属性):源码注释明确说明这是一个与 feature flag 绑定的临时属性,为true时只有非移动端引擎(SignalR/Web/桌面)会投递该通知,[EditorBrowsable(Never)]标记也表明它不应被新代码使用(PushNotification.cs)。

另外注意接口文档中的一个重要语义:PushAsync返回的Task不保证在任务完成时通知已经真正送达——这与第 6 节中"release 构建不等待引擎"的实现直接相关。

IPushNotificationService的 XML 注释还给出了架构约定:新通知不应在这个服务内部接线(wire up);你可以直接调用PushAsync,也可以基于它编写带强类型定义的扩展方法,或自己封装一个注入该服务的领域服务。接口上InstallationIdTimeProviderLogger三个成员都标注了[Obsolete("BWP0001")],进一步强调业务方应走自己的服务而不是依赖这些暴露出来的成员。

3. 扩展框架:如何新增一个通知类型

README 的 "Extending" 一节定义了向框架新增自有通知类型的标准流程,源码与测试共同构成了这套流程的约束:

3.1 第一步:给PushType枚举加成员

打开 PushType.cs,新增一个枚举成员,数值取当前最大值加 1,并必须用[NotificationInfo]特性标注负责团队和预期负载类型。当前枚举从SyncCipherUpdate = 0PremiumStatusChanged = 27共 28 个成员,例如现有的写法:

[NotificationInfo("@bitwarden/team-billing-dev", typeof(Billing.Models.PremiumStatusPushNotification))] PremiumStatusChanged = 27,

NotificationInfoAttribute有两个构造重载:一个接受Type,一个接受负载类型的完整类型名字符串。接受字符串重载是刻意设计的——它允许团队为推送类型声明一个位于其他程序集中的负载类型而不必新增 using。属性注释中写明:当前它只作为"强制文档"存在,未来计划交给 C# analyzer 校验PushAsync调用点的负载类型是否正确。

3.2 规则由单元测试强制执行

PushType的三条硬性规则全部由 PushTypeTests.cs 中的单元测试守护:

  1. 数值唯一AllEnumMembersHaveUniqueValue):不允许两个成员复用同一个 byte 值;
  2. 必须标注特性AllEnumMembersHaveNotificationInfoAttribute):每个成员都必须有[NotificationInfo("team-name", typeof(MyType))]
  3. 数值必须连续AllEnumValuesAreInSequence):如果上一个最大定义是 22,下一个必须用 23,不允许跳号——这正是 README 所说"Assign a number that is 1 above the next highest value"的机器化保证。

3.3 第二步:在 HubHelpers 中补充分发逻辑

新增通知类型后,还需要在 HubHelpers(Notifications 服务中)添加代码,读取你的负载体并决定把通知发给哪个用户或哪个组。从源码结构看,SendNotificationToHubAsyncPushType做大 switch:例如SyncCipherUpdate/SyncCipherCreate/SyncCipherDelete分支会反序列化为SyncCipherPushNotification,若负载里有UserId就走_hubContext.Clients.User(...)发给该用户,若有OrganizationId就走Clients.Group(GetOrganizationGroup(...))发给组织对应的 SignalR 组(HubHelpers.cs)。这是 Web/桌面/浏览器端经 SignalR 收到通知的实际分发点。

3.4 测试与负载设计的两条约定

  • 不要在任何IPushEngine实现中为你具体的通知类型添加测试。这些引擎目前虽然还覆盖了许多通知类型的测试,但那些测试后续会被删除,且无需新增。
  • 由于自托管用户(如果选择加入)的通知会经由 Bitwarden 云实例中转,负载信息应保持最小化。最佳实践是只发送相关实体的 ID——这些 ID 对云端毫无意义,但设备收到通知后可凭 ID 拉取更详细的信息。

4. 核心机制:请求如何被散射到所有IPushEngine

README "Implementations" 一节的机制描述可以精确对应到 MultiServicePushNotificationService.cs。该服务是 DI 中IPushNotificationService的唯一默认实现(由 PushServiceCollectionExtensions.cs 注册),构造时注入当前应用中所有已注册的IPushEngine

// Filter out any NoopPushEngine's _services = [.. services.Where(engine => engine is not NoopPushEngine)];

PushAsync调用时,PushToServices把同一条通知扇出(scatter)给每个引擎;若没有任何可用引擎,仅记录一条 "No services found to push notification" 警告并返回。源码中还能看到 README 所描述的"release 构建不等待引擎"的实现细节:

#if DEBUG var task = pushFunc(service); tasks.Add(task); #else pushFunc(service); #endif ... #if DEBUG return Task.WhenAll(tasks); #else return Task.CompletedTask; #endif

也就是说:DEBUG 构建下PushAsyncTask.WhenAll等待所有引擎完成,便于开发联调;release 构建下不 await 任何引擎,立即返回Task.CompletedTask——这解释了接口上"返回的 Task 不保证通知已送达"的语义。NoopPushEngine 作为内部空实现被过滤,保证未配置任何真实通道时框架依然安全可用。

5. 四种IPushEngine实现及其注册条件

四种引擎的源码均位于src/Core/Platform/Push/Engines/src/Core/Platform/Push/NotificationHub/目录,而"什么条件下注册哪个引擎"的完整判据在 AddPush 扩展方法中:

5.1 Azure Notification Hub(云托管,移动端 + Web Push)

  • 适用场景:应用由 Bitwarden 云托管时使用。通知被发送到 Azure Notification Hub(ANH),借助 ANH 与移动端推送系统的联邦能力触达移动客户端,同时服务于配置了 Web Push 的客户端(当前是 Chrome 扩展)。
  • 注册条件:云托管(非 SelfHosted)分支下无条件注册NotificationHubPushEngine(配套NotificationHubPool单例),并额外注册IPushRelayer;该实现假定云端运行时配置必然可用。

5.2 Azure Queue(云托管,SignalR/Web Sockets)

  • 适用场景:云托管环境下经 WebSocket(SignalR)投递通知。引擎把通知写入名为notifications的 Azure Queue,该队列由 Notifications 服务消费,再发送到 SignalR hub,使通过持久 WebSocket 连接到通知服务的客户端收到通知。
  • 注册条件GlobalSettings:Notifications:ConnectionString有值时注册。源码中对应地注册了一个 keyedQueueClient(key 为"notifications"),AzureQueuePushEngine 通过[FromKeyedServices("notifications")]注入它,并把PushNotificationData<T>序列化为 JSON 后SendMessageAsync入队。
  • 注意:此引擎与Installation.Id是否配置相关——未设置 Installation ID 时只会记录警告日志(AzureQueuePushEngine.cs)。

5.3 Relay(自托管,经云中转)

  • 适用场景:自托管实例使用。由于自托管实例无法直接向移动设备发推送,该引擎把通知从自托管实例中继(relay)到 Bitwarden 云实例,云端接收后再转发给 Azure Notification Hub。
  • 注册条件GlobalSettings:PushRelayBaseUriGlobalSettings:Installation:Key同时有值时注册。RelayPushEngine 继承BaseIdentityClientService,携带ApiScopes.ApiPush作用域、以installation.{InstallationId}作为客户端身份向云 API 的push/send端点发起 POST(该端点即 PushController)。
  • 细节:构造PushSendRequestModel<T>时,用GetTargetWhen按三种NotificationTarget分别取UserId/OrganizationId/InstallationId,并把当前设备的DeviceIdentifier与查询到的DeviceId一并写入请求;当NonMobileOnly == true时直接跳过(该引擎负责移动端通道)。

5.4 Notifications API(自托管,直连 Notifications 服务)

  • 适用场景:自托管实例使用。引擎向自托管的 Notifications 服务发 API 请求,后者收到后经 SignalR hub 发送通知。这与云端的 Azure Queue 路径非常相似,但不要求自托管客户自己搭建队列基础设施。
  • 注册条件GlobalSettings:InternalIdentityKeyGlobalSettings:BaseServiceUri:InternalNotifications有值时注册。这两个设置在受支持的 Bitwarden 部署中通常自动配置。NotificationsApiPushEngine 基于内部身份(internal.{ProjectName}客户端身份)向内部端点send发起 POST,负载同样是PushNotificationData<T>
  • 补充AddPush入口处还有一个前置校验——自托管模式下Installation.Id不能为空,否则直接抛出InvalidOperationException("Installation Id must be set for self-hosted installations.")

汇总注册判据(与 PushServiceCollectionExtensions.cs 一一对应):

引擎部署形态注册条件
NotificationHubPushEngine云托管无条件(非 SelfHosted 分支)
AzureQueuePushEngine云托管GlobalSettings:Notifications:ConnectionString有值
RelayPushEngine自托管PushRelayBaseUri+Installation:Key有值
NotificationsApiPushEngine自托管InternalIdentityKey+BaseServiceUri:InternalNotifications有值

相关配置项定义可见 GlobalSettings.cs(PushRelayBaseUriInternalIdentityKeyBaseServiceUri.InternalNotificationsNotifications等成员)。

6. 新增NotificationTarget为什么更难

README "Adding new notification targets" 一节指出:NotificationTarget是定义通知可用目标的枚举,但新增一个目标不是加个枚举成员那么简单——各IPushEngine实现是否需要感知某个目标类型各不相同,例如 ANH 实现依赖它来构造 tag 查询(tag query),因此新目标必须能"通过 tag 查询表达"。

文档给了一个典型反例:某团队想新增"组织中已验证邮箱的用户"这一目标。但今天这在目标层面无法表达——因为设备向 ANH 注册时并不携带"该用户是否已验证邮箱"。虽然理论上可以开始记录并同步这一信息(用户验证邮箱时更新推送注册),但追踪该信息并更新推送注册的成本需要与该通知的发送频率做权衡。更划算的替代方案通常是:由需要的团队自行查询出符合条件的用户,再逐个用NotificationTarget.User发送。如果此类需求足够多,官方考虑的方案是给IPushNotificationService增加一个BulkPushAsync方法。

NotificationTarget.cs 的源码注释也把协作边界写明了:"Please reach out to the Platform team if you need a new target added."——新增目标是需要与平台团队沟通的架构级变更,而非自助操作。

7. 两种部署形态下的完整投递链路

README 末尾的两张 mermaid 图清晰刻画了自托管与云托管的通知链路,此处完整保留:

7.1 自托管链路(Self-host)

自托管实例中,客户端动作到达 API 容器后分两路:一路经 HTTP 调用本地 Notifications 容器,再经 SignalR/Web Sockets 送达 Web、桌面、浏览器客户端;另一路(可选、可禁用)经 HTTP 调用云 Push Relay,由云端通过 ANH 库经 Firebase / APNS 触达 Android / iOS 移动端。

对应第 5 节的引擎:Notifications Container路径由NotificationsApiPushEngine驱动(RelayPushEngine对应虚线的 Cloud Push Relay 分支,可通过不配置PushRelayBaseUri禁用)。

7.2 云托管链路(Cloud)

云托管中,API 容器同时走两条独立通道:直接向 ANH 发移动端与 Web Push 通知;同时将通知入队 Azure Queue,由 Notifications 容器出队后经 SignalR 送达 Web、桌面、浏览器客户端。

对应引擎为NotificationHubPushEngine(ANH 通道)与AzureQueuePushEngine(队列通道),队列消费端在 Notifications 服务(src/Notifications/目录中的AzureQueueHostedService),最终分发逻辑落在 HubHelpers。

8. 小结与延伸阅读

  • 发送入口只有一个:注入 IPushNotificationService 调PushAsync,它会把通知散射到当前应用所有已注册的 IPushEngine(release 构建不等待送达)。
  • 扩展新通知类型是自助式的:PushType加连续编号成员 +[NotificationInfo]标注 + HubHelpers 补分发 + 负载保持最小化(只放 ID);规则由 PushTypeTests 强制。
  • 扩展新NotificationTarget平台级变更,受 ANH tag 查询表达能力约束,需要联系平台团队并权衡注册信息追踪成本。
  • 引擎选择完全由 AddPush 中的GlobalSettings配置决定:云托管默认 ANH + 可选 Azure Queue;自托管为 Relay(经云中转移动端)与 Notifications API(本地 SignalR),二者注册互不冲突、可同时启用。

可进一步深入的文件:src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs(ANH 客户端池)、src/Notifications/AzureQueueHostedService.cs(队列消费端)、src/Api/Platform/Push/Controllers/PushController.cs(云端接收 relay 请求的端点)、src/Core/Platform/PushRegistration/(设备向 ANH 注册推送的配套机制)。

【免费下载链接】serverBitwarden infrastructure/backend (API, database, Docker, etc).项目地址: https://gitcode.com/GitHub_Trending/ser/server

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询