Trong bài viết này, chúng ta sẽ đề cập đến sự khác biệt và lợi ích quan trọng của việc sử dụng structure.sql so với schema.rb mặc định trong ứng dụng Ruby on Rails. Trong thế giới data-driven, biết cách khai thác tất cả tính năng phong phú của database có thể tạo ra sự khác biệt giữa doanh nghiệp thành công và thất bại.
Sau khi làm rõ sự khác biệt chính giữa hai format, chúng ta sẽ nêu cách chuyển sang structure.sql và demo cách nó giúp đảm bảo tính toàn vẹn dữ liệu cũng như chức năng database mà bạn có thể không bảo toàn được.
Trong bài, tôi sẽ đưa ví dụ Rails app dùng structure.sql với PostgreSQL, nhưng khái niệm cốt lõi áp dụng cho database khác. Không có ứng dụng web thực tế nào hoàn chỉnh nếu không có database tin cậy hỗ trợ.
Chúng ta bắt đầu ngay!
Sự Khác Biệt Giữa schema.rb và structure.sql
Một trong những việc đầu tiên khi bắt đầu dự án Ruby on Rails là chạy database migration. Nếu generate model User, Rails sẽ yêu cầu chạy migration, tạo file schema.rb:
rails g model User first_name:string last_name:string
Rails generate migration:
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :first_name
t.string :last_name
t.timestamps
end
end
end
Sau khi chạy migration, Rails generate file schema.rb:
ActiveRecord::Schema.define(version: 2019_12_14_074018) do
enable_extension "plpgsql"
create_table "users", force: :cascade do |t|
t.string "first_name"
t.string "last_name"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
end
File schema.rb tuyệt vời cho app cơ bản và use case đơn giản.
Hai điểm chính:
- Là biểu diễn Ruby của database;
schema.rbđược tạo bằng cách inspect database và biểu diễn cấu trúc bằng Ruby. - Database-agnostic (dù dùng SQLite, PostgreSQL, MySQL hay database khác Rails hỗ trợ, syntax và cấu trúc giữ nguyên).
Tuy nhiên, có lúc chiến lược này trở nên quá giới hạn cho app đang lớn.
Ví dụ: bạn có hàng trăm/nghìn migration file.
Nếu cần nhanh chóng spin up production system mới, chạy tất cả migration tuần tự có thể quá lâu. Hoặc gặp tình huống migration chứa code dành cho version database cũ, không còn chạy được trên version hiện tại. Hoặc migration viết với giả định data không còn hợp lệ, gây fail migration.
Tất cả ngăn cản việc setup instance mới của app (production hoặc cho team member mới) với đơn giản rails db:create db:migrate. Nếu vậy, làm sao để có đúng database schema?
Cách một: quay lại fix tất cả migration hỏng. Không phải ý tưởng xấu!
Nếu fix migration tốn kém, cách khác: chạy task rails db:setup. Task này generate database schema từ file schema.rb. Nhưng nếu database chứa logic phức tạp không được biểu diễn trong schema.rb?
May mắn Rails cung cấp alternative: structure.sql
structure.sql khác schema.rb ở:
- Cho phép bản sao chính xác cấu trúc database. Quan trọng khi làm việc team, cũng như cần nhanh chóng generate database mới trong production từ task
rails db:setup. - Cho phép bảo toàn thông tin tính năng database nâng cao. Ví dụ dùng PostgreSQL, cho phép dùng views, materialized views, functions, constraints...
Khi app đạt mức độ chín muồi nhất định, phải dùng mọi thủ đoạn để tăng hiệu quả, bảo toàn tính đúng đắn dữ liệu, đảm bảo performance cực nhanh. Dùng structure.sql quản lý hành vi database Rails cho phép làm điều đó.
Chuyển Từ schema.rb Sang structure.sql
Chuyển đổi khá đơn giản. Chỉ cần thêm dòng sau trong config/application.rb:
module YourApp
class Application < Rails::Application
config.load_defaults 6.0
# Thêm dòng này:
config.active_record.schema_format = :sql
end
end
Sau đó chạy rails db:migrate và bạn sẽ thấy file ở db/structure.sql. Voilà! Rails sẽ dump cấu trúc database dùng tool đặc thù của database bạn dùng (PostgreSQL dùng pg_dump, MySQL/MariaDB chứa output của SHOW CREATE TABLE cho mỗi table, v.v). Khuyến khích để file này dưới version control để team có cùng cấu trúc database.
Nhìn đầu file có thể ngại: schema.rb chỉ 25 dòng, mà structure.sql lên tới 109 dòng! File lớn như vậy mang lại lợi ích gì cho dev app?
Thêm Database-level Constraints
ActiveRecord là phần yêu thích của tôi trong Rails. Nó cho phép query database tự nhiên, gần như ngôn ngữ nói. Ví dụ: tìm tất cả user tên Dan của company, ActiveRecord cho phép:
company = Company.find(name: 'Some Company')
company.users.where(first_name: 'Dan')
Nhưng có trường hợp ActiveRecord ngắn hạn. Ví dụ validation sau trên User model:
class User < ApplicationRecord
validate :name_cannot_start_with_d
private
def name_cannot_start_with_d
if first_name.present? && first_name[0].downcase == 'd'
errors.add(:first_name, "cannot start with the letter 'D'")
end
end
end
Thử tạo user tên 'Dan', sẽ thấy error khi validation chạy:
User.create!(first_name: 'Dan')
# ActiveRecord::RecordInvalid (Validation failed: First name cannot start with the letter 'D')
Được, nhưng giả sử bạn hoặc team member thay đổi data bypass ActiveRecord validation:
u = User.create(first_name: 'Pan')
u.update_attribute :first_name, 'Dan' # update_attribute bypass validation
u.first_name #=> "Dan"
Rất dễ bypass validation. Điều này có hậu quả thảm họa. ActiveRecord là phúc cũng là họa - DSL tự nhiên, dễ làm việc, nhưng thường quá chăcdigheter khi enforce model validation. Giải pháp: thêm database-level constraints.
rails g migration AddFirstNameConstraintToUser
Generate file, edit logic ngăn first name bắt đầu bằng 'D':
class AddFirstNameConstraintToUser < ActiveRecord::Migration[6.0]
def up
execute "ALTER TABLE users ADD CONSTRAINT name_cannot_start_with_d CHECK (first_name !~* '^d')"
end
def down
execute "ALTER TABLE users DROP CONSTRAINT IF EXISTS name_cannot_start_with_d"
end
end
Rất quan trọng: thêm code revert migration thành công. Ví dụ có up và down. up chạy khi migrate, down khi rollback. Không revert đúng, sau này phải dọn dẹp thủ công. Luôn khuyến khích migration file chạy được cả up và down.
Chạy migration và kiểm tra bypass constraint:
rails db:migrate
user = User.create first_name: 'Pan'
user.update_attribute :first_name, 'Dan'
# ActiveRecord::StatementInvalid (PG::CheckViolation: ERROR: new row for relation "users" violates check constraint "name_cannot_start_with_d")
# DETAIL: Failing row contains (2, Dan, null, 2019-12-14 09:40:11.809358, 2019-12-14 09:40:41.658974).
Hoàn hảo! Constraint hoạt động. Dù bypass ActiveRecord validation, vẫn có thể dựa vào database - "goalkeeper" cuối cùng - bảo toàn data integrity.
Điều này liên quan gì đến structure.sql?
Xem file structure.sql, bạn sẽ thấy:
CREATE TABLE public.users (
id bigint NOT NULL,
first_name character varying,
last_name character varying,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL,
CONSTRAINT name_cannot_start_with_d CHECK (((first_name)::text !~* '^d'::text))
);
Constraint nằm ngay trong schema!
Mặc dù schema.rb cũng support database-level constraints, quan trọng nhớ nó không biểu diễn mọi thứ database hỗ trợ như triggers, sequences, stored procedures hay check constraints. Ví dụ, cùng migration AddFirstNameConstraintToUser nếu chỉ dùng schema.rb:
ActiveRecord::Schema.define(version: 2019_12_14_074018) do
enable_extension "plpgsql"
create_table "users", force: :cascade do |t|
t.string "first_name"
t.string "last_name"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
end
File không thay đổi! Constraint không được thêm.
Nếu onboard developer mới, có thể operate dưới database regulation khác nhau.
Commit structure.sql vào version control giúp team trên cùng một trang. Chạy rails db:setup có structure.sql, database sẽ chứa constraint trên. Với schema.rb không có guarantee đó.
Tương tự cho production system. Cần spin up instance mới với fresh database - chạy tất cả migration tuần tự mất thời gian - setup từ structure.sql nhanh hơn. Chắc chắn structure.sql tạo database với cấu trúc y hệt instance khác.
Nỗi đau lớn lên
Quản lý schema.rb gọn gàng dễ dàng hơn structure.sql verbose.
Một nỗi đau lớn khi migrate sang structure.sql là đảm bảo chỉ commit required changes vào file, đôi khi khó.
Ví dụ: pull branch người khác và chạy migration branch đó. structure.sql bây giờ chứa changes. Quay lại branch mình và generate migration mới. structure.sql giờ chứa changes của cả hai branch. Khó xử lý, có learning curve khi manage conflict.
Chúng ta đang trade-off. Chấp nhận code complexity upfront để bảo toàn advanced functionality của database. Đổi lại, cũng phải deal với simpler schema representation và không có toàn bộ quyền lực database, ví dụ muốn set backup từ task db:setup. Tôi cho rằng chịu đựng version-control hassle tốt hơn fix corrupt/incorrect data production, hoặc không dùng được advanced functionality database cung cấp.
Nói chung, hai strategy tôi dùng để đảm bảo structure.sql chỉ chứa changes cần thiết cho branch:
- Xong branch chứa migration, chạy
rails db:rollback STEP=nvới n = số migration branch đó. Đảm bảo database structure revert về trạng thái gốc. - Có thể quên rollback sau branch. Lúc làm branch mới, pull pristine
structure.sqltừ master trước khi tạo migration mới.
Rule of thumb: structure.sql chỉ chứa changes relevant branch trước khi merge vào master.
Kết luận
Nói chung, khi Rails app nhỏ hoặc không cần advanced feature database thì dùng schema.rb an toàn, readable, concise, dễ manage.
Tuy nhiên, khi app lớn và phức tạp, phản ánh chính xác cấu trúc database là yếu tố then chốt. Nó cho phép team maintain đúng constraints, database modules, functions, operators mà schema.rb không thể. Học dùng Rails với structure.sql well-maintained mang lại lợi thế mà schema.rb đơn giản không thể.
P.S. Nếu muốn đọc Ruby Magic posts ngay khi xuất bản, đăng ký newsletter Ruby Magic để không bỏ lỡ bài nào!