十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OHIF v3 数据服务层深度解析:以 Service + 发布订阅模式替代 Redux 的状态管理架构

OHIF v3 数据服务层深度解析:以 Service + 发布订阅模式替代 Redux 的状态管理架构 OHIF v3 数据服务层深度解析以 Service 发布订阅模式替代 Redux 的状态管理架构【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers本文基于 OHIF 官方文档中的 Data Services 概述章节展开讲解 OHIF-v3 如何用一组各自维护内部状态的 Data Services 与 pub/sub 事件机制取代 Redux Store读者将理解数据服务的分类与职责、ServicesManager的注册机制、服务间松耦合的事件通信链路并掌握如何在扩展中编写并注册自定义数据服务。一、什么是 Data ServicesData services 是 OHIF 服务体系中处理非 UI 相关状态的第一类服务。其核心设计原则是每个服务各自拥有并管理自己的内部状态彼此之间不通过共享的全局 store 直接读写对方数据而是通过事件订阅/广播进行通信。在 OHIF-v3 中架构上做出了一次关键决策我们移除了reduxstore转而引入一系列服务services以及 pub/sub 发布-订阅模式使OHIF-v3的架构更加干净、模块化。这一改造的实际收益可以从源码结构看出platform/core/src/services/目录下每个服务一个独立子目录如DicomMetadataStore/、DisplaySetService/、MeasurementService/、HangingProtocolService/等服务之间仅依赖各自公开的事件常量与 API没有集中的全局 reducer。二、OHIF 维护的核心非 UI 服务清单官方文档列出了以下数据服务它们共同构成了查看器的数据层骨架服务职责文档入口核心实现位置DicomMetadataStore集中存储研究/序列/实例三层 DICOM 元数据DicomMetadataStoreDicomMetadataStore.tsDisplaySetService将实例元数据聚合为可渲染的 DisplaySetDisplaySetServiceDisplaySetService.tsHangingProtocolService执行悬挂协议HP逻辑决定视口布局映射HangingProtocolServiceHangingProtocolService.tsToolbarService管理主工具栏状态ToolbarServiceToolbarService.tsMeasurementService管理测量标注数据的增删查MeasurementServiceMeasurementService.tsCustomizationService管理 UI 外观、主题与定制化配置CustomizationServiceCustomizationService.tsPanelService管理左右面板的开关与内容PanelServicePanelService.tsxViewedDataService追踪当前视口所查看的数据ViewedDataService见 services 目录说明以上服务文档路径均已转换为以仓库根目录为起点的相对路径可点击直接跳转到对应章节继续深入。三、Service 架构工厂函数模式与注册机制3.1 服务的最小骨架namecreate回顾服务架构的基本约定最简单的是一个工厂函数它返回一个带有name属性和create方法的对象create方法负责实例化真正的服务类。与 UI Services 略有不同Data Services 的工厂函数直接接收servicesManager并在返回对象中暴露创建逻辑// extensions/customExtension/src/services/backEndService/index.js import backEndService from ./backEndService; export default function WrappedBackEndService(servicesManager) { return { name: myService, create: ({ configuration {} }) { return new backEndService(servicesManager); }, }; }服务被create实例化后注册进ServicesManager即可被应用代码和扩展按名称访问。服务管理器的完整设计说明即 service.md中给出了ServicesManager的简化骨架其两个公开方法为registerService注册单个服务可附带 configurationregisterServices批量注册一组服务。3.2 源码级注册流程ServicesManager.registerService官方文档中的骨架是简化版真实的实现在 ServicesManager.ts其中包含了几个文档未强调但生产上很重要的行为public registerService(service, configuration {}) { if (!service) { log.warn(Attempting to register a null/undefined service. Exiting early.); return; } if (!service.name) { log.warn(Service name not set. Exiting early.); return; } if (this.registeredServiceNames.includes(service.name)) { log.warn(Service name ${service.name} has already been registered. ...); return; } if (service.create) { this.services[service.name] service.create({ configuration, extensionManager: this._extensionManager, commandsManager: this._commandsManager, servicesManager: this, }); } this.registeredServiceNames.push(service.name); }从源码结构看可以得出三点结论防御性校验空服务、无name、重复注册同名服务都会被拦截并给出警告registeredServiceNames数组负责追踪已注册名称DI 注入create被调用时会注入configuration、commandsManager、extensionManager和servicesManager本身——因此自定义服务在构造函数中即可拿到服务管理器进而访问其它已注册服务这是服务间解耦协作的关键配置对支持registerServicesL72-L83支持传入[service, configuration]二元组数组为单个服务附带独立配置例如应用初始化时给MultiMonitorService传入appConfig.multimonitor。此外service.md 的约定还指出服务类型建议通过模块的 Types 导出且名称规范为——实例使用小驼峰如toolBarService类使用大驼峰如ToolBarService。3.3 应用启动时的默认注册清单ServicesManager是服务的唯一注册入口。在应用初始化文件 appInit.js 中OHIF 通过servicesManager.registerServices([...])批量注册了全部默认服务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], ]);其中MeasurementService、DisplaySetService、ToolbarService、HangingProtocolService、PanelService等属于本文档讨论的 Data Services而UIModalService、UIViewportDialogService、CineService等则属于 UI 类服务。值得注意的是DicomMetadataStore并不走registerServices路径——从源码看它是一个模块级单例配合pubSubServiceInterface混入由数据源DataSource在拉取元数据时直接使用这正体现了数据服务各自管理内部状态的设计。四、发布-订阅Pub/Sub服务间通信的底层机制4.1 事件模型的共享实现所有需要广播事件的服务都复用同一个 pub/sub 接口 pubSubServiceInterface.ts。消费方只需实现两个约定属性listeners {}与EVENTS {...}即可混入获得四个方法subscribe(eventName, callback)校验事件合法性后为监听器分配 GUID 并返回{ unsubscribe }句柄——每次订阅都会返回一个取消订阅函数这是防止监听器泄漏的契约_broadcastEvent(eventName, callbackProps)向所有已注册回调广播数据同时通过document.body.dispatchEvent(new CustomEvent(...))将事件派发到 DOM方便外部调试工具观察_unsubscribe/_isValidEvent内部清理与事件名白名单校验不在EVENTS值中的事件名会在subscribe时直接抛出Error(Event xxx not supported.)。该模块还导出了一个面向类的PubSubService封装额外提供subscribeDebounced带 300ms 默认抖动的订阅用于高频事件限流与reset()批量解绑所有订阅并清空监听表等能力。4.2 事件驱动的典型数据流以DicomMetadataStore为例它在 DicomMetadataStore.ts 中定义了四个事件const EVENTS { STUDY_ADDED: event::dicomMetadataStore:studyAdded, INSTANCES_ADDED: event::dicomMetadataStore:instancesAdded, SERIES_ADDED: event::dicomMetadataStore:seriesAdded, SERIES_UPDATED: event::dicomMetadataStore:seriesUpdated, };INSTANCES_ADDED在该系列的全部实例元数据写入存储后触发SERIES_ADDED在该研究的全部序列汇总元数据写入后触发。其它服务正是订阅这些事件来推进自身逻辑官方文档 pubsub.md 中的defaultRouteInit是这条链路的完整示例async function defaultRouteInit({ servicesManager, studyInstanceUIDs, dataSource }) { const { DisplaySetService, HangingProtocolService } servicesManager.services; const unsubscriptions []; // 1) 实例加入 - 构建 DisplaySet 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 }); // 触发数据源拉取 }); // 2) 序列加入 - 运行悬挂协议 const { unsubscribe: seriesAddedUnsubscribe } DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.SERIES_ADDED, ({ StudyInstanceUID }) { HangingProtocolService.run({ studies, displaySets, activeStudy }); } ); unsubscriptions.push(seriesAddedUnsubscribe); return unsubscriptions; }这条链路完整展示了数据服务协作范式DataSource 拉取元数据 → DicomMetadataStore 存储并广播事件 → DisplaySetService 消费实例事件构建展示集 → HangingProtocolService 消费序列事件决定布局。各服务互不直接 import 对方的内部状态只依赖事件与公开 API。订阅的生命周期管理同样关键Mode路由在useEffect中收集所有unsubscriptions并在组件销毁时统一调用配合extensionManager.onModeExit()避免同一观察者被重复订阅。4.3madeInClient静默写入的控制开关由于写入即广播某些场景例如为下一个研究预取缓存数据不希望触发悬挂协议等下游逻辑。此时在addInstances/addSeriesMetadata中传madeInClient true事件仍会广播但携带该标志订阅方据此跳过对应处理。从 DicomMetadataStore.ts 可以看到INSTANCES_ADDED事件的 detail 中始终包含madeInClient字段交由订阅者自行决策。五、DicomMetadataStore 的数据结构与 API 速览作为数据层的中枢DicomMetadataStore的存储模型值得单独说明。其私有变量_model按 研究 → 序列列表 → 序列 → 实例 的层级组织全部元数据源码见 DicomMetadataStore.ts#L49-L51const _model { studies: [ { /** Study Metadata **/ seriesLists: [ { // 来自 dicom web server 1或 backend 1的序列 series: [ { // 序列 1 元数据 instances: [ { /* 序列 1 实例 1 元数据 */ }, { /* 序列 1 实例 2 元数据 */ }, ], }, { /* 序列 2 ... */ }, ], }, { /* backend 2 的序列 ... */ }, ], }, ], }其公开 API 一览详见 DicomMetadataStore 文档EVENTS事件常量对象配合DicomMetadataStore.subscribe(EVENTS.SERIES_ADDED, fn)订阅addInstances(instances, madeInClient false)批量写入实例元数据完成后触发INSTANCES_ADDED对NumberOfFrames 1的多帧实例会自动用addProxyFields包裹以支持从父级元数据回退读取ImagePositionPatient等影像平面字段addSeriesMetadata(seriesSummaryMetadata, madeInClient false)写入序列汇总元数据完成后触发SERIES_ADDEDaddStudy(study)创建并加入研究级元数据PatientName、StudyDate、AccessionNumber等getStudy(StudyInstanceUID)返回含全部序列与实例元数据的研究对象getSeries(StudyInstanceUID, SeriesInstanceUID)返回序列级元数据StudyInstanceUID缺省时会在所有研究中按 SeriesInstanceUID 查找getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID)返回实例元数据getInstanceByImageId(imageId)按 imageId 反查实例——先经元数据 provider 的frameModule解析 UID未命中时再遍历全部实例兜底updateMetadataForSeries(StudyInstanceUID, SeriesInstanceUID, metadata)合并更新该序列下所有实例的元数据并广播SERIES_UPDATED。六、在扩展中编写自定义 Data Service6.1 三步式定义、工厂封装、注册按照 服务管理器文档 的约定自定义服务的完整写法分三步第一步在扩展中定义服务实现类以大驼峰命名类export default class BackEndService { constructor(servicesManager) { this.servicesManager servicesManager; } putAnnotations() { return post(/* ... */); } }第二步用工厂函数将其封装为带name小驼峰规范名与create的对象// 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); }, }; }第三步在扩展的preRegistration钩子中注册——这是扩展注册自定义服务的官方位置// extensions/customExtension/src/index.js import WrappedBackEndService from ./services/backEndService; export default { id: myExtension, preRegistration({ servicesManager }) { servicesManager.registerService(WrappedBackEndService(servicesManager)); }, };注册完成后应用任意位置都可以通过servicesManager.services按名称取用function SomePanel({ servicesManager }) { const { MeasurementService } servicesManager.services; const measurements MeasurementService.getMeasurements(); // ... }6.2 服务的模式生命周期约定若自定义服务需要维护模式Mode级别的私有状态还应实现onModeEnter/onModeExit钩子。service.md 明确了其契约服务在模式进入时所处状态必须与其是首次进入还是退出后再次进入保持一致——onModeEnter在服务自身之前先于模式onModeEnter被调用onModeExit则允许服务在模式持久化数据之后完成自身清理。七、小结与延伸阅读OHIF-v3 的数据层设计可以归纳为三条主线状态私有化每个 Data Service 自管内部状态、注册集中化ServicesManager单点注册 DI 注入、通信事件化共享 pub/sub 接口 unsubscribe生命周期契约。DicomMetadataStore的存储-广播事件是整条渲染链路元数据 → DisplaySet → 悬挂协议 → 视口布局的起点理解它的EVENTS、_model层级结构与madeInClient语义是进一步定制数据流的前提。延伸阅读均为仓库内文档路径相对仓库根目录服务管理器完整文档发布-订阅模式文档DicomMetadataStore 文档DisplaySetService 文档HangingProtocolService 文档MeasurementService 文档PanelService 文档ViewedDataService 文档CustomizationService 文档【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表