Laden...

[Artikel] Die myCSharp.de Software Architektur

Erstellt von Abt vor 2 Jahren Letzter Beitrag vor 2 Jahren 3.130 Views
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren
[Artikel] Die myCSharp.de Software Architektur

Stand: Januar 2022

In diesem Artikel wollen wir euch über die Entstehung, die Ziele sowie die aktuellen Umsetzungswege über myCSharp informieren. Die Infrastruktur von myCSharp ist hier beschrieben: [Artikel] Die myCSharp.de Infrastruktur

Disclaimer

Die bei myCSharp angewandte Architektur basiert im Endeffekt auf meiner (Abt) persönlichen Erfahrung und Herangehenweise in dutzenden Projekten aus den letzten Jahren. Wie so oft gibt es immer mehrere Wege nach Rom - das Ziel war es jedoch eine Risiko-arme, bewährte aber auch durchaus Zweck-orientierte Architektur zu haben, was für alle Bereiche von myCSharp gilt. In viele Entscheidungen und Umsetzungen war aber das gesamte Team involiert, auch hier wieder besonders gfoidl.

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Open Source

Vorweg: wir werden myCSharp als Ganzes nicht Open Source zur Verfügung stellen. Ein Open Source Projekt einer Applikation funktioniert nur dann, wenn es dafür auch eine breite Basis gibt - wir jedoch haben bewusst sehr spezifisch entwickelt. Es gibt keine Zielgruppe unserer Applikation, sodass das Aufsuchen und Ausnutzen von Fehlern die einzige Folge wäre.

Da wir aber unser Wissen teilen wollen, werden wir Konzepte und Implementierungen durchaus zeigen bzw. zur Verfügung stellen (bzw. haben das schon getan).
myCSharp auf GitHub

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Motivation

Das Forum wurde Jahrelang auf einer sehr veralteten PHP-Basis betrieben, die auch rechtlichen Anforderungen nicht mehr gerecht wurde und aufgrund des Skillsets quasi nicht mehr weiterentwickelt werden konnte. Vergangene Anpassungen an der Codebase waren jedoch so tief, dass ein Update der Software selbst nicht mehr (ohne weiteres) möglich war - wir waren also in einer Sackgasse.

Wir - bzw. als Betreiber ich - hatten zwei Möglichkeiten:* myCSharp wird abgeschalten, weil sowohl für den Betrieb wie auch für die Benutzer nicht mehr sicher

  • myCSharp wird erneuert

In Zahlen* Stand heute basiert myCSharp auf ca. 1.5 Millionen Zeilen Code

  • Über 350 dokumentierte Issues/Features (ja, wir halten das sehr sehr sehr einfach)
  • Gesamt über 450 Pull Requests (die teilweise zu groß waren)
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Glossar

Die Dokumentation verwendet .NET-übliche Begrifflichkeiten, möchte sie an dieser Stelle jedoch nochmals beschreiben:

  • Actions: ASP.NET Methoden in einem Controller und im Endeffekt "Request-Endpunkte"
  • Services: Eine Implementierung einer Logik oder Funktionsweise, die eine eine Domäne (zB User, Forum..) betrifft. Services werden i.d.R. als Scope gesehen und in Form eines Repository Patterns umgesetzt. Durch das Scope-Verhalten sind diese i.d.R. nicht Thread-Safe, können als Scoped bzw. Transient in Dependency-Injection Umgebungen registriert werden und leben in einer Web-Anwendung während eines einzelnen Requests.
  • Providers: Eine Implementierung einer spezifischen Funktionalität oder Abhängigkeit, betreffen keine Domäne. Provider werden i.d.R. als Singleton gesehen und in Form eines Strategy Patterns umgesetzt. Durch das Singleton-Verhalten sind diese Thread-Safe, können als Singleton in Dependency-Injection Umgebungen registriert werden und leben daher dauerhaft, auch über mehrere Requests hinweg.
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Die Grundarchitektur

Unsere Grundarchitektur begonn auf den Möglichkeiten von .NET Core 3 sowie ASP.NET Core 3, befindet sich aber - Stand dieses Artikels - auf Basis von .NET 6.
Die Architektur basiert dabei auf folgenden (externen) wichtigsten Komponenten:* Azure Bibliotheken (zB. Azure Identity, Azure Key Vault, Azure Storage, Azure Application Insights..)

Unsere Software Architektur (siehe Bild) ist dabei in drei Grundbestandteile getrennt:

  • ASP.NET Core Runtime
  • Engine (CQRS)
  • Services und Provider

Unsere Solution-Struktur ist dabei:


MyCSharp.Common - Erweiterungsmethoden für .NET Klassen
MyCSharp.BBCode - Unser BBCode Parser
MyCSharp.ContentRenderer - Unsere Content Render Engine, primär für BBCode, Markdown.. zu HTML.
MyCSharp.Portal - Unser Haupt-Logik-Projekt mit allen Features, Engine, Services, Provider, Datenbank...
MyCSharp.Portal.AspNetCore - Erweiterungen für ASP.NET Core
MyCSharp.Portal.Database.Mssql.Build - Unser Datenbank-Buildprojekt (DACPAC Deployment)
MyCSharp.Portal.Features - Unser Feature Management
MyCSharp.Portal.Monitoring - Monitoring, Tracing, Logging
MyCSharp.Portal.Notifications - Notifications Hauptprojekt (E-Mails, Push....)
MyCSharp.Portal.Notifications.EMail - EMail Notifications
MyCSharp.Portal.Notifications.EMail.WebApp - Dev Projekt für unsere Email Razor Templates
MyCSharp.Portal.WebApp - Die tatsächliche Webapplikation

Unsere Datenbank ist dabei wie folgt strukturiert:


MyCSharp.Portal.Database - Hauptnamespace, hier liegen generelle EF Core Features drin
MyCSharp.Portal.Database.Cache - Caching Mechanismen
MyCSharp.Portal.Database.Entities - Unsere Entitäten
MyCSharp.Portal.Database.Entities.Extensions - Erweiterungen, zB für Expressions
MyCSharp.Portal.Database.Mssql - MSSQL-spezifisches, zB DB Context
MyCSharp.Portal.Database.Mssql.Configurations - Entity Mapping Configs für MSSQL
MyCSharp.Portal.Database.Repositories - Repositories und deren Implementierungen
MyCSharp.Portal.Database.Views - Datenbank Views

Hinzu kommen jedoch Modelle aus:


MyCSharp.Portal.Data.Models - Applikationsmodelle, zB. Enums
MyCSharp.Portal.Data.Projections - Applikations Projektionen
MyCSharp.Portal.Data.Projections - Applikations Views (keine DB Views)

Das Namespacing und damit der Grundbaustein der Software Architektur entspricht also 99% den Regeln aus C# bzw. .NET.

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Die myCSharp Engine aka Event Architektur

Applikationen basieren in den meisten Fällen auf Domänen, bestehen also aus folgenden Bestandteilen:

  • Entitäten für die Datenbankdarstellung
  • Repositories für den Umgang und das Mapping von Entitäten
  • Business Modelle für die Logik
  • Services für die Logik

Dieser Aufbau ist relativ simpel, aber hat Probleme in Webanwendungen* Entitäten müssen nicht ständig neu geladen werden

  • Business Modelle benöten Informationen aus mehreren Entitäten. Man muss also viel DB-Calls absetzen bzw. Ping-Pong-Requests und Roundtrips, um Logik über Business Modelle ausführen zu können.
  • Die Service-Orchestierung ist auch teil der Logik; verlagert sich aber in die UI (bei ASP.NET die Actions). Nur zu lösen mit Service in Service.

Daher basiert unsere Architektur auf Event Sourcing, implementiert mit MediatR. Dies bietet folgende Vorteile:* Durch CQRS basiert unsere Anwendung auf "Aktionen", wobei CQRS hier ein Orchestrierungsmittel der Services sind. Die ASP.NET Action kennt daher auch nur die Abhängigkeit zu CQRS (bei uns MediatR), was unsere Webanwendung extrem schlank und einfach hält.

  • CQRS-Bausteine sind Orchestrierungs-Elemente; können wir also sehr flexibel verwenden und darin entweder Services, Provider, Repositories oder auch direkt den DB-Context nutzen, ohne etwas zu brechen oder umsauber gestalten.

Was folgende Effekte hat:* Wir haben keinerlei Business Modelle, sondern übergeben Informationen durch CQRS-Bausteine wie Commands oder Notifications

  • Wir haben als zusätzliche Datendarstellung Views und Projektionen; müsen also nicht immer alle Informationen laden, sondern nur die, die wir brauchen
    Ein Command oder eine Notification sind aus Architektursicht Abstraktionen; das bedeutet, dass eine ASP.NET Action einfach nicht den/die Services bzw. ein/die Repositories aufrufen würde, sondern i.d.R. einen Command, eine Notifications bzw. einen Queries. Es stellt also eine zusätzliche Schicht dar.

  • Durch die Handler-Implementierung von CQRS können wir uns jederzeit an jeder Stelle der CQRS-Pipeline einklinken und sparen uns zB. beim Logging oder bei Validierungen extrem viel Code, was über "leichtere" Pattern nicht möglich wäre.

In einem sehr einfachen Fall - die Ansicht eines Benutzerprofils - sieht es zum Beispiel so aus:


[Route(RouteTemplates.UserWithName)]
[FeatureGate(FeatureFlags.UsersEnabled, FeatureFlags.UsersProfileView)]
public async Task<IActionResult> Profile(int userId, string userNameSlug)
{
    var user = await _eventDispatcher.Get(new GetUserProfileViewProjectionQuery(userId)).ConfigureAwaitFalse();
    if (user is null) return UserProfileNotFoundRedirect(userId);

    UserProfileViewModel vm = new UserProfileViewModel(user).WithContextOf(this);
    return View(ViewsRoot + "UserProfile.cshtml", vm);
}

Wir sehen hier also den Aufruf eines Profils anhand der Id. Die Id wird dazu verwendet einen CQRS-Query zu laden, ohne an dieser Stelle die Implementierung zu kennen.
Der Query ist dabei einfach nur eine Referenz, die angibt, was die Parameter sind und was zurück kommt.


public record GetUserProfileViewProjectionQuery(int UserId) : IQuery<UserProfileView?>;

Durch die Signatur ist bekannt:* Es ist ein Query: es gibt daher einen Rückgabetypen, auf dessen Laden gewartet werden soll.

  • Die Rückgabe ist entweder null oder vom Typ UserProfileView (wenn der User nicht existiert, dann kommt null => kein Profil)

Der Handler dieses Queries sieht dabei folgendermaßen aus:


public class GetUserProfileViewProjectionQueryHandler : IQueryHandler<GetUserProfileViewProjectionQuery, UserProfileView?>
{
    private readonly IUserRepository _userRepository;

    public GetUserProfileViewProjectionQueryHandler(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public async Task<UserProfileView?> Handle(GetUserProfileViewProjectionQuery r, CancellationToken ct)
       => _userRepository.GetUserProfile(r.UserId, ct);
}

MediatR schaut in seine Registrierung, welcher QueryHandler zum Query passt und führt diesen aus.
An dieser Stelle passiert nichts anderes als die Anfrage 1:1 an das Repository weiter gereicht wird. Die ProfileView wird also gar nicht im Handler geladen; sondern der Handler orchestriert hier nur das Repository.

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Warum wir das Repository nicht direkt ansprechen, was aus Runtime-sicht effizienter wäre: bei MediatR können sogenannte Behaviors registriert werden, die bei jeder Handler-Ausführung "um den Handler herum" arbeiten können.
Mit solchen Behaviors können wir:* Die Laufzeit jedes Handlers messen

  • Berechtigungen prüfen
  • Auf jede Exception eines Handlers reagieren, das wir mit alternativen Pattern in der Form nicht könnten
  • An einer zentralen Stelle mit wenig Aufwand loggen

Ähnlich sieht es bei Commands aus, hier als Beispiel der Command, der ausgeführt wird, wenn jemand sein eigenes Profil aktualisiert:


public class UserProfileUpdateCommandHandler : ICommandHandler<UserProfileUpdateCommand, Unit>
{
    private readonly IMyCSharpDbContext _db;

    public UserProfileUpdateCommandHandler(IMyCSharpDbContext db)
    {
        _db = db;
    }

    public async Task<Unit> Handle(UserProfileUpdateCommand r, CancellationToken ct)
    {
        // load
        var profile = await _db.UserProfiles.GetByIdAsync(r.UserId, ct).ConfigureAwaitFalse();
        if (profile is null) throw new UserNotFoundException(r.UserId);

        // update
        profile.DateOfBirth = r.DateOfBirth;
        profile.JobTitle = r.JobTitle;
        profile.Company = r.Company;
        profile.City = r.City;
        profile.Interests = r.Interests;
        profile.HomepageUrl = r.HomepageUrl;
        profile.BlogUrl = r.BlogUrl;
        profile.LinkedInUrl = r.LinkedInUrl;
        profile.XingUrl = r.XingUrl;
        profile.FacebookUrl = r.FacebookUrl;
        profile.TwitterHandle = r.TwitterHandle;
        profile.Description = ContentCleanupProvider.Cleanup(r.Description);

        profile.LastUpdatedOn = DateTimeOffset.UtcNow;

        // save
        await _db.SaveChangesAsync(ct, updateChangeTrackerStates: false)
            .ThrowIfNoResults().ConfigureAwaitFalse();

        return Unit.Value;
    }
}

An dieser Stelle sieht man die direkte Implementierung, die erst eine DB-Entität lädt, diese aktualisiert und wieder schreibt.
Welche Alternativen es gäbe?* Profile Update im Service und Repository implementieren

  • Nur ein Update an die Datenbank schicken, ohne vorher zu laden und die Werte zu prüfen

Warum wir das nicht getan haben?* Da wir dieses Update nur an dieser Stelle benötigen, gibt es kein Vorteil das durch zwei weitere Instanzen, zuerst den UserService und dann das UserRepository, zu schleusen. Anders wäre, wenn das von mehreren Stellen aus verwendet werden würde.

  • Zugegeben, das ist nun tieferes Wissen und auch Optimierung: EF Core kann anhand eines Hash-Vergleichs erkennen, welche Werte sich wirklich geändert haben und schickt nur die zu ändernden Eigenschaften an die Datenbank. Daher kann ich auch Werte setzen ohne zu prüfen, ob der neue Wert dem alten entsprcht. Verwendet man den Update-Mechanismus in EF Core, dann werden immer alle Werte übertragen und in der Datenbank gesetzt, was zur Folge hat, dass die Datenbank an dieser Stelle auch den Index neu aufbauen wird. Das kostet - in den meisten Fällen - mehr Gesamtperformance als einfach die Entität zu aktualisieren, sofern es sich um eine einfache Entität handelt wie in diesem Beispiel.

Um Zurück auf die Aktionen zu kommen: eine Aktion kann eine Kette von Commands, Notifications und Queries auslösen, aus unterschiedlichsten Zwecken und in unterschiedlichen Reihenfolgen.
Als Beispiel dazu die ASP.NET Action, wenn man ein Forenthema aufruft:


[HttpGet]
[Route(RouteTemplates.ForumThreadWithTitle)]
[FeatureGate(FeatureFlags.ForumEnabled)]
public async Task<IActionResult> View(PortalWebUserId? webUserId, int threadId, string threadTitle, int? postId = null, int? page = null)
{
    // Aufbau und Filtern der Paging Parameter des Requests
    PagingOptions pageOptions = new(page ?? 1);

    // Laden der Thread View aus der Datenbank
    ForumThreadView? view = await _eventDispatcher
        .Get(new GetForumThreadContentViewQuery(threadId, webUserId, pageOptions.Skip, pageOptions.Take))
        .ConfigureAwaitFalse();

    if (view is null)
    {
        return RedirectWithError(_router.ToForum(), $"Thread {threadId} konnte nicht gefunden werden.");
    }

    // Benutzerauthorisierung
    //   wir haben uns hier für die ASP.NET Authorization entschieden, die in der Action geprüft werden muss.
    _forumAuthorization.ThrowIfUserBoardAccessDenied(view.AllowedBoardRoles);

    // Aufbau des Pagings
    Paginator paging = new(view.TotalPostCount, pageOptions, pageRoute => _router.ToForumThreadView(view.ThreadId, view.ThreadTopic, pageRoute));
    if (!paging.PageIsValid(pageOptions.Page))
    {
        return Redirect(_router.ToForumThreadView(view.ThreadId, view.ThreadTopic, paging.FirstPage));
    }

    // Zählen der Views
    {
        IClientInformation? clientInfo = _userTypeDetectionService.GetClientInformation();
        if (clientInfo?.IsBrowser == true)
        {
            // Hier wird eine CQRS Notification ausgelöst
            //   Uns interessiert nicht, wann diese verarbeitet wurde oder ob diese erfolgreich war
            await _eventDispatcher
                .PublishFireAndForget(new ForumThreadViewHitNotification(view.ThreadId, DateTimeOffset.UtcNow, webUserId))
                .ConfigureAwaitFalse();
        }
    }

    // View
    ForumThreadViewViewModel vm = new ForumThreadViewViewModel(view, paging).WithContextOf(this);
}
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Alle CQRS Bestandteile dieses Requests:* Es wird in der ASP.NET Core Request Piepine über ein Command geprüft, ob der User angemeldet ist. Diese Prüfung ist eine Stored Procedure Datenbank abfrage, da zeitgleich zur Prüfung auch das Akitivätsdatum des Profils aktualisiert wird. Dies erspart DB-Roundtrip Calls.

  • Es wird über ein Query geprüft, ob das Forum Feature "Forum" aktiv ist.
  • Es wird über ein Query die Forenansicht geladen.
  • Es wird über ein Query die Authorisierung geprüft.
  • Es wird über eine Notification der Themenbesuch hoch gezählt.

Fazit von unser Event Engine:* Wir haben eine sehr übersichtliche Architektur, wie die Applikation funktioniert.

  • Wir können die Aktionen jederseit mit simpelsten Code erweitern.
  • Unsere Logik hat keine primäre Abhängigkeit untereinander, sondern es ist alles weitgehend isoliert und Änderungen haben keine versteckten Folgen.
  • Wir haben durch die MediatR Implementierung mit einfachsten Code-Mitteln mit Möglichkeit alle unsere Applikationimplementierungen (operation architecture) zu überwachen, zu messen und auf Fehler zu reagieren.
  • Wir müssen uns nicht verkünsteln, sondern können viele Dinge auch über Keep-it-simple umsetzen, was uns schneller und einfacher macht.
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Datenbank Architektur

Die gesamte Datenbank-Logik basiert auf Entity Framework Core sowie MSSQL. Dabei sei gesagt, dass wir bewusst MSSQL als harte Abhängigkeit gewählt haben, da wir keine Motivation oder Notwendigkeit einer Multi-Datenbank sehen.
Davon unabhängig ist, dass wir jedoch für verschiedene Daten durchaus verschiedene Datenbank-Engines verwenden könnten - aber eben nicht mehrere Datenbanken für die gleiche Entität.

Diese Vorgehensweise ermöglicht, dass wir spezifischer und damit effizienter sowie performanter programmieren können bzw. dies auch die Abfragen sind.
Wie bereits angeschnitten basiert unsere Architektur nicht nur auf Entitäten, sondern auch auf Datensparsamkeit, sodass wir nur die Daten laden müssen, die wir auch laden wollen.
Dabei haben wir also zwei zusätzliche Elemente:

Wollen wir also zB. von Forenthemen nur wissen, welche Id und welchen Titel dieses trägt, dann haben wir folgende Möglichkeiten:* Man lädt die gesamte Entität

  • Man lädt manuell nur gewisse Spalten
  • Man lädt die über Projektionen typisiert nur das, was man will

Wir haben uns bei den aller meisten Queries für den 3. Punkt entschieden, weil wir damit typisiert, schlank und Entwicklungssucher Daten laden können.
Für Projektionen verwenden wir AutoMapper Projections, das uns 99% der Arbeit abnimmt. Eine Projektion hat dabei zwei Bestandteile:* Die Projektion

  • Das Mapping der Projektion

Für das Beispiel sieht das so aus:


public class ForumThreadTopicProjection
{
    public int ThreadId { get; set; }
    public string ThreadTopic { get; set; } = null!;
}

[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
public class ForumThreadTopicProjectionProfile : AutoMapper.Profile
{
    public ForumThreadTopicProjectionProfile()
    {
        CreateProjection<ForumThreadEntity, ForumThreadTopicProjection>()
            .ForMember(p => p.ThreadId, opt => opt.MapFrom(e => e.Id))
            .ForMember(p => p.ThreadTopic, opt => opt.MapFrom(e => e.Topic));
    }
}

Egal wo wir diese Projektionsklasse in unserem Datenbank-Code verwenden, AutoMapper erzeugt durch das Mapping dazu automatisiert die Expression und die EF Core Expression Engine dazu automatisiert den SQL Code.
Dies funktioniert nicht nur für eine einzelne Entität, sondern für alle Relationen, die in den Entitäten gepflegt sind. Bei der Profilansicht werden zB. viele Informationen über mehrere Tabellen hinweg abgefragt:


[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
public class UserForumHeadProjectionProfile : AutoMapper.Profile
{
    public UserForumHeadProjectionProfile()
    {
        CreateProjection<UserAccountEntity, UserForumHeadProjection>()
            .ForMember(p => p.UserId, opt => opt.MapFrom(e => e.Id)) // "UserAccount" Tabelle
            .ForMember(p => p.UserName, opt => opt.MapFrom(e => e.UserName)) // "UserAccount" Tabelle
            .ForMember(p => p.RegisteredOn, opt => opt.MapFrom(e => e.Profile.RegisteredOn)) // "UserProfile" Tabelle
            .ForMember(p => p.UserAvatar, opt => opt.MapFrom(e => e.Avatar)) // "UserAvatar" Tabelle
            .ForMember(p => p.UserForumStats, opt => opt.MapFrom(e => e)); // Eine indizierte View in MSSQL
    }
}

Zusätzlich zu den Projektionen, die also Darstellungen von Runtime SELECTS sind, haben wir Views. Views sind dabei nichts anderes als MSSQL Views, die sowohl dynamisch sind wie aber auch indizierte Views bzw. Rückgaben von Stored Procudures sein können. Des weiteren sind Views von uns Projektionen, die wir manuell erzeugen müssen, zB. weil das Aufgrund der Query-Art oder des SQL Codes nicht über eine AutoMapper Projektion umsetzbar ist (zB. Multi-Selects, also mehrere Abfrageergebnisse pro DB-Call).

Diese sind sehr simpel, und bestehen einfach nur aus einer Klasse mit Eigenschaften.


// EF Core View
public record UsersSearchView(int Count, IList<UserSearchUserView> Users) : IEFCoreView

// MSSQL View
public class ForumThreadPostCountStatsDbView : IDbView
{
    public int ForumThreadId { get; set; }
    public long PostsCount { get; set; }
}

Da diese Daten-Klassen nicht alleine stehen, haben auch wir Repositories bzw. Abfrage-Methoden.
In den Entity-Repositories haben wir grundlegend folgenden schematischen Aufbau:


// Query Referenz auf alle Benutzer
public IQueryable<UserAccountEntity> QueryUsers(DbTrackingOptions to)
    => DbContext.UserAccounts.With(to);
// Query Referenz auf einen Benutzer
public IQueryable<UserAccountEntity> QueryUser(int userId, DbTrackingOptions to)
    => QueryUsers(to).Where(UserAccountQuery.HasId(userId));

Diese beiden Referenz-Basen sind dann Ausgangspunkte für spezifische Queries, wie zB:


// Für weitere Query-Referenzen im Repository:
public Task<bool> ExistsId(int userId, CancellationToken ct) => QueryUser(userId, DbTrackingOptions.Disabled).AnyAsync(ct);
// Für Projektionen in Services:
public async Task<PortalUser?> GetById(int id, CancellationToken ct)
   => await _userRepository.QueryUser(id, DbTrackingOptions.Disabled).ProjectTo<PortalUser>(_mapper.ConfigurationProvider).SingleOrDefaultAsync<PortalUser?>(ct);

Um Queries nicht ständig in Delegates schreiben zu müssen, haben wir auch diese zentralisiert, um Code zu sparen, Tippfehler zu vermeiden, Komplexität aus den Queries zu nehmen. Die gezeigte Clause UserAccountQuery.HasId(userId) sieht zum Beispiel so aus:


public static Expression<Func<UserAccountEntity, bool>> HasId(int id) => x => x.Id == id;

Auch dies funktioniert mit allen Query-Arten von EF Core, solange sie Teil des Relationsmodells der Entitäten sind.

Wir haben also eine sehr hohe Einfachheit und eine sehr hohe Wiederverwendbarkeit in unsere DB Schicht.
Dadurch ist diese auch wirklich sehr schlank, sehr effizient und sehr flexibel. EF Core ist großartig, wenn man es so verwendet!

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Das myCSharp Identity Management

Wir vertrauen der Implementierung von ASP.NET Core Identity, für die Authentifizierung und Authorisierung der Benutzer. Dazu muss man jedoch sagen, dass wir die Standard Implementierungen (zB UserManager) nicht verwenden, sondern so gut wie alle Implementierungen des Identity Managers selbst durchgeführt haben. Wir haben nur die Sicherheitsrelevanten Bausteine wie zB. das Hashing von Passwörtern, Zwei-Faktor-Authentifizierung, Cookie Handling.. dem Standard belassen.

Unsere eigenen Identity Manager verwenden daher nicht mehr Entity Framework direkt, sondern unsere Engine.
So ist gewährleistet, dass jegliche Interaktion "mit dem Herz" nur über unsere Engine erfolgt. ASP.NET Core Identity ist also nur für den Web-Stack verantwortlich.

Das myCSharp Notification Management

Wir verwenden die Notifications der Engine, um jegliche Arten von Notifications zu nutzen; primär also E-Mails. Wir haben jedoch bereits teilweise WebSockets implementiert, aber noch nicht scharf geschalten.
Wir rendern derzeit alle E-Mails über eine Implementierung von ASP.NET Razor Templates; also so, wie auch ASP.NET Core später Razor zu HTML-Content umformt.

Dies ist extrem simpel umzusetzen, kostet jedoch durchaus etwas CPU/RAM und entsprechende Allocations.
Wir planen hier bereits einen Ersatz (Liquid Templates via Fluid), der deutlich effizienter ist, um E-Mails zu generieren.

Verschickt werden unsere E-Mails mit Hilfe des MailKits und einem SMTP-Gateway.

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Optimierungen* Wir haben eine eigene Fassung des Microsoft.FeatureManagement-Providers geschrieben, da die aktuelle Implementierung ein paar Defizite hat (diese wurden auch gemeldet, Reaktion war eher "meh": Fixed race conditions in FeatureManagerSnapshot. Mittlerweile ist diese Verbesserung in das Feature Management eingeflossen: Support concurrency in IFeatureManagerSnapshot

  • Wir haben einen eigenen User Agent Parser geschrieben:
  • Die meiste Leistung des Forums frisst das Content Rendering, also zB. das Rendern von BBCode, Smilies, Markdown... zu HTML; hierfür haben wir eine eigene, extrem für unsere Zwecke optimierte Render Pipeline geschrieben.

Probleme

Wir hatten während der Entwicklung durchaus Stolpersteine, dazu:* Es gab mehrfach Regressions in EF Core, was uns immer wieder nach hinten geworfen hatte; vor allem in Sachen Expressions

  • Encodings haben uns immer wieder Fallen gestellt

Technische Schulden* Es gab in der alten PHP-Forenwelt durchaus "interessante" Implementierungen und Limits, die uns heute noch Probleme machen und ohne größeren (manuellen) Aufwand nicht zu lösen sind, zB. dass Posts teilweise über 8000 Zeichen groß sind und wir deshalb alle 700.000 Posts nicht indizieren können, was das Forum in sich langsamer macht - vor allem die Suche.

Fazit

Wir sind sehr zufrieden - aber auch wir haben natürlich noch viel Möglichkeiten und viel vor 🙂

A
764 Beiträge seit 2007
vor 2 Jahren

Fazit

Wir sind sehr zufrieden - ... 🙂

Ich auch, top!

M
10 Beiträge seit 2021
vor 2 Jahren

Danke für die lehrreichen Posts!

Finde eure Solution-Struktur eine sehr gute und vorallem praxisorientierte Vorlage.
Wenn Ihr die Ordner/Namespace-Struktur hier ebenfalls veröffentlichen würdet, wäre das echt klasse.

Habe jahrelang das nopCommerce Project als Vorlage gebraucht, jedoch gefällt mir eure Struktur besser und MSDN conform.
Das Naming ist ja leider immer wieder schwierig (besonders wenn man perfektionist ist 😉. z.B. findet man unter "Infrastructure" unterschiedlich Definitionen (z.B. hier wird der Begriff für "Startup Stuff" verwendet. Oder eben für externe Dienste/DB).

Mehr Details was hinter den folgenden Projektem steckt wäre sehr interessant:

  • MyCSharp.Portal - Unser Haupt-Logik-Projekt mit allen Features, Engine, Services, Provider, Datenbank...
  • MyCSharp.Portal.Features - Unser Feature Management
  • MyCSharp.Portal.WebApp - Die tatsächliche Webapplikation

Ich gehe davon aus, dass die effektiven Mvc spezifische Features (Controllers/ViewModels/Views) im "MyCSharp.Portal.WebApp/Features/FeatureA" etc. liegen?
Wo liegen welche "Features"?

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Wenn Ihr die Ordner/Namespace-Struktur hier ebenfalls veröffentlichen würdet, wäre das echt klasse.

Ich predige zwar immer das "perfekte Bild", wie etwas sein sollte - aber auch bei uns ist Pragmatismus enthalten 😉 "MSDN" konform gibts ja eh nicht.
Ziel meiner Predigt ist ja nicht, dass das so gemacht wird, sondern dass man sich daran orientiert und für sich seinen Weg findet.
Daher sollte man unsere Struktur auch nicht kopieren; will dem aber trotzdem gerecht werden.

Hier nun die wichtigsten Namespaces bei uns (vereinfachte Darstellung)


>> MyCSharp.Portal     # Dies ist das Haupt Projekt mit der gesamten Logik
-   ----Cache
-   ----Configuration 
-   ----Data            # Alles, was bei unserem Portal mit Daten zutun hat
-   -   ----Models
-   -   ----Projections
-   -   ----Results
-   -   ----Views
-   ----Database        # Alles, was bei unserem Portal mit Datenbanken (MSSQL, Table Storage) zutun hat
-   -   ----Cache
-   -   ----Entities    # Die Datenbank-Entitäten
-   -   ----Mssql
-   -   ----Repositories
-   -   ----Views
-   ----Engine  # Das ist die Infrastruktur für unsere MediatR Implementierung
-   ----Features    # Dies sind die Features und die eigentliche Logik jedes Features
-   -   ----Forums # Alle Feature sind gleich aufgebaut in ihrer Struktur
-   -   -   ----Engine      # CQRS fürs Forum
-   -   -   ----Exceptions
-   -   -   ----Models      # Foren Modelle
-   -   -   ----Protection
-   -   -   ----Providers   # Foren Provider
-   -   -   ----Services    # Foren Services
-   -   -   ----.....
-   ----Identity    # Identitätsimplementierung, basierend auf ASP.NET Core Identity
-   -   ----Hashers
-   -   -   ----Providers
-   -   ----Managers
-   -   ----Models
-   -   ----Services
-   -   ----Stores
-   ----Monitoring  # System Monitoring Infrastruktur
-   ----Providers   # Verschiedene Provider, zB um die Azure Services zu sprechen oder Bilder zu manipulieren
-   -   ----TextRender  # Hier befindet sich unser BB Code Render Service drin
-   -   ----Validation  # übergreifende Logik-Validierung

>> MyCSharp.Portal.AspNetCore  # Spezifische Implementierungen für ASP.NET Core
-   ----FeatureManagement
-   -   ----Handlers    # Hier wird quasi beim Request geprüft, ob ein Feature aktiv / inaktiv ist
-   ----Features    # Hier sind ASP.NET Core Bausteine enthalten, die von der Ansicht selbst unabhängig sind, aber ASP.NET Core brauchen
-   -   ----Forum
-   -   ----TeamMessages
-   ----Identity    # ASP.NET Core Zeugs für Identity
-   -   ----Handlers
-   -   ----LoginIdentityCheck
-   ----Middlewares # Verschiedene Middlewares, zB. Error Handling
-   ----Models      # Modelle, die es nur in ASP.NET Core gibt
-   ----Monitoring  # ASP.NET Core spezifisches für Monitoring, zB. ASP Profilers, App Insights
-   ----Mvc         # Zeugs wie BaseController, FormFileExtensions, ViewDataExtensions...
-   ----Policies    # ASP.NET Core Policy based Authorization
-   ----Profiling   # Implementierung von MiniProfiler
-   ----Protection  # Request Schutz
-   ----Providers
-   -   ----Clients # Browser Client Detection (zB. um Google Search Bot zu erkennen)

>> MyCSharp.Portal.Database.Mssql.Build    # Unsere Datenbankmodellierung mit .sql Files, um DACPAC zu erzeugen
>> MyCSharp.Portal.Features    # "Feature Flag" Infrastruktur (Microsoft.FeatureManagement Package)
>> MyCSharp.Portal.WebApp  # Die ASP.NET Core Anwendung
-   ----Areas   # "Features" heissen hier halt historisch "Areas"
-   -   ----Account # Standard Implementierung mit Controllers, Models, Views
-   -   ----Errors
-   -   ----Forum
-   -   ----...
-   ----BackgroundServices
-   ----Caching
-   ----TagHelpers
-   ----Views
-   -   ----Shared

Im Endeffekt ist das einen Domänen-Architektur, nur dass die Domänen halt Features heissen.

M
10 Beiträge seit 2021
vor 2 Jahren

Sehr nice. Herzlichen Dank 👍 👍

Was natürlich auffählt sind die vielen Models (Projections/Views) die Ihr in jeder Schicht spezifisch definiert.

Ich gehen davon aus, dass Ihr für den Controller auch ein eigenes "SubmitModel" definiert und dann auf das CQRS "CommandModel" mappt?
Greift die Validierung (FluentValidation) auf das SubmitModel oder validiert Ihr in einer tieferen Schicht oder gar "doppelt"?

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 2 Jahren

Also viele Modelle sind das eigentlich nicht, auch wenn sich das in den Namespace aufgrund der getrennten Verantwortlichkeiten eben namentlich wiederholt.
Aber jede Schicht muss halt ohne die höhere Schicht funktionieren. Wir rufen die Commands nicht nur aus der WebApp auf, sondern zB. auch aus Tools.
Und da muss die Schnittstelle genauso funktionieren.

Ja. Die Modelle in den Actions sind standardisiert.


namespace MyCSharp.Portal.AspNetCore.Models;

public abstract class BaseRequestModel { }
public abstract class BaseResponseModel { }
public abstract class BaseResponseModel<TRequestModel> : BaseResponseModel where TRequestModel : BaseRequestModel 
{
    public TRequestModel RequestModel { get; }

    protected BaseResponseModel(TRequestModel requestModel) { RequestModel = requestModel; }
}

Greift die Validierung (FluentValidation) auf das SubmitModel oder validiert Ihr in einer tieferen Schicht oder gar "doppelt"?

Wir validieren teilweise doppelt.
CQRS Validation Pipeline with MediatR and FluentValidation - Code Maze