LoginSignup
186
179

More than 3 years have passed since last update.

たのしいOSSコードリーディング: Let's read "cookies"🍪

Last updated at Posted at 2019-07-07

この記事は2019年7月6日に開催されたTama Ruby会議01での発表「たのしいOSSコードリーディング: Let's read "cookies"🍪」を詳細解説するものです。

Railsアプリケーションで使用されるcookiesメソッドを題材に、このメソッドがどのように実装されているかを読んでいきます。
読みきれなかった部分・知識が曖昧な部分が残っているため、先輩方からの技術的指摘をお待ちしています。

当日の発表資料はこちら

image.png

調査環境

  • Rails 6.0.0.rc1
  • TraceLocation 0.9.3.1

そもそもcookiesメソッドとは

cookies[:hoge] = "fuga"

Railsが用意しているメソッド。
Cookieに「hoge=fuga」を設定するときに使用。

事前知識

①Cookie

クライアントからサーバーへリクエストを送る際、HTTPリクエストヘッダはCookieヘッダを含んでいる。

クライアント👩‍💻 →  リクエスト[Cookie: hoge=fuga]   → サーバー👨‍💻

Cookieヘッダの中身はこんな感じ。

NAME1=OPAQUE_STRING1; NAME2=OPAQUE_STRING2 ...

サーバーからクライアントにレスポンスを送る際、HTTPレスポンスヘッダはSet-Cookieヘッダを含んでいる。

クライアント👩‍💻 ← レスポンス[Set-Cookie: hoge=fuga] ← サーバー👨‍💻

Set-Cookieヘッダの中身はこんな感じ。

NAME=VALUE; expires=DATE; path=PATH; domain=DOMAIN_NAME; ...

いずれの場合も、Cookieは次のように名前と値を一つのペアとしている🍪

Cookieの名前=Cookieの値

(Rubyスクリプトの中で扱う際、ハッシュの形式{名前: 値}に変換したりする)

②Rackミドルウェア

Rackとは

  • 「Rack」は次のふたつの意味を持っている
    • WebアプリケーションとWebサーバーを繋ぐプロトコル
    • WebアプリケーションとWebサーバーを繋ぐライブラリ
  • Railsアプリケーションは、「Rackプロトコルを満たし、かつRackライブラリを内部で使うRackアプリケーション」

Rackミドルウェアとは

  • Webアプリケーションが持っているべき、「特定の汎用的な機能」を切り出したRackライブラリ
  • Railsアプリケーションは多くのRackミドルウェアを使用している
  • 今回見ていくActionDispatch::CookiesはRailsで使用されているRackミドルウェアのひとつ
$ rails middleware
use Webpacker::DevServerProxy
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
 ()...

ActionDispatch::Cookiesとは?

  • Railsアプリケーションで使用されているRackミドルウェアのひとつ
  • Cookieを保存するために使う
$ rails middleware
 ()...
use ActionDispatch::Cookies # ←これ
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run Myapp::Application.routes

それでは早速読んでいきましょう🍪

cookies[:hoge] = "fuga"の全体図

trace_locationを使用すると、cookiesメソッドを呼んだ際、次のように一連の処理が走ることが確認できます。

Logged by TraceLocation gem at 2019-06-05 20:31:31 +0900
https://github.com/yhirano55/trace_location

[Tracing events] C: Call, R: Return

C actionpack-6.0.0.rc1/lib/action_controller/metal/cookies.rb:12 [ActionController::Cookies#cookies]
  C actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:11 [ActionDispatch::Request#cookie_jar]
    C rack-2.0.7/lib/rack/request.rb:58 [Rack::Request::Env#fetch_header]
    R rack-2.0.7/lib/rack/request.rb:60 [Rack::Request::Env#fetch_header]
  R actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:15 [ActionDispatch::Request#cookie_jar]
R actionpack-6.0.0.rc1/lib/action_controller/metal/cookies.rb:14 [ActionController::Cookies#cookies]
C actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:374 [ActionDispatch::Cookies::CookieJar#[]=]
  C actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:350 [ActionDispatch::Cookies::CookieJar#handle_options]
  R actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:370 [ActionDispatch::Cookies::CookieJar#handle_options]
R actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:392 [ActionDispatch::Cookies::CookieJar#[]=]

Result: fuga

要約するとこんな感じ

[Rails]ActionDispatch::Cookiesミドルウェアにアクセスする

[Rails][Rack]リクエストから既存のCookieヘッダを見つけて返す
             見つからなかった場合は空のCookieをセットする

[Rails]ActionDispatch::Cookiesのインスタンスを生成
     インスタンス変数を初期化する

[Rails]ActionDispatch::Cookiesのインスタンス変数に
     新しいCookieの値を追加する

[Rails]ミドルウェアがアプリケーションを返すタイミングで
     インスタンス変数の内容をSet-Cookieヘッダに書き込む(?)  ここは処理を追いきれず…

RailsとRackの処理を行ったり来たりするため、手元にソースコードがある方は確認しながら進むことをお勧めします。

ひとつずつ読んでいきましょう🍪

rails/actionpack/lib/action_controller/metal/cookies.rb

      7 C /vendor/bundle/ruby/2.6.0/gems/actionpack-6.0.0.rc1/lib/action_controller/metal/cookies.rb:12#cookies

Rails側から処理を追います🍪
まずはActionController::Cookies#cookiesメソッドを読んでみましょう。

ActionController::Cookies#cookies

actionpack/lib/action_controller/metal/cookies.rb
 11     private
 12       def cookies
 13         request.cookie_jar
 14       end

一番最初に呼ばれる#cookiesメソッドの中身はこんな感じ、レシーバに対して#cookie_jarメソッドを呼んでいるだけ。

レシーバのrequestActionDispatch::Requestのインスタンス(※include先で定義されている)。

続いて#cookie_jarメソッドを読んでみましょう。

rails/actionpack/lib/action_dispatch/middleware/cookies.rb

      8   C /vendor/bundle/ruby/2.6.0/gems/actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:11#cookie_jar

引き続きRails🍪

ActionDispatch::Request#cookie_jar

actionpack/lib/action_dispatch/middleware/cookies.rb
 11     def cookie_jar
 12       fetch_header("action_dispatch.cookies") do
 13         self.cookie_jar = Cookies::CookieJar.build(self, cookies)
 14       end
 15     end

#cookie_jarメソッドの中身はこんな感じ。
一行目でRackのメソッドであるfetch_header(Rack::Request::Env#fetch_header)を呼んでいます。

続いてRack::Request::Env#fetch_headerを読んでみましょう。

rack/lib/rack/request.rb

      9     C /vendor/bundle/ruby/2.6.0/gems/rack-2.0.7/lib/rack/request.rb:58#fetch_header

Rack側に移ります🍪

Rack::Request::Env#fetch_header

rack/lib/rack/request.rb
     69       def fetch_header(name, &block)
     70         @env.fetch(name, &block)
     71       end

#fetch_headerメソッドの中身はこんな感じ。
ここで登場するオブジェクトを確認しましょう。

nameとは

引数のname"action_dispatch.cookies"

(参考)

actionpack/lib/action_dispatch/middleware/cookies.rb
 12       fetch_header("action_dispatch.cookies") do
 13         self.cookie_jar = Cookies::CookieJar.build(self, cookies)
 14       end

&blockとは

引数の&blockfetch_headerに渡されているブロック
self.cookie_jar = Cookies::CookieJar.build(self, cookies)

(参考)

actionpack/lib/action_dispatch/middleware/cookies.rb
 12       fetch_header("action_dispatch.cookies") do
 13         self.cookie_jar = Cookies::CookieJar.build(self, cookies) 
 14       end

レシーバ@envとは

HTTPヘッダを表すハッシュ。
例えばこんなRackアプリがあった場合

['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]

真ん中の{'Content-Type' => 'text/html'}というハッシュが、Rack上では@envというインスタンス変数に入っています。

つまり…

fetch_headerは、ヘッダである@envハッシュに対して、Hash#fetchメソッドを呼ぶための処理。

すなわち

  • @env"action_dispatch.cookies"というkeyを持っていた場合:
    • ➡︎そのvalueが返る
  • @env"action_dispatch.cookies"というkeyを持っていなかった場合;
    • ➡︎ブロックself.cookie_jar = Cookies::CookieJar.build(self, cookies)が実行される

続いて、ブロックself.cookie_jar = Cookies::CookieJar.build(self, cookies)が実行された場合の処理を追います。

rails/actionpack/lib/action_dispatch/middleware/cookies.rb

self.cookie_jar =は、ActionDispatch::Request#cookie_jar=としてメソッド定義されています。
早速読んでみましょう🍪

ActionDispatch::Request#cookie_jar=

actionpack/lib/action_dispatch/middleware/cookies.rb
     28     def cookie_jar=(jar)
     29       set_header "action_dispatch.cookies", jar
     30     end

ここで登場する引数のjarの正体はCookies::CookieJar.build(self, cookies)ですが、この処理は後で読むので一旦パス。

二行目で、再びRackのメソッドであるset_headerを呼んでいます。

#set_headerの処理を読んでみましょう。

rack/lib/rack/request.rb

再びRackへ🍪

Rack::Request::Env#set_header

lib/rack/request.rb
     79       def set_header(name, v)
     80         @env[name] = v
     81       end

#fetch_headerメソッドの中身はこんな感じ。
ここで登場するオブジェクトを確認しましょう。

引数nameとは

引数のnameは"action_dispatch.cookies"

引数vとは

vは先程のjar(つまりCookies::CookieJar.build(self, cookies))

具体的には

@env["action_dispatch.cookies"] = Cookies::CookieJar.build(self, cookies)

という処理が走っています。
このとき、返り値はCookies::CookieJar.build(self, cookies)になります。

ここまでのまとめ

アプリケーションでcookiesメソッドが呼ばれると、次の2つの処理が走る。
1. @envハッシュにおける"action_dispatch.cookies"keyの存在を確認する。
2. 存在する場合はvalueを返す。
存在しない場合は新たに{"action_dispatch.cookies":
*Cookies::CookieJar.build(self, cookies)*}
というペアをつくる。

Let's read Rack::Request::Helpers#cookies🍪

ここからは、先ほどパスしたCookies::CookieJar.build(self, cookies)を読んでいきます。

ActionDispatch::Cookies::CookieJar.build

actionpack/lib/action_dispatch/middleware/cookies.rb
     11     def cookie_jar
     12       fetch_header("action_dispatch.cookies") do
     13         self.cookie_jar = Cookies::CookieJar.build(self, cookies)
     14       end
     15     end

↑13行目でself.cookie_jar =に渡しているCookies::CookieJar.build(self, cookies)

が、その前に

注目:eyes:

Cookies::CookieJar.build(self, cookies)

ここで引数に入れたものは何?

(self, cookies)

selfは一番最初に出てきたrequest
cookiesは、これもRackのメソッドを呼んでいます。

rack/lib/rack/request.rb

cookiesの正体を探るため、Rack::Request::Helpers#cookiesメソッドを読んでいきましょう🍪

Rack::Request::Helpers#cookies

lib/rack/request.rb
    215       def cookies
    216         hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |k|
    217           set_header(k, {})
    218         end
    219         string = get_header HTTP_COOKIE
    220 
    221         return hash if string == get_header(RACK_REQUEST_COOKIE_STRING)
    222         hash.replace Utils.parse_cookies_header get_header HTTP_COOKIE
    223         set_header(RACK_REQUEST_COOKIE_STRING, string)
    224         hash
    225       end

#cookiesの中身はこんな感じ。

一行づつ

lib/rack/request.rb
    216         hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |k|
    217           set_header(k, {})
    218         end

fatch_headerは先程と同じメソッドで、@envハッシュに対してRACK_REQUEST_COOKIE_HASHkeyをfetchします。

  • keyがあった場合
    • ➡️valueを返す
  • keyがなかった場合
    • ➡️これも先程登場したset_headerメソッドを使用して、 @envハッシュにRACK_REQUEST_COOKIE_HASH: {}というペアを追加する
    • 返り値は空の{}

(参考)

lib/rack/request.rb
     79       def set_header(name, v)
     80         @env[name] = v
     81       end

いずれの場合も、返り値をhashに代入しています。

RACK_REQUEST_COOKIE_HASHとは?

ここで登場する、RACK_REQUEST_COOKIE_HASHの正体は、rack.request.cookie_hash
Rackがサーバーに対して問い合わせするためのメソッド。
返り値はこんな感じ。
(例)

example.rb
{"_todo_session"=>"BAh7B0kiD3Nlc3Npb25faWQGOgZFRkkiJWY1OWJmM2I3YWE3YzlhY2UzMmM5Mzk3NjZlMzJkNjU2BjsAVEkiEF9jc3JmX3Rva2VuBjsARkkiMW5vOTdFQzRtQURmMDJoVlYzT2ZSK1hxQmw3M0ZrNFRwOGpCZWdXbmx3V1k9BjsARg==--c34c5fc6de928cde391cccd2b710547c7aab1d06"}

(↑の例はたまたま下記の記事中で見つけたものです)
Rails で捕捉されない例外が発生したらメールを送る

つづきまして

lib/rack/request.rb
    219         string = get_header HTTP_COOKIE

HTTP_COOKIEを引数として、get_headerメソッドを呼んでいます。
get_headerRack::Request::Env#get_headerで、定義は以下の通り。

lib/rack/request.rb
    63          def get_header(name)
    64            @env[name]
    65          end

@envハッシュからHTTP_COOKIEkeyを探し、valueを返し、返り値がstringに代入されます。
ただし、初めてCookieを使用する場合は@envハッシュにHTTP_COOKIEkeyが見つからないため、nilが返ります。

HTTP_COOKIEとは?

ここで登場するHTTP_COOKIEの正体は、リクエストメッセージに含まれるメタ変数で、Cookieヘッダを返しています。
中身はこんな感じ(Cookieの名前と値を=で結ぶことでペアとして表現している)
(例)

example.rb _todo_session=BAh7B0kiD3Nlc3Npb25faWQGOgZFRkkiJWY1OWJmM2I3YWE3YzlhY2UzMmM5Mzk3NjZlMzJkNjU2BjsAVEkiEF9jc3JmX3Rva2VuBjsARkkiMW5vOTdFQzRtQURmMDJoVlYzT2ZSK1hxQmw3M0ZrNFRwOGpCZWdXbmx3V1k9BjsARg%3D%3D--c34c5fc6de928cde391cccd2b710547c7aab1d06

(↑の例も先ほどの記事よりお借りしました)

※メタ変数について:
参照

例えば HTTP というプロトコルにおいては、 HTTP_USER_AGENT という名前のメタ変数が HTTP 要求メッセージの User-Agent: 欄の値を提供することになっています

つづき

初めてCookieを使用する場合、ここまでで

  • hash{}
  • stringget_header HTTP_COOKIEの返り値(初めてCookieを使用する場合はnil

が代入されていることになります。

lib/rack/request.rb
    221         return hash if string == get_header(RACK_REQUEST_COOKIE_STRING)

RACK_REQUEST_COOKIE_STRINGを引数に、Rack::Request::Env#get_headerメソッドを呼んでstringと比較。

(参考)

lib/rack/request.rb
    63          def get_header(name)
    64            @env[name]
    65          end

RACK_REQUEST_COOKIE_STRINGとは?

ここで登場するRACK_REQUEST_COOKIE_STRINGの正体はrack.request.cookie_stringで、こちらもサーバーに対して問い合わせするためのメソッド。
返り値はこんな感じ。
(例)

example.rb
_todo_session=BAh7B0kiD3Nlc3Npb25faWQGOgZFRkkiJWY1OWJmM2I3YWE3YzlhY2UzMmM5Mzk3NjZlMzJkNjU2BjsAVEkiEF9jc3JmX3Rva2VuBjsARkkiMW5vOTdFQzRtQURmMDJoVlYzT2ZSK1hxQmw3M0ZrNFRwOGpCZWdXbmx3V1k9BjsARg%3D%3D--c34c5fc6de928cde391cccd2b710547c7aab1d06

(御多分に洩れず↑の例も先ほどの記事より)

HTTP_COOKIEと同じですね!

つづき

lib/rack/request.rb
    221         return hash if string == get_header(RACK_REQUEST_COOKIE_STRING)

==の場合はここでreturn hash

なぜ?

次行以降はリクエストヘッダからやって来たhash(初めてのCookieを使用する場合は空の{})をHTTP_COOKIEで更新する処理となっています。
ハッシュの中身が変更されていない場合、置き換える必要がないため。

初めてCookieを使用する場合、stringget_header(RACK_REQUEST_COOKIE_STRING)の返り値はいずれもnilのため、ここでcookiesメソッドの処理はhash(空の{})を返して終了します。

つづき(すでにCookieが存在する場合のみ)

lib/rack/request.rb
    222         hash.replace Utils.parse_cookies_header string

ここでは、
Utils.parse_cookies_header(string)の返り値{ Cookieの名前: Cookieの値 }で、hash(fetch_header(RACK_REQUEST_COOKIE_HASH)の返り値)をreplaceしています。

Hash#replace

Utils.parse_cookies_headerRack::Utilsのメソッドで、
詳しくは読みませんが、
string(Cookieの名前=Cookieの値)を{Cookieの名前: Cookieの値}
のようなハッシュに変換する役割を担っています。

(参考)

lib/rack/utils.rb
    209     def parse_cookies_header(header)
    215       cookies = parse_query(header, ';,') { |s| unescape(s) rescue s }
    216       cookies.each_with_object({}) { |(k, v), hash| hash[k] = Array === v ? v.first : v }
    217     end

(↑each_with_objectを使用して{}に名前と値を代入している)

つづき(すでにCookieが存在する場合のみ)

lib/rack/request.rb
    223         set_header(RACK_REQUEST_COOKIE_STRING, string)
    224         hash
    225       end

すなわち、ここで

@env[RACK_REQUEST_COOKIE_STRING] = string

という処理を実行。

(参考)

lib/rack/request.rb
     79       def set_header(name, v)
     80         @env[name] = v
     81       end

最後にhash({Cookieの名前: Cookieの値}) を返しています。

ここまでのまとめ

メソッドcookiesを呼ぶとき

  • 初めてCookieを使用する場合: ヘッダ(@env)に{RACK_REQUEST_COOKIE_HASH: {} }が追加される
    • 最終的な返り値は空の{}
  • すでにCookieが存在する場合: ヘッダ(@env)に{RACK_REQUEST_COOKIE_STRING: リクエストメッセージのCookieヘッダ}が追加される
    • 最終的な返り値は{Cookieの名前: Cookieの値}

改めましてActionDispatch::Cookies::CookieJar.build

actionpack/lib/action_dispatch/middleware/cookies.rb

お待たせしました。Cookies::CookieJar.buildを読んでいきましょう。
再びRailsへ🍪

ActionDispatch::Cookies::CookieJar.build

actionpack/lib/action_dispatch/middleware/cookies.rb
    170 # class Cookies
    # ...
    272 #   class CookieJar
    # ...
    289       def self.build(req, cookies)
    290         new(req).tap do |jar|
    291           jar.update(cookies)
    292         end
    293       end

Cookies::CookieJar.buildの中身はこんな感じ。

tapを使用して処理をチェーンしているのは、最終的にできあがったCookies::CookieJarインスタンスを返すためではないかと考えられます。
tapを挟まない場合、返り値がjar.updateの実行結果になる)

ここで登場するオブジェクトを確認しましょう。

reqとは

引数のreqは一番最初のrequestActionDispatch::Requestのインスタンス)

cookiesとは

cookiesは先ほど読んだRack::Request::Helpers#cookiesの返り値({Cookieの名前: Cookieの値}あるいは空の{})

Cookies::CookieJar.buildは何をやっているのか

ここでの役割は2つ

  • Cookies::CoookieJarのインスタンスを作る
  • できたインスタンスに対してCookies::CookieJar#updateメソッドを呼ぶ

順番に処理を読んでいきます。

new(req)

actionpack/lib/action_dispatch/middleware/cookies.rb
    170 # class Cookies
    # ...
    272 #   class CookieJar
    # ...
    289       def self.build(req, cookies)
    290         new(req).tap do |jar| # ←
    291           jar.update(cookies)
    292         end
    293       end

new(req)(インスタンス化)すると、initializeが呼ばれます。

actionpack/lib/action_dispatch/middleware/cookies.rb
    295       attr_reader :request
    296 
    297       def initialize(request)
    298         @set_cookies = {}
    299         @delete_cookies = {}
    300         @request = request
    301         @cookies = {}
    302         @committed = false
    303       end

ActionDispatch::Cookies::CookieJar#initializeは各インスタンス変数を初期化するだけの処理。

@requestには引数で渡されている一番最初のrequestが入ります。
attr_readerによってメソッドとしてアクセスできるようになります。

つづいてtap以下の処理

actionpack/lib/action_dispatch/middleware/cookies.rb
    170 # class Cookies
    # ...
    272 #   class CookieJar
    # ...
    290         new(req).tap do |jar|
    291           jar.update(cookies) # ←
    292         end

tapブロックの中で実行されているupdateメソッドはこちら

actionpack/lib/action_dispatch/middleware/cookies.rb
    334       def update(other_hash)
    335         @cookies.update other_hash.stringify_keys
    336         self
    337       end

引数other_hashとは

引数other_hashは先ほどのcookies、つまり{Cookieの名前: Cookieの値}もしくは空の{}

@cookiesとは

@cookiesinitializeの中で初期化したこの部分

    301         @cookies = {}

ここでは、この空の{}に対してupdateメソッドを呼んでいます。
Hash#updateメソッドはHash#merge!のエイリアス。

処理を実行すると、こうなります。

@cookies = {'Cookieの名前': 'Cookieの値'}

(other_hashが空の{}の場合は、何も起きない)

その後、self (=jar、つまりCookies::CookieJar.buildでできたインスタンス)を返しています。

(余談)
↑の方でtapを使った理由を「インスタンスを返すため」と記述しているのですが、ここでもインスタンス自身を返しているため、どちらか片方の処理で良いのではという疑惑あり…PRチャンス?

また、先述の通りこのインスタンスは

actionpack/lib/action_dispatch/middleware/cookies.rb
 13         self.cookie_jar = Cookies::CookieJar.build(self, cookies)

ここでself.cookie_jar=メソッドに引数として渡されて、

actionpack/lib/action_dispatch/middleware/cookies.rb
     28     def cookie_jar=(jar)
     29       set_header "action_dispatch.cookies", jar
     30     end

ここで@env = {"action_dispatch.cookies": できあがったインスタンス}としてヘッダーに追加されます。

ここまでのまとめ

アプリケーションからcookiesメソッドを呼ぶと、結果的には次のようなリクエストヘッダができる

@env = {
  "action_dispatch.cookies": Cookies::CookieJarのインスタンス},
  # 初めてCookieを使用する場合
  RACK_REQUEST_COOKIE_HASH: {},
  # すでにCookieが存在する場合
  RACK_REQUEST_COOKIE_STRING: リクエストメッセージから受け取ったCookieヘッダ
}

最後に[]=

cookies[:hoge] = :fuga

cookiesに値を代入する処理を見ていきます🍪

actionpack/lib/action_dispatch/middleware/cookies.rb

14 C /vendor/bundle/ruby/2.6.0/gems/actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:374#[]=

ここからはRailsのお話🍪
#[]=メソッドを読んでいきましょう。

ActionDispatch::Cookies::CookieJar#[]=

actionpack/lib/action_dispatch/middleware/cookies.rb
    170 # class Cookies
    # ...
    272 #   class CookieJar
    # ...
    372       # Sets the cookie named +name+. The second argument may be the cookie's
    373       # value or a hash of options as documented above.
    374       def []=(name, options)
    375         if options.is_a?(Hash)
    376           options.symbolize_keys!
    377           value = options[:value]
    378         else
    379           value = options
    380           options = { value: value }
    381         end
    382 
    383         handle_options(options)
    384 
    385         if @cookies[name.to_s] != value || options[:expires]
    386           @cookies[name.to_s] = value
    387           @set_cookies[name.to_s] = options
    388           @delete_cookies.delete(name.to_s)
    389         end
    390 
    391         value
    392       end

ActionDispatch::Cookies::CookieJar#[]=の中身はこんな感じ。

一行ずつ

actionpack/lib/action_dispatch/middleware/cookies.rb
    374       def []=(name, options)

これを定義すると、

[:name] = options

という形で値を代入できるようになります。

それではここで、Railsアプリからcookies[]= に渡せる値を確認しましょう🍪

Railsガイド

cookie[:hoge] = "fuga"のように文字列を渡すことが多いと思いますが、オプションを細かく設定する場合、右辺にはハッシュを渡すこともできます。

cookies[:hoge] = {
  value:      'Cookieの値',
  expires:    'Cookieの有効期限',
  path:       'Cookieが適用されるパス(デフォルトは/)',
  domain:     'Cookieが適用されるドメイン',
  tld_length: 'domain: :allの時、TLDの一部として解釈される短い(3文字以下の)ドメインを使用するときに、TLDの長さを明示的に設定',
  secure:     '暗号化通信のみを有効化(デフォルトはfalse)',
  httponly:   'スクリプト経由もしくはHTTP通信のみを有効化(デフォルトはfalse)'
}

これを踏まえて、引き続き処理を読んでいきます。

①引数optionsがハッシュの場合

actionpack/lib/action_dispatch/middleware/cookies.rb
    375         if options.is_a?(Hash)
    376           options.symbolize_keys!
    377           value = options[:value]
    378         else

cookies[:hoge] = {"value" => "fuga}の場合、symbolize_keys!{:value => "fuga"}に変換。

変数value"fuga"を代入。

②引数optionsがハッシュ以外の場合

actionpack/lib/action_dispatch/middleware/cookies.rb
    378         else
    379           value = options
    380           options = { value: value }
    381         end

cookies[:hoge] = "fuga"の場合、変数value"fuga"を代入。

options{value: "fuga"}を再代入。

今の状態

①②いずれの場合も、

  • options{value: "fuga"}
  • valuefuga

となっている。

つづき

actionpack/lib/action_dispatch/middleware/cookies.rb
    383         handle_options(options)

同じクラスのhandle_options(options)を呼びます。

ActionDispatch::Cookies::CookieJar#handle_options

handle_optionsoptionsをいい感じにするためのメソッドです。

actionpack/lib/action_dispatch/middleware/cookies.rb
    350       def handle_options(options) # :nodoc:
    351         if options[:expires].respond_to?(:from_now)
    352           options[:expires] = options[:expires].from_now
    353         end
    354 
    355         options[:path] ||= "/"
    356 
    357         if options[:domain] == :all || options[:domain] == "all"
    358           # If there is a provided tld length then we use it otherwise default domain regexp.
    359           domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP
    360 
    361           # If host is not ip and matches domain regexp.
    362           # (ip confirms to domain regexp so we explicitly check for ip)
    363           options[:domain] = if (request.host !~ /^[\d.]+$/) && (request.host =~ domain_regexp)
    364             ".#{$&}"
    365           end
    366         elsif options[:domain].is_a? Array
    367           # If host matches one of the supplied domains without a dot in front of it.
    368           options[:domain] = options[:domain].find { |domain| request.host.include? domain.sub(/^\./, "") }
    369         end
    370       end

中身はこんな感じ。

一行ずつ

actionpack/lib/action_dispatch/middleware/cookies.rb
    351         if options[:expires].respond_to?(:from_now)
    352           options[:expires] = options[:expires].from_now
    353         end

cookiesメソッドに有効期限を設定した場合(例:1.week)、from_nowで具体的な日時に変換してoptions[:expires]に再代入。
from_nowsinceのエイリアス。

actionpack/lib/action_dispatch/middleware/cookies.rb
    355         options[:path] ||= "/"

cookiesメソッドにパスが指定されていない場合、ルートパスを設定(デフォルト値として)

actionpack/lib/action_dispatch/middleware/cookies.rb
    357         if options[:domain] == :all || options[:domain] == "all"
    358           # If there is a provided tld length then we use it otherwise default domain regexp.
    359           domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP
    360 
    361           # If host is not ip and matches domain regexp.
    362           # (ip confirms to domain regexp so we explicitly check for ip)
    363           options[:domain] = if (request.host !~ /^[\d.]+$/) && (request.host =~ domain_regexp)
    364             ".#{$&}"
    365           end
    366         elsif options[:domain].is_a? Array
    367           # If host matches one of the supplied domains without a dot in front of it.
    368           options[:domain] = options[:domain].find { |domain| request.host.include? domain.sub(/^\./, "") }
    369         end
    370       end

誰か正規表現に強い方…………

  • [:domain]オプションが:allor"all"の場合
    • [:tld_length]が指定されている場合は/([^.]+\.?){#{options[:tld_length]}}$/
    • そうでない場合はDOMAIN_REGEXP

を変数domain_regexpに代入
(※DOMAIN_REGEXP/[^.]*\.([^.]*|..\...|...\...)$/)

リクエストメッセージのホストが/^[\d.]+$/に一致しない場合&&変数domain_regexpに一致する場合はoptions[:domain]".#{$&}"で置き換える

  • [:domain]オプションが配列の場合
    • options[:domain]を、リクエストメッセージのホストが含まれる要素で置き換える

ここでやったこと

  • 有効期限を具体的な日時に変換
  • パスの設定がない場合にデフォルト値を設定
  • ドメインをいい感じに再代入(?)

ActionDispatch::Cookies::CookieJar#[]=のつづき

今の状態

①②いずれの場合も、
- options{value: 'fuga'}
- valuefuga

となっています。

ActionDispatch::Cookies::CookieJar#[]=のつづき

actionpack/lib/action_dispatch/middleware/cookies.rb
    385         if @cookies[name.to_s] != value || options[:expires]
    386           @cookies[name.to_s] = value
    387           @set_cookies[name.to_s] = options
    388           @delete_cookies.delete(name.to_s)
    389         end
    390 
    391         value
    392       end

@cookies["hoge"]のvalueが"fuga"でない場合(つまり値が変わっている場合)、もしくは有効期限の設定がない場合に、

  • @cookies["hoge"]"fuga"を代入
  • @set_cookiesにoption{value: 'fuga'}を代入
  • @delete_cookiesから[:hoge]を削除する

をして、最後に"fuga"を返します。

最後に

trace_locationでログに残っていたのはここまでですが、
先述の通りActionDispatch::Cookiescallメソッドを持ったRackミドルウェアとして実装されており、
Railsアプリケーションから使用されています。

$ rails middleware
use Webpacker::DevServerProxy
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies # ←ここ 
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run Myapp::Application.routes

このことから、Railsアプリケーションにリクエストがあった際、Rackアプリとして次の処理が呼ばれることがわかります。

actionpack/lib/action_dispatch/middleware/cookies.rb
    170 # class Cookies
    # ...
    637     def initialize(app)
    638       @app = app
    639     end
    640 
    641     def call(env)
    642       request = ActionDispatch::Request.new env
    643 
    644       status, headers, body = @app.call(env)
    645 
    646       if request.have_cookie_jar?
    647         cookie_jar = request.cookie_jar
    648         unless cookie_jar.committed?
    649           cookie_jar.write(headers)
    650           if headers[HTTP_HEADER].respond_to?(:join)
    651             headers[HTTP_HEADER] = headers[HTTP_HEADER].join("\n")
    652           end
    653         end
    654       end
    655 
    656       [status, headers, body]
    657     end

中身は詳しく追っていきませんが、

actionpack/lib/action_dispatch/middleware/cookies.rb
    647         cookie_jar = request.cookie_jar
    648         unless cookie_jar.committed?
    649           cookie_jar.write(headers)
    650           if headers[HTTP_HEADER].respond_to?(:join)
    651             headers[HTTP_HEADER] = headers[HTTP_HEADER].join("\n")
    652           end
    653         end

この辺りでヘッダーへの書き込みが行われていることが確認できました。

今回追うことができなかった処理・残された疑問点

  • "rack.request.cookie_hash"がヘッダーに書き込まれるタイミング
  • Cookies::CookieJar#writeCookies::CookieJar#make_set_cookie_headerから、最終的に呼ばれる::Rack::Utils.add_cookie_to_header(↑の資料では未出)
    • ヘッダーに書き込む文字列の成形を行なっていることが確認できるが、その後実際にヘッダーに書き込まれるタイミング

おつかれさまでした🍪

参考資料

おまけ

TraceLocationで作成したログは次の通りです(マークダウン形式)
Generated by trace_location at 2019-06-05 20:28:50 +0900


actionpack-6.0.0.rc1/lib/action_controller/metal/cookies.rb:12

ActionController::Cookies#cookies
def cookies
  request.cookie_jar
end
# called from /Users/misakishioi/Projects/myapp/app/controllers/blogs_controller.rb:8



actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:11

ActionDispatch::Request#cookie_jar
def cookie_jar
  fetch_header("action_dispatch.cookies") do
    self.cookie_jar = Cookies::CookieJar.build(self, cookies)
  end
end
# called from actionpack-6.0.0.rc1/lib/action_controller/metal/cookies.rb:13



rack-2.0.7/lib/rack/request.rb:58

Rack::Request::Env#fetch_header
def fetch_header(name, &block)
  @env.fetch(name, &block)
end
# called from actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:12



actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:374

ActionDispatch::Cookies::CookieJar#[]=
def []=(name, options)
  if options.is_a?(Hash)
    options.symbolize_keys!
    value = options[:value]
  else
    value = options
    options = { value: value }
  end

  handle_options(options)

  if @cookies[name.to_s] != value || options[:expires]
    @cookies[name.to_s] = value
    @set_cookies[name.to_s] = options
    @delete_cookies.delete(name.to_s)
  end

  value
end
# called from /Users/misakishioi/Projects/myapp/app/controllers/blogs_controller.rb:8



actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:350

ActionDispatch::Cookies::CookieJar#handle_options
def handle_options(options) # :nodoc:
  if options[:expires].respond_to?(:from_now)
    options[:expires] = options[:expires].from_now
  end

  options[:path] ||= "/"

  if options[:domain] == :all || options[:domain] == "all"
    # If there is a provided tld length then we use it otherwise default domain regexp.
    domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP

    # If host is not ip and matches domain regexp.
    # (ip confirms to domain regexp so we explicitly check for ip)
    options[:domain] = if (request.host !~ /^[\d.]+$/) && (request.host =~ domain_regexp)
      ".#{$&}"
    end
  elsif options[:domain].is_a? Array
    # If host matches one of the supplied domains without a dot in front of it.
    options[:domain] = options[:domain].find { |domain| request.host.include? domain.sub(/^\./, "") }
  end
end
# called from actionpack-6.0.0.rc1/lib/action_dispatch/middleware/cookies.rb:383

186
179
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
186
179