[Net 6 AspNetCore Bug] 解决返回IAsyncEnumerable<T>类型时抛出的OperationCanceledException会被AspNetCore 框架吞掉的Bug
记录一个我认为是Net6 Aspnetcore 框架的一个Bug
Bug描述
在 Net6 的apsnecore项目中, 如果我们(满足以下所有条件)
- api的返回类型是
IAsyncEnumerable<T>
, - 且我们返回的是
JsonResult
对象, 或者返回的是ObjectResult
且要求的返回协商数据类型是json
, - 且我们用的是
System.Text.Json
来序列化(模式是它), - 且我们的响应用要求的编码是
utf-8
那么在业务方法中抛出的任何OperationCanceledException
或者继承自OperationCanceledException
的任何子类异常都会被框架吃掉.
Bug重现
如果我们有这样一段代码, 然后结果就是客户端和服务端都不会收到或者记录任何错误和异常.
[HttpGet("/asyncEnumerable-cancel")]
public ActionResult<IAsyncEnumerable<int>> TestAsync()
{
async IAsyncEnumerable<int> asyncEnumerable()
{
await Task.Delay(100);
yield return 1;
throw new OperationCanceledException();
// 或者Client 主动取消请求后 用this.HttpContext.RequestAborted.ThrowIfCancellationRequested() 或者任何地方抛出的task或operation cancel exception.
}
return this.Ok(asyncEnumerable());
}
测试代码
curl --location --request GET 'http://localhost:5000/asyncEnumerable-cancel'
# response code is 200
curl --location --request GET 'http://localhost:5000/asyncEnumerable-cancel' --header 'Accept-Charset: utf-16'
# response code is 500
显然这不是一个合理的 Behavior.
- 不同的编码响应结果不一样
- 明明抛出异常了, 但是utf-8还能收到200 ok的response http code
产生这个Bug的代码
SystemTextJsonOutputFormatter 对应的是用 return this.Ok(object)
返回的Case
SystemTextJsonResultExecutor 对应的是用 return new JsonResult(object)
返回的case
当然, 其他的实现方式或者关联代码是否也有这个Bug我就没有验证了. 以及产生这个Bug的原因就不多说了. 可以看看这2个文件的commit logs.
//核心代码就是这么点. try-catch吞掉了这个Exception
if (selectedEncoding.CodePage == Encoding.UTF8.CodePage)
{
try
{
await JsonSerializer.SerializeAsync(responseStream, context.Object, objectType, SerializerOptions, httpContext.RequestAborted);
await responseStream.FlushAsync(httpContext.RequestAborted);
}
catch (OperationCanceledException) { }
}
目前状况
昨天在 dotnet/aspnetcore/issues提交了一个issues, 等待官方的跟进.
如何手动修复这个Bug
如果是return new JsonResult(object)
, 我们可以用一个自己修复的SystemTextJsonResultExecutor
替换框架自身的.
框架自身的是这么注册的: services.TryAddSingleton<IActionResultExecutor<JsonResult>, SystemTextJsonResultExecutor>();
如果你用的是return this.Ok(object)
方式, 那么可以照着下面的代码来,
第一步, 首先从SystemTextJsonOutputFormatter copy 代码到你的本地.
然后修改构造函数并吧导致这个Bug的try-catch结构删掉即可.
// 构造函数中改动代码
public HookSystemTextJsonOutputFormatter(JsonSerializerOptions jsonSerializerOptions)
{
SerializerOptions = jsonSerializerOptions;
SupportedEncodings.Add(Encoding.UTF8);
SupportedEncodings.Add(Encoding.Unicode);
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json").CopyAsReadOnly());
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/json").CopyAsReadOnly());
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/*+json").CopyAsReadOnly());
}
// WriteResponseBodyAsync 方法中改动代码
var responseStream = httpContext.Response.Body;
if (selectedEncoding.CodePage == Encoding.UTF8.CodePage)
{
await JsonSerializer.SerializeAsync(responseStream, context.Object, objectType, SerializerOptions, httpContext.RequestAborted);
await responseStream.FlushAsync(httpContext.RequestAborted);
}
第二步, 用我们自己改造过的SystemTextJsonOutputFormatter替换系统自己的
//用IConfigureOptions方式替换我们的自带SystemTextJsonOutputFormatter.
public class MvcCoreMvcOptionsSetupWithFixedSystemTextJsonOutputFormatter : IConfigureOptions<MvcOptions>
{
private readonly IOptions<JsonOptions> jsonOptions;
public MvcCoreMvcOptionsSetupWithFixedSystemTextJsonOutputFormatter(IOptions<JsonOptions> jsonOptions)
{
this.jsonOptions = jsonOptions;
}
public void Configure(MvcOptions options)
{
options.OutputFormatters.RemoveType<SystemTextJsonOutputFormatter>();//删除系统自己的
options.OutputFormatters.Add(HookSystemTextJsonOutputFormatter.CreateFormatter(this.jsonOptions.Value));//替换为我们自己的
}
}
// 然后在Startup.ConfigureServices
的最后应用我们的更改
services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetupWithFixedSystemTextJsonOutputFormatter>());
后记
Ok, 到这里就结束了, 如果后续官方修复了这个bug, 那我们只要删除上面增加的代码即可.
开始写的时候本想多介绍一些关于ActionResult(JsonResult, ObjectResult), ObjectResult的内容格式协商, 以及在ObjectResult上的一些设计. 临到头了打不动字了, 也不想翻源代码了, 最重要的还是懒. 哈哈.
所以这个任务就交给搜索引擎吧... 搜索了一下有不少讲这个的, 啊哈哈.