ما هذا؟
بعد العمل في مشاريع مختلفة ، لاحظت أن كل واحد منهم لديه بعض المشاكل الشائعة ، بغض النظر عن المجال ، الهندسة المعمارية ، اصطلاح الكود وما إلى ذلك. لم تكن تلك المشاكل صعبة ، إنها مجرد روتين شاق: التأكد من أنك لم تفوت أي شيء غبي وواضح. بدلاً من القيام بهذا الروتين يوميًا ، أصبحت مهووسًا بالبحث عن حل: بعض نهج التطوير أو اصطلاح الكود أو أي شيء من شأنه أن يساعدني في تصميم مشروع بطريقة تمنع حدوث تلك المشاكل ، لذلك يمكنني التركيز على الأشياء المثيرة للاهتمام . هذا هو الهدف من هذه المقالة: لوصف هذه المشكلات وإظهار هذا المزيج من الأدوات والأساليب التي وجدتها لحلها.
المشاكل التي نواجهها
أثناء تطوير البرامج ، نواجه العديد من الصعوبات على طول الطريق: المتطلبات غير الواضحة وسوء الفهم وسوء عملية التطوير وما إلى ذلك.
نواجه أيضًا بعض الصعوبات الفنية: يعمل الرمز القديم على إبطاء نشاطنا ، والتوسع أمر صعب ، وبعض القرارات السيئة التي اتخذناها في الماضي تثير علينا اليوم.
يمكن التخلص منها جميعًا ، ثم تقليلها بشكل كبير ، ولكن هناك مشكلة أساسية واحدة لا يمكنك فعل شيء حيالها: تعقيد نظامك.
إن فكرة النظام الذي تقوم بتطويره بنفسه معقدة دائمًا ، سواء فهمت ذلك أم لا.
حتى عند قيامك بإنشاء تطبيق CRUD آخر ، هناك دائمًا بعض الحالات المتقدمة وبعض الأشياء الصعبة ، ومن وقت لآخر يسأل أحدهم "مهلا ، ماذا سيحدث إذا قمت بذلك وهذا في ظل هذه الظروف؟" وأنت تقول "جلالة الملك ، هذا سؤال جيد للغاية".
تلك الحالات الصعبة والمنطق المشبوه والتحقق من الصحة وإدارة الوصول - كل ما يضيف إلى فكرتك الكبيرة.
غالبًا ما تكون هذه الفكرة كبيرة جدًا بحيث لا تتناسب مع رأس واحد ، وهذه الحقيقة وحدها تجلب مشاكل مثل سوء الفهم.
لكن لنكن كرماء ونفترض أن هذا الفريق من خبراء المجال ومحللي الأعمال يتواصلون بوضوح وينتجون متطلبات جيدة ومتسقة.
الآن يتعين علينا تنفيذها ، للتعبير عن هذه الفكرة المعقدة في نظامنا. الآن أصبح هذا الرمز نظامًا آخر ، بطريقة أكثر تعقيدًا من الفكرة الأصلية التي وضعناها في الاعتبار.
كيف ذلك؟ يواجه الواقع: القيود التقنية تجبرك على التعامل مع الأحمال الكبيرة وتناسق البيانات وتوافرها بالإضافة إلى تنفيذ منطق الأعمال الفعلي.
كما ترى ، فإن المهمة صعبة للغاية ، والآن نحتاج إلى الأدوات المناسبة للتعامل معها.
لغة البرمجة هي مجرد أداة أخرى ، وكما هو الحال مع كل أداة أخرى ، فهي لا تتعلق فقط بجودتها ، بل ربما تكون أكثر حول الأداة التي تلائم المهمة. قد يكون لديك أفضل مفك براغي ، ولكن إذا كنت بحاجة إلى وضع بعض المسامير في الخشب ، فستكون المطرقة الكربي أفضل ، أليس كذلك؟
الجوانب الفنية
معظم اللغات الشعبية اليوم وجوه المنحى. عندما يقدم شخص ما مقدمة إلى OOP ، عادة ما يستخدمون الأمثلة:
النظر في سيارة ، وهو كائن من العالم الحقيقي. لديها العديد من الخصائص مثل العلامة التجارية ، الوزن ، اللون ، السرعة القصوى ، السرعة الحالية وهلم جرا.
لإظهار هذا الكائن في برنامجنا ، نجمع هذه الخصائص في فصل واحد. يمكن أن تكون الخصائص دائمة أو قابلة للتغيير ، والتي تشكل معًا الحالة الحالية لهذا الكائن وبعض الحدود التي قد تختلف فيها. ومع ذلك ، فإن الجمع بين هذه الخصائص لا يكفي ، حيث يتعين علينا التحقق من أن الحالة الحالية منطقية ، على سبيل المثال السرعة الحالية لا تتجاوز السرعة القصوى. للتأكد من أننا نعلق بعض المنطق لهذه الفئة ، قم بتمييز الخصائص كخاص لمنع أي شخص من إنشاء حالة غير قانونية.
كما ترى الأشياء تدور حول حالتها الداخلية ودورة حياتها.
لذلك فإن هذه الركائز الثلاث ل OOP لها معنى مثالي في هذا السياق: نحن نستخدم الميراث لإعادة استخدام بعض التلاعب بالحالة ، وتغليف لحماية الدولة وتعدد الأشكال لعلاج الأجسام المتشابهة بالطريقة نفسها. التداخل كإعداد افتراضي أمر منطقي أيضًا ، لأنه في هذا السياق لا يمكن أن يكون للكائن الثابت دورة حياة ولديه دائمًا حالة واحدة ، وهي ليست الحالة الأكثر شيوعًا.
الشيء عندما تنظر إلى تطبيق ويب نموذجي في هذه الأيام ، فهو لا يتعامل مع الأشياء. يحتوي كل شيء تقريبًا في الكود لدينا على مدى الحياة الأبدية أو العمر الافتراضي على الإطلاق. أكثر أنواع "الكائنات" شيوعًا هي نوع من الخدمات مثل UserService
أو EmployeeRepository
أو بعض الطرز / الكيانات / DTOs أو أي شيء تسميه. الخدمات ليس لها أي حالة منطقية بداخلها ، فهي تموت وتولد من جديد تمامًا كما نعيد إنشاء الرسم البياني للاعتماد من خلال اتصال قاعدة بيانات جديد.
ليس لدى الكيانات والنماذج أي سلوك مرتبط بها ، فهي مجرد حزم من البيانات ، ولا يساعد قابليتها للتغير ولكن العكس تمامًا.
لذلك لا تعد الميزات الرئيسية لـ OOP مفيدة في تطوير هذا النوع من التطبيقات.
ما يحدث في تطبيق ويب نموذجي هو تدفق البيانات: التحقق ، والتحويل ، والتقييم ، وما إلى ذلك. وهناك نموذج مناسب تمامًا لهذا النوع من الوظائف: البرمجة الوظيفية. وهناك دليل على ذلك: جميع الميزات الحديثة في اللغات الشعبية اليوم تأتي من هناك: async/await
، lambdas والمندوبين ، البرمجة التفاعلية ، النقابات التمييزية (التعدادات السريعة أو الصدأ ، لا يجب الخلط بينها وبين التعدادات في java أو .net. ) ، tuples - كل ما هو من FP.
على الرغم من أن هذه مجرد تدهور ، من الجيد جدًا امتلاكها ، لكن هناك المزيد والمزيد.
قبل أن أذهب إلى أي عمق ، هناك نقطة يجب توضيحها. يعد التحول إلى لغة جديدة ، خاصةً النموذج الجديد ، استثمارًا للمطورين وبالتالي للعمل. إن القيام باستثمارات حمقاء لن يعطيك شيئًا سوى المشاكل ، ولكن قد تكون الاستثمارات المعقولة هي الشيء الذي سيبقيك واقفًا.
الكثير منا يفضل اللغات ذات الكتابة الثابتة. السبب في ذلك بسيط: المحول البرمجي يعتني بالفحوصات المملة مثل تمرير المعلمات المناسبة إلى الوظائف ، وبناء كياناتنا بشكل صحيح وهلم جرا. هذه الشيكات تأتي مجانا. الآن ، بالنسبة للأشياء التي لا يستطيع المترجم التحقق منها ، لدينا خيار: الأمل في الأفضل أو إجراء بعض الاختبارات. اختبارات الكتابة تعني المال ، ولا تدفع مرة واحدة فقط لكل اختبار ، يجب عليك الاحتفاظ بها. إضافة إلى ذلك ، يصاب الناس بالذعر ، لذلك تحصل بين الحين والآخر على نتائج سلبية وإيجابية خاطئة. كلما زاد عدد الاختبارات التي يتعين عليك كتابتها ، كان متوسط جودة تلك الاختبارات. هناك مشكلة أخرى: من أجل اختبار شيء ما ، يجب أن تعرف وتذكر أن هذا الشيء يجب اختباره ، ولكن كلما كان نظامك أكبر كلما كان من السهل عليك تفويت شيء ما.
على الرغم من أن المترجم يكون جيدًا مثل نظام كتابة اللغة. إذا لم يسمح لك بالتعبير عن شيء ما بطرق ثابتة ، فعليك القيام بذلك في وقت التشغيل. وهو ما يعني الاختبارات ، نعم. لا يقتصر الأمر على نظام الكتابة على الرغم من أن ميزات بناء الجملة والسكر الصغير مهمة جدًا أيضًا ، لأننا في نهاية اليوم نرغب في كتابة أقل عدد ممكن من التعليمات البرمجية ، لذلك إذا كانت بعض الأساليب تتطلب منك كتابة عشرة أضعاف السطور ، حسنًا ، لا أحد سيستخدمها. لهذا السبب من المهم أن تحتوي اللغة التي تختارها على مجموعة مناسبة من الميزات والحيل - حسنًا ، التركيز الصحيح بشكل عام. إذا لم يحدث ذلك - فبدلاً من استخدام ميزاته لمحاربة التحديات الأصلية مثل تعقيد نظامك ومتطلباتك المتغيرة ، ستقاتل اللغة أيضًا. كل ذلك يعود إلى المال ، لأنك تدفع للمطورين وقتهم. كلما زاد عدد المشكلات التي يتعين عليهم حلها ، زاد الوقت الذي يحتاجون إليه والمزيد من المطورين الذين ستحتاجهم.
أخيرًا ، نحن على وشك رؤية بعض الكود لإثبات كل ذلك. لقد أصبحت مطورًا .NET ، لذا ستظهر نماذج الكود في C # و F # ، لكن الصورة العامة ستبدو كما هي أو أكثر في لغات OOP و FP الشائعة الأخرى.
دع الترميز يبدأ
سنعمل على إنشاء تطبيق ويب لإدارة بطاقات الائتمان.
المتطلبات الأساسية:
- إنشاء / قراءة المستخدمين
- إنشاء / قراءة بطاقات الائتمان
- تنشيط / إلغاء تنشيط بطاقات الائتمان
- حدد الحد اليومي للبطاقات
- زيادة الرصيد
- مدفوعات العملية (مع مراعاة الرصيد وتاريخ انتهاء صلاحية البطاقة والحالة النشطة / غير النشطة والحد اليومي)
من أجل البساطة ، سنستخدم بطاقة واحدة لكل حساب وسنتخطى التفويض. لكن بالنسبة للباقي ، سنعمل على إنشاء تطبيق قادر على التحقق من الصحة ومعالجة الأخطاء وقاعدة البيانات و api على الويب. لذلك دعونا ننتقل إلى مهمتنا الأولى: تصميم بطاقات الائتمان.
أولاً ، دعنا نرى كيف سيبدو في C #
public class Card { public string CardNumber {get;set;} public string Name {get;set;} public int ExpirationMonth {get;set;} public int ExpirationYear {get;set;} public bool IsActive {get;set;} public AccountInfo AccountInfo {get;set;} } public class AccountInfo { public decimal Balance {get;set;} public string CardNumber {get;set;} public decimal DailyLimit {get;set;} }
ولكن هذا لا يكفي ، يجب علينا إضافة التحقق من الصحة ، وعادة ما يتم ذلك في بعض Validator
، مثل ذلك من FluentValidation
.
القواعد بسيطة:
- رقم البطاقة مطلوب ويجب أن يكون سلسلة مكونة من 16 رقمًا.
- الاسم مطلوب ويجب أن يحتوي على حروف فقط ويمكن أن يحتوي على مسافات في المنتصف.
- الشهر والسنة يجب أن تفي بالحدود.
- يجب أن تكون معلومات الحساب موجودة عندما تكون البطاقة نشطة وتغيب عندما يتم إلغاء تنشيط البطاقة. إذا كنت تتساءل عن السبب ، فهذا بسيط: عندما يتم إلغاء تنشيط البطاقة ، فلن يكون من الممكن تغيير الرصيد أو الحد اليومي.
public class CardValidator : IValidator { internal static CardNumberRegex = new Regex("^[0-9]{16}$"); internal static NameRegex = new Regex("^[\w]+[\w ]+[\w]+$"); public CardValidator() { RuleFor(x => x.CardNumber) .Must(c => !string.IsNullOrEmpty(c) && CardNumberRegex.IsMatch(c)) .WithMessage("oh my"); RuleFor(x => x.Name) .Must(c => !string.IsNullOrEmpty(c) && NameRegex.IsMatch(c)) .WithMessage("oh no"); RuleFor(x => x.ExpirationMonth) .Must(x => x >= 1 && x <= 12) .WithMessage("oh boy"); RuleFor(x => x.ExpirationYear) .Must(x => x >= 2019 && x <= 2023) .WithMessage("oh boy"); RuleFor(x => x.AccountInfo) .Null() .When(x => !x.IsActive) .WithMessage("oh boy"); RuleFor(x => x.AccountInfo) .NotNull() .When(x => x.IsActive) .WithMessage("oh boy"); } }
الآن هناك العديد من المشكلات في هذا النهج:
- يتم فصل التحقق من الصحة عن إعلان النوع ، مما يعني أن نرى الصورة الكاملة للبطاقة التي يتعين علينا فعلاً أن نتصفحها ونعيد إنشاء هذه الصورة في رأسنا. إنها ليست مشكلة كبيرة عندما يحدث ذلك مرة واحدة فقط ، ولكن عندما يتعين علينا القيام بذلك لكل كيان في مشروع كبير ، فهو يستغرق وقتًا طويلاً للغاية.
- لا يتم فرض هذا التحقق من الصحة ، يجب أن نضع في اعتبارنا استخدامه في كل مكان. يمكننا التأكد من ذلك من خلال الاختبارات ، ولكن مرة أخرى ، يجب أن تتذكرها عند كتابة الاختبارات.
- عندما نريد التحقق من صحة رقم البطاقة في أماكن أخرى ، علينا أن نفعل الشيء نفسه من جديد. بالتأكيد ، يمكننا أن نبقي regex في مكان مشترك ، ولكن لا يزال يتعين علينا أن نسميها في كل مدقق.
في F # ، يمكننا القيام بذلك بطريقة مختلفة:
(**) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create str = match str with | (null|"") -> Error "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else Error "Card number must be a 16 digits string" (**) type CardAccountInfo = | Active of AccountInfo | Deactivated (**) type Card = { CardNumber: CardNumber Name: LetterString //
بالطبع بعض الأشياء من هنا يمكننا القيام بها في C #. يمكننا إنشاء فئة CardNumber
والتي ستلقي ValidationException
هناك أيضًا. ولكن هذه الخدعة باستخدام CardAccountInfo
لا يمكن القيام بها بطريقة C # بطريقة سهلة.
شيء آخر - جيم # تعتمد اعتمادا كبيرا على الاستثناءات. هناك العديد من المشاكل في ذلك:
- الاستثناءات لها "الانتقال إلى" دلالات. لحظة واحدة كنت هنا في هذه الطريقة ، وآخر - انتهى بك الأمر في بعض معالج العالمية.
- لا تظهر في توقيع الطريقة. تعتبر الاستثناءات مثل
ValidationException
أو InvalidUserOperationException
جزءًا من العقد ، لكنك لا تعرف ذلك حتى تقرأ التنفيذ . إنها مشكلة كبيرة ، لأنه في كثير من الأحيان يتعين عليك استخدام التعليمات البرمجية المكتوبة من قبل شخص آخر ، وبدلاً من قراءة التوقيع فقط ، عليك التنقل طوال الطريق إلى أسفل مكدس الاتصال ، والذي يستغرق الكثير من الوقت.
وهذا هو ما يزعجني: عندما أقوم بتنفيذ بعض الميزات الجديدة ، فإن عملية التنفيذ نفسها لا تستغرق الكثير من الوقت ، فمعظمها يذهب إلى شيئين:
- قراءة رمز الآخرين ومعرفة قواعد منطق الأعمال.
- التأكد من عدم كسر أي شيء.
قد يبدو كأنه عرض من أعراض تصميم رمز سيئ ، ولكن نفس الشيء يحدث حتى في المشروعات المكتوبة بشكل لائق.
حسنًا ، ولكن يمكننا تجربة استخدام نفس Result
في C #. سيبدو التنفيذ الأكثر وضوحًا كما يلي:
public class Result<TOk, TError> { public TOk Ok {get;set;} public TError Error {get;set;} }
وهي قمامة خالصة ، ولا تمنعنا من ضبط كل من " Ok
و " Error
وتسمح بتجاهل الخطأ بالكامل. سيكون الإصدار المناسب شيء مثل هذا:
public abstract class Result<TOk, TError> { public abstract bool IsOk { get; } private sealed class OkResult : Result<TOk, TError> { public readonly TOk _ok; public OkResult(TOk ok) { _ok = ok; } public override bool IsOk => true; } private sealed class ErrorResult : Result<TOk, TError> { public readonly TError _error; public ErrorResult(TError error) { _error = error; } public override bool IsOk => false; } public static Result<TOk, TError> Ok(TOk ok) => new OkResult(ok); public static Result<TOk, TError> Error(TError error) => new ErrorResult(error); public Result<T, TError> Map<T>(Func<TOk, T> map) { if (this.IsOk) { var value = ((OkResult)this)._ok; return Result<T, TError>.Ok(map(value)); } else { var value = ((ErrorResult)this)._error; return Result<T, TError>.Error(value); } } public Result<TOk, T> MapError<T>(Func<TError, T> mapError) { if (this.IsOk) { var value = ((OkResult)this)._ok; return Result<TOk, T>.Ok(value); } else { var value = ((ErrorResult)this)._error; return Result<TOk, T>.Error(mapError(value)); } } }
مرهقة جدا ، أليس كذلك؟ ولم MapError
حتى بتطبيق الإصدارات void
لـ Map
و MapError
. سيبدو الاستخدام كالتالي:
void Test(Result<int, string> result) { var squareResult = result.Map(x => x * x); }
ليس سيئا للغاية ، اه؟ حسنًا ، تخيل الآن أن لديك ثلاث نتائج وتريد أن تفعل شيئًا معهم عندما تكون جميعها على ما Ok
. مقرف. هذا بالكاد خيار.
نسخة F #:
//
في الأساس ، عليك أن تختار ما إذا كنت تكتب قدرًا معقولًا من الشفرة ، لكن الشفرة غامضة ، وتعتمد على الاستثناءات ، والتأمل ، والتعبيرات وغيرها من "السحر" ، أو تكتب رمزًا أكثر من ذلك بكثير ، وهو أمر صعب قراءته ، لكنه أكثر متانة ومباشرة إلى الأمام. عندما يصبح مثل هذا المشروع كبيرًا ، لا يمكنك محاربته ، بل بلغات مع أنظمة C # المشابهة للأنواع. دعنا نفكر في سيناريو بسيط: لديك بعض الكيان في قاعدة البيانات الخاصة بك لفترة من الوقت. اليوم تريد إضافة حقل جديد مطلوب. من الطبيعي أن تحتاج إلى تهيئة هذا الحقل في كل مكان يتم فيه إنشاء هذا الكيان ، لكن المترجم لا يساعدك على الإطلاق ، لأن الفصل قابل للتغيير ولاغية قيمة صالحة. والمكتبات مثل AutoMapper
تجعل الأمر أكثر صعوبة. تسمح لنا قابلية التحويل هذه بتهيئة الكائنات جزئيًا في مكان واحد ، ثم دفعها إلى مكان آخر ومواصلة التهيئة هناك. هذا مصدر آخر من الأخطاء.
في هذه الأثناء ، تعد مقارنة ميزات اللغة أمرًا رائعًا ، إلا أنها ليست ما تتناوله هذه المقالة. إذا كنت مهتمًا به ، فقد غطيت هذا الموضوع في مقالتي السابقة . لكن لا ينبغي أن تكون ميزات اللغة نفسها سببًا لتبديل التكنولوجيا.
وهذا يقودنا إلى هذه الأسئلة:
- لماذا نحتاج حقا إلى التحول من OOP الحديثة؟
- لماذا يجب أن نتحول إلى FP؟
الإجابة على السؤال الأول هو أن استخدام لغات OOP الشائعة للتطبيقات الحديثة يمنحك الكثير من المتاعب ، لأنها مصممة لأغراض مختلفة. ينتج عن ذلك الوقت والمال الذي تنفقه لمحاربة تصميمها جنبًا إلى جنب مع تعقيد القتال في تطبيقك.
والإجابة الثانية هي أن لغات FP تمنحك طريقة سهلة لتصميم ميزاتك بحيث تعمل مثل الساعة ، وإذا خرقت ميزة جديدة المنطق الحالي ، فإنها تخالف الشفرة ، ومن ثم تعرف ذلك فورًا.
لكن تلك الإجابات ليست كافية. كما أشار صديقي خلال إحدى مناقشاتنا ، فإن التحول إلى FP سيكون عديم الفائدة عندما لا تعرف أفضل الممارسات. أنتجت صناعتنا الكبيرة العديد من المقالات والكتب والدروس حول تصميم تطبيقات OOP ، ولدينا تجربة إنتاج مع OOP ، لذلك نحن نعرف ما يمكن توقعه من الطرق المختلفة. لسوء الحظ ، ليس هذا هو الحال بالنسبة للبرمجة الوظيفية ، لذلك حتى لو تحولت إلى FP ، فإن محاولاتك الأولى ستكون على الأرجح محرجة وبالتأكيد لن تجلب لك النتيجة المرجوة: تطوير سريع وغير مؤلم للأنظمة المعقدة.
حسنًا ، هذا بالضبط ما يدور حوله هذا المقال. كما قلت ، سنعمل على إنشاء تطبيق يشبه الإنتاج لمعرفة الفرق.
كيف نصمم التطبيق؟
الكثير من هذه الأفكار التي استخدمتها في عملية التصميم التي استعارتها من الكتاب العظيم " صنع نماذج المجال الوظيفية" ، لذلك أشجعك بشدة على قراءتها.
شفرة المصدر الكامل مع التعليقات هنا . بطبيعة الحال ، لن أضع كل ذلك هنا ، لذلك سوف أمشي فقط عبر النقاط الرئيسية.
سيكون لدينا 4 مشاريع رئيسية: طبقة الأعمال ، طبقة الوصول إلى البيانات ، البنية التحتية ، وبطبيعة الحال ، مشتركة. كل حل له ، أليس كذلك؟
نبدأ مع نمذجة مجالنا. في هذه المرحلة لا نعرف ولا نهتم بقاعدة البيانات. تم القيام به عن قصد ، لأن وجود قاعدة بيانات محددة في الاعتبار نميل إلى تصميم نطاقنا وفقًا لذلك ، نضع علاقة جدول الكيان هذه في طبقة العمل ، مما يؤدي إلى حدوث مشكلات فيما بعد. ما عليك سوى تنفيذ domain -> DAL
التعيين domain -> DAL
مرة واحدة ، في حين أن التصميم الخاطئ سوف يزعجنا باستمرار حتى النقطة التي نصلحها. إليك ما نقوم به: نقوم بإنشاء مشروع باسم CardManagement
(مبدع للغاية ، وأنا أعلم) ، <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
على الفور بتشغيل الإعداد <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
في ملف المشروع. لماذا نحتاج هذا؟ حسنًا ، سنستخدم النقابات التمييزية بشدة ، وعندما تفعل مطابقة الأنماط ، فإن المترجم يعطينا تحذيرًا ، إذا لم نغطي جميع الحالات المحتملة:
let fail result = match result with | Ok v -> printfn "%A" v //
باستخدام هذا الإعداد ، لن يتم تجميع هذا الرمز ، وهو بالضبط ما نحتاج إليه ، عندما نوسع الوظائف الحالية ونريد تعديلها في كل مكان. الشيء التالي الذي نقوم به هو إنشاء وحدة نمطية (يتم CardDomain
فئة ثابتة) CardDomain
. في هذا الملف وصفنا أنواع المجال وليس أكثر. ضع في اعتبارك أنه في F # ، يهم الكود وترتيب الملفات: افتراضيًا ، يمكنك فقط استخدام ما أعلنته سابقًا.
أنواع المجال
نبدأ في تحديد CardNumber
باستخدام CardNumber
الذي أظهرته من قبل ، على الرغم من أننا سنحتاج إلى Error
عملي أكثر من مجرد سلسلة ، لذلك سنستخدم ValidationError
.
type ValidationError = { FieldPath: string Message: string } let validationError field message = { FieldPath = field; Message = message } (**) let private cardNumberRegex = new Regex("^[0-9]{16}$", RegexOptions.Compiled) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create fieldName str = match str with | (null|"") -> validationError fieldName "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else validationError fieldName "Card number must be a 16 digits string"
ثم نحن بالطبع تحديد Card
التي هي قلب مجالنا. نحن نعلم أن البطاقة تحتوي على بعض السمات الدائمة مثل الرقم وتاريخ انتهاء الصلاحية والاسم على البطاقة وبعض المعلومات القابلة للتغيير مثل الرصيد والحد اليومي ، لذلك نقوم بتغليف هذه المعلومات القابلة للتغيير في نوع آخر:
type AccountInfo = { HolderId: UserId Balance: Money DailyLimit: DailyLimit } type Card = { CardNumber: CardNumber Name: LetterString HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo }
الآن ، هناك عدة أنواع هنا ، والتي لم نعلن عنها بعد:
نقود
يمكن أن نستخدم decimal
(وسنقوم بذلك ، ولكن لا بشكل مباشر) ، لكن decimal
أقل وصفية. علاوة على ذلك ، يمكن استخدامه لتمثيل أشياء أخرى غير الأموال ، ونحن لا نريد خلطها. لذلك نحن نستخدم نوع type [<Struct>] Money = Money of decimal
المخصص type [<Struct>] Money = Money of decimal
.
DailyLimit
يمكن ضبط الحد اليومي على مبلغ معين أو لتغيب على الإطلاق. إذا كان موجودا ، يجب أن يكون إيجابيا. بدلاً من استخدام decimal
أو Money
نحدد هذا النوع:
[<Struct>] type DailyLimit = private //
هو أكثر وصفية من مجرد الإشارة إلى أن 0M
يعني أنه لا يوجد حد ، لأنه قد يعني أيضًا أنه لا يمكنك إنفاق الأموال على هذه البطاقة. المشكلة الوحيدة هي أننا قد أخفينا المُنشئ ، ولا يمكننا القيام بمطابقة الأنماط. ولكن لا تقلق ، يمكننا استخدام أنماط نشطة :
let (|Limit|Unlimited|) limit = match limit with | Limit dec -> Limit dec | Unlimited -> Unlimited
الآن يمكننا نمط مطابقة DailyLimit
كل مكان باعتباره DU العادية.
LetterString
هذا واحد بسيط. نحن نستخدم نفس الأسلوب كما في CardNumber
. شيء واحد على الرغم من ذلك: لا تكاد LetterString
حول البطاقات الائتمانية ، بل هي شيء ويجب أن CommonTypes
في مشروع Common
في وحدة CommonTypes
. يأتي الوقت الذي ننتقل فيه ValidationError
إلى مكان منفصل أيضًا.
معرف المستخدم
هذا واحد هو مجرد اسم مستعار type UserId = System.Guid
. نستخدمها لوصف فقط.
الشهر والسنة
تلك يجب أن تذهب إلى Common
أيضا. سيصبح Month
اتحادًا unsigned int16
بطرق لتحويله من وإلى unsigned int16
، unsigned int16
Year
مثل CardNumber
ولكن بالنسبة لـ CardNumber
بدلاً من السلسلة.
الآن دعنا ننهي إعلان أنواع نطاقنا. نحتاج إلى User
لديه بعض معلومات المستخدم وجمع البطاقات ، ونحتاج إلى عمليات التوازن لعمليات الشحن والمدفوعات.
type UserInfo = { Name: LetterString Id: UserId Address: Address } type User = { UserInfo : UserInfo Cards: Card list } [<Struct>] type BalanceChange = //
جيد ، لقد صممنا أنواعنا بطريقة لا يمكن تمثيلها في حالة غير صالحة. الآن كلما تعاملنا مع مثيل لأي من هذه الأنواع ، نحن على يقين من أن البيانات الموجودة فيه صالحة وليس من الضروري التحقق من صحتها مرة أخرى. الآن يمكننا المضي قدما في منطق الأعمال!
منطق العمل
سيكون لدينا قاعدة غير قابلة للكسر هنا: سيتم ترميز كل منطق العمل في وظائف خالصة . الوظيفة البحتة هي وظيفة تستوفي المعايير التالية:
- الشيء الوحيد الذي يفعله هو يحسب قيمة الإخراج. ليس له أي آثار جانبية على الإطلاق.
- تنتج دائما نفس الناتج لنفس المدخلات.
ومن ثم ، لا تطرح الوظائف الخالصة استثناءات ، ولا تنتج قيمًا عشوائية ، ولا تتفاعل مع العالم الخارجي بأي شكل من الأشكال ، سواء أكانت قاعدة بيانات أو DateTime.Now
بسيطًا. بالطبع التفاعل مع وظيفة نجس تلقائيا يجعل استدعاء وظيفة نجس. إذن ما الذي يجب علينا تنفيذه؟
فيما يلي قائمة بالمتطلبات التي لدينا:
تنشيط / إلغاء تنشيط البطاقة
المدفوعات العملية
يمكننا معالجة الدفع إذا:
- البطاقة ليست منتهية الصلاحية
- البطاقة نشطة
- هناك ما يكفي من المال للدفع
- الإنفاقات لهذا اليوم لم تتجاوز الحد اليومي.
زيادة الرصيد
يمكننا تعبئة رصيد البطاقة النشطة وغير المنتهية الصلاحية.
تعيين الحد اليومي
يمكن للمستخدم تحديد الحد اليومي إذا كانت البطاقة غير منتهية الصلاحية ونشطة.
عندما يتعذر إكمال العملية ، يتعين علينا إرجاع خطأ ، لذلك نحتاج إلى تعريف OperationNotAllowedError
:
type OperationNotAllowedError = { Operation: string Reason: string } //
في هذه الوحدة ، يكون منطق الأعمال هو نوع الخطأ الوحيد الذي نعود إليه. نحن لا نقوم بالتحقق من هنا ، لا نتفاعل مع قاعدة البيانات - فقط ننفذ العمليات إذا كان بإمكاننا إرجاع OperationNotAllowedError
.
وحدة كاملة ويمكن الاطلاع هنا . سأدرج هنا أصعب الحالات هنا: processPayment
. يتعين علينا التحقق من انتهاء الصلاحية والحالة النشطة / غير النشطة والأموال التي يتم إنفاقها اليوم والتوازن الحالي. نظرًا لأننا لا نستطيع التفاعل مع العالم الخارجي ، يتعين علينا تمرير جميع المعلومات اللازمة كمعلمات. بهذه الطريقة سيكون هذا المنطق سهلاً للغاية ، ويسمح لك بإجراء اختبار قائم على الممتلكات .
let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) = //
هذا spentToday
- سنحتاج إلى حسابه من مجموعة BalanceOperation
بها في قاعدة البيانات. لذلك سنحتاج إلى وحدة لذلك ، والتي سيكون لها أساسًا وظيفة عامة واحدة:
let private isDecrease change = match change with | Increase _ -> false | Decrease _ -> true let spentAtDate (date: DateTimeOffset) cardNumber operations = let date = date.Date let operationFilter { CardNumber = number; BalanceChange = change; Timestamp = timestamp } = isDecrease change && number = cardNumber && timestamp.Date = date let spendings = List.filter operationFilter operations List.sumBy (fun s -> -s.BalanceChange.ToDecimal()) spendings |> Money
جيدة. الآن وبعد أن انتهينا من تنفيذ منطق الأعمال ، حان الوقت للتفكير في رسم الخرائط. يستخدم الكثير من أنواعنا نقابات تتميز بالتمييز ، وبعض أنواعنا ليس لها منشئ عام ، لذلك لا يمكننا تعريضها كما هو للعالم الخارجي. سنحتاج إلى التعامل مع (de) التسلسل. إلى جانب ذلك ، لدينا الآن سياق واحد محدود في طلبنا ، ولكن في وقت لاحق في الحياة الحقيقية قد ترغب في بناء نظام أكبر مع سياقات متعددة الحدود ، وعليهم التفاعل مع بعضهم البعض من خلال العقود العامة ، والتي ينبغي أن تكون مفهومة للجميع ، بما في ذلك لغات البرمجة الأخرى.
يتعين علينا القيام بتعيين الاتجاهين: من النماذج العامة إلى المجال والعكس بالعكس. على الرغم من أن التعيين من المجال إلى النماذج مضيق إلى الأمام ، فإن الاتجاه الآخر يحتوي على القليل من المخلل: يمكن أن تحتوي النماذج على بيانات غير صالحة ، بعد كل شيء نستخدم أنواعًا عادية يمكن تسلسلها إلى json. لا تقلق ، سيتعين علينا إنشاء التحقق من الصحة في هذا التعيين. حقيقة أننا نستخدم أنواعًا مختلفة لبيانات وبيانات ربما تكون غير صالحة ، فهذا يعني دائمًا أن هذا المترجم لن يسمح لنا بأن ننسى تنفيذ عملية التحقق من الصحة.
إليك ما يبدو عليه:
(**) type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card> let validateCreateCardCommand : ValidateCreateCardCommand = fun cmd -> (**) result { let! name = LetterString.create "name" cmd.Name let! number = CardNumber.create "cardNumber" cmd.CardNumber let! month = Month.create "expirationMonth" cmd.ExpirationMonth let! year = Year.create "expirationYear" cmd.ExpirationYear return { Card.CardNumber = number Name = name HolderId = cmd.UserId Expiration = month,year AccountDetails = AccountInfo.Default cmd.UserId |> Active } }
وحدة كاملة للتعيينات والتحقق من صحة هنا ووحدة للتعيين للنماذج هنا .
في هذه المرحلة ، لدينا تطبيق لكل منطق الأعمال والتعيينات والتحقق من الصحة وما إلى ذلك ، وكل هذا معزول تمامًا عن العالم الواقعي: إنه مكتوب في وظائف خالصة تمامًا. أنت الآن ربما تتساءل ، كيف بالضبط سنستفيد من هذا؟ لأن لدينا للتفاعل مع العالم الخارجي. أكثر من ذلك ، خلال تنفيذ سير العمل ، يتعين علينا اتخاذ بعض القرارات بناءً على نتائج تلك التفاعلات في العالم الحقيقي. لذا فإن السؤال هو كيف يمكننا تجميع كل هذا؟ في OOP يستخدمون حاويات IoC لرعاية ذلك ، ولكن هنا لا يمكننا القيام بذلك ، نظرًا لأننا لا نملك حتى كائنات ، لدينا وظائف ثابتة.
سوف نستخدم Interpreter pattern
لذلك! إنه أمر صعب بعض الشيء ، لأنه في الغالب غير مألوف ، لكنني سأبذل قصارى جهدي لشرح هذا النمط. أولاً ، دعنا نتحدث عن تكوين الوظيفة. على سبيل المثال لدينا دالة int -> string
. هذا يعني أن الدالة تتوقع int
كمعلمة وإرجاع السلسلة. الآن دعنا نقول لدينا string -> char
وظيفة أخرى string -> char
. في هذه المرحلة ، يمكننا أن نربطها بسلسلة ، أي أن ننفذ أولاً ، ونخرجه ونغذيه إلى الوظيفة الثانية ، وهناك حتى مشغل لذلك: >>
. إليك كيف تعمل:
let intToString (i: int) = i.ToString() let firstCharOrSpace (s: string) = match s with | (null| "") -> ' ' | s -> s.[0] let firstDigitAsChar = intToString >> firstCharOrSpace //
ومع ذلك ، لا يمكننا استخدام تسلسل بسيط في بعض السيناريوهات ، مثل بطاقة التنشيط. إليك سلسلة من الإجراءات:
- التحقق من صحة رقم بطاقة الإدخال. إذا كان صحيحا ، ثم
- محاولة للحصول على بطاقة من هذا الرقم. إذا كان هناك واحد
- تفعيلها.
- حفظ النتائج. إذا كان كل شيء على مايرام
- خريطة لنموذج والعودة.
أول خطوتين لها أن If it's ok then...
هذا هو السبب في أن التسلسل المباشر لا يعمل.
يمكننا ببساطة حقن هذه الوظائف كمعلمات ، مثل هذا:
let activateCard getCardAsync saveCardAsync cardNumber = ...
ولكن هناك بعض المشاكل في ذلك. أولاً ، يمكن أن ينمو عدد التبعيات الكبيرة وسيظهر توقيع الوظيفة بشكل قبيح. ثانياً ، نحن مرتبطون بتأثيرات محددة هنا: يجب أن نختار ما إذا كانت Task
أو Async
أو مكالمات مزامنة عادية فقط. ثالثًا ، من السهل createUserAsync
الأمور عندما يكون لديك العديد من الوظائف لتمريرها: على سبيل المثال ، لدى replaceUserAsync
و replaceUserAsync
نفس التوقيع ولكن له تأثيرات مختلفة ، لذلك عندما تضطر إلى اجتياز هذه المئات من المرات ، يمكنك ارتكاب خطأ بأعراض غريبة حقًا. بسبب هذه الأسباب نذهب للمترجمين الفوريين.
والفكرة هي أننا نقسم رمز التكوين الخاص بنا إلى قسمين: شجرة التنفيذ ومترجم لتلك الشجرة. كل عقدة في هذه الشجرة هي مكان لوظيفة ذات تأثير نريد getUserFromDatabase
، مثل getUserFromDatabase
. يتم تعريف هذه العقد بالاسم ، على سبيل المثال getCard
، نوع معلمة الإدخال ، على سبيل المثال CardNumber
ونوع الإرجاع ، مثل Card option
. لا نحدد هنا Task
أو Async
، فهذا ليس جزء الشجرة ، إنه جزء من المترجم الفوري . كل حافة هذه الشجرة عبارة عن سلسلة من التحولات الخالصة ، مثل التحقق من الصحة أو تنفيذ وظيفة منطق الأعمال. تحتوي الحواف أيضًا على بعض المدخلات ، مثل رقم بطاقة السلسلة الخام ، ثم هناك عملية تحقق ، والتي يمكن أن تعطينا خطأ أو رقم بطاقة صالح. إذا كان هناك خطأ ، فسوف نقطع هذه الحافة ، وإذا لم يكن الأمر كذلك ، getCard
بنا إلى العقدة التالية: getCard
. إذا كانت هذه العقدة ستعيد Some card
، فيمكننا المتابعة إلى الحافة التالية ، والتي ستكون التنشيط ، وهكذا.
لكل سيناريو مثل activateCard
أو topUp
أو topUp
سنعمل على بناء شجرة منفصلة. عندما يتم بناء تلك الأشجار ، تكون العقد الخاصة بها خالية نوعًا ما ، وليس لديها وظائف حقيقية فيها ، بل لها مكان لتلك الوظائف. هدف المترجم الشفوي هو ملء هذه العقد ، بهذه البساطة. يعرف المترجم الآثار التي نستخدمها ، على سبيل المثال Task
، ويعرف الوظيفة الحقيقية التي يجب وضعها في عقدة معينة. عندما يزور عقدة ، فإنه ينفذ الوظيفة الحقيقية المقابلة ، وينتظرها في حالة Task
أو Async
، ويمرر النتيجة إلى الحافة التالية. قد تؤدي هذه الحافة إلى عقدة أخرى ، ومن ثم فهي عمل للمترجم الفوري مرة أخرى ، حتى يصل هذا المترجم إلى عقدة الإيقاف ، أسفل العودية ، حيث نعود فقط نتيجة التنفيذ الكامل لشجرتنا.
سيتم تمثيل الشجرة بأكملها بنقابة تمييزية ، وستبدو العقدة كما يلي:
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) //
ستكون دائمًا عبارة عن tuple ، حيث يكون العنصر الأول هو مدخلات للتبعية ، والعنصر الأخير هو وظيفة ، تتلقى نتيجة هذه التبعية. هذه "المسافة" بين عناصر tuple هي المكان الذي ستناسب فيه التبعية الخاصة بك ، كما هو الحال في أمثلة التكوين هذه ، حيث لديك وظيفة 'a -> 'b
'c -> 'd
، 'c -> 'd
، وتحتاج إلى وضع عنصر آخر 'b -> 'c
بين بينهما للاتصال.
نظرًا لأننا داخل سياقنا المحصور ، فلن يكون لدينا الكثير من التبعيات ، وإذا فعلنا ذلك - فربما حان الوقت لتقسيم سياقنا إلى مستندات أصغر.
إليك ما يبدو عليه ، المصدر الكامل هنا :
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>) | CreateCard of (Card*AccountInfo) * (Result<unit, DataRelatedError> -> Program<'a>) | ReplaceCard of Card * (Result<unit, DataRelatedError> -> Program<'a>) | GetUser of UserId * (User option -> Program<'a>) | CreateUser of UserInfo * (Result<unit, DataRelatedError> -> Program<'a>) | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>) | SaveBalanceOperation of BalanceOperation * (Result<unit, DataRelatedError> -> Program<'a>) | Stop of 'a (**) let rec bind f instruction = match instruction with | GetCard (x, next) -> GetCard (x, (next >> bind f)) | GetCardWithAccountInfo (x, next) -> GetCardWithAccountInfo (x, (next >> bind f)) | CreateCard (x, next) -> CreateCard (x, (next >> bind f)) | ReplaceCard (x, next) -> ReplaceCard (x, (next >> bind f)) | GetUser (x, next) -> GetUser (x,(next >> bind f)) | CreateUser (x, next) -> CreateUser (x,(next >> bind f)) | GetBalanceOperations (x, next) -> GetBalanceOperations (x,(next >> bind f)) | SaveBalanceOperation (x, next) -> SaveBalanceOperation (x,(next >> bind f)) | Stop x -> fx (**) let stop x = Stop x let getCardByNumber number = GetCard (number, stop) let getCardWithAccountInfo number = GetCardWithAccountInfo (number, stop) let createNewCard (card, acc) = CreateCard ((card, acc), stop) let replaceCard card = ReplaceCard (card, stop) let getUserById id = GetUser (id, stop) let createNewUser user = CreateUser (user, stop) let getBalanceOperations (number, fromDate, toDate) = GetBalanceOperations ((number, fromDate, toDate), stop) let saveBalanceOperation op = SaveBalanceOperation (op, stop)
With a help of computation expressions , we now have a very easy way to build our workflows without having to care about implementation of real-world interactions. We do that in CardWorkflow module :
(**) let processPayment (currentDate: DateTimeOffset, payment) = program { (**) let! cmd = validateProcessPaymentCommand payment |> expectValidationError let! card = tryGetCard cmd.CardNumber let today = currentDate.Date |> DateTimeOffset let tomorrow = currentDate.Date.AddDays 1. |> DateTimeOffset let! operations = getBalanceOperations (cmd.CardNumber, today, tomorrow) let spentToday = BalanceOperation.spentAtDate currentDate cmd.CardNumber operations let! (card, op) = CardActions.processPayment currentDate spentToday card cmd.PaymentAmount |> expectOperationNotAllowedError do! saveBalanceOperation op |> expectDataRelatedErrorProgram do! replaceCard card |> expectDataRelatedErrorProgram return card |> toCardInfoModel |> Ok }
This module is the last thing we need to implement in business layer. Also, I've done some refactoring: I moved errors and common types to Common project . About time we moved on to implementing data access layer.
Data access layer
The design of entities in this layer may depend on our database or framework we use to interact with it. Therefore domain layer doesn't know anything about these entities, which means we have to take care of mapping to and from domain models in here. Which is quite convenient for consumers of our DAL API. For this application I've chosen MongoDB, not because it's a best choice for this kind of task, but because there're many examples of using SQL DBs already and I wanted to add something different. We are gonna use C# driver.
For the most part it's gonna be pretty strait forward, the only tricky moment is with Card
. When it's active it has an AccountInfo
inside, when it's not it doesn't. So we have to split it in two documents: CardEntity
and CardAccountInfoEntity
, so that deactivating card doesn't erase information about balance and daily limit.
Other than that we just gonna use primitive types instead of discriminated unions and types with built-in validation.
There're also few things we need to take care of, since we are using C# library:
- Convert
null
s to Option<'a>
- Catch expected exceptions and convert them to our errors and wrap it in
Result<_,_>
We start with CardDomainEntities module , where we define our entities:
[<CLIMutable>] type CardEntity = { [<BsonId>] CardNumber: string Name: string IsActive: bool ExpirationMonth: uint16 ExpirationYear: uint16 UserId: UserId } with //
Those fields EntityId
and IdComparer
we are gonna use with a help of SRTP . We'll define functions that will retrieve them from any type that has those fields define, without forcing every entity to implement some interface:
let inline (|HasEntityId|) x = fun () -> (^a : (member EntityId: string) x) let inline entityId (HasEntityId f) = f() let inline (|HasIdComparer|) x = fun () -> (^a : (member IdComparer: Quotations.Expr<Func< ^a, bool>>) x) //
As for null
and Option
thing, since we use record types, F# compiler doesn't allow using null
value, neither for assigning nor for comparison. At the same time record types are just another CLR types, so technically we can and will get a null
value, thanks to C# and design of this library. We can solve this in 2 ways: use AllowNullLiteral
attribute, or use Unchecked.defaultof<'a>
. I went for the second choice since this null
situation should be localized as much as possible:
let isNullUnsafe (arg: 'a when 'a: not struct) = arg = Unchecked.defaultof<'a> //
In order to deal with expected exception for duplicate key, we use Active Patterns again:
//
After mapping is implemented we have everything we need to assemble API for our data access layer , which looks like this:
//
The last moment I mention is when we do mapping Entity -> Domain
, we have to instantiate types with built-in validation, so there can be validation errors. In this case we won't use Result<_,_>
because if we've got invalid data in DB, it's a bug, not something we expect. So we just throw an exception. Other than that nothing really interesting is happening in here. The full source code of data access layer you'll find here .
Composition, logging and all the rest
As you remember, we're not gonna use DI framework, we went for interpreter pattern. If you want to know why, here's some reasons:
- IoC container operates in runtime. So until you run your program you can't know that all the dependencies are satisfied.
- It's a powerful tool which is very easy to abuse: you can do property injection, use lazy dependencies, and sometimes even some business logic can find it's way in dependency registering/resolving (yeah, I've witnessed it). All of that makes code maintaining extremely hard.
That means we need a place for that functionality. We could place it on a top level in our Web Api, but in my opinion it's not a best choice: right now we are dealing with only 1 bounded context, but if there's more, this global place with all the interpreters for each context will become cumbersome. Besides, there's single responsibility rule, and web api project should be responsible for web, right? So we create CardManagement.Infrastructure project .
Here we will do several things:
- Composing our functionality
- App configuration
- Logging
If we had more than 1 context, app configuration and log configuration should be moved to global infrastructure project, and the only thing happening in this project would be assembling API for our bounded context, but in our case this separation is not necessary.
Let's get down to composition. We've built execution trees in our domain layer, now we have to interpret them. Every node in that tree represents some dependency call, in our case a call to database. If we had a need to interact with 3rd party api, that would be in here also. So our interpreter has to know how to handle every node in that tree, which is verified in compile time, thanks to <TreatWarningsAsErrors>
setting. Here's what it looks like:
(**) let rec private interpretCardProgram mongoDb prog = match prog with | GetCard (cardNumber, next) -> cardNumber |> getCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetCardWithAccountInfo (number, next) -> number |> getCardWithAccInfoAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | CreateCard ((card,acc), next) -> (card, acc) |> createCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | ReplaceCard (card, next) -> card |> replaceCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetUser (id, next) -> getUserAsync mongoDb id |> bindAsync (next >> interpretCardProgram mongoDb) | CreateUser (user, next) -> user |> createUserAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetBalanceOperations (request, next) -> getBalanceOperationsAsync mongoDb request |> bindAsync (next >> interpretCardProgram mongoDb) | SaveBalanceOperation (op, next) -> saveBalanceOperationAsync mongoDb op |> bindAsync (next >> interpretCardProgram mongoDb) | Stop a -> async.Return a let interpret prog = try let interpret = interpretCardProgram (getMongoDb()) interpret prog with | failure -> Bug failure |> Error |> async.Return
Note that this interpreter is the place where we have this async
thing. We can do another interpreter with Task
or just a plain sync version of it. Now you're probably wondering, how we can cover this with unit-test, since familiar mock libraries ain't gonna help us. Well, it's easy: you have to make another interpreter. Here's what it can look like:
type SaveResult = Result<unit, DataRelatedError> type TestInterpreterConfig = { GetCard: Card option GetCardWithAccountInfo: (Card*AccountInfo) option CreateCard: SaveResult ReplaceCard: SaveResult GetUser: User option CreateUser: SaveResult GetBalanceOperations: BalanceOperation list SaveBalanceOperation: SaveResult } let defaultConfig = { GetCard = Some card GetUser = Some user GetCardWithAccountInfo = (card, accountInfo) |> Some CreateCard = Ok() GetBalanceOperations = balanceOperations SaveBalanceOperation = Ok() ReplaceCard = Ok() CreateUser = Ok() } let testInject a = fun _ -> a let rec interpretCardProgram config (prog: Program<'a>) = match prog with | GetCard (cardNumber, next) -> cardNumber |> testInject config.GetCard |> (next >> interpretCardProgram config) | GetCardWithAccountInfo (number, next) -> number |> testInject config.GetCardWithAccountInfo |> (next >> interpretCardProgram config) | CreateCard ((card,acc), next) -> (card, acc) |> testInject config.CreateCard |> (next >> interpretCardProgram config) | ReplaceCard (card, next) -> card |> testInject config.ReplaceCard |> (next >> interpretCardProgram config) | GetUser (id, next) -> id |> testInject config.GetUser |> (next >> interpretCardProgram config) | CreateUser (user, next) -> user |> testInject config.CreateUser |> (next >> interpretCardProgram config) | GetBalanceOperations (request, next) -> testInject config.GetBalanceOperations request |> (next >> interpretCardProgram config) | SaveBalanceOperation (op, next) -> testInject config.SaveBalanceOperation op |> (next >> interpretCardProgram config) | Stop a -> a
We've created TestInterpreterConfig
which holds desired results of every operation we want to inject. You can easily change that config for every given test and then just run interpreter. This interpreter is sync, since there's no reason to bother with Task
or Async
.
There's nothing really tricky about the logging, but you can find it in this module . The approach is that we wrap the function in logging: we log function name, parameters and log result. If result is ok, it's info, if error it's a warning and if it's a Bug
then it's an error. That's pretty much it.
One last thing is to make a facade, since we don't want to expose raw interpreter calls. Here's the whole thing:
let createUser arg = arg |> (CardWorkflow.createUser >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createUser") let createCard arg = arg |> (CardWorkflow.createCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createCard") let activateCard arg = arg |> (CardWorkflow.activateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.activateCard") let deactivateCard arg = arg |> (CardWorkflow.deactivateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.deactivateCard") let processPayment arg = arg |> (CardWorkflow.processPayment >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.processPayment") let topUp arg = arg |> (CardWorkflow.topUp >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.topUp") let setDailyLimit arg = arg |> (CardWorkflow.setDailyLimit >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.setDailyLimit") let getCard arg = arg |> (CardWorkflow.getCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.getCard") let getUser arg = arg |> (CardWorkflow.getUser >> CardProgramInterpreter.interpretSimple |> logifyResultAsync "CardApi.getUser")
All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here .
استنتاج
We have built an application with validation, error handling, logging, business logic — all those things you usually have in your application. The difference is this code is way more durable and easy to refactor. Note that we haven't used reflection or code generation, no exceptions, but still our code isn't verbose. It's easy to read, easy to understand and hard to break. As soon as you add another field in your model, or another case in one of our union types, the code won't compile until you update every usage. Sure it doesn't mean you're totally safe or that you don't need any kind of testing at all, it just means that you're gonna have fewer problems when you develope new features or do some refactoring. The development process will be both cheaper and more interesting, because this tool allows you to focus on your domain and business tasks, instead of drugging focus on keeping an eye out that nothing is broken.
Another thing: I don't claim that OOP is completely useless and we don't need it, that's not true. I'm saying that we don't need it for solving every single task we have, and that a big portion of our tasks can be better solved with FP. And truth is, as always, in balance: we can't solve everything efficiently with only one tool, so a good programming language should have a decent support of both FP and OOP. And, unfortunately, a lot of most popular languages today have only lambdas and async programming from functional world.