
昨天那些 SQL 都是在存取資料,今天換另一件事:那些資料表本身是怎麼來的。
資料表有哪些欄位、每欄多長、哪幾欄要建索引,權威在 TableSchema 上,資料庫裡的實體結構是照它做出來的產物。這件事在這一層最難兌現:資料庫裡已經有資料,而那些資料不會因為定義改了就自動換一種形狀。
本篇說明:
TableSchema 為何要跟 FormSchema 分家TableSchema 為什麼獨立成一份定義兩份關心的東西不同:
FormSchema |
TableSchema |
|
|---|---|---|
| 關心什麼 | 業務語意:叫什麼、什麼型別、要不要必填、指向哪張表單 | 實體儲存:佔多長、精確到第幾位、哪幾欄建索引、索引唯不唯一 |
| 誰下決定 | 懂業務的人 | 懂這個資料庫的人 |
同一個「訂單編號」,在案例訂單的 ft_order.TableSchema.xml 裡是這樣:
<DbField FieldName="sys_id" Caption="Order No" DbType="String" Length="20" />
<DbTableIndex Name="uk_{0}" Unique="true">
<IndexFields><IndexField FieldName="sys_id" /></IndexFields>
</DbTableIndex>
索引名稱裡的 {0} 是表名的位置,建出來是 uk_ft_order。Day 3 說 sys_id 的唯一性由框架擔保,擔保的位置就是這個唯一索引。
分成兩份的實際回報:調索引、改精度、為了某支報表加一個複合索引,動的只有 TableSchema,FormSchema 完全不必碰,也不必找當初寫那張表單的人。
昨天提過這一份不必從頭寫:框架可以從一份 FormSchema 直接生出對應的 TableSchema。生完之後兩份各自維護,上面那些調整才有地方做。
上面寫的是 DbType="String" Length="20",不是 nvarchar(20);整份定義從頭到尾都是這樣。
開發者能寫的型別是框架給的一組,常用的有字串、長文字、短整數、整數、長整數、小數、金額、日期、日期時間、布林、Guid、二進位、自動編號。翻成哪一種原生型別是產生語句那一刻才決定的,而各家差得比想像中多:
| 框架型別 | SQL Server | PostgreSQL | MySQL | Oracle | SQLite |
|---|---|---|---|---|---|
Guid |
uniqueidentifier |
uuid |
CHAR(36) |
RAW(16) |
UUID |
Boolean |
bit |
boolean |
TINYINT(1) |
NUMBER(1) |
BOOLEAN |
Currency |
decimal(19,4) |
numeric(19,4) |
DECIMAL(19,4) |
NUMBER(19,4) |
NUMERIC(19,4) |
同一個 Guid 欄位,有的是內建型別,有的存成文字,有的存成二進位。把這個決定收進機制,換到四件事:
Guid 存成文字、那張存成二進位,等到跨表查詢才發現接不起來。比對也因此變單純:從資料庫讀回來的欄位先翻回框架型別,再跟定義對。定義說 Guid、MySQL 回報 CHAR(36),翻回來還是 Guid,兩邊一樣就是沒有變更。
這組型別刻意收得很窄,各家特有的東西不在裡面,要用就得走定義以外的路。能表達的少一點,換到的是同一份定義在哪裡都成立。
昨天講過 CRUD 語句照 FormSchema 的欄位集合組出來,而建表與升級走 TableSchema。於是:
FormSchema 加了欄位、TableSchema 忘了補 → 資料庫裡沒有那一欄,而存檔語句偏偏會去寫它,執行期才報錯執行期沒有人替這兩份定義對帳,靠的是改動的順序:欄位先進 FormSchema,TableSchema 跟著補上實體欄位。Day 4 講那個順序時看起來像一句習慣,它是這個代價的操作版本。(建置期有一道關卡專門看這件事,Day 9 會談。)
一般做法是 migration:改完模型、工具產生腳本、進版控、按順序執行,資料庫另外記著跑到哪一版。
這套框架沒有腳本,也沒有版本號。每次升級都是當場問資料庫「你現在長什麼樣」,拿它跟定義比對,算出差異,然後執行。
差別顯現在三處:
換來一句很短的成功契約:
升級成功返回之後,資料庫至少要有定義裡的每一個欄位,而且型別、長度、允不允許 NULL 與定義一致。
重跑安全是同一件事的結果:跑第二次時比對到的現況已經是升級後的樣子,算出來的差異是空的。這個性質在談失敗處理時還要再用一次。
代價三個:
第三個代價有解,就是第四節那個先算不執行的用法,只是它得由部署流程主動去做,不會自己浮到眼前。
| 段 | 產出 | 碰資料庫嗎 |
|---|---|---|
| 比對 | 結構化差異:哪些欄位要新增、哪些欄位的定義變了、哪一欄要改名、哪些索引要建要刪。裡面沒有任何 SQL | 讀 |
| 計畫 | 這次要怎麼做:模式、分幾個階段、每階段哪幾句 SQL、有沒有警告 | 否 |
| 執行 | 實際跑 | 寫 |
拆三段最主要的理由不是分層好看,是中間那一段的產出要給人看。
三段都是應用主動呼叫的。正式環境由部署工具在更版時把 DbCategorySettings 登錄過的表逐一跑過一次,入口是 TableSchemaBuilder,案例那一段在 NorthwindSchemaSeeder.cs 裡:
var builder = new TableSchemaBuilder(category.Id, defineAccess, connectionManager);
foreach (var table in category.Tables)
builder.Execute(category.Id, table.TableName);
那一句 Execute 就是三段接起來跑。要先算不執行就把它拆開,這一步叫乾跑:CompareToDiff 拿差異、TableUpgradeOrchestrator.Plan 拿計畫,看完再決定要不要往下走。
計畫算出來的模式有四種:
| 模式 | 影響 | 該做什麼 |
|---|---|---|
| 沒有變更 | 無 | 不必動作 |
| 建表 | 新表,沒有資料 | 直接跑 |
| 改表 | 秒級,最多單一欄位短暫鎖住 | 一般時段就能跑 |
| 重建 | 整表搬資料,期間整張表鎖住 | 排維護視窗,並先估搬移時間 |
重建的做法是建臨時表、整批搬資料、丟掉舊表、把臨時表改名,一張千萬筆的表跑下來,數十分鐘到數小時都有可能。所以部署前該先看的不是 SQL,是模式:先做一次乾跑,看模式是不是重建,是的話再把 SQL 印出來人工審。
那份計畫除了模式還帶著警告清單,例如哪些欄位被縮小,所以要看的有兩樣:模式決定要不要停機,警告決定要不要先去確認資料。這一題原本靠經驗,答案是「問一下那位比較資深的」;現在是一個回傳值,寫得進部署腳本。
框架把欄位型別分成幾個粗一點的家族(字串、數值、布林、日期時間、Guid、二進位,加上自動編號自成一類),對照規則很短:
| 這一類變更 | 走哪條路 |
|---|---|
| 加欄位、加索引、刪索引 | ALTER |
| 同一家族內的變化(字串放長、整數換長整數) | ALTER |
| 允不允許 NULL、預設值 | ALTER |
| 跨家族(字串換數值、任何型別換布林) | 重建 |
| 自動編號的開關 | 重建 |
然後逐筆檢查:全部能 ALTER 就走 ALTER;只要有一筆得重建,整張表就重建;有一筆是這個資料庫做不到的,直接中止報錯,不會做一半。這件事刻意不讓人選,因為它有唯一答案,而答案由變更內容決定。開成選項只是把算得出來的東西交給人猜,猜錯賠上的是資料。
家族切得粗是刻意保守:把該重建的判成可以 ALTER,執行到一半才發現不行,比多花一次重建的時間嚴重得多。各家能力的差異也一起吃掉,最極端的是 SQLite:它的 ALTER TABLE 只能加欄位、改名與刪欄位,型別、允不允許 NULL、預設值只要動一個就得重建整張表。同一份定義、同一次變更,在 SQL Server 上是秒級 ALTER,在 SQLite 上是重建,而呼叫端寫的程式一模一樣。
走 ALTER 時 SQL 不是一口氣送出去,而是切成有序階段,每階段自己一個 transaction:
這個順序沒有別的排法:索引壓在欄位上的時候那一欄改不動,所以得先讓路,改完再建回去。
某階段失敗 → 該階段回滾、後面不跑、例外往上拋,而前面已提交的階段不會被回滾。聽起來很危險,那為什麼不整包一個 transaction?因為建表與改表這類語句,整體回滾在多數資料庫上並不可靠,有些連 transaction 都不進去,綁成一個換來的是一句看起來安全、實際上不成立的承諾。
所以框架不做那個承諾:允許停在中間,靠重跑收尾。第二節留的那個性質在這裡用上,修掉失敗原因再跑一次,已完成的階段會被算成沒有變更而自動跳過。能重跑,比能回滾實際:一個能安全重跑的流程允許你在半夜三點修掉問題再按一次;宣稱能整體回滾、實際上卻部分生效的那種,只會讓你不知道現在停在哪裡。
一套自動升級的機制,能力邊界比能力本身重要,因為它是自動的,越界的那一刀不會有人喊停。
不刪欄位更簡單的說法:資料庫可以比定義多,不能比定義少。「定義是唯一真相」在這一層的意思很精確:它保證定義裡宣告的東西一定在資料庫裡,不保證資料庫裡沒有別的東西。想反過來要求「資料庫必須剛好等於定義」,代價是框架得有權刪掉它不認得的欄位,而那個權力沒有人會想給它。
不建外鍵帶來一個順便的好處:Day 3 那個部門與員工互相指向的環,在建表這一側連問題都不構成。沒有外鍵就沒有先後順序,一張一張建就好。有外鍵約束的世界得先想清楚誰先建誰後建,這裡不必。
這幾條放在一起是同一個原則:自動化可以做加法,減法留給人。加一個欄位做錯了補一次就好;刪一個欄位做錯了,資料已經沒了。
案例啟動時會把資料表建起來,跑的就是第三節那個迴圈,把登錄的每張表逐一比對升級。
所以加一張表的時候這段程式不用改。Day 5 數的六處裡有兩處在這裡(新增 TableSchema、在 DbCategorySettings 加一列),建表就會發生。
第一次跑是全新建表,之後每次都是比對,沒有變更就什麼都不做。開發過程中改了一個欄位長度、加了一個索引,下次啟動就跟上,中間沒有人手寫過一句建表或改表的語句。
回到開頭那句話:權威在定義上,資料庫裡的結構是產物。它說的不只是「框架會幫你產生建表語句」,產生語句是簡單的部分。真正的內容是這幾件加起來:結構只描述一次,描述的時候連哪一家資料庫都不提;想知道現在該長什麼樣,讀定義就夠了;兩邊對不上的時候,被改的是資料庫那一邊。
這一層還多一件前面幾層沒有的事:這裡有資料,所以真相這個詞得有邊界。資料庫可以比定義多、不能比定義少。在別的層,定義是唯一真相說的是把重複收乾淨;在這一層,說的是知道自己不該碰哪些東西。
明天回到畫面那一側:同一份定義怎麼長出四個前端的畫面。
本系列同步發表於 HackMD,完整目錄