iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 8

Day 8:宣告式業務規則與計算欄

  • 分享至 

  • xImage
  •  

Day 8:宣告式業務規則與計算欄

前面幾天從定義長出來的都是具體產物:資料表、CRUD 語句、四個端的畫面。今天這一項不造東西,它拿定義裡寫著的算式去算:算完把值填回欄位(ValueExpression),或者把整個存檔動作擋下來(FormRule)。

必填、範圍檢查、欄位之間的四則運算,每一家的內容都不一樣,形狀卻完全一樣,都是「拿這一列的幾個欄位算一下,不成立就擋下來」。形狀一樣就收得進機制:這一類簡單的規則與計算要改,不必動程式碼。

制式化指的是形狀,不是內容。

本篇說明:

  1. FormRuleValueExpression 為何跟欄位放在同一份定義裡
  2. 求值的四個時點,以及「驗證只在後端跑」這個不對稱
  3. 前後端如何共用同一段算式
  4. 算式表達不了的兩類,以及它們性質不同
  5. 宣告式是預設值不是唯一那條路,應用的程式疊在它上面

一、FormRule 跟欄位放在同一份定義裡

宣告式邏輯有兩個位置,下面兩段節選自案例訂單的 Order.FormSchema.xml。一個掛在欄位上:

<FormField FieldName="amount" DbType="Currency" NumberKind="Amount" ReadOnly="true"
           ValueExpression="quantity * unit_price * (1 - discount)" />

另一個掛在整份 FormSchema 下面:

<Rules>
  <FormRule RuleId="customer_required" Trigger="BeforeSave"
            When="status != &quot;Draft&quot;"
            Condition="customer_rowid != Guid.Empty"
            Message="Please select a customer for the order." />
  <FormRule RuleId="quantity_positive" Trigger="BeforeSave" TargetTable="OrderDetail"
            Condition="quantity &gt; 0"
            Message="Every detail line must have a quantity greater than zero." />
</Rules>

兩者都在 FormSchema 裡,沒有另外一份規則檔。一條 FormRule 讀起來就是一句判斷,屬性的先後就是它的語意先後:

  • Trigger:什麼時候檢查,範例那兩條都是存檔之前,刪除之前的要另外標
  • When:這一次該不該檢查,不成立就整條跳過
  • Condition:必須成立的條件
  • Message:不成立時使用者看到的那句話
  • ValueExpression:不在這句判斷裡,它掛在欄位上,算完把結果寫回某一欄

範例那條 When 的意思是訂單還在草稿時可以先不填客戶,離開草稿就必須有。它與 Condition 分成兩個屬性而不是併成一句,是因為併起來的那個形狀很容易寫反,而寫反的規則不會報錯,只是從此永遠通過。

跟欄位同住一份定義是刻意的:Trigger 以外,每一個的主詞都是欄位。欄位改名、刪掉、換型別,FormRule 得跟著動。分成兩份檔案就會有一份先過期,而過期的那一份不會報錯,它只是在某一天安靜地不再擋任何東西。

還有一個效果在 FormRule 上特別明顯:系統上線之後改得最頻繁的東西裡,驗證條件排得很前面(折扣原本最多打八折、現在放寬到七折;這張單原本零元也能存、現在不行)。異動的內容小到一句話講得完,走程式碼那條路卻要付一整趟編譯、打包與部署。FormRule 是資料,改的就只是一個字串。


二、求值只發生在幾個時點

時點 在哪一端 做什麼
新增一列 前端 預設值算式求值一次,填進還空著的欄位
改動欄位的當下 前端 相關計算欄立刻重算,畫面上的金額跟著跳
存檔之前 後端 新增的列先補上還空著的預設值,接著新增與異動過的列重算計算欄,最後跑 TriggerBeforeSaveFormRule。沒動過的列不重算(重算會把它標成動過的)
刪除之前 後端 TriggerBeforeDeleteFormRule。比對的不是使用者手上那份資料,是框架另外從資料庫讀回來的那一筆

前端也在算,於是冒出一個問題:存檔時要不要相信前端算好的值?

框架的答案是不要。存檔前那次重算是無條件的,前端送上來的 ValueExpression 結果一律被覆蓋。資料完整性不能託給前端,前端可能算錯,也可能根本不是你寫的那個前端。

這個決定換到一個性質:前端那一段可以整個不存在。某個端跑不動即時求值,就是少了金額跟著跳這件事,操作照常,存進資料庫的值一模一樣。即時預覽是體驗,不是正確性的一部分。

一個不對稱:驗證只在後端跑

計算欄兩邊都算,FormRule 只在後端的 Business Object 那一步跑,前端一次都不跑。

差別在錯了會怎樣:

  • 計算欄前端算錯的代價是零,存檔前那次重算會蓋掉它。
  • 驗證沒有「蓋掉」這個動作。前端擋下來的東西後端根本看不到,前端放行的東西後端還是會擋。

所以把驗證搬一份到前端,換到的只是使用者少等一趟往返,代價卻是同一條規則在兩個地方求值,而它們遲早會對不齊,到時還多一個問題要回答:哪一邊的訊息才算數。

FormRule 的防線只有一道,就在後端存檔前那一步;前端負責的是讓使用者當下看得到數字在動。

這幾個時點落在存檔流程的哪個位置、跟寫進資料庫那一段是什麼關係,Day 14 會專門談。


三、同一段算式,兩邊算出來要一樣

同一條算式要是兩邊各寫一次,明年有人只改了其中一邊,畫面顯示一個數字、存進去變成另一個,兩邊各自看都是對的,查起來特別久。

框架的做法是讓兩邊引用同一段程式:一行 ValueExpression 同時套用到前後端。那段求值的程式放在定義層,沒有伺服器才有的相依,於是後端存檔前呼叫的,跟前端每次按鍵呼叫的,是同一個類別的同一個方法。兩邊會不會算出不同答案,這件事在結構上就不成立。

同一行算式還多給了一件事:前端要即時重算,得先知道「改了哪個欄位,要重算哪幾個計算欄」,而這份對照不是另外設定的,是把 ValueExpression 解析一次,取出裡面的名字反推的。


四、算式表達不了的兩類

算式看得到的,是這次操作手上的那一批資料,而且目前只攤開這一列:金額算的是這一列的數量乘這一列的單價,必填規則看的也是這一列填了沒有。

這個範圍決定了目前有哪些東西宣告不出來,而它們剛好分兩類,性質完全不同:

類別 例子 性質
跨列 「至少要有一列明細」、「主檔總額等於明細金額加總」 資料就在手上,是進度
要問資料庫 「狀態不能從已出貨改回草稿」、「配一個訂單編號」 資料不在手上,刻意留在外面

跨列那一類補一個聚合函式就到得了,讓算式寫得出明細金額的總和,那些列本來就跟主檔在同一批資料裡。難的是求值要重新排隊,主檔總額依賴明細金額,而明細金額自己又是一條算式,先後順序得有一套規則。這個有解法,只是目前未實作。

真正表達不了的是另一類。算式一旦能查資料庫就不再可攜,前端立刻算不動,前面那個「兩邊引用同一段程式」的性質會先斷掉。所以界線不畫在「一列還是多列」,畫在「這批資料裡有沒有」:裡面的遲早都到得了,要離開它去問一句才知道的,刻意不給。


五、宣告式是預設值,不是唯一那條路

Day 3 那個疑慮在這一層最直接:如果驗證只能用 Condition 寫,算式表達不了的怎麼辦?

一半的答案在上一節(表達不了的就寫程式),另一半是:宣告式從來不是唯一那條路,它是預設值。

  • 前面那整段求值,本身就是 BO「存檔之前」那一步的預設做法,而那一步是開放覆寫的。
  • 應用覆寫時先呼叫預設那一段(規則照跑、計算欄照算),接著往下接自己的程式。兩者是疊加,不是二選一。
  • 再往下一層還有更細的顆粒度:FormRule 是資料,每一條各有自己的 EnabledOrder,可以單獨停用,也可以指定同一個時點裡的先後順序,不必為了關掉一條而動整份定義,更不必改程式。

彈性在這一層的位置跟 Day 7 版面那一層是同一個形狀:不在能力,在顆粒度。


六、案例:定義收掉了什麼,程式裡留下什麼

一行 ValueExpression 加三條 FormRule,是案例目前宣告出來的全部。它們換掉的是原本寫在訂單 BO 裡的程式:每列金額的計算,以及客戶、產品、數量三條檢查。

留在程式裡的還有四件,正好各兩件落在前面那兩類:

  • 跨列:至少一列明細、主檔總額
  • 要問資料庫:狀態轉換、訂單編號

Day 3 也切過同一堆東西,問的是「這件事該不該由應用來寫」;今天這條線問的是「為什麼不能宣告」。兩條線問的不是同一件事,同一項自然不一定落在對應位置:狀態轉換與訂單編號在 Day 3 分屬兩邊,一個是每一家都不一樣的業務判斷,一個是該收斂而框架還沒收,今天這條線卻把它們歸成同一類。

界線移動過,而且不會有人通知你

訂單那個 BO 還做了一件 Day 3 沒算進去的事:新增的時候把訂單日期設成今天。這一件框架本來就做了,日期型別的欄位在新增一列時會填上今天,一個字都不必宣告。差別在於前面那些搬進定義之後有人回頭把程式碼刪了,這一件沒有:那行程式照常跑,測試照常綠,只是不再有存在的理由。

宣告能力每往前推一格,程式碼那一側就有一批東西從必要變成殘留。

Day 3 的說法是「留在程式碼裡的東西,一部分本來就該在那裡,一部分只是還沒被接管」。實際上有第三種:已經被接管了,只是沒有人回去把它拿掉。三種裡就屬第三種最難發現,因為它一切正常,只是白寫。


小結

案例上留下的那四件裡,只有一件是這一家自己訂的規則,其餘三件不是業務判斷,是宣告那一側沒有接住它們。而這個答案有保存期限:跨列那一半補上聚合函式與求值順序就會消失,要問資料庫那一半才是刻意留在外面的。

所以「哪些該寫程式」從來不是一份清單,是一條會動的線。框架設計者能做的不是把它一次畫在對的位置,而是讓它往前移動的時候,前面那幾百份已經寫好的定義一個字都不必改。

明天談那個一路跨層傳下去的資料容器:上千張表為什麼不各自建一個型別,以及少掉編譯期檢查之後這筆帳怎麼補。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 7:FormLayout 與多前端畫面產生
下一篇
Day 9:資料與定義分家,表單不必各自建型別
系列文
ERP 架構師筆記:定義驅動的框架設計9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言