Rails: "structure.sql" có gì hơn "schema.rb"?

https://unsplash.com/photos/vovFCApapYc?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink

Chào các bạn! 👋

Là một Rails developer chắc hẳn bạn cũng đã quá quen thuộc với file có tênschema.rb rồi nhỉ? Nó là một file mặc định khi chúng ta chạy lệnh rails db:migrate. Trong bài viết này, mình sẽ bật mí cho các bạn một file khác là structure.sql, đồng thời cũng sẽ đề cập đến sự khác biệt và những lợi ích của việc sử dụng structure.sql so với schema.rb. Điểm khác biệt giữa một sản phẩm thành công và không thành công là việc bạn biết cách khai thác tất cả các tính năng phong phú của cơ sở dữ liệu. 😀

Sau khi nêu ra những điểm khác biệt chính giữa hai loại, mình sẽ phác thảo cách để chuyển sangstructure.sql và chứng minh cách đó có thể giúp chúng ta đảm bảo tính toàn vẹn của dữ liệu cũng như chức năng của cơ sở dữ liệu.

Trong bài này, mình sẽ đưa ra các ví dụ về ứng dụng Rails sử dụng structure.sql với cơ sở dữ liệu MySQL (trong bài viết gốc sử dụng PostgreSQL), tuy nhiên với khái niệm và demo này bạn cũng có thể chuyển sang các cơ sở dữ liệu khác. Không có ứng dụng web nào thực sự hoàn chỉnh nếu không có cơ sở dữ liệu đáng tin cậy để hỗ trợ nó.

Bây giờ thì cùng mình tìm hiểu từng phần nhé!

1. Sự khác biệt giữa schema.rbstructure.sql

Một trong những điều đầu tiên bạn cần làm khi bắt đầu một project Ruby on Rails là run database migrations sau bước rails new.

Ví dụ: Nếu bạn muốn tạo một ứng dụng có User model, Rails chắc chắn sẽ yêu cầu bạn run migrations, điều này sẽ tạo một file schema.rb tương ứng:

  • Tạo nhanh một Rails app nào:
rails new rails-sql-structure-demo --database=mysql --skip-turbolinks --skip-test
  • Thêm username, password nếu mysql của bạn dùng password trong file: /config/database.yml

  • Chạy lệnh rails db:create để tạo database.

  • Tạo model User:

rails g model User first_name:string last_name:string

Rails sẽ tạo ra file migration như sau:

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

Khi bạn chạy lệnh rails db:migrate, bạn sẽ thấy Rails đã tạo một file schema.rb:

ActiveRecord::Schema.define(version: 2020_11_20_034839) do

  create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", 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 này là được coi là hữu ích cho các ứng dụng tương đối cơ bản.

Có hai điều chính cần lưu ý:

1. schema.rb được tạo ra bằng cách kiểm tra database và thể hiện cấu trúc của nó bằng Ruby.

2. Đây là database-agnostic (tức là cho dù bạn sử dụng SQLite, PostgreSQL, MySQL hoặc bất kỳ cơ sở dữ liệu nào khác mà Rails hỗ trợ, thì cú pháp và cấu trúc phần lớn sẽ vẫn giữ nguyên)

Tuy nhiên, có lúc điều này có thể trở nên quá hạn chế đối với ứng dụng bạn đang phát triển.

Ví dụ: Giả sử project của chúng ta có hàng trăm hoặc hàng nghìn file migrations.

Khi chúng ta muốn build project trên một system khác, chúng ta có thể sẽ mất rất nhiều thời khi mà chạy tất cả chúng theo một trình tự. Hoặc có thể gặp phải tình huống trong đó một số migration chứa code được thực thi trên phiên bản cũ hơn của cơ sở dữ liệu, nhưng code đó không còn thực thi được trên phiên bản hiện tại. Hoặc có thể gặp phải tình huống nơi mà migration được viết với một số certain data nhất định không còn hợp lệ, điều này sẽ khiến việc migration không thành công.

Tất cả những tình huống này ngăn cản việc thiết lập một cách hiệu quả một phiên bản mới của với app của chúng ta — có thể trong production hay cho một thành viên trong team — bằng lệnh rails db:create db:migrate đơn giản. Nếu đúng như vậy, chúng ta sẽ phải làm thế nào để việc migration của chúng ta có thể tạo được một database schema đúng?

Chắc chắn, có một cách là quay lại và fix tất cả các lần migrations bị lỗi 😃. Đó không bao giờ là một ý tưởng tồi!

Nếu việc quay lại và fix một loạt các lần migrations là tốn rất nhiều effort, thì có một cách khác sẽ là chạy rails db:setup. Task này sẽ tạo một database schema từ file schema.rb. Tuy nhiên, điều gì sẽ xảy ra nếu database chứa những logic phức tạp?

Thật tuyệt, Rails cung cấp một giải pháp thay thế: structure.sql

structure.sql khác với schema.rb ở những điểm sau:

  • Nó cho phép tạo một bản sao chính xác của cấu trúc cơ sở dữ liệu. Điều này rất quan trọng khi làm việc trong 1 team, và nếu chúng ta cần nhanh chóng tạo cơ database mới trong production từ task rails db:setup.
  • Nó cho phép lưu giữ thông tin của các tính năng cơ sở dữ liệu nâng cao. Ví dụ: nếu bạn đang sử dụng PostgreSQL, nó cho phép sử dụng các views, các views được cụ thể hóa, các functions, các constraints, v.v.

Khi ứng dụng đạt đến mức độ hoàn thiện nhất định, chúng ta phải sử dụng mọi thủ thuật để tăng hiệu quả, duy trì tính chính xác của dữ liệu và đảm bảo hiệu suất phải nhanh như chớp =)). Sử dụng structure.sql để quản lý Rails database sẽ cho phép chúng ta đạt được sự mong muốn trên. Vậy còn ngần ngại gì mà không thử?

2. Chuyển từ schema.rb sang structure.sql

Thực hiện thay đổi từ schema.rb sang structure.sql tương đối đơn giản. Tất cả những gì bạn cần làm là đặt config.active_record.schema_format = :sql vào file config/application.rb:

module RailsSqlStructureDemo
  class Application < Rails::Application
    config.load_defaults 6.0

    config.generators.system_tests = nil

    config.active_record.schema_format = :sql
  end
end

Sau đó, chạy rails db:migrate và bạn sẽ thấy xuất hiện 1 file db/structure.sql. Rails sẽ dump cấu trúc cơ sở dữ liệu bằng cách sử dụng công cụ cụ thể cho cơ sở dữ liệu bạn đang sử dụng (trong trường hợp của PostgreSQL, công cụ đó pg_dump, đối với MySQL hoặc MariaDB, nó sẽ chứa output của SHOW CREATE TABLE cho mỗi bảng, v.v.). Bạn nên đảm bảo file này được kiểm soát version điều đó sẽ dễ dàng hơn cho team để có cùng cấu trúc cơ sở dữ liệu.

Thoạt nhìn qua file đó có thể khiến bạn nản lòng: file schema.rb chỉ ít hơn 25 dòng, trong khi file structure.sql có tới hơn 50 dòng. Thử xem xét những lợi ích mà nó mang lại nhé!

Thêm các ràng buộc Database-level ActiveRecord là một trong những phần yêu thích của khá nhiều người khi sử dụng Rails. Nó cho phép chúng ta truy vấn cơ sở dữ liệu theo cách tự nhiên, gần giống như trong ngôn ngữ nói.

Ví dụ: nếu bạn muốn tìm tất cả người dùng có tên "Cải", thì ActiveRecord cho phép bạn chỉ cần chạy một truy vấn như sau:

Users.where(first_name: "Cải")

Có một số trường hợp ActiveRecord bị thiếu.

Ví dụ: giả sử chúng ta muốn check thêm một số điều kiện quy định khi đặt tên.

class User < ApplicationRecord
  validate :name_cannot_start_with_d

  private

  def name_cannot_start_with_d
    if first_name.present? && first_name[0].downcase == 'c'
      errors.add(:first_name, "Cannot start with the letter 'c'")
    end
  end
end

Nếu chúng ta cố gắng tạo user có tên 'Cải', chúng ta sẽ thấy lỗi khi check validation.

User.create!(first_name: "Cải", last_name: "Bắp")
Traceback (most recent call last):
        1: from (irb):1
ActiveRecord::RecordInvalid (Validation failed: First name cannot start with the letter 'C')

Điều này khá tốt, nhưng giả sử trong team có một member đã thay đổi dữ liệu bằng cách bỏ qua validation của ActiveRecord:

> User.create(first_name: "Tom")
User Create (0.3ms)  INSERT INTO `users` (`first_name`, `created_at`, `updated_at`) VALUES ('Tom', '2020-11-20 10:16:12.094033', '2020-11-20 10:16:12.094033')
   (14.3ms)  COMMIT
=> #<User id: 2, first_name: "Tom", last_name: nil, created_at: "2020-11-20 10:16:12", updated_at: "2020-11-20 10:16:12">

> u = User.find 2
  User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1

> u.update_attribute :first_name, "Cam"
   (0.5ms)  BEGIN
  User Update (0.8ms)  UPDATE `users` SET `users`.`first_name` = 'Cam', `users`.`updated_at` = '2020-11-20 10:17:24.049010' WHERE `users`.`id` = 2
   (7.8ms)  COMMIT
=> true

> u.first_name
=> "Cam"

Như command trên, dễ dàng để nhận thấy nó đã bỏ qua validation.

Điều này có thể gây ra hậu quả tai hại cho ứng dụng của chúng ta. ActiveRecord có thể là một sự "may mắn" nhưng nó cũng mang một "lời nguyền"⁠ — trong khi có một DSL rất xịn xò sẽ khiến bạn cảm thấy thích thú khi làm việc cùng, nó quá dễ khi thực thi các model-level validations. Giải pháp, như bạn có thể đã biết, là thêm các ràng buộc cấp cơ sở dữ liệu.

rails g migration AddFirstNameConstraintToUser

Thao tác này sẽ tạo một file mà chúng ta có thể chỉnh sửa theo logic để không cho phép first_name bắt đầu bằng chữ "C":

class AddFirstNameConstraintToUser < ActiveRecord::Migration[6.0]
  def up
    execute "ALTER TABLE users ADD CONSTRAINT name_cannot_start_with_c CHECK (first_name NOT LIKE '%c')"
  end

  def down
    execute "ALTER TABLE users DROP CONSTRAINT IF EXISTS name_cannot_start_with_c"
  end
end

Lưu ý rằng điều rất quan trọng là phải thêm code revert khi migration thành công. Trong ví dụ trên, có 2 method updown. Method up được thực thi khi chạy migration, down được thực thi khi migration rolled back. Nếu không revert đúng cấu trúc cơ sở dữ liệu, chúng ta có thể phải thực hiện một số công việc một cách thủ công sau đó. Túm lại là nên có một file migration được thực thi cả hai updown để tránh đau đầu trong tương lai :v Một lưu ý nữa là ADD CONSTRAINT không được thực hiện trên các version mysql thấp hơn.

Bây giờ, hãy chạy migration và kiểm tra xem chúng ta có thể bỏ qua ràng buộc đó hay không:

rails db:migrate

user = User.create first_name: 'Tee'
user.update_attribute :first_name, 'Celin'

ActiveRecord::StatementInvalid(ERROR:  new row for relation "users" violates check constraint "name_cannot_start_with_c")

Perfect! Có vẻ constraint đang hoạt động như tính toán. Ngay cả khi chúng ta bỏ qua validation của ActiveRecord, chúng ta vẫn có thể dựa vào database để bảo toàn tính toàn vẹn dữ liệu.

Điều này có liên quan gì structure.sql?

Nếu nhìn vào nó, bạn sẽ thấy rằng CONSTRAINT đã được thêm vào:

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 NOT LIKE '%c');
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4;

Mặc dù schema.rb cũng hỗ trợ các constraint cấp cơ sở dữ liệu, nhưng điều quan trọng cần nhớ là nó không thể hiện mọi thứ mà cơ sở dữ liệu có thể hỗ trợ như triggers, sequences, stored procedures hay check constraints.

Ví dụ: Đây là điều sẽ xảy ra với file schema với cùng một lần migration chính xác (AddFirstNameConstraintToUser) nếu chúng ta chỉ sử dụng schema.rb:

ActiveRecord::Schema.define(version: 2020_11_20_094315) do

  create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", 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

Bạn không thấy thay đổi gì đúng không? Constraint cũng chẳng được thêm.

Có cam kết rằng structure.sql kiểm soát version sẽ giúp đảm bảo team của chúng ta thực hiện cùng nhau. Nếu chạy rails db:setup có một file structure.sql, database sẽ chứa các constraint trên. Còn với schema.rb thì không có đâu.

Điều này cũng có thể nói về production system. Nếu chúng ta cần thiết lập nhanh một phiên bản mới của app với database mới và chạy tất cả các migration tuần tự sẽ mất nhiều thời gian, việc thực hiện bằng structure.sql nhanh hơn nhiều. Và nó sẽ tạo database với cấu trúc chính xác như trong các trường hợp khác.

3. Ưu, nhược điểm

Quản lý file schema.rb ngắn gọn trong một team là một nhiệm vụ dễ dàng hơn nhiều so với quản lý file structure.sql dài dòng.

Một trong những khó khăn ngày càng tăng khi di chuyển sang structure.sql là đảm bảo rằng chỉ những thay đổi bắt buộc mới được cam kết với tệp đó, điều này đôi khi có thể khó thực hiện.

Ví dụ: Bạn kéo nhánh của ai đó và chạy migrations cụ thể cho nhánh đó. structure.sql của bạn bây giờ có một số thay đổi. Sau đó, bạn quay lại làm việc trên chi nhánh của riêng mình và tạo một migration mới. File structure.sql bây giờ sẽ chứa cả những thay đổi nhánh của bạn và cả nhánh khác. Điều này có thể hơi phức tạp để giải quyết và chắc chắn chúng ta cần có một chút kinh nghiệm để quản lý những cònflict này.

Bằng cách sử dụng phương pháp này, chúng ta đang đánh đổi. Phải đối phó với một chút phức tạp của code từ trước cho phép chúng ta duy trì chức năng nâng cao của cơ sở dữ liệu. Đổi lại, chúng ta cũng không dùng schema đơn giản hơn cũng như không có tất cả sức mạnh của cơ sở dữ liệu trong tầm tay của chúng ta.

Nói chung, có hai điều mà chúng ta đã sử dụng để đảm bảo file structure.sqlchỉ chứa những thay đổi cần thiết cho một nhánh cụ thể:

  • Khi đang làm việc trên một nhánh có chứa migrations, hãy chắc chắn bạn chạy rails db:rollback STEP=n n là số migrations ở nhánh đó. Điều này sẽ đảm bảo cấu trúc cơ sở dữ liệu trở lại trạng thái ban đầu.
  • Chúng ta cũng có thể quên rollback sau khi làm việc trên một nhánh. Trong trường hợp đó, khi làm việc trên một nhánh mới, hãy đảm bảo bạn kéo một file structure.sql nguyên gốc từ từ nhánh master/main trước khi tạo bất kỳ di chuyển mới nào.

Theo nguyên tắc chung, file structure.sql chỉ nên chứa các thay đổi liên quan đến nhánh của bạn trước khi được merge vào master.

Kết luận

Nói chung, khi các ứng dụng Rails nhỏ hoặc không cần một số tính năng nâng cao hơn mà cơ sở dữ liệu cung cấp thì chúng ta có thể sử dụng schema để cho dễ đọc, ngắn gọn và dễ quản lý.

Tuy nhiên, khi một ứng dụng phát triển về quy mô và độ phức tạp, thì điều cốt yếu là phải phản ánh chính xác cấu trúc cơ sở dữ liệu. Lúc này bạn nên nghĩ đến việc dùng structure.sql, nó sẽ mang lại lợi thế mà schema.rb không thể.

Hy vọng bài viết này có thể giúp bạn có cái nhìn tổng quan về structure.sql và áp dụng nó vào trong project của mình. Thân ái!!! 🤗

Refferency: Pros and Cons of Using structure.sql in Your Ruby on Rails Application


All Rights Reserved