Hành vi Nâng cao 7 phút đọc

Interpreter Pattern trong C#: Khi Nào và Cách Dựng DSL

Interpreter pattern trong C# / .NET 10: dựng DSL nhỏ cho rule discount, khi Expression tree thắng interpreter tay, và khi không cần cả hai.

Mục lục
  1. Interpreter pattern giải quyết bài toán gì trong C#?
  2. Interpreter giáo khoa trông thế nào?
  3. Expression tree cho bạn pattern này miễn phí thế nào?
  4. Dựng parser DSL nhỏ trong .NET 10 thế nào?
  5. Khi nào Interpreter pattern bắn trật?
  6. So sánh Interpreter với Strategy và Visitor thế nào?
  7. Một ví dụ thật trong .NET 10 trông thế nào?
  8. Đọc tiếp gì trong series?

Team marketing muốn tự viết rule discount mà không nộp ticket. "20% off nếu cart chứa BOOK và total trên 50". "Free ship cho order nặng dưới 2 kg ở US". Quý sau họ chờ hai mươi rule, năm sau một trăm. Hard-code mỗi rule trong C# nghĩa là mỗi rule cần deploy.

Interpreter pattern là một câu trả lời. Định nghĩa một grammar nhỏ cho ngôn ngữ rule, parse chuỗi rule thành abstract syntax tree, và mỗi node implement Evaluate(context). Interpreter là pattern GoF hiếm gặp nhất trong app .NET thật, vì Expression tree, Roslyn scripting, và rule engine có sẵn che hầu hết case. Nhưng hiểu nó cho bạn biết các tool đó làm gì dưới hood — và khi nào thật sự với nó.

Interpreter pattern giải quyết bài toán gì trong C#?

Pattern xứng chỗ khi non-developer phải tự viết hành vi lúc runtime, trong ngôn ngữ nhỏ hơn C#. Ba hình dạng cụ thể:

  1. DSL business rule. Rule discount, validate, eligibility. Edit bởi analyst, deploy không cần build.
  2. Query language. Search box nhận tag:book price<50 author:rowling. Mỗi token thành AST node; evaluator filter data store.
  3. Config có logic. Một file YAML nói enable: when env=prod and rollout>50%. Tránh anti-pattern "config thực ra là code" bằng cách cho config grammar đã khai báo, có giới hạn.

Cái không phải bài toán Interpreter: "Tôi muốn dispatch một trong vài method lúc runtime" — đó là Strategy hoặc Command. "Tôi muốn user viết C#" — dùng Roslyn scripting; pattern quá đà.

Interpreter giáo khoa trông thế nào?

Một class mỗi cấu trúc grammar, mỗi cái có Evaluate(context) trả giá trị:

public sealed record CartContext(string[] Skus, decimal Total, double WeightKg, string Country);

public interface IRule { bool Eval(CartContext ctx); }

public sealed class ContainsSku(string Sku) : IRule
{
    public bool Eval(CartContext ctx) => ctx.Skus.Contains(Sku);
}

public sealed class TotalGreaterThan(decimal Threshold) : IRule
{
    public bool Eval(CartContext ctx) => ctx.Total > Threshold;
}

public sealed class And(IRule Left, IRule Right) : IRule
{
    public bool Eval(CartContext ctx) => Left.Eval(ctx) && Right.Eval(ctx);
}

public sealed class Or(IRule Left, IRule Right) : IRule
{
    public bool Eval(CartContext ctx) => Left.Eval(ctx) || Right.Eval(ctx);
}

Rule user gõ "cart contains BOOK and total over 50" parse thành And mà left là ContainsSku("BOOK") và right là TotalGreaterThan(50m). Gọi .Eval(ctx) đi cây.

Bức tranh cấu trúc (cây, không phải hierarchy class):

flowchart TB
    And[And]
    And --> Contains[ContainsSku 'BOOK']
    And --> Total[TotalGreaterThan 50]

Thêm operator mới (Not, WeightUnder) bằng cách viết một class mới. Interpreter không biết về rule cụ thể; rule không biết về nhau.

Expression tree cho bạn pattern này miễn phí thế nào?

System.Linq.Expressions là implementation Interpreter của BCL. Bạn dựng AST bằng cách compose object expression typed, rồi hoặc đi (LINQ-to-Entities dịch sang SQL) hoặc compile thành delegate:

using System.Linq.Expressions;

ParameterExpression ctx = Expression.Parameter(typeof(CartContext), "c");

Expression contains = Expression.Call(
    Expression.Property(ctx, nameof(CartContext.Skus)),
    typeof(Enumerable).GetMethod(nameof(Enumerable.Contains), new[] { typeof(string[]), typeof(string) })!,
    Expression.Constant("BOOK"));

Expression total = Expression.GreaterThan(
    Expression.Property(ctx, nameof(CartContext.Total)),
    Expression.Constant(50m));

Expression body = Expression.AndAlso(contains, total);

var lambda = Expression.Lambda<Func<CartContext, bool>>(body, ctx);
Func<CartContext, bool> rule = lambda.Compile();

rule(myCart) trả true hay false. EF Core lấy cây tương tự và biến thành SQL WHERE. Interpreter pattern giấu trong class BCL; bạn không bao giờ tự viết class And : IRule.

Cho hầu hết logic rule nội bộ app, đây là câu trả lời đúng. Interpreter tay xứng đáng khi user viết rule — và viết dạng chuỗi mà app phải parse.

Dựng parser DSL nhỏ trong .NET 10 thế nào?

Parser tối thiểu khả thi cho ngôn ngữ rule trên:

public static class RuleParser
{
    public static IRule Parse(string source)
    {
        var tokens = source.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return ParseAnd(tokens, 0, out _);
    }

    private static IRule ParseAnd(string[] tokens, int i, out int next)
    {
        var left = ParseAtom(tokens, i, out i);
        while (i < tokens.Length && tokens[i].Equals("and", StringComparison.OrdinalIgnoreCase))
        {
            var right = ParseAtom(tokens, i + 1, out i);
            left = new And(left, right);
        }
        next = i;
        return left;
    }

    private static IRule ParseAtom(string[] tokens, int i, out int next)
    {
        if (tokens[i].Equals("contains", StringComparison.OrdinalIgnoreCase))
        {
            next = i + 2;
            return new ContainsSku(tokens[i + 1]);
        }
        if (tokens[i].Equals("total>", StringComparison.OrdinalIgnoreCase))
        {
            next = i + 2;
            return new TotalGreaterThan(decimal.Parse(tokens[i + 1]));
        }
        throw new InvalidOperationException($"Unknown token: {tokens[i]}");
    }
}

// Sử dụng
IRule rule = RuleParser.Parse("contains BOOK and total> 50");
bool applies = rule.Eval(new CartContext(new[] {"BOOK", "MUG"}, 75m, 1.0, "US"));

Đây là cả Interpreter pattern nén trong 30 dòng: grammar, parser, AST, walker. Cho rule engine thật bạn thay parser recursive-descent bằng generated (Sprache, Pidgin, Superpower) và thêm operator precedence — nhưng hình dạng vẫn vậy.

Khi nào Interpreter pattern bắn trật?

Ba bẫy:

So sánh Interpreter với Strategy và Visitor thế nào?

Pattern Abstraction là gì? Khi áp dụng? .NET hiện đại
Interpreter Mỗi node AST có Eval Rule / query author lúc runtime Expression<Func<>>
Strategy Một thuật toán cắm được Chọn lúc runtime implementation cố định nào gọi Func<> inject qua DI
Visitor Thêm thao tác cho AST cố định Thao tác mới trên node có sẵn Pattern match switch

Tách rõ: Interpreter là parse và evaluate text; Visitor là thêm thao tác cho cây có sẵn; Strategy là đổi một thuật toán. Visitor và Interpreter thường đi cặp: build AST với Interpreter, đi nó với Visitor.

Một ví dụ thật trong .NET 10 trông thế nào?

Cho 90% case, ví dụ .NET 10 thật dùng Expression tree thay vì interpreter tay. Đây là kịch bản discount-rule giải bằng cách đó:

public sealed record CartContext(string[] Skus, decimal Total, double WeightKg, string Country);

public sealed class CompiledRule
{
    public CompiledRule(string source, Expression<Func<CartContext, bool>> expr)
    {
        Source   = source;
        Compiled = expr.Compile();
    }
    public string Source { get; }
    public Func<CartContext, bool> Compiled { get; }
    public bool Applies(CartContext ctx) => Compiled(ctx);
}

public sealed class DiscountRuleEngine
{
    private readonly List<CompiledRule> _rules = new();

    public void Add(string source, Expression<Func<CartContext, bool>> expr)
        => _rules.Add(new CompiledRule(source, expr));

    public IEnumerable<string> Matching(CartContext ctx)
        => _rules.Where(r => r.Applies(ctx)).Select(r => r.Source);
}

// Caller (rule diễn tả bằng C# lúc startup)
var engine = new DiscountRuleEngine();
engine.Add("books trên 50",
    c => c.Skus.Contains("BOOK") && c.Total > 50m);
engine.Add("order nhẹ tới US",
    c => c.WeightKg < 2.0 && c.Country == "US");

var ctx = new CartContext(new[] { "BOOK", "MUG" }, 75m, 1.0, "US");
foreach (var match in engine.Matching(ctx))
    Console.WriteLine($"matched: {match}");

Nếu marketing phải tự viết rule trong format chuỗi riêng, đặt RuleParser từ mục trước lên trên để dịch text thành AST IRule hoặc Expression<Func<>>. Evaluation dùng interpreter của BCL; chỉ có parser là của bạn.

Đọc tiếp gì trong series?

Một kết thúc thực dụng: đa số dev C# sẽ không bao giờ viết Interpreter tay, và điều đó ổn. Cái quan trọng là nhận diện pattern khi thấy nó trong EF Core, trong Expression<>, trong rule engine yêu thích — và nhận diện khi không nên tự dựng. Framework thường thắng.

Câu hỏi thường gặp

Expression tree trong C# có phải Interpreter pattern không?
Đúng. Expression<Func<TIn, TOut>> là cây các node (BinaryExpression, MethodCallExpression, ConstantExpression) trong đó mỗi node biết cách evaluate hoặc compile. EF Core đi cây để dịch sang SQL — đó là interpretation. .NET BCL cho bạn pattern; bạn gần như không cần viết AST class riêng.
Khi nào nên dựng DSL nhỏ thay vì dùng Expression tree?
Khi user của rule không phải C# programmer. Marketing manager muốn viết if cart_contains BOOK and total > 50 then 20% off, không phải cart => cart.Items.Any(i => i.Sku == "BOOK") && cart.Total > 50. DSL custom nhỏ với parser và Interpreter class an toàn hơn việc cho người không-engineer viết Roslyn script.
Roslyn scripting có phải Interpreter pattern không?
Roslyn scripting compile C# lúc runtime — nó là compiler có interpreter trên. Interpreter pattern cổ điển thấp hơn một bậc: bạn định nghĩa AST riêng và đi cây. Dùng Roslyn khi ngôn ngữ là tập con C# và user viết được C#; dùng pattern khi cần ngôn ngữ nhỏ hơn, sandbox.
Khi nào dựng DSL gì cũng quá đà?
Khi bạn có một đến ba rule và chúng đổi mỗi quý một lần. switch expression cộng deploy đơn giản hơn parser, AST, interpreter, UI config, và lớp lưu rule. DSL xứng đáng tầm khi list rule vượt 30 entry hoặc stakeholder cần đổi rule mà không deploy.