同一條商業規則寫三次,不叫三層防護;如果三份邏輯會各自演化,那只是準備製造三種答案。
上一篇,我處理了兩位使用者同時新增時,可能拿到相同流水號的 Race Condition。
最後採取的方向,是把「取得下一號」與「新增主檔」放進同一個 Stored Procedure,再透過 Transaction、Lock 與 Unique Index,避免兩個 Session 同時使用相同號碼。
併發問題解決後,另一個問題很快浮上來:
流水號的格式檢查、順序判斷與新增限制,到底應該寫在哪一層?
Legacy System 裡,答案經常不是「某一層」,而是「每一層都有一點」。
Delphi 的新增按鈕先算一次,Stored Procedure 又算一次,Trigger 再檢查一次。表面上像三層都在保護資料,實際上卻可能產生三個答案:
0296,Stored Procedure 算出 0648;這時真正的問題不再是「哪一段 SQL 寫錯」,而是:
誰才是這條規則的唯一決策者?其他層又應該負責到什麼程度?
今天就來拆開 UI、Stored Procedure、Trigger、Constraint 與 Unique Index 的責任,避免同一條規則在系統裡長出幾個互相不認識的分身。
討論規則放在哪裡之前,我先把需求分成四類:
| 規則類型 | 例子 | 主要目的 |
|---|---|---|
| 畫面互動規則 | 必填欄位未填時立即提示 | 改善操作體驗 |
| Use Case 規則 | 儲存時取得正式流水號並新增主檔 | 完成完整業務動作 |
| 資料不變量 | 同一案件內流水號不可重複 | 任何入口都不能破壞 |
| 額外防線 | 非標準入口違反已明確定義的規則時仍會被拒絕 | 防止繞過主要流程 |
如果沒有先分類,很容易把所有規則都叫做 Validation,接著每一層都複製一份。
但「讓使用者早一點看到提示」與「資料庫絕對不能接受重複號碼」,其實是兩件完全不同的事。
UI 的檢查可以被其他 Client、匯入工具、排程或人工 SQL 繞過;Schema 層的 Constraint 與 Unique Index 雖然可靠,卻不適合負責顯示「請先選擇案件」這類互動訊息。
所以不應先問:
這段
IF要放 Delphi 還是 SQL?
而應先問:
這條規則是在改善操作、執行流程,還是保護任何情況下都不能被破壞的資料狀態?
UI 最適合處理與目前畫面直接相關,而且能立即回饋使用者的檢查,例如:
匿名化後的 Delphi 程式可以寫成:
procedure TDocumentForm.ValidateInput;
begin
if Trim(edtProjectNo.Text) = '' then
raise Exception.Create('請先選擇案件。');
if Trim(edtTitle.Text) = '' then
raise Exception.Create('請輸入文件標題。');
end;
這類檢查放在 UI,可以讓使用者不必等資料庫回傳錯誤,也能直接對應畫面欄位。
但 UI 不適合決定正式流水號,更不能單獨保證唯一性。因為 UI 送出後,資料庫狀態仍可能被其他 Session 改變;即使 Delphi 查到 0295,下一個瞬間也可能有人新增 0296。
而且 UI 從來不是唯一入口。系統未來可能還有另一支 Delphi 程式、Web API、Excel 匯入工具、排程作業或資料修復腳本。
只寫在 UI 的商業規則,保護的是「這個畫面」,不是「這份資料」。
UI Validation 是提早回饋,不是資料完整性的最終保證。
上一篇已經確認,正式流水號不應由 Delphi 自己執行 MAX + 1。
Stored Procedure 適合承擔的,是一個明確的 Use Case:
接收新增資料
↓
驗證建立文件所需條件
↓
依案件取得並鎖定下一號
↓
新增主檔
↓
提交交易
↓
回傳已成功建立的正式號碼
它掌握完整的資料庫交易,也能看見真正已提交的資料,因此適合作為產號流程的唯一決策點。
下面只保留能說明責任歸屬的關鍵片段。UPDLOCK、HOLDLOCK、完整例外處理與併發測試已在 Day 9 說明,這裡不再重新展開:
-- 以下為責任分工示意,不是完整可部署版本。
-- 正式版本外層仍保留 Day 9 的 TRY...CATCH、XACT_ABORT 與 Rollback。
CREATE OR ALTER PROCEDURE dbo.CreateDocument
@ProjectNo varchar(20),
@Title nvarchar(100)
AS
BEGIN
DECLARE @NextValue int;
DECLARE @NextNo varchar(4);
IF NULLIF(LTRIM(RTRIM(@ProjectNo)), '') IS NULL
THROW 50001, N'案件編號不可空白。', 1;
IF NULLIF(LTRIM(RTRIM(@Title)), '') IS NULL
THROW 50002, N'文件標題不可空白。', 1;
BEGIN TRANSACTION;
-- 產號查詢沿用 Day 9 已確認的 Transaction 與 Lock 設計。
SELECT @NextValue =
ISNULL(MAX(TRY_CONVERT(int, ItemNo)), 0) + 1
FROM dbo.DocumentHeader WITH (UPDLOCK, HOLDLOCK)
WHERE ProjectNo = @ProjectNo
AND LEN(ItemNo) = 4
AND ItemNo NOT LIKE '%[^0-9]%';
SET @NextNo = RIGHT(
'0000' + CONVERT(varchar(4), @NextValue), 4
);
INSERT INTO dbo.DocumentHeader (ProjectNo, ItemNo, Title)
VALUES (@ProjectNo, @NextNo, @Title);
COMMIT TRANSACTION;
-- 只有資料庫成功建立主檔後,才回傳正式流水號。
SELECT @NextNo AS NewItemNo;
END;
這不是可以單獨部署的完整 Script,而是用來標示 Ownership 的節錄。真正的重點是:同一個入口同時完成驗證、正式產號、主檔建立與結果回傳;Delphi 與 Trigger 都不再各算一次。
UI 與 Stored Procedure 都檢查必填,不一定代表重複設計:
真正不應重複的是「誰來決定下一號」。正式號碼只能有一個權威來源,否則不同入口遲早會算出不同答案。
可以在多層防守同一個不變量,但不能讓多層各自決定同一個結果。
Trigger 最大的優點,是不論資料從 Delphi、Stored Procedure、匯入程式或人工 SQL 寫入,只要異動到資料表,它都有機會攔下來。
也正因如此,Trigger 很容易被當成「最後保險」,然後越寫越多:一開始只檢查格式,後來加入順序、權限、歷史資料、簽核狀態,最後變成藏在資料表背後的第二套應用程式。
問題是,Trigger 雖然知道哪些資料被 INSERT 或 UPDATE,卻不一定知道:
換句話說,Trigger 擅長觀察「資料發生了什麼」,不擅長理解「使用者為什麼這樣做」。
因此,我不會讓 Trigger 負責產號,也不會把整套新增流程搬進 Trigger。它比較適合留下少量、明確且與資料表高度相關的防線,例如:
即使如此,每一條 Trigger 規則都必須回答:
如果答案不確定,Trigger 就不應因為「比較保險」而先加上去。
如果規則是「同一個案件內,流水號不得重複」,最直接的做法不是在三層各查一次,而是建立唯一索引:
CREATE UNIQUE INDEX UX_DocumentHeader_ProjectNo_ItemNo
ON dbo.DocumentHeader (ProjectNo, ItemNo);
不論資料從哪個入口寫入,只要違反:
同一 ProjectNo + ItemNo 不可重複
資料庫就會拒絕。
若所有資料都必須是四碼十進位,也可以考慮 CHECK Constraint。但本案例仍存在舊制三碼資料,不能貿然加入只允許四碼的全表限制,否則舊資料在更新其他欄位時也可能被擋住。
Unique Index 與 Constraint 的實作形式不同,但在這裡共同承擔的是 Schema 層的資料底線:能清楚宣告的永久資料不變量,不要只藏在 Delphi、Stored Procedure 或 Trigger 的 IF 裡。
Constraint 與 Unique Index 適合保護永久成立的資料不變量;若規則只適用於新流程,就不能假裝它對整張歷史資料表永遠成立。
假設 Stored Procedure 已新增 0648,Trigger 又在 AFTER INSERT 裡重新查最大值:
CREATE TRIGGER dbo.TR_DocumentHeader_CheckNo
ON dbo.DocumentHeader
AFTER INSERT
AS
BEGIN
DECLARE @ExpectedNo int;
SELECT @ExpectedNo = MAX(CONVERT(int, ItemNo)) + 1
FROM dbo.DocumentHeader;
IF EXISTS
(
SELECT 1
FROM inserted
WHERE CONVERT(int, ItemNo) <> @ExpectedNo
)
BEGIN
ROLLBACK TRANSACTION;
RETURN;
END;
END;
這段看起來像完整防護,實際上有五個問題:
AFTER INSERT 執行時,新資料已在目前交易中可見,MAX + 1 會比剛新增的號碼再大一號;inserted 永遠只有一筆;CONVERT(int, ItemNo) 可能失敗;AI 如果只看到「檢查新增號碼是否正確」,很可能快速產生這類 Trigger。它會查 inserted、會 Rollback,外觀很像正式答案,卻漏掉執行時間點、流水號作用域、舊資料格式與多筆異動語意。
AI 能比較各層的實作方式,但「哪一層有權決定這條規則」必須先由工程師定義。
| 元件 | 負責 | 不負責 |
|---|---|---|
| Delphi UI | 必填提示、按鈕狀態、顯示錯誤、刷新結果 | 計算正式流水號 |
| Stored Procedure | 驗證建立條件、產號、主檔新增、Transaction、回傳結果 | 畫面互動 |
| Trigger | 阻擋已確認必須攔截的非標準異動 | 產號、猜測使用者意圖 |
| Schema Constraint/Unique Index | 以 NOT NULL 等 Schema 規則保護永久底線,並保證同一案件內號碼不可重複 |
決定下一號與顯示友善提示 |
資料流整理如下:
使用者按儲存
↓
Delphi 檢查畫面輸入
↓
呼叫 CreateDocument
↓
Stored Procedure 鎖定、產號並新增
↓
Schema Constraint/Unique Index 守住資料底線
↓
Trigger 執行必要的額外防線
↓
成功後回傳正式號碼
↓
Delphi 刷新並顯示結果
每一層都參與保護,但只有 Stored Procedure 決定正式號碼。
procedure TDocumentForm.SaveNewDocument;
begin
ValidateInput;
btnSave.Enabled := False;
try
qryCreateDocument.Close;
qryCreateDocument.ParamByName('ProjectNo').AsString :=
Trim(edtProjectNo.Text);
qryCreateDocument.ParamByName('Title').AsString :=
Trim(edtTitle.Text);
qryCreateDocument.Open;
edtItemNo.Text :=
qryCreateDocument.FieldByName('NewItemNo').AsString;
RefreshDocument;
ShowMessage('新增完成,流水號:' + edtItemNo.Text);
finally
btnSave.Enabled := True;
end;
end;
這裡刻意沒有:
edtItemNo.Text := FormatFloat('0000', GetMaxItemNo + 1);
也沒有在重複鍵錯誤時自行加一再重試。資料庫若拒絕新增,Delphi 應顯示錯誤並保留必要輸入,而不是猜測資料庫現在會接受哪個號碼。
新規則只針對新增號碼。如果使用者之後只是修改標題或備註,Trigger 不應重新檢查「目前是否仍為下一號」。
否則歷史資料多年後修改備註時,可能因為號碼不是目前最大值而被拒絕。
CREATE OR ALTER TRIGGER dbo.TR_DocumentHeader_InsertGuard
ON dbo.DocumentHeader
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
-- 這是目前已確認的介面限制。
-- 若未來有合法批次匯入,必須重新設計。
IF (SELECT COUNT(*) FROM inserted) <> 1
THROW 51001, N'目前僅允許單筆新增。', 1;
-- 不在 Trigger 重新計算下一號。
IF EXISTS
(
SELECT 1
FROM inserted
WHERE ItemNo IS NULL
OR LTRIM(RTRIM(ItemNo)) = ''
)
THROW 51002, N'流水號不可空白。', 1;
END;
這個範例刻意保持簡單。實際系統若所有寫入都已強制經過 Stored Procedure,而且 NOT NULL、其他 Constraint 與 Unique Index 已足以保護資料,這個 Trigger 甚至可能沒有保留的必要。
Trigger 不是架構成熟的證明;有時候,能安全刪掉不再需要的 Trigger,才代表責任真的收斂完成。
如果把需求丟給 AI:
「請在 Delphi、Stored Procedure 與 Trigger 都檢查流水號,避免錯誤資料寫入。」
它很可能會忠實產生三份檢查。
這不是完全錯誤。對某些不變量,多層防守確實合理,例如:
NULL、空字串與全空白字串;NOT NULL 阻止 NULL;CHECK Constraint。錯的是沒有區分「重複驗證」與「重複決策」。若三層都各自計算下一號,就會變成三個權威來源。未來只要其中一層忘記同步,系統就會在不同入口給出不同結果。
我接受多層防守,但加上兩個限制:
在這次案例裡:
NULL、空字串與全空白字串;Schema 則至少以 NOT NULL 阻止 NULL,必要時再以 CHECK Constraint 保護「不得為空白字串」這項永久不變量;這不是把所有責任都推給資料庫,而是讓每一層只做它最有能力證明正確的事。
| 問題 | 修改前 | 修改後 |
|---|---|---|
| 下一號由誰決定 | Delphi、SQL、Trigger 都可能參與 | Stored Procedure 唯一決定 |
| 同案件不可重複 | 先查再判斷 | Unique Index 最終保證 |
| 必填欄位 | 只有畫面檢查 | UI 提示+Stored Procedure 驗證+Schema 底線 |
| Trigger 的角色 | 重算預期號碼、影響 INSERT/UPDATE | 僅保留必要的新增防線 |
| 新增交易 | Client 分段執行 | Stored Procedure 內一次完成 |
| 錯誤處理 | 各層各自顯示或吞掉 | 資料庫回傳明確錯誤,Client 呈現 |
這張表比「最後用了哪一段 SQL」更重要,因為它記錄的是架構決策。
未來若新增 Web API,開發者不用重新猜規則,只要遵守同一個建立文件入口,並讓 Schema 層的 Constraint 與 Unique Index 繼續守住底線。
責任重新分層後,我至少會驗證以下情境。
ProjectNo + ItemNo,Unique Index 會拒絕;這些測試不是在確認每一層「都有跑到」,而是在驗證某一層被繞過時,下一層是否真的守得住。
這類修改同時碰到 Delphi、Stored Procedure、Trigger 與 Index,上線前不能只準備一份新 EXE。
我會保留:
尤其是最後一點。如果資料庫已改成唯一產號來源,但現場還有舊版 Delphi 繼續自己計算,新舊版本並存期間就會同時存在兩種流程。
發布順序也是架構的一部分:
確認舊 Client 相容性
↓
部署資料庫防線與 Stored Procedure
↓
部署不再自行產號的新 Client
↓
確認版本使用狀況
↓
移除已無必要的舊邏輯
如果無法保證所有 Client 同時更新,就必須把混合版本納入測試,而不是假設 Production 永遠只有最新版。
回頭看 Day 8 到 Day 10,這個需求原本只是把欄位從 CHAR(3) 改成 CHAR(4),最後卻必須依序釐清三件事:Day 8 先確認流水號過去代表的資料語意,Day 9 再處理多人同時取號時的 Concurrency,Day 10 最後回答規則確認後應由哪一層負責。
真正需要修改的從來不只是一個欄位長度,而是資料語意、Concurrency 與規則 Ownership。
因此,今天沒有再增加更複雜的產號演算法,而是處理一個更容易被忽略的問題:規則的所有權。
這次最後的分工是:
NOT NULL 等規則保護永久資料底線,Unique Index 保證同一案件內不可出現重複號碼;AI 很擅長回答「這段驗證可以怎麼寫」,卻不會自動知道某條規則應由誰擁有。若只要求它在三層都補上檢查,它真的可能很勤勞地複製三份,然後留下三個未來需要同步維護的答案。
💡 今日金句:好的分層不是每一層都做一遍,而是每一層都知道自己為什麼有權做這件事。
下一篇,我們要面對資料格式改版最容易被低估的後座力:
新規則上線後,為什麼十年前的舊資料突然不能修改了?