Persistenz¶
Die App hat genau einen DbContext, abgeleitet von CoworkeeDbContext. Module tragen ihre Entities bei; Coworkee bringt die Tabellen für Identity, Einstellungen, Audit und Outbox mit.
public sealed class MyAppDbContext(DbContextOptions<MyAppDbContext> options, ICurrentUser currentUser, IEnumerable<IModelContributor> contributors)
: CoworkeeDbContext(options, currentUser, contributors);
context.Services.AddCoworkeeDbContext<MyAppDbContext>((provider, options) =>
options.UseCoworkeeDatabase(provider.GetRequiredService<IConfiguration>(), "myapp"));
UseCoworkeeDatabase wählt den EF-Core-Provider nach Coworkee:Database:Provider (siehe Datenbank-Provider) und führt die Migrationshistorie in cw.__EFMigrationsHistory.
Handler injizieren CoworkeeDbContext und arbeiten mit db.Set<T>(). Repositories gibt es nicht; EF Core ist bereits eines.
Datenbank-Provider¶
Coworkee:Database:Provider |
EF-Core-Provider | Aspire-AppHost | Passt für |
|---|---|---|---|
Postgres (Standard) |
Npgsql | AddCoworkeeApp startet einen Postgres-Container |
alles, Produktion |
SqlServer |
Microsoft.EntityFrameworkCore.SqlServer | options.UseSqlServer(), ein SQL-Server-Container |
Organisationen mit SQL Server oder Azure SQL |
Sqlite |
Microsoft.EntityFrameworkCore.Sqlite | options.UseSqlite(), die Datei .data/<name>.db |
Demos, Prototypen, ein Rechner |
Der AppHost setzt die Einstellung bei jedem Dienst, neben dem Connection-String. Anderswo (Tests, eigenes Hosting) setzt du sie wie jeden anderen Konfigurationswert. coworkee new --database sqlServer schreibt den Aufruf im AppHost und legt die erste Migration für diesen Provider an.
Die providerspezifischen Teile liegen hinter kleinen Nahtstellen in Coworkee.Infrastructure, in der App ist nichts zu tun:
| Teil | Postgres | SQL Server | SQLite |
|---|---|---|---|
IHasConcurrencyToken.Version |
xmin |
Spalte, bei jedem Update hochgezählt | Spalte, bei jedem Update hochgezählt |
HasColumnType("jsonb") |
jsonb |
nvarchar(max) |
TEXT |
| Outbox-Stapel | FOR UPDATE SKIP LOCKED |
UPDLOCK, READPAST |
einfache Abfrage, keine Zeilensperren |
IDistributedLock |
Advisory Lock | sp_getapplock |
Dateisperre im Temp-Ordner (ein Rechner) |
| Hintergrundjobs | Hangfire.PostgreSql | Hangfire.SqlServer | Hangfire.InMemory, jeder Prozess hat seinen eigenen Job-Server |
| Textvergleich | Groß-/Kleinschreibung zählt | Groß-/Kleinschreibung zählt (Latin1_General_100_CS_AS auf jeder Textspalte) |
Groß-/Kleinschreibung zählt (=) |
Grenzen, die man kennen sollte:
- Migrationen gehören zu einem Provider. Eine App behält die Migrationen des Providers, mit dem sie angelegt wurde. Zum Wechseln löschst du
Migrationsund führstcoworkee migrations add Initialerneut aus. Soll eine App mehrere Provider bedienen, folge der EF-Core-Anleitung Migrations with multiple providers mit einer Migrations-Assembly pro Provider. - Datenbanksicherungen brauchen Postgres. Das Template lässt das Modul bei den anderen Providern weg.
- SQLite kennt nur einen Schreiber zur Zeit, hält Hintergrundjobs im Speicher (eingereihte und verzögerte Jobs gehen beim Neustart verloren), speichert
DateTimeOffsetals Zahl unddecimalalsdouble, prüftHasMaxLengthnicht und kennt keine Schemas (Tabellen verlieren ihrcw.). Es ist für Entwicklung und kleine Installationen auf einem Rechner gedacht. - Facetten über
bool-Spalten ([ProvideFacet]aufIsActive) scheitern auf SQL Server und SQLite, die den Wert als1/0zurückgeben; behoben im nächsten Release von Nextended. - MySQL und MariaDB gibt es nicht: Pomelo, der übliche Provider, hat noch kein Release für EF Core 10.
Basisklassen und Marker¶
| Typ | Liefert |
|---|---|
Entity |
Id, eine GUID Version 7, beim Erzeugen gesetzt |
AuditedEntity / AuditedAggregateRoot |
CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, beim Speichern gefüllt |
AggregateRoot |
Raise(IDomainEvent) für die Outbox |
IMultiTenant |
Abfragefilter auf den aktuellen Mandanten, TenantId beim Anlegen gesetzt |
ISoftDelete |
Remove markiert die Zeile als gelöscht; Abfragen blenden sie aus |
IHasConcurrencyToken |
optimistische Nebenläufigkeit über eine Version-Spalte |
IVersioned |
hält pro Revision einen Schnappschuss für die Versionshistorie |
[Realtime(permission)] |
meldet Anlegen, Ändern und Löschen an SignalR-Clients mit der Berechtigung |
[NotAudited], [Sensitive] |
nimmt einen Typ oder ein Feld aus dem Audit oder protokolliert ein Feld maskiert |
Audit¶
Jede Änderung an einer auditierten Entity wird Feld für Feld mit Benutzer, Zeitpunkt sowie altem und neuem Wert gespeichert. Die Admin-Oberfläche zeigt das pro Entity (AuditTimeline) und gesamt (Seite Audit-Log).
Mandanten und aktueller Benutzer¶
ICurrentUser trägt Benutzer-ID, Mandanten-ID und Berechtigungen des Requests. Hintergrundjobs und die Outbox stellen den Benutzer wieder her, der sie gestartet hat. Wer als jemand anderes handeln muss, etwa in einem Seed, öffnet einen Scope:
using (CurrentUserScope.Begin(new ImpersonatedUser(adminId, tenantId)))
{
// Abfragen und Anlegen laufen als dieser Benutzer und Mandant
}
Externe Datenbanken¶
Eine App kann auch eine Datenbank lesen und schreiben, die ihr nicht gehört, etwa die Datenbank eines anderen Teams mit eigenem Schema. Sie wird in einem einfachen DbContext abgebildet (nicht CoworkeeDbContext: der brächte die Tabellen und Konventionen der Bibliothek mit), wie üblich registriert und Coworkee bekannt gemacht:
services.AddDbContextPool<HubDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("Hub")));
services.AddCoworkeeExternalDbContext<HubDbContext>();
services.AddODataEntity<Shipment>("Shipments", ShippingPermissions.Shipments.View);
services.AddScoped<IODataEntityFilter<Shipment>, ShipmentsOfMyCustomers>();
- OData-Sets, Zeilenfilter, globale Suche, Export, Berichte und Client-Entities lesen jede Entity aus dem Context, der sie abbildet.
- Handler lassen sich den externen Context geben und ändern ihn; die Unit of Work speichert zuerst die externen Contexts mit Änderungen, dann den Context der App. Eine Transaktion über beide Datenbanken gibt es nicht: scheitert der Context der App, bleiben die externen Änderungen gespeichert.
- Migrationen, Audit, Verlauf, Soft Delete, Mandantenfilter,
[Realtime], Webhooks, Workflows und die Outbox bleiben beim Context der App. Zeilen eines externen Contexts haben keinen Mandanten, also entscheidet ein Zeilenfilter, wer was sieht, und eine externe Entity braucht ihr OData-Set, bevor Anhänge oder Kommentare ihre Zeilen öffnen dürfen. - Eine Abfrage kann nicht zwei Datenbanken lesen. Ein Zeilenfilter auf einem externen Set, der von Daten der App abhängt (Gruppen, Kunden), lädt diese IDs zuerst und übergibt sie als Liste:
var codes = await db.Set<OrgCode>().Where(...).Select(c => c.Code).ToListAsync(); return query.Where(s => EF.Parameter(codes).Contains(s.OrgCode));. Kind-Filter (AddODataChildFilter) brauchen Kind und Eltern in derselben Datenbank, und berechnete OData-Eigenschaften mitComputedQueryRootfunktionieren nur auf dem Context der App; berechnete Eigenschaften über die eigenen Spalten der Zeile funktionieren auch auf externen Sets. - Tags an externen Datensätzen vergleichen deren IDs als eine Liste, und Anhänge externer Datensätze werden nicht aufgeräumt, wenn der Datensatz verschwindet.
- Jedes Speichern erzeugt die registrierten externen Contexts, um nach Änderungen zu sehen; registriere also nur Contexts, die die App über Coworkee schreibt oder liest.
- Client-Entities brauchen die Datumsspalten der externen Tabelle (
ChangedAtin den Einstellungen); ohne sie lädt ein Set bei jeder Aktualisierung vollständig.Overlaperhöhen, wenn ein anderes System die Daten mit eigener Uhr schreibt oder lange nach dem Stempeln committet.
Migrationen und Seed¶
Migrationen liegen im Infrastruktur-Projekt und sind für dessen Datenbank-Provider erstellt:
Ein kleiner Host spielt sie ein. Der Migrations-Dienst des Templates seedet außerdem:
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddCoworkeeModules<MyAppDatabaseModule>(builder.Configuration);
builder.AddCoworkeeMigrations<MyAppDbContext>(options =>
{
options.DevelopmentSeed = DemoSeed.Configure;
options.DeploymentSeed = DemoSeed.ConfigureDeployment;
});
builder.Services.AddScoped<ISetupStep, DemoData>();
using var host = builder.Build();
return await host.RunCoworkeeMigrationsAsync();
AddCoworkeeMigrations (Coworkee.Identity.Setup) plant den Lauf und RunCoworkeeMigrationsAsync führt ihn aus, mit Exit-Code 0 oder 1:
- die Datenbank migrieren, alle 3 Sekunden erneut versucht, solange ihr Server noch keine Verbindungen annimmt (
Migrations:Attempts, Standard 20, oderoptions.Attempts); - seeden: mit einem ersten Administrator aus den Deploy-Parametern (
Coworkee:InitialAdmin, siehe Setup-Assistent oder Seed)DeploymentSeedmit ihm; sonstDevelopmentSeedin der Development-Umgebung (options.DevelopmentUsersentscheidet anders), danachSyncDevelopmentUsersAsync; sonst nichts, und die Setup-Seite mit ihrem Token richtet das System ein; - ein deploytes Keycloak abgleichen (
ReconcileKeycloakRealmAsync).
Außerdem baut es den Container mit den Produktions-Standards: Ein lokaler Lauf ist Development, und dessen Prüfung der Scopes scheitert an den MVC-Diensten von OData in einem Host ohne Web-Umgebung.
AddCoworkeeIdentitySeed ersetzt den Setup-Assistenten: Er legt einmalig den Standard-Mandanten, den Administrator, Rollen mit ihren Berechtigungen, weitere Benutzer und systemweite Einstellungen (seed.Settings["Theme.Mode"] = "dark") an. ISetupStep-Implementierungen laufen im selben Setup als Administrator und sind der Ort für Beispieldaten. SyncDevelopmentUsersAsync läuft nur in der Development-Umgebung (oder mit Coworkee:Identity:SyncDevelopmentUsers, das der Aspire-App-Host für die Migrationen eines lokalen Laufs setzt) und hält eine bestehende Datenbank im Stand des Seeds: Fehlende Benutzer und Rollen kommen dazu, Seed-Benutzer bekommen die seitdem im Seed ergänzten Rollen, Seed-Rollen die ergänzten Berechtigungen, Seed-Benutzer mit anderem Passwort bekommen wieder das geseedete; andere Benutzer, Rollen und Berechtigungen bleiben, wie sie sind, entfernt wird nichts. Siehe Anmeldung.