Sansan Tech Blog Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信 2024-03-27T11:00:00+09:00 sansantech Hatena::Blog hatenablog://blog/10257846132608666252 チーム開発合宿 2024 in 徳島県神山町(技術編) hatenablog://entry/6801883189092153869 2024-03-27T11:00:00+09:00 2024-03-27T11:00:01+09:00 こんにちは、研究開発部 Architectグループの辻田です。 この記事はチームメンバー合同で作成した記事です。 先日、神山ラボへ開発合宿に行ってきました。この記事では合宿中に取り組んだ内容について紹介します。合宿の目的や、全体の様子はチーム開発合宿 2024 in 徳島県神山町(レポート編)をご参照ください。 本記事は【R&D DevOps通信】という連載記事のひとつです。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20221205/20221205105323.jpg" width="1024" height="385" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、研究開発部 Architectグループの辻田です。 この記事はチームメンバー合同で作成した記事です。</p> <p>先日、神山ラボへ開発合宿に行ってきました。この記事では合宿中に取り組んだ内容について紹介します。合宿の目的や、全体の様子は<a href="https://buildersbox.corp-sansan.com/entry/2024/03/25/110000">チーム開発合宿 2024 in 徳島県神山町(レポート編)</a>をご参照ください。</p> <p>本記事は<a href="https://buildersbox.corp-sansan.com/search?q=R%26D+DevOps%E9%80%9A%E4%BF%A1">【R&amp;D DevOps通信】</a>という連載記事のひとつです。</p> <h1 id="背景">背景</h1> <p>Sansan Labsで大きめのリリースを控えているため、それまでに障壁となる課題を短期間で改善することが目的です。通常はLabs開発に関わりのないメンバーも含めて、全員で集中して取り組むことで、効率的に課題を解決することがこの合宿に期待していることです。</p> <h1 id="期待される成果">期待される成果</h1> <p>Sansan Labsの全アプリケーションを、簡単かつ安全にリリースできる状態になっていることが期待される成果です。<br/> タスクの消化量は、通常1週間のスプリントでのポイントを3日間で消化できることが目標です。最終的にどれだけのポイントを消化できたかは、<a href="todo">レポート編</a>で紹介しています。</p> <h1 id="取り組み内容">取り組み内容</h1> <p>実際の取り組み内容は以下です。それぞれの背景やゴールは各章で紹介します。</p> <ul> <li>E2Eテスト基盤 <ul> <li>ツール選定と方針決め</li> <li>研究員が迷わずテストを書けるようなサンプル作成</li> </ul> </li> <li>複数アプリケーションを一括でリリースするCDの開発</li> <li>Labsリソースにおける依存関係可視化の開発 <ul> <li>ライブラリとそのバージョンの取得</li> <li>環境変数の取得</li> <li>バッチのデータソースとなるテーブル一覧の取得</li> </ul> </li> </ul> <h2 id="E2Eテスト基盤">E2Eテスト基盤</h2> <h3 id="背景-1">背景</h3> <p>Labsには現在24のアプリケーションがありますが、それぞれのアプリケーションにはE2Eテストがありません。すべてのアプリケーションをリリースし、手動でテストを行うことは現実的でないです。そのため、まずはE2Eテストのサンプルと、CI上で実行できる仕組みを作ることが目的です。</p> <h3 id="期待される成果-1">期待される成果</h3> <p>サンプルテストの作成とCI上での実行可能な状態にすることが期待される成果です。<br/> さらに合宿後には、サンプルを参考に各アプリケーションのテストを研究員が迷わず書けるようにすることがゴールです。</p> <p>将来的にはPRを出したら、自動でPR環境にデプロイされ、E2Eテストが実行されるようにすることも検討しています。</p> <h3 id="取り組み内容-1">取り組み内容</h3> <h4 id="ツール選定と方針決め">ツール選定と方針決め</h4> <p>Labsではフロントエンドに<a href="https://github.com/streamlit/streamlit">Streamlit</a>を使っています。Streamlitのテストフレームワークとして<a href="https://docs.streamlit.io/library/api-reference/app-testing">App Testing</a>が最近リリースされたため、これを使ってE2Eテストを行うことにしました。</p> <p>App Testingには次のような特徴があります。</p> <ul> <li>headlessでアプリのインスタンスが作成される</li> <li>ウィジェットの状態、ウィジェットの操作などのテストができる</li> <li>pytestで発動するので作りやすい</li> </ul> <p>候補には<a href="https://github.com/microsoft/playwright-python">Playwright</a>なども上がりましたが、PRごとに検証環境を立てておくのが前提となるため今回は見送ることにしました。</p> <h4 id="研究員が迷わずテストを書けるようなサンプル作成">研究員が迷わずテストを書けるようなサンプル作成</h4> <p>App Testingを使って、Streamlitの全コンポーネントを網羅したようなアプリケーションのテストを行うサンプルを作成しました。</p> <pre class="code lang-python" data-lang="python" data-unlink>tab1, tab2 = st.tabs([<span class="synConstant">&quot;Tab 1&quot;</span>, <span class="synConstant">&quot;Tab2&quot;</span>]) tab1.write(<span class="synConstant">&quot;Tab 1 content&quot;</span>) <span class="synStatement">with</span> tab2: st.write(<span class="synConstant">&quot;Tab 2 content&quot;</span>) st.multiselect(label=<span class="synConstant">&quot;MultiSelect&quot;</span>, options=[<span class="synConstant">&quot;1&quot;</span>, <span class="synConstant">&quot;2&quot;</span>, <span class="synConstant">&quot;3&quot;</span>], default=[<span class="synConstant">&quot;1&quot;</span>, <span class="synConstant">&quot;2&quot;</span>], max_selections=<span class="synConstant">2</span>) st.session_state.magic_word = st.session_state.get(<span class="synConstant">&quot;magic_word&quot;</span>, <span class="synConstant">&quot;Streamlit&quot;</span>) new_word = st.text_input(<span class="synConstant">&quot;Magic word:&quot;</span>, key=<span class="synConstant">&quot;input&quot;</span>) <span class="synStatement">if</span> st.button(<span class="synConstant">&quot;Set the magic word&quot;</span>): st.session_state.magic_word = new_word st.radio(<span class="synConstant">&quot;Radio&quot;</span>, [<span class="synConstant">&quot;1&quot;</span>, <span class="synConstant">&quot;2&quot;</span>], <span class="synConstant">0</span>) </pre> <p>以下は、上記Streamlitのコンポーネントに対するテストの一部です。</p> <p>selectbox, text_input, buttonなどが正しく動作するかを確認しています。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">test_native_components</span>() -&gt; <span class="synIdentifier">None</span>: at = AppTest.from_file(<span class="synConstant">&quot;app.py&quot;</span>) at.run() <span class="synComment"># selectboxが一つしかない</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(at.selectbox) == <span class="synConstant">1</span> <span class="synComment"># 選択されている値が予想通りか</span> <span class="synStatement">assert</span> at.selectbox[<span class="synConstant">0</span>].options[at.selectbox[<span class="synConstant">0</span>].index] == <span class="synConstant">&quot;1&quot;</span> <span class="synComment"># selectboxを操作して、その結果が予想通りかを確認する</span> at.selectbox[<span class="synConstant">0</span>].select_index(<span class="synConstant">2</span>) <span class="synStatement">assert</span> at.selectbox[<span class="synConstant">0</span>].options[at.selectbox[<span class="synConstant">0</span>].index] == <span class="synConstant">&quot;3&quot;</span> <span class="synComment"># tabが2つある</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(at.tabs) == <span class="synConstant">2</span> <span class="synComment"># tab1の中身が予想通りか</span> <span class="synStatement">assert</span> at.tabs[<span class="synConstant">0</span>].markdown[<span class="synConstant">0</span>].value == <span class="synConstant">&quot;Tab 1 content&quot;</span> <span class="synComment"># tab2の中身が予想通りか</span> <span class="synStatement">assert</span> at.tabs[<span class="synConstant">1</span>].markdown[<span class="synConstant">0</span>].value == <span class="synConstant">&quot;Tab 2 content&quot;</span> <span class="synComment"># multiselectが一つしかない</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(at.multiselect) == <span class="synConstant">1</span> <span class="synComment"># multiselectのラベルが予想通りか</span> <span class="synStatement">assert</span> at.multiselect[<span class="synConstant">0</span>].label == <span class="synConstant">&quot;MultiSelect&quot;</span> <span class="synComment"># multiselectの最大選択数が予想通りか</span> <span class="synStatement">assert</span> at.multiselect[<span class="synConstant">0</span>].max_selections == <span class="synConstant">2</span> <span class="synComment"># multiselectの想定要素が予想通りか</span> <span class="synStatement">assert</span> at.multiselect[<span class="synConstant">0</span>].options == [<span class="synConstant">&quot;1&quot;</span>, <span class="synConstant">&quot;2&quot;</span>, <span class="synConstant">&quot;3&quot;</span>] <span class="synComment"># multiselectを操作して、その結果が予想通りかを確認する</span> at.multiselect[<span class="synConstant">0</span>].set_value([<span class="synConstant">&quot;1&quot;</span>, <span class="synConstant">&quot;3&quot;</span>]) <span class="synStatement">assert</span> at.multiselect[<span class="synConstant">0</span>]._value == [<span class="synConstant">&quot;1&quot;</span>, <span class="synConstant">&quot;3&quot;</span>] <span class="synComment"># multiselectの要素を削除して、その結果が予想通りか確認する</span> at.multiselect[<span class="synConstant">0</span>].unselect(<span class="synConstant">&quot;3&quot;</span>) <span class="synStatement">assert</span> at.multiselect[<span class="synConstant">0</span>]._value == [<span class="synConstant">&quot;1&quot;</span>] <span class="synComment"># text_inputが一つしかない</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(at.text_input) == <span class="synConstant">1</span> <span class="synComment"># text_inputに入力した値が予想通りか</span> at.text_input(<span class="synConstant">&quot;input&quot;</span>).input(<span class="synConstant">&quot;test&quot;</span>).run() <span class="synStatement">assert</span> at.text_input(<span class="synConstant">&quot;input&quot;</span>).value == <span class="synConstant">&quot;test&quot;</span> <span class="synComment"># buttonが一つしかない</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(at.button) == <span class="synConstant">1</span> <span class="synComment"># buttonのラベルが予想通りか</span> <span class="synStatement">assert</span> at.button[<span class="synConstant">0</span>].label == <span class="synConstant">&quot;Set the magic word&quot;</span> <span class="synComment"># session_stateのデフォルト値が予想通りか</span> <span class="synStatement">assert</span> at.session_state[<span class="synConstant">&quot;magic_word&quot;</span>] == <span class="synConstant">&quot;Streamlit&quot;</span> at.button[<span class="synConstant">0</span>].click().run() <span class="synComment"># text_inputに入力した値が session_state に保存されているか</span> <span class="synStatement">assert</span> at.session_state[<span class="synConstant">&quot;magic_word&quot;</span>] == <span class="synConstant">&quot;test&quot;</span> <span class="synComment"># radioが一つしかない</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(at.radio) == <span class="synConstant">1</span> <span class="synComment"># radioのラベルが予想通りか</span> <span class="synStatement">assert</span> at.radio[<span class="synConstant">0</span>].label == <span class="synConstant">&quot;Radio&quot;</span> <span class="synComment"># radioの選択されている値が予想通りか</span> <span class="synStatement">assert</span> at.radio[<span class="synConstant">0</span>].options[at.radio[<span class="synConstant">0</span>].index] == <span class="synConstant">&quot;1&quot;</span> <span class="synComment"># radioを操作して、その結果が予想通りかを確認する</span> at.radio[<span class="synConstant">0</span>].set_value(<span class="synConstant">&quot;2&quot;</span>) <span class="synStatement">assert</span> at.radio[<span class="synConstant">0</span>].options[at.radio[<span class="synConstant">0</span>].index] == <span class="synConstant">&quot;2&quot;</span> </pre> <p>Labsではデータの可視化にAgGridコンポーネントも使っています。そのため、AgGridコンポーネントのテストも行いました。</p> <p>まず、AgGridコンポーネントを取得する関数を作成しました。子要素がある場合は再帰的に取得し、UnknownElementを取得します。その後、UnknownElementの中からAgGridコンポーネントを取得します。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">get_unknown_element</span>(elements: <span class="synIdentifier">list</span>[Element]) -&gt; <span class="synIdentifier">list</span>[UnknownElement]: unknown_elements = [] <span class="synStatement">for</span> elem <span class="synStatement">in</span> elements: <span class="synStatement">if</span> <span class="synIdentifier">hasattr</span>(elem, <span class="synConstant">&quot;children&quot;</span>): unknown_elements.extend(get_unknown_element(elem.children)) <span class="synStatement">else</span>: <span class="synStatement">if</span> <span class="synIdentifier">isinstance</span>(elem, UnknownElement): unknown_elements.append(elem) <span class="synStatement">return</span> unknown_elements <span class="synStatement">def</span> <span class="synIdentifier">get_aggrid_components</span>(elements: <span class="synIdentifier">list</span>[Element]) -&gt; <span class="synIdentifier">list</span>[<span class="synIdentifier">dict</span>]: aggrid_components = [] unknown_elements = get_unknown_element(elements) <span class="synStatement">for</span> elem <span class="synStatement">in</span> unknown_elements: <span class="synStatement">if</span> <span class="synIdentifier">hasattr</span>(elem, <span class="synConstant">&quot;proto&quot;</span>) <span class="synStatement">and</span> elem.proto.component_name == <span class="synConstant">&quot;st_aggrid.agGrid&quot;</span>: aggrid_component = json.loads(elem.proto.json_args) aggrid_component[<span class="synConstant">&quot;row_data&quot;</span>] = json.loads(aggrid_component[<span class="synConstant">&quot;row_data&quot;</span>]) aggrid_components.append(aggrid_component) <span class="synStatement">return</span> aggrid_components </pre> <p>AgGridのデータが正しく表示されているか、ページネーションが正しく動作しているか、列の数やヘッダーが正しいかを確認しています。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">test_labs_aggrid</span>() -&gt; <span class="synIdentifier">None</span>: at = AppTest.from_file(<span class="synConstant">&quot;app.py&quot;</span>) at.run() aggrid_component = get_aggrid_components(at.main)[<span class="synConstant">0</span>] data = aggrid_component[<span class="synConstant">&quot;row_data&quot;</span>] expected_headers = [<span class="synConstant">&quot;id&quot;</span>, <span class="synConstant">&quot;name&quot;</span>, <span class="synConstant">&quot;company&quot;</span>, <span class="synConstant">&quot;address&quot;</span>] <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(data) &gt; <span class="synConstant">0</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(data) == <span class="synConstant">1000</span> <span class="synStatement">assert</span> <span class="synIdentifier">list</span>(data[<span class="synConstant">0</span>].keys()) == expected_headers grid_options = aggrid_component[<span class="synConstant">&quot;gridOptions&quot;</span>] <span class="synStatement">assert</span> grid_options[<span class="synConstant">&quot;paginationPageSize&quot;</span>] == <span class="synConstant">20</span> <span class="synStatement">assert</span> <span class="synIdentifier">len</span>(grid_options[<span class="synConstant">&quot;columnDefs&quot;</span>]) == <span class="synConstant">4</span> <span class="synStatement">assert</span> [col[<span class="synConstant">&quot;headerName&quot;</span>] <span class="synStatement">for</span> col <span class="synStatement">in</span> grid_options[<span class="synConstant">&quot;columnDefs&quot;</span>]] == expected_headers <span class="synStatement">assert</span> aggrid_component[<span class="synConstant">&quot;frame_dtypes&quot;</span>][<span class="synConstant">&quot;id&quot;</span>] == <span class="synConstant">&quot;i&quot;</span> <span class="synStatement">assert</span> aggrid_component[<span class="synConstant">&quot;frame_dtypes&quot;</span>][<span class="synConstant">&quot;name&quot;</span>] == <span class="synConstant">&quot;O&quot;</span> <span class="synStatement">assert</span> aggrid_component[<span class="synConstant">&quot;frame_dtypes&quot;</span>][<span class="synConstant">&quot;company&quot;</span>] == <span class="synConstant">&quot;O&quot;</span> <span class="synStatement">assert</span> aggrid_component[<span class="synConstant">&quot;frame_dtypes&quot;</span>][<span class="synConstant">&quot;address&quot;</span>] == <span class="synConstant">&quot;O&quot;</span> </pre> <p>以上がApp Testingを使ったテストの紹介です。</p> <h2 id="複数アプリケーションを一括でリリースするCD">複数アプリケーションを一括でリリースするCD</h2> <h3 id="背景-2">背景</h3> <p>LabsはEKSの<a href="https://buildersbox.corp-sansan.com/entry/2022/11/16/110000">アプリケーション基盤</a>で動いています。現在のデプロイフローは次の通りです。</p> <ol> <li>各アプリケーションはGitHub ActionsでECRにDockerイメージをプッシュ</li> <li><a href="https://argocd-image-updater.readthedocs.io/en/stable/">Argo CD Image Updater</a>がECRへのプッシュを検知し、アプリケーション基盤のリポジトリへイメージタグ更新のPRを出す</li> <li>PRがマージされると、Argo CDが更新されたイメージをデプロイ</li> </ol> <p>このように自動化されているため、ひとつのアプリケーションのリリースでは問題ありません。 しかし、複数アプリケーションを一括でリリースしようとすると、上記手順を実行し、それぞれに作られるPRを全てマージする必要があります。</p> <p>そのため、複数アプリケーションを一括でビルドし、別リポジトリにリリースPR1つだけ出すCDを開発しました。</p> <h3 id="期待される成果-2">期待される成果</h3> <p>複数アプリケーションを一括でリリースするCDが完成し、リリースが簡単に行える状態が期待される成果です。</p> <h3 id="取り組み内容-2">取り組み内容</h3> <p>Github Actionsのworkflow_dispatchを使って、入力したアプリケーションを一括でビルドし、アプリケーション基盤のリポジトリへPRを出すworkflowを作りました。</p> <p>ざっくりとしたフローは次の通りです。</p> <ol> <li>ユーザーがリリース対象のアプリケーション名を入力し、workflow_dispatchを実行</li> <li>入力されたアプリケーションを並列でビルドし、ECRにプッシュ</li> <li>kustomization.yamlのイメージタグを更新&コミット</li> <li>アプリケーション基盤のリポジトリへリリースPRを出す</li> </ol> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">name</span><span class="synSpecial">:</span> Multiple ECR Image Update <span class="synIdentifier">on</span><span class="synSpecial">:</span> <span class="synIdentifier">workflow_dispatch</span><span class="synSpecial">:</span> <span class="synIdentifier">inputs</span><span class="synSpecial">:</span> <span class="synIdentifier">apps</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> string <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Application name separated by commas(ex. app-1,app-2)&quot;</span> <span class="synIdentifier">required</span><span class="synSpecial">:</span> <span class="synConstant">true</span> <span class="synIdentifier">default</span><span class="synSpecial">:</span> <span class="synConstant">&quot;app-1,app-2,app-3,app-4,app-5&quot;</span> <span class="synIdentifier">app_type</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> choice <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Application type&quot;</span> <span class="synIdentifier">required</span><span class="synSpecial">:</span> <span class="synConstant">true</span> <span class="synIdentifier">options</span><span class="synSpecial">:</span> <span class="synStatement">- </span>app <span class="synStatement">- </span>batch <span class="synStatement">- </span>api <span class="synIdentifier">environment</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> choice <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Environment&quot;</span> <span class="synIdentifier">required</span><span class="synSpecial">:</span> <span class="synConstant">true</span> <span class="synIdentifier">options</span><span class="synSpecial">:</span> <span class="synStatement">- </span>staging <span class="synStatement">- </span>production <span class="synIdentifier">jobs</span><span class="synSpecial">:</span> <span class="synIdentifier">set-matrix</span><span class="synSpecial">:</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synIdentifier">outputs</span><span class="synSpecial">:</span> <span class="synIdentifier">apps</span><span class="synSpecial">:</span> ${{ steps.setmatrix.outputs.apps }} <span class="synIdentifier">image_tag</span><span class="synSpecial">:</span> ${{ steps.generate_image_tag.outputs.image_tag }} <span class="synIdentifier">timeout-minutes</span><span class="synSpecial">:</span> <span class="synConstant">5</span> <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Set matrix <span class="synIdentifier">id</span><span class="synSpecial">:</span> setmatrix <span class="synIdentifier">run</span><span class="synSpecial">:</span> | <span class="synComment"> # カンマで文字列を分割して配列に格納する</span> inputs=${{ inputs.apps }} array=(${inputs//,/ }) quoted_apps=() for element in <span class="synConstant">&quot;${array[@]}&quot;</span>; do <span class="synComment"> # 要素をダブルクォーテーションで囲んで配列に追加</span> quoted_apps+=(&quot;$element&quot;) done apps=$(printf <span class="synConstant">'%s\n'</span> <span class="synConstant">&quot;${quoted_apps[@]}&quot;</span> | jq -R . | jq -s -c .) echo <span class="synConstant">&quot;apps=$apps&quot;</span> &gt;&gt; $GITHUB_OUTPUT <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Generate Image tag <span class="synIdentifier">id</span><span class="synSpecial">:</span> generate_image_tag <span class="synIdentifier">run</span><span class="synSpecial">:</span> | <span class="synComment"> # Image Updaterが反応しないようにyyyy-mm-dd-hhmmss形式にする</span> image_tag=$(date +'%Y-%m-%d-%H%M%S') echo <span class="synConstant">&quot;image_tag=$image_tag&quot;</span> &gt;&gt; $GITHUB_OUTPUT <span class="synIdentifier">update-ecr-image-and-commit</span><span class="synSpecial">:</span> <span class="synIdentifier">needs</span><span class="synSpecial">:</span> set-matrix <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synConstant">&quot;[${{ inputs.environment }}][${{ matrix.app }}][${{ inputs.app_type }}]Multiple Update ECR Image&quot;</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synComment"> # These permissions are needed to interact with GitHub's OIDC Token endpoint.</span> <span class="synIdentifier">permissions</span><span class="synSpecial">:</span> <span class="synIdentifier">id-token</span><span class="synSpecial">:</span> write <span class="synIdentifier">contents</span><span class="synSpecial">:</span> read <span class="synIdentifier">timeout-minutes</span><span class="synSpecial">:</span> <span class="synConstant">30</span> <span class="synIdentifier">strategy</span><span class="synSpecial">:</span> <span class="synIdentifier">fail-fast</span><span class="synSpecial">:</span> <span class="synConstant">false</span> <span class="synIdentifier">matrix</span><span class="synSpecial">:</span> <span class="synIdentifier">app</span><span class="synSpecial">:</span> ${{ fromJson(needs.set-matrix.outputs.apps) }} <span class="synIdentifier">environment</span><span class="synSpecial">:</span> ${{ inputs.environment }} <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Checkout <span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4 <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Sets common env variables <span class="synIdentifier">run</span><span class="synSpecial">:</span> | APP_NAME=${{ matrix.app }} echo <span class="synConstant">&quot;UNDER_SCORE_APP_NAME=${APP_NAME//-/_}&quot;</span> &gt;&gt; $GITHUB_ENV <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Check deploy target existence <span class="synIdentifier">id</span><span class="synSpecial">:</span> check_files <span class="synIdentifier">uses</span><span class="synSpecial">:</span> andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6<span class="synComment"> # v3.0.0</span> <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">files</span><span class="synSpecial">:</span> apps/${{ env.UNDER_SCORE_APP_NAME }}/${{ inputs.app_type }} <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Generate github token <span class="synIdentifier">if</span><span class="synSpecial">:</span> steps.check_files.outputs.files_exists == <span class="synConstant">'true'</span> <span class="synIdentifier">id</span><span class="synSpecial">:</span> generate_token <span class="synIdentifier">uses</span><span class="synSpecial">:</span> tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a<span class="synComment"> # v2.1.0</span> <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">app_id</span><span class="synSpecial">:</span> ${{ secrets.APP_ID }} <span class="synIdentifier">private_key</span><span class="synSpecial">:</span> ${{ secrets.RANDD_ARTIFACT_GITHUB_APP_PRIVATE_KEY }} <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Configure AWS Credentials <span class="synIdentifier">if</span><span class="synSpecial">:</span> steps.check_files.outputs.files_exists == <span class="synConstant">'true'</span> <span class="synIdentifier">uses</span><span class="synSpecial">:</span> aws-actions/configure-aws-credentials@v4 <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">role-to-assume</span><span class="synSpecial">:</span> ${{ secrets.AWS_ASSUME_ROLE_ARN }} <span class="synIdentifier">aws-region</span><span class="synSpecial">:</span> ap-northeast-1 <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Login to Amazon ECR Private <span class="synIdentifier">if</span><span class="synSpecial">:</span> steps.check_files.outputs.files_exists == <span class="synConstant">'true'</span> <span class="synIdentifier">id</span><span class="synSpecial">:</span> login-ecr <span class="synIdentifier">uses</span><span class="synSpecial">:</span> aws-actions/amazon-ecr-login@v2 <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Build, tag, and push docker image to Amazon ECR <span class="synIdentifier">if</span><span class="synSpecial">:</span> steps.check_files.outputs.files_exists == <span class="synConstant">'true'</span> <span class="synIdentifier">id</span><span class="synSpecial">:</span> build_push_image <span class="synIdentifier">env</span><span class="synSpecial">:</span> <span class="synIdentifier">REGISTRY</span><span class="synSpecial">:</span> ${{ steps.login-ecr.outputs.registry }} <span class="synIdentifier">REPOSITORY</span><span class="synSpecial">:</span> randd/labs/${{ matrix.app }}/${{ inputs.app_type }} <span class="synIdentifier">github_access_token</span><span class="synSpecial">:</span> ${{ steps.generate_token.outputs.token }} <span class="synIdentifier">run</span><span class="synSpecial">:</span> | DOCKER_BUILDKIT=1 docker build --secret id=github_access_token -t $REGISTRY/$REPOSITORY:${{ needs.set-matrix.outputs.image_tag }} . docker push $REGISTRY/$REPOSITORY:${{ needs.set-matrix.outputs.image_tag }} <span class="synIdentifier">working-directory</span><span class="synSpecial">:</span> apps/${{ env.UNDER_SCORE_APP_NAME }}/${{ inputs.app_type }} <span class="synIdentifier">create-pull-request</span><span class="synSpecial">:</span> <span class="synIdentifier">needs</span><span class="synSpecial">:</span> <span class="synSpecial">[</span>set-matrix, update-ecr-image-and-commit<span class="synSpecial">]</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synConstant">&quot;[${{ inputs.environment }}][${{ inputs.app_type }}]Create Pull Request to randd_circuit&quot;</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synComment"> # These permissions are needed to interact with GitHub's OIDC Token endpoint.</span> <span class="synIdentifier">permissions</span><span class="synSpecial">:</span> <span class="synIdentifier">id-token</span><span class="synSpecial">:</span> write <span class="synIdentifier">contents</span><span class="synSpecial">:</span> read <span class="synIdentifier">timeout-minutes</span><span class="synSpecial">:</span> <span class="synConstant">10</span> <span class="synIdentifier">environment</span><span class="synSpecial">:</span> ${{ inputs.environment }} <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Generate github token for circuit <span class="synIdentifier">id</span><span class="synSpecial">:</span> generate_token_circuit <span class="synIdentifier">uses</span><span class="synSpecial">:</span> tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a<span class="synComment"> # v2.1.0</span> <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">app_id</span><span class="synSpecial">:</span> ${{ vars.RANDD_CIRCUIT_APP_ID }} <span class="synIdentifier">private_key</span><span class="synSpecial">:</span> ${{ secrets.RANDD_CIRCUIT_PRIVATE_KEY }} <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Checkout source with token and branch <span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4 <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">repository</span><span class="synSpecial">:</span> ${{ vars.CIRCUIT_REPO }} <span class="synIdentifier">token</span><span class="synSpecial">:</span> ${{ steps.generate_token_circuit.outputs.token }} <span class="synIdentifier">sparse-checkout</span><span class="synSpecial">:</span> <span class="synConstant">&quot;services/labs&quot;</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Update kustomization.yaml <span class="synIdentifier">run</span><span class="synSpecial">:</span> | git config --local user.email <span class="synConstant">&quot;github-actions[bot]@users.noreply.github.com&quot;</span> git config --local user.name <span class="synConstant">&quot;github-actions[bot]&quot;</span> branch_name=labs-${{ inputs.app_type }}-update-image-tag-${{ github.sha }} git switch -c $branch_name for app in $(echo <span class="synConstant">'${{ needs.set-matrix.outputs.apps }}'</span> | jq -r <span class="synConstant">'.[]'</span>); do yaml_file=&quot;services/labs/$app/${{ inputs.app_type }}/overlays/aws-randd-${{ inputs.environment }}/kustomization.yaml&quot; if <span class="synSpecial">[</span> -e $yaml_file <span class="synSpecial">]</span>; then sed -i <span class="synConstant">&quot;s/\(newTag: \)[^</span><span class="synSpecial">\n</span><span class="synConstant">]*/\1</span><span class="synSpecial">\&quot;</span><span class="synConstant">${{ needs.set-matrix.outputs.image_tag }}</span><span class="synSpecial">\&quot;</span><span class="synConstant">/&quot;</span> <span class="synConstant">&quot;$yaml_file&quot;</span> fi done git add . git commit -m <span class="synConstant">&quot;${{ inputs.app_type }}: multiple update image tag&quot;</span> git push -u origin HEAD:&quot;$branch_name&quot; <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> create Pull Request to circuit <span class="synIdentifier">id</span><span class="synSpecial">:</span> create_pr <span class="synIdentifier">run</span><span class="synSpecial">:</span> | gh pr create \ --title <span class="synConstant">&quot;[${{ inputs.environment }}][labs][${{ inputs.app_type }}]Multiple image tag updates&quot;</span> \ --body <span class="synConstant">&quot;Multiple image tag updates.&quot;</span> \ --repo ${{ vars.CIRCUIT_REPO }} \ --base main \ --head <span class="synConstant">&quot;labs-${{ inputs.app_type }}-update-image-tag-${{ github.sha }}&quot;</span> <span class="synIdentifier">env</span><span class="synSpecial">:</span> <span class="synIdentifier">GH_TOKEN</span><span class="synSpecial">:</span> ${{ steps.generate_token_circuit.outputs.token }} <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Check image tag <span class="synIdentifier">if</span><span class="synSpecial">:</span> steps.create_pr.outcome == <span class="synConstant">'success'</span> <span class="synIdentifier">run</span><span class="synSpecial">:</span> | echo <span class="synConstant">&quot;[${{ inputs.environment }}] [${{ inputs.app_type }}] image tag: \`${{ needs.set-matrix.outputs.image_tag }}\`&quot;</span> &gt;&gt; $GITHUB_STEP_SUMMARY </pre> <p>ポイントは次の通りです。</p> <ul> <li>set-matrixジョブ <ul> <li>入力したアプリケーションを並列でビルドするためのmatrixを作成</li> </ul> </li> <li>pdate-ecr-image-and-commitジョブ <ul> <li>matrixで作成したアプリケーションを並列でビルド</li> <li>Image Updaterはコミットハッシュで発火するため、それを回避するためにyyyy-mm-dd-hhmmss形式を使用</li> <li>アプリケーションによってはapp, batch, apiが存在しないため、<a href="https://github.com/andstor/file-existence-action">andstor/file-existence-action</a>でチェックを行い、存在する場合のみビルドを行う</li> </ul> </li> <li>create-pull-requestジョブ <ul> <li>すべてのアプリケーションのビルドが成功したときだけ動くよう、needsで依存関係を設定</li> <li><code>sparse-checkout</code>で必要なディレクトリだけを取得</li> <li>kustomization.yamlを更新し、アプリケーション基盤のリポジトリに対してPRを出す</li> </ul> </li> </ul> <p>以上で、複数アプリケーションを一括でリリースするCDが完成しました。</p> <h2 id="Labsリソースにおける依存関係の可視化">Labsリソースにおける依存関係の可視化</h2> <h3 id="背景-3">背景</h3> <p>Labsのサービスが外部に依存しているライブラリやデータソースなどの情報はあらゆるファイルに点在しており、整理することが困難となっています。</p> <p>現状では、外部に何らかのアップデートや障害があった際、修正箇所や影響範囲の特定に時間がかかってしまいます。 そのため、ライブラリやデータソースなど外部依存を可視化する必要があります。</p> <h3 id="期待される成果-3">期待される成果</h3> <p>Labsサービスの依存関係をGithub Actionsにて抽出し、Github wikiに表として可視化することが最終成果です。 可視化するリソースとその目的は次の通りです。</p> <ul> <li>使用しているライブラリとそのバージョン一覧 <ul> <li>サービスごとのバージョンにばらつきがあるか、古すぎるバージョンを使用しているかの確認</li> </ul> </li> <li>サービスごとの環境変数 <ul> <li>連携先のパスが変更されたときの対応</li> <li>ColossusではなくS3を参照している箇所の特定</li> <li>URLベースからURIベースに変更</li> </ul> </li> <li>各バッチが依存しているテーブル(Athena, Colossus) <ul> <li>障害が起きた時の影響範囲の特定</li> <li>データソースが提供終了した際の修正箇所の特定</li> </ul> </li> </ul> <p>これらを次のような手順を踏むActionsを作成し、実現します。</p> <ol> <li>対象のリソースを抽出</li> <li>次項で説明するPython Scriptにて該当箇所を抽出</li> <li>Wikiに書き込み</li> </ol> <h3 id="取り組み内容-3">取り組み内容</h3> <h4 id="Actions内でPythonを実行する際の工夫">Actions内でPythonを実行する際の工夫</h4> <p>Actionsの開発をする際に頭を悩ませるのはshellです。</p> <p>sh, bashの差分やローカルとの違い、さまざまな面で思わぬ時間ロスになります。</p> <p>今回の開発は比較的ロジックも軽く作り直しが容易であるため、Actions開発をPythonで行うことのテストを兼ねて開発環境をRye+UV+Ruff+mypy+pytestで構築しました。実行環境ではrequirements.lockを使用するという構成を試しています。</p> <p>今のところ、便利な点としてはshellで書いた際よりもロジックが分かりやすいため、レビューがスムーズに進むと感じました。ただ長期的に見た際のメンテナンス性など、まだまだ見えていないリスクがあるため、じっくり見極めていきたいです。</p> <h4 id="ライブラリとそのバージョンの取得">ライブラリとそのバージョンの取得</h4> <p>使用しているライブラリはすべてpyproject.tomlに格納されています。</p> <p>抽出する情報は次の通りです。</p> <ul> <li>サービス名</li> <li>ライブラリ名</li> <li>ライブラリのバージョン</li> </ul> <p>pyproject.tomlファイルへのパスを入力として各項目を取得するpythonコードは以下になります。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> tomllib <span class="synPreProc">import</span> loads results = [] <span class="synStatement">for</span> path <span class="synStatement">in</span> paths: <span class="synStatement">with</span> <span class="synIdentifier">open</span>(path, <span class="synConstant">&quot;r&quot;</span>) <span class="synStatement">as</span> f: pyproject = loads(f.read()) results += extract_library_versions(pyproject) <span class="synStatement">def</span> <span class="synIdentifier">extract_library_versions</span>(pyproject: <span class="synIdentifier">dict</span>[<span class="synIdentifier">str</span>, Any]) -&gt; <span class="synIdentifier">list</span>[<span class="synIdentifier">list</span>[<span class="synIdentifier">str</span>]]: results: <span class="synIdentifier">list</span> = [] <span class="synStatement">if</span> <span class="synConstant">&quot;poetry&quot;</span> <span class="synStatement">in</span> pyproject[<span class="synConstant">&quot;tool&quot;</span>]: name: <span class="synIdentifier">str</span> = pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>][<span class="synConstant">&quot;name&quot;</span>] <span class="synStatement">if</span> <span class="synConstant">&quot;tool&quot;</span> <span class="synStatement">in</span> pyproject <span class="synStatement">and</span> <span class="synConstant">&quot;poetry&quot;</span> <span class="synStatement">in</span> pyproject[<span class="synConstant">&quot;tool&quot;</span>]: <span class="synComment"># extract dependencies</span> <span class="synStatement">if</span> <span class="synConstant">&quot;dependencies&quot;</span> <span class="synStatement">in</span> pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>]: <span class="synStatement">for</span> key <span class="synStatement">in</span> pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>][<span class="synConstant">&quot;dependencies&quot;</span>]: results.append( [ name, key, extract_version(pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>][<span class="synConstant">&quot;dependencies&quot;</span>][key]), ] ) <span class="synComment"># extract dev-dependencies</span> <span class="synStatement">if</span> <span class="synConstant">&quot;dev-dependencies&quot;</span> <span class="synStatement">in</span> pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>]: <span class="synStatement">for</span> key <span class="synStatement">in</span> pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>][<span class="synConstant">&quot;dev-dependencies&quot;</span>]: results.append( [ name, key, extract_version(pyproject[<span class="synConstant">&quot;tool&quot;</span>][<span class="synConstant">&quot;poetry&quot;</span>][<span class="synConstant">&quot;dev-dependencies&quot;</span>][key]), ] ) <span class="synStatement">return</span> results <span class="synStatement">def</span> <span class="synIdentifier">extract_version</span>(dependency: <span class="synIdentifier">str</span> | <span class="synIdentifier">dict</span>[<span class="synIdentifier">str</span>, Any]) -&gt; <span class="synIdentifier">str</span>: <span class="synStatement">if</span> <span class="synIdentifier">isinstance</span>(dependency, <span class="synIdentifier">str</span>): <span class="synStatement">return</span> dependency <span class="synStatement">elif</span> <span class="synIdentifier">isinstance</span>(dependency, <span class="synIdentifier">dict</span>) <span class="synStatement">and</span> <span class="synConstant">&quot;tag&quot;</span> <span class="synStatement">in</span> dependency: <span class="synStatement">return</span> dependency[<span class="synConstant">&quot;tag&quot;</span>] <span class="synStatement">else</span>: <span class="synStatement">return</span> <span class="synConstant">&quot;&quot;</span> </pre> <p>このコードでは、前段でfindにて抽出されたpyproject.tomlファイルを読み込み、dependenciesとdev-dependenciesに書かれたライブラリのversionsを抽出しています。</p> <p>また、このコードはpoetryに向けて書かれたものなので、今後ryeによって書かれたものが出現し始めた際にはコードの追加が必要になります。</p> <h4 id="環境変数の取得">環境変数の取得</h4> <p>各サービスの環境変数は<a href="https://buildersbox.corp-sansan.com/entry/2022/11/16/110000">アプリケーション基盤</a>のマニフェストファイル(yaml)に記述されています。</p> <p>主に抽出する情報は次の通りです。</p> <ul> <li>サービス名</li> <li>サービスのkind(CronJob, Deploymentなど)</li> <li>環境変数の名前</li> <li>環境変数の値</li> </ul> <p>環境変数の値の中にはExternal SecretsやConfig Mapの参照などがあり、それらのkey, valueも取得します。</p> <p>以下がyamlファイルのパスから各項目を出力するpythonコードです。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> yaml <span class="synPreProc">import</span> safe_load_all results = [] <span class="synStatement">with</span> <span class="synIdentifier">open</span>(path, <span class="synConstant">&quot;r&quot;</span>) <span class="synStatement">as</span> f: manifests = safe_load_all(f.read()) <span class="synStatement">for</span> manifest <span class="synStatement">in</span> manifests: results += extract_env_values(manifest, <span class="synConstant">&quot;production&quot;</span>) <span class="synStatement">def</span> <span class="synIdentifier">extract_env_values</span>(manifest: <span class="synIdentifier">dict</span>[<span class="synIdentifier">str</span>, Any], environment: <span class="synIdentifier">str</span>) -&gt; <span class="synIdentifier">list</span>[<span class="synIdentifier">list</span>[<span class="synIdentifier">str</span>]]: results = [] <span class="synStatement">if</span> manifest[<span class="synConstant">&quot;kind&quot;</span>] == <span class="synConstant">&quot;Deployment&quot;</span>: <span class="synStatement">for</span> container <span class="synStatement">in</span> manifest[<span class="synConstant">&quot;spec&quot;</span>][<span class="synConstant">&quot;template&quot;</span>][<span class="synConstant">&quot;spec&quot;</span>][<span class="synConstant">&quot;containers&quot;</span>]: <span class="synStatement">for</span> env <span class="synStatement">in</span> container[<span class="synConstant">&quot;env&quot;</span>]: extract_values = extract_values_from_env(env) <span class="synStatement">if</span> <span class="synIdentifier">len</span>(extract_values) == <span class="synConstant">5</span>: results.append( [ manifest[<span class="synConstant">&quot;metadata&quot;</span>][<span class="synConstant">&quot;name&quot;</span>], manifest[<span class="synConstant">&quot;kind&quot;</span>], ] + extract_values ) <span class="synStatement">elif</span> manifest[<span class="synConstant">&quot;kind&quot;</span>] == <span class="synConstant">&quot;CronJob&quot;</span>: <span class="synStatement">for</span> container <span class="synStatement">in</span> manifest[<span class="synConstant">&quot;spec&quot;</span>][<span class="synConstant">&quot;jobTemplate&quot;</span>][<span class="synConstant">&quot;spec&quot;</span>][<span class="synConstant">&quot;template&quot;</span>][<span class="synConstant">&quot;spec&quot;</span>][<span class="synConstant">&quot;containers&quot;</span>]: <span class="synStatement">for</span> env <span class="synStatement">in</span> container[<span class="synConstant">&quot;env&quot;</span>]: extract_values = extract_values_from_env(env) <span class="synStatement">if</span> <span class="synIdentifier">len</span>(extract_values) == <span class="synConstant">5</span>: results.append( [ manifest[<span class="synConstant">&quot;metadata&quot;</span>][<span class="synConstant">&quot;name&quot;</span>], manifest[<span class="synConstant">&quot;kind&quot;</span>], ] + extract_values ) <span class="synStatement">elif</span> manifest[<span class="synConstant">&quot;kind&quot;</span>] == <span class="synConstant">&quot;CronWorkflow&quot;</span>: <span class="synStatement">for</span> template <span class="synStatement">in</span> manifest[<span class="synConstant">&quot;spec&quot;</span>][<span class="synConstant">&quot;workflowSpec&quot;</span>][<span class="synConstant">&quot;templates&quot;</span>]: <span class="synStatement">if</span> <span class="synConstant">&quot;container&quot;</span> <span class="synStatement">in</span> template: <span class="synStatement">if</span> <span class="synConstant">&quot;env&quot;</span> <span class="synStatement">in</span> template[<span class="synConstant">&quot;container&quot;</span>]: <span class="synStatement">for</span> env <span class="synStatement">in</span> template[<span class="synConstant">&quot;container&quot;</span>][<span class="synConstant">&quot;env&quot;</span>]: extract_values = extract_values_from_env(env) <span class="synStatement">if</span> <span class="synIdentifier">len</span>(extract_values) == <span class="synConstant">5</span>: results.append( [ manifest[<span class="synConstant">&quot;metadata&quot;</span>][<span class="synConstant">&quot;name&quot;</span>], manifest[<span class="synConstant">&quot;kind&quot;</span>], ] + extract_values ) <span class="synStatement">return</span> results <span class="synStatement">def</span> <span class="synIdentifier">extract_values_from_env</span>(env: <span class="synIdentifier">dict</span>[<span class="synIdentifier">str</span>, Any]) -&gt; <span class="synIdentifier">list</span>[<span class="synIdentifier">str</span>]: <span class="synStatement">if</span> <span class="synConstant">&quot;valueFrom&quot;</span> <span class="synStatement">in</span> env: <span class="synStatement">if</span> <span class="synConstant">&quot;configMapKeyRef&quot;</span> <span class="synStatement">in</span> env[<span class="synConstant">&quot;valueFrom&quot;</span>]: <span class="synStatement">return</span> [ env[<span class="synConstant">&quot;name&quot;</span>], <span class="synConstant">&quot;&quot;</span>, <span class="synConstant">&quot;configMapKeyRef&quot;</span>, env[<span class="synConstant">&quot;valueFrom&quot;</span>][<span class="synConstant">&quot;configMapKeyRef&quot;</span>][<span class="synConstant">&quot;key&quot;</span>], env[<span class="synConstant">&quot;valueFrom&quot;</span>][<span class="synConstant">&quot;configMapKeyRef&quot;</span>][<span class="synConstant">&quot;name&quot;</span>], ] <span class="synStatement">elif</span> <span class="synConstant">&quot;secretKeyRef&quot;</span> <span class="synStatement">in</span> env[<span class="synConstant">&quot;valueFrom&quot;</span>]: <span class="synStatement">return</span> [ env[<span class="synConstant">&quot;name&quot;</span>], <span class="synConstant">&quot;&quot;</span>, <span class="synConstant">&quot;secretKeyRef&quot;</span>, env[<span class="synConstant">&quot;valueFrom&quot;</span>][<span class="synConstant">&quot;secretKeyRef&quot;</span>][<span class="synConstant">&quot;key&quot;</span>], env[<span class="synConstant">&quot;valueFrom&quot;</span>][<span class="synConstant">&quot;secretKeyRef&quot;</span>][<span class="synConstant">&quot;name&quot;</span>], ] <span class="synStatement">elif</span> <span class="synConstant">&quot;value&quot;</span> <span class="synStatement">in</span> env: <span class="synStatement">return</span> [ env[<span class="synConstant">&quot;name&quot;</span>], env[<span class="synConstant">&quot;value&quot;</span>], <span class="synConstant">&quot;value&quot;</span>, <span class="synConstant">&quot;&quot;</span>, <span class="synConstant">&quot;&quot;</span>, ] <span class="synStatement">return</span> [] </pre> <p>このコードではActionsの前段でfindにて抽出された*.yamlのうち、Deployment, CronJob, CronWorkflowのkindのものを読み込み、そのEnvの内容を抽出しています。</p> <h4 id="バッチのデータソースとなるテーブル一覧の取得">バッチのデータソースとなるテーブル一覧の取得</h4> <p>Labsにおいては、データソースへのクエリはすべてsqlファイルにまとめられています。</p> <p>抽出する情報は次の通りです。</p> <ul> <li>サービス名</li> <li>GCPプロジェクト名(Colossusのみ)</li> <li>データベース名</li> <li>テーブル名</li> </ul> <p>sqlファイルからテーブル名を抽出する際はpythonの<a href="https://github.com/macbre/sql-metadata">sql_metadata</a>というライブラリを使いました。</p> <p>sqlファイルのパスからテーブルの情報を取得する関数は以下になります。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> sql_metadata <span class="synStatement">def</span> <span class="synIdentifier">parse_sql_files_to_table_list</span>( sql_file: Path, service_name: <span class="synIdentifier">str</span>, athena_table_data: List[AthenaTableInfoTuple], colossus_table_data: List[ColossusTableInfoTuple], ) -&gt; <span class="synIdentifier">None</span>: <span class="synStatement">with</span> sql_file.open(<span class="synConstant">&quot;r&quot;</span>) <span class="synStatement">as</span> sf: sql_contents = sf.read() <span class="synStatement">for</span> sql <span class="synStatement">in</span> sql_contents.split(<span class="synConstant">&quot;;&quot;</span>)[:-<span class="synConstant">1</span>]: sql = sql.replace(<span class="synConstant">'&quot;'</span>, <span class="synConstant">&quot;&quot;</span>) <span class="synStatement">try</span>: tables: List[<span class="synIdentifier">str</span>] = sql_metadata.Parser(sql).tables <span class="synStatement">except</span> <span class="synType">ValueError</span>: <span class="synComment"># create *** function 構文はパースできないが、テーブル情報は含まないため無視する</span> <span class="synStatement">if</span> <span class="synConstant">&quot;create temp function&quot;</span> <span class="synStatement">in</span> sql.lower() <span class="synStatement">or</span> <span class="synConstant">&quot;create temporary function&quot;</span> <span class="synStatement">in</span> sql.lower(): <span class="synStatement">continue</span> <span class="synStatement">else</span>: <span class="synStatement">raise</span> <span class="synType">ValueError</span>(f<span class="synConstant">&quot;file_path: {str(sql_file)}</span><span class="synSpecial">\n</span><span class="synConstant">Failed to parse the SQL: {sql}&quot;</span>) <span class="synStatement">for</span> table <span class="synStatement">in</span> tables: table_info = table.split(<span class="synConstant">&quot;.&quot;</span>) <span class="synStatement">if</span> <span class="synIdentifier">len</span>(table_info) == <span class="synConstant">2</span>: <span class="synComment"># Athenaは{database_name}.{table_name}の形式</span> athena_table_data.append(AthenaTableInfoTuple(service_name, table_info[<span class="synConstant">0</span>], table_info[<span class="synConstant">1</span>])) <span class="synStatement">elif</span> <span class="synIdentifier">len</span>(table_info) == <span class="synConstant">3</span>: <span class="synComment"># Colossusは{gcp_project}.{database_name}.{table_name}の形式</span> colossus_table_data.append( ColossusTableInfoTuple( service_name, table_info[<span class="synConstant">0</span>], table_info[<span class="synConstant">1</span>], table_info[<span class="synConstant">2</span>], ) ) </pre> <p>この関数の呼び出し側でsqlファイルパスを取得する際に、sqlファイルの上位にあるサービスディレクトリの名前(サービス名)も取得しています。 sqlファイルを読み込み、sql_metadataにより各クエリをパースしています。</p> <p>sql_metadataでは、FROM句やINSERT JOIN句などのテーブルを参照する箇所をtablesとして一覧化することできます。</p> <h1 id="おわりに">おわりに</h1> <p>以上が合宿で取り組んだ内容です。合宿後は、これらの成果を元にLabsの開発がより効率的に進むことを期待しています。リリース頻度やリードタイムに改善が見られるどうかは引き続きモニタリングします。</p> <p>Architectグループでは一緒に働く仲間を募集しています。</p> <p><a href="https://open.talentio.com/r/1/c/sansan/pages/76616?_gl=1*14rznce*_ga*MTg4NTg5MDMxLjE2NzAwNDU2Njg.*_ga_EN18Q6V0JW*MTcwOTk2NTc3Ny4xMi4xLjE3MDk5NjU3OTAuMC4wLjA.">R&amp;D MLOps/DevOpsエンジニア</a></p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> misaosyushi TypeScript開発にRailway Orientedを持ち込み、より型安全なエラーハンドリングへ hatenablog://entry/6801883189087155726 2024-03-26T11:00:00+09:00 2024-03-26T11:00:00+09:00 Digitization部 Bill One Entry*1グループの秋山です。 はじめに Domain Modeling Made Functionalというスゴ本 補講:Make Illegal States Unrepresentable バックエンドの処理を抽象化する 手続き型プログラミングの典型例 課題1:制約のないエラーハンドリング 課題2:低い可読性 課題3:エラーハンドリングの低い網羅性 Railway Oriented Programming TypeScriptで型安全にエラーハンドリングする ステップ1:サブ関数の出力はResult型で表現する ステップ2:サブ関数にRe… <p>Digitization部 Bill One Entry<a href="#f-cf211e54" id="fn-cf211e54" name="fn-cf211e54" title="クラウド請求書受領サービス「Bill One」が提供するデータ化機能。">*1</a>グループの<a href="https://twitter.com/aki202">秋山</a>です。</p> <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#Domain-Modeling-Made-Functionalというスゴ本">Domain Modeling Made Functionalというスゴ本</a><ul> <li><a href="#補講Make-Illegal-States-Unrepresentable">補講:Make Illegal States Unrepresentable</a></li> </ul> </li> <li><a href="#バックエンドの処理を抽象化する">バックエンドの処理を抽象化する</a></li> <li><a href="#手続き型プログラミングの典型例">手続き型プログラミングの典型例</a><ul> <li><a href="#課題1制約のないエラーハンドリング">課題1:制約のないエラーハンドリング</a></li> <li><a href="#課題2低い可読性">課題2:低い可読性</a></li> <li><a href="#課題3エラーハンドリングの低い網羅性">課題3:エラーハンドリングの低い網羅性</a></li> </ul> </li> <li><a href="#Railway-Oriented-Programming">Railway Oriented Programming</a></li> <li><a href="#TypeScriptで型安全にエラーハンドリングする">TypeScriptで型安全にエラーハンドリングする</a><ul> <li><a href="#ステップ1サブ関数の出力はResult型で表現する">ステップ1:サブ関数の出力はResult型で表現する</a></li> <li><a href="#ステップ2サブ関数にResult型を入力できるようにする">ステップ2:サブ関数にResult型を入力できるようにする</a></li> <li><a href="#ステップ3サブ関数を連結する">ステップ3:サブ関数を連結する</a></li> <li><a href="#ステップ4網羅的にエラーハンドリングする">ステップ4:網羅的にエラーハンドリングする</a></li> </ul> </li> <li><a href="#おわりに">おわりに</a></li> <li><a href="#付録">付録</a><ul> <li><a href="#TypeScriptの全文サンプル">TypeScriptの全文サンプル</a></li> </ul> </li> </ul> <h1 id="はじめに">はじめに</h1> <p>エラーハンドリングは重要な処理にも関わらず、静的型付け言語の恩恵を十分に活かしきれていない領域です。特にTypeScriptを用いた開発では、その発展途上な言語仕様も手伝い、型安全なエラーハンドリングはそもそも実現が難しいという実情がありました。</p> <p>しかし昨今の周辺ライブラリの発展により、型安全なエラーハンドリングを実際の開発現場で実現できるようになってきました。</p> <p>この記事ではScott Wlaschinの2018年の書籍『Domain Modeling Made Functional(以後、「DMMF本」)』で提唱されているRailway Oriented Programmingと呼ばれるパラダイムを借り、TypeScriptを用いた開発でどのように型安全なエラーハンドリングを担保できるかを検討していきます。</p> <h1 id="Domain-Modeling-Made-Functionalというスゴ本">Domain Modeling Made Functionalというスゴ本</h1> <p><div class="hatena-asin-detail"><a href="https://www.amazon.co.jp/dp/B07B44BPFB?tag=hatena-22&amp;linkCode=ogi&amp;th=1&amp;psc=1" class="hatena-asin-detail-image-link" target="_blank" rel="noopener"><img src="https://m.media-amazon.com/images/I/511O5zAOJiL._SL500_.jpg" class="hatena-asin-detail-image" alt="Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# (English Edition)" title="Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# (English Edition)"></a><div class="hatena-asin-detail-info"><p class="hatena-asin-detail-title"><a href="https://www.amazon.co.jp/dp/B07B44BPFB?tag=hatena-22&amp;linkCode=ogi&amp;th=1&amp;psc=1" target="_blank" rel="noopener">Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# (English Edition)</a></p><ul class="hatena-asin-detail-meta"><li><span class="hatena-asin-detail-label">作者:</span><a href="https://d.hatena.ne.jp/keyword/Wlaschin%2C%20Scott" class="keyword">Wlaschin, Scott</a></li><li>Pragmatic Bookshelf</li></ul><a href="https://www.amazon.co.jp/dp/B07B44BPFB?tag=hatena-22&amp;linkCode=ogi&amp;th=1&amp;psc=1" class="asin-detail-buy" target="_blank" rel="noopener">Amazon</a></div></div></p> <p>DMMF本は <strong>Tackle Software Complexity with Domain-Driven Design and F#</strong> という副題が示す通り、F#を用いてDDDをチュートリアル形式で学ぶという論旨です。比較的マイナーなF#を用いる所で抵抗のある方もいると思いますが、著者が前書きで述べている通り、書籍内のアイデアはF#以外の静的型付け言語を用いた開発でも広く適用可能です。</p> <p>DMMF本には多くのアイデアが散りばめられていますが、この記事ではそのうち <strong>Railway Oriented Programming</strong> というエラーハンドリングのテクニックにフォーカスしています。</p> <p>ここでハードルになるのが、F#に実装されているがTypeScriptには実装されていない言語仕様で、具体的には次の2つです。</p> <ul> <li><strong>パターンマッチング</strong></li> <li><strong>パイプライン</strong></li> </ul> <p>いずれも関数型プログラミングにおいて重要な働きをします。F#とTypeScriptは共にマルチパラダイムな言語と見做されてますが、こと関数型言語として見た場合、まだTypeScriptはF#ほどには成熟していません。</p> <p>しかしTypeScriptであっても、<strong>パターンマッチング</strong> と <strong>パイプライン</strong> はユーザーランドで実装可能あり、これらの機能を提供する優れたライブラリが存在します。この記事ではそれらのライブラリのメンテナに感謝しつつ使用しながら論を進めます。</p> <p>なお、Railway Oriented ProgrammingはDMMF本の著者が自身のブログでも紹介しています<a href="#f-1a5111e4" id="fn-1a5111e4" name="fn-1a5111e4" title="この記事の中で用いた画像はScott Wlaschinの登壇スライドから切り出していますが、氏が画像使用をブログ内で許諾されています。">*2</a>。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ffsharpforfunandprofit.com%2Frop%2F" title="Railway Oriented Programming | F# for fun and profit" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://fsharpforfunandprofit.com/rop/">fsharpforfunandprofit.com</a></cite></p> <h2 id="補講Make-Illegal-States-Unrepresentable">補講:Make Illegal States Unrepresentable</h2> <p>エラーハンドリングのスコープからは外れますが、DMMF本の中で提唱される重要な警句として <strong>Make Illegal States Unrepresentable</strong> があります。</p> <p>要約すると <strong>有り得る状態だけを型定義するべき</strong> という主張で、具体的には下記サンプルコードに示すような状態を指します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">/** ⛔️ アンチパターン */</span> <span class="synStatement">type</span> User <span class="synStatement">=</span> <span class="synIdentifier">{</span> name: <span class="synType">string</span><span class="synStatement">;</span> emailVerified: <span class="synType">boolean</span><span class="synStatement">;</span> email: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synComment">/** ✅ 望ましい型定義 */</span> <span class="synStatement">type</span> User <span class="synStatement">=</span> <span class="synIdentifier">{</span> name: <span class="synType">string</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> &amp; <span class="synStatement">(</span> <span class="synComment">/** 認証済みの場合:確実にメールアドレスが存在する */</span> <span class="synIdentifier">{</span> emailVerified: <span class="synConstant">true</span><span class="synStatement">;</span> email: <span class="synType">string</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synComment">/** 認証済みでない場合:メールアドレスは null */</span> | <span class="synIdentifier">{</span> emailVerified: <span class="synConstant">false</span> email: <span class="synType">null</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">)</span> </pre> <p>アンチパターンとして示した前者の型定義の方が簡潔ですが、後者のように型が取りうる状態を狭めることで、より型安全な開発の強制が可能です。もちろんエラーハンドリングでも威力を発揮します。</p> <p>静的型付け言語による開発には、型を可能な限り厳密に定義する態度をデフォルトとし、型の緩和はイレギュラーとして扱う、というセオリーがあります。これは主にフロントエンドにおけるテスト戦略として広く受け入れられつつあるTesting Trophyの考えとも親和しています。</p> <p>📝 Testing Trophyは下記の記事で概説しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2022%2F08%2F01%2F110000" title="今さら聞けないビジュアルリグレッションテストをChromaticで始める - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2022/08/01/110000">buildersbox.corp-sansan.com</a></cite></p> <h1 id="バックエンドの処理を抽象化する">バックエンドの処理を抽象化する</h1> <p>一つのAPIエンドポイントの仕事を抽象化すると、リクエストという入力に対してレスポンスという出力を吐き出す単一の関数と見なせます。さらにその関数は、より小さな関数(以後、「サブ関数」)を連結したパイプラインとして構築できます。DBの読み書きやネットワーク通信は、関数の副作用として扱います。</p> <p>ここでは例として、ユーザー名の変更を受け付けるPUTリクエストのAPIハンドラを考えます。このAPIハンドラは、次に示すようなサブ関数の連結として解釈できます。</p> <ol> <li>リクエストからパラメータを抜き出す</li> <li>パラメータをバリデートする</li> <li>DBレコードを更新する</li> <li>ユーザーにメールを送信する</li> <li>レスポンスを生成する</li> </ol> <p>バックエンドにおける実装業務とは、主にこのようなパイプラインの構築作業と言い換えられます。</p> <h1 id="手続き型プログラミングの典型例">手続き型プログラミングの典型例</h1> <p>上記のAPIハンドラを手続き型プログラミングで実装する場合、典型的には下記のようなコードになります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">/**</span> <span class="synComment"> * リクエストを元にユーザー名を変更する</span> <span class="synComment"> */</span> <span class="synStatement">async</span> <span class="synStatement">function</span> handleUpdateUser<span class="synStatement">(</span> request: express.Request<span class="synStatement">,</span> response: express.Response <span class="synStatement">)</span>: <span class="synSpecial">Promise</span><span class="synStatement">&lt;</span><span class="synType">void</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synSpecial">try</span> <span class="synIdentifier">{</span> <span class="synComment">// 1. リクエストからパラメータを抜き出す</span> <span class="synType">const</span> params <span class="synStatement">=</span> <span class="synIdentifier">{</span> userId: request.session.userId<span class="synStatement">,</span> name: request.params.newName<span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synComment">// 2. パラメータをバリデートする</span> <span class="synType">const</span> isValid <span class="synStatement">=</span> validateName<span class="synStatement">(</span>params.name<span class="synStatement">);</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>isValid<span class="synStatement">)</span> <span class="synSpecial">throw</span> <span class="synStatement">new</span> ApiError<span class="synStatement">(</span><span class="synConstant">400</span><span class="synStatement">,</span> <span class="synConstant">`Your name is invalid.`</span><span class="synStatement">);</span> <span class="synComment">// 3. DBレコードを更新する</span> <span class="synType">const</span> result <span class="synStatement">=</span> <span class="synStatement">await</span> updateUserInDB<span class="synStatement">(</span>params.userId<span class="synStatement">,</span> params.name<span class="synStatement">);</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>result.success<span class="synStatement">)</span> <span class="synSpecial">throw</span> <span class="synStatement">new</span> ApiError<span class="synStatement">(</span><span class="synConstant">500</span><span class="synStatement">,</span> <span class="synConstant">`Can't update user's name.`</span><span class="synStatement">);</span> <span class="synComment">// 4. ユーザーにメールを送信する</span> <span class="synStatement">await</span> sendVerificationEmail<span class="synStatement">(</span>params.email<span class="synStatement">);</span> <span class="synComment">// 5. レスポンスを生成する(正常系)</span> response.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">)</span>.json<span class="synStatement">(</span><span class="synIdentifier">{</span> userId: params.userId <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synSpecial">catch</span><span class="synStatement">(</span>error<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synComment">// 5-b. レスポンスを生成する(異常系)</span> response .<span class="synStatement">status(</span>error.<span class="synStatement">status</span> <span class="synConstant">||</span> <span class="synConstant">500</span><span class="synStatement">)</span> .json<span class="synStatement">(</span><span class="synIdentifier">{</span> message: error.message <span class="synConstant">||</span> <span class="synConstant">&quot;Internal error.&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>一見よくある実装例ですが、このコードにおける問題点は何でしょうか?ここではペインの大きな3つの課題を挙げます。</p> <h2 id="課題1制約のないエラーハンドリング">課題1:制約のないエラーハンドリング</h2> <p>例外スロー、Result型、単純なboolean返却など、多様な形で表現されたエラーが混在しています。必然的にhandleUpdateUser関数ではそれらのエラーに対して個別の対応が必要になっています。</p> <p>メンタルモデルは定義の難しい概念ですが、ここではエンジニアが設計や実装を行う上で頭の中に描く抽象イメージを指すことにします。例えば前章で述べた、APIエンドポイントの仕事はサブ関数のパイプライン、という抽象イメージがまさにメンタルモデルに当たります。</p> <p>エラーハンドリングの形が多様になると、このメンタルモデルの複雑性が高まり、肝心の設計や実装に割ける脳内メモリが圧迫されてしまいます。</p> <h2 id="課題2低い可読性">課題2:低い可読性</h2> <p>可読性が高いと言われるコードの体現方法として、「自己文書化されたコード」というアイデアがあります。この言葉も明確な定義がありませんが、ここではコードがあたかも自然言語で書かれているような状態を指します。</p> <p>handleUpdateUser関数の処理は、サブ関数を逐次的に呼び出しているだけですが、エラー表現が多様なせいで自己文書化されたコードとは程遠い実装です。</p> <p>プログラミング言語の発展の歴史を振り返ると、可読性の向上が一つの大きな進化の方向性と言えます。始めに機械語からアセンブリ言語への転向があり、FORTRAN(1957年)の誕生を皮切りに発展した高水準言語は、数学や英文に近い構文を取り入れて可読性の向上を図ってきました。</p> <p>パイプラインを始めとする言語仕様や、関数型プログラミングといったパラダイムも、この大きな方向性から必然的に生まれた発明という解釈も可能でしょう。</p> <h2 id="課題3エラーハンドリングの低い網羅性">課題3:エラーハンドリングの低い網羅性</h2> <p>サンプルコードは、各サブ関数のエラーを全て例外に変換した後、 エラーハンドリングはcatch節 に集約させるという整理になっています。この書き方の問題は、例外への変換過程でエラーの種類という重要な型が抜け落ちてしまっている点です。つまりcatch節のerror定数がunknown型である点です。</p> <p>もちろんcatch節のブロックの中で、error定数のインスタンスタイプによって分岐処理を書くことは可能です(例えばメール送信エラーの場合は対象のメールアドレスをロギングする、など)。ですが、サブ関数が出力し得る、全てのエラーの種類を、catch節が網羅的に考慮できているかどうかは、サンプルコードでは担保できていません。</p> <h1 id="Railway-Oriented-Programming">Railway Oriented Programming</h1> <p>Railway Oriented Programmingは、DMMF本で提唱されているエラーハンドリングの手法で、関数をレールと見なすメタファーが特徴です。ここでは順を追って要点を概説します。</p> <p>下図は入力と出力が1:1の関数を、単線のレールとして表現しています。</p> <p><figure class="figure-image figure-image-fotolife" title="1:1の関数"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aki202/20240229/20240229224534.png" width="1200" height="326" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>1:1の関数</figcaption></figure></p> <p>もし関数がエラーを返す場合、下図のようにレールは二股になります。</p> <p><figure class="figure-image figure-image-fotolife" title="1:2の関数"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aki202/20240229/20240229224706.png" width="1200" height="357" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>1:2の関数</figcaption></figure></p> <p>関数型プログラミングでは、文の代わりに式を使い実装を行います。文と式の明確な定義は専門書や他の解説に譲り、ここでは実用的な相違点とサンプルコード <a href="#f-54c9133b" id="fn-54c9133b" name="fn-54c9133b" title="このサンプルコードでは void を返す関数呼び出しも文に分類しています。void を返す関数は、副作用を起こすだけの関数であるため、値を生成しているとは言えないという理由です。">*3</a>だけを記載します。</p> <ul> <li>式:値を生成するもの</li> <li>文:値を生成しないもの</li> </ul> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">let</span> result<span class="synStatement">;</span> <span class="synComment">// 文: 変数定義</span> result <span class="synStatement">=</span> <span class="synConstant">5</span> * <span class="synConstant">3</span><span class="synStatement">;</span> <span class="synComment">// 文: 変数代入; `5 * 3` は 式</span> <span class="synStatement">if</span> <span class="synStatement">(</span>result <span class="synStatement">&gt;</span> <span class="synConstant">10</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synComment">// 文: if 文</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span><span class="synConstant">&quot;Result is greater than 10.&quot;</span><span class="synStatement">);</span> <span class="synComment">// 文: console.log 関数呼び出し</span> <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span><span class="synConstant">&quot;Result is 10 or less.&quot;</span><span class="synStatement">);</span> <span class="synComment">// 文: console.log 関数呼び出し</span> <span class="synIdentifier">}</span> <span class="synType">const</span> <span class="synStatement">double</span> <span class="synStatement">=</span> <span class="synStatement">(</span>x: <span class="synType">number</span><span class="synStatement">)</span>: <span class="synType">number</span> <span class="synStatement">=&gt;</span> x * <span class="synConstant">2</span><span class="synStatement">;</span> <span class="synComment">// 文: 関数定義; `x * 2` は 式</span> <span class="synSpecial">console</span>.log<span class="synStatement">(double(</span><span class="synConstant">4</span><span class="synStatement">));</span> <span class="synComment">// 文: console.log 関数呼び出し; `double(4)` は式</span> </pre> <p>下図のように、Railway Oriented Programmingでは複数のサブ関数に対し、文を使わず式で連結させようとします。</p> <p><figure class="figure-image figure-image-fotolife" title="サブ関数の連結"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aki202/20240229/20240229224803.png" width="1200" height="221" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>サブ関数の連結</figcaption></figure></p> <p>ところが、各サブ関数の入力は「一つ前の正常系の出力」のみを期待しているため、このままでは連結できません。そこで下図のように、各サブ関数の入力も二股にすることで連結させます。</p> <p><figure class="figure-image figure-image-fotolife" title="2:2の関数にして連結させる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aki202/20240229/20240229224836.png" width="1200" height="207" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>2:2の関数にして連結させる</figcaption></figure></p> <p>この結果、正常系は緑のレールに沿って実行が進み、いずれかのサブ関数でエラーが生じた場合は赤いレールへ進むというシンプルな整理ができるようになりました。</p> <p>TypeScriptを使いサブ関数を連結させパイプラインを構築し、さらに型的に安全なエラーハンドリングを実現するには、下記の4つのステップが必要です。</p> <ul> <li>ステップ1:サブ関数の出力はResult型で表現する</li> <li>ステップ2:サブ関数にResult型を入力できるようにする</li> <li>ステップ3:サブ関数を連結する</li> <li>ステップ4:網羅的にエラーハンドリングする</li> </ul> <p>順番に見ていきましょう。</p> <h1 id="TypeScriptで型安全にエラーハンドリングする">TypeScriptで型安全にエラーハンドリングする</h1> <h2 id="ステップ1サブ関数の出力はResult型で表現する">ステップ1:サブ関数の出力はResult型で表現する</h2> <p>下記のようなResult型を用意し、サブ関数の返り値はこのResult型に統一します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> Failure <span class="synStatement">=</span> <span class="synIdentifier">{</span> errorCode: <span class="synType">number</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">type</span> Result<span class="synStatement">&lt;</span>Ok<span class="synStatement">,</span> Ng <span class="synStatement">extends</span> Failure<span class="synStatement">&gt;</span> <span class="synStatement">=</span> <span class="synIdentifier">{</span> success: <span class="synConstant">true</span><span class="synStatement">;</span> data: Ok<span class="synStatement">;</span> <span class="synIdentifier">}</span> | <span class="synIdentifier">{</span> success: <span class="synConstant">false</span><span class="synStatement">;</span> error: Ng<span class="synStatement">;</span> <span class="synIdentifier">}</span> </pre> <p>Failure型はサブ関数の異常時の返り値に対応します。ここでは簡単のため<code>errorCode</code>というnumber型を内包しているだけですが、実際の開発では標準Errorクラスのサブクラスなどを設定します。</p> <p>Result型は正常時または異常時の返り値のいずれかを内包します。このResult型を返す形でサブ関数を実装すると、例えば下記のような定義になります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">/**</span> <span class="synComment"> * 2. パラメータをバリデートするサブ関数</span> <span class="synComment"> */</span> <span class="synStatement">function</span> validateName<span class="synStatement">(</span> args: <span class="synIdentifier">{</span> id: <span class="synType">number</span><span class="synStatement">,</span> name: <span class="synType">string</span> <span class="synIdentifier">}</span> <span class="synStatement">)</span>: Result<span class="synStatement">&lt;</span><span class="synIdentifier">{</span> id: <span class="synType">number</span><span class="synStatement">,</span> name: <span class="synType">string</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> errorCode: <span class="synConstant">100</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> args.name <span class="synStatement">!==</span> <span class="synConstant">&quot;&quot;</span> ? <span class="synIdentifier">{</span> success: <span class="synConstant">true</span><span class="synStatement">,</span> data: args <span class="synIdentifier">}</span> : <span class="synIdentifier">{</span> success: <span class="synConstant">false</span><span class="synStatement">,</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">100</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>入力されるパラメータに問題がなければそのまま入力をResult型にして返し、問題があれば異常系のResult型を返しています。</p> <h2 id="ステップ2サブ関数にResult型を入力できるようにする">ステップ2:サブ関数にResult型を入力できるようにする</h2> <p>各サブ関数が前のサブ関数の正常な出力をその入力として想定しています。このため、サブ関数を連結することができません。</p> <p>そこでサブ関数をResult型を受け取れる形に変換する、薄いラッパー関数を作ります。ここではそのラッパー関数をbypass関数と名付けます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">/**</span> <span class="synComment"> * サブ関数をResult型を入力できる関数に変換する</span> <span class="synComment"> *</span> <span class="synComment"> * 変換後のサブ関数は、入力( = 一つ前のサブ関数の出力)によって次のように分岐処理する</span> <span class="synComment"> * (A) 入力が正常系Resultの場合:オリジナルのサブ関数を呼び出し、その返り値を返す</span> <span class="synComment"> * (B) 入力が異常系Resultの場合:入力をそのまま返す</span> <span class="synComment"> */</span> <span class="synStatement">function</span> bypass<span class="synStatement">&lt;</span> PreviousOk<span class="synStatement">,</span> PreviousNg <span class="synStatement">extends</span> Failure<span class="synStatement">,</span> NextOk<span class="synStatement">,</span> NextNg <span class="synStatement">extends</span> Failure <span class="synStatement">&gt;(</span> func: <span class="synStatement">(</span>i: PreviousOk<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> Result<span class="synStatement">&lt;</span>NextOk<span class="synStatement">,</span> NextNg<span class="synStatement">&gt;,</span> <span class="synComment">// 引数はオリジナルのサブ関数</span> <span class="synStatement">)</span>: <span class="synStatement">(</span>input: Result<span class="synStatement">&lt;</span>PreviousOk<span class="synStatement">,</span> PreviousNg<span class="synStatement">&gt;)</span> <span class="synStatement">=&gt;</span> Result<span class="synStatement">&lt;</span>NextOk<span class="synStatement">,</span> PreviousNg | NextNg<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">(</span>input<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> input.success ? func<span class="synStatement">(</span>input.data<span class="synStatement">)</span> : input<span class="synStatement">;</span> <span class="synIdentifier">}</span> </pre> <p>bypass関数により変換されたサブ関数は、下記のようにシグネチャが変更されます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">/**</span> <span class="synComment"> * BEFORE:引数は正常系のデータ型のみ</span> <span class="synComment"> */</span> <span class="synStatement">type</span> Before <span class="synStatement">=</span> Parameters<span class="synStatement">&lt;typeof</span> validateName<span class="synStatement">&gt;;</span> <span class="synComment">/** { id: number; name: string; } */</span> <span class="synComment">/**</span> <span class="synComment"> * AFTER:引数はResult型</span> <span class="synComment"> */</span> <span class="synStatement">type</span> After <span class="synStatement">=</span> Parameters<span class="synStatement">&lt;typeof</span> bypass<span class="synStatement">(</span>validateName<span class="synStatement">)&gt;;</span> <span class="synComment">/** Result&lt;{ id: number; name: string; }, Failure&gt; */</span> </pre> <h2 id="ステップ3サブ関数を連結する">ステップ3:サブ関数を連結する</h2> <p>本来であればTypeScriptのPipeline Operator構文を使いたいのですが、Pipeline Operatorは2024年3月現在でstage-2段階のフェーズにあり仕様が定まっていません。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Ftc39%2Fproposal-pipeline-operator" title="GitHub - tc39/proposal-pipeline-operator: A proposal for adding a useful pipe operator to JavaScript." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/tc39/proposal-pipeline-operator">github.com</a></cite></p> <p><a href="https://www.npmjs.com/package/ramda">Ramda.js</a> は関数型プログラミングに必要な関数群を提供してくれるライブラリです。そのうちの一つにpipe関数があり、これを使うことでPipeline Operatorに準じた実装が可能になります。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Framda" title="ramda" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.npmjs.com/package/ramda">www.npmjs.com</a></cite></p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> pipe <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;ramda&quot;</span><span class="synStatement">;</span> <span class="synStatement">async</span> <span class="synStatement">function</span> handleUpdateUser<span class="synStatement">(</span>request: express.Request<span class="synStatement">)</span>: <span class="synSpecial">Promise</span><span class="synStatement">&lt;</span><span class="synType">void</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> pipe<span class="synStatement">(</span> extractParams<span class="synStatement">(</span>request<span class="synStatement">),</span> <span class="synComment">// 1. リクエストからパラメータを抜き出す</span> bypass<span class="synStatement">(</span>validateEmail<span class="synStatement">),</span> <span class="synComment">// 2. パラメータをバリデートする</span> bypass<span class="synStatement">(</span>updateUserInDB<span class="synStatement">),</span> <span class="synComment">// 3. DBレコードを更新する</span> bypass<span class="synStatement">(</span>sendVerificationEmail<span class="synStatement">),</span> <span class="synComment">// 4. ユーザーにメールを送信する</span> bypass<span class="synStatement">(</span>sendResponse<span class="synStatement">),</span> <span class="synComment">// 5. 正常系のレスポンスを生成する</span> <span class="synStatement">(</span>result<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synComment">/** ここでエラーハンドリングする */</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>最初のサブ関数にはbypassを適用していないことに注意してください。最初のサブ関数の引数はResult型である必要がありません。</p> <p>なお、もしPipeline Operatorが使えるようになった場合は、下記のような形で実装できるはずです(あくまでイメージです)。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// NOTE: Pipeline Operator (Stage: 2) を利用する場合</span> <span class="synStatement">async</span> <span class="synStatement">function</span> handleUpdateUser<span class="synStatement">(</span>request: express.Request<span class="synStatement">)</span>: <span class="synSpecial">Promise</span><span class="synStatement">&lt;</span><span class="synType">void</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> result <span class="synStatement">=</span> extractParams<span class="synStatement">(</span>request<span class="synStatement">)</span> <span class="synComment">// 1. リクエストからパラメータを抜き出す</span> |<span class="synStatement">&gt;</span> bypass<span class="synStatement">(</span>validateEmail<span class="synStatement">)(</span>%<span class="synStatement">)</span> <span class="synComment">// 2. パラメータをバリデートする</span> |<span class="synStatement">&gt;</span> bypass<span class="synStatement">(</span>updateUserInDB<span class="synStatement">(</span>%<span class="synStatement">)</span> <span class="synComment">// 3. 既存のDBレコードを更新する</span> |<span class="synStatement">&gt;</span> bypass<span class="synStatement">(</span>sendVerificationEmail<span class="synStatement">)(</span>%<span class="synStatement">)</span> <span class="synComment">// 4. 確認メールを送信する</span> |<span class="synStatement">&gt;</span> bypass<span class="synStatement">(</span>sendResponse<span class="synStatement">)(</span>%<span class="synStatement">)</span> <span class="synComment">// 5. 正常系のレスポンスを生成する</span> |<span class="synStatement">&gt;</span> <span class="synStatement">(</span>result<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synComment">/** ここでエラーハンドリングする */</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <h2 id="ステップ4網羅的にエラーハンドリングする">ステップ4:網羅的にエラーハンドリングする</h2> <p>パイプラインにおける最後のサブ関数には、パイプラインの中で生じ得る、全てのエラーをハンドリングする責任があります。そのために必要なピースは下記の2つです。</p> <p><strong>生じ得る全てのエラーの型を抽出できる</strong></p> <p>これは既に完了しています。最後のサブ関数の引数であるResult型は、それまでのサブ関数が返し得る異常系の型を全て知っています。</p> <p><strong>網羅的な分岐処理が書ける</strong></p> <p>ユーザーランドでパターンマッチングを実現できるライブラリ <a href="https://www.npmjs.com/package/ts-pattern">ts-pattern</a> を用います。次のように網羅的な分岐処理を静的に保証できます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> match <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;ts-pattern&quot;</span><span class="synStatement">;</span> <span class="synStatement">type</span> Role <span class="synStatement">=</span> <span class="synConstant">&quot;admin&quot;</span> | <span class="synConstant">&quot;member&quot;</span> | <span class="synConstant">&quot;guest&quot;</span><span class="synStatement">;</span> <span class="synType">const</span> role: Role <span class="synStatement">=</span> ...<span class="synStatement">;</span> match<span class="synStatement">(</span>role<span class="synStatement">)</span> .<span class="synStatement">with(</span><span class="synConstant">&quot;admin&quot;</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span>...<span class="synIdentifier">}</span><span class="synStatement">)</span> .<span class="synStatement">with(</span><span class="synConstant">&quot;member&quot;</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span>...<span class="synIdentifier">}</span><span class="synStatement">)</span> .exhaustive<span class="synStatement">();</span> <span class="synComment">/** ⛔️ &quot;guest&quot; に対する分岐がないため型エラーとなる */</span> </pre> <p>📝 ts-patternについては下記の記事でも紹介しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fzenn.dev%2Faki202%2Farticles%2F5d725c080640f9" title="ts-patternでTypeScriptにパターンマッチングを持ち込み、より型安全な世界へ" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://zenn.dev/aki202/articles/5d725c080640f9">zenn.dev</a></cite></p> <p>以上で準備は整いました。次のようにエラーハンドリングできます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> pipe <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;ramda&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> match <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;ts-pattern&quot;</span> <span class="synStatement">async</span> <span class="synStatement">function</span> handleUpdateUser<span class="synStatement">(</span>request: express.Request<span class="synStatement">)</span>: <span class="synSpecial">Promise</span><span class="synStatement">&lt;</span><span class="synType">void</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> result <span class="synStatement">=</span> pipe<span class="synStatement">(</span> extractParams<span class="synStatement">(</span>request<span class="synStatement">),</span> <span class="synComment">// 1. リクエストからパラメータを抽出する</span> bypass<span class="synStatement">(</span>validateEmail<span class="synStatement">),</span> <span class="synComment">// 2. パラメータをバリデートする</span> bypass<span class="synStatement">(</span>updateUserInDB<span class="synStatement">),</span> <span class="synComment">// 3. 既存のDBレコードを更新する</span> bypass<span class="synStatement">(</span>sendVerificationEmail<span class="synStatement">),</span> <span class="synComment">// 4. 確認メールを送信する</span> bypass<span class="synStatement">(</span>sendResponse<span class="synStatement">),</span> <span class="synComment">// 5. 正常系のレスポンスを生成する</span> <span class="synComment">/** 網羅的なエラーハンドリングを型で保証する */</span> <span class="synStatement">(</span>result<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> match<span class="synStatement">(</span>output<span class="synStatement">)</span> <span class="synComment">/** 1. extractParams で発生し得るエラー */</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">100</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> ... <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synComment">/** 2. validateEmail で発生し得るエラー */</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">200</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> ... <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synComment">/** 3. updateUserInDB で発生し得るエラー */</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">300</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> ... <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synComment">/** 4. sendVerificationEmail で発生し得るエラー */</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">400</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> ... <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synComment">/** 5. sendResponse で発生し得るエラー */</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">500</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> ... <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synComment">/** 正常系なら何もしない */</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> success: <span class="synConstant">true</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{}</span><span class="synStatement">)</span> .exhaustive<span class="synStatement">()</span> <span class="synComment">/** 💡 全てのエラーに対応できていない場合は型エラーになる */</span> <span class="synStatement">)</span> <span class="synIdentifier">}</span> </pre> <h1 id="おわりに">おわりに</h1> <p>この記事では関数型プログラミングにおける主要な言語仕様であるパイプラインとパターンマッチングを、ライブラリの力を借りながらTypeScript開発に持ち込み、網羅的なエラーハンドリングを実践する手法を検討しました。</p> <p>これにより従来の手続き型的なプログラミングを脱することで、次のようなメリットが期待できます。</p> <ul> <li>エラーの表現方法を統一しメンタルモデルをシンプルに保てる</li> <li>エラーハンドリングの網羅性を型的に保証できる</li> <li>一連の処理をサブ関数の連結として表現することで可読性が向上する</li> </ul> <p>※この記事の作成に当たり、Digitization部の小田 崇之, 薩田 和弘, 湯村 直樹 に助言をもらいました。</p> <h1 id="付録">付録</h1> <h2 id="TypeScriptの全文サンプル">TypeScriptの全文サンプル</h2> <p>下記のバージョンで動作確認しています。</p> <ul> <li>typescript:<code>5.3.3</code></li> <li>ramda:<code>0.29.1</code></li> <li>ts-pattern:<code>5.0.8</code></li> </ul> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> pipe <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'ramda'</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> match <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'ts-pattern'</span><span class="synStatement">;</span> <span class="synStatement">type</span> Result<span class="synStatement">&lt;</span>Ok<span class="synStatement">,</span> Ng<span class="synStatement">&gt;</span> <span class="synStatement">=</span> <span class="synIdentifier">{</span> success: <span class="synConstant">true</span><span class="synStatement">;</span> data: Ok<span class="synStatement">;</span> <span class="synIdentifier">}</span> | <span class="synIdentifier">{</span> success: <span class="synConstant">false</span><span class="synStatement">;</span> error: Ng<span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Failure <span class="synStatement">=</span> <span class="synIdentifier">{</span> errorCode: <span class="synType">number</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">type</span> Func1Output <span class="synStatement">=</span> <span class="synConstant">&quot;func1's success output&quot;</span><span class="synStatement">;</span> <span class="synStatement">type</span> Func1Error <span class="synStatement">=</span> <span class="synIdentifier">{</span> errorCode: <span class="synConstant">100</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">function</span> func1<span class="synStatement">(</span>p: <span class="synType">any</span><span class="synStatement">)</span>: Result<span class="synStatement">&lt;</span>Func1Output<span class="synStatement">,</span> Func1Error<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> success: <span class="synConstant">true</span><span class="synStatement">,</span> data: <span class="synConstant">&quot;func1's success output&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Func2Output <span class="synStatement">=</span> <span class="synConstant">&quot;func2's success output&quot;</span><span class="synStatement">;</span> <span class="synStatement">type</span> Func2Error <span class="synStatement">=</span> <span class="synIdentifier">{</span> errorCode: <span class="synConstant">200</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">function</span> func2<span class="synStatement">(</span>p: Func1Output<span class="synStatement">)</span>: Result<span class="synStatement">&lt;</span>Func2Output<span class="synStatement">,</span> Func2Error<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> success: <span class="synConstant">true</span><span class="synStatement">,</span> data: <span class="synConstant">&quot;func2's success output&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Func3Output <span class="synStatement">=</span> <span class="synConstant">&quot;func3's success output&quot;</span><span class="synStatement">;</span> <span class="synStatement">type</span> Func3Error <span class="synStatement">=</span> <span class="synIdentifier">{</span> errorCode: <span class="synConstant">300</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">function</span> func3<span class="synStatement">(</span>p: Func2Output<span class="synStatement">)</span>: Result<span class="synStatement">&lt;</span>Func3Output<span class="synStatement">,</span> Func3Error<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> success: <span class="synConstant">true</span><span class="synStatement">,</span> data: <span class="synConstant">&quot;func3's success output&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">function</span> bypass<span class="synStatement">&lt;</span> PreviousOk<span class="synStatement">,</span> PreviousNg <span class="synStatement">extends</span> Failure<span class="synStatement">,</span> NextOk<span class="synStatement">,</span> NextNg <span class="synStatement">extends</span> Failure <span class="synStatement">&gt;(</span> func: <span class="synStatement">(</span>i: PreviousOk<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> Result<span class="synStatement">&lt;</span>NextOk<span class="synStatement">,</span> NextNg<span class="synStatement">&gt;,</span> <span class="synStatement">)</span>: <span class="synStatement">(</span>input: Result<span class="synStatement">&lt;</span>PreviousOk<span class="synStatement">,</span> PreviousNg<span class="synStatement">&gt;)</span> <span class="synStatement">=&gt;</span> Result<span class="synStatement">&lt;</span>NextOk<span class="synStatement">,</span> PreviousNg | NextNg<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">(</span>input<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> input.success ? func<span class="synStatement">(</span>input.data<span class="synStatement">)</span> : input<span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">function</span> main<span class="synStatement">()</span> <span class="synIdentifier">{</span> pipe<span class="synStatement">(</span> func1<span class="synStatement">,</span> bypass<span class="synStatement">(</span>func2<span class="synStatement">),</span> bypass<span class="synStatement">(</span>func3<span class="synStatement">),</span> <span class="synStatement">(</span>result<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> match<span class="synStatement">(</span>result<span class="synStatement">)</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> success: <span class="synConstant">true</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{}</span><span class="synStatement">)</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">100</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{}</span><span class="synStatement">)</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">200</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{}</span><span class="synStatement">)</span> .<span class="synStatement">with(</span><span class="synIdentifier">{</span> error: <span class="synIdentifier">{</span> errorCode: <span class="synConstant">300</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{}</span><span class="synStatement">)</span> .exhaustive<span class="synStatement">()</span> <span class="synStatement">)(</span><span class="synConstant">123</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> </pre> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> <div class="footnote"> <p class="footnote"><a href="#fn-cf211e54" id="f-cf211e54" name="f-cf211e54" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">クラウド請求書受領サービス「Bill One」が提供するデータ化機能。</span></p> <p class="footnote"><a href="#fn-1a5111e4" id="f-1a5111e4" name="f-1a5111e4" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">この記事の中で用いた画像はScott Wlaschinの登壇スライドから切り出していますが、氏が画像使用をブログ内で許諾されています。</span></p> <p class="footnote"><a href="#fn-54c9133b" id="f-54c9133b" name="f-54c9133b" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">このサンプルコードでは void を返す関数呼び出しも文に分類しています。void を返す関数は、副作用を起こすだけの関数であるため、値を生成しているとは言えないという理由です。</span></p> </div> aki202 チーム開発合宿 2024 in 徳島県神山町(レポート編) hatenablog://entry/6801883189089633893 2024-03-25T11:00:00+09:00 2024-03-25T11:00:04+09:00 こんにちは、研究開発部 Architectグループの辻田です。 今年も神山ラボへ開発合宿に行ってきました!新メンバーも加わり、チームとしては2回目の開発合宿*1です。本記事では合宿レポートを紹介します。合宿で取り組んだ技術的な内容は別記事で出しますので乞うご期待です。 本記事は【R&D DevOps通信】という連載記事のひとつです。 *1:前回の合宿記事はこちらです。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20221205/20221205105323.jpg" width="1024" height="385" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、研究開発部 Architectグループの辻田です。</p> <p>今年も神山ラボへ開発合宿に行ってきました!新メンバーも加わり、チームとしては2回目の開発合宿<a href="#f-2009ff2d" id="fn-2009ff2d" name="fn-2009ff2d" title="前回の合宿記事はこちらです。">*1</a>です。本記事では合宿レポートを紹介します。合宿で取り組んだ技術的な内容は別記事で出しますので乞うご期待です。</p> <p>本記事は<a href="https://buildersbox.corp-sansan.com/search?q=R%26D+DevOps%E9%80%9A%E4%BF%A1">&#x3010;R&amp;D DevOps&#x901A;&#x4FE1;&#x3011;</a>という連載記事のひとつです。</p> <h2 id="開発合宿の概要">開発合宿の概要</h2> <h3 id="目的">目的</h3> <p>今回の合宿の目的は以下の通りです。</p> <ul> <li>チームメンバー変更に伴ってメンバーの親睦を深める</li> <li><a href="https://jp.sansan.com/function/sansan-labs/">Sansan Labs</a>リリースの障壁となる多くの課題を短期間で改善する</li> </ul> <p>リモートワークが中心かつ、チームメンバーも東京と大阪にいるためなかなか全員が集まれる機会はありません。この1年間で新卒や中途のメンバーの加入もあり、オフラインで顔を合わせてチームの親睦を深めておきたいところでした。</p> <p>そしてSansan Labsで大きめのリリースを控えているため、それまでに障壁となる課題を一気に片付けるために合宿をすることになりました。</p> <h3 id="スケジュール">スケジュール</h3> <p>2024/2/28(水) ~ 2024/3/1(金) の二泊三日</p> <table> <thead> <tr> <th> 日付 </th> <th> 時間 </th> <th> 内容 </th> </tr> </thead> <tbody> <tr> <td> 2/28 </td> <td> -12:00 </td> <td> 移動 </td> </tr> <tr> <td> </td> <td> 12:00-13:00 </td> <td> お昼ごはん </td> </tr> <tr> <td> </td> <td> 13:00-19:00 </td> <td> 開発 </td> </tr> <tr> <td> </td> <td> 19:00- </td> <td> ご飯や風呂、自由時間 </td> </tr> <tr> <td> 2/29 </td> <td> 09:30-12:00 </td> <td> 開発 </td> </tr> <tr> <td> </td> <td> 12:00-13:00 </td> <td> お昼ごはん </td> </tr> <tr> <td> </td> <td> 13:00-19:00 </td> <td> 開発 </td> </tr> <tr> <td> </td> <td> 19:00- </td> <td> ご飯や風呂、自由時間 </td> </tr> <tr> <td> 3/1 </td> <td> 09:30-13:00 </td> <td> 開発 </td> </tr> <tr> <td> </td> <td> 13:00-14:00 </td> <td> お昼ごはん </td> </tr> <tr> <td> </td> <td> 14:00-16:00 </td> <td> 開発/レビュー </td> </tr> <tr> <td> </td> <td> 16:00-16:30 </td> <td> 片付け </td> </tr> <tr> <td> </td> <td> 16:30- </td> <td> 移動 </td> </tr> </tbody> </table> <h3 id="場所">場所</h3> <p>合宿場所に選んだのは、徳島県名西郡神山町にあるサテライトオフィスの神山ラボです。わざわざ遠い場所にまで行く理由は次の通りです。</p> <ul> <li>去年、神山合宿で成果を出せた <ul> <li>合宿の成果としてABテストリリースのリードタイムが7.8日から4.5日に短縮した</li> <li>合宿が含まれるスプリントのポイント消化は1.5倍だった</li> </ul> </li> <li>神山ラボは徳島県の中心部から車で1時間ほどの山奥にあり、自然に囲まれた環境である。年に1度くらいは日常から離れた田舎で集中して作業したい。</li> </ul> <h2 id="1日目">1日目</h2> <h3 id="移動">移動</h3> <p>東京組は飛行機、大阪組は高速バスでの移動です。この日は雲ひとつない晴天で、明石海峡大橋も映えていました。飛行機からは綺麗に富士山が見えたようです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310153726.jpg" width="675" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310153646.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="神山ラボに到着">神山ラボに到着</h3> <p>庭には梅の花が咲いていました。春が近いことを感じます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155806.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="お昼ごはん">お昼ごはん</h3> <p>お昼ごはんはラボの近くのうどん屋で食べました。あまりお腹が空いていなかったので小さなうどんを食べましたが、夕方にはお腹が空いてしまいました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155725.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="開発">開発</h3> <p>早速作業の開始です。今回は事前にタスクの担当を決めていたため、すぐに作業に取り掛かれました。</p> <p>この3日間で終わらせなければならないストーリーポイントは19です。直近2ヶ月の平均ストーリーポイントは11.8でしたので、半分の期間で2倍近いストーリーポイントを消化しなければなりません。</p> <p>夕食の時間までは設計の議論をし、各自黙々と作業を進めていきました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155755.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="ご飯や風呂自由時間">ご飯や風呂、自由時間</h3> <p>夕食は焼き肉を食べました。チームで同じ網を囲って肉を焼くと、コミュニケーションがより活発になる気がして好きです。 夕食後は風呂に入り、スマブラやマリカをして自由時間を過ごしました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155718.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="2日目">2日目</h2> <h3 id="起床">起床</h3> <p>朝は早起きしてラボ周辺を走りました。空気が澄んでいて気持ちよかったです。神山は平坦な道が少ないので、ここでトレーニングを積むと強くなれそうです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155736.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="開発-1">開発</h3> <p>2日目は移動時間もなく、丸一日みんな大好きな開発のお時間です。</p> <p>神山ラボにはペレットストーブがあります。定期的に薪をくべないと火が消えてしまうので、ポモドーロを回して休憩時間の度に薪をくべていました。その名も「薪ポモドーロ」です。とても集中できたのでおすすめです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155746.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="お昼ごはん-1">お昼ごはん</h3> <p>昼食はラボの徒歩圏内にあるお店で唐揚げ定食を食べました。唐揚げがサクサクでおかずの種類も豊富で美味しかったです。ご飯もおかわりできるので、お腹いっぱいになりました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155712.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="開発-2">開発</h3> <p>昼食後は再び開発に取り掛かります。薪ポモドーロを回して、夕食まで黙々と作業を進めていきました。</p> <p>2日目の時点でだいたいのチームの進捗が70%前後になりました。このままいけば、3日目には目標のストーリーポイントを達成できそうです。</p> <h3 id="ご飯や風呂自由時間-1">ご飯や風呂、自由時間</h3> <p>2日目の夕食はおしゃれなお店でいろいろな料理をみんなでシェアしました。お昼に唐揚げを食べましたが、夜も唐揚げをたくさん食べました。唐揚げは美味しいですね。</p> <p>ラボに戻ってからはプログラミング言語かるたをしたり、大富豪をしたりして少し夜ふかししました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155705.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155726.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="3日目">3日目</h2> <h3 id="起床-1">起床</h3> <p>前日に夜更かしをしてしまったため、この日の朝は走りにいく時間はありませんでした。寝不足気味ですが、鳥のさえずりを聞きながらの目覚めはとても気持ちが良かったです。</p> <p>朝食後はチームで進捗共有を行い、最終日の作業に取り掛かりました。</p> <h3 id="開発-3">開発</h3> <p>最後の追い込みです。各自が担当しているタスクを終わらせるために、集中して作業を進めていきました。</p> <p>全員が集中して作業していたため、この日の昼食は少し遅れてしまいました。</p> <h3 id="お昼ごはん-2">お昼ごはん</h3> <p>神山の食材を使ったランチを食べました。ここ数日野菜不足だったので、野菜がたくさん入った料理はとても美味しかったです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155659.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="開発レビュー">開発/レビュー</h3> <p>昼食後は各自残りの作業を終わらせ、レビュー会を行いました。合宿中に出たプルリクエストを修正して、どんどんマージしていきました。</p> <p>最終的にすべてのプルリクエストはマージできませんでしたが、残りは簡単な修正なので後日対応することにしました。</p> <p>この時点の消化ポイントは11でした。約3日間で約1週間分のポイントを消化できたので、上出来ではないでしょうか。合宿終了後に残り全てのプルリクエストをマージしたので、無事にリリースを迎えられそうです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240310/20240310155717.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="片付け">片付け</h3> <p>最終日なので、みんなで協力してお布団や部屋の片付けを行いました。最後の日はいつも以上に時間が経つのを早く感じます。</p> <h3 id="移動-1">移動</h3> <p>以上で全日程を終えて、神山町を後にします。現地解散なのでラボで集合写真を撮りました。</p> <h2 id="おわりに">おわりに</h2> <p>やはり神山には何もない田舎ゆえの集中力を持続できる環境があると思いました。東京、大阪からのアクセスはお世辞にも良いとはいえず、近くにコンビニもありません。だからこそ、集中を維持でき、気分転換に一足外に出れば自然の空気で一気にリフレッシュできるため、とても良い環境でした。</p> <p>また、チームメンバーとの親睦を深められたので、合宿の目的を達成できたと思います。</p> <p>Architectグループでは一緒に働く仲間を募集しています。</p> <p><a href="https://open.talentio.com/r/1/c/sansan/pages/76616?_gl=1*14rznce*_ga*MTg4NTg5MDMxLjE2NzAwNDU2Njg.*_ga_EN18Q6V0JW*MTcwOTk2NTc3Ny4xMi4xLjE3MDk5NjU3OTAuMC4wLjA.">R&amp;D MLOps/DevOpsエンジニア</a></p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> <div class="footnote"> <p class="footnote"><a href="#fn-2009ff2d" id="f-2009ff2d" name="f-2009ff2d" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">前回の合宿記事は<a href="https://buildersbox.corp-sansan.com/entry/2023/04/24/110000">こちら</a>です。</span></p> </div> misaosyushi Firehoseを介したS3への中間データ保管と検索性強化 hatenablog://entry/6801883189089386589 2024-03-22T11:00:00+09:00 2024-03-22T11:00:02+09:00 はじめに こんにちは、研究開発部 Architectグループの辻田です。 SansanではユーザーフィードバックがSlackのフィードバックチャンネルに集まります。研究開発部ではこれらからモデルやアルゴリズムなどの改善をしています。 そのため、より多くのフィードバックに早く対応することが改善サイクルを回していく上で重要な活動となります。 本記事では、改善対象のデータ抽出を効率化するために、Amazon Data Firehoseを使用してS3に中間データをストリーミングし、Athenaを使ってデータ検索を実現する方法を紹介します。さらに、パーティショニングの適用によって検索性を向上させる手法も… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20221205/20221205105323.jpg" width="1024" height="385" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="はじめに">はじめに</h1> <p>こんにちは、研究開発部 Architectグループの辻田です。</p> <p>SansanではユーザーフィードバックがSlackのフィードバックチャンネルに集まります。研究開発部ではこれらからモデルやアルゴリズムなどの改善をしています。</p> <p>そのため、より多くのフィードバックに早く対応することが改善サイクルを回していく上で重要な活動となります。</p> <p>本記事では、改善対象のデータ抽出を効率化するために、Amazon Data Firehoseを使用してS3に中間データをストリーミングし、Athenaを使ってデータ検索を実現する方法を紹介します。さらに、パーティショニングの適用によって検索性を向上させる手法も紹介します。</p> <p>この記事は、<a href="https://buildersbox.corp-sansan.com/search?q=R%26D+DevOps%E9%80%9A%E4%BF%A1">【R&amp;D DevOps通信】</a>の連載記事のひとつです。</p> <h2 id="背景">背景</h2> <p>フィードバック対応の手順は前任者がメモ程度に残したもので整備されておらず、手順自体も煩雑で属人性の高い作業になっていました。</p> <p>具体的には、</p> <ul> <li>フィードバック内容にある文字列からAthenaで検索</li> <li>Athenaで特定したユニークIDを元にS3上の該当データを探す</li> <li>S3上のデータをダウンロードしてからローカルでデバッグする</li> </ul> <p>といった内容でした。また、S3のファイルは別チームが管理しており、データ連携が自動化されていない状況でした。そのためデータが欲しいときは別チームに連絡を取り、手動でdumpしてもらわないとAthenaで検索ができず、コミュニケーションコストとリードタイムがかさばっていました。</p> <h2 id="目的">目的</h2> <p>フィードバックから必要なデータの収集〜デバッグを容易にできる仕組みを作り、1日以内には原因の特定、修正が完了できる状態にすることを目指しました。</p> <h1 id="アーキテクチャ">アーキテクチャ</h1> <p>アーキテクチャは以下です。抽出APIの中間データをFirehose経由で出力し、Athenaから検索できるようにしました。クエリを実行してデバッグに必要なデータを取得するスクリプトはコンテナ化して、誰の環境でもすぐ調査ができるようにしました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/misaosyushi/20240312/20240312150113.png" width="749" height="251" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>※ 今回のサービスで扱うデータは一部ログに出力できない事情があったため、Firehoseに直接PUTしています。特別な事情がない場合はログに出力し、CloudWatch Logs -> Firehoseに連携するほうが、アプリケーションがFirehoseに依存せず綺麗だと思います。</p> <h2 id="Amazon-Data-FirehoseとS3の統合">Amazon Data FirehoseとS3の統合</h2> <p>Amazon Data Firehoseは、リアルタイムでのデータストリーミングを可能にし、さまざまなデータストアにデータを配信します。2024/02に、サービス名がAmazon Kinesis Data Firehose から Amazon Data Firehose に変更されました。</p> <p>詳しくはドキュメントを参照ください。 <a href="https://docs.aws.amazon.com/ja_jp/firehose/latest/dev/what-is-this-service.html">https://docs.aws.amazon.com/ja_jp/firehose/latest/dev/what-is-this-service.html</a></p> <p>本プロジェクトでは、Firehoseを使用して抽出APIの中間データをS3にストリーミングします。これにより、データのリアルタイム処理と効果的な保存が可能となります。</p> <h3 id="バッファサイズとバッファ間隔">バッファサイズとバッファ間隔</h3> <p>S3へのデータ配信頻度は、バッファサイズ (1 MB~128 MB) とバッファ間隔 (60~900 秒) の値を指定できます。最初に満たされた条件によってS3へのデータ配信がトリガーされます。</p> <p>S3に配信したファイルはAthenaから高速にクエリする必要があります。ファイルが細切れだとクエリのパフォーマンスが下がってしまうため、ある程度のサイズごとにまとめることが重要です。</p> <blockquote><p>ファイルサイズが非常に小さい場合、特に 128MB 未満の場合には、実行エンジンは S3ファイルのオープン、ディレクトリのリスト表示、オブジェクトメタデータの取得、データ転送のセットアップ、ファイルヘッダーの読み込み、圧縮ディレクトリの読み込み、といった処理に余分な時間がかかります。<br/> 引用:<a href="https://aws.amazon.com/jp/blogs/news/top-10-performance-tuning-tips-for-amazon-athena/">Amazon Athena &#x306E;&#x30D1;&#x30D5;&#x30A9;&#x30FC;&#x30DE;&#x30F3;&#x30B9;&#x30C1;&#x30E5;&#x30FC;&#x30CB;&#x30F3;&#x30B0; Tips &#x30C8;&#x30C3;&#x30D7; 10 | Amazon Web Services &#x30D6;&#x30ED;&#x30B0;</a></p></blockquote> <h3 id="JSON-Lines形式">JSON Lines形式</h3> <p>JSON Linesは、複数のJSONオブジェクトを1行ずつ記述した形式であり、データの取り扱いが容易で効率的です。JSONオブジェクト間に改行を挿入してバッファごとにまとまったファイルを出力します。</p> <h3 id="Terraformを使用したFirehoseの構築方法">Terraformを使用したFirehoseの構築方法</h3> <p>以下は、TerraformでS3バケットを作成し、Firehoseを設定して中間データをS3にストリーミングするコードの例です。</p> <p>まずはS3と、FirehoseがS3にファイルを配信するためのIAMロールを作成します。</p> <pre class="code lang-hcl" data-lang="hcl" data-unlink><span class="synComment"># S3バケット</span> <span class="synType">resource</span> <span class="synConstant">&quot;aws_s3_bucket&quot;</span> <span class="synConstant">&quot;example_bucket&quot;</span> <span class="synSpecial">{</span> <span class="synIdentifier">bucket</span> = <span class="synConstant">&quot;example-bucket&quot;</span> <span class="synIdentifier">acl</span> = <span class="synConstant">&quot;private&quot;</span> <span class="synSpecial">}</span> <span class="synComment"># IAMロール</span> <span class="synType">resource</span> <span class="synConstant">&quot;aws_iam_role&quot;</span> <span class="synConstant">&quot;example_role&quot;</span> <span class="synSpecial">{</span> <span class="synIdentifier">name</span> = <span class="synConstant">&quot;firehose-role&quot;</span> <span class="synIdentifier">assume_role_policy</span> = data.aws_iam_policy_document.example_assume_role_policy_document.json <span class="synSpecial">}</span> <span class="synComment"># ポリシーのアタッチ</span> <span class="synType">resource</span> <span class="synConstant">&quot;aws_iam_role_policy&quot;</span> <span class="synConstant">&quot;example_policy&quot;</span> <span class="synSpecial">{</span> <span class="synIdentifier">name</span> = <span class="synConstant">&quot;$</span><span class="synSpecial">{</span>aws_iam_role.example_role.name<span class="synSpecial">}</span><span class="synConstant">-policy&quot;</span> <span class="synIdentifier">role</span> = aws_iam_role.example_role.id <span class="synIdentifier">policy</span> = data.aws_iam_policy_document.example_policy_document.json <span class="synSpecial">}</span> <span class="synComment"># IAMポリシー</span> <span class="synType">data</span> <span class="synConstant">&quot;aws_iam_policy_document&quot;</span> <span class="synConstant">&quot;example_assume_role_policy_document&quot;</span> <span class="synSpecial">{</span> <span class="synType">statement</span> <span class="synSpecial">{</span> <span class="synIdentifier">effect</span> = <span class="synConstant">&quot;Allow&quot;</span> <span class="synIdentifier">actions</span> = <span class="synSpecial">[</span><span class="synConstant">&quot;sts:AssumeRole&quot;</span><span class="synSpecial">]</span> <span class="synType">principals</span> <span class="synSpecial">{</span> <span class="synIdentifier">type</span> = <span class="synConstant">&quot;Service&quot;</span> <span class="synIdentifier">identifiers</span> = <span class="synSpecial">[</span><span class="synConstant">&quot;firehose.amazonaws.com&quot;</span><span class="synSpecial">]</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> <span class="synComment"># IAMポリシー</span> <span class="synType">data</span> <span class="synConstant">&quot;aws_iam_policy_document&quot;</span> <span class="synConstant">&quot;example_policy_document&quot;</span> <span class="synSpecial">{</span> <span class="synType">statement</span> <span class="synSpecial">{</span> <span class="synIdentifier">actions</span> = <span class="synSpecial">[</span> <span class="synConstant">&quot;s3:AbortMultipartUpload&quot;</span>, <span class="synConstant">&quot;s3:GetBucketLocation&quot;</span>, <span class="synConstant">&quot;s3:GetObject&quot;</span>, <span class="synConstant">&quot;s3:ListBucket&quot;</span>, <span class="synConstant">&quot;s3:ListBucketMultipartUploads&quot;</span>, <span class="synConstant">&quot;s3:PutObject&quot;</span>, <span class="synSpecial">]</span> <span class="synIdentifier">resources</span> = <span class="synSpecial">[</span> aws_s3_bucket.example_bucket.arn, <span class="synConstant">&quot;$</span><span class="synSpecial">{</span>aws_s3_bucket.example_bucket.arn<span class="synSpecial">}</span><span class="synConstant">/*&quot;</span> <span class="synSpecial">]</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> </pre> <p>次にFirehoseを作成します。</p> <pre class="code lang-hcl" data-lang="hcl" data-unlink><span class="synComment"># Firehose</span> <span class="synType">resource</span> <span class="synConstant">&quot;aws_kinesis_firehose_delivery_stream&quot;</span> <span class="synConstant">&quot;example_stream&quot;</span> <span class="synSpecial">{</span> <span class="synIdentifier">name</span> = <span class="synConstant">&quot;example-stream&quot;&quot;</span> <span class="synConstant"> destination = &quot;</span>extended_s3<span class="synConstant">&quot;</span> <span class="synType">extended_s3_configuration</span> <span class="synSpecial">{</span> <span class="synIdentifier">role_arn</span> = aws_iam_role.example_role.arn <span class="synIdentifier">bucket_arn</span> = aws_s3_bucket.example_bucket.arn <span class="synIdentifier">prefix</span> = <span class="synConstant">&quot;!{partitionKeyFromQuery:category}/!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/&quot;</span> <span class="synIdentifier">error_output_prefix</span> = <span class="synConstant">&quot;errors/!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/!{firehose:error-output-type}/&quot;</span> <span class="synIdentifier">buffer_size</span> = <span class="synConstant">128</span> <span class="synComment"># MB</span> <span class="synIdentifier">buffer_interval</span> = <span class="synConstant">900</span> <span class="synComment"># 15分</span> <span class="synType">processing_configuration</span> <span class="synSpecial">{</span> <span class="synIdentifier">enabled</span> = <span class="synConstant">&quot;true&quot;</span> <span class="synType">processors</span> <span class="synSpecial">{</span> <span class="synIdentifier">type</span> = <span class="synConstant">&quot;RecordDeAggregation&quot;</span> <span class="synType">parameters</span> <span class="synSpecial">{</span> <span class="synIdentifier">parameter_name</span> = <span class="synConstant">&quot;SubRecordType&quot;</span> <span class="synIdentifier">parameter_value</span> = <span class="synConstant">&quot;JSON&quot;</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> <span class="synType">processors</span> <span class="synSpecial">{</span> <span class="synIdentifier">type</span> = <span class="synConstant">&quot;MetadataExtraction&quot;</span> <span class="synType">parameters</span> <span class="synSpecial">{</span> <span class="synIdentifier">parameter_name</span> = <span class="synConstant">&quot;MetadataExtractionQuery&quot;</span> <span class="synIdentifier">parameter_value</span> = <span class="synConstant">&quot;{category:.category}&quot;</span> <span class="synSpecial">}</span> <span class="synType">parameters</span> <span class="synSpecial">{</span> <span class="synIdentifier">parameter_name</span> = <span class="synConstant">&quot;JsonParsingEngine&quot;</span> <span class="synIdentifier">parameter_value</span> = <span class="synConstant">&quot;JQ-1.6&quot;</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> <span class="synType">processors</span> <span class="synSpecial">{</span> <span class="synIdentifier">type</span> = <span class="synConstant">&quot;AppendDelimiterToRecord&quot;</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> <span class="synType">dynamic_partitioning_configuration</span> <span class="synSpecial">{</span> <span class="synIdentifier">enabled</span> = <span class="synConstant">true</span> <span class="synSpecial">}</span> } } </pre> <p><code>prefix</code>でS3のパスの指定、バッファサイズ、バッファ間隔の指定や、オブジェクト間の区切りなどを指定して作成します。</p> <p>各設定値の詳細はTerraformのドキュメントを参照ください。 <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kinesis_firehose_delivery_stream.html">https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kinesis_firehose_delivery_stream.html</a></p> <h2 id="Athenaによる検索の実現">Athenaによる検索の実現</h2> <p>Athenaは、S3に保存されたデータに対してSQLクエリを使って分析を行うことができるサービスです。FirehoseがS3にストリーミングした中間データに対し、Athenaを介して効果的に検索できます。これにより、データの柔軟な分析と検索が可能となります。</p> <h3 id="パーティショニングによる検索性の強化">パーティショニングによる検索性の強化</h3> <p>パーティショニングは、データを論理的なセクションに分割し、検索性を向上させる手法です。データを年月日でパーティション分割しました。これによりクエリの効率が向上し、検索速度が向上します。</p> <p>Athenaでは Apache Hiveスタイルのパーティションを使用できます。このスタイルのパスには、等号で連結されたキーと値のペア (year=2024/month=03/day=01/...) が含まれています。Hiveスタイルを利用したほうがCREATE TABLRE->ロード(<a href="https://docs.aws.amazon.com/ja_jp/athena/latest/ug/msck-repair-table.html">MSCK REPAIR TABLE</a>コマンド)で簡単にパーティションを設定できるようです。</p> <p>今回はFirehose側の設定をHiveではないスタイル(2024/03/01/...)で出力するように作成していたので、非Hiveでの設定方法を紹介します。</p> <p>DDLは以下です。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">CREATE</span> EXTERNAL <span class="synSpecial">TABLE</span> `sample`( `id` string, `name` string) PARTITIONED <span class="synSpecial">BY</span> ( `year` <span class="synType">int</span>, `month` <span class="synType">int</span>, `day` <span class="synType">int</span>) <span class="synSpecial">ROW</span> FORMAT SERDE <span class="synSpecial">'</span><span class="synConstant">org.openx.data.jsonserde.JsonSerDe</span><span class="synSpecial">'</span> <span class="synSpecial">WITH</span> SERDEPROPERTIES ( <span class="synSpecial">'</span><span class="synConstant">case.insensitive</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">TRUE</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">dots.in.keys</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">FALSE</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">ignore.malformed.json</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">FALSE</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">mapping</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">TRUE</span><span class="synSpecial">'</span>) STORED <span class="synSpecial">AS</span> INPUTFORMAT <span class="synSpecial">'</span><span class="synConstant">org.apache.hadoop.mapred.TextInputFormat</span><span class="synSpecial">'</span> OUTPUTFORMAT <span class="synSpecial">'</span><span class="synConstant">org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat</span><span class="synSpecial">'</span> LOCATION <span class="synSpecial">'</span><span class="synConstant">s3://sample</span><span class="synSpecial">'</span> TBLPROPERTIES ( <span class="synSpecial">'</span><span class="synConstant">classification</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">json</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.enabled</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">true</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.day.digits</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">2</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.day.range</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">1,31</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.day.type</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">integer</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.month.digits</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">2</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.month.range</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">1,12</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.month.type</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">integer</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.year.digits</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">4</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.year.range</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">2023,2100</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.year.type</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">integer</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">storage.location.template</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">s3://sample/${year}/${month}/${day}</span><span class="synSpecial">'</span>) </pre> <p>年月日でパーティショニングしたいため、year, month, dayを<code>PARTITIONED BY</code>で宣言します。</p> <pre class="code lang-sql" data-lang="sql" data-unlink>PARTITIONED <span class="synSpecial">BY</span> ( `year` <span class="synType">int</span>, `month` <span class="synType">int</span>, `day` <span class="synType">int</span>) </pre> <p><code>TBLPROPERTIES</code>でそれぞれのパーティションキーについての詳細を設定します。</p> <p><code>projection.*</code>がパーティショニングで使用されるカスタムプロパティです。Athenaがテーブルでクエリを実行するときに、どのパーティションパターンを期待すべきかを把握できるようにします。</p> <pre class="code lang-sql" data-lang="sql" data-unlink> <span class="synSpecial">'</span><span class="synConstant">classification</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">json</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.enabled</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">true</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.day.type</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">integer</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.day.range</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">1,31</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.day.digits</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">2</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.month.type</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">integer</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.month.range</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">1,12</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.month.digits</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">2</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.year.type</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">integer</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.year.range</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">2023,2100</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">projection.year.digits</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">4</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">storage.location.template</span><span class="synSpecial">'</span>=<span class="synSpecial">'</span><span class="synConstant">s3://sample/${year}/${month}/${day}</span><span class="synSpecial">'</span> </pre> <ul> <li><code>projection.columnName.type</code>: キーの型</li> <li><code>projection.columnName.range</code>: 最小値と最大値の範囲。2要素のカンマ区切りリストで表す。</li> <li><code>projection.columnName.digits</code>: 値の桁数</li> </ul> <p>詳しくは以下を参照ください。 <a href="https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection-supported-types.html">https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection-supported-types.html</a></p> <h1 id="まとめ">まとめ</h1> <p>クエリを実行してデバッグに必要なデータを取得するスクリプトは、簡単なものなので紹介を省きます。以上でフィードバックから必要なデータの収集〜デバッグを容易にできる仕組みが完成しました。</p> <p>実際にフィードバック対応をやってみた結果、クエリの実行時間は3秒ほどで、すぐに必要なデータを取得できました。(実はFirehoseを使う前はリクエストごとの細切れのファイルでした。クエリに10分超かかっていたので大幅な改善です)</p> <p>これで目的通り、原因の特定、修正をして1日以内にプルリクエストが出せるようになりました。</p> <p>中間データを保存しておき後から検索したい場面があれば、参考にしてみてください。 以上、最後まで読んでいただきありがとうございました。</p> <p>Architectグループでは一緒に働く仲間を募集しています。</p> <p><a href="https://open.talentio.com/r/1/c/sansan/pages/76616?_gl=1*14rznce*_ga*MTg4NTg5MDMxLjE2NzAwNDU2Njg.*_ga_EN18Q6V0JW*MTcwOTk2NTc3Ny4xMi4xLjE3MDk5NjU3OTAuMC4wLjA.">R&amp;D MLOps/DevOpsエンジニア</a></p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> misaosyushi Eightのデータ抽出基盤をサーバーレスで作る際に工夫したこと hatenablog://entry/6801883189086848905 2024-03-19T11:00:00+09:00 2024-03-19T11:00:02+09:00 こんにちは、Eight Engineering Unitの井上です。 Eightのデータ抽出基盤である Data Management Platform(以下、DMP)の開発を担当しています。 はじめに Eightは330万人を超えるユーザーにご利用いただいており、お預かりしているデータ量も日々多くなっています。 データが多いことに加えて抽出条件もさまざまなため、データアクセスは基本的にフルスキャンとなり高い負荷がかかります。 負荷は高いのですが頻度は高くないこともあり、DMPではサーバーレスのGlueとAthenaを利用して低コストで安定した稼動となる構成を採用しました。 ざっくりとしたシス… <p>こんにちは、Eight Engineering Unitの井上です。<br/> Eightのデータ抽出基盤である Data Management Platform(以下、DMP)の開発を担当しています。</p> <h3 id="はじめに">はじめに</h3> <p>Eightは330万人を超えるユーザーにご利用いただいており、お預かりしているデータ量も日々多くなっています。<br/> データが多いことに加えて抽出条件もさまざまなため、データアクセスは基本的にフルスキャンとなり高い負荷がかかります。<br/> 負荷は高いのですが頻度は高くないこともあり、DMPではサーバーレスの<a href="https://aws.amazon.com/jp/glue/">Glue</a>と<a href="https://aws.amazon.com/jp/athena/">Athena</a>を利用して低コストで安定した稼動となる構成を採用しました。</p> <p>ざっくりとしたシステム構成は以下になります。</p> <p><figure class="figure-image figure-image-fotolife" title="dmp"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/ko-inoue/20240228/20240228193417.png" width="1091" height="319" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <ol> <li>Glue JobでEightデータをS3へエクスポート</li> <li>エクスポートしたファイルはGlue Data Catalogでテーブル定義</li> <li>アプリケーションからAthenaへクエリを投げてデータ抽出</li> </ol> <p>この記事では Athena のデータソースを用意するGlueの開発で工夫した点をご紹介します。</p> <h3 id="工夫-BlueGreenのデータ更新">工夫①: Blue/Greenのデータ更新</h3> <p>エクスポートするEightデータの大半はデイリー更新で良いのですが、退会やオプトアウトなど高頻度で更新が必要なデータもあります。<br/> Glue Job(PySpark)は分散処理フレームワークのため、データ量や並列数に応じて出力されるファイルが複数になります。<br/> そのため洗い替えでデータ更新するには今あるファイルを全て削除してから新しく作り直す必要があり、一時的にファイルが存在しない状態が発生してしまいます。<br/> ファイルが存在しない状態とデータ抽出が重なると予期せぬ挙動となってしまうため、以下のような実装でダウンタイムなしのデータ更新を実現しています。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> awsglue.context <span class="synPreProc">import</span> GlueContext <span class="synPreProc">from</span> pyspark.context <span class="synPreProc">import</span> SparkContext <span class="synPreProc">import</span> boto3 <span class="synPreProc">import</span> re <span class="synComment"># Blue/Greenデータ更新の流れ</span> <span class="synComment"># ① DataCatalogのテーブル情報から現在のLocationを取得</span> <span class="synComment"># ② 現在のLocationが blue か green かを判定</span> <span class="synComment"># ③ 次のLocationのディレクトリにParquetファイルを書き込み(データアクスセスが高速になる列指向データファイル形式)</span> <span class="synComment"># ④ 次のLocationでDataCatalogを更新</span> <span class="synStatement">def</span> <span class="synIdentifier">replace_table_data_location</span>(database_name, table_name, dynamic_frame): glue_client = boto3.client(<span class="synConstant">&quot;glue&quot;</span>, <span class="synConstant">&quot;ap-northeast-1&quot;</span>) base_table_location = f<span class="synConstant">&quot;s3://dummy-s3-bucket/schemas/{table_name}/&quot;</span> <span class="synComment"># ①</span> table_catalog = glue_client.get_table(DatabaseName=database_name, Name=table_name) current_table_location = table_catalog[<span class="synConstant">&quot;Table&quot;</span>][<span class="synConstant">&quot;StorageDescriptor&quot;</span>][<span class="synConstant">&quot;Location&quot;</span>] <span class="synComment"># ②</span> match_result = re.match(<span class="synConstant">r&quot;{}(?P&lt;pattern&gt;\w+)&quot;</span>.format(base_table_location), current_table_location) pattern = match_result.group(<span class="synConstant">&quot;pattern&quot;</span>) <span class="synStatement">if</span> match_result <span class="synStatement">else</span> <span class="synConstant">&quot;&quot;</span> <span class="synComment"># ③</span> next_table_location = base_table_location + (<span class="synConstant">&quot;green&quot;</span> <span class="synStatement">if</span> pattern == <span class="synConstant">&quot;blue&quot;</span> <span class="synStatement">else</span> <span class="synConstant">&quot;blue&quot;</span>) dynamic_frame.toDF().write.mode(<span class="synConstant">&quot;overwrite&quot;</span>).format(<span class="synConstant">&quot;parquet&quot;</span>).save(next_table_location) <span class="synComment"># ④ </span> <span class="synComment"># Locationのみの更新ができないため、他のテーブル情報もtable_inputに含める</span> table_input = { key: value <span class="synStatement">for</span> key, value <span class="synStatement">in</span> table_catalog[<span class="synConstant">&quot;Table&quot;</span>].items() <span class="synStatement">if</span> key <span class="synStatement">in</span> [<span class="synConstant">&quot;Name&quot;</span>, <span class="synConstant">&quot;Owner&quot;</span>, <span class="synConstant">&quot;Retention&quot;</span>, <span class="synConstant">&quot;StorageDescriptor&quot;</span>, <span class="synConstant">&quot;PartitionKeys&quot;</span>, <span class="synConstant">&quot;TableType&quot;</span>, <span class="synConstant">&quot;Parameters&quot;</span>] } table_input[<span class="synConstant">&quot;StorageDescriptor&quot;</span>][<span class="synConstant">&quot;Location&quot;</span>] = next_table_location glue_client.update_table(DatabaseName=<span class="synConstant">&quot;hoge_database&quot;</span>, TableInput=table_input, SkipArchive=<span class="synIdentifier">True</span>) <span class="synComment"># main処理</span> glue_context = GlueContext(SparkContext.getOrCreate()) dynamic_frame = glue_context.create_dynamic_frame.from_catalog(database=<span class="synConstant">&quot;hoge_database&quot;</span>, table_name=<span class="synConstant">&quot;fuga_table&quot;</span>) <span class="synComment"># データ抽出で扱いやすくするための加工処理など</span> ... <span class="synComment"># Blue/Greenデータ更新</span> replace_table_data_location(<span class="synConstant">&quot;hoge_database&quot;</span>, <span class="synConstant">&quot;fuga_table&quot;</span>, dynamic_frame) </pre> <h3 id="工夫-共通化">工夫②: 共通化</h3> <p>Glue Job間で共通化したい関数を別ファイルに切り出し、 <code>--extra-py-files</code> として再利用しています。<br/> ①のデータ更新処理はダウンタイムの解消以外に、障害耐性を上げるメリットもあるためデイリー更新のデータにも適用しています。</p> <p>Terraformでの定義は以下になります。</p> <pre class="code lang-tf" data-lang="tf" data-unlink>resource &quot;<span class="synConstant">aws_s3_object</span>&quot; &quot;<span class="synConstant">replace_table_data_location</span>&quot; <span class="synSpecial">{</span> bucket <span class="synStatement">=</span> &quot;<span class="synConstant">dummy-s3-bucket</span>&quot; key <span class="synStatement">=</span> &quot;<span class="synConstant">libs/replace_table_data_location</span>&quot; source <span class="synStatement">=</span> &quot;<span class="synConstant">libs/replace_table_data_location.py</span>&quot; source_hash <span class="synStatement">=</span> filemd5<span class="synSpecial">(</span>&quot;<span class="synConstant">libs/replace_table_data_location.py</span>&quot;<span class="synSpecial">)</span> <span class="synSpecial">}</span> resource &quot;<span class="synConstant">aws_s3_object</span>&quot; &quot;<span class="synConstant">dmp_glue_job_script</span>&quot; <span class="synSpecial">{</span> bucket <span class="synStatement">=</span> &quot;<span class="synConstant">dummy-s3-bucket</span>&quot; key <span class="synStatement">=</span> &quot;<span class="synConstant">scripts/dmp_glue_job</span>&quot; source <span class="synStatement">=</span> &quot;<span class="synConstant">scripts/dmp_glue_job.py</span>&quot; source_hash <span class="synStatement">=</span> filemd5<span class="synSpecial">(</span>&quot;<span class="synConstant">scripts/dmp_glue_job.py</span>&quot;<span class="synSpecial">)</span> <span class="synSpecial">}</span> resource &quot;<span class="synConstant">aws_glue_job</span>&quot; &quot;<span class="synConstant">dmp_glue_job</span>&quot; <span class="synSpecial">{</span> name <span class="synStatement">=</span> &quot;<span class="synConstant">dmp-glue-job</span>&quot; default_arguments <span class="synStatement">=</span> <span class="synSpecial">{</span> &quot;<span class="synConstant">--job-language</span>&quot; <span class="synStatement">=</span> &quot;<span class="synConstant">python</span>&quot; &quot;<span class="synConstant">--enable-glue-datacatalog</span>&quot; <span class="synStatement">=</span> &quot;<span class="synConstant">true</span>&quot; &quot;<span class="synConstant">--extra-py-files</span>&quot; <span class="synStatement">=</span> &quot;<span class="synConstant">s3://${aws_s3_object.replace_table_data_location.bucket}/${aws_s3_object.replace_table_data_location.key}</span>&quot; <span class="synSpecial">}</span> command <span class="synSpecial">{</span> script_location <span class="synStatement">=</span> &quot;<span class="synConstant">s3://${aws_s3_object.dmp_glue_job_script.bucket}/${aws_s3_object.dmp_glue_job_script.key}</span>&quot; python_version <span class="synStatement">=</span> &quot;<span class="synConstant">3</span>&quot; <span class="synSpecial">}</span> <span class="synSpecial">}</span> </pre> <p><code>--extra-py-files</code> は <code>script_location</code> と同一ディレクトリに配置されるため、以下のようにimportすることができます</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> replace_table_data_location <span class="synPreProc">import</span> replace_table_data_location </pre> <h3 id="おまけ">おまけ</h3> <p>Terraformでテーブル定義していると location の切り替えによる差分が出てしまいます。<br/> そこで 該当テーブルの location は ignore_changes で無視します。</p> <pre class="code lang-tf" data-lang="tf" data-unlink>resource &quot;<span class="synConstant">aws_glue_catalog_table</span>&quot; &quot;<span class="synConstant">fuga_table</span>&quot; <span class="synSpecial">{</span> database_name <span class="synStatement">=</span> &quot;<span class="synConstant">hoge_database</span>&quot; name <span class="synStatement">=</span> &quot;<span class="synConstant">fuga_table</span>&quot; ... <span class="synIdentifier">#</span> 細かいパラメータは省略 storage_descriptor <span class="synSpecial">{</span> location <span class="synStatement">=</span> &quot;<span class="synConstant">s3://dummy-s3-bucket/schemas/fuga_table/blue</span>&quot; <span class="synIdentifier">#</span> or s3<span class="synStatement">:/</span>/dmp_s3_bucket<span class="synType">/schemas/fuga_table/green</span> ... <span class="synSpecial">}</span> lifecycle <span class="synSpecial">{</span> ignore_changes <span class="synStatement">=</span> <span class="synSpecial">[</span> storage_descriptor<span class="synSpecial">[</span><span class="synIdentifier">0</span><span class="synSpecial">]</span>.location, <span class="synIdentifier">#</span> locationで差分が発生しても無視する <span class="synSpecial">]</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> </pre> <h3 id="最後に">最後に</h3> <p>今回はGlueを利用する開発のTipsを紹介しました。<br/> Eightではこのような分散処理の開発もあり、Web開発とはまた違った知見も得られます。<br/> もしご興味ありましたらエントリーをお待ちしております!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmedia.sansan-engineering.com%2Feight-engineer" title="名刺アプリ「Eight」" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://media.sansan-engineering.com/eight-engineer">media.sansan-engineering.com</a></cite></p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> ko-inoue terraform planの自動化に向けて直面した課題と解決策 hatenablog://entry/6801883189087342890 2024-03-18T11:00:00+09:00 2024-03-18T19:30:29+09:00 はじめに こんにちは! 技術本部 Bill One Engineering Unit(以下、Bill One EU)の笹島です。 IaC推進チーム(横串チームの1つ)として、CI環境でのTerraform Planの自動化に取り組んできました。 横串チームとは、Bill One EU内の各グループの垣根のない横断チームであり、Bill Oneで抱えている課題を解決するために有志で集まったメンバーによって構成されています。 IaC推進チームとは、文字通りインフラのコード化を推進するチームです。 本記事では、CI環境でセキュアなTerraform Plan自動実行を実現するにあたって直面した課題と… <h2 id="はじめに">はじめに</h2> <p>こんにちは! 技術本部 Bill One Engineering Unit(以下、Bill One EU)の笹島です。</p> <p>IaC推進チーム(横串チームの1つ)として、CI環境でのTerraform Planの自動化に取り組んできました。 横串チームとは、Bill One EU内の各グループの垣根のない横断チームであり、<a href="https://bill-one.com/">Bill One</a>で抱えている課題を解決するために有志で集まったメンバーによって構成されています。 IaC推進チームとは、文字通りインフラのコード化を推進するチームです。</p> <p>本記事では、CI環境でセキュアなTerraform Plan自動実行を実現するにあたって直面した課題とその解決策について共有します。 特に、モノレポ環境での複数プロダクト・環境の管理における自動化の課題についても紹介します。</p> <h2 id="目次">目次</h2> <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#目次">目次</a></li> <li><a href="#前提">前提</a><ul> <li><a href="#ディレクトリ構成とその役割">ディレクトリ構成とその役割</a></li> <li><a href="#Workload-Identity連携">Workload Identity連携</a><ul> <li><a href="#Workload-Identityの基本">Workload Identityの基本</a></li> <li><a href="#プロジェクト構成とWorkload-Identityの役割">プロジェクト構成とWorkload Identityの役割</a></li> <li><a href="#Workload-Identity設定の目的">Workload Identity設定の目的</a></li> </ul> </li> </ul> </li> <li><a href="#直面した課題">直面した課題</a><ul> <li><a href="#挑戦1-Terraform-Planの自動化のためのディレクトリ探索">挑戦1: Terraform Planの自動化のためのディレクトリ探索</a><ul> <li><a href="#今回作成したCIの基本設計">今回作成したCIの基本設計</a></li> <li><a href="#技術的課題-GitHub-ActionsのMatrix-JobとRunnerの特性">技術的課題: GitHub ActionsのMatrix JobとRunnerの特性</a></li> <li><a href="#具体的な実例">具体的な実例</a></li> <li><a href="#解決策-直列実行によるアプローチ">解決策: 直列実行によるアプローチ</a></li> </ul> </li> <li><a href="#挑戦2-Workload-Identityを用いたプロジェクト特定と認証">挑戦2: Workload Identityを用いたプロジェクト特定と認証</a><ul> <li><a href="#基本設計とプロジェクトの構造">基本設計とプロジェクトの構造</a></li> <li><a href="#技術的課題-動的なプロジェクト特定と認証の複雑性">技術的課題: 動的なプロジェクト特定と認証の複雑性</a></li> <li><a href="#解決策-ディレクトリ構成のルール化とシェルスクリプトによる自動化">解決策: ディレクトリ構成のルール化とシェルスクリプトによる自動化</a></li> </ul> </li> </ul> </li> <li><a href="#Terraform-Planの実行部分">Terraform Planの実行部分</a></li> <li><a href="#おわりに">おわりに</a></li> </ul> <h2 id="前提">前提</h2> <h3 id="ディレクトリ構成とその役割">ディレクトリ構成とその役割</h3> <p>Bill Oneではインフラのコード化にTerraformを積極的に採用しています。 Bill OneにおけるStateの適切な管理やディレクトリ構成に関する考え方については、<a href="https://buildersbox.corp-sansan.com/entry/2023/06/02/110000">以前のブログ記事</a>を参照ください。 本章では、それらを踏まえた上で実際に各ディレクトリがどういった役割を果たしているのかを掘り下げてみたいと思います。</p> <p>実際のディレクトリ構成は次のようになっています。</p> <pre class="code" data-lang="" data-unlink>. ├── product_A │ ├── microservices │ │ ├── service_A │ │ │ ├── environments │ │ │ │ ├── prod │ │ │ │ │ ├── main.tf │ │ │ │ │ └── provider.tf │ │ │ │ ├── stg │ │ │ │ └── dev │ │ │ └── modules │ │ │ ├── cloud_sql.tf │ │ │ ├── ... (tf files) │ │ │ └── variables.tf │ │ ├── service_B │ │ └── ... (service directories) │ └── shared_resources │ ├── resource_A │ │ ├── environments │ │ └── modules │ ├── resource_B │ └── ... (resource directories) └── product_B ├── microservices └── shared_resources</pre> <p>Bill OneのTerraformプロジェクトは複数のプロダクトを横断する形で構成されています。ここでは、product_Aとproduct_Bを例に、どのようにインフラリソースを管理しているかを掘り下げていきます。</p> <p>Microservices: product_Aとproduct_Bの下に位置するmicroservicesディレクトリは、個々のマイクロサービスに関連するインフラリソースを専門的に扱います。この階層では、各サービスが独自のリソースセットを持ち、それらの管理を独立させることで、効率的な作業分担を実現しています。</p> <p>Shared Resources: shared_resourcesディレクトリは、product_Aやproduct_Bに共通するインフラリソース、例えばネットワークリソースやIAMを管理しています。これにより、異なるマイクロサービス間でリソースの再利用が可能になり、一貫性と効率性を保ちながらインフラを構築できます。</p> <p>Environments: environmentsディレクトリではprod(本番環境)、stg(ステージング環境)、dev(開発環境)といった、環境別のTerraform設定が格納されています。これらのサブディレクトリは、実際にTerraformのinit、plan、applyといったコマンドを実行する作業ディレクトリとして機能します。</p> <p>Modules: modulesディレクトリでは、サービス固有のモジュールや共有リソース用のモジュールを集約することにより、効率的な管理を実現しています。</p> <h3 id="Workload-Identity連携">Workload Identity連携</h3> <h4 id="Workload-Identityの基本">Workload Identityの基本</h4> <p>Bill OneのサービスはGoogle Cloud上に構成されています。GitHub Actionsを通じたCI/CDプロセスでGoogle Cloudリソースを安全に操作するためには、<a href="https://cloud.google.com/iam/docs/workload-identity-federation?hl=ja">Workload Identity</a>という機能が欠かせません。 Workload Identityは、Google Cloud上で実行されるアプリケーションのセキュリティを強化するための重要な機能です。この仕組みを使用することで、アプリケーションはGoogle Cloudのサービスやリソースに対して、適切なサービスアカウントを介して安全にアクセスできます。これにより、不正アクセスのリスクを軽減しつつ、アクセス権限を精密に管理できます。</p> <h4 id="プロジェクト構成とWorkload-Identityの役割">プロジェクト構成とWorkload Identityの役割</h4> <p>Bill Oneのプロジェクトでは、複数の開発・ステージング・本番環境が存在し、それぞれが独立したGoogle Cloudプロジェクトとして構成されています。Workload Identityを各プロジェクトに適用することで、環境ごとにカスタマイズされたセキュリティポリシーを実施し、細かくアクセス制御を行っています。</p> <h4 id="Workload-Identity設定の目的">Workload Identity設定の目的</h4> <p>GitHub Actionsを使用したCI/CDプロセス中にGoogle Cloudリソースを安全に操作するため、Workload Identityの導入が必要になってきます。これにより、GitHub ActionsのランナーがGoogle Cloudサービスに対して適切なサービスアカウントを用いてアクセスすることが保証され、セキュリティと操作性の向上が実現されます。</p> <h2 id="直面した課題">直面した課題</h2> <p>本セクションでは、GitHub Actionsを使用してTerraform Planの自動化を図る過程で遭遇した主要な挑戦を2つ取り上げ、それらにどのように対処したかを説明します。今回構築したワークフローの概略図は次のようになります。 <figure class="figure-image figure-image-fotolife" title="ワークフローの概略図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/skanta/20240301/20240301180648.png" alt="" width="1200" height="343" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ワークフローの概略図</figcaption></figure></p> <p>それぞれのジョブは次のような処理を行います。</p> <ul> <li><code>service_filter</code>ジョブ、<code>env_filter</code>ジョブ <ul> <li>コード差分から、terraform planを実行する作業ディレクトリを特定</li> </ul> </li> <li><code>tf_plan</code>ジョブ <ul> <li>terraform planを実行</li> </ul> </li> </ul> <h3 id="挑戦1-Terraform-Planの自動化のためのディレクトリ探索">挑戦1: Terraform Planの自動化のためのディレクトリ探索</h3> <h4 id="今回作成したCIの基本設計">今回作成したCIの基本設計</h4> <p>Bill Oneのプロジェクトは、前提でも述べたように複数のサービスとそれぞれの環境を含む複雑な構造を持っています。 このプロジェクト構造の中、CIプロセスを効率化するため、コードの変更があった部分だけを対象にTerraform Planを実行するようにしました。 このアプローチは、CIプロセスのリソース効率を高め、変更の影響範囲を容易に把握できます。</p> <h4 id="技術的課題-GitHub-ActionsのMatrix-JobとRunnerの特性">技術的課題: GitHub ActionsのMatrix JobとRunnerの特性</h4> <p>この基本設計を実現するために、GitHub Actionsの<a href="https://docs.github.com/ja/actions/using-jobs/using-a-matrix-for-your-jobs">Matrix Job</a>機能を活用しました。Matrix Jobは、複数のインプットに基づいて並列処理を実行できる強力な機能です。 しかし、Matrix Jobを導入する過程で、GitHub ActionsのRunnerが独立した環境上で各ジョブを実行するという特性に直面しました。この特性により、1つのジョブで生成されたアウトプットを別のジョブで直接活用するのが困難になるという制約が明らかになりました。 具体的には、並列実行されるジョブ間でのアウトプット共有が直接的にはサポートされていないため、ディレクトリを特定して後続のTerraform Planを実行するジョブへスムーズにつなげるのは一筋縄ではいきませんでした。</p> <h4 id="具体的な実例">具体的な実例</h4> <p>ここからは、実際に直面した課題を実例ベースで説明していきたいと思います。CIの基本設計を基に、terraform planの作業ディレクトリ特定のため二段階のジョブを計画しました。 まずジョブ(<code>service_filter</code>ジョブ。以降、親ジョブ)では、GitHub Actionsの<a href="https://github.com/dorny/paths-filter"><code>dorny/paths-filter</code></a>アクションを使用して、変更が加えられた<code>environments</code>や<code>modules</code>ディレクトリの1つ上のレベルを特定します。 このアプローチにより、変更のあったサービスまたはリソースが明確になり、後続のプロセスでの作業範囲を絞り込めます。例えば、<code>product_A</code>の<code>microservices/service_A</code>に変更があった場合、親ジョブは次のように設定され、変更されたディレクトリ(この場合は<code>service_A</code>)を特定します。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">service_filter</span><span class="synSpecial">:</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synIdentifier">outputs</span><span class="synSpecial">:</span> <span class="synIdentifier">directories</span><span class="synSpecial">:</span> ${{ steps.service_filter.outputs.changes }} <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4 <span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> dorny/paths-filter@v3 <span class="synIdentifier">id</span><span class="synSpecial">:</span> service_filter <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">filters</span><span class="synSpecial">:</span> | <span class="synIdentifier">product_A/microservices/service_A</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synConstant">'product_A/microservices/service_A/**'</span> </pre> <p>この親ジョブで期待されるアウトプットとしては、次のような変更が加えられた複数のサービスやリソースディレクトリが挙げられます。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synSpecial">[</span><span class="synConstant">&quot;product_A/microservices/service_A&quot;</span>, <span class="synConstant">&quot;product_A/shared_resources/resource_A&quot;</span><span class="synSpecial">]</span> </pre> <p>二段階目のジョブ(<code>env_filter</code>ジョブ。以降、子ジョブ)では、親ジョブで特定されたディレクトリから、 それらの配下の<code>environments</code>ディレクトリ内にどの環境があるか特定します。 このステップは、Terraform Planの実行における作業ディレクトリを明確化するために不可欠です。 <code>environments</code>ディレクトリには、開発(dev)、ステージング(stg)、本番(prod)といった環境ごとのTerraform設定が格納されており、これらのディレクトリが実際にTerraformのコマンドを実行する場所となります。</p> <p>子ジョブでは、次のMatrix Jobを用いて、親ジョブから受け取ったディレクトリに対して並列に作業します。このプロセスにより、各環境に応じたTerraform Planを効率的に実行する準備が整います。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">env_filter</span><span class="synSpecial">:</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synIdentifier">needs</span><span class="synSpecial">:</span> <span class="synSpecial">[</span>service_filter<span class="synSpecial">]</span> <span class="synIdentifier">strategy</span><span class="synSpecial">:</span> <span class="synIdentifier">matrix</span><span class="synSpecial">:</span> <span class="synIdentifier">directory</span><span class="synSpecial">:</span> ${{ fromJson(needs.service_filter.outputs.directories) }} <span class="synIdentifier">outputs</span><span class="synSpecial">:</span> <span class="synIdentifier">working_directory</span><span class="synSpecial">:</span><span class="synComment"> # outputs</span> <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synComment"> # ここでの探索処理(省略)</span> </pre> <p>しかし、今回採用した設計ではMatrix Jobの制約により、子ジョブで動的に特定された作業ディレクトリの情報を後続のジョブで直接活用することが技術的に困難となり、プロセスの自動化において障壁となりました。</p> <h4 id="解決策-直列実行によるアプローチ">解決策: 直列実行によるアプローチ</h4> <p>この問題に対処するため、Matrix Jobを用いた並列処理からシェルスクリプトを利用した直列処理へと方針を転換しました。この選択の背景には、次の理由があります。</p> <ol> <li>直感的な操作性と理解の容易さ: シェルスクリプトは開発者にとって馴染みが深く、動作原理を直感的に理解しやすいため、タスクの実行、デバッグ、メンテナンスが容易になります。</li> <li>実行時間の効率性: 事前評価で親ジョブから渡されるアウトプット量が多くないと予想されたため、直列実行でもMatrix Jobを用いた並列実行と比較して実行時間に大きな差が出ないと判断しました。</li> <li>実装のシンプルさ: 外部ツールやサービスへの依存なしに、GitHub Actionsの既存環境とシェルスクリプトのみで要件を満たすことが可能であるため、実装がシンプルになります。</li> </ol> <p>これらの理由に基づき、次のシェルスクリプトを用いたジョブを作成しました。 親ジョブで特定したコード差分が発生しているディレクトリを基準に、Terraform Planを実行する適切なディレクトリを動的に探索します。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">env_filter</span><span class="synSpecial">:</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synIdentifier">needs</span><span class="synSpecial">:</span> <span class="synSpecial">[</span>service_filter<span class="synSpecial">]</span> <span class="synIdentifier">outputs</span><span class="synSpecial">:</span> <span class="synIdentifier">working_directory</span><span class="synSpecial">:</span> ${{ steps.env_filter.outputs.working_directory }} <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4 <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Filter Working Directory <span class="synIdentifier">id</span><span class="synSpecial">:</span> env_filter <span class="synIdentifier">env</span><span class="synSpecial">:</span> <span class="synIdentifier">DIRECTORIES</span><span class="synSpecial">:</span> ${{ needs.service_filter.outputs.directories }} <span class="synIdentifier">run</span><span class="synSpecial">:</span> | <span class="synComment"> # 初期化: 結果を格納する配列</span> result_array=() <span class="synComment"> # 親ジョブから渡されたディレクトリリストを元にループ処理</span> for dir in $(echo <span class="synConstant">&quot;$DIRECTORIES&quot;</span> | jq -r <span class="synConstant">'.[]'</span>); do <span class="synComment"> # .terraform.lock.hcl ファイルを含むディレクトリを探索し、Terraformを実行するディレクトリとして特定</span> apply_dirs=$(find <span class="synConstant">&quot;$dir&quot;</span> -name <span class="synConstant">'.terraform.lock.hcl'</span> -exec dirname <span class="synSpecial">{}</span> \; | sort -u) <span class="synComment"> # 特定したディレクトリを結果配列に追加</span> for apply_dir in $apply_dirs; do result_array+=(&quot;$apply_dir&quot;) done done <span class="synComment"> # 結果配列をJSON形式に変換(GitHub ActionsのMatrixで使用するため)</span> result_json=$(echo -n <span class="synConstant">&quot;${result_array[@]}&quot;</span> | jq -R -s -c <span class="synConstant">'split(&quot; &quot;)'</span>) <span class="synComment"> # 結果をGitHub Actionsのアウトプットとして設定</span> echo <span class="synConstant">&quot;working_directory=$result_json&quot;</span> &gt;&gt; <span class="synConstant">&quot;$GITHUB_OUTPUT&quot;</span> </pre> <h3 id="挑戦2-Workload-Identityを用いたプロジェクト特定と認証">挑戦2: Workload Identityを用いたプロジェクト特定と認証</h3> <h4 id="基本設計とプロジェクトの構造">基本設計とプロジェクトの構造</h4> <p>Bill Oneのプロジェクトは、開発・ステージング・本番環境と複数の環境を持つ<code>product_A</code>と<code>product_B</code>がそれぞれ独立したGoogle Cloudプロジェクトに対応しています。 これらの環境は、個別のWorkload Identity設定によってセキュリティポリシーが管理されており、 CIプロセス内でTerraform Planを実行するにはそれぞれの環境へ適切にアクセスすることが求められます。</p> <h4 id="技術的課題-動的なプロジェクト特定と認証の複雑性">技術的課題: 動的なプロジェクト特定と認証の複雑性</h4> <p>前述のプロジェクト構造とCIプロセスの設計を踏まえると、CIプロセス内で動的にGoogle Cloudプロジェクトを特定し、適切な認証を行う必要がありました。 具体的には、子ジョブで得られる次のようなディレクトリパスからプロダクトと環境を解析し、Google Cloudプロジェクトを特定し適切な認証を行なっていくことが1つの試練となりました。</p> <pre class="code bash" data-lang="bash" data-unlink>[&#34;product_A/microservices/service_A/environments/dev&#34;, &#34;product_A/microservices/service_A/environments/stg&#34;, &#34;product_A/microservices/service_A/environments/prod&#34;]</pre> <h4 id="解決策-ディレクトリ構成のルール化とシェルスクリプトによる自動化">解決策: ディレクトリ構成のルール化とシェルスクリプトによる自動化</h4> <p>この課題に対処するため、次のようなディレクトリ構成のルール化を明文化しました。これにより、CIプロセス内でGoogle Cloudプロジェクトを動的に特定するための基盤を整えました。</p> <pre class="code bash" data-lang="bash" data-unlink>&lt;プロダクト&gt;/microservices or shared_resources/&lt;コンポーネント名&gt;/environments or module/&lt;環境名&gt;/(以降は自由)</pre> <p>この構成を基に、子ジョブで特定されたディレクトリからプロダクトと環境を解析し、 <a href="https://github.com/google-github-actions/auth">google-github-actions/auth</a>アクションを使用してWorkload Identity Poolへのアクセスと認証を自動化するシェルスクリプトを導入しました。 このプロセスを実装したコードは次のようになります。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">tf_plan</span><span class="synSpecial">:</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> terraform plan <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synIdentifier">needs</span><span class="synSpecial">:</span> <span class="synSpecial">[</span>service_filter, env_filter<span class="synSpecial">]</span> <span class="synIdentifier">strategy</span><span class="synSpecial">:</span> <span class="synIdentifier">matrix</span><span class="synSpecial">:</span> <span class="synIdentifier">directory</span><span class="synSpecial">:</span> ${{ fromJson(needs.env_filter.outputs.directory) }} <span class="synIdentifier">permissions</span><span class="synSpecial">:</span> <span class="synIdentifier">contents</span><span class="synSpecial">:</span> read <span class="synIdentifier">id-token</span><span class="synSpecial">:</span> write <span class="synIdentifier">pull-requests</span><span class="synSpecial">:</span> write <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Clone Repo <span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4 <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Check Environment <span class="synIdentifier">id</span><span class="synSpecial">:</span> check_env <span class="synIdentifier">run</span><span class="synSpecial">:</span> | <span class="synComment"> # matrix.directoryの例: product_A/microservices/service_A/environments/dev</span> path=&quot;${{ matrix.directory }}<span class="synConstant">&quot;</span> product_segment=$(echo <span class="synConstant">&quot;$path&quot;</span> | awk -F <span class="synConstant">'/'</span> <span class="synConstant">'{print $1}'</span>) environment_segment=$(echo <span class="synConstant">&quot;$path&quot;</span> | awk -F <span class="synConstant">'/'</span> <span class="synConstant">'{print $5}'</span>) project=&quot;${product_segment}-${environment_segment}&quot; echo <span class="synConstant">&quot;environment=$environment_segment&quot;</span> &gt;&gt; <span class="synConstant">&quot;$GITHUB_OUTPUT&quot;</span> echo <span class="synConstant">&quot;project=$project&quot;</span> &gt;&gt; <span class="synConstant">&quot;$GITHUB_OUTPUT&quot;</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Authenticate to Google Cloud <span class="synIdentifier">uses</span><span class="synSpecial">:</span> google-github-actions/auth@v2 <span class="synIdentifier">env</span><span class="synSpecial">:</span> <span class="synIdentifier">PROJECT_NUM</span><span class="synSpecial">:</span> ${{ fromJson('{ <span class="synConstant">&quot;product_A-dev&quot;</span><span class="synSpecial">:</span> <span class="synConstant">&quot;xxxx&quot;</span>, <span class="synConstant">&quot;product_A-stg&quot;</span><span class="synSpecial">:</span> <span class="synConstant">&quot;yyyy&quot;</span>, ... }<span class="synConstant">')[steps.check_env.outputs.project] }}</span> <span class="synConstant"> with:</span> <span class="synConstant"> workload_identity_provider: &quot;projects/${{ env.PROJECT_NUM }}/locations/global/workloadIdentityPools/workflow-oidc-pool/providers/workflow-oidc-provider&quot;</span> <span class="synConstant"> service_account: &quot;github-actions-for-terraform@${{ steps.check_env.outputs.project }}.iam.gserviceaccount.com&quot;</span> </pre> <p>このアプローチは、自動化実現だけでなく、開発チームがプロジェクト構造を容易に把握し、新たなサービスや機能の追加時に一貫性を維持する助けになっていると思います。</p> <h2 id="Terraform-Planの実行部分">Terraform Planの実行部分</h2> <p>挑戦1と2を通じて、Terraform Planを効率的に実行するためのディレクトリ特定と必要な認証のステップを構築しました。 最後にそれらのプロセスを活かし、今回のCI構築のゴールである、Terraform Planの結果をプルリクエストで確認できるプロセスを実装します。 このプロセスの中心となるのが<a href="https://suzuki-shunsuke.github.io/tfcmt/">tfcmt</a>、Terraformの出力をGitHubのコメントとして投稿するCLIツールです。 このツールを用いて、コードの変更差分に関係するTerraform Planの結果を可視化し、レビューの効率性を向上させました。</p> <p>GitHub上にはTerraform Planの結果は次のように表示されます。 <figure class="figure-image figure-image-fotolife" title="GitHub上にterraform planの結果を表示"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/skanta/20240301/20240301175423.png" alt="" width="1200" height="1175" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>GitHub上にterraform planの結果を表示</figcaption></figure></p> <p>また、今回作成したジョブは次のようになります。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">tf_plan</span><span class="synSpecial">:</span> <span class="synComment"> # 省略: 挑戦2の解決策参照</span> <span class="synIdentifier">strategy</span><span class="synSpecial">:</span> <span class="synIdentifier">matrix</span><span class="synSpecial">:</span> <span class="synIdentifier">directory</span><span class="synSpecial">:</span> ${{ fromJson(needs.env_filter.outputs.directory) }} <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Clone Repo <span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4 <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Check Environment <span class="synComment"> # 挑戦2の解決策参照</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Authenticate to Google Cloud <span class="synComment"> # 挑戦2の解決策参照</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Setup Terraform <span class="synIdentifier">uses</span><span class="synSpecial">:</span> hashicorp/setup-terraform@v3 <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">terraform_version</span><span class="synSpecial">:</span><span class="synComment"> # version設定</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Terraform Init <span class="synIdentifier">working-directory</span><span class="synSpecial">:</span> ${{ matrix.directory }} <span class="synIdentifier">run</span><span class="synSpecial">:</span> | terraform init <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Install tfcmt <span class="synIdentifier">env</span><span class="synSpecial">:</span> <span class="synIdentifier">TFCMT_VERSION</span><span class="synSpecial">:</span><span class="synComment"> # version設定</span> <span class="synIdentifier">run</span><span class="synSpecial">:</span> | wget <span class="synConstant">&quot;https://github.com/suzuki-shunsuke/tfcmt/releases/download/${{ env.TFCMT_VERSION }}/tfcmt_linux_amd64.tar.gz&quot;</span> -O /tmp/tfcmt.tar.gz tar xzf /tmp/tfcmt.tar.gz -C /tmp mv /tmp/tfcmt /usr/local/bin <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Post Terraform Plan to PR <span class="synIdentifier">working-directory</span><span class="synSpecial">:</span> ${{ matrix.directory }} <span class="synIdentifier">run</span><span class="synSpecial">:</span> | tfcmt -var <span class="synConstant">&quot;target:${{ matrix.directory }}&quot;</span> --config $(git rev-parse --show-toplevel)/.github/tfcmt.yml plan -patch -- terraform plan </pre> <h2 id="おわりに">おわりに</h2> <p>今回は、モノレポ環境において、<code>terraform plan</code>の結果を確認するCIを構築する過程で直面した課題を共有してきました。 今後は、applyの自動化を含んだCIのさらなる拡充を行なっていければと考えています。</p> <p>最後までお読みいただきありがとうございました!この記事が何らかの形で参考になれば幸いです。</p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> skanta Salesforceの「取引の開始」関連項目がnullになるタイミングについての話 hatenablog://entry/6801883189084712413 2024-03-15T11:00:00+09:00 2024-03-15T19:30:24+09:00 はじめに 初めまして、技術本部 Sansan Engineering Unit Data Hubグループの菊池です。 約6年間Salesforce エンジニアとしてさまざまな経験を積み、2021年1月からSansan株式会社に入社しました。 現在は、営業DXサービス「Sansan」とSalesforce連携を実現する、Data Hub AppExchange Packageの開発/運用・保守をしています。 今回とある調査で初めて知った仕様があったので、本ブログで共有します。 <h3 id="はじめに">はじめに</h3> <p>初めまして、技術本部 Sansan Engineering Unit Data Hubグループの菊池です。</p> <p>約6年間Salesforce エンジニアとしてさまざまな経験を積み、2021年1月からSansan株式会社に入社しました。</p> <p>現在は、営業DXサービス「Sansan」とSalesforce連携を実現する、Data Hub AppExchange Packageの開発/運用・保守をしています。</p> <p>今回とある調査で初めて知った仕様があったので、本ブログで共有します。</p> <h3 id="背景">背景</h3> <p>とある調査の中で「取引を開始したリードレコードから取引先責任者レコードを見ようとしたら、エラー画面が表示される」という事象が起きました。 <figure class="figure-image figure-image-fotolife" title="エラー画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240222/20240222111220.png" width="1200" height="576" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>エラー画面</figcaption></figure></p> <p>「取引の開始」で作成した取引先責任者レコードが削除されていると思い開発者コンソールで対象のデータを見ると、<code>ConvertedContactId</code> には値が設定されていました。 <figure class="figure-image figure-image-fotolife" title="re"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226115333.png" width="852" height="59" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>リードレコードの「取引の開始」に関する項目の値</figcaption></figure></p> <p>そのため、再度開発者コンソールにて、<code>ConvertedContactId</code> に設定されているIdを基にSOQL(Salesforce Object Query Languageの略。Salesforce オブジェクトクエリ言語のこと)を実行したが、該当の取引先責任者レコードは存在していませんでした。 <figure class="figure-image figure-image-fotolife" title="取引先責任者レコード取得のSOQL結果"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240227/20240227095713.png" width="855" height="116" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>取引先責任者レコード取得のSOQL結果</figcaption></figure></p> <p>以上の事象からここで1つの疑問が出てきました。</p> <ul> <li><strong>参照関係項目で設定している親レコードを削除したら、通常値がnullになるのに <code>ConvertedContactId</code> は何故nullにならないのか</strong></li> </ul> <p>この疑問をSalesforceのサポートに問い合わせして確認してみました。</p> <h3 id="原因">原因</h3> <h4 id="概要">概要</h4> <p>通常の参照関係項目と「取引の開始」関連項目( <code>ConvertedAccountId</code>, <code>ConvertedContactId</code>, <code>ConvertedOpportunityId</code>)で親レコード削除時のnullになるタイミングが違うことが原因でした。</p> <h4 id="前提条件">前提条件</h4> <p>Salesforceのレコード削除には3つの状況があります。</p> <ol> <li>ごみ箱に入れる(標準画面からの削除など)</li> <li>物理削除待ちレコード(ごみ箱を空にする)</li> <li>物理削除レコード(不定期で実行されている削除プロセス)</li> </ol> <p>それぞれ1. 2.はディスクに残っている(SOQLで取得可能)が、3.はディスク内から削除されている状況になります。</p> <h4 id="詳細">詳細</h4> <p>通常の参照関係項目の親レコードを削除する(ごみ箱に入れる)と、nullになります。 <figure class="figure-image figure-image-fotolife" title="レコード削除前(ごみ箱に入れる)のレコードの中身"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226114141.png" width="845" height="48" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除前(ごみ箱に入れる)</figcaption></figure> <figure class="figure-image figure-image-fotolife" title="レコード削除後(ごみ箱に入れる)の親レコード詳細画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226114214.png" width="1200" height="596" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除後(ごみ箱に入れる)の親レコード詳細画面</figcaption></figure> <figure class="figure-image figure-image-fotolife" title="レコード削除後(ごみ箱に入れる)のレコードの中身"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226114310.png" width="843" height="56" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除後(ごみ箱に入れる)</figcaption></figure></p> <p>しかし、「取引の開始」関連項目( <code>ConvertedAccountId</code>, <code>ConvertedContactId</code>, <code>ConvertedOpportunityId</code>)は削除しても(ごみ箱に入れる)消えません。 <figure class="figure-image figure-image-fotolife" title="re"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226115333.png" width="852" height="59" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除前(ごみ箱に入れる)のレコードの中身</figcaption></figure><figure class="figure-image figure-image-fotolife" title="レコード削除後(ごみ箱に入れる)の取引先責任者レコード詳細画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226140937.png" width="1200" height="596" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除後(ごみ箱に入れる)の取引先責任者レコード詳細画面</figcaption></figure> <figure class="figure-image figure-image-fotolife" title="レコード削除後(ごみ箱に入れる)のレコードの中身"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226141109.png" width="850" height="59" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除後(ごみ箱に入れる)のレコードの中身</figcaption></figure></p> <p>また、ごみ箱を空にしてもnullにはなりません。 <figure class="figure-image figure-image-fotolife" title="空にした後のごみ箱"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226141219.png" width="1200" height="596" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>空にした後のごみ箱のリストビュー</figcaption></figure> <figure class="figure-image figure-image-fotolife" title="レコード削除後(ごみ箱を空にした)のレコードの中身"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240226/20240226141302.png" width="852" height="66" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除後(ごみ箱を空にした)のレコードの中身</figcaption></figure></p> <p>「取引の開始」関連項目がnullになるタイミングは、レコードが完全に物理削除されたときになります。 <figure class="figure-image figure-image-fotolife" title="レコード削除後(物理削除後)のレコードの中身"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/ys-kikuchi/20240227/20240227095010.png" width="853" height="64" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコード削除後(物理削除後)のレコードの中身</figcaption></figure></p> <p>通常の参照関係項目と「取引の開始」関連項目のnullになるタイミングについて以下の表でまとめました。</p> <table> <thead> <tr> <th> </th> <th> ごみ箱に入れる </th> <th> 物理削除待ちレコード </th> <th> 物理削除レコード </th> </tr> </thead> <tbody> <tr> <td> 通常の参照関係項目 </td> <td> nullになる </td> <td> nullになる </td> <td> nullになる </td> </tr> <tr> <td> 「取引の開始」関連項目 </td> <td> nullにはならない </td> <td> nullにならない </td> <td> nullになる </td> </tr> </tbody> </table> <p>したがって、通常の参照関係項目と「取引の開始」関連項目では親レコードが削除された際のnullになるタイミングが異なります。</p> <h3 id="まとめ">まとめ</h3> <p>「取引の開始」関連項目は、Apex トリガや画面開発で参照する項目ではあるので、その際に注意しないといけない仕様だと思います。</p> <p>また、本仕様について公式ドキュメントには記載が無い隠れた仕様なので、今後も細かい仕様がわかったらブログにて共有します。</p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> ys-kikuchi ChatGPTを使ってVisualforceでSLDSコンポーネントを使った開発を快適にする hatenablog://entry/6801883189086534095 2024-03-14T11:00:00+09:00 2024-03-15T19:30:10+09:00 こんにちは、技術本部 Sansan Engineering Unit Data Hubグループの小林です。 今回は、SalesforceのVisualforce開発に当たって、Lightningの見た目で開発をしたいときに便利なSalesforce Lightning Design System(以降、SLDS)コンポーネントを使う際にChatGPTを使うと便利だよ!というお話をします。 そもそもSLDSコンポーネントをVisualforceで使う機会が少なくなってきていると思いますが、エンタープライズ向けの開発でまだまだユーザがClassic UIを使っている…なんてことも少なくないので、渋… <p>こんにちは、<span style="color: #1d1c1d; font-family: NotoSansJP, Slack-Lato, Slack-Fractions, appleLogo, sans-serif; font-size: 14px; font-style: normal; font-variant-ligatures: common-ligatures; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: #f8f8f8; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; display: inline !important; float: none;">技術本部 Sansan Engineering Unit Data Hubグループ</span>の小林です。</p> <p> </p> <p>今回は、SalesforceのVisualforce開発に当たって、Lightningの見た目で開発をしたいときに便利なSalesforce Lightning Design System(以降、SLDS)コンポーネントを使う際にChatGPTを使うと便利だよ!というお話をします。<br /><br /></p> <p>そもそもSLDSコンポーネントをVisualforceで使う機会が少なくなってきていると思いますが、エンタープライズ向けの開発でまだまだユーザがClassic UIを使っている…なんてことも少なくないので、渋々Visualforceで開発をしている方の一助になればと幸いです。</p> <p><br />ちなみに最初に断っておくと、SansanのAppExchangeパッケージ「Sansan CI」では、一部Visualforceで開発したコンポーネントがありますが、LWCへの開発に徐々にシフトしています。</p> <p> </p> <p>目次</p> <ul class="table-of-contents"> <li><a href="#VisualforceでSLDSコンポーネントを使うときに困ること">VisualforceでSLDSコンポーネントを使うときに困ること</a></li> <li><a href="#ChatGPTにJavaScriptを書いてもらう">ChatGPTにJavaScriptを書いてもらう</a></li> <li><a href="#ChatGPTでSLDSコンポーネントを味方につけよう">ChatGPTでSLDSコンポーネントを味方につけよう</a></li> </ul> <p> </p> <h4 id="VisualforceでSLDSコンポーネントを使うときに困ること">VisualforceでSLDSコンポーネントを使うときに困ること</h4> <p>SLDSコンポーネントはとても便利ですが、Visualforceで使おうとすると困ることがあります。</p> <p>それは、JavaScriptを自分で書かないといけないこと…!</p> <p> </p> <p>サンプルのHTMLをコピペするだけで、Lightningの見た目にはなるのですが、CheckBoxやPickListの動作が機能しないので、自分でJavaScriptを書いてコンポーネントの動作を制御してあげないといけません。<br /><br /></p> <p>これがとても面倒くさい…。</p> <p> </p> <p>一応、SLDSコンポーネントのページに動作制御用のクラスの記載があるのですが、複雑なコンポーネントになってくると、情報が足りないことも多々あります。</p> <p>Visualforceページの情報量が多いとそれだけで工数が膨らむ…なんてことも。</p> <p>しかも、Visualforce + SLDSで開発する機会が少ないのか、検索エンジンを使っても情報が少ないことも…。</p> <p> </p> <p>そんなときに役に立つのが、あのChatGPTです!</p> <p> </p> <h4 id="ChatGPTにJavaScriptを書いてもらう">ChatGPTにJavaScriptを書いてもらう</h4> <p>私も最初は「こんなSalesforce関連のコアなこと聞いて、まともな回答返ってくるのかな…」なんて思いつつ、ChatGPTに聞いてみると…</p> <p> </p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kobaryo_blog/20240226/20240226140847.png" width="885" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p> </p> <p>返ってきた!!!</p> <p>すごい、すごすぎる。</p> <p>しかも、情報としては、以下を書くだけでドンピシャな答えが返ってきます。</p> <p> </p> <ul> <li>SLDSであること</li> <li>コンポーネントの種類</li> <li>Visualforceで使いたいこと</li> <li>JavaScriptのサンプルコードを書いてほしいこと</li> </ul> <p> </p> <p>これで一からSLDSで使われているクラスを調べて、JavaScriptで制御して…という作業をしなくて済みます。</p> <p>もちろん、サンプルコードのままだと動かないこともありますが、少し内容を修正するだけで動くことが多い印象でした。</p> <p>サンプルコードを参考にしつつ、自分でパフォーマンスの観点などを検討すると、工数を削減しつつ、良いコードが書けると思います。</p> <p> </p> <h4 id="ChatGPTでSLDSコンポーネントを味方につけよう">ChatGPTでSLDSコンポーネントを味方につけよう</h4> <p>コーディングを始め、仕事をする上で欠かせない存在になってきているChatGPTですが、SalesforceのSLDSについて聞いてもすぐに答えが返ってくるなんて最初は考えもしていませんでした。</p> <p>一番最初にドンピシャで回答してくれるとわかったときは、ものすごく感動して、その後の開発でも細かいところでもどんどんChatGPTを活用するようになりました。</p> <p> </p> <p>SLDSのVisualforceでの利用を始め、これからもChatGPTを使えるところはどんどん使いつつ、仕事を進めたいと思います。</p> <p> </p> <p>ぜひ、皆さんもChatGPTを使ってSLDSコンポーネントをフル活用して、デザインも良い画面開発を進めてくださいね!<br /><br /></p> <p><a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329" /></a></p> <p><br /><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344" /></a></p> kobaryo_blog Swift Macrosの作り方 hatenablog://entry/6801883189078154395 2024-03-13T11:00:00+09:00 2024-03-15T19:29:43+09:00 こんにちは!技術本部 Mobile ApplicationグループでiOSエンジニアをしている長﨑です。Sansanアプリでは自分たちで定義したSwift Macrosを開発に導入し始めています。Swift Macrosについての勉強会も社内で実施しており、せっかくなので勉強会のコンテンツを記事にしてみます。 この記事では、Swift Macrosを開発するに当たって必要となる基礎知識からマクロの実装方法、CocoaPodsを使ったプロジェクトへの組み込み方法について、解説していきます。 <p>こんにちは!技術本部 Mobile ApplicationグループでiOSエンジニアをしている長﨑です。</p><p>Sansanアプリでは自分たちで定義したSwift Macrosを開発に導入し始めています。Swift Macrosについての勉強会も社内で実施しており、せっかくなので勉強会のコンテンツを記事にしてみます。<br /> この記事では、Swift Macrosを開発するに当たって必要となる基礎知識からマクロの実装方法、CocoaPodsを使ったプロジェクトへの組み込み方法について、解説していきます。</p> <ul class="table-of-contents"> <li><a href="#Swift-Macrosについての基礎知識">Swift Macrosについての基礎知識</a><ul> <li><a href="#Swift-Macrosって何">Swift Macrosって何?</a></li> <li><a href="#Swift-Macrosの種類">Swift Macrosの種類</a></li> <li><a href="#Swift-Macrosには独立したモジュールが必要">Swift Macrosには独立したモジュールが必要</a></li> </ul> </li> <li><a href="#Swift-Macrosを開発してみる">Swift Macrosを開発してみる</a><ul> <li><a href="#Swift-Macros-Packageを作る">Swift Macros Packageを作る</a></li> <li><a href="#Swift-Macros-Packageの構成">Swift Macros Packageの構成</a></li> <li><a href="#マクロを実装する">マクロを実装する</a><ul> <li><a href="#1マクロのインプットとアウトプットを決める">1.マクロのインプットとアウトプットを決める</a></li> <li><a href="#2マクロのエンドポイントを定義する">2.マクロのエンドポイントを定義する</a></li> <li><a href="#3マクロのインプットを解析してアウトプットを構成する">3.マクロのインプットを解析して、アウトプットを構成する</a></li> <li><a href="#31インプットを解析する">3+1.インプットを解析する</a></li> </ul> </li> </ul> </li> <li><a href="#CocoaPodsによる配布">CocoaPodsによる配布</a><ul> <li><a href="#1マクロ実装モジュールのバイナリをビルドする">1.マクロ実装モジュールのバイナリをビルドする</a></li> <li><a href="#2バイナリをレポジトリに含める">2.バイナリをレポジトリに含める</a></li> <li><a href="#3Podspecファイルを追加する">3.Podspecファイルを追加する</a></li> </ul> </li> <li><a href="#最後に">最後に</a></li> </ul> <div class="section"> <h3 id="Swift-Macrosについての基礎知識">Swift Macrosについての基礎知識</h3> <p>まずSwift Macrosを開発するに当たって必要となる知識をまとめたいと思います。</p> <div class="section"> <h4 id="Swift-Macrosって何">Swift Macrosって何?</h4> <p>Swift MacrosはWWDC 2023で発表された技術で、Xcode15以上が必須になります。<br /> サポートしているOSバージョンについては公式な記述を見つけられなかったのですが、少なくともiOS13以上で利用可能なようです。 <a href="#f-b962c51c" id="fn-b962c51c" name="fn-b962c51c" title=" Swift Macrosを開発するためのパッケージを作ると、生成されたPackage.swiftの中で platforms: [iOS(.v13)] が設定されています。このため、少なくともiOS13以上では利用可能と思われます。 ">*1</a></p><p>Swift Macrosはボイラープレートを削減することを目的としています。コードベースにボイラープレートが増えていくのはありがちなことだと思いますが、ボイラープレートは可読性の低下を招いたり、何かしら変更を加える時に修正漏れのリスクを発生させたりします。Swift Macrosは最小限のコードでボイラープレートと同等のコードを擬似的に出力してくれる技術といえるでしょう。</p><p>どういうことなのか具体例を用いて説明してみます。</p><p>Xcode15以上では標準でいくつかの組み込みマクロが利用可能になっていて、例えば <code>#Preview</code> というマクロがあります。<br /> <code>#Preview</code> マクロは、Xcode PreviewsによるUIプレビュー用のコードを提供するマクロです。Swift Macrosによる恩恵をより感じて欲しいので、ここではボイラープレートが多めのUIKitでのUIプレビューを例に見ていきましょう。</p><p>UIKitのViewをプレビューするためには、Swift Macros以前は次のようなコードが必要でした。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Wrapper</span><span class="synSpecial">:</span> <span class="synType">UIViewRepresentable</span> { <span class="synPreProc">func</span> <span class="synIdentifier">makeUIView</span>(context<span class="synSpecial">:</span> <span class="synType">Context</span>) <span class="synSpecial">-&gt;</span> <span class="synType">SomeView</span> { <span class="synComment">// &lt;code&gt;SomeView&lt;/code&gt; は &lt;code&gt;UIView&lt;/code&gt; の子クラスとします</span> SomeView() } <span class="synPreProc">func</span> <span class="synIdentifier">updateUIView</span>(_ uiView<span class="synSpecial">:</span> <span class="synType">SomeView</span>, context<span class="synSpecial">:</span> <span class="synType">Context</span>) { } } <span class="synPreProc">struct</span> <span class="synIdentifier">SomeView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { Wrapper() } } </pre><p>Swiftの言語仕様上、これらが必要なことはわかるのですが、やりたいことに比してコード量が多いと思ってしまいます。</p><p>これが <code>#Preview</code> を使うことで次のように書けます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink>#Preview { SomeView() } </pre><p>最小限の記述だけになり、本質的な部分にフォーカスできていることがわかります。</p><p>これをSwift Macrosはどのように実現しているかというと、ビルド時にマクロの呼び出しに従って、以前のコードと同等の役割を持つコードをコンパイラの内部的に出力することで実現しています。</p><p>マクロが出力するコードを実際に見てみると、より理解が進むと思います。これはXcode上で確認可能です。Swift Macrosの使用箇所を右クリックして「Expand Macro」を選択すると、実際にマクロにより展開されるコードを表示できます。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jiro333/20240227/20240227201027.png" alt="expand-macro" width="342" height="162" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><br /> </p> </div> <div class="section"> <h4 id="Swift-Macrosの種類">Swift Macrosの種類</h4> <p>次にマクロの種類を説明します。Swift Macrosには大きく分けて2種類のマクロがあります。</p> <ul> <li>Attached Macro <ul> <li>付属型マクロ</li> <li><code>@Observable</code> のように、何らかの宣言に付属することで機能を追加するマクロ。</li> <li><code>@</code> から始めるのが特徴。</li> </ul></li> <li>Freestanding Macro <ul> <li>自立型マクロ</li> <li><code>#Preview</code> のように、単体で独立しているマクロ。</li> <li><code>#</code> から始めるのが特徴。</li> </ul></li> </ul><p>ここからさらにどういう出力をするかで種類が分かれます。</p> <ul> <li>ExpressionMacro: 式を返す</li> <li>DeclarationMacro: 定義を追加する</li> <li>…</li> </ul><p>マクロの種類の一覧は<a href="https://swiftpackageindex.com/apple/swift-syntax/509.0.2/documentation/swiftsyntaxmacros#protocols">SwiftSyntaxMacros&#x306E;&#x30D7;&#x30ED;&#x30C8;&#x30B3;&#x30EB;&#x4E00;&#x89A7;</a>にあります。すべてを説明すると長くなってしまうので、ここではこのレベルの説明にとどめさせてください。</p> </div> <div class="section"> <h4 id="Swift-Macrosには独立したモジュールが必要">Swift Macrosには独立したモジュールが必要</h4> <p>Swift Macrosを定義するには独立したモジュールが必要です。<a href="https://www.swiftlangjp.com/language-guide/macros.html#%E3%83%9E%E3%82%AF%E3%83%AD%E3%81%AE%E5%AE%9F%E8%A3%85">&#x516C;&#x5F0F;&#x306B;&#x7D39;&#x4ECB;&#x3055;&#x308C;&#x3066;&#x3044;&#x308B;&#x65B9;&#x6CD5;</a>としては、SwiftPMでパッケージを作ることになります。独立したパッケージとして構成し、ライブラリの形でアプリのプロジェクトに組み込むことで利用可能となります。</p><p>よって、アプリへの組み込み方法としては、SwiftPMを利用するのが簡単です。<br /> ただ、CocoaPodsで組み込む方法もありますので、後ほどご紹介します。</p> </div> </div> <div class="section"> <h3 id="Swift-Macrosを開発してみる">Swift Macrosを開発してみる</h3> <p>ここからは実際にどうやってSwift Macrosを開発するのかを説明していきます。</p> <div class="section"> <h4 id="Swift-Macros-Packageを作る">Swift Macros Packageを作る</h4> <p>まずは何はともあれプロジェクトを作りましょう。前述の通り、SwiftPMでパッケージを作ります。</p><p>Xcodeから作成する方法とコマンドラインを利用する方法がありますが、Xcodeから作成する方法で説明していきます。</p><p>XcodeのFile > New > Package… を選択します。</p><p>ダイアログで <code>Swift Macro</code> を選択し、Nextを押下します。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jiro333/20240227/20240227205306.png" alt="new-swift-macro-package" width="736" height="526" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p>保存ダイアログが表示されるので、保存します。ここではパッケージ名をデフォルトの <code>MyMacro</code> と設定したものとします。</p><p>これでプロジェクトの準備はできました。</p> </div> <div class="section"> <h4 id="Swift-Macros-Packageの構成">Swift Macros Packageの構成</h4> <p>出来上がったSwift Macros Packageの構成について説明します。</p><p><code>MyMacro</code> の中には3つのモジュール+1つのテストモジュールが出来上がっています。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jiro333/20240227/20240227205357.png" alt="macro-module-structure" width="268" height="355" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p>それぞれのモジュールの役割を以下に示します。</p> <ul> <li><code>MyMacro</code> <ul> <li>マクロをAPIとして公開するためのエンドポイントを定義するモジュールです。</li> </ul></li> <li><code>MyMacroClient</code> <ul> <li>プロジェクト内でマクロを動作させるためのモジュールです。実際にマクロを動かすために使用します。</li> </ul></li> <li><code>MyMacroMacros</code> <ul> <li>マクロの実装モジュールです。ここにマクロが実際にどう動くかを実装していきます。</li> </ul></li> <li><code>MyMacroTests</code> <ul> <li>テストモジュールです。テストコードでマクロを単体テストできます。</li> </ul></li> </ul><p>開発時は <code>MyMacroClient</code> のスキームを選択して、ビルドしながら動作確認するのがおすすめです。パッケージを作ったら、一度 <code>MyMacroClient</code> をビルドしておきましょう。</p> </div> <div class="section"> <h4 id="マクロを実装する">マクロを実装する</h4> <p>プロジェクトの準備が整ったので、マクロの実装に入っていきます。</p><p><code>MyMacro</code> の中にはすでにサンプルとして、 <code>#stringify</code> というマクロが定義されています。<br /> この <code>#stringify</code> マクロをベースにして、実装の流れを解説します。</p><p>実装の流れとしては次のようになります。</p><p>1. マクロのインプットとアウトプットを決める<br /> 2. マクロのエンドポイントを定義する<br /> 3. マクロのインプットを解析して、アウトプットを構成する</p><p>この流れに沿って、 <code>#stringify</code> マクロを見ていきましょう。</p> <div class="section"> <h5 id="1マクロのインプットとアウトプットを決める">1.マクロのインプットとアウトプットを決める</h5> <p>通常のプログラミングと同様、マクロにもインプットとアウトプットがあります。まず、この設計を固めましょう。</p><p>インプットは、マクロの種類によって変わってきます。Freestanding Macroであれば独立しているため、インプットはマクロに渡した引数のみになります。Attached Macroの場合は、マクロへ渡す引数に加えて、マクロが付与された定義のコードがインプットになってくるでしょう。</p><p>アウトプットは、マクロ展開後のSwiftコードになります。</p><p>インプットとアウトプットについて、 <code>#stringify</code> マクロではどうなっているか確認していきます。マクロの使用例は <code>MyMacroClient/main.swift</code> にあります。このマクロを展開させてみましょう。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jiro333/20240227/20240227205446.png" alt="expand-stringify-macro" width="441" height="56" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p><code>#stringify</code> マクロはFreestanding Macroのため、インプットは「引数で受け取った式( <code>a + b</code> )」になります。</p><p>アウトプットは「引数で受け取った式そのまま( <code>a + b</code> )と引数を文字列化したもの( <code>"a + b"</code> )のタプル」です。</p><p>インプットとアウトプットが明確になったところで、次のステップに進みます。</p> </div> <div class="section"> <h5 id="2マクロのエンドポイントを定義する">2.マクロのエンドポイントを定義する</h5> <p><code>#stringify</code> マクロのエンドポイントは <code>MyMacro/MyMacro.swift</code> に定義されています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synType">@freestanding</span><span class="synSpecial">(</span><span class="synType">expression</span><span class="synSpecial">)</span> <span class="synStatement">public</span> macro stringify<span class="synIdentifier">&lt;</span>T<span class="synIdentifier">&gt;</span>(_ value<span class="synSpecial">:</span> <span class="synType">T</span>) <span class="synSpecial">-&gt; (</span><span class="synType">T</span>,<span class="synType"> String</span><span class="synSpecial">)</span> <span class="synIdentifier">=</span> #externalMacro(module<span class="synSpecial">:</span> <span class="synConstant">&quot;MyMacroMacros&quot;</span>, type<span class="synSpecial">:</span> <span class="synConstant">&quot;StringifyMacro&quot;</span>) </pre><p>1行ずつ順に読み解いていきましょう。</p> <ul> <li><code>@freestanding(expression)</code> <ul> <li><code>#stringify</code> マクロはFreestanding Macroのため、 <code>@freestanding</code> を指定します。</li> <li>またアウトプットはタプルを式として返す ExpressionMacro であるため、 <code>expression</code> を指定しています。例えば、DeclarationMacroの場合は、ここが <code>declaration</code> になります。</li> </ul></li> <li><code>public macro stringify<T>(_ value: T) -> (T, String)</code> <ul> <li>マクロを定義するのには <code>macro</code> というマクロ用の宣言を使います。</li> <li>あとはファンクションのように、引数と戻り値を定義します。Expression Macro の場合は、アウトプットの式の定義を戻り値に指定する必要があります。</li> </ul></li> <li><code>= #externalMacro(module: "MyMacroMacros", type: "StringifyMacro")</code> <ul> <li>マクロの実装がどこにあるかを定義しています。見たままですが、 <code>MyMacroMacros</code> モジュールにある <code>StringifyMacro</code> がマクロの実装ということになります。</li> </ul></li> </ul> </div> <div class="section"> <h5 id="3マクロのインプットを解析してアウトプットを構成する">3.マクロのインプットを解析して、アウトプットを構成する</h5> <p>いよいよマクロの実装の確認に入っていきます。マクロの実装は <code>MyMacroMacros/MyMacroMacro.swift</code> にあります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">public</span> <span class="synPreProc">struct</span> <span class="synIdentifier">StringifyMacro</span><span class="synSpecial">:</span> <span class="synType">ExpressionMacro</span> { <span class="synStatement">public</span> <span class="synStatement">static</span> <span class="synPreProc">func</span> <span class="synIdentifier">expansion</span>( of node<span class="synSpecial">:</span> <span class="synType">some</span> FreestandingMacroExpansionSyntax, <span class="synStatement">in</span> context<span class="synSpecial">:</span> <span class="synType">some</span> MacroExpansionContext ) <span class="synSpecial">-&gt;</span> <span class="synType">ExprSyntax</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">argument</span> <span class="synIdentifier">=</span> node.argumentList.first?.expression <span class="synStatement">else</span> { fatalError(<span class="synConstant">&quot;compiler bug: the macro does not have any arguments&quot;</span>) } <span class="synStatement">return</span> <span class="synConstant">&quot;(</span><span class="synSpecial">\(</span>argument<span class="synSpecial">)</span><span class="synConstant">, </span><span class="synSpecial">\(</span>literal<span class="synSpecial">:</span> <span class="synType">argument.description</span><span class="synSpecial">)</span><span class="synConstant">)&quot;</span> } } <span class="synType">@main</span> <span class="synPreProc">struct</span> <span class="synIdentifier">MyMacroPlugin</span><span class="synSpecial">:</span> <span class="synType">CompilerPlugin</span> { <span class="synPreProc">let</span> <span class="synIdentifier">providingMacros</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">Macro.Type</span><span class="synSpecial">]</span> <span class="synIdentifier">=</span> [ StringifyMacro.<span class="synIdentifier">self</span>, ] } </pre><p>ここには2つのstructが定義されています。順番に見ていきます。</p><p>1番目のstructの <code>StringifyMacro</code> が <code>#stringify</code> マクロの実装になります。</p> <ul> <li><code>public struct StringifyMacro: ExpressionMacro {</code> <ul> <li>ExpressionMacroであるため、 <code>ExpressionMacro</code> プロトコルへの準拠を宣言しています。</li> </ul></li> <li><code>public static func expansion(</code> 〜 <code>) -> ExprSyntax {</code> <ul> <li><code>ExpressionMacro</code> プロトコル準拠のために必要となるファンクション定義です。</li> <li>シグネチャはマクロによって変わってきますが、使いたいマクロのプロトコルへの準拠宣言をしてXcodeの機能で必要な定義を取ってくれば簡単です。</li> </ul></li> <li><code>guard let argument = node.argumentList.first?.expression else {</code> <ul> <li>ファンクション引数の <code>node</code> から、マクロに渡された引数を受け取っています。マクロに渡された引数は <code>node.argumentList</code> に配列の形で入っています。 <code>#stringify</code> マクロでは引数は1つしかないため、 <code>first</code> でアクセスしています。</li> <li><code>expression</code> で <a href="https://swiftpackageindex.com/apple/swift-syntax/509.1.1/documentation/swiftsyntax/exprsyntax">SwiftSyntax&#x306E;ExprSyntax&#x578B;</a> を受け取っています。Swift Macrosは<a href="https://github.com/apple/swift-syntax">SwiftSyntax</a>の上に成り立っている技術であり、マクロのインプットおよびアウトプットはSwiftSyntaxの型になります。SwiftSyntaxの型について詳細を知りたい場合は、<a href="https://swiftpackageindex.com/apple/swift-syntax/509.1.1/documentation/swiftsyntax">API&#x30EA;&#x30D5;&#x30A1;&#x30EC;&#x30F3;&#x30B9;</a>を見れば解決できます。</li> </ul></li> <li><code>fatalError("compiler bug: the macro does not have any arguments")</code> <ul> <li>guardでアンラップに失敗したら <code>fatalError</code> を投げています。<code>fatalError</code> が投げられてもアプリがクラッシュするわけではなく、マクロが展開できない旨のビルドエラーになるだけなので、あまり気にすることなく <code>fatalError</code> を使用していって大丈夫です。</li> </ul></li> <li><code>return "(\(argument), \(literal: argument.description))"</code> <ul> <li>アウトプットを構成しているのが、この部分になります。</li> <li>このファンクションの戻り値の型は <code>ExprSyntax</code> ですが、ここでは文字列を返しています。これは <a href="https://swiftpackageindex.com/apple/swift-syntax/509.0.2/documentation/swiftsyntaxbuilder/swiftsyntax/exprsyntax/expressiblebystringliteral-implementations">ExprSyntax &#x304C; ExpressibleByStringLiteral &#x306B;&#x6E96;&#x62E0;&#x3057;&#x3066;&#x3044;&#x308B;</a>ためです。 <code>ExpressibleByStringLiteral</code> に準拠した型では、コンパイラが文字列リテラルをその型へ変換してくれます。</li> </ul></li> </ul><p>2番目のstructの <code>MyMacroPlugin</code> はマクロモジュールのエントリポイントを定義しています。<br /> 定義したマクロ定義の型を列挙して、モジュールにマクロを登録しています。</p><p>これで <code>#stringify</code> マクロの実装については一通り確認できました。</p><p>ただ、実践的なマクロを組む場合にはもう少しSwiftSyntaxについての知識が必要になります。この <code>#stringify</code> マクロを修正して、SwiftSyntaxへの理解を深めてみようと思います。</p> </div> <div class="section"> <h5 id="31インプットを解析する">3+1.インプットを解析する</h5> <p><code>#stringify</code> マクロではインプットをそのままアウトプットに使っていたため、インプットの解析は不要でした。マクロではインプットを解析して、その中の要素をハンドリングしたいことがあるため、その方法を解説します。</p><p>実装の流れは次の通りです。</p><p>1. インプットの構文木を把握する。<br /> 2. <code>as(XxxSyntax.self)</code> で構文木の内容にあうようにSwiftSyntaxの型にパースする。<br /> 3. SwiftSyntaxの型を操作して、目的の要素を取得する。</p><p>では、まずインプットの構文木を把握するところから始めます。構文木を把握するには <a href="https://swift-ast-explorer.com/">Swift AST Explorer</a> を使うのが便利です。 Swift AST Explorer は入力したSwiftコードをSwiftSyntaxの構文木として表示してくれるWebサービスです。</p><p>Swift AST Explorer を開いて、左のペインに <code>#stringify</code> マクロの呼び出しイメージを貼り付けてください。そうすると、右側のペインにそのコードの構文木が表示されます。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jiro333/20240227/20240227205537.png" alt="swift-ast-explorer" width="1200" height="915" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p>黒太字で元のソースコードが表示され、それぞれがどういった構造で格納されているかがわかるかと思います。この構造に沿ってSwiftSyntaxの型にパースしていきます。</p><p>例えば、引数 <code>a + b</code> の左辺 <code>a</code> だけを取得したい場合を考えてみましょう(実用性はないのであくまで練習のためです)。</p><p>引数は <code>node.argumentList.first</code> で取得できるので、そこまでは構文木の探索をスルーできます。 <code>LabeledExpr</code> がラベル付き引数を表しているので、その下の <code>InfixOperatorExpr</code> が引数 <code>a + b</code> を表していることがわかります。</p><p>ハンドリングするために引数を <code>InfixOperatorExpr</code> にパースします。SwiftSyntaxの型へのパースには <code>as(XxxSyntax.self)</code> というファンクションが用意されています。 <code>as</code> の引数はSwiftSyntaxの型です。<br /> <code>InfixOperatorExpr</code> はSwiftSyntaxの型としては末尾に <code>Syntax</code> をつけます。<br /> 従って <code>InfixOperatorExprSyntax.self</code> が <code>as</code> へ渡す引数になります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">argument</span> <span class="synIdentifier">=</span> node.argumentList.first?.expression, <span class="synPreProc">let</span> <span class="synIdentifier">infixOperatorExpr</span> <span class="synIdentifier">=</span> argument.<span class="synStatement">as</span><span class="synSpecial">(</span><span class="synType">InfixOperatorExprSyntax.self</span><span class="synSpecial">)</span> <span class="synComment">// 👈</span> <span class="synStatement">else</span> { fatalError(<span class="synConstant">&quot;compiler bug: the macro does not have any arguments&quot;</span>) } </pre><p>これで <code>InfixOperatorExprSyntax</code> 型にパースできました。</p><p><code>InfixOperatorExprSyntax</code> から左辺を取り出すには、<a href="https://swiftpackageindex.com/apple/swift-syntax/509.1.1/documentation/swiftsyntax/infixoperatorexprsyntax">API&#x30EA;&#x30D5;&#x30A1;&#x30EC;&#x30F3;&#x30B9;</a>を確認します。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jiro333/20240227/20240227205632.png" alt="InfixOperatorExprSyntax" width="1200" height="856" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p><code>Children</code> に書かれている要素にプロパティとしてアクセスできるので、 <code>leftOperand</code> で左辺が取得できそうです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">argument</span> <span class="synIdentifier">=</span> node.argumentList.first?.expression, <span class="synPreProc">let</span> <span class="synIdentifier">infixOperatorExpr</span> <span class="synIdentifier">=</span> argument.<span class="synStatement">as</span><span class="synSpecial">(</span><span class="synType">InfixOperatorExprSyntax.self</span><span class="synSpecial">)</span> <span class="synStatement">else</span> { fatalError(<span class="synConstant">&quot;compiler bug: the macro does not have any arguments&quot;</span>) } <span class="synPreProc">let</span> <span class="synIdentifier">leftOperand</span> <span class="synIdentifier">=</span> infixOperatorExpr.leftOperand <span class="synComment">// 👈</span> </pre><p>これで左辺 <code>a</code> を取得できました。</p><p>このように、インプットを解析して要素を取得し、欲しいアウトプットの形に構成していくことがマクロの実装には必要になります。</p><p>なお、構文木の解析には <code>SyntaxVisitor</code> を使って構文木を走査する方法もありますが、詳細についてはここでは割愛させてください。</p> </div> </div> </div> <div class="section"> <h3 id="CocoaPodsによる配布">CocoaPodsによる配布</h3> <p>最後に、できあがったSwift Macrosライブラリの配布について説明します。</p><p>SwiftPMでの配布であれば、とても簡単です。SwiftPMでライブラリを追加して、 <code>MyMacro</code> モジュールをimportすれば使用可能です。</p><p>ただ、プロジェクト方針などによりSwiftPMの使用に支障がある場合などもあるので、CocoaPodsでも配布したいことがあります。<br /> CocoaPodsで配布できるようにする手順としては、次の3つになります。</p><p>1. マクロ実装モジュールのバイナリをビルドする<br /> 2. バイナリをレポジトリに含める<br /> 3. Podspecファイルを追加する</p><p>順番に見ていきます。</p> <div class="section"> <h5 id="1マクロ実装モジュールのバイナリをビルドする">1.マクロ実装モジュールのバイナリをビルドする</h5> <p>ただのSwiftPMのパッケージなので、次のコマンドでビルドできます。</p> <pre class="code lang-sh" data-lang="sh" data-unlink>swift build <span class="synSpecial">-c</span> release </pre><p>これで <code>.build/release</code> の下にバイナリができあがります。いくつかファイルができていますが、必要になるものはマクロ実装モジュールのバイナリなので、例でいうと <code>MyMacroMacros</code> になります。</p> </div> <div class="section"> <h5 id="2バイナリをレポジトリに含める">2.バイナリをレポジトリに含める</h5> <p>Podライブラリの中にバイナリを含めて配布する形式になるため、ビルドしたバイナリをレポジトリ内に含めてしまいます。 <code>.build</code> 配下からどこか適当な場所にバイナリをコピーして、コミットすればOKです。例として <code>Builds/MyMacroMacros</code> へコピーしたことにします。</p> </div> <div class="section"> <h5 id="3Podspecファイルを追加する">3.Podspecファイルを追加する</h5> <p>Podspecファイルをレポジトリに追加すれば配布準備完了になります。</p><p>サンプルを以下に示します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">Pod</span>::<span class="synType">Spec</span>.new <span class="synStatement">do</span> |spec| spec.name = <span class="synSpecial">&quot;</span><span class="synConstant">MyMacro</span><span class="synSpecial">&quot;</span> spec.version = <span class="synSpecial">&quot;</span><span class="synConstant">0.0.1</span><span class="synSpecial">&quot;</span> spec.summary = <span class="synSpecial">&quot;</span><span class="synConstant">A short description of MyMacro.</span><span class="synSpecial">&quot;</span> spec.description = &lt;&lt;-<span class="synSpecial">DESC</span> <span class="synConstant"> </span><span class="synSpecial">DESC</span> spec.homepage = <span class="synSpecial">&quot;</span><span class="synConstant">http://EXAMPLE/MyMacro</span><span class="synSpecial">&quot;</span> spec.license = <span class="synSpecial">&quot;</span><span class="synConstant">MIT (example)</span><span class="synSpecial">&quot;</span> spec.author = { <span class="synSpecial">&quot;</span><span class="synConstant">xxx</span><span class="synSpecial">&quot;</span> =&gt; <span class="synSpecial">&quot;</span><span class="synConstant">xxx@example.com</span><span class="synSpecial">&quot;</span> } spec.source = { <span class="synConstant">:git</span> =&gt; <span class="synSpecial">&quot;</span><span class="synConstant">https://EXAMPLE/MyMacro.git</span><span class="synSpecial">&quot;</span>, <span class="synConstant">:tag</span> =&gt; spec.version.to_s } spec.ios.deployment_target = <span class="synSpecial">'</span><span class="synConstant">FIXME</span><span class="synSpecial">'</span> spec.source_files = [<span class="synSpecial">'</span><span class="synConstant">Sources/MyMacro/**/*.swift</span><span class="synSpecial">'</span>] spec.swift_version = <span class="synSpecial">'</span><span class="synConstant">5.9</span><span class="synSpecial">'</span> spec.preserve_paths = [<span class="synSpecial">'</span><span class="synConstant">Builds/MyMacroMacros</span><span class="synSpecial">'</span>] spec.pod_target_xcconfig = { <span class="synSpecial">'</span><span class="synConstant">OTHER_SWIFT_FLAGS</span><span class="synSpecial">'</span> =&gt; <span class="synSpecial">'</span><span class="synConstant">-load-plugin-executable ${PODS_ROOT}/MyMacro/Builds/MyMacroMacros#MyMacroMacros</span><span class="synSpecial">'</span> } spec.user_target_xcconfig = { <span class="synSpecial">'</span><span class="synConstant">OTHER_SWIFT_FLAGS</span><span class="synSpecial">'</span> =&gt; <span class="synSpecial">'</span><span class="synConstant">-load-plugin-executable ${PODS_ROOT}/MyMacro/Builds/MyMacroMacros#MyMacroMacros</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">LD_RUNPATH_SEARCH_PATHS</span><span class="synSpecial">'</span> =&gt; <span class="synSpecial">'</span><span class="synConstant">${PODS_CONFIGURATION_BUILD_DIR}/MyMacro</span><span class="synSpecial">'</span> } <span class="synStatement">end</span> </pre><p>ポイントになるのが、 <code>preserve_paths</code> <code>pod_target_xcconfig</code> <code>user_target_xcconfig</code> の3点です。</p> <ul> <li><code>preserve_paths</code> <ul> <li>ここに先ほどコピーしたバイナリのパスを指定します。</li> <li>この指定によりバイナリを含めて配布されるようになります。</li> </ul></li> <li><code>pod_target_xcconfig</code> <ul> <li>この指定によりPodターゲットの <code>OTHER_SWIFT_FLAGS</code> ビルド設定に <code>-load-plugin-executable</code> オプションを設定しています。<code>-load-plugin-executable</code> オプションによってマクロ実装モジュールがコンパイラプラグインとしてロードされるようになります。</li> </ul></li> <li><code>user_target_xcconfig</code> <ul> <li>こちらの指定は <code>pod_target_xcconfig</code> と同じ <code>OTHER_SWIFT_FLAGS</code> 設定をPodを組み込む側のターゲットにも設定しています。</li> <li>また、アプリ実行時にもライブラリを見つけられない旨のエラーが発生したため、 <code>LD_RUNPATH_SEARCH_PATHS</code> も設定しています。</li> </ul></li> </ul><p>これで実装したマクロをCocoaPodsで配布できるようになりました。</p> </div> </div> <div class="section"> <h3 id="最後に">最後に</h3> <p>長文となってしまいましたが、最後まで読んでいただきありがとうございました!</p><p>Swift Macrosを使うことで、プロジェクト内で頻発する冗長なボイラープレートを減らすのに効果があると感じています。コード自動生成など他にもアプローチはありますが、個人的には以下がSwift Macrosの強みだと思います。</p> <ul> <li>マクロ利用時に補完が効くこと <ul> <li>普通のコードと同じくXcodeが補完候補を表示してくれるので、どういう使い方するんだったっけ?と悩むことがないです。</li> </ul></li> <li>テスト可能であること <ul> <li>記事中で触れられていない点で恐縮ですが、XCTestで容易に単体テストできるので、品質を高めやすいです。</li> </ul></li> <li>Swift標準機能であること <ul> <li>ファーストパーティ提供による安心感は代えがたいです。</li> </ul></li> </ul><p>ボイラープレートが多くて読みにくいなぁと感じたら、ぜひSwift Macrosの導入にトライしてみてください!</p><p>Sansanでは、共にSansan / Eightのモバイルアプリを開発していく仲間を募集中です!<br /> 選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もありますので、ご興味ありましたらぜひ面談だけでもお越しいただければ幸いです!</p><p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76610" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+iOS%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76610?_gl=1*eqw0uw*_ga*MTczMDU1OTY2NS4xNjY0NzU2ODE0*_ga_EN18Q6V0JW*MTcwNjI1MTAyNS42LjEuMTcwNjI1MTA0My4wLjAuMA..">open.talentio.com</a></cite></p><p><a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div><div class="footnote"> <p class="footnote"><a href="#fn-b962c51c" id="f-b962c51c" name="f-b962c51c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"> Swift Macrosを開発するためのパッケージを作ると、生成されたPackage.swiftの中で platforms: [iOS(.v13)] が設定されています。このため、少なくともiOS13以上では利用可能と思われます。 </span></p> </div> jiro333 30代からプログラミングを本格的に始めたエンジニアが生産性について思うこと hatenablog://entry/6801883189088092415 2024-03-12T11:00:00+09:00 2024-03-15T19:29:22+09:00 最近キーボードで文字を打つのが面倒になってきている技術本部 Eight Engineering Unitの斉藤です。 キーボードは既に100年以上使われ続けているみたいですね。そろそろ新しい入力の方法ができてもよさそうです。 例えば、頭で考えていることが文字に起こせたら、AIに任せるよりももっと便利だと思います。前置きはさておき、Sansanではちょっと前にエンジニアの生産性と生産量の最大 化が話題になっていました。このブログをご覧の方ならご存知の方も多いのではないでしょうか。私はこれまで何度か転職をしていますが、どの職場でも例外なくこの話題が挙がりました。 チームとして、あるいは事業としてど… <p>最近キーボードで文字を打つのが面倒になってきている技術本部 Eight Engineering Unitの斉藤です。<br /> キーボードは既に100年以上使われ続けているみたいですね。そろそろ新しい入力の方法ができてもよさそうです。<br /> 例えば、頭で考えていることが文字に起こせたら、AIに任せるよりももっと便利だと思います。</p><p>前置きはさておき、Sansanではちょっと前にエンジニアの生産性と生産量の最大<br /> 化が話題になっていました。このブログをご覧の方ならご存知の方も多いのではないでしょうか。</p><p>私はこれまで何度か転職をしていますが、どの職場でも例外なくこの話題が挙がりました。<br /> チームとして、あるいは事業としてどう最大化するかが基本前提となるのですが、私が今回話したいのは個人としての生産性の最大化についてです。<br /> 私は個人の生産性を上げることもチームの生産性を上げるのと同じくらい非常に大事なことだと考えています。<br /> サッカーに例えると、同じ戦術のチームがあるとして、パスやドリブルが上手い人が多いチームの方が強そうですよね。</p><p>私は30代になってからエンジニアを本格的に始めたので、自身のスキルに一抹の不安があった時期があります。<br /> しかし、焦点になるのは先に挙げたように全体の話となることが多いので、個人の成長は基本個人任せになりがちです。</p><p>そこで、どうしたら個人の生産性を上げられるのだろうか、と自問しながらこれまでエンジニアとして経験してきた中で、いい機会だと感じたので、そのやり方を言語化することにしました。<br /> その中でも特に影響が大きかったものを3つ挙げたいと思います。</p> <div class="section"> <h4 id="1-コードを全て把握する">1. コードを全て把握する</h4> <p>コードを全て把握している、ということはどんな改修依頼がきても修正のイメージが瞬時に湧くと思います。<br /> しかし、システムを1から作っているならともかく、誰かが作ったシステムの場合、改修を担当した箇所しか把握していないことも多いと思います。</p><p>そうなると、これができている・できていないでは大きな差になると思います。<br /> 私は空き時間を利用してコードを全て見るようにしています。<br /> なんなら業務の一環としてこの時間を使ってもいいと考えています。</p><p>ただ、それだけではなかなか見るモチベーションが上がらないので、以下のやり方をする事で効率があがりました。</p> <ul> <li><strong>ユーザーストーリーに沿ってコードを追ってみる</strong></li> <li><strong>仮想修正タスクを作り、修正を加えてみる</strong></li> </ul><p>特に前者はドメイン知識の理解にもつながるのでおすすめです。</p> </div> <div class="section"> <h4 id="2-短時間でも集中する癖をつける">2. 短時間でも集中する癖をつける</h4> <p>一日中コーディングに時間を費やすことのできる環境であれば問題はないのですが、不意の他者からの仕様確認やエラー対応などで作業が中断されることは多いと思います。<br /> そんな時、15分後にミーティングがあるとしたら、手が動かない人もいるかも知れません。<br /> さらにそれが繰り返されると、今日はもうコーディングを諦めよう、という境地に至るでしょう。</p><p>しかし、そんな状況でもすぐに開発作業に復帰できるのであれば、これは大きなアドバンテージです。<br /> 私は作業が中断された時は以下のやり方で集中力の復元をしてきました。</p> <ul> <li><strong>短いゴールを目標に作業を再開する</strong></li> <li><strong>ちょっとしたリファクタリングをする</strong></li> </ul><p>後者は1行程度のロジックをこういう書き方にしたらもっとスマートになりそう、と考えることです。要は短い時間でも成果を出せる状態にすることです。<br /> 私はこれを繰り返すことで、集中状態にすぐに復帰できるようになったと感じています。<br /> ただし、ミーティングが次に控えている場合は気をつけましょう。参加するのを忘れます。</p> </div> <div class="section"> <h4 id="3-作っているものに興味を持つ">3. 作っているものに興味を持つ</h4> <p>最後は作っているものに興味を持つことです。愛着を持つ、と言っても過言ではないです。<br /> これがあれば、これまでに挙げたものを含めいろいろなことの原動力+触媒になり得ます。<br /> 感情論にも聞こえてプロには必要ないと思う方もいらっしゃるかも知れませんが、感情に振り回されることがよくないことであって、成果を高めるための感情は必要だと私は思います。</p><p>気持ちの入っていないサービスにユーザが興味を持つでしょうか。<br /> 瞬間的には流行ることもありますが、長続きはしないと思います。</p><p>では、この意識をどう持ったらいいか。自分はこうしました。</p> <ul> <li><strong>競合サービスにはないところを列挙してみる</strong></li> <li><strong>これができればNo.1になれそうな事を考えてみる</strong></li> </ul><p>なくても考えることが大事です。<br /> 先人の偉大さを感じられますし、自分が実現すればもっと凄いことに!<br /> 複雑な実装をする時も前向きな気持ちになれます。</p> </div> <div class="section"> <h4 id="終わりに">終わりに</h4> <p>以上となります。<br /> こうしてみるとタスクの消化スピードを上げることに関連したものが多いですね。<br /> これらを重要だと感じた理由としては、これまでの経験上意外にできてないことだと感じているからだと思いますが、皆さんはどう感じたでしょうか。</p><p>速くタスクをこなせることに越したことはないのに、速くする工夫をしないのではいつまで経っても変わりません。<br /> チームの環境にも依存せず、やっておいて損はないことだと思います。<br /> この内容が皆さんの成長の一助になれば幸いです。</p><p>そんなEightでは、一緒に開発してくださる仲間を募集しています!<br /> 事業状況に対して柔軟に、そして最短でプロダクトデリバリーを実現する組織を一緒に目指しませんか?</p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmedia.sansan-engineering.com%2Feight-engineer" title="名刺アプリ「Eight」" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://media.sansan-engineering.com/eight-engineer">media.sansan-engineering.com</a></cite></p><p>また、3/27 (水) に「持続可能で柔軟な開発プロセスの実践」というイベントで登壇します。ぜひ、お気軽にご参加ください!<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsansan.connpass.com%2Fevent%2F309010%2F" title="持続可能で柔軟な開発プロセスの実践 (2024/03/27 19:00〜)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://sansan.connpass.com/event/309010/">sansan.connpass.com</a></cite></p><p><br><br><br /> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div> minorusaito フロントエンドとバックエンドの一貫したバリデーションで開発プロセスに調和と効率化をもたらす hatenablog://entry/6801883189084940497 2024-03-11T11:00:00+09:00 2024-03-15T19:24:20+09:00 技術本部 Digitization部の湯村です。 新規アプリケーション開発で採用したバリデーションロジックの管理方法を紹介します。 <p>技術本部 Digitization部の湯村です。<br /> 新規アプリケーション開発で採用したバリデーションロジックの管理方法を紹介します。</p> <div class="section"> <h3 id="1-はじめに">1. はじめに</h3> <p>2023年末に以下の技術スタックでデータ化アプリケーションの開発をしました。</p> <ul> <li>フロントエンド: TypeScript + Next.js</li> <li>バックエンド: TypeScript + Express</li> </ul><p>Next.js では App Router を採用しましたが、Server Components、Route Handler は利用せず、ブラウザから Express の API を呼び出す構成にしました。</p> <div class="section"> <h4 id="SPA--API-で開発する際の課題">SPA + API で開発する際の課題</h4> <p>この構成で開発をする際の課題の1つにフロントエンドとバックエンドでのコードの重複があります。<br /> 特にバリデーションのロジックの管理方法は頭を悩ませた方も多いはずです。</p> </div> <div class="section"> <h4 id="バリデーションに対するアプローチ">バリデーションに対するアプローチ</h4> <p>バリデーションのロジックを管理する方法は以下のいずれかになるはずです。</p> <ul> <li>フロントエンド、バックエンドのいずれかにロジックを寄せる</li> <li>フロントエンド、バックエンドの両方でバリデーションをかける</li> </ul> </div> <div class="section"> <h4 id="それぞれのバリデーションの重要性">それぞれのバリデーションの重要性</h4> <p>システムを不正な入力から守り、利用者に不正な入力であることを素早くフィードバックするためにもフロントエンドとバックエンドの両方でバリデーションを実行するのが理想的です。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n-yumura/20240221/20240221135340.png" width="605" height="186" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><br /> ただし両方でバリデーションを実行すると実装の手間が増え、ロジックの不整合も発生しやすくなります。</p> </div> </div> <div class="section"> <h3 id="2-目指したこと">2. 目指したこと</h3> <p>今回開発したアプリケーションでは、このような課題を解決しながらフロントエンドとバックエンドの両方でバリデーションを実行する方法を模索しました。</p> </div> <div class="section"> <h3 id="3-採用したアプローチ">3. 採用したアプローチ</h3> <p>いくつかのアプローチを検討しましたが、今回は OpenAPI の定義ファイルを利用してバリデーションのコードを自動生成しました。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n-yumura/20240222/20240222114559.png" width="1200" height="374" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><br /> フロントエンドとバックエンドの詳細な実装フローを紹介します。</p> <div class="section"> <h4 id="フロントエンド">フロントエンド</h4> <div class="section"> <h5 id="1-OpenAPI-の定義から-Zod-のスキーマを生成する">1. OpenAPI の定義から Zod のスキーマを生成する</h5> <p><a href="https://github.com/astahmer/openapi-zod-client">openapi-zod-client</a> というライブラリを利用しました。<br /> Zodios という型安全な API クライアントを生成するライブラリですが、Zod のスキーマも同時に生成します。<br /> <a href="https://spec.openapis.org/oas/v3.1.0.html#schema-object">OpenAPI &#x306E;&#x30B9;&#x30AD;&#x30FC;&#x30DE;&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</a>で定義された minLength や required も反映できます。</p><p>OpenAPI の定義</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">paths</span><span class="synSpecial">:</span> <span class="synIdentifier">/products</span><span class="synSpecial">:</span> <span class="synIdentifier">post</span><span class="synSpecial">:</span> <span class="synIdentifier">operationId</span><span class="synSpecial">:</span> createProduct <span class="synIdentifier">requestBody</span><span class="synSpecial">:</span> <span class="synIdentifier">content</span><span class="synSpecial">:</span> <span class="synIdentifier">application/json</span><span class="synSpecial">:</span> <span class="synIdentifier">schema</span><span class="synSpecial">:</span> <span class="synIdentifier">$ref</span><span class="synSpecial">:</span> <span class="synConstant">&quot;#/components/schemas/CreateProductPayload&quot;</span> <span class="synIdentifier">responses</span><span class="synSpecial">:</span> <span class="synConstant">&quot;200&quot;</span><span class="synSpecial">:</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> OK <span class="synIdentifier">components</span><span class="synSpecial">:</span> <span class="synIdentifier">schemas</span><span class="synSpecial">:</span> <span class="synIdentifier">CreateProductPayload</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> object <span class="synIdentifier">properties</span><span class="synSpecial">:</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> string <span class="synIdentifier">minLength</span><span class="synSpecial">:</span> <span class="synConstant">1</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> string <span class="synIdentifier">required</span><span class="synSpecial">:</span> <span class="synStatement">- </span>name </pre><p>生成される Zod のスキーマ</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> CreateProductPayload <span class="synStatement">=</span> z .<span class="synType">object</span><span class="synStatement">(</span><span class="synIdentifier">{</span> name: z.<span class="synType">string</span><span class="synStatement">()</span>.min<span class="synStatement">(</span><span class="synConstant">1</span><span class="synStatement">),</span> description: z.<span class="synType">string</span><span class="synStatement">()</span>.optional<span class="synStatement">()</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> .passthrough<span class="synStatement">();</span> </pre> </div> <div class="section"> <h5 id="2-Zod-のオブジェクトを使ってフォームのバリデーションを実行する">2. Zod のオブジェクトを使ってフォームのバリデーションを実行する</h5> <p><a href="https://github.com/react-hook-form/react-hook-form">react-hook-form</a> と <a href="https://github.com/react-hook-form/resolvers">resolvers</a> というライブラリを利用しました。<br /> 次のように、生成した Zod のスキーマを渡すことでバリデーションを実行できます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> zodResolver <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@hookform/resolvers/zod&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> useForm <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;react-hook-form&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> schemas <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;./generated/schema&quot;</span><span class="synStatement">;</span> <span class="synComment">// 生成された Zod のスキーマ</span> <span class="synType">const</span> CreateProductPayload <span class="synStatement">=</span> schemas.CreateProductPayload.strict<span class="synStatement">();</span> <span class="synStatement">function</span> Form<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> <span class="synIdentifier">{</span> handleSubmit<span class="synStatement">,</span> register<span class="synStatement">,</span> formState: <span class="synIdentifier">{</span> errors<span class="synStatement">,</span> isValid <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synStatement">=</span> useForm<span class="synStatement">&lt;</span>z.infer<span class="synStatement">&lt;typeof</span> CreateProductPayload<span class="synStatement">&gt;&gt;(</span><span class="synIdentifier">{</span> resolver: zodResolver<span class="synStatement">(</span>CreateProductPayload<span class="synStatement">),</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synStatement">return</span> <span class="synStatement">&lt;</span>form /<span class="synStatement">&gt;;</span> <span class="synIdentifier">}</span> </pre><p><a href="https://react-hook-form.com/docs/useform/formstate">react-hook-form &#x306E; formState</a> や <a href="https://github.com/aiji42/zod-i18n">zod-i18n</a> を合わせて活用すると、エラーメッセージの表示や有効な値が入力されるまで送信ボタンを disabled にするなどの対応も簡単に行えます。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n-yumura/20240221/20240221161519.png" width="634" height="334" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><br /> OpenAPI の定義からこのようなフォームの実装を効率的に行えます。</p> </div> </div> <div class="section"> <h4 id="バックエンド">バックエンド</h4> <div class="section"> <h5 id="1OpenAPI-の定義からバリデーションロジックを生成する">1.OpenAPI の定義からバリデーションロジックを生成する</h5> <p><a href="https://github.com/cdimascio/express-openapi-validator">express-openapi-validator</a> を利用しました。<br /> unopinionated ライブラリで Express にミドルウェアを追加するだけでバリデーションを実行できます。<br /> OpenAPI で定義されていないクエリパラメーターの扱い方など、バリデーションのオプションが豊富である点も魅力です。</p> </div> <div class="section"> <h5 id="2-OpenAPI-の定義からリクエストの型を生成する">2. OpenAPI の定義からリクエストの型を生成する</h5> <p>express-openapi-validator はバリデーションを実行するミドルウェアです。バリデーションが実行されたリクエストのパラメーターに型がつくわけではありません。<br /> 型の再定義を避けるために <a href="https://github.com/acacode/swagger-typescript-api">swagger-typescript-api</a> を採用して、リクエストハンドラーに型をつけました。<br /> バックエンドでの実装は過去に弊社の秋山が書いた記事を参考にしました。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2023%2F08%2F14%2F182118" title=" TypeScriptプロジェクトにスキーマ駆動開発を持ち込み、より型安全な世界へ - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2023/08/14/182118">buildersbox.corp-sansan.com</a></cite></p><p>このように、ライブラリを活用することで OpenAPI の定義をベースにフロントエンドとバックエンドで一貫したバリデーションを効率的に実装できました。</p> </div> </div> </div> <div class="section"> <h3 id="4-OpenAPI-の定義のさらなる活用">4. OpenAPI の定義のさらなる活用</h3> <p>さらなる効率化のために、バリデーション以外にも OpenAPI の定義を活用しました。</p> <div class="section"> <h4 id="API-クライアントの自動生成">API クライアントの自動生成</h4> <p><a href="https://github.com/acacode/swagger-typescript-api">swagger-typescript-api</a> を利用して API クライアントを自動生成しています。<br /> openapi-zod-client で生成した Zodios をそのまま使っても良かったのですが、自動生成のオプションの豊富さ、生成される型の使いやすさを評価して採用しました。</p> </div> <div class="section"> <h4 id="API-のモックデータを半自動生成">API のモックデータを半自動生成</h4> <p><a href="https://github.com/timdeschryver/zod-fixture">zod-fixture</a> を利用して Zod のスキーマからモックデータを生成しました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> Fixture <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;zod-fixture&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> schemas <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;./generated/schema&quot;</span><span class="synStatement">;</span> <span class="synComment">// 生成された Zod のスキーマ</span> <span class="synStatement">export</span> <span class="synStatement">function</span> buildProduct<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">new</span> Fixture<span class="synStatement">()</span>.fromSchema<span class="synStatement">(</span>schemas.Product<span class="synStatement">);</span> <span class="synIdentifier">}</span> </pre><p>このモックデータを MSW のレスポンスに使用して、モックを利用したフロントエンドの動作確認を簡単に実施できるようにしました。<br /> さらにこの MSW のハンドラーは Storybook の <a href="https://storybook.js.org/docs/writing-stories/play-function">Play function</a> を使ったテストでも利用しています。これによりインタラクションテストの実装コストも削減しました。</p> </div> </div> <div class="section"> <h3 id="5-まとめ">5. まとめ</h3> <p>OpenAPI で定義したスキーマを活用することで一貫性を保ちながら効率的にバリデーションロジックを生成する方法を紹介しました。<br /> OpenAPI や Zod などエコシステムが盛んなツールを採用することで、効率化の恩恵を受けられます。</p><p><br><br><br /> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div> n-yumura テックリードによる社内キャリアイベントを開催しました hatenablog://entry/6801883189086748730 2024-03-08T11:00:00+09:00 2024-03-15T19:14:06+09:00 こんにちは。技術本部 Digitization部 Bill One Entryグループでエンジニアをしている大森です。 普段の業務に加えてTech道場というイベントの運営に関わっており、本記事はそのイベントのレポートです。Tech道場とは、最新の技術や生産性を高める技術、そしてエンジニアの技術力に触れることを目的とした全社員向けの社内イベントです。*1 今回のTech道場では、主にエンジニアをターゲットとした企画として、テックリードによる社内キャリアイベントを開催しました。 *1:Tech 道場再始動しました!. Sansan Tech Blog. https://buildersbox.co… <p>こんにちは。技術本部 Digitization部 Bill One Entryグループでエンジニアをしている大森です。<br /> 普段の業務に加えてTech道場というイベントの運営に関わっており、本記事はそのイベントのレポートです。</p><p>Tech道場とは、最新の技術や生産性を高める技術、そしてエンジニアの技術力に触れることを目的とした全社員向けの社内イベントです。<a href="#f-61b20656" id="fn-61b20656" name="fn-61b20656" title="Tech 道場再始動しました!. Sansan Tech Blog. https://buildersbox.corp-sansan.com/entry/2022/10/11/110000">*1</a><br /> 今回のTech道場では、主にエンジニアをターゲットとした企画として、テックリードによる社内キャリアイベントを開催しました。</p> <div class="section"> <h3 id="イベントの概要">イベントの概要</h3> <p>テックリードとして笹川・藤原・黒澤の3人が登壇し、新卒3年目のエンジニア、江川が3人にさまざまな質問を投げかけるパネルディスカッションイベントを開催しました。<br /> ゲストの経歴は次の通りです。さまざまなバックグラウンドを持つメンバーが集まりました。</p> <blockquote> <p>笹川 裕人 技術本部 Sansan Engineering Unit 副部長<br /> ◎ 新卒でリクルートに入社。大規模データ基盤の開発、データ分析(ABテスト設計など)、データ基盤のクラウド化に従事<br /> ◎ エムスリーにSWEとして転職。自らも手を動かしながら、AI/ML、SRE、QA、グローバルチームなどのマネジメントを担当</p> </blockquote> <blockquote> <p>藤原 雄介 技術本部Sansan Engineering Unit Data Hubグループ<br /> ◎ SIerで.NET系の技術支援、分散データ処理の導入、大規模案件のアーキテクトや社内標準の策定、IoTプロダクト開発チームのマネージャなどを経て、2023年4月にSansan入社<br /> ◎ 業務外の活動としてOSSライブラリの開発や技術書の翻訳などを実施</p> </blockquote> <blockquote> <p>黒澤 綾香 情報セキュリティ部 CSIRTグループ プロダクトセキュリティチームリーダー<br /> ◎ SIerを経て、2017年 Sansan入社<br /> ◎ Webエンジニアとして、Sansanの開発や、新規事業開発に従事した後、 2021年よりCSIRTに参加 <br /> ◎ プロダクトセキュリティチームのリーダーとして、自社プロダクトの診断やプロダクトセキュリティ強化やインシデントレスポンスに従事<br /> ◎ ペネトレーションテスト<a href="#f-f909330d" id="fn-f909330d" name="fn-f909330d" title="ビル点検員に変装→オフィスにラズパイ持ち込んで社内システム侵入 Sansanが本当にやった“何でもアリ”なセキュリティ演習. ITmedia. https://www.itmedia.co.jp/news/articles/2312/11/news172.html">*2</a>の仕掛け人</p> </blockquote> <blockquote> <p>江川 綾 技術本部 Bill One Engineering Unit<br /> ◎ 2021年新卒でSansanに入社<br /> ◎ Sansan入社前はスタートアップに所属、2人目のエンジニアとしてプロダクト立ち上げに関わる<br /> ◎ Bill Oneのフロントエンドの技術的な改善や、One系プロダクトのデザインシステム開発をリード</p> </blockquote> </div> <div class="section"> <h3 id="イベントの内容">イベントの内容</h3> <p>当日は次の4つのテーマに対して議論が展開されました。</p> <ul> <li>みなさんにとって「テックリード」って何?</li> <li>なぜ「テックリード」のキャリアを選んだ?</li> <li>エンジニアとして心がけてきたことは?</li> <li>今「テックリード」として何が楽しい?</li> </ul><p>それぞれのテーマについて、ゲストの回答と当日の議論を簡単にご紹介します。<br /> <br /> </p> <div class="section"> <h4 id="みなさんにとってテックリードって何">みなさんにとって「テックリード」って何?</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240305/20240305195104.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p><strong>笹川</strong>「frontend から DBの内部構造、最近出たOSSのことまで何でも詳しい人」<br /> <strong>藤原</strong>「プロジェクトにおける技術的な問題に対し、解決策をリードする人」<br /> <strong>黒澤</strong>「Sansan では、エンジニアリングで最大の成果を出してビジネスの価値も最大化していく人」<br /> <strong>江川</strong>「テクノロジーやITに基づいて問題を解決することに3人の共通点がある。特に"聞けば何でも返ってくる"というのは興味深い」</p> </div> <div class="section"> <h4 id="なぜテックリードのキャリアを選んだのか">なぜ「テックリード」のキャリアを選んだのか?</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240305/20240305195335.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p><strong>黒澤</strong>「オールラウンドにさまざまな業務をこなしながら、面白そうなものがあれば積極的に取り組むというスタンスで職務にあたってきた結果、テックリードになっていた」<br /> <strong>藤原</strong>「それまではテックリードとマネジメントの二足のわらじだったが、Sansanに入社する際にテックリードを選んだ。疲弊せずサステナブルにやっていけるのは夢中になれることであると思っており、それがテックリードだった」<br /> <strong>笹川</strong>「仕事には義務的なことがあると思うがそのままやるのではなく、面白いと思った要素を加えるなどして楽しんでいたらテックリードと呼ばれるようになった」<br /> <strong>江川</strong>「テックリードとマネジャーでキャリアが分かれると思うが、実際にどう思うか?」<br /> <strong>黒澤</strong>「テックリードというのは結局リーダーのポジションであり、チームとしての成果を目的とする以上、マネジメントの要素が全くないわけではない」<br /> <strong>藤原</strong>「チームのメンバーが力を発揮しチームが大きく強くなっていくことに夢中になる人もいれば、技術的な面や価値の追加でプロダクトがどんどん良くなっていくことに夢中になる人もいる」<br /> <strong>笹川</strong>「組織を巻き込んで成果を出すことが重要であり、その手段が人であるか技術であるかの違いではないかと考えている」<br /> <br /> </p> </div> <div class="section"> <h4 id="エンジニアとして心がけてきたことは">エンジニアとして心がけてきたことは?</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240305/20240305195732.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p><strong>笹川</strong>「普段から土日もずっとパソコンの前にいることが多い。また、新しい情報は早く得るように意識している。なぜなら翻訳を待ってインプットが遅れるのは致命的だと思っているから」<br /> <strong>藤原</strong>「わからなかったことをそのままにしないで、後で調べたり考えたりすることでスキルが高まると考えている」<br /> <strong>黒澤</strong>「多くのことを知っていることがポイント。隣接する技術領域やQA、セキュリティといった、ちょっと違った視野で物事を見ることで新しいアプローチが見えてくる」<br /> <strong>江川</strong>「どの程度技術を深掘りするのか、キャッチアップの方法についてどうしているのかが気になっている」<br /> <strong>笹川</strong>「全リポジトリを読んだ。会社全体の理解とプロダクトの性質を広く捉えられるようになった」<br /> <strong>黒澤</strong>「どこまで踏み込むかはケースバイケース。重要なのは複数の要素を一本の軸でつなげて理解できるかどうかで、物事の全体像が見えてどのような時にそれを応用できるかが分かるようになる」<br /> <strong>藤原</strong>「浅くても広く知識を持ってインデックスとして知っておくことは大切。とはいえ、まずは一つ得意なものを作って横に広げていくのが良いと思う」<br /> <br /> </p> </div> <div class="section"> <h4 id="今テックリードとして何が楽しい">今「テックリード」として何が楽しい?</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan_omori/20240306/20240306083612.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p><strong>笹川</strong>「"シュッ"ってできるようになったときに、よっしゃ勝った〜みたいな感じになる。嬉しくなっちゃう」<br /> <strong>藤原</strong>「技術を成果に直接つなげられるのが楽しいなぁと思っている」<br /> <strong>黒澤</strong>「平和に会社のサービスがもろもろ動いているのが一番ありがたいなって思ってる」<br /> <strong>江川</strong>「日々の技術的な改善や成果につながるみたいなところが楽しいと聞いて、自分も開発者として日々頑張っていきたいなと思った」</p> <figure class="figure-image figure-image-fotolife" title="パネルディスカッションの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240305/20240305221201.jpg" width="1200" height="798" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>パネルディスカッションの様子</figcaption></figure><p>パネルディスカッションの後には懇親会が開催され、20名を超えるエンジニアたちが軽食を囲みながら交流しました。<br /> 参加者がゲストにもっと話を聞きにいったり、異なる部署のエンジニア同士が交流したりと、こちらも大盛り上がりでした。</p> <figure class="figure-image figure-image-fotolife" title="懇親会の様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan_omori/20240229/20240229160812.jpg" width="1200" height="798" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>懇親会の様子</figcaption></figure><figure class="figure-image figure-image-fotolife" title="藤原と参加者が、藤原の訳書『.NETのクラスライブラリ設計』を手に持って記念撮影をしました。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan_omori/20240229/20240229160847.jpg" width="1200" height="798" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>藤原と参加者が、藤原の訳書『.NETのクラスライブラリ設計』を手に持って記念撮影をしました。</figcaption></figure> </div> </div> <div class="section"> <h3 id="まとめ">まとめ</h3> <p>テックリードによる社内キャリアイベントを通して、テックリードに対する解像度が上がりました。<br /> また、実際にテックリードの方たちが歩んできたキャリアや日頃から意識しているアクションはとても勉強になり、技術力向上への漠然とした不安がかなりスッキリしました。<br /> これからも、コードをたくさん書いて分からないことをたくさん調べながら、自分なりにエンジニアとして楽しみながら頑張っていこうと思います。</p> </div> <div class="section"> <h3 id="おまけイベント運営の裏話と感想">おまけ:イベント運営の裏話と感想</h3> <p>Tech道場は、普段エンジニアリングに関わりのない社員向けに「技術に触れる」をテーマとした社内勉強会を毎月開催しています。<br /> 2月は社内のフロントメンバーが繁忙期のため、思い切ってエンジニア向けの企画を進めることにしました。<br /> また、社内の強いエンジニアを道場師範に見立てた企画ができるとTech道場らしくて面白いのではと思いつき、会社のエンジニア育成に何かしら貢献したいという想いもあり、今回の企画となりました。</p><p>当日は現地参加32名・オンライン参加26名の合計58名(ほぼエンジニア)に参加してもらえて、参加者数目標40名を大きく超える賑やかな会となりました。<br /> 来場者みんながゲストの話が聞けるのを楽しみにしている様子で、ワクワクしている雰囲気ができ上がっていたのが印象的でした。<br /> 今回をきっかけに、モチベーションが高まり、何か新しいアクションにつながる人が現れたら良いなと思っています。</p><p><br><br><br /> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div><div class="footnote"> <p class="footnote"><a href="#fn-61b20656" id="f-61b20656" name="f-61b20656" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">Tech 道場再始動しました!. Sansan Tech Blog. <a href="https://buildersbox.corp-sansan.com/entry/2022/10/11/110000">https://buildersbox.corp-sansan.com/entry/2022/10/11/110000</a></span></p> <p class="footnote"><a href="#fn-f909330d" id="f-f909330d" name="f-f909330d" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">ビル点検員に変装→オフィスにラズパイ持ち込んで社内システム侵入 Sansanが本当にやった“何でもアリ”なセキュリティ演習. ITmedia. <a href="https://www.itmedia.co.jp/news/articles/2312/11/news172.html">https://www.itmedia.co.jp/news/articles/2312/11/news172.html</a></span></p> </div> sansan_omori チームで Learning Session を始めてみた hatenablog://entry/6801883189081049916 2024-03-07T11:00:00+09:00 2024-03-15T19:13:30+09:00 はじめに はじめまして。技術本部 Sansan Engineering Unit Data Hubグループの今村です。みなさんはインプットをどのようにアウトプットしているでしょうか?もちろん業務が1番のアウトプットの場であることは間違いありません。しかし、私たちのチームでは次のような問題意識がありました。 そうは言ってもまだまだアウトプットが足りない アウトプットの場が業務だけだと、学んだことが実際に使われなかった場合、偏りが生じる 学んだ知識がプロジェクトを担当したメンバーだけに閉じてしまってチーム全体に広がらない 以上の問題を解決するために、私たちは「Learning Session」とい… <div class="section"> <h3 id="はじめに">はじめに</h3> <p>はじめまして。技術本部 Sansan Engineering Unit Data Hubグループの今村です。</p><p>みなさんはインプットをどのようにアウトプットしているでしょうか?もちろん業務が1番のアウトプットの場であることは間違いありません。しかし、私たちのチームでは次のような問題意識がありました。</p> <ul> <li>そうは言ってもまだまだアウトプットが足りない</li> <li>アウトプットの場が業務だけだと、学んだことが実際に使われなかった場合、偏りが生じる</li> <li>学んだ知識がプロジェクトを担当したメンバーだけに閉じてしまってチーム全体に広がらない</li> </ul><p>以上の問題を解決するために、私たちは「Learning Session」という形で、チームが学びをアウトプットをしやすい場を設けています。</p><p>今回はその Learning Session について書きます。</p> </div> <div class="section"> <h3 id="Learning-Session-とは">Learning Session とは?</h3> <p>Learning Session はチーム内で学んだことや学びたいことを共有し合う場です。</p><p>ポイントは取り組みのハードルを極力低くすること。<br /> リラックスした雰囲気で、特に準備や成果を求めないことを原則として設けることで、アウトプットする習慣を醸成しつつ、チームの知識レベル向上を目指します。</p><p>また、所属チームメンバーだけではなくグループメンバーが自由に参加できる形式で開催しており、他チームとの交流の場にもなっています。</p> <div class="section"> <h4 id="参考">参考</h4> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fengineering.linecorp.com%2Fja%2Fblog%2Frecommend-learning-session%23%3A~%3Atext%3DLearning%2520Session%25E3%2581%25A8%25E3%2581%25AF%2C%25E5%258B%2589%25E5%25BC%25B7%25E4%25BC%259A%25E3%2581%25AE%25E3%2582%2584%25E3%2582%258A%25E6%2596%25B9%25E3%2581%25A7%25E3%2581%2599%25E3%2580%2582" title="仕事をよりクリエイティブにするための「Learning Session」ノススメ" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://engineering.linecorp.com/ja/blog/recommend-learning-session#:~:text=Learning%20Session%E3%81%A8%E3%81%AF,%E5%8B%89%E5%BC%B7%E4%BC%9A%E3%81%AE%E3%82%84%E3%82%8A%E6%96%B9%E3%81%A7%E3%81%99%E3%80%82">engineering.linecorp.com</a></cite></p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcabi99.hatenablog.com%2Fentry%2F2019%2F10%2F10%2F190152" title="チームの成長のために「Learning Session」を始めた話 - SRE兼スクラムマスターのブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://cabi99.hatenablog.com/entry/2019/10/10/190152">cabi99.hatenablog.com</a></cite><br /> </p> </div> </div> <div class="section"> <h3 id="開催形式">開催形式</h3> <p>開催形式は次の通りです。</p> <ul> <li>週に1回</li> <li>時間は30分</li> <li>参加者は自チームメンバー + グループ全員が自由に参加可能</li> <li>Teamflow というバーチャルオフィスで誰でもウェルカムスタンスで実施(後述)</li> </ul> </div> <div class="section"> <h3 id="どんな内容でセッションしているのか">どんな内容でセッションしているのか?</h3> <p>プロジェクトで得た知見や技術トピックをチームに還元することを想定し、内容はライトなものからヘビーなものまでさまざまです。</p><p>ライトな内容の例としては、「最近見つけた社内ドキュメントをシェアする」や「社内システムのコードをさんぽする」などがあります。最近のライトな話題で言えば、Azure が提供する Blob Storage というストレージサービスには3種類あるといった基本的な知識を、公式ドキュメントをまとめて発表してくれたりしました。</p><p>一方、ヘビーな内容としては、開発中に苦労した箇所をドキュメントにまとめ、それを議論するなどがあります。直近だと、Azure が提供する SDK に不具合があることを発見した内容を共有してれました。</p><p>その内容は、こちらのブログにまとめてくれています。</p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2024%2F03%2F04%2F110000" title="Azure Functionsでの大量データ処理とグレースフルシャットダウン(前編) - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2024/03/04/110000">buildersbox.corp-sansan.com</a></cite></p><br /> <p>30分の中で複数のトピックを扱うこともあれば、参加者間で議論が白熱し、1つのトピックだけで会が終わることもあります。</p><p>よくでてくるトピックは次のようにまとめられます。</p> <ul> <li>社外記事(扱っている技術の公式ドキュメント含む)について議論する</li> <li>ミスをポストモーテム的に共有する</li> <li>調査で使用したスクリプトやクエリを共有する</li> <li>社内システムの仕様を確認する</li> </ul> </div> <div class="section"> <h3 id="工夫していること">工夫していること</h3> <p>取り組みのハードルを下げ、アウトプットや参加のしやすさを重視しています。</p><p>その工夫のひとつとして、Slackの<a href="https://slack.com/intl/ja-jp/help/articles/360000482666-Slack-%E7%94%A8%E3%83%AA%E3%82%A2%E3%82%AF%E5%AD%97%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%A9%E3%83%BC">&#x30EA;&#x30A2;&#x30AF;&#x5B57;&#x30C1;&#x30E3;&#x30F3;&#x30CD;&#x30E9;&#x30FC;&#x30A2;&#x30D7;&#x30EA;</a>を使用しています。このアプリは、あらかじめ決めたリアクションスタンプを押した Slack ポストを特定のチャンネルに自動転送するアプリです。</p><p>「これはチームに共有するとタメになりそう」や「気になるけど今は時間がない。でも、後でみんなと話したい」といったトピックにリアクションさえつければ、即時 Learning Session 専用チャンネルへ投稿されるようにしています。</p><p>「自分が学んだ内容じゃないけど、このトピックはぜひ共有してほしい」と他人からリアクションを押されることもしばしばあります。</p><p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/imamura-33/20240214/20240214134820.png" width="1200" height="1010" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p>指先1つでトピックの提示ができるのでさまざまなレベルのトピックを扱えています。<br /> ちなみに使うリアクションは自由に設定できます。私たちが麦わら帽子を使っているのは、チームメンバーの好きなマンガが『ONE PIECE』だったからです。</p><p>また、Google Meet や Zoom ではなく Teamflow というバーチャルオフィスを利用しています。開放的な空間で途中参加、退出も自由というカタチでやっているので、他のチームのメンバーも時間に余裕があるときや興味深いトピックについて話しているときに気軽に参加してくれるようになりました。</p><p>Data Hub での Teamflow 活用の様子は次の記事で紹介しています。</p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2024%2F02%2F07%2F110000" title="Data Hub グループでのバーチャルオフィス(Teamflow)活用事例 - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2024/02/07/110000">buildersbox.corp-sansan.com</a></cite><br /> </p> </div> <div class="section"> <h3 id="やってみた感想">やってみた感想</h3> <p>やってみて大きな効果だなと感じるのは、自然と「知識に対するアンテナが立つ」ことです。<br /> 必要な技術をキャッチアップする際も、「これは Learning Session で共有したら面白そうだ」という視点で自分の中で体系立てて理解し、まとめることをより意識するようになりました。</p><p>「インプットするときはアウトプットを前提にするとよい」というのは知的生産の文脈でよく語られるテクニックです。<br /> Learning Session を行うことで、チーム全体でそれを自然に実践できるようになってきた手応えがあります。<br /> <br /> </p> </div> <div class="section"> <h3 id="さいごに">さいごに</h3> <p>業務が忙しいと、学ぶための時間、学んだことをアウトプットする時間を意識的に取るのが難しくなりがちです。<br /> 一定の時間を学習のためのインプット時間と決めることで、有意義な学びの時間を確保できると考えています。</p><p>Slack を使用しているなら、アプリを導入して日程を決めるだけで簡単に始められます。興味のあるチームはぜひ始めてみてください。</p><p><br><br><br /> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div> imamura-33 AndroidでBluetooth Low EnergyのL2CAP通信を行う方法と開発で得た知見 hatenablog://entry/6801883189081207383 2024-03-06T11:00:00+09:00 2024-03-15T19:13:14+09:00 技術本部 Mobile Applicationグループに所属する北村です。SansanとEightの両プロダクトをまたぐプロダクト横断チームの一員として、モバイル領域の中長期的な技術的課題の解決や、PoCの開発を担当しています。今回は昨年9月にリリースした、Eightのタッチ名刺交換機能をテーマに、そこで得た知見をこの記事で共有します。 jp.corp-sansan.com タッチ名刺交換とは、Eightのアプリを開いた状態で、同じくEightのアプリを開いた他のAndroid端末やiOS端末と自端末をタッチすることで、デジタル名刺が交換できる機能です。 その際にBluetooth Low E… <p>技術本部 Mobile Applicationグループに所属する北村です。SansanとEightの両プロダクトをまたぐプロダクト横断チームの一員として、モバイル領域の中長期的な技術的課題の解決や、PoCの開発を担当しています。</p><p>今回は昨年9月にリリースした、Eightのタッチ名刺交換機能をテーマに、そこで得た知見をこの記事で共有します。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjp.corp-sansan.com%2Fnews%2F2023%2F0926.html" title="Eightが新機能「タッチ名刺交換」を提供開始~紙の名刺の課題を解決し、 330万ユーザーの名刺交換体験が進化~ | Sansan株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jp.corp-sansan.com/news/2023/0926.html">jp.corp-sansan.com</a></cite></p><br /> <p>タッチ名刺交換とは、Eightのアプリを開いた状態で、同じくEightのアプリを開いた他のAndroid端末やiOS端末と自端末をタッチすることで、デジタル名刺が交換できる機能です。<br /> その際にBluetooth Low Energy(以降BLE)を用いてタッチの検出やデジタル名刺の交換に必要な通信のコネクションを張るタッチ検出&通信ライブラリの開発を担当しました。<br /> なお、今回開発したタッチ検出&通信ライブラリのBLEのアドバタイズ時電波強度を用いた距離推定とタッチ検出については<a href="https://buildersbox.corp-sansan.com/entry/2023/08/04/120104">&#x3053;&#x3061;&#x3089;&#x306E;&#x8A18;&#x4E8B;</a>に詳しく書かれています。また、L2CAPの技術的な部分に関してはiOSでの記事となりますが<br /> <a href="https://buildersbox.corp-sansan.com/entry/2023/11/22/150000">&#x3053;&#x3061;&#x3089;&#x306E;&#x8A18;&#x4E8B;</a>をぜひ参照ください。<br /> <strong>目次</strong></p> <ul class="table-of-contents"> <li><a href="#BLEで端末間通信を行ういくつかの方法">BLEで端末間通信を行ういくつかの方法</a><ul> <li><a href="#1-BLEのアドバタイズパケットにデータを設定しアドバタイズ">1. BLEのアドバタイズパケットにデータを設定しアドバタイズ</a></li> <li><a href="#2-BLEのGATT接続を用いたデータの送受信">2. BLEのGATT接続を用いたデータの送受信</a></li> <li><a href="#3-BLEのL2CAP接続を用いたデータの送受信">3. BLEのL2CAP接続を用いたデータの送受信</a></li> </ul> </li> <li><a href="#BLEのL2CAPを用いて端末間で全二重通信をする方法とそこで得た知見">BLEのL2CAPを用いて端末間で全二重通信をする方法とそこで得た知見</a><ul> <li><a href="#1-通信相手に自分の存在を通知するためのアドバタイズ">1. 通信相手に自分の存在を通知するためのアドバタイズ</a></li> <li><a href="#2-アドバタイズを検知し通信相手を発見する">2. アドバタイズを検知し通信相手を発見する</a></li> <li><a href="#3-GATTサーバを開始しL2CAPの通信に必要なパラメータを送る準備をする">3. GATTサーバを開始しL2CAPの通信に必要なパラメータを送る準備をする</a></li> <li><a href="#4-通信相手を検知した際に相手のGATTサーバに接続する">4. 通信相手を検知した際に相手のGATTサーバに接続する</a></li> <li><a href="#5-通信相手とL2CAP接続を確立する">5. 通信相手とL2CAP接続を確立する</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> <li><a href="#最後に">最後に</a></li> </ul> <div class="section"> <h3 id="BLEで端末間通信を行ういくつかの方法">BLEで端末間通信を行ういくつかの方法</h3> <p>BLEを用いて2端末間で通信する場合、いくつかの選択肢があります。それぞれ特徴があり、今回開発したタッチ検出&通信ライブラリではすべて使っていますが、アプリがメインで使う2端末間の通信には最後のL2CAPを用いています。</p> <div class="section"> <h4 id="1-BLEのアドバタイズパケットにデータを設定しアドバタイズ">1. BLEのアドバタイズパケットにデータを設定しアドバタイズ</h4> <p>BLEのアドバタイズはBLEデバイスが周囲のデバイスに対して自身の存在を知らせたり、提供する機能を公開できたりする仕組みです。<br /> Androidはそのアドバタイズのパケットに任意のデータを設定できます。しかし、iOSでは任意のデータを設定する事はできません。<br /> そのため、タッチ検出&通信ライブラリでは通信相手の検出とアドバタイズの電波強度を用いた距離推定によるタッチ検出を用いています。</p> </div> <div class="section"> <h4 id="2-BLEのGATT接続を用いたデータの送受信">2. BLEのGATT接続を用いたデータの送受信</h4> <p>2端末間でデータの読み書きができますが、送受信のデータ長に制約があり、全二重通信ではありません。Android 13、Pixel 7 Pro環境ではMTUを拡張しないと21byteまでしか設定できませんでした。<br /> タッチ検出&通信ライブラリではL2CAP通信に必要な各種パラメータの送受信に用いています。</p> </div> <div class="section"> <h4 id="3-BLEのL2CAP接続を用いたデータの送受信">3. BLEのL2CAP接続を用いたデータの送受信</h4> <p>TCP/IPのような全二重通信が可能です。アプリからはInputStream、OutputStreamとして読み書きが可能で扱いやすいです。<br /> タッチ検出&通信ライブラリでは2端末間でアプリが通信を行うメインの通信手段として用いています。</p> </div> </div> <div class="section"> <h3 id="BLEのL2CAPを用いて端末間で全二重通信をする方法とそこで得た知見">BLEのL2CAPを用いて端末間で全二重通信をする方法とそこで得た知見</h3> <p>今回開発したタッチ検出&通信ライブラリでは、L2CAPを用いた端末間通信を行いました。Androidでの具体的なL2CAPでの接続方法を紹介します。<br /> 以降はL2CAPの技術的な知識がある前提で紹介しますので、L2CAPは先ほど紹介した<a href="https://buildersbox.corp-sansan.com/entry/2023/11/22/150000">Core Bluetooth&#x306B;&#x304A;&#x3051;&#x308B;L2CAP&#x5B9F;&#x88C5; - &#x57FA;&#x790E;&#x7DE8;</a>を参照してください。</p> <div class="section"> <h4 id="1-通信相手に自分の存在を通知するためのアドバタイズ">1. 通信相手に自分の存在を通知するためのアドバタイズ</h4> <p>L2CAP通信をするためには、アドバタイズによる通信相手の発見、GATT接続によるPSMの交換が必要です。PSMはTCP/IPにおけるポート番号のようなものです。まずはアドバタイズで自分の存在をアドバタイズの受け取り側であるセントラルに知らせましょう。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> bluetoothAdapter = bluetoothManager.adapter <span class="synType">val</span> advertiser = adapter.bluetoothLeAdvertiser <span class="synStatement">try</span> { advertiser.startAdvertisingSet( AdvertisingSetParameters .Builder() .setLegacyMode(<span class="synConstant">true</span>) .setScannable(<span class="synConstant">true</span>) .setConnectable(<span class="synConstant">true</span>) .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_HIGH) .build(), AdvertiseData .Builder() .addServiceUuid(serviceUUID)) .build(), <span class="synConstant">null</span>, <span class="synConstant">null</span>, <span class="synConstant">null</span>, advertisingSetCallback ) } <span class="synStatement">catch</span> (e: <span class="synType">IllegalArgumentException</span>) { <span class="synComment">// 指定したパラメータでアドバタイズがサポートされていない場合に例外がthrowされる</span> } </pre><p>AndroidではBLEのアドバタイズ機能は必須ではないので、対応していない機種があります。対応しているかどうかを取得する方法は<a href="https://source.android.com/docs/compatibility/14/android-14-cdd?hl=ja">Android&#x306E;&#x4E92;&#x63DB;&#x6027;&#x30D7;&#x30ED;&#x30B0;&#x30E9;&#x30E0;&#x306E;&#x30C9;&#x30AD;&#x30E5;&#x30E1;&#x30F3;&#x30C8;</a>に記載があります。<fieldset style="padding: 10px; border: 2px solid #004e98; border-radius: 10px; -moz-border-radius: 10px; -webkit-border-radius: 10px;"><legend><span style="font-size: 18px; color: #004e98; font-weight:bold;">得た知見</span></legend>中国メーカーのAndroid端末で少し古めの端末(Android 10)はアドバタイズができない事がありました。<br /> EightのAndroidアプリに搭載しているタッチ名刺交換機能はAndroid 10以降のデバイスに提供しており、国産メーカのAndroid 10以降の端末は概ねアドバタイズをサポートしているようです。<br /> また、アドバタイズの電波強度はメーカーや端末によって異なり、iPhoneはAndroid端末の平均よりも電波強度が強く、Android端末の中でもメーカーや端末によってAndroidの平均よりも強かったり、さらに弱かったりする端末が存在します。送信電波強度ではなく受信アンテナの感度が低い端末も中には存在しました。</p><p>Androidでアドバタイズの開始と終了を短い間隔で繰り返すと、アドバタイズの開始処理が例外なく開始できてもlogcatにエラーが表示され実際にはアドバタイズが開始できないと言う問題がありました。プログラムからは検知できないエラーなので注意が必要です。</fieldset></p> </div> <div class="section"> <h4 id="2-アドバタイズを検知し通信相手を発見する">2. アドバタイズを検知し通信相手を発見する</h4> <p>次に、アドバタイズをスキャンして通信相手を検出しましょう。BluetoothLeScannerのstartScanメソッドでスキャンを開始できます。<br /> ScanModeで、スキャン結果の通知間隔を指定することができます。アドバタイズのスキャン結果をタッチ検出に用いるため、最短の通知間隔を指定できるSCAN_MODE_LOW_LATENCYを設定しタッチ検出精度を高めています。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> scanner = adapter.bluetoothLeScanner scanner.startScan( listOf( ScanFilter.Builder() .setServiceUuid(serviceUUID)) .build() ), ScanSettings.Builder() .setScanMode(SCAN_MODE_LOW_LATENCY) .build(), scanCallback <span class="synComment">// スキャン結果を受け取るコールバック</span> ) </pre><p><fieldset style="padding: 10px; border: 2px solid #004e98; border-radius: 10px; -moz-border-radius: 10px; -webkit-border-radius: 10px;"><legend><span style="font-size: 18px; color: #004e98; font-weight:bold;">得た知見</span></legend>最短の通知間隔を設定した場合、Pixelなど多くのAndroid端末は1秒間に3,4回の通知頻度ですが、一部メーカーでは1秒に1回程度の通知間隔だったり、通知間隔が不安定で間が空いたり、連続で通知されたり、通知間隔が安定しない端末もありました。<br /> また、Pixel系でもスキャン開始後30分経過すると通知間隔が勝手に1秒に1回程度に広がる挙動となり、通知間隔は安定しない傾向があります。<br /> アドバタイズしている端末を一意に特定するにはBluetoothDeviceのAddressが使えそうに思えますが、この情報はプライバシー保護のため一定時間毎に変わります。そのためアドバタイズしている端末を一意に特定するには別の方法が必要となります。</fieldset></p> </div> <div class="section"> <h4 id="3-GATTサーバを開始しL2CAPの通信に必要なパラメータを送る準備をする">3. GATTサーバを開始しL2CAPの通信に必要なパラメータを送る準備をする</h4> <p>アドバタイズで自分の存在を知らせた際に、相手へGATT接続で自分の情報を伝える必要があります。まずはGATTサーバを開始し、セントラル機からの接続を待ち受けます。<br /> そして、接続があれば接続元に対してsendResponseで応答を送信します。<br /> MTU(最大転送単位)を拡張しないと21byteまでしか送れず、MTUの拡張もどれだけの端末がどれだけのサイズまで拡張できるかわからないため送信データ長には気をつける必要があります。なお、MTUの拡張は<a href="https://developer.android.com/reference/android/bluetooth/BluetoothGatt#requestMtu(int)">BluetoothGatt#requestMtu()</a>で行う事ができます。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synComment">// GATTサーバへの接続があったときのコールバック</span> <span class="synType">val</span> callback = <span class="synType">object</span> : BluetoothGattServerCallback() { <span class="synType">override</span> <span class="synType">fun</span> onCharacteristicReadRequest( device: BluetoothDevice?, requestId: <span class="synType">Int</span>, offset: <span class="synType">Int</span>, characteristic: BluetoothGattCharacteristic?, ) { <span class="synStatement">super</span>.onCharacteristicReadRequest(device, requestId, offset, characteristic) <span class="synComment">// GATTサーバへ接続してきたクライアントに対してレスポンスを書き込む</span> <span class="synType">val</span> sendResponseSucceeded = server?.sendResponse( device, requestId, GATT_SUCCESS, offset, payloadByteArray <span class="synComment">// ペイロードを指定する。MTUを拡張しないと21byteまでしか送れない。</span> ) } <span class="synType">val</span> server = bluetoothManager.openGattServer( context, callback ) <span class="synStatement">?:</span> <span class="synStatement">throw</span> <span class="synType">Exception</span>(<span class="synConstant">&quot;GATTサーバの開始に失敗&quot;</span>) </pre><p><fieldset style="padding: 10px; border: 2px solid #004e98; border-radius: 10px; -moz-border-radius: 10px; -webkit-border-radius: 10px;"><legend><span style="font-size: 18px; color: #004e98; font-weight:bold;">得た知見</span></legend>ネット上ではキャラクタリスティックに設定できるデータ長としていくつか情報がありますが、今回AndroidのPixelで試したところ21byteが最長でした。それを超えるとsendResponseは送信に成功した結果が返ってきますがレスポンスの受信側では受信できません。</fieldset></p> </div> <div class="section"> <h4 id="4-通信相手を検知した際に相手のGATTサーバに接続する">4. 通信相手を検知した際に相手のGATTサーバに接続する</h4> <p>セントラル側でアドバタイズを検知した場合に、アドバタイズ側のGATTサーバに接続しに行きます。<br /> このGATT接続は不安定で失敗するのが日常です。リトライ処理、リトライし続けてもダメな場合に最初からやり直すリカバリー処理が必要です。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink>bluetoothGatt = bluetoothDevice.connectGatt(context, <span class="synConstant">false</span>, <span class="synType">object</span> : BluetoothGattCallback() { <span class="synComment">// GATTの接続状態更新コールバック</span> <span class="synType">override</span> <span class="synType">fun</span> onConnectionStateChange( gatt: BluetoothGatt?, status: <span class="synType">Int</span>, newState: <span class="synType">Int</span>, ) { <span class="synStatement">super</span>.onConnectionStateChange(gatt, status, newState) <span class="synComment">// 接続が成功したらサービスを取得する</span> <span class="synStatement">if</span> (newState <span class="synStatement">==</span> BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } <span class="synComment">// エラーだったら再接続</span> } <span class="synComment">// サービス取得結果通知コールバック</span> <span class="synType">override</span> <span class="synType">fun</span> onServicesDiscovered(gatt: BluetoothGatt?, status: <span class="synType">Int</span>) { <span class="synStatement">super</span>.onServicesDiscovered(gatt, status) <span class="synComment">// サービスを取得</span> <span class="synType">val</span> service = gatt?.getService(settings.serviceUUID) <span class="synComment">// サービスのキャラクタリスティックを取得</span> <span class="synType">val</span> characteristic = service.getCharacteristic(characteristicUUID) <span class="synComment">// キャラクタリスティックの読み込み開始</span> gatt.readCharacteristic(characteristic) } <span class="synComment">// キャラクタリスティックの読み込み完了コールバック</span> <span class="synType">override</span> <span class="synType">fun</span> onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, byteArray: <span class="synType">ByteArray</span>, status: <span class="synType">Int</span> ) { <span class="synComment">// L2CAPの接続に必要な情報の取り出し</span> <span class="synType">val</span> psm = parsePayload(byteArray) } }) </pre><p><fieldset style="padding: 10px; border: 2px solid #004e98; border-radius: 10px; -moz-border-radius: 10px; -webkit-border-radius: 10px;"><legend><span style="font-size: 18px; color: #004e98; font-weight:bold;">得た知見</span></legend>このGATT接続がAndroidのBLE関連で一番の闇かもしれません。<br /> GATT接続が安定せず接続に失敗するのは当たり前で、API上定義されていないエラーが返ってくるのも当たり前です。<br /> よくあるのがstatusが133でエラーになる問題ですが、接続できるまでリトライすることで回避しています。<br /> また、特定メーカーの特定機種でstatusCodeが62のエラーになることもあり、その場合もリトライすることで回避しています。<br /> 無限にリトライはせず、接続のリトライはタイムアウトを設定し、一定秒数までリトライし続けてダメだったら諦めてアドバタイズのスキャンからやり直す対応をしています。</fieldset></p> </div> <div class="section"> <h4 id="5-通信相手とL2CAP接続を確立する">5. 通信相手とL2CAP接続を確立する</h4> <p>L2CAPの接続には、接続先のBluetoothDeviceと、TCP/IPにおけるポート番号にあたるPSMが必要です。GATT接続でPSMが取得することができればL2CAPで接続します。<br /> 今回はBluetoothのペアリングはしないため、セキュアではないL2CAP接続を用いています。<br /> そのため、L2CAPを用いた端末間通信では機微な情報はやりとりせず、Eightのサーバ経由で行います。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> l2capPsm = <span class="synComment">// GATT接続で取得したPSM</span> <span class="synType">val</span> bluetoothSocket = gatt.device?.createInsecureL2capChannel(l2capPsm) bluetoothSocket.connect() <span class="synComment">// あとはInputStream、OutputStreamで読み書きする</span> bluetoothSocket.outputStream.write(byteArray) </pre><p><fieldset style="padding: 10px; border: 2px solid #004e98; border-radius: 10px; -moz-border-radius: 10px; -webkit-border-radius: 10px;"><legend><span style="font-size: 18px; color: #004e98; font-weight:bold;">得た知見</span></legend>Androidでは自端末(セントラル)から同じ端末と複数のL2CAP接続が可能ですが、iOSは同じ端末とは1つのL2CAP接続しかできないようです。</fieldset></p><p></p> </div> </div> <div class="section"> <h3 id="まとめ">まとめ</h3> <p>以上がBLEを用いて通信相手を検知し、L2CAPを用いて端末間で全二重通信を行う方法とそこで得た知見です。<br /> 今回BLEを用いた機能開発を行って得たもっとも重要な知見は、<strong>BLEに「正しい動作」と言うものは期待せず、実機での動作を受け入れ、想定通りの動作をしない場合はそれに対して柔軟に設計や実装を変える必要がある</strong>と言う点です。<br /> また、特にAndroid端末では機種毎の動作に大きな違いがあることからエラー時のログ収集を行い、想定通りの動作をしていない場合に検知できる体制も必要です。</p> </div> <div class="section"> <h3 id="最後に">最後に</h3> <p>Sansanの技術本部では、一緒にSansan / Eightのモバイルアプリを開発する仲間を募集中です。<br /> 選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです。</p><p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76400" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Android%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76400?_gl=1*1wx1ev9*_ga*MTczMDU1OTY2NS4xNjY0NzU2ODE0*_ga_EN18Q6V0JW*MTcwNTY0MDk5MS41LjEuMTcwNTY0MTAwNi4wLjAuMA">open.talentio.com</a></cite></p><p><br><br><br /> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div> kitamura1203 Azure Functionsでの大量データ処理とグレースフルシャットダウン(後編) hatenablog://entry/6801883189082798319 2024-03-05T11:00:00+09:00 2024-03-15T19:10:07+09:00 技術本部Sansan Engieering Unit Data Hubグループの藤原です。前回はAzure Functions好きにしか刺さらないとがった内容を書いてしまいました。反省しているので、今回は間口を広げて.NETの標準クラスライブラリ好きにも刺さる内容になっています。 前回、グレースフルシャットダウン対応のバグを修正したパッチを適用したが、実はうまくいっていなかった……というところまで書きました。今回はさらに深く入り込み、その問題も直した話になっています。一言でいうと、Event Hubトリガーを使っている場合、SDKのバージョンによってはメッセージが処理されないことがあります(M… <p>技術本部Sansan Engieering Unit Data Hubグループの藤原です。<a href="https://buildersbox.corp-sansan.com/entry/2024/03/04/110000">前回</a>はAzure Functions好きにしか刺さらないとがった内容を書いてしまいました。反省しているので、今回は間口を広げて.NETの標準クラスライブラリ好きにも刺さる内容になっています。</p> <p>前回、グレースフルシャットダウン対応のバグを修正したパッチを適用したが、実はうまくいっていなかった……というところまで書きました。今回はさらに深く入り込み、その問題も直した話になっています。一言でいうと、Event Hubトリガーを使っている場合、SDKのバージョンによってはメッセージが処理されないことがあります(<code>Microsoft.Azure.WebJobs.Extensions.EventHubs</code> の v6.1.0 以上を使用する必要があります)。また、 <code>LinkedCancellationTokenSource</code> を使う場合、リンク先のトークンとの競合状態があるので注意が必要です。</p> <p>なお、このブログでは事象を理解するための前提知識としてAzure Functionsの内部構造に触れています。詳しくない方は <a href="https://buildersbox.corp-sansan.com/entry/2024/03/04/110000">前回</a> をご参照ください。また、今回は .NET の内部実装にも踏み込んでいます。</p> <p><strong>目次</strong></p> <ul class="table-of-contents"> <li><a href="#さらなるグレースフルシャットダウン処理のバグ">さらなるグレースフルシャットダウン処理のバグ</a></li> <li><a href="#CancellationTokenCancellationTokenSourceLinkedCancellationTokenSource">CancellationToken、CancellationTokenSource、LinkedCancellationTokenSource</a><ul> <li><a href="#CancellationTokenとCancellationTokenSource">CancellationTokenとCancellationTokenSource</a></li> <li><a href="#LinkedCancellationTokenSource">LinkedCancellationTokenSource</a></li> <li><a href="#LinkedCancellationTokenSourceの実装詳細">LinkedCancellationTokenSourceの実装詳細</a></li> </ul> </li> <li><a href="#グレースフルシャットダウンかどうかの判定">グレースフルシャットダウンかどうかの判定</a></li> <li><a href="#調査と修正">調査と修正</a><ul> <li><a href="#キャンセルされているはずなのにされていない">キャンセルされているはずなのにされていない</a></li> <li><a href="#競合状態の解消">競合状態の解消</a></li> <li><a href="#本当に">本当に?</a></li> </ul> </li> <li><a href="#仲間を募集中です">仲間を募集中です!</a></li> </ul> <h2 id="さらなるグレースフルシャットダウン処理のバグ">さらなるグレースフルシャットダウン処理のバグ</h2> <p><a href="https://buildersbox.corp-sansan.com/entry/2024/03/04/110000">前回</a> 説明したグレースフルシャットダウン対応のパッチを適用した後、Data Hubの運用メンバーからメッセージロストが再発したと報告されました。</p> <p>この場合、可能性としては以下のいずれかです。</p> <ul> <li>他にもメッセージロストを引き起こす問題がある。</li> <li>前回適用したパッチでは完全に問題が修正されていない。</li> </ul> <p>手始めに、「上記のパッチでは完全に問題が修正されていない」可能性を考慮することにしました。あらゆる可能性を模索するには時間がかかるためです。幸運にも、パッチ適用の際にSDKのソースコードを読み込んでいたため、グレースフルシャットダウン時の挙動を再現する方法の勘所が付いていました。そのため、SDKのコードにデバッグコードを仕込みつつ、グレースフルシャットダウンの状態を再現させることができました。</p> <p>実行してみると、グレースフルシャットダウン時にチェックポイントは進まないようでした。しかし、繰り返し実行してみると、ときどきチェックポイントが進んでしまうことがありました。なぜでしょうか。今回も、Azure Functionsと、さらには.NETの標準クラスライブラリの実装に踏み込みながら、何が起こっていたのかを説明します。</p> <h2 id="CancellationTokenCancellationTokenSourceLinkedCancellationTokenSource">CancellationToken、CancellationTokenSource、LinkedCancellationTokenSource</h2> <p>ここで、.NETの標準クラスライブラリにあまり詳しくない方のためにちょっと補足します。.NET標準ライブラリに慣れている方は図の後まで読み飛ばしていただいて大丈夫です。</p> <h3 id="CancellationTokenとCancellationTokenSource">CancellationTokenとCancellationTokenSource</h3> <p>.NETの <a href="https://github.com/dotnet/runtime/blob/80de56dadb3864aec7e8edd3ae32a23aeda08285/src/libraries/System.Private.CoreLib/src/System/Threading/CancellationToken.cs"><code>CancellationToken</code></a> は、キャンセル状態になっているかどうかをアプリケーションに伝える役割を持つオブジェクト(構造体)です。<code>CancellationToken</code> は以下の機能を持ちます。</p> <ul> <li>キャンセル状態になっていることを示す <code>IsCancellationRequested</code> プロパティ</li> <li>キャンセル状態になったときに実行されるコールバックを登録する <code>Register</code> メソッド(一部のチェックを省略した <code>UnsafeRegister</code> もあります)</li> <li>キャンセル状態になっていた時にキャンセル済み例外をスローする <code>ThrowIfCancellationRequested</code> メソッド</li> </ul> <p>ここで、注目すべきポイントが2つあります。まず、プロパティ名が <code>IsCancelled</code> ではなく、<code>IsCancellationRequested</code> になっています。これは、<code>CancellationToken</code> はあくまでキャンセルが要求されていることだけを伝え、実際に処理をキャンセルするのは <code>CancellationToken</code> を利用する側の役割だからです。次に、<code>CancellationToken</code> にはキャンセル状態にする(キャンセル要求をする)機能がありません。これは、 <code>CancellationTokenSource</code> という別のオブジェクト(クラス)に実装されています。</p> <p>実は、<code>CancellationToken</code> は、<code>CancellationTokenSource</code> の <code>Token</code> プロパティから返されるオブジェクトです。取得元の <code>CancellationTokenSource</code> の <code>Cancel</code> メソッドが呼び出されると、その <code>CancellationTokenSource</code> から取得された全ての <code>CancellationToken</code> がキャンセル状態になります。実際のところ、キャンセル状態や後述するコールバックの管理は <code>CancellationTokenSource</code> に実装されており、<code>CancellationToken</code> はキャンセル状態の問い合わせとコールバック登録用のインターフェースであると言えるでしょう。</p> <h3 id="LinkedCancellationTokenSource">LinkedCancellationTokenSource</h3> <p>さて、複数の <code>CancellationToken</code> のいずれかがキャンセル状態になったときに処理をキャンセルしたい場合があるかもしれません。あるいは、受け取った <code>CancellationToken</code> とは別に、独自のキャンセル要求を行いたい場合があるかもしれません。たとえば、一定時間経過した後に処理をタイムアウトさせたい場合が考えられます。これらのケースをサポートするために、1つ以上の <code>CancellationToken</code> に「リンクした」<code>CancellationTokenSource</code>、すなわち <code>LinkedCancellationTokenSource</code> を作成できます。<code>LinkedCancellationTokenSource</code> から取得した <code>CancellationToken</code> は、リンクされた <code>CancellationToken</code> のいずれか(の取得元 <code>CancellationTokenSource</code>)、または <code>LinkedCancellationTokenSource</code> 自身がキャンセルされたときにキャンセル状態になります。</p> <p><figure class="figure-image figure-image-fotolife" title="LinkedCancellationTokenSource"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240213/20240213100525.png" width="1200" height="492" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>LinkedCancellationTokenSource</figcaption></figure></p> <h3 id="LinkedCancellationTokenSourceの実装詳細">LinkedCancellationTokenSourceの実装詳細</h3> <p><code>LinkedCancellationTokenSource</code> の実装はどうなっているのでしょうか? <a href="https://github.com/dotnet/runtime/blob/dc45e96840243b203b13e61952230e225d2aac52/src/libraries/System.Private.CoreLib/src/System/Threading/CancellationTokenSource.cs#L759">.NETのコード</a> を見てみると、リンクする <code>CancellationToken</code> の <code>Register</code> にコールバックを登録し、そのコールバックで <code>LinkedCancellationTokenSource</code> 自身のキャンセル状態を更新しています。その理由は明記されていませんが、推測する限り、次のような理由だと思われます。仮に、単にリンクされた <code>CancellationToken</code> の <code>IsCancellationRequested</code> の論理積をとる実装にした場合、複数の <code>CancellationToken</code> をリンクする場合に、処理のオーダーは O(N) になります。キャンセル状態はプロパティで確認されるので、その性能特性は O(1) であることが期待されます。プロパティの参照は低コストであることを想定し、ループ内で繰り返し参照されることが一般的だからです。コールバックによって <code>LinkedCancellationTokenSource</code> のキャンセル状態を直接書き替えるようにすると、キャンセル状態の取得と更新のいずれも O(1) で済むはずです。</p> <p>さらに、このコールバックの実行やキャンセル状態はロックフリーで実行されており、高い並行性を確保しています。コールバックの実行中に <code>LinkedCancellationTokenSource</code> (から取得した <code>CancellationToken</code>)の <code>IsCancellationRequested</code> を参照しても、ロックの解放待ちで待たされるようなことはありません。ここでも、プロパティの参照が高コストにならないよう考慮されているようです。さらに、コールバックは後入れ先出し(LIFO)で処理されます。これは、あるメソッドの呼び出し元と呼び出し先の両方でコールバックを登録したとき、呼び出し先のコールバックが先に呼び出されるようにしていると推察されます。</p> <h2 id="グレースフルシャットダウンかどうかの判定">グレースフルシャットダウンかどうかの判定</h2> <p>先ほど説明したように、グレースフルシャットダウンの場合にチェックポイントを進めないようにするのはEvent Hubリスナーの役割です。</p> <p>Event Hubリスナーは、Azure Functionsランタイムからグレースフルシャットダウンが通知されると、関数に渡している <code>CancellationToken</code> をキャンセル状態にします。そのため、この <code>CancellationToken</code> がキャンセル状態になっていれば、チェックポイント処理を行わないという単純な判定をしています。</p> <p>さらに、追加の処理として、Event Hubリスナーではオーナーシップを失った場合の考慮も行っています。オーナーシップは、スケールアウトの結果、現在のインスタンスが担当していたEvent Hubパーティションを別のインスタンスが処理するようになった場合に失われます。オーナーシップを失ったケースをフォローするためには、オーナーシップを失ったときキャンセル状態になる別の <code>CancellationToken</code> を併せて使用する必要があります。そのため、Event Hubリスナーはこれらの <code>CancellationToken</code> にリンクされた <code>LinkedCancellationTokenSource</code> を使用しています。先ほど述べたように、グレースフルシャットダウンの判定処理も、この <code>LinkedCancellationTokenSource</code> から取得した <code>CancellationToken</code> で判定しています。</p> <p>なお、関数実行にはFunctionsランタイムのキャンセル状態を判定する<code>CancellationToken</code> だけを渡すようになっていました。これは上記のオーナーシップを失ったときの対応を入れたこの <a href="https://github.com/Azure/azure-sdk-for-net/pull/38067">PR</a> で実装され、SDKのv5.5.0としてリリースされたものですが、<a href="https://github.com/Azure/azure-sdk-for-net/issues/41784#issuecomment-1939086996">issueコメント</a>を見る限り単なるうっかりミスだったようです。実は、これが今回の問題の原因でした。</p> <h2 id="調査と修正">調査と修正</h2> <p>さて、アプリケーション実行がキャンセルされているのにチェックポイント処理が進んでしまう現象が発生したとき、実際には何が起こっていたのでしょうか?</p> <h3 id="キャンセルされているはずなのにされていない">キャンセルされているはずなのにされていない</h3> <p>今回は実際にSDKのコードに手を入れ、デバッグコードを入れて確認することにしました。そうすると、関数実行に渡している <code>CancellationToken</code> の <code>IsCancellationRequested</code> が <code>true</code> になっているのに、チェックポイント処理で判定に使用している <code>LinkedCancellationTokenSource</code> 由来の <code>CancellationToken</code> の <code>IsCancellationRequested</code> は <code>false</code> のままだったのです。いったい何が起こっているのでしょうか。</p> <p>先ほど説明したように、<code>LinkedCancellationTokenSource</code> のキャンセル状態は、リンクされた <code>CancellationToken</code> に登録したコールバックで変更されます。コールバックは順番に(LIFOで)実行されるため、リンクされた <code>CancellationToken</code> がキャンセル状態になった後、 <code>LinkedCancellationTokenSource</code> がキャンセル状態になるまでには若干のタイムラグが生じます。さらに、このコールバック実行と <code>IsCancellationRequested</code> はロックフリー実装なので、このタイムラグの間も、<code>LinkedCancellationTokenSource</code> 由来の <code>CancellationToken</code> の <code>IsCancellationRequested</code> の状態はブロックされることなく確認でき、<code>false</code> を返します。</p> <p><figure class="figure-image figure-image-fotolife" title="LinkedCancellationTokenSourceのキャンセル状態の更新"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240214/20240214095201.png" width="1200" height="654" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>LinkedCancellationTokenSourceのキャンセル状態の更新と競合状態</figcaption></figure></p> <h3 id="競合状態の解消">競合状態の解消</h3> <p>この競合状態を解消する方法は簡単です。 <code>LinkedCancellationTokenSource</code> 由来の <code>CancellationToken</code> を関数に渡すか、コールバック判定処理でリンクされた <code>CancellationToken</code>(2つしかありません)それぞれの状態を見るかのどちらかを行えばよいのです。先ほど説明したように、<a href="https://github.com/Azure/azure-sdk-for-net/pull/38067">ドレイン対応PR</a> が入る前は関数実行に <code>LinkedCancellationTokenSource</code> 由来の <code>CancellationToken</code> を渡しているのですから、ドレインモードを考える必要がない限りこのバージョンの SDK を使うように修正さればよさそうです。幸いにも当該処理ではドレインモードを考慮する必要がなかったので、このグレースフルシャットダウンのバグのみが直っているバージョンに戻した結果、今のところ問題なく動いています。</p> <p>なお、これだけでは別の問題のためのパッチが出たときに現象が再発して困ってしまうので、SDK チームにフィードバックする必要もあります。そのため、この問題はissueとして報告しており、既に<a href="https://github.com/Azure/azure-sdk-for-net/pull/41891">修正パッチ</a> がマージされています。ちなみに、結局、 <code>LinkedCancellationTokenSource</code> 由来の <code>CancellationToken</code> を関数に渡すように修正されました。</p> <h3 id="本当に">本当に?</h3> <p>ところで、ここで疑問が生じます。コールバックが実行されてから <code>LinkedCancellationTokenSource</code> の状態が変わるまでにタイムラグがあることはわかりましたが、このタイムラグの間にチェックポイント判定処理まで進んでしまう可能性はどの程度あるのでしょうか? コードの行数を考えても、コールバックの完了までのCPU命令数と、関数実行がキャンセルされてリトライループを抜けてチェックポイント判定処理までのCPU命令数にはかなりの差があるように思えます。そこまで低い確率を引き当てられるなら、まず宝くじを連番で3枚買うべきでしょう。他にも要因がありそうです。</p> <p>試しに、グレースフルシャットダウンの処理でコールスタックを取ってみました。すると、以下のように、グレースフルシャットダウンの通知による <code>CancellationTokenSource</code> の <code>Cancel()</code> メソッドの呼び出しがコールスタックに含まれていたのです。</p> <p><figure class="figure-image figure-image-fotolife" title="継続が実行されているコールスタック"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240214/20240214095206.png" width="1200" height="1048" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>継続が実行されているコールスタックの抜粋(クリックして拡大)</figcaption></figure></p> <p>これは、以下の理由が合わさって発生したと考えられます。</p> <ul> <li><code>Task.Delay</code> のキャンセル処理は <code>CancellationToken</code> のコールバックとして実装されている。</li> <li>このコールバックはLIFOで実行されるため、<code>LinkedCancellationTokenSource</code> の状態変更よりも、この <code>Task.Delay</code> のキャンセル処理が実行される。</li> <li>キャンセルが行われたとき、.NETの非同期処理の実装として、そこまでの <code>await</code> 式の後続の処理が、継続(continuation)として、可能な限り効率的に実行される。具体的には、同じスレッドで連続して継続が実行される。</li> <li>Event Hubリスナーは関数呼び出しを <code>await</code> し、その後でチェックポイント判定処理を行っている。</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="コールバックで継続が実行され競合状態が発生"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240214/20240214095205.png" width="1151" height="868" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コールバックで継続が実行されたために競合状態でチェックポイント判定処理が実行された</figcaption></figure></p> <p>つまり、グレースフルシャットダウンが行われたとき、<code>LinkedCancellationTokenSource</code> がキャンセル状態になる前に、チェックポイント判定処理が行われているということです。</p> <p>実際のアプリケーション実行において、グレースフルシャットダウンの発生時に、非同期での通信のキャンセル対応やタイムアウト処理のために <code>CancellationToken</code> のコールバックが使用されている可能性は高いと考えられます。そのため、この継続が同期的に実行されるという事象によりチェックポイント処理判定が不正になる可能性は高いと言えそうです。</p> <h2 id="仲間を募集中です">仲間を募集中です!</h2> <p>このような込み入った、根の深い現象に向き合うとき、チームで取り組めるのは素晴らしいことです。この現象も、チーム内の複数のメンバーが議論し、調査、検証、アプリケーション側の修正、GitHubへの報告などを分担して実施しました。</p> <p>前回の繰り返しになりますが、Azure Functionsや.NETのランタイムにダイブして議論できる環境に興味のある方、そこまで行かなくても歯ごたえのあるバックエンドシステムを構築、運用してみたい方、引き続きお待ちしております!</p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> yfujiwara-sansan Azure Functionsでの大量データ処理とグレースフルシャットダウン(前編) hatenablog://entry/6801883189076214701 2024-03-04T11:00:00+09:00 2024-03-15T19:09:01+09:00 技術本部Sansan Engieering Unit Data Hubグループの藤原です。普段はプロダクトのアーキテクチャを改善したり、技術的な課題を解決したり、たまにOSSを書いたりコントリビュートしたりしています。 今年はSansan Data Hubの日々の開発や運用で突き当たっている課題をベースに、現在取り組んでいることや、これから取り組みたいことについて紹介していきたいと思います。今回は、Azure Functionsでの大量データ処理をするとき、グレースフルシャットダウン関連で遭遇した問題について、Azure Functionsの内部構造に触れつつ紹介します。一言でいうと、Even… <p>技術本部Sansan Engieering Unit Data Hubグループの藤原です。普段はプロダクトのアーキテクチャを改善したり、技術的な課題を解決したり、たまにOSSを書いたりコントリビュートしたりしています。</p> <p>今年はSansan Data Hubの日々の開発や運用で突き当たっている課題をベースに、現在取り組んでいることや、これから取り組みたいことについて紹介していきたいと思います。今回は、Azure Functionsでの大量データ処理をするとき、グレースフルシャットダウン関連で遭遇した問題について、Azure Functionsの内部構造に触れつつ紹介します。一言でいうと、Event Hubトリガーを使っている場合、SDKのバージョンによってはメッセージが処理されないことがあります(後編で説明しますが、<code>Microsoft.Azure.WebJobs.Extensions.EventHubs</code> の v6.1.0 以上を使用する必要があります)。</p> <p><strong>目次</strong></p> <ul class="table-of-contents"> <li><a href="#Azure-Functionsとその大まかな構造について">Azure Functionsとその大まかな構造について</a><ul> <li><a href="#Azure-Functionsの内部構造">Azure Functionsの内部構造</a></li> <li><a href="#ランタイムトリガーリスナー">ランタイム、トリガー、リスナー</a></li> </ul> </li> <li><a href="#Azure-Functionで大量のメッセージを処理するときの選択肢とEventHubについて">Azure Functionで大量のメッセージを処理するときの選択肢とEventHubについて</a><ul> <li><a href="#非同期メッセージングアーキテクチャ">非同期メッセージングアーキテクチャ</a></li> <li><a href="#Azure-Event-Hubs">Azure Event Hubs</a></li> </ul> </li> <li><a href="#グレースフルシャットダウンについて">グレースフルシャットダウンについて</a><ul> <li><a href="#グレースフルシャットダウンとは">グレースフルシャットダウンとは</a></li> <li><a href="#Azure-Functionsにおけるグレースフルシャットダウン">Azure Functionsにおけるグレースフルシャットダウン</a></li> </ul> </li> <li><a href="#EventHubTriggerとリトライ">EventHubTriggerとリトライ</a><ul> <li><a href="#Azure-Functionsにおけるリトライ">Azure Functionsにおけるリトライ</a></li> <li><a href="#Azure-FunctionsのリトライループとEvent-Hubリスナーのチェックポイント処理">Azure FunctionsのリトライループとEvent Hubリスナーのチェックポイント処理</a></li> </ul> </li> <li><a href="#グレースフルシャットダウン発生時のチェックポイント処理のバグ">グレースフルシャットダウン発生時のチェックポイント処理のバグ</a></li> <li><a href="#仲間を募集中です">仲間を募集中です!</a></li> </ul> <h2 id="Azure-Functionsとその大まかな構造について">Azure Functionsとその大まかな構造について</h2> <p>まず、前提として、Azure Functionsとは何かと、その大まかな内部構造について簡単に触れておきます。 Azure FunctionsはいわゆるFaaS(Function as a Service)と呼ばれるサービスです。AWS LambdaやGoogle Cloud Functionsと同様に、何かしらの起動条件(トリガー)に応じて、処理を実行します。その名の通り、それらの処理は「関数(Function)」と呼べる程度の短い処理にすることが多いですが、基本的にはどんな処理でも書けます。その気になればWebサーバーも記述できます。</p> <h3 id="Azure-Functionsの内部構造">Azure Functionsの内部構造</h3> <p>さて、その構造ですが、構造を理解するには少し歴史に触れた方がわかりやすいので、歴史を交えて説明します。 Azure Functionsが出た当時、Azureには既にAzure App Serviceというマネージドのアプリケーション実行環境がありました。さらに、Azure Functionsよりも機能が制限されていますが、Azure Web JobsというApp Serviceでジョブ(Webアプリケーションでない処理)を実行するための機能もありました。そして、Azure FunctionsはApp Service上で、Azure Web Jobsの拡張のような形で実装されました。 現在、Azure Functionsは以下のような構造になっています。</p> <ul> <li><a href="https://github.com/Azure/azure-functions-host/tree/47411dfdc56bedd7f7dd20aa487fe69dbc596325/src/WebJobs.Script.WebHost">Azure Functionsランタイムという ASP.NET Coreアプリケーション</a>が、通常のWeb/APサーバーではなくWeb Jobのサーバーとして動作する。</li> <li>アプリケーションは<a href="https://github.com/Azure/azure-webjobs-sdk/tree/1f5c638d08a15360e54fd620d34276b10f186da6/src">Web Jobs SDK</a>や<a href="https://github.com/Azure/azure-sdk-for-net/tree/86db5a7b325aefde3b401062c6f051e797fed85b/sdk/eventhub/Microsoft.Azure.WebJobs.Extensions.EventHubs">その拡張ライブラリとして存在するAzure FunctionsのSDK</a>を使用して実装され、Azure Functionsランタイム上で動作する。</li> </ul> <h3 id="ランタイムトリガーリスナー">ランタイム、トリガー、リスナー</h3> <p>私たちアプリケーション開発者がAzure Functionsの処理を実装するときには、Azure FunctionsのSDKを使用して処理を実装します。具体的には、処理の起動条件であるトリガーとして何を使うのか(キューにメッセージがあるとき、特定の時刻になったとき、等)を関数のパラメーターおよびそのカスタム属性として宣言します。なお、Azure FunctionsランタイムはWeb Jobs SDKのコントラクトのみを参照し、その実装はアプリケーションがDI(Dependency Injection)で設定するようになっており、Azure Functionsの拡張性を担保しています。このあたりの関係性を図示すると以下のようになります。</p> <p><figure class="figure-image figure-image-fotolife" title="Azure Functionsの構造"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240119/20240119095229.png" width="1200" height="606" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Azure Functionsの構造</figcaption></figure></p> <p>Azure Functionsランタイムは、アプリケーション側で宣言したトリガーに応じたリスナーを作成します。リスナーは、起動条件を満たしているかを監視して、条件を満たしていればアプリケーションの関数を呼び出す役割を持ちます。関数に特定の型のパラメーターを宣言しておくと、リスナーが関数を呼び出すときに、リスナー自身やAzure Functionsランタイムによってパラメーターが提供されます。たとえば、キューのメッセージ、Azure Functionsランタイム経由でログを出力するためのロガー、この後説明するグレースフルシャットダウン通知を受け取るための <code>CancellationToken</code> などです。図にすると以下のような流れです。</p> <p><figure class="figure-image figure-image-fotolife" title="Azure Functionsのトリガー実行の流れ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240119/20240119095318.png" width="1200" height="497" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Azure Functionsのトリガー実行の流れ</figcaption></figure></p> <h2 id="Azure-Functionで大量のメッセージを処理するときの選択肢とEventHubについて">Azure Functionで大量のメッセージを処理するときの選択肢とEventHubについて</h2> <h3 id="非同期メッセージングアーキテクチャ">非同期メッセージングアーキテクチャ</h3> <p>Data Hubでは、ドメインごとに分割した複数の処理をAzure Functionsで実装し、それらキューで接続してメッセージを渡すことで処理を実行する<strong>非同期メッセージング</strong>のアーキテクチャを採用しています。これにより、きめ細かなアップデートやスケーリング、万が一の障害時の部分的な再実行によるリカバリを実行しています(流行りのマイクロサービスアーキテクチャ、に見えるかもしれませんが、キューベースの非同期処理による耐障害性やスケーラビリティの確保はWindowsやUnix以前から採用されている伝統的なアーキテクチャだったりします)。</p> <p>非同期メッセージングを行うためのキューにはさまざまな選択肢がありますが、Data Hubでは主にフルマネージドのメッセージングサービスであるAzure Service Busを使用しています。組み込みのリトライ機能やPub Subのサポートがあることや、Azure PortalをはじめとしたツールセットやManaged Identityサポートなど、Azure組み込みであるメリットも多いことが理由です。また、Azure FunctionsでもService Bus Triggerをはじめとしたサポートがあります。</p> <h3 id="Azure-Event-Hubs">Azure Event Hubs</h3> <p>しかし、Azure Service Busは基本的にメッセージを1件ずつ処理するためのサービスです。そのため、大量(たとえば月間100億)のメッセージを処理する場合、メッセージ単位での処理を行うことによるオーバーヘッドが避けられません。このような大量メッセージを一気にさばくことに特化したメッセージ処理サービスとして、AzureにはAzure Event Hubs(以下Event Hubs)というサービスがあります。実際に、Azure内でのログの転送や、IoTでのテレメトリデータの処理などではEvent Hubsが使われています。Event Hubsは、以下に示すアーキテクチャにすることで、大量のデータを高速に処理できるようにしています(詳細は<a href="https://learn.microsoft.com/ja-jp/azure/event-hubs/event-hubs-scalability">公式ドキュメント</a>を参照)。</p> <ul> <li>入出力をパーティション化する。これにより、内部処理の並列性を高めています。</li> <li>メッセージ(正確にはイベントと言いますが、ここではメッセージで統一します)の処理状態をサーバーではなくクライアント側に持たせ、サーバー側は単なる一連のイベントストリームとして管理しています。これによって、状態管理をクライアント側にオフロードできるので、複数のサブスクライバーがいる場合のスケーラビリティが高まります。</li> </ul> <p>このように、Event Hubs自身は個々のメッセージを管理しないので、Event Hubsから(バルクで)取得したメッセージをどこまで処理したのかを記憶し、次の取得時にその位置を指定するのはアプリケーション側の役割になります。このどこまで処理したのか(どこから取得するのか)を表す位置情報を「チェックポイント」といいます。チェックポイントの管理はアプリケーション側の責任なので、チェックポイントの永続化もアプリケーションが行わなければなりません。とはいえ、チェックポイントを使用した取得やチェックポイントの永続化は<a href="https://www.nuget.org/packages/Azure.Messaging.EventHubs.Processor">Event HubsのSDK</a>として実装が提供されています。そのため、実際のところ、実装の負荷はそれほど高くありません(動作を理解していないと障害時の対応が難しくなるため、理解しておく必要はあります)。また、Azure Functionsの<a href="https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-event-hubs-trigger">Event Hubsトリガー</a>にはチェックポイント処理を行う機構も用意されており、Azure FunctionsでEvent Hubsを使う場合に明示的にチェックポイント処理を実装する必要はありません。</p> <h2 id="グレースフルシャットダウンについて">グレースフルシャットダウンについて</h2> <p>今回話題となるグレースフルシャットダウンについても触れておきます。</p> <h3 id="グレースフルシャットダウンとは">グレースフルシャットダウンとは</h3> <p>Azure Functionsの実行基盤となっているApp ServiceはマネージドPaaSであるため、VM(利用者からは一部のデバッグ用の機能を除き隠蔽されています)のメンテナンスはAzure運営側によって実施されます。そのため、Windows Updateやランタイムのアップデートなどの理由により、プロセスがシャットダウンされることがあります。また、専用のApp Service Planで動かしている場合やFlexible Planと言った従量課金制の場合でも、負荷が減ったときにApp Service Planをスケールインすると、減らされるApp Service Planインスタンス上のプロセスはシャットダウンされます。アプリケーションコードの更新でデプロイを行う、環境変数(アプリ設定)を変更するなどの行為によっても、プロセスが再起動するため、シャットダウンが発生します。</p> <p>このような場合に、プロセスが終了されようとしていることをアプリケーションから検知できると、適切なクリーンアップ処理を実行できて便利ですよね……というより、ないと安定した運用が非常に困難になります。グレースフルシャットダウンとは、このような場合にアプリケーション側でシャットダウンされようとしていることを検知し、クリーンアップを行うことや、そのための仕組みのことです。</p> <h3 id="Azure-Functionsにおけるグレースフルシャットダウン">Azure Functionsにおけるグレースフルシャットダウン</h3> <p>Azure Functionsランタイム(正確にはそれが利用している ASP.NET Core)では、プロセスの終了通知(Windows版のApp ServiceであればIISからの<a href="https://github.com/dotnet/aspnetcore/blob/4b2255e982a94539df04197983bb41e18a30adf7/src/Servers/IIS/AspNetCoreModuleV2/AspNetCore/dllmain.cpp#L131">ワーカープロセスシャットダウン通知や構成変更通知</a>)をフックし、関数のパラメーターとしてアプリケーション側が受け取り可能な <code>CancellationToken</code> の状態をキャンセル済にすることでグレースフルシャットダウンを実現可能にしています。</p> <p><code>CancellationToken</code> の状態が「キャンセル済」になった場合、アプリケーションや依存先のクラスライブラリのコードでは、ループ処理を完了(<code>CancellationToken.IsCancellationRequested</code> をループの終了条件に組み込んでおく)したり、<code>TaskCancelledException</code> をスローしたりして処理を中断することで、(通常 <code>finally</code> 句に指定してある)クリーンアップコードが実行されるようにします。アプリケーションが例外時のクリーンアップと処理の冪等性を正しく実装しているならば、プロセスが再び起動して処理が再実行されることで、問題なく処理が再開されるはずです。</p> <p>先ほど説明したように、Azure Functionsでは関数のパラメーターとして <code>CancellationToken</code> を受け取れます。この <code>CancellationToken</code> こそが、今説明したグレースフルシャットダウンを実現するためのものです。つまり、関数で <code>CancellationToken</code> を受け取るようにしておけば、それを使用してグレースフルシャットダウンを実装できるということです。</p> <p><figure class="figure-image figure-image-fotolife" title="Azure Functionsでのグレースフルシャットダウンの流れ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240119/20240119095343.png" width="1200" height="795" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Azure Functionsでのグレースフルシャットダウンの流れ</figcaption></figure></p> <h2 id="EventHubTriggerとリトライ">EventHubTriggerとリトライ</h2> <p>さて、システムの可用性は構成要素の数が多くなるほど落ちていきます。これはクラウドであろうがオンプレミスであろうが変わらず、確率的に故障します。さらに、前述のようにAzure App Serviceではプラットフォーム側のメンテナンスやデプロイなどによりプロセスがシャットダウンされることもあります。このような一時的な障害がすべてシステムの故障につながっていては可用性を確保できません。そのため、システム、特に非同期メッセージングアーキテクチャを始めとした分散システムでは、処理の冪等性の確保と、リトライの実装が必要になります。そのため、Azure Functionsにもリトライの仕組みが実装されています。</p> <h3 id="Azure-Functionsにおけるリトライ">Azure Functionsにおけるリトライ</h3> <p>この後の話題にも関わるので、Azure Functionsのリトライについては少し深く触れておきます(Azure Functionsランタイムv4の2024/1初頭の実装を前提とします)。</p> <p>先ほど述べたAzure Functionsのリスナーは、基本的に各Azureサービス向けのSDKを活用し、Azure Functionsのリスナーというコントラクトに合わせて調整するような形で実装されています。Event Hubs向けのリスナー(<a href="https://github.com/Azure/azure-functions-eventhubs-extension/blob/e1757a0f9295c3bebe6ab5df6d97812bd4b61044/src/Microsoft.Azure.WebJobs.Extensions.EventHubs/Listeners/EventHubListener.cs"><code>EventHubListener</code></a>)はEvent HubsのSDKを利用しています。</p> <p>Event HubsのSDKは、Event Hubsとの通信のリトライ機能を実装しています。ところが、受信した後、アプリケーションの実装がリトライを行うかどうかは関与しません。Event Hubs SDKはEvent Hubsとの通信や、先ほど述べたチェックポイント処理を行うためのSDKであり、アプリケーションフレームワークではないためです。</p> <p>とはいえ、アプリケーションの実装としては、出力先のデータベースが一時的に故障している場合など、リトライを行うことが多くあります。このようなケースをフォローするために、Azure Functionsランタイムには組み込みのリトライ機能があります。Azure Functionsランタイムは、呼び出し先の関数のメタデータを(皆さんの予想通りリフレクションを使用して)調べ、リトライ属性(たとえば <code>[ExponentialBackoffRetry]</code> カスタム属性)が付与されている場合にリトライを行います。具体的には、<a href="https://github.com/Azure/azure-webjobs-sdk/blob/ca14c114a61bd3693f629c89063655fc6ee2c522/src/Microsoft.Azure.WebJobs.Host/Executors/FunctionExecutorExtensions.cs#L20"><code>FunctionExecutorExtensions.TryExecute()</code> メソッド</a> において、リトライ属性の内容に応じたリトライを行います。ここでは、関数が例外をスローしたならば、次のリトライまでの間隔として指定された時間で待機した後に処理をやり直すようなリトライループが実装されています。</p> <h3 id="Azure-FunctionsのリトライループとEvent-Hubリスナーのチェックポイント処理">Azure FunctionsのリトライループとEvent Hubリスナーのチェックポイント処理</h3> <p>Azure Functionsのリトライループが終了する条件は2つあります。まず、当然ながら処理が正常終了した場合です。処理が正常終了したならば、Event Hubsリスナーはチェックポイント処理を行い、次のメッセージバッチを処理します。そして、グレースフルシャットダウンが行われている場合です。この場合、リトライループを終了し、Azure Functionsランタイム自身やSDKのクリーンアップを行わなければなりません。</p> <p>グレースフルシャットダウンが行われているケースでは、チェックポイント処理を行うべきではありません。グレースフルシャットダウンが発生すると、アプリケーションは処理の途中でキャンセルされます。ACIDトランザクションを実行していたならばロールバックされるでしょう。そのため、チェックポイント処理を行ってしまうと、アプリケーションが実際には処理を実行していないのにメッセージが処理済みとされてしまうと、システム全体としてはメッセージがなくなったかのような結果になってしまいます。</p> <p>そして、チェックポイント処理を行うかどうかの判定はEvent Hubリスナーの役割です。Event Hubリスナーは、Azure Functionsランタイムによる関数実行の結果に応じて、チェックポイントを更新します。具体的には、関数実行が完了した後、自身が渡した <code>CancellationToken</code> の状態を調べ、キャンセル状態でないときにのみチェックポイント処理を行います。</p> <p>まとめると、Event Hubsトリガーを使う場合、関数にリトライ属性を付けておくことで、関数の実行が失敗した時にAzure Functionsが関数実行をリトライするようにできます。ただし、グレースフルシャットダウンによる失敗のときにはリトライせずに終了します。</p> <h2 id="グレースフルシャットダウン発生時のチェックポイント処理のバグ">グレースフルシャットダウン発生時のチェックポイント処理のバグ</h2> <p>実はこのリトライとチェックポイント処理の組み合わせについて、以前、具体的には2023年の夏ごろまではバグがありました。それは、Event Hubsリスナーの実装が、グレースフルシャットダウンによるリトライループ中断の可能性を考慮していなかったというものです(この<a href="https://github.com/Azure/azure-sdk-for-net/pull/36432">PR</a>で修正されています)。つまり、Event Hubsリスナーは、「呼び出し先の <code>FunctionExecutorExtensions.TryExecute</code> はリトライが終わってからアプリケーションが処理を正常終了されたときのみ制御を返すはず」という前提を置き、制御が返ってきたらチェックポイント処理を実行していました。先ほど述べた「自身が渡した <code>CancellationToken</code> の状態を調べ」ることをしていなかったのです。しかし、実際のところ、前述のようにリトライ処理はグレースフルシャットダウンが発生した際には中断されます。そのため、グレースフルシャットダウンのためにアプリケーションによるメッセージの処理が中断されていた場合、その未処理のメッセージに対するチェックポイント処理が走る可能性があったのです。</p> <p><figure class="figure-image figure-image-fotolife" title="Event Hubsトリガーと問題"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yfujiwara-sansan/20240119/20240119095401.png" width="1200" height="683" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Event Hubsトリガーと問題</figcaption></figure></p> <p>アプリケーション実行がキャンセルされたにもかかわらずチェックポイントが進むと、キャンセルされたときに処理されていたメッセージが実際には処理されずスキップされた状態になります。アプリケーションから見ると、Event Hubsからメッセージを受信できなかった、いわゆるメッセージロスト状態になります。</p> <p>Data Hubは頻繁にスケーリングやデプロイを行うことから、この問題に遭遇していました。幸いにも、前述のようにリトライ可能な構造にしていたため大きな問題とはなりませんでしたが、リカバリによりデータの反映が遅延していました。そのため、グレースフルシャットダウンを考慮したEvent Hubリスナーのパッチを適用することにしました。</p> <p>さて、ライブラリやSDKは可能であれば最新版を使用するのが定石です。まだ遭遇していない未知の不具合が解消されているかもしれませんし、セキュリティ上の脆弱性の修正は最新版にしか提供されない可能性があるためです。最新版を適用し、リグレッションテストを行い、問題がないことを確認しました。さらに、当該SDKのパッチをアーキテクトがレビューし、修正前にはグレースフルシャットダウン時のメッセージロストの可能性があること、パッチによって解決することを確認しました。これで、処理が安定して実行できるようになった……はずでした(<a href="https://buildersbox.corp-sansan.com/entry/2024/03/05/110000">後編に続く</a>)。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2024%2F03%2F05%2F110000" title="Azure Functionsでの大量データ処理とグレースフルシャットダウン(後編) - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2024/03/05/110000">buildersbox.corp-sansan.com</a></cite></p> <h2 id="仲間を募集中です">仲間を募集中です!</h2> <p>このBlogを書いていたら、Copilotが有名なAzure Function大好きな方の名前をサジェストしてきました。</p> <p>同じようにAzure Functionsをどっぷりやりたい方、LambdaやCloud Functionsを使っているけどAzure Functionsもやってみたい方、Azure FunctionsランタイムやSDKや.NETランタイムを深く理解し何ならプルリクエストを送りたい/送ったことのある方、ぜひご連絡ください。一緒に大量データ処理システムを支えていきましょう!</p> <p><br><br> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> yfujiwara-sansan 生産性指標をFour Keysから変更した話 hatenablog://entry/6801883189082550038 2024-03-01T11:00:00+09:00 2024-03-15T19:07:18+09:00 技術本部 Mobile Applicationグループの山本です。名刺アプリEightの開発を行っています。今回はMobile ApplicationグループのEight開発チームの生産性指標をFour Keysからベロシティを含む別の値に変更した話をします。一般的にはベロシティは生産性指標にすべきではない、Four Keysは生産性指標として適切であるという評価だと思います。もちろんそれは理解した上でこの選択をしています。その理由について説明します。なお組織全体がこのように考えているわけではないということに御注意ください。例えば同じMobile ApplicationグループでもSansan… <p>技術本部 Mobile Applicationグループの山本です。名刺アプリEightの開発を行っています。</p><p>今回はMobile ApplicationグループのEight開発チームの生産性指標をFour Keysからベロシティを含む別の値に変更した話をします。</p><p>一般的にはベロシティは生産性指標にすべきではない、Four Keysは生産性指標として適切であるという評価だと思います。もちろんそれは理解した上でこの選択をしています。その理由について説明します。</p><p>なお組織全体がこのように考えているわけではないということに御注意ください。例えば同じMobile ApplicationグループでもSansan開発チームはFour Keysを生産性指標にしています。</p> <div class="section"> <h3 id="生産量2倍計画">生産量2倍計画</h3> <p>現在技術本部では中期的な課題として1年で単月の生産量を2倍にするという目標を掲げています。</p><p>ポイントとして、技術本部のレベルでは生産量を2倍にするという目標だけがあり、何を生産量の具体的な指標にするかという点は、各部署で決めることとなっています。そのためまずは、各部署において生産量の指標を何にするか考えるところからスタートします。</p><p>技術本部全体としては、プロダクト開発だけでなく、研究開発や、開発管理、データ化と多岐にわたる部署が存在するため、特定の生産性指標が適切、と一概に言うのは難しいです。そのためこのような判断になっています。</p><p>なお今後の文章では生産量指標ではなく、生産性指標という呼び方に統一します。生産性というのは単位あたりの生産量のことですが、今回の文章では単位量が時間、計測対象はEightアプリ開発チームで確定しています。そのためチームの時間あたりの生産量、つまりチームの生産性を計測していることになるからです。</p> </div> <div class="section"> <h3 id="Mobile-Applicationグループの生産性指標">Mobile Applicationグループの生産性指標</h3> <p>Mobile Applicationグループでは当初Sansanの開発、Eightの開発共通でFour Keysを生産性指標にしました。Four Keysは他社でも多く採用されていることから一定の合理性があり、一度これで進めてみて問題があれば見直すという判断になりました。Four Keysの説明については多く資料があるので割愛します。</p><p>当時EightはV10リリースの開発で非常に忙しく、Four Keysの計測基盤を作る余裕がありませんでした。そのためまずはSansan側で計測基盤の作成を先行して行い、運用しました。しかし、その中でモバイルアプリ特有の問題があることがわかりました。</p> </div> <div class="section"> <h3 id="モバイルアプリとリリース頻度">モバイルアプリとリリース頻度</h3> <p>Four Keysはリリース頻度が強い影響を持つ指標になっています。デプロイ頻度はもちろん、リードタイムも結局はリリース頻度を増やさないと短くなりません。変更障害率とサービス復元時間も、基本的にはリリース頻度を増やして、1リリースあたりの変更量を減らすことにより、該当値を減らすということを目的とした値です。</p><p>一方でモバイルアプリでは、Webと比べて過度にリリース頻度を増やすとユーザ体験を損ねる場合があります。もちろん毎日アップデート申請すること自体は可能です。しかしユーザから見るとアップデートの通知もダウンロードも毎日発生するアプリになってしまいます。</p><p>Eightでは緊急のパッチリリースを除けば、概ね2週間から4週間に1回程度のリリースとなっています。他社を見てもモバイルアプリにおいては、リリースが多いプロダクトでも1週間に1回程度のリリース頻度のところが多いように見えます。仮に毎週リリースしても1カ月のリリース回数が4回程度になります。</p><p>この場合、外れ値の影響が大きくなりすぎてしまいます。1回のリリースで大きな障害があると、他のリリースが全て問題なくても、サービス復元時間を減らすのが難しくなります。結果として、たまたまその期間に大きな障害があったかで値が大きく変動します。</p><p>またEightにおいては、V10のような大型リリースの場合にそれ以外の開発を全て止めて集中するので、リリース自体が3ヶ月などの長期にわたり止まる場合があります。この期間も開発は行っているので実際の生産量は0ではありません。しかしリリースがなければ生産性0という判断になります。実際の生産量ではなく、施策に生産性指標が強い影響を受けてしまいます。</p> </div> <div class="section"> <h3 id="生産性指標を考える上で考慮すべき観点">生産性指標を考える上で考慮すべき観点</h3> <p>ここで一旦Four Keysから離れて、別の生産性指標について再度考えてみます。完璧に正しい指標というのは存在しませんが、考慮すべき観点というのはあるように思います。</p> <div class="section"> <h4 id="計測しやすく単位時間内の量がそれなりにある">計測しやすく、単位時間内の量がそれなりにある</h4> <p>計測できないものは指標になりません。またリリース頻度の話でも出ましたが、単位期間内にそれなりの量がないと、外れ値の影響を受けやすくなります。</p> </div> <div class="section"> <h4 id="アウトプットに主体的に影響を与えることが可能である">アウトプットに主体的に影響を与えることが可能である</h4> <p>これは例えばMobile Applicationグループの生産性指標としてEight事業の売り上げを利用することが適切かという問題です。</p><p>開発組織であっても最終的な目的は事業成長です。しかし品質の高い製品を適切な開発期間でリリースできたとしても、その製品が売り上げにつながるかというのは別です。品質だけではなく、製品の機能やマーケティングや営業の影響も大きいです。そうなった場合に、開発組織の生産性指標として売り上げは適切なのかという問題が出てきます。</p><p>事業のKPIであっても問題は同じです。これは開発組織の役割にも依存します。施策の企画と決定も開発組織の役割であるならKPIが適切だと思いますし、施策が別組織であるなら適切ではないと言えます。</p> </div> <div class="section"> <h4 id="意図的な操作が行いにくい">意図的な操作が行いにくい</h4> <p>実際のところどのような値であっても意図的な操作は可能です。しかし行いにくい値というのは存在します。</p><p>例えば人が決める見積もりの値に比べて、コード行数はレビューが適切であるという前提であれば操作しにくいです。一方で、見積もりの値が他部署から検証されるので意図的な操作がしにくい、コード行数は開発に閉じているので操作しやすいという考えもできます。</p> </div> <div class="section"> <h4 id="多面的である">多面的である</h4> <p>生産性というのは絶対に正しいものが存在しない以上、複数の値から多面的に捉えることが必要です。その際にお互いが強い相関をもたないようにしないと、多面的である意味がありません。</p><p>Four Keysは以下の3つの観点で生産性を測る方法と言えます。これは参考にできそうです。</p> <ol> <li>スループット</li> <li>リードタイム</li> <li>品質</li> </ol> </div> </div> <div class="section"> <h3 id="新しい生産性指標">新しい生産性指標</h3> <p>検討の結果、Eight開発チームでは新しい生産性指標として以下を採用しました。いずれもリリース頻度には依存しない値になっています。</p><p>またスループットとリードタイムは、プロダクトバックログアイテムを元にした値とプルリクを元にした値を両方取って複数の観点から見ることで、異常値や改ざんについてある程度クロスチェックできるようにしました。</p><p>スループット</p> <ul> <li>プロダクトバックログアイテムの完了数</li> <li>完了したプロダクトバックログアイテムのポイント数合計</li> <li>プルリクのマージ数</li> </ul><p>リードタイム</p> <ul> <li>プロダクトバックログアイテム着手から完了までにかかったスプリント数の平均値</li> <li>プルリクのマージ時間の平均値</li> </ul><p>品質</p> <ul> <li>QAの不具合数</li> <li>hotfixリリースの数</li> </ul> </div> <div class="section"> <h3 id="完了したプロダクトバックログアイテムのポイント数合計を採用した理由">&quot;完了したプロダクトバックログアイテムのポイント数合計&quot;を採用した理由</h3> <p>上記指標の中で"完了したプロダクトバックログアイテムのポイント数合計"については議論がありそうなので、採用した理由を詳しく説明します。</p><p>完了したプロダクトバックログアイテムのポイント数というのは実質的にはスクラムのベロシティです。一般的にはベロシティを生産性の指標にすべきではないとされています。</p><p>ポイントは相対見積もりなので、全く同じ案件であってもチームが異なれば異なる値になります。だからペロシティを異なるチーム間の生産性の比較に使うことはできません。</p><p>一方でスクラムの書籍の多くでは、スクラムを導入するメリットとして、長期的に同じチームのベロシティが向上することが挙げられています。これは実質的には生産性が上がるということです。</p><p>例えばエッセンシャルスクラムでは7章でこの話題に触れられています。ここにおける書き方が微妙なのですが、確かに成長を計測はできるが、それを目標にした途端に作為的に増やそうという意思が発生するということです。</p><p>これは理解できる一方で、先に述べた通りどの値であっても意図的な操作はある程度行えます。</p><p>KPIのようなユーザによって決まる値は意図的な操作は難しいですが、先に述べた"アウトプットに主体的に影響を与えることが可能である"という点で難しいです。Eightの開発チームは施策に対する意見を言うことはもちろんできます。しかしプロダクトマネジャーとは別組織なので、施策を主体的に変更できるわけではありません。</p><p>ポイント数についてもいろいろ問題があることは理解しています。しかし測定したいのは同じチームの成長につながっているかどうかであること、見積もりの基準を明確化することで、意図的な水増しをある程度防げることから、今回はポイント数が適切と判断しました。</p> </div> <div class="section"> <h3 id="おわりに">おわりに</h3> <p>生産性指標というのは難しいです。自身の組織の状態と計測する目的によって適切な指標は異なります。したがって自分たちで考えて、適切な指標を探すことが重要ではないかと考えます。</p><p>共にSansan / Eightのモバイルアプリ開発していく仲間を募集中です!<br /> 選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです!<br /> <iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76400" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Android%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76400">open.talentio.com</a></cite></p><p><br><br><br /> <a href="https://forms.gle/gbLPPzecKpyb5yR78"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240312/20240312182329.jpg" alt="20240312182329"></a><br /> <br><a href="https://sansan-engineering.notion.site/54d0dfa84be24f7a89c9e67ae989ebcf?v=131b1b12ca584be6aea19c5e26a96b5f"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansantech/20240315/20240315190344.jpg" alt="20240315190344"></a></p> </div> k_yamamoto_office Active Support Instrumentation について hatenablog://entry/6801883189082409881 2024-02-27T11:00:00+09:00 2024-02-27T11:00:01+09:00 技術本部 Sansan Engineering Unit Nayose グループでエンジニアをしている冨田です。業務では、Ruby on Rails(以降 Rails)を使って名寄せサービスを開発しています。 今回は、Rails などの Ruby コード内のイベント計測に使われる、Active Support Instrumentation について解説します。本 API を利用することで、アプリケーション内で発生するさまざまなイベントを計測し、パフォーマンス改善やデバッグなどの調査に役立てられます。直近 Nayose グループでは、問題調査のために、特定テーブルへの SQL とその呼び出し元… <p>技術本部 Sansan Engineering Unit Nayose グループでエンジニアをしている冨田です。業務では、Ruby on Rails(以降 Rails)を使って名寄せサービスを開発しています。</p> <p>今回は、Rails などの Ruby コード内のイベント計測に使われる、<a href="https://railsguides.jp/v7.0/active_support_instrumentation.html">Active Support Instrumentation</a> について解説します。本 API を利用することで、アプリケーション内で発生するさまざまなイベントを計測し、パフォーマンス改善やデバッグなどの調査に役立てられます。直近 Nayose グループでは、問題調査のために、特定テーブルへの SQL とその呼び出し元をロギングする用途で使いました。</p> <p>次のバージョンを対象としています。</p> <ul> <li>Rails 7.0.4 <ul> <li>Active Record 7.0.4</li> <li>Active Support 7.0.4</li> </ul> </li> <li>Ruby 3.1.4</li> </ul> <p>以下が本記事の目次になります。</p> <ul class="table-of-contents"> <li><a href="#Active-Support-Instrumentation-について">Active Support Instrumentation について</a></li> <li><a href="#Rails-内部のイベントを購読する">Rails 内部のイベントを購読する</a></li> <li><a href="#独自イベントを計測購読する">独自イベントを計測・購読する</a></li> <li><a href="#落穂拾い">落穂拾い</a><ul> <li><a href="#正規表現によるイベント名の設定">正規表現によるイベント名の設定</a></li> <li><a href="#一時的な購読">一時的な購読</a></li> <li><a href="#エラー時の挙動">エラー時の挙動</a></li> </ul> </li> <li><a href="#Active-Support-Instrumentation-の仕組み">Active Support Instrumentation の仕組み</a><ul> <li><a href="#イベント購読">イベント購読</a></li> <li><a href="#イベント計測">イベント計測</a></li> <li><a href="#補足">補足</a></li> </ul> </li> <li><a href="#Active-Support-Subscriber-について">Active Support Subscriber について</a></li> <li><a href="#Rails-内部でのイベント計測購読について">Rails 内部でのイベント計測・購読について</a><ul> <li><a href="#イベント購読-1">イベント購読</a></li> <li><a href="#イベント計測-1">イベント計測</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> </ul> <h1 id="Active-Support-Instrumentation-について">Active Support Instrumentation について</h1> <p>最初にどのような機能かを簡単に説明します。</p> <p>次の要素から構成されます。</p> <ul> <li>計測側:イベントを計測し、イベントが起きたことを購読側に伝えます</li> <li>購読側:計測側から通知を受け取り、計測情報を元に購読処理を行います</li> </ul> <p>大まかな流れとしては、次の図になります。</p> <p><figure class="figure-image figure-image-fotolife" title="イメージ図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tommy_0/20240219/20240219081706.png" width="1200" height="756" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>イメージ図</figcaption></figure></p> <p>実例を見た方がわかりやすいと思うので、さっそく試してみましょう。</p> <h1 id="Rails-内部のイベントを購読する">Rails 内部のイベントを購読する</h1> <p>Rails 内部ではさまざまなイベントが計測されているので、最初からそれらの購読が行えます。イベントの購読には、<a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Notifications.html#method-c-subscribe">ActiveSupport::Notifications.subscribe</a> を利用します。</p> <p>例えば、次のようにイベント購読することで、実行クエリに関する情報を確認できます。イベント購読は、イベント計測(今回の場合は Vehicle.last)される前に行っておきます。 具体的には、イベント名、イベントの開始/終了時刻、実行されたクエリを確認します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">sql.active_record</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |name, started, finished, unique_id, data| <span class="synComment"># 購読処理を書く</span> puts <span class="synSpecial">&quot;#{</span>name<span class="synSpecial">}</span><span class="synConstant"> Received! (started: </span><span class="synSpecial">#{</span>started<span class="synSpecial">}</span><span class="synConstant">, finished: </span><span class="synSpecial">#{</span>finished<span class="synSpecial">}</span><span class="synConstant">)</span><span class="synSpecial">&quot;</span> puts data[<span class="synConstant">:sql</span>] <span class="synStatement">end</span> <span class="synComment"># 検証用の Active Record モデル</span> <span class="synType">Vehicle</span>.last </pre> <pre class="code" data-lang="" data-unlink>=&gt; ...(他SQL実行に関する情報も出力されているが省略) sql.active_record Received! (started: 2024-02-03 22:46:07 -0800, finished: 2024-02-03 22:46:07 -0800) SELECT `vehicles`.* FROM `vehicles` ORDER BY `vehicles`.`id` DESC LIMIT 1 ...</pre> <p>subscribe メソッドの引数には、購読対象となるイベント名を設定します。<a href="https://railsguides.jp/v7.0/active_support_instrumentation.html#sql-active-record">Active Record</a> のイベントの 1 つである <code>sql.active_record</code> を設定します。また、ブロックには計測情報が渡されるため、これらを使って購読処理を行えます。</p> <ul> <li>イベント名</li> <li>イベントの開始時刻</li> <li>イベントの終了時刻</li> <li>イベントを発火させた計測側のユニークID</li> <li>イベントのペイロード(計測側で別途設定した値)</li> </ul> <p>引数を 1 個のみ受け取るブロックを渡した場合、上記引数の代わりとして <a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Notifications/Event.html">ActiveSupport::Notifications::Event</a> インスタンスが渡されるようになります。以降の説明ではこちらを利用します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">sql.active_record</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| puts <span class="synSpecial">&quot;#{</span>event.name<span class="synSpecial">}</span><span class="synConstant"> Received! (duration: </span><span class="synSpecial">#{</span>event.duration<span class="synSpecial">}</span><span class="synConstant"> msec)</span><span class="synSpecial">&quot;</span> puts event.payload[<span class="synConstant">:sql</span>] <span class="synStatement">end</span> <span class="synType">Vehicle</span>.last </pre> <pre class="code" data-lang="" data-unlink>=&gt; ...(他SQL実行に関する情報も出力されているが省略) sql.active_record Received! (duration: 2.114999771118164 msec) SELECT `vehicles`.* FROM `vehicles` ORDER BY `vehicles`.`id` DESC LIMIT 1 ...</pre> <h1 id="独自イベントを計測購読する">独自イベントを計測・購読する</h1> <p>アプリケーション内で独自のイベントを計測・購読できます。</p> <p>インベント計測は、<a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Notifications.html#method-c-instrument">ActiveSupport::Notifications.instrument</a> で行います。引数には、イベント名、購読側に連携したいハッシュ、計測対象のイベント処理を指定します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| puts <span class="synSpecial">&quot;</span><span class="synConstant">duration: </span><span class="synSpecial">#{</span>event.duration<span class="synSpecial">}</span><span class="synConstant"> msec, payload: </span><span class="synSpecial">#{</span>event.payload<span class="synSpecial">}&quot;</span> <span class="synStatement">end</span> <span class="synComment"># イベント名は &quot;my.custom.event&quot;、購読側に {this: :data} が連携される</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synConstant">:data</span> <span class="synStatement">do</span> <span class="synComment"># 計測対象のイベント処理を書く</span> sleep <span class="synConstant">3</span> <span class="synStatement">end</span> </pre> <pre class="code" data-lang="" data-unlink>=&gt; duration: 3001.2370014190674 msec, payload: {:this=&gt;:data}</pre> <p>計測情報が表示できていますね。</p> <p>では、もう少し具体的な利用例で考えてみましょう。</p> <p>例えば、商品を工場間で運ぶことを題材にしてみましょう。具体的には、商品ABを工場Aから工場Bに運び、逆に商品BAを工場Bから工場Aに運ぶことを考えます。商品を運ぶ際にいくつかルートがあり、どのルートになるかはそのタイミングでさまざまな要因から決まります。それぞれの商品を運ぶのに使ったルート、配送時間を確認したいとしましょう。</p> <p><figure class="figure-image figure-image-fotolife" title="イメージ図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tommy_0/20240226/20240226162013.png" width="1200" height="781" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>イメージ図</figcaption></figure></p> <p>そこで、ActiveSupport::Notifications を使ってみましょう(他にも手段はありますが、利用例の紹介としてご理解ください 🙏)。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">class</span> <span class="synType">FreightTransfer</span> <span class="synPreProc">class</span> &lt;&lt; <span class="synConstant">self</span> <span class="synPreProc">def</span> <span class="synIdentifier">transport</span>(<span class="synConstant">freight</span>:, <span class="synConstant">from</span>:, <span class="synConstant">to</span>:) deliver_route = <span class="synType">Random</span>.rand(<span class="synConstant">100</span>) <span class="synComment"># さまざまな要因によって決まることを仮定...</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">freight_transport.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">message</span>: <span class="synSpecial">&quot;</span><span class="synConstant">transported_from_</span><span class="synSpecial">#{</span>from<span class="synSpecial">}</span><span class="synConstant">_to_</span><span class="synSpecial">#{</span>to<span class="synSpecial">}&quot;</span>, <span class="synConstant">deliver_route</span>: deliver_route <span class="synStatement">do</span> sleep <span class="synConstant">5</span> <span class="synComment"># 配送処理を仮定...</span> <span class="synStatement">end</span> <span class="synPreProc">end</span> <span class="synPreProc">end</span> <span class="synPreProc">end</span> </pre> <pre class="code lang-ruby" data-lang="ruby" data-unlink>result = [] <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">freight_transport.event</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| result &lt;&lt; { <span class="synConstant">message</span>: event.payload[<span class="synConstant">:message</span>], <span class="synConstant">deliver_route</span>: event.payload[<span class="synConstant">:deliver_route</span>], <span class="synConstant">duration</span>: <span class="synSpecial">&quot;#{</span>event.duration<span class="synSpecial">}</span><span class="synConstant"> msec</span><span class="synSpecial">&quot;</span> } <span class="synStatement">end</span> <span class="synType">FreightTransfer</span>.transport(<span class="synConstant">freight</span>: <span class="synSpecial">'</span><span class="synConstant">AB</span><span class="synSpecial">'</span>, <span class="synConstant">from</span>: <span class="synSpecial">'</span><span class="synConstant">A</span><span class="synSpecial">'</span>, <span class="synConstant">to</span>: <span class="synSpecial">'</span><span class="synConstant">B</span><span class="synSpecial">'</span>) <span class="synType">FreightTransfer</span>.transport(<span class="synConstant">freight</span>: <span class="synSpecial">'</span><span class="synConstant">BA</span><span class="synSpecial">'</span>, <span class="synConstant">from</span>: <span class="synSpecial">'</span><span class="synConstant">B</span><span class="synSpecial">'</span>, <span class="synConstant">to</span>: <span class="synSpecial">'</span><span class="synConstant">A</span><span class="synSpecial">'</span>) puts result </pre> <pre class="code" data-lang="" data-unlink>=&gt; {:message=&gt;&#34;transported_from_A_to_B&#34;, :deliver_route=&gt;51, :duration=&gt;&#34;5001.178998947144 msec&#34;} {:message=&gt;&#34;transported_from_B_to_A&#34;, :deliver_route=&gt;36, :duration=&gt;&#34;5000.464000701904 msec&#34;}</pre> <p>簡単な例ですが、期待通りの情報が確認できました。</p> <h1 id="落穂拾い">落穂拾い</h1> <p>その他の便利な機能について、簡単に説明しておきます。</p> <h2 id="正規表現によるイベント名の設定">正規表現によるイベント名の設定</h2> <p>購読イベントの指定には正規表現が使えます。 その場合、正規表現にマッチするイベントが購読対象になります。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">/</span><span class="synConstant">custom</span><span class="synSpecial">/</span> <span class="synStatement">do</span> |event| puts event.payload <span class="synStatement">end</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom1.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synConstant">:data1</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom2.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synConstant">:data2</span> </pre> <pre class="code" data-lang="" data-unlink>=&gt; {:this=&gt;:data1} {:this=&gt;:data2}</pre> <h2 id="一時的な購読">一時的な購読</h2> <p><a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Notifications.html#method-c-subscribed">ActiveSupport::Notifications.subscribed</a> を利用することで、計測側からの通知をブロック実行中に限定できます。購読処理はコールバックで指定し、ブロック実行後コールバックは自動的に購読解除されます。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink>callback = <span class="synStatement">lambda</span> {|event| puts event.payload } <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribed(callback, <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>) <span class="synStatement">do</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synSpecial">'</span><span class="synConstant">executing block</span><span class="synSpecial">'</span> <span class="synStatement">end</span> <span class="synComment"># このイベント計測は通知されない</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synSpecial">'</span><span class="synConstant">not executing block</span><span class="synSpecial">'</span> </pre> <pre class="code" data-lang="" data-unlink>=&gt; {:this=&gt;&#34;executing block&#34;}</pre> <p>ただ、<a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Notifications.html#method-c-subscribed:~:text=WARNING%3A%20The%20instrumentation%20framework%20is%20designed%20for%20long%2Drunning%20subscribers%2C%20use%20this%20feature%20sparingly%20because%20it%20wipes%20some%20internal%20caches%20and%20that%20has%20a%20negative%20impact%20on%20performance.">ドキュメント</a>によると、パフォーマンスに悪影響があるかもしれないとのことなので、実際に使う際は要確認です。</p> <blockquote><p>WARNING: The instrumentation framework is designed for long-running subscribers, use this feature sparingly because it wipes some internal caches and that has a negative impact on performance.</p></blockquote> <h2 id="エラー時の挙動">エラー時の挙動</h2> <p>イベントの計測中にエラーが起きた時はどうなるのでしょうか。</p> <p>その場合は、計測側から通知される情報(payload)に、エラーに関する情報が追加されます。具体的には、<code>:exception キー</code> に「エラークラスとメッセージ」、<code>exception_object キー</code> に「エラーオブジェクト」が追加されます。</p> <p>これらの情報を使ってエラーハンドリングできますね。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| puts event.payload <span class="synStatement">end</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synConstant">:data</span> <span class="synStatement">do</span> <span class="synStatement">raise</span> <span class="synType">StandardError</span>, <span class="synSpecial">'</span><span class="synConstant">an exception happened during that particular instrumentation.</span><span class="synSpecial">'</span> <span class="synStatement">end</span> </pre> <pre class="code" data-lang="" data-unlink>=&gt; { :this=&gt;:data, :exception=&gt;[&#34;StandardError&#34;, &#34;an exception happened during that particular instrumentation.&#34;], :exception_object=&gt;#&lt;StandardError: an exception happened during that particular instrumentation.&gt; }</pre> <h1 id="Active-Support-Instrumentation-の仕組み">Active Support Instrumentation の仕組み</h1> <p>一通り機能について説明してきました。</p> <p>これら機能は、どのような仕組みで実現されているのでしょうか。</p> <p>全てを解説するのは難しいため、ここでは以下を実行したときに限定して確認してみます。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| puts <span class="synSpecial">&quot;</span><span class="synConstant">duration: </span><span class="synSpecial">#{</span>event.duration<span class="synSpecial">}</span><span class="synConstant"> msec, payload: </span><span class="synSpecial">#{</span>event.payload<span class="synSpecial">}&quot;</span> <span class="synStatement">end</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synConstant">:data</span> <span class="synStatement">do</span> sleep <span class="synConstant">3</span> <span class="synStatement">end</span> </pre> <p>上記を実行した際の流れを図示してみました。 厳密には、他クラスやメソッドが出てきますが、わかりやすさ重視で省略しています。</p> <p><figure class="figure-image figure-image-fotolife" title="イメージ図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tommy_0/20240219/20240219233056.png" width="1187" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>イメージ図</figcaption></figure></p> <p>キューの役割を果たす <a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Notifications/Fanout.html">ActiveSupport::Notifications::Fanout</a> インスタンスを経由して、イベントの計測・購読が行われています。Fanout インスタンスは、内部に購読設定を管理するハッシュを持っています。具体的には、イベント名をキーにして、購読設定を保持するオブジェクト(以降イベントオブジェクト)の配列が紐付きます。</p> <p>上記実装において、各メソッドの処理内容を確認してみます。</p> <h2 id="イベント購読">イベント購読</h2> <p>subscribe では以下が行われています。</p> <ul> <li>イベントオブジェクトの 1 つである EventObject インスタンスを作成します(@delegate に購読処理を保持)</li> <li>@string_subscribers に、"my.custom.event" をキーにして上記インスタンスを追加します</li> </ul> <p>具体的な処理箇所を次に示します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synComment"># https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/notifications/fanout.rb から抜粋</span> <span class="synPreProc">module</span> <span class="synType">ActiveSupport</span> <span class="synPreProc">module</span> <span class="synType">Notifications</span> ... <span class="synPreProc">class</span> <span class="synType">Fanout</span> ... <span class="synPreProc">def</span> <span class="synIdentifier">subscribe</span>(pattern = <span class="synConstant">nil</span>, callable = <span class="synConstant">nil</span>, <span class="synConstant">monotonic</span>: <span class="synConstant">false</span>, &amp;block) subscriber = <span class="synType">Subscribers</span>.new(pattern, callable || block, monotonic) synchronize <span class="synStatement">do</span> <span class="synStatement">case</span> pattern <span class="synStatement">when</span> <span class="synType">String</span> <span class="synIdentifier">@string_subscribers</span>[pattern] &lt;&lt; subscriber <span class="synComment"># ここで追加されます</span> <span class="synIdentifier">@listeners_for</span>.delete(pattern) <span class="synStatement">when</span> <span class="synType">NilClass</span>, <span class="synType">Regexp</span> <span class="synIdentifier">@other_subscribers</span> &lt;&lt; subscriber <span class="synIdentifier">@listeners_for</span>.clear <span class="synStatement">else</span> <span class="synStatement">raise</span> <span class="synType">ArgumentError</span>, <span class="synSpecial">&quot;</span><span class="synConstant">pattern must be specified as a String, Regexp or empty</span><span class="synSpecial">&quot;</span> <span class="synStatement">end</span> <span class="synStatement">end</span> subscriber <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> </pre> <h2 id="イベント計測">イベント計測</h2> <p>instrument では以下が行われています。</p> <ul> <li>@string_subscribers を "my.custom.event" で引いて、該当インスタンスを取得します</li> <li>計測情報(イベントの開始・終了時刻など)を記録しつつ、イベント処理を実行します(今回は sleep 処理)</li> <li>計測情報を踏まえて、該当インスタンスから購読処理を実行します(@delegate.call が呼ばれる)</li> </ul> <p>具体的な処理箇所を次に示します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synComment"># https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/notifications/instrumenter.rb から抜粋</span> <span class="synPreProc">module</span> <span class="synType">ActiveSupport</span> <span class="synPreProc">module</span> <span class="synType">Notifications</span> ... <span class="synPreProc">class</span> <span class="synType">Instrumenter</span> ... <span class="synPreProc">def</span> <span class="synIdentifier">instrument</span>(name, payload = {}) listeners_state = start name, payload <span class="synStatement">begin</span> <span class="synStatement">yield</span> payload <span class="synStatement">if</span> block_given? <span class="synComment"># 計測対象のイベント処理が実行されます</span> <span class="synStatement">rescue</span> <span class="synType">Exception</span> =&gt; e payload[<span class="synConstant">:exception</span>] = [e.class.name, e.message] payload[<span class="synConstant">:exception_object</span>] = e <span class="synStatement">raise</span> e <span class="synStatement">ensure</span> finish_with_state listeners_state, name, payload <span class="synComment"># 購読処理が実行されます</span> <span class="synStatement">end</span> <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> </pre> <h2 id="補足">補足</h2> <p>ちなみに、@string_subscribers のハッシュ値は配列になっており、複数のイベントオブジェクトを保持できるようになっています。なので、あるイベントを複数回購読できます。その場合は、購読設定した順番に通知が行われます。</p> <p>実際に確認してみます。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| puts <span class="synSpecial">&quot;</span><span class="synConstant">subscribe_1: </span><span class="synSpecial">#{</span>event.payload<span class="synSpecial">}&quot;</span> <span class="synStatement">end</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.subscribe <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span> <span class="synStatement">do</span> |event| puts <span class="synSpecial">&quot;</span><span class="synConstant">subscribe_2: </span><span class="synSpecial">#{</span>event.payload<span class="synSpecial">}&quot;</span> <span class="synStatement">end</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument <span class="synSpecial">&quot;</span><span class="synConstant">my.custom.event</span><span class="synSpecial">&quot;</span>, <span class="synConstant">this</span>: <span class="synConstant">:data</span> </pre> <pre class="code" data-lang="" data-unlink>=&gt; subscribe_1: {:this=&gt;:data} subscribe_2: {:this=&gt;:data}</pre> <p>購読設定した順番に実行されていますね。</p> <p>また、今回は触れませんでしたが、スレッドセーフを考慮した実装がされているので、気になる人はコードを読むと発見があるかもしれません。</p> <h1 id="Active-Support-Subscriber-について">Active Support Subscriber について</h1> <p>これまで購読設定は ActiveSupport::Notifications.subscribe メソッドを呼んでいましたが、より便利な <a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Subscriber.html">ActiveSupport::Subscriber</a> が用意されています。</p> <p>このクラスを継承したクラスを用意することで、イベント購読できます。継承先のクラスで、<a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/Subscriber.html#method-c-attach_to">ActiveSupport::Subscriber.attach_to</a> を呼び出し、public メソッドを定義することで、 <code>[public メソッド名].[attach_to の引数名]</code> イベントが購読されます。</p> <p>実際に使い方を見ていきましょう。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">class</span> <span class="synType">CustomSubscriber</span> &lt; <span class="synType">ActiveSupport</span>::<span class="synType">Subscriber</span> <span class="synComment"># CustomSubscriber クラス内ではなく、別途呼び出してもいい</span> <span class="synComment"># CustomSubscriber.attach_to :custom</span> attach_to <span class="synConstant">:custom</span> <span class="synPreProc">def</span> <span class="synIdentifier">event</span>(event) puts <span class="synSpecial">&quot;</span><span class="synConstant">event message: </span><span class="synSpecial">#{</span>event.payload[<span class="synConstant">:message</span>]<span class="synSpecial">}&quot;</span> <span class="synPreProc">end</span> <span class="synPreProc">def</span> <span class="synIdentifier">event_2</span>(event) puts <span class="synSpecial">&quot;</span><span class="synConstant">event_2 message: </span><span class="synSpecial">#{</span>event.payload[<span class="synConstant">:message</span>]<span class="synSpecial">}&quot;</span> <span class="synPreProc">end</span> <span class="synPreProc">end</span> <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument(<span class="synSpecial">'</span><span class="synConstant">event.custom</span><span class="synSpecial">'</span>, <span class="synConstant">message</span>: <span class="synSpecial">&quot;</span><span class="synConstant">hogehoge</span><span class="synSpecial">&quot;</span>) <span class="synType">ActiveSupport</span>::<span class="synType">Notifications</span>.instrument(<span class="synSpecial">'</span><span class="synConstant">event_2.custom</span><span class="synSpecial">'</span>, <span class="synConstant">message</span>: <span class="synSpecial">&quot;</span><span class="synConstant">fugafuga</span><span class="synSpecial">&quot;</span>) </pre> <pre class="code" data-lang="" data-unlink>=&gt; event message: hogehoge event_2 message: fugafuga</pre> <p>購読設定がまとまっていてわかりやすいですね。</p> <h1 id="Rails-内部でのイベント計測購読について">Rails 内部でのイベント計測・購読について</h1> <p>冒頭で Active Support Instrumentation は Rails 内部でも使われていると書きました。</p> <p>ここまでの内容を踏まえて、どのように使われているのか確認してみましょう。</p> <p>例えば、Active Record でクエリ実行した際に log/development.log へ出力されるログについて、流れを追ってみます。</p> <pre class="code" data-lang="" data-unlink> Vehicle Load (2.6ms) SELECT `vehicles`.* FROM `vehicles` ORDER BY `vehicles`.`id` DESC LIMIT 1 ...</pre> <h2 id="イベント購読-1">イベント購読</h2> <p>Active Support では、ログ出力用の Subscriber として、<a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveSupport/LogSubscriber.html">ActiveSupport::LogSubscriber</a>が用意されています。ActiveSupport::Subscriber を継承しているため、上述の機能を使えます。</p> <p>実際に、ログ出力を行っているのは、上記クラスを継承した専用クラスになります。例えば、Active Record であれば、<a href="https://api.rubyonrails.org/v7.0.4/classes/ActiveRecord/LogSubscriber.html">ActiveRecord::LogSubscriber</a>が該当します。他モジュールにもそれぞれ専用クラスが用意されています(例. <a href="https://api.rubyonrails.org/v7.0.4/classes/ActionController/LogSubscriber.html">ActionController::LogSubscriber</a>, <a href="https://api.rubyonrails.org/v7.0.4/classes/ActionView/LogSubscriber.html">ActionView::LogSubscriber</a>, ...)。</p> <p>購読設定の流れは ActiveSupport::Subscriber と同じになります。</p> <p>ActiveRecord::LogSubscriber には、ログ出力を行う <a href="https://github.com/rails/rails/blob/v7.0.4/activerecord/lib/active_record/log_subscriber.rb#L30">sql メソッド</a>が定義されており、<a href="https://github.com/rails/rails/blob/v7.0.4/activerecord/lib/active_record/log_subscriber.rb#L153">attach_to :active_record</a> を実行することで、 <code>sql.active_record</code> のイベントが購読されます。</p> <p>具体的な処理箇所を次に示します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synComment"># https://github.com/rails/rails/blob/v7.0.4/activerecord/lib/active_record/log_subscriber.rb から抜粋</span> <span class="synPreProc">module</span> <span class="synType">ActiveRecord</span> <span class="synPreProc">class</span> <span class="synType">LogSubscriber</span> &lt; <span class="synType">ActiveSupport</span>::<span class="synType">LogSubscriber</span> ... <span class="synPreProc">def</span> <span class="synIdentifier">sql</span>(event) <span class="synComment"># ログ出力...</span> <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> <span class="synPreProc">end</span> <span class="synType">ActiveRecord</span>::<span class="synType">LogSubscriber</span>.attach_to <span class="synConstant">:active_record</span> </pre> <h2 id="イベント計測-1">イベント計測</h2> <p>その上で <code>Vehicle.last</code> のように実行すると、その処理先で <a href="https://github.com/rails/rails/blob/v7.0.4/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L756">sql.active_record のイベント計測</a>が行われ、購読処理である sql メソッドが呼ばれる、という流れになります。</p> <p>具体的な処理箇所を次に示します。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synComment"># https://github.com/rails/rails/blob/v7.0.4/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb から抜粋</span> <span class="synPreProc">module</span> <span class="synType">ActiveRecord</span> <span class="synPreProc">module</span> <span class="synType">ConnectionAdapters</span> <span class="synComment"># :nodoc:</span> <span class="synPreProc">class</span> <span class="synType">AbstractAdapter</span> ... <span class="synPreProc">def</span> <span class="synIdentifier">log</span>(sql, name = <span class="synSpecial">&quot;</span><span class="synConstant">SQL</span><span class="synSpecial">&quot;</span>, binds = [], type_casted_binds = [], statement_name = <span class="synConstant">nil</span>, <span class="synConstant">async</span>: <span class="synConstant">false</span>, &amp;block) <span class="synComment"># :doc:</span> <span class="synIdentifier">@instrumenter</span>.instrument( <span class="synSpecial">&quot;</span><span class="synConstant">sql.active_record</span><span class="synSpecial">&quot;</span>, <span class="synConstant">sql</span>: sql, <span class="synConstant">name</span>: name, <span class="synConstant">binds</span>: binds, <span class="synConstant">type_casted_binds</span>: type_casted_binds, <span class="synConstant">statement_name</span>: statement_name, <span class="synConstant">async</span>: async, <span class="synConstant">connection</span>: <span class="synConstant">self</span>) <span class="synStatement">do</span> <span class="synIdentifier">@lock</span>.synchronize(&amp;block) <span class="synStatement">rescue</span> =&gt; e <span class="synStatement">raise</span> translate_exception_class(e, sql, binds) <span class="synStatement">end</span> <span class="synPreProc">end</span> ... <span class="synPreProc">end</span> <span class="synPreProc">end</span> <span class="synPreProc">end</span> </pre> <p>普段目にするログ出力は、このような仕組みで行われていたんですね。</p> <h1 id="まとめ">まとめ</h1> <p>Active Support Instrumentation の使い方から始めて、簡単に内部の仕組み、Rails 内部での使われ方について説明しました。 基本的な使い方を理解できたら、計測したい内容を設定してみましょう。 本記事が Active Support Instrumentation を利用する際の参考になったら幸いです。</p> tommy_0 Sansan Androidチームのライブラリアップデートの取り組みについて hatenablog://entry/6801883189077420550 2024-02-26T11:00:00+09:00 2024-02-26T11:00:03+09:00 こんにちは。 この記事は、技術本部 Mobile ApplicationグループでSansan(※プロダクトとしてのSansan)のAndroid開発を行っている、桑原、小林、鎌田、原田の共著でお届けします。 今回は、アプリで使用しているライブラリのアップデートについて、 Sansanではどのようなポリシーで行っているのか そのポリシーを守るためにしていること そこから見えてくる課題 そして今後について をお話します。 <p>こんにちは。 この記事は、技術本部 Mobile ApplicationグループでSansan(※プロダクトとしてのSansan)のAndroid開発を行っている、桑原、小林、鎌田、原田の共著でお届けします。</p> <p>今回は、アプリで使用しているライブラリのアップデートについて、</p> <ul> <li>Sansanではどのようなポリシーで行っているのか</li> <li>そのポリシーを守るためにしていること</li> <li>そこから見えてくる課題</li> <li>そして今後について</li> </ul> <p>をお話します。</p> <h2 id="Sansanにおけるライブラリアップデートのポリシー">Sansanにおけるライブラリアップデートのポリシー</h2> <p>アプリで使用しているさまざまなライブラリは日々更新されており、機能の追加や変更、脆弱性の修正などが行われています。つまりアップデートを長期間放置していると、新機能を使えないことで開発生産性が落ちるなど、重大なセキュリティリスクを抱えることにつながります。</p> <p>Sansan / Eightでは、お客さまの大切なデータをお預かりしているため、特にセキュリティリスクは強く意識しなければなりません。</p> <p>そこでMobile Applicationグループでは、インシデントが発生した際に対応にあたるCSIRTと協議し、四半期ごとに1回、アプリで使用しているライブラリのアップデートを実施することとしています。ただし、プロダクトの開発で緊急対応をしていたり、優先すべき案件があったりする場合はこの限りではありませんが、基本的に実施できています。</p> <p>上記の要件を実施するため、プロダクトごとにそれぞれ業務フローに合わせてライブラリアップデートを実施しています。その中でも今回はSansan Androidチームでの取り組みを具体的にお話していきます。</p> <h2 id="ライブラリアップデートの実際の業務フローについて">ライブラリアップデートの実際の業務フローについて</h2> <p>Sansanプロダクトの開発では、プロダクトの機能開発案件と、ライブラリアップデートのようなより技術面の課題を解決する案件を優先度判断する際、同じ土俵で比較して判断しています。 「機能開発ばかりが優先され、技術的な負債の返済やチャレンジが疎かになる」ようなことはない環境と言えます。</p> <p>ライブラリアップデートについても、四半期ごとに毎回まずは優先度判断を比較する場で議論して始めます。このプロセスは、Sansanの技術基盤を最新かつ安全に保つために不可欠です。</p> <p>ここからは、この優先度設定から一歩進んで、Sansan Androidチームがどのようにライブラリアップデートを具体的に実施しているかを詳しく見ていきましょう。</p> <p>まず、ライブラリアップデートのプロセスを効率化するために、私たちはRenovateというツールを採用しました。このツールが重要な役割を果たしている理由を、次に説明します。</p> <p><a href="https://github.com/renovatebot/renovate">Renovate</a>はMend社が開発した無料の依存関係更新ツールで、SansanのAndroidチームでは昨年からこのツールを導入しました。</p> <p>導入前は、Android Studioの提案する更新を基に、手動で1つずつ確認していましたが、これが非常に非効率的でした。そのため、更新プロセスを少なくとも半自動化することで効率を向上させたいと考え、Renovateを導入しました。</p> <p>Dependabotも同様の機能を提供していますが、PR(プルリクエスト)のグループ化や更新ルールのカスタマイズが柔軟に行えるため、Renovateを選択しました。</p> <p><strong>Renovateの特徴</strong></p> <ul> <li>新しいライブラリのバージョンがリリースされると、自動的に更新用のPRを生成してくれます。</li> <li>マージプロセスの自動化も可能です。 <ul> <li>内部統制により、2人からApproveを得ないとPRをマージできないため自動マージは行わない方針にしています。</li> </ul> </li> <li><strong><code>renovate.json</code></strong>を通じてPRのグルーピングや特定のライブラリの更新除外が設定できます。 <ul> <li>例: Compose関連のライブラリは一括でグループ化する。</li> <li><p>Sansan Androidチームのrenovate.json設定</p> <pre><code class="``json"> { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:base", ":timezone(Asia/Tokyo)" ], "branchPrefix": "library_update_17y_1q/feature/", "baseBranches": [ "library_update_17y_1q/develop" ], "draftPR": true, "prConcurrentLimit": 100, "packageRules": [ { "groupName": "Jetpack Compose", "matchPackagePatterns": [ "^androidx\\.compose:", "^androidx\\.compose\\.animation:", "^androidx\\.compose\\.compiler:", "^androidx\\.compose\\.foundation:", "^androidx\\.compose\\.material:", "^androidx\\.compose\\.runtime:", "^androidx\\.compose\\.ui:" ] }, ・・・ ], "labels": [ "library_update_17Y1Q" ] } </code></pre></li> </ul> </li> </ul> <p>Renovateのこれらの特徴を活かして、次に私たちの具体的な更新プロセスを紹介します。</p> <p><strong>具体的な運用(更新プロセス)</strong></p> <p>ライブラリアップデートを効率的かつ効果的に行うため、次の更新プロセスで運用しています。</p> <ol> <li><p>PRのドラフト解除</p> <p> Renovateはライブラリが更新される度にPRを自動的に更新します。これは非常に便利ですが、PRが更新されるとビルド、テスト、Lintなどのプロセスが自動的に実行され、結果としてCIプロセスが頻繁にトリガーされてしまいます。</p> <p> そのため、無駄なCI実行を防ぐためにRenovateによって作成されたPRは初期状態でドラフトとして設定しています。作業を開始する前に、このドラフト状態を解除します。</p></li> <li><p>リベースの実行</p> <p> プロジェクトの<strong><code>develop</code></strong>ブランチの最新状態を反映させるためにリベースを行います。Renovateによって作成されたPRには、GUIで簡単にリベースを実行できるチェックボックスが提供されています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/petohtalrayn/20240123/20240123171528.png" width="918" height="1099" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p></li> <li><p>PRに調査内容の記載</p> <p> Sansan Androidチームでは全てのライブラリアップデートに対し、その変更点をひとつずつ丁寧に検証し、その結果を詳細に記録するという徹底した風習を守っています。このプロセスにおいて、次の点を特に重視しています。</p> <ul> <li>ライブラリの概要 <ul> <li>メジャーではないライブラリに関しては、レビュワーが理解しやすいよう、その基本情報をまず整理します。</li> </ul> </li> <li><p>バージョン間の変更点とそのアプリへの影響</p> <ul> <li>各ライブラリのバージョンアップに伴う変更点を調査し、それが当社のアプリケーションに与える影響を詳細に分析します。</li> <li>変更点を調べるにあたって、Renovateは差分をサマリしてくれるため非常に便利です。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/petohtalrayn/20240123/20240123171638.png" width="868" height="348" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> </li> <li><p>確認した事項</p> <ul> <li>変更点に基づいて行った確認作業の内容を、記録し、アプリケーションの安全性と品質を保証します。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/petohtalrayn/20240123/20240123171734.png" width="837" height="676" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> </li> </ul> </li> <li><p>更新しないPRのクローズ</p> <p> 特定の事情(他のライブラリやAndroid SDKとの依存関係等)により更新しない場合はPRをクローズします。クローズされたPRは、更新があっても自動的には生成されません。</p> <p> そのため、次回のライブラリアップデートのプロジェクトで再度確認対象に戻します。</p></li> <li><p>レビュー依頼</p> <p> 問題がないと判断した場合は、レビューを依頼します。</p></li> <li><p>マージ</p> <p> 2名の承認(approve)を得た後、PRをマージします。</p></li> </ol> <p>このプロセスを繰り返し、全ての更新対象ライブラリがアップデートされた後、次のライブラリアップデートの準備作業に移ります。</p> <p>この準備作業では、後述するGitHubのカンバンボードを自動更新する設定をしています。これには <strong><code>renovate.json</code></strong> ファイルの設定更新が必要です。</p> <p>具体的には、次回のアップデート対象に合わせて labels、baseBranch、branchPrefix の値を更新する作業が含まれます。</p> <p>ライブラリのアップデートが効率的に進むようにするためには、適切なタスク管理が必要です。</p> <p>次に、タスク管理のアプローチを紹介します。</p> <p><strong>具体的な運用(タスク管理)</strong></p> <p>プロジェクトを進行させる上で、タスク管理の効率化は重要です。</p> <p>この目的を達成するために、Sansan Androidチームではプロジェクト開始時にGitHubのカンバンボードを活用し、タスクのステータスを適切に分類しています。このアプローチにより、タスクの進捗状況やタスクの粒度が明確になり、残っているタスクを基にプロジェクト完了までの時間を推定できます。</p> <p>このタスク管理方法はRenovateの機能とは独立していますが、タスクの透明性を確保し、管理をよりスムーズに行うためにこの運用方式を組み立てました。</p> <ul> <li><strong>ステータスの種類</strong>: <ul> <li><strong>NoStatus</strong>: RenovateがPRを生成した際、Automationによりここに配置されます。</li> <li><strong>ToDo</strong>:案件開始時にNoStatusのタスクをすべてここに移動します</li> <li><strong>L</strong>:CIの修正や調査に時間がかかると予想される、優先度の高いタスク。</li> <li><strong>M</strong>:CIが失敗しているタスク。</li> <li><strong>S</strong>:CIが成功しているタスク。</li> <li><strong>In Progress</strong>:現在作業中のタスク。</li> <li><strong>Merged</strong>:マージ済みのタスク。</li> <li><strong>Closed</strong>:クローズされたタスク。</li> </ul> </li> <li><strong>運用のポイント</strong>: <ul> <li><strong>GitHub ProjectのAutomation機能の活用</strong>: <ul> <li>チケットのステータス移動を自動化しています。タスクがマージされると自動的に「Merged」カテゴリに、クローズされると「Closed」カテゴリに移動されます。</li> <li>Renovateによって自動生成されたPRは「NoStatus」カテゴリに追加されるように設定しています。これにより、案件開始時に作成されたPRは「ToDo」カテゴリに、それ以降に生成されたPRは「NoStatus」カテゴリに分類され、効果的に区別できます。</li> </ul> </li> <li><strong>緊急性の高いタスクへの対応</strong>: <ul> <li>リリースリスクや未知の問題を早期に解決するため、CIエラーやビルドの失敗など緊急性の高いPRは「L」または「M」として分類し、優先的に取り組んでいます。</li> </ul> </li> </ul> </li> </ul> <p>このセクションでは、Sansan Androidのライブラリアップデートのフローについて、Renovateツールの導入から更新プロセスの具体的なステップ、タスク管理の方法まで、詳しく見てきました。</p> <p>これにより、ライブラリを最新の状態に保ちながら、高品質なアプリケーション開発を継続するための基盤を築いています。</p> <p>しかし、このプロセスは完璧ではありません。</p> <p>次のセクション「ライブラリアップデートの品質保証に係る課題と解決」では、このアップデートプロセスにおける品質保証の課題に焦点を当て、それらに対する私たちの解決策を紹介します。</p> <h2 id="ライブラリアップデートの品質保証の難しさとその対策">ライブラリアップデートの品質保証の難しさとその対策</h2> <p>アプリ開発において、品質保証は重要です。それはライブラリアップデートでも例外ではなく、それどころか通常の開発案件以上に気をつけるべきポイントがあります。ここでは、ライブラリアップデートでの品質保証の難しさと、それに立ち向かうため私たちが行っている対策についてお話しします。</p> <h3 id="思わぬところがデグレする可能性がある">思わぬところがデグレする可能性がある</h3> <p>ライブラリ更新による影響範囲が広い場合や、リリースノートの情報が不完全であると問題を見落としやすくなります。また、ライブラリ自体に公開されていないバグが潜んでいる可能性もあります。Sansanでは、以前にライブラリアップデートで更新しようとしたComposeでバグを踏んでNested Scrollを行う画面でスクロール時にカクつきが生じる問題がありました。一見すると気づかないような細かな問題にも注意をする必要があります。</p> <h3 id="本番ビルドでのみ発生する問題">本番ビルドでのみ発生する問題</h3> <p>開発ビルドでは発生しないが、本番ビルドしたときに発生する問題もあります。これはコード圧縮・難読化の適用有無によるものが多いです。ライブラリのアップデートによってProguardまわりに変更があっても、ビルド時に適用していないと気づけません。最悪の場合、リリースまで問題に気づかないということもあり得るため、開発中もコード圧縮・難読化を有効にしてビルド・動作確認をすることは重要です。</p> <h3 id="Sansanにおけるテスト体制">Sansanにおけるテスト体制</h3> <p>では、問題へ立ち向かうために私たちがどのようにテストを行っているかについてお話します。Sansanでは、大きく分けて開発者によるホワイトボックステストと、QAチームによるブラックボックステストを行っています。</p> <ul> <li><strong>開発者によるテスト</strong> ではPRを出す前にライブラリの変更点から推測される影響箇所を要点絞って確認します。</li> <li><strong>QAチームによるテスト</strong> ではアプリの機能全体を網羅的に確認します。確認項目が多いこと、サポートしているOSを網羅するため実施には18人日程度の工数を要します。</li> </ul> <p>前述した「思わぬところがデグレする可能性がある」ことに加えて、影響範囲がアプリ全体に及ぶことから、通常の開発案件よりも時間をかけて手厚くテストを行っています。</p> <h3 id="リリース">リリース</h3> <p>手厚くテストを行ったとしても、問題がすり抜けてしまうリスクはまだあります。そのため、リスクの高い案件をリリースする時は一気に100%公開にするのではなく、一週間程度かけて段階的にリリースする運用をしています。そうすることで、問題があった場合のユーザーへの被害を抑えることができます。</p> <p>以上、品質保証の難しさとそれに立ち向かうための取り組みでした。アプリ全体への影響をカバーするために開発開始からリリースまでのリードタイムがどうしても長くなってしまっているため、今後はデリバリー面の向上も図っていきたいですね。</p> <h2 id="品質保証以外の観点で生じた課題">品質保証以外の観点で生じた課題</h2> <p>ここまではライブラリアップデートを通じた課題を品質保証観点でお伝えしましたが、もちろん実際にアップデート作業をしていて発生する問題はそれだけではありません。アップデートの具体的な課題やタスク運用上起こった課題とその対応に関してお伝えします。</p> <h3 id="他のライブラリとの依存を考慮する必要がある">他のライブラリとの依存を考慮する必要がある</h3> <p>プロジェクトに導入されているライブラリは相互に依存しあっています。</p> <p>そのためライブラリの依存関係を考えずに手当たり次第アップデート作業を進めようとすると、思いがけない所でビルドエラーが起こり、思うように進められないことが多くあります。</p> <p>そのため、依存関係を把握しながらエラーが起こらないように順番を考えながらアップデートを進める必要があります。</p> <p>Sansan Androidチームでは、次のような課題が挙げられていました。</p> <ul> <li>思いがけないエラーの存在を考慮するためアップデートにかかる時間の見積もりが他の開発案件と比較して困難なこと</li> <li>アップデート内容などによって対応を変える必要があるため過去のナレッジを適用しにくいこと</li> </ul> <p>アップデートする順番は各ライブラリのアップデート内容によって変わるため、具体的な対応策は一概には言えません。が、直近のライブラリアップデートでアップデートが簡単ではなかった例をナレッジの一部として以下に挙げます。</p> <ul> <li>多くのライブラリが依存しうるライブラリや、多くの箇所で使用されうるライブラリは影響範囲が広い分エラーが起こりやすいため先にアップデートするのが無難です。 <ul> <li><p><a href="https://github.com/JetBrains/kotlin">kotlin</a>は<a href="https://github.com/google/dagger">dagger・hilt</a>や<a href="https://developer.android.com/jetpack/androidx/releases/core">androidx</a>など多くのライブラリが依存しているので真っ先にアップデートした方が良さそうです。</p> <ul> <li>例えば<a href="https://github.com/google/dagger">dagger</a>の(2024/01/19時点で)最新であるv2.50を、<a href="https://github.com/JetBrains/kotlin">kotlin</a> v1.7.0が導入されている環境でビルドすると、対応バージョンが異なる旨のエラーが出ます。エラーメッセージを見る限り、<a href="https://github.com/JetBrains/kotlin">kotlin</a>をv1.7.1以降にアップデートしてから<a href="https://github.com/google/dagger">dagger</a>のアップデートを行うべきのようです。</li> </ul> <pre><code class="``kotlin"> Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.9.0, expected version is 1.7.1. </code></pre></li> <li><p>Androidアプリのほとんど全ての画面で使用されているであろうライブラリも先にアップデートした方が無難かもしれません。</p> <ul> <li><a href="https://developer.android.com/jetpack/androidx/releases/appcompat">appcompat</a>、<a href="https://developer.android.com/jetpack/androidx/releases/activity">activity</a>、<a href="https://developer.android.com/jetpack/androidx/releases/fragment">fragment</a>、<a href="https://developer.android.com/jetpack/androidx/releases/lifecycle">lifecycle</a>など…</li> </ul> </li> <li>firebase系のライブラリを使用している場合は、<a href="https://firebase.google.com/docs/android/learn-more?hl=ja#bom">firebase-bom</a>あるいはfirebase-coreのような中心となるライブラリを先にアップデートしないと、バージョンが対応していない旨のエラーが起こり他のfirebase系ライブラリをアップデートしないといけない可能性があります。</li> </ul> </li> <li><a href="https://github.com/JetBrains/kotlin">kotlin</a>と<a href="https://developer.android.com/jetpack/androidx/releases/compose-compiler">jetpack_compose_compiler</a>はバージョンごとに1対1対応しているため、kotlinをアップデートしたい時は<a href="https://developer.android.com/jetpack/androidx/releases/compose-compiler">jetpack_compose_compiler</a>も必ず同時にアップデートしないといけません。 <ul> <li><a href="https://developer.android.com/jetpack/androidx/releases/compose-kotlin">https://developer.android.com/jetpack/androidx/releases/compose-kotlin</a> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/petohtalrayn/20240123/20240123171830.png" width="928" height="645" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></li> </ul> </li> </ul> <p>運用についての章で少し触れたように、Renovateには複数のライブラリをひとまとめのPRにするグルーピング機能があります。次回のライブラリアップデートから、これらの他のライブラリと依存関係の強いライブラリはグルーピングして、一気に動作確認と対応を進められるようにしようと考えています。</p> <h3 id="SDKバージョンとの依存を考慮する必要がある">SDKバージョンとの依存を考慮する必要がある</h3> <p>targetSDK34対応をしていない状態でライブラリアップデートに取り掛かると、targetSDK 34が前提のライブラリでは必ずビルドが失敗します。</p> <p>この場合、CIが失敗している全てのPRで、</p> <ul> <li>「targetSDKが低い」ことが原因でビルド失敗しているのかどうかを確認して</li> <li>そのライブラリでtargetSDK33に対応しているバージョンを探してダウングレードして</li> <li>ダウングレードした状態であらためてビルドできるか確認する <ul> <li>他の原因でビルドできなかったらその解決にあたる</li> </ul> </li> </ul> <p>…という作業が多くのライブラリで発生して、事前にtargetSDK34対応していれば発生しなかったであろう工数を使うことになってしまいます。</p> <p>今回はtargetSDK対応のプロジェクトがライブラリアップデートより後になってしまったためこのような問題が発生しました。 新しいバージョンのAndroidがリリースされた際、既存のアプリは新しいAndroid上で動作確認をしているのですが、targetSDKの更新は別途確認する必要のある項目があるためプロジェクトを分けています。 次回からはライブラリアップデートより前にtargetSDK対応のプロジェクトを行うようにする予定です。</p> <h3 id="Renovateが自動でPRを作り続けるためPRがなくならない">Renovateが自動でPRを作り続けるため、PRがなくならない</h3> <p>Renovateはライブラリのアップデートを検知するたびに自動でPRを作成します。</p> <p>もちろんライブラリアップデートを行なっている最中にもPRが作成されるため、「今存在する全てのPRをアップデートしよう」と考えてしまうといくらアップデート作業を続けていてもPRが増え続けて終わりが見えない作業になってしまいます。</p> <p>そのため、事前にどのライブラリをどのバージョンまでアップデートするかをチームやプロダクトのステークホルダーなどと取り決めてから、ライブラリアップデートの着手をするようにしましょう。</p> <p>Sansan Androidチームではライブラリアップデート開始前にすでに作成されていたPRと、ライブラリアップデート開始後に自動で作成されたPRは別のカラムに分類されるように設定しており、ライブラリアップデート開始前にすでに作成されていたPRのみ着手すると取り決めています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/p/petohtalrayn/20240123/20240123172007.png" width="564" height="819" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>以上が、Sansan Androidチームのライブラリアップデートについての取り組みです。 ライブラリの定期的なアップデートは、アプリが生きている限りセキュリティや生産性を維持するために必要かつ重要な業務です。</p> <p>私たちは、今後もより良いアプリを作っていくため、この運用が継続して行えるよう、フローを見直しながらさらに良くしていくつもりです。</p> <p>この記事がライブラリアップデートの運用で悩める方の参考になれば幸いです。 また、共にSansanのモバイルアプリ開発する仲間を募集中です!選考とは関係なく、現場のエンジニアの生の声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもご応募ください!</p> <p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76400" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Android%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76400?_gl=1*1wx1ev9*_ga*MTczMDU1OTY2NS4xNjY0NzU2ODE0*_ga_EN18Q6V0JW*MTcwNTY0MDk5MS41LjEuMTcwNTY0MTAwNi4wLjAuMA">open.talentio.com</a></cite></p> petohtalrayn Order Oneでのドメインイベント実装 hatenablog://entry/6801883189083404500 2024-02-22T18:00:00+09:00 2024-02-22T18:00:01+09:00 技術本部 Strategic Products Engineering Unit Order One Devグループで受注業務のDXから、事業を加速するプロダクトOrder Oneの開発をしている山邊です。 本題に入る前にお知らせです。2/27 (火) に「自由な発想でつながる、失敗談を語るLTパーティー」というイベントを開催します。 ぜひ、お気軽にご参加ください! sansan.connpass.com Order Oneにドメインイベント・非同期イベントの仕組みを導入したので、仕組みの紹介をしたいと思います。ドメインイベントの導入を検討している方の役に立てば幸いです。 <p>技術本部 Strategic Products Engineering Unit Order One Devグループで受注業務のDXから、事業を加速するプロダクト<a href="https://order-one.com/">Order One</a>の開発をしている山邊です。</p> <p>本題に入る前にお知らせです。2/27 (火) に「自由な発想でつながる、失敗談を語るLTパーティー」というイベントを開催します。 ぜひ、お気軽にご参加ください! <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsansan.connpass.com%2Fevent%2F309463%2F" title="自由な発想でつながる、失敗談を語るLTパーティー (2024/02/27 19:00〜)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://sansan.connpass.com/event/309463/">sansan.connpass.com</a></cite></p> <p>Order Oneにドメインイベント・非同期イベントの仕組みを導入したので、仕組みの紹介をしたいと思います。ドメインイベントの導入を検討している方の役に立てば幸いです。</p> <h1 id="ドメインイベントとは">ドメインイベントとは</h1> <p>GPTによると</p> <blockquote><p>ドメインイベントは、ソフトウェアの専門分野(ドメイン)において重要な出来事が起こった際に使用される概念です。例えば、オンラインショップで顧客が注文すると、「注文が完了した」というドメインイベントが生成されます。この情報を使って、在庫管理システムを更新しれたり、顧客に確認メールを送ったりできます。DDD(ドメイン駆動設計)では、このようなイベントを通じてソフトウェアのビジネスロジックが正確に表現され、システムの各部分が協調して機能します。これにより、ソフトウェアがより管理しやすく、拡張しやすいものになります。</p></blockquote> <h1 id="技術スタック">技術スタック</h1> <p>Order Oneでは主に<a href="https://console.cloud.google.com/?hl=ja">Google Cloud Platform(GCP)</a>上でサーバーサイドKotlinを採用しています。今回のドメインイベントについても説明に使用するコードは全てKotlinで書かれています。また、キューイングサービスは <a href="https://cloud.google.com/tasks/docs/dual-overview?hl=ja">Google Cloud Tasks</a>を使用しています。</p> <h1 id="Order-Oneでドメインイベントの導入を決めた背景">Order Oneでドメインイベントの導入を決めた背景</h1> <p>Order Oneでは、既にDDDを採用しており、非同期イベントの仕組みを利用していましたが、これにはいくつかの課題がありました。以前のシステムでは、次のような方法で非同期イベントを実行していました。</p> <h2 id="導入前の非同期イベントの実行方法">導入前の非同期イベントの実行方法</h2> <p>例として、ユーザー作成後に2つの後続処理を実行したい場合のコードが次の通りです。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synComment">// トランザクションを開く</span> runInTransaction { <span class="synComment">// ユーザーを作成</span> <span class="synType">val</span> user = User.new() <span class="synComment">// 作成したユーザーを永続化</span> UserRepository.register(user) <span class="synComment">// ユーザーの作成通知を送信するタスクをキューに積む</span> GoogleCloudTasksClient.createTask( queueName = <span class="synConstant">&quot;exampleQueue&quot;</span>, targetUrl = <span class="synConstant">&quot;https://example.com/api/send-created-user-mail&quot;</span>, requestBody = <span class="synConstant">&quot;{'id': '</span><span class="synIdentifier">${</span>user.id<span class="synIdentifier">}</span><span class="synConstant">'}&quot;</span>, scheduledTime = <span class="synConstant">1</span> ) <span class="synComment">// ユーザーの追加情報を生成するタスクをキューに積む</span> GoogleCloudTasksClient.createTask( queueName = <span class="synConstant">&quot;exampleQueue&quot;</span>, targetUrl = <span class="synConstant">&quot;https://example.com/api/generate-user-additional-info&quot;</span>, requestBody = <span class="synConstant">&quot;{'id': </span><span class="synIdentifier">${</span>user.id<span class="synIdentifier">}</span><span class="synConstant">'}&quot;</span>, scheduledTime = <span class="synConstant">1</span> ) } </pre> <h2 id="課題点">課題点</h2> <ul> <li>非同期処理のロジックがアプリケーション層に記述されていました。そのためドメインイベントがどのタイミングで発火されるか、ドメイン同士のつながりがどうなっているかがわかりにくい状態でした。それぞれのドメイン操作やAPIがどこから呼ばれているかを判断するためにはAPIのパスでアプリケーション層を検索する必要があり、これによって仕様調査やエラー調査が難しくなっていました。</li> <li>トランザクションの処理中にタスクを登録していたため、コミット前に後続の処理が実行されるリスクがありました。多くのドメインイベントは発行元のドメインオブジェクトを参照しますが、永続化される前にこれを参照すると、ドメインオブジェクトが見つからないエラーが発生する可能性がありました。</li> </ul> <h1 id="仕組み">仕組み</h1> <p><figure class="figure-image figure-image-fotolife" title="ドメインイベントの全体像"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-yamabe/20240219/20240219224237.png" width="1200" height="565" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ドメインイベントの全体像</figcaption></figure> 今回は、Order Oneで導入したドメインイベントの仕組みを3つの段階に分けて説明します。</p> <h2 id="ドメインイベント">ドメインイベント</h2> <p>アプリケーション層の実装は次のようになります。</p> <ul> <li><code>User.new()</code> がentityを直接返さず、ドメインイベントを返すようになります。</li> <li>永続化処理は変わっていません。</li> <li>タスクをキューに積むコードの代わりにドメインイベントをパブリッシュする処理になります。</li> </ul> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synComment">// トランザクションの開始</span> runInTransaction { <span class="synComment">// ユーザーの作成</span> <span class="synType">val</span> userCreatedEvent = User.new() <span class="synComment">// 作成したユーザーの永続化</span> UserRepository.register(userCreatedEvent.entity) <span class="synComment">// ドメインイベントのパブリケーション</span> domainEventPublisher.publish(userCreatedEvent) } </pre> <p>ドメインイベントはドメインに操作を加えたときに作成されます。ドメインイベントの型は次のようなイメージで、ドメイン層からはドメインイベントを継承した型を返します。ドメインイベントに後続の処理がない場合は省略しても良いかもしれないですが、Order Oneでは原則としてドメインイベントを返す方針にしています。</p> <p>ドメインイベントは <code>asyncCommand</code> = <code>非同期で実行される処理</code> を持つことができます。 <code>asyncCommand</code>はドメインイベントと強いつながりがありますが、分けて考えることもできます。というのもドメインイベントに属さないasyncCommandも作成できる仕組みになっています。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synComment">// ドメインイベントのinterface</span> <span class="synType">interface</span> DomainEvent&lt;Payload : DomainEvent.Payload&gt; { <span class="synType">data</span> <span class="synType">class</span> Id(<span class="synType">val</span> value: UUID) <span class="synType">interface</span> Payload { <span class="synType">fun</span> asyncCommands(): <span class="synType">List</span>&lt;AsyncCommand&gt; } <span class="synType">val</span> id: Id <span class="synType">val</span> payload: Payload } <span class="synComment">// ユーザーが作成されたときのドメインイベントの例</span> <span class="synType">data</span> <span class="synType">class</span> UserCreatedEvent( <span class="synType">val</span> entity: User ) : DomainEvent(UserCreatedEvent.Payload) { <span class="synType">data</span> <span class="synType">class</span> Payload(<span class="synType">val</span> userId: entity.Id) : DomainEvent.Payload { <span class="synType">override</span> <span class="synType">fun</span> asyncCommands(): <span class="synType">List</span>&lt;AsyncCommand&gt; = listOf( <span class="synComment">// </span><span class="synTodo">TODO</span> ) } <span class="synType">override</span> <span class="synType">val</span> id = DomainEvent.Id.new() <span class="synType">override</span> <span class="synType">val</span> payload = Payload(userId = entity.id) } <span class="synComment">// Userドメインの例</span> <span class="synType">data</span> <span class="synType">class</span> User( <span class="synType">val</span> Id: UUID ){ <span class="synType">companion</span> <span class="synType">object</span> { <span class="synType">fun</span> new() = UserCreatedEvent(User(Id = UUID.randomUUID())) } } </pre> <p>そして、DomainEventPublisherの説明です。</p> <p>DBに調査用のデータを永続化しています。調査時にどのコマンドがどのドメインイベントによってInvokeされたかを容易に判別するためなので、なくても支障はありません。</p> <p>DomainEventPublisherは <code>AsyncCommandInvoker.invoke()</code> を呼び出します。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">object</span> DomainEventPublisher { <span class="synType">fun</span> publish( domainEvent: DomainEvent&lt;<span class="synStatement">*</span>&gt; ) { <span class="synComment">// DB に調査用のデータを永続化</span> DomainEventRepository.create(domainEvent) <span class="synComment">// ドメインイベントに紐づくコマンドをCommandInvokerに渡す</span> AsyncCommandInvoker.invoke( commands = domainEvent.getAsyncCommands() ) } } </pre> <h2 id="コマンド">コマンド</h2> <p>ここからはコマンドについて説明します。ドメインイベントのパブリッシャーは最終的にコマンドのインボーカーを呼び出しています。もちろんドメインイベントに属さないコマンドも作成でき、その場合は<code>AsyncCommandInvoker.Invoke()</code> を直接呼び出すことになります。</p> <p>AsyncCommandInvoke.Invoke()では <code>command_awaiting_deploy</code> というテーブルにコマンドの内容を永続化し、後で復元できるようにしています。ここで永続化する内容はAsyncCommandにどんな値を持たせるかによって変わってきますが、代表的なものとしては <code>id, payload, url, queue</code> などが含まれます。</p> <p>Order OneではAPIの呼び出しごとにユニークなID(CallUUID)をCommandのキーに使用しています。これによって他のAPIで登録されたコマンドをデプロイしないようにしています。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">object</span> AsyncCommandInvoker { <span class="synType">fun</span> invoke( commands: <span class="synType">List</span>&lt;AsyncCommand&gt;, ){ CommandRepository.createAwaitingDeploy(commands) } } </pre> <p>この時点ではコマンドはデータベースに記録されているだけで、GoogleCloudTasksにタスクが積まれてはいません。トランザクションが終了した後に、処理を追加します。</p> <p>トランザクション終了後にデプロイされていないコマンドがある場合はブローカーAPIを呼び出すタスクを積みます。</p> <p>ここで直接コマンドをタスクに変換するという方法もありますが、GoogleCloudTasksを使用している場合、タスクを積む処理には少し時間がかかることに注意が必要です。特に多くのタスクをデプロイする必要がある場合はユーザーにレスポンスを返すのが遅くなるため避けたほうが良いでしょう。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">suspend</span> <span class="synType">inline</span> <span class="synType">fun</span> &lt;T&gt; runInTransaction( <span class="synType">crossinline</span> block: (handle: Handle) <span class="synType">-&gt;</span> T ): T { <span class="synType">val</span> result: T = block(<span class="synComment">/* 略 */</span>) withHandle { <span class="synComment">// デプロイされていないコマンドを調べる</span> <span class="synType">val</span> commands = CommandRepositoryImpl.getAwaitingDeploy() <span class="synStatement">if</span> (commands.isNotEmpty()) { <span class="synComment">// デプロイされていないコマンドがあればブローカーAPIを呼び出すタスクを1つ作成する</span> AsyncCommandBroker.createTask() } } <span class="synStatement">return</span> result } </pre> <h2 id="ブローカー">ブローカー</h2> <p>ブローカーAPIではデプロイされていないコマンドを順番にデプロイします。冪等性を考慮して、1つのコマンドごとにトランザクションを設定しています。 そして、デプロイが完了したコマンドは <code>command_awaiting_deploy</code> から削除し、 <code>command_deployed</code>にログとして記録しておきます。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> commands = withHandle() { <span class="synType">-&gt;</span> CommandRepository.getAwaitingDeploy() } commands.forEach { command <span class="synType">-&gt;</span> <span class="synComment">// 冪等性を担保するために1つずつコミットする</span> runInTransaction() { <span class="synType">-&gt;</span> createTask(command) CommandRepository.markAsDeploy(command) logger.info(<span class="synConstant">&quot;タスクに作成しました。commandId: </span><span class="synIdentifier">${</span>command.id<span class="synIdentifier">}</span><span class="synConstant">&quot;</span>) } } </pre> <h1 id="導入後">導入後</h1> <ul> <li>課題の1つ目にあったドメイン同士のつながりはドメイン層に書けようになりました。これによってドメイン同士のつながりの調査が容易になり、ドメインがどこから操作されるか分かりやすくなりました。</li> <li>トランザクションが終了してからタスクをキューに積むように修正した事でエンティティが見つからずエラーになる事がなくなりました。</li> </ul> <h1 id="最後に">最後に</h1> <p>説明のために一部のコードを省略・改変していますが、全体的にはOrder Oneで稼働しているコードと同じです。参考になれば幸いです。</p> sansan-yamabe SendGridを活用したメールの送受信機能を開発した話 hatenablog://entry/6801883189082799954 2024-02-21T11:00:00+09:00 2024-02-21T11:07:49+09:00 こんにちは、技術本部 Strategic Products Engineering Unit Order One Devグループの中塚です。 Order Oneの新機能としてメール連携機能をリリースしました。 受注専用アドレスがOrder Oneユーザに対して発行され、そのアドレスに対して注文メールを送信することで、Order One上で直接メール経由の注文書を受信できるようになりました。 また、注文に関するメールでのやり取りもOrder One上でできるようになっています。 この記事では、メール連携機能でどのようにメールの送受信を実現したかを紹介します。 <p>こんにちは、技術本部 Strategic Products Engineering Unit Order One Devグループの中塚です。</p> <p><a href="https://order-one.com/">Order One</a>の新機能としてメール連携機能をリリースしました。</p> <p>受注専用アドレスがOrder Oneユーザに対して発行され、そのアドレスに対して注文メールを送信することで、Order One上で直接メール経由の注文書を受信できるようになりました。 また、注文に関するメールでのやり取りもOrder One上でできるようになっています。</p> <p>この記事では、メール連携機能でどのようにメールの送受信を実現したかを紹介します。</p> <h3 id="どうやって実現しているか">どうやって実現しているか</h3> <p>Order Oneではリリース当初からシステムメールなどのメール配信機能があり、配信基盤として<a href="https://sendgrid.com/">SendGrid</a>を利用しています。</p> <p>SendGridにはメール配信だけでなく、Webhook経由でメールの受信を行える機能もあり、それを活用して送受信全て実現しています。</p> <p>今回は、受信と送信、そして送信結果の永続化 、これらの3つの実現方法を簡単に紹介します。</p> <h3 id="受信">受信</h3> <p>メールの受信には<a href="https://sendgrid.kke.co.jp/docs/API_Reference/Webhooks/parse.html">Inbound Parse Webhook</a>を使っています。</p> <p>処理の流れは次のようにしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-nakatsuka/20240214/20240214180448.png" width="1200" height="198" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <ol> <li>Inbound Parse Webhookから受信メールの内容をCloud Functionsで受け取る</li> <li>Cloud Functionsで受信メールの内容をパースする</li> <li>パースした内容をリクエストとし、Cloud Tasksに受信メール生成のタスクを積み、非同期処理でOrder Oneに登録する</li> </ol> <p>それぞれ簡単に解説します。</p> <p><strong>1. Inbound Parse Webhookから受信メールの内容をCloud Functionsで受け取る</strong></p> <p>Inbound Parse Webhookはメールを受信すると、あらかじめ設定しておいたURLにメールの内容をポストしてくれます。</p> <p>注意点として指定するURLはPublicなURLにする必要があります。 Order OneではInbound Parse Webhookからリクエストを受け取る機能しか持っていないCloud Functionsを用意し、そのURLを指定しています。</p> <p><strong>2. Cloud Functionsで受信メールの内容をパースする</strong></p> <p>受信メールの内容をパースする処理は<a href="https://pkg.go.dev/github.com/sendgrid/sendgrid-go/helpers/inbound#readme-parsedemail-parsedattachments">SendGridのライブラリ</a>を使うことで簡単にパースできます。</p> <p>実装のサンプルです。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">package</span> main <span class="synStatement">import</span> ( <span class="synConstant">&quot;fmt&quot;</span> <span class="synConstant">&quot;log&quot;</span> <span class="synConstant">&quot;net/http&quot;</span> <span class="synConstant">&quot;github.com/sendgrid/sendgrid-go/helpers/inbound&quot;</span> ) <span class="synStatement">func</span> main() { http.HandleFunc(<span class="synConstant">&quot;/inbound&quot;</span>, inboundHandler) <span class="synStatement">if</span> err := http.ListenAndServe(<span class="synConstant">&quot;:8000&quot;</span>, <span class="synStatement">nil</span>); err != <span class="synStatement">nil</span> { log.Fatal(err) } } <span class="synStatement">func</span> inboundHandler(response http.ResponseWriter, request *http.Request) { <span class="synComment">// メールの内容をパース</span> parsedEmail, err := inbound.ParseWithAttachments(request) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { log.Fatal(err) } <span class="synComment">// パースしたメールの内容をアプリケーションの要件に合わせて処理していく</span> fmt.Print(parsedEmail.Envelope.From) fmt.Print(parsedEmail.Envelope.To) fmt.Print(parsedEmail.TextBody) response.WriteHeader(http.StatusOK) } </pre> <p><strong>3. パースした内容をリクエストとし、Cloud Tasksに受信メール生成のタスクを積み、非同期処理でOrder Oneに登録する</strong></p> <p>受信メールをOrder Oneに登録するため、パースした内容をリクエストとするCloud Tasksのタスクを積み、後続の処理へとつないでいきます。</p> <h3 id="送信">送信</h3> <p>送信に関してSendGridのドキュメント以上に特筆すべき点はないため詳しい説明は省きます。 選択肢としては<a href="https://sendgrid.kke.co.jp/docs/API_Reference/SMTP_API/integrating_with_the_smtp_api.html">SMTPでメールを送信する方法</a>と<a href="https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/index.html">SendGridのWeb API</a>を利用する2つがあります。</p> <p>Order Oneではベンダーロックインを避けるため、SMTPでメールを送信する選択をしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-nakatsuka/20240214/20240214180531.png" width="1200" height="360" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>SMTPで送信する場合の注意点としては、Content-Typeをアプリケーションで適切に設定する必要があることです。 特にファイル添付をする場合などは複雑になるため、わかりやすく説明されている次の記事などを参考にすると良いと思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.softel.co.jp%2Fblogs%2Ftech%2Farchives%2F5726" title="multipartなメールの構造は通常どの程度まで複雑な入れ子になるか at softelメモ" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.softel.co.jp/blogs/tech/archives/5726">www.softel.co.jp</a></cite></p> <h3 id="送信結果の永続化">送信結果の永続化</h3> <p>SengGridで送信したメールの送信結果はSendGridのWeb上などで確認できますが、直近の結果しか残らないため、プロダクト上で表示したい場合や調査等で必要な場合は永続化しておく必要があります。</p> <p>そのため、Order Oneでは<a href="https://sendgrid.kke.co.jp/docs/API_Reference/Webhooks/event.html">Event Webhook</a>を使い、送信結果をWebhook経由で取得し、永続化しています。</p> <p>処理の流れは次のようになっています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-nakatsuka/20240214/20240214180553.png" width="1200" height="207" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <ol> <li>Event Webhookから送信結果の内容をCloud Functionsで受け取る</li> <li>Cloud Functionsで認証の確認をする</li> <li>Cloud Tasksに送信結果生成のタスクを積み、非同期処理でOrder Oneに登録する</li> </ol> <p>それぞれ簡単に解説します。</p> <p><strong>1. Event Webhookから送信結果の内容をCloud Functionsで受け取る</strong></p> <p>Event WebhookからのリクエストもInbound Parse Webhookと同様にリクエストを受け取る専用のCloud Functionsを準備しています。</p> <p><strong>2. Cloud Functionsで認証の確認をする</strong></p> <p>Inbound Parse Webhookには認証機能がありませんが、Event Webhookは<a href="https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features">公開鍵暗号やOAuthを使った認証</a>を利用できるため、それらを利用しています。</p> <p>認証の確認は<a href="https://pkg.go.dev/github.com/sendgrid/sendgrid-go@v3.14.0+incompatible/helpers/eventwebhook">SendGridのライブラリ</a>を使うことで簡単に行うことができます。</p> <p>実装のサンプルです。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">package</span> main <span class="synStatement">import</span> ( <span class="synConstant">&quot;log&quot;</span> <span class="synConstant">&quot;net/http&quot;</span> <span class="synConstant">&quot;github.com/sendgrid/sendgrid-go/helpers/eventwebhook&quot;</span> ) <span class="synStatement">func</span> main() { http.HandleFunc(<span class="synConstant">&quot;/inbound&quot;</span>, inboundHandler) <span class="synStatement">if</span> err := http.ListenAndServe(<span class="synConstant">&quot;:8000&quot;</span>, <span class="synStatement">nil</span>); err != <span class="synStatement">nil</span> { log.Fatal(err) } } <span class="synStatement">func</span> inboundHandler(response http.ResponseWriter, request *http.Request) { requestBody, err := eventwebhook.GetRequestBody(eventwebhook.NewSettings()) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { log.Fatal(err) } <span class="synComment">// 実際には環境変数等からbase64PublicKeyを取得して利用する</span> pk, err := eventwebhook.ConvertPublicKeyBase64ToECDSA(<span class="synConstant">&quot;base64PublicKey&quot;</span>) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { log.Fatal(err) } signature := request.Header.Get(<span class="synConstant">&quot;X-Twilio-Email-Event-Webhook-Signature&quot;</span>) timestamp := request.Header.Get(<span class="synConstant">&quot;X-Twilio-Email-Event-Webhook-Timestamp&quot;</span>) authResult, err := eventwebhook.VerifySignature(pk, requestBody, signature, timestamp) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { log.Fatal(err) } <span class="synStatement">if</span> !authResult { log.Fatal(err) } <span class="synComment">// 認証成功後の処理を以降に記述していく</span> response.WriteHeader(http.StatusOK) } </pre> <p><strong>3. Cloud Tasksに送信結果生成のタスクを積み、非同期処理でOrder Oneに登録する</strong></p> <p>パースした内容で送信結果をOrder Oneに登録するため、Cloud Tasksのタスクを積み、後続の処理へとつないでいきます。</p> <h3 id="まとめ">まとめ</h3> <p>メールの配信だけでなく、Inbound Parse Webhook、Event Webhookを活用することでSendGridだけでメールの送受信を実現できます。</p> <p>今回はインフラ寄りの話でしたが、プロダクトでメールの送受信機能を実現するには、メールの仕様についても深く理解し、アプリケーションロジックに落とし込んでいく必要があります。</p> <ul> <li>メールにはどんな項目があり、それぞれどんな意味があるのか?</li> <li>必須項目は何か?どんな値が許容されるのか?</li> <li>メールの繋がりはどうやって表現されているのか?</li> </ul> <p>などなど。</p> <p>実際に運用していくと、アプリケーションロジックでの困りどころやハマりどころにも多く遭遇したので、そういった知見もまた別の記事で紹介したいと思います。</p> sansan-nakatsuka AWS Glueを使ってバッチ処理を60倍高速化した話 hatenablog://entry/6801883189080796437 2024-02-20T11:00:00+09:00 2024-03-15T11:57:00+09:00 初めまして、技術本部Digitization部データ化グループ所属の高田です。 今回はAWS GlueのJobを使ってバッチ処理を60倍高速化した話をします。 この記事は以下の内容を共有しています。 AWS Glueの概要とメリット Apache Sparkの概要とメリット Pythonを使ったAWS Glue ETL Jobの書き方(一例) <p>初めまして、技術本部Digitization部データ化グループ所属の高田です。 今回はAWS GlueのJobを使ってバッチ処理を60倍高速化した話をします。</p> <p>この記事は以下の内容を共有しています。</p> <ul> <li>AWS Glueの概要とメリット</li> <li>Apache Sparkの概要とメリット</li> <li>Pythonを使ったAWS Glue ETL Jobの書き方(一例)</li> </ul> <h2 id="背景">背景</h2> <p>Digitization部は、日々お客さまからお預かりしたアナログ画像をデータ化している部署です。 そのデータを、修正などのために一定期間保持しているのですが、保持しているデータは個人情報保護法に従い削除処理をしています。加えて、削除処理がなんらかの原因で動かなくなった時に備え、存在しているデータが全て保管対象であるかをチェックする処理(削除漏れチェック)もあります。</p> <p>削除漏れチェックの処理の中には、実行に60日間かかっているものがありました。 このチェックに時間がかかる理由は、開発当初から所持するデータ量が増え続け、膨大なデータの付き合わせが考慮されていなかったためです。具体的にはデータの付き合わせがO(n<sup>2</sup>)の処理になっていて、ハッシュなどを利用した効率的な検索ができていないためでした。 この処理を、AWS Glueを使うと1日で完了するようになりました。</p> <h2 id="AWS-Glue-とは">AWS Glue とは</h2> <p><a href="https://aws.amazon.com/jp/glue/">AWS Glue</a> とは、Amazon Web Services(AWS)が提供する完全マネージド型の抽出、変換、ロード(ETL)サービスです。 さまざまなデータソースから抽出・格納ができ、変換にはApache SparkというOSSの分散処理システムが利用できます。 今回Glueを使った意図は以下です。</p> <ul> <li>S3のデータでも、Apache Sparkを使うとRDBMSのように検索ができる(さらに効率的なアルゴリズムで検索されるため早い)</li> <li>Apache Sparkで分散処理ができるので、割り当てるリソースを増やせば短時間で処理が完了する</li> </ul> <h2 id="具体的な処理">具体的な処理</h2> <p>今回は「保持データリストが、全て保持対象リストに含まれるか」をチェックするスクリプトを、ECS上のスケジュールタスク(Ruby)からGlueのジョブ(Python)に書き換えました。</p> <p>前提として、保管データリストと保持対象リストは別プログラムによってS3上に配置しています。 これらのデータはcsv形式でおかれていて、IDを含めたCSV形式で出力されています。 データ群Aとデータ群Bはidのカラムが一致するかを元に判断します。</p> <p>以下は、今回書いたコードのダミーコードです。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> sys <span class="synPreProc">from</span> datetime <span class="synPreProc">import</span> datetime <span class="synPreProc">from</span> awsglue.utils <span class="synPreProc">import</span> getResolvedOptions <span class="synPreProc">from</span> awsglue.context <span class="synPreProc">import</span> GlueContext <span class="synPreProc">from</span> awsglue.job <span class="synPreProc">import</span> Job <span class="synPreProc">from</span> pyspark.context <span class="synPreProc">import</span> SparkContext <span class="synPreProc">from</span> inventory <span class="synPreProc">import</span> Inventory <span class="synComment"># Glueのジョブ実行時に引数を指定できる。</span> args = getResolvedOptions(sys.argv, [<span class="synConstant">&quot;JOB_NAME&quot;</span>, <span class="synConstant">&quot;BUCKET_A&quot;</span>, <span class="synConstant">&quot;BUCKET_B&quot;</span>, <span class="synConstant">&quot;OUTPUT_BUCKET&quot;</span>]) <span class="synComment"># Sparkの初期設定</span> sc = SparkContext() glueContext = GlueContext(sc) glueContext.setConf(<span class="synConstant">&quot;spark.sql.broadcastTimeout&quot;</span>, <span class="synConstant">&quot;36000&quot;</span>) spark = glueContext.spark_session job = Job(glueContext) job.init(args[<span class="synConstant">&quot;JOB_NAME&quot;</span>], args) <span class="synComment"># データ群Aの最新のパスを取得</span> inventory_a = Inventory(args[<span class="synConstant">&quot;BUCKET_A&quot;</span>]).most_recent() <span class="synComment"># データ群Bの最新のパスを取得</span> inventory_b = Inventory(args[<span class="synConstant">&quot;BUCKET_B&quot;</span>]).most_recent() <span class="synComment"># データ群A, Bのテーブルを作成 (Schemaはid以外省略)</span> schema = <span class="synConstant">&quot;id long&quot;</span> data_a = spark.read.csv(f<span class="synConstant">&quot;{inventory_a.s3_directory_path}/*&quot;</span>, schema=schema) data_b = spark.read.csv(f<span class="synConstant">&quot;{inventory_a.s3_directory_path}/*&quot;</span>, schema=schema) data_a.createOrReplaceTempView(<span class="synConstant">&quot;table_a&quot;</span>) data_b.createOrReplaceTempView(<span class="synConstant">&quot;table_b&quot;</span>) <span class="synComment"># Queryを実行し、対象のデータを取得する。</span> query = f<span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant">SELECT id</span> <span class="synConstant">FROM data_a</span> <span class="synConstant">WHERE EXISTS (</span> <span class="synConstant"> SELECT id</span> <span class="synConstant"> FROM data_b</span> <span class="synConstant"> WHERE data_b.id = data_a.id</span> <span class="synConstant">)</span> <span class="synConstant">&quot;&quot;&quot;</span> undeleted_ids = spark.sql(query) undeleted_size = undeleted_ids.count() <span class="synComment"># 結果を出力</span> output_path = f<span class="synConstant">&quot;s3://{args['OUTPUT_BUCKET']}/{datetime.today().strftime('%Y-%m-%d-%H-%M-%S')}&quot;</span> undeleted_ids.write.mode(<span class="synConstant">&quot;overwrite&quot;</span>).csv(output_path, compression=<span class="synConstant">&quot;gzip&quot;</span>, header=<span class="synIdentifier">True</span>) </pre> <p>上記のように、非常に簡単なコードでS3のデータをテーブルのように扱え、SQLで検索できます。 また設定も柔軟で、CPU数やメモリを選べ、従量課金なので今までと違って短時間に多くのリソースを使って処理が終わるようになりました。</p> <p>この結果、60日かかっていた処理が1日以内で終わるようになりました。</p> <h2 id="最後に">最後に</h2> <p>今回はAWS Glueを利用してバッチ処理を高速化する話を書きました。 Glueを使えば、S3だけでなくRedshiftなど別のデータソースもSQLで簡単に比較できるので、 今回のようなビッグデータの検索処理にはうってつけです。 みなさんも是非使ってみてください。</p> tkdalic Apollo GraphQL× Express で Playground ページの URL パスを変更する方法 hatenablog://entry/6801883189078228875 2024-02-16T11:00:00+09:00 2024-02-16T11:22:29+09:00 こんにちは、Sansan Engineering Unit の渡邉です🦐 直近で Apollo GraphQL の Express 向けライブラリを利用して API を開発していたのですが、 Playground ページに個別の URL パスを設定する方法が調べても良いページが見つからなく困ったので記事にまとめてみました🙋 <p>こんにちは、Sansan Engineering Unit の渡邉です🦐<br /> 直近で Apollo GraphQL の Express 向けライブラリを利用して API を開発していたのですが、</p><p> <strong>Playground ページに個別の URL パスを設定する方法</strong></p><p>が調べても良いページが見つからなく困ったので記事にまとめてみました🙋</p> <div class="section"> <h3 id="背景">背景</h3> <p>要件は次のとおりです。</p> <ul> <li>`/graphql` の API は特定の IP アドレス以外からのアクセスを制限できること</li> <li>社内からは API のドキュメント(スキーマ定義など)が閲覧でき、動作確認のために Playground ページで API をクエリできること</li> </ul><p>これらの要件を満たすアイデアとして「Playground ページは `/playground` という URL パスでアクセスできるようにする」という方法を思いつきました。<br /> しかし、<a href="https://www.apollographql.com/docs/apollo-server/api/plugin/landing-pages/">Apollo GraphQL &#x306E;&#x516C;&#x5F0F;&#x30DA;&#x30FC;&#x30B8;</a>に記載されている方法で Playground ページを実装すると、Playground ページに個別の URL パスを設定できないという問題に直面しました。<br /> <br /> </p> </div> <div class="section"> <h3 id="解決方法">解決方法</h3> <p>解決方法の概要は次のとおりです。<br /> <br /> </p> <ol> <li>Playground ページを無効化した Apollo Server インスタンスと Playground ページを有効化した Apollo Server インスタンスの 2 つを作成する</li> <li>1 で作った 2 つのインスタンスをそれぞれ Express ミドルウェアとしてロードする</li> </ol><p>上記を具体的なコードを交えて説明します。</p><p>1. Playground ページを無効化した Apollo Server インスタンスと Playground ページを有効化した Apollo Server インスタンスの 2 つを作成する</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// Playground ページを無効化した Apollo Server インスタンスを作成</span> <span class="synType">const</span> apiServer <span class="synStatement">=</span> <span class="synStatement">new</span> ApolloServer<span class="synStatement">(</span><span class="synIdentifier">{</span> typeDefs<span class="synStatement">,</span> resolvers<span class="synStatement">,</span> <span class="synComment">// Playground ページを無効化</span> plugins: <span class="synIdentifier">[</span>ApolloServerPluginLandingPageDisabled<span class="synStatement">()</span><span class="synIdentifier">]</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synComment">// Playground ページを有効化した Apollo Server インスタンスを作成</span> <span class="synType">const</span> playgroundServer <span class="synStatement">=</span> <span class="synStatement">new</span> ApolloServer<span class="synStatement">(</span><span class="synIdentifier">{</span> typeDefs<span class="synStatement">,</span> resolvers<span class="synStatement">,</span> <span class="synComment">// Playground ページを有効化し、ポーリングを無効化</span> plugins: <span class="synIdentifier">[</span> ApolloServerPluginLandingPageLocalDefault<span class="synStatement">(</span><span class="synIdentifier">{</span> embed: <span class="synIdentifier">{</span> initialState: <span class="synIdentifier">{</span> pollForSchemaUpdates: <span class="synConstant">false</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">),</span> <span class="synIdentifier">]</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> </pre><p>2. 2 つの ApolloServer インスタンスをそれぞれ Express ミドルウェアとしてロードする</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// Playground ページを無効化した Apollo Server インスタンスを Express ミドルウェア関数としてロード</span> app.use<span class="synStatement">(</span><span class="synConstant">&quot;/graphql&quot;</span><span class="synStatement">,</span> bodyParser.json<span class="synStatement">(),</span> expressMiddleware<span class="synStatement">(</span>apiServer<span class="synStatement">));</span> <span class="synComment">// Playground ページを有効化した Apollo Server インスタンスを Express ミドルウェア関数としてロード</span> app.use<span class="synStatement">(</span><span class="synConstant">&quot;/playground&quot;</span><span class="synStatement">,</span> bodyParser.json<span class="synStatement">(),</span> expressMiddleware<span class="synStatement">(</span>playgroundServer<span class="synStatement">));</span> </pre><p><br /> 今回のコードの全体像は以下のようになります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> ApolloServer <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@apollo/server&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> expressMiddleware <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@apollo/server/express4&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> ApolloServerPluginLandingPageDisabled <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@apollo/server/plugin/disabled&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> ApolloServerPluginLandingPageLocalDefault <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;@apollo/server/plugin/landingPage/default&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> express <span class="synStatement">from</span> <span class="synConstant">&quot;express&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> http <span class="synStatement">from</span> <span class="synConstant">&quot;http&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> bodyParser <span class="synStatement">from</span> <span class="synConstant">&quot;body-parser&quot;</span><span class="synStatement">;</span> <span class="synType">const</span> typeDefs <span class="synStatement">=</span> <span class="synConstant">`#graphql</span> <span class="synConstant"> type Book {</span> <span class="synConstant"> title: String</span> <span class="synConstant"> author: String</span> <span class="synConstant"> }</span> <span class="synConstant"> type Query {</span> <span class="synConstant"> books: [Book]</span> <span class="synConstant"> }</span> <span class="synConstant">`</span><span class="synStatement">;</span> <span class="synType">const</span> books <span class="synStatement">=</span> <span class="synIdentifier">[</span> <span class="synIdentifier">{</span> title: <span class="synConstant">&quot;The Awakening&quot;</span><span class="synStatement">,</span> author: <span class="synConstant">&quot;Kate Chopin&quot;</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> title: <span class="synConstant">&quot;City of Glass&quot;</span><span class="synStatement">,</span> author: <span class="synConstant">&quot;Paul Auster&quot;</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">]</span><span class="synStatement">;</span> <span class="synType">const</span> resolvers <span class="synStatement">=</span> <span class="synIdentifier">{</span> Query: <span class="synIdentifier">{</span> books: <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> books<span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synType">const</span> app <span class="synStatement">=</span> express<span class="synStatement">();</span> <span class="synType">const</span> httpServer <span class="synStatement">=</span> http.createServer<span class="synStatement">(</span>app<span class="synStatement">);</span> <span class="synType">const</span> apiServer <span class="synStatement">=</span> <span class="synStatement">new</span> ApolloServer<span class="synStatement">(</span><span class="synIdentifier">{</span> typeDefs<span class="synStatement">,</span> resolvers<span class="synStatement">,</span> plugins: <span class="synIdentifier">[</span>ApolloServerPluginLandingPageDisabled<span class="synStatement">()</span><span class="synIdentifier">]</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synStatement">await</span> apiServer.start<span class="synStatement">();</span> <span class="synType">const</span> playgroundServer <span class="synStatement">=</span> <span class="synStatement">new</span> ApolloServer<span class="synStatement">(</span><span class="synIdentifier">{</span> typeDefs<span class="synStatement">,</span> resolvers<span class="synStatement">,</span> plugins: <span class="synIdentifier">[</span> ApolloServerPluginLandingPageLocalDefault<span class="synStatement">(</span><span class="synIdentifier">{</span> embed: <span class="synIdentifier">{</span> initialState: <span class="synIdentifier">{</span> pollForSchemaUpdates: <span class="synConstant">false</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">),</span> <span class="synIdentifier">]</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synStatement">await</span> playgroundServer.start<span class="synStatement">();</span> app.use<span class="synStatement">(</span><span class="synConstant">&quot;/graphql&quot;</span><span class="synStatement">,</span> bodyParser.json<span class="synStatement">(),</span> expressMiddleware<span class="synStatement">(</span>apiServer<span class="synStatement">));</span> app.use<span class="synStatement">(</span><span class="synConstant">&quot;/playground&quot;</span><span class="synStatement">,</span> bodyParser.json<span class="synStatement">(),</span> expressMiddleware<span class="synStatement">(</span>playgroundServer<span class="synStatement">));</span> <span class="synStatement">await</span> <span class="synStatement">new</span> <span class="synSpecial">Promise</span><span class="synStatement">&lt;</span><span class="synType">void</span><span class="synStatement">&gt;((</span>resolve<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> httpServer.listen<span class="synStatement">(</span><span class="synIdentifier">{</span> port: <span class="synConstant">3333</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> resolve<span class="synStatement">)</span> <span class="synStatement">);</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span> <span class="synConstant">`🚀 API Server ready at [POST] http://localhost:3333/graphql, Playground Server ready at [GET] http://localhost:3333/playground`</span> <span class="synStatement">);</span> </pre> </div> <div class="section"> <h3 id="注意事項">注意事項</h3> <p>今回はあくまで社内向けの API だったので、可用性よりもコストやスピードを重視して</p><p> <strong>Playground ページを無効化した ApolloServer と Playground ページを有効化した ApolloServer を同一のアプリケーションで動かす</strong></p><p>という手段を取りましたが、お客様向けの API など、可用性が重視される場面では十分注意する必要があります。<br /> <br /> </p> </div> <div class="section"> <h3 id="まとめ">まとめ</h3> <p>この記事では、Apollo GraphQL を Express で扱う場合において、Playground ページの URL パスを変更する方法について、ApolloServer のインスタンスを 2 つ作成し、それぞれを Express ミドルウェアとして個別にロードすることで実現しました。</p><p>私たちのチームでは Web アプリ開発エンジニアを募集しています。Web アプリ開発だけではなくデータエンジニアリングなど、幅広い業務を経験できます!</p><p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76521" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Web%E3%82%A2%E3%83%97%E3%83%AA%E9%96%8B%E7%99%BA%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2%EF%BC%BB%E3%83%87%E3%83%BC%E3%82%BF%E6%88%A6%E7%95%A5%EF%BC%BD"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76521">open.talentio.com</a></cite></p><br /> <p></p> </div> <div class="section"> <h3 id="参考">参考</h3> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.apollographql.com%2Fdocs%2Fapollo-server%2Fgetting-started%2F" title="Get started with Apollo Server" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.apollographql.com/docs/apollo-server/getting-started/">www.apollographql.com</a></cite></p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fapollographql%2Fapollo-server%2Fissues%2F1908%23issuecomment-457262340" title="Support a different URL for playground · Issue #1908 · apollographql/apollo-server" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/apollographql/apollo-server/issues/1908#issuecomment-457262340">github.com</a></cite></p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fpatrickdesjardins.com%2Fblog%2Fconfiguring-apollo-playground-and-api-on-two-different-url" title="Patrick Desjardins Blog - Configuring Apollo Playground and API on two different URL" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://patrickdesjardins.com/blog/configuring-apollo-playground-and-api-on-two-different-url">patrickdesjardins.com</a></cite></p> </div> yuuta040208 Eight iOSアプリにおけるNFCを利用したタッチ入場機能の開発 hatenablog://entry/6801883189081017889 2024-02-15T11:00:00+09:00 2024-02-15T11:16:02+09:00 技術本部Mobile Applicationグループの藤門です。 23卒としてSansan株式会社に入社し、iOS版のEightの開発に従事しています。今回の記事の目次は以下の通りです。 本記事の概要 BIS 2024について タッチ入場受付機能について タッチ入場受付機能でNFCを利用した経緯 Background Tag Readingについて タッチ入場受付機能でBackground Tag Readingを採用した理由 Background Tag Readingを利用した実装方法 Background Tag Readingの使い所 おわりに 参考リンク 本記事の概要 本記事では、E… <p>技術本部Mobile Applicationグループの藤門です。<br /> 23卒としてSansan株式会社に入社し、iOS版のEightの開発に従事しています。</p><p>今回の記事の目次は以下の通りです。</p> <ul class="table-of-contents"> <li><a href="#本記事の概要">本記事の概要</a></li> <li><a href="#BIS-2024について">BIS 2024について</a></li> <li><a href="#タッチ入場受付機能について">タッチ入場受付機能について</a></li> <li><a href="#タッチ入場受付機能でNFCを利用した経緯">タッチ入場受付機能でNFCを利用した経緯</a></li> <li><a href="#Background-Tag-Readingについて">Background Tag Readingについて</a></li> <li><a href="#タッチ入場受付機能でBackground-Tag-Readingを採用した理由">タッチ入場受付機能でBackground Tag Readingを採用した理由</a></li> <li><a href="#Background-Tag-Readingを利用した実装方法">Background Tag Readingを利用した実装方法</a></li> <li><a href="#Background-Tag-Readingの使い所">Background Tag Readingの使い所</a></li> <li><a href="#おわりに">おわりに</a></li> <li><a href="#参考リンク">参考リンク</a></li> </ul> <div class="section"> <h3 id="本記事の概要">本記事の概要</h3> <p>本記事では、Eight主催のBusiness IT & SaaS Expo2024(以下BIS 2024)において、Eightのタッチ入場受付機能に焦点を当て、その開発背景や実装方法および利用技術の使い所について紹介します。</p> </div> <div class="section"> <h3 id="BIS-2024について">BIS 2024について</h3> <p>BIS 2024は、名刺アプリ「Eight」のタッチ機能を活用して、受付から名刺交換までをスマートフォン一つで完結できる展示会です。<br /> 2024年1月11日から12日までの2日間にわたり開催され、商談やセミナー、来場者同士の交流会などが実施され、来場者の方からも高い評価をいただきました。</p><p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Feight-event.8card.net%2Fbis%2F2024winter%2F" title="BIS 2024(ビジネスIT &amp; SaaS EXPO)-営業マーケDX 比較・導入展-" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://eight-event.8card.net/bis/2024winter/">eight-event.8card.net</a></cite><br /> </p> </div> <div class="section"> <h3 id="タッチ入場受付機能について">タッチ入場受付機能について</h3> <p>タッチ入場受付機能は、EightをインストールしたスマートフォンをNFCタグが組み込まれた受付用の看板に触れるだけで、受付が完了し、プリンターから入場証が印刷される機能です。</p> <figure class="figure-image figure-image-fotolife" title="BISの受付"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/R/RioFujimon/20240206/20240206162658.png" width="1200" height="889" loading="lazy" title="" class="hatena-fotolife" style="width:400px" itemprop="image"></span><figcaption>BISの受付(看板・プリンタ)</figcaption></figure> </div> <div class="section"> <h3 id="タッチ入場受付機能でNFCを利用した経緯">タッチ入場受付機能でNFCを利用した経緯</h3> <p>今回のタッチ入場受付機能の実装にあたり、スマートフォンのBluetooth機能とNFC機能のどちらを利用して実現するかを検討しました。<br /> その理由として、Eightは以前にBluetoothを活用したタッチ名刺交換機能をリリースしており、その知見を活かして、タッチ入場受付機能も実現できるのではないかと考えたからです。</p><p>しかし、Bluetoothを用いる場合、来場者が並んで入場する際にスマートフォン同士が電波干渉を起こす可能性があり、安定性や実現性に懸念がありました。</p><p>そこで、今回はNFC機能を利用して実現することを決定しました。</p><p>※ タッチ名刺交換については以下のリンクを参照ください。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjp.corp-sansan.com%2Fnews%2F2023%2F0926.html" title="Eightが新機能「タッチ名刺交換」を提供開始~紙の名刺の課題を解決し、 330万ユーザーの名刺交換体験が進化~ | Sansan株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jp.corp-sansan.com/news/2023/0926.html">jp.corp-sansan.com</a></cite><br /> </p> </div> <div class="section"> <h3 id="Background-Tag-Readingについて">Background Tag Readingについて</h3> <p>iOSプログラミングでNFC機能を利用しようと考えた際、読者の皆さんが真っ先に思いつく方法としては、Appleが提供するフレームワークであるCore NFCを利用する方法だと思います。</p><p>しかし、今回は「Background Tag Reading」というiPhone XS以降の機種に標準で搭載されている機能を活用し、Core NFCを利用せずに実装しました。<br /> 「Background Tag Reading」は、iOSのシステムがNFCタグを読み取り、新しいタグを読み取るたびにポップアップ通知が表示され、その通知をユーザーがタップするとNFCタグのデータが適切なアプリに送られるという機能です。</p><p>Background Tag Readingについて、さらに詳しく知りたい場合は、Appleの以下の記事をご覧ください。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fcorenfc%2Fadding_support_for_background_tag_reading" title="Adding Support for Background Tag Reading | Apple Developer Documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.apple.com/documentation/corenfc/adding_support_for_background_tag_reading">developer.apple.com</a></cite><br /> </p> <figure class="figure-image figure-image-fotolife" title="Background Tag Reading機能"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/R/RioFujimon/20240206/20240206160659.png" width="571" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:200px" itemprop="image"></span><figcaption>NFCタグを読み取ると、OSにより自動通知される</figcaption></figure> </div> <div class="section"> <h3 id="タッチ入場受付機能でBackground-Tag-Readingを採用した理由">タッチ入場受付機能でBackground Tag Readingを採用した理由</h3> <p>今回のタッチ入場受付機能で「Background Tag Reading」を活用したのは、実装工数、Eightユーザーにおける「Background Tag Reading」に対応した機種の割合、およびUIデザインの3点を総合的に考慮した結果です。</p><p>まずはじめに、実装の工数についてです。タッチ入場受付に必要な「NFCタグからデータを読み取る機能」は、Core NFCを活用してプログラミングするよりも最速のリリースが実現できると判断したためです。</p><p>次に、Eightユーザーが利用している機種における「Background Tag Reading」対応の割合は、「Background Tag Reading」に対応したiPhoneXS以降の機種の割合が、9割を超えています。そのため、残りの1割未満のユーザーは、iPhoneの標準カメラアプリの機能であるQRコード読み取りで対応してもらおうと判断しました。</p><p>最後に、Core NFCを採用した場合、Apple標準のCore NFCのReaderのUIが表示され、タッチ名刺交換との体験が大幅に異なってしまうため「Background Tag Reading」を選択しています。</p> <figure class="figure-image figure-image-fotolife" title="Core NFCを利用するとReaderのUIが表示されてしまう"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/R/RioFujimon/20240209/20240209155006.png" width="528" height="1050" loading="lazy" title="" class="hatena-fotolife" style="width:200px" itemprop="image"></span><figcaption>Core NFCを利用するとReaderのUIが表示されてしまう</figcaption></figure><p>ここで1点注意ですが、Background Tag Readingを利用することで、Core NFCのReaderのUI表示を防ぐことが可能ですが、NFCタグ読み込み時に出現するポップアップ通知は、カスタマイズできない点は許容する必要があります。</p> </div> <div class="section"> <h3 id="Background-Tag-Readingを利用した実装方法">Background Tag Readingを利用した実装方法</h3> <p>まず、NFCタグにUniversal LinksのURLを書き込んでおきます。<br /> また、URLにはクエリパラメータとして、イベントを識別するIDとプリンターを識別するIDを設定しておきます。<br /> ※「Background Tag Reading」の機能を利用して、NFCタグが読み取れないユーザーのために、Universal LinksのURLをQRコードに変換しておきました。</p><p>次に、NFCタグを読み込む処理ですが、「Background Tag Reading」のおかげで、実装する必要はありません。</p><p>最後に、読み込んだUniversal LinksのURLをAppDelegateのapplication(_:continue:restorationHandler:)でハンドリングします。<br /> application(_:continue:restorationHandler:)では、Universal LinksのURLを解析し、イベントとプリンターのIDをServerに送信する処理や受付完了のダイアログを表示する処理を実装するだけです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">application</span>(_ application<span class="synSpecial">:</span> <span class="synType">UIApplication</span>, <span class="synStatement">continue</span> userActivity<span class="synSpecial">:</span> <span class="synType">NSUserActivity</span>, restorationHandler<span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> ([</span><span class="synType">Any</span><span class="synSpecial">]</span><span class="synType">?</span><span class="synSpecial">)</span> <span class="synSpecial">-&gt;</span> <span class="synType">Void</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> { <span class="synComment">// URLを取得</span> <span class="synPreProc">let</span> <span class="synIdentifier">url</span> <span class="synIdentifier">=</span> userActivity.webpageURL <span class="synComment">// URLを解析</span> <span class="synComment">// クエリパラメータをServerに送信</span> <span class="synComment">// ダイアログを表示</span> <span class="synStatement">return</span> <span class="synConstant">true</span> } </pre> </div> <div class="section"> <h3 id="Background-Tag-Readingの使い所">Background Tag Readingの使い所</h3> <p>タッチ入場受付機能でBackground Tag Readingを利用してみて、使い所としては、以下のようなケースに該当する場合であると感じました。</p> <blockquote> <ul> <li><strong>NFCの読み取り機能だけが必要</strong></li> <li><strong>実装コストをできるだけ削減したい</strong></li> <li><strong>ユーザーの機種割合では、iPhoneXS以降が大部分を占めている</strong></li> <li><strong>Core NFCのReaderのUIは利用したくない</strong></li> </ul> </blockquote> <p>上記のようなケースに該当する場合は、Core NFCを利用してプログラミングをするよりもコストをかけずに実装が行えるため、Background Tag Readingを利用する価値が十分あると思います。</p> </div> <div class="section"> <h3 id="おわりに">おわりに</h3> <p>一緒にSansan / Eightのモバイルアプリ開発していく仲間を募集中です!<br /> 選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです!</p><p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76610" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+iOS%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76610?_gl=1*ls27g3*_ga*NTc2MjAzMTkwLjE2ODA2NjAyNjQ.*_ga_EN18Q6V0JW*MTcwNzE4ODg4Ny40LjAuMTcwNzE4ODg4Ny4wLjAuMA">open.talentio.com</a></cite></p><p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76400" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Android%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76400">open.talentio.com</a></cite><br /> </p> </div> <div class="section"> <h3 id="参考リンク">参考リンク</h3> <ul> <li><a href="https://developer.apple.com/documentation/corenfc">https://developer.apple.com/documentation/corenfc</a></li> <li><a href="https://developer.apple.com/documentation/corenfc/adding_support_for_background_tag_reading">https://developer.apple.com/documentation/corenfc/adding_support_for_background_tag_reading</a></li> <li><a href="https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html">https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html</a></li> </ul> </div> RioFujimon バックログへの向き合い方の変遷 hatenablog://entry/6801883189079140704 2024-02-13T11:00:00+09:00 2024-02-13T11:59:41+09:00 こんにちは、Eight事業の開発責任者の大熊です。 Eight事業では、ビジネスパーソン向けの名刺アプリ「Eight」を中心にそのプラットフォーム上でさまざまなサービスを展開しています。最近ではビジネスイベントとITを掛け合わせた新しいイベント体験を創出するという挑戦に取り組んでいます。開発組織が対峙するビジネス状況はBtoC、BtoB、あるいはモバイルアプリ、ビジネスイベントとバラエティ豊かです。そんなEight事業ですが、事業状況に合わせてバックログへの向き合い方も都度変化してきました。この記事では、過去の取り組みを通じてうまくいったこと、いかなかったこと含めてその変遷をまとめ、今どうして… <p>こんにちは、Eight事業の開発責任者の大熊です。<br /> <br /> Eight事業では、ビジネスパーソン向けの名刺アプリ「Eight」を中心にそのプラットフォーム上でさまざまなサービスを展開しています。最近ではビジネスイベントとITを掛け合わせた新しいイベント体験を創出するという挑戦に取り組んでいます。開発組織が対峙するビジネス状況はBtoC、BtoB、あるいはモバイルアプリ、ビジネスイベントとバラエティ豊かです。</p><p>そんなEight事業ですが、事業状況に合わせてバックログへの向き合い方も都度変化してきました。この記事では、過去の取り組みを通じてうまくいったこと、いかなかったこと含めてその変遷をまとめ、今どうしているかをお伝えします。<br /> ※「バックログ」はビジネスやプロダクトで実現したいことリスト、「アイテム」は具体的な開発対象とここでは定義しています。</p> <div class="section"> <h4 id="ビジネスごとのチームアサイン">ビジネスごとのチームアサイン</h4> <p>ビジネスごとにバックログがあり、一つのチームがアサインされます。<br /> <br /> 個人向けの名刺アプリである「Eight」バックログ、中小企業向け名刺管理サービスの「Eight Team」バックログ、プロフェッショナルリクルーティングを提供する「Eight Career Design」バックログがあり、それぞれにチームがアサインされていました。</p><p>図で書くとこのような形です。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/Sansan_tech/20240209/20240209132526.png" width="1005" height="298" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p>対象領域に完結して意思決定を行える、担当するチームはその領域の習熟度が高まり生産性が上がっていくことがメリットです。領域をまたがる開発では、複数のバックログに分散してアイテムを積むことになります。しかし、各バックログはそれぞれのビジネスにおける優先度でアイテムが積まれているため、既に大きな開発案件が動いていると他ビジネスからのアイテムの優先度を上げにくく、理想的なタイミングで開発着手できないこともありました。</p><p>デメリットはありつつも、基本的には独立して開発を進められるプロジェクトが多くうまく回っていました。事業状況の変化に伴い一部のバックログとチームを統合したりもしましたが、2023年5月まで採用していた形です。</p><p>今はビジネスごとではなく「Eight」として一つのバックログになっています。こちらは、この後でまた記載します。<br /> </p> </div> <div class="section"> <h4 id="複数ビジネスのバックログを一つのチームにアサイン">複数ビジネスのバックログを一つのチームにアサイン</h4> <p>複数ビジネスのバックログが一つのチームにアサインされます。これはデメリットがメリットを上回ってしまった事例です。<br /> <br /> Eight事業として広告ビジネスを行っていた時期に採用していました(注:広告ビジネスは2023年にクローズしています)。ビジネスの立ち上がりフェーズではイベントビジネスと広告ビジネスの両方に責任を持つ1人のプロダクトマネジャーがいて、その優先度判断のもとで一つのチームが開発を担当していました。<br /> <br /> しかし、事業状況が変わりイベントと広告に異なるプロダクトマネジャーが立つようになりました。つまり、ビジネス的にはバックログが二つになりました。このタイミングでチームを分ける手段もありましたが、習熟度やエンジニアリソースの都合もあり従来の開発体制を維持しました。チームが二つのバックログを見るため、各ビジネスのアイテムをチームのバックログ上に並べ直すことになります。この営みを四半期、月次のタイミングで概算工数やビジネスインパクトを踏まえて実施していました。</p><p>図としてはこのような形になります。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/Sansan_tech/20240209/20240209132619.png" width="1005" height="533" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p><p> <br /> 元々担当していたチームがそのまま両ビジネスに対応するので、知識のリセットを起こさずに開発を継続できることがメリットです。また、イベントビジネスはイベント開催に向けたスケジュールに沿った開発が中心となるため、繁忙期に合わせて柔軟にリソースを当てられるメリットもありました。<br /> <br /> しかし、対面するビジネスが二つある状況のため、優先度の調整が課題でした。イベントビジネスの開発ではイベント開催に向けたスケジュールがあり、容易にそのスケジュールを動かしにくい事情があります。また四半期や月次の計画時点での企画や見積もりはどうしても粗いものにしかできず、想定以上に工数がかかってしまうことや、作る中で改善点が明らかになり公開ギリギリで追加対応することが多々発生しました。それ自体は一定仕方ないことですが、その際に他のビジネスにも影響が出てくることが調整を複雑にしていました。</p><p>また、イベントビジネスと広告ビジネスのコンテキストスイッチの弊害が目立つようになりました。ビジネス要求の進化につれてシステム側の対応も高度になっていきます。一方で、運用やインシデント対応は仕掛かり中のアイテムがなんであれ必要に応じて対応が求められます。チームとして仕掛かり中のアイテムに集中することが難しくなり、それに起因して生産性や品質への影響が懸念される状況でした。<br /> <br /> 優先度決めの複雑さやコンテキストスイッチの弊害がリソース効率のメリットを打ち消すほどになったため、ビジネスごとにバックログを分け、それぞれにチームをアサインする体制に変更しました。各ビジネスに当てる工数の目安は決まっていたため、それに従ったアサインとしました。結果としてコンテキストスイッチがなくなり、運用やインシデント対応はあれど一つのビジネス領域の問題に集中できるようになりました。またチームが扱う領域を固定したことで、ビジネス要求が落ち着いている時はエンジニアドリブンで施策を提案する動きを生むこともできました。<br /> </p> </div> <div class="section"> <h4 id="一つのプロダクトに複数チームをアサイン">一つのプロダクトに複数チームをアサイン</h4> <p>「Eight」プロダクト上で実現されるすべてのアイテムを一つのバックログで管理し、複数チームがアサインされます。<br /> <br /> 現在の「Eight」開発で採用している体制です。プラットフォーム上で展開されるビジネスによらず、「Eight」で実現する全てのアイテムが一つのバックログで管理されています。つまり、一つの優先度軸で判断されています。図に表すまでもないくらいシンプルです。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/Sansan_tech/20240209/20240209132713.png" width="1005" height="296" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><br /> <br /> きっかけは事業状況の変化です。個別のビジネスごとではなく「Eight」として優先度を判断したい欲求が強くなりました。バックログが分かれていれば、バックログ単位に見れば優先度順に着手できます。しかし、プロダクト全体としてみた時の優先度と乖離してしまう懸念もあります。特に、2023年9月にリリースしたV10プロジェクトでは、ブランディングの変更も伴うためプロダクト全体に手を入れていく必要がありました。一方で、Eight TeamやEight Career Designの施策、コスト削減の施策といった要求も同時に求められていました。もはや特定のビジネスだけ見た優先度判断は成り立ちません。</p><p>そこで、各プロダクトマネジャーが担当していた領域の壁をなくし、「Eight」のビジネスオーナーの方針のもと全員で「Eight」プロダクトに向き合うことになりました。プロダクトマネジャーは課題の定義とビジネス状況に合わせた優先度を決める、開発組織はその優先度を踏まえた最適な開発計画を立てることに集中できます。開発計画を立てる中で、あるリリース対象としてどのアイテムまで完了させるか、開発効率やリスクを踏まえてどの順番で着手するかをプロダクトマネジャーも交えて議論します。もちろん複数のビジネスがある中でその優先度を決めること自体に難しさがあり、優先度順に複数チームでアイテムを完了させていくことも簡単ではありません。これ自体にいろいろなチャレンジがあり、今まさに向き合っている課題もありますが、それは3月に開催予定のEight開発プロセスの実践についてお話しするイベントに譲ります。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsansan.connpass.com%2Fevent%2F309010%2F" title="持続可能で柔軟な開発プロセスの実践 (2024/03/27 19:00〜)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://sansan.connpass.com/event/309010/">sansan.connpass.com</a></cite></p><p> </p> </div> <div class="section"> <h4 id="まとめ">まとめ</h4> <p>以上がEightにおけるバックログへの向き合い方の変遷です。<br /> <br /> ビジネスの状況変化に合わせて、その時点で最適と思われる形を選んできました。今振り返ってみると失敗と思える判断もありますし、現在進行形で選択しているプロセスも改善の余地はあります。しかし、一貫してビジネス状況に臨機応変に対応しながら最短で価値を届けることを目指してきました。</p><p>そんなEightでは、一緒に開発してくださる仲間を募集しています。<br /> 事業状況に柔軟に、そして最短でプロダクトデリバリーを実現する組織を一緒に目指しませんか?<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmedia.sansan-engineering.com%2Feight-engineer" title="名刺アプリ「Eight」" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://media.sansan-engineering.com/eight-engineer">media.sansan-engineering.com</a></cite></p> </div> hokumah アプリ開発者が Privacy Manifests 対応でやることについて調べてみた hatenablog://entry/6801883189074468324 2024-02-09T17:30:00+09:00 2024-02-09T18:47:45+09:00 はじめに こんにちは。技術本部 Mobile Application グループで iOS アプリエンジニアをやっている多鹿です。 さて、 WWDC 2023 にて Privacy Manifests が発表されましたね。そして、2024年春にはこの対応がされていないアプリはリジェクト対象になるというではありませんか。 ある日突然リジェクトされて慌てたくはないので、事前にどのような対応が必要か調べてみました。 <h2 id="はじめに">はじめに</h2> <p>こんにちは。技術本部 Mobile Application グループで iOS アプリエンジニアをやっている<a href="https://twitter.com/Yut_Taj">多鹿</a>です。</p> <p>さて、 WWDC 2023 にて Privacy Manifests が発表されましたね。そして、2024年春にはこの対応がされていないアプリはリジェクト対象になるというではありませんか。<br/> ある日突然リジェクトされて慌てたくはないので、事前にどのような対応が必要か調べてみました。</p> <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#Privacy-Manifests-とは">Privacy Manifests とは?</a></li> <li><a href="#アプリ開発者がやるべきこと">アプリ開発者がやるべきこと</a><ul> <li><a href="#最初にお断りを">最初にお断りを、、</a></li> <li><a href="#本記事における解釈について">本記事における解釈について</a></li> <li><a href="#やるべきことの概要">やるべきことの概要</a></li> <li><a href="#1-アプリ側で-Privacy-Manifests-の宣言をする">1. アプリ側で Privacy Manifests の宣言をする</a><ul> <li><a href="#Privacy-Manifests-ファイルの作成">Privacy Manifests ファイルの作成</a><ul> <li><a href="#NSPrivacyTracking">NSPrivacyTracking</a></li> <li><a href="#NSPrivacyTrackingDomains">NSPrivacyTrackingDomains</a></li> <li><a href="#NSPrivacyCollectedDataTypes">NSPrivacyCollectedDataTypes</a></li> <li><a href="#NSPrivacyAccessedAPITypes">NSPrivacyAccessedAPITypes</a></li> </ul> </li> </ul> </li> <li><a href="#2-アプリが利用している-Third-party-SDK-を-Privacy-Manifests-対応バージョンにアップデートする">2. アプリが利用している Third-party SDK を Privacy Manifests 対応バージョンにアップデートする</a><ul> <li><a href="#Apple-が公表した一般的に使用される-Third-party-SDKについて">Apple が公表した「一般的に使用される Third-party SDK」について</a><ul> <li><a href="#SDK-開発者の対応">SDK 開発者の対応</a></li> <li><a href="#アプリ開発者の対応">アプリ開発者の対応</a></li> </ul> </li> <li><a href="#その他の-Third-party-SDK-について">その他の Third-party SDK について</a><ul> <li><a href="#SDK-開発者の対応-1">SDK 開発者の対応</a></li> <li><a href="#アプリ開発者の対応-1">アプリ開発者の対応</a></li> </ul> </li> </ul> </li> <li><a href="#アプリ内や-Third-party-SDK-で利用されている-Required-reason-API-を調査する方法">アプリ内や Third-party SDK で利用されている Required reason API を調査する方法</a><ul> <li><a href="#方法-1-ソースコード内の文字列を検索">方法 1. ソースコード内の文字列を検索</a></li> <li><a href="#方法-2-アプリや-SDK-のバイナリのシンボルを検索">方法 2. アプリや SDK のバイナリのシンボルを検索</a></li> <li><a href="#検出ツールスクリプトの紹介">検出ツール(スクリプト)の紹介</a><ul> <li><a href="#もう一歩踏み込んでみる">もう一歩踏み込んでみる</a></li> </ul> </li> </ul> </li> </ul> </li> <li><a href="#Privacy-Manifests-対応調査時に出てきた疑問点">Privacy Manifests 対応調査時に出てきた疑問点</a></li> <li><a href="#おわりに">おわりに</a></li> <li><a href="#仲間募集中">仲間募集中!</a></li> </ul> <h2 id="Privacy-Manifests-とは">Privacy Manifests とは?</h2> <p><a href="https://developer.apple.com/videos/play/wwdc2023/10060/">WWDC 2023 にて発表された</a>プライバシーに関する機能です。これまでもアプリ内でのプライバシー情報の扱いに関してはユーザに対して明確になるように求められてきました。Privacy Manifests によって Third-party SDK も含めてより透明度を上げるような要件が追加されます。</p> <p>これまで App Store Connect 上でもアプリが扱うプライバシーデータはユーザに分かるように明確に設定していましたが、Privacy Manifests ではそれらをファイルとして定義するようになります。</p> <p>また、前述の通り、2024年春以降に Privacy Manifests への対応が審査観点として追加され、要件を満たさないアプリを審査に出した場合はリジェクト対象となります。(審査時に見られるものなので、既にストアに出ているアプリに対する影響はなさそうです。)</p> <p>そして、それだけでなく <a href="https://developer.apple.com/jp/support/terms/apple-developer-program-license-agreement#b333">Apple Developer Program License Agreement</a> にも Privacy Manifests に関連する要件が追加されています。この対応を適切に行うことは開発者の責務であり、対応しないことは Apple Developer Program License に違反することになってしまいます。</p> <h2 id="アプリ開発者がやるべきこと">アプリ開発者がやるべきこと</h2> <p>ということで、アプリ開発者として何をやるべきかを調べてみました。</p> <h3 id="最初にお断りを">最初にお断りを、、</h3> <p>まずお断りとなりますが、本記事の執筆時点において Apple から公開されている情報には明瞭さに欠ける部分があり、解釈の幅が持てるような状態となっています。<br/> そのため、誤った解釈を含む可能性があることをご了承ください。<br/> また、執筆内容は調査内容の共有のみであり、実際に調査内容をアプリに反映させて審査に提出するには至っておりません。</p> <h3 id="本記事における解釈について">本記事における解釈について</h3> <p>前置きが続き申し訳ないですが、前述のように明瞭な解釈をしづらい状況の中で執筆しているため、本記事で筆者が情報をどのように解釈するのかについて先に述べておきます。</p> <p>本記事では公開情報の内容を <strong>悲観的に</strong><a href="#f-6c3c3d04" id="fn-6c3c3d04" name="fn-6c3c3d04" title="開発者として対応事項が多くなるという意味であり、もちろんユーザから見て悲しむべきことではないです。">*1</a> 捉えて、やるべきことを整理します。 <br/> 例えば、公開情報から得られる情報の中で対応事項が <strong>OPTIONAL</strong> なのか <strong>MUST</strong> なのか判断つきづらい場合は <strong>MUST</strong> と捉えます。<br/> この解釈の仕方のトレードオフとして、 <strong>OPTIONAL</strong> だった場合、本来不要だった過剰な対応に工数を割いてしまうことが挙げられますが、楽観的に捉えて <strong>MUST</strong> を <strong>OPTIONAL</strong> と誤解釈してしまう場合のリスクの方が大きいと判断してこのように捉えることとしました。</p> <h3 id="やるべきことの概要">やるべきことの概要</h3> <p>アプリ開発者がやるべきことは大きく分けて <strong>2つ</strong> です。</p> <ol> <li>アプリ側で Privacy Manifests の宣言をする</li> <li>アプリが利用している Third-party SDK を Privacy Manifests 対応バージョンにアップデートする</li> </ol> <p>それぞれについて見ていきましょう。</p> <h3 id="1-アプリ側で-Privacy-Manifests-の宣言をする">1. アプリ側で Privacy Manifests の宣言をする</h3> <p>アプリのプロジェクトに <code>PrivacyInfo.xcprivacy</code> というプロパティリスト形式のファイルを作成し、そこに必要な情報を宣言します。</p> <h4 id="Privacy-Manifests-ファイルの作成">Privacy Manifests ファイルの作成</h4> <p>ファイルの作成方法についてはドキュメントや他の第三者の方の記事でも触れられており、それほど難しくもないのでここでは割愛します。<br/> 注意点としてはファイル名は <code>PrivacyInfo.xcprivacy</code> から変えてはいけないということと、Xcode 15 以降でないと作成できないくらいかなと思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fbundleresources%2Fprivacy_manifest_files" title="Privacy manifest files | Apple Developer Documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.apple.com/documentation/bundleresources/privacy_manifest_files">developer.apple.com</a></cite></p> <p>この <code>PrivacyInfo.xcprivacy</code> には次の 4つのキーを宣言します。</p> <ol> <li>NSPrivacyTracking</li> <li>NSPrivacyTrackingDomains</li> <li>NSPrivacyCollectedDataTypes</li> <li>NSPrivacyAccessedAPITypes</li> </ol> <p><figure class="figure-image figure-image-fotolife" title="Xcode 上での &#x60;PrivacyInfo.xcprivacy&#x60; の見え方のサンプル"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/taji-taji/20240201/20240201164543.png" width="1200" height="853" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Xcode 上での PrivacyInfo.xcprivacy の見え方のサンプル</figcaption></figure></p> <p>それぞれのキーがどういったものかを見てみましょう。</p> <h5 id="NSPrivacyTracking">NSPrivacyTracking</h5> <ul> <li>データ形式 <ul> <li>ブール値</li> </ul> </li> </ul> <p>App Tracking Transparency framework で追跡データとして定義されているデータを使用する場合は true にします。<br/> 広告ID (IDFA) を利用している場合などは true にする必要がありそうです。</p> <h5 id="NSPrivacyTrackingDomains">NSPrivacyTrackingDomains</h5> <ul> <li>データ形式 <ul> <li>文字列の配列</li> </ul> </li> </ul> <p>上記 NSPrivacyTracking を true にしている場合に記載が必要になる項目です。<br/> トラッキングを行うインターネットドメインの文字列を配列で列挙します。<br/> また、 App Tracking Transparency framework を通じてユーザからトラッキングの許可を得られなかった場合、ここに記載したインターネットドメインへの通信はブロックされ、エラーとなります。</p> <p>ドキュメントの記載の通りだとすると、例えば、自社サーバーへトラッキングデータを送っており、トラッキング API のエンドポイントのドメインとサービスの機能 API のエンドポイントを同一ドメインで扱っている場合は注意が必要かもしれません。<br/> ユーザがトラッキングを拒否した場合にトラッキングの API への通信と同時に機能 API への通信もブロッキングされ、トラッキングとは関係ない機能まで利用できなくなる可能性があります。<br/> 上記のようなケースでは、トラッキング用のドメインと機能 API のドメインを分ける方が良いかもしれません。 例えば、トラッキング API のドメインを <code>tracking.example.com</code> とし、機能 API のドメインを <code>api.example.com</code> とすることでトラッキングを拒否しても通信がブロックされるのは <code>tracking.example.com</code> の方だけなのでアプリの機能 API は利用できるはずです。</p> <p>下記記事にも詳しく紹介されているので併せてご確認ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.branch.io%2Fresources%2Fblog%2Fthe-dark-horse-of-wwdc-2023-privacy-policies-finally-get-real%2F" title="The Dark Horse of WWDC 2023: Privacy Policies Finally Get Real - Branch" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.branch.io/resources/blog/the-dark-horse-of-wwdc-2023-privacy-policies-finally-get-real/">www.branch.io</a></cite></p> <p>また、執筆時点では<a href="https://developer.apple.com/forums/thread/738723">トラッキングを拒否しても列挙したドメインへの通信がブロックされないというレポートが上がっており</a>、まだまだ情報が錯綜していそうです。</p> <h5 id="NSPrivacyCollectedDataTypes">NSPrivacyCollectedDataTypes</h5> <ul> <li>データ形式 <ul> <li>辞書の配列</li> </ul> </li> </ul> <p>アプリおよび Third-party SDK が収集するデータタイプを記述します。<br/> この項目に関しては、新しい話ではなく、すでに App Store Connect 上で管理しているプライバシーデータ相当のものをこちらに記載することになります。</p> <p><a href="https://developer.apple.com/jp/help/app-store-connect/manage-app-information/manage-app-privacy/">App &#x306E;&#x30D7;&#x30E9;&#x30A4;&#x30D0;&#x30B7;&#x30FC;&#x306E;&#x7BA1;&#x7406; - App &#x60C5;&#x5831;&#x306E;&#x7BA1;&#x7406; - App Store Connect - &#x30D8;&#x30EB;&#x30D7; - Apple Developer</a></p> <p>例えば、Sansan の iOS アプリは App Store 上でユーザからは次のようなものが見えていると思いますが、開発者としては App Store Connect 上でこれらの値を設定しています。</p> <p><figure class="figure-image figure-image-fotolife" title="Sansan iOS アプリの App Store 上でのプライバシー項目の見え方"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/taji-taji/20240201/20240201165227.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Sansan iOS アプリの App Store 上でのプライバシー項目の見え方</figcaption></figure></p> <p>この設定を <code>PrivacyInfo.xcprivacy</code> のこの NSPrivacyCollectedDataTypes 項目で記載していきます。</p> <p>App Store Connect の GUI 上で設定できる項目に対応するプロパティリストの Key-Value が<a href="https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests">定義されている</a>ので、その定義と既存の App Store Connect の設定を見ながらマニフェストファイルを作成するとよさそうです。</p> <p>ここでは、 App Store Connect の既存の定義を参考にして Privacy Manifests を作成するという手段を紹介しました。しかし、そもそもの Apple の意図としては「Privacy Manifests をメンテナンスすることで App Store Connect 上の値をより正確に更新していけ」というものです。したがって、作成後は Privacy Manifests の更新とともに App Store Connect の設定も更新しましょう。</p> <p>ちなみにですが、現時点で Privacy Manifests の値を App Store Connect の設定に自動で反映してくれるような機構は残念ながらなさそうなので、 Privacy Manifests 導入後もこれまで通り手動で App Store Connect の値を更新する必要があります。</p> <p>それでは既存の App Store Connect の設定と Privacy Manifests の二重管理になるだけでは?と思いますが、 Privacy Manifests のメリットとしては、Third-party SDK の Privacy Manifests の内容も含めて App Store Connect に設定すべきプライバシーデータの内容を PDF 出力してくれる点にあります。<br/> これまで Third-party SDK が収集するデータに関しては、アプリ開発者が調べて App Store Connect に反映させる必要がありました。一方 Privacy Manifests 導入後は、Third-party SDK が収集するデータは Third-party SDK 開発者が用意した Privacy Manifests に書かれており、アプリ開発者はアプリ側の Privacy Manifests の情報とマージされたものを出力できます。これによって App Store Connect への設定において抜け漏れが少なくなることが見込まれます。</p> <h5 id="NSPrivacyAccessedAPITypes">NSPrivacyAccessedAPITypes</h5> <ul> <li>データ形式 <ul> <li>辞書の配列</li> </ul> </li> </ul> <p><a href="https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api">理由が必要な API (Required reason API)</a> を使用している場合、どの種類の API を利用しているかと、どういった理由で利用しているかを既定のリストから選択します。</p> <p>Apple が列挙している API はいずれもユーザ特定の一助となるような、フィンガープリントとして利用され得るものになるので、用途を明確に宣言する必要があります。</p> <p>これらの API の中でも多くのアプリ開発者にとってインパクトがあるのが <code>UserDefaults</code> の利用かと思います。 <code>UserDefaults</code> では <code>AppleKeyboards</code> というキーでキーボードの情報が入手でき、こういった情報がフィンガープリントとして利用できるようです。したがって、利用用途を宣言する API として含まれているようです。</p> <p>ということで多くのアプリ開発者にとって影響がありそうな <code>UserDefautls</code> は利用理由の選択肢を取り上げてみようと思います。</p> <table> <thead> <tr> <th style="text-align:center;"> 利用理由 </th> <th style="text-align:left;"> 説明 </th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"> <code>CA92.1</code> </td> <td style="text-align:left;"> 一般的にアプリ内の情報を永続化する目的で利用している場合はこちらで良さそうです。 </td> </tr> <tr> <td style="text-align:center;"> <code>1C8F.1</code> </td> <td style="text-align:left;"> 上記の利用に加え、Extension や 同じ App Group の別アプリに情報を共有している場合はこちらになりそうです。 </td> </tr> <tr> <td style="text-align:center;"> <code>C56D.1</code> </td> <td style="text-align:left;"> これはアプリ開発者にはあまり関係なさそうです。Third-party SDK 開発者が SDK 利用者に <code>UserDefautls</code> のラッパーを提供する場合に選択するようなものになります。<br>Privacy Manifests 発表当初はこの理由は用意されていませんでした。<a href="https://github.com/flutter/flutter/issues/131495#issuecomment-1660506952">Flutter の issue などでも言及</a>されており、こういったフィードバックをもとに追加されたものと考えられます。 </td> </tr> <tr> <td style="text-align:center;"> <code>AC6B.1</code> </td> <td style="text-align:left;"> MDM の設定でアプリの挙動を制御するようなアプリはこちらを選択します。<br>Sansan の iOS アプリも MDM の情報を取得して挙動を変える機能を有しているのでこちらを選択する必要がありそうです。 </td> </tr> </tbody> </table> <p>ちなみに、選択肢は複数選択可能なようなので、用途が複数あれば該当するものを全て選択することになりそうです。</p> <h3 id="2-アプリが利用している-Third-party-SDK-を-Privacy-Manifests-対応バージョンにアップデートする">2. アプリが利用している Third-party SDK を Privacy Manifests 対応バージョンにアップデートする</h3> <p>次に、アプリが利用している Third-party SDK のアップデートを行います。</p> <p>アプリ開発者は、自身が作成する機能はもちろんのこと、アプリが利用する Third-party SDK の利用でもユーザのプライバシーを守る責任を負います。<br/> つまり、Third-party SDK がプライバシーデータを利用するような機能を有している場合でも、それを利用するアプリ開発者が把握した上で適切にユーザから許可を得る必要があるということです。<br/> そのため、利用している Third-party SDK についても今回の Privacy Manifests 対応がされたバージョンにアップデートする必要があります。</p> <p>また、2023年12月7日に Apple から「一般的に使用される Third-party SDK」が公表されました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.apple.com%2Fjp%2Fsupport%2Fthird-party-SDK-requirements%2F" title="Upcoming third-party SDK requirements - サポート - Apple Developer" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.apple.com/jp/support/third-party-SDK-requirements/">developer.apple.com</a></cite></p> <p>これらの SDK とそれ以外の Third-party SDK では少し条件が違ってきそうなので、別で見ていきます。</p> <h4 id="Apple-が公表した一般的に使用される-Third-party-SDKについて">Apple が公表した「一般的に使用される Third-party SDK」について</h4> <h5 id="SDK-開発者の対応">SDK 開発者の対応</h5> <ul> <li>署名対応が必須。</li> <li>Privacy Manifests 対応が必須。</li> </ul> <h5 id="アプリ開発者の対応">アプリ開発者の対応</h5> <ul> <li>対応されているバージョンにアップデートする。</li> <li>アプリ側の Privacy Manifests 対応が必須。</li> <li>対応されない場合、SDK の乗り換えを検討する。 <ul> <li>例えば、リストに上がっている <a href="https://github.com/AFNetworking/AFNetworking"><code>AFNetworking</code></a> は既にメンテナンスが終了しており、対応される見込みがないので、別の SDK を利用するといったことを検討する必要がある。</li> </ul> </li> </ul> <h4 id="その他の-Third-party-SDK-について">その他の Third-party SDK について</h4> <h5 id="SDK-開発者の対応-1">SDK 開発者の対応</h5> <ul> <li>プライバシーデータの収集をしている場合もしくは理由が必要な API を使用している場合、Privacy Manifests 対応が必須。</li> </ul> <h5 id="アプリ開発者の対応-1">アプリ開発者の対応</h5> <ul> <li>アプリで利用しているどの Third-party SDK が Privacy Manifests 対応の対象かを把握する。 <ul> <li>全ての SDK が対応してくれるとは限らないので、アプリ開発者としても利用している SDK のうちどれが Privacy Manifests 対応が必要なのかを把握しておくことで SDK 開発者にコミュニケーションを取るなどの手段が取れるでしょう。</li> </ul> </li> <li>対象の SDK を対応されているバージョンにアップデートする。</li> <li>対応されない場合、 SDK の乗り換えを検討する。</li> </ul> <h3 id="アプリ内や-Third-party-SDK-で利用されている-Required-reason-API-を調査する方法">アプリ内や Third-party SDK で利用されている Required reason API を調査する方法</h3> <p>現状、 Apple から Required reason API を使っているアプリや SDK を調査するツールなどは用意されていません。したがって、自分たちで調査するしかありません。</p> <p>調査方法としては次のようなものが考えられます。</p> <ol> <li>ソースコード内の文字列を検索</li> <li>アプリや SDK のバイナリのシンボルを検索</li> </ol> <h5 id="方法-1-ソースコード内の文字列を検索">方法 1. ソースコード内の文字列を検索</h5> <p>こちらは、自身のアプリや Third-party SDK のソースコードが手に入るのであれば、やり方としてはシンプルな方法です。</p> <p>一方、単なるテキスト検索なので、自身で定義した別の変数やメソッド名にこれらの API の文字列が含まれる場合やコメント内の文字列も引っかかります。また、これらの API を宣言していても実際には使われていない場合なども引っかかる可能性があり、検索結果にノイズが多くなる可能性は高いです。</p> <p>また、注意点として Required reason API で列挙されている API はそれぞれ Objective-C のメソッド名と Swift でのメソッド名があるのでそれぞれ網羅できるように検索する必要があるということです。</p> <h5 id="方法-2-アプリや-SDK-のバイナリのシンボルを検索">方法 2. アプリや SDK のバイナリのシンボルを検索</h5> <p>こちらの方法では、アプリや SDK のバイナリに対して <code>nm</code> コマンドを使ってシンボルを吐き出し、シンボルの中から Required reason API を検出する方法になります。</p> <p>こちらに関しては <code>nm</code> コマンド自体があまり利用頻度が高いものではないと思うので馴染みが薄いという方も多いかもしれません。そういう意味では取っ付きにくい方法かもしれませんが、ソースコードのテキストを検索するよりは精度高く検出できそうです。</p> <p>シンボルを検出するので、コメントなどの文字列は当然検出対象外になります。また、ソースコード上で Swift で Required reason API を記述していてもシンボルでは Objective-C にブリッジされているようなのでテキスト検索の方法のように Swift でのメソッド名の検索も省けるメリットがあります。</p> <p><code>nm</code> コマンドでの具体的な検出も説明しようかと思いましたが、 <code>nm</code> コマンドを利用した検出ツール(スクリプト)を作っている方がいたのでそちらを紹介しようと思います。 <code>nm</code> コマンドの使い方が気になる方はスクリプトの中も参考にしていただければと思います。</p> <h5 id="検出ツールスクリプトの紹介">検出ツール(スクリプト)の紹介</h5> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fomarzl%2Fios_17_required_reason_api_scanner" title="GitHub - omarzl/ios_17_required_reason_api_scanner: A simple shell script to scan your Xcode Swift project for required reason API usage" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/omarzl/ios_17_required_reason_api_scanner">github.com</a></cite></p> <p>先ほど紹介したテキスト検索とバイナリ検索の 2パターンのスクリプトを用意してくれています。<br/> これを使うことで比較的簡単にアプリや Third-party SDK 内で Required reason API を利用している箇所を調査できるかもしれません。<br/> 前述した <code>nm</code> コマンドもスクリプトを見ていくと<a href="https://github.com/omarzl/ios_17_required_reason_api_scanner/blob/main/required_reason_api_binary_scanner.sh#L78">使われている</a>ことが分かります。</p> <h6 id="もう一歩踏み込んでみる">もう一歩踏み込んでみる</h6> <p>上記スクリプトの動作確認用デモアプリの中にも<a href="https://github.com/omarzl/ios_17_required_reason_api_scanner/blob/main/DemoSymbols/DemoSymbols/DemoSymbolsApp.swift#L48-L53">記載されて</a>いますが、 Swift の API での <code>ProcessInfo.processInfo.systemUptime</code> と <code>UITextInputMode.activeInputModes</code> は <code>nm</code> コマンドでもシンボルとして検出できません。<br/> これらも含めて検出するには、どうすれば良いでしょうか? 調査の結果、手元にソースコードがある場合、オブジェクトファイル <code>.o</code> を作って <code>nm</code> コマンドに食わせると Swift の <code>ProcessInfo.processInfo.systemUptime</code> と <code>UITextInputMode.activeInputModes</code> API も検出可能になっていました。<br/> この辺りの挙動は興味がある方は調べてみると面白いかもしれません。</p> <h3 id="Privacy-Manifests-対応調査時に出てきた疑問点">Privacy Manifests 対応調査時に出てきた疑問点</h3> <p>冒頭にも書きましたが、本記事の執筆時点で情報の明瞭さに欠ける部分があり、 Privacy Manifests 対応の調査を行なっている最中にいくつか疑問点が出てきたのでこちらにまとめておきます。<br/> 疑問点のうち、解決したものもあれば、そうでないものもありますがご了承ください。</p> <ul> <li><code>2024年春</code> って具体的にはいつ? <ul> <li>不明です。</li> <li>Apple Developer Program のサポートに問い合わせても回答はありませんでした。追加の情報を待つしかなさそうです。</li> </ul> </li> <li>Apple が公開した<a href="https://developer.apple.com/jp/support/third-party-SDK-requirements/">「一般的に使用される Third-party SDK」のリスト</a>にある SDK 以外の Third-party SDK も Privacy Manifests 対応の対象か? <ul> <li>記事中に記載の通り、データ収集を行なっているか、Required reason API を使っている場合は全て対象になるそうです。</li> <li><a href="https://developer.apple.com/forums/thread/743295?answerId=776587022#776587022">こちらの Apple Developer Forum の Apple の回答</a>を参照ください。Apple Developer Program のサポートに問い合わせてもこの Forum の回答が正しいという回答をもらいました。</li> </ul> </li> <li>Third-party SDK が Privacy Manifests 対応されない場合、アプリ側の Privacy Manifests に Third-party SDK の分も追加して良いか? <ul> <li>不明です。</li> <li>Apple Developer Forum に<a href="https://developer.apple.com/forums/thread/741961">同様の質問</a>が投げられていますが、記事執筆時点で回答がありません。</li> </ul> </li> </ul> <h2 id="おわりに">おわりに</h2> <p>最後にもう1点、開発者として重要な「やること」があります。それは <strong>「引き続き Apple からの情報をキャッチすること」</strong> です。<br/> 冒頭にも述べたように、まだまだ情報としては明確になっていないことが多く、多くの開発者が混乱している状況のように思います。(具体的な対応期日も出てこないのは辛いですね、、)したがって、今後の Apple の公式情報をしっかりウォッチしておき、適切に対応していく必要があります。</p> <p>今回この調査を通じて、改めて、アプリ開発者として自身が書くコードだけでなく、 Third-party SDK の利用においてもユーザのプライバシーデータを適切に扱えるように責任を持って開発する必要があるということを再認識できたように思います。<br/> Apple からの情報の少なさには大変な思いをさせられますが、今回の件でアプリのデリバリーを途切れさせることなく、継続してユーザに価値を届けたいですね。そして、プライバシーに関しても安心して使っていただけるようなアプリを作っていければと思います。</p> <p>不明瞭な点も多いですが、この記事がアプリ開発者の皆様の役に立てれば幸いです。</p> <h2 id="仲間募集中">仲間募集中!</h2> <p>共にSansan / Eight のモバイルアプリ開発していく仲間を募集中です! 選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです!</p> <p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76610" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+iOS%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76610?_gl=1*eqw0uw*_ga*MTczMDU1OTY2NS4xNjY0NzU2ODE0*_ga_EN18Q6V0JW*MTcwNjI1MTAyNS42LjEuMTcwNjI1MTA0My4wLjAuMA..">open.talentio.com</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-6c3c3d04" id="f-6c3c3d04" name="f-6c3c3d04" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">開発者として対応事項が多くなるという意味であり、もちろんユーザから見て悲しむべきことではないです。</span></p> </div> taji-taji 2023アドベントカレンダーの社内LT大会の開催報告:技術とチキンでつながる一夜 hatenablog://entry/6801883189077845224 2024-02-08T11:00:00+09:00 2024-02-08T11:00:02+09:00 こんにちは。 技術本部研究開発部の高橋寛治です。 Sansan Advent Calendar 2023を書いた人達で交流と技術への理解を深めるために、社内でLT*1会を実施しました。 準備が少なくテンポ良く会として進められるため、LT形式を取りました。 また、フライドチキンや飲み物を用意し、ゆるい雰囲気で実施しました。 本記事は、そのLT会の開催報告です。 *1:Lightning Talkの略です。稲妻のように5分程度の短時間でプレゼンテーションをすることを指します。 <p>こんにちは。 技術本部研究開発部の<a href="https://8card.net/p/34779592122">高橋寛治</a>です。</p> <p><a href="https://adventar.org/calendars/9035">Sansan Advent Calendar 2023</a>を書いた人達で交流と技術への理解を深めるために、社内でLT<a href="#f-82ea6611" id="fn-82ea6611" name="fn-82ea6611" title="Lightning Talkの略です。稲妻のように5分程度の短時間でプレゼンテーションをすることを指します。">*1</a>会を実施しました。 準備が少なくテンポ良く会として進められるため、LT形式を取りました。 また、フライドチキンや飲み物を用意し、ゆるい雰囲気で実施しました。</p> <p>本記事は、そのLT会の開催報告です。</p> <h3 id="LT大会のハイライト">LT大会のハイライト</h3> <p>4本の発表がありました。 それぞれの内容について簡単に紹介したいと思います。</p> <p>オンラインとオフラインのハイブリッドで開催しました。 オフラインでは写真のように、軽食を食べながらLTを楽しんでいました。 また、Slackでの実況や質問スレッドも盛り上がりました。 <figure class="figure-image figure-image-fotolife" title="LT会の様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kanjirz50/20240125/20240125092131.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>LT会の様子</figcaption></figure></p> <h4 id="Knative-ServingでKustomizeができると何が嬉しいのか実演">Knative ServingでKustomizeができると何が嬉しいのか実演</h4> <p>技術本部研究開発部 Architectグループ ML Platformチームの宮地さんによる発表です。 「<a href="https://buildersbox.corp-sansan.com/entry/2023/12/22/110000">Knative ServingをKustomizeでPatchStrategicMergeしたい!</a>」で紹介した内容について、実演しました。</p> <p>冗長になってしまう設定ファイルが、簡潔に記述できる点はお見事でした。 利用者である研究員の学習コストをおさえることが目的だということです。</p> <p><a href="https://buildersbox.corp-sansan.com/entry/2023/12/22/110000#%E8%A7%A3%E6%B1%BA%E6%89%8B%E9%A0%86">実例は記事を見ていただければ</a>と思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2023%2F12%2F22%2F110000" title="Knative ServingをKustomizeでPatchStrategicMergeしたい! - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2023/12/22/110000">buildersbox.corp-sansan.com</a></cite></p> <h4 id="メタデータとは何かをチキンを使って分かりやすく解説する">メタデータとは何かをチキンを使って分かりやすく解説する</h4> <p>研究開発部 Architectグループ データエンジニアの出相さんによる発表です。 「<a href="https://buildersbox.corp-sansan.com/entry/2023/12/23/000000">【R&amp;D DevOps通信】データ分析基盤の分析用メタデータ管理 -2. 実装-</a>」の内容を「メタデータとは何かをチキンを使って分かりやすく解説する」というユーモアを交えた内容でした。</p> <p>メタデータの必要性がよくわかるスライドは以下です。</p> <p><figure class="figure-image figure-image-fotolife" title="メタデータのある肉売り場の例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kanjirz50/20240125/20240125093845.png" width="943" height="525" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>メタデータのある肉売り場の例</figcaption></figure></p> <p>肉を買いに来たお客さん(ユーザ)が商品(データ)を選び、料理(データ活用)するという工程で、メタデータが無いと困ることがわかります。 例えば、消費期限がないと腐っているかどうかわかりません。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2023%2F12%2F23%2F000000" title="【R&amp;D DevOps通信】データ分析基盤の分析用メタデータ管理 -2. 実装- - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2023/12/23/000000">buildersbox.corp-sansan.com</a></cite></p> <h4 id="OpenTelemetryの計装をやってみた話-LT">OpenTelemetryの計装をやってみた話: LT</h4> <p>技術本部 Bill One Engineering Unit の前田さんによる発表です。</p> <p>「<a href="https://buildersbox.corp-sansan.com/entry/2023/12/05/110000">OpenTelemetryの計装をやってみた話</a>」で紹介された内容を、Splunk上でどう見えるのかデモを投影しました。</p> <p>対象に詳しくなくても、何に時間がかかっているのかわかるようになったということです。 ただし、原因がわかるわけではないため、当たり前ですがトラブルシュートができません。</p> <p>お金がすごくかかるというのも、生々しくも参考になるお話でした。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2023%2F12%2F05%2F110000" title="Advent Calendar: OpenTelemetryの計装をやってみた話 - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2023/12/05/110000">buildersbox.corp-sansan.com</a></cite></p> <h4 id="LTの時間内にブログの案を作りきれるか実演検証する">LTの時間内にブログの案を作りきれるか実演検証する</h4> <p>私の発表です。</p> <p>「<a href="https://buildersbox.corp-sansan.com/entry/2023/12/01/110000">GPTsによる執筆支援Botとtextlintによる校正Botをつくってみた</a>」で紹介した執筆支援Botを実演し、本記事の案を作るというものです。 下図は、実演したプロンプトです。報告したい内容を列挙すると、GPTがタイトル案から構成までを提案します。</p> <p><figure class="figure-image figure-image-fotolife" title="GPTでの実演"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kanjirz50/20240130/20240130084324.png" width="591" height="811" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>GPTでの実演</figcaption></figure></p> <p>本記事は、この結果を元に書きました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2023%2F12%2F01%2F110000" title="GPTsによる執筆支援Botとtextlintによる校正Botをつくってみた - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2023/12/01/110000">buildersbox.corp-sansan.com</a></cite></p> <h3 id="交流と学び">交流と学び</h3> <p>部門を超えた交流の機会としてLT大会を提供できました。 実際にアンケート回答では、他部門の発表を聞き、交流ができたという内容が多かったです。</p> <p>技術的な知見の共有をすることで、技術の選択肢が増えると感じました。 例えば、LT会を終えるまでの私は、処理時間を測定するためにロガーを仕込むことが選択肢でした。「OpenTelemetryの計装をやってみた」という話を聞いたあとは、選択肢が増えました。原因究明に必要な情報をロギングし、処理時間はOpenTelemetryのようなライブラリに任せようと思えました。 知らないことを知るというのは難しいので、良い機会だと思います。</p> <h3 id="おわりに">おわりに</h3> <p>LT大会を通じて、普段の業務では触れられない知識に触れられ、また気軽に直接質問ができるというのは、良い場だと思いました。</p> <p>業務へより活かすことを考えると、テーマを技術領域で限定することが必要に感じました。 アドベントカレンダーはテーマが様々です。 異なるテーマの内容を業務に直接活かせることは少ないかと思います。</p> <p>次回は、CIに限定したLT会が開かれるようです。色々な場が開かれると、何かの機会につながって良いなと思います。</p> <h5 id="執筆者プロフィール">執筆者プロフィール</h5> <p>高橋寛治 Sansan株式会社 <small>技術本部 研究開発部 Data Analysisグループ</small></p> <p>阿南工業高等専門学校卒業後に、長岡技術科学大学に編入学。 同大学大学院電気電子情報工学専攻修了。 在学中は、自然言語処理の研究に取り組み、解析ツールの開発や機械翻訳に関連する研究を行う。 大学院を卒業後、2017年にSansan株式会社へ入社。 キーワード抽出など自然言語処理を生かした研究に取り組む。</p> <div class="footnote"> <p class="footnote"><a href="#fn-82ea6611" id="f-82ea6611" name="f-82ea6611" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">Lightning Talkの略です。稲妻のように5分程度の短時間でプレゼンテーションをすることを指します。</span></p> </div> kanjirz50 Data Hub グループでのバーチャルオフィス(Teamflow)活用事例 hatenablog://entry/6801883189075815029 2024-02-07T11:00:00+09:00 2024-02-08T13:18:31+09:00 はじめに こんにちは、技術本部 Sansan Engineering Unit Data Hub グループの髙芝です。 私は同グループで開発チームのリーダーを務めています。いわゆるプレイングマネージャー的な立ち位置で、プロダクト開発に関わる一連の業務(プロジェクト管理、要件定義から実装、テスト)やチームメンバーのマネジメント(評価・育成)を担当しています。 そして私たち Data Hub グループは Sansan Data Hub というプロダクトを開発しています。Data Hub について詳しくは以下のリンクを参照いただければと思います。 media.sansan-engineering.c… <div class="section"> <h3 id="はじめに">はじめに</h3> <p>こんにちは、技術本部 Sansan Engineering Unit Data Hub グループの髙芝です。<br /> 私は同グループで開発チームのリーダーを務めています。いわゆるプレイングマネージャー的な立ち位置で、プロダクト開発に関わる一連の業務(プロジェクト管理、要件定義から実装、テスト)やチームメンバーのマネジメント(評価・育成)を担当しています。<br /> そして私たち Data Hub グループは Sansan Data Hub というプロダクトを開発しています。Data Hub について詳しくは以下のリンクを参照いただければと思います。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmedia.sansan-engineering.com%2Fdatahub-engineer" title="データ統合機能「Sansan Data Hub」" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://media.sansan-engineering.com/datahub-engineer">media.sansan-engineering.com</a></cite></p><p>さて、リモートで働くことが日常となって久しいですが、皆さまの働き方については変化があったでしょうか。弊社では出社とリモートワークのハイブリッド形式が定着しており、その中で Data Hub グループではリモートコミュニケーションツールとして Teamflow というバーチャルオフィスツールを使っています。今回はその活用方法や運用についてご紹介しようと思います。</p> </div> <div class="section"> <h3 id="Teamflow-について">Teamflow について</h3> <p>公式サイトはこちらです。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.teamflowhq.com%2F" title="Teamflow - Feel like a team again" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.teamflowhq.com/">www.teamflowhq.com</a></cite><br /> いわゆるバーチャルオフィスツールの一つで、主要な機能は以下の通りです。</p> <ul> <li>音声およびビデオ通話</li> <li>画面共有(同時に複数の画面を共有することも可)</li> <li>ホワイトボードとメモ</li> <li>プライベートルームの作成</li> <li>バーチャルオフィスのカスタマイズ</li> <li>Slack 等との連携</li> </ul><p>特に通話を開始するハードルが低く、手軽に話しかけやすいツールです。</p> </div> <div class="section"> <h3 id="コミュニケーションツールの変遷">コミュニケーションツールの変遷</h3> <p>弊社では本格的に在宅勤務に移行し始めたのが2020年3月ごろでした。<br /> 移行後すぐにオンラインでのコミュニケーションの難しさに直面し、試行錯誤しながら Google Meet => Zoom => Discord => Teamflow と乗り換えてきています。<br /> なお、Discord に至るまでの経緯は過去に弊チームの元メンバーの記事があるためそちらを参考にしていただければと思います。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbuildersbox.corp-sansan.com%2Fentry%2F2020%2F12%2F10%2F110000" title="Discord を使って作る簡単バーチャルオフィス〜実例もあるよ〜 - Sansan Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://buildersbox.corp-sansan.com/entry/2020/12/10/110000">buildersbox.corp-sansan.com</a></cite><br /> </p> </div> <div class="section"> <h3 id="Discord-から-Teamflow-へ">Discord から Teamflow へ</h3> <p>前述の過去記事を参照していただけると少し空気感が伝わるかも知れませんが、Discord を利用していた当時は、少なくとも業務を遂行するという面においては明確な不満はありませんでした。<br /> 強いて言えば「ちょっといいですか?」など気軽に話しかけることが難しい(仕組み的にも心理的にも)ことでしたが、これはオンラインコミュニケーションでは仕方のないことだと思っていました。<br /> そんな中で、弊社内の他プロダクトの開発チームが Teamflow を使いはじめたという噂をメンバーが聞きつけ、Data Hub グループでも試してみよう、となったのが移行のきっかけでした。</p><p>早速トライアル利用を始めてみたところ、業務遂行に支障がないだけでなく、バーチャルオフィスツールであることでコミュニケーション面でメリットが大きいことがわかり、すぐに移行を決めました。<br /> 次に Teamflow ルームの簡単な紹介と、特に弊チームにとって有用な特徴や機能について事例を元にご紹介します。</p> </div> <div class="section"> <h3 id="Data-Hub-グループの部屋">Data Hub グループの部屋</h3> <p>現在の Data Hub グループの Teamflow の状況です。</p> <figure class="figure-image figure-image-fotolife" title="Data Hub ルーム"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-takashiba/20240131/20240131174337.png" width="1200" height="773" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Data Hub ルーム</figcaption></figure><p>弊チームは現在5つの小規模な開発チーム+遊撃隊メンバー<a href="#f-5c591126" id="fn-5c591126" name="fn-5c591126" title="特定の開発チームには所属せず、グループ全体に影響力を及ぼすミッションを持つメンバーの集まりです">*1</a>で構成されており、それぞれのチームの部屋がばらばらに配置されています。ミーティング用の部屋も大小用意してあります。<br /> 基本的にメンバーは自チームの部屋で過ごすことが多いですが、ミーティングの際は空いているミーティングルームを利用しています。また、中央のフリースペースで他チームのメンバーと相談や雑談をすることもよくあります。</p> </div> <div class="section"> <h3 id="活用事例1-誰が何をしているかを可視化する">活用事例1: 誰が何をしているかを可視化する</h3> <p>オンライン中心の働き方になってまず最初に困るのが「誰が何をしているのかわかりづらい」という点ではないでしょうか。自席で作業しているのか、ミーティングしているのか、そもそも出勤しているのか。<br /> 弊チームでは業務を開始したら Teamflow にログインするというルールを設けており、それにより誰が勤務しているかが一目でわかるようになります。もちろんお互いを監視するような意図はありません。が、「あれ?今日〇〇さん出勤してないな。大丈夫かな?」みたいな会話が稀によくあります<s>(大抵は寝坊なわけですが)</s>。</p> <div class="section"> <h5 id="カスタムステータスでより詳細な状況を伝えられる">カスタムステータスでより詳細な状況を伝えられる</h5> <p>Teamflow では自身の状況をアイコンと文章で表現できるカスタムステータス機能というものがあります。<br /> 作業に集中したいときに「〇〇対応中」などと記載したり、ランチやコーヒー休憩等で一時的に離席していることを伝えたり、使い方は様々です。</p> <figure class="figure-image figure-image-fotolife" title="カスタムステータス例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-takashiba/20240131/20240131142431.png" width="508" height="230" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>カスタムステータス例</figcaption></figure><p>最近よくある使い方は自由参加タイプのミーティング実施時にファシリテーターがミーティングのテーマを記載する使い方です。<br /> これにより、話しているテーマを見てメンバー側で参加する/しないを選んでもらえます。弊チームのアーキテクトが定期的に開催している技術相談会(Tech 相談会と呼ばれています)で会話しているテーマを記載したり、月1回開催しているOST(Open Space Technology)<a href="#f-62fd9f32" id="fn-62fd9f32" name="fn-62fd9f32" title="OSTについては[https://buildersbox.corp-sansan.com/entry/2023/07/14/110000:title=こちらの記事]を参照ください。Data Hub では Teamflow 上で実施しています">*2</a>ではメンバー自身が話したいテーマを掲げてディスカッションしています。</p> <figure class="figure-image figure-image-fotolife" title="OST でのディスカッションの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-takashiba/20240131/20240131142612.png" width="701" height="477" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>OST でのディスカッションの様子</figcaption></figure> </div> </div> <div class="section"> <h3 id="活用事例2-肩たたき機能でちょっといいですかができる">活用事例2: 肩たたき機能で「ちょっといいですか?」ができる</h3> <p>オンラインになって直接的なコミュニケーションの機会が減ると気軽に声をかけづらくなる傾向があるかと思いますが、そんな時に便利なのが「Tap on shoulder」機能です。弊チームでは肩たたき機能と呼んでいます。</p> <figure class="figure-image figure-image-fotolife" title="Tap on shoulder 機能で肩を叩こうとしている様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sansan-takashiba/20240131/20240131144815.png" width="671" height="403" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Tap on shoulder 機能で肩を叩こうとしている様子</figcaption></figure><p>肩を叩かれたユーザーにはデスクトップ通知が飛びます。通知に反応すると肩を叩かれたユーザーは叩いたユーザーの近くに瞬時に移動し会話を始めることができます。通知なので忙しければ無視しても構いません。<br /> この機能がかなり好評です。1対1で声をかけるときももちろんですが、例えばミーティングの最中に、ミーティングに参加していないメンバーの助けがほしいときに「ちょっといいですか?」のノリで呼びかけることができます。リモートワークへの移行後、Discord までのツールで感じていた話しかけづらさがこの機能によってかなり軽減されています。</p> </div> <div class="section"> <h3 id="さいごに">さいごに</h3> <p>以上、Sansan Data Hub 開発チームにおける Teamflow 活用事例でした。<br /> 決して宣伝ではないですが Teamflow はリモートコミュニケーションの煩わしさを軽減してくれるいいツールです。チーム開発の場では有用だと思いますので採用を検討してみてはいかがでしょうか。<br /> 他のバーチャルオフィスツールについても気になるところなので、もしいいものがあればコメント等で紹介していただけると幸いです。</p> </div><div class="footnote"> <p class="footnote"><a href="#fn-5c591126" id="f-5c591126" name="f-5c591126" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">特定の開発チームには所属せず、グループ全体に影響力を及ぼすミッションを持つメンバーの集まりです</span></p> <p class="footnote"><a href="#fn-62fd9f32" id="f-62fd9f32" name="f-62fd9f32" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">OSTについては<a href="https://buildersbox.corp-sansan.com/entry/2023/07/14/110000">&#x3053;&#x3061;&#x3089;&#x306E;&#x8A18;&#x4E8B;</a>を参照ください。Data Hub では Teamflow 上で実施しています</span></p> </div> sansan-takashiba NFCタグ読み取り機能を使ったイベント受付機能を開発した話 hatenablog://entry/6801883189077950589 2024-02-06T11:00:00+09:00 2024-02-06T11:00:03+09:00 技術本部Mobile Applicationグループの森です。 EightのAndroidアプリを開発しています。Eight主催のイベントでEightアプリを使って簡単に入場できる機能を開発したので、その紹介をしたいと思います。 <p>技術本部Mobile Applicationグループの森です。<br /> EightのAndroidアプリを開発しています。</p><p>Eight主催のイベントでEightアプリを使って簡単に入場できる機能を開発したので、その紹介をしたいと思います。</p> <div class="section"> <h3 id="はじめに">はじめに</h3> <p>2024/1/11−12の日程でEight主催のイベントBIS2024を開催しました。<br /> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Feight-event.8card.net%2Fbis%2F2024winter%2F" title="BIS 2024(ビジネスIT &amp; SaaS EXPO)-営業マーケDX 比較・導入展-" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://eight-event.8card.net/bis/2024winter/">eight-event.8card.net</a></cite></p><p>本イベントの詳細については割愛させていただきますが、Eightアプリのみでイベントの入場・セミナー/ブースでの受付・名刺交換が簡単に行える紙の名刺が要らないイベントになっております。</p> </div> <div class="section"> <h3 id="アプリの実装">アプリの実装</h3> <p>イベントの受付でEightアプリをインストールしたスマートフォンをタッチするだけで入場受付が完了し、目の前のプリンターから受付票が印刷されるという機能となります。</p><p>この受付機能を実装するにあたりNFCタグの読み取り機能を実装しました。</p> <div class="section"> <h4 id="NFCを利用した経緯">NFCを利用した経緯</h4> <p>Eightアプリは2023年9月にリニューアルし、スマートフォンをかざすだけで名刺交換が行える「タッチ名刺交換」機能をリリースしました。<br /> 本イベントではアプリを開いてスマートフォンをタッチするという一貫した体験を提供したいという理由からタッチすることで入場できる機能を開発しました。</p><p>タッチ名刺交換ではBLE(Bluetooth Low Energy)を利用していますが、安定性や実現性の観点からNFCを使用することになりました。</p><p>NFCタグには、入場に必要な情報を付与したURLを書き込みました。</p><p>URLを利用した理由は以下です。</p> <ul> <li>Eightアプリが未インストールの場合はアプリストアに遷移したい</li> <li>NFC非対応のスマートフォンの場合代替手段としてQRコード撮影からの入場と処理を共通化したい</li> <li>iOSとの互換性が高い</li> </ul><p><br /> 以下で、具体的な実装のサンプルを記載していきます。</p> </div> <div class="section"> <h4 id="NFCタグを読み取る">NFCタグを読み取る</h4> <p>基本的にNFCに対応したAndroidスマートフォンはNFCタグにタッチするだけでタグを検出できます。</p><p>検出したNFCタグの情報をAndroidアプリで取得するためにはAndroidManifestにIntentFilterを設定する必要があります。<br /> タッチでの入場処理ではACTION_NDEF_DISCOVERDというIntentをIntentFilterに設定することで実現しました。</p><p>NFCタグにEightのURLを書き込んでおき、そのURLをIntentFilterに指定します。</p> <pre class="code lang-xml" data-lang="xml" data-unlink> <span class="synIdentifier">&lt;intent-filter&gt;</span> <span class="synIdentifier">&lt;action </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">name</span>=<span class="synConstant">&quot;android.nfc.action.NDEF_DISCOVERED&quot;</span><span class="synIdentifier">/&gt;</span> <span class="synIdentifier">&lt;category </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">name</span>=<span class="synConstant">&quot;android.intent.category.DEFAULT&quot;</span><span class="synIdentifier">/&gt;</span> <span class="synIdentifier">&lt;data </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">scheme</span>=<span class="synConstant">&quot;https&quot;</span> <span class="synIdentifier"> </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">host</span>=<span class="synConstant">&quot;example.com&quot;</span> <span class="synIdentifier"> </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">pathPattern</span>=<span class="synConstant">&quot;/home&quot;</span><span class="synIdentifier"> /&gt;</span> <span class="synIdentifier">&lt;/intent-filter&gt;</span> </pre><p>上記の例では<a href="https://example.com/home">https://example.com/home</a>と一致するURLがNFCタグに書き込まれているときにアプリにIntentが通知されます。</p> <figure class="figure-image figure-image-fotolife" title="NFCタグにタッチした時のスクリーンショット"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/forestsoftjpdev/20240130/20240130165549.png" width="511" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>NFCタグにタッチした時のスクリーンショット</figcaption></figure><p>実際にタッチした時の画像がこちらで、NFCタグに書き込まれたURLをハンドリングできるアプリの一覧が表示されます。</p><p>一覧画面で自分のアプリを選択することで、IntentFilterを設定したActivityが起動されます。<br /> 起動されたActivityでIntentからデータを取得することで特定の処理を行うことが可能です。<br /> <br /> </p> </div> <div class="section"> <h4 id="特定のActivityでのみNFCタグを読み込む">特定のActivityでのみNFCタグを読み込む</h4> <p>上記のAndroidManifestにIntentFIiterを記載する方法では、NFCタグにタッチしたときの起点が固定のActivityになってしまい、必ず画面遷移が発生してしまいます。<br /> Eightアプリとしては、タッチ入場の機能を明示している画面上でタッチをした際はIntentFilterを通した遷移を行わずにそのまま画面上で入場処理を行いたいという要望があったため、上記の方法では対応が不十分でした。</p><p>そこで、利用したのがForegroundDispatchという方法です。</p><p>前の項目で記載しましたが、Androidスマートフォンは誰に言われるでもなく、NFCタグを検出し、タグに含まれる情報を処理できるIntentFilterを設定したアプリを起動しようとします。<br /> ForegroundDispatchを利用することにより、Androidのシステムがタグを検出するよりも先にActivityでNFCタグの情報を処理することができます。</p><p>以下にサンプルコードを記載します。</p> <div class="section"> <h5 id="AndroidManifestの変更">AndroidManifestの変更</h5> <p>ForegroundDispatchを利用するために、NfcAdapterを使用します。<br /> このクラスのメソッドを呼び出すにはNFCのパーミッションが必要になります。</p><p>パーミッションがない場合呼び出し時にSecurityExceptionが発生します。</p> <pre class="code lang-xml" data-lang="xml" data-unlink> <span class="synIdentifier">&lt;uses-permission </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">name</span>=<span class="synConstant">&quot;android.permission.NFC&quot;</span><span class="synIdentifier">/&gt;</span> </pre> </div> <div class="section"> <h5 id="Activityの変更">Activityの変更</h5> <p>まずはIntentFilterを定義します。</p><p>最初のサンプルと同じく、<a href="https://example.com/home">https://example.com/home</a>に反応するようにしています。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink> <span class="synType">val</span> intentFilter: IntentFilter = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED).apply { addDataScheme(<span class="synConstant">&quot;https&quot;</span>) addDataAuthority(<span class="synConstant">&quot;example.com&quot;</span>, <span class="synConstant">null</span>) addDataPath(<span class="synConstant">&quot;/home&quot;</span>, PatternMatcher.PATTERN_LITERAL) } </pre><p>次は処理するNFCのテクノロジーのリストを定義します。</p><p>NFCにはさまざまな規格があり、どの規格のタグを検出可能かをホワイトリスト形式で記載します。<br /> 一覧は以下リンクから確認ください。<br /> <a href="https://developer.android.com/guide/topics/connectivity/nfc/advanced-nfc?hl=ja#tag-tech">https://developer.android.com/guide/topics/connectivity/nfc/advanced-nfc?hl=ja#tag-tech</a><br /> </p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink> <span class="synType">val</span> techListsArray = arrayOf(arrayOf&lt;<span class="synType">String</span>&gt;(Ndef<span class="synStatement">::</span><span class="synType">class</span>.java.name)) </pre><p>ForegroundDispatchによる受け取るNFCタグの情報はActivityのonNewIntentメソッドにて受け取ります。<br /> 自分自身を呼び出すためのPendingIntentを定義します。</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink> <span class="synType">private</span> <span class="synType">val</span> pendingIntent = PendingIntentCompat.getActivity( <span class="synStatement">this</span>, <span class="synConstant">0</span>, Intent(activity, activity.javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), <span class="synConstant">0</span>, <span class="synConstant">true</span>) </pre><p>ActivityのライフサイクルメソッドにNfcAdapterを呼び出すコードを記載します。<br /> <br /> </p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink> <span class="synType">val</span> nfcAdapter: NfcAdapter? <span class="synStatement">by</span> lazy{ NfcAdapter.getDefaultAdapter(<span class="synStatement">this</span>) } <span class="synType">override</span> <span class="synType">fun</span> onResume() { <span class="synStatement">super</span>.onResume() <span class="synStatement">try</span> { adapter?.enableForegroundDispatch(<span class="synStatement">this</span>, pendingIntent, arrayOf(intentFilter), techListsArray) } <span class="synStatement">catch</span> (e: <span class="synType">UnsupportedOperationException</span>) { <span class="synComment">// このメソッドの呼び出しをサポートしていない場合の処理</span> } } <span class="synType">override</span> <span class="synType">fun</span> onPause() { <span class="synStatement">super</span>.onPause() <span class="synStatement">try</span> { adapter?.disableForegroundDispatch(<span class="synStatement">this</span>) } <span class="synStatement">catch</span> (e: <span class="synType">UnsupportedOperationException</span>) { <span class="synComment">// このメソッドの呼び出しをサポートしていない場合の処理</span> } } </pre><p>enableForegroundDispatchですが、IntentFilterは配列で渡すことができますので、必要に応じて追加してください。</p><p>上記のコードでForegroundDispatchを受け取る準備は完了です。</p><p>NFCタグをタッチしたデータを受け取るためにonNewIntentをオーバーライドします</p> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink> <span class="synType">override</span> <span class="synType">fun</span> onNewIntent(intent: Intent?) { <span class="synStatement">super</span>.onNewIntent(intent) intent?.let { <span class="synStatement">if</span> (it.action <span class="synStatement">==</span> NfcAdapter.ACTION_NDEF_DISCOVERED) { <span class="synComment">// </span><span class="synTodo">TODO</span><span class="synComment">:フィルターに一致するか確認</span> <span class="synComment">// 一致したら何かしらの処理を行う</span> } } } </pre><p>このように、onNewIntentで受け取ったIntentからNFCタグの情報を取得し、目的のタグにタッチされた場合のみ処理を行います。<br /> <br /> </p> </div> </div> </div> <div class="section"> <h3 id="実装における注意点">実装における注意点</h3> <div class="section"> <h4 id="サンプルが古い">サンプルが古い</h4> <p>サンプルコードはApiDemosという、いにしえのサンプルアプリに存在しています。</p><p>Android Developerのサイトには、サンプルへのリンクが貼られていますが、そこから飛ぶことはできません。<br /> Javaかつ、実装が古いためコピペで動作するか確認したいと思っても、そのままでは動かすことは難しいです。<br /> <a href="https://android.googlesource.com/platform/development/+/refs/heads/main/samples/ApiDemos/src/com/example/android/apis/nfc/ForegroundDispatch.java">samples/ApiDemos/src/com/example/android/apis/nfc/ForegroundDispatch.java - platform/development - Git at Google</a></p><p></p> </div> <div class="section"> <h4 id="NFC非対応スマートフォンの場合の対応">NFC非対応スマートフォンの場合の対応</h4> <p>NFC非対応の端末の場合、NfcAdapter.getDefaultAdapterはnullを返してきます。</p><p>NfcAdapter.enableForegroundDispatchがThrowする例外にUnsupportedOperationExceptionというものがあったため、Adapterは取得できるが、例外が投げられるのかと思っていましたが、そうではなくnullが帰ってきます。<br /> nullであっても問題ないようにコーディングしておきましょう。</p><p>ちなみにNFC対応スマートフォンでAndroidの設定でNFCをオフにしているときは、nullが帰ってくることはなく、メソッドを呼び出しても例外は発生しません。<br /> 必須の機能である場合は、NfcAdapter.isEnabledにて設定が有効になっているか確認してください。</p> </div> <div class="section"> <h4 id="NFCのパーミッションが付与されない場合がある">NFCのパーミッションが付与されない場合がある</h4> <p>日々の運用チェックでNfcAdapter.enableForegroundDispatchを呼び出した時にSecurityExceptionが稀に発生しているのを発見しました。</p><p>NFCのパーミッションはAndroidManifestに記載するだけで付与されるはずですが、権限が付与されないままアプリが起動してしまうことがあるようです。<br /> SecurityExceptionが発生するのは予期していないためアプリがクラッシュします。</p><p>回避方法としては、SecurityExceptionをcatchするか、権限があるかを確認してから呼び出すことになります。<br /> <br /> </p> </div> </div> <div class="section"> <h3 id="参考サイトのリンク">参考サイトのリンク</h3> <p><a href="https://developer.android.com/guide/topics/connectivity/nfc/nfc?hl=ja">NFC &#x306E;&#x57FA;&#x672C; &nbsp;|&nbsp; Android &#x30C7;&#x30D9;&#x30ED;&#x30C3;&#x30D1;&#x30FC; &nbsp;|&nbsp; Android Developers</a><br /> <a href="https://developer.android.com/guide/topics/connectivity/nfc/advanced-nfc?hl=ja">&#x9AD8;&#x5EA6;&#x306A; NFC &#x306E;&#x6982;&#x8981; &nbsp;|&nbsp; Android &#x30C7;&#x30D9;&#x30ED;&#x30C3;&#x30D1;&#x30FC; &nbsp;|&nbsp; Android Developers</a></p><p></p> </div> <div class="section"> <h3 id="おわりに">おわりに</h3> <p>共にSansan / Eightのモバイルアプリ開発していく仲間を募集中です!<br /> 選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もあるので、ご興味ありましたらぜひ面談だけでもお越しいただけたら幸いです!</p><p><iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/76400" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Android%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/76400?_gl=1*1wx1ev9*_ga*MTczMDU1OTY2NS4xNjY0NzU2ODE0*_ga_EN18Q6V0JW*MTcwNTY0MDk5MS41LjEuMTcwNTY0MTAwNi4wLjAuMA">open.talentio.com</a></cite></p> </div> forestsoftjpdev レガシーに向き合う - Reactのクラスコンポーネントを置き換える前にやるべきこと hatenablog://entry/6801883189079185580 2024-02-05T11:00:00+09:00 2024-02-05T11:00:02+09:00 こんにちは。Eightでエンジニアをしている藤野です。 Sansan Tech Blogに最後に記事を書いたのが2020年12月なので、約3年ぶりの投稿になります。時の流れって恐ろしい。 今回は、Reactのクラスコンポーネント(Class Component)を関数コンポーネント(Function Component)へ置き換える前にやるべきことについて話していこうと思います。 <p>こんにちは。Eightでエンジニアをしている藤野です。 Sansan Tech Blogに最後に記事を書いたのが2020年12月なので、約3年ぶりの投稿になります。時の流れって恐ろしい。</p> <p>今回は、Reactのクラスコンポーネント(Class Component)を関数コンポーネント(Function Component)へ置き換える前にやるべきことについて話していこうと思います。</p> <h2 id="背景">背景</h2> <p>EightのWeb版はReact + TypeScriptで書かれており、2024年3月で12年目となるプロジェクトです。日々改善はしていますが、長く運用してきただけあってフロントエンドのコードでも多少レガシーな部分が残っています。その中で挙げられる課題の一つとして、クラスコンポーネントがあります。</p> <p>Reactは、初期段階ではコンポーネントは主にJavaScriptのクラスを利用したコンポーネントで記述されていました。Reactから提供されるComponentクラスを継承し、stateの管理やライフサイクルメソッドの使用ができるようになる、Reactの主要な機能を利用するための唯一の方法でした。しかし、クラスコンポーネントは、ライフサイクルメソッドやthisのバインディングなど、コードを複雑にする要素も含んでいました。</p> <p>そして、その後登場したのが関数コンポーネントとhooksでした。関数コンポーネントはその名の通り、コンポーネントを関数で記述することができ、今までクラスコンポーネントでしか記述できなかったStateの管理やライフサイクルのメソッドはhooksという形で埋め込めるようになりました。また、関数コンポーネントはクラスコンポーネントと違い、コードがシンプルになるという利点があり、Eightとしても関数コンポーネント化(= FC化)を進めていくことになりました。</p> <h2 id="FC化置き換えのサンプル">FC化置き換えのサンプル</h2> <p>実際にFC化を行う例を見てみましょう。以下に、stateを持つクラスコンポーネントがあります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">class</span> SampleComponent <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>Props<span class="synStatement">,</span> <span class="synIdentifier">{</span> value: <span class="synType">string</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">constructor(</span>props: Props<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">super(</span>props<span class="synStatement">);</span> <span class="synIdentifier">this</span>.state <span class="synStatement">=</span> <span class="synIdentifier">{</span> value: <span class="synConstant">&quot;&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">get</span> inputText<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synConstant">`input value: </span><span class="synSpecial">${</span><span class="synIdentifier">this</span>.state.value<span class="synSpecial">}</span><span class="synConstant">`</span> <span class="synIdentifier">}</span> handleChange<span class="synStatement">(</span>event: ChangeEvent<span class="synStatement">&lt;</span>HTMLInputElement<span class="synStatement">&gt;)</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.setState<span class="synStatement">(</span><span class="synIdentifier">{</span> value: event.target.value <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>span<span class="synStatement">&gt;</span><span class="synIdentifier">{this</span>.inputText<span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/span<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>input <span class="synStatement">type=</span><span class="synConstant">&quot;text&quot;</span> value<span class="synStatement">=</span><span class="synIdentifier">{this</span>.state.value<span class="synIdentifier">}</span> onChange<span class="synStatement">=</span><span class="synIdentifier">{this</span>.handleChange.bind<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">)</span><span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>上記は、テキストの入力を行うInputFieldと、入力された値をspanで表示するコンポーネントです。</p> <p>このクラスコンポーネントは、大きく以下の3つの要素に分解できます。</p> <ol> <li>constructorでの初期化</li> <li>クラスメソッド、getter、プロパティ</li> <li>render関数でのコンポーネントのレンダリング</li> </ol> <p>これらの要素を関数コンポーネントとして整理すると以下のように置き換えられます。</p> <h3 id="constructorでの初期化">constructorでの初期化</h3> <p>関数コンポーネントでは、基本的に関数同様に変数・関数を定義していくため、constructorではなく、関数上部に初期化の処理を記述します。stateやrefはそれぞれ用意されているhooksを用いることで表現することができます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> <span class="synIdentifier">[</span>value<span class="synStatement">,</span> setValue<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">(</span><span class="synConstant">&quot;&quot;</span><span class="synStatement">);</span> <span class="synComment">// value(string)のstate</span> <span class="synType">const</span> textInput <span class="synStatement">=</span> useRef<span class="synStatement">&lt;</span>HTMLInputElement<span class="synStatement">&gt;(</span><span class="synType">null</span><span class="synStatement">);</span> <span class="synComment">// &lt;input /&gt;のref</span> </pre> <h3 id="クラスメソッドgetterプロパティ">クラスメソッド、getter、プロパティ</h3> <p>関数コンポーネントでは、クラスメソッドやゲッター・セッターなどは、通常通り関数を定義するか、useCallbackやuseMemoといったhooksを使うことで表現できます。</p> <table> <thead> <tr> <th> クラスコンポーネント </th> <th> 関数コンポーネント </th> </tr> </thead> <tbody> <tr> <td> getter </td> <td> useMemo </td> </tr> <tr> <td> クラスメソッド </td> <td> useCallback </td> </tr> <tr> <td> state </td> <td> useState </td> </tr> <tr> <td> ライフサイクルメソッド </td> <td> useEffect </td> </tr> <tr> <td> refやその他プロパティ </td> <td> useRef </td> </tr> </tbody> </table> <p>ここで注意してほしい点として、<code>this.xxx = yyy</code> といったインスタンスに代入しているタイプのプロパティを置き換える際には、Refを使わなければならないということです。React.Componentを継承していますが、プロパティに関してはReactのライフサイクルには乗らない為、リアクティブではありません。もちろん、Refである必要のない場合もありますが、置き換えの際には極力実装を合わせる方が得策なため、基本的にはRefを用います。</p> <p>基本的にはライフサイクルメソッドはコンポーネントの値が変化した時にリアクティブに発火するだけなので、useEffectとその依存配列を組み合わせることで実現することができます。</p> <h3 id="render関数でのコンポーネントのレンダリング">render関数でのコンポーネントのレンダリング</h3> <p>render関数は関数コンポーネントでは廃止され、代わりに関数コンポーネントでreturnされる値が、そのままレンダリングされる内容として表示されます。</p> <h3 id="FC化の結果">FC化の結果</h3> <p>上記を踏まえ、先ほどのクラスコンポーネントを置き換えたのがこちらです。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> SampleComponent <span class="synStatement">=</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> <span class="synIdentifier">[</span>value<span class="synStatement">,</span> setValue<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">(</span><span class="synConstant">&quot;&quot;</span><span class="synStatement">);</span> <span class="synType">const</span> inputText <span class="synStatement">=</span> useMemo<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span><span class="synConstant">`input value: </span><span class="synSpecial">${</span>value<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">,</span> <span class="synIdentifier">[</span>value<span class="synIdentifier">]</span><span class="synStatement">);</span> <span class="synType">const</span> handleChange <span class="synStatement">=</span> useCallback<span class="synStatement">((</span>event<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> setValue<span class="synStatement">(</span>event.target.value<span class="synStatement">),</span> <span class="synIdentifier">[]</span><span class="synStatement">);</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>span<span class="synStatement">&gt;</span><span class="synIdentifier">{</span>inputText<span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/span<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>input <span class="synStatement">type=</span><span class="synConstant">&quot;text&quot;</span> value<span class="synStatement">=</span><span class="synIdentifier">{</span>value<span class="synIdentifier">}</span> onChange<span class="synStatement">=</span><span class="synIdentifier">{</span>handleChange<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">);</span> <span class="synIdentifier">}</span> </pre> <h2 id="置き換える前にやったこと">置き換える前にやったこと</h2> <p>慣れてしまえば、先ほどのサンプルのような簡単なコンポーネントはすぐにFC化することができます。ただ、そんな簡単に行くわけもなく、簡単には置き換えられないケースやレビューが困難であるケースが多々出てきます。</p> <p>それらのケースに対応するべく、我々が行った前準備と取り決めについて紹介していきます。</p> <h3 id="使われていないPropsやState変数の削除">使われていないPropsやState、変数の削除</h3> <p>クラスコンポーネントはレガシーが故に、過去には使われていたが現在では使われていないpropsやstate(消し残し)が存在するケースがあります。基本的にFC化を行う上ではコード量が少ない方が置き換えやすいことは自明であるため、これらを見つけた場合には事前に削除しておくことが望ましいです。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> LegacyComponentProps <span class="synStatement">=</span> <span class="synIdentifier">{</span> autoFocus?: <span class="synType">boolean</span><span class="synStatement">;</span> <span class="synComment">// 定義されているが実際には使われていない</span> <span class="synIdentifier">}</span> <span class="synStatement">class</span> LegacyComponent <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>LegacyComponentProps<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synComment">// ...</span> <span class="synComment">// このメソッドも不要</span> focus<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if(</span><span class="synConstant">!</span>autoFocus<span class="synStatement">)</span> <span class="synStatement">return;</span> <span class="synComment">// その他処理</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>⚠️ここで注意しなければいけない点として、実際にその関数や変数は<strong>本当にどこからも呼ばれていないのか</strong>という点を確認するということです。 例えば、ts-expect-error等で型エラーを潰していたケースです。以下のコードを見てみましょう。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">class</span> Component <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span><span class="synIdentifier">{</span> selectedDialog: <span class="synConstant">&quot;A&quot;</span> | <span class="synConstant">&quot;B&quot;</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synComment">// ...</span> handleClick<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synComment">// @ts-expect-error this.A, this.BはComponentで初期化されてない</span> <span class="synIdentifier">this[this</span>.props.selectedDialog<span class="synIdentifier">]</span>.open<span class="synStatement">();</span> <span class="synIdentifier">}</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;&gt;</span> <span class="synStatement">&lt;</span>button <span class="synSpecial">onClick</span><span class="synStatement">=</span><span class="synIdentifier">{this</span>.handleClick.bind<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">)</span><span class="synIdentifier">}</span><span class="synStatement">&gt;</span>Open Dialog<span class="synStatement">&lt;</span>/button<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span><span class="synComment">/* @ts-expect-error this.A, this.BはComponentで初期化されてない */</span><span class="synIdentifier">}</span> <span class="synStatement">&lt;</span>Dialog ref<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>ref<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">this[</span><span class="synConstant">&quot;A&quot;</span><span class="synIdentifier">]</span> <span class="synStatement">=</span> ref<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span><span class="synComment">/* @ts-expect-error this.A, this.BはComponentで初期化されてない */</span><span class="synIdentifier">}</span> <span class="synStatement">&lt;</span>Dialog ref<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>ref<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">this[</span><span class="synConstant">&quot;B&quot;</span><span class="synIdentifier">]</span> <span class="synStatement">=</span> ref<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/<span class="synStatement">&gt;</span> <span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synStatement">class</span> Dialog <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>Props<span class="synStatement">,</span> <span class="synIdentifier">{</span> isOpen: <span class="synType">boolean</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> open<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.setState<span class="synStatement">(</span><span class="synIdentifier">{</span> isOpen: <span class="synConstant">true</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synComment">// ...</span> <span class="synIdentifier">}</span> </pre> <p>このように、JavaScriptからTypeScriptに乗り換えたプロジェクトなどでは、クラスコンポーネントのメンバ変数の初期化などは行っておらず型エラーが出ているが、実装を変更するまでの工数が取れていないという場合においてts-expect-errorで型エラーをもみ消している場合があります。実際にこのようなコードがEight WebのTypeScript化黎明期には存在していました。</p> <p>このケースにおいてDialogコンポーネントのopenからは参照が途切れており、エディタ上では使われていないように見えてしまいます。Dialogコンポーネント内のみを見てopenメソッドを消してしまった場合、Component側のhandleClickが押下された瞬間画面落ちが発生するというわけです。そのため、コードを消す際には<strong>本当にどこからも呼ばれていないのか</strong>を入念に調べる必要があります(大事なことなので2回言いました)。</p> <h3 id="変数や関数の命名の修正">変数や関数の命名の修正</h3> <p>クラスコンポーネントでは、stateやprops、メンバ変数・関数の値が同名になってしまっているケースがあります。以下の例を見てください。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">class</span> Component <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span><span class="synIdentifier">{</span> <span class="synSpecial">onClick</span>: VoidFunction <span class="synIdentifier">}</span><span class="synStatement">&gt;</span><span class="synIdentifier">{</span> <span class="synSpecial">onClick</span><span class="synStatement">(</span>e: MouseEvent<span class="synStatement">&lt;</span>HTMLButtonElement<span class="synStatement">&gt;)</span> <span class="synIdentifier">{</span> e.preventDefault<span class="synStatement">();</span> <span class="synIdentifier">this</span>.props.<span class="synSpecial">onClick</span><span class="synStatement">();</span> <span class="synIdentifier">}</span> <span class="synComment">// ...</span> <span class="synIdentifier">}</span> </pre> <p>このように、Componentのメンバ関数であるonClickと、propsで渡されるonClickは同名です。これをFC化すると以下のようになります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> Component: FC<span class="synStatement">&lt;</span><span class="synIdentifier">{</span> <span class="synSpecial">onClick</span>: VoidFunction <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synStatement">=</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synSpecial">onClick</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synComment">// 🚨 onClickはすでにpropsで渡されているため関数内で同名のものを定義できない。</span> <span class="synType">const</span> <span class="synSpecial">onClick</span> <span class="synStatement">=</span> useCallback<span class="synStatement">((</span>e: MouseEvent<span class="synStatement">&lt;</span>HTMLButtonElement<span class="synStatement">&gt;)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> e.preventDefault<span class="synStatement">();</span> <span class="synSpecial">onClick</span><span class="synStatement">();</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">[</span><span class="synSpecial">onClick</span><span class="synIdentifier">]</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> </pre> <p>このように、単純に置き換えることができません。また、this.onClickなのかthis.props.onClickなのかどうかも置き換える際に混乱する可能性があるため、事前に命名を修正しておくのが好ましいです。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// ⭕️ OK</span> handleOnClick<span class="synStatement">(</span>e: MouseEvent<span class="synStatement">&lt;</span>HTMLButtonElement<span class="synStatement">&gt;)</span> <span class="synIdentifier">{</span> e.preventDefault<span class="synStatement">();</span> <span class="synIdentifier">this</span>.props.<span class="synSpecial">onClick</span><span class="synStatement">()</span> <span class="synIdentifier">}</span> <span class="synType">const</span> handleOnClick <span class="synStatement">=</span> useCallback<span class="synStatement">((</span>e: MouseEvent<span class="synStatement">&lt;</span>HTMLButtonElement<span class="synStatement">&gt;)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> e.preventDefault<span class="synStatement">();</span> <span class="synSpecial">onClick</span><span class="synStatement">()</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">[</span><span class="synSpecial">onClick</span><span class="synIdentifier">]</span><span class="synStatement">);</span> </pre> <h3 id="オブジェクトのプロパティの動的な参照を減らす">オブジェクトのプロパティの動的な参照を減らす</h3> <p>クラスコンポーネントの場合、メンバ変数やstateに置いて、以下のような記法で処理を書くことができます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> FilterState <span class="synStatement">=</span> <span class="synIdentifier">{</span> price: <span class="synType">string</span><span class="synStatement">;</span> age: <span class="synType">number</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">class</span> FilterComponent <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>Props<span class="synStatement">,</span> FilterState<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">constructor(</span>props<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synComment">// filterの初期値をstateに代入する</span> <span class="synIdentifier">this</span>.state <span class="synStatement">=</span> getFilterValue<span class="synStatement">(</span>props<span class="synStatement">);</span> <span class="synIdentifier">}</span> onChange<span class="synStatement">&lt;</span>K <span class="synStatement">extends</span> <span class="synStatement">keyof</span> FilterState<span class="synStatement">&gt;(</span>filterKey: K<span class="synStatement">,</span> value: FilterState<span class="synIdentifier">[</span>K<span class="synIdentifier">]</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.setState<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synIdentifier">[</span>filterKey<span class="synIdentifier">]</span>: value <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synComment">// ...</span> <span class="synIdentifier">}</span> </pre> <p>この場合、handleOnFilterChangeに渡されたキーによって動的にメンバ変数とstateにセットされる値が決定します。</p> <p>関数コンポーネントの場合、stateはhooksを介して作成されるため現在のconstructorのような書き方はできません。素直に書こうとするとfilterの数だけhooksを呼び出し、stateを作成する必要があります。また、値をsetする際にはkeyの数だけ場合わけを増やしていく必要があります。</p> <p>解決策として、stateの構造を修正し、 関数コンポーネントでも扱いやすいような形式に事前に修正しておくアプローチが考えられます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> FilterBase <span class="synStatement">=</span> <span class="synIdentifier">{</span> price: <span class="synType">string</span><span class="synStatement">;</span> age: <span class="synType">number</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> FilterState <span class="synStatement">=</span> <span class="synIdentifier">{</span> filter: FilterBase<span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">class</span> FilterComponent <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>Props<span class="synStatement">,</span> FilterState<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> onChange<span class="synStatement">&lt;</span>K <span class="synStatement">extends</span> <span class="synStatement">keyof</span> FilterState<span class="synStatement">&gt;(</span>filterKey: K<span class="synStatement">,</span> value: FilterState<span class="synIdentifier">[</span>K<span class="synIdentifier">]</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.setState<span class="synStatement">((</span>prev<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> filter: <span class="synIdentifier">{</span> <span class="synComment">// 更新されるkey以外はprevのstateを使う</span> ...prev.filter<span class="synStatement">,</span> <span class="synIdentifier">[</span>filterKey<span class="synIdentifier">]</span>: value<span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">));</span> <span class="synIdentifier">}</span> <span class="synComment">// ...</span> <span class="synIdentifier">}</span> </pre> <p>Filterのような、今後もstateが増え続けると予想されるケースにおいてこれらは有効な手段です。</p> <h3 id="不要なライフサイクルメソッドの削除">不要なライフサイクルメソッドの削除</h3> <p>FC化の難点の一つに、ライフサイクルメソッドの置き換えの難易度があります。先述した通り、関数コンポーネントでライフサイクルメソッドを再現するにはuseEffectを使う必要がありますが、完全に同じ挙動になっているかどうかのチェックは難易度が高いです。また、useEffectは依存配列の中身を吟味しないと、容易に無限ループが発生してしまうので障害の原因にもなります。</p> <p>そのため、そもそもライフサイクルメソッドが本当に必要なのかどうかを事前に判断し、必要でない場合にはライフサイクルメソッドそのものを削除する(あるいは簡単にする)ことで、FC化を簡単にすることができます。以下は不要なライフサイクルを持ったダイアログの例です。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> Props <span class="synStatement">=</span> <span class="synIdentifier">{</span> isShow?: <span class="synType">boolean</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synStatement">class</span> SuggestDialog <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>Props<span class="synStatement">,</span> <span class="synIdentifier">{</span> isOpen: <span class="synType">boolean</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">constructor(</span>props: Props<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.state <span class="synStatement">=</span> <span class="synIdentifier">{</span> isOpen: props.isShow <span class="synConstant">||</span> <span class="synConstant">false</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> componentDidUpdate<span class="synStatement">(</span>prevProps<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">if(</span><span class="synIdentifier">this</span>.props.isShow <span class="synStatement">!==</span> prevProps.isOpen<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.setState<span class="synStatement">(</span><span class="synIdentifier">{</span> isOpen: <span class="synIdentifier">this</span>.props.isShow <span class="synConstant">||</span> <span class="synConstant">false</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if(</span><span class="synConstant">!</span><span class="synIdentifier">this</span>.state.isOpen<span class="synStatement">)</span> <span class="synStatement">return</span> <span class="synType">null</span><span class="synStatement">;</span> <span class="synStatement">return</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span><span class="synIdentifier">{</span><span class="synComment">/* content */</span><span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>SuggestDialogにはisShowというpropsが渡され、その値をisOpenの初期値として代入しています。isOpenがfalseの場合にコンポーネントをレンダリングしません。propsのisShowの値の変更が検知されると、componentDidUpdateでそれをキャッチし、isShowの値をstateにセットします。</p> <p>このコンポーネントをよくみると、propsのisShowとstateのisOpenは同じ役割をしていることがわかります。つまり、stateでisOpenを保持する必要はなく、propsのisShowだけで要件を満たせることがわかります。よって以下のように書き換えることができます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">class</span> SuggestDialog <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span><span class="synIdentifier">{</span> isShow?: <span class="synType">boolean</span> <span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if(</span><span class="synConstant">!</span><span class="synIdentifier">this</span>.props.isShow<span class="synStatement">)</span> <span class="synStatement">return</span> <span class="synType">null</span><span class="synStatement">;</span> <span class="synStatement">return</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span><span class="synIdentifier">{</span><span class="synComment">/* content */</span><span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>結果stateもcomponentDidUpdateも削除することができ、ただのプレゼンテーショナルなコンポーネントにすることができました。こうなればFC化も容易です。</p> <h3 id="継承の削除">継承の削除</h3> <p>Reactのクラスコンポーネントは、通常のJavaScriptのクラスと同様に継承を利用することができます。しかし、関数コンポーネントでは継承は存在しないため、継承を利用しているクラスコンポーネントを関数コンポーネントに置き換える際には、継承の機能を別の形で実現する必要があります。</p> <p>継承を消していく方法として、二種類の方法が考えられます。継承されるコンポーネントを親、親コンポーネントを継承するコンポーネントを子と呼ぶと</p> <ul> <li>親に子の実装を押し込むパターン</li> <li>子に親の実装を押し込むパターン</li> </ul> <p>があります。</p> <p>それぞれの例を以下に示します。</p> <h4 id="親に子の実装を押し込むパターン">親に子の実装を押し込むパターン</h4> <p>BaseComponentには、typeを渡すことで場合分けを行います。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> BaseProps <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Child1'</span> | <span class="synConstant">'Child2'</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">class</span> BaseComponent <span class="synStatement">extends</span> Component<span class="synStatement">&lt;</span>BaseProps<span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synIdentifier">this</span>.props.<span class="synStatement">type</span> <span class="synStatement">===</span> <span class="synConstant">'Child1'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synComment">// Child1の実装</span> <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synIdentifier">this</span>.props.<span class="synStatement">type</span> <span class="synStatement">===</span> <span class="synConstant">'Child2'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synComment">// Child2の実装</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>注意点として、typeが多い場合に親コンポーネントが肥大化する恐れがあります。</p> <h4 id="子に親の実装を押し込むパターン">子に親の実装を押し込むパターン</h4> <p>一方このパターンは、親コンポーネントの共通の実装をそのまま子コンポーネントに適用します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">class</span> Child1 <span class="synStatement">extends</span> Component <span class="synIdentifier">{</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synComment">// Baseの実装</span> <span class="synComment">// Child1独自の実装</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synStatement">class</span> Child2 <span class="synStatement">extends</span> Component <span class="synIdentifier">{</span> render<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synComment">// Baseの実装</span> <span class="synComment">// Child2独自の実装</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>こちらも注意点として、BaseComponentが多数のコンポーネントで継承されていた場合、Childコンポーネントに対してコピーする数も増えるため、コード量や作業量が多くなる恐れがあります。</p> <p>このように、いずれの方法も一長一短があります。したがって、どの方法を選ぶべきかは、プロジェクトの設計や要件によります。</p> <h2 id="まとめ">まとめ</h2> <p>Reactのクラスコンポーネントから関数コンポーネントへの移行は、簡単なことではありません。命名の変更、ライフサイクルの見直し、コンポーネント設計の修正など、数多くのやるべきことがあります。</p> <p>しかし十分に準備をし、負債を返済することで、プロダクトのコードのシンプルさ・可読性・保守性が向上し、コード品質や開発者体験の向上に繋がります。この記事で紹介した方法が、皆さんのFC化作業をスムーズに進めるための一歩目になれば幸いです。</p> <p>また、Eight ではエンジニアを募集中です。 ご興味があれば、エントリーお待ちしております! <iframe src="https://open.talentio.com/r/1/c/sansan/embed/pages/84005" width="100%" height="300" frameborder=0 title="Sansan%E6%A0%AA%E5%BC%8F%E4%BC%9A%E7%A4%BE+%7C+Web%E3%82%A2%E3%83%97%E3%83%AA%E9%96%8B%E7%99%BA%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2%EF%BC%BBEight%EF%BC%BD"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/sansan/pages/84005">open.talentio.com</a></cite></p> mahito6