作者:吳祐賓
—— 可以理解為什麼 dbExpress 要被列 Deprecated,因為高階物件和低階抽象完全是兩個風景
在老系統現代化(Legacy Modernization)這條宛如「走出埃及」的路上,最恐怖的從來不是程式碼有多爛,而是在這條路上遇上問題時,無人能指引解惑。更恐怖的是,哪怕你選擇留在埃及,這些幽靈依舊存在
這次差點被搞到懷疑人生的經歷是:
專案從 DataSnap 轉向 Horse,資料透過 dbExpress (DBX) 底層與 Devart 驅動程式拉出來,準備轉成 JSON API
這本是走出埃及的日常,結果就在最簡單的一行判斷上,我被狠狠教做人了
if TDBXValue(Value).IsNull then // ← 就在這裡直接噴 Range check error
而且只在特定資料庫、特定欄位上發生
黑夜降臨:案發現場的詭異症狀
問題欄位是老掉牙的 TEXT 型態,內容看起來明明是空字串
但只要碰到這欄位,所有常識判斷全部失效:
- IsNull 直接爆炸
- 改用 GetString、GetStream、GetBytes 也會收到 Devart 各種型別衝突的錯誤 (TDBXTypes.BLOB value type cannot be accessed as TDBXTypes.BYTES value type)
雖然這和埃及日常沒什麼區別,但那一刻我還真的有慌。這不是單純的 Null 判斷問題,而是某種更底層、更邪門的東西在作祟
因為,同樣的 SQL,使用 TSQLQuery / TSQLDataSet 時,完全沒有這問題啊!
守衛睜眼 —— 查出真正的狼人
由於這和歷史經驗不同, 理所當然往資料庫挖
對出問題的資料表下了診斷 SQL:
SELECT
NotesField,
DATALENGTH(NotesField) AS DataLen,
TEXTPTR(NotesField) AS Ptr
FROM LegacyOrderTable
WHERE CategoryCode = 'BUG_TEST';
結果出來的那一刻,我整個人傻掉
這筆資料的狀態極度詭異:
- DATALENGTH(NotesField) = 0
- 但 TEXTPTR(NotesField) 居然是有效的 8 Bytes 指標(像 0xFBFFC67A... 這種)
原來,這根本不是什麼無辜的空資料,而是一頭「披著空字串外皮的狼」
這東西通常出現在很久以前的資料轉檔、批次匯入,或是老系統某些特殊的寫入行為
SQL Server 在行外(Out-of-row)幫它配置了 LOB 指標,但實際上什麼資料都沒塞進去。就像一具只剩下指標的空殼。
預言家睜眼:不是追兵,是自己人崩潰 —— Devart 驅動程式的精神分裂
最要命的是 Devart 看到這頭「披著空字串外皮的狼」之後的處理方式
它偵測到有 TEXTPTR(長度 8),就很「聰明」地把這個欄位標記成 WideStringType,並且把 Size 設為 8
而 Delphi 這邊在準備讀取的時候,卻認為這是長度為 0 的空資料。於是在底層 DBXRow_GetBytes 裡,出現了類似這樣的致命操作:
PBuf := @Value[ValueOffset]; // Value 是長度 0 的陣列 → 直接 Range check error!
因為專案有開 {$R+}(範圍檢查),Delphi 非常乾脆地下殺手
這就是典型的「兩個黑盒子互相誤會,所引爆的災難現場」
女巫睜眼:三顆解藥
第一顆解藥:SQL 層釜底抽薪
乾淨利落,不讓 Devart Driver 看到那頭「披著空字串外皮的狼」
// 1. Alter Table
ALTER TABLE LegacyOrderTable
ALTER COLUMN NotesField NVARCHAR(MAX);
// 2. CAST
SELECT
CAST(NotesField AS NVARCHAR(MAX)) AS NotesField,
CAST(AnotherTextField AS NVARCHAR(MAX)) AS AnotherTextField
FROM LegacyOrderTable
只要轉成 NVARCHAR(MAX),Devart 立刻恢復正常,不再亂認 Size = 8
這一招幾乎零成本,卻直接斬斷了問題根源
第二顆解藥:程式碼硬控
動資料庫其實蠻傷及筋骨的,所以並不是一個好選擇
既然只是為了把資料轉成 JSON API,我們大可在轉換的迴圈裡加上防護罩,直接攔截這個怨靈:
try
Result := Value.IsNull;
except
Result := TJSONNull.Create;
end;
犧牲一點 except 效能與 Debug 的雜訊,至少 API 不會再半夜隨機暴斃
第三顆解藥:修改 Driver 原始碼
很妙的是,Devart 不含原始碼的 DLL 版並沒有這方面的問題,我分析是 {+R} 在封裝 DLL 時被關掉了,同時也沒有吐出任何例外,這 Bug 就與世無爭地活了數十年
但不想依賴 DLL 過活的我,選擇的是含原始碼的版本
若你和我一樣有 Devart 的 Source Code 授權,而且真的很想把根治了,可以直接進到 DBXRow_GetBytes 之類的核心方法,加上防護:
if (Value <> nil) and (System.Length(Value) > ValueOffset) then
PBuf := @Value[ValueOffset]
else
PBuf := nil; // 神之一手,拯救無數 Range check error
這顆解藥很硬,但一旦改完,整個 Devart Driver 在面對 SQL Server 老 TEXT 時都會變得更穩
當然,這問題我已提交給 Devart,目前的版號是 9.5.0 版,未來可以關心之後新版的 History 有沒有相關的記錄出現
白晝到來:傷害力不高,汙辱性極強
回到開頭說的:dbExpress 之所以被列為 Deprecated,不是沒有原因
更有趣的是,同樣使用 dbExpress,傳統的 TSQLQuery/TSQLDataSet 並未重現此問題;只有直接操作 TDBXReader/TDBXValue 時才踩中地雷。這表示高階 DataSet 與底層 DBX 抽象層在處理 LOB 欄位時,很可能走的是不同路徑。究竟是透過 Stream、額外緩衝層,還是其他容錯機制避開了這個缺陷,仍有待進一步驗證;但可以確定的是,當我直接面對 DBX 最底層抽象時,這頭狼人終於現出原形。
所以:就算你不走出埃及,留在舒適圈裡,這些狼人依然會在正式環境半夜等著你
老系統的坑,從來不是一次就能填完的。它考驗的不是你會不會寫 Code,而是當所有常識都失效時,你願不願意把屍體挖出來解剖。
如果你也正在 DataSnap 轉 Horse、DBX 操作踩雷、或任何 Legacy Modernization 的路上,歡迎在留言區分享你的戰場故事
願我們一起在走出埃及的路上,相互扶持

沒有留言:
張貼留言