Joe's Blog

iOS 開發筆記

用 Swift 寫一台 FHIR server:司命(Siming)與 generator 護城河

發佈於 2026-07-08

台灣的診所資訊系統如果要跟上 FHIR(醫療資料交換標準),第一個撞到的現實是:業界預設的 server 是 HAPI FHIR——一頭 JVM 巨獸,image 幾百 MB、啟動要一分鐘、吃資源不手軟。對醫學中心無所謂,對一間小診所的自架環境,這個門檻高得很沒必要。

所以我開了一個實驗專案:用 server-side Swift 寫一台 FHIR R4 server,目標很明確——資源需求更低、原生支援台灣的 TW Core IG、一行指令部署,先瞄準小診所。

專案叫 Siming(司命)——掌生死簿的神,管臨床資料剛好對口。這篇講三件事:技術選型、benchmark 結果,以及整個專案我認為最值錢的一個設計決定:search 不是手寫的


🧰 技術選型:把 Swift 當一門正經的後端語言用

選擇為什麼
HTTP frameworkHummingbird 2(SwiftNIO)輕量、現代 concurrency 原生,不揹 Vapor 的全家桶
DBPostgresNIO 直連不用 ORM,手寫 SQL,效能路徑全部自己掌握
FHIR modelsapple/FHIRModelsApple 官方維護的 FHIR 型別,Codable 直接編解碼

「不用 ORM」不是炫技,是這個領域的必然:FHIR 的 search 語意(token、chained search、_has_include、quantity 的 ap 前綴……)沒有任何 ORM 表達得出來,與其跟 ORM 搏鬥不如直接寫 SQL,把資源型別的欄位抽進五張 idx_* 索引表。

做出來的能力清單:23 種 FHIR R4 resource 的 CRUD / search / history / compartment / transaction bundle、TW Core IG v1.0.0 九個 profile 全數通過驗證、SMART on FHIR JWT、內建 resource browser(GET /ui)、Prometheus metrics。部署是一行 bash scripts/setup.sh


📊 Benchmark:跟 HAPI 正面對決

Release build、5,000 筆 patient、兩邊都跑 PostgreSQL,同機對打:

情境SimingHAPI FHIR倍率
GET /Patient/:id15,515 RPS6,883 RPS2.25×
GET /Patient?name=Wang2,512 RPS1,627 RPS1.54×

數字漂亮,但老實說這不是重點——HAPI 揹著十幾年的功能包袱,Siming 只做臨床資料的核心路徑,贏它本來就該的。真正的意義是證明 server-side Swift 在這個量級完全站得住:SwiftNIO 的 event loop + 編譯後的原生碼 + 手寫 SQL,讓一台小診所等級的機器就能跑出這種吞吐。


🏰 護城河:search 是 generate 出來的,不是手寫的

這是整個專案最核心的一個設計決定。

FHIR server 最大的維護噩夢是 search parameter:每種 resource 有幾十個 search param,每個都要「從 resource 的某個路徑抽值 → 寫進正確的索引表」。手寫的話,23 種 resource 就是幾百段長得很像又不能複製貼上的抽取碼;而且 IG(Implementation Guide)一改版,全部重來。

Siming 的做法:這些程式碼全部由 SimingGenerator 從 FHIR package 產生

flowchart LR
    P["packages/*.tgz<br/>hl7.fhir.r4.core<br/>tw.gov.mohw.twcore"]
    P -->|"swift run SimingGenerator<br/>(開發期)"| G["Sources/SimingCore/Generated/<br/>23 個 SearchExtractor<br/>TerminologyBindings<br/><b>commit 進 git</b>"]
    P -->|"server 啟動時<br/>(runtime)"| M["GET /metadata<br/>CapabilityStatement"]
  

同一份 packages/*.tgz 有兩個消費者,跑在不同時間點:

這帶來的 headline property 是:

換 IG = 換一個 package + 重新 generate。不改任何 handler。

TW Core IG 從 1.0 升 2.0?把新的 .tgz 丟進 packages/、跑一次 generator、review Generated/ 底下的 diff——新增或移除的 search param 會以普通程式碼變更的形式出現在 code review 裡。handler 一行都不用動。

配套的紀律只有一條鐵律:generated code 永遠不准手改。生成的檔案有錯,就去修 generator 或修輸入的 package。每個生成檔案的檔頭都印著出處:

1
2
3
// GENERATED — do not edit directly.
// Source: packages/*.tgz (hl7.fhir.r4.core + tw.gov.mohw.twcore)
// Regenerate: swift run SimingGenerator

還有一個我很喜歡的小細節:R4 規格認得、但還沒實作的 search param,generator 會在產出的程式碼裡留下 TODO 標記——覆蓋率的缺口直接長在程式碼上,不會躲在某份沒人更新的文件裡。


🐧 戰爭故事:Linux CI 與 Task 撞名

macOS 上開發一路順風,上了 Linux CI 才發現一個很妙的坑:FHIR R4 有一種 resource 就叫 Task,所以 ModelsR4 模組裡有個 ModelsR4.Task——在 Linux 的編譯環境下,它會把 Swift Concurrency 的 Task 整個遮蔽掉

Task { ... } 突然變成在初始化一個 FHIR 的工作流程 resource,編譯錯誤訊息完全狀況外。解法是明確寫出模組路徑:

1
2
3
4
// ModelsR4.Task(FHIR resource)遮蔽了 Swift Concurrency 的 Task
_Concurrency.Task {
    await doSomething()
}

另一個日常級的坑是 FHIRModels 的 primitive 存取——每個值都包在 FHIRPrimitive<T> 裡,取值路徑不讀 source 猜不到:

1
2
3
4
humanName.family?.value?.string                        // FHIRString → String?
identifier.system?.value?.url.absoluteString           // FHIRURI → String?
patient.birthDate?.value?.year                         // FHIRDate → Int?
patient.gender?.value?.rawValue                        // enum → "male" / "female"

這種 API cheatsheet 我直接寫進專案的 CLAUDE.md——與其每次重新考古,不如把「只有讀過 source 才知道的事」沉淀成工作規則。


🚪 範圍紀律:不早蓋,但也不把門焊死

可行性專案最容易死在範圍膨脹。Siming 的 CLAUDE.md 裡有一條我覺得值得抄走的規則:

Don’t build future features early, but don’t weld future doors shut. (不提早蓋未來的功能,但也不把未來的門焊死。)

具體的取捨:

generator 就是那扇「不焊死的門」:新 IG、新 profile binding、新 search param,全部以資料(package)的形式進來,經過一次 regenerate 變成程式碼,handler 永遠不用重寫。


💡 總結

程式碼、docs(generator 設計、FHIR 實作筆記、TW Core 合規報告)都在 repo:

👉 github.com/shinrenpan/Siming

本文使用 Claude 共同完成