Rails là một framework lớn với nhiều công cụ tích hợp sẵn tiện lợi cho các tình huống cụ thể. Trong loạt bài này, chúng ta sẽ xem xét một số công cụ ít người biết đến ẩn giấu trong codebase lớn của Rails.
Trong bài này, chúng ta tập trung vào phương thức store và store_accessor của ActiveRecord. Cả hai phương thức này nhằm vào trường hợp lưu trữ dữ liệu có cấu trúc trong một cột database, như JSON hoặc YAML. Trong khi store_accessor cung cấp cách tiện lợi để lấy giá trị từ dữ liệu này mà không làm rối model bằng các phương thức getter, thì store đi xa hơn bằng cách serialize/deserialize dữ liệu minh bạch sang định dạng chúng ta chọn. Để hiểu nơi này có thể hữu ích, chúng ta cũng sẽ xem các tùy chọn lưu trữ JSON trong database quan hệ và một số lý do bạn muốn làm vậy.
JSON trong Database
Tôi nên làm rõ rằng khi nói 'database' trong bài này, tôi đang nói về database quan hệ, đặc biệt là PostgreSQL và MySQL, vì chúng được sử dụng rộng rãi nhất trong cộng đồng Rails.
Có thể hỏi tại sao bạn muốn lưu JSON trong database quan hệ. Thực tế, cách tận dụng lợi ích của database quan hệ là chia nhỏ dữ liệu để các mối quan hệ giữa chúng có thể được thực thi bởi database (ví dụ: khóa ngoại), và dữ liệu có thể được lập chỉ mục để cải thiện hiệu suất truy vấn.
Một nhược điểm của mô hình database quan hệ là cấu trúc dữ liệu phải được biết trước và giống hệt nhau cho mỗi hàng trong một bảng. Nếu ứng dụng của bạn xây dựng xung quanh dữ liệu không đáp ứng các yêu cầu này, bạn có thể muốn điều tra các database NoSQL. Đối với hầu hết các ứng dụng web, tuy nhiên, chúng ta muốn gắn bó với ~~ác quỷ~~ database quan hệ chúng ta biết cho hầu hết dữ liệu và chỉ "rắc" thêm các cấu trúc dữ liệu động một cách thận trọng. Trong các trường hợp này, một cột JSON có thể rất hợp lý.
JSON vs. JSONB
PostgreSQL có hai loại cột JSON: json và jsonb. Khác biệt chính là jsonb được parse tại thời điểm ghi, nghĩa là dữ liệu được lưu trữ ở định dạng database có thể truy vấn nhanh hơn. Điều kiện là vì JSON đã được parse, khi xuất ra dưới dạng text, nó có thể không còn khớp chính xác với những gì người dùng nhập. Ví dụ: các key trùng lặp có thể bị loại bỏ, hoặc thứ tự key có thể không khớp với bản gốc.
Tài liệu PostgreSQL khẳng định rằng trong hầu hết các trường hợp, jsonb là thứ bạn muốn trừ khi có lý do cụ thể ngược lại.
Cột json của MySQL hoạt động tương tự như jsonb trong PostgreSQL. Để hỗ trợ đầu ra 'chính xác như người dùng nhập', bạn có thể phải dùng cột varchar hoặc tương tự.
JSON vs. Text
Ngoài việc cho phép dữ liệu được pre-parse, sử dụng cột JSON thay vì lưu cùng dữ liệu trong trường text cho phép truy vấn sử dụng chính dữ liệu đó. Ví dụ: bạn có thể truy vấn tất cả bản ghi nơi một cặp key-value cụ thể tồn tại trong cột. Lưu ý rằng chính Rails không hỗ trợ nhiều (nếu có) truy vấn JSON đặc thù, vì chúng phụ thuộc vào database. Do đó, nếu bạn muốn tận dụng các tính năng này, bạn sẽ phải dùng truy vấn SQL.
Cột JSON trong Rails
Rails hỗ trợ tạo cột json (và jsonb trên PostgreSQL) trong migration:
class CreateItems < ActiveRecord::Migration[7.0]
def change
create_table :items do |t|
t.jsonb :user_attributes
...
end
end
end
Khi đọc cột này, kết quả trả về là một Hash:
> Item.first.user_attributes
Item Load (0.6ms) SELECT "items".* FROM "items" ORDER BY "items"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> {"color"=>"text-red-400"}
> Item.first.update!(user_attributes: {color: "text-blue-400"})
> Item.first.user_attributes.dig(:color)
=> "text-blue-400"
Bây giờ chúng ta có thuộc tính Hash, bạn có thể muốn thêm một số phương thức helper vào model để đọc/ghi giá trị:
class Item < ApplicationRecord
def color=(value)
self.user_attributes["color"] = value
end
def color
user_attributes.dig("color")
end
end
Các phương thức như vậy hoạt động hoàn hảo nhưng có thể nhanh chóng trở nên khó kiểm soát nếu bạn có nhiều key JSON cần xử lý; may mắn là Rails đã lo liệu cho chúng ta.
ActiveRecord's store và store_accessor
Có hai khía cạnh của việc lưu JSON trong database: serialization và truy cập. Nếu bạn dùng cột loại json trong database, bạn không cần lo về khía cạnh serialization. Rails và database adapter sẽ lo cho bạn (bạn có thể bỏ qua thẳng đến store_accessor). Nếu bạn lưu dữ liệu trong cột text, thì phương thức store của ActiveRecord là dành cho bạn, nó đảm bảo dữ liệu bạn ghi vào cột được serialize sang định dạng bạn chọn.
ActiveRecord's store
ActiveRecord có phương thức store để tự động serialize dữ liệu chúng ta đọc hoặc ghi vào cột:
class Item < ApplicationRecord
store :user_attributes, accessors: [:color], coder: JSON
end
Ở đây, :user_attributes là cột chúng ta muốn dùng, accessors là danh sách key chúng ta muốn truy cập (chỉ color trong trường hợp này), và cuối cùng, chúng ta chỉ định cách mã hóa dữ liệu. Chúng ta dùng JSON, nhưng bạn có thể dùng bất cứ thứ gì, bao gồm YAML hoặc encoding tùy chỉnh. Phương thức này chỉ xử lý serialization (với coder bạn chọn) và gọi store_accessor bên dưới.
ActiveRecord's store_accessor
Chúng ta tạo các phương thức get/set trong model bằng cách dùng store_accessor:
class Item < ApplicationRecord
store_accessor :user_attributes, :color
store_accessor :user_attributes, :name, prefix: true
store_accessor :user_attributes, :location, prefix: 'primary'
end
Ở đây nữa, user_attributes là cột database chúng ta muốn dùng, theo sau là key chúng ta muốn dùng trong dữ liệu JSON, và cuối cùng là tùy chọn dùng prefix (hoặc suffix). Lưu ý store_accessor không hỗ trợ dữ liệu lồng nhau, chỉ các cặp key-value ở cấp cao nhất. Tùy chọn prefix và suffix nhận boolean, string hoặc symbol. Nếu boolean true được truyền, tên cột sẽ được dùng làm prefix/suffix.
=> item = Item.create!(color: 'red', user_attributes_name: 'Jonathan', primary_location: 'New Zealand')
>#<Item:0x000055d63f4f0360 id: 4, user_attributes: {"color"=>"red", "name"=>"Jonathan", "location"=>"New Zealand"}>
=> item.color
>"red"
=> item.user_attributes_name
>"Jonathan"
=> item.name
>NoMethodError: undefined method `name'...
=> item.primary_location
>"New Zealand"
Sử dụng thực tế
Tôi chỉ đôi khi cần lệch khỏi schema database quan hệ thông thường biết trước. Những lần đó, nó làm cấu trúc database vừa sạch hơn vừa đơn giản hơn so với việc không có các tùy chọn này.
Một ví dụ tôi gặp là hỗ trợ nhiều API nơi người dùng kết nối tài khoản của họ. Điều này trở nên khó khăn khi các API không dùng cùng схéma xác thực. Một số dùng username+mật khẩu, người dùng API key, người khác có API key, secret và merchant ID. Một cách là chỉ thêm cột vào bảng, nhiều cột sẽ là null cho hầu hết provider. Tuy nhiên, dùng json, chúng ta chỉ lưu các giá trị mà một API cụ thể cần.
Một dự án phụ tôi đang làm cũng dùng lưu trữ JSON để cho phép người dùng đặt các thuộc tính tùy ý trên item, bao gồm thuộc tính do người dùng định nghĩa. Với bản chất biến động và không thể dự đoán của dữ liệu này, lưu trữ JSON (với store_accessor cho các thuộc tính đã biết) là lựa chọn tự nhiên.
Tóm tắt
Dữ liệu JSON (và các helper của ActiveRecord quanh nó) có thể rất hữu ích khi dữ liệu và cấu trúc dữ liệu thay đổi hoặc không thể biết trước. Tất nhiên, loại lưu trữ này, như hầu hết mọi thứ, là sự đánh đổi. Mặc dù bạn获得 nhiều sự linh hoạt trong cấu trúc dữ liệu cho các bản ghi cụ thể, bạn hy sinh một phần tính toàn vẹn dữ liệu mà các ràng buộc database có thể cung cấp. Bạn cũng giảm khả năng truy vấn trên các bản ghi với các truy vấn ActiveRecord thông thường, joins, v.v.
Dưới đây là một vài quy tắc ngón tay cái, nếu bạn:
- Biết rằng các key JSON sẽ giống nhau cho tất cả các hàng, hoặc
- Đang lưu ID (khóa chính) của bất kỳ bảng database nào khác, hoặc
- Đang lưu giá trị dùng để tra cứu bản ghi từ một bảng trong JSON
Thì bạn có thể tốt hơn bằng cách tạo bảng mới có thể tận dụng database để thực thi tính toàn vẹn dữ liệu cho bạn. Ngược lại, nếu bạn lưu dữ liệu đặc thù cho từng hàng không liên quan trực tiếp đến các bảng khác, thì JSON có thể giúp bạn đơn giản hóa cấu trúc database.