
一次請求走前端 → API → Business Object → Repository → 資料庫,每一層都要拿到同一筆資料。多數系統在這裡放的是自己寫的型別:一張表一個 Entity、前後端各一份 DTO、中間再一層 Mapper 設定。
這套框架放的是兩樣東西:一個資料容器(DataSet),加上一份跟它平行的定義。資料在容器裡,描述資料的那些話在 FormSchema 裡。分家換到一件事:八張表單也好、上千張也好,中間沒有多出任何一個型別。
本篇說明:
Day 1 數過「訂單加一個預計到貨日」落到十三個地方,其中四格是 Entity、DTO、Mapper 與前端型別。這四格為什麼可以是空的,就是這五節合起來的答案。
痛點:使用者要求「訂單多一個預計到貨日」,沒有計算、沒有連動。你打開 Entity 加一個屬性、打開 DTO 加一個、打開 Mapper 加一條、打開前端型別再加一個。四個檔案,一次編譯,一次部署。
這個欄位在業務上什麼事都沒做,但它在型別系統裡留下了四份簽名。
再乘以一千張表,乘以每年幾十次這種需求。
跨層要不要用強型別容器長年有兩派各自的理由,這裡不判高下。強型別換到的東西很明確(編譯器會替你看名字),而那正是這裡要付的代價。這一節只回答:這套框架在這個位置需要的是什麼樣的東西。
答案是一個在編譯的時候還不知道自己有幾個欄位的容器,理由兩個:
Day 1 說「改定義到生效沒有編譯打包部署」,這句話的成敗有一半押在這一層上。
第二個痛點沒那麼刺眼,但更花時間。
一個欄位真正要交代的事遠不只「叫什麼、什麼型別」:必不必填?清單上顯不顯示?寬度多少?是不是金額、留幾位小數?指向客戶那張表單,選完要把哪幾個值回填到哪幾個欄位?開窗挑選時要不要只列啟用中的?
這些話在一份 DTO 裡沒有現成的位置。型別能表達的就是屬性名與屬性型別,其餘全部得靠一疊標註往上堆,而讀這些話的又不是同一層。於是它們散開:
| 這件事 | 散到哪裡 |
|---|---|
| 必填規則 | 驗證層 |
| 清單欄位 | 前端設定 |
| 關連 | 資料庫的外鍵 |
| 顯示格式 | 某個共用的工具方法 |
散開的代價不是檔案變多,是沒有一處是權威。同一件事在好幾個地方各自宣告一次,任何兩處都可能對不起來,而對不起來的時候沒有人會報錯。
框架的做法是把兩件事拆開:資料走容器,描述資料的話走定義。
拆開之後描述就不必再擠進型別系統。Day 2 把一個欄位能宣告的東西分成六類(結構、行為、呈現、語意、關連、安全),而一個屬性本身寫得出來的只有第一類的頭兩樣:欄位名與資料型別。
這六類裡,關連要多說幾句。Day 3 講過關連宣告在表單之間,指向的是「客戶」這支程式。一個型別可以持有另一個型別的參考,但那個參考只說得出「我指向它」,說不出清單顯示哪幾欄、開窗給人看什麼、哪些欄位可查詢、要不要再加條件。表單之間的關係比型別之間的關係寬,硬用型別表達,多出來的那幾件事就會被擠到型別以外的地方去。
拆開還有一個回報:
DTO 上的描述只有編譯器讀得懂;抽離出來的描述,誰都讀得懂。
「不寫 DTO」不代表把資料丟成一包沒有形狀的東西。形狀還在,只是它不是型別,而是跟著資料一起走的一份描述。
送出去的時候,一張表格帶的是:
收到的人不需要事先認識它。因為描述跟著資料一起到,所以沒有「型別對不上」這種事;也因為每一層拿到的是同一個東西,所以從資料庫讀出來到送進前端控件,沒有一次「把 A 轉成 B」。少掉的不只是那幾行 Mapper 設定,是那幾行會出的所有錯。
容器裡可以不只一張表格,而 ERP 幾乎每一張單據都需要這件事:
sys_master_rowid 掛回主檔FormSchema 已經講過的事每一列身上還帶著兩樣東西:它是新增 / 改過 / 刪掉 / 沒動過,以及它原本的值。這讓存檔那一趟不必附帶任何說明。使用者改了主檔兩個欄位、刪掉一列明細、又加了一列,送出去的就是這個容器本身,伺服器照每一列的狀態決定下哪一句語句。
代價 Day 2 已經記在適用邊界:每次回應都帶著容器裡那份欄位清單一起送。一張訂單值得,每秒上萬筆的事件流不值得。
資料與定義拆開之後,兩邊的接點是欄位名稱,而欄位名稱是字串。
row["amout"] 少打一個字母,編譯器一聲不吭,要到那一行真的被執行才丟出例外。更麻煩的是失敗的形狀不只一種:清單欄位打錯一個字,畫面上只是少一欄,同一個名字送進查詢卻會在組 SQL 時直接失敗;開窗挑選的欄位打錯則是從頭到尾安靜,那一欄不會出現,日誌也是乾淨的。
同一個名字在不同層寫法不一致會更難查,而有些前端控件的資料繫結是區分大小寫的。容器裡的欄名因此一律小寫,跟定義檔、資料庫欄位、算式識別字、前端繫結鍵對齊,名字全程只有一組,大小寫差異這一類錯誤就沒有發生的餘地。
編譯期那道關卡補不回來,也不必假裝補得回來。想在編譯期檢查一個執行期才決定的形狀,這個要求本身就是矛盾的。
能做的是把發現錯誤的時機往前挪,挪到形狀已經確定的那一刻。定義寫完,形狀就確定了。往前挪有兩站。
框架把這些檢查做在 Bee.Analyzers 這個專案裡,包進定義層的套件一起發佈,引用就生效,不必額外安裝。其中大半讀的不是程式碼,是定義檔本身那些 XML。
十九條分四組:
下面兩個是實際跑出來的。把訂單清單欄位裡的 ref_customer_name 打成 ref_customer_nmae:
error BEE1004: FormSchema 'Order' lists 'ref_customer_nmae' in ListFields,
but the schema declares no such field. The list layout drops the unknown
column, but the query keeps it: building the SELECT throws
InvalidOperationException at run time. Fix: correct the name, or declare
the field.
訊息有三部分:哪裡錯了、不修的話執行期會怎麼失敗、以及修法。中間那句最該有,因為執行期會壞成什麼樣子,決定的正是這個錯誤好不好查。
第二個對應 Day 6 那個代價(FormSchema 加了欄位、TableSchema 忘了補):
error BEE2006: FormSchema 'Order' declares persisted field 'required_date'
on table 'ft_order', but the table schema has no such column. Queries
touching that field fail at run time. Fix: add the column to the table
schema, or mark the field Type="VirtualField" if it is not persisted.
兩次建置都停在定義檔那一行上。Day 1 那個「加一個欄位要改三處」漏掉其中一處的下場,現在是一次建置失敗,而不是一張上線後的問題單。
第一站攔的是定義自己寫錯。攔不到的是「定義都對,但這張單跑出來的結果不對」,那要靠資料。做法:
前面三步只證明資料進得去,最後那一步才問結果對不對。頭尾各釘一次,中間那一整段都在被驗,而不必為其中每一步各寫一個斷言。
這一套寫得成一個工具,是因為描述被抽離出來了。它是一份程式讀得到的資料,向 API 要一次就拿得到,所以工具不必先認得任何一張表單,加表單、改欄位它也不必跟著動。改版之後整批重跑一次,就知道有沒有壞。
這條路刻意跳過前端,理由不是省事。畫面上的控件是框架自己的一組,欄位用哪個控件是照定義算出來的,同一種型別的欄位在每張表單上長出來的是同一個控件、走同一段程式。
於是錯誤的分布形狀變了:控件某個行為壞掉,壞的是全面,不是某一張表單。
Day 7 說渲染層是一個收斂點,手寫畫面一次錯一張、渲染層一次錯全部。那句話當時講的是風險,在測試上是另一面:一次錯全部的東西藏不住,隨便開一張表單就撞得到。逐張點畫面,點到的是同一段程式的第 N 次執行。
一套完整的套裝 ERP 業務資料表動輒數千張,表與表高度牽連。在這個量級上「每張表單配一組型別」不是麻煩,是管不動;Definition-Driven 要付的那些代價,也只有在這個量級上才值得付。
第一個回報是加一張表單不必寫程式:三份新檔案加三行登錄就長得出來。案例八張表單有七張是這樣來的,資料一路從資料庫走到畫面,中間經過的層一個不少,而應用一行都沒寫;剩下的那一張是訂單,它多寫了幾條定義表達不了的規則。
第二個回報是成本從乘法變成加法。加一個欄位,FormSchema 加一行、TableSchema 加一行、FormLayout 加一行,然後資料庫多一欄、CRUD 語句多一欄、容器多一欄、送到前端的欄位清單多一欄、畫面多一個控件。這條路上沒有一個中間型別。Day 1 那十三格裡的四格,在這個模式下不是比較好改,是根本不存在。
加一條關連、加一個前端也是加法。型別的世界裡每多一條關連就要決定它怎麼表達、什麼時候載入、誰維護;這裡只是在定義上多宣告一段,帶出來的顯示欄在容器裡多幾個欄。Day 7 說多支援一個端要付一個渲染層,而那個端不必自備一份型別,因為它收到的資料自己帶著描述。
資料與定義分家,表單不必各自建型別,這句話真正說的不是「省掉幾個檔案」。它是三件事的總和:
三件缺一件,分家就分不成。
分家是有代價的。編譯器少替你看了一類東西,這筆帳沒有消失,只是挪到前面那兩站去付:能從定義本身看出來的錯,建置的時候就攔下來;攔不住的,用同一份定義把資料準備好跑一遍,最後拿報表逐格對。
一個設計誠不誠實,看的不是它有沒有代價,是它知不知道自己的代價落在哪裡。
明天談這些資料最後怎麼進到資料庫:一套框架怎麼在不預先認識任何一家資料庫驅動程式的前提下,還替每一家產生得出 CRUD 語句,也改得動它們的結構。
本系列同步發表於 HackMD,完整目錄