Die Prüfung von Dodo basiert nicht auf Papier: Der Prüfer verfügt über ein Tablet, auf dem der Prüfer alle Produkte notiert und Berichte erstellt. Bis 2020 wurde die Überarbeitung der Pizzerien jedoch genau auf Papier durchgeführt - einfach weil es auf diese Weise immer einfacher wurde. Dies führte natürlich zu ungenauen Daten, Fehlern und Verlusten - Menschen machen Fehler, Papierstücke gehen verloren und es gibt noch viel mehr. Wir haben uns entschlossen, dieses Problem zu beheben und den Tablet-Weg zu verbessern. Die Implementierung entschied sich für DDD. Wie wir es gemacht haben, werden wir Ihnen weiter erzählen.
Zunächst kurz über Geschäftsprozesse, um den Kontext zu verstehen. Betrachten wir das Schema der Bewegung von Produkten und wo sind die Überarbeitungen darin, und gehen wir dann zu den technischen Details über, von denen es viele geben wird.
Schema der Bewegung von Produkten und warum eine Überarbeitung erforderlich ist
Es gibt mehr als 600 Pizzerien in unserem Netzwerk (und diese Zahl wird weiter wachsen). In jedem von ihnen gibt es eine tägliche Bewegung von Rohstoffen: vom Kochen und Verkaufen von Produkten über das Abschreiben von Zutaten bis zum Verfallsdatum bis hin zum Transport von Rohstoffen in andere Pizzerien der Kette. Der Rest der Pizzeria enthält ständig etwa 120 Artikel, die für die Herstellung von Produkten erforderlich sind, sowie viele Verbrauchsmaterialien, Haushaltsmaterialien und Chemikalien, um die Pizzeria sauber zu halten. All dies erfordert eine "Buchhaltung", um zu wissen, welche Rohstoffe im Überfluss vorhanden sind und welche fehlen.
"Buchhaltung" beschreibt jede Bewegung von Rohstoffen in Pizzerien. Die Lieferung ist ein Plus in der Bilanz und die Abschreibung ein Minus. Wenn wir beispielsweise eine Pizza bestellen, nimmt der Kassierer die Bestellung an und sendet sie zur Bearbeitung. Der Teig wird dann ausgerollt und mit Zutaten wie Käse, Tomatensauce und Peperoni gefüllt. Alle diese Produkte gehen in Produktion - werden abgeschrieben. Eine Abschreibung kann auch erfolgen, wenn das Ablaufdatum endet.
Durch Lieferungen und Abschreibungen entstehen "Lagerbestände". Dies ist ein Bericht, der angibt, wie viel Rohstoffe in der Bilanz enthalten sind, basierend auf den Vorgängen im Informationssystem. All dies ist die "Abrechnungsbilanz". Aber es gibt einen "tatsächlichen Wert" - wie viel Rohmaterial ist derzeit tatsächlich auf Lager.
Überarbeitungen
Zur Berechnung des Istwertes werden "Revisionen" verwendet (sie werden auch als "Vorräte" bezeichnet).
Audits helfen dabei, die Menge der Rohstoffe für Einkäufe genau zu berechnen. Zu viele Käufe frieren das Betriebskapital ein und das Risiko, überschüssige Produkte abzuschreiben, steigt, was ebenfalls zu Verlusten führt. Nicht nur ein Überschuss an Rohstoffen ist gefährlich, sondern auch ein Mangel - dies kann zu einem Produktionsstillstand einiger Produkte führen, was zu einem Umsatzrückgang führen wird. Mithilfe von Audits können Sie feststellen, wie viel Gewinn ein Unternehmen aufgrund von bilanzierten und nicht bilanzierten Rohstoffverlusten erzielt, und die Kosten senken.
Revisionen teilen ihre Daten unter gebührender Berücksichtigung der weiteren Verarbeitung, z. B. der Erstellung von Berichten.
Probleme im Revisionsprozess oder Funktionsweise alter Revisionen
Überarbeitungen sind ein mühsamer Prozess. Es nimmt viel Zeit in Anspruch und besteht aus mehreren Schritten: Zählen und Fixieren der Rohstoffreste, Zusammenfassen der Rohstoffergebnisse nach Lagerbereichen und Eingeben der Ergebnisse in das Dodo IS-Informationssystem.
Zuvor wurden Audits mit einem Stift- und Papierformular durchgeführt, auf dem sich eine Liste der Rohstoffe befand. Beim manuellen Zusammenfassen, Abgleichen und Übertragen von Ergebnissen an Dodo IS besteht die Möglichkeit, dass ein Fehler gemacht wird. Bei einem vollständigen Audit werden mehr als 100 Rohstoffe gezählt, und die Berechnung selbst wird häufig am späten Abend oder am frühen Morgen durchgeführt, unter denen die Konzentration leiden kann.
So lösen Sie das Problem
Unser Game of Threads-Team entwickelt die Buchhaltung in Pizzerien. Wir haben beschlossen, ein Projekt namens „Auditor's Tablet“ zu starten, das die Prüfung von Pizzerien vereinfacht. Wir haben uns entschlossen, alles in unserem eigenen Informationssystem Dodo IS zu tun, in dem die Hauptkomponenten für das Rechnungswesen implementiert sind, sodass wir keine Integration in Systeme von Drittanbietern benötigen. Darüber hinaus können alle Länder unserer Präsenz das Tool verwenden, ohne auf zusätzliche Integrationen zurückgreifen zu müssen.
Noch bevor wir mit der Arbeit an dem Projekt begannen, diskutierten wir im Team den Wunsch, DDD in der Praxis anzuwenden. Glücklicherweise hat eines der Projekte diesen Ansatz bereits erfolgreich angewendet. Wir hatten also ein Beispiel, das Sie sich ansehen können - dies ist das „ Cash Desk “ -Projekt.
In diesem Artikel werde ich über die taktischen DDD-Muster sprechen, die wir in der Entwicklung verwendet haben: Aggregate, Befehle, Domänenereignisse, Anwendungsservice und Integration begrenzter Kontexte. Wir werden die strategischen Muster und Grundlagen von DDD nicht beschreiben, da der Artikel sonst sehr lang sein wird. Darüber haben wir bereits im Artikel „ Was können Sie in 10 Minuten über Domain Driven Design lernen? ""
Neue Version der Revisionen
Bevor Sie mit dem Audit beginnen, müssen Sie wissen, was genau zu zählen ist. Dafür benötigen wir Revisionsvorlagen . Sie werden von der Rolle "Büroleiter" konfiguriert. Die Revisionsvorlage ist eine InventoryTemplate-Entität. Es enthält die folgenden Felder:
- Vorlagenkennung;
- Pizzeria ID;
- Vorlagenname;
- Revisionskategorie: monatlich, wöchentlich, täglich;
- Einheiten;
- Lagerbereiche und Rohstoffe in diesem Lagerbereich
Für diese Entität wurde eine CRUD-Funktionalität implementiert, auf die wir nicht näher eingehen werden.
Sobald der Prüfer eine Liste mit Vorlagen hat, kann er die Prüfung starten . Dies geschieht normalerweise, wenn die Pizzeria geschlossen ist. Derzeit gibt es keine Bestellungen und die Rohstoffe bewegen sich nicht - Sie können zuverlässig Daten zu Salden abrufen.
Zu Beginn des Audits wählt der Auditor eine Zone aus, z. B. einen Kühlschrank, und zählt dort die Rohstoffe. Im Kühlschrank sieht er 5 Packungen Käse, jeweils 10 kg, gibt 10 kg * 5 in den Taschenrechner ein und drückt "Enter more". Dann bemerkt er 2 weitere Packungen im obersten Regal und klickt auf "Hinzufügen". Als Ergebnis hat er 2 Messungen - jeweils 50 und 20 kg.
MessungWir nennen die vom Inspektor eingegebene Rohstoffmenge in einem bestimmten Bereich, aber nicht unbedingt die Gesamtmenge. Der Inspektor kann zwei Messungen von einem Kilogramm oder nur zwei Kilogramm in einer Messung eingeben - jede Kombination kann sein. Die Hauptsache ist, dass der Auditor selbst klar sein sollte.
Taschenrechner-Schnittstelle.
Schritt für Schritt prüft der Prüfer alle Rohstoffe in 1-2 Stunden und schließt dann das Audit ab.
Der Aktionsalgorithmus ist recht einfach:
- Der Abschlussprüfer kann die Prüfung beginnen.
- Der Prüfer kann der gestarteten Revision Messungen hinzufügen.
- Der Abschlussprüfer kann die Prüfung abschließen.
Aus diesem Algorithmus werden Geschäftsanforderungen für das System gebildet.
Implementierung der ersten Version des Aggregats, der Befehle und Ereignisse der Domäne
Definieren wir zunächst die Begriffe, die im taktischen DDD-Vorlagensatz enthalten sind. Wir werden in diesem Artikel darauf verweisen.
Taktische DDD-Vorlagen
Aggregat ist ein Cluster von Entitäts- und Wertobjekten. Objekte in einem Cluster sind hinsichtlich der Datenänderung eine Einheit. Jedes Aggregat verfügt über ein Stammelement, über das auf Entitäten und Werte zugegriffen wird. Einheiten sollten nicht zu groß ausgelegt werden. Sie verbrauchen viel Speicher und die Wahrscheinlichkeit eines erfolgreichen Abschlusses der Transaktion nimmt ab.
Die aggregierte Grenze ist eine Reihe von Objekten, die innerhalb einer einzelnen Transaktion konsistent sein müssen: Alle Invarianten innerhalb dieses Clusters müssen beobachtet werden.
Invarianten sind Geschäftsregeln, die nicht inkonsistent sein können.
BefehlIst eine Art Aktion auf dem Gerät. Als Ergebnis dieser Aktion kann der Status des Aggregats geändert und ein oder mehrere Domänenereignisse generiert werden.
Ein Domänenereignis ist eine Benachrichtigung über eine Änderung des Status eines Aggregats, die zur Aufrechterhaltung der Konsistenz erforderlich ist. Das Aggregat stellt die Transaktionskonsistenz sicher: Alle Daten müssen hier und jetzt geändert werden. Die daraus resultierende Konsistenz garantiert auf lange Sicht Konsistenz - die Daten ändern sich, jedoch nicht hier und jetzt, sondern nach unbestimmter Zeit. Dieses Intervall hängt von vielen Faktoren ab: der Überlastung der Nachrichtenwarteschlangen, der Bereitschaft externer Dienste, diese Nachrichten zu verarbeiten, dem Netzwerk.
WurzelelementIst eine Entität mit einer eindeutigen globalen Kennung. Untergeordnete Elemente können nur innerhalb eines gesamten Aggregats eine lokale Identität haben. Sie können aufeinander verweisen und nur auf ihr Wurzelelement verweisen.
Teams und Events
Beschreiben wir die Geschäftsanforderungen als Team. Befehle sind nur DTOs mit beschreibenden Feldern.
Der Befehl "Messung hinzufügen" enthält die folgenden Felder:
- Messwert - Die Menge der Rohstoffe in einer bestimmten Maßeinheit kann Null sein, wenn die Messung gelöscht wurde.
- version - Die Messung kann bearbeitet werden, daher wird eine Version benötigt.
- Rohstoffkennung;
- Maßeinheit: kg / g, l / ml, Stücke;
- Speicherbereichskennung.
Messung mit Befehlscode
public sealed class AddMeasurementCommand
{
// ctor
public double? Value { get; }
public int Version { get; }
public UUId MaterialTypeId { get; }
public UUId MeasurementId { get; }
public UnitOfMeasure UnitOfMeasure { get; }
public UUId InventoryZoneId { get; }
}
Wir brauchen auch ein Ereignis, das sich aus der Ausführung dieser Befehle ergibt. Wir kennzeichnen die Veranstaltung mit einer Schnittstelle
IPublicInventoryEvent- wir werden sie in Zukunft für die Integration mit externen Verbrauchern benötigen.
Im Fall "Messung" sind die Felder dieselben wie im Befehl "Messung hinzufügen", außer dass das Ereignis auch die Kennung der Einheit, auf der es aufgetreten ist, und deren Version speichert.
Ereigniscode "eingefroren"
public class MeasurementEvent : IPublicInventoryEvent
{
public UUId MaterialTypeId { get; set; }
public double? Value { get; set; }
public UUId MeasurementId { get; set; }
public int MeasurementVersion { get; set; }
public UUId AggregateId { get; set; }
public int Version { get; set; }
public UnitOfMeasure UnitOfMeasure { get; set; }
public UUId InventoryZoneId { get; set; }
}
Wenn wir Befehle und Ereignisse beschrieben haben, können wir das Aggregat implementieren
Inventory.
Implementieren des Inventaraggregats
UML-Aggregatdiagramm Inventar.
Der Ansatz ist folgender: Der Beginn der Revision initiiert die Erstellung des Aggregats
Inventory. Dazu verwenden wir die Factory-Methode Createund starten die Revision mit dem Befehl StartInventoryCommand.
Jeder Befehl ändert den Status des Aggregats und speichert Ereignisse in der Liste
changes, die zur Aufzeichnung an den Speicher gesendet werden. Basierend auf diesen Änderungen werden auch Ereignisse für die Außenwelt generiert.
Wenn das Aggregat
Inventoryerstellt wurde, können wir es für jede nachfolgende Anforderung wiederherstellen, um seinen Status zu ändern.
- Änderungen (
changes) werden seit der letzten Wiederherstellung des Geräts gespeichert.
- Der Status wird durch eine Methode wiederhergestellt
Restore, die alle vorherigen Ereignisse, sortiert nach Version, auf der aktuellen Instanz des Aggregats wiedergibtInventory.
Dies ist die Umsetzung der Idee
Event Sourcinginnerhalb der Einheit. Wir werden Event Sourcingetwas später darüber sprechen, wie die Idee im Rahmen des Repositorys umgesetzt werden kann. Es gibt eine schöne Illustration aus Vaughn Vernons Buch: Der
Zustand der Einheit wird wiederhergestellt, indem Ereignisse in der Reihenfolge angewendet werden, in der sie auftreten.
Dann finden mehrere Messungen durch das Team statt
AddMeasurementCommand. Das Audit endet mit einem Befehl FinishInventoryCommand. Das Aggregat validiert seinen Zustand in Mutationsmethoden, um seinen Invarianten zu entsprechen.
Es ist wichtig zu beachten, dass das Gerät
Inventorysowie jede Messung vollständig versioniert ist. Bei Messungen ist es schwieriger - Sie müssen Konflikte in der Ereignisbehandlungsmethode lösen When(MeasurementEvent e). Im Code werde ich nur die Verarbeitung des Befehls zeigen AddMeasurementCommand.
Aggregierter Inventarcode
public sealed class Inventory : IEquatable<Inventory>
{
private readonly List<IInventoryEvent> _changes = new List<IInventoryEvent>();
private readonly List<InventoryMeasurement> _inventoryMeasurements = new List<InventoryMeasurement>();
internal Inventory(UUId id, int version, UUId unitId, UUId inventoryTemplateId,
UUId startedBy, InventoryState state, DateTime startedAtUtc, DateTime? finishedAtUtc)
: this(id)
{
Version = version;
UnitId = unitId;
InventoryTemplateId = inventoryTemplateId;
StartedBy = startedBy;
State = state;
StartedAtUtc = startedAtUtc;
FinishedAtUtc = finishedAtUtc;
}
private Inventory(UUId id)
{
Id = id;
Version = 0;
State = InventoryState.Unknown;
}
public UUId Id { get; private set; }
public int Version { get; private set; }
public UUId UnitId { get; private set; }
public UUId InventoryTemplateId { get; private set; }
public UUId StartedBy { get; private set; }
public InventoryState State { get; private set; }
public DateTime StartedAtUtc { get; private set; }
public DateTime? FinishedAtUtc { get; private set; }
public ReadOnlyCollection<IInventoryEvent> Changes => _changes.AsReadOnly();
public ReadOnlyCollection<InventoryMeasurement> Measurements => _inventoryMeasurements.AsReadOnly();
public static Inventory Restore(UUId inventoryId, IInventoryEvent[] events)
{
var inventory = new Inventory(inventoryId);
inventory.ReplayEvents(events);
return inventory;
}
public static Inventory Restore(UUId id, int version, UUId unitId, UUId inventoryTemplateId,
UUId startedBy, InventoryState state, DateTime startedAtUtc, DateTime? finishedAtUtc,
InventoryMeasurement[] measurements)
{
var inventory = new Inventory(id, version, unitId, inventoryTemplateId,
startedBy, state, startedAtUtc, finishedAtUtc);
inventory._inventoryMeasurements.AddRange(measurements);
return inventory;
}
public static Inventory Create(UUId inventoryId)
{
if (inventoryId == null)
{
throw new ArgumentNullException(nameof(inventoryId));
}
return new Inventory(inventoryId);
}
public void ReplayEvents(params IInventoryEvent[] events)
{
if (events == null)
{
throw new ArgumentNullException(nameof(events));
}
foreach (var @event in events.OrderBy(e => e.Version))
{
Mutate(@event);
}
}
public void AddMeasurement(AddMeasurementCommand command)
{
if (command == null)
{
throw new ArgumentNullException(nameof(command));
}
Apply(new MeasurementEvent
{
AggregateId = Id,
Version = Version + 1,
UnitId = UnitId,
Value = command.Value,
MeasurementVersion = command.Version,
MaterialTypeId = command.MaterialTypeId,
MeasurementId = command.MeasurementId,
UnitOfMeasure = command.UnitOfMeasure,
InventoryZoneId = command.InventoryZoneId
});
}
private void Apply(IInventoryEvent @event)
{
Mutate(@event);
_changes.Add(@event);
}
private void Mutate(IInventoryEvent @event)
{
When((dynamic) @event);
Version = @event.Version;
}
private void When(MeasurementEvent e)
{
var existMeasurement = _inventoryMeasurements.SingleOrDefault(x => x.MeasurementId == e.MeasurementId);
if (existMeasurement is null)
{
_inventoryMeasurements.Add(new InventoryMeasurement
{
Value = e.Value,
MeasurementId = e.MeasurementId,
MeasurementVersion = e.MeasurementVersion,
PreviousValue = e.PreviousValue,
MaterialTypeId = e.MaterialTypeId,
UserId = e.By,
UnitOfMeasure = e.UnitOfMeasure,
InventoryZoneId = e.InventoryZoneId
});
}
else
{
if (!existMeasurement.Value.HasValue)
{
throw new InventoryInvalidStateException("Change removed measurement");
}
if (existMeasurement.MeasurementVersion == e.MeasurementVersion - 1)
{
existMeasurement.Value = e.Value;
existMeasurement.MeasurementVersion = e.MeasurementVersion;
existMeasurement.UnitOfMeasure = e.UnitOfMeasure;
existMeasurement.InventoryZoneId = e.InventoryZoneId;
}
else if (existMeasurement.MeasurementVersion < e.MeasurementVersion)
{
throw new MeasurementConcurrencyException(Id, e.MeasurementId, e.Value);
}
else if (existMeasurement.MeasurementVersion == e.MeasurementVersion &&
existMeasurement.Value != e.Value)
{
throw new MeasurementConcurrencyException(Id, e.MeasurementId, e.Value);
}
else
{
throw new NotChangeException();
}
}
}
// Equals
// GetHashCode
}
Wenn das Ereignis "Gemessen" auftritt, wird das Vorhandensein einer vorhandenen Messung mit dieser Kennung überprüft. Ist dies nicht der Fall, wird eine neue Messung hinzugefügt.
In diesem Fall sind zusätzliche Überprüfungen erforderlich:
- Sie können eine Fernmessung nicht bearbeiten.
- Die eingehende Version muss größer als die vorherige sein.
Wenn die Bedingungen erfüllt sind, können wir einen neuen Wert und eine neue Version für die vorhandene Messung festlegen. Wenn die Version kleiner ist, liegt ein Konflikt vor. Dafür werfen wir eine Ausnahme
MeasurementConcurrencyException. Wenn die Version übereinstimmt und die Werte unterschiedlich sind, ist dies auch eine Konfliktsituation. Wenn sowohl die Version als auch der Wert übereinstimmen, sind keine Änderungen aufgetreten. Solche Situationen treten normalerweise nicht auf.
Die Entität "Messung" enthält genau dieselben Felder wie der Befehl "Messung hinzufügen".
Entitätscode "eingefroren"
public class InventoryMeasurement
{
public UUId MeasurementId { get; set; }
public UUId MaterialTypeId { get; set; }
public UUId UserId { get; set; }
public double? Value { get; set; }
public int MeasurementVersion { get; set; }
public UnitOfMeasure UnitOfMeasure { get; set; }
public UUId InventoryZoneId { get; set; }
}
Die Verwendung öffentlicher Aggregatmethoden wird durch Unit-Tests gut demonstriert.
Unit-Test-Code "Hinzufügen einer Messung nach Beginn der Revision"
[Fact]
public void WhenAddMeasurementAfterStartInventory_ThenInventoryHaveOneMeasurement()
{
var inventoryId = UUId.NewUUId();
var inventory = Domain.Inventories.Entities.Inventory.Create(inventoryId);
var unitId = UUId.NewUUId();
inventory.StartInventory(Create.StartInventoryCommand()
.WithUnitId(unitId)
.Please());
var materialTypeId = UUId.NewUUId();
var measurementId = UUId.NewUUId();
var measurementVersion = 1;
var value = 500;
var cmd = Create.AddMeasurementCommand()
.WithMaterialTypeId(materialTypeId)
.WithMeasurement(measurementId, measurementVersion)
.WithValue(value)
.Please();
inventory.AddMeasurement(cmd);
inventory.Measurements.Should().BeEquivalentTo(new InventoryMeasurement
{
MaterialTypeId = materialTypeId,
MeasurementId = measurementId,
MeasurementVersion = measurementVersion,
Value = value,
UnitOfMeasure = UnitOfMeasure.Quantity
});
}
Alles zusammen: Befehle, Ereignisse, Inventaraggregat
Gesamtlebenszyklus des Inventars beim Ausführen von Inventar beenden.
Das Diagramm zeigt den Prozess der Befehlsverarbeitung
FinishInventoryCommand. Vor der Verarbeitung muss der Status der Einheit Inventoryzum Zeitpunkt der Befehlsausführung wiederhergestellt werden . Dazu laden wir alle Ereignisse, die auf diesem Gerät ausgeführt wurden, in den Speicher und spielen sie ab (S. 1).
Zum Zeitpunkt des Abschlusses der Überarbeitung haben wir bereits die folgenden Ereignisse - den Beginn der Überarbeitung und das Hinzufügen von drei Messungen. Diese Ereignisse sind als Ergebnis der Befehlsverarbeitung
StartInventoryCommandund AddMeasurementCommandentsprechend aufgetreten . In der Datenbank enthält jede Zeile in der Tabelle die Revisions-ID, die Version und den Hauptteil des Ereignisses.
In dieser Phase führen wir den Befehl aus
FinishInventoryCommand(S. 2). Dieser Befehl überprüft zunächst die Gültigkeit des aktuellen Status der Einheit - dass sich die Revision in einem Status befindet InProgress- und generiert dann eine neue Statusänderung, indem FinishInventoryEventder Liste ein Ereignis hinzugefügt wird changes(Element 3).
Wenn der Befehl abgeschlossen ist, werden alle Änderungen in der Datenbank gespeichert. Infolgedessen wird eine neue Zeile mit dem Ereignis
FinishInventoryEventund der neuesten Version des Geräts in der Datenbank angezeigt (S. 4).
Typ
Inventory(Revision) - Aggregat und Stammelement in Bezug auf ihre verschachtelten Entitäten. Somit Inventorydefiniert der Typ die Grenzen der Einheit. Die Aggregatgrenzen enthalten eine Liste von Entitäten vom Typ Measurement(Messung) und eine Liste aller Ereignisse, die für das Aggregat ( changes) ausgeführt werden.
Implementierung der gesamten Funktion
Mit Funktionen ist die Implementierung einer bestimmten Geschäftsanforderung gemeint. In unserem Beispiel betrachten wir die Funktion "Messung hinzufügen". Um die Funktion zu implementieren, müssen wir das Konzept des "Anwendungsdienstes" (
ApplicationService) verstehen .
Ein Anwendungsdienst ist ein direkter Client des Domänenmodells. Application Services garantieren Transaktionen bei Verwendung der ACID-Datenbank und stellen sicher, dass Statusübergänge atomar erhalten bleiben. Darüber hinaus berücksichtigen Anwendungsdienste auch Sicherheitsbedenken.
Wir haben bereits eine Einheit
Inventory... Um die gesamte Funktion zu implementieren, verwenden wir den Anwendungsdienst vollständig. Darin müssen Sie das Vorhandensein aller verbundenen Entitäten sowie die Zugriffsrechte des Benutzers überprüfen. Erst wenn alle Bedingungen erfüllt sind, ist es möglich, den aktuellen Status des Geräts zu speichern und Ereignisse an die Außenwelt zu senden. Um einen Anwendungsdienst zu implementieren, verwenden wir MediatR.
Funktionscode "Messung hinzufügen"
public class AddMeasurementChangeHandler
: IRequestHandler<AddMeasurementChangeRequest, AddMeasurementChangeResponse>
{
// dependencies
// ctor
public async Task<AddMeasurementChangeResponse> Handle(
AddMeasurementChangeRequest request,
CancellationToken ct)
{
var inventory =
await _inventoryRepository.GetAsync(request.AddMeasurementChange.InventoryId, ct);
if (inventory == null)
{
throw new NotFoundException($"Inventory {request.AddMeasurementChange.InventoryId} is not found");
}
var user = await _usersRepository.GetAsync(request.UserId, ct);
if (user == null)
{
throw new SecurityException();
}
var hasPermissions =
await _authPermissionService.HasPermissionsAsync(request.CountryId, request.Token, inventory.UnitId, ct);
if (!hasPermissions)
{
throw new SecurityException();
}
var unit = await _unitRepository.GetAsync(inventory.UnitId, ct);
if (unit == null)
{
throw new InvalidRequestDataException($"Unit {inventory.UnitId} is not found");
}
var unitOfMeasure =
Enum.Parse<UnitOfMeasure>(request.AddMeasurementChange.MaterialTypeUnitOfMeasure);
var addMeasurementCommand = new AddMeasurementCommand(
request.AddMeasurementChange.Value,
request.AddMeasurementChange.Version,
request.AddMeasurementChange.MaterialTypeId,
request.AddMeasurementChange.Id,
unitOfMeasure,
request.AddMeasurementChange.InventoryZoneId);
inventory.AddMeasurement(addMeasurementCommand);
await HandleAsync(inventory, ct);
return new AddMeasurementChangeResponse(request.AddMeasurementChange.Id, user.Id, user.GetName());
}
private async Task HandleAsync(Domain.Inventories.Entities.Inventory inventory, CancellationToken ct)
{
await _inventoryRepository.AppendEventsAsync(inventory.Changes, ct);
try
{
await _localQueueDataService.Publish(inventory.Changes, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "error occured while handling action");
}
}
}
Event-Sourcing
Während der Implementierung haben wir uns aus mehreren Gründen für den ES-Ansatz entschieden:
- Dodo hat Beispiele für die erfolgreiche Anwendung dieses Ansatzes.
- ES erleichtert das Verständnis des Problems während eines Vorfalls - alle Benutzeraktionen werden gespeichert.
- Wenn Sie den traditionellen Ansatz wählen, können Sie nicht zu ES wechseln.
Die Idee der Implementierung ist recht einfach: Wir fügen der Datenbank alle neuen Ereignisse hinzu, die aufgrund von Befehlen aufgetreten sind. Um das Aggregat wiederherzustellen, erhalten wir alle Ereignisse und spielen sie auf der Instanz ab. Um nicht jedes Mal eine große Anzahl von Ereignissen zu erhalten, entfernen wir die Zustände aller N Ereignisse und spielen den Rest dieses Schnappschusses ab.
Inventaraggregat-Geschäfts-ID
internal sealed class InventoryRepository : IInventoryRepository
{
// dependencies
// ctor
static InventoryRepository()
{
EventTypes = typeof(IEvent)
.Assembly.GetTypes().Where(x => typeof(IEvent).IsAssignableFrom(x))
.ToDictionary(t => t.FullName, x => x);
}
public async Task AppendAsync(IReadOnlyCollection<IEvent> events, CancellationToken ct)
{
using (var session = await _dbSessionFactory.OpenAsync())
{
if (events.Count == 0) return;
try
{
foreach (var @event in events)
{
await session.ExecuteAsync(Sql.AppendEvent,
new
{
@event.AggregateId,
@event.Version,
@event.UnitId,
Type = @event.GetType().FullName,
Data = JsonConvert.SerializeObject(@event),
CreatedDateTimeUtc = DateTime.UtcNow
}, cancellationToken: ct);
}
}
catch (MySqlException e)
when (e.Number == (int) MySqlErrorCode.DuplicateKeyEntry)
{
throw new OptimisticConcurrencyException(events.First().AggregateId, "");
}
}
}
public async Task<Domain.Models.Inventory> GetInventoryAsync(
UUId inventoryId,
CancellationToken ct)
{
var events = await GetEventsAsync(inventoryId, 0, ct);
if (events.Any()) return Domain.Models.Inventory.Restore(inventoryId, events);
return null;
}
private async Task<IEvent[]> GetEventsAsync(
UUId id,
int snapshotVersion,
CancellationToken ct)
{
using (var session = await _dbSessionFactory.OpenAsync())
{
var snapshot = await GetInventorySnapshotAsync(session, inventoryId, ct);
var version = snapshot?.Version ?? 0;
var events = await GetEventsAsync(session, inventoryId, version, ct);
if (snapshot != null)
{
snapshot.ReplayEvents(events);
return snapshot;
}
if (events.Any())
{
return Domain.Inventories.Entities.Inventory.Restore(inventoryId, events);
}
return null;
}
}
private async Task<Inventory> GetInventorySnapshotAsync(
IDbSession session,
UUId id,
CancellationToken ct)
{
var record =
await session.QueryFirstOrDefaultAsync<InventoryRecord>(Sql.GetSnapshot, new {AggregateId = id},
cancellationToken: ct);
return record == null ? null : Map(record);
}
private async Task<IInventoryEvent[]> GetEventsAsync(
IDbSession session,
UUId id,
int snapshotVersion,
CancellationToken ct)
{
var rows = await session.QueryAsync<EventRecord>(Sql.GetEvents,
new
{
AggregateId = id,
Version = snapshotVersion
}, cancellationToken: ct);
return rows.Select(Map).ToArray();
}
private static IEvent Map(EventRecord e)
{
var type = EventTypes[e.Type];
return (IEvent) JsonConvert.DeserializeObject(e.Data, type);
}
}
internal class EventRecord
{
public string Type { get; set; }
public string Data { get; set; }
}
Nach mehreren Monaten Betrieb haben wir festgestellt, dass nicht alle Benutzeraktionen auf der Einheiteninstanz gespeichert werden müssen. Das Unternehmen verwendet diese Informationen in keiner Weise. Abgesehen davon ist die Aufrechterhaltung dieses Ansatzes mit einem Aufwand verbunden. Nachdem wir alle Vor- und Nachteile bewertet haben, planen wir, von ES zum traditionellen Ansatz überzugehen - das Zeichen
Eventsdurch Inventoriesund zu ersetzen Measurements.
Integration in externe begrenzte Kontexte
Dies ist das Schema der Interaktion eines begrenzten Kontexts
Inventorymit der Außenwelt.
Interaktion des Revisionskontexts mit anderen Kontexten. Das Diagramm zeigt Kontexte, Dienste und deren Zugehörigkeit zueinander.
Im Fall von
Auth, Inventoryund Datacataloggibt es einen beschränkten Kontext für jeden Dienst. Der Monolith führt mehrere Funktionen aus, aber jetzt interessieren wir uns nur noch für die Abrechnungsfunktionen in Pizzerien. Das Rechnungswesen umfasst neben Revisionen auch die Bewegung von Rohstoffen in Pizzerien: Quittungen, Überweisungen, Abschreibungen.
HTTP
Der Dienst
Inventoryinteragiert Authüber HTTP. Zunächst wird der Benutzer konfrontiert Auth, was ihn dazu auffordert, eine der ihm zur Verfügung stehenden Rollen auszuwählen.
- Das System hat eine Rolle "Auditor", die der Benutzer während des Audits auswählt.
- .
- .
In der letzten Phase hat der Benutzer ein Token von
Auth. Der Revisionsdienst muss dieses Token überprüfen und fordert daher Autheine Überprüfung an. Authprüft, ob die Lebensdauer des Tokens abgelaufen ist, ob es dem Eigentümer gehört oder ob es über die erforderlichen Zugriffsrechte verfügt. Wenn alles Inventoryin Ordnung ist, werden die Stempel in den Cookies gespeichert - Benutzer-ID, Login, Pizzeria-ID und die Lebensdauer der Cookies.
Hinweis .
AuthWie der Dienst funktioniert, haben wir im Artikel " Feinheiten der Autorisierung: Ein Überblick über die OAuth 2.0-Technologie " ausführlicher beschrieben .
Es
Inventoryinteragiert mit anderen Diensten über Nachrichtenwarteschlangen. Das Unternehmen verwendet RabbitMQ als Nachrichtenbroker sowie die darüber liegende Bindung - MassTransit.
RMQ: Ereignisse konsumieren
Der Verzeichnisdienst -
Datacatalog- stellt Inventoryalle erforderlichen Einheiten bereit : Rohstoffe für das Rechnungswesen, Länder, Abteilungen und Pizzerien.
Ohne auf Details der Infrastruktur einzugehen, werde ich die Grundidee des Konsumierens von Ereignissen beschreiben. Auf der Seite des Verzeichnisdienstes ist bereits alles für die Veröffentlichung von Ereignissen bereit. Schauen wir uns das Beispiel der Rohstoffentität an.
Datenkatalog-Ereignisvertragscode
namespace Dodo.DataCatalog.Contracts.Products.v1
{
public class MaterialType
{
public UUId Id { get; set; }
public int Version { get; set; }
public int CountryId { get; set; }
public UUId DepartmentId { get; set; }
public string Name { get; set; }
public MaterialCategory Category { get; set; }
public UnitOfMeasure BasicUnitOfMeasure { get; set; }
public bool IsRemoved { get; set; }
}
public enum UnitOfMeasure
{
Quantity = 1,
Gram = 5,
Milliliter = 7,
Meter = 8,
}
public enum MaterialCategory
{
Ingredient = 1,
SemiFinishedProduct = 2,
FinishedProduct = 3,
Inventory = 4,
Packaging = 5,
Consumables = 6
}
}
Dieser Beitrag wurde veröffentlicht in
exchange. Jeder Dienst kann ein eigenes Bundle erstellen exchange-queue, um Ereignisse zu nutzen.
Schema zum Veröffentlichen eines Ereignisses und seines Verbrauchs über die RMQ-Grundelemente.
Letztendlich gibt es eine Warteschlange für jede Entität, die der Dienst abonnieren kann. Sie müssen lediglich die neue Version in der Datenbank speichern.
Ereigniskonsumentencode aus dem Datenkatalog
public class MaterialTypeConsumer : IConsumer<Dodo.DataCatalog.Contracts.Products.v1.MaterialType>
{
private readonly IMaterialTypeRepository _materialTypeRepository;
public MaterialTypeConsumer(IMaterialTypeRepository materialTypeRepository)
{
_materialTypeRepository = materialTypeRepository;
}
public async Task Consume(ConsumeContext<Dodo.DataCatalog.Contracts.Products.v1.MaterialType> context)
{
var materialType = new AddMaterialType(context.Message.Id,
context.Message.Name,
(int)context.Message.Category,
(int)context.Message.BasicUnitOfMeasure,
context.Message.CountryId,
context.Message.DepartmentId,
context.Message.IsRemoved,
context.Message.Version);
await _materialTypeRepository.SaveAsync(materialType, context.CancellationToken);
}
}
RMQ: Veröffentlichen von Ereignissen
Der Buchhaltungsteil des Monolithen verwendet Daten
Inventory, um den Rest der Funktionalität zu unterstützen, für die Revisionsdaten erforderlich sind. Alle Ereignisse, über die wir andere Dienste benachrichtigen möchten, haben wir mit der Schnittstelle markiert IPublicInventoryEvent. Wenn ein Ereignis dieser Art auftritt, isolieren wir sie vom Changelog ( changes) und senden sie an die Versandwarteschlange. Hierzu werden zwei Tabellen verwendet publicqueueund publicqueue_archive.
Um die Zustellung von Nachrichten zu gewährleisten, verwenden wir ein Muster, das wir normalerweise als "lokale Warteschlange" bezeichnen
Transactional outbox pattern. Das Speichern des Status des Aggregats Inventoryund das Senden von Ereignissen an die lokale Warteschlange erfolgt in einer Transaktion. Sobald die Transaktion festgeschrieben ist, versuchen wir sofort, Nachrichten an den Broker zu senden.
Wenn die Nachricht gesendet wurde, wird sie aus der Warteschlange entfernt
publicqueue. Wenn nicht, wird versucht, die Nachricht später zu senden. Abonnenten der Monolith- und Datenpipelines verbrauchen dann Nachrichten. In der Tabelle publicqueue_archivewerden Daten für immer gespeichert, damit Ereignisse bequem erneut versendet werden können, wenn dies irgendwann erforderlich ist.
Code zum Veröffentlichen von Ereignissen im Nachrichtenbroker
internal sealed class BusDataService : IBusDataService
{
private readonly IPublisherControl _publisherControl;
private readonly IPublicQueueRepository _repository;
private readonly EventMapper _eventMapper;
public BusDataService(
IPublicQueueRepository repository,
IPublisherControl publisherControl,
EventMapper eventMapper)
{
_repository = repository;
_publisherControl = publisherControl;
_eventMapper = eventMapper;
}
public async Task ConsumePublicQueueAsync(int batchEventSize, CancellationToken cancellationToken)
{
var events = await _repository.GetAsync(batchEventSize, cancellationToken);
await Publish(events, cancellationToken);
}
public async Task Publish(IEnumerable<IPublicInventoryEvent> events, CancellationToken ct)
{
foreach (var @event in events)
{
var publicQueueEvent = _eventMapper.Map((dynamic) @event);
await _publisherControl.Publish(publicQueueEvent, ct);
await _repository.DeleteAsync(@event, ct);
}
}
}
Wir senden Ereignisse an den Monolithen, um Berichte zu erhalten. Mit dem Verlust- und Überschussbericht können Sie zwei beliebige Revisionen miteinander vergleichen. Darüber hinaus gibt es einen wichtigen Bericht "Lagerbestände", der bereits zuvor erwähnt wurde.
Warum Ereignisse an die Datenpipeline senden? Trotzdem - für Berichte, aber nur auf neuen Schienen. Früher lebten alle Berichte in einem Monolithen, jetzt werden sie herausgenommen. Dies teilt zwei Verantwortlichkeiten - Speicherung und Verarbeitung von Produktions- und Analysedaten: OLTP und OLAP. Dies ist sowohl für die Infrastruktur als auch für die Entwicklung wichtig.
Fazit
Durch Befolgen der Prinzipien und Praktiken des domänengesteuerten Designs ist es uns gelungen, ein zuverlässiges und flexibles System aufzubauen, das die Geschäftsanforderungen der Benutzer erfüllt. Wir haben nicht nur ein anständiges Produkt, sondern auch guten Code, der leicht zu ändern ist. Wir hoffen, dass es in Ihren Projekten einen Platz für die Verwendung von Domain-Driven Design gibt.
Weitere Informationen zu DDD finden Sie in unserer DDDevotion- Community und auf dem DDDevotion- Youtube-Kanal . Sie können den Artikel im Telegramm im Dodo Engineering-Chat diskutieren .