Keri sisuni

Repository disainimuster

Repository on disainimuster, mille ideeks on võimaldada andmebaasidele juurdepääs selliselt, et suhtlus toimuks nagu programmeerimisest tuntud kollektsioonide ja loenditega.

Kuigi Repository idee on lihtne, on selle disainimustri mõju ärirakendustele suur. Ilma Repositoryta oleks andmebaasidega suhtlemiseks miljoneid erinevaid lähenemisi - osad paremad, teised teisiti - ja arendajad riskiksid alatasa sellega, et andmekihi kasvades kasvab olulisel määral ka programmi nende osade keerukus, mis suhtlevad andmebaasiga. Repository disainimustri näol on meil olemas kindel viis andmebaasiga suhtluse organiseerimiseks. See töötab üle erimevate süsteemide.

CRUD

Repository disainimuster aitab ühtsetesse raamidesse viia järgmised andmetega seotud toimingud:

  • lisamine
  • pärimine
  • muutmine
  • kustutamine
  • päringud

CRUD

Nelja esimese toimingu kohta kasutatakse ka lühedit CRUD (Create, Read, Update, Delete). Selle lühendi soovitan meelde jätta, sest seda kasutatakse erialases kirjanduses läbivalt.

Kui vaatame repository toimingutele uuesti peale, siis näeme, et repository koosneb kahest:

  • CRUD - toimingud ühe objektiga
  • päringud- objektide loendeid tagastavad päringud

Objekt-orienteeritud rakendustes mõtleme me andmete all klasse, mille andmeid andmebaasis hoitakse. Iga klassi jaoks, mille andmeid me soovime andmebaasis hoida, luuakse üks repository.

Repository klassi loomine

Alustame sellest, et meil on toote andmete esitamiseks klass Product.

public class Product
{
    public int Id  { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
}

Me võiksime kirjutada igasugust koodi, et toodete andmeid andmebaasi salvestada ja sealt lugeda, kuid selle asemel, et riskida oma lähenemiste loomisega, ehitame me repository klassi. Me loome skeleti, kus on kõik vajalik olemas, kuid mis midagi mõistlikku ei tee.

public class ProductRepository
{
    // Toote pärimine
    public async Task<Product> Get(int id)
    {
        return null;
    }

    // Toote lisamine ja salvestamine
    public async Task Save(Product product)
    {
    }

    // Toote kustutamine
    public async Task Delete(Product customer)
    {        
    }

    // Toote kustutamine
    public async Task Delete(int id)
    {
    }

    // Toodete loendi pärimine
    public async Task<IList<Product>> List()
    {
        return null;
    }
}

Sarnase repository peaksime looma ka teiste klasside jaoks (näiteks: Invoice, Product, Order jne).

Kuivõrd meil on tühi kest olemas, siis on hea hetk sellele elu ka sisse puhuda. Et selle aine raames kasutame andmebaasiga suhtlemiseks EF Core DbContext-i klassi, siis peavad seda klassi kasutama ka repositoryd, et andmebaasiga suhelda.

public class ProductRepository
{
    private readonly ApplicationDbContext _dataContext;

    public ProductRepository(ApplicationDbContext dataContext)
    {
        _dataContext = dataContext;
    }

    public async Task<Product> Get(int id)
    {
        return await _dataContext.Products.FindAsync(id);
    }

    public async Task Save(Product product)
    {
        if(product.Id == 0)
        {
            await _dataContext.Products.AddAsync(product);
        }
        else
        {
            _dataContext.Products.Update(product);
        }
    }

    public async Task Delete(Product product)
    {
        _dataContext.Products.Remove(product);
    }

    public async Task<IList<Product>> List()
    {
        return await _dataContext.Products.ToListAsync();
    }
}

Nüüd on meil olemas esimene töötav repository klass.

Repository interface

Automaattestide juurde jõudes on meil vaja täiendavaid repository klasse - selliseid, mis ei suhtleks andmebaasiga, vaid millele saab ette anda andmed, mida need tagastama peavad. Me jõuame olukorda, kus meil on kaks repository klassi ja sõltuvalt sellest, kas tegemist on rakenduse enda või testidega, me soovime kasutada kord ühte ja kord teist repositoryt.

public class ProductRepository // seda kasutab rakendus
{
    // kood
}

public class FakeProductRepository // seda kasutavad testid
{
    // kood
}

Nende kahe klassi vahel puudub struktuurne seos, mis aitaks meil nende vahel vahetada ilma, et peaksime rakenduse enda funktsionaalset koodi muutma. Selle probleemi lahendamiseks võtame kasutusele repository liidese.

public interface IProductRepository
{
    Task<Product> Get(int id);
    void Save(Product product);
    void Delete(Product product);
    void Delete(int id);
    Task<IList<Product>> List();
}

See liides ongi struktuurne seos ProductRepository ja FakeProductRepository vahel.

public class ProductRepository : IProductRepository
{
    // kood
}

public class FakeProductRepository : IProductRepository
{
    // kood
}

Tänu IProductRepository liidesele on need kaks repository klassi omavahel vahetatavad ilma, et rakenduse muu kood muutuks.

IProductRepository productRepository;

productRepository = new ProductRepository();
productRepository = new FakeProductRepository();

Andmeklasside baasklass Entity

Enne kui hakkame looma repositoryde ühist baasklassi peame lahendama ühe probleemi, mis meil baasrepository loomisel tekib. Meil puudub klasside vahel struktuurne seos ning seega jääme me hätta Delete(int id) meetodi kirjutamisel. Baasrepositorys me teame küll objekti tüüpi, kuid me ei saa olla kindlad, kas objektil on Id omadus või ei.

public async Task Save(Product product)
{
    if(product.Id == 0) // PROBLEEM
    {
        await _dataContext.Products.AddAsync(product);
    }
    else
    {
        _dataContext.Products.Update(product);
    }
}

Et kõik andmeklassid struktuurselt siduda - see võimaldab nende ühtse käsitlemise baasrepositorys hiljem - loome neile baasklassi Entity, millel on ainult üks omadus: Id. Ühtlasi peame Product ja Manufacturer klassid panema Entity klassist pärima.

public abstract class Entity
{
    [Key]
    public int Id { get; set; }
}

public class Product : Entity
{
    public string Name { get; set; }
    public string Description { get; set; }

    public Manufacturer Manufacturer { get; set; }
}

public class Manufacturer : Entity
{
    public string Name { get; set; }
}

Pane tähele, et Product ja Manufacturer klassides pole enam Id omadust, sest see on defineeritud Entity baasklassis.

BaseRepository baasklass

Tarkvara arendades on meie huviks kirjutada koodi võimalikult vähe, kuid mitte liiga vähe. Lisaks püüame vältida korduvat koodi, sest me ei jõua meeles pidada kohti, kuhu vaid mõned nädalad tagasi sai copy-paste tehtud.

Kõrvutame nüüd kaks repository klassi - ProductRepository ja ManufacturerRepository.

public class ProductRepository : IProductRepository
{
    public async Task<Product> Get(int id)
    {
        // kood
    }

    public async Task Save(Product product)
    {
        // kood
    }

    public async Task Delete(Product product)
    {
        // kood
    }

    public async Task Delete(int id)
    {
        // kood
    }

    public async Task<IList<Product>> List()
    {
        // kood
    }
}
public class ManufacturerRepository : IManufacturerRepository
{
    public async Task<Manufacturer> Get(int id)
    {
        // kood
    }

    public async Task Save(Manufacturer manufacturer)
    {
        // kood
    }

    public async Task Delete(Manufacturer manufacturer)
    {
        // kood
    }

    public async Task Delete(int id)
    {
        // kood
    }

    public async Task<IList<Manufacturer>> List()
    {
        // kood
    }
}

Meetodite nimed on mõlemal klassil samad. Samuti on praktiliselt sama meetodite sisu. Kuigi üks repository on mõeldud toodetega ja teine tootjatega tegelemiseks, on nende kood siiski suures osas sama. Mõlemad repositoryd kasutavad ka DbContext-i klassi ja võimalused on head, et saame korduva koodi ühte kohta kokku koondada.

Korduvast koodist vabanemiseks kasutame lihtsat trikki:

  1. Loome repositoryde baasklassi, millel on tüübiparameeter
  2. Loome repositoryde baaklassile liidese, millel on tüübiparameeter
  3. Paneme olemasolevad repositoryd kasutama uut baasklassi
public abstract class BaseRepository<T> where T : Entity
{
    protected ApplicationDbContext DataContext { get; private set; }

    public BaseRepository(ApplicationDbContext dataContext)
    {
        DataContext = dataContext;
    }

    public virtual async Task<T> Get(int id)
    {
        return await DataContext.Set<T>().FindAsync(id);
    }

    public async Task Save(T instance)
    {
        if (instance.Id == 0)
        {
            await DataContext.Set<T>().AddAsync(instance);
        }
        else
        {
            DataContext.Set<T>().Update(instance);
        }
    }

    public async Task Delete(T instance)
    {
        DataContext.Set<T>().Remove(instance);
    }

    public virtual async Task<IList<T>> List()
    {
        return await DataContext.Set<T>().ToListAsync();
    }
}

Kui võtame kasutusele baasklassi, siis saame ProductRepository ja ManufacturerRepository sisuliselt tühjaks ja korduv kood on likvideeritud.

public class ProductRepository : BaseRepository<Product>, IProductRepository
{

}
public class ManufacturerRepository : BaseRepository<Manufacturer>, IManufacturerRepository
{

}

IBaseRepository liides

Suuremad kordused saime repository klassidest likvideeritud ja nüüd on aeg muuta õhemaks ka repositoryde liidesed. Siingi on kordused sees, mida annab vältida. Võrrelgem IProductRepository ja IManufacturerRepository liideseid.

public interface IProductRepository
{
    Task<Product> Get(int id);
    Task Save(Product product);
    Task Delete(Product product);
    Task Delete(int id);
    Task<IList<Product>> List();
}
public interface IManufacturerRepository
{
    Task<Manufacturer> Get(int id);
    Task Save(Manufacturer manufacturer);
    Task Delete(Manufacturer manufacturer);
    Task Delete(int id);
    Task<IList<Manufacturer>> List();
}

Probleem on sama nagu eelmises punktis - kood on sarnane, kuid kasutatakse erinevat tüüpi. Vaja on jällegi struktuurset muudatust, et liidesed puhtaks saada. Seekord defineerime repository liidestele baasliidese.

public interface IBaseRepository<T> where T : class
{
    Task<T> Get(int id);
    Task Save(T instance);
    Task Delete(T instance);
    Task Delete(int id);
    Task<IList<T>> List();
}

Võtame baasliidese kasutusele IProductRepository ja IManufacturerRepository liidestes.

public interface IProductRepository : IBaseRepository<Product>
{    
}

public interface IManufacturerRepository : IBaseRepository<Manufacturer>
{    
}

Nüüd on repository klassid ja liidesed minimaalsed, korduv kood likvideeritud ning võime asuda järgmisi kohendusi tegema, mille abil saaksime kirjutatava koodi hulka veelgi vähendada.

Üldine repository

Nagu eelnevalt sai mainitud, siis repository klasse tekiks meil sama palju kui on klasse, mille andmeid me andmebaasis hoiame. Mõtleme korra sellistele klassidele nagu näiteks:

  • CarType
  • Country
  • ConstructionType
  • ProductCategory

Tüüpiliselt on tegemist klassidega, mis esindavad klassifikaatoreid. Kasutusliideses on need tavaliselt ripploenditest valitavad väärtused. Mingit äriloogikat need klassid endaga otseselt ei kanna, kuid taolisi klasse võib süsteemis olla päris palju ja kui vähegi võimalik, siis neile eraldi repository klasse me luua ei tahaks. Võiks kuidagi lihtsamini hakkama saada.

Lahendust ei tule õnneks kaugelt otsida. Eespool defineerisime me sellise klassi nagu BaseRepository. Me tegime selle klassi abstraktseks, sest plaanisime seda kasutada baasklassina, millest teised repository klassid ennast pärivad. Me ei pea sellist lähenemist ilmtingimata kasutama.

Kui me muudame BaseRepository tavaliseks klassiks, millest saab instantsi luua, siis saaksime seda kasutada kõikide klassidega, millele me eraldi repositoryt luua ei soovi. Nimetame liidese ja klassi ümber ka:

  • IBaseRepository > IGenericRepository
  • BaseRepository > GenericRepository
public interface IGenericRepository<T> where T : class
{
    Task<T> Get(int id);
    Task Save(T instance);
    Task Delete(T instance);
    Task Delete(int id);
    Task<IList<T>> List();
}

public class GenericRepository<T> : IGenericRepository where T : Entity
{
    protected ApplicationDbContext DataContext { get; private set; }

    public BaseRepository(ApplicationDbContext dataContext)
    {
        DataContext = dataContext;
    }

    public virtual async Task<T> Get(int id)
    {
        return await DataContext.Set<T>().FindAsync(id);
    }

    public async Task Save(T instance)
    {
        if (instance.Id == 0)
        {
            await DataContext.Set<T>().AddAsync(instance);
        }
        else
        {
            DataContext.Set<T>().Update(instance);
        }
    }

    public async Task Delete(T instance)
    {
        DataContext.Set<T>().Remove(instance);
    }

    public virtual async Task<IList<T>> List()
    {
        return await DataContext.Set<T>().ToListAsync();
    }
}

GenericRepositoryst saame me luua objekti ning tänu tüübiparameetrile saame määrata ka tüübi, millega antud intsants opereerib.

Vaatame näitena tootekategooria lisamist. Alustuseks olgu meil tootekategooria klass.

public class ProductCategory : Entity
{
    public string Name { get; set; }
}

Uue kategooria lisamiseks saame kirjutada sellise (näitliku) koodi.

var category = new ProductCategory { Name = "Pasta" };

var repository = new GenericRepository<ProductCategory>(dbContext);
repository.Save(category);

Pärimismeetodid

Repository klassid jäid meil päris tühjaks peale seda kui viisime koodi baasklassidesse üle. Sama juhtus ka repository liidestega. Praktikas repository klassid siiski nii tühjaks ei jää, sest iga klassiga on seotud omad päringud. Näiteks:

  • leia tähtaja ületanud projektid
  • leia kümme kõige populaarsemat toodet
  • leia autod, mille läbisõit on suurem kui 200K km
  • leia kümme parima tagasisidega üritust, mis toimusid eelmisel aastal

Selliseid päringuid ei ole mõtet liigutada repositoryde baasklassi, sest need puudutavad alati ühte konkreetset klassi.

Vaatame päringut, millega leiame suure läbisõiduga autod. Alustame autot esindavast klassist Car.

public class Car : Entity
{
    public string RegistrationNo { get; set; }
    public string VinCode { get; set;}
    public double Odometer { get; set; }
}

Päringu jaoks peame looma uue meetodi CarRepository klassi. Samuti tuleb see meetod viia ICarRepository liidesse.

public interface ICarRepository : IGenericRepository<Car>
{
    Task<IList<Car>> ListCarsWithBigMileage();
}

public class CarRepository : GenericRepository<Car>, ICarRepository
{
    public CarRepository(ApplicationDbContext dataContext) : base(dataContext)
    {
    }

    public async Task<IList<Car>> ListCarsWithBigMileage()
    {
        return await DataContext.Cars.Where(car => car.Odometer >= 200000)
                                     .ToListAsync();
    }
}

Sõltuvalt klassist, mille andmetega repository tegeleb, võib päringuid repository klassis olla päris palju. Need päringud defineerime repository klassis ja selle liideses.