关于 validation-patterns
面向 ASP.NET Core 开发者的验证模式指南,涵盖从属性标注到应用管线的多层验证方式,并说明各方案适用场景与常见陷阱。
主要功能
- 01比较多层验证职责
- 02介绍 DataAnnotations 与自定义属性
- 03涵盖 IValidatableObject 和 IValidateOptions
- 04说明 FluentValidation 与 MediatR 管线
- 05提供文件上传及手动验证模式
适用场景
- 01为请求模型选择验证方式
- 02在应用启动时检查配置选项
- 03集中处理复杂或异步业务规则
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill validation-patterns安装后在 Claude Code 中输入/validation-patterns确认已加载。
详细介绍
ASP.NET Core 中的验证模式
原因
验证对于安全性和用户体验都至关重要。验证不当会导致数据无效、安全漏洞和令人困惑的错误消息。这些模式提供了一种在多个层级进行验证的全面方法。
验证策略
| 层级 | 用途 | 技术 |
|---|---|---|
| 客户端 | 即时反馈,减少服务器负载 | jQuery Validation、HTML5 |
| 模型绑定 | 数据类型/格式验证 | 模型绑定器 |
| 应用程序 | 业务规则验证 | FluentValidation、DataAnnotations |
| 配置 | 启动时验证 | IValidateOptions |
| 数据库 | 约束强制 | EF Core 配置 |
验证方法决策树
根据复杂度选择验证方法:
- DataAnnotations(默认)——声明式
[Required]、[Range]、[StringLength]、[RegularExpression]特性。最适用于简单的属性级约束。 IValidatableObject——实现Validate()以处理跨属性规则。最适用于日期范围比较和条件必填字段。- 自定义
ValidationAttribute——继承ValidationAttribute,实现可复用的属性级规则。 IValidateOptions<T>——在启动时验证配置/选项类,并可访问 DI 服务。- FluentValidation——用于复杂、可测试验证的第三方库,提供流畅的 API。最适用于异步验证器和依赖数据库的规则。
模式 1:DataAnnotations
System.ComponentModel.DataAnnotations 命名空间通过特性提供声明式验证。
using System.ComponentModel.DataAnnotations;
public sealed class CreateProductRequest
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; set; }
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between {1} and {2}")]
public decimal Price { get; set; }
[RegularExpression(@"^[A-Z]{2,4}-\d{4,8}$",
ErrorMessage = "SKU format: AA-0000 to AAAA-00000000")]
public string? Sku { get; set; }
[EmailAddress]
public string? ContactEmail { get; set; }
[Url]
public string? WebsiteUrl { get; set; }
[Range(0, int.MaxValue, ErrorMessage = "Quantity cannot be negative")]
public int Quantity { get; set; }
}
特性参考
| 特性 | 用途 | 示例 |
|---|---|---|
[Required] | 非 null、非空 | [Required] |
[StringLength] | 最小/最大长度 | [StringLength(200, MinimumLength = 1)] |
[Range] | 数值/日期范围 | [Range(1, 100)] |
[RegularExpression] | 模式匹配 | [RegularExpression(@"^\d{5}$")] |
[EmailAddress] | 电子邮件格式 | [EmailAddress] |
[Phone] | 电话格式 | [Phone] |
[Url] | URL 格式 | [Url] |
[CreditCard] | Luhn 校验 | [CreditCard] |
[Compare] | 属性相等性 | [Compare(nameof(Password))] |
[MaxLength] / [MinLength] | 集合/字符串长度 | [MaxLength(50)] |
[AllowedValues](.NET 8+) | 值允许列表 | [AllowedValues("Draft", "Published")] |
[DeniedValues](.NET 8+) | 值拒绝列表 | [DeniedValues("Admin", "Root")] |
[Length](.NET 8+) | 在一个特性中指定最小值和最大值 | [Length(1, 200)] |
[Base64String](.NET 8+) | Base64 格式 | [Base64String] |
模式 2:自定义 ValidationAttribute
创建可复用的验证特性,以实现特定于领域的规则。
属性级
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class FutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(
object? value, ValidationContext validationContext)
{
if (value is DateOnly date && date <= DateOnly.FromDateTime(DateTime.UtcNow))
{
return new ValidationResult(
ErrorMessage ?? "Date must be in the future",
[validationContext.MemberName!]);
}
return ValidationResult.Success;
}
}
public sealed class CreateEventRequest
{
[Required]
[StringLength(200)]
public required string Title { get; set; }
[FutureDate(ErrorMessage = "Event date must be in the future")]
public DateOnly EventDate { get; set; }
}
类级
[AttributeUsage(AttributeTargets.Class)]
public sealed class DateRangeAttribute : ValidationAttribute
{
public string StartProperty { get; set; } = "StartDate";
public string EndProperty { get; set; } = "EndDate";
protected override ValidationResult? IsValid(
object? value, ValidationContext validationContext)
{
if (value is null) return ValidationResult.Success;
var type = value.GetType();
var startValue = type.GetProperty(StartProperty)?.GetValue(value);
var endValue = type.GetProperty(EndProperty)?.GetValue(value);
if (startValue is DateOnly start && endValue is DateOnly end && end < start)
{
return new ValidationResult(
ErrorMessage ?? $"{EndProperty} must be after {StartProperty}",
[EndProperty]);
}
return ValidationResult.Success;
}
}
模式 3:IValidatableObject
实现 IValidatableObject,在模型内进行跨属性验证:
public sealed class CreateOrderRequest : IValidatableObject
{
[Required]
[StringLength(50)]
public required string CustomerId { get; set; }
[Required]
public DateOnly OrderDate { get; set; }
public DateOnly? ShipByDate { get; set; }
[Required]
[MinLength(1, ErrorMessage = "At least one line item is required")]
public required List<OrderLineItem> Lines { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (ShipByDate.HasValue && ShipByDate.Value <= OrderDate)
{
yield return new ValidationResult(
"Ship-by date must be after order date",
[nameof(ShipByDate)]);
}
if (Lines.Sum(l => l.Quantity * l.UnitPrice) > 1_000_000)
{
yield return new ValidationResult(
"Total order value cannot exceed 1,000,000",
[nameof(Lines)]);
}
if (Lines.Any(l => l.RequiresShipping) && ShipByDate is null)
{
yield return new ValidationResult(
"Ship-by date is required when order contains shippable items",
[nameof(ShipByDate)]);
}
}
}
何时使用 IValidatableObject,何时使用自定义特性: 当验证逻辑只针对一个模型时,使用 IValidatableObject。当同一规则适用于多个模型时,使用自定义 ValidationAttribute。
模式 4:IValidateOptions
在启动时验证配置/选项类,并可访问 DI 服务:
public sealed class DatabaseOptions
{
public const string SectionName = "Database";
public string ConnectionString { get; set; } = "";
public int MaxRetryCount { get; set; } = 3;
public int CommandTimeoutSeconds { get; set; } = 30;
public int MaxPoolSize { get; set; } = 100;
public int MinPoolSize { get; set; } = 0;
}
public sealed class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
{
public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
{
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
failures.Add("Database connection string is required.");
}
if (options.MaxRetryCount is < 0 or > 10)
{
failures.Add("MaxRetryCount must be between 0 and 10.");
}
if (options.MinPoolSize > options.MaxPoolSize)
{
failures.Add($"MinPoolSize ({options.MinPoolSize}) cannot exceed MaxPoolSize ({options.MaxPoolSize}).");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
注册
builder.Services
.AddOptions<DatabaseOptions>()
.BindConfiguration(DatabaseOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();
模式 5:FluentValidation 设置
NuGet 包
<PackageReference Include="FluentValidation" Version="11.9.*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.*" />
配置
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
基本验证器
public class SignUpRequest
{
public required string FirstName { get; set; }
public required string LastName { get; set; }
public required string Email { get; set; }
public required string Username { get; set; }
public required string Password { get; set; }
public required string ConfirmPassword { get; set; }
public required bool AcceptsTos { get; set; }
}
internal sealed class SignUpValidator : AbstractValidator<SignUpRequest>
{
public SignUpValidator()
{
RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("Please enter a first name")
.MinimumLength(3).WithMessage("First name must be at least 3 characters long")
.MaximumLength(50).WithMessage("First name cannot exceed 50 characters")
.Matches(@"^[a-zA-Z\s'-]+$").WithMessage("First name contains invalid characters");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Please enter an email")
.EmailAddress().WithMessage("Please enter a valid email address")
.MustAsync(BeUniqueEmail).WithMessage("An account with this email already exists");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Please enter a password")
.MinimumLength(8).WithMessage("Password must be at least 8 characters long")
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches(@"[a-z]").WithMessage("Password must contain at least one lowercase letter")
.Matches(@"[0-9]").WithMessage("Password must contain at least one number")
.Matches(@"[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("Passwords do not match");
RuleFor(x => x.AcceptsTos)
.Equal(true).WithMessage("You must accept our Terms of Service to sign up");
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
{
return !await _dbContext.Users.AnyAsync(u => u.Email == email, cancellationToken);
}
}
条件验证
public class OrderValidator : AbstractValidator<OrderRequest>
{
public OrderValidator()
{
RuleFor(x => x.ShippingAddress)
.NotEmpty()
.When(x => x.RequiresShipping)
.WithMessage("Shipping address is required when shipping is needed");
RuleFor(x => x.PickupLocation)
.NotEmpty()
.When(x => !x.RequiresShipping)
.WithMessage("Pickup location is required for in-store pickup");
When(x => x.IsExpressShipping, () =>
{
RuleFor(x => x.ShippingAddress.Country)
.Must(BeSupportedCountry)
.WithMessage("Express shipping is not available for this country");
});
}
}
集合验证
public class OrderRequestValidator : AbstractValidator<OrderRequest>
{
public OrderRequestValidator()
{
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Order must contain at least one item")
.Must(items => items.Count <= 100).WithMessage("Order cannot contain more than 100 items");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(x => x.ProductId)
.NotEmpty().WithMessage("Product is required");
item.RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be greater than 0")
.LessThanOrEqualTo(999).WithMessage("Quantity cannot exceed 999");
});
}
}
模式 6:MediatR 验证管道行为
internal sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!validators.Any())
{
return await next(cancellationToken).ConfigureAwait(false);
}
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken)))
.ConfigureAwait(false);
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count == 0)
{
return await next(cancellationToken).ConfigureAwait(false);
}
throw new ValidationException(failures);
}
}
public class ValidationException : Exception
{
public IReadOnlyList<ValidationFailure> Errors { get; }
public ValidationException(IEnumerable<ValidationFailure> failures)
: base("Validation failed")
{
Errors = failures.ToList().AsReadOnly();
}
public IDictionary<string, string[]> ToDictionary()
{
return Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
}
}
注册
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
模式 7:手动验证
以编程方式运行 DataAnnotations 验证:
public static class ValidationHelper
{
public static (bool IsValid, IReadOnlyList<ValidationResult> Errors) Validate<T>(
T instance) where T : notnull
{
var results = new List<ValidationResult>();
var context = new ValidationContext(instance);
bool isValid = Validator.TryValidateObject(
instance, context, results, validateAllProperties: true);
return (isValid, results);
}
}
重要: 如果不设置 validateAllProperties: true,Validator.TryValidateObject 只会检查 [Required] 特性。
模式 8:验证文件上传
public class FileUploadValidator : AbstractValidator<FileUploadRequest>
{
private readonly string[] _allowedExtensions = [".jpg", ".jpeg", ".png", ".pdf"];
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
public FileUploadValidator()
{
RuleFor(x => x.File)
.NotNull()
.Must(BeValidSize).WithMessage("File size must not exceed 10MB")
.Must(BeValidExtension).WithMessage("Invalid file type. Allowed: .jpg, .jpeg, .png, .pdf");
RuleFor(x => x.Description)
.MaximumLength(500).When(x => !string.IsNullOrEmpty(x.Description));
}
private bool BeValidSize(IFormFile file) => file.Length <= MaxFileSize;
private bool BeValidExtension(IFormFile file)
{
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
return _allowedExtensions.Contains(extension);
}
}
反模式
重复验证
// BAD: Validation in multiple places
if (string.IsNullOrEmpty(model.Email))
ModelState.AddModelError("Email", "Required");
RuleFor(x => x.Email).NotEmpty(); // Duplicate!
// GOOD: Centralize validation in validators
静默验证失败
// BAD: Ignoring validation results
catch (ValidationException)
{
return Page();
}
// GOOD: Always add errors to ModelState
catch (ValidationException ex)
{
foreach (var error in ex.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
return Page();
}
信任客户端验证
// BAD: Only client-side validation
public IActionResult OnPost(UserInput input)
{
SaveToDatabase(input);
}
// GOOD: Server-side always validates
public IActionResult OnPost(UserInput input)
{
if (!ModelState.IsValid)
return Page();
SaveToDatabase(input);
}
Agent 常见问题
- 始终向
Validator.TryValidateObject传入validateAllProperties: true。 - 选项类必须使用
{ get; set; },而不是{ get; init; }——配置绑定器需要修改属性。 - 只有在所有特性验证均通过后,才会运行
IValidatableObject.Validate()——不要依赖它进行主要验证。 - 不要通过构造函数向
ValidationAttribute注入服务——在IsValid()中使用validationContext.GetService<T>()。 - 将
IValidateOptions<T>注册为单例——选项验证基础设施会以单例方式解析验证器。 - 不要忘记
ValidateOnStart()——否则,选项验证只会在首次访问时运行。
参考资料
评论
支持 Markdown