Dela via


Implementera infrastrukturbeständighetsskiktet med Entity Framework Core

Tips/Råd

Det här innehållet är ett utdrag från eBook, .NET Microservices Architecture for Containerized .NET Applications, tillgängligt på .NET Docs eller som en kostnadsfri nedladdningsbar PDF som kan läsas offline.

Miniatyrbild av omslag för eBooken om .NET mikroservicearkitektur för containerbaserade .NET-applikationer.

När du använder relationsdatabaser som SQL Server, Oracle eller PostgreSQL rekommenderar vi att du implementerar beständighetsskiktet baserat på Entity Framework (EF). EF har stöd för LINQ och tillhandahåller starkt inskrivna objekt för din modell samt förenklad beständighet i databasen.

Entity Framework har en lång historik som en del av .NET Framework. När du använder .NET bör du också använda Entity Framework Core, som körs på Windows eller Linux på samma sätt som .NET. EF Core är en fullständig omskrivning av Entity Framework som implementeras med ett mycket mindre fotavtryck och viktiga prestandaförbättringar.

Introduktion till Entity Framework Core

Entity Framework (EF) Core är en lätt, utökningsbar och plattformsoberoende version av den populära Entity Framework-dataåtkomsttekniken. Den introducerades med .NET Core i mitten av 2016.

Eftersom en introduktion till EF Core redan finns tillgänglig i Microsoft-dokumentationen tillhandahåller vi här bara länkar till den informationen.

Ytterligare resurser

Infrastruktur i Entity Framework Core ur ett DDD-perspektiv

Ur DDD-synvinkel är en viktig funktion för EF möjligheten att använda POCO-domänentiteter, även kända i EF-terminologi som POCO-kod-första entiteter. Om du använder POCO-domänentiteter är domänmodellklasserna persistence-ignoranta, enligt principerna Beständig okunnighet och Infrastrukturoberoende .

Per DDD-mönster bör du kapsla in domänbeteende och regler i själva entitetsklassen, så att den kan styra invarianter, valideringar och regler när du kommer åt en samling. Därför är det inte en bra praxis i DDD att tillåta publik åtkomst till samlingar av underordnade entiteter eller värdeobjekt. I stället vill du exponera metoder som styr hur och när dina fält och egenskapssamlingar kan uppdateras och vilket beteende och vilka åtgärder som ska ske när det händer.

Sedan EF Core 1.1 kan du för att uppfylla dessa DDD-krav ha oformaterade fält i entiteterna i stället för publika egenskaper. Om du inte vill att ett entitetsfält ska vara externt tillgängligt kan du bara skapa attributet eller fältet i stället för en egenskap. Du kan också använda privata egenskapssetters.

På liknande sätt kan du nu ha skrivskyddad åtkomst till samlingar genom att använda en offentlig egenskap av typen IReadOnlyCollection<T>, som backas upp av ett privat fält för samlingen (som en List<T>) i din entitet som förlitar sig på EF för persistens. Tidigare versioner av Entity Framework krävde samlingsegenskaper för att stödja ICollection<T>, vilket innebar att alla utvecklare som använder den överordnade entitetsklassen kunde lägga till eller ta bort objekt via sina egenskapssamlingar. Den möjligheten skulle vara emot de rekommenderade mönstren i DDD.

Du kan använda en privat samling när du exponerar ett skrivskyddat IReadOnlyCollection<T> objekt, enligt följande kodexempel:

public class Order : Entity
{
    // Using private fields, allowed since EF Core 1.1
    private DateTime _orderDate;
    // Other fields ...

    private readonly List<OrderItem> _orderItems;
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;

    protected Order() { }

    public Order(int buyerId, int paymentMethodId, Address address)
    {
        // Initializations ...
    }

    public void AddOrderItem(int productId, string productName,
                             decimal unitPrice, decimal discount,
                             string pictureUrl, int units = 1)
    {
        // Validation logic...

        var orderItem = new OrderItem(productId, productName,
                                      unitPrice, discount,
                                      pictureUrl, units);
        _orderItems.Add(orderItem);
    }
}

Egenskapen OrderItems kan endast nås som endast läsbar med hjälp av IReadOnlyCollection<OrderItem>. Den här typen är skrivskyddad så den skyddas mot vanliga externa uppdateringar.

EF Core är ett sätt att mappa domänmodellen till den fysiska databasen utan att "förorena" domänmodellen. Det är ren .NET POCO-kod eftersom mappningsåtgärden implementeras i beständighetsskiktet. I den mappningsåtgärden måste du konfigurera fält-till-databas-mappningen. I följande exempel av metoden OnModelCreating från OrderingContext och klassen OrderEntityTypeConfiguration instruerar anropet till SetPropertyAccessMode EF Core att komma åt egenskapen OrderItems via dess fält.

// At OrderingContext.cs from eShopOnContainers
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   // ...
   modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
   // Other entities' configuration ...
}

// At OrderEntityTypeConfiguration.cs from eShopOnContainers
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);
        // Other configuration

        var navigation =
              orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        //EF access the OrderItem collection property through its backing field
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        // Other configuration
    }
}

När du använder fält i stället för egenskaper sparas entiteten som om den OrderItem hade en List<OrderItem> egenskap. Den exponerar dock en enskild accessor, AddOrderItem metoden, för att lägga till nya objekt i ordern. Därför är beteende och data kopplade till varandra och kommer att vara konsekventa i alla programkoder som använder domänmodellen.

Implementera anpassade lagringsplatser med Entity Framework Core

På implementeringsnivå är en lagringsplats helt enkelt en klass med kod för datapersistens som samordnas av en arbetsenhet (DBContext i EF Core) när du utför uppdateringar, enligt följande klass:

// using directives...
namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
    public class BuyerRepository : IBuyerRepository
    {
        private readonly OrderingContext _context;
        public IUnitOfWork UnitOfWork
        {
            get
            {
                return _context;
            }
        }

        public BuyerRepository(OrderingContext context)
        {
            _context = context ?? throw new ArgumentNullException(nameof(context));
        }

        public Buyer Add(Buyer buyer)
        {
            return _context.Buyers.Add(buyer).Entity;
        }

        public async Task<Buyer> FindAsync(string buyerIdentityGuid)
        {
            var buyer = await _context.Buyers
                .Include(b => b.Payments)
                .Where(b => b.FullName == buyerIdentityGuid)
                .SingleOrDefaultAsync();

            return buyer;
        }
    }
}

Gränssnittet IBuyerRepository kommer från domänmodelllagret som ett kontrakt. Implementeringen av lagringsplatsen sker dock på beständighets- och infrastrukturnivå.

EF DbContext kommer till konstruktorn genom Dependency Injection. Det delas mellan flera lagringsplatser inom samma HTTP-begärandeomfång tack vare dess standardlivslängd (ServiceLifetime.Scoped) i IoC-containern (som också uttryckligen kan anges med services.AddDbContext<>).

Metoder för att implementera på en lagringsplats (uppdateringar eller transaktioner jämfört med frågor)

Inom varje lagringsplatsklass bör du placera beständighetsmetoderna som uppdaterar tillståndet för entiteter som ingår i dess relaterade aggregering. Kom ihåg att det finns en-till-en-relation mellan en aggregering och dess relaterade lagringsplats. Tänk på att ett aggregerat rotentitetsobjekt kan ha inbäddade barntentiteter inom sin EF-graf. En köpare kan till exempel ha flera betalningsmetoder som relaterade underliggande entiteter.

Eftersom metoden för att beställa mikrotjänster i eShopOnContainers också baseras på CQS/CQRS implementeras de flesta frågorna inte i anpassade lagringsplatser. Utvecklare har friheten att skapa de frågor och kopplingar som de behöver för presentationsskiktet utan begränsningar som införts av aggregeringar, anpassade lagringsplatser per aggregering och DDD i allmänhet. De flesta anpassade lagringsplatser som föreslås i den här guiden har flera uppdaterings- eller transaktionsmetoder, men bara de frågemetoder som krävs för att få data att uppdateras. Lagringsplatsen BuyerRepository implementerar till exempel en FindAsync-metod, eftersom programmet måste veta om en viss köpare finns innan en ny köpare som är relaterad till ordern skapas.

De verkliga frågemetoderna för att hämta data som ska skickas till presentationslagret eller klientapparna implementeras, som nämnts, i CQRS-frågorna baserat på flexibla frågor med Dapper.

Använda en anpassad lagringsplats jämfört med att använda EF DbContext direkt

Klassen Entity Framework DbContext baseras på arbets- och lagringsplatsens mönster och kan användas direkt från din kod, till exempel från en ASP.NET Core MVC-styrenhet. Arbets- och lagringsenhetens mönster resulterar i den enklaste koden, som i CRUD-katalogmikrotjänsten i eShopOnContainers. I de fall där du vill ha den enklaste möjliga koden kanske du vill använda klassen DbContext direkt, som många utvecklare gör.

Att implementera anpassade lagringsplatser ger dock flera fördelar när du implementerar mer komplexa mikrotjänster eller program. Arbets- och lagringsplatsens mönster är avsedda att kapsla in infrastrukturens beständighetsskikt så att det frikopplas från program- och domänmodellskikten. Implementering av dessa mönster kan underlätta användningen av falska lagringsplatser som simulerar åtkomst till databasen.

I bild 7–18 kan du se skillnaderna mellan att inte använda lagringsplatser (direkt med EF DbContext) jämfört med att använda lagringsplatser, vilket gör det enklare att håna dessa lagringsplatser.

Diagram som visar komponenterna och dataflödet i de två lagringsplatserna.

Bild 7-18. Använda anpassade lagringsplatser jämfört med en vanlig DbContext

Bild 7–18 visar att användning av en anpassad lagringsplats lägger till ett abstraktionslager som kan användas för att underlätta testning genom att håna lagringsplatsen. Det finns flera alternativ när du hånar. Du kan simulera bara datalager eller så kan du simulera en hel arbetsenhet. Vanligtvis är det tillräckligt att håna bara lagringsplatserna, och komplexiteten att abstrakta och håna en hel arbetsenhet behövs vanligtvis inte.

Senare, när vi fokuserar på programskiktet, ser du hur beroendeinmatning fungerar i ASP.NET Core och hur det implementeras när du använder lagringsplatser.

Med anpassade lagringsplatser kan du enkelt testa kod med enhetstester som inte påverkas av datanivåtillståndet. Om du kör tester som också har åtkomst till den faktiska databasen via Entity Framework är de inte enhetstester utan integrationstester, som är mycket långsammare.

Om du använder DbContext direkt måste du mocka den eller köra enhetstester med hjälp av en in-memory SQL Server med förutsägbara data. Men att håna DbContext eller kontrollera falska data kräver mer arbete än att håna på lagringsplatsnivå. Naturligtvis kan du alltid testa MVC-styrenheterna.

EF DbContext- och IUnitOfWork-instansens livslängd i din IoC-container

Objektet DbContext (som exponeras som ett IUnitOfWork objekt) ska delas mellan flera lagringsplatser inom samma OMfång för HTTP-begäran. Detta gäller till exempel när åtgärden som körs måste hantera flera aggregeringar, eller bara för att du använder flera lagringsplatsinstanser. Det är också viktigt att nämna att IUnitOfWork gränssnittet är en del av domänlagret, inte en EF Core-typ.

För att kunna göra det måste instansen DbContext av objektet ha tjänstens livslängd inställd på ServiceLifetime.Scoped. Det här är standardlivslängden när du registrerar en DbContext med builder.Services.AddDbContext i din IoC-container från Program.cs-filen i ditt ASP.NET Core Web API-projekt. Följande kod illustrerar detta.

// Add framework services.
builder.Services.AddMvc(options =>
{
    options.Filters.Add(typeof(HttpGlobalExceptionFilter));
}).AddControllersAsServices();

builder.Services.AddEntityFrameworkSqlServer()
    .AddDbContext<OrderingContext>(options =>
    {
        options.UseSqlServer(Configuration["ConnectionString"],
                            sqlOptions => sqlOptions.MigrationsAssembly(typeof(Startup).GetTypeInfo().
                                                                                Assembly.GetName().Name));
    },
    ServiceLifetime.Scoped // Note that Scoped is the default choice
                            // in AddDbContext. It is shown here only for
                            // pedagogic purposes.
    );

DbContext-instansieringsläget bör inte konfigureras som ServiceLifetime.Transient eller ServiceLifetime.Singleton.

Livslängden för en lagringsplatsinstans i din IoC-container

På liknande sätt bör lagringsplatsens livslängd vanligtvis anges som begränsad (InstancePerLifetimeScope i Autofac). Det kan också vara tillfälligt (InstancePerDependency i Autofac), men tjänsten blir effektivare när det gäller minne när du använder den begränsade livslängden.

// Registering a Repository in Autofac IoC container
builder.RegisterType<OrderRepository>()
    .As<IOrderRepository>()
    .InstancePerLifetimeScope();

Om du använder singleton-livslängden för lagringsplatsen kan du få allvarliga samtidighetsproblem när din DbContext är inställd på livslängden för scoped (InstancePerLifetimeScope) (standardlivslängden för en DBContext). Så länge din tjänstlivslängd för dina lagringsplatser och dbContext båda är begränsade, undviker du dessa problem.

Ytterligare resurser

Tabellmappning

Tabellmappning identifierar de tabelldata som ska frågas från och sparas i databasen. Tidigare såg du hur domänentiteter (till exempel en produkt- eller orderdomän) kan användas för att generera ett relaterat databasschema. EF är starkt utformat kring begreppet konventioner. Konventioner tar upp frågor som "Vad kommer namnet på en tabell att vara?" eller "Vilken egenskap är den primära nyckeln?" Konventioner baseras vanligtvis på konventionella namn. Det är till exempel vanligt att primärnyckeln är en egenskap som slutar med Id.

Enligt konventionen konfigureras varje entitet för att mappa till en tabell med samma namn som egenskapen DbSet<TEntity> som exponerar entiteten i den härledda kontexten. Om inget DbSet<TEntity> värde anges för den angivna entiteten används klassnamnet.

Dataanteckningar jämfört med Fluent API

Det finns många ytterligare EF Core-konventioner och de flesta av dem kan ändras med hjälp av antingen dataanteckningar eller Fluent API som implementeras i metoden OnModelCreating.

Datakommentarer måste användas i själva entitetsmodellklasserna, vilket är ett mer påträngande sätt från en DDD-synvinkel. Det beror på att du förorenar din modell med dataanteckningar relaterade till infrastrukturdatabasen. Å andra sidan är Fluent API ett bekvämt sätt att ändra de flesta konventioner och mappningar i infrastrukturlagret för datapersistence, så att entitetsmodellen rensas och frikopplas från beständighetsinfrastrukturen.

Fluent API och metoden OnModelCreating

Som nämnts kan du använda metoden OnModelCreating i klassen DbContext för att ändra konventioner och mappningar.

Beställningsmikrotjänsten i eShopOnContainers implementerar explicit mappning och konfiguration när det behövs, enligt följande kod.

// At OrderingContext.cs from eShopOnContainers
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   // ...
   modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
   // Other entities' configuration ...
}

// At OrderEntityTypeConfiguration.cs from eShopOnContainers
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);

        orderConfiguration.HasKey(o => o.Id);

        orderConfiguration.Ignore(b => b.DomainEvents);

        orderConfiguration.Property(o => o.Id)
            .UseHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

        //Address value object persisted as owned entity type supported since EF Core 2.0
        orderConfiguration
            .OwnsOne(o => o.Address, a =>
            {
                a.WithOwner();
            });

        orderConfiguration
            .Property<int?>("_buyerId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("BuyerId")
            .IsRequired(false);

        orderConfiguration
            .Property<DateTime>("_orderDate")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderDate")
            .IsRequired();

        orderConfiguration
            .Property<int>("_orderStatusId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderStatusId")
            .IsRequired();

        orderConfiguration
            .Property<int?>("_paymentMethodId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("PaymentMethodId")
            .IsRequired(false);

        orderConfiguration.Property<string>("Description").IsRequired(false);

        var navigation = orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        // DDD Patterns comment:
        //Set as field (New since EF 1.1) to access the OrderItem collection property through its field
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        orderConfiguration.HasOne<PaymentMethod>()
            .WithMany()
            .HasForeignKey("_paymentMethodId")
            .IsRequired(false)
            .OnDelete(DeleteBehavior.Restrict);

        orderConfiguration.HasOne<Buyer>()
            .WithMany()
            .IsRequired(false)
            .HasForeignKey("_buyerId");

        orderConfiguration.HasOne(o => o.OrderStatus)
            .WithMany()
            .HasForeignKey("_orderStatusId");
    }
}

Du kan ange alla Fluent API-mappningar inom samma OnModelCreating metod, men det är lämpligt att partitioneras koden och ha flera konfigurationsklasser, en per entitet, som du ser i exemplet. Särskilt för stora modeller är det lämpligt att ha separata konfigurationsklasser för att konfigurera olika entitetstyper.

Koden i exemplet visar några explicita deklarationer och mappning. EF Core-konventioner utför dock många av dessa mappningar automatiskt, så den faktiska kod som du skulle behöva i ditt fall kan vara mindre.

Hi/Lo-algoritmen i EF Core

En intressant aspekt av koden i föregående exempel är att den använder Hi/Lo-algoritmen som nyckelgenereringsstrategi.

Hi/Lo-algoritmen är användbar när du behöver unika nycklar innan du genomför ändringar. Som en sammanfattning tilldelar Hi-Lo-algoritmen unika identifierare till tabellrader utan att omedelbart behöva lagra raden i databasen. På så sätt kan du börja använda identifierarna direkt, vilket händer med vanliga sekventiella databas-ID:t.

Hi/Lo-algoritmen beskriver en mekanism för att hämta en batch med unika ID:er från en relaterad databassekvens. Dessa ID:n är säkra att använda eftersom databasen garanterar unikheten, så det blir inga kollisioner mellan användare. Den här algoritmen är intressant av följande skäl:

  • Den bryter inte arbetsenhetens mönster.

  • Den hämtar sekvens-ID:t i batchar för att minimera tur och retur till databasen.

  • Det genererar en mänsklig läsbar identifierare, till skillnad från tekniker som använder GUID.

EF Core stöder HiLo med UseHiLo metoden, som du ser i föregående exempel.

Mappa fält i stället för egenskaper

Med den här funktionen, som är tillgänglig sedan EF Core 1.1, kan du mappa kolumner direkt till fält. Du kan inte använda egenskaper i entitetsklassen och bara mappa kolumner från en tabell till fält. En vanlig användning för det skulle vara privata fält för alla interna tillstånd som inte behöver nås utanför entiteten.

Du kan göra detta med enkla fält eller även med samlingar, till exempel ett List<> fält. Den här punkten nämndes tidigare när vi diskuterade modellering av domänmodellklasserna, men här kan du se hur den mappningen utförs med konfigurationen PropertyAccessMode.Field markerad i föregående kod.

Använda skuggegenskaper i EF Core, dolda på infrastrukturnivå

Skuggegenskaper i EF Core är egenskaper som inte finns i entitetsklassmodellen. Värdena och tillstånden för dessa egenskaper underhålls enbart i klassen ChangeTracker på infrastrukturnivå.

Implementera frågespecifikationsmönstret

Som vi introducerade tidigare i designavsnittet är frågespecifikationsmönstret ett Domain-Driven designmönster som utformats som den plats där du kan placera definitionen av en fråga med valfri sorterings- och växlingslogik.

Mönstret Frågespecifikation definierar en fråga i ett objekt. Till exempel, för att omsätta en pagineringfråga som söker efter vissa produkter kan du skapa en PagedProduct-specifikation som tar nödvändiga indataparametrar (sidnummer, sidstorlek, filtrering osv.). Inom valfri lagringsplatsmetod (vanligtvis en list() överlagring) skulle den sedan acceptera en IQuerySpecification och köra den förväntade frågan baserat på den specifikationen.

Ett exempel på ett allmänt specifikationsgränssnitt är följande kod, som liknar koden som används i referensprogrammet eShopOnWeb .

// GENERIC SPECIFICATION INTERFACE
// https://github.com/dotnet-architecture/eShopOnWeb

public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    List<string> IncludeStrings { get; }
}

Sedan är implementeringen av en generisk specifikationsbasklass följande.

// GENERIC SPECIFICATION IMPLEMENTATION (BASE CLASS)
// https://github.com/dotnet-architecture/eShopOnWeb

public abstract class BaseSpecification<T> : ISpecification<T>
{
    public BaseSpecification(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }
    public Expression<Func<T, bool>> Criteria { get; }

    public List<Expression<Func<T, object>>> Includes { get; } =
                                           new List<Expression<Func<T, object>>>();

    public List<string> IncludeStrings { get; } = new List<string>();

    protected virtual void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }

    // string-based includes allow for including children of children
    // for example, Basket.Items.Product
    protected virtual void AddInclude(string includeString)
    {
        IncludeStrings.Add(includeString);
    }
}

Följande specifikation laddar en enskild korgentitet givet antingen korgens ID eller köparens ID som korgen tillhör. Den laddar ivrigt korgens samling.

// SAMPLE QUERY SPECIFICATION IMPLEMENTATION

public class BasketWithItemsSpecification : BaseSpecification<Basket>
{
    public BasketWithItemsSpecification(int basketId)
        : base(b => b.Id == basketId)
    {
        AddInclude(b => b.Items);
    }

    public BasketWithItemsSpecification(string buyerId)
        : base(b => b.BuyerId == buyerId)
    {
        AddInclude(b => b.Items);
    }
}

Och slutligen kan du se nedan hur en allmän EF-lagringsplats kan använda en sådan specifikation för att filtrera och läsa in data som är relaterade till en viss entitetstyp T.

// GENERIC EF REPOSITORY WITH SPECIFICATION
// https://github.com/dotnet-architecture/eShopOnWeb

public IEnumerable<T> List(ISpecification<T> spec)
{
    // fetch a Queryable that includes all expression-based includes
    var queryableResultWithIncludes = spec.Includes
        .Aggregate(_dbContext.Set<T>().AsQueryable(),
            (current, include) => current.Include(include));

    // modify the IQueryable to include any string-based include statements
    var secondaryResult = spec.IncludeStrings
        .Aggregate(queryableResultWithIncludes,
            (current, include) => current.Include(include));

    // return the result of the query using the specification's criteria expression
    return secondaryResult
                    .Where(spec.Criteria)
                    .AsEnumerable();
}

Förutom att kapsla in filtreringslogik kan specifikationen ange formen på de data som ska returneras, inklusive vilka egenskaper som ska fyllas i.

Även om vi inte rekommenderar att du returnerar IQueryable från en lagringsplats, är det helt okej att använda dem inom lagringsplatsen för att skapa en samling resultat. Du kan se den här metoden som används i listmetoden ovan, som använder mellanliggande IQueryable uttryck för att bygga upp frågans lista över inkluderingar innan du kör frågan med specifikationens villkor på den sista raden.

Lär dig hur specifikationsmönstret tillämpas i exemplet eShopOnWeb.

Ytterligare resurser