参数校验
传统方式
使用ValidationAttribute特性
其他方式
使用特性为Controller进行参数校验
ValidationAttribute特性
自定义校验
校验错误返回格式
校验时使用依赖注入?
搭配过滤器使用效果更佳
现代化的应用越来越偏向于使用前后端分离的程序架构,对比传统的前后端高度耦合的应用,前后端分离开发不仅能使开发效率有所提高,而且代码质量也会更有保证。
在传统的WebForms项目中,前端和后台高度耦合,通常来说,一个后台只会对应一个前端所以接口的参数校验既可以在前端做,也可以在后台做,甚至可以交给数据库做(虽然可以这样做,但不推荐);而在前后端分离的架构中,服务端一般采用Web Api的模式向任意前端提供HTTP接口,这时候服务端就不能完全信任前端提交的数据,必须自己做一次参数校验。
你是否在为复杂的参数校验而头疼,你是否已经对写这样的校验代码感到烦躁?
[HttpPost("sign-in")] public IActionResult SignIn([FromBody] User usr) { if (string.IsNullOrWhiteSpace(usr.Name)) { return BadRequest("用户姓名不能为空"); } if (string.IsNullOrWhiteSpace(usr.Account) || usr.Account.Length > 10) { return BadRequest("用户账户不能为空且长度不能超过10"); } if (usr.Age < 18) { return BadRequest("未满18岁禁止注册"); } //... return Ok(); }
如果你还在用这种传统的方式写参数校验,那么你只在第一层。
其实在WebApi中我们还可以向数据传输类中的属性添加ValidationAttribute特性来达到参数校验的效果。
以下代码与上文传统方式的校验效果完全一致,而且只需要编写1次,不需要为每个使用User参数的接口都编写一次校验代码。
public class User { /// <summary> /// 姓名 /// </summary> [Required(ErrorMessage = "姓名不能为空")] public string Name { get; set; } /// <summary> /// 年龄 /// </summary> [Range(18, 200, ErrorMessage = "未满18岁禁止注册")] public int Age { get; set; } /// <summary> /// 用户账号 /// </summary> [NoSpace(ErrorMessage = "用户账号不能包含空格")] [StringLength(10, ErrorMessage = "账号最大长度不能超过10")] public string Account { get; set; } }
如果你会使用特性的方式为Controller进行参数校验,那么你已经在第二层了。
除了以上两种方式之外还可以使用例如FluentValidation等第三方库进行参数校验。若使用FluentValidation等第三方库确实可以方便优雅地实现非常复杂的参数校验,此部分内容不在本文记录范围内,暂时不作深究。如果你已经能用第三方的参数校验库对项目接口进行参数校验,并且能够优雅地集成到项目中,那么你应该已经在大气层了。
言归正传,如果我们在WebApi中想用为传输类(DTO)属性添加特性的方式进行参数校验,我们需要做什么呢?
ValidationAttribute特性是一个抽象类,如果它的派生类被标注在属性上,则表示该属性可以进行一定规则的校验。
那么如果我们在DTO中已经为需要校验的属性加上了合适的校验特性,我们还需要做什么使它生效吗?如果你的Controller是ApiController,那么此时直接运行就可以了什么都不用做。
[Route("api/")] [ApiController] public class ApiController : ControllerBase { }
在名命空间 System.ComponentModel.Annotations中微软已经定义了一些常用的参数校验特性,我们可以按需取用。
下列是一些比较常用的校验特性。
特性名 | 效果 |
---|---|
MaxLengthAttribute | 校验字符串or数组最大长度 |
MinLengthAttribute | 校验字符串or数组最小长度 |
RangeAttribute | 数值范围校验 |
RegularExpressionAttribute | 正则表达式校验 |
RequiredAttribute | 必填项校验 |
StringLengthAttribute | 字符串长度范围校验 |
如果想自定义校验规则也非常简单,只需要创建一个派生于ValidationAttribute的特性然后重写IsValid方法和FormatErrorMessage方法即可。
以下代码为示例自定义校验特性
/// <summary> /// 不允许包含空格校验 /// </summary> public class NoSpaceAttribute : ValidationAttribute { /// <summary> /// 重写验证规则 /// </summary> /// <param name="value">属性值</param> /// <returns></returns> public override bool IsValid(object value) { bool ret = true; if(value is string str) { ret = !(str.Contains(' ')); } return ret; } /// <summary> /// 重写验证错误信息 /// </summary> /// <param name="name">属性名</param> /// <returns></returns> public override string FormatErrorMessage(string name) { return $"{name}不能包含有空格"; } }
自定义校验特性的使用和其他预定义的校验特性使用完全一致,直接挂载到属性上就好了。
这里说明一下,一个属性是可以有挂载多个校验特性的,挂载了多个校验特性后它们将同时生效,例如用户账户既不能包含空格也不能超过10个字符,那么就同时可以挂载NoSpace特性和StringLength特性。
大部分Web Api 项目中可能会要求所有接口返回统一的JSON结构格式,可是如果我们使用了ValidationAttribute作为参数校验之后,虽然校验是自动进行了,但是返回的格式也不一样了。
参数校验错误默认返回格式
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "traceId": "|a47a4b9b-41759e0f8f72bf52.", "errors": { "Age": [ "未满18岁禁止注册" ], "Account": [ "Account不能包含有空格", "账号最大长度不能超过10" ] } }
如果我们想返回项目中定义好的统一返回格式就需要修改一下Startup.cs了
找到ConfigureService方法,在AddControllers后调用ConfigureApiBehaviorOptions配置模型校验错误后统一返回格式。
public void ConfigureServices(IServiceCollection services) { ... services.AddControllers(). ConfigureApiBehaviorOptions(options => { // 规定模型校验错误时返回统一的结构 options.InvalidModelStateResponseFactory = context => { // 创建项目定义的统一的返回结构ApiResult var apiResult = new ApiResult(); // 向apiResult记录参数校验错误 // ... // 参数校验错误详情可在context.ModelState中获取 // 统一返回400的Json var result = new BadRequestObjectResult(apiResult); result.ContentTypes.Add(MediaTypeNames.Application.Json); return result; }; }); ... }
我想在自定义校验特性里使用依赖注入获得其他服务来协助验证可以吗?非常遗憾的告诉你,不可以哦,首先,ASP.Net Core中的依赖注入是通过构造方法的形式使用的,而如果在特性中的构造函数加上参数,则在使用特性时必须也添加上,这就造成了无法在特性中使用构造函数依赖注入。
根据这位外国朋友的文章
https://blogs.cuttingedge.it/steven/posts/2014/dependency-injection-in-attributes-dont-do-it/
他分析了C#中特性的作用,最终得出的结论是:C#中特性应该是类似标签一样的东西用来表示类或属性的静态特征,所以不应该在特性中进行服务操作,而依赖注入是用来获取外部服务的,所以引申过来特性中也不应该使用依赖注入。
那么有时候参数校验确实需要用到外部服务,例如校验用户ID是否存在的时候不能用特性那难道就只能用回传统方式在Controller里写if else了吗?
同样是上面那位外国朋友的思路,当对象需要使用外部服务的时候应该用过滤器来实现,而不是用特性来实现。例如此时有一个用户User类,用户登录时我们需要校验它的用户账号Account是否存在,这时,校验用户信息的这个行为应该是User类自己的行为,所以我们可以自己定义一个IVerifiable接口表示这类数据可以进行校验,再让User类实现该接口。
ValidationError.cs
/// <summary> /// 校验错误实体 /// </summary> public class ValidationError { /// <summary> /// 属性名 /// </summary> public string Property { get; set; } /// <summary> /// 错误信息(一个属性可能多个校验) /// </summary> public List<string> Messages { get; set; } public ValidationError() { Messages = new List<string>(); } }
IVerifiable.cs
/// <summary> /// 可校验接口 /// </summary> public interface IVerifiable { /// <summary> /// 数据校验 /// </summary> /// <returns>返回错误集合</returns> IEnumerable<ValidationError> Verify(); /// <summary> /// 使用依赖注入容器获取其他对象进行数据校验 /// </summary> /// <param name="provider">依赖注入容器</param> /// <returns>返回错误集合</returns> IEnumerable<ValidationError> Verify(IServiceProvider provider); }
User.cs
public class User : IVerifiable { /// <summary> /// 姓名 /// </summary> [Required(ErrorMessage = "姓名不能为空")] public string Name { get; set; } /// <summary> /// 年龄 /// </summary> [Range(18, 200, ErrorMessage = "未满18岁禁止注册")] public int Age { get; set; } /// <summary> /// 用户账号 /// </summary> [NoSpace(ErrorMessage = "用户账号不能包含空格")] [StringLength(10, ErrorMessage = "账号最大长度不能超过10")] public string Account { get; set; } public IEnumerable<ValidationError> Verify() { return Verify(null); } public IEnumerable<ValidationError> Verify(IServiceProvider provider) { List<ValidationError> ret = new List<ValidationError>(); if (provider != null) { // 使用依赖注入容器获取数据访问实体以校验用户数据是否合法 // 用于校验户账号是否已存在,用户年龄是否符合地区法律要求等 //provider.GetRequiredService<UserRepository>(); //ret.Add(new ValidationError() { Property = "Age", Messages = new List<string>() { "该游戏只运行16周岁以上用户游玩" } }); } return ret; } }
校验规则都完成了,这时就需要一个过滤器来自动调用Verify方法了。
ArgumentValidFilter.cs
public class ArgumentValidFilter : IActionFilter { public void OnActionExecuted(ActionExecutedContext context) { } public void OnActionExecuting(ActionExecutingContext context) { List<ValidationError> errs = new List<ValidationError>(); foreach(var arg in context.ActionArguments) { if(arg.Value is IVerifiable data) { // 当参数是可校验的时就调用校验方法并记录错误 errs.AddRange(data.Verify(context.HttpContext.RequestServices)); } } if (errs.Count > 0) { // 返回统一的json格式 var apiResult = new ApiResult(); apiResult.Message = "参数校验错误"; apiResult.Data = errs; // 统一返回400的Json var result = new BadRequestObjectResult(apiResult); result.ContentTypes.Add(MediaTypeNames.Application.Json); // 设置Result使请求管道中止 context.Result = result; } } }
最后将这个过滤器添加到请求管道中。依旧是Startup.cs的ConfigureServices方法,在AddController方法配置中添加过滤器。
public void ConfigureServices(IServiceCollection services) { ... services.AddControllers(config=> { // 添加参数校验过滤器 config.Filters.Add<ArgumentValidFilter>(); }). ConfigureApiBehaviorOptions(options => { // 规定模型校验错误时返回统一的结构 options.InvalidModelStateResponseFactory = context => { // 创建项目定义的统一的返回结构ApiResult var apiResult = new ApiResult(); // 向apiResult记录参数校验错误 // ... // 参数校验错误详情可在context.ModelState中获取 // 统一返回400的Json var result = new BadRequestObjectResult(apiResult); result.ContentTypes.Add(MediaTypeNames.Application.Json); return result; }; }); ... }
此时,静态校验特性+动态校验过滤器相互配合足以满足绝大部分的校验需求。