ASP.NET Core 性能优化最佳做法

本文提供了有关 ASP.NET Core 性能优化最佳做法的准则。

主动缓存

此文档的几个部分讨论了缓存。 有关详细信息,请参阅 ASP.NET Core 中的响应缓存

了解热代码路径

在本文档中,将 热代码路径 定义为经常调用的代码路径和执行时间量。 热代码路径通常限制应用向外缩放和性能,并将在本文档的几个部分中进行讨论。

ASP.NET Core 性能优化最佳做法
ASP.NET Core 性能优化最佳做法

避免阻止调用

应将 ASP.NET Core 应用程序设计为同时处理许多请求。 异步 Api 允许一小部分线程通过不等待阻止调用来处理上千个并发请求。 线程可以处理另一请求,而不是等待长时间运行的同步任务完成。

ASP.NET Core 应用中的常见性能问题是阻止可能是异步的调用。 很多同步阻塞调用会导致 线程池 不足并降低响应时间。

ASP.NET Core 性能优化最佳做法请勿

  • 通过调用 task. Wait 或 task.来阻止异步执行。
  • 获取通用代码路径中的锁。 当构建为并行运行代码时,ASP.NET Core 应用程序的性能最高。
  • 调用 任务。运行 并立即等待。 ASP.NET Core 已在正常线程池线程上运行应用程序代码,因此调用任务。运行仅会导致额外的不必要的线程池计划。 即使计划的代码会阻止线程,任务也不会阻止。

Do

  • 使 热代码路径 处于异步状态。
  • 如果异步 API 可用,则异步调用数据访问、i/o 和长时间运行的操作 Api。 不要使用任务。运行以使同步 API 成为异步同步。
  • 使控制器/ Razor 页面操作异步。 为了受益于 async/await 模式,整个调用堆栈是异步的。

探查器(如 PerfView)可用于查找频繁添加到 线程池中的线程。 Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start事件指示添加到线程池的线程。

ASP.NET Core 性能优化最佳做法 1
ASP.NET Core 性能优化最佳做法

返回 IEnumerable <T> 或 IAsyncEnumerable<T>

IEnumerable<T>从操作返回会导致序列化程序同步集合迭代。 因此会阻止调用,并且可能会导致线程池资源不足。 若要避免同步枚举,请 ToListAsync 在返回可枚举的前使用。

从 ASP.NET Core 3.0 开始, IAsyncEnumerable<T> 可将其用作 IEnumerable<T> 异步枚举的替代方法。 有关详细信息,请参阅 控制器操作返回类型

最小化大型对象分配

.Net Core 垃圾回收器在 ASP.NET Core 应用中自动管理内存的分配和释放。 自动垃圾回收通常意味着开发人员无需担心如何或何时释放内存。 但是,清理未引用的对象会占用 CPU 时间,因此开发人员应最大限度地减少 热代码路径中的对象分配。 垃圾回收在大型对象上特别昂贵 ( # A0 85 K 字节) 。 大型对象存储在 大型对象堆 上,需要完整的 (第2代) 垃圾回收。 与第0代和第1代回收不同,第2代回收需要临时暂停应用执行。 频繁分配和取消分配大型对象会导致性能不一致。

ASP.NET Core 性能优化最佳做法建议:

  • 请考虑缓存 经常使用的大型对象。 缓存大型对象会阻止开销较高的分配。
  • 使用ArrayPool <T> 存储大型数组来池缓冲区
  • 不要 在 热代码路径上分配很多生存期较短的大型对象。

可以通过查看 PerfView 中的垃圾回收 (GC) 统计信息并进行检查来诊断内存问题,如前面的问题:

  • 垃圾回收暂停时间。
  • 垃圾回收所用的处理器时间百分比。
  • 第0代、第1代和第2代垃圾回收量。

有关详细信息,请参阅 垃圾回收和性能

ASP.NET Core 性能优化最佳做法 2
ASP.NET Core 性能优化最佳做法

优化数据访问和 i/o

与数据存储和其他远程服务的交互通常是 ASP.NET Core 应用程序的最慢部分。 有效读取和写入数据对于良好的性能至关重要。

ASP.NET Core 性能优化最佳做法建议:

  •  以异步方式调用所有数据访问 api。
  • 检索的数据是必需的。 编写查询以仅返回当前 HTTP 请求所必需的数据。
  • 如果数据可以接受,请考虑缓存经常访问的从数据库或远程服务检索的数据。 使用 MemoryCache 或 microsoft.web.distributedcache,具体取决于方案。 有关详细信息,请参阅 ASP.NET Core 中的响应缓存
  • 尽量减少 网络往返次数。 目标是使用单个调用而不是多个调用来检索所需数据。
  • 在访问数据时,请不要在 Entity Framework Core 中使用无跟踪查询 EF Core 可以更有效地返回无跟踪查询的结果。
  • 使用、或语句** (筛选和**聚合 LINQ 查询 .Where .Select .Sum ,例如) ,以便数据库执行筛选。
  • 请考虑 EF Core 在客户端上解析一些查询运算符,这可能导致查询执行效率低下。 有关详细信息,请参阅 客户端评估性能问题
  • 不要 对集合使用投影查询,这可能会导致执行 “N + 1” 个 SQL 查询。 有关详细信息,请参阅 相关子查询的优化

请参阅 EF 高性能 ,ASP.NET Core 性能优化最佳做法了解可提高大规模应用程序性能的方法:

建议在提交基本代码之前测量前面的高性能方法的影响。 已编译查询的额外复杂性可能不会提高性能。ASP.NET Core 性能优化最佳做法。

通过查看 Application Insights 或分析工具访问数据所用的时间,可以检测到查询问题。 大多数数据库还提供有关频繁执行的查询的统计信息。

与 HttpClientFactory 建立池 HTTP 连接

尽管 HttpClient 实现了 IDisposable 接口,但它是为重复使用而设计的。 关闭 HttpClient 的实例使套接字在 TIME_WAIT 一小段时间内处于打开状态。 如果经常使用创建和处置对象的代码路径 HttpClient ,应用可能会耗尽可用的套接字。 ASP.NET Core 2.1 中引入了HttpClientFactory作为此问题的解决方案。 它处理池 HTTP 连接以优化性能和可靠性。

ASP.NET Core 性能优化最佳做法建议:

快速保持通用代码路径

您希望所有代码的速度都很快。 经常称为 “代码路径” 是最重要的。 这些方法包括:

  • 应用程序的请求处理管道中的中间件组件,尤其是在管道早期运行的中间件。 这些组件会对性能产生很大的影响。
  • 针对每个请求或每个请求多次执行的代码。 例如,自定义日志记录、授权处理程序或暂时性服务的初始化。

ASP.NET Core 性能优化最佳做法建议:

在 HTTP 请求之外完成长时间运行的任务

大多数对 ASP.NET Core 应用程序的请求都可以通过控制器或页面模型进行处理,该模型调用必要的服务并返回 HTTP 响应。 对于涉及长时间运行的任务的某些请求,最好将整个请求响应过程设为异步处理。

ASP.NET Core 性能优化最佳做法建议:

  •  不要等待长时间运行的任务在普通的 HTTP 请求处理过程中完成。
  • 请考虑使用后台服务处理长时间运行的请求,或使用Azure 函数处理进程外的请求。 在进程外完成工作对于 CPU 密集型任务特别有用。
  • 请使用实时 通信选项(如 SignalR )以异步方式与客户端进行通信。

缩小客户端资产

具有复杂前端的 ASP.NET Core 应用通常会提供许多 JavaScript、CSS 或图像文件。 可以通过以下方式改善初始负载请求的性能:

  • 绑定,将多个文件合并到一个文件中。
  • 缩小,它通过删除空白和注释来减小文件大小。

ASP.NET Core 性能优化最佳做法建议:

  •  使用 ASP.NET Core 的 内置支持 ,以便对客户端资产进行捆绑和缩小。
  • 请考虑其他 第三方工具(如 Webpack),以实现复杂的客户端资产管理。

压缩响应

减小响应大小通常会显著提高应用程序的响应能力。 减少负载大小的一种方法是压缩应用的响应。 有关详细信息,请参阅 响应压缩

使用最新 ASP.NET Core 版本

ASP.NET Core 的每个新版本都包括性能改进。 .NET Core 和 ASP.NET Core 中的优化意味着较新版本通常优于较旧的版本。 例如,.NET Core 2.1 添加了对范围 <T> 内已编译的正则表达式和获益的支持。 ASP.NET Core 2.2 添加了对 HTTP/2 的支持。 ASP.NET Core 3.0 添加了许多改进 ,减少了内存使用量并提高了吞吐量。 如果性能是优先考虑的,请考虑升级到 ASP.NET Core 的当前版本。

最小化异常

异常应极少。 相对于其他代码流模式,引发和捕获异常的速度很慢。 因此,不应使用异常来控制正常的程序流。

ASP.NET Core 性能优化最佳做法建议:

  • 不要 使用引发或捕获异常作为正常程序流的方法,尤其是在 热代码路径中。
  • 在应用程序中包括逻辑,以检测和处理会导致异常的情况。
  • 引发或 捕获异常或意外情况的异常。

应用诊断工具(如 Application Insights)可帮助识别应用中可能影响性能的常见异常。

性能和可靠性

以下各节提供了性能提示以及已知的可靠性问题和解决方案。

避免 HttpRequest/Httpresponse.cache 正文上的同步读取或写入

ASP.NET Core 中的所有 i/o 都是异步的。 服务器实现 Stream 了接口,该接口同时具有同步和异步重载。 应首选异步文件以避免阻塞线程池线程。 阻塞线程可能会导致线程池不足。

请勿执行此操作: 下面的示例使用 ReadToEnd 。 此方法阻止当前线程等待结果。 这是一个 通过异步同步的示例。

public class BadStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public ActionResult<ContosoData> Get()
    {
        var json = new StreamReader(Request.Body).ReadToEnd();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }
}

在上面的代码中, Get 将整个 HTTP 请求正文以同步方式读入内存中。 如果客户端缓慢上传,则应用通过异步执行同步。 应用通过异步同步,因为 Kestrel 不支持同步 读取。

执行以下操作: 下面的示例使用 ReadToEndAsync ,在读取时不会阻止线程。

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        var json = await new StreamReader(Request.Body).ReadToEndAsync();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }

}

前面的代码异步将整个 HTTP 请求正文读入内存中。

 警告

如果请求很大,则将整个 HTTP 请求正文读取到内存中可能会导致内存不足 (OOM) 情况。 OOM 可能会导致拒绝服务。 有关详细信息,请参阅本文档中的 避免将大型请求正文或响应正文读入内存 中。

执行以下操作: 下面的示例使用非缓冲请求正文完全异步:

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
    }
}

前面的代码将请求正文异步反序列化为 c # 对象。

首选 ReadFormAsync over 请求。窗体

请使用 HttpContext.Request.ReadFormAsync,而不是 HttpContext.Request.Form HttpContext.Request.Form 只能在以下条件下安全地读取:

  • 已通过对的调用读取了窗体 ReadFormAsync ,
  • 正在使用读取缓存的表单值 HttpContext.Request.Form

请勿执行此操作: 下面的示例使用 HttpContext.Request.Form 。 HttpContext.Request.Form通过异步使用同步,并可能导致线程池不足。

public class BadReadController : Controller
{
    [HttpPost("/form-body")]
    public IActionResult Post()
    {
        var form =  HttpContext.Request.Form;

        Process(form["id"], form["name"]);

        return Accepted();
    }

执行以下操作: 下面的示例使用 HttpContext.Request.ReadFormAsync 以异步方式读取窗体体。

public class GoodReadController : Controller
{
    [HttpPost("/form-body")]
    public async Task<IActionResult> Post()
    {
       var form = await HttpContext.Request.ReadFormAsync();

        Process(form["id"], form["name"]);

        return Accepted();
    }

避免将大型请求正文或响应正文读入内存

在 .NET 中,大于 85 KB 的每个对象分配将在大型对象堆 (LOH) 结束。 大型对象的开销很大:

  • 分配开销较高,因为必须清除新分配的大型对象的内存。 CLR 确保清除所有新分配对象的内存。
  • LOH 随堆的其余部分一起收集。 LOH 需要完整的 垃圾回收 或 Gen2 集合

此 博客文章 简单介绍了问题:

分配大型对象时,会将其标记为第2代对象。 对于小对象,不是0代。 后果是,如果在 LOH 中用尽内存,GC 将清除整个托管堆,而不仅是 LOH。 因此,它会清除第0代第1代和第2代,包括 LOH。 这称为完整垃圾回收,是最耗费时间的垃圾回收。 许多应用程序都可以接受。 但一定不能用于高性能的 web 服务器,在这种情况下,需要少量的大内存缓冲区来处理平均 web 请求 (从套接字读取、解压缩、解码 JSON & 更) 。

将大型请求或响应正文存储到单个或中的 Naively byte[] string :

  • 可能会导致 LOH 中的空间快速耗尽。
  • 可能导致应用程序出现性能问题,因为正在运行完全 Gc。

使用同步数据处理 API

使用仅支持同步读和写的序列化程序/反序列化程序时 (例如, JSON.NET) :

  • 将数据异步缓冲到内存中,然后将其传递给序列化程序/反序列化程序。

警告

如果请求很大,则可能会导致内存不足 (OOM) 情况。 OOM 可能会导致拒绝服务。 有关详细信息,请参阅本文档中的 避免将大型请求正文或响应正文读入内存 中。

System.Text.Json默认情况下,ASP.NET Core 3.0 使用 JSON 序列化。 System.Text.Json:

  • 以异步方式读取和写入 JSON。
  • 针对 UTF-8 文本进行了优化。
  • 通常比 Newtonsoft.Json 性能更高。

不要在字段中存储 IHttpContextAccessor

IHttpContextAccessor.HttpContext HttpContext 从请求线程访问时,IHttpContextAccessor 将返回活动请求的。 IHttpContextAccessor.HttpContext应存储在字段或变量中。

请勿执行此操作: 下面的示例将存储 HttpContext 在字段中,并稍后尝试使用它。

public class MyBadType
{
    private readonly HttpContext _context;
    public MyBadType(IHttpContextAccessor accessor)
    {
        _context = accessor.HttpContext;
    }

    public void CheckAdmin()
    {
        if (!_context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

前面的代码 HttpContext 在构造函数中经常捕获 null 或错误。

执行以下操作: 下面的示例:

  • 将存储 IHttpContextAccessor 在字段中。
  • HttpContext在正确的时间使用字段并检查 null 。
public class MyGoodType
{
    private readonly IHttpContextAccessor _accessor;
    public MyGoodType(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是线程安全的。 HttpContext并行从多个线程进行访问可能会导致未定义的行为,如挂起、崩溃和数据损坏。

请勿执行此操作: 下面的示例执行三个并行请求,并在传出 HTTP 请求之前和之后记录传入的请求路径。 可以从多个线程访问请求路径,可能会并行进行。

public class AsyncBadSearchController : 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 = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.", 
                                    HttpContext.Request.Path);
            searchResults = _searchService.Search(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 await searchResults;
    }

执行以下操作: 下面的示例在发出三个并行请求之前复制传入请求中的所有数据。

public class AsyncGoodSearchController : 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 = _searchService.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 await searchResults;
    }

请求完成后,不要使用 HttpContext

HttpContext 只有在 ASP.NET Core 管道中存在活动 HTTP 请求时,它才有效。 整个 ASP.NET Core 管道是一系列执行每个请求的委托。 当从此 Task 链返回的完成时, HttpContext 会回收。

请勿执行此操作: 下面的示例使用 async void ,当达到第一个时,它将使 HTTP 请求完成 await :

  • 在 ASP.NET Core 应用程序中,这 始终 是一种不好的做法。
  • HttpResponseHTTP 请求完成后访问。
  • 崩溃进程。
public class AsyncBadVoidController : Controller
{
    [HttpGet("/async")]
    public async void Get()
    {
        await Task.Delay(1000);

        // The following line will crash the process because of writing after the 
        // response has completed on a background thread. Notice async void Get()

        await Response.WriteAsync("Hello World");
    }
}

执行以下操作: 下面的示例将返回 Task 到框架,以便在操作完成之前,不会完成 HTTP 请求。

public class AsyncGoodTaskController : Controller
{
    [HttpGet("/async")]
    public async Task Get()
    {
        await Task.Delay(1000);

        await Response.WriteAsync("Hello World");
    }
}

不要捕获后台线程中的 HttpContext

请勿执行此操作: 下面的示例演示关闭 HttpContext 从 Controller 属性捕获。 这是一种不好的做法,因为工作项可以:

  • 在请求范围之外运行。
  • 尝试读取错误 HttpContext 。
[HttpGet("/fire-and-forget-1")]
public IActionResult BadFireAndForget()
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        var path = HttpContext.Request.Path;
        Log(path);
    });

    return Accepted();
}

执行以下操作: 下面的示例:

  • 在请求过程中复制后台任务所需的数据。
  • 不从控制器引用任何内容。
[HttpGet("/fire-and-forget-3")]
public IActionResult GoodFireAndForget()
{
    string path = HttpContext.Request.Path;
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        Log(path);
    });

    return Accepted();
}

应将后台任务作为托管服务实现。 有关详细信息,请参阅使用托管服务的后台任务

不要捕获注入到后台线程控制器的服务

请勿执行此操作: 下面的示例演示关闭 DbContext Controller 操作从操作参数捕获。 这是一种不好的做法。 工作项可以在请求范围之外运行。 的 ContosoDbContext 作用域限定为请求,导致 ObjectDisposedException 。

[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1([FromServices]ContosoDbContext context)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        context.Contoso.Add(new Contoso());
        await context.SaveChangesAsync();
    });

    return Accepted();
}

执行以下操作: 下面的示例:

  • 注入,以便在 IServiceScopeFactory 后台工作项中创建范围。 IServiceScopeFactory 是单一实例。
  • 在后台线程中创建新的依赖项注入范围。
  • 不从控制器引用任何内容。
  • 不 ContosoDbContext 从传入请求中捕获。
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        using (var scope = serviceScopeFactory.CreateScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

以下突出显示的代码:

  • 在后台操作的生存期内创建一个范围,并从中解析服务。
  • 使用 ContosoDbContext 正确的作用域。
[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        using (var scope = serviceScopeFactory.CreateScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

请不要在响应正文开始后修改状态代码或标头

ASP.NET Core 不会缓冲 HTTP 响应正文。 第一次写入响应时:

  • 标头将与主体块区一起发送到客户端。
  • 不能再更改响应标头。

请勿执行此操作: 以下代码在响应已启动之后尝试添加响应标头:

app.Use(async (context, next) =>
{
    await next();

    context.Response.Headers["test"] = "test value";
});

在前面的代码中, context.Response.Headers["test"] = "test value"; 如果 next() 已写入响应,将引发异常。

执行以下操作: 下面的示例在修改标头之前检查 HTTP 响应是否已启动。

app.Use(async (context, next) =>
{
    await next();

    if (!context.Response.HasStarted)
    {
        context.Response.Headers["test"] = "test value";
    }
});

执行以下操作: 下面的示例使用在 HttpResponse.OnStarting 将响应标头刷新到客户端之前设置标头。

如果检查响应是否尚未启动,则允许注册将在写入响应标头之前调用的回调。 检查响应是否尚未开始:

  • 提供了随时追加或重写标头的功能。
  • 不需要了解管道中的下一个中间件。
app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers["someheader"] = "somevalue";
        return Task.CompletedTask;
    });

    await next();
});

如果已开始写入响应正文,请不要调用下一个 ( # A1

仅当组件可以处理和操作响应时,才应调用组件。

使用 IIS 中的进程内托管

使用进程内托管,ASP.NET Core 在与其 IIS 工作进程相同的进程中运行。 进程内托管提供了对进程外托管的性能改进,因为请求未通过环回适配器进行代理。 环回适配器是一种将传出的网络流量返回到相同计算机的网络接口。 IIS 使用 Windows 进程激活服务 (WAS) 处理进程管理。

项目默认为 ASP.NET Core 3.0 及更高版本中的进程内承载模型。

本文转载自:https://docs.microsoft.com/zh-cn/aspnet/core/performance/performance-best-practices?view=aspnetcore-3.1

猜你喜欢

本站最新优惠

Namesilo优惠:新用户省 $1 域名注册-优惠码:45D%UYTcxYuCloZ 国外最便宜域名!点击了解更多

特别优惠:免费赠送 $100 Vultr主机-限时优惠!英文站必备海外服务器!点击了解更多

VPS优惠:搬瓦工优惠码:BWH3OGRI2BMW 最高省5.83%打开外面世界的一款主机点击了解更多

加入电报群

【江湖人士】(jhrs.com) 投稿作者:IT菜鸟,不代表江湖人士立场,如若转载,请注明出处:https://jhrs.com/2020/38360.html

扫码加入电报群,让你获得国外网赚一手信息。

文章标题:ASP.NET Core 性能优化最佳做法

(0)
IT菜鸟的头像IT菜鸟普通会员
上一篇 2020-11-01 12:48
下一篇 2020-11-09 22:13

热门推荐

发表回复

登录后才能评论
畅访海外网站,外贸/外企/科技工作者专用工具,无缝体验真实的互联网,解锁LinkedIn访问
$19.95 /年
直达官网