クックパッド開発者ブログ 2023-10-19T13:48:02+09:00 cookpadtech Hatena::Blog hatenablog://blog/12921228815724287123 AWS 内で大規模言語モデルを利用できる Amazon Bedrock を使って作る RAG アプリケーション hatenablog://entry/6801883189051716186 2023-10-19T13:48:02+09:00 2023-10-19T13:50:28+09:00 こんにちは。機械学習グループの深澤(@fukkaa1225)です。 先日、Amazon Bedrock が一般利用できるよう(GA)になりました 。本記事ではこちらを用いて RAG(Retrieval-augmented generation) アプリケーションを作成してみた様子と、他 LLM モデルとの比較結果についてご紹介します。 Amazon Bedrock とは aws.amazon.com 公式サイトより文言を引用します。 Amazon Bedrock は、Amazon や主要な AI スタートアップ企業が提供する基盤モデル (FM) を API を通じて利用できるようにする完全マネ… <p>こんにちは。機械学習グループの深澤(<a href="https://twitter.com/fukkaa1225">@fukkaa1225</a>)です。</p> <p>先日、Amazon Bedrock が一般利用できるよう(GA)になりました 。本記事ではこちらを用いて RAG(Retrieval-augmented generation) アプリケーションを作成してみた様子と、他 LLM モデルとの比較結果についてご紹介します。</p> <h2 id="Amazon-Bedrock-とは">Amazon Bedrock とは</h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Faws.amazon.com%2Fjp%2Fbedrock%2F" title="基盤モデルによる生成系 AI アプリケーションの構築 - Amazon Bedrock - AWS" 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://aws.amazon.com/jp/bedrock/">aws.amazon.com</a></cite></p> <p>公式サイトより文言を引用します。</p> <blockquote><p>Amazon Bedrock は、Amazon や主要な AI スタートアップ企業が提供する基盤モデル (FM) を API を通じて利用できるようにする完全マネージド型サービスです。そのため、さまざまな FM から選択して、ユースケースに最も適したモデルを見つけることができます。Amazon Bedrock のサーバーレスエクスペリエンスにより、すぐに FM を開始したり、FM を簡単に試したり、独自のデータを使用して FM をプライベートにカスタマイズしたり、AWS のツールや機能を使用して FM をアプリケーションにシームレスに統合してデプロイしたりできます。Amazon Bedrock のエージェントはフルマネージド型で、デベロッパーは独自の知識源に基づいて最新の回答を提供し、幅広いユースケースのタスクを完了できる生成系 AI アプリケーションを簡単に作成できます。</p></blockquote> <p>殆どの企業にとって、現時点で LLM を使うときには OpenAI が提供する GPT-3.5-turbo, GPT-4 を使うことがほぼ唯一の選択肢になっているかと思います。弊社も GPT シリーズの API を活用して社内版 ChatGPT を展開しています。 一方で、OpenAI を用いる上でいくつか考えなくてはならない問題もあります。例えば、権限管理が API Key によってなされているため取り扱いに注意する必要があったり、OpenAI と通信する必要がある以上セキュリティ要件を満たせないケースがあるなどの点が挙げられます。 これに対して Amazon Bedrock は AWS のサービスであるため IAM での権限管理が可能です。通信も AWS 内で完結しており、VPC と接続できるのも嬉しいポイントです。モデルについても、GPT シリーズに匹敵する十分な性能を持ったものが用意されています。</p> <h3 id="Claude-とは">Claude とは</h3> <p>Amazon Bedrock で利用できるモデルはいくつかありますが、日本語での質問応答に適したものとなると実質的に使えるモデルは Claude シリーズです。 Claude シリーズは Anthropic が提供しているモデルです。Chatbot-arena・Nejumi JGLUE スコアリーダーボード などいくつかのベンチマークで Claude シリーズの能力は GPT シリーズに匹敵するスコアを出しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwandb.ai%2Fwandb%2FLLM_evaluation_Japan%2Freports%2FLLM-JGLUE---Vmlldzo0NTUzMDE2%3FaccessToken%3Du1ttt89al8oo5p5j12eq3nldxh0378os9qjjh14ha1yg88nvs5irmuao044b6eqa" title="Nejumi LLMリーダーボード" 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://wandb.ai/wandb/LLM_evaluation_Japan/reports/LLM-JGLUE---Vmlldzo0NTUzMDE2?accessToken=u1ttt89al8oo5p5j12eq3nldxh0378os9qjjh14ha1yg88nvs5irmuao044b6eqa">wandb.ai</a></cite></p> <p>Amazon Bedrock では Playground で Claude シリーズとのチャットを試すこともできます。試してみると、想像以上に流暢な日本語で喋ってくれることがわかります。下図のように夕飯の献立を提案してくれました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/fufufukakaka/20231019/20231019132857.png" width="1200" height="627" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Claude シリーズの優れている点として、入力できるトークン数の多さと価格の安さが挙げられます。Claude シリーズに入力可能なトークン数は脅威の 10万トークンで、これは GPT-4 の 8192 トークンと比較すると圧倒的な数字です。 また、価格も GPT-4 と比べると非常に安いです。2023/10/12 時点で <a href="https://aws.amazon.com/jp/bedrock/pricing/">Build Generative AI Applications with Foundation Models - Amazon Bedrock Pricing - AWS</a> を見ると Claude 2 の値段は掲載されていないため、 Claude の値段で比較を行います。単位はドルです。</p> <table> <thead> <tr> <th> Model </th> <th> Input </th> <th> Output </th> </tr> </thead> <tbody> <tr> <td> Claude(100k) </td> <td> 0.011 </td> <td> 0.032 </td> </tr> <tr> <td> GPT-4(8k) </td> <td> 0.03 </td> <td> 0.06 </td> </tr> <tr> <td> GPT-4(32k) </td> <td> 0.06 </td> <td> 0.12 </td> </tr> <tr> <td> GPT-3.5-turbo(4k) </td> <td> 0.0015 </td> <td> 0.002 </td> </tr> <tr> <td> GPT-3.5-turbo(16k) </td> <td> 0.003 </td> <td> 0.004 </td> </tr> </tbody> </table> <p>Claude と GPT-4 のみの比較で言えば、Claude は 1/3 以下の値段となっています。それでいてベンチマーク上ではかなり良い勝負をしているので、非常に優れた選択肢であると言えるでしょう。GPT-3.5-turbo と比較してしまうと GPT-3.5-turbo の安さが際立ちます。トークン数や応答の質に関して満足できるならばやはり GPT-3.5-turbo は有力な選択肢ではあります。 一方で 10万トークンを実現しながら AWS 内で通信を完結できる十分な性能を持った LLM を用いることができる Bedrock はそれだけで十分なメリットを持っていると思います。</p> <h2 id="社内文書に対する-RAG-アプリケーションを作成する">社内文書に対する RAG アプリケーションを作成する</h2> <p>Cookpad には Groupad と呼ばれる社内 wiki のようなものがあります。かなり長年運用されており、様々な知見が蓄積されていて日々の仕事を助けてくれています。 一方で問題もあり、Groupad に対する検索システムはあまりチューニングを行っていないため、同義語解決などがされず、ほしい結果を得るのに苦労することがありました。 そこで、ユーザーからのクエリに基づいて外部データから関連するドキュメントを検索し、検索結果を prompt に埋め込んで LLM に結果を生成させて表示するアプリケーション (Retrieval-augmented generation、RAG) を作成することにしました。 LLM を用いて semantics を考慮したベクトル検索と質問応答を実装することで、チューニングの手間なく今よりも幅広い検索結果を得られるだろうと考えました。</p> <p>以下、実際に作ってみた様子をご紹介します。 RAG アプリケーションを作るために必要なコンポーネントはいくつか存在します。代表的なものとしては以下のようなものかと思います。</p> <ul> <li>Vector DB ... RAG のソースとなる情報(ここでは Groupad の文書群)をベクトルとして保持しておくための DB <ul> <li>Embedding Function ... ベクトル DB に文書を追加する際に文書をベクトルに変換する</li> <li>Retriever ... ベクトル DB から検索クエリにマッチする文書を取得する</li> </ul> </li> <li>LLM ... クエリから得られた関連文書を元に、LLM による対話応答を行う</li> </ul> <p>この内、LLM は Amazon Bedrock で使うことができる Claude 2 を用います。 Vector DB は Chroma DB を用いることにします。 簡単な構成図としては以下のようなものになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/fufufukakaka/20231019/20231019132901.png" width="1200" height="658" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>なお、Bedrock には知識ベースと接続して質問応答を行うアプリケーションを作るための機能が存在します。<a href="https://aws.amazon.com/jp/bedrock/knowledge-bases/">&#x691C;&#x7D22;&#x62E1;&#x5F35;&#x751F;&#x6210; (RAG) - Amazon Bedrock &#x306E;&#x30CA;&#x30EC;&#x30C3;&#x30B8;&#x30D9;&#x30FC;&#x30B9; - AWS</a> この場合 Embedding には Bedrock が提供する Amazon Titan を使うことになります。ですが、Amazon Titan は現時点では英語にのみ対応したモデルで、日本語のテキストに対する埋め込み表現を得るのに適したモデルとなっていないため、今回は自分たちで huggingface hub から適したモデルをダウンロードして使うこととします。</p> <h3 id="Vector-DB">Vector DB</h3> <p>embedding function には <code>oshizo/sbert-jsnli-luke-japanese-base-lite</code> を利用しました。これを用いて Groupad の文書群をベクトル化し、 Chroma DB に保存します。 ベクトル検索をするだけのクライアントを用意するなら例えば以下のようなものが考えられるかと思います。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">class</span> <span class="synIdentifier">VectorSearcher</span>: <span class="synStatement">def</span> <span class="synIdentifier">__init__</span>(self, db_path: <span class="synIdentifier">str</span>) -&gt; <span class="synIdentifier">None</span>: embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=<span class="synConstant">&quot;oshizo/sbert-jsnli-luke-japanese-base-lite&quot;</span>) client = chromadb.PersistentClient(path=db_path) self.collection = client.create_collection(<span class="synConstant">&quot;groupad&quot;</span>, embedding_function=embedding_function) <span class="synStatement">def</span> <span class="synIdentifier">set_groupad_data</span>(self, data_path: <span class="synIdentifier">str</span>) -&gt; <span class="synIdentifier">None</span>: logger.info(<span class="synConstant">&quot;set groupad data&quot;</span>) <span class="synComment"># columns: id, title, content, created_at</span> df = pd.read_csv(data_path) self.collection.add( ids=df[<span class="synConstant">&quot;id&quot;</span>].apply(<span class="synIdentifier">str</span>).values.tolist(), documents=df[<span class="synConstant">&quot;content&quot;</span>].values.tolist(), metadatas=[{<span class="synConstant">&quot;title&quot;</span>: title, <span class="synConstant">&quot;created_at&quot;</span>: created_at} <span class="synStatement">for</span> title, created_at <span class="synStatement">in</span> <span class="synIdentifier">zip</span>(df[<span class="synConstant">&quot;title&quot;</span>].values, df[<span class="synConstant">&quot;created_at&quot;</span>].values)], ) logger.info(<span class="synConstant">&quot;set done&quot;</span>) <span class="synStatement">def</span> <span class="synIdentifier">search</span>(self, query: <span class="synIdentifier">str</span>, top_k: <span class="synIdentifier">int</span> = <span class="synConstant">10</span>) -&gt; <span class="synIdentifier">list</span>[<span class="synIdentifier">tuple</span>[<span class="synIdentifier">str</span>, <span class="synIdentifier">str</span>]]: res = self.collection.query(query_texts=[query], n_results=top_k) <span class="synStatement">return</span> res </pre> <p>今回は RAG を LangChain を用いて実装するため、実際にはこのベクトル検索クラスは用いません。</p> <h3 id="LLM-Claude-2">LLM: Claude 2</h3> <p>では続いて、今回の目玉である Claude 2 を Amazon Bedrock から利用する設定をします。</p> <p>Bedrock の client はシンプルに <code>import boto3; client = boto3.client('bedrock')</code> で使えるようになっています。<a href="https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock.html">Bedrock - Boto3 1.28.66 documentation</a></p> <p>LangChain に Bedrock を使えるオプションが存在するので、今回はVector DB と接続する部分をそれに頼ることにします。 以下のように簡単に書けますので、あとはいつもの OpenAI などを使うときと同じように使えるはずです。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> langchain.llms <span class="synPreProc">import</span> Bedrock llm = Bedrock( credentials_profile_name=<span class="synConstant">&quot;bedrock&quot;</span>, model_id=<span class="synConstant">&quot;anthropic.claude-v2&quot;</span>, model_kwargs={ <span class="synConstant">&quot;max_tokens_to_sample&quot;</span>: <span class="synConstant">1000</span> } ) </pre> <h3 id="RAG-全体像">RAG 全体像</h3> <p>ChromaDB の設定などをすべて LangChain 上で行うようにして、以下のようにすれば RAG を実装できます。</p> <p>なお ChromaDB の設定上、sqlite のバージョンが 3.35.5 以上でないといけないため、事前に sqlite の設定が必要な場合があります。 (ビルドして <code>export LD_LIBRARY_PATH=sqlite-3.42.0/.libs</code> を指定するなど)</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> langchain.chains <span class="synPreProc">import</span> RetrievalQA <span class="synPreProc">from</span> langchain.document_loaders.csv_loader <span class="synPreProc">import</span> CSVLoader <span class="synPreProc">from</span> langchain.embeddings <span class="synPreProc">import</span> HuggingFaceEmbeddings <span class="synPreProc">from</span> langchain.llms <span class="synPreProc">import</span> Bedrock <span class="synPreProc">from</span> langchain.vectorstores <span class="synPreProc">import</span> Chroma <span class="synStatement">def</span> <span class="synIdentifier">build_qa_chain</span>(file_path: <span class="synIdentifier">str</span>) -&gt; RetrievalQA: loader = CSVLoader(file_path=file_path, source_column=<span class="synConstant">&quot;content&quot;</span>) data = loader.load_and_split() embeddings = HuggingFaceEmbeddings( model_name=<span class="synConstant">&quot;oshizo/sbert-jsnli-luke-japanese-base-lite&quot;</span> ) vectorstore = Chroma.from_documents(documents=data, embedding=embeddings) llm = Bedrock( credentials_profile_name=<span class="synConstant">&quot;bedrock&quot;</span>, model_id=<span class="synConstant">&quot;anthropic.claude-v2&quot;</span>, model_kwargs={<span class="synConstant">&quot;max_tokens_to_sample&quot;</span>: <span class="synConstant">1000</span>}, ) qa_chain = RetrievalQA.from_chain_type(llm, retriever=vectorstore.as_retriever()) <span class="synStatement">return</span> qa_chain </pre> <p>実際に使うときは以下のようにして利用することができます。</p> <pre class="code lang-python" data-lang="python" data-unlink>qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;ほげほげ&quot;</span>}) </pre> <h2 id="実際の出力を見てみた">実際の出力を見てみた</h2> <p>さて、ここまで RAG の実装方針について示してきました。社内ドキュメントを対象とした実際の例をご紹介することはできませんが、今回は代替データとして深澤が Techlife で公開した記事をソースとして、RAG の挙動を確かめることとします。</p> <p>今回のコードでは content カラムだけを取り込むようにしているので、以下の記事の markdown を持ってきて idx,content カラムの形式で csv <code>"data/fukasawa_techlife.csv"</code> に保存します。</p> <ul> <li><a href="https://techlife.cookpad.com/entry/nlp2023-attendance">NLP2023 &#x306B;&#x53C2;&#x52A0;&#x3057;&#x307E;&#x3057;&#x305F;&#xFF1A;&#x8074;&#x8B1B;&#x7DE8; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/cookpad-mart-item-recommendation">&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x30DE;&#x30FC;&#x30C8;&#x306B;&#x304A;&#x3051;&#x308B; item-to-item &#x30EC;&#x30B3;&#x30E1;&#x30F3;&#x30C7;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x306E;&#x5909;&#x9077; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2021/11/04/090000">RecBole &#x3092;&#x7528;&#x3044;&#x3066;&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x30DE;&#x30FC;&#x30C8;&#x306E;&#x30C7;&#x30FC;&#x30BF;&#x306B;&#x5BFE;&#x3059;&#x308B;50&#x4EE5;&#x4E0A;&#x306E;&#x30EC;&#x30B3;&#x30E1;&#x30F3;&#x30C9;&#x30E2;&#x30C7;&#x30EB;&#x306E;&#x5B9F;&#x9A13;&#x3092;&#x3057;&#x3066;&#x307F;&#x305F; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2021/09/06/130000">Cookpad Summer Internship 2021 10 Day Tech&#x30B3;&#x30FC;&#x30B9;&#x3092;&#x958B;&#x50AC;&#x3057;&#x307E;&#x3057;&#x305F;&#xFF01; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2020/05/15/120000">&#x7CFB;&#x5217;&#x30E9;&#x30D9;&#x30EA;&#x30F3;&#x30B0;&#x306B;&#x3088;&#x308B; NPS &#x30B3;&#x30E1;&#x30F3;&#x30C8;&#x306E;&#x30DD;&#x30B8;&#x30C6;&#x30A3;&#x30D6;&#x30FB;&#x30CD;&#x30AC;&#x30C6;&#x30A3;&#x30D6;&#x90E8;&#x5206;&#x306E;&#x62BD;&#x51FA; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> </ul> <p><code>qa_chain = build_qa_chain("data/fukasawa_techlife.csv")</code> として qa_chain を取り出せば質問応答を行うパイプラインが用意できます。 出力を見てみましょう。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">4</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできたレコメンドについて教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできたレコメンドについて教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'''</span> <span class="synConstant">では、深澤さんが取り組んできたクックパッドマートのレコメンドについて要点をまとめます。</span> <span class="synConstant">- 最初はItem2Vecでレコメンデーションを実装していましたが、オフラインの評価指標が思わしくなかったため他の手法を探していました</span> <span class="synConstant">- そこでRecBoleを利用して30モデル以上の実験を行った結果、RecVAEが良好な指標を示しました</span> <span class="synConstant">- RecVAEとItem2Vecをオンラインで比較するため、インターリービングによるテストを実施しました</span> <span class="synConstant">- その結果、RecVAEの方が指標的に良く、Item2VecからRecVAEへの移行を決定しました</span> <span class="synConstant">深澤さんはItem2Vecから始まり、RecBoleを利用した実験を重ね、最終的にRecVAEへ移行したことが分かります。レコメンデーション精度の改善に向けた断続的な取り組みが伺えます。</span> <span class="synConstant">'''</span>} </pre> <p>質問応答としてはかなり完璧ですね。<a href="https://techlife.cookpad.com/entry/cookpad-mart-item-recommendation">&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x30DE;&#x30FC;&#x30C8;&#x306B;&#x304A;&#x3051;&#x308B; item-to-item &#x30EC;&#x30B3;&#x30E1;&#x30F3;&#x30C7;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x306E;&#x5909;&#x9077; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a> での内容です。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">5</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできた仕事について教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできた仕事について教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'''</span> <span class="synConstant">はい、深澤さんの仕事についてまとめてみました。</span> <span class="synConstant">- クックパッドに入社後、主に自然言語処理を活用したプロジェクトを担当されています。</span> <span class="synConstant">- NPSアンケートの自動分類システムを構築したり、本記事で紹介されているように、NPSコメントのポジティブ/ネガティブ部分の抽出システムの開発に取り組んでいます。</span> <span class="synConstant">- 料理手順の自動生成プロジェクトにも関わっていて、手順間の照応解析や手順から材料を自動抽出する研究などを行っています。</span> <span class="synConstant">- Google BigQueryを活用した大規模データ分析も行っており、レシピ閲覧ログの分析結果を基にレシピ検索の改善を図るなどしています。</span> <span class="synConstant">- 推薦システムの研究開発にも携わっており、Matrix Factorizationや popularity-based モデルなどを用いたレコメンデーションエンジンの開発をしています。</span> <span class="synConstant">- 卒業論文ではソーシャルメディアテキスト(Twitter)のセンチメント分析を研究していました。</span> <span class="synConstant">- 論文執筆や学会発表も積極的に行っており、自然言語処理分野を中心に研究開発を進めています。</span> <span class="synConstant">以上のように、クックパッドにおける深澤さんの仕事は、自然言語処理とデータ分析を組み合わせた研究開発が中心だと言えそうです。</span> <span class="synConstant">'''</span>} </pre> <p><code>料理手順の自動生成プロジェクト</code> や <code>ソーシャルメディアテキスト(Twitter)のセンチメント分析</code> はしていないので hallucination が見られますが、大体の雰囲気はあっています。ちょっと範囲の広い聞き方をしたのが良くなかったのかもしれません。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">6</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんはインターンシップにもかかわっていたことがあるのですか?&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんはインターンシップにもかかわっていたことがあるのですか?'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'''</span> <span class="synConstant">はい、深澤さんはクックパッドでインターンシップにかかわっていました。</span> <span class="synConstant">深澤さんはクックパッドの研究開発部に所属していて、過去にサマーインターンシップのメンターを務めた経験があるようです。</span> <span class="synConstant">例えば、このブログ記事では2020年のサマーインターンシップで深澤さんがメンターを務めた様子が書かれています。</span> <span class="synConstant">https://techlife.cookpad.com/entry/2020/09/14/140000</span> <span class="synConstant">この記事によると、深澤さんは機械学習コースのメンターとして、インターン学生の技術指導を行ったそうです。また、インターンシップのプログラム作成にも関わったとのことです。他にも、深澤さんは過去のインターンシップで機械学習の講義を行ったり、インターン学生の研究開発にアドバイスをしたりするなど、インターンシップに積極的に関わってきたようです。そのため、深澤さんはインターンシップにかかわった経験が豊富で、インターン学生の育成に力を入れているエンジニアのようです。</span> <span class="synConstant">'''</span>} </pre> <p>回答してきた URL や <code>機械学習コースのメンターとして、インターン学生の技術指導</code> というあたりもまた hallucination が起きています。一方で事実も存在しており、<code>インターンシップのプログラム作成にも関わった</code> という部分などは事実です。</p> <h3 id="GPT-4-との回答比較">GPT-4 との回答比較</h3> <p>claude-v2 には hallucination が見られましたが、GPT-4 ではどうでしょうか。出力を比較するためにコードに以下の変更を加えます:</p> <ul> <li><code>llm</code> の部分を <code>langchain.chat_models.ChatOpenAI</code> を用いるようにする</li> <li>GPT-4 の Max Token Size 8192 に合わせて <code>langchain.text_splitter.RecursiveCharacterTextSplitter</code> や max_token パラメータの調整を行う</li> </ul> <p>最終的には以下のようなコードで実行しました。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">build_qa_chain2</span>(file_path: <span class="synIdentifier">str</span>) -&gt; RetrievalQA: loader = CSVLoader(file_path=file_path, source_column=<span class="synConstant">&quot;content&quot;</span>) data = loader.load_and_split(RecursiveCharacterTextSplitter(chunk_size=<span class="synConstant">2048</span>, chunk_overlap=<span class="synConstant">0</span>)) embeddings = HuggingFaceEmbeddings( model_name=<span class="synConstant">&quot;oshizo/sbert-jsnli-luke-japanese-base-lite&quot;</span> ) vectorstore = Chroma.from_documents(documents=data, embedding=embeddings) llm = ChatOpenAI( model_name=<span class="synConstant">&quot;gpt-4&quot;</span>, temperature=<span class="synConstant">0</span>, max_tokens=<span class="synConstant">4096</span> ) qa_chain = RetrievalQA.from_chain_type( llm, retriever=vectorstore.as_retriever(), chain_type=<span class="synConstant">&quot;stuff&quot;</span> ) <span class="synStatement">return</span> qa_chain </pre> <p>Claude 2 に対して行ったのと同じ質問を投げてみた結果、以下のような結果を得られました。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">71</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできたレコメンドについて教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできたレコメンドについて教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'深澤さんは、レコメンド系のプロジェクトRecBoleに取り組んでいます。RecBoleは再現性に取り組むプロジェクトで、50以上のレコメンドモデルを一つのコマンドで試すことができます。深澤さんは、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って、これらのレコメンドモデルを試す実験を行いました。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できました。'</span>} In [<span class="synConstant">72</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできた仕事について教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできた仕事について教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'深澤さんはクックパッドの研究開発部で働いており、レコメンド系のプロジェクトRecBoleに注目しています。RecBoleを用いて、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って、50以上のレコメンドモデルの実験を行いました。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できたとのことです。'</span>} In [<span class="synConstant">73</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんはインターンシップにもかかわっていたことがあるのですか?&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんはインターンシップにもかかわっていたことがあるのですか?'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'文脈からは深澤さんがインターンシップに関与していたかどうかは明らかになりません。'</span>} </pre> <p>GPT-4 はレコメンドと仕事に関する質問については Claude 2 と同じような返答をしています。一方、インターンシップの話題に関してはうまく見つけられなかったのか答えられない、という返答になりました。hallucination を抑えるような学習の結果として、このように外れではない無難な回答に終始しているのかもしれません。</p> <p>Claude 2 は context window が大きい分長い系列を処理できますが、長距離依存をうまく扱えず hallucination が起こった可能性が考えられます。そうだとすれば max_token パラメータを調整することで hallucination を抑制できたかもしれません。</p> <p>LLM のこうした傾向の違いについてはまだあまり調査できていないため、今後比較検討できればと思っています。</p> <h2 id="まとめ">まとめ</h2> <p>Amazon Bedrock が提供している Claude 2 と LangChain を組み合わせて RAG アプリケーションを作成してみました。 Claude 2 は OpenAI のモデルと比べても性能やコストの点で魅力であり、これを AWS のサービスとして用いることができるのは非常に便利だなと感じました。 Amazon Bedrock の到来によって社内ドキュメント に対する RAG アプリケーションを作るハードルはかなり下がったと感じています。積極的に社内で試して、ゆくゆくはユーザの方にも使っていただけるような LLM アプリケーションを作る際の知見をためていきたいです。</p> <p>この記事を読んでいただきありがとうございました。 クックパッド機械学習グループでは引き続き最先端の機械学習の技術をプロダクトで活かすべく、試行錯誤と開発を進めていきます。</p> fufufukakaka Hatamoto 〜モバイルアプリに関する情報を一元管理するためのWebアプリケーション〜 hatenablog://entry/6801883189051182465 2023-10-18T10:00:00+09:00 2023-10-18T10:00:22+09:00 こんにちは。元モバイル基盤部(現クックパッドマートプロダクト開発部)の大川(@aomathwift)です。 クックパッドでは、レシピサービスのクックパッドアプリだけでなく、生鮮 EC サービスのクックパッドマートをはじめ、複数の iOS アプリを開発しています。 複数のアプリを開発する上で、機能そのものの開発以外に開発者が気にかけないといけないことがいくつかあります。 例えば、各種証明書の有効期限、ライブラリの選定やバージョンアップなどです。開発者はこれらの関心事に対して、全てのアプリで同じように注意していなければいけません。 クックパッドでは、「Hatamoto」という Web アプリケーシ… <p>こんにちは。元モバイル基盤部(現クックパッドマートプロダクト開発部)の大川(<a href="https://twitter.com/aomathwift">@aomathwift</a>)です。</p> <p>クックパッドでは、レシピサービスのクックパッドアプリだけでなく、生鮮 EC サービスのクックパッドマートをはじめ、複数の iOS アプリを開発しています。</p> <p>複数のアプリを開発する上で、機能そのものの開発以外に開発者が気にかけないといけないことがいくつかあります。 例えば、各種証明書の有効期限、ライブラリの選定やバージョンアップなどです。開発者はこれらの関心事に対して、全てのアプリで同じように注意していなければいけません。</p> <p>クックパッドでは、「Hatamoto」という Web アプリケーションでこれらのアプリに関する情報を一元管理しています。 本記事では、iOS アプリにまつわる情報管理の中で生じる課題と、Hatamoto がそれをどのように解決しているかを紹介します。</p> <h1 id="Hatamoto-導入前の課題">Hatamoto 導入前の課題</h1> <p>クックパッドでは、モバイル基盤というグループが App Store Connect をはじめとするアプリ開発に必要なサービスのワークスペースの管理者としての役割を担っています。アプリの証明書の発行等のオペレーションは、モバイル基盤グループで行っています。</p> <p>このモバイル基盤グループに所属するエンジニア目線で、Hatamoto 導入前は社内アプリの証明書管理やライブラリの更新に関していくつかの課題がありました。</p> <h2 id="開発者が自発的に証明書の有効期限に気づく必要がある">開発者が自発的に証明書の有効期限に気づく必要がある</h2> <p>iOSアプリでプッシュ通知を送信するには、Apple Push Notification Service(以下 APNs )の証明書が必要です。この証明書は1年という有効期限を持つため、継続的にプッシュ通知を送り続けるためには期限が切れる前に更新して差し替える必要があります。 もしこの証明書の差し替えを忘れて有効期限を過ぎると、アプリのプッシュ通知が突然届かなくなってしまいます。 そのため、アプリ開発者達は APNs 証明書の有効期限を把握し、期限が切れる前に自発的にモバイル基盤グループに更新を依頼する必要がありました。</p> <p>また、クックパッドでは、<a href="https://developer.apple.com/jp/programs/enterprise/">Apple Developer Enterprise Program</a> に加入しており、社内で動作確認するためのアプリを配信するのに利用しています。この Apple Developer Enterprise Program を使ってアプリを配信する際にも、通常の Apple Developer Program を使った配信と同様に Provisioning Profile が必要になります。 もしこの Provisioning Profile の更新を忘れて有効期限を過ぎると、社内ですでに配信済みだったアプリが突然利用できなくなるという、開発を進める上での問題が起こります。さらに、通常の Apple Developer Program で作成した App Store 配信用の Provisioning Profile も、更新を忘れると App Store にアップロードするバイナリをビルドできない問題に直面します。 したがって、これらも APNs 証明書と同様に、期限が切れる前に気づいて更新を依頼する必要がありました。</p> <h2 id="更新が必要なライブラリを使い続けていることに気づけない">更新が必要なライブラリを使い続けていることに気づけない</h2> <p>クックパッドで開発している iOS アプリでは、オープンソースのライブラリから社内ライブラリまで、様々なライブラリを利用しています。 モバイル基盤グループでは、あるライブラリで脆弱性を含んだバージョンがリリースされたり、既存の挙動を壊すような問題・変更のあるバージョンがリリースされたりした場合に、そのライブラリを使用しているアプリの開発者に迅速に共有したいという需要がありました。<a href="#f-6b93ebfd" name="fn-6b93ebfd" title="社内ではアプリごとにライブラリの更新を監視するスクリプトを自前で書いて週次で実行していましたが、利用するバージョンを固定している場合や、更新の緊急性が高い場合にこれが機能しないという事情がありました。現在は、Renovate のようなツールを利用するのも解決手段の一つだと思います。">*1</a></p> <p>しかし、各アプリで使われているライブラリとそのバージョンを常に人力で追い続けることは難しく、利用しているアプリ側で実際に問題に直面してから対処するということが多いのが実情でした。</p> <h1 id="Hatamotoの機能">Hatamotoの機能</h1> <p>上記の課題の解決をモチベーションに、Hatamoto には以下の機能が実装されています。</p> <h2 id="証明書の有効期限を監視し期日が近づいたら-Issue-を起票する">証明書の有効期限を監視し、期日が近づいたら Issue を起票する</h2> <p>各アプリの APNs 証明書に加え、Provisioning Profile ・開発者証明書の有効期限を一覧して管理できます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135006.png" width="1200" height="585" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>また、この収集した有効期限を毎日定期実行されるジョブで監視し、有効期限が1ヶ月以内に近づいた場合にモバイル基盤グループへの依頼用リポジトリ宛に更新依頼 Issue を自動で立てることができます。</p> <h2 id="各アプリで使われているライブラリを一覧する">各アプリで使われているライブラリを一覧する</h2> <p>アプリごとに、使用しているライブラリとそのバージョンを一覧できます。 iOS はライブラリの管理方法が SwiftPM、CocoaPods、 Carthage と複数存在するので、それぞれに対し異なる収集方法で収集しています。 また、この収集した情報を活かし、「このライブラリを利用しているアプリ」も参照できます。 この機能は <a href="https://techlife.cookpad.com/entry/2017/03/23/115619">gem_collector</a> にインスパイアされています。</p> <h2 id="ライブラリを利用しているアプリに向けての一括アナウンスをする">ライブラリを利用しているアプリに向けての一括アナウンスをする</h2> <p>これもまた gem_collector にインスパイアを受けた機能になりますが、特定のライブラリを使用しているアプリの開発リポジトリに対して、一斉に Issue を起票することができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135118.png" width="1200" height="417" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>これは、ライブラリ側に問題が見つかり早急にアップデートを依頼したい場合や、社内ライブラリに破壊的な変更が加わった場合などに利用される想定で作られています。</p> <p>ちなみに Hatamoto という名前は、モバイルアプリにまつわるデータを「監視する」という意味合いから監視役→衛兵→戦国時代における武将の近衛兵=旗本という流れで名付けられています。</p> <h1 id="各機能の実現方法">各機能の実現方法</h1> <p>各機能は以下のように実装しています。</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/a/aomathwift/20231017/20231017135226.png" width="1200" height="737" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>APNs 証明書の更新の際は、有効な証明書の有無を確認し、必要に応じて新規作成するという作業をまとめて行う fastlane の <a href="https://docs.fastlane.tools/actions/pem/">pem</a> というアクションを利用しています<a href="#f-ebb25834" name="fn-ebb25834" title=" App Store Connect API には証明書を作成する機能がないため、作成処理を完全に自動化することは現状できません">*2</a>。</p> <p>この機能を利用するためには、Apple Developer の AppManager 以上のユーザーでログインする必要があり、そのログインの際に他要素認証が求められることから、APNs 証明書の更新はモバイル基盤グループのメンバーによって手動で実行されています。 この更新の際、作成された証明書を Amazon S3 (以下 S3 )のバケットにアップロードするようにしているので、その中身を見て有効期限をチェックしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135317.png" width="1200" height="632" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Provisioning Profile・開発者証明書の有効期限の取得には <a href="https://developer.apple.com/documentation/appstoreconnectapi/profiles">App Store Connect API</a> を使っています。</p> <p>有効期限が近づいているかどうかは、毎日定期実行されるジョブ<a href="#f-b4e41c88" name="fn-b4e41c88" title="クックパッドで利用されている Ruby 製の Web ベースのジョブスケジューラー Kuroko2 を利用しています">*3</a>で今日の日付と有効期限を比較して確認しています。</p> <h2 id="使用ライブラリの収集">使用ライブラリの収集</h2> <p>各アプリが利用しているライブラリの取得は、パッケージマネージャーごとに異なる方法で実現しています。</p> <h3 id="CarthageSwiftPM">Carthage、SwiftPM</h3> <p><a href="https://docs.github.com/en/rest?apiVersion=2022-11-28">GitHub API</a> を使って各リポジトリ内の Cartfile.resolved、Package.resolved の中身を取得しパースしています。同じく GitHub API 経由で、利用しているライブラリの最新バージョンを取得しています。</p> <h3 id="CocoaPods">CocoaPods</h3> <p>CocoaPods で解決されたライブラリのリストとそれぞれのバージョンが記述される Podfile.lock には、そのライブラリのリポジトリ URL が記載されません。そのため、SwiftPM や Carthage と同様に Podfile.lock の中身を基に GitHub API を利用してライブラリの最新バージョンを取得するというのは難しいです。 したがって、利用している CocoaPods ライブラリの取得及び最新の podspec の情報の取得は、各アプリのメインブランチの CI で実行するようにしました。ここで収集した情報を S3 にアップロードし、アップロードされた情報を同様に Kuroko2 のジョブを定期実行して取得し DB に登録する、という流れにしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135946.png" width="1200" height="569" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Hatamoto-を実際に運用してみて">Hatamoto を実際に運用してみて</h1> <p>Hatamoto を実際に運用してみて、最も良かったのは証明書の有効期限を気にする必要がなくなったことです。証明書は各アプリの管理者がダウンロードして差し替えることになるので、管理者であるモバイル基盤グループのオペレーションが減りました。</p> <p>また、各アプリが使っているライブラリを一覧できるというのは、思いの外管理者以外の開発者の目線から役に立つ場面がありました。 たとえば、新しくライブラリを導入しようとしたときに、社内にそのライブラリを使っているアプリが他に無いかを調べ、導入方法を確認する、といった活用例がありました。</p> <p>逆に、ライブラリを利用しているアプリに向けての一括アナウンスは未だ活用されていません。対応が必要な問題がみつかったときに、各アプリで開発者側が自発的に Issue を立てて対応していることが多く、開発者達の対応力の高さ故にあまり需要がない機能となってしまいました。</p> <h1 id="おわりに">おわりに</h1> <p>この記事では、アプリの各種証明書や各プロジェクトが依存している開発ツール・ライブラリなど、 iOS アプリに関する情報を一元管理するための Web アプリケーション「Hatamoto」を紹介しました。 このツールのおかげで、 iOS アプリを開発していく上で面倒となる管理部分が効率化できています。App Store Connect API の機能が増えれば、証明書の生成フローなど更に自動化できる部分が増え、より便利に利用することもできそうです。</p> <p>この Hatamoto は主に App Store Connect API と GitHub API 、fastlane を使って比較的単純な処理で実現されています。複数アプリにおける証明書等の情報管理に悩む方は、Hatamoto のようなWebアプリによる管理を試してみてはいかがでしょうか。</p> <div class="footnote"> <p class="footnote"><a href="#fn-6b93ebfd" name="f-6b93ebfd" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">社内ではアプリごとにライブラリの更新を監視するスクリプトを自前で書いて週次で実行していましたが、利用するバージョンを固定している場合や、更新の緊急性が高い場合にこれが機能しないという事情がありました。現在は、<a href="https://github.com/marketplace/renovate">Renovate</a> のようなツールを利用するのも解決手段の一つだと思います。</span></p> <p class="footnote"><a href="#fn-ebb25834" name="f-ebb25834" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"> App Store Connect API には証明書を作成する機能がないため、作成処理を完全に自動化することは現状できません</span></p> <p class="footnote"><a href="#fn-b4e41c88" name="f-b4e41c88" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">クックパッドで利用されている Ruby 製の Web ベースのジョブスケジューラー Kuroko2 を利用しています</span></p> </div> aomathwift 開発を快適にするiOSアプリ内ログ確認ツール hatenablog://entry/820878482972950423 2023-10-13T17:30:00+09:00 2023-10-14T10:57:58+09:00 クックパッドでは、iOSアプリ内の行動ログやネットワーク通信ログを見やすく使いやすくする「ログ確認ツール」を活用しています。その使い方や背景、実装時の知見などについて詳しくご紹介します。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231013/20231013164516.png" alt="&#x958B;&#x767A;&#x3092;&#x5FEB;&#x9069;&#x306B;&#x3059;&#x308B;iOS&#x30A2;&#x30D7;&#x30EA;&#x5185;&#x30ED;&#x30B0;&#x78BA;&#x8A8D;&#x30C4;&#x30FC;&#x30EB;&#xFF08;&#x5C65;&#x6B74;&#x30FB;&#x5B9A;&#x7FA9;&#x8F9E;&#x66F8;&#x30FB;&#x30C1;&#x30A7;&#x30C3;&#x30AF;&#x30EA;&#x30B9;&#x30C8;&#xFF09;" width="1200" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは!レシピ事業部の藤坂(<a href="https://twitter.com/yujif_">@yujif_</a>) です。</p> <p>クックパッドiOSアプリの開発者用機能として「ログ確認ツール」を作ってみました。社内で1年以上運用して好評なので、その経緯や学びをまとめてみます。</p> <p>iOSDC Japan 2022 では「<a href="https://www.youtube.com/watch?list=PLod2oSGQp3W6tx5JMQntpuZ-fNpP4Y_Hh&amp;v=4NGvI9PoAIQ">モバイルアプリの行動ログの『仕込み』を快適にする</a>」と題して関連した内容を発表しています。こちらの資料も合わせてご覧ください。</p> <p><iframe id="talk_frame_920663" class="speakerdeck-iframe" src="//speakerdeck.com/player/32c68404daf74ab8b42c2a004415922a" 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/yujif/iosdc-japan-2022-mobile-app-logging">speakerdeck.com</a></cite></p> <ul class="table-of-contents"> <li><a href="#アプリ内ログ確認ツールとは">アプリ内ログ確認ツールとは</a><ul> <li><a href="#1-ログ履歴">1. ログ履歴</a><ul> <li><a href="#ログの内容確認が楽になる">ログの内容確認が楽になる</a></li> </ul> </li> <li><a href="#2-ログ定義辞書">2. ログ定義辞書</a><ul> <li><a href="#知らないログの意味が分かる">知らないログの意味が分かる</a></li> <li><a href="#ログを業務に活かしやすくなる">ログを業務に活かしやすくなる</a></li> </ul> </li> <li><a href="#3-ログ送信チェックリスト">3. ログ送信チェックリスト</a><ul> <li><a href="#ログが送られていない">ログが送られていない!?</a></li> <li><a href="#QA作業でログの実装漏れもわかる">QA作業でログの実装漏れもわかる</a></li> <li><a href="#実装担当者以外でも分担できる">実装担当者以外でも分担できる</a></li> </ul> </li> </ul> </li> <li><a href="#実装方法">実装方法</a><ul> <li><a href="#1-送信済みログを読み出せるようにする">1. 送信済みログを読み出せるようにする</a><ul> <li><a href="#osLogger-で書き込む">os.Logger で書き込む</a></li> <li><a href="#OSLogStore-で読み出す">OSLogStore で読み出す</a></li> </ul> </li> <li><a href="#2-ログ確認ツールで扱いやすいデータに変換する">2. ログ確認ツールで扱いやすいデータに変換する</a><ul> <li><a href="#OSLogEntry-のメッセージをデコードする">OSLogEntry のメッセージをデコードする</a></li> <li><a href="#行動ログの定義ドキュメントとの紐付け">行動ログの定義ドキュメントとの紐付け</a></li> </ul> </li> <li><a href="#3-便利な機能を色々実装する">3. 便利な機能を色々実装する</a><ul> <li><a href="#チェックリスト機能">チェックリスト機能</a></li> <li><a href="#ネットワーク通信のログにも対応">ネットワーク通信のログにも対応</a><ul> <li><a href="#実装に関して調査しやすくする">実装に関して調査しやすくする</a></li> <li><a href="#SimulatorではURLコピーに">SimulatorではURLコピーに</a></li> </ul> </li> </ul> </li> </ul> </li> <li><a href="#振り返って">振り返って</a><ul> <li><a href="#よかったこと">よかったこと</a><ul> <li><a href="#ログの実装確認がつらくなくなる">ログの実装・確認がつらくなくなる</a></li> <li><a href="#自由に実験できる環境で遊べる">自由に実験できる環境で遊べる</a></li> <li><a href="#欲しいものを作れると楽しい">欲しいものを作れると楽しい</a></li> </ul> </li> <li><a href="#改善したいこと">改善したいこと</a><ul> <li><a href="#ログの読み込みを速くしたい">ログの読み込みを速くしたい</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> </ul> </li> </ul> <h1 id="アプリ内ログ確認ツールとは">アプリ内ログ確認ツールとは</h1> <p>クックパッドiOSアプリに内蔵された、ログ関連の開発者用ツール<a href="#f-899f9b8c" name="fn-899f9b8c" title="このツールは開発版ビルドのみに含まれており、App Store版では利用できません。">*1</a>です。 「ログ履歴」「ログ定義辞書」「ログ送信チェックリスト」の3つの機能があります。</p> <h2 id="1-ログ履歴">1. ログ履歴</h2> <p><figure class="figure-image figure-image-fotolife" title="クックパッドiOSアプリ内のログ確認ツール"></p> <div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231012/20231012151415.png" width="609" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231012/20231012151136.png" width="609" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></div> <p><figcaption>クックパッドiOSアプリ内のログ確認ツール</figure></p> <p>ユーザーの行動ログ(例:ボタンのタップ、特定の要素の表示など)やAPIサーバーとの通信ログを、クックパッドiOSアプリの中ですぐに確認できます。</p> <p><figure class="figure-image figure-image-fotolife" title="開発版のクックパッドiOSアプリ内で、ログ確認ツールを素早く表示できる様子"></p> <div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231012/20231012150813.gif" alt="&#x958B;&#x767A;&#x7248;&#x306E;&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;iOS&#x30A2;&#x30D7;&#x30EA;&#x5185;&#x3067;&#x3001;&#x30ED;&#x30B0;&#x78BA;&#x8A8D;&#x30C4;&#x30FC;&#x30EB;&#x3092;&#x7D20;&#x65E9;&#x304F;&#x8868;&#x793A;&#x3067;&#x304D;&#x308B;&#x69D8;&#x5B50;" width="590" height="1176" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></div> <p><figcaption>ログ確認ツールを素早く表示できる様子<a href="#f-315eaa85" name="fn-315eaa85" title="SwiftUIで作られた画面なので .blur(radius:) を適当につけるだけで簡単にぼかせて、こういうGIFをつくるときに便利です。">*2</a></figcaption></figure></p> <p>このツールはデバイスのシェイク(Simulatorでは ⌃ ⌘ Z <code>control + command + Z</code>)でも表示でき、どの画面からでも気軽に使えます。</p> <h3 id="ログの内容確認が楽になる">ログの内容確認が楽になる</h3> <p>必要な情報だけに絞り、種類別に色分けすることで、パッと見て把握しやすくしています。</p> <p>これまでもロガーからの出力は Xcode 内のコンソール<a href="#f-2a16df53" name="fn-2a16df53" title="なお、Xcode 15 では Debug Console が強化され、重要度やログの種類ごとにフィルタリングできるなど便利になりました。https://developer.apple.com/videos/play/wwdc2023/10226/ ">*3</a>や Console.app などで確認できました。</p> <p><figure class="figure-image figure-image-fotolife" title="[Before] Xcodeのコンソールに流れるログは見づらい"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008020615.png" alt="Xcode 14&#x306E;&#x30B9;&#x30AF;&#x30EA;&#x30FC;&#x30F3;&#x30B7;&#x30E7;&#x30C3;&#x30C8;&#x3002;&#x30B3;&#x30F3;&#x30BD;&#x30FC;&#x30EB;&#x306B;&#x884C;&#x52D5;&#x30ED;&#x30B0;&#x306E;&#x4E2D;&#x8EAB;&#x304C;&#x8868;&#x793A;&#x3055;&#x308C;&#x3066;&#x3044;&#x308B;&#x3002;" width="600" height="322" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>[ログ確認ツールがなかった頃] Xcodeのコンソールに流れるログは見づらい</figcaption></figure></p> <p>しかし、関係のない情報もたくさん流れてくるし、見た目もJSONそのままだったり、差分も分かりづらかったりと、人間にとっては疲れるものでした。</p> <p><strong>行動ログの構成要素</strong></p> <ul> <li>そのログ特有の付加情報 <ul> <li>例:対象リソースID、検索キーワード など</li> </ul> </li> <li>全ログ共通で付加されている情報 <ul> <li>例:ユーザーID、端末OSバージョン、アプリバージョン など</li> </ul> </li> </ul> <p>1行のログには様々な情報が含まれますが、全てのプロパティを常に見たいわけではありません。固有の情報だけに絞って、内容の確認に集中しやすくしました。</p> <h2 id="2-ログ定義辞書">2. ログ定義辞書</h2> <p><figure class="figure-image figure-image-fotolife" title="iPadで見る「ログ定義一覧」"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008022235.png" alt="" width="1200" height="801" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>全ログ定義を一覧表示し、横断検索もできる。iPadにも対応。</figcaption></figure></p> <p>送信されたログだけでなく、いま定義されている全てのログについても辞書のように調べられます。</p> <h3 id="知らないログの意味が分かる">知らないログの意味が分かる</h3> <p><strong>ログ定義ドキュメントの活用</strong></p> <p>クックパッドでは、Markdown形式のログ定義をもとに型安全なログ実装用コードを生成する仕組みを3年以上運用しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2020%2F11%2F05%2F110000" title="ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2020/11/05/110000">techlife.cookpad.com</a></cite></p> <p>この仕組みのおかげで、ログ定義一つ一つに必ず説明文が用意されています。何のために導入されたログなのか、パラメータにはどのような値が入るのか、注意点は何か、当時のログ設計者が記したドキュメントから把握できます。</p> <p><strong>担当領域外にも目を向けやすく</strong></p> <p>そんな便利なログ定義Markdownですが、沢山の <code>.md</code> ファイルがただ置いてあるだけでは活用されません。 自分の担当領域のログは詳しくても、他の誰かが入れたログは何かきっかけがないとなかなか見ないものです。</p> <p>アプリ内で見やすくなることで、触っているうちに自然と「こんなのあったんだ!こういうときに使えそう💡」といった境界を越えた発見が生まれるのを狙った部分もあります。</p> <h3 id="ログを業務に活かしやすくなる">ログを業務に活かしやすくなる</h3> <p><figure class="figure-image figure-image-fotolife" title="ログ定義から、社内の分析SQL例をすぐに探せる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008023549.png" alt="&#x30ED;&#x30B0;&#x5B9A;&#x7FA9;&#x753B;&#x9762;&#x306E;&#x300C;Bdash Server&#x3067;SQL&#x4F8B;&#x3092;&#x63A2;&#x3059;&#x300D;&#x30DC;&#x30BF;&#x30F3;&#x3092;&#x30BF;&#x30C3;&#x30D7;&#x3059;&#x308B;&#x3053;&#x3068;&#x3067;&#x3001;&#x793E;&#x5185;&#x306E;&#x30C7;&#x30FC;&#x30BF;&#x5206;&#x6790;SQL&#x3068;&#x5B9F;&#x884C;&#x7D50;&#x679C;&#x306B;&#x3059;&#x3050;&#x305F;&#x3069;&#x308A;&#x7740;&#x3051;&#x3066;&#x3044;&#x308B;&#x56F3;" width="1200" height="789" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ログ定義から、社内の分析SQLの例をすぐに探せる</figcaption></figure></p> <p>ログを見にきた人は何かを調査・分析したい人のはずなので、それを手助けするリンクを用意しています。</p> <p><strong>集計・分析へのショートカット</strong></p> <p>例えばバナーの表示やタップのログであれば、集計SQLを書いて施策の効果検証をしそうです。</p> <p>クックパッドでは、データ分析SQLを共有できる社内Webサービス「Bdash Server」がよく使われています。そこで、各ログ定義に関するSQL例をBdash Serverですぐに検索できるボタンをつけました(上図)。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2021%2F06%2F11%2F120000" title="データ分析 SQL とその実行結果を共有・検索できるアプリ Bdash Server を作りました - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2021/06/11/120000">techlife.cookpad.com</a></cite></p> <p>「この結果が気になるけど、SQLを書くのがちょっと……」という人も、もしすでに誰かが作った結果で事足りるなら即解決できますし、少し違うとしても参考にできるSQLがあるだけで書きやすくなります。</p> <p>溜まっている知見をなめらかに使えるようにして、全社での生産性向上を狙っています。</p> <h2 id="3-ログ送信チェックリスト">3. ログ送信チェックリスト</h2> <p><figure class="figure-image figure-image-fotolife" title="ログ送信確認チェックリストに4つのログ定義が並んでいる図。4つのうち、3つは「送信済み」だが、1つだけ「未送信」と強調表示されている。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008153505.png" alt="&#x30ED;&#x30B0;&#x9001;&#x4FE1;&#x78BA;&#x8A8D;&#x30C1;&#x30A7;&#x30C3;&#x30AF;&#x30EA;&#x30B9;&#x30C8;&#x306B;4&#x3064;&#x306E;&#x30ED;&#x30B0;&#x5B9A;&#x7FA9;&#x304C;&#x4E26;&#x3093;&#x3067;&#x3044;&#x308B;&#x56F3;&#x3002;4&#x3064;&#x306E;&#x3046;&#x3061;&#x3001;3&#x3064;&#x306F;&#x300C;&#x9001;&#x4FE1;&#x6E08;&#x307F;&#x300D;&#x3060;&#x304C;&#x3001;1&#x3064;&#x3060;&#x3051;&#x300C;&#x672A;&#x9001;&#x4FE1;&#x300D;&#x3068;&#x5F37;&#x8ABF;&#x8868;&#x793A;&#x3055;&#x308C;&#x3066;&#x3044;&#x308B;&#x3002;" width="601" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>チェックリストに追加すれば、未送信のログがあぶり出される</figcaption></figure></p> <p>各ログ定義をチェックリストに登録しておけば、ログ送信時に自動でチェックされ、そのログが送信済み<a href="#f-b74e4bdd" name="fn-b74e4bdd" title="ここでの「送信済み」とは、アプリの起動から終了までの間での話です。アプリを再起動すると、すべて「未送信」に戻ります。">*4</a>かどうかを一瞬で確認できます。</p> <h3 id="ログが送られていない">ログが送られていない!?</h3> <p>新機能をリリースしていざ分析しようとしたら、必要なログの一部が送れていないことに気づき、「オァー!実装が漏れてました 💦」と焦る、そんな失敗が実際にありました。</p> <p><strong>例:「定期便」機能を新たにリリースする場合</strong></p> <ul> <li>見たい指標 <ul> <li>定期便初回登録時のファネル</li> <li>キャンペーンごとの効果</li> <li>定期便解除数</li> <li>定期便ユーザーのLTV など</li> </ul> </li> </ul> <p>施策に関する意思決定者と「最終的に何を知りたいのか」の認識を揃えることで、必要なログが洗い出せます。指標によっては、サーバー側のデータで事足りるものもあれば、モバイルアプリ側での行動ログが必要不可欠な場合もあります。</p> <ul> <li>必要なログ <ul> <li>定期便の商品詳細画面の表示</li> <li>定期便登録ボタンのタップ(≒定期便登録確認画面の表示)</li> <li>キャンペーンバナーの表示</li> <li>キャンペーンバナーのタップ</li> <li>定期便解除ボタンのタップ</li> <li>…… などなど</li> </ul> </li> </ul> <p>ややこしいのは、ユーザー状態次第では送らないログもあることです。例えば、定期便の初回ユーザー限定のバナーの表示ログは、一度でも定期便登録をしたユーザーからは送られないはずです。他にも、無料会員と有料会員の差、キャンペーンの流入経路ごとの差など、組み合わせ次第でどんどん複雑化していきます。</p> <p>仕様が複雑になるとミスもしやすいですし、品質保証のテストも手間がかかります。必要なログを漏れなくすべて送るのは結構大変です。チェックリスト機能はこの対策のために作ってみました。</p> <h3 id="QA作業でログの実装漏れもわかる">QA作業でログの実装漏れもわかる</h3> <p><figure class="figure-image figure-image-fotolife" title="ログ送信チェックリストの使い方"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231009/20231009193458.gif" width="720" height="260" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ログ送信チェックリストの使い方</figcaption></figure></p> <p>まず分析に必要なログ定義を一通りチェックリストに入れます。次に、想定されるユーザーと同様の流れでアプリを操作します。</p> <p>操作を終えたとき「すべて送信済み」となっているなら問題ありません。アプリをリリースしてOKです。もし「未送信あり」なら、ログ送信の実装漏れがどこかにあるということです。</p> <p>このチェックリスト機能を使えば、品質保証(QA)の手動テストの時間で、ついでにログの実装漏れも検出できます。</p> <h3 id="実装担当者以外でも分担できる">実装担当者以外でも分担できる</h3> <p>アプリ単体で完結するので、Xcodeなどの開発環境も必要なく誰でも実施できます。複数パターンの検証も、エンジニアに限らずチームで分担して一気に進められるのはうれしい点です。</p> <h1 id="実装方法">実装方法</h1> <p>アプリ内ログ確認ツールを実現するには、どうすればよいでしょうか?</p> <p>まず、送信済みログを読み出せること。次に、それらをログ定義ドキュメントとうまく紐付けて扱えること。この2つが必要です。</p> <p><figure class="figure-image figure-image-fotolife" title="クックパッドiOSアプリのログ関連の実装概要図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231009/20231009204815.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>クックパッドiOSアプリのログ関連の実装概要図</figcaption></figure></p> <p>送信済みログを端末内で保持するなら、ファイルへの出力、インメモリでの保持などいくつか方法が考えられます。 今回は、iOSの統合ロギングシステム(以下、OSログ)を活用することにしました。</p> <p><a href="https://developer.apple.com/documentation/os/logging">Logging | Apple Developer Documentation</a></p> <h2 id="1-送信済みログを読み出せるようにする">1. 送信済みログを読み出せるようにする</h2> <p>クックパッドiOSアプリでは、ログ収集ライブラリとして <code>Puree-Swift</code> を使っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2018%2F02%2F28%2F113000" title="良い感じにログを収集するライブラリ、Puree-Swiftをリリースしました - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2018/02/28/113000">techlife.cookpad.com</a></cite></p> <h3 id="osLogger-で書き込む">os.Logger で書き込む</h3> <p>以下のようなコードで、簡単にOSログに出力できます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> os <span class="synComment">// ログの出自がわかるように subsystem と category を指定</span> <span class="synPreProc">let</span> <span class="synIdentifier">logger</span> <span class="synIdentifier">=</span> Logger( subsystem<span class="synSpecial">:</span> <span class="synType">Bundle.main.bundleIdentifier!</span>, category<span class="synSpecial">:</span> <span class="synConstant">&quot;ActivityLog&quot;</span><span class="synComment">// 行動ログの場合の例</span> ) <span class="synPreProc">let</span> <span class="synIdentifier">logDataString</span> <span class="synIdentifier">=</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant">{</span> <span class="synConstant"> &quot;</span>user_id<span class="synConstant">&quot;: 1234567890,</span> <span class="synConstant"> &quot;</span>event_category<span class="synConstant">&quot;: &quot;</span>recipe_detail<span class="synConstant">&quot;,</span> <span class="synConstant"> &quot;</span>event_name<span class="synConstant">&quot;: &quot;</span>tap_save_button<span class="synConstant">&quot;,</span> <span class="synConstant"> &quot;</span>recipe_id<span class="synConstant">&quot;: 123456</span> <span class="synConstant">}</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synComment">// ログを書き込む</span> logger.notice(<span class="synConstant">&quot;</span><span class="synSpecial">\(</span>logDataString<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) </pre> <p>例えば、以下のように OSログ出力用の <code>Puree-Swift</code> のOutputを定義してConfiguration に加えると、ログサーバーへ送信されるログと同じ内容が、端末内のOSログにも出力されます。</p> <div style="border: 1px solid #aaa; border-radius: 4px; padding: 0.5em;"> <details> <summary>より詳細なPuree-Swift の Output 実装例はこちら</summary> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> Foundation <span class="synPreProc">import</span> os <span class="synPreProc">import</span> Puree <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">OSLogOutput</span><span class="synSpecial">:</span> <span class="synType">InstantiatableOutput</span> { <span class="synPreProc">let</span> <span class="synIdentifier">tagPattern</span><span class="synSpecial">:</span> <span class="synType">TagPattern</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">logger</span><span class="synSpecial">:</span> <span class="synType">os.Logger</span> <span class="synComment">// iOS 14+ で利用可能</span> <span class="synStatement">required</span> <span class="synIdentifier">init</span>(logStore<span class="synSpecial">:</span> <span class="synType">LogStore</span>, tagPattern<span class="synSpecial">:</span> <span class="synType">TagPattern</span>, options<span class="synSpecial">:</span> <span class="synType">OutputOptions?</span>) { <span class="synIdentifier">self</span>.tagPattern <span class="synIdentifier">=</span> tagPattern logger <span class="synIdentifier">=</span> Logger( subsystem<span class="synSpecial">:</span> <span class="synType">Bundle.main.bundleIdentifier!</span>, category<span class="synSpecial">:</span> <span class="synConstant">&quot;ActivityLog&quot;</span> <span class="synComment">// 行動ログの場合の例</span> ) } <span class="synPreProc">func</span> <span class="synIdentifier">emit</span>(log<span class="synSpecial">:</span> <span class="synType">LogEntry</span>) { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">userData</span> <span class="synIdentifier">=</span> log.userData <span class="synStatement">else</span> { assertionFailure(<span class="synConstant">&quot;logEntry must have userData&quot;</span>) <span class="synStatement">return</span> } <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">payload</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span>? JSONSerialization.jsonObject(with<span class="synSpecial">:</span> <span class="synType">userData</span>, options<span class="synSpecial">:</span> <span class="synSpecial">[]</span>) <span class="synStatement">as?</span> <span class="synSpecial">[</span><span class="synType">String</span><span class="synSpecial">:</span><span class="synType"> Any</span><span class="synSpecial">]</span> <span class="synStatement">else</span> { assertionFailure(<span class="synConstant">&quot;Cannot decode userData as JSONObject.&quot;</span>) <span class="synStatement">return</span> } <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">logDataString</span> <span class="synIdentifier">=</span> prettyJSONString(payload) { logger.notice(<span class="synConstant">&quot;</span><span class="synSpecial">\(</span>logDataString, privacy<span class="synSpecial">:</span> .<span class="synStatement">public</span><span class="synSpecial">)</span><span class="synConstant">&quot;</span>) <span class="synComment">// デフォルトでは情報がマスクされるが、開発版ビルドのみなので、`.public` にしている</span> } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">prettyJSONString</span>(_ object<span class="synSpecial">:</span> <span class="synType">Any</span>) <span class="synSpecial">-&gt;</span> <span class="synType">String?</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">data</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span>? JSONSerialization.data(withJSONObject<span class="synSpecial">:</span> <span class="synType">object</span>, options<span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">.prettyPrinted, .sortedKeys, .withoutEscapingSlashes</span><span class="synSpecial">]</span>) <span class="synStatement">else</span> { <span class="synStatement">return</span> <span class="synConstant">nil</span> } <span class="synStatement">return</span> String(data<span class="synSpecial">:</span> <span class="synType">data</span>, encoding<span class="synSpecial">:</span> .utf8) } } </pre> </details> </div> <p>この os フレームワークの Logger ですが、以下のような特徴があります。</p> <ul> <li>非常に効率的で、アプリの動作遅延なく使える<a href="#f-158f8cb4" name="fn-158f8cb4" title="https://developer.apple.com/videos/play/wwdc2020/10168/ より">*5</a></li> <li>Console.app や Xcode のコンソールでログを確認できる</li> <li>センシティブな情報はマスクできる(指定して公開もできる)</li> </ul> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fos%2Flogger" title="Logger | Apple Developer Documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.apple.com/documentation/os/logger">developer.apple.com</a></cite></p> <h3 id="OSLogStore-で読み出す">OSLogStore で読み出す</h3> <p>アプリ内ログ確認ツールで利用する際は、<code>OSLogStore</code> を使ってOSログから読み出しています。これは iOS 15以降で利用できます。</p> <p> <a href="https://developer.apple.com/documentation/oslog/oslogstore">OSLogStore | Apple Developer Documentation</a></p> <pre class="code lang-swift" data-lang="swift" data-unlink> <span class="synPreProc">import</span> OSLog <span class="synPreProc">protocol</span> <span class="synIdentifier">OSLogEntriesDataStoreProtocol</span><span class="synSpecial">:</span> <span class="synType">AnyObject</span> { <span class="synPreProc">func</span> <span class="synIdentifier">fetchEntries</span>() async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">OSLogEntry</span><span class="synSpecial">]</span> } <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">OSLogEntriesDataStore</span><span class="synSpecial">:</span> <span class="synType">OSLogEntriesDataStoreProtocol</span> { <span class="synPreProc">func</span> <span class="synIdentifier">fetchEntries</span>() async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">OSLogEntry</span><span class="synSpecial">]</span> { <span class="synPreProc">let</span> <span class="synIdentifier">store</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> OSLogStore(scope<span class="synSpecial">:</span> .currentProcessIdentifier) <span class="synPreProc">let</span> <span class="synIdentifier">predicate</span> <span class="synIdentifier">=</span> NSPredicate(format<span class="synSpecial">:</span> <span class="synConstant">&quot;subsystem == %@&quot;</span>, Bundle.main.bundleIdentifier<span class="synIdentifier">!</span>) <span class="synStatement">return</span> <span class="synStatement">try</span> store.getEntries(matching<span class="synSpecial">:</span> <span class="synType">predicate</span>) .reversed() <span class="synComment">// Workaround: `store.getEntries(with: .reverse, matching: predicate)` で降順(新しいログが先)に返されるはずだが、iOS 16時点では機能しないため、ここで逆順にしている。</span> <span class="synComment">// ※追記:iOS 17で直っていました!</span> } } </pre> <h2 id="2-ログ確認ツールで扱いやすいデータに変換する">2. ログ確認ツールで扱いやすいデータに変換する</h2> <h3 id="OSLogEntry-のメッセージをデコードする">OSLogEntry のメッセージをデコードする</h3> <p>読み出した <code>OSLogEntry</code> の <code>composedMessage</code> には行動ログの中身が入っていますが、この時点ではただのJSON文字列です。以下のようなコードで中身をデコードしてアプリ内ログ確認ツールで扱いやすくします。</p> <p><code>OSLogEntry</code> のままでは <code>category</code> <a href="#f-73cb7b3b" name="fn-73cb7b3b" title="https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category">*6</a> (先ほどの例では "ActivityLog" という値)を参照できないので、<code>OSLogEntryWithPayload</code> にダウンキャストします。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> OSLog <span class="synPreProc">struct</span> <span class="synIdentifier">LogEntryResolver</span> { <span class="synComment">/// OSLogEntryから必要な情報を取りだして、アプリ内ログ確認ツールで扱いやすいモデルに変換します</span> <span class="synStatement">static</span> <span class="synPreProc">func</span> <span class="synIdentifier">resolve</span>(entry<span class="synSpecial">:</span> <span class="synType">OSLogEntry</span>) <span class="synSpecial">-&gt;</span> <span class="synType">CookpadLogEntry?</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">entryWithPayload</span> <span class="synIdentifier">=</span> entry <span class="synStatement">as?</span> <span class="synType">OSLogEntryWithPayload</span> <span class="synStatement">else</span> { <span class="synStatement">return</span> <span class="synConstant">nil</span> } <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">payload</span> <span class="synIdentifier">=</span> decode(message<span class="synSpecial">:</span> <span class="synType">entry.composedMessage</span>, category<span class="synSpecial">:</span> <span class="synType">entryWithPayload.category</span>) <span class="synStatement">else</span> { <span class="synStatement">return</span> <span class="synConstant">nil</span> } <span class="synStatement">return</span> CookpadLogEntry(id<span class="synSpecial">:</span> <span class="synType">UUID</span>().uuidString, date<span class="synSpecial">:</span> <span class="synType">entry.date</span>, payload<span class="synSpecial">:</span> <span class="synType">payload</span>) } } </pre> <h3 id="行動ログの定義ドキュメントとの紐付け">行動ログの定義ドキュメントとの紐付け</h3> <p>クックパッドでは、Markdown形式のログ定義から型安全なログ実装用コードを生成するために <code>daifuku</code> <a href="#f-6841f8d7" name="fn-6841f8d7" title="ちなみに daifuku の由来は「大福帳」から。クックパッドでは、2020年頃に新しいログの仕組み、通称「大統一アクティビティログ」を導入した際に、すべてのカラムが1つのテーブルに横長に存在する非正規化された「大福帳型テーブル」に行動ログを集積するようになりました。https://techlife.cookpad.com/entry/2020/12/29/004145 ">*7</a> というライブラリを使っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fcookpad%2Fdaifuku" title="GitHub - cookpad/daifuku: A markdown parser and compiler for log definitions in mobile applications" 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></p> <p>今回はその仕組みを応用し、アプリ内ログ確認ツールからログ定義ごとの解説情報を参照できるコードを自動生成するようにしました。<a href="#f-ef4c1da7" name="fn-ef4c1da7" title="ここでは詳細を省いていますが、デモアプリを後々公開できればと思っています。">*8</a></p> <p><strong>ログ定義ごとの解説情報の用意</strong></p> <p>例えば、下記のように解説情報のためのstructを定義します。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">ActivityLogDefinition</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> { <span class="synComment">/// ログイベントカテゴリ(例: `sagasu` )</span> <span class="synPreProc">var</span> <span class="synIdentifier">category</span><span class="synSpecial">:</span> <span class="synType">Category</span> <span class="synComment">/// ログイベント(例:`show_content`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">event</span><span class="synSpecial">:</span> <span class="synType">Event</span> <span class="synPreProc">struct</span> <span class="synIdentifier">Event</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> { <span class="synComment">/// ログイベント名(例:`show_content`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// ログイベントの解説文(例:`さがすタブのコンテンツが画面に表示された時に送信されます。`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// ログイベントに付加されるパラメーター</span> <span class="synPreProc">var</span> <span class="synIdentifier">parameterNotes</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ParameterNote</span><span class="synSpecial">]</span> <span class="synComment">/// 各ログに付加されるパラメーターについての解説</span> <span class="synPreProc">struct</span> <span class="synIdentifier">ParameterNote</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> { <span class="synComment">/// パラメーターのキー名(例:`hashtag_ids`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// パラメーターの解説文(例:`表示されたハッシュタグID`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// パラメーターのSwiftでの型名(例: `String?` )</span> <span class="synPreProc">var</span> <span class="synIdentifier">swiftType</span><span class="synSpecial">:</span> <span class="synType">String</span> } } } </pre> <p>適当にRuby スクリプトを書いて、<code>daifuku</code>を使ってログ定義Markdownの情報を扱い、テンプレートをもとにログ解説情報を Swift の enum として自動生成します。</p> <p>Markdown からログ定義の enum を生成するRubyスクリプトの例 <a href="https://github.com/cookpad/daifuku/blob/e3cbfd1066fd7704b8210696aa90d5546ff6857d/example/iOS/generate-log-classes.rb">https://github.com/cookpad/daifuku/blob/e3cbfd1066fd7704b8210696aa90d5546ff6857d/example/iOS/generate-log-classes.rb</a></p> <div style="border: 1px solid #aaa; border-radius: 4px; padding: 0.5em;"> <details> <summary>自動生成用のテンプレートファイル (.erb) の例</summary> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// This file is automatically generated by generate-log-classes.</span> <span class="synPreProc">extension</span> <span class="synIdentifier">ActivityLogDefinition</span> { <span class="synPreProc">enum</span> <span class="synIdentifier">Category</span><span class="synSpecial">:</span> <span class="synType">String</span>, Hashable, CaseIterable { <span class="synIdentifier">&lt;%-</span> categories.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>category<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">case</span> <span class="synIdentifier">&lt;%=</span> category.variable_name <span class="synIdentifier">%&gt;</span> <span class="synIdentifier">=</span> <span class="synConstant">&quot;&lt;%= category.name %&gt;&quot;</span> <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> } } <span class="synPreProc">extension</span> <span class="synIdentifier">ActivityLogDefinition.Category</span> { <span class="synPreProc">var</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synType">String</span> { <span class="synStatement">switch</span> <span class="synIdentifier">self</span> { <span class="synIdentifier">&lt;%-</span> categories.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>category<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">case</span> .<span class="synIdentifier">&lt;%=</span> category.variable_name <span class="synIdentifier">%&gt;</span><span class="synSpecial">:</span> <span class="synType">return</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant"> &lt;%- category.descriptions.flat_map(&amp;:lines).each do |description_line| -%&gt;</span> <span class="synConstant"> &lt;%= description_line.strip %&gt;</span> <span class="synConstant"> &lt;%- end -%&gt;</span> <span class="synConstant"> &quot;&quot;&quot;</span> <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> } } <span class="synPreProc">var</span> <span class="synIdentifier">events</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ActivityLogDefinition.Event</span><span class="synSpecial">]</span> { <span class="synStatement">switch</span> <span class="synIdentifier">self</span> { <span class="synIdentifier">&lt;%-</span> categories.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>category<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">case</span> .<span class="synIdentifier">&lt;%=</span> category.variable_name <span class="synIdentifier">%&gt;</span><span class="synSpecial">:</span> <span class="synIdentifier">&lt;%-</span> <span class="synStatement">if</span> category.available_events.empty? <span class="synIdentifier">-%&gt;</span> <span class="synStatement">return</span> [] <span class="synIdentifier">&lt;%-</span> <span class="synStatement">else</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">return</span> [ <span class="synIdentifier">&lt;%-</span> category.available_events.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>event<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> .<span class="synIdentifier">init</span>( name<span class="synSpecial">:</span> <span class="synConstant">&quot;&lt;%= event.name %&gt;&quot;</span>, description<span class="synSpecial">:</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant"> &lt;%- event.descriptions.flat_map(&amp;:lines).each do |description_line| -%&gt;</span> <span class="synConstant"> &lt;%= description_line.strip %&gt;</span> <span class="synConstant"> &lt;%- end -%&gt;</span> <span class="synConstant"> &quot;&quot;&quot;</span>, parameterNotes<span class="synSpecial">:</span> <span class="synSpecial">[</span> <span class="synType"> &lt;%- event.columns.each do |column| -%&gt;</span> <span class="synType"> .init</span><span class="synSpecial">(</span> <span class="synType"> name: &quot;&lt;%= column.original_name %&gt;&quot;</span>, <span class="synType"> description: &quot;&quot;&quot;</span> <span class="synType"> &lt;%- column.descriptions.flat_map</span><span class="synSpecial">(</span><span class="synType">&amp;:lines</span><span class="synSpecial">)</span><span class="synType">.each do |description_line| -%&gt;</span> <span class="synType"> &lt;%= description_line.strip %&gt;</span> <span class="synType"> &lt;%- end -%&gt;</span> <span class="synType"> &quot;&quot;&quot;</span>, <span class="synType"> swiftType: &quot;&lt;%= column.swift_type %&gt;&quot;</span> <span class="synType"> </span><span class="synSpecial">)</span><span class="synType">,</span> <span class="synType"> &lt;%- end -%&gt;</span> <span class="synType"> </span><span class="synSpecial">]</span> ), <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> ] <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> <span class="synSpecial">&lt;%- </span><span class="synIdentifier">end</span><span class="synSpecial"> -%&gt;</span> } } } </pre> </details> </div> <p>こうして用意した解説情報を、送信済みログと紐付けます。</p> <p><strong>送信済みログとの紐付け</strong></p> <p>送信済みログの中身に含まれる <code>eventCategory</code> と <code>eventName</code> の値から、どのログ定義かは一意に定まります。下記のように、送信済みログのペイロードからログ定義解説情報を参照できるようにしました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">extension</span> <span class="synIdentifier">ActivityLogPayload.DefinitionKey</span> { <span class="synComment">/// 行動ログの定義ごとの解説情報</span> <span class="synPreProc">var</span> <span class="synIdentifier">definition</span><span class="synSpecial">:</span> <span class="synType">ActivityLogDefinition</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">category</span> <span class="synIdentifier">=</span> ActivityLogDefinition.Category(rawValue<span class="synSpecial">:</span> <span class="synType">eventCategory</span>), <span class="synPreProc">let</span> <span class="synIdentifier">event</span> <span class="synIdentifier">=</span> category.events.first(<span class="synStatement">where</span><span class="synSpecial">:</span> { <span class="synIdentifier">$0</span>.name <span class="synIdentifier">==</span> eventName }) <span class="synStatement">else</span> { fatalError(<span class="synConstant">&quot;ログ定義が見つかりませんでした&quot;</span>) } <span class="synStatement">return</span> ActivityLogDefinition(category<span class="synSpecial">:</span> <span class="synType">category</span>, event<span class="synSpecial">:</span> <span class="synType">event</span>) } } </pre> <h2 id="3-便利な機能を色々実装する">3. 便利な機能を色々実装する</h2> <p>あとは「扱いやすくした送信済みログ」と「解説情報」を材料として、自由に料理して好みの画面をつくるだけです。ここでは雑多にいくつかのトピックをご紹介します。</p> <h3 id="チェックリスト機能">チェックリスト機能</h3> <p>チェックリスト機能は、次のような単純な実装です。</p> <ul> <li>チェックリストに登録したログ定義のキーを UserDefaults で保持しておく。</li> <li>送信済みログの中に、そのキーと一致するログが1つでもあれば、チェックリスト上でそのログ定義を「送信済み」にする。</li> </ul> <h3 id="ネットワーク通信のログにも対応">ネットワーク通信のログにも対応</h3> <p>行動ログだけでなく、ネットワーク通信のログも見られるようにしています。実際にアプリを操作しながら、どのAPIエンドポイントがどのタイミングで使われているのか、すぐに確認できるのは便利です。</p> <p><figure class="figure-image figure-image-fotolife" title=" Request や Response の詳細も良い感じに表示"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011172953.png" width="500" height="987" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011131320.gif" width="590" height="1176" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption> Request や Response の詳細も良い感じに表示<a href="#f-4164d41f" name="fn-4164d41f" title="このJSONの表示部分は、同僚のNiaさんの [https://zenn.dev/niaeashes/articles/fca80c3ae3f8b4:title] を使わせてもらいました。https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8">*9</a></figcaption></figure></p> <p><a href="https://www.charlesproxy.com/">Charles</a> や <a href="https://proxyman.io/">Proxyman</a> などのサードパーティーアプリのほうが高機能ですし網羅性も高い<a href="#f-99417883" name="fn-99417883" title="アプリ内ログ確認ツールでは、自社のAPIクライアントを経由する通信のみに対応しています。例えば、 Firebase などサードパーティーライブラリの通信は対象外です。">*10</a>ですが、常に起動しているとも限らないですし、いざ使いたいときにちょっと手間がかかります。</p> <p><figure class="figure-image figure-image-fotolife" title=""><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011182939.png" width="609" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>障害発生時の調査にも役立った</figcaption></figure></p> <p>例えば、特定の画面がエラーになるといった障害が発生したとき、アプリ単体でも素早く調査できたのは便利でした。発生条件の特定や原因の切り分けがスムーズにできると、より焦らずに対応できます。</p> <p>なお、ネットワーク通信のログについては OSログには送らず、メモリ上に保持しています。 <a href="#f-8d0b9558" name="fn-8d0b9558" title="元々はURLとステータスコード程度の簡素な情報だけだったのでOSログに入れていましたが、同僚のVincent さん が response body も含める対応や、GraphQL の POST request への対応をしてくれました。APIクライアントに interceptor として追加し、一定量までメモリ上に保持するようになっています。">*11</a></p> <h4 id="実装に関して調査しやすくする">実装に関して調査しやすくする</h4> <p><figure class="figure-image figure-image-fotolife" title="実装の調査が捗るリンク"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011181717.png" width="1200" height="853" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ネットワーク通信のログから、API仕様の調査やソースコード検索がすぐできる</figcaption></figure></p> <p>行動ログの集計・分析ショートカットと同様に、ネットワーク通信のログを見にきた人はAPIや実装に関して色々調査をしたいはずだということで、以下の社内Webサービスへのリンクを用意しています。</p> <ul> <li>APIサーバーに対して実際のリクエストを手軽に試せる「API3 Console」</li> <li>APIサーバーのスキーマ定義からドキュメントを提供する「Garage Playground<a href="#f-b6ce1ca1" name="fn-b6ce1ca1" title="クックパッドでは Garage と呼ばれるRESTful Web API 開発を楽にするライブラリが標準的に使われています。https://techlife.cookpad.com/search?q=Garage">*12</a>」</li> <li>ソースコードやタスク、プロジェクトの管理をしている「GitHub Enterprise」</li> </ul> <p>例えば 「実装箇所を GitHub Enterprise(GHE)で表示する」ボタンは、レポジトリ内のSwiftコードの検索結果のURLを開くだけですが、秒で利用箇所が見つかるのは思っている以上に便利です。</p> <p>小ネタですが、次のような工夫も入れています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// recipeID, userID などを含むURLは、GHE検索時にヒットせず不便なので * に変換している</span> <span class="synComment">// (例: `/v1/recipes/:id`, `/v1/users/:id/visited_recipes` など)</span> <span class="synPreProc">let</span> <span class="synIdentifier">query</span> <span class="synIdentifier">=</span> request.url.path.replacingOccurrences(of<span class="synSpecial">:</span> <span class="synConstant">&quot;/([0-9]+)(/|$)&quot;</span>, with<span class="synSpecial">:</span> <span class="synConstant">&quot;/*$2&quot;</span>, options<span class="synSpecial">:</span> .regularExpression) </pre> <h4 id="SimulatorではURLコピーに">SimulatorではURLコピーに</h4> <p><figure class="figure-image figure-image-fotolife" title="実機ではブラウザで開き、SimulatorではURLをクリップボードにコピーする"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011051235.gif" width="590" height="470" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>実機ではブラウザで開き、SimulatorではURLをコピーする</figcaption></figure></p> <p>さらに小ネタですが、iOS Simulatorで開発中にこのボタンを使うと、Simulator内のSafariが開いてしまい不便<a href="#f-4ecb99da" name="fn-4ecb99da" title="Simulator内のMobile Safariでも使えることは使えますが、ログインが必要で「うーーーん」となってしまいました。macOS側で開けるほうが快適そうです。">*13</a>だったので、こんな対策をしました。 Simulator実行時は URLをクリップボードにコピーするので macOS側 ですぐ開けます。iPhone/iPadの実機では実機のブラウザが開きます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// リンクボタンの実装例</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { <span class="synPreProc"> #if</span> targetEnvironment(simulator) buttonForSimulator(targetURL) <span class="synPreProc"> #else</span> buttonForRealDevice(targetURL) <span class="synPreProc"> #endif</span> } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">buttonForSimulator</span>(_ targetURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { CopyTextButton( stringToCopy<span class="synSpecial">:</span> <span class="synType">targetURL.absoluteString</span>, labelTitleForCopied<span class="synSpecial">:</span> <span class="synConstant">&quot;URLをコピーしました(Simulator内のSafariで開くと不便なので)&quot;</span> ) { label } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">buttonForRealDevice</span>(_ targetURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { Link(destination<span class="synSpecial">:</span> <span class="synType">targetURL</span>) { label } .contextMenu { <span class="synComment">// 実機でも長押しメニューから一応コピーできるようにしている</span> CopyTextButton(stringToCopy<span class="synSpecial">:</span> <span class="synType">targetURL.absoluteString</span>) { Label(<span class="synConstant">&quot;URLをコピー&quot;</span>, systemImage<span class="synSpecial">:</span> <span class="synConstant">&quot;doc.on.doc&quot;</span>) } } } </pre> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synPreProc">struct</span> <span class="synIdentifier">CopyTextButton</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Content</span><span class="synSpecial">: </span><span class="synType">View</span><span class="synSpecial">&gt;:</span> <span class="synType">View</span> { <span class="synPreProc">var</span> <span class="synIdentifier">stringToCopy</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synPreProc">var</span> <span class="synIdentifier">labelTitleForCopied</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synType">@ViewBuilder</span> <span class="synType">var</span> content<span class="synSpecial">:</span> <span class="synType">Content</span> <span class="synIdentifier">init</span>(stringToCopy<span class="synSpecial">:</span> <span class="synType">String</span>, labelTitleForCopied<span class="synSpecial">:</span> <span class="synType">String</span> <span class="synIdentifier">=</span> <span class="synConstant">&quot;コピーしました!&quot;</span>, <span class="synType">@ViewBuilder</span> <span class="synType">content</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span>) { <span class="synIdentifier">self</span>.stringToCopy <span class="synIdentifier">=</span> stringToCopy <span class="synIdentifier">self</span>.labelTitleForCopied <span class="synIdentifier">=</span> labelTitleForCopied <span class="synIdentifier">self</span>.content <span class="synIdentifier">=</span> content() } <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isCopied</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { Button { UIPasteboard.general.string <span class="synIdentifier">=</span> stringToCopy print(<span class="synConstant">&quot;[LogChecker] Copied to the pasteboard: </span><span class="synSpecial">\(</span>stringToCopy<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) Task { <span class="synStatement">defer</span> { isCopied <span class="synIdentifier">=</span> <span class="synConstant">false</span> } isCopied <span class="synIdentifier">=</span> <span class="synConstant">true</span> <span class="synStatement">try</span>? await Task.sleep(<span class="synStatement">for</span><span class="synSpecial">:</span> .seconds(<span class="synConstant">3</span>)) } } label<span class="synSpecial">:</span> { <span class="synStatement">if</span> isCopied { Label(labelTitleForCopied, systemImage<span class="synSpecial">:</span> <span class="synConstant">&quot;doc.on.doc&quot;</span>) .font(.callout) .foregroundColor(.secondary) .imageScale(.small) } <span class="synStatement">else</span> { content } } } } </pre> <h1 id="振り返って">振り返って</h1> <h2 id="よかったこと">よかったこと</h2> <p>このログ確認ツールは、色んな面で開発を楽しくできました。</p> <h3 id="ログの実装確認がつらくなくなる">ログの実装・確認がつらくなくなる</h3> <p>「大事だけど正直面倒な作業」とも感じていたログの実装や確認を幾分か快適にできたと思います。個人的にはアプリ内ログ確認ツールを使いはじめてからは「ちょっと楽しいまである」という気持ちに変化していました。同僚からもSlackなどで「すごい見やすくなってる!」「はちゃめちゃに助かっている」「課金したい」といったポジティブな反応をもらえています。</p> <h3 id="自由に実験できる環境で遊べる">自由に実験できる環境で遊べる</h3> <p>Viewについては、すべてSwiftUIで実装しました。</p> <p>普段、一般ユーザー向けに開発している画面は SwiftUI (場合によっては UIKit)を採用していますが、全体的には VIPER アーキテクチャで、画面遷移も UINavigationController をベースに使っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2021%2F11%2F01%2F090000" title="UINavigationControllerをカスタマイズ 〜OSの影響を受けづらいカスタムナビゲーションの実装〜 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2021/11/01/090000">techlife.cookpad.com</a></cite></p> <p>今回は開発者用ツールということもあって、サポートOSバージョンや不具合などはそこまで気にしなくても済む状況でした。むしろ、こういう機会に積極的に新しい技術を試して、知見を貯めるほうが望ましいでしょう。</p> <p>チームで合意をとり、ログ確認ツールに関しては <code>@available(iOS 16.0, *)</code> (今なら iOS 17)をつけて、最新のSwiftやSwiftUIの機能を使い放題にしました。制約なく技術を楽しめるエンジニアにとってのオアシスのような場所です。</p> <p>例えば <code>NavigationStack</code> や <code>NavigationSplitView</code> など、普段使っていないSwiftUIの画面遷移関連も試しています。 正規表現を使う箇所では、<code>RegexBuilder</code> も試しました。</p> <p>「<code>ViewThatFits</code>を使えば、簡単に解決できてすごく便利だ」「この書き方、iOS 17から deprecated になるのか!」「これ便利だけど、この挙動は気を付けないと不具合を生み出しそうだ……」</p> <p>このように自由に実践して得られた知見や肌感覚は、ただ楽しいだけではなく、近い将来のユーザー向け機能の開発をスムーズにして、とても役立ちます。</p> <h3 id="欲しいものを作れると楽しい">欲しいものを作れると楽しい</h3> <p>あったらいいなを次々と実現するのが純粋に楽しかったです。 <a href="#f-17ee0445" name="fn-17ee0445" title="Cookpad TechConf 2022のLTでも「めちゃくちゃ楽しかった仕事の話をさせてほしい〜iOSアプリのログ編〜」として発表しています。動画: https://youtu.be/2HitJxXXzwY?t=1325 ">*14</a></p> <p><figure class="figure-image figure-image-fotolife" title="改善の無限ループが爆速で進むのは楽しい"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231013/20231013155021.png" width="1200" height="825" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>改善の無限ループが爆速で進むのは楽しい</figcaption></figure></p> <p>開発者向けツールはユーザーが自分でもあるので、ニーズの理解も、真に解決できているのかの実感もすぐできます。 作ってみて、試しに使って、新たな発見があってまた作る、この改善が爆速で進められます。</p> <p>業務にしっかり役立つ「仕事」ではあるものの、楽しくてついやってしまう「趣味」でもあり、「趣味の仕事」という言葉が社内で流行していました。このアプリ内ログ確認ツールも趣味の仕事の一例です。</p> <h2 id="改善したいこと">改善したいこと</h2> <h3 id="ログの読み込みを速くしたい">ログの読み込みを速くしたい</h3> <p>OSログは書き込みは良いですが、読み込みは遅いようです。 <code>os.signpost</code> と Instruments を使って計測してみると、<code>.getEntries</code><a href="#f-77c1a087" name="fn-77c1a087" title="https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries">*15</a> の1行だけで圧倒的に時間がかかっています。</p> <p>動作環境によって大きく差があり、気にならない程度のときもあれば10秒近くかかってさすがに使いづらいと感じるときもあります。Simulatorと実機の差、OSログに溜まった量の差などいくつか要因がありそうな気もしつつ、詳しくはまだ調べられていません。</p> <p>画面表示毎に更新すると読み込みで待ちすぎるので、今はキャッシュ層を挟んで更新頻度を下げています。</p> <h2 id="まとめ">まとめ</h2> <p>今回は、アプリ内ログ確認ツールの機能や実装方法、分かったことについてご紹介しました。</p> <p>まだ改善の余地はありますが、ちょっとした工夫の積み重ねによって開発を快適にする目的は一定達成できたと感じています。</p> <p>日々のサービス開発をより良くするために、この記事が何か少しでも参考になったら幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-899f9b8c" name="f-899f9b8c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">このツールは開発版ビルドのみに含まれており、App Store版では利用できません。</span></p> <p class="footnote"><a href="#fn-315eaa85" name="f-315eaa85" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">SwiftUIで作られた画面なので .blur(radius:) を適当につけるだけで簡単にぼかせて、こういうGIFをつくるときに便利です。</span></p> <p class="footnote"><a href="#fn-2a16df53" name="f-2a16df53" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">なお、Xcode 15 では Debug Console が強化され、重要度やログの種類ごとにフィルタリングできるなど便利になりました。<a href="https://developer.apple.com/videos/play/wwdc2023/10226/">https://developer.apple.com/videos/play/wwdc2023/10226/</a> </span></p> <p class="footnote"><a href="#fn-b74e4bdd" name="f-b74e4bdd" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここでの「送信済み」とは、アプリの起動から終了までの間での話です。アプリを再起動すると、すべて「未送信」に戻ります。</span></p> <p class="footnote"><a href="#fn-158f8cb4" name="f-158f8cb4" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/videos/play/wwdc2020/10168/">https://developer.apple.com/videos/play/wwdc2020/10168/</a> より</span></p> <p class="footnote"><a href="#fn-73cb7b3b" name="f-73cb7b3b" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category">https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category</a></span></p> <p class="footnote"><a href="#fn-6841f8d7" name="f-6841f8d7" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">ちなみに daifuku の由来は「大福帳」から。クックパッドでは、2020年頃に新しいログの仕組み、通称「大統一アクティビティログ」を導入した際に、すべてのカラムが1つのテーブルに横長に存在する非正規化された「大福帳型テーブル」に行動ログを集積するようになりました。<a href="https://techlife.cookpad.com/entry/2020/12/29/004145">https://techlife.cookpad.com/entry/2020/12/29/004145</a> </span></p> <p class="footnote"><a href="#fn-ef4c1da7" name="f-ef4c1da7" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここでは詳細を省いていますが、デモアプリを後々公開できればと思っています。</span></p> <p class="footnote"><a href="#fn-4164d41f" name="f-4164d41f" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text">このJSONの表示部分は、同僚のNiaさんの <a href="https://zenn.dev/niaeashes/articles/fca80c3ae3f8b4">SwiftUI &#x3067; JSON &#x3092;&#x8868;&#x793A;&#x3059;&#x308B; View</a> を使わせてもらいました。<a href="https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8">https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8</a></span></p> <p class="footnote"><a href="#fn-99417883" name="f-99417883" class="footnote-number">*10</a><span class="footnote-delimiter">:</span><span class="footnote-text">アプリ内ログ確認ツールでは、自社のAPIクライアントを経由する通信のみに対応しています。例えば、 Firebase などサードパーティーライブラリの通信は対象外です。</span></p> <p class="footnote"><a href="#fn-8d0b9558" name="f-8d0b9558" class="footnote-number">*11</a><span class="footnote-delimiter">:</span><span class="footnote-text">元々はURLとステータスコード程度の簡素な情報だけだったのでOSログに入れていましたが、同僚の<a href="https://twitter.com/vincentisambart">Vincent さん</a> が response body も含める対応や、GraphQL の POST request への対応をしてくれました。APIクライアントに interceptor として追加し、一定量までメモリ上に保持するようになっています。</span></p> <p class="footnote"><a href="#fn-b6ce1ca1" name="f-b6ce1ca1" class="footnote-number">*12</a><span class="footnote-delimiter">:</span><span class="footnote-text">クックパッドでは Garage と呼ばれるRESTful Web API 開発を楽にするライブラリが標準的に使われています。<a href="https://techlife.cookpad.com/search?q=Garage">https://techlife.cookpad.com/search?q=Garage</a></span></p> <p class="footnote"><a href="#fn-4ecb99da" name="f-4ecb99da" class="footnote-number">*13</a><span class="footnote-delimiter">:</span><span class="footnote-text">Simulator内のMobile Safariでも使えることは使えますが、ログインが必要で「うーーーん」となってしまいました。macOS側で開けるほうが快適そうです。</span></p> <p class="footnote"><a href="#fn-17ee0445" name="f-17ee0445" class="footnote-number">*14</a><span class="footnote-delimiter">:</span><span class="footnote-text">Cookpad TechConf 2022のLTでも「めちゃくちゃ楽しかった仕事の話をさせてほしい〜iOSアプリのログ編〜」として発表しています。動画: <a href="https://youtu.be/2HitJxXXzwY?t=1325">https://youtu.be/2HitJxXXzwY?t=1325</a> </span></p> <p class="footnote"><a href="#fn-77c1a087" name="f-77c1a087" class="footnote-number">*15</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries">https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries</a></span></p> </div> y_f クックパッドの検索反映時間を 1/288 にしたシステム改修 hatenablog://entry/820878482973215525 2023-10-05T15:00:00+09:00 2023-10-05T15:07:53+09:00 レシピを投稿してから検索結果に反映されるまでの時間を、24 時間から 5 分にまで短縮したシステム改修について紹介します。 <p>こんにちは。レシピ事業部の新井(<a href="https://twitter.com/SpicyCoffee66">@SpicyCoffee</a>)です。</p> <p>クックパッドではこれまで、レシピを投稿してから検索結果に反映されるまで最長で 24 時間程度の時間がかかっていました。今回、この時間を 5 分程度、最長でも 10 分程度に短縮することに成功しました。本記事では、プロジェクトオーナーの立場で関わった私が代表してその開発について紹介します。</p> <h1 id="プロジェクトの目的と数値目標">プロジェクトの目的と数値目標</h1> <p>本プロジェクトでは上記の「レシピを投稿してから検索結果に反映されるまでの時間短縮」が目的とされました。しかし、時間短縮といっても現状 24 時間であるものを "1 時間" にするのか、"1 分" にするのか、"1 秒" にするのかでは話が全然違います。この数値目標は設計を始めとした後の意思決定に大きく影響を与えるため、しっかりとした意図を持った状態で明確に定めておく必要がありました。</p> <p>そこで、私とプロダクトオーナー<a href="#f-9b11b8ee" name="fn-9b11b8ee" title="今回は CEO がその役割を担っていました。社長と直接仕事をする機会が降ってきてラッキー。">*1</a>が議論を重ね、まずは ”今回のプロジェクトで実現したいユーザー体験" を定めました。その体験から必要となる検索結果の反映頻度を逆算し、最終的な数値目標を「中央値 5 分程度、最大でも 10 分以内の検索結果への反映」であると定めることとなりました。同時に定めたプロジェクトのスケジュールは 6 週間であり、見積もりの第一印象としてはかなりギリギリの設定でした。</p> <p>この記事では、今後本プロジェクトで実現された「検索結果が反映されるまでの時間の短縮」を “short-period indexing” と呼称することにします。</p> <h1 id="旧システムの概要">旧システムの概要</h1> <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/s/spicycoffee/20231005/20231005140329.png" width="1200" height="665" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>旧システムの構成</figcaption></figure></p> <p>旧システムの肝は以下の2点です。</p> <h2 id="検索インデックスを生成する日次バッチ">検索インデックスを生成する日次バッチ</h2> <p>旧システムでは、検索結果の更新を 24 時間に一度でいいと割り切り、日次バッチでインデックスの更新を行っていました。レシピに関する各種メタデータを集め、必要に応じて加工することでドキュメントを生成し、そのドキュメントを Solr に送信することでインデックスを生成します。生成されたインデックスは後ほど説明する ECS を利用したデプロイメントのために S3 に配置されます。</p> <p>日次更新でよいという割り切りの元に、およそ 100 を超える field の情報を数百万レシピについて毎日生成しており、中には機械学習を用いてレシピにスコアを付与するような処理も含まれていたため、その実行時間は 90 分程度になっていました。</p> <p>ちなみに、このバッチ自体も 5 年ほど前に旧システムから分離・リプレイスされたものになります。当時の様子は以下の記事に記載してあるため、よろしければあわせてご覧ください。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2019%2F06%2F17%2F123000" title="レシピ検索を支えるレガシーでクリティカルな大規模バッチを刷新した話 - クックパッド開発者ブログ" 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></p> <h2 id="ECS-を利用したデプロイメント">ECS を利用したデプロイメント</h2> <p>旧システムでは、ECS のタスクとして Solr を起動していました。一般に、検索エンジンのようなステートフルなミドルウェアと ECS は相性がよくないとされています。しかし、旧システムでは S3 にインデックスを配置してタスクの起動時にそれをダウンロードしてくることでステートをコンテナの外に出し、その相性の悪さを解消しています。</p> <p>この設計は「ステート(= インデックス)の更新頻度が十分に低い」という前提に基づいているものであり、本プロジェクトの目的を達成するためにはリプレイスする必要性が出てくる可能性もある箇所でした。</p> <p>この開発についての詳細は以下の記事で解説されていますので、よろしければこちらもご覧ください。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2020%2F11%2F25%2F080000" title="人気順検索のSolrはスケールのためにディスクを捨てた - クックパッド開発者ブログ" 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></p> <h1 id="目標達成のための課題">目標達成のための課題</h1> <p>旧システムを考察することで、目標を達成するためには以下のような課題があることがわかってきました。</p> <h2 id="short-period-indexing-に適した-Solr-の使い方を再考する">short-period indexing に適した Solr の使い方を再考する</h2> <p>旧システムでは、常在的に起動している Solr は "参照系" のみであり、index の更新時には spot instance として "更新系" の Solr を立ち上げて index を生成していました。index の更新が日次であればこの方法でも問題ありませんが、これが数分以下のオーダーになるなら "更新系" の Solr も常に稼働し、かつ複数の "参照系" Solr に更新を同期する必要がありそうです。</p> <p>更新の同期方法については、たとえば、Solr にはクラスタを組んで replication を実行するための機能があります。しかし、この機能が ECS に Solr を乗せている状態でも問題なく動作するかは自明ではありません。ECS を活用することによるデプロイやスケーリングの容易性といったメリットは可能な限り残したい<a href="#f-f14419c3" name="fn-f14419c3" title="当時の ECS &amp;amp; 社内基盤 Hako という構成は運用負荷が低い上に非常に安定しており、Solr が直接の理由となって障害が起きたのは年に 1 度もないように記憶しています。">*2</a>ものの、そのためには新しい要件に合わせた調査や工夫が必要になりそうです。</p> <p>そもそも S3 を介してインデックスを配布するやり方が適しているかも含め Solr 周りの構成・設計は大幅に考え直す必要がありそうでした。</p> <h2 id="インデックスする情報を選別する">インデックスする情報を選別する</h2> <p>前述したように、レシピのドキュメントは 100 前後の field を持っており、中には機械学習を用いて付与されたスコアのようなものも含まれます。これら全ての情報をインデックスしようとすると、そもそもその処理に時間がかかる可能性が高く、short-period indexing のタイムスパンでこれを実行することは困難だと考えられます。したがって、ユーザー体験に立ち返って short-period indexing のスコープに含める field を定義する必要がありました。</p> <p>また、クックパッドのレシピはユーザー投稿物です。したがって、何のチェックもせずにレシピをインデックスしてしまうと、明らかに料理ではない写真を用いたレシピなどの、不適切な投稿の露出が増えてしまう可能性があります。このことを考えると、インデックスする情報に加えて「どのレシピをインデックスするか」という判定が必要になると予想されました<a href="#f-f1ecce9b" name="fn-f1ecce9b" title="人手によるレシピの全件チェックは short-period indexing 以前も行われていたため、「オペレーションの見直しも含め、レシピチェック周りでもシステム変更が必要になると予想された」という表現の方が正確かもしれません。">*3</a>。</p> <h2 id="日次バッチによる更新と-short-period-indexing-による更新を同居させる">日次バッチによる更新と short-period indexing による更新を同居させる</h2> <p>日次バッチによるインデックス更新は、更新頻度と引き換えではありましたが、緊急時にロールバックが容易になるといったメリットもありました。検索結果に不具合が生じた際、インデックスのバージョンを巻き戻すことで前日時点のインデックスを用いて検索機能を提供することが容易で、これは検索システムそのものの頑健性を支える一つの要素になっています。</p> <p>この「セーブポイントをつくる」機能は有用なため可能であれば残したく、そうなると日次バッチによる更新と short-period indexing による更新が並列することになります。こうなるとインデックスの更新経路が複数になるため、その際にコンフリクトが起こらないようにシステムを設計する必要がありそうでした。</p> <h2 id="キャッシュが検索結果の更新を阻害しないようにする">キャッシュが検索結果の更新を阻害しないようにする</h2> <p>前述した構成図では表現されていませんでしたが、検索システムの周辺には多種多様のキャッシュが存在しています。クライアントアプリからのリクエストを受け付ける API や、検索サーバーからのリクエストを受け付ける Solr と、複数箇所にキャッシュが存在しており、検索インデックスの更新時にはこれらを破棄しなければ検索結果が変化しません。</p> <p>単純にキャッシュを剥がせば各サービスへの負荷増大は避けられず、まずは現状のヒット率等を調査して剥がせるなら少しずつ剥がす、難しそうならサーバーを増やすなどの対応が必要になりそうでした。</p> <h1 id="新システムの概要">新システムの概要</h1> <p>以上に挙げた課題を解決するために、以下の図に示すような全体像のシステムを設計・開発しました。</p> <p>開発の流れとしては、全体設計についてはプロジェクトメンバーの 4 名全員で議論しながら固め、必要な開発がある程度特定された後に、各位の専門領域に合わせて調査や実装を割り振る形にしました。</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/spicycoffee/20231005/20231005140655.png" width="1200" height="856" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>新システムの構成</figcaption></figure></p> <p>新システムの特徴を以下に示します。</p> <h2 id="1-User-Managed-Index-Replication-を利用した-Solr-cluster-の構築">1. User-Managed Index Replication を利用した Solr cluster の構築</h2> <p>新システムでは Solr が提供する <a href="https://solr.apache.org/guide/solr/9_1/deployment-guide/user-managed-index-replication.html">User-Managed Index Replication</a> の仕組みを利用して "更新系" と "参照系" を組み合わせた Solr cluster を構築しました。</p> <p>このモードでは Solr インスタンスは update リクエストを受け付ける 1 台の leader と、検索リクエストを受け付ける複数台の follower に分かれます。follower は設定した時間ごとに leader に対してポーリングを行い、差分をダウンロードします<a href="#f-5d18f9a0" name="fn-5d18f9a0" title="leader から follower に対して変更を通知しない点は MySQL の replication との違いかもしれません。">*4</a>。それぞれの Solr は旧システムと変わらず Hako を用いて ECS Task として起動しています。</p> <p>細かな要件としては、更新がコンフリクトしないように同時に起動している leader は最大 1 task に抑える必要があり、これは ECS の <code>minimumHealthyPercent</code> や <code>maximumPercent</code> を設定することで保証しています。</p> <p>また、follower は起動時に日次バッチで生成された index を S3 からダウンロードし、その後 leader が保持している更新分を replicate し終わったタイミングで自身の status を healthy としてサービスインします。こうすることで、ヘルスチェックを成功させるタイミングをコントロールし、起動後 replication 途中の follower にアクセスが集中すると、アクセス毎に検索結果が変わってしまうといった問題を防いでいます。</p> <h2 id="2-EFS-を利用した-index-の永続化">2. EFS を利用した index の永続化</h2> <p>新システムにおいては、leader Solr が再起動や deploy をした場合においても index の状態を保ち、update と replication が正しく動作する状態を保証する必要があります。</p> <p>これを実現するために、AWS のネットワークストレージサービスである EFS が利用できます。EFS を ECS にアタッチすることで、永続的なストレージをマウントすることができます。しかし、EFS はネットワーク越しにアクセスするストレージであるため、レイテンシ等の性能は ECS のエフェメラルストレージに対して少し劣るものとなってしまいます。</p> <p>そこで、update リクエストを受け付けて index を永続化する必要のある leader のストレージには EFS を使い、ユーザーからの検索リクエストを受け付けて素早く応答する必要がある follower のストレージには tmpfs を利用することとしています。</p> <p>また、新システムにおいても、旧システムと同様に日次で計算・付与される field は存在するため、日次バッチで生成された index で EFS の中身を差し替える処理が実行されています。</p> <p>このとき、index の差し替えや leader/follower の再起動順序によっては replication の整合性が取れなくなり様々な問題が発生することがわかったため、依存関係を丁寧に整理して各処理の実行順序を制御しています<a href="#f-7ea06241" name="fn-7ea06241" title="現状の実装だと検索結果が数時間前の状態に一瞬だけ巻き戻ってしまったりするのですが、実装難易度を考えてこれを仕様側で許容するといった判断もおこなっています。">*5</a>。</p> <h2 id="3-index-update-batch-の定期実行">3. index update batch の定期実行</h2> <p>index の更新は 5 分ごとに定期実行するバッチで実現しています。その定期実行ごとに「直近 1 時間で更新があったレシピの情報」を取得し、その情報を元に必要な処理を施してドキュメントを生成し、leader に update のリクエストを投げるという流れです。</p> <p><figure class="figure-image figure-image-fotolife" title="5 分に一度 update がリクエストされている様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/spicycoffee/20231005/20231005141050.png" width="1200" height="322" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>5 分に一度 update がリクエストされている様子</figcaption></figure></p> <p>このとき「そのレシピが不適切な投稿である確率はどのくらいか」を ML によって判定する API へのリクエストを挟むことで、不適切投稿の露出が増えることを防いでいます<a href="#f-68d5fb5c" name="fn-68d5fb5c" title="この API の開発は、投稿物のチェックを行っているチームと機械学習チームの協力によって迅速に開発されました。">*6</a>。</p> <p>定期実行バッチにするのではなく、レシピの投稿・更新にフックさせてイベントを発行・キューイングして都度処理する方針も考えましたが、</p> <ul> <li>イベントの発行数が多くなり既存の社内基盤を利用することができるかどうかが明らかでなかった</li> <li>リトライ処理の実装が複雑になる</li> <li>そこまでのリアルタイム性が求められていない</li> </ul> <p>ことから採用を見送っています。</p> <h1 id="本番環境への展開">本番環境への展開</h1> <p>プロジェクトの完遂には、システムの構築とは別に展開に向けた各種作業も必要です。今回は SRE のメンバーの協力によって、以下に挙げるような作業を事前にキャッチアップ・進行してもらうことができ、非常にスムーズに展開を終えることができました。</p> <h2 id="キャッシュの整理">キャッシュの整理</h2> <p>システムを構築しても、既存のキャッシュ構成は日次での検索結果更新を前提としていたため、TTL が数時間単位のものになっていました。このままでは、キャッシュの更新間隔が検索結果の更新間隔よりも長くなってしまいます。</p> <p>検索結果の更新頻度に合わせてキャッシュの TTL を短縮したいですが、調査が不十分のまま進めるとキャッシュの裏側にあるサービスへの負荷が増大し、障害を引き起こしてしまう可能性があります。</p> <p>そこでまずはキャッシュの設定変更が与えている影響を観測できるように、Prometheus + prometheus_exporter gem を用い、キャッシュのヒット率などを計測するようにしました<a href="#f-441c6e5a" name="fn-441c6e5a" title="クックパッドが採用している Unicorn はマルチプロセスで動いているため、prometheus_exporter の multi process modeを用いました。">*7</a>。次にそれらの変化や各サービスの負荷を確認しながらキャッシュの TTL を徐々に短くする変更を行い、最終的に、サービス障害を起こすことなく TTL を 5 分にまで短縮できました。</p> <h2 id="負荷試験と段階ロールアウト">負荷試験と段階ロールアウト</h2> <p>検索機能の変更はクックパッドのほぼ全ユーザーに影響を与える大規模なものになります。本番展開前の負荷試験は、展開後の障害発生率を抑えることができるのはもちろん、開発者が安心して展開を行えるようになります。</p> <p>また、展開自体を一度に行うのではなく、徐々にユーザーリクエストを流すような段階ロールアウトの手順を踏むことで、大規模障害の発生率を抑えることができます。</p> <p>今回は以下の手順で負荷試験と段階ロールアウトを行いました。</p> <ol> <li>本番の Solr に届いているリクエストをミラーリングし、新 Solr cluster でもリクエストを問題なく捌けるかを確認する(負荷試験) <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/spicycoffee/20231005/20231005141214.png" width="1160" height="582" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>負荷試験の概要</figcaption></figure></li> <li>実際に一部のレスポンスを新システムからのものに差し替え、徐々にその割合を大きくしていく(段階ロールアウト) <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/spicycoffee/20231005/20231005141301.png" width="1149" height="572" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>段階ロールアウトの概要</figcaption></figure></li> <li>全てのレスポンスが新システムからのものになった後、short-period indexing を有効にする</li> </ol> <p>このうち、1 の負荷試験は Envoy の<a href="https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-msg-config-route-v3-routeaction-requestmirrorpolicy">RequestMirrorPolicy</a>を、 2 の段階ロールアウトは Envoy の<a href="https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-weightedcluster">WeightedCluster</a>を使って実現しています。</p> <h1 id="まとめと振り返り">まとめと振り返り</h1> <p>本プロジェクトでは、従来の検索システムではレシピ投稿から結果への表示までに最長 24 時間かかっていたものを、5 分程度にまで短縮することに成功しました。課題の特定から解決までを 6 週間でおこなうというタイトなスケジュールではありましたが、事業要件に過不足のない開発を事故なく完遂することができたのではないかと思います。</p> <p>振り返ってみると成功の要因としては</p> <ul> <li>プロジェクト冒頭にプロダクトの実現すべき体験からブレークダウンする形で要件をしっかりと定義した <ul> <li>後の意思決定に軸が通り、手戻りも少なくなった</li> </ul> </li> <li>プロダクトからインフラサイドまで、各領域について高い専門性を持つメンバーが集まった <ul> <li>全体の要件定義やざっくりとした設計は全員で行い、そこから先の詳細開発は各メンバーが担当した</li> <li>プロジェクトのため臨時に結成されたチームだったが、期間中は週2回の check-in MTG を設定してスムーズに同期と相談をおこなえるようにした</li> </ul> </li> <li>スポットで機械学習エンジニアなど、他チームの助力も得ることができた</li> </ul> <p>ことが大きかったのではないかと思います。</p> <p>組織として達成したいミッションがあり、そのための事業・プロダクトがあり、それが実現したい体験を阻んでいる障壁があるところに技術をぶつけてそれを取り除くという仕事は、やはりとてもやりがいのあるものだと改めて実感しました。それぞれに高い専門性を持つメンバーから成るチームで仕事ができたことも含めて、個人的には入社以来もっともおもしろい仕事の一つであったように思います。</p> <h1 id="Acknowledgements">Acknowledgements</h1> <p>本プロジェクトは 4 名のメインメンバー+周辺部署のメンバーが関わり、それぞれ力を発揮したことで完遂することのできたプロジェクトです。私一人の力では到底実現できなかったであろう課題解決を共に推進してくれたことに改めて感謝します。</p> <p>最後に、メインメンバーの 4 名について、各作業をどのように担当したかを明記します。</p> <ul> <li><a href="https://twitter.com/SpicyCoffee66">@SpicyCoffee</a>(筆者) <ul> <li>検索エンジニア</li> <li>担当:プロジェクト全体の統括・最終意思決定 / indexing application の実装</li> </ul> </li> <li><a href="https://twitter.com/osyoyu">@osyoyu</a> <ul> <li>検索エンジニア</li> <li>担当:Solr Cluster と Persistent Storage 周りの設計・開発</li> </ul> </li> <li><a href="https://twitter.com/s4ichi">@s4ichi</a> <ul> <li>SRE</li> <li>担当:Solr Cluster と Persistent Storage 周りの設計・開発 / 負荷試験とロールアウト</li> </ul> </li> <li><a href="https://twitter.com/eagletmt">@eagletmt</a> <ul> <li>SRE</li> <li>担当:キャッシュの調査と最適化 / indexing application の実装</li> </ul> </li> </ul> <p>この記事が、日々技術を用いてユーザー課題を解決しているみなさまのお役に立てば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-9b11b8ee" name="f-9b11b8ee" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">今回は CEO がその役割を担っていました。社長と直接仕事をする機会が降ってきてラッキー。</span></p> <p class="footnote"><a href="#fn-f14419c3" name="f-f14419c3" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">当時の ECS &amp; 社内基盤 <a href="https://github.com/eagletmt/hako">Hako</a> という構成は運用負荷が低い上に非常に安定しており、Solr が直接の理由となって障害が起きたのは年に 1 度もないように記憶しています。</span></p> <p class="footnote"><a href="#fn-f1ecce9b" name="f-f1ecce9b" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">人手によるレシピの全件チェックは short-period indexing 以前も行われていたため、「オペレーションの見直しも含め、レシピチェック周りでもシステム変更が必要になると予想された」という表現の方が正確かもしれません。</span></p> <p class="footnote"><a href="#fn-5d18f9a0" name="f-5d18f9a0" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">leader から follower に対して変更を通知しない点は MySQL の replication との違いかもしれません。</span></p> <p class="footnote"><a href="#fn-7ea06241" name="f-7ea06241" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">現状の実装だと検索結果が数時間前の状態に一瞬だけ巻き戻ってしまったりするのですが、実装難易度を考えてこれを仕様側で許容するといった判断もおこなっています。</span></p> <p class="footnote"><a href="#fn-68d5fb5c" name="f-68d5fb5c" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">この API の開発は、投稿物のチェックを行っているチームと機械学習チームの協力によって迅速に開発されました。</span></p> <p class="footnote"><a href="#fn-441c6e5a" name="f-441c6e5a" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">クックパッドが採用している Unicorn はマルチプロセスで動いているため、prometheus_exporter の <a href="https://github.com/discourse/prometheus_exporter#multi-process-mode">multi process mode</a>を用いました。</span></p> </div> spicycoffee クックパッドのフロントエンド CSS in JS をゼロランタイムに切り替えました hatenablog://entry/820878482972440079 2023-10-03T10:52:40+09:00 2023-10-03T18:18:20+09:00 こんにちは。レシピ事業部のkaorun343です。我々のチームではレシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログにて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSをEmotionからゼロランタイムのvanilla-extractへ変更した話です。 vanilla-extract.style 背景 以前書いた レシピサービスのフロントエンドに CSS in JS を採用した話 - クックパッド開発者ブログでは、CSS in JSライ… <p>こんにちは。レシピ事業部のkaorun343です。我々のチームでは<a href="https://techlife.cookpad.com/entry/2020/12/01/093000">&#x30EC;&#x30B7;&#x30D4;&#x30B5;&#x30FC;&#x30D3;&#x30B9;&#x306E;&#x30D5;&#x30ED;&#x30F3;&#x30C8;&#x30A8;&#x30F3;&#x30C9;&#x3092; Next.js &#x3068; GraphQL &#x306E;&#x30B7;&#x30B9;&#x30C6;&#x30E0;&#x306B;&#x7F6E;&#x304D;&#x63DB;&#x3048;&#x3066;&#x3044;&#x308B;&#x8A71; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a>にて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSをEmotionからゼロランタイムのvanilla-extractへ変更した話です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fvanilla-extract.style%2F" title="vanilla-extract — Zero-runtime Stylesheets-in-TypeScript." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://vanilla-extract.style/">vanilla-extract.style</a></cite></p> <h3 id="背景">背景</h3> <p>以前書いた <a href="https://techlife.cookpad.com/entry/2021/03/15/090000">&#x30EC;&#x30B7;&#x30D4;&#x30B5;&#x30FC;&#x30D3;&#x30B9;&#x306E;&#x30D5;&#x30ED;&#x30F3;&#x30C8;&#x30A8;&#x30F3;&#x30C9;&#x306B; CSS in JS &#x3092;&#x63A1;&#x7528;&#x3057;&#x305F;&#x8A71; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a>では、CSS in JSライブラリとして Emotion(@emotion/react)を採用した経緯と開発環境整備を紹介しました。採用理由としては以下の通りでした。</p> <ul> <li>セレクタに一意なIDが割り振られるので、スタイルを適用した要素とは別の要素への、意図しないスタイル適用を防ぐことができる。</li> <li>ESLintやTypeScriptコンパイラといったJavaScriptの静的解析ツールの恩恵を受けることができ、タイポや機能削除時の削除漏れに気づきやすくなる。</li> <li>styled-componentsのようなスタイルではJSXのツリーを見たときに、機能を持つコンポーネントなのか装飾されたコンポーネントなのかわからず、コードレビューがしにくい。</li> <li>通常のCSSの記法に慣れたメンバーが多いので、String Styles、すなわちタグ付きテンプレートリテラルを採用する。</li> </ul> <p>このような方針でEmotionの導入を決め、stylelintやeslintを導入し、必要に応じてカスタムルールを作成して機能開発を進めました。</p> <p>しかしながら、Emotionを導入してから2年ほど経った結果、以下のような課題や懸念を抱えるようになりました。</p> <ol> <li><strong>ページサイズ</strong>:SSR時には初期表示用のCSSをEmotionが作るわけですが、このCSSは .css ファイルとしてブラウザに届くのではなく Next.jsから配信されるHTMLに埋め込まれた状態でブラウザに届きます。そのため、ロードバランサーを通過するHTMLのサイズが増加してしまいます。CSSのデータがCDNを通らないため、パフォーマンスの面でもコストの面で問題です。実際、background-imageにbase64の画像URLを埋め込んだときには、その影響が強く出てしまいました。</li> <li><strong>動的生成による肥大化</strong>:Next.jsのSSR時にうっかりCSSのバリエーションを増やしてしまうと、Next.jsプロセスのメモリ使用量が増大し、アプリケーションが落ちてしまいます。これは、Emotionがインメモリのキャッシュ機構を備えており、一度生成したCSSデータを保持し続けるためです。過去の事例では、レシピごとに異なるbackground-imageを設定するCSSをEmotionで書いたときにこの問題が生じました<a href="#f-77e91b7c" name="fn-77e91b7c" title="このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです">*1</a>。</li> <li><strong>クライアント側のオーバーヘッド</strong>:CSS生成のためにブラウザ上でJavaScriptが実行されるため、ページのパフォーマンスへ影響しうる懸念があります。EmotionはCSSの記述内容の解析、古いブラウザ向けの記述の追加、CSSの合成、そしてスタイルのDOMへの挿入をブラウザ上で実行します。Emotionの<code>css</code>関数を使えば使うほどCSSに関する処理の実行時間が増えていきます。また、これらの処理をブラウザ上で実行するためのJSのコードが必要となるため、バンドルサイズが増加してしまいます。新システムがホストしているページはスマートフォン向けのページであり、パフォーマンスやバンドルサイズは特に注視しています。</li> </ol> <p>そこで、Emotionから別の CSS 環境への移行を検討しました。</p> <h3 id="技術選定">技術選定</h3> <p>上記の課題を踏まえ、以下の要件で新しいCSS 環境を検討しました。</p> <ul> <li><strong>Emotionに近い開発体験</strong>:Emotionと同様に、CSS クラス名を自分でつける必要がないこと</li> <li><strong>CDNの活用</strong>:ビルド時に CSS ファイルが生成されて CDN から静的に配信できること</li> <li><strong>低いオーバーヘッド</strong>:ゼロランタイムであること(ビルド時にCSSを生成し、ブラウザに送られるJavaScriptにはCSSを生成するコードを含まないこと)</li> <li><strong>将来性</strong>:Server Components導入を見据えて、Server Componentsに対応していると嬉しい</li> </ul> <p>検討した結果、これらの要件を満たすライブラリとしてvanilla-extractが挙がりました。 vanilla-extractではCSSをJavaScriptのオブジェクトとして .css.[jt]s という拡張子のファイルに記述します。これをvanilla-extractの各種バンドラに対応したプラグインがCSSに変換し、CSSファイルを生成します。また、それぞれのスタイルは一意なクラス名をセレクタとしており、Emotionと同じように意図しないスタイル適用を防ぐことができます。</p> <p>Emotionとvanilla-extractの比較を表にすると以下のようになり、技術選定で重視した項目を満たしています。</p> <table> <thead> <tr> <th> 比較項目 </th> <th> Emotion </th> <th> vanilla-extract </th> </tr> </thead> <tbody> <tr> <td> クラス名の自動付与 </td> <td> ✓ </td> <td> ✓ </td> </tr> <tr> <td> CDNから配布可能 </td> <td> (HTMLに埋め込まれる) </td> <td> ✓ </td> </tr> <tr> <td> ゼロランタイム </td> <td> (ブラウザ) </td> <td> ✓ </td> </tr> <tr> <td> Server Components対応 </td> <td> (CSRのみ) </td> <td> ✓ </td> </tr> <tr> <td> コンポーネントと同じファイルに書ける </td> <td> ✓ </td> <td> (.css.[jt]sに書く必要がある)</td> </tr> <tr> <td> CSSの書き方 </td> <td> String Styles、Object Styles </td> <td> Object Stylesのみ </td> </tr> <tr> <td> Stylelint </td> <td> ✓ </td> <td> (未対応)</td> </tr> <tr> <td> スナップショットテスト </td> <td> ✓ </td> <td> (なし) </td> </tr> <tr> <td> ベンダープレフィクスの自動付与 </td> <td> ✓ </td> <td>(なし)</td> </tr> </tbody> </table> <p>(比較当時、@emotion/reactはv11.10.0、@vanilla-extract/cssはv1.9.1でした)</p> <p>筆者がvanilla-extractを提案した際は、記述方法の違いやビルド成果物の差がわかるように、実際のページを書き換えたプルリクエストを例示しました。CSSのコード量が小さい<a href="https://techlife.cookpad.com/entry/dynamic-og-image">OGP画像生成用のページ</a>を対象にしました。</p> <h4 id="vanilla-extractのデメリット">vanilla-extractのデメリット</h4> <p>一方で、vanilla-extractにはデメリットも存在します。</p> <h5 id="CSSの書き方">CSSの書き方</h5> <p>まず、これまで通りString Stylesで記述することができなくなりました。この点について懸念点がないかデザイナーの方に伺ったところ、「CSSを書ければ問題ない」とのことでした。vanilla-extractは .css.[jt]s に記述する必要がありますが、この点についても、チームメンバーから合意をもらいました。</p> <h5 id="Stylelint">Stylelint</h5> <p>加えて記法が変わったことによりStylelintで検査できなくなりました。しかしながら、CSSのプロパティや値のタイポ・プロパティの重複はTypeScriptで見つけられますし、我々のアプリケーションでは詳細度に関連して困るような書き方をしていないので、Stylelintを廃止するデメリットは小さいと判断しました。</p> <h5 id="スナップショットテスト">スナップショットテスト</h5> <p>Emotionでは@emotion/jestがスナップショットテストにCSSの記述を表示する仕組みを提供していました。しかしvanilla-extractでは提供されておらず、スナップショットテストでCSSの記述を確認することもできなくなりました。スナップショットテストについては、運良くバグを検知できるほどのメリットしかないと判断し、使えなくなるデメリットは小さいと判断しました。</p> <h5 id="ベンダープレフィクスの自動付与">ベンダープレフィクスの自動付与</h5> <p>Emotionはベンダープレフィクスを自動で付与してくれるのですが、Emotionではライブラリ利用者がブラウザのバージョンを指定できないため、クックパッドの推奨環境より古いブラウザを対象としたプロパティも追加されていました。クックパッドの推奨環境も鑑み、自動付与がなくなるデメリットは小さいと判断しました。</p> <h3 id="vanilla-extractへの移行">vanilla-extractへの移行</h3> <p>CSSの記述をvanilla-extractへ移行することを決定した後、移行作業にとりかかりました。</p> <p>最初はすべて手作業で書き換えていたのですが、途中から正規表現を使った簡素な変換ツールを導入して移行作業がスピードアップしました。 Emotionとvanilla-extractは共存できたため、手が空いているときに手分けをして少しずつ移行していきました。また、Next.jsアプリケーション本体だけではなく、共通コンポーネントパッケージ、そして社内のデザインシステムのReactライブラリもvanilla-extractに移行しました。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// Emotion</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> css <span class="synIdentifier">}</span> from <span class="synConstant">'@emotion/react'</span> <span class="synStatement">const</span> linkStyle = css<span class="synConstant">`</span> <span class="synConstant"> flex: 1;</span> <span class="synConstant"> box-sizing: border-box;</span> <span class="synConstant"> background-color: white;</span> <span class="synConstant">`</span> <span class="synStatement">const</span> linkDisableStyle = css<span class="synConstant">`</span> <span class="synConstant"> </span><span class="synSpecial">${linkStyle}</span> <span class="synConstant"> background-color: gray;</span> <span class="synConstant">`</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span>&gt; Link &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkDisableStyle<span class="synIdentifier">}</span>&gt; Disabled Link &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// vanilla-extract</span> <span class="synComment">// MyComponent.css.js</span> <span class="synStatement">export</span> <span class="synStatement">const</span> linkStyle = style(<span class="synIdentifier">{</span> flex: 1, boxSizing: <span class="synConstant">'border-box'</span>, backgroundColor: <span class="synConstant">'white'</span>, <span class="synIdentifier">}</span>) <span class="synStatement">export</span> <span class="synStatement">const</span> linkDisabledStyle = style(<span class="synIdentifier">[</span> linkStyle, <span class="synIdentifier">{</span> backgroundColor: <span class="synConstant">'gray'</span>, <span class="synIdentifier">}</span>, <span class="synIdentifier">]</span>) <span class="synComment">// MyComponent.js</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> linkDisabledStyle, linkStyle <span class="synIdentifier">}</span> from <span class="synConstant">'./MyComponent.css.js'</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span>&gt; Link &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkDisabledStyle<span class="synIdentifier">}</span>&gt; Disabled Link &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <p>動的にスタイルを生成していた箇所については、 @vanilla-extract/dynamic や @vanilla-extract/recipesを利用して問題なく置き換えられました。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// Emotion</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> css <span class="synIdentifier">}</span> from <span class="synConstant">'@emotion/react'</span> <span class="synStatement">const</span> linkStyle = (size) =&gt; css<span class="synConstant">`</span> <span class="synConstant"> width: </span><span class="synSpecial">${size}</span><span class="synConstant">;</span> <span class="synConstant"> height: </span><span class="synSpecial">${size}</span><span class="synConstant">;</span> <span class="synConstant">`</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkStyle(<span class="synConstant">'100px'</span>)<span class="synIdentifier">}</span>&gt; Link 1 &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkStyle(<span class="synConstant">'200px'</span>)<span class="synIdentifier">}</span>&gt; Link 2 &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// vanilla-extract</span> <span class="synComment">// MyComponent.css.js</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> createVar, style <span class="synIdentifier">}</span> from <span class="synConstant">'@vanilla-extract/css'</span> <span class="synStatement">export</span> <span class="synStatement">const</span> sizeVar = createVar() <span class="synStatement">export</span> <span class="synStatement">const</span> linkStyle = style(<span class="synIdentifier">{</span> width: sizeVar, height: sizeVar, <span class="synIdentifier">}</span>) <span class="synComment">// MyComponent.js</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> assignInlineVars <span class="synIdentifier">}</span> from <span class="synConstant">'@vanilla-extract/dynamic'</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> sizeVar, linkStyle <span class="synIdentifier">}</span> from <span class="synConstant">'./MyComponent.css.js'</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span> style=<span class="synIdentifier">{</span>assignInlineVars(<span class="synIdentifier">{</span> <span class="synIdentifier">[</span>sizeVar<span class="synIdentifier">]</span>: <span class="synConstant">'100px'</span> <span class="synIdentifier">}</span>)<span class="synIdentifier">}</span> &gt; Link 1 &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span> style=<span class="synIdentifier">{</span>assignInlineVars(<span class="synIdentifier">{</span> <span class="synIdentifier">[</span>sizeVar<span class="synIdentifier">]</span>: <span class="synConstant">'200px'</span> <span class="synIdentifier">}</span>)<span class="synIdentifier">}</span> &gt; Link 2 &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <p>移行した結果、Emotionで課題や懸念に感じていたことを解消できました。</p> <ol> <li><strong>ページサイズ</strong>:CSSファイルにページ全体のCSSが含まれるようになり、CDNから配布できるようになりました。background-imageとしてbase64の画像ファイルを埋め込んだ場合でもロードバランサーを通るHTMLのサイズが大きくなることはありません。</li> <li><strong>動的生成による肥大化</strong>:メモリ使用量が増加してNext.jsプロセスが落ちることはなくなりました。</li> <li><strong>クライアント側のオーバーヘッド</strong>:CSSの生成はビルド時にのみおこなわれるようになり、生成のためのJavaScriptは@vanilla-extract/dynamicや@vanilla-extract/recipesだけになりました。また、Emotionのランタイム削除によりバンドルサイズはgzipで10kB弱減少しました。</li> </ol> <p>エンジニアやデザイナーからも、特にネガティブな意見は出ていません。初めてvanilla-extractを触るメンバーも、問題なくCSSを変更できています。</p> <h3 id="さいごに">さいごに</h3> <p>今回はレシピサービスの新システムにおける ゼロランタイムCSS in JS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。</p> <div class="footnote"> <p class="footnote"><a href="#fn-77e91b7c" name="f-77e91b7c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです</span></p> </div> kaorun343 iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作る hatenablog://entry/820878482967113840 2023-09-14T16:00:00+09:00 2023-09-20T23:24:19+09:00 こんにちは、クックパッドマートプロダクト開発部の佐藤(@n_atmark)です。 普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。 動作している物を見ていただくのが分かりやすいと思うので、早速ですが動作イメージがこちらになります。 フレームインスペクタの動作の様子 (gif) アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマ… <p>こんにちは、クックパッドマートプロダクト開発部の佐藤(<a href="https://twitter.com/n_atmark">@n_atmark</a>)です。</p> <p>普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。</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/n/n_atmark/20230914/20230914162704.gif" alt="&#x30D5;&#x30EC;&#x30FC;&#x30E0;&#x30A4;&#x30F3;&#x30B9;&#x30DA;&#x30AF;&#x30BF;&#x306E;&#x52D5;&#x4F5C;&#x306E;&#x69D8;&#x5B50;" width="577" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span><figcaption>フレームインスペクタの動作の様子 (<a href="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913154929.gif">gif</a>)</figcaption></figure></p> <p>アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマージンを表示するようにしています。</p> <h2 id="開発の背景">開発の背景</h2> <p>私が普段開発に従事しているクックパッドマートiOSアプリでは元々5の倍数マージンを採用していたのですが、これを4の倍数マージンに変えたいという背景がありました。</p> <p>後発のクックパッドマートAndroidアプリで4の倍数マージンを採用しており、デザイナーが画面デザインを作成するために5の倍数マージン / 4の倍数マージンを切り替えてデザインを作らないといけないという課題があり、どちらかに統一したいという要望がありました。クックパッド社内の他のiOSアプリでも4の倍数マージンを採用していることもあり、クックパッドマートiOSアプリも4の倍数マージンに合わせることになりました。</p> <p>しかし、マージン値を機械的に置き換えるのは難しく、現在は気づいた箇所から徐々に置き換えていく方針で進めています。 クックパッドマートiOSアプリにはUIKit (AutoLayout) で作られた画面もあればSwiftUIで作られた画面もあり、マージンを直接設定している箇所や変数に置いている箇所、アニメーションのためにマージン値を切り替えている箇所などがあるため、統一した方法で置き換えができないためです。また、10ptの箇所を8ptに置き換えるべきか12ptに置き換えるべきかといった問題もあります。</p> <p>そこで、マージンの違いに気づきやすくするという目的で今回のツールを開発しました。 QA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みとして用意しています。</p> <h2 id="実装の紹介">実装の紹介</h2> <p>実装の全体は <a href="https://gist.github.com/natmark/ef27845aff19059e74916df421223b79">https://gist.github.com/natmark/ef27845aff19059e74916df421223b79</a> に置いてあります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">DebugFrameInspectorView</span><span class="synSpecial">:</span> <span class="synType">UIView</span> { <span class="synIdentifier">init</span>() { <span class="synIdentifier">super</span>.<span class="synIdentifier">init</span>(frame<span class="synSpecial">:</span> .zero) backgroundColor <span class="synIdentifier">=</span> .clear isUserInteractionEnabled <span class="synIdentifier">=</span> <span class="synConstant">false</span> } <span class="synComment">// … 略</span> } </pre> <p><code>DebugFrameInspectorView</code> がマージンやフレームサイズを表示しているViewです。 <code>backgroundColor = .clear</code> かつ <code>isUserInteractionEnabled = false</code> なViewとなっていて、これを <code>keyWindow</code> に対して <code>addSubView(_:)</code> して利用してもらう想定です。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">setup</span>() { <span class="synPreProc">let</span> <span class="synIdentifier">singleLongPressGestureRecognizer</span> <span class="synIdentifier">=</span> UILongPressGestureRecognizer(target<span class="synSpecial">:</span> <span class="synType">self</span>, action<span class="synSpecial">:</span> #selector(didSingleLongPress(_<span class="synSpecial">:</span>))) singleLongPressGestureRecognizer.minimumPressDuration <span class="synIdentifier">=</span> <span class="synConstant">0.2</span> singleLongPressGestureRecognizer.numberOfTouchesRequired <span class="synIdentifier">=</span> <span class="synConstant">1</span> window?.addGestureRecognizer(singleLongPressGestureRecognizer) <span class="synPreProc">let</span> <span class="synIdentifier">doubleLongPressGestureRecognizer</span> <span class="synIdentifier">=</span> UILongPressGestureRecognizer(target<span class="synSpecial">:</span> <span class="synType">self</span>, action<span class="synSpecial">:</span> #selector(didDoubleLongPress(_<span class="synSpecial">:</span>))) doubleLongPressGestureRecognizer.minimumPressDuration <span class="synIdentifier">=</span> <span class="synConstant">0.2</span> doubleLongPressGestureRecognizer.numberOfTouchesRequired <span class="synIdentifier">=</span> <span class="synConstant">2</span> window?.addGestureRecognizer(doubleLongPressGestureRecognizer) } </pre> <p><code>setup()</code> メソッドの中で <code>UILongPressGestureRecognizer</code> を <code>UIWindow</code> に追加して長押しのジェスチャーを補足できるようにしています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synType">@objc</span> <span class="synType">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">didSingleLongPress</span>(_ sender<span class="synSpecial">:</span> <span class="synType">UILongPressGestureRecognizer</span>) { <span class="synStatement">if</span> sender.state <span class="synIdentifier">==</span> .began { <span class="synPreProc">let</span> <span class="synIdentifier">positionInWindow</span> <span class="synIdentifier">=</span> sender.location(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">window</span>) <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">hitView</span> <span class="synIdentifier">=</span> window?.hitTest(positionInWindow, with<span class="synSpecial">:</span> <span class="synType">nil</span>) { <span class="synPreProc">let</span> <span class="synIdentifier">positionInHitView</span> <span class="synIdentifier">=</span> sender.location(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">hitView</span>) <span class="synPreProc">let</span> <span class="synIdentifier">globalRect</span> <span class="synIdentifier">=</span> CGRect( x<span class="synSpecial">:</span> <span class="synType">positionInWindow.x</span> <span class="synIdentifier">-</span> positionInHitView.x, y<span class="synSpecial">:</span> <span class="synType">positionInWindow.y</span> <span class="synIdentifier">-</span> positionInHitView.y, width<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.width</span>, height<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.height</span> ) singlePressValue <span class="synIdentifier">=</span> SinglePressValue( viewWireframe<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( rect<span class="synSpecial">:</span> <span class="synType">globalRect</span>, cornerRadius<span class="synSpecial">:</span> <span class="synType">hitView.layer.cornerRadius</span>, maskedCorners<span class="synSpecial">:</span> <span class="synType">hitView.layer.maskedCorners</span> ) ) setNeedsDisplay() } } <span class="synStatement">else</span> <span class="synStatement">if</span> sender.state <span class="synIdentifier">==</span> .ended { singlePressValue <span class="synIdentifier">=</span> <span class="synConstant">nil</span> setNeedsDisplay() } } <span class="synType">@objc</span> <span class="synType">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">didDoubleLongPress</span>(_ sender<span class="synSpecial">:</span> <span class="synType">UILongPressGestureRecognizer</span>) { <span class="synComment">// 略</span> } </pre> <p>長押し時の処理がこちらになります。1本指で長押しするか2本指で長押しするかによって <code>didSingleLongPress(_:)</code> <code>didDoubleLongPress(_:)</code> と実装を分けていますが、内容としてはほぼ同じ処理になります。</p> <p><code>let positionInWindow = sender.location(in: window)</code> で <code>keyWindow</code> 内におけるタッチ位置を取得した後 <code>hitTest(_:with:)</code> を用いてタップされたViewを特定しています。 (<code>let hitView = window?.hitTest(positionInWindow, with: nil)</code> の箇所)</p> <p>その後タップされたViewのframe (superviewを基準にした相対位置) を <code>keyWindow</code> 内における座標に変換したいので、<code>hitView</code> 内でのタッチ位置の座標を取得し ( <code>let positionInHitView = sender.location(in: hitView)</code> の箇所) 、 <code>positionInWindow</code> から <code>positionInHitView</code> の座標分ずらすことで <code>keyWindow</code> 内における座標を取得しています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">let</span> <span class="synIdentifier">globalRect</span> <span class="synIdentifier">=</span> CGRect( x<span class="synSpecial">:</span> <span class="synType">positionInWindow.x</span> <span class="synIdentifier">-</span> positionInHitView.x, y<span class="synSpecial">:</span> <span class="synType">positionInWindow.y</span> <span class="synIdentifier">-</span> positionInHitView.y, width<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.width</span>, height<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.height</span> ) </pre> <p><code>singlePressValue</code> 、<code>doublePressValue</code> という変数に値を保持しておいて <code>setNeedsDisplay()</code> を呼び出すことで <code>draw(_:)</code> メソッドを呼び出し、フレーム境界やマージンなどの線の描画を行っています。</p> <p>線の描画に関しては詳しく触れませんが、CoreGraphicsを用いてゴリゴリ記述しています。</p> <h2 id="利用方法">利用方法</h2> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">/// SceneDelegate.swift</span> <span class="synPreProc">func</span> <span class="synIdentifier">scene</span>(_ scene<span class="synSpecial">:</span> <span class="synType">UIScene</span>, willConnectTo session<span class="synSpecial">:</span> <span class="synType">UISceneSession</span>, options connectionOptions<span class="synSpecial">:</span> <span class="synType">UIScene.ConnectionOptions</span>) { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">windowScene</span> <span class="synIdentifier">=</span> (scene <span class="synStatement">as?</span> <span class="synType">UIWindowScene</span>) <span class="synStatement">else</span> { <span class="synStatement">return</span> } <span class="synPreProc">let</span> <span class="synIdentifier">window</span><span class="synSpecial">:</span> <span class="synType">UIWindow</span> <span class="synComment">// 以下はアプリの構成によって変わるので必要箇所だけ入れてください</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">keyWindow</span> <span class="synIdentifier">=</span> windowScene.keyWindow { <span class="synComment">// keyWindowがある場合 (SwiftUI.App利用時)</span> window <span class="synIdentifier">=</span> keyWindow } <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">firstWindow</span> <span class="synIdentifier">=</span> windowScene.windows.first { <span class="synComment">// keyWindowはないが、windowが存在する場合 (Main Storyboard利用時)</span> window <span class="synIdentifier">=</span> firstWindow window.makeKeyAndVisible() } <span class="synStatement">else</span> { <span class="synComment">// window自体存在しない場合 (Storyboard不使用時)</span> window <span class="synIdentifier">=</span> UIWindow(windowScene<span class="synSpecial">:</span> <span class="synType">windowScene</span>) window.makeKeyAndVisible() window.rootViewController <span class="synIdentifier">=</span> MyRootViewController() <span class="synIdentifier">self</span>.window <span class="synIdentifier">=</span> window } <span class="synPreProc"> #if</span> DEBUG <span class="synPreProc">let</span> <span class="synIdentifier">debugFrameInspectorView</span> <span class="synIdentifier">=</span> DebugFrameInspectorView() window.addSubview(debugFrameInspectorView) debugFrameInspectorView.translatesAutoresizingMaskIntoConstraints <span class="synIdentifier">=</span> <span class="synConstant">false</span> NSLayoutConstraint.activate([ window.leadingAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.leadingAnchor</span>), window.trailingAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.trailingAnchor</span>), window.topAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.topAnchor</span>), window.bottomAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.bottomAnchor</span>), ]) debugFrameInspectorView.setup() <span class="synPreProc"> #endif</span> } </pre> <p><code>keyWindow</code> となるUIWindowに対して <code>addSubView(_:)</code> した後、 <code>setup()</code> を呼び出すことで利用できます。</p> <p>デバッグ用の機能なのでCompilation conditionsを使ってbuild configurationが <code>DEBUG</code> の時のみ有効にするなどしておくことをオススメします。</p> <h2 id="実際に開発したツールを使ってもらって">実際に開発したツールを使ってもらって</h2> <p>開発の背景の箇所にもある通り、今回のツールの目的としてはQA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みを用意することでした。</p> <p>チームメンバーに実際に使ってもらうと、以下のようなコメントをもらいました。</p> <h4 id="ポジティブな意見">ポジティブな意見</h4> <ul> <li>(エンジニア) 実際のアプリのマージンを実装を見に行かなくても確かめられるのは嬉しい</li> <li>(デザイナー) Webだとインスペクタですぐ要素を確認できるのにネイティブアプリだと見ることができないので、アプリでもフレーム値を確認できるのがありがたい</li> </ul> <h4 id="追加要望">追加要望</h4> <ul> <li>(デザイナー) 実装されているフォントサイズや色も確認できると嬉しい</li> </ul> <h4 id="ネガティブな意見">ネガティブな意見</h4> <ul> <li>(エンジニア) ボタンの領域を長押しした時に、ボタン内の要素のフレームサイズが見れない</li> <li>(エンジニア) フレームサイズが実装を行った際に意図したサイズと違って表示されることがある</li> </ul> <p>実装されたアプリ上でマージン値をサクッと確かめられることに対して一定効果がありそうなことが分かりました。 また、フォントサイズや色も確認できるとアプリに実装されたデザインが正しいかどうかを確認する用途で、より便利に使えそうです。</p> <p>一方で、前後に重なった要素に対してはフレームサイズの確認がうまくできないという課題や、実際の実装値と表示される値が違うことがあるという課題も浮き彫りになりました。</p> <h2 id="実装が難しい部分の紹介">実装が難しい部分の紹介</h2> <p>ここまで開発したフレームインスペクタの紹介をしましたが、できないことも結構あります。 先ほどチームメンバーから要望のあった「実装されているフォントサイズや色」といった要素や、「実装値と表示値が違う」という課題の解決も現状難しいと感じている点です。</p> <p>その難しさの元になるのがSwiftUIで実装された画面です。</p> <p>UIKitとSwiftUIで以下のような画面を実装したとします。</p> <h3 id="UIKit">UIKit</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913162229.png" width="1200" height="1016" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="SwiftUI">SwiftUI</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913162238.png" width="1200" height="868" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><code>UIWindow</code> の <code>subViews</code> を辿って階層構造を表示するとそれぞれ下のようになります。</p> <table> <tr> <th>UIKit</th> <th>SwiftUI</th> </tr> <tr> <td> UIWindow<br> └ UITransitionView<br> &nbsp;&nbsp;└ UIDropShadowView<br> &nbsp;&nbsp;&nbsp;&nbsp;└ UIView<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;└ UIStackView<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;├ UIStackView<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;│ ├ UIImageView (<font color="red">♡</font>)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;│ └ UILabel (<b>タイトル</b>)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;└ UILabel (テキストテキストテキスト)<br> </td> <td> UIWindow<br> └ UITransitionView<br> &nbsp;&nbsp;└ UIDropShadowView<br> &nbsp;&nbsp;&nbsp;&nbsp;└ _UIHostingView&lt;ModifiedContent&lt;AnyView, RootModifier&gt;&gt;<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (<font color="red">♡</font>)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ CGDrawingView (<b>タイトル</b>)<br> &nbsp;&nbsp;&nbsp;&nbsp;└ CGDrawingView (テキストテキストテキスト)<br> </td> </tr> </table> <p>UIKitの場合は <code>UIImageView</code> や <code>UILabel</code> といったお馴染みのViewクラスなので、 <code>UIView</code> 型の <code>subview</code> をキャストすれば簡単にプロパティを確認できますが、SwiftUIの場合はSwiftUIのView構造ではなく、描画用のクラスである <code>SwiftUI._UIGraphicsView</code> や <code>SwiftUI.DisplayList.ViewUpdater.Platform.CGDrawingView</code> といったprivateなクラスが利用されます。</p> <p>これが一つ目の難しいポイントで、SwiftUIでViewを組み立てた時にどういったModifierが適用されたかといった情報を持たないため、これらからフォントサイズや設定された色を取り出すことは困難です。</p> <p>また、UIKitではViewの階層構造が保持されるのに対して、SwiftUIでは <code>_UIHostingView</code> というクラスの配下にフラットに展開されてしまいます。 SwiftUIのViewを組み立てる時に利用した <code>VStack</code> や <code>HStack</code> は、フレームの決定だけに用いられUIViewの世界においては現れません。 これが二つ目の難しいポイントです。</p> <p>「<code>VStack</code> や <code>HStack</code> は、フレームの決定だけに用いられUIViewの世界においては現れない」というのがどう難しさにつながっているのか説明するために以下の図を用意してみました。</p> <table> <thead> <tr> <th style="text-align:center;">UIKit</th> <th style="text-align:center;">SwiftUI</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/n/n_atmark/20230913/20230913163324.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" 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/n/n_atmark/20230913/20230913163305.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span></td> </tr> </tbody> </table> <p>これは <code>UIWindow</code> の <code>subviews</code> を辿ってそれぞれの <code>CALayer</code> に対して枠線を表示したものです。</p> <p>SwiftUI側の実装で、「<b>タイトル</b>」および「テキストテキストテキスト」には <code>.frame(maxWidth: .infinity, alignment: .leading)</code> をつけているものの <code>CGDrawingView</code> が表現するフレームはテキストが文字幅に縮んでしまっていることがわかると思います。</p> <p>元々VStackに設定していた <code>.padding(.horizontal, 20)</code> のうち、<code>trailing</code> 側に関しては正しく効いているかどうか、今回のフレームインスペクタでは上手く確認することができません。</p> <h3 id="補足">補足</h3> <p>ちなみに <code>VStack</code> および <code>HStack</code> に <code>.background(Color.white)</code> を追加するとVStack/HStackの領域が描画され、下のように画面幅にフレームが広がっているのを確認できます。</p> <table> <tr> <th>View構造</th> <th>フレームレイアウト</th> </tr> <tr> <td> UIWindow<br> └ UITransitionView<br> &nbsp;&nbsp;└ UIDropShadowView<br> &nbsp;&nbsp;&nbsp;&nbsp;└ _UIHostingView&lt;ModifiedContent&lt;AnyView, RootModifier&gt;&gt;<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (HStackのbackground)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (VStackのbackground)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (<font color="red">♡</font>)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ CGDrawingView (<b>タイトル</b>)<br> &nbsp;&nbsp;&nbsp;&nbsp;└ CGDrawingView (テキストテキストテキスト)<br> </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913163313.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span> </td> </tr> </table> <h2 id="今後の展望">今後の展望</h2> <p>今回作ったフレームインスペクタではSwiftUIで作った画面表示にまだ課題があることを紹介しました。 ところでXcodeには <code>Debug View Hierarchy</code> というデバッグ機能があることはよく知られていると思います。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913163813.png" width="1200" height="651" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>この <code>Debug View Hierarchy</code> を用いるとSwiftUIで開発された画面に関しても <code>Horizontal Stack</code> や <code>Text</code> といったSwiftUIで組み立てたViewの構造や、テキストのフォントといったModifierも確認することができます。</p> <p>どうにか <code>Debug View Hierarchy</code> で表示しているような情報を取得できると、今回開発したフレームインスペクタに機能追加することができそうです。</p> <h3 id="_UIHostingView_viewDebugData"><code>_UIHostingView._viewDebugData()</code></h3> <p><code>SwiftUI._UIHostingView</code> に非公開APIとして <code>_viewDebugData()</code> というメソッドが存在します。これを用いるとデバッグ用にSwiftUIのView構造を解析できそうです。 (<a href="https://apurin.me/articles/swiftui-secrets/">https://apurin.me/articles/swiftui-secrets/</a> を参考にさせていただきました)</p> <p>SwiftUIのView構造をもう一度示すのですが</p> <pre></code>UIWindow └ UITransitionView └ UIDropShadowView └ _UIHostingView&lt;ModifiedContent&lt;AnyView, RootModifier&gt;&gt; ├ _UIGraphicsView (<font color="red">♡</font>) ├ CGDrawingView (<b>タイトル</b>) └ CGDrawingView (テキストテキストテキスト) </code></pre> <p>階層を辿ることで <code>_UIHostingView</code> を取得できそうです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">ViewDebugData</span> { <span class="synPreProc">let</span> <span class="synIdentifier">data</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">_ViewDebug.Property</span><span class="synSpecial">:</span><span class="synType"> Any</span><span class="synSpecial">]</span> <span class="synPreProc">let</span> <span class="synIdentifier">childData</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span> } <span class="synPreProc">protocol</span> <span class="synIdentifier">DebuggableSwiftUIView</span> { <span class="synPreProc">func</span> <span class="synIdentifier">viewDebugData</span>() <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span> } <span class="synPreProc">extension</span> <span class="synIdentifier">_UIHostingView</span><span class="synSpecial">:</span> <span class="synType">DebuggableSwiftUIView</span> { <span class="synPreProc">func</span> <span class="synIdentifier">viewDebugData</span>() <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span> { <span class="synPreProc">let</span> <span class="synIdentifier">_viewDebugData</span> <span class="synIdentifier">=</span> _viewDebugData() <span class="synStatement">return</span> unsafeBitCast(_viewDebugData, to<span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span>.<span class="synIdentifier">self</span>) } } </pre> <p>DebuggableSwiftUIView というプロトコルを用意して <code>_UIHostingView</code> に準拠させています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">digDebuggableSwiftUIView</span>(from view<span class="synSpecial">:</span> <span class="synType">UIView</span>) <span class="synSpecial">-&gt; (</span><span class="synType">any DebuggableSwiftUIView</span><span class="synSpecial">)</span>? { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">debuggableView</span> <span class="synIdentifier">=</span> view <span class="synStatement">as?</span> <span class="synType">DebuggableSwiftUIView</span> { <span class="synStatement">return</span> debuggableView } <span class="synStatement">else</span> { <span class="synStatement">for</span> subView <span class="synStatement">in</span> view.subviews { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">debuggableView</span> <span class="synIdentifier">=</span> debuggableView(from<span class="synSpecial">:</span> <span class="synType">subView</span>) { <span class="synStatement">return</span> debuggableView } } <span class="synStatement">return</span> <span class="synConstant">nil</span> } } </pre> <p>DebuggableSwiftUIViewに準拠したViewを探索するメソッドを用意して</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">window</span> <span class="synStatement">else</span> { <span class="synStatement">return</span> } <span class="synPreProc">let</span> <span class="synIdentifier">viewDebugData</span> <span class="synIdentifier">=</span> digDebuggableSwiftUIView(from<span class="synSpecial">:</span> <span class="synType">window</span>)?.viewDebugData() print(viewDebugData) </pre> <p><code>viewDebugData()</code> を呼び出すことで、<code>_UIHostingView._viewDebugData()</code> の結果を確認できそうです。</p> <p><code>_viewDebugData()</code> の出力はかなり大きいので出力の全体は <a href="https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118">https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118</a> に置いておきます。</p> <p><code>SwiftUI._ViewDebug.Property.type</code> の部分だけ階層表示すると</p> <pre><code><font size="-3">_SafeAreaInsetsModifier └ ModifiedContent<ModifiedContent<ModifiedContent<AnyView, RootModifier>, EditModeScopeModifier>, HitTestBindingModifier> └ HitTestBindingModifier └ ModifiedContent<ModifiedContent<AnyView, RootModifier>, EditModeScopeModifier> └ EditModeScopeModifier └ ModifiedContent<_ViewModifier_Content<EditModeScopeModifier>, TransformModifier> └ TransformModifier └ _ViewModifier_Content<EditModeScopeModifier> └ ModifiedContent<AnyView, RootModifier> └ RootModifier └ ModifiedContent<ModifiedContent<_ViewModifier_Content<RootModifier>, RootEnvironmentModifier>, PresentedSceneValueInputModifier> └ PresentedSceneValueInputModifier └ ModifiedContent<_ViewModifier_Content<RootModifier>, RootEnvironmentModifier> └ RootEnvironmentModifier └ _ViewModifier_Content<RootModifier> └ AnyView └ ContentView └ ModifiedContent<VStack<TupleView<(HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>>, ModifiedContent<Text, _FlexFrameLayout>)>>, _PaddingLayout> └ _PaddingLayout └ VStack<TupleView<(HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>>, ModifiedContent<Text, _FlexFrameLayout>)>> └ Tree<_VStackLayout, TupleView<(HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>>, ModifiedContent<Text, _FlexFrameLayout>)>> ├ _FlexFrameLayout ├ └ Text ├ └ AccessibilityStyledTextContentView ├ └ ModifiedContent<ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier>, AccessibilityLargeContentViewModifier<Text>> ├ └ AccessibilityLargeContentViewModifier<Text> ├ └ ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier> ├ └ AccessibilityAttachmentModifier ├ └ StyledTextContentView └ HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>> └ Tree<_HStackLayout, TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>> ├ _FlexFrameLayout ├ └ Text ├ └ AccessibilityStyledTextContentView ├ └ ModifiedContent<ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier>, AccessibilityLargeContentViewModifier<Text>> ├ └ AccessibilityLargeContentViewModifier<Text> ├ └ ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier> ├ └ AccessibilityAttachmentModifier ├ └ StyledTextContentView └ _FrameLayout └ Image └ ModifiedContent<Resolved, AccessibilityAttachmentModifier> └ AccessibilityAttachmentModifier └ Resolved </font></code></pre> <p>のようになっており、Viewの階層構造が取れそうです。 また、<code>SwiftUI._ViewDebug.Property.type: SwiftUI.Text</code> の箇所だけピックアップしてみると</p> <pre class="code" data-lang="" data-unlink>SampleSwiftUIView.ViewDebugData( data: [ SwiftUI._ViewDebug.Property.value: SwiftUI.Text( storage: SwiftUI.Text.Storage.anyTextStorage(&lt;LocalizedTextStorage: 0x00006000017f54a0&gt;: \&#34;テキストテキストテキスト\&#34;), modifiers: [SwiftUI.Text.Modifier.font(Optional(SwiftUI.Font(provider: SwiftUI.(unknown context at $105d9f910).FontBox&lt;SwiftUI.Font.(unknown context at $105e5aae8).SystemProvider&gt;)))] ), SwiftUI._ViewDebug.Property.type: SwiftUI.Text, SwiftUI._ViewDebug.Property.size: (191.0, 20.333333333333332), SwiftUI._ViewDebug.Property.position: (20.0, 442.5) ], childData: [] // 略 )</pre> <p>Modifierに関しても確認することができました。</p> <p><code>_viewDebugData</code> の内容を実際にデバッグツールに活用しようと思うとかなり骨が折れる作業になりそうですが、SwiftUIのView構造をここまで詳細に取得できれば、Viewのデバッグツールを作るにあたってできることは広がりそうです。</p> <h2 id="まとめ">まとめ</h2> <p>今回、アプリに実装されたUI要素のフレームサイズやマージンを簡単に確認できるツールを作って紹介しました。</p> <p>チームメンバーに使ってもらって、サクッとフレームサイズやマージンを確認する用途であれば便利に使えそうなことが分かった一方で、より完成度の高いツールを目指そうとした際に、SwiftUI製のViewのインスペクタ実装は難しい部分が多いことも分かりました。</p> <p><code>_UIHostingView</code> のprivate APIである <code>_viewDebugData()</code> を使うと詳細なSwiftUIのView構造を利用できそうなこと分かったため、 <code>_viewDebugData()</code> のデータを活用したデバッグツールの改善に関しても引き続き検討してみようと考えています。</p> <p>今回の記事が快適なデバッグ環境構築の参考になれば嬉しいです。</p> n_atmark クローズしたサービスの管理画面を静的サイトにする hatenablog://entry/820878482963625603 2023-09-01T10:04:02+09:00 2023-09-06T14:53:37+09:00 こんにちは、技術部の石川です。 ある日、社内の各種アプリケーションを眺めている中で、とあるクローズしたサービスの管理画面を担っていたウェブアプリが今も動いていると気付きました。簡単にヒアリングしたところ、サービス自体はクローズしたものの、保有していたデータが次のチャレンジに生かせるため管理画面だけ残しているとのことでした。 一方で、その管理画面へのアクセスはそう多くありませんでした。毎日ちょっとだけのリクエストを処理するためだけにデータベースとサーバーが動いており、少し無駄がある状態になっていました。 やや気になったので検討した結果、最終的にこの管理画面アプリを Next.js 製の静的なデー… <p>こんにちは、技術部の石川です。</p> <p>ある日、社内の各種アプリケーションを眺めている中で、とあるクローズしたサービスの管理画面を担っていたウェブアプリが今も動いていると気付きました。簡単にヒアリングしたところ、サービス自体はクローズしたものの、保有していたデータが次のチャレンジに生かせるため管理画面だけ残しているとのことでした。</p> <p>一方で、その管理画面へのアクセスはそう多くありませんでした。毎日ちょっとだけのリクエストを処理するためだけにデータベースとサーバーが動いており、少し無駄がある状態になっていました。</p> <p>やや気になったので検討した結果、最終的にこの管理画面アプリを Next.js 製の静的なデータビューワーサイトとしてリニューアルし、社内向けの GitHub Pages として提供されている状態にできました。この記事ではその顛末をご紹介します。</p> <h2 id="技術選定">技術選定</h2> <p>いくつか事前調査をした結果、今回の管理画面について以下のことが分かりました。</p> <ul> <li>Rails が動いている。サーバー側では graphql-ruby を利用した GraphQL API が動いていて、ウェブフロントエンド側では素の React が API にリクエストを行いながらページを作っている。データベースは PostgreSQL (Amazon RDS for PostgreSQL)。</li> <li>データ量はそこまで多くないが、目で全件確認できるほど少なくもない。</li> <li>ページの種別は、Rails の <code>app/javascript/pages</code> 下にある index.tsx を数えてみると 80 程度。移植しなくて良いページもそれなりにありそう。</li> <li>画像や映像を表示しているページがある。</li> <li>データの追加や更新はもう行わない。</li> <li>認証・認可は不要になる。正確には、社内ネットに閉じた環境であれば全公開で構わない。</li> <li>予定としては今後数年アクセスするつもりがある。</li> <li>データの一覧ページのところにある検索機能は残したい。</li> </ul> <p>この状況下で、現状の管理画面アプリに替わる運用として以下の選択肢を考えました。</p> <ul> <li>DWH へのクエリで済ませてしまう。クックパッドでは Amazon Redshift に各種データを集積し、DWH として活用しています。<a href="#f-7d5b6688" name="fn-7d5b6688" title="https://techlife.cookpad.com/entry/2019/10/18/090000">*1</a>今回のアプリが利用しているデータベース上の情報は DWH にもあるため、キレイな画面は消してしまって素朴な SQL クエリにしてしまうことは可能です。クエリ結果を共有するアプリが常用されていたりもします。<a href="#f-f4daf0b7" name="fn-f4daf0b7" title="https://techlife.cookpad.com/entry/2021/06/11/120000">*2</a></li> <li>BI ツールのダッシュボードとして再実装する。</li> <li>データベースやアプリをサーバーレスな構成に移植する。</li> <li>静的サイトジェネレーターの何かしらを使って再実装する。</li> <li>今ある管理画面に対して古典的なウェブスクレイピングを行って全ページのファイルを取得し、それを手直ししたうえで静的サイトとして提供する。検索機能は諦めて、ブラウザのページ内検索を使う。</li> </ul> <p>このうち、DWH 案とダッシュボード案はすぐに取り下げました。画像や映像とテキストが横に並んだ状態でパッと一覧できる現状を保ちたいという利用者からの要望があったのと、再実装したいページ種別数がそれなりにあってクエリやダッシュボードとして作るには時間がかかりそうだったのが理由です。</p> <p>サーバーレス化をやってみるのも面白そうではありました。たとえばデータベースだけ Amazon Aurora Serverless にしてアプリはそのまま、というのは社内に過去事例もありできそうでした。一方でアプリが残る以上そのうちセキュリティアップデート等の対応は必要になるため、もっとラクができるなら嬉しいと考えました。クローズしたサービスのアプリはオーナー不在になる確率が高そうで、誰がメンテナンスするのかという問題もありました。<a href="#f-4b212a06" name="fn-4b212a06" title="もちろん静的サイトにしたとしても JavaScript ライブラリの更新が必要になる可能性が無いとは言えないのですが。数年くらい経って新しいブラウザーでうまく動かなくなったときくらいでしょうかね。">*3</a></p> <p>というわけで静的サイトジェネレーターかスクレイピングの 2 択になりました。ラクそうだったスクレイピングを選んでも良かったのですが、ここで少し欲を出して、静的サイトジェネレーターを試してみたいという気分になりました。</p> <p>静的サイトジェネレーターを使う場合、元々の画面が React で実装されているので、移植の容易さを考えると Next.js や Gatsby などの選択肢が考えられます。実は Gatsby で静的サイト化するのは別の小さな社内向けアプリで前例がありました。ただ 2023 年現在の社内では Next.js を利用しているアプリが多くあり、また Next.js の静的サイト向け機能である <a href="https://nextjs.org/docs/app/building-your-application/deploying/static-exports">Static Exports</a> を自分で使ってみたことがなかったため、技術検証の意味も込めて Next.js を使ってみることにしました。<a href="#f-042b27a5" name="fn-042b27a5" title="この管理画面を開発していたエンジニアはサービスクローズ後も社内に在籍しているのですが、このあたりの技術検証をしてみたいという自分の要望から自分が実装してみることにしたのでした。">*4</a></p> <p>React 以外の依存関係も確認しておきましょう。元々の管理画面のコードで使われている package.json を見てみたところ、UI ライブラリとして Ant Design (antd)、GraphQL クライアントとして Apollo が入っており、その他小さなライブラリがすこし入っているという状況でした。このくらいの複雑度なら移植できそうだなと判断しました。</p> <p>さて、Next.js の Static Exports で再実装するのであれば、PostgreSQL に入っているデータをどうするか考えなければいけません。AWS 上にデータベースが残ったままだとコストはあまり減りませんし、ローカル環境のデータベースに移すならシンプルな形にしたいです。考えた結果、今回のデータはそこまで巨大では無かったため、SQLite へ移植して .sqlite3 ファイルとして持ってしまうことにしました。jsonb 型など PostgreSQL 固有の機能を使っている箇所もあったのですが数箇所しか無かったため SQLite 向けに書き直し、SQLite のみだと足りない処理はデータベースからデータを取ってきた後にアプリ側で行うようにしてしまいました。</p> <p>データ取得まわりについては GraphQL を使うことにしました。元々の Rails 製 GraphQL API をコピペしてくれば GraphQL スキーマとサーバーが出来ますし、クライアント側でも型が自動生成できてラクが出来そうという目論見がありました。SQLite に対して直接 SQL クエリを走らせても良かったのですが、自分が元の管理画面の実装にそこまで詳しくなかったというのもあり、どこまで複雑なクエリが必要になるのか実装前の時点で判断しづらかったためコピペにしてしまいました。</p> <h2 id="実装">実装</h2> <p>方針が決まってしまえば後は実装するだけです。API サーバーは元の Rails のコードをそのままコピペしてきて動かすようにし、フロントエンドは <code>create-next-app</code> しました。Next.js 13 で App Router を使いつつ、Static Exports のために next.config.js が</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synStatement">const</span> nextConfig = <span class="synIdentifier">{</span> output: <span class="synConstant">'export'</span>, <span class="synIdentifier">}</span> module.exports = nextConfig </pre> <p>になっています。<a href="#f-de2afe0b" name="fn-de2afe0b" title="最終的な next.config.js では更に、next/image の最適化は Static Exports では意味が無いので unoptimized: true にしたり、実装の都合で trailingSlash: true にしたりもしています。GitHub Pages にデプロイする前には basePath を調整するのもやっています。">*5</a></p> <p><code>next build</code> をする際には横で SQLite に繋がった Rails 製 GraphQL API を動かしていて、必要に応じて API にリクエストすることでデータを取ってきます。Static Exports の場合はビルドが終わると HTML 等のファイル群が生成されるので、開発環境では <code>serve</code> を使うなどすれば閲覧できます。</p> <p>実装の移植について、ページや React Components の移植はまあまあコピペで終わりました。元々の実装がそれなりにコンポーネント化されていて全容を把握しやすかったのと、元と変わらず GraphQL を使っているあたりが効きました。</p> <p>ただし Server Components と Client Components、つまりどのコンポーネントの処理はサーバー側で行われどの処理はブラウザ側で行われるのかについては整理する必要がありました。Static Exports を行ううえでは API からデータを取得する部分はすべてサーバー側で行われていないといけませんし、逆に <code>useState</code> を使うような箇所はブラウザ側で行われなければなりません。元々の管理画面ではブラウザから GraphQL リクエストを行っていたため、至るところにある API リクエストはサーバー側に集中するよう書き換えが必要でした。データの流れを整理した結果としてひとつのコンポーネントを分割して Server Component と Client Component に分けたりもしました。</p> <p>とはいえ「データの追加や更新はもう行わない」という制約がとても強く効いていて、実装は比較的シンプルになりました。データの追加・更新のために存在していた React コードをばっさり削除していった結果、大抵のページは「Server Components でデータを取得して、子となる UI コンポーネントにデータを渡す。子コンポーネントは必要であれば Client Components にする」くらいの単純さになりました。</p> <p>Client Components を用いたのは主に、検索フォームを設置しているところと antd 5.8 が必要とするところです。<a href="#f-0e92a6ba" name="fn-0e92a6ba" title="antd で必要になるのは https://ant.design/docs/react/use-with-next#using-nextjs-app-router に書かれている &quot;if you use the above sub-components in your page, you can add &quot;use client&quot; to the first line of the page component to avoid warnings&quot; です。">*6</a></p> <p>検索機能については、元の管理画面ではサーバー側に検索機能を実装していましたが静的サイトでは不可能なので、ブラウザ側の JavaScript で素朴に <code>String.prototype.includes</code> を使って絞り込むことで実現しました。ページネーションして見た目上の表示件数を減らしはしましたが、それなりの数のデータがあっても高速に動作するのでブラウザは凄いですね……。もし複雑な全文検索が欲しくなる箇所があれば <a href="https://github.com/olivernn/lunr.js">Lunr.js</a> 等を使ってみるつもりでしたが、今回はそこまで複雑な検索は無かったため使わずに済んでしまいました。</p> <p>そんなこんなで詳細が固まり、まずは複雑そうなページから実装してみたところ上手く動いたため実装を進め、必要なページすべてについて実装しきることができました。</p> <h2 id="振り返り">振り返り</h2> <p>全部終わったあと振り返ってみると、Next.js の App Router と GraphQL を使うことにしたのは成功だったと感じます。コピペできたのもそうですし、実装している最中、App Router のディレクトリ構造を使って GraphQL クエリや小さい React Components のファイルたちをページの実装の近くに配置できるのがラクでした。</p> <p>具体的にはたとえば graphql-codegen で <a href="https://the-guild.dev/graphql/codegen/plugins/presets/near-operation-file-preset"><code>near-operation-file</code></a> を使うようにしたうえで、以下のようなファイル配置になるわけです。</p> <pre class="code" data-lang="" data-unlink>app ├── _lib │ └── types.generated.ts ├── recipes │ ├── _lib │ │ ├── Table.tsx │ │ ├── query.generated.ts │ │ └── query.graphql │ └── page.tsx : :</pre> <p>元々の実装もページごとのコードとページ固有の React Components、それとたまに全体で共有の React Components という感じだったので、それをそのまま持ってくることが出来ました。データの流れを整理する過程で小さい Server Components や Client Components が生まれたのですが、このディレクトリ構造だと気になりません。</p> <p>Static Exports は何の問題もなく動いてくれました。移植の際に <code>&lt;a&gt;</code> タグをすべて next/link の <code>&lt;Link&gt;</code> に書き換えたため、ただでさえ静的サイトで速いのに prefetch のおかげで更に速く感じられるサイトが出来上がりました。</p> <p>いちおう実装前の不安点として、antd などの依存ライブラリは実装時点での最新バージョンまで上げないと App Router および React Server Components 対応の不充分な点がありそうだとは分かっていました。この関係で元の管理画面で使っていたバージョンからメジャーバージョンを上げないといけないライブラリもありました。とはいえ実装前にザーッと各ライブラリの変更履歴を眺めた結果そこまで困らなさそうと判断し、実際あまり困りませんでした。</p> <p>また App Router の関係なのか Static Exports の関係なのかはちゃんと調べていませんが、いくつかのエラーで原因を調べるのに少し時間がかかりました。エラーメッセージとスタックトレースが分かりづらかったのですよね……。<code>"use client";</code> をつけて Client Components にしたら直るのだけどエラーメッセージから直接は分かりづらい類のエラーにはいくつか遭遇しました。</p> <p>とはいえ始まりから終わりまで見ると、そう労力をかけずに移植できたので満足です。この管理画面が提供しているデータは社内を見渡してもそれなりにユニークな料理データでして、これを参照しやすい形で残し続けられることには価値があると、個人的にも考えています。そういった意義のついでに技術検証も出来たオトクな仕事でした。</p> <div class="footnote"> <p class="footnote"><a href="#fn-7d5b6688" name="f-7d5b6688" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://techlife.cookpad.com/entry/2019/10/18/090000">https://techlife.cookpad.com/entry/2019/10/18/090000</a></span></p> <p class="footnote"><a href="#fn-f4daf0b7" name="f-f4daf0b7" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://techlife.cookpad.com/entry/2021/06/11/120000">https://techlife.cookpad.com/entry/2021/06/11/120000</a></span></p> <p class="footnote"><a href="#fn-4b212a06" name="f-4b212a06" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん静的サイトにしたとしても JavaScript ライブラリの更新が必要になる可能性が無いとは言えないのですが。数年くらい経って新しいブラウザーでうまく動かなくなったときくらいでしょうかね。</span></p> <p class="footnote"><a href="#fn-042b27a5" name="f-042b27a5" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">この管理画面を開発していたエンジニアはサービスクローズ後も社内に在籍しているのですが、このあたりの技術検証をしてみたいという自分の要望から自分が実装してみることにしたのでした。</span></p> <p class="footnote"><a href="#fn-de2afe0b" name="f-de2afe0b" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">最終的な next.config.js では更に、next/image の最適化は Static Exports では意味が無いので unoptimized: true にしたり、実装の都合で trailingSlash: true にしたりもしています。GitHub Pages にデプロイする前には basePath を調整するのもやっています。</span></p> <p class="footnote"><a href="#fn-0e92a6ba" name="f-0e92a6ba" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">antd で必要になるのは <a href="https://ant.design/docs/react/use-with-next#using-nextjs-app-router">https://ant.design/docs/react/use-with-next#using-nextjs-app-router</a> に書かれている "if you use the above sub-components in your page, you can add "use client" to the first line of the page component to avoid warnings" です。</span></p> </div> nekketsuuu Rubyの並列並行処理のこれまでとこれから hatenablog://entry/820878482963206936 2023-08-31T15:25:11+09:00 2023-08-31T15:29:52+09:00 本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。 Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。 <p>技術部の笹田です。今日で退職するので、バタバタと返却などの準備をしています。</p> <p>本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。</p> <p>並行と並列はよく似た言葉ですが、本記事では次のような意味で使います。</p> <p>並行処理(concurrent processing)は、「複数の独立した実行単位が、待っていればいつか終わる(もしくは、処理が進む)」という論理的な概念で、古典的にはタイムシェアリングシステムなどが挙げられます。</p> <p>並列処理(parallel processing)は、「複数の独立した実行単位のうちのいくつかが、あるタイミングで同時に動いている」という物理的な概念で、古典的には複数のCPU上で同時に実行させる、というものです。最近では、1つのCPU上で複数コアが同時に動いている、というのが普通になってきましたね。</p> <p>Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。</p> <h2 id="簡単な歴史">簡単な歴史</h2> <h3 id="Ruby-18-まで">Ruby 1.8 まで</h3> <p>Ruby 1.8 までは、Rubyはユーザーレベルスレッドと呼ばれる仕組みで、OSなどが提供するネイティブスレッド(下記NTとも表記、PthreadやWindows APIのスレッド)を1つつかって、複数のRubyスレッド(下記、RTとも表記、<code>Thread.new{}</code>で作るやつ)を管理していました。1つのネイティブスレッドで複数(M個)の Rubyスレッドを管理するので、M:1 モデルということもあります(世間的には 1:N スレッドモデルということが多いのですが記事の都合上、M:1 と書いておきます)。</p> <p><figure class="figure-image figure-image-fotolife" title="M:1 (N:1) model, Green threads, user level threads, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113351.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>M:1 (N:1) model, Green threads, user level threads, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>複数のRubyスレッドは、(1) I/Oや sleep、Mutex などで待ちが入るタイミング、(2) 一定時間(タイムスライス)経過したときのタイミングで切り替わります。I/O に関しては、select(に類する)システムコールで準備できたかを定期的に確認しながら管理します。</p> <p>この手法の利点と欠点は次の通りです。</p> <ul> <li>利点: <ul> <li>ユーザレベルスレッドなので、生成は速い</li> </ul> </li> <li>欠点: <ul> <li>当時はRubyスレッドの切り替えをスタックを丸々コピーするという手法を使っていたので、結構遅いものでした。</li> <li>select で待ちを制御できない処理、たとえば <code>waitpid()</code> や <code>flock()</code>、ドメイン名解決、待ちが入るようなライブラリ関数などで待っていると他のRubyスレッドに切り替わらない</li> <li>ポーリング出来る処理は都度ポーリングで対処していたが、スレッド数が増えるとスケールしない可能性がある</li> <li>ネイティブスレッドを占有するほうが都合がよいライブラリ(GUI系など)を素直に使えない</li> <li>作るのが(メンテし続けるのが)結構大変</li> </ul> </li> </ul> <h3 id="Ruby-19-Thread">Ruby 1.9 Thread</h3> <p>Ruby 1.9 では、Rubyスレッド一つにつきネイティブスレッド1つ用意する 1:1 モデルに変更されました。というのも、ユーザレベルスレッドはいろいろ tricky で、実際に実装していた私には手がおえなかったからです。また、1:1 モデルにすることで、並行処理だけでなく、並列処理への拡張も視野に入っていたからです。</p> <p><figure class="figure-image figure-image-fotolife" title="1:1 model, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113458.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>1:1 model, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>Ruby 1.9 で 1:1 モデルを導入する際には、GVL(Global VM Lock)というのを導入しました。ただ一つのGVLをロックしているRubyスレッドしか動かない、というモデルとすることで、同時にRubyスレッドはたかだか1つしか動かない、というのものです。そのため、Rubyスレッドは相変わらず並行処理をサポートしますが、並列処理はサポートしません。いろいろな理由はあるのですが、要は「実装が簡単だから」ということに尽きます。</p> <p>(余談:どちらも簡単に拘っているのは、手抜きしたいというのが本音なのですが、建前でいうと、マンパワーのない状況で安定した処理系を提供するためには適切なトレードオフであるのではないかな、と思っており、この判断は今振り返っても妥当だったんじゃないかなと思います。2006年ごろですかね)</p> <ul> <li>利点: <ul> <li>1:1 スレッドモデルは結構簡単</li> <li>GVL があるので並列に実行されないためにさらに簡単</li> <li>GVL を解放することで(C-extension)、ブロックする処理をしている間、他のRubyスレッドに処理を移すことができる</li> <li>GVL を解放することで(C-extension)、複数の処理(Rubyで記述してある処理ではない)を並列に実行することができる。例えば、Bignumの時間がかかる計算や、I/O の処理(Ruby インタプリタとは独立した処理)など。</li> <li>Rubyスレッド切り替えはネイティブスレッドによって実装されるので速い</li> <li>スケジューリングをネイティブスレッドに任せてしまうので楽</li> </ul> </li> <li>欠点: <ul> <li>1:1 モデルなので Ruby スレッドを1つ作るたびにネイティブスレッド 1 つ必要になり、Rubyスレッド数がスケールしない(ネイティブスレッドの実装による)</li> <li>GVL があるので並列実行できない</li> <li>Rubyスレッド間で処理を受け渡すような処理が遅い(CPU core 間で処理を頻繁に切り替えるときに遅い)</li> <li>スケジューリングをネイティブスレッドに任せてしまうので、Ruby側からの細かい制御が難しい</li> </ul> </li> </ul> <p>Ruby 1.8 であった問題がだいぶ解決されているのがわかると思います。そのほかはトレードオフですね。</p> <h3 id="Ruby-19-Fiber">Ruby 1.9 Fiber</h3> <p>Ruby 1.9 では、ユーザレベルスレッドの利点をちょっとのこしておこうということで Fiber が導入されました。Fiber は Ruby 1.8 のユーザレベルスレッドとほぼ同様ですが、タイムスライスがなく、I/O などで自動的にスイッチすることのない、という点が異なります。</p> <p>Fiber は当初は Ruby 1.8 のスレッド切り替え処理をそのまま踏襲していたのですが、のちのバージョンで改善され、今では CPU ごとにアセンブラを用いて記述する、というものになりました。</p> <h3 id="Ruby-30-の-Fiber-scheduler">Ruby 3.0 の Fiber scheduler</h3> <p>Ruby 3.0 で導入された Fiber scheduler は、I/O など、ブロックするような処理を契機にフックを呼び出す仕組みで、自分で Fiber をスケジュールする処理(これを総称して Fiber scheduler)を記述するための仕組みです。実際には、自分で記述するのではなく、すでにある gem を利用するといいでしょう。</p> <ul> <li>利点: <ul> <li>ユーザレベルスレッドの利点(低コストな生成)を得られる</li> <li>自分でスケジューラーを記述できる</li> <li>タイムスライスがないため予測可能性が上がる</li> </ul> </li> <li>欠点: <ul> <li>Ruby 1.8 スレッドと同じ(管理できないブロックする処理では他のFiberに切り替えられない)</li> <li>Fiber を意識する必要がある</li> <li>タイムスライスがない</li> </ul> </li> </ul> <p>アプリケーションに特化したスケジューラを記述できるというのは、最高性能を目指すという観点からは良いものですが、多くの場合 too much じゃないかなぁ、というのが私の感想です。</p> <h3 id="Ruby-30-の-Ractor">Ruby 3.0 の Ractor</h3> <p>そもそも Ruby スレッド、というかいわゆるスレッド一般って、変更可能(Mutable)データの共有によるデータレース、レースコンディションの問題があり、Mutexなどで正しく排他制御が必要です。なので、私は、スレッド難しいなぁ、あんまり便利にしたくないなぁ、という意見を持っていました。この問題に対処するために、例えば他の言語では次のような工夫をしていました。</p> <ul> <li>領域を完全に分けてしまう: Unix などのプロセス、Racket(のPlace)</li> <li>すべてのデータを Immutable にして、Immutable なデータしか共有できないようにする: Erlang, Elixir(のProcess)</li> <li>型でいい感じになんとかする: Rust</li> <li>データを共有する方法を標準化する: Java, Golang(Goroutine)</li> <li>実行時にやばそうなところを検知して修正する: Valgrind, Thread sanitiser, ...</li> </ul> <p>Rubyで取れそうな戦略は何かな、ということを考えて、「"Immutable なデータしか共有できないようにする" なら、なんとかなりそうかな?」という発想で Ractor を設計しました。イメージとしては、Unixなどのプロセスに近いですが、共有可能な部分は共有するし、Mutable でもロックを必須とするなら共有可能にする、というところが近いです。</p> <ul> <li>利点: <ul> <li>並列実行が可能</li> <li>データレースなどの問題が原理上起こらない</li> </ul> </li> <li>欠点: <ul> <li>共有可能オブジェクトを制限するので、ふつうのRubyプログラムが multi-Ractor の上では動かない</li> <li>実装が悪いので色々遅い</li> </ul> </li> </ul> <p>「実装が悪い」という部分は、改善すればいいのですが、いくつか大きな問題がありました。</p> <ul> <li>1:1 スレッドを踏襲している(Ractor 内に 1 個 Ruby スレッドを作るが、それが 1 ネイティブスレッドを要求する)</li> <li>すべてをとめて GC しなければいけないので Ractor 数にスケールしない</li> </ul> <p>欠点の1つ目の問題から、なかなか利用されず、ではスクラッチで Ractor ベースに書き直せばよくなるか、というと2つ目の欠点である実装が悪いという点から利用も進まない、となれば順当に実装をよくしていくぞ、というのが今年の RubyKaigi 2023 での私の発表でした。</p> <p><figure class="figure-image figure-image-fotolife" title="“Ractor” reconsidered, or 2nd progress report of MaNy projects"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113943.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>“Ractor” reconsidered, or 2nd progress report of MaNy projects</figcaption></figure></p> <p><a href="https://rubykaigi.org/2023/presentations/ko1.html#day1">"Ractor" reconsidered - RubyKaigi 2023</a></p> <p>要点としては、</p> <ul> <li>Ractor はせっかく入ったんだけど <ul> <li>既存のコードがすぐには動かないから使われていない</li> <li>性能が悪いことが多くてなかなか使われていない</li> </ul> </li> <li>とりあえず性能よくすることで、小規模なコードから使われるんじゃないだろうか</li> <li>そのためにはこういうことをやったし、これからこういうことをするよ</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="Future expected situation on Ractor, quoted from my RubyKaigi2023 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113133.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Future expected situation on Ractor, quoted from my RubyKaigi2023 talk</figcaption></figure></p> <p>というものでした。</p> <h2 id="MN-スレッドの導入">M:N スレッドの導入</h2> <p>というわけで「こういうことをしたよ/するよ」という話です。</p> <p>まず、RactorやRubyスレッドは、1:1モデルであることで、生成が遅かったり生成できる数が少なかったりします(1:1モデルの欠点)。そこで、M:N スレッドモデルを導入できないか、今(というか去年から)開発中です。</p> <p>という内容が RubyKaigi 2022 での私の発表でした。</p> <p><figure class="figure-image figure-image-fotolife" title="Making *MaNy* threads on Ruby"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113830.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Making <em>MaNy</em> threads on Ruby</figcaption></figure></p> <p><a href="https://rubykaigi.org/2022/presentations/ko1.html">Making <em>MaNy</em> threads on Ruby - RubyKaigi 2022</a></p> <p>ちなみに、M:N スレッドモデルの実装なので MaNy プロジェクトというコードネームを中田さんにつけてもらいました。</p> <ul> <li>M:1スレッドは、RubyスレッドM個にたいしてネイティブスレッドが1個(Ruby 1.8 まで)</li> <li>1:1スレッドは、Rubyスレッド1個にたいしてネイティブスレッドが1個(Ruby 1.9~)</li> <li>M:Nスレッドは、RubyスレッドM個にたいしてネイティブスレッドがN個</li> </ul> <p>というモデルです。N をコア数にすることで、十分小さい数のN個のネイティブスレッドで、M個のRubyスレッド(例えば1000個)を賄おうというものです。これで、「1:1スレッドモデルがスケールしない」という問題が解決します。</p> <p><figure class="figure-image figure-image-fotolife" title="Thread system implementation techniques, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112246.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Thread system implementation techniques, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>欠点としては、実装が複雑であることですが、そこは頑張りました/頑張っています。</p> <p>同じRactorに属するRubyスレッドは同時並列には動かないので、1つしかRactorを動かさない場合はNをいくら多くしても2個以上のネイティブスレッドを使うことはありません。なので、その点はユーザレベルスレッドと同じです。 あまりRubyスレッドをよくしたいという動機はないのですが(Ractor 使ってほしい)、副次的に現在のマルチ Ruby スレッドプログラムがユーザレベルスレッドモデルを用いることで改善されることもあるかもしれません。</p> <p><figure class="figure-image figure-image-fotolife" title="M:1 Thread level scheduling in a Ractor, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112630.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>M:1 Thread level scheduling in a Ractor, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>ユーザレベルスレッドで問題となっていた、どーしょーもなくブロックしてしまう処理は、そのブロックしてしまう(可能性のある)処理を実行中、1:1スレッド、つまり1つのRubyスレッドが1つのネイティブスレッドを占有する、という状態にしておきます。ちなみにこの状態から戻らないと、他のRubyスレッドに処理がうつらないような気がしますが、ちゃんと移すために、準備のできたRubyスレッドを実行するためのネイティブスレッドを1個余分に追加します。つまり、N はそのようなスケジュール可能なネイティブスレッドの数であり、占有されている状態のネイティブスレッドは数に入れません(上限なしにネイティブスレッドが作られる可能性がありますが、動かないよりまし、というスタンスです)。</p> <p><figure class="figure-image figure-image-fotolife" title="Handle unmanaged blocking operations, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112416.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Handle unmanaged blocking operations, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>この工夫により、ユーザーレベルスレッドモデルで問題であった「何か処理が止まってしまう」ときに別のRubyスレッドに切り替わらなくなる、という問題が解決します。 M:Nスケジューラは、切り替わらなくなるかもしれない、といった危険のなくなった、そしてタイムスライスで切り替わる(プリエンプションがある)組み込みの Fiber scheduler みたいなもの、というとらえ方もできると思います。</p> <p>このM:Nスレッドの実装は、Go language の構成によく似ています。ちょっとした違いとしては、goroutine はどれも順不同で実行できるのですが、Rubyのスレッドは同じRactorに所属している場合、同時に動くことはできない(GVLをもつRubyスレッドしか動かせない)、という制約です。この制約を満たすため、Ractor内のRubyスレッドについてのスケジューラと、どのRactorを動かすか選ぶRactorについてのスケジューラの2つのスケジューラによる構成となっています(もちろん細かい違いはほかにもいろいろあります)。</p> <p>M:Nスレッドは、多くの場合で(ちゃんと作って有れば)問題ないと思われるのですが、どうしても仕組み的にネイティブスレッドの Thread local storage に依存した作りになっているコードを利用すると破綻する、という問題があります(あるRubyスレッドが異なるネイティブスレッドで実行するようになるため)。そこで、今のところ M:N スレッドモデルはデフォルトではオンにならないようにしようということにしています。より正しくは、メインRactorの中でRubyスレッドを作る場合(つまり、ふつうのRubyによるスレッドプログラムの場合)、1:1 スレッドという従来のスレッドモデルで実行されることになります。複数Ractorを利用する場合は、メインRactor以外はM:Nスレッドモデルで実行されます。</p> <p>今のところ、<code>RUBY_MN_THREADS=1</code> という環境変数でメインRactorでのM:Nスレッド対応を指定出来るようにする予定です。もし M:N スレッドの実装がマージされたら試してみてください。ちなみに、ネイティブスレッド数の上限Nを指定するには、今のところ<code>RUBY_MAX_CPU=n</code>という環境変数で指定できるようにする予定です。</p> <p>詳細は当該チケットをご覧ください: <a href="https://bugs.ruby-lang.org/issues/19842">Feature #19842: Introduce M:N threads - Ruby master - Ruby Issue Tracking System</a></p> <p>性能改善などはあまりきちんとできていないのですが、去年の RubyKaigi 2022 の発表で、少し述べています。場合によってはだいぶ速くなっています。</p> <p><figure class="figure-image figure-image-fotolife" title="Ring example, quoted from my RubyKaig 2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112739.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ring example, quoted from my RubyKaig 2022 talk</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="Ring example, compare with Go/Loop time, quoted from my RubyKaigi 2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112809.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ring example, compare with Go/Loop time, quoted from my RubyKaigi 2022 talk</figcaption></figure></p> <h2 id="Ractor-local-GC-の導入">Ractor local GC の導入</h2> <p>現在 Ractor が遅いもっとも大きな理由が GC です。並列に実行される Ractor ですが、GC をするためにはすべてを止める必要があり、とめた状態で唯一のネイティブスレッド上でGCが実行されるようになっています。これは色々遅いので、RactorごとにGCをそれぞれ並列に実行する、というRactor local GCができないか試行錯誤中です(他の方が試験実装中)。</p> <p><figure class="figure-image figure-image-fotolife" title="Ractor local GC, quoted from my RubyKaigi2023 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113102.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ractor local GC, quoted from my RubyKaigi2023 talk</figcaption></figure></p> <p>これを実現するためには、Ractorがなまじっか Immutable オブジェクトは Ractor 間で共有できるといった仕組みから、きちんと動かすためには分散GCが必要になります。現在、実装しながら問題を見つけ解決していくような手探りな感じで開発を進めています。来年くらいに何かご紹介できるといいですね。</p> <p><figure class="figure-image figure-image-fotolife" title="Ractor local GC needs distributed GC, quoted from my RubyKaigi2023 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113020.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ractor local GC needs distributed GC, quoted from my RubyKaigi2023 talk</figcaption></figure></p> <h2 id="おわりに">おわりに</h2> <p>本稿では、Rubyの並行並列処理について外観し、利点と欠点をまとめました。そして、それらの欠点を解消するために M:N スレッドモデルの実装を行っており、現状をご紹介しました。また、さらにまともな性能にするためにはRactor local GCが必要であるということをご紹介しました。</p> <p>いまのところ、M:Nスケジューラは、1:1モデルにしておけば(デフォルトです)テスト通っているのですが、M:Nスケジューラを有効にすると「あれー?」というところでバグを出すので、Ruby 3.3 にマージできるのか予断を許さない感じです。がんばろ。</p> <p>クックパッドでは、実はずっとこの辺をやっていました。</p> <ul> <li>2016: Guild (のちの Ractor)構想の発表</li> <li>2017: Ractor につなげるための、Fiber 周りの整理(この年にクックパッド入社)</li> <li>2018: Ractor の実装の検討</li> <li>2019: Ractor の実装(RubyKaigi 2019 では Ruby で MRI を書くための話をしていたけど)</li> <li>2020: Ractor の入ったRubyのリリース(Guild -> Ractor に名前が変わったのもこの年)</li> <li>2021: Ractor もデバッグできるようにするために debug.gem の開発(まだ Ractor では動かないんだけど)</li> <li>2022: M:N スケジューラ構想の発表とプロトタイプ</li> <li>2023: M:N スケジューラで Ractor を動かせるように</li> </ul> <p>(やっていたのはこれだけじゃないけど)同じテーマで何年やってるんだ、という気もしますが、長い目で開発を支えてくれたクックパッドに深く感謝します。</p> <p>そんな感じでまだまだやることがイッパイありますが、「並列並行処理を書くならRubyもいいね」といってもらえるように、これからも頑張っていきたいと思います。</p> <p>読まなくてもよい余談です。私の卒論(2002)はスレッドライブラリ(pthread)の実装でして、20年たっても似たようなことしかやってねーな、という感想があります(でも、20年ずっと楽しいので幸せ。ちなみにYARV開発は2004年からなのでもうすぐ20年)。M:N スケジューラはその頃から考えてはいたんだけど、当時は逆に遅くなるかもなぁ、などと思っていたところでした。Go がだいたい同じようなことをしている、ということを確認できたので、結構自信をもって進めているという次第です。まぁ、Go はほかのランタイムを全部自分でかいているので、TLSみたいな互換性問題があまり起きないというところはあるとは思うんですが。2018年のRubyConf で、まつもとさんの部屋でこんな構想を話して、「でも2020年には間に合わないよなー」と言っていた内容が、やっと形になってきました。うまくまとめたいな。回り道しすぎ?</p> <p>というわけで、またどこかで成果をおみせできることを楽しみにしています。</p> <p>Happy hacking!</p> koichi-sasada TechMTG文字起こしレポート:クックパッドマートのAndroidアプリのUI開発のこれまでとこれから hatenablog://entry/820878482954241916 2023-07-31T18:18:11+09:00 2023-07-31T18:18:11+09:00 こんにちは、CTO室の緑川です。クックパッドでは隔週で全エンジニアが集まるTechMTGというミーティングを行っています。今回はTechMTGで話した技術的な取り組みや解説を文字起こしレポートとしてお届けします。 今回は4月19日に発表されたクックパッドマートでのAndroidアプリのUI開発についてです。 以下、レポート本文です。 クックパッドマートのAndroidアプリのUI開発のこれまでとこれから こんにちは、門田です。 2016年に新卒入社をして2019年にクックパッドマートに移動し、今は買物プロダクト開発部というところでAndroidエンジニアをしています。 今回はAndroidアプ… <p>こんにちは、CTO室の緑川です。クックパッドでは隔週で全エンジニアが集まるTechMTGというミーティングを行っています。今回はTechMTGで話した技術的な取り組みや解説を文字起こしレポートとしてお届けします。</p> <p>今回は4月19日に発表されたクックパッドマートでのAndroidアプリのUI開発についてです。 以下、レポート本文です。</p> <h2 id="クックパッドマートのAndroidアプリのUI開発のこれまでとこれから">クックパッドマートのAndroidアプリのUI開発のこれまでとこれから</h2> <p>こんにちは、門田です。 2016年に新卒入社をして2019年にクックパッドマートに移動し、今は買物プロダクト開発部というところでAndroidエンジニアをしています。</p> <p>今回はAndroidアプリのUI開発の話をしようと思います。 Jetpack Composeの話がメインになると思っていたんですけど、去年の11月や先月にもJetpack Composeの話は社内でしていたので、今回はそんなに話すこともなくなったかなと思ったのですが、「クックパッドマートでJetpack Composeをどういうふうに使ってきたか?」という話はできていなかったと思うので、今回はそれを話そうかなと思っています。</p> <h3 id="アプリの実装で最も時間がかかる部分">アプリの実装で最も時間がかかる部分</h3> <p>最初に問題定義をしたいのですが、アプリの実装をするときに最も時間がかかるところって皆さんどこだと思いますか? 僕の中ではなんといってもUI開発が一番大きいと思っています。今回はこれを前提に話していこうかなと思います。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161556.png" width="1200" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>アプリの開発をしていく上で素早くUIを構築することが、素早く機能を開発していくことに最も寄与するんじゃないかなと思っています。じゃあそれをどうすれば実現できるのだろうというところを考えたときに、パターン化して簡単に誰でも早く開発できればいいんじゃないかということを考えて、クックパッドマートの開発初期から結構そのことを考えて実装してきました。</p> <p>2019年にクックパッドマートを作り始めたときはAndroid View、いわゆるXMLを使って構築して、コードで味付けしていくみたいな方法が一般的だった時代なんですけど、この時にはRecyclerViewという要素をいっぱい並べるライブラリと、あとそれを簡単に使うためのGroupieっていうライブラリを使って複雑な画面の構築を簡単にしていくのをやっていました。これのメリットとしてはレイアウトファイル、たとえば商品詳細だったりとか、長いレイアウトのファイルでもある程度のまとまりごとに分割して組み立てていくことができるので、全体がシンプルに構築できるということに一つ大きなメリットがあります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161622.png" width="1200" height="595" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとは一覧画面と詳細画面みたいなところで実装のパターン化ができて、これも実装速度の向上に大きく寄与をしたんじゃないかなと思っています。これに関しては当時テックブロックにも書いてたのでこれを参照してください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2019%2F04%2F11%2F130000" title="クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2019/04/11/130000">techlife.cookpad.com</a></cite></p> <p>具体的にはどんなふうになっていくかというと、上の方からSectionっていう画面の単位を分けていってそれを横側に書いていくと、画面全体の構築も割と見やすい状態になっているし、一つ一つのレイアウトのファイルの単位も左側のlayout XMLが構築されていたら右側でそれに対して名前を入れたりとか画像を設定したりとか、というふうに設定していくと結構わかりやすいシンプルなレイアウトを組めたんじゃないかなと思っています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161649.png" width="1200" height="695" 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/c/cookpadtech/20230731/20230731162435.png" width="1200" height="657" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ただ、この辺は開発上の問題が結構あって、たとえば動的なUIの更新というのが地味に難しかったりするんですよね。たとえば、要素を追加したりとか削除したりとかチップみたいなUI要素を入れたい時とかにViewを追加削除するのは結構力技になりがちで難しかったり、角丸とかボーダーとかの表現がAndroidだと地味に面倒くさいところがあってすごく難しいところでした。</p> <p>あとは複雑なUI状態というのがLayout XMLのPreviewって一つしかないので、実際にデータを当てはめて確認するしか道がないので結構難しいというのがあります。クックパッドマートだと特に商品の受け取りの状態って複数あって、6・7種類ぐらいあるんですけどステージング環境だとその状態を再現するのにも一苦労するので、その動作確認がすごく大変だったっていうのがあります。あとは全体をコードで組み立てているので画面全体の構築っていうところが確認できないのが結構難しいところでした。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161732.png" width="1200" height="616" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>たとえばUIを追加とか、削除とかするみたいな話なんですけど、jQueryとかを見てもらえると分かると思うんですけど、特定のdivタグに子要素のビューを全部消してもう一回入れ直すみたいなことを力技で入れていた気がして、実行してみないとうまくできたかわからないとか、入れたところのビューの大きさだったり要素間のマージンだったりとかが結構調整しづらかったりとか、そういう問題がいっぱい起きました。あと角丸をつけたいだけでこれだけ書かなきゃいけなくて、これをbackgroundに設定するみたいなことをやらなきゃいけなくてすごく面倒くさかったっていうのがあります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161757.png" width="1200" height="660" 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/c/cookpadtech/20230731/20230731161820.png" width="665" height="613" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>AndroidとかiOSの開発って結構レイアウトが崩れることがあると思うんですけどこういうところが大きいんじゃないかなと僕は思ってます。</p> <h3 id="Jetpack-ComposeはUI開発の何を変えたか">Jetpack ComposeはUI開発の何を変えたか?</h3> <p>2022年は結構Jetpack Composeの年になったかと思うんですけど、Jetpack ComposeってUI開発の何を変えたかっていうと、そういう動的なViewの追加削除だったりとか角丸だったりとかボーダーだったりとかそういう今までちょっとやりづらかったUIの表現だったりとかを簡略化してくれたのが結構大きいかなと思うんです。</p> <p>他には高性能なPreviewを作ってくれたっていうのが僕の中では一番 Jetpack Composeの中でいいと思っていて、これが強力すぎてこれを扱うための開発スタイルの設計を去年はしていました。Jetpack Composeは直感的にif文を書いたりとかして条件式によってこのViewが表示されるされないみたいなものを制御できたりとか、あと角丸を表示するときに.clipって書くだけで一行で表現できたりとかこういうのが本当に助かりますね。</p> <h3 id="Previewのここがすごい">Previewのここがすごい</h3> <p>あと、今日はPreviewの話をすごくしたいんですけど、Previewはめちゃくちゃ良くて、PreviewのすごくいいところはPreviewを自分で書くことができるところです。逆に言うと自分で書かないといけないんですけど、任意の要素に対してPreviewを自分で書けるんです。なので、どんなPreviewでも自分で作ることができて、つまり同じUIに対しても複数のデータを入れてPreviewを書くことももちろんできるというのがかなり強いところかなと思います。あとはLive Editってホットリロードみたいなやつですね。Composeのコードを編集していると横で自動でコンパイルを走ってPreviewがどんどん更新されていってその状態が見えるという感じになってます。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161856.png" width="1200" height="589" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Previewが出たばっかりのときはAndroid Studioが結構不安定でうまく動かなかったときもあったんですけど、最近はかなり安定して使えるようになってきたので、これなしではUI開発が進まないというぐらいに最高になってきました。</p> <p>あとInteractive ModeだったりとかDeploy Previewっていうのがあって、作ったPreviewを実機とかエミュレーターにもインストールできるんです。たとえばボタンを押したときにトースト通知を表示してくださいとか、ボタンを押したときにこの要素を隠してくださいとかそういうイベントとかも全部出てきているので、実はPreviewだけでアプリも作れて軽いプロトタイピングみたいなところもできるというのが結構大きいなと個人的には思っています。</p> <p>で、これをどういうふうに扱っていこうかというところを考えたときにLive Editにすごく注目していて、Live Editを有効に扱うためにはアプリのコンパイル時間を短くする必要があるんです。   なんでかと言うと、さっき言ったとおりLive Editするときは横でコンパイルを走って自動でPreviewが更新されるという話をしてたので、逆に言うとコンパイル時間が長かったら編集している際にコンパイルがすごく時間がかかって、Previewが表示されるまで30秒とか1分とかかかるっていうような状態になると結構きついと思うので、その時間を短くする必要があります。Composeの関数を書くためだけのモジュールを各フィーチャーごとに分離するような開発手法をクックパッド マートでは取り組みました。で、これでPreviewしたいモジュールだけビルドをすればよくなるので、かなりコンパイルも楽になったという感じです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161926.png" width="1200" height="573" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとは表示パターンをPreviewで網羅できるようになったということで、ちゃんと網羅しようといういくつかの実装ルールだったりとかも決めたりしました。 実際にPreviewを書くと@Previewって付いているアノテーションが書いてある関数がPreview用のComposeの関数で、実際のComposeの関数に対して引き数にデータを渡してあげれば右側にデータが出てきます。上に書いてあるProviderが実際にこのPreviewに渡したいデータの一覧みたいな感じになっていて、これを増やしていくことでいろんな状態のPreviewをいっぱい作ることができたりします。</p> <p>で、これ受取一覧画面の受取のデータの種類をバーッとPreviewで作ってみたいやつなんですけどこれすごく便利でアプリで実機で実際に確認しようとするとこれ全部再現するのめちゃくちゃ大変なんですけど、Previewで確認するだけでこんなに簡単に作れるっていうのはすごく良かったなっていう感じです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161956.png" width="1200" height="674" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとはさっきのモジュールの分離の話をすると、モジュールはいっぱいわけています。で、Previewに必要なビルド範囲を最小限にしています。アプリのフルビルドって結構時間かかっていてクックパッドマートのアプリだと5分とか、最悪7分くらいかかったりするんですけど、フィーチャモジュールの一つだけのビルドとかだと数十秒程度で終わったりするので、これで開発速度が変わっているなという印象がありますね。</p> <p>あとは開発方針の話でいくつかの実装ルールを分けた話を軽くしたと思うんですけど、画面全体のことをScreenというふうにクックパッドマートでは呼んでいって、区切り線で囲まれた部分をSectionというふうに呼んでいます。あとはSectionをいくつかの単位でまとめたのをContentというふうに呼んでいるんですけど、実装に落としていくとContentが並んでいるみたいな実装だったり、ファイルを分けてそのContentの中身にはラベルにあるテキストとSectionとみたいな感じでそれぞれ分けているというふうにして、しかもこれに対してそれぞれでPreviewも作れるので画面全体の構築もそうだし画面の細かいところに対してのUIのPreviewもかなり簡単に作れるようになったっていうのがすごく大きい変化だったなというふうに思います。</p> <h3 id="これからの話">これからの話</h3> <p>ここまでが2022年ぐらいまで、僕らが今まで開発してきたUI開発の話だったんですけど2023年だからこれからの話でちょっとしようかなと思います。最近Relayっていうツールが気になっていて、これが何かと言うとfigmaのデータをComposeのコードに変換してくれるツールなんですね。で、この辺冷静に考えてみると結構面倒くさい作業で、デザイナーさんがfigmaでデザイン作ってくれて、我々アプリエンジニアがSwift UIだったりとかJetpack Composeでそれを実現するっていう開発フローになってるんですけど、これっていうのはfigmaとSwift UIとJetpack Composeで同じデザインで3回作っているっていうことになっていて、すごくだるくないかっていうふうに最近は考えています。逆に言うと、これをなくせればUIの開発って最高になるんじゃねって最近はちょっと考えたりしています。</p> <p>コード変換だったりコード生成だったりを行ってくれるツールっていろいろあると思うんですけど、このRelayってツールは何がいいかっていうとAndroid Developersが公式で出してるツールなんですよね。だからちょっと興味があって、ちょっと見てるんですけどまだまだな段階といえそうな予感はしています。</p> <p>ちょっと使ってみたんですけど、商品詳細画面のfigmaをComponentとしてRelayに喰わせるとComposeの関数を勝手に作ってくれます。今はレイアウトとか崩れちゃっているんですけど、フォントを調整して上手くいくととわりときれいに描画されたりとかして、結構簡単に作れてUI開発がわりとスムーズになるんじゃないかなというふうに妄想してるところです。</p> <p>Relayを簡単に触れてみた所感としてはfigmaのリンクを貼るだけでコードが生成されるので簡単に使えるっていうのはすごくよくて、デザインのアップデートをfigmaにして、実装も右クリックで簡単にアップデートできるのでいいんじゃないかなと思ってるんですけど、Android Studio上からアップデートするしかなくてCIとかで更新するのがちょっと難しそうっていうところはひとつネックかなと思ってます。あとはfigmaのデザインがAuto Layoutっていう結構きれいにかかなきゃいけないという仕組みを使わないといけなくて、それがちょっと面倒くさいなっていうところがあります。</p> <p>あと細かい制御が難しいっていうところがあるんですけど、割り切って使える場面はありそうかなと思っていて、共通Componentになるものだけ切り出すとか、そういうところはうまく使えるところがないかなって探しながら妄想しているところです。クックパッドマートでは細かく定義されてるものがあるので、小さなComponentぐらいだったらうまく使えないかなっていうのはちょっと妄想してたりします。</p> <p>クックパッドマートのAndroidのUI開発についてたくさん話してきたんですけど、Jetpack Composeはいいぞと今まで皆さんも聞いてきたと思うんですけど、Jetpack Composeはいいんですよ。特にLive Previewが最高にいいので、皆さんもぜひ使ってみてください。あとは最後に言ってた通りデザインをコードに写し込む写経みたいな作業をいつか終わらせられたらいいなと思っているところです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731162022.png" width="1200" height="603" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> cookpadtech iOS画像非同期取得 hatenablog://entry/820878482952300372 2023-07-25T09:00:00+09:00 2023-07-25T09:56:42+09:00 こんにちは、モバイル基盤のヴァンサン(@vincentisambart)です。 半年くらい前に、iOSクックパッドアプリで画像非同期取得を自作することになりました。導入してから何ヶ月も問題なく動いているので、どう動いているのか紹介しようと思います。でもその前に自作することになった経緯を説明しましょう。 自作経緯 長年画像非同期取得に既存のライブラリを使っていましたが、昨年ライブラリの不具合で画像の取得が稀に失敗していたバグがいくつかありました。バグが修正されて、その数ヶ月後にまた似た問題。 この状態が好ましくなかったので、以下の選択肢のどれかにしようと議論しました。 使っているライブラリのメン… <p>こんにちは、モバイル基盤のヴァンサン(<a href="https://twitter.com/vincentisambart">@vincentisambart</a>)です。</p> <p>半年くらい前に、iOSクックパッドアプリで画像非同期取得を自作することになりました。導入してから何ヶ月も問題なく動いているので、どう動いているのか紹介しようと思います。でもその前に自作することになった経緯を説明しましょう。</p> <h2 id="自作経緯">自作経緯</h2> <p>長年画像非同期取得に既存のライブラリを使っていましたが、昨年ライブラリの不具合で画像の取得が稀に失敗していたバグがいくつかありました。バグが修正されて、その数ヶ月後にまた似た問題。</p> <p>この状態が好ましくなかったので、以下の選択肢のどれかにしようと議論しました。</p> <ul> <li>使っているライブラリのメンテナンスにもっと直接参加する <ul> <li>コードが古くメンテナンスしやすくなさそうでした。</li> </ul> </li> <li>使っているライブラリのバージョンを固定する <ul> <li>自動的に更新をやめても、バグ修正や最新のOSの対応のために定期的に更新した方が良いでしょう。</li> </ul> </li> <li>別のライブラリにするか <ul> <li>選定が難しいでしょう。例えばアプリをリリースしないとライブラリの安定性が判断しづらいです。</li> </ul> </li> <li>自作するか</li> </ul> <p>ライブラリを使っていたものの、複雑な機能は使っていませんでした。必要だったのは画像のダウンロード、キャッシュ、<a href="https://ja.wikipedia.org/wiki/WebP">WebP</a>対応、くらいです。</p> <p>クックパッドでモバイルアプリの画像はWebPを使っているので、画像取得を自作することになっても、WebP読み込みにライブラリが必要だと思っていました。でも議論中に、iOS 14以降<code>UIImage</code>がWebPを読み込めるのが発覚しました。その時点で最新のiOSクックパッドアプリの最小サポートバージョンはすでにiOS 15でした。</p> <p>画像ダウンロードとキャッシュだけが必要なら自作してみても良いかもという結論になりました。</p> <h2 id="実装">実装</h2> <p>経緯の次は実装の詳細を説明しようと思います。その中で、まずは一番複雑そうなキャッシュの実装はどうしましょうか。</p> <h3 id="キャッシュ">キャッシュ</h3> <p>多くの画像非同期取得ライブラリがキャッシュを2段階で行います:</p> <ul> <li>取得された画像ファイルをディスクにキャッシュします。アプリを再起動してもデータは残ります(ただし端末に空き容量が足りなくなった場合、OSが一部を消すことがあります)。</li> <li>画像ファイルが読み込まれた<code>UIImage</code>をメモリ上にキャッシュします。もちろんアプリを再起動したら一掃されます。ディスクからのファイル読み込みも、画像データのデコードも、メインスレッドでやらない方が良いことですが、<code>UIImage</code>をメモリ上でキャッシュするとどっちも必要ないのでこのキャッシュは直接メインスレッドで扱えます。</li> </ul> <p>どちらのキャッシュの種類もできれば自分で実装したくありません。ディスクやメモリの空き容量、キャッシュの使っている容量、を気にする必要があるのはややこしそうです。でも実はFoundationにそのためのツールがあります。</p> <p>ダウンロードされるデータをキャッシュするために<a href="https://developer.apple.com/documentation/foundation/urlcache"><code>URLCache</code></a>があります。ダウンロードに使う<code>URLSession</code>の<code>configuration</code>に代入するだけでダウンロードするデータがキャッシュされるようになります。</p> <p>メモリ上でデータをキャッシュするために<a href="https://developer.apple.com/documentation/foundation/nscache"><code>NSCache</code></a>があります。使い方が<code>Dictionary</code>に近いです。キーと値が<code>AnyObject</code>であるべきなのでSwiftから使う場合少し不便な場面もありますが、今回キー(<code>URL</code>)を<code>NSURL</code>に簡単にキャストできるので、別のものにラップせずに<code>AnyOject</code>にできます。</p> <h3 id="API">API</h3> <p>キャッシュのためのツールが揃ったので、画像取得APIを見てみましょう。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">enum</span> <span class="synIdentifier">LoadingImage</span> { <span class="synStatement">case</span> cached(UIImage) <span class="synStatement">case</span> inProgress(Task<span class="synIdentifier">&lt;</span>UIImage, any Error<span class="synIdentifier">&gt;</span>) } <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synPreProc">func</span> <span class="synIdentifier">loadImage</span>(from imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">LoadingImage</span> { </pre> <p><code>Task</code>が見られるのでSwift Concurrencyが使われているのですが、asyncメソッドではありません。</p> <p>ビューの読み込みや表示時にOSから呼ばれるメソッド(<code>collectionView(_:cellForItemAt:)</code>、<code>viewDidLoad</code>、など)は基本的にasyncではありません。</p> <p><code>loadImage</code>の定義が<code>func loadImage(from imageURL: URL) async throws -&gt; UIImage</code>でしたら、asyncでないメソッドから呼ぶと新規タスクを作成する必要があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">viewDidLoad</span>() { Task { <span class="synType">@MainActor</span> <span class="synType">in</span> <span class="synComment">// このコードが`viewDidLoad`のタイミングで実行されるのではなく、</span> <span class="synComment">// `MainActor`が次回実行するようにキューされます。</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> await imageLoader.loadImage(from<span class="synSpecial">:</span> <span class="synType">imageURL</span>) imageView.image <span class="synIdentifier">=</span> image } } </pre> <p><code>loadImage</code>内で<code>await</code>をせずに<code>return</code>したとしても、同じ<code>MainActor</code>で実行される別のタスク内なので、<code>viewDidLoad</code>の後に実行されてしまいます。画像がメモリ上のキャッシュにあったとしても、ビューの最初に描写で画像が表示されない可能性があります。最初の描写で画像なし、次の描写で画像あり、はチカチカして雑に見えます。フェードインを使えば少しマシですが、すぐ表示できればしたいです。</p> <h2 id="loadImage"><code>loadImage</code></h2> <p>本来の<code>loadImage</code>のコードを見てみましょう。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synPreProc">func</span> <span class="synIdentifier">loadImage</span>(from imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">LoadingImage</span> { <span class="synComment">// 画像がメモリ上キャッシュに入っていれば、すぐ返します。</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> cachedImage(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) { <span class="synStatement">return</span> .cached(image) } <span class="synComment">// バックグラウンドタスクで取得とデコードを行います。</span> <span class="synComment">// `detached`を使うのはactorを引き継がないためです。</span> <span class="synStatement">return</span> .inProgress(Task.detached { <span class="synPreProc">let</span> <span class="synIdentifier">request</span> <span class="synIdentifier">=</span> URLRequest(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) <span class="synComment">// コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、</span> <span class="synComment">// 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。</span> <span class="synPreProc">let</span> <span class="synIdentifier">data</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> await <span class="synIdentifier">self</span>.loadData(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>) <span class="synStatement">return</span> <span class="synStatement">try</span> <span class="synIdentifier">self</span>.decode(data, <span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) }) } </pre> <p>ダウンロードをキャンセルしたければ、<code>inProgress()</code>に入ったタスクの<code>cancel()</code>メソッドを呼びます。<code>Task.init()</code>や今回のように<code>Task.detached()</code>を使うとstructured concurrencyではないので、<code>loadImage(from:)</code>の戻り値を放置してもタスクがキャンセルされることはありません。</p> <p>上記に使われている<code>cachedImage(for:)</code>が<code>NSCache</code>のメソッドを呼ぶだけです。<code>URL</code>が<code>AnyObject</code>ではないので、<code>NSCache</code>のキーに使うには<code>NSURL</code>にキャストする必要があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">memoryCache</span> <span class="synIdentifier">=</span> NSCache<span class="synIdentifier">&lt;</span>NSURL, UIImage<span class="synIdentifier">&gt;</span>() <span class="synPreProc">func</span> <span class="synIdentifier">cachedImage</span>(<span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">UIImage?</span> { memoryCache.object(forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) } </pre> <p><code>loadData(for:)</code>に関しては、<code>URLSession.data(for:)</code>をもう少し使いやすくするだけです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">session</span><span class="synSpecial">:</span> <span class="synType">URLSession</span> <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">loadData</span>(<span class="synStatement">for</span> request<span class="synSpecial">:</span> <span class="synType">URLRequest</span>) async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">Data</span> { <span class="synStatement">do</span> { <span class="synStatement">return</span> <span class="synStatement">try</span> await session.data(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>).<span class="synConstant">0</span> } <span class="synStatement">catch</span> { <span class="synComment">// タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、</span> <span class="synComment">// Swiftでは`CancellationError`がもっと自然だと思います。</span> <span class="synComment">// タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。</span> <span class="synStatement">try</span> Task.checkCancellation() <span class="synStatement">throw</span> error } } </pre> <p><code>decode(_:for:)</code>が<code>UIImage(data:)</code>をラップして、デコードされた画像を<code>NSCache</code>に入れてくれるだけです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">decode</span>(_ data<span class="synSpecial">:</span> <span class="synType">Data</span>, <span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">UIImage</span> { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> UIImage(data<span class="synSpecial">:</span> <span class="synType">data</span>) { memoryCache.setObject(image, forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) <span class="synStatement">return</span> image } <span class="synStatement">else</span> { <span class="synStatement">throw</span> InvalidImageDataError(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) } } </pre> <p><code>ImageLoader</code>の全コードをこの記事の一番下にまとめました。上記になかった<code>init</code>も含まれています。</p> <h2 id="使い方">使い方</h2> <p>上記に実装したAPIは基本的に以下のように使えば良いのではないでしょうか。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// 以前行われた取得が終わっていなければキャンセルします。</span> <span class="synComment">// キャンセルされる可能性ないなら、`loadTask`インスタンス変数は要らないでしょう。</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">loadTask</span> { loadTask.cancel() <span class="synIdentifier">self</span>.loadTask <span class="synIdentifier">=</span> <span class="synConstant">nil</span> } <span class="synComment">// 取得を始めます。</span> <span class="synStatement">switch</span> imageLoader.loadImage(from<span class="synSpecial">:</span> <span class="synType">imageURL</span>) { <span class="synStatement">case</span> <span class="synPreProc">let</span> .cached(image)<span class="synSpecial">:</span> <span class="synComment">// `image`をそのまま表示できます。</span> <span class="synStatement">case</span> <span class="synPreProc">let</span> .inProgress(loadTask)<span class="synSpecial">:</span> <span class="synComment">// キャンセルできるために`loadTask`をとっておきます。</span> <span class="synIdentifier">self</span>.loadTask <span class="synIdentifier">=</span> loadTask Task { <span class="synType">@MainActor</span> <span class="synSpecial">[</span><span class="synType">weak self</span><span class="synSpecial">]</span> <span class="synStatement">in</span> <span class="synStatement">do</span> { <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> await loadTask.value <span class="synComment">// 無事に画像を取得できたので表示できます。</span> } <span class="synStatement">catch</span> <span class="synStatement">is</span> <span class="synType">CancellationError</span> { <span class="synComment">// 待ち合わせていた`loadTask`がキャンセルされたので何もやるべきではありません。</span> } <span class="synStatement">catch</span> { <span class="synComment">// 取得が失敗したので、placeholderを表示することが多いです。</span> } } } </pre> <h2 id="移行">移行</h2> <p>iOSクックパッドアプリで以前使っていたライブラリから自作<code>ImageLoader</code>への移行が割とスムーズでした。<a href="https://techlife.cookpad.com/entry/2021/06/16/110000">モジュール化</a>によって画像読み込みが抽象化されていた場面多かったですし、ほとんどの画像が限られた数のビューに表示されています。</p> <p>画像取得がまだ抽象化されていなかったら、まず抽象化するか、新しい実装の上に以前のに近いAPIを用意するか、の2択ですかね。前者は抽象化が終わったら移行するけど、後者は新しいコードに移行してから以前のAPIの利用を少しずつ減らします。</p> <h2 id="最後に">最後に</h2> <p>iOSの既存のツールを使えば、シンプルな画像取得は割とシュッと実装できました。だからといって画像取得を自作した方が良いわけでもありません。アプリによって状況が違います。自作したら自分でメンテナンスする必要がありますし、必要になった機能も自分で実装します。</p> <p>自作するかどうか関係なく、画像取得が抽象化されていると、別のライブラリに移行しやすいので、いま直接ライブラリを使っていていて変える可能性があれば、とりあえず抽象化しても良いかもしれません。</p> <p>この記事のコードを元に自作するのでしたら、機能の追加が必要かもしれません。ここで紹介されていませんが、iOSクックパッドアプリでは、画像をprefetchする仕組みや、メモリ上キャッシュになかった画像を表示にフェードインで表示させる<code>FadeInImageView</code>(UIKit版)と<code>FadeInImage</code>(SwiftUI版)があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// This project is licensed under the MIT No Attribution license.</span> <span class="synComment">// </span> <span class="synComment">// Copyright (c) 2023 Cookpad Inc.</span> <span class="synComment">// </span> <span class="synComment">// Permission is hereby granted, free of charge, to any person obtaining a copy</span> <span class="synComment">// of this software and associated documentation files (the &quot;Software&quot;), to deal</span> <span class="synComment">// in the Software without restriction, including without limitation the rights</span> <span class="synComment">// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell</span> <span class="synComment">// copies of the Software, and to permit persons to whom the Software is</span> <span class="synComment">// furnished to do so.</span> <span class="synComment">//</span> <span class="synComment">// THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR</span> <span class="synComment">// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,</span> <span class="synComment">// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE</span> <span class="synComment">// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER</span> <span class="synComment">// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,</span> <span class="synComment">// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE</span> <span class="synComment">// SOFTWARE.</span> <span class="synPreProc">import</span> Foundation <span class="synPreProc">import</span> UIKit <span class="synStatement">public</span> <span class="synPreProc">enum</span> <span class="synIdentifier">LoadingImage</span> { <span class="synStatement">case</span> cached(UIImage) <span class="synStatement">case</span> inProgress(Task<span class="synIdentifier">&lt;</span>UIImage, any Error<span class="synIdentifier">&gt;</span>) } <span class="synStatement">public</span> <span class="synPreProc">struct</span> <span class="synIdentifier">InvalidImageDataError</span><span class="synSpecial">:</span> <span class="synType">Error</span> { <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">url</span><span class="synSpecial">:</span> <span class="synType">URL</span> <span class="synStatement">public</span> <span class="synIdentifier">init</span>(url<span class="synSpecial">:</span> <span class="synType">URL</span>) { <span class="synIdentifier">self</span>.url <span class="synIdentifier">=</span> url } } <span class="synStatement">public</span> <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">session</span><span class="synSpecial">:</span> <span class="synType">URLSession</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">memoryCache</span> <span class="synIdentifier">=</span> NSCache<span class="synIdentifier">&lt;</span>NSURL, UIImage<span class="synIdentifier">&gt;</span>() <span class="synStatement">public</span> <span class="synIdentifier">init</span>() { <span class="synPreProc">let</span> <span class="synIdentifier">configuration</span> <span class="synIdentifier">=</span> URLSessionConfiguration.<span class="synStatement">default</span> <span class="synPreProc">let</span> <span class="synIdentifier">cacheDirectoryURL</span><span class="synSpecial">:</span> <span class="synType">URL?</span> <span class="synStatement">do</span> { <span class="synPreProc">let</span> <span class="synIdentifier">systemCacheURL</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> FileManager.<span class="synStatement">default</span>.url(<span class="synStatement">for</span><span class="synSpecial">:</span> .cachesDirectory, <span class="synStatement">in</span><span class="synSpecial">:</span> .userDomainMask, appropriateFor<span class="synSpecial">:</span> <span class="synType">nil</span>, create<span class="synSpecial">:</span> <span class="synType">true</span>) cacheDirectoryURL <span class="synIdentifier">=</span> systemCacheURL.appendingPathComponent(<span class="synConstant">&quot;CookpadImageLoader&quot;</span>, isDirectory<span class="synSpecial">:</span> <span class="synType">true</span>) } <span class="synStatement">catch</span> { assertionFailure(<span class="synConstant">&quot;Could not create cache path: </span><span class="synSpecial">\(</span>error<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) cacheDirectoryURL <span class="synIdentifier">=</span> <span class="synConstant">nil</span> } <span class="synComment">// デフォルトでは`URLCache.shared`が使われますが、もう少しディスク容量を使える画像専用のを使います。</span> configuration.urlCache <span class="synIdentifier">=</span> URLCache( <span class="synComment">// `memoryCapacity`は試した限り0でも問題なく動きそうですが、一応念の為少しのメモリを割り当てます。</span> memoryCapacity<span class="synSpecial">:</span> <span class="synType">URLCache.shared.memoryCapacity</span>, diskCapacity<span class="synSpecial">:</span> <span class="synType">URLCache.shared.diskCapacity</span> <span class="synIdentifier">*</span> <span class="synConstant">4</span>, directory<span class="synSpecial">:</span> <span class="synType">cacheDirectoryURL</span> ) session <span class="synIdentifier">=</span> .<span class="synIdentifier">init</span>(configuration<span class="synSpecial">:</span> <span class="synType">configuration</span>) } <span class="synStatement">public</span> <span class="synPreProc">func</span> <span class="synIdentifier">cachedImage</span>(<span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">UIImage?</span> { memoryCache.object(forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">decode</span>(_ data<span class="synSpecial">:</span> <span class="synType">Data</span>, <span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">UIImage</span> { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> UIImage(data<span class="synSpecial">:</span> <span class="synType">data</span>) { memoryCache.setObject(image, forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) <span class="synStatement">return</span> image } <span class="synStatement">else</span> { <span class="synStatement">throw</span> InvalidImageDataError(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">loadData</span>(<span class="synStatement">for</span> request<span class="synSpecial">:</span> <span class="synType">URLRequest</span>) async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">Data</span> { <span class="synStatement">do</span> { <span class="synStatement">return</span> <span class="synStatement">try</span> await session.data(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>).<span class="synConstant">0</span> } <span class="synStatement">catch</span> { <span class="synComment">// タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、</span> <span class="synComment">// Swiftでは`CancellationError`がもっと自然だと思います。</span> <span class="synComment">// タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。</span> <span class="synStatement">try</span> Task.checkCancellation() <span class="synStatement">throw</span> error } } <span class="synStatement">public</span> <span class="synPreProc">func</span> <span class="synIdentifier">loadImage</span>(from imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">LoadingImage</span> { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> cachedImage(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) { <span class="synStatement">return</span> .cached(image) } <span class="synStatement">return</span> .inProgress(Task.detached { <span class="synPreProc">let</span> <span class="synIdentifier">request</span> <span class="synIdentifier">=</span> URLRequest(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) <span class="synComment">// コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、</span> <span class="synComment">// 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。</span> <span class="synPreProc">let</span> <span class="synIdentifier">data</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> await <span class="synIdentifier">self</span>.loadData(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>) <span class="synStatement">return</span> <span class="synStatement">try</span> <span class="synIdentifier">self</span>.decode(data, <span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) }) } } </pre> vincentisambart Project Googrename: Google Workspace で 14 年運用されたドメインエイリアスをプライマリドメインに変更 & 全ユーザーを安全にリネームする hatenablog://entry/820878482945343579 2023-06-28T17:04:51+09:00 2023-06-28T19:17:22+09:00 id:sora_h がクックパッドの Google Workspace でドメインエイリアスとして運用されていたものをプライマリドメインへ変更、全ユーザーのドメインも合わせて大規模なリネームを安全に実施した道のりを解説します。 <p><figure class="figure-image figure-image-fotolife" title="Google Workspace の Primary Domain Changed 画面のスクリーンショット"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160304.png" width="1200" height="501" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>コーポレートエンジニアリング部の <a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> です <a href="#f-55f9a260" name="fn-55f9a260" title="技術部 SRE グループが主務です。一応…">*1</a>。今回は 3 ヵ月ほど前に実施した、Google Workspace テナントのプライマリドメイン変更について、記録を兼ねて説明します。</p> <p>クックパッドは 2009 年頃 <a href="#f-d60ff08e" name="fn-d60ff08e" title="テナント自体は 2007 年から、業務に本格的に使われたのは 2009 年から、らしいですが詳細は不明です">*2</a> より Google Workspace <a href="#f-9eed22d0" name="fn-9eed22d0" title="当時は Google Apps">*3</a> を利用しています。当社の対外的なメールアドレスは cookpad.com ですが、Google ではプライマリドメインとして cookpad.jp が設定されています。各ユーザーには cookpad.com のアドレスを別名 (エイリアス) として登録されていて、メールアドレスとしては cookpad.com を利用、ただ Google へログインする時だけ cookpad.jp を利用する運用になっていました。想像が出来ると思いますが、これが様々な面で不便・混乱を発生させていました。どうしてこうなった… <a href="#f-6766843a" name="fn-6766843a" title="これも導入時期と同様に経緯は完全に損われていて不明、利用していないドメインで「一旦」設定してみて、そのまま本番利用されちゃったパターンと想像しています">*4</a>。</p> <p>この負債を解決すべく、2022/8 頃から緩やかに準備を始め、2023/3 上旬に全ユーザーのドメインとプライマリドメインを cookpad.com に変更しました。本稿では変更に踏み切った理由から下準備、当日~事後の作業について解説します。そこそこ長く様々なタスクがあったため乱雑な記事となっていますが、何かの役に立てば幸いです。</p> <ul class="table-of-contents"> <li><a href="#変更のモチベーション">変更のモチベーション</a></li> <li><a href="#Google-Workspace-におけるドメインとは">Google Workspace におけるドメインとは</a><ul> <li><a href="#エイリアス">エイリアス</a></li> <li><a href="#Cookpad-における-Google-Workspace-ドメイン設定">Cookpad における Google Workspace ドメイン設定</a></li> <li><a href="#プライマリドメインまで変更するかどうか">プライマリドメインまで変更するかどうか</a></li> </ul> </li> <li><a href="#目標-影響を最小に抑える">目標: 影響を最小に抑える</a></li> <li><a href="#プロジェクトの流れ">プロジェクトの流れ</a></li> <li><a href="#事前準備-各種サービス-SaaS-の影響確認">事前準備: 各種サービス (SaaS) の影響確認</a></li> <li><a href="#事前準備-内製システムの準備">事前準備: 内製システムの準備</a></li> <li><a href="#事前準備-メール受信ダウンタイムの影響を最小化する">事前準備: メール受信ダウンタイムの影響を最小化する</a></li> <li><a href="#事前準備-作業用データの準備">事前準備: 作業用データの準備</a></li> <li><a href="#作業当日-プライマリドメイン変更作業">作業当日: プライマリドメイン変更作業</a><ul> <li><a href="#直前作業-MXレコードの切り替え">直前作業: MXレコードの切り替え</a></li> <li><a href="#ドメインエイリアスとしての-cookpadcom-を削除">ドメインエイリアスとしての cookpad.com を削除</a></li> <li><a href="#セカンダリドメインとして-cookpadcom-を再追加">セカンダリドメインとして cookpad.com を再追加</a></li> <li><a href="#全ユーザーグループのドメインを-cookpadcom-に変更">全ユーザー・グループのドメインを cookpad.com に変更</a></li> <li><a href="#MXレコードの復旧">MXレコードの復旧</a></li> <li><a href="#プライマリドメインの変更">プライマリドメインの変更</a></li> <li><a href="#障害-メールエイリアスの考慮不足">障害: メールエイリアスの考慮不足</a></li> </ul> </li> <li><a href="#事後作業">事後作業</a><ul> <li><a href="#各種SaaSへの反映確認作業">各種SaaSへの反映・確認作業</a></li> <li><a href="#Asana">Asana</a></li> <li><a href="#Figma">Figma</a></li> <li><a href="#Zoom">Zoom</a></li> <li><a href="#Slack">Slack</a></li> <li><a href="#メールダウンタイムの事後報告">メールダウンタイムの事後報告</a></li> <li><a href="#DKIM-の再設定">DKIM の再設定</a></li> </ul> </li> <li><a href="#社内アナウンスについて">社内アナウンスについて</a></li> <li><a href="#振り返り">振り返り</a></li> <li><a href="#Acknowledgements">Acknowledgements</a></li> </ul> <h3 id="変更のモチベーション">変更のモチベーション</h3> <p><figure class="figure-image figure-image-fotolife" title="図: 作業前に撮影した筆者の Google アカウントの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160106.png" width="404" height="245" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図: 作業前に撮影した筆者の Google アカウントの様子</figcaption></figure></p> <p>これは多岐にわたるため、代表的なものを箇条書きで説明します:</p> <ul> <li>ユーザーから見てメールアドレスやアカウント名が cookpad.com と cookpad.jp の 2 種類を持つことになり混乱する <ul> <li>対外的なメールアドレスは cookpad.com であるべきだが cookpad.jp を利用してしまう</li> <li>逆に Google アカウントは cookpad.jp なのに cookpad.com を入力してエラーになってしまう</li> </ul> </li> <li>クックパッドは Azure AD を併用していて <a href="#f-f7cd5bb7" name="fn-f7cd5bb7" title="Intune の存在、SAML機能の柔軟性など諸々で併用になっていますが、本プロジェクトでやっとアドレスが統一され混乱を防げるようになったので Google Workspace は Azure AD からのフェデレーションに切り替えたい…!">*5</a>、そちらは cookpad.com であるため、同様に混乱する <ul> <li>Google ログインから Azure AD の SAML に切り替えた場合 SAML 上は cookpad.jp を名乗らせる必要がある (もしくは後述のように頑張って変更作業を行う)。</li> </ul> </li> <li>Slack が当初 Google ログインによって cookpad.jp の利用であったため <a href="#f-0a97cccc" name="fn-0a97cccc" title="Slack がまさに Enterprise Grid で SAML に変更になった時、cookpad.jp を SAML で Azure AD に名乗らせていた例">*6</a>、Slack Connect の招待を cookpad.com のアドレスへ送信されると Slack アプリ上でそれを受諾することができない、などなど各種アプリでの不都合</li> <li>cookpad.com (メールアドレス, Azure AD) と cookpad.jp (Google) の意味に違いがあるため、社内システム各所の実装で読み替えなどの考慮が必要 <ul> <li>各位の認識とその実装がまちまちで不具合が…</li> </ul> </li> <li>Google Workspace のプライマリドメインを変更できなくなる条件を満たしてしまうサービスを利用できない (Chrome OS の MDM など)</li> </ul> <p>ただし、本稿が非常に長いことから分かるように、丁寧な調査と作業を行わなければ様々な問題が発生してしまうため、腰がたいへん重いものでした。業務で Chrome OS を一部で利用していることから Chrome OS の MDM を検討するのが再検討のキッカケとして大きな要因でした。</p> <h3 id="Google-Workspace-におけるドメインとは">Google Workspace におけるドメインとは</h3> <p>前提として、Google Workspace におけるドメインについて軽く解説します。</p> <p>Google Workspace 配下のユーザーはテナントに登録されているドメインを利用します。各 Google Workspace テナントは複数のドメインが登録でき、それを配下のユーザーに設定します。</p> <p>登録されているドメインの 1 つをプライマリドメインに指定し、デフォルトかつテナントを示すドメインとして各所で利用されます。その上で、プライマリドメイン以外にセカンダリドメインも設定してユーザーごとに使い分けることが可能になっています。</p> <h4 id="エイリアス">エイリアス</h4> <p>Google Workspace ユーザー/グループにはエイリアスを設定できます。エイリアスとして(異なるユーザー名・ドメインを持つ)別のメールアドレスを追加し、そのメールアドレスでもメールを送受信できるようになります <a href="#f-2c3d8465" name="fn-2c3d8465" title="人の名前は姓名いずれも変わったり、ビジネスネームが異なったり、衝突したりするため、ユーザー名に命名規則を設けるのは非常におすすめしません。ひどい例では、イレギュラーがあるのにユーザー名の命名規則を鵜呑みにしてメールアドレスの推測を行うような実装も発生してしまいます。当社では記号の制約はありますが、任意のユーザー名を入社時にリクエストすることが出来るようになっています">*7</a>。</p> <p>そして、ユーザー・グループ単位に設定するエイリアスとは別にドメインエイリアスも利用できます。ドメインエイリアスはその名の通りドメイン全体に対して他のドメインを別名として登録するもので、ユーザー・グループにエイリアスとして登録せずとも、エイリアスが存在するドメインでアドレスが存在していれば、そのドメインエイリアスでもメールの送受信が可能になります。</p> <p>ドメインエイリアスはプライマリドメインを含むテナントのドメインに対して追加するものです。そのためドメインエイリアスはユーザーの(プライマリの)メールアドレスとして直接割り当てることが不可能になっています。</p> <h4 id="Cookpad-における-Google-Workspace-ドメイン設定">Cookpad における Google Workspace ドメイン設定</h4> <p>本プロジェクト以前の Google Workspace のドメインは、ざっくり以下のようになっていました。</p> <ul> <li>cookpad.jp (プライマリドメイン) <ul> <li>cookpad.com (ドメインエイリアス)</li> </ul> </li> <li>{その他個別のエイリアスで利用する前提の細々としたドメイン} <a href="#f-3576f14d" name="fn-3576f14d" title="昔は hostmaster@ でメールを受けて SSL 証明書発行の承認とかをしていたので、そういうやつのために色々追加されていました">*8</a></li> </ul> <p>冒頭で書いたように、cookpad.jp がプライマリドメイン・全ユーザー/グループのドメインに設定されています。cookpad.com をメールアドレスとして利用するため、cookpad.com は cookpad.jp に対するドメインエイリアスになっていました。</p> <p>本プロジェクトでは cookpad.com をプライマリドメインに変更することが最終目的です。察しの良い方は気付くと思いますが、変更先のドメインがドメインエイリアスとして既に設定されていることで、まあまあ苦労します。</p> <h4 id="プライマリドメインまで変更するかどうか">プライマリドメインまで変更するかどうか</h4> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsupport.google.com%2Fa%2Fanswer%2F7009324%3Fhl%3Den" title="Change your primary domain for Google Workspace - Google Workspace Admin Help" 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></p> <p>ユーザーが利用する = ユーザーの Google アカウントとなるドメインを変更するだけであればプライマリドメインの変更は必須ではありません。ただ、Google Cloud Platform の組織名など各所で混乱を残してしまうこと、変更しない理由もないこと、Google の OAuth2/OIDC の hd オプションの挙動がイマイチになる <a href="#f-5ad8fcdd" name="fn-5ad8fcdd" title="後述しますが、Google Workspace や Cloud Identity のドメインを元にアカウント選択画面の表示をフィルタしたりスキップ、また新規ログイン時にフォームにドメインをあらかじめ表示する機能。組織のプライマリドメイン != ユーザーのドメイン時の挙動が不定">*9</a>。ことから今回はユーザーのドメインに加え、プライマリドメインまで変更を行いました。</p> <p>プライマリドメインについては上記ヘルプに記載されているように「変更できない」条件が存在します。そのうちの大きな物が Chrome Enterprise や Chrome OS 端末をエンロールしているという点で、これはモチベーションの 1 つとなっていたため、後戻り出来なくなる前に済ませたかったため踏み切りました。</p> <h3 id="目標-影響を最小に抑える">目標: 影響を最小に抑える</h3> <p>ユーザー・グループのドメインを変更するだけなら Google Workspace のドキュメント通りに作業するだけなので簡単ですが、それだけで済むはずがありません。本プロジェクトには各種サービス断など影響を最小限に抑えることが大きな目標としてありました。これは下記が含まれます:</p> <ul> <li><strong>メール:</strong> 受信が出来ない時間を短く抑える。出来ない場合でもその影響を最小にする</li> <li><strong>ログイン連携の継続:</strong> Google アカウントでログインしている各種サービスへのアクセスを損わないようにする。ログインしたら新規ユーザーになってしまう、といったトラブルを回避する</li> <li><strong>混乱を避ける:</strong> 各種アナウンスをきっちり行いユーザーの混乱を避ける。特に強制ログアウトが発生した場合に備える</li> </ul> <p>特に Google の OAuth2/OIDC を利用してログインを行っている各種 SaaS への影響が心配でした。真っ当に実装していれば sub クレームを利用してユーザー検索を行う実装になるはず <a href="#f-0dca8a1f" name="fn-0dca8a1f" title="Google も sub claim を利用しろ、email は適さないゾと ドキュメントで明記しています。OIDC 仕様上でも sub クレーム以外での照合は non-conformant です https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability 。">*10</a> です。</p> <p>ただ、OIDC や OAuth2 + userinfo API で得た email を元にユーザーを検索している不届きなサービスは確実に存在します (しました)。万が一業務でそのようなサービスが利用されていた場合 (いました)、業務に影響が生じてしまうため、それらについては事前に対策を行う必要があります。</p> <h3 id="プロジェクトの流れ">プロジェクトの流れ</h3> <p>本プロジェクトは下記の手順で進行しました。冒頭に記載した通り 2022/8 ~ 2023/2 頃まで緩やかに準備、2023/3 上旬に一気に片付けるようなスケジュールです。</p> <ol> <li>事前準備: 各種サービス (SaaS)、社内(内製)システムへの影響を確認、必要な作業についてアナウンスを準備</li> <li>事前準備: 検証環境 (Google Workspace テナントなど) を用意して各種挙動のチェック、ユーザー・グループ情報変更に利用するスクリプトの準備</li> <li>各自作業: 事前にアナウンスした各種サービスで必要な対応を各自で実施</li> <li>直前作業: 各種サービスで必要な対応を実施</li> <li>本番作業: 本番 Google Workspace テナントの変更作業</li> <li>事後作業: 各種サービスで必要な対応を実施</li> <li>事前準備: 各種サービス (SaaS) の影響確認</li> </ol> <h3 id="事前準備-各種サービス-SaaS-の影響確認">事前準備: 各種サービス (SaaS) の影響確認</h3> <p>というわけで、本プロジェクトでまず最初に行ったのは各種サービスでの影響・必要なアクションを確認することです。プロジェクトの大半の期間は地道に順次この点検を行っていた気がします。</p> <p>Google Admin から利用履歴のある OAuth2 Client のリストを取得できるため、取得した上で業務で利用している・されていそうな <a href="#f-d41066f8" name="fn-d41066f8" title="シャドーITっぽいというよりかは、子会社で利用していて本体で関知していないサービス等がそこそこ">*11</a> サービスのリストを作成しました。およそ 30 サービスがリストアップされ、順次サポートに聞く、検証環境でちょっとアカウントリネームしてみて動作を試してみる…というのを地道に行います。</p> <p>すると、やはり下記のようにさまざまなパターンで対応を求められることが分かってきました。</p> <ul> <li>何も対応する必要はない (ログインしなおせばメールアドレスの変更が自動で反映される)</li> <li>事前に各自でメールアドレスを変更しておく必要がある</li> <li>事前に管理者が手動でメールアドレスを 1 ユーザーずつ変更して回る必要がある</li> <li>事前に管理者が新しいドメインを allowlist に追加したり所有権の検証を済ませておく必要がある</li> <li>管理者からドメイン変更を伝えたらまとめてサポートに変更してもらえる</li> <li>管理者で新旧が分かる csv をサポートに提出したら変更してもらえる</li> <li>管理者でユーザーを一度削除して再招待しなければいけない</li> <li>ログイン自体はリネーム後でも継続して行えるが、メールアドレス変更は手動で各自が行う必要がある</li> </ul> <p>はい、真面目に OIDC 仕様通りにログインを実装してないところがあまりにも多すぎて本当に困ります。OIDC プロバイダから得られるメールアドレスを信用してそれでしかマッチングをしないのはあまりにも雑実装すぎます <a href="#f-5fe8f86d" name="fn-5fe8f86d" title="そういえば最近 https://www.descope.com/blog/post/noauth とかありましたね。Google の場合は email_verified claim も見ていればいいけど、見ていないところも実はあったりしたんだろうか?">*12</a> 。</p> <p>今回のプライマリドメイン変更では新規にドメインを取得してそれを変更するわけではなく、10 年ほど平行して両ドメインともメールアドレスとして使ってきた <a href="#f-1bd2ca63" name="fn-1bd2ca63" title="変更元の cookpad.jp は意図せず使われてきてしまった、と言うのが正しいんですが">*13</a> 関係で、人によっては cookpad.jp → cookpad.com に管理者やサポートでリネームしようとしたらコンフリクトしてしまった! という事例がかなり発生しやすい状況です <a href="#f-f9abd0ca" name="fn-f9abd0ca" title="たとえば Google ログインを強制できず、パスワードでサインアップやアカウント招待を受諾してしまうとそうなってしまう">*14</a>。これについても問合せを行い、下記のようなパターンが分かってきました。</p> <ul> <li>本人にどちらかのアカウントを削除してもらう必要がある <a href="#f-2b9c1d76" name="fn-2b9c1d76" title="Google ログインできない場合でもパスワードリセットの手順でなんとかなったりする事が多かった">*15</a></li> <li>サポートがユーザーマージを行ってくれる <a href="#f-03a6a6da" name="fn-03a6a6da" title="ドメイン所有権の検証があるサービスだとお願いできたり、一度テナントに .jp, .com 両方のユーザーを入れてからだったり">*16</a></li> </ul> <p>そもそも企業が利用することを前提としているサービスでもドメインのリネームやユーザー名のリネームがある旨をサポートに伝えてもなかなか理解してもらえないことが多くて結構困ってしまうところです。ドメインだけじゃなくてユーザー名もわりと変わることがあるので…。SAML ならともかく OAuth2/OIDC を利用してるなら sub claim を適切に利用してほしい (重要な事なので何度でも書きます)。</p> <p>まず、事前に各自で作業が必要なサービスについては事前にリストアップ・作業内容を Wiki に記載して全社アナウンスで各自の点検を依頼します。各自で作業が必要ないサービスでも、変更作業中は新規ログインができない可能性があり、それについては全て手元の端末でログインができているか確認するように記述しました。</p> <p>当日に複雑な対応をしなければならなかったいくつかのサービスについては、後述する変更作業の節で解説します。</p> <p>なお、Google Workspace コアサービス以外の Google のサービスについては GCP を含めて現在まで大きな問題は報告されていません。こちらの事前調査もサポートを通して行いたかったところ、コアサービスでないことから Google Workspace のサポートからは返答を貰えず各サービスのサポートへ誘導され、各サービスのサポートからは Google Workspace のサポートへ誘導されました。そのため、一部の非コアサービスで重要な Google Analytics や Google Cloud Platform については検証環境で権限などが維持されることを確認して済ませました。</p> <p>※ 大きな問題はありませんでしたが、筆者のアカウントでは Google カレンダーが一切読み込まれない (Web だと白紙になる) という事象が発生しました。サポート曰く 24 時間は <em>propagation</em> にかかるらしかったので、おとなしく待ったら自然に解消してゆきました。</p> <h3 id="事前準備-内製システムの準備">事前準備: 内製システムの準備</h3> <p>社内で内製しているシステムはほとんど <a href="#f-a40f5a87" name="fn-a40f5a87" title="日本側の場合。UK オフィスが中心となっているグローバル事業は ALB + Azure AD OIDC だったりします">*17</a> が <a href="https://techlife.cookpad.com/entry/2018/04/02/140846">Hako で ECS にデプロイ</a> されていて、またユーザー認証はだいたい omniauth-google_oauth2 gem を利用しています。プライマリドメイン変更時の追従をスムーズに行うため、大半のパターンである Hako + Rails + omniauth-google_oauth2 について対応を検討しました。</p> <p>まず、omniauth-google_oauth2 gem では hd オプションに Google Workspace のドメインを指定することで omniauth strategy レベルでログイン可能なドメインを制限できます (hosted domain, hd claim の検証)。omniauth-google_oauth2 の hd オプション自体は複数のドメインを指定可能ですが、hd オプションは Google の OAuth2 認可エンドポイントへも引き継がれていて、こちらは複数の指定を受け付けません。hd オプションを利用するとアカウント選択画面で表示するアカウントを絞ったり、1つに絞れたら省略したり、また新規ログインの際もドメイン名が補完されるためユーザー体験上有益です。この利点を失いたくはありませんでした <a href="#f-9ce5d767" name="fn-9ce5d767" title="もちろん、一時的に hd に両方指定してもらって後で戻すというのもアリですが、後で戻してもらうことは必須ではないため、戻してもらえなかったところだけ不便なままという状況が予想できます">*18</a>。また、hd オプション以外でもドメインを検証している箇所もちらほら確認できました。</p> <p>プライマリドメインの変更作業は週末、日本時間の土曜朝に行いました。何も手を打たないのであれば各自で週明けに cookpad.jp を cookpad.com に書き換えてデプロイしたり、DB 上のレコードを書き換えてもらうというところですが、社内システムに週明けに各自で変更をデプロイするまで、新規ログインできずに週末中の業務が停止する事態は避けたいものです。</p> <p>検討した結果、AWS Systems Manager Parameter Store に現在の Google Workspace プライマリドメインを示す値を保存しました (作業前は cookpad.jp)。これを環境変数として ECS タスクに事前に入れてデプロイ、その環境変数が存在すれば hd オプションを含めプライマリドメインを示す値として利用するようにエンジニア全員に依頼をかけました。プライマリドメイン変更後、Parameter Store 上の更新して各 ECS サービスのタスクを再起動すればプライマリドメイン変更の反映が完了するという仕掛けです。</p> <p>これに加えて、DB などに保存されている Google アカウント名があれば cookpad.jp → cookpad.com にしてもらう必要があります <a href="#f-99f459b1" name="fn-99f459b1" title="もちろん認証は sub claim を利用して認証が行われるのが望ましいですが、各種フォームなどメールアドレスから引くことも当然あるため">*19</a>。アナウンスから作業直後の移行期間中はどちらが来てもいいように cookpad.jp を .com に読み替えてくれ、といった基本的な事も含めて確認をお願いしました。</p> <p><figure class="figure-image figure-image-fotolife" title="エンジニア組織全体へのアナウンス文面 (GitHub Issue)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160028.png" width="1103" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>エンジニア組織全体へのアナウンス文面 (GitHub Issue)</figcaption></figure></p> <p>プライマリドメイン変更作業後は用意した Parameter Store の値を更新し、ECS タスクを再起動することで反映させます。当社はほとんど Fargate の利用がなく EC2 をコンテナインスタンスとして利用しているので、社内向けサービス用の ECS クラスタに対応する Auto scaling group の instance refresh を実行しました。</p> <p>多数の ECS サービスに force new update を行うとクラスタ全体が乱れて必要以上のコンテナインスタンス数にになる、集積率が一時的に下がる、コンテナインスタンスの起動の待ちが逐次発生して時間がかかる、といったデメリットがあるため、instance refresh でコンテナインスタンスを全て入れ替えてタスクの再起動に替えるのがおすすめです。</p> <h3 id="事前準備-メール受信ダウンタイムの影響を最小化する">事前準備: メール受信ダウンタイムの影響を最小化する</h3> <p>次はメール受信についてです。前述したように、変更前の Google Workspace テナントは下記のようなドメイン構成になっていました:</p> <ul> <li>cookpad.jp (プライマリドメイン) <ul> <li>cookpad.com (ドメインエイリアス)</li> </ul> </li> </ul> <p>ドメインエイリアスを利用しているので、ユーザーを cookpad.jp ドメインで作成するともれなく cookpad.com のメールアドレスがエイリアスとして付いてくる状態です。</p> <p>そして変更作業後は、下記を満たしている必要があります。</p> <ul> <li>既存ユーザー・グループのドメインが cookpad.com に切り替わっている</li> <li>既存ユーザー・グループは引き続き cookpad.jp ドメインでもメールを受け取ることができる</li> </ul> <p>これを達成するためには、2パターンの案がありました:</p> <ul> <li>A案: 下記構成に変更する <ul> <li>cookpad.com (プライマリドメイン) <ul> <li>cookpad.jp (ドメインエイリアス)</li> </ul> </li> </ul> </li> <li>B案: 下記構成にした上で、全ユーザー・グループにエイリアスを個別に追加する <ul> <li>cookpad.com (プライマリドメイン)</li> <li>cookpad.jp (セカンダリドメイン)</li> </ul> </li> </ul> <p>長期的には、新規ユーザーは cookpad.jp のドメインでメールを受信する必要がないため、周辺サービス含めて変更が完了したタイミングで cookpad.jp ドメインでのメールアドレス付与を停止したいと考えていました。その場合 A 案だといつまでも引きずり、新規利用を完全に止めることができません。したがって今回は B 案を採用しました。</p> <p>ただし、A 案 B 案どちらにも問題があり、Google Workspace はドメインエイリアスをユーザー・グループに直接割り当て可能な「ドメイン」に変更する操作が存在しません。どちらの場合も、一度ドメインエイリアスを削除、ドメインとして再追加して所有権の検証をやり直さなければいけません。また、削除→追加は即座に行えず、削除完了をしばらく待つ必要があります。</p> <p>また、A 案だと cookpad.com へ変更し終えた後に cookpad.jp を削除、その上でエイリアスとして再追加する手順が必要なため作業時間が伸びてしまうのも問題と考えました。</p> <p>ドメインエイリアスを削除すると当然ながらエイリアスによって発生していたメールアドレスは存在しなくなります。したがって、変更先かつメールアドレスとしてはプライマリである cookpad.com ドメインでメールを一定時間受信できないダウンタイムが発生します。削除完了~ドメイン再追加~エイリアス再追加がどれくらいの時間になるかは予測ができません <a href="#f-ec846153" name="fn-ec846153" title="事前に検証用の Google Workspace 上で同じ数のユーザーを作成してみれば分かるかもしれませんが、アテにならない上に費用が無駄にかかってしまう…">*20</a>。このダウンタイムについてどうするかを考えました。</p> <p>人間同士のメール送受信であればあまり問題にはなりませんが、このダウンタイム中は Google の MTA がメールを受信した際、送信元のサーバにメールボックスが存在しない hard bounce (5.2.1 NoSuchUser) が返ることになります。送信元が supression list を持つようなシステムからのメールをダウンタイム中に受信して bounce した際、その対応として自動で supression list に入って以後のメールが送信されなくなる可能性があります。</p> <p>Supression list に入っていることが自分で確認できないサービスは多く、サポートに確認しなければいけないサービスが多数存在します。それを各位で思い出してもらってサポートに問い合わせなければならない…という状況にするのは避けたいものです。出来ることなら soft bounce にしてリトライを促したいと考えました。</p> <p>検討した結果、作業日前にダウンタイムが発生する cookpad.com の MX レコードの TTL を短くして、ダウンタイムが発生する前に一時的にデプロイした Postfix サーバーへ向けることにしました。以下のような設定で cookpad.com, cookpad.jp 宛のメールについては 4.3.2 の soft bounce になり、送信者にリトライを促せます。</p> <pre class="code" data-lang="" data-unlink># main.cf maillog_file = /dev/stdout debug_peer_level = 1 compatibility_level = 2 myhostname = inboundmx.googrename.cookpad.com mydomain = cookpad.com myorigin = googrename.cookpad.com mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain, cookpad.jp local_recipient_maps = # 4xx unknown_local_recipient_reject_code = 450 smtpd_recipient_restrictions = reject_unauth_destination, defer inet_interfaces = all smtp_tls_security_level = may smtpd_tls_security_level = may smtpd_tls_auth_only = yes smtpd_tls_key_file = /etc/postfix/cert/key.pem smtpd_tls_cert_file = /etc/postfix/cert/cert.pem smtpd_tls_CAfile = /etc/postfix/cert/chain.pem smtpd_tls_loglevel = 1 smtpd_tls_received_header = yes # tls cipher list や mandatory protocols は省略; Mozilla SSL Configuration Generator などを利用するのがオススメ https://ssl-config.mozilla.org/#server=postfix&amp;version=3.4.8&amp;config=intermediate&amp;openssl=1.1.1k&amp;guideline=5.7</pre> <p>これを利用すればメールを受信したことにせずデータを保存することもないので、完全ステートレスで運用可能です。検証環境で動作確認をしたところ、問題なくメール受信をリトライさせて遅延して受信できました。</p> <p>これは当社の標準的なプラットフォームである <a href="https://techlife.cookpad.com/entry/2018/04/02/140846">Amazon ECS へ Hako を利用</a> して作業数日前にデプロイしました。StartTLS がある都合、NLB の TLS 終端機能は利用できないため ECS タスクに TLS 証明書を渡す必要があります。TLS 証明書は当社の場合 ECS 上のシステムでは <a href="https://github.com/sorah/acmesmith">sorah/acmesmith</a> を利用して発行・コンテナ起動時に S3 から取得するのが一般的なため、その仕組みを利用しました。また、この設計で受信するだけであれば外部にメールを送信することはないため、AWS の OP25B 解除なども行っていません。</p> <p>なお、代案としては MX レコードの削除や MX レコード上で工夫することでリトライさせられないか検討しましたが、 <a href="https://datatracker.ietf.org/doc/html/rfc5321#section-5.1">RFC 5321 § 5.1. </a> や実際の挙動をいくつか確認する限りでも確実にリトライさせられるものではないと判断し、Postfix の設定に至っています。</p> <p>これでメールに関する準備も終わりました。</p> <h3 id="事前準備-作業用データの準備">事前準備: 作業用データの準備</h3> <p>作業数日前に、テナント内のユーザーリストを元に実作業で利用する各種データを作成しました。このため、実作業一週間前に IT ヘルプデスクや HR チームと連携の元、テナント内の新規ユーザー作成を一時的に保留・新規作成を止めてもらうようにお願いしています。</p> <p>作業で利用するデータは一部 SaaS のサポートへ提出するリネーム対応表、また Google Admin へインポートする CSV などがありました。dry-run を兼ねる、また terraform plan file のように反映内容を明らかにするため、可能であれば事前にデータを自動作成できるようにして確認しながら作業する意図で行っています。</p> <h3 id="作業当日-プライマリドメイン変更作業">作業当日: プライマリドメイン変更作業</h3> <p>日本時間の土曜朝から作業を開始、ざっくり下記の手順で進行しました。以下に解説します。</p> <ol> <li>直前作業: 一部社内サービスをメンテに入れたり、MXレコードを前述の Postfix に入れ替える</li> <li>ドメインエイリアス cookpad.com の削除 (ここでメール受信のダウンタイムが始まる)</li> <li>cookpad.com をドメインとして再追加</li> <li>全ユーザー・グループのドメインを cookpad.com に変更</li> <li>全ユーザー・グループのエイリアスに cookpad.jp を追加</li> <li>メール受信が可能であることを確認して MX レコードの復旧</li> <li>社内サービス向けの Parameter Store 上の値を変更して一斉反映</li> <li>プライマリドメインを cookpad.com へ変更</li> <li>各種 SaaS へ変更を反映</li> </ol> <p>これらの手順は事前に作業手順書を用意してログを記入しながら進行しました。また、作業開始前に Google のユーザーリストなどを一通りダウンロードしてバックアップしておきます。</p> <h4 id="直前作業-MXレコードの切り替え">直前作業: MXレコードの切り替え</h4> <p>まずはメール受信のダウンタイムに備えて MX レコードを前述の Postfix へ変更します。作業週の早いうちに予め TTL を短縮しておき迅速な反映を狙いましたが、しっかり問題なく切り換えが行われました。</p> <p>SMTP の各種確認では swaks が便利でよく利用しています。事前に OP25B が解除された IP アドレスを持つマシンやサーバを用意して対応する SPF レコードも作成しておき、swaks でメールを送信して動作チェックを進めました。</p> <pre class="code" data-lang="" data-unlink>swaks --from &#39;sorah@[REDACTED]&#39; --to &#39;sorah@cookpad.com&#39; --tls-optional-strict --tls-verify</pre> <h4 id="ドメインエイリアスとしての-cookpadcom-を削除">ドメインエイリアスとしての cookpad.com を削除</h4> <p><figure class="figure-image figure-image-fotolife" title="Google Workspace Admin のドメインエイリアス削除確認画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160004.png" width="1015" height="614" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>次は cookpad.com を Google Workspace のドメインエイリアスから削除します。最初に書いたように削除しなければ通常のドメインとして再追加ができないため行いますが、ここからメール送受信のダウンタイムが発生します。</p> <p>ドメインエイリアス削除実行後は即座に消えるわけではなくしばらく時間がかかります。(記憶が正しければ) ドメイン一覧からは即座に消失しますが、ユーザー詳細ページを確認するとユーザーから順次エイリアスが削除されていく様子が確認できます。</p> <p>こちらも swaks で状況を確認できます。MX レコードに指定される SMTP サーバへメールを直送すると、NoSuchUser エラーが返ってくることを確認しました。</p> <pre class="code" data-lang="" data-unlink>swaks --from &#39;sorah@[REDACTED]&#39; --to &#39;sorah@cookpad.com&#39; --server aspmx.l.google.com:25 --tls-optional-strict --tls-verify</pre> <h4 id="セカンダリドメインとして-cookpadcom-を再追加">セカンダリドメインとして cookpad.com を再追加</h4> <p>当社の環境ではおよそ 1 時間をかけてドメインエイリアスの削除が完全に完了しました。辞書順でエイリアスがユーザーから削除されていっているようだったので、辞書順で最後のユーザーからも消えたことを確認してから再追加を実行します。</p> <p>ドメインが一度削除されている都合、ドメイン所有権の再確認が必要です。こちらは DNS レコードを用いて速やかに完了させました。</p> <h4 id="全ユーザーグループのドメインを-cookpadcom-に変更">全ユーザー・グループのドメインを cookpad.com に変更</h4> <p>ドメインをテナントに再追加しただけでは、エイリアスではないため各所で利用され始めることはありません。今回の目的はプライマリドメインに加え全ユーザー・グループが cookpad.com を主たるアドレスとして利用する状況を作ることなので、次はその変更作業を行います。</p> <p>今回の作業では、ユーザーは Google Admin でエキスポートした CSV の New Primary Email 属性を埋めて CSV インポートすることで行いました。旧 Primary Email はエイリアスとして自動で残るため、この対応で問題なく変更を行うことができます。 <strong>ただし既存のエイリアスを除くため、実際には全て API で実施するのが無難です。</strong> この点については後述します。</p> <p>グループは Google Admin API を利用して一括更新を行いました。</p> <p>作業チームのアカウントについては後回しにして手動で実行しました。リネームによって過去には再ログインが必要になったりした記憶がありますが、2023/3 に確認した限りでは Android や iOS <a href="#f-f20f6dab" name="fn-f20f6dab" title="純正メール・カレンダーで利用する iOS 自体に登録されているアカウント情報。Google 製アプリでは不要だった">*21</a> 以外で再ログインを求められませんでした。強制ログアウトによって全ての端末でログイン不可能になるなど (人はパスワードを忘れるので) 大きなトラブルは回避できるため、これは嬉しい挙動でした。</p> <h4 id="MXレコードの復旧">MXレコードの復旧</h4> <p>cookpad.com ドメインで再度メールが受信できるようになったことを確認して、MXレコードを元に戻します。不測の事態に備え、このタイミングでは TTL は短いまま維持しておきます。</p> <p>メールの受信はレコードを変更せずともドメインエイリアス削除時の実行例と同様に swaks で確認可能です。</p> <h4 id="プライマリドメインの変更">プライマリドメインの変更</h4> <p>ここまで問題なく来たら後はテナントのプライマリドメインを変更するだけです。</p> <p>ただし、プライマリドメイン変更不可の条件に引っ掛かりすぐに変更することはできませんでした。 <a href="https://support.google.com/a/answer/7009324?hl=en">https://support.google.com/a/answer/7009324?hl=en</a> に記載されているように様々な条件があるのですが、変更時のエラーでは何がダメかは教えてもらえません。</p> <p><figure class="figure-image figure-image-fotolife" title="プライマリドメイン変更時のエラー画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160011.png" width="1040" height="695" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>プライマリドメイン変更時のエラー画面</figcaption></figure></p> <p>プライマリドメイン変更を行うか、行わないかはユーザーに(ほぼ)影響する話ではないため、ユーザー・グループの primary email address のドメインが変更されていれば問題はありません。ただ先述の通り完遂はさせたい、というところで対応を検討しました。</p> <p>予定されていた作業時間中の反映は諦めましたが、幸いにしてチャットサポートに問い合わせたところ迅速に回答してもらえました <a href="#f-5d6bda02" name="fn-5d6bda02" title="チャットサポートはログが残らない上その場で解決まで至らないイメージが強かったのですが、Google Workspace の英語サポートは割とその期待は上回った気がします">*22</a>。事前に目視して問題ないと思っていた Chrome Enterprise の試用ライセンス <a href="#f-1ea41bf8" name="fn-1ea41bf8" title="リセラーからトライアル目的で渡されたものと記憶していますがこれも詳細が不明…">*23</a> が実はアクティブで引っ掛かっていました。プライマリドメイン変更のために hold を依頼して、その日の深夜に改めて変更を完了しました。</p> <h4 id="障害-メールエイリアスの考慮不足">障害: メールエイリアスの考慮不足</h4> <p>作業が一息ついたところで考慮不足が発覚しました。ユーザー・グループのprimary email については cookpad.jp から .com へ変更されましたが、secondary email - つまり個別に付与しているエイリアスについて cookpad.com への変更が必要だったところ考慮から漏れていました。この状態だと cookpad.com の secondary email address へメールが着信した際に NoSuchUser で hard bounce が返ってしまいます。</p> <p>急ぎ MX レコードを再度 Postfix へ戻し、スクリプトを書いて復旧作業へ取り掛かりました。これについては既存のエイリアスで cookpad.jp ドメインのみ存在するアドレスを抽出し、cookpad.com ドメインのエイリアスとして追加しました。</p> <p>Email の変更は旧名の alias が自動生成されるから CSV で良いのでは? となってインポート用 CSV の生成部のコードレビューまでしたところ、お互いで完全に存在を忘れる大きめなミスとなってしまいました。この点のリカバリについては後述します。</p> <h3 id="事後作業">事後作業</h3> <p>Google Workspace テナント自体の作業はこれで完了ですが、ここからは事後に行った作業について解説します。</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/sora_h/20230628/20230628155957.png" width="840" height="679" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図: 本当にできたのかと驚く社員の様子</figcaption></figure></p> <h4 id="各種SaaSへの反映確認作業">各種SaaSへの反映・確認作業</h4> <p>各種 SaaS の管理者側作業が下記の通り存在しました。</p> <ul> <li>管理コンソールからユーザー情報を更新する</li> <li>サポート側リネーム完了を待つ (もしくは Google 側の完了を伝えて実施してもらう。いずれにせよ日程は事前に相談しておく)</li> <li>サポートに Google 側のリネームが完了した旨伝達する</li> </ul> <p>管理コンソールからユーザー情報を更新する場合、一括で出来ない例がほとんどでした。複数人で分担してひたすらメールアドレスの更新を実施しました。</p> <p>それ以外のいくつかの SaaS では事前に合意していた時間までにリネームが完了していない、または新ドメインである cookpad.com のアドレスに既にアカウントが存在してリネームできなかった (コンフリクト) といったトラブルがありました。</p> <p>本稿では対応が長引いた SaaS を中心に代表例を解説します。なお、もし本稿をプライマリドメイン変更のために参照している方がいれば、サービス側の実装や状況は変化する可能性があるため、本稿の内容は参考程度に留めて都度問合せや検証は行うようにしてください。</p> <h4 id="Asana">Asana</h4> <p>Asana はテナント(組織) ごとに複数のドメインを登録可能ですが、cookpad.jp と cookpad.com がそれぞれ独立した組織で存在している状態でした。</p> <p><figure class="figure-image figure-image-fotolife" title="Asana の組織切り替えメニュー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628155919.png" width="570" height="440" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Asana の組織切り替えメニュー</figcaption></figure></p> <p>社内横断的に動いている一部のチームが 2 つの組織を頻繁に切り替えなければならないというフィードバックがあったのも受け、こちらについては準備も兼ねて、Asana については 2023/1 頃に準備を始めました。Asana 側に組織の移行ガイドが用意されていたため、これに従って組織を 1 つにマージ <a href="#f-2a1bf5cc" name="fn-2a1bf5cc" title="実際にはマージではなく、統合先の組織にデータがコピーされる実装のようです">*24</a> しました。</p> <p><a href="https://asana.com/guide/help/organizations/data-migration">https://asana.com/guide/help/organizations/data-migration</a></p> <p>ユーザーのマージについては、ユーザーに cookpad.jp, cookpad.com 両方のメールアドレスを登録して検証を通してもらうことで自動で成されるようでした。マージが必要なユーザーは個別に連絡をして対応を依頼しました。</p> <h4 id="Figma">Figma</h4> <p>Figma は Google ログインについてメールアドレスをベースに実装されているようです。幸いにしてサポートが CSV を元にした一括のリネームを行ってもらえました。また、サポートに依頼するドメイン追加の手順が必要だったため、これは事前に済ませてあります。</p> <p>また、土日での作業については合意が得られなかったため、金曜のうちにリネームを実施してもらいました。この対応として、事前のアナウンスで業務で重要な場合は Figma のログイン状態を確認して、ログインできていなければログインをするように周知しました。</p> <p>実施した結果、コンフリクトが生じているユーザーがいましたが、それ以外は無事に完了しました。コンフリクトしたユーザーについては Figma サポートに空かどうか確認してもらい、空であれば削除してリネームを続行してもらうことができたので、そのように依頼しました。</p> <h4 id="Zoom">Zoom</h4> <p>Zoom に関しても同様の対応で CSV を元にリネームとなりました。ドメイン追加・検証を事前に行う点も同様です。</p> <p>こちらは事前に CSV を送付し作業日程まで合意していたところ、事前に内容を精査してもらえず、作業時間になってコンフリクトが発生したため全体の作業を中断したと報告されてしまいました。それに加え、事前に作成した全 Google ユーザーの新旧対照 CSV を送っていたのですが、実在する Zoom ユーザーに絞ってほしいとのリクエストがありました。事前に送っているのだから事前にチェックしておいてほしい………。</p> <p>コンフリクトについては Google ログインではなくそれ以外の手段 (メールアドレス+パスワード) でサインアップ・ログインしてしまった場合に発生します。このような問題が生じてしまうのも複数のドメインがある故と言えます。仮にユーザー本人が誤りに気付いてログインし直したとしても、作成されてしまったアカウントとユーザーはそのまま残置されるため、コンフリクトへ繋がるという訳でした。</p> <p>次に、残念ながらもともと休日に実施している関係でサポートから追加のレスが返ってくることはなく、大変困ったことになりました。 その場で検証を行ったところ、なかなか厄介なことが分かりました。特にコンフリクトがなければ Google ログイン後にメールアドレスを更新するか聞かれ、更新すれば Zoom ユーザーはリネームに問題なく追従できます。</p> <p>しかし、コンフリクトがある場合の挙動が非常に困りました。まず、 cookpad.com アドレスで Zoom ユーザーが既に存在する場合は容赦なくそちらへログインする事になり、下記の選択肢が提示されます:</p> <ol> <li>メールアドレスを変更する</li> <li>ドメインに対応するアカウントへ参加する</li> <li>無視して続行する</li> </ol> <p><a href="https://support.zoom.us/hc/en-us/articles/4405656980109-Advanced-Associated-Domain-configurations">https://support.zoom.us/hc/en-us/articles/4405656980109-Advanced-Associated-Domain-configurations</a> (実施当時よりユーザーが目にする画面の例が掲載されていてドキュメントが拡充されている!)</p> <p>これらの選択肢はアカウントに追加したドメインごとにカスタマイズ可能です。本プロジェクトの目標として「影響を最小限に抑える」を掲げているため、業務を支障なく継続できる必要があります。従って、これまで利用していた cookpad.jp の Zoom ユーザーへ復帰できるよう整える必要が生じました。</p> <p>検証の末、(3) 無視して続行する → 無料プラン (Basic) のアカウントを削除、という手順を辿り cookpad.com 名義の(1ユーザーしかいない)アカウントとユーザーを削除することで cookpad.jp ユーザーへログイン、リネームへ追従できることが判明しました。</p> <p>(2) のオプション (consolidation) については実施されると IT で管理するメインの Zoom アカウントへ参加し、cookpad.jp と cookpad.com 名義で重複するユーザーが存在してしまう上、自力での復帰が不可能になるため無効化しました。(1) のオプションのみ提示するカスタマイズも可能ですが、cookpad.jp の Zoom ユーザーが既に存在してそのメールアドレスには変更できない上、cookpad.com は誤って作成されたユーザーそのもので不可能なため見送りました。</p> <p>アカウント削除の操作はクリック数も多くやや不安だったため、日英両方でスクリーンショットも添えた作業手順のアナウンスページを追加で周知しました。</p> <p>調査の結果パスワードログイン → Google ログインへ切り替える際の確認画面も存在する事が判明し、フロー含めてやや複雑な文章になりましたが、その分丁寧に作成したため混乱なく乗り越えられました。週明けから IT ヘルプデスクに対応してもらったトラブルシュートで、Google/Zoom のクッキーを全削除しないと cookpad.com へ更新されない例などいくつか追加でケースが見つかりましたが、概ね問題なかったようです。</p> <p>設定変更までに誤ってアカウントが consolidate されてしまったパターンなど、メインの Zoom アカウントに cookpad.jp, cookpad.com 両ユーザーが存在してしまうケースが発生しましたが、これについては Zoom サポートに週明けにバルクでのリネームと合わせてマージ対応をお願いできました。</p> <h4 id="Slack">Slack</h4> <p>Slack に関しても Google ログインで運用を開始したため、現状は SAML ログインですが cookpad.jp から cookpad.com に変更する必要があります <a href="#f-71aa549b" name="fn-71aa549b" title="かなり前に Enterprise Grid プランに移行しているので現在は Azure AD から SAML ログイン、cookpad.jp を名乗らせています。Enterprise Grid 移行時にリネームを検討したところ当時は不可能だった覚え">*25</a>。今回サポートに聞いたところ SCIM を利用するのが早いかも、という提案を貰いました。</p> <p>スクリプトレスで済めば &amp; このタイミングで SCIM 導入ができれば嬉しいということで SCIM を導入する方向で決めましたが、検証から導入までまあまあ時間がかかってしまい、実は Slack について完了したのはつい先日の事です。その上で導入直後に SCIM v2 のリリースがされており、タイミングが悪かったですね…。</p> <p>Azure AD から SCIM を行っていて、cookpad.jp で最初にマッチングさせ cookpad.com に変更を行いました。Slack は user name から display name に方針を切り替えてだいぶ経ちますが、user name は API 上の各所に残っていて、SCIM も例外ではありません。SCIM のドキュメントにも記載されているように文字種・文字長制限を回避して指定する必要があります。</p> <p>SCIM 化にあたって問題だったのはこの userName を重複せずに設定することです。Display name はユーザーが自由に設定できるようにしたいため SCIM では渡さないことにしたため、新規にプロビジョンされたユーザーは userName を目にします。したがって、ここは機械的な値に設定できません。Azure AD テンプレートのデフォルトである email のローカルパートを採用することにしましたが、single/multi channel guest の userName とも衝突してはならないという所で何件か衝突が発生しました。これについては個別にエラーを確認して guest 側を別名に変更するという措置を行いました。</p> <h4 id="メールダウンタイムの事後報告">メールダウンタイムの事後報告</h4> <p>週明けまでに復旧できていないと困る SaaS 類の対応まで一段落したところで、メールダウンタイムの事後報告と、メールエイリアス障害の事後対応を行います。</p> <p>まずはダウンタイム中に着信したメールログを集計してユーザー・グループごとに着信した envelope from のリストを作成しました。CloudWatch Logs Insights で Postfix のログを下記クエリで抽出します:</p> <pre><code>fields @timestamp, @message, @logStream, @log | filter @message like /NOQUEUE/ | parse @message /RCPT from (?&lt;remote_mta&gt;.+?)\[(?&lt;remote_ip&gt;.+?)\]: (?&lt;response_code&gt;[0-9. ]+?) &lt;(?&lt;recipient_email&gt;.+?)&gt;: (?&lt;response_message&gt;.+?);/ | parse @message /; from=&lt;(?&lt;from_email&gt;.*?)&gt; to=&lt;(?&lt;to_email&gt;.+?)&gt; proto=(?&lt;proto&gt;.+?) helo=&lt;(?&lt;helo&gt;.+?)&gt;/ | filter from_email not like /bounces.google.com/ | stats count(*) as cnt by from_email, to_email | sort to_email asc, cnt desc | limit 10000 </code></pre> <p>これで得られる from_email は envelope from であるため、Google Sheets にインポートして各種配信サービスの動的に作成される envelope from を正規化、エイリアスを primary email address に変換、再集計のち CSV で出力しました。</p> <p>障害自体は wiki で周知の上、このデータを添えて障害の対象だったユーザー・それ以外で文面を分けてメールで個別にお知らせを送信しました。送信したデータがどれくらい役に立つかは分かりませんが、少なくとも障害がなかった場合についてはリトライで後から到着すべきメールが来ているかどうかは判断できるということで実施しています。</p> <p>また、エイリアスが削除されたことにより Gmail の Send mail as 設定も自動で剥奪されることを確認したため、再設定についても案内しました。</p> <h4 id="DKIM-の再設定">DKIM の再設定</h4> <p>これもメール関連で盲点の1つでしたが、ドメインエイリアスを削除した時点で DKIM の設定も消失します。実施後しばらくしてセキュリティチームのメンバーから DKIM 忘れてない? と言われて慌てて設定しました <a href="#f-4b6482da" name="fn-4b6482da" title="DMARC の reject ポリシー有効化のためにレポートを監視していたようです">*26</a>。再セットアップにより鍵長も現代的になったのでお得だったと思うことにします。</p> <h3 id="社内アナウンスについて">社内アナウンスについて</h3> <p>さて長々と書いてまいりましたが、最後に作業中および作業前後に実施した社内へのアナウンスについて言及しようと思います。今回はアカウントのリネームによって社内システムや SaaS にアクセス不可能になるリスクや、事前事後に各自の作業が存り、また既に書いているようにその他不測の事態が発生する可能性があったため、比較的丁寧にアナウンスを執筆しました。</p> <p>Slack での社内アナウンスはもちろん、事後 Zoom や Figma などリネーム・マージの積み残しが発生したため、常に状況が分かるページを更新し続けていました。</p> <p><figure class="figure-image figure-image-fotolife" title="各種アナウンス文面"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160036.png" width="1200" height="768" 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/s/sora_h/20230628/20230628155947.png" width="1200" height="780" 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/s/sora_h/20230628/20230628160101.png" width="1200" height="158" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>各種アナウンス文面</figcaption></figure></p> <p>今回、リネーム後にいくつかトラブルがあったり、想定スケジュール通りに SaaS 含めリネームが完了しないといった計画外の出来事がありましたが、実は日本・グローバル通してほぼ問い合わせがありませんでした。丁寧なドキュメントや簡潔なアナウンスが混乱を防ぎセルフサービスで解決も出来るということを改めて実感できるプロジェクトとなりました。</p> <h3 id="振り返り">振り返り</h3> <p>さて、以上が Google Workspace プライマリドメイン変更プロジェクトの解説となります。まとめと振り返りしては下記でしょうか。</p> <ul> <li><strong>Google Workspace 外のサービスへの影響を調査するのがとにかく大変</strong> <ul> <li>Relying Party 各位には OIDC の適切な実装をお願いしたい。ドメイン以外でもメールアドレスは普通に変わりうるもの。</li> <li>最近 <a href="https://techcommunity.microsoft.com/t5/microsoft-entra-azure-ad-blog/the-false-identifier-anti-pattern/ba-p/3846013">The False Identifier Anti-pattern</a> といういい記事が Microsoft Entra チームから出ていたのでオススメです。</li> <li>SAML ログインに対応するのは必須ではない <a href="#f-b01be3ce" name="fn-b01be3ce" title="しかし SAML が嫌なら任意の OIDC クライアントを設定させてほしい…">*27</a> として、Google ログインなどを強制するオプションは欲しい。それはそれとしてサポートコストが掛かるのは分かるが <a href="https://sso.tax">SSO を有償オプションにしないでほしい</a>。</li> </ul> </li> <li><strong>作業日程を JST 土曜朝にしたが、金曜深夜にするべきだった</strong> <ul> <li>各種サービスのサポートが米国西海岸時間 (PT) で稼動しているため</li> <li>そもそも本件をまったく理解してもらえない <a href="#f-8aafcc3a" name="fn-8aafcc3a" title="実際説明に苦労したサービスはたくさんあった">*28</a> 、対応が複雑、といった理由で英語話者へエスカレされることも想定し、日本国外で開発されているサービスへの問合せは日本語サポートがあろうとも全て英語で行っていたのもある。もちろん日本語サポートが無い場合も多数だった。</li> <li>当社で社内システムメンテ時に考慮すべきタイムゾーンは UK 時間と日本時間。いずれにも被っていない PT、かつ平日に実施すればスムーズにサポートに作業を行ってもらえたり、リアルタイムに連携ができたと思われる</li> <li>また、土曜作業で水曜に Google Workspace を含めた IdP のフリーズを宣言したが、サポートとの TZ 差による RTT を考慮して月曜にはフリーズしておくべきだった</li> </ul> </li> <li><strong>プライマリドメインの変更可・不可は事前に確認しておくべきだった</strong> <ul> <li>正確には途中で(諸事情で)タスクがこぼれてしまった… そしてこれについてはサポートに問い合わせるしかないのが大変なところ</li> </ul> </li> <li><strong>ほとんどのメールについてロストを簡単な仕掛けで防げたのは良かった</strong> <ul> <li>ただしエイリアスを見落としてメールのエイリアスのみ障害を起こしてしまった</li> </ul> </li> <li><strong>日本・グローバルチーム両方で実施後、週明けからのトラブルやユーザー問合せはほぼ無しを達成できて良かった</strong> <ul> <li>丁寧なドキュメント・簡潔なアナウンスが助けたと思われる</li> <li>英日両方でドキュメントを用意したり、スクリーンショットまで英日で分けるのは作業負荷としては割と大変ではある</li> <li>また、変更によって強制ログアウトが発生しないのが良かった。モバイルでも iOS/iPadOS では Google 製アプリは何もなく、Android でもパスワードのみでメールアドレスまで求められることがなかった</li> </ul> </li> </ul> <p>総作業時間としてはプロジェクト終盤に集中していたとはいえ、2022/8 ~ 2023/3、Slack などを含めれば 2023/6 までと長期間にわたり本当に大変だったので仮ドメイン名で運用を開始するのは本当におすすめしません <a href="#f-3eff14a5" name="fn-3eff14a5" title="関係ないですが Active Directory (Windows Server) のドメインもちゃんとしたドメインを使うのが良いです。public TLD のサブドメインかなにかを設定した上で(それもKerberos Realmになるのでユーザーに分かりやすいドメインの下が良いと思います)、UPN suffix をメールアドレスに合わせましょう。">*29</a>。</p> <h3 id="Acknowledgements">Acknowledgements</h3> <p>本プロジェクトは筆者 (sorah) だけでは手が到底足りなかったため、<a href="https://www.i-style.jp/">株式会社I-Style</a> の板垣 崇司さま, 菅井 祐太朗 (<a href="http://blog.hatena.ne.jp/hokkai7go/">id:hokkai7go</a>) さま, 中田 健史さまに事前調査やスクリプトの作成・検証のご協力をいただきました。</p> <p>また下記の記事を先行事例として参考にさせていただきました。やれば出来るんだ…! というところで踏み切るキッカケにもなりました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.pepabo.com%2F2022%2F01%2F19%2Fprimary-domain-change%2F" title="Google Workspaceのプライマリドメイン変更を実施しました - Pepabo Tech Portal" 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></p> <p>また、こちらは同様の懸念で別テナントへの引っ越しを行った事例だそうです。本稿のように頑張れば同じアカウントを維持してリネームすることもできますが、興味深い事例として紹介します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fproduct.st.inc%2Fentry%2F2023%2F04%2F03%2F123846" title="社名変更に伴うGoogle Workspaceアカウントの引越し手順を解説します - STORES Product 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></p> <p>みなさまがこの記事を役に立てないことを願いつつ終わりたいと思いますが、何かの役に立てば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-55f9a260" name="f-55f9a260" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">技術部 SRE グループが主務です。一応…</span></p> <p class="footnote"><a href="#fn-d60ff08e" name="f-d60ff08e" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">テナント自体は 2007 年から、業務に本格的に使われたのは 2009 年から、らしいですが詳細は不明です</span></p> <p class="footnote"><a href="#fn-9eed22d0" name="f-9eed22d0" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">当時は Google Apps</span></p> <p class="footnote"><a href="#fn-6766843a" name="f-6766843a" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">これも導入時期と同様に経緯は完全に損われていて不明、利用していないドメインで「一旦」設定してみて、そのまま本番利用されちゃったパターンと想像しています</span></p> <p class="footnote"><a href="#fn-f7cd5bb7" name="f-f7cd5bb7" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">Intune の存在、SAML機能の柔軟性など諸々で併用になっていますが、本プロジェクトでやっとアドレスが統一され混乱を防げるようになったので Google Workspace は Azure AD からのフェデレーションに切り替えたい…!</span></p> <p class="footnote"><a href="#fn-0a97cccc" name="f-0a97cccc" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">Slack がまさに Enterprise Grid で SAML に変更になった時、cookpad.jp を SAML で Azure AD に名乗らせていた例</span></p> <p class="footnote"><a href="#fn-2c3d8465" name="f-2c3d8465" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">人の名前は姓名いずれも変わったり、ビジネスネームが異なったり、衝突したりするため、ユーザー名に命名規則を設けるのは非常におすすめしません。ひどい例では、イレギュラーがあるのにユーザー名の命名規則を鵜呑みにしてメールアドレスの推測を行うような実装も発生してしまいます。当社では記号の制約はありますが、任意のユーザー名を入社時にリクエストすることが出来るようになっています</span></p> <p class="footnote"><a href="#fn-3576f14d" name="f-3576f14d" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">昔は hostmaster@ でメールを受けて SSL 証明書発行の承認とかをしていたので、そういうやつのために色々追加されていました</span></p> <p class="footnote"><a href="#fn-5ad8fcdd" name="f-5ad8fcdd" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text">後述しますが、Google Workspace や Cloud Identity のドメインを元にアカウント選択画面の表示をフィルタしたりスキップ、また新規ログイン時にフォームにドメインをあらかじめ表示する機能。組織のプライマリドメイン != ユーザーのドメイン時の挙動が不定</span></p> <p class="footnote"><a href="#fn-0dca8a1f" name="f-0dca8a1f" class="footnote-number">*10</a><span class="footnote-delimiter">:</span><span class="footnote-text">Google も sub claim を利用しろ、email は適さないゾと <a href="https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo">ドキュメントで明記しています</a>。OIDC 仕様上でも sub クレーム以外での照合は non-conformant です <a href="https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability">https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability</a> 。</span></p> <p class="footnote"><a href="#fn-d41066f8" name="f-d41066f8" class="footnote-number">*11</a><span class="footnote-delimiter">:</span><span class="footnote-text">シャドーITっぽいというよりかは、子会社で利用していて本体で関知していないサービス等がそこそこ</span></p> <p class="footnote"><a href="#fn-5fe8f86d" name="f-5fe8f86d" class="footnote-number">*12</a><span class="footnote-delimiter">:</span><span class="footnote-text">そういえば最近 <a href="https://www.descope.com/blog/post/noauth">https://www.descope.com/blog/post/noauth</a> とかありましたね。Google の場合は email_verified claim も見ていればいいけど、見ていないところも実はあったりしたんだろうか?</span></p> <p class="footnote"><a href="#fn-1bd2ca63" name="f-1bd2ca63" class="footnote-number">*13</a><span class="footnote-delimiter">:</span><span class="footnote-text">変更元の cookpad.jp は意図せず使われてきてしまった、と言うのが正しいんですが</span></p> <p class="footnote"><a href="#fn-f9abd0ca" name="f-f9abd0ca" class="footnote-number">*14</a><span class="footnote-delimiter">:</span><span class="footnote-text">たとえば Google ログインを強制できず、パスワードでサインアップやアカウント招待を受諾してしまうとそうなってしまう</span></p> <p class="footnote"><a href="#fn-2b9c1d76" name="f-2b9c1d76" class="footnote-number">*15</a><span class="footnote-delimiter">:</span><span class="footnote-text">Google ログインできない場合でもパスワードリセットの手順でなんとかなったりする事が多かった</span></p> <p class="footnote"><a href="#fn-03a6a6da" name="f-03a6a6da" class="footnote-number">*16</a><span class="footnote-delimiter">:</span><span class="footnote-text">ドメイン所有権の検証があるサービスだとお願いできたり、一度テナントに .jp, .com 両方のユーザーを入れてからだったり</span></p> <p class="footnote"><a href="#fn-a40f5a87" name="f-a40f5a87" class="footnote-number">*17</a><span class="footnote-delimiter">:</span><span class="footnote-text">日本側の場合。UK オフィスが中心となっているグローバル事業は ALB + Azure AD OIDC だったりします</span></p> <p class="footnote"><a href="#fn-9ce5d767" name="f-9ce5d767" class="footnote-number">*18</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん、一時的に hd に両方指定してもらって後で戻すというのもアリですが、後で戻してもらうことは必須ではないため、戻してもらえなかったところだけ不便なままという状況が予想できます</span></p> <p class="footnote"><a href="#fn-99f459b1" name="f-99f459b1" class="footnote-number">*19</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん認証は sub claim を利用して認証が行われるのが望ましいですが、各種フォームなどメールアドレスから引くことも当然あるため</span></p> <p class="footnote"><a href="#fn-ec846153" name="f-ec846153" class="footnote-number">*20</a><span class="footnote-delimiter">:</span><span class="footnote-text">事前に検証用の Google Workspace 上で同じ数のユーザーを作成してみれば分かるかもしれませんが、アテにならない上に費用が無駄にかかってしまう…</span></p> <p class="footnote"><a href="#fn-f20f6dab" name="f-f20f6dab" class="footnote-number">*21</a><span class="footnote-delimiter">:</span><span class="footnote-text">純正メール・カレンダーで利用する iOS 自体に登録されているアカウント情報。Google 製アプリでは不要だった</span></p> <p class="footnote"><a href="#fn-5d6bda02" name="f-5d6bda02" class="footnote-number">*22</a><span class="footnote-delimiter">:</span><span class="footnote-text">チャットサポートはログが残らない上その場で解決まで至らないイメージが強かったのですが、Google Workspace の英語サポートは割とその期待は上回った気がします</span></p> <p class="footnote"><a href="#fn-1ea41bf8" name="f-1ea41bf8" class="footnote-number">*23</a><span class="footnote-delimiter">:</span><span class="footnote-text">リセラーからトライアル目的で渡されたものと記憶していますがこれも詳細が不明…</span></p> <p class="footnote"><a href="#fn-2a1bf5cc" name="f-2a1bf5cc" class="footnote-number">*24</a><span class="footnote-delimiter">:</span><span class="footnote-text">実際にはマージではなく、統合先の組織にデータがコピーされる実装のようです</span></p> <p class="footnote"><a href="#fn-71aa549b" name="f-71aa549b" class="footnote-number">*25</a><span class="footnote-delimiter">:</span><span class="footnote-text">かなり前に Enterprise Grid プランに移行しているので現在は Azure AD から SAML ログイン、cookpad.jp を名乗らせています。Enterprise Grid 移行時にリネームを検討したところ当時は不可能だった覚え</span></p> <p class="footnote"><a href="#fn-4b6482da" name="f-4b6482da" class="footnote-number">*26</a><span class="footnote-delimiter">:</span><span class="footnote-text">DMARC の reject ポリシー有効化のためにレポートを監視していたようです</span></p> <p class="footnote"><a href="#fn-b01be3ce" name="f-b01be3ce" class="footnote-number">*27</a><span class="footnote-delimiter">:</span><span class="footnote-text">しかし SAML が嫌なら任意の OIDC クライアントを設定させてほしい…</span></p> <p class="footnote"><a href="#fn-8aafcc3a" name="f-8aafcc3a" class="footnote-number">*28</a><span class="footnote-delimiter">:</span><span class="footnote-text">実際説明に苦労したサービスはたくさんあった</span></p> <p class="footnote"><a href="#fn-3eff14a5" name="f-3eff14a5" class="footnote-number">*29</a><span class="footnote-delimiter">:</span><span class="footnote-text">関係ないですが Active Directory (Windows Server) のドメインもちゃんとしたドメインを使うのが良いです。public TLD のサブドメインかなにかを設定した上で(それもKerberos Realmになるのでユーザーに分かりやすいドメインの下が良いと思います)、UPN suffix をメールアドレスに合わせましょう。</span></p> </div> sora_h Path Drawing in SwiftUI hatenablog://entry/820878482943197956 2023-06-21T16:25:23+09:00 2023-06-21T16:25:23+09:00 How to draw shapes using paths in SwiftUI, starting from the basics. <p>Hi, this is Chris Trott (<a href="https://twitter.com/twocentstudios">@twocentstudios</a>) from Cookpad Mart's iOS team.</p> <p>In this post I want to share a few tips for how to draw shapes using paths in SwiftUI, starting from the basics. The code in this post targets iOS 16 and Xcode 14, but is low-level enough that it should be relatively forward and backward compatible.</p> <p>Drawing paths manually is not a common task in day-to-day app work. It can be especially tedious for complex shapes. However, it can sometimes be a most prudent choice over bitmaps or 3rd party rendering libraries.</p> <p>You can view the complete code from this post from <a href="https://gist.github.com/twocentstudios/6deb870942ce0b69816c7550c73a3a14">this gist</a>.</p> <h1 id="Contents">Contents</h1> <ul> <li>Basic shapes</li> <li>Styling</li> <li>Drawing line-by-line</li> <li>How to use arcs</li> <li>How to use quadratic bezier curves</li> <li>Path operations</li> <li>Creating a chat bubble shape</li> <li>Trimming a path</li> <li>Transition animations</li> </ul> <h1 id="Basic-shapes">Basic shapes</h1> <p>SwiftUI has a protocol <code>Shape</code> – both conforming to, and conceptually similar to <code>View</code> – that we can use to draw custom shapes.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">protocol</span> <span class="synIdentifier">Shape</span> <span class="synSpecial">:</span> <span class="synType">Animatable</span>, View </pre> <p>It has one requirement: a function that takes a <code>CGRect</code> and returns a <code>Path</code>.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synPreProc">struct</span> <span class="synIdentifier">MyCustomShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synComment">/// </span><span class="synTodo">TODO</span><span class="synComment">: return a `Path`</span> } } </pre> <p>As we'll see later, the input <code>rect</code> is determined by SwiftUI's <a href="https://kean.blog/post/swiftui-layout-system#layout-process">layout system rules</a>, but the path we return can draw anywhere, including outside the bounds of <code>rect</code>.</p> <p><code>Path</code> is SwiftUI's drawing command primitive, while UIKit has <code>UIBezierPath</code> and CoreGraphics has <code>CGPath</code>. All are similar, but not quite the same.</p> <p>Let's use SwiftUI's <code>Path</code> primitives to make a simple rounded rectangle.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { Path(roundedRect<span class="synSpecial">:</span> <span class="synType">rect</span>, cornerRadius<span class="synSpecial">:</span> <span class="synConstant">20</span>) } } </pre> <p>Of course this is the same as SwiftUI's built-in shape:</p> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectangle(cornerRadius<span class="synSpecial">:</span> <span class="synConstant">20</span>) </pre> <p>A <code>Shape</code> has only a "<a href="https://developer.apple.com/documentation/swiftui/shape">default fill</a> based on the foreground color", so let's add a SwiftUI Preview for it.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { RoundedRectShape() .fill(.gray) .frame(width<span class="synSpecial">:</span> <span class="synConstant">200</span>, height<span class="synSpecial">:</span> <span class="synConstant">150</span>) .padding(<span class="synConstant">50</span>) .previewLayout(.sizeThatFits) } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182416.png" width="654" height="554" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Why not make a custom view modifier for making previewing more convenient:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">extension</span> <span class="synIdentifier">View</span> { <span class="synType">@ViewBuilder</span> <span class="synType">func</span> previewingShape() <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { frame(width<span class="synSpecial">:</span> <span class="synConstant">200</span>, height<span class="synSpecial">:</span> <span class="synConstant">150</span>) .padding(<span class="synConstant">50</span>) .previewLayout(.sizeThatFits) } } <span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { RoundedRectShape() .fill(.gray) .previewingShape() } } </pre> <h1 id="Styling">Styling</h1> <p>Before we get too far into the weeds with <code>Path</code>, we should take a look at basic <code>Shape</code> styling. Otherwise, how will we be able to see what we're drawing?</p> <p>We can either stroke <em>or</em> fill a shape instance, but not both. This is because <code>.stroke</code> and <code>.fill</code> are both defined on <code>Shape</code> but return a <code>View</code>.</p> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectShape() .fill(.gray) </pre> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectShape() .stroke(.gray) </pre> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectShape() .stroke(.gray) .fill(.gray) <span class="synComment">// Error: Value of type 'some View' has no member 'fill'</span> </pre> <table> <thead> <tr> <th>Fill</th> <th>Stroke</th> </tr> </thead> <tbody> <tr> <td><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182416.png" width="654" height="554" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></td> <td><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182842.png" width="658" height="552" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></td> </tr> </tbody> </table> <p><a href="https://www.hackingwithswift.com/quick-start/swiftui/how-to-fill-and-stroke-shapes-at-the-same-time">To do both</a>, we need to layer two separate instances of the shape:</p> <pre class="code lang-swift" data-lang="swift" data-unlink>ZStack { RoundedRectShape() .fill(.gray) RoundedRectShape() .stroke(Color.black, lineWidth<span class="synSpecial">:</span> <span class="synConstant">4</span>) } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182909.png" width="652" height="554" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Drawing-line-by-line">Drawing line-by-line</h1> <p>We draw a path line-by-line or curve-by-curve as if we were describing pen strokes to a friend.</p> <ul> <li><code>move(to:)</code> moves the "cursor" without drawing.</li> <li><code>addLine(to:)</code> draws a line from current "cursor" to the the <code>to</code> point.</li> <li><code>closeSubpath()</code> marks the subpath as closed by drawing a line from the "cursor" back to the start point if necessary.</li> </ul> <blockquote><p><strong>Note</strong>: it's required to call <code>move(to:)</code> before adding a line or curve. Otherwise the path will not appear. When adding a complete subpath like <code>addEllipse(in:)</code>, <code>move(to:)</code> is <em>not</em> required.</p></blockquote> <p>Let's draw a banner shape, starting from the bottom left corner:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">BannerShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.midY</span>)) p.closeSubpath() } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182953.png" width="658" height="462" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Since we're using the <code>rect</code> parameter to specify our drawing points, the shape will always be <em>relative</em> to the size of the view.</p> <p>We could also specify <code>absolute</code> coordinates:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">BannerAbsoluteShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synConstant">10</span>, y<span class="synSpecial">:</span> <span class="synConstant">50</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synConstant">10</span>, y<span class="synSpecial">:</span> <span class="synConstant">10</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synConstant">100</span>, y<span class="synSpecial">:</span> <span class="synConstant">30</span>)) p.closeSubpath() } } } </pre> <p>And you can see from the lighter gray background color I've added to the view that the path that defines the shape no longer fills it.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183015.png" width="662" height="456" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="How-to-use-arcs">How to use arcs</h1> <p>There are three APIs for drawing an arc:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">/// Adds an arc of a circle to the path, specified with a radius and a</span> <span class="synComment">/// difference in angle.</span> <span class="synStatement">public</span> <span class="synStatement">mutating</span> <span class="synPreProc">func</span> <span class="synIdentifier">addRelativeArc</span>(center<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, startAngle<span class="synSpecial">:</span> <span class="synType">Angle</span>, delta<span class="synSpecial">:</span> <span class="synType">Angle</span>, transform<span class="synSpecial">:</span> <span class="synType">CGAffineTransform</span> <span class="synIdentifier">=</span> .identity) <span class="synComment">/// Adds an arc of a circle to the path, specified with a radius and angles.</span> <span class="synStatement">public</span> <span class="synStatement">mutating</span> <span class="synPreProc">func</span> <span class="synIdentifier">addArc</span>(center<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, startAngle<span class="synSpecial">:</span> <span class="synType">Angle</span>, endAngle<span class="synSpecial">:</span> <span class="synType">Angle</span>, clockwise<span class="synSpecial">:</span> <span class="synType">Bool</span>, transform<span class="synSpecial">:</span> <span class="synType">CGAffineTransform</span> <span class="synIdentifier">=</span> .identity) <span class="synComment">/// Adds an arc of a circle to the path, specified with a radius and two</span> <span class="synComment">/// tangent lines.</span> <span class="synStatement">public</span> <span class="synStatement">mutating</span> <span class="synPreProc">func</span> <span class="synIdentifier">addArc</span>(tangent1End p1<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, tangent2End p2<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, transform<span class="synSpecial">:</span> <span class="synType">CGAffineTransform</span> <span class="synIdentifier">=</span> .identity) </pre> <p>The first two add a new subpath disconnected from the current path.</p> <p>The last one – using tangents – adds an arc connected to the current subpath. We can use this API to add an arc to a line-by-line drawing session like the banner above.</p> <p>Let's create the rounded rectangle shape with only the <code>addLine</code> and <code>addArc</code> primitives. It should take a corner radius as a parameter and draw inside the provided bounds rectangle.</p> <p>First, we'll visualize what we want to draw. The black-outlined rectangle is a representation of our input rectangle and the gray-filled shape is the target shape we want to draw.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183045.png" width="1200" height="697" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>The corner radius <code>r</code> can be visualized as a square, situated at each corner of the bounds rectangle, with side <code>r</code>.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183107.png" width="321" height="303" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Looking at the <a href="https://developer.apple.com/documentation/swiftui/path/addarc(tangent1end:tangent2end:radius:transform:"><code>addArc</code></a>) function again:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">addArc</span>(tangent1End p1<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, tangent2End p2<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>) </pre> <p>We need to assemble 4 parameters:</p> <ol> <li><code>startPoint</code> (implicit; this is where the "cursor" is)</li> <li><code>tangent1End</code></li> <li><code>tangent2End</code></li> <li><code>radius</code></li> </ol> <p>We only know (4) <code>radius</code>.</p> <p>Despite the potentially 🤔 names, the tangents correspond to the following points on the aforementioned square:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183123.png" width="321" height="318" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Zooming out to the whole rectangle, if we decide to draw clockwise, that means we'll have 4 arcs with the following points:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183134.png" width="1200" height="671" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Let's alternate drawing lines and arcs, clockwise, and in the following order:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183147.png" width="1200" height="671" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>We want the points to be drawn relative to the bounds rectangle. We can use the following helper functions on <code>CGRect</code> to derive the corner points we need:</p> <ul> <li><code>CGRect.minX</code></li> <li><code>CGRect.maxX</code></li> <li><code>CGRect.minY</code></li> <li><code>CGRect.maxY</code></li> <li><code>CGRect.midX</code> (also useful)</li> <li><code>CGRect.midY</code> (also useful)</li> </ul> <blockquote><p>If you mix up these helpers while writing drawing code, you're in good company.</p></blockquote> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183201.png" width="1200" height="671" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>We derive the non-corner points by adding or subtracting the corner radius.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183216.png" width="1200" height="294" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>With all the details worked out, all we have to do is arrange the code:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectArcUnsafeShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">let</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span> <span class="synIdentifier">-</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span> <span class="synIdentifier">+</span> cornerRadius), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">-</span> cornerRadius)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span> <span class="synIdentifier">-</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">-</span> cornerRadius), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span> <span class="synIdentifier">+</span> cornerRadius)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.closeSubpath() } } } </pre> <p>If we overlay SwiftUI's build-in <code>RoundedRectangle</code> shape, ours looks pretty good:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectArcUnsafeView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { <span class="synPreProc">let</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">20</span> ZStack { RoundedRectArcUnsafeShape(cornerRadius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span>) .stroke(.gray, lineWidth<span class="synSpecial">:</span> <span class="synConstant">9</span>) RoundedRectangle(cornerRadius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span>, style<span class="synSpecial">:</span> .circular) .stroke(.red, lineWidth<span class="synSpecial">:</span> <span class="synConstant">1</span>) } .previewingShape() } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183240.png" width="888" height="608" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>But what happens if we make <code>cornerRadius</code> something like <code>100</code> (when our shape height is <code>100</code>)?</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183254.png" width="884" height="608" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Looks like SwiftUI's version does some bounds checking so the shape becomes a capsule or circle. Let's fix our implementation:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectArcShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">let</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">maxBoundedCornerRadius</span> <span class="synIdentifier">=</span> min(min(cornerRadius, rect.width <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>), rect.height <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>) <span class="synPreProc">let</span> <span class="synIdentifier">minBoundedCornerRadius</span> <span class="synIdentifier">=</span> max(maxBoundedCornerRadius, <span class="synConstant">0.0</span>) <span class="synPreProc">let</span> <span class="synIdentifier">boundedCornerRadius</span> <span class="synIdentifier">=</span> minBoundedCornerRadius <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> boundedCornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) <span class="synComment">// ...</span> } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183308.png" width="890" height="616" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>That's better. As a bonus, I'm showing SwiftUI's <code>.continuous</code> corner style in blue over the <code>.circular</code> style in red.</p> <h1 id="How-to-use-quadratic-bezier-curves">How to use quadratic bezier curves</h1> <p>We often want to avoid the kinds of sharp corners that appear when connecting <code>line</code>s, but don't necessarily want to use circular <code>arc</code>s.</p> <p>For smoother lines, the <code>Path</code> API gives us:</p> <ul> <li><code>addCurve</code> for cubic Bézier curves</li> <li><code>addQuadCurve</code> for quadratic Bézier curves</li> </ul> <p>Cubic Bézier curves give us a lot of flexibility. They can also be a <a href="https://pomax.github.io/bezierinfo/">weighty</a> <a href="https://en.wikipedia.org/wiki/B%C3%A9zier_curve">topic</a>. I recommend <a href="https://www.youtube.com/watch?v=aVwxzDHniEw">this YouTube video</a> by Freya Holmér.</p> <p>I've found quadratic Bézier curves as a nice compromise between flexibility and complexity, so let's try to quickly build some intuition on how to use them.</p> <p>Let's start by looking at the <a href="https://developer.apple.com/documentation/swiftui/path/addquadcurve(to:control:"><code>addQuadCurve</code></a>) function:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">addQuadCurve</span>(to p<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, control cp<span class="synSpecial">:</span> <span class="synType">CGPoint</span>) </pre> <p>We need to assemble 3 parameters:</p> <ol> <li><code>startPoint</code> (implicit; this is where the "cursor" is)</li> <li><code>endPoint</code> (<code>p</code>)</li> <li><code>controlPoint</code> (<code>cp</code>)</li> </ol> <p>When we set up the three points as various triangles, we can see that the curve is stretched towards the control point.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183332.png" width="1200" height="579" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Calculating the actual positions of the three points will depend on our use case.</p> <p>Let's say we want to draw a simple quad curve as a "scoop" with the control point at the bottom. But we'll allow the caller to specify a relative position on the x-axis for the control point.</p> <p>Add the input rectangle to our planning diagram will help us determine how to calculate each of the three points:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183344.png" width="1200" height="579" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>With that, here's the code:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">QuadCurveScoop</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synComment">/// 0...1</span> <span class="synPreProc">var</span> <span class="synIdentifier">pointOffsetFraction</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">0.0</span> <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addQuadCurve( to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), control<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span> <span class="synIdentifier">*</span> pointOffsetFraction, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>) ) } } } </pre> <blockquote><p>If we don't explicitly close the subpath, SwiftUI presumably closes it for us when drawing.</p></blockquote> <p>I've set up the preview to mimic the figure above, and I've added an overlay to show the input rectangle and approximate control point for each curve.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183358.png" width="882" height="884" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Path-operations">Path operations</h1> <p>Path operations look like set operations: <code>union</code>, <code>intersection</code>, <code>subtracting</code>, etc.</p> <p>These operations allow us to combine subpaths in unique ways, without necessarily needing to draw line-by-line or arc-by-arc.</p> <p>Let's try making a cloud shape by adding together 3 ellipses:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Cloud1Shape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">inset</span> <span class="synIdentifier">=</span> rect.width <span class="synIdentifier">/</span> <span class="synConstant">2.0</span> <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.addEllipse(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synConstant">0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span>))) p.addEllipse(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>))) p.addEllipse(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synConstant">0</span>))) } } } </pre> <p>When we fill it, it looks fine:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Cloud1View_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { Cloud1Shape() .fill(.gray) .previewingShape() } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183416.png" width="658" height="456" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>But if we decide to draw an outline instead, it looks like 3 ellipses:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183426.png" width="656" height="454" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>We can fix this by joining the shapes together using the <a href="https://developer.apple.com/documentation/coregraphics/cgpath/3994971-union"><code>union</code></a> path operation.</p> <p>The path operation APIs are available on iOS 16+. Unfortunately, they're defined on <code>CGPath</code> and not <code>Path</code>. It's simple to convert between them, but we'll have to rewrite our path drawing code.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Cloud2Shape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">inset</span> <span class="synIdentifier">=</span> rect.width <span class="synIdentifier">/</span> <span class="synConstant">2.0</span> <span class="synPreProc">let</span> <span class="synIdentifier">leftEllipse</span> <span class="synIdentifier">=</span> Path(ellipseIn<span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synConstant">0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span>))) <span class="synPreProc">let</span> <span class="synIdentifier">centerEllipse</span> <span class="synIdentifier">=</span> Path(ellipseIn<span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>))) <span class="synPreProc">let</span> <span class="synIdentifier">rightEllipse</span> <span class="synIdentifier">=</span> Path(ellipseIn<span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synConstant">0</span>))) <span class="synPreProc">let</span> <span class="synIdentifier">combinedCGPath</span> <span class="synIdentifier">=</span> leftEllipse.cgPath .union(centerEllipse.cgPath) .union(rightEllipse.cgPath) <span class="synStatement">return</span> Path(combinedCGPath) } } </pre> <p>Now when we outline the shape, we get a cloud again.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183437.png" width="648" height="446" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Creating-a-chat-bubble-shape">Creating a chat bubble shape</h1> <p>I used the above techniques to create a chat bubble shape for the onboarding section of the recently decommissioned <a href="https://note.com/tabedori/">Tabedori たべドリ</a> app.</p> <p>The arrow position on the bottom can be adjusted by providing <code>arrowOffsetFraction</code>.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">MapOnboardingBubbleShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">var</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">12</span> <span class="synPreProc">var</span> <span class="synIdentifier">arrowRectSize</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">20</span> <span class="synPreProc">var</span> <span class="synIdentifier">arcLength</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">12</span> <span class="synComment">/// 0.0 = left, 0.5 = center, 1.0 = right</span> <span class="synPreProc">var</span> <span class="synIdentifier">arrowOffsetFraction</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">0.5</span> <span class="synPreProc">func</span> <span class="synIdentifier">baseXPos</span>(<span class="synStatement">for</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">CGFloat</span> { (rect.maxX <span class="synIdentifier">-</span> cornerRadius <span class="synIdentifier">-</span> cornerRadius <span class="synIdentifier">-</span> arrowRectSize) <span class="synIdentifier">*</span> arrowOffsetFraction <span class="synIdentifier">+</span> cornerRadius } <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">roundedRect</span> <span class="synIdentifier">=</span> Path(roundedRect<span class="synSpecial">:</span> <span class="synType">rect</span>, cornerRadius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span>) <span class="synPreProc">let</span> <span class="synIdentifier">arrowPath</span> <span class="synIdentifier">=</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>), y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize <span class="synIdentifier">-</span> arcLength, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">+</span> arrowRectSize <span class="synIdentifier">-</span> arcLength )) p.addQuadCurve( to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">+</span> arrowRectSize <span class="synIdentifier">-</span> arcLength ), control<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">+</span> arrowRectSize ) ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.closeSubpath() } <span class="synPreProc">let</span> <span class="synIdentifier">combinedCGPath</span> <span class="synIdentifier">=</span> roundedRect.cgPath.union(arrowPath.cgPath) <span class="synPreProc">let</span> <span class="synIdentifier">combinedPath</span> <span class="synIdentifier">=</span> Path(combinedCGPath) <span class="synStatement">return</span> combinedPath } } </pre> <p>The <code>arrowOffsetFraction</code> is the text inside the bubble.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183452.png" width="676" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Here's a screenshot of it in context:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183506.jpg" width="553" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Trimming-a-path">Trimming a path</h1> <p>Animating the path is something that can't be done (easily) with a single static image, but is easy to do with a <code>Shape</code>.</p> <p>The <code>trim</code> modifier on <code>Shape</code> allows you to draw only a variable fraction of the path.</p> <p>Since SwiftUI is adept at many kinds of animations, we can use it to animate the path being drawn:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">DrawBubbleView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@State</span> <span class="synType">var</span> drawFraction<span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">0</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { VStack { MapOnboardingBubbleShape() .trim(from<span class="synSpecial">:</span> <span class="synConstant">0</span>, to<span class="synSpecial">:</span> <span class="synType">drawFraction</span>) .stroke(.gray, lineWidth<span class="synSpecial">:</span> <span class="synConstant">3</span>) .animation(.spring(), value<span class="synSpecial">:</span> <span class="synType">drawFraction</span>) .frame(width<span class="synSpecial">:</span> <span class="synConstant">150</span>, height<span class="synSpecial">:</span> <span class="synConstant">100</span>) .padding(.bottom, <span class="synConstant">50</span>) Button(drawFraction <span class="synIdentifier">&gt;</span> <span class="synConstant">0.0</span> ? <span class="synConstant">&quot;Hide&quot;</span> <span class="synSpecial">:</span> <span class="synConstant">&quot;Show&quot;</span>) { drawFraction <span class="synIdentifier">=</span> drawFraction <span class="synIdentifier">&gt;</span> <span class="synConstant">0.0</span> ? <span class="synConstant">0.0</span> <span class="synSpecial">:</span> <span class="synConstant">1.0</span> } .tint(Color.gray) } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183524.gif" width="628" height="776" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Transition-animations">Transition animations</h1> <p>And finally, since <code>Shape</code>s have appearance/disappearance transitions like any other <code>View</code>, we can add a fun springy insertion animation.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">BubbleTransitionView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@State</span> <span class="synType">var</span> isVisible<span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { VStack { ZStack { <span class="synStatement">if</span> isVisible { Text(<span class="synConstant">&quot;Hello!&quot;</span>) .padding(<span class="synConstant">30</span>) .background { MapOnboardingBubbleShape().fill(Color(.systemGray5)) } .transition(.opacity.combined(with<span class="synSpecial">:</span> .scale).animation(.spring(response<span class="synSpecial">:</span> <span class="synConstant">0.25</span>, dampingFraction<span class="synSpecial">:</span> <span class="synConstant">0.7</span>))) } } .frame(width<span class="synSpecial">:</span> <span class="synConstant">200</span>, height<span class="synSpecial">:</span> <span class="synConstant">100</span>) .padding(.bottom, <span class="synConstant">50</span>) Button(isVisible ? <span class="synConstant">&quot;Hide&quot;</span> <span class="synSpecial">:</span> <span class="synConstant">&quot;Show&quot;</span>) { isVisible.toggle() } } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183543.gif" width="628" height="776" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Conclusion">Conclusion</h1> <p>Thanks for reading! I hope this post has led you on a <em>path</em> of enlightenment.</p> christopher-trott RubyKaigi 2023 Wi-Fi: 足回り徹底解説 hatenablog://entry/820878482937160246 2023-05-31T11:30:00+09:00 2023-05-31T11:30:00+09:00 id:sora_h です。今回は RubyKaigi 2023 で提供した Wi-Fi ネットワークの足周りについて徹底解説します。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531000513.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> です。最近は RubyKaigi の Organizer や Wi-Fi NOC をやっていましたが… 何屋なんだろう? 一応 Software Engineer (Site Reliability, Corporate Engineering) を名乗っていますが…。あっ RubyKaigi から戻ってからは<a href="https://jp.finalfantasyxiv.com/jobguide/scholar/">学者</a>をやってますね。<a href="https://jp.finalfantasyxiv.com/lodestone/topics/detail/f73877a5727c8d14fb966f3b593464280d29c575">落ち着いたら</a>本業を思い出していこうと思います。</p> <p>さて、Cookpad は 2010 年より RubyKaigi に協賛していますが、近年は Wi-Fi Sponsor など<a href="#f-132f6ad6" name="fn-132f6ad6" title="Ruby Committers’ スポンサー (2017-2022) や Rubyists on Rails スポンサー (2023) も平行してやっています。Wi-Fi スポンサーは 2017 年から">*1</a>として携わっています。実体的には、 <a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> (筆者) が RubyKaigi 前にほぼフルタイムで Wi-Fi の準備に提供されたり、細々とした機材、一部の回線・ラックスペースの提供を行っています <a href="#f-8f477a84" name="fn-8f477a84" title="RubyKaigi の Wi-Fi 機材の大半はスポンサーのみなさまからの協賛金をもとにして RubyKaigi で購入所有しています。スポンサーのみなさま、ありがとうございます!">*2</a>。</p> <p>本稿では RubyKaigi 2023 Wi-Fi ネットワークの L1~L4 設計について解説します <a href="#f-33e8c248" name="fn-33e8c248" title="RubyKaigi 2022 でもほぼ同様の構成を取っていました">*3</a>。Wi-Fi についてというより、Wi-Fi AP より先の足回り、会場のルータからインターネットまでの区間についてがメイントピックです。</p> <ul class="table-of-contents"> <li><a href="#実績">実績</a></li> <li><a href="#概要">概要</a></li> <li><a href="#AWS-VPC-上に構築される機能">AWS VPC 上に構築される機能</a></li> <li><a href="#エクスターナル">エクスターナル</a><ul> <li><a href="#対外回線">対外回線</a></li> <li><a href="#中継拠点-Point-of-Presence">中継拠点 (Point of Presence)</a></li> <li><a href="#インターネット接続">インターネット接続</a></li> <li><a href="#おまけ-NTT西日本エリアでの開催の場合">おまけ: NTT西日本エリアでの開催の場合</a></li> </ul> </li> <li><a href="#L3-L4-設計">L3-L4 設計</a><ul> <li><a href="#構造">構造</a></li> <li><a href="#会場エクスターナル向き通信-outbound">会場→エクスターナル向き通信 (outbound)</a></li> <li><a href="#エクスターナル会場向き通信-inbound">エクスターナル→会場向き通信 (inbound)</a></li> <li><a href="#パケットフィルタ">パケットフィルタ</a></li> <li><a href="#NAPT">NAPT</a></li> </ul> </li> <li><a href="#L1-L2-設計">L1-L2 設計</a></li> <li><a href="#Wi-Fi-区間">Wi-Fi 区間</a></li> <li><a href="#その他の取り組み">その他の取り組み</a><ul> <li><a href="#8021X-WPA23-Enterprise">802.1X (WPA2/3 Enterprise)</a></li> <li><a href="#Public-dashboard-と-timelapse">Public dashboard と timelapse</a></li> <li><a href="#DNS-over-HTTPS">DNS over HTTPS</a></li> </ul> </li> <li><a href="#Acknowledgements">Acknowledgements</a></li> <li><a href="#まとめ">まとめ</a></li> <li><a href="#あわせて読みたい">あわせて読みたい</a></li> </ul> <h2 id="実績">実績</h2> <p><figure class="figure-image figure-image-fotolife" title="会場内に設置した Wi-Fi AP の写真"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531000605.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>会場内に設置した Wi-Fi AP の写真</figcaption></figure></p> <p>まずはじめに、RubyKaigi 2023 Wi-Fi の規模ってどういうものなの? というのを理解してもらうために実績から説明します。だいたいの実績は下記のようなところでした。</p> <ul> <li>IPv4/IPv6 dualstack</li> <li>Wi-Fi: 1,325 clients (peak)</li> <li>Traffic: <ul> <li>Peak: 480 Mbps (in) 240 Mbps (out)</li> <li>90%ile: 330 Mbps (in) 75 Mbps (out)</li> </ul> </li> <li>IPv4 NAPT: 32,000 sessions (peak)</li> <li>DNS: 530 qps (peak), 370 qps (mean), うち ⅓ は DoH</li> </ul> <p>事前に (後述するトンネル接続先との) フレッツ光 (NGN) 区間で測定した速度が 2 回線合わせても良い結果ではなかった<a href="#f-b9ba4439" name="fn-b9ba4439" title="回線工事に立ち合って直後に計測した結果それぞれで inbound 100Mbps しかでず結構ひやひやしていた">*4</a>ので昨年よりクライアント数は伸びつつもそこまでトラフィックは伸びなかったどころか減ったというのが印象です。ただQoSを特にせずとも問題ないくらいのクオリティで運用することができたのでまずまずという結果。</p> <p>Association (Wi-Fi client 数) も参加者数に対してそこまで伸びてないなという感想です。みなさん最低でも 2 つは繋ぐでしょという前提で、2,400 assocs くらいは想定していたんですが (捌けるとは言っていない)、近年では参加者数に対して 1.1 倍くらいの Wi-Fi association 数の見積で良いのかもしれません。2 端末以上に設定されていて自動切断されていたのか、そもそも1端末にしか設定されていないのか、など理由はきになるところです。</p> <h2 id="概要">概要</h2> <p><figure class="figure-image figure-image-fotolife" title="RubyKaigi 2023 Wi-Fi L3 概要図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531000651.png" width="1200" height="934" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>RubyKaigi 2023 Wi-Fi L3 概要図</figcaption></figure></p> <p>上記は RubyKaigi 2023 の L3 概要図です。本稿ではこれを上から順に解説します。</p> <p>RubyKaigi のネットワークは参加者が会期中に呼吸をするために構築して提供しています。オペレーションや構成はシンプルにしつつ、イベントネットワークとして面白いことはやる、その上で冗長化可能なところは実装して耐障害性を高めたり、active/active 構成でパフォーマンス向上も目指したりします。</p> <p>(現地で障害が起きて機材をリプレイスしているとカンファレンス本編に参加する時間を損ねてしまうので、なるべく現地かつ会期中でのオペレーションは最低限にしたい) 基本的にサービスレベルは高くなく <a href="#f-57341175" name="fn-57341175" title="ただし、RubyKaigi 2022, 2023 ではバーチャル会場へのセッションのライブ配信を行っていたので、ここについては重要度が高いものになっていました">*5</a>、ある程度諦めても良いと思ってはいますが、それはそれとして高品質なネットワークを提供することに価値はあるし、できる範囲で高い品質を目指す気持ちで実装しています。</p> <h2 id="AWS-VPC-上に構築される機能">AWS VPC 上に構築される機能</h2> <p>はじめに前提として、RubyKaigi Wi-Fi では Linux サーバが必要になるような機能については可能な限り AWS VPC 上に構築しています。RubyKaigi 2023 では下記の機能を AWS 上に構築しました。</p> <ul> <li>監視基盤 (Prometheus/Alertmanager/Grafana)</li> <li>Syslog (Fluentd, S3, CloudWatch Logs)</li> <li>DHCPv4 サーバ (kea4-dhcp)</li> <li>DNS リゾルバ (unbound, envoy)</li> <li>RADIUS サーバ (freeradius)</li> </ul> <p>RubyKaigi 2022 に引き続いて、Amazon EKS を主に利用しています <a href="#f-0ff2ecc0" name="fn-0ff2ecc0" title="Cookpad Japan では主に ECS が利用されていますが、たまには味変もいいよねというのと、Helm で雑に立てるのでいいか…という理由で EKS + Bottlerocket (Arm64 only) にしています">*6</a>。Kubernetes 環境のお世話が必要になりますが、やはり 2023 年になって EC2 で Linux サーバをそのまま運用するのは面倒で、デプロイなどを含めてもコンテナベースのワークフローにしたいものです。</p> <p>このあたりは <a href="https://github.com/ruby-no-kai/rubykaigi-nw">https://github.com/ruby-no-kai/rubykaigi-nw</a> で Terraform や Jsonnet ファイルが public になっているのと、ごくごくふつうのことをしているので本稿では省略します。</p> <h2 id="エクスターナル">エクスターナル</h2> <p>会場側の設計解説に入る前に、まずはエクスターナル – 対外回線とインターネット接続について説明します。</p> <h3 id="対外回線">対外回線</h3> <p>会場からの対外接続はフレッツ光 (NGN) の網内折り返しを利用しています。RubyKaigi 2022 までは 1 箇所に 2 回線を引いていたのですが、NTT 局側の同じ機材に収容されてしまったのか<a href="https://twitter.com/sora_h/status/1568163246988996610">共倒れ</a>する障害が発生してしまいました。</p> <p>この反省を受けて、RubyKaigi 2023 では 2 箇所に 1 回線ずつ手配しました。場所が分散することで運用は煩雑になりますが、少しでも同じ機材に収容されるような事態を回避するための取り組みです。しかしここばかりは会場周辺の光スプリッタ・芯の状況に依存するため、別の機材に収容されることを願うことしかできません <a href="#f-ef76e978" name="fn-ef76e978" title="RubyKaigi は地方開催を続けているためサービスエリアの都合選択肢に入ることはありませんが、サービスエリア内なのであればフレッツ光ネクスト, 光クロスを 1 本ずつ契約することで高い確度で別の機材に収容されることが期待できます。光ファイバについてはもしかしたら共用している部分があり、経路上の物理障害についてはどのみち運かもしれません">*7</a>。</p> <p>今年はおそらく違う局舎側装置で終端されていそうでした。来年も 24 時間出張修理オプションに忘れずに加入し、お祈りしたいと思います。</p> <h3 id="中継拠点-Point-of-Presence">中継拠点 (Point of Presence)</h3> <p>会場の回線からは直接インターネットに抜けていません。RubyKaigi では別途、中継地点を 2 拠点用意、そこへルータを設置して IPsec VPN や IP over IPv6 トンネルで <a href="#f-a5c0bc08" name="fn-a5c0bc08" title="private IPv4 prefix は IPsec VPN、それ以外は IPv4/v6 over IPv6 トンネル。ルータ負荷とMTUを考慮して IPsec の対象にするトラフィックを絞っていますが、IPsec だけでも今採用している機材なら十分性能が出るかもしれません (IX2215)。暗号化されないトンネルで利用するプロトコルについては IX シリーズだと IPv4/v6 over IPv6, EtherIP, GRE が候補になりますが、GRE はキープアライブなどやや複雑なので外し、EtherIP は RubyKaigi 2022 で採用していましたが、L3 ルーティングをするために Tunnel interface とは別に BVI interface を利用する必要があります。IX シリーズの IP over IP は 1 interface で v4/v6 dualstack にできないため、EtherIP でも IP over IP でも 2 interface 管理する必要があるのなら IP over IP の方がシンプルだなと思い直して今回はそのようになりました。">*8</a> 会場と接続しています。本稿でこの中継地点を以後 RubyKaigi PoP (point of presence) と呼びます。PoP の目的はインターネット や AWS VPC との接続を終端し会場側のルータにトンネルを提供するものです。</p> <p>直接インターネットへ接続しない理由は会場側機材の接続設定をシンプルに保ちつつ冗長化と帯域のアグリゲーションを行うためです。中継拠点を設けることで自宅やオフィスでホットステージを行いやすく事前に dogfooding が可能になりますし、中継拠点の機材は RubyKaigi NOC チームでコントロールできるため、まとめて運用が可能です。会場から直接インターネットへ接続する場合は現地の回線に依存してしまい、同じ環境で検証を行いづらくなります。現地で確認してはじめて分かる情報が増えると設営時の作業が煩雑になるため、これを避けられる利点もあります。</p> <p>RubyKaigi 2023 の PoP は Cookpad のラックスペースと筆者の自宅の 2 箇所に設置しました。前者を hnd, 後者を nrt と呼称します <a href="#f-f9da842f" name="fn-f9da842f" title="traceroute して出てくる逆引き名にも出現していたと思いますが、気付いたひとはいるかな?">*9</a>。</p> <h3 id="インターネット接続">インターネット接続</h3> <p>PoP で終端する実際のインターネット接続は、筆者が構築/運営に携わっている <a href="https://www.kmc.gr.jp/projects/as/">AS59128 (KMC, 京大マイコンクラブ)</a> からトランジットと IP アドレスを提供しました。これは RubyKaigi 2022 から <a href="#f-66cd9e8a" name="fn-66cd9e8a" title="正確にはキャンセルになった in-person の RubyKaigi 2020 の頃から AS59128 からのトランジットと番号資源の提供を予定していました">*10</a> の変更で、マルチホーム接続など遊び要素、BGP による容易な冗長化のための取り組みです。</p> <p>なおこの関係で、RubyKaigi NOC チームには多数の KMC 部員が Helper として参加しています。RubyKaigi 2023 NOC は幽霊部員の発見や現地での入部を経て最終的に <a href="https://twitter.com/k_hanazuki/status/1659230794517291008">9 人中 7 人が KMC 部員</a> でした。したがって、RubyKaigi Wi-Fi は Cookpad がスポンサーしつつ KMC 部員が好き勝手するイベントネットワークになりつつあります。KMC 部員以外でも、何かしら面白いものをデプロイしてみたい人の参加は歓迎しているので、筆者や関係者まで Twitter などでお声がけくださいね。</p> <p>また、Cookpad のラックスペースにある AWS Direct Connect Connection (dxcon) 上に RubyKaigi の AWS アカウント/VPC の private virtual interface (dxvif) を作成して、AWS VPC への接続も確立しています。バックアップとしてはもう片方の PoP で AWS Site-to-Site VPN (+ 適当なフレッツ光 PPPoE の ISP) を設定しました <a href="#f-2d7a2b81" name="fn-2d7a2b81" title="はやく IPv6 outer の StS VPN をサポートしてほしい!">*11</a>。</p> <p>そして、実は同じ dxcon で Cookpad から AS59128 に public dxvif も提供していて、AS59128 は AWS とピアリング (直接接続) を行っています。そのため、RubyKaigi Wi-Fi と AWS の public IP prefixes の通信は AS59128 から直接 AWS に出て行き、その逆もしかり AWS から直接 AS59128 にパケットが送信されていました。AS59128 の対外接続 (RubyKaigi からみて上流の上流) はこれを除いて全て NGN などを通したトンネル接続ということもあり、AWS 宛の通信だけやたら低レイテンシ・広帯域が実現されています <a href="#f-7dd987c9" name="fn-7dd987c9" title="余談: AWS re:Invent の会場 Wi-Fi も同じように public dxvif で Direct Connect してそうな雰囲気がありますね">*12</a>。AS16509 (Amazon), AS15169 (Google) ともにそれぞれ inbound traffic の 15% 程度を占めていたので、transit の帯域を他に譲れたので接続しておいて良かった印象です <a href="#f-36c5fbbd" name="fn-36c5fbbd" title="本当は RubyKaigi 2022 からそうなる予定でしたが、AWS 側の承認が間に合っていませんでした">*13</a>。</p> <h3 id="おまけ-NTT西日本エリアでの開催の場合">おまけ: NTT西日本エリアでの開催の場合</h3> <p>RubyKaigi PoP の配置場所は、AS59128 と物理的に接続できる場所という理由でも選定しています <a href="#f-f2c5a6a4" name="fn-f2c5a6a4" title="Cookpad の余剰ラックスペースや資源は RubyKaigi に加え、AS59128 にも貸し出しています">*14</a>。</p> <p>RubyKaigi 2023 は長野県松本市、NTT 東日本エリアでの開催だったため VNE (いわゆる IPv6 IPoE 接続)を利用しませんでした。RubyKaigi 2022 (三重県津市) のように NTT 西日本エリア側で開催する場合は、NTT 西の拠点として KMC 部室、NTT 東の拠点として Cookpad のラックスペースを利用します。この場合、NTT東西のNGNで互いに通信するために VNE の契約が必要です。</p> <p>AS59128 の構成上、単一 IP prefix で東西をカバーしているため柔軟な traffic engineering ができない・その事情も含め東京の方がスループットのポテンシャルが (前述のAWS DXも含め) 高い・接続先コンテンツはだいたい関東側・関西で 2 箇所以上の拠点を確保しづらい…のような理由で、西日本で開催する場合は VNE を利用して東西 1 拠点ずつ PoP 開設・接続することにしています。</p> <p>フレッツ光回線開通工事後、NTT 側の所定の作業が終わらないと VNE 事業者が回線と VNE を紐付けるサービスオーダーを行うことができません。この作業がいつ終わるかはケースバイケースのようで、回線オーダー時に早めに IPoE 開通がしたい旨を伝えつつ、回線開通工事から VNE のサービスオーダーまで 3 営業日程度余裕を持っておくことが無難だと思っています (筆者の個人の感覚です)。帯域の問題以外にも、VNE 開通失敗を見越して、NTT 西エリアに PoP を用意して VNE に依存しない網内折り返しができるように備えています。</p> <h2 id="L3-L4-設計">L3-L4 設計</h2> <p><figure class="figure-image figure-image-fotolife" title="主ホール楽屋に設置した L3 ルータ類や WLC"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531000730.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>主ホール楽屋に設置した L3 ルータ類や WLC <a href="#f-19c686b6" name="fn-19c686b6" title="設営時に余裕がなくてケーブルコスメが一切できてないまま運用に突入してしまったのが悔やましい。">*15</a></figcaption></figure></p> <p>次は PoP〜会場間を担う L3-L4 の設計について解説します。図面については冒頭に掲載した L3 Diagram を参照してください。</p> <h3 id="構造">構造</h3> <p>L3-L4 ではほぼ NEC の Univerge IX シリーズルータを採用しています。IPv4 は(ほぼ全て) BGP, IPv6 は OSPF を動作させています。経路冗長と帯域のアグリゲーションが主な目的です。RubyKaigi 2022, RubyKaigi 2023 ともに下記 4 レイヤの構成を取っていました:</p> <ul> <li><strong>br (border router):</strong> PoPに設置する対外接続終端ルータ。PoP に 1 台ずつ設置</li> <li><strong>tun (tunnel):</strong> 会場で NGN に直接接続するトンネル終端ルータ。</li> <li><strong>gw (gateway):</strong> IPv4 については BGP/OSPF 間の redistribute 役とNAPT 装置. IPv6 はただのルータ。</li> <li><strong>csw (core switch):</strong> ユーザ収容 VLAN の L2 スイッチングと L3 デフォルトゲートウェイ (これは Juniper EX スイッチ)。</li> </ul> <p>br は PoP に 1 台ずつ、残りのルータは会場の回線終端場所に 1 台ずつ設置します。RubyKaigi 2023 では 2 回線工事したため tun, gw, csw はそれぞれ 2 台存在しました。</p> <p>tun/gw 間、gw 同士, csw 同士を除いて L3 接続を行っています。RubyKaigi 2023 では前述の通り主・小ホールに回線とルータが分散したため、ホールを跨ぐ機器同士の link は L2 スイッチを通る構成になっていました。csw, gw 同士でパケット転送する必要はないため接続していません。</p> <p>末端のユーザ収容 prefix については、private IPv4 prefix は csw から OSPF へ流し gw が BGP へ redistribute しています (v6 は gw から br まで全て OSPF <a href="#f-1b7e9824" name="fn-1b7e9824" title="IX シリーズに IPv6 BGP のサポートが欲しい…">*16</a>, csw/gw 間は static)。IPv4 NAPT outer address は NAPT 装置である gw から BGP advertise しています。</p> <h3 id="会場エクスターナル向き通信-outbound">会場→エクスターナル向き通信 (outbound)</h3> <p><figure class="figure-image figure-image-fotolife" title="outbound 向きの通信で通りうるパス。ECMP で flow ごとに固定"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531000849.png" width="1200" height="934" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>outbound 向きの通信で通りうるパス。ECMP で flow ごとに固定</figcaption></figure></p> <p>まずは outbound の通信について。エクスターナル宛のパケットは csw に届いて、まず ECMP でいずれかの gw に振り分けられます。IPv4 インターネット宛のパケットはここで NAPT の対象となって outer address が決まります <a href="#f-7940fb45" name="fn-7940fb45" title="5-tuple, per-flow ECMP であれば同じ flow は異なるパケットでも同じ宛先が選択されるため問題なく動きますが、障害などでルーティングテーブルに変動が起きたら TCP セッション等は普通に切れちゃいます">*17</a>。</p> <p>tun/gw 間の接続は冗長になっていません。IX シリーズの NAPT 機能は interface 単位で設定するため、まず NAPT を有効にして複数の interface で冗長化することが不可能です。NAPT 装置の IX ルータを別途用意している理由はこの制約と、CPU bound なトンネル処理を行う tun の負荷を減らすためです。</p> <p>また、IX シリーズでは NAPT の制御も基本的に source address でしかできません。AWS VPC 宛の通信などで NAPT されては困るため、対インターネット向け通信でのみ対象にするために tun/gw 間の接続は VLAN tagged にして interface を 2 つに分割しています。IPv4 のみ BGP セッションを分けて、private IPv4 prefix 宛の通信は NAPT の設定がない interface を通って tun に向かうように設定されています。</p> <p>gw から先はシンプルです。tun/br 間も ECMP になっていて、br からは AWS VPC <a href="#f-d5e57e57" name="fn-d5e57e57" title="ただし、VPC については AWS Direct Connect 経路を Site-to-Site VPN 経路より優先する経路制御をしているため、br-01.nrt に VPC 宛パケットが到着した場合は Direct Connect 接続をしている br-01.hnd にルーティングされます">*18</a> か default route の AS59128 にルーティングされ、インターネットへ到達します。</p> <h3 id="エクスターナル会場向き通信-inbound">エクスターナル→会場向き通信 (inbound)</h3> <p><figure class="figure-image figure-image-fotolife" title="inbound 向き通信で通りうるパス"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531000858.png" width="1200" height="934" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>inbound 向き通信で通りうるパス</figcaption></figure></p> <p>RubyKaigi Wi-Fi は eyeball network であり inbound heavy です。会場側の 2 回線の帯域を活かすため、戻りの経路でも ECMP で帯域のアグリゲーションがされるようにしています。その都合で装置ごとに異なる ASN で運用される br, gw と異なり tun に関しては 2 台とも同 ASN で運用、tun 同士は iBGP <a href="#f-ef7c9a2f" name="fn-ef7c9a2f" title="IGP は OSPF を起動。next hop self して IGP なしでも良いのかも…?">*19</a> で接続しました。</p> <p>前述の通り NAPT outer address は gw からそれぞれ BGP advertise されていて、NAPT outer address に対応する gw 筐体へルーティングされていきます。具体的には、br からどちらかの tun にパケットが着信し、それが同じ場所の gw の持つ prefix であればそのままパケットを流し、そうでなければ tun 同士の link を通って、もう片方の (違う設置場所の) tun へ転送し適切な gw へルーティングされます。</p> <p>光ネクストの NGN 2 回線で出せる帯域の合計が 1 Gbps になれば良い方だと思っていて、ルータ間の link は LAG により 2 Gbps 程度あるため tun 同士の link にはじゃんじゃかトラフィックを流すことにしていました。設置場所を跨ぐこれが最適な戦略なのかはいまいち良く分かってはいません。</p> <p>gw から csw へは ECMP ではなく同じ設置場所の csw に渡るようになっています。これは csw から先は L2 になり、最寄りの csw からスイッチングしてもらうのが効率が良いためここは ECMP になっていません。設置場所を跨ぐ csw/gw の link (例: csw-02/gw-01) は前述 IPv4 NAPT outer address の振り分けのために設定されていて、inbound traffic が通ることはありません。</p> <h3 id="パケットフィルタ">パケットフィルタ</h3> <p>RubyKaigi 2022 までは Stateful Firewall <a href="#f-659ad140" name="fn-659ad140" title="ここではTCP/UDPなどflowを認識して外部からの新規着信を防ぐものを指します。NEC IX シリーズでは dynamic アクセスリストです。">*20</a> を gw で実装していましたが、後述の NAPT セッション数の問題と同じ懸念があることと、RubyKaigi 2022 で観測されたパケットロスの原因の可能性も疑い RubyKaigi 2023 では Stateful Firewall を v4/v6 ともに実装しませんでした <a href="#f-8d283c7d" name="fn-8d283c7d" title="IPv4 インターネット宛通信は NAPT があるのである程度は落とされると思います">*21</a>。TCP は ACK | RST の established filter を入れていますが、UDP は WebRTC/STUN での通信も見込んで全て通していました。</p> <p>また、IPv6 についてはユーザ収容 prefix を csw で originate、以降 csw/gw, br/tun 区間で ECMP されており、行き戻りパケットで異なる gw 筐体を通過する可能性があるため、そもそもこの構成では stateful firewall を IX シリーズでは実装することができません。 IPv4 に関しては NAPT の都合で inbound は outer address にルーティングされ行き戻りパケットともに同じ gw 筐体を通過することから、NAPT/stateful firewall ともに実装可能です。</p> <h3 id="NAPT">NAPT</h3> <p>経路の話を終えたところで IPv4 NAPT の設定についてです。RubyKaigi 2022 は RubyKaigi 2019 と同じパラメータで運用していましたが、 <a href="https://www.janog.gr.jp/meeting/janog48/lt4/">QUIC</a> が <a href="https://www.janog.gr.jp/meeting/janog49/quic/">登場した</a> ため <a href="https://github.com/ruby-no-kai/rubykaigi-nw/issues/69">見直しが必要</a> でした。</p> <p>結論としては下記のパラメータで運用していましたが、peak 32,000 sessions くらいで済んでいました。</p> <pre class="code" data-lang="" data-unlink> ip napt translation max-entries 129020 ip napt translation max-entries per-address 350 ip napt translation tcp-timeout 120 ip napt translation udp-timeout 120 ip napt translation icmp-timeout 10 ip napt translation dns-timeout 10 ip napt translation gre-timeout 600 ip napt translation syn-timeout 300 ip napt translation finrst-timeout 15 ip napt translation other-timeout 600 ip napt translation port-range 1025-65535</pre> <p>(max-entries per-address は NAPT は 2 装置あるため実質的には 700 です)</p> <p>udp-timeout を見直していなければ (600→120 へ変更) つらかったと思いますが、IPv6 dualstack だったため QUIC で繋がるような大部分は NAPT を必要としていなかったというのが大きいと思います。IPv4 single stack で 1装置のみの検証回線では max-entries per-address 350 でぎりぎりかなという印象。みなさん IPv6 対応しましょう…… といいつつ、非 BGP 回線で IPv6 の冗長が面倒で安価にすまないなあ…と思っている今日この頃です。ご家庭は簡単なんだけど、オフィスがね…。</p> <p>いちおうセーフティで実は今回 NAPT outer address については装置ごとに 2 つ、合計で 4 つ用意していました。実際には NAPT 装置ごとに 1、合計 2 で十分だったと思います。NAPT outer address の使い分けについては csw→gw の ECMP で振り分けた上で gw 上で inner source address の上位 1 ビットで行っていました。なお、 採用している DHCPv4 サーバー kea4-dhcp はデフォルトでは sequential にアドレスを割り当てるため source address での振り分けを機能させるために random allocator が導入された <a href="https://github.com/ruby-no-kai/rubykaigi-nw/pull/71">kea をビルドする対応</a> が必要でした。</p> <h2 id="L1-L2-設計">L1-L2 設計</h2> <p><figure class="figure-image figure-image-fotolife" title="RubyKaigi 2023 Wi-Fi L1 構成図 (APを除く)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531001021.png" width="1200" height="934" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>RubyKaigi 2023 Wi-Fi L1 構成図 (APを除く)</figcaption></figure></p> <p>続いて、会場側の L1-L2 設計です。とはいえ、L2 についてはユーザー収容 VLAN などいくつかありますが特に複雑なことをしていないため、L1 を中心に解説します。</p> <p>原則としては対外回線のそばに L3 機能を担うルータ類を設置、そこから L2 スイッチを伸ばしていく形で設計します。会場から LAN のパッチパネルを借りれそうであれば借りますが、RubyKaigi 2022, 2023 では存在しなかったため全て自前配線となりました。</p> <p>また、100m 前後を越える区間については光ファイバー (1000BASE-SX) で接続するしかないので光ファイバーを敷設しています。RubyKaigi 2022 より必要になって導入しましたが、はたしてどこまで雑に扱っていいのかというのは毎回首をかしげながら作業しています。</p> <p>前述の通り RubyKaigi 2023 から対外回線を会場内の 2 箇所に設置するようにしたため、今回はルータ・回線が設置されている部屋同士の接続については最優先で敷設しました。</p> <p>前述した L3 経路で設置場所を跨ぐものはシアターパーク (スポンサーブースエリア) にある L2 スイッチ 2 つを経由して link が構成されています (図面上の点線がその link)。また収容 VLAN の default gateway は csw-01 であること、全ての通信が wlc-01 を経由する <a href="#f-bfaf8f05" name="fn-bfaf8f05" title="FlexConnect を利用していないため、ただ制約と思っていた Peer-to-peer blocking が実は動きそうなので来年は変えるかもしれません">*22</a> 関係で、ワーストでこの経路を trb→wlc→csw-01→gw-02 のように 2 往復する可能性があります。</p> <p>総ケーブル長を短く抑えるため、L2 スイッチは積極的に cascading をしますが、複数の部屋のトラフィックを担う接続は LAG を行うようにしています (そのため微妙に減ったり減らなかったり... 上記図面では省略)。今回は特に前述の往復が見込まれたため、csw-01/tpk/csw-02 間のリンクが最も重要でした。</p> <p>この構成で帯域上の問題は生じませんでしたが、ルータ間の接続を保持してしまっていることから撤収の際に依存関係が生じ、シアターパークの機材だけ撤収作業が最後までブロックされてしまいました。これを回避するため、次回以降は同様の構成を取る場合独立した物理配線をすることになりそうです <a href="#f-dcdf67bb" name="fn-dcdf67bb" title="冗長を組んでいるのでそれを信じるならばルータ同士の link は落としてしまっても問題は起こりづらいと思いつつも、Cisco WLC と AP を FlexConnect で運用してなかったりするので微妙なところ">*23</a>。長距離ケーブルが増えることによる敷設コストの増加よりも、撤収コストが安い方が勝ると考えているためです (みんなはやく打ち上げに行きたいというわけ)。</p> <p>配線経路や cascading の接続といった設計は図面を元に考えて現地に 1~2 度下見にいって決定します。レーザー距離計や実際のケーブルを持ち込み、ケーブルを通せるかどうか、実際必要な長さはどうかという点を徹底的に確認します。</p> <p>RubyKaigi 2023 では回線・ルータは主ホール楽屋と小ホール音響室に設置しました。そこから 3F のオープンスタジオ (#rubykaigiC), 4F の Hack Space が最も遠く、主・小ホール間は実験劇場 (主ホール舞台裏) とシアターパーク (スポンサーブース) を経由して接続していました。特に表に出る経路では3F, 4Fへ向かう光ファイバーが目立ったのではないでしょうか。たまたま綺麗に縦に通すことができたので、やっとるな〜と写真にでも収めたりしてもらえていればうれしいです (?)。</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/sora_h/20230531/20230531001103.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>敷設した光ファイバーの様子。前提として長距離なのと万が一の破損や不良時の交換に備え、各所ドアの手前などで分割しています</figcaption></figure></p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/rubykaigi?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigi</a> むしろ誰か他の人が撮影してくれてたら嬉しい写真 (5フロア分の高さを垂直に上昇する光ファイバー) <a href="https://t.co/A6tF3EMkEL">pic.twitter.com/A6tF3EMkEL</a></p>&mdash; osyoyu (@osyoyu) <a href="https://twitter.com/osyoyu/status/1658128324940427265?ref_src=twsrc%5Etfw">2023年5月15日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <h2 id="Wi-Fi-区間">Wi-Fi 区間</h2> <p><figure class="figure-image figure-image-fotolife" title="各部屋に設置した L2 スイッチと Wi-Fi AP の様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531001146.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>各部屋に設置した L2 スイッチと Wi-Fi AP の様子</figcaption></figure></p> <p>最初に書いたように本稿では深くは解説しませんが、</p> <ul> <li>低データレートの拒否</li> <li>基本は RRM に任せて power, channel は自動調整</li> <li>Dual 5 GHz radio の活用</li> <li>AP を適切にたくさん置く</li> <li>2.4 GHz は client band steering に頼らずに SSID を分けたり切ったりする</li> </ul> <p>である程度は…というところ。今年はあまり Wi-Fi 区間はリアルタイムにモニタリングしていなかったので、本当にこれでいいのかと思いつつも特に問題を感じないから良いのかなと思っています。client load balancing とかどれくらいちゃんと動いていたのかなあ。ここ数年は L3-L4 を主に見ていたので、来年は Wi-Fi またもう少し見てみても良いのかもしれません。会場で繋がりにくいなと感じた際にはTwitter #rubykaigiNOC ハッシュタグなどでご報告くださいね。</p> <p>個人的にはスペクトラムアナライザを利用したサイトサーベイなどは全然できていないためこのへんに手を出したい気持ちがあります。いつも雰囲気で機材配置してる。</p> <h2 id="その他の取り組み">その他の取り組み</h2> <h3 id="8021X-WPA23-Enterprise">802.1X (WPA2/3 Enterprise)</h3> <p>Honeypot AP を防ぐため、また per-client encryption key のため、WPA2/3 Enterprise で 802.1X を実装していました。EAP-PEAP, EAP-TTLS によるパスワード認証で、全参加者で共有の資格情報でログインするという点ではこれまでの WPA2/3 Personal (PSK) での運用と変わりありません。</p> <p>接続に際し手順がやや複雑になるのでどうなるかな、と思っていたものの、やってみたら 9 割程度は問題なく接続できていたようで良かったです。ただそのうちのどれくらいが盲目的に証明書を受け入れていたかどうかは分かりません。</p> <p><figure class="figure-image figure-image-fotolife" title="macOS が接続時に表示する Verify Certificate プロンプトで証明書を確認する様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531001226.png" width="1200" height="922" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>macOS が接続時に表示する Verify Certificate プロンプトで証明書を確認する様子</figcaption></figure></p> <p>証明書の CN を welcome.rubykaigi.org にして歓迎感を出してみていたんですが、これに気付いた人はいったいどれくらいいたんだろうか…</p> <h3 id="Public-dashboard-と-timelapse">Public dashboard と timelapse</h3> <p>今回の Grafana はインターネットからログインせず public に見られるようにしていました <a href="#f-d7b8ebd7" name="fn-d7b8ebd7" title="最近の Grafana には public dashboard 機能として一部だけ絞って公開することができるようになっていますが、Weathermap plugin などが未対応だったりするため Grafana 自体を公開、非公開にしたい data source は org を分けることで対応していました">*24</a>。</p> <p>あわせて、敷設撤収時の Grafana ダッシュボードのタイムラプスを録画していました。様子が見えてすこしおもしろい。</p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="en" dir="ltr"><a href="https://twitter.com/hashtag/rubykaigi?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigi</a> <a href="https://twitter.com/hashtag/rubykaigiNOC?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigiNOC</a> Grafana time lapse Day 0 (Set up day) <a href="https://t.co/41nZbag6XN">pic.twitter.com/41nZbag6XN</a></p>&mdash; そらは (@sora_h) <a href="https://twitter.com/sora_h/status/1657583321591988227?ref_src=twsrc%5Etfw">2023年5月14日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> <blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="en" dir="ltr"><a href="https://twitter.com/hashtag/rubykaigi?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigi</a> <a href="https://twitter.com/hashtag/rubykaigiNOC?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigiNOC</a> Grafana time lapse Day 3 (Teardown day) <a href="https://t.co/ydSmFgOC1S">pic.twitter.com/ydSmFgOC1S</a></p>&mdash; そらは (@sora_h) <a href="https://twitter.com/sora_h/status/1657584547490570242?ref_src=twsrc%5Etfw">2023年5月14日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <h3 id="DNS-over-HTTPS">DNS over HTTPS</h3> <p>DNS over HTTPS (DoH) をリゾルバで提供していました。詳細は下記の記事を参照してください。およそ ⅓ は DoH でクエリされていて、Apple デバイスの勢いを感じます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fblog.kmc.gr.jp%2Fentry%2F2023%2F05%2F10%2F165300" title="RubyKaigiとDNS-over-HTTPSとDDR - KMC活動ブログ" 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></p> <p>今回は KMC の PI を利用して IP SAN を持った TLS 証明書を取得しましたが、DDR の現状の仕様だと IP SAN を持った valid な証明書が必要で、private IPv4 address 帯でデプロイするにはやや敷居が高いという印象です。</p> <p>このデプロイメントについても <a href="https://github.com/ruby-no-kai/rubykaigi-nw">https://github.com/ruby-no-kai/rubykaigi-nw</a> にコードが存在しているので、眺めてみてください。</p> <p>L3-L4 レイヤでの DNS リゾルバへの対応がいくつかありました。まず、IP SAN での証明書検証を正しく通すために SAN に入っている KMC の IP アドレスで DNS リゾルバの AWS NLB へ接続できる必要があります。AWS NLB に直接このアドレスを持たせることは困難 <a href="#f-df33b3c8" name="fn-df33b3c8" title="2 AZで利用することを考えると /28 ブロックを KMC の IP アドレスから追加で VPC cidr と subnet へ 2 つ追加する必要があり、番号資源を消費してしまう。もしくは何かしらで Static NAT を EC2 で実装。">*25</a> です。そのため gw の downstream 側 interface で Static NAT を設定、outside (downstream側)にリゾルバのアドレス、inside に NLB のアドレスを指定していました (NAPT は internet 側, upstream で設定しているので、ここではユーザ側を外側ネットワークと認識させています)。</p> <p>また、これは上記記事でも言及されている内容ですが、AWS VPC 内にデプロイした DNS リゾルバから権威サーバへのクエリは AS59128 のインターネット接続を経由させています。br で NAPT を有効にはしたくなかったため、Private IP address を持つ NAT Gateway を VPC で作成 <a href="#f-2a5b0198" name="fn-2a5b0198" title="2021 年末頃から NAT Gateway は Internet gateway と EIP を必須とせず、private IP を outer address とすることができるようになっています">*26</a> して専用のサブネットはこの NAT Gateway を default route にしています。NAT Gateway からは RubyKaigi PoP へ向くよう default route に vgw を指定、そして br のインターネット側 interface で Static NAT を実装していました。これは Terraform 上で onpremises サブネットとして定義されている内容です。</p> <h2 id="Acknowledgements">Acknowledgements</h2> <p>筆者 (<a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a>) は主に L1-L4 の設計、L2-L4 の構築を担当していました。当日の L1 作業 (物理配線の敷設と撤収) の大部分は <a href="https://rubykaigi.org/2023/about">RubyKaigi 2023 NOC チームのみなさん</a> に対応してもらいました <a href="#f-adb6b39c" name="fn-adb6b39c" title="id:sora_h は RubyKaigi 2021, 2022, 2023 で用意したバーチャル会場や配信の面倒を見ているので、そちらにも手を持っていかれがちのため…">*27</a>。いつもありがとうございます。</p> <p>加えて、冒頭の注釈で言及したように RubyKaigi の Wi-Fi 機材は殆どがスポンサーさまからの協賛費で購入したものです。最新機材やコンパクトな機材で揃え、機材の貸し借りで手配や返却が複雑・機材が不揃いになり運用が複雑になることを避けるために導入しましたが、これについては Wi-Fi Sponsor である弊社以外のスポンサーのみなさまのおかげです。RubyKaigi スポンサーのみなさま、いつもご支援ありがとうございます。</p> <p>また AS59128 KMC へトランジットを提供していただいている AS59103, AS59105 のみなさまにも感謝します。</p> <h2 id="まとめ">まとめ</h2> <p><figure class="figure-image figure-image-fotolife" title="荷物整理発送の Day 5 で機材を並べた様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230531/20230531001318.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>荷物整理発送の Day 5 で機材を並べた様子</figcaption></figure></p> <p>RubyKaigi 2023 の Wi-Fi ネットワークにおける足まわりについて解説しました。</p> <p>来年も <a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> は Wi-Fi NOC として活動する予定です。また同様の構成を取る見込みなため、この情報を踏まえて mtr や traceroute 結果を楽しむとよいでしょう。</p> <p>また、この規模のネットワークに対して何かをデプロイして遊んでみたい、というような主体性のある NOC メンバーはいつでも歓迎です。興味があればお声がけくださいね。</p> <h2 id="あわせて読みたい">あわせて読みたい</h2> <ul> <li><a href="https://techlife.cookpad.com/entry/2023/05/18/121547">RubyKaigi 2023の冷蔵庫は何だったのか - クックパッド開発者ブログ</a> <ul> <li>RubyKaigi 2023 では <a href="https://cookpad.connpass.com/event/277569/">Rubyists on Rails</a> 企画と Wi-Fi スポンサーに加えて実は冷蔵庫を提供していました。RubyKaigi 運営で松本ブルワリーさんにお願いしたビールを冷やすのどうしよう? と社内で相談したら Cookpad から冷蔵庫も提供することになりました。</li> <li>実はこの冷蔵庫で動いている Linux box のネットワークや OS イメージのビルド・OTA まわりも <a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> が本業で製作したものです。バックエンドからエッジまで Ruby が動いている冷蔵庫になっています!</li> </ul> </li> </ul> <div class="footnote"> <p class="footnote"><a href="#fn-132f6ad6" name="f-132f6ad6" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">Ruby Committers’ スポンサー (2017-2022) や Rubyists on Rails スポンサー (2023) も平行してやっています。Wi-Fi スポンサーは 2017 年から</span></p> <p class="footnote"><a href="#fn-8f477a84" name="f-8f477a84" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">RubyKaigi の Wi-Fi 機材の大半はスポンサーのみなさまからの協賛金をもとにして RubyKaigi で購入所有しています。スポンサーのみなさま、ありがとうございます!</span></p> <p class="footnote"><a href="#fn-33e8c248" name="f-33e8c248" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">RubyKaigi 2022 でもほぼ同様の構成を取っていました</span></p> <p class="footnote"><a href="#fn-b9ba4439" name="f-b9ba4439" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">回線工事に立ち合って直後に計測した結果それぞれで inbound 100Mbps しかでず結構ひやひやしていた</span></p> <p class="footnote"><a href="#fn-57341175" name="f-57341175" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">ただし、RubyKaigi 2022, 2023 ではバーチャル会場へのセッションのライブ配信を行っていたので、ここについては重要度が高いものになっていました</span></p> <p class="footnote"><a href="#fn-0ff2ecc0" name="f-0ff2ecc0" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">Cookpad Japan では主に ECS が利用されていますが、たまには味変もいいよねというのと、Helm で雑に立てるのでいいか…という理由で EKS + Bottlerocket (Arm64 only) にしています</span></p> <p class="footnote"><a href="#fn-ef76e978" name="f-ef76e978" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">RubyKaigi は地方開催を続けているためサービスエリアの都合選択肢に入ることはありませんが、サービスエリア内なのであればフレッツ光ネクスト, 光クロスを 1 本ずつ契約することで高い確度で別の機材に収容されることが期待できます。光ファイバについてはもしかしたら共用している部分があり、経路上の物理障害についてはどのみち運かもしれません</span></p> <p class="footnote"><a href="#fn-a5c0bc08" name="f-a5c0bc08" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">private IPv4 prefix は IPsec VPN、それ以外は IPv4/v6 over IPv6 トンネル。ルータ負荷とMTUを考慮して IPsec の対象にするトラフィックを絞っていますが、IPsec だけでも今採用している機材なら十分性能が出るかもしれません (IX2215)。暗号化されないトンネルで利用するプロトコルについては IX シリーズだと IPv4/v6 over IPv6, EtherIP, GRE が候補になりますが、GRE はキープアライブなどやや複雑なので外し、EtherIP は RubyKaigi 2022 で採用していましたが、L3 ルーティングをするために Tunnel interface とは別に BVI interface を利用する必要があります。IX シリーズの IP over IP は 1 interface で v4/v6 dualstack にできないため、EtherIP でも IP over IP でも 2 interface 管理する必要があるのなら IP over IP の方がシンプルだなと思い直して今回はそのようになりました。</span></p> <p class="footnote"><a href="#fn-f9da842f" name="f-f9da842f" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text">traceroute して出てくる逆引き名にも出現していたと思いますが、気付いたひとはいるかな?</span></p> <p class="footnote"><a href="#fn-66cd9e8a" name="f-66cd9e8a" class="footnote-number">*10</a><span class="footnote-delimiter">:</span><span class="footnote-text">正確にはキャンセルになった in-person の RubyKaigi 2020 の頃から AS59128 からのトランジットと番号資源の提供を予定していました</span></p> <p class="footnote"><a href="#fn-2d7a2b81" name="f-2d7a2b81" class="footnote-number">*11</a><span class="footnote-delimiter">:</span><span class="footnote-text">はやく IPv6 outer の StS VPN をサポートしてほしい!</span></p> <p class="footnote"><a href="#fn-7dd987c9" name="f-7dd987c9" class="footnote-number">*12</a><span class="footnote-delimiter">:</span><span class="footnote-text">余談: AWS re:Invent の会場 Wi-Fi も同じように public dxvif で Direct Connect してそうな雰囲気がありますね</span></p> <p class="footnote"><a href="#fn-36c5fbbd" name="f-36c5fbbd" class="footnote-number">*13</a><span class="footnote-delimiter">:</span><span class="footnote-text">本当は RubyKaigi 2022 からそうなる予定でしたが、AWS 側の承認が間に合っていませんでした</span></p> <p class="footnote"><a href="#fn-f2c5a6a4" name="f-f2c5a6a4" class="footnote-number">*14</a><span class="footnote-delimiter">:</span><span class="footnote-text">Cookpad の余剰ラックスペースや資源は RubyKaigi に加え、AS59128 にも貸し出しています</span></p> <p class="footnote"><a href="#fn-19c686b6" name="f-19c686b6" class="footnote-number">*15</a><span class="footnote-delimiter">:</span><span class="footnote-text">設営時に余裕がなくてケーブルコスメが一切できてないまま運用に突入してしまったのが悔やましい。</span></p> <p class="footnote"><a href="#fn-1b7e9824" name="f-1b7e9824" class="footnote-number">*16</a><span class="footnote-delimiter">:</span><span class="footnote-text">IX シリーズに IPv6 BGP のサポートが欲しい…</span></p> <p class="footnote"><a href="#fn-7940fb45" name="f-7940fb45" class="footnote-number">*17</a><span class="footnote-delimiter">:</span><span class="footnote-text">5-tuple, per-flow ECMP であれば同じ flow は異なるパケットでも同じ宛先が選択されるため問題なく動きますが、障害などでルーティングテーブルに変動が起きたら TCP セッション等は普通に切れちゃいます</span></p> <p class="footnote"><a href="#fn-d5e57e57" name="f-d5e57e57" class="footnote-number">*18</a><span class="footnote-delimiter">:</span><span class="footnote-text">ただし、VPC については AWS Direct Connect 経路を Site-to-Site VPN 経路より優先する経路制御をしているため、br-01.nrt に VPC 宛パケットが到着した場合は Direct Connect 接続をしている br-01.hnd にルーティングされます</span></p> <p class="footnote"><a href="#fn-ef7c9a2f" name="f-ef7c9a2f" class="footnote-number">*19</a><span class="footnote-delimiter">:</span><span class="footnote-text">IGP は OSPF を起動。next hop self して IGP なしでも良いのかも…?</span></p> <p class="footnote"><a href="#fn-659ad140" name="f-659ad140" class="footnote-number">*20</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここではTCP/UDPなどflowを認識して外部からの新規着信を防ぐものを指します。NEC IX シリーズでは dynamic アクセスリストです。</span></p> <p class="footnote"><a href="#fn-8d283c7d" name="f-8d283c7d" class="footnote-number">*21</a><span class="footnote-delimiter">:</span><span class="footnote-text">IPv4 インターネット宛通信は NAPT があるのである程度は落とされると思います</span></p> <p class="footnote"><a href="#fn-bfaf8f05" name="f-bfaf8f05" class="footnote-number">*22</a><span class="footnote-delimiter">:</span><span class="footnote-text">FlexConnect を利用していないため、ただ制約と思っていた Peer-to-peer blocking が実は動きそうなので来年は変えるかもしれません</span></p> <p class="footnote"><a href="#fn-dcdf67bb" name="f-dcdf67bb" class="footnote-number">*23</a><span class="footnote-delimiter">:</span><span class="footnote-text">冗長を組んでいるのでそれを信じるならばルータ同士の link は落としてしまっても問題は起こりづらいと思いつつも、Cisco WLC と AP を FlexConnect で運用してなかったりするので微妙なところ</span></p> <p class="footnote"><a href="#fn-d7b8ebd7" name="f-d7b8ebd7" class="footnote-number">*24</a><span class="footnote-delimiter">:</span><span class="footnote-text">最近の Grafana には public dashboard 機能として一部だけ絞って公開することができるようになっていますが、Weathermap plugin などが未対応だったりするため Grafana 自体を公開、非公開にしたい data source は org を分けることで対応していました</span></p> <p class="footnote"><a href="#fn-df33b3c8" name="f-df33b3c8" class="footnote-number">*25</a><span class="footnote-delimiter">:</span><span class="footnote-text">2 AZで利用することを考えると /28 ブロックを KMC の IP アドレスから追加で VPC cidr と subnet へ 2 つ追加する必要があり、番号資源を消費してしまう。もしくは何かしらで Static NAT を EC2 で実装。</span></p> <p class="footnote"><a href="#fn-2a5b0198" name="f-2a5b0198" class="footnote-number">*26</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://aws.amazon.com/about-aws/whats-new/2021/06/aws-removes-nat-gateways-dependence-on-internet-gateway-for-private-communications/">2021 年末頃から</a> NAT Gateway は Internet gateway と EIP を必須とせず、private IP を outer address とすることができるようになっています</span></p> <p class="footnote"><a href="#fn-adb6b39c" name="f-adb6b39c" class="footnote-number">*27</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> は RubyKaigi 2021, 2022, 2023 で用意したバーチャル会場や配信の面倒を見ているので、そちらにも手を持っていかれがちのため…</span></p> </div> sora_h RubyKaigi 2023の冷蔵庫は何だったのか hatenablog://entry/820878482933525039 2023-05-18T12:15:47+09:00 2023-05-18T13:54:55+09:00 エンジニアの成田(@mirakui)です。最近はクックパッドマートの流通基盤エンジニアとして、商品の流通に関わるソフトウェアやハードウェアに携わっています。 さて、クックパッドは先日長野県の松本で開催された RubyKaigi 2023 にスポンサーとして参加しました。そのスポンサーシップの一環として、参加者に配られるドリンクを冷やすための冷蔵庫を提供しました。 会場に設置した6台の冷蔵庫は、私たちが「マートステーション」と呼ぶ、クックパッドマートにおいてユーザーが購入した商品を受け取るための冷蔵庫です。現在は都内を中心に、駅やコンビニエンスストア、マンションの共用部といった生活動線に設置して… <p>エンジニアの成田(@mirakui)です。最近はクックパッドマートの流通基盤エンジニアとして、商品の流通に関わるソフトウェアやハードウェアに携わっています。</p> <p>さて、クックパッドは先日長野県の松本で開催された RubyKaigi 2023 にスポンサーとして参加しました。そのスポンサーシップの一環として、参加者に配られるドリンクを冷やすための冷蔵庫を提供しました。</p> <p>会場に設置した6台の冷蔵庫は、私たちが「マートステーション」と呼ぶ、クックパッドマートにおいてユーザーが購入した商品を受け取るための冷蔵庫です。現在は都内を中心に、駅やコンビニエンスストア、マンションの共用部といった生活動線に設置しています。マートステーションの技術的な詳細は下記の記事をご覧下さい。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2023%2F02%2F07%2F123000" title="クックパッドが手がけるスマート冷蔵庫「マートステーション」の開発の歴史2023 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2023/02/07/123000">techlife.cookpad.com</a></cite></p> <p>今回設置したのは、上記の記事中で "JCM-Mk4" と呼んでいる、現行型である第4世代の冷蔵庫です。それまでのモデル第3世代とはベースとなる冷蔵庫は同じですが、断熱性能を高めて結露対策を行ったり、Raspberry Pi を使うのを止めて、より安定性の高い産業用の機材に変更したりといった改良が加わっています。</p> <h1 id="冷蔵庫監視ダッシュボード">冷蔵庫監視ダッシュボード</h1> <p>会場のクックパッドブースでは、6台の冷蔵庫をリアルタイムに監視する <a href="https://grafana.com/">Grafana</a> ダッシュボードを展示していました。</p> <p><figure class="figure-image figure-image-fotolife" title="会場で展示した冷蔵庫のダッシュボード"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mirakui/20230518/20230518103327.png" width="1200" height="651" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>会場で展示した冷蔵庫のダッシュボード</figcaption></figure></p> <p>ダッシュボードには、会場に設置したそれぞれの冷蔵庫において、主に以下のような値を表示していました。</p> <ul> <li>冷蔵庫の庫内温度</li> <li>解錠状態</li> <li>ドアの開け閉めの状態</li> </ul> <p>各冷蔵庫には産業用IoTゲートウェイである<a href="https://www.centurysys.co.jp/products/linuxserver/mas110.html">FutureNet MA-S110</a> が取り付けられています。これは簡単に言うと小型 Linux PC であり、SIM カードやアンテナを取り付けることで、LTE 回線を通じてインターネットに繋がることができます。</p> <p>ダッシュボードは Grafana で作られており、AWS 上にある <a href="https://prometheus.io/">Prometheus</a> に格納されたデータを表示していました。Prometheus からは <a href="https://soracom.jp/services/gate/">SORACOM Gate</a> を経由して、各冷蔵庫の <a href="https://soracom.jp/services/air/">SORACOM Air SIM</a> と通信を行い、温度センサーや鍵の会場状態の値を取得しています。これらの値を返しているのは、もちろん Ruby です!(RubyKaigi しぐさ)Linux 上で Sinatra の API サーバが動いていて、冷蔵庫における各種デバイスの制御や状態取得を司っています。</p> <p>クックパッドマートは主に生鮮食品を配送するサービスであるため、商品の温度を担保することは事業において非常に重要です。たとえばもし冷蔵庫のドアが開けっぱなしになっていて庫内の温度が上がってしまうと、商品が傷んでしまう可能性があります。そのために私たちは冷蔵庫の温度やドアの開閉などを遠隔監視できるようにしており、会場で展示したダッシュボードは実際に運用で用いているものを簡易化したものです。</p> <h1 id="写真で振り返る-RubyKaigi-冷蔵庫">写真で振り返る RubyKaigi 冷蔵庫</h1> <p>RubyKaigi 開催前日です。会場に6台の冷蔵庫が届きました。各種の配線を行う私です。配線を終えて電源を入れればやがてオンラインになり、冷蔵庫の管理システムに自動的に登録されます。</p> <p><figure class="figure-image figure-image-fotolife" title="Photo by [@pastak](https://twitter.com/pastak/status/1658104770383454208)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mirakui/20230510/20230510174737.jpg" width="1016" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>設営中の筆者 / Photo by <a href="https://twitter.com/pastak/status/1658104770383454208">@pastak</a></figcaption></figure></p> <p>設営を完了した6台の冷蔵庫です。中にはドリンクが入っています。 <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/m/mirakui/20230511/20230511114951.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>会場に設置した冷蔵庫</figcaption></figure></p> <p>ドリンクはみやもとファームのりんごジュースと<a href="https://matsu-brew.com/">松本ブルワリー</a>のクラフトビールで、RubyKaigi から提供されました。りんごジュースはりんごの品種違いで4種類あり、風味の違いを楽しむことができました。松本ブルワリーのビールは Session IPA と RubyKaigi 2023 Matsumoto Lager で、どちらも RubyKaigi 2023 のオリジナルラベルがデザインされていました。 <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/m/mirakui/20230518/20230518104659.jpg" width="1200" height="561" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>提供されたドリンク</figcaption></figure></p> <p>冷蔵庫を開けるためには QR コードリーダーに解錠用の QR コードをかざす必要があります。実際の商品受け取りではクックパッドマートのアプリ上に表示される購入者用の QR コードを使って解錠するのですが、RubyKaigi の解錠では簡易的に、あらかじめ印刷した QR コードをぶら下げておくことで誰でも解錠できるようにしました。ちなみにこのカードは私の手作りです。QR コードは現地で<a href="https://techlife.cookpad.com/entry/2019/04/10/180000">マートのラベルプリンター</a>を使って印刷しました。 <figure class="figure-image figure-image-fotolife" title="解錠用のQRコード"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mirakui/20230510/20230510194444.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>解錠用のQRコード</figcaption></figure></p> <p>りんごジュースは全日提供でしたが、ビールの提供は17時以降のみにしていました。Ruby で制御しているスマートロックなので、17時以降でないと解錠できないというギミックも簡単に用意できるのですが、わかりやすさを優先して物理的に QR コードリーダーを封印していました。 <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/m/mirakui/20230511/20230511114907.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>物理的な封印。これもマートのラベルプリンターで現地作成</figcaption></figure></p> <p>クックパッドブースではダッシュボードの展示を行っていました。ビールの提供が始まる17時以降、ドアの開け閉めによって一斉にグラフが動き始めます。 <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/m/mirakui/20230511/20230511171822.jpg" width="1200" height="800" 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/m/mirakui/20230511/20230511172334.jpg" width="1198" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>温度監視の動作確認</figcaption></figure></p> <h2 id="おわりに">おわりに</h2> <p>RubyKaigi 2023 では、とてもおいしいりんごジュースとクラフトビールが用意されました。私たちは大量に冷蔵庫を所有するスポンサーとして偶然居合わせたので、喜んで冷蔵庫を提供しつつ、クックパッドマートの流通の裏側をデモする機会をいただきました。クックパッドマートでは、生鮮食品を適切な温度を保ちながらユーザーに届けるために様々な工夫を凝らしています。首都圏のサービス提供エリアにお住まいの方は、ぜひクックパッドマートで買物をしてみて、生産者から冷蔵庫に届くまでの技術に思いを馳せていただければ幸いです。 最後に、RubyKaigi 2023 スタッフのみなさまおよび、ブースに訪れていただいたみなさま、どうもありがとうございました。</p> mirakui いい感じのプランニングポーカー作りました hatenablog://entry/4207575160648131871 2023-05-12T11:30:45+09:00 2023-05-13T11:53:56+09:00 こんにちは。クックパッド事業部プロダクト開発グループの末田(@terfno_mai)です。 クックパッドがスポンサーする 2023 年のカンファレンスに向けてノベルティ制作をしました。 この記事では、今年作ったプランニングポーカーについて書きます。 プランニングポーカーとは プランニングポーカーについて ChatGPT に聞いてみました。 Q.プランニングポーカーについて 140 字程度で説明してください。 プランニングポーカーは、アジャイルソフトウェア開発において、作業の見積もりを行うための手法で、開発チームがカードを使って難易度を評価し、議論を重ねて合意を得ることで正確な見積もりを行います… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512094908.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> こんにちは。クックパッド事業部プロダクト開発グループの末田(<a href="https://twitter.com/terfno_mai">@terfno_mai</a>)です。 クックパッドがスポンサーする 2023 年のカンファレンスに向けてノベルティ制作をしました。 この記事では、今年作ったプランニングポーカーについて書きます。</p> <h2 id="プランニングポーカーとは">プランニングポーカーとは</h2> <p>プランニングポーカーについて ChatGPT に聞いてみました。</p> <p>Q.プランニングポーカーについて 140 字程度で説明してください。</p> <blockquote><p>プランニングポーカーは、アジャイルソフトウェア開発において、作業の見積もりを行うための手法で、開発チームがカードを使って難易度を評価し、議論を重ねて合意を得ることで正確な見積もりを行います。</p></blockquote> <p>基本的な使い方も ChatGPT に聞くと答えてくれるのでそちらを参照してください。クックパッドでのプランニングポーカーの運用については後述します。</p> <p>Q.プランニングポーカーの基本的な使い方について完結に教えてください。</p> <blockquote><p>プランニングポーカーの基本的な使い方は以下の通りです。</p> <ol> <li>チームメンバー全員が集まり、見積もりを行う作業についての理解を深めます。</li> <li>見積もりに使うカードを用意し、1〜100 の数字が書かれたカードを配布します。</li> <li>チームメンバーは、各作業について自分が考える見積もりを、カードを使って提示します。</li> <li>カードの数字によって見積もりがばらついた場合、チームメンバーは理由を話し合い、合意を得るまで議論を続けます。</li> <li>見積もりが合意されたら、次の作業に進みます。</li> <li>複数の見積もりが必要な場合、上記の手順を繰り返します。 プランニングポーカーは、チームメンバーが互いに議論しながら合意を形成するため、見積もりの正確性が向上するというメリットがあります。</li> </ol> </blockquote> <p>過去にプランニングポーカーの詳細な使い方について言及している記事もあります。合わせてどうぞ。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2015%2F06%2F04%2F180218" title="チーム開発の進め方 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2015/06/04/180218">techlife.cookpad.com</a></cite></p> <h2 id="今回制作したプランニングポーカーの構成">今回制作したプランニングポーカーの構成</h2> <p>今回配布するプランニングポーカーのカード構成は一般に流通しているものとは異なります。 1, 2, 3, 5, 8, 13/? の 6 枚 1 セットで 9 セット 54 枚です。紛失時のために、白紙になっているカードが 1 枚入っています。もしなくしてしまったら、いい感じに書いて代用してください。合計 55 枚 1 束にしています。なぜこの構成になっているかについては後述します。</p> <h3 id="13カードについて">「13/?」カードについて</h3> <p>このカードは少し特殊です。13 を表示したいときは、? を隠してください。 ? を表示したいときは、13 を隠してください。右手で持つといい感じになる想定です。</p> <div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512105902.jpg" width="1200" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512105909.jpg" width="1200" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div> <h2 id="クックパッドでのプランニングポーカー活用">クックパッドでのプランニングポーカー活用</h2> <p>私が所属しているプロダクト開発グループのフィーチャーチームではスクラム開発を採用しています。スクラム開発では、ある一定期間を「スプリント」として捉えます。 私のチームでは、スプリントの開始/終了のタイミングで「スプリントレビュー」「レトロスペクティブ」「スプリントプランニング」をしています。 スプリントプランニングで、次のスプリントで着手する施策について見積もりをしていて、この見積もりにプランニングポーカーをしています。プランニングポーカーに使うカードは私の私物のトランプや、UNO などで代用していました。</p> <h3 id="もやもや">もやもや</h3> <p>「さすがにプランニングポーカー使いたいな〜。毎回トランプから使う数字だけ取り出すのめんどいし…」 「かといって普通のプランニングポーカーだと使わないカードあるし…」</p> <p>一般に流通しているプランニングポーカーの構成は、0, 1, 2, 3, 5, 8, 13, 20, 40, 100, ½, ?, 無限大, ☕️ などのカードが 1 セットになっていることが多いです。 私のチームでは、1 スプリント 2 週間としていました。また、プランニングポーカーの 1 ポイント = 1 時間 として扱っていました。なので、20 時間より大きな見積もりについては細分化するのでカードを使うことがなく、通常のプランニングポーカーは私達にとって過剰でした。また、1 束に 4 セット(4 人分)しか入っておらず、十数人規模の私達のチームでは 3 セット以上用意する必要があり、大変面倒です。 これらの理由から、いい感じのプランニングポーカーが欲しかったです。</p> <h2 id="祈り">祈り</h2> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512095215.png" width="1200" height="266" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> クックパッドには tech/tanzaku という、願い事を書いて叶ったら close するというリポジトリがあります。ここにいろいろ書いておいて、チャンスがあれば…</p> <h3 id="チャンス到来">チャンス到来!</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512095334.png" width="1200" height="680" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> RubyKaigi に向けてノベルティを作ることになり、口頭で提案してみると本当に作ることになりました。なんでも言ってみるもんですね。</p> <h2 id="制作">制作</h2> <p>「良質のプランニングポーカー」が欲しいです。良質なプランニングポーカーは良質なカードによってもたらされます。しかし、普通にカードを発注するだけでは私達の求めるカードを得られないことが一般に知られています。いい感じに設計し、発注する必要があります。</p> <p>私は 2.5 inch x 3.5 inch の紙製品をシャッフルすることにおいて、社内で最も秀でています。ガチで。<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup> 何を言っているか分からないかもしれませんが、私はこのサイズの紙製品について社内で最も詳しい社員でもあります。2.5 inch x 3.5 inch で 54 枚程度のいい感じのカードを作ってくれる会社を知っていました。そこにいい感じに発注することで、良質なカードが得られそうです。今回のプランニングポーカーは 54 枚程度に収めることにしました。</p> <h3 id="カードの構成について">カードの構成について</h3> <p>私達が使わないカードを排して、よく使うカードだけを選んで用意することにします。同じチームのテックリードや、他の部署のテックリードや同期とお話して、「1, 2, 3, 5, 8, 13 あればよくない?」という結論に至りました。13 より大きな見積もりは分割したい、また見積もり不可能を意思表示するために「?」も必要そうという話をしました。「1, 2, 3, 5, 8, 13」のどれか に「?」のカードを同居させれば 6 枚になり、 9 人分で 54 枚になります。1 セットで 9 人分まかなえるので、そこそこの人数のチームでもカバーできていい感じですね。</p> <div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512095920.jpg" width="1200" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512100052.jpg" width="1200" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div> <h3 id="カードそのものの仕様を考える">カードそのものの仕様を考える</h3> <p>今回は MPC<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup> に発注することにしました。いろいろな紙と仕上げがありますね。今回の発注数に合わせて選べる紙からいい感じの組み合わせを選んで提案しました。</p> <ul> <li>紙(Card stock): M31 Linen <ul> <li>M31 Linen は今回の部数(256 個)で作れるなかでは一番良さそうな紙です。</li> <li>私が普段使っているカードに比べるとやや分厚く、硬いです。</li> <li>一覧: <a href="https://www.makeplayingcards.com/pops/card-types.html">https://www.makeplayingcards.com/pops/card-types.html</a></li> </ul> </li> <li>枚数(Size of deck): up to 55</li> <li>印刷(Print type): full color print</li> <li>仕上げ(Finish): BETA playing card finish <ul> <li>仕上げ/finish とは表面加工のことです。</li> <li>カードの表面にエンボス加工や薬品によるコーティングがされています。</li> <li>一覧: <a href="https://www.makeplayingcards.com/pops/card-finish.html">https://www.makeplayingcards.com/pops/card-finish.html</a></li> <li>BETA 以外のやつはよくある百均のやつみたいになります。避けたい。</li> <li>BETA playing card finish は、紙製トランプによく使われるエンボス加工と薬品によるコーティングでカード同士がむらなく広がったり、カードの耐久性を高める仕上げです。</li> </ul> </li> <li>しおり(Booklets): なし <ul> <li>ゲーム用のカードならルールや宣伝をデザインするやつ。今回はなしで。</li> <li>詳細: <a href="https://www.makeplayingcards.com/pops/booklet-guide.html">https://www.makeplayingcards.com/pops/booklet-guide.html</a></li> </ul> </li> <li>箱(Packaging) <ul> <li>Custom tuck box (sealed base)</li> <li>Box printed: Outside</li> <li>Custom box finish: T30 UV coated (gloss)</li> <li>Custom box effect: Full color print</li> <li>Box seals: None</li> <li>Wrapping: Shrink-wrapped</li> </ul> </li> <li>Add-on: None <ul> <li>詳細: <a href="https://www.makeplayingcards.com/template/customer/package/playingcard/playingcard-box-template.html">https://www.makeplayingcards.com/template/customer/package/playingcard/playingcard-box-template.html</a></li> </ul> </li> </ul> <p>いい感じですね。</p> <h3 id="デザインを考える">デザインを考える</h3> <p>デザイナーの方と共通の用語を使うために、用語集を作りました。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512100139.png" width="1200" height="292" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>私物のコレクションをいくつか持ち込み、「いい感じにしてください!」とお願いしました。何度かの壁打ちの結果、コーポレートブランドデザインの Organic<sup id="fnref:3"><a href="#fn:3" rel="footnote">3</a></sup> 、オリジナルフォントの Cookpad Sans、クックパッドで使用している Cookpad Symbols などのデザイン資産をふんだんに使って、めちゃいい感じになりました。 デザイナーすごい…助かりました。</p> <h3 id="発注">発注!</h3> <p>MPC に発注しました。Fedex で届くらしいです。途中税関で止まってそうでしたが無事届きました! <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512100446.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="できあがり">できあがり!</h2> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/terfno/20230512/20230512102134.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>という感じで、tanzaku に書いた願いが叶いました。ありがとう…!!!!!!!</p> <p>最高のプランニングポーカーをノベルティとして用意していますが、最高故にたくさん作ることはできませんでした。スポンサーブースで誰でも簡単に入手できる感じではないです。ごめんなさい。</p> <p>RubyKaigi 2023 に限らず、他のカンファレンスにも持っていきます。何かしらのチャレンジをクリアした人などに配る予定です。会場でスポンサーブースを見かけたら是非立ち寄ってみてください。</p> <p>クックパッドではサービス開発に気持ちがあるやさしいエンジニアを募集しています。クックパッドで働くことに興味のある方もない方も是非クックパッドのスポンサーブースにお越しください。お待ちしております。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> <p>PLANNING POKER ® is a registered trademark of Mountain Goat Software, LLC Sequence of values is (C) Mountain Goat Software, LLC</p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> <a href="https://www.instagram.com/terfno/">https://www.instagram.com/terfno/</a><a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> <a href="https://www.makeplayingcards.com/">https://www.makeplayingcards.com/</a><a href="#fnref:2" rev="footnote">&#8617;</a></li> <li id="fn:3"> <a href="https://prtimes.jp/main/html/rd/p/000000255.000027849.html">https://prtimes.jp/main/html/rd/p/000000255.000027849.html</a><a href="#fnref:3" rev="footnote">&#8617;</a></li> </ol> </div> terfno クックパッドの最近のスマートキッチンの取り組み hatenablog://entry/4207112889985260519 2023-04-28T15:00:12+09:00 2023-04-30T08:21:02+09:00 大谷伸弥(@shinyaohtani)と申します。 クックパッドのスマートキッチンの取り組みについて少し公開しようと思います。 料理を自動記録 クックパッドでは毎日の料理をキッチンの現場から楽しみにするために、IRと可視光を同時に扱えるカメラを試作してきました。この写真はそのカメラと、情報提示のためのディスプレイをキッチンに設置した写真です。カメラはキッチンの換気扇部分にマグネットで取り付けることができるように設計したため料理の邪魔にならずに料理を観察できるようになっています。 このカメラを調理を把握するために利用しています。次の写真は唐揚げを観察しています。油の表面温度は今何度かという情報だ… <p>大谷伸弥(@shinyaohtani)と申します。</p> <p>クックパッドのスマートキッチンの取り組みについて少し公開しようと思います。</p> <h1 id="料理を自動記録">料理を自動記録</h1> <p>クックパッドでは毎日の料理をキッチンの現場から楽しみにするために、IRと可視光を同時に扱えるカメラを試作してきました。この写真はそのカメラと、情報提示のためのディスプレイをキッチンに設置した写真です。カメラはキッチンの換気扇部分にマグネットで取り付けることができるように設計したため料理の邪魔にならずに料理を観察できるようになっています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/ShinyaOhtani/20230428/20230428145735.jpg" width="1200" height="672" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>このカメラを調理を把握するために利用しています。次の写真は唐揚げを観察しています。油の表面温度は今何度かという情報だけでなく、IRカメラ画像から温度の少し下がった食材部分を自動認識し、唐揚げひとつひとつの揚げ時間を把握できるようになっています。また調理温度の変化をグラフで出しており、鶏肉を入れると温度が下がる様子もグラフから読み取れるようになっています。</p> <p>このようにIR画像の画像認識技術を組み合わせて調理情報を捉えることで新しいユーザ体験の提供ができると考えています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/ShinyaOhtani/20230428/20230428125936.jpg" width="1200" height="968" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>クックパッドが研究してきたキッチン画像処理技術はいくつかあります。</p> <ol> <li>可視光・赤外光カメラ <ul> <li>独自試作した画像入力機器</li> </ul> </li> <li>鍋位置検出 <ul> <li>温度上昇しているサークル部分</li> </ul> </li> <li>加熱中の食材位置検出 <ul> <li>鍋の中で鍋と温度が異なる食材の領域を全部抽出</li> </ul> </li> <li>食材の加熱トラッキング <ul> <li>鍋の中の食材ごとの加熱時間を計測</li> </ul> </li> <li>鍋の温度推定 <ul> <li>鍋の中の主要な領域の温度を推定</li> </ul> </li> <li>複数のカメラ画像位置合わせ <ul> <li>位置も画角も異なる2つのカメラの位置関係推定</li> </ul> </li> <li>ユーザ調理操作かどうか判別 <ul> <li>ユーザが鍋の上で調理操作をしているかどうかを推定</li> </ul> </li> <li>調理時間自動計測 <ul> <li>鍋に食材を入れたりかき混ぜてからの時間計測</li> </ul> </li> <li>ユーザ調理操作セクション認識 <ul> <li>鍋の上での調理操作の開始時刻と終了時刻を推定</li> </ul> </li> </ol> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/ShinyaOhtani/20230428/20230428125939.jpg" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>調理記録は「調理対象」「調理環境」「調理行動」の3つの要素に分けることができます。このシステムは画像だけでなく、重量や気温も統合して扱えるようになっており、部分的ではありますが3要素をリアルタイムで記録できるようになっています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/ShinyaOhtani/20230428/20230428125942.jpg" width="1200" height="681" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>これは自動記録から半自動でレシピにした<a href="https://cookpad.com/recipe/7247788">私のレシピ</a>です。調理画像だけでなく、材料の重さや加熱時間は自動記録したものを利用しつつ、テキストは自分で考えてレシピにしました。今ならChatGPTなど大規模言語モデルなども利用できそうですし、この研究を続けるとそう遠くない未来に完全自動レシピができるかもしれません。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/ShinyaOhtani/20230428/20230428125917.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="キッチンのコックピット化">キッチンのコックピット化?</h1> <p>キッチンから得られた情報を用いてできることは当然記録だけではありません。リアルタイムに提示することで得られるユーザ価値もあります。例えば鉄板の温度など自分では把握しづらかった情報を簡単に得られるようになります。つぎの画像は情報をすべて画面に出している図です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/S/ShinyaOhtani/20230428/20230428125930.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>一般に、情報提示は慎重にしなければ情報過多なり、大きな不便を生むこともあります。上の図は情報多すぎパターンかもしれません。提示情報は極限まで絞るというのが基本だと思います。どんな情報を提示するとユーザは料理を楽しめるのか、体験はきちんと検証しなければなりません。</p> <p>とはいえ一方で、飛行機のコックピットにある計器類の写真をみるとワクワクを感じるのは私だけでしょうか。飛行機のコックピットを思い出させるような上記システムがキッチンにあることで、少なくとも私は料理が楽しくなりました。将来のキッチンがどんなキッチンになるか楽しみです。</p> <h1 id="まとめ">まとめ</h1> <p>このようにキッチンとIoTは非常に相性がよくユーザの調理行動を変容させる可能性を秘めています。今回は技術視点でスマートキッチンの取り組みについて少し公開してみました。クックパッドではもちろん技術開発だけでなく体験を軸に活動をしております。</p> ShinyaOhtani NLP2023 に参加しました:座長編 hatenablog://entry/4207112889983146577 2023-04-21T09:00:04+09:00 2023-04-21T09:04:35+09:00 技術部機械学習グループの原島です。本連載では山口(@altescy)が発表編を、深澤(@fukkaa1225)が聴講編をお届けしてきました。最後にお届けするのは座長編です。 学会があれば発表があり、発表があればセッションがあり、セッションがあれば座長がいます。今年の言語処理学会であれば、延べ 80 人以上もの座長がいたようです。 このように沢山の座長がいるわけですが、その仕事には馴染みがない人も多いのではないでしょうか。そんな人にも、ある日突然、座長の依頼はやってきます。 このブログでは、単なる一つの事例ではありますが、今年の言語処理学会において私が座長として行なったことや気をつけたことについ… <p>技術部機械学習グループの原島です。本連載では山口(<a href="https://twitter.com/altescy">@altescy</a>)が発表編を、深澤(<a href="https://twitter.com/fukkaa1225">@fukkaa1225</a>)が聴講編をお届けしてきました。最後にお届けするのは座長編です。</p> <p>学会があれば発表があり、発表があればセッションがあり、セッションがあれば座長がいます。今年の言語処理学会であれば、延べ 80 人以上もの座長がいたようです。</p> <p>このように沢山の座長がいるわけですが、その仕事には馴染みがない人も多いのではないでしょうか。そんな人にも、ある日突然、座長の依頼はやってきます。</p> <p>このブログでは、単なる一つの事例ではありますが、今年の言語処理学会において私が座長として行なったことや気をつけたことについてお話します。</p> <h1 id="座長の仕事">座長の仕事</h1> <p>座長の仕事はおおきく会期前と会期中にわかれます。会期中に関しては、さらに、セッション前とセッション直前、セッション中にわかれます。</p> <h2 id="会期前">会期前</h2> <p>例年、座長の依頼は会期の一ヶ月くらい前にきます。セッションの名前と日時、仮のプログラムが伝えられるので、特に問題がなければ、了承の旨を伝えます。会期の三週間くらい前から大会参加マニュアル等の情報が公開されるので、しっかり目を通しておきます。</p> <p>もちろん、担当セッションの論文にも目を通しておきます。余談ですが、今年は会期の一週間前に予稿集が一般公開されましたね。私は去年と一昨年のプログラム委員を務めており、予稿集の一般公開にも関わっていた(というか、実作業を担っていた)のですが、一般公開されたのは会期の一ヶ月後だった記憶があります。今年のように論文のアクセシビリティが上がるのはすごくいいですね。</p> <p>また、予稿集の一般公開と同時に slack がオープンしたので、担当セッションのチャンネルに入っておきます。自分の自己紹介を書いたり、発表者の自己紹介や論文紹介を促します。これはオンライン開催やハイブリッド開催ならではの文化ですね。</p> <h2 id="会期中">会期中</h2> <h3 id="セッション前">セッション前</h3> <p>セッションの前日までに slack のチャンネルに発表者を invite しておくと安心です。会期の一週間前に slack がオープンしても、全員がすぐに slack に入るわけではありません。むしろ、会期が始まってから入っている人の方が多い印象を受けました。セッション直前に「発表者がいない!」と焦らないように、早めに invite しておくとよいです。発表者がまだ slack に入ってない場合は、共著者に声をかけるとよいです。</p> <h3 id="セッション直前">セッション直前</h3> <p>開始 15 分前に担当セッションの zoom がオープンするので、入ります。現地のスタッフに進行管理の説明を受けつつ、疑問点等があれば、解消します。私はリモート参加の座長だったので、現地の様子がはっきりわかりません。発表に対する質問が現地であった場合、それがどのように座長に伝えられるのかを聞いたりしました(ちなみに、質問があった場合は現地のスタッフが都度教えてくれました)。</p> <p>リモート参加の座長にとって重要なポイントをあと二つ挙げておきます。一つはセッションの時間を間違えないことです。現地参加であれば人の動きで次のセッションが始まる雰囲気を感じ取れます。リモート参加ではそうはいきません。座長がうっかり遅刻したなんてことは絶対に避けましょう。私は部屋中のアラームを鳴らしました。</p> <p>もう一つは zoom の URL を間違えないことです。「そんなん間違えるやつおらんやろ」と思うかもしれませんが、私は去年間違えました(しかも、参加者が数百人にも及ぶチュートリアルの座長でした)。直前に間違いに気づき、事なきを得ましたが、リモート参加ならではのうっかりポイントの一つだと思います。気をつけましょう。</p> <p>開始 1 分前になったら、セッションの名前と座長の名前や所属、発表の件数や形式、質疑の形式等を参加者に伝えます。</p> <h3 id="セッション中">セッション中</h3> <p>ついにセッションが始まりました。発表者の名前と所属を告げ、発表を始めてもらいます。今年は各発表用のスライドが slack に事前にアップロードされていました。そこで、発表が始まったら、発表用のスライドへのリンクを slack に貼るようにしました。また、セッションを盛り上げるため、slack 上の発言には積極的に絵文字を送るようにしました。</p> <p>今年は発表者が多いこともあり、口頭発表の時間は 15 分、質疑応答の時間は 3 分でした。発表開始後、13 分と 15 分にタイマーが鳴ります。そこで、15 分を過ぎたら、発表をまとめてほしい旨を発表者に伝えようと思っていました。思っていましたが、みなさん、しっかり 15 分にまとめていたので、特になにもすることはありませんでした。</p> <p>発表が終わると忙しいです。上記の通り、今年は質疑応答の時間が 3 分しかありませんでした。しかも、最後の 1 分は発表者の入れ替えの時間です。基本的には現地の質問を順番に取りあげます。時間があれば slack の質問も取りあげるつもりでしたが、質問も多く、時間も短かったので、ほとんどそういったことはありませんでした。残り 1 分になったら、slack での質問を促すようにします。slack は時間や場所を選ばず議論ができるのが便利ですね。今後、オフライン開催に戻っていったとしても、この仕組みは残ってほしいです。</p> <p>すべての発表が終わったら、発表者や参加者にお礼を述べて、セッションを締めます。最終的には slack も zoom も 70 名以上もの方にご参加いただきました。盛り上がってよかったです。</p> <h1 id="H11-言語処理応用-2">H11: 言語処理応用 (2)</h1> <p>せっかくなので、私が座長を担当したセッション「H11: 言語処理応用 (2)」で発表された研究を紹介します。未来学や医療、調理科学、広告等、様々な分野における自然言語処理の応用に関する研究でした。</p> <h2 id="H11-1-ホライゾンスキャニングの自動化のための言語処理応用"><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/H11-1.pdf">H11-1: ホライゾン・スキャニングの自動化のための言語処理応用</a></h2> <p>未来学の分野では、未来に起こりうる社会変化についてシナリオを記述するため、大規模な情報の収集(ホライゾン・スキャニング)と集約(プランニング)が行われているそうです。この研究では前者に着目し、未来を示唆する文書の探索とそれらに対するコメントの生成というタスクに取り組んでいます。</p> <p>2,266 記事からなるデータセットの構築・分析、BERT や word2vec 等の文書探索モデルの評価、BART やリード 3 法のコメント生成モデルの評価等は、いずれも今後の研究の基準・基盤となる成果だと思いました。未来学の分野における自然言語処理の応用可能性を感じる研究でした。</p> <h2 id="H11-2-所望の患者データを作るVariational-Auto-Encoderによる症例報告生成"><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/H11-2.pdf">H11-2: 所望の患者データを作る:Variational Auto-Encoderによる症例報告生成</a></h2> <p>医療言語処理の分野では、プライバシーの問題から共有可能なコーパスが少ないという課題があります。そのため、コーパス生成の研究が盛んです。GPT 等の利用も考えられますが、プロンプトのデザインは恣意的であるため、コントロール性の高い生成が困難という問題があります。例えば、プロンプト 1 とプロンプト 2 の中間的なプロンプトをデザインするのは難しいでしょう。</p> <p>そこで、この研究では Variational Auto-Encoder(VAE)の利用を試みています。VAE の潜在変数は連続的であるため、コントロール性の高い生成が可能です。実験では VAE の入出力が似ている(医学概念を再構成できる)こと、潜在変数の変化が出力に変化を与える(生成がコントロール可能である)ことが示されています。</p> <h2 id="H11-3-食感の物性と言語表現の対応の推定"><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/H11-3.pdf">H11-3: 食感の物性と言語表現の対応の推定</a></h2> <p>こちらは食感の物性(硬さ、凝集性、粘着性)と表現(柔らかい、ふわふわ、...)の対応を推定する研究です。調理科学分野の実証データにおける「(a) 食感の物性」と「(b) 材料の濃度」の対応と、トピックモデルで推定した「(b) 材料の濃度」と「(c) 食感の表現」の対応を (b) で紐付けることで、(a) と (c) の対応を推定しています。</p> <p>仕事柄、レシピに関する研究はかなり見てきたつもりでしたが、このような研究は初めて見ました。非常に新規性が高い研究だと思います。実際に食感の物性と表現の一致性が示されたのも面白いです。</p> <p>余談になりますが、この研究では、我々が 2015 年に公開したデータセットが使われていました。こういった面白い研究に使ってもらっているのを見ると、我々も非常に嬉しいです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2015%2F02%2F24%2F161915" title="クックパッドのデータを研究者に公開します - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2015/02/24/161915">techlife.cookpad.com</a></cite></p> <h2 id="H11-4-広告文生成タスクの規定とベンチマーク構築"><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/H11-4.pdf">H11-4: 広告文生成タスクの規定とベンチマーク構築</a></h2> <p>近年、広告市場の成長に伴い、広告文生成の研究が盛んです。一方、共通のデータセットがないため、タスクの合意形成が取られておらず、手法の比較も難しいという課題がありました。この研究ではこれらの課題を解決するため、CA Mutimodal Evaluation for Ad Text Generation(CAMERA)というデータセットを構築・公開しています。</p> <p>CAMERA には LP と検索キーワード、広告文のセットが 1.6 万件ほど含まれているそうです。LP については画像も含まれているため、マルチモーダルの研究にも使えます。また、評価用のデータには業種ラベルが付いているため、業種ごとの評価にも使えます。この研究は有用性の観点で委員特別賞を受賞されていましたが、納得の選考です。おめでとうございます!</p> <h2 id="H11-5-大規模言語モデルによる脚本データの解析-プロダクトプレイスメント挿入箇所の探索と評価"><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/H11-5.pdf">H11-5: 大規模言語モデルによる脚本データの解析: プロダクト・プレイスメント挿入箇所の探索と評価</a></h2> <p>ドラマや映画等の小道具や背景に商品や企業ロゴを露出する広告手法を「プロダクト・プレイスメント(PP:Product Placement)」と呼ぶそうです。PP を挿入できるシーンの探索や挿入する根拠の提示には担当者の時間や経験を要するという課題があります。この研究では大規模言語モデルを使い、これらの課題の解決を試みています。</p> <p>具体的には、GPT-2 や GPT-3 を使い、脚本中の顕在性が高いシーンや商品と関連性が高いシーンを抽出しています。特に Select all of the following sentences that conatin beverages といったプロンプトで脚本から飲料との関連性が高いシーンを抽出するのは GPT-3 の利用事例としてユニークだと思いました。こちらの研究はスポンサー賞の一つである博報堂DYホールディングス賞を受賞されています。おめでとうございます!</p> <h1 id="おわりに">おわりに</h1> <p>このブログでは、今年の言語処理学会で私が座長として行なったことや気をつけたことについてお話しました。これらは学会や学会の開催形式、セッションの発表形式等によって違うと思います。それでも、このブログがこれから座長を引き受ける人のなにかの参考になれば幸いです。</p> <p>言語処理学会には基本的に毎年参加していますが、今年もおおいに楽しませてもらいました。ハイブリッド開催の運営は大変だったと思います。大会委員会の皆様、大会プログラム委員会の皆様、大会実行委員会の皆様、ありがとうございました。来年は神戸ということで、いまから楽しみにしております。</p> <p>発表編、聴講編、座長編とお届けしてきた NLP2023 参加報告もこれで終わりです。ここからは、学会で見聞きした最新の研究成果を実際のサービスで使っていくフェーズです。</p> <p>クックパッドでは機械学習エンジニアを募集しています。また、学生の方に対しても就業型インターンシップ(機械学習コース)を開設しています。本連載を読んでご興味を持った方がいらっしゃれば、是非、私達の採用サイトをご覧ください。ご連絡をお待ちしております。</p> <ul> <li>機械学習エンジニア <ul> <li><a href="https://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/Kanagawa--Japan/--_R-002435">https://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/Kanagawa--Japan/--_R-002435</a></li> </ul> </li> <li>[就業型インターンシップ] 機械学習コース <ul> <li><a href="https://cookpad.wd3.myworkdayjobs.com/ja-JP/new_grad/job/Kanagawa--Japan/XMLNAME----_R-002691">https://cookpad.wd3.myworkdayjobs.com/ja-JP/new_grad/job/Kanagawa--Japan/XMLNAME----_R-002691</a></li> </ul> </li> </ul> jharashima NLP2023 に参加しました:聴講編 hatenablog://entry/4207112889982928157 2023-04-20T14:00:00+09:00 2023-04-20T13:44:07+09:00 こんにちは。技術部機械学習グループの深澤(@fukkaa1225)です。 3月に沖縄で行われた言語処理学会2023に参加してきました。本連載の1つ目ではクックパッドとして「レシピに含まれる不使用な材料等に関する記述の抽出」という発表を山口(@altescy)がしてくれた様子をお伝えしました。自分は共著者兼聴講参加です。 本連載の2つ目となるこの記事では気になったり面白いと感じた論文やセッションを紹介していきます。 印象に残ったセッション1: 形態素・構文解析 形態素解析といえば MeCab ですが、一強というわけではもちろんなく、様々なアプローチが提案されています。今回のセッションでは KWJ… <p>こんにちは。技術部機械学習グループの深澤(<a href="https://twitter.com/fukkaa1225">@fukkaa1225</a>)です。</p> <p>3月に沖縄で行われた言語処理学会2023に参加してきました。本連載の1つ目ではクックパッドとして「レシピに含まれる不使用な材料等に関する記述の抽出」という発表を山口(<a href="https://twitter.com/altescy">@altescy</a>)がしてくれた様子をお伝えしました。自分は共著者兼聴講参加です。</p> <p>本連載の2つ目となるこの記事では気になったり面白いと感じた論文やセッションを紹介していきます。</p> <h2 id="印象に残ったセッション1-形態素構文解析">印象に残ったセッション1: 形態素・構文解析</h2> <p>形態素解析といえば MeCab ですが、一強というわけではもちろんなく、様々なアプローチが提案されています。今回のセッションでは KWJA・vibrato、そして新たに提案された Jagger という多様な発表を一度に聞くことができたのは非常に貴重な経験でした。</p> <h3 id="テキスト生成モデルによる日本語形態素解析">テキスト生成モデルによる日本語形態素解析</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C2-3.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C2-3.pdf</a></p> <p>京都・早稲田発の汎用言語モデルを用いて日本語を統合的に解析するツール KWJA についての発表です。本発表は KWJA 1.0 でトークン分類問題として定式化した形態素解析ではなく、単語の読み・原形等の推定を生成問題として定式化した、という内容でした。用いられている汎用言語モデルは T5 の他言語版である mT5 のXLサイズで、既存手法を上回る精度を達成したと報告されています。</p> <p>感想: 見出し語強制デコーディング・疑似データによる曖昧性解消など、モデルの入力と出力それぞれについて必要な処理を加えており試行錯誤に基づく工夫も見られてとても勉強になりました。KWJA 2.0 は <a href="https://github.com/ku-nlp/kwja">https://github.com/ku-nlp/kwja</a> で既に公開されており、少し試してみましたが、速度は置いておいて非常に高い精度で形態素解析・読み推定が行われていることを実感できました。今後の発展が楽しみです。</p> <h3 id="最小コスト法に基づく形態素解析におけるCPUキャッシュの効率化">最小コスト法に基づく形態素解析におけるCPUキャッシュの効率化</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C2-4.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C2-4.pdf</a></p> <p>最小コスト法に基づく形態素解析は入力文に現れる形態素をノードとしたグラフ構造(形態素ラティス)を構築し、その後コストが最小となる経路を探索し、対応する形態素列を出力する、といった手法です。 MeCab が代表的です。代表的な手法である一方で問題点も存在する、といった指摘がされており、こちらの発表ではその解決法を提案していました。具体的に焦点が当てられた問題点は辞書構造の肥大化に伴う CPU キャッシュの効率低下です。この問題点に対して、データを格納する際にバイト構造ではなく Unicode で持つようにした上で、頻度順に並び替えることで局所性を改善したと報告されています。</p> <p>感想: <a href="https://github.com/daac-tools/vibrato">https://github.com/daac-tools/vibrato</a> から利用できる vibrato の発表でした。実際に <a href="https://vibrato-demo.pages.dev/">https://vibrato-demo.pages.dev</a> からブラウザで動くデモを試すことができるのですが、かなり高速で動作していることがわかります。論文中でも速度に重きが置かれた比較が行われていました。記述されていない形態素解析の性能が気になりますが、今後も継続して改善を行っていくとのことなので非常に楽しみです。</p> <h3 id="最長一致パターンに基づく高速高精度な日本語形態素解析">最長一致パターンに基づく高速・高精度な日本語形態素解析</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C2-5.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C2-5.pdf</a></p> <p>様々な高速化が行われてきた現代でこれ以上速くなることは当分ないかな、と思っていたのですが最長一致法でさらなる高速化が提案されました。学会が始まる前から話題になっており、個人的に注目度の高かった発表だったと思います。 辞書を元にパターンをまとめて、そのパターンを使って文章の先頭から照合を行い条件に合致したパターンを解析結果として提供する、というのが概要です。京都大学テキストコーパスと京都大学ウェブ文書リードコーパスでの実験結果では MeCab や Vaporetto と同程度の F1 値で、 MeCab の 16-21 倍、 Vaporetto の 3-9 倍の速度で解析を行うことができたと報告されています。</p> <p>感想: 恐らくパターン生成に使う辞書、及びそれを適用する文章ドメインによって解析性能が上下しやすいのではないか、と思っていますが、それを差し置いてもシンプルなアイデアで効果的な結果が得られた印象深い研究だと思いました。実装が公開されるのが楽しみです。</p> <h2 id="印象に残ったセッション2-埋め込み表現">印象に残ったセッション2: 埋め込み表現</h2> <p>BERT が出てきて以降、そのメカニズムや挙動を調べる研究は年々盛り上がり続けている印象があります。日本語で質の高いセッションが聞けてとても良かったです。</p> <h3 id="自己注意機構における注意の集中が相対位置に依存する仕組み">自己注意機構における注意の集中が相対位置に依存する仕組み</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C3-3.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C3-3.pdf</a></p> <p>Transformerの自己注意機構において、各トークンが周辺のトークンに注意を集中させるメカニズムを分析した研究です。RoBERTaが学習を通して獲得した位置埋め込みには周期性があり、自己注意機構はそこから周期成分を抽出し、クエリとキーで位相がずれる方向に注意を集中させていることがこの研究で明らかにされました。これにより、絶対的な位置埋め込みが相対的な位置に基づいた推論をできているという経験的な事実を一部説明することができるようになりました。</p> <p>感想: まだまだ分かっていないことが多い Transformer について丁寧な解析に基づいた発見が積み重ねられたことはとても大きなインパクトがあり、重要な研究だなと感じました。</p> <h3 id="BERTを用いた文埋め込みモデルの単語の暗黙的な重み付け">BERTを用いた文埋め込みモデルの単語の暗黙的な重み付け</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C3-4.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C3-4.pdf</a></p> <p>word2vec や fastText といった static な embedding が主流だったときは文章埋め込みを得る際、重みを調整しながら平均することでよりより文章埋め込みが得られることが知られていました。 が、BERT以降の context を汲み取れるモデルではそうした工夫なく既存手法を凌駕しています。この研究ではその理由を知る一歩として、マスク言語モデルが各単語を情報量に従って重み付けしているのでは、という仮説を Integrated Grad を使って検証しています。 結果、情報量 -logP(w) と言語モデル内の重みの強さには緩やかな正の相関が見られたとのことでした。また、追加学習をすると更に相関係数が上がることが報告されています。</p> <p>感想: ただただ面白かったです。テキストにも使える GradCam みたいな手法があることを初めて知れて勉強になりました。</p> <h3 id="Transformer言語モデルの予測ヘッド内バイアスによる頻度補正効果">Transformer言語モデルの予測ヘッド内バイアスによる頻度補正効果</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C3-5.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C3-5.pdf</a></p> <p>Transformer の挙動を調べる研究において、注意機構やフィードフォワードに着目した研究は数多くありましたが、予測ヘッドに近い部分のバイアス項に着目した研究はそれほど多くありません。こちらの研究ではバイアス項と出力との関係性に関する分析を行っています。 対象としたモデルは BERT-cased と、GPT-2 です。両者に共通しているバイアス項を除去してみると、コーパス中で高頻度の単語の出現回数が減り、低頻度の単語の出現回数が増える現象が確認されました。 次にこのバイアスを除去ではなく調整することで、頻度の高い表現を出にくくするよう調整したり、頻度の低い表現を出やすくするよう調整することが可能かどうかの実験を行ったところ、バイアスを小さくすると多様性がある文章が得られる一方で人間が出力する文章との近さが低くなる、といったトレードオフな関係性があることが明らかにされました。</p> <p>感想: 表現力の小さいモデルは頻度情報の制御をバイアスに任せて、本体のモデル重みでより核となる言語情報を処理しているのではないか、という考察が面白かったです。モデルにとって重要な情報がなにか、はさらなる大規模言語モデルになると変わってきそうで、今後もこうした研究を追っていきたいです。</p> <h3 id="ガウス埋め込みに基づく文表現生成">ガウス埋め込みに基づく文表現生成</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C4-4.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C4-4.pdf</a></p> <p>文章埋め込みは多くのタスクで有用なものとされています。文章埋め込みは点で表現されますが、こちらの研究では文章埋め込みを分布で表現することで、広がりを持った表現を獲得しようとしたと報告されています。分布で埋め込みを表現することで文章同士の重なり具合が計算できるようになり、含意表現などの問題を解きやすくなっています。NLIデータセットを使って、BERTに入力した際に得られる CLS トークンのベクトルから、ガウス分布を構成する平均と分散のパラメータを後続の FFN が出力します。これをある文章のガウス埋め込みとして、ガウス埋め込みの類似度を計算し、含意か矛盾かといったラベルに従って対照学習を実施したとのことでした。後続タスク(含意かどうか)を解くときは similarity がしきい値を超えているかどうかでラベルを推定し、その結果 SNLI データセットで Supervised-simCSE を上回ったとのことでした。</p> <p>感想: Box-Embedding などいくつか似たアプローチがあるかと思いますが、こちらのアプローチはとても直感的で理解しやすく、シンプルなアイデアだったため非常に扱いやすく感じました。クックパッドでも似ている料理を判別したい時があるのですが、分布で表現するこのようなアプローチはとても有用に感じました。</p> <h2 id="印象に残ったセッション3-ことばの評価と品質推定">印象に残ったセッション3: ことばの評価と品質推定</h2> <h3 id="編集操作によるデータ拡張を用いたテキスト平易化の自動評価">編集操作によるデータ拡張を用いたテキスト平易化の自動評価</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A6-2.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A6-2.pdf</a></p> <p>既存のテキスト平易化タスクを評価する際には、どの程度平易になったか、を評価するための参照文が必要になります。が、この参照文を集めるのはそれなりのコストがかかることが知られています。こちらの研究では、参照文を必要としない平易化の評価方法として SIERA(SImplification metric based on Edit operation through learning to RAnk)が提案されています。核となるのは、平易性を評価するランキングモデルを構築し、それを平易化指標として利用する部分です。このランキングモデルを作るための学習データとして、編集操作を行って拡張したデータを用いています。原文と平易文のパラレルコーパスを使って、原文を平易に編集操作したものを中間文として、これを生成することでより粒度の細かいモデルが学習できることを狙っています。得られたモデルの出力結果を人手で得られた評価結果と比較したところ、相関係数が0.3~0.4程度得られており、有用な指標であることが示されています。</p> <p>感想: クックパッドにおいて平易化の概念は持ち込みにくいですが、少し考え方を変えて基本のレシピ→それをアレンジしたレシピ、という対が作れればそのデータを使ってアレンジ度合いを評価することができそうだな、と思ったりしました。そのときにこのような編集操作を用いてある材料・調味料を減らしたり増やしたりして、疑似データを作るときにこの考え方が役に立つかもしれません。</p> <h3 id="入力文と自然言語処理モデルの相性判定">入力文と自然言語処理モデルの相性判定</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A6-5.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A6-5.pdf</a></p> <p>あるテキストを翻訳モデルに入力した後の出力品質について、本来であれば出力された文章と Gold な文章とを比較することで BLEU などの指標を計算して評価を行います。こちらの研究では文章を入力する前にどの程度うまくいきそうか、を品質推定するモデルが提案されています。学習方法としては機械翻訳を通したコーパスについてBLEU の値がしきい値以上であるかどうかでラベルを付与・感情極性分類についてはその正解率についてしきい値を基準にしてラベルを付与し、そのラベルを分類するモデルとして多言語 XLM-RoBERTa・日本語BERT をそれぞれ fine-tuning したものを使っています。結果としてはそれぞれ7~8割程度の正解率でラベルを予測できた、とのことでした。</p> <p>感想: 入力文のみを使って品質を推定するという発想がとてもユニークに感じました。正解率もそれなりにあったため、翻訳・極性分類がうまくいく文章とそうでない文章との間で何らかの差異が見られるというのは面白かったです。</p> <h3 id="参照例を使わないキャッチコピーの自動評価">参照例を使わないキャッチコピーの自動評価</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A7-2.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A7-2.pdf</a></p> <p>キャッチコピーを評価するタスク・データセットとそれを解くモデルとして対照学習を提案した論文です。キャッチコピーを生成するタスクを解こうとしたときにそれを評価する術がこれまでありませんでしたが、この論文ではそのキャッチコピーがどのような賞を受賞したのかに応じてスコアを振り、それを学習することで評価器を得ています。ランダム・Perplexity・SVR・BERT Regression・BERT contrastive(スコアについて s1>s2 になるようにペアを選び学習) というモデルを用意して比較したところ、BERT contrastive が最も良い性能が得られたとのことでした。</p> <p>感想: 特徴量を用いて学習するモデルにおいて、会社の情報を用いると精度が上がるが会社情報だけでも性能があまり落ちなかった、という報告があり、キャッチコピーの性質上避けられない問題だな、と思って聞いていました。とはいえ受賞、というのはある側面からのキャッチコピーの質を評価したものであることに変わりなく、他に使えるデータもなかなかなさそうなので難しそうでした。が、とにかく面白い研究でした。</p> <h3 id="下流タスクでの日本語事前学習モデルの性別バイアスの評価">下流タスクでの日本語事前学習モデルの性別バイアスの評価</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A7-3.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/A7-3.pdf</a></p> <p>日本語の事前学習モデルについて、そのモデルを使って下流タスクを解く場合に問題となるのが事前学習モデルが含むバイアスです。バイアスにも様々な種類がありますが、こちらの研究では性別バイアスに着目しています。バイアスの計測には NLI 的な考え方を適用し、職業に関する性別バイアスが反映されそうな文章を用意します。例えば「医師がテニスをしています」「男性がテニスをしています」「女性がテニスをしています」のようなものです。これらを用意して、矛盾しているか含意関係にあるかをモデルに出力させることで、バイアスを可視化するといった狙いです。ステレオタイプ・ステレオタイプでの性別をひっくり返したもの(アンチステレオタイプ)・ノンステレオタイプのデータをそれぞれ用意して含意・矛盾・中立のいずれの出力が得られるかを計測したとのことでした。cl-tohoku BERT をはじめとして広く使えるモデルを対象として実験を行ったところ、ステレオタイプのデータでは含意、アンチステレオタイプのデータでは矛盾を出力する割合が高くなっていることを確認できたそうです。程度の大小はあるものの、全ての事前学習モデルに性別バイアスが存在することが明らかになりました。</p> <p>感想: ImageNet を学習した画像系の事前学習モデルでもキャプション生成でキッチンに居る人を本来は男性なのに女性と出力してしまう、など以前から事前学習モデルにバイアスが存在することは指摘されていましたが、言語系でもそれがあることがはっきり示されており、インパクトのわかりやすい研究でとても印象に残りました。クックパッドで事前学習モデルを使うときに、料理ドメインに関するバイアスがないかを計測するタスクを考えてみるのも面白そうだなと考えたりしています。</p> <h2 id="その他印象に残った発表">その他印象に残った発表</h2> <h3 id="意味解析-思考連鎖指示における大規模言語モデルの否定表現理解">意味解析: 思考連鎖指示における大規模言語モデルの否定表現理解</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C10-1.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/C10-1.pdf</a></p> <p>GPT-3・OPT を対象とした研究です。Chain Of Thoughts Prompt と呼ばれている、思考過程を出力させながら答えを求める質問を投げることで、GPT-3 の推論能力を引き上げられることが知られています。この論文では、言語モデルが特にこれまで苦手としてきた否定表現を対象として、否定表現が含まれる質問に対して、その推論内容及び結論が正しく得られるかどうかを評価しています。 事前に得られている知識に基づく推論がされないように架空のスポーツ・人物名を用意し、それを用いて YES, NO が得られる質問文章を用意します(Yes, No の割合はおおよそ50%ずつ)。質問文章は only, implausible, not を混ぜたものを数パターン用意しており、こうした否定文が含まれる質問文章から正しい応答が得られるかどうかを確認しています。 対象のモデルは GPT-3 (text-davinci-002)、OPT-(最大 66B) を用いています。得られた結論としては、いずれのモデルでも否定表現が混じった質問文章では No と答える確率が 8割以上(正答率5,60%程度)になってしまうことがわかりました。否定表現が混じっていない質問文の場合は正答率が80%程度ある場合でも否定表現が含まれるとかなり正答率が落ちており、現在の最先端 LLM であっても否定表現が苦手であることがわかりやすく示されています。</p> <p>感想: 否定が苦手なのはGPT-3.5-turbo、GPT-4 になっても変わっていないような感覚があります。OpenAI も <a href="https://help.openai.com/en/articles/6654000-best-practices-for-prompt-engineering-with-openai-api">Best practices for prompt engineering with OpenAI API | OpenAI Help Center</a> の中で <code>Instead of just saying what not to do, say what to do instead</code> と書いています。この研究ではどれくらい否定表現が理想的な出力を得る上での障害となり得るか、について詳細な知見が得られて非常に勉強になりました。特に研究で得られた結果の面白さもさることながら、架空の名称を用いることで事前知識が使われないようにして推論能力だけに着目できるようにする実験デザインが非常に素晴らしいなと感じました。</p> <h3 id="形式言語-チョムスキー階層とニューラル言語モデル">形式言語: チョムスキー階層とニューラル言語モデル</h3> <p><a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/D12-3.pdf">https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/D12-3.pdf</a></p> <p>チョムスキー階層と呼ばれる形式言語を階層別に分類する考え方があります。近年、言語モデルについてチョムスキー階層のどのクラスに属する言語までを認識できるのか、といった研究が盛んに行われています。こちらの研究では語彙の影響を排除し、先行研究より自然言語の実態に即した評価を行うために終端記号を多様化したタスクを新たに提案し、その形式言語を用いて言語モデルの扱える範囲を調べています。 対象としたのは LSTM と GPT-2 です。提案した形式言語を用いて幾つかのパターンを用意し、それらのパターンについてあるクラスに属するかどうかの二値分類タスクを学習させてどの程度認識が可能かを比較しています。 結論として、LSTM の方が GPT-2 よりも多くの場合で高い精度での汎化性能を示したとのことでした。特に論文中における A<sup>nB</sup><sup>n</sup> 型では顕著な差が生じており、言語モデル間での扱える範囲に差が見られる結果となっています。</p> <p>感想: LSTM の方が GPT-2 よりも一定高い精度での汎化性能を示したことは近年の流れからいうと興味深い現象でした。ただ Transformer がチョムスキー階層にうまく位置づけられない、といった論文がいくつかあることもあり、それほど不思議ではない結果かなとも思いました。じゃあなぜ GPT-3,4 があれほど上手に言語を扱えるのか、という疑問が次に浮かんできますが、それは今後の研究できっと明かされていくのだろう、と思い今後の研究を引き続き追っていきたいなと思います。</p> <h2 id="NLP-2023-に参加してみて">NLP 2023 に参加してみて</h2> <p>しばらくオンライン開催が続いていた自然言語処理学会ですが、改めて最先端の研究にオフラインで直に触れる機会が貴重であることを実感しました。知り合いとあってその場で議論をしたり、発表者の方に直接質問して知見を深めたり、というのはオンラインとオフラインでどうしても質が違うものだな、と感じました。情勢が落ち着いていたらぜひ来年もオフラインで参加したいです。 特に今年は会期中に GPT-4 が発表され、参加者の発表の中でも急遽 GPT-3,4 に触れている発表が散見され、研究をされている皆さんの動きの速さを見て改めて重要度を再認識しました。</p> <p>さて、クックパッドは非常にテキストで構成されているデータの比重が高いサービスです。機械学習グループに所属しているメンバーの殆どが自然言語処理を専門としていることもあり、毎日議論は尽きないのですが、今回の学会を通じてより一層 NLP に対する熱量を高められた、と感じています。 また、学会で行われた発表を聴講している中で感じたのは使用されているモデルのトレンドでした。殆どの研究が BERT 以降のモデルを使用していました。一方で、クックパッドの料理ドメインにおける言語処理タスクで、これら基盤モデルの性能を十分に発揮させることはまだできていない状況です。しかし、今後より一層積極的に基盤モデルと向き合い、活用の可能性を模索していきたいと考えています。</p> <p>この記事を読んでいただきありがとうございました。 最先端の自然言語処理技術を用いて機械学習の技術をプロダクトで活かしたい方がいらっしゃいましたら、ぜひ新卒・中途採用にご応募ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.wd3.myworkdayjobs.com%2Fja-JP%2Fjobs%2Fjob%2FKanagawa--Japan%2F--_R-002435" 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://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/Kanagawa--Japan/--_R-002435">cookpad.wd3.myworkdayjobs.com</a></cite></p> <p>また、今年の夏には就業型インターンの枠を増やして募集を行います。学生の方で興味がある方はぜひ申し込んでみてください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.wd3.myworkdayjobs.com%2Fja-JP%2Fnew_grad%2Fjob%2FKanagawa--Japan%2FXMLNAME----_R-002691" 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://cookpad.wd3.myworkdayjobs.com/ja-JP/new_grad/job/Kanagawa--Japan/XMLNAME----_R-002691">cookpad.wd3.myworkdayjobs.com</a></cite></p> <p>明日は弊グループの原島(id: jharashima) から座長編が投稿されます。こちらもぜひご覧ください。</p> fufufukakaka NLP2023 に参加しました:発表編 hatenablog://entry/4207112889982639365 2023-04-19T12:10:57+09:00 2023-04-20T13:30:19+09:00 こんにちは!技術部機械学習グループの山口(@altescy)です。 先日、沖縄にて開催された言語処理学会第29回年次大会(NLP2023)に参加してきました。 今年の大会は過去最多の参加者数となり、かつ久しぶりの本格的なオフライン開催ということで大変活気のある大会になったかと思います。 クックパッドからもML/NLPエンジニア3名が参加し、研究発表を行いました。 NLP2023会場裏の砂浜 今週のTechLifeでは、今回から発表編、聴講編、座長編の3回に渡ってNLP2023の参加報告を各参加メンバーからお伝えしたいと思います。 ぜひお楽しみください! クックパッドと自然言語処理 NLP202… <p>こんにちは!技術部機械学習グループの山口(<a href="https://twitter.com/altescy">@altescy</a>)です。</p> <p>先日、沖縄にて開催された<a href="https://www.anlp.jp/nlp2023/">言語処理学会第29回年次大会</a>(NLP2023)に参加してきました。 今年の大会は過去最多の参加者数となり、かつ久しぶりの本格的なオフライン開催ということで大変活気のある大会になったかと思います。 クックパッドからもML/NLPエンジニア3名が参加し、研究発表を行いました。</p> <p><figure class="figure-image figure-image-fotolife" title="NLP2023会場裏の砂浜"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20230419/20230419115702.png" alt="NLP2023&#x4F1A;&#x5834;&#x88CF;&#x306E;&#x7802;&#x6D5C;" width="1200" height="960" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>NLP2023会場裏の砂浜</figcaption></figure></p> <p>今週のTechLifeでは、今回から発表編、聴講編、座長編の3回に渡ってNLP2023の参加報告を各参加メンバーからお伝えしたいと思います。 ぜひお楽しみください!</p> <h2 id="クックパッドと自然言語処理">クックパッドと自然言語処理</h2> <p>NLP2023での様子をお伝えする前に、クックパッドにおける自然言語処理技術・研究に関する取り組みについて紹介させてください。</p> <p>クックパッドで主に扱われるレシピデータはその情報の大部分が自然言語で記述されるため、サービスや社内において自然言語処理が活躍する場面が多くあります。 <a href="https://techlife.cookpad.com/entry/2015/09/30/170015">レシピのカテゴリ分類</a>、<a href="https://techlife.cookpad.com/entry/2020/11/06/110000">レシピからの固有表現抽出</a>、<a href="https://techlife.cookpad.com/entry/2019/02/20/120219">レシピ入力補助のための食材提案</a>、<a href="https://techlife.cookpad.com/entry/2020/02/26/093000">レシピの分量予測</a>など、レシピを対象にしたものだけでも非常にユニークなタスクで溢れています。 これまでにも上記のような業務における課題解決の中で生まれた成果を研究としてまとめて言語処理学会をはじめとした大会などで発表してきました。</p> <p>例えば直近2年の言語処理学会年次大会 (NLP2021, NLP2022) では以下のような研究発表を行っています。</p> <ul> <li>NLP2021 <ul> <li><a href="https://www.anlp.jp/proceedings/annual_meeting/2021/pdf_dir/P2-6.pdf">レシピ解析の現状と課題: Cookpad Parsed Corpus を例として (平松淳, 原島純)</a></li> <li><a href="https://www.anlp.jp/proceedings/annual_meeting/2021/pdf_dir/P4-21.pdf">マルチラベル分類による材料推薦モデル (深澤祐援, 西川荘介, 原島純)</a></li> </ul> </li> <li>NLP2022 <ul> <li><a href="https://www.anlp.jp/proceedings/annual_meeting/2022/pdf_dir/PT3-16.pdf">単語埋め込みを利用した商品に対するキーワードの予測 (山口泰弘, 深澤祐援, 原島純)</a></li> </ul> </li> </ul> <p>その他にもクックパッドでは多数の研究活動を行っていて、過去に発表した内容は <a href="https://research.cookpad.com/">research.cookpad.com</a> から見ることができます。 今回もこうした研究活動の一環として NLP2023 に参加してきました。</p> <h2 id="NLP2023での発表の紹介">NLP2023での発表の紹介</h2> <p>今大会では<a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/Q2-3.pdf">『レシピに含まれる不使用な材料等に関する記述の抽出』</a>という題で研究成果の発表を行いました。 この研究では題の通りレシピ中のテキストに含まれる、不使用であることが明記された材料・調理器具・調理工程の記述を抽出するタスクに取り組んでいます。</p> <p>不使用に着目する大きなモチベーションのひとつとしてあげられるのはレシピ検索における課題です。 通常の転置インデックスを用いた検索システムでレシピを検索する場合、「卵」で検索すると「卵なし」と記載されたレシピが検索結果に載ることがあります。 多くの場合、「卵」でレシピを検索するユーザーは卵を使ったレシピを探している可能性が高いと考えらるため、「卵なし」のレシピが登場すると違和感を感じるかもしれません。 そこで、レシピ中のテキストにおいて使わないアイテムをあらかじめ検出しておけば検索体験の改善に繋げられるのではと考え、不使用に関する記述を抽出するモデルの開発に取り掛かりました。</p> <p>また、この研究はインターンシップに参加していただいた<a href="https://www.linkedin.com/in/taiga-someya/">染谷大河さん</a>と共に進めました。 染谷さんには特に後述のデータ拡張の検討や手法の比較実験に注力していただき、有意義な結果を残すことができました。 クックパッドでは自然言語処理や機械学習を扱う就業型インターンシップを募集していますので、ご興味のある方はぜひご応募ください!</p> <p><a href="https://cookpad.wd3.myworkdayjobs.com/ja-JP/new_grad/job/Kanagawa--Japan/XMLNAME----_R-002691">Cookpad 採用サイト - [就業型インターンシップ] 機械学習コース</a></p> <h3 id="データセットの準備">データセットの準備</h3> <p>モデルの学習・評価に用いるデータセットを作成するために、社内のアノテーターに依頼して1万件のレシピのタイトルと説明文に含まれる不使用な材料・調理器具・調理工程のスパンのアノテーションを行いました。 下図のようにテキストに対して不使用の対象となる領域とそのカテゴリを付与しています。</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/altescy/20230419/20230419115636.png" alt="&#x30A2;&#x30CE;&#x30C6;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x306E;&#x4F8B;" width="887" height="168" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>アノテーションの例</figcaption></figure></p> <p>作成したデータセットの統計は以下のようになりました。 不使用の記述を含むデータ数(<code># texts w/ spans</code>)はそうでないものと比べて少ないことがわかります。 そこで、より多くの正例を学習に利用できるように不使用の記述に着目したデータ拡張を試すことにしました。</p> <p><figure class="figure-image figure-image-fotolife" title="データセットの統計"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20230419/20230419115651.png" alt="&#x30C7;&#x30FC;&#x30BF;&#x30BB;&#x30C3;&#x30C8;&#x306E;&#x7D71;&#x8A08;" width="1200" height="467" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>データセットの統計</figcaption></figure></p> <p>今回は以下3つのデータ拡張手法を検討しています。 より詳細な設定は<a href="https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/Q2-3.pdf">論文</a>をご確認ください。</p> <p><figure class="figure-image figure-image-fotolife" title="検討したデータ拡張手法"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20230419/20230419115639.png" alt="&#x691C;&#x8A0E;&#x3057;&#x305F;&#x30C7;&#x30FC;&#x30BF;&#x62E1;&#x5F35;&#x624B;&#x6CD5;" width="1200" height="545" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>検討したデータ拡張手法</figcaption></figure></p> <h3 id="不使用抽出モデルの仕組み">不使用抽出モデルの仕組み</h3> <p>今回作成したモデルは不使用と書かれたアイテムの領域を予測するスパン抽出モデルと、抽出したスパンのフィルタリングを行うモデルの2つから構成されます。</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/altescy/20230419/20230419115659.png" alt="&#x4E0D;&#x4F7F;&#x7528;&#x691C;&#x51FA;&#x30E2;&#x30C7;&#x30EB;&#x306E;&#x69CB;&#x6210;" width="1030" height="680" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>不使用検出モデルの構成</figcaption></figure></p> <p>まず、スパン抽出モデルは BiGRU-CRF を用いた系列ラベリングにより不使用の記述とそのカテゴリを予測します。 BiGRU-CRF の入力は文字レベルで分割されたトークンです。 単語の境界の情報を与えるために、形態素解析により得られた品詞の情報を BIOUL スキーマでエンコードした系列を文字列と合わせて入力しています。 出力は材料・調理器具・調理工程のそれぞれのスパンに対応する BIOUL タグ系列となります。</p> <p>入力テキストからスパンを抽出した後はフィルタ処理を行います。 ここではレシピの材料欄の内容を元に誤って抽出されたと考えられるスパンを除去します。 抽出したスパンのテキストと材料欄に書かれた材料名をそれぞれ正規化し<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>、もし材料欄の内容と一致するスパンがあればそれらを取り除く、という仕組みです。 スパン抽出モデルが抽出したスパンの中から実際にレシピ中で使われていそうな材料を取り除くことで Precision の改善が期待できます。</p> <h3 id="実験結果">実験結果</h3> <p>提案手法を使ってテストデータに対して予測を行った結果が以下の表になります。 ベースラインとして、辞書ベースで抽出を行う手法 (Dictionary-based Extractor) と依存構造解析の結果を使う手法 (Dependency-based Extractor) を用意しました。</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/altescy/20230419/20230419115655.png" alt="&#x30C6;&#x30B9;&#x30C8;&#x30C7;&#x30FC;&#x30BF;&#x306B;&#x5BFE;&#x3059;&#x308B;&#x4E88;&#x6E2C;&#x7CBE;&#x5EA6;" width="1200" height="541" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>テストデータに対する予測精度</figcaption></figure></p> <p>2つのベースライン手法に比べて、提案手法は F1 スコアで +20% 以上高い精度を達成しています。 データ拡張も Precision の向上に貢献し、全ての拡張手法を適用した場合に F1 スコアにおいて +2.1% の向上が見られました。 また、材料欄に基づくスパンのフィルタ処理も Precision の改善 (+2.3%) が確認でき、F1 スコアでも +1% の改善となりました。</p> <p>誤りの例を見てみると「〜の代わりに」や「〜を避けたい」のように「なし」や「不使用」などに比べてレアな表現であることがわかりました。 また、カテゴリごとの結果を見てみると材料や調理器具は F1 86% 程度であるのに対し、調理工程は F1 77% 程度と他に比べて悪い結果でした。 材料や調理器具は一般名詞である場合が多いのに対して調理工程は「一晩水に浸け」や「粉をふるう」のように複雑な表現が存在するため、それが精度低下の要因になった可能性があります。 不使用抽出タスクにおいては、こうした多様な表現にいかに対応するかが今後の課題となりそうです<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup>。</p> <p>ちなみに、最近話題の ChatGPT はこのタスクを上手く扱うことができるのでしょうか? 以下の画像は zero-shot / few-shot の設定で GPT-4 に不使用検出タスクを解いてもらった結果になります。</p> <p><figure class="figure-image figure-image-fotolife" title="GPT-4による不使用検出の結果"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20230419/20230419115644.png" alt="GPT-4&#x306B;&#x3088;&#x308B;&#x4E0D;&#x4F7F;&#x7528;&#x691C;&#x51FA;&#x306E;&#x7D50;&#x679C;" width="1200" height="473" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>GPT-4による不使用検出の結果</figcaption></figure></p> <p>抽出したスパンとラベルの関係は正しく対応していますが、「さつまいも」や「ボウル」のように使用するアイテムも抽出されてしまいました。 もし人間がこのプロンプトを見たのであれば、このように間違うことは少ない気がします。 もちろん、プロンプトをもっとチューニングしたり、Chain-of-thought のようなテクニックと組み合わせることで改善する可能性はありますが、期待した結果を得るにはそれなりの工夫が必要そうです。 NLP2023でも<a href="https://www.anlp.jp/proceedings/annual_meeting/2023/#C10-1">LLMが否定表現を上手く扱えない可能性を示す研究</a>があったように、「不使用」も LLM にとって苦手な表現なのかもしれません。</p> <h2 id="おわりに">おわりに</h2> <p>NLP2023で発表したレシピから不使用に関する記述を抽出する研究について紹介しました。 今後は抽出した結果を使って実際にレシピ検索の改善などに応用できるか確かめていく予定です。</p> <p>次回は聴講編として深澤(<a href="https://twitter.com/fukkaa1225">@fukkaa1225</a>)が NLP2023 で発表された研究から特に興味深かったものをピックアップして紹介します!</p> <p>ちなみに、クックパッドでは現在就業型のサマーインターンも募集中です。 ご興味のある方はぜひご応募ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.wd3.myworkdayjobs.com%2Fja-JP%2Fnew_grad%2Fjob%2FKanagawa--Japan%2FXMLNAME----_R-002691" 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://cookpad.wd3.myworkdayjobs.com/ja-JP/new_grad/job/Kanagawa--Japan/XMLNAME----_R-002691">cookpad.wd3.myworkdayjobs.com</a></cite></p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> 材料名の正規化に関する記事はこちら: <a href="https://techlife.cookpad.com/entry/2017/10/30/080102">https://techlife.cookpad.com/entry/2017/10/30/080102</a><a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> RoBERTa などの事前学習済みモデルを使う方法も試しましたが、F1における精度向上は +1% ほどと限られたものでした。<a href="#fnref:2" rev="footnote">&#8617;</a></li> </ol> </div> altescy モブプログラミングを1年以上継続するコツ hatenablog://entry/4207112889981134417 2023-04-14T17:18:46+09:00 2023-04-14T17:27:36+09:00 こんにちは、メディアプロダクト開発部のマーケティングサービス開発グループ(通称msdev)の id:asonas です。msdevウィーク最後の記事です。チームメンバーの記事も是非読んでみてください。 クックパッドの toB 向け事業における ChatGPT API の活用事例紹介 - クックパッド開発者ブログ ポリモーフィック関連を活用し、森羅万象の「いいね」を実現する手法 - クックパッド開発者ブログ マーケティングサービス開発グループでは毎週月曜日13時から17時の決まった時間にモブプログラミングを実践しています。 このモブプログラミングの枠は1年以上継続していて、毎週様々な課題の解決や… <p> こんにちは、メディアプロダクト開発部のマーケティングサービス開発グループ(通称msdev)の <a href="http://blog.hatena.ne.jp/asonas/">id:asonas</a> です。msdevウィーク最後の記事です。チームメンバーの記事も是非読んでみてください。</p> <ul> <li><a href="https://techlife.cookpad.com/entry/2023/04/12/142744">&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x306E; toB &#x5411;&#x3051;&#x4E8B;&#x696D;&#x306B;&#x304A;&#x3051;&#x308B; ChatGPT API &#x306E;&#x6D3B;&#x7528;&#x4E8B;&#x4F8B;&#x7D39;&#x4ECB; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2023/04/13/141200">&#x30DD;&#x30EA;&#x30E2;&#x30FC;&#x30D5;&#x30A3;&#x30C3;&#x30AF;&#x95A2;&#x9023;&#x3092;&#x6D3B;&#x7528;&#x3057;&#x3001;&#x68EE;&#x7F85;&#x4E07;&#x8C61;&#x306E;&#x300C;&#x3044;&#x3044;&#x306D;&#x300D;&#x3092;&#x5B9F;&#x73FE;&#x3059;&#x308B;&#x624B;&#x6CD5; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> </ul> <p>マーケティングサービス開発グループでは毎週月曜日13時から17時の決まった時間にモブプログラミングを実践しています。 このモブプログラミングの枠は1年以上継続していて、毎週様々な課題の解決や機能の開発をしています。この記事ではモブプログラミングを長く継続するためのコツをお伝えします。</p> <h2 id="モブプログラミングとは">モブプログラミングとは</h2> <p>まず、モブプログラミングとは、チームメンバーが同時にコーディングを行う手法です。重要な点はこの「チームメンバー」にはソフトウェアエンジニアだけではなく、プロダクトオーナーやデザイナーのような方々も含まれていることです。<a href="https://scrummaster.jp/scrum-guide#%E9%96%8B%E7%99%BA%E8%80%85">スクラムガイド(2020)の開発者</a>と同じように考えてもらうと自然かもしれません。</p> <p>モブプログラミングにはいくつかのメリットがあります。第一にチーム全員が共通の目標に向かって協力するためコミュニケーションの質が向上します。また、プロダクトに深く関わることで、知識の共有が促進され、チーム全体でより高い品質のコードを書くことができます。</p> <p>さらに、モブプログラミングは開発プロセスを迅速化できます。複数の開発者が同時に機能を実装することで、エラーやミスがすばやく発見され、修正できます。これにより、開発プロセスがスムーズに進み、品質の高いコードをより迅速に提供できます。また、機能の実装途中に意見の分かれるポイントが出てきたときには、プロダクトオーナーやディレクター、デザイナーの方々の意見も取り入れることで"戻し"の作業を省くことができます。</p> <p>モブプログラミングはチーム全体の知識や技術レベルを向上させることができます。開発者が一緒に開発をすることで、新しいアイデアやテクニックを学び、開発者自身のスキルを向上させることができます。これによりチーム全体の技術力が向上しより高度なプロジェクトに取り組むことで、いわゆる暗黙知から形式知、形式知から暗黙知へのループがモブプログラミングを通して回せます。</p> <p>このようなモブプログラミングを2021年12月から16ヶ月以上実践しています。また、私たちのモブプログラミングは社員だけではなく、株式会社えにしテックさんの <a href="https://twitter.com/darashi">darashi</a> さんと <a href="https://twitter.com/cafedomancer">cafedomancer</a> さんを招聘して毎週実践しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.enishi-tech.com%2F" title="Home | 株式会社えにしテック" 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></p> <h2 id="1年以上開催するうえでどのようなコツが必要でしょうか">1年以上開催するうえでどのようなコツが必要でしょうか?</h2> <p>darashiさんとcafedomancerさんは遠方からZoomを使ってモブプログラミングに参加してくださっています。よくある定義として「ペアプログラミング・モブプログラミングはひとつのコンピューターでやる」とありますが、昨今の開発体験の進化により、Zoomによる画面共有や VSCode の <a href="https://learn.microsoft.com/ja-jp/visualstudio/liveshare/use/install-live-share-visual-studio-code">Live Share</a> などのツールの発展により遠隔地にいてもモブプログラミングは充分に満足できる形で開催できます。一昔前だと開発者全員でひとつの開発サーバーにsshで入ってscreenやtmuxのようなターミナルマルチプレクサのセッションを共有してコードを書いていましたね(なつかしい。10年以上前の話ですが)</p> <p>定期的にモブプログラミングを実践するいくつかの良い方法を紹介します。 ひとつは毎週決まった曜日と時間に開催することです。</p> <p>私たちのやっているモブプログラミングは毎週月曜の13時から17時に固定して実施しています(年末年始の休暇、祝日などが無い限りは基本的に開催です)。</p> <p>これは私たちがスクラムを実践している点も関係しており、スクラムガイド(2020)には、</p> <blockquote><p>スクラムイベント スプリントは他のすべてのイベントの⼊れ物である。(略) スクラムにおけるイベントは、規則性を⽣み、スクラムで定義されて いない会議の必要性を最⼩限に抑えるために⽤いられる</p></blockquote> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fscrummaster.jp%2Fscrum-guide%23%25E3%2582%25B9%25E3%2582%25AF%25E3%2583%25A9%25E3%2583%25A0%25E3%2582%25A4%25E3%2583%2599%25E3%2583%25B3%25E3%2583%2588" 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></p> <p>とあります。私の中ではモブプログラミングもスクラムのイベントのひとつ<a href="#f-2f21d860" name="fn-2f21d860" title="正確にはモブプログラミングはスクラムイベントの枠組みではありませんし、スクラムガイドには定義されていません。どちらかというともっと大きな枠組みの文脈で語られることが多いです。が、そこはスクラムやモブプロもXPのかけらということで解釈してもいいよなと考えています。">*1</a> として取り組んだほうがお得だと思っていることです。</p> <p>また、モブプログラミングは13時から17時の間で実施されるのでその朝会で取り組むことを決めています。モブプログラミングで取り組みたいことは日々のスクラムイベントから適宜Issueに起票されてラベルで管理されています。</p> <p>ふたつ目は毎週のログを取ることです。</p> <p>開催時に必ず全員で見るGoogleドキュメントがあります。僕たちのチームでは「モブプロメモ」と呼ばれています。</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/asonas/20230414/20230414162951.png" width="1200" height="702" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>モブプロメモの様子</figcaption></figure></p> <p>朝会で取り組むことが決まれば事前にこのモブプロメモへ書いておきます。13時になりZoomへ人々が集まり、モブプログラミングで取り組みたいことをオーナーがメンバーに説明をして開始となります。 基本的には、このメモには会話した内容のメモやコードを書きながら発生した議論をまとめたりしてあとから読み直せるようにしています(全文をメモするようなことはしません)。 モブプロは毎週開催しておりその成果物はPull Requestになりますが、休暇などで参加できない方に向けてもこのメモは役に立ちます。</p> <h2 id="モブプログラミングの座組">モブプログラミングの座組</h2> <p>誰から取り組むか、という点については毎週の参加者でランダムに順番をきめています。</p> <p><figure class="figure-image figure-image-fotolife" title="その日の参加者の名前を書きつつshuffleするスニペット"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/asonas/20230414/20230414164010.png" width="912" height="200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>その日の参加者の名前を書きつつshuffleするスニペット</figcaption></figure></p> <p>持ち込んだ課題のオーナーからはじめても良いのかもしれませんが、私たちは特に気にすることなく、ランダムに順番を決めています。明確な理由付けはないですが、施策や課題の知識の偏りがチーム内にあります。この偏りをうまく利用する形で施策の目的や達成したいことはモブプログラミングを通して共有できるようになっています。</p> <p>そこで私が大切にしていることのひとつとして、自分がドライバーの時は頭の中で思っていることをすべてしゃべるようにしています。「このコードの意図は何だろう」「なるほど、ブランドごとにトピックスの最新の1件を取ってきて、その順序でブランドの一覧を掲出しているのか」「フロントエンドのテストで行数を指定して実行方法はどうやるんだっけ」「ここのコードの修正はvimでやるほうが得意なのでvimに切替えますね」というようなことをしゃべるようにしています。これはドライバーが思っていることをすべて言うことでモブの方々がドライバーの思考を理解しやすくするためです。逐一しゃべりながらやるので少し大変なのですが、ライブコーディングのような感覚でやると楽しめます。エディタやツールのテクニックも実況しながらやると「それなに?」のような会話も発生します。時には取り組む課題とは関係のない話題についても触れて開発体験の共有をするのもよいなと実感しています。</p> <p>ペアプログラミングでもそうですが、どんどん交代しながら課題を解決していくので、25分+5分休憩を1セットとして回していきます。2時間ほど経過するタイミングで休憩の時間を15分取っています。</p> <p>5,6人で回していくとなんだかんだで3時間30分ほど経過するので、最後の30分の枠でふりかえりをします。 ふりかえりではよかったこと、取り組んだ課題の難しかったところ、白熱した議論についてなど様々なことが書かれています。</p> <p><figure class="figure-image figure-image-fotolife" title="ある日の振り返りの様子。この日は業務分析をしたりReactのテストに苦戦する様子が描かれてる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/asonas/20230414/20230414164428.png" width="1200" height="838" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ある日の振り返りの様子。この日は業務分析をしたりReactのテストに苦戦する様子が描かれてる</figcaption></figure></p> <h2 id="私たちが実践するモブプログラミングのまとめ">私たちが実践するモブプログラミングのまとめ</h2> <p>カタはだいたいこのような流れです。ただ、このカタにハマらずブレることもままあります。前週に性能改善系の課題をこなしたときには冒頭で性能改善の様子をメトリクスを眺めたりもしますし、新しい参加者がいれば自己紹介をしたりしますし、Ruby/Rails、ReactなどだけではなくSQLを眺めてみんなでウンウン唸りながら改善をすることもありますしコードをほぼ書かずに業務分析をすることもあります。 おすすめのツールや設定、スニペットがあれば自慢してみたり、最近の技術的な話題で盛り上がることもあります。</p> <p>モブプログラミングはとてもハードなプラクティスです。一日4時間もやるととてもヘトヘトになります。それでもチームのモチベーションを維持のためにも緩急をつけて、たまには雑談を挟むことで機械的な開催になることを避けるように心がけています。仕事として楽しめるような雰囲気作りもとても重要です。</p> <p>モブプログラミングを実践するうえで、特にはじめて参加されるチームメンバーの場合はアプリケーションに精通していないこともあります。暗黙知が備わっていない、わからないことは当然あります。ドライバーになる人であれば分からない旨を伝えることも大事ですし、モブの方々も率先して自分たちが持つ知見を展開する心意気も重要です。ここのサイクルを回していくことでチームの生産性が向上しより早くユーザーに価値を届けることができます。</p> <p>ただ、ここまで書いた内容は、一朝一夕でなしえたものではありません。毎週のふりかえり以外にもチームのモブプログラミングの意義を問うようなふりかえりも別途実施しました。チームメンバー各位がモブプログラミングに対する認識を揃えるなどを経て今のモブプログラミングのカタが完成しています。</p> <p>モブプログラミングを継続してやっていくうえでメンバーの練度の差も次第に揃ってきます。特に導入時などはバタバタとしてしまうこともありました。それでも毎週のふりかえりの積み重ねでよりよい体験へと持っていくことができます。</p> <p>モブプログラミングを長く続けることで対外的な登壇もしたりしました。前日の <a href="https://twitter.com/pndcat">pndcat</a>さんがKaigi on Railsで登壇するきっかけになったプロポーザルも実はモブプログラミングで取り組んだ成果でした。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkaigionrails.org%2F2022%2Ftalks%2Fpndcat%2F" title="森羅万象に「いいね」するためのデータ構造 by Natsuko Nadoyama - Kaigi on Rails 2022" 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://kaigionrails.org/2022/talks/pndcat/">kaigionrails.org</a></cite></p> <p>今は違うチームに異動してしまったのですが <a href="https://twitter.com/osyoyu">osyoyu</a>さんもモブプログラミングで取り組んだことが採択されました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkaigionrails.org%2F2022%2Ftalks%2Fosyoyu%2F" title="お隣さんの API のデータを Rails らしく、しなやかに扱う by Daisuke Aritomo (@osyoyu) - Kaigi on Rails 2022" 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://kaigionrails.org/2022/talks/osyoyu/">kaigionrails.org</a></cite></p> <p>今回の記事では私たちのチームでうまく、そして継続的にモブプログラミングを実践する方法を紹介しました。定期的に開催しつつも、義務的にはならずソフトウェアエンジニアとして楽しく取り組めるような雰囲気作りを紹介しました。 もし私たちの取り組みに興味がありましたら、以下のリンク、またはTwitterなどでDMを頂ければカジュアルな面談からでも実施できればと思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-2f21d860" name="f-2f21d860" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">正確にはモブプログラミングはスクラムイベントの枠組みではありませんし、スクラムガイドには定義されていません。どちらかというともっと大きな枠組みの文脈で語られることが多いです。が、そこはスクラムやモブプロも<a href="https://speakerdeck.com/kakutani/xpmatsuri2019-keynote">XPのかけら</a>ということで解釈してもいいよなと考えています。</span></p> </div> asonas ポリモーフィック関連を活用し、森羅万象の「いいね」を実現する手法 hatenablog://entry/4207112889980534695 2023-04-13T14:12:00+09:00 2023-04-13T14:12:00+09:00 こんにちは!メディアプロダクト開発部マーケティングサービス開発グループ (msdev) のなどやま (@pndcat) です。業務では、クックパッドの広告の開発・運用や、新規サービスの開発をしています。本業の推し活動では、今年の夏はたくさんのイベントに参加するため、推し活動もがんばっていく予定です。 今週は msdev week として Techlife を更新しており、この記事は2日目になります。 1日目は三條さんによる「クックパッドの toB 向け事業における ChatGPT API の活用事例紹介」の投稿でした。 本記事も、メーカーズタウンに関するブログなので、ぜひこちらの記事もご覧くだ… <p>こんにちは!メディアプロダクト開発部マーケティングサービス開発グループ (msdev) のなどやま (<a href="https://twitter.com/pndcat">@pndcat</a>) です。業務では、クックパッドの広告の開発・運用や、新規サービスの開発をしています。本業の推し活動では、今年の夏はたくさんのイベントに参加するため、推し活動もがんばっていく予定です。</p> <p>今週は msdev week として Techlife を更新しており、この記事は2日目になります。<br/> 1日目は三條さんによる「<a href="https://techlife.cookpad.com/entry/2023/04/12/142744">クックパッドの toB 向け事業における ChatGPT API の活用事例紹介</a>」の投稿でした。<br/> 本記事も、メーカーズタウンに関するブログなので、ぜひこちらの記事もご覧ください!</p> <h1 id="はじめに">はじめに</h1> <p>この記事では、<a href="https://kaigionrails.org/2022/talks/pndcat/">Kaigi on Rails 2022 森羅万象に「いいね」するためのデータ構造</a> というタイトルで発表をした、Rails を用いたデータ構造のリファクタリングについて紹介します。ポリモーフィック関連を用いたリファクタリングにより、テーブルやコードの重複を排除し、メンテナンス性や拡張性を向上させることができました。データ構造をリファクタリングする背景や、具体的な手法、そしてなぜその変更を行うことができたのかについてを詳しく説明します。</p> <h1 id="目次">目次</h1> <ul> <li>データ構造の初期設計 <ul> <li>新機能の「いいね」を実装したい</li> <li>最初のデータ構造</li> <li>リリースをして1年... どうなったか...</li> </ul> </li> <li>ポリモーフィック関連を使ったデータ構造に変更 <ul> <li>新しいデータ構造: likes と anonymous_likes</li> <li>リファクタリングの手順 <ol> <li>新旧いいねのテーブルに書き込む (Write)</li> <li>旧いいねを新いいねにマイグレーションする (バッチ)</li> <li>新いいねを使う (Read)</li> <li>旧いいねのモデルとテーブルを削除する (Delete)</li> </ol> </li> </ul> </li> <li>まとめ</li> </ul> <h1 id="データ構造の初期設計">データ構造の初期設計</h1> <h2 id="新機能のいいねを実装したい">新機能の「いいね」を実装したい</h2> <p>msdev では、中小の食関連メーカーとユーザーをつなぐコミュニケーションプラットフォーム「<a href="https://makerstown.cookpad.com/">メーカーズタウン</a>」を開発しています。メーカーズタウンでは、メーカーはトピックスの投稿や商品の登録を行い、ユーザーはコメントやクチコミを投稿したり、いいねをすることができます。</p> <p>リリースのタイミングで、「いいね」機能を導入することになりました。要件は、以下の3つでした。</p> <ul> <li>何に「いいね」ができるか? → トピックスと商品</li> <li>1つの対象に何回「いいね」ができるか? → 1回</li> <li>「誰が」いいねをすることができるか? → 誰でも (ログインユーザーと、未ログインユーザー)</li> </ul> <p>特に、最後の要件をどう実現するのかについて悩みました。メーカーズタウンでは、ログインユーザーと未ログインユーザーのデータの扱いが異なるため、「いいね」の種類を分ける必要がありました。</p> <p>ここまでをまとめると、「いいね」は以下の4種類に整理することができます。</p> <ul> <li>ログインユーザーの商品のいいね</li> <li>ログインユーザーのトピックスのいいね</li> <li>未ログインのユーザーの商品のいいね</li> <li>未ログインのユーザーのトピックスのいいね</li> </ul> <h2 id="最初のデータ構造">最初のデータ構造</h2> <p>データ構造の案として、以下の3つの方法を検討しました。</p> <ul> <li>対象別とユーザー区分別で、4つのテーブルを作成する</li> <li>ポリモーフィック関連</li> <li>STI</li> </ul> <p>結果として、対象別とユーザー区分別のテーブルを作成することに決めました。将来的に「いいね」の種類が増える可能性があったため、ポリモーフィック関連やSTIを用いたテーブル構造を選択することもできましたが、サービスが使用され続ける中で仕様が変わる可能性があるため、必要に応じてデータ構造を改善する方針としました。</p> <h2 id="リリースをして1年どうなったか">リリースをして1年…どうなったか?</h2> <p>1年後、コメント機能やクチコミ機能が増え、「いいね」の対象が4つに増えました。これに伴い、テーブルが8個になったことで2つの問題が生じました。</p> <ul> <li>「いいね」の対象が増えるたびに、テーブル・モデル・コントローラーを毎回追加するのでつらい</li> <li>新しい対象について、いいね数の集計バッチを追加することを忘れ、集計漏れが発生した</li> </ul> <p>テーブルを毎回追加することはできますが、新しいテーブルが追加されるたびに、集計バッチの修正を行うことを意識するのは難しいことです。</p> <p>一方で、「いいね」の対象が4つまで増えたことで、<strong>「いいね」の仕様はすべて共通であり、今後も「いいね」対象別の振る舞いはなさそうということがわかりました。</strong></p> <h1 id="ポリモーフィック関連を使ったデータ構造に変更">ポリモーフィック関連を使ったデータ構造に変更</h1> <p>ポリモーフィック関連は、複数のオブジェクトを関連付ける場合に適しています。今回の場合は、商品、トピックス、コメント、クチコミに「いいね」をつける必要があるため、ポリモーフィック関連を用いて実現することができます <a href="#f-59b9368d" name="fn-59b9368d" title="ポリモーフィック関連の具体的な実装に関しては、Railsガイド を参照してください">*1</a>。</p> <p>サービスを1年間運用した結果、商品、トピックス、コメント、クチコミの「いいね」に関する仕様や振る舞いが共通していることがわかり、ポリモーフィック関連に移行することを決定しました。移行に際して、チームで以下の2つの制約に合意しました。</p> <ul> <li>今後も「いいね」の仕様を変更しない</li> <li><strong>if 文を書くと破綻するため、if 文を絶対に書かない (=異なる振る舞いはさせない)</strong> <a href="#f-b46cf8e0" name="fn-b46cf8e0" title="Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしました の「ポリモーフィック関連で if 文を書くと破綻するの例は?」 に詳細を書いています">*2</a></li> </ul> <h2 id="新しいデータ構造likes-と-anonymous_likes">新しいデータ構造:likes と anonymous_likes</h2> <p>新しいデータ構造は、likes (いいね) と anonymous_likes (未ログインのユーザーのいいね) の2種類にまとめました。 ログインユーザーには UserID がありますが、未ログインユーザーには UserID はありません。そこで、初めのデータ構造と同じように、ログインユーザーと、未ログインユーザーは、2種類のテーブルに分けることにしました。anonymous_likes は、UserID の代わりに like_identifier というカラムを作りました <a href="#f-7dd7280a" name="fn-7dd7280a" title="Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしました の「匿名いいねの like_identifier ってなに?」を詳細を書いています">*3</a></p> <p>今までの product_likes テーブルは <code>product_id</code>, <code>user_id</code> の2カラムでしたが、ポリモーフィック関連を利用すると下図の likes テーブルの <code>likable_id</code>, <code>likable_type</code>, <code>user_id</code> の3カラムで表現します (他の *_likes テーブルも同様)。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/marin72_com/20230412155441" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412155441.png" width="1060" height="558" loading="lazy" title="" class="hatena-fotolife" style="width:400px" itemprop="image"></a></span></figure></p> <p>では、モデルはどうなるかというと、今までは、4つのモデル (ProductLike、TopicLike、CommentLike、KuchikomiLike) があり、それぞれに対応するテーブルが存在していました。しかし、ポリモーフィック関連を利用することで、1つの Like モデルで表現できるようになりました。現在は対象が4つしかない「いいね」ですが、10個でも100個でも「いいね」の実装ができる、森羅万象の「いいね」のデータモデルが完成しました🌲🌳</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412220647.png" width="1200" height="562" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>※ before / after を見やすくするために、コードを画像にしています。</p> <h2 id="リファクタリングの手順">リファクタリングの手順</h2> <p>データの移行は、以下の手順で行っています。移行手順は特別な手法ではありませんが、Rails のポリモーフィック関連のマイグレーションの一例として、紹介します。</p> <ol> <li>新旧の「いいね」テーブルに書き込む (Write)</li> <li>旧いいねを新いいねにマイグレーション (バッチ)</li> <li>新しい「いいね」を使う (Read)</li> <li>旧いいねのモデルとテーブルを削除 (Delete)</li> </ol> <h3 id="1-新旧のいいねテーブルに書き込む-Write">1. 新旧のいいねテーブルに書き込む (Write)</h3> <p>これは、コントローラーの create メソッドで実行されます。上部分では、既存の ProductLike モデル (旧いいね) を作成し、下部分で Like モデル (新いいね) も作成します。1つのメソッド内で「いいね」がされた場合、新旧のいいねにそれぞれ書き込まれるようにします。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/marin72_com/20230413001016" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230413/20230413001016.png" width="853" height="371" loading="lazy" title="" class="hatena-fotolife" style="width:430px" itemprop="image"></a></span></figure></p> <p>削除も同様に、旧いいねを削除しつつ、新いいねも削除します。ただし、この時点で必ずしも旧いいねと対応する新いいねが存在するわけではありません。Like が見つからないときのために、Safe Navigation Operator の <code>&amp;</code> を destroy の前に書く必要があります。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/marin72_com/20230412234300" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412234300.png" width="1065" height="357" loading="lazy" title="" class="hatena-fotolife" style="width:530px" itemprop="image"></a></span></figure></p> <h3 id="2-旧いいねを新いいねにマイグレーションする-バッチ">2. 旧いいねを新いいねにマイグレーションする (バッチ)</h3> <p>次に、データのマイグレーションについて説明します。まず、product_likes テーブルのデータを likes テーブルに合わせたハッシュをつくります。<br/> Rails 6からは、upsert_all メソッドが導入され、ハッシュをそのまま渡すことでデータを生成することができます。バッチを冪等に実行することができるため、今回のようなマイグレーションを行いたい場合は、upsert_all メソッドをぜひ活用してください。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412173256.png" width="1200" height="750" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>新いいねと、旧いいねの ID (primary key) の順番がばらばらになることが気になるかもしれませんが、likes テーブルは、product_likes や topic_likes などの4つのテーブルを1つにマージするためのテーブルであり、ID の順序は意味を持ちません。重要なのは、「いつ」「誰が」「何に」に対して「いいね」を行ったのかというデータを正しくマイグレーションすることです。ID の順序については気にする必要はありませんが、「いつ」の情報をコピーするために、created_at を忘れないように注意してください。</p> <p>また、「1. 新旧のいいねテーブルに書き込む」の段階では、旧いいねと対応する新いいねが必ずあるわけではなかったため、新いいねの削除に Safe Navigation Operator を付けていましたが、データ移行が完了したため、Safe Navigation Operator を外します。</p> <h3 id="3-新いいねを使う-Read">3. 新いいねを使う (Read)</h3> <p>新いいねにデータが入ったので、アソシエーションを以下のように変更します。これからは、product.product_likes ではなく、product.likes を使います <a href="#f-56997d76" name="fn-56997d76" title="grep をしやすいように、product.likes ではなく product.product_likes の書き方をしていました。product_likes は likes に書き換える必要があります">*4</a>。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412161439.png" width="1200" height="314" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>「新しい対象について、いいね数の集計バッチを追加することを忘れる」という問題もありましたが、今回の変更により、1クエリで「いいね」を合算することができます。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/marin72_com/20230412161536" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412161536.png" width="1200" height="419" loading="lazy" title="" class="hatena-fotolife" style="width:550px" itemprop="image"></a></span></figure></p> <p>さらに、「ある時刻以降の Like を求める」「あるユーザーの Like を求める」というケースのクエリも簡単に書くことができるようになりました。</p> <p><figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/marin72_com/20230412162154" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/marin72_com/20230412/20230412162154.png" width="635" height="230" loading="lazy" title="" class="hatena-fotolife" style="width:350px" itemprop="image"></a></span></figure></p> <h3 id="4-旧いいねのモデルとテーブルを削除-Delete">4. 旧いいねのモデルとテーブルを削除 (Delete)</h3> <p>すべてのコードで新いいねを参照したら、旧いいねの書き込みを削除します。最後に、旧いいねのモデルとテーブルを削除することができたら、ポリモーフィック関連への移行が完了です 🎉</p> <h1 id="まとめ">まとめ</h1> <p>本ブログでは、似ているけどちょっと違う「いいね」の設計の紹介をしました。最初は、変更に耐えられるようなデータ構造にし、実装が複雑になったタイミングで、データ構造を見直しました。今回は、ポリモーフィック関連へのリファクタリングをしました。</p> <p>ポリモーフィック関連を適用できるケースは多くないと思いますが、場合によってはとても強力なデータ構造の手法であり、特に Rails ではフレームワークレベルでのポリモーフィック関連付けの支援があるため、テーブルやコード量を減らし、かつ、わかりやすいコードに置き換えることが可能です。<br/> ポリモーフィック関連を利用したことがない人や、ポリモーフィック関連は SQL アンチパターンでよく挙げられているから抵抗があるという人も多いと思いますが、今回のブログを通して、今後のデータ構造でポリモーフィック関連を選択肢の一つに入れてもらえると嬉しいと思います。</p> <p>クックパッドでは、toB 事業をやりたい!データ構造の話をたくさんしたい!リファクタリングに興味がある!というエンジニアを募集しています。以下のリンクからのご応募をお待ちしています!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-59b9368d" name="f-59b9368d" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">ポリモーフィック関連の具体的な実装に関しては、<a href="https://railsguides.jp/association_basics.html">Railsガイド</a> を参照してください</span></p> <p class="footnote"><a href="#fn-b46cf8e0" name="f-b46cf8e0" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://techlife.cookpad.com/entry/2022/10/31/181000">Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしました</a> の「ポリモーフィック関連で if 文を書くと破綻するの例は?」 に詳細を書いています</span></p> <p class="footnote"><a href="#fn-7dd7280a" name="f-7dd7280a" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://techlife.cookpad.com/entry/2022/10/31/181000">Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしました</a> の「匿名いいねの like_identifier ってなに?」を詳細を書いています</span></p> <p class="footnote"><a href="#fn-56997d76" name="f-56997d76" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">grep をしやすいように、product.likes ではなく product.product_likes の書き方をしていました。product_likes は likes に書き換える必要があります</span></p> </div> marin72_com クックパッドの toB 向け事業における ChatGPT API の活用事例紹介 hatenablog://entry/4207112889980473176 2023-04-12T14:27:44+09:00 2023-04-12T14:27:44+09:00 メディアプロダクト開発部マーケティングサービス開発グループ(通称 msdev)の三條です。広告システムやメーカーズタウンというBtoBtoCプラットフォームなどクックパッドにおける toB 向け事業の開発・保守・運用を担当しています。 今週は msdev week と題して、 msdev のメンバーから連続で記事の投稿をしていきます。楽しみにしていてください! 今回は、今流行りの ChatGPT API をメーカーズタウンというプロダクトに活用して機能開発を行い、課題解決を試みた例を紹介したいと思います。 私たちのチームでは、新しい技術を積極的に取り入れつつ、楽しみながらサービスを作っていって… <p>メディアプロダクト開発部マーケティングサービス開発グループ(通称 msdev)の三條です。広告システムやメーカーズタウンというBtoBtoCプラットフォームなどクックパッドにおける toB 向け事業の開発・保守・運用を担当しています。<br/> 今週は msdev week と題して、 msdev のメンバーから連続で記事の投稿をしていきます。楽しみにしていてください!</p> <p>今回は、今流行りの ChatGPT API をメーカーズタウンというプロダクトに活用して機能開発を行い、課題解決を試みた例を紹介したいと思います。<br/> 私たちのチームでは、新しい技術を積極的に取り入れつつ、楽しみながらサービスを作っていっていますので、もし興味を持っていただけたら末尾に採用サイトへのリンクがあるのでそちらからご応募いただけると嬉しいです!</p> <h1 id="メーカーズタウンとは">メーカーズタウンとは</h1> <p>メーカーズタウンは、食関連メーカーとクックパッドユーザーが双方向のコミュニケーションを取りながら、さまざまな課題を解消していく、企業マーケティングを支援するユーザー参加型のプラットフォームです。<br/> メーカーズタウンというプラットフォーム上で、企業はユーザーとコミュニケーションを取りながらマーケティングを行うことができます。 例えば、自社商品を活用したレシピをユーザーに募集したり、一部のファンユーザーに対して発売前の商品へのフィードバックをもらったりできます。</p> <p>メーカーズタウンには他にも様々な機能があるのですが、今回はその中の機能のひとつである「トピックス」機能について焦点を絞って紹介します。</p> <h2 id="トピックスとは">トピックスとは</h2> <p>トピックスは企業がユーザーに対して情報を発信することができる機能です。 ここでは企業に興味があるユーザーに対してレシピや商品の紹介、イベントの告知など企業主体で情報を発信することができます。</p> <p>ユーザーはそのトピックスに対してコメントをすることができ、さらに企業はそのコメントに対して返信を行うことができます。<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/j/johshisha/20230412/20230412114126.png" alt="&#x30C8;&#x30D4;&#x30C3;&#x30AF;&#x30B9;&#x306E;&#x4F8B;" width="378" height="1183" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>トピックスの例</figcaption></figure></p> <h1 id="トピックスにおける課題">トピックスにおける課題</h1> <p>サービスの成長にともなってユーザーからコメントが投稿されることが多くなってきました。 企業もコメントが増えたことを喜んでいて、とても丁寧に返信作業を行ってくださっています。</p> <p>その一方で、現在利用している企業の多くはメーカーズタウン専用の担当者がいるわけではなく、その他業務の傍らでメーカーズタウンの運用をしているため、ユーザーからのコメントが増えるにつれて返信作業の負荷が高くなってきています。<br/> 企業も慣れないなか、多くの時間を割いて返信作業を行っていますが、返信作業が滞ってしまったり、負担に感じている企業もいるのが現状です。<br/> せっかくユーザーがコメントをしてくれているにも関わらず、返信がこなかったり時間がかかったりすると、ネガティブな印象を与えてしまうため、どうにか返信作業の負荷を下げたいという要望が企業や営業担当の方からありました。</p> <p>しかし、安易に返信テンプレートを用意したりすると、サービスに同じような返信が投稿されることになるし、本来のコミュニケーションを取る目的からも逸れてしまうのではないか、ということでいい解決策が思いついていない状況でした。</p> <p>そこで白羽の矢を立てたのが ChatGPT でした。</p> <h1 id="ChatGPT-とは">ChatGPT とは</h1> <p>ChatGPT は OpenAI が提供している AI チャットサービスです。<br/> GPT と呼ばれる大規模言語モデルをチャット用にチューニングしたものを利用しており、自然言語の理解や文章の自動生成などを高度に行うことができます。<br/> ChatGPT は GUI で提供されており、気軽に利用することができますが、開発者向けに API も提供されており、今回は API を利用しました。</p> <p>ChatGPT では利用するモデルを選択できますが、今回は gpt-3.5-turbo を利用しています。(gpt-4 を使いたい)</p> <h1 id="ChatGPT-API-の活用">ChatGPT API の活用</h1> <p>ChatGPT を使うとコメントに対してとても自然な返信内容を生成することができます。 返信作業の中で負荷が高いのは文の構成を 0 から考える部分であると考え、ChatGPT に基礎となる返信内容の骨組みを考えてもらい、それを元に企業が本来取りたいコミュニケーションにあわせて加筆修正を加えるだけで返信ができるようにすることで、負荷軽減ができるのではないかと考えました。</p> <p>今回は返信内容の骨組みを生成することが目的で、加筆修正されることが前提にあるので、文言生成に求められる精度が高くないのもまだ不慣れな ChatGPT を活用する上でとても嬉しいポイントでした。</p> <p>実際に作成した画面は以下になります。</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/j/johshisha/20230412/20230412114659.gif" alt="&#x4F5C;&#x6210;&#x3057;&#x305F;UI" width="962" height="818" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>作成したUI</figcaption></figure></p> <p>この画面を構築するにあたって、私たちのチームにはデザイナーがいないため、ディレクターやビジネスチームと一緒になって UI や UX について議論しました。</p> <h2 id="UIUX-においての工夫点">UI/UX においての工夫点</h2> <p>以下、議論の中で考えた工夫点です。</p> <ul> <li>本来の目的であるコミュニケーションを阻害しないように注意する<br/> 自分で 0 から考えたい担当者がいた際に意図せずバイアスを与えてしまうことを避けるため、担当者がアクションを起こした場合のみ、生成された文言を提示するようにしました。</li> <li>あくまで参考程度にして、自身で加筆修正を行ってほしいことを伝える<br/> ChatGPT は不適当な内容を生成する可能性があり、企業とユーザーのコミュニケーションを行うのが本来の目的であるため、生成内容をそのまま投稿するのではなく、参考にしつつ自身の文言で返信をしてもらいたいと考えています。</li> <li>ワクワク感の演出<br/> ChatGPT の Web UI の徐々に文言が表示される表現がワクワク感が出て楽しいのでそれを取り入れたいという意見から、メーカーズタウンでも同じようにローディングの演出を行いました。<br/> 実装コストの観点から、1 文字ずつ表示されるものではなく、既存 UI で利用していたローディングを再利用しました。</li> </ul> <h2 id="実装においての工夫点">実装においての工夫点</h2> <p>ChatGPT API において生成文言をコントロールするためには、以下が重要になってきます。</p> <ul> <li>どのようなプロンプトを与えるか</li> <li>HyperParameter をどう設定するか</li> </ul> <p>今回は上述のように求められる精度がそれほど高くなかったのと実現までのスピードを優先したため、網羅的な実験を行ったわけではないのですが、プロンプトについていくつか工夫した点を紹介します。</p> <p>返信内容を生成するにあたって、定量的に評価できる指標などを用意することができていなかったので、以下の 2 点を軸にして主観でよいと思う文言が生成されるようなプロンプトにしました。</p> <ul> <li>コメント内容に自然に触れていること</li> <li>企業の返信スタイルに沿っていること</li> </ul> <p>また、 HyperParameter のチューニングはできていないのですが、利用したパラメーターは temperature=0.7 でその他はデフォルト値にしています。<br/> パラメーターの詳細は<a href="https://platform.openai.com/docs/api-reference/chat/create">ドキュメント</a>に詳しく記載されています。</p> <h3 id="コメント内容に自然に触れていること">コメント内容に自然に触れていること</h3> <p>これは以下のようなプロンプトを ChatGPT に与えることでユーザーのコメントに触れつつ、自然な返信を生成することができました</p> <pre class="code" data-lang="" data-unlink>[ { &#39;system&#39;: system_role_prompt }, { &#39;user&#39;: user_role_prompt }, ]</pre> <h4 id="system-role-prompt">system role prompt</h4> <pre class="code" data-lang="" data-unlink>メーカーである${企業名}の広報担当者として振る舞ってください。 あなたのSNSの投稿にユーザーからコメントが来たので返信を考えてください。 ## メーカー名 ${企業名} ## メーカー情報 ${企業の説明文} ## 返信の目的 より${企業名}の商品を好きになってもらうこと。 ## 制約 - ユーザーのコメント内容に間接的に触れること - 投稿内容・コメント内容を繰り返さないこと - 返信内容のみを回答すること - ${その他システムやサービスの都合で必要な制約を列挙}</pre> <h4 id="user-role-prompt">user role prompt</h4> <pre class="code" data-lang="" data-unlink>## あなたの投稿内容 ${トピックスの内容} ## ユーザーの名前 ${ユーザー名} ## ユーザーのコメント ${コメント内容}</pre> <h4 id="具体的な生成例">具体的な生成例</h4> <p>生成文言例を作成するソースは、今回の記事用に用意したテストデータを利用しています。<br/> この後説明する企業のスタイルに合わせて生成文言が変わっていることを示すための例なので、企業の説明文など細かい情報は省略します。</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;"> クックWU2JRU☆さん、コメントありがとうございます!<br/>そうなんです、みそは発酵させることでたくさんの栄養素が生成されるんですよ。食事の栄養補給にもってこいですね!私たちも、美味しさと共に栄養たっぷりの味噌をお届けできるよう取り組んでいます。ぜひ、新郷醸造の味噌を試してみてください! </td> </tr> </tbody> </table> <p>かなり自然な受け答えになっていると思います。(個人的には「食事の栄養補給にもってこいですね!」あたりはやや違和感がありますが、このへんは加筆修正してもらう想定です。)</p> <h3 id="企業の返信スタイルに沿っていること">企業の返信スタイルに沿っていること</h3> <p>返信内容には企業によって特色が異なります。 例えば、絵文字をよく使う企業や、言い回し、ユーザー名の引用方法などは企業によって異なります。<br/> これらを再現するため、過去の企業とユーザーのやり取りを利用し few-shot prompting を行いました。 具体的には以下のようにしました。</p> <pre class="code" data-lang="" data-unlink>[ { &#39;system&#39;: system_role_prompt }, { &#39;user&#39;: ${過去のトピックスとユーザーコメント} }, { &#39;assistant&#39;: ${過去の企業の返信} }, ... // 3つ分繰り返す { &#39;user&#39;: user_role_prompt}, ]</pre> <h4 id="生成例">生成例</h4> <p>先程例を紹介したものと同じコメントに対して、 few-shot prompting を行った際の例を紹介します。</p> <p>過去の企業の返信コメントを1つだけ与えてみました。(下表)<br/> わかりやすく極端な例を用意しました。名前の引用方法や、絵文字を多用している点などが先程の生成された文言とは異なります。</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;"> 「畑の肉」という別名かっこいいですね☆ <br/>豆知識勉強になります!</td> </tr> <tr> <td style="text-align:left;"> 企業の返信内容 </td> <td style="text-align:left;"> >クックW66VXK☆さん<br/>コメントありがとうございます✨<br/>私たちも「畑の肉」という別名があることに驚きました❗<br/>今後もたくさんの役に立つ情報を発信していきますのでよろしくお願いします✨ </td> </tr> </tbody> </table> <p>これを例として与えた場合の生成文言は以下になります。</p> <pre class="code" data-lang="" data-unlink>&gt;クックWU2JRU☆さん コメントありがとうございます! そうなんです!みそにはさまざまな栄養素が含まれていて、体に良い影響を与えることが期待されています。また、無添加や有機の味噌もあるので、安心して食べることができますよ✨</pre> <p>例を与えなかった場合(zero-shot)と比べて、 few-shot では名前の引用や絵文字などが反映されていることがわかります。</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;"> zero-shot </td> <td style="text-align:left;"> クックWU2JRU☆さん、コメントありがとうございます!<br/>そうなんです、みそは発酵させることでたくさんの栄養素が生成されるんですよ。食事の栄養補給にもってこいですね!私たちも、美味しさと共に栄養たっぷりの味噌をお届けできるよう取り組んでいます。ぜひ、新郷醸造の味噌を試してみてください! </td> </tr> <tr> <td style="text-align:left;"> few-shot </td> <td style="text-align:left;"> >クックWU2JRU☆さん<br/>コメントありがとうございます!<br/>そうなんです!みそにはさまざまな栄養素が含まれていて、体に良い影響を与えることが期待されています。また、無添加や有機の味噌もあるので、安心して食べることができますよ✨ </td> </tr> </tbody> </table> <p>過去に企業が実際に返信している例を参考情報として ChatGPT に与えることで、絵文字や言い回しなど、企業の返信スタイルを再現してくれるようになりました。</p> <p>余談ですが、プロンプトのトークン数は、投稿内容によりますが、多いもので 2000~2500 トークンくらいなので、1つの返信内容を生成するのにだいたい $0.05 くらいの金額感でした。</p> <h1 id="今後の課題と試してみたいこと">今後の課題と試してみたいこと</h1> <p>今回は、コミュニケーションの負荷軽減を目的として、 ChatGPT API を利用して、返信の参考文言を生成しました。 ある一定のクオリティで文言を生成することができましたが、課題もあります。</p> <ul> <li>文言生成性能の定量評価<br/> 定量的に評価するための指標が定められておらず、主観による定性評価しかできていません。</li> <li>Hallucination の検知<br/> 定量評価ができていないことにも関連しますが、生成された文言を評価することができていないので ChatGPT が生成する文言が不適当だった場合に自動で検知することができていません。<br/> 現在はコメント数的に手動で確認できる量なので直接見て不適当なものがあれば、手動で再生成するようにしています。</li> <li>UI/UX の改善<br/> UI/UX の専門家がいないので手探り状態です。これらは企業からのフィードバックをもらいながら徐々に改善していく予定です。</li> </ul> <h2 id="試してみたいこと">試してみたいこと</h2> <p>上記の課題に対して改善案として試してみたいことは以下になります。<br/> 企業からのフィードバックを元に優先度等を調整して、挑戦していく予定です。</p> <ul> <li>性能改善について<br/> few-shot の例として取得する過去事例を、今回対象とするトピックスやコメントの内容に近いものを利用するようにしてみたいなと考えています。<br/> 現在は最新の事例を3つ取得しているので、テキストをベクトルに変換して類似度が高いものを採用するとより文脈に沿った文言を生成してくれるかもしれません。</li> <li>定量評価について<br/> 定量評価についてはまだいい方法が思いついていないのですが、人手でいくつかの正解データセットを作り、評価するのが一歩目かなと思っています。</li> <li>Hallucination の検知<br/> 出力結果を評価するようにプロンプトで統制した GPT を利用して、生成された返信内容が適当かどうかを評価させるとどうだろうか、考えています。<br/> その GPT モデルが不適当と判断した場合は再生成したり、アラートを上げるなどして検知させる仕組みを構築できるといいなと考えています。</li> <li>UX 改善<br/> 企業が返信内容の主題を入力し、それを元に文言を生成させるのはどうだろうか、と考えています。<br/> 現在は私たちが用意した目的が固定化されているので、企業の意図した返信内容ではない可能性があります。<br/> しかし、ここは本来の目的であるコミュニケーションに大きく関わる部分なので慎重に検討していきたいと思っています。</li> </ul> <h1 id="まとめ">まとめ</h1> <p>今回は、ChatGPT API をメーカーズタウンというプロダクトに活用して機能開発を行い、企業の課題解決を試みた例を紹介しました。<br/> 網羅的な実験はできていないものの、うまく活用できた一例としてみなさんの参考になっていれば幸いです。<br/> 私たちのチームでは、新しい技術を積極的に取り入れつつ、楽しみながらサービスを作っていっていますので、もし興味を持っていただけたら下記の採用サイトからご応募いただけると嬉しいです!</p> <p>また、今回、ChatGPT を活用するにあたり、機械学習グループの深澤さんに多大なるサポートをいただきました。<br/> 機械学習グループの目標として「ChatGPT などの LLM を会社全体で活用できる状態にする。」というものがあるので、プロダクトに LLM を活用していくことに興味がある方はぜひご連絡ください!<br/> 機械学習グループについては下記のインタビューをご覧ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fnote.com%2Fcookpad%2Fn%2Fnbf65ec49418f" title="機械学習グループ新旧リーダーに聞く 「自走」と「協調」のループが生む次の技術とは|クックパッド公式note|note" 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://note.com/cookpad/n/nbf65ec49418f">note.com</a></cite></p> johshisha Cookpad Summer Internship 2023 を開催します hatenablog://entry/4207112889978594250 2023-04-07T11:00:00+09:00 2023-04-07T11:14:14+09:00 クックパッドでは例年サマーインターンシップを開催しています。2023 年に行われるエンジニア向けのインターンシップについてご紹介します。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nekketsuuu/20230406/20230406200431.png" alt="Cookpad Summer Internship 2023 &#x306E;&#x30ED;&#x30B4;&#x3067;&#x3059;&#x3002;" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、技術部 SRE グループの石川です。</p> <p>クックパッドでは例年サマーインターンシップを開催しています。今年も同様にインターンを開催するので、この記事ではその中でもエンジニア向けのコースたちについてご紹介します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Finternship.cookpad.jp%2F" title="Cookpad Summer Internship | 新卒採用 - クックパッド株式会社" 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></p> <p>今年は 2 つのコースをご案内することにいたしました。</p> <h2 id="Tech-Workshop-Course">Tech Workshop Course</h2> <blockquote><p>クックパッドが業務で実際に利用している技術に触れつつ、サービス開発の技法を経験するコースです。プロダクトを開発してユーザーの課題を解決する楽しさ、難しさ、そして面白さを体感してみませんか。</p></blockquote> <p>こちらのコースは、クックパッドが毎日の料理を楽しみにするために日々使っている技術や技法を、4〜5 日のワークショップに詰め込んだコースです。Ruby と GraphQL を使ったサーバーサイドアプリケーション開発、Swift を使った iOS アプリ開発、そしてユーザーの課題を解決し価値を届けるためのサービス開発、という内容でお届けする予定です。</p> <p>どれもクックパッドが社内で実際に使っている内容を抜き出してきてワークショップ形式にしたものです。参加者の皆さんがサービス開発をする際に武器として使えるものを、コンパクトに学んでいただきます。</p> <p>Tech Workshop Course は、同じ内容のものを 7 月と 8 月に 1 回ずつ開催することにしました。7 月の方は土日を 2 回、8 月の方は平日 5 日間連続のスケジュールとなっております。どちらか参加しやすい方を選んでいただければと思います。横浜みなとみらいでの開催になるので、遠方の方は 8 月の方が参加していただきやすいです。</p> <ul> <li>第 1 期:7 月 1 日、2 日、8 日、9 日</li> <li>第 2 期:8 月 7 日、8 日、9 日、10 日、11 日</li> </ul> <h2 id="Tech-OJT-Course">Tech OJT Course</h2> <blockquote><p>多くのユーザーにご利用いただいているサービスの開発チームの一員となり、社員と共に開発に携わるコースです。数週間に渡ってサービス開発に没頭し技術とノウハウを学ぶことで、プロダクトを開発する力を身につけませんか?</p></blockquote> <p>こちらのコースは Tech OJT Course と呼んでいたり就業型インターンシップと呼んでいたりするものです。</p> <p>クックパッドにおける実際のサービス開発の現場で社員と共にサービス開発に取り組んでいただき、その手法を体験していただきます。数年前に公開した「<a href="https://note.com/cookpad_hr/n/na826978e0617">新卒採用応募前にクックパッドでインターンシップしませんか?</a>」という記事の説明が私の気持ちをぴったり表していたので、引用します。</p> <blockquote><p>クックパッドは非常にユニークでおもしろい会社です。 ただ、ひと目見ただけでその魅力を理解するのは容易ではありません。 ミッションである「毎日の料理を楽しみにする」という本当の意味や目指したい世界、ユーザーの課題をとことん考え抜く愚直なサービス開発とそのノウハウ、そして魅力あふれる社員たち。ぜひ、学生のみなさんに体験して感じてほしいです。</p> <p>そして、クックパッドもみなさんのことをもっとたくさん知りたいと感じています。一定期間一緒に働くことで、何を考えどういうことを目指したいと思っているのか、1-2時間の面接よりもずっとよくみなさんのことを知ることができます。</p> <p>新卒採用の応募前にそういった貴重な機会を持てると、より良いコミュニケーションになるのではないかと考えています。</p></blockquote> <p>Tech OJT Course は去年よりも枠を拡充してお待ちしております。具体的な時期や内容については一緒にご相談しながら決めることとなります。サービス開発コース、基盤開発コース、機械学習コースがあるので、興味のあるコースへご応募ください。</p> <h2 id="まとめ">まとめ</h2> <p>Tech Workshop Course と Tech OJT Course のどちらも、参加者の皆さんがクックパッドのサービス開発をイメージでき、その一部に挑戦できるような内容になっています。毎日の料理が楽しみになるようユーザーさんに価値を届けるのは、難易度の高い業務です。その難しさに、私たちと一緒にチャレンジしてみませんか。</p> <p>↓↓↓皆さんのご応募をお待ちしております!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Finternship.cookpad.jp%2F" title="Cookpad Summer Internship | 新卒採用 - クックパッド株式会社" 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></p> nekketsuuu 【RubyKaigi 2023】ゲストハウスのオーナーに聞いた松本グルメ! 松本を256倍楽しむためのお店 hatenablog://entry/4207112889977925051 2023-04-06T11:38:15+09:00 2023-04-06T12:22:57+09:00 こんにちは、CTO室の緑川です。早いもので来月にはRubyKaigi 2023が開催されます。今年のRubyKaigiは長野県松本市で開催されるので、松本市で楽しめるグルメ記事を用意しました。今回は毛色を変えて、松本市で宿とサウナとジェラートのタビシロを営む小澤様にインタビューをし、オススメして頂いたお店を紹介しています。どのお店も人気店なので確実に訪れたい方は予約必須です。是非RubyKaigi中のお食事のご参考にしてください。 目次 目次 宿とサウナとジェラート tabi-shiro(タビシロ) 信州そば 三城 (さんじろ) 焼肉 味楽苑 (みらくえん) 伊勢町店 谷椿(たにつばき) 焼き… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tmidorikawa/20230404/20230404152215.jpg" width="1200" height="799" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは、CTO室の緑川です。早いもので来月にはRubyKaigi 2023が開催されます。今年のRubyKaigiは長野県松本市で開催されるので、松本市で楽しめるグルメ記事を用意しました。今回は毛色を変えて、松本市で宿とサウナとジェラートのタビシロを営む小澤様にインタビューをし、オススメして頂いたお店を紹介しています。どのお店も人気店なので確実に訪れたい方は予約必須です。是非RubyKaigi中のお食事のご参考にしてください。</p> <h3 id="目次">目次</h3> <ul class="table-of-contents"> <li><a href="#目次">目次</a></li> <li><a href="#宿とサウナとジェラート">宿とサウナとジェラート</a><ul> <li><a href="#tabi-shiroタビシロ">tabi-shiro(タビシロ)</a></li> </ul> </li> <li><a href="#信州そば">信州そば</a><ul> <li><a href="#三城-さんじろ">三城 (さんじろ)</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><ul> <li><a href="#山女や-やまめや">山女や (やまめや)</a></li> <li><a href="#鳥しん">鳥しん</a></li> </ul> </li> <li><a href="#うなぎ">うなぎ</a><ul> <li><a href="#山勢-やませい">山勢 (やませい)</a></li> </ul> </li> <li><a href="#海鮮">海鮮</a><ul> <li><a href="#三重鮨みえずし">三重鮨(みえずし)</a></li> <li><a href="#居酒屋まるか">居酒屋まるか</a></li> </ul> </li> <li><a href="#団体OKのお店">団体OKのお店</a><ul> <li><a href="#みつぶ-野菜巻き串">みつぶ 野菜巻き串</a></li> </ul> </li> <li><a href="#やきそば">やきそば</a><ul> <li><a href="#たけしや">たけしや</a></li> </ul> </li> <li><a href="#カレー">カレー</a><ul> <li><a href="#松本メーヤウ-桐店">松本メーヤウ 桐店</a></li> </ul> </li> <li><a href="#深夜">深夜</a><ul> <li><a href="#香根-かね">香根 (かね)</a></li> <li><a href="#Bar-BRORA">Bar BRORA</a></li> <li><a href="#昭和レトロスナックヴィーナス">昭和レトロスナック ヴィーナス</a></li> <li><a href="#スタンディング-8オンス">スタンディング 8オンス</a></li> <li><a href="#Jazz喫茶エオンタ">Jazz喫茶 エオンタ</a></li> </ul> </li> <li><a href="#朝食">朝食</a><ul> <li><a href="#Alps-gohan">Alps gohan</a></li> <li><a href="#珈琲美学-アベ">珈琲美学 アベ</a></li> </ul> </li> <li><a href="#銭湯">銭湯</a><ul> <li><a href="#塩井の湯">塩井の湯</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> </ul> <h3 id="宿とサウナとジェラート">宿とサウナとジェラート</h3> <h4 id="tabi-shiroタビシロ">tabi-shiro(タビシロ)</h4> <p>まずは今回お話を伺ったゲストハウスtabi-shiroのご紹介です。 tabi-shiroは何年もの間空き家だった築50年の元旅館を半年間かけてDIYしたそうです。昔ながらの日本建築でありながら、バーラウンジや完全貸切制のプライベートサウナがある充実したゲストハウスです。サウナはフィンランド式のセルフロウリュなので温度や湿度を調整でき、心地よい空間を作り上げることができます。オーナーのお話によると、最大のポイントは動線にあり、サウナから出てすぐに水風呂や外気浴を楽しむことができるそうです。ハウス内にはカフェもあり、サウナ後にジェラートを購入することもできます。 サウナ、ジェラート、バーだけのご利用も可能なので、是非チェックして下さい。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabi-shiro.com%2Fsauna%2F" title="SAUNA" 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://tabi-shiro.com/sauna/">tabi-shiro.com</a></cite></p> <h3 id="信州そば">信州そば</h3> <h4 id="三城-さんじろ">三城 (さんじろ)</h4> <p>長野県といえば信州そばが有名で、松本市内にも多くのそば屋が存在します。数ある名店の中でも今回オススメなのが三城です。このお店にはメニューがなく、2000円のお任せコースがあるのみで、日本酒・突き出し・ざるそば・漬物・甘味が提供されます。松本市内で唯一、そば百名店2022に選出されたそばは少し太めで噛みごたえがあり、そばの風味を楽しめるのが特徴です。 ただ、飲める飲めない関係なく、お昼時でもお茶の代わりに日本酒が出てくるそうなので、カンファレンス前や運転される方はご注意ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20000629%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://tabelog.com/nagano/A2002/A200201/20000629/">tabelog.com</a></cite></p> <h3 id="焼肉">焼肉</h3> <h4 id="味楽苑-みらくえん-伊勢町店">味楽苑 (みらくえん) 伊勢町店</h4> <p>松本駅付近でオススメの焼肉屋さんがこちらの味楽苑です。こちらのお店は松本市で半世紀営業されていることもあり、信州牛をはじめ日本各地の特選和牛を仕入れているそうです。極上セットや牛肉三種セットといった大皿メニューがあり、上質なカルビやロースをリーズナブルなお値段で楽しむことができます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20000401%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://tabelog.com/nagano/A2002/A200201/20000401/">tabelog.com</a></cite></p> <h4 id="谷椿たにつばき">谷椿(たにつばき)</h4> <p>谷椿は松本駅付近にある昔ながらの焼肉屋さんです。お昼はラーメンや牛めしが人気のお店とのことですが、夜は名物店長が時に泥酔しながらジンギスカン鍋にバンバンと肉を乗せてくれるそうです。ホルモン焼きがメインですが、その他のお肉も鮮度や質が高く、美味しいお肉が大量に食べられるそうです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20007141%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://tabelog.com/nagano/A2002/A200201/20007141/">tabelog.com</a></cite></p> <h3 id="焼き鳥">焼き鳥</h3> <h4 id="山女や-やまめや">山女や (やまめや)</h4> <p>山女やはコの字型のカウンターと手書きのメニューが壁の至る所に貼られている昔ながらの焼き鳥屋さんです。メインはもちろん焼き鳥で、中でも皮のタレがオススメだそうです。そして、焼き鳥以外のメニューも豊富で信州名物の山賊焼きや旬の野菜などさまざまなメニューがあり、そばも食べることができますので、飲み始めから締めまでいられるお店です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20000078%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://tabelog.com/nagano/A2002/A200201/20000078/">tabelog.com</a></cite></p> <h4 id="鳥しん">鳥しん</h4> <p>鳥しんはささみフライが最高に美味しい焼き鳥屋さんです。こちらもコの字型のカウンターがあるお店で、店内には20名ほどしか入れないそうです。毎日、多くのお客さんとチームワークが良い店員さんたちでいつも活気に溢れているそうです。こちらのお店では塩での味付がオススメで、独特な塩の掛け方が必見です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20003244%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://tabelog.com/nagano/A2002/A200201/20003244/">tabelog.com</a></cite></p> <h3 id="うなぎ">うなぎ</h3> <h4 id="山勢-やませい">山勢 (やませい)</h4> <p>松本でうなぎというイメージはあまりないかもしれませんが、山勢は著名人も多く訪れるうなぎの名店です。ランチで約5,600円、夜だと一人あたり12,000円以上かかるそうですが、有名店ということもあり予約が困難だそうです。夜のコース料理ではうなぎに加え、名物のすっぽん焼きも味わえます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20021045%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://tabelog.com/nagano/A2002/A200201/20021045/">tabelog.com</a></cite></p> <h3 id="海鮮">海鮮</h3> <h4 id="三重鮨みえずし">三重鮨(みえずし)</h4> <p>松本市内だとお寿司屋さんはそれほど多くないそうですが、三重鮨は寿司にこだわりがある方にもオススメできるお寿司屋さんだそうです。リーズナブルなお値段で新鮮なお寿司が食べられるうえ、遅くまで営業しているのでゆったりと食事を楽しむことができます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20004388%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://tabelog.com/nagano/A2002/A200201/20004388/">tabelog.com</a></cite></p> <h4 id="居酒屋まるか">居酒屋まるか</h4> <p>居酒屋まるかは海鮮の居酒屋ですが、馬肉や天ぷらなどのお酒に合う料理の種類が多いです。もちろん刺身の鮮度がよく、居酒屋としては珍しくお寿司も注文することができます。席はカウンターとテーブルで20人ほどしか入れないのですが、座敷もありますので、必要な方は予約をすると良いかもしれません。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20010301%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://tabelog.com/nagano/A2002/A200201/20010301/">tabelog.com</a></cite></p> <h3 id="団体OKのお店">団体OKのお店</h3> <h4 id="みつぶ-野菜巻き串">みつぶ 野菜巻き串</h4> <p>ここまで比較的省スペースのお店を紹介してきましたが、RubyKaigiだと大人数での食事もあるかと思います。松本市で大きいお店としてオススメなのがみつぶ 野菜巻き串です。このお店は農家直送の新鮮な野菜巻きが楽しめるお店とのことです。カンファレンスで会食の機会が多いと思いますので、体に良い野菜中心の食事もあるとよさそうです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20024126%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://tabelog.com/nagano/A2002/A200201/20024126/">tabelog.com</a></cite></p> <h3 id="やきそば">やきそば</h3> <h4 id="たけしや">たけしや</h4> <p>松本市のソウルフードと言えば、焼きそば。たけしやはソースやきそば専門店として松本市でながい期間営業しています。ワンコインの並サイズでも麺の量が多く、自家調合の甘めのオリジナルソースが絡んだもちもちの麺を楽しむことができます。持ち帰りも可能なので、ホテルでゆっくり食べるのも良さそうです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20001590%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://tabelog.com/nagano/A2002/A200201/20001590/">tabelog.com</a></cite></p> <h3 id="カレー">カレー</h3> <h4 id="松本メーヤウ-桐店">松本メーヤウ 桐店</h4> <p>松本メーヤウはエスニックカレー専門店のお店です。日本式のカレーだけでなく、タイやインドのカレーが食べられます。また、桐店ではカレーがバイキング形式で食べられるので、値段を気にせずたくさんのカレーを味わうことができます。幅広い種類のカレーがあるので、何回も往復していろいろな種類のカレーを食べるとよさそうです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20025312%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://tabelog.com/nagano/A2002/A200201/20025312/">tabelog.com</a></cite></p> <h3 id="深夜">深夜</h3> <h4 id="香根-かね">香根 (かね)</h4> <p>香根は深夜まで営業している町中華のお店です。多くの飲食店が12時に終わってしまう中、こちらのお店はAM 2:00まで営業しているので、締めとしてラーメンや水餃子が食べられます。ラーメンと言いましても、正確には酸辣湯麺(サンラータン)と呼ばれる中華料理で、ラー油や胡椒で味つけられた酸っぱ辛い味が特徴だそうです。二次会で行きたいお店ですね。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20009427%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://tabelog.com/nagano/A2002/A200201/20009427/">tabelog.com</a></cite></p> <h4 id="Bar-BRORA">Bar BRORA</h4> <p>落ち着いてお酒が飲みたい方はこちらのBar BRORAです。カウンターは迫力がある一枚板が置かれており、あまりの大きさにもう二度と出せないそうです。初めての人でも入りやすい雰囲気のBarです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20010947%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://tabelog.com/nagano/A2002/A200201/20010947/">tabelog.com</a></cite></p> <h4 id="昭和レトロスナックヴィーナス">昭和レトロスナック ヴィーナス</h4> <p>ヴィーナスは松本最古のレトロなスナックです。昭和の頃はスナックと言うと軽食をさしていたそうで、昔懐かしいナポリタンや鳥手羽などが食べられる昭和の雰囲気を現在まで残しているお店です。広い店内には楽器や照明設備が配備されており、事前に確認すれば自分達で弾けるそうです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsnackbar-venus.wixsite.com%2Fvenus" 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://snackbar-venus.wixsite.com/venus">snackbar-venus.wixsite.com</a></cite></p> <h4 id="スタンディング-8オンス">スタンディング 8オンス</h4> <p>スタンディング 8オンスは酒屋のお店がやっている立ち飲みのBarです。生ちゅーはいが390円で、年代物のウイスキーも安い値段で楽しめます。評価が高まっている日本のウイスキーが手頃に飲めるので、お酒好きの方には喜ばれるかもしれません。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20009581%2F" title="スタンディング 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://tabelog.com/nagano/A2002/A200201/20009581/">tabelog.com</a></cite></p> <h4 id="Jazz喫茶エオンタ">Jazz喫茶 エオンタ</h4> <p>お酒が飲めない方はこちらのJazz喫茶 エオンタがおすすめです。Jazz喫茶なので話すことはできませんが、最高級のスピーカーがあるので、Jazz好きの方はもちろん、音楽好きの方にも行ってほしいお店です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20005065%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://tabelog.com/nagano/A2002/A200201/20005065/">tabelog.com</a></cite></p> <h3 id="朝食">朝食</h3> <h4 id="Alps-gohan">Alps gohan</h4> <p>Alps gohanは料理研究家の人が素材や産地だけでなく、お醤油などの調味料にもこだわったお店で、長野県産の食材の魅力を十分に味わうことができます。ランチや夜はもちろん、朝ご飯も提供しており、8時からお店がオープンするとのことです。席は少ないので、大人数でいく際にはご注意ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20021242%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://tabelog.com/nagano/A2002/A200201/20021242/">tabelog.com</a></cite></p> <h4 id="珈琲美学-アベ">珈琲美学 アベ</h4> <p>朝食でもう一つオススメなのが、こちらの珈琲美学 アベです。多くの飲み物とサイドメニューからお好みの物を選ベるセルフビルドスタイルのモーニングです。コーヒーは一杯づつハンドドリップで作ってもらえ、コーヒーだけでなく、モカパフェやホットサンドなど喫茶店ならではのメニューもあります。カフェオレもオススメだそうです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20001420%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://tabelog.com/nagano/A2002/A200201/20001420/">tabelog.com</a></cite></p> <h3 id="銭湯">銭湯</h3> <h4 id="塩井の湯">塩井の湯</h4> <p>カンファレンスでの疲れを癒すのに必要な銭湯も紹介します。塩井の湯はなんと明治時代から営業をしており、大正時代に建てられた銭湯を現在も使用しているそうです。外観も内観も貴重なものとなっています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=http%3A%2F%2Fwww.mcci.or.jp%2Fwww%2Fsioinoyu%2F" title="0263-32-1507,塩井の湯,長野県,松本,松本市,大手,松本城,専用,駐車場,銭湯,お風呂屋,浴場,モダン,建築,大正時代,鉱泉,温泉,井戸,泉,泉質,ミネラル,長生き,水,温まる,塩井の湯" 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="http://www.mcci.or.jp/www/sioinoyu/">www.mcci.or.jp</a></cite></p> <h3 id="松本のお土産">松本のお土産</h3> <h4 id="マサムラ本店ベビーシュークリーム">マサムラ本店(ベビーシュークリーム)</h4> <p>マサムラも半世紀以上営業している老舗の洋菓子屋さんです。さまざまな洋菓子が売られていますが、中でもお土産として喜ばれるのがベビーシュークリームです。ミニサイズながらもカスタードクリームとホイップクリームがバランスよくたっぷりと詰まっている一品です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftabelog.com%2Fnagano%2FA2002%2FA200201%2F20015174%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://tabelog.com/nagano/A2002/A200201/20015174/">tabelog.com</a></cite></p> <h4 id="スーパーツルヤ">スーパーツルヤ</h4> <p>長野県産のお土産を購入したい場合、スーパーツルヤというご当地スーパーに行くのがオススメです。スーパーツルヤはオリジナル商品が販売されており、中でも信州産ふじ りんごバターが人気とのことです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fshop.tsuruya-corp.co.jp%2Ffr%2F13" 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://shop.tsuruya-corp.co.jp/fr/13">shop.tsuruya-corp.co.jp</a></cite></p> <h3 id="最後に">最後に</h3> <p>最後に今回ご協力いただいた宿とサウナとジェラートのタビシロの小澤様からお借りした松本散策マップをご紹介いたします。ぜひこちらもRubyKaigi 2023でのお店選びに役立てていただければと思います。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tmidorikawa/20230404/20230404160450.jpg" width="853" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>また、クックパッドはRubyKaigi 2023にWi-Fi &amp; Rubyists on Rails Sponsorとして、新宿松本間の交通支援を実施させていただいています。Rubyists on Railsは4月6日まで参加者を募集中ですので、よろしければこちらもご確認ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.connpass.com%2Fevent%2F277569%2F" title="Rubyists on Rails - RubyKaigi 2023 (2023/05/10 08: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://cookpad.connpass.com/event/277569/">cookpad.connpass.com</a></cite></p> <p>それでは松本でお会いしましょう!</p> tmidorikawa クックパッドは RubyKaigi 2023 にWi-Fi & Rubyists on Rails Sponsorとして協賛しています hatenablog://entry/4207112889976129913 2023-03-30T18:05:00+09:00 2023-03-30T18:06:28+09:00 こんにちはCTO室の緑川です。 RubyKaigi 2023がいよいよ5月11〜13日に松本市で開催されますね。今年もクックパッドから多くのエンジニアが参加する予定ですので、会場でお話しできる機会がありましたら、どうぞよろしくお願いいたします。 さて、クックパッドはRubyKaigi 2023にWi-Fi & Rubyists on Rails Sponsorとして協賛します。聞きなれないスポンサー名だと思いますので、登壇者情報と合わせてこちらのエントリーでお知らせします。 スポンサーする背景 クックパッドはレシピサービスcookpadや、生鮮食品ECプラットフォームのクックパッドマートの開発… <p>こんにちはCTO室の緑川です。</p> <p>RubyKaigi 2023がいよいよ5月11〜13日に松本市で開催されますね。今年もクックパッドから多くのエンジニアが参加する予定ですので、会場でお話しできる機会がありましたら、どうぞよろしくお願いいたします。</p> <p>さて、クックパッドはRubyKaigi 2023にWi-Fi &amp; Rubyists on Rails Sponsorとして協賛します。聞きなれないスポンサー名だと思いますので、登壇者情報と合わせてこちらのエントリーでお知らせします。</p> <h2 id="スポンサーする背景">スポンサーする背景</h2> <p>クックパッドはレシピサービス<a href="https://cookpad.com/">cookpad</a>や、生鮮食品ECプラットフォームの<a href="https://cookpad-mart.com">クックパッドマート</a>の開発にRubyを使用しています。また、Webサービスだけでなく、クックパッドマートで使用しているステーション(冷蔵庫)で稼働するソフトウェアやハードウェア開発にもRubyを使用しており、Rubyはクックパッドにとって重要な技術の1つです。このようにRubyに支えられているからこそ、Rubyに関するさまざまなオープンソースソフトウェアの発展に貢献したいと考えています。</p> <h2 id="Wi-Fi--Rubyists-on-Rails-Sponsorとは">Wi-Fi &amp; Rubyists on Rails Sponsorとは</h2> <p>クックパッドは今年もカスタムスポンサーを行います。 Wi-Fi Sponsorに関しては、会場のWi-Fiに関する調達・設計・構築・運用などを引き続き <a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> が担当しています。</p> <p>Rubyists on Rails Sponsorは、募集人数に制限はありますが、新宿-松本間を運行する特急あずさの乗車券・特急券をRubyKaigi 2023へ参加する方へ無料提供するスポンサー企画です。こちらの企画ではRubyist同士で松本への移動時間を共有することで、RubyKaigi 2023でのコミュニケーションの場をより創出することを目指しています。</p> <p>応募に関しては、connpassから募集をしていますので、ご希望の方は是非お申し込みください。</p> <p>【企画概要】<br/> ・企画概要:「新宿駅〜松本駅」間を運行する特急あずさの乗車券・特急券を本会議の参加者に抽選で無料提供します。<br/> ・運行区間:新宿駅→松本駅 <a href="#f-f9939a83" name="fn-f9939a83" title="本列車の運行は往路のみ、復路の実施はなし。 ">*1</a> <br/> ・運行日時:2023年5月10日(水)8:00発(10:37着予定)/15:00発(17:37着予定) <br/></p> <p>【参加方法】<br/> ・募集期間:2023年3月30日〜4月6日<br/> ・募集人数:<br/> - 8:00発:30人募集<br/> - 15:00発:30人募集<br/> - どちらでも可:15人募集<br/> ・費用:無料<br/> ・URL:<a href="https://cookpad.connpass.com/event/277569/">https://cookpad.connpass.com/event/277569/</a></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tmidorikawa/20230330/20230330111048.jpg" alt="Rubyists on Rails" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="登壇情報">登壇情報</h2> <p>クックパッドからは<a href="https://twitter.com/koichisasada">@koichisasada</a>(<a href="https://github.com/ko1">ko1</a>)、<a href="https://twitter.com/mametter">@mametter</a>、<a href="https://twitter.com/osyoyu">@osyoyu</a>の3名が登壇します。</p> <h2 id="おわりに">おわりに</h2> <p>RubyKaigi 2023の会場で皆様にお会いできることをクックパッド社員一同楽しみにしています。また、クックパッドの社員をお見かけの際には、ぜひお声がけいただけますと嬉しいです!</p> <p>最後に、クックパッドではサービス開発に挑戦をしたいRubyistの方を募集中です。クックパッドで働くことにご興味のある方は気軽にブースにお越しください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-f9939a83" name="f-f9939a83" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">本列車の運行は往路のみ、復路の実施はなし。 </span></p> </div> tmidorikawa SwiftUIでStickyなタブを実現する hatenablog://entry/4207112889967248624 2023-02-28T16:36:45+09:00 2023-02-28T16:39:27+09:00 こんにちは。クックパッド事業部でiOSアプリの開発をしている新堀(@tk108gabalian)です。 iOS版のクックパッドアプリではスクロール時にコンポーネントが上部に固着する画面があります。 所謂StickyHeaderというやつです。 今回はそのStickyHeaderをSwiftUIで、かつTabViewを使用つつ実現する方法について紹介します。 導入の背景 2022年7月にリリースした「のせる」画面には以下の要件がありました。 画面上部にユーザー情報を表示する。 ユーザー情報の下にタブを表示する。 タブをタップするか、タブより下を横にスワイプすることでタブの切り替えが可能。 画面全… <p>こんにちは。クックパッド事業部でiOSアプリの開発をしている新堀<a href="https://twitter.com/tk108gabalian">(@tk108gabalian)</a>です。<br> iOS版のクックパッドアプリではスクロール時にコンポーネントが上部に固着する画面があります。 所謂StickyHeaderというやつです。 今回はそのStickyHeaderをSwiftUIで、かつTabViewを使用つつ実現する方法について紹介します。</p> <h2 id="導入の背景">導入の背景</h2> <p>2022年7月にリリースした「のせる」画面には以下の要件がありました。</p> <ul> <li>画面上部にユーザー情報を表示する。</li> <li>ユーザー情報の下にタブを表示する。</li> <li>タブをタップするか、タブより下を横にスワイプすることでタブの切り替えが可能。</li> <li>画面全体をスクロールできるが、ユーザー情報が隠れるまでスクロールしたら画面上部にタブが固着し、以降はタブより下の部分のみスクロールする。(逆方向にスクロールする場合は再度ユーザー情報が表出する。)</li> </ul> <p><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228144117" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228144117.gif" width="612" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></a></span></p> <p>また、事業的な希望ではありませんが、開発メンバー達の中ではSwiftUIで画面を実装したいという気持ちがありました。 というのも、この「のせる」以前の画面は基本的にUIKitで作られており、SwiftUIと比べてUIの変更に時間がかかることが課題としてあったからです。</p> <p>UIKitでこのStickyHeader実現することは可能だと分かっていました。<br> なぜなら、同クックパッドアプリの「きろく」画面にて、UIKit製のStickyHeaderが既に実装されていたからです。<br> そのため、UIKitを使えば確実に要件を満たすものが作れると分かりつつも、SwiftUIで同様の振る舞いを実現する方法を探求することにしました。</p> <h2 id="TabViewなしのStickyHeaderの実現方法">TabViewなしのStickyHeaderの実現方法</h2> <p>横スクロールによるタブの切り替えを実現するため、SwiftUIのTabViewを使うことを検討していました。 しかし、検証を進めると、TabViewを使いつつStickyHeaderを実現する難しさが分かってきました。</p> <p>まずはTabViewを使わない場合のStickyHeaderの実現方法を見てみます。<br> これはScrollView内のコンテンツでLazyVStackなどを使用し、<code>pinnedViews</code>に<code>.sectionHeaders</code>を指定した上で、LazyVStackの中のSectionにheaderとなるViewを指定するだけです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">ContentView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { ScrollView { LazyVStack(pinnedViews<span class="synSpecial">:</span> .sectionHeaders) { Section(header<span class="synSpecial">:</span> <span class="synType">Text</span>(<span class="synConstant">&quot;ここが固着する&quot;</span>)) { ForEach(<span class="synConstant">1</span><span class="synIdentifier">...</span><span class="synConstant">50</span>, id<span class="synSpecial">:</span> \.<span class="synIdentifier">self</span>) { number <span class="synStatement">in</span> Text(<span class="synConstant">&quot;Row </span><span class="synSpecial">\(</span>number<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) } } } } } } </pre> <p>しかし、上記の方針だとScrollViewの中にTabViewを入れる時に問題が発生します。<br> ScrollViewにジェスチャーが吸われるので、TabViewの中身を横スワイプで切り替えることができません。 (一応TabViewのselectionを切り替えるボタンを用意すればタブを切り替えることはできます。)</p> <p>また、ScrollView内にTabViewを配置する場合、TabViewの高さが<code>frame modifier</code>のheightによって明示的に指定されないと、高さが確定せずに何も表示されません。 よって、TabView内のコンテンツの高さを全て計算してheightを設定する必要があり、かつそれがタブごとに、またタブの切り替えごとに必要になります。</p> <p>このように、TabViewと<code>pinnedViews</code>を併用してStickyHeaderを実現しようと思うと、TabViewの旨みである横スワイプが無効になり、さらにTab内のすべてのViewの高さ計算が必要になってしまいます。</p> <p>ここまでの調査でTabViewを使用しつつStickyHeaderを実現することが簡単ではないことが分かりましたが、検証を進めていく中で要件を満たす実装に辿り着くことができました。</p> <h2 id="動作環境">動作環境</h2> <p>iOS Deployment Targetが 14.0 以上を想定しています。 ※ iOS13での動作は未検証となっています。</p> <h2 id="画面構成">画面構成</h2> <p>まず初めに、StickeyHeaderを実現している画面の構成について説明します。</p> <p>前提として、iOS版クックパッドアプリでSwiftUIを採用している画面では、SwiftUIのViewをUIHostingControllerでラップし、そのUIHostingControllerのviewを親となるUIViewControllerに載せて使っています。<br> 上記の構成は<a href="https://techlife.cookpad.com/entry/2021/01/18/kaimono-swift-ui">SwiftUI を活用した「レシピ」×「買い物」の新機能開発</a>で紹介されているので、詳しくはこちらをご参照ください。</p> <p>SwiftUIのViewは以下のような階層になっています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">FooTabView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@Binding</span> <span class="synType">var</span> selection<span class="synSpecial">:</span> <span class="synType">TabType</span> <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">topAreaHeight</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> { Header.height <span class="synIdentifier">+</span> TabBar.height } <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { ZStack(alignment<span class="synSpecial">:</span> .top) { tabView VStack(alignment<span class="synSpecial">:</span> .center, spacing<span class="synSpecial">:</span> <span class="synConstant">0</span>) { Header() TabBar(tabTypes<span class="synSpecial">:</span> <span class="synType">TabType.allCases</span>, selection<span class="synSpecial">:</span> <span class="synIdentifier">$selection</span>) } } } <span class="synType">@ViewBuilder</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">tabView</span><span class="synSpecial">:</span> <span class="synType">some</span> View { TabView(selection<span class="synSpecial">:</span> <span class="synIdentifier">$selection</span>) { ForEach(TabType.allCases) { _ <span class="synStatement">in</span> ScrollView { Content() } } } .padding(.top, topAreaHeight) .tabViewStyle(PageTabViewStyle(indexDisplayMode<span class="synSpecial">:</span> .never)) } } </pre> <p>ZStackを配置し、ZStack内にTabViewとヘッダー部分を配置します。 この時、ヘッダーが前面、TabViewが背面になります。 背面のTabViewにはTabごとに縦方向のScrollViewを配置します。 そのままではTabViewが上部のヘッダーに隠れてしまうので、TabViewのtopにpaddingを付けます。</p> <h2 id="TabViewありのStickyHeaderの実現方法">TabViewありのStickyHeaderの実現方法</h2> <p>次にスクロールに合わせて前面のコンポーネントが動くように修正し、さらに一定以上は動かなくなる(画面上部で固着する)振る舞いを実現する方法を紹介します。<br> 以下の2つのステップに分けて説明します。</p> <ol> <li>SwiftUIのScrollViewからスクロール量を取得する。</li> <li>取得したスクロール量を使って上部のコンポーネントを動かす。</li> </ol> <h3 id="1-SwiftUIのScrollViewからスクロール量を取得する">1. SwiftUIのScrollViewからスクロール量を取得する</h3> <p>スクロール量を取得するため、SwiftUIのGeometryReaderとPreferenceKeyを使います。<br> GeometryReaderは親となるViewのサイズや座標を取得するAPIです。 ScrollView内にGeometryReaderを配置することで、ScrollView内のコンテンツのY座標を取得できます。(このY座標が実質スクロール量になります。) GeometryReaderから座標とサイズを取得するには、GeometryReaderのinitializerのclosureからGeometryProxyを受け取る必要があります。 しかしこのclosureは<code>@ViewBuilder</code>となっており、このスコープ内で<code>@State</code>な変数を上書きすることはできません。</p> <p>そこでPreferenceKeyが登場します。<br> PreferenceKeyを使用すると子Viewから親Viewに値を受け渡すことができます。 まずGeometryReader内に透明なViewを配置します。これで<code>@ViewBuilder</code>のコンパイルできる条件を満たします。 そして上記の透明なViewでPreferenceKeyを使用し、親ViewにGeometryProxyのy座標を渡します。</p> <p>ここまでの一連の処理を一つのViewで行えるようにし、利用元はclosureからスクロール量を受け取ることができるようにしました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">private</span> <span class="synPreProc">struct</span> <span class="synIdentifier">ScrollViewOffsetYPreferenceKey</span><span class="synSpecial">:</span> <span class="synType">PreferenceKey</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">defaultValue</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> .zero <span class="synStatement">static</span> <span class="synPreProc">func</span> <span class="synIdentifier">reduce</span>(value<span class="synSpecial">:</span> <span class="synPreProc">inout</span> <span class="synIdentifier">CGFloat</span>, nextValue<span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">CGFloat</span>) {} } <span class="synStatement">public</span> <span class="synPreProc">struct</span> <span class="synIdentifier">OffsetReadableVerticalScrollView</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Content</span><span class="synSpecial">: </span><span class="synType">View</span><span class="synSpecial">&gt;:</span> <span class="synType">View</span> { <span class="synStatement">private</span> <span class="synPreProc">struct</span> <span class="synIdentifier">CoordinateSpaceName</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> {} <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">showsIndicators</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">onChangeOffset</span><span class="synSpecial">: (</span><span class="synType">CGFloat</span><span class="synSpecial">)</span> <span class="synSpecial">-&gt;</span> <span class="synType">Void</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">content</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span> <span class="synStatement">public</span> <span class="synIdentifier">init</span>( showsIndicators<span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">true</span>, onChangeOffset<span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> (</span><span class="synType">CGFloat</span><span class="synSpecial">)</span> <span class="synSpecial">-&gt;</span> <span class="synType">Void</span>, <span class="synType">@ViewBuilder</span> <span class="synType">content</span><span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span> ) { <span class="synIdentifier">self</span>.showsIndicators <span class="synIdentifier">=</span> showsIndicators <span class="synIdentifier">self</span>.onChangeOffset <span class="synIdentifier">=</span> onChangeOffset <span class="synIdentifier">self</span>.content <span class="synIdentifier">=</span> content } <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { ScrollView(.vertical, showsIndicators<span class="synSpecial">:</span> <span class="synType">showsIndicators</span>) { ZStack(alignment<span class="synSpecial">:</span> .top) { GeometryReader { geometryProxy <span class="synStatement">in</span> Color.clear.preference( key<span class="synSpecial">:</span> <span class="synType">ScrollViewOffsetYPreferenceKey.self</span>, value<span class="synSpecial">:</span> <span class="synType">geometryProxy.frame</span>(<span class="synStatement">in</span><span class="synSpecial">:</span> .named(CoordinateSpaceName())).minY ) } .frame(width<span class="synSpecial">:</span> <span class="synConstant">1</span>, height<span class="synSpecial">:</span> <span class="synConstant">1</span>) content() } } .coordinateSpace(name<span class="synSpecial">:</span> <span class="synType">CoordinateSpaceName</span>()) .onPreferenceChange(ScrollViewOffsetYPreferenceKey.<span class="synIdentifier">self</span>) { offset <span class="synStatement">in</span> onChangeOffset(offset) } } } </pre> <h3 id="2-取得したスクロール量を使って上部のコンポーネントを動かす">2. 取得したスクロール量を使って上部のコンポーネントを動かす</h3> <p>スクロール量が取得できたらそれを使ってヘッダー部分を動かします。<br> SwiftUIでは<code>offset modifier</code>を変更することで描画地点をずらすことができます。<br> ①これを利用し、取得したスクロール量をoffsetに渡すことでヘッダーを動かします。</p> <table> <thead> <tr> <th style="text-align:center;"> before </th> <th style="text-align:center;"> after </th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145158" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145158.gif" width="614" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145338" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145338.gif" width="612" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> </tr> </tbody> </table> <p>しかし、スクロール量をそのままoffsetに反映させ続けると、固着して欲しいタブの部分が画面の上に突き抜けていってしまったり、動いて欲しい方向とは逆向きにヘッダーが動いてしまいます。<br> ②そこで、offsetの範囲をあらかじめ決めておき、その範囲内でoffsetを変えるようにしています。</p> <table> <thead> <tr> <th style="text-align:center;"> before </th> <th style="text-align:center;"> after </th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145338" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145338.gif" width="612" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145600" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145600.gif" width="614" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> </tr> </tbody> </table> <p>また、ヘッダーが上に動いているのにヘッダーより下のコンポーネントが動いていないと、ヘッダーの下に隙間ができてしまいます。<br> ③これを防ぐために、ヘッダーより下のコンポーネント(参考実装で言うところのtabView)のpaddingにoffsetを反映させ、ヘッダーに追従して動くようにしています。</p> <table> <thead> <tr> <th style="text-align:center;"> before </th> <th style="text-align:center;"> after </th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145600" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145600.gif" width="614" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145827" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145827.gif" width="612" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> </tr> </tbody> </table> <p>しかし、上記のpaddingの変更によってヘッダー以下のコンポーネントの位置が変わると、その分ScrollView内のコンテンツのY座標も動いてしまい、スクロールとpadding変更の両方が合わさった結果、倍速のスクロールが発生してしまいます。<br> ④これを防ぐために、ScrollView内のコンテンツの<code>offset modifier</code>にて反転したoffsetを指定することで、paddingの変更分を相殺し、倍速でスクロールしないようにしています。</p> <table> <thead> <tr> <th style="text-align:center;"> before </th> <th style="text-align:center;"> after </th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228145827" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228145827.gif" width="612" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> <td style="text-align:center;"> <span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228150014" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228150014.gif" width="612" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span> </td> </tr> </tbody> </table> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">FooTabView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@Binding</span> <span class="synType">var</span> selection<span class="synSpecial">:</span> <span class="synType">s</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">offset</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> .zero <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">topAreaHeight</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> { Header.height <span class="synIdentifier">+</span> TabBar.height } <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { ZStack(alignment<span class="synSpecial">:</span> .top) { tabView VStack(alignment<span class="synSpecial">:</span> .center, spacing<span class="synSpecial">:</span> <span class="synConstant">0</span>) { Header() TabBar(tabTypes<span class="synSpecial">:</span> <span class="synType">TabType.allCases</span>, selection<span class="synSpecial">:</span> <span class="synIdentifier">$selection</span>) } .offset(y<span class="synSpecial">:</span> <span class="synType">offset</span>) <span class="synComment">// ①</span> } } <span class="synType">@ViewBuilder</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">tabView</span><span class="synSpecial">:</span> <span class="synType">some</span> View { TabView(selection<span class="synSpecial">:</span> <span class="synIdentifier">$selection</span>) { ForEach(TabType.allCases) { _ <span class="synStatement">in</span> OffsetReadableVerticalScrollView(onChangeOffset<span class="synSpecial">:</span> <span class="synType">updateOffset</span>) { Content() .offset(y<span class="synSpecial">:</span> <span class="synIdentifier">-</span>offset) <span class="synComment">// ④</span> .padding(.bottom, topAreaHeight) } } } .padding(.top, topAreaHeight <span class="synIdentifier">+</span> offset) <span class="synComment">// ③</span> .tabViewStyle(PageTabViewStyle(indexDisplayMode<span class="synSpecial">:</span> .never)) } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">updateOffset</span>(_ newOffset<span class="synSpecial">:</span> <span class="synType">CGFloat</span>) { <span class="synComment">// ②</span> <span class="synStatement">if</span> newOffset <span class="synIdentifier">&lt;=</span> <span class="synIdentifier">-</span>topAreaHeight { <span class="synComment">// HostingControllerを使わない場合、ここにsafeAreaを高さを足す必要がある。</span> offset <span class="synIdentifier">=</span> <span class="synIdentifier">-</span>topAreaHeight } <span class="synStatement">else</span> <span class="synStatement">if</span> newOffset <span class="synIdentifier">&gt;=</span> <span class="synConstant">0.0</span> { offset <span class="synIdentifier">=</span> <span class="synConstant">0</span> } <span class="synStatement">else</span> { offset <span class="synIdentifier">=</span> newOffset } } } </pre> <p>ここまでの実装で、上部コンポーネントの移動とタブの固着が実現できます。</p> <h2 id="対処した問題">対処した問題</h2> <p>前章で紹介した実装でタブの固着は実現できるのですが、実装に進むと以下の問題点に気づきました。</p> <ol> <li>タブを切り替える時にoffsetが大きくずれてしまう。</li> <li>iOS15系だとサイズクラスの変更の際にTabView以下の表示が崩れてしまう。</li> <li>TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう。</li> </ol> <h3 id="タブを切り替える時にoffsetが大きくずれてしまう">タブを切り替える時にoffsetが大きくずれてしまう</h3> <p>スクロール量の取得とoffsetの変更はタブごとに行なっています。<br> そのため、タブを切り替えるとスクロール量も大きく変更される可能性があります。<br> その際にヘッダー部分のoffsetが追従していないと、ScrollView内のコンテンツがヘッダー部分に隠れたり、ScrollView内のコンテンツのTopとヘッダーの間に大きな余白が見えてしまいます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228151456" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228151456.gif" width="614" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span></p> <p>これを防ぐために、タブごとのスクロール量を保持しておき、タブの切り替えの際に変更先のタブのスクロール量を流すようにしています。 (コードは簡略化したものです。)</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">OffsetReadableTabContentScrollView</span><span class="synSpecial">&lt;</span><span class="synIdentifier">TabType</span><span class="synSpecial">: </span><span class="synType">Hashable</span><span class="synSpecial">, </span><span class="synIdentifier">Content</span><span class="synSpecial">: </span><span class="synType">View</span><span class="synSpecial">&gt;:</span> <span class="synType">View</span> { <span class="synPreProc">let</span> <span class="synIdentifier">tabType</span><span class="synSpecial">:</span> <span class="synType">TabType</span> <span class="synPreProc">var</span> <span class="synIdentifier">selection</span><span class="synSpecial">:</span> <span class="synType">TabType</span> <span class="synPreProc">let</span> <span class="synIdentifier">onChangeOffset</span><span class="synSpecial">: (</span><span class="synType">CGFloat</span><span class="synSpecial">)</span> <span class="synSpecial">-&gt;</span> <span class="synType">Void</span> <span class="synPreProc">let</span> <span class="synIdentifier">content</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">currentOffset</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> .zero <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { OffsetReadableVerticalScrollView( onChangeOffset<span class="synSpecial">:</span> { offset <span class="synStatement">in</span> currentOffset <span class="synIdentifier">=</span> offset <span class="synStatement">if</span> tabType <span class="synIdentifier">==</span> selection { onChangeOffset(offset) } }, content<span class="synSpecial">:</span> <span class="synType">content</span> ) .onChange(of<span class="synSpecial">:</span> <span class="synType">selection</span>) { selection <span class="synStatement">in</span> <span class="synStatement">if</span> tabType <span class="synIdentifier">==</span> selection { onChangeOffset(currentOffset) } } } } </pre> <h3 id="iOS15系だとサイズクラスの変更の際にTabView以下の表示が崩れてしまう">iOS15系だと、サイズクラスの変更の際にTabView以下の表示が崩れてしまう</h3> <p>これはSwiftUI側の問題のようなのですが、iOS15系でTabViewを使用している時、タブを2ページ目以降にした状態でサイズクラスを変更すると、タブ内の表示が崩れる不具合がありました。(iOS14、16系では再現しない。)</p> <p><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228152424" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228152424.gif" width="640" height="854" loading="lazy" title="" class="hatena-fotolife" style="width:500px" itemprop="image"></a></span></p> <p>そのため、iOS15系のみサイズクラスの変更を検知した際のworkaroundを入れることにしました。<br> 「のせる」画面ではUIHostingControllerを使用していたため、UIHostingControllerのviewを保持するUIViewControllerでサイズクラスの変更を検知し、SwiftUIのViewへイベントを流すようにしました。</p> <p>ただし、この方法には一つ別の問題があります。<br> それはサイズクラスの変更によってViewが再生成されるため、showログが送り直されたりスクロール位置がリセットされてしまうことです。 しかし、ユーザー数の多いiPhoneでは画面回転を無効にしていてサイズクラスの変更が発生せず、iOS15系のiPadのみで発生する可能性があることから、許容する方針としました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">override</span> <span class="synStatement">public</span> <span class="synPreProc">func</span> <span class="synIdentifier">viewDidLayoutSubviews</span>() { <span class="synIdentifier">super</span>.viewDidLayoutSubviews() <span class="synStatement">if</span> screenSizeDataSource.screenSize <span class="synIdentifier">!=</span> view.bounds.size { screenSizeDataSource.screenSize <span class="synIdentifier">=</span> view.bounds.size } } </pre> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">FooTabView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">layoutTrigger</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { ZStack(alignment<span class="synSpecial">:</span> .top) { <span class="synStatement">if</span> layoutTrigger { tabView } <span class="synStatement">else</span> { tabView } <span class="synIdentifier">...</span> } .onChange(of<span class="synSpecial">:</span> <span class="synType">screenSize</span>) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> #unavailable(iOS <span class="synConstant">16.0</span>), UIDevice.current.userInterfaceIdiom <span class="synIdentifier">==</span> .pad { layoutTrigger.toggle() } } } <span class="synIdentifier">...</span> } </pre> <h3 id="TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に画面がガタガタしてしまう">TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう</h3> <p>ちょっとgifだと分かりづらいのですが、以下のような挙動になります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/toya108_climbing/20230228153005" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20230228/20230228153005.gif" width="555" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:250px" itemprop="image"></a></span></p> <p>ヘッダーが完全に縮小していないかつスクロールの最下部に達した状態でさらにスクロールしようとすると、スクロールのバウンス<a href="#f-21084bd2" name="fn-21084bd2" title="スクロール領域の最後まで行った時にそれまでのスクロール方向とは逆向きに小さくコンテンツが跳ねること">*1</a>によってoffsetが小刻みに増減し、Viewが振動しているような振る舞いをします。<br> この挙動を回避するため、ScrollViewがコンテンツの最下部に達したかどうかを判定し、さらにバウンスが発生する向きにスクロールしようとしている場合はoffsetを更新しないようにしました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">updateOffset</span>(_ newOffset<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, _ didUpdateByTabChange<span class="synSpecial">:</span> <span class="synType">Bool</span>, _ hasReachedContentBottom<span class="synSpecial">:</span> <span class="synType">Bool</span>) { <span class="synComment">// タブの中身のスクロールが最下部に達してもヘッダーが完全に縮小していない状態でさらに下にスクロールすると、スクロールのバウンスによって offset が小刻みに増減し画面がガタガタしてしまう</span> <span class="synComment">// タブの中身のコンテンツの最下部が表示されている かつ さらに下にスクロールしようとしている場合は offset を更新しないようにする</span> <span class="synComment">// ただし、タブの選択が切り替わった時( didUpdateByTabChange が true の時)の offset 更新は常に行う</span> <span class="synStatement">if</span> <span class="synIdentifier">!</span>didUpdateByTabChange <span class="synIdentifier">&amp;&amp;</span> hasReachedContentBottom <span class="synIdentifier">&amp;&amp;</span> (newOffset <span class="synIdentifier">&lt;</span> offset) { <span class="synStatement">return</span> } <span class="synStatement">if</span> newOffset <span class="synIdentifier">&lt;=</span> <span class="synIdentifier">-</span>maxHeaderOffset { offset <span class="synIdentifier">=</span> <span class="synIdentifier">-</span>maxHeaderOffset } <span class="synStatement">else</span> <span class="synStatement">if</span> newOffset <span class="synIdentifier">&gt;=</span> <span class="synConstant">0.0</span> { offset <span class="synIdentifier">=</span> <span class="synConstant">0</span> } <span class="synStatement">else</span> { offset <span class="synIdentifier">=</span> newOffset } } </pre> <p>ScrollViewのコンテンツが最下部に達したかどうかの判定は透明なViewのonAppear/onDisapperによって行なっています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">private</span> <span class="synPreProc">struct</span> <span class="synIdentifier">ReachedContentBottomTracker</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@Binding</span> <span class="synType">var</span> hasReachedContentBottom<span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { <span class="synComment">// View が表示された時に onAppear/onDisappear が呼ばれるようにしたいため LazyVStack で囲っている</span> LazyVStack { Color.clear .frame(width<span class="synSpecial">:</span> <span class="synConstant">1</span>, height<span class="synSpecial">:</span> <span class="synConstant">1</span>) .onAppear { hasReachedContentBottom <span class="synIdentifier">=</span> <span class="synConstant">true</span> } .onDisappear { hasReachedContentBottom <span class="synIdentifier">=</span> <span class="synConstant">false</span> } } } } Content() .background(ReachedContentBottomTracker(hasReachedContentBottom<span class="synSpecial">:</span> <span class="synIdentifier">$hasReachedContentBottom</span>), alignment<span class="synSpecial">:</span> .bottom) </pre> <h2 id="その他検討した実現方法">その他検討した実現方法</h2> <p>以下の2つの方法も実現が可能か調査しました。</p> <ol> <li>UIKitのUIScrollViewをUIViewRepresentableで使用する。</li> <li>Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。</li> </ol> <h3 id="1-UIKitのUIScrollViewをUIViewRepresentableで使用する">1. UIKitのUIScrollViewをUIViewRepresentableで使用する</h3> <p>UIViewRepresentableはUIKitのViewをSwiftUIで使用するためのAPIです。<br> UIKitのUIScrollViewではcontentOffsetを取得することでスクロール量が分かります。 そのため、UIScrollViewをSwiftUIのViewに組み込めば、SwiftUIのScrollViewを使わずにスクロール量を取得することが可能です。<br> しかし、UIViewRepresentableを使用すると、過去にサイズクラスの変更やdynamicTypeの変更を追従してくれない不具合が発生しており、採用は見送りになりました。</p> <p>一方で、UIKitのUIScrollViewを使用するメリットもありました。 それは精度の高いスクロール量を取得できることです。</p> <p>後述しますが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。 取得できるスクロール量の精度はヘッダーの動きの滑らかさに直結するのですが、プロトタイプを元にデザイナーに相談しならがら検証を進めていき、許容できる範囲と判断してSwiftUIを採用することにしました。</p> <h3 id="2-Introspectを使用しScrollViewの裏側のUIScrollViewのdelegateを使用する">2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。</h3> <p><a href="https://github.com/siteline/SwiftUI-Introspect">Introspect</a>はSwiftUIの裏側で使用されているUIKitにアクセスし、UIKitの機能を使用できるライブラリです。<br> これを使用すればSwiftUIのScrollViewでUIScrollViewを取得し、スクロール量を取得することが可能です。 しかし、SwiftUIの内部実装の変更によって裏側のUIKitを取得できなくなる可能性があることから、採用は見送りになりました。</p> <h2 id="PreferenceKeyとGeometryReaderで取得できるスクロール量の精度についての注意点">PreferenceKeyとGeometryReaderで取得できるスクロール量の精度についての注意点</h2> <p>「UIKitのUIScrollViewをUIViewRepresentableで使用する」でも書きましたが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。</p> <p>スクロール量の元にViewを動かすのが今回の紹介したStickyHeaderの実現方法のため、取得するスクロール量の精度が粗いとその分Viewのカクつきが気になってきます。<br> そのため、ヘッダーの動く範囲が大きくてカクつきが目立ってしまうような画面での使用はお勧めできません。<br> その場合、UIScrollViewを使用して実装するか、そもそもStickyHeaderが必要ないデザインで同じ機能を実現できないか考えることも必要だと思いました。</p> <h2 id="まとめ">まとめ</h2> <p>今回紹介させていただいたSwiftUIでStickyなタブを実現する方法は、注意点こそあれど、SwiftUIで実現できる要件を増やす一つの手段になると思っています。 同じようにSwiftUIは使いたいけどタブを実現できるか分からないという方がいましたら、この記事がお役に立てると幸いです。</p> <p>また、本記事で紹介した内容はほぼすべて同チームの<a href="https://mobile.twitter.com/miichan_ocha">@miichan_ocha</a>が検証、実装してくれたものです。 <a href="https://mobile.twitter.com/miichan_ocha">@miichan_ocha</a>を筆頭に、調査や検証に協力してくれた同僚の皆さんに感謝します。</p> <p><span><style> img{ display: inline-block; box-sizing: border-box; border: solid 0.5px #333; } </style></span></p> <div class="footnote"> <p class="footnote"><a href="#fn-21084bd2" name="f-21084bd2" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">スクロール領域の最後まで行った時にそれまでのスクロール方向とは逆向きに小さくコンテンツが跳ねること</span></p> </div> toya108_climbing クックパッドが手がけるスマート冷蔵庫「マートステーション」の開発の歴史2023 hatenablog://entry/4207112889960962736 2023-02-07T12:30:00+09:00 2023-03-03T20:38:10+09:00 こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、生鮮食品のECプラットフォーム、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等を担当しています。 cookpad-mart.com クックパッドマートはiOSおよびAndroidの専用アプリで利用可能なサービスです。このアプリで商品を購入して、街中のスマート冷蔵庫「マートステーション」で受け取れます。 マートステーションは駅やコンビニエンスストア、マンションの共用部といった人々の生活動線に設置し、共同納品を行っています。そのため自宅に… <p>こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、生鮮食品のECプラットフォーム、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等を担当しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad-mart.com%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://cookpad-mart.com/">cookpad-mart.com</a></cite></p> <p>クックパッドマートはiOSおよびAndroidの専用アプリで利用可能なサービスです。このアプリで商品を購入して、街中のスマート冷蔵庫「<strong>マートステーション</strong>」で受け取れます。 マートステーションは駅やコンビニエンスストア、マンションの共用部といった人々の生活動線に設置し、共同納品を行っています。そのため自宅に個別配送をする他の生鮮食品ECに比べ、より物流コストを抑えた配送を実現しています <a href="#f-b65ce044" name="fn-b65ce044" title="なお、一部有料オプションで自宅配送サービスもあります。">*1</a> 。</p> <p>クックパッドマートは食材の輸配送や保管をはじめとした現実世界を相手に展開しているビジネスです。 そのためマートステーションや温度センサをはじめとした様々な機材が必要なこと、加えて社内にはハードウェアチームがあり、ハードウェアエンジニアや組込エンジニア等の普通のWeb系企業にはいない、ユニークな人材がいることを、前回下記の記事でお話ししました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2023%2F02%2F03%2F152212" title="工事設計認証(技適)をとってみた - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2023/02/03/152212">techlife.cookpad.com</a></cite></p> <p>本稿ではマートステーションの開発、進化の歴史をご紹介します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035033.png" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>前述したとおり、クックパッドマートでは駅やコンビニエンスストア、マンションの共用部等に設置し、配送した食材を保管するためのスマート冷蔵庫「<strong>マートステーション</strong>」を独自開発・運用しています。</p> <p>マートステーションは単なる冷蔵庫として機能するのみならず、盗難防止用の電子錠や鍵として機能するQRコードのリーダー、さらには温度監視や死活監視の機能を搭載しています。</p> <p>サービス開発初期、私たちの用途にかなう機材を探したものの、残念ながら世の中に存在しませんでした。従って自分たちで試行錯誤をしながら、以下のような開発を行っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2019%2F04%2F12%2F180000" title="新規事業のIoTプロダクト開発に必要なこと【連載:クックパッドマート開発の裏側 vol.5】 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2019/04/12/180000">techlife.cookpad.com</a></cite> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fascii.jp%2Felem%2F000%2F004%2F064%2F4064111%2F" title="好調クックパッドマートを支える「マートステーション」の開発とSORACOM (1/2)" 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://ascii.jp/elem/000/004/064/4064111/">ascii.jp</a></cite> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fcookpad%2Fpost_articles%2F287050" 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.wantedly.com/companies/cookpad/post_articles/287050">www.wantedly.com</a></cite></p> <p>冷蔵庫の鍵は受け取りにきたお客様や配送ドライバー、メンテナンススタッフがQRコードを用いて開錠することができるようになっています。 また、すべての冷蔵庫に温度計を設置し、常に庫内温度をモニタリングしています。機材トラブルやドアの閉じ忘れ等、様々な要因による温度異常を検知した際には直ちに適切な対応が取れるようになっています。</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/c/cookpadtech/20230207/20230207035131.png" width="636" height="434" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>庫内温度のモニタリングの様子</figcaption></figure></p> <p>このようなハードウェアを開発するにあたって、私たちが得意としてきたソフトウェア開発とは異なるノウハウが必要であったため、チームで悪戦苦闘しながら、数多くの新規開発とアップデートを行ってきました。今回は私たちがその過程で獲得した開発に対する考え方と、2023年2月現在に活躍しているマートステーションのご紹介、そしてその進化の歴史についてご紹介します。</p> <h2 id="開発の方針">開発の方針</h2> <p>マートステーションは人々の生活動線上に設置先のオーナー等の承諾を得た上で間借りして設置されています。またその数は2023年1月末時点で一都三県に約1,000箇所にのぼるため、保守を行う上でも効率的に管理できることが求められます。 そのようなマートステーションを開発する上で重要となる開発方針を説明します。</p> <h3 id="ノーメンテナンスで安定稼働">ノーメンテナンスで安定稼働</h3> <p>メンテナンス要員が常に現場にいることを想定しておらず、その場で復旧および故障判断を即座に行いづらいマートステーションには、高い安定性が要求されます。 せっかくお客様に商品を購入していただいても、マートステーションにトラブルが発生するとお客様は商品を受け取ることができず、最悪の場合その日の食事の支度ができなくなってしまうからです。 しかしながら当初はハードウェア開発や管理等の知見が不足していたため、毎日不具合が発生しては現場にメンバーが向かって、復旧を行う必要がありました。お客様が購入した食材を受け取れない事態が発生することもありました。 ユーザ体験の著しい悪化を防ぐためにも、ステーションが安定して稼働していることは、とても重要です。</p> <h3 id="省スペース">省スペース</h3> <p>たとえばコンビニエンスストア等の店舗では、既存の棚やラック等の店頭什器の代わりにマートステーションを設置するため、よりコンパクトなスペースで設置できることが求められます。スペースの都合で設置できないということも発生しうるため、よりコンパクトなマートステーションが開発できれば、それだけより多くの場所に設置できる可能性が広がります。</p> <h3 id="低コスト管理">低コスト管理</h3> <p>マートステーションの「食材を冷やす」冷蔵庫機能については、冷蔵庫メーカの既存製品を用いています。 しかしながら、QRコードリーダや電子錠、温度監視、死活監視機能等、私たちがマートステーションとして運用する機材(以後コントロール・ユニットと表記します)およびシステムは、社内にて全体設計、機材選定、組込ソフトウェアの開発・運用を行っています。そのためコントロール・ユニットの変更を行えば、その分ファームウェアの更新や管理にかかるコストが増えてしまいます。極力同一のコントロール・ユニットで多様なモデルに適用できるように設計する必要があります。</p> <p><figure class="figure-image figure-image-fotolife" title="コントロール・ユニット(JCM-Mk1)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035239.png" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コントロール・ユニット(JCM-Mk1)</figcaption></figure></p> <h3 id="よりやすいマートステーションへ">より「やす」いマートステーションへ</h3> <p>上記に加え、開発では4つの「やすい」をキーワードに開発を行っています。機材を安定して低コストで調達するための方針です。</p> <ul> <li><strong>調達しやすい</strong>:どれほど優れた機材でも、部品調達のリードタイムがかかってしまったり、市中在庫がなくなってしまっては、生産を行うことができません。特に現在の半導体をはじめとした部品不足により、マートステーションも調達リードタイムが伸びてしまい、欠品となるリスクに何度も直面しました。そのため、極力汎用品で代替調達が可能なものを部品として選定しています。</li> <li><strong>つくりやすい</strong>:部品点数が増えたり製造工程が複雑になってしまうと、組立ミスや組立コストの増加につながります。できる限り部品点数を削減し、つくりやすい設計を行うことで、コストを削減し、量産品質を安定させることができます。</li> <li><strong>扱いやすい</strong>:品質の良いものを量産しても、設置後どうしても一定の割合で不具合が出てしまいます。最悪の場合、商品を購入したお客様が商品を受け取れなくなってしまう可能性もあるため、すぐに復旧できるよう保守チームにとって扱いやすい設計を常に意識する必要があります。</li> <li><strong>価格もやすい</strong>:当然ではありますが、マートステーションは高額機材のため、1円単位の原価低減が非常に重要です。仕様設計についても常に優先順位をつけて、ある程度の割り切りを行う必要があり、高度なバランス感覚が必要となります。</li> </ul> <h2 id="現在設置されているマートステーションと開発の歴史">現在設置されているマートステーションと開発の歴史</h2> <p>上記の考え方は最初から方針として打ち立てられていたものではなく、開発/量産/保守運用を行う過程やお客様からのフィードバックで得た気づきを、新たな開発や既存機への保守アップデートとして反映しながら身につけてきました。</p> <h3 id="本格量産を行えるマートステーションJCM-Mk1Mk2">本格量産を行えるマートステーション:JCM-Mk1/Mk2</h3> <p>当初マートステーションは市販のワインセラーを用いて製造していましたが、耐久性の問題もあり、業務用冷蔵ショーケースを用いて本格量産機と位置付けたのがJCM-Mk1とJCM-Mk2です <a href="#f-5981fe84" name="fn-5981fe84" title="弊社では冷蔵庫本体の製造メーカと開発世代を組み合わせて、形式名称を分類しています。ここではJCM社の冷蔵ショーケースとそれを用いた1世代/2世代目の形式のものをさすため、JCM-Mk1/Mk2という表記を行っています。">*2</a> 。少量生産を行いながら鍵の位置を変えたりするなど、カイゼンを行いながら生産を行い、量産ノウハウを蓄積しました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035437.jpg" width="897" height="877" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="耐結露性能の向上と安定性の向上を目指してJCM-Mk3Mk31">耐結露性能の向上と、安定性の向上を目指して:JCM-Mk3/Mk3.1</h2> <p>JCM-Mk1/Mk2は2つの問題を抱えていました。1つは冷蔵ショーケースをベースとして量産していたため、一部の環境下で夏場結露に悩まされたことです。そのため、ガラス面に断熱材を貼り付けて結露の発生を抑えたり、結露水を受けるトレイを設置したりする等、追加対処を行うことで対応をはかる必要がありました。<br/> 次期モデルJCM-Mk3では断熱性の高いLow-Eガラスを用いることで、結露耐性を引き上げることとしました。</p> <p>もう1つの問題はコントロール・ユニットの安定性が欠けていたことです。 マートステーションのメインコンピュータはRaspberry Piを採用していました。Raspberry Piはプロトタイプ検証や工場内の設備稼働状況の監視等、IoTの文脈で広く用いられています。しかしSoCのデータシートが一般公開されていなかったり等、遠隔地に大量に設置する機材に採用するには向いていない側面がありました。</p> <p>加えてLTEモジュールやQRコードリーダ、独自開発の基板等を重ねた複雑な機材構成になっていて、不具合発生時の要因の特定が難しい構成となっていました。当初は突然機材からの通信が途絶え、現場対応に向かうもその複雑さから何が原因で不具合が発生しているのか、不具合の切り分けができないことも多々ありました。 ときには運用を中断してマートステーションごと交換するといった多大なリソースを必要とし、毎日のように不具合に悩まされました。</p> <p>そのため、社内に構築した検証用機材で不具合の解析をしたり、現場投入前に改善策の品質をテストしたりしました。合わせて現場からのフィードバックをもとに、その1つ1つの事象に対して、保守チームとエンジニアが対処方法を議論し、機材構成のカイゼンや、ファームウェアの安定性を向上させるアップデートを行いました。</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/c/cookpadtech/20230207/20230207035105.png" width="1200" height="900" 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/c/cookpadtech/20230207/20230207035537.png" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>機材アップデートの例</figcaption></figure></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2022%2F02%2F24%2F110000" title="工学的知見と実際の観測データに基づいて物理世界にサービスを展開しています - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2022/02/24/110000">techlife.cookpad.com</a></cite></p> <p>マイナーチェンジモデルJCM-Mk3.1では上記のカイゼン結果を反映しつつ、当時起こりつつあった半導体や電子部品の不足も考慮したアップデートを考える必要がありました。最終的に内部構成の変更や、部品点数の削減、また調達しやすい部品を多く採用することで、機材の安定性を向上させ、原価を低減し、さらには調達リードタイムを減らすことができました。</p> <p>このような活動を通して、現在はJCM-Mk1からJCM-Mk3.1まで、運用上機材の差をほとんど意識せずとも運用が行えるまでに、高い安定性を獲得しています。 2年前と比較し、現在マートステーションは3倍ほどに増えていますが、保守チームの人数およびコストは増やすことなく運用を行っています。</p> <p><figure class="figure-image figure-image-fotolife" title="JCM-Mk3.1で開発したロゴ入りのオリジナル基板"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035617.png" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>JCM-Mk3.1で開発したロゴ入りのオリジナル基板</figcaption></figure></p> <h3 id="冷蔵庫制御システムを一新JCM-Mk4">冷蔵庫/制御システムを一新:JCM-Mk4</h3> <p>JCM-Mk3/Mk3.1で行った結露対策をさらに強化するため、ショーケースのドアを鉄板に変更し、ドアに結露対策のヒータを内蔵する等の更なる対策をほどこしたのが、JCM-Mk4です。 構造を大きく変えたため冷蔵庫本体は新しくPSE認証を取得しています。</p> <p>またRaspberry Piを産業用IoTゲートウェイである<a href="https://www.centurysys.co.jp/products/linuxserver/mas110.html">FutureNet MA-S110</a>に一新、構成部品をゼロベースで見直すことで、更なる安定性の向上を行うことができました。</p> <p>さらにコントロール・ユニットの点検用ドアを手前に引っ張り出せる仕様としました。従来はメンテナンス用に確保する必要があったヒンジドアの可動部のスペースを無くすことで、設置スペースのコンパクト化にも寄与することができたほか、最悪の状況ではユニットごと取り替えられるようになりました。</p> <p><figure class="figure-image figure-image-fotolife" title="新開発の引き出し式点検ドア"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035718.png" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>新開発の引き出し式点検ドア</figcaption></figure></p> <p>デザインも一新し、クックパッドマートのアピールポイントである「こだわりの新鮮な食材をおとどけする」ことを強調したクリエイティブとして、サービス認知向上に寄与するものとしています。 現在、このクリエイティブのトンマナはポスター・のぼり等販促品にも引き継がれています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035033.png" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="次世代ステーションHZK-Mk1">次世代ステーション:HZK-Mk1</h3> <p>フルモデルチェンジ相当のJCM-Mk4で、全てが解決したわけではありません。</p> <p>これまでのベース機は中国で生産を行っていたため、春節等で一斉に工場が止まってしまうと生産が止まってしまうほか、ある程度まとまったロットでそれなりのリードタイムを想定して見込み生産を行う必要があったため、過剰在庫を抱えるリスクがありました。</p> <p>また、冷蔵庫そのものの安定性をより向上させる必要もありました。 庫内が冷えないといった不具合が発生した際、交換を行うための金銭的コストや、対応期間中マートステーションとしての営業を中断する機会ロスが発生してしまいます。それはお客様のユーザ体験を悪化させる原因にもなります。</p> <p>そして、安定的な調達の観点から複数社より調達できるルートも検討する必要がありました。これを踏まえて業務用冷蔵庫の製造メーカに相談した結果、開発・量産の合意に至ったのがHZK-Mk1です。 本機は国内生産の業務用冷蔵庫をベースとし、冷蔵庫本機の信頼性の高さが最大の特徴です。実際に先行量産を行って設置を行いましたが、現在トラブル0でそれを実証しています。 また更なるコンパクト化を行い、設置ハードルをさらに下げることができています。 コントロール・ユニットについてもJCM-Mk4とほぼ同様のものを流用し、ファームウェアの保守管理コストを抑えました。</p> <p><figure class="figure-image figure-image-fotolife" title="HZK-Mk1(開発中の機材です)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230207/20230207035822.png" width="413" height="650" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>HZK-Mk1(開発中の機材です)</figcaption></figure></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fprtimes.jp%2Fmain%2Fhtml%2Frd%2Fp%2F000000297.000027849.html" title="生鮮食品EC「クックパッドマート」と業務用冷蔵庫 国内シェアトップクラスのホシザキ生鮮宅配ボックス「マートステーション」の新型機を共同開発" 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/000000297.000027849.html">prtimes.jp</a></cite></p> <h3 id="その他の試作開発">その他の試作開発</h3> <p>その他にも複数のメーカと試作開発を行い、さらなる開発の検討を行っています。その際もなるべく現状のコントロール・ユニットを用い、モデルが増加しても不用意に社内の管理コストを増やさないよう、配慮することを心がけています。</p> <h2 id="終わりに">終わりに</h2> <p>現在マートステーションの設置箇所は1,000を超えています。そういった状況でも、管理コスト・保守コストを設置拠点数の増加に比例させず、より安心・安全によりよいユーザ体験でお使いいただけるよう、エンジニア/デザイナ/保守/ハードウェアPdM一丸となって、頑張っています。</p> <p>もしご興味がある方がいらっしゃったら、是非こちらから採用情報をご確認ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> <div class="footnote"> <p class="footnote"><a href="#fn-b65ce044" name="f-b65ce044" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">なお、一部有料オプションで自宅配送サービスもあります。</span></p> <p class="footnote"><a href="#fn-5981fe84" name="f-5981fe84" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">弊社では冷蔵庫本体の製造メーカと開発世代を組み合わせて、形式名称を分類しています。ここではJCM社の冷蔵ショーケースとそれを用いた1世代/2世代目の形式のものをさすため、JCM-Mk1/Mk2という表記を行っています。</span></p> </div> cookpadtech 工事設計認証(技適)をとってみた hatenablog://entry/4207112889959870945 2023-02-03T15:22:12+09:00 2023-02-06T22:19:41+09:00 こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等をやっています。 クックパッドマートとハードウェア クックパッドマートは2018年9月20日にリリースされた生鮮食品のECプラットフォームです。リリースから4年以上経ち、新規事業ならではのスピードを維持しつつサービス拡大のため試行錯誤を日々続けています。 cookpad-mart.com クックパッドマートはiOSとAndroidの専用アプリで利用可能です。このアプリで商品を購入して、近所… <p>こんにちは、クックパッドの齋藤です。 私はハードウェアPdMとして、クックパッドマートで事業に関わるハードウェア(マートステーション、プリンタ、温度監視システム等)の企画開発・開発ディレクション・調達・保守等をやっています。</p> <h2 id="クックパッドマートとハードウェア">クックパッドマートとハードウェア</h2> <p>クックパッドマートは2018年9月20日にリリースされた生鮮食品のECプラットフォームです。リリースから4年以上経ち、新規事業ならではのスピードを維持しつつサービス拡大のため試行錯誤を日々続けています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad-mart.com%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://cookpad-mart.com/">cookpad-mart.com</a></cite></p> <p>クックパッドマートはiOSとAndroidの専用アプリで利用可能です。このアプリで商品を購入して、近所の受け取り場所(マートステーションと呼んでいます)で受け取れます。有料で自宅配送するオプションもあります。</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/N/Ness/20201221/20201221150649.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>時には自分たちでオリジナルの基板を開発・量産することもあります。</figcaption></figure></p> <p>そのため社内にはハードウェアチームがあり、ハードウェアエンジニアや組込エンジニア等、普通のWeb系企業にはいない、ユニークな人材がいます。</p> <p>今日はその中でも、海外のデバイスメーカーから工事設計認証(技適)を自分たちで取得したお話をしたいと思います。</p> <h2 id="なぜ技適を取るのか">なぜ技適を取るのか</h2> <p>今クックパッドマートではチルド食材の配送時、シッパーという断熱ボックスの中に食材と蓄冷剤を一緒に入れて、軽自動車のバンなどで運んでいます。</p> <p>バンの中にはいくつもシッパーが入っているのですが、食材の安全性を担保するため、シッパー内の温度が異常になっていないか監視を行っています。</p> <p>現在はGPSマルチユニットSORACOM Editionという機材を用いて温度監視を行っていますが、サービスローンチから時間が経って事業規模が大きくなってきたため、より低コストで温度監視ができる仕組みが必要となってきました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2021%2F04%2F20%2F110000" title="クックパッドマートの生鮮食品を SORACOM の IoT デバイスで遠隔温度監視している話 - クックパッド開発者ブログ" 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://techlife.cookpad.com/entry/2021/04/20/110000">techlife.cookpad.com</a></cite></p> <p>そのためより効率的に配送中のシッパーの温度を把握し、異常を検知したり、万一品質不具合が出た時のトレーサビリティを確保する仕組み “TemperatureRightHear”、通称「TempRa(テンプラ)」を開発しています。</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/N/Ness/20230203/20230203145315.png" width="960" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ざっくりとしたポンチ絵</figcaption></figure></p> <p>開発するものとしては、</p> <ol> <li>シッパーの中に入れ、温度センサーが取得する値をBLEで送信するビーコン</li> <li>バンのシガーソケットに刺し、1のデータを受信してLTE経由で送信する車載IoTゲートウェイ</li> <li>2のデータを集計分析し、蓄積したりアラートを促すバックエンドシステム</li> </ol> <p>の3つがありますが、現在マートでは配送バンを数多く運用しており、その時のシッパーは膨大な量になります。</p> <p>1で用いる市販のビーコンは、1個あたり9,000円程度してしまいます。配送時には数千ものシッパーを用いるため、普通に調達してしまうと数千万円もの高額出費を覚悟する必要があります。</p> <p>そこでまず、必要な個数を減らせないか考えました。実はこのビーコンは既にマートステーションでの温度監視に用いていたため、これを回収して転用することで新規調達の個数を減らす計画です。マートステーションの側ではより安価な有線の温度センサを利用するように変更します。余談ですがこの有線の温度センサも新規開発を行いました。</p> <p>しかしながらそれでも足りないため、今回直接中国の深圳にあるメーカーから現地のビーコンを直接購入し、調達価格を抑えることとしました。 その場合1個あたり15米ドル程度で調達することができるため、1個あたりの差額は7,000円程度安く調達することができます。そのため大幅に調達価格を抑えることができるのです。</p> <p>であればはじめからそうすればいいじゃん、となると思いきや、そうは問屋がおろしません。 日本で電波を発生する機器を使用する際は、「技術適合認証」(通称技適)を取得する必要があります。技適を取得していない機器で電波を発した場合、電波法違反という法令違反となります。</p> <p>詳細な説明はこのブログ <a href="https://www.musen-connect.co.jp/blog/course/other/japan-radio-law-basic/">https://www.musen-connect.co.jp/blog/course/other/japan-radio-law-basic/</a> がわかりやすいです。</p> <p>これを取得するのは結構面倒くさいですし、それなりに電気や電波に関するエンジニアリングの知見が必要なため、なかなか大変だったりします。</p> <p>しかしながらクックパッドマートには、強力なハードウェアエンジニアがいますし、私自身もマートステーションの設置をするときや将来的に物流倉庫等をIoT化するときに使えるかと思い「第一級陸上特殊無線技士」という電波についての資格も取っていました。そこで認証機関に相談したところ3-50万円で取得できることがわかり、将来的なクックパッドの知見にもなると思ったので、社内で相談した結果、量産するもの1つ1つに技適を適用するときに用いる「工事設計認証」を取得した上で調達を行うこととしました。</p> <h2 id="取得の流れ">取得の流れ</h2> <p>取得に当たっては、具体的に下のような流れですすめていきました。</p> <ol> <li><p>デバイスの調達元からデータシート、金額見積等を照会します。 見積はサンプル費用、正式調達時の数量における単価と総額の2つにわけてもらいます。予算上問題がないことを確認し、サンプルを調達しました。 <figure class="figure-image figure-image-fotolife" title="サンプルデバイス"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/N/Ness/20220824/20220824103029.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>サンプルデバイス</figcaption></figure></p></li> <li><p>サンプルデバイスについて『技適未取得機器を用いた実験等の特例制度』の届出をし、動作や私たちの要求仕様を満たしているか確認します。 制度の詳細は <a href="https://www.tele.soumu.go.jp/j/sys/others/exp-sp/">https://www.tele.soumu.go.jp/j/sys/others/exp-sp/</a> をご確認ください。</p></li> <li><p>認証機関に相談の上、申請書類を作成します。 認証期間はいくつかあるのですが、今回は老舗である「一般財団法人テレコムエンジニアリングセンター」(通称TELEC)に依頼しました。 書類作成は結構大変だったのですが、先方はとても親切で、細かい表現に至るまで細かくフィードバックいただけました。それだけ書類の「てにおは」含め細かく校正が必要で、このフィードバックがなければ書類作成ができませんでした。</p></li> <li><p>認証機関で試験を行います。 試験の際はサンプルデバイスとは別にテスト用のデバイスが必要です。このデバイスはスペクトルアナライザ等に接続するためRF出力ができる必要があり、また先方の安定化電源に繋げられる必要があります。このテストデバイスを用いて、入力電圧などを変えながら挙動を確認する試験を行っていただきました。</p></li> </ol> <h2 id="取得費用">取得費用</h2> <p>デバイスの仕様によって異なりますが、今回はBLEのみのデバイスということで、約30万円が費用としてかかりました。</p> <h2 id="申請書類">申請書類</h2> <p>主な申請書類は認証申込書・別紙資料、工事設計書、無線設備系統図、確認方法書、 部品配置図又は写真、外観図又は写真です。詳細は <a href="https://www.telec.or.jp/services/tech/offer.html">https://www.telec.or.jp/services/tech/offer.html</a> をご確認ください。 無線設備が1チップになっていたりしたときはどうするのか、その半導体の詳細構成が開示されてない場合はどうするのか、等細かい不明点が山のように出てきますし、製造元とのやりとりもいろいろ発生します。</p> <p>面白かったのは、部品が容易に変えられないことを説明するやり取りです。調達予定のデバイスは普通のプラスドライバーでケースが開いてしまうのでそこがいけないのかと思い、ネジが保護ゴムで覆われていて容易には開けられないということを書いたところ、「部品が表面実装部品で構成されている」と書けばよいとのことでした。この辺のニュアンスは、慣れていないと全く分からないですね……。</p> <h2 id="試験当日">試験当日</h2> <p>ここから書類の修正作業をしたりテストデバイスをメーカーから取り寄せたりに10営業日くらい使いました。申請書類がOKとなれば、試験当日です。 それまでにテストデバイスの制御用ソフトウェアの動作確認や、デバイスとの接続確認をした上で、試験機関を訪問してテストです。 テスト中デバイスが想定外の挙動をするなど、ヒヤヒヤする場面もありましたが、なんとかみんなのファインプレーで切り抜けることができました!</p> <h2 id="審査と認証書交付">審査と認証書交付</h2> <p>その後先方内で審査があり、終了後1週間程度で無事認証書が交付されました! やったね!</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/N/Ness/20230203/20230203144128.png" width="848" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>実際の認証書</figcaption></figure></p> <h2 id="正式調達">正式調達</h2> <p>実際に交付された認証番号をデバイスに記載した上で、ようやく正式調達です! せっかくなので、ロゴと弊社ミッション “Make everyday cooking fun!” も書いておいてもらいました!</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/N/Ness/20230203/20230203144406.png" width="1200" height="735" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>正式調達のビーコン</figcaption></figure></p> <h2 id="終わりに">終わりに</h2> <p>ということで、今回クックパッドではじめて、工事設計認証の取得を試みてみました。 今までマートのハードウェアチームはステーションの開発やプリンタの開発等、リアルワールドで仕事をする上で欠かせないハードウェアを開発・量産・保守してきました。 もちろん今回のデバイスはスクラッチ開発ではないですし、なんなら別メーカーにて日本に導入済みのデバイスです。 しかしながら今回取得した工事設計認証も、そういったハードウェアを扱えるチームがあってこそ、無事取得まで漕ぎ着けることができたのは間違いありませんし、 社内ブログ「Groupad」にて知見共有が積極的に行われていたので、そういったクックパッドならではの技術的知見で、はじめてのことでもチャレンジすることができました。 取引先との雑談でそういった話が出た時には、「そこまでやるんですか!」と言われることも多いです。</p> <p>このような形で、ハードウェアチームはユーザの皆様に安全・安心で高品質な食材をお届けするという、「あたりまえのことを」「あたりまえに」実現するために日々開発や保守をおこなっています。</p> <p>もしご興味がある方がいらっしゃったら、是非こちらから採用情報をご確認ください!</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.careers%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://cookpad.careers/">cookpad.careers</a></cite></p> Ness SwiftUIで画面内の各コンテンツの表示ログを送る hatenablog://entry/4207112889958827839 2023-01-31T15:00:22+09:00 2023-01-31T15:00:22+09:00 こんにちは、レシピサービス開発部の@miichan_ochaです。普段はiOS版クックパッドアプリの開発をしています。 クックパッドアプリでは開発した機能の評価を行うために、画面のPVログや画面内の各コンテンツの表示・タップログなどの様々な行動ログを送っています。 今回は、SwiftUIで新たに作った画面内の各コンテンツの表示ログを送る仕組み(ShowContentLog)についてご紹介します。この仕組みは昨年7月にリリースされたiOS版クックパッドアプリ「のせる」タブ開発時に作られたもので、現在約半年ほど運用しています。 ShowContentLogの仕組み ログの要件 レシピサービス開発部… <p>こんにちは、レシピサービス開発部の<a href="https://mobile.twitter.com/miichan_ocha">@miichan_ocha</a>です。普段はiOS版クックパッドアプリの開発をしています。</p> <p>クックパッドアプリでは開発した機能の評価を行うために、画面のPVログや画面内の各コンテンツの表示・タップログなどの様々な行動ログを送っています。</p> <p>今回は、SwiftUIで新たに作った画面内の各コンテンツの表示ログを送る仕組み(ShowContentLog)についてご紹介します。この仕組みは昨年7月にリリースされたiOS版クックパッドアプリ「のせる」タブ開発時に作られたもので、現在約半年ほど運用しています。</p> <p><figure class="figure-image figure-image-fotolife" title="ShowContentLogの仕組み"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/miichan_ocha_tech/20230130/20230130172833.png" alt="" width="1200" height="461" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ShowContentLogの仕組み</figcaption></figure></p> <h2 id="ログの要件">ログの要件</h2> <p>レシピサービス開発部では、iOS版クックパッドアプリの画面内の各コンテンツの表示ログを以下の要件で取っています。</p> <ol> <li>コンテンツが初めて画面に表示される時に、そのコンテンツの表示ログを送る</li> <li>画面のスクロールによって、コンテンツが一度画面外に出てから再度画面内に表示された時には、<strong>そのコンテンツの表示ログは送らない</strong></li> <li>一定時間経過後の画面自動更新やPull to Refreshによる画面更新を行った時は、更新後の画面に表示されているコンテンツの表示ログを送る</li> <li>別画面にプッシュ遷移した後、遷移先から戻ってきて画面が再度表示された時に、その画面に表示されているコンテンツの表示ログを送る</li> <li>タブの切り替えによって画面が再度表示された時に、その画面に表示されているコンテンツの表示ログを送る</li> <li>アプリがバックグラウンドからフォアグラウンドに復帰した時に、復帰時の画面に表示されているコンテンツの表示ログを送る</li> </ol> <p>各要件に対応するデモ動画</p> <table> <thead> <tr> <th style="text-align:center;"> 1 </th> <th style="text-align:center;"> 2 </th> <th style="text-align:center;"> 3 </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/m/miichan_ocha_tech/20230130/20230130173845.gif" alt="&#x8981;&#x4EF6;1&#x306E;&#x30C7;&#x30E2;&#x52D5;&#x753B;" width="1048" height="1200" 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/m/miichan_ocha_tech/20230130/20230130174018.gif" alt="&#x8981;&#x4EF6;2&#x306E;&#x30C7;&#x30E2;&#x52D5;&#x753B;" width="1048" height="1200" 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/m/miichan_ocha_tech/20230130/20230130174140.gif" alt="&#x8981;&#x4EF6;3&#x306E;&#x30C7;&#x30E2;&#x52D5;&#x753B;" width="1048" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> </tr> </tbody> </table> <table> <thead> <tr> <th style="text-align:center;"> 4 </th> <th style="text-align:center;"> 5 </th> <th style="text-align:center;"> 6 </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/m/miichan_ocha_tech/20230130/20230130174309.gif" alt="&#x8981;&#x4EF6;4&#x306E;&#x30C7;&#x30E2;&#x52D5;&#x753B;" width="1049" height="1200" 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/m/miichan_ocha_tech/20230130/20230130174413.gif" alt="&#x8981;&#x4EF6;5&#x306E;&#x30C7;&#x30E2;&#x52D5;&#x753B;" width="1048" height="1200" 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/m/miichan_ocha_tech/20230130/20230130174509.gif" alt="&#x8981;&#x4EF6;6&#x306E;&#x30C7;&#x30E2;&#x52D5;&#x753B;" width="1048" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> </td> </tr> </tbody> </table> <p>なお、ここでいう各コンテンツとは「表示・タップ回数を計測したいViewのまとまり」のことを指しており、ある画面では画面内のセクション単位であったり、別の画面ではバナー・カルーセルなどViewのコンポーネント単位であったりと、その粒度は画面によって様々です。</p> <p>UIKitで作られた画面では、ViewController内で表示された各コンテンツのIDを管理するSetをpropertyとして保持し、<code>UICollectionView</code>で作られた画面であれば<code>UICollectionViewDelegate</code>の<a href="https://developer.apple.com/documentation/uikit/uicollectionviewdelegate/1618087-collectionview">collectionView(_:willDisplay:forItemAt:)</a>をトリガーにログを送信することでこの要件を実現していました。</p> <h2 id="仕組みが必要になった背景">仕組みが必要になった背景</h2> <p>きっかけは前述の通り、レシピサービス開発部で「のせる」タブの画面をSwiftUIで作ったことです。</p> <p>「のせる」タブでは上記の仕様で各コンテンツの表示ログを送る要件があったため、SwiftUIの画面でも各コンテンツの表示ログを送る必要が出てきました。加えて、UIKitでは画面ごとに都度表示ログを送る実装をしていましたが、SwiftUIでは仕組み化して簡単に送れるようにしたいという動機から、今回の仕組みが生まれました。</p> <h2 id="動作する環境について">動作する環境について</h2> <p>今回の仕組みはiOS版クックパッドアプリ上で使用されており、以下の条件・環境で動作しています。</p> <ul> <li><p>iOS版クックパッドアプリで採用しているVIPERアーキテクチャに適合したまま、View層のみでSwiftUIを使っている</p> <ul> <li><a href="https://techlife.cookpad.com/entry/2021/01/18/kaimono-swift-ui">https://techlife.cookpad.com/entry/2021/01/18/kaimono-swift-ui</a> と同様の構成です</li> </ul> </li> <li><p>表示ログを送る各コンテンツは、<code>LazyVStack</code>や<code>List</code>など、遅延ロードを行うViewの中に配置されている必要がある</p> <ul> <li>各コンテンツが表示されたかどうかは onAppear/onDisappear で判定しているため</li> </ul> </li> <li><p>Markdownで書かれたログ定義から自動生成された行動ログ(以下「自動生成行動ログ」と呼びます)を送る前提で設計されている(追加で別のログを送ることも可能)</p> <ul> <li>この自動生成の仕組みについては <a href="https://techlife.cookpad.com/entry/2020/11/05/110000">https://techlife.cookpad.com/entry/2020/11/05/110000</a> に詳しい解説があります</li> </ul> </li> <li><p>iOS Deployment Targetが 14.0 の時代に開発された</p> <ul> <li>iOS13での動作は未検証となっています</li> </ul> </li> </ul> <h2 id="ShowContentLogの使い方">ShowContentLogの使い方</h2> <p>まずは、仕組みの使い方について簡単に説明します。</p> <p>*説明のためコードを簡略化しています。完成版のコードに関しては「完成版のコード」の章をご覧ください。</p> <p>最初に、<code>UIHostingController</code>を保持しているViewController内で<code>ShowContentLogController</code>というクラスのインスタンスを作成します。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ViewController</span> <span class="synStatement">private</span> <span class="synStatement">lazy</span> <span class="synPreProc">var</span> <span class="synIdentifier">showContentLogController</span> <span class="synIdentifier">=</span> ShowContentLogController(screenViewController<span class="synSpecial">:</span> <span class="synType">self</span>) </pre> <p>次に、表示ログを送る各コンテンツを内包しているView(以下「大元のView」と呼びます)に <code>ShowContentLogRootModifier</code>というViewModiferを付与します。これにより、このViewModiferを付与したViewが表示されている時にだけ、各コンテンツのログが送られるようになります。</p> <p>シンプルな画面では<code>UIHostingController</code>の引数に渡す<code>rootView</code>のViewに<code>ShowContentLogRootModifier</code>を付与すれば良いですが、タブがある画面においては、各タブの中身のViewごとに付与します(各タブで表示状態が異なるため)。</p> <p><code>ShowContentLogRootModifier</code>の引数<code>controller</code>には、先程作成した<code>ShowContentLogController</code>のインスタンスを渡します。各タブの中身のViewごとに<code>ShowContentLogRootModifier</code>を付与した場合は、タブの数だけ<code>ShowContentLogController</code>のインスタンスを作成し、タブごとに別々のインスタンスを渡してください。</p> <p>*このViewModiferに対応する<code>showContentLogRoot</code>というメソッドをSwiftUI.Viewにextensionとして定義しています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ViewController</span> <span class="synStatement">override</span> <span class="synPreProc">func</span> <span class="synIdentifier">viewDidLoad</span>() { <span class="synIdentifier">super</span>.viewDidLoad() <span class="synPreProc">let</span> <span class="synIdentifier">rootView</span> <span class="synIdentifier">=</span> HogeView(delegate<span class="synSpecial">:</span> <span class="synType">self</span>, dataSource<span class="synSpecial">:</span> <span class="synType">dataSource</span>) .showContentLogRoot(controller<span class="synSpecial">:</span> <span class="synType">showContentLogController</span>) <span class="synPreProc">let</span> <span class="synIdentifier">hostingVC</span> <span class="synIdentifier">=</span> UIHostingController(rootView<span class="synSpecial">:</span> <span class="synType">rootView</span>) <span class="synIdentifier">...</span> } </pre> <p>最後に、表示ログを送りたい各コンテンツのViewそれぞれに<code>PostShowContentLogModifier</code>というViewModiferを付与します。引数<code>event</code>には<code>LogCategory</code> protocolに準拠したログイベントを渡します(「自動生成行動ログ」の全てのログイベントは<code>LogCategory</code>に準拠しています)。</p> <pre class="code lang-swift" data-lang="swift" data-unlink>RecipeView() .postShowContentLog(SampleCategory.showRecipe(recipeId<span class="synSpecial">:</span> <span class="synType">recipe.id</span>)) </pre> <p>以上で、ログの要件通りに各コンテンツの表示ログを送ることができるようになります。</p> <h2 id="ShowContentLogの設計">ShowContentLogの設計</h2> <p>次に、このShowContentLogの設計・内部実装について詳しく見ていきます。</p> <p>*ここでもコードは適宜簡略化しています。簡略化されていないものは「完成版のコード」の章をご覧ください。</p> <h3 id="ShowContentLogController">ShowContentLogController</h3> <p>ShowContentLogControllerは、表示ログを送る「大元のView」の表示状態を、子Viewである各コンテンツのViewに通知する役割を担うクラスです。<code>isRootViewAppearing</code>というPublisherを持ち、各コンテンツのViewはこれを監視することで「大元のView」の表示状態を知ることができます<a href="#f-2d496cf9" name="fn-2d496cf9" title="isRootViewAppearingを最初computed propertyにしていたのですが、それだとSwiftUIのViewのbodyが再実行される度にonReceiveで毎回新たなPublisherのインスタンスを購読するという挙動になりremoveDuplicatesが効かなくなってしまうので、lazy varで宣言しています。">*1</a>。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synType">@MainActor</span> <span class="synType">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ShowContentLogController</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">isRootViewAppearingSubject</span> <span class="synIdentifier">=</span> CurrentValueSubject<span class="synIdentifier">&lt;</span>Bool, Never<span class="synIdentifier">&gt;</span>(<span class="synConstant">false</span>) <span class="synStatement">lazy</span> <span class="synPreProc">var</span> <span class="synIdentifier">isRootViewAppearing</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Bool</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">&gt;</span> <span class="synIdentifier">=</span> isRootViewAppearingSubject .removeDuplicates() .eraseToAnyPublisher() <span class="synPreProc">func</span> <span class="synIdentifier">setIsRootViewAppearing</span>(_ appearing<span class="synSpecial">:</span> <span class="synType">Bool</span>) { isRootViewAppearingSubject.send(appearing) } } </pre> <h3 id="ShowContentLogRootModifier">ShowContentLogRootModifier</h3> <p>ShowContentLogRootModifierは、表示ログを送る「大元のView」に付与するViewModifierです。</p> <p>このViewModifierは、</p> <ul> <li>ShowContentLogControllerのインスタンスをEnvironmentValuesに設定する</li> <li>ViewModiferが付けられた「大元のView」の表示状態をShowContentLogControllerに伝える</li> </ul> <p>という2つの役割を担っています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">ShowContentLogRootModifier</span><span class="synSpecial">:</span> <span class="synType">ViewModifier</span> { <span class="synPreProc">let</span> <span class="synIdentifier">controller</span><span class="synSpecial">:</span> <span class="synType">ShowContentLogController</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isAppearing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">func</span> <span class="synIdentifier">body</span>(content<span class="synSpecial">:</span> <span class="synType">Content</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { content .onAppear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> } .onDisappear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> } .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.willEnterForegroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> didEnterBackground { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">false</span> } } .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.didEnterBackgroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> isAppearing { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">true</span> } } .onChange(of<span class="synSpecial">:</span> <span class="synType">isAppearing</span>) { appearing <span class="synStatement">in</span> controller.setIsRootViewAppearing(appearing) <span class="synComment">// 表示状態を ShowContentLogController のインスタンスに伝える</span> } .environment(\.showContentLogController, controller) <span class="synComment">// EnvironmentValues に設定する</span> } </pre> <h3 id="PostShowContentLogModifier">PostShowContentLogModifier</h3> <p>PostShowContentLogModifierは、表示ログを送りたい各コンテンツのViewに付与するViewModifierです。 このViewModifierは、ShowContentLogControllerの<code>isRootViewAppearing</code> Publisherを監視しつつ、適切なタイミングで表示ログを送る役割を担っています。</p> <p>また、ViewModifierが付与されたViewが現在表示されているか(<code>isAppearing</code>)、既に表示ログを送ったか(<code>didPostLog</code>)の状態をStateとして保持していて、監視している<code>isRootViewAppearing</code>がfalseになった時(「大元のView」が非表示になった時)に、既に表示ログを送ったか(<code>didPostLog</code>)の状態をリセットしています。</p> <p>* <code>ShowContentLogControllerRequired</code>(と<code>AppEnvironmentRequired</code>)については「実用段階にするまでに用意した仕組み」の章で詳しく説明します。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">PostShowContentLogModifier</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Category</span><span class="synSpecial">: </span><span class="synType">LogCategory</span><span class="synSpecial">&gt;:</span> <span class="synType">ViewModifier</span> { <span class="synPreProc">let</span> <span class="synIdentifier">event</span><span class="synSpecial">:</span> <span class="synType">Category</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isRootViewAppearing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isAppearing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">didPostLog</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">func</span> <span class="synIdentifier">body</span>(content<span class="synSpecial">:</span> <span class="synType">Content</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { AppEnvironmentRequired { appEnvironment <span class="synStatement">in</span> ShowContentLogControllerRequired { showContentLogController <span class="synStatement">in</span> content .onAppear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> <span class="synStatement">if</span> isRootViewAppearing <span class="synIdentifier">&amp;&amp;</span> <span class="synIdentifier">!</span>didPostLog { postLog(appEnvironment) } } .onDisappear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> } .onReceive(showContentLogController.isRootViewAppearing) { rootAppearing <span class="synStatement">in</span> <span class="synStatement">if</span> rootAppearing { isRootViewAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> <span class="synStatement">if</span> isAppearing <span class="synIdentifier">&amp;&amp;</span> <span class="synIdentifier">!</span>didPostLog { postLog(appEnvironment) } } <span class="synStatement">else</span> { isRootViewAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> didPostLog <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synComment">// didPostLog の状態をリセット</span> } } } } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">postLog</span>(_ appEnvironment<span class="synSpecial">:</span> <span class="synType">any</span> AppEnvironment) { appEnvironment.activityLogger.post(event) didPostLog <span class="synIdentifier">=</span> <span class="synConstant">true</span> } } </pre> <h2 id="設計時に検討したこと">設計時に検討したこと</h2> <h3 id="ログを送ったかどうかの管理をどこで行うか">ログを送ったかどうかの管理をどこで行うか</h3> <p>UIKit時代は、表示したコンテンツのIDをViewControllerのSetで管理し、親Viewが中央集権的に各コンテンツのログ送信フラグの管理を行っていました。しかし、SwiftUIでは全てのViewがIdentityを持っており、ログを送るView自身がStateでログを送ったかどうかを管理する方がSwiftUI的に自然だと考えました(加えて子Viewから親Viewに自身の Identifierを伝えて管理させるやりとりも減らすことができて、実装もシンプルになります)。そのためShowContentLogでは、<code>PostShowContentLogModifier</code>を付与したView自身がログ送信フラグの管理を行う設計となっています。</p> <h2 id="実用段階にするまでに用意した仕組み">実用段階にするまでに用意した仕組み</h2> <h3 id="ShowContentLogController-1">ShowContentLogController</h3> <h4 id="isPresented"><code>isPresented</code></h4> <p>対応するログの要件</p> <blockquote><ol> <li>アプリがバックグラウンドからフォアグラウンドに復帰した時に、復帰時の画面に表示されているコンテンツの表示ログを送る</li> </ol> </blockquote> <p>開発中に気付いたのですが、ShowContentLogを使っている画面上でモーダルを表示し、そのモーダルが表示されている状態でバックグラウンド→フォアグラウンド復帰した時に表示ログが送られていました。原因は、モーダルが表示されている状態でバックグラウンド→フォアグラウンド復帰した時にShowContentLogRootModifierの<code>willEnterForegroundNotification</code>・<code>didEnterBackgroundNotification</code>が発火していたことでした。</p> <p>iOS版クックパッドアプリでは、モーダル表示を含めた画面遷移はUIKitで行われているため、ShowContentLogControllerの初期化時にSwiftUIのViewを表示しているViewControllerを渡して、モーダルを表示しているかどうかを取得するクロージャ<code>isPresented: () -&gt; Bool</code>を保持することにしました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ShowContentLogController</span> { <span class="synIdentifier">...</span> <span class="synPreProc">let</span> <span class="synIdentifier">isPresented</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> <span class="synIdentifier">init</span>(screenViewController<span class="synSpecial">:</span> <span class="synType">UIViewController</span>) { isPresented <span class="synIdentifier">=</span> { [<span class="synStatement">weak</span> screenViewController] <span class="synStatement">in</span> screenViewController?.presentedViewController <span class="synIdentifier">!=</span> <span class="synConstant">nil</span> } } } </pre> <p>これをShowContentLogRootModifier内で<code>didEnterBackgroundNotification</code>の通知を受け取った時に参照することで、モーダルが表示されている状態でバックグラウンド→フォアグラウンド復帰した時には表示ログを送らないようにしています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ShowContentLogRootModifier</span> .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.willEnterForegroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> didEnterBackground { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">false</span> } } .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.didEnterBackgroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synComment">// モーダルが表示されていない時だけ処理を行う</span> <span class="synStatement">if</span> isAppearing <span class="synIdentifier">&amp;&amp;</span> <span class="synIdentifier">!</span>controller.isPresented() { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">true</span> } } </pre> <h4 id="willRefreshOnForeground"><code>willRefreshOnForeground</code></h4> <p>対応するログの要件</p> <blockquote><ol> <li>一定時間経過後の画面自動更新やPull to Refreshによる画面更新を行った時は、更新後の画面に表示されているコンテンツの表示ログを送る</li> <li>アプリがバックグラウンドからフォアグラウンドに復帰した時に、復帰時の画面に表示されているコンテンツの表示ログを送る</li> </ol> </blockquote> <p>iOS版クックパッドアプリの一部の画面では、一定時間経過後に再びタブ切り替えで戻ってきたりバックグラウンド→フォアグラウンド復帰したりすると、画面の自動更新が行われます(自動更新の判定はViewControllerで行われています)。この時<code>UIApplication.willEnterForegroundNotification</code>が送られるタイミングが自動更新が走るタイミングより早いので、バックグラウンド→フォアグラウンド復帰時に自動更新が走る場合は、更新前後で古いコンテンツの表示ログと更新後のコンテンツの表示ログが2回送られてしまっていました。</p> <p>この場合は更新後の表示ログのみを送りたいので、ShowContentLogControllerに画面の更新が予定されているかどうかを取得する<code>willRefreshOnForeground: () -&gt; Bool</code>というクロージャを保持し、バックグラウンド→フォアグラウンド復帰時に画面の更新が予定されている場合は更新が終わるまで<code>isAppearing</code>の変更を待つようにしました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ShowContentLogController</span> { <span class="synIdentifier">...</span> <span class="synPreProc">let</span> <span class="synIdentifier">willRefreshOnForeground</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> <span class="synIdentifier">init</span>(screenViewController<span class="synSpecial">:</span> <span class="synType">UIViewController</span>, willRefreshOnForeground<span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> { <span class="synConstant">false</span> }) { isPresented <span class="synIdentifier">=</span> <span class="synIdentifier">...</span> <span class="synIdentifier">self</span>.willRefreshOnForeground <span class="synIdentifier">=</span> willRefreshOnForeground } } </pre> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ShowContentLogRootModifier</span> .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.willEnterForegroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> didEnterBackground { <span class="synComment">// 画面をリフレッシュする場合はリフレッシュを待ってから isAppearing を true にする</span> <span class="synStatement">if</span> <span class="synIdentifier">!</span>controller.willRefreshOnForeground() { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> } didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">false</span> } } } </pre> <h3 id="ShowContentLogRootModifier-1">ShowContentLogRootModifier</h3> <h4 id="forceDisappear"><code>forceDisappear</code></h4> <p>対応するログの要件</p> <blockquote><ol> <li>タブの切り替えによって画面が再度表示された時に、その画面に表示されているコンテンツの表示ログを送る</li> </ol> </blockquote> <p><code>forceDisappear</code>をtrueにすると<code>isAppearing</code>の値を常にfalseにして<code>ShowContentLogController</code>に伝えることができます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink>TabContentView(<span class="synIdentifier">...</span>) .showContentLogRoot( controller<span class="synSpecial">:</span> <span class="synType">tabState.showContentLogController</span>, forceDisappear<span class="synSpecial">:</span> <span class="synType">selection</span> <span class="synIdentifier">!=</span> tabState.tabType <span class="synComment">// 違うタブが選択されている時は `isAppearing` の値を常に false にする</span> ) </pre> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ShowContentLogRootModifier</span> .onChange(of<span class="synSpecial">:</span> <span class="synType">forceDisappear</span>) { newForceDisappear <span class="synStatement">in</span> controller.setIsRootViewAppearing(<span class="synIdentifier">!</span>newForceDisappear <span class="synIdentifier">&amp;&amp;</span> isAppearing) } .onChange(of<span class="synSpecial">:</span> <span class="synType">isAppearing</span>) { appearing <span class="synStatement">in</span> controller.setIsRootViewAppearing(<span class="synIdentifier">!</span>forceDisappear <span class="synIdentifier">&amp;&amp;</span> appearing) } </pre> <p>元々はiOS14の<code>TabView</code>で、選択されていないタブのonAppearが呼ばれることがあり、それを回避するために生まれました。それ以外にも、OSバージョン問わずタブの選択が完全に切り替わっていない時(スワイプで隣のタブが少しだけ見えている状態)にも隣のタブのonAppearが呼ばれていたので、iOS14のサポートを終了してからもこの指定は続けています。</p> <p>ちなみに<code>setIsRootViewAppearing</code>自体をスキップしてしまうと、各種イベントの発火タイミングによっては本来送られるべきログが送られなくなってしまう可能性があるため、このように値を上書きして<code>setIsRootViewAppearing</code>を呼ぶ方法を取っています。</p> <h4 id="isRefreshing"><code>isRefreshing</code></h4> <p>対応するログの要件</p> <blockquote><ol> <li>一定時間経過後の画面自動更新やPull to Refreshによる画面更新を行った時は、更新後の画面に表示されているコンテンツの表示ログを送る</li> </ol> </blockquote> <p>更新時に<code>didPostLog</code>(ログを送ったかどうか)をリセットするために用意されているpropertyです。</p> <p>現在レシピサービス開発部でSwiftUIで新規画面を開発する時は、下記の<code>ScreenState</code>のようなものを使って画面の状態管理をしているので、このpropertyを使う必要はありません(画面更新時は<code>ScreenState</code>が<code>loading</code>→<code>loaded</code>となり<code>loaded</code>に対応するViewが再生成されるため)。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">enum</span> <span class="synIdentifier">ScreenState</span><span class="synSpecial">&lt;</span><span class="synIdentifier">T</span><span class="synSpecial">, </span><span class="synIdentifier">E</span><span class="synSpecial">: </span><span class="synType">Error</span><span class="synSpecial">&gt;</span> { <span class="synStatement">case</span> initial <span class="synStatement">case</span> loading(T?) <span class="synStatement">case</span> loaded(T) <span class="synStatement">case</span> error(E) } </pre> <p>例えば画面の表示切り替えを<code>ZStack</code>内のViewのopacity変更で行っていて、画面更新時にViewが再生成されない場合にこのpropertyを使って<code>didPostLog</code>をリセットすることができます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// ShowContentLogRootModifier</span> <span class="synPreProc">var</span> <span class="synIdentifier">isRefreshing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">...</span> .onChange(of<span class="synSpecial">:</span> <span class="synType">isRefreshing</span>) { refreshing <span class="synStatement">in</span> isAppearing <span class="synIdentifier">=</span> <span class="synIdentifier">!</span>refreshing } </pre> <h3 id="PostShowContentLogModifier-1">PostShowContentLogModifier</h3> <h4 id="ShowContentLogControllerRequired"><code>ShowContentLogControllerRequired</code></h4> <p><code>ShowContentLogControllerRequired</code>は、<code>PostShowContentLogModifier</code>を付けたViewよりも上の階層でShowContentLogControllerのインスタンスがEnvironmentValuesに設定されていない(つまり<code>ShowContentLogRootModifier</code>を付け忘れている)時にassertionFailureを起こすためのViewです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synPreProc">struct</span> <span class="synIdentifier">ShowContentLogControllerRequired</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Content</span><span class="synSpecial">: </span><span class="synType">View</span><span class="synSpecial">&gt;:</span> <span class="synType">View</span> { <span class="synType">@Environment</span><span class="synSpecial">(</span><span class="synType">\.showContentLogController</span><span class="synSpecial">)</span> <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">showContentLogController</span><span class="synSpecial">:</span> <span class="synType">ShowContentLogController?</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">content</span><span class="synSpecial">: (</span><span class="synType">ShowContentLogController</span><span class="synSpecial">)</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span> <span class="synIdentifier">init</span>(<span class="synType">@ViewBuilder</span> <span class="synType">content</span><span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> (</span><span class="synType">_: ShowContentLogController</span><span class="synSpecial">)</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span>) { <span class="synIdentifier">self</span>.content <span class="synIdentifier">=</span> content } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">noEnvironment</span>() <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { assertionFailure(<span class="synConstant">&quot;You must pass the showContentLogController from a parent or ancestor view. If you use postShowContentLog modifier, add showContentLogRoot modifier to a parent or ancestor view.&quot;</span>) <span class="synStatement">return</span> EmptyView() } <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">showContentLogController</span> <span class="synIdentifier">=</span> showContentLogController { content(showContentLogController) } <span class="synStatement">else</span> { noEnvironment() } } } </pre> <p><code>AppEnvironmentRequired</code>も同じような実装となっています(むしろ<code>ShowContentLogControllerRequired</code>が先に実装された<code>AppEnvironmentRequired</code>の実装を参考にしています)。</p> <p><code>AppEnvironmentRequired</code>がクロージャの引数に渡している<code>appEnvironment</code>は、iOS版クックパッド上で用意されている依存関係を取り出すためのDIコンテナで、PostShowContentLogModifier内では<code>appEnvironment</code>を用いて行動ログを送るための依存にアクセスしています。</p> <p><code>appEnvironment</code>については下記に詳しい説明があります(記事内では<code>Environment</code>と呼ばれています)。 <a href="https://techlife.cookpad.com/entry/2021/06/16/110000">https://techlife.cookpad.com/entry/2021/06/16/110000</a></p> <h4 id="onPostLog"><code>onPostLog</code></h4> <p><code>onPostLog</code> は「自動生成行動ログ」が送られるタイミングで呼び出されるクロージャで、「自動生成行動ログ」とは別のログを追加で送るためのものです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// PostShowContentLogModifier</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">onPostLog</span><span class="synSpecial">: ((</span><span class="synType">any AppEnvironment</span><span class="synSpecial">)</span><span class="synType"> -&gt; Void</span><span class="synSpecial">)</span>? <span class="synIdentifier">...</span> <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">postLog</span>(_ appEnvironment<span class="synSpecial">:</span> <span class="synType">any</span> AppEnvironment) { appEnvironment.activityLogger.post(event) onPostLog?(appEnvironment) didPostLog <span class="synIdentifier">=</span> <span class="synConstant">true</span> } </pre> <h2 id="完成版のコード">完成版のコード</h2> <p>以下が、「実用段階にするまでに用意した仕組み」を踏まえた完成版のコードです。</p> <p>*動作確認時の環境: Xcode 14.1、iOS Deployment Target 15.0</p> <h3 id="ShowContentLogController-2">ShowContentLogController</h3> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> Combine <span class="synPreProc">import</span> SwiftUI <span class="synPreProc">import</span> UIKit <span class="synType">@MainActor</span> <span class="synType">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ShowContentLogController</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">isRootViewAppearingSubject</span> <span class="synIdentifier">=</span> CurrentValueSubject<span class="synIdentifier">&lt;</span>Bool, Never<span class="synIdentifier">&gt;</span>(<span class="synConstant">false</span>) <span class="synStatement">lazy</span> <span class="synPreProc">var</span> <span class="synIdentifier">isRootViewAppearing</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Bool</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">&gt;</span> <span class="synIdentifier">=</span> isRootViewAppearingSubject .removeDuplicates() .eraseToAnyPublisher() <span class="synPreProc">let</span> <span class="synIdentifier">isPresented</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> <span class="synPreProc">let</span> <span class="synIdentifier">willRefreshOnForeground</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> <span class="synIdentifier">init</span>(screenViewController<span class="synSpecial">:</span> <span class="synType">UIViewController</span>, willRefreshOnForeground<span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> { <span class="synConstant">false</span> }) { isPresented <span class="synIdentifier">=</span> { [<span class="synStatement">weak</span> screenViewController] <span class="synStatement">in</span> screenViewController?.presentedViewController <span class="synIdentifier">!=</span> <span class="synConstant">nil</span> } <span class="synIdentifier">self</span>.willRefreshOnForeground <span class="synIdentifier">=</span> willRefreshOnForeground } <span class="synPreProc">func</span> <span class="synIdentifier">setIsRootViewAppearing</span>(_ appearing<span class="synSpecial">:</span> <span class="synType">Bool</span>) { isRootViewAppearingSubject.send(appearing) } } <span class="synStatement">private</span> <span class="synPreProc">struct</span> <span class="synIdentifier">ShowContentLogControllerKey</span><span class="synSpecial">:</span> <span class="synType">EnvironmentKey</span> { <span class="synStatement">static</span> <span class="synPreProc">let</span> <span class="synIdentifier">defaultValue</span><span class="synSpecial">:</span> <span class="synType">ShowContentLogController?</span> <span class="synIdentifier">=</span> <span class="synConstant">nil</span> } <span class="synPreProc">extension</span> <span class="synIdentifier">EnvironmentValues</span> { <span class="synPreProc">var</span> <span class="synIdentifier">showContentLogController</span><span class="synSpecial">:</span> <span class="synType">ShowContentLogController?</span> { <span class="synStatement">get</span> { <span class="synIdentifier">self</span>[ShowContentLogControllerKey.<span class="synIdentifier">self</span>] } <span class="synStatement">set</span> { <span class="synIdentifier">self</span>[ShowContentLogControllerKey.<span class="synIdentifier">self</span>] <span class="synIdentifier">=</span> newValue } } } </pre> <h3 id="ShowContentLogRootModifier-2">ShowContentLogRootModifier</h3> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synStatement">private</span> <span class="synPreProc">struct</span> <span class="synIdentifier">ShowContentLogRootModifier</span><span class="synSpecial">:</span> <span class="synType">ViewModifier</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">controller</span><span class="synSpecial">:</span> <span class="synType">ShowContentLogController</span> <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isRefreshing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synStatement">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">forceDisappear</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isAppearing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">didEnterBackground</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synIdentifier">init</span>(controller<span class="synSpecial">:</span> <span class="synType">ShowContentLogController</span>, isRefreshing<span class="synSpecial">:</span> <span class="synType">Bool</span>, forceDisappear<span class="synSpecial">:</span> <span class="synType">Bool</span>) { <span class="synIdentifier">self</span>.controller <span class="synIdentifier">=</span> controller <span class="synIdentifier">self</span>.isRefreshing <span class="synIdentifier">=</span> isRefreshing <span class="synIdentifier">self</span>.forceDisappear <span class="synIdentifier">=</span> forceDisappear } <span class="synPreProc">func</span> <span class="synIdentifier">body</span>(content<span class="synSpecial">:</span> <span class="synType">Content</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { content .onAppear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> } .onDisappear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> } .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.willEnterForegroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> didEnterBackground { <span class="synComment">// 画面をリフレッシュする場合はリフレッシュを待ってから isAppearing を true にする</span> <span class="synStatement">if</span> <span class="synIdentifier">!</span>controller.willRefreshOnForeground() { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> } didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">false</span> } } .onReceive(NotificationCenter.<span class="synStatement">default</span>.publisher(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">UIApplication.didEnterBackgroundNotification</span>)) { _ <span class="synStatement">in</span> <span class="synStatement">if</span> isAppearing <span class="synIdentifier">&amp;&amp;</span> <span class="synIdentifier">!</span>controller.isPresented() { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> didEnterBackground <span class="synIdentifier">=</span> <span class="synConstant">true</span> } } .onChange(of<span class="synSpecial">:</span> <span class="synType">isRefreshing</span>) { refreshing <span class="synStatement">in</span> isAppearing <span class="synIdentifier">=</span> <span class="synIdentifier">!</span>refreshing } .onChange(of<span class="synSpecial">:</span> <span class="synType">forceDisappear</span>) { newForceDisappear <span class="synStatement">in</span> controller.setIsRootViewAppearing(<span class="synIdentifier">!</span>newForceDisappear <span class="synIdentifier">&amp;&amp;</span> isAppearing) } .onChange(of<span class="synSpecial">:</span> <span class="synType">isAppearing</span>) { appearing <span class="synStatement">in</span> controller.setIsRootViewAppearing(<span class="synIdentifier">!</span>forceDisappear <span class="synIdentifier">&amp;&amp;</span> appearing) } .environment(\.showContentLogController, controller) } } <span class="synPreProc">extension</span> <span class="synIdentifier">View</span> { <span class="synPreProc">func</span> <span class="synIdentifier">showContentLogRoot</span>(controller<span class="synSpecial">:</span> <span class="synType">ShowContentLogController</span>, isRefreshing<span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span>, forceDisappear<span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { modifier(ShowContentLogRootModifier(controller<span class="synSpecial">:</span> <span class="synType">controller</span>, isRefreshing<span class="synSpecial">:</span> <span class="synType">isRefreshing</span>, forceDisappear<span class="synSpecial">:</span> <span class="synType">forceDisappear</span>)) } } </pre> <h3 id="PostShowContentLogModifier-2">PostShowContentLogModifier</h3> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synStatement">private</span> <span class="synPreProc">struct</span> <span class="synIdentifier">PostShowContentLogModifier</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Category</span><span class="synSpecial">: </span><span class="synType">LogCategory</span><span class="synSpecial">&gt;:</span> <span class="synType">ViewModifier</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">event</span><span class="synSpecial">:</span> <span class="synType">Category</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">onPostLog</span><span class="synSpecial">: ((</span><span class="synType">any AppEnvironment</span><span class="synSpecial">)</span><span class="synType"> -&gt; Void</span><span class="synSpecial">)</span>? <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isRootViewAppearing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isAppearing</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">didPostLog</span><span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synIdentifier">init</span>(event<span class="synSpecial">:</span> <span class="synType">Category</span>, onPostLog<span class="synSpecial">: ((</span><span class="synType">any AppEnvironment</span><span class="synSpecial">)</span><span class="synType"> -&gt; Void</span><span class="synSpecial">)</span>?) { <span class="synIdentifier">self</span>.event <span class="synIdentifier">=</span> event <span class="synIdentifier">self</span>.onPostLog <span class="synIdentifier">=</span> onPostLog } <span class="synPreProc">func</span> <span class="synIdentifier">body</span>(content<span class="synSpecial">:</span> <span class="synType">Content</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { AppEnvironmentRequired { appEnvironment <span class="synStatement">in</span> ShowContentLogControllerRequired { showContentLogController <span class="synStatement">in</span> content .onAppear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> <span class="synStatement">if</span> isRootViewAppearing <span class="synIdentifier">&amp;&amp;</span> <span class="synIdentifier">!</span>didPostLog { postLog(appEnvironment) } } .onDisappear { isAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> } .onReceive(showContentLogController.isRootViewAppearing) { rootAppearing <span class="synStatement">in</span> <span class="synStatement">if</span> rootAppearing { isRootViewAppearing <span class="synIdentifier">=</span> <span class="synConstant">true</span> <span class="synStatement">if</span> isAppearing <span class="synIdentifier">&amp;&amp;</span> <span class="synIdentifier">!</span>didPostLog { postLog(appEnvironment) } } <span class="synStatement">else</span> { isRootViewAppearing <span class="synIdentifier">=</span> <span class="synConstant">false</span> didPostLog <span class="synIdentifier">=</span> <span class="synConstant">false</span> } } } } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">postLog</span>(_ appEnvironment<span class="synSpecial">:</span> <span class="synType">any</span> AppEnvironment) { appEnvironment.activityLogger.post(event) onPostLog?(appEnvironment) didPostLog <span class="synIdentifier">=</span> <span class="synConstant">true</span> } } <span class="synPreProc">extension</span> <span class="synIdentifier">View</span> { <span class="synPreProc">func</span> <span class="synIdentifier">postShowContentLog</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Category</span><span class="synSpecial">: </span><span class="synType">LogCategory</span><span class="synSpecial">&gt;</span>(_ event<span class="synSpecial">:</span> <span class="synType">Category</span>, onPostLog<span class="synSpecial">: ((</span><span class="synType">any AppEnvironment</span><span class="synSpecial">)</span><span class="synType"> -&gt; Void</span><span class="synSpecial">)</span>? <span class="synIdentifier">=</span> <span class="synConstant">nil</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { modifier(PostShowContentLogModifier(event<span class="synSpecial">:</span> <span class="synType">event</span>, onPostLog<span class="synSpecial">:</span> <span class="synType">onPostLog</span>)) } } </pre> <h2 id="まとめ">まとめ</h2> <p>今回ご紹介したShowContentLogによって、SwiftUIの画面でもUIKit同様に各コンテンツの表示ログを送ることができるようになりました。また、UIKit時代は画面を作る度に一から表示ログの実装が必要だったのですが、仕組みを作ったことでSwiftUIでは表示ログを簡単に送ることができるようにもなりました。</p> <p>仕組みを作る中で様々な意見・指摘をくれたチームの同僚に感謝します。この記事が、SwiftUIを使った画面で行動ログを送る際の参考に少しでもなれば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-2d496cf9" name="f-2d496cf9" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">isRootViewAppearingを最初computed propertyにしていたのですが、それだとSwiftUIのViewのbodyが再実行される度にonReceiveで毎回新たなPublisherのインスタンスを購読するという挙動になりremoveDuplicatesが効かなくなってしまうので、lazy varで宣言しています。</span></p> </div> miichan_ocha_tech 【後編】企業所属のRubyコミッター対談! 〜Ruby開発の裏話と今後の取り組み〜 hatenablog://entry/4207112889957094129 2023-01-27T12:00:49+09:00 2023-01-27T12:00:49+09:00 こんにちはCTO室の緑川です。今回はアンドバッドさんが主催しているPodcast「ANDPAD TECH TALK」のゲストに弊社の@mameが出演した記事の後半です。Podcastとしてお聞きしたい方は下記のアンドパッドさんの記事からお聴きください。 tech.andpad.co.jp 前編の記事はこちらです。 【前編】企業所属のRubyコミッター対談! 〜企業に所属するOSS開発者って何?〜 - クックパッド開発者ブログ トーク本編 櫻井:皆さま、こんにちは。アンドパッドの開発本部でエンジニアリングマネージャーをしている櫻井です。 櫻井:13回目のANDPAD TECH TALKです。AN… <p>こんにちはCTO室の緑川です。今回はアンドバッドさんが主催しているPodcast「ANDPAD TECH TALK」のゲストに弊社の@mameが出演した記事の後半です。Podcastとしてお聞きしたい方は下記のアンドパッドさんの記事からお聴きください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.andpad.co.jp%2Fentry%2F2023%2F01%2F27%2F100000" title="ANDPAD TECH TALK 第13回 - 企業所属のRubyコミッター対談!後編 Ruby開発の裏話と今後の取り組み - ANDPAD Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.andpad.co.jp/entry/2023/01/27/100000">tech.andpad.co.jp</a></cite></p> <p>前編の記事はこちらです。</p> <p><a href="https://techlife.cookpad.com/entry/ruby-committer-1">&#x3010;&#x524D;&#x7DE8;&#x3011;&#x4F01;&#x696D;&#x6240;&#x5C5E;&#x306E;Ruby&#x30B3;&#x30DF;&#x30C3;&#x30BF;&#x30FC;&#x5BFE;&#x8AC7;&#xFF01; &#x301C;&#x4F01;&#x696D;&#x306B;&#x6240;&#x5C5E;&#x3059;&#x308B;OSS&#x958B;&#x767A;&#x8005;&#x3063;&#x3066;&#x4F55;&#xFF1F;&#x301C; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></p> <h2 id="トーク本編">トーク本編</h2> <p>櫻井:皆さま、こんにちは。アンドパッドの開発本部でエンジニアリングマネージャーをしている櫻井です。</p> <p>櫻井:13回目のANDPAD TECH TALKです。ANDPAD TECH TALKはアンドパッドの開発チームの中の人をゲストに招いて、あれやこれやお話しするカジュアルなテック系Podcastなのですが、今回は前回に引き続き社外ゲストをお招きしたスペシャル会の後編となっております。企業に所属するRubyコミッターであるお二人をお招きしています。</p> <p>櫻井:アンドパッドからはフェローでありRubyコミッターの柴田さん。対談相手はクックパッド株式会社所属のRubyコミッターであるmameさんこと遠藤侑介さんをお呼びしています。前回はお二人の今までの経緯、Rubyコミッターが普段どんなことをしているのかをお話いただき、非常に良いところで後編となっていたところです。今回はRuby開発の裏話と今後の取り組みなど深振りをさせていただきたいと思っております。それでは後編をお聞きください。</p> <h2 id="Ruby開発の裏話">Ruby開発の裏話</h2> <p>櫻井:まずは遠藤さんからお伺いしますが、開発裏話みたいなものはありますでしょうか?</p> <p>遠藤:クックパッドでRubyをめちゃくちゃ現場で使っている職場に転職したから、現場感がつかめてなかったので、よくわからなかったところがうまくいくようになったのはちょっとあったりしますね。</p> <p>遠藤:最初に述べたカバレッジの測定をする機能を担当しているんですけれども、カバレッジの測定を止めたり再開したりする機能が欲しいという要望が以前Rubyに来たことがあったんですけれどもその時は必要性がよくわからなかったんですね。実装するのも大変なので断っていたんですけども、クックパッドで働くようになって、現場で働いている人 から同じ問題で困っているという話を聞くことができて、どういうふうに困っているのか理解がちゃんとできたので今回対応することにし、oneshot coverageという機能を導入するようにした話があったりします。</p> <p>柴田:その話で言うと、RubyコミッターRubyのコード書いていない問題みたいなものをよく言われたりしています。Cプログラマーの皆さんだから会社の中でRubyをどう使って何を書いているかとか、またRailsをメインに開発されている人とかRailsを使ってアプリケーションを開発している人とかが「Rubyでこういうことができるといいんだけどな」みたいな部分とあとRubyを開発しているRubyのコミッターの人たちが「こういうRubyのコードは書かないでしょ」って言った矢先に「書きますよ」みたいな話とか、逆にこう書けたら便利じゃないって「誰も書きませんよ、そういうこと」みたいな話は結構イベントとかSlackとかそういったところでよく散見されたりするのはあるあるネタですよね。</p> <p>遠藤:そうですね。本当に現場感がない人がRubyを作っているっていうのはちょっと問題としてはあったりしますね。</p> <p>柴田:ただ最近はShopifyのエンジニアであるとか、あとはよく喋る場みたいな部分が割と増えてきたような気はします。スタートアップ中心にRubyを採用している会社も結構な数があったりするので、「こういうことができるといいんだけどなー」みたいな部分とか拾ったりヒアリングしたりしやすくなったりしているのかなというのはありますよね。</p> <p>遠藤:ありますね。</p> <p>柴田:最近のライブラリというかプログラミング言語のGoとかRustみたいな言語はライブラリ自体がGoとRustで書いてあるというような言語なので、初学者の方とかが最初にGoとかRustをやりましょうみたいなときに割りかし開発を始めるまでのつまづくステップが比較的ないんですよね。それに比較するとRubyはC言語で書かれていて、C言語を動かすためにはコンパイラーとコンパイルした後の実行パイナリを実行する場所が必要になっていて、その辺の組み合わせで動かないとかビルドできないとか、何かプログラムを書こうと思ったんだけどプログラムを書くまでに1日とか何なら数日かかってしまう。Googleで検索しても何かエラーメッセージが出てこないみたいなことが増えていますと。</p> <p>柴田:あとはAppleのmacOS、MacBookのアーキテクチャーがガラッと変わったことでいろいろビルドできない問題が引き続き多いとか、ARMのCPUの上では何かうまく動かないとかそういったいろいろな社会のコンピューティング環境の変化にともなって、自分が使いたいものがすぐ使えない、みたいな部分に散見されるなっていう問題があります。僕はいろいろやってはいるんですけど、その辺のプログラムを書こうと思ったときにすぐ書けるようになるみたいな部分の時間をとにかく小さくしたいと思っているので、その辺をいろいろやったり、前回遠藤さんの方から紹介があったkateinoigakukunさんがmacOSについてすごい詳しくて本当に助かったんですけれどもその辺のmacOSでビルドできない問題、動かない問題みたいな部分もいろいろ複数人で協力しながら解決していったりしているっていうのが現在進行系の話になってますね。</p> <h2 id="開発者に知ってもらいたいこと">開発者に知ってもらいたいこと</h2> <p>櫻井:ありがとうございます。Ruby開発の裏側を聞いてきましたが、開発者に知ってもらいたいことなどがあれば、お二人からお伺いしたいんですけれどもいかがでしょうか?</p> <p>柴田:はい、そうですね。昨今のプログラミング言語界隈の流れというか流行りみたいな部分の話をちょっとしたいんですけど、VS Codeと呼ばれるエディターが割とメインというかメジャーな存在となっていて、VS CodeはTypeScriptであるとか、Go言語であるとか、最近だったらRust言語みたいなもののサポートが非常に豊富なんですよね。</p> <p>柴田:それもちろんMicrosoftが今すごい投資をしているんですけど、開発者体験という言葉があってデベロッパーエクスペリエンスというんですけど、開発者が何かをしようとした時に「うっ」てつまずかないように、なおかつ、こういうものを書きたいと思った時にスラスラっと書けます、テストも実行できます、不具合があった場所を見つけますみたいなものをできる限り提供していこうというのがどのプログラミング言語でも非常に重要視されています。</p> <p>柴田:RubyもVS Codeのサポートであるとか、そういった型システムみたいな部分については少しずつ頑張っているんですけれども、やはり他の言語と相対的にはまだまだだなという部分があります。その中でも2022年にリリースするRubyのバージョン3.2でも今話したようなエラーを見つけましょうとか、そういう開発者にとってスラスラっとRubyのコードを書けるようにするための機能がいくつかあるので、その機能の開発を頑張っていた遠藤さんに詳細を聞くといいんじゃないかなと思います。</p> <p>遠藤:僕がRuby3.2の中で新しくやったこととしてはRubyの例外が出たときに「このコードのせいでおそらく例外が出てるんじゃないの?」というのをエラーメッセージでサジェストをするという機能を少し拡充するのをやってみました。ErrorHighlightと呼ばれる機能なんですけども、それを拡充していました。また僕が作ったやつ以外にもSyntaxSuggestっていう機能がRuby3.2に増えまして、Rubyでコードを書いててありがちなのはendでステートメントを区切る言語なんで、endを書きすぎたり、逆にendが足りなかったりした時にどこにendが足りなかったのかというのはよくわからなくなりがちなんですよね。</p> <p>遠藤:シンタックスサジェストとはコードの構造を大きく抜粋して「おそらくここにendが多すぎるんじゃないか」とか「足りないんじゃないか」というのをエラーメッセージの中にヒント情報として出してくれるという拡張が行われていて、これもエラーがでた時に開発者がどこを直せばいいのかというのをサジェスチョンしてくれるという機能がちょっとずつ増えています。</p> <p>柴田:今の遠藤さんのお話はRubyのコードを実行したときに「この辺がエラーではないか?」というのを開発者の方にすぐお知らせするというような機能だったりするんですけど、他にもRubyの開発会議とか開発のこういうふうな変更を加えてはどうかみたいな時にも割と新しい機能を入れたりこういうメソッドを入れて警告を出すとか、「この辺が良くないのでは?」みたいなのを教えた方がいいんじゃないっていう提案が来るたびにじゃあそのエラーメッセージなり警告メッセージをプログラマーが見て「なにかできることはあるの?」みたいな話をしたら「いや、ないかも」みたいな話とか結構開発の方針を決めたりするときはあるあるネタです。メッセージを出したりプログラミング言語の動きとして、それを使った人が、「じゃあそれ見てなんかできることはあるの?」とか逆に「こうすればもっと良くなる」みたいなことをすぐ適切に知らせるにはどうしたらいいのかみたいなのは本当にRubyコミッターの中でも熟考というか結構紛糾しがちなネタだったりします。</p> <p>柴田:例えばワード1個をセーフって言い切ってしまっていいのかみたいな話とかでも、1時間とか2時間とか議論して「いやこれセーフって言うとダメでしょう」とか「いやじゃあなんて言えばいいの」みたいな話とかは結構あるあるネタだったりしますよね。</p> <p>遠藤:名前はね、本当にデベロッパーエクスペリエンスに直結するところなので、だいぶ長く議論をしますね。その結果がやや不自然な結果になることがあるんですけど本当に熱意を持って設計されているところだと思います。</p> <h2 id="今後の計画">今後の計画</h2> <p>櫻井:なるほど。ありがとうございます。さまざまなお話を伺ってきまして、ぜひ今後のお話も伺いたいと思うんですけれども、今後お二人がやっていきたいRuby開発にはどんなことがあるでしょうか? もし計画などがあればぜひ教えていただきたいです。</p> <p>柴田:はい。計画はないのでそれぞれが勝手にやりたいことをやっている、という話のあとに計画の話をするというのもあれなんですけど、僕が思っている部分としては、やっぱりRubyを開発する人が、開発をもっとしやすくなるようにできるといいなと思っている部分があるのでそこの部分の支援ですね。</p> <p>柴田:具体的にはRubyがちゃんとサポートしている動く場所、コンピューターの上として10個とか20個とか、Linuxの上であるとかmacOSの上であるとかWindowsの上であるとか、そういったいろんな部分で動かせるようにしましょうというのをユーザーと約束していて、ちゃんとそこの上で動かせるようにするというのをやっているんですけど、何かの変更を入れた時にWindowsは動きませんでしたとか、Windows向けに入れた変更はmacOSではダメでしたみたいなことがよくあるんですね。</p> <p>柴田:ただ、やはりRubyコミッター1人1人が持っているコンピューターは1個とか2個とかに限られているので、あるRubyコミッターがサポートするよって約束している10個とか20個とかの環境に即座に自分の目の前のコードを実行したりテストできるようにするみたいな部分を来年何かしら用意したいなというのが1個目の野望というか計画で、2つ目はリリース作業をもっと楽にしたいと思っていて現状は僕と遠藤さんとあとは3、4名のリリース担当のRubyコミッターと呼ばれる人たちが18時ぐらいから大体いつも22時から23時過ぎまでいつもリリース作業と呼ばれる作業をします。皆様に最新のRubyをご提供みたいな仕事をしているんですけど、もうなんかやるたびに(少しずつ良くはなっているんですけど)ほぼみんながもうやりたくないなっていう風に考えて、また次頑張るみたいな消耗戦を繰り広げているので、もう念じたらリリースできるというくらいまではちょっと頑張りたいなと思っているところです。だからいろいろ問題があるんですよね。</p> <p>遠藤:不思議ですよね。リリースは本当にびっくりするぐらい何かしら必ずはまるという感じで。</p> <p>柴田:なんか解決したはずなのに新しい問題がまた起きるみたいなことを本当に繰り返していて、誰かがサボリとか悪意を持ってやってしまったとか、そういう話ではなくて本当に新しい技術的な課題が毎回起きていて、そのたびにちゃんとこれはポストモーテムして対策を入れるみたいのを毎回少しずつ対策してるんですけど毎回新しい問題が起きるんですよね。</p> <p>遠藤:普通にソースコードをtarballにまとめるだけでなんでこんなにハマるんだっていう風に思う人もいるかもしれませんけど、本当になぜかtarballに固めたバージョンだけで発生するバグとかが毎回ちょっとずつ混入するのでパッケージを作ってテストしてみたら失敗するとかっていうのが発生するんですよね。なので、いざ本当にリリースバージョンを作ったら新しい問題が分かるのでそこから慌てて直すとかっていうのが何かしら発生する感じで大変ですよね本当に。</p> <p>柴田:ですよね。Webサービスの場合は自分たちが面倒を見て全責任を持っているコンピューターの上で動かすようにソフトウェアをリリースしますっていう感じなんですけど、Rubyとかプログラミング言語の場合は自分たちの外に向かってソフトウェアをリリースするみたいな部分です。なので、使う人の数だけそのソフトウェアが動く場所があって可能な限りそれを広くカバーしたいけどカバーしきれないこともあって「とあるソフトウェアがこういうバージョンだったらビルドできませんでした。これあかんみたい」な話とかを何回も繰り返しています。そこの大変さをちょっと広い目っていうか大きいスコープで捉えて解決するような仕組みを用意することでリリースとかはもっと毎月でもバンバンやっていいんじゃないのくらいのほうが、短いサイクルのほうがちょっとした不具合があっても「3ヶ月待たないとRubyは直らないんだよな」から「来月直るし」みたいなほうがユーザーにとってはおそらくいいことだろうし、開発する側にとっても何かミスっても来月直せばええやみたいな感じになって、みんなが楽になると思うので、少しずつがんばりたいなっていうのは僕の2023年の目標活動に入れています。</p> <p>遠藤:ほんとリリース頻度上げたいですよね。定期に3、4ヶ月くらいしたら1回入れるかどうかっていう。</p> <p>柴田:年3、4回ですもんね。</p> <p>遠藤:nodeとかは実質的に2ヶ月に1回リリースしてるみたいなので、そういうのをまねしていきたいですよね。</p> <p>柴田:そのくらいになりたい、、、隔月くらいになりたいですよね。</p> <p>遠藤:隔月くらいになるとユーザーも「そろそろRubyの新しいバージョンが出るらしい」みたいな備えができるかなと思っていて、いいなと思ってます。</p> <p>柴田:それに雑に壊れても、次回直せばいいなみたいな感じにもなれると思うので、やっぱり儀式化すると挑戦する意欲を高めるのにもすごい時間がかかるし、やってしまったときの「あー」みたいな気持ちもすごい高まるので、その辺の敷居はどんどん雑に下げていきたいですね。遠藤さんも何かあるんですか?</p> <p>遠藤:そうですね。最初のほうに話したTypeProfっていうやつを今まで作ってきてるんですけど、今年はですね今の実装のアプローチ、バイトコードを解析するというベースのアプローチだとちょっと限界を感じてきていて、抽象構文木ベースで解析し直すように作り直すというのを考えています。そのためにまずParserをどうにかするという必要があって、その辺りをShopifyの人たちがやってくれているのがそろそろ形になってきているのでそれをベースに2023年に作り直したいなというふうに思っています。それによってVS codeでのRubyの対応が弱いとかって言われているのを改善するように1つの提案ができたらなというふうに思っております。</p> <h2 id="個人の開発者がRuby開発へ貢献できる方法">個人の開発者がRuby開発へ貢献できる方法</h2> <p>櫻井:では最後にですね、ここまで聞いてきたリスナーで自分もRuby開発に貢献したいと思ったエンジニアが結構いるんじゃないかなと思ってるんですが、どうやったら個人の開発者がRuby開発へ貢献できるのでしょうか?</p> <p>柴田:一番簡単な方法はとにかくRubyを使うっていうのがあって、手元の仕事で開発しているソフトウェアであるとか仕事じゃないソフトウェアもいっぱい皆さんのお手元にあると思うんですけど、その辺のコードをとにかくRubyで実行するということがまず最初の一歩です。</p> <p>柴田:2つ目はですね、ここがちょっとアクションが必要になってくるんですけど、その動かした結果をRubyの開発チームに伝えるっていうのがポイントだと思っていて、動いたっていうことも重要なんですよ実は。</p> <p>柴田:Rubyの開発チームはPreviewバージョンとRCバージョンっていうのを1年間に2、3回リリースするんですけど、その2、3回リリースしたPreviewバージョンを使って自分の手元のコードを実行してみて動いたら動いたって言ってほしいですし、動かなかったらこういうエラーが出て動かなかったっていうのを教えてほしい。どちらも実は重要でまず実行してもらわないことには不具合なり、そのちゃんと正しいっていう動きも我々が知ることができませんし。で実行した後に動いた動かないっていう状況を伝えてもらわないことには我々はそれを知ることができないっという二段構えになってまして、本当に本当に大事で、ベータバージョンを出してみんながテストしてくれたから、大丈夫だったと思ってベータじゃない正式バージョンをリリースしたら全然動きませんでした。「なんでだ?」「ベータバージョンは誰も実行していなかったからだ」みたいなのは本当ソフトウェアあるあるな話なので、とにかくベータバージョンみたいなものを触ってみて「なんだこれ?」みたいなものがあったら動かなかったっていう報告であるとか、あとこの動きはちょっとおかしいんじゃないとかもぜひ教えてもらいたいなと思います。</p> <p>柴田:それで、教えてもらう方法もできる限りいろんな窓口を用意していて、メインで使ってるのはRedmineっていう課題管理ソフトウェアなので、Redmineの方で報告してもらうっていうのが一番の王道というかメインの手段なんですけど、それ以外にも例えばSlackにRubyのグローバルなコミュニティーとかもあったりするんですけど、そこの部分で動かなかったとか動いたみたいなものを僕とか遠藤さんにお伝えてもらってもいいですし、なんかTwitterとかそういう類似のソーシャルネットワークのサービスでメンションして、このコードが動きませんでしたみたいなことを伝えてもらってもいいですし、何かしらの手段でとにかくRubyの開発をしている「Rubyコミッターです」って名乗っている人たちに伝えるっていうのが一番最初にまずできることだと思いますね。</p> <p>柴田:で、第3ステップ目がちょっとハードルが上がるのかなと思うんですけど「なんだこれ」みたいな動きに対して「こうした方がいいんじゃないのか」っていうようなコードを書いて、それをGithubなりのプルリクエストでサブミットするとかRedmineの方にコードの断片をパッチとして貼り付けて投稿するとか、そういった部分を繰り返していくっていうのがRuby開発への貢献。Rubyのコミッターサイドとしてすごいありがたいなっていうような動きになるのかなと思います。</p> <p>柴田:皆さんが会社で行われてるような陸続きだと思っていて、RubyコミッターはただRubyを開発してる人でしかないので、チーム開発とかサービスを開発するときに隣の人が作った機能を実行してみたら動かなかったんだけどみたいなことだったら、皆さんは多分すぐSlackとかGitHubとか何かしらのチャットツールとかで動かなかったよって伝えると思うんですね。本当それと同じようなノリでいいと思っていて、ほんとまつもとゆきひろさんにTwitterで「これ動かないんですけど」みたいなって言うくらいでもいいと思って、まつもとさんすごいフレンドリーなので、まあそういう形でこうどんどん伝えて一緒に作っていくっていうのがRubyの魅力だと思うのでぜひなんかやっていただけるといいのかなと思います。</p> <p>遠藤:そうですね。本当に動かなかったときに誰にも言わないで諦めてしまうっていうのが一番残念で、誰かが困った問題では他の人が困るのでいったん声を上げるっていうのが重要だと思います。声を上げるのもできればTwitterで誰ともなしに語るだけではなくて、僕らのようにRubyの開発やってる人になんとか伝わる形で、一番簡単なメンションとか、より理想的にはやっぱりバグトラッカーに報告をするっていう形で伝えてもらえたら嬉しいなと思います。本当に時々あるんですけどTwitterで動かなかったというツイートを誰かコミッターが拾って直すっていう対応をすることもあるんですけども たぶんほとんどのやつは気が付かずに流れていってると思うので伝えてもらえると嬉しいなと思います。個人の開発者がRuby開発へ貢献できるかっていう話だと、RubyはCで書かれているのでちょっとハードルが高いっていう風におっしゃる方が時々見かけるんですけれども、そんなに難しく考えなくても(もちろんCが書けるに越したことはないと思うんですけども)そのようにバグ報告をするっていうのも重要な貢献ですし、特に機能提案に関してこういうユースケースでこの機能が欲しいとか、この機能提案だとこういうケースで問題があるだろうという議論に参加するという形の貢献もあると思います。実際にそのRubyのコードに手を動かして貢献したいっていう時も何かしら声をかけてもらえれば課題を紹介したりとか書こうとしているプログラムを手伝ったりとかもできると思いますので、やっぱりこれも声をかけてもらうというのがすごく重要かなと思います。</p> <p>櫻井:ありがとうございます。思っているだけではなくて、アクションに起こすことでRuby開発への貢献もできるし、コミッターへの道も開けるのではないかと。皆さんぜひどうしても見構えてしまう方もいるのかなというところがあるので、そういったところはちょっと一旦置いておいて、ちょっとした勇気を持ってコミュニケーションを取ってみるとそこから先に進めるのかなという気がしました。</p> <p>遠藤:そうですね。自分が実際にRubyを使っていて、こういう機能が欲しいっていう思いから貢献してもらうのがベストではあるんですけども、何かやりたいけれども特にアイデアがないっていう時には、時々Google Summer of CodeとかRubyのコミュニティからこういう課題があるっていうのを紹介することがあるので、それを参考にこれだったら自分ができるかもというのを選んでもらうというのもいいかなと思います。</p> <p>遠藤:この間柴田さんが書いたブログの記事とかね。そういう感じでRubyの長い懸案になっている課題みたいなのを紹介することもあるのでそういうのを考えてみてもらえといいかなと思ったりします。</p> <p>柴田:そうですよね。結構発信してなかったなと思ったんで、こういうことをやりたいとか。プログラミング言語業界の懸案事項って実は結構あって、どの言語でも実は今この問題があって、どの言語も解決できてないとか、ある言語だけこういうアプローチで解決できているとか、何なら無視しているとか、結構そういうのはRubyコミッターの中では議論として出たりしているんで、そういったものをできる限り開発ネタとして発信をして意欲のある人が「えいっ」て頑張って作るみたいな部分でネタを提供したりもできればいいかなと思ってますね。</p> <p>遠藤:そうですね。バグトラッカー上で議論しているのを頑張っておりますが、ちゃんとまとまってないのでどういう課題があるかというのを一覧できないのがやっぱり難しいですね。発信していかないとですね。</p> <p>櫻井:ありがとうございます。今回も本当にいろいろな裏のお話だったりとか、深いお話もお伺いできたかなと思うのですが、そろそろお時間となりますので、今回のANDPAD TECH TALKとしては以上で終了とさせていただきたいと思います。</p> <h2 id="さいごに">さいごに</h2> <p>Rubyコミッターによる対談はいかがだったでしょうか?クックパッドではサーバーサイドやOSSに関わりたい仲間を募集しています。Rubyについてもう少し詳細を知りたい方はカジュアル面談も実施していますので、ご興味のある方はぜひ気軽にご連絡ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=http%3A%2F%2Fcookpad.careers" 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="http://cookpad.careers">cookpad.careers</a></cite></p> tmidorikawa