.Net小知识:自定义模型校验

前言

之前写过一点关于自定义模型校验的,此处 ,但是里面的方案一、方案二只是局限于具体的一个类,不具有通用性,那么如果针对比较通用的模型校验,该如何处理?.

操作

示例环境:vs2022、.Net6

列举场景:当前我们要添加用户考试科目的成绩,所以我们有了下面的接口。

入参

/// <summary>
/// 添加用户成绩请求类
/// </summary>
public class AddUserGradeVm
{
    /// <summary>
    /// 用户ID
    /// </summary>
    [Required]
    public int UserId { get; set; }

    /// <summary>
    /// 科目ID
    /// </summary>
    [Required]
    public int Subject { get; set; }

    /// <summary>
    /// 成绩
    /// </summary>
    [Required]
    public double Grade { get; set; }
}

接口实现

[HttpPost]
public int AddUserGrade(AddUserGradeVm request)
{
    _logger.LogInformation($"用户ID:{request.UserId} 科目{request.Subject} 成绩是:{request.Grade}");

    return 1;
}

看咱们的入参,都是值类型,虽然我们对属性标注了Required特性,但是值类型默认值为0,所以当你不传的时候就直接取了默认值。输出结果为

用户ID:0 科目0 成绩是:0

这个时候我们可以写if判断来限制值必须大于0等,那么如果项目里面这种场景比较多,就有点繁琐了。

方案一

我们可以模仿着MaxLengthAttribute去继承ValidationAttribute编写一个ValidMinValueAttribute

/// <summary>
///验证最小值
/// </summary>
[AttributeUsage(AttributeTargets.All, AllowMultiple = false)]
public class ValidMinValueAttribute : ValidationAttribute
{
    private readonly int _minValue;

    public ValidMinValueAttribute(int minValue)
    {
        _minValue = minValue;
    }

    public override bool IsValid(object? value)
    {
        if (value == null)
        {
            return false;
        }
        if (value is not int valueAsInt)
        {
            return false;
        }

        if (valueAsInt <= _minValue)
        {
            return false;
        }

        return true;
    }
}

使用方法

[ValidMinValue(1, ErrorMessage = "值必须大于等于1")]
[Required]
public int UserId { get; set; }

当我们请求接口参数参数都是0的时候,返回结果

{
    "errors": {
        "UserId": [
            "值必须大于等于1"
        ]
    },
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-869a6b371a821f168a11eb06085950b6-9c9e21e35a8614c5-00"
}

方案二

ASP.NET Core默认包含了内置模型的校验,也就是说当你传递校验不通过的值时候,会在未到达Action之前已经帮你过滤处理,这样子的返回格式和我们封装的公共返回类不一样,所以就是需要禁用默认的校验,然后自己写过滤器校验,如果是自定义(自己编写校验特性,而不模仿官方的)的那么就只能处理了。

小疑问:那么框架是在什么地方帮我们做的校验那?是否可以从那个地方入手处理?回头再研究一下。

思路:自己编写特定校验搭配过滤器使用。

准备

示例中使用的返回类格式是

/// <summary>
/// 返回类模型
/// </summary>
public class ResultModel : IResultModel
{
    private string _message;
    private string _code;
    private IEnumerable<ErrorInfo> _error;

    /// <summary>
    /// 是否成功
    /// </summary>
    public bool IsSuccess { get; set; }

    /// <summary>
    ///状态码
    /// </summary>
    public virtual string Code
    {
        get => string.IsNullOrWhiteSpace(_code) ? string.Empty : _code;
        set => _code = value;
    }

    /// <summary>
    /// 消息
    /// </summary>
    public virtual string Message
    {
        get => string.IsNullOrEmpty(_message) ? string.Empty : _message;
        set => _message = value;
    }

    /// <summary>
    /// 错误信息  model验证错误时候输入
    /// </summary>
    public IEnumerable<ErrorInfo> Errors
    {
        get => _error is null ? Enumerable.Empty<ErrorInfo>() : _error;
        set => _error = value;
    }
}

增加校验特性以及一个实现类(仅做演示使用的)

/// <summary>
/// 抽象根特性
/// </summary>
[AttributeUsage(AttributeTargets.All, AllowMultiple = false)]
public abstract class AbstractAttribute : Attribute
{
    /// <summary>
    /// 抽象验证方法
    /// </summary>
    /// <param name="oValue"></param>
    /// <returns></returns>
    public abstract IResultModel Validate(object oValue);
}

/// <summary>
/// 最小值校验特性 继承抽象特性,并重写Validate方法
/// </summary>
[AttributeUsage(AttributeTargets.All, AllowMultiple = false)]
public class ValidMinValue2Attribute : AbstractAttribute
{
    private readonly string _errorMessage;
    private readonly int _minValue;

    public ValidMinValue2Attribute(int minValue, string errorMessage = "最小值不符合要求")
    {
        _errorMessage = errorMessage;
        _minValue = minValue;
    }

    public override IResultModel Validate(object oValue)
    {
        if (oValue == null)
        {
            return new ResultModel
            {
                Code = "400",
                IsSuccess = false,
                Message = "参数无效"
            };
        }
        if (oValue is not int valueAsInt)
        {
            return new ResultModel
            {
                Code = "400",
                IsSuccess = false,
                Message = "参数格式不对"
            };
        }

        if (valueAsInt <= _minValue)
        {
            return new ResultModel
            {
                Code = "400",
                IsSuccess = false,
                Message = _errorMessage
            };
        }

        return new ResultModel
        {
            Code = "200",
            IsSuccess = true,
            Message = string.Empty
        };
    }
}

修改之前的Action过滤器

/// <summary>
/// 模型验证Action过滤器
/// </summary>
public class ModelVerifyFilter : ActionFilterAttribute
{
    // 当不关闭默认的框架验证的时候,进不来这点都已经校验返回了
    //services.Configure<ApiBehaviorOptions>(options => options.SuppressModelStateInvalidFilter = true);
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        //因为我们还包含自定义的校验处理,所以这需要注释
        //if (context.ModelState.IsValid)
        //return;

        var errorResults = new List<ErrorInfo>();
        //默认的模型校验
        foreach (var (key, value) in context.ModelState)
        {
            var result = new ErrorInfo
            {
                Field = key,
            };
            foreach (var error in value.Errors)
            {
                if (!string.IsNullOrEmpty(result.Message))
                {
                    result.Message += '|';
                }

                result.Message += error.ErrorMessage;
            }

            errorResults.Add(result);
        }

        //自定义的模型校验处理
        foreach (var item in context.ActionArguments)
        {
            var type = item.Value?.GetType();
            if (type?.IsClass != true)
            {
                continue;
            }
            foreach (var property in type.GetProperties().Where(t => t.IsDefined(typeof(AbstractAttribute), true)))
            {
                foreach (AbstractAttribute attribute in property.GetCustomAttributes<AbstractAttribute>())
                {
                    var value = property.GetValue(item.Value);
                    if (value == null)
                        continue;

                    var info = attribute.Validate(value);
                    if (info.IsSuccess)
                        continue;
                    var result = new ErrorInfo
                    {
                        Field = property.Name,
                        Message = info.Message
                    };
                    errorResults.Add(result);
                }
            }
        }
        if (errorResults.Count == 0)
            return;

        context.Result = new BadRequestObjectResult(new ResultModel
        {
            Code = StatusCodes.Status400BadRequest.ToString(),
            Errors = errorResults,
            Message = "参数格式不正确"
        });
    }

    public override void OnActionExecuted(ActionExecutedContext context)
    {
    }
}

当然这里别忘了关闭默认的模型校验

//关闭模型校验
services.Configure<ApiBehaviorOptions>(options => options.SuppressModelStateInvalidFilter = true);

上面的准备工作结束,下面正式开始

编写接口测试

我们有一个接口AddUserGrade2,该接口标注了过滤器ModelVerifyFilter,当然你如果想可以全局配置。

/// <summary>
/// 考虑通过过滤器的方式增加自定义校验
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost]
[ModelVerifyFilter]
public IResultModel<int> AddUserGrade2(AddUserGradeVm2 request)
{
    _logger.LogInformation($"用户ID:{request.UserId} 科目{request.Subject} 成绩是:{request.Grade}");

    return ResultModel<int>.Success(1);
}

我们的入参AddUserGradeVm2如下

/// <summary>
/// 添加用户成绩请求类
/// </summary>
public class AddUserGradeVm2
{
    /// <summary>
    /// 用户ID
    /// </summary>
    [ValidMinValue2(1, "用户ID必须大于等于1")]
    [Required]
    public int UserId { get; set; }

    /// <summary>
    /// 科目ID
    /// </summary>
    [Required]
    [ValidMinValue2(1, "科目ID必须大于等于1")]
    public int Subject { get; set; }

    /// <summary>
    /// 成绩
    /// </summary>
    [Required]
    public double Grade { get; set; }

    /// <summary>
    /// 备注
    /// </summary>
    [MinLength(2)]
    [Required]
    public string Remark { get; set; }
}

这个里面包含了内置的模型校验MinLength以及我们自定义的ValidMinValue2,下面开始演示

示例1:

当我们入参为

{
  "userId": 0,
  "subject": 0,
  "grade": 0,
  "remark": ""
}

返回值为

{
    "isSuccess": false,
    "code": "400",
    "message": "参数格式不正确",
    "errors": [
        {
            "field": "Remark",
            "message": "The Remark field is required.|The field Remark must be a string or array type with a minimum length of '2'."
        },
        {
            "field": "UserId",
            "message": "用户ID必须大于等于1"
        },
        {
            "field": "Subject",
            "message": "科目ID必须大于等于1"
        }
    ]
}

示例2:

当我们入参为

{
  "userId": 0,
  "subject": 0,
  "grade": 0,
  "remark": "123"
}

返回值为

{
    "isSuccess": false,
    "code": "400",
    "message": "参数格式不正确",
    "errors": [
        {
            "field": "UserId",
            "message": "用户ID必须大于等于1"
        },
        {
            "field": "Subject",
            "message": "科目ID必须大于等于1"
        }
    ]
}

示例3:

当我们入参为:

{
  "userId": 716,
  "subject": 10,
  "grade": 99.9,
  "remark": "123"
}

返回值为

{
    "data": 1,
    "isSuccess": true,
    "code": "200",
    "message": "success",
    "errors": []
}

上面已经实现了我们的需要,自定义模型校验,并且兼具通用性。

总结

这两个方法都可以实现做自定义模型校验,第一种直接继承ValidationAttribute实现起来更快,第二种的话感觉可扩展性更高。具体选择根据实际情况选择。

最后感谢歪老师和二胖老师提供的思路。