一休.com Developers Blog 一休のエンジニア、デザイナー、ディレクターが情報を発信していきます 2024-03-28T11:56:31+09:00 ikyu_com Hatena::Blog hatenablog://blog/6653586347153895035 データベースの在庫の持ち方をビットで管理してる話 hatenablog://entry/6801883189091723652 2024-03-28T11:56:31+09:00 2024-03-28T12:17:38+09:00 こんにちは、一休.comスパ(以下、「スパ」)の開発を担当しているshibataiと申します🙏 今回はスパのデータベースの在庫の持ち方で試行錯誤した話をさせていただきます。 背景 スパは予約を受け付けるために在庫の管理をしてます🎁 データベースで在庫テーブルを持っていますが、ベタな管理をしています。 特定の施設・日・在庫の数を00:00をt0000とみなして15分おきにt0000・t0015..t2345まで格納してます🤔 在庫テーブルのイメージは以下です。 shop_id inventory_id inventory_date t0000 t0015 (省略) t1300 t1315 (省略… <p>こんにちは、<a href="https://spa.ikyu.com/">一休.comスパ</a>(以下、「スパ」)の開発を担当しているshibataiと申します🙏<br/> 今回はスパのデータベースの在庫の持ち方で試行錯誤した話をさせていただきます。</p> <h2 id="背景">背景</h2> <p>スパは予約を受け付けるために在庫の管理をしてます🎁<br/> データベースで在庫テーブルを持っていますが、ベタな管理をしています。<br/> 特定の施設・日・在庫の数を00:00をt0000とみなして15分おきにt0000・t0015..t2345まで格納してます🤔<br/> 在庫テーブルのイメージは以下です。</p> <table> <thead> <tr> <th> shop_id </th> <th> inventory_id </th> <th> inventory_date </th> <th> t0000 </th> <th> t0015 </th> <th> (省略) </th> <th> t1300 </th> <th> t1315 </th> <th> (省略) </th> <th> t2345 </th> </tr> </thead> <tbody> <tr> <td> 1 </td> <td> 1 </td> <td> 2024-01-01 </td> <td> 0 </td> <td> 0 </td> <td> ... </td> <td> 1 </td> <td> 0 </td> <td> ... </td> <td> 0 </td> </tr> <tr> <td> 1 </td> <td> 2 </td> <td> 2024-01-01 </td> <td> 0 </td> <td> 0 </td> <td> ... </td> <td> 0 </td> <td> 1 </td> <td> ... </td> <td> 0 </td> </tr> </tbody> </table> <p>この設計は在庫の調査時に在庫数を確認しやすいのですが、レコード挿入時にtxxxの形にしたり、描画時にtxxxをtimeに変換する必要があったりと、実際に在庫を含めた描画を行う処理に難ありでした😞<br/> チーム内で相談した結果、検索で描画する際は時間の配列(例: <code>['10:00', '11:15', '12:45']</code>)を圧縮したビットを使うようにしました。</p> <table> <thead> <tr> <th> shop_id </th> <th> inventory_id </th> <th> inventory_date </th> <th> timeBits1 </th> <th> timeBits2 </th> </tr> </thead> <tbody> <tr> <td> 1 </td> <td> 1 </td> <td> 2024-01-01 </td> <td> 1 </td> <td> 0 </td> </tr> <tr> <td> 1 </td> <td> 2 </td> <td> 2024-01-01 </td> <td> 64 </td> <td> 2 </td> </tr> </tbody> </table> <p>具体的な実装は後述しますが、カラムをビットで管理する場合のメリット・デメリットは以下です。</p> <p>【メリット】</p> <ul> <li>あるスパンごとのカラムを大量に持たずにビットの表現で圧縮できるのでデータ容量を抑えることができる</li> <li>動的にカラムを決めるために一般的にオーバーヘッドの大きいと言われるリフレクションを使わなくていいため、ビット値を用いると比較的高速に検索可能</li> <li>施設単位やプラン単位などで在庫有無をサマライズしたい時、ANDやOR検索で柔軟な条件指定が可能</li> </ul> <p>【デメリット】</p> <ul> <li>テーブルをSELECTで検索するだけでは状態がわからない(値を変換しなければならない)ため、デバッグやクエリ構築の難易度が上がる</li> <li>ビット値と時間の配列の間を相互変換するライブラリの用意が必要</li> <li>ビット値はBIGINT型でも桁溢れする場合があるので、Bit1とBit2といったようにある部分で分割する検討が必要</li> </ul> <p>以下からはビット演算の仕組みと、実際にどういうイメージで検索するかを説明します👀</p> <h2 id="ビット演算とは">ビット演算とは?</h2> <p>データをビット列(0 or 1で構成される)とみなして演算します。<br/> メリットは、値に対してANDやOR検索ができることです。<br/> 例えば1/2/3をビット列で表した場合、<code>00000001</code>/<code>00000010</code>/<code>00000011</code>です。 1と2でビットOR演算を行うと、</p> <pre class="code" data-lang="" data-unlink> 00000001 OR 00000010 ------------- 00000011</pre> <p>各ビットを縦に見て、少なくとも一方に1がある場合、結果のそのビット位置は1になるので、演算結果は10進数の3です。<br/> 実際にSQLServerで検索する際にAND演算を使う例を出すと、</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">CREATE</span> <span class="synSpecial">TABLE</span> Example ( Bits <span class="synType">INT</span> ); <span class="synStatement">INSERT</span> <span class="synSpecial">INTO</span> Example(Bits) <span class="synSpecial">VALUES</span> (<span class="synConstant">3</span>); <span class="synStatement">SELECT</span> * <span class="synSpecial">FROM</span> Example <span class="synSpecial">WHERE</span> Bits &amp; <span class="synConstant">1</span> = <span class="synConstant">1</span>; // Bits列の値と<span class="synConstant">1</span>のビットANDが<span class="synConstant">1</span>に等しい行を選択するのでヒットする <span class="synStatement">SELECT</span> * <span class="synSpecial">FROM</span> Example <span class="synSpecial">WHERE</span> Bits &amp; <span class="synConstant">2</span> = <span class="synConstant">2</span>; // Bits列の値と<span class="synConstant">2</span>のビットANDが<span class="synConstant">2</span>に等しい行を選択するのでヒットする <span class="synStatement">SELECT</span> * <span class="synSpecial">FROM</span> Example <span class="synSpecial">WHERE</span> Bits &amp; <span class="synConstant">4</span> = <span class="synConstant">4</span>; // <span class="synConstant">3</span>(<span class="synConstant">00000011</span>)と<span class="synConstant">4</span>(<span class="synConstant">00000100</span>)はそれぞれに<span class="synConstant">1</span>が立っている位置が違うのでヒットしない </pre> <p>Pythonの代表的なORMであるSQLAlchemyを使う場合は以下のように書けます。</p> <pre class="code lang-python" data-lang="python" data-unlink>query.filter(Example.Bits.op(<span class="synConstant">&quot;&amp;&quot;</span>)(bits1) == bits1) </pre> <h2 id="実装例">実装例</h2> <p>ビット演算で在庫管理するには、たとえば次のように実装します。</p> <ol> <li><code>INSERT INTO Example(Bits) VALUES (n);</code>の nに相当する値を在庫がある時間帯からビットへ変換して格納</li> <li>検索時に時間を<code>query.filter(Example.Bits.op("&amp;")(bits1) == bits1)</code>として検索し、取得できたBitsカラムを時間帯に変換</li> </ol> <p>なので、デメリットでもお伝えしましたとおり、ビット値と時間の配列の間を相互変換するライブラリの用意が必要です。<br/> 今回は先人達が実装してくれていたライブラリが社内にあったため、ありがたく使わせていただきました。</p> <h2 id="変換の考え方">変換の考え方</h2> <p>例えば00:00-23:45で15分スパンとしたとき、1日は96区切りです。<br/> <code>10:00 ~ 19:00に在庫が存在する</code>を表現すると以下のようになり、96bitsで時間が有効であれば1が立つと考えることができます👼<br/> 要件によっては00:00で終わりではなく、24時以降の表現をしたい場合もあるので、1日の区切り数やスパンをどうするかはプロジェクトの定義によって決めて下さい。</p> <pre class="code" data-lang="" data-unlink> |0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | || | | | | | | | | | | | | | | | | | | | | | | | | &lt;000000000000000000000000000000000000000011111111111111111111111111111111111100000000000000000000&gt;</pre> <p>1に関して、96bits(12bytes)のままではバイトオーダーの都合上扱いづらいので16bytesに変換すると、<code>b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xf0\x00\x00'</code>で、先頭~8bytesまでと9~16bytesまでの値を取得できます。これをbits1とbits2カラムとして格納します。<br/> 変換の一部をPythonでの実装してみると以下です。<br/> 実際の社内では複数のユースケースに対応できるように、より複雑なことをしてますが、社内のソースコードをそのまま載せられないのでサンプルコードのみです🙏</p> <pre class="code lang-python" data-lang="python" data-unlink>bits = <span class="synConstant">'000000000000000000000000000000000000000011111111111111111111111111111111111100000000000000000000'</span> bytes_array = <span class="synIdentifier">int</span>(bits, <span class="synConstant">2</span>).to_bytes(<span class="synConstant">16</span>, byteorder=<span class="synConstant">'big'</span>) bits_int1 = <span class="synIdentifier">int</span>.from_bytes(bytes_array[<span class="synConstant">0</span>:<span class="synConstant">8</span>], byteorder=<span class="synConstant">&quot;big&quot;</span>, signed=<span class="synIdentifier">True</span>) bits_int2 = <span class="synIdentifier">int</span>.from_bytes(bytes_array[<span class="synConstant">8</span>:<span class="synConstant">16</span>], byteorder=<span class="synConstant">&quot;big&quot;</span>, signed=<span class="synIdentifier">True</span>) <span class="synIdentifier">print</span>(bits_int1) <span class="synComment"># 0</span> <span class="synIdentifier">print</span>(bits_int2) <span class="synComment"># 72057594036879360</span> </pre> <p>2.に関しても逆の処理を行えば良く、検索したい時間をビットに変換し、データベースから時間帯をAND演算で取得。取得できたbits1/bitsをbytesに変換しつなげて、96bitsを復元します。<br/> あとは0と1の状態によって、00:00から15分おきに繰り返しで判定することで時間帯を復元できます🍿<br/> 変換の一部をPythonでの実装してみると以下です。</p> <pre class="code lang-python" data-lang="python" data-unlink>bits_pair = (<span class="synConstant">0</span>, <span class="synConstant">72057594036879360</span>) bytes_int1 = bits_pair[<span class="synConstant">0</span>].to_bytes(<span class="synConstant">8</span>, byteorder=<span class="synConstant">&quot;big&quot;</span>, signed=<span class="synIdentifier">True</span>) bytes_int2 = bits_pair[<span class="synConstant">1</span>].to_bytes(<span class="synConstant">8</span>, byteorder=<span class="synConstant">&quot;big&quot;</span>, signed=<span class="synIdentifier">True</span>) reconstructed_bits = <span class="synIdentifier">format</span>(<span class="synIdentifier">int</span>.from_bytes(bytes_int1 + bytes_int2, byteorder=<span class="synConstant">&quot;big&quot;</span>), <span class="synConstant">'096b'</span>) <span class="synIdentifier">print</span>(reconstructed_bits) <span class="synComment"># 000000000000000000000000000000000000000011111111111111111111111111111111111100000000000000000000が復元される</span> </pre> <p>以上が相互変換するイメージでございます。</p> <h2 id="最後に">最後に</h2> <p>時間をビットで持つ実装の他にもチューニングしたため、単体での評価はできていませんが、今回の取り組みを通してスパの検索画面の描画は従来から1/3~1/5程度時間短縮することができました。<br/> よって、ビットでの管理は今回スパの課題の解決手段としてはとても有効だったと考えます。<br/> 前述の通りデメリットもありますが、課題の解決手段の一つとして参考になれば幸いです!</p> <hr /> <p>一休では、ともに試行錯誤しながらよいサービスを作ってくれる仲間を募集しています!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <p>カジュアル面談も実施していますので、ぜひお気軽にご連絡ください!<br/> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> shibatai 開発プロセスをインクリメンタルに改善する hatenablog://entry/6801883189088581141 2024-03-13T12:49:07+09:00 2024-03-13T12:49:07+09:00 一休.comレストランのエンジニアのkymmtです。 2023年度の下半期、一休.comレストランの開発チームでは開発プロセス改善に取り組みました。改善は小さい単位で徐々に進め、バックログの作りかたやカンバンの運用方法を改善することで、フロー効率の向上、開発ペースの把握、チーム内外からの進捗の見える化ができるようになりました。 この記事では、このようなインクリメンタルな開発プロセス改善の取り組みについて紹介します。 従来の開発プロセス 主に2023年度前半の開発プロセスは次のような形でした1。 プロダクトのリリースに必要なタスクが長いバックログとして存在し、ひたすらタスクを消化 その状況に課題… <p>一休.comレストランのエンジニアの<a href="https://twitter.com/kymmt90">kymmt</a>です。</p> <p>2023年度の下半期、一休.comレストランの開発チームでは開発プロセス改善に取り組みました。改善は小さい単位で徐々に進め、バックログの作りかたやカンバンの運用方法を改善することで、フロー効率の向上、開発ペースの把握、チーム内外からの進捗の見える化ができるようになりました。</p> <p>この記事では、このようなインクリメンタルな開発プロセス改善の取り組みについて紹介します。</p> <h2 id="従来の開発プロセス">従来の開発プロセス</h2> <p>主に2023年度前半の開発プロセスは次のような形でした<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>。</p> <ul> <li>プロダクトのリリースに必要なタスクが長いバックログとして存在し、ひたすらタスクを消化</li> <li>その状況に課題を感じ、区切りを入れるために2週間のスプリントを導入</li> </ul> <p>この時点では、スプリントは2週間ごとに状況を確認するためのもので、目標に対するふりかえりや、次のスプリントの計画を作るためのものとしては活用していませんでした。</p> <p>この開発プロセスに起因して、チームメンバーは次のような課題を感じていました。</p> <ul> <li>どの機能に紐づくかが一見してわかりにくい技術的タスクや、やることが曖昧な項目がバックログにある</li> <li>タスクは進んでいるが、ひとまとまりの機能ができるのに時間がかかる</li> <li>開発ペースを見通しにくく、今後の予定についてチーム内外に説明責任を果たしにくい</li> <li>スプリントを導入したものの、スプリント終了時の残項目が完了しなかった理由など、開発のボトルネックを深掘りできていない</li> </ul> <h2 id="改善の方針">改善の方針</h2> <p>先述した課題を受けて、開発プロセスをできるだけ早く改善したいという機運が生まれました。しかし、スクラムなど大きめの方法論をチームに導入するのはこれまで例がなく、ある種の理想的な開発プロセスには近づけますが、効果が出るまでに時間がかかりそうでした。また、著者(kymmt)は入社直後だったので、技術的なキャッチアップと並行してプロセス改善をサポートしたいという状況でした。</p> <p>そこで、アジャイル開発のプラクティスをインクリメンタルに導入してプロセスを改善することにしました。</p> <p>ここで、それらのプラクティスの生まれた理由や避けるべき罠は理解したうえで、課題の解決に必要なものを選択的に導入するという点に気を配りました。最近出た本だと<a href="https://www.shoeisha.co.jp/book/detail/9784798176826">『アジャイルプラクティスガイドブック』</a>は参考になりました。</p> <h2 id="2023年度後半からの開発プロセス">2023年度後半からの開発プロセス</h2> <p>上記の方針に基づいて、2023年度下半期からは、チームで次のような改善活動に取り組みました。</p> <ul> <li>顧客価値に直結する開発はユーザーストーリーとして項目を整理し、その下で技術的タスクを分解/整理する</li> <li>カンバン上でユーザーストーリーを左から右に流すようにして、顧客価値がどの程度生み出せているか、ボトルネックはどこかを見える化する</li> <li>ユーザーストーリーに対する規模の見積もりとベロシティの計測を繰り返し、開発の見通しを立てられるようにする</li> </ul> <p>これらの活動はある小規模なプロジェクトから始めて、次にもう1つの中規模なプロジェクトに横展開することで、徐々にチーム全体に活動範囲を広げました。</p> <h2 id="導入の様子">導入の様子</h2> <h3 id="小規模の開発プロジェクトへの導入">小規模の開発プロジェクトへの導入</h3> <p>すでに述べたとおり、2週間ごとに期間を区切るという枠組みだけ導入されていました。今回はそれを足がかりに、まずは小さい規模の開発プロジェクト(強いていうならエピック)に対してプラクティスを導入していきました。</p> <p>まず、事前にユーザーストーリーとして開発項目を改めて明らかにしつつ整理し直しました。そして、それらに優先度をつけてバックログ上で並び替えました。あくまでも例ですが、次のようなイメージです。</p> <table> <thead> <tr> <th style="text-align:left;">名前</th> <th style="text-align:left;">優先度</th> </tr> </thead> <tbody> <tr> <td style="text-align:left;">ユーザーが関連するレストランの一覧を閲覧できる</td> <td style="text-align:left;">高</td> </tr> <tr> <td style="text-align:left;">ユーザーが人気のレストランの一覧を閲覧できる</td> <td style="text-align:left;">中</td> </tr> <tr> <td style="text-align:left;">ユーザーが近隣のスポットに基づくレストランの一覧を閲覧できる</td> <td style="text-align:left;">低</td> </tr> </tbody> </table> <p>(ここでは一休.comレストランの利用者のことを「ユーザー」と呼んでいます)</p> <p>そのうえで、項目の規模を相対見積もりしました。ストーリーに必要な技術的タスクについて認識を合わせながら、それぞれの項目の相対的な規模を比較します。現在に至るまで、フィボナッチ数列に基づくストーリーポイント(1, 2, 3, 5, 8)を使っています。ここでは、プロジェクトに携わる3人ほどで、規模の感覚を揃えて見積もりをしました。古典ですが<a href="https://book.mynavi.jp/ec/products/detail/id=22141">『アジャイルな見積りと計画づくり』</a>もあらためて参考にしました。</p> <p>これらの項目を左から右に「To Do」、「In Progress」、「In Review」、「Done」のレーンを持つカンバンで管理します。これまでベロシティを計測したことがなかったので、見積もり実施後の初回スプリントでは、優先度に基づいてバックログの項目を「To Do」に並べ、優先度が高いものから取り組みました。また、できるだけ複数ストーリーを取らない(マルチタスクにならない)ように進めました<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup>。</p> <p>この時点でバックログの項目が整理された状態でカンバン上に現れ、関係者から見て進捗がわかりやすくなりました。また、スプリントを繰り返すなかで、カンバン上にあるストーリーを左から右に流すために複数人で手分けするような動きもできるようになりました。この点が効いて、目標期日をきつめにとっていましたがプロジェクトの作業を完了できました。</p> <p>一方で、一部の開発プロジェクトだけに改善を適用していたので、チーム全体の開発ペースの計測ができていませんでした。これについては、次の中規模の開発プロジェクトであらためて進めました。</p> <h3 id="ツールの適切な運用">ツールの適切な運用</h3> <p>カンバン導入と前後して、コードベースとプロジェクト管理の距離が近いほうがチームの好みに合っていたので、従来Jiraを使っていたところをGitHub Projectsに移行し、これまで述べた運用に沿うようにカンバンや項目のメタデータを整備しました。また、チームで合意した運用方法はドキュメントとして明文化しました。</p> <p>GitHub Projectsの効果的な利用方法については、以前このブログでitinaoが紹介しているのでぜひご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2023%2F11%2F09%2F175121" title="GitHub Projects を利用したタスク管理 - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2023/11/09/175121">user-first.ikyu.co.jp</a></cite></p> <p>できるだけ業務に支障がないように、Jiraにあったデータも移行しました。こういう移行はやり切るのが大事なので、GitHub APIを利用して必要なデータを極力自動でGitHub側にインポートしました。</p> <p><figure class="figure-image figure-image-fotolife" title="一休.comレストラン開発チームのカンバン"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kymmt90/20240308/20240308150355.png" alt="&#x4E00;&#x4F11;.com&#x30EC;&#x30B9;&#x30C8;&#x30E9;&#x30F3;&#x958B;&#x767A;&#x30C1;&#x30FC;&#x30E0;&#x306E;&#x30AB;&#x30F3;&#x30D0;&#x30F3;" width="1200" height="429" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>一休.comレストラン開発チームのカンバン</figcaption></figure></p> <p>項目間の依存関係を示しづらいなどの課題感もありますが、現在はおおむね現状を把握しやすいカンバンを運用できています。</p> <h3 id="中規模の開発プロジェクトへの導入">中規模の開発プロジェクトへの導入</h3> <p>前述のとおり、ある程度プラクティスの導入による効果が出てきたので、著者(kymmt)が直接担当しているわけではない別の中規模プロジェクトについても導入してみました。</p> <p>このフェイズでは、メンバー全員がプラクティスを実践できるように、プロジェクトを進めるメンバーと一緒にストーリーの単位で項目を整理し直し、方法のコツなどを共有しました。さらに、それらの相対規模の見積もりも一緒にやることで、規模に対する感覚をチーム全体で揃えていきました。</p> <p>もとは「状態管理追加」、「UI実装」のような技術的タスクの単位で項目が並べられていましたが、項目間の依存関係やまとまりを顧客価値として整理することで、何が実現できるか明確になりました。また、カンバン上でユーザーストーリーの粒度で左から右に1つずつ開発項目を流せるようになりました。チームメンバーからも作業が進めやすくなり、1つ1つのユーザーストーリーのリードタイムが向上したという声をもらいました。</p> <p>加えて、見積もりされたバックログ項目に取り組む中で、チーム全体のベロシティも安定して見えるようになってきたので、今後の開発の見通しを立てやすくなりました。</p> <p><figure class="figure-image figure-image-fotolife" title="一休.comレストラン開発チームのベロシティ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kymmt90/20240308/20240308150505.png" alt="&#x4E00;&#x4F11;.com&#x30EC;&#x30B9;&#x30C8;&#x30E9;&#x30F3;&#x958B;&#x767A;&#x30C1;&#x30FC;&#x30E0;&#x306E;&#x30D9;&#x30ED;&#x30B7;&#x30C6;&#x30A3;" width="1200" height="829" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>一休.comレストラン開発チームのベロシティ</figcaption></figure></p> <h3 id="スプリント開始時にチームで計画づくり">スプリント開始時にチームで計画づくり</h3> <p>以前は前のスプリントの残項目をそのまま次スプリントに移す<sup id="fnref:3"><a href="#fn:3" rel="footnote">3</a></sup>というプロセスでしたが、現在はビジネスの状況やすべきことの優先度、またチームのベロシティも都度確認して、目標を決めてバックログを作っています。</p> <p>結果的に前スプリントで残った分も次のスプリントでやりましょうになることはあるのですが、なにも考えずに移すのではなく議論をしたうえで必要なら移すというプロセスを経るようにしています。</p> <h2 id="結果">結果</h2> <p>2023年度下半期に次のような開発プロセス改善活動をおこないました。</p> <ul> <li>顧客価値に直結する開発をユーザーストーリーとして項目を整理</li> <li>カンバン上で顧客価値につながる開発の進捗やボトルネックを見える化</li> <li>ユーザーストーリーに対する規模の見積もりとベロシティの計測で開発ペースを見える化</li> <li>スプリントの計画づくりで目標を定め、そのために必要なバックログを作る</li> </ul> <p>もともと技術的にしっかりしたチームだったので、これらの改善活動の結果でフロー効率をよくすることで、以前よりリードタイムの向上や安定が見られるようになりました。</p> <p>また、ストーリーに基づいた開発項目の見える化によって進捗がチーム内外からわかりやすくなり、デモやレポーティングなど組織運営に必要な業務も進めやすくなりました。先の計画を立てやすく、予定変更にも柔軟に対応できるようになってきています。</p> <p>他には、計画づくりに意識的に取り組むようになったので、ずるずると開発してしまうことが減りました。ビジネスの推進に必要なことがなにかを都度確認しながら開発を進められています。</p> <h2 id="これから">これから</h2> <p>すでに始めている取り組みとして、継続的に各チームメンバーがプロセス改善できるように、開発プロセスに関する知識をインプットする読書会を週次で開催しています。先日<a href="https://www.oreilly.co.jp/books/9784873117645/">『カンバン仕事術』</a>を読み終えたところです。</p> <p>課題としては、技術的に専門性のあるメンバーに下周りの整備のようなタスクが集中したり、緊急の差し込みタスクをシステムに詳しいメンバーが多めに取りがちだったりと、メンバー間のスキルの差によってWIPが多くなったりすることもあります。こういうときにタスクを取捨選択したり、メンバー間で知識を共有していく方法については、既存のプラクティスも参照しながら継続的にチームで考えていくつもりです。</p> <hr /> <p>一休では、ともに良いサービスをつくっていく仲間を募集中です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <p>カジュアル面談も実施しているので、お気軽にご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> 著者(kymmt)は入社前〜入社直後なので聞いた話も含みます<a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> WIP制限に基づく方針ですが、このとき数値はとくに指定していませんでした<a href="#fnref:2" rev="footnote">&#8617;</a></li> <li id="fn:3"> Jiraの機能でそうなっていたというのもあります<a href="#fnref:3" rev="footnote">&#8617;</a></li> </ol> </div> kymmt90 一休レストランのふつうのRustバックエンド開発 hatenablog://entry/6801883189068906224 2023-12-25T13:22:15+09:00 2023-12-25T13:22:15+09:00 この記事は一休.com Advent Calendar 2023 25日目の記事です。 一休レストランでは、よりスムーズな予約体験の提供を目的とするシステムのリニューアルを進めています。その一環として、2023年10月から、レストラン個別ページの表示から予約までのスマートフォンビューにおいて、バックエンドのサーバをRustで書かれたものに置き換えました。 一休レストランの Rust バックエンドが正式リリースされました。https://t.co/7N4VGv5ej9 このページのスマートフォンビューはバックエンドが Rust で書かれた GraphQL になってます— naoya (@naoy… <p>この記事は<a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a> 25日目の記事です。</p> <p>一休レストランでは、よりスムーズな予約体験の提供を目的とするシステムのリニューアルを進めています。その一環として、2023年10月から、レストラン個別ページの表示から予約までのスマートフォンビューにおいて、バックエンドのサーバをRustで書かれたものに置き換えました。</p> <blockquote class="twitter-tweet"><p lang="ja" dir="ltr">一休レストランの Rust バックエンドが正式リリースされました。<a href="https://t.co/7N4VGv5ej9">https://t.co/7N4VGv5ej9</a> このページのスマートフォンビューはバックエンドが Rust で書かれた GraphQL になってます</p>&mdash; naoya (@naoya_ito) <a href="https://twitter.com/naoya_ito/status/1709507132209680702?ref_src=twsrc%5Etfw">October 4, 2023</a></blockquote> <p> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></p> <p>本番運用が始まって3か月近く経ちましたが、これまで安定して継続的な開発と運用ができています。これはRustだからと構えることなく、「ふつう」のバックエンド開発を心がけてきたからだと考えています。</p> <p>Advent Calendar 2023最終日は、一休レストランの開発チーム一同から、一休レストランのRustバックエンド開発の様子をお届けします。</p> <ul class="table-of-contents"> <li><a href="#Rustを選定した理由">Rustを選定した理由</a></li> <li><a href="#現在のバックエンドのユースケース">現在のバックエンドのユースケース</a><ul> <li><a href="#レストラン情報の取得">レストラン情報の取得</a></li> <li><a href="#予約の確保">予約の確保</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="#GraphQLとHTTPサーバ">GraphQLとHTTPサーバ</a></li> <li><a href="#ライブラリ">ライブラリ</a></li> </ul> </li> <li><a href="#Rustによる開発のふりかえり">Rustによる開発のふりかえり</a><ul> <li><a href="#よかったこと">よかったこと</a><ul> <li><a href="#Rustはビジネスロジックを書くのにも便利">Rustはビジネスロジックを書くのにも便利</a></li> <li><a href="#アプリケーションの各層で型安全にデータを変換">アプリケーションの各層で型安全にデータを変換</a></li> <li><a href="#Cargo-workspaceを活用した開発">Cargo workspaceを活用した開発</a></li> <li><a href="#パフォーマンスの向上">パフォーマンスの向上</a></li> </ul> </li> <li><a href="#もっとよくなると嬉しいこと">もっとよくなると嬉しいこと</a><ul> <li><a href="#エコシステムのさらなる成熟">エコシステムのさらなる成熟</a></li> </ul> </li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> </ul> <h2 id="Rustを選定した理由">Rustを選定した理由</h2> <p>一休レストランのリニューアル計画が始まったころ、一休では宿泊予約サービスや社内の基盤サービスを中心としてGoが標準的なバックエンドの技術スタックでした。</p> <p>一休レストランの開発でも、宿泊予約サービスでの経験があるメンバーのスキルセットに基づいてGoを使うこともできました。その一方で、この方針だと社内の技術ポートフォリオがGoに偏ってしまうという懸念もありました。</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/kymmt90/20231222/20231222151615.jpg" width="1200" height="780" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption><a href="https://speakerdeck.com/kensuketanaka/introduce-ikyu?slide=25">&#x682A;&#x5F0F;&#x4F1A;&#x793E;&#x4E00;&#x4F11; &#x4F1A;&#x793E;&#x7D39;&#x4ECB;&#x8CC7;&#x6599; / introduce-ikyu - Speaker Deck</a> より一休の技術選定の方針について</figcaption></figure></p> <p>そこで、チームメンバーの中にRustに詳しいエンジニアがいたことも助けになり、Rustをバックエンドの言語として採用するかどうかを検討しました。</p> <p>Rustの採用による狙いは次のとおりです。</p> <ul> <li>まず置き換えたい参照系処理のCPU利用効率を上げて、高速なバックエンドサーバとする</li> <li>今後のさらなる開発を見据え、メモリ安全、型安全な開発体験を実現する</li> <li>技術的知見の多様性という点で、関数型のメンタルモデルでプログラミングできるエンジニアを増やす</li> </ul> <p>同時に、Rustの採用に対する次のような懸念も上がりました。</p> <ul> <li>初めて使うエンジニアにとっては学習に時間がかかる</li> <li>ライブラリの自作が必要となるケースもありそう</li> </ul> <p>Rustは<a href="https://www.rust-lang.org/learn">公式ドキュメント</a>や<a href="https://docs.rs/">docs.rs</a>のリファレンスなどでドキュメントが充実しているので、学習曲線は急ではあるものの、学習自体は進めやすいと判断しました。</p> <p>ライブラリについては、Rustから一休の基幹DBであるSQL Serverにどうやって接続するかという技術的な検証が必要でした。最終的には、Prismaが公開している<a href="https://github.com/prisma/tiberius">Tiberius</a>というSQL Server用のDBドライバをベースとして、ある程度アプリケーションから使いやすいインタフェースのライブラリを整備することで開発が進められると判断できました。</p> <p>これらの議論や調査に基づいて、一休レストランのバックエンドでRustを採用することになりました。</p> <p>現在、一休レストランのバックエンドを開発するエンジニアは3人います。そのうち2人は、一休レストランの開発をきっかけに、はじめてRustを本格的に利用し始めました。豊富な学習リソースやRustに詳しいメンバーのヘルプを通じて、プロジェクト開始前の学習では<code>String</code>と<code>&amp;str</code>の違いを理解するところから始めたメンバーも、プロジェクト開始後はスムーズに開発できるようになりました。</p> <h2 id="現在のバックエンドのユースケース">現在のバックエンドのユースケース</h2> <p>ここからはRustでバックエンドを「ふつう」に開発するための、設計や実装における面白いポイントを紹介していきます。</p> <p>現在は主に次のユースケースでバックエンドを利用しています。</p> <h3 id="レストラン情報の取得">レストラン情報の取得</h3> <p>店舗情報や予約可能時間など、レストランの情報をお客様に提供するための情報を取得します。機能はGraphQLのクエリとして提供しています。</p> <p>今回はレストラン個別のページの表示から予約までのフローの置き換えを開発スコープとしたので、現在はこのユースケースが大半を占めています。後述のとおりコードベース上もデータの読み出しに関するコードが多いです。</p> <h3 id="予約の確保">予約の確保</h3> <p>お客様から入力いただいた情報をもとに予約を確保するエンドポイントをGraphQLのミューテーションとして提供しています。また、実際の予約処理は、予約処理モジュールを持つ既存の社内別サービスに委譲しています。</p> <h2 id="現在のアーキテクチャ">現在のアーキテクチャ</h2> <p>現在、アプリケーションのアーキテクチャとしてコマンドクエリ責務分離(CQRS)に基づいた構造を採用しています。つまり、データを読み出すだけのクエリと、データの作成や更新をするコマンドで、利用するモデルを分離する方式をとっています。</p> <p>また、たとえばクエリの場合、DBとSolrそれぞれについてデータアクセス層を設け、GraphQLのデータローダーのようなシステムの界面に近い層からは、データアクセス層を通じてクエリモデルの形式でデータを取得します。</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/i/ikyu_com/20231225/20231225120420.png" width="575" height="601" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>モジュールとその依存関係</figcaption></figure></p> <p>これらのモジュールはCargo workspaceを用いて管理しています。この点についてはあとで詳しく説明します。</p> <h2 id="各モジュールの紹介">各モジュールの紹介</h2> <p>上述した図における各層を構成するモジュールについて紹介します。</p> <h3 id="ドメインモデル">ドメインモデル</h3> <p>CQRSにおけるクエリとコマンドで利用するモデルを実装している層です。ドメインモデルは他のどのモジュールにも依存しません。また、クエリとコマンドは別モジュールとするためにcrateを分けています。</p> <p>クエリモデルの例としては、レストラン詳細画面で表示する店舗情報があります。これらのデータは実際は複数のテーブルに存在しますが、クエリモデルはそのような実装詳細には依存せず、クエリの結果としてほしい構造を定義しています。実際には、SQL ServerもしくはSolrから得たデータをクエリモデルに変換して利用します。</p> <pre class="code lang-rust" data-lang="rust" data-unlink><span class="synPreProc">#[derive(</span><span class="synType">Debug</span><span class="synPreProc">, </span><span class="synType">Clone</span><span class="synPreProc">)]</span> <span class="synStatement">pub</span> <span class="synStatement">struct</span> <span class="synIdentifier">Restaurant</span> { <span class="synStatement">pub</span> id: RestaurantId, <span class="synStatement">pub</span> name: <span class="synType">String</span>, <span class="synStatement">pub</span> description: <span class="synType">Option</span><span class="synStatement">&lt;</span><span class="synType">String</span><span class="synStatement">&gt;</span>, <span class="synComment">// ...</span> } </pre> <p>コマンドモデルの例としてはお気に入り店舗登録用のコマンドモデルなどが存在します。こちらはまだ数が少ないので割愛します。</p> <h3 id="データアクセス層">データアクセス層</h3> <p>実際のデータを取得するためのロジックを実装している層です。現在は、一休の基幹DBであるSQL Serverや、検索サーバであるSolrからデータを取得しています。このデータアクセス層の利用者に対して、取得したデータをもとにモデルのインスタンスを返します。つまり、ドメインモデルに依存します。</p> <p>クエリを実行するときは、<a href="https://github.com/serde-rs/serde">Serde</a>や<a href="https://docs.rs/serde_with/latest/serde_with/">serde_with</a>を利用して、データストアから取得した生データをDTOにデシリアライズします。</p> <pre class="code lang-rust" data-lang="rust" data-unlink><span class="synStatement">mod</span> <span class="synIdentifier">dto</span> { <span class="synComment">// ...</span> <span class="synPreProc">#[serde_with::serde_as]</span> <span class="synPreProc">#[derive(</span><span class="synType">Debug</span><span class="synPreProc">, serde::Deserialize)]</span> <span class="synStatement">pub</span> <span class="synStatement">struct</span> <span class="synIdentifier">Restaurant</span> { <span class="synPreProc">#[serde(rename = </span><span class="synConstant">&quot;restaurant_id&quot;</span><span class="synPreProc">)]</span> <span class="synPreProc">#[serde_as(as = </span><span class="synConstant">&quot;serde_with::TryFromInto&lt;i32&gt;&quot;</span><span class="synPreProc">)]</span> id: RestaurantId, <span class="synPreProc">#[serde(rename = </span><span class="synConstant">&quot;restaurant_name&quot;</span><span class="synPreProc">)]</span> name: <span class="synType">String</span>, <span class="synComment">// ...</span> } } </pre> <p>さらに、このDTOからクエリモデルに変換するために<code>std::convert</code>の<code>From</code>トレイトや<code>TryFrom</code>トレイトを活用しています。詳しくは後述します。</p> <h3 id="GraphQLとHTTPサーバ">GraphQLとHTTPサーバ</h3> <p>バックエンドはGraphQLを通じてフロントエンドにクエリとミューテーションを提供しています。このGraphQL APIの実装にはasync-graphqlを利用しています。async-graphqlはコードファーストでGraphQLスキーマを定義できるcrateです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fasync-graphql%2Fasync-graphql" title="GitHub - async-graphql/async-graphql: A GraphQL server library implemented in Rust" 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/async-graphql/async-graphql">github.com</a></cite></p> <pre class="code lang-rust" data-lang="rust" data-unlink><span class="synComment">// Restaurant {</span> <span class="synComment">// name</span> <span class="synComment">// }</span> <span class="synComment">// のようなスキーマをコードで定義</span> <span class="synStatement">pub</span> <span class="synStatement">struct</span> <span class="synIdentifier">Restaurant</span>(<span class="synStatement">pub</span> <span class="synPreProc">query_model</span><span class="synSpecial">::</span>Restaurant); <span class="synPreProc">#[async_graphql::Object]</span> <span class="synStatement">impl</span> Restaurant { async <span class="synStatement">fn</span> <span class="synIdentifier">name</span>(<span class="synType">&amp;</span><span class="synConstant">self</span>) <span class="synStatement">-&gt;</span> <span class="synType">&amp;str</span> { <span class="synType">&amp;</span><span class="synConstant">self</span>.<span class="synConstant">0</span>.name } <span class="synComment">// ...</span> } </pre> <p>また、HTTPサーバとしてはAxumを利用しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Ftokio-rs%2Faxum" title="GitHub - tokio-rs/axum: Ergonomic and modular web framework built with Tokio, Tower, and Hyper" 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/tokio-rs/axum">github.com</a></cite></p> <p>これまではGraphQLなのでエンドポイント1つで済んでいましたが、最近は社内の他サービスと通信するためにインターナルなREST APIを作る機会も増えてきています。</p> <h3 id="ライブラリ">ライブラリ</h3> <p>アプリケーションを構成するモジュールとは別に、独立したロジックをまとめたライブラリとしてのcrateもいくつか作成してworkspaceに含めています。これらのライブラリは他モジュールから利用されます。</p> <p>たとえば、先述したTiberiusをベースにしたDBドライバや社内サービスのクライアント、他にはログなどの横断的関心事を扱うライブラリが存在します。</p> <h2 id="Rustによる開発のふりかえり">Rustによる開発のふりかえり</h2> <h3 id="よかったこと">よかったこと</h3> <h4 id="Rustはビジネスロジックを書くのにも便利">Rustはビジネスロジックを書くのにも便利</h4> <p>Rustの言語機能として、所有権やライフタイムのようにメモリ安全性を意識したものがよく注目されます。さらに、Webアプリケーションバックエンドを書くうえでは、<a href="https://doc.rust-lang.org/std/option/index.html"><code>Option</code></a>や<a href="https://doc.rust-lang.org/std/result/index.html"><code>Result</code></a>に代表される関数型言語のエッセンスを取り込んだ機能や、データ変換にまつわる機能も非常に便利だとあらためて感じました。</p> <p>一休レストランは15年以上の歴史があるサービスです。このようなサービスは、しばしば歴史的事情からなるデータ構造やコードを多く持っています。たとえば有効な値とnullの両方が存在しうるカラムを扱うこともあります。このときに<code>Option</code>を利用することで、ビジネスロジック上でnullにまつわるバグを避け、match式やif let式によって値がないケースをつねに考慮できます。</p> <p>また、Webアプリケーションは無効な値を入力されたり外部のサービスとの通信に失敗するなど、つねにロジックが失敗する可能性があります。そのようなロジックでは返り値として<code>Result</code><sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>を使うことで、確実にエラーをハンドリングできます。また、<code>?</code>演算子を利用することで、コードを簡潔に保ちつつエラーハンドリングできるのも便利な点です。</p> <p>他には、一休レストランだと予約可能な時間や食事コースの検索結果などでコレクションを操作する場面が数多くあります。このようなときに、イテレータと<a href="https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.map"><code>map</code></a>や<a href="https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.filter"><code>filter</code></a>のようなイテレータアダプタを利用することで、コレクションにまつわるビジネスロジックを簡潔に書けるのもよい点だと感じています。</p> <h4 id="アプリケーションの各層で型安全にデータを変換">アプリケーションの各層で型安全にデータを変換</h4> <p>先述したように、このアプリケーションでは複数のモジュールで責務を分けています。よって、そのままではデータアクセス層でデータストアから取得した生のデータをDTOを経由してクエリモデルに変換するロジックを書く必要が出てきます。</p> <p>ここで、<a href="https://doc.rust-lang.org/std/convert/trait.From.html"><code>From</code></a>トレイトや<a href="https://doc.rust-lang.org/std/convert/trait.TryFrom.html"><code>TryFrom</code></a>トレイトを用いて型安全なデータの変換を実装することで、層の間で安全にデータを受け渡しできます。たとえばDTOをクエリモデルに変換するために<code>From</code>トレイトや<code>TryFrom</code>トレイトをDTOに対して実装し、適切にモデルへ変換できるようにしています。</p> <pre class="code lang-rust" data-lang="rust" data-unlink><span class="synStatement">impl</span> <span class="synType">From</span><span class="synStatement">&lt;</span><span class="synPreProc">dto</span><span class="synSpecial">::</span>Restaurant<span class="synStatement">&gt;</span> <span class="synStatement">for</span> <span class="synPreProc">query_model</span><span class="synSpecial">::</span>Restaurant { <span class="synStatement">fn</span> <span class="synIdentifier">from</span>(d: <span class="synPreProc">dto</span><span class="synSpecial">::</span>Restaurant) <span class="synStatement">-&gt;</span> <span class="synType">Self</span> { <span class="synPreProc">query_model</span><span class="synSpecial">::</span>Restaurant { id: d.id, name: d.name, <span class="synComment">// ...</span> } } } </pre> <p>このようにモデルに対して変換のためのトレイトを実装しておけば、あとは<code>from</code>/<code>try_from</code>や<a href="https://doc.rust-lang.org/std/convert/trait.Into.html#tymethod.into"><code>into</code></a>/<a href="https://doc.rust-lang.org/std/convert/trait.TryInto.html#tymethod.try_into"><code>try_into</code></a>を使うだけで層の間の型安全なデータ変換が可能になります。</p> <h4 id="Cargo-workspaceを活用した開発">Cargo workspaceを活用した開発</h4> <p>Cargo workspaceを活用してモジュール間の依存関係を制御しながら開発できているのもよい点です。</p> <p>リポジトリのルートディレクトリにあるCargo.tomlでは、workspaceのmembersとしてアプリケーション内の各モジュールを指定しています。そして、それらのモジュールをcrateとして実装し、各crateのCargo.tomlではアーキテクチャを意識して他のcrateへの依存関係を設定することで、意図しない依存はコンパイラによってエラーにできる構造にしています。</p> <pre class="code toml" data-lang="toml" data-unlink># ルートディレクトリのCargo.toml [workspace] resolver = &#34;2&#34; members = [ &#34;backend/*&#34;, ] # データアクセス層のCargo.toml [package] name = &#34;backend-data-access&#34; version.workspace = true authors.workspace = true edition.workspace = true publish.workspace = true [dependencies] backend-query-model = { workspace = true }</pre> <p>また、モジュールをcrateに分離したことで、コードを変更したときに、変更のあったcrateとそのcrateに依存するcrateだけを再ビルドすればよくなりました。結果として、毎回アプリケーション全体をビルドせずに済み、開発時のビルド時間の短縮にも貢献しています。</p> <h4 id="パフォーマンスの向上">パフォーマンスの向上</h4> <p>もちろんパフォーマンスの向上も当初の狙いどおり達成できた点であり、よかったことの1つです。</p> <p>バックエンドはGoogle Cloud Runで運用しています。現在は年末年始でレストラン予約が非常に増える時期ですが、ピーク時でも3台程度のインスタンスでリクエストを受けることができています。</p> <p>また、一休レストランのバックエンドの一部をRustに移行したことで、従来のPythonのバックエンドにおけるKubernetes DeploymentのReplicaSet数を次のように60程度から40程度に減らすことができました。</p> <p><figure class="figure-image figure-image-fotolife" title="Wed 4以降はPythonバックエンドの負荷をオフロードできた"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ikyu_com/20231224/20231224133930.png" width="697" height="703" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Wed 4以降はPythonバックエンドの負荷をオフロードできた</figcaption></figure></p> <p>他には、バックエンドの高速化にともなってサービス全体の構成を最適化することで、一休レストラン全体のパフォーマンスが向上しました。こちらについてはチームメンバーのkozaiyが次の記事に詳しく書いたのでご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2023%2F12%2F06%2F173215" title="Solr クエリを速度改善したら Solr 全体のパフォーマンスが向上した - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2023/12/06/173215">user-first.ikyu.co.jp</a></cite></p> <h3 id="もっとよくなると嬉しいこと">もっとよくなると嬉しいこと</h3> <h4 id="エコシステムのさらなる成熟">エコシステムのさらなる成熟</h4> <p>Webアプリケーションバックエンドを開発するうえで、さらにプラットフォームのRust対応が拡充されると開発が楽になりそうです。</p> <p>たとえば、現在はCloud Runを使っているので、APMとしてCloud Traceを利用することにしました。しかし、公式にはRustのSDKが提供されていないことから、独自のライブラリを開発することで対応しています。</p> <h2 id="まとめ">まとめ</h2> <p>この記事では、一休レストランにおいてRustを採用した理由と、Rustによる「ふつう」のWebアプリケーションバックエンド開発の様子について紹介しました。</p> <p>Rustを採用したことで、期待どおり性能面で大きなメリットを得ることができました。また、RustやCargoの機能を適切に活用することで、生産性を保ちつつ今後の継続性も考慮した設計で開発を進めることができています。</p> <p>新たにRustを利用し始めたチームメンバーからは、Rustに対する感想として</p> <blockquote><ul> <li>自分自身にプログラミングを教えてくれる言語だなと思いました</li> <li>プログラミングする上で、気にすべきポイントを気にさせてくれる言語</li> </ul> </blockquote> <p>という声もあがっています。</p> <p>今後のバックエンドの展望としては、よりよい予約体験の提供やレガシーシステムの改善を目的として、</p> <ul> <li>高速なレスポンスが求められるレストラン検索</li> <li>レストラン予約のロジックなどのレガシーかつコアドメインであるモジュール</li> </ul> <p>についてもRustで置き換えていく予定です。このような箇所では、高いパフォーマンスや型に守られた開発体験を提供してくれるRustを活かすことができるだろうと考えています。</p> <p>このような技術的なチャレンジができる一休レストランのバックエンド開発に興味があるかたは、ぜひカジュアル面談応募ページや求人ページからご連絡ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> 一休レストランでは<a href="https://docs.rs/anyhow/latest/anyhow/type.Result.html"><code>anyhow::Result</code></a>を利用しています<a href="#fnref:1" rev="footnote">&#8617;</a></li> </ol> </div> ikyu_com 開発ディレクターの進化と挑戦 hatenablog://entry/6801883189069279420 2023-12-24T09:09:02+09:00 2023-12-24T09:09:02+09:00 この記事は 一休.com Advent Calendar 2023 24日目の記事です。 宿泊プロダクト開発で開発ディレクターをしています、橋本と申します。 ついにクリスマスイブ。残すところこの記事を含めて2つとなりました。 本日の記事では開発ディレクター1年目の奮闘劇を皆さんに紹介したいと思います。 同じディレクターの方はもちろん、何か新しいことに挑戦している皆さんに届くと嬉しいです。 簡単に経歴紹介 新卒でNWインフラの会社に入社し、 エンジニアとして法人顧客のサービス導入をサポートをしてきました。 AWSの運用、セキュリティ商材の導入、NW機器の導入運用、スマホ管理サービス導入など様々な… <p>この記事は <a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a> 24日目の記事です。</p> <hr /> <p>宿泊プロダクト開発で開発ディレクターをしています、橋本と申します。<br/> ついにクリスマスイブ。残すところこの記事を含めて2つとなりました。<br/> 本日の記事では開発ディレクター1年目の奮闘劇を皆さんに紹介したいと思います。<br/> 同じディレクターの方はもちろん、何か新しいことに挑戦している皆さんに届くと嬉しいです。</p> <h1 id="簡単に経歴紹介">簡単に経歴紹介</h1> <p>新卒でNWインフラの会社に入社し、 エンジニアとして法人顧客のサービス導入をサポートをしてきました。 AWSの運用、セキュリティ商材の導入、NW機器の導入運用、スマホ管理サービス導入など様々な分野を担当し、直近では技術営業として提案メインでの活動に従事していました。</p> <p>5年目になったころ、サービスの導入ではなく、サービスを作ることに興味を持ち、プロダクト開発という新しい分野にチャレンジすることを決めました。<br/> そこから社内の制度を活用し、現在は一休にお世話になっています。</p> <h1 id="奮闘劇">奮闘劇</h1> <h2 id="インプット多量死をなんとか免れた序盤">インプット多量死をなんとか免れた序盤</h2> <p>入社前に開発ディレクターとはなんぞやということですごく簡単な資格だけ取りました。<br/> <a href="https://webken.jp/direction/">Web&#x30C7;&#x30A3;&#x30EC;&#x30AF;&#x30B7;&#x30E7;&#x30F3; | Web&#x691C;&#x5B9A;&#xFF08;&#x30A6;&#x30A7;&#x30D6;&#x30B1;&#x30F3;&#xFF09;</a><br/> 一般的なWeb業界の用語がメインで、実際に何をする役割なのかはふわっとだけ学びました。ただ、実際に入社してみると、言葉通り「右も左もわからない」状況でした。</p> <p>社内で使われているツールで触ったことがあったのがGmailとSlackだけで、進め方以前に使い方がわからない。。<br/> 業務フローについて説明を受けるも、表面的なところだけ分かった気になってしまう。。<br/> SQLも書けないのでデータ抽出を頼まれても時間がかかる。。<br/> 操作方法について聞くも1回では理解できないので、録画をして後で自分でコンフルにまとめる日々。。<br/> なによりアウトプットが何もできない状態でした。</p> <p>そこで自分が意識したのは、</p> <ul> <li>わからないことはわからないままにしない</li> <li>一度教えてもらったことは、次回からは一人でできるようになる</li> </ul> <p>とにかく周りに迷惑をかけないように、渡されたタスクはミスなくこなせるようにすることを日々考えていました。<br/> まずは仕事に慣れること、一人分の仕事ができるようになることを目指してがむしゃらに取り組む日々でした。</p> <p>とにかくインプット量が多くて整理しきれなくなりそうになるのをなんとか踏ん張った2カ月。<br/> 2カ月目で起きたのが常に追いかけまわしていたディレクターの先輩が産休に入られるという出来事。。。</p> <h2 id="必死に犬掻きをする中盤">必死に犬掻きをする中盤</h2> <p>産休に入られた先輩から複数プロジェクトのディレクションを引き継ぎました。<br/> 正直、やってやる!という気持ちと、自分が主体になることでプロジェクトが失敗するのではないかという不安で、精神的には余裕のない状態でした。<br/> 実際に業務に取り掛かると、<br/> 引き継いだ業務をうまく進めようと意気込むが、頑張りどころと向かう先がイマイチ合っておらず、日々犬掻き状態。。<br/> チームからはディレクションとしての役割を求められるが調整業務にも何日も時間をかけてしまう状態。。</p> <p>今振り返るとこんな状態でした。</p> <ul> <li>進め方や要件、仕様について各所と調整をしているつもりが、状況や要望を聞いてきて持って帰るだけの伝書鳩になっていた</li> <li>ユースケースを複数考慮できず出戻りが発生することが多かった</li> <li>自分がやるべき最低限タスクができていないのに、改善や新規の提案など背伸びをして何か価値を出そうと空回りしていた</li> </ul> <p>振り返るとなかなか恥ずかしいですね、、</p> <p>この状況を打破すべく意識したことは</p> <ul> <li>取り組む前に進もうとしている方向の認識合わせを行う</li> <li>悩むポイントはこまめに壁打ちを行う</li> </ul> <p>チームマネージャーに週1回、プロジェクトの進め方やチームのコンディションについて会話をする時間をいただきました。 これがとても大きかった…!<br/> この時に必ず自分の考えをもって臨み、ギャップを埋めていくことに努めた結果、<br/> 敷いてもらったレール上を進めることはできるようになってきました。</p> <p>では次は自ら動けるようにならねば。。。</p> <h2 id="自分の役割が何となくわかってきた今">自分の役割が何となくわかってきた今</h2> <p>複数のプロジェクトを経験することで、プロジェクトの初期、中盤、リリース前、リリース後のぞれぞれのタイミングでディレクションがやるべきことがわかってきました。<br/> 「あのプロジェクトと同じように、こう進めていきます」といえるようになったのは大きい。<br/> 今後の動きを予測して動けるようになったこともあり、チームメンバーや他部署から依頼をされることも増えてきました。<br/> さらに成長を感じたところとしては、【考えるタスク】を少しずつこなせるようになったこと。<br/> 調整業務やチームの開発を前に進めることだけではなく、本来のディレクション(方向を示す)という意味での【考えるタスク】を担当し、チームがその方向に進んでいくという体験が少しずつできるようになってきています。</p> <p>最近ではこのようなことに悩んでいます。</p> <ul> <li>開発目線になりすぎてビジネス観点(価値あるもの適切なタイミングで世に出すためにはどうすべきか)が漏れてしまうことがある</li> </ul> <p>これは同じ悩みをお持ちの方もいらっしゃるのではないでしょうか。<br/> 開発ディレクターは開発メンバーと過ごす時間が多いこともあり陥りがちな思考だと思います。 安全にミスなく進めるためにはとても重要ですが、忘れてはいけないのは、<br/> リリースをすることがゴールではなく、【価値のあるプロダクトを生み出すことがゴールである】ということです。</p> <p>例えばA案が良いと思って進めていたけれど、リリース直前になってB案の方が顧客の満足度も高く、売上にもつながるとわかったケースがあるとします。<br/> 開発チームとしては、直前で変更を加えなくてはいけない、リリース日の延長はなるべく避けたい、という状況はストレスにつながると思います。<br/> しかし、私たちが進むべきゴールは【価値あるプロダクトを生み出すこと】です。<br/> 開発チームには負荷がかかりますがディレクターとしてはサンクコストではなく、プロダクトの価値を見るべきです。</p> <p>私は同様の経験を通じで、ディレクターは開発目線とプロダクトオーナーのどちらの目線も持つことがとても重要だと身をもって学びました。<br/> ディレクターとして働き始めた当初は、ディレクターって正直いなくても開発は進むよな…と自分の価値を見つけられずにいました。<br/> 今では、プロダクトの価値を最大化すること、さらに開発チームとプロダクトオーナーの両者が最も進めやすい方法を模索することがディレクションの価値だと思っています。</p> <h1 id="今後に向けて">今後に向けて</h1> <p>今の私が意識し、目標にしていることを宣言させてください。<br/> 私は、エンジニアより技術力はない。<br/> 私は、マーケターよりも市場の把握や予測に強くない。<br/> 私は、営業よりも現場の考え方が理解できていない。<br/> けれど、チームの推進力を高め、開発によって生み出されるプロダクトを価値あるものにする力は誰よりも持てるようになりたい。</p> <p>そのために2つのことを意識していきたいと考えています。</p> <ol> <li>チームマネジメントについて学び、チームに合った進め方でさらに推進力を上げていく</li> <li>ビジネス目線を常に意識し、開発で生まれるサービスが価値あるものになるようにディレクションを行う</li> </ol> <p>一人のディレクターとしてチームや会社にとってなくてはならない存在になることを目指してきます。</p> <h1 id="最後に一言">最後に一言</h1> <p>勇気を出して、別業界かつ別職種にチャレンジしたことを本当によかったと思っています。<br/> 辛い時もありますが、日々自己成長できていると実感することができています。<br/> この場を借りてチームメンバー、同じエンジニアメンバー、一休の皆さんに感謝の気持を伝えたいです。いつも温かいアドバイス、ありがとうございます。<br/> これからも明るさと元気を取柄に頑張ります!</p> hashimotoy61 一休レストランの XState 導入記 hatenablog://entry/6801883189068150637 2023-12-22T19:03:42+09:00 2023-12-25T08:50:18+09:00 このエントリーは 一休.comのカレンダー | Advent Calendar 2023 - Qiita の22日目の記事です。 レストランプロダクトUI開発チームの鍛治です。 一休レストランのフロントエンドを担当しています。 一休レストランでは Next.js App Router Remix を採用しています。 user-first.ikyu.co.jp 昨年の終わり頃から始まった一休レストランのリニューアルですが、フロントエンドは Nuxt v2 (Vue 2) から Next.js App Router (React) に、という大きな切り替えで、不慣れだった我々は React 初心者… <p>このエントリーは <a href="https://qiita.com/advent-calendar/2023/ikyu">&#x4E00;&#x4F11;.com&#x306E;&#x30AB;&#x30EC;&#x30F3;&#x30C0;&#x30FC; | Advent Calendar 2023 - Qiita</a> の22日目の記事です。</p> <p>レストランプロダクトUI開発チームの鍛治です。 一休レストランのフロントエンドを担当しています。</p> <p>一休レストランでは <s>Next.js App Router</s> Remix を採用しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2023%2F12%2F15%2F093427" title="一休レストランで Next.js App Router から Remix に乗り換えた話 - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2023/12/15/093427">user-first.ikyu.co.jp</a></cite></p> <p>昨年の終わり頃から始まった一休レストランのリニューアルですが、フロントエンドは Nuxt v2 (Vue 2) から Next.js App Router (React) に、という大きな切り替えで、不慣れだった我々は React 初心者がひっかかる落とし穴を全部踏み抜いてきました。</p> <p>例えば、チュートリアルに従って useState で変化する状態を定義して、最初はそれで全てがうまくいっていました。機能追加していく過程でいつの間にか一つ増え二つ増え、あとはズルズルと。 ふと我に返ると一つのコンポーネントに10個もの useState が生えてしまっていました。 その結果、 <code>&amp;&amp;</code>, <code>||</code>, <code>??</code> のオンパレードと三項演算子だらけの JSX だけが残りました。何度も何度も読み返してるのに、コンポーネントが今どんな状態にあるのか、さっぱり把握できない…</p> <p>他にも、</p> <ul> <li>バケツリレー コールバック</li> <li>useEffect 問題</li> </ul> <p>といった落とし穴を踏み抜いてきました。</p> <p>フロントエンドの状態管理って本当に難しいですよね。</p> <p>あらためて本日は React 状態管理改善の第一弾として useState 濫用からどう抜け出したのかについてお話しします。</p> <p>コールバックや useEffect 問題は来月以降の記事でご紹介する予定です。</p> <h2 id="useState-の難しさ">useState の難しさ</h2> <p>まずは一番初歩的なところから考えてみましょう。</p> <p>複数のuseStateフックを使用する場合、予期しない状態の組み合わせが発生する可能性があります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> Sample<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> <span class="synIdentifier">[</span>show<span class="synStatement">,</span> setShow<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">(</span><span class="synConstant">false</span><span class="synStatement">);</span> <span class="synType">const</span> <span class="synIdentifier">[</span>disabled<span class="synStatement">,</span> setDisabled<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">(</span><span class="synConstant">false</span><span class="synStatement">);</span> <span class="synType">const</span> toggle <span class="synStatement">=</span> useCallback<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> setShow<span class="synStatement">((</span>prev<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synConstant">!</span>prev<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> toggleDisabled <span class="synStatement">=</span> useCallback<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> setDisabled<span class="synStatement">((</span>prev<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synConstant">!</span>prev<span class="synStatement">);</span> <span class="synIdentifier">}</span><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;&gt;</span> <span class="synStatement">&lt;</span>button <span class="synSpecial">onClick</span><span class="synStatement">=</span><span class="synIdentifier">{</span>toggle<span class="synIdentifier">}</span> disabled<span class="synStatement">=</span><span class="synIdentifier">{</span>disabled<span class="synIdentifier">}</span><span class="synStatement">&gt;</span> show <span class="synStatement">&lt;</span>/button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>button <span class="synSpecial">onClick</span><span class="synStatement">=</span><span class="synIdentifier">{</span>toggleDisabled<span class="synIdentifier">}</span><span class="synStatement">&gt;</span>disable<span class="synStatement">&lt;</span>/button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>SampleModal show<span class="synStatement">=</span><span class="synIdentifier">{</span>show<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> </pre> <p>このシンプルな例では、show(モーダル表示用)と disabled(ボタン無効化用)の二つの状態を管理しています。</p> <p>しかし、たった二つしかないのに <code>show === true &amp;&amp; disabled === true</code> のように、ボタンが無効化されているにも関わらずモーダルが表示されている、という矛盾した状態を表現できてしまいます。useState で管理する状態が増えれば増えるほど、矛盾した状態を生んでしまう可能性は高くなります。</p> <p>この問題を解決するためには、コンポーネントの粒度を小さくし、useState には primitive 値を入れず構造化されたデータを用いて、ありえない状態を生まないようにするのが自然な発想でしょう。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink> <span class="synStatement">type</span> State <span class="synStatement">=</span> Initial | Disabled | Modal <span class="synStatement">type</span> Initial <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Initial'</span> disabled: <span class="synConstant">false</span> show: <span class="synType">boolean</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Disabled <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Disabled'</span> disabled: <span class="synConstant">true</span> show: <span class="synConstant">false</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Modal <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Modal'</span> disabled: <span class="synConstant">false</span> show: <span class="synConstant">true</span> modalData: ModalData <span class="synIdentifier">}</span> <span class="synStatement">function</span> Sample<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> <span class="synIdentifier">[</span>state<span class="synStatement">,</span> setState<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">&lt;</span>State<span class="synStatement">&gt;(</span><span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Initial'</span><span class="synStatement">,</span> disabled: <span class="synConstant">false</span><span class="synStatement">,</span> show: <span class="synConstant">false</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synType">const</span> open <span class="synStatement">=</span> useCallback<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> setState<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Modal'</span><span class="synStatement">,</span> modalData: <span class="synConstant">'data'</span><span class="synStatement">,</span> disabled: <span class="synConstant">false</span><span class="synStatement">,</span> show: <span class="synConstant">true</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">[</span>setState<span class="synIdentifier">]</span><span class="synStatement">)</span> <span class="synType">const</span> toggleDisabled <span class="synStatement">=</span> useCallback<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">if(</span>state.disabled<span class="synStatement">)</span> <span class="synIdentifier">{</span> setState<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Disabled'</span><span class="synStatement">,</span> disabled: <span class="synConstant">true</span><span class="synStatement">,</span> show: <span class="synConstant">false</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> setState<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'Initial'</span><span class="synStatement">,</span> disabled: <span class="synConstant">false</span><span class="synStatement">,</span> show: <span class="synConstant">false</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">[</span>setState<span class="synIdentifier">]</span><span class="synStatement">)</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">{</span>open<span class="synIdentifier">}</span> disabled<span class="synStatement">=</span><span class="synIdentifier">{</span>state.disabled<span class="synIdentifier">}</span><span class="synStatement">&gt;</span> show <span class="synStatement">&lt;</span>/button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>button <span class="synSpecial">onClick</span><span class="synStatement">=</span><span class="synIdentifier">{</span>toggleDisabled<span class="synIdentifier">}</span><span class="synStatement">&gt;</span>disable<span class="synStatement">&lt;</span>/button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>SampleModal show<span class="synStatement">=</span><span class="synIdentifier">{</span>state.show<span class="synIdentifier">}</span> /<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/<span class="synStatement">&gt;</span> <span class="synStatement">)</span> </pre> <h2 id="useState--union-型では足りなかった">useState + union 型では足りなかった</h2> <p>上述した実装のように、union 型によって不正な状態が作られなくなりました。</p> <p>遷移はイベントハンドラ内で暗黙的に記述されます。上記のモーダルでは状態が2つしかなく、シンプルな実装なので遷移の全体像を把握できていますが、状態の数が増え遷移が複雑になると遷移の全体を把握するのが困難になり、人為的に遷移先を決定するロジックをテストする必要があります。結果、誤って不正な遷移が紛れ込む場合があります。</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/kajimm/20231222/20231222170804.png" width="775" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>空席確認カレンダー</figcaption></figure></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/kajimm/20231222/20231222145359.png" width="1200" height="329" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>カレンダーの状態遷移図</figcaption></figure></p> <p>状態が7個、遷移イベントが20個あり、イベントハンドラ内での遷移先を決めるロジックが複雑になってしまい不正な遷移を起こしてしまう可能性がありました。</p> <p>このような不正な遷移を人為的ではなく機械的に防ぐために、state machine を導入します。</p> <h3 id="state-machine-とは">state machine とは?</h3> <p>state machine は複数の「状態」と「状態間の遷移」で構成されます。</p> <p>上述した web 画面のシナリオを例にすると「フラットな状態」(通常の状態)から「モーダルが開いた状態」への遷移は「 show ボタンをクリックする」というイベントによって行われます。</p> <p>「モーダルが開いた状態」では再度 「show クリック」イベントが発生しても、そのイベントに対応する状態遷移は定義されていないので、それ以上何も起きません。</p> <p>また「フラットな状態」から最初に disabled ボタンが押されて (disable イベントが発火して)「ボタンが無効化された状態」になると、そこで仮に show イベントが発火しても、同様に show イベントに対応する状態遷移が定義されていないので、「ボタンが無効なのにモーダルが開いてしまう」という矛盾した状態が生じません。</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/kajimm/20231221/20231221092434.png" width="1200" height="686" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>モーダルの状態遷移図</figcaption></figure></p> <p>state machine では、あらかじめ定義した状態とその状態間の遷移しか存在しないので、予期しない状態に陥ることがありません。state machine を導入すると、アプリケーションロジックを明確かつ宣言的に定義できるのが非常に魅力的なポイントです。</p> <h1 id="XState-state-machine-の導入">XState (state machine) の導入</h1> <p>state mcahine を導入するために、<a href="https://stately.ai/docs/xstate">XState</a> を使った状態管理方法を導入することを決定しました。</p> <p>もちろん他の解決策もあったと思います。</p> <p>例えば、弊社 CTO が以前ご紹介した TypeScript の discriminated union (タグ付きユニオン型)で状態を、関数で遷移を表現する手法はその一つであり、弊社プロダクトで実績あるソリューションであることは間違いありません。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechplay.jp%2Fcolumn%2F1631" title="TypeScriptによるGraphQLバックエンド開発 ──TypeScriptの型システムとデータフローに着目した宣言的プログラミング - TECH PLAY Magazine" 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://techplay.jp/column/1631">techplay.jp</a></cite></p> <p>ただ、現在の自分達では、制約のない状況下でうまく型を定義して、状態を完全にコントロールできるという自信は持てませんでした。state machine もどきの不完全な物を生み出してしまわないか不安があったのです。</p> <p>XState であれば state machine を正しく定義することを強制されます。技術としてのフレームワークに留まらず、思考のフレームワークとしてガイドレールを提示してくれる点を評価しました。</p> <h2 id="XState-とは">XState とは?</h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fstately.ai%2Fdocs%2Fxstate" title="XState | Stately" 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://stately.ai/docs/xstate">stately.ai</a></cite></p> <p>state machineを作成することができる非常に高機能なライブラリです。</p> <p>例えば、フロントエンドのサンプルとしてよく用いられる TODO リストを XState で実装<a href="#f-bbc46b43" id="fn-bbc46b43" name="fn-bbc46b43" title="XState 4 ベースのコードです。XState 5には近日中に移行予定です">*1</a>すると以下のようになります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> TodoList <span class="synStatement">=</span> <span class="synIdentifier">{</span> items: <span class="synIdentifier">{</span> id: <span class="synType">number</span> name: <span class="synType">string</span> completed: <span class="synType">boolean</span> <span class="synIdentifier">}[]</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> TodoEvent <span class="synStatement">=</span> Add | Toggle | Disable | Enable <span class="synStatement">type</span> Add <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'ADD'</span> item: <span class="synIdentifier">{</span> id: <span class="synType">number</span> name: <span class="synType">string</span> completed: <span class="synType">boolean</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Toggle <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'TOGGLE'</span> id: <span class="synType">number</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Disable <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'DISABLE'</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> Enable <span class="synStatement">=</span> <span class="synIdentifier">{</span> <span class="synStatement">type</span>: <span class="synConstant">'ENABLE'</span> <span class="synIdentifier">}</span> <span class="synStatement">type</span> TodoState <span class="synStatement">=</span> <span class="synIdentifier">{</span> value: <span class="synConstant">'ACTIVE'</span><span class="synStatement">;</span> context: TodoList <span class="synIdentifier">}</span> | <span class="synIdentifier">{</span> value: <span class="synConstant">'INACTIVE'</span><span class="synStatement">;</span> context: TodoList <span class="synIdentifier">}</span> <span class="synStatement">export</span> <span class="synType">const</span> machine <span class="synStatement">=</span> createMachine<span class="synStatement">&lt;</span>TodoList<span class="synStatement">,</span> TodoEvent<span class="synStatement">,</span> TodoState<span class="synStatement">&gt;(</span><span class="synIdentifier">{</span> initial: <span class="synConstant">'ACTIVE'</span><span class="synStatement">,</span> states: <span class="synIdentifier">{</span> ACTIVE: <span class="synIdentifier">{</span> on: <span class="synIdentifier">{</span> ADD: <span class="synIdentifier">{</span> target: <span class="synConstant">'ACTIVE'</span><span class="synStatement">,</span> actions: assign<span class="synStatement">((</span>ctx<span class="synStatement">,</span> event<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> items: <span class="synIdentifier">[</span>...ctx.items<span class="synStatement">,</span> event.item<span class="synIdentifier">]</span> <span class="synIdentifier">}</span><span class="synStatement">)),</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> TOGGLE: <span class="synIdentifier">{</span> target: <span class="synConstant">'ACTIVE'</span><span class="synStatement">,</span> actions: assign<span class="synStatement">((</span>ctx<span class="synStatement">,</span> event<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synStatement">(</span><span class="synIdentifier">{</span> items: ctx.items.map<span class="synStatement">((</span>item<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> item.id <span class="synStatement">===</span> event.id ? <span class="synIdentifier">{</span> ...item<span class="synStatement">,</span> completed: <span class="synConstant">!</span>item.completed <span class="synIdentifier">}</span> : item <span class="synStatement">),</span> <span class="synIdentifier">}</span><span class="synStatement">)),</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> DISABLE: <span class="synConstant">'INACTIVE'</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> INACTIVE: <span class="synIdentifier">{</span> on: <span class="synIdentifier">{</span> ENABLE: <span class="synConstant">'ACTIVE'</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="synIdentifier">}</span><span class="synStatement">)</span> </pre> <p>まず state として TODO を追加したりトグルを変更が可能な状態の <code>ACTIVE</code> と、なにもできない状態の <code>INACTIVE</code> を定義します。</p> <p>次に、各 state が各イベントを受け取った時にどの状態に遷移するか、すなわち状態遷移を <code>on</code> で定義し、その状態遷移時の副作用としてのデータ更新を <code>actions</code> で指定することで、state machine が完成します。</p> <p>XStateでは、内部情報として context (詳しいことは後のセクションで説明します)を持ちます。<code>ADD</code> イベントでは context である <code>items</code> に 新しい TODO を追加しています。</p> <p>XState で定義した state mahine では、<code>INACTIVE</code> の状態で <code>ADD</code> や <code>TOGGLE</code> のイベントに対する状態遷移を定義していないので、<strong>ありえない状態に遷移しないことが保証されます。</strong>。</p> <h3 id="context">context</h3> <p>context とは、state machine が扱う状態の「詳細」や「変動する部分」を吸収して、複雑な状況に対応する仕組みです。</p> <p>state machine 、厳密には有限状態機械(FSM: Finite State Machine)の「有限」は、あくまで数学的な「有限」です。</p> <p>実際のアプリケーションでは、管理しなければならない状態に紐づくデータや条件が複雑で、有限状態機械を原理的に適用すると、たとえ「有限」であっても、人間の認知能力ではとうてい把握しきれない膨大なバリエーションを生み出してしまいます。</p> <p>有限状態機械を現実的に利用するために 状態とその状態に関連するデータを分離して、context という形で保存・管理します。</p> <p>例えば以下のように、ユーザーの入力やアプリケーションの現在の状態など、状態自体ではなく、状態の「内容」を表すデータのことです。</p> <ul> <li>予約する人数日時</li> <li>予約の際に選択する支払い方法</li> <li>使用するクーポン情報</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/k/kajimm/20231222/20231222144556.png" width="1200" height="521" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>予約入力の状態遷移図</figcaption></figure></p> <h2 id="XState-で管理すべきでない状態">XState で管理すべきでない状態</h2> <p>XState で全ての状態を管理すべきと言ってるわけではありません。ボタンを押すとモーダルが表示される状態遷移は、XState で管理してしまうと却ってオーバーエンジニアリングになってしまいます。</p> <p>また、以下の場合は状態として持つべきではありません。</p> <ul> <li><strong>状態遷移から独立しており、値が操作の過程で変化しないもの</strong></li> </ul> <p>例えば、API レスポンスは state machine の遷移に変化する値ではないので XState で管理すべきではなく、useState で管理すべきです。</p> <p>XStateで管理すべき基準としては</p> <ul> <li>1つのコンポーネントで useState が3つ以上定義されている</li> <li>何かアクションを起こした時の遷移先が2つ以上ある</li> </ul> <p>場合だと思ってます。(プロダクトによって基準は違うと思うのであくまで目安です)</p> <h1 id="XStateを導入して良かったこと">XStateを導入して良かったこと</h1> <h2 id="フロントエンドの改修が容易になった">フロントエンドの改修が容易になった</h2> <p>state machine によりありえない状態ができないことが担保されているので、フロントエンドの改修をする際に大きいバグが起きなくなりました。</p> <h2 id="実装前の仕様--モデリングの議論ができるようになった">実装前の仕様 / モデリングの議論ができるようになった</h2> <p>state machine が画面のドメインモデルとなるので、画面や機能を作成する際にどのような state machine にするか議論することで、意図せずも画面や機能のモデリングの議論ができるようになりました。</p> <h1 id="所感">所感</h1> <p>XState による state machine という考え方のガイドレールができたことで、条件文を最小限にする state mahine のメンタルモデルが形成されてきたように思います。</p> <p>また、上述したように全て XState で管理すべきだとは思ってません。適材適所で XState をうまく活用していきたいです。</p> <h1 id="さいごに">さいごに</h1> <p>一休では、より良いサービスを作ってくれる仲間を募集しています!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施していますので、ぜひお気軽にご連絡ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-bbc46b43" id="f-bbc46b43" name="f-bbc46b43" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">XState 4 ベースのコードです。XState 5には近日中に移行予定です</span></p> </div> kajimm Cloud Runで開発用環境を沢山作る hatenablog://entry/6801883189067031238 2023-12-16T11:27:05+09:00 2023-12-16T11:27:05+09:00 概要 この記事は 一休.com Advent Calendar 2023 16日目の記事です。 RESZAIKO開発チームの松村です。 一休では各サービス毎に、開発中のサービスの動作を社内で確認できる環境があります。 それぞれmain(master)ブランチと自動的に同期している環境と、特定のブランチを指定して利用できる環境の2種類があります。 今回、RESZAIKOの新規サービス(予約画面)に対してブランチを指定してデプロイできる環境を作成したので、その方針と反省点と今後について記述していきます。 現在運用中の予約画面 開発環境を作る理由 一休では長らく、EKS上に複数の環境を用意して、ブラ… <h1 id="概要">概要</h1> <p>この記事は <a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a> 16日目の記事です。</p> <p>RESZAIKO開発チームの松村です。</p> <p>一休では各サービス毎に、開発中のサービスの動作を社内で確認できる環境があります。 それぞれmain(master)ブランチと自動的に同期している環境と、特定のブランチを指定して利用できる環境の2種類があります。</p> <p>今回、RESZAIKOの新規サービス(予約画面)に対してブランチを指定してデプロイできる環境を作成したので、その方針と反省点と今後について記述していきます。</p> <ul> <li>現在運用中の予約画面</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="web予約サービス画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/narumatt/20231215/20231215134136.png" width="303" height="651" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <h1 id="開発環境を作る理由">開発環境を作る理由</h1> <p>一休では長らく、EKS上に複数の環境を用意して、ブランチを指定すると開発環境にデプロイするシステムが利用されてきました。 一般的にこのような環境を構築するのは以下のような理由が挙げられます。</p> <ul> <li>動作確認 <ul> <li>マイクロサービスで、異なるブランチ同士の組み合わせで動作確認がしたい</li> <li>ローカルだと何故か再現しない</li> <li>デプロイがちゃんと動くか確認したい</li> </ul> </li> <li>他人と成果物の共有 <ul> <li>リリースできるほど動作に自信は無いが、ステークホルダーと内容を共有したい</li> </ul> </li> </ul> <p>本サービスでは<a href="https://www.prisma.io/">Prisma</a>を利用してDBのスキーマをアプリのコードと同じリポジトリで管理しているため、 複数の新機能を平行して開発していく場合に開発環境が1つだと、DB定義が衝突したりして尚更大変です。 そこで、複数の開発環境を作成できるようにしました。</p> <p>本サービスは基盤にGoogle Cloudの<a href="https://cloud.google.com/run?hl=ja">Cloud Run</a>を使用しています。 Cloud Runは特に設定しなければアクセスがある時だけコンテナが起動するようになっているので、EKSを使用した場合よりスペックやコストをあまり気にせず環境を増やしていけます。</p> <h1 id="実現方法">実現方法</h1> <p>サーバはCloud Runで動いていて、デプロイは Github Actionsで行っています。 そのため、開発環境用のGithub Actions Workflowを作成していきます。</p> <h2 id="デプロイを行うGithub-Actions-Workflowの作成">デプロイを行うGithub Actions Workflowの作成</h2> <p>本記事の主旨から外れるので詳しく説明しませんが、 Google Cloudには<a href="https://cloud.google.com/blog/ja/products/devops-sre/using-github-actions-with-google-cloud-deploy">Github Actionsと連携してデプロイを行うための機能</a> が各種用意されているので、参考にしてWorkflowのyamlファイルを作成します。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">name</span><span class="synSpecial">:</span> backend.demo.create <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">name</span><span class="synSpecial">:</span> <span class="synIdentifier">required</span><span class="synSpecial">:</span> <span class="synConstant">true</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> string <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Environment name to deploy&quot;</span> <span class="synIdentifier">jobs</span><span class="synSpecial">:</span> <span class="synIdentifier">build</span><span class="synSpecial">:</span> <span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest <span class="synIdentifier">steps</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Checkout&quot;</span> <span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v3 <span class="synComment"> # SecretからGCPの認証用のjsonを読み出す</span> <span class="synStatement">- </span><span class="synIdentifier">id</span><span class="synSpecial">:</span> <span class="synConstant">&quot;auth&quot;</span> <span class="synIdentifier">uses</span><span class="synSpecial">:</span> <span class="synConstant">&quot;google-github-actions/auth@v0&quot;</span> <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">credentials_json</span><span class="synSpecial">:</span> <span class="synConstant">&quot;${{ secrets.gcp-dev-service-accont-key }}&quot;</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Set up Cloud SDK&quot;</span> <span class="synIdentifier">uses</span><span class="synSpecial">:</span> <span class="synConstant">&quot;google-github-actions/setup-gcloud@v0&quot;</span> <span class="synIdentifier">with</span><span class="synSpecial">:</span> <span class="synIdentifier">install_components</span><span class="synSpecial">:</span> <span class="synConstant">'alpha,beta'</span> <span class="synComment"> # 以下ビルド・デプロイの記述</span> </pre> <h2 id="Workflowの呼び出し">Workflowの呼び出し</h2> <p>Workflowに <code>workflow_dispatch</code> を定義することで、 <a href="https://docs.github.com/ja/rest/actions/workflows?apiVersion=2022-11-28#create-a-workflow-dispatch-event">外部からREST APIでWorkflowを呼び出す</a>ことができます。 開発環境用のアプリを作成して、そちらからREST APIで必要に応じてWorkflowを呼び出してあげます。</p> <pre class="code" data-lang="" data-unlink>POST https://api.github.com/repos/test/test-repo/actions/workflows/backend.demo.create/dispatches Content-Type: application/json Accept: application/vnd.github+json Authorization: Bearer &lt;TOKEN&gt; X-GitHub-Api-Version: 2022-11-28 { &#34;ref&#34;:&#34;feature/branch-to-test&#34;, &#34;inputs&#34;:{&#34;name&#34;:&#34;demo-1&#34;} }</pre> <h2 id="実装された運用">実装された運用</h2> <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/n/narumatt/20231215/20231215134204.png" width="1200" height="414" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ブランチデプロイサービス画面</figcaption></figure></p> <p>こんな感じのアプリを作成しました。 ブランチ名を入力して <code>Deploy</code> を押すと、デモ環境に該当のブランチがデプロイされます。 いつ、誰が、どのブランチをデプロイしたかを記録するようになっています。 削除機能はまだ実装していないので、使い終わったらmainブランチを手動で適用する運用になっています。</p> <h1 id="反省と将来">反省と将来</h1> <p>折角Cloud Runを使っているのに、既存の他サービスの仕様に引きずられた実装にしてしまいました。 特に以下の点が良くないです。</p> <ul> <li>設定ファイルをコピペして増やしていたので、環境を増やす毎に同じような設定ファイルが増える</li> <li>環境毎に社内用のドメイン( <code>[env-name].dev.reszaiko.com</code> のような)を作っていたので、環境を増やす度にDNSとSSLの設定が必要になる</li> </ul> <p>このため、気軽に環境を増減させる事が困難になっていて、既存の問題をそのまま引き継いでいます。</p> <ul> <li>使わなくなった環境を戻し忘れてそのまま占有し続ける</li> <li>空いている環境がない場合、他の環境を使っている人とコミュニケーションして融通してもらう必要がある</li> </ul> <h2 id="このままデプロイ環境を作るなら">このままデプロイ環境を作るなら</h2> <p>ブランチデプロイ環境として、全てのブランチに対して自動的にデモ環境を作成、破棄するのが理想です。 コンテナのビルドやDBやサーバの用意、デプロイは既にGithub Actionsで行うようにしていますし、 開発環境へのアクセスはCloud Routerを利用して振り分けているため、 <code>dev.reszaiko.com/[branch-name]/</code> のように環境毎のパスの追加もGithub Action上で構築できます。</p> <p>また、特に開発環境を必要としない軽微な修正に対しても無制限に環境を作るのを防ぐために、以下の手段が考えられます。</p> <ul> <li><code>dev-****</code> のように、特定のprefixを持つブランチに対して自動で環境を作る</li> <li>既存のデプロイ用UIを拡張して、環境数を増やしたり減らしたりできるようにする</li> </ul> <p>前者はブランチが消えれば自動で環境が消えるので、使わなくなった環境が残ってしまうというよくある問題が解消できます。 後者はUI上で存在する環境の把握やアプリへのリンク、DBのリセットなど機能を追加する事ができて便利です。</p> <h2 id="開発環境を作らないと駄目なのか">開発環境を作らないと駄目なのか</h2> <p>そもそもブランチデプロイ環境が必要か、という問題もあります。</p> <p>開発中のブランチを長期間利用していると本番環境との乖離が大きくなり、mainブランチにマージする際に入念なチェックが必要になります。 RESZAIKOの予約チームでは <a href="https://cloud.google.com/architecture/devops/devops-tech-trunk-based-development?hl=ja">トランクベース開発</a> のように 頻繁にリリースする手法を導入するか議論していますが、 このような手法では開発中の機能はフィーチャーフラグを利用して出し分けるのが適しています。</p> <p>RESZAIKOでは <a href="https://launchdarkly.com/">LaunchDarkly</a> というフィーチャーフラグ機能を提供してくれるSaasを導入しているため、 コストをかけてブランチデプロイ環境を開発していくよりは、フィーチャーフラグを適切に利用する体制を整備し、開発環境はmainブランチと同期したものだけで運用していく方がいいかもしれません。</p> <h1 id="まとめ">まとめ</h1> <p>使用している技術やサービスは日々新しい物が導入対象になるので、最適な開発手法というのはその時に合わせて検討する必要があります。 次に記事を書くときは「トランクベース開発に合わせたフィーチャーフラグの運用法」みたいなのが書けるように頑張ります。</p> <p>一休では、共に働くエンジニアを募集しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施しているので、お気軽にご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> narumatt 一休レストランで Next.js App Router から Remix に乗り換えた話 hatenablog://entry/6801883189066390971 2023-12-15T09:34:27+09:00 2023-12-23T09:27:55+09:00 一休レストランのフロントエンドのリアーキテクトの過程で Next.js App Router から Remix に乗り換えた話をご紹介します。 <p>このエントリーは<a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a>の15日目の記事になります。</p> <hr /> <p>CTO 室の恩田です。</p> <p>現在は<a href="https://restaurant.ikyu.com/">一休レストラン</a>のフロントエンドのリアーキテクトを手がけています。 今日はその中で <a href="https://nextjs.org/docs/app">Next.js App Router</a> から <a href="https://remix.run/">Remix</a> に乗り換えた話をご紹介したいと思います<a href="#f-bd9d7065" id="fn-bd9d7065" name="fn-bd9d7065" title="同じ一休レストランフロントエンドのリアーキテクトの一環で XState を導入した話は22日目の記事でご紹介しています。">*1</a>。</p> <h1 id="背景">背景</h1> <p><a href="https://user-first.ikyu.co.jp/entry/2023/12/06/173215">6日目</a>の記事で香西から紹介させていただきましたが、2023年10月に<a href="https://restaurant.ikyu.com/">一休レストラン</a>のスマートフォン用レストラン詳細ページをリニューアルしました。</p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">一休レストランの Rust バックエンドが正式リリースされました。<a href="https://t.co/7N4VGv5ej9">https://t.co/7N4VGv5ej9</a> このページのスマートフォンビューはバックエンドが Rust で書かれた GraphQL になってます</p>&mdash; naoya (@naoya_ito) <a href="https://twitter.com/naoya_ito/status/1709507132209680702?ref_src=twsrc%5Etfw">2023年10月4日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">ちなみにフロントエンドも、旧バージョンは Nuxt v2 で、新バージョンは Next.js です。一休レストラン React に寄せることに決めました。React Server Component を使った実装になっており、こちらも後者の方が体感速度は速いと思います。</p>&mdash; naoya (@naoya_ito) <a href="https://twitter.com/naoya_ito/status/1709740327375086038?ref_src=twsrc%5Etfw">2023年10月5日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <p>あらためてリニューアルでの技術的な変更点を再掲すると:</p> <ul> <li>バックエンド言語:Python から Rust へ</li> <li>フロントエンドフレームワーク:Nuxt v2 から Next.js App Router へ</li> </ul> <p>つまり、このエントリは先日リリースしたばかりの Next.js から Remix に乗り換えた、という話になります。</p> <p>図らずも、昨今盛り上がっている Next.js 論争<a href="#f-92d901d1" id="fn-92d901d1" name="fn-92d901d1" title="後段で紹介します。">*2</a>に足を踏み入れることになりました。</p> <h1 id="Nextjs-App-Router-について">Next.js App Router について</h1> <p>まずは disclaimer として、あくまで一休レストランにおいて Next.js App Router が "not for us" であっただけで Next.js そのものに対する評価ではないことは申し添えておきます。</p> <p>その上で、ここでは Next.js App Router を採用した経緯と、実際に採用してみてどんな課題に遭遇したのかを簡単に説明したいと思います。</p> <h2 id="当初-Nextjs-を採用した経緯">当初 Next.js を採用した経緯</h2> <p>採用を決めたのは <a href="https://nextjs.org/blog/next-13">Next.js 13</a> の発表直後、一休レストランのリニューアル計画が動きはじめた頃になります。</p> <p>以下が主に評価した点ですが、</p> <ul> <li>メタフレームワークとしてデファクトスタンダードとしての地歩を固めつつあったこと</li> <li>弊社内の別プロダクトで Next.js (Pages Router) の採用実績が複数あること</li> <li>そして toC サービスである一休レストランにとって、カリカリにチューニングできそうな React Server Component が非常に魅力的なフィーチャーであったこと</li> </ul> <p>特に最後の React Server Component が採用の決め手となりました。</p> <p>先日の <a href="https://nextjs.org/blog/next-14">Next.js 14</a> で発表された <a href="https://vercel.com/blog/partial-prerendering-with-next-js-creating-a-new-default-rendering-model">Partial Prerendering</a> もそうですが、toC サービスの欲しい機能をピンポイントに突いてくるニクいフレームワークです。</p> <h2 id="Nextjs-の-Pain-Points">Next.js の Pain Points</h2> <p>そもそも今回のリニューアルにおけるビジネス上のゴールは、一休レストランで予約するとき、お店に電話をかけたときのようなスムーズな体験を提供する、というものでした。</p> <p>しかし、社内レビューや canary release の過程で見つかったユーザー体験の問題を改善するにあたって、Next.js App Router では実現が難しそうな課題がいくつか見つかってきました。</p> <h3 id="History-API-の-state-を触れない">History API の state を触れない</h3> <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/tak-onda/20231213/20231213191246.jpg" width="540" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レストラン詳細ページ</figcaption></figure></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/kajimm/20231222/20231222170804.png" width="775" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>空席確認カレンダーモーダル</figcaption></figure></p> <p>人数・日時を選択する空席確認カレンダーのモーダル表示がポイントです。<a href="#f-87804ca9" id="fn-87804ca9" name="fn-87804ca9" title="カレンダーの状態管理についての紆余曲折については22日目の XState の記事で紹介しているので、ご笑覧いただければ幸いです。">*3</a></p> <p>ここでの選択は予約にいたるまでの一連の流れのワンステップなので、操作中はブラウザの「戻る」やリロードで開いた状態を維持したいモーダルです。</p> <p>ただ、その状態で URL が LINE などで共有されたときは、モーダルのない詳細ページが開いて欲しい場面でもあります。</p> <p>Next.js App Router の <a href="https://nextjs.org/docs/app/api-reference/components/link">Link</a> コンポーネントや <a href="https://nextjs.org/docs/app/api-reference/functions/use-router">useRouter</a> フックでは <a href="https://developer.mozilla.org/en-US/docs/Web/API/History">History API</a> の state を操作することはできず、URL を変更せずにブラウザ履歴を積んだ上で画面表示を変更することができません。</p> <h3 id="Cache-Control-ヘッダを自由に設定できない">Cache-Control ヘッダを自由に設定できない</h3> <p>Next.js App Router では <code>Cache-Control</code> ヘッダは <a href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers#dynamic-functions">Dynamic Functions</a> が利用されたかどうかと <a href="https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config">Route Segment Config</a> で設定した値を元に Next.js 自身が出力する仕様となっており、利用者が自由に値を設定することはできません。</p> <p>例えば <a href="https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional">searchParams</a> を参照しただけで Dynamic Functions と判定され、強制的に <code>Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate</code> が出力されてしまいます。</p> <p>Fastly を CDN として利用している一休では、<code>Cache-Control</code> ヘッダを制御できない<a href="#f-24191d26" id="fn-24191d26" name="fn-24191d26" title="Fastly のキャッシュ制御は Surrogate-Control ヘッダで、ブラウザキャッシュのための Cache-Control ヘッダは VCL など他の手段で上書きすることはできますが...">*4</a>という制限は、パフォーマンスやインフラ負荷に影響を与える大きな問題です。</p> <p>また、レストラン詳細ページ以降のページだけが今回のリニューアル範囲のため、 <a href="https://web.dev/articles/bfcache?hl=en">bfcache</a> が無効になってしまうのも、既存ページとの遷移でユーザー体験に悪影響を及ぼします。</p> <h3 id="継続的なアップデートに懸念を覚えた">継続的なアップデートに懸念を覚えた</h3> <p>Next.js のパッチバージョンを上げたときに production build でだけ 500 エラーが発生するという問題に幾度か苦しめられました。</p> <p>App Router で運用している世界の様々なサイトで同じ問題が発生していたら大きな Issue になっているはずで、一休レストランのコード、もしくは利用ライブラリのいずれかに原因があったことには間違いないとは思います。</p> <p>現象の再現状況の特定が難しく、加えて調査に十分なリソースを割けなかったという背景もありましたが、正確な原因が掴めず仕舞いとなってしまったことには歯痒い思いとともに、懸念が残りました。</p> <h1 id="Remix-への乗り換え">Remix への乗り換え</h1> <p>上記の課題を解決するため、最終的には <a href="https://remix.run/">Remix</a> に乗り換えることを決定しました。</p> <h2 id="Remix-を採用した理由">Remix を採用した理由</h2> <p>Next.js App Router で抱えていた課題の裏返しになるのですが、そもそもの Remix の設計指針である、<strong>Web 標準 API を尊重している点</strong><a href="#f-83bbccc5" id="fn-83bbccc5" name="fn-83bbccc5" title="Remix サイトのトップページに &quot;Focused on web standards and modern web app UX&quot; と掲げられています。">*5</a>を特に重視しました。</p> <h3 id="History-API">History API</h3> <p>改善したかったクライアントサイドのナビゲーションを例に取ると、Remix の提供している <a href="https://remix.run/docs/en/main/components/link">Link</a> コンポーネントや <a href="https://remix.run/docs/en/main/hooks/use-navigate">useNavigate</a> フックは <a href="https://developer.mozilla.org/en-US/docs/Web/API/History_API">History API</a> <a href="#f-5e1662a6" id="fn-5e1662a6" name="fn-5e1662a6" title="Navigation API が早く普及して欲しい...">*6</a> の薄い wrapper になっていて <a href="https://remix.run/docs/en/main/components/link#state">state</a> を利用することが可能です。</p> <p>具体的には、Remix 自身もスクロール位置の維持をはじめとするクライアントサイドナビゲーションの管理に <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/state">History API state</a> を利用していて、Remix API で利用者が指定した <a href="https://remix.run/docs/en/main/components/link#state">state</a> は <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/state">History API state</a> では、</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">usr</span>&quot;: <span class="synSpecial">{</span>&quot;<span class="synStatement">state</span>&quot;: <span class="synSpecial">[</span>&quot;<span class="synConstant">set</span>&quot;, &quot;<span class="synConstant">from</span>&quot;, &quot;<span class="synConstant">Remix API</span>&quot;<span class="synSpecial">]}</span>, &quot;<span class="synStatement">key</span>&quot;: &quot;<span class="synConstant">dgfkntlh</span>&quot;, &quot;<span class="synStatement">idx</span>&quot;: <span class="synConstant">2</span> <span class="synSpecial">}</span> </pre> <p>上記の例のように Remix が定義する History state の構造の中の <code>"usr"</code> キーの中に格納されます。</p> <p>この構造を理解していれば、直接 <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState">History API replaceState</a> を呼ぶことで Remix の遷移は抑止しつつ state だけを置き換えるような運用も実現できます。</p> <h3 id="Cache-Control-ヘッダ">Cache-Control ヘッダ</h3> <p>Next.js Pages Router の <a href="https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props">getServerSideProps</a> に相当する Remix の機能に <a href="https://remix.run/docs/en/main/route/loader">loader</a> があります。</p> <p>loader の引数や返り値は Web 標準の <a href="https://developer.mozilla.org/en-US/docs/Web/API/Request">Request</a> / <a href="https://developer.mozilla.org/en-US/docs/Web/API/Response">Response</a> なので <code>Cache-Control</code> にも出力したかった値を設定でき、CDN やブラウザキャッシュをコントロールする自由を取り戻しました。</p> <h3 id="その他">その他</h3> <p>他にも Next.js App Router の Async Server Component に相当する効果<a href="#f-3be90aa8" id="fn-3be90aa8" name="fn-3be90aa8" title="正確に述べると fetch 処理は loader に一元化して Promise を defer を使って返す必要があります。">*7</a>が得られる <a href="https://remix.run/docs/en/main/guides/streaming">defer</a> など、toC サービスである一休レストランにとって魅力的な機能を備えています。</p> <h2 id="検討した代替案">検討した代替案</h2> <p>Remix 以外に検討した対策についても簡単にご紹介します。</p> <h3 id="Nextjs-に-patch-をあてる">Next.js に patch をあてる</h3> <p><code>Cache-Control</code> ヘッダの問題は Next.js の設計方針そのものでどうしようもないので、 <a href="https://pnpm.io/cli/patch">pnpm patch</a> でヘッダを出力している Next.js の当該コードを上書きしてしまう対策<a href="#f-d19bf24c" id="fn-d19bf24c" name="fn-d19bf24c" title="この問題は他の利用者も困っているようで Next.js の Issue 内に patch をあてる workaround が紹介されています。">*8</a>も試しました。</p> <p>ですが <code>Cache-Control</code> を制御したい path が増える度に patch を更新するのは手間がかかって煩わしいし、ヘッダを書き換えられるようになるだけで、ナビゲーション問題は解決できません。</p> <h3 id="Pages-Router-への切り替え">Pages Router への切り替え</h3> <p><a href="https://nextjs.org/docs/pages">Pages Router</a> への切り替えも少しだけ検討しました。</p> <p>一休の他プロダクトで Pages Router の実績はあるので安定性に不安はありませんが、React Server Component に期待したパフォーマンス面はあまり期待できそうにありません。<a href="#f-de74717a" id="fn-de74717a" name="fn-de74717a" title="Remix 公式ブログの Next.js との比較記事 で詳解されていますが Pages Router と比較すると Remix に軍配があがるようです。 ">*9</a></p> <p>また Vercel の開発リソースも App Router にほぼ向けられているだろうし、現時点において Pages Router を選択するのは将来性も見込めないと判断しました。</p> <h1 id="Remix-置き換えで得られた効果">Remix 置き換えで得られた効果</h1> <p>ちょうど Remix 版をリリースして一週間経過したところですが、以下のような効果が得られています。</p> <h2 id="継続的なアップデート">継続的なアップデート</h2> <p>2023-12-18 追記</p> <p>つい先日の 12/14 にリリースされたばかりの <a href="https://github.com/remix-run/remix/releases/tag/remix%402.4.0">Remix 2.4.0</a> まで、問題なく追随できていることをご報告しておきます。</p> <h2 id="Fastly-の-cache-hit-ratio-が-63--68-に">Fastly の cache hit ratio が 63% → 68% に</h2> <p>置き換えの目的の内の一つである CDN とブラウザキャッシュの有効活用です。</p> <p>背景で紹介していますが、リニューアル対象はスマートフォン用のレストラン詳細ページ以降のみで、一休レストラン全体から見れば、ごく限られた範囲でしかありません。</p> <p>にも関わらず、一休レストラン全体の cache hit ratio を 5% ポイント近く向上させることができました。</p> <p>インフラの効率化もさることながら、Fastly のキャッシュから返ってくるときのレスポンス速度は圧倒的に高速なので、ユーザー体験を向上させる改善に繋がったことが何よりも嬉しい成果です。</p> <h2 id="Cloud-Run-の効率化">Cloud Run の効率化</h2> <p>ここは意図していませんでしたが Remix 乗り換えで得られた嬉しい副作用です。</p> <h3 id="メモリ使用量が-14-に">メモリ使用量が 1/4 に</h3> <p><figure class="figure-image figure-image-fotolife" title="Cloud Run Memory Utilization"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tak-onda/20231213/20231213164640.jpg" width="1200" height="453" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Cloud Run Memory Utilization</figcaption></figure></p> <p>グラフの通りメモリ使用量が 1/4 に減りました。 一休レストランは夕方から夜にかけてアクセスのピークを迎えるのですが、その間も安定して同じ水準を保っています。</p> <h3 id="コンテナ起動時間が-12-に">コンテナ起動時間が 1/2 に</h3> <p><figure class="figure-image figure-image-fotolife" title="Cloud Run Startup Latency"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tak-onda/20231213/20231213164943.jpg" width="1200" height="518" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Cloud Run Startup Latency</figcaption></figure></p> <p>Next.js では 20 秒強かかっていたコンテナ起動時間が 10 秒に縮まりました。</p> <p>Next.js 時代からの課題ですが、ローカルでは一瞬で起動するのに、Cloud Run だと起動に時間がかかってしまう問題は調査中です。</p> <h1 id="所感と最近の議論">所感と最近の議論</h1> <p>Remix に乗り換えての<strong>個人的な</strong>所感になりますが、Web 標準 API がそのまま使えて、利用者が思った通りにコントロールできる非常に扱いやすいフレームワークだと感じています。</p> <p>上記はあくまで私の印象になるので、最近の Next.js の議論で特に参考にさせていただいたリソースを紹介します。</p> <ul> <li><a href="https://www.epicweb.dev/why-i-wont-use-nextjs">Why I Won't Use Next.js</a><br>Next.js 論争の火種になった Kent C. Dodds の記事</li> <li><a href="https://leerob.io/blog/using-nextjs">Why I'm Using Next.js</a><br>Kent C. Dodds の記事に対する Lee Robinson によるアンサー記事</li> <li><a href="https://mozaic.fm/episodes/135/monthly-ecosystem-202311.html">Mozaic.fm ep135 Monthly Ecosystem 202311</a><br>Next.js 14 や上記の議論について</li> <li><a href="https://speakerdeck.com/mugi_uno/next-dot-js-app-router-deno-mpa-hurontoendoshua-xin?slide=66">Next.js App Router での MPA フロントエンド刷新</a><br>サイボウズさんの App Router 導入知見。所感が趣き深い。</li> <li><a href="https://zenn.dev/catnose99/articles/f8a90a1616dfb3">しずかなインターネットの技術構成</a><br>Zenn の作者でも知られる catnose さんの記事。App Router を見送った理由を参照されたい。</li> </ul> <h1 id="今後の展望">今後の展望</h1> <p>現時点ではまだ Remix に置き換えただけで、ようやく改善のための足回りが整った、という段階です。</p> <p>引き続きよりよいユーザー体験を目指して、本丸のナビゲーションの改善、CDN キャッシュ効率向上によるレスポンスの高速化を進めていきたいと思います。</p> <h1 id="おわりに">おわりに</h1> <p>今回の一休レストランの問題だけでなく、フロントエンド領域で難しい課題をまだまだ抱えています。</p> <p>一休では、事業の成功を技術面からともに支える仲間を募集しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>まずはカジュアル面談からお気軽にご応募ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-bd9d7065" id="f-bd9d7065" name="f-bd9d7065" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">同じ一休レストランフロントエンドのリアーキテクトの一環で <a href="https://stately.ai/docs/xstate">XState</a> を導入した話は<a href="https://user-first.ikyu.co.jp/entry/2023/12/22/190342">22日目の記事</a>でご紹介しています。</span></p> <p class="footnote"><a href="#fn-92d901d1" id="f-92d901d1" name="f-92d901d1" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">後段で紹介します。</span></p> <p class="footnote"><a href="#fn-87804ca9" id="f-87804ca9" name="f-87804ca9" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">カレンダーの状態管理についての紆余曲折については<a href="https://user-first.ikyu.co.jp/entry/2023/12/22/190342">22日目の XState の記事</a>で紹介しているので、ご笑覧いただければ幸いです。</span></p> <p class="footnote"><a href="#fn-24191d26" id="f-24191d26" name="f-24191d26" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">Fastly のキャッシュ制御は <a href="https://developer.fastly.com/reference/http/http-headers/Surrogate-Control/">Surrogate-Control</a> ヘッダで、ブラウザキャッシュのための Cache-Control ヘッダは VCL など他の手段で上書きすることはできますが...</span></p> <p class="footnote"><a href="#fn-83bbccc5" id="f-83bbccc5" name="f-83bbccc5" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://remix.run/">Remix サイトのトップページ</a>に "Focused on <strong>web standards</strong> and modern web app UX" と掲げられています。</span></p> <p class="footnote"><a href="#fn-5e1662a6" id="f-5e1662a6" name="f-5e1662a6" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API">Navigation API</a> が早く普及して欲しい...</span></p> <p class="footnote"><a href="#fn-3be90aa8" id="f-3be90aa8" name="f-3be90aa8" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">正確に述べると fetch 処理は <a href="https://remix.run/docs/en/main/route/loader">loader</a> に一元化して Promise を <a href="https://remix.run/docs/en/main/utils/defer">defer</a> を使って返す必要があります。</span></p> <p class="footnote"><a href="#fn-d19bf24c" id="f-d19bf24c" name="f-d19bf24c" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">この問題は他の利用者も困っているようで Next.js の <a href="https://github.com/vercel/next.js/issues/22319">Issue</a> 内に patch をあてる <a href="https://github.com/vercel/next.js/issues/22319#issuecomment-1565268095">workaround</a> が紹介されています。</span></p> <p class="footnote"><a href="#fn-de74717a" id="f-de74717a" name="f-de74717a" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text">Remix 公式ブログの <a href="https://remix.run/blog/remix-vs-next">Next.js との比較記事</a> で詳解されていますが Pages Router と比較すると Remix に軍配があがるようです。 </span></p> </div> tak-onda 宿泊管理システムのフロントエンド設計と改善の変遷 hatenablog://entry/6801883189066199996 2023-12-14T09:11:16+09:00 2023-12-14T09:11:16+09:00 一休.com宿泊の管理システムのフロントエンド設計について、この1年ほどで行った改善をお話します。 <ul class="table-of-contents"> <li><a href="#宿泊の管理システムについて">宿泊の管理システムについて</a></li> <li><a href="#新しい管理システムについて">新しい管理システムについて</a></li> <li><a href="#開発初期のフロントエンド設計">開発初期のフロントエンド設計</a><ul> <li><a href="#コンポーネントは4レイヤー方式を採用">コンポーネントは4レイヤー方式を採用</a></li> <li><a href="#UIのコンポーネントライブラリを採用">UIのコンポーネントライブラリを採用</a></li> <li><a href="#これ以上の設計方針は決めなかった">これ以上の設計、方針は決めなかった</a></li> </ul> </li> <li><a href="#初期ローンチ後の課題">初期ローンチ後の課題</a></li> <li><a href="#改善した内容">改善した内容</a><ul> <li><a href="#1-コンポーネント設計の見直し">1. コンポーネント設計の見直し</a><ul> <li><a href="#ディレクトリ構成の変更">ディレクトリ構成の変更</a></li> <li><a href="#大きくなったコンポーネントの分割">大きくなったコンポーネントの分割</a></li> <li><a href="#Fragment-Colocationを導入してコンポーネントのインターフェースとFragmentを整理">Fragment Colocationを導入してコンポーネントのインターフェースとFragmentを整理</a></li> </ul> </li> <li><a href="#2-業務処理composablesの分割">2. 業務処理(composables)の分割</a></li> <li><a href="#3-型安全に開発できるように厳しいlint設定に変更">3. 型安全に開発できるように厳しいlint設定に変更</a></li> <li><a href="#4-秩序を保てる開発体制ドキュメントの整備">4. 秩序を保てる開発体制、ドキュメントの整備</a></li> </ul> </li> <li><a href="#現在と今後">現在と今後</a><ul> <li><a href="#今後やりたいこと">今後やりたいこと</a></li> <li><a href="#改善を継続するためのポイント">改善を継続するためのポイント</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> <li><a href="#おわりに">おわりに</a></li> </ul> <p>宿泊プロダクト開発部の田中(<a href="http://blog.hatena.ne.jp/kentana20/">id:kentana20</a>)です。</p> <p>このエントリーは<a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a>の14日目の記事です。昨日は<a href="https://x.com/kosuke_yellow">@kosuke1012</a>による<a href="https://user-first.ikyu.co.jp/entry/2023/12/13/115112">ADR を1年間書いてみた感想</a>でした。このチームの活動に刺激を受けて、自分のチームでもADRを導入して現在も活用しています。</p> <p>今回は自分が担当している一休.com宿泊の管理システムのフロントエンド設計について、この1年ほどで行った改善をお話します。</p> <h1 id="宿泊の管理システムについて">宿泊の管理システムについて</h1> <p>一休.com宿泊の管理システムは、一休社内とホテルの2面で構成されていて、利用者は一休の社内スタッフとホテルの担当者がおり、それぞれ以下のような業務に活用しています。</p> <ul> <li>一休社内のスタッフ <ul> <li>ホテルの作成、一休全体の予約の管理 など</li> </ul> </li> <li>ホテル担当者 <ul> <li>ホテル情報の管理、商品の在庫や料金設定 など</li> </ul> </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/k/kentana20/20231213/20231213183046.png" width="1200" height="655" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>宿泊の管理システムイメージ</figcaption></figure></p> <h1 id="新しい管理システムについて">新しい管理システムについて</h1> <p>1年半ほど前から、この管理システムに大きめの機能追加をするプロジェクトが発足し、現在も続いています。</p> <p>このプロジェクトは社内スタッフ向け、ホテル担当者向けの両面をカバーする必要があったのですが、新機能を開発をするにあたり</p> <ul> <li>新機能は中長期での開発・運用を想定していること</li> <li>既存システムで採用しているフレームワークやコードベースが古くなっており、新機能をスピーディに開発していくのに難があったこと</li> <li>新機能は既存システムに依存せずに作れそうなこと</li> </ul> <p>などの点から、既存のシステムとは別に新システムをゼロから開発する方針を決めました。</p> <p>新システムのテクノロジースタックは、先行して刷新をしていた一休.com、Yahoo!トラベルの画面に合わせる形で</p> <ul> <li>フロントエンド: Nuxt.js、TypeScript、Apollo Client、Tailwind CSS</li> <li>バックエンド: Go、GraphQL(gqlgen)</li> </ul> <p>という構成にしました。 Nuxt.jsについては開発開始時点ではRC版だったv3を採用しました。</p> <h1 id="開発初期のフロントエンド設計">開発初期のフロントエンド設計</h1> <h2 id="コンポーネントは4レイヤー方式を採用">コンポーネントは4レイヤー方式を採用</h2> <p>Components配下は</p> <ul> <li>pages</li> <li>features</li> <li>objects</li> <li>elements</li> </ul> <p>の4レイヤー構成を採用しており、各レイヤーの役割は以下のとおりです。</p> <table> <thead> <tr> <th style="text-align:left;"> レイヤー </th> <th style="text-align:left;"> 役割 </th> <th style="text-align:left;"> 具体例 </th> <th style="text-align:left;"> 再利用性 </th> <th style="text-align:left;"> 外部アクセス </th> <th style="text-align:left;"> 反証 </th> </tr> </thead> <tbody> <tr> <td style="text-align:left;"> pages </td> <td style="text-align:left;"> ページ固有のコンポーネント群<br />ページ固有の API アクセス、表示を担う </td> <td style="text-align:left;"> ホテル管理ページ </td> <td style="text-align:left;"> ✕ </td> <td style="text-align:left;"> ◯ </td> <td style="text-align:left;"> 複数ページで使われるもの </td> </tr> <tr> <td style="text-align:left;"> features </td> <td style="text-align:left;"> 機能を持った共通コンポーネント<br />API アクセスをする<br /> </td> <td style="text-align:left;"> グローバルヘッダー<br /> </td> <td style="text-align:left;"> ◯ </td> <td style="text-align:left;"> ◯ </td> <td style="text-align:left;"> API アクセスをしない<br />ページ固有の UI </td> </tr> <tr> <td style="text-align:left;"> objects </td> <td style="text-align:left;"> アプリケーション上の機能、デザインのひと固まりとなるコンポーネント<br /> </td> <td style="text-align:left;"> サイドメニュー </td> <td style="text-align:left;"> ◯ </td> <td style="text-align:left;"> ✕ </td> <td style="text-align:left;"> API アクセスをする<br />ページ全体を実装<br />ボタンなどプリミティブな要素 </td> </tr> <tr> <td style="text-align:left;"> elements </td> <td style="text-align:left;"> HTML のサブセットとなるもっともプリミティブなコンポーネント<br />アプリケーション全体の統一感に寄与するコンポーネント </td> <td style="text-align:left;"> チェックボックス<br />ボタン </td> <td style="text-align:left;"> ◯ </td> <td style="text-align:left;"> ✕ </td> <td style="text-align:left;"> API アクセスをする<br />様々なコンポーネントを用いたデザイン状のかたまり </td> </tr> </tbody> </table> <p>この設計は一休.comのユーザー向けシステムに倣った形で、<a href="https://atomicdesign.bradfrost.com/">Atomic Design</a>と当時の一休レストランで採用していた<a href="https://user-first.ikyu.co.jp/entry/2018/10/09/080000">ITCSSによるレイヤードアーキテクチャ</a>をベースに、宿泊サービスの開発に合わせてカスタマイズした設計となっています。</p> <p>実際の画面だと、こんな形で用途に応じて各レイヤーにコンポーネントを作成してUIの開発をしています。</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/kentana20/20231213/20231213185230.png" width="1200" height="883" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コンポーネントのレイヤー例</figcaption></figure></p> <h2 id="UIのコンポーネントライブラリを採用">UIのコンポーネントライブラリを採用</h2> <ul> <li>デザイナーがいないプロジェクト</li> <li>一覧(テーブル)や入力フォームがよく登場する管理画面で一貫したUIを素早く提供したい</li> </ul> <p>という点から、Vue/Nuxtで利用できるUIコンポーネントライブラリとして、Alibabaグループが開発しているElementのVue3対応版であるElement Plusを採用しました。</p> <p><a href="https://element-plus.org/en-US/">A Vue 3 UI Framework | Element Plus</a></p> <p>当時はVuetifyとElement Plusを比較検討したのですが</p> <ul> <li>フォームの画面ではVuetifyよりも書きやすい</li> <li>当時のVuetifyはVue3サポートが完了していなかったがElement Plusは対応済(現在はVuetifyもVue3をサポートしています)</li> <li>Element Plusの方がTailwind CSSとの親和性が高い</li> </ul> <p>といった点からElement Plusを選択しました。</p> <p>当時RCだったNuxt.js v3に対応したUIコンポーネントライブラリは多くありませんでしたが、現在は<a href="https://vuetifyjs.com/en/">Vuetify</a>や<a href="https://quasar.dev/">Quasar</a>などのライブラリが対応しており、選択肢が広がっています</p> <h2 id="これ以上の設計方針は決めなかった">これ以上の設計、方針は決めなかった</h2> <p>ほかにも開発方針として</p> <ul> <li>コンポーネントの分割方針をどうするか</li> <li>Composition API(コンポーネントとロジックの分離)をどう活用するか</li> <li>社内スタッフ向け、ホテル向けと2面ある管理画面のUIでコンポーネントを共用するのか</li> </ul> <p>など、初期に決めるべきことはたくさんあったのですが、機能開発をいち早く進めるためにこれらの方針を明確に定めずに開発を進めてしまいました。</p> <p>振り返ると、これはとても良くない判断で、むしろ早く作るためにもっとじっくり設計や開発方針を練るべきだったと考えています。</p> <h1 id="初期ローンチ後の課題">初期ローンチ後の課題</h1> <ul> <li>2022年4月~9月 ... 初期開発</li> <li>2022年12月~2023年3月 ... 大きめな機能追加</li> </ul> <p>を経て、その後も機能追加や改善を続けていくことになったのですが、機能追加の際に以下のような課題を感じました。</p> <ul> <li>新たにコンポーネントを開発する際に迷うことが多い <ul> <li>コンポーネントのインターフェース(Props)をどう定義するか</li> <li>GraphQLのFragmentをどう使っていくべきか</li> <li>エラーメッセージをどこにどう書くか</li> </ul> </li> <li>コードの見通しが良くない <ul> <li>入力項目が多いフォーム画面のロジックを扱うcomposablesが肥大化していて、見通しが悪い</li> </ul> </li> <li>型を厳密に扱えていない <ul> <li>as, anyを使っている箇所があり、型の安全性を担保できていない記述がある</li> </ul> </li> </ul> <p>これらを踏まえて、チームメンバーとも相談をした上で中長期で開発・運用していくためにフロントエンドの設計を改善することにしました。</p> <h1 id="改善した内容">改善した内容</h1> <p>宿泊事業を成長させるためのプロジェクトという前提があるため、ビジネスとして必要な機能追加をしながら、少しずつ以下の改善を行い、現在も継続しています。</p> <h2 id="1-コンポーネント設計の見直し">1. コンポーネント設計の見直し</h2> <h3 id="ディレクトリ構成の変更">ディレクトリ構成の変更</h3> <p>前述のコンポーネントレイヤーのうち、特にobjects配下にコンポーネントが多く存在しており、見通しが悪かったため、以下のルールで分別しました。</p> <ul> <li>社内、ホテル、共通のコンポーネントを分別する構成に変更</li> </ul> <table> <thead> <tr> <th style="text-align:left;"> ディレクトリ </th> <th style="text-align:left;"> 役割 </th> </tr> </thead> <tbody> <tr> <td style="text-align:left;"> inside </td> <td style="text-align:left;"> 一休社内スタッフ用の管理画面のみで使用するコンポーネント </td> </tr> <tr> <td style="text-align:left;"> accommodations </td> <td style="text-align:left;"> ホテル向けの管理画面のみで使用するコンポーネント </td> </tr> <tr> <td style="text-align:left;"> shared </td> <td style="text-align:left;"> 2つの管理画面で共用するコンポーネント </td> </tr> </tbody> </table> <h3 id="大きくなったコンポーネントの分割">大きくなったコンポーネントの分割</h3> <p>大きいものになると1コンポーネントで1,000行に近いサイズになっていて、見通しが悪かったため <strong>1コンポーネント350行程度を目安とする</strong> というガイドラインを定めてコンポーネントを分割しました。分割時にコンポーネントの依存関係を明確にするために、以下のルールで分割後に再配置をしました。</p> <pre class="code" data-lang="" data-unlink>components └objects └inside └HotelDescription └HotelDescription.vue(親コンポーネント) └components ├child1/child1.vue(親コンポーネントのみで使う子コンポーネントその1) └child2/child2.vue(親コンポーネントのみで使う子コンポーネントその2)</pre> <h3 id="Fragment-Colocationを導入してコンポーネントのインターフェースとFragmentを整理">Fragment Colocationを導入してコンポーネントのインターフェースとFragmentを整理</h3> <p>改善前はルールを敷かずにFragmentによるGraphQLクエリの共通化をしていました。 以下はコード例です。</p> <pre class="code lang-graphql" data-lang="graphql" data-unlink><span class="synType">fragment</span> <span class="synIdentifier">HotelFragment</span> <span class="synStatement">on</span> <span class="synType">Hotel</span> <span class="synSpecial">{</span> <span class="synIdentifier">id</span> <span class="synIdentifier">name</span> <span class="synIdentifier">description</span> <span class="synIdentifier">address</span> <span class="synIdentifier">rooms</span> <span class="synSpecial">}</span> </pre> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// HotelFragmentを必要とするコンポーネント</span> <span class="synComment">// idとdescriptionがあれば良いが他の情報も含んだFragmentをPropsとして要求してしまっている</span> <span class="synStatement">&lt;</span>template<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span><span class="synIdentifier">{{</span> id <span class="synIdentifier">}}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span><span class="synIdentifier">{{</span> description <span class="synIdentifier">}}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/template<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>script setup lang<span class="synStatement">=</span><span class="synConstant">&quot;ts&quot;</span><span class="synStatement">&gt;</span> <span class="synStatement">interface</span> Props <span class="synIdentifier">{</span> hotel: HotelFragment <span class="synIdentifier">}</span> <span class="synStatement">&lt;</span>/script<span class="synStatement">&gt;</span> </pre> <p>これにより</p> <ul> <li>オーバーフェッチが発生していた<a href="#f-5e5e2faf" id="fn-5e5e2faf" name="fn-5e5e2faf" title="Apollo Clientのキャッシュや、GraphQLサーバのLoaderによって発生しないケースもあります">*1</a></li> <li>共通化しているFragmentの配置場所が定まっていない</li> </ul> <p>という課題があったため、Fragment Colocationを導入しました。</p> <p>Fragmentによるデータの宣言を強制しているRelayの設計を参考に、以下のようなルールでコンポーネントのインターフェースとFragmentを扱うようにしています。</p> <ul> <li>Fragmentファイルは利用するコンポーネントと同階層に配置する</li> <li>コンポーネントのインターフェース(Props)はFragmentの型で定義する</li> <li>Fragment名は「コンポーネント名 + GraphQLスキーマの型名」で命名する</li> </ul> <p>改善後のファイル配置とコード例はこんな形です。</p> <pre class="code" data-lang="" data-unlink>components └objects └inside └HotelDescription(コンポーネントのディレクトリ) ├HotelDescription.vue(ホテルの説明文を表示するコンポーネント) └HotelDescription_Hotel.frag.graphql(コンポーネントが利用するFragment)</pre> <ul> <li>コンポーネントのインターフェース</li> </ul> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">&lt;</span>script setup lang<span class="synStatement">=</span><span class="synConstant">&quot;ts&quot;</span><span class="synStatement">&gt;</span> <span class="synStatement">interface</span> Props <span class="synIdentifier">{</span> hotel: HotelDescriptionHotelFragment <span class="synIdentifier">}</span> <span class="synStatement">&lt;</span>/script<span class="synStatement">&gt;</span> </pre> <ul> <li>Fragment</li> </ul> <pre class="code lang-graphql" data-lang="graphql" data-unlink><span class="synType">fragment</span> <span class="synIdentifier">HotelDescriptionHotel</span> <span class="synStatement">on</span> <span class="synType">Hotel</span> <span class="synSpecial">{</span> <span class="synIdentifier">id</span> <span class="synIdentifier">description</span> <span class="synSpecial">}</span> </pre> <p>プロジェクトで利用しているGraphQL Code GeneratorのClient PresetではFragment Maskingという機能が提供されていて、これによってFragmentで取得するフィールドは利用するコンポーネント以外からは参照できないように隠蔽化もできますが、まだこの機能は有効にしていません。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fthe-guild.dev%2Fgraphql%2Fcodegen%2Fplugins%2Fpresets%2Fpreset-client%23how-to-disable-fragment-masking" title="client-preset – GraphQL Code Generator" 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://the-guild.dev/graphql/codegen/plugins/presets/preset-client#how-to-disable-fragment-masking">the-guild.dev</a></cite></p> <h2 id="2-業務処理composablesの分割">2. 業務処理(composables)の分割</h2> <p>Vue.jsのComposition APIの設計に沿って、コンポーネント内のロジックをcomposablesに書いていく方針で進めていましたが、入力内容が多いフォームの画面では</p> <ul> <li>登録や変更処理などのふるまい</li> <li>フォームの初期状態</li> <li>Validation</li> </ul> <p>などが1箇所に書かれており、記述量が多く見通しが悪くなっていました。</p> <p>これを解決するために、ルートに <code>lib/domain</code> というディレクトリを設置して</p> <ul> <li>フォームの初期状態</li> <li>Validation</li> </ul> <p>を分離する設計に変更しました。</p> <pre class="code" data-lang="" data-unlink>lib └domain └Hotel ├HotelForm.ts └HoetlValidator.ts</pre> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// HotelForm.ts</span> <span class="synStatement">export</span> <span class="synStatement">type</span> HotelForm <span class="synStatement">=</span> <span class="synIdentifier">{</span> name?: Scalars<span class="synIdentifier">[</span><span class="synConstant">'String'</span><span class="synIdentifier">]</span> description?: Scalars<span class="synIdentifier">[</span><span class="synConstant">'String'</span><span class="synIdentifier">]</span> ... <span class="synIdentifier">}</span> </pre> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// HotelValidator.ts</span> <span class="synStatement">export</span> <span class="synStatement">function</span> useHotelValidator<span class="synStatement">(</span>form: HotelForm<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synType">const</span> descriptionCheck <span class="synStatement">=</span> <span class="synStatement">(</span>description: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synComment">// descriptionに対するチェック処理</span> <span class="synIdentifier">}</span> <span class="synType">const</span> rules <span class="synStatement">=</span> computed<span class="synStatement">&lt;</span>FormRules<span class="synStatement">&gt;(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> description: <span class="synIdentifier">[</span> <span class="synIdentifier">{</span> validator: descriptionCheck<span class="synStatement">,</span> trigger: <span class="synConstant">'change'</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="synIdentifier">}</span><span class="synStatement">)</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> rules<span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// composables</span> <span class="synStatement">export</span> <span class="synStatement">function</span> useHotel<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synComment">// HotelFormの初期化</span> <span class="synType">const</span> hotelForm: HotelForm <span class="synStatement">=</span> reactive<span class="synStatement">(</span><span class="synIdentifier">{</span> name: <span class="synType">undefined</span><span class="synStatement">,</span> description: <span class="synType">undefined</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synComment">// Hotelに関する業務処理</span> ... <span class="synStatement">return</span> <span class="synIdentifier">{</span> validationRules: useHotelValidator<span class="synStatement">(</span>form<span class="synStatement">)</span>.rules<span class="synStatement">,</span> <span class="synIdentifier">}</span> </pre> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// validationを使うFormを持つVueコンポーネント</span> <span class="synStatement">&lt;</span>template<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>Form :model<span class="synStatement">=</span><span class="synConstant">&quot;form&quot;</span> :rules<span class="synStatement">=</span><span class="synConstant">&quot;validationRules&quot;</span> <span class="synStatement">&gt;</span> ... <span class="synStatement">&lt;</span>/Form<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/template<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>script setup lang<span class="synStatement">=</span><span class="synConstant">&quot;ts&quot;</span><span class="synStatement">&gt;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> useHotel <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./composables'</span> <span class="synType">const</span> <span class="synIdentifier">{</span> form<span class="synStatement">,</span> validationRules<span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synStatement">=</span> useHotel<span class="synStatement">()</span> <span class="synStatement">&lt;</span>/script<span class="synStatement">&gt;</span> </pre> <h2 id="3-型安全に開発できるように厳しいlint設定に変更">3. 型安全に開発できるように厳しいlint設定に変更</h2> <p>初期開発時はeslint, prettierによるコードフォーマット、型検査は導入していましたが、非nullアサーション(!)や型アサーションによるasやany型の利用を制限していませんでした。</p> <p>この結果、本来は型ガードやアサーション関数を使って型を保証するべきところを!, asを使ってコンパイルエラーを回避したり、any型を不用意に使うケースが出てきてしまいました。 (以下でもasやanyの危険性について語られていて、TypeScriptによる型の安全性を享受するために避けるべき、と書かれています)</p> <p><a href="https://qiita.com/uhyo/items/aae57ba0734e36ee846a">&#x6557;&#x5317;&#x8005;&#x306E;TypeScript #TypeScript - Qiita</a></p> <p>これを踏まえて</p> <ul> <li>非nullアサーション</li> <li>型アサーション</li> <li>any型</li> </ul> <p>の利用箇所を撲滅してlintで制限することにしました。小さい単位で作業を分割して進められるように</p> <ol> <li>修正対象箇所がわかるようにwarningを出すようにlintを変更</li> <li>地道にwarningが出なくなるように書き換え</li> <li>warningがなくなったらlint設定を変更してerrorにしてCIで止まるようにする</li> </ol> <p>というステップで作業を実施しました。</p> <p><figure class="figure-image figure-image-fotolife" title="asの撲滅のためのpull request"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kentana20/20231212/20231212204846.png" width="1161" height="491" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>asの撲滅のためのpull request</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="CIで止まるようにlintでエラーになるようにするpull request"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kentana20/20231212/20231212210025.png" width="1150" height="803" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CIで止まるようにlintでエラーになるようにするpull request</figcaption></figure></p> <p>非nullアサーションは完全に撲滅できましたが、型アサーションとanyの利用は改善の途中です。</p> <h2 id="4-秩序を保てる開発体制ドキュメントの整備">4. 秩序を保てる開発体制、ドキュメントの整備</h2> <p>1~3でだいぶコードに秩序がある状態になりましたが、今後の開発によって悪化しないように以下を実施しました。</p> <ul> <li>コードレビューの強化 <ul> <li>CODEOWNERによるレビューを必須にして、定めた設計方針に沿った内容になっているかを識者がレビューする体制に</li> </ul> </li> </ul> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.github.com%2Fja%2Frepositories%2Fmanaging-your-repositorys-settings-and-features%2Fcustomizing-your-repository%2Fabout-code-owners" title="コードオーナーについて - GitHub Docs" 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://docs.github.com/ja/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners">docs.github.com</a></cite></p> <ul> <li>ドキュメントの整備 <ul> <li>コンポーネントのレイヤーと役割、Fragmentの利用方針、スタイルガイドなどをリポジトリのWikiにまとめて開発やレビューでの指摘に活用</li> </ul> </li> </ul> <p><figure class="figure-image figure-image-fotolife" title="Wiki(抜粋)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kentana20/20231212/20231212210928.png" width="861" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Wiki(抜粋)</figcaption></figure></p> <h1 id="現在と今後">現在と今後</h1> <p>これらを積み重ねた結果</p> <ul> <li>components配下はかなり見通しがよくなり、秩序がある状態になった</li> <li>設計・開発をする際の指針ができており、レビューも指摘しやすくなった</li> </ul> <p>など、改善の効果を感じています。先月末~現在にかけて、新機能を開発しているのですが、フロントエンドの開発はとてもスムーズで、迷うことがほぼなくなってきました。</p> <h2 id="今後やりたいこと">今後やりたいこと</h2> <p>引き続きプロジェクトを進めながら改善を続ける状態を維持したいと思っています。 具体的に考えている大きめの改善テーマとしては</p> <ul> <li>@Vue/apollo(@vue/apollo-composable)の脱却 <ul> <li>v4のβ期間が長く、バージョンアップによって意図しない不具合が入ったことがあるため、別のGraphQLクライアントへの変更を検討中</li> </ul> </li> <li>E2Eテストの整備 <ul> <li>機能追加・変更時のリグレッションテストを効率的に行うため、Playwrightを導入してE2Eテストを整備、CIに組み込む予定で改善中</li> </ul> </li> </ul> <p>などがあります。</p> <h2 id="改善を継続するためのポイント">改善を継続するためのポイント</h2> <ul> <li>プロジェクトで開発する際に違和感を感じたら、熱量があるうちにIssueにする(コードレビューや開発しながらやるとよい)</li> <li>上がったIssueを開発者で議論・認識合わせをしておく</li> <li>機能開発とセットで改善することを常に考える</li> </ul> <p>改善のネタを常に仕込んでおいて、機能開発をする際に「あ、あれ一緒にやりません?」みたいな形で組み込んで機能追加とシステム改善を同時にやっていくのが理想だと考えています。</p> <h1 id="まとめ">まとめ</h1> <p>一休.com宿泊の管理画面のフロントエンド設計について、開発初期から現在までの変遷と今後について紹介しました。 本来は開発初期に決めておくべき内容を決めなかったことでローンチ後に改善することになってしまいましたが、まだシステムが大きくないタイミングで改善を進められたことは良かったと思っています。</p> <p>一緒に改善を進めてくれているチームメンバーにとても感謝しています。</p> <p>今後もこのシステムで</p> <ul> <li>大きなビジネス成果につなげる</li> <li>中長期で開発・運用していけるシステムにする</li> </ul> <p>を両立してやっていけるように、引き続きやっていきたいと思います。</p> <h1 id="おわりに">おわりに</h1> <p>一休では、技術的にも妥協せず、事業の成果をともに目指せる仲間を募集しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>まずはカジュアル面談からお気軽にご応募ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <p>明日はtak-ondaの「一休レストランで Next.js App Router から Remix に乗り換えた話」です。お楽しみに!</p> <div class="footnote"> <p class="footnote"><a href="#fn-5e5e2faf" id="f-5e5e2faf" name="f-5e5e2faf" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">Apollo Clientのキャッシュや、GraphQLサーバのLoaderによって発生しないケースもあります</span></p> </div> kentana20 ADR を1年間書いてみた感想 hatenablog://entry/6801883189065866348 2023-12-13T11:51:12+09:00 2023-12-13T11:58:44+09:00 宿泊開発チームでエンジニアをしている @kosuke1012 です。チームで ADR を書き始めて1年くらい経ったので、その感想を書いてみたいと思います。 この記事は 一休.comのカレンダー | Advent Calendar 2023 - Qiita の13日目の記事です。 ADRとは アーキテクチャ・ディシジョン・レコードの略で、アーキテクチャに関する意思決定を軽量なテキストドキュメントで記録していくものです。 出典はこちらで、 Documenting Architecture Decisions わかりやすい和訳は以下の記事が、 アーキテクチャ決定レコードの概要 | Cloud アーキ… <p>宿泊開発チームでエンジニアをしている <a href="https://x.com/kosuke_yellow">@kosuke1012</a> です。チームで ADR を書き始めて1年くらい経ったので、その感想を書いてみたいと思います。</p> <p>この記事は <a href="https://qiita.com/advent-calendar/2023/ikyu">&#x4E00;&#x4F11;.com&#x306E;&#x30AB;&#x30EC;&#x30F3;&#x30C0;&#x30FC; | Advent Calendar 2023 - Qiita</a> の13日目の記事です。</p> <h1 id="ADRとは">ADRとは</h1> <p>アーキテクチャ・ディシジョン・レコードの略で、アーキテクチャに関する意思決定を軽量なテキストドキュメントで記録していくものです。</p> <p>出典はこちらで、</p> <ul> <li><a href="https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions">Documenting Architecture Decisions</a></li> </ul> <p>わかりやすい和訳は以下の記事が、</p> <ul> <li><a href="https://cloud.google.com/architecture/architecture-decision-records?hl=ja">&#x30A2;&#x30FC;&#x30AD;&#x30C6;&#x30AF;&#x30C1;&#x30E3;&#x6C7A;&#x5B9A;&#x30EC;&#x30B3;&#x30FC;&#x30C9;&#x306E;&#x6982;&#x8981; &nbsp;|&nbsp; Cloud &#x30A2;&#x30FC;&#x30AD;&#x30C6;&#x30AF;&#x30C1;&#x30E3; &#x30BB;&#x30F3;&#x30BF;&#x30FC; &nbsp;|&nbsp; Google Cloud</a></li> <li><a href="https://developer.mamezou-tech.com/blogs/2022/04/28/adr/">&#x30A2;&#x30FC;&#x30AD;&#x30C6;&#x30AF;&#x30C1;&#x30E3;&#x30FB;&#x30C7;&#x30B7;&#x30B8;&#x30E7;&#x30F3;&#x30FB;&#x30EC;&#x30B3;&#x30FC;&#x30C9;&#x306E;&#x52E7;&#x3081; | &#x8C46;&#x8535;&#x30C7;&#x30D9;&#x30ED;&#x30C3;&#x30D1;&#x30FC;&#x30B5;&#x30A4;&#x30C8;</a></li> <li><a href="https://qiita.com/fuubit/items/dbb22435202acbe48849">&#x30A2;&#x30FC;&#x30AD;&#x30C6;&#x30AF;&#x30C1;&#x30E3;&#x306E;&#x300C;&#x306A;&#x305C;&#xFF1F;&#x300D;&#x3092;&#x8A18;&#x9332;&#x3059;&#x308B;&#xFF01;ADR&#x3063;&#x3066;&#x306A;&#x3093;&#x305E;&#x3084;&#xFF1F; #&#x8A2D;&#x8A08; - Qiita</a></li> </ul> <p>事例は以下の記事が分かりやすかったです。</p> <ul> <li><a href="https://blog.studysapuri.jp/entry/architecture_decision_records">&#x301C;&#x305D;&#x306E;&#x610F;&#x601D;&#x6C7A;&#x5B9A;&#x3092;&#x523B;&#x3081;&#x301C;&#x300C;&#x30A2;&#x30FC;&#x30AD;&#x30C6;&#x30AF;&#x30C1;&#x30E3;&#x30FB;&#x30C7;&#x30B7;&#x30B8;&#x30E7;&#x30F3;&#x30FB;&#x30EC;&#x30B3;&#x30FC;&#x30C9;(ADR)&#x300D;&#x3092;&#x5229;&#x7528;&#x3057;&#x305F;&#x8A2D;&#x8A08;&#x306E;&#x8A18;&#x9332; - &#x30B9;&#x30BF;&#x30C7;&#x30A3;&#x30B5;&#x30D7;&#x30EA; Product Team Blog</a></li> </ul> <h1 id="ADRを導入したねらい">ADRを導入したねらい</h1> <p>機能を追加したり改修したりする際は、チーム外のメンバー含む様々な人との議論を経て、仕様やアーキテクチャが決定されていくと思います。</p> <p>そうした議論を経た最終的な決定は実際のプロダクトやアーキテクチャ図などに表現されるのですが、「どうしてそのような仕様やアーキテクチャになっているのか」と言った部分を後から知りたくなったりすることがありました。</p> <p>これは ADR で解決したい課題そのものと言って良いものなので、チームで ADR を書いていってみよう!という話になりました。</p> <h1 id="採用したフォーマット">採用したフォーマット</h1> <p>いろいろなフォーマットがあるようなのですが、まずは以下のようなフォーマットで記載しました。</p> <pre style="white-space: pre-wrap; word-break: break-all"> # タイトル タイトルには、一目で論点がわかるタイトルを記載します。可能な限り具体的で、それでいて簡潔なタイトルを心がけると良さそうです。(これが難しい) # ステータス draft, proposed, accepted, rejected, deprecated, superseded 原典のフォーマットには draft はありませんが、この段階で決定を除いて記載しておいて、MTG で決定みたいに進めたいシチュエーションがあったので、追加してみました。 proposed で一旦完成で、チーム(またはチーム間)で合意ができたら accepted にするのが良いかと思います。 別な議論などで決定が覆された場合、当該 ADR の決定を修正するのではなく、当該 ADR (ADR: 1 とする) のステータスを ( rejected: ADR: 2 に伴い ) とした上で、別途新しく ADR を起こし ( ADR: 2 とする )、そのステータスを (proposed: ADR: 1は破棄 ) などとすると良いです。 # コンテキスト コンテキストには、その ADR の決定が求められている背景や、対応案、対応案に対する評価を記載します。 # 決定 コンテキストを踏まえた決定を、受動的ではなく、肯定的かつ能動的に記載します。 # 影響 この決定の結果生じる影響を記載します。これは、決定の結果得られるメリットのほか、コンテキストで記載した対案を選択しなかった故のデメリットであったりも記載すると良いと思いました。 また、決定の結果、今後チームで意識しなければならないことであったり、改めて必要になる機能やその ADR を記載しても良いと思います。 </pre> <p> <a href="https://github.com/joelparkerhenderson/architecture-decision-record/blob/ab49e5ecad09b0e80c6ebeaf4f41c7958a2ad291/locales/en/templates/decision-record-template-by-michael-nygard/index.md">Michael Nygard さんのフォーマット</a>そのままに draft ステータスだけを追加しています。「ADR を書くときのコツ」の項で後述しますが、draft ステータスは結論が決まっていない段階で ADR を書くのに便利です。このフォーマットで1年運用してみましたが、必要十分だなという感じでした。</p> <h1 id="ADRの格納場所">ADRの格納場所</h1> <p>私のチームではドキュメントシステムに Confluence を利用していたので、 ADR もそこに記載していきました。そのほかの選択肢としては、プロダクトの GitHub のリポジトリに置く案もあったのですが、そうするとプロダクトを横断する ADR や、具体的なプロダクトが決まっていない柔らかい段階での ADR の置き場に困ったりするので、 Confluence に落ち着きました。</p> <p>ADR は自分たち以外のいくつかのチームでも書くようになったのですが、その管理方法はチームによりけりでした。<br/> 例えば <a href="https://user-first.ikyu.co.jp/entry/2023/11/09/175121">GitHub Projects &#x3092;&#x5229;&#x7528;&#x3057;&#x305F;&#x30BF;&#x30B9;&#x30AF;&#x7BA1;&#x7406; - &#x4E00;&#x4F11;.com Developers Blog</a> のチームでは、ADR 専用のリポジトリを作った上で、GitHub Issues に記載していったようでした。これなら先述の問題はクリアできています。<br/> プロジェクト管理に GitHub Projects を用いている場合は GitHub に一元化することが出来て相性も良いため、GitHub Issues に記載していく方法が良いかもです。</p> <h1 id="書いてみたADRの例">書いてみたADRの例</h1> <h3 id="個々のADR">個々のADR</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/w/woodcock/20231211/20231211200121.png" width="658" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="ADRに番号を振ってプロダクトや案件ごとにまとめています">ADRに番号を振ってプロダクトや案件ごとにまとめています</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/w/woodcock/20231211/20231211123012.png" width="816" height="431" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="書いてみてよかったところ">書いてみてよかったところ</h1> <p>ADR を書いてみてよかったことをいくつか書いてみます。</p> <h3 id="1-ここの設計どうしてこうなってたんだっけに困らない">1. 「ここの設計どうしてこうなってたんだっけ?」に困らない</h3> <p>ADR を書いた1番のモチベーションです。これが解消するのは非常に助かりました。自チームだけではなく、他チームが困っているときに「スッ…」と ADR をスマートに差し出すこともできました。</p> <h3 id="2-議論の効率が上がる">2. 議論の効率が上がる</h3> <p>以下の複数のポイントで、開発する中での議論の効率が上がりました。</p> <h4 id="議論が蒸し返らない">議論が蒸し返らない</h4> <p>1.とも重なるのですが、議論になるような仕様上/設計上のポイントでその背景を思い出すのに手間取ったり、(新事実が見つからない限りは)「やっぱりこっちの方がいいのでは」みたいな話にならないので、議論の効率が上がります。</p> <h4 id="意思決定するべきことが明確になる">意思決定するべきことが明確になる</h4> <p>「ADR を書くときのコツ」項で後述するのですが、あらかじめ draft の状態で ADR を記載しておくことで、意思決定しなければいけない項目が明確になり、議論の中であいまいにせず意思決定するようになり、議論の効率が上がります。</p> <h4 id="意思決定したことが明確になる">意思決定したことが明確になる</h4> <p>ADR を導入してから、MTG の最後に「hoge の件 ADR に書いておきましょう」といった会話が増えました。これによって、意思決定したことをクリアに言語化することになり、議論の効率が上がります。</p> <h4 id="仕様検討決定までのフレームワークができる">仕様検討~決定までのフレームワークができる</h4> <p>チームで議論が必要になった際に「じゃ、ADR 書いてまとめておきましょう」という流れができるのが結構良く、検討の中心となるメンバーが増えたり変わったりしてもフレームワークに沿って進めることで議論のレベルを保ちやすくなります。</p> <h3 id="3-新規メンバーが立ち上がりやすくなる">3. 新規メンバーが立ち上がりやすくなる</h3> <p>新しく参画したメンバーが疑問に思うであろうポイントに ADR があるケースが多いので、キャッチアップしやすいという意見も上がりました。</p> <h1 id="ADR-を書くときのコツ">ADR を書くときのコツ</h1> <p>良い ADR を書くのには割とコツがあることがわかってきたので、気づいた点を書いてみます。</p> <h3 id="1-タイトルは体言止めにせず文にする">1. タイトルは体言止めにせず、文にする</h3> <p>「hoge について」や「hoge の設計」など、体言止めにするのではなく、「hoge は fuga とする」といったように、タイトルを文にします。</p> <p>こうすると、タイトルをみるだけで内容が一発でわかるほか、ADR を書く際にも論点がクリアになり、記載や議論の効率があがりました。</p> <h3 id="2-結論が決まっていなくても-ADR-を書きはじめてしまう">2. 結論が決まっていなくても ADR を書きはじめてしまう</h3> <p>結論が決まっていない段階であっても ADR を書きはじめることで、何を決める必要があるのかが明確になってよかったです。<br/> 未定のところは実際に hoge などと書いておいて、それを元に議論して、決定事項で hoge を埋める感じです。</p> <h3 id="3-コンテキストを-SCQA-フォーマットで書く">3. コンテキストを SCQA フォーマットで書く</h3> <p>コンテキストの章で、結論に至るまでのギャップをいかに埋めるかというのが大切なのですが、これが慣れるまで結構難しいです。</p> <p>その際のフォーマットとして、SCQA というのが有用でした。『考える・書く技術』という本で紹介されているフォーマットなのですが、</p> <ul> <li>S: Situation 状況</li> <li>C: Complication 複雑化</li> <li>Q: Question 疑問</li> <li>A: Answer 答え</li> </ul> <p>Situation でまず状況の説明をして、それに続く Complication で、今回の Question やその Answer が必要になるトリガーを説明します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.diamond.co.jp%2Fbook%2F9784478490273.html" title="[新版]考える技術・書く技術" 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.diamond.co.jp/book/9784478490273.html">www.diamond.co.jp</a></cite></p> <p>上で記載した ADR の例で行くと、</p> <h4 id="S">S:</h4> <blockquote><p>(Slack リンク) での記載の通り、未付与のトランザクションに対して、PayPayの取消が発生することは考えられる。</p></blockquote> <h4 id="C--Q-">C ~ Q :</h4> <blockquote><p>この場合に、<br/> 1. 新たに取消トランザクションを作成した上で、新規と取消のトランザクションを見て付与取消バッチに判断してもらうのか<br/> 2. 既存のトランザクションを論理削除するのか<br/> の2通りの対応がありうるが、どちらにするか。<br/> 1.のメリットとしては、<br/> ...<br/> などのメリットがある一方で、<br/> ...<br/> というデメリットはある。</p></blockquote> <p>のような感じです。</p> <p>このフレームワークは、ADRに限らず、割と複雑な PR の Description を書く際にも有用だなと思いました。 ちなみに SCQA フォーマットは『スタッフエンジニア』という本でも紹介されていて(私もそれで知りました)、<br/> 曰く、</p> <blockquote><p>多くの議論で、冒頭の段落が巧みに構成されているだけで重要な対話に火が灯る。</p></blockquote> <p>だそうです。シビれますね。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbookplus.nikkei.com%2Fatcl%2Fcatalog%2F23%2F04%2F07%2F00760%2F" title="<5/8新刊>『スタッフエンジニア マネジメントを超えるリーダーシップ』" 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://bookplus.nikkei.com/atcl/catalog/23/04/07/00760/">bookplus.nikkei.com</a></cite></p> <h3 id="4-コンテキストにもうほぼ結論の手前まで書いてしまう">4. コンテキストに、もうほぼ結論の手前まで書いてしまう</h3> <p>前述の通り、コンテキストで背景の共有 → 問題意識の共有、と進めた上で「決定」の項目で結論を書くのですが、コンテキストにどこまで書くかというのが悩みどころです。</p> <p>これは好みもありますが、もうほぼほぼ結論の手前まで「コンテキスト」の項目に書いてしまえば良いと思いました。</p> <p>コンテキストで結論の手前まで書いた結果、読み手が「決定」を読んだ感想としては『でしょうね~』となるくらいまで書いてしまって良いのではないかなと思います。</p> <h3 id="5-とにかく軽量にする">5. とにかく軽量にする</h3> <p>優先順位として、<br/> <strong>開発する中での重要な意思決定の記録を漏らさないこと > リッチな ADR を書くこと </strong><br/> として、1つ1つの ADR を軽量にして、記載するハードルを下げることを意識すると良さそうです。</p> <p>1つ1つの ADR にあまり力を入れすぎると、だんだんと書かなくなっていってしまうことがありました。</p> <h3 id="6-アーキテクチャに限らず仕様上の決定も-ADR-に記載していく">6. アーキテクチャに限らず、仕様上の決定も ADR に記載していく</h3> <p>ADR はアーキテクチャ以外の決定の記録にも有用でした。それらの決定が、アーキテクチャ上の決定に影響を与えることもあるため、同じ ADR として並べて管理しておくと便利でした。</p> <h1 id="ADR-では足りないところ">ADR では足りないところ</h1> <p>ここまで説明してきた ADR ですが、それだけでは足りないなと思う部分もありました。</p> <h3 id="検討する単位が大きいものを1つのADRで書こうとするのは厳しい">検討する単位が大きいものを1つのADRで書こうとするのは厳しい</h3> <p>「hogehoge の仕様検討、といった粒度のものを一つの ADR がチームで出てきたのですが、決める論点が多かったり、発散したりしてしまってあまりうまくいかなかった」という意見がありました。</p> <p>「ADR を書くときのコツ」の項に「タイトルは体言止めにせず、文にする」「とにかく軽量にする」と記載しましたが、逆に言うと、これが出来ないようなテーマについては、ADR には向かないのではないかと思いました。</p> <h3 id="全体として今どうなっているのかを示すドキュメントはADR-とは別にほしい">「全体として今どうなっているのか」を示すドキュメントはADR とは別にほしい</h3> <p>ADR は、ここの意思決定やその背景を記述するドキュメントですが、それに加えて、やはり「全体として今どのような設計になっているのか」といったドキュメントは必要だなと思いました。いわゆる Design Docs がそれにあたると思います。</p> <p>Design Docs があり、その個々の設計に至った意思決定やその背景がADRとして残されていると理想的なのではないかと思います。全体としての What を Design Docs に記載して、Why を ADR でサポートするイメージでしょうか。</p> <h1 id="Design-Docs-とのすみわけ">Design Docs とのすみわけ</h1> <p>Design Docs には、Why に答える項目を含めたフォーマットもあったりするので、チームの中で ADR と Design Docs のすみわけの指針がそろっていると良さそうです。一つの観点として「Design Docs が実装でのフィードバックに基づいて継続的に更新される性質を持ち、一方でADRはスナップショットである」という性質の違いがありそう、との意見が出ました。</p> <p>以上から、Design Docs と ADR の性質の違いをまとめてみます。</p> <table> <thead> <tr> <th></th> <th>反映するもの</th> <th>時間軸</th> <th>答える対象</th> </tr> </thead> <tbody> <tr> <td>Design Docs (特に実装以後) </td> <td>実装</td> <td>今</td> <td>What</td> </tr> <tr> <td>ADR</td> <td>意思決定</td> <td>スナップショット</td> <td>Why</td> </tr> </tbody> </table> <p>表中 Design Docs と ADR としてまとめていますが、必ずしもそれぞれのフォーマットでフルに記載する必要はないかもしれません。 例えば ADR はログの形で簡易的に記載していったり、逆に Design Docs も必要な部分だけ記載する、といった判断もあるかもしれません。</p> <p>これらの項目があることを考慮しておくと、必要十分なドキュメントを用意していけるのではないかと思いました。</p> <h1 id="さいごに">さいごに</h1> <p>一休では、ともに試行錯誤しながらよいサービスを作ってくれる仲間を募集しています! <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施していますので、ぜひお気軽にご連絡ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> woodcock 請求書発行のためにEmbulkを使って爆速でデータを集約した話 hatenablog://entry/6801883189065580064 2023-12-12T11:11:54+09:00 2023-12-12T11:11:54+09:00 こんにちは。宿泊開発チームの菊地です! このエントリは 一休.com Advent Calendar 2023 12日目の記事です。昨日は id:rotom によるSlack Enterprise Grid における情報バリアの設計でした。その他の素敵なエントリも以下のリンクからご覧ください。 qiita.com 私はEmbulkを使って、各プロダクトの請求データを集約する機能を担当しました。今回は、Embulkの紹介とふりかえりをしていきたいと思います! 背景 課題 解決策 Embulkとは? 今回の課題に対してEmbulkがマッチした理由 union: 複数のデータソースを連結する con… <p>こんにちは。宿泊開発チームの菊地です!</p> <p>このエントリは <strong>一休.com Advent Calendar 2023</strong> 12日目の記事です。昨日は <a href="http://blog.hatena.ne.jp/rotom/" class="hatena-id-icon"><img src="https://cdn.profile-image.st-hatena.com/users/rotom/profile.png" width="16" height="16" alt="" class="hatena-id-icon">id:rotom</a> による<a href="https://user-first.ikyu.co.jp/entry/sack-info-barrier">Slack Enterprise Grid における情報バリアの設計</a>でした。その他の素敵なエントリも以下のリンクからご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fqiita.com%2Fadvent-calendar%2F2023%2Fikyu" title="一休.comのカレンダー | Advent Calendar 2023 - Qiita" 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://qiita.com/advent-calendar/2023/ikyu">qiita.com</a></cite></p> <p>私はEmbulkを使って、各プロダクトの請求データを集約する機能を担当しました。今回は、Embulkの紹介とふりかえりをしていきたいと思います!</p> <ul class="table-of-contents"> <li><a href="#背景">背景</a></li> <li><a href="#課題">課題</a></li> <li><a href="#解決策">解決策</a><ul> <li><a href="#Embulkとは">Embulkとは?</a></li> <li><a href="#今回の課題に対してEmbulkがマッチした理由">今回の課題に対してEmbulkがマッチした理由</a><ul> <li><a href="#union-複数のデータソースを連結する">union: 複数のデータソースを連結する</a><ul> <li><a href="#configymlの記述例">config.ymlの記述例</a></li> </ul> </li> <li><a href="#lookup-複数のデータソースを結合する">lookup: 複数のデータソースを結合する</a><ul> <li><a href="#configymlの記述例-1">config.ymlの記述例</a></li> </ul> </li> </ul> </li> </ul> </li> <li><a href="#ふりかえり">ふりかえり</a><ul> <li><a href="#とくに良かったこと">とくに良かったこと</a><ul> <li><a href="#configymlの取り回しのよさが開発スピードをあげてくれた">config.ymlの取り回しのよさが開発スピードをあげてくれた</a></li> <li><a href="#configymlliquidのサポート">config.yml.liquidのサポート</a></li> </ul> </li> <li><a href="#注意したほうがいいこと">注意したほうがいいこと</a><ul> <li><a href="#任意のクエリでlookupしたいときはCSVを一度経由する必要がある">任意のクエリでlookupしたいときは、CSVを一度経由する必要がある</a></li> <li><a href="#GCSへのCSVアップロードプラグインにはstorageobjectslistが必要">GCSへのCSVアップロードプラグインにはstorage.objects.listが必要</a></li> </ul> </li> </ul> </li> <li><a href="#まとめ">まとめ</a><ul> <li><a href="#小ネタEmbulkのメンテナンス体制が新しくなったとのこと2023年3月">小ネタ:Embulkのメンテナンス体制が新しくなったとのこと!(2023年3月)</a></li> </ul> </li> <li><a href="#さいごに">さいごに</a></li> </ul> <h1 id="背景">背景</h1> <p>一休では、これまでプロダクト毎に請求書発行機能が実装されていました。私のチームでは、2023年10月に施行されたインボイス制度<a href="#f-100956dd" id="fn-100956dd" name="fn-100956dd" title=" [https://www.nta.go.jp/taxes/shiraberu/zeimokubetsu/shohi/keigenzeiritsu/invoice_about.htm:title] ">*1</a>の対応として、全プロダクトの請求書を適格請求書形式に改修することになりました。</p> <p>今後法律が改正されることも考慮して、既存の実装を個別に修正するのではなく、請求書マイクロサービスに一元化して各プロダクトから利用するという方針を立てました。</p> <h1 id="課題">課題</h1> <p>一休ではプロダクト毎に個別のDBを持っています。プロダクトによって採用しているDBMSも様々です。全プロダクトの請求書を発行するためには、個別に管理されているデータを統合する必要がありました。また社内向けに、複数DBをまたいだ情報を書き出したCSVの発行が求められていました。</p> <p>これらの要件を満たすため、複数のデータソースからデータを集約して別のデータソースに出力する手法を検討しました。</p> <h1 id="解決策">解決策</h1> <p>この課題を解決するために、Embulkを使って各プロダクトのDBからデータを集約することにしました。</p> <h2 id="Embulkとは">Embulkとは?</h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.embulk.org%2F" title="Embulk" 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.embulk.org/">www.embulk.org</a></cite></p> <blockquote><p>Embulk is an Open-source Pluggable Bulk Data Loader to/from varieties of storages, file formats, databases, cloud services, and else.</p></blockquote> <p>Embulkはあるデータソースからデータを吸い出し、別のデータソースへ転送するためのETLツールです。また、<code>Pluggable</code>とあるように、Embulk本体は基本的な処理順序(inputプラグインを実行し、filterプラグインを実行し、outputプラグインを実行する)のみを制御しており、利用者は個々のユースケースに合わせたプラグイン<a href="#f-19d0c3b5" id="fn-19d0c3b5" name="fn-19d0c3b5" title=" [https://plugins.embulk.org/:title] ">*2</a>の組み合わせで処理を実現します。</p> <p>簡単に動かしてみたい方は、embulkのコマンドでquick startが提供されていますので、試してみてください<a href="#f-0ad09179" id="fn-0ad09179" name="fn-0ad09179" title=" [https://qiita.com/shinyashikis@github/items/80162a252f92b3aca3a8:title] ">*3</a>。</p> <pre class="code lang-sh" data-lang="sh" data-unlink>embulk example <span class="synSpecial">{</span>dir<span class="synSpecial">}</span> </pre> <h2 id="今回の課題に対してEmbulkがマッチした理由">今回の課題に対してEmbulkがマッチした理由</h2> <p>Embulkでは、プラグインを組み合わせることで<strong>複数データソースをまたいだ操作</strong>が簡単に記述できます。今回の要件では、次の2つの操作ができることが非常に強力でした。</p> <h3 id="union-複数のデータソースを連結する">union: 複数のデータソースを連結する</h3> <p><a href="https://github.com/civitaspo/embulk-input-union">unionプラグイン</a>を使うことで、複数のDBからのデータ取得処理を書くことができます。また、一休ではプロダクト毎にPostgresやSQL Serverなどの異なるDBMSを使っているため、適切なinputプラグインが異なります。union プラグインはソースとなる input もまたプラガブルになっており、任意の input プラグインを組み合わせられる自由度の高さも非常にありがたかったです。</p> <h4 id="configymlの記述例">config.ymlの記述例</h4> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">in</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> union <span class="synIdentifier">union</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> product_hoge <span class="synIdentifier">in</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> sqlserver <span class="synIdentifier">url</span><span class="synSpecial">:</span> product_hoge_jdbc_url <span class="synIdentifier">user</span><span class="synSpecial">:</span> product_hoge_db_user <span class="synIdentifier">password</span><span class="synSpecial">:</span> product_hoge_db_pwd <span class="synIdentifier">query</span><span class="synSpecial">:</span> | SELECT hoge_id AS common_id, amount, tax_fee FROM product_hoge_table <span class="synIdentifier">filters</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> column <span class="synIdentifier">add_columns</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synSpecial">{</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> product_code, <span class="synIdentifier">type</span><span class="synSpecial">:</span> string, <span class="synIdentifier">default</span><span class="synSpecial">:</span> <span class="synConstant">&quot;hoge&quot;</span> <span class="synSpecial">}</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> product_fuga <span class="synIdentifier">in</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> postgresql <span class="synIdentifier">host</span><span class="synSpecial">:</span> product_fuga_db_host <span class="synIdentifier">port</span><span class="synSpecial">:</span> product_fuga_db_port <span class="synIdentifier">user</span><span class="synSpecial">:</span> product_fuga_db_user <span class="synIdentifier">password</span><span class="synSpecial">:</span> product_fuga_db_pwd <span class="synIdentifier">database</span><span class="synSpecial">:</span> product_fuga_db_name <span class="synIdentifier">query</span><span class="synSpecial">:</span> | SELECT fuga_id AS common_id, charge AS amount, tax_fee FROM product_fuga_table <span class="synIdentifier">filters</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> column <span class="synIdentifier">add_columns</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synSpecial">{</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> product_code, <span class="synIdentifier">type</span><span class="synSpecial">:</span> string, <span class="synIdentifier">default</span><span class="synSpecial">:</span> <span class="synConstant">&quot;fuga&quot;</span> <span class="synSpecial">}</span> <span class="synIdentifier">out</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> postgresql <span class="synIdentifier">host</span><span class="synSpecial">:</span> common_db_host <span class="synIdentifier">user</span><span class="synSpecial">:</span> common_db_user <span class="synIdentifier">port</span><span class="synSpecial">:</span> common_db_port <span class="synIdentifier">password</span><span class="synSpecial">:</span> common_db_pwd <span class="synIdentifier">database</span><span class="synSpecial">:</span> common_db_name <span class="synIdentifier">table</span><span class="synSpecial">:</span> common_table <span class="synIdentifier">mode</span><span class="synSpecial">:</span> merge <span class="synIdentifier">merge_rule</span><span class="synSpecial">:</span> <span class="synSpecial">[</span> <span class="synConstant">&quot;product_code = S.product_code&quot;</span>, <span class="synConstant">&quot;id = S.common_id&quot;</span>, <span class="synConstant">&quot;amount = S.amount&quot;</span>, <span class="synConstant">&quot;tax_fee = S.tax_fee&quot;</span> <span class="synSpecial">]</span> </pre> <h3 id="lookup-複数のデータソースを結合する">lookup: 複数のデータソースを結合する</h3> <p><a href="https://github.com/InfoObjects/embulk-filter-csv_lookup"> csv_lookupプラグイン</a>を使うことで、DBから取得した情報に対し、CSV のデータを SQL の left join のような形で結合できます。このプラグインでデータベースと CSV を結合した帳票を得ることができました。処理自体も非常に軽量で、例えば、6,000件のDBレコードに対し18,000行のCSVをlookupしたCSVを発行するジョブは平均5分18秒で実行できました<a href="#f-14143157" id="fn-14143157" name="fn-14143157" title="このケースはSQL自体がやや重かったり、後述の理由からEmbulkを内部で多重実行していますので本来はもっと早いと思います">*4</a>。</p> <h4 id="configymlの記述例-1">config.ymlの記述例</h4> <pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">exec</span><span class="synSpecial">:</span> <span class="synIdentifier">min_output_tasks</span><span class="synSpecial">:</span> <span class="synConstant">1</span> <span class="synIdentifier">in</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> sqlserver <span class="synIdentifier">url</span><span class="synSpecial">:</span> hoge_db_jdbc_url <span class="synIdentifier">user</span><span class="synSpecial">:</span> hoge_db_user <span class="synIdentifier">password</span><span class="synSpecial">:</span> hoge_db_pwd <span class="synIdentifier">query</span><span class="synSpecial">:</span> | SELECT hoge_key, hoge_col_1, hoge_col_2 FROM hoge_table <span class="synIdentifier">filters</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> csv_lookup <span class="synIdentifier">mapping_from</span><span class="synSpecial">:</span> <span class="synStatement">- </span>hoge_key <span class="synIdentifier">mapping_to</span><span class="synSpecial">:</span> <span class="synStatement">- </span>fuga_key <span class="synIdentifier">new_columns</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synSpecial">{</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> fuga_col_1, <span class="synIdentifier">type</span><span class="synSpecial">:</span> string <span class="synSpecial">}</span> <span class="synStatement">- </span><span class="synSpecial">{</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> fuga_col_2, <span class="synIdentifier">type</span><span class="synSpecial">:</span> string <span class="synSpecial">}</span> <span class="synIdentifier">path_of_lookup_file</span><span class="synSpecial">:</span> <span class="synConstant">&quot;ref/fuga.csv&quot;</span> <span class="synIdentifier">out</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> file <span class="synIdentifier">path_prefix</span><span class="synSpecial">:</span> ./out <span class="synIdentifier">file_ext</span><span class="synSpecial">:</span> csv <span class="synIdentifier">formatter</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> csv <span class="synIdentifier">header_line</span><span class="synSpecial">:</span> <span class="synConstant">true</span> <span class="synIdentifier">charset</span><span class="synSpecial">:</span> UTF-8 </pre> <h1 id="ふりかえり">ふりかえり</h1> <p>Embulkを導入し、予定通りにインボイス対応を完了することができました!実際に使ってみて得た知見をまとめます。</p> <h2 id="とくに良かったこと">とくに良かったこと</h2> <h3 id="configymlの取り回しのよさが開発スピードをあげてくれた">config.ymlの取り回しのよさが開発スピードをあげてくれた</h3> <p>Embulkでデータ移送のジョブを6個、CSV発行のジョブを12個担当しましたが、慣れてからは1日1ジョブのペースで開発を進めることができました。Embulkはconfig.ymlにテンプレートにしたがってSQLやプラグインの実行を記述していくだけで、非常に取り回しがよかったのが開発速度を後押ししてくれました。</p> <h3 id="configymlliquidのサポート">config.yml.liquidのサポート</h3> <p>Embulkではconfig.ymlへの変数埋め込みのために、Liquidテンプレートをサポートしています<a href="#f-44cf4f9c" id="fn-44cf4f9c" name="fn-44cf4f9c" title=" [https://www.embulk.org/docs/built-in.html:title] ">*5</a>。たとえばunionプラグインを使ったconfig.ymlの記述例では、DB接続文字列を指定しています。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synIdentifier">type</span><span class="synSpecial">:</span> union <span class="synIdentifier">union</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> product_hoge <span class="synIdentifier">in</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> sqlserver <span class="synIdentifier">url</span><span class="synSpecial">:</span> product_hoge_jdbc_url <span class="synIdentifier">user</span><span class="synSpecial">:</span> product_hoge_db_user <span class="synIdentifier">password</span><span class="synSpecial">:</span> product_hoge_db_pwd <span class="synIdentifier">query</span><span class="synSpecial">:</span> | SELECT col_1, col_2, col_3 FROM product_hoge_table </pre> <p>しかし、実際にはDB接続文字列はリポジトリ管理すべき情報ではありませんし、構築環境ごとに専用DBに接続したいものです。そのため、Liquidテンプレートを使い、環境変数から以下のように接続文字列を読み込む実装にしました。</p> <pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synIdentifier">type</span><span class="synSpecial">:</span> union <span class="synIdentifier">union</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> product_hoge <span class="synIdentifier">in</span><span class="synSpecial">:</span> <span class="synIdentifier">type</span><span class="synSpecial">:</span> sqlserver <span class="synIdentifier">url</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> env.HOGE_JDBC_URL <span class="synSpecial">}}</span> <span class="synIdentifier">user</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> env.HOGE_DB_USER <span class="synSpecial">}}</span> <span class="synIdentifier">password</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> env.HOGE_DB_PWD <span class="synSpecial">}}</span> <span class="synIdentifier">query</span><span class="synSpecial">:</span> | SELECT col_1, col_2, col_3 FROM product_hoge_table </pre> <h2 id="注意したほうがいいこと">注意したほうがいいこと</h2> <h3 id="任意のクエリでlookupしたいときはCSVを一度経由する必要がある">任意のクエリでlookupしたいときは、CSVを一度経由する必要がある</h3> <p>先ほど、複数のデータソースを結合したCSVの生成に<a href="https://github.com/InfoObjects/embulk-filter-csv_lookup"> csv_lookupプラグイン</a>を紹介しました。プラグイン一覧から、lookup 先にDB テーブルを直接参照できる<code>{db}_lookup</code>プラグインが提供されていることにお気づきの方もいるでしょう。</p> <p>これらの<code>{db}_lookup</code>プラグインは設定ファイルで指定したテーブルとカラムから自動で lookup する仕組みになっていて、任意のクエリ(SELECT 文)は指定できません。<a href="#f-ff26f0cc" id="fn-ff26f0cc" name="fn-ff26f0cc" title=" [https://github.com/InfoObjects/embulk-filter-mssql_lookup#example---columns:title] ">*6</a>。そのため、内部表をサブクエリにしたいケースではこのプラグインが利用できないことに注意が必要です。今回は回避策として、内部表の部分をローカルにCSV出力するEmbulkジョブを実行し、出力したCSVに対して<a href="https://github.com/InfoObjects/embulk-filter-csv_lookup">csv_lookupプラグイン</a>でlookupすることにしました。</p> <h3 id="GCSへのCSVアップロードプラグインにはstorageobjectslistが必要">GCSへのCSVアップロードプラグインにはstorage.objects.listが必要</h3> <p>社内向けにCSVを公開するため、Google Cloud Storageへ保存したいという要件がありました。当初は<a href="https://github.com/embulk/embulk-output-gcs">gcsプラグイン</a>を利用して、Embulk内部でGCSへのアップロードまで実行しようと考えていましたが、実装してみると次のエラーが発生してしまいました。</p> <pre class="code" data-lang="" data-unlink>Caused by: java.lang.RuntimeException: org.embulk.config.ConfigException: org.embulk.util.retryhelper.RetryGiveupException: com.google.cloud.storage.StorageException: {my_service_account}@{my_project}.iam.gserviceaccount.com does not have storage.objects.list access to the Google Cloud Storage bucket. Permission &#39;storage.objects.list&#39; denied on resource (or it may not exist).</pre> <p>ライブラリの内部実装を確認したところ、バケットの存在確認のためにObject Listを取得するようになっていたためでした<a href="#f-420283b6" id="fn-420283b6" name="fn-420283b6" title=" https://github.com/embulk/embulk-output-gcs/blob/427f9fdc677885a7467606393f6a343ceda2c4c9/src/main/java/org/embulk/output/gcs/GcsAuthentication.java#L141-L142:title] ">*7</a>。今回は、なるべく最小のPermissionsに絞ったサービスアカウントを利用したかったため、ローカルに出力したCSVをgsutilでアップロードするスクリプトを組みました。</p> <h1 id="まとめ">まとめ</h1> <p>ここまで読んでいただきありがとうございました!使ってみて、EmbulkはPluginが豊富でとても強力なツールであることがわかりました!ETLや複数データソースをまたいだCSV生成を行う際には導入を検討してはいかがでしょうか。</p> <h2 id="小ネタEmbulkのメンテナンス体制が新しくなったとのこと2023年3月">小ネタ:Embulkのメンテナンス体制が新しくなったとのこと!(2023年3月)</h2> <p>EmbulkはFluentdの開発者である古橋氏によって2015年に公開されました<a href="#f-3d5190c9" id="fn-3d5190c9" name="fn-3d5190c9" title=" [https://frsyuki.hatenablog.com/entry/2015/02/16/080150:title] ">*8</a>。その後、氏が創設者であるTreasure Data社によって運用や設計の改善が行われてきましたが、2023年3月からは、社に限定せず広くコアチームを結成し設計検討を行っていく方針が発表されました<a href="#f-c2f74057" id="fn-c2f74057" name="fn-c2f74057" title=" [https://www.embulk.org/articles/2023/03/10/embulk-maintenance-gets-open.html:title] ">*9</a>。その経緯については、Treasure Data社のTech Talk2022の発表資料にてより詳しくまとめられています。<br/> <a href="https://techplay.jp/eventreport/879660">&#x30A4;&#x30D9;&#x30F3;&#x30C8;&#x8CC7;&#x6599;&#xFF5C;TreasureData Tech Talk 2022 - TECH PLAY&#xFF3B;&#x30C6;&#x30C3;&#x30AF;&#x30D7;&#x30EC;&#x30A4;&#xFF3D;</a></p> <p>業務としてのOSS開発のアンビバレンスなど、かなり実情に即した部分まで言及されており示唆に富んだ発表資料でした。私はOSS開発の経験はありませんが、事業会社でエンジニアリングを行ううえでビジネス優先度は常に考慮すべき観点ですので、非常に考えさせられました。</p> <p>あらためて、OSSメンテナの皆様、いつもありがとうございます!</p> <h1 id="さいごに">さいごに</h1> <p>一休では、ともに試行錯誤しながらよいサービスを作ってくれる仲間を募集しています!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施していますので、ぜひお気軽にご連絡ください</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-100956dd" id="f-100956dd" name="f-100956dd" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://www.nta.go.jp/taxes/shiraberu/zeimokubetsu/shohi/keigenzeiritsu/invoice_about.htm">&#x30A4;&#x30F3;&#x30DC;&#x30A4;&#x30B9;&#x5236;&#x5EA6;&#x306E;&#x6982;&#x8981;&#xFF5C;&#x56FD;&#x7A0E;&#x5E81;</a> </span></p> <p class="footnote"><a href="#fn-19d0c3b5" id="f-19d0c3b5" name="f-19d0c3b5" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://plugins.embulk.org/">List of Embulk Plugins by Category</a> </span></p> <p class="footnote"><a href="#fn-0ad09179" id="f-0ad09179" name="f-0ad09179" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://qiita.com/shinyashikis@github/items/80162a252f92b3aca3a8">Embulk&#x3067;MySQL&#x306B;&#x5927;&#x91CF;&#x30C7;&#x30FC;&#x30BF;&#x3092;&#x6295;&#x5165;&#x3057;&#x3066;&#x307F;&#x308B; - &#x305D;&#x306E;&#xFF11; #MySQL - Qiita</a> </span></p> <p class="footnote"><a href="#fn-14143157" id="f-14143157" name="f-14143157" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">このケースはSQL自体がやや重かったり、後述の理由からEmbulkを内部で多重実行していますので本来はもっと早いと思います</span></p> <p class="footnote"><a href="#fn-44cf4f9c" id="f-44cf4f9c" name="f-44cf4f9c" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://www.embulk.org/docs/built-in.html">Embulk: Configuration</a> </span></p> <p class="footnote"><a href="#fn-ff26f0cc" id="f-ff26f0cc" name="f-ff26f0cc" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://github.com/InfoObjects/embulk-filter-mssql_lookup#example---columns">GitHub - InfoObjects/embulk-filter-mssql_lookup</a> </span></p> <p class="footnote"><a href="#fn-420283b6" id="f-420283b6" name="f-420283b6" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://github.com/embulk/embulk-output-gcs/blob/427f9fdc677885a7467606393f6a343ceda2c4c9/src/main/java/org/embulk/output/gcs/GcsAuthentication.java#L141-L142">embulk-output-gcs/src/main/java/org/embulk/output/gcs/GcsAuthentication.java at 427f9fdc677885a7467606393f6a343ceda2c4c9 &middot; embulk/embulk-output-gcs &middot; GitHub</a>] </span></p> <p class="footnote"><a href="#fn-3d5190c9" id="f-3d5190c9" name="f-3d5190c9" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://frsyuki.hatenablog.com/entry/2015/02/16/080150">&#x4E26;&#x5217;&#x30C7;&#x30FC;&#x30BF;&#x8EE2;&#x9001;&#x30C4;&#x30FC;&#x30EB;&#x300E;Embulk&#x300F;&#x30EA;&#x30EA;&#x30FC;&#x30B9;&#xFF01; - Blog by Sadayuki Furuhashi</a> </span></p> <p class="footnote"><a href="#fn-c2f74057" id="f-c2f74057" name="f-c2f74057" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text"> <a href="https://www.embulk.org/articles/2023/03/10/embulk-maintenance-gets-open.html">Embulk maintenance goes open | Embulk</a> </span></p> </div> Kikuch1 Slack Enterprise Grid における情報バリアの設計 hatenablog://entry/6801883189063992208 2023-12-11T11:58:20+09:00 2023-12-12T11:47:00+09:00 はじめに Enterprise Grid 移行と課題 情報バリアとは 情報バリアの設計 IDPグループの作成 情報バリアの有効化 情報バリアの設定 実現 終わりに CM のお時間です はじめに 社内情報システム部 兼 CISO室 コーポレートエンジニア id:rotom です。一休のコーポレートIT・セキュリティ領域はだいたい全部見てます。 このエントリは 一休.com Advent Calendar 2023 11日目の記事です。昨日は id:naoya による TypeScriptでどこまで「関数型プログラミング」するか ─ 「手続き Haskell」から考察する でした。その他の素敵なエ… <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#Enterprise-Grid-移行と課題">Enterprise Grid 移行と課題</a></li> <li><a href="#情報バリアとは">情報バリアとは</a></li> <li><a href="#情報バリアの設計">情報バリアの設計</a><ul> <li><a href="#IDPグループの作成">IDPグループの作成</a></li> <li><a href="#情報バリアの有効化">情報バリアの有効化</a></li> <li><a href="#情報バリアの設定">情報バリアの設定</a></li> <li><a href="#実現">実現</a></li> </ul> </li> <li><a href="#終わりに">終わりに</a><ul> <li><a href="#CM-のお時間です">CM のお時間です</a></li> </ul> </li> </ul> <h1 id="はじめに">はじめに</h1> <p>社内情報システム部 兼 CISO室 コーポレートエンジニア <a href="http://blog.hatena.ne.jp/rotom/" class="hatena-id-icon"><img src="https://cdn.profile-image.st-hatena.com/users/rotom/profile.png" width="16" height="16" alt="" class="hatena-id-icon">id:rotom</a> です。一休のコーポレートIT・セキュリティ領域はだいたい全部見てます。</p> <p>このエントリは <strong>一休.com Advent Calendar 2023</strong> 11日目の記事です。昨日は <a href="http://blog.hatena.ne.jp/naoya/" class="hatena-id-icon"><img src="https://cdn.profile-image.st-hatena.com/users/naoya/profile.png" width="16" height="16" alt="" class="hatena-id-icon">id:naoya</a> による <a href="https://user-first.ikyu.co.jp/entry/2023/12/10/134411">TypeScriptでどこまで「関数型プログラミング」するか ─ 「手続き Haskell」から考察する</a> でした。その他の素敵なエントリも以下のリンクからご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fqiita.com%2Fadvent-calendar%2F2023%2Fikyu" title="一休.comのカレンダー | Advent Calendar 2023 - Qiita" 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://qiita.com/advent-calendar/2023/ikyu">qiita.com</a></cite></p> <p>一休は今年、全社利用しているコミュニケーションツールである Slack を Enterprise Grid へ移行しました。 Enterprise Grid はその名の通り大規模なエンタープライズ組織向けの管理機能・セキュリティ機能が拡充されたもので、他のプランとは大きく思想が異なります。</p> <p>Enterprise Grid について取り上げたエントリは多くありますが、その中でも<strong>「情報バリア」</strong>について詳しく書かれた記事が無かったので、本エントリで解説します。</p> <p>Enterprise Grid に関する詳細な説明はここでは割愛するので、公式ドキュメントをご覧いただくか、Slack サポートにお問い合わせください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fslack.com%2Fintl%2Fja-jp%2Fenterprise" title="エンタープライズ向け Slack" 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://slack.com/intl/ja-jp/enterprise">slack.com</a></cite></p> <h1 id="Enterprise-Grid-移行と課題">Enterprise Grid 移行と課題</h1> <p>一休ではエンジニアに限らず全ての従業員が Slack をコミュニケーションツールとして利用しています。 これまではビジネスプラスプランで利用してきましたが、監査ログや DLP などの機能を利用し、よりセキュリティ・コンプライアンス体制を強化するため、Enterprise Grid へ移行することにしました。</p> <p>一休には従前、日常的に利用している一般のワークスペースと、機密情報を取り扱うワークスペースの2つのワークスペースが存在しました。 これらのワークスペースは完全に独立しており、機密情報は一般のワークスペースに持ち出すことができないよう厳しく統制されていました。</p> <p>今回 Enterprise Grid に移行するにあたり、マルチワークスペースに対応することから、この2つのワークスペースは1つの OrG 配下に置くことにしました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231205/20231205095713.png" width="1200" height="658" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>この際、仕様として OrG 配下のユーザーは、<strong>自分が所属していないワークスペースのユーザーに対してもダイレクトメッセージやハドルミーティングが可能</strong>です。 ワークスペース間でのダイレクトメッセージやハドルミーティングを禁止する設定は行えず、情報の持ち出しを防ぐために対策が必要でした。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231205/20231205104125.png" width="1200" height="793" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="情報バリアとは">情報バリアとは</h1> <p>この問題を解決するために、Enterprise Grid の情報バリアという機能を利用しました。</p> <p>Slack 管理者でもビジネスプラス以下のプランの方は聞いたことがない人も多いと思います。あるいは、既に Enterprise Grid で運用している組織でも利用していないことが多いかもしれません。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fslack.com%2Fintl%2Fja-jp%2Fhelp%2Farticles%2F360056171734-Slack-%25E3%2581%25A7%25E6%2583%2585%25E5%25A0%25B1%25E3%2583%2590%25E3%2583%25AA%25E3%2582%25A2%25E3%2582%2592%25E4%25BD%259C%25E6%2588%2590%25E3%2581%2599%25E3%2582%258B" title="Slack で情報バリアを作成する" 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://slack.com/intl/ja-jp/help/articles/360056171734-Slack-%E3%81%A7%E6%83%85%E5%A0%B1%E3%83%90%E3%83%AA%E3%82%A2%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B">slack.com</a></cite></p> <p>簡単にまとめると、<strong>特定の IDPグループ間でのダイレクトメッセージやハドルミーティングを禁止することができる機能</strong>です。</p> <p>また IDPグループという聞き慣れない用語が現れましたが、これは Okta や Microsoft Entra ID などの<strong> IdP(Identity Provider)のグループを Slack 上に連携させ、チャンネルやワークスペースと紐付けることができる Enterprise Grid の機能</strong>です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fslack.com%2Fintl%2Fja-jp%2Fhelp%2Farticles%2F115001435788-Enterprise-Grid-%25E3%2582%25AA%25E3%2583%25BC%25E3%2582%25AC%25E3%2583%258A%25E3%2582%25A4%25E3%2582%25BC%25E3%2583%25BC%25E3%2582%25B7%25E3%2583%25A7%25E3%2583%25B3%25E3%2581%25AB-ID-%25E3%2583%2597%25E3%2583%25AD%25E3%2583%2590%25E3%2582%25A4%25E3%2583%2580%25E3%2582%25B0%25E3%2583%25AB%25E3%2583%25BC%25E3%2583%2597%25E3%2582%2592%25E9%2580%25A3%25E6%2590%25BA%25E3%2581%2595%25E3%2581%259B%25E3%2582%258B" title="Enterprise Grid オーガナイゼーションに ID プロバイダグループを連携させる" 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://slack.com/intl/ja-jp/help/articles/115001435788-Enterprise-Grid-%E3%82%AA%E3%83%BC%E3%82%AC%E3%83%8A%E3%82%A4%E3%82%BC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AB-ID-%E3%83%97%E3%83%AD%E3%83%90%E3%82%A4%E3%83%80%E3%82%B0%E3%83%AB%E3%83%BC%E3%83%97%E3%82%92%E9%80%A3%E6%90%BA%E3%81%95%E3%81%9B%E3%82%8B">slack.com</a></cite></p> <p>この機能を利用することで、擬似的にワークスペース間のダイレクトメッセージ・ハドルミーティングを禁止にすることができると考えました。</p> <h1 id="情報バリアの設計">情報バリアの設計</h1> <p>ここからは実際に情報バリアの構築した手順を解説します。これから情報バリアの利用を開始しようとしている方は業務影響のない Sandbox 環境で検証してから設定することを推奨します。</p> <h2 id="IDPグループの作成">IDPグループの作成</h2> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231205/20231205105329.png" width="1200" height="561" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>IdP として利用している Microsoft Entra ID 側に一般ワークスペース、機密情報ワークスペースそれぞれに所属するユーザーを追加したグループを用意します。 このグループは SCIM(System for Cross-domain Identity Management)により、対応する IDPグループを OrG 上に作成され、Microsoft Entra ID 側のグループに追加されたユーザーの Slack アカウントが自動的に追加されるようになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231205/20231205110656.png" width="1200" height="438" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>IDPグループは複数のワークスペースやチャンネルと接続することが可能ですが、今回の用途ではそれぞれのグループに対応するワークスペース1つずつに接続します。 これにより Microsoft Entra ID 側で対象のグループに追加されたメンバーは、自動的にワークスペースへ追加されます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231205/20231205135512.png" width="1200" height="1112" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>なお、IDPグループの名称変更やユーザーの追加・移動・削除は Slack OrG 管理画面の GUI 上は行えません。全て SCIM で連携されているので IdP 側で変更し、プロビジョニングする必要があります。 API による操作は可能なので必要に応じて <a href="https://api.slack.com/admins/scim#patch-groups-id">SCIM API</a> を利用して操作することは可能です。</p> <h2 id="情報バリアの有効化">情報バリアの有効化</h2> <p>情報バリアは標準で利用できないオプトイン機能なので、<strong>OrG オーナーより Slack サポートチームに連絡をして有効化する必要</strong>があります。 OrG 管理者やワークスペースのオーナーでもリクエストができないので、ご自身が OrG オーナーではない場合は OrG オーナーにリクエストを依頼してください。</p> <p>リクエストは <strong>/feadback</strong> で「 {yourdomain}.enterprise.slack.com で情報バリアが利用できるように機能を有効化してください。」のよう送信すれば OK です。</p> <p>機能が有効化されると OrG 管理コンソール > セキュリティ > 情報バリアの項目が開けるようになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231207/20231207175341.png" width="564" height="694" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="情報バリアの設定">情報バリアの設定</h2> <p>情報バリア内から「障壁を作成」ボタンを押すと情報バリアを作成することができます。なぜかここではバリアが障壁と訳されていますが、気にしないでください。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231207/20231207175116.png" width="1200" height="721" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ここではプライマリーグループにIDPグループ(一般)、障壁の対象にIDPグループ(機密情報)を入力しました。対象は複数の IDP グループを指定することが可能です。</p> <h2 id="実現">実現</h2> <p>ここまで設定が完了すると、IDPグループ間で情報バリアが作成されます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231208/20231208183941.png" width="1200" height="380" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>別のワークスペースに所属するユーザーのダイレクトメッセージ画面を開くと、このようにポリシーによって送信ができない旨メッセージが表示され、送信ができなくなります。 情報バリアの設定は OrG 画面から設定後、即時反映されるわけではなく少しラグがありました。設定後は少し時間をおいてから動作確認を行うことをおすすめします。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20231208/20231208191101.png" width="1200" height="718" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>これにより、IdP グループ間の情報バリアの設定で実質的にワークスペース間のダイレクトメッセージ・ハドルミーティングを禁止することができました。</p> <h1 id="終わりに">終わりに</h1> <p>Enterprise Grid への移行時に発生した課題と、情報バリアを使って解決した話を書きました。 要所のみ掻い摘んで記載しましたが、Enterprise Grid への移行は他の SaaS の<strong>シンプルなプランアップグレードではなく、イニシャルコストとダウンタイムを伴いながら環境を丸ごとお引越しする</strong>ことになるため、本件以外にも想定外の様々な課題があり、Slack(Salesforce)マネージャー / アーキテクトにサポートいただきながらプロジェクトを完遂できました。</p> <p>個人的にはSlack 認定管理者試験で学んだ Enterprise Grid の知識を実践で活用することができ、今年最も成長できたプロジェクトのひとつだったと思っています。</p> <p>私も Enterprise Grid 管理者 1年生なので内容に誤りや、もっと良い方法があるよ!といったご指摘 / ご助言があれば <a href="https://twitter.com/rotomx">X</a> や <a href="https://corp-engr.jp/">情シス Slack</a> などでご連絡いただけると嬉しいです。</p> <h2 id="CM-のお時間です">CM のお時間です</h2> <p>一休では現在コーポレートエンジニアの採用は行っていませんが、ソフトウェアエンジニアをはじめ、多くの職種で積極的に採用を行っています。 選考をともなわないカジュアル面談からも受け付けておりますので、お気軽にご応募ください 👋</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>明日は <a href="http://blog.hatena.ne.jp/Kikuch1/" class="hatena-id-icon"><img src="https://cdn.profile-image.st-hatena.com/users/Kikuch1/profile.png" width="16" height="16" alt="" class="hatena-id-icon">id:Kikuch1</a> による <a href="https://user-first.ikyu.co.jp/entry/2023/12/12/111154">請求書発行のためにEmbulkを使って爆速でデータを集約した話</a> です</p> rotom TypeScriptでどこまで「関数型プログラミング」するか ─ 「手続き Haskell」から考察する hatenablog://entry/6801883189065589604 2023-12-10T13:44:11+09:00 2023-12-10T14:10:04+09:00 この記事は 一休.comのカレンダー | Advent Calendar 2023 - Qiita 10日目の記事です。 昨今は Web アプリケーション開発の世界でも、関数型プログラミングのエッセンスを取り入れるような機会が増えてきました。 とはいえ、一つのアプリケーションを 1 から 10 までがっちり関数型プログラミングで構成するというわけではなく、そのように書くこともあればそうでない従来からの手続き的スタイルで書くところもあるというのが現状で、どこまで関数型プログラミング的な手法を取り入れるかその塩梅もまちまちだと思います。まだ今はその過渡期という印象も受けます。 本稿ではこの辺りを少… <p>この記事は <a href="https://qiita.com/advent-calendar/2023/ikyu">一休.comのカレンダー | Advent Calendar 2023 - Qiita</a> 10日目の記事です。</p> <p>昨今は Web アプリケーション開発の世界でも、関数型プログラミングのエッセンスを取り入れるような機会が増えてきました。</p> <p>とはいえ、一つのアプリケーションを 1 から 10 までがっちり関数型プログラミングで構成するというわけではなく、そのように書くこともあればそうでない従来からの手続き的スタイルで書くところもあるというのが現状で、どこまで関数型プログラミング的な手法を取り入れるかその塩梅もまちまちだと思います。まだ今はその過渡期という印象も受けます。</p> <p>本稿ではこの辺りを少々考察してみたいと思います。</p> <p>先日、Qiita Conference 2023 Autumn で以下のテーマで発表を行いました。</p> <iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/ed40eb21f5be431395028ee3777ca727" title="関数型プログラミングと型システムのメンタルモデル" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe> <p>この発表では「関数型プログラミング最強!」という話をしたわけではなく、プログラムを関数型で考えるというのはこれこれこういうメンタルモデルにもとづいていて、一方で手続き型で考えるというのはこういうメンタルモデルにもとづく、という整理をしました。</p> <p>より具体的には</p> <ul> <li>関数型プログラミング ··· 式によって計算を宣言し、関数を適用することで値を得る</li> <li>手続き型 (命令型) プログラミング ··· 文によって計算機に対し命令を行う。命令によって状態を更新することで結果を得る</li> </ul> <p>と整理できるだろうという話をしました。</p> <p>この発表の中で、蛇足的に以下のようなスライドを用意していました。</p> <iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/ed40eb21f5be431395028ee3777ca727?slide=37" title="関数型プログラミングと型システムのメンタルモデル" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe> <p>宣言的な記述はどちらかといえば純粋関数型的なパラダイムに基づくもの、命令的な記述は手続き的なパラダイムに基づくものと考えられる一方「純粋関数型言語」の Haskell はすべてのコードが宣言的になるかと思いきや、案外、手続き的な記述をすることもあるし、一度手続きを使うとそこから命令のコンテキスト以下は、手続き的なパラダイムに影響を受けることになります。</p> <p>後に改めて触れますが、Haskell は「純粋関数型言語」としてのイメージが強いですが、上記のように、戻り値を戻さない (※ 実際にはユニット型を返してはいる) 手続き的なプログラミングが可能です。可能、というよりは、例えばミュータブルなデータ構造を更新したいときなどは、手続き的に書くのが自然です。</p> <p>このように純粋関数型言語を使うにあたっても、関数型プログラミング / 手続き型プログラミングは一つのプログラムの中で混在する、混在していいものだということがわかります。</p> <h2 id="TypeScript-でどこまで関数型プログラミングする">TypeScript でどこまで「関数型プログラミング」する?</h2> <p>Haskell の話に触れましたが、普段は私のチームのプロダクトは GraphQL バックエンドも含めて TypeScript でアプリケーション開発を行っています。 この TypeScript でのバックエンド開発については <a href="https://speakerdeck.com/naoya/typescript-niyoru-graphql-batukuendokai-fa-75b3dab7-90a8-4169-a4dc-d1e7410b9dbd">TypeScript による GraphQL バックエンド開発 - Speaker Deck</a> のスライドでも詳しく解説していますが、やや関数型プログラミングよりのスタイルで開発を続けています。</p> <p>以下のスライドで、その雰囲気が少し伝わるかと思います。</p> <iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/91e940838e7845bf8df5ca43633a1edc?slide=53" title="TypeScript による GraphQL バックエンド開発" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe> <p>アプリケーションを記述するにあたり各種関数は、基本的に値を返す「式」として定義します。</p> <p>計算は場合によっては失敗に分岐することがありますが、失敗は Result 型によって表現します。例外をスローすることはしません。そして Result 型を返す関数の合成 ··· 上のスライドの <code>andThen</code> などによって計算のパイプラインを作って実行します。</p> <p>「Result」はその名前だけを見ると計算の結果だけに関与する部分的な型にも見えますが、実際には Result を導入すると計算構造の構築に Result のもつ合成を使うのが基本になり、実装スタイルに大きな影響を与えます。</p> <p>例外を使わず合成によって一連の処理を構成するため、基本的にそのロジックの過程では大域脱出しません。結果、計算の流れが一方通行になります。また、Result によりおきうる失敗が全て明示されており、型によってそれを無視したプログラミングはできないようになっています。この両者の制約によってより堅牢な実装が可能になっています。</p> <p>こう書くと、この宣言的プログラミングのスタイルや Result はとても良いもので何のトレードオフもないものに思えるかもしれません。しかし、やはりそんなことはありません。制約がかかる以上「いや、そこは手続き的に書いたら簡単なのに」と思うことはよくありますし、値が Result に包まれているおかげで、中身の値が欲しいのにいちいちコンテナを意識して実装をしなければならなかったり、面倒ごともあります。</p> <p>こうなってくるとやはり「たとえどんな場合でも宣言的に、関数型ライクに書くのが良いのか?」という疑問がもたげてきます。</p> <p>開発当時は、チームでもそういう話題になることもよくありました。手続き的なプログラミングは極力避けて、常に関数型プログラミング的に書くべきなのか? どうなんでしょう? という疑問です。</p> <h2 id="手続きを-Result-のコンテキストに閉じる--neverthrow-の-fromPromise--fromThrowable">手続きを Result のコンテキストに閉じる ··· neverthrow の fromPromise / fromThrowable</h2> <p>ところで Result の実装には <a href="https://github.com/supermacro/neverthrow">supermacro/neverthrow: Type-Safe Errors for JS &amp; TypeScript</a> というライブラリを使っています。</p> <p>neverThrow には計算の合成に必要な基本的な関数が諸々定義されていますが、その中に一風変わった <code>fromPromise</code> や <code>fromThrowable</code> という関数があります。</p> <p>その名の通り Promise (async / await も含む) や例外による大域脱出が使われている手続きを、Result のコンテナの中に閉じ込めるための関数です。これを使うことで、Result を返さないサードパーティのライブラリなども含めて、Result を使った自分たちの関数と合成することが可能になります。</p> <p>視点を変えれば、この <code>fromPromise</code> や <code>fromThrowable</code> を使えば、Result を使いつつも、そのコンテキストの中では async / await を使ったり、例外を使ったりといった「作用のある手続き」を用いたプログラミングをすることができる... というわけです。</p> <p>例えば以下は実際のプロダクションのコードの中にある、一部の実装です。</p> <p>これは内部的なマイクロサービスへの HTTP リクエストのための関数ですが、相手側のサービスに無駄なリクエストを投げすぎないよう Redis にキャッシュさせながら問い合わせを行う、という実装になっています。Redist へのアクセスは keyv を使います。</p> <p>非同期 IO で、かつ Redis を間に挟みながらオリジンの HTTP サーバーへのアクセスを行う実装です。昨今の Web アプリケーション開発ではよくある典型的な実装ですが、これを Result の合成で宣言的に書こうとすると Result が多段になりかなり面倒な実装になってしまいます。</p> <p>そこで、手続きの流れは普段通り手続き的に async / await で書きつつ、その関数を前述の <code>fromPromise</code> によって Result のコンテキストに包みます。これで「手続き的に書いた方が良い場面ではそうする」ことができます。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>const restoreAccessToken <span class="synStatement">=</span> ({ keyv, key, requestFunc }<span class="synStatement">:</span> { keyv<span class="synStatement">:</span> Keyv; key<span class="synStatement">:</span> string; requestFunc<span class="synStatement">:</span> requestAccessToken }) <span class="synStatement">=&gt;</span> (requestArgs<span class="synStatement">:</span> requestAccessTokenArgs)<span class="synStatement">:</span> ReturnType<span class="synStatement">&lt;</span>requestAccessToken<span class="synStatement">&gt;</span> <span class="synStatement">=&gt;</span> { const promise <span class="synStatement">=</span> async () <span class="synStatement">=&gt;</span> { <span class="synStatement">//</span> キャッシュを検索 const value <span class="synStatement">=</span> await keyv<span class="synStatement">.</span>get(key) <span class="synStatement">if</span> (value) return { access_token<span class="synStatement">:</span> value as string, expires_in<span class="synStatement">:</span> <span class="synConstant">0</span>, token_type<span class="synStatement">:</span> 'Bearer' as const, } <span class="synStatement">//</span> キャッシュになかったらリクエストする return requestFunc(requestArgs)<span class="synStatement">.</span>match( (response) <span class="synStatement">=&gt;</span> { <span class="synStatement">//</span> キャッシュに格納 keyv<span class="synStatement">.</span>set( key, response<span class="synStatement">.</span>access_token, (response<span class="synStatement">.</span>expires_in <span class="synStatement">-</span> <span class="synConstant">600</span>) <span class="synStatement">*</span> <span class="synConstant">1000</span> ) return response }, (error) <span class="synStatement">=&gt;</span> { throw error } ) } <span class="synStatement">//</span> fromPromise で Result に包む。エラーの型が unknown になってしまうのに注意 return fromPromise(promise(), (error<span class="synStatement">:</span> unknown) <span class="synStatement">=&gt;</span> error instanceof AuthenticationError <span class="synStatement">||</span> error instanceof ValidationError <span class="synStatement">||</span> error instanceof NetworkError <span class="synStatement">?</span> error <span class="synStatement">:</span> new NetworkError(error as string) ) } </pre> <p>このように全てを Result を使って関数型/宣言的に··· とするのではなく、非同期 IO が絡み合う箇所など、手続的に書く方がシンプルに書けるということもよくあるわけです。</p> <p>関数型プログラミングに固執せず、手続き的に書けばいい時はそうすればいい、ということになります。じゃあその「手続き的に書けばいいとき」というのは一体どういう時なんでしょうか? こういうときはなかなか、TypeScript だけをやっていてもよくわかりません。</p> <p>そこで他の言語、手続きも書ける純粋関数型言語 Haskell ではどのような考えに基づいてみんな実装しているのか、その例を少し見てみることで相対化してみましょう。ただし当然この短い記事で全部を見ることはできません。いくつかの典型例に着目して見ていくことにしましょう。</p> <h2 id="Haskell-でも手続き型で記述するケース">Haskell でも手続き型で記述するケース</h2> <p>先述の通り Haskell は「純粋関数型」言語ですが、純粋関数型即ち関数型でしか書けないわけではなく実際には手続き型で記述することもよくあります。むしろ積極的に手続きを使う場面もあります。</p> <p>なお、手続きHaskellについては、以下の書籍がおすすめです。30ページほどの薄い本なので、さっと読めます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbooth.pm%2Fja%2Fitems%2F829369" title="手続き Haskell - 趣味はデバッグ…… - BOOTH" 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://booth.pm/ja/items/829369">booth.pm</a></cite></p> <h3 id="IO-を行いたいとき">IO を行いたいとき</h3> <p>IO というのは外界の世界とのやり取りを、計算機に命令する手続きだと考えられます。「計算機に命令する」のですから、自然と手続き型 (命令型) のコードを書くことになります。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> name <span class="synStatement">&lt;-</span> getLine putStrLn (<span class="synConstant">&quot;Hello, &quot;</span> <span class="synStatement">++</span> name) </pre> <p>見ての通り <code>putStrLn</code> からは戻り値を受け取っていません。つまり「文」に相当する記述になっています。</p> <p>厳密には <code>putStrLn</code> は文ではなく <code>IO ()</code> 型の値を返す式です。他の返値を受け取らない式も同様なのですが、ここでは説明のため手続き型プログラミングでいうところの文相当だと思ってください。</p> <p>ところで <code>main</code> の後ろに <code>do</code> という記述があります。何気ない記述ですが、これこそが Haskell の手続きプログラミングを可能にするものです。</p> <p>上記を do 記法を使わずに記述することもできるわけですが、</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>main <span class="synStatement">=</span> getLine <span class="synStatement">&gt;&gt;=</span> (<span class="synStatement">\</span>name <span class="synStatement">-&gt;</span> return (<span class="synConstant">&quot;Hello, &quot;</span> <span class="synStatement">++</span> name)) <span class="synStatement">&gt;&gt;=</span> putStrLn </pre> <p>その場合 Monad 型のバインド演算子 <code>&gt;&gt;=</code> を使って関数合成で記述することになります。<code>getLine</code> 関数によって端末からの入力値を受け取ることができますが、それは外界からやってきた値であり、IO 型のコンテナに入っています。それを扱うため、上記のような少し変わった記述が必要になります。</p> <p>do 記法はこのイディオムを逐次で記述できるようにするシンタックスシュガーです。つまり上記のバインド演算子による実装は、do を脱糖した場合の記述です。</p> <p>do 記法があることで、コンテナの中に入った値を扱いやすくなります。結果、作用を起こしてその結果を受け取ったりする記述が容易になります。計算機に命令して作用を起こし、その結果を得る··· 手続き的プログラミングそのものですね。</p> <p>do 記法を使わずに記述していけば見た目は関数の合成になるわけですが、そうした方がいい理由は特にありません。たとえば複数の値を一つずつ出力していきたいのであれば <code>mapM</code> や <code>traverse</code> などを無理して使わなくても、素直に <code>for_</code> で手続き的にループを回して出力すれば良いのです。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink><span class="synPreProc">import</span> Data.Foldable (for_) main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> for_ [<span class="synConstant">1</span> <span class="synStatement">..</span> <span class="synConstant">10</span>] <span class="synStatement">$</span> <span class="synStatement">\</span>i <span class="synStatement">-&gt;</span> <span class="synStatement">do</span> print i </pre> <h3 id="ミュータブル配列を使いたいとき">ミュータブル配列を使いたいとき</h3> <p>Haskell のデータ構造は基本、イミュータブルです。 しかし場合によってはイミュータブルなデータ構造だけでは効率的な実装が不可能な場合があります。</p> <p>その典型例といえば、配列です。先日 <a href="https://zenn.dev/naoya_ito/articles/87a8a21d52c302">Haskell の Array</a> という記事でも投稿しましたが、Haskell にはイミュータブルな配列と、ミュータブルな配列があります。両者を都度変換して使うこともできます。</p> <p>イミュータブルな配列は以下のように使います。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink><span class="synSpecial">{-# LANGUAGE TypeApplications #-}</span> <span class="synPreProc">import</span> Data.Array.IArray <span class="synPreProc">import</span> Data.Array.Unboxed (UArray) main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> <span class="synStatement">let</span> as <span class="synStatement">=</span> listArray <span class="synStatement">@</span>UArray (<span class="synConstant">0</span>, <span class="synConstant">5</span>) [<span class="synConstant">3</span>, <span class="synConstant">1</span>, <span class="synConstant">4</span>, <span class="synConstant">1</span>, <span class="synConstant">9</span>, <span class="synConstant">2</span> <span class="synStatement">::</span> Int] print <span class="synStatement">$</span> as <span class="synStatement">!</span> <span class="synConstant">2</span> </pre> <p><code>!</code> 演算子が配列への添字アクセスを行う関数で、もちろん O(1) です。</p> <p>問題は配列の更新です。 イミュータブルなデータ構造は直接は書き換えられないので、更新時にはコピーが発生します。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> <span class="synStatement">let</span> as <span class="synStatement">=</span> listArray <span class="synStatement">@</span>UArray (<span class="synConstant">0</span>, <span class="synConstant">5</span>) [<span class="synConstant">3</span>, <span class="synConstant">1</span>, <span class="synConstant">4</span>, <span class="synConstant">1</span>, <span class="synConstant">9</span>, <span class="synConstant">2</span> <span class="synStatement">::</span> Int] as' <span class="synStatement">=</span> as <span class="synStatement">//</span> [(<span class="synConstant">0</span>, <span class="synConstant">4</span>), (<span class="synConstant">1</span>, <span class="synConstant">8</span>)] <span class="synComment">-- 配列要素の更新</span> <span class="synComment">-- [4,8,4,1,9,2]</span> print <span class="synStatement">$</span> elems as' </pre> <p>このとき配列全体がコピーされるので、更新関数 <code>//</code> の計算量は O(n) になります。</p> <p>単一の特定の要素を更新する場合は O(1) で済んで欲しいわけですが、イミュータブルな配列ではそれは不可能です。O(1) での更新が必要なときはミュータブルな配列を使うと良いでしょう。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink><span class="synPreProc">import</span> Data.Array.IO (IOUArray) <span class="synPreProc">import</span> Data.Array.MArray main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> as <span class="synStatement">&lt;-</span> newListArray <span class="synStatement">@</span>IOUArray (<span class="synConstant">0</span>, <span class="synConstant">5</span>) [<span class="synConstant">3</span>, <span class="synConstant">1</span>, <span class="synConstant">4</span>, <span class="synConstant">1</span>, <span class="synConstant">9</span>, <span class="synConstant">2</span> <span class="synStatement">::</span> Int] writeArray as <span class="synConstant">0</span> <span class="synConstant">4</span> writeArray as <span class="synConstant">1</span> <span class="synConstant">8</span> print <span class="synStatement">=&lt;&lt;</span> getElems as </pre> <p>このようにミュータブルな配列を書き換えるのには <code>writeArray</code> などの関数を使います。この関数は「値を更新しろ」という命令を行うものですから、戻り値はありません。すなわち文 (相当) になります。</p> <p>上記のミュータブル版の実装を見ると、<code>writeArray</code> の戻り値を受け取っていないだけでなく、イミュータブル版の実装の時には出てこなかった <code>&lt;-</code> や <code>=&lt;&lt;</code> などの演算子が必要になっています。詳細は割愛しますが、これらはやはり「データ構造を直接書き換える」という作用の結果必要になるもので、IO やミュータブルなデータの更新などの作用を起こすと周囲にも影響が及ぶことが見て取れます。</p> <p>同様の例として、先の発表スライドの以下のページを見てください。Union-Find (Disjoint Set とも呼ばれます) というデータ構造とそのアルゴリズムを実装した時の比較です。</p> <p>Union-Find は内部的に集合の管理をするわけですが、その管理用のデータ構造にイミュータブルなデータ構造を使った場合と、ミュータブルなデータ構造を使った場合のインタフェースの比較を行なっています。</p> <iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/ed40eb21f5be431395028ee3777ca727?slide=38" title="関数型プログラミングと型システムのメンタルモデル" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe> <p>左の、Union-Find がイミュータブルになケースでは、Union-Find 内の実装はもちろん、Union-Find を利用する側の実装もイミュータブルにそれを使うことになるので式で宣言します。より宣言的に、関数型プログラミング的にプログラムを構成することになります。</p> <p>一方、右側のミュータブルな Union-Find はどうでしょうか。</p> <p>左の実装とは異なり、<code>forM_</code> (先の <code>for_</code> と同じです) や <code>unless</code> など戻り値を伴わない制御構造文的なものを使った実装になります。これは無理にそうしているわけではなくて、ミュータブルなデータ構造を更新するのには文を使うことになり、その結果、制御構造も値を戻さない文を使うことになるだけです。作用によってデータを更新する、ミュータブルなデータ構造を使うとそれを皮切りに、そのコンテキストの実装は自然と手続き型になることを意味します。</p> <p>ということはそのままプログラム全体を構成していくと、プログラム全体が手続き的になってしまいます。そこでミュータブルなデータ構造を改めてイミュータブルなデータ構造に変換するなどして、作用への依存を切って、関数型での記述に戻していくこともできます。</p> <p>なお、Union-Find はそのデータ構造の都合上、ミュータブルなデータ構造を使って構成する方が望ましいと考えています。具体的にはクエリ時に経路圧縮をすることによってデータ構造内部のバランシングを行うわけですが、このバランシングに作用を伴うためです。詳しくは以前 <a href="https://zenn.dev/naoya_ito/articles/50e79d637d55c3">Haskell で Union-Find とクラスカルのアルゴリズム</a> に記述しました。</p> <h2 id="手続き的に書く方がわかりやすいケース">手続き的に書く方が「わかりやすい」ケース</h2> <p>IO や ミュータブルなデータ構造の例は、作用があるゆえ結果的に自然と手続き的に書くに至るというようなケースでした。 積極的に手続き的プログラミングを選択したというよりは、半ば受動的に、手続き的に記述するようなケースです。</p> <p>では、その積極的に手続き的プログラミングを選択したいケースも見てみましょう。</p> <p>以下 AtCoder の競技プログラミングの問題を題材にしますが、問題の内容は重要でありませんので詳細を理解する必要はありません。問題を解くためのコードの形がどんなものになるかにだけ着目していってください。</p> <p>次の再帰による深さ優先探索 (DFS) の問題を解いてみます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc284%2Ftasks%2Fabc284_e" title="E - Count Simple Paths" 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://atcoder.jp/contests/abc284/tasks/abc284_e">atcoder.jp</a></cite></p> <p>グラフが与えられる、頂点 1 を出発点にして探索を行った時、同じ頂点を複数通らないパスを数え上げていったときその個数 K を出力する。ただし K が 10<sup>6</sup> より大きくなる場合はそこで数え上げをやめる。という問題です。DFS を行えばいいだけの問題に見えますが、経路を数え上げた結果上界に達したら計算を打ち切る... いわゆる枝刈りが必要です。</p> <p>この手の再帰を回しながら数え上げをする実装は、手続き型で書くと思いのほか簡単です。 以下は Python による実装です (ChatGPT に書かせました)</p> <p>数え上げは再帰関数のスコープを跨いで行う必要がありますが、カウンタ値 <code>k</code> を <code>global</code> で関数のスコープ外で生存できるようにし、上界の 10<sup>6</sup> に達したら早期 <code>return</code> で大域脱出すれば良いだけでです。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> sys sys.setrecursionlimit(<span class="synConstant">10</span>**<span class="synConstant">7</span>) <span class="synComment"># Input</span> n, m = <span class="synIdentifier">map</span>(<span class="synIdentifier">int</span>, <span class="synIdentifier">input</span>().split()) g = [[] <span class="synStatement">for</span> _ <span class="synStatement">in</span> <span class="synIdentifier">range</span>(n)] <span class="synStatement">for</span> _ <span class="synStatement">in</span> <span class="synIdentifier">range</span>(m): u, v = <span class="synIdentifier">map</span>(<span class="synIdentifier">int</span>, <span class="synIdentifier">input</span>().split()) g[u - <span class="synConstant">1</span>].append(v - <span class="synConstant">1</span>) g[v - <span class="synConstant">1</span>].append(u - <span class="synConstant">1</span>) visited = [-<span class="synConstant">1</span>] * n k = <span class="synConstant">0</span> <span class="synStatement">def</span> <span class="synIdentifier">dfs</span>(v): <span class="synStatement">global</span> k k += <span class="synConstant">1</span> <span class="synStatement">if</span> k &gt; <span class="synConstant">10</span>**<span class="synConstant">6</span>: k = <span class="synConstant">10</span>**<span class="synConstant">6</span> <span class="synStatement">return</span> visited[v] = <span class="synConstant">1</span> <span class="synStatement">for</span> u <span class="synStatement">in</span> g[v]: <span class="synStatement">if</span> visited[u] == -<span class="synConstant">1</span>: dfs(u) visited[v] = -<span class="synConstant">1</span> dfs(<span class="synConstant">0</span>) <span class="synIdentifier">print</span>(k) </pre> <p>同じような実装を Haskell で、純粋関数で書こうとするとグローバル変数や大域脱出に相当するところをどうするか悩むことになります。 以下は最初に書いた、純粋関数で実装した実装です。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>dfs nextStates visited v <span class="synStatement">=</span> <span class="synStatement">do</span> <span class="synStatement">let</span> visited' <span class="synStatement">=</span> IS.insert v visited us <span class="synStatement">=</span> filter (<span class="synStatement">`IS.notMember`</span> visited') (nextStates v) foldl' ( <span class="synStatement">\</span>k u <span class="synStatement">-&gt;</span> <span class="synStatement">if</span> k <span class="synStatement">&gt;=</span> <span class="synConstant">10</span> <span class="synStatement">^</span> <span class="synConstant">6</span> <span class="synStatement">then</span> k <span class="synStatement">else</span> k <span class="synStatement">+</span> dfs nextStates visited' u ) (length us) us main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> [n, m] <span class="synStatement">&lt;-</span> getInts uvs <span class="synStatement">&lt;-</span> replicateM m getTuple <span class="synStatement">let</span> g <span class="synStatement">=</span> graph2 (<span class="synConstant">1</span>, n) uvs k <span class="synStatement">=</span> dfs (g <span class="synStatement">!</span>) IS.empty (<span class="synConstant">1</span> <span class="synStatement">::</span> Int) print <span class="synStatement">$</span> min (<span class="synConstant">10</span> <span class="synStatement">^</span> <span class="synConstant">6</span>) (k <span class="synStatement">+</span> <span class="synConstant">1</span>) </pre> <p><code>foldl'</code> で畳み込みをする際に上界を超えていたらそれ以上は再帰を行わない、ということを途中で行なっています。</p> <p>再帰をまたいだカウンターのものを用意するのではなく <code>foldl'</code> の計算結果のアキュムレータを再帰関数が返したものを受け取って、さらにそれをまた <code>foldl'</code> の初期値にして... ということを繰り返しており、その動きを頭の中で想像しようとするとなかなか大変···認知負荷で頭がパンクしそうになります。</p> <p>Python の実装に比べると、ずっと難しく感じますね。なお、誤解がないよう説明すると、ここではコードの読みやすさが簡単・難しいという話をしているわけはなくて、計算の構造、計算を実際に頭の中で追うときの認知負荷の観点で簡単か、難しいかを論じています。</p> <p>もとい、先の Python の実装のように再帰関数のコンテキスト中にカウンタ値を共有して計算を打ち切るような実装はできないのものでしょうか? そこで手続き型プログラミングです。</p> <p>State モナドを使うと、関数の実行コンテキスト間で値を共有しながら計算を進めることができます。実質的に、関数の実行コンテキスト内部に閉じたグローバル変数のように使えます。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>dfs nextStates visited v <span class="synStatement">=</span> <span class="synStatement">do</span> <span class="synStatement">let</span> us <span class="synStatement">=</span> filter (<span class="synStatement">`IS.notMember`</span> visited) (nextStates v) modify' (<span class="synStatement">+</span> length us) k <span class="synStatement">&lt;-</span> get when (k <span class="synStatement">&lt;</span> <span class="synConstant">10</span> <span class="synStatement">^</span> <span class="synConstant">6</span>) <span class="synStatement">$</span> <span class="synStatement">do</span> forM_ us <span class="synStatement">$</span> <span class="synStatement">\</span>u <span class="synStatement">-&gt;</span> <span class="synStatement">do</span> dfs nextStates (IS.insert v visited) u main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> [n, m] <span class="synStatement">&lt;-</span> getInts uvs <span class="synStatement">&lt;-</span> replicateM m getTuple <span class="synStatement">let</span> g <span class="synStatement">=</span> graph2 (<span class="synConstant">1</span>, n) uvs k <span class="synStatement">=</span> execState (dfs (g <span class="synStatement">!</span>) IS.empty <span class="synConstant">1</span>) <span class="synConstant">0</span> print <span class="synStatement">$</span> min k (<span class="synConstant">10</span> <span class="synStatement">^</span> <span class="synConstant">6</span>) </pre> <p>State モナドの更新には、例によって戻り値のない命令 <code>modify'</code> などを使います。カウンタ値が上界を超えたら再帰を呼ぶ必要はないし、純粋関数でやっていた時のように値を戻す必要もないので戻り値のない <code>when</code> により分岐を制御します。</p> <p><code>dfs</code> 関数の中が、手続き的になりました。こちらの方が計算の流れは簡単に追うことができるでしょう。</p> <p>この問題は比較的シンプルではあるので、純粋関数で構成してもなんとかなるかなとは思います。 一方、再帰を呼ぶ、呼ばないの分岐がより複雑になってくると再帰から戻ってきた値をどう結合するかも含めて考えるときの認知負荷が大きくなり、きつくなってきます。</p> <p>再帰関数による全探索の、別の問題を見てみます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc196%2Ftasks%2Fabc196_d" title="D - Hanjo" 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://atcoder.jp/contests/abc196/tasks/abc196_d">atcoder.jp</a></cite></p> <p>この問題は、縦長の畳、正方形の畳がある決まった数が与えられたとき、それを部屋の中にちょうどよく敷き詰められるか? というパズルを再帰的に全探索することで解く問題です。</p> <p>詳細は割愛しますが、こちらの問題も State モナドを使って手続き的に記述することで、先のグラフ問題同様に、再帰を継続するしないの選択や、数え上げを、戻り値を戻すことを意識せずに実装できるので比較的、頭の中にあるモデル通りの実装が可能です。</p> <p>これを純粋関数でやろうとすると再帰から戻ってきた値をどう扱うか、書けないわけではないですが、少し面倒になるでしょう。</p> <pre class="code lang-haskell" data-lang="haskell" data-unlink>dfs (h, w) _ _ s [] <span class="synStatement">=</span> <span class="synStatement">do</span> modify' (<span class="synStatement">+</span> <span class="synConstant">1</span>) dfs hw a b s (v<span class="synStatement">@</span>(i, j) <span class="synStatement">:</span> vs) <span class="synStatement">|</span> Set.member v s <span class="synStatement">=</span> dfs hw a b s vs <span class="synStatement">|</span> otherwise <span class="synStatement">=</span> <span class="synStatement">do</span> when (a <span class="synStatement">&gt;</span> <span class="synConstant">0</span>) <span class="synStatement">$</span> <span class="synStatement">do</span> <span class="synStatement">let</span> <span class="synStatement">!</span>yoko <span class="synStatement">=</span> (i, j <span class="synStatement">+</span> <span class="synConstant">1</span>) <span class="synStatement">!</span>tate <span class="synStatement">=</span> (i <span class="synStatement">+</span> <span class="synConstant">1</span>, j) when (inRange ((<span class="synConstant">1</span>, <span class="synConstant">1</span>), hw) yoko) <span class="synStatement">$</span> <span class="synStatement">do</span> dfs hw (a <span class="synStatement">-</span> <span class="synConstant">1</span>) b (Set.insert yoko <span class="synStatement">$</span> Set.insert v s) vs when (inRange ((<span class="synConstant">1</span>, <span class="synConstant">1</span>), hw) tate) <span class="synStatement">$</span> <span class="synStatement">do</span> dfs hw (a <span class="synStatement">-</span> <span class="synConstant">1</span>) b (Set.insert tate <span class="synStatement">$</span> Set.insert v s) vs when (b <span class="synStatement">&gt;</span> <span class="synConstant">0</span>) <span class="synStatement">$</span> <span class="synStatement">do</span> dfs hw a (b <span class="synStatement">-</span> <span class="synConstant">1</span>) (Set.insert v s) vs main <span class="synStatement">::</span> IO () main <span class="synStatement">=</span> <span class="synStatement">do</span> [h, w, a, b] <span class="synStatement">&lt;-</span> getInts <span class="synStatement">let</span> vs <span class="synStatement">=</span> range ((<span class="synConstant">1</span>, <span class="synConstant">1</span>), (h, w)) k <span class="synStatement">=</span> execState (dfs (h, w) a b Set.empty vs) (<span class="synConstant">0</span> <span class="synStatement">::</span> Int) print k </pre> <p>IO を扱いたいとき、ミュータブルなデータ構造を扱いたいとき、再帰関数の例のように手続き的に書く方がわかりやすいとき、など Haskell での手続きプログラミングの例を見ました。いずれの例も、無理をして手続き的に書いているわけではありません。関数型言語を使うからと言って、式による計算の宣言、つまりは関数型プログラミングに固執する必要はないことがわかります。</p> <p>do 記法や State モナドは決して特別なものではなく言語の基本機能として用意されているものです。つまり、その場その場に応じて適切なパラダイムを選択する ... 関数型と命令型のマルチパラダイムでコードを実装しても特に問題ないからこそでしょう。</p> <p>ただし手続き型の命令に伴って副作用が現れる場合に、Haskell は型やモナドによって明示的にそれを扱っています。そのため比較的安全に、手続き型プログラミングを純粋関数型プログラミングの中に混在させることが可能になっている... というところが大きなポイントです。これによって都度パラダイムを行き来してもプログラム全体を破壊することなく、堅牢な記述を続けることができるというわけです。</p> <h2 id="まとめ">まとめ</h2> <p>Haskell の手続きプログラミングの例を見ました。Haskell でも手続き的プログラミングをする場面というのは案外多くあることがわかります。そしてその、関数型プログラミングと手続き型プログラミングのパラダイムの行き来をスムーズにするのに、do 記法や型が重要な役割を果たしています。</p> <p>手続き型プログラミングの副作用を安全に隔離しながらも記述のオーバーヘッドを抑えるのに do 記法が貢献しているとも言えます。</p> <p>裏返せば、do 記法のような文法の支援のないプログラミング言語で無理に純粋関数型だけでやっていこうとすると「ここは手続き的に書けばいいのに」という場面で柔軟な方針が取れず、自分で自分の足を撃っているような状況に陥るかもしれません。例えば Result はモナドのようなものですが、Result が入れ子になって多段になると、すぐにコードが複雑化します。(Rust など、最近のプログラミング言語には Result が組み込みで用意されているものもありますが、そこにはプログラミング言語による文法の支援が、セットで付いています。)</p> <p>TypeScript には関数型プログラミング言語的な側面があるとはいえ、一方で Haskell の do 記法 (やモナド) のようなプログラミング言語組み込みの機構はありません。よって、純粋関数型言語と完全に真似たスタイルでやっていこうとすると、少し困難が伴うかもしれません。</p> <p>ここまでで分かったことを列挙すると以下のような考察になります。</p> <ul> <li>明示的に作用を起こしたいのであれば手続きを使えば良い。ただし Haskell はその結果の作用から、純粋関数の世界を守ってくれる機構がある。それがない言語では、作用が伴う時その影響をどう管理するかが論点になる</li> <li>TypeScript には do 記法がない。(パターンマッチや代数的データ型も、エミュレートはできるものの十分とはいえない )。そして TypeScript で記述するようなアプリケーションは非同期 IO をたくさん行うものが多い。···にもかかわらず完全関数型スタイルでやっていくというのには道具が足りてない (と思います)</li> <li>TypeScript でも無理なく宣言的に書けるところはそうすれば良いだろう。同様に、手続き的に書く方がいい場面では (もしその手続きに副作用を伴うなら、それを手続きのスコープに留めることを何かしらで担保しつつ) 手続きで書くので良さそう。「関数型プログラミング」に固執する必要は (Haskell ですらそうなのだから、道具の足りてない TypeScript ではなおのこと) ない。 <ul> <li>純粋な値だけで構成されるようなロジックの場合は、イミュータブルかつ宣言的に書いていってもなんら不便はない</li> <li>副作用のあるところと、純粋に書けるところを分離することでその併用が可能になる。その点で、高階関数による Dependency Injection を積極的に使って業務ロジックの IO への依存を切っていくのは良いプラクティスと言える</li> </ul> </li> <li>neverthrow には各種合成関数、 fromPromise や fromThrowable などで、コンテナに入っている値を扱いやすくする機構はあるものの、やはり十分とはいえず。 プログラミング言語そのものがそれをサポートしている言語などに比較するとやはり不便はある。(私たちはシステムが扱っている業務の都合上、その不便を受け入れてでも堅牢性の方を優先しました)</li> </ul> <p>後日さらに理解が深めた結果、私自身がこの考察を否定することもあるかもしれませんので、その点はご了承ください。</p> <p>長々とした文書をここまで読んでいただき、ありがとうございました。</p> naoya Solr クエリを速度改善したら Solr 全体のパフォーマンスが向上した hatenablog://entry/6801883189064478613 2023-12-06T17:32:15+09:00 2023-12-07T11:44:43+09:00 この記事は 一休.com Advent Calendar 2023 6日目の記事です。 一休レストランの開発チームでエンジニアをしている香西です。 今回は Solr クエリの速度改善についてお話します。 背景 2023年10月、一休レストランのスマートフォン用 レストラン詳細ページをリニューアルしました! UI/UX の見直しとともに、使用技術も一新しました。 バックエンド言語:Python から Rustへ フロントエンドフレームワーク:Nuxt.js から Next.jsへ*1 スマートフォン用 レストラン詳細ページ 課題 「日付を選ぶカレンダーの表示が遅い」 社内限定リリースの直後、多方… <p>この記事は <a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a> 6日目の記事です。</p> <hr /> <p>一休レストランの開発チームでエンジニアをしている香西です。 今回は Solr クエリの速度改善についてお話します。</p> <h1 id="背景">背景</h1> <p>2023年10月、一休レストランのスマートフォン用 レストラン詳細ページをリニューアルしました! UI/UX の見直しとともに、使用技術も一新しました。</p> <ul> <li>バックエンド言語:Python から Rustへ</li> <li>フロントエンドフレームワーク:Nuxt.js から Next.jsへ<a href="#f-99f25fcd" id="fn-99f25fcd" name="fn-99f25fcd" title="Next.js で起きた課題については 一休.com Advent Calendar 2023 15日目の記事で解説予定です。">*1</a></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/k/kozaiy/20231206/20231206121433.png" width="553" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>スマートフォン用 レストラン詳細ページ</figcaption></figure></p> <h1 id="課題">課題</h1> <p>「日付を選ぶカレンダーの表示が遅い」</p> <p>社内限定リリースの直後、多方面からこの声が聞こえてきました...<br> レストランへ行く日付を選ぶカレンダーは予約フローの第一ステップなので、表示速度が遅いことは致命的です。 特に、設定データ(料理のコース種類・席の種類など)が多いレストランでは、カレンダーの空席状況を取得するのに15秒以上かかることがあり、このままでは正式リリースできない状況でした。</p> <p><figure class="figure-image figure-image-fotolife" title="空席カレンダーの UI"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20231206/20231206093736.png" width="577" height="696" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>空席カレンダーの UI</figcaption></figure></p> <p>一休レストランでは、各レストランの空席情報を全文検索システム Solr にインデックスし、予約できる日を Solr で検索して空席状況を取得しています。 Solr には、レストランの設定データごとにドキュメントを作成しインデックスしています。そのため設定データが多いレストランは検索対象のドキュメント数が膨大になり、検索に時間がかかっていました。</p> <h1 id="やったこと">やったこと</h1> <h2 id="検索マイクロサービスを経由するのをやめた">検索マイクロサービスを経由するのをやめた</h2> <p>Solr へのアクセスは「フロントエンド → バックエンド → 検索マイクロサービス → Solr」という流れで行われます。 従来からあった検索マイクロサービスのオーバーヘッドが大きかったため、検索マイクロサービスを経由するのをやめて、「バックエンド → Solr」に直接アクセスするようにしました。<br> 検索マイクロサービス(C#)で行われていた Solr クエリの組み立てや、Solr からのレスポンスをオブジェクトに変換し在庫計算を行う処理を、バックエンド(Rust)に移行しました。</p> <h2 id="ワイルドカードを使うようにした">ワイルドカードを使うようにした</h2> <p>Solr にインデックスされているデータのなかには、日付ごとに異なる情報が含まれています。これらの情報は、それぞれ特定の日付(例:231025)を含むフィールド名で表現されています。</p> <pre class="code" data-lang="" data-unlink>&#34;231025Close_tdt&#34;: &#34;2023-10-21T00:00:00Z&#34;, &#34;231025VisitTimeFrom_tdt&#34;: &#34;2023-10-25T18:00:00Z&#34;, &#34;231025VisitTimeTo_tdt&#34;: &#34;2023-10-25T18:30:00Z&#34;, &#34;231025HasInventory_b&#34;: true, &#34;231025HasRotationOrBlockTime_b&#34;: false, &#34;231025Inventory_ti&#34;: 1, &#34;231025SalesUpperLimitOver_b&#34;: false,</pre> <p>例えば、先1か月分の各日付の情報を取得する場合、以下のような Solr クエリを生成していました。</p> <pre style="white-space: pre-wrap; word-break: break-all"> // 変更前 fl=231025Close_tdt,231025VisitTimeFrom_tdt,231025VisitTimeTo_tdt,231025HasInventory_b,231025HasRotationOrBlockTime_b,231025Inventory_ti,231025SalesUpperLimitOver_b,231026Close_tdt,231026VisitTimeFrom_tdt,231026VisitTimeTo_tdt,231026HasInventory_b,231026HasRotationOrBlockTime_b,231026Inventory_ti,231026SalesUpperLimitOver_b,231027Close_tdt,231027VisitTimeFrom_tdt,231027VisitTimeTo_tdt,231027HasInventory_b,231027HasRotationOrBlockTime_b,231027Inventory_ti,231027SalesUpperLimitOver_b,231028Close_tdt,231028VisitTimeFrom_tdt,231028VisitTimeTo_tdt,231028HasInventory_b,231028HasRotationOrBlockTime_b,231028Inventory_ti,231028SalesUpperLimitOver_b,231029Close_tdt,231029VisitTimeFrom_tdt,231029VisitTimeTo_tdt,231029HasInventory_b,231029HasRotationOrBlockTime_b,231029Inventory_ti,231029SalesUpperLimitOver_b,231030Close_tdt,231030VisitTimeFrom_tdt,231030VisitTimeTo_tdt,231030HasInventory_b,231030HasRotationOrBlockTime_b,231030Inventory_ti,231030SalesUpperLimitOver_b...つづく </pre> <p><code>field list</code> で「231025のフィールド群,231026のフィールド群,231027のフィールド群 ...」のように、特定の日付が含まれるフィールド群を個別に指定していましたが、日付部分(231025)をワイルドカード(??????)に置き換えて「??????のフィールド群」という書き方に変更しました。</p> <pre style="white-space: pre-wrap; word-break: break-all"> // 変更後 fl=??????Close_tdt,??????VisitTimeFrom_tdt,??????VisitTimeTo_tdt,??????HasInventory_b,??????HasRotationOrBlockTime_b,??????Inventory_ti,??????SalesUpperLimitOver_b </pre> <p>この変更により、設定データが多いレストランではレスポンスタイムが約 1/5 に短縮され、大きな改善効果が得られました!</p> <h2 id="100件ずつ並列で取得するようにした">100件ずつ並列で取得するようにした</h2> <p>最初に、検索結果の総件数のみを取得し、総件数を100で割って何回取得すればよいか判断し、100件ずつ並列で Solr にリクエストを送るようにしました。</p> <p>Rust で Solr から結果を取得するサンプルコードです。<code>search_calendar</code> にカレンダーの検索条件を渡すと、まず Solr から総件数を取得し、そのあと100件ずつ検索結果を取得します。</p> <pre class="code lang-rust" data-lang="rust" data-unlink><span class="synStatement">pub</span> async <span class="synStatement">fn</span> <span class="synIdentifier">search_calendar</span>( <span class="synType">&amp;</span><span class="synConstant">self</span>, input: <span class="synType">&amp;</span><span class="synPreProc">model</span><span class="synSpecial">::</span>CalendarInput, ) <span class="synStatement">-&gt;</span> <span class="synPreProc">anyhow</span><span class="synSpecial">::</span><span class="synType">Result</span><span class="synStatement">&lt;</span><span class="synType">Vec</span><span class="synStatement">&lt;</span><span class="synPreProc">model</span><span class="synSpecial">::</span>Date<span class="synStatement">&gt;&gt;</span> { <span class="synStatement">let</span> rows <span class="synStatement">=</span> <span class="synConstant">100</span>; <span class="synStatement">let</span> query <span class="synStatement">=</span> <span class="synIdentifier">CalendarQuery</span>(input.<span class="synIdentifier">clone</span>()); <span class="synComment">// 先に総件数のみを取得する</span> <span class="synStatement">let</span> total_count <span class="synStatement">=</span> <span class="synConstant">self</span>.<span class="synIdentifier">get_solr_data</span>(<span class="synType">&amp;</span>query, <span class="synConstant">0</span>, <span class="synConstant">0</span>).await<span class="synSpecial">?</span>.response.total_count; <span class="synStatement">let</span> query <span class="synStatement">=</span> <span class="synType">&amp;</span>query; <span class="synComment">// 100 件ずつ取得する</span> <span class="synStatement">let</span> futures <span class="synStatement">=</span> (<span class="synConstant">0</span>..total_count.<span class="synIdentifier">div_ceil</span>(rows)).<span class="synIdentifier">map</span>(<span class="synStatement">|</span>n<span class="synStatement">|</span> async <span class="synType">move</span> { <span class="synConstant">self</span>.<span class="synIdentifier">get_solr_data</span>(query, n <span class="synStatement">*</span> rows, rows).await }); <span class="synStatement">let</span> res <span class="synStatement">=</span> <span class="synPreProc">futures_util</span><span class="synSpecial">::</span><span class="synPreProc">future</span><span class="synSpecial">::</span><span class="synIdentifier">try_join_all</span>(futures).await<span class="synSpecial">?</span>; <span class="synComment">// 以下略(Solr の結果をもとに返り値を作る)</span> } </pre> <h2 id="不要な-Solr-クエリを削る">不要な Solr クエリを削る</h2> <p>改めて Solr クエリに削除できる部分がないか見直しました。</p> <ul> <li>不要なフィールドを取得していないか</li> <li>ユーザーの指定条件によって削除できるフィールドはないか</li> <li>無駄に <code>group</code>, <code>sort</code> の機能を使用していないか</li> </ul> <p>といった観点でチェックを行いました。</p> <h1 id="成果">成果</h1> <p>この改善により、カレンダーの空席状況を取得するのに15秒程かかっていたのが2~3秒程度に短縮され、スムーズな UX をユーザーに提供することができました!</p> <p>また、システム観点でも大きな効果がありました。 今回の速度改善対象は、スマートフォン用 レストラン詳細ページのカレンダーの検索処理でしたが、Solr 全体のパフォーマンスが向上しました。</p> <ul> <li>Solr の CPU コア使用率が半分以上減少</li> <li>Solr 全体のレスポンスタイムが450ミリ秒から200ミリ秒程度に短縮</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="Solr の CPU コア使用率が半分以上減少"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20231206/20231206093849.png" width="1200" height="352" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Solr の CPU コア使用率が半分以上減少</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="Solr 全体のレスポンスタイムが450ミリ秒から200ミリ秒程度に短縮"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20231206/20231206093845.png" width="1200" height="324" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Solr 全体のレスポンスタイムが450ミリ秒から200ミリ秒程度に短縮</figcaption></figure></p> <p>一休レストランの Solr に関する改善点はまだ多くありますが、少しずつ着実に取り組んでいきたいと思います。</p> <h1 id="さいごに">さいごに</h1> <p>一休では、ともに良いサービスをつくっていく仲間を募集中です!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施しているので、お気軽にご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-99f25fcd" id="f-99f25fcd" name="f-99f25fcd" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">Next.js で起きた課題については <a href="https://qiita.com/advent-calendar/2023/ikyu">一休.com Advent Calendar 2023</a> 15日目の記事で解説予定です。</span></p> </div> kozaiy GitHub Projects を利用したタスク管理 hatenablog://entry/820878482973537217 2023-11-09T17:51:21+09:00 2023-11-10T17:39:07+09:00 宿泊開発チームでエンジニアをしている @itinao です。 昨年の10月に入社しました。 今回は GitHub Projects を利用したタスク管理について記載します。 なんとなーく GitHub Projects 使うと、KANBANにしてみたり リストにして使ってみたり で終わってしまいます。 もっと色々できるんだよってことが伝えられればと思います。 背景 どんな機能があるか Custom Fields Views Group by Slice by Workflows ISSUEと Pull requestの紐づけ Insights タスクの進め方 タスクの洗い出し 見積もり 現状の… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231109/20231109172257.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>宿泊開発チームでエンジニアをしている @itinao です。<br> 昨年の10月に入社しました。</p> <p>今回は GitHub Projects を利用したタスク管理について記載します。</p> <p>なんとなーく GitHub Projects 使うと、KANBANにしてみたり</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006175625.png" width="1195" height="612" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>リストにして使ってみたり</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006175805.png" width="1175" height="542" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>で終わってしまいます。</p> <p>もっと色々できるんだよってことが伝えられればと思います。</p> <ul class="table-of-contents"> <li><a href="#背景">背景</a></li> <li><a href="#どんな機能があるか">どんな機能があるか</a><ul> <li><a href="#Custom-Fields">Custom Fields</a></li> <li><a href="#Views">Views</a><ul> <li><a href="#Group-by">Group by</a></li> <li><a href="#Slice-by">Slice by</a></li> </ul> </li> <li><a href="#Workflows">Workflows</a><ul> <li><a href="#ISSUEと-Pull-requestの紐づけ">ISSUEと Pull requestの紐づけ</a></li> </ul> </li> <li><a href="#Insights">Insights</a></li> </ul> </li> <li><a href="#タスクの進め方">タスクの進め方</a><ul> <li><a href="#タスクの洗い出し">タスクの洗い出し</a></li> <li><a href="#見積もり">見積もり</a></li> </ul> </li> <li><a href="#現状の課題と今後の展望">現状の課題と今後の展望</a></li> <li><a href="#まとめ">まとめ</a></li> <li><a href="#さいごに">さいごに</a></li> </ul> <h2 id="背景">背景</h2> <p>一休ではチームごとにタスクの管理方法が違い、<br> Google Spreadsheet・GitHub Projects・Jiraなど、チームごとにタスク管理の方法が異なっています。</p> <p>各ツールの印象は、、</p> <ul> <li>Google Spreadsheet <ul> <li>作り込めば便利なんだけど、壊れやすい...</li> </ul> </li> <li>Jira <ul> <li>過去に使って、エンジニア目線だと操作感とかそんな好きになれなかったなあという印象があった..</li> </ul> </li> <li>GitHub Projects <ul> <li>エンジニアだととっつきやすいのと、ツールをアレコレ移動しなくて済む</li> </ul> </li> </ul> <p>個人的には Jira か GitHub Projects を使いたくて、<br> できれば GitHub Projects を選択したいという気持ちがありました。</p> <p>そのモチベーションでやり方を考え、現在はこの記事の管理方法で落ち着いています。</p> <h2 id="どんな機能があるか">どんな機能があるか</h2> <p>この4つを抑えておけば良いです。<br> Custom Fields / Views / Workflows / Insights</p> <p>ざっくり概念。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006175945.png" width="960" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="Custom-Fields">Custom Fields</h3> <p>下記の種別でパラメータを作成でき、Draft / ISSUE / Pull request に値をセットすることができます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.github.com%2Fja%2Fissues%2Fplanning-and-tracking-with-projects%2Funderstanding-fields" title="フィールドについて - GitHub Docs" 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://docs.github.com/ja/issues/planning-and-tracking-with-projects/understanding-fields">docs.github.com</a></cite></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006180538.png" width="1200" height="957" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <table> <thead> <tr> <th>種別 </th> <th> 設定できる内容</th> </tr> </thead> <tbody> <tr> <td>Text </td> <td> テキスト</td> </tr> <tr> <td>Number </td> <td> 数値</td> </tr> <tr> <td>Date </td> <td> 日付</td> </tr> <tr> <td>Single select </td> <td> 決められた項目のみ選択できる</td> </tr> <tr> <td>Iteration </td> <td> 決められた間隔の時間ブロックを作り、その時間ブロックを選択できる</td> </tr> </tbody> </table> <p>★ 自身のチームではこのようなパラメータを作って運用しています。</p> <table> <thead> <tr> <th>名前 </th> <th> 種別 </th> <th> 設定内容 </th> <th> 例</th> </tr> </thead> <tbody> <tr> <td>Status </td> <td> Single select </td> <td> タスクの状態 </td> <td> Backlog / In progress / In review / Done</td> </tr> <tr> <td>Epic </td> <td> Single select </td> <td> 作業階層の最上位の単位で、チームが目指す大きなゴールのようなもの </td> <td> Epic1 / Epic2 / Epic3 / 改善 / ...</td> </tr> <tr> <td>Estimate </td> <td> Number </td> <td> 見積もり </td> <td> 1, 2, 3, 5, 8, 13, ...<br>→ フィボナッチ数列で運用しているため、Single selectにしたいところだが Insights で積算を表示させたいので Number</td> </tr> <tr> <td>Sprint </td> <td> Iteration </td> <td> 区切りになる開発期間 </td> <td> Sprint1, Sprint2, Sprint3, ...</td> </tr> <tr> <td>Function </td> <td> Single select </td> <td> どこの機能か<br>リポジトリに近いイメージ </td> <td> 管理画面Backend, 管理画面Frontend, ユーザー画面Backend, ...</td> </tr> <tr> <td>Priority </td> <td> Single select </td> <td> 優先順位 </td> <td> 高, 中, 低<br>→ 普段は使わないが、バグチケットなどで目印が欲しいときに使う</td> </tr> </tbody> </table> <h3 id="Views">Views</h3> <p>Table / Board / Roadmap のLayoutで、Draft / ISSUE / Pull requestを表示することができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006180712.png" width="898" height="668" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Group by, Slice byが良い感じです。</p> <h4 id="Group-by">Group by</h4> <p>設定した項目でグルーピング化し、表示してくれる <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231024/20231024140549.png" width="1018" height="624" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="Slice-by">Slice by</h4> <p>設定した項目でフィルタリングし、左にメニューが表示される <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231024/20231024140602.png" width="1010" height="581" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>★ 自身のチームでは、ざっくり、、4つの Viewを良く使っています。<br></p> <table> <thead> <tr> <th>種別 </th> <th> イメージ </th> <th> 説明 </th> <th> 利用シーン</th> </tr> </thead> <tbody> <tr> <td>プロダクトバックログ </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006180921.png" width="1200" height="693" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> GitHub Projects全体のチケットをEpic単位で絞り込めるようにしている </td> <td> 全体のタスクを眺める時<br>見積もりをする時</td> </tr> <tr> <td>スプリントバックログ </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006180959.png" width="1200" height="523" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> 現在のSprintのタスクをAssignees単位で絞り込めるようにしている </td> <td> 朝会/夕会で各々の作業を報告する時</td> </tr> <tr> <td>自身のタスク </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006181125.png" width="1200" height="528" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> 自身がアサインされているタスクをSprint単位で絞り込めるようにしている </td> <td> 自身でアサインされているタスクを確認する時</td> </tr> <tr> <td>ADR </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006181220.png" width="1200" height="519" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> タスク管理という軸ではないが、議論したことを書いておく<br>・専用のリポジトリのISSUEを表示している<br>・GitHub Discussionsでも良い </td> <td> 議論の場</td> </tr> </tbody> </table> <h3 id="Workflows">Workflows</h3> <p>自由度は低いですが、<br> Draft / ISSUE / Pull request の操作をHookとして、特定のアクションを行うように設定ができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006181522.png" width="1200" height="644" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>★自身のチームでは、このような設定をしています。</p> <ul> <li>ISSUE / Pull requeset をProjectsに追加した時、Statusを Backlogに設定する</li> <li>ISSUE / Pull requeset をクローズした時、Statusを Doneに設定する</li> <li>Pull requeset をマージした時、Statusを Doneに設定する</li> <li>ADRのリポジトリに <code>ラベル: ADR</code> のISSUEを作成した時、このProjectsに設定する</li> </ul> <p>→ チケットの整理をしたくなるフェーズで Auto-archive items を設定する</p> <h4 id="ISSUEと-Pull-requestの紐づけ">ISSUEと Pull requestの紐づけ</h4> <p>ISSUEと Pull requestを紐付けることができ、<br> これを設定するとマージされたタイミングで紐づいている ISSUEがクローズされます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006183152.png" width="638" height="330" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Workflowsとセットで使うことで、自動的にステータスをDoneに更新することができます。<br> ★ Pull requestのマージ → ISSUEのクローズ → Custom Fieldsのステータスが Doneになる</p> <p>※ 注意点としては複数のPull requestを ISSUEに紐づけている場合、1つでもマージされるとISSUEがクローズされてしまう</p> <h3 id="Insights">Insights</h3> <p>Draft / ISSUE / Pull requestの状態を参照し、グラフを作ることができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006183349.png" width="1200" height="594" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>★ 自身のチームではこのような設定をしています。</p> <table> <thead> <tr> <th>種別 </th> <th> イメージ </th> <th> 説明</th> </tr> </thead> <tbody> <tr> <td>Burn Up </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006184017.png" width="1200" height="852" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> 作成されているISSUEとクローズされているISSUEの傾向を確認できる</td> </tr> <tr> <td>EPIC </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006184049.png" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> Epicごとのタスク量を確認できる(縦軸はチケットの合計)</td> </tr> <tr> <td>Velocity </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006184138.png" width="1200" height="767" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> Sprintごとの進行速度を確認できる(縦軸はEstimateの合計)</td> </tr> <tr> <td>Plan </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/ichijunxxx/20231006/20231006184219.png" width="1200" height="792" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> <td> Sprintごとに割り当てられているタスク量を見ることができる(縦軸はEstimateの合計)</td> </tr> </tbody> </table> <h2 id="タスクの進め方">タスクの進め方</h2> <p>自身のチームでは、このようなステップでタスクを進めています。<br> まずはタスクの洗い出しと見積もりです。</p> <ol> <li>タスクの洗い出し</li> <li>見積もり</li> </ol> <p>↓ スプリントごとにタスクのアサイン〜開発〜整理を繰り返す</p> <ol> <li>タスクのアサイン</li> <li>開発</li> <li>タスクの整理</li> </ol> <h3 id="タスクの洗い出し">タスクの洗い出し</h3> <p>スムーズに見積もりを行うために、何をどこまでやるかが整理できてると良いです。</p> <p>なのでチケットに概要、どこまでやるかなどを書くルールにしています。<br> タスクの範囲が曖昧だと見積もりがブレがちになります。</p> <pre class="code" data-lang="" data-unlink>## 概要 ◯◯を設定できるようにしたい ## やること - ◯◯が設定できるようになってる - backendと疎通し、DBにデータが保存できるようになっている - mainブランチにマージできている ## 補足 - GraphQLスキーマは決定している状態からスタート</pre> <h3 id="見積もり">見積もり</h3> <p>下記のようなルールで決めていきます。</p> <ul> <li>チケットの重さは 1, 2, 3, 5, 8, 13, .. (フィボナッチ数列)で書き、人日では表さない</li> <li>チケットの重さは 相対評価 <ul> <li>タスクA が 2で、タスクB が 8だった場合、B の工数は A の工数の 4倍あると見積もる ○</li> <li>タスクCが 2よりもかかりそうだけど、5まではいかないなあ。。と思ったら 3を設定 ○ <ul> <li>タスクをこなしていくと徐々に成熟していくイメージ</li> <li>最初のうちはマトリクスを作って意識統一する <ul> <li>1: 数分で終わり、やる内容は明確、リスクがない</li> <li>2: 数時間で終わり、やる内容は明確、リスクがない</li> <li>3: 1日で終わり、やる内容は少し整理が必要、リスクがほとんどない</li> <li>5: 数日で終わり、やる内容は整理が必要、リスクを考慮</li> <li>8: 1週間で終わり、やる内容は複雑、リスクがある</li> </ul> </li> </ul> </li> </ul> </li> <li>個人の裁量で決めず、チームで決定する <ul> <li>あの人だったら慣れてるから 1日で終わりそうだけど、、自分はもっとかかるかも × <ul> <li>(ブラウザでプランニングポーカーをするサービスがあるので、そちらを活用する)</li> </ul> </li> </ul> </li> <li>できる限り小さい数字にする <ul> <li>小さくするのは手間だったりするので、手間にならない程度に分解する <ul> <li>分解することでタスクの解像度が上がる ○</li> </ul> </li> </ul> </li> </ul> <p>チームで決められた値を見積もりに使うことで、<br> スプリント内でチームがどれくらいチケットを消化できるかが見えるようになります。</p> <h2 id="現状の課題と今後の展望">現状の課題と今後の展望</h2> <p>運用していると下記のような課題点が出てきました。</p> <ul> <li>サブタスクを作りたい <ul> <li>少し大きなチケットを消化する際にチケットを分けたくなることもあるが、現状サブタスクが作れない <ul> <li><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.github.com%2Fja%2Fissues%2Fmanaging-your-tasks-with-tasklists%2Fcreating-a-tasklist" title="タスクリストの作成 - GitHub Docs" 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://docs.github.com/ja/issues/managing-your-tasks-with-tasklists/creating-a-tasklist">docs.github.com</a></cite> <ul> <li>2023年10月時点でプライベートベータ</li> </ul> </li> </ul> </li> </ul> </li> <li>バーンダウンチャートが見たい <ul> <li>いまの進行速度だといつごろ開発が完了するのかを見たくなるが、現状見れない</li> </ul> </li> </ul> <p>Slice byは2023年8月に追加されて使いやすくなったように、今後の進化に期待です。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.blog%2Fchangelog%2F2023-08-10-github-issues-projects-august-10th-update%2F%23slice-by" title="GitHub Issues &amp; Projects - August 10th update" 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.blog/changelog/2023-08-10-github-issues-projects-august-10th-update/#slice-by">github.blog</a></cite></p> <p>今後の展望としては Qaseのようなテスト管理ツールと連携し、<br> 自動テストの実行と絡めた バグチケットの連携まで出来るようになれれば良いなと思っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fqase.io%2F" title="Qase | Test management software for quality assurance" 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://qase.io/">qase.io</a></cite></p> <h2 id="まとめ">まとめ</h2> <p>タスクのチケット化・見積もりをする癖をチームに作るのが最初の課題かなと思います。</p> <ol> <li>スプリントごとにやることを決め、そのチケットを見ながら朝会などで会話する</li> <li>スプリントでどの程度タスクをこなせたのかを測り、いつ頃までに開発が完了するのかが分かるようになる</li> </ol> <p>このようなフローになればチームの透明性も確保できて良い感じです◯</p> <p>GitHub Projectsで 小中規模の開発に十分耐えられるので、ブラッシュアップしながら継続して使っていきたいと思います。</p> <h2 id="さいごに">さいごに</h2> <p>一休では、ともに良いサービスをつくっていく仲間を募集中です!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施しているので、お気軽にご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> ichijunxxx 一休.comサイトパフォーマンス改善 - 2023年 夏の振り返り hatenablog://entry/820878482959216593 2023-09-15T17:43:09+09:00 2023-09-16T11:15:02+09:00 一休.comのサイトパフォーマンス改善活動について、振り返ります。 <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/g/gpxo/20230817/20230817152058.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>ヤフー株式会社より出向しております、卯田と申します。<br/> 主務で、<a href="http://www.ikyu.com/">一休.com</a>および<a href="https://travel.yahoo.co.jp/">Yahoo!トラベル</a>のフロントエンド開発を担当しています。<br/> 兼務で、ヤフー株式会社の全社横断組織で<a href="https://techblog.yahoo.co.jp/entry/2022090530337093/">Webパフォーマンス改善の推進</a>を行っております。</p> <p>本稿では、直近半年弱(2023年2月〜8月)で、断続的に行っていた一休.comのパフォーマンス改善について振り返ります。</p> <p>開始が2023年2月となった理由は、Nuxt3バージョンアップ以降にパフォーマンス改善活動に着手したためです。</p> <p>一休.com/Yahoo!トラベルのNuxt3バージョンアップ詳細については、以下のブログをご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2023%2F04%2F18%2F081144" title="一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2023/04/18/081144">user-first.ikyu.co.jp</a></cite></p> <ul class="table-of-contents"> <li><a href="#サイトパフォーマンス改善の意義">サイトパフォーマンス改善の意義</a></li> <li><a href="#改善の方針">改善の方針</a><ul> <li><a href="#方針1-Core-Web-Vitalsを改善する">方針1: Core Web Vitalsを改善する</a></li> <li><a href="#方針2-重要課題から優先的に対応する">方針2: 重要課題から優先的に対応する</a></li> </ul> </li> <li><a href="#改善の進め方">改善の進め方</a><ul> <li><a href="#可視化">可視化</a><ul> <li><a href="#ブラウザサイド">ブラウザサイド</a></li> <li><a href="#サーバーサイド">サーバーサイド</a></li> </ul> </li> <li><a href="#優先順位決め">優先順位決め</a></li> </ul> </li> <li><a href="#具体的な改善内容">具体的な改善内容</a><ul> <li><a href="#CLSの改善">CLSの改善</a><ul> <li><a href="#こだわり条件更新時に発生するレイアウトシフト">こだわり条件更新時に発生するレイアウトシフト</a></li> <li><a href="#クチコミ画像表示時に発生するレイアウトシフト">クチコミ画像表示時に発生するレイアウトシフト</a></li> </ul> </li> <li><a href="#LCPの改善">LCPの改善</a><ul> <li><a href="#リソースの読み込み順序の改善">リソースの読み込み順序の改善</a></li> <li><a href="#documentのgzip圧縮">documentのgzip圧縮</a></li> <li><a href="#サーバーサイドKeep-Aliveの実装">サーバーサイドKeep Aliveの実装</a></li> <li><a href="#SQLの最適化">SQLの最適化</a></li> <li><a href="#検索システムのバージョンアップ">検索システムのバージョンアップ</a></li> </ul> </li> <li><a href="#FIDの改善">FIDの改善</a></li> <li><a href="#LCPFIDCLS3つの指標が良好になった後">LCP、FID、CLS、3つの指標が良好になった後</a></li> </ul> </li> <li><a href="#結果">結果</a></li> <li><a href="#今後">今後</a><ul> <li><a href="#CLSの改善-1">CLSの改善</a></li> <li><a href="#LCPの改善-1">LCPの改善</a><ul> <li><a href="#スタイルの計算とレンダリングの最適化">スタイルの計算とレンダリングの最適化</a></li> <li><a href="#Apollo-Clientのキャッシュ計算処理">Apollo Clientのキャッシュ計算処理</a></li> <li><a href="#算出方法の改善期待">算出方法の改善期待</a></li> </ul> </li> <li><a href="#FIDINPの改善">FID(INP)の改善</a></li> <li><a href="#パフォーマンス改善によるビジネス貢献">パフォーマンス改善によるビジネス貢献</a></li> </ul> </li> <li><a href="#最後に">最後に</a></li> </ul> <h2 id="サイトパフォーマンス改善の意義">サイトパフォーマンス改善の意義</h2> <p>サイトパフォーマンスは、<strong><em>「お客様に上質な体験を提供するための重要非機能要件」</em></strong>と考えています。 一休.comは、「心に贅沢を」をコンセプトに宿泊予約サイトを提供しております。 こちらのコンセプトのもと、便利な機能やUIをお客様に提供したいという気持ちで日々開発しており、パフォーマンスに関しても同じです。 お客様に気持ちよくサイトをご利用いただくためにも、パフォーマンスを維持することは非常に重要であると考えています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817161144.png" width="774" height="316" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="改善の方針">改善の方針</h2> <h3 id="方針1-Core-Web-Vitalsを改善する">方針1: Core Web Vitalsを改善する</h3> <p>パフォーマンス改善の指標は、サイト全体のCore Web Vitals(フィールドデータのLCP・FID・CLS)としました。</p> <p>PageSpeed Insightで示すと、赤枠の箇所です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817152636.png" width="1200" height="834" 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/g/gpxo/20230817/20230817152641.png" width="1200" height="807" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>GoogleではCore Web Vitalsを以下のように定義しています。</p> <blockquote><p>Core Web Vitals は、Web 上で実際にユーザーが体験するユーザー エクスペリエンスに関する重要な観点の測定を目的とした一連のフィールド指標(データ)です。 Core Web Vitals には指標と各指標のターゲットとなるしきい値が含まれており、これらを参考にすることで、運営するサイトでのユーザー体験が "良い"、"改善が必要"、"悪い" のいずれの状態にあるかを開発者が定性的に理解できるようになります。</p></blockquote> <p>引用: <a href="https://web.dev/i18n/ja/defining-core-web-vitals-thresholds/">https://web.dev/i18n/ja/defining-core-web-vitals-thresholds/</a></p> <blockquote><p><strong>LCP</strong> : Largest Contentful Paint (最大視覚コンテンツの表示時間): 読み込みのパフォーマンスを測定するための指標です。優れたユーザー エクスペリエンスを提供するためには、ページの読み込みが開始されてからの LCP を 2.5 秒以内にする必要があります。</p> <p><strong>FID</strong> : First Input Delay (初回入力までの遅延時間): インタラクティブ性を測定するための指標です。優れたユーザー エクスペリエンスを提供するためには、ページの FID を 100 ミリ秒以下にする必要があります。</p> <p><strong>CLS</strong> : Cumulative Layout Shift (累積レイアウト シフト数): 視覚的な安定性を測定するための指標です。優れたユーザー エクスペリエンスを提供するためには、ページの CLS を 0.1 以下に維持する必要があります。</p></blockquote> <p>引用: <a href="https://web.dev/i18n/ja/vitals/">https://web.dev/i18n/ja/vitals/</a></p> <p>また、Core Web Vitalsを改善するためのパフォーマンス指標として、ラボデータ(synthetic monitoringと言う場合もあります)とフィールドデータ(RUMと言う場合もあります)の2種類を提供しています。</p> <blockquote><p><strong>Lab data</strong>: Lab data is determined by loading a web page in a controlled environment with a predefined set of network and device conditions. These conditions are known as a lab environment, sometimes also referred to as a synthetic environment.<br></p> <p><strong>Field data</strong>: Field data is determined by monitoring all users who visit a page and measuring a given set of performance metrics for each one of those users' individual experiences. Because field data is based on real-user visits, it reflects the actual devices, network conditions, and geographic locations of your users.<br></p></blockquote> <p>引用: <a href="https://web.dev/lab-and-field-data-differences">https://web.dev/lab-and-field-data-differences</a></p> <p>ラボデータとフィールドデータの関係において重要なことは、フィールドデータの改善が主たる目的であり、ラボデータは、あくまでフィールドデータを改善するための補足情報であるということです。 ユーザー体験を改善することが目的であることを鑑みると、フィールドデータ、さらにはその中でも最も重要と位置付けているCore Web Vitalsが、改善すべきパフォーマンス指標として適切です。 もちろん、Core Web Vitalsを改善するためにブレイクダウンした値として、ラボデータのスコアを改善指標とすることもできますが、あくまで参考程度に留めています。</p> <p>Core Web Vitalsの3つの指標のバランスも重要と考えています。 LCP、FID、CLSの特定の指標が非常に良い状態を目指すのではなく、3つの指標が満遍なく良好な状態を目指しています。</p> <p>Core Web Vitalsの良好に関する詳細は、以下のページをご覧ください。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fweb.dev%2Fi18n%2Fja%2Fdefining-core-web-vitals-thresholds%2F" title="Core Web Vitals の指標のしきい値の定義" 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://web.dev/i18n/ja/defining-core-web-vitals-thresholds/">web.dev</a></cite></p> <h3 id="方針2-重要課題から優先的に対応する">方針2: 重要課題から優先的に対応する</h3> <p>パフォーマンス改善は、重要課題(大きく改善が見込める領域)から取り組むことで効率的に改善できます。 改善に時間をかけたにも関わらず、対して改善しなかったでは意味がありません。 特に、解決したい課題を理解せずにTipsベースで取り組むのは注意です。 「xxxでCore Web Vitalsが改善しました!」という記事をみて、同じ手法を取り入れてみたが、イマイチだったという経験がある方もいらっしゃるかもしれません(私もあります)。 そのようなことにならないよう、重要な課題から優先して対処していくことを念頭におきました。 もちろん、全て正しいアプローチで進められたわけではありませんが、常にチーム内で心掛けていました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817153422.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="改善の進め方">改善の進め方</h2> <h3 id="可視化">可視化</h3> <p>まずは、重要課題を把握するために、現状を可視化しました。</p> <h4 id="ブラウザサイド">ブラウザサイド</h4> <p>ブラウザサイドのパフォーマンス可視化には、<a href="https://codelabs.developers.google.com/codelabs/web-vitals-google-analytics-bigquery">GoogleのCodelabsに掲載されている資料</a>を参考に、Looker Studioのダッシュボードを作成しました。</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/g/gpxo/20230817/20230817154246.png" width="1200" height="556" 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/g/gpxo/20230817/20230817154253.png" width="1200" height="903" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ダッシュボード(一部非掲載)</figcaption></figure></p> <h4 id="サーバーサイド">サーバーサイド</h4> <p>サーバーサイドのパフォーマンス可視化には、もともと一休で導入しているDatadogの<a href="https://docs.datadoghq.com/ja/dashboards/">ダッシュボード</a>と<a href="https://docs.datadoghq.com/ja/tracing/">トレース機能</a>を利用しました。</p> <p>全体把握には、ダッシュボードを使って、サーバーサイドのレイテンシを可視化しています。<br/> 以下はダッシュボードに載せている図の一例で、レスポンスの75パーセンタイル値の推移です</p> <p><figure class="figure-image figure-image-fotolife" title="75パーセンタイルレスポンスタイム"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817231900.png" width="1200" height="568" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>75パーセンタイルレスポンスタイム</figcaption></figure></p> <p>より詳細を見るためにはトレース機能を活用します。 デフォルトのプリセットで、ある程度のタスクをトレースできます。 デフォルトで表示されないタスクのトレースを試みたい場合、Node.jsであれば<a href="https://datadoghq.dev/dd-trace-js/">dd-trace-js</a>のwrap関数で、traceしたい処理をwrapします。</p> <p>以下は、<a href="https://www.ikyu.com/tokyo/140000/">東京のホテル・旅館</a>のリクエストをトレースしている図です。赤枠は詳細トレース用にwrapしたApollo Clientのキャッシュ計算処理です。 そのほかにもネットワークリクエストの流れを把握できます。</p> <p><figure class="figure-image figure-image-fotolife" title="Datadog APM Trace"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817224836.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Datadog APM Trace</figcaption></figure></p> <h3 id="優先順位決め">優先順位決め</h3> <p>可視化したところ、3つの指標で特にスマートフォンのCLSが不良でした。 そこでまずはスマートフォンのCLS改善に取り組みました。 次に、LCPも良好ではなかったためLCPの改善に取り組みました。 FIDは良好であった、かつ、2024年3月にINPに置き換わるということが2023年3月に周知されたため、後対応としました。</p> <div style="padding: 40px; background: #f8f8f8; line-height: 1.7; margin-bottom: 30px"> <strong>補足</strong> <p> INPは、ユーザー操作に適切に画面が反応できているかを示す指標です。 不良な状態であるということはユーザーの操作を阻害していることを意味しています。 したがって、Core Web Vitalsが2024年3月に置き換わるタイミングに関わらず、可能であれば早急に改善したい課題です。 ただし、今後計測のエコシステムが整ってくるだろうという楽観的な希望もあり、節足に取り組むことはしないとチームで判断しました。</p> </div> <ul> <li><a href="https://web.dev/inp">INP - web.dev</a></li> <li><a href="https://www.w3.org/TR/event-timing">2023年8月執筆時点で、Chromiumのみが対応 - w3</a></li> <li><a href="https://bugs.chromium.org/p/chromium/issues/detail?id=1312568">別タブでリンクを開いた際に、極端にスコアが悪くなってしまう問題- chromium</a></li> <li><a href="https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/speed/metrics_changelog/inp.md">INP変更履歴 - chromium</a></li> </ul> <h2 id="具体的な改善内容">具体的な改善内容</h2> <p>具体的な改善内容を、改善に取り組んだ時系列、CLS、LCP、FIDの順に紹介します。<br/> ※パフォーマンス改善で実施した改善施策のうち、分かりやすい施策を中心に紹介します。</p> <h3 id="CLSの改善">CLSの改善</h3> <p>上記で作成したダッシュボードを利用することで、</p> <ul> <li>どのDOM要素が</li> <li>どれくらいの頻度で</li> <li>どれくらいの大きさ</li> </ul> <p>レイアウトシフトしているかを一覧できるようになりました。<br/> 下の画像左のプロット図をご覧ください。</p> <p><figure class="figure-image figure-image-fotolife" title="Looker Studio CLS ダッシュボード "><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817155418.png" width="1200" height="478" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Looker Studio CLS ダッシュボード </figcaption></figure></p> <p>右上にプロットされている点が、頻度が高く、大きくレイアウトシフトしているDOM要素を示しています。</p> <p>この図に従い、右上に存在するDOM要素から順にレイアウトシフトを特定し、改善を施していきました。<br/> 以下に、分かりやすい事例として2つ、頻繁に起きていた事例と大きくレイアウトシフトしていた事例を紹介します。</p> <h4 id="こだわり条件更新時に発生するレイアウトシフト">こだわり条件更新時に発生するレイアウトシフト</h4> <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/g/gpxo/20230822/20230822001352.gif" width="500" height="488" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>こだわり条件更新時のレイアウトシフト</figcaption></figure></p> <h4 id="クチコミ画像表示時に発生するレイアウトシフト">クチコミ画像表示時に発生するレイアウトシフト</h4> <p>お客様の投稿したクチコミ画像を表示するモーダルです。 画像領域の高さ指定をしていなかったため、画像読み込みの間、領域の高さが0となっていました。 頻度は低いですが、レイアウトシフトの大きさとしては非常に大きい事例です。<br/> ※図はYahoo!トラベルですが、一休.comでも同様です。</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/g/gpxo/20230821/20230821230259.gif" width="500" height="496" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>クチコミ画像のレイアウトシフト</figcaption></figure></p> <h3 id="LCPの改善">LCPの改善</h3> <h4 id="リソースの読み込み順序の改善">リソースの読み込み順序の改善</h4> <p>ChromeのDev Toolsのネットワークタブで、リクエストウォーターフォールを確認したところ、大量のJavaScriptとCSSをpreloadしており、LCPとなる画像を取得するタイミングが遅れていました。</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/g/gpxo/20230817/20230817220244.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>修正前のネットワーク</figcaption></figure></p> <p>LCP画像に<a href="https://html.spec.whatwg.org/#link-type-preload">Resource Hints</a> を定義することで一定の改善も見込めますが、ページごとに個別最適した実装が必要で少し手間がかかります。 より、サイト全体に効果があるアプローチとして、すでにNuxt3のGitHubで<a href="https://github.com/nuxt/nuxt/discussions/16231#discussion-4773542">議論</a>されており、解決方法まで示されていたので、こちらを先に採用することにしました。 結果的には、この改善はNuxt3で動いているページ全体への効果が非常に大きく、計測ページ全体で、400msほどLCP改善しました。 本修正は、JavaScriptのloadを遅らせるため、FIDに悪い影響が出る懸念もありましたが、結果的には問題ありませんでした。</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/g/gpxo/20230817/20230817232816.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ネットワーク前後比</figcaption></figure></p> <p>※上記課題は、2023年8月25日リリースの<a href="https://nuxt.com/blog/v3-7#html-rendering-optimisations">Nuxt 3.7</a>experimental機能でも改善が図られています。以下のように、headNext機能を有効化することで検証できます。</p> <pre class="code TypeScript" data-lang="TypeScript" data-unlink>export default defineNuxtConfig({ experimental: { headNext: true } })</pre> <h4 id="documentのgzip圧縮">documentのgzip圧縮</h4> <p>Nuxt3ではdocumentのgzip圧縮をできていませんでした。 そこで、Nuxt3が採用しているhttpフレームワークの<a href="https://github.com/unjs/h3">unjs/h3</a>のpatchを独自で用意しました。 レスポンス直前にdocumentをgzip圧縮する処理を追加しています。</p> <p><figure class="figure-image figure-image-fotolife" title="gzip 前後比"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817210001.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>gzip 前後比</figcaption></figure></p> <h4 id="サーバーサイドKeep-Aliveの実装">サーバーサイドKeep Aliveの実装</h4> <p>DNS LookupやTCP connectionをバックエンドへのリクエストの度に行っていたため、HTTP(S)/1.1 KeepAliveの実装をしました。</p> <p>※<a href="https://nodejs.org/en/blog/announcements/v19-release-announce#https11-keepalive-by-default">Node.jsのバージョン19からデフォルトで有効になる機能</a>です。</p> <p><figure class="figure-image figure-image-fotolife" title="Datadog APM Trace - KeepAlive"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817190859.png" width="1200" height="610" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Datadog APM Trace - KeepAlive</figcaption></figure></p> <h4 id="SQLの最適化">SQLの最適化</h4> <p>非常に遅いSQLです。 SQLの実行のみで1.4秒近く時間を要しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817191239.png" width="1200" height="253" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>対象のデータベースはSQL Serverを使っています。 <a href="https://learn.microsoft.com/ja-jp/sql/relational-databases/indexes/tune-nonclustered-missing-index-suggestions?view=sql-server-ver16">SQL Server Managementで実行プラン</a>を確認し、不足しているインデックス情報に従い、インデックスを設定し直すことで改善しました。</p> <h4 id="検索システムのバージョンアップ">検索システムのバージョンアップ</h4> <p>検索システムにはApache Solrを使っています。 古いバージョンのApache Solrを使用していたため、まずは改善土壌を整えるべくバックエンドチームが4月から3ヶ月ほどかけてバージョンアップを行いました。 バージョンアップを行う過程で<a href="https://solr.apache.org/guide/8_3/major-changes-in-solr-7.html#point-fields-are-default-numeric-types">DeprecatedとなったFieldType</a>を改修したところ、検索システムのレイテンシが劇的に改善しました。</p> <p><figure class="figure-image figure-image-fotolife" title="Solr クエリのレイテンシー"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gpxo/20230817/20230817191408.png" width="1142" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>Solr クエリのレイテンシー</figcaption></figure></p> <p>Solrのドキュメントにレイテンシが改善すると記載されてはいたものの、正直想定していた以上の結果だったとのことです。</p> <p>(嬉しい誤算ですね。こういうこともあります。 )</p> <p>結果として、検索システムを呼び出している画面のLCPが200ms改善しました。</p> <h3 id="FIDの改善">FIDの改善</h3> <p>上述の通り、FIDは当初より良好であったことと、INPへと置き換わることが周知されており、後対応としました。 今後は、INPの改善に取り組んでいきたいと考えています。</p> <h3 id="LCPFIDCLS3つの指標が良好になった後">LCP、FID、CLS、3つの指標が良好になった後</h3> <p>機能開発で、パフォーマンスが悪化することもあります。 週1で、パフォーマンスチェックをする機会を設け、惰性で悪化することを防ぎました。 悪化した場合には、Looker Studioのダッシュボード、Datadog、一週間のコミットを照らし合わせ、改善しています。 幸い、一休.comのフロントエンド開発ではビッグバンになるようなリリースが極めて稀なため、変更コード量も限られており、悪化したとしても原因を特定することには苦労していません。</p> <h2 id="結果">結果</h2> <p>下図は、直近6ヶ月弱のCore Web Vitalsの推移です。 線が下に行くほど、良い状態を示しています。 2月,3月の改善着手初期でCLSとLCPが大きく改善し、以降、3つの指標が要改善となるのを防ぎつつ、SolrのバージョンアップでLCPがさらに改善しました。 CLSは、良好の範囲内で一時的に悪化しておりましたが、作業時間を確保できたところで改善を施し、元の水準までスコアを戻しています。</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/g/gpxo/20230913/20230913200040.png" width="1200" height="277" 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/g/gpxo/20230913/20230913200036.png" width="1200" height="264" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>デスクトップ</figcaption></figure></p> <p>Googleが毎月更新している<a href="https://developer.chrome.com/docs/crux/dashboard/">Chrome User Experience Report</a>でもフィールドデータの大まかな傾向を確認できます。</p> <p>下図は、一休.comのCrUXダッシュボードです。 緑色の領域が良好を示しています。 2022年11月に比べ、2023年8月では良好の割合が増えていることが確認できます。</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/g/gpxo/20230913/20230913135816.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>上:スマートフォン / 下:デスクトップ</figcaption></figure></p> <p>一休.comのCrUXダッシュボードの詳細は、以下のページでもご覧いただけます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Flookerstudio.google.com%2Fs%2Ftqoj1wUKCow" title="CrUX Dashboard v2" 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://lookerstudio.google.com/s/tqoj1wUKCow">lookerstudio.google.com</a></cite></p> <h2 id="今後">今後</h2> <h3 id="CLSの改善-1">CLSの改善</h3> <p>CLSは、この6ヶ月間でも、機能改修で、幾度か悪化することがありました。 引き続き監視しつつ、良好を維持できるようにします。</p> <h3 id="LCPの改善-1">LCPの改善</h3> <h4 id="スタイルの計算とレンダリングの最適化">スタイルの計算とレンダリングの最適化</h4> <p>スタイルの計算とレンダリングに時間がかかっています。<br/> CSSセレクタのパフォーマンスを<a href="https://learn.microsoft.com/ja-jp/microsoft-edge/devtools-guide-chromium/evaluate-performance/selector-stats">Microsoft Edgeのパフォーマンスツール</a>で確認したところ、*, ::after, ::before のCSS変数の計算が大半を占めていました。</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/g/gpxo/20230817/20230817222104.jpg" width="1024" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>セレクターパフォーマンス</figcaption></figure></p> <p>CSS変数を埋め込んでいるのは、Tailwindのベーススタイルです。 GitHubで検索してみたところ、Tailwindのリポジトリで<a href="https://github.com/tailwindlabs/tailwindcss/discussions/7411">同様の議論</a>をしていました。</p> <p>DOMサイズが大きい場合に顕著に悪化する問題で、一休.comは全体的に初期表示時のDOMサイズが大きいサイトなため影響を受けています。</p> <p>最もDOMサイズが大きい<a href="http://www.ikyu.com/">トップページ</a>で、開発環境検証してみたところ、スタイル計算のパフォーマンスが改善されることを確認できました。 他のページへの修正影響を確認した上でリリースしたいと考えています。</p> <p>また、ブラウザサイドでの画像のリクエストタイミングが、最善ではありません。 LCPの値が好ましくない、かつ、お客様訪問の多いページ・デバイスからResource Hintsもしくは<a href="https://html.spec.whatwg.org/multipage/urls-and-fetching.html#fetch-priority-attributes">Fetch Priority</a>属性を実装し、改善を図りたいです。</p> <h4 id="Apollo-Clientのキャッシュ計算処理">Apollo Clientのキャッシュ計算処理</h4> <p>Apollo Clientのキャッシュ計算処理に時間を要しています。 実装改修コストも非常に高いですが、重要なページから異なるGraphQL Clientへの移行を始めています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2022%2F07%2F01%2F121325" title="あなたのプロダクトに Apollo Client は必要ないかもしれない - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2022/07/01/121325">user-first.ikyu.co.jp</a></cite></p> <h4 id="算出方法の改善期待">算出方法の改善期待</h4> <p>Googleでは"soft navigations"に関するLCP算出方法の変更を検討しています。 一休.comでは"soft navigations"を多用しており、ユーザーが体験したパフォーマンスにより近くなると期待しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.chrome.com%2Fblog%2Fsoft-navigations-experiment%2F" title="Experimenting with measuring soft navigations - Chrome Developers" 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.chrome.com/blog/soft-navigations-experiment/">developer.chrome.com</a></cite></p> <h3 id="FIDINPの改善">FID(INP)の改善</h3> <p>CLSで行った手法同様、まずはINPの計測環境を整備します。 そして、不良かつ頻繁に発生するイベント(動作)を特定し、改善に最も効く重要なイベントから最優先で改善を行っていきたいです。</p> <h3 id="パフォーマンス改善によるビジネス貢献">パフォーマンス改善によるビジネス貢献</h3> <p>現状、パフォーマンス改善によって、どれだけビジネスに貢献できたは把握できておりません。 ヤフー株式会社の全社横断組織にて、ビジネス指標(直帰率、離脱率、コンバージョン率など)とパフォーマンスの相関を計測する環境が整備されつつあるので、 次は、Yahoo!トラベルで、ビジネス貢献にもつながるパフォーマンス改善に取り組んでいきたいです。</p> <h2 id="最後に">最後に</h2> <p>株式会社一休では、上質なウェブ体験を一緒に実現してくださる方を絶賛募集していますo(<em>^▽^</em>)o~♪</p> <p>一緒に、宿泊・飲食予約の未来を作りましょう!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F0001067" title="ソフトウェアエンジニア(フロントエンド)(正社員) | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/0001067">hrmos.co</a></cite></p> gpxo 【検索改善】マイクロサービス化から適合率向上まで hatenablog://entry/820878482962572505 2023-09-01T12:00:00+09:00 2023-09-01T12:05:42+09:00 はじめに こんにちは。宿泊検索チームの渥美 id:atsumim です。 最近は検索改善のプロジェクトを行っており、特にキーワードでの検索の改善を行っています。 今回はその中でこの1年くらいの改善についてお話しします。 言葉の定義 先にこの記事で用いる言葉の説明をします。 ハード検索 指定した条件と完全に一致する結果のみを返す検索方法です。 今回は ID に変換される検索のことを指します。 ID なので一文字でも違うと、異なる条件として取り扱われます。 より具体的に言えば、下記の検索パネルから選択できる条件はすべて ID に変換されます。 例えば箱根は are=160418 となります ソフト… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/atsumim/20230829/20230829142507.jpg" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="はじめに">はじめに</h1> <p>こんにちは。宿泊検索チームの渥美 <a href="http://blog.hatena.ne.jp/atsumim/" class="hatena-id-icon"><img src="https://cdn.profile-image.st-hatena.com/users/atsumim/profile.png" width="16" height="16" alt="" class="hatena-id-icon">id:atsumim</a> です。</p> <p>最近は検索改善のプロジェクトを行っており、特にキーワードでの検索の改善を行っています。<br> 今回はその中でこの1年くらいの改善についてお話しします。</p> <h1 id="言葉の定義">言葉の定義</h1> <p>先にこの記事で用いる言葉の説明をします。</p> <h3 id="ハード検索">ハード検索 <br /> </h3> <p>指定した条件と完全に一致する結果のみを返す検索方法です。<br> 今回は ID に<strong>変換される</strong>検索のことを指します。<br> ID なので一文字でも違うと、異なる条件として取り扱われます。<br> より具体的に言えば、下記の検索パネルから選択できる条件はすべて ID に変換されます。</p> <p><figure class="figure-image figure-image-fotolife" title="例えば箱根は are=160418 となります"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/atsumim/20230828/20230828212428.png" width="677" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>例えば箱根は are=160418 となります</figcaption></figure></p> <h3 id="ソフト検索">ソフト検索<br /> </h3> <p>指定した条件と部分的に一致する結果も返す検索方法です。<br> 今回は ID に<strong>変換されない</strong>検索(つまり純粋なキーワード)のことを指します。<br> 例えば <code>一休</code> と検索したときに <code>一休み</code> や <code>一休さん</code> などの結果が含まれることになります。<br> より具体的に言えば、上記の検索パネルからできないキーワードのことになります。</p> <h1 id="箱根かに問題-">箱根かに問題 🦀</h1> <p>ことの発端は「かに」でした。<br> まずは下記のスクリーンショットをご覧ください。<br> 一年前の一休.com で「箱根, かに」を検索した画面です。</p> <table> <thead> <tr> <th style="text-align:center;">検索結果A</th> <th style="text-align:center;">検索結果B</th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/atsumim/20230829/20230829011037.png" width="872" height="1038" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></td> <td style="text-align:center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/atsumim/20230829/20230829011042.png" width="872" height="1038" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></td> </tr> </tbody> </table> <p>同じ条件であるはずの「箱根」「かに」の検索結果が異なることがわかります。<br> 検索結果Aでは56件、検索結果Bでは96件となっています。<br> また、表記も <code>箱根, かに</code> と <code>箱根 かに</code> で微妙に異なっています。</p> <p>なぜこのようなことが起きたのでしょう。<br> 原因は検索方法の違いにあります。</p> <p><strong>検索結果A</strong> <br> 「ハード検索 + ソフト検索」の検索方法です。<br> 細かく見ると「ハード検索: <code>箱根</code> , ソフト検索: <code>かに</code> 」という検索になります。<br> 一休.com で使われるクエリパラメータに変換すると <code>?are=160418&amp;kwd=かに</code> という形になります。</p> <p><strong>検索結果B</strong> <br> 一方、検索結果B は「ソフト検索」のみの検索方法です。<br> 細かく見ると「ソフト検索: <code>箱根, かに</code> 」という検索になります。<br> 一休.com で使われるクエリパラメータに変換すると <code>?kwd=箱根 かに</code> という形になります。</p> <p>ソフト検索では、クチコミの文章や施設・プランの紹介文などからもデータを取得しています。<br> そのため、施設が実際に箱根になくても、 <code>箱根</code> というワードがクチコミに入っていると検索結果に表示されてしまっていたのです。<br> また、クチコミのデータの中には「穏や<strong>かに</strong>過ごせました」や「静<strong>かに</strong>楽しめました」などの文章が入っています。 これもソフト検索で「かに」に引っかかってしまい、本来箱根の旅館でかにを食べたいのに全く別の結果が返ってきている状態でした。<br> これを「箱根かに問題」と呼びます🦀</p> <h1 id="システム的な問題">システム的な問題</h1> <p>「箱根かに問題」が根本的になぜ起きたのか、原因はシステム構成にありました。<br> 元々の検索システムの構成は下記のようになっていました。</p> <p><figure class="figure-image figure-image-fotolife" title="Before"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/atsumim/20230828/20230828231728.png" width="1200" height="730" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Before</figcaption></figure></p> <h2 id="システム構成">システム構成</h2> <p>先に図の説明をします。</p> <p>キーワードを検索すると、まずバックエンドからサジェストAPIが呼び出されます。<br> サジェストAPI は<strong>1つの単語</strong>に対して「キーワードを変換したID( <code>are</code> など)」または「変換できなかったキーワード( <code>kwd</code> )」を返却します。<br> この「ID に変換できなかったキーワード」に対してキーワードAPIが呼ばれます。<br> キーワードAPI は前述の通り、「クチコミ」や「施設の説明文」などの文章からキーワードにマッチした施設のデータを返却します。<br> それを元に検索結果がフロントエンドで描画されます。</p> <p>これが元々の構成です。</p> <h2 id="問題点">問題点</h2> <p>問題となっていたのは、サジェストAPI です。<br> 本来、その名の通りキーワードに対応するサジェストを表示するために使うのが サジェストAPI です。 <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/atsumim/20230829/20230829113944.png" width="1138" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:500px" itemprop="image"></span><figcaption>本来のサジェスト用途</figcaption></figure> しかしこれを検索のために使っていたために問題が起きていたのです。<br> サジェストAPIはそもそも<strong>1つの単語</strong>しか受け取りません。<br> つまり、例えば「箱根 かに」という2語のキーワードを1語として受け取り「<code>kwd=箱根 かに</code>」というように解釈してしまいます。</p> <p>これはサジェストの用途であれば問題がないのですが、検索用途では問題になります。<br> 本来「箱根」は <code>are=160418</code> という ID に変換されるべきなのです。<br> また、返ってきた <code>kwd</code> に対してフロントエンドでも変換を行う実装が入っており、検索周りの実装を複雑にしていました。</p> <p>これが「箱根かに問題」の実態です。<br> もちろん「かに」だけが問題が起きるわけではなく、上で見たように2語以上の単語を検索すると問題が起こるようになっていました。</p> <p>上記をまとめると、箱根かに問題は下記の2つに分解できます。</p> <ul> <li>使うべきではないサジェスト API を検索に使っている <ul> <li>「箱根」がキーワードとして認識されている</li> <li>キーワード検索に関する実装がフロントエンドにも漏れ出している</li> </ul> </li> <li>キーワード API の精度が高くない <ul> <li>「穏や<strong>かに</strong>」などのキーワードを含まずに純粋な「かに」を抽出したい</li> </ul> </li> </ul> <h1 id="マイクロサービスの導入">マイクロサービスの導入</h1> <p>前者の「使うべきではないサジェスト API を検索に使っている」という課題はマイクロサービスを立てることで解決しました。 以下が新しいシステム構成です。<br> フロントエンドのキーワード解釈の実装もこのマイクロサービスに寄せています。</p> <p><figure class="figure-image figure-image-fotolife" title="After"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/atsumim/20230828/20230828231743.png" width="1200" height="730" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>After</figcaption></figure></p> <p>このマイクロサービスは、例えば</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">keyword</span>&quot;:&quot;<span class="synConstant">箱根 かに</span>&quot; <span class="synSpecial">}</span> </pre> <p>というリクエストに対して</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">areaIds</span>&quot;: <span class="synSpecial">[</span> &quot;<span class="synConstant">160418</span>&quot; <span class="synSpecial">]</span>, &quot;<span class="synStatement">keyword</span>&quot;: &quot;<span class="synConstant">かに</span>&quot; <span class="synSpecial">}</span> </pre> <p>というレスポンスを返します。<br> キーワードをクエリに変換するサービスなので「クエリサービス」と名付けました。<br> これは我々が求めていたシンプルな形のものです。<br> かにも綺麗に分かれています₍₍⁽⁽🦀₎₎⁾⁾</p> <p>また、元々サジェストAPI はデータサイエンス部の管轄で、属人的になっていました。<br> しかし、今回クエリサービスはデータサイエンスのメンバーだけでなく、検索チームがオーナーシップを持って開発するように取り決めをしました。</p> <h1 id="適合率を上げる">適合率を上げる</h1> <p>一方、後者の「キーワード API の精度が高くない」という課題に関しては長期的なメンテナンスが必要です。 我々はこの課題に対して「ハード変換できるキーワードを増やす」というアプローチを取りました。 専門的に言うと長期的には「再現率」を改善し、短中期的には「適合率」を上げるようにしたのです。</p> <p>今までは <code>箱根</code> などのエリアや、<code>朝食付き</code> といったメジャーな検索条件に関しては、すでにキーワードから ID に変換できていました。<br> しかし「市区町村名」や「グループホテル名」などに関しては ID 変換をしていませんでした。<br> これらを変換し、適合率を上げるようにした、というわけです。<br> ID 変換できるキーワードが増えると、キーワードAPI に流れるキーワードは減るので、結果的により検索精度が上がります。</p> <h2 id="進め方">進め方</h2> <p>ソフト検索されているキーワードを監視して、検索需要が高いものから優先して ID 変換するようにしました。 これらはワードクラウドで視覚的に見えるようにしており、文字の大きいものほど検索需要が大きいキーワードということになります。<br> 執筆時の一ヶ月前(2023/07/21)の実際のデータを載せます。<br> この時点ではグループホテルが変換できていませんでした。</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/atsumim/20230829/20230829001912.png" width="1134" height="746" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グループホテル変換前の様子</figcaption></figure></p> <p><code>ふふ</code> や <code>ドーミーイン</code> , <code>星野リゾート</code> が大きい割合を占めているのがわかると思います。<br> では「グループホテル名」を ID 変換できるようになった今の様子を見てみましょう。</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/atsumim/20230829/20230829003501.png" width="1134" height="746" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グループホテル変換後の様子</figcaption></figure></p> <p><code>ふふ</code> や <code>ドーミーイン</code> などのグループホテルはなくなり、<code>オールインクルーシブ</code> がデカデカと台頭するようになりました。<br> (星野リゾート系列は一休では取り扱いがなく、ID がないので <code>界</code> , <code>星のや</code> , <code>omo</code> などが変換できず残っていますが、)全体がまるっと変わったのが見て取れます。</p> <p>このように検索需要のあるキーワードをハード変換することで精度の高い検索結果を提供する、というのがここ数ヶ月の改善です。 並行してキーワードAPI は随時改善しており、「かに」などの食材については純粋な「かに」が抽出できるようになっています。</p> <h1 id="将来の展望">将来の展望</h1> <p>システム構成を見直し、ハード変換のカバレッジを上げて今期は過ごしてきました。<br> しかし、まだまだキーワード改善の余地はたくさんあります。<br> 直近では <a href="https://user-first.ikyu.co.jp/entry/2023/08/25/184946">ChatGPT&#x306B;&#x81EA;&#x793E;&#x306E;&#x60C5;&#x5831;&#x3092;&#x7D44;&#x307F;&#x8FBC;&#x307F;&#x305F;&#x3044;&#x2460; - &#x4E00;&#x4F11;.com Developers Blog</a> で書かれているように、ChatGPT を使って検索体験を全く異なるものにできないか検証をしています。<br></p> <p>「ユーザーの頭の中にあることをそのまま検索できる」ような検索体験を提供できるよう、引き続き開発を行っていきます 🦀</p> <h1 id="さいごに">さいごに</h1> <p>一休では随時エンジニアを募集しています。<br> 上記のような検索改善に興味がある方はぜひ下記からご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>カジュアル面談も実施しているので、話だけ聞きたい!という方でもお待ちしております。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> atsumim 一休.com、Yahoo!トラベルのフロントエンドにカナリアリリースを導入しました hatenablog://entry/820878482962337198 2023-08-29T12:05:20+09:00 2023-08-29T12:05:20+09:00 はじめに 宿泊UI開発チームでソフトウェアエンジニアをしている原です。昨年の10月に入社しました。 私の所属する宿泊プロダクト開発部では主に 一休.com と Yahoo!トラベル を開発しており、今回お話するのは、両サービスのトップページ、施設一覧ページ、施設詳細ページなどの主要な導線のフロントエンドを担う Nuxt.js で作られたアプリケーションのインフラとデプロイについてです。 今回はこのアプリケーションにカナリアリリースの手法を取り入れて、より安全にリリースできるようになった話をします。 カナリアリリースとは カナリアリリースとは、複数の実行環境を用意しアプリケーションの新旧のバージ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/hxrxchang/20230829/20230829115040.jpg" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>宿泊UI開発チームでソフトウェアエンジニアをしている原です。昨年の10月に入社しました。</p> <p>私の所属する宿泊プロダクト開発部では主に <a href="https://www.ikyu.com/">一休.com</a> と <a href="https://travel.yahoo.co.jp/">Yahoo!トラベル</a> を開発しており、今回お話するのは、両サービスのトップページ、施設一覧ページ、施設詳細ページなどの主要な導線のフロントエンドを担う Nuxt.js で作られたアプリケーションのインフラとデプロイについてです。</p> <p>今回はこのアプリケーションにカナリアリリースの手法を取り入れて、より安全にリリースできるようになった話をします。</p> <h2 id="カナリアリリースとは">カナリアリリースとは</h2> <p>カナリアリリースとは、複数の実行環境を用意しアプリケーションの新旧のバージョンを同時に稼働させ、一部のユーザーに絞って新環境を公開するリリース手法です。 カナリアリリースによって新バージョンに不具合があった場合でもユーザー全体に影響を及ぼすことなく、リスクを低減してリリースすることができます。</p> <h2 id="導入のきっかけ">導入のきっかけ</h2> <p>一休では昨年から今年にかけて、宿泊プロダクトのNuxt.jsのバージョンを2系から3系にアップグレードしました。 詳しくは <a href="https://user-first.ikyu.co.jp/entry/2023/04/18/081144">一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました</a> をご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2023%2F04%2F18%2F081144" title="一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2023/04/18/081144">user-first.ikyu.co.jp</a></cite></p> <p>上記のブログ記事の「リリース戦略」の項にあるように、メインブランチの内容が反映されているNuxt2バージョンの環境と、検証用ブランチのNuxt3バージョンの環境を立てて、Fastlyでユーザーの振り分けを行っていました。</p> <p>無事にNuxt3へのバージョンアップが完了し、検証用環境がお役御免になったと思っていたところ、検索フォームの実装をまるごと置き換える大掛かりなリファクタリングが行われました。 同一アプリケーション内で条件分岐によるfeature flagは元来行われていましたが、それを差し込むのも難しいくらい大きな差分が発生するリファクタリングになりました。 そこで影響範囲が大きいリリースになるので失敗のリスクを最小限にしたいと考え、バージョンアップの検証用の環境をカナリア環境と銘打って引き続き使用することにしました。</p> <h2 id="カナリア環境の実現方法">カナリア環境の実現方法</h2> <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/h/hxrxchang/20230829/20230829113830.png" alt="&#x30AB;&#x30CA;&#x30EA;&#x30A2;&#x74B0;&#x5883;&#x3092;&#x5B9F;&#x73FE;&#x3059;&#x308B;&#x305F;&#x3081;&#x306E;&#x30B7;&#x30B9;&#x30C6;&#x30E0;&#x69CB;&#x6210;&#x56F3;" width="1200" height="494" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>システム構成図</figcaption></figure></p> <h3 id="EKS">EKS</h3> <p>宿泊プロダクト内のシステムの多くは EKS で稼働していて、この Nuxt.js アプリケーションも EKS で動いています。 クラスタ内に通常バージョンが動作しているものとは別にカナリア環境用のデプロイメントを作成し、そこでカナリアバージョンのアプリケーションを動かします。</p> <h3 id="Fastly">Fastly</h3> <p>通常環境とカナリア環境どちらにリクエストを向けるのか振り分けをFastlyで行っています。</p> <p>以下コード例です(変数やCookieは仮のものです)。</p> <pre class="code vcl" data-lang="vcl" data-unlink>sub vcl_recv { // リクエストの振り分け if (req.http.Cookie:new-environment-v1) { set req.http.new-environment-v1 = req.http.Cookie:new-environment-v1; } else { set req.http.new-environment-v1 = false; // カナリア環境に10%リクエストを振り分ける if (randombool(10, 100)) { set req.http.new-environment-v1 = true; } set req.http.new-environment-v1-new-cookie = &#34;new-environment-v1=&#34; req.http.new-environment-v1 &#34;; max-age=31536000; path=/; secure; httponly&#34;; } ... // req.backend はfastlyがリクエストを流すオリジンを指定する if (req.http.new-environment-v1 == true) { set req.backend = new-environment; } else { set req.backend = normal-environment; } } sub vcl_deliver { // Cookieの付与 if (req.http.new-environment-v1-new-cookie) { add resp.http.Set-Cookie = req.http.new-environment-v1-new-cookie; set resp.http.Cache-Control = &#34;no-store&#34;; unset req.http.new-environment-v1-new-cookie; } ... }</pre> <p>大まかな処理の流れを説明すると、</p> <ul> <li>リクエストを新旧どちらの環境に向けるのかを識別するCookieの有無を確認</li> <li>Cookieが付与されていなかったら、<a href="https://developer.fastly.com/reference/vcl/functions/randomness/randombool/">randombool関数</a> で一定の割合で新旧どちらに向けるかを決める</li> <li>新旧どちらかのオリジンにリクエストを流す</li> <li>新環境へリクエストした場合、次回リクエスト時も新環境へ向けられるようにCookieを付与</li> </ul> <p>という流れになっています。 このアプリケーションは初回リクエスト以降はNuxtサーバーへのリクエストが不要なSPAになっているため、旧環境へ向いていたが動作中に新環境へリクエストしてしまい動作に不具合が生じるといったことも起こりません。 Nuxtのビルド成果物はS3にアップロードしており、アプリケーションが必要とする静的ファイルはカナリアリリースとは関係なく取得することができます。</p> <h2 id="運用方法">運用方法</h2> <h3 id="アプリケーションリリース方法">アプリケーションリリース方法</h3> <p>通常バージョンのアプリケーションはreleaseブランチへのマージを契機にCIで自動的にimageをビルド、pushをしてリリースされます。 カナリアバージョンも同様に<code>canary-release</code>ブランチへのマージを契機にカナリア環境へリリースされます。</p> <h3 id="スケールインスケールアウト">スケールイン、スケールアウト</h3> <p>カナリアリリースを使用していない間はインフラコストの削減のため、podの最小レプリカ数を最小限にしています。 カナリアリリース開始時に流すリクエストの割合に応じてpodの最小レプリカ数を引き上げます。</p> <h3 id="リクエスト割合の調整">リクエスト割合の調整</h3> <p>上述のvclのrandomboolの割合を変更します。 この際に割合が正しく反映されるように<code>new-environment-v1</code> といった変数やCookieのsuffixのバージョンもインクリメントさせる必要があります。</p> <p>以下カナリア環境へのリクエスト割合を10%から0%に変更する際のコード差分の例です。</p> <pre class="code lang-diff" data-lang="diff" data-unlink> sub vcl_recv { <span class="synSpecial">- if (req.http.Cookie:new-environment-v1) {</span> <span class="synSpecial">- set req.http.new-environment-v1 = req.http.Cookie:new-environment-v1;</span> <span class="synIdentifier">+ if (req.http.Cookie:new-environment-v2) {</span> <span class="synIdentifier">+ set req.http.new-environment-v2 = req.http.Cookie:new-environment-v2;</span> } else { set req.http.new-environment-v1 = false; <span class="synSpecial">- if (randombool(10, 100)) {</span> <span class="synSpecial">- set req.http.new-environment-v1 = true;</span> <span class="synIdentifier">+ if (randombool(0, 100)) {</span> <span class="synIdentifier">+ set req.http.new-environment-v2 = true;</span> } <span class="synSpecial">- set req.http.new-environment-v1-new-cookie = &quot;new-environment-v1=&quot; req.http.new-environment-v1 &quot;; max-age=31536000; path=/; secure; httponly&quot;;</span> <span class="synIdentifier">+ set req.http.new-environment-v2-new-cookie = &quot;new-environment-v1=&quot; req.http.new-environment-v2 &quot;; max-age=31536000; path=/; secure; httponly&quot;;</span> } ... <span class="synSpecial">- if (req.http.new-environment-v1 == true) {</span> <span class="synIdentifier">+ if (req.http.new-environment-v2 == true) {</span> set req.backend = new-environment; } else { set req.backend = normal-environment; } </pre> <p>このように割合変更(カナリア環境へのリクエストを取りやめることも含む)をする度に、コードの修正が必要になります。</p> <h2 id="カナリアリリース導入の効果">カナリアリリース導入の効果</h2> <p>Nuxtをはじめとしたライブラリのバージョンアップや、コード差分が大きく影響範囲の大きなリリースに対するリスクを大幅に軽減できるようになりました。 またこのNuxtアプリケーションは、宿泊事業部内の複数チームが開発しているのですが、カナリアリリースの概要やリリース手順書といったドキュメントの作成をしたり、リリースの都度レクチャーすることで、どのチームもカナリアリリースによって安全にデプロイできるようになりました。</p> <h2 id="課題">課題</h2> <p>上述の通りカナリア環境へのリクエスト割合を変更するたびにVCLの変数のsuffixを変更する必要があり、そこそこ面倒な作業になっています。 また、カナリアリリース実施中に通常環境のリリースがあった際にその内容をカナリア環境に取り込む必要があります。そのため常に通常バージョンのリリース動向をチェックする必要があり、これもそれなりの負担があります。 これらを自動化などでコストを軽減できないかと模索中です。</p> <h2 id="最後に">最後に</h2> <p>一休ではプロダクトの機能開発をしながらボトムアップで開発基盤の改善もしたい!というエンジニアを大募集中です。 カジュアル面談も実施しているので、お気軽にご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> hxrxchang ChatGPTに自社の情報を組み込みたい① hatenablog://entry/820878482961447685 2023-08-25T18:49:46+09:00 2023-08-25T18:49:46+09:00 はじめに こんにちは、一休.comデータサイエンス部の平田です。 みなさんChatGPT活用してますか? 一エンジニアとして便利に使ってはいるものの、自社サービスにどのように組み込もうか模索しているところも多いかもしれません。 一番の利用先として思いつくのが、自社の情報をもとに質問に答えるチャットボットではないでしょうか。 その中では、ハイコンテキストな検索(例えば、「東京から2時間以内で子供も楽しめるアクティビティがあって、景色も良い宿」のような)にも答えられるとボットの価値が増します。 ChatGPTが事前に学習した内容では古く、正確ではないためそういった検索に応えるのはかなり厳しいです。… <h1 id="はじめに">はじめに</h1> <p>こんにちは、一休.comデータサイエンス部の平田です。 みなさんChatGPT活用してますか?</p> <p>一エンジニアとして便利に使ってはいるものの、自社サービスにどのように組み込もうか模索しているところも多いかもしれません。 一番の利用先として思いつくのが、自社の情報をもとに質問に答えるチャットボットではないでしょうか。</p> <p>その中では、ハイコンテキストな検索(例えば、「東京から2時間以内で子供も楽しめるアクティビティがあって、景色も良い宿」のような)にも答えられるとボットの価値が増します。 ChatGPTが事前に学習した内容では古く、正確ではないためそういった検索に応えるのはかなり厳しいです。</p> <p>そのため、こちら側が持っているデータを渡してあげる必要があるのですが、今回はその自社の情報をどう組み込むのか、という部分についてご紹介します。</p> <h1 id="素のChatGPTでは">素のChatGPTでは?</h1> <p>ChatGPTに例えば「熱海で子供も楽しめるアクティビティがあって、景色も良い宿を教えてください」 を聞いてそのまま納得できる内容が返ってくるなら、組み込むだけで済むのでかなり楽です。 まずは試してみます。</p> <pre class="code" data-lang="" data-unlink>熱海は温泉地として有名で、子供から大人まで楽しめる観光地がたくさんあります。以下は、子供も楽しめるアクティビティと景色の良い宿泊施設の一例です。 アクティビティ: 熱海海洋公園: 水族館で、色とりどりの魚やイルカショーなどが楽しめます。 熱海梅園: 季節によっては梅の花が楽しめる庭園で、散策やピクニックが楽しめます。 熱海サンビーチ: 夏場は海水浴が楽しめるビーチで、子供たちに人気です。 MOA美術館: アートに興味を持つ家族には、美術館の散策もおすすめです。 宿泊施設: アタミセイザンリゾート: 子供向けのプールや遊び場があり、家族連れに人気。部屋からの海の眺めも素晴らしいです。 ホテルニュー熱海: 温泉が楽しめる宿で、子供用のアメニティも充実しています。海を一望できる部屋もあります。 熱海温泉 ホテルマイクローネ: 子供向けのプレイルームやプールがあり、温泉も楽しめます。部屋からの景色も良いです。 これらの施設は、子供向けのアクティビティと素晴らしい景色を楽しめるため、家族旅行にぴったりです。予約時には、子供向けの設備やサービスがあることを確認すると良いでしょう。最新の情報や詳細は、公式ウェブサイトや旅行サイトで確認することをおすすめします。</pre> <p>アクティビティと宿を出してくれるのでぱっと見は良さそうですが、熱海海洋公園はありませんし、宿泊施設に至っては3つともありません。 ChatGPTの学習データは2021年9月までのものなので、かつてはあったのかもしれません。 やはり、最新性のある情報を提供させるために、こちらから与えてあげる必要がありそうです。</p> <h1 id="情報を組み込む方法">情報を組み込む方法</h1> <h2 id="ファインチューニング">ファインチューニング</h2> <p>質問文と答えの対を大量に用意し、それを事前学習されたモデルに対してさらに学習させることでオリジナルのモデルを作る方法があります。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fplatform.openai.com%2Fdocs%2Fguides%2Ffine-tuning" title="OpenAI Platform" 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://platform.openai.com/docs/guides/fine-tuning">platform.openai.com</a></cite></p> <p>質問文と答えの対を人手で作るのも難しいので、ChatGPTにやらせます。</p> <pre class="code" data-lang="" data-unlink>response = openai.Completion.create( engine=COMPLETIONS_MODEL, prompt=f&#34;&#34;&#34;次の複数の口コミから、50文字以内の日本語の質問文を1つ生成してください。 \n\nテキスト: {context}\n\n質問文:\n1.&#34;&#34;&#34;, temperature=0.8, max_tokens=400, top_p=1, stop=[&#34;\n\n&#34;] ) prompt=f&#34;次のテキストに基づいて質問に答えてください\n\nテキスト: {row.context}\n\n質問文:\n{row.questions}\n\n答え:\n1.&#34;,</pre> <p>ただ、質の高い対を大量に作るのは難しく、学習としてもあまりいい結果になりませんでした。(例はcurieモデル) <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/i/ikyu_com/20230825/20230825115601.png" width="821" height="436" 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/i/ikyu_com/20230825/20230825115703.png" width="847" height="445" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>上手く行かなかった例(こちらを向かう海を渡る…?)</figcaption></figure> 8/22にcompletionモデルではなく、chatのモデルである gpt-3.5-turbo-0613 を対象にしたファインチューニングができるようになりました。 chatのモデルで行うともしかしたらいい結果が得られるかもしれません。</p> <p>(↓見ると難しそうですが…)</p> <p><a href="https://zenn.dev/ml_bear/articles/49ed93d33e69cc">ChatGPT &#x306E; Fine-tuning &#x3092;&#x8A66;&#x3057;&#x305F;&#x3051;&#x3069;&#x4E0A;&#x624B;&#x304F;&#x3044;&#x304B;&#x306A;&#x304B;&#x3063;&#x305F;&#x8A71;</a></p> <h2 id="埋め込みベクトル表現">埋め込みベクトル表現</h2> <p>ファインチューニング以外にも情報を渡す方法としては、プロンプトに必要な情報がまとまった文章を加えておき、 それに基づいて文章を生成してもらうというものがあります。</p> <p>そのためには、「どの情報を渡すか」という部分をこちらで選択する必要があります。 ChatGPTのtoken数の制限、価格を考慮すると全てを渡すことはできません。 必要最低限の量を渡す方法として、クエリをベクトル化して、あらかじめベクトル化した情報と類似度が高いものだけをプロンプトに加えるようにします。</p> <p>ファインチューニングと比較したとき、渡す情報をコントロールしやすいメリットがある反面、プロンプトが肥大化しやすいというデメリットがあります。</p> <h3 id="検証">検証</h3> <p>今回は、口コミの中から必要な情報だけを抽出できるのか?というところをトライしてみます。 ベクトルで類似度をスコアリングしたのち、各項目について言及しているかどうかを正規表現で正誤判定させることにしています。</p> <p>検証ではOpenAIのembedding APIを使用しています。対象は単語になっていますが、任意の文章をベクトル化することができます。</p> <p>また、英語だと精度が良いらしいですが、ベクトルマッチングにおいてもそうなのかついでに調べてみます。 ちなみに翻訳にはdeepL APIを使いました。</p> <pre class="code" data-lang="" data-unlink>querys = {&#39;朝食&#39;: [&#39;朝食&#39;, &#39;朝ごはん&#39;, &#39;朝ご飯&#39;, &#39;朝御飯&#39;, &#39;朝餉&#39;, &#39;ブレックファースト&#39;], &#39;ペット&#39;: [&#39;ペット&#39;, &#39;犬&#39;, &#39;わんこ&#39;, &#39;ドッグ&#39;, &#39;わんちゃん&#39;,&#39;愛犬&#39;,&#39;ワンコ&#39;,&#39;ワンちゃん&#39;], &#39;花火&#39;: [&#39;花火&#39;], &#39;絶景&#39;: [&#39;絶景&#39;, &#39;景色がいい&#39;, &#39;景色が良い&#39;, &#39;景色のいい&#39;, &#39;景色の良い&#39;], &#39;バリアフリー&#39;: [&#39;バリアフリー&#39;, &#39;車椅子&#39;, &#39;車いす&#39;, &#39;足が悪い&#39;, &#39;脚が悪い&#39;], &#39;有名建築家&#39;: [&#39;有名建築家&#39;,&#39;隈研吾&#39;,&#39;安藤忠雄&#39;,&#39;北川原温&#39;,&#39;坂茂&#39;,&#39;山口隆&#39;,&#39;岸本和彦&#39;]} target_query = &#39;有名建築家&#39; target_vec = get_embedding([target_query])[0][&#34;embedding&#34;] target_query_eng = translate_text([target_query])[0] target_eng_vec = get_embedding([target_query_eng])[0][&#34;embedding&#34;] df[&#39;test&#39;] = df.review_text.apply(lambda x: any([q in x for q in querys[target_query]])) df[&#39;jpn_score&#39;] = df.embedding.apply(lambda x: calc_cossim(target_vec, x)) df[&#39;eng_score&#39;] = df.embedding_english.apply(lambda x: calc_cossim(target_eng_vec, x))</pre> <p>ROC曲線は、スコアの閾値を0~1で動かしたときに、横軸に疑陽性の率、縦軸に真陽性の率をプロットしたものです。 下側の面積をAUCと呼び、1なら完全な分類、0.5ならランダムな分類と同程度の精度だと評価されます。</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/i/ikyu_com/20230825/20230825120900.png" width="732" height="558" 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/i/ikyu_com/20230825/20230825120927.png" width="734" height="560" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>朝食(英語翻訳)</figcaption></figure></p> <table> <thead> <tr> <th> 項目 </th> <th> 日本語 </th> <th> 英語翻訳 </th> </tr> </thead> <tbody> <tr> <td> 朝食 </td> <td> 0.83 </td> <td> 0.88 </td> </tr> <tr> <td> ペット </td> <td> 0.92 </td> <td> 0.85 </td> </tr> <tr> <td> 花火 </td> <td> 0.95 </td> <td> 0.97 </td> </tr> <tr> <td> 景色 </td> <td> 0.90 </td> <td> 0.83 </td> </tr> <tr> <td> バリアフリー </td> <td> 0.63 </td> <td> 0.78 </td> </tr> <tr> <td> 有名建築家 </td> <td> 0.95 </td> <td> 0.95 </td> </tr> </tbody> </table> <p>単語によって、日本語が良かったり英語が良かったりバラバラですね。 明らかに英語の精度が良くなるかと思ったので意外でした。 有名建築家は正解データ数が少ないので両者高くなっています。</p> <h3 id="実際の口コミ抽出例">実際の口コミ抽出例</h3> <p>施設、キーワードを入力するとマッチした口コミを返すAPIを作りました。 口コミの一部分を返すことで、プロンプトが長くなるのを防ぐ工夫もしています。</p> <p>以下はkeyword=温泉をとある施設の口コミを対象に入れたときの一例です。</p> <p>一つ目は「温泉」がちゃんと入っていますね。二つ目は温泉に近い「温水プール」が入っています。</p> <pre class="code" data-lang="" data-unlink>{ &#34;hotel_id&#34;: &#34;00002627&#34;, &#34;review_id&#34;: &#34;1000022712&#34;, &#34;review_text&#34;: &#34;3連泊させていただきました。主にホテルにこもって過ごすことを前提にお伺いしましたが、客室はとても居心地がよく、朝焼けがとても綺麗に見えました。 また、プールもこじんまりとしていますが十分楽しめました。 特に23時までオープンしていることからナイトプールはとても綺麗なライティングで、夜空も綺麗に眺めることができました。 また、料理のレベルが高く、味も見た目にも楽しめるものばかりでした。 盛り付けはとってもオシャレでした(部屋食もよかった)。 接客は過度なものはなく、他のホテルに比べるとややあっさりした印象ですが、感じの良い方達ばかりでした。 温泉・お風呂は星4つにしたのですが、特段不満に感じることはありませんでした(水圧、綺麗さなどどれも満足)。 館内至るところ、アロマの香りが楽しめたのもよかったです。またお伺いしたいです。ありがとうございました。&#34;, &#34;score&#34;: 0.75787675, &#34;matches&#34;: [ { &#34;match_text&#34;: &#34;温泉・お風呂は星4つにしたのですが、特段不満に感じることはありませんでした(水圧、綺麗さなどどれも満足)。館内至るところ、アロマの香りが楽しめたのもよかったです。&#34;, &#34;positive_score&#34;: 0.89829713, &#34;score&#34;: 0.75787675 }, { &#34;match_text&#34;: &#34;また、プールもこじんまりとしていますが十分楽しめました。&#34;, &#34;positive_score&#34;: 0.89906454, &#34;score&#34;: 0.71830595 } ] }, { &#34;hotel_id&#34;: &#34;00002627&#34;, &#34;review_id&#34;: &#34;1001226432&#34;, &#34;review_text&#34;: &#34;10月30日宿泊\n客室はゆったりしていて寛げる\n温水プールは温かく景色が良い\n レストランはおしゃれな空間で\n雰囲気が良い\nスタッフは一生懸命で好感が持てる\n来月またお世話になります&#34;, &#34;score&#34;: 0.7502389, &#34;matches&#34;: [ { &#34;match_text&#34;: &#34;10月30日宿泊\n客室はゆったりしていて寛げる\n温水プールは温かく景色が良い&#34;, &#34;positive_score&#34;: 0.779632, &#34;score&#34;: 0.7502389 }, { &#34;match_text&#34;: &#34;レストランはおしゃれな空間で\n雰囲気が良い&#34;, &#34;positive_score&#34;: 0.79234755, &#34;score&#34;: 0.7080128 } ] }</pre> <h1 id="まとめ">まとめ</h1> <p>今回は自社の情報をChatGPTに組み込む方法をご紹介しました。</p> <p>しかし実はまだ、冒頭の「熱海で子供も楽しめるアクティビティがあって、景色も良い宿を教えてください」に応えられるものが出来ていません。 これは、全国の施設情報をベクトルマッチングで一部に絞っていてもなお量が多くてプロンプトに埋め込むことができないからです。</p> <p>解決方法についてはChatGPTの次の記事でお伝えできればと思います。</p> <p>また一休では、ともに良いサービスをつくっていく仲間を積極募集中です!応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206469" title="データサイエンティスト(正社員) | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1693126708022206469">hrmos.co</a></cite></p> ikyu_com マイクロサービス/サービス分割をテーマにしたイベントを開催しました。 hatenablog://entry/820878482951331289 2023-07-20T14:49:47+09:00 2023-07-20T14:49:47+09:00 2023/719に、TECH PLAYでマイクロサービス/サービス分割をテーマにしたイベントを開催しました。 techplay.jp 発表資料はこちらです。ぜひご覧ください。 speakerdeck.com speakerdeck.com <p>2023/719に、TECH PLAYでマイクロサービス/サービス分割をテーマにしたイベントを開催しました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechplay.jp%2Fevent%2F908123" title="技術負債に立ち向かうエンジニアを助けるマイクロサービス化 - 一休の7年間の取り組み事例から紐解く成功と失敗を分けるポイント -|IT勉強会・イベントならTECH PLAY[テックプレイ]" 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://techplay.jp/event/908123">techplay.jp</a></cite></p> <p>発表資料はこちらです。ぜひご覧ください。</p> <p><iframe id="talk_frame_1052945" class="speakerdeck-iframe" src="//speakerdeck.com/player/3659da61db98470c93350651a9da6974" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/tokutakesatoshi/xiu-dot-comnoakitekutiyabian-qian-karakao-erusabisufen-ge-nokan-suo">speakerdeck.com</a></cite></p> <p><iframe id="talk_frame_1052943" class="speakerdeck-iframe" src="//speakerdeck.com/player/81b3a9e036f5456cab9968b3d86b15a5" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/eguchij/xin-tanamaikurosabisuqu-rizu-minoshi-li">speakerdeck.com</a></cite></p> s-tokutake 一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました hatenablog://entry/4207112889980332447 2023-04-18T08:11:44+09:00 2023-04-18T17:23:01+09:00 CTO室プラットフォーム開発チームの山口(@igayamaguchi)です。 プラットフォーム開発チームではさらに内部でプロジェクトチームが分かれており、私はフロントエンド改善チームというチームでリーダーをしています。 フロントエンド改善チームでは主に一休.com、Yahoo!トラベルのフロントエンドの改善を行っております。 今回は一休.com、Yahoo!トラベルで使用しているNuxtのバージョンを2から3にアップグレードしたお話をさせていただきます。 一休.com、Yahoo!トラベルではトップページや検索ページ、ホテル・旅館の詳細ページなど主要なページのフロントエンドはNuxtで開発され… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/igatea/20230418/20230418053835.png" width="1200" height="853" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>CTO室プラットフォーム開発チームの山口(<a href="https://twitter.com/igayamaguchi">@igayamaguchi</a>)です。<br/> プラットフォーム開発チームではさらに内部でプロジェクトチームが分かれており、私はフロントエンド改善チームというチームでリーダーをしています。<br/> フロントエンド改善チームでは主に一休.com、Yahoo!トラベルのフロントエンドの改善を行っております。</p> <p>今回は一休.com、Yahoo!トラベルで使用しているNuxtのバージョンを2から3にアップグレードしたお話をさせていただきます。</p> <p>一休.com、Yahoo!トラベルではトップページや検索ページ、ホテル・旅館の詳細ページなど主要なページのフロントエンドはNuxtで開発されています。<br/> NuxtのバックエンドにはGo+gqlgenでGraphQLのサーバーを立てており、NuxtからはApolloを使用してバックエンドと通信を行っています。<br/> このNuxtのバージョンは2となっており、それを今回3にアップグレードしました。</p> <h3 id="なぜバージョンを上げたのか">なぜバージョンを上げたのか</h3> <p>話は2022年7月半ばにさかのぼります。<br/> 当時は宿泊事業におけるフロントエンド開発の課題について議論をしていました。<br/> 一休.com/Yahoo!トラベルではフロントエンドのコードの共通化を行っており、同じリポジトリ内の同じコードで両サイトを実現しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2021%2F11%2F30%2F164400" title="Yahoo! トラベルと一休.com のシステム統合プロジェクト - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2021/11/30/164400">user-first.ikyu.co.jp</a></cite></p> <p>しかし当初はうまくいっていた一休.com/Yahoo!トラベルのコード共通化、コンポーネント共通化も課題が生まれ、開発速度が大きく落ちてきていました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/igatea/20230413/20230413020809.png" width="960" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>上記はフロントエンドの現状のコードの問題をレポーティングしたときの資料から抜粋したものです。 <br/> 上記画像のようにコードの重複が多く発生していました。(SDとは一休社内におけるスマホを指す用語です)<br/> 何か変更をしようとすると一休.com/Yahoo!トラベルとPC/スマホで別々になったコードに変更を加える必要があり変更箇所が4倍になるようになっていました。</p> <p>この問題に対してデザインシステムを作ることで小さなUIコンポーネントやスタイルの共通化をすることが出来ました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2022%2F06%2F22%2F142755" title="デザインシステム導入しました - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2022/06/22/142755">user-first.ikyu.co.jp</a></cite></p> <p>しかしVueの状態やGraphQLに絡んだ処理などロジックに関連する処理の共通化がまだうまくいっていませんでした。<br/> これはうまく抽象化、共通化する手段がNuxt2/Vue2では提供されていないことが問題でした。<br/> Mixinなどを使えば可能ではありますが、Mixinでは見通しが悪くなる可能性が高く暗黙的な抽象化が行われあまりよい形にはなりません。<br/> そこでVue3で登場したComposition APIです。<br/> <a href="https://vuejs.org/guide/introduction.html#composition-api">https://vuejs.org/guide/introduction.html#composition-api</a><br/> これを使うことで処理の共通化が行いやすくなり、コードの無用な重複も解消することができます。</p> <p>Nuxt3/Vue3に移行すると他にも利点があり、以下に簡単に記します。</p> <ul> <li>設計を全く違うものにできる <ul> <li>Composition APIを使うことができると設計が全く変わる。より良い抽象化、共通化が可能になる</li> </ul> </li> <li>状態管理の選択肢が増える。(useState, Piniaなど)</li> <li>型が効いて安全に <ul> <li>Vue3に上がることでVolar(vue-tsc)と組み合わせて型が効くように</li> </ul> </li> <li>開発環境が爆速に <ul> <li>Viteが本当に早い</li> </ul> </li> </ul> <p>他にもSuspense、Teleport、ビルドの最適化、などいいことはたくさん。<br/> 色々利点がありますがやはり設計が改善できる、ということが大きいです。<br/> Nuxt3の方がより良い方法でコンポーネント、状態管理の設計ができるため移行を決断したのが7月末です。<br/> 8月くらいから実際に移行に向けての3人のチームメンバーで作業を進めて2月頭にリリースしました。 (最初期は4人で途中からメンバーの異動があったため基本的に3人)</p> <h3 id="移行指針">移行指針</h3> <p>まずは最初に移行の指針を立てました。<br/> それは <strong>最低限のコストで移行する</strong> というものです。<br/> Nuxt3にアップグレードする理由はより良い設計にして開発をしやすいようにするためです。<br/> 移行しながらきれいな書き方にすることもできますが、その場合移行期間が延びるのと、リリース時にビッグバンリリースとなってしまいます。<br/> 移行のコストとリスクを最小限にするためにこの指針を立て、プロジェクト進行時には常にこの指針に従い意思決定を行っていきました。</p> <h3 id="移行戦略">移行戦略</h3> <p>次に移行戦略を立てました。<br/> Nuxt3/Vue3のドキュメントを読み、Nuxt3/Vue3の破壊的変更を洗い出し、タスクの一覧化、移行戦略を立てました。<br/> 移行戦略では以下のことを定めました。</p> <ul> <li>Nuxt Bridgeを使うか</li> <li>タスクの切り分け</li> <li>ブランチ運用</li> <li>ミニマムに検証をする</li> <li>リリース戦略</li> </ul> <h4 id="Nuxt-Bridgeを使うか">Nuxt Bridgeを使うか</h4> <p>NuxtではNuxt Bridgeというモジュールが提供されています。<br/> <a href="https://github.com/nuxt/bridge">https://github.com/nuxt/bridge</a> <br/> Nuxt2に対してこれを導入することで、Nuxt2でありながらNuxt3の機能を利用できる前方互換性のあるレイヤーです。<br/> これを使うことでNuxt2のままNuxt3に対応するコードに徐々に移行するということができます。<br/> しかし私たちはこれを使わない選択をしました。</p> <p>1つ目の理由はNuxt Bridgeに対応していないモジュールがあることです。<br/> 私たちの使用しているNuxt moduleでは、Tailwind CSSなどが例として挙げられます。<br/> そういったモジュールにどういった対応が必要になるのか、そこの対応にどのくらいの時間がかかるか予測できませんでした。<br/> 最悪開発が止まりリリースができなくなる可能性がありました。</p> <p>2つ目の理由は二度テストを行う必要があることです。<br/> 上記のモジュール対応もあり、移行時にはサイト全体のテストを行う必要があります。<br/> Nuxt2からNuxt Bridgeに移行するときとNuxt BridgeからNuxt3に移行するときで2度テストを行わなければいけません。</p> <p>これらの理由によりNuxt Bridgeを使わずNuxt3への移行を行いました。<br/> この決断は後に意思決定もシンプルにすることができ非常によかったです。<br/> あらゆる対応がNuxt Bridge、Nuxt3両方を意識するのではなくシンプルにNuxt3のことだけを考えればよくなりコストが下がりました。</p> <h4 id="タスクの切り分け">タスクの切り分け</h4> <p>アップグレードをするためにタスクを対応タイミングに応じて切り分けを行いました。<br/> 大まかには以下の3つです</p> <ul> <li>Nuxt2/Vue2の状態で対応</li> <li>Nuxt3/Vue3の状態で対応</li> <li>リリース前に対応</li> </ul> <p>まず1つ目のNuxt2/Vue2の状態で対応可能なものです。<br/> Nuxt3/Vue3の破壊的変更はNuxt2/Vue2の状態でも対応できるものがあります。<br/> 例えばfunctionalコンポーネントがVue3で廃止されたためfunctionalコンポーネントを通常のコンポーネントに変更したり、Vueのfiltersが廃止されたため関数に書き換える、といったものです。</p> <p>2つ目はNuxt3の状態で対応可能なものです。<br/> 1つ目を行ってからNuxt2/Vue2のときに対応できないNuxt3/Vue3の破壊的変更の対応です。<br/> 例えばモジュールやプラグインの書き換えです。</p> <p>3つ目はリリース前に対応するものです。<br/> これはテスト、画面の確認などです。</p> <p>さらにタスクとは別にこのプロジェクトでやらないことも明記してリスト化するようにしました。<br/> 例えばNuxt3/Vue3に移行することでComposition APIが使えるようになるわけですが、「変更を最小限にするために移行時にComposition APIへの書き換えは行わない」、というように理由とともにやらないことを明記してリスト化していました。<br/> 実際に作業を進める上でもチームで議論して都度都度これは今やるべきではないとなったものを追加していました。</p> <h4 id="ブランチ運用">ブランチ運用</h4> <p>作業を進めるために作業ブランチをどうやって運用するかを決めました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/i/igatea/20230411/20230411213334.png" width="1112" height="372" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>前述のタスクの切り分けを基に以下のようなブランチ運用を行うようにしました。</p> <ul> <li>破壊的変更に対してNuxt2/Vue2の時点で対応できるものをまず対応してmasterマージ</li> <li>事前対応できるものが終わり次第Nuxt3移行用のブランチを作成、そのブランチに対して残りの破壊的変更の対応をマージ</li> <li>各施策のブランチがmasterにマージされた場合、適宜Nuxt3移行用のブランチに取り込み</li> </ul> <p>このようにブランチを運用することでNuxt3移行リリース時にマージするコードの変更は最小限になるようにしました。</p> <h4 id="ミニマムに検証する">ミニマムに検証する</h4> <p>Nuxt3/Vue3を初めて触る際は様々な挙動がわからず悩む時があります。<br/> そういったものを実際のプロダクトで検証しようとすると手間です。<br/> そのため最小限でプロダクションに近しいNuxt3のboilerplateを用意し、そこで様々な実装の検証を行うようにしました。</p> <h4 id="リリース戦略">リリース戦略</h4> <p>本番リリースでは一休.com、Yahoo!トラベルそれぞれを順々に公開するようにしました。</p> <ul> <li>一休.comで10%のユーザーに公開</li> <li>問題がなければ一休.comで100%のユーザーに公開</li> <li>問題がなければYahoo!トラベルで10%のユーザーに公開</li> <li>問題がなければYahoo!トラベルで100%のユーザーに公開</li> </ul> <p>ユーザーの振り分けはFastlyで行い、あるユーザーは常にNuxt2、あるユーザーは常にNuxt3を見るように設定を行いました。<br/> この時、一部のページ、例えばトップページだけNuxt3を公開する、ということはしませんでした。<br/> 理由はNuxtがSPA的なJSを使用したページ遷移がありそれを振り分けるのは難しいと判断したためです。</p> <p>このように移行戦略を立ててNuxt3へのアップグレードを開始しました。</p> <h3 id="作業を開始する前に">作業を開始する前に</h3> <p>ここまでで戦略を立てて作業を始める準備は整ったのですが、ここでもう一つ作業を開始する前に取り組んだことがあります。<br/> それがNuxt3/Vue3のドキュメントをチームで読み漁ったことです。<br/> プロジェクトを始めた当初はNuxt3/Vue3について解像度が低く当初に洗い出したタスクで過不足がないか、どのくらい時間がかかるのかはかなり不透明だったため、解像度を上げるためにやってみようということで始めました。<br/> 毎朝1時間~1時間半ほどチームでZoomで集まりドキュメントを画面共有しながら読みました。<br/> チームメンバーはNuxt2/Vue2の開発経験はあったため、Vueはマイグレーションガイド(<a href="https://v3-migration.vuejs.org/">https://v3-migration.vuejs.org/</a> )を、Nuxt3は頭からドキュメント(<a href="https://nuxt.com/">https://nuxt.com/</a> )を読んで機能差分について話しながら読み進めました。(当時Nuxt3はマイグレーションガイドが作成途中の項目が薄かったためドキュメントを頭から読んでいます。今なら <a href="https://nuxt.com/docs/migration/overview">https://nuxt.com/docs/migration/overview</a> を読めばよいと思います)<br/> 結果チームメンバーそれぞれに基礎知識がつきタスクや対応方法の議論が活発になる土壌を作れたためよかった試みでした。</p> <h3 id="移行作業">移行作業</h3> <p>ここからは実際に行った移行作業の具体的なお話をします。<br/> とはいえ基本的には前述のNuxt、Vueそれぞれのマイグレーションガイドを読み、破壊的変更となるものを対応しています。<br/> そこで今回、私たちが躓いたものや独自の対応をしているものを紹介させていただきます。</p> <h4 id="defineComponentdefineNuxtComponent">defineComponent/defineNuxtComponent</h4> <p>Nuxt2/Vue2でVueコンポーネントを作成するときに <code>Vue.extend</code> を使っていましたが、Nuxt3では <code>defineNuxtComponent</code> を使う必要があります。<br/> <a href="https://nuxt.com/docs/api/utils/define-nuxt-component">https://nuxt.com/docs/api/utils/define-nuxt-component</a><br/> 今回、破壊的変更は別ブランチを立てて変更をしていくためmasterブランチマージ時に発生する変更は小さくしたいです。<br/> そのためmasterブランチ上でも <code>defineNuxtComponent</code> を使用出来るようにaliasとなる関数を用意しました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">/**</span> <span class="synComment"> * defineComponent</span> <span class="synComment"> */</span> <span class="synStatement">import</span> Vue <span class="synStatement">from</span> <span class="synConstant">'vue'</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> VueConstructor <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'vue/types'</span> <span class="synStatement">type</span> VueExtend <span class="synStatement">=</span> VueConstructor<span class="synIdentifier">[</span><span class="synConstant">'extend'</span><span class="synIdentifier">]</span> <span class="synComment">// @ts-ignore</span> <span class="synType">const</span> defineComponent: VueExtend <span class="synStatement">=</span> <span class="synStatement">(</span>options<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> Vue.extend<span class="synStatement">(</span>options<span class="synStatement">)</span> <span class="synStatement">export</span> <span class="synIdentifier">{</span> defineComponent <span class="synIdentifier">}</span> <span class="synComment">/**</span> <span class="synComment"> * dynamic import</span> <span class="synComment"> */</span> <span class="synStatement">export</span> <span class="synStatement">function</span> defineAsyncComponent<span class="synStatement">(</span>f: <span class="synSpecial">Function</span><span class="synStatement">)</span>: <span class="synSpecial">Function</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> f <span class="synIdentifier">}</span> </pre> <p>これを用いてmasterブランチに各コンポーネントを <code>Vue.extend</code> から <code>defineComponent</code> に書き換えました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> defineComponent <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'~/nuxt3-alias'</span> <span class="synStatement">export</span> <span class="synStatement">default</span> defineComponent<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synComment">// 中のoption apiはそのまま</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> </pre> <p>Nuxt3移行ブランチでは以下のようにaliasを差し替えるだけでよいのでリリース時の変更が最小限になります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> defineAsyncComponent <span class="synStatement">as</span> _defineAsyncComponent <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'vue'</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> defineNuxtComponent <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'#imports'</span> <span class="synType">const</span> defineComponent <span class="synStatement">=</span> defineNuxtComponent <span class="synStatement">export</span> <span class="synIdentifier">{</span> defineComponent <span class="synIdentifier">}</span> <span class="synType">const</span> defineAsyncComponent <span class="synStatement">=</span> _defineAsyncComponent <span class="synStatement">export</span> <span class="synIdentifier">{</span> defineAsyncComponent <span class="synIdentifier">}</span> </pre> <h4 id="fetchフック">fetchフック</h4> <p>Nuxt2ではfetchフックという各コンポーネントで非同期にデータ取得、設定を行うことができる専用のhookが用意されています。 <a href="https://nuxtjs.org/docs/components-glossary/fetch/">https://nuxtjs.org/docs/components-glossary/fetch/</a></p> <p>これがNuxt3では使うことが出来ないようになっていました。 <code>useAsyncData</code> <code>useFetch</code> というものが使えるようになっていてそちらに置き換えが必要です。<br/> <a href="https://v3.nuxtjs.org/getting-started/data-fetching">https://v3.nuxtjs.org/getting-started/data-fetching</a><br/> しかし <code>useAsyncData</code> <code>useFetch</code> を使うにはComposition APIへの書き換えが必要です。<br/> これには時間がかかることが予想されたのとmasterブランチとの差分が大きくなってしまうのでfetchフックをそのままにどうにかできないか検討しました。<br/> 最終的には以下のようなプラグインを作り、fetchメソッドをそのまま解釈できるようにして対応しました。<br/> 難点が1つあって、fetchフックを使用するときは<code>nuxt2FetchKey</code> というキーを定義する必要があるのと、fetchフックを使用するコンポーネントが画面で複数個所で呼ばれないことを前提にしています。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synStatement">default</span> defineNuxtPlugin<span class="synStatement">((</span>nuxt<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> nuxt.vueApp.mixin<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synStatement">async</span> serverPrefetch<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>hasFetch<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">))</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">}</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synIdentifier">this</span>.$options.fetchOnServer <span class="synStatement">===</span> <span class="synConstant">false</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">}</span> <span class="synStatement">await</span> <span class="synIdentifier">this</span>.$options.<span class="synSpecial">fetch</span>.call<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">)</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>nuxt.payload.data<span class="synStatement">)</span> <span class="synIdentifier">{</span> nuxt.payload.data <span class="synStatement">=</span> <span class="synIdentifier">{}</span> <span class="synIdentifier">}</span> nuxt.payload.data<span class="synIdentifier">[this</span>.$_fetchKey<span class="synIdentifier">]</span> <span class="synStatement">=</span> <span class="synIdentifier">this</span>.$data <span class="synIdentifier">this</span>.$_fetchResolve<span class="synStatement">()</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> created<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>hasFetch<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">))</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">}</span> <span class="synIdentifier">this</span>.$_fetchKey <span class="synStatement">=</span> <span class="synIdentifier">this</span>.$options.nuxt2FetchKey <span class="synIdentifier">this</span>.$_fetchPromise <span class="synStatement">=</span> <span class="synStatement">new</span> <span class="synSpecial">Promise</span><span class="synStatement">((</span>resolve<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.$_fetchResolve <span class="synStatement">=</span> resolve <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">async</span> beforeMount<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>hasFetch<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">))</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">}</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span><span class="synSpecial">window</span>.__NUXT__<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synSpecial">window</span>.__NUXT__ <span class="synStatement">=</span> <span class="synIdentifier">{</span> data: <span class="synIdentifier">{}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synType">const</span> serverData <span class="synStatement">=</span> <span class="synSpecial">window</span>.__NUXT__.data<span class="synIdentifier">[this</span>.$_fetchKey<span class="synIdentifier">]</span> <span class="synStatement">if</span> <span class="synStatement">(</span>serverData<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synSpecial">Object</span>.assign<span class="synStatement">(</span><span class="synIdentifier">this</span>.$data<span class="synStatement">,</span> serverData<span class="synStatement">)</span> <span class="synSpecial">window</span>.__NUXT__.data<span class="synIdentifier">[this</span>.$_fetchKey<span class="synIdentifier">]</span> <span class="synStatement">=</span> <span class="synType">undefined</span> <span class="synStatement">return</span> <span class="synIdentifier">}</span> <span class="synStatement">await</span> <span class="synIdentifier">this</span>.$options.<span class="synSpecial">fetch</span>.call<span class="synStatement">(</span><span class="synIdentifier">this</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">function</span> hasFetch<span class="synStatement">(</span>vm<span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">(</span> vm.$options <span class="synConstant">&amp;&amp;</span> <span class="synStatement">typeof</span> vm.$options.<span class="synSpecial">fetch</span> <span class="synStatement">===</span> <span class="synConstant">'function'</span> <span class="synConstant">&amp;&amp;</span> <span class="synConstant">!</span>vm.$options.<span class="synSpecial">fetch</span>.length <span class="synStatement">)</span> <span class="synIdentifier">}</span> </pre> <p>使用するコンポーネント側</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synStatement">default</span> defineNuxtComponent<span class="synStatement">(</span><span class="synIdentifier">{</span> nuxt2FetchKey: <span class="synConstant">&quot;defineNuxtComponentFetchKey&quot;</span><span class="synStatement">,</span> <span class="synStatement">async</span> <span class="synSpecial">fetch</span><span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synComment">// 非同期に何かを取得して設定する</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> </pre> <h4 id="headメソッド">headメソッド</h4> <p>Nuxt2で使用することができるheadメソッドの内部実装がNuxt3で変わり、computedを解釈することができなくなっていました。<br/> 例えば以下のようにheadメソッド内でcomputedを使用しているとundefinedになります。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synStatement">default</span> defineComponent<span class="synStatement">(</span><span class="synIdentifier">{</span> head<span class="synStatement">()</span>: MetaInfo <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> title: <span class="synIdentifier">this</span>.hoge<span class="synStatement">,</span> <span class="synComment">// undefinedになる</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> comptued: <span class="synIdentifier">{</span> hoge<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synConstant">'hoge'</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> </pre> <p>これに対応するために独自にプラグインを作り、Nuxt2と同じような動きをできるように、 <code>xxx.call(this)</code> によるVueコンテキストを注入しての実行、実行後の値をwatchにより監視し、変更後に useHead を使用してmeta情報への適用をするようにしました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synStatement">default</span> defineNuxtPlugin<span class="synStatement">((</span>nuxt<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> nuxt.vueApp.mixin<span class="synStatement">(</span><span class="synIdentifier">{</span> data<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> head: <span class="synType">undefined</span><span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> created<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span><span class="synIdentifier">this</span>.$options.oldHead <span class="synConstant">||</span> <span class="synIdentifier">this</span>.$options.head<span class="synStatement">)</span> <span class="synStatement">return</span> <span class="synIdentifier">this</span>.$watch<span class="synStatement">(</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">this</span>.$options.oldHead.call<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synComment">// @ts-expect-error</span> <span class="synStatement">(</span>newValue<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.head <span class="synStatement">=</span> newValue <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> immediate: <span class="synConstant">true</span><span class="synStatement">,</span> deep: <span class="synConstant">true</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synStatement">)</span> <span class="synIdentifier">this</span>.$_fetchPromise?.then<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synIdentifier">this</span>.head <span class="synStatement">=</span> <span class="synIdentifier">this</span>.$options.oldHead.call<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> useHead<span class="synStatement">(()</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">this</span>.head<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>コンポーネントを使用する側は <code>head</code> というメソッドを <code>oldHead</code> というメソッドに書き換えるだけで正しくcomputedが解釈できるようにしました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synStatement">default</span> defineNuxtComponent<span class="synStatement">(</span><span class="synIdentifier">{</span> oldHead<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> title: <span class="synIdentifier">this</span>.hoge<span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> comptued: <span class="synIdentifier">{</span> hoge<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synConstant">'hoge'</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> </pre> <p>他にもいくつか問題があり対応をしています。</p> <ul> <li>Apollo <ul> <li>NuxtのApolloモジュールは現在Nuxt3対応がされていますが、つい最近まではNuxt3に対応していませんでした</li> <li>vue-apolloを直接使用しNuxtのプラグインとして独自実装</li> </ul> </li> <li>Storybook <ul> <li>NuxtのStorybookモジュールは現在もNuxt3は未対応のまま</li> <li>@storybook/vue3を使い独自に実装</li> </ul> </li> <li>axiosの脱却 <ul> <li>ApolloのRestLinkへ移行</li> </ul> </li> </ul> <p>他にもここには書ききれないほどに様々な対応をしていますが、この記事では省略させていただきます。<br/> 私たちはかなり早い段階でNuxt3対応を始めたことでNuxt3のバグを踏むということも多々ありました。<br/> 今であればバグも減り、ドキュメントも整ってきているのでもう少し早く移行を進められるかもしれません。<br/> しかし早い段階で対応できたことで本来実現したかった設計の改善に着手できており、この面ではよかったと思っています。</p> <h3 id="終わり">終わり</h3> <p>現在はNuxt3を使って更なる改善に挑戦中です。 この記事がこれからNuxt3へのアップグレードを行う方々の力になれば幸いです。</p> <p>一休では一緒に働く仲間を募集しています。まずはカジュアル面談からお気軽にご応募ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1745000651779629061" title="【エンジニア】カジュアル面談応募フォーム | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1745000651779629061">hrmos.co</a></cite></p> igatea 宿特化の写真投稿SNS「YADOLINK」のUIUX設計 について hatenablog://entry/4207112889965181893 2023-03-03T15:15:04+09:00 2023-03-03T15:15:04+09:00 YADOLINK事業部デザイナーの李と申します。 YADOLINKは、一休が運営する「ホテルや旅館など”宿”が大好きな人たちが集まるSNS」です。宿に特化したサービスだからこそ宿泊体験を気兼ねなく投稿でき、深い共感を得られます。 web版を2022年4月19日に公開し、iOSアプリを2023年1月24日(火)にローンチしました。 YADOLINK by 一休.comIKYU Corporation旅行無料apps.apple.com 1. 写真投稿SNSの “ベーシック” を考える どこまでInstagramに寄せるべきか? YADOLINKは写真投稿が主要な手段のSNSです。広く知られている… <p>YADOLINK事業部デザイナーの李と申します。</p> <p>YADOLINKは、一休が運営する「ホテルや旅館など”宿”が大好きな人たちが集まるSNS」です。宿に特化したサービスだからこそ宿泊体験を気兼ねなく投稿でき、深い共感を得られます。 web版を2022年4月19日に公開し、iOSアプリを2023年1月24日(火)にローンチしました。</p> <p><div class="itunes-embed freezed itunes-kind-software"><a href="https://apps.apple.com/jp/app/yadolink-by-%E4%B8%80%E4%BC%91-com/id6443713210?uo=4&at=10l8JW&ct=hatenablog" rel="nofollow" target="_blank"><img src="https://cdn.image.st-hatena.com/image/scale/5a9ce454c2bc5bbc86971173a121f607f3e30380/enlarge=0;height=200;version=1;width=200/https%3A%2F%2Fis2-ssl.mzstatic.com%2Fimage%2Fthumb%2FPurple116%2Fv4%2F1f%2F3c%2F8d%2F1f3c8d57-dbbb-c471-7ffd-4e8df8968fa2%2FAppIcon-1x_U007emarketing-0-7-0-85-220.png%2F100x100bb.jpg" alt="YADOLINK by 一休.com" title="YADOLINK by 一休.com" class="itunes-embed-image"/></a><div class="itunes-embed-info"><p class="itunes-embed-title"><a href="https://apps.apple.com/jp/app/yadolink-by-%E4%B8%80%E4%BC%91-com/id6443713210?uo=4&at=10l8JW&ct=hatenablog" rel="nofollow" target="_blank">YADOLINK by 一休.com</a></p><ul><li class="itunes-embed-artist">IKYU Corporation</li><li class="itunes-embed-genre">旅行</li><li class="itunes-embed-price">無料</li><li class="itunes-embed-badge"><a href="https://apps.apple.com/jp/app/yadolink-by-%E4%B8%80%E4%BC%91-com/id6443713210?uo=4&at=10l8JW&ct=hatenablog" rel="nofollow" target="_blank"><img src="https://cdn.blog.st-hatena.com/images/theme/itunes/itunes-badge-appstore@2x.png" width="60px" height="15px" /></a></li></ul></div></div><cite class="hatena-citation"><a href="https://apps.apple.com/jp/app/id6443713210?mt=8/">apps.apple.com</a></cite></p> <p><strong><span style="font-size: 180%">1. 写真投稿SNSの “ベーシック” を考える</span></strong></p> <p> <strong><span style="font-size: 150%">どこまでInstagramに寄せるべきか?</span></strong></p> <p>YADOLINKは写真投稿が主要な手段のSNSです。広く知られている写真投稿SNSにInstagramがありますが、「インスタっぽいものだよ!」と簡単に伝えることで、最初の利用するハードルが下がると考えました。どのようにInstagramに寄せて、どのように差別するかを最初に検討しました。</p> <h3 id="フィードUI"><strong><span style="font-size: 100%">フィードUI</span></strong></h3> <p>Instagramの最も基本的なUIはホーム画面に表示される通常の投稿フィードです。</p> <p>多くのユーザが使い慣れているUIであるため、YADOLINKでもユーザーの投稿をフィード形式にすることを決定しました。しかし、Instagramのフィードをそのままにするのではなく、YADOLINK独自のユーザーニーズに合わせて、情報整理を行い、以下の部分を差別化することにしました。</p> <div style="padding: 40px; background: #f8f8f8; line-height: 1.7; margin-bottom: 30px"> <span style="font-size: 120%"></span> <span style="font-weight: bold;">1. </span>宿に特化したサービスのため、宿泊施設が一番重要な要素となり、フィードに宿泊施設名を表示するように追加した。<br><br> <span style="font-weight: bold;">2. </span>投稿全体に対しての説明だけでなく、写真ごとに説明&思いを記述できるようにキャプションを追加した。<br><br> <span style="font-weight: bold;">3. </span>「いいね」をするハードルを下げるため「いいね数」だけを表示して、「いいね」をした人のリストは非表示した。<br><br> <span style="font-weight: bold;">4. </span>ユーザーの体験をよりスムーズにするため、ページ遷移せずにコメントできるようにコメントのUIをモーダルにした。(アプリのみ)<br><br> <span style="font-weight: bold;">5. </span>初期リリース時には、お互い知らない人同士の投稿にいいねやコメントすることのハードルが高いと思われるため、いいね数やコメント数が少なくても投稿するモチベーションを高めるため、「閲覧数」という指標を表示することを決定した。<br><br> </div> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230222/20230222164428.png" width="1200" height="997" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="2列フィードUI中華圏を中心とした新興SNSを参考に"><strong><span style="font-size: 100%">2列フィードUI<br />中華圏を中心とした新興SNSを参考に</span></strong></h3> <p>初期リリース時にはフィードUIのみが提供されましたが、一覧性が低く1スクロールで1~1.5の投稿しか見られない、タイムラインに流れている投稿が自分がフォローしている人の投稿に限らず全ての投稿が表示されるなどの課題がありました。</p> <p>この課題を解決するために、下記の2つの改善を行いました。</p> <p><strong>1. </strong>YADOLINKでは中華圏を中心とした新興SNSでよく見られる「2列フィード」のUIを導入しました。これは、生活必需品とは異なり、宿みたいな嗜好品を「気になるからとりあえず見てみたい(予約意欲までいかない場合も含む)」という場合、ユーザーのニーズが漠然としていることが多いです。「行きたい宿を探す」というより「漫然とユーザーの投稿を眺めている内に行きたい宿を見つける」という仕様がYADOLINKユーザーに最もマッチすると考えました。「行きたい宿が決まっている」場合は、YADOLINKよりも一休.comの方を利用するユーザーが多いかもしれません。「小紅書」という中国のSNSの2列フィードUIは、このようなユーザーの気持ちにとことん寄り添い、参考になりました!</p> <div style="font-size: 100%; color:#696969; line-height: 1.7;">※「小紅書」(RED) は、2022年2月時点、登録ユーザー数3億人の動画や写真などを共有できる「インスタグラムとアマゾンが合体したようなアプリ」です。</div> <p><strong>2. </strong>2列フィードUIの導入に伴い、トップページは「おすすめ」「フォロー」「新着投稿」の三つのタブに分けられ、それぞれがタイムラインとして閲覧できるようになりました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230228/20230228184645.png" width="1200" height="1075" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong> <span style="font-size: 150%">宿の写真が引き立っていることを一番大事に</span></strong></p> <p>一休.comというサービス従来の上質感を残しつつ、新しいサービスとしての活発・躍動感も表現できるようにデザインしています。</p> <p>主役である宿の写真が引き立っていることを一番大事にしています。InstagramなどのSNSで流行っている写真の上に文字入れインパクト系の画像は上位表示させないなど、一休.comの上質な世界観を取り入れています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230228/20230228184724.png" width="1200" height="849" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong><span style="font-size: 180%">2. SNSは “アプリが命”</span></strong></p> <p>YADOLINKはWebからスタートしましたが、最初からアプリを意識していました。なぜならば、SNSは「アプリが命」だと考えられているからです。頻繁に開かれるものであり、毎回ブラウザを開くことは大変面倒です。「いいね」「コメント」「フォロー」などの機能にもリアルタイムのプッシュ通知が不可欠です。アプリではウェブサイトで実装が難しいインタラクションも実現できるため、ユーザーの没頭を促し、ストレスなく操作することが期待できます。</p> <div style="margin-bottom: 15px;">下記の図のようにアプリでは</div> <div> <div style="font-wight: 600; font-size: 110%; margin-bottom: 10px;">・スワイプでタブ移動、ページ遷移できるようになった</div> <div style="font-wight: 600; font-size: 110%; margin-bottom: 10px;">・インタラクションでおすすめ投稿一覧と投稿詳細間がスムーズな往来になった</div> </div> <div style="padding: 30px 40px 20px; background: #f8f8f8; line-height: 1.7; margin-bottom: 30px; color: #696969; margin-top: 30px;"> <div style="text-align: center; margin-bottom: 35px; margin-top" 50px;"> <div style="margin: 0 140px 20px; display: flex; justify-content: flex-start;"><span style="margin: 0 20px; font-weight: bold;">WEB</span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230301/20230301153445.gif" width="240" height="519" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div>  <div style="margin: 0 140px 20px; display: flex; justify-content: flex-start;"><span style="margin: 0 20px; font-weight: bold;">APP</span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230301/20230301152914.gif" width="240" height="519" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div> </div> </div> <div style="font-wight: 600; font-size: 110%; margin-bottom: 10px;">・ログインフローのUIUX改善</div> <div style="padding: 30px 40px 20px; background: #f8f8f8; line-height: 1.7; margin-bottom: 30px; color: #696969; margin-top: 30px;"> <div style="text-align: center; margin-bottom: 35px; margin-top" 50px;"> <div style="margin: 0 140px 20px; display: flex; justify-content: flex-start;"><span style="margin: 0 20px; font-weight: bold;">WEB</span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230301/20230301161424.gif" width="240" height="519" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div>  <div style="margin: 0 140px 20px; display: flex; justify-content: flex-start;"><span style="margin: 0 20px; font-weight: bold;">APP</span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tei_ikyu/20230301/20230301162858.gif" width="240" height="427" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div> </div> </div> <div style="padding: 40px; background: #f8f8f8; line-height: 1.7; margin-bottom: 30px; color: #696969;"> <div style="font-weight: bold; font-size: 110%; margin-top: 10px; margin-bottom: 20px; color: #333;">アプリ開発に伴い、その他の改善点も整理してみました。興味がある方ぜひ触ってみてください〜</div> ・新しく実装された関連投稿で次々に行きたい宿が見つかる <br>・いいねの傾向に基づいたおすすめ投稿の精度向上 <br>・写真選択段階に並び順を変えられるように <br> ・投稿画像の縦横方向を指定できるように <br> ・トリミング時の画像拡大・縮小 <br>・入力画面にてスクロースせずに全ての項目を入力できるように <br>・キャプションの追加などはページ遷移→モーダルに変更 <br> ・自分の投稿へのコメント通知により素早く返事ができるように <br>・他にも自分への投稿へのいいねやフォロー通知が受け取れる <br> ・受け取りたい通知はカテゴリごとに制御可能 </div> <p><strong><span style="font-size: 180%">3.まとめ</span></strong></p> <p>「YADOLINK」は、お宿を訪れた方が自分で撮影した写真を投稿できる「宿特化型SNS」です。写真を中心に、ユーザーが直感的に好みの宿を探しやすいインターフェースを提供しています。自分自身では言語化できないニーズを発見したり、まだ知らなかった「自分好みのお宿」との偶然の出会いを演出します。</p> <p>これまで一休にはSNSサービスがありませんでしたが、YADOLINKをきっかけに新しいUIUXに取り組み、試行錯誤しながら多くのことを学びました。</p> <p>特定のジャンルを絞らないSNSとは異なり、宿泊施設に特化したSNSであるため、ユーザー層や利用頻度などが比較的限定的ですが、UIUXの改善により、より多くのユーザーに愛用していただけるよう努めています。</p> <p>一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206485" title="プロダクトデザイナー (正社員) | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1693126708022206485">hrmos.co</a></cite></p> tei_ikyu 本社を東京ガーデンテラス紀尾井町へ移転し、オフィスファシリティ・コーポレートIT を刷新した話 hatenablog://entry/4207112889915922925 2022-12-15T15:17:17+09:00 2022-12-15T15:19:27+09:00 はじめに 社内情報システム部 / CISO室 所属 コーポレートエンジニアの 大多和(id:rotom)です。 2022年12月5日、一休は本社オフィスを港区赤坂から千代田区紀尾井町の東京ガーデンテラス紀尾井町 紀尾井町タワーへ移転しました。 ヤフーや PayPay、ZOZO をはじめ、Zホールディングス各社やデジタル庁も入居するビルです。 新オフィスのコンセプト、概要についてはプレスリリースをご覧ください。 prtimes.jp 当社は2022年4月に働き方を刷新し、オフィスワークとリモートワークのハイブリッド制を導入しました。従業員がより高いパフォーマンスを発揮できるよう、オフィスワークの… <h3 id="はじめに">はじめに</h3> <p>社内情報システム部 / CISO室 所属 コーポレートエンジニアの 大多和(<a href="http://blog.hatena.ne.jp/rotom/" class="hatena-id-icon"><img src="https://cdn.profile-image.st-hatena.com/users/rotom/profile.png" width="16" height="16" alt="" class="hatena-id-icon">id:rotom</a>)です。</p> <p>2022年12月5日、一休は本社オフィスを港区赤坂から千代田区紀尾井町の東京ガーデンテラス紀尾井町 紀尾井町タワーへ移転しました。 ヤフーや PayPay、ZOZO をはじめ、Zホールディングス各社やデジタル庁も入居するビルです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094415.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>新オフィスのコンセプト、概要についてはプレスリリースをご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fprtimes.jp%2Fmain%2Fhtml%2Frd%2Fp%2F000000215.000003544.html" title="株式会社一休 千代田区紀尾井町に本社を移転" 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://prtimes.jp/main/html/rd/p/000000215.000003544.html">prtimes.jp</a></cite></p> <blockquote><p>当社は2022年4月に働き方を刷新し、オフィスワークとリモートワークのハイブリッド制を導入しました。従業員がより高いパフォーマンスを発揮できるよう、オフィスワークの日を職種ごとに週1・3・5回に分けています。移転後の座席は、出社回数に応じて、フリーアドレス・グループアドレス・固定席の「ハイブリッド」とし、オフィス勤務時には従業員同士が円滑にコミュニケーションを取れるよう設計しました。</p></blockquote> <p>一休は2022年4月より<strong>リモートワークとのハイブリッド勤務となるように働き方が大きく刷新</strong>されました。</p> <p>本社移転に伴い、ただ現状のインフラのまま引っ越すだけではなく、上記のような時勢に合わせた新しいオフィスファシリティの構築、PBX や FAX のほかオンプレミスサーバーを撤廃した、クラウドネイティブでモダンなコーポレートIT への刷新も行いました。 今回はその取り組みの一部をご紹介します。</p> <p>この記事は<strong> corp-engr 情シス Slack(コーポレートエンジニア x 情シス) #1 Advent Calendar 2022</strong> 15日目の記事でもあります。他の素晴らしい記事は以下のリンクからご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fadventar.org%2Fcalendars%2F7756" title="corp-engr 情シスSlack(コーポレートエンジニア x 情シス)#1 Advent Calendar 2022 - Adventar" 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://adventar.org/calendars/7756">adventar.org</a></cite></p> <h3 id="オフィスファシリティ">オフィスファシリティ</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205131840.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>まずはオフィス環境についてご紹介します。一休の前オフィスは5階・6階・7階の3フロアに分かれていましたが、<strong>今回の移転で10階のワンフロアに統合</strong>されました。 これまでの3フロアの床面積を合計しても、なお新オフィスのワンフロアのほうが広いという一休史上最大規模のオフィスです。</p> <h4 id="執務室">執務室</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205092728.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>執務室はエンジニアに限らず、営業、バックオフィスなどを含む<strong>全ての座席にエルゴヒューマンのチェアが設置</strong>されています。 後述しますが、今回の移転のタイミングで<strong>各席に置かれていた固定電話を廃止</strong>し、机上への配線は OA タップのみという非常にすっきりしたデスクになりました。</p> <h4 id="エンジニアエリア">エンジニアエリア</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205091937.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>前述の通り、職種に応じてオフィスへの出社日が決まっており、エンジニア職は週1 オフィスワークの職種です。 そのため、各事業部のソフトウェアエンジニアやデータサイエンティストなどの席は、<strong>従来の固定席を廃止しフリーアドレス化</strong>しました。</p> <p>エンジニア席には LG 製の 4K ディスプレイが標準で配備されています。<strong>USB PD(Power Delivery)給電に対応</strong>しているため、出社時も充電アダプタを持ち歩く必要がなく、USB-C ケーブルを接続すればモニタへの出力と充電を同時に行うことができます。</p> <h4 id="会議室--個室ブース--集中スペース">会議室 / 個室ブース / 集中スペース</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094213.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094940.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>コロナ前は会議室で対面で行っていたミーティングは、コロナ禍に Zoom や Google Meet を利用したオンラインミーティングへと移行しました。 そのため、新オフィスでは大人数向けの会議室より、1on1 などで利用できる小規模の会議室や、<strong>遮音性の高いオンラインミーティング用の個室ブースや、囲いで覆われた集中ブースで多く設置</strong>されています。</p> <p><strong>会議室の壁はホワイトボード</strong>になっているため、ブレインストーミングなどに最適です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205092621.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094613.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094247.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>オフィスは非常に広く、座席も大量にあるのですが、エンジニアにはここが一番人気のようです。</p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">めちゃくちゃ広くて席たくさんあるのにソフトウェアエンジニアにはここが一番人気 <a href="https://t.co/3WBj4yyxvv">pic.twitter.com/3WBj4yyxvv</a></p>&mdash; naoya (@naoya_ito) <a href="https://twitter.com/naoya_ito/status/1599988159017734146?ref_src=twsrc%5Etfw">2022年12月6日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <p>また、窓際には予約不要で利用できるフリースペースは多く設置されており、ちょっとした打ち合わせにサクッと利用できます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094554.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>晴れた日のカウンター席は景観がよく、素晴らしい夕日が差し込むおすすめスポットです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205094233.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="ラウンジ">ラウンジ</h4> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205092444.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>社内で「ラウンジ」と呼ばれているのが休憩や食事、社内イベントなどに利用されるリフレッシュスペースです。写真の「イソテーブル」と呼ばれる什器はヤフーで使われていたものを譲り受けました。新オフィスのコンセプト「サステナビリティ」を体現しており、早速ティーブレイクやちょっとした打合せで活用されています。</p> <p>前オフィスでは、コロナ前はラウンジのあったフロアに全就業員が集まり、経営陣からの実績報告やケータリングの料理やお酒を楽しむパーティーが開催されていました。 また、技術コミュニティのIT勉強会や、外部の経営者をお招きした MANABIBA と呼ばれる経営対談も活発に行われていました。</p> <p>これらのイベントはコロナ禍では <strong>YouTube Live や Zoom ウェビナーを活用した配信方式へ</strong>の切り替わっていました。 オフィスワークとリモートワークのハイブリッド制となった今、このラウンジも<strong>会場・配信の双方に最適化されたAV システムに刷新</strong>しました。</p> <h4 id="社員食堂--カフェテリア--コンビニ--コワーキングスペース">社員食堂 / カフェテリア / コンビニ / コワーキングスペース</h4> <p>東京ガーデンテラス紀尾井町 紀尾井タワーはヤフーをはじめ、Zホールディングス各社が多く入居するオフィスビルです。 一休も同じビルへ同居することでヤフーのオフィス設備の一部を利用させていただいています。</p> <p>1つ上の11階には<strong>BASE11</strong>と呼ばれる社員食堂・カフェテリアがあります。紀尾井町は前オフィスの赤坂と比べ飲食店街からは少し離れた場所に位置するため、外食をするには移動しなければなりません。オフィス内に社員食堂があることで、安くて美味しいバランスの採れた食事を日替わりで食べることができ大変便利です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fabout.yahoo.co.jp%2Fhr%2Flinotice%2F20191220.html" title="データと地産食材を活用した、おいしくて健康的なヤフーの社員食堂BASE・CAMPを徹底紹介! - linotice" 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://about.yahoo.co.jp/hr/linotice/20191220.html">about.yahoo.co.jp</a></cite></p> <p>同フロアにはZホールディングスのアスクル・出前館が運営するコンビニ <strong>Yahoo!マート</strong> の店舗もあり、オフィスから出なくても食事や日用品を購入することもできます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fabout.yahoo.co.jp%2Fpr%2Frelease%2F2022%2F08%2F19a%2F" title="最短15分で食料品などを宅配するYahoo!マート、クイックコマース事業者として初の来店型店舗運営を開始 - ニュース - ヤフー株式会社" 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://about.yahoo.co.jp/pr/release/2022/08/19a/">about.yahoo.co.jp</a></cite></p> <p>また、コロナ前は一般の方へも公開していたコワーキングスペースの <strong>LODGE</strong> は現在はZホールディングス従業員向けに公開されています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Flodge.yahoo.co.jp%2F" title="LODGE -Yahoo! JAPANのオフィス内にあるオープンコラボレーションスペース-" 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://lodge.yahoo.co.jp/">lodge.yahoo.co.jp</a></cite></p> <p>一休のオフィスはワンフロアですが、このようにヤフーのオフィス設備も利用させていただくことで快適に業務を行える環境が整っています。</p> <h3 id="コーポレートIT">コーポレートIT</h3> <p>ここからはコーポレートITの話をします。移転前のオフィスにはサーバールームがあり、オンプレミスで稼働しているサーバーが複数ありました。 これらのオンプレミスサーバーは以前より AWS への移行や SaaS へのリプレイスなどクラウド移行を継続して行っており、移転前の時点ではプロキシサーバーと PBX を残すのみとなっていました。</p> <p>今回の移転のタイミングでプロキシサーバーをクラウド移行を完了し、固定電話も廃止することで PBX を撤廃し、<strong>新オフィスからはサーバールームが無くなりました。</strong></p> <h4 id="電話--Dialpad-Enterprise">電話 / Dialpad Enterprise</h4> <p>前オフィスではサーバールームに PBX が設置されており、事業部や本部ごとに電話番号を持ち、各席には固定電話機が設置されていました。 コロナ感染拡大に伴い、全社的に在宅勤務の体制が取られた際に、コールセンターや一部部署で先行して利用していたピュアクラウド型のビジネスフォンシステム <strong>Dialpad</strong> を全社導入しました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.dialpad.co.jp%2F" title="Dialpad - Work beautifully" 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.dialpad.co.jp/">www.dialpad.co.jp</a></cite></p> <p>固定電話の番号を Dialpad で発番した 050 の番号に即時転送をかけることで、オフィスに出社することなく、在宅でも受電対応が行える環境を構築しました。 このときの取り組みは大規模な導入になったため、Dialpad Japan の導入事例としてもご紹介いただいております。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjapan.blog.dialpad.com%2Fblog%2Fcustomer-story%2Fikyu" title="Dialpad 導入企業インタビュー | 株式会社一休さま — Dialpad Japan Blog" 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://japan.blog.dialpad.com/blog/customer-story/ikyu">japan.blog.dialpad.com</a></cite></p> <p>Dialpad はフルクラウドであることから非常に管理がしやすい一方で、IP 電話であるため電話番号が 050 番号でした。 当時は 050 番号から 0120 を始めとするフリーダイヤルへ発信できないことや、03 番号への着信の転送への折返しの際も先方には 050 番号が表示されてしまうため出ていただけないことがある、といった課題感もありました。 この課題を解決するためには自社でゲートウェイを設置する必要があり、フルクラウドである恩恵を得られにくいものでした。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.softbank.jp%2Fbiz%2Fnews%2Fcloud%2F210901%2F" title="回線敷設・機器設置不要で固定電話番号を利用できる「Dialpad Air 0AB-J」を提供開始 | 法人のお客さま | ソフトバンク" 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.softbank.jp/biz/news/cloud/210901/">www.softbank.jp</a></cite></p> <p>2021年9月に <strong>Dialpad Air 0AB-J </strong> という新オプションが登場し、<strong>ゲートウェイなどの機器を設置不要で 03 番号(0-ABJ)が利用可能</strong>になったことから、今回の移転のタイミングで<strong>オンプレ PBX を脱却し、Dialpad へ一本化</strong>することを決めました。 この際、利用部門が大きく拡大することから<strong>グループ数が無制限となる最上位プランであり、Azure AD による SAML/SSO や SCIM にも対応した Enterprise にアップグレード</strong>を行いました。</p> <p>現在は元から固定電話のないエンジニアをのぞく、全ての部門で Dialpad を利用しています。</p> <h4 id="FAX--FAX-PLUS-Enterprise">FAX / FAX PLUS Enterprise</h4> <p>PBX を同じく、オフィスに設置されているオンプレミス機器として FAX の存在がありました。 事前のヒアリングから事業部、管理本部ともに FAX はすでに業務でほぼ利用されていないことは分かっていましたが、官公庁やビルの防災センター、クレジットカード会社など、一部 FAX でしか受付を行えない組織との取引手段として、FAX は今後も残す必要がありました。</p> <p>従来の FAX はアナログ回線を引き込み複合機から送信するものでしたが、これもオフィスからではないと送受信が行えないため現在の働き方には合っておらず、フルクラウド型へ移行しました。</p> <p>選定したのは <strong>FAX PLUS</strong> というスイスのオンライン FAX サービスで、<strong>マルチプラットフォームかつ Web アプリから利用可能</strong>なものです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.fax.plus%2Fja%2F" title="FAX.PLUS - 最高のオンライン ファックス サービス |オンラインでファクスを安全に送信" 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.fax.plus/ja/">www.fax.plus</a></cite></p> <p>海外 SaaS ではあるのですが、<strong>日本の FAX 番号を取得することが可能</strong>です。2022年12月時点では 050 番号のみ利用可能で、03 番号などの 0-ABJ は利用できません。 050 番号の取得には日本の法律により審査が必要で、登記簿謄本や代表者(または委任を受けた担当者)の本人確認書類などの提出が求められます。サポートとは英語でのやりとりなので難易度は少し高めでした。</p> <p>FAX PLUS も<strong>最上位である Enterprise プランを契約し、Azure AD による SAML/SSO や SCIM を構築</strong>しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221214/20221214195710.png" width="1036" height="840" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>また、<strong>Slack とのインテグレーションが可能</strong>であり、FAX を受信した際は Web アプリを開くことなく、Slack のチャンネルから直接 PDF で閲覧・ダウロードが可能です。</p> <h4 id="入退室管理--Akerun-コントローラー">入退室管理 / Akerun コントローラー</h4> <p>オフィスの入退室を管理するセキュリティ製品も、移転前にオンプレミスサーバー型のものからクラウド型のものへリプレイスを行っていました。<strong>Akerun コントローラー</strong>という製品です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fakerun.com%2Fentry_and_exit%2Fakerunctl%2F" title="Akerunコントローラー | 電気錠や自動ドア対応のスマートロック" 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://akerun.com/entry_and_exit/akerunctl/">akerun.com</a></cite></p> <p>Akerun というと、WeWork などのレンタルオフィスに工事不要で設置ができる Akerun Pro のイメージが強いかもしれません。 今回選定している Akerun コントローラーは、専門業者の工事により、<strong>既設のオフィスビルの電子錠や自動ドアにも対応</strong>したものです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcorp.teamspirit.com%2Fja-jp%2Fnews%2Frelease%2F2019%2F10%2Fakerun%25E5%2585%25A5%25E9%2580%2580%25E5%25AE%25A4%25E7%25AE%25A1%25E7%2590%2586%25E3%2582%25B7%25E3%2582%25B9%25E3%2583%2586%25E3%2583%25A0%25E3%2581%25A8teamspirit%25E3%2581%258C%25E9%2580%25A3%25E6%2590%25BA.html" title="Akerun入退室管理システムとTeamSpiritが連携 | TeamSpirit Inc.(株式会社チームスピリット)" 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://corp.teamspirit.com/ja-jp/news/release/2019/10/akerun%E5%85%A5%E9%80%80%E5%AE%A4%E7%AE%A1%E7%90%86%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E3%81%A8teamspirit%E3%81%8C%E9%80%A3%E6%90%BA.html">corp.teamspirit.com</a></cite></p> <p>また、Akerun は勤怠管理システムである <strong>TeamSpirit と API 連携を行うことが可能</strong>であるため、<strong>オフィスに出社した際には出勤・退勤が自動打刻</strong>されるように設定を行いました。 一休は北海道から沖縄まで多くの拠点を持ちますが、<strong>全ての拠点で Akerun へのリプレイスが完了</strong>しており、勤怠打刻の自動化は多くの従業員に喜んでいただけています。</p> <h3 id="終わりに">終わりに</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rotom/20221205/20221205092108.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>今回の移転ではモダンなオフィス・コーポレートIT環境へ一気に推進することができており、キラキラなオフィス環境にも見えるかもしれません。 一方で、タイトなスケジュールの中でのオフィス移転は、想定外のトラブルも含め多くの課題もあり、<strong>まだまだ改善・進化の余地が残されています</strong>。</p> <p>情シス / コーポレートエンジニアとして、エンジニアを含む<strong>従業員体験を向上</strong>を目指し、皆さんがより快適に業務を行うことができるオフィス・コーポレートIT 環境を目指して、引き続き全力でやっていきます 💪</p> <h3 id="突然ですがここで-CM-のお時間です">突然ですが、ここで CM のお時間です</h3> <p>一休ではソフトウェアエンジニアをはじめ、多くの職種で積極的に採用を行っています。 選考をともなわないカジュアル面談からも受け付けておりますので、お気軽にご応募ください 👋</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ikyu.co.jp%2Frecruit%2Fengineer%2F" title="エンジニア採用 - 株式会社一休" 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.ikyu.co.jp/recruit/engineer/">www.ikyu.co.jp</a></cite></p> <p>エンジニア採用の Twitter アカウントも開設し、イベント告知などを発信しています。こちらもフォローお待ちしております。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftwitter.com%2Fikyu_saiyo" title="株式会社一休【採用担当】 (@Ikyu_saiyo) / Twitter" 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://twitter.com/ikyu_saiyo">twitter.com</a></cite></p> <p>また、今回ご紹介した話はコーポレートITの取り組みの一部です。より深堀りした内容を <strong>Business Technology Conference Japan(BTCONJP)</strong> というITのカンファレンスでもお話させていただく予定です。 ご都合の合う方はこちらも是非オンラインにてご参加ください 🙏</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fbtcon.jp%2F" title="BTCONJP|Business Technology Conference Japan" 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://btcon.jp/">btcon.jp</a></cite></p> rotom 新サービス「一休.comふるさと納税」でデザインシステムの活用とFigmaを使いました hatenablog://entry/4207112889935366849 2022-11-11T11:05:40+09:00 2022-11-11T11:05:40+09:00 プロダクト開発部デザイナーの安松と申します。 10/3、新サービスの「一休.comふるさと納税」がローンチしました。 選んだ宿がある自治体に寄附をすると、一休.comで使える割引クーポンを返礼品として、web上で受け取れるというサービスです。 一休.comの宿泊予約とは違ったサービスですが、予約へとつながるサービスをどのようにデザインに反映させたか、また一休.comの宿泊デザインシステムの活用やFigmaを使ったことを振り返ります。 目次 1) ふるさと納税サイトで意識したこと 2) 宿泊のデザインシステムを活かせるか 3) Figma導入後、初のゼロからデザイン 4) まとめ 1. ふるさと… <p>プロダクト開発部デザイナーの安松と申します。</p> <p>10/3、新サービスの「一休.comふるさと納税」がローンチしました。<br> 選んだ宿がある自治体に寄附をすると、一休.comで使える割引クーポンを返礼品として、web上で受け取れるというサービスです。</p> <p>一休.comの宿泊予約とは違ったサービスですが、予約へとつながるサービスをどのようにデザインに反映させたか、また<a href="https://user-first.ikyu.co.jp/entry/2022/06/22/142755">一休.comの宿泊デザインシステム</a>の活用やFigmaを使ったことを振り返ります。</p> <div style="padding: 10px 20px 20px; background: #eee; line-height: 1.8;"> <span style="font-size: 120%"><b>目次</b></span> <br>1) ふるさと納税サイトで意識したこと <br>2) 宿泊のデザインシステムを活かせるか <br>3) Figma導入後、初のゼロからデザイン <br>4) まとめ </div> <h2 id="1-ふるさと納税サイトで意識したこと">1. ふるさと納税サイトで意識したこと</h2> <h3 id="サービスコンセプトのすり合わせ">サービスコンセプトのすり合わせ</h3> <p>モックを作成するにあたりビジネスサイドから、サービスの概要やターゲット層に加え、一休.com(宿泊)のUIを活かしたい、とにかくシンプルに…などの要望をもらいました。また、今回のプロジェクトは、短期期間での開発ということもあり、主要な導線の要素が大筋決まっていたので、画面イメージを早く作り詳細をすり合わせながら「どんなユーザー体験にしたいか」のヒアリングや掘り下げ、他のデザイナーからのフィードバックを受けながらの作成です。</p> <h3 id="デザインにどう反映したか">デザインにどう反映したか</h3> <p>まずは既に一休.comを使っていただいているユーザーがターゲットです。どのようにデザインに反映したかまとめます。</p> <p><strong>ユーザーが「寄附」と「予約」を混乱しないように</strong><br> 宿泊と同様に宿から選ぶのですがあくまで「寄附」をするサイト。宿の情報などは最低限にして「予約」に関する宿の情報やプランは、宿泊サイトで確認していただくような作りです。行き来することも考え、メインとなる画像の見せ方も差別化しました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110182119.jpg" width="1200" height="518" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>宿のカードも差別化のため、ベースは活かしつつ意図的にデザインを変更しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110182342.jpg" width="1200" height="621" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>シンプルかつスムーズに</strong><br> 情報もシンプルにしたので、使用する色も少なくし、目立つアクセントカラーをCTAボタンとして寄附完了までの道しるべを明確にすることで、迷わずスムーズに手続きいただけるよう意識したポイントです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110182406.jpg" width="1200" height="774" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>安心して寄附していただけるように</strong><br> 高額の寄附かつ変更やキャンセルができないため、特に寄附やクーポンに関する注意事項の配置や寄附金額・返礼品としてもらえる割引クーポン額の表示には気を付けました。</p> <h2 id="2-宿泊のデザインシステムを活かせるか">2. 宿泊のデザインシステムを活かせるか</h2> <p>ふるさと納税サイトを着手するの少し前から、宿泊では<a href="https://user-first.ikyu.co.jp/entry/2022/06/22/142755">デザインシステムの導入</a>を始めており、デザインツールをXDからFigmaに移行し、デザインコンポーネントを大小さまざまな粒度で作成しています。こちらを活用することで宿泊のUIを活かしつつ、ふるさと納税サイトを組み立てようと考えました。実装上はコンポーネント化されておらず、宿泊のように、デザインとコードの連動はしていません。</p> <p>実際にどの部分を採用したか一部をご紹介します。</p> <p><strong>タイポグラフィー</strong><br> 一休らしさを担うタイポブラフィー全般(書体・サイズ・太さ)はそのまま使用しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110182825.jpg" width="1200" height="525" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>フォームのUI・パーツ</strong><br> 一休.comのユーザーが使い慣れているUIを活かすため、入力画面や決済画面はページ全体、ラジオボタンや入力フォームなどのパーツもほぼそのまま取り入れています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110182905.jpg" width="1200" height="802" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>タブデザイン</strong><br></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110182931.jpg" width="1200" height="902" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>実装面でも宿泊と同様のTailwind CSSを採用していたこともあり、角丸やシャドウ、padding/marginなどの細かい部分も同じように使えたのもよかったことです。</p> <p>このように別サービスでも、一休.comの宿泊デザインシステムで定義したデザインの基本要素や粒度の細かいコンポーネントが活用でき、「一休.comのUIを活かしたい」にも繋がったのです。また、活用したことで統一や上記で挙げた部分を考える時間は、他の作業に当てることができました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2022%2F06%2F22%2F142755" title="デザインシステム導入しました - 一休.com Developers 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://user-first.ikyu.co.jp/entry/2022/06/22/142755">user-first.ikyu.co.jp</a></cite></p> <h2 id="3-Figma導入後初のゼロからデザイン">3. Figma導入後、初のゼロからデザイン</h2> <p>宿泊でFigmaを使い始めた際、ツールの使い方に慣れず苦悩していました。さらに、既存サービスである宿泊デザインシステムではどのコンポーネントにするかを現状のデザインを見ながら考えていましたが、今回は新規サービスであったため、現状のデザインはない状態で新しいデザインの検討やパターン出しをすることに苦労しました。慣れないツールと新規サービスという2つの難しさが重なったことが辛かったです。</p> <p><strong>テキストスタイル・色の定義で大失敗、慣れるまで時間がかかった</strong><br> デザインする中で、宿泊のコンポーネントを変更して使いました。それは問題のないことですが、ふるさと納税側で新しく定義する色を試行錯誤している段階では、Color Stylesで定義せずに進めていました。</p> <p>結果様々な画面でパターンを出した後に定義を追加したため、作成済みのモックで色やテキストの置き換え作業が発生してしまいました。</p> <p>仮の状態で後から微調整をするからこそ、定義をしておくべきでした。また、色の選考の際は定義したカラーリングごとにフォルダを作るなどをして、Figmaの便利な機能を活用できそうです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yasumatsu_ikyu/20221110/20221110183144.jpg" width="1200" height="980" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>コンポーネントも散らかり気味で、同じようなものができたり、手動で置き換え作業も何度もしましたが、最終的にページを横断して使うコンポーネントはまとめて管理し、ライブラリとして公開した後は、変更を加えると反映される状態になっていきました。ある程度デザインが固まったときにコンポーネントの見直し・整理をするというのも大事かもしれません。</p> <h2 id="4-まとめ">4. まとめ</h2> <p>デザインに入る前に「どんなユーザー体験にしたいか」を掘り下げ事前に話し合ったことで、新しい機能や改善の際も、検討中の時にそれがぶれていないか?と一つの指標になり、チームが同じ方向を向けたと思います。</p> <p>また、ふるさと納税では、宿泊のデザインシステムがあって本当によかったと感じています。統一感やスピードの面で活かすことができたためです。ただ、別サービスでも使用するという目的で作られていないため、今後の扱いは改めて検討する必要はありそうです。</p> <p>そんな中、一休のサービスを横断したガイドラインを作成するプロジェクトが、デザイナー主体で始動しました。どのサービスを使ってもユーザーにとって素晴らしい体験ができるよう、さらに進化をしている最中ですので、今回の経験が少しでも役立てばよいなと思います。</p> <p>一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206485" title="プロダクトデザイナー (正社員) | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1693126708022206485">hrmos.co</a></cite></p> yasumatsu_ikyu 閲覧メインのページを検索メインのページに統合しました hatenablog://entry/4207112889935372894 2022-11-10T19:03:28+09:00 2023-08-23T12:30:28+09:00 こんにちは、プロダクト開発部の野口です。 一休にはたくさんの施設紹介ページがあるのですが、その中でもキュレーションページという流入数が高いページがありました。それをメイン動線であるリストページに統合したので、その経緯や裏側をご紹介します。 一休の施設掲載ページはたくさんある 一休には施設をまとめて掲載するページがたくさん存在します。 リストページ(メイン動線) https://www.ikyu.com/area/ma000000/t105/si1/?adc=1&asc=01&cid=20221008&cod=20221010&hoi=1,2&lc=2&mtc=003&per_page=20&p… <p>こんにちは、プロダクト開発部の野口です。</p> <p>一休にはたくさんの施設紹介ページがあるのですが、その中でもキュレーションページという流入数が高いページがありました。それをメイン動線であるリストページに統合したので、その経緯や裏側をご紹介します。</p> <p></p> <h3 id="一休の施設掲載ページはたくさんある"> 一休の施設掲載ページはたくさんある </h3> <p>一休には施設をまとめて掲載するページがたくさん存在します。</p> <h4 id="リストページメイン動線">リストページ(メイン動線)</h4> <figure class="figure-image figure-image-fotolife mceNonEditable" title="https://www.ikyu.com/area/ma000000/t105/si1/?adc=1&amp;asc=01&amp;cid=20221008&amp;cod=20221010&amp;hoi=1,2&amp;lc=2&amp;mtc=003&amp;per_page=20&amp;pn=1&amp;ppc=2&amp;rc=1"> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nog_yo/20221005/20221005171051.png" width="1095" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <figcaption class="mceEditable">https://www.ikyu.com/area/ma000000/t105/si1/?adc=1&amp;asc=01&amp;cid=20221008&amp;cod=20221010&amp;hoi=1,2&amp;lc=2&amp;mtc=003&amp;per_page=20&amp;pn=1&amp;ppc=2&amp;rc=1</figcaption> </figure> <h4 id="キュレーションページ">キュレーションページ</h4> <figure class="figure-image figure-image-fotolife mceNonEditable" title="https://www.ikyu.com/theme/t105/"> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nog_yo/20221005/20221005151924.png" width="1200" height="842" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <figcaption class="mceEditable">https://www.ikyu.com/theme/t105/</figcaption> </figure> <h4 id="観光ページ">観光ページ</h4> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nog_yo/20221005/20221005172553.png" width="1070" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <h4 id="特集ページ">特集ページ</h4> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nog_yo/20221005/20221005172702.png" width="1052" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p>などなど。。。</p> <p>その中でも今回はキュレーションページをリストページに統合したというお話です。</p> <h2 id="どうして統合したの">どうして統合したの?</h2> <p>キュレーションページは検索するためのページというよりも、検索エンジンからの流入口として用意されているものになります。そのため閲覧数は一休の中でもかなりいいほうなのですが、受動的な情報ばかりなので残念ながら予約までたどり着く割合は低かったのです。</p> <p>一方でメイン動線であるリストページは検索に特化しており、ユーザが能動的に欲しい情報を要求し、的確な情報を表示できます。そのため予約までたどり着く割合も高く、メイン動線としての役割をしっかりと全うしています。</p> <p>この2つを統合することで流入数と予約割合のどちらも高めたいというのが狙いになります。</p> <h3 id="苦労したこと">苦労したこと</h3> <p>キュレーションページというのは、「地域xテーマ」ごとにページが生成されており、施設はランキング形式で表示されています。(先に掲載した画像は各キュレーションページの親ページになります)</p> <p>一方でリストページはさまざまな条件で検索ができるのですが、キュレーションページと同じように「地域、テーマ」による検索もできます。しかもランキング形式での表示もできるため、キュレーションページと同じようなページを再現できます。</p> <p>しかしながら、同じ条件で検索してもキュレーションページに表示される内容と異なることがあったのです。</p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nog_yo/20221005/20221005175742.png" width="1200" height="836" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <p>原因はランキング生成ロジックがそれぞれのページで異なっていたからです。</p> <p>もちろんどちらも八百長ということはなくデータに基づいてランキングを生成しているのですが、細かい条件が少しずつ異なり、結果別の施設が表示されてしまったのです。</p> <p>実装方針ですが、キュレーションページには親ページが存在し、親ページでもランキングを掲載しており、その親ページは残すという前提がありました。そのためリストページにキュレーションページのロジックを持ってくる必要があります。しかし一休ではアーキテクチャを更新している最中で、キュレーションページ側は旧アーキテクチャ、リストページ側は新アーキテクチャと別れてしまったためロジックの移植はしないことにしました。代わりにキュレーション側のアーキテクチャで「エリアIDとテーマID」を受け取ってランキングを返すAPIを作成し、それをリストページのアーキテクチャが叩くことにしました。</p> <p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nog_yo/20221005/20221005185542.png" width="1200" height="636" loading="lazy" title="" class="hatena-fotolife" itemprop="image" /></p> <h3 id="進捗は順調かと思ったら">進捗は順調・・・かと思ったら・・・</h3> <p>大体のページではランキングの結果が一致するようになり開発は順調かと思われたのですが、一部のページに限ってランキングの結果が合わない状況に直面しました。</p> <p>キュレーションページのURLをよくよく見てみると、なにやら見たことない数字がいました。</p> <blockquote> <p><a href="https://www.ikyu.com/theme/a140000/g10/101/">https://www.ikyu.com/theme/a140000/g10/101/</a></p> </blockquote> <p>「a140000」は東京を表すエリアIDで、「101」というのがテーマIDになります。「g10」は何者だ・・・?</p> <p>キュレーションページに詳しい方に聞いてみると、どうやらキュレーション関連のページ限定の概念であるジャンルIDをいうものもページ生成に影響しているようで、「地域xテーマ」ではなく「地域xテーマxジャンル」で生成されているとのことでした。</p> <p>ここで困ったことが起きました。</p> <p>APIの方は単にジャンルIDも指定できるようにすればよかったのですが、リストページではジャンルIDという概念がないため、そもそもAPIのパラメータに含めることができなかったのです。</p> <p>ジャンルIDを無視することも検討したのですが、それだとキュレーション親ページに表示されている施設とリストページに表示されている施設が異なってしまう可能性があります。一休ではユーザファーストを掲げているため、そんな体験は許されません。</p> <p>そこでリストページのアーキテクチャにジャンルIDという概念を組み込むことにしました。「リストページ」に組み込むのではなく、「アーキテクチャ」に組み込みます。このため当初予定していたよりも多くの改修が必要になり、リリース予定日も延長してしまいました。</p> <p>ですが、最終的には要件を落とすことなく、ユーザ体験も悪化させずにリストページに統合することができました。先のリンクを開くとリダイレクトしてリストページが開くのですが、「g10」の部分を変えることで結果が変わることを確認できると思います。</p> <h3 id="統合を終えて">統合を終えて</h3> <p>今回の統合では要件を落としたり、無理やり実装したりという部分もいくつかあったためちょっと悔しい思いも残っています。</p> <p>しかし訪れた人が予約した割合を比較してみると、統合前に比べて約1.6倍ほどに増えていました。少しでも使い勝手が良くなったと思い、端的に嬉しかったです。</p> <p>今後のプロジェクトでもより良い体験を提供できるよう精一杯努力して参ります。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206472" title="ソフトウェアエンジニア(正社員) | 株式会社一休" 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://hrmos.co/pages/ikyu/jobs/1693126708022206472">hrmos.co</a></cite></p> nog_yo 一休 × 出前館 Frontend Meetup の第2回を開催します! hatenablog://entry/4207112889920527852 2022-09-22T13:17:01+09:00 2022-09-22T13:17:01+09:00 前回好評だった一休と出前館のオンライン・イベント Frontend Meetup の第2回を開催します。 イベント後のアーカイブ動画を公開しませんのでご興味がある方はぜひご参加ください! 日時:9/29(木) 18:00~20:00 費用:無料 場所:オンライン(Zoom) お申し込みは以下のリンクからお願いします。 ikyu.connpass.com 時間 内容 登壇者 18:00 ご挨拶 18:05 - 18:25 一休. com/Yahoo!トラベルのNuxt3移行における開発プロセス 杉田 隆紀 18:25 - 18:45 React 18 に見るユーザーファーストなローディング表示 … <p>前回好評だった一休と出前館のオンライン・イベント Frontend Meetup の第2回を開催します。</p> <p>イベント後のアーカイブ動画を公開しませんのでご興味がある方はぜひご参加ください!</p> <ul> <li>日時:9/29(木) 18:00~20:00</li> <li>費用:無料</li> <li>場所:オンライン(Zoom)</li> </ul> <p>お申し込みは以下のリンクからお願いします。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fikyu.connpass.com%2Fevent%2F258913%2F" title="【一休 × 出前館】Frontend Meetup#2 (2022/09/29 18: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://ikyu.connpass.com/event/258913/">ikyu.connpass.com</a></cite></p> <table> <thead> <tr> <th style="text-align:left;">時間</th> <th style="text-align:left;">内容</th> <th style="text-align:left;">登壇者</th> </tr> </thead> <tbody> <tr> <td style="text-align:left;">18:00</td> <td style="text-align:left;">ご挨拶</td> <td></td> </tr> <tr> <td style="text-align:left;">18:05 - 18:25</td> <td style="text-align:left;">一休. com/Yahoo!トラベルのNuxt3移行における開発プロセス</td> <td style="text-align:left;">杉田 隆紀</td> </tr> <tr> <td style="text-align:left;">18:25 - 18:45</td> <td style="text-align:left;">React 18 に見るユーザーファーストなローディング表示</td> <td style="text-align:left;">yoshiyamay</td> </tr> <tr> <td style="text-align:left;">18:45 - 19:05</td> <td style="text-align:left;">Testing for Demae-can App</td> <td style="text-align:left;">黒澤 慎治</td> </tr> <tr> <td style="text-align:left;">19:05 - 19:35</td> <td style="text-align:left;">パネルディスカッション</td> <td></td> </tr> <tr> <td style="text-align:left;">20:00頃</td> <td style="text-align:left;">終了</td> <td></td> </tr> </tbody> </table> shozawa あなたのプロダクトに Apollo Client は必要ないかもしれない hatenablog://entry/4207112889894938952 2022-07-01T12:13:25+09:00 2022-07-01T12:13:25+09:00 Apollo Client は複雑 Apollo Client が向いているケース 一休.com に Apollo Client は必要ないかもしれない では何を使えばいいの? 複雑なアプリケーションには Apollo を使えばいい? もう一つのリッチなクライアント、Relay の話 結局、何を使えばいいのか この記事は一休 × 出前館 Frontend Meetup でお話した内容をブログにまとめたものです。 user-first.ikyu.co.jp speakerdeck.com GraphQL クライアントと聞いて一番に思い浮かぶライブラリは何でしょうか? 多くの方にとっては Apol… <ul class="table-of-contents"> <li><a href="#Apollo-Client-は複雑">Apollo Client は複雑</a></li> <li><a href="#Apollo-Client-が向いているケース">Apollo Client が向いているケース</a></li> <li><a href="#一休com-に-Apollo-Client-は必要ないかもしれない">一休.com に Apollo Client は必要ないかもしれない</a></li> <li><a href="#では何を使えばいいの">では何を使えばいいの?</a></li> <li><a href="#複雑なアプリケーションには-Apollo-を使えばいい">複雑なアプリケーションには Apollo を使えばいい?</a></li> <li><a href="#もう一つのリッチなクライアントRelay-の話">もう一つのリッチなクライアント、Relay の話</a></li> <li><a href="#結局何を使えばいいのか">結局、何を使えばいいのか</a></li> </ul> <p>この記事は一休 × 出前館 Frontend Meetup でお話した内容をブログにまとめたものです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fuser-first.ikyu.co.jp%2Fentry%2F2022%2F06%2F02%2F131548" title="一休 × 出前館 で Frontend Meetup を開催します! - 一休.com Developers Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://user-first.ikyu.co.jp/entry/2022/06/02/131548">user-first.ikyu.co.jp</a></cite></p> <p><iframe id="talk_frame_889018" class="speakerdeck-iframe" src="//speakerdeck.com/player/43b9ce30d439471f94f47c947ce1e16d" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/shozawa/how-to-pick-graphql-client">speakerdeck.com</a></cite></p> <p>GraphQL クライアントと聞いて一番に思い浮かぶライブラリは何でしょうか?</p> <p>多くの方にとっては Apollo Client ではないかと思います。npm trends を見ても Apollo Client のダウンロード数は urql や relay などほかのクライアントと比べ圧倒的です。</p> <p>実際、一休でも 一休.com や YADOLINK で Apollo を利用しています。</p> <table> <thead> <tr> <th style="text-align:left;">サービス</th> <th style="text-align:left;">GraphQL クライアント</th> </tr> </thead> <tbody> <tr> <td style="text-align:left;">一休.com</td> <td style="text-align:left;">Apollo Clinet</td> </tr> <tr> <td style="text-align:left;">YADOLINK</td> <td style="text-align:left;">Apollo Client</td> </tr> <tr> <td style="text-align:left;">レストラン座席管理画面</td> <td style="text-align:left;"> なし (axios)</td> </tr> <tr> <td style="text-align:left;">(新)EC①</td> <td style="text-align:left;">urql</td> </tr> <tr> <td style="text-align:left;">(新)EC②</td> <td style="text-align:left;">urql</td> </tr> <tr> <td style="text-align:left;">(新)予約管理画面</td> <td style="text-align:left;">Relay</td> </tr> </tbody> </table> <p>しかし、Apollo Client は 「一番有名だから」という理由で使っていいほど無難なライブラリではありません。どちらかといえば、使い所を選ぶ癖のあるライブラリだと私は考えています。</p> <p>この記事は一休での採用事例を交えながら GraphQL クライアントの選び方についてお話します。もしかすると、Apollo Client はあなたのプロダクトに合っていないかもしれません。</p> <h2 id="Apollo-Client-は複雑">Apollo Client は複雑</h2> <p>以下の図は主要なクライアントライブラリをバンドルサイズが小さい順に並べたものです。これは正確な指標ではありませんが、バンドルサイズの大きさと、機能の豊富さ・複雑さは比例していると考えると Apollo や Relay が比較的複雑なライブラリだということがわかります。</p> <table> <thead> <tr> <th style="text-align:left;">name</th> <th style="text-align:left;">minified + gzipped</th> </tr> </thead> <tbody> <tr> <td style="text-align:left;">cross-fetch</td> <td style="text-align:left;">2.8kB</td> </tr> <tr> <td style="text-align:left;">graphql-request</td> <td style="text-align:left;">7.6kB</td> </tr> <tr> <td style="text-align:left;">urql</td> <td style="text-align:left;">8.5kB</td> </tr> <tr> <td style="text-align:left;">@apollo/client</td> <td style="text-align:left;">40kB</td> </tr> <tr> <td style="text-align:left;">react-relay</td> <td style="text-align:left;">55kB</td> </tr> </tbody> </table> <p>バンドルサイズを小さくするために、別のクライアントを使えと言ってるわけではないことに注意してください。バンドルサイズも重要ですが、"必要最低限の機能を持っているライブラリを使う" ことが大切です。</p> <p>さて、GraphQL クライアントの仕事とは何でしょうか。突き詰めると HTTP リクエストを発行してAPIサーバーと通信することです。実は専用のクライアントを使わずとも fetch で GraphQL サーバーと通信ができます。</p> <p>では cross-fetch のようなシンプルなクライアントと比べ、なぜ Apollo Client はこんなにもバンドルサイズが大きいのでしょうか? 通信以外に Apollo が提供している機能とは何でしょうか。</p> <p>その答えは公式ドキュメントに書いてあります。</p> <blockquote><p>Apollo Client is a state management library that simplifies managing remote and local data with GraphQL.</p> <p>― Apollo Clientは、GraphQLを使用してリモートデータとローカルデータを簡単に管理できる状態管理ライブラリです。</p></blockquote> <p>Apollo Client は "状態管理" ライブラリなのです。</p> <p>Apollo Client を導入する際は、まず「このアプリケーションに状態管理ライブラリは必要か?」という問いに答えなければいけません。答えがNoであれば Apollo Client は必要ありません。</p> <p>かつてSPAと状態管理ライブラリはセットでした。どのプロダクトのコードを覗いても必ず状態管理ライブラリが入っていました。しかしグローバルな状態のデメリットが認知された現代では、状態管理ライブラリはアプリケーションにとっても必須のパーツではありません。</p> <h2 id="Apollo-Client-が向いているケース">Apollo Client が向いているケース</h2> <p>Apollo Client が向いているアプリケーションとはどんなものでしょうか?</p> <p>Apollo Client は "Mutation が頻繁に発生し、かつ Mutation 後に refetch できない" 性質を持つアプリケーションで真価を発揮します。例えば、Twitter や Instagram、我々が運営しているものだとYADOLINKのようなSNSに向いているでしょう。キーワードは『無限スクロール』です。</p> <p><figure class="figure-image figure-image-fotolife" title="YADOLINK"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shozawa/20220701/20220701081649.png" width="575" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>YADOLINK</figcaption></figure></p> <p>無限スクロールにより複数ページのデータ取得した後、特定のアイテムに Mutation を実行するときを考えてみます。例えば、投稿に「いいね」するという操作です。「いいね」が完了すると、投稿のハートアイコンに色が付き、いいねがカウントアップします。</p> <p>Apollo Client は Mutation のレスポンスを元に1ラウンドトリップで特定の投稿の値を書き換えます。これは Apollo Client の特徴である、"正規化されたキャッシュ" のおかげです。</p> <pre class="code lang-graphql" data-lang="graphql" data-unlink><span class="synType">mutation</span> <span class="synSpecial">{</span> <span class="synIdentifier">likePost</span>(<span class="synIdentifier">postId</span>: <span class="synType">Int</span><span class="synStatement">!</span>) <span class="synSpecial">{</span> <span class="synIdentifier">postId</span> <span class="synIdentifier">likeCount</span> <span class="synIdentifier">isLiked</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span> </pre> <p>反応速度を少し犠牲にすれば、 Mutation 後にページを丸ごと再取得することで同じことが実現できます。実際、urql の Document Cache では Mutation 後に関連するクエリを再取得することでUIを更新します。</p> <p>しかし無限スクロールによるページネーションを実装している場合は、現在の状態を復元するために複数ページ分のリクエストを発行する必要があるため、すべてのデータを再取得する戦略が現実的ではありません。</p> <p>逆に言うと、そもそも Mutation がほとんど発生しないアプリケーションや、Mutation 後にデータの再取得によってUIの更新をすることが許されるケースでは Apollo を使う必要はありません。</p> <h2 id="一休com-に-Apollo-Client-は必要ないかもしれない">一休.com に Apollo Client は必要ないかもしれない</h2> <p>さて、では Apollo Client を採用しているもう一つのアプリケーション、我々の看板サービスである「一休.com」はどうでしょうか?</p> <p>実は一休.comは Query がメインで Mutation がほとんど使われていません。</p> <p>ECサイトという性質上、一休上で発行されるリクエストのほとんどは "検索クエリ" です。最も重要な操作である "予約" はもちろん Mutation ですが、予約処理後に別ページへ遷移するので Mutation 後にローカルの状態を更新する必要はありません。他にも、クーポンの獲得・お気に入りの追加といった Mutation がありますが、これらもデータの再取得をすれば十分です。</p> <ul> <li>検索 ... Query</li> <li>予約 ... Mutation</li> <li>クーポンの獲得 ... Mutation</li> <li>お気に入りの追加 ... Mutation</li> </ul> <p>大は小を兼ねる、という一面もあり Apollo Client は一休.comで必要なユースケース "も" カバーしているため、普段の開発では Apollo を使うデメリット感じることはほとんどありません。</p> <p>しかしエッジケースにおいて、過分なライブラリを使っているせいでトラブルに巻き込まれることがあります。たとえば、一休.com ではIEからのアクセスに対して、Apollo の Store を fork した自前のキャッシュ機構を使うような実装になっていました。これは Apollo Client のキャッシュの正規化がIE上の特定のデータで非常に時間がかかってしまうためです。ひと月以上の時間をかけて Apollo Client のコードを読み、workaround を実装しました。</p> <p>また、Apollo のプラグインの対応状況が芳しくないせいで、Nuxt3 へのマイグレーションが遅れてしまっています。</p> <p>もっと軽量なライブラリを使っていれば、こういったトラブルにも巻き込まれいなかったでしょう。自分たちが必要な "一番ミニマムな実装" を採用することが重要だと学びました。</p> <h2 id="では何を使えばいいの">では何を使えばいいの?</h2> <p>では、Apollo が必要ないというケースではどのクライアントを使えばいいでしょうか?</p> <p>urql や graphql-request がおすすめです。</p> <p>Apollo が正規化されたキャッシュを持つのに対して、urql は Document Cache というシンプルなキャッシュ機構を採用している点が特徴です。urql は Mutation の実行後に Mutation の戻り値と同じ __typename を取得している Query をすべて再取得します。少し乱暴なようにも感じますが、この方法で十分というアプリケーションも多くあるはずです。</p> <p>また、Next.js を使っているなら swr + graphql-request という組み合わせも良いでしょう。graphql-request はもっともシンプルな GrapQL クライアントで、状態管理機能を持ちません。クライアント固有の状態がなく、APIレスポンスのキャッシュとして状態を扱うだけであればこの組み合わせがマッチします。swr は Vercel が作っているだけあって、Next と組み合わせてSSRするのも簡単なので要件によってはこちらを検討してみてください。</p> <h2 id="複雑なアプリケーションには-Apollo-を使えばいい">複雑なアプリケーションには Apollo を使えばいい?</h2> <p>ここまで、「シンプルなアプリケーションにはシンプルな GraphQL クライアントを使おう」というお話をしてきました。では、複雑なアプリケーションでは Apollo Clinet を使うのが正解なのでしょうか?</p> <p>実はそうではありません。複雑なアプリケーションの中には Apollo と相性が悪いものも存在します。それはサーバーのAPIキャッシュとは別に、リッチな状態を持つアプリケーションです。例えば、われわれが運営しているアプリケーションだと、レストランの席管理画面などがこれに該当します。</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/s/shozawa/20220701/20220701081754.png" width="1200" height="691" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>座席管理画面</figcaption></figure></p> <p>これは日付ごとに何席を一休レストランに提供するかを管理する "在庫カレンダー" と呼ばれる画面です。カレンダー内で日付を選んで、席毎にその日の提供座席数を決定します。曜日一括操作などもあり、サーバーとすぐに同期しない状態操作が多く存在します。</p> <p>Apollo にはサーバーと同期する状態の他にローカル固有のデータを操作する機能も提供しています。これは Reduxや Vuex が提供する状態管理と同等のものです。一休.com では一部のこの Local State Management の仕組みを利用していますが...正直なところ Redux, Recoil などの専門の状態管理ライブラリと比べてインターフェースがこなれておらず、あまり使いやすいとは言えません。</p> <p>Apollo で "ローカルの状態管理もできる" ことは確かですが、複雑な状態管理には素直に状態管理ライブラリを入れることをおすすめします。この場合は GraphQL クライアントで状態管理する必要はなくなるので、Recoil + graphql-request などの組み合わせ検討すると良いでしょう。</p> <p>上記の座席管理画面では GraphQL クライアントは使わずに axios でAPIサーバーと通信し、Vuex で状態管理を行っています。axios で GraphQL の型の恩恵をあまり受けられていないので...もし作り直すとしたら axios ではなく、 graphql-request を使い、GraphQL Code Generator でコードと型の自動生成を行いたいです。</p> <h2 id="もう一つのリッチなクライアントRelay-の話">もう一つのリッチなクライアント、Relay の話</h2> <p>最後に、Apollo Client と並んで高機能な Relay について少し触れます。Relay も正規化されたキャッシュを持つ GraphQL クライアントです。ユースケースとしては Apollo Client とほぼ同じだと考えていいでしょう。</p> <p>では Apollo ではなく、 Relay を使うべきなのはどういったときでしょうか? 以下のケースでは Relay を検討してもいいでしょう。</p> <ol> <li>コードの自動生成 + Fragment Colocation したい</li> <li>ページネーションされた要素に対して頻繁に要素の追加・削除を行う</li> <li>React の Experimental な機能をいち早く試したい</li> </ol> <p>Relay には Relay Compiler というコンパイラが付属しています。コンパイラの仕事はコード内の <code>graphql</code> タグから型情報を含むファイルを自動生成することです。GraphQL Code Generator 相当の働きをしているというとわかりやすいかもしれません。また Relay でアプリケーションを作ると自然と Fragment と Component が一致する Fragment Colocation スタイルになります。</p> <p>Relay には便利なディレクティブがいくつか追加されていますが、私が注目しているのは <code>@appendNode</code> <code>@prependNode</code> ディレクティブです。これは connection に対する要素の追加を宣言的に行えるディレクティブです。Apollo Client で要素を追加する際は手続き的にはキャッシュを操作する必要がありますが、Relay ではそれらの操作はライブラリ内に隠蔽されます。Facebook で利用されているだけあってSNSを作るのに便利な機能がほかにもあります。</p> <p>Relay は Meta 製のライブラリだけあって React の実験的な機能の取り込みが早いです。Suspense についても Suspense が Experimental の頃から対応していました。今後も先進的な機能が先取りされる可能性があります。ドキュメントが足りないのが懸念点ですが、トータルでは筋がよく、React エコシステムの未来を反映しているライブラリだと考えています。</p> <p>一休ではとある新規サービスの予約管理画面を Relay を使って開発中です。 社内向け管理画面のようなシンプルな管理画面にはシンプルな状態管理機構を持つ graphql-rquest / urql が向いていると思います。ただ、この予約画面はレストランの方も使うSaaSのような位置づけの管理画面なのでUXを重視して Relay を採用しています。</p> <p>YADOLINKはSNSですし、GraphQL Code Generator でのコード生成、Fragment Colocation も採用しているため、現在リリース中のアプリケーションの中ではYADOLINKが一番 Relay と相性が良さそうです。YADOLINKのアプリ版を React Native で作ることを検討しているので、その際は Relay が第一候補です。</p> <h2 id="結局何を使えばいいのか">結局、何を使えばいいのか</h2> <p>さて、まとめです。</p> <p>Apollo Client が圧倒的な知名度を持っているので Apollo Client を批判するような内容になってしまいましたがそうではありません。 Apollo Client が向いているアプリケーションもあれば、もっとシンプルなクライアントで十分な場合もあります。</p> <p>最後に簡単なフローチャートを掲載します。</p> <p>ECサイトや管理画面には Apollo は too much かもしれません。 Apollo Client や Relay、urql + GraphCache のようなリッチなクライアントはSNSのようなユーザーの Mutation が頻繁に発生するサイトに向いています。</p> <p><figure class="figure-image figure-image-fotolife" title="GraphQL クライアントの選び方"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shozawa/20220701/20220701081932.png" width="1200" height="670" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>GraphQL クライアントの選び方</figcaption></figure></p> shozawa GraphQL + Go による画像投稿機能の実装談・・・Exif 情報の削除、AWS S3 での画像管理、ユーザー体験の模索など hatenablog://entry/4207112889893974460 2022-06-30T14:29:06+09:00 2023-08-23T12:35:01+09:00 こんにちは。宿泊プロダクト開発部 UI開発チーム エンジニアの香西です。 半年ほど前に、一休.comとヤフートラベルで、クチコミ画像の投稿機能をリリースしました。 一休.comとヤフートラベルでは、ユーザーに画像をアップロードしてもらう機能の実装は前例が無かったため、試行錯誤しながらの開発となりました。 今回はその時の開発についてお話したいと思います。 背景 全体像 フロントエンドの実装 GraphQL のリクエスト送信 どのタイミングで画像をアップロードするか アップロード進捗状況を表示したい バックエンドの実装 画像のバリデーション 画像のデコード・エンコード (余談)JPEG のエンコー… <p>こんにちは。宿泊プロダクト開発部 UI開発チーム エンジニアの香西です。</p> <p>半年ほど前に、一休.comとヤフートラベルで、クチコミ画像の投稿機能をリリースしました。<br/> 一休.comとヤフートラベルでは、ユーザーに画像をアップロードしてもらう機能の実装は前例が無かったため、試行錯誤しながらの開発となりました。<br/> 今回はその時の開発についてお話したいと思います。</p> <ul class="table-of-contents"> <li><a href="#背景">背景</a></li> <li><a href="#全体像">全体像</a></li> <li><a href="#フロントエンドの実装">フロントエンドの実装</a><ul> <li><a href="#GraphQL-のリクエスト送信">GraphQL のリクエスト送信</a></li> <li><a href="#どのタイミングで画像をアップロードするか">どのタイミングで画像をアップロードするか</a></li> <li><a href="#アップロード進捗状況を表示したい">アップロード進捗状況を表示したい</a></li> </ul> </li> <li><a href="#バックエンドの実装">バックエンドの実装</a><ul> <li><a href="#画像のバリデーション">画像のバリデーション</a></li> <li><a href="#画像のデコードエンコード">画像のデコード・エンコード</a></li> <li><a href="#余談JPEG-のエンコードでメモリを大量に使用してハマった">(余談)JPEG のエンコードでメモリを大量に使用してハマった</a><ul> <li><a href="#ベンチマーク計測">ベンチマーク計測</a></li> </ul> </li> <li><a href="#Amazon-S3-バケットに画像をアップロード">Amazon S3 バケットに画像をアップロード</a></li> <li><a href="#S3-署名付きURLを使用">S3 署名付きURLを使用</a></li> </ul> </li> <li><a href="#使いやすいユーザーインターフェースを求めて">使いやすいユーザーインターフェースを求めて</a></li> <li><a href="#最後に">最後に</a></li> </ul> <h2 id="背景">背景</h2> <p>クチコミを投稿する機能自体は、以前から存在していました。<br/> 実際に宿泊したユーザーだけがクチコミを投稿できるため、信憑性の高いクチコミではあるものの、投稿できるのは文字情報のみでした。近年、あらゆるサービスにおいてクチコミの重要性が高まってきているため、視覚情報を増やしてクチコミの質をあげるべく、画像を投稿できるようにしよう!ということで、クチコミ画像の投稿機能を開発することになりました。</p> <h2 id="全体像">全体像</h2> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20220630/20220630132553.png" width="1148" height="438" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>画像の保管場所には、Amazon S3 を使用することにしました。<br/> 以前から一休.comとヤフートラベルで使用しており、imgIX と連携する仕組みがすでに整っていたため、サイト上で扱いやすいというのが一番の理由です。<br/> もう一つの理由は、「外部からアクセスできる保管場所」「外部からアクセスできない保管場所」をそれぞれ用意したかったためです。<br/> ユーザーが投稿した画像をそのままサイト上に公開するのではなく、社内で掲載チェック(不適切な画像を取り除く)をしてからサイト上に公開したいという要件がありました。つまり、掲載チェックが済んでいない画像は、「外部からアクセスできない場所」に置いておく必要があります。 Amazon S3 で「公開バケット」「非公開バケット」を用意し、それぞれ適切なアクセス設定を行うことで、今回の要件が実現できることがわかったので、Amazon S3 を使用することにしました。</p> <p>ただし、掲載チェックが済んでいない「非公開バケット」にある画像であっても、</p> <ul> <li>投稿者のユーザーに対してのみ表示したい(自分がどんな画像を投稿したか確認できるようにするため)</li> <li>掲載チェックを行うために社内の管理画面上には表示したい</li> </ul> <p>という要件が出てきました。この点については、後述する「署名付きURL」の仕組みを使って、「非公開バケット」にある画像を特定の画面上でのみ表示できるようにしました。</p> <h2 id="フロントエンドの実装">フロントエンドの実装</h2> <h3 id="GraphQL-のリクエスト送信">GraphQL のリクエスト送信</h3> <p>Apollo でファイルをアップロードする方法はいくつかありますが、multipart リクエストを使用して mutation を実行し画像をアップロードする方法を採用しました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink>uploadFile<span class="synStatement">(</span>file: File<span class="synStatement">)</span>: <span class="synType">void</span> <span class="synIdentifier">{</span> <span class="synType">const</span> input: ReviewImageInput <span class="synStatement">=</span> <span class="synIdentifier">{</span> id: <span class="synConstant">12345678</span><span class="synStatement">,</span> file: <span class="synType">null</span><span class="synStatement">,</span> <span class="synIdentifier">}</span> <span class="synType">const</span> formData <span class="synStatement">=</span> <span class="synStatement">new</span> FormData<span class="synStatement">()</span> formData.append<span class="synStatement">(</span> <span class="synConstant">'operations'</span><span class="synStatement">,</span> <span class="synConstant">`{ &quot;query&quot;: &quot;mutation($input: ReviewImageInput!) { registerReviewImage(input: $input) { id error __typename }}&quot;,</span> <span class="synConstant"> &quot;variables&quot;: { &quot;input&quot;: </span><span class="synSpecial">${JSON</span>.stringify(input)<span class="synSpecial">}</span><span class="synConstant"> } }`</span><span class="synStatement">,</span> <span class="synStatement">)</span> formData.append<span class="synStatement">(</span><span class="synConstant">'map'</span><span class="synStatement">,</span> <span class="synConstant">'{ &quot;0&quot;: [&quot;variables.input.file&quot;] }'</span><span class="synStatement">)</span> formData.append<span class="synStatement">(</span><span class="synConstant">'0'</span><span class="synStatement">,</span> file<span class="synStatement">)</span> <span class="synComment">// 以下略</span> </pre> <h3 id="どのタイミングで画像をアップロードするか">どのタイミングで画像をアップロードするか</h3> <p>クチコミ投稿を行うときの画面の構成は、以下の三画面です。1~3の順に遷移します。</p> <ol> <li>クチコミ入力画面(ここで投稿する画像を選択する)</li> <li>クチコミ入力確認画面(選択した画像を確認する)</li> <li>クチコミ投稿完了画面</li> </ol> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20220630/20220630111144.png" width="963" height="783" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>どのタイミングで、画像アップロードのリクエストを送信するのがよいでしょうか。</p> <p>クチコミ入力画面で、画像を選択するたびにリクエストを送信する?<br/> もしくは、クチコミ確認画面で「投稿する」ボタンを押したときに、全画像まとめてリクエストを送信する?</p> <p>全画像まとめてアップロード処理を行った場合、処理が完了するまでユーザーを待たせることになり、画像枚数が多いと煩わしさを感じるかもしれません。 また、アップロードに失敗したときに最初から画像を選択し直すとなるとユーザーのモチベーションが下がってしまうので、どの画像が失敗したのかユーザーに伝えつつ成功した画像のみ復元して...といったケアをしようとすると、処理がどんどん複雑化していきそうです。</p> <p>開発メンバーで検討した結果、ユーザーが画像を選択したタイミングで、1枚ずつ画像アップロードのリクエストを送信することにしました。 それがユーザーにとって最もスムーズな体験であり、かつ実装上もシンプルだという結論に至りました。</p> <h3 id="アップロード進捗状況を表示したい">アップロード進捗状況を表示したい</h3> <p>各画像のアップロード処理がどのくらい進んでいるのか?が視覚的に分かると、ユーザーにとって安心感があると思います。<br/> しかし、fetch API / Apollo client ではアップロードの進捗を確認する機能がサポートされていなかったため、XMLHttpRequest の upload プロパティで進捗を監視し、プログレスバーでアップロードの進捗を表示するようにしました。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synSpecial">fetch</span><span class="synStatement">(</span> url: <span class="synType">string</span><span class="synStatement">,</span> opts: <span class="synType">any</span><span class="synStatement">,</span> onProgress: <span class="synStatement">(</span>ev: ProgressEvent<span class="synStatement">&lt;</span>EventTarget<span class="synStatement">&gt;)</span> <span class="synStatement">=&gt;</span> <span class="synType">void</span><span class="synStatement">,</span> <span class="synStatement">)</span>: <span class="synSpecial">Promise</span><span class="synStatement">&lt;</span><span class="synType">string</span><span class="synStatement">&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">new</span> <span class="synSpecial">Promise</span><span class="synStatement">((</span>resolve<span class="synStatement">,</span> reject<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synType">const</span> xhr <span class="synStatement">=</span> <span class="synStatement">new</span> XMLHttpRequest<span class="synStatement">()</span> xhr.open<span class="synStatement">(</span>opts.method <span class="synConstant">||</span> <span class="synConstant">'get'</span><span class="synStatement">,</span> url<span class="synStatement">)</span> xhr.timeout <span class="synStatement">=</span> <span class="synConstant">60000</span> <span class="synStatement">for</span> <span class="synStatement">(</span><span class="synType">const</span> k <span class="synStatement">in</span> opts.headers <span class="synConstant">||</span> <span class="synIdentifier">{}</span><span class="synStatement">)</span> xhr.setRequestHeader<span class="synStatement">(</span>k<span class="synStatement">,</span> opts.headers<span class="synIdentifier">[</span>k<span class="synIdentifier">]</span><span class="synStatement">)</span> xhr.<span class="synSpecial">onload</span> <span class="synStatement">=</span> <span class="synStatement">()</span> <span class="synStatement">=&gt;</span> resolve<span class="synStatement">(</span>xhr.response<span class="synStatement">)</span> xhr.onerror <span class="synStatement">=</span> <span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> reject<span class="synStatement">(</span>e<span class="synStatement">)</span> xhr.ontimeout <span class="synStatement">=</span> <span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> reject<span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">if</span> <span class="synStatement">(</span>xhr.upload<span class="synStatement">)</span> xhr.upload.onprogress <span class="synStatement">=</span> onProgress xhr.send<span class="synStatement">(</span>opts.body<span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">,</span> </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20220628/20220628104033.png" width="448" height="319" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="バックエンドの実装">バックエンドの実装</h2> <p>フロントエンドから画像アップロードのリクエストが飛んできたとき、大まかに以下の4つの処理を行っています。</p> <ul> <li>画像のバリデーション</li> <li>画像のデコード・エンコード</li> <li>S3 非公開画像用バケットに画像をアップロード</li> <li>データベースに画像情報を登録</li> </ul> <h3 id="画像のバリデーション">画像のバリデーション</h3> <p>画像の条件については、<a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP のチートシート</a>や他サービスなどを参考にしながら以下の仕様に決めました。</p> <table> <thead> <tr> <th> チェック項目 </th> <th> 制限 </th> </tr> </thead> <tbody> <tr> <td> 1画像ファイルサイズ上限 </td> <td> 10MBまで </td> </tr> <tr> <td> 画像ファイル種類(MIMEタイプ) </td> <td> image/jpeg, image/png </td> </tr> <tr> <td> 画像ファイルの縦横比 </td> <td> 4:1まで許容 </td> </tr> <tr> <td> 画像ファイルの最小幅 </td> <td> 80px </td> </tr> </tbody> </table> <p>ユーザーから送信される content-type ヘッダーは偽装される可能性があるため信頼せず、画像のバイナリデータの先頭 512byte を見てファイル種類(MIMEタイプ)の判定を行うようにしました。<br/> Go の http パッケージの <a href="https://pkg.go.dev/net/http#DetectContentType">DetectContentType</a> を使用しています。</p> <pre class="code lang-go" data-lang="go" data-unlink>head := <span class="synStatement">make</span>([]<span class="synType">byte</span>, <span class="synConstant">512</span>) n, err := r.Read(head) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> &amp;&amp; !errors.Is(err, io.EOF) { <span class="synStatement">return</span> <span class="synStatement">nil</span>, err } contentType := http.DetectContentType(head[:n]) <span class="synStatement">if</span> contentType != <span class="synConstant">&quot;image/jpeg&quot;</span> &amp;&amp; contentType != <span class="synConstant">&quot;image/png&quot;</span> { <span class="synStatement">return</span> <span class="synStatement">nil</span>, ErrRegisterInvalidType } </pre> <h3 id="画像のデコードエンコード">画像のデコード・エンコード</h3> <p>セキュリティ観点から、Amazon S3 に画像をアップロードする前に、画像のバイナリデータに含まれている Exif 情報(位置情報・撮影日時など)を削除する必要があります。<br/> Exif 情報には画像の向き(Orientation)が含まれているため、この情報は削除したくありません。</p> <p>Go の <a href="https://github.com/disintegration/imaging">imaging</a> パッケージを使用してデコードを行うと、Exif 情報が取り除かれた Image が返ってきます。 また、引数に <code>imaging.AutoOrientation(true)</code> のオプションを渡すと画像の向き(Orientation)を自動で適用してくれます。</p> <pre class="code lang-go" data-lang="go" data-unlink>img, err := imaging.Decode(r, imaging.AutoOrientation(<span class="synStatement">true</span>)) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { <span class="synStatement">return</span> <span class="synStatement">nil</span>, err } </pre> <p>デコードで Exif 情報を取り除いた Image を、今度はエンコードし、Amazon S3 にアップロードする画像データを用意します。<br/> JPEG は、引数に <code>imaging.JPEGQuality(75)</code> のオプションを渡して品質を指定することができます。 デフォルト値 <code>95</code> のままエンコードすると、画像によってはファイル容量が2倍程度大きくなるケースが見受けられたため <code>75</code> を指定することにしました。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">import</span> ( <span class="synConstant">&quot;bytes&quot;</span> <span class="synConstant">&quot;github.com/disintegration/imaging&quot;</span> ) <span class="synStatement">func</span> (i *Image) Encode() (*bytes.Reader, <span class="synType">error</span>) { b := <span class="synStatement">new</span>(<span class="synType">bytes.Buffer</span>) <span class="synStatement">if</span> i.ContentType == <span class="synConstant">&quot;image/jpeg&quot;</span> { err := imaging.Encode(b, i.Image, imaging.JPEG, imaging.JPEGQuality(<span class="synConstant">75</span>)) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { <span class="synStatement">return</span> <span class="synStatement">nil</span>, err } } <span class="synStatement">else</span> { err := imaging.Encode(b, i.Image, imaging.PNG, imaging.PNGCompressionLevel(png.DefaultCompression)) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { <span class="synStatement">return</span> <span class="synStatement">nil</span>, err } } <span class="synStatement">return</span> bytes.NewReader(b.Bytes()), <span class="synStatement">nil</span> } </pre> <h3 id="余談JPEG-のエンコードでメモリを大量に使用してハマった">(余談)JPEG のエンコードでメモリを大量に使用してハマった</h3> <p>画像のデコード・エンコードでは imaging パッケージを使用したとお話しましたが、開発当初は Go 標準 の image パッケージを使用して、デコード・エンコードを行い、Exif 情報を削除しようとしていました。<br/> ところが、いざ処理を実行してみるとすごく重かったのです。</p> <p>testing パッケージでベンチマークを測定したところ、メモリを大量に使用していることが判明しました。</p> <p>デバッグしながら調査していくと、image パッケージの <a href="https://pkg.go.dev/image/jpeg#Encode">jpeg.Encode</a> の処理が怪しそうだという事がわかってきました。<br/> さらに深堀してみると、画像データに書き出している処理 <code>writeSOS</code> のなかで、<code>rgba</code> <code>ycbcr</code> がどちらも nil になっていたため、 <code>toYCbCr</code> の処理に入っていました。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synComment">// writeSOS writes the StartOfScan marker.</span> <span class="synStatement">func</span> (e *encoder) writeSOS(m image.Image) { <span class="synComment">// 中略</span> <span class="synStatement">default</span>: rgba, _ := m.(*image.RGBA) <span class="synComment">// nil になっていた</span> ycbcr, _ := m.(*image.YCbCr) <span class="synComment">// nil になっていた</span> <span class="synStatement">for</span> y := bounds.Min.Y; y &lt; bounds.Max.Y; y += <span class="synConstant">16</span> { <span class="synStatement">for</span> x := bounds.Min.X; x &lt; bounds.Max.X; x += <span class="synConstant">16</span> { <span class="synStatement">for</span> i := <span class="synConstant">0</span>; i &lt; <span class="synConstant">4</span>; i++ { xOff := (i &amp; <span class="synConstant">1</span>) * <span class="synConstant">8</span> yOff := (i &amp; <span class="synConstant">2</span>) * <span class="synConstant">4</span> p := image.Pt(x+xOff, y+yOff) <span class="synStatement">if</span> rgba != <span class="synStatement">nil</span> { rgbaToYCbCr(rgba, p, &amp;b, &amp;cb[i], &amp;cr[i]) } <span class="synStatement">else</span> <span class="synStatement">if</span> ycbcr != <span class="synStatement">nil</span> { yCbCrToYCbCr(ycbcr, p, &amp;b, &amp;cb[i], &amp;cr[i]) } <span class="synStatement">else</span> { toYCbCr(m, p, &amp;b, &amp;cb[i], &amp;cr[i]) <span class="synComment">// ここの処理に入っていた</span> } prevDCY = e.writeBlock(&amp;b, <span class="synConstant">0</span>, prevDCY) } scale(&amp;b, &amp;cb) prevDCCb = e.writeBlock(&amp;b, <span class="synConstant">1</span>, prevDCCb) scale(&amp;b, &amp;cr) prevDCCr = e.writeBlock(&amp;b, <span class="synConstant">1</span>, prevDCCr) } } <span class="synError"> </span> <span class="synComment">// 以下略</span> </pre> <p><code>toYCbCr</code> の処理なかを見ていくと、<code>Image.At</code> を使って各ピクセルの色情報(RGBA)を取得していました。ここでメモリを大量に使用していました。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synComment">// toYCbCr converts the 8x8 region of m whose top-left corner is p to its</span> <span class="synComment">// YCbCr values.</span> <span class="synStatement">func</span> toYCbCr(m image.Image, p image.Point, yBlock, cbBlock, crBlock *block) { b := m.Bounds() xmax := b.Max.X - <span class="synConstant">1</span> ymax := b.Max.Y - <span class="synConstant">1</span> <span class="synStatement">for</span> j := <span class="synConstant">0</span>; j &lt; <span class="synConstant">8</span>; j++ { <span class="synStatement">for</span> i := <span class="synConstant">0</span>; i &lt; <span class="synConstant">8</span>; i++ { r, g, b, _ := m.At(min(p.X+i, xmax), min(p.Y+j, ymax)).RGBA() <span class="synComment">// ここで Image.At</span> yy, cb, cr := color.RGBToYCbCr(<span class="synType">uint8</span>(r&gt;&gt;<span class="synConstant">8</span>), <span class="synType">uint8</span>(g&gt;&gt;<span class="synConstant">8</span>), <span class="synType">uint8</span>(b&gt;&gt;<span class="synConstant">8</span>)) yBlock[<span class="synConstant">8</span>*j+i] = <span class="synType">int32</span>(yy) cbBlock[<span class="synConstant">8</span>*j+i] = <span class="synType">int32</span>(cb) crBlock[<span class="synConstant">8</span>*j+i] = <span class="synType">int32</span>(cr) } } } </pre> <p>こちらの <a href="https://github.com/golang/go/issues/18487">issue</a> でも言及されており<a href="https://github.com/golang/go/commit/435450bf3c6efcc65111e96a42fc1c8acd3081e3">こちら</a>で修正されていましたが、<code>ycbcr</code> が nil ではない場合に <code>yCbCrToYCbCr</code> の処理に入るようになっているため、そもそも <code>ycbcr</code> が nil になってしまうと、<code>toYCbCr</code> の処理のほうに入って <code>Image.At</code> によってメモリが大量に使われてしまう、ということが起きていました。</p> <p>ちなみに、JPEG のデータがどうなっているかを理解する際、 <a href="https://github.com/corkami/pics/blob/master/binary/README.md">Ange Albertini</a> さんの作ったイメージに助けてもらったので貼っておきます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20220630/20220630094820.png" width="1200" height="1085" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> 引用: <a href="https://github.com/corkami/pics/blob/master/binary/JPG.png">https://github.com/corkami/pics/blob/master/binary/JPG.png</a></p> <p>さてどうしようかと頭を悩ませていましたが、社内メンバーに助言をもらい imaging パッケージを使ってデコード・エンコードしてみたところ、メモリの使用量が抑えられたのでした。<br/> さらには Orientation も自動設定してくれるので、自力で Orientation を設定するコードも不要になりました。</p> <p>imaging パッケージの <a href="https://pkg.go.dev/github.com/disintegration/imaging#Encode">Encode</a> でも、内部では image パッケージの <a href="https://pkg.go.dev/image/jpeg#Encode">jpeg.Encode</a> を使っていますが、事前に <code>rgba</code> を作成し、<code>jpeg.Encode</code> に <code>rgba</code> を渡していました。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synComment">// Encode writes the image img to w in the specified format (JPEG, PNG, GIF, TIFF or BMP).</span> <span class="synStatement">func</span> Encode(w <span class="synType">io.Writer</span>, img image.Image, format Format, opts ...EncodeOption) <span class="synType">error</span> { cfg := defaultEncodeConfig <span class="synStatement">for</span> _, option := <span class="synStatement">range</span> opts { option(&amp;cfg) } <span class="synStatement">switch</span> format { <span class="synStatement">case</span> JPEG: <span class="synStatement">if</span> nrgba, ok := img.(*image.NRGBA); ok &amp;&amp; nrgba.Opaque() { rgba := &amp;image.RGBA{ Pix: nrgba.Pix, Stride: nrgba.Stride, Rect: nrgba.Rect, } <span class="synStatement">return</span> jpeg.Encode(w, rgba, &amp;jpeg.Options{Quality: cfg.jpegQuality}) <span class="synComment">// jpeg.Encode に rgba を渡していた</span> } <span class="synStatement">return</span> jpeg.Encode(w, img, &amp;jpeg.Options{Quality: cfg.jpegQuality}) <span class="synComment">// 以下略</span> </pre> <p>そうすることで、例の <code>writeSOS</code> の処理のなかで <code>rgba</code> が nil にならず、<code>rgbaToYCbCr</code> の処理のほうへ入るようになりました。<br/> <code>rgbaToYCbCr</code> の処理のなかではすでに色情報(RGBA)が分かっているため <code>Image.At</code> を実行する必要もなく、大量にメモリを使うことなくエンコードが出来ていました。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synComment">// writeSOS writes the StartOfScan marker.</span> <span class="synStatement">func</span> (e *encoder) writeSOS(m image.Image) { <span class="synComment">// 中略</span> <span class="synStatement">for</span> i := <span class="synConstant">0</span>; i &lt; <span class="synConstant">4</span>; i++ { xOff := (i &amp; <span class="synConstant">1</span>) * <span class="synConstant">8</span> yOff := (i &amp; <span class="synConstant">2</span>) * <span class="synConstant">4</span> p := image.Pt(x+xOff, y+yOff) <span class="synStatement">if</span> rgba != <span class="synStatement">nil</span> { rgbaToYCbCr(rgba, p, &amp;b, &amp;cb[i], &amp;cr[i]) <span class="synComment">// こちらの処理に入るようになった</span> } <span class="synStatement">else</span> <span class="synStatement">if</span> ycbcr != <span class="synStatement">nil</span> { yCbCrToYCbCr(ycbcr, p, &amp;b, &amp;cb[i], &amp;cr[i]) } <span class="synStatement">else</span> { toYCbCr(m, p, &amp;b, &amp;cb[i], &amp;cr[i]) <span class="synComment">// もともとは、ここの処理に入っていた</span> } prevDCY = e.writeBlock(&amp;b, <span class="synConstant">0</span>, prevDCY) } <span class="synComment">// 以下略</span> </pre> <h4 id="ベンチマーク計測">ベンチマーク計測</h4> <p>imaging パッケージを使った場合・image パッケージのみを使った場合でベンチマークを比較してみると、その差は明らかです。<br/> imaging パッケージを使ったほうが、処理速度・メモリ割当領域・メモリアロケーション回数が小さく高パフォーマンスであることが分かります。</p> <pre class="code" data-lang="" data-unlink>$ go test -bench . -benchmem goos: windows goarch: amd64 cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz BenchmarkImagingPkg-16 3 435976333 ns/op 71321248 B/op 122 allocs/op -- imaging パッケージ使用 BenchmarkImagePkg-16 2 614217700 ns/op 120224332 B/op 12193780 allocs/op -- image パッケージ使用 </pre> <p>以下がベンチマーク測定のために用意したコードです。<br/> ※確認用のため処理を簡易化し、エラーハンドリングはしていません。</p> <p>image パッケージを使用していたときは、わざわざ Orientation を設定する関数 <code>setOrientation</code> を書いて、画像データが持つ Orientation の値を見て Image を回転させる、ということをやっていました。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">import</span> ( <span class="synConstant">&quot;bytes&quot;</span> <span class="synConstant">&quot;image&quot;</span> <span class="synConstant">&quot;image/jpeg&quot;</span> <span class="synConstant">&quot;os&quot;</span> <span class="synConstant">&quot;testing&quot;</span> <span class="synConstant">&quot;github.com/disintegration/imaging&quot;</span> <span class="synConstant">&quot;github.com/rwcarlsen/goexif/exif&quot;</span> ) <span class="synComment">// imaging パッケージ使用</span> <span class="synStatement">func</span> BenchmarkImagingPkg(t *testing.B) { <span class="synStatement">for</span> i := <span class="synConstant">0</span>; i &lt; t.N; i++ { file, _ := os.Open(<span class="synConstant">&quot;C://dev/test-exif-orientation-2842.jpg&quot;</span>) <span class="synStatement">defer</span> file.Close() <span class="synComment">// デコード</span> img, _ := imaging.Decode(file, imaging.AutoOrientation(<span class="synStatement">true</span>)) <span class="synComment">// エンコード</span> b := <span class="synStatement">new</span>(<span class="synType">bytes.Buffer</span>) _ = imaging.Encode(b, img, imaging.JPEG, imaging.JPEGQuality(<span class="synConstant">75</span>)) } } <span class="synComment">// image パッケージ使用</span> <span class="synStatement">func</span> BenchmarkImagePkg(t *testing.B) { <span class="synStatement">for</span> i := <span class="synConstant">0</span>; i &lt; t.N; i++ { file, _ := os.Open(<span class="synConstant">&quot;C://dev/test-exif-orientation-2842.jpg&quot;</span>) <span class="synStatement">defer</span> file.Close() <span class="synComment">// デコード</span> img, _, _ := image.Decode(file) _, _ = file.Seek(<span class="synConstant">0</span>, <span class="synConstant">0</span>) <span class="synComment">// 画像の Exif 情報から Orientation を取得し、デコードした Image に Orientation を設定する</span> ex, _ := exif.Decode(file) tag, _ := ex.Get(exif.Orientation) orientation, _ := tag.Int(<span class="synConstant">0</span>) newImg, _ := setOrientation(img, orientation) <span class="synComment">// エンコード</span> b := <span class="synStatement">new</span>(<span class="synType">bytes.Buffer</span>) _ = jpeg.Encode(b, newImg, <span class="synStatement">nil</span>) } } <span class="synStatement">func</span> setOrientation(img image.Image, orientation <span class="synType">int</span>) (image.Image, <span class="synType">error</span>) { <span class="synStatement">var</span> newImg image.Image <span class="synComment">// @see: https://www.jeita.or.jp/japanese/standard/book/CP-3451E_J/#target/page_no=34</span> <span class="synStatement">switch</span> orientation { <span class="synStatement">case</span> <span class="synConstant">1</span>: newImg = img <span class="synStatement">case</span> <span class="synConstant">2</span>: newImg = imaging.FlipH(img) <span class="synStatement">case</span> <span class="synConstant">3</span>: newImg = imaging.Rotate180(img) <span class="synStatement">case</span> <span class="synConstant">4</span>: newImg = imaging.FlipV(img) <span class="synStatement">case</span> <span class="synConstant">5</span>: newImg = imaging.Rotate90(img) newImg = imaging.FlipH(newImg) <span class="synStatement">case</span> <span class="synConstant">6</span>: newImg = imaging.Rotate90(img) <span class="synStatement">case</span> <span class="synConstant">7</span>: newImg = imaging.Rotate270(img) newImg = imaging.FlipH(newImg) <span class="synStatement">case</span> <span class="synConstant">8</span>: newImg = imaging.Rotate270(img) <span class="synStatement">default</span>: <span class="synStatement">return</span> <span class="synStatement">nil</span>, errors.New(<span class="synConstant">&quot;invalid value: &quot;</span> + strconv.Itoa(orientation)) } <span class="synStatement">return</span> newImg, <span class="synStatement">nil</span> } </pre> <h3 id="Amazon-S3-バケットに画像をアップロード">Amazon S3 バケットに画像をアップロード</h3> <p>「非公開バケット」に画像をアップロードするときは、<a href="https://aws.amazon.com/jp/sdk-for-go/">AWS SDK for Go</a> の PutObjectWithContext を使用しています。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">import</span> ( <span class="synConstant">&quot;context&quot;</span> <span class="synConstant">&quot;github.com/aws/aws-sdk-go/aws&quot;</span> <span class="synConstant">&quot;github.com/aws/aws-sdk-go/service/s3&quot;</span> ) <span class="synStatement">func</span> (c *client) Put(ctx context.Context, resource <span class="synType">string</span>, input PutInput) <span class="synType">error</span> { in := &amp;s3.PutObjectInput{ Bucket: aws.String(input.Target.Bucket), Key: aws.String(input.Target.Key), Body: input.Body, ContentType: aws.String(input.ContentType), ContentLength: aws.Int64(input.ContentLength), } ctx = httptrace.WithSpan(ctx, c.service, resource, <span class="synType">map</span>[<span class="synType">string</span>]any{ <span class="synConstant">&quot;http.content_length&quot;</span>: input.ContentLength, <span class="synConstant">&quot;http.content_type&quot;</span>: input.ContentType, }) _, err := c.s3.PutObjectWithContext(ctx, in) <span class="synStatement">return</span> err } </pre> <h3 id="S3-署名付きURLを使用">S3 署名付きURLを使用</h3> <p>掲載チェックが済んでいない「非公開バケット」にある画像であっても、</p> <ul> <li>投稿者のユーザーに対してのみ表示したい(自分がどんな画像を投稿したか確認できるようにするため)</li> <li>掲載チェックするために社内の管理画面上には表示したい</li> </ul> <p>という話を冒頭でしました。</p> <p>これを実現するために<a href="https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html">「署名付きURL」</a>の仕組みを使用することにしました。<br/> 外部からアクセスできないように制御している画像に対して、署名付きURLを発行することができます。署名付きURLを <code>&lt;img&gt;</code> タグの <code>src</code> に指定し、特定の画面上に画像を表示しています。<br/> なお、署名付きURLの有効時間は、自由に指定することができます。</p> <pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">import</span> ( <span class="synConstant">&quot;github.com/aws/aws-sdk-go/aws&quot;</span> <span class="synConstant">&quot;github.com/aws/aws-sdk-go/service/s3&quot;</span> ) <span class="synStatement">func</span> (c *client) Presign(bucket <span class="synType">string</span>, key <span class="synType">string</span>) (<span class="synType">string</span>, <span class="synType">error</span>) { req, _ := c.s3.GetObjectRequest(&amp;s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) url, err := req.Presign(<span class="synConstant">5</span> * time.Minute) <span class="synStatement">if</span> err != <span class="synStatement">nil</span> { <span class="synStatement">return</span> <span class="synConstant">&quot;&quot;</span>, err } <span class="synStatement">return</span> url, <span class="synStatement">nil</span> } </pre> <h2 id="使いやすいユーザーインターフェースを求めて">使いやすいユーザーインターフェースを求めて</h2> <p>フロントエンド、バックエンド、両方の実装がだいたい完了して動作する状態になったらすぐにデモ環境にデプロイし、プロジェクトメンバーに触り心地を確認してもらうようにしました。UI開発チームでは、他のプロジェクトにおいても、なるべく早い段階でデモ環境にデプロイしてみんなで触ってみる、ということを大切にしています。</p> <p>そこで出てきたフィードバックをもとに修正し、再びユーザー体験を確認し...を繰り返して改善していきます。今回、サービス初の画像アップロード機能ということで、実際に触ってみるとさまざまな問題が出てきましたが、デザイナーと密に連携しながらユーザーインターフェースを詰めていきました。</p> <p>▽ Slack 上のフィードバックのやりとり <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozaiy/20220628/20220628104124.png" width="957" height="739" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="最後に">最後に</h2> <p>クチコミ画像の投稿機能の他にも、社内での掲載チェック機能や、非公開画像の削除機能など、関連機能がいろいろあるのですが、今回は画像の投稿機能に焦点をあててお話してみました。</p> <p>UI開発チームでは、ユーザー体験に関わる部分はフロントエンド・バックエンドに関わらず開発できるため、全体像を把握しながら実装することができます。<br/> 一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。</p> kozaiy デザインシステム導入しました hatenablog://entry/13574176438104261616 2022-06-22T14:27:55+09:00 2022-06-24T23:54:02+09:00 プロダクト開発部デザイナーの河村恵です。昨今、デザインシステムを用いた「UI / UXの品質担保」「トンマナの統一」「再利用性の向上による開発効率のUP」が注目されつつある中、一休.comでも本格的なデザインシステムの構築を目指し、プロジェクトが発足しました。 本記事では、プロジェクト発足から一休.comならではの課題・実際に作っているUIガイドラインについてなど赤裸々にお話ししたいと思います。 目次 1) プロジェクト発足に至る経緯 2) プロジェクトの進め方 3) 実際に作っているUIガイドライン 4) まとめ 1.プロジェクト発足に至る経緯 CTOからのフィードバック そもそも「デザイン… <p>プロダクト開発部デザイナーの河村恵です。昨今、デザインシステムを用いた「UI / UXの品質担保」「トンマナの統一」「再利用性の向上による開発効率のUP」が注目されつつある中、一休.comでも本格的なデザインシステムの構築を目指し、プロジェクトが発足しました。</p> <p>本記事では、プロジェクト発足から一休.comならではの課題・実際に作っているUIガイドラインについてなど赤裸々にお話ししたいと思います。</p> <div style="padding: 10px 20px 20px; background: #eee; line-height: 1.8;"> <span style="font-size: 120%"><b>目次</b></span> <br>1) プロジェクト発足に至る経緯 <br>2) プロジェクトの進め方 <br>3) 実際に作っているUIガイドライン <br>4) まとめ </div> <h2>1.プロジェクト発足に至る経緯</h2> <h3>CTOからのフィードバック</h3> <p>そもそも「デザインシステム導入しよう!」となったきっかけは、CTO(以下直也さん)から一休.com と Yahoo! トラベルの2システムを一つに統合することで実現した、Yahoo!トラベルのリニューアル(詳しくは<a href="https://user-first.ikyu.co.jp/entry/2021/11/30/164400">こちら</a>)に際して「デザイナーとエンジニアのコラボレーションが上手く出来ていない」という指摘を受けたことからでした。</p> <p>Yahoo!トラベルリニューアルはUI/UXの改善を実施した上で、一休.com・Yahoo!トラベルと2つの異なるサービスのUIコンポーネントを共通化し、一貫したユーザー体験と開発体験の向上を実現する一大プロジェクトでした。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kawamuram/20220621/20220621134503.png" width="1200" height="1120" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>しかしいざ開発が進むとサービス毎の微妙なデザインの違いで「提供する機能は同じだが色が違うだけ」のようなUIコンポーネントがいくつも作成される事態が発生してしまいました。 当然、ほぼ同じ責務を持ったコンポーネントがそれぞれのサービスに存在するので、修正があった場合も同じ箇所を修正するという非効率な開発になってしまっていました。</p> <p>これは明らかにエンジニアとの連携不足が招いた事態でした。 Yahoo!トラベルリニューアルという一大プロジェクトにも関わらず、私の初動によって本来在るべき実装が行われなかったことへの猛省と、同時に必ずこのままでは終わらないという決意に変わりました。</p> <h3>エンジニアとの共通言語</h3> <p>この事態に対して、直也さんはエンジニアとデザイナーがコミュニケーションをとるための共通言語が必要だと考えていました。 その上で「デザイナーが感覚でデザインしていた部分をちゃんと言語化・型化する。そのためにデザインシステムを導入してみてはどうか?」と提案してくれました。</p> <p>さらに社内にはデザイナー・エンジニア含めデザインシステムに関する知見を持つメンバーがいなかったため、過去に、はてな、クックパッドなどの経験からデザイナーとエンジニアの連携についての知見が深い池田拓司さんより指導を受けられるよう手配してもらい、池田さんを講師に迎えデザインシステムプロジェクトがスタートしました。</p> <h2>2.プロジェクトの進め方</h2> <h3>figma導入</h3> <p>まず、デザインシステムの構築を行う上で最初に行ったのが、デザインツール「figma」の導入でした。これまで一休のデザイナーはAdobe XDをメインのプロトタイピングツールとして使用していました。XDでもコンポーネントの作成やエンジニアにcssコードを展開できる機能等はありますが、多くの会社でfigmaによるUIガイドラインの作成事例が公開されている点や、様々なアセットを大量に管理するのに適していることなどから、デザイナー間やエンジニアとのコミュニケーションも取りやすいfigmaの導入が決まりました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kawamuram/20220621/20220621134517.png" width="1200" height="608" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>デザインシステムでやること</h3> <p>一休のデザインシステムプロジェクトでは、大きく分けて3つの実施項目を行いました。</p> <p><strong>1. UIガイドライン及びFigmaでのデザインデータの作成<br> 2. 1で定義したデザインデータを元に実装上でコンポーネント化<br> 3. ドキュメント作成</strong></p> <p>デザイナーのメインタスクは1.のUIガイドライン作成になりますが、2.の実装に落とし込む作業の際にエンジニアとの密なコミュニケーションが必要となりました。お互いに意見を交わしながら作業を行いました。3.のドキュメント作成に関しては、デザイナー側のドキュメントはfigma上にルールを言語化したページを設け、エンジニア側は開発の際デザインシステムに意識を向けてもらえるようGitHub上に総合的なガイドブックとなるドキュメントを残すことにしました。</p> <h2>3.実際に作っているUIガイドライン</h2> <h3>一休.com/Yahoo!トラベルとの共通部分、差分を可視化する</h3> <p>実際のUIガイドラインは、一休.com と Yahoo!トラベルとの共通部分、差分を可視化することを第一の目的としました。</p> <p>UIガイドラインは下記の3つの要素で構成しました。</p> <p><strong>Guidelines…</strong>色、タイポグラフィー、スペース、角丸、シャドウなどのデザインの基本要素<br> <strong>Master…</strong>最新の本番画面のデザインデータ<br> <strong>Components…</strong>特定のページのみではなく、サイト全体で汎用的に使用するデザインパーツ</p> <p>色やタイポグラフィーといった「Guidelines」、本番画面と同一のデザインデータである「Master」に関しては左右に一休.com、Yahoo!トラベルを並べることで比較可能としました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kawamuram/20220624/20220624235334.png" width="1200" height="1138" 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/k/kawamuram/20220621/20220621134526.png" width="1200" height="1118" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>「Components」の各コンポーネントは、figmaの状態管理機能であるVariantsを利用して、IK =一休.comとY=Yahoo!トラベルのステートをServiceで定義し比較可能としました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kawamuram/20220621/20220621134532.png" width="1200" height="667" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>4.まとめ</h2> <p>デザインシステム導入を進める中で、小さい粒度(ボタン、ラジオボタン、チェックボックス、タブ等)のコンポーネントに関しては、一休.comとYahoo!トラベルでファイルが分かれていた部分の共通化を行うことができました。その過程には、デザイナー間のコミュニケーション(一休とヤフトラで分かれていたデザインの統一=より高いクオリティーで統一)、デザイナーエンジニア間のコミュニケーション(デザインデータの不備指摘や、様々なケースの掲示等)など、多くの会話と時間を要しましたが、一旦フローが出来てからはスムーズに進行できました。</p> <p>これまで一休ではデザインの仕組み化をエンジニアとデザイナーと共同で行う機会がありませんでしたが、チャンスをくれた直也さん、親身に指導していただいた池田さんのサポートもあり、プロジェクトを着実に前に進めることができました。 引き続き一休.com、Yahoo!トラベルのデザインが一定のクオリティを担保し続けられるよう、「美しく機能的なサイトで宿泊先を選んでいる」という、ユーザーの心地よい体験を叶えるべく、デザインシステム構築を進めてまいります!</p> <p>一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206485" title="プロダクトデザイナー (正社員) | 株式会社一休" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://hrmos.co/pages/ikyu/jobs/1693126708022206485">hrmos.co</a></cite> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F0001067" title="ソフトウェアエンジニア(フロントエンド)(正社員) | 株式会社一休" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://hrmos.co/pages/ikyu/jobs/0001067">hrmos.co</a></cite> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206472" title="ソフトウェアエンジニア(正社員) | 株式会社一休" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://hrmos.co/pages/ikyu/jobs/1693126708022206472">hrmos.co</a></cite></p> kawamuram 一休 × 出前館 で Frontend Meetup を開催します! hatenablog://entry/13574176438098236730 2022-06-02T13:15:48+09:00 2022-06-02T13:15:48+09:00 一休と、「出前館」を運営する株式会社出前館でオンライン・イベントを開催します。 今回はフロントエンド開発をテーマとして両社のエンジニアにお話いただきながら、様々な学びを得ることを目的としたイベントです。 イベント後のアーカイブ動画を公開しませんのでご興味がある方はぜひご参加ください。 日時:6/30(木) 18:00~20:00 費用:無料 場所:オンライン(Zoom) お申し込みは以下のリンクからお願いします。 ikyu.connpass.com 発表テーマ プロダクトのタイプ別 GraphQL クライアントの選び方(一休 / 管理画面 / 新規サービス) 一休/Yahooトラベル、マルチブ… <p>一休と、「出前館」を運営する株式会社出前館でオンライン・イベントを開催します。</p> <p>今回はフロントエンド開発をテーマとして両社のエンジニアにお話いただきながら、様々な学びを得ることを目的としたイベントです。</p> <p>イベント後のアーカイブ動画を公開しませんのでご興味がある方はぜひご参加ください。</p> <ul> <li>日時:6/30(木) 18:00~20:00</li> <li>費用:無料</li> <li>場所:オンライン(Zoom)</li> </ul> <p>お申し込みは以下のリンクからお願いします。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fikyu.connpass.com%2Fevent%2F248619%2F" title="【一休 × 出前館】Frontend Meetup (2022/06/30 18:00〜)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://ikyu.connpass.com/event/248619/">ikyu.connpass.com</a></cite></p> <h2>発表テーマ</h2> <ol> <li>プロダクトのタイプ別 GraphQL クライアントの選び方(一休 / 管理画面 / 新規サービス)</li> <li>一休/Yahooトラベル、マルチブランドにまたがるデザインシステム</li> <li>20年続いているサービスの注文画面をGraphQLを活用して作り直した話</li> <li>ライフインフラとなるために進めているアクセシビリティ向上への取り組み</li> </ol> <p>多くの方のご参加をお待ちしております!</p> shozawa 新サービス『YADOLINK』をリリースしました hatenablog://entry/13574176438086474763 2022-04-28T08:58:28+09:00 2022-04-28T08:58:28+09:00 新規事業本部、エンジニアの所澤です。 今回は4/19にリリースした一休の新サービス『YADOLINK(ヤドリンク)』についてお話します。 yadolink.com YADOLINKとは? サービス立ち上げの経緯と開発体制 技術選定 React or Vue ? Next.js を使うのか? Apollo Server、GraphQL、そして Universal TS 一休と新規開発について YADOLINKとは? TOPページ YADOLINKはホテル・旅館に特化した写真投稿SNSです。「宿好きが集まり、心置きなく宿愛を語れ、それが誰かの役に立つ幸せな場所」となることを目指しています。 サービ… <p>新規事業本部、エンジニアの所澤です。</p> <p>今回は4/19にリリースした一休の新サービス『YADOLINK(ヤドリンク)』についてお話します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fyadolink.com%2F" title="YADOLINK(ヤドリンク)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://yadolink.com/">yadolink.com</a></cite></p> <ul class="table-of-contents"> <li><a href="#YADOLINKとは">YADOLINKとは?</a></li> <li><a href="#サービス立ち上げの経緯と開発体制">サービス立ち上げの経緯と開発体制</a></li> <li><a href="#技術選定">技術選定</a></li> <li><a href="#React-or-Vue-">React or Vue ?</a></li> <li><a href="#Nextjs-を使うのか">Next.js を使うのか?</a></li> <li><a href="#Apollo-ServerGraphQLそして-Universal-TS">Apollo Server、GraphQL、そして Universal TS</a></li> <li><a href="#一休と新規開発について">一休と新規開発について</a></li> </ul> <h2 id="YADOLINKとは">YADOLINKとは?</h2> <p><figure class="figure-image figure-image-fotolife" title="TOPページ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shozawa/20220427/20220427144623.png" alt="YADOLINK&#x30C8;&#x30C3;&#x30D7;" width="552" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>TOPページ</figcaption></figure></p> <p>YADOLINKはホテル・旅館に特化した写真投稿SNSです。<strong>「宿好きが集まり、心置きなく宿愛を語れ、それが誰かの役に立つ幸せな場所」</strong>となることを目指しています。</p> <h2 id="サービス立ち上げの経緯と開発体制">サービス立ち上げの経緯と開発体制</h2> <p>YADOLINKは一休.com のマーケターの提案からボトムアップで事業化が決まったサービです。 開発チームは社内公募で集められ、ディレクター、デザイナー、エンジニア2名の合計4人で約半年ほどの開発期間を経てリリースされました。</p> <h2 id="技術選定">技術選定</h2> <p>技術選定にあたっては、</p> <ol> <li><strong>既存サービスの技術スタックに縛られない "もっとも良い選択肢" を選ぶこと</strong></li> <li><strong>開発スピードを重視してなるべく素朴な作りにすること</strong></li> </ol> <p>を心がけました。</p> <p>以下がYADOLINKの技術スタックをまとめた図です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/shozawa/20220427/20220427145638.png" alt="&#x6280;&#x8853;&#x30B9;&#x30BF;&#x30C3;&#x30AF;" width="1200" height="677" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>それぞれの詳細はまた別エントリで述べますが、簡単に各技術について所感を書いていきます。</p> <h2 id="React-or-Vue-">React or Vue ?</h2> <p>一休.com も 一休.com レストラン もどちらも Vue を使って開発していまが、今回はあえて React を選択しました。 Vue を選ばなかった理由は、<strong>Vue2系の型検査に不満があったこと</strong>と<strong>開発開始時点では Nuxt 3 の正式リリースの目処が立っていなかった</strong>からです。</p> <p>既存サービスの開発チームとの人員の入れ替えがあるのであればコンテキスト・スイッチを減らすために Vue を選んだかもしれませんが、YADOLINKは完全に独立した開発チームなので React を選択することができました。</p> <p>開発開始から2週間ほど経った頃にはすっかり React にも慣れ、Vue を使うのとそう変わらない速度で開発できるようになりました。</p> <p>型に対する不満・不安もなくなり、堅牢な型に守られて快適な開発ができています。</p> <h2 id="Nextjs-を使うのか">Next.js を使うのか?</h2> <p>将来的にSEOで集客をしたいので、SSRができる Next を選びました。また、Next のレールに乗って開発効率を上げたいという狙いもありました。</p> <p>現時点ではSSRはしていませんし、Vercel にデプロイしていないのでISRもできません。Next の真価をフルに発揮する構成ではありませんが、それでも Next を採用するメリットは十分にありました。</p> <p>Zero Config で開発が始められることは React の開発経験があまりなかった私にとってもは非常にありがたかったですし、SWC はフロントエンドの「コンパイル遅すぎ問題」を解決してくれました。</p> <h2 id="Apollo-ServerGraphQLそして-Universal-TS">Apollo Server、GraphQL、そして Universal TS</h2> <p>YADOLINKはフロントエンド開発の比重が大きいアプリケーションなので、2人のエンジニアがフロントエンド・バックエンドで役割分担をするのが効率的ではありません。一人のエンジニアがフロントエンドとバックエンド両方触ってもストレスが少ないように、サーバーは Node を採用して開発言語を TypeScript に統一しました。</p> <p>Prisma, Nexus, GraphQL Code Generator を採用したこともあっ、てDBからフロントエンドの Component まで、アプリケーションの隅々まで型情報が行き渡り非常に安心して開発ができています。YADOLINKは現時点ではあまり複雑なロジックもないこともあり、型がアプリケーションの品質の多くの部分を保証してくれています。 <strong>コンパイルが通れば不具合がほぼない</strong> という状態です。</p> <p>TypeScript の強力な型システムの力をフルに引き出して効率的な開発ができています。</p> <h2 id="一休と新規開発について">一休と新規開発について</h2> <p>さて、簡単ではありますが新サービスの開発についてご紹介しました。</p> <p>採用面接に出るとしばしば「一休は成熟したサービスを運営しているが、新規開発することはあるのか?」と質問を受けます。 おそらく、既存サービスの改修だけでなく新規の開発でバリバリコードを書きたい、という思いがあっての質問だと思います。</p> <p>答えは『YES』。既存サービスにも大規模な機能を追加を頻繁に行っていますし、今回のYADOLINKのようにまったくの新サービスを開発することもあります。</p> <p>(実は、今もいくつか新サービスの開発が動いています)</p> <p>ちょっとでも興味を持ってくれた方がいたらカジュアル面談などで気軽にお話しましょう!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F1693126708022206472" title="ソフトウェアエンジニア(正社員) | 株式会社一休" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://hrmos.co/pages/ikyu/jobs/1693126708022206472">hrmos.co</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhrmos.co%2Fpages%2Fikyu%2Fjobs%2F0001067" title="ソフトウェアエンジニア(フロントエンド)(正社員) | 株式会社一休" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://hrmos.co/pages/ikyu/jobs/0001067">hrmos.co</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmeety.net%2Fmatches%2FMDRhRfqrbqEl" title="一休の新サービス『YADOLINK』の立ち上げと技術選定について" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://meety.net/matches/MDRhRfqrbqEl">meety.net</a></cite></p> shozawa