本文同步發表於個人部落格: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。
在這台伺服器上這樣行不通。
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 完全變了個樣:

那串 _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 對得上。

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

不看這個欄位,清單的筆數會直接變成兩倍,而且裡面混著根本不是你要的資源型別。
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,逗號分隔才是 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 |
日期,可加 ge、le 等前綴 |
同名重複是 AND |
code |
代碼,可寫 `system | code` |
_count 那一列要補一句實測。我送 100、500、1000 都是一次回全部 94 筆,這台不會硬是給你比較少。
但規範允許伺服器回比你要的少。所以判斷還有沒有下一頁,依據永遠是 next 在不在,不是回傳筆數等於 _count。_count=0 在規範裡的意思是「只要 total 不要 entry」。這台沒照做,它回了預設的 50 筆。
_elements 那一列也實測過。查 _elements=code 回來的資源只剩四個鍵:code、id、meta、resourceType,而且 meta.tag 被標上 SUBSETTED。省流量是真的,但拿到的資源不完整,別把它存進快取當成完整資料。
_sort 那一列 day17 踩過,我當時說排序參數的名稱每種資源都不一樣。
接新伺服器時查它的 CapabilityStatement,就是 GET /metadata 回的那份。rest[0].resource 底下每種資源各帶一個 searchParam 陣列,把合法參數全列出來。
實際拉一次,這台大約 920 KB。裡面有 146 種資源型別。三種我們用過的資源,搜尋參數數量差很多:
| 資源 | 搜尋參數 | 日期類的有哪些 |
|---|---|---|
| Observation | 40 個 | date、value-date、code-value-date |
| Condition | 23 個 | onset-date、recorded-date、abatement-date |
| MedicationRequest | 18 個 | date、authoredon |
這張表修正 day17 那句話。講得更準一點,不是「每種資源的名字都不同」,而是不能假設每個資源都有 date。Condition 就沒有,它的是 onset-date;MedicationRequest 兩個都有。
Condition 的日期參數有三個,分別對應發病、記錄、緩解。同樣那三件事另外還掛著四個不是日期型別的參數:onset-age、onset-info、abatement-age、abatement-string。加起來跟時間有關的共七個。查「什麼時候得的」跟查「什麼時候記錄的」是兩件事,選錯參數查出來的東西不一樣。
920 KB 拉一次很花時間。這台有個更快的做法:直接送一個錯的參數,回來的錯誤訊息會把合法參數全列出來。回應只有幾 KB,day19 那個 400 就是這樣來的。但規範建議的是忽略不認得的參數。換一台伺服器不保證有這一招,該查的還是 CapabilityStatement。
起點是 day19 結束時的專案。今天新增 search.js。
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 的兩種 modeexport 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 會被夾成取比較晚的那個,這一種我沒有實跑過。

回到開場那條規則。平行發出很簡單,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,是 fulfilled 或 rejected,兩種你都拿得到。這正是 day19 那條「一台逾時不能影響其他台」在程式碼上的樣子。
day21 兩台伺服器一起查時會用到它。
完整可跑的版本在 GitHub 上的 day20 資料夾。想先看跑起來的樣子,可以直接開線上版。
今天有三件事要記。一是分頁照抄 next,停的時機是沒有 next,不是數量夠了。二是 _include 回來的東西要看 search.mode。三是同名參數重複出現是 AND,逗號才是 OR。
明天開始最後兩篇。這個 app 目前只認得一台伺服器,而真實的病人不會只去一家醫院。要同時服務兩家,第一個要丟掉的就是「一組 client 設定打天下」這個想法。