摘要

牙科医疗影像处理与手术辅助支持系统(Dental Imaging Processing and Surgical Assistance System, DIPSAS)需要在异构数据源、多种成像模态(CBCT、口内扫描、全景片)、实时交互与严格医疗合规约束下运行。传统分层架构在应对领域逻辑与外部框架(VTK渲染、Boost并行、DICOM库、硬件设备)高度耦合时,易产生"框架渗透"与测试困难。本文提出以六边形架构(Ports & Adapters)为核心的组织范式,将牙科影像处理与手术导航的领域逻辑置于架构中心,通过端口定义与适配器隔离VTK、Boost、DICOM、Qt及手术器械SDK等外部依赖。文中给出基于C++17/VTK 9/Boost 1.8x的完整项目代码骨架,包括领域模型、端口契约、VTK渲染适配器、Boost.Asio设备适配器、依赖注入组合根及GoogleTest测试适配器,并从依赖倒置、可测试性、可替换性与并行处理四个维度分析该架构的收益与代价。结果表明,六边形架构能够在不牺牲VTK渲染性能与Boost并行能力的前提下,显著提升领域代码的可测试性与外部技术栈的可演进性,适用于医疗影像类长生命周期系统。

关键词:六边形架构;端口与适配器;C++;VTK;Boost;牙科影像;手术导航;依赖倒置


1 引言

牙科数字化诊疗链条涵盖锥形束CT(CBCT)三维重建、口内扫描网格配准、种植体规划、导板设计与术中导航。此类系统的典型特征是:领域算法(配准、分割、路径规划、配准误差评估)复杂且需反复验证;外部技术栈庞杂(VTK用于体绘制与网格处理,ITK用于配准,Boost用于图算法与异步IO,Qt用于界面,各家手术器械SDK用于位姿跟踪);同时受医疗器械软件注册与IEC 62304生命周期约束,要求领域逻辑具备可追溯的单元测试覆盖。

分层架构(Controller-Service-Repository)在早期可行,但实践中常出现领域层直接包含vtkSmartPointer、boost::asio::io_context等类型,导致:领域代码无法脱离图形环境运行测试;更换渲染后端或跟踪设备时需修改业务代码;并行策略与业务逻辑纠缠。六边形架构(Hexagonal Architecture,又称Ports & Adapters)由Cockburn提出[1],其核心是以领域为中心的依赖倒置:领域定义端口(接口),外部技术以实现适配器的方式接入,依赖箭头一律指向内部。本文以DIPSAS项目为背景,论证该架构在C++医疗影像系统中的适用性,并给出可编译的代码骨架与设计权衡分析。


2 需求与架构驱动力

2.1 功能需求(摘要)

  • FR1:加载CBCT体数据(DICOM序列)与口内扫描网格(STL/PLY),完成坐标系统一。

  • FR2:颌骨与牙列分割、神经管提取、种植体路径规划。

  • FR3:术中器械位姿实时跟踪,与术前规划配准并渲染偏差。

  • FR4:手术导板与规划报告导出(STL/PDF)。

  • FR5:操作审计日志与影像数据的可追溯访问。

2.2 质量属性(架构驱动力)

属性场景架构含义
可测试性配准算法须在无GPU/无显示环境CI中运行领域层不得依赖VTK渲染上下文
可替换性跟踪设备从光学式更换为电磁式设备访问须经端口抽象
性能体绘制≥30FPS,配准在秒级完成适配器层可充分利用VTK/Boost并行
合规性IEC 62304 B/C级,需求-代码-测试可追溯端口即契约,适配器可独立验证
可演进性DICOM库、Qt版本升级技术栈变更限于适配器

这些驱动力共同指向"领域隔离 + 技术可插拔"的架构诉求,正是六边形架构的设计目标。


3 六边形架构总体设计

3.1 分层与依赖规则

系统划分为三个同心区域:

  1. 领域核心(Domain Core):纯C++17,仅依赖标准库与Boost中与IO无关的头文件(如boost::geometry的纯计算部分需谨慎引入,实际项目中以自研几何类型为主)。包含实体、值对象、领域服务与用例。

  2. 端口(Ports):定义在领域边界上的抽象接口,分为入站端口(用例接口,由外部调用)与出站端口(领域所需的外部能力,如影像仓库、渲染器、跟踪设备、日志)。

  3. 适配器(Adapters):驱动型适配器(Qt界面、REST/CLI入口)调用入站端口;被驱动型适配器(VTK渲染、DICOM IO、Boost.Asio设备通信、文件系统)实现出站端口。

依赖规则:适配器 → 端口 → 领域;领域不包含任何#include <vtk...>或#include <boost/asio...>。

3.2 端口清单(节选)

端口方向职责
IPlanningUseCase入站规划会话的创建、编辑、提交
INavigationUseCase入站术中导航会话控制
IImagingRepository出站体数据/网格的加载与持久化
IVolumeRenderer出站体绘制与网格叠加渲染
ITrackerDevice出站器械位姿流订阅
IAuditLogger出站合规审计事件
IClock出站时间抽象(便于测试)

3.3 模块与构建视图

采用CMake多目标组织,以编译期强制依赖规则:

text

dipsas_domain      (STATIC, 无VTK/Boost.Asio依赖)
dipsas_ports       (INTERFACE, 仅头文件)
dipsas_adapter_vtk (STATIC, 链接VTK)
dipsas_adapter_io  (STATIC, 链接Boost.Asio/DCMTK)
dipsas_app         (EXECUTABLE, 组合根)
dipsas_tests       (测试目标, 链接domain+ports+fake适配器)

dipsas_domain的CMake中显式不声明VTK依赖,任何误引入将导致链接失败,从构建系统层面固化架构约束。


4 领域核心与端口设计

4.1 领域模型(值对象与实体)

cpp

// domain/geometry_types.hpp  —— 纯标准库,替代vtkVector
namespace dipsas::domain {
struct Vec3 { double x{}, y{}, z{}; };
struct Mat4 { double m[16]{}; };           // 列主序
struct Transform { Mat4 matrix; };

struct ImplantPlan {
    std::string toothFdi;                  // FDI牙位编号
    Vec3 entryPoint;
    Vec3 apexPoint;
    double diameterMm{};
    double lengthMm{};
};

struct RegistrationResult {
    Transform preOpToTracker;
    double fiducialRmsMm{};
    bool converged{};
};
} // namespace

4.2 出站端口

cpp

// ports/outbound/volume_renderer.hpp
namespace dipsas::ports {
class IVolumeRenderer {
public:
    virtual ~IVolumeRenderer() = default;
    virtual void loadVolume(const domain::VolumeId& id) = 0;
    virtual void overlayMesh(const domain::MeshId& id,
                             const domain::Transform& xform) = 0;
    virtual void setImplantPreview(const domain::ImplantPlan& plan) = 0;
    virtual void requestRender() = 0;   // 线程语义由适配器文档约定
};

class ITrackerDevice {
public:
    struct PoseSample {
        domain::Transform toolToTracker;
        std::chrono::steady_clock::time_point stamp;
    };
    using Callback = std::function<void(const PoseSample&)>;
    virtual ~ITrackerDevice() = default;
    virtual void start(Callback cb) = 0;
    virtual void stop() = 0;
};
} // namespace

端口设计要点:参数与返回值仅使用领域类型与标准库类型,杜绝VTK/Boost类型泄漏;线程与生命周期语义以契约注释形式固化,避免"接口能编译但语义不明"。

4.3 入站端口与用例实现

cpp

// ports/inbound/planning_use_case.hpp
namespace dipsas::ports {
class IPlanningUseCase {
public:
    virtual ~IPlanningUseCase() = default;
    virtual domain::PlanId createPlan(const domain::PatientId&) = 0;
    virtual void addImplant(const domain::PlanId&, domain::ImplantPlan) = 0;
    virtual domain::RegistrationResult registerFiducials(
        const domain::PlanId&, const std::vector<domain::Vec3>& pre,
        const std::vector<domain::Vec3>& intra) = 0;
    virtual void commit(const domain::PlanId&) = 0;
};
}

cpp

// domain/planning_service.cpp —— 用例编排,注入出站端口
namespace dipsas::domain {
class PlanningService final : public ports::IPlanningUseCase {
public:
    PlanningService(std::shared_ptr<ports::IImagingRepository> repo,
                    std::shared_ptr<ports::IAuditLogger> audit,
                    std::shared_ptr<ports::IClock> clock)
        : repo_(std::move(repo)), audit_(std::move(audit)), clock_(std::move(clock)) {}

    PlanId createPlan(const PatientId& pid) override {
        PlanId id = repo_->createPlan(pid);
        audit_->log({clock_->now(), "PLAN_CREATED", pid.value});
        return id;
    }

    RegistrationResult registerFiducials(const PlanId& id,
        const std::vector<Vec3>& pre, const std::vector<Vec3>& intra) override {
        // 领域算法:SVD-based 点集配准(纯计算,可单测)
        auto result = computeRigidRegistration(pre, intra);
        audit_->log({clock_->now(), "REGISTRATION_DONE", id.value,
                     std::to_string(result.fiducialRmsMm)});
        return result;
    }
    // ...
private:
    std::shared_ptr<ports::IImagingRepository> repo_;
    std::shared_ptr<ports::IAuditLogger> audit_;
    std::shared_ptr<ports::IClock> clock_;
};
}

配准算法computeRigidRegistration为纯函数,CI中无需任何图形或设备即可验证,直接满足FR2的可测试性诉求。


5 适配器实现

5.1 VTK渲染适配器

cpp

// adapters/vtk/vtk_volume_renderer.hpp
#include <vtkSmartPointer.h>
#include <vtkRenderer.h>
#include <vtkVolume.h>
#include <vtkActor.h>
#include "ports/outbound/volume_renderer.hpp"

namespace dipsas::adapters::vtk {
class VtkVolumeRenderer final : public ports::IVolumeRenderer {
public:
    explicit VtkVolumeRenderer(vtkSmartPointer<vtkRenderer> renderer)
        : renderer_(std::move(renderer)) {}

    void loadVolume(const domain::VolumeId& id) override {
        auto reader = makeDicomReader(id);          // 内部封装DCMTK/VTK
        auto mapper = vtkSmartPointer<vtkGPUVolumeRayCastMapper>::New();
        mapper->SetInputConnection(reader->GetOutputPort());
        volume_ = vtkSmartPointer<vtkVolume>::New();
        volume_->SetMapper(mapper);
        renderer_->AddVolume(volume_);
    }

    void overlayMesh(const domain::MeshId& id,
                     const domain::Transform& x) override {
        auto actor = meshCache_.getOrLoad(id);      // 网格缓存
        actor->SetUserMatrix(toVtkMatrix(x));       // 领域Mat4 -> vtkMatrix4x4
        renderer_->AddActor(actor);
    }

    void requestRender() override { renderer_->GetRenderWindow()->Render(); }
    // ...
private:
    vtkSmartPointer<vtkRenderer> renderer_;
    vtkSmartPointer<vtkVolume> volume_;
    MeshActorCache meshCache_;
};
}

适配器是唯一允许出现VTK类型的编译单元;领域Transform到vtkMatrix4x4的转换被限制在适配器内部的toVtkMatrix函数中,形成明确的"翻译边界"。

5.2 Boost.Asio跟踪设备适配器

cpp

// adapters/io/asio_tracker_device.hpp
#include <boost/asio.hpp>
#include "ports/outbound/tracker_device.hpp"

namespace dipsas::adapters::io {
class AsioTrackerDevice final : public ports::ITrackerDevice {
public:
    AsioTrackerDevice(boost::asio::io_context& io, std::string endpoint)
        : socket_(io), endpoint_(std::move(endpoint)) {}

    void start(Callback cb) override {
        callback_ = std::move(cb);
        socket_.connect(udp::endpoint(
            boost::asio::ip::make_address("0.0.0.0"), portOf(endpoint_)));
        doReceive();
    }
    void stop() override { socket_.close(); }
private:
    void doReceive() {
        socket_.async_receive(boost::asio::buffer(buf_),
            [this](auto ec, std::size_t n) {
                if (ec) return;
                if (auto s = parsePose(buf_, n)) callback_(*s);
                doReceive();
            });
    }
    // parsePose: 字节流 -> domain::Transform(适配器内翻译)
    udp::socket socket_;
    std::array<std::byte, 256> buf_{};
    Callback callback_;
};
}

Boost.Asio的异步模型被完全封装在适配器内,领域层仅感知PoseSample回调。若未来替换为同步串口或厂商SDK,仅需新增一个ITrackerDevice实现,组合根更换注入即可,满足2.2节的可替换性场景。

5.3 组合根与依赖注入

cpp

// app/main.cpp
int main(int argc, char** argv) {
    boost::asio::io_context io;
    auto audit  = std::make_shared<FileAuditLogger>("audit.log");
    auto clock  = std::make_shared<SystemClock>();
    auto repo   = std::make_shared<DicomImagingRepository>();
    auto useCase = std::make_shared<domain::PlanningService>(repo, audit, clock);

    QApplication app(argc, argv);
    auto renderer = std::make_shared<adapters::vtk::VtkVolumeRenderer>(/*...*/);
    auto tracker  = std::make_shared<adapters::io::AsioTrackerDevice>(io, "0.0.0.0:9000");

    MainWindow w(useCase, renderer, tracker);   // 驱动型适配器
    w.show();
    std::thread ioThread([&]{ io.run(); });
    return app.exec();
}

组合根是唯一知晓全部具体类型的位置,依赖注入以构造函数完成,避免服务定位器带来的隐式依赖。


6 架构属性分析

6.1 可测试性

领域与端口目标不链接VTK/Asio,CI中可直接以FakeRenderer、FakeTracker、ManualClock驱动用例测试。配准、审计事件时序、规划状态机均可确定性验证;测试目标构建时间与GPU无关,符合IEC 62304对单元验证的要求。

6.2 可替换性与可演进性

外部技术栈变更被限制在适配器:VTK 9→10升级只影响adapter_vtk;跟踪设备更换仅新增实现类。端口作为稳定契约,使需求-代码-测试的追溯链在技术演进中保持稳定。

6.3 性能考量

六边形架构的常见质疑是抽象层开销。分析表明:端口调用为虚函数或std::function间接调用,相对VTK渲染管线与DICOM解码的毫秒级开销可忽略;体绘制热点在适配器内部,可直接使用VTK原生管线与GPU加速;Boost并行算法(如boost::sort、boost::geometry批量运算)在适配器或领域纯计算函数中均可使用,唯一约束是领域不得引入带全局状态的Boost组件(如Asio的io_context),而boost::geometry的纯算法部分经评估可在领域层受限使用,并在编码规范中列明白名单。

6.4 代价与约束

  • 类型翻译成本:领域类型与VTK/协议字节流之间需显式转换,增加适配器代码量;以集中式转换工具与契约测试缓解。

  • 端口粒度设计难度:端口过细则适配器爆炸,过粗则泄漏技术细节;本项目以用例为粒度、以"一次外部交互"为出站端口边界。

  • 团队认知负担:需通过构建系统强制依赖规则、代码评审检查#include白名单来维持架构不腐化。


7 相关工作与讨论

六边形架构与Clean Architecture[2]、洋葱架构同属依赖倒置家族;在C++领域,其落地关键在于编译期约束而非仅靠约定。本文以CMake目标隔离替代"架构守护测试",成本更低且更严格。与Martin的Clean Architecture相比,本文未强制引入UseCase/Interactor双层,而是以入站端口直接承载用例,减少医疗小团队的样板代码。局限在于:实时导航路径中跨线程的端口调用需明确线程契约,本文以接口注释与适配器内部串行化处理,尚未引入形式化验证;未来可结合Boost.Asio的executor模型为端口调用定义统一的执行上下文语义。


8 结论

本文面向牙科影像处理与手术辅助系统,提出并实现了基于C++17/VTK/Boost的六边形架构:以领域核心为中心,入站端口承载用例,出站端口抽象影像仓库、渲染、设备与审计能力,适配器隔离全部外部技术栈,组合根完成依赖注入。构建系统层面的目标隔离将依赖规则固化为编译约束。分析表明,该架构在保持VTK渲染性能与Boost异步/并行能力的同时,显著提升了领域逻辑的可测试性与技术栈可替换性,契合医疗软件长生命周期与合规验证需求。后续工作将聚焦端口线程语义的形式化、适配器契约测试自动化,以及在多模态配准流水线中的并行调度优化。


参考文献

[1] Cockburn A. Hexagonal Architecture (Ports and Adapters). 2005.
[2] Martin R C. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall, 2017.
[3] Gamma E, Helm R, Johnson R, Vlissides J. Design Patterns. Addison-Wesley, 1994.
[4] Schroeder W, Martin K, Lorensen B. The Visualization Toolkit (4th ed.). Kitware, 2006.
[5] Schlingensiepen J, et al. Boost C++ Libraries 文档. boost.org.
[6] IEC 62304:2006/AMD1:2015 Medical device software — Software life cycle processes.
[7] Fowler M. Patterns of Enterprise Application Architecture. Addison-Wesley, 2002.(依赖倒置与网关模式)

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐