12/08/2018, 15:23

Result Objects Pattern

Trong Ruby, errors và failures thường được implement bởi exceptions. Trong một số trường hợp, exceptions không phải là sự lựa chọn tốt nhất. Bài viết này sẽ trình bày một số vấn đề với exceptions và giới thiệu một giải pháp thay thế mang hướng lập trình hàm (functional) để giải quyết vấn đề xử lý ...

Trong Ruby, errors và failures thường được implement bởi exceptions. Trong một số trường hợp, exceptions không phải là sự lựa chọn tốt nhất. Bài viết này sẽ trình bày một số vấn đề với exceptions và giới thiệu một giải pháp thay thế mang hướng lập trình hàm (functional) để giải quyết vấn đề xử lý lỗi.

Giả sử đoạn code dưới đây không có bug nào, exceptions nào có thể được raise lên:

user = register_new_user(params)

Nếu không nhìn vào implementation của register_new_user thì khó mà biết được:

def register_new_user(params)
  new_user = User.new(params)
  authorize! :create, new_user

  new_user.save!

  send_welcome_email(new_user)
end

Kể cả khi đọc implementation, ta cũng khó có thể biết được khi nào thì exception được đưa ra. Một số khả năng bắn ra exceptions có thể tương đối dễ thấy đối với những người quen làm việc với Rails, tuy nhiên exception có thể được bắn ra bởi bất kỳ method nào, bất kể ActiveRecord callback hay từ những gem được thêm vào từ bên ngoài.

Vậy thì những exceptions nào ta cần phải bắt khi gọi hàm ? exceptions nào thì được coi là bug ? exceptions nào thì ta không nên bắt lại ? Không thể có một câu trả lời thỏa đáng cho method ở trên.

Nếu chúng ta không thể đoán toàn bộ các case sinh ra lỗi thì việc xử lý lỗi sẽ không được thực hiện đúng và từ đó sẽ sinh ra những bug không mong muốn.

Tôi muốn giới thiệu một cách khác để xử lý lỗi bằng việc sử dụng Result Object thông qua gem Resonad. Điểm khác biệt của phương thức này là:

  • Errors là một phần của giá trị trả về thay vì exception

  • Tách biệt các trường hợp errors với những bug không mong muốn. Lỗi mong muốn được đưa vào result.error, và bug không mong muốn sẽ là exceptions.

  • Tất cả các lỗi mong muốn đều được bắt một cách tự động

  • Result object giúp ta luôn lưu tâm đến việc xử lý lỗi.

Việc gọi một method sẽ có cấu trúc như sau:

result = register_new_user(params)
if result.success?
  handle_success(result.value)
else
  handle_failure(result.error)
end

Và register_new_user có thể được implement như thế này:

def register_new_user(params)
  authorize(:create, User.new(params))
    .and_then { |user| save_model(user) }
    .on_success { |user| send_welcome_email(user) }
end

def authorize(permission, model)
  authorize! permission, model
  Resonad.Success(model)
rescue AuthorizationFailed => error
  Resonad.Failure(error)
end

def save_model(model)
  if model.save
    Resonad.Success(model)
  else
    Resonad.Failure(model.errors)
  end
end

def send_welcome_email(user)
  UserMailer.welcome(user).deliver_now
end

Những Resonad object là wrapper cho giá trị thành công hoặc lỗi. Nếu result.success? trả về true thì result.value là giá trị trả về của việc method chạy thành công. Ngược lại, result.error sẽ chứa mô tả lỗi.

Method on_success được sử dụng để thực hiện side effect mà không ảnh hưởng đến kết quả trả về. Ở đoạn code trên, send_welcome_email được coi như luôn chạy thành công. Nếu nó có thể fail, thông qua việc raise exception, thì đó được coi là bug không mong muốn.

Các method authorize và save_model có những trường hợp lỗi mong muốn. Chúng trả về Resonad.Success hoặc Resonad.Failure. Nếu trả về Resonad.Failure thì tức là hàm chạy sai thực sự chứ không phải là bug. Chúng ta phải đưa ra những xử lý thích hợp cho những trường hợp nay.

Những method nào trả về Result Object đề có thể chain được thông qua hàm and_then. Hàm and_then chỉ chạy block của nó khi thành công và sẽ skip khi gặp failure. Failure sẽ được pass xuống chuỗi and_then và không bị thay đổi gì.

Ví dụ với register_new_user, do trả về một Resonad nên nó có thể được chain như sau:

check_registrations_are_open
  .and_then { register_new_user(params) }
  .and_then { |user| create_placeholder_data_for(user) }

Ta không cần sử dụng Result Object khi:

  • Khi method luôn chạy thành công. Ta có thể sử dụng exception ở đây để thông báo là có bug xảy ra.

  • Khi chỉ có một trường hợp failure duy nhất xảy ra. Có thể trả về nil, false hoặc một giá trị nào đó để xác định việc failure.

Result Object sẽ phù hợp khi:

  • Một method có thể gặp nhiều lỗi với nhiều cách khác nhạu

  • Khi ta muốn chain các hành động cùng với nhau và mỗi hành động đều có thể fail khi chạy.

  • Khi người gọi hàm cần phải biết là method khi gọi có khả năng bị fail.

Với Web app, Result Object phù hợp với service/interactor/command object. Những business logic ở các object này thường mang nhiều trường hợp bị fail mà cần phải xử lý cùng với đó là các business logic thường sẽ hay chain với nhau.

Result Object không phải là một ý tưởng mới. Nó tồn tại dưới tên "result monad". Resonad thức chất là được ghép lại bởi result và monad.

Dưới đây là list implementation trong Ruby:

  • Resonad

  • dry-monads

  • GitHub::Result

  • monadic

  • result-monad

Các bạn cũng có thể tự viết một result object cho riêng mình.

Rubypigeon

0