OHIF Services 架构详解:v3 服务层、ServicesManager 注册机制与 Pub/Sub 事件通信
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
本篇基于 OHIF 官方文档 Services 总览 展开,系统讲解 OHIF v3 的「面向关注点(concern-specific)」服务架构:从ServicesManager的服务注册与工厂化创建,到内置数据服务/UI 服务清单,再到贯穿各层的发布-订阅(Pub/Sub)通信模式。读完后你将能够理解 OHIF 如何用一个可插拔的服务体系取代传统 Redux 单一状态树,并知道如何查阅每个服务对应的文档与源码实现。
一、什么是 Service:面向关注点的代码模块
文档给出的核心定义是:Services 是"concern-specific"(面向关注点)的代码模块,可以被跨层(cross-layer)消费。它们提供一组操作,通常绑定到某些共享状态之上,并通过ServicesManager在整个应用中可用。这类模块尤其适合处理横切关注点(cross-cutting concerns)。
文档同时给出了 OHIF 对每个服务的设计约束,这是判断一段代码是否适合作为服务来写的标尺:
- self-contained(自包含):服务内部维护自己的状态,不依赖外部全局状态树的细节;
- able to fail and/or be removed without breaking the application(可失败/可移除):单个服务不可用或被删除时,应用整体不崩溃;
- completely interchangeable with another module implementing the same interface(可互换):只要实现相同接口,另一个模块可以完全替换该服务。
文档还指出,OHIF-v3的一个重要架构变化是引入了大量非 UI 服务,并采用pub/sub(发布-订阅)模式来降低各层之间的耦合。关于 Pub/Sub 的独立章节见 发布-订阅模式文档;数据服务的整体定位(用服务 + pub/sub 取代 redux 单一 store)在 数据服务总览 中有专门说明。
二、ServicesManager:唯一的注册入口
文档指出服务"通过ServicesManager在应用各处可用"。在源码中,这一角色由 ServicesManager 类 承担,它是所有服务的单点注册入口(single point of service registration)。其公开方法只有两个:registerService(注册单个服务,可带配置)与registerServices(批量注册)。
从 ServicesManager.ts 的实现可以看到注册流程的几个关键设计:
空值与命名校验:
registerService会拒绝null/undefined的服务、缺少name属性的服务,并对重名服务打印告警后提前退出,保证registeredServiceNames中每个服务名唯一;工厂函数
create:服务本身必须提供create方法。ServicesManager调用它并注入四个上下文:this.services[service.name] = service.create({ configuration, // 注册时传入的配置 extensionManager: this._extensionManager, commandsManager: this._commandsManager, servicesManager: this, // 服务可反向访问管理器 });也就是说,每个服务实例在被创建时就能拿到命令管理器、扩展管理器和整个服务管理器,这是服务之间协作而不直接 import 依赖的关键通道。若服务未定义
create,则打印Service create factory function not defined告警并跳过;批量注册支持配置对:
registerServices接受「服务数组」或「[服务, 配置]元组数组」混合形式,元组形式允许为服务单独注入配置。
默认注册的服务清单
应用启动时的批量注册发生在 appInit.js。当前仓库中的默认注册列表为:
servicesManager.registerServices([ [MultiMonitorService.REGISTRATION, appConfig.multimonitor], UINotificationService.REGISTRATION, UIModalService.REGISTRATION, UIDialogService.REGISTRATION, UIViewportDialogService.REGISTRATION, MeasurementService.REGISTRATION, DisplaySetService.REGISTRATION, [CustomizationService.REGISTRATION, appConfig.customizationService], ToolbarService.REGISTRATION, ViewportGridService.REGISTRATION, HangingProtocolService.REGISTRATION, CineService.REGISTRATION, UserAuthenticationService.REGISTRATION, PanelService.REGISTRATION, WorkflowStepsService.REGISTRATION, [StudyPrefetcherService.REGISTRATION, appConfig.studyPrefetcher], ]);可以注意到两点:MultiMonitorService、CustomizationService、StudyPrefetcherService使用[服务, 配置]元组形式,从appConfig中取各自的配置对象(appConfig.multimonitor、appConfig.customizationService、appConfig.studyPrefetcher);其余服务则以REGISTRATION导出常量形式直接注册。从源码结构看,当前注册列表已比文档表格覆盖的范围更广(新增了UserAuthenticationService、PanelService、WorkflowStepsService、StudyPrefetcherService、MultiMonitorService等),说明服务清单是持续演进的,文档表格可作为核心服务的最小集理解。
所有内置服务统一从 services 包入口 导出,包括ServicesManager、ServiceProvidersManager以及各具体服务与pubSubServiceInterface、PubSubService。
服务的导出结构:{ name, create }
与文档《Service Manager》章节(服务管理器文档)的说明一致,每个服务目录导出一个含name与create的包装对象,create才是实例化服务类的工厂。例如文档以ToolBarService为例:
// platform/core/src/services/ToolBarService/index.js import ToolBarService from './ToolBarService'; export default { name: 'ToolBarService', create: ({ configuration = {}, commandsManager }) => { return new ToolBarService(commandsManager); }, };创建后的服务挂载在ServicesManager的services属性上,应用与扩展代码统一通过servicesManager.services按名访问,例如:
function PanelMeasurementTableTracking({ servicesManager }) { const { MeasurementService } = servicesManager.services; // ... const measurements = MeasurementService.getMeasurements(); }在扩展中注册自定义服务
文档指出扩展的preRegistration钩子是注册自定义服务的位置(详见 服务管理器文档)。典型写法如下:
// extensions/customExtension/src/index.js import WrappedBackEndService from './services/backEndService'; export default { id: 'myExtension', preRegistration({ servicesManager }) { servicesManager.registerService(WrappedBackEndService(servicesManager)); }, };// extensions/customExtension/src/services/backEndService/index.js import BackEndService from './BackEndService'; export default function WrappedBackEndService(servicesManager) { return { name: 'backEndService', create: ({ configuration = {} }) => { return new BackEndService(servicesManager); }, }; }文档同时给出了命名约定:类名为 UpperCamelCase(如BackEndService),服务注册名为 lowerCamelCase(如backEndService),服务类型应从模块的 Types 导出。
三、内置服务清单(文档表格完整版)
原文档以服务表格形式列出了OHIF-v3可用服务,按「DataService / UI Service / Segmentation Service」三类划分。下表完整保留该清单,并将原文档的相对链接转换为从仓库根目录出发的路径:
| 服务 | 类型 | 文档页面 |
|---|---|---|
| DicomMetadataStore | Data Service | DicomMetadataStore.md |
| DisplaySetService | Data Service | DisplaySetService.md |
| segmentationService | Segmentation Service | SegmentationService.md |
| HangingProtocolService | Data Service | HangingProtocolService.md |
| MeasurementService(文档标注 MODIFIED) | Data Service | MeasurementService.md |
| ToolBarService | Data Service | ToolbarService.md |
| ViewedDataService | Data Service | ViewedDataService.md |
| ViewportGridService | UI Service | viewport-grid-service.md |
| Cine Service | UI Service | cine-service.md |
| CustomizationService | UI Service | customizationService.md |
| UIDialogService | UI Service | ui-dialog-service.md |
| UIModalService | UI Service | ui-modal-service.md |
| UINotificationService | UI Service | ui-notification-service.md |
| UIViewportDialogService | UI Service | ui-viewport-dialog-service.md |
文档目录下还有 数据服务总览 与 UI 服务总览 两个分类入口,以及 pubsub.md 专门讲解发布-订阅模式。核心服务的源码实现集中在 platform/core/src/services 目录,每个服务一个子目录(如 DicomMetadataStore、DisplaySetService、HangingProtocolService、MeasurementService、ViewportGridService、CineService、CustomizationService 等),共享基础设施则放在 _shared 目录。需要留意的是,文档表格中列出、但当前 core 注册列表中未直接出现的部分服务(如 ViewedDataService、SegmentationService),其文档页仍保留在上述 services 文档目录下;查阅具体行为时,建议以文档页 + 对应源码目录为准。
四、Pub/Sub 事件驱动:服务间解耦通信的底层实现
文档强调 v3 引入 pub/sub 模式「减少层与层之间的耦合」。其具体实现在 pubSubServiceInterface.ts,所有需要对外广播事件的服务都消费同一套接口。使用方需实现两个约定:
this.listeners = {}; // 事件名 -> 监听器数组 this.EVENTS = { "EVENT_KEY": "EVENT_VALUE" }; // 白名单式事件定义核心 API
subscribe(eventName, callback):校验事件名是否在EVENTS白名单内(_isValidEvent检查Object.values(this.EVENTS)),为监听器分配guid()生成的唯一listenerId,返回{ unsubscribe }对象;订阅了未定义的事件会直接抛出Event ${eventName} not supported.;_unsubscribe(eventName, listenerId):按 id 过滤移除监听器,移除时还会调用callback?.clearDebounceTimeout?.()清理挂起的防抖定时器,避免组件销毁后回调仍被触发;_broadcastEvent(eventName, callbackProps):双通道广播——既把事件封装成CustomEvent派发到document.body(供 DOM 层/外部系统监听),又遍历this.listeners[eventName]逐个同步调用回调;PubSubService类封装:在裸接口之外提供类式封装,并扩展了 两个实用能力:subscribeDebounced(eventName, callback, wait = 300, immediate = false):内置防抖订阅,用于限制高频事件(如体数据滚动、窗宽窗位连续变化)的回调执行频率,wait默认 300ms,immediate控制前缘/后缘触发;reset():批量执行已记录的unsubscriptions并清空listeners;createConsumableEvent(props):生成带isConsumed标志与consume()方法的事件对象,用于「事件是否被某消费方处理过」的一次性消费语义。
以 DicomMetadataStore 为例,其事件定义即为该白名单模式:
const EVENTS = { STUDY_ADDED: 'event::dicomMetadataStore:studyAdded', INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded', SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded', SERIES_UPDATED: 'event::dicomMetadataStore:seriesUpdated', };典型订阅链路:defaultRouteInit
Pub/Sub 文档 给出了Mode.jsx中默认初始化流程的订阅示例,它完整展示了「数据流靠事件串联」的 v3 工作方式:
async function defaultRouteInit({ servicesManager, studyInstanceUIDs, dataSource }) { const { DisplaySetService, HangingProtocolService } = servicesManager.services; const unsubscriptions = []; // 实例元数据到达 -> 由 DisplaySetService 构建 display sets const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.INSTANCES_ADDED, ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) => { const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID); DisplaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient); } ); unsubscriptions.push(instanceAddedUnsubscribe); studyInstanceUIDs.forEach(StudyInstanceUID => { dataSource.retrieve.series.metadata({ StudyInstanceUID }); }); // 序列元数据到达 -> 触发挂起协议引擎 const { unsubscribe: seriesAddedUnsubscribe } = DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.SERIES_ADDED, ({ StudyInstanceUID }) => { HangingProtocolService.run({ studies, displaySets, activeStudy }); } ); unsubscriptions.push(seriesAddedUnsubscribe); return unsubscriptions; }这条链路中,DicomMetadataStore(数据服务)不直接调用DisplaySetService,也不直接驱动HangingProtocolService;两者只是事件的订阅方,实现了文档所说的「层间解耦」。
退订(Unsubscription)是必须的
文档专门用一节强调:每个subscribe都会返回一个 unsubscription 函数,必须在组件/路由销毁时执行,否则同一观察者上会累积多个订阅,导致重复执行。Mode.jsx的简化写法是标准范式:
export default function ModeRoute(/**..**/) { useEffect(() => { DisplaySetService.init(extensionManager, sopClassHandlers); extensionManager.onModeEnter(); mode?.onModeEnter({ servicesManager, extensionManager }); const setupRouteInit = async () => { if (route.init) { return await route.init(/**...**/); } return await defaultRouteInit(/**...**/); }; let unsubscriptions; setupRouteInit().then(unsubs => { unsubscriptions = unsubs; }); return () => { extensionManager.onModeExit(); mode?.onModeExit({ servicesManager, extensionManager }); unsubscriptions.forEach(unsub => { unsub(); }); // 销毁时全部退订 }; }); return <> /**...**/ </>; }五、服务与 Mode 的生命周期契约
服务管理器文档 补充了服务生命周期约束,与「服务可失败可移除」的设计原则相呼应:
- 状态一致性契约:服务在「首次进入某个 mode」与「退出后再次进入该 mode」时所处状态应当一致。若 mode 需要跨次保留数据(如测量值),应由 mode 在
onModeExit中存盘、onModeEnter中恢复——是否应用缓存状态由 mode 决定,这不违反契约; onModeEnter:服务可实现该钩子以在进入 mode 前完成自身初始化,它先于 mode 自己的onModeEnter被调用;onModeExit:mode 存好持久化数据后,服务借此钩子清理自身状态。
六、延伸阅读路径
- 服务设计原理与自定义服务写法:Services Manager 文档、数据服务总览、UI 服务总览;
- 事件通信细节:Pub/Sub 文档 与源码 pubSubServiceInterface.ts;
- 注册入口实现:ServicesManager.ts、appInit.js;
- 各服务专题页:见上文第三节服务清单中的文档链接。
综合来看,OHIF v3 的服务体系可以概括为三层:ServicesManager负责注册与访问、{ name, create }工厂约定负责可插拔性、pub/sub 接口负责服务间异步通信。这三者共同支撑了文档提出的设计目标——服务自包含、可移除、可互换,使应用能够以最小耦合的方式扩展 DICOM 查看与测量、分割、挂起协议等横切能力。
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考