</> 技術筆記Tech Notes

獎金規則老是在變,我用 DSL 把它們搬出程式碼

把規則搬出程式碼

做企業系統最累的不是功能難寫,是規則一直改。

獎金公式、風險評分、簽核條件,這些東西幾乎每季都會動一次。如果它們是寫死在程式裡的 if/else,那每次業務調一個係數,你就得改程式、寫測試、重新編譯、跑一次發版流程。改的內容可能只有一個數字,流程卻要走兩個禮拜。久了 IT 就變成業務推不動事情的原因。

問題其實不在改得多勤,而在於很會變的業務規則,跟不太會變的程式碼被綁在一起了

把它們拆開的做法叫 DSL。這篇是我自己整理的筆記,從概念到 C# 和 T-SQL 的實作都走一遍。

DSL 到底是什麼

DSL(Domain-Specific Language,領域特定語言)就是專門為了某一個領域設計的小語言。

它跟 C#、Python 這種通用語言的差別在於企圖心。通用語言什麼都要能寫,所以語法一定複雜;DSL 只管一件事,詞彙直接借用那個領域本來就在講的話。

以獎金計算來說,這個領域會用到的概念其實就這幾個:

  • 業績

  • 目標

  • 達成率

  • 獎金級距

  • 獎金係數

所以獎金計算的 DSL 就圍繞這幾個詞長出來,裡面不會有迴圈、類別、泛型這些跟獎金無關的東西。

這樣拆開之後,兩邊各做各的:規則描述「要算成什麼樣」,引擎負責「怎麼算」。有點像食譜跟廚師的關係,寫食譜的人不用管爐火怎麼控。

三種寫法,看你想付多少代價

假設規則長這樣:

  • 達成率 100%(含)到 120%(不含),獎金是業績的 3%

  • 達成率 120% 以上,獎金是業績的 5%

  • 其他情況沒有獎金

同樣一份規則,可以有三種寫法。

一、JSON:最省事的起點

{
  "規則名稱": "2025年第三季銷售獎金",
  "獎金級距": [
    {
      "級距名稱": "標準獎金",
      "最低達成率": 1.0,
      "最高達成率": 1.2,
      "獎金係數": 0.03
    },
    {
      "級距名稱": "超級獎金",
      "最低達成率": 1.2,
      "最高達成率": 999,
      "獎金係數": 0.05
    }
  ],
  "預設獎金係數": 0
}

好處是不用做什麼準備工作,每種語言都有現成又快的解析器,開發者也都看得懂。

麻煩的地方是語法很嚴格,陣列最後一個元素後面多一個逗號就整份掛掉;而且原生 JSON 不能寫註解,規則旁邊想加一句「這是老闆特別交代的」都沒地方寫。

二、YAML:給人看的版本

規則名稱: 2025年第三季銷售獎金

# 定義不同達成率的獎金級距
獎金級距:
  - 級距名稱: 標準獎金
    最低達成率: 1.0
    最高達成率: 1.2
    獎金係數: 0.03

  - 級距名稱: 超級獎金
    最低達成率: 1.2
    最高達成率: 999 # 代表無上限
    獎金係數: 0.05

預設獎金係數: 0

同樣的內容,YAML 乾淨很多,而且可以寫註解。如果你希望業務或財務的同事自己看得懂、甚至自己改,這是我會選的格式。

代價是它對縮排很敏感,不小心按到 Tab 就爆了,所以要配一個認得 YAML 的編輯器。

三、自訂腳本:最像「語言」的那種

// 規則檔案: Q3_bonus.bonus_dsl

rule "2025年第三季銷售獎金"

when achievement_rate >= 1.2 {
    bonus = sales * 0.05
}

when achievement_rate >= 1.0 {
    bonus = sales * 0.03
}

default {
    bonus = 0
}

這種寫法最貼近業務講話的樣子,表達能力也最強,複雜的條件判斷可以直接寫在裡面。

但代價很實在:你得自己寫剖析器(Parser)跟直譯器(Interpreter),詞法分析、語法分析那一整套。除非規則真的複雜到 YAML 撐不住,不然我不會一開始就跳到這裡。

規則有了,還需要一個引擎

規則檔本身只是靜態的文字,要有人讀它、照著做,那個人就是執行引擎。

先看這份規則跑起來的結果長什麼樣:

四個業務跑同一份規則的結果

接下來用 C# 和 T-SQL 各做一次。

C# 的做法

步驟一:定義對應的資料模型

先讓 JSON 的結構在 C# 這邊有個對應的型別,之後就能強型別反序列化。

using System.Text.Json.Serialization;

// 對應 "獎金級距" 陣列中的每個物件
public class BonusTier
{
    [JsonPropertyName("級距名稱")]
    public string TierName { get; set; }

    [JsonPropertyName("最低達成率")]
    public decimal MinRate { get; set; }

    [JsonPropertyName("最高達成率")]
    public decimal MaxRate { get; set; }

    [JsonPropertyName("獎金係數")]
    public decimal BonusFactor { get; set; }
}

// 對應整個 JSON 檔案的根物件
public class BonusRuleSet
{
    [JsonPropertyName("規則名稱")]
    public string RuleName { get; set; }

    [JsonPropertyName("獎金級距")]
    public List<BonusTier> Tiers { get; set; }

    [JsonPropertyName("預設獎金係數")]
    public decimal DefaultFactor { get; set; }
}

步驟二:計算引擎

整個引擎其實就這麼一小段。找出達成率落在哪一段,套係數,乘上去。

public class BonusCalculator
{
    private readonly BonusRuleSet _rules;

    public BonusCalculator(BonusRuleSet rules)
    {
        _rules = rules;
    }

    public decimal CalculateBonus(SalesData data)
    {
        if (data.SalesTarget <= 0) return 0;

        decimal achievementRate = data.SalesAmount / data.SalesTarget;

        var applicableTier = _rules.Tiers
            .FirstOrDefault(tier => achievementRate >= tier.MinRate && achievementRate < tier.MaxRate);

        decimal bonusFactor = applicableTier?.BonusFactor ?? _rules.DefaultFactor;

        return data.SalesAmount * bonusFactor;
    }
}

值得注意的是這個類別完全沒有任何獎金規則。3%、5%、120% 這些數字一個都沒出現在程式碼裡,它只知道「要照級距表查」。這就是整件事的目的。

步驟三:串起來跑跑看

using System.Text.Json;

public class Program
{
    public static void Main(string[] args)
    {
        string jsonDsl = @"
        {
          ""規則名稱"": ""2025年第一季基礎獎金"",
          ""獎金級距"": [
            { ""級距名稱"": ""標準獎金"", ""最低達成率"": 1.0, ""最高達成率"": 1.2, ""獎金係數"": 0.03 },
            { ""級距名稱"": ""超級獎金"", ""最低達成率"": 1.2, ""最高達成率"": 999.0, ""獎金係數"": 0.05 }
          ],
          ""預設獎金係數"": 0.0
        }";

        var bonusRules = JsonSerializer.Deserialize<BonusRuleSet>(jsonDsl);
        Console.WriteLine($"已載入獎金規則: {bonusRules.RuleName}\n");

        var salesData = new List<SalesData>
        {
            new SalesData { EmployeeID = 101, EmployeeName = "王小明", SalesAmount = 110000.00m, SalesTarget = 100000.00m },
            new SalesData { EmployeeID = 102, EmployeeName = "陳大膽", SalesAmount = 150000.00m, SalesTarget = 120000.00m },
            new SalesData { EmployeeID = 103, EmployeeName = "張三",   SalesAmount = 80000.00m,  SalesTarget = 100000.00m },
            new SalesData { EmployeeID = 104, EmployeeName = "李四",   SalesAmount = 120000.00m, SalesTarget = 100000.00m }
        };

        var calculator = new BonusCalculator(bonusRules);

        Console.WriteLine($"{"員工ID",-10}{"姓名",-10}{"達成率",-12}{"獎金金額",-12}");
        Console.WriteLine(new string('-', 44));

        foreach (var employee in salesData)
        {
            decimal bonus = calculator.CalculateBonus(employee);
            decimal rate = employee.SalesTarget > 0 ? (employee.SalesAmount / employee.SalesTarget) : 0;
            Console.WriteLine($"{employee.EmployeeID,-10}{employee.EmployeeName,-10}{rate,-12:P1}{bonus,-12:C}");
        }
    }
}

public class SalesData
{
    public int EmployeeID { get; set; }
    public string EmployeeName { get; set; }
    public decimal SalesAmount { get; set; }
    public decimal SalesTarget { get; set; }
}

換成 T-SQL 也可以

如果資料本來就在資料庫,而且是幾萬筆一起算,那把資料搬出來再搬回去就有點浪費了。這時候直接在資料庫裡面做完比較划算。

同一份規則餵給兩種引擎

步驟一:準備資料表

IF OBJECT_ID('dbo.SalesData', 'U') IS NOT NULL DROP TABLE dbo.SalesData;
CREATE TABLE dbo.SalesData (
    EmployeeID INT PRIMARY KEY,
    EmployeeName NVARCHAR(50),
    SalesAmount DECIMAL(18, 2),
    SalesTarget DECIMAL(18, 2)
);

IF OBJECT_ID('dbo.BonusResults', 'U') IS NOT NULL DROP TABLE dbo.BonusResults;
CREATE TABLE dbo.BonusResults (
    EmployeeID INT PRIMARY KEY,
    EmployeeName NVARCHAR(50),
    AchievementRate DECIMAL(18, 4),
    BonusAmount DECIMAL(18, 2)
);

INSERT INTO dbo.SalesData (EmployeeID, EmployeeName, SalesAmount, SalesTarget) VALUES
(101, '王小明', 110000.00, 100000.00),
(102, '陳大膽', 150000.00, 120000.00),
(103, '張三',   80000.00, 100000.00),
(104, '李四',   120000.00, 100000.00);

步驟二:把引擎寫成預存程序

關鍵是 OPENJSON,它可以把 JSON 直接攤成一張暫存表,接下來就是一般的 JOIN 而已。

CREATE OR ALTER PROCEDURE dbo.usp_CalculateBonusFromDsl
    @JsonRules NVARCHAR(MAX)
AS
BEGIN
    SET NOCOUNT ON;

    -- A. 解析 DSL 規則
    IF OBJECT_ID('tempdb..#BonusTiers') IS NOT NULL DROP TABLE #BonusTiers;
    SELECT *
    INTO #BonusTiers
    FROM OPENJSON(@JsonRules, '$.獎金級距')
    WITH (
        TierName        NVARCHAR(50)    '$.級距名稱',
        MinRate         DECIMAL(18, 4)  '$.最低達成率',
        MaxRate         DECIMAL(18, 4)  '$.最高達成率',
        BonusFactor     DECIMAL(18, 4)  '$.獎金係數'
    );
    DECLARE @DefaultFactor DECIMAL(18, 4) = JSON_VALUE(@JsonRules, '$.預設獎金係數');

    -- B. 準備計算資料
    IF OBJECT_ID('tempdb..#CalculationData') IS NOT NULL DROP TABLE #CalculationData;
    SELECT
        EmployeeID, EmployeeName, SalesAmount, SalesTarget,
        CASE WHEN SalesTarget > 0 THEN SalesAmount / SalesTarget ELSE 0 END AS AchievementRate
    INTO #CalculationData
    FROM dbo.SalesData;

    -- C. 套規則算獎金
    TRUNCATE TABLE dbo.BonusResults;
    INSERT INTO dbo.BonusResults (EmployeeID, EmployeeName, AchievementRate, BonusAmount)
    SELECT
        c.EmployeeID, c.EmployeeName, c.AchievementRate,
        c.SalesAmount * ISNULL(t.BonusFactor, @DefaultFactor) AS BonusAmount
    FROM
        #CalculationData c
    LEFT JOIN
        #BonusTiers t ON c.AchievementRate >= t.MinRate AND c.AchievementRate < t.MaxRate;

    -- D. 收尾
    DROP TABLE #BonusTiers;
    DROP TABLE #CalculationData;

    PRINT '獎金計算完成!';
END
GO

步驟三:丟規則進去

DECLARE @MyDslJson NVARCHAR(MAX) = N'
{
  "規則名稱": "2025年第一季基礎獎金",
  "獎金級距": [
    { "級距名稱": "標準獎金", "最低達成率": 1.0, "最高達成率": 1.2, "獎金係數": 0.03 },
    { "級距名稱": "超級獎金", "最低達成率": 1.2, "最高達成率": 999.0, "獎金係數": 0.05 }
  ],
  "預設獎金係數": 0.0
}';

EXEC dbo.usp_CalculateBonusFromDsl @JsonRules = @MyDslJson;

SELECT * FROM dbo.BonusResults;

兩個引擎讀的是同一份規則。要從 C# 換成資料庫算,規則檔一個字都不用動。

設計 DSL 時我會提醒自己的幾件事

用領域的話來命名。 欄位叫 業績獎金級距,不要叫 var1op2。這件事聽起來很基本,但它決定了業務同事願不願意打開這個檔案。

忍住不要加功能。 DSL 只放解決這個領域需要的東西。一旦開始加檔案讀寫、迴圈、函式,它就會慢慢長成一個設計很差的通用語言,那還不如直接寫程式。

讓它好上手。 這語言是要給非開發人員用的,學習曲線太陡就沒人要用,最後又變成工程師在改。

描述「要什麼」,不要描述「怎麼做」。 宣告式的規則不但讀起來清楚,引擎之後想怎麼最佳化都可以。就像你跟計程車司機講目的地,不會一路指揮他什麼時候轉彎。

讓規則可以組合。 例如 總獎金 = 基本獎金 + 專案獎金,兩個部分各自定義、疊起來用。

錯誤訊息要講人話。 這點最容易被忽略。規則寫錯的時候,引擎要能講出「『超級獎金』的最低達成率 1.2 不能小於『標準獎金』的最高達成率 1.2,級距不能重疊」這種程度的話。如果只丟一句「解析失敗」,業務同事下次就不敢自己改了,你的 DSL 等於白做。

最後

DSL 不是什麼新技術,比較像一種取捨:多花一點力氣做一個引擎,換規則以後可以自己走。

值不值得,看規則變動的頻率。如果一年才改一次,寫死在程式裡也還好;如果一季改一次、每次改都要走完發版流程,那這個引擎大概第二次就回本了。