Audit di Dodo tidak berbasis kertas: auditor memiliki tablet tempat auditor mencatat semua produk dan membuat laporan. Namun hingga tahun 2020, revisi pizza dilakukan tepat di atas kertas - hanya karena lebih mudah dan mudah begitu. Hal ini tentu saja menimbulkan ketidakakuratan data, kesalahan dan kerugian - orang melakukan kesalahan, potongan kertas hilang, dan masih banyak lagi. Kami memutuskan untuk memperbaiki masalah ini dan meningkatkan cara tablet. Implementasinya memutuskan untuk menggunakan DDD. Bagaimana kami melakukannya, kami akan memberi tahu Anda lebih lanjut.
Pertama, secara singkat tentang proses bisnis untuk memahami konteksnya. Pertimbangkan diagram alir produk, dan mana revisinya, lalu lanjutkan ke detail teknis, yang jumlahnya banyak.
Skema pergerakan produk dan mengapa diperlukan revisi
Ada lebih dari 600 restoran pizza di jaringan kami (dan jumlah ini akan terus bertambah). Setiap hari ada perpindahan bahan mentah di masing-masingnya: dari persiapan dan penjualan produk, penghapusan bahan berdasarkan tanggal kedaluwarsa, hingga perpindahan bahan mentah ke restoran pizza lain dalam rantai tersebut. Keseimbangan restoran pizza selalu berisi sekitar 120 item yang diperlukan untuk produksi produk, dan sebagai tambahan banyak bahan habis pakai, bahan rumah tangga, dan bahan kimia untuk menjaga kebersihan restoran pizza tersebut. Semua ini membutuhkan "penghitungan" untuk mengetahui bahan mentah mana yang melimpah dan mana yang kurang.
"Akuntansi" menggambarkan setiap pergerakan bahan mentah di restoran pizza. Pengiriman adalah nilai tambah di neraca, dan penghapusan adalah minus. Misalnya, saat kita memesan pizza, kasir menerima pesanan tersebut dan mengirimkannya untuk diproses. Adonan kemudian digulung dan diisi dengan bahan-bahan seperti keju, saus tomat, dan pepperoni. Semua produk ini masuk ke produksi - dihapuskan. Selain itu, penghapusan dapat terjadi ketika tanggal kedaluwarsa berakhir.
Sebagai hasil dari pengiriman dan penghapusan, "saldo gudang" terbentuk. Ini adalah laporan yang mencerminkan berapa banyak bahan baku yang ada di neraca berdasarkan operasi di sistem informasi. Semua ini adalah "saldo penyelesaian". Tapi ada "nilai sebenarnya" - berapa banyak bahan mentah yang sebenarnya tersedia sekarang.
Revisi
Untuk menghitung nilai sebenarnya, digunakan "revisi" (juga disebut "persediaan").
Audit membantu menghitung secara akurat jumlah bahan mentah untuk pembelian. Terlalu banyak pembelian akan membekukan modal kerja, dan risiko penghapusan produk berlebih akan meningkat, yang juga menyebabkan kerugian. Tidak hanya kelebihan bahan baku yang berbahaya, tetapi juga kekurangannya - hal ini dapat menyebabkan terhentinya produksi beberapa produk, yang akan menyebabkan penurunan pendapatan. Audit membantu untuk melihat berapa banyak keuntungan yang diterima bisnis lebih sedikit karena kerugian bahan baku yang diperhitungkan dan tidak dihitung, dan bekerja untuk mengurangi biaya.
Revisi membagikan datanya dengan memperhatikan pemrosesan lebih lanjut, misalnya, membuat laporan.
Masalah dalam proses revisi, atau Berapa lama revisi bekerja
Revisi adalah proses yang melelahkan. Proses ini memakan banyak waktu dan terdiri dari beberapa tahapan: menghitung dan memperbaiki sisa bahan baku, merangkum hasil bahan baku menurut tempat penyimpanan, memasukkan hasilnya ke dalam sistem informasi Dodo IS.
Sebelumnya, audit dilakukan dengan formulir pena dan kertas yang di dalamnya terdapat daftar bahan baku. Saat meringkas, merekonsiliasi, dan mentransfer hasil secara manual ke Dodo IS, ada kemungkinan membuat kesalahan. Dalam audit penuh, lebih dari 100 jenis bahan mentah dihitung, dan penghitungannya sendiri sering dilakukan pada larut malam atau dini hari, sehingga konsentrasi dapat berkurang.
Bagaimana mengatasi masalah tersebut
Tim Game of Threads kami sedang mengembangkan akuntansi di restoran pizza. Kami memutuskan untuk meluncurkan proyek yang disebut "tablet auditor", yang akan menyederhanakan audit restoran pizza. Kami memutuskan untuk melakukan semuanya di sistem informasi kami sendiri Dodo IS, di mana komponen utama akuntansi diterapkan, jadi kami tidak memerlukan integrasi dengan sistem pihak ketiga. Selain itu, semua negara tempat kami hadir akan dapat menggunakan alat tersebut tanpa menggunakan integrasi tambahan.
Bahkan sebelum mulai mengerjakan proyek, kami dalam tim membahas keinginan untuk menerapkan DDD dalam praktik. Untungnya, salah satu proyek telah berhasil menerapkan pendekatan ini, jadi kami memiliki contoh yang dapat Anda lihat - ini adalah proyek " meja kas ".
Pada artikel ini, saya akan berbicara tentang pola DDD taktis yang kami gunakan dalam pengembangan: agregat, perintah, peristiwa domain, layanan aplikasi, dan integrasi konteks terbatas. Kami tidak akan menjelaskan pola strategis dan dasar-dasar DDD, jika tidak artikelnya akan sangat panjang. Kami sudah membicarakan hal ini di artikel “ Apa yang dapat Anda pelajari tentang Desain Didorong Domain dalam 10 menit? "
Versi revisi baru
Sebelum memulai audit, Anda perlu tahu persis apa yang harus dihitung. Untuk ini kita membutuhkan template revisi . Mereka dikonfigurasi oleh peran "manajer kantor". Template revisi adalah entitas InventoryTemplate. Ini berisi bidang berikut:
- pengenal template;
- ID restoran pizza;
- nama template;
- kategori revisi: bulanan, mingguan, harian;
- unit;
- tempat penyimpanan dan bahan baku di tempat penyimpanan ini
Untuk entitas ini, fungsi CRUD telah diterapkan dan kami tidak akan membahasnya secara mendetail.
Setelah auditor memiliki daftar templat, dia dapat memulai audit . Ini biasanya terjadi saat restoran pizza tutup. Saat ini, tidak ada pesanan dan bahan mentah tidak bergerak - Anda bisa mendapatkan data saldo dengan andal.
Memulai audit, auditor memilih zona, misalnya kulkas, dan menghitung bahan mentah di sana. Di lemari es ia melihat 5 bungkus keju, masing-masing 10 kg, memasukkan 10 kg * 5 ke dalam kalkulator, menekan "Masukkan lagi". Kemudian dia melihat 2 bungkus lagi di rak paling atas, dan mengklik "Tambah". Hasilnya, ia memiliki 2 pengukuran - masing-masing 50 dan 20 kg.
Pengukurankami menyebut jumlah bahan mentah yang dimasukkan oleh inspektur di area tertentu, tetapi belum tentu total. Auditor dapat memasukkan dua pengukuran satu kilogram atau hanya dua kilogram dalam satu pengukuran - kombinasi apa pun bisa. Hal utama adalah bahwa auditor itu sendiri harus jelas.
Antarmuka kalkulator.
Jadi, selangkah demi selangkah, auditor mempertimbangkan semua bahan mentah dalam 1-2 jam, lalu menyelesaikan audit.
Algoritme tindakannya cukup sederhana:
- auditor dapat memulai audit;
- auditor dapat menambahkan pengukuran dalam revisi yang dimulai;
- auditor dapat menyelesaikan audit.
Persyaratan bisnis untuk sistem dibentuk dari algoritma ini.
Implementasi versi pertama dari agregat, perintah, dan peristiwa domain
Pertama, mari tentukan istilah-istilah yang termasuk dalam set templat taktis DDD. Kami akan merujuk mereka di artikel ini.
Template DDD Taktis
Agregat adalah sekumpulan objek entitas dan nilai. Objek dalam cluster adalah entitas tunggal dalam hal modifikasi data. Setiap agregat memiliki elemen root yang digunakan untuk mengakses entitas dan nilai. Unit sebaiknya tidak didesain terlalu besar. Mereka akan menghabiskan banyak memori, dan kemungkinan berhasilnya transaksi menurun.
Batas agregat adalah sekumpulan objek yang harus konsisten dalam satu transaksi: semua invarian dalam cluster ini harus diamati.
Invarian adalah aturan bisnis yang tidak boleh tidak konsisten.
PerintahApakah semacam tindakan pada unit. Sebagai hasil dari tindakan ini, status agregat dapat diubah, dan satu atau beberapa peristiwa domain dapat dibuat.
Peristiwa domain adalah pemberitahuan tentang perubahan status agregat, yang diperlukan untuk menjaga konsistensi. Agregat memastikan konsistensi transaksional: semua data harus diubah di sini dan sekarang. Konsistensi yang dihasilkan menjamin konsistensi dalam jangka panjang - data akan berubah, tetapi tidak di sini dan sekarang, tetapi setelah jangka waktu yang tidak terbatas. Interval ini bergantung pada banyak faktor: kemacetan antrian pesan, kesiapan layanan eksternal untuk memproses pesan-pesan ini, jaringan.
Elemen rootAdalah entitas dengan pengenal global unik. Elemen turunan hanya dapat memiliki identitas lokal dalam keseluruhan agregat. Mereka bisa merujuk satu sama lain dan hanya bisa mereferensikan elemen root mereka.
Tim dan acara
Mari kita gambarkan kebutuhan bisnis sebagai sebuah tim. Perintah hanyalah DTO dengan bidang deskriptif.
Perintah "tambahkan pengukuran" memiliki bidang berikut:
- nilai pengukuran - jumlah bahan baku dalam unit pengukuran tertentu, dapat menjadi nol jika pengukuran tersebut dihapus;
- versi - pengukuran dapat diedit, sehingga diperlukan versi;
- pengenal bahan baku;
- satuan takaran: kg / g, l / ml, potongan;
- pengenal area penyimpanan.
Pengukuran menambahkan kode perintah
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; }
}
Kami juga membutuhkan acara yang akan dihasilkan dari eksekusi perintah ini. Kami menandai acara tersebut dengan antarmuka
IPublicInventoryEvent- kami akan membutuhkannya untuk integrasi dengan konsumen eksternal di masa mendatang.
Dalam peristiwa "pengukuran", kolomnya sama seperti di perintah "Tambahkan pengukuran", kecuali peristiwa tersebut juga menyimpan pengenal unit tempat terjadinya dan versinya.
Kode acara "dibekukan"
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; }
}
Ketika kita telah mendeskripsikan perintah dan kejadian, kita dapat mengimplementasikan agregat
Inventory.
Menerapkan Agregat Inventaris
Inventaris Diagram Agregat UML.
Pendekatannya adalah ini: awal revisi memulai pembuatan agregat
Inventory, untuk ini kita menggunakan metode pabrik Createdan memulai revisi dengan perintah StartInventoryCommand.
Setiap perintah mengubah status agregat dan menyimpan peristiwa dalam daftar
changes, yang akan dikirim ke penyimpanan untuk direkam. Selain itu, berdasarkan perubahan ini, acara untuk dunia luar akan dibuat.
Saat agregat telah
Inventorydibuat, kita dapat memulihkannya untuk setiap permintaan berikutnya untuk mengubah statusnya.
- Perubahan (
changes) disimpan sejak terakhir kali unit dipulihkan.
- Status dipulihkan dengan metode
Restoreyang memutar semua peristiwa sebelumnya, diurutkan berdasarkan versi, pada instance agregat saat iniInventory.
Ini adalah implementasi ide
Event Sourcingdi dalam unit. Kami akan Event Sourcingberbicara tentang bagaimana mengimplementasikan ide dalam kerangka repositori nanti. Ada ilustrasi bagus dari buku Vaughn Vernon:
Status unit dipulihkan dengan menerapkan peristiwa dalam urutan kejadiannya.
Kemudian beberapa pengukuran dilakukan oleh tim
AddMeasurementCommand. Audit diakhiri dengan perintah FinishInventoryCommand. Agregat memvalidasi statusnya dalam metode mutasi untuk mematuhi invariannya.
Penting untuk dicatat bahwa unit ini memiliki
Inventoryversi lengkap, serta setiap pengukuran. Pengukuran lebih sulit - Anda harus menyelesaikan konflik dalam metode penanganan kejadian When(MeasurementEvent e). Dalam kodenya, saya hanya akan menunjukkan pemrosesan perintah AddMeasurementCommand.
Kode Inventaris Gabungan
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
}
Saat peristiwa "Terukur" terjadi, keberadaan pengukuran yang ada dengan pengenal ini diperiksa. Jika tidak demikian, pengukuran baru ditambahkan.
Jika demikian, diperlukan pemeriksaan tambahan:
- Anda tidak dapat mengedit pengukuran jarak jauh;
- versi yang masuk harus lebih besar dari yang sebelumnya.
Jika kondisi terpenuhi, kita dapat menetapkan nilai baru dan versi baru untuk pengukuran yang ada. Jika versinya lebih kecil, maka ini adalah konflik. Untuk ini, kami memberikan pengecualian
MeasurementConcurrencyException. Jika versinya cocok dan nilainya berbeda, maka ini juga merupakan situasi konflik. Nah, jika versi dan nilainya cocok, maka tidak ada perubahan yang terjadi. Situasi seperti itu biasanya tidak muncul.
Entitas "pengukuran" berisi bidang yang sama persis dengan perintah "Tambahkan pengukuran".
Kode entitas "membeku"
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; }
}
Penggunaan metode agregat publik ditunjukkan dengan baik oleh pengujian Unit.
Kode uji unit "menambahkan pengukuran setelah dimulainya revisi"
[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
});
}
Menyatukan semuanya: perintah, peristiwa, agregat inventaris
Inventory aggregate life cycle saat menjalankan Finish Inventory.
Diagram menunjukkan proses pemrosesan perintah
FinishInventoryCommand. Sebelum diproses, perlu untuk mengembalikan status unit Inventorypada saat eksekusi perintah. Untuk melakukan ini, kami memuat semua acara yang dilakukan pada unit ini ke dalam memori dan memutarnya (hlm. 1).
Pada saat penyelesaian revisi, kami sudah memiliki kejadian berikut - awal revisi dan penambahan tiga pengukuran. Peristiwa ini muncul sebagai hasil dari pemrosesan perintah
StartInventoryCommanddan AddMeasurementCommand, karenanya. Dalam database, setiap baris dalam tabel berisi ID revisi, versi, dan isi acara itu sendiri.
Pada tahap ini, kami menjalankan perintah
FinishInventoryCommand(hal. 2). Perintah ini pertama-tama akan memeriksa keabsahan status unit saat ini - bahwa revisi dalam status InProgress, dan kemudian akan menghasilkan perubahan status baru dengan menambahkan acara FinishInventoryEventke daftar changes(item 3).
Saat perintah selesai, semua perubahan akan disimpan ke database. Hasilnya, baris baru dengan acara
FinishInventoryEventdan versi terbaru dari unit akan muncul di database (p. 4).
Jenis
Inventory(revisi) - elemen agregat dan akar dalam kaitannya dengan entitas bertingkat mereka. Jadi, tipe Inventorymendefinisikan batas-batas unit. Batas agregat mencakup daftar entitas jenis Measurement(pengukuran), dan daftar semua peristiwa yang dilakukan pada agregat ( changes).
Implementasi seluruh fitur
Yang kami maksud dengan fitur adalah penerapan persyaratan bisnis tertentu. Dalam contoh kami, kami akan mempertimbangkan fitur Tambahkan Pengukuran. Untuk mengimplementasikan fitur tersebut, kita perlu memahami konsep "layanan aplikasi" (
ApplicationService).
Layanan aplikasi adalah klien langsung dari model domain. Layanan Aplikasi menjamin transaksi saat menggunakan database ACID, memastikan bahwa transisi status dipertahankan atomic. Selain itu, layanan aplikasi juga menangani masalah keamanan.
Kami sudah memiliki unit
Inventory... Untuk mengimplementasikan keseluruhan fitur, kami akan menggunakan layanan aplikasi sepenuhnya. Di dalamnya, Anda perlu memeriksa keberadaan semua entitas yang terhubung, serta hak akses pengguna. Hanya setelah semua kondisi terpenuhi, maka dimungkinkan untuk menyimpan keadaan unit saat ini dan mengirim acara ke dunia luar. Untuk mengimplementasikan layanan aplikasi, kami menggunakan MediatR.
Kode fitur "menambahkan pengukuran"
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");
}
}
}
Sumber acara
Selama implementasi, kami memutuskan untuk memilih pendekatan ES karena beberapa alasan:
- Dodo memiliki contoh keberhasilan penggunaan pendekatan ini.
- ES memudahkan untuk memahami masalah selama insiden - semua tindakan pengguna disimpan.
- Jika Anda mengambil pendekatan tradisional, Anda tidak akan bisa pindah ke ES.
Ide implementasinya cukup sederhana - kami menambahkan semua event baru yang muncul sebagai hasil dari perintah ke database. Untuk memulihkan agregat, kami mendapatkan semua acara dan memutarnya di instance. Agar tidak mendapatkan banyak peristiwa setiap saat, kami menghapus status setiap N peristiwa dan memutar sisa snapshot ini.
ID Penyimpanan Gabungan Inventaris
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; }
}
Setelah beberapa bulan beroperasi, kami menyadari bahwa kami tidak memiliki kebutuhan besar untuk menyimpan semua tindakan pengguna pada instance unit. Bisnis tidak menggunakan informasi ini dengan cara apa pun. Namun demikian, ada biaya tambahan dalam mempertahankan pendekatan ini. Setelah mengevaluasi semua pro dan kontra, kami berencana untuk beralih dari ES ke pendekatan tradisional - untuk mengganti tanda
Eventsdengan Inventoriesdan Measurements.
Integrasi dengan konteks terikat eksternal
Ini adalah skema interaksi konteks terbatas
Inventorydengan dunia luar.
Interaksi konteks revisi dengan konteks lain. Diagram menunjukkan konteks, layanan, dan milik mereka satu sama lain.
Dalam kasus
Auth, Inventorydan Datacatalog, ada satu konteks terbatas untuk setiap layanan. Monolit melakukan beberapa fungsi, tetapi sekarang kami hanya tertarik pada fungsi akuntansi di restoran pizza. Selain revisi, akuntansi juga mencakup pergerakan bahan mentah di restoran pizza: kwitansi, transfer, penghapusan.
HTTP
Layanan
Inventoryberinteraksi dengan Authmelalui HTTP. Pertama-tama, pengguna dihadapkan dengan Auth, yang meminta pengguna untuk memilih salah satu peran yang tersedia baginya.
- Sistem memiliki peran "auditor", yang dipilih pengguna selama audit.
- .
- .
Pada tahap terakhir, pengguna memiliki token dari
Auth. Layanan revisi harus memvalidasi token ini, sehingga meminta Authvalidasi. Authakan memeriksa apakah masa pakai token telah kedaluwarsa, apakah itu milik pemiliknya, atau apakah ia memiliki hak akses yang diperlukan. Jika semuanya baik-baik saja, maka ia Inventorymenyimpan perangko di cookie - ID pengguna, login, ID restoran pizza dan mengatur masa pakai cookie.
Catatan .
AuthKami menjelaskan secara lebih detail cara kerja layanan di artikel " Seluk-beluk otorisasi: ikhtisar teknologi OAuth 2.0 ".
Ini
Inventoryberinteraksi dengan layanan lain melalui antrian pesan. Perusahaan menggunakan RabbitMQ sebagai broker pesan, serta pengikat di atasnya - MassTransit.
RMQ: Acara Mengkonsumsi
Layanan direktori -
Datacatalog- akan menyediakan Inventorysemua entitas yang diperlukan: bahan mentah untuk akuntansi, negara, divisi, dan restoran pizza.
Tanpa membahas detail infrastrukturnya, saya akan menjelaskan ide dasar dari acara makan. Di sisi layanan direktori, semuanya sudah siap untuk acara penerbitan, mari kita lihat contoh entitas bahan baku.
Kode Kontrak Peristiwa Datacatalog
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
}
}
Posting ini diterbitkan di
exchange. Setiap layanan dapat membuat bundelnya sendiri exchange-queueuntuk menggunakan acara.
Skema penerbitan acara dan konsumsinya melalui RMQ primitif.
Pada akhirnya, ada antrian untuk setiap entitas tempat layanan dapat berlangganan. Yang tersisa hanyalah menyimpan versi baru ke database.
Kode konsumen acara dari Datacatalog
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: Acara Penerbitan
Bagian akuntansi dari monolit menggunakan data
Inventoryuntuk mendukung fungsionalitas lainnya yang memerlukan data revisi. Semua event yang ingin kami beri tahu layanan lain, kami tandai dengan antarmuka IPublicInventoryEvent. Saat peristiwa semacam ini terjadi, kami mengisolasi mereka dari changelog ( changes) dan mengirimkannya ke antrean pengiriman. Untuk ini, dua tabel digunakan publicqueuedan publicqueue_archive.
Untuk menjamin pengiriman pesan, kami menggunakan pola yang biasa kami sebut "antrian lokal", artinya
Transactional outbox pattern. Menyimpan keadaan agregat Inventorydan mengirim peristiwa ke antrian lokal terjadi dalam satu transaksi. Begitu transaksi dilakukan, kami segera mencoba mengirim pesan ke broker.
Jika pesan telah terkirim, maka pesan tersebut akan dihapus dari antrian
publicqueue. Jika tidak, upaya akan dilakukan untuk mengirim pesan nanti. Pelanggan monolit dan pipeline data kemudian menggunakan pesan tersebut. Tabel akan publicqueue_archiveselamanya menyimpan data untuk memudahkan pengiriman ulang peristiwa jika diperlukan di beberapa titik.
Kode untuk mempublikasikan acara ke broker pesan
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);
}
}
}
Kami mengirim acara ke monolit untuk laporan. Laporan kerugian dan surplus memungkinkan Anda membandingkan dua revisi satu sama lain. Selain itu, ada laporan penting "saldo gudang", yang sudah disebutkan sebelumnya.
Mengapa mengirim peristiwa ke pipeline data? Semua sama - untuk laporan, tetapi hanya di rel baru. Sebelumnya, semua laporan hidup dalam satu monolit, tetapi sekarang dihapus. Ini berbagi dua tanggung jawab - penyimpanan dan pemrosesan produksi dan data analitik: OLTP dan OLAP. Ini penting baik dari segi infrastruktur maupun pembangunan.
Kesimpulan
Dengan mengikuti prinsip dan praktik Domain-Driven Design, kami telah mampu membangun sistem yang andal dan fleksibel yang memenuhi kebutuhan bisnis pengguna. Kami tidak hanya mendapatkan produk yang layak, tetapi juga kode bagus yang mudah dimodifikasi. Kami berharap dalam proyek Anda akan ada tempat untuk menggunakan Desain Berdasarkan Domain.
Anda dapat menemukan informasi lebih lanjut tentang DDD di komunitas DDDevotion kami dan di saluran Youtube DDDevotion . Anda dapat membahas artikel di Telegram di obrolan Teknik Dodo .