Adatmodell
A tervezést én minden esetben az adatmodellezéssel szeretem kezdeni, mivel ezen entitások köré tudom a leghatékonyabban felépíteni a kódomat. Az URL rövidítőnk központi eleme egy olyan modell lesz, ami leírja azt, hogy hogyan kell egy rövid URL-t hosszú, tényleges URL-re konvertálni. Ezt a kódunkban az UrlModel osztály fogja reprezentálni.
Mivel egy tulajdonos felhasználóhoz kell rendelni egy URL-t, ezért szükségünk lesz egy UserModel osztályra is.
namespace UrlShortner.Core.Models;
public class UrlModel
{
public required Uri LongUri { get; set; }
public required string ShortUri { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime? ValidTill { get; set; }
public required UserModel CreatedBy { get; set; }
public required long ClickCount { get; set; }
}
public class UserModel
{
public required string UserName { get; init; }
}
A hosszú URL-ek modellezésénél a .NET beépített Uri osztályát alkalmaztam, mivel ez kifejezetten URL-ek modellezésére lett kitalálva és ha később bővítenénk a kódot, funkcionalitást, akkor ez az osztály egy csomó hasznos kódot biztosít, ami az URL-ek kezeléséhez kapcsolódik.
A létrehozás idejének tárolása nem volt explicit requirement, azonban a későbbi módosítás szempontjából hasznos és szükséges, mert le kell kezelni azt a szituációt, hogy az URL nem lehet hamarábbi lejáratú, mint ahogy létre lett hozva.
A felhasználó szempontjából egy csomó tulajdonságot kellene tárolnunk és fogunk is majd, de üzleti logika modellezés szintjén a felhasználóból nekünk csak annyi fontos, hogy be tudjuk azonosítani valamilyen formában.
Ehhez használhatnánk egy ID-t is, hiszen elsődleges kulcs alapján a legkönnyebb keresni egy relációs adatbázisban, de kényszereket hoznánk be implicit módon az adatbázis modell számára. Egy ilyen kényszer például az elsődleges kulcs típusára vonatkozna. Azonban a nagyobb baj az, hogy ha szekvenciálisan növekedő, egész szám alapú ID-t alkalmazna a DB, akkor egy másik adatbázis importáláskor a szekvenciális ID-k már nem stimmelnek majd és az alkalmazás rosszul fog működni.
Az üzleti logika szempontjából ez egy olyan részletkérdés, amivel nem kellene neki foglalkoznia. Éppen ezért inkább az üzleti logikánk a felhasználót a neve alapján fogja azonosítani.
Rövid URL generálása
Az alkalmazás szempontjából az egyik legfontosabb szolgáltatás, hogy hogyan képzünk egy hosszú URL-ből rövid URL-t. Az első kérdés, amit itt fel kell tennünk magunknak, hogy determinisztikus vagy nem determinisztikus generálást szeretnénk alkalmazni.
A determinisztikus vagy nem determinisztikus jelen esetben azt jelenti, hogy egy ismert bemenet minden esetben ugyanazt a kimenetet jelentse-e. Mindkét megoldásnak van előnye és hátránya.
A determinisztikus megoldás előnye lenne, hogy ugyanahhoz a hosszú URL-hez nem generálódna több rövid URL és könnyebb lenne ezek tárolása. Hátránya viszont az, hogy nem túl biztonságos módszer, mivel a kiszámíthatóság miatt könnyebb megjósolni az URL rövidített változatát, illetve a privát URL-ek kezelését igencsak megnehezítené, mivel két felhasználó ugyan azon linkje nem két bejegyzést eredményezne, hanem ugyanazt.
A nem determinisztikus generálás előnye, hogy rugalmas és biztonságos. Ha jó implementáljuk, akkor nehéz megjósolni, mi lesz a rövidített forma és minden egyes rövidítés egy új egyedi azonosítót hoz létre. Viszont cserébe komplexebb lesz a rendszer.
A privát (felhasználóhoz rendelt) linkek támogatásának szükségessége és a biztonság miatt a nem determinisztikus generálást választottam.
A rövid URL generálás esetén érdemes kicsit kitérni a könnyű használhatóságra. Programozói szempontból kézenfekvő lenne a 7 bites ASCII kódolásból az összes nyomtatható karaktert felhasználni, mivel ebben az esetben pár karakterrel egész sok URL-t le tudnánk fedni, de felhasználói szempontból ez nem biztos, hogy a legjobb ötlet. Mégpedig azért, mert előfordulhat, hogy a felhasználónak kézzel kell beírnia az URL-t. Ebben az esetben a speciális karakterek legépelése nehézkes lehet, mivel billentyűzetkiosztástól függően ezek akár több gomb lenyomását is igénylik és telefonról sem egyszerű ezeket legépelni. Valamint figyelembe kell venni a betűtípusokat is. Egyes betűtípusok esetén az O és a nulla nagyon hasonló, ami könnyen elírást eredményezhet, ezért ezeket érdemes kihagyni a generálásból.
using System.Security.Cryptography;
using System.Text;
namespace UrlShortner.Core.Services;
public interface IShortNameGenerator
{
int Length { get; }
string GenerateShortName();
}
public sealed class ShortNameGenerator : IShortNameGenerator
{
private const string Characters
= "abcdefghjklmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789-";
public ShortNameGenerator(int length = 10)
{
Length = length;
}
public int Length { get; }
public string GenerateShortName()
{
StringBuilder result = new(Length);
for (int i = 0; i < Length; i++)
{
int index = RandomNumberGenerator.GetInt32(0, Characters.Length);
result.Append(Characters[index]);
}
return result.ToString();
}
}
A rövid név generálásért felelős komponenst az IShortNameGenerator interfész írja le. Ez egyetlen egy metódust tartalmaz, ami a GenerateShortName() nevet kapta. Az implementációjában konfigurálható a generált azonosítók hossza, ami alapértelmezetten 10 karakterre van beállítva. A generálás a fix 58 lehetséges szimbólum közül választ ki 10-et minden esetben, ami azt jelenti, hogy minden generált helyre 58 közül tudunk választani. Ez végeredményben azt eredményezi, hogy 5810 egyedi URL-t (~ 4.30 x 1017) tudunk generálni.
Ez azt jelenti gyakorlatban, hogy az NFR-rek alapján ha naponta 150 link keletkezik, akkor az éves szinten 54750db linkkel számolva 7 868 569 989 030.243 évig kitartana. Ez bőven több, mint amire valaha szükségünk lesz, viszont 10 az egy kerek szám, így most a példa kedvéért így marad.
Adatbázis-elérés
A modelljeinket, az adatot valahol majd tárolnunk kell. Ez az üzleti logika szempontjából mindegy, hogy hogyan és hol történik. Az üzleti logikánknak az a fontos, hogy legyen egy komponens, amihez fordulva az adatokhoz hozzáfér. Ezt többféleképpen is meg lehetséges valósítani, de a leggyakrabban egy repository tervezési mintával szokás megoldani. Ez amellett, hogy egy absztrakciós réteget biztosít az adatbázis és az üzleti logika közé, centralizálja a rajtuk végezhető műveleteket is.
A repository tipikusan CRUD műveletek megvalósításáért felelős. A CRUD egy mozaikszó a Create (Létrehozás), Read (Olvasás), Update (Frissítés) és Delete (Törlés) szavakra. Ez a négy fő művelet szinte minden adatvezérelt alkalmazás entitásainál előkerül. A minta implementálásánál fontos, hogy minden entitás egy saját repository-val rendelkezzen, illetve fontos, hogy a repository interfész definíciója az üzleti logika szintjén legyen, hogy még véletlenül se legyen függősége az infrastruktúrára, ami mögé kerül.
A repository minta sokféleképpen definiálható. A legtöbb helyen igyekeznek valami generikus módon definiálni, hogy egyfajta sablonként vezesse a fejlesztőket. Valahogy így:
public interface IGenericRepository<TModel, TKey> where TModel: class where TKey : notnull
{
IEnumerable<TModel> Read();
TModel? GetById(TKey id);
void Create(TModel model);
void Delete(TModel model);
void Update(TModel model);
}
Ezzel a megközelítéssel az a bajom, hogy kompromisszumokkal jár. Például, ha így implementálnánk az URL-ek elérését, akkor alkalmazáslogika szintjére kellene emelnem azt a műveletet, ami kiszűri a csak az adott felhasználóhoz tartozó linkeket, ez pedig biztos, hogy nem lesz olyan hatékony, mint ha SQL szinten intézné el az adatbázis a műveletet. Ez részben elkerülhető lenne, ha Ienumerable<TModel> helyett egy IQueryable<TModel> szerepelne visszatérési értékként a Read() metódus előtt. Ebben az esetben viszont az implementáció válhat igencsak komplexé.
Továbbá érdemes figyelembe venni, hogy a Repository addig működik jól, amíg nem kell azon gondolkodnunk, hogy biztos jó ötlet-e minden esetben joinolni két vagy több táblát, ha lekérdezünk.
Természetesen ezen problémák áthidalására számos minta és megoldás létezik, mint pl. a Specification pattern (https://en.wikipedia.org/wiki/Specification_pattern) vagy a CQRS (https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs). Azonban ezen minták a kis alkalmazásunkban ágyúval a verébre kategória, mert összesen van két modellünk.
Erre egy személyre szabott repository is bőven elég kiegészítve egy unit of work megközelítéssel. A unit of work lehetővé teszi, hogy egy adott üzleti művelethez kapcsolódó több adatbázis-módosító művelet (például beszúrás, frissítés, törlés) egyetlen tranzakcióba legyen csoportosítva. Ez garantálja, hogy az összes módosítás vagy sikeresen, egyszerre történjen meg, vagy – hiba esetén – egyik sem hajtódik végre, így biztosítva az adatok konzisztenciáját.
Ezek alapján az alábbi repository implementációt definiáltam:
public interface IUrlRepository : IDisposable
{
IAsyncEnumerable<UrlModel> GetUrlsAsync(string userName);
Task<UrlModel?> GeByShortUriAsync(string shortUri);
void Create(UrlModel urlModel);
void Delete(UrlModel model);
void Update(UrlModel urlModel);
Task Save();
}
Tényleges üzleti logika
Az üzleti logikánk a CRUD műveleteket csomagolja lényegében használhatóbb formába, némi ellenőrzéssel és logikával. Ezt az IUrlShortner interfész publikálja ki és az UrlShortner implementálja.
using UrlShortner.Core.Models;
using UrlShortner.Core.Services;
namespace UrlShortner.Core;
public interface IUrlShortner : IDisposable
{
Task<UrlModel?> CreateAsync(string longUrl, string user, DateTime? validTill);
Task DeleteByShortUriAsync(string shortUrl, string user);
Task<Uri?> GetRedirectUriAsync(string url);
IAsyncEnumerable<UrlModel> GetUrlModelsAsync(string user);
Task UpdateAsync(string shortUrl, string user, DateTime? validTill);
}
Az UrlShortner implementációja a repository és a rövid név generáló szolgáltatásunk mellett egy TimeProvider-re függ rá, ami az idő elérését biztosítja absztraktált módon, hogy tesztelhető maradjon a logika.
using UrlShortner.Core.Models;
using UrlShortner.Core.Services;
namespace UrlShortner.Core;
public sealed class UrlShortner : IUrlShortner
{
private readonly IUrlRepository _urlRepository;
private readonly IShortNameGenerator _shortUriGenerator;
private readonly TimeProvider _timeProvider;
public UrlShortner(IUrlRepository urlRepository,
IShortNameGenerator shortUriGenerator,
TimeProvider timeProvider)
{
_urlRepository = urlRepository;
_shortUriGenerator = shortUriGenerator;
_timeProvider = timeProvider;
}
public void Dispose()
{
_urlRepository.Dispose();
}
public async Task<UrlModel?> CreateAsync(string longUrl, string user, DateTime? validTill)
{
ArgumentException.ThrowIfNullOrWhiteSpace(longUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(user);
AppException.ThrowIfTimeInvalid(validTill, _timeProvider.GetUtcNow().Date);
var model = new UrlModel
{
LongUri = new Uri(longUrl),
ShortUri = _shortUriGenerator.GenerateShortName(),
CreatedAt = _timeProvider.GetUtcNow().Date,
ValidTill = validTill,
CreatedBy = new UserModel { UserName = user },
ClickCount = 0
};
_urlRepository.Create(model);
await _urlRepository.Save();
return model;
}
public async Task<Uri?> GetRedirectUriAsync(string shortUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(shortUrl);
var model = await _urlRepository.GeByShortUriAsync(shortUrl);
if (model is not null)
{
if (model.ValidTill < _timeProvider.GetUtcNow())
{
return null;
}
model.ClickCount++;
_urlRepository.Update(model);
await _urlRepository.Save();
}
return model?.LongUri;
}
public IAsyncEnumerable<UrlModel> GetUrlModelsAsync(string user)
{
ArgumentException.ThrowIfNullOrWhiteSpace(user);
return _urlRepository.GetUrlsAsync(user);
}
public async Task DeleteByShortUriAsync(string shortUrl, string user)
{
ArgumentException.ThrowIfNullOrWhiteSpace(shortUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(user);
var model = await _urlRepository.GeByShortUriAsync(shortUrl);
AppException.ThrowIfNull(model);
AppException.ThrowIfNotAuthorized(model, user);
_urlRepository.Delete(model);
await _urlRepository.Save();
}
public async Task UpdateAsync(string shortUrl, string user, DateTime? validTill)
{
ArgumentException.ThrowIfNullOrWhiteSpace(shortUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(user);
var model = await _urlRepository.GeByShortUriAsync(shortUrl);
AppException.ThrowIfNull(model);
AppException.ThrowIfNotAuthorized(model, user);
AppException.ThrowIfTimeInvalid(validTill, model.CreatedAt);
model.ValidTill = validTill;
_urlRepository.Update(model);
}
}
Az üzleti logikánk, mint látható a kódban, kivételek dobásával „validálja” a paraméterek és a belső állapotának helyességét, például azt, hogy a repository tényleg a kért felhasználóhoz tartozó adatokat adja-e vissza vagy sem. Az idézőjeles „validáció” nem véletlen, mert ez nem validáció, ez inkább hibavédelem. Az üzleti logika esetén teljesen helyes és jó megközelítés, hogy kivételek dobásával védjük ki a nem várt helyzeteket.
Inkább omoljon össze a kivétel dobásának következtében az alkalmazás itt, mint egy másik szinten, kockáztatva az adatok épségét és konzisztenciáját. A tényleges üzleti logika metódusainak végrehajtása előtt majd validáljuk az adatokat és szép olvasható hibaüzeneteket gyártunk belőlük egy másik komponenssel, de ez nem az üzleti logika feladata. Viszont az az üzleti logika feladatkörébe tartozik, hogy ne engedje meg a helytelen használatot.
A kódban egy saját kivételtípus az AppException is feltűnik, ami az alkalmazás állapotának ellenőrzésére szolgál. Mivel ismétlődő ellenőrző logikákról van szó, ezek Factory metódus jelleggel magában a kivételtípusban kaptak helyet.
public class AppException : Exception
{
public AppException(string message) : base(message)
{
}
public AppException() : base()
{
}
public AppException(string? message, Exception? innerException) : base(message, innerException)
{
}
internal static void ThrowIfNotAuthorized(UrlModel model, string userName)
{
if (model.CreatedBy.UserName != userName)
throw new AppException("You are not authorized to perform this action.");
}
internal static void ThrowIfNull([NotNull] UrlModel? model)
{
if (model is null)
throw new AppException("The requested URL does not exist.");
}
internal static void ThrowIfTimeInvalid(DateTime? validTill, DateTime validationDate)
{
if (validTill is not null && validTill < validationDate)
throw new AppException("The valid till date cannot be in the past.");
}
}