Skip to content

领域模型:DO / BO / VO 与对象关系

这页写给要在平台上写代码的人:理清 Profile / Point / Command / Event / Device / Driver 这几个对象怎么挂在一起,搞懂最容易踩坑的"三层配置"(Param / Attribute / Config),并掌握同一份数据在 DO、BO、VO 三层之间如何用 MapStruct *Builder 来回转换。读完你就能正确地新增字段、加枚举、读懂任意一个 *Controller → *Service → *Manager 调用链里值的形态。

你在这里:已经看过 核心概念 的对象关系,现在往下钻一层看字段与分层。下一步可看 数据平面 (位号值的落库链路)或 驱动开发(把这些对象落成一个真实驱动)。

一切始于 Profile

IoT DC3 的领域模型有一个根:模板 Profile。它不是一台设备,而是"一类设备的能力清单"——这类设备有哪些可读写的位号 Point 、支持哪些自定义命令 Command、会上报哪些事件 Event。把能力沉淀在模板上,设备只要绑定模板就自动继承这套能力,无需逐台重复定义。

设备 Device 是现场一台具体设备在平台里的镜像。它做两件绑定:绑一个 Profile(决定"有哪些能力"),绑一个 驱动 Driver (决定"用什么协议通信")。这里有一个 Phase-1 之后定下的硬约束:DeviceDO.profileId单一外键(一个 Long),不再是早期的多对多 ProfileBind——每台设备只能绑一个模板

设备与模板是一对一,别再按多对多设计

dc3_device.profile_id 是单值外键(DeviceDO.java)。新增涉及"设备的能力来源"的查询时,按"设备 → 唯一 Profile" 来写,不要假设一台设备能挂多个模板。

位号是数据的最小单位。它的两个关键标志位决定了它能干什么:

  • pointTypeFlagPointTypeEnum)——值的数据类型。
  • rwFlagRwTypeEnum)——读写方向。一个位号能不能写,由它自己的 rwFlag 决定,而不是命令表。 试图写一个 READ_ONLY 位号会在命令校验阶段被拒。

位号还带工程量信息:unit(单位)、valueDecimal(小数精度,默认 6)、以及线性换算 baseValue / multiple ——把驱动采到的原始值变换成工程值(工程值 = 原始值 × multiple + baseValue 的语义由驱动实现)。

位号类型枚举其实有 8 个,不止 4 个

introduction/concepts 和 Add Point 的 API 表为了好懂,只列了 STRING / INT / FLOAT / DOUBLE。源码里 PointTypeEnum 实际有 8 个值:STRING(0) / BYTE(1) / SHORT(2) / INT(3) / LONG(4) / FLOAT(5) / DOUBLE(6) / BOOLEAN(7)PointTypeEnum.java)。 rwFlag 对应 RwTypeEnumREAD_ONLY(0) / WRITE_ONLY(1) / READ_WRITE(2)。以代码为准。

领域实体关系

下图把根对象 Profile、它的三类子能力、设备与驱动的绑定,以及"三层配置" 里的属性/配置关系画在一起。它比 核心概念 里的那张更全——多出了协议层 *Attribute 与实例层 *AttributeConfig

模板域 · Profile 是根 (绑定即继承能力)协议层 Attribute · 驱动启动注册 (有这个坑)实例层 Config · 这个坑填什么N ─ 1profile_idN ─ 1N ─ 1N ─ 1profile_id 单值N ─ 1注册driver_id · 1─N1 ─ Nattribute_id1 ─ Ndc3_point«位号 · 数据最小单位»+ point_id PK+ profile_id FK → profile+ point_type_flag (8 枚举值)+ rw_flag 读/写方向+ unit · value_decimal = 6+ base_value / multiple可写性由 rw_flag 决定dc3_profile«根 · 一类设备的能力清单»+ profile_id PK+ profile_name+ profile_share_flag TENANT / DRIVER / USER+ tenant_id设备绑模板即继承全部能力dc3_command«自定义命令»+ command_id PK+ profile_id FK参数: dc3_command_paramdc3_event«事件定义»+ event_id PK+ profile_id FK+ event_type_flag (4 类)dc3_device«设备实例»+ device_id PK+ profile_id FK → 唯一模板 (单值外键 · 非多对多!)+ driver_id FK → driver+ enable_flag · tenant_iddc3_driver«协议驱动»+ driver_id PK+ service_name · driver_typedc3_point_attribute«协议层»+ id PK · driver_id FK例: 寄存器地址驱动启动时注册dc3_driver_attribute«协议层»+ id PK · driver_id FK例: 从机地址驱动启动时注册dc3_command_attribute«协议层»+ id PK · driver_id FK命令的协议参数项dc3_event_attribute«协议层»+ id PK · driver_id FK事件的协议参数项dc3_point_attribute_config«实例值»+ attribute_id FK+ device_id FK · point_id FK+ config_value例: 寄存器 40001每设备每位号一行dc3_driver_attribute_config«实例值»+ attribute_id FK+ device_id FK+ config_value例: 从机号 = 1command/event config 同构模板 / 驱动设备实例Attribute 协议层Config 实例值关系连线单外键警示: device.profile_id

profileShareFlagProfileShareTypeEnumTENANT / DRIVER / USER)控制模板的共享范围;Eventevent_type_flag0=info / 1=alert / 2=fault / 3=lifecycle)是事件定义上的分类,存在 dc3_event 表(管理域,由 04-iot-dc3-manager.sql 建)。这点容易和告警混淆,下文专门讲。

三层配置:Param、Attribute、Config 各管一摊

这是领域模型里最容易混的地方。平台把"配置"拆成三个作用域完全不同的层,每层回答不同的问题、由不同的人/流程产生、对应不同的 DO 类:

① Param 业务层 · 命令 / 事件参数 (与协议无关)② Attribute 协议层 · 驱动声明有哪些坑③ Config 实例层 · 每台设备填的值使用方 · 定义存储与运行时读取定义注册 1─N填值1─N · 每台设备各填一行入库入库 · CRUDgRPC · 元数据查询采集前读 ConfigProfile 模板定义命令/事件的能力清单业务语义·不含协议驱动启动注册读 application.yml声明本协议配置项设备接入配置POST *_attribute_config/add为属性填具体值Param (业务层)CommandParamDO · EventParamDO模板里一个命令/事件的输入输出参数定义Attribute (协议层)Driver/Point/Command/Event AttributeDO定义「有哪些项」· 不含值例: 位号需要寄存器地址Config (实例层)*AttributeConfigDOattributeId + deviceId + pointId+ config_value · 例: 40001管理中心 dc3-center-manager保存三层定义 · 提供 CRUD 接口驱动运行时采集/下发前按 deviceId (+pointId)读 Config 拿到本设备的参数值字段速记 (point_attribute_config)attributeId → 指向哪个属性 (哪个坑)deviceId / pointId → 哪台设备的哪个位号configValue → 填的值 (坑里填什么)一句话: Attribute 说「有这个坑」, Config 说「这个坑填什么」;Param 是与协议无关的业务参数; Config 的请求字段正是 attributeId / deviceId / pointId / configValue模板 / 驱动配置操作方Param 业务层Attribute 协议层Config 实例层产生 / 读取流
  • Param(业务层) —— CommandParamDO / EventParamDO。它描述模板里一个命令/事件的输入输出参数,是业务语义,和具体协议无关。
  • Attribute(协议层) —— DriverAttributeDO / PointAttributeDO / CommandAttributeDO / EventAttributeDO。它由驱动在 启动时注册:驱动读自己的 application.yml,告诉管理中心"我这个协议需要哪些配置项"。比如 Modbus 驱动会声明" 位号需要一个寄存器地址"——这是 Attribute,定义的是有哪些项,不含值。
  • Config(实例层) —— PointAttributeConfigDO(以及 DriverAttributeConfigDO / CommandAttributeConfigDO / EventAttributeConfigDO)。它存的是这台设备为那些属性填的具体值PointAttributeConfigDO 的核心字段就是 attributeId(指向哪个属性)+ deviceId + pointId + configValue(填的值)。比如"3 号设备的温度位号,寄存器地址是 40001"——40001 就落在这里。

一句话区分:Attribute 说"有这个坑",Config 说"这个坑填什么"。 理解这层映射,才能看懂 设备接入 里"配置位号属性"那一步,以及 POST /api/v3/manager/point_attribute_config/add(请求字段正是 attributeId / deviceId / pointId / configValue )到底在写什么。

同一份数据的三种形态:DO / BO / VO

一个领域对象在系统里有三种长相,对应三个层、三种关注点。以位号为例:PointDO / PointBO / PointVO

  • DO(*DO,如 PointDO)—— 数据库形态。 它是 dc3_point 表的镜像:标志位是裸 BytepointTypeFlagrwFlagenableFlag 都是 Byte),带 MyBatis-Plus 注解 @TableName / @TableId(type = ASSIGN_ID)(Snowflake ID)/ @TableLogic(逻辑删除 deleted)/ JSON 扩展用 JacksonTypeHandler。DO 只在持久层出现,**裸 Byte 标志位不允许泄漏到业务层或对外响应 **。
  • BO(*BO,如 PointBO)—— 业务形态。 同样的标志位在这里是域枚举pointTypeFlagPointTypeEnumrwFlagRwTypeEnumenableFlagEnableFlagEnum。BO 继承 BaseBO 并实现 TenantOwned(携带 tenantId ,租户隔离的起点)。业务代码、Service 之间传的是 BO,不是 VO。换算字段在 BO 里用 BigDecimalbaseValue / multiple),到 DO 落库时是 Double——精度边界也由 *Builder 处理。
  • VO(*VO,如 PointVO)—— API 形态。 Controller 的请求/响应用 VO。它和 BO 一样用域枚举,除非为兼容旧客户端要保留原始数值。

下图是三层在调用链里的站位,以及 MapStruct *Builder 负责的转换方向。

API 层 · Controller ↔ VO (域枚举)业务层 · Service ↔ BO (域枚举 · TenantOwned)持久层 · Manager ↔ DO (裸 Byte · 表镜像)转换层 · MapStruct *BuilderbuildBOByVO()buildVOByBO()buildDOByBO()buildBOByDO()VO 收发传 BOselect*VO ↔ BOBO ↔ DOBO ↔ DOPointController· 收发 PointVO· @PreAuthorize 守卫· CRUD 动词约定PointVO«API 形态»+ pointTypeFlag: PointTypeEnum+ rwFlag: RwTypeEnum+ enableFlag: EnableFlagEnum+ baseValue/multiple: BigDecimalPointService· 业务逻辑传 BO· 裸 Byte 不出持久层· get*/list* 门面PointBO«业务形态»+ 同 VO 的枚举字段+ 继承 BaseBO+ 实现 TenantOwned (tenantId)+ 换算 BigDecimal (DO 为 Double)PointManager· select* 仅在此层· buildDOByBO 后落库· 读取反向 buildBOByDOPointDO«表镜像»+ @TableName dc3_point+ pointTypeFlag / rwFlag: Byte+ pointExt: JsonExt (Jackson)+ @TableId ASSIGN_ID · @TableLogicPointBuilder«MapStruct 转换器»+ buildVOByBO(BO) → VO+ buildBOByVO(VO) → BO+ buildBOByDO(DO) → BO+ buildDOByBO(BO) → DO@AfterMapping 钩子:· Byte → 枚举 ofIndex(byte)· 枚举 → Byte getIndex()· JSON 串 → 扩展 parseObject· 扩展 → JSON 串 toJsonString同名同类型字段自动映射枚举字段先 @Mapping(ignore=true)null 安全: 枚举为空则不写漏写钩子 → 编译失败或静默丢值PointExt: JsonExt ⇄ PointExtBaseExt 三件套: type·version·remark加字段口诀: DO 加 Byte + 枚举,两个方向的 @AfterMapping 都要补API 形态业务形态表镜像调用链组件调用返回MapStruct 转换

PointController 收到 PointVO 后用 PointBuilder.buildBOByVO() 转成 PointBO 交给 PointService;Service 用 buildDOByBO() 转成 PointDO 交给 PointManager 落库;读取时反向走 buildBOByDO()buildVOByBO()select* 这类原始 Mapper 方法只在 *ManagerImpl 里出现,Service / Controller 一律用 get* / list* / add / update / delete (见 API 文档 的 CRUD 动词约定)。

枚举与 JSON 扩展:@AfterMapping 是关键

MapStruct 能自动映射同名同类型字段,但 Byte ↔ 域枚举JSON 字符串 ↔ 扩展对象这两类转换它不会,得在 *Builder@AfterMapping 钩子里手写。这正是 DO/BO/VO 分层不"漏"的地方。

枚举两端的契约很固定:DO 里存的 Byte 就是枚举上 @EnumValue 标注的 index;DO→BO 用 XxxEnum.ofIndex(byte) 把数值变枚举,BO→DO 用 enum.getIndex() 取回数值。下面这条时序就是 PointBuilder 里读一行位号时真实发生的事:

① GET /manager/point/get_by_id② get(id) · tenantId③ selectById(id)④ SELECT ... WHERE point_id = ?⑤ PointDO (标志位是裸 Byte)buildBOByDO() · @AfterMapping⑥ DO → BO⑦ PointBO (域枚举)⑧ PointBObuildVOByBO()⑨ BO → VO⑩ PointVO⑪ PointVO⑫ 200 · R<PointVO>钩子: Byte→枚举 ofIndexpointExt → parseObjectBO / VO 同为域枚举直接同名映射写路径反向 (对照)Controller: buildBOByVO(VO) → BO → Service: buildDOByBO(BO) → DO反方向钩子: enum.getIndex() 写回 Byte · JsonUtil.toJsonString 落 JSON 扩展调用方HTTP 客户端PointControllerAPI 层 · VOPointService业务层 · BOPointManager持久层 · DOPointBuilderMapStruct 转换PostgreSQLdc3_pointAPI / 调用方业务 / 持久MapStruct数据库调用返回

对应到 PointBuilder.java 里的真实代码:buildBOByDO 上把 pointTypeFlag / rwFlag / enableFlag 标了 @Mapping(ignore = true),再在 @AfterMapping 里逐个 RwTypeEnum.ofIndex(entityDO.getRwFlag()) 赋回;反方向 buildDOByBO@AfterMappingOptional.ofNullable(rwFlag).ifPresent(v -> entityDO.setRwFlag(v.getIndex()))null 安全是显式处理的——枚举为空就不写,不会抛 NPE。

JSON 扩展同理。PointDO.pointExtJsonExtcontent 存成 JSON 字符串,配 JacksonTypeHandler 落库),到 BO 是强类型的 PointExt@AfterMapping 里 DO→BO 用 JsonUtil.parseObject(content, PointExt.Content.class) 反序列化,BO→DO 用 JsonUtil.toJsonString(...) 序列化。所有扩展对象都带 BaseExt 的三件套字段:type(解析时识别子类型)、version(乐观锁,默认 1)、remark

加一个带枚举或 JSON 扩展的新字段时

  1. DO 加 Byte 字段 + @TableField;BO/VO 加对应的枚举字段。
  2. *Builder 上对该字段加 @Mapping(target = "xxx", ignore = true)(DO↔BO 两个方向都要)。
  3. 在两个 @AfterMapping 里补 ofIndex / getIndex 转换;JSON 扩展则补 parseObject / toJsonString。 漏掉第 2、3 步时 MapStruct 会因类型不匹配编译失败或静默丢值——改完务必 mvn -s .mvn/settings.xml -q -DskipTests compile 兜底。

枚举命名:看后缀就知道语义

平台的标志位枚举用后缀编码语义,三类各有约定:

后缀语义例子
*FlagEnum0/1 开关EnableFlagEnumENABLE(0) / DISABLE(1)
*StatusEnum状态机PointCommandStatusEnumPENDING → SENT → ...
*TypeEnum分类集合PointTypeEnumRwTypeEnumProfileShareTypeEnum

EnableFlagEnum 的 0 是"启用",不是"禁用"

ENABLE 的 index 是 0DISABLE1EnableFlagEnum.java)。很多人直觉里 0 是 false/关,这里反了。读 SQL 时 enable_flag = 0 表示启用

dc3_event 是定义,dc3_entity_alarm 是实例

最后一个易混点,也是领域建模里"模型 vs 运行实例"的典型分界:

  • dc3_event(管理域,04-iot-dc3-manager.sql)—— 事件定义。它挂在 Profile 下,描述"这类设备会上报哪种事件",带 event_type_flag0=info / 1=alert / 2=fault / 3=lifecycle)。这是模板能力的一部分,和 Point、Command 平级。
  • dc3_entity_alarm(数据域,03-iot-dc3-data.sql)—— 运行期告警实例。它是规则引擎、状态超时、设备/驱动/事件上报等多个来源在运行时产生的记录,按 alarm_source_flag 区分来源。

换句话说:dc3_event 回答"这设备报什么",dc3_entity_alarm 回答"现在报了什么"。两张表在不同 schema、不同 init 脚本里,新增查询时别把它们当一回事。告警与事件的完整模型(规则、通知通道、状态跟踪)见 告警与通知

延伸阅读

  • 核心概念 — 没读过先补这张更简的对象关系图与一句话心智模型
  • 数据平面PointValueReadPointValuePointValueDO 的逐层变换与落库
  • 驱动开发 — 驱动如何注册 Attribute、把领域对象落成一个真实协议适配
  • API 文档get/list/add/update/delete 动词约定与 OpenAPI
  • 告警与通知dc3_entity_alarm 的来源、规则与通知链路

基于 AGPL-3.0 协议发布