ข้าม​ไป​ยัง​เนื้อหา

Rules Engine

จัดการ​กฎ​ธุรกิจ​ซับซ้อน​ไว้​นอก code หลัก​ของ app

ระบบ​ธุรกิจ​จริง​มัก​มี​กฎ​จำนวน​มาก​ที่​เปลี่ยน​บ่อย​กว่า code ส่วน​อื่น เช่น เกณฑ์​ให้​ส่วนลด กฎ​อนุมัติ​สินเชื่อ กฎ​คำนวณ​ค่า​คอม​มิช​ชัน หรือ​กฎ​กรอง​อีเมล ถ้า​เขียน​กฎ​เหล่า​นี้​เป็น if/else หรือ switch ซ้อน​กัน​ใน code หลัก จะ​เกิด​ปัญหา​สอง​อย่าง​พร้อม​กัน — code ยิ่ง​เพิ่ม​กฎ​ก็​ยิ่ง​ซับซ้อน​จน​อ่าน​ไม่​ออก (ดู Conditional Complexity) และ​ทุก​ครั้ง​ที่​กฎ​ธุรกิจ​เปลี่ยน นัก​พัฒนา​ต้อง​แก้ แก้ compile แล้ว deploy ใหม่ ทั้ง​ที่​ตัว​ตรรกะ​การ​ไหล​ของ​โปรแกรม​ไม่​ได้​เปลี่ยน​เลย

Rules Engine แก้​ปัญหา​นี้​ด้วย​การ แยก​การ​นิยาม​กฎ (what) ออก​จาก​ตัว​ประเมิน (how) engine ทำ​หน้าที่​เดียว​คือ​รับ input มา​วน​ถาม​กฎ​แต่ละ​ข้อ​ว่า “ใช้​ไหม” แล้ว​รวม​ผลลัพธ์ ตัว​กฎ​แต่ละ​ข้อ​ถูก​ห่อ​หุ้ม​เป็น object อิสระ​ที่​เพิ่ม ลบ แก้ไข​ได้​โดย​ไม่​ต้อง​แตะ engine เลย — engine ไม่​จำเป็น​ต้อง​รู้จัก​กฎ​แต่ละ​ข้อ​เป็น​ราย​ตัว จึง​สอดคล้อง​กับ Open-Closed Principle โดยตรง: เปิด​ให้​ขยาย (เพิ่ม​กฎ​ใหม่) แต่​ปิด​การ​แก้ไข (engine เดิม​ไม่​ต้อง​เปลี่ยน)

แนวคิด​นี้​มี​ราก​มา​จาก Production Rule System ใน​สาย AI/expert system แบบ​ดั้งเดิม — ชุด​กฎ​รูปแบบ IF condition THEN action ที่ inference engine เลือก​ลำดับ​การ​ประเมิน​เอง (มัก​เป็น forward chaining) ไม่ใช่​ลำดับ​ที่​โปรแกรมเมอร์​เขียน​ไว้​ตายตัว เอนจินระดับ enterprise อย่าง Drools หรือ Rete algorithm ใช้​แนวคิด​นี้​เพื่อ​ให้​จับ​คู่​กฎ​กับ fact ได้​เร็ว​แม้​มี​กฎ​หลาย​พัน​ข้อ แต่​ใน​ระดับ application ทั่วไป มัก​พอ​แค่​ทำ Rules Engine แบบ​เรียบ​ง่าย — วน​กฎ​ที​ละ​ข้อ​แบบ imperative ธรรมดา ไม่​ต้อง​มี inference engine เต็ม​รูปแบบ

Rules Engine มี​สาม​องค์​ประกอบ​หลัก: RulesEngine (ตัว​ประสาน), Rule (สัญญา​ของ​กฎ​แต่ละ​ข้อ พร้อม concrete rule หลาย​ตัว), และ Input/Context (ข้อมูล​ที่​กฎ​ถูก​นำ​ไป​ใช้​ประเมิน)

classDiagram
    class Input {
      +data
    }
    class IRule {
      +Applies(Input) bool
      +Apply(Input) Result
    }
    class RulesEngine {
      -List~IRule~ rules
      +Execute(Input) Result
    }
    class DiscountRule {
      +Applies(Input) bool
      +Apply(Input) Result
    }
    class LoyaltyRule {
      +Applies(Input) bool
      +Apply(Input) Result
    }
    class ShippingRule {
      +Applies(Input) bool
      +Apply(Input) Result
    }
    RulesEngine o-- IRule
    RulesEngine ..> Input
    IRule <|.. DiscountRule
    IRule <|.. LoyaltyRule
    IRule <|.. ShippingRule

จุด​สำคัญ​ของ​โครงสร้าง​นี้​คือ RulesEngine ผูก​กับ IRule ผ่าน interface เดียว ไม่​ผูก​กับ DiscountRule, LoyaltyRule, ShippingRule โดยตรง — เพิ่ม​กฎ​ใหม่​คือ​เพิ่ม class ที่ implement IRule แล้ว​ลง​ทะเบียน​เข้า collection เท่านั้น (มัก​ผ่าน Dependency Injection)

ลำดับ​การ​ทำงาน​มาตรฐาน​คือ: caller ส่ง input เข้า engine → engine วน iterate กฎ​ทุก​ข้อ → ถาม​แต่ละ​กฎ​ว่า Applies กับ input นี้​หรือ​ไม่ → ถ้า​ใช่ ให้​กฎ​นั้น​ประมวล​ผล​และ​คืน​ค่า/ผล​ข้าง​เคียง → engine รวม​ผลลัพธ์​ทั้งหมด​แล้ว​ส่ง​กลับ

sequenceDiagram
    participant Client
    participant Engine as RulesEngine
    participant R1 as Rule A
    participant R2 as Rule B

    Client->>Engine: Execute(input)
    Engine->>R1: Applies(input)
    R1-->>Engine: true
    Engine->>R1: Apply(input)
    R1-->>Engine: result A
    Engine->>R2: Applies(input)
    R2-->>Engine: false
    Engine-->>Client: รวมผลลัพธ์ (result A)

สังเกต​ว่า​กฎ B ถูก​ถาม​แต่​ไม่​ถูก​เรียก​ใช้​เพราะ Applies คืน false — engine ไม่​จำเป็น​ต้อง​รู้​เหตุผล​ว่า​ทำไม​กฎ​แต่ละ​ข้อ​ใช้​หรือ​ไม่​ใช้ นี่​คือ​จุด​ที่​ทำให้​เพิ่ม/ลบ/สลับ​กฎ​ทำได้​อย่าง​อิสระ ข้อ​ควร​ระวัง​ตาม​ที่ Martin Fowler เตือน​ไว้​คือ rule chaining — ถ้า​กฎ​ข้อ​หนึ่ง​แก้ state แล้วไป​เปลี่ยน​เงื่อนไข​ของ​กฎ​ข้อ​อื่น​ที่​ยัง​ไม่​ถูก​ประเมิน จะ​เกิด “implicit program flow” ที่​ตาม​รอย​ยาก ทาง​แก้​ทั่วไป​คือ​จำกัด​ให้​กฎ​แต่ละ​ข้อเป็น​อิสระ​ต่อ​กัน (ไม่​แก้ input ระหว่าง​ทาง) หรือ​กำหนด​ลำดับ​ความ​สำคัญ (priority) ชัดเจน​ถ้า​จำเป็น​ต้อง​มี​ลำดับ

ตัวอย่าง​นี้​ขยาย​จาก​กฎ​ส่วนลด​ออเดอร์​ธรรมดา ให้​เห็น​ทั้ง interface ของ​กฎ, กฎ​รูปธรรม​หลาย​ข้อ, และ engine ที่​ประกอบ​กฎ​เข้า​ด้วย​กัน​ผ่าน DI:

// สัญญาของกฎหนึ่งข้อ: ตรวจว่าใช้ได้ไหม (Applies) และคำนวณผล (Discount)
public interface IDiscountRule
{
bool Applies(Order order);
decimal Discount(Order order);
string Reason { get; }
}
// กฎ: ออเดอร์เกิน 1,000 บาท ลด 5%
public class BulkOrderDiscountRule : IDiscountRule
{
public string Reason => "ส่วนลดยอดสั่งซื้อขั้นต่ำ";
public bool Applies(Order order) => order.Total >= 1000m;
public decimal Discount(Order order) => order.Total * 0.05m;
}
// กฎ: ลูกค้าระดับ VIP ลดเพิ่มคงที่ 100 บาท
public class VipCustomerDiscountRule : IDiscountRule
{
public string Reason => "ส่วนลดลูกค้า VIP";
public bool Applies(Order order) => order.Customer.Tier == CustomerTier.Vip;
public decimal Discount(Order order) => 100m;
}
// กฎ: ช่วงเทศกาล ลดเพิ่ม 10% แต่ไม่เกิน 300 บาท
public class SeasonalPromotionRule : IDiscountRule
{
private readonly IClock _clock;
public SeasonalPromotionRule(IClock clock) => _clock = clock;
public string Reason => "โปรโมชันเทศกาล";
public bool Applies(Order order) => _clock.Today is { Month: 12 };
public decimal Discount(Order order) => Math.Min(order.Total * 0.10m, 300m);
}
// ผลลัพธ์ที่รวมส่วนลดพร้อมเหตุผล เพื่อ audit ย้อนหลังได้
public record DiscountResult(decimal TotalDiscount, IReadOnlyList<string> AppliedReasons);
// RulesEngine ไม่รู้จักกฎแต่ละข้อ รู้แค่ว่ามันคือ IEnumerable<IDiscountRule>
public class DiscountRulesEngine
{
private readonly IEnumerable<IDiscountRule> _rules;
public DiscountRulesEngine(IEnumerable<IDiscountRule> rules) => _rules = rules;
public DiscountResult Evaluate(Order order)
{
var applicable = _rules.Where(r => r.Applies(order)).ToList();
var total = applicable.Sum(r => r.Discount(order));
var reasons = applicable.Select(r => r.Reason).ToList();
return new DiscountResult(total, reasons);
}
}
// การประกอบ (composition root) — เพิ่มกฎใหม่คือเพิ่มบรรทัดเดียวตรงนี้ ไม่แตะ engine
services.AddScoped<IDiscountRule, BulkOrderDiscountRule>();
services.AddScoped<IDiscountRule, VipCustomerDiscountRule>();
services.AddScoped<IDiscountRule, SeasonalPromotionRule>();
services.AddScoped<DiscountRulesEngine>();
  • มี​กฎ​ธุรกิจ​จำนวน​มาก​ที่​เปลี่ยน​บ่อย​กว่า code ส่วน​อื่น และ​แต่ละ​กฎอิสระ​ต่อ​กันได้​ชัดเจน
  • ต้องการ​เพิ่ม/ปิด​กฎ​โดย​ไม่​แตะ​หรือ deploy code หลัก​ซ้ำ (เช่น เปิด/ปิด​โปรโมชัน​ตาม​ฤดูกาล)
  • code conditional เดิม​เริ่ม​มี​กลิ่น Conditional Complexity หรือ Combinatorial Explosion จาก​การ​รวม​เงื่อนไข​หลาย​มิติ
  • ต้องการ audit trail ว่า​กฎ​ข้อ​ไหน​ถูก​ใช้​กับ​ข้อมูล​ไหน (เช่น เหตุผล​การ​อนุมัติ​สินเชื่อ)
  • ทีม​มี regression test ครอบ​กฎ​แต่ละ​ข้อ ทำให้​มั่นใจ​ได้​ว่า​เพิ่ม​กฎ​ใหม่​ไม่​กระทบ​กฎ​เดิม
  • มี​กฎ​เพียง​ไม่​กี่​ข้อ (2-3 ข้อ) และ​ไม่​ค่อย​เปลี่ยน — if/else ธรรมดา​อ่าน​ง่าย​กว่า​และ​ไม่​ต้อง​มี layer เพิ่ม (ดู YAGNI และ KISS)
  • กฎ​มี​ลำดับ​การ​ทำงาน​ที่​ต้อง​พึ่งพา​กัน​ชัดเจน (กฎ B ต้อง​รัน​หลัง​กฎ A เสมอ) — ควร​พิจารณา Chain of Responsibility หรือ pipeline แทน เพราะ Rules Engine สื่อ​ว่า​กฎ​เป็น​อิสระ​ต่อ​กัน
  • ต้องการ​นำ​เข้า​เครื่องมือ commercial rules engine เต็ม​รูปแบบ​ทั้ง​ที่​ปัญหา​จริง​แคบ​มาก — Fowler เตือน​ว่า​คำมั่น​สัญญา “ให้ business user เขียน​กฎ​เอง​ได้” มัก​ไม่​เป็น​จริง​ใน​ทาง​ปฏิบัติ
  • ทีม​ไม่มี​วินัย​เขียน test ครอบ​กฎ​แต่ละ​ข้อ เพราะ engine ที่​ประเมิน​กฎ​แบบ​ไดนามิก​จะ debug ยาก​กว่า code imperative ธรรมดา​เมื่อ​ไม่มี test คุ้มกัน
ด้านรายละเอียด
ข้อดีสนับสนุน OCP — เพิ่ม​กฎ​โดย​ไม่​แก้ engine
ข้อดีแต่ละ​กฎ test แยก​หน่วย​ได้ (TDD) และ​อ่าน​เจตนา​ชัดเจน​กว่า conditional ซ้อน​ลึก
ข้อดีลด​การ​เปลี่ยนแปลง​กระจาย (แก้​กฎ​เดียว ไม่​ต้อง​ไล่​แก้​หลาย​จุด) เทียบ​กับ Shotgun Surgery
ข้อ​เสียเพิ่ม indirection — ตาม​รอย​ว่า​กฎ​ไหน​ทำงาน​เมื่อไหร่​ยาก​กว่า​อ่าน if/else ตรง ๆ
ข้อ​เสียถ้า​กฎ chain กัน​หรือ​มี​ผล​ข้าง​เคียง​เปลี่ยน state จะ​เกิด implicit flow ที่ debug ยาก
ข้อ​เสียถ้า​ใช้ commercial rules engine เต็ม​รูปแบบ มี​ต้นทุน​เรียนรู้ ต้นทุน infrastructure และ​ความ​คาด​หวัง​ผิด ๆ ว่า business user จะ​เขียน​กฎ​เอง​ได้