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.rblà snapshot authoritative- Validations chạy trước
savevớipresence/length/format/uniqueness;uniquenesscần index duy nhất ở DB để tránh race conditionhas_secure_passworddùng bcrypt lưupassword_digest, cung cấpauthenticateconstant-time- Model tests kế thừa
ActiveSupport::TestCase, dùngsetup+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.rbluôn là snapshot chuẩn đểdb:schema:load.
Tôi chạy lệnh quen thuộc:
rails generate model User name:string email:stringRails 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
endcreate_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:stringRails 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
validateslà chốt chặn trướccreate/save/update; nếu fail thì trảfalsevà điềnerrors, 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 }
endpresence: 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/iThiế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"]| Method | Khi fail |
|---|---|
save / update | Trả false |
save! / update! | Raise RecordInvalid |
update_attribute | Ghi 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ồiuser.errors.full_messagesngay 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: trueDò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ầng | Chặn gì | Ví dụ |
|---|---|---|
| Model validation | Lỗi nhập liệu thường | validates :email, presence: true |
| DB constraint | Race condition | add_index :users, :email, unique: true |
| Optimistic locking | Ghi đè đồng thời | lock_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: truemà 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ểmdb/schema.rbcóunique: truetrướ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 gembcrypt+ cộtpassword_digest) tự băm mật khẩu bằng Blowfish salted cost 2^12, lưu hash và cung cấpauthenticate(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 }
endhas_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") # => falseCost 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.
| Cost | Vòng | Thời gian | Ghi chú |
|---|---|---|---|
| 10 | 1,024 | ~60 ms | Nhanh, yếu hơn |
| 12 | 4,096 | ~250 ms | Mặc định Rails |
| 14 | 16,384 | ~1 s | Chậ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ừaActiveSupport::TestCase, dùngsetuptạo@userhợp lệ rồi đổi từng field để assertvalid?/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
endsetup 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?
endFixtures 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 fileTô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
validatesvà 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:
rails db:migrate:statussạch,db/schema.rbđã commit,db:schema:loadchạy được trên CI.- Validations đủ bốn loại, mỗi loại có test valid/invalid, biên
51và5đều có. password_digestlà string,bcryptđã bundle,has_secure_passwordbật.add_index :users, :email, unique: truecó trong schema.rails test:modelsxanh,authenticatetest 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ước | Tạo gì | Kiểm gì |
|---|---|---|
| 1. Migrations | create_users, add_index unique | db:migrate:status sạch |
| 2. Validations | presence/length/format/uniqueness | valid? |
| 3. Secure password | bcrypt + has_secure_password | authenticate |
| 4. Model tests | setup + assertions | rails 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.rbvà đọc to: email cóunique: truekhông,password_digestlà string không,lock_versioncó 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 ❓
Migrations khác schema.rb thế nào?
Migrations là các file thay đổi tăng dần theo thời gian;
schema.rblà ảnh chụp tổng hợp sau khi chạy hết migrations. Deploy production thường dùngdb:schema:loadtừ schema để nhanh và ít lỗi hơn chạy lại chuỗi migrations dài.
Vì sao uniqueness validation cần thêm unique index ở DB?
Vì validation chỉ kiểm tra ở tầng Ruby trước khi INSERT. Hai request đồng thời có thể cùng thấy “chưa tồn tại” rồi cùng ghi, tạo trùng lặp.
add_index :users, :email, unique: truekhóa ở tầng DB, chặn race condition.
has_secure_password có tự validate độ dài mật khẩu không?
Không. Nó chỉ tự thêm presence khi tạo mới và kiểm tra confirmation khi có field. Bạn cần tự thêm
validates :password, length: { minimum: 6 }và test vớipassword = "a"*5để đảm bảo.
Nên dùng save, save! hay update_attribute?
Dùng
save/updatekhi muốn tôn trọng validations và xử lýfalse; dùngsave!khi muốn raise lỗi ngay (ví dụ seeds). Tránhupdate_attributevì nó bỏ qua validations, dễ ghi dữ liệu bẩn.
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ự.