تقليل كود Boilerplate باستخدام Scala Macros و Quasiquotes

نشرت: 2022-03-11

توفر لغة Scala للمطورين الفرصة لكتابة كود وظيفي وموجّه للكائنات في صيغة نظيفة وموجزة (مقارنة بجافا ، على سبيل المثال). تعد فئات الحالة والوظائف ذات الترتيب الأعلى والاستدلال بالنوع بعض الميزات التي يمكن لمطوري Scala الاستفادة منها لكتابة التعليمات البرمجية التي يسهل الحفاظ عليها وأقل عرضة للخطأ.

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

لكن هذا صحيح فقط حتى يتعلم المطورون كيفية الاستفادة من وحدات الماكرو و quasiquotes لإنشاء التعليمات البرمجية المتكررة في وقت الترجمة.

حالة الاستخدام: تسجيل نفس المعالج لجميع الأنواع الفرعية لفئة أصل

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

 trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) }

بوجود معالج أحداث لأي نوع Event ، يمكننا تسجيل معالجات للفئات الفرعية من Event باستخدام طريقة addHandler .

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

التسلسل الهرمي لأحداث Scala تنازليًا من UserEvent. هناك ثلاثة توابع مباشرة: UserCreated (لها اسم وبريد إلكتروني ، وكلاهما سلاسل) ، و UserChanged ، و UserDeleted. علاوة على ذلك ، يحتوي UserChanged على سلالتين خاصتين به: NameChanged (له اسم ، وهو سلسلة) و EmailChanged (وجود بريد إلكتروني ، وهو عبارة عن سلسلة).
تسلسل هرمي لفئة حدث Scala.

تبدو إعلانات Scala المقابلة كما يلي:

 sealed trait UserEvent final case class UserCreated(name: String, email: String) extends UserEvent sealed trait UserChanged extends UserEvent final case class NameChanged(name: String) extends UserChanged final case class EmailChanged(email: String) extends UserChanged case object UserDeleted extends UserEvent

يمكننا تسجيل معالج لكل فئة حدث معين. ولكن ماذا لو أردنا تسجيل معالج لجميع فئات الأحداث؟ كانت محاولتي الأولى هي تسجيل المعالج لفئة UserEvent . كنت أتوقع أن يتم استدعاؤه لجميع الأحداث.

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)

لقد لاحظت أنه لم يتم استدعاء المعالج أثناء الاختبارات. لقد حفرت في كود Lagom ، الإطار الذي كنت أستخدمه.

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

 type Handler[Event] = (_ <: Event) => Unit private case class EventProcessorImpl[Event]( handlers: Map[Class[_ <: Event], List[Handler[Event]]] = Map[Class[_ <: Event], List[Handler[Event]]]() ) extends EventProcessor[Event] { override def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] = { val eventClass = implicitly[ClassTag[E]].runtimeClass.asInstanceOf[Class[_ <: Event]] val eventHandlers = handler .asInstanceOf[Handler[Event]] :: handlers.getOrElse(eventClass, List()) copy(handlers + (eventClass -> eventHandlers)) } override def process(event: Event): Unit = { handlers .get(event.getClass) .foreach(_.foreach(_.asInstanceOf[Event => Unit].apply(event))) } }

أعلاه ، قمنا بتسجيل معالج لفئة UserEvent ، ولكن كلما تم إصدار حدث مشتق مثل UserCreated ، فلن يجد المعالج فئته في التسجيل.

هكذا يبدأ كود Boilerplate

الحل هو تسجيل نفس المعالج لكل فئة حدث ملموس. يمكننا القيام بذلك على النحو التالي:

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)

الآن الكود يعمل! لكنها متكررة.

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

هذا أمر مخيب للآمال ، لأن UserEvent عبارة عن فئة مختومة ، مما يعني أن جميع الفئات الفرعية المباشرة الخاصة بها معروفة في وقت الترجمة. ماذا لو تمكنا من الاستفادة من هذه المعلومات لتجنب النموذج المعياري؟

وحدات الماكرو للإنقاذ

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

بينما قد يبدو أن واجهة macro تأخذ القيم كمعلمات ، فإن تنفيذها سوف يلتقط بالفعل شجرة بناء الجملة المجردة (AST) - التمثيل الداخلي لهيكل كود المصدر الذي يستخدمه المترجم - لتلك المعلمات. ثم يستخدم AST لإنشاء AST جديد. أخيرًا ، يحل AST الجديد محل استدعاء الماكرو في وقت الترجمة.

لنلقِ نظرة على إعلان macro الذي سينشئ تسجيل معالج الأحداث لجميع الفئات الفرعية المعروفة لفئة معينة:

 def addHandlers[Event]( processor: EventProcessor[Event], handler: Event => Unit ): EventProcessor[Event] = macro setEventHandlers_impl[Event] def setEventHandlers_impl[Event: c.WeakTypeTag](c: Context)( processor: c.Expr[EventProcessor[Event]], handler: c.Expr[Event => Unit] ): c.Expr[EventProcessor[Event]] = { // implementation here }

لاحظ أنه لكل معلمة (بما في ذلك معلمة النوع ونوع الإرجاع) ، فإن طريقة التنفيذ لها تعبير AST مطابق كمعامل. على سبيل المثال ، c.Expr[EventProcessor[Event]] مع EventProcessor[Event] . المعلمة c: Context يلتف سياق الترجمة. يمكننا استخدامه للحصول على جميع المعلومات المتاحة في وقت الترجمة.

في حالتنا ، نريد استرداد أطفال صفنا المختوم:

 import c.universe._ val symbol = weakTypeOf[Event].typeSymbol def subclasses(symbol: Symbol): List[Symbol] = { val children = symbol.asClass.knownDirectSubclasses.toList symbol :: children.flatMap(subclasses(_)) } val children = subclasses(symbol)

لاحظ الاستدعاء المتكرر لأسلوب subclasses للتأكد من معالجة الفئات الفرعية غير المباشرة أيضًا.

الآن بعد أن أصبح لدينا قائمة بفئات الأحداث المراد تسجيلها ، يمكننا إنشاء AST للرمز الذي سينشئه ماكرو Scala.

إنشاء رمز Scala: ASTs أم Quasiquotes؟

لبناء AST لدينا ، يمكننا إما التعامل مع فئات AST أو استخدام Scala quasiquotes. يمكن أن ينتج عن استخدام فئات AST تعليمات برمجية يصعب قراءتها وصيانتها. في المقابل ، تقلل quasiquotes بشكل كبير من تعقيد الكود من خلال السماح لنا باستخدام بناء جملة مشابه جدًا للشفرة التي تم إنشاؤها.

لتوضيح مكاسب البساطة ، لنأخذ التعبير البسيط a + 2 . يبدو إنشاء هذا باستخدام فئات AST كما يلي:

 val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))

يمكننا تحقيق الشيء نفسه باستخدام علامات الاقتباس ببنية أكثر إيجازًا وقابلة للقراءة:

 val exp = q"a + 2"

لإبقاء الماكرو واضحًا ، سنستخدم علامات الاقتباس.

لنقم بإنشاء AST وإعادته كنتيجة لوظيفة الماكرو:

 val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls)

يبدأ الكود أعلاه بتعبير المعالج الذي تم استلامه كمعامل ، ولكل فئة فرعية من Event ، فإنه ينشئ استدعاءًا لطريقة addHandler مع الفئة الفرعية ووظيفة المعالج كمعلمات.

الآن يمكننا استدعاء الماكرو في فئة UserEvent وسيقوم بإنشاء رمز لتسجيل المعالج لجميع الفئات الفرعية:

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)

سيؤدي ذلك إلى إنشاء هذا الرمز:

 com.example.event.processor.EventProcessor .apply[com.example.event.handler.UserEvent]() .addHandler[UserEvent](handler) .addHandler[UserCreated](handler) .addHandler[UserChanged](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted](handler)

يتم تجميع رمز المشروع الكامل بشكل صحيح وتوضح حالات الاختبار أن المعالج مسجل بالفعل لكل فئة فرعية من UserEvent . الآن يمكننا أن نكون أكثر ثقة في قدرة الكود الخاص بنا على التعامل مع أنواع الأحداث الجديدة.

كود مكرر؟ احصل على وحدات ماكرو Scala لكتابتها

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

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