12/08/2018, 15:17

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 3

Trong bài viết trước mình đã giới thiệu tới các bạn một số cách để refactor code bao gồm: sử dụng Value Object, Service Object, và Query Object. Trong nội dung của bài viết này tôi sẽ trình bài thêm về cách sử dụng Form Object, sử dụng callback trong Service Object và cách sử dụng Decorator. Đầu ...

Trong bài viết trước mình đã giới thiệu tới các bạn một số cách để refactor code bao gồm: sử dụng Value Object, Service Object, và Query Object. Trong nội dung của bài viết này tôi sẽ trình bài thêm về cách sử dụng Form Object, sử dụng callback trong Service Object và cách sử dụng Decorator.

Đầu tiên theo như nội dung chúng ta đã đề cập trước khi bắt tay vào việc refactoring, trong models sẽ chỉa chứa các associations và các constants, không có thêm bất cứ thứ gì khác (các validatation và callback). Chính vì lí do đó hãy bắt đầu xóa đi callback và sử dụng Form Object thay thế.

Một Form Object là một Plain Old Ruby Object (PORO). Nó sẽ được gọi thông qua controrller hoặc service object bất cứ khi nào cần thao tác với database.

Tại sao lại sử dụng Form Object?

Khi bắt đầu refactor code, chúng ta luôn phải nhớ single responsibility principle (SRP). SRP giúp bạn có đưa ra một thiết kế tốt hơn xung quanh việc một class nên làm gì.

Ví dụ mô hình bảng cơ sở dữ liệu (database table model) - một ActiveRecord model theo context của Rails, đại diện cho một bản ghi cơ sở dữ liệu duy nhất trong code. Do đó, không có lý do để nó được quan tâm đến bất cứ điều gì người dùng đang làm.

Đó là lí do vì sao chúng ta cần sử dụng Form Object. Nó chịu trách nhiệm đại diện cho một form trong app. Vì vậy, mỗi trường đầu vào có thể được coi như là một thuộc tính trong lớp. Nó có thể xác nhận rằng các thuộc tính này đáp ứng một số validation rules và nó có thể truyền dữ liệu đúng đắn đến nơi nó cần phải đi (ví dụ: model hoặc có thể là trình tạo truy vấn tìm kiếm).

Khi nào chúng ta nên sử dụng Form Object?

  • Khi bạn muốn đưa validation ra khỏi Rails models
  • Khi mà nhiều models có thể được cập nhật thông qua một form.

Điều này cho phép bạn đặt tất cả các logic (quy ước đặt tên - naming conventions, validations, ...) vào một nơi.

Làm sao để tạo một Form Object?

  • Tạo một class Ruby đơn giản
  • Include ActiveModel::Model (trong Rails 3, phải include Naming, Conversion, and Validations thay thế)
  • Bắt đầu sử dụng lớp biểu mẫu mới của bạn như thể nó là một ActiveRecord model bình thường, sự khác biệt lớn nhất là bạn không thể persist dữ liệu được lưu trữ trong đối tượng này.

Lưu ý rằng bạn có thể sử dụng gem reform, nhưng để stick với POROS chúng ta sẽ tạo entry_form.rb như sau:

Chúng ta sẽ chỉnh sửa class CreateEntry để dụng form EntryForm:

     class CreateEntry
       
       ......
       ......

        def call
          @entry_form = ::EntryForm.new(@params)

          if @entry_form.valid?
             ....
          else
             ....
          end
        end
      end

Lưu ý: Một số bạn sẽ nói rằng không cần truy cập Form Object từ Service Object và chúng ta chỉ có thể gọi Form Object trực tiếp từ controllers, đây là một đối số hợp lệ. Tuy nhiên, tôi muốn có flow rõ ràng, và đó là lý do tại sao tôi luôn luôn gọi Form Object từ Service Object.

Như đã nói ở trên, trong models sẽ không chưa validations và callbacks. Bên trên chúng ta đã chuyển validation vào Form Object. Tuy nhiên hiện tại vẫn còn sử dụng mốt số callback (after_create trong Entry model compare_speed_and_notify_user).

Tại sao lại phải cần xóa callback khỏi model?

Đầu tiên chúng ta cần phải quay ngược về khái niệm của callback: Callbacks là các phương thức được thực thi tại một thời điểm nhất định trong vòng đời của object (created, saved, updated, deleted, validated hoặc load từ database). Callbacks cho phép thực thi các thao tác logic trước hoặc sau sự thay đổi trạng thái của một object.

Rails developers thường bắt đầu nhận thấy đau đầu trong quá trình kiểm thử. Nếu bạn không kiểm thử các ActiveRecord model của mình, bạn sẽ bắt đầu nhận thấy sự khó khăn sau đó khi ứng dụng của bạn phát triển và cần phải có logic để gọi hoặc tránh gọi lại.

after_* callbacks chủ yếu được sử dụng liên quan đến việc tiết kiệm hoặc persisting object

Khi đối tượng được lưu, mục đích (tức là trách nhiệm) của đối tượng đã được hoàn thành. Vì vậy, nếu chúng ta vẫn thấy callbacks được triệu gọi sau khi đối tượng được lưu, những gì chúng ta có thể thấy là callbacks đến bên ngoài phạm vi trách nhiệm của đối tượng, và đó là khi chúng ta gặp rắc rối.

Trong trường hợp của này, chúng ta sẽ gửi một tin nhắn SMS tới người dùng sau khi lưu một entry, không thực sự liên quan đến tên miền Entry. Một cách đơn giản để giải quyết vấn đề là chuyển callbacks tới Service Object liên quan. Sau tất cả, gửi một tin nhắn SMS cho người dùng cuối có liên quan đến CreateEntry Service Object và không phải là Entry model.

Khi làm như vậy, chúng ta không còn phải tóm tắt phương thức compare_speed_and_notify_user trong các kiểm thử. Chúng ta đã tạo ra một entry mà không yêu cầu gửi SMS, và đang theo thiết kế hướng đối tượng tốt bằng cách đảm bảo các lớp có trách nhiệm duy nhất (SRP).

Chúng ta sẽ sửa lại CreateEntry như sau:

Chúng ta có thể dễ dàng sử dụng Draper, tôi sẽ stick vào POROs vì lợi ích của bài viết này.

Những gì chúng ta cần là một lớp mà sẽ gọi các phương pháp trên các decorated object.

Ta có thể sử dụng method_missing để implement, nhưng ở đây chúng ta sử dụng thư viện chuẩn của Ruby là SimpleDelegator.

Đoạn code dưới đây sẽ khai báo các sử dụng SimpleDelegator để implement base decorator của chúng ta:

# app/decorators/base_decorator.rb

    require 'delegate'


    class BaseDecorator < SimpleDelegator
      def initialize(base, view_context)
        super(base)
        @object = base
        @view_context = view_context
      end


      private


      def self.decorates(name)
        define_method(name) do
          @object
        end
      end


      def _h
        @view_context
      end
    end

Vì sao cần _h method?

Phương thức này hoạt động như một proxy cho view context. Theo mặc định, view context là một thể hiện của một view class, mặc định là ActionView :: Base. Bạn có thể truy cập view helper như sau:

  _h.content_tag :div, 'my-div', class: 'my-class'

Để thuận tiện hơn, chúng ta sẽ thêm phương thức decorate vào ApplicationHelper như sau:

# app/helpers/application_helper.rb

    module ApplicationHelper


      # .....


      def decorate(object, klass = nil)
        klass ||= "#{object.class}Decorator".constantize
        decorator = klass.new(object, self)
        yield decorator if block_given?
        decorator
      end


      # .....


    end

Bây giờ chúng ta sẽ chuyển EntriesHelper helpers vào decorators:

    # app/decorators/entry_decorator.rb
    
    class EntryDecorator < BaseDecorator
      decorates :entry


      def readable_time_period
        mins = entry.time_period
        return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60
        Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe
      end


      def readable_speed
        "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe
      end
    end

Cuối cùng chúng ta có thể sử dụng phương thức readable_time_periodreadable_speed như sau:

    # app/views/entries/_entry.html.erb
    -  <td><%= readable_speed(entry) %> </td>
    +  <td><%= decorate(entry).readable_speed %> </td>
    -  <td><%= readable_time_period(entry) %></td>
    +  <td><%= decorate(entry).readable_time_period %></td>

Project của chúng ta bây giớ đã có nhiều file hơn, tuy nhiên điều đó không nhất thiết là một điều xấu (ví dụ này chỉ dành cho mục đích minh họa và không nhất thiết là một trường hợp sử dụng tốt cho tái cấu trúc):

app
    ├── assets
    │   └── ...
    ├── controllers
    │   ├── application_controller.rb
    │   ├── entries_controller.rb
    │   └── statistics_controller.rb
    ├── decorators
    │   ├── base_decorator.rb
    │   └── entry_decorator.rb
    ├── forms
    │   └── entry_form.rb
    ├── helpers
    │   └── application_helper.rb
    ├── mailers
    ├── models
    │   ├── entry.rb
    │   ├── entry_status.rb
    │   └── user.rb
    ├── queries
    │   └── group_entries_query.rb
    ├── services
    │   ├── create_entry.rb
    │   └── report
    │       └── generate_weekly.rb
    └── views
        ├── devise
        │   └── ..
        ├── entries
        │   ├── _entry.html.erb
        │   ├── _form.html.erb
        │   └── index.html.erb
        ├── layouts
        │   └── application.html.erb
        └── statistics
            └── index.html.erb

Mặc dù bài viết tập trung vào Rails, nhưng RoR không phải là sự phụ thuộc của các service object đã được mô tả và các PORO khác. Bạn có thể sử dụng cách tiếp cận này với bất kỳ web framework, mobile hoặc console app.

Bằng cách sử dụng MVC làm kiến trúc của các ứng dụng web, mọi thứ vẫn kết nối và làm cho bạn đi chậm hơn vì hầu hết các thay đổi đều có tác động đến các phần khác của ứng dụng. Ngoài ra, nó buộc bạn phải suy nghĩ nơi để đặt một số business logic - nên ở model, controller hay view?

Bằng cách sử dụng PORO đơn giản, chúng ta đã chuyển business logic sang các model hoặc service không kế thừa từ ActiveRecord, vốn đã là một thắng lợi lớn, chưa kể đến chúng tôi có code sạch hơn, hỗ trợ SRP và các bài kiểm tra đơn vị nhanh hơn.

Clean architecture đặt các use case ở giữa / trên cùng của cấu trúc của bạn để bạn có thể dễ dàng xem ứng dụng của mình làm việc gì. Nó cũng làm cho nó dễ dàng hơn để áp dụng thay đổi vì nó là nhiều hơn module và cô lập.

Tôi hy vọng tôi đã chứng minh làm thế nào sử dụng Plain Old Ruby Objects và nhiều abstractions decouples concern, đơn giản hóa việc thử nghiệm và giúp produce clean, maintainable code.

0