Service object คือ Ruby object ที่ทำ single action มัน encapsulates process ใน domain หรือ business logic ของคุณ จินตนาการว่าคุณต้องสร้าง book instance ใน library application ทั่วไป ใน plain Rails app คุณจะทำแบบนี้:
class BookController < ApplicationController def create Book.new(*args) end end นี่ถูกต้องสำหรับสิ่งง่ายๆ อย่างไรก็ตาม เมื่อ app โต คุณอาจได้ boilerplate มากมายรอบๆ:
class BookController < ApplicationController def create default_args = { genre: find_genre(), author: find_author() } Book.new(attrs.merge(default_args)) end private def find_genre // ... end def find_author // ... end end Service objects ให้คุณ abstract พฤติกรรมนี้เข้า separate class โค้ดของคุณกลายเป็น simple อีกครั้ง:
class BookController < ApplicationController def BookCreator.create_book end end ทำไมคุณต้องการ Service Objects
Rails ออกแบบมา support MVC (models, controllers, views, helpers) natively โครงสร้างนี้พอสำหรับ simple applications อย่างไรก็ตาม เมื่อ application โต คุณอาจเริ่มเห็น domain/business logic متناثرไปทั่ว models และ controllers Logic เหล่านี้ไม่ได้เป็นของ controller หรือ model ทำให้โค้ดยากต่อการ reuse และ maintain Rails service object เป็น pattern ที่ช่วยแยก business logic ออกจาก controllers และ models ทำให้ models เป็นแค่ data layers และ controller เป็น entry point ของ API
เราได้ประโยชน์มากมายเมื่อนำ services มา encapsulate business logic รวมถึง:
Lean Rails controller - Controller รับผิดชอบแค่เข้าใจ requests และแปลง request params, sessions, cookies เป็น arguments ส่งให้ service object ทำงาน Controller แล้ว redirect หรือ render ตาม service response แม้ใน large applications controller actions ที่ใช้ service objects มักไม่เกิน 10 บรรทัด
Testable controllers - เนื่องจาก controllers lean และเป็น collaborators ของ service จึงทดสอบง่าย เพราะเราแค่ตรวจสอบว่า methods ใดใน controller ถูกเรียกเมื่อ action เกิดขึ้น
ความสามารถในการทดสอบ business process แยกต่างหาก - Services ทดสอบง่ายและเร็ว เพราะเป็น Ruby objects เล็กๆ ที่แยกจาก environment เราสามารถ stub collaborators ทั้งหมดและตรวจสอบว่า steps ใดถูก perform ภายใน service
Re-usable services - Service objects เรียกได้จาก controllers, service objects อื่นๆ, queued jobs ฯลฯ
การแยก framework และ business domain - Rails controllers เห็นแค่ services และ interact กับ domain objects ผ่านพวกมัน การลด coupling ทำให้ scalability ง่ายขึ้น โดยเฉพาะตอนย้ายจาก monolith ไป microservice Services ของคุณ extract และย้ายไป service ใหม่ได้ง่ายด้วยการปรับปรุงน้อย
การสร้าง Service Object
ก่อนอื่น สร้าง BookCreator ใหม่ในโฟลเดอร์ใหม่ app/services สำหรับ imaginary library management application:
$ mkdir app/services && touch app/services/book_creator.rb ต่อมา ให้ dump logic ทั้งหมดเข้า Ruby class ใหม่:
# app/services/book_creator.rb class BookCreator def initialize(title:, description:, author_id:, genre_id:) @title = title @description = description @author_id = author_id @genre_id = genre_id end def create_book Boook.create!( title: @title description: @description author_id: @author_id genre_id: @genre_id ) rescue ActiveRecord::RecordNotUnique => e # handle duplicate entry end end end จากนั้น เราสามารถเรียก service object ใน controller หรือที่ไหนก็ได้ใน application:
class BookController < ApplicationController def create BookCreator.new(title: params[:title], description: params[:description], author_id: params[:author_id], genre_id: params[:genre_id]).create_book end end Service Object Syntactic Sugar
เราสามารถลดความซับซ้อนของ BookCreator.new(arguments).create chain โดยเพิ่ม class method ที่ instantiate BookCreator และเรียก create method ให้เรา:
# app/services/book_creator.rb class BookCreator def initialize(title:, description:, author_id:, genre_id:) @title = title @description = description @author_id = author_id @genre_id = genre_id end def call(*args) new(*args).create_book end private def create_book Boook.create!( title: @title description: @description author_id: @author_id genre_id: @genre_id ) rescue ActiveRecord::RecordNotUnique => e # handle duplicate entry end end end ใน controller book creator สามารถเรียกได้ดังนี้:
class BookController < ApplicationController def create BookCreator.call( title: params[:title], description: params[:description], author_id: params[:author_id], genre_id: params[:genre_id]) end end เพื่อให้ code DRY (Don't Repeat Yourself) และ reuse พฤติกรรมนี้กับ service objects อื่นๆ เราสามารถ abstract call method เข้า base ApplicationService class ที่ทุก service object จะ inherit จาก:
class ApplicationService self.call(*args) new(*args).call end end ด้วยโค้ดนี้ เราสามารถ refactor BookCreator ให้ inherit จาก ApplicationService:
# app/services/book_creator.rb class BookCreator < ApplicationService def initialize(title:, description:, author_id:, genre_id:) @title = title @description = description @author_id = author_id @genre_id = genre_id end def call create_book end private def create_book # ... end end การสร้าง Service Objects ด้วย BusinessProcess Gem
ด้วย BusinessProcess gem คุณไม่ต้องสร้าง base application service class หรือ define initialize method เพราะ gem มี configurations ทั้งหมด built-in แล้ว Service object ของคุณแค่ inherit จาก BusinessProcess::Base
ใน Gemfile เพิ่ม:
gem 'business_process' แล้วรันคำสั่ง bundle ใน terminal
class BookCreator < BusinessProcess::Base # Specify requirements needs :title needs :description needs :author_id needs :genre_id # Specify process (action) def call create_book end private def create_book # ... end end แนวทางสร้าง Service Objects ที่ดี
One public method (เมธอด public เดียว)
Service object ควรทำ business action หนึ่งอย่างและทำได้ดี ดังนั้นควร expose single public method สำหรับทำนั้น เมธอดอื่นควรเป็น private และถูกเรียกโดย public method คุณเลือกชื่อ public method ได้ตามชอบ ตราบใดที่ naming consistent ทั่ว service objects ในตัวอย่างเราใช้ call ชื่อปกติอื่นๆ คือ perform และ execute
ตั้งชื่อ Service Objects ตามบทบาทที่ทำ
ชื่อ service object ควรบ่งบอกว่ามันทำอะไร มีวิธีนิยมตั้งชื่อด้วยคำลงท้าย "or" และ "er" ตัวอย่าง หาก service object ทำหน้าที่สร้าง book ให้ชื่อ BookCreator หากอ่าน book ให้ชื่อ BookReader
อย่าสร้าง instance ของ service objects โดยตรง
ใช้ abstractions เช่น syntactic sugar pattern หรือ gems เช่น BusinessProcess เพื่อลด notation ของการเรียก service objects การใช้วิธีนี้จะทำให้ลด BookCreator.new(*args).call หรือ BookCreator.new.call(*args) เป็น BookCreator.call(*args) ซึ่งสั้นกว่าและอ่านง่ายกว่า
จัดกลุ่ม Service Objects ใน Namespaces
การนำ service objects เข้า โดยเฉพาะ large application หมายถึงคุณจะโตจากหนึ่ง service object เป็นสิบๆ service objects เพื่อ improve code organization การจัดกลุ่ม common service objects เข้า namespaces เป็น good practice ใน library application ตัวอย่าง เราจะจัดกลุ่ม book-related services ไว้ด้วยกัน และ author-related services ใน namespace แยก โฟลเดอร์ structure จะเป็น:
services ├── application_service.rb └── book ├── book_creator.rb └── book_reader.rb Service objects จะเป็นแบบนี้:
# services/book/book_creator.rb module Book class BookCreator < ApplicationService ... end end # services/twitter_manager/book_reader.rb module Book class BookReader < ApplicationService ... end end การเรียกจะกลายเป็น Book::BookCreator.call(*args) and Book::BookReader.call(*args)
One responsibility per service object (หน้าที่เดียวต่อ service object)
Service object ที่ทำมากกว่าหนึ่งอย่าง ขัดกับ mindset "business action" ของ service objects โดย convention ไม่ควรมี generic service object ที่ทำ multiple actions หากต้องการ share code ระหว่าง service objects สร้าง base หรือ helper module และใช้ mixins include ใน service objects
Rescue exceptions และ raise custom exceptions
จุดประสงค์ของ service object คือ encapsulate implementation details ไว้ภายใน เช่น interactions ระหว่าง third-party services หรือ libraries หรือ database manipulation ด้วย Rails ActiveRecord หาก error เกิดขึ้น เช่น ActiveRecord::RecordNotUnique ขณะ interact กับ ActiveRecord service ต้อง rescue exception อย่างเหมาะสม Errors ไม่อนุญาตให้ propagate ขึ้น call stack หากจัดการไม่ได้ใน rescue block ให้ raise custom-defined exception ที่ specific สำหรับ service object นั้น
สรุป
Service object pattern สามารถปรับปรุง overall design ของ application ได้มาก เมื่อคุณเพิ่ม features ใหม่ จะทำให้ codebase expressive มากขึ้น ง่ายต่อการ maintain และทดสอบน้อยลง