Single Table Inheritance - vấn đề và cách giải quyểt
Bài đăng này đã không được cập nhật trong 6 năm
I. Câu chuyện liên quan
Các dự án liên quan đến việc lưu trữ các địa chỉ thanh toán và vận chuyển của khách hàng ở Hoa Kì được cho là rất phổ biến. Ở Ba Lan, bạn có thể có nhiều địa chỉ ví dụ như địa chỉ đăng kí, địa chỉ nhà riêng hoặc địa chỉ gửi thư. Vì vậy chúng ra hãy lấy địa chỉ cho ví dụ của chúng ta.
II. Chú ý
Hãy nhớ rằng code ví dụ ở dưới đây chỉ để mô tả các vấn đề và đưa ra giải pháp. Trên thực tế, chúng ta dùng STI cho các địa chỉ thanh toán và địa chỉ giao hàng là sai. Có nhiều giải pháp sẽ tốt hơn cách dùng STI.
III. Điểm khởi đầu trong việc dùng STI
Gỉa sử người dùng (user) có nhiều địa chỉ (addresses).
class User < ActiveRecord::Base
has_many :addresses
end
class Address < ActiveRecord::Base
belongs_to :user
end
class BillingAddress < Address
end
class ShippingAddress < Address
end
Ban đầu mọi thứ nó sẽ luôn hoạt động bình thường. Và mọi thứ nó sẽ bắt đầu trở nên phức tạp hơn khi bạn bắt đầu thêm các tính năng mới vào. Rõ ràng là chúng ta đang thiếu 1 vài thứ cần validate cho các model ở trên. Vì bất kì 1 lí do gì đi nữa, chúng ta cần phải tạo ra sự khác nhau giữa các loại địa chỉ ở trên để phân biệt chúng. Vì dụ trong ShippingAddress, chúng ta muốn hạn chế số lượng quốc gia cho nó.
class Address < ActiveRecord::Base
validates_presence_of :full_name, :city, :street, :postal_code
end
class BillingAddress < Address
validates_presence_of :country
end
class ShippingAddress < Address
validates_inclusion_of :country, in: %w(USA Canada)
end
Giờ chúng ta sẽ tạo vài đối tượng để làm ví dụ:
u = User.new(login: "rupert")
u.save!
a = u.addresses.build(type: "BillingAddress", full_name: "Robert Pankowecki", city: "Wrocław", country: "Poland")
a.save!
IV.Thay đổi loại địa chỉ
STI có khả năng thay đổi kiểu của đối tượng, hay là thay đổi loại model linh động. Nhưng hãy xem vấn đề mà chúng ta đang gặp phải
a.update_attributes(type: "ShippingAddress", country: "Spain") # => true # but should be false
a.class.name # => "BillingAddress" # But we wanted ShippingAddress
a.valid? # => true # but should be false, we ship only to USA and Canada
a.reload # => ActiveRecord::RecordNotFound:
# Couldn't find BillingAddress with id=1 [WHERE "addresses"."type" IN ('BillingAddress')]
# Because we changed the type in DB to Shipping but ActiveRecord is not aware
Vấn đề ở đây là chúng ta không thể thay đổi class của đối tượng trong ví dụ trên. Vấn đề này không hề giới hạn ở trong ruby, nhiều ngôn ngữ lập trình hướng đối tượng cũng gặp phải nó. Và khi bạn nghĩ về nó, bạn sẽ hiểu rõ nó hơn rất nhiều. Tôi nghĩ rằng điêu này sẽ cho chúng ta biết 1 cái gì đó về tính thừa kế nói chung ở đây. Đó là cơ chế rất linh hoạt ở trong ngôn ngữ hướng đối tượng nhưng chúng ta nên tránh nó khi nó có khả năng thay đổi kiểu và hành vi của lớp. Chúng ta sẽ ưu tiên các giải pháp khác như sử dụng delegate, strategy hoặc là roles. Bất cứ khi nào chúng ta muốn sử dụng thừa kế, chúng ta cần phải tự hỏi rằng việc là đó nó sẽ chắc chắn không làm cho câu lệnh trở nên dài hơn. Nếu có, hãy tránh kế thừa. Ví dụ: Admin < User. Có thể user mà chúng ta tạo ra không còn làm admin nữa, nhưng làm sao để chúng ta có thể thay đổi linh hoạt từ admin thành user bây giờ. Do đó chúng ta hãy lấy admin như là 1 role ở trong bảng User. Rõ ràng, việc kế thừa dùng ở đây sẽ là không thích hợp.
V. Giải pháp
Giải pháp sẽ yêu cầu rails phải sửa ở 2 điểm. Đầu tiên là phương thức update_record phải thực hiện câu lệnh mà không hạn chế cập nhật SQL cho kiểu của đối tượng mà chúng ta muốn thay đổi nó.
Chúng ta cũng cần 1 phương thức thứ 2 (metamorphose) phụ thuộc vào phương thức ActiveRecord#becomes
module ActiveRecord
module StiFriendly
# Rails 3.2
def update(attribute_names = @attributes.keys)
attributes_with_values = arel_attributes_values(false, false, attribute_names)
return 0 if attributes_with_values.empty?
klass = self.class.base_class # base_class added
stmt = klass.unscoped.where(klass.arel_table[klass.primary_key].eq(id)).arel.compile_update(attributes_with_values)
klass.connection.update stmt
end
# Rails 4.0
def update_record(attribute_names = @attributes.keys)
attributes_with_values = arel_attributes_with_values_for_update(attribute_names)
if attributes_with_values.empty?
0
else
klass = self.class
column_hash = klass.connection.schema_cache.columns_hash klass.table_name
db_columns_with_values = attributes_with_values.map { |attr,value|
real_column = column_hash[attr.name]
[real_column, value]
}
bind_attrs = attributes_with_values.dup
bind_attrs.keys.each_with_index do |column, i|
real_column = db_columns_with_values[i].first
bind_attrs[column] = klass.connection.substitute_at(real_column, i)
end
# base_class added
stmt = klass.base_class.unscoped.where(klass.arel_table[klass.primary_key].eq(id_was || id)).arel.compile_update(bind_attrs)
klass.connection.update stmt, 'SQL', db_columns_with_values
end
end
def metamorphose(klass)
obj = becomes(klass)
obj.type = klass.name
return obj
end
end
end
class Address < ActiveRecord::Base
include ActiveRecord::StiFriendly
end
Hãy chạy thử 1 vài câu lệnh sau:
u = User.last
a = u.addresses.last # => BillingAddress instance
a.country # => Poland
a = a.metamorphose(ShippingAddress) # => ShippingAddress instance
# new object
a.update_attributes(full_name: "RP") # => false
# Stopped by validation
# Validation worked properly
# We only ship to USA and Canada
a.errors => #<ActiveModel::Errors:0x0000000352f0f0 @base=#<BillingAddress id: 1, ... >,
# @messages={:country=>["is not included in the list"]} >
# Yay!
a.update_attributes(full_name: "RP", country: "USA") # => true
a.reload # => ShippingAddress
Có 2 vấn đề tiềm ẩn ở đây:
- Các thuộc tính ảo sẽ không được sao chép, rails sẽ không biết về chúng, và rất có thể là bạn không lưu chúng ở trong biến @attributes
- Có thể thấy chúng ta đang sử dụng kết nối từ lớp base_class. Điều này không quan trọng vì hầu hết các dự án đều sử dụng chung kết nối cho tất cả các lớp ActiveRecord. Thật khó có thể nói kết nối của lớp nào nên được sử dụng khi chúng ta thay đổi loại đối tượng từ lọai này sang loại khác
Bạn xem này, đầu ra có gì đó sai sai và kiểm tra nó đơn giản như sau:
a.class # => ShippingAddress
a.errors # => #<ActiveModel::Errors:0x0000000352f0f0 @base=#<BillingAddress id: 1 ... >>
a.errors.instance_variable_get(:@base).object_id == a.object_id # => false
Khi bạn làm như vậy, ta có thể tạo ra 1 bản ghi với #metamorphose, lưu nó và xóa bản ghi cũ nếu việc lưu bản ghi mới thành công. Tất cả đều nằm chính xác trong 1 transaction. Nhưng điều này có thể còn khó khăn hơn khi có nhiều quan hệ với đối tượng cũng cần được sửa khóa ngoài. Có thể xóa bản ghi cũ trước, sau đó tạo 1 bản ghi mới với id giống với bản ghi cũ là 1 giải pháp.
VI. Delegation
Thay vì kế thừa kiểu, việc mà ngăn cản chúng ta thay đổi hành vi của đối tượng tự động, chúng ta sẽ sử dụng delegate, điều này sẽ làm cho nó trở nên dễ dàng hơn.
class Billing
def validate_address(address)
country_validator.validate(address)
end
private
def country_validator
@country_validator ||= ActiveModel::Validations::PresenceValidator.new(
attributes: :country,
)
end
end
class Shipping
def validate_address(address)
country_validator.validate(address)
end
private
def country_validator
@country_validator ||= ActiveModel::Validations::InclusionValidator.new(
attributes: :country,
in: %w(USA Canada)
)
end
end
class Address < ActiveRecord::Base
belongs_to :user
validates_presence_of :full_name, :city
validate :type_specific_validation
def type
case address_type
when 'shipping'
Shipping.new
when
'billing'
Billing.new
else
raise "Unknown address type"
end
end
def type_specific_validation
type.validate_address(self)
end
end
Hãy chạy 1 vài câu lệnh sau:
a = Address.last
a.address_type = "billing"
a.valid? # => true
a.country = nil
a.valid? # => false
a.errors # => #<ActiveModel::Errors:0x000000047d1528 @base=#<Address id: 1 ... >,
# @messages={:country=>["can't be blank"]}>
a.country = "Poland"
a.address_type = "shipping"
a.valid? # => false
a.errors # => #<ActiveModel::Errors:0x000000047d1528 @base=#<Address id: 1 ...>,
# @messages={:country=>["is not included in the list"]}>
# Yay! Just like we wanted, different validations and different behavior
# depending on address_type which can be set based on form attributes.
Trong ví dụ nhỏ của chúng ta, chúng ta chỉ đang delegate 1 vài chỗ validate nhưng trong thực tế bạn cần delegate nhiều hơn nữa. Trong 1 vài trường hợp có thể nó sẽ được dùng cho các phương thức.
class Shipping
attr_reader :address
delegate :country, :city, :full_name, to: :address
def initialize(address)
@address = address
end
def validate_address
country_validator.validate(address)
end
def to_s
"Ship me to: #{country.upcase} #{city}"
end
private
def country_validator
@country_validator ||= ActiveModel::Validations::InclusionValidator.new(
attributes: :country,
in: %w(USA Canada)
)
end
end
class Address < ActiveRecord::Base
validate :type_specific_validation
def type
case address_type
when 'shipping'
Shipping.new(self)
when 'billing'
Billing.new(self)
else
raise "Unknown address type"
end
end
def type_specific_validation
type.validate_address
end
def to_s
type.to_s
end
end
Vì vậy 2 đối tượng của chúng ta thay đổi role của delegate và đối tượng delegate phụ thuộc vào nhiệm vụ của nó cần thực hiện. Bài viết tham khảo tại: https://blog.arkency.com/2013/07/sti/
All rights reserved