ASP.NET Core 定制

Web 开发是 .NET 最广泛使用的场景之一,现在越来越多的企业 Web 开发业务系统采用 ASP.NET Core 或第三方框架如 ABP 等进行开发,这些第三方框架绝大多数是基于 ASP.NET Core 进行开发的,当然每个企业或每个开发者对框架的需求都不一样,有的人觉得 ABP 满足了大多数需求,有的人觉得 ABP 太重了,也有很多人选择自己基于 ASP.NET Core 开发新的 Web 开发框架。由于 ASP.NET Core 是 .NET 日常开发中最常是使用的框架,因此本章主要讲解如何定制 Web,以便满足不同的需求。

创建 Maomi.Web.Core 项目,添加对 Maomi.Core 的引用,接着创建模块类 MaomiWebModule.cs。

    public class MaomiWebModule : IModule
    {
        public void ConfigureServices(ServiceContext context)
        {
        }
    }

定制统一响应模型

无论是报错还是模型验证失败、请求成功等,都要设置一定的响应体格式,方便前端或客户端使用。

在编程中,常见 return 缩写为 ret,result 缩写为 res,不过在 C# 中并不提倡缩写,不过考虑到响应模型类使用太频繁,为了便于使用,可以将响应模型类设置为 Res。

// 响应模型类
public class Res<T>
{
    // 当前请求是否有错误
    public virtual bool IsSuccess => Code == 200;

    // 业务代码
    public virtual int Code { get; set; }

    // 响应消息
    public virtual string Msg { get; set; }

    // 返回数据
    public virtual T? Data { get; set; }
}

// 响应模型类
public partial class Res : Res<object?>
{
    // 创建 Res
    public static Res<T> Create<T>(int code, string message, T data)
    {
        return new Res<T>
        {
            Code = code,
            Msg = message,
            Data = data
        };
    }

    /// 创建 Res
    /// </summary>
    public static Res<T> Create<T>(HttpStatusCode code, string message, T data) => Create((int)code, message, data);
}

// 分页结果模型类
public class PageRes<T>
{
    /// 当前页
    public virtual int PageNo { get; set; }

    /// 页大小
    public virtual int PageSize { get; set; }

    public virtual T? List { get; set; }
}

// 分页结果模型类
public partial class PageListRes<T> : Res<PageRes<IEnumerable<T>>>
{
}

/// 分页结果模型类
/// <typeparam name="T"></typeparam>
public partial class PageArrayRes<T> : Res<PageRes<T[]>>
{
}

模型验证

在本节中,我们将来学习如何在控制台使用模型验证以及 ASP.NET Core 中的模型验证是怎么实现的。

标记的特性注解称为 DataAnnotations 数据注释。在使用 ASP.NET Core 时,我们可以通过对模型类设置 [EmailAddress][Required] 等特性注解,用户的数据请求便会被 ASP.NET Core 拦截并验证其模型是否正确。

模型验证是如何实现的

实际上,模型验证也可以脱离 ASP.NET Core 使用,我们可以新建控制台。

示例项目在 Demo10.ConsoleDataAnnotations 中。

首先定义一个模型类:

public class UserInfo
{
    [Required]
    [EmailAddress]
    public string Email { get; set; }
}

然后创建一个验证器方法:

private static (bool IsValid, IReadOnlyList<ValidationResult> ValidationResult) VerifyModel(object o)
{
    var result = new List<ValidationResult>();
    var validationContext = new ValidationContext(o);
    var isValid = Validator.TryValidateObject(o, validationContext, result, validateAllProperties: true);
    return (isValid, result);
}

ValidationContext:模型验证检查上下文。

Validator.TryValidateObject:使用验证上下文和验证结果集合确定指定对象是否有效。

然后,使用它:

static void Main()
{
    var json = """
            {
              "Email":"aaa#qq.com"
            }
            """;
    var userInfo = System.Text.Json.JsonSerializer.Deserialize<UserInfo>(json);

    var (isValid, result) = VerifyModel(userInfo);
    if (!isValid)
    {
        foreach (var item in result)
        {
            Console.WriteLine($"{item.MemberNames.First()}:{item.ErrorMessage}");
        }
    }
}

这段代码运行之后,会输出:

Email:The Email field is not a valid e-mail address.

其实可以看到,模型验证最核心的是使用 ValidationContext 和 Validator.TryValidateObject 对模型的属性字段进行检查,然后获取检查结果。

ASP.NET Core 本质也是控制台,API 接口的模型类进行模型验证时的机制也差不多如此,例如当用户发起一个 application/json 请求时,首先从 Body 中取出 json 反序列化为对象,此过程可以配置是否忽略大小写、字符串和枚举或数字互转等,在反序列化时,如果有报错,则会抛出异常。接着会对模型类进行验证,如果验证失败,则会返回 400 状态码。

  • 下面是一些内置的验证属性:

    • [ValidateNever] :不验证此属性。
    • [CreditCard]:是否为信用卡格式。
    • [Compare]: 对比模型中两个字段值是否一致。
    • [EmailAddress] :属性为电子邮件格式 。
    • [Phone]:属性为电话号码格式。
    • [Range]:属性在值范围之内。
    • [RegularExpression]:使用正则表达式验证属性。
    • [Required]:该属性必填。
    • [StringLength]:限制字符串的最大长度。
    • [Url]:属性为 url 地址格式。

所有的模型验证类都继承了 ValidationAttribute,所以也可以直接通过模型验证类的 IsValid 方法获取验证结果。

示例:

bool isValid = new EmailAddressAttribute().IsValid(userInfo);

自定义模型验证器

处理 .NET 自带的模型验证特性外,我们也可以自定义特性类。在本小节中,笔者将会介绍如何实现一个模型验证特性。

示例项目在 Demo10.ConsoleDataAnnotations 中。

假设的场景如下,我们需要在邮件地址字段中,检查邮件后缀是否为 @maomi.com

自定义模型验证器主要是重写两个 IsValid,示例代码如下:

public class MaomiEmailAttribute : ValidationAttribute
{
    public override bool IsValid(object? value)
    {
        if (value is string email)
        {
            return email.EndsWith("@maomi.com");
        }
        return false;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        // 成员名称
        var memberName = validationContext.MemberName;
        // [DisplayName] 定义的名称
        var displayName = validationContext.DisplayName;
        // 实例对象
        var obj = validationContext.ObjectInstance;
        if (this.IsValid(value))
        {
            return ValidationResult.Success;
        }
        string[] memberNames = memberName != null ? new string[] { memberName } : null;
        return new ValidationResult(this.FormatErrorMessage(validationContext.DisplayName), memberNames);
    }
    public override string FormatErrorMessage(string name)
    {
        return $"{name} 不是 @maomi.com 邮箱后缀!";
    }
}

然后新建 UserInfoMaomi 模型类:

public class UserInfoMaomi
{
    [Required]
    [EmailAddress]
    [MaomiEmail]
    public string Email { get; set; }
}

将 json 字符串改成:

var json = """
            {
              "Email":"aaa@qq.com"
            }
            """;
var userInfoMaomi = System.Text.Json.JsonSerializer.Deserialize<UserInfoMaomi>(json);
var (isValid2, result2) = VerifyModel(userInfoMaomi);

由于我们使用 VerifyModel 方法,验证模型使用的是 Validator.TryValidateObject,因此调用模型验证特性类,会使用 IsValid(object?, ValidationContext) 重载。

IsValid(object? value) 重载,我们可以单独针对一个值来使用:

new MaomiEmailAttribute().IsValid("aaa@qq.com")

模型验证器使用 i18n

ASP.NET Core 中模型验证器打印的字符串默认都是英文,我们在业务中往往需要多语言以及自定义模型验证特性,因此有必要让模型验证也支持 i18n。

在 Maomi.Web.Core 中添加 Maomi.I18n 引用。

我们在 Maomi.I18n 中添加一个扩展类型,用来为 API 注入 I18n 扩展服务。

public static class DataAnnotationsExtensions
{
    // 为 API 模型验证注入 i18n 服务
    public static IMvcBuilder AddI18nDataAnnotation(this IMvcBuilder builder)
    {
        builder.Services.AddTransient<IStringLocalizerFactory, I18nStringLocalizerFactory>();
        builder
            .AddDataAnnotationsLocalization(options =>
            {
                options.DataAnnotationLocalizerProvider = (modelType, stringLocalizerFactory) =>
                stringLocalizerFactory.Create(modelType);
            });
        return builder;
    }
}

注入模型验证本地化服务之后,框架会从此模型类所在的程序集中取出本地化语言字符串。

在 MaomiWebModule 中添加 i18n 服务,以及为 API 注入模型验证服务。

public class MaomiWebModule : IModule
{
    public void ConfigureServices(ServiceContext context)
    {
        // i18n 服务
        context.Services.AddI18n("zh-CN");
        context.Services.AddI18nResource(options =>
        {
            options.AddJson<MaomiWebModule>("i18n");
        });

        // 添加控制器
        context.Services.AddControllers()
            .AddI18nDataAnnotation();
    }
}

然后创建名为 Demo10.ApiDataAnnotations 的 API 服务,添加对 Maomi.Web.Core 的引用,项目结构如下。

image-20230824202942462

我们将 Demo10.ApiDataAnnotations 模块化后,只需要在该项目中注入 json 多语言资源服务即可。

[InjectModule<MaomiWebModule>()]
public class ApiModule : IModule
{
    public void ConfigureServices(ServiceContext context)
    {
        context.Services.AddI18nResource(options =>
        {
            options.AddJson<ApiModule>("i18n");
        });
    }
}

然后我们在该项目中创建一个模型类:

public class UserInfo
{
    [EmailAddress(ErrorMessage = "邮箱地址格式不正确")]
    public string Email { get; set; }
}

因为 UserInfo 模型类在 Demo10.ApiDataAnnotations 项目中,因此我们需要在 Demo10.ApiDataAnnotations 中创建 json 多语言资源文件,目录在 i18n/Demo10.ApiDataAnnotations 下。

json 文件示例如下:

zh-CN.json

{
  "邮箱地址格式不正确": "邮箱地址格式不正确"
}

en-US.json

{
  "邮箱地址格式不正确": "The email address format is incorrect"
}

然后创建一个简单的 API 接口进行测试:

    [ApiController]
    [Route("[controller]")]
    public class IndexController : ControllerBase
    {
        [HttpPost("user")]
        public string GetUserName([FromBody] UserInfo info)
        {
            return info.Email.Split("@").FirstOrDefault();
        }
    }

然后对 API 发出请求,分别在 email 字段输入不同的内容。

image-20230415163738744

image-20230415163702793

使用 i18n 服务之后,模型验证错误会随着语言标识而变化。

筛选器

我们知道,ASP.NET Core 的功能是由一系列中间件管道提供的,当 API 接收一个 http 请求时,请求上下文会经过管道中各种中间件的处理,最后到达 Action 中。

ASP.NET Core 管道中默认中间件的排序顺序如下图所示:

中间件

ASP.NET Core 中提供了筛选器,可以帮助我们在部分中间件的前后执行一系列的操作。

过滤器

Action 筛选器

在 ASP.NET Core 中,当模型验证失败时,会返回如下格式的内容。

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-4fcae233f0ff7c4de765680f5ee8b8b8-67b2c0d9a41224c6-00",
    "errors": {
        "Email": [
            "The email address format is incorrect"
        ]
    }
}

但是在项目开发中,我们往往需要固定一种格式,比如本章开头就设置了统一的响应模型类 ResRes<T>

在 Maomi.Web.Core 中,依次创建 i18n、Maomi.Web.Core 目录,添加 zh-CN.json、en-US.json 两个文件,内容分别如下:

zh-CN.json

{
  "400": "发生了一个或多个验证错误"
}

en-US.json

{
  "400": "One or more validation errors occurred."
}

设置两个文件的生成属性为 “始终复制”。

image-20240215105836674

为了通过项目引用、nuget 包引用时,文件会自动复制到启动项目运行目录中,需要手动配置多语言文件属性:

    <ItemGroup>
        <None Update="i18n\Maomi.Web.Core\en-US.json" Pack="true" PackagePath="contentFiles\any\any;">
            <PackageCopyToOutput>true</PackageCopyToOutput>
        </None>
    </ItemGroup>
    <ItemGroup>
        <None Update="i18n\Maomi.Web.Core\zh-CN.json" Pack="true" PackagePath="contentFiles\any\any;">
            <PackageCopyToOutput>true</PackageCopyToOutput>
        </None>
    </ItemGroup>

由于 Microsoft.NET.Sdk.Web SDK 在编译 Web 项目时,会自动配置 json 文件属性,这样一来会导致与手动配置的多语言文件属性配置冲突,因此需要关掉该功能。

<EnableDefaultContentItems>false</EnableDefaultContentItems>

Action 筛选器在执行 Action 之前或之后被框架自动调用,用来拦截请求或处理 Action 返回的结果。Action 筛选器有两种实现方法,第一种是实现 IActionFilter 接口,然后依赖注入配置控制器时通过 .AddControllers() 扩展方法配置全局。第二种使用特性注解,单独给 Controller 或 Action 使用。

使用特性注解配置 Action,需要继承 ActionFilterAttribute 特性,简化我们编写筛选器的代码。

ActionFilterAttribute 的定义如下:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public abstract class ActionFilterAttribute :
    Attribute,
    IActionFilter,
    IFilterMetadata,
    IAsyncActionFilter,
    IResultFilter,
    IAsyncResultFilter,
    IOrderedFilter
{
    protected ActionFilterAttribute();

    public int Order { get; set; }

    public virtual void OnActionExecuted(ActionExecutedContext context);
    public virtual void OnActionExecuting(ActionExecutingContext context);
    public virtual Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next);
    public virtual void OnResultExecuted(ResultExecutedContext context);
    public virtual void OnResultExecuting(ResultExecutingContext context);
    public virtual Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next);
}

在 Maomi.Web.Core 中创建一个名为 MaomiActionFilter 的筛选器,同时继承 ActionFilterAttribute、IActionFilter,MaomiActionFilter 筛选器使用时需要注入相关的依赖。

// Action 过滤器
[InjectOn(Scheme = InjectScheme.None, Own = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class MaomiActionFilter : ActionFilterAttribute
{
    private readonly IStringLocalizer<ActionFilter> _localizer;
    public ActionFilter(IStringLocalizer<ActionFilter> stringLocalizer)
    {
        _localizer = stringLocalizer;
    }

    public override void OnResultExecuting(ResultExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            Dictionary<string, List<string>> errors = new();
            foreach (var item in context.ModelState)
            {
                List<string> list = new();
                foreach (var error in item.Value.Errors)
                {
                    list.Add(error.ErrorMessage);
                }
                errors.Add(item.Key, list);
            }
            context.Result = new BadRequestObjectResult(R.Create(400, _localizer["400"], errors));
        }
    }
}

MaomiActionFilter 使用了自动注册特性,我们只注册 MaomiActionFilter,而不包含 ActionFilterAttribute、IActionFilter。

然后在 Demo10.ApiDataAnnotations 项目中的 IndexController 中加上 MaomiActionFilter 特性。

[ApiController]
[Route("[controller]")]
public class IndexController : ControllerBase
{
    [ServiceFilter(typeof(MaomiActionFilter))]
    [HttpPost("user")]
    public string GetUserName([FromBody] UserInfo info)
    {
        return info.Email.Split("@").FirstOrDefault();
    }
}

启动 Web 程序,请求该接口,模型验证错误的结果使用固定格式响应。

image-20230826092909288

ActionFilterAttribute 可以用在 Action 上,也可以用在 Controller 上,在 Controller 配置时,对所有 Action 起效。

[ServiceFilter(typeof(MaomiActionFilter))]
[ApiController]
[Route("[controller]")]
public class IndexController : ControllerBase
{
}

因为 MaomiActionFilter 需要进行依赖注入,需要使用 ServiceFilter 配置筛选器,如果自定义的 ActionFilterAttribute 并不需要使用依赖注入,那么可以直接使用。

[MyActionFilter]
[HttpPost("user")]
public string GetUserName([FromBody] UserInfo info)
{
    return info.Email.Split("@").FirstOrDefault();
}

我们也可以使用全局配置:

            context.Services.AddControllers(options =>
            {
                // 不需要依赖注入的筛选器
                // options.Filters.Add<MyActionFilter>();
                // 需要依赖注入的筛选器
                options.Filters.AddService<MaomiActionFilter>();
            })

我们将 Maomi.Web.Core.MaomiWebModule 改造一下。

    public class MaomiWebModule : IModule
    {
        public void ConfigureServices(ServiceContext context)
        {
            // i18n 服务
            context.Services.AddI18n("zh-CN");
            context.Services.AddI18nResource(options =>
            {
                options.AddJson<MaomiWebModule>("i18n");
            });

            // 添加控制器
            context.Services.AddControllers(options =>
            {
                options.Filters.Add<MaomiActionFilter>();
                options.Filters.AddService<MaomiActionFilter>();
            })
                .AddI18nDataAnnotation();
        }
    }

现在,Maomi.Web.Core 已经具备了本地化多语言、模型验证信息多语言、统一模型验证输出三个功能了。

资源筛选器

资源筛选器有 IResourceFilter 或 IAsyncResourceFilter 两种接口,接口定义如下:

public interface IResourceFilter : IFilterMetadata
{
    void OnResourceExecuted(ResourceExecutedContext context);
    void OnResourceExecuting(ResourceExecutingContext context);
}
public interface IAsyncResourceFilter : IFilterMetadata
{
    Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next);
}

资源筛选器发生在模型绑定、Action 之前,因此可以提前做预检查提前拦截非正常请求等。对于某些情况下 API 要求携带 header 或者其他参数时,在资源筛选器处理会比在 API 中处理好,减少了一些过程,节约了资源。

假设有这么一个场景,我们需要将 API 的结果缓存到 Redis 中,当用户请求时,如果 Redis 中有缓存则直接返回,不需要再进入 Controller、Action 中。如果换成不存在,则执行 Action 并返回结果,然后自动加入到 Redis 缓存中。

新建名为 Demo10.ResourceFilter 的 Web项目,并新建 CacheResourceFilter 类型,引入 Maomi.Web.Core、FreeRedis 两个依赖。

新建 CacheResourceFilter 筛选器,CacheResourceFilter 使用 {ControllerName}:{ActionName} 作为 key 名称,从 redis 中获取数据,然后直接写入到响应中,不需要再执行 Action 中代码。

[InjectOn(Scheme = InjectScheme.None, Own = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class CacheResourceFilter : Attribute, IAsyncResourceFilter
{
    private readonly IStringLocalizer<CacheResourceFilter> _localizer;
    private readonly IRedisClient _redisClient;
    public CacheResourceFilter(IStringLocalizer<CacheResourceFilter> stringLocalizer, IRedisClient redisClient)
    {
        _localizer = stringLocalizer;
        _redisClient = redisClient;
    }

    public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
    {
        var action = context.ActionDescriptor as Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor;
        ArgumentNullException.ThrowIfNull(action);
        var key = $"{action.ControllerName}:{action.ActionName}";
        var methodInfo = action.MethodInfo;
        var returnResult = methodInfo.ReturnType;
        if (returnResult.GetGenericTypeDefinition() == typeof(Task<>))
        {
            returnResult = returnResult.GetGenericArguments()[0];
        }

        // 如果有缓存
        var text = await _redisClient.GetAsync<string>(key);
        if (!string.IsNullOrEmpty(text))
        {
            var value = System.Text.Json.JsonSerializer.Deserialize(text, returnResult);
            context.Result = new JsonResult(value);
            return;
        }

        var newContext = await next();

        // 记录到缓存中,下次直接从缓存中取出
        var result = newContext.Result as ObjectResult;
        if (result != null)
        {
            await _redisClient.SetAsync(key, result.Value, timeoutSeconds: 10);
        }
    }
}

然后编写一个 API 进行测试:

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    [HttpGet(Name = "get")]
    public IEnumerable<TestModel> Get()
    {
        var data = new List<TestModel>()
            {
                new TestModel(){ Name = "1"},
                new TestModel(){ Name = "2"}
            };
        return data;
    }
}

public class TestModel
{
    public string Name { get; set; }
}

然后配置 API 模块:

[InjectModule<MaomiWebModule>()]
public class ApiModule : IModule
{
    public void ConfigureServices(ServiceContext context)
    {
        context.Services.AddControllers(options =>
        {
            // 这里不会跟 MaomiWebModule 中的筛选器冲突,两者的筛选器会被合起来一起使用
            options.Filters.AddService<CacheResourceFilter>();
        });

        // 配置 FreeRedis
        RedisClient redis = new RedisClient("127.0.0.1:6379,defaultDatabase=0");
        redis.Serialize = obj => System.Text.Json.JsonSerializer.Serialize(obj);
        redis.Deserialize = (json, type) => System.Text.Json.JsonSerializer.Deserialize(json, type);

        context.Services.AddSingleton<IRedisClient, RedisClient>(s => redis);
    }
}

你可以在 CacheResourceFilter 中进行断点调试,你会发现在第一次请求之后,会直接在 Redis 中取得数据,而不需要执行 await next()

为什么笔者不把 CacheResourceFilter 放到 Maomo.Web.Core 中。

因为 ResourceFilter 通用的场景比较少,不必要在模块化框架中混入太多组件,比如上一小节的 ActionFilter 中也仅仅是为了通用返回的模型验证错误的格式。

每个公司可能在过滤器中有不一样的需求,甚至每个项目都有不同的需求,这些很可能在开发中定制需求,那么正在框架之外,编写扩展完善补充即可。

Maomi.Web.Core 框架应当保持简洁,支持基础功能即可。

异常筛选器

异常筛选器主要用于捕获发生在 Action 中的异常。

当 API 出现异常时,我们需要屏蔽异常信息以免泄露敏感信息,以及统一响应模型。

我们在 Maomi.Web.Core 中定义一个异常筛选器 MaomiExceptionFilter,我们需要将异常信息输出到日志中,统一响应结果,并且显示的信息使用 i18n 进行多语言支持。

// 统一异常处理
[InjectOn(Scheme = InjectScheme.None, Own = true)]
public class MaomiExceptionFilter : IAsyncExceptionFilter
{
    private readonly ILogger<MaomiExceptionFilter> _logger;
    private readonly IStringLocalizer<MaomiExceptionFilter> _stringLocalizer;

    // 统一异常处理
    public MaomiExceptionFilter(ILogger<MaomiExceptionFilter> logger, IStringLocalizer<MaomiExceptionFilter> stringLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
    }

    /// 异常处理
    public async Task OnExceptionAsync(ExceptionContext context)
    {
        // 未经处理的异常
        if (!context.ExceptionHandled)
        {
            var action = context.ActionDescriptor as Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor;

            _logger.LogCritical(context.Exception,
                """
                    RequestId: {0}
                    ControllerName: {1}
                    ActionName: {2}
                    """,
                context.HttpContext.TraceIdentifier,
                action?.ControllerName,
                action?.ActionName
                );
            var response = new R()
            {
                Code = 500,
                Msg = _stringLocalizer["500", context.HttpContext.TraceIdentifier],
            };

            context.Result = new ObjectResult(response)
            {
                StatusCode = 500,
            };

            context.ExceptionHandled = true;
        }

        await Task.CompletedTask;
    }
}

然后给 zh-CN.jsonen-US.json 文件分别添加以下内容:

  "500": "服务出现故障,请求 Id:{0}"
  "500": "Service error,Request Id:{0}"

最后在 MaomiWebModule 中添加异常处理筛选器:

// 添加控制器
context.Services.AddControllers(options =>
{
    options.Filters.Add<MaomiActionFilter>();
    options.Filters.AddService<MaomiActionFilter>();
    options.Filters.AddService<MaomiExceptionFilter>();
})
    .AddI18nDataAnnotation();

然后创建一个简单的示例项目 Demo10.ExceptionFilter,引用 MaomiWebModule ,只需要创建一个简单的 API 即可。

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    [HttpGet(Name = "get")]
    public IEnumerable<TestModel> Get()
    {
        throw new Exception("test");
    }
}

public class TestModel
{
    public string Name { get; set; }
}

直接请求 http://localhost:5242/Test,响应:

{
    "isSuccess": false,
    "code": 500,
    "msg": "服务出现故障,请求 Id:0HMT8H2RIMEQ7:00000001",
    "data": null
}

使用多语言请求 http://localhost:5242/Test?culture=en-US&ui-culture=en-US ,响应:

{
    "isSuccess": false,
    "code": 500,
    "msg": "Service error,Request Id:0HMT8H2RIMEQ7:00000002",
    "data": null
}

Swagger 定制

MaomiSwaggerOptions.cs
MaomiSwaggerSchemaFilter.cs
SwaggerExtensions.cs

模型类属性类型处理

在示例项目 Demo9.Swagger 中,有一个模型类:

public class Test
{
    [JsonConverter(typeof(string))]
    public Boolean Value1 { get; set; }  
    ...  
    [JsonConverter(typeof(string))]
    public Int32 Value7 { get; set; }

    [JsonConverter(typeof(string))]
    public UInt32 Value8 { get; set; }

    [JsonConverter(typeof(string))]
    public Int64 Value9 { get; set; }
    ...
}

Test 模型类中包含了多种类型,打开 swagger 后,如下所示:

{
  "value1": true,
  "value2": "string",
  "value3": 0,
  "value4": 0,
  "value5": 0,
  "value6": 0,
  "value7": 0,
  "value8": 0,
  "value9": 0,
  "value": 0,
  "value10": 0,
  "value11": 0,
  "value12": 0,
  "value13": "2024-02-15T03:50:35.891Z",
  "value14": "string"
}

我们希望 swagger 中显示时,能够显示 JsonConverter 中指定的格式。

在 Maomi.Web.Core 中创建 MaomiSwaggerSchemaFilter 类型,继承 ISchemaFilter。

// Swagger 模型类过滤器
public class MaomiSwaggerSchemaFilter : ISchemaFilter
{
    // schema: Swagger 中的属性
    // context: 模型类上下文
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        // 模型类的类型
        var type = context.Type;

        // 如果 API 参数不是对象
        if (type.IsPrimitive || TypeInfo.GetTypeCode(type) != TypeCode.Object)
        {
            return;
        }

        // 如果 API 参数是对象类型

        // 获取类型的所有属性
        PropertyInfo[] ps = context.Type.GetProperties();

        // 获取 swagger 文件显示的所有属性
        // 注意文档属性是已经已经生成的,这里进行后期转换,替换为需要显示的类型
        foreach (var property in schema.Properties)
        {
            var p = ps.FirstOrDefault(x => x.Name.ToLower() == property.Key.ToLower());
            if (p == null) continue;
            var t = property.Value.Type;
            var converter = p.GetCustomAttribute<JsonConverterAttribute>();
            if (converter == null || converter.ConverterType == null) continue;

            var targetType = TypeInfo.GetTypeCode(converter.ConverterType);

            // 如果是基元类型或 Decimal、DateTime
            if (targetType != TypeCode.Empty &&
                targetType != TypeCode.DBNull &&
                targetType != TypeCode.Object)
            {
                if (GetValueType(targetType, out var valueType))
                {
                    property.Value.Type = valueType;
                }
            }
        }

        static bool GetValueType(TypeCode targetType, out string? valueType)
        {
            valueType = null;
            switch (targetType)
            {
                case TypeCode.Boolean: valueType = "boolean"; break;
                case TypeCode.Char: valueType = "string"; break;
                case TypeCode.SByte: valueType = "integer"; break;
                case TypeCode.Byte: valueType = "integer"; break;
                case TypeCode.Int16: valueType = "integer"; break;
                case TypeCode.UInt16: valueType = "integer"; break;
                case TypeCode.Int32: valueType = "integer"; break;
                case TypeCode.UInt32: valueType = "integer"; break;
                case TypeCode.Int64: valueType = "integer"; break;
                case TypeCode.UInt64: valueType = "integer"; break;
                case TypeCode.Single: valueType = "number"; break;
                case TypeCode.Double: valueType = "number"; break;
                case TypeCode.Decimal: valueType = "number"; break;
                case TypeCode.DateTime: valueType = "string"; break;
                case TypeCode.String: valueType = "string"; break;
                // 一般不需要处理对象
                // case TypeCode.Object: valueType = p.PropertyType.Name; break;
                default: return false;
            }
            return true;
        }
    }
}

在使用

services.AddSwaggerGen(options =>
{
    // 模型类过滤器
    options.SchemaFilter<MaomiSwaggerSchemaFilter>();
});

重新打开 swagger 。

{
  "value1": "string",
  "value2": "string",
  "value3": "string",
  "value4": "string",
  "value5": "string",
  "value6": "string",
  "value7": "string",
  "value8": "string",
  "value9": "string",
  "value": "string",
  "value10": "string",
  "value11": "string",
  "value12": "string",
  "value13": "2024-02-15T03:54:45.440Z",
  "value14": "string"
}

接口分组

在 ASP.NET Core 中使用 swagger 时,默认配置如下:

builder.Services.AddSwaggerGen(options =>
{
});
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

使用 swagger 分为两个部分,一个是服务注册,一个是中间件,本节从两个步骤出发讲解如何定制 swagger 去解决业务开发中的一些问题。

由于项目逐渐庞大,API 接口越来越多,所有接口都在一个 swagger 中,导致 swagger.json 文件庞大。第六章中提到过可以使用工具将 swagger.json 生成代码文件,可是如果只需要使用其中一部分接口,却需要生成全部接口代码文件,以及不同模块中可能会有相同名称的接口,这样会导致混乱。因此,需要将 swagger 中的接口进行分组。

[ApiController]
[Route("[controller]")]
[ApiExplorerSettings(GroupName = "控制器E")]
public class EController : ControllerBase
{
}

效果如下:

image-20240216075931954

本节示例代码请参考 Demo9.Swagger 项目。主要原理是通过注入 swagger 服务,将已经扫描的接口整理出来,对每个接口进行分组划分,如果接口没有设置 [ApiExplorerSettings] 特性,则放到默认分组中。

创建模型类保存分组信息。

// swagger 配置
public class MaomiSwaggerOptions
{
    /// 默认分组名称
    public string DefaultGroupName { get; set; } = "default";

    /// 默认标题
    public string DefaultGroupTitle { get; set; } = "default";
}

分组管理的代码较为复杂,作用是对接口进行分组,分组后 swagger 框架会给每个分组生成一个单独的 swagger.json 文件,其示例代码如下:

// 配置每个分组中有哪些 Action
void BuildGroupApis(SwaggerGenOptions swaggerGenOptions, MaomiSwaggerOptions maomiSwaggerOptions)
{
    // docname == GroupName
    swaggerGenOptions.DocInclusionPredicate((string docname, ApiDescription apiDescription) =>
    {
        if (!apiDescription.TryGetMethodInfo(out MethodInfo methodInfo)) return false;
        // 属于默认分组
        if (docname == maomiSwaggerOptions.DefaultGroupName && apiDescription.GroupName == null)
        {
            return true;
        }

        return apiDescription.GroupName == docname;
    });
}


// 1,这里注入
builder.Services.AddSwaggerGen(options =>
{
    var ioc = builder.Services.BuildServiceProvider();
    // IApiDescriptionGroupCollectionProvider 提供对程序中所有 ApiDescriptionGroup 对象的访问,
    // ApiDescriptionGroup 记录 Controller 的分组描述信息
    var descriptionProvider = ioc.GetRequiredService<IApiDescriptionGroupCollectionProvider>();
    var apiVersionoptions = ioc.GetRequiredService<IOptions<ApiVersioningOptions>>();
    var maomiSwaggerOptions = ioc.GetRequiredService<IOptions<MaomiSwaggerOptions>>();

    HashSet<Assembly> ApiAssemblies = new();
    // 配置分组信息
    // Items 是根据 ApiExplorerSettings.GroupName 进行分组的
    foreach (var description in descriptionProvider.ApiDescriptionGroups.Items)
    {
        // 如果 Controller 没有配置分组,则放到默认分组中
        if (description.GroupName == null)
        {
            options.SwaggerDoc(maomiSwaggerOptions.Value.DefaultGroupName, new OpenApiInfo
            {
                // 分组默认的 Api 版本号
                Version = apiVersionoptions.Value.DefaultApiVersion.ToString(),
                Title = maomiSwaggerOptions.Value.DefaultGroupTitle
            });

            // 保存每个 Action 反射的 MethodInfo
            foreach (var item in description.Items)
            {
                if (item.TryGetMethodInfo(out var methodInfo))
                {
                    var assembly = methodInfo.DeclaringType?.Assembly;
                    if (assembly != null) ApiAssemblies.Add(assembly);
                }
            }
        }
        else
        {
            options.SwaggerDoc(description.GroupName, new OpenApiInfo
            {
                Version = apiVersionoptions.Value.DefaultApiVersion.ToString(),
                Title = description.GroupName,
            });
        }
    }

    // 加载所有控制器对应程序集的文档
    var dir = new DirectoryInfo(AppContext.BaseDirectory);
    var files = dir.GetFiles().Where(x => x.Name.EndsWith(".xml")).ToArray();
    foreach (var item in files)
    {
        // 如果 Controller 程序集的 xml 文件存在,则加载
        if (ApiAssemblies.Any(x => item.Name.Equals(x.GetName().Name + ".xml", StringComparison.CurrentCultureIgnoreCase)))
            options.IncludeXmlComments(item.FullName);
    }

    // 构建分组信息
    BuildGroupApis(options, maomiSwaggerOptions.Value);
});

最后还需要配置中间件,指定每个分组指向的 swagger.json 位于何处。

public static IApplicationBuilder UseMaomiSwagger(this IApplicationBuilder app)
{
    var ioc = app.ApplicationServices;
    var descriptionProvider = ioc.GetRequiredService<IApiDescriptionGroupCollectionProvider>();
    var maomiSwaggerOptions = ioc.GetRequiredService<IOptions<MaomiSwaggerOptions>>();

    app.UseSwagger();

    app.UseSwaggerUI(options =>
    {
        // 是否将该接口放到默认分组中
        bool haveDefault = false;

        // 配置页面显示和使用哪些位置的 swagger.json 文件
        foreach (var description in descriptionProvider.ApiDescriptionGroups.Items)
        {
            if (description.GroupName == null)
            {
                haveDefault = true;
                continue;
            }
            options.SwaggerEndpoint($"{description.GroupName}/swagger.json", description.GroupName);
        }

        // 有默认不带分组的
        if (haveDefault)
        {
            options.SwaggerEndpoint($"{maomiSwaggerOptions.Value.DefaultGroupName}/swagger.json", maomiSwaggerOptions.Value.DefaultGroupName);
        }
    });

    return app;
}

接口版本号

随着项目迭代,接口越来越多,为了不影响已经对接的项目,同一个接口就会出现多个版本号。

只需要在 Controller 或 Action 上添加 [ApiVersion("1.0")] 特性注解即可。

image-20240216091143355

第一步,配置 ASP.NET Core 中关于接口版本号和 [ApiVersion] 的处理。

// 配置 Api 版本信息
builder.Services.AddApiVersioning(setup =>
{
    // 全局默认 api 版本号
    setup.DefaultApiVersion = new ApiVersion(1, 0);
    // 用户请求未指定版本号时,使用默认版本号
    setup.AssumeDefaultVersionWhenUnspecified = true;
    // 响应时,在 header 中返回版本号
    setup.ReportApiVersions = true;
    // 从哪里读取版本号信息
    setup.ApiVersionReader =
    ApiVersionReader.Combine(
       new HeaderApiVersionReader("X-Api-Version"),
       new QueryStringApiVersionReader("version"));
});

// 在 swagger 中显示版本信息,
// 进一步使用版本号进行隔分
builder.Services.AddVersionedApiExplorer(o =>
{
    // 获取或设置版本参数到 url 地址中
    o.SubstituteApiVersionInUrl = true;
    // swagger 页面默认填入的版本号
    o.DefaultApiVersion = new ApiVersion(1, 0);
    // 显示的版本分组格式
    o.GroupNameFormat = "'v'VVV";
});

第二步,配置 swagger 生成对应版本号文档。

builder.Services.AddSwaggerGen(options =>
{
    var ioc = builder.Services.BuildServiceProvider();
    var apiVersionDescriptionProvider = ioc.GetRequiredService<IApiVersionDescriptionProvider>();
    var apiVersionoptions = ioc.GetRequiredService<IOptions<ApiVersioningOptions>>();
    foreach (var item in apiVersionDescriptionProvider.ApiVersionDescriptions)
    {
        // 给每个版本号创建 swagger.json 
        options.SwaggerDoc(item.GroupName, new OpenApiInfo
        {
            Version = apiVersionoptions.Value.DefaultApiVersion.ToString(),
            Title = item.GroupName,
        });
    }
});

第三步,配置 swagger 生成对应下拉框选项列表。

// 配置 ui
app.UseSwaggerUI(options =>
{
    var ioc = app.Services;
    var apiVersionDescriptionProvider = ioc.GetRequiredService<IApiVersionDescriptionProvider>();
    var descriptions = apiVersionDescriptionProvider.ApiVersionDescriptions;

    foreach (var description in apiVersionDescriptionProvider.ApiVersionDescriptions)
    {
        var url = $"/swagger/{description.GroupName}/swagger.json";
        var name = description.GroupName.ToUpperInvariant();
        options.SwaggerEndpoint(url, name);
    }
});

由于 swagger 定制起来比较复杂,因此需要将其封装到框架中的 SwaggerExtensions.cs 文件中,使用时只需要很简单的代码即可,示例项目可参考 Demo9.MaomiSwagger。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
// 1,这里注入
builder.Services.AddMaomiSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    // 2,这里配置中间件
    app.UseMaomiSwagger();
    });
}

image-20240216093753981

路由后缀

在微服务场景下,许多服务会共用一个域名,然后使用不同的路由后缀指向对应的服务,比如 https://localhost/a、 https://localhost/b ,不过默认 swagger ui 中请求时,使用了绝对路径,因此不会自动使用这些路由后缀参数。为了能够让 swagger ui 在微服务下能够支持不同路由后缀请求,需要对此进行配置。

// 2,这里配置中间件
app.UseMaomiSwagger(setupAction: setup =>
{
    setup.PreSerializeFilters.Add((swagger, httpReq) =>
    {
        swagger.Servers = new List<OpenApiServer>
        {
            new  (){ Url = $"{httpReq.Scheme}://{httpReq.Host.Value}/mya" },
            new  (){ Url = $"{httpReq.Scheme}://{httpReq.Host.Value}/myb" }
        };
    });
});

image-20240216094548214

Copyright © 痴者工良 2024 all right reserved,powered by Gitbook文档最后更新时间: 2024-03-31 08:27:49

results matching ""

    No results matching ""