ลดรหัส Boilerplate ด้วย Scala Macros และ Quasiquotes
เผยแพร่แล้ว: 2022-03-11ภาษา Scala ช่วยให้นักพัฒนามีโอกาสเขียนโค้ดเชิงวัตถุและโค้ดการทำงานในรูปแบบที่ชัดเจนและรัดกุม (เช่น เมื่อเทียบกับ Java เป็นต้น) คลาสเคส ฟังก์ชันระดับสูง และการอนุมานประเภทเป็นคุณสมบัติบางอย่างที่นักพัฒนา Scala สามารถใช้เพื่อเขียนโค้ดที่ดูแลรักษาง่ายกว่าและเกิดข้อผิดพลาดน้อยลง
น่าเสียดายที่โค้ด Scala นั้นไม่มีภูมิคุ้มกันต่อการสร้าง และนักพัฒนาอาจพยายามหาวิธีปรับโครงสร้างและนำโค้ดดังกล่าวกลับมาใช้ใหม่ ตัวอย่างเช่น ห้องสมุดบางแห่งบังคับให้นักพัฒนาทำซ้ำตัวเองโดยเรียก API สำหรับแต่ละคลาสย่อยของคลาสที่ปิดสนิท
แต่นั่นเป็นเรื่องจริงเท่านั้น จนกว่านักพัฒนาจะได้เรียนรู้วิธีใช้ประโยชน์จากมาโครและควอซิโควตเพื่อสร้างโค้ดซ้ำ ณ เวลารวบรวม
กรณีใช้: การลงทะเบียนตัวจัดการเดียวกันสำหรับประเภทย่อยทั้งหมดของคลาสพาเรนต์
ในระหว่างการพัฒนาระบบไมโครเซอร์วิส ฉันต้องการลงทะเบียนตัวจัดการเดียวสำหรับเหตุการณ์ทั้งหมดที่ได้รับจากคลาสใดคลาสหนึ่ง เพื่อหลีกเลี่ยงไม่ให้ทำให้เราเสียสมาธิกับเฟรมเวิร์กที่ฉันใช้โดยเฉพาะ ต่อไปนี้คือคำจำกัดความที่เข้าใจง่ายของ API สำหรับการลงทะเบียนตัวจัดการเหตุการณ์:
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } การมีตัวประมวลผลเหตุการณ์สำหรับประเภท Event ใดๆ เราสามารถลงทะเบียนตัวจัดการสำหรับคลาสย่อยของ Event ด้วยเมธอด addHandler
เมื่อพิจารณาจากลายเซ็นข้างต้น นักพัฒนาซอฟต์แวร์อาจคาดหวังว่าตัวจัดการที่ลงทะเบียนสำหรับประเภทที่กำหนดจะถูกเรียกใช้สำหรับเหตุการณ์ของประเภทย่อย ตัวอย่างเช่น ลองพิจารณาลำดับชั้นของเหตุการณ์ที่เกี่ยวข้องกับวงจรชีวิตเอนทิตี User ต่อไปนี้:
การประกาศของ 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: AST หรือ Quasiquotes?
ในการสร้าง AST เราสามารถจัดการคลาส AST หรือใช้ Scala quasiquotes การใช้คลาส AST สามารถสร้างโค้ดที่อ่านและบำรุงรักษาได้ยาก ในทางตรงกันข้าม quasiquotes ช่วยลดความซับซ้อนของโค้ดได้อย่างมาก โดยทำให้เราสามารถใช้ไวยากรณ์ที่คล้ายกับโค้ดที่สร้างขึ้นได้
เพื่อแสดงความเรียบง่าย ให้ลองใช้นิพจน์ง่าย ๆ a + 2 การสร้างสิ่งนี้ด้วยคลาส AST มีลักษณะดังนี้:
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))เราสามารถบรรลุสิ่งเดียวกันด้วย quasiquotes ด้วยไวยากรณ์ที่กระชับและอ่านง่ายยิ่งขึ้น:
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 กับ quasiquotes เพื่อแก้ไขปัญหาดังกล่าว ทำให้โค้ด Scala สะอาดและบำรุงรักษาได้
นอกจากนี้ยังมีไลบรารียอดนิยม เช่น Macwire ที่ใช้ประโยชน์จากมาโคร Scala เพื่อช่วยนักพัฒนาในการสร้างโค้ด ฉันขอสนับสนุนให้นักพัฒนา Scala ทุกคนเรียนรู้เพิ่มเติมเกี่ยวกับคุณลักษณะภาษานี้ เนื่องจากอาจเป็นทรัพย์สินที่มีค่าในชุดเครื่องมือของคุณ
