การจัดการข้อผิดพลาดเป็นส่วนสำคัญของทุกโปรแกรม จำเป็นต้องคาดการณ์ล่วงหน้าว่าข้อผิดพลาดอะไรอาจเกิดขึ้นระหว่างการ implement โค้ด ข้อผิดพลาดเหล่านี้ควรจัดการให้ output อธิบายข้อผิดพลาดแต่ละอย่างและขั้นตอนของแอปพลิเคชันที่เกิดขึ้น อย่างไรก็ตาม สำคัญไม่قلว่า คือทำให้โค้ดยังคง functional และ readable
เริ่มต้นด้วยการตอบคำถามที่คุณอาจมีอยู่แล้ว: Railway Oriented Programming คืออะไร?
Railway Oriented Programming
ฟังก์ชันที่บรรลุวัตถุประสงค์เฉพาะอาจเป็นการ compose ของฟังก์ชันย่อยหลายตัว ฟังก์ชันเหล่านี้ทำขั้นตอนต่างๆ ที่สุดท้ายนำไปสู่เป้าหมายสุดท้าย ตัวอย่างเช่น ฟังก์ชันอัปเดตที่อยู่ผู้ใช้ในฐานข้อมูลและแจ้งเตือนผู้ใช้เมื่ออัปเดตสำเร็จ อาจประกอบด้วยขั้นตอน:
validate user -> update address -> send mail upon successful update
แต่ละขั้นตอนอาจ fail หรือ success และความล้มเหลวของขั้นตอนใดขั้นตอนหนึ่งนำไปสู่ความล้มเหลวของกระบวนการทั้งหมด เนื่องจากวัตถุประสงค์ของฟังก์ชันไม่ได้รับบรรลุ
Railway Oriented Programming (ROP) เป็นศัพท์ที่ Scott Wlaschin สร้างขึ้น นำ analogy railway switch มาใช้กับ error handling ในฟังก์ชันแบบนี้ Railway switches (เรียกว่า "points" ใน UK) นำรถไฟจาก track หนึ่งไปอีก track Scott ใช้ analogy นี้ในความหมายว่า success/failure output ของแต่ละขั้นตอนทำงานเหมือน railway switch เพราะสามารถย้ายคุณไป track สำเร็จหรือ track ล้มเหลว
Success/failure outputs ทำหน้าที่เหมือน railway switch
เมื่อมีข้อผิดพลาดในขั้นตอนใด เราจะถูกย้ายไป failure track โดย failure output ทำให้ bypass ขั้นตอนที่เหลือ อย่างไรก็ตาม เมื่อมี success output จะเชื่อมต่อกับ input ของขั้นตอนถัดไป ช่วยนำเราไปสู่จุดหมายปลายทาง ตามภาพด้านล่าง
Success/failure outputs ของหลายขั้นตอนเชื่อมต่อกัน
Analogy สอง track นี้เป็น idea ข้างหลัง Railway Oriented Programming มุ่งคืน success/failure outputs ที่ทุกขั้นตอน (ทุกเมธอดที่เป็นส่วนหนึ่งของกระบวนการ) เพื่อให้ความล้มเหลวในขั้นตอนหนึ่งคือความล้มเหลวของกระบวนการทั้งหมด เฉพาะการสำเร็จของทุกขั้นตอนนำไปสู่ความสำเร็จโดยรวม
ตัวอย่างในชีวิตประจำวัน
تخيلว่าคุณมีเป้าหมายซื้อนมหนึ่งกล่องที่ร้านชื่อ The Milk Shakers ขั้นตอนที่น่าจะเกี่ยวข้อง:
ออกจากบ้าน -> ถึง The Milk Shakers -> หยิบนมกล่องหนึ่ง -> จ่ายนมกล่องนั้น
หากไม่สามารถออกจากบ้านได้ กระบวนการทั้งหมดล้มเหลวเพราะขั้นตอนแรกล้มเหลว ถ้าคุณออกจากบ้านแต่ไป Walmart กระบวนการยังล้มเหลวเพราะไม่ได้ไปร้านที่กำหนด การที่ซื้อนมจาก Walmart ได้ไม่ได้หมายความว่ากระบวนการจะดำเนินต่อ ROP หยุดกระบวนการที่ Walmart และคืน failure output บอกว่าล้มเหลวเพราะร้านไม่ใช่ The Milk Shakers แต่หากไปร้านถูกต้อง กระบวนการจะดำเนินต่อ ตรวจสอบ outputs และจบหรือไปขั้นตอนถัดไป ทำให้ error handling อ่านง่ายและขลัง ไม่ต้องใช้ if/else และ return statements เชื่อมขั้นตอนต่างๆ
ใน Rails เราสามารถทำ two-track railway output นี้ได้ด้วย gem Dry Monads
แนะนำ Dry Monads และการทำงาน
Monads ต้นทางเป็น mathematical concept โดยพื้นฐานคือ composition หรือ abstraction ของ special functions หลายตัว ที่เมื่อใช้ในโค้ด จะ eliminate explicit handling ของ stateful values พวกมันยังสามารถเป็น abstraction ของ computational boilerplate code ที่ logic ของโปรแกรมต้องการ Stateful values ไม่ได้ local ในฟังก์ชันเฉพาะ ตัวอย่าง ได้แก่ inputs, global variables, outputs Monads มี bind function ทำให้ values เหล่านี้ pass จาก monad หนึ่งไปอีก monad ได้ ดังนั้นจึงไม่ถูกจัดการ explicitly พวกมันสามารถสร้างขึ้นเพื่อจัดการ exceptions, rollback commits, retry logic ฯลฯ อ่านเพิ่มเติมเกี่ยวกับ monads ที่นี่
ตามเอกสาร (แนะนำอ่าน) dry monads คือชุด common monads สำหรับ Ruby Monads ให้วิธีขลังจัดการ errors, exceptions และ chaining functions ให้โค้ดเข้าใจง่ายขึ้น และมี error handling ครบถ้วนโดยไม่ต้องใช้ ifs and elses มาก เราจะโฟกัสที่ Result Monad เพราะมันตรงกับ success/failure outputs ที่พูดถึง
เริ่ม Rails app ใหม่ชื่อ Railway-app ด้วยคำสั่ง:
rails new railway-app -T
-T ในคำสั่งหมายถึง skip test folder เนื่องจากจะใช้ RSpec ทดสอบ
เพิ่ม gems ที่ต้องการใน Gemfile: gem dry-monads สำหรับ success/failure results และ gem rspec-rails ใน group test และ development เป็น testing framework รัน bundle install เพื่อติดตั้ง gems เพื่อ generate test files และ helpers ต้องรัน:
rails generate rspec:install
แบ่งฟังก์ชันเป็นหลายขั้นตอน
ควรแบ่งฟังก์ชันเป็นเมธอดย่อยที่ทำงานร่วมกันเพื่อบรรลุเป้าหมายสุดท้าย Errors จากเมธอดเหล่านี้ช่วยระบุได้ชัดเจนว่ากระบวนการล้มเหลวที่ไหน และทำให้โค้ดสะอาด อ่านง่าย เพื่อให้เข้าใจง่ายที่สุด เราจะสร้าง class ของตัวแทนรถ Toyota ที่ส่งรถให้ผู้ใช้ หาก model และสีที่ต้องการมี stock, ปีผลิตไม่ก่อนปี 2000 และเมืองปลายทางอยู่ในรายการเมืองใกล้เคียง
แบ่งกระบวนการส่งรถเป็นขั้นตอน:
- ตรวจสอบปีผลิตไม่ก่อนปี 2000
- ตรวจสอบ model มี stock
- ตรวจสอบสีมี stock
- ตรวจสอบเมืองปลายทางเป็นเมืองใกล้เคียง
- ส่งข้อความว่ารถจะถูกส่ง
เริ่มเขียนโค้ดกัน
ใส่ Success/Failure Output Results
ในโฟลเดอร์ app/model สร้างไฟล์ car_dealership.rb และ initialize class ด้วยรายละเอียดสำคัญ บนสุดไฟล์ require dry/monads และหลังชื่อ class include Dry::Monads[:result, :do] ทำให้ result monad และ do notation (ทำให้รวม monadic operations หลายตัวด้วย yield) พร้อมใช้งาน
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
เพิ่มเมธอด deliver_car ประกอบด้วยขั้นตอนอื่นๆ และคืน success message หากทุกขั้นตอนสำเร็จ เพิ่มคำว่า yield เพื่อ combine/bind ขั้นตอนเหล่านี้เข้าด้วยกัน หมายถึง failure message ในขั้นตอนใดขั้นตอนหนึ่งจะกลายเป็น failure message ของ deliver_car และ success output ในขั้นตอนใด yield ไปยังการเรียกขั้นตอนถัดไป
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
เพิ่มเมธอดอื่นๆ และ attach success/failure results ตามผลการตรวจสอบ:
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
ตอนนี้มี class และเมธอดครบแล้ว ลองดูผลลัพธ์โดยสร้าง instance ใหม่และเรียก deliver_car ด้วย arguments ต่างๆ
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")
เห็นได้ว่า failure result ของ deliver_car แตกต่างกันไป ขึ้นอยู่กับเมธอดที่ล้มเหลว Failure ของเมธอดนั้นกลายเป็น failure ของมัน และเมื่อทุกเมธอดสำเร็จ จะคืน success result ของตัวมัน 別忘了 ขั้นตอนเหล่านี้เป็นเมธอดอิสระ เรียกใช้แยกจาก deliver_car ได้ ตัวอย่าง:
good_dealer.check_color('wine') # Failure("Color wine is unavailable")
good_dealer.check_model('Camry') # Success('Model available')
ทดสอบด้วย RSpec
ทดสอบโค้ดข้างบน ไปที่ spec folder สร้างไฟล์ car_dealership_spec.rb ใน path spec/models บรรทัดแรก require 'rails_helper' เขียน test สำหรับ context failure ก่อน แล้วค่อย 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
เห็นได้ว่าเข้าถึง failure หรือ success results ได้ด้วย result.failure หรือ result.success สำหรับ context success test จะเป็นแบบนี้:
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
ตอนนี้เพิ่ม test อื่นๆ ใน failure context โดยปรับ arguments ให้ deliver_car เพิ่ม checks อื่นๆ ในโค้ดสำหรับกรณี invalid argument (เช่น string สำหรับ year variable) รัน bundle exec rspec ใน terminal รัน test และแสดงว่าทั้งหมด pass คุณไม่ต้องเพิ่ม checks failure และ success พร้อมกันใน test เพราะไม่สามารถมีทั้งคู่เป็น output ของเมธอดได้ ผมเพิ่มไว้แค่ช่วยเข้าใจ success result หน้าตายังไงเมื่อมี failure และ vice versa
สรุป
นี่คือการแนะนำ dry-monads แบบเบื้องต้น และการใช้ในแอปเพื่อบรรลุ Railway Oriented Programming ความเข้าใจพื้นฐานนี้สามารถนำไปใช้กับ operations และ transactions ที่ซับซ้อนมากขึ้นได้ ตามที่เห็น โค้ดที่สะอาดและอ่านง่ายไม่ได้แค่ achievable ด้วย ROP แต่ error handling ก็ detailed และน้อย stress down จำไว้เสมอว่าให้ attach concise failure/success messages กับเมธอดต่างๆ ที่ประกอบกระบวนการ เพราะ approach นี้ช่วยระบุ where และ why error เกิดขึ้น หากต้องการข้อมูลเพิ่มเติมเกี่ยว ROP แนะนำดู presentation โดย Scott Wlaschin