Az adatbázis rétegünket többféleképpen tudnánk leimplementálni. Lényeg az alkalmazáslogika szempontjából, hogy biztosítva legyen egy IUrlRepository implementáció. Mivel a könyv korábbi fejezeteiben az Entity Framework-ről volt szó, ezért kézenfekvő, hogy ezzel érjük el az adatainkat.
Az adatbázis kapcsán beszélnünk kell a felhasználókról és azok azonosításáról, hiszen adatokat kell tárolnunk a felhasználókról. Az autentikáció és autorizáció témaköre nem egyszerű. A kriptográfia, a jó gyakorlatok és a helyes adatbázis használat közös metszetén múlik, hogy egy rendszer biztonságos lesz vagy sebezhető, ezért a legkézenfekvőbb, hogyha használunk egy olyan komponenst, ami erre a célra lett kitalálva.
Persze van az a szituáció, amikor érdemes lehet egy sajátot is fejleszteni, de ez ritka és nagyon sok apróság van, amire ügyelni érdemes és folyamatosan naprakészen kell tartani. Éppen ezért a Microsoft az ASP.NET-hez biztosít egy azonosításra kitalált könyvtárat, ami a Microsoft.AspNetCore.Identity csomagban található. De mi köze ennek az adatbázishoz? Természetesen a tárolás.
Az Entity Framework-nek szüksége van egy DbContext implementációra és az Identity használatához kell egy DBContext, ami az adatokat tárolja. A Microsoft.AspNetCore.Identity.EntityFrameworkCore tartalmaz egy IdentityDbContext implementációt, amiből örököltetve a saját DbContext implementációnkat rendelkezni fogunk a felhasználók és jogosultságok modellezéséhez és tárolásához szükséges típusokkal, így csak az URL entitásunkat kell modellezni és konfigurálni.
Az entitásunk és konfigurációja:
using Microsoft.AspNetCore.Identity;
namespace UrlShortner.Database.Entity;
public class UrlEntity
{
public long Id { get; init; }
public string LongUri { get; set; } = null!;
public string ShortUri { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime? ValidTill { get; set; }
public IdentityUser CreatedBy { get; set; } = null!;
public long ClickCount { get; set; }
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UrlShortner.Database.Entity;
namespace UrlShortner.Database.EntityConfigurations;
internal class UrlEntityConfig : IEntityTypeConfiguration<UrlEntity>
{
public void Configure(EntityTypeBuilder<UrlEntity> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.LongUri).IsRequired().HasMaxLength(2048);
builder.Property(x => x.ShortUri).IsRequired().HasMaxLength(24);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ValidTill).IsRequired(false);
builder.Property(x => x.ClickCount).IsRequired().HasDefaultValue(0);
builder.HasOne(x => x.CreatedBy).WithMany().OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => x.ShortUri);
}
}
A DBContext implementációnk:
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using UrlShortner.Database.Entity;
namespace UrlShortner.Database;
public class UrlShortnerDbContext : IdentityDbContext
{
public UrlShortnerDbContext(DbContextOptions options) : base(options)
{
Database.Migrate();
}
protected UrlShortnerDbContext()
{
Database.Migrate();
}
public DbSet<UrlEntity> Urls { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(UrlShortnerDbContext).Assembly);
}
}
A UrlShortnerDbContext két konstruktorral rendelkezik. Az egyik protected módosítóval ellátott. Ez a konstruktor a migrációk tervezésekor fog futni csupán, a másik konstruktor pedig egy DbContextOptions példányt vár el, amit továbbpasszol az ős konstruktorának. Ez a konstruktor fog futni az éles alkalmazásban és ez teszi lehetővé, hogy az SQL kiszolgáló konfigurációs adatait ne kelljen beégetnünk a kódba.
Adatelérésre az alkalmazásunk SQLite-ot fog használni, mivel ennek a teljesítménye egyelőre bőven megfelel a nem funkcionális követelményekben támasztott elvárásoknak és az "Entity Framework-nek köszönhetően igény esetén bármikor cserélhetjük egy másik adatbázis kiszolgálóra.
A migrációk tervezéséhez és generálásához szükségünk lesz egy tervezési idejű DBContext Factory-ra. Erre azért van szükség, mert a DB elérésért felelős kód nem ugyan abban a szerelvényben található, mint az alkalmazás, ami használni fogja.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace UrlShortner.Database;
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<UrlShortnerDbContext>
{
public UrlShortnerDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<UrlShortnerDbContext>();
optionsBuilder.UseSqlite("Data Source=Application.db", b => b.MigrationsAssembly("UrlShortner.Database"));
return new UrlShortnerDbContext(optionsBuilder.Options);
}
}
Ezt követően az első migrációt a solution mappájából az első parancs segítségével tudjuk létrehozni:
dotnet ef migrations add InitialCreate --project UrlShortner.Database
A migráció létrehozása után érdemes a DB elérési régteget optimalizáltatni a dotnet ef optimize --project UrlShortner.Database parancs kiadásával. Az Entity Framework tradicionálisan futásidőben generálja ki az SQL-ből osztályokat és osztályokból SQL-t gyártó logikát. Ennek az elkészítése időt vesz igénybe, ami megspórolható, ha fordítási időben kigeneráltatjuk ezt. A fenti parancs pont ezt csinálja. A projektben meg fog jelenni egy CompiledModels névtér, ami ezt a kódot tartalmazza.
Megjegyzés: Compiled modellek esetén a modell módosításakor nem csak egy új migrációt kell gyártanunk, hanem ismételten le kell futtatnunk a dotnet ef parancsot az optimize kapcsolóval.
Az optimalizált, lefordított modellt elvileg automatikusan használni fogja a kód, de biztos, ami biztos a DBContext OnConfiguringmetódusát felülírva érdemes kikényszeríteni ezt:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseModel(UrlShortnerDbContextModel.Instance);
}
Repository implementáció
A repository implementációnk a DB context műveleteit és az SQL utasításokat csomagolja be úgy, hogy az megfeleljen az üzleti logikában definiált interfésznek.
using Microsoft.EntityFrameworkCore;
using UrlShortner.Core;
using UrlShortner.Core.Models;
namespace UrlShortner.Database;
public sealed class UrlRespository : IUrlRepository
{
private readonly UrlShortnerDbContext _context;
private bool _disposed;
public UrlRespository(UrlShortnerDbContext context)
{
_context = context;
}
public void Dispose()
{
_context.Dispose();
_disposed = true;
}
public void Delete(UrlModel model)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(UrlRespository));
_context.Urls.Where(u => u.ShortUri == model.ShortUri).ExecuteDelete();
}
public async Task<UrlModel?> GeByShortUriAsync(string shortUri)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(UrlRespository));
var url = await _context.Urls.FirstOrDefaultAsync(u => u.ShortUri == shortUri);
if (url != null)
return url.ToModel();
return null;
}
public async IAsyncEnumerable<UrlModel> GetUrlsAsync(string userName)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(UrlRespository));
await foreach (var url in _context.Urls.Where(u => u.CreatedBy.UserName == userName).AsNoTracking().AsAsyncEnumerable())
{
yield return url.ToModel();
}
}
public void Create(UrlModel urlModel)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(UrlRespository));
var user = _context.Users.First(u => u.UserName == urlModel.CreatedBy.UserName);
var entity = urlModel.ToEntity();
entity.CreatedBy = user;
_context.Urls.Add(entity);
}
public void Update(UrlModel urlModel)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(UrlRespository));
var entity = _context.Urls.First(u => u.ShortUri == urlModel.ShortUri);
entity.ClickCount = urlModel.ClickCount;
entity.ValidTill = urlModel.ValidTill;
_context.Entry(entity).State = EntityState.Modified;
}
public async Task Save()
{
ObjectDisposedException.ThrowIf(_disposed, nameof(UrlRespository));
await _context.SaveChangesAsync();
}
}
A kódot elnézve és az Entity Framework felépítését ismerve jogos kérdés lehet, hogy kell-e egyáltalán nekünk egy extra repository réteg, hiszen a DBcontext alatt lévő kiszolgálót bármikor cserélhetjük, sőt tesztelési célra ott van dedikáltan az InMemory kiszolgáló.
Ez tipikusan egy „attól függ” döntés a szoftverarchitektúrában. De mitől is függ? Leginkább az alkalmazás követelményeitől. A legtöbb esetben a DBContext használata elfogadható, de ez azzal jár, hogy a későbbiekben mindig Entity Framework-öt kell használnunk adatelérésre. Ez nem biztos, hogy baj, de ha a jövőben skálázási bajaink lesznek és az Entity Framework a szűk keresztmetszet, akkor az alkalmazásunk egy jó részét adaptálni kell majd. Itt egy fontos kérdés az, hogy a jövőben felmerülhet-e olyan igény, ami indokolhatja a Repository leválasztással járó plusz munkát.
Jelen alkalmazás kontextusában valószínűleg nem lett volna szükség rá, de mivel állatorvosi lóról van szó, ezért érdemesnek gondoltam erről is beszélni. Ha az éles alkalmazásunkban a repository alkalmazása mellett döntünk, akkor készüljünk fel arra, hogy a tradicionális repository eléggé rugalmatlan lesz hosszú távon, ha az alkalmazás bővül. Éppen ezért érdemes kiegészíteni egy unit of work és specification pattern kombinációval. Ezek implementálása kutatómunkát és biztosan extra munkát fognak igényelni.