DEV Community

Δυναμική Εφαρμογή Επιχειρησιακών Κανόνων σε C# με JSON και Func

Περιγραφή:

Σε αυτό το άρθρο παρουσιάζουμε πώς να υλοποιήσετε μια καθαρή και ευέλικτη αρχιτεκτονική για την εκτέλεση επιχειρησιακών κανόνων σε μια εφαρμογή C#. Οι κανόνες φορτώνονται δυναμικά από ένα αρχείο JSON και αξιολογούνται χρησιμοποιώντας το Func, αποφεύγοντας έτσι μεγάλα μπλοκ if/else. Η μέθοδος αυτή επιτρέπει εύκολη συντήρηση, προσθήκη εκατοντάδων κανόνων και γρήγορη προσαρμογή της λογικής χωρίς αλλαγές στον κώδικα.

H υλοποίηση του Rule Engine ακολουθεί τις SOLID principles


1 Δημιουργία των Interfaces

public interface IRuleLoader
{
    List<RuleDefinition> LoadRules(string path);
}

public interface IRuleEngine
{
    decimal CalculateDiscount(Order order, List<RuleDefinition> rules);
}
Enter fullscreen mode Exit fullscreen mode

Αυτό ικανοποιεί ISP και DIP: η υψηλού επιπέδου λογική δεν εξαρτάται από συγκεκριμένες υλοποιήσεις.


2 Models

public class Customer
{
    public bool IsVIP { get; set; }
}

public class Order
{
    public decimal Total { get; set; }
    public Customer Customer { get; set; }
}

public class RuleDefinition
{
    public string Name { get; set; } = string.Empty;
    public string Condition { get; set; } = string.Empty; // π.χ. "Total > 1000 && Customer.IsVIP"
    public decimal Discount { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

3 Loader με JSON

using System.Text.Json;

public class JsonRuleLoader : IRuleLoader
{
    public List<RuleDefinition> LoadRules(string path)
    {
        if (!File.Exists(path))
        {
            Console.WriteLine($"Το αρχείο κανόνων δεν βρέθηκε: {path}");
            return new List<RuleDefinition>();
        }

        var json = File.ReadAllText(path);
        if (string.IsNullOrWhiteSpace(json))
        {
            Console.WriteLine($"Το αρχείο κανόνων είναι κενό: {path}");
            return new List<RuleDefinition>();
        }

        var options = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        };

        var rules = JsonSerializer.Deserialize<List<RuleDefinition>>(json, options);
        return rules ?? new List<RuleDefinition>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Τηρεί SRP: μόνο για φόρτωση κανόνων.


4 Rule Engine με Dynamic Expresso

Install-Package DynamicExpresso.Core

using DynamicExpresso;

public class DynamicRuleEngine : IRuleEngine
{
    public decimal CalculateDiscount(Order order, List<RuleDefinition> rules)
    {
        var interpreter = new Interpreter();
        interpreter.SetVariable("Total", order.Total);
        interpreter.SetVariable("Customer", order.Customer);

        foreach (var rule in rules)
        {
            try
            {
                // Εκτέλεση συνθήκης από JSON δυναμικά
                bool isMatch = interpreter.Eval<bool>(rule.Condition);
                if (isMatch)
                {
                    Console.WriteLine($"Ταιριάξε ο κανόνας: {rule.Name}");
                    return order.Total * rule.Discount;
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Σφάλμα στην εκτέλεση κανόνα '{rule.Name}': {ex.Message}");
            }
        }

        return 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Τηρεί OCP: νέες συνθήκες προστίθενται στο JSON χωρίς αλλαγή κώδικα.
✅ Τηρεί DIP: το Program δουλεύει με IRuleEngine.
✅ Τηρεί SRP: η κλάση ασχολείται μόνο με εκτέλεση κανόνων.

🔹 Τι είναι η DynamicExpresso

DynamicExpresso

🔹 Τι κάνει ο συγκεκριμένος κώδικας βήμα–βήμα

Ας εξηγήσουμε τη μέθοδο CalculateDiscount:

public decimal CalculateDiscount(Order order, List<RuleDefinition> rules)
{
    var interpreter = new Interpreter();
Enter fullscreen mode Exit fullscreen mode

👉 Δημιουργεί έναν Interpreter της DynamicExpresso,
δηλαδή έναν “μηχανισμό που μπορεί να καταλαβαίνει και να εκτελεί C# εκφράσεις”.

    interpreter.SetVariable("Total", order.Total);
    interpreter.SetVariable("Customer", order.Customer);
Enter fullscreen mode Exit fullscreen mode

👉 Εδώ δηλώνεις μεταβλητές που θα είναι διαθέσιμες μέσα στις εκφράσεις (τους κανόνες).

Δηλαδή, αν έχεις έναν κανόνα:

{ "Condition": "Total > 1000 && Customer.IsVIP" }
Enter fullscreen mode Exit fullscreen mode

τότε η βιβλιοθήκη “ξέρει” τι είναι το Total και τι είναι το Customer.

    foreach (var rule in rules)
    {
        bool isMatch = interpreter.Eval<bool>(rule.Condition);
Enter fullscreen mode Exit fullscreen mode

👉 Για κάθε κανόνα:

  • Παίρνει τη συμβολοσειρά (string) του rule.Condition
  • Την εκτελεί σαν να ήταν C# κώδικας
  • Επιστρέφει true ή false

Παράδειγμα:

"condition": "Total > 1000"

➡️ Η Eval θα επιστρέψει true αν order.Total > 1000, αλλιώς false.

        if (isMatch)
        {
            Console.WriteLine($"Ταιριάξε ο κανόνας: {rule.Name}");
            return order.Total * rule.Discount;
        }
Enter fullscreen mode Exit fullscreen mode

👉 Αν η συνθήκη ισχύει, εφαρμόζει την έκπτωση του κανόνα.

    catch (Exception ex)
    {
        Console.WriteLine($"Σφάλμα στην εκτέλεση κανόνα '{rule.Name}': {ex.Message}");
    }
Enter fullscreen mode Exit fullscreen mode

👉 Αν ο κανόνας έχει λάθος (π.χ. συντακτικό σφάλμα), δεν “σκάει” το πρόγραμμα·
απλώς γράφει μήνυμα λάθους στη κονσόλα.


5 Παράδειγμα JSON (rules.json)

[
  { "Name": "HighValueOrder", "Condition": "Total > 1000", "Discount": 0.10 },
  { "Name": "VIPCustomer", "Condition": "Customer.IsVIP == true", "Discount": 0.20 },
  { "Name": "SmallOrder", "Condition": "Total < 100", "Discount": 0.05 }
]
Enter fullscreen mode Exit fullscreen mode

6 Χρήση στην Main

class Program
{
    static void Main()
    {
        IRuleLoader loader = new JsonRuleLoader();
        IRuleEngine engine = new DynamicRuleEngine();

        var rules = loader.LoadRules("rules.json");
        if (rules.Count == 0)
        {
            Console.WriteLine("Δεν φορτώθηκαν κανόνες. Τερματισμός.");
            return;
        }

        var order = new Order
        {
            Total = 1200,
            Customer = new Customer { IsVIP = true }
        };

        var discount = engine.CalculateDiscount(order, rules);
        Console.WriteLine($"Έκπτωση που εφαρμόστηκε: {discount:C}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Output

✅ Ταιριάξε ο κανόνας: HighValueOrder
💰 Έκπτωση που εφαρμόστηκε: 120,00 €

🔹 Τι κερδίζουμε με αυτή τη SOLID έκδοση

  1. SRP: Κάθε κλάση έχει μια ευθύνη
  2. OCP: Νέες συνθήκες προστίθενται στο JSON χωρίς να αλλάζει ο κώδικας
  3. LSP: Μπορούμε να αντικαταστήσουμε τον IRuleEngine με άλλη υλοποίηση
  4. ISP: Τα interfaces είναι μικρά και συγκεκριμένα
  5. DIP: Το υψηλού επιπέδου module (Program) εξαρτάται από abstraction (IRuleEngine, IRuleLoader)

Παρακάτω θα δούμε και ένα βήμα παραπέρα με δυο παραδείγματα cashing:

🔹 Caching των κανόνων (ώστε να μην ξαναφορτώνονται από JSON κάθε φορά)
🔹 Caching των compiled expressions (ώστε να μην ξανα-ερμηνεύονται / ξαναμεταγλωττίζονται κάθε φορά που εκτελείς έναν κανόνα)

🧩 1️⃣ Caching των κανόνων (JSON)

Αυτό είναι απλό και γίνεται στον RuleLoader.
Αν οι κανόνες δεν αλλάζουν συχνά, μπορείς να τους διαβάζεις μία φορά και να τους κρατάς σε στατική μεταβλητή ή memory cache.

public class CachedRuleLoader : IRuleLoader
{
    private static List<RuleDefinition>? _cachedRules;

    public List<RuleDefinition> LoadRules(string path)
    {
        if (_cachedRules != null)
            return _cachedRules;

        if (!File.Exists(path))
        {
            Console.WriteLine($"Το αρχείο κανόνων δεν βρέθηκε: {path}");
            return new List<RuleDefinition>();
        }

        var json = File.ReadAllText(path);
        if (string.IsNullOrWhiteSpace(json))
        {
            Console.WriteLine($"Το αρχείο κανόνων είναι κενό: {path}");
            return new List<RuleDefinition>();
        }

        var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
        _cachedRules = JsonSerializer.Deserialize<List<RuleDefinition>>(json, options) ?? new List<RuleDefinition>();

        return _cachedRules;
    }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Πλεονέκτημα: Το αρχείο διαβάζεται μόνο την πρώτη φορά.
🔴 Μειονέκτημα: Αν αλλάξεις το rules.json, πρέπει να γίνει restart ή refresh.

🧩 2️⃣ Caching των συνθηκών (expressions)

Αυτό είναι το πιο σημαντικό.
Η Dynamic Expresso μεταγλωττίζει (compile) τα expressions κάθε φορά που τα εκτελείς — και αυτό έχει κόστος.

Αν έχεις 500 κανόνες, μπορούμε να κάνουμε caching του compiled delegate (Func) στη μνήμη.

Νέα έκδοση του DynamicRuleEngine με expression cache:

using DynamicExpresso;
using System.Collections.Concurrent;

public class CachedDynamicRuleEngine : IRuleEngine
{
    private readonly Interpreter _interpreter;
    private readonly ConcurrentDictionary<string, Lambda> _compiledCache;

    public CachedDynamicRuleEngine()
    {
        _interpreter = new Interpreter();
        _compiledCache = new ConcurrentDictionary<string, Lambda>();
    }

    public decimal CalculateDiscount(Order order, List<RuleDefinition> rules)
    {
        _interpreter.SetVariable("Total", order.Total);
        _interpreter.SetVariable("Customer", order.Customer);

        foreach (var rule in rules)
        {
            try
            {
                // Αν ο κανόνας υπάρχει ήδη στο cache, χρησιμοποίησέ τον
                var lambda = _compiledCache.GetOrAdd(rule.Condition, condition =>
                    _interpreter.Parse(condition, typeof(bool))
                );

                // Δημιουργία delegate που εκτελείται πάνω σε συγκεκριμένο αντικείμενο
                bool isMatch = (bool)lambda.Invoke();

                if (isMatch)
                {
                    Console.WriteLine($"Ταιριάξε ο κανόνας: {rule.Name}");
                    return order.Total * rule.Discount;
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Σφάλμα στην εκτέλεση κανόνα '{rule.Name}': {ex.Message}");
            }
        }

        return 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

Τι κάνει αυτό:

  • Η πρώτη φορά που τρέχει κάθε Condition → μεταγλωττίζεται και αποθηκεύεται στο _compiledCache.

  • Οι επόμενες φορές → χρησιμοποιούν τον ίδιο μεταγλωττισμένο delegate (καμία καθυστέρηση).

✅ Εξαιρετικά γρήγορο για πολλά Order αντικείμενα
✅ Τηρεί ακόμη OCP, DIP, SRP
✅ Λειτουργεί για εκατοντάδες κανόνες


💾 Πού γίνεται τελικά το caching;

Τύπος Cache Πού εφαρμόζεται Σκοπός
Rules cache Στον RuleLoader Να μη φορτώνονται ξανά οι κανόνες από JSON
Expression cache Στον RuleEngine Να μη μεταγλωττίζονται ξανά οι ίδιες συνθήκες

⚙️ Τελική Σύνοψη

Με αυτό το setup έχεις:

  • SOLID αρχιτεκτονική
  • Dynamic evaluation των κανόνων
  • Caching σε δύο επίπεδα
  • Υψηλή απόδοση για 500+ κανόνες και πολλά orders
  • Ευκολία επεκτασιμότητας χωρίς να αλλάξεις ούτε μια γραμμή κώδικα όταν προσθέτεις νέο rule

Top comments (0)