Rails 8.2 预览:用 has_json 实现类型安全的 JSON 属性
Note: 这个特性将在 Rails 8.2 中登场。它已经合并到 main,但尚未发布。你可以在 GitHub 上查看源码,或者把你的 Gemfile 指向 main 分支来试用它。Rails 8.2 另一个值得一看的新增功能是通过 Rails.app.revision 实现的内置部署追踪。
每个 Rails 开发者都写过这样的代码:
class Account < ApplicationRecord
def settings
super || {}
end
def max_invites
settings["max_invites"]&.to_i
end
def max_invites=(value)
settings["max_invites"] = value.to_i
save_settings!
end
def restrict_admins_only?
ActiveModel::Type::Boolean.new.cast(settings["restrict_admins_only"])
end
def restrict_admins_only=(value)
settings["restrict_admins_only"] = ActiveModel::Type::Boolean.new.cast(value)
save_settings!
end
private
def save_settings!
self.settings = settings # trigger the dirty tracking
end
end
而且每加一个设置项,它就变得更糟。
Rails 8.2 引入了 has_json,把这一切简化成:
class Account < ApplicationRecord
has_json :settings, restrict_admins_only: false, max_invites: 10
end
就这样。类型转换、默认值和访问器,全都处理好了。
问题所在:表单参数永远是字符串
当用户提交表单时,一切都以字符串形式到达:
params[:account][:settings]
# => { "max_invites" => "50", "restrict_admins_only" => "true" }
如果你直接把它存进 JSON 列,数据库里就会是字符串值:
{"max_invites": "50", "restrict_admins_only": "true"}
于是你的代码里到处都是 .to_i 和 boolean 转换。比较会悄无声息地出错:
account.settings["max_invites"] > 25 # => false (string comparison!)
account.settings["restrict_admins_only"] == true # => false (it's "true", not true)
has_json 如何解决这个问题
has_json 创建一个感知 schema 的访问器,自动进行类型转换:
class Account < ApplicationRecord
has_json :settings,
restrict_admins_only: true, # boolean, defaults to true
max_invites: 10, # integer, defaults to 10
welcome_message: "Hello!" # string, defaults to "Hello!"
end
schema 由默认值定义。Rails 会从每个值推断类型:
true/false表示 boolean- Integer 表示 integer
- String 表示 string
现在表单参数直接就能用:
account = Account.new
account.settings = {
"max_invites" => "50",
"restrict_admins_only" => "false"
}
account.settings.max_invites # => 50 (integer!)
account.settings.restrict_admins_only? # => false (boolean!)
实例:用户偏好设置面板
我们来构建一个偏好设置面板,让用户配置自己的通知设置。
Migration
class AddPreferencesToUsers < ActiveRecord::Migration[8.2]
def change
add_column :users, :preferences, :jsonb, default: {}, null: false
end
end
模型
class User < ApplicationRecord
has_json :preferences,
email_notifications: true,
sms_notifications: false,
digest_frequency: 7, # days between digest emails
timezone: "UTC",
items_per_page: 25
end
表单
<%# app/views/users/preferences.html.erb %>
<%= form_with model: current_user, url: preferences_path, method: :patch do |f| %>
<fieldset>
<legend>Notifications</legend>
<%= f.label :email_notifications do %>
<%= f.check_box :email_notifications,
checked: current_user.preferences.email_notifications? %>
Email notifications
<% end %>
<%= f.label :sms_notifications do %>
<%= f.check_box :sms_notifications,
checked: current_user.preferences.sms_notifications? %>
SMS notifications
<% end %>
<%= f.label :digest_frequency %>
<%= f.select :digest_frequency,
[["Daily", 1], ["Weekly", 7], ["Monthly", 30]],
selected: current_user.preferences.digest_frequency %>
</fieldset>
<fieldset>
<legend>Display</legend>
<%= f.label :items_per_page %>
<%= f.select :items_per_page,
[10, 25, 50, 100],
selected: current_user.preferences.items_per_page %>
<%= f.label :timezone %>
<%= f.time_zone_select :timezone,
nil,
default: current_user.preferences.timezone %>
</fieldset>
<%= f.submit "Save Preferences" %>
<% end %>
控制器
class PreferencesController < ApplicationController
def update
# All the type coercion happens automatically
current_user.preferences = preferences_params
current_user.save!
redirect_to preferences_path, notice: "Preferences saved!"
end
private
def preferences_params
params.require(:user).permit(
:email_notifications,
:sms_notifications,
:digest_frequency,
:items_per_page,
:timezone
)
end
end
无需手动类型转换。表单发送字符串,has_json 会根据 schema 把它们转换成正确的类型。
用 has_delegated_json 实现顶层访问
如果你想跳过 .preferences 访问器,直接在模型上访问这些设置,就用 has_delegated_json:
class User < ApplicationRecord
has_delegated_json :preferences,
email_notifications: true,
sms_notifications: false,
digest_frequency: 7
end
user = User.new
user.email_notifications? # => true (delegated to preferences.email_notifications?)
user.digest_frequency = 30
user.digest_frequency # => 30
当你的 JSON 列更像是模型的扩展而非一个嵌套对象时,这很有用。
声明类型但不带默认值
有时你想指定一个类型但不带默认值。用符号:
class Account < ApplicationRecord
has_json :settings,
max_invites: :integer, # integer, defaults to nil
beta_enabled: :boolean, # boolean, defaults to nil
custom_domain: :string # string, defaults to nil
end
account = Account.new
account.settings.max_invites # => nil
account.settings.beta_enabled? # => false (nil is falsey)
这对于不应该有默认值的可选设置很方便。
实例:多租户的功能开关
这是一个实用场景:把按租户区分的功能开关存进 JSON。
class Tenant < ApplicationRecord
has_json :feature_flags,
new_dashboard: false,
api_v2: false,
ai_assistant: false,
max_users: 5,
max_storage_gb: 10
end
在你的应用中:
class ApplicationController < ActionController::Base
before_action :check_feature_access
private
def feature_enabled?(flag)
current_tenant.feature_flags.public_send("#{flag}?")
end
helper_method :feature_enabled?
def check_feature_access
# Automatically enforce limits
if current_tenant.users.count >= current_tenant.feature_flags.max_users
redirect_to upgrade_path if action_name == "create" && controller_name == "users"
end
end
end
在你的视图中:
<% if feature_enabled?(:new_dashboard) %>
<%= render "dashboards/new_layout" %>
<% else %>
<%= render "dashboards/legacy_layout" %>
<% end %>
<% if feature_enabled?(:ai_assistant) %>
<%= render "shared/ai_chat_widget" %>
<% end %>
切换开关的管理后台:
class Admin::TenantsController < AdminController
def update_flags
tenant = Tenant.find(params[:id])
tenant.feature_flags = feature_flag_params
tenant.save!
redirect_to admin_tenant_path(tenant), notice: "Flags updated"
end
private
def feature_flag_params
params.require(:tenant).permit(
:new_dashboard, :api_v2, :ai_assistant, :max_users, :max_storage_gb
)
end
end
表单复选框发送 “1”/“0”,数字输入框发送字符串。has_json 把这些全都处理了。
它的底层原理
它的实现很优雅。当你调用 has_json 时:
- 它定义一个 getter,把 JSON 哈希包装进一个
DataAccessor DataAccessor用method_missing为每个键提供带类型的访问- 类型转换使用
ActiveModel::Type.lookup,这与 Rails 用于数据库列的是同一套系统 - 默认值在访问器被实例化时通过
reverse_merge!应用 - 一个
before_save回调确保默认值在持久化之前被写入
实际的转换发生在这里:
def lookup_schema_type_for(key)
type_or_default_value = @schema[key.to_sym]
case type_or_default_value
when :boolean, :integer, :string
ActiveModel::Type.lookup type_or_default_value
when TrueClass, FalseClass
ActiveModel::Type.lookup :boolean
when Integer
ActiveModel::Type.lookup :integer
when String
ActiveModel::Type.lookup :string
end
end
这意味着你得到的转换行为和常规的 ActiveRecord 属性完全一致。"true"、"1"、"yes" 都会变成 true。"42" 会变成 42。
需要了解的限制
在你全面押注之前,先了解这些约束:
-
只有三种类型。 boolean、integer、string。没有 date、array 或嵌套对象。
-
不支持嵌套。 你不能在一个 schema 里再定义一个 schema。每个键都必须是基本类型。
-
没有校验。 没有内置方法来校验值(比如
max_invites必须为正)。请自行添加校验。 -
method_missing 开销。 每次属性访问都要经过
method_missing。对于热路径,考虑做缓存。
对于复杂的 JSON 结构,你仍然会想用 store_accessor 或一个专门的模型。
从手动访问器迁移
如果你有带手动访问器的现存 JSON 列,迁移很直接:
Before:
class Account < ApplicationRecord
def settings
super || {}
end
def max_invites
settings["max_invites"]&.to_i || 10
end
def max_invites=(value)
self.settings = settings.merge("max_invites" => value.to_i)
end
# ... 20 more methods like this
end
After:
class Account < ApplicationRecord
has_json :settings, max_invites: 10 # ... add all your other fields
end
JSON 结构保持不变。你现有的数据无需迁移即可工作。
今天就试试
在等待 Rails 8.2 的同时,你可以通过指向 main 来试用 has_json:
# Gemfile
gem "rails", github: "rails/rails", branch: "main"
或者只把这个模块抽出来。它大约 100 行,没有任何依赖。
总结
如果你厌倦了为每个 JSON 字段写 .to_i 和 ActiveModel::Type::Boolean.new.cast,那 has_json 就是为你准备的。它处理用户偏好、功能开关和配置,让你不必一遍又一遍地写同样的转换代码。
如果你正在升级 Rails 版本来用上这类特性,要注意 Rails 7.2 中的连接池性能变化,它可能会影响你的应用。
完整的实现与讨论请见 PR #56258。