ยินดีต้อนรับกลับสู่ภาคที่สี่ของซีรี่ส์ Ruby on Rails Patterns และ Anti-Patterns
ก่อนหน้านี้ เราได้กล่าวถึงรูปแบบและรูปแบบการต่อต้านโดยทั่วไป ตลอดจนความเกี่ยวข้องกับ Rails Models และ Views ในบทความนี้ เราจะวิเคราะห์ส่วนสุดท้ายของรูปแบบการออกแบบ MVC (Model-View-Controller) — theController มาดูรูปแบบและรูปแบบการต่อต้านที่เกี่ยวข้องกับ Rails Controllers กัน
อยู่ที่แนวหน้า
เนื่องจาก Ruby on Rails เป็นเว็บเฟรมเวิร์ก คำขอ HTTP จึงเป็นส่วนสำคัญ ลูกค้าทุกประเภทเข้าถึงคำขอแบ็กเอนด์ของ Rails และนี่คือจุดที่ผู้ควบคุมส่องแสง ผู้ควบคุมเป็นแนวหน้าในการรับและจัดการคำขอ นั่นทำให้พวกเขาเป็นส่วนพื้นฐานของเฟรมเวิร์ก Ruby on Rails แน่นอนว่ามีโค้ดที่มาก่อนคอนโทรลเลอร์ แต่โค้ดคอนโทรลเลอร์เป็นสิ่งที่พวกเราส่วนใหญ่ควบคุมได้
เมื่อคุณกำหนดเส้นทางที่ config/routes.rb
คุณสามารถกดเซิร์ฟเวอร์บนเส้นทางที่กำหนด และตัวควบคุมที่เกี่ยวข้องจะดูแลส่วนที่เหลือ การอ่านประโยคก่อนหน้าอาจทำให้รู้สึกว่าทุกอย่างเรียบง่ายเช่นนั้น แต่บ่อยครั้งที่น้ำหนักจำนวนมากตกอยู่ที่ไหล่ของผู้ควบคุม มีความกังวลเกี่ยวกับการตรวจสอบสิทธิ์และการอนุญาต จากนั้นจึงมีปัญหาในการดึงข้อมูลที่จำเป็น ตลอดจนตำแหน่งและวิธีการดำเนินการตามตรรกะทางธุรกิจ
ความกังวลและความรับผิดชอบทั้งหมดเหล่านี้ที่อาจเกิดขึ้นภายในตัวควบคุมสามารถนำไปสู่การต่อต้านรูปแบบบางอย่างได้ หนึ่งใน 'ที่มีชื่อเสียง' ที่สุดคือรูปแบบการต่อต้านของตัวควบคุม "อ้วน"
ตัวควบคุมไขมัน (อ้วน)
ปัญหาในการใส่ตรรกะมากเกินไปในคอนโทรลเลอร์คือคุณกำลังเริ่มละเมิด Single Responsibility Principle (SRP) ซึ่งหมายความว่าเรากำลังทำงานมากเกินไปภายในคอนโทรลเลอร์ บ่อยครั้งสิ่งนี้นำไปสู่รหัสและความรับผิดชอบจำนวนมากซ้อนอยู่ที่นั่น ในที่นี้ 'อ้วน' หมายถึงโค้ดที่ครอบคลุมซึ่งอยู่ในไฟล์คอนโทรลเลอร์ เช่นเดียวกับตรรกะที่คอนโทรลเลอร์รองรับ มักถูกมองว่าเป็นการต่อต้านรูปแบบ
มีความคิดเห็นมากมายเกี่ยวกับสิ่งที่ผู้ควบคุมควรทำ ความรับผิดชอบทั่วไปที่ผู้ควบคุมควรมีมีดังนี้:
- การตรวจสอบสิทธิ์และการอนุญาต — ตรวจสอบว่าเอนทิตี (บ่อยครั้งคือผู้ใช้) ที่อยู่เบื้องหลังคำขอนั้นคือใครและได้รับอนุญาตให้เข้าถึงทรัพยากรหรือดำเนินการหรือไม่ บ่อยครั้ง การตรวจสอบสิทธิ์ได้รับการพิสูจน์ในเซสชันหรือคุกกี้ แต่ผู้ควบคุมควรตรวจสอบว่าข้อมูลการตรวจสอบความถูกต้องยังใช้ได้อยู่หรือไม่
- กำลังดึงข้อมูล — ควรเรียกตรรกะในการค้นหาข้อมูลที่ถูกต้องตามพารามิเตอร์ที่มาพร้อมกับคำขอ ในโลกที่สมบูรณ์แบบ มันควรจะเป็นวิธีการเดียวที่ได้ผลทั้งหมด ตัวควบคุมไม่ควรทำงานหนัก ควรมอบหมายให้ทำงานต่อไป
- การแสดงเทมเพลต — สุดท้ายควรส่งคืนการตอบสนองที่ถูกต้องโดยแสดงผลด้วยรูปแบบที่เหมาะสม (HTML, JSON เป็นต้น) หรือควรเปลี่ยนเส้นทางไปยังเส้นทางหรือ URL อื่น
การปฏิบัติตามแนวคิดเหล่านี้สามารถช่วยให้คุณไม่ต้องดำเนินการมากเกินไปในการดำเนินการควบคุมและตัวควบคุมโดยทั่วไป การทำให้ง่ายในระดับคอนโทรลเลอร์จะช่วยให้คุณสามารถมอบหมายงานไปยังส่วนอื่น ๆ ของแอปพลิเคชันของคุณได้ การมอบหมายความรับผิดชอบและการทดสอบทีละรายการจะช่วยให้คุณพัฒนาแอปให้มีประสิทธิภาพ
แน่นอนว่าคุณสามารถปฏิบัติตามหลักการข้างต้นได้ แต่คุณต้องกระตือรือร้นที่จะยกตัวอย่าง มาดูกันดีกว่าว่าเราจะใช้รูปแบบใดในการบรรเทาผู้ควบคุมน้ำหนักได้บ้าง
วัตถุแบบสอบถาม
ปัญหาหนึ่งที่เกิดขึ้นภายในการดำเนินการของตัวควบคุมคือการสอบถามข้อมูลมากเกินไป หากคุณติดตามบล็อกโพสต์ของเราเกี่ยวกับรูปแบบและรูปแบบการต่อต้านของ Rails Model เราพบปัญหาที่คล้ายกันซึ่งโมเดลมีตรรกะการสืบค้นมากเกินไป แต่คราวนี้ เราจะใช้รูปแบบที่เรียกว่า Query Object Query Object เป็นเทคนิคที่แยกการสืบค้นที่ซับซ้อนของคุณออกเป็นวัตถุเดียว
ในกรณีส่วนใหญ่ Query Object เป็นออบเจ็กต์ Ruby แบบธรรมดาที่เริ่มต้นด้วย ActiveRecord
ความสัมพันธ์ 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
มันถูกสร้างขึ้นเพื่อใช้ภายในคอนโทรลเลอร์ดังนี้:
class SongsController < ApplicationController
def index
@songs = AllSongsQuery.new.call(all_songs_params)
end
private
def all_songs_params
params.slice(:artist_id)
end
end
คุณยังสามารถลองใช้วิธีอื่นของออบเจ็กต์การสืบค้น:
# 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
วิธีหลังทำให้วัตถุแบบสอบถามมีประสิทธิภาพมากขึ้นโดยการสร้าง params
ไม่จำเป็น. นอกจากนี้ โปรดทราบว่าขณะนี้เราสามารถเรียก AllSongsQuery.new.call
. หากคุณไม่ใช่แฟนตัวยงของสิ่งนี้ คุณสามารถใช้วิธีการเรียนได้ หากคุณเขียนคลาสเคียวรีของคุณด้วยเมธอดคลาส คลาสนั้นจะไม่ใช่ 'object' อีกต่อไป แต่เป็นเรื่องของรสนิยมส่วนตัว เพื่อจุดประสงค์ในการอธิบาย มาดูกันว่าเราจะสร้าง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
. นอกจากนี้ เราสามารถผ่านขอบเขตเริ่มต้นได้หากจำเป็นต้องเปลี่ยนด้วยเหตุผลบางประการ หากคุณต้องการหลีกเลี่ยงการโทร new
ในคลาสการสืบค้น ลองใช้ 'เคล็ดลับ' นี้:
# app/queries/application_query.rb
class ApplicationQuery
def self.call(*params)
new(*params).call
end
end
คุณสามารถสร้าง ApplicationQuery
แล้วสืบทอดจากคลาสการสืบค้นอื่น:
# app/queries/all_songs_query.rb
class AllSongsQuery < ApplicationQuery
...
end
คุณยังคงเก็บ AllSongsQuery.call
แต่คุณทำให้มันดูสง่างามขึ้น
สิ่งที่ยอดเยี่ยมเกี่ยวกับออบเจ็กต์คิวรีคือคุณสามารถทดสอบออบเจ็กต์แบบแยกส่วนและทำให้แน่ใจว่าพวกเขากำลังทำในสิ่งที่ควรทำ นอกจากนี้ คุณสามารถขยายคลาสแบบสอบถามเหล่านี้และทดสอบโดยไม่ต้องกังวลเกี่ยวกับตรรกะในตัวควบคุมมากเกินไป สิ่งหนึ่งที่ควรทราบคือ คุณควรจัดการพารามิเตอร์คำขอของคุณที่อื่น และไม่ต้องพึ่งพาออบเจกต์การสืบค้นเพื่อดำเนินการดังกล่าว คุณคิดอย่างไร คุณจะลองใช้วัตถุค้นหาหรือไม่
พร้อมให้บริการ
ตกลง ดังนั้นเราจึงได้จัดการวิธีการมอบหมายการรวบรวมและดึงข้อมูลไปยัง QueryObjects เราจะทำอย่างไรกับตรรกะที่ซ้อนขึ้นระหว่างการรวบรวมข้อมูลและขั้นตอนที่เราแสดงผล ดีที่คุณถามเพราะหนึ่งในวิธีแก้ปัญหาคือการใช้สิ่งที่เรียกว่าบริการ บริการมักถูกมองว่าเป็น PORO (Plain Old Ruby Object) ที่ดำเนินการ (ธุรกิจ) เดียว เราจะดำเนินการต่อและสำรวจแนวคิดนี้ด้านล่างเล็กน้อย
ลองนึกภาพเรามีบริการสองอย่าง คนหนึ่งสร้างใบเสร็จ อีกคนส่งใบเสร็จให้ผู้ใช้ดังนี้:
# 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
จากนั้นในตัวควบคุมของเรา เราจะเรียก 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
ตอนนี้คุณมีบริการสองอย่างที่ทำงานทั้งหมด และผู้ควบคุมจะเรียกใช้บริการเหล่านั้น คุณสามารถทดสอบสิ่งเหล่านี้แยกกันได้ แต่ปัญหาคือ ไม่มีการเชื่อมต่อระหว่างบริการที่ชัดเจน ใช่ ตามทฤษฎีแล้ว พวกเขาทั้งหมดดำเนินการทางธุรกิจเพียงส่วนเดียว แต่ถ้าเราพิจารณาระดับนามธรรมจากมุมมองของผู้มีส่วนได้ส่วนเสีย มุมมองของพวกเขาเกี่ยวกับการดำเนินการสร้างใบเสร็จจะเกี่ยวข้องกับการส่งอีเมลถึงระดับนั้น ระดับนามธรรมของใครที่ 'ถูกต้อง'™️?
ในการทำให้การทดลองทางความคิดนี้ซับซ้อนขึ้นอีกหน่อย ให้เพิ่มข้อกำหนดที่ว่าจะต้องคำนวณหรือดึงผลรวมทั้งหมดในใบเสร็จจากที่ใดที่หนึ่งระหว่างการสร้างใบเสร็จ แล้วเราจะทำอย่างไร? เขียนบริการอื่นเพื่อจัดการกับผลรวมของผลรวมทั้งหมด? คำตอบก็คือการปฏิบัติตาม SingleResponsibility Principle (SRP) และสิ่งที่เป็นนามธรรมให้ห่างจากกัน
# 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 จะทำให้มั่นใจได้ว่าบริการของเราจะรวมกันเป็นนามธรรมที่ใหญ่ขึ้นได้ เช่น ReceiptCreation
กระบวนการ. ด้วยการสร้างคลาส 'กระบวนการ' นี้ เราสามารถจัดกลุ่มการดำเนินการทั้งหมดที่จำเป็นเพื่อทำให้กระบวนการเสร็จสมบูรณ์ คุณคิดอย่างไรเกี่ยวกับแนวคิดนี้ ในตอนแรกอาจดูเหมือนเป็นนามธรรมมากเกินไป แต่อาจเป็นประโยชน์หากคุณเรียกใช้การกระทำเหล่านี้ทั่วทุกแห่ง หากสิ่งนี้ฟังดูดีสำหรับคุณ ให้ตรวจสอบการปฏิบัติการของเทรลเบลเซอร์
สรุป CalculateReceiptTotalService
. ใหม่ บริการสามารถจัดการกับการกระทืบทั้งหมด CreateReceiptService
ของเรา มีหน้าที่เขียน areceipt ลงฐานข้อมูล SendReceiptService
มีเพื่อส่งอีเมลถึงผู้ใช้เกี่ยวกับใบเสร็จของพวกเขา การมีคลาสที่มีขนาดเล็กและมุ่งเน้นเหล่านี้สามารถทำให้การรวมคลาสเหล่านี้ในกรณีการใช้งานอื่นๆ ได้ง่ายขึ้น ส่งผลให้ดูแลรักษาง่ายและทดสอบ codebase ได้ง่ายขึ้น
เบื้องหลังการบริการ
ในโลกของ Ruby แนวทางการใช้คลาสบริการเรียกอีกอย่างว่าการกระทำ การดำเนินการ และอื่นๆ ที่คล้ายกัน สิ่งเหล่านี้ล้วนเกิดจากรูปแบบคำสั่ง แนวคิดเบื้องหลังรูปแบบคำสั่งคืออ็อบเจกต์ (หรือในตัวอย่างของเราคือ aclass) กำลังห่อหุ้มข้อมูลทั้งหมดที่จำเป็นเพื่อดำเนินการทางธุรกิจหรือทริกเกอร์เหตุการณ์ ข้อมูลที่ผู้เรียกใช้คำสั่งควรรู้คือ:
- ชื่อคำสั่ง
- ชื่อเมธอดที่จะเรียกใช้บนอ็อบเจ็กต์/คลาสคำสั่ง
- ค่าที่จะส่งผ่านสำหรับพารามิเตอร์เมธอด
ดังนั้น ในกรณีของเรา ผู้เรียกคำสั่งคือผู้ควบคุม วิธีการคล้ายกันมาก แค่การตั้งชื่อใน Ruby คือ '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 ไม่ทำงานหรือสิ่งที่คล้ายกัน ผู้ใช้ของคุณจะต้องรอคำขอไปยังเซิร์ฟเวอร์ Goodreads เพื่อหมดเวลา หรือหากเซิร์ฟเวอร์ทำงานช้า หน้าจะโหลดช้า คุณสามารถแยกการเรียกบริการของบุคคลที่สามเป็นการกระทำอื่นได้ดังนี้:
# 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
เส้นทางจากมุมมองของคุณ แต่เดี๋ยวก่อน การแสดงของคุณไม่มีตัวบล็อกอีกต่อไป นอกจากนี้ คุณต้องมี 'book_rating' บางส่วน หากต้องการทำสิ่งนี้ให้ง่ายขึ้น คุณสามารถใช้ render_async gem ได้ คุณเพียงแค่ใส่ข้อความต่อไปนี้ในที่ที่คุณแสดงการให้คะแนนหนังสือของคุณ:
<%= render_async book_rating_path %>
แยก HTML เพื่อแสดงการให้คะแนนลงใน book_rating
บางส่วน และใส่:
<%= content_for :render_async %>
ภายในไฟล์เลย์เอาต์ของคุณ เจมจะเรียก book_rating_path
ด้วยคำขอ AJAX เมื่อหน้าเว็บของคุณโหลด และเมื่อได้รับการจัดอันดับแล้ว ระบบจะแสดงขึ้นบนหน้า ข้อดีอย่างหนึ่งที่ได้มาคือผู้ใช้ของคุณจะเห็นหน้าหนังสือเร็วขึ้นด้วยการโหลดการให้คะแนนแยกกัน
หรือหากต้องการ คุณสามารถใช้ Turbo Frames จาก Basecamp แนวคิดก็เหมือนกัน แต่คุณแค่ใช้ <turbo-frame>
องค์ประกอบในมาร์กอัปของคุณดังนี้:
<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>
ไม่ว่าคุณจะเลือกตัวเลือกใด แนวคิดก็คือการแยกงานที่หนักหรือไม่สม่ำเสมอออกจากการทำงานของตัวควบคุมหลัก และแสดงหน้าเว็บให้ผู้ใช้เห็นโดยเร็วที่สุด
ความคิดสุดท้าย
ถ้าคุณชอบแนวคิดในการทำให้คอนโทรลเลอร์บางและนึกภาพว่าเป็นเพียงแค่ 'ผู้โทร' ของวิธีการอื่นๆ ฉันเชื่อว่าโพสต์นี้ให้ข้อมูลเชิงลึกเกี่ยวกับวิธีการคงไว้เช่นนั้น รูปแบบและรูปแบบการต่อต้านบางส่วนที่เรากล่าวถึงในที่นี้ไม่ใช่รายการที่ละเอียดถี่ถ้วน หากคุณมีไอเดียว่าอะไรดีกว่าหรือต้องการอะไร โปรดติดต่อ Twitter แล้วเราจะพูดคุยกันได้
โปรดคอยติดตามซีรีส์นี้ เราจะทำโพสต์บล็อกเพิ่มเติมอย่างน้อยหนึ่งโพสต์ ซึ่งเราจะสรุปปัญหาทั่วไปเกี่ยวกับ Rails และประเด็นสำคัญจากซีรีส์นี้
ไว้เจอกันใหม่คราวหน้า !
ป.ล. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่ออกจากสื่อ สมัครรับจดหมายข่าว Ruby Magic ของเราและไม่พลาดแม้แต่โพสต์เดียว!