
前面幾天從定義長出來的都是具體產物:資料表、CRUD 語句、四個端的畫面。今天這一項不造東西,它拿定義裡寫著的算式去算:算完把值填回欄位(ValueExpression),或者把整個存檔動作擋下來(FormRule)。
必填、範圍檢查、欄位之間的四則運算,每一家的內容都不一樣,形狀卻完全一樣,都是「拿這一列的幾個欄位算一下,不成立就擋下來」。形狀一樣就收得進機制:這一類簡單的規則與計算要改,不必動程式碼。
制式化指的是形狀,不是內容。
本篇說明:
FormRule 與 ValueExpression 為何跟欄位放在同一份定義裡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 != "Draft""
Condition="customer_rowid != Guid.Empty"
Message="Please select a customer for the order." />
<FormRule RuleId="quantity_positive" Trigger="BeforeSave" TargetTable="OrderDetail"
Condition="quantity > 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 是資料,改的就只是一個字串。
| 時點 | 在哪一端 | 做什麼 |
|---|---|---|
| 新增一列 | 前端 | 預設值算式求值一次,填進還空著的欄位 |
| 改動欄位的當下 | 前端 | 相關計算欄立刻重算,畫面上的金額跟著跳 |
| 存檔之前 | 後端 | 新增的列先補上還空著的預設值,接著新增與異動過的列重算計算欄,最後跑 Trigger 為 BeforeSave 的 FormRule。沒動過的列不重算(重算會把它標成動過的) |
| 刪除之前 | 後端 | 跑 Trigger 為 BeforeDelete 的 FormRule。比對的不是使用者手上那份資料,是框架另外從資料庫讀回來的那一筆 |
前端也在算,於是冒出一個問題:存檔時要不要相信前端算好的值?
框架的答案是不要。存檔前那次重算是無條件的,前端送上來的 ValueExpression 結果一律被覆蓋。資料完整性不能託給前端,前端可能算錯,也可能根本不是你寫的那個前端。
這個決定換到一個性質:前端那一段可以整個不存在。某個端跑不動即時求值,就是少了金額跟著跳這件事,操作照常,存進資料庫的值一模一樣。即時預覽是體驗,不是正確性的一部分。
計算欄兩邊都算,FormRule 只在後端的 Business Object 那一步跑,前端一次都不跑。
差別在錯了會怎樣:
所以把驗證搬一份到前端,換到的只是使用者少等一趟往返,代價卻是同一條規則在兩個地方求值,而它們遲早會對不齊,到時還多一個問題要回答:哪一邊的訊息才算數。
FormRule的防線只有一道,就在後端存檔前那一步;前端負責的是讓使用者當下看得到數字在動。
這幾個時點落在存檔流程的哪個位置、跟寫進資料庫那一段是什麼關係,Day 14 會專門談。
同一條算式要是兩邊各寫一次,明年有人只改了其中一邊,畫面顯示一個數字、存進去變成另一個,兩邊各自看都是對的,查起來特別久。
框架的做法是讓兩邊引用同一段程式:一行 ValueExpression 同時套用到前後端。那段求值的程式放在定義層,沒有伺服器才有的相依,於是後端存檔前呼叫的,跟前端每次按鍵呼叫的,是同一個類別的同一個方法。兩邊會不會算出不同答案,這件事在結構上就不成立。
同一行算式還多給了一件事:前端要即時重算,得先知道「改了哪個欄位,要重算哪幾個計算欄」,而這份對照不是另外設定的,是把 ValueExpression 解析一次,取出裡面的名字反推的。
算式看得到的,是這次操作手上的那一批資料,而且目前只攤開這一列:金額算的是這一列的數量乘這一列的單價,必填規則看的也是這一列填了沒有。
這個範圍決定了目前有哪些東西宣告不出來,而它們剛好分兩類,性質完全不同:
| 類別 | 例子 | 性質 |
|---|---|---|
| 跨列 | 「至少要有一列明細」、「主檔總額等於明細金額加總」 | 資料就在手上,是進度 |
| 要問資料庫 | 「狀態不能從已出貨改回草稿」、「配一個訂單編號」 | 資料不在手上,刻意留在外面 |
跨列那一類補一個聚合函式就到得了,讓算式寫得出明細金額的總和,那些列本來就跟主檔在同一批資料裡。難的是求值要重新排隊,主檔總額依賴明細金額,而明細金額自己又是一條算式,先後順序得有一套規則。這個有解法,只是目前未實作。
真正表達不了的是另一類。算式一旦能查資料庫就不再可攜,前端立刻算不動,前面那個「兩邊引用同一段程式」的性質會先斷掉。所以界線不畫在「一列還是多列」,畫在「這批資料裡有沒有」:裡面的遲早都到得了,要離開它去問一句才知道的,刻意不給。
Day 3 那個疑慮在這一層最直接:如果驗證只能用 Condition 寫,算式表達不了的怎麼辦?
一半的答案在上一節(表達不了的就寫程式),另一半是:宣告式從來不是唯一那條路,它是預設值。
FormRule 是資料,每一條各有自己的 Enabled 與 Order,可以單獨停用,也可以指定同一個時點裡的先後順序,不必為了關掉一條而動整份定義,更不必改程式。彈性在這一層的位置跟 Day 7 版面那一層是同一個形狀:不在能力,在顆粒度。
一行 ValueExpression 加三條 FormRule,是案例目前宣告出來的全部。它們換掉的是原本寫在訂單 BO 裡的程式:每列金額的計算,以及客戶、產品、數量三條檢查。
留在程式裡的還有四件,正好各兩件落在前面那兩類:
Day 3 也切過同一堆東西,問的是「這件事該不該由應用來寫」;今天這條線問的是「為什麼不能宣告」。兩條線問的不是同一件事,同一項自然不一定落在對應位置:狀態轉換與訂單編號在 Day 3 分屬兩邊,一個是每一家都不一樣的業務判斷,一個是該收斂而框架還沒收,今天這條線卻把它們歸成同一類。
訂單那個 BO 還做了一件 Day 3 沒算進去的事:新增的時候把訂單日期設成今天。這一件框架本來就做了,日期型別的欄位在新增一列時會填上今天,一個字都不必宣告。差別在於前面那些搬進定義之後有人回頭把程式碼刪了,這一件沒有:那行程式照常跑,測試照常綠,只是不再有存在的理由。
宣告能力每往前推一格,程式碼那一側就有一批東西從必要變成殘留。
Day 3 的說法是「留在程式碼裡的東西,一部分本來就該在那裡,一部分只是還沒被接管」。實際上有第三種:已經被接管了,只是沒有人回去把它拿掉。三種裡就屬第三種最難發現,因為它一切正常,只是白寫。
案例上留下的那四件裡,只有一件是這一家自己訂的規則,其餘三件不是業務判斷,是宣告那一側沒有接住它們。而這個答案有保存期限:跨列那一半補上聚合函式與求值順序就會消失,要問資料庫那一半才是刻意留在外面的。
所以「哪些該寫程式」從來不是一份清單,是一條會動的線。框架設計者能做的不是把它一次畫在對的位置,而是讓它往前移動的時候,前面那幾百份已經寫好的定義一個字都不必改。
明天談那個一路跨層傳下去的資料容器:上千張表為什麼不各自建一個型別,以及少掉編譯期檢查之後這筆帳怎麼補。
本系列同步發表於 HackMD,完整目錄