فصل ۷ — بهینهسازی APIها
اکنون میدانیم چگونه Endpoint بنویسیم، برنامه را معماری کنیم و به داده دسترسی داشته باشیم. گام بعدی آمادهکردن API برای مقیاسپذیری در ترافیک بیشتر است. Optimizationهای این فصل ساده و مؤثرند و میتوان آنها را در پروژههای مختلف تکرار کرد. نویسنده Compression پاسخ JSON را در این فصل مطرح نمیکند، چون در سناریوی مورد نظر سود آن را متناسب با هزینه نمیداند.
موضوعات فصل:
- Asynchronous Programming (برنامهنویسی ناهمگام)
- کارهای طولانی با Background Service
- Paging (صفحهبندی)
- JSON Streaming
- Caching (کش)
- سریعتر کردن HTTP با HTTP/2 و HTTP/3
Asynchronous Programming (برنامهنویسی ناهمگام)
در نمونههای قبلی کلیدواژههای Task<T>، async و await بارها استفاده شدند. در این بخش نقش آنها روشن میشود.
مبانی برنامهنویسی ناهمگام
در .NET، Task شیئی است که یک عملیات در حال انجام یا قابل انجام را نمایش میدهد. Task میتواند فقط پایان عملیات را اعلام کند یا با Task<T> نتیجهای از نوع T برگرداند.
فرض کنید یک Endpoint برای پاسخ به درخواست کاربر به Database دسترسی دارد. اگر این I/O بهصورت Synchronous اجرا شود، Thread تا رسیدن پاسخ Database Block میماند. هرچه پاسخ منبع خارجی کندتر باشد، Thread مدت بیشتری اشغال است. در ترافیک بالا، مسدود شدن تعداد زیادی Thread میتواند Availability برنامه را کاهش دهد.
کلیدواژهٔ async مشخص میکند متد شامل عملیات Async است و await پایان Task را بدون Block نگهداشتن Thread جاری انتظار میکشد. در زمان انتظار، Thread میتواند برای کار دیگری استفاده شود و پس از تکمیل I/O ادامهٔ عملیات قبلی از سر گرفته میشود. در الگوی معمول، async و await با هم استفاده میشوند.
public async Task<List<CountryDto>> GetAllAsync()
{
return await _demoContext.Countries
.AsNoTracking()
.Select(x => new CountryDto
{
Id = x.Id,
Name = x.Name,
Description = x.Description,
FlagUri = x.FlagUri
})
.ToListAsync();
}
Listing 7-1 — متد GetAllAsync
این Query با ToListAsync بهشکل Async به Database میرود. EF Core برای بسیاری از عملیات نسخهٔ Async دارد، از جمله ExecuteUpdateAsync، ExecuteDeleteAsync و SaveChangesAsync. در HTTP نیز متدهایی مانند GetAsync و PostAsync وجود دارند. نویسنده توصیه میکند متدهای ناهمگام خودتان نیز پسوند Async داشته باشند.
Async بهویژه برای I/O خارجی که زمان پاسخ آن تحت کنترل برنامه نیست مناسب است؛ مانند SQL، HTTP یا فایل. اگر کتابخانهٔ قدیمی فقط API همگام ارائه کند، در موارد لازم میتوان کد Synchronous را با Task.Run روی Task جداگانه اجرا کرد:
var result = await Task.Run(() => DoSomething());
استفاده از CancellationToken
یکی از مزیتهای پردازش Async امکان Cancellation است. اگر Client درخواست را لغو کند یا مرورگر را ببندد، نباید Query SQL یا درخواست HTTP سنگین لزوماً تا انتها ادامه پیدا کند. با انتقال CancellationToken از Endpoint تا منبع خارجی، لغو درخواست میتواند در کل زنجیره منتشر شود.
app.MapGet("/cancellable", async (ICountryService countryService,
CancellationToken cancellationToken) =>
{
await countryService.LongRunningQueryAsync(cancellationToken);
return Results.Ok();
});
Listing 7-2 — GET /cancellable با CancellationToken
ASP.NET Core مقدار Token را برای Endpoint فراهم میکند. این Token باید از API به Service و Repository منتقل شود و در متد I/O نهایی استفاده شود.
using Domain.Repositories;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.SQL.Repositories;
public class CountryRepository : ICountryRepository
{
private readonly DemoContext _demoContext;
public CountryRepository(DemoContext demoContext)
{
_demoContext = demoContext;
}
public async Task LongRunningQueryAsync(
CancellationToken cancellationToken)
{
await _demoContext.Database
.ExecuteSqlRawAsync(
"WAITFOR DELAY '00:00:10'",
cancellationToken: cancellationToken);
}
}
Listing 7-3 — CountryRepository با Query قابل لغو
WAITFOR DELAY در این مثال فقط یک Query دهثانیهای مصنوعی ایجاد میکند. اگر درخواست HTTP قبل از پایان لغو شود، Cancellation به EF Core منتقل و اجرای SQL نیز متوقف میشود.
شکل ۷-۱ — Exception سمت SQL پس از Cancellation
همین الگو در متدهای EF Core مانند ToListAsync(cancellationToken) و FirstOrDefaultAsync(cancellationToken) و نیز در HttpClientFactory و Refit قابل استفاده است.
using Domain.Repositories;
namespace Infrastructure.Http.Repositories;
public class MediaRepository : IMediaRepository
{
private readonly IHttpClientFactory _httpClientFactory;
public MediaRepository(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<(byte[] Content, string MimeType)>
GetCountryFlagContent(string countryShortName,
CancellationToken cancellationToken)
{
byte[] fileBytes;
using HttpClient client = _httpClientFactory.CreateClient();
fileBytes = await client.GetByteArrayAsync(
$"https://anthonygiretti.blob.core.windows.net/countryflags/{countryShortName}.png",
cancellationToken);
return (fileBytes, "image/png");
}
}
Listing 7-4 — MediaRepository همراه با CancellationToken
using Refit;
namespace Domain.Repositories;
public interface IMediaRepository
{
[Get("/countryflags/{countryShortName}.png")]
Task<byte[]> GetCountryFlagContent(
string countryShortName,
CancellationToken cancellationToken);
}
Listing 7-5 — CancellationToken در Interface نوع Refit
این فصل فقط حداقل مبانی Async را پوشش میدهد؛ موضوع Concurrency و Asynchrony در .NET بسیار گستردهتر است.
کارهای طولانی با Background Services
ASP.NET Core ابزار لازم برای اجرای کارهای پسزمینهٔ طولانی در همان Web Application را فراهم میکند. Interface اصلی IHostedService در Namespace Microsoft.Extensions.Hosting قرار دارد و Package Microsoft.Extensions.Hosting.Abstractions آن را فراهم میکند. در عمل معمولاً بهجای پیادهسازی مستقیم Interface، از کلاس abstract با نام BackgroundService ارثبری میشود.
این مدل سه متد کلیدی دارد:
StartAsyncStopAsyncExecuteAsync
StartAsync و StopAsync در شروع و پایان Host خودکار فراخوانی میشوند و در صورت نیاز Override میشوند. منطق اصلی کار طولانی در ExecuteAsync قرار میگیرد.
using Microsoft.Extensions.Hosting;
namespace Infrastructure.BackgroundTasks;
public class CountryFileIntegrationBackgroundService : BackgroundService
{
public CountryFileIntegrationBackgroundService()
{
}
protected override async Task ExecuteAsync(
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
// Do some job
}
}
}
Listing 7-6 — اسکلت CountryFileIntegrationBackgroundService
این Task با بستن Browser متوقف نمیشود؛ چون به Request خاصی وابسته نیست. اما هنگام توقف ASP.NET Core، CancellationToken آن لغو میشود.
ایجاد Scope برای Serviceهای Scoped
Background Service خود Hosted Service با طول عمر طولانی است، درحالیکه بسیاری از Serviceهای برنامه Scoped هستند. بنابراین برای مصرف Serviceهای Scoped داخل Background Task، IServiceProvider تزریق و یک Scope موقت ساخته میشود.
using Domain.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Infrastructure.BackgroundTasks;
public class CountryFileIntegrationBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
public CountryFileIntegrationBackgroundService(
IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
using (var scope = _serviceProvider.CreateScope())
{
var service = scope.ServiceProvider
.GetRequiredService<ICountryService>();
// await service.IngestFile();
}
}
}
}
Listing 7-7 — Background Service با IServiceProvider
ارتباط API و Background Service با Channel
API و Background Task در یک Process، Codebase و Configuration مشترک اجرا میشوند؛ بنابراین میتوانند با پیامهای داخل برنامه ارتباط برقرار کنند. .NET برای این سناریو System.Threading.Channels را فراهم میکند. یک بخش پیام را در Channel مینویسد و بخش دیگر همان Channel را میخواند.
شکل ۷-۲ — Background Task در برنامهٔ ASP.NET Core
Background Task در یک لایهٔ جداگانهٔ Infrastructure.BackgroundTasks نگه داشته میشود. قرارداد Channel نیز در Domain تعریف میشود.
namespace Domain.Channels;
public interface ICountryFileIntegrationChannel
{
IAsyncEnumerable<Stream> ReadAllAsync(
CancellationToken cancellationToken);
Task<bool> SubmitAsync(
Stream twilioRouteProgrammerParameters,
CancellationToken cancellationToken);
}
Listing 7-8 — رابط ICountryFileIntegrationChannel
SubmitAsync یک Stream را منتشر میکند و ReadAllAsync پیامها را با IAsyncEnumerable<Stream> بهمحض آمادهشدن یکییکی تحویل میدهد.
using Domain.Channels;
using System.Threading.Channels;
namespace AspNetCore8MinimalApis.Channels;
public class CountryFileIntegrationChannel :
ICountryFileIntegrationChannel
{
private readonly Channel<Stream> _channel;
public CountryFileIntegrationChannel()
{
var options = new UnboundedChannelOptions
{
SingleWriter = false,
SingleReader = true
};
_channel = Channel.CreateUnbounded<Stream>(options);
}
public async Task<bool> SubmitAsync(
Stream fileContent,
CancellationToken cancellationToken)
{
while (await _channel.Writer.WaitToWriteAsync(cancellationToken)
&& !cancellationToken.IsCancellationRequested)
{
if (_channel.Writer.TryWrite(fileContent))
{
return true;
}
}
return false;
}
public IAsyncEnumerable<Stream>
ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
Listing 7-9 — پیادهسازی CountryFileIntegrationChannel
Channel از نوع Unbounded است؛ یعنی صف ظرفیت ثابت ندارد و پیامها یکییکی توسط Consumer پردازش میشوند. SingleWriter=false اجازه میدهد چند Request همزمان پیام منتشر کنند، اما SingleReader=true فقط یک Reader، یعنی Background Service، را در نظر میگیرد. WaitToWriteAsync امکان نوشتن را با رعایت Cancellation بررسی و TryWrite پیام را در صف قرار میدهد.
Background Service اکنون Channel را مصرف میکند و با هر Stream یک Scope جدید برای ICountryService میسازد.
using Domain.Channels;
using Domain.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Infrastructure.BackgroundTasks;
public class CountryFileIntegrationBackgroundService : BackgroundService
{
private readonly ICountryFileIntegrationChannel _channel;
private readonly IServiceProvider _serviceProvider;
public CountryFileIntegrationBackgroundService(
ICountryFileIntegrationChannel channel,
IServiceProvider serviceProvider)
{
_channel = channel;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(
CancellationToken cancellationToken)
{
await foreach (var fileContent in _channel.ReadAllAsync(cancellationToken))
{
try
{
using (var scope = _serviceProvider.CreateScope())
{
var service = scope.ServiceProvider
.GetRequiredService<ICountryService>();
await service.IngestFile(fileContent);
}
}
catch { }
}
}
}
Listing 7-10 — Background Service همراه با Channel
IAsyncEnumerable باعث میشود پیامها بهمحض رسیدن به Channel یکییکی مصرف شوند.
Channel باید Singleton ثبت شود تا Writer و Reader دقیقاً همان Instance را ببینند؛ در غیر این صورت پیامها میان Instanceهای متفاوت گم میشوند. Background Service نیز با AddHostedService ثبت میشود.
builder.Services.AddSingleton<ICountryFileIntegrationChannel,
CountryFileIntegrationChannel>();
builder.Services.AddHostedService<CountryFileIntegrationBackgroundService>();
Listing 7-11 — ثبت Channel و Background Service
برای Shutdown میتوان فرصت محدودی به کارهای در حال اجرا داد. نمونه ۶۰ ثانیه Timeout تعریف میکند:
builder.Services.PostConfigure<HostOptions>(option =>
{
option.ShutdownTimeout = TimeSpan.FromSeconds(60);
});
Listing 7-12 — تنظیم ShutdownTimeout روی ۶۰ ثانیه
Endpoint Upload فایل را به Channel تحویل میدهد. چون پردازش هنوز تمام نشده است، پاسخ مناسب در حالت پذیرش موفق 202 Accepted است.
app.MapPost("/countries/upload", async (IFormFile file,
ICountryFileIntegrationChannel channel, CancellationToken cancellationToken) =>
{
if (await channel.SubmitAsync(
file.OpenReadStream(),
cancellationToken))
Results.Accepted();
Results.StatusCode(StatusCodes.Status500InternalServerError);
}).DisableAntiforgery();
Listing 7-13 — ارسال فایل از POST /countries/upload به Channel
شکل ۷-۳ — اجرای Background Service پس از انتشار پیام در Channel
این الگو برای عملیات طولانی مفید است تا Client مجبور نباشد تا پایان پردازش منتظر بماند. برای چند نوع Background Task بهتر است Channel اختصاصی هر Task ایجاد شود تا پیام هر صف به Consumer مربوط به خودش برسد.
Paging (صفحهبندی)
وقتی Endpoint مجموعهٔ بزرگی از داده برمیگرداند، ارسال کل داده میتواند پهنای باند و حافظه را بیدلیل مصرف کند؛ مخصوصاً در Clientهایی با اتصال محدود مانند Mobile App. صفحهبندی مجموعه را به بخشهای کوچکتر تقسیم میکند.
نمونه از دو Query Parameter استفاده میکند: pageIndex برای شمارهٔ صفحه و pageSize برای تعداد آیتم هر صفحه.
شکل ۷-۴ — مجموعهٔ دهعضوی در دو صفحهٔ پنجتایی
app.MapGet("/countries", async (
int? pageIndex,
int? pageSize,
ICountryMapper mapper,
ICountryService countryService) => {
var countries = await countryService
.GetAllAsync(
new PagingDto {
PageIndex = pageIndex.HasValue ? pageIndex.Value : 1,
PageSize = pageSize.HasValue ? pageSize.Value : 10
});
return Results.Ok(mapper.Map(countries));
});
Listing 7-14 — GET /countries با پارامترهای Paging
اگر Query Parameter زیاد باشد میتوان آنها را در یک Object کپسوله و از [AsParameters] استفاده کرد. چون Query Parameter اجباری نیست، در Endpoint بهصورت Nullable دریافت شده و در نبود مقدار، Default اعمال میشود.
namespace Domain.DTOs;
public class PagingDto
{
public int PageIndex { get; set; } = 1;
public int PageSize { get; set; } = 10;
}
Listing 7-15 — کلاس PagingDto
در Query EF Core، Skip تعداد ردیفهایی را که باید قبل از صفحهٔ فعلی رد شوند محاسبه میکند و Take فقط تعداد آیتم صفحه را برمیگرداند. صفحهبندی باید در SQL انجام شود؛ نه اینکه ابتدا همهٔ رکوردها به برنامه منتقل و سپس در حافظه بریده شوند.
public async Task<List<CountryDto>>
GetAllAsync(PagingDto paging)
{
return await _demoContext.Countries
.AsNoTracking()
.Select(x => new CountryDto
{
Id = x.Id,
Name = x.Name,
Description = x.Description,
FlagUri = x.FlagUri
})
.Skip((paging.PageIndex - 1) * paging.PageSize)
.Take(paging.PageSize)
.ToListAsync();
}
Listing 7-16 — Query صفحهبندیشده با Skip و Take
همین مفهوم برای Refit و IHttpClientFactory نیز با افزودن Query Parameterهای لازم به Request قابل اجراست. مجموعههای حجیم را برای حفظ Performance صفحهبندی کنید.
JSON Streaming
ASP.NET Core میتواند آیتمهای یک مجموعه را بهصورت Streaming و یکییکی به Client بفرستد، بهجای اینکه ابتدا کل مجموعه را آماده و سپس یکباره ارسال کند. این کار میتواند استفاده از پهنای باند و زمان شروع دریافت را بهتر کند. برخی Clientهای سنگین مانند الگوی معمول HttpClient ممکن است تا دریافت کامل Response عملاً داده را یکجا مصرف کنند، درحالیکه Clientهایی مانند JavaScript میتوانند از آیتمهای رسیده بهصورت تدریجی استفاده کنند.
Endpoint زیر بهجای یک List، IAsyncEnumerable<Country> برمیگرداند و هر Country را با yield return منتشر میکند.
app.MapGet("/countries", async (
int? pageIndex,
int? pageSize,
ICountryMapper mapper,
ICountryService countryService) => {
async IAsyncEnumerable<Country> StreamCountriesAsync()
{
var countries = await countryService
.GetAllAsync(
new PagingDto
{
PageIndex = pageIndex.HasValue ? pageIndex.Value : 1,
PageSize = pageSize.HasValue ? pageSize.Value : 10
});
var mappedCountries = mapper.Map(countries);
foreach (var country in mappedCountries)
{
yield return country;
}
}
return StreamCountriesAsync();
});
Listing 7-17 — GET /countries با خروجی IAsyncEnumerable<Country>
تصاویر منبع مرتبط با این بخش