.NET Core开发实战课程备忘(14) -- 异常处理中间件:区分真异常与逻辑一场

处理异常的方式

  • 异常处理页
  • 异常处理匿名委托方法
  • IExceptionFilter
  • ExceptionFilterAttribute

代码实现

创建项目

创建名字为ExceptionDemoASP.NET Core项目,类型为API

注释掉系统自带的异常处理中间件

Startup.Configure中有app.UseDeveloperExceptionPage();这个中间件,这个就是ASP.NET Core自带的一个异常处理页,但是这个页面错误信息太多,只适合开发时对开发人员进行提示,不适合放到生产环境,所以这里注释掉这个中间件

创建自定义的异常类

为什么要创建自定义的异常类

通常情况下我们系统里面的异常与我们业务逻辑里的异常是不同的,业务逻辑上的判断异常,比如输入的参数不合法、订单状态不符合条件,当前账户余额不足这样的错误信息,我们有两种处理方式,一种处理方式是对不同的逻辑输出不同的业务对象,还有一种方式就是对于这种业务逻辑输出一个异常,用异常来承载我们的逻辑的特殊分支,那这个时候我们就需要识别出哪些是我们的业务异常,哪些是我们不确定的未知异常,比如网络突发的无法连接、MySql的闪断之类的

那这里怎么识别出哪些是业务异常,哪些是未知异常?

首先通过定义一个接口,接口里有错误码和错误信息,当我们有一个业务出现异常,我们可以人为的抛出一个已经实现了这个接口的自定义异常类。然后在异常处理过程中,我们尝试将捕获到的异常转为我们定义的异常接口,如果能转成功,说明这个异常是我们认为抛出的业务异常,否则为系统抛出的未知异常

自定义异常类

在项目根目录创建文件夹Exceptions,所有异常的自定义类都放在这里

创建IKnownException接口,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
namespace ExceptionDemo.Exceptions
{
public interface IKnownException
{
string Message { get; }

int ErrorCode { get; }

object[] ErrorData { get; }
}
}

创建KnownException类,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace ExceptionDemo.Exceptions
{
public class KnownException:IKnownException
{
public KnownException(object[] errorData, int errorCode, string message)
{
ErrorData = errorData;
ErrorCode = errorCode;
Message = message;
}

public string Message { get; }
public int ErrorCode { get; }
public object[] ErrorData { get; }

public static readonly IKnownException UnKnown = new KnownException(errorData: new object[] { }, errorCode: 9999, message: "未知错误");

public static IKnownException FromKnownException(IKnownException exception)
{
return new KnownException(errorData: exception.ErrorData, errorCode: exception.ErrorCode, message: exception.Message);
}
}
}

创建测试用的InvalidParameterException类,用来模拟参数错误的异常,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;

namespace ExceptionDemo.Exceptions
{
public class InvalidParameterException: Exception,IKnownException
{
public InvalidParameterException(int errorCode, string message, params object[] errorData) : base(message)
{
ErrorCode = errorCode;
ErrorData = errorData;
}

public int ErrorCode { get; }
public object[] ErrorData { get; }
}
}

异常处理页代码实现

创建处理页面控制器ErrorController,在Index方法中获取到当前请求上下文的异常信息,并尝试进行转成IKnownException,如果转成功则表示为业务逻辑异常,如果失败则表示为未知异常,未知异常则通过KnownException的静态方法生成一个特定的未知异常对象,具体代码如下:

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
using ExceptionDemo.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace ExceptionDemo.Controllers
{
public class ErrorController : Controller
{
private readonly ILogger<ErrorController> _logger;

public ErrorController(ILogger<ErrorController> logger)
{
_logger = logger;
}

[Route("/error")]
public IActionResult Index()
{
var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
var ex = exceptionHandlerPathFeature?.Error;

var knownException = ex as IKnownException;
if (knownException == null)
{
_logger.LogError(ex,ex.Message);
knownException = KnownException.UnKnown;
}
else
{
knownException = KnownException.FromKnownException(knownException);
}

return View(knownException);
}
}
}

对应的试图Index.cshtml代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@model ExceptionDemo.Exceptions.IKnownException
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Index</title>
</head>
<body>
<div>
<p>错误码:@Model.ErrorCode</p>
<p>错误信息:@Model.Message</p>
</div>
</body>
</html>

回到Startup,对ConfigureServicesConfigure两个方法做出调整,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}

切换到WeatherForecastController,在这里来主动抛出异常,将Get方法修改如下:

1
2
3
4
5
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
throw new InvalidParameterException(65, "参数有误", new List<string>() {"exception info 1","exception info 2" });
}

运行代码,访问/weatherforecast,可以看到返回了以下

1
2
3
错误码:65

错误信息:参数有误

WeatherForecastController.Get里的异常换成一个普通的异常,在重新运行代码,可以看到页面会变成未知错误的提示,同时控制台打印出来的日志是完全的异常日志

异常处理匿名委托方法代码实现

Startup.Configure方法中的app.UseExceptionHandler("/error");注释掉,原位置新增以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
app.UseExceptionHandler(errApp =>
{
errApp.Run(async context =>
{
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
var knownException = exceptionHandlerPathFeature.Error as IKnownException;
if (knownException == null)
{
var logger = context.RequestServices.GetService<ILogger<Startup>>();
logger.LogError(exceptionHandlerPathFeature.Error, exceptionHandlerPathFeature.Error.Message);
knownException = KnownException.UnKnown;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
else
{
knownException = KnownException.FromKnownException(knownException);
context.Response.StatusCode = StatusCodes.Status200OK;
}

var jsonOptions = context.RequestServices.GetService<IOptions<JsonOptions>>();
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(JsonSerializer.Serialize(knownException, jsonOptions.Value.JsonSerializerOptions));
});
});

这里的操作与异常处理页逻辑差不多,只是不再返回视图,而是返回json,同时设定好业务逻辑异常返回200状态码,未知异常返回500状态码(这样做的好处后面说明),运行代码,访问/weatherforecast,通过修改抛出异常,可看到对应的返回结果

IExceptionFilter代码实现

Exceptions文件夹新建MyExceptionFilter.cs,具体代码如下:

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
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace ExceptionDemo.Exceptions
{
public class MyExceptionFilter:IExceptionFilter
{
public void OnException(ExceptionContext context)
{
var knownException = context.Exception as IKnownException;
if (knownException == null)
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<MyExceptionFilter>>();
logger.LogError(context.Exception,context.Exception.Message);
knownException = KnownException.UnKnown;
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
else
{
knownException = KnownException.FromKnownException(knownException);
context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;
}

context.Result = new JsonResult(knownException)
{
ContentType = "application/json; charset=utf-8"
};
}
}
}

这里对异常的处理逻辑与异常处理匿名委托方法一样

修改StartupConfigureServicesConfigure,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews(options => { options.Filters.Add<MyExceptionFilter>(); }).AddJsonOptions(
options => { options.JsonSerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseHttpsRedirection();

app.UseRouting();

app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}

运行代码,访问/weatherforecast,通过修改抛出异常,可看到对应的返回结果

ExceptionFilterAttribute代码实现

Exceptions中新建MyExceptionFilterAttribute.cs,具体代码如下:

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
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace ExceptionDemo.Exceptions
{
public class MyExceptionFilterAttribute : ExceptionFilterAttribute
{
public override void OnException(ExceptionContext context)
{
var knownException = context.Exception as IKnownException;
if (knownException == null)
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<MyExceptionFilterAttribute>>();
logger.LogError(context.Exception, context.Exception.Message);
knownException = KnownException.UnKnown;
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
else
{
knownException = KnownException.FromKnownException(knownException);
context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;
}

context.Result = new JsonResult(knownException)
{
ContentType = "application/json; charset=utf-8"
};
}
}
}

修改StartupConfigureServicesConfigure,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}

WeatherForecastController中添加类特性,具体代码如下:

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
using System.Collections.Generic;
using ExceptionDemo.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace ExceptionDemo.Controllers
{
[ApiController]
[Route("[controller]")]
[MyExceptionFilter]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

private readonly ILogger<WeatherForecastController> _logger;

public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
throw new InvalidParameterException(65, "参数有误!!!", new List<string>() {"exception info 1","exception info 2" });
}
}
}

运行代码,访问/weatherforecast,通过修改抛出异常,可看到对应的返回结果

总结

  • 用特定的异常类或接口表示业务逻辑异常
  • 为业务逻辑异常定义全局错误码
  • 为未知异常定义特定的输出信息和错误码,不应该输出系统内部的异常堆栈
  • 对已知的业务逻辑异常相应HTTP 200,这样对监控系统友好,不会区分不开真异常和逻辑异常
  • 对于未预见的异常相应HTTP 500
  • 为所有异常记录详细的日志