Value Objects trong Ruby on Rails
Bài đăng này đã không được cập nhật trong 7 năm
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ể.
All rights reserved