4. 数据格式基础(下):HL7 v2.x消息结构、FHIR资源模型、DICOM文件结构、自定义二进制协议解析

好,咱们接着聊数据格式。上一节我们把基础概念过了一遍,这一节要动真格的了。HL7 v2.x、FHIR、DICOM,还有那些自定义的二进制协议——这些才是你在医院系统里天天要打交道的「硬骨头」。

我个人习惯把这类格式分成两类:一类是「文本可读型」,比如 HL7 和 FHIR,你拿记事本打开还能猜个大概意思;另一类是「二进制黑箱型」,比如 DICOM 和自定义协议,不借助工具你根本不知道里面是什么。咱们一个一个来拆。

4.1 HL7 v2.x 消息结构:医院里的「电报」

HL7 v2.x 是什么?说白了,它就是医院信息系统之间互相喊话的「暗号」。你想想看,HIS 要给 LIS 发一个检验申请,总不能说「喂,帮我查个血常规」吧?得有一套大家都懂的格式。

HL7 v2.x 的消息结构,核心就三个字:段、字段、组件。

  • 段(Segment):每一行是一个段,以三个大写字母开头。比如 MSH 是消息头,PID 是患者信息,OBR 是医嘱信息。
  • 字段(Field):段里面用竖线 | 分隔的就是字段。比如 PID 段里,第 5 个字段通常是患者姓名。
  • 组件(Component):字段里面用 ^ 分隔的更小单位。比如姓名这个字段,可能拆成姓^名^中间名。

来,看个实际例子。这是我以前在集成平台上抓的一条真实消息(脱敏后的):

MSH|^~\&|HIS|XX医院|LIS|XX医院|202403121030||ORM^O01|123456|P|2.3
PID|1||10086||张三^张^^先生||19800101|M|||北京市海淀区^^^北京^100000
OBR|1||20240312001||血常规^CBC|||202403121030|||||||李医生

嗯,这里要注意:MSH 段第 2 个字段 ^~\& 是编码字符,它告诉解析器用什么符号来分隔组件、重复字段和转义字符。很多新手在这里栽跟头——解析出来的字段全是乱的,其实就是编码字符没配对。

⚠️ 避坑指南: 我曾经在一个急诊项目中,因为 HL7 消息里的编码字符被中间件篡改,导致患者姓名一直解析不出来。排查了整整两天,最后发现是防火墙把 ^ 当成特殊字符过滤了。所以,传输 HL7 消息时,一定要确认编码字符没有被转义或过滤。

4.2 FHIR 资源模型:新一代的 RESTful 风格

FHIR 是 HL7 的新一代标准,它把医疗数据抽象成一个个「资源(Resource)」。说白了,就是把患者、医嘱、检查报告这些东西,都变成 JSON 或 XML 格式的 RESTful API 资源。

我个人觉得 FHIR 最大的好处是:前端开发人员终于能看懂了。以前 HL7 v2.x 那套竖线分隔符,前端小哥看了直摇头。FHIR 用 JSON,结构清晰,还有标准的 HTTP 方法(GET、POST、PUT、DELETE)。

举个例子,上面那条 HL7 v2.x 消息,如果用 FHIR 的 Patient 资源来表示,大概是这样的:

{
  "resourceType": "Patient",
  "id": "10086",
  "name": [{
    "use": "official",
    "family": "张",
    "given": ["张三"]
  }],
  "gender": "male",
  "birthDate": "1980-01-01",
  "address": [{
    "city": "北京市",
    "district": "海淀区",
    "postalCode": "100000"
  }]
}

你看,是不是直观多了?但这里有个坑:FHIR 虽然标准统一,但不同厂商的实现千差万别。我遇到过一家厂商,他们把患者姓名放在 name.text 字段里,而不是标准的 name.familyname.given。你按标准解析,拿到的就是空值。

💡 我的建议: 对接 FHIR 接口时,不要完全相信文档。先拿一个真实的患者数据,用 Postman 调一下,看看返回的 JSON 结构到底长什么样。文档和实现之间,往往隔着一条「鸿沟」。

4.3 DICOM 文件结构:医学影像的「集装箱」

DICOM 是医学影像领域的标准,CT、MRI、X光、超声,这些设备的输出格式都是 DICOM。它的文件结构,你可以想象成一个「集装箱」:

  • 文件头(Preamble):前 128 个字节,通常是 0,用于兼容其他格式。紧接着是 4 个字节的 "DICM" 标识。
  • 数据集(Data Set):由一个个数据元素(Data Element)组成。每个数据元素包含:标签(Tag)、值表示(VR)、值长度(VL)、值域(VF)。

标签是 DICOM 的核心,它是一个 4 字节的十六进制数,比如 (0010,0010) 表示患者姓名,(0008,0060) 表示检查部位。我刚开始学 DICOM 时,背这些标签背得头大。后来发现,其实不用背,记住几个常用的就行,其他的查 DICOM 标准字典。

来看一个 DICOM 文件头的解析示例(用 Python 的 pydicom 库):

import pydicom

ds = pydicom.dcmread("example.dcm")
print(ds.PatientName)        # 输出:张三
print(ds.StudyDate)          # 输出:20240312
print(ds.Modality)           # 输出:CT
print(ds[0x0010, 0x0010])    # 通过标签访问,效果同上
⚠️ 注意: DICOM 有两种字节序:小端序(Little Endian)和大端序(Big Endian)。Windows 系统通常用小端序,而一些老旧的影像设备可能用大端序。解析时如果发现患者姓名变成乱码,十有八九是字节序搞反了。我曾经因为这个,被放射科主任追着骂了三天。

4.4 自定义二进制协议解析:没有标准,就自己造

最后聊聊自定义二进制协议。这东西在医疗设备里太常见了——监护仪、呼吸机、输液泵,这些设备厂商往往有自己的私有协议。说白了,就是「没有标准,就自己造」。

解析自定义二进制协议,我总结了一套「三步法」:

  1. 找帧头帧尾:大多数协议会用固定的字节作为帧头(比如 0xAA、0x55)和帧尾(比如 0x0D、0x0A)。找到它们,你才能把一条完整的消息从数据流里切出来。
  2. 定长度和校验:帧头后面通常跟着数据长度字段,告诉你这条消息有多长。然后是校验字段(比如 CRC16、累加和),用来验证数据有没有被篡改。
  3. 解析载荷:根据协议文档,把载荷里的每个字节映射成具体的含义。比如第 5 个字节是心率,第 6-7 个字节是血压。

举个例子,假设某监护仪的协议格式如下:

帧头(1B) | 长度(2B) | 设备ID(1B) | 数据(可变) | 校验(1B) | 帧尾(1B)
  0xAA   |  0x000C  |   0x01     |  ...      |  0x??   |  0x0D

用 Python 解析的代码片段:

import struct

def parse_monitor_data(raw_bytes):
    if raw_bytes[0] != 0xAA:
        return None  # 帧头不对,丢弃
    length = struct.unpack('>H', raw_bytes[1:3])[0]
    device_id = raw_bytes[3]
    data = raw_bytes[4:4+length-5]  # 减去帧头、长度、设备ID、校验、帧尾
    checksum = raw_bytes[4+length-5]
    # 校验逻辑省略...
    return {
        'device_id': device_id,
        'heart_rate': data[0],      # 假设第1个字节是心率
        'blood_pressure': data[1]   # 假设第2个字节是血压
    }
💡 实战经验: 解析自定义协议时,一定要处理「粘包」和「半包」问题。串口通信中,数据是一帧一帧发过来的,但接收端可能一次收到多帧(粘包),也可能只收到半帧(半包)。我的做法是用一个环形缓冲区,先把数据存起来,然后循环查找帧头帧尾,切出完整的帧再解析。

小结

这一节我们聊了四种数据格式:HL7 v2.x 的竖线分隔、FHIR 的 JSON 资源、DICOM 的标签结构、自定义二进制协议的帧格式。每种格式都有自己的脾气,但核心思路是一样的——搞清楚「数据是怎么组织的」,然后「按规则拆解」。

下一节,我们会把这些格式串起来,讲讲实际项目中如何做数据转换和映射。嗯,那才是真正考验功力的时候。