Interactor Design Pattern trong Ruby (Phần 1)
Lời nói đầu Thường thì, mình khá là ghét bắt đầu một bài viết thẳng thừng với một đoạn code nào đó. Nhưng, thực sự đây lại là cách tốt nhất để cho bạn thấy vấn đề và sau đó là đưa ra 1 giải pháp rõ ràng cho nó (ít nhất là đúng trong bài viết này đã) Thôi không lan man nữa, chúng ta hãy xem đoạn ...
Lời nói đầu
Thường thì, mình khá là ghét bắt đầu một bài viết thẳng thừng với một đoạn code nào đó. Nhưng, thực sự đây lại là cách tốt nhất để cho bạn thấy vấn đề và sau đó là đưa ra 1 giải pháp rõ ràng cho nó (ít nhất là đúng trong bài viết này đã) Thôi không lan man nữa, chúng ta hãy xem đoạn code viết trong controller dưới đây và thử xem bạn có thể phát hiện ra vấn đề với nó:
def create sale = Sale.new(params[:sale]) Sale.transaction do if sale.save #1 stocks = find_stocks_for(sale.products) #2 stocks.each do |stock| stock.update_amount(product) end if stocks_updated?(stocks) shipping = Shipping.create(sale, current_user) dispatch_shipping(shipping) #3 SaleMailer.sale_completed(sale).deliver_later #4 redirect_to sales_path, success: 'Sale completed!' else redirect_to sales_path, error: 'An error occurred!' log_error(stocks) end else redirect_to sales_path, error: 'An error occurred!' log_error(sale) end end end
Yikes! Có vẻ là 1 Fat Controller "cổ điển" phải không? Nó khởi tạo và lưu các object lại, sau đó thực hiện gửi mail và các tương tác khác với hệ thống. Tất cả viết trong 1 method. Từ lúc bắt đầu code thì chúng ta đã nghe quá nhiều đến việc không nên đưa quá nhiều xử lý logic vào trong 1 method/classes, nếu không chúng có thể lớn lên và dấn đến việc không kiểm soát được. Hãy tưởng tượng nếu sau này chúng ta phát hiện ra rằng các xử lý trong controller này cần validate 1 vài tham số để kiểm tra object đó có được lưu vào DB không? Controller đã cồng kềnh, lại phải giải quyết vấn đề này nữa? phải làm sao nhỉ?
- Đúng, sẽ có người nói "Chúng ta sẽ giải quyết bằng việc handle các business logic ở bên trong Model, sau đó dùng các câu queries, scopes hay callbacks ... để tái sử dụng"
- Không, chờ đã, đây không phải là năm 2010 nữa đâu? Liệu chúng ta có cách làm khác?
Tương tác để giải quyết (Interactor to the rescue)
Và đây là nơi để Interactor Design Pattern tỏa sáng =)) Về cơ bản, một Interactor là một đối tượng đóng gói các business logic (đọc từ model) cho một (và chỉ môt) trường hợp sử dụng. Chính là nó, phương pháp giải quyết từng vấn đề cho từng phần nhỏ của ứng dụng. Nó dựa trên Command Pattern, nghĩa là trong mỗi lớp lệnh sẽ có 1 đối tượng đại diện cho 1 nhiệm vụ nào đó và ứng với phương thức public. Để dễ hiểu hơn, sẽ như thế này:
PressButton.new(button).execute # or PurchaseOrder.new(params).call # or even SendEmail.perform(email) # it could be a class method, why not?
Tôi biết, đây là 1 định nghĩa rất đơn đơn của Command Pattern. Nếu bạn tò mò, thì có thể tham khảo ở Đây để có 1 định nghĩa chính thức và cụ thể hơn về Command Pattern. Nhưng bạn có 1 ý tưởng chính ở đây là Interactor được hiểu như 1 đối đượng Command (lệnh) cơ bản, cái mà chúng ta có thể sử dụng để giảm phình to trong controller và model của mình. Nói đến đây thì các bạn có thể nghĩ ngay đến Service Object. Đúng, Service Object 1 tên khác cho nó, mặc dù nó khá là chung chung. Ý tôi là, không có mô hình rõ ràng cho nó. Có một số hướng dẫn khác thì so sánh như: "nó phải là một PORO (Plain Old Ruby Object)" và "cố gắng tạo ra nó chỉ có 1 nhiệm vụ nếu có thể" hoặc là "API public rất nhỏ", nhưng không có gì thực sự thực thi nó. Có thể nói rằng, tôi đã nhìn thấy Service Object với nhiều hơn 1 phương thức được sử dụng trong các pulic APIs, và bạn biết không? Chúng trông khá ổn.
Nhân tiện, các TrailBlazer framework (nằm trên đầu trang của Rails) gọi các đối tượng hoạt động. Nhóm nghiên cứu tạo ra DSL dựa trên cùng 1 ý tưởng với 1 vài tính năng nữa trong các dry-transaction. Nhưng tôi muốn sử dụng ví dụ về 1 loại Gem phổ biến hơn để xử lý các object tương tự như vậy, gem interactor. Và kể từ khi tôi nói về nó, tôi sẽ dành thời gian để làm nổi bật 1 số điểm mạnh và 1 số cải tiến có thể mà thôi nghĩ là chúng khả thi. Tất nhiên, đây là chỉ là ý kiến của tôi, bình tĩnh đừng quá nôn nóng mà áp dụng chúng =))
Đơn giản như choco-pie
Ruby Gem Interactor sẽ giải quyết các vấn đề đã đề cập ở trên 1 cách rất rất đơn giản. Đối với mỗi trường hợp sử dụng, hành động hoặc nhiệm vụ, hoặc bất cứ điều gì bán có thể gọi nó, bạn tạo 1 lớp được đặt tên như là mục đích method bạn sử dụng (như ReadFile, SaveUserProfile hoặc DeleteAccount) và gọi phương thức đó tương tự như sau:
# DeleteAccount interactor class DeleteAccount include Interactor def call end end # Controller def destroy account = Account.find(params[:id]) DeleteAccount.call(account: account) # pass whatever you want as a hash
Hai điều cần thiết lập: bao gồm các module Interactor và xác định 1 phương thức instance được gọi. Đó là nó. Sự đơn giản này là 1 trong nhưng lý do tôi thích loại Gem này và chúng tôi đã sử dụng nó trog nhiều dự án của chúng tôi tại Guava. Bên cạnh đó, nếu chúng ta cần trước khi xử lý 1 cái gì đó trước khi phương pháp này được gọi, như kiểu thiết lập hoặc xác nhận các đối số ... Chúng ta có thể làm điều đó với before, chấp nhận 1 khối như 1 tham số. Ngoài ra có các từ khóa after và around . Bây giờ chúng ta hãy thử thực hành với các nhiệm vụ được thấy trong controller ở phần đầu bài viết. Lưu ý rằng action create đang cố gắng thực hiện 1 danh sách các thứ theo thứ tự sau:
- Tạo ra các bản ghi mua bán
- Tìm và xử lý các hóa đơn cho các sản phẩm liên quan
- Xử lý và gửi đến bộ phân vận chuyển
- Gửi mail xác nhận
Tốt, để bắt đầu lược bỏ các business code từ controller, chúng tôi tạo ra 4 tương tác ( 4 interactors): CreateSale, ProcessStock, DispatchShipping and SendConfirmationEmail Nếu chúng ta gọi mỗi một trong số các tương tác này, chúng sẽ làm việc tốt. Nhưng có 1 sự báo trước: nếu 1 trong những hoạt động này thất bại, chúng ta cần phải rollback tát cả những thứ khác đã hoàn thành. Do đó, nếu chúng ta sử dụng cách tiếp cận này, chúng ta sẽ kết thức với rất nhiều điều kiện trong controller như sau:
# controller sale_result = CreateSale.call(sale: sale) if result.success? stock_result = ProcessStock.call(products: sale.products) if payment_result.success? # verify result again and so on… else # rollback CreateSale, deleting record from database end end
Không được hay cho lắm. Trên thực tế, nó được gọi là điều kiện của địa ngục. Tạo ra 1 tổ chức. Mỗi tổ chức là 1 loại Interactor đặc biệt có 1 trách nhiệm: gọi những người tương tác khác trong một trình tự nhất định. Đối với tính huống trên, chúng ta sẽ có:
# organizer class PlaceOrder include Interactor::Organizer organize CreateSale, ProcessStock, DispatchShipping, SendConfirmationEmail end # controller def create sale = Sale.new(params[:sale]) PlaceOrder.call(sale: sale) end
Phần đầu mình đã giới thiệu cơ bản Interactor Design Pattern là gì? và cách tạo 1 Organizer cơ bản Phần tới chúng ta sẽ sâu vào việc phân tích và giải thích các vấn đề xoay quanh controller được nêu ra ở phần đầu bài viết nhé