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ể.