Di Habré dan tidak hanya sejumlah artikel yang layak telah ditulis tentang Desain Berbasis Domain - keduanya secara umum tentang arsitektur, dan dengan contoh-contoh di .Net. Tetapi pada saat yang sama, bagian penting dari arsitektur ini sebagai Value Objects sering disebutkan dengan buruk.
Pada artikel ini, saya akan mencoba mengungkap nuansa mengimplementasikan Value Objects dalam .Net Core menggunakan Entity Framework Core.
Di bawah kucing ada banyak kode.
Sedikit teori
Inti dari arsitektur Desain Didorong Domain adalah
Domain - area subjek di mana perangkat lunak yang dikembangkan diterapkan. Berikut adalah seluruh logika bisnis aplikasi, yang biasanya berinteraksi dengan berbagai data. Data dapat terdiri dari dua jenis:
- Objek Entitas
- Objek Nilai (selanjutnya - VO)
Objek Entitas mendefinisikan entitas dalam logika bisnis dan selalu memiliki pengidentifikasi yang dengannya Entity dapat ditemukan atau dibandingkan dengan Entitas lain. Jika dua Entitas memiliki pengidentifikasi yang identik, ini adalah Entitas yang sama. Hampir selalu berubah.
Nilai Objek adalah tipe yang tidak dapat diubah, nilai yang ditetapkan selama penciptaan dan tidak berubah sepanjang umur objek. Itu tidak memiliki pengidentifikasi. Jika dua VO secara struktural identik, mereka setara.
Entity dapat berisi Entity dan VO lainnya. VO mungkin termasuk VO lain, tetapi bukan Entitas.
Dengan demikian, logika domain harus bekerja secara eksklusif dengan Entity dan VO - ini menjamin konsistensi. Tipe data dasar seperti string, int, dll. seringkali mereka tidak dapat bertindak sebagai VO, karena mereka hanya dapat melanggar keadaan domain - yang hampir merupakan bencana dalam kerangka kerja DDD.
Sebuah contoh Dalam berbagai manual, kelas Person, yang membuat semua orang muak, sering ditampilkan seperti ini:
public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } }
Sederhana dan jelas - pengidentifikasi, nama dan usia, di mana Anda dapat membuat kesalahan?
Dan mungkin ada beberapa kesalahan di sini - misalnya, dari sudut pandang logika bisnis, namanya wajib, tidak boleh panjang nol atau lebih dari 100 karakter dan tidak boleh mengandung karakter khusus, tanda baca, dll. Dan usia tidak boleh kurang dari 10 atau lebih dari 120 tahun.
Dari sudut pandang bahasa pemrograman, 5 adalah integer yang benar-benar normal, sama seperti string kosong. Tetapi domain sudah dalam kondisi yang salah.
Mari kita lanjutkan berlatih
Pada titik ini, kita tahu bahwa VO harus tidak berubah dan mengandung nilai yang valid untuk logika bisnis.
Kekebalan dicapai dengan menginisialisasi properti readonly saat membuat objek.
Validasi nilai terjadi di konstruktor (Penjaga klausa). Sangat diinginkan untuk membuat verifikasi itu sendiri tersedia untuk umum - sehingga lapisan lain dapat memvalidasi data yang diterima dari klien (browser yang sama).
Mari kita membuat VO untuk Nama dan Usia. Selain itu, kami sedikit menyulitkan tugas - menambahkan PersonalName yang menggabungkan FirstName dan LastName, dan menerapkannya pada Orang.
Nama public class Name { private static readonly Regex ValidationRegex = new Regex( @"^[\p{L}\p{M}\p{N}]{1,100}\z", RegexOptions.Singleline | RegexOptions.Compiled); public Name(String value) { if (!IsValid(value)) { throw new ArgumentException("Name is not valid"); } Value = value; } public String Value { get; } public static Boolean IsValid(String value) { return !String.IsNullOrWhiteSpace(value) && ValidationRegex.IsMatch(value); } public override Boolean Equals(Object obj) { return obj is Name other && StringComparer.Ordinal.Equals(Value, other.Value); } public override Int32 GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); } }
Nama pribadi public class PersonalName { protected PersonalName() { } public PersonalName(Name firstName, Name lastName) { if (firstName == null) { throw new ArgumentNullException(nameof(firstName)); } if (lastName == null) { throw new ArgumentNullException(nameof(lastName)); } FirstName = firstName; LastName = lastName; } public Name FirstName { get; } public Name LastName { get; } public String FullName => $"{FirstName} {LastName}"; public override Boolean Equals(Object obj) { return obj is PersonalName personalName && EqualityComparer<Name>.Default.Equals(FirstName, personalName.FirstName) && EqualityComparer<Name>.Default.Equals(LastName, personalName.LastName); } public override Int32 GetHashCode() { return HashCode.Combine(FirstName, LastName); } public override String ToString() { return FullName; } }
Usia public class Age { public Age(Int32 value) { if (!IsValid(value)) { throw new ArgumentException("Age is not valid"); } Value = value; } public Int32 Value { get; } public static Boolean IsValid(Int32 value) { return 10 <= value && value <= 120; } public override Boolean Equals(Object obj) { return obj is Age other && Value == other.Value; } public override Int32 GetHashCode() { return Value.GetHashCode(); } }
Dan akhirnya Orang:
public class Person { public Person(PersonalName personalName, Age age) { if (personalName == null) { throw new ArgumentNullException(nameof(personalName)); } if (age == null) { throw new ArgumentNullException(nameof(age)); } Id = Guid.NewGuid(); PersonalName= personalName; Age = age; } public Guid Id { get; private set; } public PersonalName PersonalName{ get; set; } public Age Age { get; set; } }
Karenanya, kami tidak dapat membuat Orang tanpa nama atau usia lengkap. Selain itu, kami tidak dapat membuat nama "salah" atau usia "salah". Seorang programmer yang baik pasti akan memeriksa data yang diterima di controller menggunakan Name.IsValid ("John") dan Age.IsValid (35) metode dan, jika ada data yang salah, akan menginformasikan klien tentang hal ini.
Jika kita membuat aturan di mana-mana dalam model untuk menggunakan hanya Entity dan VO, maka kita akan melindungi diri kita dari sejumlah besar kesalahan - data yang salah tidak akan masuk ke model.
Ketekunan
Sekarang kita perlu menyimpan data kita di gudang data dan mendapatkannya berdasarkan permintaan. Kami akan menggunakan Entity Framework Core sebagai ORM, dan data warehouse adalah MS SQL Server.
DDD dengan jelas mendefinisikan: Kegigihan adalah subspesies dari lapisan infrastruktur karena menyembunyikan implementasi spesifik dari akses data.
Domain tidak perlu tahu apa-apa tentang Kegigihan, ini hanya menentukan antarmuka repositori.
Dan Kegigihan berisi implementasi spesifik, konfigurasi pemetaan, serta objek UnitOfWork.
Ada dua pendapat apakah perlu membuat repositori dan Unit Kerja.
Di satu sisi - tidak, itu tidak perlu, karena di Entity Framework Core ini semua sudah diterapkan. Jika kita memiliki arsitektur multi-level dari bentuk DAL -> Business Logic -> Presentation, yang didasarkan pada penyimpanan data, lalu mengapa tidak menggunakan kemampuan EF Core secara langsung.
Tetapi domain dalam DDD tidak bergantung pada penyimpanan data dan ORM yang digunakan - ini semua adalah seluk-beluk implementasi yang dirangkum dalam Persistence dan tidak lagi menarik minat siapa pun. Jika kami memberikan DbContext ke lapisan lain, maka kami segera mengungkapkan detail implementasi, mengikat erat ke ORM yang dipilih dan mendapatkan DAL - sebagai dasar dari semua logika bisnis, tetapi seharusnya tidak demikian. Secara kasar, domain seharusnya tidak melihat perubahan dalam ORM dan bahkan hilangnya Persistence sebagai layer.
Jadi, antarmuka repositori Orang, dalam domain:
public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); }
dan implementasinya dalam Kegigihan:
public class EfPersons : IPersons { private readonly PersonsDemoContext _context; public EfPersons(UnitOfWork unitOfWork) { if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _context = unitOfWork.Context; } public async Task Add(Person person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } await _context.Persons.AddAsync(person); } public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons.ToListAsync(); } }
Tampaknya tidak ada yang rumit, tetapi ada masalah. Entity Framework Core keluar dari kotak hanya bekerja dengan tipe dasar (string, int, DateTime, dll.) Dan tidak tahu apa-apa tentang PersonalName dan Usia. Ayo ajari EF Core untuk memahami Objek Nilai kita.
Konfigurasi
API Lancar paling cocok untuk mengonfigurasi Entitas di DDD. Atribut tidak cocok, karena domain tidak perlu tahu apa-apa tentang nuansa pemetaan.
Buat kelas dengan Kegigihan dengan konfigurasi dasar PersonConfiguration:
internal class PersonConfiguration : IEntityTypeConfiguration<Person> { public void Configure(EntityTypeBuilder<Person> builder) { builder.ToTable("Persons"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedNever(); } }
dan hubungkan ke DbContext:
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); }
Pemetaan
Bagian dari mana bahan ini ditulis.
Saat ini, ada dua cara yang lebih atau kurang nyaman untuk memetakan kelas non-standar ke tipe dasar - Konversi Nilai dan Jenis Milik.
Konversi nilai
Fitur ini muncul di Entity Framework Core 2.1 dan memungkinkan Anda untuk menentukan konversi antara kedua tipe data.
Mari kita menulis konverter untuk Umur (di bagian ini semua kode dalam PersonConfiguration):
var ageConverter = new ValueConverter<Age, Int32>( v => v.Value, v => new Age(v)); builder .Property(p => p.Age) .HasConversion(ageConverter) .HasColumnName("Age") .HasColumnType("int") .IsRequired();
Sintaksis yang sederhana dan ringkas, tetapi bukan tanpa cacat:
- Tidak dapat mengonversi nol;
- Tidak mungkin untuk mengubah satu properti menjadi beberapa kolom dalam sebuah tabel dan sebaliknya;
- EF Core tidak dapat mengonversi ekspresi LINQ dengan properti ini ke kueri SQL.
Saya akan membahas poin terakhir lebih terinci. Tambahkan metode ke repositori yang mengembalikan daftar Orang di atas usia yang diberikan:
public async Task<IReadOnlyList<Person>> GetOlderThan(Age age) { if (age == null) { throw new ArgumentNullException(nameof(age)); } return await _context.Persons .Where(p => p.Age.Value > age.Value) .ToListAsync(); }
Ada kondisi untuk usia, tetapi EF Core tidak akan dapat mengubahnya menjadi query SQL dan, mencapai Where (), itu akan memuat seluruh tabel ke dalam memori aplikasi dan, hanya kemudian, menggunakan LINQ, itu akan memenuhi kondisi p.Age.Value> age.Value .
Secara umum, Konversi Nilai adalah opsi pemetaan yang sederhana dan cepat, tetapi Anda perlu mengingat tentang fitur EF Core ini, jika tidak, pada titik tertentu, saat menanyakan tabel besar, memori mungkin habis.
Jenis yang dimiliki
Jenis yang Dimiliki muncul di Entity Framework Core 2.0 dan menggantikan Tipe Kompleks dari Entity Framework biasa.
Mari jadikan Umur sebagai Jenis Milik:
builder.OwnsOne(p => p.Age, a => { a.Property(u => u.Value).HasColumnName("Age"); a.Property(u => u.Value).HasColumnType("int"); a.Property(u => u.Value).IsRequired(); });
Tidak buruk. Dan Jenis Milik tidak memiliki beberapa kelemahan dari Konversi Nilai, yaitu poin 2 dan 3.
2.
Dimungkinkan untuk mengubah satu properti menjadi beberapa kolom dalam tabel dan sebaliknya
Apa yang Anda butuhkan untuk PersonalName, meskipun sintaks sudah sedikit kelebihan:
builder.OwnsOne(b => b.PersonalName, pn => { pn.OwnsOne(p => p.FirstName, fn => { fn.Property(x => x.Value).HasColumnName("FirstName"); fn.Property(x => x.Value).HasColumnType("nvarchar(100)"); fn.Property(x => x.Value).IsRequired(); }); pn.OwnsOne(p => p.LastName, ln => { ln.Property(x => x.Value).HasColumnName("LastName"); ln.Property(x => x.Value).HasColumnType("nvarchar(100)"); ln.Property(x => x.Value).IsRequired(); }); });
3. EF Core
dapat mengubah ekspresi LINQ dengan properti ini menjadi query SQL.
Tambahkan pengurutan menurut LastName dan FirstName saat memuat daftar:
public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); }
Ekspresi seperti itu akan dikonversi dengan benar ke query SQL dan penyortiran dilakukan pada sisi server SQL, dan tidak dalam aplikasi.
Tentu saja, ada juga kekurangannya.
- Masalah dengan null belum hilang;
- Bidang Jenis yang Dimiliki tidak dapat dibaca dan harus memiliki setter yang dilindungi atau pribadi.
- Jenis yang Dimiliki diimplementasikan sebagai Entitas reguler, yang berarti:
- Mereka memiliki pengidentifikasi (seperti properti bayangan, mis. Itu tidak muncul di kelas domain);
- EF Core melacak semua perubahan pada Jenis yang Dimiliki, persis sama dengan Entitas reguler.
Di satu sisi, ini sama sekali bukan apa yang seharusnya menjadi Objek Nilai. Mereka tidak boleh memiliki pengidentifikasi. VO tidak boleh dilacak untuk perubahan - karena mereka awalnya tidak dapat diubah, properti Entitas induk harus dilacak, tetapi bukan properti VO.
Di sisi lain, ini adalah detail implementasi yang bisa dihilangkan, tapi sekali lagi, jangan lupa. Perubahan pelacakan mempengaruhi kinerja. Jika ini tidak terlihat dengan pilihan Entitas tunggal (misalnya, dengan Id) atau daftar kecil, maka dengan sampling daftar besar Entitas "berat" (banyak properti-VO), penurunan kinerja akan sangat terlihat justru karena pelacakan.
Presentasi
Kami menemukan cara menerapkan Objek Nilai dalam domain dan repositori. Saatnya menggunakan semuanya. Mari kita buat dua halaman sederhana - dengan daftar Person dan formulir untuk menambahkan Person.
Kode pengontrol tanpa metode Tindakan terlihat seperti ini:
public class HomeController : Controller { private readonly IPersons _persons; private readonly UnitOfWork _unitOfWork; public HomeController(IPersons persons, UnitOfWork unitOfWork) { if (persons == null) { throw new ArgumentNullException(nameof(persons)); } if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _persons = persons; _unitOfWork = unitOfWork; }
Tambahkan Aksi untuk mendapatkan daftar Orang:
[HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); }
Lihat @model PersonsListModel @{ ViewData["Title"] = "Persons List"; } <div class="text-center"> <h2 class="display-4">Persons</h2> </div> <table class="table"> <thead> <tr> <td><b>Last name</b></td> <td><b>First name</b></td> <td><b>Age</b></td> </tr> </thead> @foreach (var p in Model.Persons) { <tr> <td>@p.LastName</td> <td>@p.FirstName</td> <td>@p.Age</td> </tr> } </table>
Tidak ada yang rumit - kami memuat daftar, membuat Objek Transfer Data (PersonModel) untuk masing-masing
Orang dan dikirim ke Tampilan yang sesuai.
Jauh lebih menarik adalah penambahan Person:
[HttpPost] public async Task<IActionResult> AddPerson(PersonModel model) { if (model == null) { return BadRequest(); } if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } if (!Name.IsValid(model.LastName)) { ModelState.AddModelError(nameof(model.LastName), "LastName is invalid"); } if (!Age.IsValid(model.Age)) { ModelState.AddModelError(nameof(model.Age), "Age is invalid"); } if (!ModelState.IsValid) { return View(); } var firstName = new Name(model.FirstName); var lastName = new Name(model.LastName); var person = new Person( new PersonalName(firstName, lastName), new Age(model.Age)); await _persons.Add(person); await _unitOfWork.Commit(); var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View("Index", result); }
Lihat @model PersonDemo.Models.PersonModel @{ ViewData["Title"] = "Add Person"; } <h2 class="display-4">Add Person</h2> <div class="row"> <div class="col-md-4"> <form asp-action="AddPerson"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="FirstName" class="control-label"></label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="LastName" class="control-label"></label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Age" class="control-label"></label> <input asp-for="Age" class="form-control" /> <span asp-validation-for="Age" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} }
Ada validasi wajib data yang masuk:
if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); }
Jika ini tidak dilakukan, maka saat membuat VO dengan nilai yang salah, ArgumentException akan dilempar (ingat tentang Penjaga Klausul dalam konstruktor VO). Dengan verifikasi, jauh lebih mudah untuk mengirim pesan kepada pengguna bahwa salah satu nilainya salah.
Di sini Anda perlu melakukan penyimpangan kecil - di Asp Net Core ada cara reguler validasi data - menggunakan atribut. Namun dalam DDD, metode validasi ini tidak benar karena beberapa alasan:
- Kemampuan atribut mungkin tidak cukup untuk logika validasi;
- Logika bisnis apa pun, termasuk aturan untuk memvalidasi parameter, ditetapkan secara eksklusif oleh domain. Dia memonopoli ini dan semua lapisan lain harus memperhitungkan ini. Atribut dapat digunakan, tetapi Anda tidak harus bergantung padanya. Jika atribut melewatkan data yang salah, maka kita akan mendapatkan kembali pengecualian saat membuat VO.
Kembali ke AddPerson (). Setelah validasi data, PersonalName, Umur, dan kemudian Person dibuat. Selanjutnya, tambahkan objek ke repositori dan simpan perubahan (Komit). Sangat penting bahwa Komit tidak dipanggil dalam repositori EfPersons. Tugas repositori adalah melakukan beberapa tindakan dengan data, tidak lebih. Komit dilakukan hanya dari luar, ketika tepatnya - programmer memutuskan. Kalau tidak, situasi mungkin terjadi ketika kesalahan terjadi di tengah-tengah iterasi bisnis tertentu - beberapa data disimpan dan beberapa tidak. Kami menerima domain dalam keadaan "rusak". Jika Komit dilakukan di bagian paling akhir, maka jika kesalahan terjadi, transaksi hanya akan memutar kembali.
Kesimpulan
Saya memberikan contoh-contoh implementasi Value Objects secara umum dan nuansa pemetaan di Entity Framework Core. Saya berharap bahwa materi ini akan berguna dalam memahami bagaimana menerapkan elemen-elemen Desain Didorong Domain dalam praktek.
Kode Sumber Proyek PersonsDemo Lengkap -
GitHubMateri tidak mengungkapkan masalah berinteraksi dengan Objek Nilai opsional (nullable) - jika PersonalName atau Umur tidak diperlukan properti Person. Saya ingin menggambarkan ini di artikel ini, tetapi sudah keluar agak kelebihan. Jika ada minat dalam masalah ini - tulis komentar, kelanjutannya akan.
Untuk penggemar "arsitektur yang indah" pada umumnya dan Desain Domain Driven pada khususnya, saya sangat merekomendasikan sumber daya
Enterprise Craftsmanship .
Ada banyak artikel bermanfaat tentang konstruksi arsitektur yang benar dan contoh implementasi di .Net. Beberapa ide dipinjam di sana, berhasil diimplementasikan dalam proyek "pertempuran" dan sebagian tercermin dalam artikel ini.
Dokumentasi resmi untuk
Jenis yang Dimiliki dan
Konversi Nilai juga digunakan.