如何在 FluentValidation 中重用数据

How to reuse data in FluentValidation

例如我有两个验证规则的验证器:

// Rule 1
RuleFor(o => o.Email).Must((email) => this.GetDataDataFromDB(email) != 0)
    .WithMessage("User with provided Email was not found in database!");

// Rule 2
RuleFor(o => o.Email).Must((email) => this.GetDataDataFromDB(email) >= 1)
    .WithMessage("There are multiple users with provided Email in database!");

如您所见,有两次使用相同方法调用数据库。如何调用一次并为其他规则重复使用数据?

显示错误消息时的另一个问题:

RuleFor(o => o.Email).Must((email) => this.GetDataDataFromDB(email) >= 1)
    .WithMessage("There are multiple users with following Email '{0}' in database!",
    (model, email) => { return email; });

有没有更好的方法来显示错误消息,而不是总是编写那些 lambda 表达式来检索 属性?就像在某处保存模型然后稍后使用它。

简单易行的方案就好了!

第 1 部分

您想将数据库调用从 2 次减少到 1 次,因此您需要使用字段来保存数据库调用结果,因为验证器规则 code 实际上在 中起作用"runtime"

验证者class:

public class MyValidator : Validator<UserAccount>
{
    private int? _countOfExistingMails;
    private string _currentEmail;
    private object locker = new object();

    public MyValidator()
    {
        CallEmailValidations();
        // other rules...
    }
}

这里是邮件验证调用的单独方法。至于 Must 将表达式作为参数,你可以传递方法名称及其参数:

public void CallEmailValidations()
{
    RuleFor(o => o.Email).Must(x => EmailValidation(x, 0))
        .WithMessage("User with provided Email was not found in database!");

    RuleFor(o => o.Email).Must(x => EmailValidation(x, 1))
        .WithMessage("There are multiple users with provided Email in database!");
}

和验证方法的主体本身:

public bool EmailValidation(string email, int requiredCount)
{
    var isValid = false;

    lock(locker)
    {
        if (email != _currentEmail || _currentEmail == null)
        {
            _currentEmail = email;
            _countOfExistingMails = (int)GetDataDataFromDB(email);
        }

        if (requiredCount == 0)
        {
            isValid = _countOfExistingMails != 0; // Rule 1
        }
        else if (requiredCount == 1)
        {
            isValid = _countOfExistingMails <= 1; // Rule 2
        }
    }
    // Rule N...

    return isValid;
}

更新: 此代码有效,但更好的方法是在数据访问层方法中实现缓存。

第 2 部分

这里是重写的规则:

RuleFor(o => o.Email).Must((email) => GetDataDataFromDB(email) >= 1)
    .WithMessage("There are multiple users with following Email '{0}' in database!", m => m.Email)

来自"C# in depth"

When the lambda expression only needs a single parameter, and that parameter can be implicitly typed, C# 3 allows you to omit the parentheses, so it now has this form

陷阱:

  1. 不要显式传递 this 到 lambda 表达式。据我所知,它可能会导致性能问题。没有理由创建额外的闭包。

  2. 我想你在 GetDataDataFromDB 方法中以某种形式使用了 DataContext。所以你必须控制上下文的生命周期,因为验证器对象实例化为单例。

对于#1,恐怕没有办法做到这一点。验证器被设计为无状态的,因此它们可以跨线程重用(事实上,强烈建议您将验证器实例创建为单例,因为它们的实例化成本非常高。MVC 集成默认情况下会这样做)。不要乱用静态字段,因为您会 运行 陷入线程问题。

(编辑:在这种特殊的简单情况下,您可以将规则组合到对 Must 的单个调用中,但通常您不能在规则之间共享状态)

对于 #2,这取决于您使用的 属性 验证器。大多数 属性 验证器实际上允许您使用 {PropertyValue} 占位符,该值将自动插入。但是,在这种情况下,您使用的是不支持占位符的 "Must" 验证器 (PredicateValidator)。

我在此处列出了哪些验证器支持自定义占位符:https://github.com/JeremySkinner/FluentValidation/wiki/c.-Built-In-Validators

在寻找更好的方法时遇到这个问题;)

另一种方法是重写 ValidateAsyncValidate 方法并将结果存储在本地字段中,该字段可以按如下规则访问:

public class MyValidator : AbstractValidator<MyCommand>
{
    User _user = User.Empty;

    public MyValidator()
    {
        RuleFor(o => o.Email)
            .Must((_) => !_user.IsEmpty)
            .WithMessage("User with provided Email was not found in database!");

        // Rule 2
        //other rules which can check _user
    }

    public override async Task<ValidationResult> ValidateAsync(ValidationContext<MyCommand> context, CancellationToken cancellation = default)
    {
        var cmd = context.InstanceToValidate;
        // you could wrap in a try block if this throws, here I'm assuming empty user
        _user = await _repository.GetUser(cmd.Email);
        return await base.ValidateAsync(context, cancellation);
    }

    public override ValidationResult Validate(ValidationContext<SubmitDecisionCommand> context) => ValidateAsync(context).Result;
}

你可以做的是使用 WhenAsync。我创建了一个扩展方法来简化事情。

public static class ValidatorExtensions
{
    public static void ResolveDataAsync<TEntity, TData>(
        this AbstractValidator<TEntity> validator,
        Func<TEntity, CancellationToken, Task<TData>> resolver,
        Action<ValueAccessor<TData>> continuation)
    {
        TData data = default;
        var isInitialized = false;
        var valueAccessor = new ValueAccessor<TData>(() =>
        {
            if (!isInitialized)
            {
                throw new InvalidOperationException("Value is not initialized at this point.");
            }

            return data;
        });

        validator.WhenAsync(async (entity, token) =>
            {
                data = await resolver(entity, token);
                return isInitialized = true;
            },
            () => continuation(valueAccessor));
    }
}

public class ValueAccessor<T>
{
    private readonly Func<T> _accessor;

    public ValueAccessor([NotNull] Func<T> accessor)
    {
        _accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
    }

    public T Value => _accessor();
}

用法:

public class ItemCreateCommandValidator : AbstractValidator<ItemCreateCommand>
{
    private readonly ICategoryRepository _categoryRepository;

    public ItemCreateCommandValidator(ICategoryRepository categoryRepository)
    {
        _categoryRepository = categoryRepository;

        this.ResolveDataAsync(CategoryResolver, data =>
        {
            RuleFor(x => x.CategoryIds)
                .NotEmpty()
                .ForEach(subcategoryRule => subcategoryRule
                    .Must(x => data.Value.ContainsKey(x))
                    .WithMessage((_, id) => $"Category with id {id} not found."));
        });
    }

    private Func<ItemCreateCommand, CancellationToken, Task<Dictionary<int, Category>>> CategoryResolver =>
        async (command, token) =>
        {
            var categories = await _categoryRepository.GetByIdsAsync(command.SubcategoryIds, token);
            return categories.ToDictionary(x => x.Id);
        };
}

对我来说工作正常,但有一些 GOTCHAS:

  1. 验证器通常必须定义为 Scoped 或 Transient(Scoped 的性能更好)以便与其依赖项的生命周期兼容(例如在构造函数中传递的存储库)。

  2. 您无法在 ResolveDataAsync 回调 中访问 data.Value。这是因为到那时该值尚未初始化。此时验证器处于创建阶段并且未调用 ValidateAsync 方法 => 无需验证 => 无法访问值。

它只能在 AbstractValidator 方法中使用:

this.ResolveDataAsync(CategoryResolver, data =>
{
    var value = data.Value; // Throws InvalidOperationException
    RuleFor(x => x.CategoryIds)
        .NotEmpty()
        .ForEach(subcategoryRule => subcategoryRule
            .Must(data.Value.ContainsKey)  // Also throws
            .WithMessage((_, id) => $"Category with id {id} not found."));
});

这些陷阱也会出现在其他方法中,例如重写 ValidateAsync 方法,您对此无能为力。

您还可以在使用 WhenAsyncUnlessAsync 时根据条件使用不同的解析器调用 ResolveDataAsync。这将帮助您不要每次都加载在所有情况下都不需要的数据:

WhenAsync(myCondition1, () => this.ResolveDataAsync(myResolver1, data => { ... }))
UnlessAsync(myCondition2, () => this.ResolveDataAsync(myResolver2, data => { ... }))