Khách hàng rơi vào tình trạng này như thế nào?
Trước khi đi sâu vào chi tiết, hãy thử hiểu một ứng dụng có thể rơi vào trạng thái này như thế nào. Chúng ta bắt đầu với một bảng users đơn giản. Sau vài tuần, chúng ta cần xác định thời gian đăng nhập cuối cùng nên thêm users.last_sign_in_at. Sau đó cần biết tên người dùng. Thêm first_name và last_name. Nickname Twitter? Thêm cột nữa. Hồ sơ GitHub? Số điện thoại? Sau vài tháng, bảng trở nên khó quản trị.
Vấn đề là gì?
Một bảng lớn như vậy cho thấy một số vấn đề:
Usercó nhiều trách nhiệm không liên quan. Điều này làm cho nó khó hiểu, thay đổi và kiểm thử hơn.- Trao đổi dữ liệu giữa ứng dụng và cơ sở dữ liệu yêu cầu băng thông thêm.
- Ứng dụng cần nhiều bộ nhớ hơn để lưu trữ model cồng kềnh.
Ứng dụng lấy User ở mọi request để xác thực và phân quyền nhưng thường chỉ sử dụng một vài cột. Việc sửa vấn đề này sẽ cải thiện cả thiết kế và hiệu suất.
Tách bảng
Chúng ta có thể giải quyết bằng cách tách các cột ít dùng sang bảng mới (hoặc các bảng mới). Ví dụ, tách thông tin hồ sơ (first_name, v.v.) vào profiles theo các bước:
- Tạo
profilesvới các cột trùng lặp các cột liên quan đến hồ sơ trongusers. - Thêm
profile_idvàousers. Đặt thànhNULLtạm thời. - Với mỗi dòng trong
users, chèn một dòng vàoprofilessao chép các cột liên quan đến hồ sơ. - Trỏ
profile_idcủa dòng tương ứng trongusersđến dòng vừa chèn ở bước 3. - Không làm cho
users.profile_idthành non-NULL. Ứng dụng chưa biết sự tồn tại của nó nên sẽ bị lỗi.
Chúng ta cần thay thế các tham chiếu đến users.first_name bằng profiles.first_name, v.v. Nếu chỉ tách vài cột với ít tham chiếu, khuyến nghị làm thủ công. Nhưng ngay khi nghĩ "Trời ơi, đây là công việc tồi tệ nhất!" thì nên tìm giải pháp khác.
Đừng bỏ qua vấn đề. Một phần code mà mọi người tránh né sẽ suy giảm thêm và chịu sự bỏ bê lớn hơn. Cách dễ nhất để phá vỡ vòng luẩn quẩn là bắt đầu từ nhỏ.
Đọc tiếp nếu bạn tò mò khách hàng của tôi giải quyết như thế nào.
Sửa code từng dòng một
Cách tiếp cận tăng tiến nhất là sửa một tham chiếu đến cột cũ tại một thời điểm. Hãy tập trung di chuyển first_name từ users sang profiles.
Đầu tiên, tạo Profile với:
rails generate model Profile first_name:stringSau đó thêm tham chiếu từ users đến profiles và sao chép users.first_name sang profiles:
class ExtractUsersFirstNameToProfiles < ActiveRecord::Migration # Định nghĩa lại model để phá phụ thuộc vào code production. Cần # các model thuần túy không có callback, v.v. Việc xóa model sau này # có thể làm hỏng migration. class User < ActiveRecord::Base; end class Profile < ActiveRecord::Base; end def up add_reference :users, :profile, index: true, unique: true, foreign_key: true User.find_each do |user| profile = Profile.create!(first_name: user.first_name) user.update!(profile_id: profile.id) end change_column_null :users, :profile_id, false end def down remove_reference :users, :profile end endVì nó buộc mỗi user phải có đúng một profile, tham chiếu từ users đến profiles ưu tiên hơn tham chiếu ngược lại.
Với cấu trúc DB sẵn sàng, chúng ta có thể ủy thác first_name từ User sang Profile. Khách hàng có một số yêu cầu:
- Các accessor nên sử dụng
Profileliên kết. Chúng cũng nên ghi log nơi accessor cũ được gọi từ. - Lưu
Usernên tự động lưuProfileđể tránh làm hỏng code dùng accessor cũ. User#first_name_changed?và các phương thứcActiveModel::Dirtykhác vẫn nên hoạt động.
Điều này có nghĩa User nên trông như sau:
class User < ActiveRecord::Base # Cần autosave vì code client có thể không biết # Profile#first_name và vẫn tham chiếu User#first_name. belongs_to :profile, autosave: true def first_name log_backtrace(:first_name) profile.first_name end def first_name=(new_first_name) log_backtrace(:first_name) # Gọi super để User#first_name_changed? và các phương thức tương tự # vẫn hoạt động như mong đợi. super profile.first_name = new_first_name end private def log_backtrace(name) filtered_backtrace = caller.select do |item| item.start_with?(Rails.root.to_s) end Rails.logger.warn(<<-END) Tham chiếu đến thuộc tính lỗi thời #{name} tại: #{filtered_backtrace.join("\n")} END end endSau các thay đổi này, ứng dụng hoạt động như cũ nhưng có thể chậm hơn một chút do tham chiếu thêm đến Profile (nếu hiệu suất thành vấn đề, dùng công cụ như AppSignal). Code ghi log tất cả tham chiếu đến thuộc tính cũ, kể cả những cái không grep được (ví dụ user[attr] = ... hoặc user.send("#{attr}=", ...)) nên chúng ta có thể định vị tất cả ngay cả khi grep không giúp được.
Với cơ sở hạ tầng này, chúng ta có thể cam kết sửa một tham chiếu đến users.first_name theo lịch trình định kỳ, ví dụ mỗi sáng (để bắt đầu ngày với một quick win) hoặc trưa (để làm việc nhẹ sau buổi sáng tập trung). Sự cam kết này thiết yếu vì mục tiêu là giảm rào cản tâm lý khi sửa vấn đề. Để code trên mà không hành động sẽ làm nghèo nàn ứng dụng thêm.
Sau khi xóa hết tham chiếu cũ (và xác nhận bằng grep và log) chúng ta cuối cùng có thể xóa users.first_name:
class RemoveUsersFirstName < ActiveRecord::Migration def change remove_column :users, :first_name, :string end endChúng ta cũng nên xóa code thêm vào User vì nó không còn cần thiết.
Hạn chế
Phương pháp này có thể áp dụng cho trường hợp của bạn nhưng hãy nhớ một số hạn chế:
- Không xử lý các truy vấn hàng loạt như
User.update_all. - Không xử lý truy vấn SQL thô.
- Có thể làm hỏng monkey-patches (nhớ rằng dependency có thể giới thiệu chúng).
UservàProfilecó thể out-of-sync, nếuprofiles.first_nameđược cập nhật nhưngusers.first_namekhông.
Bạn có thể khắc phục một số hạn chế. Ví dụ, giữ model in-sync bằng service object hoặc callback trên Profile. Hoặc nếu dùng PostgreSQL, có thể cân nhắc materialized view trong giai đoạn trung gian.
Kết thúc!
Bài học quan trọng nhất: đừng tránh code có mùi hôi mà hãy đối mặt trực tiếp. Nếu nhiệm vụ quá lớn, hãy làm lặp lại theo lịch trình định kỳ. Bài viết trình bày một phương pháp cân nhắc khi tách bảng khó khăn. Nếu không áp dụng được thì tìm cách khác. Nếu không biết làm sao thì liên hệ tôi. Sẽ cố gắng giúp đỡ. Đừng để code của bạn thối rữa.