فصل ۶ — دسترسی ایمن و کارآمد به داده
تا اینجا تمرکز کتاب روی قابلیتهای ASP.NET Core و سطح API بوده است. اکنون نوبت لایههای پشت API است؛ جایی که برنامه معمولاً داده را از چند منبع میگیرد. دو منبع رایج در این فصل پایگاه دادهٔ SQL و یک REST API راهدور از طریق HTTP هستند. هدف این فصل آن است که این دسترسیها هم ایمن و هم کارآمد باشند.
موضوعات اصلی فصل:
- بهترین شیوههای عمومی دسترسی به داده
- دسترسی به داده با Entity Framework Core 8
- دسترسی به REST API با HttpClient
مقدمهای بر بهترین شیوههای دسترسی به داده
مسئله فقط خود داده نیست؛ نحوهٔ دسترسی به آن نیز اهمیت دارد. فناوریهای دیگری مانند gRPC، OData و NoSQL وجود دارند، اما این فصل روی SQL و HTTP تمرکز میکند، چون بسیار متداولاند.
در هر دو حالت ممکن است اتصال به منبع داده شکست بخورد؛ برای مثال مشکل شبکه وجود داشته باشد یا منبع راهدور برای مدتی Overload یا Unavailable باشد. برنامه روی وقوع چنین خطاهایی کنترل کامل ندارد، اما میتواند با Retry Strategy با آنها مقابله کند. خطاهای Transient (گذرا) خطاهای موقتیاند که ممکن است خودبهخود برطرف شوند؛ تکرار عملیات در برابر این خطاها بخشی از Resilience (تابآوری) است. این اصل برای اتصال SQL، درخواست HTTP و دیگر منابع راهدور کاربرد دارد. در بخش HTTP از کتابخانهٔ Polly نیز استفاده خواهد شد.
دسترسی به دادهٔ SQL
در SQL دو موضوع اصلی باید همزمان در نظر گرفته شوند:
- ایمنی Queryها: ورودیهای کاربر نباید اجازه دهند SQL Injection رفتار Query را تغییر دهد یا اطلاعات حساس افشا کند. استفاده از Object-Relational Mapping (ORM) مانند Entity Framework Core دادهٔ SQL را از طریق Entityهای C# و LINQ در دسترس قرار میدهد. EF Core عبارت LINQ را به SQL تبدیل میکند و در سناریوهای معمول، Parameterization آن از SQL Injection جلوگیری میکند.
- مدیریت Connection: باز و بسته کردن اتصال SQL هزینه دارد. Connection Pooling اتصالهای باز را برای استفادهٔ مجدد نگه میدارد و مخصوصاً در حجم بالای ترافیک تفاوت قابلتوجهی در Performance (کارایی) ایجاد میکند.
دسترسی به داده با HTTP
در .NET درخواستهای راهدور با HTTP Client انجام میشوند. اتصال HTTP نیز یک منبع محدود است و ساخت تعداد زیاد Handler میتواند مشکلاتی مانند Socket Exhaustion ایجاد کند. IHttpClientFactory چرخهٔ عمر HttpMessageHandler را کارآمدتر مدیریت میکند. در ادامه Refit نیز معرفی میشود؛ کتابخانهای که روی Typed HttpClient و IHttpClientFactory قرار میگیرد و نوشتن Clientهای REST را با کد کمتر سادهتر میکند.
معماری دسترسی به داده
مطابق اصول معماری فصلهای قبل، فناوری دسترسی به داده باید از منطق Domain جدا باشد. در نمونههای کتاب، یک لایهٔ Infrastructure برای SQL و یک لایهٔ Infrastructure جداگانه برای HTTP ایجاد میشود؛ زیرا مخلوط کردن فناوریهای متفاوت در یک لایه با Separation of Concerns سازگار نیست.
هر لایهٔ Infrastructure به Domain وابسته است. Domain قراردادهای داده مانند DTO یا Domain Object و Interfaceهای Service/Repository را در اختیار میگذارد. لایهٔ فناوری Interfaceهای Repository را پیادهسازی میکند و DTOهایی را برمیگرداند که توسط لایهٔ BLL و در نهایت API مصرف میشوند.
شکل ۶-۱ — معماری Solution با لایههای Infrastructure
لایهها تا حد امکان از یکدیگر Decouple هستند. API برای ثبت Abstractionها و Implementationها در Dependency Injection به لایههای لازم دسترسی دارد. در Repositoryها Exceptionها محلی Catch نمیشوند؛ همان سازوکار Exception Handler فصل قبل مسئول مدیریت سراسری آنهاست.
دسترسی به داده با Entity Framework Core 8
Entity Framework Core (EF Core) چارچوب دسترسی به دادهٔ Microsoft و یک ORM است. در این الگو Entityهای C# به جدولهای SQL نگاشت میشوند. نمونهٔ فصل یک جدول Countries در SQL Server است. هدف معرفی اصول اصلی EF Core و چند Optimization مرتبط است، نه پوشش کامل این فناوری.
یک پروژهٔ جدید با نام Infrastructure.SQL ساخته و بستهٔ NuGet زیر به آن افزوده میشود:
Microsoft.EntityFrameworkCore.SqlServer
شکل ۶-۲ — لایهٔ Infrastructure.SQL
گام ۱: ساخت CountryEntity
CountryEntity از نظر Propertyها شبیه CountryDto است، اما مسئولیت متفاوتی دارد. Entity مستقیماً به مدل پایگاه داده نگاشت میشود، درحالیکه DTO یک قرارداد Domain است و نباید به پایگاه داده وابسته باشد.
namespace Infrastructure.SQL.Database.Entities;
public class CountryEntity
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string FlagUri { get; set; }
}
Listing 6-1 — کلاس CountryEntity
گام ۲: ساخت DbContext
در EF Core، DbContext Context اجرای دسترسی به داده را نگه میدارد؛ یعنی Connection و وضعیت Entityها را مدیریت میکند و با LINQ عملیات خواندن، درج، بهروزرسانی و حذف را به SQL تبدیل میکند. کلاس DemoContext از DbContext ارث میبرد و یک DbSet<CountryEntity> برای جدول کشورها دارد.
using Infrastructure.SQL.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.SQL.Database;
public class DemoContext : DbContext
{
public DemoContext (DbContextOptions options) : base(options)
{
}
public DbSet<CountryEntity> Countries { get; set; }
}
Listing 6-2 — کلاس DemoContext
گام ۳: پیکربندی CountryEntity
EF Core برخی نگاشتهای نوع را با Convention تشخیص میدهد، اما Constraintهای موردنیاز مدل باید بهصراحت تعیین شوند. در این نمونه شناسه کلید اصلی و Auto-increment است، نام کشور Unique است، توضیح حداکثر ۲۰۰ کاراکتر دارد و همهٔ فیلدها Required هستند.
پیکربندی میتواند با Attribute روی Entity یا با Fluent API در OnModelCreating انجام شود. نویسنده روش دوم را انتخاب کرده و نام جدول و Schema را نیز بهصورت صریح مشخص میکند.
using Infrastructure.SQL.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.SQL.Database;
public class DemoContext : DbContext
{
public DemoContext (DbContextOptions options) : base(options)
{
}
public DbSet<CountryEntity> Countries { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var builder = modelBuilder.Entity<CountryEntity>();
builder.ToTable("Countries", "dbo");
builder.HasIndex(p => p.Name).IsUnique(true);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.Name).IsRequired();
builder.Property(p => p.Description).HasMaxLength(200).IsRequired();
builder.Property(p => p.FlagUri).IsRequired();
base.OnModelCreating(modelBuilder);
}
}
Listing 6-3 — DemoContext همراه با تنظیم CountryEntity
ValueGeneratedOnAdd تولید شناسه هنگام درج را تنظیم میکند، IsRequired Nullable نبودن فیلدها را مشخص میکند، IsUnique روی Index نام قید یکتایی ایجاد میکند و HasMaxLength(200) طول توضیح را محدود میکند. نام ستونها در این مثال همان نام Propertyهای C# است.
گام ۴: تولید مدل پایگاه داده از C#
برای ایجاد یا بهروزرسانی Database باید Connection String تعریف شود و برنامه در Startup Migrationها را اعمال کند. نمونه از SQL Server LocalDB استفاده میکند که همراه Visual Studio 2022 قابل نصب است.
"ConnectionStrings": {
"DemoDb": "Data Source=(LocalDB)\\MSSQLLocalDB;Initial Catalog=DemoDb;MultipleActiveResultSets=true;Encrypt=false;timeout=30;"
}
Listing 6-4 — Connection String پایگاه داده
Timeout اتصال روی ۳۰ ثانیه تنظیم شده است تا برنامه برای دریافت Connection مدت نامحدود منتظر نماند، ولی فرصت کوتاهی برای در دسترس شدن SQL Server داشته باشد.
using Infrastructure.SQL.Database;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var dbConnection = builder.Configuration.GetConnectionString("DemoDb");
builder.Services.AddDbContextPool<DemoContext>(options =>
options.UseSqlServer(dbConnection));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<DemoContext>();
db.Database.SetConnectionString(dbConnection);
db.Database.Migrate();
}
app.Run();
Listing 6-5 — پیکربندی اتصال پایگاه داده
GetConnectionString مقدار تنظیمشده را میخواند. AddDbContextPool علاوه بر ثبت Context در DI از Pooling برای استفادهٔ مجدد کارآمد از Context/Connection پشتیبانی میکند. Migrate Migrationهای EF Core را روی Database اعمال میکند.
برای تولید Migration بستهٔ Microsoft.EntityFrameworkCore.Design در لایهٔ API و Microsoft.EntityFrameworkCore.Tools در Infrastructure.SQL نصب میشوند. سپس در Package Manager Console، پروژهٔ Infrastructure.SQL انتخاب و دستور زیر اجرا میشود:
شکل ۶-۳ — تولید Migration اولیه
با اجرای برنامه، Database و جدول Countries ایجاد میشوند.
شکل ۶-۴ — پایگاه دادهٔ Demo پس از تولید
EF Core همچنین جدول تاریخچهٔ Migration ایجاد میکند تا بداند کدام Migrationها قبلاً روی Database اعمال شدهاند و از اجرای تکراری آنها جلوگیری کند. برای تغییرات بعدی مدل، Migration جدید ساخته میشود.
شکل ۶-۵ — جدول تاریخچهٔ Migrationها
گام ۵: فعالسازی Resilience در EF Core
برای SQL Server میتوان Retry داخلی EF Core را بدون Polly فعال کرد. EnableRetryOnFailure در نمونه حداکثر سه بار خطای گذرا را Retry میکند.
builder.Services.AddDbContextPool<DemoContext>(options =>
options.UseSqlServer(dbConnection,
sqlServerOptionsAction: sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3);
}));
Listing 6-6 — فعالسازی Resilience برای Connectionهای SQL Server
میتوان Delay بین Retryها و مجموعهٔ خطاهای Transient را نیز تنظیم کرد. نکتهٔ اصلی این است که خطاهای گذرای منبع خارجی باید در طراحی در نظر گرفته شوند؛ اگر Database برای مدت کوتاهی در دسترس نباشد، Retryها پیش از پرتاب Exception نهایی اجرا میشوند.
گام ۶: پیادهسازی Repository روی CountryEntity
ابتدا Interface مربوط به Repository در Domain قرار میگیرد تا Abstraction از فناوری SQL جدا بماند.
using Domain.DTOs;
namespace Domain.Repositories;
public interface ICountryRepository
{
Task<CountryDto> RetrieveAsync(int id);
Task<List<CountryDto>> GetAllAsync();
Task<int> CreateAsync(CountryDto country);
Task<int> UpdateAsync(CountryDto country);
Task<int> UpdateDescriptionAsync(int id, string description);
Task<int> DeleteAsync(int id);
}
Listing 6-7 — رابط ICountryRepository
متدها Task برمیگردانند و همهٔ دسترسیهای I/O بهصورت ناهمگام پیادهسازی میشوند. جزئیات چرایی Async/Await در فصل ۷ بررسی خواهد شد.
using Domain.DTOs;
using Domain.Repositories;
using Infrastructure.SQL.Database;
using Infrastructure.SQL.Database.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.SQL.Repositories;
public class CountryRepository : ICountryRepository
{
private readonly DemoContext _demoContext;
public CountryRepository(DemoContext demoContext)
{
_demoContext = demoContext;
}
public async Task<int> CreateAsync(CountryDto country)
{
var countryEntity = new CountryEntity
{
Name = country.Name,
Description = country.Description,
FlagUri = country.FlagUri
};
await _demoContext.AddAsync(countryEntity);
await _demoContext.SaveChangesAsync();
return countryEntity.Id;
}
public async Task<int> UpdateAsync(CountryDto country)
{
var countryEntity = new CountryEntity
{
Id = country.Id,
Name = country.Name,
Description = country.Description,
FlagUri = country.FlagUri
};
return await _demoContext.Countries
.Where(x => x.Id == countryEntity.Id)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Name, countryEntity.Name)
.SetProperty(p => p.Description, countryEntity.Description)
.SetProperty(p => p.FlagUri, countryEntity.FlagUri));
}
public async Task<int> DeleteAsync(int id)
{
return await _demoContext.Countries
.Where(x => x.Id == id)
.ExecuteDeleteAsync();
}
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();
}
public async Task<CountryDto> RetrieveAsync(int id)
{
return await _demoContext.Countries
.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new CountryDto
{
Id = x.Id,
Name = x.Name,
Description = x.Description,
FlagUri = x.FlagUri
})
.FirstOrDefaultAsync();
}
public async Task<int> UpdateDescriptionAsync(int id, string description)
{
return await _demoContext.Countries
.Where(x => x.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Description, description));
}
}
Listing 6-8 — کلاس CountryRepository
چند بخش مهم این Syntax عبارتاند از:
AsNoTracking: وقتی Entity فقط خوانده و به Client برگردانده میشود و قرار نیست در همان Context تغییر کند، Tracking را غیرفعال میکند و سربار را کاهش میدهد.
AddAsync و SaveChangesAsync: Entity را به Context اضافه و تغییر را بهصورت Async در Database ذخیره میکنند. پس از ذخیره، شناسهٔ Auto-increment روی Entity مقداردهی میشود.
FirstOrDefaultAsync: Query را اجرا و نخستین رکورد Matchشده را برمیگرداند؛ اگر موردی پیدا نشود مقدار پیشفرض، برای Object معمولاً null، بازگردانده میشود.
ExecuteDeleteAsync: بدون بارگذاری Entityها، رکوردهای Matchشده را حذف میکند.
ExecuteUpdateAsync: بدون بارگذاری Entityها، Propertyهای رکوردهای Matchشده را بهروزرسانی میکند.
Projection به DTO
قرار دادن CountryDto داخل Select یک Projection ایجاد میکند. EF Core در نتیجه تنها ستونهایی را از SQL درخواست میکند که برای ساخت DTO لازماند، بهجای آنکه همهٔ ستونها را بخواند و سپس در حافظه بخشی از آنها را Map کند. این کار Query را سبکتر و کارآمدتر میکند.
Service لایهٔ BLL روی Repository قرار میگیرد:
using Domain.DTOs;
using Domain.Repositories;
using Domain.Services;
namespace BLL.Services;
public class CountryService : ICountryService
{
private readonly ICountryRepository _countryRepository;
public CountryService(ICountryRepository countryRepository)
{
_countryRepository = countryRepository;
}
public async Task<bool> DeleteAsync(int id)
{
return await _countryRepository.DeleteAsync(id) > 0;
}
public async Task<List<CountryDto>> GetAllAsync()
{
return await _countryRepository.GetAllAsync();
}
public async Task<CountryDto> RetrieveAsync(int id)
{
return await _countryRepository.RetrieveAsync(id);
}
public async Task<int> CreateOrUpdateAsync(CountryDto country)
{
if (country?.Id is null)
return await _countryRepository.CreateAsync(country);
if (await _countryRepository.CreateAsync(country) > 0)
return country.Id;
return 0;
}
public async Task<bool> UpdateDescriptionAsync(int id, string description)
{
return await _countryRepository.UpdateDescriptionAsync(id, description) > 0;
}
}
Listing 6-9 — کلاس CountryService
اتصال لایهها در Program.cs
در آخر Endpointها به Serviceهای Async متصل میشوند، DbContext Pool و Retry SQL ثبت میشود، Repository و Serviceها در DI قرار میگیرند و Migration در Startup اجرا میشود.
var builder = WebApplication.CreateBuilder(args);
var dbConnection = builder.Configuration.GetConnectionString("DemoDb");
builder.Services.AddDbContextPool<DemoContext>(options =>
options.UseSqlServer(dbConnection,
sqlServerOptionsAction: sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(maxRetryCount: 3);
}));
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<ICountryMapper, CountryMapper>();
builder.Services.AddScoped<ICountryService, CountryService>();
builder.Services.AddScoped<ICountryRepository, CountryRepository>();
builder.Services.AddExceptionHandler<TimeOutExceptionHandler>();
builder.Services.AddExceptionHandler<DefaultExceptionHandler>();
var app = builder.Build();
app.MapPost("/countries", async (
[FromBody] Country country,
ICountryMapper mapper,
ICountryService countryService) => {
var countryDto = mapper.Map(country);
var countryId = await countryService.CreateOrUpdateAsync(countryDto);
if (countryId <= 0)
return Results.StatusCode(StatusCodes.Status500InternalServerError);
return Results.CreatedAtRoute("countryById", new { Id = countryId });
}).AddEndpointFilter<InputValidatorFilter<Country>>();
app.MapGet("/countries/{id}", async (
int id,
ICountryMapper mapper,
ICountryService countryService) => {
var country = await countryService.RetrieveAsync(id);
if (country is null)
return Results.NotFound();
return Results.Ok(mapper.Map(country));
}).WithName("countryById");
app.MapGet("/countries", async (
ICountryMapper mapper,
ICountryService countryService) => {
var countries = await countryService.GetAllAsync();
return Results.Ok(mapper.Map(countries));
});
app.MapDelete("/countries/{id}", async (
int id,
ICountryService countryService) => {
if (await countryService.DeleteAsync(id))
return Results.NoContent();
return Results.NotFound();
});
app.MapPut("/countries", async (
[FromBody] Country country,
ICountryMapper mapper,
ICountryService countryService) => {
var countryDto = mapper.Map(country);
var countryId = await countryService.CreateOrUpdateAsync(countryDto);
if (countryId <= 0)
return Results.StatusCode(StatusCodes.Status500InternalServerError);
if (country.Id is null)
return Results.CreatedAtRoute("countryById", new { Id = countryId });
return Results.NoContent();
}).AddEndpointFilter<InputValidatorFilter<Country>>();
app.MapPatch("/countries/{id}", async (
int id,
[FromBody] CountryPatch countryPatch,
ICountryMapper mapper,
ICountryService countryService) => {
if (await countryService.UpdateDescriptionAsync(id, countryPatch.Description))
return Results.NoContent();
return Results.NotFound();
}).AddEndpointFilter<InputValidatorFilter<CountryPatch>>();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<DemoContext>();
db.Database.SetConnectionString(dbConnection);
db.Database.Migrate();
}
app.Run();
Listing 6-10 — Program.cs با لایهٔ SQL و Endpointهای Async
شکل ۶-۶ — ساختار کلی Solution در ASP.NET Core
در این نقطه جریان کامل از تعریف Endpoint تا Service، Repository، EF Core و Database پیادهسازی شده است. بخش بعدی دسترسی به داده از طریق HTTP و Optimizationهای مربوط به آن را بررسی میکند.
تصاویر منبع مرتبط با این بخش