ยินดีต้อนรับสู่ตอนที่ 4 ของซีรีส์ Ruby on Rails Patterns and Anti-Patterns
ก่อนหน้านี้ เราครอบคลุม Patterns และ Anti-Patterns ทั่วไป รวมถึงที่เกี่ยวกับ Rails Models และ Views ในโพสต์นี้ เราจะวิเคราะห์ส่วนสุดท้ายของ MVC (Model-View-Controller) Design Pattern — คือ Controller มาเจาะลึกและผ่าน Patterns และ Anti-Patterns ที่เกี่ยวกับ Rails Controllers กัน
อยู่ที่ Front Lines
เนื่องจาก Ruby on Rails เป็น Web Framework HTTP Requests จึงเป็นส่วนสำคัญ Clients ทุกประเภทติดต่อ Rails Backends ผ่าน Requests และนี่คือที่ที่ Controllers ส่องสว่าง Controllers อยู่ที่ Front Lines ของการรับและจัดการ Requests นั่นทำให้พวกเขาเป็นส่วนพื้นฐานของ Ruby on Rails Framework แน่นอนว่ามีโค้ดที่มาก่อน Controllers แต่ Controller Code เป็นสิ่งที่เราส่วนใหญ่ควบคุมได้
เมื่อคุณกำหนด Routes ที่ config/routes.rb คุณสามารถ Hit Server บน Route ที่ตั้งไว้ และ Controller ที่ตรงกันจะดูแลส่วนที่เหลือ การอ่านประโยคก่อนหน้านี้อาจให้ impresión ว่าทุกอย่าง Simple เพียงแค่นั้น แต่บ่อยครั้ง ภาระมากมายตกอยู่บนไหล่ Controller มีเรื่อง Authentication และ Authorization แล้วมีปัญหาเกี่ยวกับการ Fetch ข้อมูลที่ต้องการ รวมถึงที่ไหนและอย่างไรที่จะทำ Business Logic
Concerns และ Responsibilities ทั้งหมดที่อาจเกิดขึ้นภายใน Controller นำไปสู่ Anti-Patterns บางอย่าง หนึ่งในที่ 'มีชื่อเสียง' มากที่สุดคือ Anti-pattern ของ Controller ที่ "Fat" (อ้วน)
Fat (Obese) Controllers
ปัญหาของการใส่ Logic มากเกินไปใน Controller คือคุณเริ่มละเมิด Single Responsibility Principle (SRP) หมายความว่าเราทำงานมากเกินไปภายใน Controller บ่อยครั้งนำไปสู่โค้ดและ Responsibilities มากมายสะสมอยู่ที่นี่ 'Fat' ในที่นี้หมายถึงโค้ดมากใน Controller Files รวมถึง Logic ที่ Controller รองรับ มักถือว่าเป็น Anti-pattern
มีความคิดมากมายเกี่ยวกับว่า Controller ควรทำอะไร Common Ground ของ Responsibilities ที่ Controller ควรมี รวมถึง:
- Authentication และ Authorization — ตรวจสอบว่า Entity (บ่อยครั้งคือ User) ด้านหลัง Request เป็นผู้ที่มันกล่าวว่ามันคือ และว่าได้รับอนุญาตให้เข้าถึง Resource หรือทำ Action ปกติ Authentication ถูกเก็บใน Session หรือ Cookie แต่ Controller ควรตรวจสอบว่าข้อมูล Authentication ยัง Valid อยู่
- Data Fetching — ควรเรียก Logic สำหรับหาข้อมูลที่ถูกต้องตาม Parameters ที่มาพร้อม Request ในโลกที่สมบูรณ์แบบ ควรเป็นการเรียก Method เดียวที่ทำงานทั้งหมด Controller ไม่ควรทำ Heavy Work ควร Delegate ต่อไป
- Template Rendering — สุดท้าย ควร Return Response ที่ถูกต้องโดย Render ผลลัพธ์ด้วย Format ที่เหมาะสม (HTML, JSON, ฯลฯ) หรือ Redirect ไป Path หรือ URL อื่น
การปฏิบัติตามแนวคิดเหล่านี้สามารถช่วยคุณหลีกเลี่ยงการมีสิ่งต่างๆ เกิดขึ้นมากเกินไปใน Controller Actions และ Controller โดยทั่วไป การเก็บให้ Simple ที่ระดับ Controller จะช่วยให้คุณ Delegate งานไปยังพื้นที่อื่นของแอปพลิเคชัน การ Delegate Responsibilities และทดสอบทีละอย่าง จะทำให้คุณพัฒนาแอปให้ Robust
แน่นอน คุณสามารถปฏิบัติตามหลักการข้างต้น แต่คุณต้อง渴望ตัวอย่าง มาเจาะลึกดู Patterns ที่เราสามารถใช้เพื่อลดน้ำหนัก Controllers กัน
Query Objects
ปัญหาหนึ่งที่เกิดขึ้นใน Controller Actions คือ Query ข้อมูลมากเกินไป หากคุณติดตาม Blog Post ของเราเกี่ยวกับ Rails Model Anti-patterns และ Patterns เราผ่านปัญหาคล้ายกันที่ Models มี Query Logic มากเกินไป แต่คราวนี้เราจะใช้ Pattern ที่เรียกว่า Query Object Query Object เป็นเทคนิคที่แยก Complex Queries ออกเป็น Object เดียว
ในกรณีส่วนใหญ่ Query Object เป็น Plain Old Ruby Object ที่ Initialize ด้วย ActiveRecord Relation Query Object ทั่วไปอาจมีลักษณะแบบนี้:
# app/queries/all_songs_query.rb class AllSongsQuery def initialize(songs = Song.all) @songs = songs end def call(params, songs = Song.all) songs.where(published: true) .where(artist_id: params[:artist_id]) .order(:title) end end
มันถูกสร้างมาเพื่อใช้ภายใน Controller เหมือนนี้:
class SongsController < ApplicationController def index @songs = AllSongsQuery.new.call(all_songs_params) end private def all_songs_params params.slice(:artist_id) end end
คุณยังสามารถลอง Approach อีกแบบของ Query Object:
# app/queries/all_songs_query.rb class AllSongsQuery attr_reader :songs def initialize(songs = Song.all) @songs = songs end def call(params = {}) scope = published(songs) scope = by_artist_id(scope, params[:artist_id]) scope = order_by_title(scope) end private def published(scope) scope.where(published: true) end def by_artist_id(scope, artist_id) artist_id ? scope.where(artist_id: artist_id) : scope end def order_by_title(scope) scope.order(:title) end end
Approach หลังที้นี้ทำให้ Query Object Robust ขึ้นด้วยการทำให้ params Optional นอกจากนี้ สังเกตว่าเราสามารถเรียก AllSongsQuery.new.call ได้ หากคุณไม่ชอบแบบนี้ คุณสามารถใช้ Class Methods หากคุณเขียน Query Class ด้วย Class Methods มันจะไม่ใช่ 'Object' แล้ว แต่เป็นเรื่อง Personal Taste เพื่อการอธิบาย เรามาดูว่าสามารถทำให้ AllSongsQuery เรียกง่ายขึ้นในทางปฏิบัติได้อย่างไร
# app/queries/all_songs_query.rb class AllSongsQuery class << self def call(params = {}, songs = Song.all) scope = published(songs) scope = by_artist_id(scope, params[:artist_id]) scope = order_by_title(scope) end private def published(scope) scope.where(published: true) end def by_artist_id(scope, artist_id) artist_id ? scope.where(artist_id: artist_id) : scope end def order_by_title(scope) scope.order(:title) end end end
ตอนนี้ เราสามารถเรียก AllSongsQuery.call และเสร็จแล้ว เราสามารถส่ง params พร้อม artist_id ได้ นอกจากนี้ เราสามารถส่ง Initial Scope หากเราต้องการเปลี่ยนมันเพื่อบางเหตุผล หากคุณอยากหลีกเลี่ยงการเรียก new บน Query Class จริงๆ ลอง 'Trick' นี้:
# app/queries/application_query.rb class ApplicationQuery def self.call(*params) new(*params).call end end
คุณสามารถสร้าง ApplicationQuery และ Inherit จากมันใน Query Classes อื่นๆ:
# app/queries/all_songs_query.rb class AllSongsQuery < ApplicationQuery ... end
คุณยังคง AllSongsQuery.call ไว้ แต่ทำให้มัน Eleganter
สิ่งที่ยอดเยี่ยมของ Query Objects คือคุณสามารถ Test พวกเขาใน Isolation และรับรองว่าพวกเขาทำสิ่งที่ควรทำ นอกจากนี้ คุณสามารถ Extend Query Classes นี้และ Test โดยไม่ต้องกังวลมากเกี่ยวกับ Logic ใน Controller สิ่งหนึ่งที่ควรทราบคือคุณควรจัดการ Request Parameters ที่อื่น และไม่ควรพึ่งพา Query Object ให้ทำ คุณคิดยังไง จะลอง Query Object ไหม?
พร้อมที่จะ Serve
โอเค เราจัดการวิธี Delegate การรวบรวมและ Fetch ข้อมูลเข้า Query Objects แล้ว เราทำอะไรกับ Logic ที่สะสมระหว่าง Data Gathering และขั้นตอนที่เรา Render มัน? ถามได้ดี เพราะ Solution หนึ่งคือการใช้สิ่งที่เรียกว่า Services Service บ่อยครั้งถูกมองว่าเป็น PORO (Plain Old Ruby Object) ที่ Perform Single (Business) Action เราจะสำรวจแนวคิดนี้เล็กน้อยด้านล่าง
จินตนาการว่าเรามี 2 Services หนึ่งสร้าง Receipt อีกหนึ่งส่ง Receipt ให้ User เหมือนนี้:
# app/services/create_receipt_service.rb class CreateReceiptService def self.call(total, user_id) Receipt.create!(total: total, user_id: user_id) end end # app/services/send_receipt_service.rb class SendReceiptService def self.call(receipt) UserMailer.send_receipt(receipt).deliver_later end end
จากนั้น ใน Controller เราจะเรียก SendReceiptService เหมือนนี้:
# app/controllers/receipts_controller.rb class ReceiptsController < ApplicationController def create receipt = CreateReceiptService.call(total: receipt_params[:total], user_id: receipt_params[:user_id]) SendReceiptService.call(receipt) end end
ตอนนี้คุณมี 2 Services ทำงานทั้งหมด และ Controller เพียงแค่เรียกพวกเขา คุณสามารถ Test แยกกันได้ แต่ปัญหาคือ ไม่มี Connection ที่ชัดเจนระหว่าง Services ใช่ ในทฤษฎี ทั้งหมด Perform Single Business Action แต่ หากเราพิจารณา Abstraction Level จากมุมมองของ Stakeholders — มุมมองของพวกเขาเกี่ยวกับ Action ของการสร้าง Receipt เกี่ยวข้องกับการส่ง Email ของมัน ระดับ Abstraction ของใคร 'ถูก'™️?
เพื่อทำให้ Thought Experiment นี้ซับซ้อนขึ้นอีกนิด เรามาเพิ่ม Requirement ว่า Total Sum บน Receipt ต้อง Calculated หรือ Fetched จากที่ไหนที่หนึ่งระหว่างการสร้าง Receipt เราทำไงดี? เขียน Service อีกตัวจัดการ Summation ของ Total Sum? คำตอบอาจเป็นการปฏิบัติตาม Single Responsibility Principle (SRP) และ Abstract สิ่งต่างๆ ออกจากกัน
# app/services/create_receipt_service.rb class CreateReceiptService ... end # app/services/send_receipt_service.rb class SendReceiptService ... end # app/services/calculate_receipt_total_service.rb class CalculateReceiptTotalService ... end # app/controllers/receipts_controller.rb class ReceiptsController < ApplicationController def create total = CalculateReceiptTotalService.call(user_id: receipts_controller[:user_id]) receipt = CreateReceiptService.call(total: total, user_id: receipt_params[:user_id]) SendReceiptService.call(receipt) end end
โดยการปฏิบัติตาม SRP เราทำให้ Services ของเราสามารถ Compose เข้าด้วยกันเป็น Abstractions ขนาดใหญ่ได้ เช่น Process ReceiptCreation โดยการสร้าง Class 'Process' นี้ เราสามารถ Group Actions ทั้งหมดที่จำเป็นเพื่อ Complete Process ได้ คุณคิดยังไงกับ Idea นี้? อาจดูเหมือน Abstraction มากเกินไปตอนแรก แต่อาจพิสูจน์ว่า Beneficial หากคุณเรียก Actions เหล่านี้ทุกที่ หาก聽起來ดี ลองดู Trailblazer's Operation
สรุปคือ CalculateReceiptTotalService ใหม่จัดการ Number Crunching ทั้งหมด CreateReceiptService ของเรา Responsive สำหรับการเขียน Receipt ลง Database SendReceiptService อยู่ที่นี่เพื่อ Dispatch Emails ให้ Users เกี่ยวกับ Receipts ของพวกเขา การมี Classes เล็กและ Focused เหล่านี้ ทำให้การ Combine พวกเขาใน Use Cases อื่นๆ ง่ายขึ้น ทำให้ Codebase ง่ายต่อการ Maintain และ Test
The Service Backstory (ประวัติของ Service)
ในโลก Ruby แนวทางการใช้ Service Classes ยังรู้จักในชื่อ Actions, Operations และคล้ายๆ กัน สิ่งเหล่านี้ทั้งหมด归结为 Command Pattern แนวคิดเบื้องหลัง Command Pattern คือ Object (หรือในตัวอย่างของเรา Class) Encapsulate ข้อมูลทั้งหมดที่จำเป็นเพื่อ Perform Business Action หรือ Trigger Event ข้อมูลที่ Caller ของ Command ควรทราบคือ:
- Name ของ Command
- Method Name ที่จะเรียกบน Command Object/Class
- Values ที่จะส่งสำหรับ Method Parameters
ดังนั้น ในกรณีของเรา Caller ของ Command คือ Controller แนวทางค่อนข้างคล้ายกัน แค่ Naming ใน Ruby คือ 'Service'
Split Up The Work (แยกงานออก)
หาก Controllers ของคุณเรียก 3rd Party Services บางอย่างและพวกเขา Block Rendering ของคุณ อาจถึงเวลา Extract เรียกเหล่านี้และ Render แยกต่างหากด้วย Controller Action อื่น ตัวอย่างอาจเป็นเมื่อคุณพยายาม Render ข้อมูลหนังสือและ Fetch Rating จาก Service อื่นที่คุณไม่ควบคุมได้จริงๆ (เช่น Goodreads)
# app/controllers/books_controller.rb class BooksController < ApplicationController def show @book = Book.find(params[:id]) @rating = GoodreadsRatingService.new(book).call end end
หาก Goodreads Down หรืออย่างนั้น Users ของคุณจะต้องรอ Request ไป Server Goodreads จน Timeout หรือ หากบางอย่างช้าบน Server ของพวกเขา Page จะ Load ช้า คุณสามารถ Extract การเรียก 3rd Party Service ไป Action อื่นได้:
# app/controllers/books_controller.rb class BooksController < ApplicationController ... def show @book = Book.find(params[:id]) end def rating @rating = GoodreadsRatingService.new(@book).call render partial: 'book_rating' end ... end
จากนั้น คุณจะต้องเรียก rating Path จาก View ของคุณ แต่ Hey Show Action ของคุณไม่มี Blocker แล้ว นอกจากนี้ คุณต้องการ Partial 'book_rating' เพื่อทำได้ง่ายขึ้น คุณสามารถใช้ Gem render_async คุณเพียงแค่ใส่ Statement ต่อไปนี้ที่นี่ที่คุณ Render Rating ของหนังสือ:
<%= render_async book_rating_path %>
Extract HTML สำหรับ Render Rating เข้า book_rating Partial และใส่:
<%= content_for :render_async %>
ภายใน Layout Fileของคุณ Gem จะเรียก book_rating_path ด้วย AJAX Request เมื่อ Page Load และเมื่อ Rating ถูก Fetch จะแสดงบน Page ข้อได้เปรียบใหญ่คือ Users ของคุณได้เห็นหน้าหนังสือเร็วขึ้นด้วยการ Load Ratings แยกต่างหาก
หรือ หากคุณต้องการ คุณสามารถใช้ Turbo Frames จาก Basecamp แนวคิดเหมือนกัน แต่คุณเพียงใช้ Element <turbo-frame> ใน Markup เหมือนนี้:
<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>
无论ตัวเลือกไหนที่คุณเลือก แนวคิดคือ Split Heavy หรือ Flaky Work ออกจาก Main Controller Action และแสดง Page ให้ User ไวที่สุดเท่าที่จะเป็นไปได้
ความคิดเห็นสุดท้าย
หากคุณชอบ Idea ของการเก็บ Controllers ให้ Thin และมองพวกเขาแค่ 'Callers' ของ Method อื่นๆ ฉันเชื่อว่าโพสต์นี้นำ Insight มาบ้างเกี่ยวกับวิธีเก็บพวกเขาแบบนั้น Patterns และ Anti-patterns ที่เราพูดถึงที่นี่當然 ไม่ใช่ Exhaustive List หากคุณมี Idea เกี่ยวกับอะไรที่ดีกว่าหรือสิ่งที่คุณชอบ กรุณา Contact บน Twitter และเราสามารถ Discuss ได้
Definitely Stay Tuned บนซีรีส์นี้ เราจะทำอีกอย่างน้อย 1 Blog Post ที่ Sum Up ปัญหา Rails ทั่วไปและ Takeaways จากซีรีส์
Until next time, cheers!
P.S. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่เผยแพร่ สมัครรับข่าวสาร Ruby Magic newsletter และจะไม่พลาดโพสต์เด็ดๆ ไปเลย!