iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

SMART on FHIR 開發之路:30 天做一個跨醫院的 app系列 第 20

Day20 - FHIR 搜尋,分頁不要自己算

  • 分享至 

  • xImage
  •  

本文同步發表於個人部落格:FHIR 搜尋,分頁不要自己算


火線超人一次要查好幾家醫院。day19 講的是一台掛掉不能拖累其他台。今天這條也寫在同一份規格裡:每一台的查詢要同時發出去,不能一台跑完才換下一台。

三家醫院各花兩秒,排隊跑是六秒,一起跑是兩秒。使用者在 LINE 裡等六秒,早就切去別的對話了。

三個月查不到就放寬到十二個月那條,day19 也提過。今天換個角度看它:要查幾個月不是寫死的數字,本來就是查詢的一部分。

今天要處理的是兩件事。一是查詢怎麼寫才對,二是資料多到一頁裝不下的時候怎麼辦。

先看一個直覺但會出事的做法

到目前為止我們的查詢都很小。三筆病況、兩筆用藥、十筆血壓,一頁就裝完了。

換一個大一點的:這位病人全部的 Observation。

GET /Observation?patient=018f428e-34f6-4707-8009-5ad742f901e7&_count=10

回來的 Bundle 裡 total 是 94,但 entry 只有 10 筆。

直覺的做法是自己算。既然一頁 10 筆、總共 94 筆,那就發 10 次請求,每次把 offset 往後推 10。

在這台伺服器上這樣行不通。

next 跟你送出去的查詢長得完全不一樣

Bundle 的 link 陣列是這樣:

[
  {
    "relation": "self",
    "url": ".../fhir/Observation?_count=10&patient=018f428e-34f6-4707-8009-5ad742f901e7"
  },
  {
    "relation": "next",
    "url": ".../fhir?_getpages=f18b16bc-a4dc-4497-96b4-070a36e62fc5&_getpagesoffset=10&_count=10&_pretty=true&_bundletype=searchset"
  }
]

self 就是你送出去的那個查詢。next 完全變了個樣:

URL 拆解對照圖,米色底,標題「self 與 next,兩個網址長得完全不一樣」,副標「同一次搜尋回來的 link 陣列,兩個 URL 逐字取自實跑」。最上方一條深藍色橫帶,左邊灰字「回應裡的」接白色等寬粗體 Bundle.link,右邊灰字「兩個 relation,長得不一樣」。橫帶下方是一張白色卡片,左緣一條深藍直條。卡片第一行是等寬深藍粗體,欄名 relation,值是 self,後面接灰字「你送出去的那個查詢」。第二行是淺灰標籤「共同前綴」,接淺灰等寬字 https://launch.smarthealthit.org/v/r4/sim/WzMs…Il0/fhir。第三行是等寬字的網址尾段 /Observation?_count=10&patient=018f428e-34f6-4707-8009-5ad742f901e7,其中 /Observation 與 &patient=018f428e-34f6-4707-8009-5ad742f901e7 兩段以淺藍底深藍粗體標示,中間夾著灰色的 _count=10。被標示的這兩段各有一條珊瑚色直線垂直往下,接到兩個白底珊瑚色外框的圓角膠囊,膠囊左側各有一個珊瑚色圓形叉號,左邊那個寫「路徑上這一段不見了」,右邊那個寫「patient 參數不見了」。畫面下半是一張深藍色卡片。第一行是白色等寬粗體,欄名 relation,值是 next,後面接淺藍灰字「伺服器叫你照抄的那個」。第二行同樣是「共同前綴」加上與上面卡片逐字相同的那一段網址。第三行由一個問號起頭,接珊瑚色底白字的 _getpages=f18b16bc-a4dc-4497-96b4-070a36e62fc5,再接深珊瑚色底白字的 &_getpagesoffset=10,這兩段底下各有一行珊瑚色小字,左邊寫「這次搜尋結果的 id」,右邊寫「偏移量」。最後一行是淺藍灰等寬字 &_count=10&_pretty=true&_bundletype=searchset。圖片下方一行灰字:每次搜尋都是新的一串,這個網址不能存起來重用

那串 _getpages 是伺服器把這次搜尋的結果暫存起來的 id。你猜不出來,也組不出來。而且它每次搜尋都不一樣。我前後跑了四次,四次都是不同的 UUID,所以那串東西不能存起來重用。

結論只有一句:分頁的唯一正確做法是照抄 next 的網址。

_offset_skip 這類參數,FHIR 規範根本沒有定義,是某些伺服器自己加的。寫死它等於綁定特定伺服器。

要一頁一頁跟到什麼時候

判斷的方式不是「數量夠了」,是最後一頁沒有 next

export async function fetchAllPagesByHand(client, query) {
  let url = query
  const resources = []

  while (url) {
    const bundle = await client.request(url)
    const links = Object.fromEntries(
      (bundle.link ?? []).map((link) => [link.relation, link.url])
    )
    for (const entry of bundle.entry ?? []) {
      if ((entry.search?.mode ?? 'match') === 'match') {
        resources.push(entry.resource)
      }
    }
    url = links.next ?? null
  }

  return resources
}

這一版只留翻頁的基本流程,回傳一個資源陣列。等一下「跟著做」那節會給完整版,多記每一頁的 link 與筆數,回傳也換成 { pages, resources }

實跑結果:10 頁、94 筆,與第一頁的 total 對得上。

分頁逐頁對照圖,米色底,標題「十頁跟到底,最後一頁沒有 next」,副標「同一個查詢一路照抄 next,逐頁記下 link 的 relation」。最上方一條深藍色橫帶,左邊是等寬字的 /Observation?patient=018f428e-34f6-4707-8009-5ad742f901e7&_count=10,右邊灰字「第 1 頁回報」接白色粗體 total 94。橫帶下方三張並排的卡片,卡片之間各有一個灰色小箭頭指向右邊。左卡白底,標題「第 1 頁」,底下等寬灰字 entry 10 筆,一條分隔線之後是小標「link 的 relation」,接著三列:淺藍底深藍粗體的 self、淺藍底深藍粗體的 next、灰色虛線框的 previous 並在右側附註「還沒有上一頁」。中卡白底,標題「第 2 至 9 頁」,等寬灰字 entry 各 10 筆,三列 self、next、previous 全部是淺藍底深藍粗體。右卡是深藍底白字,標題「第 10 頁」,等寬字 entry 4 筆,三列由上而下是深藍底白字的 self、珊瑚色虛線框的 next 並在右側以珊瑚色附註「跟到這裡結束」、深藍底白字的 previous。三張卡片下方是一條白底加灰色虛線框的橫帶,左邊等寬字寫 10 + 8 × 10 + 4 = 94 筆,其中 94 筆是深藍粗體,右邊灰字寫「跟了 10 頁,加總與第 1 頁的 total 對得上」。圖片下方一行灰字:頁數與每頁筆數都是伺服器決定的,程式這邊唯一能問的只有一件事:next 還在不在

不要拿 total 當成停下來的依據。 理由有兩個。一是 day03 講過 total 是選填的,大範圍查詢常常整個欄位都不回。二是資料在你分頁的過程中可能被別人改動。規範把這種情況怎麼處理留給搜尋引擎決定,所以就算 total 有值也不能拿它取代 next

fhirclient 替你做了這件事

上面那十幾行,client.request() 兩個選項就解決:

client.request(query, { pageLimit: 0, flat: true })

pageLimit: 0 是「不限頁數,跟到底」。預設值是 1,只拿第一頁。這個預設很容易踩到,你以為抓完了,其實只有第一頁。

flat: true 是「把每一頁的 entry 攤平成一個資源陣列」,省掉自己 map 的功夫。

day16 那兩個查詢一開始就用了這組選項,當時只丟了一句「自己做要寫十幾行」。那十幾行你現在看過了。

一次帶回關聯資源

趨勢圖上想標出每一筆血壓是哪一次就診量的。Observation 的 encounter 只給你一個 reference:

"encounter": { "reference": "Encounter/9af7d15f-c9f0-40cc-a4d1-d66a45f8ac5f" }

笨方法是列出十筆血壓之後,再發十次請求去讀 Encounter。十筆就是十一次來回。

_include 讓伺服器一次全帶回來:

GET /Observation?patient=<id>&_count=3&_include=Observation:encounter

實跑回來 6 筆 entry。3 筆是查到的 Observation,另外 3 筆是順便帶回來的 Encounter。

分辨的依據是 entry.search.mode

_include 回應組成圖,米色底,標題「6 筆 entry,只有 3 筆是你查的」,副標「同一個 Bundle 裡兩種 search.mode,實跑取樣自 _include 的回應」。最上方一條深藍色橫帶,裡面是等寬字的查詢 Observation?patient=…&amp;_count=3,後面接以珊瑚色粗體標示的 _include 參數,值指向 Observation 的 encounter。橫帶下方一行小字「entry 共 6 筆」接灰字「回應裡的順序不保證,型別也不只一種」。再往下是一條由六個等寬格子組成的橫列,左邊三格是深藍底白字的 Observation,右邊三格是珊瑚色底白字的 Encounter。第一格底下有一條深藍色連線往下走再轉右,接到左下角的白色卡片;第六格底下有一條珊瑚色連線往下走再轉左,接到右下角的白色卡片,兩條線各走各的通道沒有交會。左下卡片左緣是深藍直條,第一行是等寬深藍粗體,欄名 search.mode,值是 match;第二行是深藍色的大字 3 加小字「筆」,同一行最右邊是等寬灰字 Observation;第三行是灰字「搜尋條件真的命中的,這 3 筆才該進清單」。右下卡片左緣是珊瑚色直條,第一行是等寬珊瑚色粗體,欄名 search.mode,值是 include;第二行是珊瑚色的大字 3 加小字「筆」,最右邊是等寬灰字 Encounter;第三行是灰字「_include 順便帶回來的,型別跟你查的根本不同」。圖片下方一行灰字:3 筆 Observation 各自的 encounter 都被帶回來,所以這次剛好是一比一,不是每個 _include 都會這麼整齊

不看這個欄位,清單的筆數會直接變成兩倍,而且裡面混著根本不是你要的資源型別。

if ((entry.search?.mode ?? 'match') === 'match') {
  resources.push(entry.resource)
}

那個 ?? 'match' 是有理由的。不帶 _include 的普通搜尋,有些伺服器根本不放 search 這個欄位,預設當成 match 才不會把全部資料濾掉。

一次要帶回好幾種關聯資源時,_include 就重複寫幾次:

GET /Observation?patient=<id>&_count=3
    &_include=Observation:encounter
    &_include=Observation:performer
    &_include=Observation:subject

我用這三個實跑了一次,拿到 7 筆 entry。3 筆 match,4 筆 include。但三個 _include 只有兩個帶回東西。

encounter 回 3 筆,三筆各自的就診事件都不同。subject 只回 1 筆。三筆 Observation 的 subject 都指向同一位病人,同一份資源沒必要回三次。performer 回 0 筆,這三筆 Observation 沒有這個欄位。

所以送三個不等於拿三份。實際幾筆看三件事。一是資源有沒有那個欄位,二是多筆會不會指向同一個目標,三是伺服器支不支援。total 也不會因此變大,實跑仍是 94,它只算 match 的那些。

我還試了 _include=Observation:*,想一次帶回全部 reference。這台回 200,但 entry 只有 3 筆。一筆 include 都沒有,也沒有報錯。規範允許伺服器忽略它不支援的 _include。這台就是這樣,資源少了,錯誤沒有。

日期區間:同名參數是 AND

同一個參數重複出現是 AND,逗號分隔才是 OR。這兩個很容易記反,實測一次就清楚了。血壓是 55284-4,體重是 29463-7,各自單獨查都是 10 筆:

code=55284-4&code=29463-7     total 0
code=55284-4,29463-7          total 20

一筆 Observation 不可能同時是血壓又是體重。重複參數要求兩個條件都成立,所以查出來是 0。逗號是任一個成立就算,所以是 10 加 10。

這條只管搜尋參數。前面那三個 _include 重複寫是三種都帶回來,不是取交集。_include 管的是結果要附帶什麼,不是拿來過濾的條件。

日期區間就是靠這個特性夾出來的:

date=ge2016-01-01&date=le2020-12-31

ge 是大於等於,le 是小於等於,前綴接在值的前面不是另一個參數。兩個條件同時成立,夾出 2016 到 2020 這個區間。

實跑這個查詢的 total 是 5。剛好是 2016 到 2020 那五筆血壓,順序由新到舊。

組查詢字串一定要用 URLSearchParams

export function dateRangeQuery(patientId, code, from, to) {
  const params = new URLSearchParams()
  params.set('patient', patientId)
  params.set('code', code)
  params.append('date', `ge${from}`)
  params.append('date', `le${to}`)
  params.set('_sort', '-date')
  return `Observation?${params}`
}

append 而不是 set,因為 date 要出現兩次。

day16 的查詢只送純代碼,剛好躲過一個坑。自己用字串接 system|code 就會踩到。code=http://loinc.org|55284-4 裡那個 | 不編碼成 %7C,在這台實測查出來會是零筆,而且伺服器不報錯。

常用參數速查

參數 用途 注意
_count 一頁幾筆 伺服器可以回比你要的少
_sort 排序,前面加 - 是反向 不是每個資源都有 date
_include 一併帶回關聯資源 要看 search.mode,重複寫會全部帶回來
_elements 只回指定欄位 省流量,但拿到的資源不完整
date 日期,可加 gele 等前綴 同名重複是 AND
code 代碼,可寫 `system code`

_count 那一列要補一句實測。我送 100、500、1000 都是一次回全部 94 筆,這台不會硬是給你比較少。

但規範允許伺服器回比你要的少。所以判斷還有沒有下一頁,依據永遠是 next 在不在,不是回傳筆數等於 _count_count=0 在規範裡的意思是「只要 total 不要 entry」。這台沒照做,它回了預設的 50 筆。

_elements 那一列也實測過。查 _elements=code 回來的資源只剩四個鍵:codeidmetaresourceType,而且 meta.tag 被標上 SUBSETTED。省流量是真的,但拿到的資源不完整,別把它存進快取當成完整資料。

_sort 那一列 day17 踩過,我當時說排序參數的名稱每種資源都不一樣。

接新伺服器時查它的 CapabilityStatement,就是 GET /metadata 回的那份。rest[0].resource 底下每種資源各帶一個 searchParam 陣列,把合法參數全列出來。

實際拉一次,這台大約 920 KB。裡面有 146 種資源型別。三種我們用過的資源,搜尋參數數量差很多:

資源 搜尋參數 日期類的有哪些
Observation 40 個 datevalue-datecode-value-date
Condition 23 個 onset-daterecorded-dateabatement-date
MedicationRequest 18 個 dateauthoredon

這張表修正 day17 那句話。講得更準一點,不是「每種資源的名字都不同」,而是不能假設每個資源都有 date。Condition 就沒有,它的是 onset-date;MedicationRequest 兩個都有。

Condition 的日期參數有三個,分別對應發病、記錄、緩解。同樣那三件事另外還掛著四個不是日期型別的參數:onset-ageonset-infoabatement-ageabatement-string。加起來跟時間有關的共七個。查「什麼時候得的」跟查「什麼時候記錄的」是兩件事,選錯參數查出來的東西不一樣。

920 KB 拉一次很花時間。這台有個更快的做法:直接送一個錯的參數,回來的錯誤訊息會把合法參數全列出來。回應只有幾 KB,day19 那個 400 就是這樣來的。但規範建議的是忽略不認得的參數。換一台伺服器不保證有這一招,該查的還是 CapabilityStatement。

跟著做:把分頁跟完

起點是 day19 結束時的專案。今天新增 search.js

第一步,手動一頁一頁跟,看清楚 link

export async function fetchAllPagesByHand(client, query) {
  let url = query
  const pages = []
  const resources = []

  while (url) {
    const bundle = await client.request(url)
    const links = Object.fromEntries(
      (bundle.link ?? []).map((link) => [link.relation, link.url])
    )
    pages.push({
      total: bundle.total,
      count: bundle.entry?.length ?? 0,
      relations: Object.keys(links).sort(),
      next: links.next ?? null,
    })
    for (const entry of bundle.entry ?? []) {
      if ((entry.search?.mode ?? 'match') === 'match') {
        resources.push(entry.resource)
      }
    }
    url = links.next ?? null
  }

  return { pages, resources }
}

pages 那個陣列只是為了讓你看見過程,正式程式不需要留著。

第二步,加一顆按鈕跑跑看

index.html 裡加一個區塊,按鈕的 id 等一下要用:

<section class="rounded bg-white p-4 shadow-sm">
  <h2 class="mb-2 font-semibold">分頁</h2>
  <button id="run-paging"
          class="rounded border border-slate-300 px-3 py-1 text-sm">跟完所有分頁</button>
</section>
import { fetchAllPagesByHand } from './search.js'

async function runPaging(client) {
  const { pages, resources } = await fetchAllPagesByHand(
    client,
    `Observation?patient=${client.patient.id}&_count=10`
  )
  console.log('第一頁的 relations:', pages[0].relations)
  console.log('第一頁的 next:', pages[0].next)
  console.log(`跟了 ${pages.length} 頁,拿到 ${resources.length} 筆,total 是 ${pages[0].total}`)
}

按鈕的事件綁定放在 showPatient() 裡面,跟 day18 那顆存回伺服器的按鈕放在一起。要等授權完成拿到 client 才綁得上:

  document
    .querySelector('#run-paging')
    .addEventListener('click', () => runPaging(client))

console 應該印出 10 頁、94 筆、total 94。三個數字要對得起來,對不起來就是哪裡漏了。

pages[0].next 那一行的輸出跟 self 比一比,親眼看到那個 _getpages

第三步,_include 的兩種 mode

export async function withIncluded(client, query) {
  const bundle = await client.request(query)
  const byMode = { match: [], include: [] }
  for (const entry of bundle.entry ?? []) {
    byMode[entry.search?.mode ?? 'match']?.push(entry.resource)
  }
  return byMode
}

Observation?patient=<id>&_count=3&_include=Observation:encounter 跑一次,印出來會是 match 3 筆、include 3 筆。

search.mode 的判斷拿掉再跑一次,你會看到 6 筆全部混在一起,那就是不看這個欄位的下場。

第四步,日期區間

血壓代碼用 day16 那個 BLOOD_PRESSURE,也就是 55284-4

import { dateRangeQuery } from './search.js'
import { BLOOD_PRESSURE } from './vitals.js'

import 放檔首,下面這幾行要放進 showPatient() 或別的拿得到 client 的地方,直接貼在模組頂層 client 還不存在:

  const query = dateRangeQuery(client.patient.id, BLOOD_PRESSURE, '2016-01-01', '2020-12-31')
  const bundle = await client.request(query)
  console.log(query)
  console.log('total:', bundle.total)

total 應該是 5,剛好是 2016 到 2020 那五筆。

le2020-12-31 改成 le2015-12-31 試試,那是一個空區間,total 會變 0。照 AND 的意思推,改成兩個 ge 會被夾成取比較晚的那個,這一種我沒有實跑過。

執行結果圖,淺灰綠底色,標題「search.js 三項驗證跑出來的樣子」,副標「直接跑 search.js,不經瀏覽器,所以視窗上沒有網址列」。中央一個白色圓角面板,最上一列是三個灰色小圓點,右邊接一行灰字「沒有網址列,這一次是腳本實跑」,這一列沒有網址列。下一列是淺灰底的 Console 標籤。面板內容分成三段,每段左上角有一個綠色圓形編號。編號一那段標題「手動一頁一頁跟」,同一行最右邊是灰色等寬字 Observation?patient=…&amp;_count=10;底下三行等寬輸出,第一行是「第一頁的 relations」接一個陣列,裡面是 &quot;next&quot; 與 &quot;self&quot; 兩個字串;第二行是灰色小字「第一頁的 next」接一個問號起頭的網址 _getpages=ee3437b1-4d52-48e6-bdf5-863829a6c4df&amp;_getpagesoffset=10&amp;_count=10 再接一個刪節號;第三行是粗體的「跟了 10 頁,拿到 94 筆,total 是 94」。編號二那段標題是 _include,最右邊是灰色等寬字 &amp;_count=3 接 _include 指向 encounter,底下一行輸出「6 筆 entry:match 3 筆,include 3 筆」,其中 match 3 筆是深色粗體,include 3 筆是珊瑚色粗體。編號三那段標題「日期區間」,最右邊是灰字「dateRangeQuery() 組出來的字串」,底下先是兩行灰色等寬字 Observation?patient=018f428e-34f6-4707-8009-5ad742f901e7&amp;code=55284-4 與 &amp;date=ge2016-01-01&amp;date=le2020-12-31&amp;_sort=-date,再一行粗體 total 5 接「,由新到舊」,最後一行是灰色等寬字列出的五個日期 2020-12-04 / 2019-11-29 / 2018-11-23 / 2017-11-17 / 2016-11-11。面板下方三條綠色圓形編號註記:編號一寫 10 頁、94 筆、total 94 三個數字要對得起來,對不起來就是哪一頁漏了;編號二寫把 search.mode 的判斷拿掉再跑一次,這一行會變成 6 筆全混在一起;編號三寫五筆的完整時間都是 23:37:54+00:00,圖上只留日期

平行不是加個 Promise.all 就好

回到開場那條規則。平行發出很簡單,Promise.all 一行的事,day16 抓血壓跟體重就是這樣寫的。

麻煩的是一台失敗會拖垮全部Promise.all 只要有一個 reject,整個就 reject,你連那些成功的結果都拿不到。

要保住其他台的結果,用 Promise.allSettled

const results = await Promise.allSettled(
  servers.map((server) => queryOne(server))
)

servers 是你要查的那幾台,queryOne 是對其中一台發查詢的函式。兩個都還沒有實作。day21 接第二台伺服器的時候才會寫出來,這裡只是先看寫起來長什麼樣。

Promise.allSettled 回傳的每一項都有 status,是 fulfilledrejected,兩種你都拿得到。這正是 day19 那條「一台逾時不能影響其他台」在程式碼上的樣子。

day21 兩台伺服器一起查時會用到它。

完整可跑的版本在 GitHub 上的 day20 資料夾。想先看跑起來的樣子,可以直接開線上版

小結

今天有三件事要記。一是分頁照抄 next,停的時機是沒有 next,不是數量夠了。二是 _include 回來的東西要看 search.mode。三是同名參數重複出現是 AND,逗號才是 OR。

明天開始最後兩篇。這個 app 目前只認得一台伺服器,而真實的病人不會只去一家醫院。要同時服務兩家,第一個要丟掉的就是「一組 client 設定打天下」這個想法。


上一篇
Day19 - FHIR 伺服器的處理錯誤
下一篇
Day21 - 一組設定打天下行不通
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言