上一篇,我處理了一個 Legacy System Debug 很常見的問題。
使用者說:
某些資料轉入之後,金額會變成 0。
第一個直覺通常是去找公式。
金額 = 數量 × 單價
但 Day 13 最後真正找到問題的方法,不是先改公式,而是一路追:
Source
↓
Dataset
↓
Variable
↓
Calculation
↓
Database
看資料到底在哪一層第一次發生變化。
所以上一篇的重點,可以濃縮成一句:
先追 Data,再決定 Fix。
可是實際維護祖傳系統,很快又會遇到另一種更麻煩的情況。
這一次,資料可能根本沒有錯。
程式碼看起來也沒有錯。
你甚至真的找到了一支跟這個功能高度相關的 Procedure。
例如使用者回報:
「這筆資料明明有勾選併入,為什麼最後的結果不對?」
我搜尋專案,很快找到一支:
procedure TransferToTarget;
begin
// 將資料併入目標資料
...
end;
名稱這麼明顯,怎麼看都很可疑。
打開之後又看到:
Amount := Qty * Price;
很好。
金額有問題、資料又有勾選併入,現在連 TransferToTarget 都找到了。
嫌疑犯幾乎自己站出來了。
於是接下來很容易開始檢查:
Qty 有沒有錯?
Price 有沒有錯?
Amount 算得對不對?
Dataset 有沒有 Post?
甚至把整支 TransferToTarget 丟給 AI,它也可以很認真地幫我分析十幾個可能的問題。
只是這時候其實少問了一個問題。
而且是最前面的那個問題:
這次操作,真的有執行到
TransferToTarget嗎?
假設原始程式大概是這樣:
if dtDATA.FieldByName('TO_TARGET').AsString = 'T' then
begin
TransferToTarget;
end
else
begin
SaveCurrentData;
end;
光看程式,其實很直覺:
TO_TARGET = 'T' ?
│
┌──────┴──────┐
Yes No
│ │
▼ ▼
TransferToTarget SaveCurrentData
│ │
└──────┬───────┘
▼
Result
使用者又告訴我:
「這筆有勾併入。」
那腦中很自然就會補成:
有勾併入
↓
TO_TARGET = 'T'
↓
TransferToTarget
問題就出在這裡。
前面第一行是使用者看到的畫面。
第二行開始,已經是我的推論。
它們不是同一件事。
真正 Runtime 的值可能是:
TO_TARGET = ''
也可能因為前面的某段同步邏輯,現在 Dataset 裡的值根本還不是我以為的 'T'。
所以這次我沒有先改 TransferToTarget。
而是先加了一個很土、但很好用的確認:
ShowMessage(
'TO_TARGET = [' +
dtDATA.FieldByName('TO_TARGET').AsString +
']'
);
結果如果跳出:
TO_TARGET = []
事情突然就完全不一樣了。
原本腦中的流程是:
TO_TARGET = 'T'
↓
TransferToTarget
↓
Result 錯誤
Runtime Evidence 告訴我的卻是:
TO_TARGET = ''
↓
Condition = False
↓
SaveCurrentData
那我前面花時間檢查的 TransferToTarget 呢?
它可能一行都沒有執行。
這就是這次 Debug 真正開始轉向的地方。
這個陷阱其實跟上一篇很像,但方向不太一樣。
Day 13 是看到:
Amount = 0
不要立刻認定:
公式錯了
今天則是看到:
Result 不符合預期
不要立刻認定:
負責這個功能的 Procedure 寫錯了
因為假設 SaveCurrentData 本來就是:
Amount := Qty * Price;
最後算出:
Qty = 10
Price = 100
Amount = 1000
這段程式其實完全沒錯。
真正的問題可能只是:
這筆資料原本應該走
TransferToTarget,結果卻進了SaveCurrentData。
所以我開始把 Debug 順序換成:
Condition
↓
Branch
↓
Result
先別急著問:
「這個 Result 為什麼錯?」
先問:
「這個 Result 到底是哪一條 Branch 做出來的?」
if如果祖傳系統真的只有:
if TO_TARGET = 'T' then
那人生會輕鬆很多。
但實際打開程式,通常比較接近:
if GSD_EDIT = 'Y' then
begin
if dtDATA.FieldByName('TO_TARGET').AsString = 'T' then
begin
if dtDATA.FieldByName('STATUS').AsString <> 'C' then
TransferToTarget
else
SaveClosedData;
end
else
SaveCurrentData;
end;
這時候 TransferToTarget 要真的被執行,條件其實不是一個,而是:
GSD_EDIT = 'Y'
AND
TO_TARGET = 'T'
AND
STATUS <> 'C'
也就是說,使用者就算真的有勾「併入」,也只能證明其中一個條件可能成立。
如果 Runtime 是:
GSD_EDIT = Y
TO_TARGET = T
STATUS = C
實際流程就會變成:
GSD_EDIT = 'Y'
↓ True
TO_TARGET = 'T'
↓ True
STATUS <> 'C'
↓ False
SaveClosedData
這時候我終於可以很明確地說:
這次沒有進
TransferToTarget,不是因為我猜它沒進,而是 Runtime Evidence 已經證明它在第三個 Condition 轉去了另一條 Branch。
不過這裡還不能直接下結論:
「那一定是
STATUS判斷寫錯了。」
因為現在證明的只有:
程式為什麼會走到這裡。
至於它應不應該走到這裡,是下一個商業規則問題。
這兩件事,我現在會刻意分開。
前面的 TO_TARGET、STATUS 至少還是資料。
但 Legacy System 裡還有另一種很常出現在 Condition 裡的東西:
GSD_EDIT
GSD_QUERY
GSD_CHK
FTransferring
這些 Flag 通常只是程式執行期間的狀態,未必會存進資料庫,畫面上也未必看得到,卻可能直接決定某一段 Code 要不要執行。
例如:
if GSD_EDIT = 'Y' then
begin
if dtDATA.FieldByName('TO_TARGET').AsString = 'T' then
TransferToTarget;
end;
就算我已經確認:
TO_TARGET = T
也不能直接推論一定會進 TransferToTarget。
因為 TO_TARGET 只是其中一個 Condition。
如果更前面的:
GSD_EDIT = ''
流程根本不會走到這裡。
資料欄位出問題,我通常還知道要往 Dataset、SQL、Database 找。
Flag 就比較調皮了。
像 GSD_QUERY 這類 Flag,可能只是用來告訴其他 Event:
「現在這次變動是程式同步,不是使用者操作。」
所以某個地方可能設定:
GSD_QUERY := 'Y';
另一個 Event 則判斷:
if GSD_QUERY = 'Y' then
Exit;
流程結束後,再把 GSD_QUERY 清掉:
GSD_QUERY := '';
但真正麻煩的地方,是 Flag 如果沒有正常恢復。
例如另一個很常見的情境,是防止同一段流程重複進入:
if FTransferring then
Exit;
FTransferring := True;
try
TransferData;
finally
FTransferring := False;
end;
正常流程是:
FTransferring = False
↓
進入流程
↓
FTransferring = True
↓
TransferData
↓
finally
↓
FTransferring = False
這裡的 try...finally 不是為了「吃掉 Exception」,而是確保不管流程正常結束,還是中途拋出 Exception,清理邏輯都會執行,讓 FTransferring 能恢復成 False。
如果舊程式不是這樣寫,而是:
FTransferring := True;
TransferData;
FTransferring := False;
乍看好像差不多。
直到 TransferData 中途發生 Exception:
FTransferring := True
↓
TransferData
↓
Exception
↓
後面的 False 沒有執行
↓
FTransferring 還是 True
下一次使用者再操作:
if FTransferring then
Exit;
直接出去。
然後使用者只會告訴你:
「我剛剛按了,怎麼什麼都沒發生?」
如果只盯著最後那個 Exit,很容易做出一個非常危險的修法:
// if FTransferring then
// Exit;
看起來問題解決了。
按鈕又有反應了。
但原本拿來防止 Re-entry 的保護也一起被拔掉了。
所以現在如果 AI 告訴我:
問題可能出在
if FTransferring then Exit;。
這個判斷不能算錯,但 Debug 還沒結束。
下一個問題應該是:
為什麼執行到這裡時,
FTransferring會是True?
所以原本的 Branch Trace:
Condition
↓
Branch
↓
Result
碰到 Flag 之後,我會再往前、往後各追一點:
Flag Setter
↓
Flag State
↓
Condition
↓
Branch
↓
Result
↓
Flag Reset
我把這件事叫做 Flag Trace。
Flag Trace 的核心,就是追這個 Flag 的生命週期:它在哪裡被設定、哪些地方會讀取,以及最後有沒有正常 Reset。
所以看到 Condition 裡有 Flag 時,我現在至少會確認四件事:
True、False、Y 還是空值?只知道第一項,通常還不能算完成 Flag Trace。
如果 Condition 開始變多,我不太想一直:
ShowMessage('1');
ShowMessage('2');
ShowMessage('3');
ShowMessage('4');
不然 Debug 到最後,程式會比使用者還健談。
我會比較傾向暫時把關鍵狀態一次列出來:
ShowMessage(
'GSD_EDIT = [' + GSD_EDIT + ']' + #13#10 +
'TO_TARGET = [' +
dtDATA.FieldByName('TO_TARGET').AsString + ']' + #13#10 +
'STATUS = [' +
dtDATA.FieldByName('STATUS').AsString + ']' + #13#10 +
'FTransferring = [' +
BoolToStr(FTransferring, True) + ']'
);
假設得到:
GSD_EDIT = [Y]
TO_TARGET = [T]
STATUS = [A]
FTransferring = [True]
再回頭對照:
if FTransferring then
Exit;
if GSD_EDIT = 'Y' then
begin
if dtDATA.FieldByName('TO_TARGET').AsString = 'T' then
begin
if dtDATA.FieldByName('STATUS').AsString <> 'C' then
TransferToTarget;
end;
end;
現在就不用再猜了。
| 條件 / Flag | 觀察到的值 | Runtime 結果 |
|---|---|---|
FTransferring |
True |
Exit |
GSD_EDIT = 'Y' |
Y |
未判斷 |
TO_TARGET = 'T' |
T |
未判斷 |
STATUS <> 'C' |
A |
未判斷 |
TransferToTarget |
— | 未執行 |
這張表有一個很重要的地方。
GSD_EDIT、TO_TARGET、STATUS 的值,我確實已經取得了。
但是程式的控制流程根本還沒有執行到那些 if。
真正的 Runtime Path 是:
FTransferring = True
↓
Exit
↓
後面的 Condition 全部沒有被判斷
所以:
值已經取得,不代表 Condition 已經被判斷。
這跟:
「這些值看起來都正確,所以一定會執行
TransferToTarget。」
是兩件完全不同的事。
ifLegacy System 最痛苦的地方,是實際程式很少像文章範例這麼漂亮。
比較常看到的是:
if A then
begin
if B then
begin
...
end
else if C then
begin
if D then
...
end;
end;
if FChecking then
Exit;
中間可能再穿插 Exit,甚至某個 Procedure 裡又偷偷改了 Flag。
看久了真的很容易漏。
這時候 AI 很適合做一件我以前得自己慢慢畫的工作:
把 Branch 展開。
但我現在不會直接問:
這段 Code 有什麼問題?幫我修掉。
我會改成:
請先不要修改程式。
請根據以下 Delphi 程式碼,整理這次操作可能經過的執行路徑,
用:
Condition → Branch → Result
表示。
另外請標出:
1. 哪些 Condition 來自 Dataset
2. 哪些 Condition 來自 Flag
3. 可能提前結束流程的 Exit
4. 每個 Flag 的 Setter / Reader / Reset
5. Static Code 可以確認的資訊
6. 必須取得 Runtime Value 才能確認的資訊
最後列出我需要蒐集哪些 Runtime Evidence,
才能證明這次操作實際走的是哪一條 Branch。
目前不要提出 Fix。
最後那句我還是會特別留下:
目前不要提出 Fix。
因為它等於先把 AI 從「看到問題就想修」的模式拉回來。
先別急。
先把路走一遍。
例如看到:
if STATUS <> 'C' then
TransferToTarget
else
SaveClosedData;
我可以從程式碼確認:
STATUS <> 'C'
→ TransferToTarget
STATUS = 'C'
→ SaveClosedData
這是 Static Evidence。
但它只能告訴我:
程式有哪些可能的路。
直到我真的看到:
STATUS = C
而且 Runtime 進入:
SaveClosedData
我才有 Runtime Evidence 可以說:
這一次操作,實際走的是這條。
換個比較好記的說法:
Static Code 畫出的是「可能路線圖」,Runtime Evidence 才是這一次真正留下的足跡。
這個差異對 AI Debug 特別重要。
因為 AI 非常會讀 Static Code。
你貼一大段 Delphi 給它,它很快就能把可能性全部列出來。
但如果沒有 Runtime Evidence,它跟我一樣,都只能看到:
程式可能怎麼走。
它並沒有真的看到使用者剛剛那次操作是怎麼走的。
假設最後 Evidence 已經很完整:
預期:
STATUS = 'C'
仍然應該 TransferToTarget
實際:
STATUS = 'C'
走 SaveClosedData
這時候很容易覺得:
抓到了,就是這個
if!
然後:
if STATUS <> 'C' then
TransferToTarget
else
SaveClosedData;
直接改成:
TransferToTarget;
Bug 看起來解決了。
但這也是 Legacy System 最可怕的地方。
因為你不知道十年前寫這個 Condition 的人,是不是為了另一種案件才把 STATUS = 'C' 擋掉。
你今天修好案例 A:
案例 A → 正常
隔天可能變成:
案例 B → 壞掉
案例 C → 壞掉
所以找到 Branch 之後,我還是會回到前幾篇一直在做的事情:
先確認商業規則
↓
確認這個 Condition 原本保護什麼
↓
找出會受到影響的其他案例
↓
縮小修改範圍
↓
再做 Regression
AI 可以很快告訴我:
「把這個條件拿掉,就會進
TransferToTarget。」
這句話在技術上可能完全正確。
但工程師真正要回答的是:
我真的可以把它拿掉嗎?
這才是兩個完全不同的問題。
回頭看 Day 13 和今天,其實是在追不同的東西。
上一篇,我問的是:
資料在哪裡第一次變了?
所以一路追:
Source
↓
Dataset
↓
Variable
↓
Calculation
↓
Database
今天問的則是:
程式為什麼會走到這裡?
所以變成:
Condition
↓
Branch
↓
Result
如果 Branch 又被 Flag 控制,就再多做一次 Flag Trace:
Setter
↓
State
↓
Reader
↓
Reset
實際 Debug 的時候,這幾種 Trace 還會交錯進行。
例如一開始只是:
Result 不對
先用 Branch Trace 確認:
到底執行哪支 Procedure?
找到真正執行的 Procedure 之後,再切回 Data Trace:
這支 Procedure 裡,
資料又是從哪一行開始不對?
如果中間又碰到 Flag:
這個 Flag 為什麼現在是 True?
再切到 Flag Trace。
這比一看到錯誤就全文搜尋 Amount,然後挑一段最像兇手的 Code 開始改,可靠得多。
看到這裡,如果你沒有寫過 Delphi,可能會想:
等一下,我的專案裡根本沒有
TDataSet、FieldByName,也沒有什麼GSD_EDIT,那這篇跟我有什麼關係?
其實把 Delphi 語法拿掉之後,今天遇到的問題就是:
某個狀態
↓
某個 Condition
↓
走進不同 Branch
↓
得到不同 Result
這件事幾乎每一種程式都會遇到。
例如 JavaScript 裡常見:
if (isLoading) {
return;
}
if (order.status === 'C') {
saveClosedOrder();
} else {
transferOrder();
}
前面的:
if (isLoading) {
return;
}
本質上跟 Delphi 裡:
if FTransferring then
Exit;
沒有差多少。
語法不同而已。
真正值得帶走的是 Debug 時的思考順序。
當你找到一段「看起來就是兇手」的 Code 時,先不要急著改。
先確認:
這段 Code 要在什麼 Condition 下才會執行?
↓
這次 Runtime 的 Condition 是什麼?
↓
實際走進哪一條 Branch?
↓
最後才看 Result
如果 Condition 裡又出現:
isLoading
isSaving
isProcessing
isEditing
isTransferring
這類 Flag,就再追它的生命週期:
Setter
↓
State
↓
Reader
↓
Reset
所以今天真正可以從 Delphi 案例帶走的,不是某一個語法,而是三種 Debug 思路:
資料在哪裡變掉?
→ Data Trace
程式到底走哪條路?
→ Branch Trace
Flag 為什麼會是這個狀態?
→ Flag Trace
其中 Flag Trace 真正追的,就是它的生命週期。
不管你現在維護的是 Delphi、Java、C#、JavaScript,甚至是一套十年前沒人敢動的內部系統,這幾個問題都還是成立。
Delphi 只是今天剛好出事的那個人。
以前 Debug 祖傳系統,我很容易有一種錯覺。
只要搜尋到一支名稱很像的 Procedure:
TransferToTarget;
再看到裡面真的有:
Amount := Qty * Price;
腦中就會開始響起:
就是你了。
但現在我會先忍一下。
因為「這段 Code 跟功能有關」和「這次 Runtime 有執行這段 Code」,中間其實差了一大截。
所以今天我真正想留下的是:
Condition
↓
Branch
↓
Result
如果中間有 Flag,就再追:
Setter
↓
State
↓
Reader
↓
Reset
這也是我現在讓 AI 協助追 Branch 時,會特別要求它做的一件事:
不要只列出哪些 Condition 成立,而是把參與判斷的 Flag 一起追出 Setter、Reader 與 Reset。
AI 很容易找到「是哪個 if 擋住流程」,但工程師還是得判斷:這個 Flag 現在真的是錯誤狀態,還是它原本就在保護另一條流程?
所以看到 Flag 擋路,先別急著拔掉。它可能不是 Bug,反而是在阻止另一個 Bug。
這樣即使最後真的要動那一行 if,至少我知道自己改的不是「看起來最可疑的 Code」,而是已經有 Evidence 證明會影響這次流程的 Code。
先證明程式怎麼走,再決定程式怎麼改。