为什么需要写日志

不管是写接口,还是写 MVC 程序,我们都必须对程序中的每个异常做记录,这样才能知道哪里发生了错误,以便更好地改进代码。拿 Asp.Net Core WebApi 接口项目来说,最笨的方法,就是在每个 Action 的主要逻辑中加上 try catch 代码块来捕获异常,然后写对应的日志,但这不是一个合格的程序员做得出来的事情——那么多的接口,若每个都加 try catch 代码块,简直会疯。好在 .net core 有对应的方法来简化这些逻辑。

如何优雅地写日志

总则:与业务代码低耦合。

写日志本身,并不属于业务的一部分,因此我们在写日志时,不应该与主要业务逻辑代码强耦合,这也是上面举例中不合适的一个原因。

捕获 Action 执行异常

对于 Action 执行的异常,一般有两种常用方式:分别使用中间件过滤器

下图是官方用来解释过滤器在 HTTP 请求管道中的位置,可以看到过滤器是在中间件执行之后才会到达。

当然,过滤器也不只一种,而是由很多种,下面是从网上找来的一张表示过滤器执行顺序的图片:

现在来看一下这两种方法怎么实现。

使用中间件(Middleware)

在 .net core 中,请求管道就是由一系列的中间件组成,我们可以在其中任何一个节点,使用自定义的中间件,来拦截请求。每个中间件中,都有一个 Invoke 方法,该方法接收一个请求上下文对象 HttpContext 的参数,在该方法中,我们可以自定义对请求的处理逻辑。另外还有一个 RequestDelegate 类型的委托 next,该委托指向下一步中间件,在当前中间件执行完成后,不要忘记调用 next() 委托,此时管道会流转到下一步中间件中,如果这里没有调用 next(),整个请求将会被短路。

我们注册一个名为 ErrorHandlingMiddleware 的类,按照上面的说明,在类中声明一个 Invoke 方法,以及一个 RequestDelegate 类型的委托 next

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/// <summary>
/// 异常处理中间件
/// </summary>
public class ErrorHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ErrorHandlingMiddleware> _logger;
    private readonly IWebHostEnvironment _env;

    /// <summary>
    /// 构造方法
    /// </summary>
    /// <param name="next">调用下一步中间件的委托</param>
    /// <param name="logger">日志对象</param>
    /// <param name="env">程序环境</param>
    public ErrorHandlingMiddleware(RequestDelegate next,
        ILogger<ErrorHandlingMiddleware> logger,
        IWebHostEnvironment env)
    {
        _next = next;
        _logger = logger;
        _env = env;
    }

    /// <summary>
    /// 执行当前中间件
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    /// <summary>
    /// 异常处理
    /// </summary>
    /// <param name="context"></param>
    /// <param name="exception"></param>
    /// <returns></returns>
    private Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        // 记录日志
        _logger.LogError(new EventId(0), exception, exception.Message);
        _logger.LogDebug(exception.StackTrace);
        context.Response.ContentType = "application/json";

        var serializerSetting = new JsonSerializerSettings
        {
            // 首字母小写
            ContractResolver = new CamelCasePropertyNamesContractResolver()
        };

        if (exception is ApiBaseException apiException)
        {
            context.Response.StatusCode = (int)apiException.HttpCode;

            return context.Response.WriteAsync(JsonConvert.SerializeObject(new ErrorOutput
            {
                Code = apiException.ErrorCode,
                Status = apiException.Status,
                Message = apiException.Message,
                Details = apiException?.Details
            }, serializerSetting));
        }
        else
        {
            context.Response.StatusCode = 500;
            return context.Response.WriteAsync(JsonConvert.SerializeObject(new ErrorOutput
            {
                Code = InternalErrorCode.InternalServerError,
                Status = "INTERNAL_SERVER_ERROR",
                Message = _env.IsDevelopment() ? exception.GetBaseException().Message : "Internal server error, please contact websites maintainer.",
                Details = _env.IsDevelopment()
                ? new List<string> { exception.StackTrace }
                : null
            }, serializerSetting));
        }
    }
}

然后在 Startup 类的 Configure 方法中,使用该中间件:

1
2
3
4
5
6
7
public void Configure(IApplicationBuilder app)
{
    // 其他中间件...
    // ...
    // 使用自定义异常处理中间件
    app.UseMiddleware<ErrorHandlingMiddleware>();
}

当然也可以自定义一个扩展方法,然后像使用原生中间件一样使用自定义中间件,形如:app.UseMyErrorHandling();,这里就不展开。

使用异常过滤器(ExceptionFilterAttribute)

过滤器其实是面向切面编程(Aspect Oriented Programming,AOP)的一种实现方式。它也是在请求发生时,对请求进行拦截,感官上和中间件的作用类似,可以看作是功能精细化后的一个中间件(即专门用来处理请求异常)。我们自定义一个名为 ErrorHandlingFilterAttribute 的类,它必须继承官方的 ExceptionFilterAttribute 类,然后重写 OnException 方法,在 OnException 方法中来实现我们对异常的处理逻辑即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/// <summary>
/// 异常处理过滤器
/// </summary>
public class ErrorHandlingFilterAttribute : ExceptionFilterAttribute
{
    private readonly ILogger<ErrorHandlingFilterAttribute> _logger;
    private readonly IWebHostEnvironment _webHostEnvironment;

    /// <summary>
    /// 构造方法
    /// </summary>
    /// <param name="logger">日志对象</param>
    /// <param name="webHostEnvironment">程序使用环境</param>
    public ErrorHandlingFilterAttribute(ILogger<ErrorHandlingFilterAttribute> logger,
        IWebHostEnvironment webHostEnvironment)
    {
        _logger = logger;
        _webHostEnvironment = webHostEnvironment;
    }

    /// <summary>
    /// 异常发生时
    /// </summary>
    /// <param name="context"></param>
    public override void OnException(ExceptionContext context)
    {
        // 记录日志
        _logger.LogError(new EventId(0), context.Exception, context.Exception.ToString());

        // 将请求上下文中的异常是否已处理置为 true
        context.ExceptionHandled = true;

        // 自定义异常基类
        if (context.Exception is ApiBaseException exception)
        {
            context.HttpContext.Response.StatusCode = (int)exception.HttpCode;
            context.Result = new JsonResult(new ErrorOutput()
            {
                Code = exception.ErrorCode,
                Status = exception.Status,
                Message = exception.Message,
                Details = exception?.Details
            });
        }
        else
        {
            context.HttpContext.Response.StatusCode = 500;
            context.Result = new JsonResult(new ErrorOutput()
            {
                Code = InternalErrorCode.InternalServerError,
                Status = "INTERNAL_SERVER_ERROR",
                Message = context.Exception.GetBaseException().Message,
                Details = _webHostEnvironment.IsDevelopment()
                ? new List<string>() { context.Exception.StackTrace }
                : null
            });
        }
    }
}

然后需要在 Startup 类的 ConfigureServices 方法中,使用该过滤器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public void ConfigureServices(IServiceCollection services)
{
    // 其他服务...
    // ...
    // 使用自定义异常处理过滤器
    services.AddMvc(options =>
    {
        options.Filters.Add(typeof(ErrorHandlingFilterAttribute));
    });
}

捕获模型绑定异常

上面说的都是捕获 Action 执行时发生的异常,通过前面的过滤器执行顺序图可以看到,在 ActionFilter 过滤器之前,还有一个模型绑定阶段。很多时候,在模型绑定阶段就因为参数无法解析而早已经发生异常,这里的异常我们又该如何全局捕获并记录日志呢?这就需要使用到 ApiBehaviorOptions 配置了。

我们定义一个扩展方法,用来配置 ApiBehaviorOptions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public static class ModelBindingExceptionHandlingExtension
{
    /// <summary>
    /// 配置模型绑定异常捕获
    /// </summary>
    /// <param name="services">服务容器</param>
    public static void ConfigureModelBindingExceptionHandling(this IServiceCollection services)
    {
        services.Configure<ApiBehaviorOptions>(options =>
        {
            // 每次请求时,如果发生了入参解析失败的情况,则会调用此处逻辑
            options.InvalidModelStateResponseFactory = actionContext =>
            {
                var error = actionContext.ModelState?.FirstOrDefault(e => e.Value.Errors.Count > 0).Value;
                // 记录日志
                var loger = services.BuildServiceProvider().GetRequiredService<ILogger<StartupModule>>();
                loger.LogError(new EventId(0), error?.Errors.First().Exception, $"入参解析失败:{error?.Errors.First().ErrorMessage}");
                loger.LogDebug($"入参解析失败:{error?.Errors.First().ErrorMessage}");
                
                var errorOutput = new ErrorOutput
                {
                    Code = Api.Exceptions.Contracts.InternalErrorCode.InternalServerError,
                    Status = "服务器内部异常",
                    Message = $"入参解析失败:{error?.Errors.First().ErrorMessage}",
                    Details = error?.Errors.First().Exception?.ToString()
                };
				// 返回自定义异常信息
                return new BadRequestObjectResult(errorOutput);
            };
        });
    }
}

然后需要在 Startup 类的 ConfigureServices 方法中,使用该配置。

1
2
3
4
5
6
7
public void ConfigureServices(IServiceCollection services)
{
    // 其他服务...
    // ...
    // 配置模型绑定异常处理
    services.ConfigureModelBindingExceptionHandling();
}

参考文档

感谢以下两篇博客及其作者: