OHIF Services 架构详解:v3 服务层、ServicesManager 注册机制与 Pub/Sub 事件通信

OHIF Services 架构详解:v3 服务层、ServicesManager 注册机制与 Pub/Sub 事件通信 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」三类划分。下表完整保留该清单并将原文档的相对链接转换为从仓库根目录出发的路径服务类型文档页面DicomMetadataStoreData ServiceDicomMetadataStore.mdDisplaySetServiceData ServiceDisplaySetService.mdsegmentationServiceSegmentation ServiceSegmentationService.mdHangingProtocolServiceData ServiceHangingProtocolService.mdMeasurementService文档标注 MODIFIEDData ServiceMeasurementService.mdToolBarServiceData ServiceToolbarService.mdViewedDataServiceData ServiceViewedDataService.mdViewportGridServiceUI Serviceviewport-grid-service.mdCine ServiceUI Servicecine-service.mdCustomizationServiceUI ServicecustomizationService.mdUIDialogServiceUI Serviceui-dialog-service.mdUIModalServiceUI Serviceui-modal-service.mdUINotificationServiceUI Serviceui-notification-service.mdUIViewportDialogServiceUI Serviceui-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 }; // 白名单式事件定义核心 APIsubscribe(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默认 300msimmediate控制前缘/后缘触发reset()批量执行已记录的unsubscriptions并清空listenerscreateConsumableEvent(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, };典型订阅链路defaultRouteInitPub/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被调用onModeExitmode 存好持久化数据后服务借此钩子清理自身状态。六、延伸阅读路径服务设计原理与自定义服务写法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),仅供参考