😺

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 を確認してみると、下記の感じになっていました。
長いので関係のない部分は端折りましたが、ActionTextActiveJob で依存関係の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_idGlobalID::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に入る)

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/identification.rb#L37-L39

GlobalID#createの中身を見てみると、
app変数にアプリ名を格納しています。ここでは、アプリ名が渡されていればそれを格納し、渡されていなければデフォルトのアプリ名を格納して次の処理に移ります。
(ちなみに、GlobalID.appを実行すると、アプリ名が返ってきます!)

irb(main):044> GlobalID.app
=> "global-id-app"

もしappに値が格納されていた場合は、:app:verifier:forを除外した値をparamsに格納します。次に、URI::GID#createを使ってGlobalIDクラスのオブジェクトを作成します。
もしappに何も格納されていなければ、ArgumentErrorを発生させます

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/global_id.rb#L11-L18

URI::GID#createの中身を見てみると、アプリ名、オブジェクトのクラス名、オブジェクトのid、あればparamsがセットされてbuildされている事が分かります。
これでURI部分のアプリ名/モデル名/idが生成される事が分かりました!!!
URIの形ができたので、今回のリーディングはここまでとします。このあとは、GlobalID#create実行結果が返されるという感じで理解しました〜🙌
https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/uri/gid.rb#L72-L74

以上の流れで、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メソッドの方を読み進める必要があるのでそうします。

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/locator.rb#L20-L33

それでは、locator_forメソッドの中身を見ていきます!

このメソッドは、ロケーターオブジェクトにgid.appと同じ名前のkeyがあればその値を返し、ない場合は、DEFAULT_LOCATORが返るようになっています。前者はロケーターを自作した場合にのみ該当するようです。通常(ロケーターを自作しない限り)はDEFAULT_LOCATORが返るようなので、今回はその場合を想定して読み進めていきます。

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/locator.rb#L136-L138

DEFAULT_LOCATORは、UnscopedLocator のオブジェクトとなっています。これがlocator_forメソッドの返り値となります。

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/locator.rb#L226

locator_forの返り値が分かった所でもう一度、GlobalID::Locator#locateに戻ります。先ほど読み飛ばした、locator.locate(gid, options.except(:only))部分を読みます。locatorUnscopedLocatorクラスのオブジェクトとなっているので、ここで呼ばれているのは、UnscopedLocator#locator ということが分かりました!

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/locator.rb#L20-L33

UnscopedLocator#locate の中身を見ていきます。unscopedメソッドは、直接Global IDのデシリアライズ処理には直結しないので詳しくは触れませんが、対象のモデルクラスに定義されたscopeを解除するメソッドで、すべてのレコードを取得対象にするためのメソッドのようです。
そして、super で、スーパークラスのlocate が呼ばれます。継承元のBaseLocator#locateですね!!

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/locator.rb#L208-L211

BaseLocator#locateの中身を見ます!引数で渡ってきたGlobal IDのモデルidが有効か確認します。有効であれば、model_class にGlobal IDに保存されたモデルのクラス名をmodel_class変数に格納します。

最後に、findメソッドで、対象のモデルのオブジェクトを取得しています。(この時に使用しているfindメソッドは、ActiveRecord#find です。)

https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/locator.rb#L156-L163

お〜〜〜👏👏👏

長旅になりましたが、終わりました💪
お疲れ様でした!!!

今回の記事のまとめと所感

まとめ

  • Global IDとは何か?
    • Global IDとは、アプリ内のモデルのオブジェクトの情報を一意に識別するURIのこと
  • Global IDは何をしているのか?
    • to_global_id メソッドを使って、
      • ActiveRecordのオブジェクトをGlobalID クラスのオブジェクトに変換し、gid://アプリ名/モデル名/idという構成で、アプリ内で一意となるURIを作成している
      • GlobalID クラスのオブジェクトに変換することで、ActiveRecordのオブジェクトなどの複雑な情報を、簡潔な情報にしている
    • GlobalID::Locator.locateメソッドを使って、
      • Global IDクラスのオブジェクトからfindメソッドを使ってActiveRecordのオブジェクトを再度取得して返している

所感

業務中に起きていた疑問のActiveJobperform_later メソッドの引数に、ActiveRecordのオブジェクトを渡した際に起きていたことは、

  1. perform_laterメソッドの引数に値を渡すと、その値がto_global_idメソッドでActiveRecordクラスのオブジェクトからGlobal IDクラスのオブジェクトに変換される
  2. そして、Jobの実行時にGlobalID::Locator.locateメソッドが呼び出され、元のActiveRecordクラスのオブジェクトを取得して返すので、取得するレコードは最新の状態が担保されている

という処理を、内部でしていたみたいです!なるほど〜〜〜🤔

Global IDを使う事でJobの実行時にActiveRecordのオブジェクトが取得されるため、最新のレコードを返せるようになっています。これで、非同期実行の場合でも扱うデータの正確性が担保できています。
また、Global IDオブジェクトは、複雑なオブジェクトの情報ではなくURI形式に変換された情報を保持する事で、保持する情報の簡略化とデータの軽量化につながり、処理をより安全で行うことに繋がっていそうだなと思いました!

最後に

今回の記事で、Global IDが何なのかと内部で何をしているのかについて知ることができましたが、ActiveJobを利用する際に、Global IDがどのように流れ呼び出されているのかを調べることはできませんでした。その流れのアウトプットは、また次回にやる事にします💪

合同会社春秋テックブログ

Discussion