บางครั้ง Unique Situations และสิ่งที่ควบคุมไม่ได้ นำไปสู่ Requirements ที่แปลกใหม่อย่างไร้ที่ติ เร็วๆ นี้ ผมมีประสบการณ์ต้องใช้ ActiveRecord โดยไม่พึ่งพา Database ID สำหรับ Records ใดๆ ถ้าคิดจะทำแบบนี้ ขอแนะนำให้หาทางอื่น! แต่ไปต่อกันที่เนื้อเรื่อง
มี Decisions ทำแล้ว Smaller Databases (Clones โครงสร้างแต่ไม่ใช่ Data) ต้อง Merge ผม Join โปรเจกต์ตอนทีมกำลังจบ Script ที่ Copy-Paste Database Records จาก Database หนึ่งไปอีกอัน Copy ทุกอย่าง As-is รวม IDs
Database A
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | orange | 456 |
| ... | ... | ... |
Database B
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | banana | 74 |
| ... | ... | ... |
Database A หลัง Merge
| id | fruit | user_id |
|---|---|---|
| ... | ... | ... |
| 123 | orange | 456 |
| 123 | banana | 74 |
| ... | ... | ... |
สิ่งนี้ทำลาย Fundamental Reason ของ IDs: Unique Identification ผมไม่รู้รายละเอียดแต่วินัยว่าทุกปัญหาจะโผล่มาเมื่อ Duplicate IDs เข้ามาใน System พยายามพูด แต่เพิ่ง Join โปรเจกต์ อื่นๆ มั่นใจว่าเป็น Best Path ในไม่กี่วัน จะ Deploy Code และเริ่ม Handle Data ด้วย Duplicate IDs คำถามไม่ใช่ "ควรทำไหม?" แต่เป็น "ทำยังไง?" และ "อีกนานแค่ไหน?"
ทำงานกับ Duplicate IDs
Solution คือทำ Composite ID จากหลาย Fields DB Fetches ส่วนใหญ่เป็นแบบนี้:
# นี้ไม่ได้ เนื่องจากอาจมี 2 Users ID: 123
FavoriteFruit.find(123)
# Multiple IDs Scope Query ให้ Record ถูกต้อง
FavoriteFruit.find_by(id: 123, user_id: 456)
ActiveRecord Calls ทั้งหมด Update แบบนี้ และอ่านคราวๆ ดูสมเหตุสมผล จนกว่าเราจะ Deploy
ทุกอย่างแตกสลาย
ไม่กี่วันหลัง Deploy โทรศัพท์ดังตลอด Customers เห็น Numbers ที่ไม่รวมกัน Update Records ของตัวเองไม่ได้ Features ต่างๆ พังทีละอัน
ทำยังไง? เราไม่ได้แค่ Deploy Code เราย้าย Data ระหว่าง Databases (และ Data ใหม่ถูก Create/Update หลัง Deploy) ไม่ใช่ Simple Rollback Situation ต้อง Fix ด่วน
Rails ทำอะไรอยู่?
Debug 步แรก: ดู Current Behavior และ Reproduce Error ผม Clone Production Data เริ่ม Rails Console ขึ้นอยู่กับ Setup คุณอาจไม่เห็น SQL Queries ที่ Rails รันตอน Execute ActiveRecord Query อัตโนมัติ นี่คือวิธีให้ SQL Statements มองเห็นบน Console:
ActiveRecord::Base.logger = Logger.new(STDOUT)
ลอง Common Rails Queries:
$ FavoriteFruit.find_by(id: 123, user_id: 456)
FavoriteFruit Load (0.6ms) SELECT "favorite_fruits".* FROM "favorite_fruits" WHERE "favorite_fruits"."id" = $1 AND "favorite_fruits"."user_id" = $2 [["id", "123"], ["user_id", "456"]]
find_by ทำงานดี แต่แล้วเห็นโค้ดแบบนี้:
fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
...
...
fruit.reload
reload นั้นทำให้ Curious ทดสอบ:
$ fruit.reload
FavoriteFruit Load (0.3ms) SELECT "favorite_fruits".* FROM "favorite_fruits" WHERE "favorite_fruits"."id" = $1 LIMIT $2 [["id", 123], ["LIMIT", 1]]
Oh no แม้ find_by Fetch Record ถูกต้อง แต่ตอนเรียก reload มันเอา ID ของ Record ไป Query Find-by-ID แบบ Simple ซึ่งให้ Incorrect Data เพราะ Duplicate IDs
ทำไมมันทำอย่างนั้น? ผมตรวจ Rails Source Code หา Clues นี่คือข้อดีของ Ruby on Rails Source Code เป็น Plain Ruby และเข้าถึงได้ฟรี ผมแค่ Google "ActiveRecord reload" หาเจอทันที:
# File activerecord/lib/active_record/persistence.rb, line 602
def reload(options = nil)
self.class.connection.clear_query_cache
fresh_object = if options && options[:lock]
self.class.unscoped { self.class.lock(options[:lock]).find(id) }
else
self.class.unscoped { self.class.find(id) }
end
@attributes = fresh_object.instance_variable_get("@attributes")
@new_record = false
self
end
โชว์ว่า reload คือ Wrapper ของ self.class.find(id) Query แค่ ID ถูก Hardwired ใน Method นี้ เพื่อทำงานกับ Duplicate IDs เราต้อง Override Core Rails Methods (ไม่แนะนำ) หรือ หยุดใช้ reload เลย
Solution ของเรา
เรา Decide ไปทุก reload ในโค้ด เปลี่ยนเป็น find_by เพื่อ Database Fetching ด้วย Multiple Keys
แต่นั้นแก้ Bugs บางส่วน หลังขุดลึก ผมทดสอบ update Calls:
$ fruit = FavoriteFruit.find_by(id: 123, user_id: 456)
$ fruit.update(last_eaten: Time.now)
FavoriteFruit Update (43.3ms) UPDATE "favorite_fruits" SET "last_eaten" = $1 WHERE "favorite_fruits"."id" = $2 [["updated_at", "2020-04-16 06:24:57.989195"], ["id", 123]]
Oh no แม้ find_by Scope Record ด้วย Fields เฉพาะ ตอนเรียก update บน Rails Record มันสร้าง Simple WHERE id = x Query ซึ่งพังกับ Duplicate IDs จะรอบคาบได้ยังไง?
เราสร้าง Custom Update Method update_unique แบบนี้:
class FavoriteFruit
def update_unique(attributes)
run_callbacks :save do
self.class
.where(id: id, user_id: user_id)
.update_all(attributes)
end
self.class.find_by(id: id, user_id: user_id)
end
end
ให้ Update Records Scoped มากกว่า IDs:
$ fruit.update_unique(last_eaten: Time.now)
FavoriteFruit Update All (3.2ms) UPDATE "favorite_fruits" SET "last_eaten" = '2020-04-16 06:24:57.989195' WHERE "favorite_fruits"."id" = $1 AND "favorite_fruits"."user_id" = $2 [["id", "123"], ["user_id", "456"]]
โค้ดนี้ Ensure Narrow Scope สำหรับ Update แต่ด้วยการ Call Class update_all เราสูญเสีย Callbacks ที่มากับ Update ปกติ จึงต้อง Manual Run Callbacks และ Query อีกรอบเพื่อดึง Updated Record เพราะ update_all ไม่ Return Updated Record ผลลัพธ์สุดท้ายไม่ มาก Messy แต่มันแน่นอนยากต่อการอ่านกว่า fruit.update
The Real Solution
เพราะ Sunk Costs, Management, และ Time Constraints Solution ของเราคือ Monkey Patch Rails ให้ใช้ Multiple Keys สำหรับ Database Calls ทั้งหมด มัน Work ในความหมายที่ Customers ยังซื้อและใช้ Product แต่เป็น Bad Idea หลายเหตุผล:
- Future Development อาจ Reintroduce Bugs โดยใช้ Common Rails Methods New Developers ต้อง Training เข้มงวดให้ Code ไปรอดจาก Hidden Bugs เช่น ใช้
reloadMethod - Code ซับซ้อนกว่า ไม่น่าอ่าน ไม่ง่ายต่อ Maintain Technical Debt ที่ช้า Development Speed ลงเรื่อยๆ
- Testing ช้าลงมาก ต้อง Test ไม่แค่ Function Works แต่ Works เมื่อ Objects ต่างๆ มี Duplicate IDs ใช้เวลาเขียน Tests มาก และ Test Suite รันนานขึ้น Testing ยัง Miss Bugs ได้ง่ายถ้า Developer แต่ละคนไม่ Test Scenarios ทั้งหมดอย่างระมัดระวัง
Real Solution คือ ไม่มี Duplicate IDs ตั้งแต่แรก ถ้า Data ต้อง Transfer ระหว่าง Databases Script ที่ทำควร Collect และ Insert Data โดยไม่มี IDs ให้ Receiving Database ใช้ Auto-increment Counter มาตรฐาน ให้แต่ละ Record Unique ID ของตัวเอง
Solution อีกอย่าง: ใช้ UUIDs สำหรับ Records ทุกตัว ID ประเภทนี้คือ Long String สร้างแบบ Random (แทน Step-by-step Counting เหมือน Integer ID) การย้าย Data ไป Databases อื่นๆ จะไม่มี Conflicts หรือ Issues
Bottom Line: Rails สร้างบนความเข้าใจว่า IDs Unique ต่อ Record และเป็น Quick/Easy Way Manipulate Specific Data ใน Database Rails เป็น Opinionated Framework ความงดงามคือทุกอย่างรัน Smooth ตราบใดที่ Stick to Rails Way นี้ใช้ได้ไม่แค่ Rails แต่มากมายใน Programming ตอนซับซ้อน เราควรรู้ Identify Problem แต่ถ้าเขียน Clear, Maintainable, Conventional Code เราสามารถหลีกเลี่ยง Complications หลายอย่างตั้งแต่แรกได้