Skip to main content

進階用法與公司場景


條件驗證(When / Unless)

只在特定條件下才執行某些規則。

public class ProcessParameterValidator : AbstractValidator<ProcessParameter>
{
public ProcessParameterValidator()
{
RuleFor(x => x.CoolingRate)
.GreaterThan(0)
.WithMessage("冷卻速率必須大於 0")
.When(x => x.Mode == ProcessMode.Cooling);
// 只有冷卻模式才驗證冷卻速率

RuleFor(x => x.HeatingRate)
.GreaterThan(0)
.InclusiveBetween(1, 50)
.WithMessage("加熱速率必須在 1-50°C/min 之間")
.When(x => x.Mode == ProcessMode.Heating);

// Unless = When 的反面
RuleFor(x => x.HoldDuration)
.GreaterThan(0)
.Unless(x => x.Mode == ProcessMode.Manual);
// 手動模式不需要設定保持時間
}
}

集合驗證(RuleForEach)

驗證集合中的每個項目。

public class RecipeValidator : AbstractValidator<Recipe>
{
public RecipeValidator()
{
RuleFor(x => x.Name)
.NotEmpty().MaximumLength(50);

// 至少要有一個步驟
RuleFor(x => x.Steps)
.NotEmpty().WithMessage("Recipe 至少需要一個步驟");

// 每個步驟都要驗證
RuleForEach(x => x.Steps).SetValidator(new RecipeStepValidator());
}
}

public class RecipeStepValidator : AbstractValidator<RecipeStep>
{
public RecipeStepValidator()
{
RuleFor(x => x.StepName)
.NotEmpty().WithMessage("步驟名稱不可為空");

RuleFor(x => x.TargetTemperature)
.InclusiveBetween(0, 500)
.WithMessage("步驟溫度必須在 0-500°C 之間");

RuleFor(x => x.Duration)
.GreaterThan(TimeSpan.Zero)
.WithMessage("步驟時間必須大於 0");
}
}

跨欄位驗證

public class TemperatureRangeValidator : AbstractValidator<TemperatureRange>
{
public TemperatureRangeValidator()
{
RuleFor(x => x.MinTemperature)
.LessThan(x => x.MaxTemperature)
.WithMessage("最低溫度必須小於最高溫度");

RuleFor(x => x.EndTemperature)
.GreaterThanOrEqualTo(x => x.StartTemperature)
.When(x => x.Mode == ProcessMode.Heating)
.WithMessage("加熱模式下,結束溫度必須 ≥ 起始溫度");

RuleFor(x => x.EndTemperature)
.LessThanOrEqualTo(x => x.StartTemperature)
.When(x => x.Mode == ProcessMode.Cooling)
.WithMessage("冷卻模式下,結束溫度必須 ≤ 起始溫度");
}
}

自訂驗證器(Must / MustAsync)

簡單自訂邏輯

RuleFor(x => x.DeviceName)
.Must(name => !name.Contains("TEST"))
.WithMessage("正式環境不可使用測試名稱");

非同步自訂驗證(呼叫外部服務)

public class DeviceConfigValidator : AbstractValidator<DeviceConfig>
{
private readonly IDeviceRepository _repository;

public DeviceConfigValidator(IDeviceRepository repository)
{
_repository = repository;

RuleFor(x => x.DeviceName)
.NotEmpty()
.MustAsync(BeUniqueNameAsync)
.WithMessage("設備名稱 '{PropertyValue}' 已存在");

RuleFor(x => x.IpAddress)
.MustAsync(BeReachableAsync)
.WithMessage("IP 位址 {PropertyValue} 無法連線")
.When(x => !string.IsNullOrEmpty(x.IpAddress));
}

private async Task<bool> BeUniqueNameAsync(
string name, CancellationToken ct)
{
return !await _repository.ExistsAsync(name, ct);
}

private async Task<bool> BeReachableAsync(
string ip, CancellationToken ct)
{
try
{
using var ping = new System.Net.NetworkInformation.Ping();
var reply = await ping.SendPingAsync(ip, 2000);
return reply.Status == System.Net.NetworkInformation.IPStatus.Success;
}
catch { return false; }
}
}

驗證嚴重度(Severity)

區分 Error(必須修正)和 Warning(建議修正)。

public class SensorCalibrationValidator : AbstractValidator<CalibrationData>
{
public SensorCalibrationValidator()
{
// Error:必須修正
RuleFor(x => x.Offset)
.InclusiveBetween(-100, 100)
.WithMessage("偏移量超出允許範圍")
.WithSeverity(Severity.Error);

// Warning:超出建議值但未超限制
RuleFor(x => x.Offset)
.InclusiveBetween(-10, 10)
.WithMessage("偏移量超出建議範圍(±10),請確認")
.WithSeverity(Severity.Warning);
}
}

使用時分開處理:

var result = validator.Validate(data);

var errors = result.Errors
.Where(e => e.Severity == Severity.Error).ToList();
var warnings = result.Errors
.Where(e => e.Severity == Severity.Warning).ToList();

if (errors.Any())
ShowErrorDialog(errors);
else if (warnings.Any())
ShowWarningDialog(warnings); // 讓使用者選擇是否繼續
warning

即使只有 Severity.Warning 的失敗,result.IsValid 仍然會回傳 false。你必須自行依 Severity 過濾判斷是否要阻擋操作。


DI 整合

註冊 Validator

// 自動掃描並註冊所有 Validator
services.AddValidatorsFromAssemblyContaining<RecipeValidator>();

// 或手動註冊
services.AddScoped<IValidator<Recipe>, RecipeValidator>();

在 Service 中注入使用

public class RecipeService
{
private readonly IValidator<Recipe> _validator;

public RecipeService(IValidator<Recipe> validator)
{
_validator = validator;
}

public async Task SaveRecipeAsync(Recipe recipe)
{
var result = await _validator.ValidateAsync(recipe);
if (!result.IsValid)
throw new ValidationException(result.Errors);

await _repository.SaveAsync(recipe);
}
}

與 WPF 整合:INotifyDataErrorInfo

WPF 的 {Binding} 支援 INotifyDataErrorInfo 來顯示驗證錯誤。

搭配 CommunityToolkit.Mvvm

public partial class RecipeEditorViewModel : ObservableValidator
{
private readonly RecipeValidator _validator = new();

[ObservableProperty]
private string _recipeName = "";

partial void OnRecipeNameChanged(string value)
{
ValidateProperty(value);
}

private void ValidateProperty(object value,
[CallerMemberName] string propertyName = "")
{
ClearErrors(propertyName);
var result = _validator.Validate(
new Recipe { Name = RecipeName /* ... */ });
foreach (var error in result.Errors
.Where(e => e.PropertyName == propertyName))
{
SetErrors(propertyName, new[] { error.ErrorMessage });
}
}
}

XAML 顯示錯誤

<TextBox Text="{Binding RecipeName, UpdateSourceTrigger=PropertyChanged,
ValidatesOnNotifyDataErrors=True}" />
<!-- 預設會在 TextBox 周圍顯示紅色框線 -->

與 ReactiveUI 整合:響應式驗證

搭配 ReactiveUI 可以做到即時響應式驗證:

public class RecipeEditorViewModel : ReactiveObject
{
[Reactive] public string RecipeName { get; set; } = "";
[Reactive] public double Temperature { get; set; }

[ObservableAsProperty] public string NameError { get; }
[ObservableAsProperty] public string TemperatureError { get; }
[ObservableAsProperty] public bool IsValid { get; }

public RecipeEditorViewModel()
{
var validator = new RecipeValidator();

// 任何屬性變化都觸發驗證
var validationResult = this.WhenAnyValue(
x => x.RecipeName,
x => x.Temperature)
.Throttle(TimeSpan.FromMilliseconds(200))
.Select(_ => validator.Validate(
new Recipe { Name = RecipeName, Temperature = Temperature }));

// 將錯誤訊息綁定到 UI
validationResult
.Select(r => r.Errors
.FirstOrDefault(e => e.PropertyName == nameof(RecipeName))
?.ErrorMessage ?? "")
.ToPropertyEx(this, x => x.NameError);

validationResult
.Select(r => r.Errors
.FirstOrDefault(e => e.PropertyName == nameof(Temperature))
?.ErrorMessage ?? "")
.ToPropertyEx(this, x => x.TemperatureError);

validationResult
.Select(r => r.IsValid)
.ToPropertyEx(this, x => x.IsValid);
}
}

測試

FluentValidation 提供便利的測試擴充方法。

dotnet add package FluentValidation  # 測試方法已含在核心套件

基本測試

[TestClass]
public class RecipeValidatorTests
{
private readonly RecipeValidator _validator = new();

[TestMethod]
public void Name_WhenEmpty_ShouldHaveError()
{
var recipe = new Recipe { Name = "", Temperature = 100 };
var result = _validator.TestValidate(recipe);
result.ShouldHaveValidationErrorFor(x => x.Name)
.WithErrorMessage("名稱不可為空");
}

[TestMethod]
public void Name_WhenValid_ShouldNotHaveError()
{
var recipe = new Recipe { Name = "測試 Recipe", Temperature = 100 };
var result = _validator.TestValidate(recipe);
result.ShouldNotHaveValidationErrorFor(x => x.Name);
}

[TestMethod]
public void Temperature_WhenOutOfRange_ShouldHaveError()
{
var recipe = new Recipe { Name = "Test", Temperature = 600 };
var result = _validator.TestValidate(recipe);
result.ShouldHaveValidationErrorFor(x => x.Temperature);
}

[TestMethod]
public void Temperature_WhenInRange_ShouldNotHaveError()
{
var recipe = new Recipe { Name = "Test", Temperature = 200 };
var result = _validator.TestValidate(recipe);
result.ShouldNotHaveValidationErrorFor(x => x.Temperature);
}

[TestMethod]
public void Steps_WhenEmpty_ShouldHaveError()
{
var recipe = new Recipe
{
Name = "Test",
Temperature = 100,
Steps = new List<RecipeStep>()
};
var result = _validator.TestValidate(recipe);
result.ShouldHaveValidationErrorFor(x => x.Steps)
.WithErrorMessage("Recipe 至少需要一個步驟");
}
}

測試條件驗證

[TestMethod]
public void CoolingRate_WhenCoolingMode_ShouldValidate()
{
var param = new ProcessParameter
{
Mode = ProcessMode.Cooling,
CoolingRate = 0 // 無效
};
var result = _validator.TestValidate(param);
result.ShouldHaveValidationErrorFor(x => x.CoolingRate);
}

[TestMethod]
public void CoolingRate_WhenHeatingMode_ShouldSkipValidation()
{
var param = new ProcessParameter
{
Mode = ProcessMode.Heating,
CoolingRate = 0 // 加熱模式下不驗證此欄位
};
var result = _validator.TestValidate(param);
result.ShouldNotHaveValidationErrorFor(x => x.CoolingRate);
}

延伸閱讀