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 时:

  1. 它定义一个 getter,把 JSON 哈希包装进一个 DataAccessor
  2. DataAccessormethod_missing 为每个键提供带类型的访问
  3. 类型转换使用 ActiveModel::Type.lookup,这与 Rails 用于数据库列的是同一套系统
  4. 默认值在访问器被实例化时通过 reverse_merge! 应用
  5. 一个 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

需要了解的限制

在你全面押注之前,先了解这些约束:

  1. 只有三种类型。 boolean、integer、string。没有 date、array 或嵌套对象。

  2. 不支持嵌套。 你不能在一个 schema 里再定义一个 schema。每个键都必须是基本类型。

  3. 没有校验。 没有内置方法来校验值(比如 max_invites 必须为正)。请自行添加校验。

  4. 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_iActiveModel::Type::Boolean.new.cast,那 has_json 就是为你准备的。它处理用户偏好、功能开关和配置,让你不必一遍又一遍地写同样的转换代码。

如果你正在升级 Rails 版本来用上这类特性,要注意 Rails 7.2 中的连接池性能变化,它可能会影响你的应用。

完整的实现与讨论请见 PR #56258