ASP.NET Core指导[Guidance]
Table of contents
- 避免对 HttpRequest.Body 和 HttpResponse.Body 使用同步读/写重载
- 首选使用 HttpRequest.ReadAsFormAsync() 而不是 HttpRequest.Form
- 避免将大型请求正文或响应正文读取到内存中
- 使用缓冲和同步读取和写入作为异步读取和写入的替代方法
- 不要将 IHttpContextAccessor.HttpContext 存储在字段中
- 不要从多个线程并行访问 HttpContext。它不是线程安全的。
- 请求完成后,请勿使用 HttpContext
- 不要在后台线程(background threads)中捕获 HttpContext
- 不要捕获注入到后台线程上的控制器的服务
- 避免在 HttpResponse 启动后添加标头
原文地址:https://github.com/davidfowl/AspNetCoreDiagnosticScenarios
ASP.NET Core 是一个跨平台、高性能的开源框架,用于构建基于云的现代 Internet 连接应用程序。本指南介绍了编写可伸缩服务器应用程序时的一些常见陷阱和做法。
避免对 HttpRequest.Body 和 HttpResponse.Body 使用同步读/写重载
ASP.NET Core 中的所有 IO 都是异步的。服务器实现具有同步和异步重载的 Stream
接口。应首选异步线程,以避免阻塞线程池线程(这可能导致线程池匮乏)。
❌ BAD 此示例使用 StreamReader.ReadToEnd
and 作为结果阻止当前线程等待结果。这是异步同步的示例。
public class MyController : Controller
{
[HttpGet("/pokemon")]
public ActionResult<PokemonData> Get()
{
// This synchronously reads the entire http request body into memory.
// If the client is slowly uploading, we're doing sync over async because Kestrel does *NOT* support synchronous reads.
var json = new StreamReader(Request.Body).ReadToEnd();
return JsonConvert.DeserializeObject<PokemonData>(json);
}
}
✅ GOOD 此示例使用 StreamReader.ReadToEndAsync
并因此在读取时不会阻塞线程。
public class MyController : Controller
{
[HttpGet("/pokemon")]
public async Task<ActionResult<PokemonData>> Get()
{
// This asynchronously reads the entire http request body into memory.
var json = await new StreamReader(Request.Body).ReadToEndAsync();
return JsonConvert.DeserializeObject<PokemonData>(json);
}
}
💡注意:如果请求很大,可能会导致内存不足问题,从而导致拒绝服务。有关详细信息,请参阅此处。
首选使用 HttpRequest.ReadAsFormAsync() 而不是 HttpRequest.Form
您应该始终更喜欢 HttpRequest.ReadAsFormAsync()
HttpRequest.Form
.唯一可以安全使用 HttpRequest.Form
的情况是,调用 已经 HttpRequest.ReadAsFormAsync()
读取了表单,并且正在使用 HttpRequest.Form
读取缓存的表单值。
❌ BAD 此示例使用 HttpRequest.Form 在后台使用同步而不是异步,并可能导致线程池匮乏(在某些情况下)。
public class MyController : Controller
{
[HttpPost("/form-body")]
public IActionResult Post()
{
var form = HttpRequest.Form;
Process(form["id"], form["name"]);
return Accepted();
}
}
✅ GOOD:此示例用于 HttpRequest.ReadAsFormAsync()
异步读取表单正文。
public class MyController : Controller
{
[HttpPost("/form-body")]
public async Task<IActionResult> Post()
{
var form = await HttpRequest.ReadAsFormAsync();
Process(form["id"], form["name"]);
return Accepted();
}
}
避免将大型请求正文或响应正文读取到内存中
在 .NET 中,任何大于 85KB 的单个对象分配最终都会进入大型对象堆 (LOH)。大型物体在以下两个方面很昂贵:
分配成本很高,因为必须清除新分配的大型对象的内存(CLR 保证清除所有新分配对象的内存)
LOH 与堆的其余部分一起收集(它需要“完全垃圾回收”或 Gen2 收集)
这篇blog简明扼要地描述了这个问题:
When a large object is allocated, it’s marked as Gen 2 object. Not Gen 0 as for small objects. The consequences are that if you run out of memory in LOH, GC cleans up whole managed heap, not only LOH. So it cleans up Gen 0, Gen 1 and Gen 2 including LOH. This is called full garbage collection and is the most time-consuming garbage collection. For many applications, it can be acceptable. But definitely not for high-performance web servers, where few big memory buffers are needed to handle an average web request (read from a socket, decompress, decode JSON & more).
分配大型对象后,会将其标记为第 2 代对象。对于小物体来说,不是第 0 代。结果是,如果 LOH 中的内存不足,GC 会清理整个托管堆,而不仅仅是 LOH。因此,它会清理第 0 代、第 1 代和第 2 代,包括 LOH。这称为完全垃圾回收,是最耗时的垃圾回收。对于许多应用程序,这是可以接受的。但绝对不适用于高性能 Web 服务器,因为这些服务器几乎不需要大内存缓冲区来处理普通的 Web 请求(从套接字读取、解压缩、解码 JSON 等)。
幼稚地将大型请求或响应正文存储到单个 byte[]
请求或响应正文中,或者 string
可能导致 LOH 中的空间快速耗尽,并且由于运行完整的 GC 可能会导致应用程序出现性能问题。
使用缓冲和同步读取和写入作为异步读取和写入的替代方法
使用仅支持同步读取和写入的序列化程序/反序列化程序(如 JSON.NET)时,最好先将数据缓冲到内存中,然后再将数据传递到串行程序/反序列化程序中。
💡注意:如果请求很大,可能会导致内存不足问题,从而导致拒绝服务
不要将 IHttpContextAccessor.HttpContext
存储在字段中
当从请求线程访问时,将 IHttpContextAccessor.HttpContext
返回 HttpContext
活动请求。它不应存储在字段或变量中。
❌ BAD 此示例将 HttpContext 存储在字段中,然后稍后尝试使用它。
public class MyType
{
private readonly HttpContext _context;
public MyType(IHttpContextAccessor accessor)
{
_context = accessor.HttpContext;
}
public void CheckAdmin()
{
if (!_context.User.IsInRole("admin"))
{
throw new UnauthorizedAccessException("The current user isn't an admin");
}
}
}
上述逻辑可能会在构造函数中捕获 null 或伪造的 HttpContext 以供以后使用。
✅GOOD:此示例将 IHttpContextAccesor 本身存储在字段中,并在正确的时间使用 HttpContext 字段(检查 null)。
public class MyType
{
private readonly IHttpContextAccessor _accessor;
public MyType(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
public void CheckAdmin()
{
var context = _accessor.HttpContext;
if (context != null && !context.User.IsInRole("admin"))
{
throw new UnauthorizedAccessException("The current user isn't an admin");
}
}
}
不要从多个线程并行访问 HttpContext。它不是线程安全的。
HttpContext
不是线程安全的。从多个线程并行访问它可能会导致损坏,从而导致未定义的行为(挂起、崩溃、数据损坏)。
❌ BAD 此示例发出 3 个并行请求,并记录传出 http 请求前后的传入请求路径。这将从多个线程访问请求路径,这些线程可能并行。
public class AsyncController : Controller
{
[HttpGet("/search")]
public async Task<SearchResults> Get(string query)
{
var query1 = SearchAsync(SearchEngine.Google, query);
var query2 = SearchAsync(SearchEngine.Bing, query);
var query3 = SearchAsync(SearchEngine.DuckDuckGo, query);
await Task.WhenAll(query1, query2, query3);
var results1 = await query1;
var results2 = await query2;
var results3 = await query3;
return SearchResults.Combine(results1, results2, results3);
}
private async Task<SearchResults> SearchAsync(SearchEngine engine, string query)
{
var searchResults = SearchResults.Empty;
try
{
_logger.LogInformation("Starting search query from {path}.", HttpContext.Request.Path);
searchResults = await _searchService.SearchAsync(engine, query);
_logger.LogInformation("Finishing search query from {path}.", HttpContext.Request.Path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed query from {path}", HttpContext.Request.Path);
}
return searchResults;
}
}
✅ GOOD:此示例在发出 3 个并行请求之前复制传入请求中的所有数据。
public class AsyncController : Controller
{
[HttpGet("/search")]
public async Task<SearchResults> Get(string query)
{
string path = HttpContext.Request.Path;
var query1 = SearchAsync(SearchEngine.Google, query, path);
var query2 = SearchAsync(SearchEngine.Bing, query, path);
var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path);
await Task.WhenAll(query1, query2, query3);
var results1 = await query1;
var results2 = await query2;
var results3 = await query3;
return SearchResults.Combine(results1, results2, results3);
}
private async Task<SearchResults> SearchAsync(SearchEngine engine, string query, string path)
{
var searchResults = SearchResults.Empty;
try
{
_logger.LogInformation("Starting search query from {path}.", path);
searchResults = await _searchService.SearchAsync(engine, query);
_logger.LogInformation("Finishing search query from {path}.", path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed query from {path}", path);
}
return searchResults;
}
}
请求完成后,请勿使用 HttpContext
仅当存在正在进行的活动 http 请求时才 HttpContext
有效。整个 ASP.NET Core 管道是一个异步委托链,用于执行每个请求。当从该链返回的完成后 Task
, HttpContext
将被回收。
❌ BAD 此示例使用 async void(在 ASP.NET Core 应用程序中始终是错误的),因此,在 http 请求完成后访问 。 HttpResponse
结果,它将使进程崩溃。
public class AsyncVoidController : Controller
{
[HttpGet("/async")]
public async void Get()
{
await Task.Delay(1000);
// THIS will crash the process since we're writing after the response has completed on a background thread
await Response.WriteAsync("Hello World");
}
}
✅ GOOD:此示例将 a Task
返回给框架,因此在整个操作完成之前,http 请求不会完成。
public class AsyncController : Controller
{
[HttpGet("/async")]
public async Task Get()
{
await Task.Delay(1000);
await Response.WriteAsync("Hello World");
}
}
不要在后台线程(background threads)中捕获 HttpContext
❌ BAD 此示例显示闭包正在从 Controller 属性捕获 HttpContext。这很糟糕,因为此工作项可能会在请求范围之外运行,因此可能导致读取伪造的 HttpContext。
[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1()
{
_ = Task.Run(() =>
{
await Task.Delay(1000);
// This closure is capturing the context from the Controller property. This is bad because this work item could run
// outside of the http request leading to reading of bogus data.
var path = HttpContext.Request.Path;
Log(path);
});
return Accepted();
}
✅ GOOD:此示例在请求期间显式复制后台任务中所需的数据,并且不引用控制器本身的任何内容。
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3()
{
string path = HttpContext.Request.Path;
_ = Task.Run(async () =>
{
await Task.Delay(1000);
// This captures just the path
Log(path);
});
return Accepted();
}
不要捕获注入到后台线程上的控制器的服务
❌ BAD 此示例显示闭包正在从 Controller 操作参数捕获 DbContext。这很糟糕,因为此工作项可能在请求范围之外运行,并且 PokemonDbContext 的范围限定为请求。因此,这最终将产生 ObjectDisposedException。
[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1([FromServices]PokemonDbContext context)
{
_ = Task.Run(() =>
{
await Task.Delay(1000);
// This closure is capturing the context from the Controller action parameter. This is bad because this work item could run
// outside of the request scope and the PokemonDbContext is scoped to the request. As a result, this throw an ObjectDisposedException
context.Pokemon.Add(new Pokemon());
await context.SaveChangesAsync();
});
return Accepted();
}
✅ 好 此示例在后台线程中注入并 IServiceScopeFactory
创建新的依赖项注入作用域,并且不引用控制器本身的任何内容。
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory serviceScopeFactory)
{
// This version of fire and forget adds some exception handling. We're also no longer capturing the PokemonDbContext from the incoming request.
// Instead, we're injecting an IServiceScopeFactory (which is a singleton) in order to create a scope in the background work item.
_ = Task.Run(async () =>
{
await Task.Delay(1000);
// Create a scope for the lifetime of the background operation and resolve services from it
using (var scope = serviceScopeFactory.CreateScope())
{
// This will a PokemonDbContext from the correct scope and the operation will succeed
var context = scope.ServiceProvider.GetRequiredService<PokemonDbContext>();
context.Pokemon.Add(new Pokemon());
await context.SaveChangesAsync();
}
});
return Accepted();
}
避免在 HttpResponse 启动后添加标头
Avoid adding headers after the HttpResponse has started
ASP.NET Core 不缓冲 http 响应正文。这意味着,在第一次写入响应时,标头将与正文的该块一起发送到客户端。发生这种情况时,无法再更改响应标头。
❌ BAD 此逻辑尝试在响应开始后添加响应标头。
app.Use(async (next, context) =>
{
await context.Response.WriteAsync("Hello ");
await next();
// This may fail if next() already wrote to the response
context.Response.Headers["test"] = "value";
});
✅ GOOD 此示例在写入正文之前检查 http 响应是否已启动。
app.Use(async (next, context) =>
{
await context.Response.WriteAsync("Hello ");
await next();
// Check if the response has already started before adding header and writing
if (!context.Response.HasStarted)
{
context.Response.Headers["test"] = "value";
}
});
✅ GOOD:此示例用于 HttpResponse.OnStarting
在将响应标头刷新到客户端之前设置标头。
app.Use(async (next, context) =>
{
// Wire up the callback that will fire just before the response headers are sent to the client.
context.Response.OnStarting(() =>
{
context.Response.Headers["someheader"] = "somevalue";
return Task.CompletedTask;
});
await next();
});