iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
ChatGPT & Codex

AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發系列 第 14 篇

Day 14|祖傳系統同一筆資料有兩條處理路徑:你修對 Code,卻可能修錯流程

  • 分享至 

  • xImage
  •  

前言/情境導入

上一篇,我處理了一個 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 嗎?


核心技術解析

找到相關的 Code,不代表 Runtime 真的有走到這裡

假設原始程式大概是這樣:

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 真正開始轉向的地方。


Result 不對,不一定是產生 Result 的公式錯了

這個陷阱其實跟上一篇很像,但方向不太一樣。

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 判斷寫錯了。」

因為現在證明的只有:

程式為什麼會走到這裡。

至於它應不應該走到這裡,是下一個商業規則問題。

這兩件事,我現在會刻意分開。


程式碼實作

更麻煩的是,有些 Condition 根本不是資料,而是 Flag

前面的 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 = ''

流程根本不會走到這裡。


Flag 不只是「現在是多少」,還要看它的生命週期

資料欄位出問題,我通常還知道要往 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?


Flag Trace:追的不只是值,而是整個 Lifecycle

所以原本的 Branch Trace:

Condition
    ↓
Branch
    ↓
Result

碰到 Flag 之後,我會再往前、往後各追一點:

Flag Setter
    ↓
Flag State
    ↓
Condition
    ↓
Branch
    ↓
Result
    ↓
Flag Reset

我把這件事叫做 Flag Trace。

Flag Trace 的核心,就是追這個 Flag 的生命週期:它在哪裡被設定、哪些地方會讀取,以及最後有沒有正常 Reset。

所以看到 Condition 裡有 Flag 時,我現在至少會確認四件事:

  1. Current Value:現在到底是 True、False、Y 還是空值?
  2. Setter:誰把它設成現在這個值?
  3. Reader:哪些 Event / Procedure 會根據它決定是否繼續?
  4. Reset:正常流程與 Exception 路徑最後都會把它復原嗎?

只知道第一項,通常還不能算完成 Flag Trace。


值已經取得,不代表 Condition 已經被判斷

如果 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。」

是兩件完全不同的事。


這時候 AI 最好用的地方,不是幫我改 if

Legacy 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 從「看到問題就想修」的模式拉回來。

先別急。

先把路走一遍。


Static Code 畫出路線圖,Runtime Evidence 才留下真正的足跡

例如看到:

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,它跟我一樣,都只能看到:

程式可能怎麼走。

它並沒有真的看到使用者剛剛那次操作是怎麼走的。


找到走錯的 Branch,也先不要急著改 Condition

假設最後 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。」

這句話在技術上可能完全正確。

但工程師真正要回答的是:

我真的可以把它拿掉嗎?

這才是兩個完全不同的問題。


從 Data Trace 到 Branch Trace,再到 Flag Trace

回頭看 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,也可以從這個案例學到什麼?

看到這裡,如果你沒有寫過 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。

先證明程式怎麼走,再決定程式怎麼改。


上一篇
Day 13|祖傳系統的金額為什麼突然變成 0?Debug 時先追資料,不要先改公式
下一篇
Day 15|AI 很有自信地找到 Bug——而我差點真的相信它
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言