iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 6

Day 6:TableSchema 與資料庫結構升級

  • 分享至 

  • xImage
  •  

Day 6:TableSchema 與資料庫結構升級

昨天那些 SQL 都是在存取資料,今天換另一件事:那些資料表本身是怎麼來的。

資料表有哪些欄位、每欄多長、哪幾欄要建索引,權威在 TableSchema 上,資料庫裡的實體結構是照它做出來的產物。這件事在這一層最難兌現:資料庫裡已經有資料,而那些資料不會因為定義改了就自動換一種形狀。

本篇說明:

  1. TableSchema 為何要跟 FormSchema 分家
  2. 升級為何是「比對現況」而不是「產生腳本」
  3. 比對 / 計畫 / 執行為什麼要拆成三段
  4. 這次升級要花多久,怎麼在動手之前算出來
  5. ALTER 還是重建,由誰決定
  6. 五個階段各自一個 transaction,失敗了怎麼收尾
  7. 刻意不做的那幾件事,以及它們共同的原則

一、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 的唯一性由框架擔保,擔保的位置就是這個唯一索引。

分成兩份的實際回報:調索引、改精度、為了某支報表加一個複合索引,動的只有 TableSchemaFormSchema 完全不必碰,也不必找當初寫那張表單的人。

昨天提過這一份不必從頭寫:框架可以從一份 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 忘了補 → 資料庫裡沒有那一欄,而存檔語句偏偏會去寫它,執行期才報錯
  • 反過來多一欄是無害的 → 會被建出來,但表單這條路不會碰它

執行期沒有人替這兩份定義對帳,靠的是改動的順序:欄位先進 FormSchemaTableSchema 跟著補上實體欄位。Day 4 講那個順序時看起來像一句習慣,它是這個代價的操作版本。(建置期有一道關卡專門看這件事,Day 9 會談。)


二、升級不是產生腳本,是比對現況

一般做法是 migration:改完模型、工具產生腳本、進版控、按順序執行,資料庫另外記著跑到哪一版。

這套框架沒有腳本,也沒有版本號。每次升級都是當場問資料庫「你現在長什麼樣」,拿它跟定義比對,算出差異,然後執行。

差別顯現在三處:

  • 不需要知道這個資料庫上一次停在哪一版
  • 不需要保證每一版的腳本都照順序跑過
  • 有人手動改過資料庫也不會讓後面全部對不上,因為下一次比對看到的就是改過之後的樣子

換來一句很短的成功契約:

升級成功返回之後,資料庫至少要有定義裡的每一個欄位,而且型別、長度、允不允許 NULL 與定義一致。

重跑安全是同一件事的結果:跑第二次時比對到的現況已經是升級後的樣子,算出來的差異是空的。這個性質在談失敗處理時還要再用一次。

代價三個:

  1. 比對得向資料庫問它自己的結構,各家問法不同,這一段每個資料庫各寫一份
  2. 「刪掉一個欄位」表達不出來,定義裡沒有的欄位只會被忽略(第七節回來講)
  3. 沒有腳本,就沒有一份寫著「這次部署會對資料庫做什麼」的檔案可以審。migration 那條路上那份腳本會出現在變更清單裡,資深的人掃一眼就知道動到什麼。這裡的變更清單只有定義檔差異,「一個欄位從 50 改成 100」讀得出來,但「這會不會觸發重建」得另外問

第三個代價有解,就是第四節那個先算不執行的用法,只是它得由部署流程主動去做,不會自己浮到眼前。


三、比對、計畫、執行,切成三段

產出 碰資料庫嗎
比對 結構化差異:哪些欄位要新增、哪些欄位的定義變了、哪一欄要改名、哪些索引要建要刪。裡面沒有任何 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 印出來人工審。

那份計畫除了模式還帶著警告清單,例如哪些欄位被縮小,所以要看的有兩樣:模式決定要不要停機,警告決定要不要先去確認資料。這一題原本靠經驗,答案是「問一下那位比較資深的」;現在是一個回傳值,寫得進部署腳本。


五、ALTER 還是重建,框架自己決定

框架把欄位型別分成幾個粗一點的家族(字串、數值、布林、日期時間、Guid、二進位,加上自動編號自成一類),對照規則很短:

這一類變更 走哪條路
加欄位、加索引、刪索引 ALTER
同一家族內的變化(字串放長、整數換長整數) ALTER
允不允許 NULL、預設值 ALTER
跨家族(字串換數值、任何型別換布林) 重建
自動編號的開關 重建

然後逐筆檢查:全部能 ALTER 就走 ALTER;只要有一筆得重建,整張表就重建;有一筆是這個資料庫做不到的,直接中止報錯,不會做一半。這件事刻意不讓人選,因為它有唯一答案,而答案由變更內容決定。開成選項只是把算得出來的東西交給人猜,猜錯賠上的是資料。

家族切得粗是刻意保守:把該重建的判成可以 ALTER,執行到一半才發現不行,比多花一次重建的時間嚴重得多。各家能力的差異也一起吃掉,最極端的是 SQLite:它的 ALTER TABLE 只能加欄位、改名與刪欄位,型別、允不允許 NULL、預設值只要動一個就得重建整張表。同一份定義、同一次變更,在 SQL Server 上是秒級 ALTER,在 SQLite 上是重建,而呼叫端寫的程式一模一樣。


六、五個階段,各自一個 transaction

走 ALTER 時 SQL 不是一口氣送出去,而是切成有序階段,每階段自己一個 transaction:

  1. 先刪掉那些即將被改定義的索引
  2. 改既有欄位(名字、型別、長度、允不允許 NULL)
  3. 加新欄位
  4. 建新索引,並把第一步刪掉的重新建回來
  5. 同步欄位說明

這個順序沒有別的排法:索引壓在欄位上的時候那一欄改不動,所以得先讓路,改完再建回去。

某階段失敗 → 該階段回滾、後面不跑、例外往上拋,而前面已提交的階段不會被回滾。聽起來很危險,那為什麼不整包一個 transaction?因為建表與改表這類語句,整體回滾在多數資料庫上並不可靠,有些連 transaction 都不進去,綁成一個換來的是一句看起來安全、實際上不成立的承諾。

所以框架不做那個承諾:允許停在中間,靠重跑收尾。第二節留的那個性質在這裡用上,修掉失敗原因再跑一次,已完成的階段會被算成沒有變更而自動跳過。能重跑,比能回滾實際:一個能安全重跑的流程允許你在半夜三點修掉問題再按一次;宣稱能整體回滾、實際上卻部分生效的那種,只會讓你不知道現在停在哪裡。


七、刻意不做的那幾件事

一套自動升級的機制,能力邊界比能力本身重要,因為它是自動的,越界的那一刀不會有人喊停。

  • 不刪欄位。定義裡沒有、資料庫裡有的欄位一律保留。理由是這一欄未必是垃圾(可能是別的系統加的,可能是還沒收進定義的擴充欄位),而刪除做完就回不去。
  • 不建外鍵,也不碰觸發器與檢視。業務規則不放進資料庫,是同一個決定貫徹到底。
  • 縮小欄位預設拒絕。除非明確打開那個選項,打開之後照做但會記進警告。理由是靜默截斷是最糟的失敗方式:它不報錯,只是把資料切掉一截,發現時通常已經是幾個月後有人問「為什麼這筆地址少了半行」。
  • 欄位改名要明講。框架不會猜「這大概是改名吧」,要改名就在定義上標出舊名。而且只保證單次,跨好幾版累積的連續改名不支援。

不刪欄位更簡單的說法:資料庫可以比定義多,不能比定義少。「定義是唯一真相」在這一層的意思很精確:它保證定義裡宣告的東西一定在資料庫裡,不保證資料庫裡沒有別的東西。想反過來要求「資料庫必須剛好等於定義」,代價是框架得有權刪掉它不認得的欄位,而那個權力沒有人會想給它。

不建外鍵帶來一個順便的好處:Day 3 那個部門與員工互相指向的環,在建表這一側連問題都不構成。沒有外鍵就沒有先後順序,一張一張建就好。有外鍵約束的世界得先想清楚誰先建誰後建,這裡不必。

這幾條放在一起是同一個原則:自動化可以做加法,減法留給人。加一個欄位做錯了補一次就好;刪一個欄位做錯了,資料已經沒了。


回到 Northwind

案例啟動時會把資料表建起來,跑的就是第三節那個迴圈,把登錄的每張表逐一比對升級。

所以加一張表的時候這段程式不用改。Day 5 數的六處裡有兩處在這裡(新增 TableSchema、在 DbCategorySettings 加一列),建表就會發生。

第一次跑是全新建表,之後每次都是比對,沒有變更就什麼都不做。開發過程中改了一個欄位長度、加了一個索引,下次啟動就跟上,中間沒有人手寫過一句建表或改表的語句。


真相的邊界

回到開頭那句話:權威在定義上,資料庫裡的結構是產物。它說的不只是「框架會幫你產生建表語句」,產生語句是簡單的部分。真正的內容是這幾件加起來:結構只描述一次,描述的時候連哪一家資料庫都不提;想知道現在該長什麼樣,讀定義就夠了;兩邊對不上的時候,被改的是資料庫那一邊。

這一層還多一件前面幾層沒有的事:這裡有資料,所以真相這個詞得有邊界。資料庫可以比定義多、不能比定義少。在別的層,定義是唯一真相說的是把重複收乾淨;在這一層,說的是知道自己不該碰哪些東西。

明天回到畫面那一側:同一份定義怎麼長出四個前端的畫面。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 5:FormSchema 如何驅動 SQL
系列文
ERP 架構師筆記:定義驅動的框架設計6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言