下拉重新整理

開發環境準備

3,649 字 10 分鐘閱讀 11 次閱讀

操作依據:SMART JS Client

火線超人後來接觸的測試伺服器變多,每台的 Base URL、OAuth 能力與 client 類型都不一樣,才逐步整理出 FhirServerRegistry,把「這個 app 現在連哪裡」集中管好。

我的體會是:開發環境不只是把專案跑起來,還要知道資料從哪裡來。 今天先固定一組公開、可重複的組合,當作後續拆解授權的起點。

先把三個角色分開

「sandbox」這個字常常把不同東西混在一起。在這個系列裡,我們會同時用到三個角色:

角色 我們使用的工具 用途
模擬 EHR SMART Health IT Launcher 從 EHR 啟動 app,附帶 isslaunch 與模擬病人情境
開放 FHIR server https://r4.smarthealthit.org 先測試 FHIR 查詢與程式連線,不需授權
我們的 app 本機的一個資料夾 寫 JS、承接啟動參數,後續完成 OAuth 流程

這三者不能互相取代。開放 FHIR server 很適合測試查詢,但它沒有登入、scope 與 launch context,因此還不是完整的 SMART 環境。Launcher 才是後面模擬 EHR Launch 與 Standalone Launch 的練習場。

表格裡的 iss 和 launch 是什麼

這兩個參數之後每天都會遇到,先講清楚。

醫師在病歷系統裡點開你的 app 時,EHR 不是直接把畫面叫出來就結束,而是先轉址到你登記的啟動網址,並在後面掛上兩個參數:

https://你的app/launch
  ?iss=https://launch.smarthealthit.org/v/r4/fhir
  &launch=WzAsIiIsIiIsIkFVVE8iXQ

iss 是 issuer 的縮寫,值就是這次要連的 FHIR 伺服器位址。它解決一個很實際的問題:同一支 app 可能今天被 A 醫院啟動、明天被 B 醫院啟動,你寫程式時根本不會知道要連哪一台,所以由啟動方直接告訴你。這也是為什麼前面那段程式碼要把 Base URL 集中在一個地方,因為它遲早不會是寫死的。

launch 則是一串你看不懂、也不需要看懂的識別碼。規格上它是「不透明的」(opaque),意思是內容由發出它的 EHR 自己定義,你不該去猜也不該去解。你唯一要做的是原樣把它帶去授權伺服器,對方就知道「這次啟動是哪位醫師、他正在看哪個病人」,然後把這些資訊隨著 token 一起還給你。

換句話說,app 不必自己問「現在是哪位病人」,那是啟動流程幫你帶進來的。這正是從 EHR 裡被點開,跟使用者自己開 app 最大的差別。明天整篇就在拆這兩種模式。

使用 SMART Health IT Launcher 不用先註冊帳號,也不用向真實 EHR 申請 client。開啟 SMART Health IT Launcher 就能練習。sandbox 只能放測試資料,不要放入真實病人資料。

那些測試病人是哪來的

點開 sandbox 你會看到 Jerrell Gerlach、Marlin Kuphal 這種名字,生日、用藥、檢驗值一應俱全。這些不是真人去識別化來的,是 Synthea 這套開源工具合成出來的。它由非營利機構 MITRE 開發,Apache-2.0 授權,可以直接輸出 FHIR R4、C-CDA 或 CSV。

Synthea 的做法是替每個虛擬病人跑一遍「人生」:依流行病學模型決定他何時得什麼病、走哪條臨床照護流程、開什麼藥、做哪些檢驗,然後把整段病史輸出成 FHIR 資源。所以那些資料在統計上像真的,臨床邏輯也接得起來。體重紀錄會隨時間變化,糖尿病的病人會有對應的用藥與追蹤檢驗。

知道這件事有兩個好處。一是你可以放心地在文章、簡報、公開 repo 裡使用這些資料,不涉及任何個資。二是當你看到某個病人有 136 筆 Observation 卻只有 2 筆 Condition 時,那不是資料殘缺,是模型跑出來的合理結果。

準備本機工具

這個系列只需要三樣東西:現代瀏覽器、文字編輯器、一個靜態伺服器

沒有 Node.js、沒有 npm、沒有打包工具。這是刻意的選擇,理由有三個。

第一,SMART 的核心是協定不是工具鏈。 這 30 天要拆的是 OAuth 2.0 的授權流程、FHIR 的資源模型、token 的生命週期,這些全部發生在 HTTP 層。中間插一層打包器只會讓你在「為什麼建置失敗」上花掉本來要用來理解 code_challenge(PKCE 的核心參數,純前端 app 沒有 client secret 也能安全授權就是靠它,day08 會整篇拆)的時間。

第二,現代瀏覽器已經夠用。 ES modules、fetchcrypto.subtle 這些我們會用到的東西,瀏覽器原生就支援。過去需要打包器是為了讓舊瀏覽器看得懂新語法,現在這個前提不成立了。

第三,門檻越低越多人跟得上。 要人先裝 Node 再學 npm,光是這一步就會勸退一部分讀者,而這個系列的目的正好相反。

ES module 是什麼

早期的 JavaScript 沒有「模組」這回事。你在 HTML 裡放五個 <script>,這五個檔案的變數就全部擠在同一個全域空間裡,載入順序排錯會壞,兩個檔案不小心用了同名變數會互相蓋掉。想把程式碼分檔又不出事,過去只能靠打包器幫你合併、改名、包進一層函式裡。

ES module 是瀏覽器後來內建的解法。只要把 <script> 標上 type="module",這個檔案就有兩個關鍵變化:

  • 可以用 importexport:檔案之間直接互相要東西、給東西,不必經過全域
  • 有自己的作用域:裡面宣告的變數不會外洩出去,兩個檔案各自宣告 client 也不會打架

所以待會你會看到 app.js 開頭那行 export,它不是裝飾。後面幾天我們會把授權流程拆成 discovery.jspkce.jsauth.js 這幾個檔案,它們要用到伺服器位址就直接 import 那一行,全程不需要打包器。

左右對照圖。左半邊是一塊深藍色區域,標題為傳統的 script src,四個程式檔圖示各有一條箭頭指進下方同一個名為 window 的虛線框,框內放著 baseUrl 與 token 兩個灰色標籤,以及兩個疊在一起的同名 client 標籤,用珊瑚紅標示並附一個警示三角形與「蓋掉」字樣;區塊下方結論是「共用一個空間」。右半邊是白底區域,標題為 script type=module,discovery.js、pkce.js、auth.js 三張獨立卡片分別 export baseUrl、verifier 與 authorize,各自拉出一條 90 度轉角的線、走各自的路徑指向下方的 app.js,線旁標著 import;區塊下方結論是「各自一個空間」

左邊那兩個 client 撞在一起的時候,瀏覽器不會報錯,也不會警告,後載入的那個就這樣安靜地蓋掉前一個。你只會發現程式行為變得很奇怪,卻找不到是哪一行害的。打包器當年做的事情之一就是幫你把這些名字改掉,讓分檔變成一件安全的事。現在這個問題在語言層面就解決了,所以那層工具也就不必要了。

反過來看 fhir-client.pure.min.js,它走的是傳統的 <script src>,沒有這層隔離,載入後直接在全域掛一個 FHIR 物件。這就是為什麼待會 app.js 裡可以劈頭就用 FHIR,卻不必先 import 它。

支援度不用擔心,2017 到 2018 年間主流瀏覽器就陸續內建了。但模組多了一條規矩:它是照跨來源的規則去抓檔案的,這件事馬上就會咬到我們。

靜態伺服器則不能省。你可能會想「直接用瀏覽器開 HTML 檔不就好了」,但 file:// 開啟的頁面有兩個限制。

第一個限制跟同源政策有關。這個詞常被當成大家都懂,其實值得停下來說清楚,因為後面拆授權時還會遇到它。

瀏覽器判斷兩個網址是不是「同源」,看三件事:協定、網域、埠號,三個全都一樣才算同源。所以 https://example.com/ahttps://example.com/b 同源;但 http://example.com 換了協定、https://api.example.com 換了網域、https://example.com:8080 換了埠號,這三個都不同源。不同源的資源要互相載入,得由對方明確表態允許,這套「表態」機制就是 CORS。

問題在於 file:// 開的頁面既沒有網域也沒有埠號,瀏覽器沒辦法比對,乾脆把每一個 file 頁面都當成獨立的來源,代號 null。於是即使 index.htmlapp.js 就躺在同一個資料夾裡,載入時仍被當成跨來源請求,而 CORS 只支援 http、https 這幾種協定,file:// 不在名單上。Console 會這樣抱怨:

Access to script at 'file:///…/app.js' from origin 'null'
has been blocked by CORS policy

值得注意的是傳統的 <script src> 不受這條限制,所以 fhir-client.pure.min.jsfile:// 反而載得進來,只有 type="module" 的檔案會被擋。這也是為什麼有些人「明明照著做卻只有一半壞掉」。

第二個限制是授權伺服器的 redirect_uri 必須是 http 位址,file:// 不能當轉址目標。後面幾天走授權流程時一定會撞到。

macOS 與多數 Linux 內建 Python,一行就能起:

python3 -m http.server 5173

Windows 或不想裝 Python 的話,VS Code 的 Live Server 擴充功能按一下就好,效果一樣。

建立專案

建一個資料夾,裡面放三個東西:

smart-app/
├── index.html
├── app.js
└── vendor/
    └── fhir-client.pure.min.js

vendor/ 裡那個檔案是 SMART Health IT 官方維護的 JavaScript client,直接下載進專案:

mkdir -p smart-app/vendor && cd smart-app
curl -o vendor/fhir-client.pure.min.js \
  https://cdn.jsdelivr.net/npm/[email protected]/build/fhir-client.pure.min.js

抓下來只有 54 KB。它叫 pure 是因為不含給舊瀏覽器的 polyfill,完整版有 214 KB,我們用不到那些相容層。

為什麼要下載而不是從 CDN 引用?三個理由。一是你的 app 在執行時不會對外發請求,少一個會壞的環節;二是版本鎖死,哪天 CDN 上的檔案改了你的專案不會跟著變;三是這個檔案進了版控,任何人 clone 下來就能跑,不需要網路。

在醫療場域這第三點特別實際,很多醫院的開發機是不能連外網的。

不要把伺服器網址散落各處

index.html 長這樣:

<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="utf-8" />
    <title>SMART App</title>
  </head>
  <body>
    <div id="app">正在連線到公開 FHIR server…</div>
    <script src="vendor/fhir-client.pure.min.js"></script>
    <script type="module" src="app.js"></script>
  </body>
</html>

兩個 script 標籤就是前面說的那兩種載入方式:fhir-client.pure.min.js 走傳統的 <script src>,掛出全域的 FHIRapp.jstype="module",後面幾天才拆得開。

接著是 app.js

// 連線設定集中在一處,換 sandbox 只改這裡
export const FHIR_BASE_URL = 'https://r4.smarthealthit.org'

const result = document.querySelector('#app')

const client = FHIR.client({ serverUrl: FHIR_BASE_URL })

client
  .request('Patient?_count=1')
  .then((bundle) => {
    const patient = bundle.entry?.[0]?.resource
    result.textContent = patient
      ? `連線成功:Patient/${patient.id}`
      : '連線成功,但沒有找到 Patient 資料'
  })
  .catch((error) => {
    result.textContent = `連線失敗:${error.message}`
  })

把 Base URL 放在檔案頂端並 export 出來,換 sandbox 時就不用到處找字串,後面幾天新增的模組也能直接 import 它。這和火線超人 FhirServerRegistry 的思路一樣:連線設定要集中。

有一件事現在講、之後每天都適用:這個檔案會被瀏覽器原封不動下載,任何人按 F12 都看得到。所以裡面只能放 Base URL、client ID 這類公開設定,不能放 client secret、密碼或真實 token。這不是「記得別放」的層次,是「放了就等於公開」。

跟著做:確認你的起點

現在做一次完整驗收:

  1. smart-app 目錄執行 python3 -m http.server 5173
  2. 瀏覽器開啟 http://localhost:5173
  3. 頁面應該從「正在連線」變成「連線成功:Patient/…」。這代表靜態伺服器、fhirclient、公開 FHIR server 與瀏覽器 CORS 都已經對上。
  4. 按 F12 打開 Console,確認沒有紅色錯誤。
  5. 另開分頁進入 SMART Health IT Launcher,確認操作頁可以載入。今天先不按 Launch,因為還沒寫授權入口。

瀏覽器視窗,網址列顯示 http://localhost:5173,頁面內容為「連線成功:Patient/adbf81ef-0d6d-40ad-b7ee-8b301b74a3e3」,下方 Console 面板沒有任何訊息

畫面上那串 id 每個人跑出來不會一樣。_count=1 只是請伺服器隨手給第一筆,換個時間跑可能換一個病人,所以看到任何一組 id 都算成功。

如果卡住了,照這個順序排查:

頁面停在「正在連線」、Console 出現 blocked by CORS policy 而且 origin 顯示 null,就是前面說的 file:// 問題,你直接用瀏覽器開了 HTML 檔。回到步驟 1 用靜態伺服器啟動。網址列開頭是 file:/// 而不是 http://localhost 就是徵狀。

瀏覽器視窗,網址列顯示 file:///Users/you/smart-app/index.html,頁面停在「正在連線到公開 FHIR server…」,Console 面板出現紅色錯誤:Access to script at file:///Users/you/smart-app/app.js from origin null has been blocked by CORS policy,第二行是 net::ERR_FAILED 載入失敗

值得一提的是,這一頁裡的 fhir-client.pure.min.js 其實有載進來,被擋的只有 app.js。所以你可能會遇到「一半正常一半壞掉」的狀況,那不是哪裡打錯了,是兩個 script 標籤適用的規則不同。

出現 FHIR is not defined,代表 vendor/fhir-client.pure.min.js 沒載到。檢查檔案是不是真的在那個路徑,以及 curl 有沒有抓成功。如果網址打錯,你會拿到一個內容是 404 頁面的檔案,大小只有幾百位元組而不是 54 KB。

畫面停在「正在連線」不動,開 Network 分頁看那筆 Patient?_count=1 的請求。狀態是紅色的 CORS 錯誤,表示瀏覽器擋下了跨來源請求;狀態是 200 但頁面沒變,問題在你的 .then() 裡。

顯示「連線失敗」,把 FHIR_BASE_URL 改成備援的 https://hapi.fhir.org/baseR4 再重整。備援能連、SMART 伺服器不能連,問題通常在遠端服務;兩者都不能連,再檢查本機網路或防火牆。

小結

今天把開發地基鋪好了:一個不需帳號的 SMART Launcher、一個可查詢的公開 FHIR server,加上一個不需要 Node.js 也不需要打包工具的本機專案:一個 HTML、一個 JS、一個 vendor 進來的 54 KB 函式庫。

也順帶認識了 Synthea:那些看起來很真的測試病人,是照流行病學模型合成出來的,用起來不必擔心個資。

當測試伺服器變多時,記得把連線設定集中管理,這是火線超人後來長出 FhirServerRegistry 的原因。

第一幕到這裡結束。明天正式進入 SMART 核心,先把 EHR Launch 與 Standalone Launch 這兩種啟動模式擺在一起,看懂 app 究竟是從哪裡出發的。