ลองพยายามอธิบาย Active Record และ Active Record Migration
บทความนี้ได้แรงผลักดันมาจากการที่โดนถามมาเรื่องการทำ Database Migration ของ Rails ซึ่งส่วนตัวคิดว่าบทความในอินเตอร์เนตเท่าที่หาดู ไม่มีอันไหนช่วยให้เข้าใจได้ดีกว่า Official Doc ของ Rails เลย แต่เราก็เชื่อว่าคนถามน่าจะลองอ่านแล้วล่ะ แต่แค่อาจจะยังไม่ตกผลึกออกมาเป็นแนวปฏิบัติไม่ได้ ก็เลยคิดว่าจะลองพยายามเขียนอะไรซักอย่างขึ้นมาอุดช่องว่าง สำหรับคนที่พอเข้าใจการทำงานของฐานข้อมูลอยู่แล้ว แต่แค่ไม่ได้ทำงานด้วย Ruby on Rails มาก่อน ให้สามารถเห็นภาพการทำงานที่ชัดขึ้น
Active Record
Model ใน MVC ของ Ruby on Rails นั้นใช้ library ORM เฉพาะของตัวเองที่ชื่อว่า Active Record
โดยชื่อของ library ก็ได้รับอิทธิพลโดยตรงมาจากแนวคิด
Active record pattern
ซึ่งแนวคิดหลักของ pattern นี้ คือ
-
การเข้าถึง table แต่ล่ะ table จะถูก encapsulate อยู่ภายใน class โดยที่แต่ล่ะ instance ของ class ก็คือตัวแทนของ row หรือ record ของข้อมูลใน table และมีวิธีเข้าถึงข้อมูลแต่ล่ะ column ตามชื่อของ column นั้นโดยตรง
user.class.name # => "User" user.firstname # => "John"
-
เมื่อ instance ของ class ได้รับการ initiate ขึ้นมาใหม่และถูก save …จะเท่ากับการ insert record ใหม่
user = User.new(firstname: "John") user.save # => INSERT INTO "users" (firstname) VALUES ("John", "2024-06-02 12:34:56", "2024-06-02 12:34:56") RETURNING "id"
-
เมื่อ instance ของ class ได้รับการ load จากข้อมูลที่อยู่เดิมและถูก save …จะเท่ากับการ update record ใหม่
user = User.where(firstname: "John").first user.firstname = "Jack" user.save # => UPDATE "users" SET "firstname" = ?, "updated_at" = ? WHERE "users"."id" = ? [["firstname", "Jack"], ["updated_at", "2024-06-02 11:34:54.562867"], ["id", 1]]
-
ชื่อของ table กับชื่อของ class จะตั้งตามหลัก Convention over configuration ซึ่งสรุปสั้นๆเร็วๆตรงนี้ได้ตามตัวอย่างนี้
- ชื่อ class = ชื่อ entity ของสิ่งที่เราต้องการใช้ในรูปเอกพจน์
- ชื่อ table = ชื่อ entity ของสิ่งที่เราต้องการใช้ในรูปพหูพจน์
Ex: entity ของ user จะใช้ชื่อ class ว่า User ภายในไฟล์ชื่อ user.rb และอยู่ใน table ที่ชื่อ users
Active Record Migration
เป็นเครื่องมือที่ทำมาเพื่อ support การทำงานของ Active Record เพื่อให้นักพัฒนาสามารถทำงานไปพร้อมๆกันได้ โดยที่สามารถตรวจสอบการเปลี่ยนแปลงของ Database Schema ได้ตลอด
คำว่า Database Schema ในที่นี้หมายถึง spec ของฐานข้อมูลนั้นๆ เช่น ชื่อ table รวมถึงชื่อ column ภายใน, Primary Key, Foreign Key, Index ต่างๆ รวมถึง trigger หรือ store procedure ด้วย
อธิบายตามโครงสร้างไฟล์
- config/
- database.yml
# ใช้ประกาศค่าต่างๆที่ใช้ในการเชื่อมต่อกับฐานข้อมูล เช่น host, port หรือ
# connection string
- db/
- migrate
# directory ที่ใช้เก็บไฟล์ migration
# ใช้งานผ่านคำสั่ง rails db:migrate, rails db:rollback
- ....
- schema.rb
# ไฟล์ที่เก็บ schema ปัจจุบันของแอปพลิเคชัน
# ใช้งานผ่านคำสั่ง rails db:schema:load, rails db:schema:dump
- seeds.rb
# ไฟล์ที่ใช้เก็บคำสั่งสำหรับสร้างข้อมูลเบื้องต้นที่จำเป็นในแอปพลิเคชัน
# ใช้งานผ่านคำสั่ง rails db:seed
การใช้งานในการทำงานจริงๆเป็นยังไง ?
จากที่เกริ่นมาทั้งหมดข้างบน คิดว่าหลายคนสามารถอ่านจนเข้าใจได้ไม่มีปัญหา แต่ถ้าจะมีปัญหาก็คือ แนวทางปฏิบัติตอนที่ทำงานจริง ว่าเป็นยังไงกันแน่ ทำไมลองทำเองดูแล้วมันก็ยังงงๆ ไม่แน่ใจว่าจะใช่แบบที่ทำรึเปล่า ซึ่งจากประสบการณ์แล้ว ก็ไม่แปลกที่จะงงกันตรงนี้ เพราะคนที่ชอบใช้ Rails แต่ไม่เข้าใจการทำ migration มีอยู่จำนวนไม่น้อยเลย
มีทั้งแบบที่แค่หยุดทำไฟล์ migration กับอีกแบบคือหนีไปใช้ NoSQL แบบ MongoDB แทน
แบบฝึกให้ทำหรือคิดตาม: แอปพลิเคชันซื้อขายสินค้า
สมมติว่า เรากำลังทำแอปพลิเคชันที่สามารถวางขายสินค้าและเปิดให้ผู้ซื้อสามารถสั่งซื้อ ครั้งละหลายรายการได้ โดยมี entity ที่เราจะใช้ตามนี้
- Buyer ผู้ซื้อสินค้า
- Product สินค้า
- Order รายการสั่งซื้อสินค้า
ต่อจากนี้ไป ทุกครั้งหลังการสร้าง model, migration ให้รันคำสั่ง
rails db:migrate
ออกแบบ class ที่จะใช้ ตามลำดับโปรแกรมที่เขียน
สมมติว่าเราเริ่มจากการมีสินค้าในระบบก่อน
สร้าง model ของ Product
โดยการ generate model
จะเป็นการสร้างไฟล์ model และ migration ขึ้นมา
rails generate model product name:string description:text price:integer
ก่อนจะไปต่อกันสามารถลองสร้างไฟล์ migration เพื่อเพิ่ม column ง่ายๆได้ เช่น การให้ Product
สามารถเก็บจำนวน stock ได้
rails generate migration add_stock_to_products stock:integer
จะเห็นได้ว่าเราไม่ได้ระบุชื่อ table เป็น parameter โดยตรงแต่คำสั่งนี้ก็ยังทำงานตามที่เราต้องการได้ เพราะรูปแบบคำสั่งก็ generate จากชื่อไฟล์ตามแนวคิด Convention over configuration (จะ generate ไฟล์เปล่าแล้วเขียนเองก็ได้)
มีสินค้าแล้วเราก็อยากให้การสั่งซื้อสินค้าแต่ล่ะครั้งของ Buyer
ถูกเก็บไว้ใน Order
rails generate model buyer email:string
rails generate model order buyer:references
references คือการสร้าง foreign key พร้อมกับ index ไปที่ resource นั้น
ซึ่ง buyer:references
ในที่นี้ จะทำให้ table orders
มี column buyer_id
เพิ่มเข้าไปพร้อมกับสร้าง index ให้ โดยไฟล์ order.rb
ที่ถูกสร้างขึ้นทีหลังจะมีความสัมพันธ์ระบุ belongs_to :buyer
ระบุให้เลย
แต่ในไฟล์ buyer.rb
เราต้องเข้าไปใส่ ความสัมพันธ์เองตามข้างล่าง
class Buyer < ApplicationRecord
has_many :orders
end
พอมาถึงตรงนี้ เราอยากจะให้ความสัมพันธ์ของ Order
กับ Product
เป็นแบบ many-to-many
ซึ่งจากประสบการณ์ของผมค้นพบว่าส่วนมากจะหมดใจกันแถวๆนี้แหละ ที่ไม่รู้จะไปไงต่อดี 😅
จะไปต่อกับไฟล์ migration ก็งงๆ จะข้ามส่วนนี้ไปเลยก็เท่ากับว่าไฟล์ก็มีขั้นตอนไม่ครบแล้ว
ฝืนทำต่อไปก็ไม่สมบูรณ์อยู่ดี
แต่เราลองทำต่อตรงนี้ซักหน่อย เพราะ Active Record Migration มีวิธีใช้งานที่ผมเองเชื่อว่าครอบคลุม 99% ของการใช้งานทั้งหมดแล้ว กรณีแบบนี้ก็เช่นกัน
สร้างไฟล์ migration สำหรับ Join Table
เราสามารถสร้างความสัมพันธ์แบบ many-to-many ได้ ด้วยการสร้าง join table
ซึ่งใช้คำสั่งสร้างแบบนี้
rails generate migration CreateJoinTableOrdersProducts order product
หลังการรัน rails db:migrate
ในครั้งนี้ เนื่องจากเราไม่ได้ generate model ขึ้นใหม่ เราจึงจำเป็นต้อง ประกาศความสัมพันธ์เองภายในไฟล์ model ของ Product
, Order
class Product < ApplicationRecord
has_and_belongs_to_many :orders
end
class Order < ApplicationRecord
belongs_to :buyer
has_and_belongs_to_many :products
end
มาถึงตรงนี้เราก็น่าจะสามารถลองเล่นกับความสัมพันธ์ใน rails console
ดูได้แบบนี้
b = Buyer.new(email: '[email protected]')
b.save
# Insert Buyer b
p = Product.new(name: 'The Shirt')
p.save
# Insert Product p
o = Order.new
o.products << p
o.buyer = b
o.save
# Insert Order o with Buyer b, Product p
สิ่งที่อยากให้สนใจหลังแบบฝึกข้างบน
อยากให้ลองศึกษาไฟล์ migration ที่ถูกสร้างขึ้นมาเรียงตามลำดับแล้ว
อาจจะลองรันคำสั่ง rails db:rollback
เปลี่ยนแปลงค่าบางอย่าง แล้วลอง rails db:migrate
อีกครั้งเพื่อดูผลลัพธ์ที่เกิดขึ้น โดยดูตัวอย่างทั้งหมดจาก
Official Doc
และศึกษาการเปลี่ยนแปลงของไฟล์ db/schema.rb
ไปพร้อมๆกัน
schema.rb
คือ Single Source of Truth ของ Database Schema
หนึ่งในจุดที่ผมจะเข้าไปเปิดดูเป็นไฟล์แรกๆ หากได้รับมอบหมายให้ทำงานใน Ruby on Rails
แอปพลิเคชัน ก็คือไฟล์ schema.rb
เพราะเป็นไฟล์ที่เราสามารถมองเห็น
ภาพรวมความซับซ้อนของระบบฐานข้อมูลภายในได้ภายในไฟล์เดียว
ถ้าจำนวนไฟล์ migrations เยอะขึ้นจนจัดการไม่ไหว
หนึ่งในข้อกังวลอย่างหนึ่งของการเริ่มทำไฟล์ migration ที่ค่อนข้างมีเหตุผล แต่ Active Record Migration ก็วางแนวทางไว้รับมือแล้ว โดยที่เราสามารถ…
ย้ายไฟล์ที่เก่ามากไปวางที่อื่นหรือลบทิ้งได้เลย แล้วใช้วิธีสร้างฐานข้อมูลเริ่มต้นด้วยคำสั่ง
rails db:schema:load
ให้สร้างฐานข้อมูลตามข้อมูลใน schema.rb
แทนการใช้คำสั่ง
rails db:migrate
เพื่อรันคำสั่งทั้งหมดทีล่ะคำสั่ง
นอกจากการ load schema แล้วก็ยังมี rails db:schema:dump
ที่เป็นคำสั่งตรงข้ามของการ load
โดยเป็นคำสั่งที่เอาข้อมูล schema ในฐานข้อมูลที่เชื่อมต่อลงเก็บที่ schema.rb
ซึ่งก็สะดวกสำหรับใช้ในการตรวจสอบว่า Database schema ตรงกับที่มีใน schema.rb
หรือไม่
โดยถ้าไฟล์มีการเปลี่ยนแปลงหลังการรันคำสั่งก็สามารถบอกได้ว่าแตกต่างตรงไหนด้วย
Git Version Control
การตรวจสอบสถานะของ database migration
ใช้คำสั่ง rails db:migrate:status
เพื่อเรียกดูข้อมูลใน terminal
โดยจะเห็นแบบตัวอย่างข้างล่าง
Status Migration ID Migration Name
--------------------------------------------------
up 20240508063759 ********** NO FILE **********
up 20240602151931 Create products
up 20240602154948 Create buyers
up 20240602155002 Create orders
up 20240602161438 Create join table orders products
down 20240602161959 Add stock to products
ข้อดีและข้อเสียของการทำ Migration แบบนี้
ข้อดี | ข้อเสีย |
---|---|
สามารถติดตามการเปลี่ยนแปลงได้ Git Version Control เพราะการเปลี่ยนแปลงทั้งหมดถูกทำผ่าน code | การจัดการด้วย Ruby code ทั้งหมด อาจไม่เหมาะกับองค์กรที่ใช้ DBA แยก เพราะ DBA อาจจะเคืองถ้าโดนบังคับให้เรียน Ruby เพิ่ม 😂 |
เพิ่ม abstraction layer ขึ้นมาเพื่อรองรับการเขียน code ครั้งเดียวให้ใช้ได้หลายฐานข้อมูล เช่น สามารถเปลี่ยนไปใช้ MySQL จาก PostgreSQL ได้ทันที | การ execute คำสั่งโดยตรงอาจมีผลเสียกับ performance ในระบบฐานข้อมูลขนาดใหญ่ ต้องวางแผนให้ดี |
รองรับการ migrate ละ rollback ผ่านคำสั่งที่ program ได้ | ความเสี่ยงจากการทำ data loss ระหว่าง migration เหมือนข้างบน |
การเปลี่ยน schema แต่ล่ะครั้ง สามารถ test ได้ |
Alternatively ถ้าไม่ใช้ Ruby on Rails แต่อยากได้แบบนี้จะใช้อะไรได้บ้าง
- Liquibase
- Atlas
- golang-migrate
- Gorm
- Prisma
- etc.