ลูกค้ามาถึงจุดนี้อย่างไร?
ก่อนจะลงลึกเข้าสู่รายละเอียด เรามาลองเข้าใจกันก่อนว่าแอปพลิเคชันอาจจะกลายเป็นสถานะนี้ได้อย่างไร เราเริ่มต้นด้วยตาราง users ที่เรียบง่าย หลังจากผ่านไป几สัปดาห์ เราต้องการทราบเวลาลงชื่อเข้าใช้ล่าสุด จึงเพิ่มคอลัมน์ users.last_sign_in_at ต่อมาเราต้องการชื่อผู้ใช้ จึงเพิ่ม first_name และ last_name ต้องการ Twitter handle? เพิ่มคอลัมน์อีก ต้องการโปรไฟล์ GitHub? หมายเลขโทรศัพท์? หลังจากผ่านไปหลายเดือน ตารางก็กลายเป็นเรื่องยากที่จะเข้าใจ
ปัญหาคืออะไร?
ตารางที่ใหญ่โตอย่างนี้บ่งชี้ถึงปัญหาหลายประการ:
Userมีหน้าที่ที่ไม่เกี่ยวข้องกันหลายอย่าง ทำให้เข้าใจยาก แก้ไขยาก และทดสอบยาก- การแลกเปลี่ยนข้อมูลระหว่างแอปและฐานข้อมูลต้องใช้แบนด์วิดท์มากขึ้น
- แอปต้องการหน่วยความจำมากขึ้นเพื่อจัดเก็บโมเดลที่ใหญ่โต
แอปดึงข้อมูล User ในทุกคำขอเพื่อการยืนยันตัวตนและการอนุญาต แต่โดยปกติใช้เพียงไม่กี่คอลัมน์ การแก้ไขปัญหานี้จะปรับปรุงทั้งการออกแบบและประสิทธิภาพ
การแยกตาราง
เราสามารถแก้ปัญหาได้โดย การแยกคอลัมน์ที่ใช้บ่อยน้อยไปยังตารางใหม่ (หรือหลายตาราง) ตัวอย่างเช่น เราสามารถแยกข้อมูลโปรไฟล์ (first_name เป็นต้น) ไปยังตาราง profiles ด้วยขั้นตอนดังนี้:
- สร้างตาราง
profilesพร้อมคอลัมน์ที่ซ้ำกับคอลัมน์ที่เกี่ยวข้องกับโปรไฟล์ในusers - เพิ่ม
profile_idในusersตั้งค่าเป็นNULLไว้ก่อน - สำหรับแต่ละแถวใน
usersให้แทรกแถวลงในprofilesโดยคัดลอกคอลัมน์ที่เกี่ยวข้องกับโปรไฟล์ - ชี้
profile_idของแถวที่สอดคล้องในusersไปยังแถวที่แทรกในขั้นตอนที่ 3 - อย่า ทำให้
users.profile_idเป็น non-NULLเพราะแอปยังไม่รู้จักคอลัมน์นี้ การทำอย่างนั้นจะทำให้แอพพัง
เราต้องเปลี่ยนการอ้างอิงจาก users.first_name เป็น profiles.first_name และอื่นๆ หากเราแยกเพียงไม่กี่คอลัมน์ที่มีการอ้างอิงไม่มากนัก แนะนำให้ทำด้วยมือ แต่เมื่อเราจับได้ว่าตัวเองคิดว่า "โอ้ไม่ นี่คืองานที่แย่ที่สุดเลย!" เราควรหาวิธีทางเลือก
อย่าละเลยปัญหา ส่วนของโค้ดที่ทุกคนหลีกเลี่ยงจะเสียหายเพิ่มขึ้นและได้รับการเฝ้าระวังน้อยลง วงจรร้ายแรงนี้สามารถทำลายได้ง่ายที่สุดโดยการเริ่มจากเล็กๆ
อ่านต่อไป หากคุณอยากรู้ว่าลูกค้าของผมแก้ปัญหานี้อย่างไร
แก้ไขโค้ดทีละบรรทัด
วิธีที่เพิ่มขึ้นทีละน้อยที่สุดคือการแก้ไขการอ้างอิงคอลัมน์เก่าทีละอัน มาเน้นที่การย้าย first_name จาก users ไป profiles
ขั้นแรก สร้าง Profile ด้วย:
rails generate model Profile first_name:stringจากนั้นเพิ่มการอ้างอิงจาก users ไป profiles และคัดลอก users.first_name ไป profiles:
class ExtractUsersFirstNameToProfiles < ActiveRecord::Migration # กำหนดโมเดลใหม่เพื่อตัดการพึ่งพาโค้ด production เราต้องการ # โมเดลธรรมดาโดยไม่มี callbacks เป็นต้น นอกจากนี้ การลบโมเดลในอนาคตอาจทำให้ migration พัง class User < ActiveRecord::Base; end class Profile < ActiveRecord::Base; end def up add_reference :users, :profile, index: true, unique: true, foreign_key: true User.find_each do |user| profile = Profile.create!(first_name: user.first_name) user.update!(profile_id: profile.id) end change_column_null :users, :profile_id, false end def down remove_reference :users, :profile end endเนื่องจากบังคับให้ผู้ใช้แต่ละคนมีโปรไฟล์เพียงหนึ่งเดียว การอ้างอิงจาก users ไป profiles จึงเหมาะสมกว่าการอ้างอิงในทางตรงข้าม
เมื่อโครงสร้างฐานข้อมูลพร้อมแล้ว เราสามารถมอบหมาย first_name จาก User ไป Profile ได้ ลูกค้าของผมมีข้อกำหนดหลายประการ:
- Accessor ควรใช้
Profileที่เกี่ยวข้อง และควรบันทึก log ว่าการเรียก accessor เก่าเกิดขึ้นที่ไหน - การบันทึก
UserควรบันทึกProfileอัตโนมัติ เพื่อไม่ให้โค้ดที่ใช้ accessor เก่าพัง User#first_name_changed?และเมธอดActiveModel::Dirtyอื่นๆ ควรทำงานได้ตามปกติ
นั่นหมายความว่า User ควรมีลักษณะดังนี้:
class User < ActiveRecord::Base # ต้องการ autosave เพราะโค้ดลูกค้าอาจไม่รู้จัก # Profile#first_name และยังอ้างอิง User#first_name อยู่ belongs_to :profile, autosave: true def first_name log_backtrace(:first_name) profile.first_name end def first_name=(new_first_name) log_backtrace(:first_name) # เรียก super เพื่อให้ User#first_name_changed? และเมธอดคล้ายๆ ทำงานได้ตามที่คาดหวัง super profile.first_name = new_first_name end private def log_backtrace(name) filtered_backtrace = caller.select do |item| item.start_with?(Rails.root.to_s) end Rails.logger.warn(<<-END) การอ้างอิงแอตทริบิวต์ที่ล้าสมัย #{name} ที่: #{filtered_backtrace.join("\n")} END end endหลังจากการเปลี่ยนแปลงเหล่านี้ แอปทำงานเหมือนเดิมแต่อาจช้าลงเล็กน้อยเนื่องจากการอ้างอิงเพิ่มเติมไป Profile (หากประสิทธิภาพเป็นปัญหา ให้ใช้เครื่องมืออย่าง AppSignal) โค้ดจะบันทึก log การอ้างอิงแอตทริบิวต์เก่าทั้งหมด แม้จะเป็นแบบที่ grep ไม่หาได้ (เช่น user[attr] = ... หรือ user.send("#{attr}=", ...)) ดังนั้นเราจึงจะหาทั้งหมดได้แม้ grep จะช่วยไม่ได้
ด้วยโครงสร้างพื้นฐานนี้ เราสามารถมุ่งมั่นแก้ไขการอ้างอิง users.first_name หนึ่งอันในตารางเวลาประจำ เช่น ทุกเช้า (เริ่มวันด้วยชัยชนะเล็กๆ) หรือกลางวัน (ทำงานง่ายๆ หลังเช้าที่เน้นไป) ความมุ่งมั่นนี้จำเป็นเพราะเป้าหมายคือลดอุปสรรคทางใจในการแก้ปัญหา การทิ้งโค้ดข้างต้นไว้โดยไม่ดำเนินการจะทำให้แอปยิ่งแย่ลง
หลังจากลบการอ้างอิงที่ล้าสมัยทั้งหมด (และยืนยันด้วย grep และ log) เราจึงสามารถลบ users.first_name ได้ทีสุดท้าย:
class RemoveUsersFirstName < ActiveRecord::Migration def change remove_column :users, :first_name, :string end endเราควรลบโค้ดที่เพิ่มใน User ด้วย เพราะมันไม่จำเป็นแล้ว
ข้อจำกัด
วิธีนี้อาจใช้ได้กับกรณีของคุณ แต่โปรดจำข้อจำกัดบางประการ:
- ไม่จัดการ bulk queries เช่น
User.update_all - ไม่จัดการ raw SQL queries
- อาจทำลาย monkey-patches (จำได้ว่า dependencies อาจนำเข้ามาก็ได้)
UserและProfileอาจ out-of-sync หากprofiles.first_nameถูกอัปเดตแต่users.first_nameไม่ได้
คุณอาจเอาชนะบางข้อจำกัดได้ เช่น รักษาโมเดลให้ sync ด้วย service object หรือ callback บน Profile หรือถ้าใช้ PostgreSQL อาจพิจารณา materialized view ในช่วงระหว่าง
จบแล้ว!
บทเรียนสำคัญที่สุดของบทความนี้คือ อย่าหลีกเลี่ยงโค้ดที่มีกลิ่นแต่เผชิญมันโดยตรง หากงานใหญ่เกินไป ให้ทำงานแบบ iterative ตามตารางเวลาประจำ บทความนำเสนอ หนึ่งวิธี ที่ควรพิจารณาเมื่อการแยกตารางยาก หากคุณประยุกต์ไม่ได้ ให้หาวิธีอื่น หากไม่มีแนวทางเลย ส่งข้อความมาหาผมได้ ผมจะพยาชามช่วย อย่าให้บิตของคุณเน่าเสีย