實體驗證是業務邏輯核心。從最初的 if-else 堆疊,到利用 C# 3.0 擴充方法建構 Fluent 鏈式 API,再到引入驗證規則物件與錯誤收集器實現聲明式、可組合、可測試的驗證框架。本文記錄完整演進歷程與程式碼。
public class User
{
public string Username { get; set; }
public string Email { get; set; }
public int Age { get; set; }
public IEnumerable Validate()
{
var errors = new List();
if (string.IsNullOrWhiteSpace(Username)) errors.Add("用戶名不可為空");
else if (Username.Length < 3) errors.Add("用戶名至少 3 字元");
if (string.IsNullOrWhiteSpace(Email)) errors.Add("Email 不可為空");
else if (!Email.Contains("@")) errors.Add("Email 格式錯誤");
if (Age < 18) errors.Add("年齡需滿 18 歲");
return errors;
}
}
缺點:驗證邏輯與實體耦合、難以複用、難以單元測試個別規則、新增規則需修改實體類別 (違反 OCP)。
public static class ValidationExtensions
{
public static ValidationContext Validate(this T entity) => new ValidationContext(entity);
}
public class ValidationContext
{
private readonly T _entity;
private readonly List _errors = new();
public ValidationContext(T entity) { _entity = entity; }
public ValidationContext Check(Func predicate, string errorMessage)
{
if (!_errors.Any(e => e == errorMessage) && !predicate(_entity))
_errors.Add(errorMessage);
return this;
}
public IEnumerable GetErrors() => _errors;
}
// 使用
var errors = user.Validate()
.Check(u => !string.IsNullOrWhiteSpace(u.Username), "用戶名不可為空")
.Check(u => u.Username.Length >= 3, "用戶名至少 3 字元")
.Check(u => u.Email.Contains("@"), "Email 格式錯誤")
.GetErrors();
缺陷:第一個失敗即停止? 否,上述會全跑。但無法取消後續驗證、規則不可複用 (寫死在 Lambda)、錯誤訊息硬編碼、無法針對屬性分組錯誤 (前端欄位標紅需要知道哪個屬性錯)。
public interface IValidationRule
{
string PropertyName { get; }
string ErrorMessage { get; }
bool Validate(T entity);
}
public class ValidationResult
{
public bool IsValid => !Errors.Any();
public List Errors { get; } = new();
public void AddError(string propertyName, string message) => Errors.Add(new ValidationError(propertyName, message));
}
public record ValidationError(string PropertyName, string Message);
public class RequiredRule : IValidationRule
{
public string PropertyName { get; }
public string ErrorMessage { get; }
private readonly Func _selector;
public RequiredRule(string propertyName, Func selector, string message = null)
{
PropertyName = propertyName;
_selector = selector;
ErrorMessage = message ?? $"{propertyName} 不可為空";
}
public bool Validate(T entity)
{
var value = _selector(entity);
if (value == null) return false;
if (value is string s && string.IsNullOrWhiteSpace(s)) return false;
if (value is IEnumerable
public class UserValidator
{
private readonly List> _rules = new()
{
new RequiredRule("Username", u => u.Username),
new StringLengthRule("Username", u => u.Username, 3, 50),
new RequiredRule("Email", u => u.Email),
new RegexRule("Email", u => u.Email, @"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"),
new RangeRule("Age", u => u.Age, 18, 120)
};
public ValidationResult Validate(User user)
{
var result = new ValidationResult();
foreach (var rule in _rules)
{
if (!rule.Validate(user))
result.AddError(rule.PropertyName, rule.ErrorMessage);
}
return result;
}
}
var validator = new UserValidator();
var result = validator.Validate(user);
if (!result.IsValid)
{
// 綁定到前端 ModelState
foreach (var err in result.Errors)
ModelState.AddModelError(err.PropertyName, err.Message);
// 或序列化回傳 API
return BadRequest(new { errors = result.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.Message).ToArray()) });
}
public class ValidatorBuilder
{
private readonly List> _rules = new();
public ValidatorBuilder RuleFor(Expression> expr)
{
string propName = ((MemberExpression)expr.Body).Member.Name;
return new RuleBuilder(this, propName, expr.Compile());
}
public IValidator Build() => new Validator(_rules);
}
public class RuleBuilder
{
private readonly ValidatorBuilder _parent;
private readonly string _propName;
private readonly Func _selector;
public RuleBuilder(ValidatorBuilder parent, string propName, Func selector)
{ _parent = parent; _propName = propName; _selector = selector; }
public RuleBuilder Required(string msg = null)
{ _parent._rules.Add(new RequiredRule(_propName, _selector, msg)); return this; }
public RuleBuilder Length(int min, int max, string msg = null)
{ _parent._rules.Add(new StringLengthRule(_propName, _selector, min, max, msg)); return this; }
// ... Match, Range, Custom ...
}
// 使用:極度優雅
var validator = new ValidatorBuilder()
.RuleFor(u => u.Username).Required().Length(3, 50)
.RuleFor(u => u.Email).Required().Match(EmailRegex)
.RuleFor(u => u.Age).Range(18, 120)
.Build();
| 階段 | 核心改進 | 剩餘問題 |
|---|---|---|
| If-Else | 直觀 | 耦合、難測試、違反 OCP |
| Fluent Extension | 消除 If、鏈式可讀 | 規則不可複用、錯誤無屬性關聯、無短路控制 |
| Rule Object + Collector | 關注點分離、可複用、可測試、屬性級錯誤、開放擴充 | 樣板代碼較多 (可用 Builder/Source Generator 解決) |