[Code Refactoring#1] หยุดใช้ Model Callbacks ซะ! ถ้าไม่อยากปวดตับ

Nuttavut Thongjor

ผมเชื่อว่าเพื่อนๆที่ใช้เฟรมเวิร์ก MVC ฝั่ง backend ทั้งหลายเช่น Ruby on Rails หรือ Laravel ล้วนผ่านการใช้งาน Model Callbacks กันแล้วทั้งสิ้น มันก็คือเมธอดตระกูล before_save (ก่อนบันทึกข้อมูลลงฐานข้อมูลให้ทำอะไร) หรือ after_save (หลังบันทึกข้อมูลลงฐานข้อมูลแล้วจะให้ทำอะไรต่อ) แต่ละเฟรมเวิร์กอาจเรียก callbacks เหล่านี้ต่างกัน เช่น Laravel เรียก Model Observers ส่วนพ่อทุกสถาบันอย่าง Ruby on Rails เรียก Active Record Callbacks ไม่ว่าจะใช้ชื่ออะไรต่างสื่อถึงสิ่งเดียวกัน

ยิ่งใช้ Callbacks มากเท่าไหร่ โปรแกรมเมอร์อย่างเรายิ่งฟินเท่านั้น นี่หละสวรรค์ของการใช้เฟรมเวิร์ก อยากให้ส่งอีเมล์เพื่อยืนยันตนไปหาผู้ใช้งาน หลังสร้างบัญชีผู้ใช้ใช่ไหม? ง่ายมากยัดโค๊ดส่งอีเมล์ลงไปใน after_create ของโมเดล User ซะเลยซิ ที่เหลือก็ไปนั่งจิบกาแฟ เล่นไพ่ป๊อกกับเพื่อนโต๊ะข้างๆต่อ

ต้องบอกก่อนว่าไม่ได้ตั้งใจจะดับฝันเพื่อนๆนะครับ แต่เชื่อไหม Callbacks ในมือคุณ อาจเป็นซาตานในคราบนักบุญ ที่จะนำพาหายนะมาให้ผู้ใช้ซวยแล้วซวยอีก ยิ่งกว่าโดนตำรวจจับเพราะเล่นไพ่ป๊อกกับเพื่อนข้างโต๊ะซะอีก

หมายเหตุ ตัวอย่างโปรแกรมในบทความนี้เขียนด้วยภาษา Ruby บน Ruby on Rails ผู้อ่านไม่จำเป็นต้องสนใจว่าไวยากรณ์ทางภาษาของ Ruby เป็นอย่างไร อ่านเพื่อเอาคอนเซปต์ไปประยุกต์กับเฟรมเวิร์กที่ใช้อยู่เป็นพอครับ :)

ส่งอีเมล์ยืนยันตนเมื่อสร้างผู้ใช้งานใหม่ในระบบ

โจทย์ข้อแรกของระบบเราคือต้องอนุญาตให้สร้างผู้ใช้งานได้ สร้างยูสเซอร์เมื่อไหร่ช่วยกรุณาส่งอีเมล์ยืนยันให้ด้วยนะ

Ruby
1class User < ActiveRecord::Base
2 # after_create เป็นการบอกว่าหลังสร้าง User ในฐานข้อมูลเรียบร้อยให้ทำอะไร
3 # ในที่นี้คือให้เรียกเมธอด send_confirmation_email หลังสร้าง User เสร็จแล้ว
4 after_create :send_confirmation_email
5
6 private
7
8 def send_confirmation_email
9 # เมื่อสร้าง User ในฐานข้อมูลแล้ว
10 # ให้ส่งอีเมล์ยืนยันไปด้วย
11 Mailer.confirmation_email(self).deliver
12 end
13end

เย้ เรียบร้อย จิบกาแฟแล้วหลีสาวข้างโต๊ะต่อ~ สองนาทีต่อมาเจ้านายเดินมาบอกว่า ถ้า user คนนั้นเป็น admin ลื้อจะส่งอีเมล์หาบรรพบุรุษหรอครัช เราก็เลยต้องมาแก้โค๊ดของเราใหม่ แบบนี้

Ruby
1class User < ActiveRecord::Base
2 after_create :send_confirmation_email
3
4 private
5
6 def send_confirmation_email
7 # ส่งอีเมล์ไปเสียเถอะพี่น้อง "ยกเว้น (unless)" คนนั้นเป็น admin
8 return if user.admin?
9 Mailer.confirmation_email(self).deliver
10 end
11end

เย้ เรียบร้อย จิบกาแฟแล้วชวนสาวข้างโต๊ะเล่นไพ่ป๊อก~

เนื่องจากระบบที่คุณทำนั้นเป็นการพัฒนาใหม่หมดจากระบบเดิมที่มีอยู่ คุณจึงมีรายชื่อของผู้ใช้งานในระบบเดิมอยู่ในกำมือ อีกสองนาทีต่อมา เจ้านายเดินชิวมาหาคุณ ไอ้ user จากระบบเก่า ลื้อเพิ่มเข้ามาในระบบใหม่ แล้วลื้อจะส่งอีเมล์ยืนยันทำไมวะครัช ในเมื่อพวกเขาอยู่ในระบบมาตั้งแต่ต้นแล้ว

นั่นละฮะ ปัญหาเกิดจากเราผูก send_confirmation_email ของเราไว้กับโมเดล User ดังนั้นแล้วเมื่อไหร่ก็แล้วแต่ที่เราสร้าง User ใหม่ User เหล่านั้นทุกตัวไม่เว้นแม้แต่ admin หรือ user เก่า ก็จะโดนยัดเยียดให้ทำ send_confirmation_email ทั้งหมด เริ่มเห็นปัญหาแล้วใช่ไหมครับ!

ปัญหาของการใช้ Model Callbacks แบบผิดวิธี

Model Callbacks นั้นมีประโยชน์ แต่ตอนนี้ขอชี้หน้าด่าเพื่อความสะใจส่วนตัวก่อนครับ

Callbacks นั้นเป็นสิ่งที่เรียกว่า persistence logic หรือการทำงานที่เดี่ยวข้องกับฐานข้อมูล ส่วน send_confirmation_email ของเรานั้นเป็น business logic หรือการทำงานหลักที่เป็นความต้องการของเรา การใช้งาน callbacks จึงเป็นการผูก logic สองส่วนนี้เข้าด้วยกัน

ทุกครั้งที่เราสร้าง user เราหลีกเลี่ยงไม่ได้ที่จะต้องส่งอีเมล์ นั่นเป็นเพราะโค๊ดเราบอกว่าให้ทำสิ่งนี้ทุกครั้งหลังสร้าง user ถ้าเราต้องการหยุดพฤติกรรมที่ผูกกันแน่นเช่นนี้เราก็แค่เอา callbacks ออกไปซะ!

อีกปัญหาของการใช้ Callbacks ที่จะไม่พูดถึงไม่ได้คือ การใช้ callbacks ทำให้เราทำ unit test ยากขึ้น ลองจินตนาการดูนะครับ หากเรามีการใช้ callbacks เราจะทดสอบโปรแกรมของเราโดยปราศจากการเชื่อมต่อฐานข้อมูลไม่ได้เลย เว้นแต่จะ Mock/Stub เอาไว้ แถมยังมี logic ที่ซับซ้อนให้ทดสอบเพิ่มขึ้นอีกด้วย

หยุดความสัมพันธ์ที่ลึกซึ้งด้วยการยุติการใช้ Model Callbacks

เพื่อทำให้ตัวอย่างนี้สมบูรณ์แบบ เราจึงเลิกใช้ callbacks ดังนี้

Ruby
1# Model
2class User < ActiveRecord::Base
3 # ลาก่อยย after_create
4 def send_confirmation_email
5 return if user.admin?
6 Mailer.confirmation_email(self).deliver
7 end
8end
9
10# Controller
11class UsersController < BaseController
12 def create
13 user = User.create(user_params)
14 user.send_confirmation_email
15 # จะทำอะไรต่อก็ทำโล๊ด
16 end
17end

จากตัวอย่างข้างบนเราเปลี่ยน send_confirmation_email จาก private ให้เป็น public เพื่อให้ภายนอกเข้าถึงได้ นอกจากนั้นเรายังเลิกผูกเมธอดนี้กับ callbacks แล้ว มาดูกันซิเราได้ประโยชน์อะไรกันบ้างจากการทำแบบนี้

  • เราทราบดีว่า model ของเรามักทำ unit testing เราจึงลดความซับซ้อนของโค๊ดใน model เพื่อให้ง่ายต่อการทดสอบ
  • แต่ controller นั้นต่างออกไป เรามักทำ integration testing เราจึงมาเพิ่มความซับซ้อนให้ส่วนนี้แทนได้อย่างไม่ลังเล

โค๊ดแบบไหนควรงดใช้ Callbacks

จากตัวอย่างเพื่อนๆคงเห็นนะครับว่า User และ Mailer ที่ทำหน้าที่ส่งอีเมล์ไม่ใช่สิ่งเดียวกัน มันอยู่คนละ domain problem กันเลย user ไว้จัดการเรื่องของผู้ใช้งานระบบ ส่วน Mailer นั้นใช้จัดการงานส่งอีเมล์ เมื่อทั้งสองสิ่งไม่ได้อยู่บนพื้นฐานของปัญหาเดียวกัน เราจึงไม่ควรผูกมันเข้าด้วยกันผ่าน callbacks

ผมจึงสรุปให้เพื่อนๆหลีกเลี่ยงการผูกสองสิ่งที่ไม่มีปัญหาร่วมกัน เข้าด้วยกันผ่าน callbacks ครับ

แล้ว callbacks ควรใช้กับอะไรหละ?

มาถึงตรงนี้เพื่อนๆบางคนคงรีบเปิดคอมไปเอา callbacks ออกใหญ่เลยใช่ไหมหละ แต่ช้าก่อนครับอ่านตรงนี้ก่อนครับสำคัญมาก มีอีกหลายกรณีที่ callbacks ยังสำคัญอยู่ นั่นคือการใช้ callbacks เพื่ออัพเดทตัวเองในบางกรณี ขอให้เพื่อนๆพิจารณาตัวอย่างต่อไปนี้ดูครับ

Ruby
1class Money < ActiveRecord::Base
2 # เมธอดสำหรับเปลี่ยนค่าของเงินเป็นจำนวนบวก
3 def to_positive!
4 # เปลี่ยนให้เป็นค่าบวกผ่าน absolute (abs)
5 self.money = money.abs
6 end
7end
8
9# เรามีเงินอยู่ -99 บาท ซึ่งเป็นไปไม่ได้ที่เงินจะติดลบ
10money = Money.new(-99)
11# จึงเรียก to_positive เพื่อการันตีว่าเงินต้องเป็นบวก
12money = money.to_positive!
13# จากนั้นถึง save ได้อย่างสบายใจ
14money.save

ตัวอย่างข้างต้นเพื่อนๆเริ่มเห็นความไม่ฉลาดของคลาส Money แล้วใช่ไหมครับ แทนที่ตัวคลาสจะจัดการเองอัตโนมัติว่า ถ้าเราใส่เลขลบเข้าไปให้แปลงเป็นเลขบวกอัตโนมัติ แต่เรากลับต้องมาเรียกเมธอด to_positive! ไม่ฉลาดเอาซะเลย แถมยังเป็นการออกแบบคลาสที่ไม่ดีด้วยเพราะทำให้ภายนอกรับรู้กลไกภายในมากเกินไป

เพื่อความสบายใจของทุกคน เรามาเปลี่ยนโค๊ดของเราด้วยการใช้ callbacks กันเถอะ!

Ruby
1class Money < ActiveRecord::Base
2 # ก่อน save ช่วยแปลงเป็นบวกทีเถอะนะ ข้าขอร้องง
3 before_save :to_positive!
4
5 def to_positive!
6 self.money = money.abs
7 end
8end
9
10money = Money.new(-99)
11money.save

ไม่ยากอย่างที่คิดใช่ไหมครับเรื่องการจัดการกับ callbacks เพื่อนๆคนไหนมีข้อคิดเห็นอย่างไรพิมพ์ทิ้งไว้ล่างบทความเลยนะฮะ

สารบัญ

สารบัญ

  • ส่งอีเมล์ยืนยันตนเมื่อสร้างผู้ใช้งานใหม่ในระบบ
  • ปัญหาของการใช้ Model Callbacks แบบผิดวิธี
  • หยุดความสัมพันธ์ที่ลึกซึ้งด้วยการยุติการใช้ Model Callbacks
  • โค๊ดแบบไหนควรงดใช้ Callbacks
  • แล้ว callbacks ควรใช้กับอะไรหละ?