Xử lý lỗi là phần quan trọng của mọi chương trình. Cần chủ động về những lỗi có thể nảy sinh trong quá trình triển khai code. Những lỗi này nên được xử lý sao cho output sản sinh ra mô tả đúng từng lỗi và giai đoạn ứng dụng nơi nó xảy ra. Tuy nhiên, cũng quan trọng không kém là đạt được điều này sao cho code vẫn functional và readable. Hãy bắt đầu bằng câu hỏi bạn có thể đã có: Railway Oriented Programming là gì?
Railway Oriented Programming
Một function đạt mục đích cụ thể có thể là composition của các function nhỏ hơn. Các function này thực hiện các bước khác nhau cuối cùng dẫn đến mục tiêu cuối cùng. Ví dụ, function cập nhật địa chỉ user trong database và sau đó thông báo user về thay đổi này có thể bao gồm các bước sau:
validate user -> update address -> send mail upon successful update
Mỗi bước có thể fail hoặc success, và failure của bất kỳ bước nào dẫn đến failure của toàn bộ process, vì mục đích function không đạt được.
Railway Oriented Programming (ROP) là thuật ngữ do Scott Wlaschin sáng tạo, áp dụng analogy railway switch (chuyển đường ray) vào error handling trong các function như vậy. Railway switch (gọi là "points" ở UK) dẫn tàu từ một đường ray sang đường khác. Scott dùng analogy này vì success/failure output của mỗi bước hoạt động như railway switch, có thể chuyển bạn sang success track hoặc failure track.
Output success/failure đóng vai trò railway switch
Khi có lỗi ở bất kỳ bước nào, chúng ta bị chuyển sang failure track bởi failure output, do đó bypass các bước còn lại. Tuy nhiên, khi có success output, nó kết nối với input của bước tiếp theo giúp dẫn chúng ta đến đích cuối, như hình dưới.
Output success/failure của nhiều bước xích lại với nhau
Analogy hai đường ray này là ý tưởng cốt lõi của Railway Oriented Programming. Nó nỗ lực trả về success/failure output ở mỗi bước (tức là mọi method là một phần của process) để đảm bảo failure ở một bước là failure của toàn bộ process. Chỉ có successful completion của mỗi bước mới dẫn đến overall success.
Ví dụ đời thường
Hãy tưởng tượng bạn có mục tiêu mua một hộp sữa trực tiếp tại cửa hàng tên The Milk Shakers. Các bước có thể là:
Rời nhà -> Đến The Milk Shakers -> Lấy hộp sữa -> Trả tiền hộp sữa
Nếu bạn không thể ra khỏi nhà, toàn bộ process fail vì bước đầu fail. Giả sử bạn ra được nhà nhưng đi Walmart? Process vẫn fail vì bạn không đi đến cửa hàng chỉ định. Việc bạn có thể mua sữa ở Walmart không có nghĩa process tiếp tục. ROP dừng process tại Walmart và trả về failure output báo process fail vì cửa hàng không phải The Milk Shakers. Tuy nhiên, nếu bạn đi đúng cửa hàng, process sẽ tiếp tục, kiểm tra output và hoặc kết thúc hoặc chuyển bước tiếp theo. Điều này đảm bảo error handling readable, elegant và hiệu quả mà không cần statement if/else và return nối các bước riêng lẻ.
Trong Rails, chúng ta có thể đạt two-track railway output này dùng gem Dry Monads.
Giới thiệu Dry Monads và cách hoạt động
Monads ban đầu là khái niệm toán học. Cơ bản, chúng là composition hoặc abstraction của một số function đặc biệt, khi dùng trong code có thể loại bỏ việc xử lý explicit stateful values. Chúng cũng có thể là abstraction của boilerplate code tính toán cần thiết cho program logic. Stateful values không local đến function cụ thể, ví dụ: inputs, global variables, outputs. Monads chứa bind function giúp các values này truyền từ monad này sang monad khác; do đó, chúng không bao giờ được handle explicit. Chúng có thể build để handle exceptions, rollback commits, retry logic, v.v. Bạn có thể tìm thêm info về monads tại đây.
Như trong documentation (khuyên bạn review), dry monads là tập hợp monads phổ biến cho Ruby. Monads cung cấp cách elegant để handle errors, exceptions, và chaining functions sao cho code dễ hiểu hơn và có tất cả error handling mong muốn mà không cần tất cả ifs and elses. Chúng ta sẽ tập trung vào Result Monad vì nó chính xác là gì chúng ta cần để đạt success/failure outputs nói ở trên.
Hãy bắt đầu Rails app mới tên Railway-app bằng lệnh:
rails new railway-app -T
Flag -T nghĩa là bỏ qua folder test vì chúng ta định dùng RSpec cho testing.
Tiếp theo, thêm gem cần thiết vào Gemfile: gem dry-monads cho success/failure results và gem rspec-rails trong group test và development làm testing framework. Bây giờ chạy bundle install trong app để cài gem. Để generate test files và helpers, cần chạy lệnh:
rails generate rspec:install
Chia Function thành nhiều bước
Luôn khuyến khích chia function thành các method nhỏ hơn làm việc cùng nhau đạt mục tiêu cuối. Errors từ các method này, nếu có, giúp xác định chính xác process fail ở đâu và giữ code clean, readable. Để đơn giản nhất, chúng ta sẽ xây dựng class đại lý ô tô Toyota giao xe cho user nếu model và color yêu cầu có sẵn, năm sản xuất không trước năm 2000, và thành phố giao hàng nằm trong danh sách nearby cities. Thú vị! :)
Hãy bắt đầu chia process giao xe thành các bước:
- Verify năm sản xuất không trước năm 2000.
- Verify model có sẵn.
- Verify color có sẵn.
- Verify thành phố giao hàng là nearby city.
- Gửi message xác nhận xe sẽ được giao.
Đã có các bước, hãy đi vào code.
Nhập kết quả output Success/Failure
Trong folder app/model, tạo file car_dealership.rb và khởi tạo class với chi tiết quan trọng. Ở đầu file, require dry/monads, và ngay sau tên class, include Dry::Monads[:result, :do]. Điều này làm cho result monad và do notation (cho phép combine nhiều monadic operations dùng từ yield) khả dụng cho chúng ta.
require 'dry/monads'
class CarDealership
include Dry::Monads[:result, :do]
def initialize
@available_models = %w[Avalon Camry Corolla Venza]
@available_colors = %w[red black blue white]
@nearby_cities = %w[Austin Chicago Seattle]
end
end
Tiếp theo, thêm method deliver_car, bao gồm tất cả các bước khác và trả về success message nếu tất cả bước success. Thêm từ yield để combine/bind các bước với nhau. Nghĩa là failure message ở bất kỳ bước nào trở thành failure message của method deliver_car, và success output ở bất kỳ bước nào yield sang gọi bước tiếp theo.
def deliver_car(year, model, color, city)
yield check_year(year)
yield check_model(model)
yield check_city(city)
yield check_color(color)
Success("A #{color} #{year} Toyota #{model} will be delivered to #{city}")
end
Bây giờ, thêm tất cả method còn lại và gán success/failure results dựa trên kết quả kiểm tra.
def check_year(year)
year < 2000 ? Failure("We have no cars manufactured in year #{year}") : Success('Cars of this year are available')
end
def check_model(model)
@available_models.include?(model) ? Success('Model available') : Failure('The model requested is unavailable')
end
def check_color(color)
@available_colors.include?(color) ? Success('This color is available') : Failure("Color #{color} is unavailable")
end
def check_city(city)
@nearby_cities.include?(city) ? Success("Car deliverable to #{city}") : Failure('Apologies, we cannot deliver to this city')
end
Hiện tại chúng ta có class và tất cả method cần. Sẽ diễn ra như thế nào? Hãy thử tạo instance mới của class này và gọi method deliver_car với các argument khác nhau.
good_dealer = CarDealership.new
good_dealer.deliver_car(1990, 'Venza', 'red', 'Austin') #Failure("We have no cars manufactured in year 1990")
good_dealer.deliver_car(2005, 'Rav4', 'red', 'Austin') #Failure("The model requested is unavailable")
good_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin') #Failure("Color yellow is unavailable")
good_dealer.deliver_car(2000, 'Venza', 'red', 'Surrey') #Failure("Apologies, we cannot deliver to this city")
good_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin') #Success("A blue 2000 Toyota Avalon will be delivered to Austin")
Như trên, failure result của method deliver_car thay đổi tùy method mà nó fail. Failure của method đó trở thành failure của nó, và khi tất cả method success, nó trả về success result của chính nó. Cũng đừng quên các bước này là method riêng lẻ cũng có thể gọi độc lập khỏi method deliver_car. Ví dụ:
good_dealer.check_color('wine') #Failure("Color wine is unavailable")
good_dealer.check_model('Camry') #Success('Model available')
Testing với RSpec
Để test code trên, vào folder spec tạo file car_dealership_spec.rb tại path spec/models. Dòng đầu require 'rails_helper'. Chúng ta viết test cho context failure trước rồi success.
require 'rails_helper'
describe CarDealership do
describe "#deliver_car" do
let(:toyota_dealer) { CarDealership.new }
context "failure" do
it "does not deliver a car with the year less than 2000" do
delivery = toyota_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
expect(delivery.success).to eq nil
expect(delivery.failure).to eq 'We have no cars manufactured in year 1990'
end
it "does not deliver a car with the year less than 2000" do
delivery = toyota_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
expect(delivery.success).to eq nil
expect(delivery.failure).to eq 'Color yellow is unavailable'
end
end
end
end
Như trên, ta truy cập failure hoặc success results dùng result.failure hoặc result.success. Với context success, test trông như sau:
context "success" do
it "delivers a car when all conditions are met" do
delivery = toyota_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
expect(delivery.success).to eq 'A blue 2000 Toyota Avalon will be delivered to Austin'
expect(delivery.failure).to eq nil
end
end
Bây giờ, bạn có thể thêm test khác trong context failure bằng cách điều chỉnh argument truyền cho method deliver_car. Bạn cũng có thể thêm check khác trong code cho trường hợp argument invalid (ví dụ: string cung cấp cho year variable v.v.). Chạy bundle exec rspec trong terminal chạy test và cho thấy tất cả pass. Về cơ bản bạn không cần thêm check cho cả failure và success results cùng lúc trong test, vì không thể có cả hai là output của method. Tôi chỉ thêm để giúp hiểu success result trông như thế nào khi có failure result và ngược lại.
Kết luận
Đây chỉ là giới thiệu về dry-monads và cách dùng trong app để đạt Railway Oriented Programming. Hiểu cơ bản này có thể áp dụng cho operations và transactions phức tạp hơn. Như đã thấy, code clean và readable không chỉ đạt được nhờ ROP, mà error handling chi tiết và ít stress hơn. Luôn nhớ gán failure/success messages ngắn gọn cho các method cấu thành process, vì cách này giúp xác định đâu và tại sao error xảy ra. Nếu muốn thêm info về ROP, khuyến khích xem presentation của Scott Wlaschin.