Sơ lược về ActiveRecord và ActiveModel Attributes API ở Rails 5

Rails 5 là là phiên bản mới của Rails với nhiều thay đổi và bổ sung thú vị. Thay đổi đáng giá nhất chắc chắn là ActionCable - tầng trung gian chịu trách nhiệm liên kết giữa app và websockets. Tuy nhiên, có một chức năng hứa hẹn mang đến những bước nhảy lớn cho app, nhưng bị ánh sáng của ActionCable che khuất. Chức năng đó là Attributes API.

Khai báo ActiveRecord Attributes theo phương pháp cũ

Đặt trường hợp ta muốn làm một app hỗ trợ đặt phòng khách sạn, ta cần có một model lưu trữ và xử lý các yêu cầu đặt chỗ, gọi model đó là Reservation. Để đơn giản cho ví dụ, ta chỉ cần hai trường date start_dateend_date để thể hiện thời hạn đặt và trường price để lưu trữ giá cả, một trường rất quan trọng trừ khi đây là app của một tổ chức từ thiện 😉. Bây giờ nếu muốn đặt giá trị mặc định cho start_dateend_date là 1 và 8 ngày kể từ ngày khởi tạo đối tượng mới của Reservation và trường price cần được chuyển thành integer, để có thể xử lý những giá trị kiểu như "$1000.12".

Về phần đặt các giá trị mặc định, ta sử dụng callback after_initialize để đặt một giá trị mặc định cho trường trừ khi giá trị đó đã được đặt. Với trường price là chỉ cần override hàm ghi giá trị Reservation#price=. Code sau khi cài đặt xong sẽ có dạng:

# app/models/reservation.rb

class Reservation < ApplicationRecord
  after_initialize :set_default_start_date
  after_initialize :set_default_end_date

  def price= value
    return super(0) unless value.to_s.include?("$")

    price_in_dollars = value.gsub(/\$/, "").to_d
    super price_in_dollars * 100
  end

  private

  def set_default_start_date
    self.start_date = 1.day.from_now if start_date.blank?
  end

  def set_default_end_date
    self.end_date = 8.days.from_now if end_date.blank?
  end
end

Tất nhiên là đoạn code trên vẫn hoạt động trong phiên bản mới, nhưng nó sẽ lặp đi lặp lại ở nhiều model tương đương và trông không được gọn gàng lắm, và sẽ tốt hơn nếu có một phương pháp tiếp cận có tính khai báo tốt hơn, hạn chế việc ghi đè phương thức. Liệu Rails có cung cấp một phương thức dựng sẵn nào giải quyết vấn đề này trong ActiveRecord không?

Câu trả lời là có, cái tên được nhắc đến ở đây là ActiveRecord Attributes API.

Phương pháp mới với Attributes API

Bắt đầu từ Rails 5 ta có thể sử dụng Attributes API trong các model. Chỉ cần khai báo tên của attribute cùng với hàm attribute, type của nó và cung cấp giá trị mặc định (không bắt buộc). Điều tuyệt vời ở đây là các attribute không bị giới hạn bới database, mà có có thể khai báo các attribute ảo.

Với model Reservation của chúng ta, ta có thể refactor code bằng Attributes API:

# app/models/reservation.rb

class Reservation < ApplicationRecord
  attribute :start_date, :date, default: ->{1.day.from_now}
  attribute :end_date, :date, default: ->{8.days.from_now}

  def price= val
    return super(0) unless value.to_s.include?("$")

    price_in_dollars = value.gsub(/\$/, "").to_d
    super price_in_dollars * 100
  end
end

Bây giờ code đã trông gọn hơn, hãy cũng kiểm tra xem code có hoạt động không:

2.3.1 :001 > reservation = Reservation.new
 => #<Reservation id: nil, start_date: "2016-12-03", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :002 > reservation.start_date
 => Sat, 03 Dec 2016
2.3.1 :003 > reservation.end_date
 => Sat, 10 Dec 2016
2.3.1 :004 > reservation = Reservation.new start_date: 3.days.from_now
 => #<Reservation id: nil, start_date: "2016-12-05", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :005 > reservation.start_date
 => Mon, 05 Dec 2016

Đây chính xác là những gì chúng ta cần. Nhưng còn việc convert trường price? Như đã thấy ở yêu cầu của trường, các tối ưu nhất là định nghĩa một type mới. Việc này thực sự rất đơn giản, chỉ cần tạo một class kế thừa từ ActiveRecord::Type::Value hoặc một type đã có sẵn, ví dụ như ActiveRecord::Type::Integer, định nghĩa method cast và đăng ký type mới. Trong ví dụ của chúng ta định nghĩa một type price mới:

class PriceType < ActiveRecord::Type::Integer
  def cast value
    return super if value.kind_of?(Numeric)
    return super unless value.to_s.include?("$")

    price_in_dollars = BigDecimal.new value.gsub(/\$/, "")
    super price_in_dollars * 100
  end
end

ActiveRecord::Type.register :price, Price

Sử dụng Attributes API cho attribute price:

# app/models/reservation.rb

class Reservation < ApplicationRecord
  attribute :start_date, :date, default: ->{1.day.from_now}
  attribute :end_date, :date, default: ->{8.days.from_now}
  attribute :price, :price
end

Kiểm tra xem nó có chạy được không:

2.3.1 :001 > reservation = Reservation.new
 => #<Reservation id: nil, start_date: "2016-12-03", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :002 > reservation.price = "$100.12"
 => "$100.12"
2.3.1 :003 > reservation.price
 => 10012

Bây giờ code trông đã đẹp hơn và tái sử dụng tốt hơn.

Attributes API còn có thêm các feature khác, ví dụ như tùy chọn array hoặc range và làm việc với attribute như với các array và range:

# app/models/reservation.rb

class Reservation < ApplicationRecord
  attribute :start_date, :date, default: ->{1.day.from_now}
  attribute :end_date, :date, default: ->{8.days.from_now}
  attribute :price, :money
  attribute :virtual_array, :integer, array: true
  attribute :virtual_range, :date, range: true
end
2.3.1 :001 > reservation = Reservation.new virtual_array: ["1.0", "2"], virtual_range: "[2016-01-01,2017-01-1]"
 => #<Reservation id: nil, start_date: "2016-12-03", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :002 > reservation.virtual_array
 => [1, 2]
2.3.1 :003 > reservation.virtual_range
 => Fri, 01 Jan 2016..Sun, 01 Jan 2017

Attributes API như vậy đã rất tuyệt rồi, nhưng đó chưa phải là tất cả. Ta có thể dùng custom type để query database, chỉ cần định nghĩa method serialize cho type:

class PriceType < ActiveRecord::Type::Integer
  def cast(value)
    return super if value.kind_of?(Numeric)
    return super if !value.to_s.include?('$')

    price_in_dollars = BigDecimal.new(value.gsub(/\$/, ''))
    super(price_in_dollars * 100)
  end

  def serialize(value)
    cast(value)
  end
end

Với phương pháp này ta có thể đưa vào truy vấn các giá trị thô của price và Rails tự động convert giá trị để cho truy vấn đúng.

Reservation.where(price: "$100.12")
 => Reservation Load (0.3ms)  SELECT "reservations".* FROM "reservations" WHERE "reservations"."price" = $1  [["price", 10012]]

Nếu muốn tìm hiểu thêm danh sách các type dựng sẵn hoặc nghiên cứu thêm, xin vui lòng tham khảo docs.

Một chút về ActiveModel Attributes API

Phần trên đã giới thiệu về ActiveRecord Attributes API, nhưng như đã ghi ở trên tiêu đề, đây là phần nói về ActiveModel. Có một tin xấu và một tin tốt.

Tin xấu là API vẫn chưa được hỗ trợ chính thức trong Rails core, nhưng chắc chắn là nó sẽ trở thành một phần của ActiveModel trong thời gian tới.

Tin tốt là có một gem cung cấp Attributes API cho ActiveModel và hoạt động gần giống như ActiveRecord Attributes. Tên của gem đó là ActiveModelAttributes.

Chỉ cần định nghĩa ActiveModel model, include ActiveModel::ModelActiveModelAttributes rồi đến các attribute và type của chúng sử dụng class method attribute:

class MyAwesomeModel
  include ActiveModel::Model
  include ActiveModelAttributes

  attribute :description, :string, default: "default description"
  attribute :start_date, :date, default: ->{Date.new(2016, 1, 1)}
end

Ta cũng có thể thêm các custom type. Chỉ cần khai báo class và kế thừa từ ActiveModel::Type::Value hoặc một type có sẵn như ActiveModel::Type::Integer, định nghĩa cast method và đăng ký type mới:

class MoneyType < ActiveModel::Type::Integer
  def cast(value)
    return super if value.kind_of?(Numeric)
    return super if !value.to_s.include?('$')

    price_in_dollars = BigDecimal.new(value.gsub(/\$/, ''))
    super(price_in_dollars * 100)
  end
end

ActiveModel::Type.register(:money, MoneyType)

class MyAwesomeModel
  include ActiveModel::Model
  include ActiveModelAttributes

  attribute :price, :money
end

Kết luận

ActiveRecord Attributes API chắc chắn là một sự bổ sung tuyệt vời cho Rails 5. Mặc dù chưa được hỗ trợ cho ActiveModel, gem ActiveModelAttributes có thể được thêm vào và sử dụng dễ dàng cung cấp các chức năng tương tự.

Nguồn