
「支援 SQL Server、PostgreSQL、MySQL、Oracle、SQLite」寫在規格書上是一行字,寫在框架裡是一個必須先回答的問題:到底要替它們抽象什麼。
兩個方向都走不通:
這套框架的答案很窄,窄到只有兩件事。
本篇說明:
一套框架該支援幾家資料庫沒有通用的答案,它取決於賣的是什麼。只支援一家換到的是深度(那一家的原生型別、批次介面、方言特性全都能直接用)。Day 6 說過,對套裝軟體來說支援清單短就是少掉一整批客戶,多家支援因此是入場條件。
這一層要對的是五種常用的關聯式資料庫,不是所有能存資料的東西。這五家的資料模型是同一套(有表、有欄、有列,都講 SQL,都有 transaction),正因為底下那一層共通,「CRUD」與「讓結構跟上定義」在五家上才指得到同一件事。
判準兩個,要同時成立:
拿這兩把尺量下去,剩下的東西不多:
| 收進來 | 為什麼 |
|---|---|
| 對一張表 CRUD | 沒有一個應用不做,每次使用者按存檔就做一次 |
| 讓結構跟上定義 | 沒有一個應用能躲掉,每次部署要面對一次 |
沒收進來的:分割表、物化檢視、全文檢索、各家自己的分析函式、查詢提示、大量匯入的專用介面。每一項都很有價值,但共有一個性質:不是每個應用都需要,而需要的那些,通常也只用在少數幾個位置。
拿大量匯入當例子。多數資料庫都有比逐筆寫入快很多的專用機制,但形狀沒有一個對得起來(有的給你一個專屬類別,有的是一句特別的語句,有的要先落成檔案再讓資料庫自己讀)。要替它們設計一個共同介面,做得出來的只有最小交集,而最小交集通常等於「跟逐筆寫入差不多」。包了等於沒包,卻多出五份要維護的實作。
這兩把尺量的是「該不該由這一層做」,不是「重不重要」。分割表對一張上億筆的單據表可能是生死問題,它只是不該由一個要同時對五家成立的介面來決定怎麼做。
量完之後剩下的就是全部:框架整個對外的抽象表面就是一個介面,問一家資料庫的事情只有兩類(怎麼替一張表產生 CRUD 語句、怎麼讓結構跟上定義)。第二類拆成四小件:讀出現況、建新表、就地改、改不動就重建。
這條線畫得窄,換到的是它畫得完。一個介面、兩類問題,要接一家新的資料庫,看得完也寫得完。如果那個介面有三十個成員,這件事在實務上就不會發生。
不過這兩件工作雖然收在同一個介面裡,抽象的厚度差得很遠。
痛點:同一句查詢在五家上長得不一樣,但不一樣的地方非常無聊(表名欄名用什麼符號包、參數怎麼寫、分頁怎麼下)。無聊,但每一句都得對,錯一個就是執行期語法錯誤,而且是那種在測試環境用另一家資料庫時完全不會出現的錯誤。
這些差異的性質是好的:它們是同一件事的不同寫法,不是不同的事。
之所以收成一張對照表,是因為不收的代價是分散的。組語句的地方不只一處(撈清單、讀單筆、算筆數、增修刪各一段),每一段都自己判斷現在是哪一家,就是每一段都有機會漏掉一處,而漏掉那一處只有在客戶剛好用那一家資料庫時才會現形。
| 資料庫 | 識別符引號 | 參數前綴 |
|---|---|---|
| SQL Server | [sys_id] |
@ |
| PostgreSQL | "sys_id" |
@ |
| MySQL | `sys_id` |
@ |
| Oracle | "SYS_ID" |
: |
| SQLite | "sys_id" |
@ |
Oracle 那一列多一件事:沒加引號的識別符在 Oracle 會被折成大寫,所以框架送出去的一律是加引號的大寫;讀回來時框架在邊界上折回小寫,讓上面每一層看到的欄名在五家上完全一樣。昨天說名字全程只有一組,這是那條規則在最底層的收尾動作。
還有一處查表解不掉,因為形狀真的不同:
| 資料庫 | 取第 21 到 40 筆 | 跳過前 20 筆,之後全要 |
|---|---|---|
| SQL Server、Oracle | OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY |
OFFSET 20 ROWS |
| PostgreSQL、SQLite | LIMIT 20 OFFSET 20 |
OFFSET 20 |
| MySQL | LIMIT 20 OFFSET 20 |
LIMIT 18446744073709551615 OFFSET 20 |
MySQL 只有在取一段區間的時候跟中間那列一樣。只給起點、不給筆數時,它不接受單獨出現的 OFFSET,得補一個上限值上去湊,而那串數字是它文件裡指定的哨兵值,不是隨手打的。
結論在五份實作的形狀:框架裡對應五家的那五個類別內容幾乎一模一樣,每一個都只是把查詢與刪除轉交給同一段共用程式,順便告訴它「我是哪一家」。存檔要用的那三句更徹底,連五份都沒有,是同一個類別拿著一個列舉值組出來的。
那個類別是 TableSchemaCommandBuilder,建構時收下「哪一家」跟那張表的結構,三句由它組,再跟 DataSet 裡的那張表包成一份交下去:
var insertCmd = BuildInsertCommand();
var updateCmd = BuildUpdateCommand();
var deleteCmd = BuildDeleteCommand();
return new DataTableUpdateSpec()
{
DataTable = dataTable,
InsertCommand = insertCmd,
UpdateCommand = updateCmd,
DeleteCommand = deleteCmd
};
Day 9 那句「照每一列的狀態決定下哪一句語句」,兌現的地方就是這裡。而整個類別裡「現在是哪一家」只從兩個地方進來:識別符怎麼加引號、參數名加什麼前綴,正是上面第一張表的那兩欄;三句語句怎麼組,一個分支都沒有。
差異全部沉在下面那一層:能查表的查表,剩下那一處由共用程式收掉。
這一半的抽象非常薄,而薄是好事:真要再接一家,這一半幾乎不必寫。
同一句「把這一欄從 20 個字改成 50 個字」,各家的差別不在寫法,在做不做得到。這已經不是同一件事的不同寫法,是能力本身不一樣,而能力不一樣的東西吸收不掉。
五家沒有一家一樣:
| 資料庫 | 從哪裡讀 |
|---|---|
| SQL Server | sys.* 那一組系統檢視 |
| PostgreSQL | information_schema 混用 pg_catalog |
| MySQL | INFORMATION_SCHEMA |
| Oracle | USER_* 資料字典 |
| SQLite | sqlite_master 再配幾個 PRAGMA |
拆不掉的原因不在方言,在每一家怎麼看待自己的結構:SQL Server 把欄位預設值當成獨立物件,跟欄位分開存、分開命名,要讀就得多接一張系統檢視;SQLite 走另一個極端,它不強制型別,當初寫下的 VARCHAR(50) 原樣存著,框架要知道能放多長,就得把那個字串重新拆開來讀。
讀回來還有一步,那一步比查詢本身難。
Day 6 那條「定義裡沒有任何一家資料庫的型別名」的代價落在這裡:建表時要把框架型別翻成原生型別,比對就得把讀回來的原生型別翻回去,而這個翻譯是多對一的。以 MySQL 為例,Guid 存成長度 36 的定長字元欄,而定長字元欄本來就是字串的一種寫法;自動編號與長整數同樣落在 BIGINT 上。反向翻的時候,資料庫回報的只有一個 char 加一個長度、只有一個 bigint,語意在正向那一步就已經被丟掉了。
所以反向那一半不是把對照表倒過來查,是靠額外線索把語意認回來:
Guid
BIGINT 再看一次身上有沒有自動遞增的旗標這些線索成立的前提是框架只會發出那幾種寫法,所以框架自己建的表一定認得回來。代價也在這個前提上:接管一張不是框架建的表,這些推斷會失真,症狀是比對每次都看到一個並不存在的差異。
正向的對應一張表就講得完,反向那一半不行;一個抽象只要在中間那一步丟掉了資訊,回頭走就沒有免費的路。
Day 6 那條規則(同家族就地改、跨家族重建)在 SQLite 上幾乎失效,因為它的 ALTER TABLE 動不了既有欄位。於是同一個變更,在別的資料庫上是幾秒鐘的事,在 SQLite 上是把整張表的資料複製一次。
框架在這裡沒有選擇抹平,它做的是把這個差異算出來給人看(Day 6 那個乾跑)。同一份定義接到不同資料庫上,得到的計畫可以是不同的,而要不要排維護視窗本來就該由那份計畫回答。抹平反而糟糕,因為被抹掉的正是使用者最需要知道的那件事。
MySQL 從 8.0.13 起規定:預設值只要不是字面值,就必須寫成括號包起來的運算式。所以框架要讓一個欄位自動產生 Guid,送出去的是 DEFAULT (UUID())。寫進去沒問題。
但下一次比對時,INFORMATION_SCHEMA 把它報回來的樣子是 uuid():括號不見了,函式名還變成小寫。
於是照字面比對:定義說 (UUID())、資料庫說 uuid(),兩邊不一樣 → 認定有變更 → 多一道 ALTER → 執行 → 再比一次,資料庫還是報 uuid() → 還是一道 ALTER。
一個永遠不會收斂的升級。它不會失敗、不會有錯誤訊息,只會在每次啟動時安靜地多下一道語句,改一個根本沒有變過的欄位。
框架的解法是比對之前把兩邊正規化到同一形狀(剝掉一層外括號、大小寫不計)再比。SQLite 有同一類問題(它把建表語句原樣存著、讀回來也是原樣),同樣需要一段自己的正規化。
定義裡每個欄位都有一句給人看的說明,要存進資料庫,五家給了四種答案:
| 資料庫 | 怎麼存欄位說明 |
|---|---|
| MySQL | 直接寫在欄位定義後面 |
| PostgreSQL、Oracle | 另一句 COMMENT ON |
| SQL Server | 掛在一組叫擴充屬性的東西上 |
| SQLite | 不保存,寫進去是空操作、讀回來一律是空 |
框架對最後那一家的作法是明講它做不到、把說明留在定義那一側。假裝存進去了才是災難,因為讀回來永遠對不上,就是上面那個不收斂的迴圈再來一次。
這幾種差異都是同一個抽象往下走時分岔出來的,而上千份定義沒有一份需要知道這件事,代價是有人得替它們知道。
那個人就是這一層。讀得回來、寫得出去只是入門,難的是「寫出去的東西讀回來,還認得出它是同一個」。
痛點:你的系統只用一家資料庫,但框架宣稱支援五家。於是輸出目錄躺著五家的驅動程式,容器映像大一圈;某天其中一家發了安全性更新,你明明一行都沒用到,弱點掃描報告照樣列出來。更難處理的是版本:框架把五家驅動包進自己的相依,等於替你把五家的版本都釘住了。
一個相依只要它自己沒有在用,就不該由它替所有使用者做這個決定。
所以框架一家都不內建。應用要支援哪一家,啟動時登記哪一家就好,案例的那一行出自 NorthwindBackend.cs:
DbProviderRegistry.Register(DatabaseType.SQLite, new SqliteProviderFactory(SqliteFactory.Instance));
登記進去的是 ADO.NET 那一層的工廠,各家自己出的套件。同一個檔案還有一行,登記的是框架替它產生語句與讀出結構的那一套,也就是前面兩節整個介面;要接一家框架沒內建的資料庫,缺的就是那一份。
框架自己從來不登記任何一家,這句話直接寫在原始碼註解裡。
登記發生在程式碼裡,不在設定檔裡。設定檔上寫的是資料庫類別(一個列舉值),不是型別名字。若讓設定檔指名型別,框架就得在執行期照名字去找組件,那等於把驅動程式的相依從相依清單搬進字串,建置時反而看不到它。
這條紀律在原始碼上驗得到:整個框架沒有任何一個專案引用那五家驅動的任何一個套件。
SQLite 上為此多做了一件事:它的驅動沒有提供框架寫回要用的 DataAdapter,框架自己補了一個,包住抽象的工廠型別、其餘成員原樣轉交,實例由應用在登記時交進來。需要那一家的東西,也不去引用那一家的套件。
案例只用 SQLite,另外四家的套件連引用都沒有。選它的理由與框架設計無關:不必安裝任何資料庫,一個檔案就跑得起來,拿來示範最省事。
五家資料庫,兩件事。收進來的共同點是每一個應用都要做,而且會一直做;沒收進來的不是比較不重要,是它們只在特定的少數位置才需要。
兩件事被抽象之後的樣子差得很遠:CRUD 那一半薄到五份實作幾乎一樣,因為各家差異是同一件事的不同寫法;結構升級那一半厚到每一家各寫一份,因為各家差異是能力本身不同。
抽象的厚度不是設計者決定的,是底下那些東西差多少決定的。
這一層的回報要時間才看得到。第一次交付時它是純成本(多一層介面、多兩行接線、多五份實作),回本要等到第二家客戶指定了另一家資料庫,而你發現上千份定義一個字都不必動。
多支援一家資料庫,真正付的不是那幾百行實作,是那些沒有一份文件會事先告訴你的落差,而它有多少,要踩過才知道。
明天談框架決定不自己做的那一段:SQL 交回給應用自己寫的時候,框架沒有一起交出去的是哪幾件事。
本系列同步發表於 HackMD,完整目錄