Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

รูปแบบตัวควบคุม Ruby on Rails และ Anti-patterns

ยินดีต้อนรับกลับสู่ภาคที่สี่ของซีรี่ส์ 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 ของเราและไม่พลาดแม้แต่โพสต์เดียว!