Chuyển đến nội dung chính

第 18 課:實作 — 使用 HAPI FHIR 建置 FHIR 伺服器

HAPI FHIR JPA 伺服器設定與 Spring Boot、PostgreSQL 後端、索引和搜尋參數配置、資源驗證、攔截器模式、自訂操作($everything、$validate)、批次資料匯出、Docker 部署、效能調優。

🏗️ 建築 — 第 18 課 第 18 課:實作 — 建置 FHIR 伺服器 與 HAPI FHIR

HL7 FHIR - 基礎到進階醫療資料標準

第 6 部分:實踐 - 建構 FHIR 系統

亞洲開發網

1.HAPI FHIR簡介

哈皮FHIR 是用於建立 FHIR 伺服器和客戶端的最受歡迎的開源 Java 庫。 HAPI FHIR JPA 伺服器使用 JPA/Hibernate 提供具有持久層的完整 FHIR 伺服器。

成分描述
HAPI FHIR 核心解析器、模型類別、客戶端/伺服器框架
JPA伺服器具有資料庫持久性的完整 FHIR 伺服器
驗證基於設定檔的驗證引擎
命令列介面用於遷移、上傳的命令列工具
FHIR版本支援 DSTU2、STU3、R4、R4B、R5

2.使用Spring Boot初始化項目

Maven 依賴項

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>

<properties>
    <hapi.fhir.version>7.4.0</hapi.fhir.version>
</properties>

<dependencies>
    <dependency>
        <groupId>ca.uhn.hapi.fhir</groupId>
        <artifactId>hapi-fhir-jpaserver-starter</artifactId>
        <version>${hapi.fhir.version}</version>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
    </dependency>
    <dependency>
        <groupId>ca.uhn.hapi.fhir</groupId>
        <artifactId>hapi-fhir-structures-r5</artifactId>
        <version>${hapi.fhir.version}</version>
    </dependency>
</dependencies>

應用程式屬性

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/hapi_fhir
    username: hapi
    password: ${DB_PASSWORD}
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: false
        jdbc:
          batch_size: 20
        order_inserts: true
        order_updates: true

hapi:
  fhir:
    fhir_version: R5
    server_address: http://localhost:8080/fhir
    allow_multiple_delete: true
    allow_external_references: true
    default_page_size: 20
    max_page_size: 200
    validation:
      requests_enabled: true
      responses_enabled: false

3.配置FHIR伺服器

@Configuration
public class FhirServerConfig {

    @Bean
    public RestfulServer restfulServer(
            ApplicationContext context,
            FhirContext fhirContext) {
        
        RestfulServer server = new RestfulServer(fhirContext);
        server.setDefaultResponseEncoding(EncodingEnum.JSON);
        server.setDefaultPrettyPrint(true);
        
        // Register resource providers
        server.registerProviders(
            context.getBean(PatientResourceProvider.class),
            context.getBean(ObservationResourceProvider.class),
            context.getBean(EncounterResourceProvider.class)
        );
        
        // Register interceptors
        server.registerInterceptor(new ResponseHighlighterInterceptor());
        server.registerInterceptor(new CorsInterceptor());
        server.registerInterceptor(context.getBean(AuditInterceptor.class));
        
        return server;
    }
    
    @Bean
    public FhirContext fhirContext() {
        return FhirContext.forR5();
    }
}

4. 資源提供者

@Component
public class PatientResourceProvider implements IResourceProvider {
    
    @Autowired
    private PatientRepository patientRepo;
    
    @Override
    public Class<Patient> getResourceType() {
        return Patient.class;
    }
    
    @Read
    public Patient read(@IdParam IdType theId) {
        return patientRepo.findById(theId.getIdPart())
            .orElseThrow(() -> new ResourceNotFoundException(theId));
    }
    
    @Create
    public MethodOutcome create(@ResourceParam Patient patient) {
        // Validate trước khi lưu
        ValidationResult result = validator.validateWithResult(patient);
        if (!result.isSuccessful()) {
            throw new UnprocessableEntityException(
                result.toOperationOutcome());
        }
        
        Patient saved = patientRepo.save(patient);
        return new MethodOutcome()
            .setId(saved.getIdElement())
            .setCreated(true);
    }
    
    @Search
    public List<Patient> searchByName(
            @RequiredParam(name = Patient.SP_NAME) StringParam name) {
        return patientRepo.findByNameContaining(name.getValue());
    }
    
    @Search
    public List<Patient> searchByIdentifier(
            @RequiredParam(name = Patient.SP_IDENTIFIER) 
            TokenParam identifier) {
        return patientRepo.findByIdentifier(
            identifier.getSystem(), identifier.getValue());
    }
    
    @Operation(name = "$everything", idempotent = true)
    public Bundle patientEverything(@IdParam IdType patientId) {
        Bundle bundle = new Bundle();
        bundle.setType(Bundle.BundleType.SEARCHSET);
        
        Patient patient = read(patientId);
        bundle.addEntry().setResource(patient);
        
        // Thêm Encounters, Observations, Conditions...
        encounterRepo.findByPatient(patientId.getIdPart())
            .forEach(e -> bundle.addEntry().setResource(e));
        observationRepo.findBySubject(patientId.getIdPart())
            .forEach(o -> bundle.addEntry().setResource(o));
        
        return bundle;
    }
}

5.攔截器

使用 HAPI FHIR 攔截器模式 掛鉤請求的生命週期。

@Component
public class AuditInterceptor {
    
    @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED)
    public void logIncomingRequest(
            RequestDetails requestDetails) {
        log.info("FHIR Request: {} {} from {}",
            requestDetails.getRequestType(),
            requestDetails.getCompleteUrl(),
            requestDetails.getAttribute("remoteAddr"));
    }
    
    @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
    public void auditCreate(IBaseResource resource,
            RequestDetails requestDetails) {
        AuditEvent audit = new AuditEvent();
        audit.setAction(AuditEvent.AuditEventAction.C);
        audit.setRecorded(new Date());
        
        AuditEvent.AuditEventAgentComponent agent = 
            audit.addAgent();
        agent.setRequestor(true);
        // Set agent from auth context
        
        AuditEvent.AuditEventEntityComponent entity = 
            audit.addEntity();
        entity.setWhat(new Reference(
            resource.getIdElement().toUnqualifiedVersionless()));
        
        auditRepo.save(audit);
    }
}

驗證攔截器

@Component
public class ValidationInterceptor {
    
    @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
    @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
    public void validateResource(IBaseResource resource) {
        FhirValidator validator = fhirContext.newValidator();
        
        // Thêm profile validation
        IValidatorModule module = new FhirInstanceValidator(
            validationSupport);
        validator.registerValidatorModule(module);
        
        ValidationResult result = validator.validateWithResult(resource);
        
        if (!result.isSuccessful()) {
            OperationOutcome oo = (OperationOutcome) 
                result.toOperationOutcome();
            throw new UnprocessableEntityException(
                fhirContext, oo);
        }
    }
}

6. 自訂搜尋參數

{
  "resourceType": "SearchParameter",
  "url": "http://xdev.asia/fhir/SearchParameter/patient-cccd",
  "name": "cccd",
  "status": "active",
  "description": "Tìm kiếm bệnh nhân theo số CCCD",
  "code": "cccd",
  "base": ["Patient"],
  "type": "token",
  "expression": "Patient.identifier.where(system='http://xdev.asia/fhir/sid/cccd').value"
}

暫存器搜尋參數:

curl -X POST http://localhost:8080/fhir/SearchParameter \
  -H "Content-Type: application/fhir+json" \
  -d @search-parameter-cccd.json

# Reindex để áp dụng
curl -X POST http://localhost:8080/fhir/$reindex \
  -H "Content-Type: application/fhir+json" \
  -d '{"resourceType":"Parameters","parameter":[{"name":"url","valueString":"Patient"}]}'

7. 批量資料匯出

# Khởi tạo export Patient và Observation
curl -X GET 'http://localhost:8080/fhir/$export' \
  -H "Accept: application/fhir+json" \
  -H "Prefer: respond-async" \
  -H "Content-Type: application/fhir+json" \
  --data '{
    "resourceType": "Parameters",
    "parameter": [
      {"name": "_type", "valueString": "Patient,Observation"},
      {"name": "_outputFormat", "valueString": "application/fhir+ndjson"}
    ]
  }'

# Response Header: Content-Location: http://localhost:8080/fhir/$export-poll/abc123

# Kiểm tra trạng thái
curl http://localhost:8080/fhir/\$export-poll/abc123

# Download kết quả khi hoàn tất
curl -o patients.ndjson http://localhost:8080/fhir/bulk/abc123/Patient

8.Docker部署

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/hapi-fhir-server.jar app.jar

ENV JAVA_OPTS="-Xmx2g -Xms1g"
ENV SPRING_PROFILES_ACTIVE=production

EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
# docker-compose.yml
services:
  fhir-server:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hapi_fhir
      - SPRING_DATASOURCE_USERNAME=hapi
      - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
      - HAPI_FHIR_SERVER_ADDRESS=https://fhir.xdev.asia/fhir
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/fhir/metadata"]
      interval: 30s
      timeout: 10s
      retries: 5
  
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: hapi_fhir
      POSTGRES_USER: hapi
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U hapi"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

9. 效能調優

配置建議值描述
hibernate.jdbc.batch_size20-50批次插入/更新
最大頁面大小200限制回傳結果
重複使用_緩存_搜尋_結果60000(毫秒)快取搜尋結果
JVM堆2-4GB取決於資源數量
連接池20-50HikariCP 連接
PostgreSQL 共享緩衝區25% 內存資料庫緩衝區

10. 總結

  • HAPI FHIR JPA 伺服器 — 基於 Spring Boot + PostgreSQL 建置的全功能 FHIR 伺服器

  • 資源提供者 — 實現 CRUD、搜尋、自訂操作

  • 攔截器 — 掛鉤請求生命週期以進行稽核、驗證、安全

  • 自訂搜尋參數 — 根據需要擴大搜尋(CCCD、健康保險)

  • 批量資料匯出 — 以NDJSON格式匯出大量數據

  • 碼頭工人 — 使用 docker-compose 進行生產部署