كيف ساعدتنا DDD في بناء مراجعات جديدة في مطاعم البيتزا

في مطاعم البيتزا ، من المهم بناء نظام لإدارة المخزون والمخزون. هناك حاجة إلى النظام حتى لا تفقد المنتجات ، ولا لإجراء عمليات شطب غير ضرورية والتنبؤ بشكل صحيح بالمشتريات للشهر التالي. دور مهم في المحاسبة عن المراجعات. أنها تساعدك على التحقق من موازين الطعام والتحقق من الكمية الفعلية وما هو موجود في النظام.







تدقيق Dodo ليس مستندًا إلى الورق: يمتلك المدقق جهازًا لوحيًا ، حيث يلاحظ المدقق جميع المنتجات وينشئ التقارير. ولكن حتى عام 2020 ، تم إجراء المراجعة في مطاعم البيتزا بدقة على قطع من الورق - ببساطة لأنها كانت أسهل وأسهل بهذه الطريقة. هذا ، بالطبع ، أدى إلى بيانات غير دقيقة وأخطاء وخسائر - يرتكب الناس أخطاء ، وتُفقد قطع الورق ، وهناك الكثير. قررنا حل هذه المشكلة وتحسين طريقة الجهاز اللوحي. قرر التنفيذ استخدام DDD. كيف فعلنا ذلك ، سنخبرك أكثر.



أولاً ، باختصار حول العمليات التجارية من أجل فهم السياق. دعونا ننظر في مخطط حركة المنتجات ، وأين توجد المراجعات فيه ، ثم ننتقل إلى التفاصيل الفنية ، والتي سيكون هناك الكثير منها.



مخطط حركة المنتجات وسبب الحاجة إلى المراجعة



يوجد أكثر من 600 مطعم بيتزا في شبكتنا (وسيستمر هذا العدد في النمو). كل يوم هناك حركة للمواد الخام في كل منها: من إعداد وبيع المنتجات ، وشطب المكونات حسب تاريخ انتهاء الصلاحية ، إلى نقل المواد الخام إلى مطاعم البيتزا الأخرى في السلسلة. يحتوي رصيد مطعم البيتزا باستمرار على حوالي 120 عنصرًا ضروريًا لإنتاج المنتجات ، بالإضافة إلى الكثير من المواد الاستهلاكية والمواد المنزلية والمواد الكيميائية للحفاظ على نظافة مطعم البيتزا. كل هذا يتطلب "محاسبة" لمعرفة أي المواد الخام متوفرة بكثرة وأيها ناقص. 



تصف "المحاسبة" أي حركة للمواد الخام في مطاعم البيتزا. التسليم زائد في الميزانية العمومية ، والشطب هو ناقص. على سبيل المثال ، عندما نطلب بيتزا ، يقبل أمين الصندوق الأمر ويرسله للمعالجة. ثم تُلف العجينة وتُحشى بالمكونات مثل الجبن وصلصة الطماطم والبيبروني. كل هذه المنتجات تدخل حيز الإنتاج - يتم شطبها. أيضًا ، يمكن أن يحدث الشطب عند انتهاء تاريخ انتهاء الصلاحية.



نتيجة لعمليات التسليم والشطب ، تتشكل "أرصدة المستودعات". هذا تقرير يعكس كمية المواد الخام الموجودة في الميزانية العمومية بناءً على العمليات في نظام المعلومات. كل هذا هو "ميزان التسوية". ولكن هناك "قيمة فعلية" - مقدار المواد الخام الموجودة بالفعل في المخزون الآن.



التنقيحات



لحساب القيمة الفعلية ، يتم استخدام "المراجعات" (وتسمى أيضًا "المخزونات"). 



تساعد عمليات التدقيق في حساب كمية المواد الخام للمشتريات بدقة. ستؤدي عمليات الشراء الكثيرة جدًا إلى تجميد رأس المال العامل ، وستزداد مخاطر شطب المنتجات الزائدة ، مما يؤدي أيضًا إلى خسائر. ليس فقط وجود فائض من المواد الخام أمر خطير ، ولكن أيضًا نقصها - يمكن أن يؤدي ذلك إلى توقف إنتاج بعض المنتجات ، مما يؤدي إلى انخفاض في الإيرادات. تساعد عمليات التدقيق في معرفة مقدار الربح الذي تخسره الشركة بسبب الخسائر المسجلة وغير المحسوبة من المواد الخام ، والعمل على تقليل التكاليف.



تشارك المراجعات بياناتها مع إيلاء الاعتبار الواجب لمزيد من المعالجة ، على سبيل المثال ، إعداد التقارير.



مشاكل في عملية المراجعة ، أو كيفية عمل المراجعات القديمة



المراجعات عملية شاقة. يستغرق الكثير من الوقت ويتكون من عدة مراحل: عد وإصلاح بقايا المواد الخام ، وتلخيص نتائج المواد الخام حسب مناطق التخزين ، وإدخال النتائج في نظام معلومات Dodo IS.



في السابق ، تم إجراء عمليات التدقيق باستخدام قلم وورقة ، حيث توجد قائمة بالمواد الخام. عند تلخيص النتائج يدويًا وتسويتها ونقلها إلى Dodo IS ، هناك احتمال لارتكاب خطأ. في مراجعة كاملة ، يتم حساب أكثر من 100 نوع من المواد الخام ، وغالبًا ما يتم إجراء الحساب نفسه في وقت متأخر من المساء أو في الصباح الباكر ، مما قد يؤثر على التركيز.



كيفية حل هذه المشكلة



يعمل فريق لعبة الخيوط لدينا على تطوير المحاسبة في مطاعم البيتزا. قررنا إطلاق مشروع يسمى "جهاز المدقق اللوحي" ، والذي من شأنه تبسيط عملية تدقيق مطاعم البيتزا. قررنا أن نفعل كل شيء في نظام المعلومات الخاص بنا Dodo IS ، حيث يتم تنفيذ المكونات الرئيسية للمحاسبة ، لذلك لا نحتاج إلى تكامل مع أنظمة الطرف الثالث. بالإضافة إلى ذلك ، ستتمكن جميع دول وجودنا من استخدام الأداة دون اللجوء إلى عمليات تكامل إضافية.



حتى قبل بدء العمل في المشروع ، ناقشنا في الفريق الرغبة في تطبيق DDD عمليًا. لحسن الحظ ، قام أحد المشاريع بالفعل بتطبيق هذا النهج بنجاح ، لذلك كان لدينا مثال يمكنك إلقاء نظرة عليه - هذا هو مشروع " مكتب النقدية ".



في هذه المقالة ، سأتحدث عن أنماط DDD التكتيكية التي استخدمناها في التطوير: المجاميع والأوامر وأحداث المجال وخدمة التطبيق وتكامل السياقات المحدودة. لن نصف الأنماط والأساسيات الإستراتيجية لـ DDD ، وإلا ستكون المقالة طويلة جدًا. لقد تحدثنا بالفعل عن هذا في مقال " ما الذي يمكنك تعلمه عن التصميم المستند إلى المجال في 10 دقائق؟ "



نسخة جديدة من المراجعات



قبل البدء في التدقيق ، عليك أن تعرف بالضبط ما يجب حسابه. لهذا نحن بحاجة إلى قوالب المراجعة . يتم تكوينها بواسطة دور "مدير المكتب". نموذج المراجعة هو كيان InventoryTemplate. يحتوي على الحقول التالية:



  • معرف القالب

  • معرف مطعم البيتزا ؛

  • اسم القالب؛

  • فئة المراجعة: شهرية ، أسبوعية ، يومية ؛

  • الوحدات.

  • مناطق التخزين والمواد الخام في منطقة التخزين هذه 



بالنسبة لهذا الكيان ، تم تنفيذ وظيفة CRUD ولن نتناولها بالتفصيل.



بمجرد أن يكون لدى المدقق قائمة بالقوالب ، يمكنه بدء التدقيق . يحدث هذا عادة عند إغلاق مطعم البيتزا. في هذه اللحظة ، لا توجد طلبات والمواد الخام لا تتحرك - يمكنك الحصول على بيانات موثوقة عن الأرصدة.



عند بدء التدقيق ، يختار المدقق منطقة ، على سبيل المثال ثلاجة ، ويذهب لعد المواد الخام هناك. في الثلاجة يرى 5 عبوات من الجبن ، كل منها 10 كجم ، يدخل 10 كجم * 5 في الآلة الحاسبة ، ويضغط على "أدخل المزيد". ثم لاحظ حزمتين أخريين على الرف العلوي ، ثم نقر على "إضافة". نتيجة لذلك ، حصل على قياسين - 50 و 20 كجم لكل منهما.



القياسنحن نسمي كمية المواد الخام التي أدخلها المفتش في منطقة معينة ، ولكن ليس بالضرورة الإجمالي. يمكن للمفتش إدخال قياسين لكل كيلوغرام واحد أو كيلوغرامين فقط في قياس واحد - أي مجموعة يمكن أن تكون كذلك. الشيء الرئيسي هو أن المدقق نفسه يجب أن يكون واضحًا.





واجهة الآلة الحاسبة.



لذلك ، خطوة بخطوة ، يأخذ المدقق في الاعتبار جميع المواد الخام في غضون ساعة إلى ساعتين ، ثم يكمل التدقيق.



خوارزمية الإجراءات بسيطة للغاية:



  • يمكن للمدقق بدء التدقيق ؛

  • يمكن للمراجع إضافة قياسات في المراجعة التي بدأت ؛

  • يمكن للمدقق إكمال التدقيق.



يتم تشكيل متطلبات العمل للنظام من هذه الخوارزمية.



تنفيذ الإصدار الأول من التجميع وأوامر وأحداث المجال



أولاً ، دعنا نحدد المصطلحات المضمنة في مجموعة قوالب DDD التكتيكية. سوف نشير إليهم في هذه المقالة.



قوالب DDD التكتيكية



التجميع هو مجموعة من كائنات القيمة والكيانات. الكائنات في الكتلة هي كيان واحد من حيث تعديل البيانات. يحتوي كل تجميع على عنصر جذر يتم من خلاله الوصول إلى الكيانات والقيم. لا ينبغي تصميم الوحدات كبيرة جدًا. سوف يستهلكون الكثير من الذاكرة ، وتقل احتمالية إتمام الصفقة بنجاح.



الحد الإجمالي هو مجموعة من العناصر التي يجب أن تكون متسقة في صفقة واحدة: يجب مراعاة جميع الثوابت داخل هذه المجموعة.



الثوابت هي قواعد عمل لا يمكن أن تكون غير متسقة.



أمرهو نوع من العمل على الوحدة. نتيجة لهذا الإجراء ، يمكن تغيير حالة التجميع ، ويمكن إنشاء واحد أو أكثر من أحداث المجال.



حدث المجال هو إشعار بتغيير في حالة التجميع اللازمة للحفاظ على الاتساق. يضمن التجميع اتساق المعاملات: يجب تغيير جميع البيانات هنا والآن. يضمن الاتساق الناتج الاتساق على المدى الطويل - ستتغير البيانات ، ولكن ليس هنا والآن ، ولكن بعد فترة زمنية غير محددة. يعتمد هذا الفاصل الزمني على العديد من العوامل: ازدحام قوائم انتظار الرسائل ، واستعداد الخدمات الخارجية لمعالجة هذه الرسائل ، والشبكة.



عنصر الجذرهو كيان بمعرف عالمي فريد. يمكن أن يكون للعناصر الفرعية هوية محلية فقط ضمن مجموعة كاملة. يمكنهم الرجوع إلى بعضهم البعض ويمكنهم فقط الرجوع إلى عنصر الجذر الخاص بهم.



الفرق والأحداث



دعنا نصف متطلبات العمل كفريق. الأوامر هي فقط DTOs مع الحقول الوصفية.



يحتوي الأمر "إضافة قياس" على الحقول التالية:



  • قيمة القياس - يمكن أن تكون كمية المواد الخام في وحدة قياس معينة لاغية إذا تم حذف القياس ؛

  • الإصدار - يمكن تحرير القياس ، لذلك هناك حاجة إلى إصدار ؛

  • معرف المواد الخام

  • وحدة القياس: كجم / جم ، لتر / مل ، قطع ؛

  • معرف منطقة التخزين.



قياس إضافة كود الأمر
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; }
}




نحتاج أيضًا إلى حدث سينتج عن تنفيذ هذه الأوامر. نحتفل بالحدث بواجهة IPublicInventoryEvent- سنحتاجها للتكامل مع المستهلكين الخارجيين في المستقبل.



في حالة "القياس" ، تكون الحقول هي نفسها الموجودة في الأمر "إضافة قياس" ، باستثناء أن الحدث يخزن أيضًا معرف الوحدة التي حدث فيها وإصدارها.



رمز الحدث "مجمد"
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; }
}




عندما وصفنا الأوامر والأحداث ، يمكننا تنفيذ التجميع Inventory.



تنفيذ حاصل الجرد





جرد الرسم البياني الكلي UML.



النهج هو: بداية المراجعة تبدأ في إنشاء التجميع Inventory، ولهذا نستخدم طريقة المصنع Createونبدأ المراجعة بالأمر StartInventoryCommand.



كل أمر يغير حالة التجميع ويحفظ الأحداث في القائمة changes، والتي سيتم إرسالها إلى التخزين للتسجيل. أيضًا ، بناءً على هذه التغييرات ، سيتم إنشاء أحداث للعالم الخارجي.



عندما Inventoryيتم إنشاء التجميع ، يمكننا استعادته لكل طلب لاحق لتغيير حالته.



  • changesيتم تخزين التغييرات ( ) منذ آخر مرة تم فيها استعادة الوحدة.

  • تتم استعادة الحالة من خلال طريقة Restoreتقوم بتشغيل جميع الأحداث السابقة ، مرتبة حسب الإصدار ، في المثيل الحالي للتجميع Inventory.



هذا هو تنفيذ الفكرة Event Sourcingداخل الوحدة. Event Sourcingسنتحدث عن كيفية تنفيذ الفكرة في إطار المستودع بعد قليل. يوجد توضيح جميل من كتاب فوغن فيرنون:





تتم استعادة حالة الوحدة بتطبيق الأحداث بالترتيب الذي تحدث به.



ثم يتم إجراء العديد من القياسات بواسطة الفريق AddMeasurementCommand. التدقيق ينتهي بأمر FinishInventoryCommand. يقوم المجموع بالتحقق من حالته في طرق التحوير لتتوافق مع ثوابتها.



من المهم ملاحظة أن الوحدة Inventoryمزودة بإصدارات كاملة ، بالإضافة إلى كل قياس. القياسات أكثر صعوبة - عليك حل التعارضات في طريقة معالجة الحدث When(MeasurementEvent e). في الكود ، سأعرض فقط معالجة الأمر AddMeasurementCommand.



إجمالي كود الجرد
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
}




عند وقوع حدث "القياس" ، يتم التحقق من وجود قياس موجود بهذا المعرف. إذا لم يكن الأمر كذلك ، فسيتم إضافة قياس جديد.



إذا كان الأمر كذلك ، يلزم إجراء فحوصات إضافية:



  • لا يمكنك تحرير قياس عن بعد ؛

  • يجب أن يكون الإصدار الوارد أكبر من الإصدار السابق.



إذا تم استيفاء الشروط ، فيمكننا تعيين قيمة جديدة ونسخة جديدة للقياس الحالي. إذا كان الإصدار أصغر ، فهذا تعارض. لهذا ، فإننا نطرح استثناء MeasurementConcurrencyException. إذا تطابق الإصدار والقيم مختلفة ، فهذه أيضًا حالة تعارض. حسنًا ، إذا تطابق الإصدار والقيمة ، فلن تحدث أي تغييرات. مثل هذه الحالات عادة لا تنشأ.



يحتوي كيان "القياس" على نفس الحقول تمامًا مثل أمر "إضافة قياس".



رمز الكيان "مجمّد"
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; }
}




يتضح استخدام طرق التجميع العام بشكل جيد من خلال اختبارات الوحدة.



كود اختبار الوحدة "إضافة قياس بعد بدء المراجعة"
[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
    });
}




وضع كل ذلك معًا: الأوامر ، الأحداث ، إجمالي المخزون





دورة الحياة الإجمالية للمخزون عند تشغيل إنهاء الجرد.



يوضح الرسم التخطيطي عملية معالجة الأوامر FinishInventoryCommand. قبل المعالجة ، من الضروري استعادة حالة الوحدة Inventoryفي وقت تنفيذ الأمر. للقيام بذلك ، نقوم بتحميل جميع الأحداث التي تم إجراؤها على هذه الوحدة في الذاكرة وتشغيلها (ص 1). 



في وقت الانتهاء من المراجعة ، لدينا بالفعل الأحداث التالية - بداية المراجعة وإضافة ثلاثة قياسات. هذه الأحداث ظهرت نتيجة لمعالجة الأمر StartInventoryCommandو AddMeasurementCommand، وفقا لذلك. في قاعدة البيانات ، يحتوي كل صف في الجدول على معرف المراجعة وإصدارها ونصها للحدث نفسه.



في هذه المرحلة ، نقوم بتنفيذ الأمرFinishInventoryCommand(ص 2). سيتحقق هذا الأمر أولاً من صحة الحالة الحالية للوحدة - أن المراجعة في حالة InProgress، ثم سينشئ تغييرًا جديدًا للحالة عن طريق إضافة حدث FinishInventoryEventإلى القائمة changes(العنصر 3).



عند اكتمال الأمر ، سيتم حفظ جميع التغييرات في قاعدة البيانات. نتيجة لذلك ، سيظهر سطر جديد مع الحدث FinishInventoryEventوأحدث إصدار للوحدة في قاعدة البيانات (ص 4).



النوع Inventory(مراجعة) - العنصر التجميعي والجذر فيما يتعلق بالكيانات المتداخلة. وهكذا ، فإن النوع Inventoryيحدد حدود الوحدة. تتضمن الحدود الإجمالية قائمة بالكيانات من النوع Measurement(القياس) ، وقائمة بجميع الأحداث التي تم إجراؤها على التجميع ( changes).



تنفيذ الميزة بأكملها



نعني بالميزات تنفيذ متطلبات عمل محددة. في مثالنا ، سننظر في ميزة إضافة قياس. لتنفيذ الميزة ، نحتاج إلى فهم مفهوم "خدمة التطبيق" ( ApplicationService).



خدمة التطبيق هي عميل مباشر لنموذج المجال. تضمن خدمات التطبيقات المعاملات عند استخدام قاعدة بيانات ACID ، مما يضمن الحفاظ على انتقالات الحالة الذرية. بالإضافة إلى ذلك ، تعالج خدمات التطبيقات أيضًا المخاوف الأمنية.



لدينا بالفعل وحدةInventory... لتنفيذ الميزة بالكامل ، سنستخدم خدمة التطبيق بالكامل. في ذلك ، تحتاج إلى التحقق من وجود جميع الكيانات المتصلة ، وكذلك حقوق الوصول للمستخدم. فقط بعد استيفاء جميع الشروط ، يمكن حفظ الحالة الحالية للوحدة وإرسال الأحداث إلى العالم الخارجي. لتنفيذ خدمة التطبيق ، نستخدم MediatR.



رمز الميزة "إضافة القياس"
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");
            }
    }
}




مصادر الحدث



أثناء التنفيذ ، قررنا اختيار نهج ES لعدة أسباب:



  • لدى Dodo أمثلة على الاستخدام الناجح لهذا النهج.

  • يجعل ES من السهل فهم المشكلة أثناء وقوع حادث - يتم تخزين جميع إجراءات المستخدم.

  • إذا اتبعت النهج التقليدي ، فلن تتمكن من الانتقال إلى ES.



فكرة التنفيذ بسيطة للغاية - نضيف كل الأحداث الجديدة التي ظهرت نتيجة أوامر إلى قاعدة البيانات. لاستعادة التجميع ، نتلقى جميع الأحداث ونشغلها على سبيل المثال. من أجل عدم الحصول على مجموعة كبيرة من الأحداث في كل مرة ، نقوم بإزالة الحالات في كل N من الأحداث وتشغيل بقية هذه اللقطة.



معرّف المتجر الإجمالي للمخزون
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; }
}




بعد عدة أشهر من التشغيل ، أدركنا أنه ليس لدينا حاجة كبيرة لتخزين جميع إجراءات المستخدم على مثيل الوحدة. لا تستخدم الشركة هذه المعلومات بأي شكل من الأشكال. ومع ذلك ، هناك عبء في الحفاظ على هذا النهج. بعد تقييم جميع الإيجابيات والسلبيات ، نخطط للابتعاد عن ES إلى النهج التقليدي - لاستبدال العلامة Eventsبـ Inventoriesو Measurements.



التكامل مع السياقات الخارجية المحدودة



هذه هي الطريقة التي يتفاعل بها السياق المحدود Inventoryمع العالم الخارجي.





تفاعل سياق المراجعة مع سياقات أخرى. يوضح الرسم التخطيطي السياقات والخدمات وانتمائها إلى بعضها البعض.



في حالة Auth، Inventoryو Datacatalog، هناك سياق واحد محدد لكل خدمة. تؤدي وحدة monolith عدة وظائف ، لكننا الآن مهتمون فقط بوظيفة المحاسبة في مطاعم البيتزا. بالإضافة إلى المراجعات ، تشمل المحاسبة أيضًا حركة المواد الخام في مطاعم البيتزا: الإيصالات والتحويلات والمشطوبات.



HTTP



الخدمة Inventoryتتفاعل مع Authعبر HTTP. بادئ ذي بدء ، يواجه المستخدم Auth، مما يدفع المستخدم إلى اختيار أحد الأدوار المتاحة له.



  • يقوم النظام بدور "المدقق" الذي يختاره المستخدم أثناء المراجعة.

  • .

  • .



في المرحلة الأخيرة ، يمتلك المستخدم رمزًا مميزًا من Auth. تحتاج خدمة المراجعة إلى التحقق من هذا الرمز المميز ، لذا فهي تطلب Authالتحقق. Authسيتحقق مما إذا كان عمر الرمز المميز قد انتهى ، وما إذا كان ملكًا للمالك ، أو ما إذا كان لديه حقوق الوصول الضرورية. إذا كان كل شيء على ما يرام ، فإنه Inventoryيحفظ الطوابع في ملفات تعريف الارتباط - معرف المستخدم ، وتسجيل الدخول ، ومعرف مطعم البيتزا ، ويضبط عمر ملف تعريف الارتباط.



ملاحظة . Authوصفنا بمزيد من التفصيل كيفية عمل الخدمة في مقالة "التفاصيل الدقيقة للترخيص: نظرة عامة على تقنية OAuth 2.0 ". يتفاعل مع



الخدمات الأخرى من Inventoryخلال قوائم انتظار الرسائل. تستخدم الشركة RabbitMQ كوسيط للرسائل ، بالإضافة إلى الرابط أعلاه - MassTransit.



RMQ: الأحداث المستهلكة



خدمة الدليل - Datacatalog- ستوفر Inventoryجميع الكيانات اللازمة: المواد الخام للمحاسبة ، والبلدان ، والأقسام ومطاعم البيتزا.



دون الخوض في تفاصيل البنية التحتية ، سأصف الفكرة الأساسية لاستهلاك الأحداث. من جانب خدمة الدليل ، كل شيء جاهز بالفعل لنشر الأحداث ، دعنا نلقي نظرة على مثال كيان المواد الخام.



رمز عقد حدث كتالوج البيانات
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
    }
}




تم نشر هذا المنشور في exchange. يمكن لكل خدمة إنشاء حزمة خاصة بها exchange-queueلاستهلاك الأحداث.





مخطط نشر حدث واستهلاكه من خلال بدائل RMQ.



في النهاية ، هناك قائمة انتظار لكل كيان يمكن للخدمة الاشتراك فيه. كل ما تبقى هو حفظ النسخة الجديدة في قاعدة البيانات.



رمز مستهلك الحدث من 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: نشر الأحداث



يستهلك الجزء المحاسبي من monolith البيانات Inventoryلدعم باقي الوظائف التي تتطلب بيانات المراجعة. تم تمييز جميع الأحداث التي نريد إخطار الخدمات الأخرى بها بالواجهة IPublicInventoryEvent. عند حدوث حدث من هذا النوع ، فإننا نعزلهم عن سجل التغيير ( changes) ونرسلهم إلى قائمة انتظار الإرسال. لهذا ، يتم استخدام جدولين publicqueueو publicqueue_archive.



لضمان تسليم الرسائل ، نستخدم نمطًا نسميه عادةً "قائمة الانتظار المحلية" ، مما يعني ضمنيًا Transactional outbox pattern. يتم حفظ حالة التجميع Inventoryوإرسال الأحداث إلى قائمة الانتظار المحلية في معاملة واحدة. بمجرد تنفيذ الصفقة ، نحاول على الفور إرسال رسائل إلى الوسيط.



إذا تم إرسال الرسالة ، فسيتم إزالتها من قائمة الانتظار publicqueue. إذا لم يكن الأمر كذلك ، فسيتم إجراء محاولة لإرسال الرسالة لاحقًا. ثم يستهلك المشتركون في المونوليث وخطوط البيانات الرسائل. publicqueue_archiveيخزن الجدول البيانات إلى الأبد لإعادة إرسال الأحداث بسهولة إذا كانت مطلوبة في مرحلة ما.



رمز لنشر الأحداث إلى وسيط الرسائل
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);
       }
    }
}




نرسل الأحداث إلى منوليث للتقارير. يسمح لك تقرير الخسارة والفائض بمقارنة أي مراجعتين مع بعضهما البعض. بالإضافة إلى ذلك ، هناك تقرير مهم "أرصدة المستودعات" ، والذي سبق ذكره سابقًا. 



لماذا ترسل الأحداث إلى خط أنابيب البيانات؟ كل نفس - للتقارير ، ولكن فقط على القضبان الجديدة. في السابق ، كانت جميع التقارير تعيش في كتلة متراصة ، ولكن الآن تم إزالتها. تشترك في مسؤوليتين - تخزين ومعالجة بيانات الإنتاج والبيانات التحليلية: OLTP و OLAP. هذا مهم سواء من حيث البنية التحتية والتنمية.



خاتمة



باتباع مبادئ وممارسات التصميم المستند إلى المجال ، تمكنا من بناء نظام موثوق ومرن يلبي احتياجات العمل للمستخدمين. ليس لدينا منتج لائق فحسب ، بل لدينا أيضًا كود جيد يسهل تعديله. نأمل أن يكون هناك مكان في مشروعاتك لاستخدام التصميم المستند إلى المجال.



يمكنك العثور على مزيد من المعلومات حول DDD على مجتمع DDDevotion الخاص بنا وعلى قناة DDDevotion على Youtube . يمكنك مناقشة المقال في Telegram في دردشة Dodo Engineering .



All Articles