على Habré وليس فقط قد تم كتابة قدر لا بأس به من المقالات حول تصميم النطاق المُدار - بشكل عام عن الهندسة المعمارية ، مع أمثلة على .Net. ولكن في الوقت نفسه ، غالبًا ما يتم ذكر جزء مهم من هذه البنية مثل كائنات القيمة بشكل سيئ.
في هذه المقالة ، سأحاول الكشف عن الفروق الدقيقة في تطبيق كائنات القيمة في .Net Core باستخدام Entity Framework Core.
تحت القط هناك الكثير من التعليمات البرمجية.
قليلا من الناحية النظرية
إن جوهر تصميم Domain Driven Design هو
المجال -
مجال الموضوع الذي يتم فيه تطبيق البرنامج الذي يتم تطويره. هنا هو منطق العمل بأكمله للتطبيق ، والذي يتفاعل عادة مع البيانات المختلفة. يمكن أن تكون البيانات من نوعين:
- كائن الكيان
- كائن القيمة (فيما يلي - VO)
يعرّف
كائن الكيان الكيان في منطق العمل ويكون له دائمًا معرف يمكن من خلاله العثور على الكيان أو مقارنته مع كيان آخر. إذا كان لدى كيانين معرف متطابق ، فهذا هو نفس الكيان. دائما تقريبا تغيير.
كائن القيمة هو نوع غير قابل للتغيير ، يتم تعيين القيمة الخاصة به أثناء الإنشاء ولا تتغير طوال عمر الكائن. ليس لديه معرف. إذا كان هناك صوتان متطابقان من الناحية الهيكلية ، فهما متكافئان.
قد يحتوي الكيان على كيان آخر و VO. قد تتضمن VOs VOs أخرى ، ولكن ليس الكيان.
وبالتالي ، يجب أن يعمل منطق المجال بشكل حصري مع Entity و VO - وهذا يضمن اتساقه. أنواع البيانات الأساسية مثل السلسلة ، int ، إلخ. غالبًا ما لا يمكنهم التصرف كإشارة صوتية (VO) ، لأنهم ببساطة يمكنهم انتهاك حالة المجال - وهي كارثة تقريبًا في إطار DDD.
مثال في الأدلة المختلفة ، غالبًا ما تظهر فئة الأشخاص ، التي سئمت من الجميع ، على النحو التالي:
public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } }
بسيطة وواضحة - معرف ، اسم وعمر ، أين يمكنك أن ترتكب خطأ؟
ولكن يمكن أن يكون هناك العديد من الأخطاء هنا - على سبيل المثال ، من وجهة نظر منطق العمل ، اسم إلزامي ، ولا يمكن أن يكون طوله صفريًا أو أكثر من 100 حرفًا ويجب ألا يحتوي على أحرف خاصة وعلامات الترقيم ، إلخ. ولا يمكن أن يكون العمر أقل من 10 سنوات أو أكثر من 120 عامًا.
من وجهة نظر لغة البرمجة ، 5 عبارة عن عدد صحيح طبيعي تمامًا ، وبالمثل عبارة عن سلسلة فارغة. لكن المجال بالفعل في حالة غير صحيحة.
دعنا ننتقل إلى الممارسة
في هذه المرحلة ، نعلم أن VO يجب أن تكون غير قابلة للتغيير وتحتوي على قيمة صالحة لمنطق الأعمال.
يتم تحقيق المناعة عن طريق تهيئة الخاصية للقراءة فقط عند إنشاء الكائن.
يحدث التحقق من القيمة في المُنشئ (بند الحماية). من المرغوب فيه إتاحة عملية التحقق نفسها للعامة - حتى تتمكن الطبقات الأخرى من التحقق من صحة البيانات التي تم تلقيها من العميل (نفس المتصفح).
دعونا إنشاء VO للاسم والعمر. بالإضافة إلى ذلك ، فإننا نعقد المهمة قليلاً - نضيف PersonalName يجمع بين FirstName و LastName ، ونطبق ذلك على الشخص.
الاسم 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); } }
الاسم الشخصي 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; } }
العمر 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(); } }
وأخيرا الشخص:
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; } }
لذلك ، لا يمكننا إنشاء شخص بدون اسم أو عمر كاملين. أيضًا ، لا يمكننا إنشاء اسم "خطأ" أو عصر "خطأ". سوف يقوم المبرمج الجيد بالتحقق من البيانات التي تم استلامها في وحدة التحكم باستخدام طرق Name.IsValid ("John") و Age.IsValid (35) ، وفي حالة وجود بيانات غير صحيحة ، سيقوم العميل بإبلاغ العميل بهذا.
إذا وضعنا قاعدة في كل مكان في النموذج لاستخدام Entity و VO فقط ، فسنحمي أنفسنا من عدد كبير من الأخطاء - البيانات الخاطئة ببساطة لن تدخل في النموذج.
الثبات
نحتاج الآن إلى حفظ بياناتنا في مستودع البيانات والحصول عليها عند الطلب. سوف نستخدم Entity Framework Core كـ ORM ، ومستودع البيانات هو MS SQL Server.
تحدد DDD بوضوح: الثبات هو نوع فرعي من طبقة البنية التحتية لأنه يخفي تنفيذًا محددًا للوصول إلى البيانات.
لا يحتاج المجال إلى معرفة أي شيء عن المثابرة ، وهذا يحدد فقط واجهات المستودعات.
والثبات يحتوي على تطبيقات محددة ، ورسم خرائط التعيينات ، وكذلك كائن UnitOfWork.
هناك رأيان ما إذا كان الأمر يستحق إنشاء مستودعات ووحدة العمل.
من ناحية - لا ، ليس من الضروري ، لأنه في Entity Framework Core ، تم تنفيذ كل هذا بالفعل. إذا كان لدينا بنية متعددة المستويات للنموذج DAL -> منطق الأعمال -> العرض التقديمي ، الذي يعتمد على تخزين البيانات ، فلماذا لا نستخدم إمكانيات EF Core مباشرة.
لكن المجال في DDD لا يعتمد على تخزين البيانات و ORM المستخدم - هذه هي كل التفاصيل الدقيقة للتنفيذ التي يتم تضمينها في المثابرة والتي لا تهم أي شخص آخر. إذا قمنا بتوفير DbContext لطبقات أخرى ، فإننا نكشف على الفور عن تفاصيل التنفيذ ، ونربط بإحكام ORM المحدد ونحصل على DAL - كأساس لكل منطق العمل ، ولكن هذا لا ينبغي أن يكون. بمعنى تقريبي ، يجب ألا يلاحظ المجال حدوث تغيير في ORM وحتى فقدان الثبات كطبقة.
لذلك ، واجهة مستودع الأشخاص ، في المجال:
public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); }
وتنفيذه في الثبات:
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(); } }
لا يبدو شيئًا معقدًا ، لكن هناك مشكلة. خارج الإطار ، يعمل Entity Framework Core فقط مع الأنواع الأساسية (السلسلة ، int ، DateTime ، وما إلى ذلك) ولا يعرف شيئًا عن PersonalName والعمر. دعونا نعلم EF Core أن نفهم كائنات القيمة لدينا.
التكوين
API Fluent هو الأنسب لتكوين الكيان في DDD. السمات غير مناسبة ، لأن المجال لا يحتاج إلى معرفة أي شيء عن الفروق الدقيقة في التعيين.
قم بإنشاء فصل في المثابرة باستخدام التكوين الأساسي 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(); } }
وقم بتوصيله في DbContext:
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); }
رسم الخرائط
القسم الذي كتبت له هذه المادة.
في الوقت الحالي ، هناك طريقتان أكثر أو أقل ملاءمة لتعيين الفئات غير القياسية للأنواع الأساسية - تحويلات القيمة والأنواع المملوكة.
تحويلات القيمة
ظهرت هذه الميزة في Entity Framework Core 2.1 وتتيح لك تحديد التحويل بين نوعي البيانات.
دعنا نكتب المحول الخاص بـ Age (في هذا القسم ، كل الكود في تكوين شخصية):
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();
بناء جملة بسيط وموجز ، ولكن ليس بدون عيوب:
- غير قادر على تحويل فارغة.
- لا يمكن تحويل خاصية واحدة إلى أعمدة متعددة في جدول والعكس ؛
- لا يمكن تحويل EF Core تعبير LINQ مع هذه الخاصية إلى استعلام SQL.
سأتناول النقطة الأخيرة بمزيد من التفصيل. أضف طريقة إلى المستودع الذي يعرض قائمة الأشخاص فوق سن معين:
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(); }
هناك شرط للعمر ، لكن EF Core لن تكون قادرة على تحويله إلى استعلام SQL ، والوصول إلى Where () ، سيتم تحميل الجدول بأكمله في ذاكرة التطبيق ، وعندها فقط ، باستخدام LINQ ، سوف يحقق الشرط p.Age.Value> age.Value .
بشكل عام ، تعد Value Conversions خيارًا بسيطًا وسريعًا للتخطيط ، ولكن عليك أن تتذكر هذه الميزة من EF Core ، وإلا ، في مرحلة ما ، عند الاستعلام عن الجداول الكبيرة ، قد تنفد الذاكرة.
الأنواع المملوكة
ظهرت الأنواع المملوكة في Entity Framework Core 2.0 واستبدلت الأنواع المعقدة من Entity Framework المعتاد.
لنجعل العمر كنوع مملوك:
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(); });
ليس سيئا لا تملك الأنواع المملوكة بعضًا من عيوب تحويلات القيمة ، وهي النقطتان 2 و 3.
2. من
الممكن تحويل خاصية واحدة إلى عدة أعمدة في الجدول والعكس
ما تحتاجه لـ PersonalName ، على الرغم من أن بناء الجملة قد تم تحميله بالفعل قليلاً:
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 تحويل تعبير LINQ مع هذه الخاصية إلى استعلام SQL.
أضف الفرز حسب اسم العائلة واسم العائلة عند تحميل القائمة:
public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); }
سيتم تحويل هذا التعبير بشكل صحيح إلى استعلام SQL ويتم الفرز على جانب خادم SQL ، وليس في التطبيق.
بالطبع ، هناك أيضا عيوب.
- مشاكل مع لاغية لم تختف.
- لا يمكن أن تكون حقول الأنواع المملوكة للقراءة فقط ويجب أن تحتوي على محدد خاص أو محمي.
- يتم تطبيق الأنواع المملوكة ككيان منتظم ، مما يعني:
- لديهم معرف (مثل خاصية الظل ، أي أنها لا تظهر في فئة المجال) ؛
- تقوم EF Core بتتبع جميع التغييرات في الأنواع المملوكة ، تمامًا كما هو الحال بالنسبة للكيان العادي.
من ناحية ، هذا ليس على الإطلاق ما ينبغي أن تكون عليه كائنات القيمة. يجب ألا يكون لديهم أي معرفات. يجب عدم تتبع أصوات VO للتغييرات - نظرًا لأنها غير قابلة للتغيير في البداية ، يجب تتبع خصائص الكيان الأصل ، ولكن ليس خصائص VO.
من ناحية أخرى ، هذه هي تفاصيل التنفيذ التي يمكن حذفها ، ولكن مرة أخرى ، لا تنسَ. تتبع التغييرات يؤثر على الأداء. إذا لم يكن هذا ملحوظًا مع تحديدات الكيان الفردي (على سبيل المثال ، بواسطة Id) أو القوائم الصغيرة ، ثم مع أخذ عينات من القوائم الكبيرة من الكيان "الثقيل" (العديد من خصائص VO) ، سيكون انخفاض الأداء ملحوظًا للغاية بسبب التتبع.
عرض تقديمي
لقد اكتشفنا كيفية تنفيذ "كائنات القيمة" في مجال ومستودع. حان الوقت لاستخدام كل شيء. لنقم بإنشاء صفحتين بسيطتين - مع قائمة الأشخاص ونموذج إضافة شخص.
يبدو رمز وحدة التحكم بدون أساليب الإجراء كما يلي:
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; }
أضف إجراء للحصول على قائمة الأشخاص:
[HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); }
عرض @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>
لا شيء معقد - لقد قمنا بتحميل القائمة ، وقمنا بإنشاء كائن نقل البيانات (PersonModel) لكل منها
الشخص وإرسالها إلى عرض المقابلة.
الأكثر إثارة للاهتمام هو إضافة الشخص:
[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); }
عرض @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");} }
يوجد التحقق الإلزامي للبيانات الواردة:
if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); }
إذا لم يتم ذلك ، فعند إنشاء VO بقيمة غير صحيحة ، سيتم طرح ArgumentException (تذكر جملة Guard في مُنشئي VO). مع التحقق ، من الأسهل بكثير إرسال رسالة إلى المستخدم تفيد بأن إحدى القيم غير صحيحة.
هنا تحتاج إلى إجراء استطراد صغير - في Asp Net Core ، هناك طريقة منتظمة للتحقق من صحة البيانات - باستخدام السمات. ولكن في DDD ، طريقة التحقق من الصحة هذه غير صحيحة لعدة أسباب:
- قد لا تكون إمكانيات السمة كافية لمنطق التحقق ؛
- يتم تعيين أي منطق تجاري ، بما في ذلك قواعد التحقق من صحة المعلمات ، حصريًا بواسطة المجال. لديه احتكار على هذا وجميع الطبقات الأخرى يجب أن يحسب هذا. يمكن استخدام السمات ، لكن يجب ألا تعتمد عليها. إذا تخطت السمة بيانات غير صحيحة ، فسنحصل مرة أخرى على استثناء عند إنشاء VO.
رجوع إلى AddPerson (). بعد التحقق من صحة البيانات ، يتم إنشاء PersonalName ، والعمر ، ثم الشخص. بعد ذلك ، أضف الكائن إلى المستودع وحفظ التغييرات (الالتزام). من المهم جدًا ألا يتم التذرع بالالتزام في مستودع EfPersons. مهمة المستودع هي القيام ببعض الإجراءات مع البيانات ، لا أكثر. يتم الالتزام فقط من الخارج ، عندما يقرر المبرمج بالضبط. خلاف ذلك ، يكون الموقف ممكنًا عندما يحدث خطأ في منتصف تكرار أعمال معين - يتم حفظ بعض البيانات والبعض الآخر لا يتم حفظه. نتلقى المجال في حالة "كسر". إذا تم تنفيذ الالتزام في النهاية ، ثم إذا حدث الخطأ ، فستتراجع المعاملة ببساطة.
استنتاج
أعطيت أمثلة على تطبيق كائنات القيمة بشكل عام والفروق الدقيقة في التعيين في Entity Framework Core. آمل أن تكون المادة مفيدة في فهم كيفية تطبيق عناصر التصميم المُحرك بالمجال في الممارسة العملية.
استكمال كود المشروع للمصدر الشخصي -
جيثبلا تكشف المادة عن مشكلة التفاعل مع كائنات القيمة الاختيارية (الفارغة) - إذا لم يكن PersonalName أو Age من خصائص الشخص. أردت أن أصف هذا في هذا المقال ، لكنه خرج بالفعل محملاً إلى حد ما. إذا كان هناك اهتمام بهذه المشكلة - اكتب التعليقات ، فستكون المتابعة مستمرة.
لمحبي "البنى الجميلة" بشكل عام والتصميم المدفوع بالمجال بشكل خاص ، أوصي بشدة بمورد
Enterprise Craftmanship .
هناك العديد من المقالات المفيدة حول البناء الصحيح للعمارة وأمثلة التنفيذ على .Net. تم استعارة بعض الأفكار ، وتم تنفيذها بنجاح في مشاريع "قتالية" وانعكست جزئيًا في هذه المقالة.
كما تم استخدام الوثائق الرسمية
للأنواع المملوكة وتحويلات القيمة .