Rails เป็น Framework ใหญ่ที่มี Built-in Tools หลายอย่างสำหรับสถานการณ์เฉพาะ ในซีรีส์นี้เราจะมาสำรวจ Tools ที่แอบแฝงอยู่ใน Codebase ขนาดใหญ่ของ Rails ที่น่ารู้แต่ค่อนข้างนิยมไม่ค่อยรู้จัก
บทความนี้จะเน้นที่ Method store และ store_accessor ของ ActiveRecord ทั้งคู่แก้ Use Case การเก็บ Structured Data ใน Database Column เช่น JSON หรือ YAML ในขณะที่ store_accessor ให้วิธีสะดวกในการดึงค่าจาก Data เหล่านี้โดยไม่ทำให้ Model แออัดด้วย Getter Methods แล้ว store ไปไกลกว่านั้นด้วยการ Serialize/Deserialize Data ให้กับ Format ที่เลือกอย่างโปร่งใส เราจะดู Options ในการเก็บ JSON ใน Relational Databases และเหตุผลที่อาจอยากทำเช่นนั้น
JSON ใน Database
ควรชี้แจงว่า "Database" ในบทความนี้หมายถึง Relational Databases โดยเฉพาะ PostgreSQL และ MySQL เพราะเป็นที่นิยมที่สุดในชุมชน Rails
อาจถามว่าทำไมต้องเก็บ JSON ใน Relational Database? ประโยชน์ของ Relational DB คือการแบ่ง Data เพื่อ Enforce Relationships (Foreign Keys) และ Index เพื่อ Query Performance
ข้อเสียคือ Data Structure ต้องรู้ล่วงหน้าและเหมือนกันทุก Row หาก App ของคุณข้อมูลไม่ตรงเงื่อนไขนี้ ควรพิจารณา NoSQL แต่สำหรับ Web Apps ส่วนใหญ่ เราอยากอยู่กับ Relational DB ที่คุ้นเคยสำหรับ Data ส่วนใหญ่ และ "โรย" Dynamic Data Structures เข้าไปเฉพาะจุดที่จำเป็น JSON Column จึงมีความหมายในกรณีเหล่านี้
JSON vs JSONB
PostgreSQL มี JSON Columns 2 ชนิด: json และ jsonb ความแตกต่างหลักคือ jsonb ถูก Parse ตอน Write-time ทำให้เก็บใน Format ที่ Query เร็วกว่า ข้อควรระวังคือเพราะ Parse แล้ว ตอน Output เป็น Text อาจไม่ Match ตรงกับที่ User Input ไปเลย เช่น Duplicate Keys ถูกลบ หรือ Key Order ไม่ตรงต้นฉบับ
เอกสาร PostgreSQL แนะนำว่าในกรณีส่วนใหญ่ jsonb คือสิ่งที่ต้องการ เว้นแต่จะมีเหตุผลเฉพาะ
MySQL json Column พฤติกรรมคล้าย jsonb ของ PostgreSQL หากต้องการ Output "ตรงกับที่ User Input" อาจต้องใช้ varchar แทน
JSON vs Text
นอกจาก Pre-parsed แล้ว JSON Column ยัง Query ด้วย Data เองได้ เช่น Query Records ที่มี Key-Value Pair เฉพาะใน Column หมายเหตุ: Rails เองไม่ Support JSON-specific Queries มาก (หรือเลย) เพราะเป็น Database-specific หากอยากใช้ Feature เหล่านี้ ต้องเขียน SQL Queries เอง
JSON Columns ใน Rails
Rails Support สร้าง json (และ jsonb บน PostgreSQL) Columns ใน Migrations:
class CreateItems < ActiveRecord::Migration[7.0]
def change
create_table :items do |t|
t.jsonb :user_attributes
...
end
end
end
ตอนอ่าน Column จะได้ Hash กลับมา:
> Item.first.user_attributes
Item Load (0.6ms) SELECT "items".* FROM "items" ORDER BY "items"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> {"color"=>"text-red-400"}
> Item.first.update!(user_attributes: {color: "text-blue-400"})
> Item.first.user_attributes.dig(:color)
=> "text-blue-400"
ตอนมี Hash Attribute แล้ว อาจอยากเพิ่ม Helper Methods ใน Model เพื่อ Read/Write Values:
class Item < ApplicationRecord
def color=(value)
self.user_attributes["color"] = value
end
def color
user_attributes.dig("color")
end
end
Methods แบบนี้ทำงานได้ดีแต่อาจ Unwieldy หากมี JSON Keys เยอะ ไปโชคดี Rails มี Solution ให้
ActiveRecord's store และ store_accessor
มี 2 ด้านของการเก็บ JSON: Serialization และ Access หากใช้ json-type Column ไม่ต้องกังวล Serialization Rails และ DB Adapter จัดการให้ (ข้ามไป store_accessor ได้เลย) แต่ถ้าเก็บใน Text Column store ของ ActiveRecord คือคำตอบ ซึ่งรับผิดชอบ Serialize Data ให้เป็น Format ที่เลือก
ActiveRecord's store
class Item < ApplicationRecord
store :user_attributes, accessors: [:color], coder: JSON
end
:user_attributes คือ Column ที่จะใช้ accessors คือ List ของ Keys ที่อยาก Access (แค่ color ในตัวอย่าง) และ coder ระบุ Encoding (JSON, YAML, หรือ Custom Encodings) Method นี้จัดการ Serialization และเรียก store_accessor ให้อัตโนมัติ
ActiveRecord's store_accessor
class Item < ApplicationRecord
store_accessor :user_attributes, :color
store_accessor :user_attributes, :name, prefix: true
store_accessor :user_attributes, :location, prefix: 'primary'
end
user_attributes = Database Column, ตามด้วย Key ใน JSON Data, และ Option prefix/suffix (Boolean, String, Symbol) ถ้า Boolean true จะใช้ Column Name เป็น Prefix/Suffix
หมายเหตุ: store_accessor ไม่ Support Nested Data แค่ Top-level Key-Value Pairs
=> item = Item.create!(color: 'red', user_attributes_name: 'Jonathan', primary_location: 'New Zealand')
> #<Item:0x000055d63f4f0360 id: 4, user_attributes: {"color"=>"red", "name"=>"Jonathan", "location"=>"New Zealand"}>
=> item.color
> "red"
=> item.user_attributes_name
> "Jonathan"
=> item.name
> NoMethodError: undefined method `name'...
=> item.primary_location
> "New Zealand"
Real-World Usage (การใช้งานจริง)
ผมเคยหลุดจาก Relational Schema ที่รู้ล่วงหน้าไม่มากนัก แต่ทุกครั้งที่ทำ ทำให้ Database Structure Clean และ Simple กว่าเดิม
ตัวอย่าง: Support Multiple APIs ที่ User Connect Accounts เอง ปัญหาคือ APIs ไม่ได้ใช้ Auth Scheme เดียวกัน บางตัว Username+Password บางตัว API Key บางตัว Key+Secret+Merchant ID วิธีหนึ่งคือเพิ่ม Columns เยอะ (ส่วนใหญ่ Null) แต่用 JSON เก็บแค่ค่าที่ API แต่ละตัวต้องการ
Side Project อีกตัวก็ใช้ JSON Storage ให้ User ตั้ง Arbitrary Attributes บน Items รวมถึง User-defined Attributes ด้วยลักษณะ Fluid และ Unpredictable JSON Storage (พร้อม store_accessor สำหรับ Known Attributes) จึงเหมาะที่สุด
Summary (สรุป)
JSON Data (และ ActiveRecord Helpers) มีประโยชน์มากเมื่อ Data และ Structure เปลี่ยนแปลงหรือไม่รู้ล่วงหน้า แต่นี่คือ Trade-off: ได้ Flexibility แต่อเสีย Data Integrity จาก DB Constraints และลดความสามารถ Query แบบ ActiveRecord ทั่วไป Joins เป็นต้น
Rules of Thumb หาก:
- JSON Keys เหมือนกันทุก Rows, หรือ
- เก็บ ID (Primary Key) ของ Table อื่น, หรือ
- เก็บค่าที่ใช้ Lookup Record จาก Table ใน JSON
ควรสร้าง Table ใหม่เพื่อให้ DB Enforce Integrity ให้ แต่ถ้าเก็บ Row-specific Data ที่ไม่ Relate ตรงกับ Tables อื่น JSON อาจช่วย Simplify Database Structure ได้