Sơ lược về ActiveRecord và ActiveModel Attributes API ở Rails 5
Bài đăng này đã không được cập nhật trong 8 năm
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_date
và end_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_date
và end_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::Model
và ActiveModelAttributes
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ự.
All rights reserved