Chào mừng quay lại phần thứ tư của series Ruby on Rails Patterns and Anti-Patterns.
Trước đó, chúng ta đã bao quát pattern và anti-pattern nói chung cũng như liên quan đến Rails Models và Views. Trong bài này, chúng ta sẽ phân tích phần cuối của design pattern MVC (Model-View-Controller) — Controller. Hãy đi sâu và xem các pattern và anti-pattern liên quan đến Rails Controllers.
Ở Dòng Đệ Tiên (At The Front Lines)
Vì Ruby on Rails là web framework, HTTP request là phần sống còn. Tất cả loại Client truy cập Rails backend qua request và đây là nơi controller tỏa sáng. Controller ở tuyến đầu tiên nhận và xử lý request. Điều đó khiến chúng trở thành phần cơ bản của Rails. Tất nhiên, có code chạy trước controller, nhưng code controller là thứ hầu hết chúng ta có thể kiểm soát.
Một khi bạn định nghĩa routes tại config/routes.rb, bạn có thể hit server trên route đã đặt, và controller tương ứng sẽ lo phần còn lại. Đọc câu trước có thể cho ấn tượng mọi thứ đơn giản như vậy. Nhưng thường, rất nhiều trọng lượng rơi lên vai controller. Có lo ngại về authentication và authorization, rồi vấn đề lấy dữ liệu như thế nào, cũng như đâu và như thế nào thực hiện business logic.
Tất cả lo ngại và trách nhiệm có thể xảy ra bên trong controller có thể dẫn đến anti-pattern. Một trong những anti-pattern "nổi tiếng" nhất là anti-pattern controller "béo" (fat).
Controller Béo (Fat/Obese Controllers)
Vấn đề đặt quá nhiều logic trong controller là bạn bắt đầu vi phạm Single Responsibility Principle (SRP — Nguyên tắc Trách nhiệm Đơn nhất). Nghĩa là chúng ta làm quá nhiều việc bên trong controller. Thường dẫn đến nhiều code và trách nhiệm chồng chất đó. Ở đây, 'béo' ám chỉ code mở rộng trong file controller, cũng như logic controller hỗ trợ. Thường được coi là anti-pattern.
Có nhiều ý kiến về controller nên làm gì. Một common ground (điểm chung) về trách nhiệm controller nên bao gồm:
- Authentication và authorization — kiểm tra entity (thường là user) phía sau request có đúng là ai nó nói và có được phép truy cập resource hoặc thực hiện action không. Thường authentication lưu trong session hoặc cookie, nhưng controller vẫn nên check xem data authentication còn valid không.
- Data fetching (Lấy dữ liệu) — nó nên gọi logic tìm dữ liệu đúng dựa trên parameter đi kèm request. Trong thế giới hoàn hảo, nó nên là gọi một method làm tất cả việc. Controller không nên làm heavy work, nó nên delegate (uỷ quyền) tiếp.
- Template rendering (Render template) — cuối cùng, nó nên trả về response đúng bằng cách render kết quả với format đúng (HTML, JSON, v.v.). Hoặc redirect đến path/URL khác.
Tuân theo ý tưởng này có thể cứu bạn khỏi việc có quá nhiều thứ xảy ra bên trong controller action và controller nói chung. Giữ nó đơn giản ở cấp controller sẽ cho phép bạn delegate việc sang các vùng khác của ứng dụng. Delegate trách nhiệm và test chúng một một sẽ đảm bảo bạn phát triển app robust.
Chắc chắn, bạn có thể tuân theo nguyên tắc trên, nhưng bạn chắc hẳn mong đợi ví dụ. Hãy đi sâu và xem pattern nào chúng ta có thể dùng để giảm bớt gánh nặng cho controller.
Query Objects
Một vấn đề xảy ra bên trong controller action là quá nhiều truy vấn dữ liệu. Nếu bạn theo dõi blog post về Rails Model anti-patterns và patterns, chúng ta đã đi qua vấn đề tương tự nơi model có quá nhiều logic truy vấn. Nhưng lần này chúng ta sẽ dùng pattern gọi là Query Object. Query Object là kỹ thuật tách biệt truy vấn phức tạp vào một object đơn lẻ.
Trong hầu hết trường hợp, Query Object là Plain Old Ruby Object được khởi tạo với một ActiveRecord relation. Một Query Object điển hình có thể trông như sau:
# 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
Nó được thiết kế để dùng trong controller như sau:
class SongsController < ApplicationController def index @songs = AllSongsQuery.new.call(all_songs_params) end private def all_songs_params params.slice(:artist_id) end end
Bạn cũng có thể thử cách tiếp cận khác của 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
Cách sau làm query object robust hơn bằng cách làm params tùy chọn. Cũng chú ý rằng chúng ta giờ có thể gọi AllSongsQuery.new.call. Nếu bạn không thích cách này, bạn có thể dùng class method. Nếu bạn viết query class với class method, nó sẽ không còn là 'object', nhưng đây là sở thích cá nhân. Cho mục đích minh họa, hãy xem cách làm AllSongsQuery đơn giản hơn để gọi trong thực tế.
# 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
Bây giờ, chúng ta có thể gọi AllSongsQuery.call và xong. Chúng ta có thể pass params với artist_id. Cũng có thể pass scope ban đầu nếu cần thay đổi. Nếu bạn thực sự muốn tránh gọi new trên query class, hãy thử 'mẹo' này:
# app/queries/application_query.rb class ApplicationQuery def self.call(*params) new(*params).call end end
Bạn có thể tạo ApplicationQuery và inherit từ nó trong các query class khác:
# app/queries/all_songs_query.rb class AllSongsQuery < ApplicationQuery ... end
Bạn vẫn giữ AllSongsQuery.call, nhưng làm cho nó elegant hơn.
Điều tuyệt vời của query object là bạn có thể test chúng độc lập và đảm bảo chúng làm đúng việc. Hơn nữa, bạn có thể extend các query class này và test chúng mà không lo lắng quá nhiều về logic trong controller. Một điều lưu ý: bạn nên handle request parameter ở nơi khác, và không dựa vào query object để làm điều đó. Bạn nghĩ sao, bạn sẽ thử query object không?
Sẵn sàng Phục vụ (Ready To Serve)
OK, chúng ta đã xử lý cách delegate việc thu thập và lấy dữ liệu vào Query Objects. Chúng ta làm gì với logic chồng chất giữa bước lấy dữ liệu và bước render? Hay lắm bạn hỏi, vì một giải pháp là dùng cái gọi là Services. Service thường được xem là PORO (Plain Old Ruby Object) thực hiện một hành động (business) đơn lẻ. Chúng ta sẽ khám phá ý tưởng này một chút dưới đây.
Hãy tưởng tượng chúng ta có hai service. Một tạo receipt, cái kia gửi receipt cho user như sau:
# 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
Sau đó, trong controller chúng ta gọi SendReceiptService như này:
# 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
Bây giờ bạn có hai service làm tất cả việc, và controller chỉ gọi chúng. Bạn có thể test chúng riêng biệt, nhưng vấn đề là không có sự liên kết rõ ràng giữa các service. Vâng, về lý thuyết, tất cả thực hiện một business action đơn lẻ. Nhưng, nếu xem abstraction level từ góc độ stakeholder — quan điểm của họ về action tạo receipt bao gồm gửi email của nó. Cấp độ abstraction của ai là 'đúng'™️?
Để làm thí nghiệm tư duy này phức tạp hơn, hãy thêm yêu cầu: tổng số trên receipt phải được tính toán hoặc lấy từ đâu đó trong quá trình tạo receipt. Làm sao bây giờ? Viết service khác handle summation tổng? Câu trả lời có thể là tuân theo SRP và trừu tượng hóa các thứ ra khỏi nhau.
# 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
Bằng cách tuân theo SRP, chúng ta đảm bảo service có thể compose (kết hợp) thành abstraction lớn hơn, như process ReceiptCreation. Bằng cách tạo class 'process' này, chúng ta nhóm tất cả action cần thiết để hoàn thành process. Bạn nghĩ sao về ý tưởng này? Nó có thể nghe như quá nhiều abstraction lúc đầu, nhưng có thể hữu ích nếu bạn gọi các action này khắp nơi. Nếu nghe hay, hãy xem Trailblazer's Operation.
Tóm lại, CalculateReceiptTotalService mới có thể lo toàn bộ number crunching. CreateReceiptService chịu trách nhiệm ghi receipt vào database. SendReceiptService gửi email cho user về receipt của họ. Có những class nhỏ và tập trung này làm việc kết hợp chúng trong use case khác dễ dàng hơn, dẫn đến codebase dễ maintain và dễ test hơn.
Câu chuyện Phía sau Service
Trong thế giới Ruby, cách tiếp cận dùng service class còn gọi là actions, operations, và tương tự. Tất cả đều归结为 Command pattern. Ý tưởng sau Command pattern là một object (hoặc trong ví dụ của chúng ta, một class) đóng gói tất cả thông tin cần thiết để thực hiện business action hoặc trigger event. Thông tin caller của command cần biết:
- tên command
- tên method gọi trên command object/class
- giá trị pass cho parameter method
Vậy trong case của chúng ta, caller của command là controller. Cách tiếp cận rất tương tự, chỉ khác naming trong Ruby là 'Service'.
Tách Việc (Split Up The Work)
Nếu controller của bạn gọi một số service bên thứ 3 và chúng block rendering của bạn, có lẽ đã đến lúc extract các call này và render riêng với controller action khác. Ví dụ khi bạn render thông tin sách và fetch rating từ service khác bạn không thể ảnh hưởng (như Goodreads).
# app/controllers/books_controller.rb class BooksController < ApplicationController def show @book = Book.find(params[:id]) @rating = GoodreadsRatingService.new(book).call end end
Nếu Goodreads down hoặc tương tự, user của bạn sẽ phải chờ request đến server Goodreads timeout. Hoặc nếu thứ gì chậm ở server của họ, trang sẽ load chậm. Bạn có thể extract việc gọi service thứ 3 vào action khác như sau:
# 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
Sau đó, bạn phải gọi path rating từ view, nhưng hey, show action của bạn không còn blocker nữa. Cũng cần partial 'book_rating'. Để làm dễ hơn, bạn có thể dùng gem render_async. Chỉ cần đặt statement sau ở chỗ bạn render rating sách:
<%= render_async book_rating_path %>
Extract HTML render rating vào partial book_rating, và đặt:
<%= content_for :render_async %>
Trong layout file, gem sẽ gọi book_rating_path với AJAX request khi trang load, và khi rating được fetch, nó sẽ hiển thị trên trang. Một lợi ích lớn: user thấy trang sách nhanh hơn bằng cách load rating riêng biệt.
Hoặc nếu muốn, bạn có thể dùng Turbo Frames từ Basecamp. Ý tưởng giống nhau, chỉ dùng element <turbo-frame> trong markup như sau:
<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>
Bất kỳ tùy chọn nào bạn chọn, ý tưởng là tách việc heavy hoặc flaky khỏi main controller action và show trang cho user sớm nhất có thể.
Tư duy Cuối Cùng
Nếu bạn thích ý tưởng giữ controller mỏng và hình dung chúng chỉ là 'caller' của các method khác, tôi tin bài viết này mang lại insight về cách giữ chúng như vậy. Những pattern và anti-pattern chúng ta nhắc ở đây當然 không phải danh sách exhaustive. Nếu bạn có ý tưởng gì tốt hơn hoặc thích hơn, hãy reach out trên Twitter và chúng ta thảo luận.
Chắc chắn hãy theo dõi series này, chúng ta sẽ làm ít nhất một bài blog nữa tổng kết các vấn đề Rails phổ biến và takeaway từ series.
Cho đến lần sau, cheers!
P.S. Nếu bạn muốn đọc bài Ruby Magic ngay khi ra mắt, hãy subscribe newsletter Ruby Magic và không bao giờ bỏ lỡ bài nào!