理解ASP.NET Core - 模型绑定&验证(Model Binding and Validation) _
注:本文隶属于《理解ASP.NET Core》系列文章,请查看置顶博客或
模型绑定
什么是模型绑定?简单说就是将HTTP请求参数绑定到程序方法入参上,该变量可以是简单类型,也可以是复杂类。
绑定源
所谓绑定源,是指用于模型绑定的值来源。
先举个例子:
csharp[HttpPost]
public string Post1([FromForm] CreateUserDto input)
{
return JsonSerializer.Serialize(input);
}
[HttpPost]
public string Post2([FromRoute]int[] numbers)
{
return JsonSerializer.Serialize(numbers);
}
如Post2方法的模型属性numbers要求从路由中寻找值,但是很明显我们的路由中并未提供,这种情况就是模型属性在绑定源中不存在。
默认的,若模型属性在绑定源中不存在,且不加任何验证条件时,不会将其标记为模型状态错误,而是会将该属性设置为null或默认值:
- 可以为Null的简单类型设置为
null - 不可为Null的值类型设置为
default - 如果是复杂类型,则通过默认构造函数创建该实例。如例子中的
Post1,如果我们没有通过表单传值,你会发现会得到一个使用CreateUserDto默认构造函数创建的实例。 - 数组则设置为
Array.Empty,不过() byte[]数组设置为null。如例子中的Post2,你会得到一个空数组。
情况二:绑定源无法转换为模型中的目标类型
比如,当尝试将绑定源中的字符串abc转换为模型中的值类型int时,会发生类型转换错误,此时,会将该模型状态标记为无效。
绑定格式
int、string、模型类等绑定格式大家已经很熟悉了,我就不再赘述了。这次,只给大家介绍一些比较特殊的绑定格式。
集合
假设存在以下接口,接口参数是一个数组:
csharppublic Dictionary<int, string> Post([FromQuery] Dictionary<int, string> idNames)
参数为:{ [1] = "j", [2] = "k" }
为了将参数绑定到字典idNames上,你可以通过表单或查询字符串传入,可以采用以下格式之一:
idNames[1]=j&idNames[2]=k,注意:方括号中的数字是字典的key[1]=j&[2]=kidNames[0].key=1&idNames[0].value=j&idNames[1].key=2&idNames[1].value=k,注意:方括号中的数字是索引,不是字典的key[0].key=1&[0].value=j&[1].key=2&[1].value=k
同样,请注意Url长度限制问题。
模型验证
聊完了模型绑定,那接下来就是要验证绑定的模型是否有效。
假设UserController中存在一个Post方法:
{
"age":"abc"
}
会得到如下响应:
csharppublic class ModelStateDictionary : IReadOnlyDictionary<string, ModelStateEntry>
{
public static readonly int DefaultMaxAllowedErrors = 200;
public ModelStateDictionary()
: this(DefaultMaxAllowedErrors) { }
public ModelStateDictionary(int maxAllowedErrors) { ... }
public ModelStateDictionary(ModelStateDictionary dictionary)
: this(dictionary?.MaxAllowedErrors ?? DefaultMaxAllowedErrors) { ... }
public ModelStateEntry Root { get; }
// 允许的模型状态最大错误数量,默认是 200
public int MaxAllowedErrors { get; set; }
// 指示模型状态错误数量是否达到最大值
public bool HasReachedMaxErrors { get; }
// 通过`AddModelError`或`TryAddModelError`方法添加的错误数量
public int ErrorCount { get; }
// 无效节点的数量
public int Count { get; }
public KeyEnumerable Keys { get; }
IEnumerable<string> IReadOnlyDictionary<string, ModelStateEntry>.Keys => Keys;
public ValueEnumerable Values { get; }
IEnumerable IReadOnlyDictionary<string, ModelStateEntry>.Values => Values;
// 枚举,模型验证状态,有 Unvalidated、Invalid、Valid、Skipped 共4种
public ModelValidationState ValidationState { get; }
// 指示模型状态是否有效,当验证状态为 Valid 和 Skipped 有效
public bool IsValid { get; }
public ModelStateEntry this[string key] { get; }
}
折叠
MaxAllowedErrors:允许的模型状态错误数量,默认是 200。- 当错误数量达到
MaxAllowedErrors - 1时,若还要添加错误,则该错误不会被添加,而是添加一个TooManyModelErrorsException错误 - 可以通过
AddModelError或TryAddModelError方法添加错误 - 另外,若是直接修改
ModelStateEntry,那错误数量不会受该属性限制
- 当错误数量达到
ValidationState:模型验证状态Unvalidated:未验证。当模型尚未进行验证或任意一个ModelStateEntry验证状态为Unvalidated时,该值为未验证。Invalid:无效。当模型已验证完毕(即没有ModelStateEntry验证状态为Unvalidated)并且任意一个ModelStateEntry验证状态为Invalid,该值为无效。Valid:有效。当模型已验证完毕,且所有ModelStateEntry验证状态仅包含Valid和Skipped时,该值为有效。Skipped:跳过。整个模型跳过验证时,该值为跳过。
重新验证
默认情况下,模型验证是自动进行的。不过有时,需要为模型进行一番自定义操作后,重新进行模型验证。可以先通过ModelStateDictionary.ClearValidationState方法清除验证状态,然后调用ControllerBase.TryValidateModel方法重新验证:
public class CreateUserDto
{
public string Name { get; set; }
public int Age { get; set; }
}
安装
今天,我们要安装两个包,分别是FluentValidation和FluentValidation.AspNetCore(后者依赖前者):
- FluentValidation:是整个验证库的核心
- FluentValidation.AspNetCore:用于与ASP.NET Core集成
选择你喜欢的安装方式:
- 方式1:通过NuGet安装:
dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore
创建 CreateUserDto 的验证器
为了配置CreateUserDto各个属性的验证规则,我们需要为它创建一个验证器(validator),该验证器继承自抽象类AbstractValidator,T就是你要验证的类型,这里就是CreateUserDto。
[HttpPost]
public string Post([FromBody] CreateUserDto input)
{
var validator = new CreateUserDtoValidator();
var result = validator.Validate(input);
if (!result.IsValid)
{
return $"模型状态无效:{result}";
}
return JsonSerializer.Serialize(input);
}
通过
ValidationResult.ToString方法,可以将所有错误消息组合为一条错误消息,默认分隔符是换行(Environment.NewLine),但是你也可以传入自定义分隔符。
当我们传入一个空的json对象时,会得到以下响应:
csharppublic void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews()
.AddFluentValidation(fv =>
fv.RegisterValidatorsFromAssemblyContaining());
}
注意:
AddFluentValidation必须在AddMvc之后注册,因为其需要使用Mvc的服务。
通过RegisterValidatorsFromAssemblyContaining方法,可以自动查找指定类型所属的程序集。
该方法可以指定一个filter,可以对要注册的验证器进行筛选。
需要注意的是,这些验证器默认注册的生命周期是Scoped,你也可以修改成其他的:
fv.RegisterValidatorsFromAssemblyContaining(includeInternalTypes: true)
好了,现在将Post方法改回我们熟悉的样子:
public class CreateUserDto
{
public CreateUserNameDto Name { get; set; }
public int Age { get; set; }
}
public class CreateUserNameDto
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
public class CreateUserNameDtoValidator : AbstractValidator<CreateUserNameDto>
{
public CreateUserNameDtoValidator()
{
RuleFor(x => x.FirstName).NotEmpty();
RuleFor(x => x.LastName).NotEmpty();
}
}
现在,我们的Name重新封装为了一个类CreateUserNameDto,该类包含了FirstName和LastName两个属性,并为其创建了一个验证器。很显然,我们希望在验证CreateUserDtoValidator中,可以使用CreateUserNameDtoValidator来验证Name。这可以通过SetValidator来实现:
public class CreateUserDto
{
public int Age { get; set; }
public List<string> Hobbies { get; set; }
public List Names { get; set; }
}
可以看到,新增了两个集合:简单集合Hobbies和复杂集合Names。如果仅使用RuleFor设定验证规则,那么其验证的是集合整体,而不是集合中的每个项。
为了验证集合中的每个项,需要使用RuleForEach或在RuleFor后跟ForEach来实现:
public class CreateUserDtoNameValidator : AbstractValidator<CreateUserDto>
{
public CreateUserDtoNameValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
public class CreateUserDtoAgeValidator : AbstractValidator<CreateUserDto>
{
public CreateUserDtoAgeValidator()
{
RuleFor(x => x.Age).GreaterThan(0);
}
}
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
public CreateUserDtoValidator()
{
Include(new CreateUserDtoNameValidator());
Include(new CreateUserDtoAgeValidator());
}
}
继承验证
虽然模型绑定不支持反序列化接口类型,但是它在其他场景中还是有用途的。
首先,改造一下CreateUserDto:
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
public CreateUserDtoValidator()
{
RuleFor(x => x.Age).GreaterThan(0);
RuleFor(x => x.Pet).NotEmpty().SetInheritanceValidator(v =>
{
v.Add(new DogPetValidator());
v.Add(new CatPetValidator());
});
}
}
自定义验证
官方提供的验证器已经可以覆盖大多数的场景,但是总有一些场景是和我们的业务息息相关的,因此,自定义验证就不可或缺了,官方为我们提供了Must和Custom。
Must
Must使用起来最简单,看例子:
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
public CreateUserDtoValidator()
{
RuleFor(x => x.Hobbies).NotEmpty()
.Custom((hobbies, context) =>
{
var duplicateHobby = hobbies.GroupBy(h => h).FirstOrDefault(g => g.Count() > 1)?.Key;
if (duplicateHobby is not null)
{
// 当验证失败时,会同时输出这两条消息
context.AddFailure($"爱好不能重复,重复项:{duplicateHobby}");
context.AddFailure($"再说一次,爱好不能重复");
}
});
}
}
当存在重复项时,会同时输出两条错误消息(即使设置了CascadeMode.Stop,这就是所期望的)。
验证配置
现在,模型验证方式你已经全部掌握了。现在的你,是否想要验证消息重写、属性重命名、条件验证等功能呢?
验证消息重写和属性重命名
默认的验证消息可以满足一部分需求,但是无法满足所有需求,所以,重写验证消息,是不可或缺的一项功能,这可以通过WithMessage来实现。
public class CreateUserDto
{
public string Name { get; set; }
public int Age { get; set; }
public bool? HasGirlfriend { get; set; }
public bool HardWorking { get; set; }
public bool Healthy { get; set; }
}
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
public CreateUserDtoValidator()
{
RuleFor(x => x.HasGirlfriend)
.NotNull()
.Equal(false).When(x => x.Age < 18, ApplyConditionTo.CurrentValidator)
.Equal(true).When(x => x.Age >= 18, ApplyConditionTo.CurrentValidator);
When(x => x.HasGirlfriend == true, () =>
{
RuleFor(x => x.HardWorking).Equal(true);
RuleFor(x => x.Healthy).Equal(true);
}).Otherwise(() =>
{
RuleFor(x => x.Healthy).Equal(true);
});
}
}
折叠
When有两种使用方式:
1.第一种是在规则后紧跟When设定条件,那么只有当满足该条件时,才会执行前面的验证规则。
需要注意的是,默认情况下,When会作用于它之前的所有规则上。例如,对于条件x.Age >= 18,他默认会作用于NotNull、Equal(false)、Equal(true)上面,只有当Age >= 18时,才会执行这些规则,然而,NotNull、Equal(false)又受限于条件x.Age < 18。
如果我们想要让When仅仅作用于紧跟它之前的那一条验证规则上,可以通过指定ApplyConditionTo.CurrentValidator来达到目的。例如示例中的x.Age < 18仅会作用于Equal(false),而x.Age >= 18仅会作用于Equal(true)。
可见,第一种比较适合用于对某一条验证规则设定条件。
2.第二种则是直接使用When来指定达到某个条件时要执行的验证规则。相比第一种,它的好处是更加适合针对多条验证规则添加同一条件,还可以结合Otherwise来添加反向条件达成时的验证规则。
其他验证配置
一起来看以下其他常用的配置项。
请注意,以下部分配置项,可以在每个验证器内进行配置覆盖。
csharppublic string Post([FromBody] List input)
若要验证该集合,则需要实现继承自AbstractValidator的验证器,或者指定>
ImplicitlyValidateChildProperties = true。
如果,你想仅仅验证CreateUserDto的属性,而不验证其子属性CreateUserNameDto的属性,则必须设置ImplicitlyValidateChildProperties = false,并设置ImplicitlyValidateRootCollectionElements = true(当ImplicitlyValidateChildProperties = true时,会忽略该配置)。
ValidatorOptions.CascadeMode
指定验证失败时的级联模式,共两种(外加一个已过时的):
Continue:默认的。即使验证失败了,也会执行全部验证规则。Stop:当一个验证器中出现验证失败时,立即停止当前验证器的继续执行。如果在当前验证器中通过SetValidator为复杂属性设置另一个验证器,那么会将其视为一个验证器。不过,如果设置ImplicitlyValidateChildProperties = true,那么这将会被视为不同的验证器。[Obsolete]StopOnFirstFailure:官方建议,如果可以使用Stop,就不要使用该模式。注意该模式和Stop模式行为并非完全一致,具体要不要用,自己决定。点击此处查看他俩的区别。
ValidatorOptions.Severity
设置验证错误的严重级别,可以配置的项有Error(默认)、Warning、Info。
即使你讲严重级别设置为了Warning或者Info,ValidationResult.IsValid仍是false。不同的是,ValidationResult.Errors中的严重级别是Warning或者Info。
ValidatorOptions.LanguageManager
可以忽略当前文化,强制设置指定文化,如强制设置为美国:
csharpValidatorOptions.DisplayNameResolver = (type, member, expression) =>
{
if (member is not null)
{
return "xiaoxiaotank_" + member.Name;
}
return null;
};
错误消息类似如下:
json{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-16fd10e48fa5d545ae2e5f3fee05dc84-d23c49c9a5e35d49-00",
"errors": {
"Hobbies[0].LastName": [
"'xiaoxiaotank_LastName' 不能为Null。",
"'xiaoxiaotank_LastName' 不能为空。"
],
"Hobbies[0].FirstName": [
"'xiaoxiaotank_FirstName' 不能为Null。",
"'xiaoxiaotank_FirstName' 不能为空。"
]
}
}
其实现的根本原理是使用了ModelStateInvalidFilter过滤器,该过滤器会附加在所有被标注了ApiControllerAttribute的类型上。
public class ModelStateValidationFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
if (context.HttpContext.Request.AcceptJson())
{
var errorMsg = string.Join(Environment.NewLine, context.ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
context.Result = new BadRequestObjectResult(AjaxResponse.Failed(errorMsg));
}
else
{
context.Result = new ViewResult();
}
}
}
}
public static class HttpRequestExtensions
{
public static bool AcceptJson(this HttpRequest request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
var regex = new Regex(@"^(\*|application)/(\*|json)$");
return request.Headers[HeaderNames.Accept].ToString()
.Split(',')
.Any(type => regex.IsMatch(type));
}
}
折叠
AjaxResponse.Failed(errorMsg)只是自定义的json数据结构,你可以按照自己的方式来。
__EOF__
- 本文作者: xiaoxiaotank
- 本文链接:
- 关于博主: 使用微信扫描一下左侧的二维码关注我的订阅号,或加入我的QQ交流群:705680556
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
- 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。