TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

411

はじめに こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 この記事は every Tech Blog Advent Calendar の13日目の記事になります。 今回は、社内ChatApp向けに作成した、RAG(Retrieval-Augmented Generation)と呼ばれる手法を用いてコードを解説する機能について紹介します。 社内向けChatApp作成の取り組みは こちらの記事 で紹介されています。 LLMを利用したコード解説 最近、古くから利用されているロジックの詳細を調査する機会がありました。 しかし、初めて利用するサービスのコードであり、また、初見の言語であることからLLMを利用してコードを解説してもらいながら調査を進めていました。 都度コードをコピペしつつChatAppに解説を依頼していましたが、手間がかかるため、より効率的に利用したいと考えました。 ChatAppによるコード解説例 RAGについて RAGは、ユーザの質問に関連する情報をPromptに入れ込むことで、prompt内の情報を用いて回答を生成する手法です。 LLMに事前に用意した「カンニングペーパー」を渡してあげることで、より正確な回答を生成できます。 RAGは以下のような手順で動作します。 ユーザが質問を入力する 類似する情報を取得する 取得した情報をPrompt中に入れ込み、LLMに入力する LLMが回答を生成する RAGのイメージ 一般的に、事前に情報をベクトル変換したものをVector DBに保存しておき、類似度検索をかけることで質問に関連する情報を取得します。 ベクトル変換と類似検索 LLMに対して特定のデータを参照させる場合、 LLMをFine-tuningさせる方法 プロンプト中にデータを埋め込む方法 などが挙げられますが、LLMのFine-tuningは大量の計算リソースと時間が必要となるため、コストがかかります。 特に頻繁に更新されるデータを参照させる場合、Fine-tuningを行うたびにコストがかかるため、大きな問題となります。 今回扱うコードは頻繁に更新されるため、Fine-tuningの利用は避けたいと考え、プロンプト中にデータを埋め込む方法であるRAGを採用しました。 コード解説機能構成 今回作成する機能は、利用に必要な労力をなるべく減らすことを目的としてます。 そのため、ユーザが質問を投げかけるだけで、コード解説が実行されるようにします。 構成図 構成として、 コードのベクトル化部分 (pre.1,2) ユーザの質問から関連するコードを取得する部分 (1 ~ 3) 取得したコードを元に回答を生成する部分 (4,5) があり、それぞれ GitHubからコードを取得し前処理を行ってからEmbeddingを行いベクトル化し、メタデータとともにVector DBに保存する ユーザの質問を元に、Vector DBのメタデータフィルタを生成し、類似するコードを取得する 取得したコードとユーザの質問を元に、回答を生成する といった処理を行います。 今回Vector DBとして Pinecone を利用しました。 また、GitHubからのコード取得、前処理などはそれぞれ LlamaIndexの Github Repository Loader LangChainの CodeTextSplitter を利用しました。 Vector DBのMetadata Filteringを利用した検索 Vector DBにはコードのベクトルとともに、 リポジトリ名 ブランチ名 ファイル名 ファイルパス といった情報をメタデータとして保存しています。 これは単純にコードのみをEmbeddingした場合、コード中に存在しない情報を検索に利用できないため、ユーザの期待とは異なるコードが返却される可能性があるためです。 期待と異なる検索結果 そこで、Vector DBのMetadata Filtering機能を利用することで、より正確な検索結果を返却できるようにします。 https://docs.pinecone.io/docs/metadata-filtering ただし、ユーザがメタデータを意識することなく利用できるようにしたいため、メタデータフィルタリングの利用を自動化する必要があります。 そこで、メタデータとメタデータの説明がセットになったメタデータカタログを用意しLLMに入力することで、ユーザの質問から利用するメタデータと値を生成させます。 メタデータフィルタリングを利用した検索 生成された値を元にメタデータフィルタリングを利用することで、ユーザの質問に対するコードをより正確に取得できます。 これにより、 hogeレポジトリのfuga.pyのpiyo関数の処理を教えて といった質問を投げるだけで、対象のコードを正確に取得し回答を生成できます。 今回、LangChainの self-querying retriever を利用することで簡単に実装できました。 実験 テストとして簡単なコードを用意し、適切なフィルタリングが行われているかを確認します。 from langchain.embeddings.openai import OpenAIEmbeddings from langchain.chat_models import ChatOpenAI from langchain.chains import ConversationalRetrievalChain from langchain.retrievers.self_query.base import SelfQueryRetriever from langchain.chains.query_constructor.base import AttributeInfo from langchain.schema import Document from langchain.vectorstores import Chroma # ベクターストアの構築 def vectorstore (): # テスト用のドキュメント docs = [ Document( page_content= """ def func(a: int, b: int) -> int: return a + b """ , metadata={ "repository" : "AAA" , "filename" : "hoge.py" } ), Document( page_content= """ def func(a: int, b: int) -> int: return a - b """ , metadata={ "repository" : "BBB" , "filename" : "hoge.py" } ), Document( page_content= """ def func(a: int, b: int) -> int: return a * b """ , metadata={ "repository" : "BBB" , "filename" : "huga.py" } ), ] return Chroma.from_documents(docs, OpenAIEmbeddings()) # メタデータフィルタリングを利用するself-query retrieverの構築 def self_query_retriever (vectorstore): # メタデータカタログ metadata_field_info = [ AttributeInfo( name= "filename" , description= "The filename of the source code" , type = "string" , ), AttributeInfo( name= "repository" , description= "Repository name where source code exists" , type = "string" , ), ] document_content_description = "Source code files collected from multiple repositories" llm = ChatOpenAI(temperature= 0 , model_name= "gpt-4-1106-preview" ) return SelfQueryRetriever.from_llm( llm, vectorstore, document_content_description, metadata_field_info, verbose= True ) # メタデータフィルタリングを利用しないretrieverの構築 def retriever (vectorstore): return vectorstore.as_retriever() def get_retrieval_chain (retriever): return ConversationalRetrievalChain.from_llm(ChatOpenAI(temperature= 0 , model_name= "gpt-4-1106-preview" ), retriever=retriever, return_source_documents= True , verbose= True ) vectorstore = vectorstore() retriever = self_query_retriever(vectorstore) # retriever() or self_query_retriever() qa = get_retrieval_chain(retriever) result = qa({ "question" : "repository BBBのhoge.pyの関数funcについて解説してください" }) メタデータフィルタリングを利用しない場合 まずは、メタデータフィルタリングを利用せずに、 repository BBBのhoge.pyの関数funcについて解説してください という質問を投げてみます。 メタデータフィルタリングを利用しない場合 LLMが参照にしたコードの項目を見ると、 repository BBBのコード以外にもAAAのコードも参照している hoge.pyではなくhuga.pyのコードも参照している といったように、ユーザの質問に対して不適切なコードを参照していることがわかります。 その結果、ユーザが期待する回答を得られませんでした。 メタデータフィルタリングを利用した場合 次に、メタデータフィルタリングを利用して、同様の質問を投げてみます。 メタデータフィルタリングを利用した場合 今度は、repository BBBのhoge.pyのみを参照しており、ユーザが期待する回答を得ることができました。 メタデータフィルタリングを適切に利用できない場合 self-query retrieverはLLMがユーザの質問とメタデータカタログを元に利用するメタデータフィルタを自動生成します。 そのため質問の仕方や、メタデータカタログの内容によっては、適切なメタデータフィルタが生成されない可能性があります。 その場合、誤ったドキュメントの参照や、ドキュメントの参照が行われず、適切な回答が得られない可能性があります。 メタデータフィルタの生成精度を向上させるには、self-query retrieverで使用するpromptや、メタデータカタログの内容を調節する必要があります。 終わりに 今回作成した、コード解説機能は社内向けChatAppにβ版としてリリース予定です。 self-query retrieverを利用してメタデータフィルタを自動生成することで、RAGで適切なデータを参照することができるようになり、ユーザが質問を投げるだけでコード解説が行えるようになりました。 今後の取り組みとしては、コードEmbeddingの効率化や最適化、prompt改善による回答精度の向上などを行っていきたいと考えています。
アバター
こんにちは。開発本部のデータ&AIチームでマネージャー兼データサイエンティストをしている伊藤です。 この記事は every Tech Blog Advent Calendar 12日目の記事です。 今回は、先日発表されたOpenAI Assistants APIを使って分析用のSQLの生成を試してみた取り組みについて紹介します。 分析用SQLの生成 最近1年ほどでLLMが注目されるようになり、ChatGPTをはじめLLMを使ってより効率的に処理できる作業が増えてきています。 自分自身も、社内のChatAppにコーディングを手伝ってもらったり、ちょっとした文章の整形や知識の深掘りに活用しています。 中でも、データサイエンティストとしてLLMで効率化したい作業の一つにデータ分析、特にSQLの作成があると思います。 LLMによってSQLが生成できるようになると、データサイエンティストをはじめ分析者がより効率的に分析できるようになるのはもちろん、 普段SQLを書かない社員が分析する際の助けにもなります。 SQLの生成自体は現在のLLMでも可能ではありますが、世の中にリリースされているLLMは自社独自のデータ構造等のドメイン知識は学習していないため、 社内での分析利用を目指すのであれば、そういったドメイン知識をいかに扱うかが重要となります。 直近 Amazon Q Generative SQLのプレビュー版が発表されるなど、ドメイン知識を活用したSQL生成はLLM活用の中でも関心の高い領域となっていそうです。 今回紹介する取り組みは、11月の中旬に開催された挑戦weekで実施したもので、 当時リリースされたばかりだった OpenAI Assistants API を使った内容を紹介できればと思います。 OpenAI Assistants APIについて OpenAI Assistants APIは、チャットボットのようなアプリケーションにCode InterpreterやRetrieval、Function callingといった"Tool"を 簡単に組み込める機能を提供しています。 https://platform.openai.com/docs/assistants/overview 詳しい使い方は公式ドキュメントに委ねますが、主要な構成要素として ドメイン知識などのデータが記録されているFile 入力に対してFileを使った処理を行うTool 入力に対するレスポンスとして生成されるMessage 過去やり取りされたMessage群を一連の会話として保持しているThread FileやToolを利用し、入力に対してMessageを作成するAssistant があり、それらは AssistantにFile、Toolを紐づける(それぞれ複数指定可能) Threadを立ててユーザーからMessageを入力する ThreadをAssistantに渡すと、LLMからのレスポンスがMessageとしてThreadに追加される といった関係性で動作します。 Assistants API 概念図 開発時には、Fileの作成やToolの選定を適切に実施するだけで、目的に応じたAssistantの作成が可能です。 方針 今回作成するSQL生成アプリケーション(以下SQLジェネレーターと呼びます)は、社員が誰でも利用できるようなツールを目指しています。 現状エブリーのデータ基盤のうち、社員全員が利用できる部分ではRedashからTreasureData (Presto) を参照しているため、 TreasureData上で実行可能なSQLの生成を目標とします。 要件を整理すると、 ユーザーから知りたい内容の質問を受け取り 質問内容を集計できるTreasureData (Presto) で動作するSQLを返す ような方針で設計を行いました。 SQLジェネレーター構成 まずToolの選定ですが、入力に基づいてFileを参照し、必要なドメイン知識を抽出する役割が必要なため、Retrievalを採用しました。 本来、外部のドメイン知識を扱う際には、それらを適切な長さのチャンクに分割し、ベクトルデータベース等に格納しておく必要がありますが、 Retrieval Toolではその辺りをOpenAI側で処理しているため、深く考慮せずとも実装が可能です。 ドメイン知識に関しては、テーブル情報を記載した表や特殊な関数の使い方をまとめたものをPDFファイルとしてアップロードしました。 ユーザーの質問をAssistantに渡す部分では、LLMがタスク内容を解釈しやすいように、入力するプロンプト内で ユーザーから要求されている分析内容を集計できるSQLを作成する 利用するデータソースやカラム、独自関数を使った期間指定の方法等はファイルを参考にする TreasureDataでそのまま実行できるSQLを出力する といった内容を記述しています。 実験 まずは、何もドメイン知識を与えない状態で、社内ChatApp (GPT-4-turbo) にSQLを生成させてみます。 SQL生成例1 当然ですが、ドメイン知識を何も渡していないため、このままではTreasureData上で実行できません *1 。 続いて、 SQLジェネレーターに同じ質問を投げかけてみます。 SQL生成例2 今度は、検索データのevent_searchテーブルを参照した上で、適切なカラムのselect、search_typeやtag_idなどの条件指定、TreasureDataの独自関数 (TD_TIME_FORMAT、TD_TIME_RANGE) の使用が適切で、 実際に使用できるSQLとなっています *2 。 実行例 実際の分析でよく利用されるデモグラフィックデータとの突合も試してみます。 SQL生成例3 検索データのevent_searchに加えて年代情報のあるuser_masterテーブルとカラムを正しく指定できており、join操作含めて正しいSQLになっています。 他にも、ウインドウ関数を使った移動平均の集計なども対応でき、ドメイン知識を取り込んだSQLの生成が簡単に実装できました。 ドメイン知識を適切に扱えないケース 前節ではうまく生成できた結果をピックアップして紹介しましたが、もちろん間違ったSQLが出てくることも多くあります。 間違え方としては、SQLの文法自体が誤っているケースはほとんどなく、主に独自関数の使い方に集中していました。 特に、期間指定に用いる独自関数の中でも、TD_TIME_RANGEよりもTD_INTERVALという関数の扱い方は間違っている割合が高く、 ファイルに記載しているにも関わらず、入力パラメータや出力値の型を間違って認識していました。 試しに、ドメイン知識を与えない状態でTD_TIME_RANGE、TD_INTERVALそれぞれの意味を質問したところ、以下のような回答が返ってきました。 TreasureData独自関数に関する回答 回答では、TD_TIME_FORMATについては正しい内容が書かれてますが、TD_INTERVALは間違った回答(Hallucination)になっています *3 。 SQLジェネレーターにおいて、TD_INTERVALの使い方は必ず間違えるわけではなく、正解する場面も見られたため、 プロンプトや確率的な変数など何かしらの要素が起因してファイル中の知識よりもGPTが持っている知識体系にバイアスを受けている可能性がありそうでした。 このようなLLM自体が持つバイアスへの対処は、ファイル中のドメイン知識の書き方やプロンプトエンジニアリングなどで解消できる可能性はあるため、今後の課題と考えています。 今後の展望 今回作ったSQLジェネレーターは、改善点は多く残されてはいますが、社内ChatAppの拡張機能としてベータ版を展開予定です。 今後の取り組みとしては、レスポンス速度や精度の改善、SQLが間違っている場合に修正しやすい仕組みの提供といった機能的な改善はもちろん、 社員が普段見ているドキュメントをAssistantsのドメイン知識にどのように転換させるかといった運用的な改善も考えられます。 今後もLLMを中心とした業務効率の改善に、チーム全体として取り組んでいければと思います。 *1 : 入力プロンプト内にドメイン知識を記述して生成させる手段はありますが、今回はユーザーから直接ドメイン知識を与えない状況を想定しているため考えないものとします。 *2 : 具体的な値にはぼかしを入れています *3 : 公式ドキュメント https://docs.treasuredata.com/display/public/PD/Supported+Presto+and+TD+Functions#SupportedPrestoandTDFunctions-TD_INTERVAL
アバター
この記事は every Tech Blog Advent Calendar 2023 の11日目です。 こんにちは。トモニテ開発部iOS担当兼、開発組織活性化委員会リーダーを勤めている國吉です。 今回はエブリーで初の試みとなる開発部全体イベント”挑戦week”を開催/運営してみての所感を書こうと思います。 前段 開発部では各事業部毎にバックエンドチームやクライアントチームが存在しています(一部横断するチームもあります)。 このような組織体系だと、時間経過と共に課題として挙がってくるのがチーム横断でのコミュニケーションの取りづらさです。 この課題があり続ける限り、ナレッジ共有ができず知らず知らずの間に同じような技術検証をしていることなどが生じてしまいます。 それらを含めた問題を解決するために”組織活性化委員会”が発足しました。 施策 ”組織活性化委員会”は名前の通り、組織を活性化させチーム間で生じているコミュニケーション障壁をなくす施策や他チームが取り組んでいる内容を把握することができる施策に取り組んでいます。 具体的に実施している施策は下記です。 TechTalk 挑戦week アドベントカレンダー 簡単に挑戦week以外の活性化施策にも触れようと思います。 TechTalk TechTalk毎月1回実施しており、内容としては”AllHands”と”LT”です。 ”AllHands”は前月に各チームが取り組んだ内容やその中でのトピックの共有を行い、”LT”は毎回2~3人ほどが技術について発表してくれています。 TechTalk終了後は、雑談する時間を設けておりチームを横断したコミュニケーションを促進しております。 アドベントカレンダー これは絶賛取り組んでいるイベントです! ブログを執筆し社外へアウトプットする目標は開発部側の目標となりますが、組織活性化委員会と協業してイベント事として盛り上げていこうとしています。 是非、こちらから他のブログも見ていただけたらと思います! tech.every.tv 挑戦week さて、本題の挑戦weekのお話に戻ります。 ロードマップ まず、挑戦week含め各施策の組織活性化委員会のロードマップをざっくり共有します。 図を見て分かる通り、挑戦weekを実施するだけで1年間みっちり活動しており、その中でTechTalkやアドベントカレンダーを実施しているので結構大変です。 組織活性化委員会のメンバーは異なるチームからメンバー構成がされているため、MTGは週に1時間〜2時間しかとれず各パート長めに期間を取らざる終えません。 実施内容 続きまして、内容に少し触れていきます。 これまでで計3回開催していますが、共通していることがあります。それは”1週間事業部の施策やMTGには参加せず、集中して挑戦weekに取り組む”ことです。 もちろんこれは各事業部側の皆さんにも協力してもらい成り立っていることです。1週間施策を止めることはすごく大きなことなので、改めて感謝しないといけませんね。 開発部のメンバーが実際に取り組む内容については、開発部に所属している人からやりたいことを募り内容を決めたり、部長陣から今後に活かせる内容を募り内容を決めたりと試行錯誤しながら開催しています。 取り組んだ内容は、ブログとして執筆している人がいるので気になる方は是非ご一読ください!おそらくアドベントカレンダーの中にも挑戦weekで取り組んだことについて執筆する人いると思います。 tech.every.tv tech.every.tv tech.every.tv 開催/運営してみた所感 今回は挑戦weekの内容について深ぼるのではなく、開発部全体を巻き込むイベントを実装に運用してみて僕個人が難しいと感じたとこや楽しいと感じたところをアウトプットできたらと思います。 難しいポイント まず、エブリーとして全体イベントを実施するのが初めてでノウハウもない中での実施となるので、最初は一つ一つ全てが難しかったです。 その中でも継続して運営していく上で特に難しいと感じるポイントは下記です。 取り組むお題(内容)の選定 アンケート結果から改善内容の精査 取り組むお題(内容)の選定 漠然とやってみたい形式はあるが、詳細に落とし込む段階で”今後事業部に活かせるのか””その粒度で皆をまとめることができるのか”に納得できず、時間だけが経過してしまうことが多々あります。 第1回目は実施実績がないので、まずは無難なとこから始めてみようということで開発部のみんなが日々挑戦したいと思っていることや改善したいと感じているとこから取り組むことにしました。 結果、日々の改善系が多くなりましたが第1回目としては成功したと感じています。 ただ、イベント名に”挑戦”という単語が含まれているため一定の挑戦要素を盛り込みたいなと考えており、お題を決めるとこに関しては引き続き頭を抱えながらアンケート結果を振り返りつつ検討しています。 アンケート結果から改善内容の精査 毎回挑戦week実施後に、参加者に対しアンケートを実施しています。アンケート内容は満足度や不満点、改善点などです。 参加者が多ければ多いほど、様々な意見がありどこまで吸収し、次に活かしていくかを毎回議論するのですが、それが難しいです。 極端なことを言うと、「挑戦week自体やる意味ありますか?」といった意見もあります。少しでもやる意義を提示できてないことは”組織活性化委員会”の課題であり改善点です。 まずは、皆が協力して参加してくれるようなイベントにできるよう改善を行うのが重要なのかなと考えています。 他にも時間がない中で、様々な部分での意思決定が必要となるため難しいと感じるポイントが多々あります。 楽しいポイント 逆に楽しいと感じるポイントも少なからずあります。楽しいと言うよりは”嬉しい”や”組織活性化委員会やって良かった”と感じるポイントと言った方が適切かもしれません。 一番嬉しいことは、挑戦week実施後のアンケートで「新しい技術への挑戦ができ楽しかった!」や「話したことない人とコミュニケーションが取れ、会話の幅が広がった」「いつも運営ありがとうございます」等の意見を言ってもらえることです。 まだまだ会社の文化として全社イベントは根付いてないので、試行錯誤のフェーズだと思いますがその中でポジティブな意見がもらえるのはとてもありがたいことです。 他には少なからず意思決定する場面が多く、イベント内容の仕様が決まっていく様子や実際にイベントを開催することで達成感があり、同時にやりがいを感じます。 最後に ここまで所感などを書いてきましたが、総括すると開催実績がない状態で全体イベントを開催/運営に携われたのは良かったです。 意思決定や大勢を巻き込むイベント実施の難しさを体感できたのは、今後に活かせる経験であることは間違いなく、開催に協力してくれた組織活性化委員会のメンバーはもちろんのこと、開発部に所属している方や事業部側にもとても感謝です。 まだまだ文化として定着させるには時間がかかるかもしれませんが、将来的には他職種を巻き込み影響範囲を広げていけたら、会社全体としても盛り上がっていくんじゃないかなと期待しています。 最後に挑戦week最終日に取り組んだ内容を共有する場を設けているのですが、その時の写真を載せておきます。
アバター
概要 この記事は every Tech Blog Advent Calendar 2023 の10日目です。 TIMELINE開発部の内原です。 本日はTIMELINE開発部で利用しているAWS RDSへの踏み台サーバの構成を、ECS Fargate+PortForward+Adhocな機構に変更した話を書きます。 似たような記事はいたるところで見かけるので何番煎じになるか分からない状況ですが、以前からやってみたいと考えていたものだったので個人的にはよかったです。 変更前の構成 変更前の踏み台サーバの構成は以下のようなものでした。 EC2インスタンス (Amazon Linux, t2.micro) SSHポート転送を利用してRDSに接続 SSH用アカウントはgithubの公開鍵を手動で登録 上記構成を利用してSSHポート転送を行うには、以下のようなコマンドを実行します。 $ ssh -L 3306:database.xxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306 $user @ $db_proxy_host その後MySQLクライアントなどで 127.0.0.1:3306 に接続することで、RDSに接続できます。 問題点 この構成には以下のような問題点がありました。 EC2インスタンス固定費が発生 t2.micro の料金は USD 0.0116/hour で、1ヶ月で 24 * 30 = 720 時間稼働すると USD 8.35 かかります 微々たるものですが、使っていないにも関わらず費用が発生するのはもったいないです EC2インスタンスメンテナンスが必要 AMIの更新やセキュリティ脆弱性など、必要に応じて定期的にメンテナンスが必要になります アカウントの管理が面倒 開発者が増える度に、手動でUnixユーザ追加と公開鍵の登録を行う必要があります また、退職者のアカウントの削除も手動で行う必要があり、忘れるとセキュリティ上問題があるため注意が必要です 対応策 以下のようなことを実現したいと考えました。 EC2インスタンスを廃止 ECS Fargateを利用して、必要に応じてコンテナを起動する方針とします。 これによりインスタンスの管理が不要となります。 SSHポート転送を廃止 ポート転送にはAWS SSM Session Managerを利用することにします。 これによりSSHの管理も不要となります。 アカウントの個別管理を廃止 エブリーではAWSのアカウント管理にAWS IAM Identity Center(旧AWS SSO)を利用しており、認証にはGoogle Workspaceを用いています。 退職者はGoogle Workspaceにアクセスできなくなるため、自動的にAWSへのアクセスも不可能となり、アカウントの管理が不要となります。 自動的に停止する機能 せっかくECS Fargateに切り替えても、常時コンテナが起動していたのでは却って費用が割高になってしまいます。 このため、RDSへの接続が不要になったタイミングでコンテナが自動的に終了するようにしたいと考えました。 また安全のため、なんらかの理由でコンテナの自動終了ができなかった場合でも、一定時間経過したら強制的に終了するようにしておきたいです。 なるべく軽くかつ安くしたい ECS Fargateのコンテナは、最低でもvCPU 0.25、メモリ 512MBのリソースを必要とします。やりたいことはポート転送だけなので、最低限のスペックにしておきます。 また2024/02/01より、AWSではPublic IP Addressに対する課金が発生することになっています。このため、Public IP Addressを割り当てないように設定します。 RDSはVPC内にあるため、private subnetにコンテナを配置しても問題なく接続できます。 というわけでできました ECSタスク定義 あらかじめ以下のようなタスク定義を作成しておきます。 スペックは最低限、かつ一定時間が経過したらコンテナが自動的に終了する構成にしておきます。 { " taskDefinitionArn ": " arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:task-definition/db-proxy:1 ", " containerDefinitions ": [ { " name ": " db-proxy ", " image ": " alpine:latest ", " cpu ": 0 , " portMappings ": [] , " essential ": true , " entryPoint ": [ " sh ", " -c " ] , " command ": [ " sleep $TIMEOUT_SEC " ] , " environment ": [ { " name ": " TIMEOUT_SEC ", " value ": " 3600 " } ] , " mountPoints ": [] , " volumesFrom ": [] } ] , " family ": " db-proxy ", " taskRoleArn ": " arn:aws:iam::xxxxxxxxxxxx:role/ecs-task-role ", " executionRoleArn ": " arn:aws:iam::xxxxxxxxxxxx:role/ecsTaskExecutionRole ", " networkMode ": " awsvpc ", " volumes ": [] , " requiresAttributes ": [ { " name ": " com.amazonaws.ecs.capability.task-iam-role " } , { " name ": " com.amazonaws.ecs.capability.docker-remote-api.1.18 " } , { " name ": " ecs.capability.task-eni " } ] , " placementConstraints ": [] , " compatibilities ": [ " EC2 ", " FARGATE " ] , " requiresCompatibilities ": [ " FARGATE " ] , " cpu ": " 256 ", " memory ": " 512 " } 起動スクリプト そして、RDSに接続をする際には以下のようなスクリプトを用います。 スクリプトではFargateコンテナの起動とポート転送を行い、スクリプト終了時にはコンテナを停止します。 スクリプトの終了はCtrl-Cで行うことができます。 #!/bin/bash set -e # タスクが終了するまで待機する場合は wait_stopped=1 を指定する wait_stopped = cluster = " YOUR_CLUSTER_NAME " remote_db_host = " YOUR_DB_HOST " remote_db_port = " 3306 " local_db_port = " 3306 " profile = " ${AWS_PROFILE :- default } " task_definition = " db-proxy:1 " subnets = " subnet-xxxxxxxxxxxxxxxxx,subnet-xxxxxxxxxxxxxxxxx,subnet-xxxxxxxxxxxxxxxxx " # private subnetsを指定 security_groups = " sg-xxxxxxxxxxxxxxxxx " running_task_arn = "" # Ctrl-C で終了した場合にコンテナを停止する function shutdown() { if [[ -z $running_task_arn ]] ; then exit 0 fi aws \ --profile $profile \ ecs stop-task \ --cluster $cluster \ --task $running_task_arn > /dev/null if [[ -z $wait_stopped ]] ; then exit 0 fi aws \ --profile $profile \ ecs wait tasks-stopped \ --cluster $cluster \ --tasks $running_task_arn > /dev/null } trap shutdown 2 # タスク起動 running_task_arn = $( aws \ --profile $profile \ ecs run-task \ --cluster $cluster \ --enable-execute-command \ --task-definition $task_definition \ --launch-type FARGATE \ --network-configuration " awsvpcConfiguration={subnets=[ $subnets ],securityGroups=[ $security_groups ],assignPublicIp=DISABLED} " | jq -r ' .tasks[0].containers[0].taskArn ' ) # タスクが起動するまで待機 aws \ --profile $profile \ ecs wait tasks-running \ --cluster $cluster \ --tasks $running_task_arn # runtimeId を取得 runtime_id = $( aws \ --profile $profile \ ecs describe-tasks \ --cluster $cluster \ --tasks $running_task_arn | jq -r ' .tasks[0].containers[0].runtimeId ' ) task_id = $( echo " $running_task_arn " | cut -d ' / ' -f 3 ) target = " ecs: ${cluster} _ ${task_id} _ ${runtime_id} " # コンテナに対しポート転送を行う aws \ --profile $profile \ ssm start-session \ --target $target \ --document-name AWS-StartPortForwardingSessionToRemoteHost \ --parameters ' {"host":[" ' $remote_db_host ' "],"portNumber":[" ' $remote_db_port ' "], "localPortNumber":[" ' $local_db_port ' "]} ' 以下のような出力が得られたら、MySQLクライアントなどで 127.0.0.1:3306 に接続することでRDSに接続できます。 Starting session with SessionId: xxxxxxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69 Port 3306 opened for sessionId xxxxxxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69. Waiting for connections... その後 Ctrl-C を押すと、以下のような出力が得られ、コンテナが終了します。 Terminate signal received, exiting. Exiting session with sessionId: xxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69. 仮にスクリプトを長時間放置したままにしていた場合や、なんらか不測の事態によりコンテナを正しく終了できなかった場合でも、1時間経過すると自動的に終了します。 まとめ 以上で、ECS Fargate+PortForward+Adhocな機構による踏み台サーバの構成変更を行うことができました。 これにより、EC2インスタンスの管理やSSHの管理、アカウントの管理が不要となり、かつコンテナの起動は必要最低限に抑えられるため、費用も削減できるようになりました。 セキュリティ向上やメンテナンスコストがなくなったのが個人的には一番のメリットでした。 以上、何番煎じか分からないRDS踏み台サーバを作る話でした。
アバター
目次 はじめに range over int range over intとは range over intをGo Playgroundで触ってみる range over intを用いる利点 よくあるfor文が簡潔になる リーディングコストが軽減される range over func range over funcとは range over funcをGo Playgroundで触ってみる func(func()bool) func(func(V)bool) func(func(K, V)bool) range over funcを用いる利点 シンプルで標準的なイテレータを実装できる おわりに Appendix: range over funcを理解するコツ はじめに 株式会社エブリーで DELISH KITCHEN 事業のバックエンドエンジニアをしている、GopherのYuki( @YukiBobier )です。主に 広告サービス を担当しています。 前回の記事 では、Goにおけるヒープ使用量改善手法についてご紹介しました。今回は、 every Tech Blog Advent Calendar 2023 の9日目の記事として、変わりゆくGoの range について取り上げます。 なお、ここで取り上げる内容は開発中のものとなりますので、将来的に変更される可能性があることを申し添えておきます。 range over int range over intとは 早い話、これが for i := 0 ; i < 5 ; i++ { fmt.Println(i) } こう書けるようになるということです。 for i := range 5 { fmt.Println(i) } 2022年10月25日にRuss Coxによって後述するrange over funcと合わせて ディスカッション が開始され、2023年7月18日に プロポーザル が出されたのち、同年10月27日に Accepted となりました。 また、そこで合意された内容として、range over intはGo 1.22に追加されることとなりました(後述しますが、range over funcはGo 1.22ではGOEXPERIMENT入りにとどまります)。 range over intをGo Playgroundで触ってみる Goの開発ブランチにはrange over intがすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。 [Go Playgroundで実行する] package main import "fmt" func main() { for i := range 5 { fmt.Println(i) } } 次のような結果が得られるはずです。 0 1 2 3 4 Program exited. range over intを用いる利点 よくあるfor文が簡潔になる 私たちがrange over intを用いる1つ目の利点として、 for i := 0; i < N; i++ { のような、0からN-1までカウントアップするよくあるfor文が簡潔になります。 特に、カウンタに関心がない場合は前述の例よりもさらに簡潔になります。例えば、ベンチマークテストは下のようになります。 for range b.N { doSomething() } リーディングコストが軽減される 2つ目の利点として、リーディングコストが軽減されます。 通常の3節からなるfor文は、それが0からN-1までカウントアップするよくあるfor文なのか、それとも例えば1からNまでカウントアップするunusualなfor文なのか、その区別はちゃんと読まないとつきません。逆に、どうせよくあるfor文だと思って読み飛ばしていると、実はそうではなくてミスリードが生じるということもあるでしょう。 for i := 0 ; i < 5 ; i++ { fmt.Println(i) } // 👆よくあるfor文とunusualなfor文はちゃんと読まないと区別できない👇 for i := 1 ; i <= 5 ; i++ { fmt.Println(i) } この点、よくあるfor文をrange over intに置き換えることで、そうではないunusualなfor文が通常の3節からなるfor文として際立ちます。つまり、通常のfor文に出会った時だけちゃんと読めばいいので、for文を読む負担が減ります。 for i := range 5 { fmt.Println(i) } // 👆range over intとunusualなfor文はちゃんと読まなくても区別がつく👇 for i := 1 ; i <= 5 ; i++ { fmt.Println(i) } range over func range over funcとは range がイテレータの標準として機能するようになるということです。 ディスカッションでRuss Coxは次のように述べています。 When you want to iterate over something, you first have to learn how the specific code you are calling handles iteration. This lack of uniformity hinders Go’s goal of making it easy to easy to move around in a large code base. People often mention as a strength that all Go code looks about the same. That’s simply not true for code with custom iteration. 現在のGoにはイテレータの標準がなく、それぞれがそれぞれのアプローチをしています。標準ライブラリ内でさえ、それぞれの方法でイテレーションをハンドリングしています。 例えば、 database/sql.Rows のイテレータは次のようになっています。 for rows.Next() { var name string if err := rows.Scan(&name); err != nil { log.Fatal(err) } fmt.Println(name) } 一方で、 archive/tar.Reader.Next では次のようになっています。 for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { log.Fatal(err) } fmt.Println(hdr.Name) } だいぶ違いますね。 このような状況なので、コードを書くにしろ読むにしろ、イテレータについては個々のハンドリング方法を知らなければなりません。同じような目的は同じようなコードで達成されるというGoの長所が、イテレータに関しては発揮されていないということになります。 このような状況を解決するべく、 range を拡張する形でイテレータの標準化が図られることとなりました。それがどのようなものであるかの詳細については、コードをみるのが一番分かりやすいので次項に譲ります。 なお、range over intとともにAcceptedとなりましたが、こちらはGo 1.22ではGOEXPERIMENT入りにとどまり、引き続き詳細が検討されることになりました。環境変数 GOEXPERIMENT に rangefunc を設定することで実験的な使用が可能になります。 range over funcをGo Playgroundで触ってみる こちらもGoの開発ブランチにすでに実装されているので、さっそくGo Playgroundで”Go dev branch”を選択して実行してみましょう。 なお、range over funcには次の3タイプがあるため、ひとつひとつ取り上げます。 func(func()bool) func(func(V)bool) func(func(K, V)bool) ちなみに、プロポーザルの冒頭ではそれぞれ bool を返すとされていますが、検討を経た結果としてこの戻り値は取り除かれると Discussion Summary / FAQ では述べられており、実際に開発ブランチではそのように実装されています。 func(func()bool) まずは、ループ変数に値を渡さないタイプです。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func () bool ) { if !yield() { return } if !yield() { return } if !yield() { return } if !yield() { return } if !yield() { return } } func main() { for range rangeFive { fmt.Println( "Hello" ) } } 次のような結果が得られるはずです。おそらく開発中のバグでGo vetが転けていますが、実行には成功しています。 # [play] vet: ./prog.go:26:12: cannot range over rangeFive (value of type func(yield func() bool)) Go vet failed. Hello Hello Hello Hello Hello Program exited. func(func(V)bool) 次に、1つのループ変数に値を渡すタイプです。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func ( string ) bool ) { if !yield( "H" ) { return } if !yield( "e" ) { return } if !yield( "l" ) { return } if !yield( "l" ) { return } if !yield( "o" ) { return } } func main() { for s := range rangeFive { fmt.Println(s) } } 次のような結果が得られるはずです。 # [play] vet: ./prog.go:26:17: cannot range over rangeFive (value of type func(yield func(string) bool)) Go vet failed. H e l l o Program exited. func(func(K, V)bool) 最後に、2つのループ変数に値を渡すタイプです。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" func rangeFive(yield func ( string , int ) bool ) { if !yield( "H" , 4 ) { return } if !yield( "e" , 3 ) { return } if !yield( "l" , 2 ) { return } if !yield( "l" , 1 ) { return } if !yield( "o" , 0 ) { return } } func main() { for s, i := range rangeFive { fmt.Println(s, i) } } 次のような結果が得られるはずです。 # [play] vet: ./prog.go:26:20: cannot range over rangeFive (value of type func(yield func(string, int) bool)) Go vet failed. H 4 e 3 l 2 l 1 o 0 Program exited. range over funcを用いる利点 シンプルで標準的なイテレータを実装できる 私たちがrange over funcを用いる利点としてはやはり、 range を介したシンプルで標準的なイテレータを実装できることです。 例えば、二分木のイテレータを実装したいとします。現在のGoでは次のようになるでしょう。 [Go Playgroundで実行する] package main import "fmt" type binaryTreeNode struct { v int left *binaryTreeNode right *binaryTreeNode } func (btn *binaryTreeNode) getIterator() iterator { return iterator{ stack: []*binaryTreeNode{btn}, } } type iterator struct { stack []*binaryTreeNode } func (i *iterator) hasNext() bool { return len (i.stack) > 0 } func (i *iterator) getNext() binaryTreeNode { var btn *binaryTreeNode btn, i.stack = i.stack[ len (i.stack)- 1 ], i.stack[: len (i.stack)- 1 ] if btn.right != nil { i.stack = append (i.stack, btn.right) } if btn.left != nil { i.stack = append (i.stack, btn.left) } return *btn } func main() { bt := binaryTreeNode{ v: 1 , left: &binaryTreeNode{ v: 2 , left: &binaryTreeNode{ v: 3 , left: &binaryTreeNode{ v: 4 , left: nil , right: nil , }, right: nil , }, right: nil , }, right: &binaryTreeNode{ v: 5 , left: &binaryTreeNode{ v: 6 , left: nil , right: nil , }, right: &binaryTreeNode{ v: 7 , left: nil , right: nil , }, }, } iter := bt.getIterator() for iter.hasNext() { btn := iter.getNext() fmt.Println(btn.v) } } 気になるのは次の点です。 イテレーションの状態を管理する必要があるので、イテレータが別途必要であり実装が複雑である デザインパターン(Iterator)に則ったインターフェースにしているとはいえ、将来の読み手に使い方や意図が伝わるか心配がある これをrange over funcで書き直すと、次のようになります。 [Go Playgroundで実行する] // GOEXPERIMENT=rangefunc package main import "fmt" type binaryTreeNode struct { v int left *binaryTreeNode right *binaryTreeNode } func (btn *binaryTreeNode) all(yield func (binaryTreeNode) bool ) { if btn == nil { return } if !yield(*btn) { return } btn.left.all(yield) btn.right.all(yield) } func main() { bt := binaryTreeNode{ v: 1 , left: &binaryTreeNode{ v: 2 , left: &binaryTreeNode{ v: 3 , left: &binaryTreeNode{ v: 4 , left: nil , right: nil , }, right: nil , }, right: nil , }, right: &binaryTreeNode{ v: 5 , left: &binaryTreeNode{ v: 6 , left: nil , right: nil , }, right: &binaryTreeNode{ v: 7 , left: nil , right: nil , }, }, } for btn := range bt.all { fmt.Println(btn.v) } } このバージョンには次のような利点があります。 yield func(binaryTreeNode) bool を介してforループとイテレータをシーケンシャルに行き来することから状態管理が不要であるため、イテレータを別途設ける必要がなく実装がシンプルである range を介したシンプルで標準的なインターフェースにより、使い方や意図が明らかである 将来的に、各種ライブラリが range を介したシンプルで標準的なイテレータを実装するようになれば、利用者としてもその恩恵を受けることができるでしょう。 おわりに Go 1.22がリリースされrange over intが使用できるようになったら、既存のforループをゴリゴリ書き換えて可読性を高めていきたいと思っています。 また、最終的にどのような形に落ち着くかはまだ確定していないものの、もしrange over funcが正式に追加されたら、既存の各種ライブラリに大きな変化を促すGenerics以来の機能追加になると思います。こういった大きな変更にリリース前から触れて慣れておくことができるのは、オープンに開発されているメリットだと感じます。 いやあ、やっぱりGoっていいですね。 Appendix: range over funcを理解するコツ この記事を書くにあたって、range over funcについてきっちり調べたのですが、実は、range over funcに触れたのは今回が初めてではありませんでした。以前とある勉強会で登壇者が紹介しているのを聞いたのがファーストコンタクトだったのですが、その時は仕組みを理解できませんでした。 きっとrange over funcを理解するのが難しいというのは私に限ったことではないと思うので、最後におまけとしてこれを理解するコツを紹介したいと思います。 range over funcを理解するコツは、 これは被訪問者側に主導権があるVisitorパターンだと思ってコードを読んでみること です。 デザインパターンのVisitorは、被訪問者クラスがVisitorクラスを受け入れ、それによりVisitorクラスが被訪問者クラスの構造を巡りながら処理を行うパターンです。 range over funcにおけるVisitorは、上の二分木の例における yield です。この yield というVisitorが引数という玄関から all メソッドというイテレータに訪問し、値を受け取ってはループに送り込んでいます。 ただし、本家のVisitorパターンと異なるのが、range over funcにおけるVisitorは構造を巡る主導権を持たないということです。次の値、次の値と構造を巡る実装が書かれているのは被訪問者側である all メソッド側です。言うなれば、訪問者 yield は引数という玄関から入ったのち、主の all に手を引かれて屋敷の中を案内され、次から次へとお土産を持たされるイメージです。そして、 all が渡すべきお土産が尽きるか、 yield がこれ以上はいらないと言うかして( break )訪問が終了するというわけです。 なんとなくお分かりいただけたでしょうか? 理解の助けになったならば幸いです。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2023 の 8 日目です。 トモニテのiOSアプリは今年、トモニテ妊娠アプリの開発を期にSPMを用いたマルチモジュール構成に移行しました。 これらのアプリにはアカウント管理やデザインシステムなど共通部分が多くあります。また一部機能は重複しているため、コード共通化をしやすくするのが主目的でした。 この記事ではマルチモジュール構成への移行をどのように進めたかと結果について書きたいと思います。 コード共通化の方針 以下の選択肢がありました。 A: コード共通化をしない B: 共通部分を別リポジトリに切り出し、アプリもそれぞれ別リポジトリで運用する C: 1プロジェクト(1リポジトリ)で複数アプリを開発する コード共通化にはデメリットもあります。片方のアプリを修正した時もう一方のアプリにも影響する可能性があり、本来は必要のなかった調査や動作確認が必要になるかもしれません。しかし今回は共通部分が多く共通化のメリットの方が大きいと判断したためAは選びませんでした。 Bは、共通部分がアプリ本体と疎で、変更頻度が低ければ良い選択肢だったと思いますが、今回は選びませんでした。 モジュール構成を決める モジュール間の依存が循環しないように関係を整理しつつ、分割方法を決めます。 まず、共通部分を以下のように、レイヤーに沿って分割しました。 Utilities: 便利クラス、Extensionなど Network: 外部通信 Model: モデル Core: Feature間の画面遷移の仕組みなどを提供する 一方、機能ごとに Feature モジュール群を作成しました。 Home: ホーム Media: 記事や記事検索機能など Childcare: 育児記録機能 Babyfood: 離乳食機能 … これらの Feature モジュールは共通モジュール群に依存しますが、各Feature が相互に依存することは禁止しています。 Featureモジュール間の参照をせずに、Feature間の画面遷移を可能にする実装についてはこちらの記事を参考にさせていただきました。 メルペイのスケーラビリティを支えるマルチモジュール開発 この仕組みによってFeatureモジュールの独立性が保たれ、必要なモジュールだけをターゲットに組み込むことができるようになりました。 モジュールを切り出す 方針に沿ってモジュールを作成していきました。他への依存が少ない部分から順番に進める必要があります。Utilities -> Network -> Model -> Core -> 各Feature のような順序です。 Xcodeでプロジェクト内にパッケージを作成し、 Package.swift ファイルにパッケージの定義と依存関係を記述します。 import PackageDescription let package = Package( name : "Network" , platforms : [ .iOS ( .v14 ) ] , products : [ .library ( name: "Network" , targets: [ "Network" ]) ] , dependencies : [ .package ( url: "https://github.com/Moya/Moya.git" , .upToNextMajor ( from: "15.0.0" )) , .package ( path: "Utilities" ) ] , targets : [ .target ( name: "Network" , dependencies: [ "Moya", "Utilities" ] , resources: [ .copy ( "Stab" )] ) ] ) あとはファイルをパッケージの中に移動し、外部から参照される宣言をpublicにするなど、アクセス修飾子を修正します。 移動したコードに本来あるべきでない依存があった場合は、依存関係をなくすための修正が必要になる場合も多々あります。 作業量がかなり多くなりますが、一度に終わらせる必要はなくパッケージ単位でリリースが可能です。通常の開発と並行して少しずつ進め、3ヶ月程度かかりました。 開発用の minimumTarget マルチモジュールの利点を活かし、新規機能や新規画面を作る場合の開発効率を上げるために minimumTarget というものを作りました。 開発対象の Feature と最低限の共通部分だけをターゲットに組み込んで開発でき、以下のような利点があります。 ビルド時間短縮 トモニテ本体と比較して、クリーンビルド時間 141 秒 → 38 秒 シミュレータでのデバッグ開始が早い SwiftUIのプレビューを利用可能 本体ではプレビューが表示されるまでの時間が非常に長く実質利用できなかったのが利用可能になります Xcode Cloud デバッグ用の Firebase App Distribution 配布と App Store Connect へのサブミットを Xcode Cloud で自動化していて、リポジトリに変更を加えると二つのアプリが配布/サブミットされます。 結果 これまで書いてきたとおり開発効率を向上できました。 複数のアプリに同様の変更を加える場合、重複した開発をせずに済む モジュール間の依存関係が整理され、コード変更の影響範囲を把握しやすい パッケージ内のファイルは xcodeproj ファイルで管理されなくなるので、 xcodeproj のコンフリクトがほぼ無くなる minimumTarget で時間短縮 一方よくない点もありました。 複数のアプリへの影響を考慮しながら開発する必要がある 本来開発対象ではないアプリへの影響を調査したり、動作確認が必要になる可能性がある しかし全体としては利点が大きく、移行した価値があったと判断しています。 この記事がどなたかの参考になれば幸いです。
アバター
はじめに 目指す状態と現状のギャップを考える 足りない情報を要求・整理 コミュニケーションツール、ドキュメンテーションツールの重要性 契約上の引き継ぎ時点を迎えての作業 Git リポジトリの移管 クラウドベンダー、ドメインレジストラなど各種契約の移管 AWS ドメインレジストラ 各種管理者の認証情報の受領 引き継ぎの後にやったこと クラウドサービスの料金の傾向・コスト構造の確認 手動運用の自動化 監視機構の確認、追加 共同でのトラブル対応 おわりに はじめに エブリーで小売業界向き合いの開発を行っている @kosukeohmura です。 今年、エブリーでは ネットスーパーのシステムを株式会社ベクトルワン様から引き継ぎました 。その裏で、私たちのチームでは知見のないシステムを、自分たちで運用・開発可能な状態にするように様々なことをやってきました。 ここでは every Tech Blog Advent Calendar 2023 の 7 日目の記事として、システムを別の会社から引き継いだ中で考えたこと・やってきたことを紹介したいと思います。今回引き継いだシステムは具体的には Web アプリケーションサーバー、スマホアプリ、複数のスマホアプリ向け API サーバー、及びそれに付随するシステム(非同期処理・バッチ処理基盤、ロードバランサーなど)です。 なお、私は引き継ぎ作業の前段階(デューデリジェンス、大枠のスケジュール策定、契約締結など)が済んで、さあ実際にシステムを引き継ぐぞというところでこのプロジェクトのオーナーとなったので、実際のシステムの引き継ぎ作業に絞ってお話します。 目指す状態と現状のギャップを考える 私はシステムを他社から引き継いだ経験がなく担当となった当初何をしていいかわかりませんでした。両社のソースコードは GitHub で管理されていたので、とりあえず自社の開発メンバーを 外部のコラボレーター として招待していただきました。その後ソースコードをざっと眺めましたが、今回引き継いだネットスーパーのシステムはそれなりに大きく、ソースコードを読み続けてシステムを理解するのは筋が悪そうだとわかりました。 そこで引き継ぎのプロジェクトを通してどうなりたいかを考えだしました。システムを引き継いだらそれは自社のシステムとなります。ということは自社のメンバーで運用を行っていくのはもちろん、障害が起こったら自分たちで復旧し、プロダクトの詳細な仕様やバグへの説明責任も基本的には自分たちが持つことになります。その役割が果たせる状態と現状とのギャップは何か、それを埋めていくには何をすべきかを考えていきました。 足りない情報を要求・整理 何もわからないという状態を紐解いていくと、当たり前ではあるのですが自社のプロダクトなら当然知っているような情報が欠如していることに気づきました。例えば次のようなものです。 システムの実現するサービスとユースケース 全体的なシステムの構成、構成要素それぞれの関係 システムと周辺システム、外部のシステムとの関係 ネットワーク、DNS アクセス制御 各種アカウント・認証情報 アプリケーションコード 責務の分割のされ方 アプリケーションコード・テストコード記述のルール・方針 データベースの構造 システムの監視方法、正常性の把握の方法、異常時の対処方法 セキュアな情報の取り扱い方 各種運用フロー QA リリース 手動の作業 etc... これらをリスト化し、それぞれについて情報を要求・整理していきました。知りたい項目についてドキュメントが無いこともありますし、ドキュメントが存在しても断片的、あるいは前提知識を必要としたりします。不足する情報はドキュメントを用意してもらったり、断片的な情報は受け取った後に情報をまとめて包括的に構成します。 この時強く思ったことは、引き継ぎは引き継ぐ側と引き継がれる側が協力して行うプロジェクトであるということでした。引き継がれる側としてはただ情報を待っているのではなく、どんな情報がなぜ必要で、どのようなアウトプットを期待しているのかをなるべく明確に伝える必要があると感じ、一つ一つの項目ごとにどういう状態になれば引き継ぎが完了となるのかの合意を取っていきました。 コミュニケーションツール、ドキュメンテーションツールの重要性 前述の通り、引き継ぎは自社・他社含めた協力プロジェクトであり、その中では多くのやり取りや資料の作成が行われます。社内ならば既定のツールを使用すればいいですが、社外の方とのやり取りでは既定のツールというものがありません。しかしフロー情報とストック情報を記入できるツールの導入は非常に重要です。 私達は共用のコミュニケーションツールとして Slack や Zoom を、ドキュメンテーションツールとして Notion を、タスク管理に Google スプレッドシートを使用しました。いずれのツールも一方が他方の普段使いのツールに相乗りする形を取っています。この場合引き継ぎプロジェクトが終わればツールへの相乗りも終了するので、相乗りしている側は必要な情報をエクスポートすることになります。 契約上の引き継ぎ時点を迎えての作業 引き継ぎのプロジェクトは契約上の引き継ぎ時点より数ヶ月前から始めましたが、実際の移管作業については契約上の引き継ぎ時点の近辺で行います。例えば次のようなことです。 Git リポジトリ移管 クラウドベンダー、ドメインレジストラなど各種契約の移管 各種管理者の認証情報の受領 それぞれについて軽く触れます。 Git リポジトリの移管 GitHub の リポジトリの移譲 作業を行いました。ドキュメントに書いてあることではあるのですが、組織間のリポジトリの移譲には 移譲前のリポジトリに対する管理者権限(または移譲前の組織の管理者権限またはオーナー権限) 移譲先の組織のリポジトリを作成する権限 が必要でした。外部コラボレーターとして移譲前のリポジトリに招待してもらっている引き継ぎ元の開発者アカウントをリポジトリの管理者にしていただき、そのアカウントにて移譲作業を行いました。 リポジトリ移譲後は GitHub 引き継ぎ後にソースコードの内容を質問させていただくことを考え、逆に引き継ぎ元の開発メンバーを外部のコラボレーターとして招待させていただきました。 クラウドベンダー、ドメインレジストラなど各種契約の移管 システムの稼働や運営に必要な契約の付け替えを行います。契約しているサービスによって移管の方法は様々でした。ここでは AWS アカウントとドメインレジストラの移管の方法に触れます。 AWS AWS Organizations のメンバーアカウントを他の組織へ移行する_ Part 1 _ Amazon Web Services ブログ の記事を参考に、メンバーアカウントの移管作業を行いました。 今回は両社ともに組織アカウントを使用しており、また移管対象のメンバーアカウントに移管対象外のシステムが含まれていなかったことから、単にメンバーアカウントを組織アカウントへ移行するのみで済みました。引き継ぎ対象外のリソースが存在するなどで AWS アカウントごとの移管が難しい場合には、前もって計画的にリソースを別の AWS アカウントに移すなどし、AWS アカウント移管を行える状態を作っておくことが必要です。またアカウントごとの移管が現実的ではない場合は、リソース単位で移管を行うことになると思います。 細かい話ですが、AWS アカウントの移行ではその時間を指定するようなことができず、移管作業を行ったタイミングで移管が行われるようです。出来ればとある日時以降の料金の請求がエブリーに対して行われるようにしたかったのですが、その方法は調べた限り無さそうでした。 ドメインレジストラ 引き継ぎ元ではお名前.com が利用されており、エブリーでは別のレジストラを主に使用していたのですが、今回は引き継ぐドメインが十数個と多く、引き継ぎ作業の楽さを優先してエブリーのお名前.com へとドメインを移管しました。お名前.com には お名前 ID 付け替え という機能があり、比較的楽にドメインを移管できました。今後必要となれば社内でお名前.com から普段使いのレジストラへの移管も検討しますが、優先度は低く置いています。 各種管理者の認証情報の受領 各種サーバーの root ユーザーの SSH 鍵や、データベースの root アカウントの認証情報の共有を頂きます。引き継ぎ元の開発者によるアクセスが必要なくなったら、認証情報を変更ができると良いでしょう。 引き継ぎの後にやったこと ここまでで引き継ぎ作業としては終了ですが、引き継いだままの状態では社内の運用のフローとの齟齬が開発時の戸惑いに繋がったり、改めてプロダクトの状態を自社視点でみると最適化すべき部分が見つかります。そのそれぞれを自社の基準や文化に合わせて修正していくことはアジリティやサービス品質の維持・向上に繋がります。 なお、今回は引き継ぎを受けた後にも引き継ぎ元の開発者の方々に一定期間はサポートをいただけることになっています。契約上の引き継ぎ時点を過ぎ、必要な情報を頂いたつもりでも色々な情報が足りていないことに後から気づく場面が多くありましたので、契約等が許す限り一定期間サポートを受けられる体制をつくることが理想だと強く感じます。 次に具体的に引き継ぎを受けた後にやったことをいくつか紹介します。 クラウドサービスの料金の傾向・コスト構造の確認 AWS Web コンソール内の Cost Explorer にて料金の傾向を簡単に把握し、過剰なリソースがないかをざっとチェックしました。実際に、とあるマシンのインスタンスタイプが不自然に大きいことに気づきその変更を行いました。 これについては早くやるほどコストが節約できるので、AWS アカウントの移管が終わり、Cost Explorer が閲覧できるようになった初日に実施しました。結果として月々 6 万円以上のコスト削減に繋がりました。 手動運用の自動化 手動で行われていた運用について自動化出来そうなところがいくらかあったので、運用の背景を引き継ぎ元の開発者に伺いつつ、無理なく出来そうな部分については自動化をすすめています。 一見自動化できそうでも出来ない理由があったりするので、サポートいただけるうちに背景を聞きます。この作業では手動運用が削減できるだけではなく、システムについてより深く知る機会にもなりました。 監視機構の確認、追加 引き継ぎ前から行われているシステムの監視について整理し、社内の基準を鑑みて監視しておきたくなった点については新たに仕組みを導入しています。 合わせて既存の監視機構の通知先を変更し、自社の開発チームで異常に気付けるようにします。 こちらも不明点は引き継ぎ元の開発者に協力をいただき解消していきました。作業を通して、システムの全体への把握を強める機会にもなりました。 共同でのトラブル対応 移管後しばらくしてちょっとしたトラブルが起こりました。ソフトウェアエンジニアにとって、知らないシステムからの大量のアラートほど絶望するものは無いかもしれません。 ここでも引き継ぎ元の開発者にトラブルの概要を伺いながら原因を特定し、スムーズに対応することができました。もし自社のメンバーだけでの対応だったなら原因の特定や対処方針策定に相当な時間がかかっていたと思います。 余談ですが、このトラブルの原因となったバグは、システムの引き継ぎ前から潜んでいたものが偶然引き継ぎ直後に露見したというものでした。心臓に悪いので、もう少し空気を読んで露見するタイミングを選んでほしいものです。 おわりに 「ゼロからはじめるシステム引き継ぎ」と題して、何もわからないところからシステムを引き継いだプロジェクトについて紹介しました。こう書いてみて思ったこととして、今回は他社からシステムを引き継ぐ形でしたが、たとえ社内であっても異動があったり、新しくジョインされた方は何もわからないような状況に置かれる事に気づきました。システムのドキュメンテーションやクラウド料金・監視体制の見直しなんかは継続的に社内でも行っていけると良いなと思いました。 こういったプロジェクトに携わられる方はそう多くないとは思いつつ、どなたかの役に立つと幸いです。お読みいただきありがとうございました。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2023 の 6 日目です。 今回は「DI toolkit samber/do の紹介」と題しまして、 samber/do のざっくりとした紹介と、今後リリースされるであろう次期バージョンでの変更点についてまとめていきます。 samber/do とは samber/do は Go で DI を実現するモジュールのひとつです。 同様の領域では google/wire が一番メジャーでしょうか。 その次に uber-go/fx が使われている印象です。 google/wire は現在はメンテナンスモードで、継続的な開発は行われていません。 uber-go/fx は reflection を利用した高度な機能を提供しているほか、単なるDIツールではなく、アプリケーションフレームワークとしての性格も兼ね備えている点が特徴です。 今回紹介する samber/do は reflection も code generation も用いず、シンプルに依存性の解決のみに注力している点が特徴的なモジュールです。 samber/do の DI 以下、 公式の Quick start より転載です。 import ( "github.com/samber/do" ) func main() { injector := do.New() // provides CarService do.Provide(injector, NewCarService) // provides EngineService do.Provide(injector, NewEngineService) car := do.MustInvoke[*CarService](injector) car.Start() // prints "car starting" do.HealthCheck[EngineService](injector) // returns "engine broken" // injector.ShutdownOnSIGTERM() // will block until receiving sigterm signal injector.Shutdown() // prints "car stopped" } type EngineService interface {} func NewEngineService(i *do.Injector) (EngineService, error ) { return &engineServiceImplem{}, nil } type engineServiceImplem struct {} // [Optional] Implements do.Healthcheckable. func (c *engineServiceImplem) HealthCheck() error { return fmt.Errorf( "engine broken" ) } func NewCarService(i *do.Injector) (*CarService, error ) { engine := do.MustInvoke[EngineService](i) car := CarService{Engine: engine} return &car, nil } type CarService struct { Engine EngineService } func (c *CarService) Start() { println ( "car starting" ) } // [Optional] Implements do.Shutdownable. func (c *CarService) Shutdown() error { println ( "car stopped" ) return nil } 基本的に do.Provide[T any](*do.Injector, func(*do.Injector) (T, error)) で生成関数を登録し、 do.Invoke[T any](*do.Injector) で値を取得するという流れです。 uber-go/fx のように生成関数の引数から reflection で自動的に解釈して依存ツリーを作ってくれるような仕組みはないため、利用側で samber/do に依存した生成関数を定義する必要があります。 samber/do のメリット、デメリット samber/do は非常に小規模なモジュールで、コア機能は依存性を登録することと依存性を解決することのみです。 それゆえに取り回しが非常にしやすく、例えばあるタイミングで依存性ツリーを再構築したい(実例として、Config のホットリロードなど)、と言うようなことも自分でアプリケーションを作り込めば無理なく実現可能です。 しかし、samber/do に依存する生成関数を定義しなければならない点はやや取り回しは悪いと感じるかもしれません。 また、依存性ツリーの構築はコンパイル時ではなく実行時に動的に行われるため、構築されたツリーに瑕疵がないか(登録が不十分で依存性の解決ができない)は利用者が担保する必要があります。 私も実際にテストを書いていて、うっかり生成関数の登録を忘れていて依存性解決の際にエラーになるということが稀にありました。 この辺りをDIツール側で担保してほしいというニーズが強い場合、google/wire や uber-go/fx の方が合っているかもしれません。 私が開発に関わっているプロジェクトでは、今の所規模も小さいこともあってこのようなトラブルは発生していませんが、いずれ向き合わなければならない問題だと認識しております。 近い将来に google/wire と似たようなアプローチで samber/do 向けの依存ツリーを静的に生成するモジュールを作ろうかと考えているところです。 samber/do@v2 samber/do は非常に新しいモジュールです。一般的に、新しいモジュールを導入する場合、それらがどのようにメンテナンスされているかを把握しておくことが重要です。 ところで私は最近 samber/do に次のメジャーバージョン v2 の計画があることに気づきました。 まだ計画段階ですが、いくつかこれが欲しかったんだ!と言う機能が盛り込まれる予定ですので、いくつかピックアップして紹介します。 Scope v2 における目玉となる機能です。 依存ツリーを一つのまとまり(Scope)として、Scope間のツリー構造を構築し、依存性解決の際にScopeツリーを辿りながら値を取得することができるようになります。 これだけ聞くとなんのこっちゃ、と思うかもしれませんが、Java/Spring Boot に慣れている方であれば @ApplicationScoped や @SessionScoped 、 @RequestScoped のように生存時間が異なるオブジェクトを一つの依存性ツリーでまとめて管理できるようになると言えばイメージできるかもしれません。 Java/Spring Boot を触っていない人は全くわからない話で申し訳ありません。実際の サンプルコード があるので、そちらを読むと良いかもしれません。 依存ツリーの明確化 これまでは do.Injector のフィールドにサービスを単純にmapで保持していたのですが、依存ツリーをDAG(有向非巡回グラフ)として明確に保持するように変わります。 これによって実行時にサービス間の依存関係を取得できるようになるため、DI部分で何かトラブルがあった際に問題を特定することが 容易になります 。 循環参照の検出 依存性解決の際に循環参照を検出し、エラーにすることができるようになります。 Transient services 依存性解決のたびに毎回生成関数を呼び出して新たなオブジェクトを生成するサービスを登録することができるようになります。 現時点では以下のように引数なしの関数をサービスとして登録し、依存性解決後に自分で関数を呼び出してオブジェクトを取得する工夫が必要です。 import ( "time" "github.com/samber/do" ) func main() { injector := do.New() do.ProvideNamed(injector, "nowFunc" , func (_ *do.Injector) ( func () time.Time, error ) { return func () time.Time { return time.Now() }, nil }) nowFunc := do.MustInvokeNamed[ func () time.Time](injector, "nowFunc" ) now := nowFunc() println (now.Format(time.RFC3339)) } tag ベースでの依存性注入の自動化 これ はまだ v2 に入るかは不透明ですが、生成関数を毎回自分で書くのはそれなりに面倒なため、uber-go/fx のように reflection を使って自動的に依存性注入を行うヘルパー関数が提供される予定です。 おわりに 今回は samber/do という Go で DI を実現するモジュールについて紹介をしました。 Go は他の言語に比べて DI が採用されるケースが少ない印象です。もちろんDIは銀の弾丸でも、唯一の答えでもなく、採用するかどうかはプロジェクトごとに判断が必要です。 あくまで個人的な意見ですが、今回紹介した samber/do は比較的 Go の思想に寄り添った形で無理なく DI を導入できるバランスのいいモジュールだと思います。 今回の記事がみなさんの参考になれば幸いです!
アバター
こんにちは、トモニテ開発部の Android エンジニアです。 この記事は every Tech Blog Advent Calendar 2023 の 5 日目です。 最近、 Android エンジニアに新たなメンバが増えました。 こんなこともあろうかと作っておいた贈り物としてドキュメントがありますので、どんなものか紹介します。 どんなドキュメントなのか GitHub にあるプロダクトのリポジトリの Wiki に、 アプリの構成 開発の前に 開発時の Q&A File Templates をまとめたものです。 アプリがどんな作りなのか? 開発するにあたり守るべきことはあるか? xxx をやりたいときはどうすればよいか? を知り、今後の開発作業での詰まりポイントを減らしていきたい、という思いから作成しました。 ドキュメントの内容 1: アプリの構成 自分自身、まずはアプリの歩き方を知りたくなるので、アプリの地図たる構成をまとめました。 アプリはマルチモジュールで作成していたため、どこにどんなモジュールがあるのかを列挙しています。 一部抜粋するとこんな感じです。 presentation: プレゼンテーション層 ┣━ common: プレゼンテーション層で使う便利な処理置き場 ┣━ feature: ユーザ向けの機能置き場 ┃ ┣━ home: ホーム ┃ ┣━ article: 記事詳細 ここでは他にも、モジュール間の依存関係や各モジュールの役割も記載し、全体像を把握してもらえるようにしています。 ドキュメントの内容 2: 開発の前に 開発作業を進めるにあたり基本的な情報となるものをまとめています。 内容は、 ブランチの命名ルール アプリ起動のルート(通常のアプリ起動、Push 通知による Notification 経由、Scheme 起動)ごとのエントリポイントの明記 ライブラリは Version catalog でまとめていること 後述の File Templates を使うと楽になる実装があること を記載しています。 これからの開発で意識してもらいたいルールや、覚えておいてもらえると役にたつものたちです。 ドキュメントの内容 3: 開発時の Q&A 現状の設計に対して「こうするとやりたいことが実現できます」といった情報をまとめています。 一例としては、 新機能を作りたい 各レイヤーで実装するガワを作成したり DI モジュールへの登録といった流れを記載 他画面へ遷移したい feature モジュール間で依存を持たないようにしているため、遷移を行うための router について記載 などがあります。 機能開発やメンテナンスを容易に行えるようにという目的での記載です。 ドキュメントの内容 4: File Templates AndroidStudio の Settings にある File and Code Templetes で使っているテンプレートを記載しています。 全社的な AndroidStudio の Settings をエクスポートしたファイルは別途管理されているのですが、こちらはプロダクト固有なので Wiki 中に記載しました。 テンプレートの内容としては、プレゼンテーション層、ドメイン層、データ層で主に実装することになるインターフェースやクラスのガワを出力するというものです。 実装してほしい箇所を todo として出力しているので、そこを埋めるだけで他と同様の作りにできます。 これにより、実装箇所のガイドに沿って作業を進めるだけで自然と同じようなメンテナンスをするだけで OK となる作りを広げることができます。 運用してみてどうだったか ドキュメントを読んだ新メンバに感想を聞いたところ、こちらの期待した通りの役立ち方をしていたようでした。 わかってる人が書いた資料なのでメモを取るよりも正確であり、かつそれを自身の確認するペースで読み込める点が良かったとのことです。 最後に ドキュメントの内容を細かく記載していくとその時の情報としては正しく、あると嬉しいものなのですが、内容のメンテナンスを怠ると嘘つきの書になってしまいます。 定期的に更新タイミングを設けていき、新たなメンバが早めに真価を発揮できる環境を維持していきたいです。
アバター
Playwrightを活用したE2Eテストの導入 はじめに 想定読者 ハンズオンの前提条件 この記事で得られるもの 実行環境 Playwrightを活用したE2Eテストの導入 Playwrightとは Playwrightの特徴的な機能 Test generator UI Mode Watch mode 定義したアクションごとのスナップショット ハンズオン Next.jsをセットアップする Playwrightをセットアップする Next.jsのサンプルアプリケーションを起動する テストコードの作成 テストの実行 GitHub Actionsでテストを実行する playwrightの設定ファイルを変更する テストの結果を確認する 最後に Playwrightを活用したE2Eテストの導入 はじめに この記事は、every Tech Blog Advent Calendar 2023 の4日目の記事です。 tech.every.tv はじめまして。 株式会社エブリー DELISH KITCHEN 開発本部の羽馬( @NaokiHaba )と申します。 今回は、簡単なハンズオンを通して、Playwrightの基本的な使い方を紹介していきます。 実装したソースコードは 以下のレポジトリで公開していますので、興味のある方はご覧ください。 github.com 想定読者 この記事では、以下のような方を想定しています。 playwrightを触ってみたい方。 E2Eテストを導入したい方。 ハンズオンの前提条件 この記事を読む前に、以下の準備をお願いします。 Node.jsのセットアップ お済みでない方は、 こちら を参考にNode.jsをインストールしてください。 GitHubアカウントの作成 GitHub のアカウントをお持ちでない方は、 こちら からアカウントを作成してください。 GitHubリポジトリの作成 お済みでない方は、 こちら を参考に任意のリポジトリを作成してください。 この記事で得られるもの この記事を読むことで、Playwrightを使ったE2Eテストの導入ができるようになることを目指します。 実行環境 Next.js v14.0.3 playwright v1.40.1 Mac OS Sonoma v14.1.1 Playwrightを活用したE2Eテストの導入 Playwrightとは github.com Microsoftが開発したE2Eテストツール Chromium、WebKit、Firefoxを含むすべての最新のレンダリングエンジンをサポートしているNode.jsベースのライブラリ PuppeteerとPlaywrightはほとんど同じチームによって開発されている 以下のブログでPuppeteerとPlaywrightの比較がまとめられていますので、興味がある方はご覧ください。 blog.logrocket.com Playwrightの特徴的な機能 Test generator codegenコマンドを使用してテストジェネレータを実行し、その後にテストを生成したいウェブサイトのURLを入力します。 URLなしでコマンドを実行し、代わりにブラウザウィンドウに直接URLを追加することもできます。 $ pnpm exec playwright codegen demo.playwright.dev/todomvc この画面で任意の操作を行うと、テストコードが自動的に生成されます。 使用してみた感想としては、テストコードを書いたことがない方でも、この機能を使えばテストコードを自動生成できるので、テストコードを書くハードルが下がるのではないかと思います。 UI Mode Playwright v1.32.0 から、UIモードが追加されました。 UIモードは テストを実行したり、デバッグするための機能を提供しています。 $ pnpm exec playwright test --ui 起動に成功すると、以下のような画面が表示されます。 ここからは、使ってみてこの機能が便利だと感じた点を紹介していきます。 Watch mode テストコードの変更を検知して、自動的にテストを実行してくれます。 テストコードを修正して、実行結果を確認するという作業を繰り返す際に便利です。 定義したアクションごとのスナップショット テストコードを実行すると、定義したアクションごとにスナップショットが作成されます。 どのタイミングでテストが失敗したのか・どのような操作を行ったのかなどを確認する際に便利です。 他にも、便利な機能が多数ありますので、 詳しくは、 公式ドキュメント を参考にしてください。 ハンズオン ここからは、PlaywrightをNext.jsに導入してE2Eテストを実装していきます。 あくまで、一例としてNext.jsを利用していますが、その他のフレームワークでも同様の手順で導入できると思います。 Next.jsをセットアップする Next.jsのセットアップ方法は、 こちら を参考にしてください。 ここでは詳細な手順は割愛しますが、 今回は ~/Documents に Next.jsをインストールしています。 # 任意のディレクトリに移動してください $ cd ~/Documents # プロジェクト名は任意のものを指定してください # ここでは、playwright-next-app-sample というプロジェクト名で作成しています $ npx create-next-app@latest $ cd playwright-next-app-sample # pnpm を利用していますが、npm や yarn・bunでも問題ありません。お好きなものを利用してください $ pnpm dev http://localhost:3000/ にアクセスして、以下のような画面が表示されれば成功です。 Playwrightをセットアップする Playwrightをセットアップするには、以下のコマンドを実行します。 詳しくは、 公式ドキュメント を参考にしてください。 $ pnpm create playwright Choose between TypeScript or JavaScript (default is TypeScript) # TypeScript を選択 Name of your Tests folder (default is tests or e2e if you already have a tests folder in your project) # 任意のフォルダ名を入力 (今回は tests を入力) Add a GitHub Actions workflow to easily run tests on CI Install Playwright browsers (default is true) # true を選択 pnpm create playwright を実行すると、以下のようなディレクトリ構成が作成されます。 - tests - tests-example - playwright.config.ts 最後に、テストを実行して以下のような結果が表示されれば成功です。 $ pnpm exec playwright test ➜ playwright-next-app-sample git:(main) ✗ pnpm exec playwright test Running 6 tests using 5 workers 6 passed (4.1s) To open last HTML report run: pnpm exec playwright show-report 実行結果は、 playwright-report ディレクトリに保存されます。 pnpm exec playwright show-report を実行すると、実行結果をブラウザで確認できます。 $ pnpm exec playwright show-report Next.jsのサンプルアプリケーションを起動する 今回は、Next.jsのサンプルアプリケーションを利用してテストを実装していきます。 テストコードを実装する前に、Next.jsのサンプルアプリケーションを起動しておいてください。 # Next.jsをローカルで起動 $ pnpm run dev テストコードの作成 tests/example.spec.ts に以下のテストコードを記述します。 // example.spec.ts import { expect , test } from '@playwright/test' ; // テストコードの実行前にTOPページにアクセスする test .beforeEach( async ( {page} ) => { await page.goto( 'http://localhost:3000' ); } ); test ( 'Get started by editing src/app/page.tsx が表示される' , async ( {page} ) => { await expect (page.getByRole( 'main' )).toContainText( 'Get started by editing src/app/page.tsx' ); } ) test ( 'Docページに遷移できる' , async ( {page} ) => { const page7Promise = page.waitForEvent( 'popup' ); await page.getByRole( 'link' , { name : 'Docs -> Find in-depth' } ).click(); const page7 = await page7Promise; await expect (page7.getByRole( 'heading' , { name : 'Introduction' } )).toBeVisible(); } ) テストの実行 UIモードを利用してテストを実行していきます。 $ pnpm exec playwright test --ui テストの実行結果は、以下のようになります。 コマンドライン上でテストの実行結果を確認することもできます。 $ pnpm exec playwright test Running 6 tests using 5 workers 6 passed (4.2s) To open last HTML report run: npx playwright show-report GitHub Actionsでテストを実行する ここからは、オマケとして GitHub Actionsを利用したワークフローを実装していきます。 Playwright はセットアップ時に、GitHub Actionsの設定ファイルを自動で作成してくれます。 今回は、そのまま利用してテストを実行していきますが、必要に応じてカスタマイズしてください。 name: Playwright Tests on: push: branches: [ main, master ] pull_request: branches: [ main, master ] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 18 - name: Install dependencies run: npm install -g pnpm && pnpm install - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps - name: Run Playwright tests run: pnpm exec playwright test - uses: actions/upload-artifact@v3 if: always() with: name: playwright-report path: playwright-report/ retention-days: 30 playwrightの設定ファイルを変更する playwright.config.ts を編集して、テストの実行前にローカルサーバーを起動するようにします。 複数環境で切り替えたい場合は、環境変数を利用して切り替えることもできます。 // playwright.config.ts export default defineConfig( { /* Run your local dev server before starting the tests */ webServer : { command : 'pnpm run dev' , url : 'http://127.0.0.1:3000' , reuseExistingServer : ! process .env.CI } } ); テストの結果を確認する それでは、ここまでの差分をコミットして、GitHubにプッシュしてください。 GitHub Actionsでテストが実行されていることを確認することができます。 最後に 以上で、Playwrightの基本的な使い方を紹介しました。 Playwrightは、Puppeteerと比較しても遜色ない機能を持っているので、今後はPlaywrightを利用してE2Eテストを実装していきたいと思います。 また、Vscodeに拡張機能が用意されているので、VsCodeユーザーはぜひ利用してみてください。 この記事が、Playwrightを触ってみたい方やE2Eテストを導入したい方の参考になれば幸いです。
アバター
every Tech Blog Advent Calendar 3日目の記事になります! こんにちは 開発本部データ&AIチームでデータエンジニアを担当している塚田です。 今回は、挑戦WEEK中にGitHub Packagesを利用したnpmパッケージの社内利用を行いましたのでその内容についてご紹介します。 挑戦WEEKとは 開発メンバーが通常の各事業部のロードマップから離れ、技術的に何かに集中して挑戦する1週間 としており、 弊社CTOの今井がTech Blogにて説明しておりますので、よろしければ併せてご覧ください。 tech.every.tv エブリーで利用している画像について エブリーでは DELISH KITCHEN 、 トモニテ 、 TIMELINE の3つのメディアを運営しています。 どのメディアも画像を多数利用して構成しており、画像配信基盤はシステムを構成する重要な一機能となっています。 画像配信基盤の問題点 それぞれのメディアで画像処理 → 画像返却 → 配信という処理の流れは共通しています。 ただし、画像処理の部分で画像変換はほぼ共通しているのですが、変換前処理がそれぞれ独自実装されている状況でした。 改善方針 独自処理が発生してしまうのは避けられない部分ではありますが、画像変換に関しては統一できる処理であるため共通化する方針となりました。 前置きが長くなりましたが、画像変換の共通処理のみを行うnpmパッケージとして切り出しGitHub Packagesで社内利用する流れを整備したので、事例として紹介します。 前提 GitHub Organizationを利用している Organizationでなくても実行可能ですが、今回はOrganizationを利用した場合の説明となります 開発端末でNode.jsの実行環境が整備されている 執筆時はNode.js 20.9.0を利用しています package.jsonの作成(初期化) それでは実際にGitHub Packagesに登録するパッケージを作成します。 npm init -y 後述の手順で package.json の変更を行いますのでデフォルトのままで問題ありません。 S3からファイル名を取得する処理を実装 今回はサンプルとして、 Amazon S3に保存しているファイルを取得しデータ返却する を実装します。 パッケージのインストール npm i -D typescript @types/node npm i -S @aws-sdk/client-s3 今回は上記のパッケージをインストールします。 用途に合わせて他のパッケージもインストールしてください。 tsconfig.jsonの作成 npx tsc --init 作成された tsconfig.json を以下のように修正します。 (自動的に作成されたものや今回の説明に必要ないものは削除しています) { " compilerOptions ": { " target ": " es2022 ", " module ": " commonjs ", " rootDir ": " ./src ", " outDir ": " ./dist ", " esModuleInterop ": true , " forceConsistentCasingInFileNames ": true , " strict ": true , " skipLibCheck ": true } } S3ファイル情報の取得ロジック 以下のサンプルプログラムを src/index.ts として作成します。 import { GetObjectCommand , GetObjectCommandOutput , S3Client } from '@aws-sdk/client-s3' export async function getObject ( bucket: string , key: string ) { const params = { Bucket: bucket , Key: key } const command = new GetObjectCommand ( params ) const response = await new S3Client () .send ( command ) const str = await response.Body?.transformToString (); return str } package.jsonの変更 GitHub Packagesに登録できるよう package.json を以下のように変更します。 今回は s3-file-getter というパッケージ名にしています。 GitHubの環境に合わせて {Organization名} と {リポジトリ名} を変更してください。 { " name ": " @{Organization名}/s3-file-getter ", " version ": " 0.1.0 ", " description ": "", " main ": " dist/index.js ", " files ": [ " dist " ] , " scripts ": { " prepublishOnly ": " npm run build ", " build ": " tsc -d " } , " author ": "", " license ": "", " repository ": { " type ": " git ", " url ": " https://github.com/{Organization名}/{リポジトリ名}.git " } , " dependencies ": { " @aws-sdk/client-s3 ": " ^3.450.0 " } , " devDependencies ": { " @types/node ": " ^20.9.0 ", " typescript ": " ^5.2.2 " } } ここで重要なのは name と repository となっており、それ以外は環境によって適宜変更してください。 GitHub Packagesへ登録 GitHub ActionsワークフローYAMLファイルの作成 自動的にGitHub Packagesへ登録させたいので、GitHub Actionsの設定を行います。 GitHubクイックスタート が用意されているので、それを流用し以下の設定を .github/workflows/publish.yml として作成します。 name : npm package publish on : release : types : [ created ] jobs : publish-gpr : runs-on : ubuntu-latest permissions : packages : write contents : read steps : - uses : actions/checkout@v4 - uses : actions/setup-node@v3 with : node-version : 20 registry-url : https://npm.pkg.github.com/ - run : npm ci - run : npm publish env : NODE_AUTH_TOKEN : ${{secrets.GITHUB_TOKEN}} リリースの作成 作成した publish.yml はリリースが新規作成された際に実行される設定となっているため、GitHubでリリースを作成します。 この時 package.jsonのversion が既にGitHub Packagesで登録されている場合エラーとなるため、導入するプロジェクトのリリースフローによって柔軟に変更してください。 今回はリリース前にversionの更新を行なっているものとして運用します。 GitHub Packagesの確認 GitHub Actionsの処理が完了すると以下のように対象リポジトリTOPの右下のPackagesに今回作成したかったnpm packageが登録されています。 今回作成した s3-file-getter のリンクを選択するとパッケージのバージョンなどのサマリーが確認できます。 GitHub Packagesの利用 登録だけしても利用できないと意味がないので利用するために必要な手順を記載します。 ローカルの場合 Personal Access Token (Classic)を作成 GitHub Packagesへの認証を行う ドキュメントが用意されているので、これに従いAccess Tokenを作成します。 パッケージの読み込みができれば良いので、今回Scopeは read:packages のみを選択します。 .npmrc の作成 以下の設定を .npmrc として作成します。 {Organization名} と {作成したAccess Token} を環境に合わせて置換してください。 registry=https: //npm.pkg.github.com/{Organization名} //npm.pkg.github.com/:_authToken={作成したAccess Token} 注意: .npmrc にAccess Tokenを直書きしているので、 .gitignore で管理対象外にするなど誤って公開される状況にならないよう防御策を取るようにしてください。 GitHub Packagesからパッケージのインストール .npmrc の設定をしていてもGitHub Packages以外のパッケージは今まで通りインストール可能です。 GitHubの環境に合わせて {Organization名} を変更してください。 npm i -D typescript @types/node npm i -S @ { Organization名 } /s3-file-getter パッケージの利用 他のパッケージと同様にimportすることで利用できます。 import { getObject } from '@{Organization名}/s3-file-getter' ; const data = async () => { console .log (await getObject ( '{bucketName}' , '{key}' )) } data () GitHub Actionsの場合 GitHub Packagesにアクセスできるリポジトリを設定 パッケージのサマリー画面に Package settings のリンクがあるので、クリックします。 パッケージを利用したいリポジトリを以下の機能から追加することで参照可能な権限を付与できます。 GitHub ActionsのWorkFlowを設定する パッケージを利用するための設定のみ記載しています。 環境に合わせて適宜変更してください。 jobs : sample : steps : - uses : actions/checkout@v4 - uses : actions/setup-node@v3 with : node-version : 20 registry-url : https://npm.pkg.github.com/ - run : npm ci env : NODE_AUTH_TOKEN : ${{secrets.GITHUB_TOKEN}} おわりに GitHub Packagesを利用した、社内でのみ参照可能なnpmパッケージの作成方法と利用方法について一連の流れを紹介しました。 今回のサンプルは公開しても問題ないものですが、共通の処理をパッケージにして利用したいけどロジックは外部に公開したくないなどのシチュエーションで利用できると感じています。 画像配信基盤の改善はデータエンジニアとして関わるのはあまりないことだと思うのですが、挑戦WEEKが良いきっかけとなりました。 この後のAdvent Calendarでも挑戦WEEKに関連する投稿を予定していますので、ぜひチェックしてみてください。
アバター
はじめてのシステムメンテナンスをする君へ はじめに 主にインフラ周りや時折バックエンドでGoを書いているyoshikenです。 この記事はevery Tech Blog Advent Calendar 2023の2日目の記事となります。 昨日の記事は「Next.js + Go + AWS API Gateway で WebSocket API を使って API サーバーからフロントエンドに通知を送る」となります。 tech.every.tv tech.every.tv 対象読者層 インフラレイヤー(バックエンドも含)の知識/経験が少ないエンジニア エンジニアがどういう手順でメンテナンスをするのか知りたいPdM/PjM/PM 本記事の目的 システムメンテナンスについて どのような流れで行うことがあるのか例を示す 気をつけるべき事柄を示す ドキュメンテーションに必要な項目の例を示す 以上を持って、初めてメンテナンスを行うエンジニアが1例を参考にメンテナンスに関するノウハウを得て、0ベースではなく1べースでメンテナンスの"準備"を行えるようにすることを目的とします。 本記事の対象外 システムメンテナンスの具体的な手順やコマンド 使用ツールの説明や使い方 準備の準備 DBのストレージ増設、サーバーOSのアップデート、キャッシュ増設など、システムメンテンスと一概に言っても、その目的や範囲は様々です。 そのため、予めその目的を明確にしておくことで、影響範囲の把握や停止時間の正当性などが明確になります。 また、メンテナンスを一つのプロジェクトとして責任者を立てましょう。 ここでいう責任者は"メンテナンスについて網羅的に把握し、進捗の管理や渉外に立つ人"という意味です。 実際にメンテナンスを行うオペレーターは一人かもしれませんが、事前準備では複数人が関わるため責任の所在が曖昧になることもあります。特に大規模であればあるほど、関わる人間が多く、全体を把握するには複数の人にヒアリングをしないとわからないということが起こりやすいので注意が必要です。 目的・影響範囲・責任者が決まったら、メンテナンスを実施する旨を関係各所へ連絡しましょう。 サービス/アプリケーションに影響があるメンテナンスの場合は、この時点でのおおよその目安で良いので影響する範囲と時間を関係部署に伝えておきましょう。 例. 20xx/xx/xx ~ 20xx/yy/yyの間で 大体3時間前後。 Android/iOS 両OSのモバイルアプリケーションがメンテナンス状態で使用不可になる。ただしwebはアクセス可能。 そうすることで、厳密な日時は決まらなくても影響範囲からメンテナンス実施可能な日時の割り出し、エンジニアが想定する影響範囲から"関係部署としての影響範囲"(連絡する企業やユーザーへ通知手順)などを割り出すことができます。 後述する手順書の作成中に精度がある程度しぼれた影響時間が算出できたら、再度関係部署に連絡し、具体日時を確定させましょう。 こういった"準備の準備"は直接的なメンテナンスの成功に貢献する割合は少ないですが、円滑なメンテンスの実施やその後のメンテナンスを行う際の他部署との連携には大きく貢献するので、メンテナンスの前には必ず行いましょう。 メンテナンス手順書 具体的な準備に取り掛かって行きましょう。 兎にも角にもメンテナンス手順書を作成します。 といってもいきなりはかけないと思いますので目安として以下の項目を書き出していきましょう。 目的と範囲 対象のシステムの構成(図) 手順に必要なツール一覧/インストール方法 実施手順 ロールバック手順 メンテナンスの後に必要な作業 トラブルシューティング 参考資料 それぞれの項目ごとに上から"ロールバック手順"まで、どのような情報があると望ましいか触れていきます 目的と範囲 前章でも触れましたが、メンテナンスの目的と範囲を明確にすることで、メンテナンスの成功基準(ゴール)や影響範囲を明確にすることができます。 この部分が明確ではないと、この後に書いていく情報や手順が正しいのかどうなのかが不明瞭になります。 例えば、「データベースをアップデートする」という情報では "なんのデータベースが" "どのバージョンまで" が不明瞭なので、「(データベース名)をv1.2.3からv4.5.6にアップデートする」というのが目的としては好ましいです。 また、メンテナンスの範囲も明確にしておきましょう。 とはいえ、いきなり最初から範囲がわかるわけでもないので、後述するシステムの構成図などを書きながら範囲を絞っていきましょう。 ではなぜ最初の節に紹介したかというと、 "ドキュメンテーションとして多くの人が必要とする情報ほど前に書く" というのはドキュメンテーションの基本中の基本です。これはメンテナンスに限った話ではないです。 今回の場合は多くの人は手順そのものではなく影響する範囲が、関係するエンジニアや後世にドキュメンテーションを読むであろうエンジニアが一番に見たいのはメンテナンスの前後の状態と予想されるので一番最初の節にに記載しています。 対象のシステムの構成(図) システムの構成(図)を書く目的・必要性ですが、よほど簡素で単純なシステムでない限りインフラレイヤーはごちゃごちゃしています。 「AWSのEC2でサービス立ててます!」といっても、subnetやsecurity group・route table・AZなどなど、実際はさらにRDSやELB、Route53など複雑に絡み合っています。 またインフラレイヤーに限らずアプリケーションレイヤーでも、昨今のマイクロサービス化やモノリシックなアプリケーションでも、複数のサービスが絡み合っていることが多く、暗黙知であったりロストテクノロジーとなっていることが多いです。 そういった状況でレビューをしたりリストアップをすると漏れが生じてしまうので、漏れの可能性を最小限にするためシステムの構成(図)を書くことが望ましいです。 システム構成(図)と表現しているのは、"可能であれば図で表現するのは望ましいが、それに時間をあまりにも割くのが難しいのであれば他でも代用可能"という意味合いで記述しています。 大事なのはメンテンスの対象と関係あるネットワークや通信がどのように絡み合っているかを把握することです。 手順に必要なツール一覧/インストール方法 「メンテナンス手順に含まれているライブラリやコマンドがインストールされていなかったので当日慌ててインストールした」というのはあるあるなお笑い話なんですが、そういったイレギュラーはない事に越したことはないので事前に手を打っておきましょう。 インストールする際は極力バージョンによる挙動の違いをなくすためにバージョンは指定したインストール方法を記載しましょう。 実施手順 ここが一番のボリュームかつ大事な部分になると思います。 手順についてはオペレーティングする人が当日やりやすいを前提に、レビューする人が見やすい形も意識しましょう。 一例として以下のフォーマットを参考にしてみてください 具体作業をわかりやすく一言で 事前確認 作業を行う前にステータスを確認できるCLIコマンドと結果 [ ] 目視の場合はチェック欄があると尚良し 作業 実際の作業を行うCLIコマンド [ ] GUIをポチポチする場合はチェック欄が(ry 事後確認 作業を行ったあとのステータスを(ry [ ] 目視の場合はチェック(ry 具体的な例を出すと、 route53のprivate hosted zoneのレコードを新しいエンドポイントに切り替え 事前確認 dig hoge.local 作業 cloudmapに登録されているインスタンスのCNAME設定を6の手順で作った新クラスターのエンドポイントに変更 https://github.com/org/repo/blob/master/path/to/terraform/service-discovery.tf git checkout feat/update_nanntara__uwaaaa cd path/to/terraform // 差分確認 terraform plan // 反映 terraform apply 事後確認 // login to bastion ssh user@humidaihost -i ~/.ssh/koukaikagi.pem // 向き先確認(手順6のエンドポイントを参照 dig hoge.local // ログイン redis-cli -h hoge.local -c // バージョン確認 > INFO [ ] コンソール上のroute53で向き先が変わっている [ ] privateDNSが新しいエンドポイントの向き先に変わっている このようになります。 これらを作業工程ごとに記載します。 非常にボリューミーになるので必要な情報を簡潔に記載しましょう。 ロールバック手順 「いちばん大事なのは手順だ」と一個前の節で書いたかもしれませんが、この節が一番大事かもしれません。 メンテナンスには失敗がつきものです。こんだけ偉そうに書いてますが打率は7・8割です。 ですが、取り返しの付かない失敗は…まだ片手で数えられるぐらいしかしていません。たぶん。 その失敗に備えるためにいかなる状況でもメンテナンス前に戻せるような手順をいくつか事前にリストアップしておくことが非常に大事です。 ロールバックを行うということは、何かしら失敗をしてしまった後です。精神的に不安定になり、簡単な作業もオペレーションミスしてしまう可能性がぐっと上昇します。ですので粒度は細かく、コマンドをコピペして終わるぐらい詳細に書きましょう。 どうしても都合上非可逆的な作業でロールバックが不可能な場合は"上位の判断者が判断を下し、復旧作業が開始されるまで被害が最小限に済む方法"を記載しておくのが良いです。例えば破壊的変更を含むバッチの停止やデータ書き込みの停止などです。 手順を開発環境で試そう 手順書を作成してく上でわからないことや実際にどういった反応が返ってくるかわからない場合も多々あると思います。そういったときはぜひ開発環境で試してみましょう。 上記以外にも本番環境と限りなく近い状態の環境を用意し手順を再現することで当日にかかる時間の見積もりの精度も向上します 手順書のレビュー 手順が一通り出来上がったら知見がありそうな人にレビューをもらいましょう。 コードレビューと一緒で何回か往復をしてブラッシュアップしていきましょう。 まとめ メンテナンスの手順書を作っていくことで自ずとメンテナンスに必要な準備ができてくると思います。 メンテナンスは事前に共有し、ドキュメンテーションもしっかり書けば、当日はあとは手順書を見ながらコマンドを打つだけです。 よく「メンテナンスやったことないから怖い」という話も聞きますが、それは不明瞭なものに対して抱く恐怖心であり、"メンテナンス"にたいしてではありません。手順書をしっかり書き、リハーサルを重ねることで恐怖心が和らいでいきます。正しく恐れていきましょう。 ここまで書いてあれですが、当日はきっと手順書に書いてない想定外のトラブルが起こることが多々あるでしょう。 そういった場合に備えて日常で発生するシステムのアラートに対して積極的に対応して行きましょう。そういった場で得た経験はきっとメンテンス中のトラブル対応として生きていきます。 それでは皆さんよきメンテナンスライフを!
アバター
Next.js + Go + AWS API Gateway で WebSocket API を使って API サーバーからフロントエンドに通知を送る はじめに こんにちは トモニテ でバックエンド周りの開発を行っている rymiyamoto です。 エブリーとして初の試みとなる Tech Blog Advent Calendar 2023 の 1日目の記事として参加させていただきました。 毎日他の記事も公開されるので、ぜひチェックしてみてください! tech.every.tv 今回ですが Next.js + Go + AWS API Gateway で WebSocket API を使ってみたのでその内容を紹介していきます。 経緯 トモニテ では現在、バックエンドは Go でフロントエンドは React(Next.js) で開発を行っています。 フロントエンドとバックエンドの通信は REST API で行っていましたが、エンドユーザーの行動に対してダッシュボードを利用しているユーザーに即時性のある通知機能を実装する必要が出てきたため、WebSocket API を使ってみることにしました。 現状 API サーバーは ECS 上で動いており、API サーバー側で WebSocket API を実装するのは少し手間がかかるため、AWS API Gateway で WebSocket API を実装することにしました。 WebSocket API とは ユーザーのブラウザーとサーバー間で対話的な通信セッションを開くことができるものです。 サーバーにメッセージを送信したり、応答をサーバーにポーリングすることなく、イベント駆動型のレスポンスを受信することができます。 developer.mozilla.org 今回の実装 元々ある ECS 環境(API サーバー・dashboard・web)から API Gateway で WebSocket API を利用できるように各種 Lambda を作成しました。 また裏側では Lambda から RDS への接続を行いたいため、RDS Proxy を利用しています。 構成図 流れとしては以下のようなフローです。 dashboard(FE)で WebSocket API を利用するためのクライアントを作成してコネクション確立 web(FE)で API サーバーに対してリクエストを送った際に、通知を行う Lambda を呼び出し API Gateway を通して dashboard に通知が飛ぶ API Gateway の設定 ともかく WebSocket API を利用できるように API Gateway を作成します。 API Gateway において、どのリクエストに対してどの操作を行うかを決定するルート式を指定します。 今回は特別に指定もいらないので $request.body.action としておきます。 WebSocket API を使うための API GateWay の作成 以降の部分は特に指定がなければデフォルトのまま作成していきます。 このとき、ルートに $connect と $disconnect が追加されますが、これらは接続と切断時のルートとなります。 IAM Role の作成 実行用の Lambda の Role API Gateway や SecretManager(RDS Proxy 周りの機密情報の管理) を利用するために Role を作成します (以降 web-socket-lambda-role とします) その時に必要となるポリシーは以下です。 { " Statement ": [ { " Action ": " secretsmanager:GetSecretValue ", " Effect ": " Allow ", " Resource ": " * ", " Sid ": " GetSecretValue " } , { " Action ": [ " ec2:DescribeNetworkInterfaces ", " ec2:DeleteNetworkInterface ", " ec2:CreateNetworkInterface " ] , " Effect ": " Allow ", " Resource ": " * ", " Sid ": " ManageNetworkInterface " } , { " Action ": [ " logs:PutLogEvents ", " logs:CreateLogStream ", " logs:CreateLogGroup " ] , " Effect ": " Allow ", " Resource ": " * ", " Sid ": " ManageLogGroup " } , { " Action ": " execute-api:* ", " Effect ": " Allow ", " Resource ": " * ", " Sid ": " ExecuteAPI " } ] , " Version ": " 2012-10-17 " } API サーバーから Lambda を呼び出すための Policy 追加 Lambda を呼び出すための Policy を API サーバーの Role に付与します。 その時に必要となるポリシーは以下です。 ( websocket-notification が後に作成される通知用の Lambda の名前です) { " Statement ": [ { " Action ": " lambda:InvokeFunction ", " Effect ": " Allow ", " Resource ": " arn:aws:lambda:ap-northeast-1:111111111111:function:websocket-notification ", " Sid ": "" } ] , " Version ": " 2012-10-17 " } Lambda の設定 今回 WebSocket API を利用するための Lambda は以下の 3 つとなります。 connect: 接続時に API Gateway から呼び出される disconnect: 切断時に API Gateway から呼び出される notification: API サーバーから呼び出されて通知を行う connect やること API Gateway の $connect ルートをイベントトリガーとして設定する クエリにユーザーが特定できるような情報を渡しておく WebSocket の接続 ID を取得して DB に書き込みを行う 内部の処理のイメージは以下です。 package main import ( "context" "log" "net/http" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ) type Response events.APIGatewayProxyResponse func Handler(_ context.Context, request events.APIGatewayWebSocketProxyRequest) (Response, error ) { log.Println( "Begin WebSocket connect" ) log.Println( "ユーザー特定" ) // リクエストのクエリからユーザーを一意に特定できる情報を取得します // 今回はユーザーの識別となるトークンを取得しています(複数タブを識別するため) token := request.QueryStringParameters[ "token" ] // 以下にトークンからユーザーIDを取得 log.Println( "DSNの取得開始" ) // 以下にDNS情報をsecret managerから取得する処理 log.Println( "DBの接続" ) // 以下にDBの接続処理 log.Println( "コネクションIDの保存" ) // リクエストからコネクションIDがとれます connectionID := request.RequestContext.ConnectionID // 以下にコネクションIDの保存処理 log.Println( "End WebSocket connect" ) return Response{StatusCode: http.StatusOK}, nil } func main() { lambda.Start(Handler) } disconnect やること API Gateway の $disconnect ルートをイベントトリガーとして設定する WebSocket の接続 ID を取得して DB から削除を行う 内部の処理のイメージは以下です。 package main import ( "context" "log" "net/http" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ) type Response events.APIGatewayProxyResponse func Handler(_ context.Context, request events.APIGatewayWebsocketProxyRequest) (Response, error ) { log.Println( "Begin WebSocket disconnect" ) log.Println( "DSNの取得開始" ) // 以下にDNS情報をsecret managerから取得する処理 log.Println( "DBの接続" ) // 以下にDBの接続処理 log.Println( "コネクションIDの削除" ) // リクエストからコネクションIDがとれます connectionID := request.RequestContext.ConnectionID // 以下にコネクションIDの削除処理 log.Println( "End WebSocket disconnect" ) return Response{StatusCode: http.StatusOK}, nil } func main() { lambda.Start(Handler) } notification やること 呼び出し時の payload には通知を行うユーザーの ID を含めておく そのユーザー ID からコネクション ID を取得して通知を行う 内部の処理のイメージは以下です。 package main import ( "context" "fmt" "log" "net/http" "os" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/apigatewaymanagementapi" ) type Response events.APIGatewayProxyResponse func sendMessage(ctx context.Context, endpoint, connectionID, message string ) error { cfg, err := config.LoadDefaultConfig(ctx) if err != nil { return err } client := apigatewaymanagementapi.NewFromConfig(cfg, func (o *apigatewaymanagementapi.Options) { o.BaseEndpoint = aws.String(endpoint) }) input := &apigatewaymanagementapi.PostToConnectionInput{ ConnectionId: aws.String(connectionID), Data: [] byte (message), } _, err = client.PostToConnection(ctx, input) return err } type Event struct { UserID uint64 `json:"user_id"` JsonString string `json:"json"` } func Handler(ctx context.Context, event Event) (Response, error ) { log.Println( "Begin WebSocket notification" ) log.Println( "DSNの取得開始" ) // 以下にDNS情報をsecret managerから取得する処理 log.Println( "DBの接続" ) // 以下にDBの接続処理 log.Println( "対象ユーザーのコネクションIDリスト(複数端末や複数タブの都合上)取得" ) // lambda呼び出し時のpayloadには特定させるためのユーザーID、渡したいメッセージのjson入れておきます userID := event.UserID // 以下に対象ユーザーのコネクションIDのリスト取得処理 log.Println( "API Gatewayを経由してWeb Socketのメッセージを送信" ) endpoint := os.Getenv( "API_GATEWAY_ENDPOINT" ) for _, connectionID := range connectionIDs { err = sendMessage(ctx, endpoint, connectionID, event.JsonString) } if err != nil { return Response{StatusCode: http.StatusInternalServerError}, err } log.Println( "End WebSocket notification" ) return Response{StatusCode: http.StatusOK}, nil } func main() { lambda.Start(Handler) } API サーバーで通知 Lambda(notification)を呼び出し API サーバー側で以下の関数を作成し通知を行う Lambda を呼び出すようにします。 package aws import ( "context" "fmt" "os" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/lambda" ) func createClient() (*lambda.Client, error ) { cfg, err := config.LoadDefaultConfig(context.Background()) if err != nil { return nil , fmt.Errorf( "load default config on background context failed. err: %w" , err) } return lambda.NewFromConfig(cfg), nil } func Invoke(payload [] byte ) error { funcName := os.Getenv( "WEBSOCKET_NOTIFICATION_FUNCTION" ) client, err := createClient() if err != nil { return fmt.Errorf( "create lambda client failed. lambda function: %s, err: %w" , funcName, err) } input := &lambda.InvokeInput{ FunctionName: aws.String(funcName), Payload: payload, } _, err = client.Invoke(context.Background(), input) if err != nil { return fmt.Errorf( "call %s failed. err: %w" , funcName, err) } return nil } WebSocket API を使うためのクライアントの作成 WebSocket API を利用するためのクライアントを作成します。 フロントエンドは Next.js で作成しているので、useEffect でコネクション確立を行うようにしています。 "use client" ; import { useEffect , useState } from "react" ; type ApplicationEvent = { message: string ; } ; export default function EventReceiver ( { token } : { token: string } ) { const [ applicationEvent , setApplicationEvent ] = useState < ApplicationEvent >(); // WebSocketのコネクションを張る useEffect (() => { const connectWebSocket = () => { const webSocketURL = process .env.NEXT_PUBLIC_WEB_SOCKET_URL ; if ( ! webSocketURL ) { throw new Error ( "Web SocketのURLが設定されていません。" ); } const ws = new WebSocket ( ` ${ webSocketURL } ?token= ${ token } ` ); ws.onmessage = ( event ) => { const e = JSON .parse ( event.data ); setApplicationEvent ( e ); } ; // 勝手に接続切れたときの再接続 ws.onclose = () => { setTimeout ( connectWebSocket , 1000 ); } ; return ws ; } ; const ws = connectWebSocket (); return () => { ws.close (); } ; } , [ token ] ); if ( ! applicationEvent ) { return null ; } return < div > { e.message } < /div >; } (Next.js ver.13 想定のため、12 以下の場合は "use client"; は不要です。) まとめ WebSocket API を利用して API サーバーからフロントエンドに通知を送る方法を紹介しました。 自身で WebSocket API を実装するのは少し手間がかかりますが、API Gateway を使うことで簡単に実装することができます。 今回は複数ブラウザのケースを考慮してユーザーにコネクションを紐付けるようにしましたが、そうでない場合は通知部分にコネクション ID を渡すことでもう少しスッキリかけたと思います。 ただクライアント側で WebSocket のコネクションを張るときに再接続処理を書く必要があるので、プレーンで書くと少し面倒です。 WebSocket API は今回のような通知機能だけでなく、リアルタイムなチャットなどにも利用できるので、今後も様々な場面で利用可能です。 同じような実装してみたい方の参考になれば幸いです。
アバター
こんにちは。2023/10/29~11/1に開催された統計・機械学習系の学会、 第26回情報論的学習理論ワークショップ(IBIS2023) に、弊社データサイエンティストチームでオンライン参加してきました。 チュートリアル や 企画セッション では、2023年の開催ということもありやはり、昨今盛り上がりを見せているLLMが多くテーマとして取り上げられていました。 様々な研究や活用例がわかりやすくかつ多様に紹介されており、オンラインという形ではあったものの、その勢いや盛り上がりを感じることが出来ました。 本記事ではその中から、気になった講演をいくつか紹介していきます。 チュートリアル: 大規模言語モデル活用技術の最前線 稲葉通将様(電気通信大学 人工知能先端研究センター) 資料: IBIS2023チュートリアル「大規模言語モデル活用技術の最前線」 - Speaker Deck この講演では、LLMの効果的な「使い方」に焦点を当て、LLMの性能を最大限に活かすための様々な技術や研究が、具体的な精度検証事例も交えつつ紹介されていました。 (数えたら優に10種類以上ありました) 特に印象に残ったものを以下に抜粋します。 Chain-of-Thought(Wei et al., 2022) LLMに問題を解かせる際、単に回答を出力させるだけでなく、回答に至る思考過程も述べさせるように、(例示などを交えながら)プロンプトを誘導する手法 応用として、同じ指示に対して複数のCoT生成結果を獲得し、それらの結果の多数決を取るアプローチもある(その分コストはかかる) Let’s think step by step(Kojima et al., 2022) プロンプト末尾に「Let’s think step by step.」 を付けるだけでも精度に寄与するというテクニック 例示プロンプトの準備に手間がかかるChain-of-Thoughtの代替アプローチ Tree of Thought(Yao et al., 2023) 複数のプランをLLMに生成させ、LLM自身にそれらのプランを評価させる。そして、高い評価のプランをもとにさらに次のプランを複数生成を繰り返すアプローチ LLMが不得意になりがちな先読みタスクや、探索が重要なタスクに有効。 感想 まず「Chain-of-Thoughtが上手くいく」のは、日頃プロンプトを試行錯誤しながらGPTとお喋りする中で肌感としてなんとなく感じていたことではあります。 しかし、こうした経験則を研究、データとして裏付けた事例を知ることで、よりそのノウハウを自分の中で体系化出来たと感じます。 また、Let’s think step by stepするだけでも良いプロンプトが狙える、Tree of Thoughtで先回りタスクの苦手さに対処するなど、目から鱗なテクニックも知ることが出来、より活用の幅が広がった気がします。 これらのテクニックを、弊社のLLM活用にも是非とも還元していきたいと感じました。 例えば、 ビジネスサイドに向けたChatGPT講習資料※をアップデートする(特にLet’s think step by step等の気軽に試せそうなもの) 社内AI ChatApp(OpenAI APIのGPTをもとに社内Webツール化したもの)のテンプレートとして、これらのテクニックを埋め込む 等々、日頃のビジネス改善に寄与しそうなタネは多く転がっているのではないかと感じます。 ※参考記事 tech.every.tv 企画セッション: 大規模言語モデルとVision-and-Language 西田光甫様(NTT人間情報研究所) 資料: ⼤規模⾔語モデルとVision-and-Language - Speaker Deck この講演では、画像処理と自然言語処理の融合領域に焦点が当てられていました。 代表的な基盤モデル(GPT, CLIP等々)の紹介と共に、大規模モデルが如何にして画像と言語の情報を共に扱えるようになっていったのか、その技術の発展や応用例がとてもわかりやすく紹介されていました。 ここでは、特に興味深かったテーマを2つ掘り下げます。 Visual Instruction Tuning 画像×言語情報を扱える大規模モデルLLaVA: Large Language-and-Vision Assistantが、「複雑怪奇な画像のどこにおかしな点があるか」を的確に答える様子が紹介されていました。 「車の後ろでアイロンをかけているのがおかしい」とLLaVAが答える様子 Liu, H., Li, C., Wu, Q., & Lee, Y. J. (2023). Visual Instruction Tuning. そして、この能力を実現する鍵となったのは、Instruction Tuningの手法を画像処理タスクにも応用したVisual Instruction Tuningでした(Liu et al., 2023)。 Instruction Tuningは、「モデルが人間の指示に従い、未知のNLPタスクを柔軟に解く能力」を得られるように学習させる手法です(Wei et al., 2022)。 これは、ChatGPTにおける指示プロンプト→文章生成をZero-Shotで行う流れに代表されます。 Visual Instruction Tuningでは、画像の情報を文字情報(ex. 画像の内容を説明するキャプション、画像の中にある物体の座標値)に変換します。 そして、このようにして作成した「画像の説明」と、元の「画像特徴ベクトル」の組み合わせをモデルに入力して学習させることにより、LLaVAは画像情報を解析する能力を獲得します。 「画像に説明情報を付加して学習させる」というシンプルな方法により、大規模モデルがまるで視覚を持つかのように多様なタスクに対応できるようになる様に奥深さを感じました。 GPT-4Vの評価論文 2023年9月にChatGPTの画像入力機能として登場したばかりのGPT-4V(GPT-4 with Vision)の潜在的可能性を評価した論文(Yang et al., 2023)の一部が紹介されていました。 ここではGPT-4Vが処理できるタスクとして、以下のような事例が示されていました。 指示されたプロンプトに応じて、画像の中にあるや印や注釈などを読みに行き、そこにある情報を取得&活用できる。 画像として入力された論文を要約できる。数字や一文程度の誤りはあるものの、それ以外は概ね正確な要約を実現できる。 画像のどこに何があるかを物体検出し示せる。しかしその位置座標はまだまだ誤差が大きい。 画像中に示された注釈が、何の物体を指すものか答える様子 Yang, Z., Li, L., Lin, K., Wang, J., Lin, C.-C., Liu, Z., & Wang, L. (2023). The Dawn of LMMs: Preliminary Explorations with GPT-4V(ision) (Version 2). これにより、現状どの程度複雑なタスクを処理できる可能性があるかの勘所を掴んだり、逆にどのようなタスクが苦手で課題があるのかを把握したりすることが出来ました。 企画セッション: テキストから実世界理解に向けて 栗田修平様(理化学研究所AIP) 資料: テキストからの実世界理解に向けて - Speaker Deck 画像と言語の対応付けを実世界へどう応用していくのかの研究に関する講演でした。 画像と言語の対応付けには、画像キャプション生成と画像質問応答が2大タスクであるそうです。 これらよりも細かく画像中の物体とテキストを対応付けられないかについて、近年のVision-and-Language領域の研究について紹介していました。 特に参照表現理解というテキスト表現で参照された物体の座標(Bounding Box)を推測する研究分野について詳しく述べられていました。 具体的な手法としては、MDETR(Kamath et al., 2021)とGLIPv2(Zhang et al., 2023)などがあり、MDETRは名詞句に対応づいた画像中の物体を検出するもので、GLIPv2は画像内部の対照学習(MDETR)と他の画像との対照学習(CLIP)を組み合わせた手法だそうです。 参照表現理解の例 Plummer, B., Wang, L., Cervantes, C., Caicedo, j., Hockenmaier, J., & Lazebnik, S. (2016). Flickr30k Entities: Collecting Region-to-Phrase Correspondences for Richer Image-to-Sentence Models 講演の後半には、実世界への応用について述べられていました。 参照表現理解は、テキストで参照された物体を画像から探すものですが、これは暗黙的に画像内にその物体が存在することを前提としていると述べられていました。 そのようなケースは確かに実世界だと限定的だなという印象を受け、課題感について納得できました。 この課題解決に向けて、物体が存在しないことも考慮できるようになれば、参照物体が画像内に存在するかの判定機としても動作すると述べられており、非常に興味深いと思いました。 将来的にロボットやドローンによる災害救助などへの応用できれば、非常に有用な技術となりそうだなと感じました。 企画セッション: 作業動画と手順書を対象としたマルチモーダル理解 西村太一様(京都大学大学院 情報学研究科 (現: LINEヤフー株式会社)) 資料: 作業動画と手順書を対象としたマルチモーダル理解 - Speaker Deck 動画と言語から学習するVision-and-Language領域で、動画としては作業動画を、言語としては人手で作成した手順書や音声書き起こしを活用した研究に関する講演でした。 この研究では、手順書はノイズが少なく、音声書き起こしはノイズが多いという特性があります。 アノテーションには時間区間のアノテーションと区間ごとの文を付与するといった手法が使われるため、手順書だとデータ量が少なくなり、音声書き起こしだと大規模なデータを学習させることができるそうです。 応用研究として、料理動画からレシピを生成する研究(Nishimura et al., 2022)や、初期状態と最終状態から中間の動作を推定するProcedure planningといった研究(Chang et al., 2020; Sun et al., 2022)を紹介されていました。 弊社でも料理の手順を動画として提供しているため、この講演に大変興味を持ちました。 料理動画からレシピの生成は、ビジネス的観点からどのように活用するのが良いのか、という視点で考えると、弊社の料理動画はPGC(Professional Generated Content)のため、社内のコンテンツ作成チームの効率化が可能かもしれません。 UGC(User Generated Content)観点では、ユーザが動画を撮ってアップロードするだけでレシピを生成して投稿できるといったシステムが作れれば、YouTubeなどと棲み分けができ、新たな価値を生み出せるではないかと感じました。 また、Procedure planningは料理の活用が困難と述べられていましたが、弊社のような動画の画角が固定されていて、手順をトリミング編集したような動画を使うことができれば、うまく学習できるのかという点についても気になりました。 Procedure planningを実世界へ応用した研究:Planning Transformer (PlaTe)の概要図 Sun, J., Huang, D., Lu, B., Liu, Y., Zhou, B., & Garg, A. (2022). PlaTe: Visually-Grounded Planning with Transformers in Procedural Tasks. 最後に 本記事では、「Vision and Languageの最前線」をテーマとした企画セッションを多く取り上げました。 前述した通り、この領域は、弊社が提供する料理動画メディア『DELISH KITCHEN』とも親和性が高いと感じる部分も多々ありました。 これまで、大量に存在している料理動画&画像データ(原石)を如何にして磨くか、ビジネスの発展やプロダクトの成長に活かしていこうか頭を捻らせていたことがありました。しかし、Vision and Language大規模モデルの出現により「視覚的な料理過程の情報と、それに対応する付帯情報(例えば料理手順説明テキストなど)を丸ごと覚えさせ、その文脈を多様なタスクの解決に発展させる」アプローチが現実味を帯びてきた印象を受けます。 こうした技術の発展にアンテナを張りつつ、我々のプロダクトならではの活路を見つけ出していきたいと思います。
アバター
はじめに こんにちは、 retail HUB で Software Engineer をしているほんだです。 早いもので 2023 年も残り一ヶ月程度となりました。12 月といえばみなさんお待ちかねのクリスマスということで 12/1 から 12/25 を盛り上げるためにエブリー初の Tech Blog Advent Calendar 2023 を開催します! 公開スケジュール Tech Blog Advent Calendar 2023 の公開日と公開内容については下記を予定しています。 アドベントカレンダー記事が投稿され次第リンクを更新していく予定です。 最終日 12/25 は CTO の imakei が記事公開予定です! 公開日 公開内容 2023/12/1 Next.js + Go + AWS API Gateway で Websocket API を使ってAPIサーバーからフロントエンドに通知を送る 2023/12/2 はじめてのシステムメンテナンスをする君へ 2023/12/3 GitHub Packages を利用した npm パッケージの社内利用 2023/12/4 Playwrightを活用したE2Eテストの導入 2023/12/5 新たなチームメンバへの贈り物 2023/12/6 DI toolkit samber/do の紹介 2023/12/7 ゼロからはじめるシステム引き継ぎ 2023/12/8 SPMでマルチモジュール/マルチターゲット開発 2023/12/9 Go 1.22で追加予定のrange over intと、GOEXPERIMENT入り予定のrange over funcを触ってみる 2023/12/10 RDS踏み台サーバをよく見かけるECS Fargate+PortForward+Adhocな機構に変更する 2023/12/11 全社イベント"挑戦week"を実施/運営した所感 2023/12/12 OpenAI Assistants APIを使って分析用SQLを生成してみる 2023/12/13 Retrieval-Augmented Generationを使ってコードの解説を生成してみる 2023/12/14 iOSでGraphQLを使ってみた 2023/12/15 トモニテの Amazon Aurora を MySQL 5.7 から 8.0 へアップグレードした話 2023/12/16 Android でのバーコードリーダー実装について 2023/12/17 View をソフトウェアキーボードに追従させる 2023/12/18 トモニテ相談室におけるTwilioを用いた電話の仕組み 2023/12/19 ネットスーパーアプリにおける GraphQL Mesh を利用した Gateway Server について 2023/12/20 DELISH KITCHEN のレシピレコメンドの立ち上げとこれから Hygen で加速する Next.js App Router 開発 2023/12/21 実務に入る前に理解していたらもう少し開発速度を上げられたかなと思うこと monorepo環境でeslint flat configを導入してみた 2023/12/22 microCMS × Next.js でのキャンペーン LP 制作効率化 新卒1年目Web系エンジニアがChatGPTを利用した社内ChatAppのテンプレート機能の実装に挑戦した話 2023/12/23 IVSを用いたライブ配信 wearOS について 2023/12/24 分析に向けたログ設計の話 AndroidのonResumeの挙動を再現したい 2023/12/25 2023年の振り返りと2024年に向けて 気になる記事があった方はぜひブックマークやシェアお願いします!
アバター
はじめに DELISH KITCHEN 開発部で小売向き合いの開発に携わっている野口です。主に Flutter でのアプリ開発を担当しています。 弊社では retail HUB という小売向けのサービスを行っています https://biz.delishkitchen.tv/retailhub 今回は弊社で開発している retail HUB で作成しているアプリの構成について2つ紹介し、 複数クライアントアプリのつらみとそれぞれのメリット、デメリットを述べたいと思っています。 複数クライアントのアプリ構成とは 上記で紹介している複数クライアントのアプリとは複数のアプリを共通のソースコードで管理するものを想定しています。 イメージとしては、A 社、B 社、C 社に提供する場合、共通項目は使いまわせる構成、アプリ自体は異なる(App Store では別)ので別アプリとして管理できる構成を考えています。また、クライアントごとに独自の機能をカスタマイズすることもあります。 複数クライアントのアプリ構成のつらみ 複数クライアントのアプリ構成には今回のブログのテーマでもあるカスタマイズのつらみがあり、かなり厄介なものになります。 主につらみポイントとしては カスタマイズ要素が増えれば増えるほど管理が大変になる クライアントによってカスタマイズを出しわけないといけない 契約終了とかになったら、使用しない機能を削除するかとか になります。コードの管理が大変なんです。一部のクライアントではここは使うけどここは使わないとか。クライアントが数十社とかになったら大変になりそうです。 ということで、以降で2つの構成をざっくり解説してメリット、デメリット話します。 先に結論を話すと筆者的には Melos がいいかなと思ってます。 弊社のアプリ構成例 make を使用したアプリ構成 make を使用したアプリ構成は共通のコードから各クライアントに出し分けるイメージです。 make で CHAIN というクライアントの識別子の変数を指定することでクライアントの出し分けを行います。 Makefile CHAIN := app_A .PHONY: run run: chain-switch flutter run .PHONY: chain-switch chain-switch: ./main $(CHAIN) make run を実行できます。 クライアントを出し分けるコマンドは make run CHAIN=app_B で CHAIN= の後ろが識別子となります。 make run コマンドには以下が指定されており順番に実行されます。実際に出し分けを行う処理は chain-switch で行っており以降で解説します。 chain-switch : 渡された CHAIN に応じて出し分けを行う flutter run : ビルドが実行されるコマンド chain-switch ではCHAINに応じて出し分ける処理を行っています。出し分ける内容はconfig.jsonファイルで設定します。 config.jsonファイルでクライアントごとにアプリ名、bundleId,カラーなどその他(他にもたくさんありますが、省略してます)の項目を設定します。 config.json { " app_A ": { " chainCode ": " app_A ", " appName ": " APP A ", " bundleId ": " app_A.app ", " colors ": { " primaryMain ": " FF9800 " } } , " app_B ": { " chainCode ": " app_B ", " appName ": " APP B ", " bundleId ": " app_B.app ", " colors ": { " primaryMain ": " FF8800 " } } } 取得したCHAINに応じてconfig.json の項目をmain内でshellを使って出し分けています。 main chain = $1 //chainの取得 chain_config_path =config.json  chain_config = $( cat config.json | jq --exit-status ' . ' $chain) || handle_no_chain_error chain_code = $( echo $chain_config | jq -r ' .chainCode ' ) app_name = $( echo $chain_config | jq -r ' .appName ' ) bundle_id = $( echo $chain_config | jq -r ' .bundleId ' ) template_paths = ( ./ios/Flutter/Release.xcconfig.template ./lib/ui/theme/color.dart.template ) replace () { template_file = $( cat $1) template_file = $( echo " $template_file " | sed -e " s/###CHAIN###/ $chain /g " \ -e " s/###APP_NAME###/ $app_name /g " \ -e " s/###BUNDLE_ID###/ $bundle_id /g " \ -e " s/###COLORS_PRIMARY_MAIN###/ $colors_primary_main /g " \ echo " $template_file " > ${1 % .* } } for path in " ${template_paths[ @ ]} " do replace $path done 少し解説を行うと、 ここで chain と config.json の key が一致するものを取得しています。 chain_config = $( cat config.json | jq --exit-status ' . ' $chain) || handle_no_chain_error chain が app_A だと以下が取得できる感じですね。 config.json { " chainCode ": " app_A ", " appName ": " APP A ", " bundleId ": " app_A.app ", " colors ": { " primaryMain ": " FF9800 " } } 続いて、上記で取得した json を各項目を取得して変数に格納します。 main chain_code = $( echo $chain_config | jq -r ' .chainCode ' ) app_name = $( echo $chain_config | jq -r ' .appName ' ) bundle_id = $( echo $chain_config | jq -r ' .bundleId ' ) 出し分ける対象のファイルのパスを template ファイルを作成し定義します。 main template_paths = ( ./ios/Flutter/Release.xcconfig.template ./lib/ui/theme/color.dart.template ) ちなみに template ファイルはこんな感じです。 color.dart.template で###COLORS_PRIMARY_MAIN###部分を変数にして切り替えられるようにしています。 import 'package:flutter/material.dart' ; class AppColor { static const primaryMain = Color ( 0xFF ### COLORS_PRIMARY_MAIN ###); } 対象のファイルの変数を置換します。 置換後の内容を、元のファイル名から .template 拡張子を取り除いた名前の新しいファイルに保存します。 color.dart.template は color.dart のファイルが作成され、実際にはこちらが使用されます。 replace () { template_file = $( cat $1) template_file = $( echo " $template_file " | sed -e " s/###CHAIN###/ $chain /g " \ -e " s/###APP_NAME###/ $app_name /g " \ -e " s/###BUNDLE_ID###/ $bundle_id /g " \ -e " s/###COLORS_PRIMARY_MAIN###/ $colors_primary_main /g " ) //置換 echo " $template_file " > ${1 % .* }  //ファイル作成 } for path in " ${template_paths[ @ ]} " do replace $path done 出しわけの設定の際にconfig.json、templete,mainにそれぞれに出し分ける項目を記述しないといけないのでかなり手間がかかり大変ですね。。 また、実際の出しわけ処理はshellで行っていますが、この処理はわかりずらいかなと思っています。 私はshellがあまり詳しくないのもあると思いますが、新しくプロジェクトに参画するメンバーが出しわけの処理を理解するのに時間がかかってしまうのではと感じています。 カスタマイズは config.json に項目を追加する形になります。 つまり、カスタマイズが増えれば custom1 のようにフラグや何かしらの値が入るようになります。 config.json { " app_A ": { " chainCode ": " app_A ", " appName ": " APP A ", " bundleId ": " app_A.app ", " colors ": { " primaryMain ": " FF9800 " } , " custom1 ": " true " } , " app_B ": { " chainCode ": " app_B ", " appName ": " APP B ", " bundleId ": " app_B.app ", " colors ": { " primaryMain ": " FF8800 " } , " custom1 ": " false " } } Melos を使用したアプリ構成 Melos を使用したアプリ構成を紹介します。 https://melos.invertase.dev/ Melos についてはこの記事が詳しいので気になる方はこちらから https://zenn.dev/altiveinc/articles/melos-for-multiple-packages-dart-projects Melos の構成は各クライアントのアプリから共通のコードを呼ぶイメージです。 以下のように packages の配下に各クライアント向けのアプリ(app_A、app_B、app_C)と各クライアントに対して共通で使用する項目(common)を呼ぶようにしています。 カスタマイズは各クライアントのアプリに格納します。つまり、app_A のカスタマイズは app_A のアプリ内のみで管理することになります。 例えば、クライアントが app_A である場合、app_A から common を呼びます。 packages/app_A/lib/main.dart void main () { runMartApp (config : getConfig ()); } Config getConfig () => Config ( color : appColor, chainConfig : ChainConfig (chainCode : 'app_A' ), //クライアントを識別するための文字 ); // appColorでクライアントごとにカラーコードが指定できる final appColor = AppColor ( primaryMain : const Color ( 0xFFFF9800 ), primaryDark : const Color ( 0xFFFF6D00 ), primaryLight : const Color ( 0xFFFFB74D ),     // 以下省略 ); main()はビルド時に必ず呼ばれるものであり、runMartApp()を呼びます。runMartApp()は common で定義されているものなので、ビルド時に getConfig()で取得した設定情報とともに共通部分を呼ぶようにしています。 packages/app_A/lib/main.dart void main () { runMartApp (config : getConfig ()); } getConfig は Config というアプリの設定情報を定義しています。これによってクラアントごとに共通分の出し分けを行います。 今回は例で appColor というアプリのテーマカラーの設定情報しか取得していませんが、他にも環境情報など出し分けが必要な項目を設定します。 packages/app_A/lib/main.dart Config getConfig () => Config ( color : appColor, chainConfig : ChainConfig (chainCode : 'app_A' ), //クライアントを識別するための文字 ); // appColorでクライアントごとにカラーコードが指定できる final appColor = AppColor ( primaryMain : const Color ( 0xFFFF9800 ), primaryDark : const Color ( 0xFFFF6D00 ), primaryLight : const Color ( 0xFFFFB74D ),     // 以下省略 ); 呼ばれた共通部分の runMartApp() でアプリを実行します。 packages/common/lib/entrpoint.dart void runMartApp ({ required Config config, }) {   // アプリの実行 runApp ( UncontrolledProviderScope ( container : await setupProviderContainer (config : config),  child : const Application (), ), ); } 取得した config は setupProviderContainer によって configProvider という設定項目を状態管理するものに渡して上書きします。 これによって、他の画面でも設定情報を取得することができるようにしています。 Future < ProviderContainer > setupProviderContainer ({ required Config config, }) async { final container = ProviderContainer ( overrides : [configProvider. overrideWithValue (config)], ); return container; } ちなみに、こんな感じで状態管理の configProvider を定義しています。 final configProvider = Provider < Config > ( (ref) => throw UnimplementedError ( 'could not read config, you should set config before read this.' , ), ); class Config { Config ({ required this .color, required this .chainConfig, }); final AppColor color; final ChainConfig chainConfig; } class ChainConfig { ChainConfig ({ required this .chainCode, }); final String chainCode; } Melos を使用しなくてもこちらの構成はできますが、Melosを使用するとパッケージ間のバージョンが異なっても動かせるため、各クライアントのカスタマイズ部分と共通で使用する項目(common)で使用するパッケージのバージョンを頑張って合わせる必要がなくなり、運用が楽になります。例えば、commonのバージョンを上げた場合、他クライアントのバージョンはそのままでも動くため気兼ねなくバージョンを上げることができます。 逆にバージョンを統一した場合はcommonでバージョンを上げた際に他クライアントに影響が出る可能性があり、コードの修正をしないといけないかもしれません。 commonは共通項目でメインで開発する部分なのでバージョンをかなりの頻度で上げていきたいですが、他クライアントに影響があるとバージョンを上げるのに躊躇してしまうと思います。クライアントが増えたら、バージョンの影響範囲がかなり広くなりそうなのでバージョンは各クライアントで管理したいですね。 また、CI/CD のサポートあるのでクライアントのビルドや配布の自動化もできるのでそれも Melos を使用するメリットかなと思います。 双方を使用しての感想 make と Melos を使用して感じたことをまとめたいと思います。 make メリット カスタマイズを他クライアントでも流用できる カスタマイズしたものではあるが、設定項目などを変えれば同じロジックのものをそのまま使える デメリット カスタマイズ要素が増えるとコードが複雑になるためカスタマイズ要素の管理がつらくなる クライアントの追加、削除などで使ってないけどコードはあるみたいことが起きそう、コードを削除するにも他のクライアントで使いそうな場合など判断が難しくなる 出しわけのためにconfig.jsonの記述、mainの記述、template ファイル作るのは手間 手法としては特殊なので他メンバーのキャッチアップに時間がかかる カスタマイズする際に他のクライアントも同じソースなので影響が出る可能性がある テストの工数が増える Melos メリット カスタマイズ要素の管理が楽 カスタマイズは各クライアントのアプリの中に閉じ込める(common には記述しない)ことでカスタマイズ要素の管理をしなくて良くなる 他クライアントの機能に影響が出ない カスタマイズは各クライアントのアプリの中に閉じ込めるので他クライアントに影響が出なくなる デメリット カスタマイズを他クライアントに流用したい場合、実装が必要(コピペはできるので工数は減らせる) 結論 カスタマイズの管理が楽な Melos の方が良いのではないかと思います。 カスタマイズがそのまま流用できないというデメリットはありますが、一番のコード管理の問題が解決できるのでメリットの方が大きいかなと。 ただ、直近は Melos はあまり運用しておらず、 make を主に運用しているためつらみを多く感じているだけで、Melos で気づいていない他のデメリットがあるかもしれません。 よき構成が見つかればまた記事にしたいと思います。 最後までご覧いただき、本当にありがとうございました。
アバター
はじめに エブリーでソフトウェアエンジニアをしている本丸です。 DELISH KITCHENでカナリアリリースの仕組みを作成したので、今回はそのことについて紹介させていただこうかと思います。 カナリアリリースとは カナリアリリースとは、一度に全体に公開するのではなく、最初は一部のユーザーに限定して公開を行い、問題がなければ全体に公開していくリリースの方法です。 カナリアリリースを行うメリットとしては、本番環境で影響範囲は狭めて動作確認ができることや問題が発生した場合にリリース前のサーバに切り戻ししやすいことなどが挙げられます。 やったこと 概要 ECSのserviceを2つ用意しておき、片方のservice(task)の環境変数にflagを持たせます。このflagがtrueかどうかでカナリアリリースしたい機能が呼び出されるようになります。 カナリアリリース時のルーティングにはALBを使用します。ALBではターゲットグループへの重みづけを使って、どの程度カナリアリリース用のserviceにルーティングするかのルールを作成しておきます。 カナリアリリースを行うためのルーティングについてもう少し掘り下げて説明します。 通常のタスク定義 { " family ": " sample ", " cpu ": " 1024 ", " memory ": " 8192 ", " containerDefinitions ": [ { " name ": " sample ", " portMappings ": [ { " hostPort ": 80 , " protocol ": " tcp ", " containerPort ": 80 } ] , " environment ": [ // 通常のタスクではここがfalseになる { " name ": " flag ", " value ": " false " } ] , " secrets ": [] , " volumesFrom ": [] } ] } カナリアリリースのタスク定義 { " family ": " sample ", " cpu ": " 1024 ", " memory ": " 8192 ", " containerDefinitions ": [ { " name ": " sample ", " portMappings ": [ { " hostPort ": 80 , " protocol ": " tcp ", " containerPort ": 80 } ] , " environment ": [ // カナリアリリース用のタスクではここがtrueになる { " name ": " flag ", " value ": " true " } ] , " secrets ": [] , " volumesFrom ": [] } ] } ALBのルーティング カナリアリリースへのルーティングには前述した通りAWSのALBを使用しています。ALBのリスナールールのアクションの項目で、どのターゲットグループにルーティングするかを選択することができるのですが、そのアクションにターゲットグループごとのルーティングされる割合を指定することができます。 Terraformだと下記のようなコードになります。 locals { normal_target_weight = 100 canary_target_weight = 0 } # 動作確認用のルール resource " aws_lb_listener_rule " " canary " { listener_arn = aws_lb_listener.sample.arn priority = 1 action { type = " forward " target_group_arn = aws_alb_target_group.canary.arn } condition { # HTTPヘッダに特定の文字列が入った時にこのルールを適用するようにする http_header { http_header_name = " canary " values = [ " true " ] } } } # カナリアリリースに必要なルール resource " aws_lb_listener_rule " " sample " { listener_arn = aws_lb_listener.sample.arn priority = 2 action { type = " forward " forward { # 通常のサービスに向ける target_group { arn = aws_alb_target_group.sample.arn # 重みづけを行う weight = local.normal_target_weight } # カナリアリリース用のサービスに向ける target_group { arn = aws_alb_target_group.canary.arn # 重みづけを行う weight = local.canary_target_weight } } } condition { path_pattern { values = [ " * " ] } } } カナリアリリースに必要なルールは aws_lb_listener_rule.sample の方です。このリソースのアクションの中にターゲットグループを2つ用意してそれぞれのweightを変更することでルーティングされる割合を制御します。 aws_lb_listener_rule.canary のルールはカナリアリリースしたいserverへの重みづけが0の時に動作確認を行えるように準備しています。 上記の実装では aws_lb_listener_rule.canary のpriorityを1、 aws_lb_listener_rule.sample のpriorityを2にし、 aws_lb_listener_rule.canary にHTTPヘッダに特定の文字列が入るときに適用されるという条件を加えることで制御しています。 問題点 実装や運用をする上で問題になったことがいくつかあるので、共有させていただければと思います。 コスト ECSのserviceが通常のものとカナリアリリース用のもので2つになるので、無計画に運用するとコストが嵩むという問題があります。そこで弊社では、カナリアリリース時以外はカナリアリリース用のserverの台数を0にすることで対応しています。 AutoScaling よく考えると当たり前の話なのですが、AutoScalingを使用していて必要数を低めに設定している場合、割合の切り替えに気をつける必要があります。サービスに影響はなかったのですが、カナリアリリースの導入当初に一度に切り替えを行なってしまいタスクの数が少なく負荷がかかってしまうということがありました。 おわりに 本記事では、ECSとALBを使ってカナリアリリースを行う方法を紹介しました。ECSとALBを使ってカナリアリリースを考えている人の参考になれば幸いです。 ここまで読んでいただきありがとうございました。 参考資料 https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/userguide/how-elastic-load-balancing-works.html https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener_rule
アバター
はじめに はじめまして。2023年6月からエブリーの DELISH KITCHEN 開発部 ユーザーグロースチームで内定者インターンをしている新谷です。 DELISH KITCHENは、「だれでもおいしく簡単に作れるレシピ」を毎日配信するレシピ動画メディアです。食のプロが提案する、家庭にある食材を使った簡単においしくできるレシピをご提案しています。 本記事では、インターンでの業務内容や学んだことについて紹介できればと思います! インターン参加の経緯 現在私は大学院の修士2年で、2024年4月からエブリーに入社することが決まっています。大学院ではハードウェア系の研究を行っており、半導体における製造時のばらつきを機械学習を用いてモデル化する研究をしています。これまで開発の経験としては、Laravelを使ったAPI開発やWordPressのプラグイン開発(PHP)などを行っていました。ただ、規模が大きいサービスの開発にはこれまで携わったことはなく、自分の技術力向上や入社前に少しでも業務に慣れておきたいと思いエブリーのインターンに参加しました。 インターンでの業務内容 ミーティング 業務ではコードを書く以外に、チームで以下のようなミーティングを行いながら開発をしています。 開発ミーティング(毎日) 開発・PdM・デザイナーミーティング(毎日) Web Vitalsミーティング(毎週) KPTミーティング(毎週) 開発ミーティングでは現状の作業内容と進捗を開発のチームメンバーで共有し、技術的な相談なども行います。開発・PdM・デザイナーミーティングでは、KPIの確認を行いその後開発とPdM、デザイナーの間で進捗を共有します。Web Vitalsミーティングでは、直近のWeb Vitalの状況を確認し今後の対応について話し合います。KPTミーティングでは、一週間の振り返りを行い今後の課題や改善点を話し合います。 開発の進め方 開発の進め方としては、まずPdMの方がまとめた要件を読んだり話を聞いたりして、今から行う施策の背景などを把握します。その後、要件をもとにどのような実装が必要かを考えます。例えば、この機能を追加するにはフロント側でどのような実装が必要か、バックエンド側でどのような実装が必要かなどを考えます。この際実装や設計の方針を決める際に、GitHubのIssuesを立てて開発のチームメンバーで相談を行います。その後、実装を行いPull Requestを作成します。Pull Requestは、開発のチームメンバーでレビューを行い、問題がなければ開発環境にマージし、その後問題がなければ本番環境にマージします。 雰囲気 インターンでは週2日リモートで勤務しています。コミュニケーションはテキストですることが多いですが、Slackのハドルで通話を行うこともあり気軽に質問をしています。主にチームのメンバーとPdMの方とコミュニケーションを取っており、皆さんとても優しく働きやすい環境だと感じています。また、社内勉強会も毎週行なっており、業務以外の新しい技術の共有も行ったりしています。 一度出社して勤務したことがありますが、その際はチームでランチを食べに行きました。その際には、業務外の話をすることもありとても楽しかったです。 具体的な業務内容 6月からインターンとして業務に携わって約5ヶ月が経ち、その間にUIの変更からAPIの実装まで幅広く様々な開発を行いました。ここではこれまで行なった開発の内容を一部紹介します。 レシピ作成者をページ上部に表示 DELISH KITCHENの レシピページ では、レシピを作成したフードスタイリストの方の情報を表示しています。 このレシピ作成者の情報はページの下部に表示されています。こちらの情報のうちプロフィール画像と名前、肩書きをページの上部にも表示することになりました。 上記は実際に完成したレシピページ上部の画像です。このレシピ作成者情報は、クリックするとレシピ作成者のプロフィールページに遷移するようにも実装しました。 実装にあたっては、すでにAPIからレシピ作成者の情報を取得する処理が実装されていました。そのため、変更箇所としてはUIの追加のみであったため、実装自体は比較的簡単に行うことができました。ただ、なぜこのような変更を行うのかの意図を理解することは重要だと考えており、実際にエンジニア側にも実装の意図が共有されている状態で開発が行われています。 この変更の意図としては、以下のようなものがあります。 専門家(管理栄養士)が監修した信頼のおけるレシピであることがEAT(Googleが定めたウェブページの品質を評価する際の基準の一つ)の観点からGoogleから評価される可能性がある キーワード「プロ」「本格」系のレシピの根拠となり、他のレシピと比べてユーザーからの信頼度が上がる 私はインターン生ですが、このような意図を理解し実装を行うことができました。これは、エブリーのインターンでは、インターン生にも業務の意図を理解してもらうことを大切にしているからだと思います。 記事内のコンテンツが非公開のとき非公開画像を設定 DELISH KITCHENでは、料理する際に役立つ知見やおすすめレシピなどが書かれた 記事ページ があります。この記事ページでは、該当するレシピや他の記事などへのリンクが埋め込まれています。 この記事内のコンテンツ(レシピや他の記事など)は、入稿した時点では公開状態ですが後から非公開状態になる場合があります。現状このような際は、非表示になるorページに遷移しても該当コンテンツが表示されないという状態になっています。そこで、コンテンツが非公開であることを分かりやすくユーザーに伝えるため、非公開のコンテンツには非公開画像を設定することになりました。 実装として、まずサーバー側のAPIを改修しました。現状記事のコンテンツを取得するAPIでは、コンテンツの種類ごとにレイアウト番号が設定されています。そのため非公開のコンテンツのときは、新たに非公開のレイアウト番号が設定されるように実装しました。以下は実装したAPIのレスポンスの一部であり、非公開のコンテンツのときは layout が 16 となります。 { " data ": { ... " contents ": [ { " num ": 1 , " layout ": 16 } , { " num ": 2 , " layout ": 8 , " recipe_id ": { " id ": 136506859786862859 , " id_str ": " 136506859786862859 " } , " recipe_lead ": " おせちにもおすすめ! ", " recipe_title ": " 筑前煮 ", ... } , ... ] , } } 次に、フロント側の実装を行いました。フロント側では、APIから取得したコンテンツのレイアウト番号をもとに、非公開のコンテンツのときは非公開画像を表示するように実装しました。 技術的な学び インターン期間中、さまざまなタスクを通して多くの技術的な学びがありました。先述した業務やその他の業務を通じて得られた学びについて紹介します。 Nuxt.jsについて DELISH KITCHENのWebはNuxt.jsを使って開発しています。Nuxt.jsはVueのフレームワークで、サーバーサイドレンダリングや静的サイト生成などの機能を提供しています。Nuxt.jsを使うのは初めてだったので、Vue.jsとNuxt.jsについて勉強することから始めました。これまでReact.jsは少し触ったことがあったので、ある程度Vue.jsの理解はできました。ただ、特に難しかったのはNuxt.jsのVuexの理解でした。VuexはVue.jsの状態管理ライブラリで、アプリケーションの状態を一元管理することができます。状態管理ライブラリは使ったことがなかったので大変勉強になりました。 APIサーバーのアーキテクチャについて DELISH KITCHENのAPIサーバーはGolangで開発されています。Golangに関してもこれまで触ったことがなかったので、最初はGolangの勉強から始めました。APIサーバーのコードを読む上で難しかったことは、アーキテクチャの理解です。DELISH KITCHENのAPIサーバーでは、クリーンアーキテクチャが採用されています。クリーンアーキテクチャは、アプリケーションをレイヤーに分割し、各レイヤーの依存関係が内側から外側に向かって一方向になるように設計するアーキテクチャです。 Web Vitalsについて DELISH KITCHENのSEO対策として、Web Vitalsの改善を行っています。 SEOについてあまり知識がなくWeb Vitalsについても初めて知りました。Web Vitalsとは、Googleが提唱するWebサイトのパフォーマンス指標です。Web Vitalsの中でも中心となる3つの指標があります。これをCore Web Vitalsと呼び、以下の通りです。 LCP(Largest Contentful Paint):コンテンツが表示されるまでの時間 FID(First Input Delay):ユーザーの入力に対する反応速度 CLS(Cumulative Layout Shift):コンテンツの安定性 Web VitalsはSEOに影響を与える可能性があり、またユーザーの体験にも影響を与えるため、Web Vitalsの改善は重要です。そのため、日々Web Vitalsの改善に取り組んでいます。 最後に エブリーでは、常に新しい学びがあり業務に携わっていてとても楽しいです。今後もエブリーでの業務を通して、エンジニアとしての技術力を身につけていきたいと思います!
アバター
目次 はじめに リブランディングの懸念点 ドメイン/サービス名変更に伴う差し替えについて リリース作業について まとめ はじめに 初めまして。 2023年4月から新卒エンジニアとして子育てメディア「トモニテ」の開発チームにジョインして、バックエンドやフロントエンドの設計・開発に携わっている庄司( ktanonymous )です。 以前投稿した Go サーバーのメモリリークを調査・改善した話 の記事や snapshotをResult Buildersを使って宣言的に書く の記事の冒頭でも実はお伝えしていたのですが、 2023年8月1日、MAMADAYSはトモニテとして生まれ変わりました。 多様性が広く認められてきている現代において、より多くの人に求められる存在になるべく、新しいスタートをきることにしました。 子育てを通じて、人や社会が「ともに手(トモニテ)」をとる世界を目指して挑戦していきます。 機能観点で言えば、アプリのメイン機能である「育児記録」「妊娠週数管理」「食材リスト」を軸として、 家族やパートナー、家族以外の人や社会との接点を作るためのシェア機能やコミュニティ機能などの拡充をめざしていきます。 リブランディングを実施するにあたり、開発チームでも管理しているソースコードに対して様々な変更を施す必要がありました。 今回必要となった作業を大きく分けると以下のようになりました。 インフラ周りの対応 Google Search Consoleなど、SEO対策 ドメインの変更に伴う差し替え サービス名変更に伴う差し替え 本記事では、筆者自身が主に担当していたという意味合いで、インフラ/SEO周りの対応を除いた2点について主にお話しできればと思います。 リブランディングの懸念点 本題に入る前に、リブランディングの実施に伴う懸念点について簡単に触れていきたいと思います。 今回のリブランディングの実施にあたり、サービスの名称が変更されることになります。 サービス名称の変更に伴い、以下のような懸念点がありました。 既存ユーザー視点で、突然名前が変わることでMAMADAYSが無くなった(「トモニテ」という知らないサービスが出てきた)と思われてしまう恐れがある 検索エンジンによるインデックスが初期化されることで、検索順位が低下してユーザーの流入が大幅に減少する 事前に早い段階でのお知らせが必要ですが、 1. についてはユーザーへのお知らせで対応できる範囲かと思います。我々のビジョンを伝えるためのページ( トモニテについて )も併せて公開しています。 トモニテについて (とはいえユーザーの情報取得タイミングには様々な理由からムラができるもので、実際に8月の名称変更に10月に気づいた方なども見受けられました。← 好印象だったようで我々としては嬉しいお声でした) しかし、 2. については避けようがありません。 Google検索の公式ページにもドメイン変更に伴う影響について下記のような記述があります 1 。 移転中、サイトのランキングが一時的に変動することを想定する。 サイトの大幅な変更があった場合は、Google がサイトを再クロールしてインデックスに登録し直す間にランキングが変動することがあります。 こういった背景もあり、リブランディング作業を実施するにあたり、一定の閲覧数減少は覚悟する必要がありました。 ドメイン/サービス名変更に伴う差し替えについて ドメイン変更に伴う差し替え対応では、主にserver側の実装の中で旧ドメインを利用してパス指定している箇所について、リブランディング後のドメインを指定したURLに差し替えました。 mamadays.tv -> tomonite.com また、サービス名称変更に伴う差し替え対応では、ユーザーの目に触れる部分やwebページのメタデータに含まれるサービス名称をリブランディング後のサービス名称に差し替えました。 MAMADAYS/mamadays/ママデイズ -> トモニテ どちらもソースコード内にある文字列を変更後の名称に置換するだけの作業ということには変わりありませんが、これが思ったよりも曲者でした。 今回の作業では差し替えをせずに後で対応する箇所などが一部にあったことをふまえて、以下のようなことを考えながら作業を進めていました。 差し替えの対象となるものか対象外となるものか リリースタイミングをどう分割するか ユーザーの目に見える形で出す必要性があるためコード上で定数として管理するには不都合なものもあり、ドメインやサービス名称をベタ書きしてしまっている箇所が相当数あるのが実情です。 そのため、ドメイン・サービス名称を差し替える際、しらみ潰しに探し回って1つ1つ差し替えていくというのは非常に面倒な作業になりますし、予期せぬミスの温床にもなってしまいます。 そこで、極力ミスを減らしたいという思いで「mamadays.tv/MAMADAYS/mamadays/ママデイズ」(1回の作業の変更量を抑えるために英大・小文字を敢えて区別しています)のキーワードを それぞれソース内で検索し、「tomonite.com/トモニテ」に一括置換する方法を採用しました。 これにより、差し替え忘れや差し替えに伴うミスを防ぐことができました。 一方で、この方法では、異なる文脈の中で該当するキーワードを利用しているような、差し替え対象外のものも置換されてしまいます。 こちらに関しては、 git add -p (addコマンドのインタラクティブモードの一種、詳細は こちら )で 変更差分の観点から差し替え対象に該当するかどうかを確認して対象を選別する方法を採用しました。 これにより、差し替え対象外のものを1つ1つ探し回って置換を取り消していくのではなく、自動で表示される差分だけを見ながら差し替え対象の仕分けを効率よく行うことができたと思います。 git add -pで「ママデイズ」を「トモニテ」に差し替える例 以上の作業を通して、 1. に関する対策を取りつつも作業量を抑えることができました。 また、 2. については、差し替え作業を一定の基準で分割することで個々のPRが大きくなりすぎないように気をつけていました。 今回の作業では、サーバー側の対応とフロント側の対応で分割、さらに、ドメイン変更対応と名称変更対応で分割するような形としました (実際にはもう少し細かくしている部分もあります)。 リリース作業について 先述のような方針で進めることを想定していたため、実際のリリース作業時には複数のPRを立て続けに開発/本番環境へマージする必要がありました (1つのPRにまとめてからマージしてしまうとPRが肥大化してサービスへの影響度が大きくなり過ぎてしまうと考え、選択肢から除外していました)。 (↑ server 側の1観点からの差し替えPRですが、30ものファイルを変更しています。) 何かしら問題が発生した時の手戻りのコストを抑えるための対応ではありますが、必然的に全てをリリースするまでの手順は増えてしまいます。 そのため、各PRの重要度や依存関係、リリースによるサービスへの影響度の大きさを考慮しながらリリース順を決定する必要がありました。 そんなこんなで、最終的なリリース作業の手順は以下のようになりました。 infra周りの対応(本記事の対象外) フロント側、サービス名称差し替え サーバー側、URL差し替え サイトマップの構築・配置・送信(ここでは ecschedule を利用) フロント側、URL差し替え サーバー側、サービス名称差し替え 実際のリリース作業時には、関係者への作業進捗の報告やこまめな動作確認を挟みつつ作業を進め、 最終的には大きな問題を起こすことなくリブランディング作業を終えることができました。 余談ですが、トモニテではドメイン移行直後のwebのユーザー流入が(予想通り)落ち込んだものの、 徐々にトラフィックが回復して約2ヶ月で移行前の数値を超えていました。 また、 はじめにも軽く触れましたが 、SNS等でユーザーから好意的な声が上がっているなど、 プロジェクト成功と言える結果になったと思います。 まとめ 本記事では、MAMADAYSがトモニテに生まれ変わる裏側のお話をサーバー・フロントエンドの視点からお伝えしてきました。 ここでお伝えした主な作業自体はソースコード内の文字列を置換するだけという単純なものでしたが、「たかが置換、されど置換」という感じで 思いのほか癖のある作業だったなぁと感じています。 サービスのドメイン・名称を変更するというのはなかなか出会う機会のない作業だと思いますが、偶然にも入社して間もないタイミングで このような大きな施策に携わることができたのはとても良い経験になりました。 この記事を読んでいる方が同じような状況になることがあるかどうかはわかりませんが、プロダクトの根幹を変更する作業の大変さの片鱗は感じとっていただけたのではないかと思います。 また、もし同じような状況になった際には、本記事が少しでも参考になれば幸いです。 最後になりますが、子育てを通じて、人や社会がともに手をとる世界を目指して新しいスタートを切ったトモニテにこれからもご注目ください。 最後までお読みいただきありがとうございました。 Google検索セントラル | サイトを移転する方法 ↩
アバター
エンジニアブログタイトル はじめに いきさつ #1:mapをmake関数で作成するとき第2引数sizeを渡す やったこと 解説 #2:(可能なら)mapをsliceで代用する やったこと 解説 補足 おわりに はじめに 株式会社エブリーで DELISH KITCHEN 事業のバックエンドエンジニアをしている、GopherのYuki( @YukiBobier )です。現在は主に 広告サービス を担当しています。 当社の広告サービスには 店頭サイネージ というものがあり、サーバーサイドはGoで実装されています。先日、このサービスの空き広告枠検索アルゴリズムを改善しました。その際に、Goのデータ構造と上手に付き合うことでヒープ使用量を改善できることを実感しました。 本記事では、特に効果があった2つの手法を紹介します。 いきさつ 当社の店頭サイネージは、スーパーの店頭でレシピを放映するのみならず、広告を放映することもできます。店頭サイネージには広告を放映できる時間的な枠が定められており、その枠を必要な数だけ占めるかたちで広告を登録します。したがって、広告を登録する際には、希望するサイネージ×日時に十分な空き枠があるかどうかを確認する必要があります。この時に使用されるのが、空き広告枠検索アルゴリズムです。 従来の空き広告枠検索アルゴリズムは、広告配信期間に比例してDBへのI/Oが多発するものでした。他方で、サービスとしては広告配信期間の単位をより細かくしたい要求があり、これを叶えることはI/Oのさらなる増加を意味しました。そこで、広告配信期間とI/Oの頻度が相関しないアルゴリズムに改善することになりました。 アルゴリズム改善の結果、あるベンチマークテストでは、DBへのI/Oは一定して1回に、さらに実行時間は従来の1/10以下にまで改善されました。しかし、DBからごそっと取得したデータをメモリに乗せて集計するため、ヒープ使用量は従来の40倍に増加しました。ヒープ使用量の増加はGCすなわち"stop-the-world"の頻度を高めてしまいます。そこで、さらにこのアルゴリズムのヒープ使用量を改善することになりました。 その際に、Goのデータ構造と上手に付き合うことでヒープ使用量を改善できることを実感しました。次節から、特に効果があった手法を紹介していきます。 #1: map を make 関数で作成するとき第2引数 size を渡す やったこと 改善後のアルゴリズムでは、DBから取得したデータを集計するためのデータ構造として2重の map を採用していました( map[string]map[string]int8 )。これは、サイネージ×日時ごとの広告枠空き状況を保持するのに都合が良かったからです。 map [ string ] map [ string ] int8 { "2ab91c5f-ac7f-46c8-8040-28d265c71591" : { "2023-10-27 00:00:00" : 2 , "2023-10-28 00:00:00" : 0 , "2023-10-29 00:00:00" : 2 , "2023-10-30 00:00:00" : 3 , ... .. . }, "e3a3ddd9-96b9-43df-a659-574b2f768ba8" : { "2023-10-27 00:00:00" : 1 , "2023-10-28 00:00:00" : 0 , "2023-10-29 00:00:00" : 2 , "2023-10-30 00:00:00" : 7 , ... .. . }, ... .. . } map は make 関数で作成するとき、第2引数 size を渡すことができます。最終的に map に保持することになるであろう要素数を size に渡すようにしたところ、ヒープ使用量が37%削減されました。 解説 runtimeパッケージ によれば、 map は”バケット”の配列を基底とするハッシュテーブルです。バケットとは、キーと値のペアを8個まで保持できる構造です。 map はより多くの要素を保持するために拡大します。つまり、 map に要素を追加することを引き金として、より大きな基底の作成と全要素のコピーが発生することがあります。不要になった元の基底はGCの対象となります。これはアプリケーションのパフォーマンスにとって小さくないペナルティです。 この点、 map を make 関数で作成するとき第2引数 size に数値を渡すと、その数の要素を保持するのに十分な大きさの基底を持つ map が作成されます。要するに、少なくとも要素数が size に満たないうちは、 map の拡大を抑制できるということです。 そのインパクトを実際にコードで確認してみましょう。まずは size を渡さない場合のヒープ使用量を計測します( Go Playground )。 package main import ( "fmt" "runtime" "runtime/debug" ) const count = 1_000_000 func init() { debug.SetGCPercent(- 1 ) // GCが自動で実行されないようにする } func main() { start := allocatedMB() // 開始時のヒープ使用量 m := make ( map [ int ] struct {}) afterMake := allocatedMB() // map作成後のヒープ使用量 for i := 0 ; i < count; i++ { m[i] = struct {}{} } afterAdd := allocatedMB() // mapに要素を追加した後のヒープ使用量 runtime.GC() // GCを手動で実行する afterGC := allocatedMB() // GCが実行された後のヒープ使用量 runtime.KeepAlive(m) // mapがGCの対象にならないようにする fmt.Printf( "作成直後のmapのヒープ使用量: %dMB \n " , afterMake-start) fmt.Printf( "要素追加後のmapのヒープ使用量: %dMB \n " , afterAdd-start) fmt.Printf( "GCされたヒープ量: %dMB \n " , afterAdd-afterGC) fmt.Printf( "最終的なmapのヒープ使用量: %dMB \n " , afterGC-start) } func allocatedMB() uint64 { var ms runtime.MemStats runtime.ReadMemStats(&ms) return ms.Alloc / 1024 / 1024 } 次のような結果が得られるはずです。 map が作成直後の0MBから21MBに拡大し、その間に24MBの”ごみ”がヒープに生じたことがわかります。 作成直後のmapのヒープ使用量: 0MB 要素追加後のmapのヒープ使用量: 45MB GCされたヒープ量: 24MB 最終的なmapのヒープ使用量: 21MB 次に size を渡す場合です( Go Playground )。 package main import ( "fmt" "runtime" "runtime/debug" ) const count = 1_000_000 func init() { debug.SetGCPercent(- 1 ) // GCが自動で実行されないようにする } func main() { start := allocatedMB() // 開始時のヒープ使用量 m := make ( map [ int ] struct {}, count) afterMake := allocatedMB() // map作成後のヒープ使用量 for i := 0 ; i < count; i++ { m[i] = struct {}{} } afterAdd := allocatedMB() // mapに要素を追加した後のヒープ使用量 runtime.GC() // GCを手動で実行する afterGC := allocatedMB() // GCが実行された後のヒープ使用量 runtime.KeepAlive(m) // mapがGCの対象にならないようにする fmt.Printf( "作成直後のmapのヒープ使用量: %dMB \n " , afterMake-start) fmt.Printf( "要素追加後のmapのヒープ使用量: %dMB \n " , afterAdd-start) fmt.Printf( "GCされたヒープ量: %dMB \n " , afterAdd-afterGC) fmt.Printf( "最終的なmapのヒープ使用量: %dMB \n " , afterGC-start) } func allocatedMB() uint64 { var ms runtime.MemStats runtime.ReadMemStats(&ms) return ms.Alloc / 1024 / 1024 } 次のような結果が得られるはずです。 map は作成直後から21MBの十分な容量を持ち、拡大する必要がないのでヒープに”ごみ”は生じませんでした。 作成直後のmapのヒープ使用量: 21MB 要素追加後のmapのヒープ使用量: 21MB GCされたヒープ量: 0MB 最終的なmapのヒープ使用量: 21MB map を make 関数で作成するとき第2引数 size を渡すと、これだけのヒープ使用量の改善につながります。 #2:(可能なら) map を slice で代用する やったこと サイネージ×日時ごとの広告枠空き状況を保持する2重の map ( map[string]map[string]int8 )は、外側のキーがUUIDで、内側のキーが等差な日時でした。ですので内側の map は、インデックス i に”最初の日時+公差× i ”の日時の広告枠空き状況が保持された slice で代用できました( []int8 )。 map [ string ][] int8 { "2ab91c5f-ac7f-46c8-8040-28d265c71591" : { 2 , 0 , 2 , 3 , ...}, "e3a3ddd9-96b9-43df-a659-574b2f768ba8" : { 1 , 0 , 2 , 7 , ...}, ... .. . } #1に加えてこれをしたところ、ヒープ使用量が90%削減されました。 解説 まずは、 map と slice のヒープ使用量の違いが顕著であることを計測によって示します( Go Playground )。 package main import ( "fmt" "runtime" "runtime/debug" ) const count = 1_000_000 func init() { debug.SetGCPercent(- 1 ) // GCが自動で実行されないようにする } func main() { start := allocatedMB() // 開始時のヒープ使用量 _ = make ( map [ string ] int8 , count) afterMakeMap := allocatedMB() // map作成後のヒープ使用量 _ = make ([] int8 , 0 , count) afterMakeSlice := allocatedMB() // slice作成後のヒープ使用量 fmt.Printf( "mapのヒープ使用量: %dMB \n " , afterMakeMap-start) fmt.Printf( "sliceのヒープ使用量: %dMB \n " , afterMakeSlice-afterMakeMap) } func allocatedMB() uint64 { var ms runtime.MemStats runtime.ReadMemStats(&ms) return ms.Alloc / 1024 / 1024 } 次のような結果が得られるはずです。両者のヒープ使用量には顕著な差が見られます。 mapのヒープ使用量: 40MB sliceのヒープ使用量: 1MB なぜこのような差が見られるのでしょうか? もう少し深掘りしてみましょう。 次に示すのは、様々な型の slice と map のヒープ使用量を計測するコードです( Go Playground )。 package main import ( "fmt" "runtime" "runtime/debug" ) const count = 1_000_000 func init() { debug.SetGCPercent(- 1 ) // GCが自動で実行されないようにする } func main() { start := allocatedMB() // 開始時のヒープ使用量 _ = make ([] struct {}, count) afterStructSlice := allocatedMB() // []struct{}作成後のヒープ使用量 _ = make ([] int8 , count) afterInt8Slice := allocatedMB() // []int8作成後のヒープ使用量 _ = make ( map [ struct {}] struct {}, count) afterStructStruct := allocatedMB() // map[struct{}]struct{}作成後のヒープ使用量 _ = make ( map [ string ] struct {}, count) afterStringStruct := allocatedMB() // map[string]struct{}作成後のヒープ使用量 _ = make ( map [ struct {}] int8 , count) afterStructInt8 := allocatedMB() // map[struct{}]int8{}作成後のヒープ使用量 _ = make ( map [ string ] int8 , count) afterStringInt8 := allocatedMB() // map[string]int8{}作成後のヒープ使用量 fmt.Printf( "[]struct{}のヒープ使用量: %dMB \n " , afterStructSlice-start) fmt.Printf( "[]int8のヒープ使用量: %dMB \n " , afterInt8Slice-afterStructSlice) fmt.Printf( "map[struct{}]struct{}のヒープ使用量: %dMB \n " , afterStructStruct-afterInt8Slice) fmt.Printf( "map[string]struct{}のヒープ使用量: %dMB \n " , afterStringStruct-afterStructStruct) fmt.Printf( "map[struct{}]int8{}のヒープ使用量: %dMB \n " , afterStructInt8-afterStringStruct) fmt.Printf( "map[string]int8{}のヒープ使用量: %dMB \n " , afterStringInt8-afterStructInt8) } func allocatedMB() uint64 { var ms runtime.MemStats runtime.ReadMemStats(&ms) return ms.Alloc / 1024 / 1024 } 次のような結果が得られるはずです。 []struct{}のヒープ使用量: 0MB []int8のヒープ使用量: 1MB map[struct{}]struct{}のヒープ使用量: 4MB map[string]struct{}のヒープ使用量: 38MB map[struct{}]int8{}のヒープ使用量: 6MB map[string]int8{}のヒープ使用量: 41MB この結果から次のことがわかります。 map は要素ごとにオーバーヘッドが存在し、それは空の要素( struct{} )であっても避けられない ひとつ前のコードで map と slice のヒープ使用量に顕著な差が見られたのは、キーの型が string だったから map を slice で代用することで、キーが string のような大きな型であった場合には、ヒープ使用量の顕著な改善につながります。そうでなくても、1要素ごとのオーバーヘッドを回避する効果は少なくとも得られます。 補足 map を slice で代用できるかどうかの判断について補足します。 まず大前提として、用途によって判断基準も結論も異なります。総合的にみて map の方が良いことや、 map でなければならないことは大いにあり得ますし、むしろ slice で代用できることの方が稀かもしれません。その上で、判断の一例を示します。 map の代表的な特徴は、高速なルックアップです。公式に言明はされていませんが、そのパフォーマンスはO(1)であることが実装から窺い知れます。ですので、同等のパフォーマンスで slice をルックアップできるかどうかがひとつの判断基準になると思います。本件の場合は下のようにすることで、ある日時に対応するインデックスを”(日時-最初の日時)/公差”で求めることができます。したがってルックアップのパフォーマンスはO(1)なので、 map を slice で代用できました。 // 等差な日時をキーに持つmap { "2023-10-27 00:00:00" : 2 , "2023-10-28 00:00:00" : 0 , "2023-10-29 00:00:00" : 2 , "2023-10-30 00:00:00" : 3 , ... .. . } // インデックス`i`が”最初の日時+公差×`i`”の日時に対応するslice { 2 , 0 , 2 , 3 , ...} その他の map の特徴として、キーの重複を許さないことが挙げられます。そのため、他言語における”Set”のように map を使用することができます。この場合は slice で代用することは難しいでしょう( Go Playground )。 package main import "fmt" type node struct { next *node } func main() { node3 := node{} node2 := node{next: &node3} node1 := node{next: &node2} node3.next = &node1 visited := make ( map [*node] struct {}) for n := &node1; n.next != nil ; n = n.next { if _, ok := visited[n]; ok { fmt.Println( "ループしてます!" ) return } visited[n] = struct {}{} } fmt.Println( "ループしてません!" ) } こういった map の特徴を必要としない場合には、 slice で代用できる可能性は高いと思います。 おわりに もしGoで多量のヒープ使用が観測されるようでしたら、データ構造に着目することが改善の糸口となるかもしれません。 ちなみに、本記事で語った知見は『 100 Go Mistakes and How to Avoid Them 』から学んだところが大きいです。とても勉強になりますし、最近は 邦訳 も出たので、ぜひ読んでみてください。
アバター