mcpField
V
validation-patterns

validation-patterns

github.com/wshaddix开发86537 次安装35 周前更新
代码仓库

关于 validation-patterns

面向 ASP.NET Core 开发者的验证模式指南,涵盖从属性标注到应用管线的多层验证方式,并说明各方案适用场景与常见陷阱。

主要功能

  1. 01比较多层验证职责
  2. 02介绍 DataAnnotations 与自定义属性
  3. 03涵盖 IValidatableObject 和 IValidateOptions
  4. 04说明 FluentValidation 与 MediatR 管线
  5. 05提供文件上传及手动验证模式

适用场景

  1. 01为请求模型选择验证方式
  2. 02在应用启动时检查配置选项
  3. 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 配置

验证方法决策树

根据复杂度选择验证方法:

  1. DataAnnotations(默认)——声明式 [Required]、[Range]、[StringLength]、[RegularExpression] 特性。最适用于简单的属性级约束。
  2. IValidatableObject——实现 Validate() 以处理跨属性规则。最适用于日期范围比较和条件必填字段。
  3. 自定义 ValidationAttribute——继承 ValidationAttribute,实现可复用的属性级规则。
  4. IValidateOptions<T>——在启动时验证配置/选项类,并可访问 DI 服务。
  5. 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 常见问题

  1. 始终向 Validator.TryValidateObject 传入 validateAllProperties: true。
  2. 选项类必须使用 { get; set; },而不是 { get; init; }——配置绑定器需要修改属性。
  3. 只有在所有特性验证均通过后,才会运行 IValidatableObject.Validate()——不要依赖它进行主要验证。
  4. 不要通过构造函数向 ValidationAttribute 注入服务——在 IsValid() 中使用 validationContext.GetService<T>()。
  5. 将 IValidateOptions<T> 注册为单例——选项验证基础设施会以单例方式解析验证器。
  6. 不要忘记 ValidateOnStart()——否则,选项验证只会在首次访问时运行。

参考资料

评论

支持 Markdown