Làm hoặc không làm. Đừng "thử" - Object#try
Bài đăng này đã không được cập nhật trong 3 năm
I.Mở đầu
Sử dụng method Object#try
khá phổ biến khi ta code Rails app.
Nó giúp cover được cái thằng nil value, hoặc cung cấp 1 interface động cho các Object khác nhau - gọi ra method mà nó không nhất thiết phải được implement.
Túm lại là nó giúp ta tránh được cái lỗi NoMethodError
:man_detective:
No NoMethodError exception, no problem!
Nhưng không hẳn vậy.
Việc sử dụng try sẽ khiến chúng ta gặp rắc rối không chỉ về performance, và thường thì luôn có cách khác hiệu quả hơn để xử lý mà không cần dùng tới try.
II.Object#try
Ý tưởng đằng sau Object#try
khá đơn giản: thay vì raise lên NoMethodError
exception khi gọi method từ nil
hoặc gọi method mà chưa được implement trong object, hàm này sẽ trả về giá trị là nil
.
Thử tưởng tượng bạn muốn lấy email của user đầu tiên trong DB. Để đảm bảo không gặp lỗi, thường thì sẽ viết kiểu này:
user.first.try :email
Giả sử bạn muốn viết một service dùng chung cho nhiều loại objects, sau khi save xong thì gửi notification nếu object đó đã được implement method. Với Object#try
có thể viết như sau:
class MyService
def call(object)
object.save!
object.try(:send_success_notification, "saved from MyService")
end
end
Giả sử bạn gọi ra 1 chuỗi các methods mà nó có thể nil
ở bất cứ bước nào? Object#try
cân được hết:
payment.client.try(:addresses).try(:first).try(:country).try(:name)
Vậy vấn đề gặp phải là gì?
Rõ ràng try
có khả năng xử lý được rất nhiều trường hợp, vậy vấn đề khi sử dụng nó là gì?
- Phần lớn các case có thể chạy bình thường mà không gặp phải giá trị
nil
nên ko cần try ở những thằng đó. - Đoạn code sẽ không rõ ràng, gây bối rối cho người đọc. Bạn thử xem đoạn code dưới đây muốn nói điều gì?
payment.client.try(:address)
- Có một vài thằng
payment
không cóclient
và thực sự nó có thểnil
được? - Hay cho vào để đề phòng trường hợp
client
bịnil
nên tránhNoMethodErrror
? - Hoặc tệ hơn nữa là
client
là một polymorphic, đặttry
vào để phòng 1 số model thì có implement methodaddresses
, 1 số model thì không?
Chỉ nhìn vào dòng code này, bạn không thể biết chính xác mục đích của try
ở đây là gì, vì có nhiều khả năng xảy ra.
May mắn thay, là có rất nhiều giải pháp thay thế mà bạn có thể áp dụng để tránh sử dụng try
và giúp code bạn clear hơn, "biểu cảm" hơn.
Từ đó sẽ dễ maintain, dễ đọc và ít gặp bug vì ý nghĩa của code không còn mơ hồ nữa.
III.Giải pháp
Dưới đây là một vài patterns mà bạn có thể áp dụng tùy thuộc vào hoàn cảnh mà try
đang được dùng.
Law of Demeter (aka Nguyên tắc một dấu chấm)
Code fact
Trong thần thoại Hy Lạp, Demeter là vị nữ thần của nông nghiệp, mùa màng và thiên nhiên. Tuy nhiên nữ thần Demeter không định nghĩa ra và cũng đếch liên quan tới cái nguyên tắc mà ta đang nói cả (yaoming)
Law of Demeter được phát biểu là:
Don't talk to strangers - Đừng nói chuyện với người lạ.
Nguyên tắc được đặt ra nhằm mục đích tối thiểu hóa sự phụ thuộc lẫn nhau của các thành phần trong ứng dụng.
Nếu Object A gọi methods thì chỉ nên gọi tới những thằng xung quanh mà có quan hệ với nó, không cần quan tâm tới cấu trúc bên trong của các thằng nó gọi tới ra sao.
Về mặt coding, trong nhiều trường hợp thì chỉ cần duy nhất 1 dấu "chấm" khi gọi method là đủ. Ví dụ đoạn code dưới đây là vi phạm nguyên tắc:
input.to_s.strip.split(" ").map(&:capitalize).join(" ")
Nhưng đoạn này thì không:
payment.client.address
Áp dụng Law of Demeter sẽ giúp code bạn clear và dễ maintain hơn, vì vậy trừ khi có lý do xác đáng để vi phạm, còn không thì bạn nên áp dụng nguyên tắc này.
Quay trở lại ví dụ, làm thế nào để refactor đoạn code này:
payment.client.try(:address)
Ở đây, thằng payment
đang phụ thuộc vào cấu trúc của thằng client
, nên phải thêm try
để care những trường hợp đó.
Đầu tiên hãy giảm thiểu điều đó bằng cách implement thêm hàm Payment#client_address
class Payment
def client_address
client.try :address
end
end
Thay vì gọi tới address thông qua payment.client.try(:address)
, ta đơn giản chỉ cần gọi payment.client_address
- cái mà đã implement sẵn try
rồi.
payment
ở đây không cần quan tâm tới client
có address hay không.
Tiếp tục, giờ thằng client
có 2 khả năng xảy ra:
- Bằng
nil
là hợp lệ - Bằng
nil
là không hợp lệ.
Ta thể hiện cho người đọc biết điều này bằng cách chọn 1 trong 2 đoạn code sau:
Nếu client
bằng nil là hợp lệ:
class Payment
def client_address
return nil if client.nil?
client.address
end
end
Nếu client
không bao giờ bằng nil
:
class Payment
def client_address
client.address
end
end
Viết đoạn code như vậy trông sida quá, liệu Rails có giải pháp cho vấn đề này không?
Câu trả lời là có.
ActiveSupport cung cấp 1 giải pháp để xử lý vụ này: ActiveSupport#delegate
.
Ở trường hợp đầu tiên, khi mà nil
là case hợp lệ, ta có thể viết lại như sau:
class Payment
delegate :address, to: :client, prefix: true, allow_nil: true
end
Ở trường hợp thứ hai, khi mà nil
không thể xảy ra:
class Payment
delegate :address, to: :client, prefix: true
end
Trông code giờ clear hơn nhiều, mà lại còn giảm sự phụ thuộc vào cấu trúc thằng gọi tới.
Tuy nhiên, kể cả nếu client
luôn có tương ứng với payment
, vẫn có khả năng xảy ra kết quả NoMethodError
.
Ví dụ như ta delete nhầm 1 record của client
chẳng hạn :v. Trong trường hợp đó, cần phải sửa.
Toàn vẹn dữ liệu:
Để đảm bảo toàn vẹn dữ liệu, đặc biệt là với PostgreSQL hay MySQL khá đơn giản - chúng ta chỉ cần nhớ add thêm các constraints khi tạo một bảng mới.
Hãy nhớ rằng, đây là bước xử lý ở tầng Database bên dưới, validate trên model không bao giờ là đủ và có thể bị vượt qua bất cứ lúc nào.
Để tránh trường hợp client
bị biến thành nil
, kể cả khi đã có validate presence, chúng ta cũng nên add thêm NOT NULL
và FOREIGN KEY
khi tạo bảng payments
, điều này sẽ cản được việc xóa đi record client khi mà nó có quan hệ với payment.
create_table :payments do |t|
t.references :client, index: true, foreign_key: true, null: false
end
Convert types
Tôi đã một vài lần gặp trường hợp sử dụng Object#try
khá củ chuối như sau:
params[:name].try :upcase
Đoạn code này rõ ràng chỉ ra rằng, có đoạn string nằm bên trong params với key là name
, vậy sao không đảm bảo nó là một string bằng convert method to_s
?
params[:name].to_s.upcase
Khá rõ ràng hơn rồi.
Tuy nhiên, 2 đoạn code trên không tương đương nhau.
Một đoạn sẽ trả về string nếu giá trị params[:name]
là string, nhưng nếu nó là nil
thì sẽ return nil
.
Đoạn thứ hai thì luôn trả về 1 string rồi.
Ở đây xảy ra 2 trường hợp, nếu ý đồ của tác giả là giá trị trả về của params[:name]
có thể nil
- nil là 1 case hợp lệ, thì ta hoàn toàn có cách khác clear hơn việc dùng try
return if params[:name].nil?
params[:name].to_s.upcase
Nếu tác giả muốn nó luôn trả về string thì cứ làm theo cách 2 là xong thôi
params[:name].to_s.upcase
Trong những kịch bản phức tạp hơn, ta có thể sử dụng form objects, hoặc công cụ để quản lý types kiểu như gem dry-type để xử lý, và nó cũng tương đương cái trò convert này.
Sử dụng đúng methods
Ruby rất ma thuật.
Có hàng trăm các method mà bạn còn không biết nó tồn tại, dù nó được sinh ra để xử lý cho đúng trường hợp mà bạn đang mắc phải.
Vì vậy, hãy tìm hiểu kỹ trước khi làm điều gì đó kỳ cục. =))
Xử lý nested hashes ta gặp rất nhiều khi code, đặc biệt là nếu xây dựng API. Tưởng tượng bạn đang quẩy với JSON gửi tới từ client, và muốn lấy ra client name. Đoạn payload expect sẽ là chuẩn như sau:
{
data: {
id: 1,
type: "clients",
attributes: {
name: "some name"
}
}
}
Tuy nhiên, chúng ta không thể đoán được đoạn JSON nó gửi ta thực sự như thế nào, nó có thể không đúng với cấu trúc mà ta expect.
Để giải quyết vấn đề này, có 1 cách khá dã man, đấy là dùng ... Object#try
:
params[:data].try(:[], :attributes).try(:[], :name)
Có cách khác để cải thiện nó:
params[:data].to_h[:attributes].to_h[:name]
Trông đẹp hơn rồi, nhưng không "biểu cảm" lắm.
Một trong những methods chuyên dụng cho vấn đề này là dùng Hash#fetch
- cho phép bạn tự cấp một giá trị cần return nếu như key không tìm thấy được trong Hash.
params.fetch(:data).fetch(:attributes, {}).fetch(:name)
Ngon rồi. Nhưng nếu các hash lồng nhau nhiều bậc nữa thì fetch lại bớt đẹp trai.
May mắn thay, kể từ Ruby 2.3.0, chúng ta có thể thoải mái đào bới hash bằng method Hash#dig
- không hề raise exception nếu 1 key bất kỳ trong chuỗi không tồn tại.
params.dig(:data, :attributes, :name)
Duck typing
Quay trở lại ví dụ này, dùng try
vì phải tùy thuộc vào loại object có implement method hay không:
class MyService
def call(object)
object.save!
object.try(:send_success_notification, "saved from MyService")
end
end
Ở đây có 2 giải pháp như sau:
1. Tách services: một loại có gửi notification và một loại thì không:
class MyServiceA
def call(object)
object.save!
end
end
class MyServiceB
def call(object)
object.save!
object.send_success_notification("saved from MyService")
end
end
Ta đã loại bỏ được try
. Nhưng bây giờ lại cần phải phân biệt xem những object nào thì sử dụng MyServiceA
cái này thì dùng MyServiceB
. Cách thứ 2 sẽ khả thi hơn:
2. Duck typing. Đơn giản là add thêm method send_success_notification
cho tất cả objects sẽ ném vào MyService
, và nếu nó không xử lý gì thì cứ để body method trống không:
class MyService
def call(object)
object.save!
object.send_success_notification("saved from MyService")
end
end
Một lợi ích nữa của phương pháp này là nó giúp hiểu được behaviors của objects, làm nó rõ ràng hơn nhiều so với sử dụng try
.
Null object pattern
Sử dụng lại ví dụ trên và thay đổi một chút. Giả dụ ta thêm 1 argument cho method call
và gọi ra method send_success_notification
từ chính argument đấy:
class MyService
def call(object, mailer: SomeMailer)
object.save!
mailer.send_success_notification(object, "saved from MyService")
end
end
Nó hoạt động tốt nếu ta luôn luôn muốn gửi notification. Nhưng tùy lúc có lúc không thì sao, lại try
phát:
class MyService
def call(object, mailer: SomeMailer)
object.save!
mailer.try(:send_success_notification, object, "saved from MyService")
end
end
Service.new.call(object, mailer: nil)
Tất nhiên là có cách khác rồi, ta có thể dùng Null Object Pattern và truyền sang instance NullMailer
- được implement method send_success_notification
với body trống không:
class NullMailer
def send_success_notification(*)
end
end
class MyService
def call(object, mailer: SomeMailer)
object.save!
mailer.send_success_notification(object, "saved from MyService")
end
end
MyService.new.call(object, mailer: NullMailer.new)
Về safe navigation operator (&.) thì sao?
Safe navigation operator (&.) mới được giới thiệu trong ruby 2.3.0.
Nó khá tương đồng với Object#try
nhưng bớt ảo diệu hơn - Nếu bạn gọi method từ object khác nil
, và method đó không được implement bên trong object
thì vẫn raise lên exception NoMethodError
.
User.first.try(:unknown_method) # assuming `user` is nil
=> nil
User.first&.unknown_method
=> nil
User.first.try(:unknown_method!) # assuming `user` is not nil
=> nil
User.first&.unknown_method
=> NoMethodError: undefined method `unknown_method' for #<User:0x007fb10c0fd498>
Điều đó có nghĩa sử dụng safe navigation operator là an toàn?
Không hẳn. Nó vẫn có vấn đề giống như Object#try
gặp phải, chỉ là ít hơn thôi.
Nhưng sử dụng safe navigation operator cũng khá hay trong một số trường hợp. Ví dụ:
Comment.create!(
content: content,
author: current_user,
group_id: current_user&.group_id,
)
Cái chúng ta muốn làm ở đây là tạo ra comment của current_user
, mà cái current_user
này có thể có hoặc không thuộc một group_id
.
Hoặc có thể viết là:
comment_params = {
content: content,
author: current_user,
}
comment_params[:group_id] = current_user.group_id if current_user
Comment.create!(comment_params)
Với cách nhìn của tôi thì cách thứ 2 nhìn vẫn dễ đọc hơn việc sử dụng &.
, nó là đánh đổi giữa ngắn gọn và dễ đọc.
Túm cái váy lại
Hàm Object#try
khá bá đạo, nó có thể ôm đồm việc xử lý của rất nhiều case khác nhau. Nhưng chính sự "bá đạo" đó sẽ gây mơ hồ cho người đọc để hiểu toàn bộ đoạn code mà mình đã viết. Vì vậy, nên phân tách nó ra, tùy vào từng case gặp phải mà viết code xử lý rõ ràng.
References:
All rights reserved