Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Teil 4 - Erweiterung um ein Repository Pattern und Service Layer

Einleitung

Dieser Artikel ist eine Serie von Artikeln, die den Aufbau einer Fullstack Applikation unter DotNet Core und Vue.js beschreiben.

Bisher sind folgende Artikel erschienen:

  • Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Erste Schritte Link
  • Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Teil 2 Link
  • Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Teil 3 - Einbinden PostgreSQL als Datenbank Link
  • Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Teil 4 - Erweiterung um ein Repository Pattern und Service Layer Link
  • Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Teil 5 - Erste Schritte mit Vue.js und Typescript Link
  • Fullstack - ASP.NET Entwicklung mit DotNet Core und Vue.js - Teil 6 - Erweiterung um Vuex und Axios Link

In diesen Artikel wollen wir die Anbindung der Datenbank "optimaler" machen, da wir aktuell das Daten-Layer an das Front-End angebunden haben.

Hintergrund ist, dass man direkte Abhängigkeiten zwischen dem UI Interface und der Datenbasis vermeiden möchte, so dass Änderungen in einen der Schichten nicht unweigerlich zu komplexen Änderungen in allen Schichten führen. Hierdurch wird der Code flexibler und robuster, insbesondere in größeren Applikationen.

Implementieren der Repository Schicht (Layer)

Im ersten Schritt fügen wir das Repository Pattern als Generic Repository und das Unit-of Work-Pattern in unseren Source Code ein.

Das Unit-of-Work Pattern wird im Data Layer implementiert, um mehrere Transaktionen zusammenzulegen und erst, wenn der "Commit" erfolgreich werden alle Datenbank-Änderungen auf einmal durchgeführt oder mit einen "Rollback" abgebrochen. Werden mehrere Tabellen verwendet (was in unseren Beispiel) nicht der Fall ist, kann vermieden werden, dass inkonsistente Verknüpfungen und ähnliches entstehen.

Das Generic Repository Pattern ermöglicht uns zwei Aufagben zu erfüllen. Zum einen definieren wir eine Standard-Schnittstelle mit denen auf Daten zugegriffen werden. Hierbei wird der CRUD Ansatz implementiert. CRUD steht für:

  • Create
  • Read
  • Update
  • Delete

Aktuell benötigen wir selbstverständlich nur "Read", allerdings sind in einer realen Anwendung häufig alle diese Aktionen in Verwendung.

Im Projekt legen wir hierzu ein neues Verzeichnis "Repositories" an und dort als Interface "IGenericRepository.cs":

using System;  
using System.Collections.Generic;  
using System.Linq.Expressions;

namespace DotNetVueBlog.Repositories  
{
    public interface IGenericRepository<T> where T : class
    {
        IEnumerable<T> Get();
        IEnumerable<T> Get(Expression<Func<T, bool>> predicate);
        void Add(T entity);
        void Delete(T entity);
        void Update(T entity);
      }
}

Nur die Interfaces werden außerhalb des Repository Layers verwendet, damit keine direkten Abhängigkeiten zwischen den Klassen entstehen. Die Verbindung wird später über Dependency Injection ermöglicht.

Auch für die Unit of Work ist ein Interface zu erstellen:

using System;  
using Microsoft.EntityFrameworkCore;

namespace DotNetVueBlog.Repositories  
{
    public interface IUnitOfWork : IDisposable
    {
        DbContext Context { get;  }
        void Commit();
    }
}

Anschließend kann auch die Implementierung der "UnitOfWork.cs" erfolgen:

using Microsoft.EntityFrameworkCore;

namespace DotNetVueBlog.Repositories  
{
    public class UnitOfWork :  IUnitOfWork
    {
        public DbContext Context { get; }

        public UnitOfWork(DbContext context)
        {
            Context = context;
        }
        public void Commit()
        {
            Context.SaveChanges();
        }

        public void Dispose()
        {
            Context.Dispose();
        }
    }
}

Sowie das recht umfangreiche "GenericRepository.cs":

using System;  
using System.Collections.Generic;  
using System.Linq;  
using Microsoft.EntityFrameworkCore;

namespace DotNetVueBlog.Repositories  
{
    public class GenericRepository<T> : IGenericRepository<T> where T : class
    {
       private readonly IUnitOfWork _unitOfWork;
        public GenericRepository(IUnitOfWork unitOfWork)
        {
            _unitOfWork = unitOfWork;
        }
        public void Add(T entity)
        {
            _unitOfWork.Context.Set<T>().Add(entity);
        }

        public void Delete(T entity)
        {
            T existing = _unitOfWork.Context.Set<T>().Find(entity);
            if (existing != null) _unitOfWork.Context.Set<T>().Remove(existing);
        }

        public IEnumerable<T> Get()
        {
            return _unitOfWork.Context.Set<T>().AsEnumerable<T>();
        }

        public IEnumerable<T> Get(System.Linq.Expressions.Expression<Func<T, bool>> predicate)
        {
            return _unitOfWork.Context.Set<T>().Where(predicate).AsEnumerable<T>();
        }

        public void Update(T entity)
        {
            _unitOfWork.Context.Entry(entity).State = EntityState.Modified;
            _unitOfWork.Context.Set<T>().Attach(entity);
        }
    }
}

Ist alles implementiert, sollte die Verzeichnisstruktur wie folgt aussehen:

Damit das Ganze auch funktioniert, muss im Startup.cs der Dependancy Injection noch gesagt werden, welche Interfaces mit welchen Klassen zu verbinden sind:

Zu guter letzt, kann nun der "SampleDataController" nun so umgebaut werden, dass er "vorläufig" das Repository anspricht:

Wenn alles richtig geändert wurde, sollte ein "dotnet build" und ein "dotnet run" erfolgreich laufen.

Und anschließend natürlich die Werte weiterhin erfolgreich im Browser angezeigt werden:

Dieser Stand ist unter V0.0.3 im GIT Repository abgelegt: https://github.com/smoki99/DotNetVueBlog/releases/tag/v0.0.3

Einziehen einer weiteren Service Schicht

Richtig ideal ist die aktuelle Lösung leider noch nicht, denn sie hat noch einige Nachteile:

  • Der Controller verwendet immer noch direkt das Datenmodell und nicht nur sein eigenes Model
  • Der Controller weiss immer noch indirekt, wo sich welche Daten befinden. (In unseren Fall ist es nur eine Tabelle, aber bei komplexeren Business Applikationen kann ein Datensatz der auf den Bildschirm angezeigt in mehreren Tabellen abgelegt werden)
  • Business Logik ist aktuell noch im Model von MVC verborgen, nämlich die Umrechnung von Celcius in Fahrenheit. (Das ist natürlich ein relativ primitives Beispiel für eine potentiell komplexere Business Logik, die durchaus auch mal mehrfach in Tabellen Werte zu Kalkulation holen könnte. Diese würde man dann an einer zentrallen Stelle, nämlich im Service Layer ablegen wollen.)

Daher wäre das Ideale Bild der Layer, wie folgende:

Zunächst verschieben wir das aktuelle Model, dass unglücklich noch in SampleDataController.cs direkt abgelegt hat in ein eigenes Verzeichnis "DomainModels" und legen es dort unter "WheatherForcastModels.cs" ab (um die Business Logik kümmern wir uns später):

namespace DotNetVueBlog.DomainModel {  
    public class WeatherForecastModel
    {
        public string DateFormatted { get; set; }
        public int TemperatureC { get; set; }
        public string Summary { get; set; }

        public int TemperatureF
        {
            get
            {
                return 32 + (int)(TemperatureC / 0.5556);
            }
        }
    }
}

So das diese nun wie folgt abgelegt ist:

In der Dataei "SampleDataController.cs" ist nun natürlich der neue Namespace einzubinden und "WheatherForecast" in "WheatherForecastModel" umzubenennen:

Anschließend ist ein neues Verzeichnis "Services" anzulegen und dort legen wir zunächst ein neues Interface an, das verantwortlich ist, die Wettervorhersage für eine Stadt zurück zu geben:

using System.Collections.Generic;  
using DotNetVueBlog.DomainModel;

namespace DotNetVueBlog.Services {  
    public interface IWheatherForecastService
    {
        IList<WeatherForecastModel> GetForecast(string city);
    }
}

Anschließend ist der Service zu implementieren. Zunächst mit einen noch recht primitiven Mapping:

using System.Collections.Generic;  
using System.Linq;  
using DotNetVueBlog.DomainModel;  
using DotNetVueBlog.Models;  
using DotNetVueBlog.Repositories;

namespace DotNetVueBlog.Services {  
    public class WheatherForecastService : IWheatherForecastService
    {
        private readonly IGenericRepository<Wheather> _wheatherRepository;


        public WheatherForecastService(IGenericRepository<Wheather> wheatherRepository)
        {
            _wheatherRepository = wheatherRepository;                
        }
        public IList<WeatherForecastModel> GetForecast(string city) {
            return _wheatherRepository.Get().Select(x => new WeatherForecastModel
               {
                   DateFormatted = x.DateFormatted.ToString("dd.MM.yyyy")                 ,
                   TemperatureC = x.TemperatureC,
                   Summary = x.Summary
               }).ToList();
        }
    }
}

Anschließend kann dann der Controller auf den Service umgestellt werden:

Ich hoffe es wird deutlich, dass wenn man sich mehrere Masken mit Wettervorhersagen vorstellt und die Logik nur an einer Stelle hat, viel Entwicklungs- und Pflegeaufwand verringert wird.

Zum Schluss muss nun noch der Service auch im "Startup.cs" eingetragen werden:

Anschließend funktioniert die Applikation mit mehreren Layern.

Extrahieren der Business Logic und des Mappers

Damit das Beispiel vollständig ist, müssen noch zwei Punkte erledigt werden:

  • Das Mapping sollte in einer eigenen Methode erfolgen. Damit verschiedene Methoden die diese Konvertierung durchführen immer auf die gleiche Vorgehensweise haben. (Häufig macht man sogar eine eigene HelperKlasse, aber das würde den Rahmen dieser Übung sprengen).
  • Die Business Logik sollte in einer eigenen Methode (oder auch HelperKlasser) abgelegt werden, damit diese Logik sich nur an einen Ort befindet. Die Business Logik sollte zudem nicht im Datenmodel liegen.

Als einfachsten Schritt wird zunächst die Business-Logik aus dem "WheatherForecastModel.cs" entfernt:

namespace DotNetVueBlog.DomainModel {  
    public class WeatherForecastModel
    {
        public string DateFormatted { get; set; }
        public int TemperatureC { get; set; }
        public string Summary { get; set; }

        public int TemperatureF { get; set; }
    }
}

Nun sieht die Klasse aus, wie erwartet ohne irgendwelche Logik werden dort Daten abgelegt.

Im "WheatherForecastService.cs" werden zwei Methoden hinzugefügt für das Mapping und die Berechnung von Fahrenheit:

using System;  
using System.Collections;  
using System.Collections.Generic;  
using System.Linq;  
using DotNetVueBlog.DomainModel;  
using DotNetVueBlog.Models;  
using DotNetVueBlog.Repositories;

namespace DotNetVueBlog.Services {  
    public class WheatherForecastService : IWheatherForecastService
    {
        private readonly IGenericRepository<Wheather> _wheatherRepository;


        public WheatherForecastService(IGenericRepository<Wheather> wheatherRepository)
        {
            _wheatherRepository = wheatherRepository;                
        }
        public IList<WeatherForecastModel> GetForecast(string city) {
            return _wheatherRepository.Get().Select(x => MapToWeatherForecastModel(x)).ToList();
        }

        private WeatherForecastModel MapToWeatherForecastModel(Wheather wheather) {
            return new WeatherForecastModel
            {
                DateFormatted = wheather.DateFormatted.ToString("dd.MM.yyyy"),
                TemperatureC = wheather.TemperatureC,
                Summary = wheather.Summary,
                TemperatureF = CalculateF(wheather.TemperatureC)
            };
        }

        private int CalculateF(int temperatureC)
        {
            return 32 + (int)(temperatureC / 0.5556);
        }
    }
}

Anschließend sollte sich der Code kompilieren lassen und nach dem Ausführen das richtige Ergebnis anzeigen:

Bleibt nun nur noch eine Frage offen: Müssen alle Layer in ein eigenes Projekt abgelegt werden?

Diese Frage wird in folgenden Blog Artikel umfangreich behandelt: https://programmingwithmosh.com/csharp/should-you-split-your-asp-net-mvc-project-into-multiple-projects/

Zusammenfassen würde ich auch zustimmen, dass Layers und Tiers (also verwenden mehrerer Projekte) unterschiedliche Punkte sind. In unseren Fall sehe ich noch keine Notwendigkeit, dass man wirklich eigene Tiers benötigt. Die Layers bieten eine flexible Möglichkeit bei Bedarf diese mit geringen in Tiers aufzusplitten, aber ich finde das sollte erst dann gemacht werden, wenn es wirklich notwendig ist.

Zusammenfassung

Durch die Einführung von mehreren Schichten und Abgrenzen von Darstellung, Service und Datenhaltung hat unsere Applikation nun einen professionellen Aufbau, der bei der Wartung und Weiterentwicklung viele Probleme von vorherein beiseite räumt.

Mit Hilfe der Dependancy Injection können die Services flexibel miteinander verbunden werden, da diese über die Schichten hinaus nur mit Interfaces miteinander in Kontakt treten.

Die aktuelle Version liegt als Tag "v0.0.4" unter Github:

https://github.com/smoki99/DotNetVueBlog/releases/tag/v0.0.4

Ich wünsche viel Erfolg beim Durcharbeiten dieses Beispiels.