ลองนึกภาพสถานการณ์สมมติต่อไปนี้:ในระบบการจัดการทรัพย์สินให้เช่า พนักงาน A เริ่มแก้ไขข้อมูลติดต่อสำหรับการเช่า X โดยเพิ่มหมายเลขโทรศัพท์เพิ่มเติม ในช่วงเวลาเดียวกัน พนักงาน B สังเกตเห็นการสะกดผิดในข้อมูลติดต่อสำหรับ X เช่านั้นและดำเนินการอัปเดต ไม่กี่นาทีต่อมา พนักงาน A จะอัปเดตข้อมูลติดต่อของ Rent X ด้วยหมายเลขโทรศัพท์ใหม่และ ... การอัปเดตการแก้ไขข้อผิดพลาดหายไปแล้ว!
นั่นไม่ดีอย่างแน่นอน! และนี่เป็นสถานการณ์ที่ค่อนข้างเล็กน้อย ลองนึกภาพความขัดแย้งที่คล้ายกันที่เกิดขึ้นในระบบการเงิน!
เราสามารถหลีกเลี่ยงสถานการณ์ดังกล่าวในอนาคตได้หรือไม่? โชคดีคำตอบคือใช่! เราต้องการการป้องกันและการล็อกพร้อมกัน — โดยเฉพาะการล็อกในแง่ดี — เพื่อป้องกันปัญหาดังกล่าว
มาสำรวจการล็อกในแง่ดีใน Rails REST API
'การอัปเดตที่หายไป' และการล็อกในแง่ดีเทียบกับการล็อกในแง่ร้าย
สถานการณ์ที่เราเพิ่งผ่านไปคือประเภท 'การอัปเดตที่หายไป' เมื่อธุรกรรมสองรายการพร้อมกันอัปเดตคอลัมน์เดียวกันของแถวเดียวกัน รายการที่สองจะแทนที่การเปลี่ยนแปลงจากอันแรก เสมือนว่าธุรกรรมแรกไม่เคยเกิดขึ้น
โดยปกติ ปัญหานี้สามารถแก้ไขได้โดย:
- การตั้งค่าระดับการแยกธุรกรรมที่เหมาะสม ซึ่งจัดการปัญหาในระดับฐานข้อมูล
- การล็อกในแง่ร้าย — ป้องกันธุรกรรมที่เกิดขึ้นพร้อมกันจากการอัปเดตแถวเดียวกัน ธุรกรรมที่สองจะรอให้ธุรกรรมแรกเสร็จสิ้นก่อนที่จะอ่านข้อมูลด้วยซ้ำ ข้อได้เปรียบที่ดีของที่นี้คือ ใช้งานกับข้อมูลเก่าไม่ได้ ข้อเสียที่สำคัญคือมันยังบล็อกการอ่านข้อมูลจากแถวที่กำหนด
- Optimistic locking — หยุดการแก้ไขแถวที่กำหนดหากสถานะในขณะที่ทำการแก้ไขแตกต่างจากตอนที่อ่าน
ปัญหาของเราไม่ได้เกี่ยวกับธุรกรรมฐานข้อมูลพร้อมกัน (เหมือนธุรกรรมทางธุรกิจมากกว่า) — ดังนั้นวิธีแรกจึงไม่สามารถใช้งานได้จริง ซึ่งหมายความว่าเราต้องเผชิญกับการล็อคในแง่ร้ายและการล็อคในแง่ดี
การล็อกในแง่ร้ายจะป้องกันไม่ให้การอัปเดตที่สูญหายเกิดขึ้นในสถานการณ์สมมติของเราตั้งแต่แรก อย่างไรก็ตาม มันจะทำให้ชีวิตยากสำหรับผู้ใช้เช่นกัน หากบล็อกการเข้าถึงข้อมูลเป็นเวลานานมาก (ลองนึกภาพว่าอ่านและแก้ไขบางฟิลด์เป็นเวลา 30 นาทีขึ้นไป)
การล็อกในแง่ดีจะมีข้อจำกัดน้อยกว่ามาก เนื่องจากจะทำให้ผู้ใช้หลายคนเข้าถึงข้อมูลได้ อย่างไรก็ตาม หากผู้ใช้หลายคนเริ่มแก้ไขข้อมูลพร้อมกัน จะมีเพียงคนเดียวเท่านั้นที่ดำเนินการได้ ส่วนที่เหลือจะเห็นข้อผิดพลาดที่ระบุว่าพวกเขาดำเนินการกับข้อมูลเก่าและจำเป็นต้องลองอีกครั้ง ไม่สมบูรณ์แบบ แต่ด้วย UX ที่เหมาะสม อาจไม่จำเป็นต้องเจ็บปวดขนาดนั้น
มาดูกันว่าเราจะใช้การล็อกในแง่ดีใน Rails REST API สมมุติได้อย่างไร
การล็อกในแง่ดีใน REST API
ก่อนที่เราจะนำไปปรับใช้ในแอพ Rails จริง มาคิดกันก่อนว่าการล็อคในแง่ดีจะเป็นอย่างไรในบริบทของ REST API ทั่วไป
ตามที่อธิบายไว้ข้างต้น เราจำเป็นต้องติดตามสถานะดั้งเดิมของวัตถุเมื่ออ่านเพื่อเปรียบเทียบกับสถานะภายหลังในระหว่างการอัพเดท หากสถานะไม่เปลี่ยนแปลงตั้งแต่การอ่านครั้งล่าสุด การดำเนินการจะได้รับอนุญาต หากมีการเปลี่ยนแปลงก็จะล้มเหลว
สิ่งที่เราต้องหาในบริบทของ REST API คือ:
- เมื่ออ่านข้อมูลของทรัพยากรที่กำหนด เราจะแสดงสถานะปัจจุบันของวัตถุและส่งคืนเพื่อตอบสนองต่อผู้บริโภคได้อย่างไร
- ผู้บริโภคควรเผยแพร่สถานะดั้งเดิมของทรัพยากรไปยัง API อย่างไรเมื่อทำการอัปเดต
- API ควรส่งคืนอะไรให้กับผู้บริโภคหากสถานะมีการเปลี่ยนแปลงและไม่สามารถอัปเดตได้
ข่าวดีก็คือคำถามเหล่านี้สามารถตอบและจัดการกับความหมายของ HTTP ได้
เท่าที่มีการติดตามสถานะของทรัพยากร เราสามารถใช้ประโยชน์จาก Entity Tags
(หรือ ETag) เราสามารถส่งคืนลายนิ้วมือ/เช็คซัม/หมายเลขเวอร์ชันของทรัพยากรได้ใน ETag
ส่วนหัวไปยังผู้บริโภค API เพื่อส่งในภายหลังด้วยคำขอ PATCH เราสามารถใช้ If-Match
ส่วนหัว ทำให้เซิร์ฟเวอร์ API ตรวจสอบว่าทรัพยากรมีการเปลี่ยนแปลงหรือไม่ เป็นเพียงกรณีของการเปรียบเทียบเช็คซัม/หมายเลขเวอร์ชัน/สิ่งอื่นๆ ที่คุณเลือกเป็น ETag
คำขอจะสำเร็จหาก ETag
. ปัจจุบัน และ If-Match
ค่าจะเหมือนกัน หากไม่เป็นเช่นนั้น API ควรตอบสนองด้วย 412 Precondition Failed
. ที่ไม่ค่อยได้ใช้ สถานะ สถานะที่เหมาะสมและแสดงออกมากที่สุดที่เราสามารถใช้เพื่อการนี้ได้
มีอีกสถานการณ์หนึ่งที่เป็นไปได้ เราสามารถเปรียบเทียบ ETags ได้ก็ต่อเมื่อผู้บริโภค API จัดเตรียม If-Match
หัวข้อ. เกิดอะไรขึ้นถ้ามันไม่ได้? คุณอาจละเลยการป้องกันภาวะพร้อมกันและลืมการล็อกในแง่ดีไปได้เลย แต่นั่นอาจไม่เหมาะ อีกวิธีหนึ่งคือการกำหนดให้มี If-Match
ส่วนหัวและตอบกลับด้วย 428 Precondition Required
สถานะถ้าไม่ใช่
ตอนนี้เรามีภาพรวมที่ชัดเจนว่าการล็อกในแง่ดีนั้นสามารถทำงานใน REST API ได้อย่างไร มาใช้งานใน Rails กัน
การล็อกในแง่ดีใน Rails
ข่าวดีก็คือ Rails นำเสนอการล็อคในแง่ดีตั้งแต่แกะกล่อง — เราสามารถใช้คุณสมบัติที่จัดหาให้โดย ActiveRecord::Locking::Optimistic
. เมื่อคุณเพิ่ม lock_version
คอลัมน์ (หรืออย่างอื่นที่คุณต้องการ แม้ว่าจะต้องมีการประกาศเพิ่มเติมในระดับโมเดลเพื่อกำหนดคอลัมน์การล็อก) ActiveRecord จะเพิ่มขึ้นหลังจากการเปลี่ยนแปลงแต่ละครั้ง และตรวจสอบว่าเวอร์ชันที่กำหนดในปัจจุบันเป็นเวอร์ชันที่คาดไว้หรือไม่ ถ้ามันเก่าแล้ว ActiveRecord::StaleObjectError
ข้อยกเว้นจะเพิ่มขึ้นในการพยายามอัปเดต/ทำลาย
วิธีที่ง่ายที่สุดในการจัดการการล็อกในแง่ดีใน API ของเราคือการใช้ค่าจาก lock_version
เป็น ETag ลองทำสิ่งนี้เป็นขั้นตอนแรกใน RentalsController
สมมุติฐานของเรา :
class RentalsController
after_action :assign_etag, only: [:show]
def show
@rental = Rental.find(params[:id])
respond_with @rental
end
private
def assign_etag
response.headers["ETag"] = @rental.lock_version
end
end
แน่นอนว่านี่เป็นเวอร์ชันที่เรียบง่ายของคอนโทรลเลอร์ เนื่องจากเราสนใจเฉพาะสิ่งที่จำเป็นสำหรับการล็อกในแง่ดีเท่านั้น ไม่ใช่การรับรองความถูกต้อง การอนุญาต หรือแนวคิดอื่นๆ ซึ่งเพียงพอที่จะเปิดเผย ETag ที่เหมาะสมแก่ผู้บริโภค ตอนนี้เรามาดูแล If-Match
ส่วนหัวที่ผู้บริโภคสามารถให้ได้:
class RentalsController
after_action :assign_etag, only: [:show, :update]
def show
@rental = Rental.find(params[:id])
respond_with @rental
end
def update
@rental = Rental.find(params[:id])
@rental.update(rental_params)
respond_with @rental
end
private
def assign_etag
response.headers["ETag"] = @rental.lock_version
end
def rental_params
params
.require(:rental)
.permit(:some, :permitted, :attributes).merge(lock_version: lock_version_from_if_match_header)
end
def lock_version_from_if_match_header
request.headers["If-Match"].to_i
end
end
และนั่นก็เพียงพอแล้วที่จะทำให้การล็อกในแง่ดีเวอร์ชันน้อยที่สุดทำงานได้! แม้ว่าชัดเจนว่า เราไม่ต้องการตอบกลับ 500 คำตอบหากมีข้อขัดแย้งใดๆ เราจะทำ If-Match
จำเป็นสำหรับการอัปเดตใด ๆ ด้วย:
class RentalsController
before_action :ensure_if_match_header_provided, only: [:update]
after_action :assign_etag, only: [:show, :update]
rescue_from ActiveRecord::StaleObjectError do
head 412
end
def show
@rental = Rental.find(params[:id])
respond_with @rental
end
def update
@rental = Rental.find(params[:id])
@rental.update(rental_params)
respond_with @rental
end
private
def ensure_if_match_header_provided
request.headers["If-Match"].present? or head 428 and return
end
def assign_etag
response.headers["ETag"] = @rental.lock_version
end
def rental_params
params
.require(:rental)
.permit(:some, :permitted, :attributes)
.merge(lock_version: lock_version_from_if_match_header)
end
def lock_version_from_if_match_header
request.headers["If-Match"].to_i
end
end
และนั่นเป็นเกือบทุกอย่างที่จำเป็นในการใช้งานฟังก์ชันทั้งหมดที่เรากล่าวถึงก่อนหน้านี้ เราสามารถปรับปรุงสิ่งต่างๆ ได้มากขึ้น เช่น ให้ข้อความแสดงข้อผิดพลาดเพิ่มเติมนอกเหนือจากรหัสตอบกลับ แต่จะอยู่นอกเหนือขอบเขตของบทความนี้
สรุป:ความสำคัญของการล็อกในแง่ดีใน Rails API
การป้องกันการทำงานพร้อมกันมักถูกมองข้ามเมื่อออกแบบ REST API ซึ่งอาจนำไปสู่ผลลัพธ์ที่ร้ายแรง
อย่างไรก็ตาม การใช้การล็อกในแง่ดีใน Rails API ค่อนข้างตรงไปตรงมา ดังที่แสดงในบทความนี้ และจะช่วยหลีกเลี่ยงปัญหาร้ายแรงที่อาจเกิดขึ้นได้
ขอให้สนุกกับการเขียนโค้ด!
ป.ล. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่ออกจากสื่อ สมัครรับจดหมายข่าว Ruby Magic ของเราและไม่พลาดแม้แต่โพสต์เดียว!