Tôi từng nghĩ tạo bảng users chỉ là CREATE TABLE rồi xong, cho tới khi một lần đổi tên cột trên staging làm sập deploy vì thiếu migration rollback. Tôi chạy rails db:migrate:status và thấy một migration ở trạng thái down, schema trên server lệch với code. Trải nghiệm đó cho thấy Model trong Rails không chỉ là class Ruby - nó là hợp đồng giữa code, schema versioned và dữ liệu sản xuất. Bài này đi theo đúng thứ tự Chương 6 tài liệu Rails Tutorial của Michael Hartl để khóa từng tầng một cách có kỷ luật.

Tóm tắt

  • Migrations là file Ruby versioned tiến hóa schema; db/schema.rb là snapshot authoritative
  • Validations chạy trước save với presence/length/format/uniqueness; uniqueness cần index duy nhất ở DB để tránh race condition
  • has_secure_password dùng bcrypt lưu password_digest, cung cấp authenticate constant-time
  • Model tests kế thừa ActiveSupport::TestCase, dùng setup + valid?/invalid? để khóa chặt mọi validation
  • Quy trình thiết kế Model chuẩn mực: migrations → validations → secure password → tests tạo vòng khép kín evergreen

Nền tảng Model trong Rails 🏗️

Hãy tưởng tượng Model như bản thiết kế nhà. Migrations là móng, đổ một lần và ghi lại từng lần sửa. Validations là quy chuẩn xây dựng, chặn vật liệu sai trước khi dựng tường. has_secure_password là khóa cửa, không phải ổ khóa giả. Tests là kiểm định, gõ búa vào từng mối nối để chắc không sập khi có người ở. Thiếu một trụ, nhà vẫn đứng tạm, nhưng gió lớn sẽ lộ.

Trả lời nhanh

Model là lớp duy nhất chạm cả ba tầng - Ruby, SQL và dữ liệu người dùng - nên lỗi ở Model lan ra toàn hệ thống; Chương 6 tài liệu Rails Tutorial của Michael Hartl chọn đúng thứ tự migrations → validations → has_secure_password → tests để khóa từng tầng.

Tôi thấy nhiều team bắt đầu từ controller rồi vá model sau. Controller chết sau mỗi request, model sống cùng dữ liệu nhiều tháng. Một validation thiếu hôm nay thành dữ liệu bẩn ngày mai.

Bốn trụ trong Chương 6 (Modeling Users) của Michael Hartl tạo chuỗi phụ thuộc: không có cột password_digest thì has_secure_password vô nghĩa. Không có validation thì test không có gì để assert.

Trong một hệ thống tự động tôi từng xây dựng, bộ lọc đánh giá ban đầu vô tình tính cả các bản ghi chưa duyệt vào cửa sổ tính toán, khiến tỷ lệ chính xác bị thổi phồng. Bài học rút ra là: mọi điều kiện lọc trạng thái bắt buộc phải được khóa chặt bằng mệnh đề where ở tầng truy vấn cơ sở dữ liệu thay vì đưa vào bộ nhớ ứng dụng rồi mới lọc.

Đọc thêm

Nếu bạn mới với Rails, hãy đọc kiến trúc Ruby on Rails MVC trước để đặt Model đúng chỗ trong tam giác M-V-C, rồi quay lại đây với bản đồ 4 trụ trong đầu.

Migrations và schema.rb 🔍

Nghĩ về migrations như git log cho database. Mỗi file là một commit: tạo bảng, thêm cột, thêm index. Rails chạy chúng theo timestamp, và db/schema.rb là bản build cuối cùng.

Trả lời nhanh

Migrations là file Ruby có timestamp trong db/migrate, mô tả thay đổi schema theo thời gian; rails db:migrate áp tuần tự và db/schema.rb luôn là snapshot chuẩn để db:schema:load.

Tôi chạy lệnh quen thuộc:

rails generate model User name:string email:string

Rails sinh ra bốn thứ cùng lúc: db/migrate/YYYYMMDDHHMMSS_create_users.rb, app/models/user.rb, test/models/user_test.rb và test/fixtures/users.yml. File migration trông như thế này (Rails Guides - Active Record Migrations):

class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email
      t.timestamps
    end
  end
end

create_table tự tạo cột id kiểu bigint. t.timestamps thêm created_at và updated_at. Method change tự reversible với create_table, add_column, add_index; thao tác phức tạp thì tách up/down.

Thêm cột sau này tuân quy ước tên:

rails generate migration add_password_digest_to_users password_digest:string

Rails suy luận add_column từ tên migration như ví dụ ở trên.

db/schema.rb được sinh tự động sau mỗi migrate, luôn commit vào git. Trên CI, rails db:schema:load nhanh hơn chạy lại hàng trăm migration. ActiveRecord Migrations chỉ rõ nguyên tắc: schema là snapshot, migrations là lịch sử tiến hóa.

Lưu ý phiên bản

ActiveRecord::Migration[7.0] khóa API theo phiên bản Rails. Khi bạn nâng Rails, giữ nguyên số này trong file cũ, chỉ file mới dùng số mới. Đổi bừa sẽ làm rollback sai.

Bốn khiên Validations 🛡️

Nếu migrations là móng, validations là người gác cửa. Mỗi bản ghi phải qua bốn khiên trước khi chạm DB: có mặt, độ dài hợp lệ, định dạng đúng, và không trùng.

Trả lời nhanh

validates là chốt chặn trước create/save/update; nếu fail thì trả false và điền errors, giúp UI báo lỗi mà không chạm DB.

Tôi định nghĩa model như Hartl gợi ý:

class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  validates :password, presence: true, length: { minimum: 6 }
end

presence: true chặn nil, "" và " " - quan trọng vì form thường gửi chuỗi rỗng (Rails Guides - Active Record Validations).

length hỗ trợ minimum, maximum, in: 6..20, is: 10. format dùng regex với neo \A và \z để khớp toàn chuỗi:

VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

Thiếu \A/\z, chuỗi như user@example,com có thể lọt như đã dẫn ở phần validations trên. uniqueness: true là khiên mềm ở tầng Ruby, sẽ khóa cứng ở H2 sau.

Kiểm tra nhanh:

user.valid?              # true nếu không lỗi
user.errors.full_messages # ["Email has already been taken"]
MethodKhi fail
save / updateTrả false
save! / update!Raise RecordInvalid
update_attributeGhi thẳng, bỏ qua validations

Tôi tránh update_attribute vì nó bỏ qua mọi khiên. tài liệu ActiveRecord Validations và Rails Model Tests nhấn mạnh cạm bẫy này.

Mẹo nhỏ

Khi debug, gọi user.valid? rồi user.errors.full_messages ngay trong console thay vì đoán. Thông điệp lỗi là nguồn chân lý nhanh nhất.

Ràng buộc tầng Database ⚠️

Tôi từng tin validation ở model là đủ, cho tới khi hai request đăng ký cùng email chạy song song và cả hai đều valid?. Hai INSERT cùng qua, DB không phàn nàn, và tôi có duplicate. Vấn đề là timing.

Trả lời nhanh

Validation ở model chạy ở tầng Ruby nên hai request song song có thể cùng vượt qua; chỉ add_index ... unique: true ở DB mới đảm bảo duy nhất tuyệt đối.

Hai request cùng kiểm exists? → false, cùng INSERT → duplicate nếu thiếu index. Đây là race condition kinh điển.

Giải pháp là defense in depth: validation cho UX, DB constraint cho toàn vẹn dữ liệu:

add_index :users, :email, unique: true

Dòng này tạo B-Tree unique ở DB, chặn duplicate dù app có race. Tôi thêm null: false ngay từ đầu cho cột quan trọng để tránh NULL lọt qua unique index.

Để xử lý ghi đè đồng thời khi nhiều người cùng sửa một bản ghi, Rails cung cấp cơ chế Optimistic Locking thông qua cột lock_version. Khi hai giao dịch cùng đọc một phiên bản nhưng gửi ghi đè lệch nhau, giao dịch thứ hai sẽ nhận ActiveRecord::StaleObjectError để ứng dụng xử lý và yêu cầu client nạp lại dữ liệu mới.

TầngChặn gìVí dụ
Model validationLỗi nhập liệu thườngvalidates :email, presence: true
DB constraintRace conditionadd_index :users, :email, unique: true
Optimistic lockingGhi đè đồng thờilock_version

Mọi uniqueness: true phải đi kèm add_index ... unique: true. Optimistic Locking là bước tiếp khi có ghi đồng thời thực sự.

Sai lầm phổ biến

Thêm uniqueness: true mà quên index sẽ qua hết test đơn luồng, nhưng sập ở tải song song. Hãy chạy test concurrency hoặc ít nhất kiểm db/schema.rb có unique: true trước khi merge.

has_secure_password và bcrypt 🔒

Mật khẩu là thứ duy nhất bạn không bao giờ được nhìn thấy lại sau khi người dùng gõ. Tôi từng thấy codebase lưu plaintext để “debug dễ”. Đó là cửa mở cho rò rỉ.

Trả lời nhanh

has_secure_password (cần gem bcrypt + cột password_digest) tự băm mật khẩu bằng Blowfish salted cost 2^12, lưu hash và cung cấp authenticate(plaintext) so sánh constant-time.

Setup chỉ ba bước, nhưng thiếu một bước là lỗi ngay:

# Gemfile
gem "bcrypt", "~> 3.1.7"
# Migration
class AddPasswordDigestToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :password_digest, :string
  end
end
# Model
class User < ApplicationRecord
  has_secure_password
  validates :password, length: { minimum: 6 }
end

has_secure_password thêm password= (băm vào password_digest), password_confirmation= và authenticate(plaintext) (Rails API - ActiveModel::SecurePassword). Hai user cùng mật khẩu vẫn cho hash khác nhau vì salt.

user = User.create!(name: "An", email: "an@example.com", password: "foobar", password_confirmation: "foobar")
user.authenticate("foobar") # => user
user.authenticate("wrong")  # => false

Cost 12 nghĩa là 4096 vòng, tốn ~250 ms, đủ chậm để chống brute-force (OWASP Password Storage Cheat Sheet; bcrypt gem). OWASP khuyến nghị chỉnh cost sao cho băm tốn 250-500 ms.

CostVòngThời gianGhi chú
101,024~60 msNhanh, yếu hơn
124,096~250 msMặc định Rails
1416,384~1 sChậm, UX kém

authenticate so sánh constant-time, tránh timing attack như Rails API đã mô tả ở trên. Validation tự thêm chỉ có presence khi tạo mới, bạn cần tự thêm validates :password, length: { minimum: 6 }.

Đọc thêm

Sau khi nắm bcrypt, hãy xem ActiveRecord has_secure_password để đào sâu cost và cấu trúc hash.

Khóa hành vi bằng Model Tests 🧪

Tôi viết test model không phải để đạt coverage, mà để khóa hành vi. Mỗi validation có một test khóa, xóa validation là test đỏ ngay.

Trả lời nhanh

Model tests sống ở test/models/user_test.rb, kế thừa ActiveSupport::TestCase, dùng setup tạo @user hợp lệ rồi đổi từng field để assert valid?/invalid?.

Khung chuẩn từ tài liệu kiểm thử Rails:

require "test_helper"
 
class UserTest < ActiveSupport::TestCase
  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
 
  test "should be valid" do
    assert @user.valid?
  end
end

setup là Arrange. Mỗi test đổi một field thành invalid rồi assert_not @user.valid?.

Các pattern tôi dùng:

test "name should be present" do
  @user.name = "   "
  assert_not @user.valid?
end
 
test "email validation should accept valid addresses" do
  valid_addresses = %w[user@example.com USER@foo.COM A_US-ER@foo.bar.org
                       first.last@foo.jp alice+bob@baz.cn]
  valid_addresses.each do |valid_address|
    @user.email = valid_address
    assert @user.valid?, "#{valid_address.inspect} should be valid"
  end
end
 
test "email addresses should be unique" do
  duplicate_user = @user.dup
  @user.save
  assert_not duplicate_user.valid?
end
 
test "password should be present (nonblank)" do
  @user.password = @user.password_confirmation = " " * 6
  assert_not @user.valid?
end

Fixtures nạp trước mỗi test khi fixtures :all bật:

michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest("password") %>

Model tests kế thừa ActiveSupport::TestCase, không cần get/post như controller tests (Rails Guides - Testing).

Lệnh chạy:

rails test                 # tất cả
rails test:models           # chỉ model
rails test test/models/user_test.rb  # một file

Tôi chạy rails test:models trước mỗi commit liên quan đến User. Nếu test xanh, tôi mới nghĩ tới controller. quy trình TDD Red-Green-Refactor và Rails Testing là hai neo để mở rộng từ model sang integration.

Gợi ý thực hành

Viết test presence trước, rồi length, rồi format, rồi uniqueness. Mỗi bước thêm một validates và chạy test tới xanh. Nhịp red-green nhỏ giúp bạn không quên nhánh nào.

Quy trình và Checklist bàn giao 📋

Mỗi trụ đứng riêng đã tốt, nhưng giá trị thật nằm ở thứ tự. Migrations trước, validations sau, bcrypt sau nữa, tests khóa cuối. Đảo thứ tự, bạn sẽ vá lỗi thay vì ngăn lỗi.

Trả lời nhanh

Quy trình khép kín là: sinh migration → chạy migrate → thêm validations + has_secure_password → viết test khóa lại → thêm index DB → rà soát lại các điều kiện lọc trạng thái để đảm bảo dữ liệu luôn được bảo vệ đa tầng.

Checklist trước mỗi deploy có đụng tới User:

  1. rails db:migrate:status sạch, db/schema.rb đã commit, db:schema:load chạy được trên CI.
  2. Validations đủ bốn loại, mỗi loại có test valid/invalid, biên 51 và 5 đều có.
  3. password_digest là string, bcrypt đã bundle, has_secure_password bật.
  4. add_index :users, :email, unique: true có trong schema.
  5. rails test:models xanh, authenticate test cả đúng/sai.

Thứ tự không đảo được: thiếu cột thì has_secure_password lỗi ngay. Bài học từ thực tế xử lý dữ liệu lớn: luôn dùng pluck(:id) kết hợp mệnh đề where trực tiếp ở SQL để lấy đúng tập dữ liệu hợp lệ thay vì nạp toàn bộ object vào bộ nhớ rồi mới lọc - ràng buộc toàn vẹn phải luôn được neo vững ở tầng cơ sở dữ liệu.

BướcTạo gìKiểm gì
1. Migrationscreate_users, add_index uniquedb:migrate:status sạch
2. Validationspresence/length/format/uniquenessvalid?
3. Secure passwordbcrypt + has_secure_passwordauthenticate
4. Model testssetup + assertionsrails test:models xanh

Tôi hay hỏi: nếu xóa một dòng validates, test nào sẽ đỏ? Nếu không có câu trả lời, tức là thiếu test. Sau khi khóa xong User, hướng mở rộng là Optimistic Locking khi nhiều người sửa cùng bản ghi.

Checklist bàn giao

Trước khi merge, mở db/schema.rb và đọc to: email có unique: true không, password_digest là string không, lock_version có nếu cần concurrency không. Nếu thiếu, thêm migration, đừng vá bằng validation.

Câu hỏi thường gặp ❓

Kết luận 📌

Rails Model Foundations không phải mẹo vặt mà là kỷ luật: migrations giữ lịch sử schema minh bạch, validations chặn dữ liệu bẩn sớm, bcrypt bảo vệ mật khẩu bằng toán học, và model tests khóa tất cả lại bằng valid?. Đi đúng thứ tự Chương 6 tài liệu Rails Tutorial của Michael Hartl giúp bạn tránh những lỗi production đắt giá - từ duplicate email do thiếu index tới plaintext lộ ra vì thiếu password_digest. Từ đây, hãy đào sâu Optimistic Locking và Rails Testing để thấy Model bền vững vận hành thế nào khi có concurrency thực sự.