<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://anipos.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://anipos.github.io/" rel="alternate" type="text/html" /><updated>2025-06-19T04:12:40+00:00</updated><id>https://anipos.github.io/feed.xml</id><title type="html">アニポスのしっぽ</title><subtitle>「アニポスのしっぽ」は株式会社アニポスの技術ブログです。 ソフトウェアに関する事を中心に、アニポスが行っている活動の一端（しっぽ）を共有します。</subtitle><entry><title type="html">Global IDとpolymorphic associationの相性が良い</title><link href="https://anipos.github.io/2025/06/19/global-id-works-great-with-polymorphic-associations.html" rel="alternate" type="text/html" title="Global IDとpolymorphic associationの相性が良い" /><published>2025-06-19T01:36:28+00:00</published><updated>2025-06-19T01:36:28+00:00</updated><id>https://anipos.github.io/2025/06/19/global-id-works-great-with-polymorphic-associations</id><content type="html" xml:base="https://anipos.github.io/2025/06/19/global-id-works-great-with-polymorphic-associations.html"><![CDATA[<p>Global ID<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>（railsが内部的に依存しているライブラリ）とpolymorphic associationの相性が良いです。このことをGitHubようなシステムを例に見てみましょう。以下のmodelがあるとします。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Issue</span>
  <span class="n">has_many</span> <span class="ss">:comments</span><span class="p">,</span> <span class="ss">as: :commentable</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">PullRequest</span>
  <span class="n">has_many</span> <span class="ss">:comments</span><span class="p">,</span> <span class="ss">as: :commentable</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">Comment</span>
  <span class="n">belongs_to</span> <span class="ss">:commentable</span><span class="p">,</span> <span class="ss">polymorphic: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>IssueとPullRequestにはコメントが付けられます。コメントはpolymorphicになっており同じmodelが使い回されています。view/controllerも共通化したいので以下のようにするのが素直でしょう。</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">model: </span><span class="vi">@comment</span> <span class="k">do</span> <span class="o">|</span><span class="n">form</span><span class="o">|</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">hidden_field_tag</span> <span class="ss">:commentable_type</span><span class="p">,</span> <span class="vi">@commentable</span><span class="p">.</span><span class="nf">class</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">hidden_field_tag</span> <span class="ss">:commentable_id</span><span class="p">,</span> <span class="vi">@commentable</span><span class="p">.</span><span class="nf">id</span> <span class="cp">%&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_area</span> <span class="ss">:body</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">submit</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CommentsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">before_action</span> <span class="ss">:set_commentable</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@comment</span> <span class="o">=</span> <span class="vi">@commentable</span><span class="p">.</span><span class="nf">comments</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">comment_params</span><span class="p">)</span>
    <span class="k">if</span> <span class="vi">@comment</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">redirect_to</span> <span class="n">comment_path</span><span class="p">(</span><span class="vi">@comment</span><span class="p">)</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">set_commentable</span>
    <span class="n">commentable_type</span> <span class="o">=</span> <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:commentable_type</span><span class="p">).</span><span class="nf">presence_in</span><span class="p">(</span><span class="sx">%w[Comment PullRequest]</span><span class="p">)</span> <span class="o">||</span> <span class="k">raise</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">BadRequest</span>
    <span class="vi">@commentable</span> <span class="o">=</span> <span class="n">commentable_type</span><span class="p">.</span><span class="nf">constantize</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:commentable_id</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">presence_in(%w[Comment PullRequest])</code>でコメント対象modelを限定しているのが重要です。これをしないと悪意のあるユーザーが、意図しないmodelにコメントを付けることが出来てしまいます。この書き方で大きな問題はないのですが、以下の点が気に食わないです。</p>

<ul>
  <li>controllerがコメントを付けられるmodelを知っている（余計なドメイン知識がある）。</li>
  <li>コメントを付けられるmodelが増えたときにcontrollerを修正する必要がある。</li>
</ul>

<p>Global IDを使うとこれら問題を解決しつつ、よりスッキリ書けます。それを紹介する前に、先ずは前提知識となるGlobal IDを簡単に紹介します。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gid</span> <span class="o">=</span> <span class="no">Issue</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">to_global_id</span><span class="p">.</span><span class="nf">to_s</span>
<span class="c1">#=&gt; "gid://app/Issue/1"</span>

<span class="no">GlobalID</span><span class="o">::</span><span class="no">Locator</span><span class="p">.</span><span class="nf">locate</span><span class="p">(</span><span class="n">gid</span><span class="p">)</span>
<span class="c1"># =&gt; #&lt;Issue:0x007fae94bf6298 @id="1"&gt;</span>
</code></pre></div></div>

<p>Global IDは単純にclass/idをセットで文字列にエンコードしているだけです。さらに文字列が改竄出来ないように署名付きにすることも出来ます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sgid</span> <span class="o">=</span> <span class="no">Issue</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">to_signed_global_id</span><span class="p">.</span><span class="nf">to_s</span>
<span class="c1">#=&gt; "BAhJIh5naWQ6Ly9pZGluYWlkaS9Vc2VyLzM5NTk5BjoGRVQ=--81d7358dd5ee2ca33189bb404592df5e8d11420e"</span>

<span class="no">GlobalID</span><span class="o">::</span><span class="no">Locator</span><span class="p">.</span><span class="nf">locate_signed</span><span class="p">(</span><span class="n">sgid</span><span class="p">)</span>
<span class="c1"># =&gt; #&lt;Issue:0x007fae94bf6298 @id="1"&gt;</span>
</code></pre></div></div>

<p>さあ、これを使ってview/controllerを書き換えてみましょう。</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">model: </span><span class="vi">@comment</span> <span class="k">do</span> <span class="o">|</span><span class="n">form</span><span class="o">|</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">hidden_field_tag</span> <span class="ss">:commentable_signed_global_id</span><span class="p">,</span> <span class="vi">@commentable</span><span class="p">.</span><span class="nf">to_signed_global_id</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CommentsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">set_commentable</span>
    <span class="vi">@commentable</span> <span class="o">=</span> <span class="no">GlobalID</span><span class="o">::</span><span class="no">Locator</span><span class="p">.</span><span class="nf">locate_signed</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:commentable_signed_global_id</span><span class="p">])</span> <span class="o">||</span> <span class="k">raise</span><span class="p">(</span><span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotFound</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>viewで<code class="language-plaintext highlighter-rouge">to_signed_global_id</code>を使いclass/idをセットで渡しつつ、改竄不可能にしているのが味噌です。改竄されていないことが保証されているので、controllerでは<code class="language-plaintext highlighter-rouge">GlobalID::Locator.locate_signed</code>を呼ぶだけで済んでいます。よって前述の気に食わない点を解決することが出来ています。</p>

<p>Global IDはrailsが内部的に使っているので、直接意識する機会は多くないでしょう。今回はそれを上手く使うことで、より洗練されたコードを書くことが出来ました。フレームワークやライブラリの内部を理解することの重要さが分かります。</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://github.com/rails/globalid">rails/globalid: Identify app models with a URI</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[Global ID1（railsが内部的に依存しているライブラリ）とpolymorphic associationの相性が良いです。このことをGitHubようなシステムを例に見てみましょう。以下のmodelがあるとします。 rails/globalid: Identify app models with a URI &#8617;]]></summary></entry><entry><title type="html">翻訳がない場合にhuman_attribute_nameが例外を投げるようになりました</title><link href="https://anipos.github.io/2024/08/09/human-attribute-name-raises-on-missing-translations.html" rel="alternate" type="text/html" title="翻訳がない場合にhuman_attribute_nameが例外を投げるようになりました" /><published>2024-08-09T07:56:00+00:00</published><updated>2024-08-09T07:56:00+00:00</updated><id>https://anipos.github.io/2024/08/09/human-attribute-name-raises-on-missing-translations</id><content type="html" xml:base="https://anipos.github.io/2024/08/09/human-attribute-name-raises-on-missing-translations.html"><![CDATA[<p>Railsアプリケーションが日本語のみだとしても、I18nを使うと文言を一箇所に集約出来ます。これにより表記揺れし難くなる利点があります。この際に翻訳が存在しない場合に例外を投げるように設定しておくと、開発中に必ず気付けるので便利です。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># &gt; https://guides.rubyonrails.org/configuring.html#config-i18n-raise-on-missing-translations</span>
<span class="c1"># &gt; Determines whether an error should be raised for missing translations. This defaults to false.</span>
<span class="n">config</span><span class="p">.</span><span class="nf">i18n</span><span class="p">.</span><span class="nf">raise_on_missing_translations</span> <span class="o">=</span> <span class="kp">true</span>
</code></pre></div></div>

<p>こうしておくとview/controllerで存在しないI18nのkeyを指定した場合に例外が発生します。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= t "key_that_does_not_exist" %&gt;
#=</span><span class="o">&gt;</span> <span class="no">Translation</span> <span class="ss">missing: </span><span class="n">en</span><span class="p">.</span><span class="nf">key_that_does_not_exist</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">create</span>
  <span class="n">redirect_to</span> <span class="vi">@post</span><span class="p">,</span> <span class="ss">notice: </span><span class="n">t</span><span class="p">(</span><span class="s2">"key_that_does_not_exist"</span><span class="p">)</span>
<span class="k">end</span>
<span class="c1">#=&gt; Translation missing: en.key_that_does_not_exist</span>
</code></pre></div></div>

<p>ただしmodelは例外を投げてくれません。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Post</span><span class="p">.</span><span class="nf">human_attribute_name</span><span class="p">(</span><span class="s2">"title"</span><span class="p">)</span>
<span class="c1">#=&gt; "Title"</span>
</code></pre></div></div>

<p>この挙動によりviewで長いI18nのkeyを指定する必要があり面倒です。</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- 本当はこう書きたいが、翻訳がなくても例外が発生しない。 --&gt;</span>
<span class="nt">&lt;</span><span class="err">%=</span> <span class="na">Post.human_attribute_name</span><span class="err">("</span><span class="na">title</span><span class="err">")</span> <span class="err">%</span><span class="nt">&gt;</span>

<span class="c">&lt;!-- I18nのkeyを完全に指定する必要があり面倒。特にnestしたmodelの場合は長大になりがち。 --&gt;</span>
<span class="nt">&lt;</span><span class="err">%=</span> <span class="na">t</span> <span class="err">"</span><span class="na">activerecord.attributes.post.title</span><span class="err">"</span> <span class="err">%</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>そこで<code class="language-plaintext highlighter-rouge">human_attribute_name</code>が例外を投げるようにRails本体に修正を入れました。<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>今回はその修正について解説します。</p>

<p>修正の要点は2つあります。</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">ActiveModel::Translation</code>が例外を投げるオプションを加える。</li>
  <li>Railtiesからそのオプションを設定する。</li>
</ol>

<p>1点目は単純にオプションを追加して、そのオプションが有効の場合に例外を投げるようにするだけです。</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">--- a/activemodel/lib/active_model/translation.rb
</span><span class="gi">+++ b/activemodel/lib/active_model/translation.rb
</span><span class="p">@@ -22,6 +22,8 @@</span> module ActiveModel
   module Translation
     include ActiveModel::Naming

+    singleton_class.attr_accessor :raise_on_missing_translations
<span class="gi">+
</span>     # Returns the +i18n_scope+ for the class. Override if you want custom lookup.
     def i18n_scope
       :activemodel
<span class="p">@@ -60,13 +62,17 @@</span> def human_attribute_name(attribute, options = {})
         end
       end

+      raise_on_missing = options.fetch(:raise, Translation.raise_on_missing_translations)
<span class="gi">+
</span>       defaults &lt;&lt; :"attributes.#{attribute}"
       defaults &lt;&lt; options[:default] if options[:default]
<span class="gd">-      defaults &lt;&lt; MISSING_TRANSLATION
</span><span class="gi">+      defaults &lt;&lt; MISSING_TRANSLATION unless raise_on_missing
</span>
-      translation = I18n.translate(defaults.shift, count: 1, **options, default: defaults)
<span class="gi">+      translation = I18n.translate(defaults.shift, count: 1, raise: raise_on_missing, **options, default: defaults)
</span>       translation = attribute.humanize if translation == MISSING_TRANSLATION
       translation
     end
   end
</code></pre></div></div>

<p>2点目はRailtiesから<code class="language-plaintext highlighter-rouge">config.i18n.raise_on_missing_translations</code>を<code class="language-plaintext highlighter-rouge">ActiveModel::Translation</code>に伝播させてやります。ただしRailsは機能ごとに有効化・無効化出来るので直接伝播させることは出来ません。今回の場合だとActive Modelが無効化されている場合があるので、それを考慮してやる必要があります。そこで<code class="language-plaintext highlighter-rouge">ActiveSupport::LazyLoadHooks</code><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>の出番です。</p>

<p>Railsは機能ごとに有効化・無効化をするために内部的に粗結合に作られています。例えばAction CableではAction Viewがloadされた際に以下のコードを実行しています。これによりviewから<code class="language-plaintext highlighter-rouge">action_cable_meta_tag</code>が呼べるようになっています。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/rails/rails/blob/9f80efc79119037fc4421d06e94a0d7e076876a4/actioncable/lib/action_cable/engine.rb#L19-L23</span>
<span class="n">initializer</span> <span class="s2">"action_cable.helpers"</span> <span class="k">do</span>
  <span class="no">ActiveSupport</span><span class="p">.</span><span class="nf">on_load</span><span class="p">(</span><span class="ss">:action_view</span><span class="p">)</span> <span class="k">do</span>
    <span class="kp">include</span> <span class="no">ActionCable</span><span class="o">::</span><span class="no">Helpers</span><span class="o">::</span><span class="no">ActionCableHelper</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>この仕組みは外部のgemでも使われています。例えば<code class="language-plaintext highlighter-rouge">turbo-rails</code>を使うと<code class="language-plaintext highlighter-rouge">turbo_frame_tag</code>などのhelperが使えるようになるのは、以下のコードによりRails本体が拡張されているからです。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/hotwired/turbo-rails/blob/b0e7ebf2c7e2925c4d5fee4bf7d527c53ff4c1e3/lib/turbo/engine.rb#L59-L64</span>
<span class="n">initializer</span> <span class="s2">"turbo.helpers"</span><span class="p">,</span> <span class="ss">before: :load_config_initializers</span> <span class="k">do</span>
  <span class="no">ActiveSupport</span><span class="p">.</span><span class="nf">on_load</span><span class="p">(</span><span class="ss">:action_controller_base</span><span class="p">)</span> <span class="k">do</span>
    <span class="kp">include</span> <span class="no">Turbo</span><span class="o">::</span><span class="no">Streams</span><span class="o">::</span><span class="no">TurboStreamsTagBuilder</span><span class="p">,</span> <span class="no">Turbo</span><span class="o">::</span><span class="no">Frames</span><span class="o">::</span><span class="no">FrameRequest</span><span class="p">,</span> <span class="no">Turbo</span><span class="o">::</span><span class="no">Native</span><span class="o">::</span><span class="no">Navigation</span>
    <span class="n">helper</span> <span class="no">Turbo</span><span class="o">::</span><span class="no">Engine</span><span class="p">.</span><span class="nf">helpers</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>さて話を元に戻します。今回は<code class="language-plaintext highlighter-rouge">ActiveModel::Translation</code>がloadされた場合に限り<code class="language-plaintext highlighter-rouge">raise_on_missing_translations</code>を設定したいです。そこで<code class="language-plaintext highlighter-rouge">ActiveModel::Translation</code>がloadされた際のhookを追加します。</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">--- a/activemodel/lib/active_model/translation.rb
</span><span class="gi">+++ b/activemodel/lib/active_model/translation.rb
</span><span class="p">@@ -68,5 +68,7 @@</span> def human_attribute_name(attribute, options = {})
       translation = attribute.humanize if translation == MISSING_TRANSLATION
       translation
     end
<span class="gi">+
+    ActiveSupport.run_load_hooks(:active_model_translation, Translation)
</span>   end
 end
</code></pre></div></div>

<p>次にload時に実行されるコードを追加します。</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">--- a/activesupport/lib/active_support/i18n_railtie.rb
</span><span class="gi">+++ b/activesupport/lib/active_support/i18n_railtie.rb
</span><span class="p">@@ -83,6 +83,10 @@</span> def self.setup_raise_on_missing_translations_config(app)
         ActionView::Helpers::TranslationHelper.raise_on_missing_translations = app.config.i18n.raise_on_missing_translations
       end

+      ActiveSupport.on_load(:active_model_translation) do
<span class="gi">+        ActiveModel::Translation.raise_on_missing_translations = app.config.i18n.raise_on_missing_translations
+      end
+
</span>       if app.config.i18n.raise_on_missing_translations &amp;&amp;
           I18n.exception_handler.is_a?(I18n::ExceptionHandler) # Only override the i18n gem's default exception handler.
</code></pre></div></div>

<p>以上で翻訳がない場合に<code class="language-plaintext highlighter-rouge">human_attribute_name</code>が例外を投げるようになりました。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ActiveModel::Translation.raise_on_missing_translations = true</span>
<span class="no">Post</span><span class="p">.</span><span class="nf">human_attribute_name</span><span class="p">(</span><span class="s2">"title"</span><span class="p">)</span>
<span class="o">=&gt;</span> <span class="no">Translation</span> <span class="n">missing</span><span class="o">.</span> <span class="no">Options</span> <span class="n">considered</span> <span class="ss">were: </span><span class="p">(</span><span class="no">I18n</span><span class="o">::</span><span class="no">MissingTranslationData</span><span class="p">)</span>
    <span class="o">-</span> <span class="n">en</span><span class="p">.</span><span class="nf">activerecord</span><span class="p">.</span><span class="nf">attributes</span><span class="p">.</span><span class="nf">post</span><span class="p">.</span><span class="nf">title</span>
    <span class="o">-</span> <span class="n">en</span><span class="p">.</span><span class="nf">attributes</span><span class="p">.</span><span class="nf">title</span>

            <span class="k">raise</span> <span class="n">exception</span><span class="p">.</span><span class="nf">respond_to?</span><span class="p">(</span><span class="ss">:to_exception</span><span class="p">)</span> <span class="p">?</span> <span class="n">exception</span><span class="p">.</span><span class="nf">to_exception</span> <span class="p">:</span> <span class="n">exception</span>
                  <span class="o">^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^</span>
</code></pre></div></div>

<p>これにてviewで長大なI18nのkeyを書かずとも済むようになりました。めでたし、めでたし。</p>

<p>余談ですが、この変更は既存のアプリケーションへの影響が大きいとの指摘が入りました。そこで追加で以下の変更を加えようとしています。なおこの記事を書いている段階では取り込まれていません。</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">raise_on_missing_translations</code>が<code class="language-plaintext highlighter-rouge">:strict</code>の場合に限り例外を投げる。<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup></li>
  <li>modelごとに例外を投げる・投げないを設定出来る。<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></li>
</ul>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/52426">Change ActiveModel human_attribute_name to raise an error</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://api.rubyonrails.org/classes/ActiveSupport/LazyLoadHooks.html">ActiveSupport::LazyLoadHooks</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/52487">Change human_attribute_name to raise an error iff in strict mode</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/52495">Enable raising an error for missing translations on a per-model basis</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[Railsアプリケーションが日本語のみだとしても、I18nを使うと文言を一箇所に集約出来ます。これにより表記揺れし難くなる利点があります。この際に翻訳が存在しない場合に例外を投げるように設定しておくと、開発中に必ず気付けるので便利です。]]></summary></entry><entry><title type="html">テストではJSON.parseの代わりにparsed_bodyを使いましょう</title><link href="https://anipos.github.io/2024/04/19/a-hidden-gem-parsed-body.html" rel="alternate" type="text/html" title="テストではJSON.parseの代わりにparsed_bodyを使いましょう" /><published>2024-04-19T07:27:46+00:00</published><updated>2024-04-19T07:27:46+00:00</updated><id>https://anipos.github.io/2024/04/19/a-hidden-gem-parsed-body</id><content type="html" xml:base="https://anipos.github.io/2024/04/19/a-hidden-gem-parsed-body.html"><![CDATA[<p>APIのテストをする際に手で<code class="language-plaintext highlighter-rouge">JSON.parse</code>を呼んでいる人は多いでしょう。もちろんそれで問題がある訳ではないですが、実は<code class="language-plaintext highlighter-rouge">ActionDispatch::TestResponse#parsed_body</code>を呼び出せばMIME typeに応じてbodyをparseしてくれます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">get</span> <span class="n">posts_path</span>
<span class="n">response</span><span class="p">.</span><span class="nf">content_type</span>                  <span class="c1">#=&gt; "text/html; charset=utf-8"</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span>                   <span class="c1">#=&gt; Nokogiri::HTML5::Document</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span><span class="p">.</span><span class="nf">at_css</span><span class="p">(</span><span class="s2">"#posts"</span><span class="p">)</span>  <span class="c1">#=&gt; #&lt;Nokogiri::XML::Element:0xea24...</span>

<span class="n">get</span> <span class="n">posts_path</span><span class="p">,</span> <span class="ss">as: :json</span>
<span class="n">response</span><span class="p">.</span><span class="nf">content_type</span> <span class="c1">#=&gt; "application/json; charset=utf-8"</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span>  <span class="c1">#=&gt; Array</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span>  <span class="c1">#=&gt; [{"id"=&gt;42, "title"=&gt;"Title"},...</span>

<span class="n">get</span> <span class="n">post_path</span><span class="p">(</span><span class="n">post</span><span class="p">),</span> <span class="ss">as: :json</span>
<span class="n">response</span><span class="p">.</span><span class="nf">content_type</span> <span class="c1">#=&gt; "application/json; charset=utf-8"</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span>  <span class="c1">#=&gt; Hash</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span>  <span class="c1">#=&gt; {"id"=&gt;42, "title"=&gt;"Title"}</span>
</code></pre></div></div>

<p>Railsはdefaultでhtml/jsonに対応しています。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/rails/rails/blob/cc638d1c09f11ac1307ad887d5bb9e41d6be3aa5/actionpack/lib/action_dispatch/testing/request_encoder.rb#L57-L58</span>
<span class="k">module</span> <span class="nn">ActionDispatch</span>
  <span class="k">class</span> <span class="nc">RequestEncoder</span> <span class="c1"># :nodoc:</span>
    <span class="n">register_encoder</span> <span class="ss">:html</span><span class="p">,</span>
      <span class="ss">response_parser: </span><span class="o">-&gt;</span> <span class="n">body</span> <span class="p">{</span> <span class="no">Rails</span><span class="o">::</span><span class="no">Dom</span><span class="o">::</span><span class="no">Testing</span><span class="p">.</span><span class="nf">html_document</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="p">}</span>
    <span class="n">register_encoder</span> <span class="ss">:json</span><span class="p">,</span>
      <span class="ss">response_parser: </span><span class="o">-&gt;</span> <span class="n">body</span> <span class="p">{</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">body</span><span class="p">,</span> <span class="ss">object_class: </span><span class="no">ActiveSupport</span><span class="o">::</span><span class="no">HashWithIndifferentAccess</span><span class="p">)</span> <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>自分で別のformatを追加する事も出来ます。例えばxmlを追加してみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">ActionDispatch</span><span class="o">::</span><span class="no">IntegrationTest</span><span class="p">.</span><span class="nf">register_encoder</span> <span class="ss">:xml</span><span class="p">,</span>
  <span class="ss">param_encoder: </span><span class="o">-&gt;</span> <span class="n">params</span> <span class="p">{</span> <span class="n">params</span><span class="p">.</span><span class="nf">to_xml</span> <span class="p">},</span>
  <span class="ss">response_parser: </span><span class="o">-&gt;</span> <span class="n">body</span> <span class="p">{</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="p">}</span>

<span class="n">get</span> <span class="n">posts_path</span><span class="p">,</span> <span class="ss">as: :xml</span>
<span class="n">response</span><span class="p">.</span><span class="nf">content_type</span>                                  <span class="c1">#=&gt; "application/xml; charset=utf-8"</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span>                                   <span class="c1">#=&gt; REXML::Document</span>
<span class="n">response</span><span class="p">.</span><span class="nf">parsed_body</span><span class="p">.</span><span class="nf">elements</span><span class="p">[</span><span class="s2">"posts/post[1]/id"</span><span class="p">].</span><span class="nf">text</span> <span class="c1">#=&gt; "42"</span>
</code></pre></div></div>

<p>Railsはこの様にちょっと気の利いた機能があり、かつそれが拡張可能になっていて良いですね。</p>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[APIのテストをする際に手でJSON.parseを呼んでいる人は多いでしょう。もちろんそれで問題がある訳ではないですが、実はActionDispatch::TestResponse#parsed_bodyを呼び出せばMIME typeに応じてbodyをparseしてくれます。]]></summary></entry><entry><title type="html">Go 1.22に更新したらTLSエラーが発生する</title><link href="https://anipos.github.io/2024/03/16/go1.22-tls-handshake-failure.html" rel="alternate" type="text/html" title="Go 1.22に更新したらTLSエラーが発生する" /><published>2024-03-16T13:01:33+00:00</published><updated>2024-03-16T13:01:33+00:00</updated><id>https://anipos.github.io/2024/03/16/go1.22-tls-handshake-failure</id><content type="html" xml:base="https://anipos.github.io/2024/03/16/go1.22-tls-handshake-failure.html"><![CDATA[<p><strong>※この記事はアニポスとは直接関係がありません。</strong></p>

<p>個人的にメンテナンスしているサービスのgoのversionを1.21から1.22に更新したところ外部サービスとの通信で以下のエラーが発生するようになりました。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>remote error: tls: handshake failure
</code></pre></div></div>

<p>Goのリリースノートを確認したところ、TLS通信の際のdefault cipher suitesからECDHEが削除されていました。</p>

<blockquote>
  <p>https://tip.golang.org/doc/go1.22
By default, cipher suites without ECDHE support are no longer offered by either clients or servers during pre-TLS 1.3 handshakes. This change can be reverted with the tlsrsakex=1 GODEBUG setting.</p>
</blockquote>

<p>実際に<code class="language-plaintext highlighter-rouge">GODEBUG</code>環境変数に<code class="language-plaintext highlighter-rouge">tlsrsakex=1</code>を指定すると正常に通信出来るようになりました。削除されたcipher suitesを確認してみます<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>。</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff --git a/src/crypto/tls/cipher_suites.go b/src/crypto/tls/cipher_suites.go
index af7c64c..6f5bc37 100644
</span><span class="gd">--- a/src/crypto/tls/cipher_suites.go
</span><span class="gi">+++ b/src/crypto/tls/cipher_suites.go
</span><span class="p">@@ -52,11 +52,6 @@</span>
 // and might not match those returned by this function.
 func CipherSuites() []*CipherSuite {
 	return []*CipherSuite{
<span class="gd">-		{TLS_RSA_WITH_AES_128_CBC_SHA, "TLS_RSA_WITH_AES_128_CBC_SHA", supportedUpToTLS12, false},
-		{TLS_RSA_WITH_AES_256_CBC_SHA, "TLS_RSA_WITH_AES_256_CBC_SHA", supportedUpToTLS12, false},
-		{TLS_RSA_WITH_AES_128_GCM_SHA256, "TLS_RSA_WITH_AES_128_GCM_SHA256", supportedOnlyTLS12, false},
-		{TLS_RSA_WITH_AES_256_GCM_SHA384, "TLS_RSA_WITH_AES_256_GCM_SHA384", supportedOnlyTLS12, false},
-
</span> 		{TLS_AES_128_GCM_SHA256, "TLS_AES_128_GCM_SHA256", supportedOnlyTLS13, false},
 		{TLS_AES_256_GCM_SHA384, "TLS_AES_256_GCM_SHA384", supportedOnlyTLS13, false},
 		{TLS_CHACHA20_POLY1305_SHA256, "TLS_CHACHA20_POLY1305_SHA256", supportedOnlyTLS13, false},
<span class="p">@@ -85,7 +80,11 @@</span>
 	return []*CipherSuite{
 		{TLS_RSA_WITH_RC4_128_SHA, "TLS_RSA_WITH_RC4_128_SHA", supportedUpToTLS12, true},
 		{TLS_RSA_WITH_3DES_EDE_CBC_SHA, "TLS_RSA_WITH_3DES_EDE_CBC_SHA", supportedUpToTLS12, true},
<span class="gi">+		{TLS_RSA_WITH_AES_128_CBC_SHA, "TLS_RSA_WITH_AES_128_CBC_SHA", supportedUpToTLS12, true},
+		{TLS_RSA_WITH_AES_256_CBC_SHA, "TLS_RSA_WITH_AES_256_CBC_SHA", supportedUpToTLS12, true},
</span> 		{TLS_RSA_WITH_AES_128_CBC_SHA256, "TLS_RSA_WITH_AES_128_CBC_SHA256", supportedOnlyTLS12, true},
<span class="gi">+		{TLS_RSA_WITH_AES_128_GCM_SHA256, "TLS_RSA_WITH_AES_128_GCM_SHA256", supportedOnlyTLS12, true},
+		{TLS_RSA_WITH_AES_256_GCM_SHA384, "TLS_RSA_WITH_AES_256_GCM_SHA384", supportedOnlyTLS12, true},
</span> 		{TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", supportedUpToTLS12, true},
 		{TLS_ECDHE_RSA_WITH_RC4_128_SHA, "TLS_ECDHE_RSA_WITH_RC4_128_SHA", supportedUpToTLS12, true},
 		{TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", supportedUpToTLS12, true},
</code></pre></div></div>

<p>では削除された方式の中でどの方式を使えば良いのかopensslで確認してみます。</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>openssl s_client <span class="nt">-connect</span> example.com:443
<span class="nt">---</span>
New, TLSv1.2, Cipher is DHE-RSA-AES256-GCM-SHA384
Server public key is 2048 bit
Secure Renegotiation IS supported
No ALPN negotiated
SSL-Session:
    Protocol  : TLSv1.2
    Cipher    : DHE-RSA-AES256-GCM-SHA384
    Session-ID: 2489D581D975E20E29A5EBD348B36E39CAA2D077E8BDB6A7E5F92531EB3926BB
<span class="nt">---</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DHE-RSA-AES256-GCM-SHA384</code>を指定して回避は完了しました。※外部サービスには安全な方式に対応して貰えるように依頼済みです。</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">http</span><span class="o">.</span><span class="n">Client</span><span class="p">{</span>
    <span class="n">Transport</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">http</span><span class="o">.</span><span class="n">Transport</span><span class="p">{</span>
        <span class="n">TLSClientConfig</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">tls</span><span class="o">.</span><span class="n">Config</span><span class="p">{</span>
            <span class="n">CipherSuites</span><span class="o">:</span> <span class="p">[]</span><span class="kt">uint16</span><span class="p">{</span><span class="n">tls</span><span class="o">.</span><span class="n">TLS_RSA_WITH_AES_256_GCM_SHA384</span><span class="p">},</span>
        <span class="p">},</span>
    <span class="p">},</span>
<span class="p">}</span>
</code></pre></div></div>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://go-review.googlesource.com/c/go/+/544336">crypto/tls: mark RSA KEX cipher suites insecure</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="go" /><category term="tls" /><summary type="html"><![CDATA[※この記事はアニポスとは直接関係がありません。]]></summary></entry><entry><title type="html">同じ名前のcookieが複数ある場合の仕様</title><link href="https://anipos.github.io/2024/03/08/cookie-list-order.html" rel="alternate" type="text/html" title="同じ名前のcookieが複数ある場合の仕様" /><published>2024-03-08T06:43:44+00:00</published><updated>2024-03-08T06:43:44+00:00</updated><id>https://anipos.github.io/2024/03/08/cookie-list-order</id><content type="html" xml:base="https://anipos.github.io/2024/03/08/cookie-list-order.html"><![CDATA[<p>1つのRailsアプリケーションを複数のsubdomainで動かしているとします。デフォルトでsession storeはcookieなのでブラウザは以下のようなcookieを保持している状態になります。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_my_app=ABC; domain=www.example.com
</code></pre></div></div>

<p>Subdomain間でログイン状態を維持したくなったので、subdomain間で同じcookieを送る設定に変更したとします。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">MyApp</span>
  <span class="k">class</span> <span class="nc">Application</span> <span class="o">&lt;</span> <span class="no">Rails</span><span class="o">::</span><span class="no">Application</span>
    <span class="n">config</span><span class="p">.</span><span class="nf">session_store</span> <span class="ss">:cookie_store</span><span class="p">,</span> <span class="ss">key: </span><span class="s2">"_my_app"</span><span class="p">,</span> <span class="ss">domain: :all</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>変更後、sessionに値を書き込んで<code class="language-plaintext highlighter-rouge">Set-Cookie: _my_app=GHI; domain=.example.com</code>を返却したとします。するとブラウザは以下2つのcookieを保持することになります。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_my_app=ABC; domain=www.example.com
_my_app=EFG; domain=.example.com
</code></pre></div></div>

<p>この状態でブラウザが送信するリクエストのヘッダーは<code class="language-plaintext highlighter-rouge">Cookie: _my_app=ABC; _my_app=GHI</code>になります。同じ名前のcookieが複数ありますが、Railsからすると<code class="language-plaintext highlighter-rouge">ABC</code>の方のみしか見えません。結果としてsessionに書き込んだ値は全て無視されてしまいます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/rack/rack/blob/64ad26e3381da2ce1853638a2c4ea241c2ad3729/lib/rack/utils.rb#L223-L231</span>
<span class="k">def</span> <span class="nf">parse_cookies_header</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
  <span class="k">return</span> <span class="p">{}</span> <span class="k">unless</span> <span class="n">value</span>

  <span class="n">value</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="sr">/; */n</span><span class="p">).</span><span class="nf">each_with_object</span><span class="p">({})</span> <span class="k">do</span> <span class="o">|</span><span class="n">cookie</span><span class="p">,</span> <span class="n">cookies</span><span class="o">|</span>
    <span class="k">next</span> <span class="k">if</span> <span class="n">cookie</span><span class="p">.</span><span class="nf">empty?</span>
    <span class="n">key</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="n">cookie</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">'='</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
    <span class="c1"># unless cookies.key?してるので、2つ目以降は無視される。</span>
    <span class="n">cookies</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">unescape</span><span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="k">rescue</span> <span class="n">value</span><span class="p">)</span> <span class="k">unless</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">key?</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>対応としては単にcookie storeのkeyを変更すれば十分です。ただ、ブラウザがcookieを組み立てる際の順番が気になります。先ずはRFC<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>で仕様を確認してみましょう。</p>

<blockquote>
  <p>2 . The user agent SHOULD sort the cookie-list in the following
order:</p>

  <ul>
    <li>Cookies with longer paths are listed before cookies with
shorter paths.</li>
    <li>Among cookies that have equal-length path fields, cookies with
earlier creation-times are listed before cookies with later
creation-times.</li>
  </ul>
</blockquote>

<p>Pathが長い方を優先し、同じ場合は作成時刻が早い方を優先するとされています。最後に、この仕様通りに実装されているかChromiumのコードを覗いたところ、確かに仕様通りに実装されている事が確認出来ました。</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// https://github.com/chromium/chromium/blob/36b5627a5247893ed3cbfbc2fd569dc406b0b570/net/cookies/cookie_monster.cc#L580-L588</span>
<span class="kt">bool</span> <span class="n">CookieMonster</span><span class="o">::</span><span class="n">CookieSorter</span><span class="p">(</span><span class="k">const</span> <span class="n">CanonicalCookie</span><span class="o">*</span> <span class="n">cc1</span><span class="p">,</span>
                                 <span class="k">const</span> <span class="n">CanonicalCookie</span><span class="o">*</span> <span class="n">cc2</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// Mozilla sorts on the path length (longest first), and then it sorts by</span>
  <span class="c1">// creation time (oldest first).  The RFC says the sort order for the domain</span>
  <span class="c1">// attribute is undefined.</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">cc1</span><span class="o">-&gt;</span><span class="n">Path</span><span class="p">().</span><span class="n">length</span><span class="p">()</span> <span class="o">==</span> <span class="n">cc2</span><span class="o">-&gt;</span><span class="n">Path</span><span class="p">().</span><span class="n">length</span><span class="p">())</span>
    <span class="k">return</span> <span class="n">cc1</span><span class="o">-&gt;</span><span class="n">CreationDate</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">cc2</span><span class="o">-&gt;</span><span class="n">CreationDate</span><span class="p">();</span>
  <span class="k">return</span> <span class="n">cc1</span><span class="o">-&gt;</span><span class="n">Path</span><span class="p">().</span><span class="n">length</span><span class="p">()</span> <span class="o">&gt;</span> <span class="n">cc2</span><span class="o">-&gt;</span><span class="n">Path</span><span class="p">().</span><span class="n">length</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://www.rfc-editor.org/rfc/rfc6265">HTTP State Management Mechanism</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="http" /><category term="rails" /><summary type="html"><![CDATA[1つのRailsアプリケーションを複数のsubdomainで動かしているとします。デフォルトでsession storeはcookieなのでブラウザは以下のようなcookieを保持している状態になります。]]></summary></entry><entry><title type="html">Active Storageの仕組み（その2）</title><link href="https://anipos.github.io/2024/02/23/active-storage-internals-part-2.html" rel="alternate" type="text/html" title="Active Storageの仕組み（その2）" /><published>2024-02-23T02:34:36+00:00</published><updated>2024-02-23T02:34:36+00:00</updated><id>https://anipos.github.io/2024/02/23/active-storage-internals-part-2</id><content type="html" xml:base="https://anipos.github.io/2024/02/23/active-storage-internals-part-2.html"><![CDATA[<p>前回<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>に引き続きActive Storageの仕組みを見てみましょう。前回は<code class="language-plaintext highlighter-rouge">image_tag user.avatar</code>が、<code class="language-plaintext highlighter-rouge">ActiveStorage::Blobs::RedirectController#show</code>にroutingされることを突き止めました。今回はその中身を見ることから始めてみましょう。</p>

<p><strong>※抜粋するRailsのコードは説明のために大幅に編集・簡略化してあります。</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ActiveStorage::Blobs::RedirectController</span> <span class="o">&lt;</span> <span class="no">ActiveStorage</span><span class="o">::</span><span class="no">BaseController</span>
  <span class="k">def</span> <span class="nf">show</span>
    <span class="n">redirect_to</span> <span class="vi">@blob</span><span class="p">.</span><span class="nf">url</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ActiveStorage::Blob#url</code>でURLを生成し、そこにリダイレクトしています。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ActiveStorage::Blob</span> <span class="o">&lt;</span> <span class="no">ActiveStorage</span><span class="o">::</span><span class="no">Record</span>
  <span class="k">def</span> <span class="nf">url</span>
    <span class="n">service</span><span class="p">.</span><span class="nf">url</span> <span class="n">key</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ActiveStorage::Blob#service</code>を呼び出しているだけで、その実体は<code class="language-plaintext highlighter-rouge">ActiveStorage::Service::DiskService</code>です。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">irb</span><span class="o">&gt;</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span><span class="p">.</span><span class="nf">build_avatar_blob</span><span class="p">.</span><span class="nf">service</span>
<span class="o">=&gt;</span> <span class="c1">#&lt;ActiveStorage::Service::DiskService:0x00007fbe6e1eff58 @name=:local, @public=false&gt;</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">ActiveStorage</span>
  <span class="k">class</span> <span class="nc">Service::DiskService</span> <span class="o">&lt;</span> <span class="no">Service</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>さて、<code class="language-plaintext highlighter-rouge">ActiveStorage::Service::DiskService</code>のファイルを読んでも<code class="language-plaintext highlighter-rouge">#url</code>は見当たりません。困ったようですが、Rubyの<code class="language-plaintext highlighter-rouge">source_location</code><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>を使えば、定義ファイルと行を調べられます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">irb</span><span class="o">&gt;</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span><span class="p">.</span><span class="nf">build_avatar_blob</span><span class="p">.</span><span class="nf">service</span><span class="p">.</span><span class="nf">method</span><span class="p">(</span><span class="ss">:url</span><span class="p">).</span><span class="nf">source_location</span>
<span class="o">=&gt;</span> <span class="p">[</span><span class="s2">"activestorage-7.1.3/lib/active_storage/service.rb"</span><span class="p">,</span> <span class="mi">119</span><span class="p">]</span>
</code></pre></div></div>

<p>親クラスに定義されていました、その中身を確認してみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">ActiveStorage</span>
  <span class="k">class</span> <span class="nc">Service</span>
    <span class="k">def</span> <span class="nf">url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">)</span>
      <span class="k">if</span> <span class="kp">public</span><span class="p">?</span>
        <span class="n">public_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">)</span>
      <span class="k">else</span>
        <span class="n">private_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">public_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="o">**</span><span class="p">)</span>
      <span class="k">raise</span> <span class="no">NotImplementedError</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">private_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="o">**</span><span class="p">)</span>
      <span class="k">raise</span> <span class="no">NotImplementedError</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>実装は子クラスにする想定のようです。<code class="language-plaintext highlighter-rouge">ActiveStorage::Service::DiskService</code>に戻りましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">ActiveStorage</span>
  <span class="k">class</span> <span class="nc">Service::DiskService</span> <span class="o">&lt;</span> <span class="no">Service</span>
    <span class="k">def</span> <span class="nf">private_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">expires_in</span><span class="p">:)</span>
      <span class="n">generate_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">expires_in: </span><span class="n">expires_in</span><span class="p">)</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">public_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">filename</span><span class="p">:)</span>
      <span class="n">generate_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">expires_in: </span><span class="kp">nil</span><span class="p">)</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">generate_url</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">expires_in</span><span class="p">:)</span>
      <span class="n">verified_key_with_expiration</span> <span class="o">=</span> <span class="no">ActiveStorage</span><span class="p">.</span><span class="nf">verifier</span><span class="p">.</span><span class="nf">generate</span><span class="p">(</span>
        <span class="p">{</span>
          <span class="ss">key: </span><span class="n">key</span><span class="p">,</span>
        <span class="p">},</span>
        <span class="ss">expires_in: </span><span class="n">expires_in</span><span class="p">,</span>
      <span class="p">)</span>

      <span class="n">url_helpers</span><span class="p">.</span><span class="nf">rails_disk_service_url</span><span class="p">(</span><span class="n">verified_key_with_expiration</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">private_url</code>と<code class="language-plaintext highlighter-rouge">public_url</code>の違いは有効期限の有無のみで、URLの生成は<code class="language-plaintext highlighter-rouge">generate_url</code>が担っています。<code class="language-plaintext highlighter-rouge">MessageVerifier</code><sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>で<code class="language-plaintext highlighter-rouge">Blob#key</code>を署名、URL helperでURLを生成しています。</p>

<p><code class="language-plaintext highlighter-rouge">rails_disk_service_url</code>はActive Storageの<code class="language-plaintext highlighter-rouge">routes.rb</code>で定義されています。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">get</span>  <span class="s2">"/disk/:encoded_key/*filename"</span> <span class="o">=&gt;</span> <span class="s2">"active_storage/disk#show"</span><span class="p">,</span>
  <span class="ss">as: :rails_disk_service</span>
</code></pre></div></div>

<p>Routing先のcontrollerを見てみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ActiveStorage::DiskController</span> <span class="o">&lt;</span> <span class="no">ActiveStorage</span><span class="o">::</span><span class="no">BaseController</span>
  <span class="k">def</span> <span class="nf">show</span>
    <span class="k">if</span> <span class="n">key</span> <span class="o">=</span> <span class="n">decode_verified_key</span>
      <span class="n">serve_file</span> <span class="n">named_disk_service</span><span class="p">(</span><span class="n">key</span><span class="p">[</span><span class="ss">:service_name</span><span class="p">]).</span><span class="nf">path_for</span><span class="p">(</span><span class="n">key</span><span class="p">[</span><span class="ss">:key</span><span class="p">]))</span>
    <span class="k">else</span>
      <span class="n">head</span> <span class="ss">:not_found</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>
    <span class="k">def</span> <span class="nf">named_disk_service</span><span class="p">(</span><span class="nb">name</span><span class="p">)</span>
      <span class="no">ActiveStorage</span><span class="o">::</span><span class="no">Blob</span><span class="p">.</span><span class="nf">services</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="nb">name</span><span class="p">)</span> <span class="k">do</span>
        <span class="no">ActiveStorage</span><span class="o">::</span><span class="no">Blob</span><span class="p">.</span><span class="nf">service</span>
      <span class="k">end</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">decode_verified_key</span>
      <span class="n">key</span> <span class="o">=</span> <span class="no">ActiveStorage</span><span class="p">.</span><span class="nf">verifier</span><span class="p">.</span><span class="nf">verified</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:encoded_key</span><span class="p">])</span>
      <span class="n">key</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">deep_symbolize_keys</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">serve_file</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
      <span class="no">Rack</span><span class="o">::</span><span class="no">Files</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="kp">nil</span><span class="p">).</span><span class="nf">serving</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">path</span><span class="p">).</span><span class="nf">tap</span> <span class="k">do</span> <span class="o">|</span><span class="p">(</span><span class="n">status</span><span class="p">,</span> <span class="n">headers</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span><span class="o">|</span>
        <span class="nb">self</span><span class="p">.</span><span class="nf">status</span> <span class="o">=</span> <span class="n">status</span>
        <span class="nb">self</span><span class="p">.</span><span class="nf">response_body</span> <span class="o">=</span> <span class="n">body</span>

        <span class="n">headers</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">value</span><span class="o">|</span>
          <span class="n">response</span><span class="p">.</span><span class="nf">headers</span><span class="p">[</span><span class="nb">name</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MessageVerifier</code>で署名した<code class="language-plaintext highlighter-rouge">Blob#key</code>を取り出し、<code class="language-plaintext highlighter-rouge">DiskService#path_for</code>で対応するファイルのパスを得ています。実際にファイルを送る処理は<code class="language-plaintext highlighter-rouge">Rack::Files</code>に委譲しています。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">ActiveStorage</span>
  <span class="k">class</span> <span class="nc">Service::DiskService</span> <span class="o">&lt;</span> <span class="no">Service</span>
    <span class="k">def</span> <span class="nf">path_for</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
      <span class="no">File</span><span class="p">.</span><span class="nf">join</span> <span class="n">root</span><span class="p">,</span> <span class="n">folder_for</span><span class="p">(</span><span class="n">key</span><span class="p">),</span> <span class="n">key</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">folder_for</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
      <span class="p">[</span> <span class="n">key</span><span class="p">[</span><span class="mi">0</span><span class="o">..</span><span class="mi">1</span><span class="p">],</span> <span class="n">key</span><span class="p">[</span><span class="mi">2</span><span class="o">..</span><span class="mi">3</span><span class="p">]</span> <span class="p">].</span><span class="nf">join</span><span class="p">(</span><span class="s2">"/"</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>ファイルのパスを返しているだけですが、フォルダを階層化している点は注目に値します。これは1つのフォルダに置けるファイル数の上限と、パフォーマンスのためと思われます<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>（GitHub<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup><sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>に背景が書いておらず、はっきりとした理由は不明です）。</p>

<p>これにてActive Storageがファイルを返すまでの動きを理解することが出来ました。</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="/2024/02/16/active-storage-internals-part-1.html">Active Storageの仕組み（その1）</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://docs.ruby-lang.org/en/3.3/Method.html#method-i-source_location">class Method - Documentation for Ruby 3.3</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html">ActiveSupport::MessageVerifier</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://stackoverflow.com/questions/466521/how-many-files-can-i-put-in-a-directory">How many files can I put in a directory?</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/30019">Add Active Storage to Rails</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/30020">Add Active Storage to Rails</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[前回1に引き続きActive Storageの仕組みを見てみましょう。前回はimage_tag user.avatarが、ActiveStorage::Blobs::RedirectController#showにroutingされることを突き止めました。今回はその中身を見ることから始めてみましょう。 Active Storageの仕組み（その1） &#8617;]]></summary></entry><entry><title type="html">Active Storageの仕組み（その1）</title><link href="https://anipos.github.io/2024/02/16/active-storage-internals-part-1.html" rel="alternate" type="text/html" title="Active Storageの仕組み（その1）" /><published>2024-02-16T10:35:57+00:00</published><updated>2024-02-16T10:35:57+00:00</updated><id>https://anipos.github.io/2024/02/16/active-storage-internals-part-1</id><content type="html" xml:base="https://anipos.github.io/2024/02/16/active-storage-internals-part-1.html"><![CDATA[<p>今回から何回かに分けてActive Storage<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>の仕組みを紐解いてみましょう。 さて、早速ですが公式ガイドにならい<code class="language-plaintext highlighter-rouge">User</code>が<code class="language-plaintext highlighter-rouge">avatar</code>を持っているとします。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_one_attached</span> <span class="ss">:avatar</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">avatar</code>を表示するには<code class="language-plaintext highlighter-rouge">image_tag</code>に<code class="language-plaintext highlighter-rouge">user.avatar</code>を渡すだけです。</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">image_tag</span> <span class="n">user</span><span class="p">.</span><span class="nf">avatar</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>すると以下のようなリンクが生成されます。</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span>
  <span class="na">src=</span><span class="s">"/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--22989801b78c27abf7bc8c8b023cac322a42fbea/dog.jpg"</span>
<span class="nt">/&gt;</span>
</code></pre></div></div>

<p>ここで連鎖的に疑問が生まれます。</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">user.avatar</code>を渡すと<code class="language-plaintext highlighter-rouge">/rails/active_storage/blob/redirect/...</code>なるURLが生成されたのは何故か。</li>
  <li>Railsの規約に従うなら<code class="language-plaintext highlighter-rouge">/avatars/1</code>のようなURLが生成されるべきではないのか。</li>
  <li>そもそも<code class="language-plaintext highlighter-rouge">user.avatar</code>とは何か。</li>
</ol>

<p>順番に調べてみましょう。まずは<code class="language-plaintext highlighter-rouge">user.avatar</code>の実態は何かを調べます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">irb</span><span class="o">&gt;</span> <span class="no">User</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">avatar</span>
<span class="c1">#&lt;ActiveStorage::Attached::One:0x00007ff680e052a8</span>
 <span class="vi">@name</span><span class="o">=</span><span class="s2">"avatar"</span><span class="p">,</span>
 <span class="vi">@record</span><span class="o">=</span>
  <span class="c1">#&lt;User:0x00007ff68104ab58&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ActiveStorage::Attached::One</code>が返却されました。</p>

<p><strong>※以下より後に抜粋するRailsのコードは説明のために大幅に編集・簡略化してあります。</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">ActiveStorage</span>
  <span class="k">class</span> <span class="nc">Attached</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">Attached::One</span> <span class="o">&lt;</span> <span class="no">Attached</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>どうやら<code class="language-plaintext highlighter-rouge">ActiveStorage::Attached::One</code>はPORO（Plain Old Ruby Object）のようです。言い換えるとActive Recordを継承している訳ではないので、<code class="language-plaintext highlighter-rouge">link_to</code>や<code class="language-plaintext highlighter-rouge">image_tag</code>に渡すとエラーになりそうなものです。実際にPOROを<code class="language-plaintext highlighter-rouge">link_to</code>に渡すと以下のエラーが発生します。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Post</span> <span class="c1"># ApplicationRecordから継承してないことに注意。</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Post"</span><span class="p">,</span> <span class="no">Post</span><span class="p">.</span><span class="nf">new</span> <span class="cp">%&gt;</span>
#=&gt; undefined method `to_model' for #<span class="nt">&lt;Post:0x00007fe354a9aeb8&gt;</span>
</code></pre></div></div>

<p>ではPOROであるにも関わらず<code class="language-plaintext highlighter-rouge">ActiveStorage::Attached::One</code>がエラーにならないのは何故でしょうか。中身をもう少し掘り下げてみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">ActiveStorage</span>
  <span class="k">class</span> <span class="nc">Attached::One</span> <span class="o">&lt;</span> <span class="no">Attached</span>
    <span class="n">delegate_missing_to</span> <span class="ss">:attachment</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>どうやら<code class="language-plaintext highlighter-rouge">delegate_missing_to</code>大体の処理を<code class="language-plaintext highlighter-rouge">attachment</code>に委譲しているようです。そこで<code class="language-plaintext highlighter-rouge">attachment</code>の正体を探ってみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">irb</span><span class="o">&gt;</span> <span class="no">User</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">avatar</span><span class="p">.</span><span class="nf">attachment</span>
<span class="o">=&gt;</span> <span class="c1">#&lt;ActiveStorage::Attachment:0x00007f8f12b568e8 id: 1, name: "avatar", record_type: "User", record_id: 1, blob_id: 1&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">attachment</code>の正体は<code class="language-plaintext highlighter-rouge">ActiveStorage::Attachment</code>だと分かりました。これの実装を確認してみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ActiveStorage::Attachment</span> <span class="o">&lt;</span> <span class="no">ActiveStorage</span><span class="o">::</span><span class="no">Record</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">ActiveStorage::Record</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">abstract_class</span> <span class="o">=</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ActiveRecord::Attachment</code>は<code class="language-plaintext highlighter-rouge">ActiveRecord::Base</code>を継承していました。本丸に近付いて来たようですが、やはり生成されるURLがRailsの規約に従っておらず、疑問が残ります。</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- 実際に生成されたURL --&gt;</span>
<span class="nt">&lt;img</span>
  <span class="na">src=</span><span class="s">"/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--22989801b78c27abf7bc8c8b023cac322a42fbea/dog.jpg"</span>
<span class="nt">/&gt;</span>

<span class="c">&lt;!-- Railsの規約に従えば生成されるであろうURL --&gt;</span>
<span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"/active_storage/attachments/1"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>さて、最後の鍵はActive Storageの<code class="language-plaintext highlighter-rouge">routes.rb</code>にあります。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">resolve</span><span class="p">(</span><span class="s2">"ActiveStorage::Attachment"</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">attachment</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span><span class="p">(</span><span class="ss">:rails_storage_redirect</span><span class="p">,</span> <span class="n">attachment</span><span class="p">.</span><span class="nf">blob</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>上記により<code class="language-plaintext highlighter-rouge">link_to</code>などに<code class="language-plaintext highlighter-rouge">ActiveStorage::Attachment</code>が渡された場合、<code class="language-plaintext highlighter-rouge">rails_storage_redirect</code>を呼び出すようにしています。これがRailsの規約ではないURLが生成されていた理由です。では次に<code class="language-plaintext highlighter-rouge">rails_storage_redirect</code>の中身を見ましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">direct</span> <span class="ss">:rails_storage_redirect</span> <span class="k">do</span> <span class="o">|</span><span class="n">model</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span><span class="p">(</span><span class="ss">:rails_service_blob</span><span class="p">,</span> <span class="n">model</span><span class="p">.</span><span class="nf">signed_id</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>これも<code class="language-plaintext highlighter-rouge">rails_service_blob</code>を呼び出しているだけですが、注目すべき点が1つあります。それは呼び出しの際に<code class="language-plaintext highlighter-rouge">model.signed_id</code><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>を呼び出している点です。これが生成されたURLにランダムな文字列が含まれていた理由です。実際に<code class="language-plaintext highlighter-rouge">signed_id</code>を呼び出してみると、URLの文字列と一致してることが分かります。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">irb</span><span class="o">&gt;</span> <span class="no">User</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">avatar</span><span class="p">.</span><span class="nf">attachment</span><span class="p">.</span><span class="nf">blob</span><span class="p">.</span><span class="nf">signed_id</span>
<span class="c1">#=&gt; "eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--22989801b78c27abf7bc8c8b023cac322a42fbea"</span>
</code></pre></div></div>

<p>最後に<code class="language-plaintext highlighter-rouge">rails_service_blob</code>ですが、これは<code class="language-plaintext highlighter-rouge">ActiveStorage::Blobs::RedirectController#show</code>を呼び出しているだけです。この部分の記法は馴染み深いでしょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">get</span> <span class="s2">"/blobs/redirect/:signed_id/*filename"</span> <span class="o">=&gt;</span> <span class="s2">"active_storage/blobs/redirect#show"</span><span class="p">,</span> <span class="ss">as: :rails_service_blob</span>
</code></pre></div></div>

<p>さて、これにて<code class="language-plaintext highlighter-rouge">user.avatar</code>からURLが生成される仕組みを解明出来ました。<code class="language-plaintext highlighter-rouge">routes.rb</code>で使用されていた<code class="language-plaintext highlighter-rouge">resolve</code>や<code class="language-plaintext highlighter-rouge">direct</code>は、日常的に使うものではありませんが、Active Storageではその仕組みを上手に使ってURLを生成していることが分かりました。Railsのroutingは上手に設定すると非常に便利です。改めて公式ガイド<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>を読むことをお勧めします。</p>

<p>長くなってしまったので今回はここまでとします。次回は<code class="language-plaintext highlighter-rouge">ActiveStorage::Blobs::RedirectController#show</code>の中身から続きを確認しましょう。</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://edgeguides.rubyonrails.org/active_storage_overview.html">Active Storage Overview</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://api.rubyonrails.org/classes/ActiveRecord/SignedId.html#method-i-signed_id">ActiveRecord::SignedId</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://guides.rubyonrails.org/routing.html">Rails Routing from the Outside In</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[今回から何回かに分けてActive Storage1の仕組みを紐解いてみましょう。 さて、早速ですが公式ガイドにならいUserがavatarを持っているとします。 Active Storage Overview &#8617;]]></summary></entry><entry><title type="html">rails consoleをdefaultでsandboxモードで起動する</title><link href="https://anipos.github.io/2024/02/14/open-rails-console-in-sandbox-mode-by-default.html" rel="alternate" type="text/html" title="rails consoleをdefaultでsandboxモードで起動する" /><published>2024-02-14T11:07:48+00:00</published><updated>2024-02-14T11:07:48+00:00</updated><id>https://anipos.github.io/2024/02/14/open-rails-console-in-sandbox-mode-by-default</id><content type="html" xml:base="https://anipos.github.io/2024/02/14/open-rails-console-in-sandbox-mode-by-default.html"><![CDATA[<p>バグの調査などのために本番環境で<code class="language-plaintext highlighter-rouge">rails console</code>を開く必要に迫られることはあります。このとき意図せずに本番データを書き換えてしまわないように注意が必要です。Railsは安全に見えるmethodでも副作用を伴う場合があります。例えば<code class="language-plaintext highlighter-rouge">has_one</code>で宣言したrelationに対して代入すると、即座にSQLが発行されます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_one</span> <span class="ss">:profile</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>
<span class="k">end</span>

<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">profile: </span><span class="no">Profile</span><span class="p">.</span><span class="nf">new</span><span class="p">)</span>
<span class="c1"># INSERT INTO users;</span>
<span class="c1"># INSERT INTO profiles (user_id) VALUES (1);</span>

<span class="n">user</span><span class="p">.</span><span class="nf">profile</span> <span class="o">=</span> <span class="no">Profile</span><span class="p">.</span><span class="nf">new</span>
<span class="c1"># DELETE FROM profiles WHERE id = 1;</span>
<span class="c1"># INSERT INTO profiles (user_id) VALUES (1);</span>
</code></pre></div></div>

<p>このようなミスを防止するためには<code class="language-plaintext highlighter-rouge">rails console --sandbox</code>とsandboxモードで起動するのが良いでしょう。Sandboxモードで開いた<code class="language-plaintext highlighter-rouge">rails console</code>はtransactionで囲われており、consoleを終了した際に全てがrollbackされます。※データベースへの操作は全てrollbackされますが、それ以外（例えばredisへの書き込み）はrollbackされないことに注意しましょう。</p>

<p>TODO(shouichi): 該当のrailsコードを貼る。</p>

<p>ただし毎回<code class="language-plaintext highlighter-rouge">--sandbox</code>を指定するのは面倒ですし、指定を忘れかねません。こんな時に便利なのがRails 7.1で入った<code class="language-plaintext highlighter-rouge">sandbox_by_default</code>オプションです。<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">YourRailsApp</span>
  <span class="k">class</span> <span class="nc">Application</span> <span class="o">&lt;</span> <span class="no">Rails</span><span class="o">::</span><span class="no">Application</span>
    <span class="n">config</span><span class="p">.</span><span class="nf">sandbox_by_default</span> <span class="o">=</span> <span class="kp">true</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>これにより毎回オプションを指定しなくてもsandboxモードでconsoleが起動されます。データの書き換えが必要な場合は<code class="language-plaintext highlighter-rouge">rails console --no-sandbox</code>で起動します。</p>

<p>因みにこの機能はアニポスで毎回<code class="language-plaintext highlighter-rouge">--sandbox</code>を指定するのが面倒になり開発しました。Sandboxでconsoleを開く機能は以前からあったので実装自体は自明でした。加えた変更とそのテストは以下です。</p>

<div class="language-patch highlighter-rouge"><div class="highlight"><pre class="highlight"><code>     def sandbox?
<span class="gd">-      options[:sandbox]
</span><span class="gi">+      return options[:sandbox] if !options[:sandbox].nil?
+
+      return false if Rails.env.local?
+
+      app.config.sandbox_by_default
</span>     end
</code></pre></div></div>

<p>この変更を送る際に<code class="language-plaintext highlighter-rouge">sandbox</code>オプションが正しく反映されているかのテストが書かれていることには感心しました。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/rails/rails/blob/6e7ef7d61c7146ca03b173abc32f7ed97e3d949a/railties/test/application/console_test.rb</span>
<span class="k">class</span> <span class="nc">ConsoleTest</span> <span class="o">&lt;</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">TestCase</span>
  <span class="kp">include</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Testing</span><span class="o">::</span><span class="no">Isolation</span>

  <span class="k">def</span> <span class="nf">setup</span>
    <span class="n">build_app</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">teardown</span>
    <span class="n">teardown_app</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">test_sandbox_by_default</span>
    <span class="n">add_to_config</span> <span class="o">&lt;&lt;-</span><span class="no">RUBY</span><span class="sh">
      config.sandbox_by_default = true
</span><span class="no">    RUBY</span>

    <span class="n">options</span> <span class="o">=</span> <span class="s2">"-e production -- --verbose"</span>
    <span class="n">spawn_console</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>

    <span class="n">write_prompt</span> <span class="s2">"puts Rails.application.sandbox"</span><span class="p">,</span> <span class="s2">"puts Rails.application.sandbox</span><span class="se">\r\n</span><span class="s2">true"</span>
    <span class="vi">@primary</span><span class="p">.</span><span class="nf">puts</span> <span class="s2">"quit"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>実際にrails applicationを起動して、その標準出力を比較することでテストを実現しています。すごい力技ですね。<code class="language-plaintext highlighter-rouge">build_app</code>と<code class="language-plaintext highlighter-rouge">teardown_app</code>の内容を以下に抜粋します。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/rails/rails/blob/6e7ef7d61c7146ca03b173abc32f7ed97e3d949a/railties/test/isolation/abstract_unit.rb</span>
<span class="k">module</span> <span class="nn">TestHelpers</span>
  <span class="k">module</span> <span class="nn">Generation</span>
    <span class="k">def</span> <span class="nf">build_app</span><span class="p">(</span><span class="n">options</span> <span class="o">=</span> <span class="p">{})</span>
      <span class="no">FileUtils</span><span class="p">.</span><span class="nf">rm_rf</span><span class="p">(</span><span class="n">app_path</span><span class="p">)</span>
      <span class="no">FileUtils</span><span class="p">.</span><span class="nf">cp_r</span><span class="p">(</span><span class="n">app_template_path</span><span class="p">,</span> <span class="n">app_path</span><span class="p">)</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">teardown_app</span>
      <span class="no">FileUtils</span><span class="p">.</span><span class="nf">rm_rf</span><span class="p">(</span><span class="n">tmp_path</span><span class="p">)</span>
    <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>抽象化をせずに直接テストしているところに、Active Recordに代表される「unit testよりintegration test重視」のRails精神が垣間見られました。</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/48984">Add an option to start rails console in sandbox mode by default</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[バグの調査などのために本番環境でrails consoleを開く必要に迫られることはあります。このとき意図せずに本番データを書き換えてしまわないように注意が必要です。Railsは安全に見えるmethodでも副作用を伴う場合があります。例えばhas_oneで宣言したrelationに対して代入すると、即座にSQLが発行されます。]]></summary></entry><entry><title type="html">GitHubもTerraformで管理する</title><link href="https://anipos.github.io/2024/02/13/managing-github-organizations-using-terraform.html" rel="alternate" type="text/html" title="GitHubもTerraformで管理する" /><published>2024-02-13T05:09:15+00:00</published><updated>2024-02-13T05:09:15+00:00</updated><id>https://anipos.github.io/2024/02/13/managing-github-organizations-using-terraform</id><content type="html" xml:base="https://anipos.github.io/2024/02/13/managing-github-organizations-using-terraform.html"><![CDATA[<p>アニポスではインフラを全てterraformで管理しています。一度terraformに慣れてしまうと手動での設定が億劫になるものです。githubも手動で設定するのは面倒ですし、チームやリポジトリ数が増えるに従い、一貫した設定を適用するのは難しくなります。そこでアニポスではgithubもterraformの管理下に置いて設定をコード化しています。これには幾つかの利点があります。</p>

<p>先ずterraformのコードは当然git管理されているので、githubの設定を変更する場合も通常の開発と同様、pull requestを通して行われます。これにより手動での設定変更と比較してミスの防止になりますし、変更履歴の追跡も<code class="language-plaintext highlighter-rouge">git log</code>するだけと容易です。例えばこのブログ向けのリポジトリに関連した<code class="language-plaintext highlighter-rouge">git log --oneline</code>が以下です。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>95d38dd anipos.github.ioのhomepage urlを設定 (#506)
ded00e6 anipos.github.ioのstatus checkを厳格化 (#502)
beda597 anipos.github.ioのgithub pagesを有効化 (#499)
2f63c38 anipos.github.ioリポジトリ作成 (#497)
</code></pre></div></div>

<p>またterraform moduleによりチームやリポジトリの設定を共通化出来ます。例えばcode reviewを交互に行う設定を全チームに適用しています。</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"github_team_settings"</span> <span class="s2">"team_settings"</span> <span class="p">{</span>
  <span class="nx">team_id</span> <span class="p">=</span> <span class="nx">github_team</span><span class="err">.</span><span class="nx">team</span><span class="err">.</span><span class="nx">id</span>

  <span class="nx">review_request_delegation</span> <span class="p">{</span>
    <span class="nx">algorithm</span>    <span class="p">=</span> <span class="s2">"ROUND_ROBIN"</span>
    <span class="nx">member_count</span> <span class="p">=</span> <span class="mi">2</span>
    <span class="nx">notify</span>       <span class="p">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>別の例として、全てのリポジトリに<code class="language-plaintext highlighter-rouge">lgtm</code>ラベルを設定しています。アニポスでは<code class="language-plaintext highlighter-rouge">lgtm</code>ラベルが付いたpull requestはテスト通過後にbulldozer<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>により自動で取り込まれます。</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"github_issue_label"</span> <span class="s2">"lgtm"</span> <span class="p">{</span>
  <span class="nx">repository</span>  <span class="p">=</span> <span class="nx">github_repository</span><span class="err">.</span><span class="nx">repository</span><span class="err">.</span><span class="nx">name</span>
  <span class="nx">name</span>        <span class="p">=</span> <span class="s2">"lgtm"</span>
  <span class="nx">color</span>       <span class="p">=</span> <span class="s2">"38d87b"</span>
  <span class="nx">description</span> <span class="p">=</span> <span class="s2">"PRs with this label will be merged by bulldozer (when possible)."</span>
<span class="p">}</span>
</code></pre></div></div>

<p>実際のpull requestを一例に流れを見てみましょう。</p>

<ol>
  <li>@RyochanUedasanがチームメンバーを追加するpull requestを作成。</li>
  <li>atlantis<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>がterraform planを実行し結果をコメント。</li>
  <li>@shouichiがplan結果を確認しapprove。</li>
  <li>@RyochanUedasanがatlantisにapplyを命令、また<code class="language-plaintext highlighter-rouge">lgtm</code>ラベルを付与。</li>
  <li>bulldozer<sup id="fnref:1:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>がそれを検知してmerge。</li>
</ol>

<p><img src="/assets/2024-02-13-managing-github-organizations-using-terraform.png" alt="pull requestの一例" /></p>

<p>上記の例から分かるように、アニポスでは厳密な権限管理をしている一方で、メンバーが能動的に権限を手に入れられる、風通しの良い環境になっています。これはgithubをterraform管理することの嬉しい副作用でした。</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://github.com/palantir/bulldozer">bulldozer</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:1:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://www.runatlantis.io">Atlantis</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="terraform" /><summary type="html"><![CDATA[アニポスではインフラを全てterraformで管理しています。一度terraformに慣れてしまうと手動での設定が億劫になるものです。githubも手動で設定するのは面倒ですし、チームやリポジトリ数が増えるに従い、一貫した設定を適用するのは難しくなります。そこでアニポスではgithubもterraformの管理下に置いて設定をコード化しています。これには幾つかの利点があります。]]></summary></entry><entry><title type="html">CSVエクスポート機能でもレールに乗る</title><link href="https://anipos.github.io/2024/02/05/csv-exporting-on-rails.html" rel="alternate" type="text/html" title="CSVエクスポート機能でもレールに乗る" /><published>2024-02-05T12:05:31+00:00</published><updated>2024-02-05T12:05:31+00:00</updated><id>https://anipos.github.io/2024/02/05/csv-exporting-on-rails</id><content type="html" xml:base="https://anipos.github.io/2024/02/05/csv-exporting-on-rails.html"><![CDATA[<p>データのCSV形式エクスポート機能はあらゆるアプリケーションで求められる事でしょう。Controllerで直接CSVを作りそれを送るのが素朴な実装で、それで問題なく動作する場合も多いでしょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Admin::UsersController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@users</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">conditions</span><span class="p">)</span>

    <span class="n">respond_to</span><span class="p">.</span><span class="nf">csv</span> <span class="o">|</span><span class="nb">format</span><span class="o">|</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">html</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">csv</span> <span class="p">{</span> <span class="n">send_file</span><span class="p">(</span><span class="n">generate_csv</span><span class="p">(</span><span class="vi">@users</span><span class="p">))</span> <span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>ただしこの実装は潜在的に以下の問題をはらんでいます。</p>

<ul>
  <li>Web serverのworkerを長い時間占有する。</li>
  <li>処理時間が一定以上長くなるとタイムアウトする。</li>
</ul>

<p>これらの問題に対処するためにはCSV処理をbackground workerに任せるのが良いでしょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Admin::UserExportJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">conditions</span><span class="p">)</span>
    <span class="n">csv</span> <span class="o">=</span> <span class="n">generate_csv</span><span class="p">(</span><span class="no">User</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">conditions</span><span class="p">))</span>
    <span class="n">bucket</span><span class="p">.</span><span class="nf">create_file</span><span class="p">(</span><span class="n">csv</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">bucket</span>
    <span class="vi">@bucket</span> <span class="o">=</span> <span class="n">storage</span><span class="p">.</span><span class="nf">bucket</span><span class="p">(</span><span class="s2">"my-bucket"</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">storage</span>
    <span class="vi">@storage</span> <span class="o">||=</span> <span class="no">Google</span><span class="o">::</span><span class="no">Cloud</span><span class="o">::</span><span class="no">Storage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
      <span class="ss">project_id: </span><span class="s2">"my-project"</span><span class="p">,</span>
      <span class="ss">credentials: </span><span class="s2">"/path/to/keyfile.json"</span>
    <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>一方で上記の実装にもいくつかの問題があります。</p>

<ul>
  <li>GCSとの通信を直書きしているのでテストが書き難い。</li>
  <li>Active Storageを使っている場合にbucketの設定が重複している。</li>
</ul>

<p>もちろんWebMockなどを使えばこれもテスト可能でしょう。<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>ただしレールに乗っている感がありません。Active Storageを使ってもっとレールに乗れないでしょうか。</p>

<p>そこで思い切って「CSVエクスポート」自体をテーブルとして表現してみましょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Admin::UserExport</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:exported_by</span><span class="p">,</span> <span class="ss">class_name: </span><span class="s2">"User"</span>

  <span class="n">has_one_attached</span> <span class="ss">:csv</span>

  <span class="n">store_accessor</span> <span class="ss">:conditions</span>

  <span class="n">after_create</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="no">LaterJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span><span class="nb">self</span><span class="p">,</span> <span class="ss">:generate_and_notify</span><span class="p">)</span> <span class="p">}</span>

  <span class="k">def</span> <span class="nf">generate_and_notify</span>
    <span class="n">csv</span><span class="p">.</span><span class="nf">attach</span><span class="p">(</span><span class="n">generate_and_csv</span><span class="p">(</span><span class="no">User</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">conditions</span><span class="p">)))</span>
    <span class="no">UserMailer</span><span class="p">.</span><span class="nf">exported</span><span class="p">(</span><span class="n">exported_by</span><span class="p">,</span> <span class="n">csv</span><span class="p">).</span><span class="nf">deliver_later</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>ここでの味噌は<code class="language-plaintext highlighter-rouge">after_create</code>で作成後にCSVを生成するjobをenqueueする部分です。CSVの生成自体はモデルに定義されていますが、<code class="language-plaintext highlighter-rouge">LaterJob</code>を呼び出すことにより実際のはbackground workerによって行われます。なお<code class="language-plaintext highlighter-rouge">after_create</code>では<code class="language-plaintext highlighter-rouge">after_create_commit</code>を使うべきと言う意見もあります。<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">2</a></sup></p>

<p><code class="language-plaintext highlighter-rouge">LaterJob</code>は与えられたobjectのmethodを呼び出すだけの非常に単純なjobです。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">LaterJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">object</span><span class="p">,</span> <span class="nb">method</span><span class="p">)</span> <span class="o">=</span> <span class="n">object</span><span class="p">.</span><span class="nf">public_send</span><span class="p">(</span><span class="nb">method</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>また<code class="language-plaintext highlighter-rouge">Admin::UserExport</code>を作成するcontrollerを考えてみると、scaffoldで生成されたCRUDのコードと同一と言って差し支えないでしょう。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Admin::UsersController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@export</span> <span class="o">=</span> <span class="no">UserExport</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">export_params</span><span class="p">)</span>
    <span class="k">if</span> <span class="vi">@export</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">redirect_to</span> <span class="vi">@export</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"エクスポートを開始しました"</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">export_params</span>
    <span class="n">params</span><span class="p">.</span>
    <span class="nf">require</span><span class="p">(</span><span class="ss">:user_export</span><span class="p">).</span>
    <span class="nf">permit</span><span class="p">(</span><span class="ss">conditions: </span><span class="p">{}).</span>
    <span class="nf">merge</span><span class="p">(</span><span class="ss">exported_by: </span><span class="n">current_user</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>最後にモデル部分のテストが容易に書けることを示します。Active Storageを使っているので、テスト時は自動でGCSからテスト可能なbackendに切り替えが行われます。</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Admin::UserExportTest</span> <span class="o">&lt;</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">TestCase</span>
  <span class="nb">test</span> <span class="s2">"作成後にjobをenqueueする"</span> <span class="k">do</span>
    <span class="n">export</span> <span class="o">=</span> <span class="no">Admin</span><span class="o">::</span><span class="no">UserExport</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>

    <span class="n">assert_enqueued_with</span> <span class="ss">job: </span><span class="no">LaterJob</span><span class="p">,</span> <span class="ss">args: </span><span class="p">[</span><span class="n">export</span><span class="p">,</span> <span class="ss">:generate_and_notify</span><span class="p">]</span> <span class="k">do</span>
      <span class="n">export</span><span class="p">.</span><span class="nf">save!</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"csvを作成する"</span> <span class="k">do</span>
    <span class="n">export</span> <span class="o">=</span> <span class="no">Admin</span><span class="o">::</span><span class="no">UserExport</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>

    <span class="n">assert_changes</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">export</span><span class="p">.</span><span class="nf">csv</span><span class="p">.</span><span class="nf">attached?</span> <span class="p">},</span> <span class="ss">from: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">to: </span><span class="kp">true</span> <span class="k">do</span>
      <span class="n">export</span><span class="p">.</span><span class="nf">generate_and_notify</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>まとめると以下の目標を達成する事が出来ました。</p>

<ul>
  <li>HTTP requestがタイムアウトしない。</li>
  <li>HTTP serverのworkerを長時間占有しない。</li>
  <li>テストが容易に書ける。</li>
</ul>

<p>RailsはActive RecordでHTTPリクエストとDBを串刺しにすることで、高い生産性を生み出しています。今回はそれを最大限利用するために、「CSVエクスポート」という動作自体をテーブルとして表現しました。これによりRailsが敷設したレールに乗ることが出来ました。</p>

<p>蛇足ですがこのパータンだとActive StorageのattachableとしてTempfileを渡したくなります。Railsにその旨のpull requestを送ってみました（この記事を書いている時点では取り込まれていません）。<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">3</a></sup></p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://github.com/bblimke/webmock">Webmock</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/issues/26045">Prevent jobs from being scheduled within transactions</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://github.com/rails/rails/pull/50862">Accept Tempfile as ActiveStorage attachable</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>@shouichi</name></author><category term="rails" /><summary type="html"><![CDATA[データのCSV形式エクスポート機能はあらゆるアプリケーションで求められる事でしょう。Controllerで直接CSVを作りそれを送るのが素朴な実装で、それで問題なく動作する場合も多いでしょう。]]></summary></entry></feed>