Active Recordのサポート拡張
Active Recordでは、非同期クエリのためのメソッド、属性値の正規化、複合主キーなどが新たにサポートされました。
非同期クエリのサポート拡張
Active Recordには、load_asyncという非同期実行を指示するメソッドがありました。countなどのメソッドにチェインして呼び出すことで非同期実行するものですが、Rails 7.1では非同期メソッドが大きく拡張され、メソッド単独での非同期実行が可能になりました。以下のメソッドが利用できます。
async_count、async_sum、async_minimum、async_maximum、async_average、async_pluck、async_pick、async_ids、async_find_by_sql、async_count_by_sql
例えばasync_countメソッドは、一致するレコード数を返すcountメソッドの非同期版です。例えばname属性とbirth属性を持つUserモデルに対して、以下のように使用します。
% rails c
> User.create!(name: 'Nao', birth: 1980)
> User.create!(name: 'Shino', birth: 1985)
# 同期版
> count = User.where('birth >= 1980').count
=> 2
# 非同期版
> promise = User.where('birth >= 1980').async_count
=> #<ActiveRecord::Promise status=complete>
> promise.value
=> 2
非同期版では、async_countメソッドの戻り値はActiveRecord::Promiseとなり、処理終了後にPromiseオブジェクトを通じて結果を受け取ります。これらのメソッドを意識して使うことで、単純なクエリでもそれなりに時間のかかる処理を並列化できるので、パフォーマンスの向上が期待できます。
normalizesによる属性値の正規化
Rails 7.1では、ActiveRecord::Base.normalizesが使えるようになり、属性値に対して正規化を宣言できるようになりました。正規化とは、値を望ましい形に整えることで、ユーザー入力のサニタイズや書式の統一で役立つ機能です(リレーショナルモデルにおけるいわゆる正規化とは意味が異なります)。正規化を属性値に指定することで、値はデータベーステーブルに正規化された形で永続化され、正規化されていないパラメーターでの検索も可能になります。
属性への正規化は、以下のリストのように指定します。この場合、email属性は先頭と末尾の空白文字(\t\n\f\v)は除去され、英小文字に変換されます。normalizesを指定された属性はクエリ文字列でも正規化されるので、事前の整形が不要になり便利です。
class User < ActiveRecord::Base
normalizes :email, with: -> email { email.strip.downcase }
end
以下は、コンソールでの操作例です。空白文字を含んだり、英大文字で構成されたメールアドレスも、永続化された値もクエリの値も期待する形に正規化されることを確認できます。
% rails c > user = User.create!(name: 'Nao', email: "\tNAO@NAOSAN.JP\n") > user.email => "nao@naosan.jp" > User.where(email: "\tNAO@NAOSAN.JP\n").count => 1
複合主キーのサポート
Rails 7.1では、従来の1カラムによる主キーに加え、複数のカラムからレコードを一意に指定する複合主キー(composite primary key)が使えるようになりました。例えば、2つの外部キー(costomer_idとproduct_id)持つ注文テーブル(orders)が、これらのキーで各レコードを識別するとします。このとき、customer_idとproduct_idが複合主キーとなります。
複合主キーをテーブルに使用するには、モデル作成後のマイグレーションファイル内で、create_tableメソッドのprimary_keyオプションに主キーとするフィールドの配列を指定します。マイグレーション時には、primary_keyが指定されていることが分かります。
class CreateOrders < ActiveRecord::Migration[7.1]
def change
create_table :orders, primary_key: [:customer_id, :product_id] do |t|
t.integer :customer_id
t.integer :product_id
t.integer :quantity
end
end
end
% rails db:migrate
== 20231113032225 CreateOrders: migrating =====================================
-- create_table(:orders, {:primary_key=>[:customer_id, :product_id]})
-> 0.0029s
== 20231113032225 CreateOrders: migrated (0.0029s) ============================
findメソッドによる検索では、同じく配列で複合主キーを指定します。配列の配列とすることで、いずれかの複合主キーに一致するものという条件で検索できます。
% rails c > Order.create!(customer_id: 1, product_id: 2, quantity: 10) > Order.create!(customer_id: 2, product_id: 4, quantity: 20) > order = Order.find([2, 4]) > order.quantity => 20
複合主キーは一見便利そうですが、テーブルの頻繁な更新ではインデックスの構築などのオーバーヘッドが大きくなります。参照が主体であるテーブルに限定するか、そもそも複合主キーが必要なのか(テーブルの正規化で解決できないか)、見定めて使う必要があるでしょう。
ワンタイムトークン生成と検証メソッドの追加
Rails 7.1では、ワンタイムトークン生成とその検証のためのActiveRecord::TokenForクラスのメソッドが使用できるようになりました。これらのメソッドは、ユーザーの情報を保持するようなクラスがパスワードを扱う場合に、認証のために一時的に利用するトークン生成や検証の機能をサポートします。主にパスワードリセットなどで便利に使用できます。以降は、has_secure_passwordメソッドによるパスワード管理機能を実装したモデルにおける、トークン生成と検証の例です。
has_secure_passwordメソッドは、BCrypt(Blowfish暗号に基づいて作られたパスワードハッシュ関数)に基づいた認証の仕組みをモデルに導入します。なお、has_secure_passwordメソッドの利用にはbcrypt GEMが必要で、テーブルにはハッシュ化されたパスワードの永続化のためのpassword_digestフィールドが必要となります。
bcrypt GEMの利用には、Gemfileに以下の行を追加し、bundleコマンドを実行します。
gem "bcrypt"
テーブルは、例えばUserモデルをpassword_digestフィールドを含めて以下のように作成します。
% rails g model user name:text password_digest:text
has_secure_passwordメソッドによってモデルに追加される属性、メソッド、検証機能は以下の通りです。
- 属性:password(パスワード)、password_confirmation(検証用パスワード)、password_salt(パスワードから計算されるSALT値《ハッシュ化の際に用いられるランダムな文字列》)
- メソッド:authenticate(パスワード検証用。引数とpasswordが一致すればtrueを返す)
- 検証機能:モデル生成時にパスワード(password)と確認パスワード(password_confirmation)が必要で、かつ一致する必要がある(一致しないとnil)
以下のリストは、has_secure_passwordメソッドによるパスワード管理機能を実装したクラスで、パスワードリセットのためのトークンを生成する例です。
class User < ActiveRecord::Base
has_secure_password
generates_token_for :password_reset, expires_in: 10.minutes do
password_salt&.last(10)
end
end
ここでは、generates_token_forメソッドを以下の指定で呼び出しています。
- 第1引数(purpose)にpassword_resetを指定し目的をパスワードリセットとする
- expires_inオプションで10分後に破棄する(既定では有効期限なし)
generates_token_forメソッドはあくまで目的ごとのトークンの作成方法を定義するのみで、ここではトークンそのものを作成しているわけではありません。具体的には、SALT値であるpassword_saltの末尾10文字を抜き出した値をトークンに埋め込むことを指定しているだけです。
実際にトークンを生成するのは、generate_token_forメソッド(generateは単数形)です。このときトークンは、generates_token_forメソッドの指定でSALT値の一部、そしてモデルのid属性、有効期限、目的を保持するJSONデータをBase64エンコーディングしたものに、ActiveSupport::MessageVerifierによる署名を付加して生成されます。署名の付加により改ざんが防止されるので、外部へ公開することが可能になるわけです。なお、トークンはパスワードのダイジェストと異なり永続化されません。モデルのクラス変数として保持されるだけの、一時的なデータです。
以下に、具体的な例を示します。
% rails c # パスワードを指定してユーザーを作成する > User.create!(name: 'Nao', password: 'password', password_confirmation: 'password') # 最初に見つかるユーザーを取得、パスワードリセット目的のトークンを生成する > user = User.first > token = user.generate_token_for(:password_reset) => "eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjFyVUdhUmQxay4iXSwiZXhwIjoiMjAyMy0xOToxOC4zMT..." # パスワードリセット目的の指定トークンを持つユーザーを検索する(userが返る) > User.find_by_token_for(:password_reset, token) # ユーザーのパスワードを更新して、再度トークンを指定してユーザーを検索する # (トークンは無効になっているので検索は失敗してnilが返る) > user.update!(password: "new_password") > User.find_by_token_for(:password_reset, token) => nil
既述の通り、generate_token_forメソッドで実際にトークンが生成されます。このトークンを用いてfind_by_token_forメソッドでレコードを検索できます。パスワードを変更するとトークンは無効になるので、そのトークンを持つレコードは存在しないとしてfind_by_token_forメソッドからはnilが返ります。
ユーザーがパスワードリセットしたいときなどに、署名や有効期限のあるトークンを使って認証できるので、安全で利便性の高いパスワードの運用が可能になる機能と言えます。
まとめ
今回は、Ruby on Rails 7.1の新機能のうち、DockerサポートやActive Recordにおける機能強化について紹介しました。
次回は、Active Jobの新機能、新しいデータベースアダプターとJavaScriptランタイム環境をはじめとする主要な機能強化について紹介します。
