RailsのGlobal IDとは何者なのかについて調べる🔍
はじめに
ことの発端
業務でHogeJob.perform_later(user)
のようなコードを書いていると、Jobの実行時には引数にオブジェクトを渡すのではなく、HogeJob.perform_later(use.id)
という風に「オブジェクトのidを渡す使い方がベストプラクティスではないか?」という指摘をもらいました。
ActiveJob
のアダプタとしてSidekiqを使っていたので、sidekiqのBestPracticesを調べてみると下記のようのことが書かれていました。
Sidekiq persists the arguments to
perform_async
to Redis as JSON. Sometimes people are tempted to make the following kind of mistake:# MISTAKE quote = Quote.find(quote_id) SomeJob.perform_async(quote)
Complex Ruby objects do not convert to JSON, by default it will convert with
to_s
and look like#<Quote:0x0000000006e57288>
. Even if they did serialize correctly, what happens if your queue backs up and that quote object changes in the meantime? Don't save state to Sidekiq, save simple identifiers. Look up the objects once you actually need them in your perform method.
まとめると、適切な状態のオブジェクトを扱うために
①performメソッドにはオブジェクトではなく識別子を渡すこと
②Jobの実行時にオブジェクトを取得すること
上記の2点をふまえて実装する必要があるとされていました
レビュー時にいただいた指摘について結論から言うと、RailsのActiveJob
で利用されているGlobal IDがその辺をいい感じに処理してくれているので、perform_later
の引数にオブジェクトを渡してもオブジェクトのidを渡してもどちらでも大丈夫ということが分かり、適切な実装が担保できている事が分かりました。
ただ、「Railsで利用されているGlobal IDって何なんだ?」という疑問が出てきたので、今回はそれについて調べたいと思います。
今回の目標
Railsで利用されているGlobal IDに絞って、それが何かを理解することを目標にします。
具体的には、
- Global IDとは何か?
- Global IDはどんな処理をしているのか?
を調べていきます💪
ActiveJob
内でGlobal IDがどのように呼ばれているのか・どのように実装されているのかについては、今回の記事の対象外として次の機会で調べることにします。
Railsで利用されているGlobal IDとは何か?
globalid gemのREADMEによると
グローバルIDは、モデルのインスタンスを一意に識別するアプリ全体のURIです:
gid://YourApp/Some::Model/id
これは、異なるクラスのオブジェクトを参照するために単一の識別子が必要な場合に役立ちます。
一つの例は、ジョブスケジューリングです。オブジェクト自体をシリアライズするのではなく、モデルオブジェクトを参照する必要があります。グローバルIDを渡せば、ジョブの実行時にモデルの場所を特定することができます。ジョブスケジューラはモデルの命名やIDの詳細を知る必要はなく、モデルを参照するグローバル識別子を持つだけでよいのです。
上記はREADMEの一部をDeeplで日本語訳したものです。Global IDとはアプリ内でオブジェクトを一意に識別するURIである事が分かりました。
Railsガイドによると
Active JobではGlobalIDがパラメータとしてサポートされています。 GlobalIDを使えば、動作中のActive Recordオブジェクトをジョブに渡す際にクラスとidを指定する必要がなくなります。クラスとidを指定する従来の方法では、後で明示的にデシリアライズ(deserialize)する必要がありました。
従来のジョブが以下のようなものだったとします。
class TrashableCleanupJob < ApplicationJob def perform(trashable_class, trashable_id, depth) trashable = trashable_class.constantize.find(trashable_id) trashable.cleanup(depth) end end
現在は、上を以下のように簡潔に書けます。
class TrashableCleanupJob < ApplicationJob def perform(trashable, depth) trashable.cleanup(depth) end end
このコードは、
GlobalID::Identification
をミックスインするすべてのクラスで動作します。このモジュールはActive Recordクラスにデフォルトでミックスインされます。
Active Job、Active RecordではGlobal Idが利用できるため、perform
メソッドの引数にオブジェクトを指定するだけで適切に処理されると理解しました。
ここまでの内容の整理
ここまで分かった内容を整理してみると、
- RailsのGlobal IDとは、モデルのオブジェクト一意に識別するためのURIのこと
- RailsのActiveJob、Active RecordではデフォルトでGlobal ID利用されているため、peformメソッドの引数にはオブジェクトそのものを渡しても適切に処理される
という事が分かり、「Global IDとは何か?」について概要の理解ができました🤔
実際に使ってみる
ここからは、実際に使いながらGlobal IDの実装を調べ、具体的にイメージしやすくしたいと思います!
今回動作確認した環境は下記の通りで行いました。
- Ruby 3.3.0
- Ruby on Rails 7.1.4
まず初めにrails new
した段階でGemfile.lock
を確認してみると、下記の感じになっていました。
長いので関係のない部分は端折りましたが、ActionText
とActiveJob
で依存関係のgemとしてGlobal IDがインストールされており、デフォルトで利用可能となっていることが分かります。
GEM
remote: https://rubygems.org/
specs:
~~ 省略 ~~
actiontext (7.1.5.1)
actionpack (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
~~ 省略 ~~
activejob (7.1.5.1)
activesupport (= 7.1.5.1)
globalid (>= 0.3.6)
~~ 省略 ~~
globalid (1.2.1)
activesupport (>= 6.1)
~~ 省略 ~~
ActiveRecord
でもGlobal IDが利用できるので、実際に使ってみてGlobal IDが何なのかを確認したいと思います。
サンプルとしてUserモデルのみを持つRailsアプリを作成し、rails console
上で動作確認をしていきます。
globalid gemREADMEのUsageに記載されているメソッドを実行してみたいと思います!
まずはActiveRecord
のオブジェクトを取得し、to_global_id
メソッドを使いUserモデルのオブジェクトをGlobal IDにしてみたいと思います。
irb(main):002> user = User.first
# 取得したオブジェクトをGlobal IDに変換
irb(main):004> gid = user.to_global_id
=> #<GlobalID:0x00000001215b91f0 @uri=#<URI::GID gid://global-id-app/User/1>>
Global IDのオブジェクトが返されました。
返されたオブジェクトを確認してみると、Global IDのオブジェクトであることが分かります。
irb(main):163> gid.class
=> GlobalID
次にuri
メソッドを使用して、URI部分のみを取得してみます。
URI部分はgid://アプリ名/モデル名/id
という構成になっており、アプリ内で一意の値を担保しているようです。
# 変換されたURI部のみを取得
irb(main):005> gid.uri
=> #<URI::GID gid://global-id-app/User/1>
最後にGlobalID::Locator.locate
メソッドを使用して、Global IDを元のオブジェクトに戻してみます。この時に、内部ではSQLのクエリが実行され、Userモデルののオブジェクトが再取得されています。
# Global IDを元のオブジェクトに戻す
irb(main):009> GlobalID::Locator.locate gid
User Load (0.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=>
#<User:0x00000001217153c8
id: 1,
name: "テスト太郎",
email: "test@example.com",
created_at: Thu, 06 Mar 2025 08:52:49.528295000 UTC +00:00,
updated_at: Thu, 06 Mar 2025 08:52:49.528295000 UTC +00:00>
ここまでで、to_global_id
メソッドでUserモデルのオブジェクトがGlobal ID化され、アプリ内で一意のURIとして保存される。そして、GlobalID::Locator.locate
メソッドでUserモデルのオブジェクトが再取得されるということが分かりました!
改めて整理と簡単にまとめ
ここまでで分かった内容を下記に整理してみると
- Global IDとは、アプリ内のモデルのオブジェクトの情報を一意に識別するURIのこと
-
to_global_id
メソッドを使うと、ActiveRecordのオブジェクトがGlobalID
クラスのオブジェクトに変換される-
GlobalID
クラスのオブジェクトの中に、ActiveRecord
のオブジェクトの情報を持ったURIが作られる - URIの構成は、
gid://アプリ名/モデル名/id
という構成で、アプリ内で一意となるように作られている
-
-
GlobalID::Locator.locate
メソッドを使うことで、GlobalID
クラスのオブジェクトの情報を元に、ActiveRecord
のオブジェクトが再取得されている
次の章で、実際にto_global_id
とGlobalID::Locator.locate
は内部でどんな処理をしているのかをソースコードを読みなが理解したいと思います!自分で理解できる範囲で読んでいくので、ご容赦ください🙏
Global IDは内部で何をしているのか?
「Global IDは何をしているのか?」について、Global IDのソースコードを読んで理解していきたいと思います
to_global_id
メソッドについて
まずは、オブジェクトをGlobal IDに変換しているto_global_id
メソッドが「どうやってGlobal IDに変換しているのか?」を、簡単にリーディングしたいと思います。
to_global_id
の中身はGlobalID#create
が実行されています。この時、引数のselfはto_global_id
のレシーバーということになります。(レシーバーがActiveRecordのオブジェクトだったら、それがselfに入る)
GlobalID#create
の中身を見てみると、
app
変数にアプリ名を格納しています。ここでは、アプリ名が渡されていればそれを格納し、渡されていなければデフォルトのアプリ名を格納して次の処理に移ります。
(ちなみに、GlobalID.app
を実行すると、アプリ名が返ってきます!)
irb(main):044> GlobalID.app
=> "global-id-app"
もしapp
に値が格納されていた場合は、:app
、:verifier
、:for
を除外した値をparams
に格納します。次に、URI::GID#create
を使ってGlobalIDクラスのオブジェクトを作成します。
もしapp
に何も格納されていなければ、ArgumentError
を発生させます
URI::GID#create
の中身を見てみると、アプリ名、オブジェクトのクラス名、オブジェクトのid、あればparamsがセットされてbuild
されている事が分かります。
これでURI部分のアプリ名/モデル名/id
が生成される事が分かりました!!!
URIの形ができたので、今回のリーディングはここまでとします。このあとは、GlobalID#create
実行結果が返されるという感じで理解しました〜🙌
以上の流れで、Global IDが生成されるまでのおおまかな流れを理解しました!!🙌
GlobalID::Locator#locate
メソッド
次は、Global IDを元のオブジェクトに戻しているGlobalID::Locator#locate
をリーディングして、どうやってGlobal IDから元のオブジェクトに戻しているのか?」を理解していこうと思います。
コードが複雑で細かく見ると収拾がつかなくなりそうなので、今回知りたい「どうやってGlobal IDから元のオブジェクトに戻しているのか?」に関連する部分をリーディングしていく事にします🙇
locate
を実行すると、始めに、引数に渡したGlobal IDをparseして有効なオブジェクトに変換します。そして、gidが有効でなければリターンさせます。
次に、locator_for
を実行しています。
最後に、もしlocater
オブジェクトの持つlocate
メソッドの引数が1つなら例外を発生させ、それ以外ならlocate
メソッドを実行します。ここで実行されるlocate
メソッドは、locator
がどのオブジェクトなのかで決まります。そのままコードを読み進めたいのですが、先にlocator_for
メソッドの方を読み進める必要があるのでそうします。
それでは、locator_for
メソッドの中身を見ていきます!
このメソッドは、ロケーターオブジェクトにgid.app
と同じ名前のkeyがあればその値を返し、ない場合は、DEFAULT_LOCATOR
が返るようになっています。前者はロケーターを自作した場合にのみ該当するようです。通常(ロケーターを自作しない限り)はDEFAULT_LOCATORが返るようなので、今回はその場合を想定して読み進めていきます。
DEFAULT_LOCATOR
は、UnscopedLocator
のオブジェクトとなっています。これがlocator_for
メソッドの返り値となります。
locator_for
の返り値が分かった所でもう一度、GlobalID::Locator#locate
に戻ります。先ほど読み飛ばした、locator.locate(gid, options.except(:only))
部分を読みます。locator
はUnscopedLocator
クラスのオブジェクトとなっているので、ここで呼ばれているのは、UnscopedLocator#locator
ということが分かりました!
UnscopedLocator#locate
の中身を見ていきます。unscoped
メソッドは、直接Global IDのデシリアライズ処理には直結しないので詳しくは触れませんが、対象のモデルクラスに定義されたscopeを解除するメソッドで、すべてのレコードを取得対象にするためのメソッドのようです。
そして、super
で、スーパークラスのlocate
が呼ばれます。継承元のBaseLocator#locate
ですね!!
BaseLocator#locate
の中身を見ます!引数で渡ってきたGlobal IDのモデルidが有効か確認します。有効であれば、model_class
にGlobal IDに保存されたモデルのクラス名をmodel_class
変数に格納します。
最後に、find
メソッドで、対象のモデルのオブジェクトを取得しています。(この時に使用しているfind
メソッドは、ActiveRecord#find
です。)
お〜〜〜👏👏👏
長旅になりましたが、終わりました💪
お疲れ様でした!!!
今回の記事のまとめと所感
まとめ
- Global IDとは何か?
- Global IDとは、アプリ内のモデルのオブジェクトの情報を一意に識別するURIのこと
- Global IDは何をしているのか?
-
to_global_id
メソッドを使って、- ActiveRecordのオブジェクトを
GlobalID
クラスのオブジェクトに変換し、gid://アプリ名/モデル名/id
という構成で、アプリ内で一意となるURIを作成している -
GlobalID
クラスのオブジェクトに変換することで、ActiveRecord
のオブジェクトなどの複雑な情報を、簡潔な情報にしている
- ActiveRecordのオブジェクトを
-
GlobalID::Locator.locate
メソッドを使って、-
Global ID
クラスのオブジェクトからfind
メソッドを使ってActiveRecord
のオブジェクトを再度取得して返している
-
-
所感
業務中に起きていた疑問のActiveJob
のperform_later
メソッドの引数に、ActiveRecord
のオブジェクトを渡した際に起きていたことは、
-
perform_later
メソッドの引数に値を渡すと、その値がto_global_id
メソッドでActiveRecordクラスのオブジェクトからGlobal IDクラスのオブジェクトに変換される - そして、Jobの実行時に
GlobalID::Locator.locate
メソッドが呼び出され、元のActiveRecord
クラスのオブジェクトを取得して返すので、取得するレコードは最新の状態が担保されている
という処理を、内部でしていたみたいです!なるほど〜〜〜🤔
Global IDを使う事でJobの実行時にActiveRecord
のオブジェクトが取得されるため、最新のレコードを返せるようになっています。これで、非同期実行の場合でも扱うデータの正確性が担保できています。
また、Global IDオブジェクトは、複雑なオブジェクトの情報ではなくURI形式に変換された情報を保持する事で、保持する情報の簡略化とデータの軽量化につながり、処理をより安全で行うことに繋がっていそうだなと思いました!
最後に
今回の記事で、Global IDが何なのかと内部で何をしているのかについて知ることができましたが、ActiveJob
を利用する際に、Global IDがどのように流れ呼び出されているのかを調べることはできませんでした。その流れのアウトプットは、また次回にやる事にします💪
Discussion