12/08/2018, 15:43

Value Objects trong Ruby on Rails

Giới thiệu Trong bài viết này, tôi sẽ đề cập đến value objects, cách để sử dụng kỹ thuật này như thế nào và các dấu hiệu để xác định value objects trong một ứng dụng Rails. Mục tiêu khi viết code là làm đơn giản tối đa models và controllers bằng cách chia nhỏ thành các class. Và để đạt được ...

Giới thiệu

Trong bài viết này, tôi sẽ đề cập đến value objects, cách để sử dụng kỹ thuật này như thế nào và các dấu hiệu để xác định value objects trong một ứng dụng Rails.

Mục tiêu khi viết code là làm đơn giản tối đa models và controllers bằng cách chia nhỏ thành các class. Và để đạt được điều đó, ta sử dụng kỹ thuật tạo các value object. Khi code, tôi thích tạo ra nhiều class nhỏ hơn là viết một vài class lớn, vì với các class nhỏ, ta có thể dễ dàng để test, thay đổi và hiểu nhiệm vụ của class.

Tôi sẽ đưa ra một vài tình huống và cung cấp một vài ví dụ value object mà có thể làm cho ứng dụng trở nên đơn giản và dễ dùng hơn khi làm việc.

Value object là gì?

Một value object biểu diễn một thuộc tính đơn giản mà sự so sánh bằng nhau được dựa vào giá trị của nó, nghĩa là 2 đối tượng khác nhau được coi là bằng nhau khi chúng có cùng giá trị. Value object là không thay đổi. Khi định nghĩa một value object trong Ruby, chúng ta nên định nghĩa các phương thức == hay <=> để chúng ta so sánh chúng dựa theo giá trị của chúng.

Các đối tượng cơ bản như Symbol, String, Integer và Range trong Ruby là các ví dụ về value object đơn giản nhất.

Tại sao chúng ta cần value object?

Việc xác định các value object trong ứng dụng Rails có thể làm đơn giản hệ thống của bạn một cách đáng kể. Ưu điểm của việc tối ưu code theo value objects và tận dụng value objects là để

  • Chia nhỏ các chức năng
  • Cho phép kết hợp hành vi của class với dữ liệu, gắn chức năng của class vào dữ liệu mà không làm lạm dụng model
  • Cô lập các chức năng của code để có thể dễ dàng viết test
  • Loại bỏ lặp code
  • Cải thiện khả năng hiểu code và tổ chức code. Các hành động liên quan tới một dữ liệu cụ thể được tập trung trong một khu vực duy nhất, thay vì trải rộng trong toàn bộ code

Làm thế nào để xác định được value object?

Thời điểm cần thiết để tạo một value object là khi:

  • Tất cả các tham số cùng được sử dụng cùng nhau tại mọi thời điểm
  • Một thuộc tính liên quan tới một hành vi
  • Hai thuộc tính có quan hệ chặt chẽ là giá trị và đơn vị
  • Lớp có thể đếm được

Tất cả các tham số cùng được sử dụng cùng nhau tại mọi thời điểm

Trường hợp này được xảy ra khi chúng ta có 2 hay nhiều tham số được truyền vào và sử dụng đồng thời tại tất cả các thời điểm, được biết đến như cụm dữ liệu. VD, một khoảng thời gian.

Khi start_date và end_date được truyền đồng thời tại mọi thời điểm trong hàm, chúng ta có thể tạo một class DateRange với các thuộc tính là start_date và end_date và class này giữ chức năng như cột start_date và end_date trong một đối tượng ActiveRecord và đi kèm với tất cả các thuộc tính liên quan, bao gồm include_date?(date), include_date_range?(date_range), overlap_date_range?(date_range) và to_s.

class DateRange
  attr_reader :start_date, :end_date

  def initialize(start_date, end_date)
    @start_date, @end_date = start_date, end_date
  end

  def include_date?(date)
    date >= start_date && date <= end_date
  end

  def include_date_range?(date_range)
    start_date <= date_range.start_date && end_date >= date_range.end_date
  end

  def overlap_date_range?(date_range)
    start_date <= date_range.end_date && end_date >= date_range.start_date
  end

  def to_s
    "from #{start_date.strftime('%d-%B-%Y')} to #{end_date.strftime('%d-%B-%Y')}"
  end
end

Đây chỉ là một đối tượng Ruby cơ bản, không được kế thừa từ ActiveRecord:Base. Class này được sử dụng với một model Event bao gồm các cột như name, description, address_city, address_state, starts_at, end_at.

class Event < ActiveRecord::Base
  def date_range
    DateRange.new(start_date, end_date)
  end

  def date_range=(date_range)
    self.start_date = date_range.start_date
    self.end_date = date_range.end_date
  end
end

Khi đó, ta có thể sử dụng bằng cách:

> event = Event.create(name: 'Ruby conf', start_date: Date.today, end_date: Date.today + 1.days)
> event.date_range
=> #<DateRange:0x007fd8760c2690 @start_date=Tue, 06 Jun 2017, @end_date=Fri, 16 Jun 2017>
> event.date_range.include_date?(Date.today)
=> true
> event.date_range.include_date_range?(DateRange.new(Date.today, Date.today + 2.days))
=> false
> event.date_range.include_date_range?(DateRange.new(Date.today, Date.today + 1.days))
=> true

Ví dụ khác, ta có model Person với một Address duy nhất, class Person có các thuộc tính như name, address_city và address_state:

class Person < ActiveRecord::Base
  def address
    Address.new(address_city, address_state)
  end

  def address=(address)
    self.address_city = address.city
    self.address_state = address.state
  end
end

Khi đó ta có value object Address:

class Address
  attr_reader :city, :state

  def initialize(city, state)
    @city, @state = city, state
  end

  def ==(other_address)
    city == other_address.city && state == other_address.state
  end
end

khi đó, ta có thể sử dụng bằng cách:

> gary = Person.create(name: "Gary")
> gary.address_city = "Brooklyn"
> gary.address_state = "NY"
> gary.address
=> #<Address:0x007fcbfcce0188 @city="Brooklyn", @state="NY">

> gary.address = Address.new("Brooklyn", "NY")
> gary.address
=> #<Address:0x007fcbfa3b2e78 @city="Brooklyn", @state="NY">

Một ưu điểm của việc chia nhỏ code của model và tạo value object là bạn có thể tái sử dụng value object. Với ví dụ trên, ta có thể sử dụng đối tượng Address ở trong cả model Event.

Một thuộc tính liên quan tới hành vi

Một trường hợp khác cần sử dụng một value object là khi ta có một thuộc tính đơn giản cần một vài hành vi liên kết đến và các hành vi đó không liên quan đến model. Giả sử ta có model Room kế thừa từ ActiveRecord::Base với một thuộc tính degress, ta thêm một class Temperature để trả lời các câu hỏi liên quan đến các giá trị nhiệt độ.

class Temperature
  include Comparable
  attr_reader :degrees
  COLD = 20
  HOT = 25

  def initialize(degrees)
    @degrees = degrees
  end

  def cold?
    self < COLD
  end

  def hot?
    self > HOT
  end

  def <=>(other)
    degrees <=> other.degrees
  end

  def hash
    degrees.hash
  end

  def to_s
    "#{degrees} °C"
  end
end

Ngoài định nghĩa các hàm cold? và hot?, ta còn định nghĩa hàm <=>, hash, và eql?, cho phép thực hiện các hành động như sort và uniq. VD:

> room_1 = Room.create(degrees: 10)
> room_2 = Room.create(degrees: 20)
> room_3 = Room.create(degrees: 30)
> room_1.temperature.cold?
=> true
> room_1.temperature.hot?
=> false
> [room_1.temperature, Temperature.new(20), room_3.temperature, room_2.temperature].sort
=> [#<Temperature:0x007fe194378840 @degrees=10>, #<Temperature:0x007fe194378818 @degrees=20>, #<Temperature:0x007fe1943787c8 @degrees=20>, #<Temperature:0x007fe1943787f0 @degrees=30>]
> [room_1.temperature, Temperature.new(20), room_3.temperature, room_2.temperature].uniq
=> [#<Temperature:0x007fe194361e88 @degrees=10>, #<Temperature:0x007fe194361e60 @degrees=20>, #<Temperature:0x007fe194361e38 @degrees=30>]

Hai thuộc tính có quan hệ chặt chẽ là giá trị và đơn vị

Giả sử có một trường hợp là khi bạn có các cặp giá trị - đơn vị như nhiệt độ (degress và unit), money (cents và currency), distance (value and unit), ... Một cặp kiểu như thế cần một vài phương thức chuyển đổi, từ euros sang dollars, từ kelvin sang fahrenheit, từ miles sang kilometres, ... Và không chỉ chuyển đổi, mà còn cần các hành động tính toán đặc biệt nào đó. Khi đó ta cần một vài xử lý logic mà thông thường là thêm một vài gem, chính các gem đó là các value object cơ bản.

Một vài gem phổ biến là money gem, hỗ trợ khả năng chuyển đổi money và currency bằng cách cung cấp class Money, đã được đóng gói tất cả các thông tin liên quan tới số lượng money , VD như currency và giá trị của nó. Bạn có thể sử dụng một model như Product:

class Product < ActiveRecord::Base
  def cost
    Money.new(cents, currency)
  end

  def cost=(cost)
    self.cents = cost.cents
    self.currency = cost.currency.to_s
  end
end

Trong trường hợp này, khi yêu cầu trả về giá một product, chúng ta sẽ sử dụng một thực thể Money:

> product = Product.create(cost: Money.new(500, "EUR"))
> product.cost
=> #<Money fractional:500 currency:EUR>
> product.cost.cents
=> 500
> product.currency
=> "EUR"

Lớp có thể đếm được

Thông thường, ta có thể định nghĩa một value object trong rails model bằng cách tạo một class array:

class Event < ActiveRecord::Base
  SIZE = %w(
    small
    medium
    big
  )
end

Cách xử lý trên không tốt bởi vì mảng giá trị có thể được dùng cho các thuộc tính model nhưng chúng không liên quan trực tiếp đến phạm vi của model. Định nghĩa value object theo cách trên có một vài nhược điểm là không thể thêm chức năng cho value object mà không làm cho model bị mở rộng quá nhiều và không cho phép tái sử dụng object. Vì vậy , chúng ta có thể tạo một object đi kèm với dữ liệu của mảng và cũng để thêm các hàm phù hợp nếu cần. VD:

class Size
  SIZES = %w(small medium big)
  attr_reader :size

  def initialize(size)
    @size = size
  end

  def self.to_select
    SIZES.map{|c| [c.capitalize, c]}
  end

  def valid?
    SIZES.include?(size)
  end

  def to_s
    size.capitalize
  end
end

Kết luận:

Khi code, chúng ta nên chia nhỏ class và làm cho model và controller bớt cồng kềnh hơn. Trong bài viết đã cung cấp một vài ví dụ về value object được sử dụng trong các trường hợp cụ thể.

0