Edited at

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

この記事は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

  • 改めましてActionDispatch::Cookies::CookieJar.build
  • 最後に[]=
  • 最後に
  • 今回追うことができなかった処理・残された疑問点
  • おつかれさまでした🍪