Làm hoặc không làm. Đừng "thử" - Object#try

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ánh NoMethodErrror?
  • Hoặc tệ hơn nữa là client là một polymorphic, đặt try vào để phòng 1 số model thì có implement method addresses, 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à . 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 NULLFOREIGN 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