Cấu trúc của các component trong Rails và các cách refactor code với các Ruby object - Part 2
Trong bài viết trước tôi đã trình bày với các bạn về tổng quan cấu trúc cơ bản của một Rails project. Hôm nay tôi sẽ giới thiệu với các bạn các cách refactor code bằng các kĩ thuật với object: Form object, Service object, Query object, Decorator/Presenter, Value object. Trước khi đi sâu vào việc ...
Trong bài viết trước tôi đã trình bày với các bạn về tổng quan cấu trúc cơ bản của một Rails project. Hôm nay tôi sẽ giới thiệu với các bạn các cách refactor code bằng các kĩ thuật với object: Form object, Service object, Query object, Decorator/Presenter, Value object.
Trước khi đi sâu vào việc refactor, chúng ta sẽ sử dụng các hướng dẫn được liệt kê dưới đây, nhưng xin lưu ý đây không phải là nguyên tắc mà bạn phải tuân theo. Các hướng dẫn này chỉ giúp bạn sẽ có một cái nhìn tốt hơn về các phần làm khi refactor code.
- ActiveRecord models có thể chứa các associations và constants, và chỉ có như thế. Chính về thế mà sẽ không có callback (được thay thế bằng Service object và chúng ta sẽ cho callback vào đó) và không có validatation (được thay thế bằng Form object để naming và validate cho model)
- Giữ cho Controller như các thin layer và luôn gọi Service object trong đó. Vì sao chúng ta lại luôn gọi Service object ở trong controller?. Lí do đơn giản vì Controller không phải là nơi để xử lí logic, trong Controller chỉ chứa HTTP routing, parameters parsing, authentication, content negotiation, calling service hoặc editor object, exception catching, response formatting, và trả về đúng HTTP status code.
- Services nên gọi tới Query objects, and không nên lưu state. Chỉ sử dụng instance method và không dùng class method. Nên có ít các public method để phù hợp vs SRP.
- Các Queries nên thực hiện trong query objects. Query object methods nên trả về một object, hash hay một array, không phải là một ActiveRecord association.
- Tránh sử dụng Helper và sử dụng Decorator để thay thế. Tại sao? Một cái bẫy phỏ biến với Rails helpers là chúng có thể trở thành một phần lơn của non-OO functions, tất cả sử dụng chung namespace và chồng chéo lên nhau. Nhưng tồi tệ hơn là không có cách nào tuyệt vời để sử dụng bất kỳ loại đa hình với Rails helpers - cung cấp các cách implementations khác nhau cho các contexts hoặc types khác nhau, over-riding or sub-classing helpers. Tôi nghĩ rằng các lớp helper Rails nói chung nên được sử dụng cho utility methods, không phải cho các use case cụ thể, ví dụ như formatting model attributes cho bất kì loại presentation logic.
- Tránh sử dụng concerns và sử dụng Decorators/Delegators thay thế. Tại sao? Sau tất cả, concerns dường như là một phần core của Rails và có thể DRY code khi chia sẽ giữa các model. Tuy nhiên, vấn đề chính là concern không làm cho model object có tính kết hợp hơn. Code sẽ được tổ chức tốt hơn. Nói cách khác, sẽ không có thay đổi thực sự tới API của model.
- Cố gắng trích xuất Value Objects từ model để giữ cho code clear hơn và nhóm các attributes có liên hệ với nhau.
- Luôn luôn pass một instance variable trên một view.
Đầu tiên Value Objects là gì? Martin Fowler giải thích:
Value Object là một object nhỏ, ví dụ như object tiền hay object date range. Key ở đây là nó theo value semantics hơn là reference semantics.
Đôi khi bạn có thể gặp phải một tình huống mà một khái niệm trừu tượng và có sự bình đẳng không dựa trên giá trị, mà về bản sắc(identity). Ví dụ sẽ bao gồm date của Ruby, URI, và Pathname. Truy xuất đến một value object (hoặc domain model) là một tiện ích tuyệt vời.
Quan tâm làm gì?
Một trong những lợi thế lớn nhất của một Value Object là những expressiveness trong code. Code của bạn sẽ rõ ràng hơn, hay ít nhất có thể giúp bạn có thực hành về việc nameing. Vì Value Object là một khái niệm trừu tượng, nó sẽ làm cho code của bạn clean hơn và ít lỗi hơn.
Một lợi ích khác là tính bất biến. Tính bất biến của một object là rất quan trọng. Khi chúng ta lưu trự tập dữ liệu nhất đinh, chúng ta có thể sử dụng trong value object.
Khi nào nó trở nên hữu ích?
Hãy làm những gì là tốt nhất cho bạn và những gì có ý nghĩa trong bất kỳ tình huống nào.
Đi xa hơn nữa, mặc dù có một số hướng dẫn tôi sử dụng để giúp tôi đưa ra quyết định. Nếu bạn nghĩ về một nhóm các phương pháp này là có liên quan, với các value object expressive hơn. Những expressiveness này có nghĩa là một value object nên đại diện cho một tập hợp dữ liệu riêng biệt, trong đó sự phát triển trung bình của bạn có thể suy luận đơn giản chỉ bằng cách nhìn vào tên của object.
Làm cách nào để thực hiện?
Value object nên theo một số rule sau:
- Value object nên có nhiều attributes
- Attributes không nên thay đổi trong suốt vòng đời của object
- Bình đẳng được xác định bởi các attributes của object.
Ví dụ sau đây, tôi sẽ khởi tạo một EntryStatus value object để trừu tượng Entry#status_weather và Entry#status_landform attributes như sau:
Lưu ý: Đây chỉ là một Plain Old Ruby Object (PORO) không kế thừa từ ActiveRecord::Base. Chúng ta sẽ định nghĩa reader methods cho các attributes và assign khi khởi tạo. Chúng tôi cũng sử dụng một mixin so sánh để so sánh đối tượng sử dụng (<=>) method.
Chúng ta có thể sửa model Entry để sử dụng value obejct đã tạo ra:
Chúng ta sửa method create trong EntryController để sử dụng giá trị mới của object như sau:
Service Objects là gì?
Công việc của một service object là xử một logic nghiệp vụ cụ thể. Không giống như "fat model", nơi mà một lượng ít các object mà chứa nhiều các method cho tất cả các logic cần thiết. Sử dụng service object trong nhiều class với các mục đích riêng biệt.
Vậy lợi ích của việc này là gì?
- Decoupling(Tách biệt). Service object giúp bạn đạt được sử cô lập hơn giữa các object.
- Visibility(Sự hiển thị). Service object (nếu như đặt tên tốt) sẽ chỉ ra được một ứng dụng làm gì. Tôi chỉ cần lướt qua thư mục các services để thấy được khả năng một ứng dụng cung cấp.
- Clean-up models và controllers. Controller biến yêu cầu (params, session, cookies) vào arguments, đẩy xuống các service và chuyển hướng hoặc làm theo các service response. Trong khi đó model chỉ liên quan tới các associations và persistence. Trích xuất code từ controllers/models tới service objects sẽ hỗ trợ SRP và làm code tách biệt. Công việc của model chỉ là làm việc với các associations và thêm sửa xóa các record, còn service object có một công việc riêng(single responsibility - SRP). Điều này sẽ dần tới code sẽ đc thiết kế tốt hơn và unit test tốt hơn.
- DRY và Embrace change. Chúng ta sẽ đảm bảo một service có thể đơn giản và nhỏ nhất có thể. Các service object sẽ tương tác lẫn nhau và có thể sử dụng lại
- Dọn dẹp và tăng tốc độ test của bạn. Các service sẽ đơn giản và nhanh hơn để test vì chúng là các object Ruby nhỏ với 1 điểm truy cập (việc gọi method). Các service phức tạp sẽ gọi tới các service khác, nên bạn hoàn toàn có thể chia nhỏ test ra. Bên cạnh đó, service object cũng sẽ dễ dàng mock/stub các object có liên quan mà không cần load trong suốt Rails environment.
- Có thể gọi ở bất kì đâu. Chúng ta có thể gọi các service ở controller, cũng như service khác, DelayedJob / Rescue / Sidekiq Jobs, Rake tasks, console, ...
Tuy nhiên, Service object cũng tồn tại một số hạn chế nhất định: nó có thể quá mức cần thiết (overkill) để sử dụng cho một action đơn giản. Trong trường hợp này, bạn có thể sẽ làm code phức tạp hơn là.
Khi nào chúng ta nên sử dụng serivce object? Thực tế không có các quy tắc cụ thể nào trong việc sử dụng cả. Thông thường, serice object sẽ tốt hơn cho các hệ thông vừa và lớn, nó sẽ vào khối lượng logic cần xử lí của các tiêu chuẩn CRUD.
Vì vậy, bất cứ khi nào bạn thấy một đoạn code có thể không thuộc về thư mục mà bạn đã thêm nó, nó có thể là một ý tưởng tốt để xem xét lại và xem nếu nó phải đi đến một service object thay thế.
Dưới đây là một số chỉ số khi sử dụng service object:
- Action phức tạp.
- Các actions xảy ra trên nhiều model.
- Action tương tác với các service bên ngoài.
- Action không phải là một mối quan tâm cốt lõi của model cơ bản.
- Có nhiều cách để thể hiện một action.
Bạn nên thiết kế service object như thế nào?
Thiết kế một service object khá đơn giản, vì bạn không cần phải thêm một gem nào đặc biệt cả, không cần học một DSL mới và có thể nhiều hơn hoặc ít hơn dựa vào kỹ năng thiết kế phần mềm mà bạn đã có. Tôi thường đi theo hướng dẫn và conventions để thiết kể service object:
- Không lưu state trong service object
- Sử dụng instance method và không dùng class method
- Nên có ít các public method (tốt nhất nên là một để thỏa mãn SRP)
- Các method nên trả về một object và không nên là boolean
- Service đặt dưới thứ mục app/services. Các bạn nên đặt các thư mục con cho các phần logic lớn. Ví dụ file app/services/report/generate_weekly.rb sẽ định nghĩa Report::GenerateWeekly trong khi app/services/report/publish_monthly.rb sẽ định nghĩa Report::PublishMonthly.
- Service nên đặt tên bắt bằng một động từ (và không là một động từ ở cuối): ApproveTransaction, SendTestNewsletter, ImportUsersFromCsv.
- Service đáp ứng với các phương thực gọi. Việc sử dụng các động từ khác làm cho nó có một chút dư thừa: ApproveTransaction.approve() đọc không tốt lắm. Ngoài ra, phương pháp gọi là phương pháp thực tế cho các đối tượng lambda, procs, và method object.
Nếu như bạn nhìn vào StatisticsController#index, bạn sẽ thấy một nhóm các method (weeks_to_date_from, weeks_to_date_to, avg_distance, ...) đặt trong controller. Điều này thực sự không tốt. Hãy xem xét các nhánh nếu bạn muốn generate report hàng tuần ra khỏi statistics_controller. Trong tường hợp này, hãy tạo ra một Report::GenerateWeekly và xử lí logic báo cáo trong StatisticsController:
StatisticsController#index sẽ như sau:Bằng cách áp dụng Service object pattern, chúng tã đã thay đổi việc xử lí code xung quanh một hành động cụ thể, phức tạp và thúc đẩy việc tạo ra các phương pháp nhỏ hơn, rõ ràng hơn.
Query Object là gì? Query Object là một PORO chịu tách nhiệm cho một database query. Nó có thể được sử dụng lại ở các chỗ khác nhau trong ứng dụng trong khi có thể ẩn đi query logic. Nó cũng cung cấp một đơn vị bị cô lập tốt để test.
Bạn cần trích xuất các truy vấn SQL / NoSQL phức tạp vào class của mình.
Mỗi query object chịu trách nhiệm trả về kết quả một tập hợp kết quả dựa trên các tiêu chí / quy tắc nghiệp vụ (business rules)
Trong ví dụ sau đây, chúng ta sẽ không có bất kí query nào phức tạp, nên sử dụng Query object sẽ không có tác dụng. Tuy nhiên để thể hiện mục đích, hãy trích xuất query trong Report::GenerateWeekly#call và tạo ra app/queries/generate_entries_query.rb
Thêm vào trong Report::GenerateWeekly#call, hãy thay thế:
def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( ... ) end end
bằng:
def call weekly_grouped_entries = GroupEntriesQuery.new(@user).call weekly_grouped_entries.map do |week, entries| WeeklyReport.new( ... ) end end
Query object pattern giúp cho model của bạn liên kết chặt chẽ với một hành vi của một class (class’ behavior), trong khi vẫn đảm bảo controller được clean. Vì chúng không có gì hơn plain ola Ruby class, query object không cần phải kế thừa từ ActiveRecord::Base, và không phải chịu trách nhiệm gì khác hơn là các truy vấn thực hiện.
Trích xuất việc khởi tạo Entry vào một service object
EntriesController#create sẽ như sau:
def create begin CreateEntry.new(current_user, entry_params).call flash[:notice] = 'Entry was successfully created.' rescue Exception => e flash[:error] = e.message end redirect_to root_path end
Trên đây tôi đã trình bày cho các phần về một số convention và rule khi refactor code và Value Object, Service Object và Query Object, cùng với các lưu ý cũng như các lợi hại của việc sử dụng các cách refactor code trên. Ở phần tiếp theo tôi sẽ trình bày thêm về Form Object, cách thêm callback vào Service Object, sử dụng Decorator và tổng hợp lại cấu trúc của Rails application sau khi refactor. Cảm ơn các bạn đã theo dõi.