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
- Interpreter pattern giải quyết bài toán gì trong C#?
- Interpreter giáo khoa trông thế nào?
- Expression tree cho bạn pattern này miễn phí thế nào?
- Dựng parser DSL nhỏ trong .NET 10 thế nào?
- Khi nào Interpreter pattern bắn trật?
- So sánh Interpreter với Strategy và Visitor thế nào?
- Một ví dụ thật trong .NET 10 trông thế nào?
- Đọ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ể:
- DSL business rule. Rule discount, validate, eligibility. Edit bởi analyst, deploy không cần build.
- Query language. Search box nhận
tag:book price<50 author:rowling. Mỗi token thành AST node; evaluator filter data store. - 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:
- DSL sớm. Ba rule cộng
switchđơn giản hơn parser. Dựng DSL khi số rule vượt ngưỡng mà chi phí deploy mỗi rule vượt chi phí viết parser. Ngưỡng đó thường cao hơn engineer nghĩ. - Input runtime không an toàn. Chuỗi rule từ admin UI là user
input. Nếu interpreter gọi
Eval()trên reflection tuỳ ý, bạn vừa expose sandbox escape. Định nghĩa grammar đóng; reject mọi thứ ngoài. - Phát minh lại Expression tree. Nếu AST của bạn là "binary
op, comparison, member access, constant", BCL đã làm. Dùng
Expression<>vàCompile(); đừng viếtclass Andtừ đầu.
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?
- Bài trước: Command — đóng gói một hành động thành record + handler.
- Bài kế: Iterator — traversal đồng
nhất qua
IEnumerable<T>vàyield return. - Tham chiếu chéo: Visitor — thêm thao tác cho AST cố định.
- Tham chiếu chéo: Strategy — khi lựa chọn là "thuật toán nào" thay vì "biểu thức này nghĩa gì".
- Cây quyết định: Cách chọn design pattern phù hợp.
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?
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?
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?
Khi nào dựng DSL gì cũng quá đà?
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.