TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

176

はじめに 2025年01月21日(火)に開催される「ML@Loft #16 音声基盤モデル」にRevCommプリンシパルリサーチエンジニアの石塚 賢吉が登壇します。 イベント概要 ml-loft.connpass.com 名称: ML@Loft #16 音声基盤モデル 日程: 2025年01月21日 (火) 会場: AWS Startup Loft Tokyo 主催: アマゾンウェブサービスジャパン合同会社 登壇者 石塚 賢吉 株式会社RevComm プリンシパルリサーチエンジニア 筑波大学大学院博士後期課程卒業。博士(工学)。日本HP株式会社にて通信事業者向けのシステム開発、株式会社ドワンゴで全文検索システムの開発などに従事。2019年12月株式会社RevComm入社。音声認識、音声感情認識、全文検索システムの研究開発を行なっている。 → 過去記事一覧 参加登録 参加登録やイベントの詳細などについてはconnpassページより確認いただけます。奮ってご参加ください。 ml-loft.connpass.com
皆さんこんにちは。RevComm の CTO の平村 ( id:hiratake55 , @hiratake55 ) です。今年もあと数日となりました。この記事では、2024 年の RevComm の開発チームの振り返りを行いたいと思います。 この記事は、 RevComm Advent Calendar 2024 の 25 日目の記事です。 1 月: 組織体制変更 1 月には、エンジニア組織の組織変更を行いました。2023 年 12 月まではマトリックス型の組織を採用し、フロントエンドやサーバサイド、インフラ、モバイルなど、それぞれの技術スタックの専門性を活かしながら各開発プロジェクトに所属して開発を進める組織形態でした。 しかし主力の MiiTel Phone に加え、MiiTel Meetings (オンライン会議解析) や MiiTel RecPod (対面商談解析) など製品が増えてきたこともあり、プロダクト単位の開発組織への変更を行い、よりわかりやすい組織体制に変更しました。新しい組織体制ではプロダクト別の組織に加えて、組織横断で最適化を行う CTO 室で構成されています。 2 月: 開発者向けサイト MiiTel Developers を発表 開発者向けサイトの MiiTel Developers を発表しました。2023 年には、Incoming Webhook や Outgoing Webhook を開発者向けにリリースし、MiiTel にデータを登録したり、外部のサービスに連携することが容易になりました。 MiiTel Developers は、このような機能を開発する開発者向けにチュートリアルや API ドキュメントを整備することで、Developer Friendly な製品へ前進しました。 MiiTel Developers 日本語版: https://developers.miitel.com/ MiiTel Developers 英語版: https://developers.en.miitel.com/ www.revcomm.co.jp 3 月: MiiTel Phone Mobile バージョン 3 をリリース MiiTel Phone Mobile バージョン 3 をリリースしました。バージョン 3 では、プラットフォームを Flutter に変更するため、全てのコードを書き直しました。約 1 年間にわたって技術検証と開発を進め、これまで数多く寄せられていた多数の要望にも併せて対応しました。 MiiTel Phone Mobile は、バックグラウンドやロック画面での処理など、VoIP アプリならではの苦労もありましたが、4 名のチームでリリースを成功しました。 4 月: 会話コーチング機能のリリース 「会話コーチング機能」は、生成 AI を活用して、ユーザーの会話の傾向が他のユーザーと比較してどのような状態にあるのかを、システムが自然な文章で会話の改善点を学校の通知表のように届ける機能です。 これまでは、ダッシュボードを操作して傾向を把握する必要がありましたが、このリリースにより自分自身の会話の改善点やよくできている点を簡単に具体的に把握することができるようになりました。 www.revcomm.co.jp 5 月: SMS 機能のリリース MiiTel の SMS 機能は通話終了後や通話中に SMS をユーザーが取引先のお客様に送信できる機能です。また、コールセンターでオペレーターにつながるまでに時間を要する場合や、オペレータの数が限られている場合に、SMS を送信しお客様をお待たせしないようにするための機能です。 この機能の開発は多くのメンバーが関わりました。音声通信システムを開発するチーム、ブラウザ上の通話アプリを開発するチーム、応対履歴データを管理するチーム、料金計算を担当するチーム、キャリアから回線の仕入れを行うチームなど社内の多数のチームがコラボレーションすることで、短期間でリリースを成功させました。 www.revcomm.co.jp 7 月: MiiTel Scan To Call のリリース MiiTel Scan To Call をリリースし、記者発表会を開催しました。MiiTel Scan To Call は、QR コードをスキャンするだけで、通話料無料、アプリのインストールを必要とせず、モバイルブラウザから電話による通話が可能な革新的なサービスです。また、どの媒体を見て発信したかをトラッキングできるため、これまで困難とされていた電話の広告効果測定が可能になりました。 www.revcomm.co.jp 8 月: 全社オフサイトミーティング RevComm では約 260 名の社員がフルリモート・フルフレックスで業務にあたっています。オフサイトミーティングでは、フルリモート・フルフレックス勤務のレブコムにとって、年に1度、全社員が通常業務から離れ、部署を超えたコミュニケーションを取ることのできる貴重な機会です。オフサイトミーティングでは、CEO の會田、経営企画の鈴木、そして CTO の私から、経営や事業に関するプレゼンテーションを行った後、懇親会で交流を深めました。 note.com 10 月: 経団連へ入会 経団連へ入会しました。スタートアップが経団連に入会したというニュースには驚いたメンバーも多く、私から入会目的や狙いを説明しました。 入会の理由としては、国内外の経済動向や政策に関する情報収集を強化し、事業成長を加速させること。また、日本の経済界や各業界のリーダーと連携し、AI や音声テクノロジーを中心としたイノベーションを推進すること。経団連というと、重厚長大系のお堅い企業群というイメージがありますが、ここに新しい風を吹かせることがスタートアップに期待されていること、グローバルで日本を代表して活躍する企業を目指していくことです。 www.revcomm.co.jp 10 月: インドネシア出張 インドネシアのジョグジャカルタで開催された PyCon APAC 2024 で RevComm から 3 名のエンジニアのプロポーザルが採択され、プレゼンテーションを行うためインドネシアへ出張しました。 また、ジャカルタにあるインドネシア子会社の RevComm Indonesia のオフィスにも訪問し、同時にユーザー会のイベントや顧客訪問のため出張で滞在していたプロダクトマネージャーや私も合流して交流会を開催しました。 RevComm Indonesia では販売とサポートを行い、日本のメンバーとはリモートでインドネシアチームと新機能の企画やお客様との対応についてディスカッションを行っていますが、実際に現地にインドネシアの社会課題やカルチャー、テクノロジーの浸透度を肌で感じることができ、モチベーションが高まりました。 note.com note.com 11月: 総務大臣賞受賞 「第18回 ASPIC クラウドアワード 2024」で表彰を受けた全約 130 社中、最高位の総務大臣賞を受賞し、阿達総務副大臣より表彰を受けました。受賞の背景として、ユーザーが最新のテクノロジーを日々のビジネスに活用できるようになっている点、ユーザー数や導入企業数の増加度合い、海外進出をしている点を高く評価されました。 表彰の概要は 総務省のサイト にも掲載されました。 www.revcomm.co.jp 12 月: re:Invent 参加 米国ラスベガスで開催された AWS の re:Invent に 3 名のエンジニアが最新技術の調査のため参加しました。生成 AI やデータベースの新機能、機械学習モデルを効率的に学習・推論するための仕組みについて、これまでリリースされていたものの知らなかった機能や、量子コンピューターやブロックチェーン、スポーツにおける IT の活用、IoT など業務では扱うことない知識を得ることができ、新サービスや既存サービスの効率化を考える上でのインスピレーションになりました。 まとめ 2025 年も魅力的な新サービス、新機能のリリースを予定しています。世界で活用される MiiTel のサービスの開発に興味のある方は、ぜひ応募をお待ちしております。
こんにちは。Corporate Engineeringチーム所属の @mottake3 と申します。本記事は RevComm Advent Calendar 2024 の 24 日目の記事です。 はじめに ツールの説明 実装手順 slack appのインストールとtokenの取得 tokenをSecret Managerに登録 アプリケーションコードの説明 Cloud Runへのデプロイ Event Subscriptionsの設定 Slack Channelへインテグレーションの追加 終わりに 参考 はじめに Slack などのテキストコミュニケーションにおいて、伝えたいことを丁寧な言葉遣いでスムーズに作文するのが難しいことがあります。特に音声入力などでメッセージを作成する場合、丁寧な表現にしようとすると発話数が増えてしまい、入力に時間がかかってしまいます。ChatGPT などを活用して文章を校正している方もいるかと思いますが、複数のアプリ間で作業を切り替えるのは少々手間がかかります。そこでSlack上で画面を切り替えることなく、より簡単に自然で丁寧な文章を Slack に投稿できるようなプチツールをSlack BoltとVertex AIを用いて作成してみました。 注意事項 本記事のコードはあくまでサンプルですので参考程度に御覧ください。 セキュリティなどの考慮についても同様になります。 ツールの説明 特定のスタンプを押すと、Vertex AI上のLLMに文章を校正するプロンプトが投げられ、その結果が新規メッセージとして投稿されます。スタンプを外すと編集前のメッセージは削除されます。編集前と編集後のメッセージを見比べて問題があれば手動で微修正をすることを想定してます。スレッド内のメッセージの場合はそのスレッド内で新規メッセージが作成されます。 アーキテクチャの略図は以下のようになります。長くなってしまうので本記事では赤枠の部分の実装を目標にご説明しようとおもいます。 ※その他の部分に関しては別記事として追ってどこかに掲載しようと考えてます。 アーキテクチャ略図 Cloud Run 実行環境です。利用しないときは0スケールさせてコストを節約することを想定しています。 Slack Bolt Slack Appを簡単につくれるフレームワーク。Websocketを使うmodeもありますが、今回はhttpを使うmodeを使用しています。 Flask PythonのWebフレームワークです。Flask上でSlack Boltを起動しています。 Vertex AI LLMの実行環境です。今回はファンデーションモデルにGemini 1.5 Flashを選んでいます。 SQLite ファイル保存形式の軽量なDBMS。SlackのUser TokenなどのUser情報を保存するために利用しています。 Litestream SQLiteをGCSなどにロジカルレプリケーションできるツール。Cloud Runがゼロスケールした際にSQLiteのDBファイルが破棄されてデータが消えてしまう問題に対処するために利用しています。コンテナがコールドスタートする際にGCSからDBファイルを復元しています。 Cloud RunにGCSをボリュームマウントし、そこにDBファイルを置いてもよかったのですが、レスポンスの速さを考えてDBファイルはコンテナに持たせるようにしました。 BigQuery 分析基盤です。プロンプト・編集前後のメッセージ・ユーザー自身が手動で変更等行い最終確定したメッセージの4つを履歴として保存しておき、Gen AI evaluation service等を利用してプロンプトやファンデーションモデルの評価・改善に利用します。 実装手順 slack appのインストールとtokenの取得 こちら のドキュメントを参考にslack appのインストールとtokenを取得してください。 TokenのScopeは以下のキャプチャのように付与してください。 App home -> App Display Nameで表示名をSaveしないとworkspaceへのAppのインストール時に以下のようなエラーが出るのでお気をつけください。 tokenをSecret Managerに登録 SLACK_BOT_TOKEN と SLACK_SIGNING_SECRET をCloud Runから読み込めるようにSecret Managerへ登録しておきます。 アプリケーションコードの説明 ディレクトリ構成 ├── Dockerfile ├── Makefile ├── main.py ├── requirements.txt ├── slack_util_tools.db main.py import os import logging from slack_bolt import App from slack_bolt.adapter.flask import SlackRequestHandler from flask import Flask, request import vertexai from vertexai.generative_models import GenerativeModel import sqlite3 logger = logging.getLogger(__name__) app = App( token=os.environ.get( "SLACK_BOT_TOKEN" ), signing_secret=os.environ.get( "SLACK_SIGNING_SECRET" ) ) flask_app = Flask(__name__) handler = SlackRequestHandler(app) vertexai.init(project=os.environ.get( "PROJECT_ID" ), location=os.environ.get( "LOCATION" )) model = GenerativeModel( "gemini-1.5-flash-002" ) @ app.event ( "reaction_added" ) def reaction_added (say, event): emoji = event[ "reaction" ] user = event[ "user" ] # 自分のmessageに対してスタンプを押したとき if emoji == "メッセージ編集" and "item_user" in event and user == event[ "item_user" ]: channel = event[ "item" ][ "channel" ] ts = event[ "item" ][ "ts" ] thread_ts = None message_text = None #スタンプを押したmessageの取得 conversations_history = app.client.conversations_history( channel=channel, oldest=ts, latest=ts, inclusive= True ,limit= 1 ) if not conversations_history[ "messages" ]: #スレッド内のmessageへのスタンプだったとき reply_history = app.client.conversations_replies( channel=channel, ts=ts) message_text = reply_history[ "messages" ][ 0 ][ "text" ] thread_ts = reply_history[ "messages" ][ 0 ][ "thread_ts" ] else : #通常のメッセージへのスタンプだったとき message_text = conversations_history[ "messages" ][ 0 ][ "text" ] response = model.generate_content( f """ 以下のメッセージを丁寧にしてください。 候補を出すのではなく最適な1つのメッセージのみを答えてください。 もし相手を傷つけてしまいそうな感情的な文章の場合は、相手を思いやった文章に編集してください。 {message_text} """ ) #DBファイルからuser_oauth_tokenの取得 conn = sqlite3.connect(os.environ.get( "DB_NAME" )) cur = conn.cursor() res = cur.execute( f "SELECT user_token FROM user WHERE user_id = '{user}'" ) user_token = res.fetchone()[ 0 ] conn.close() result = app.client.chat_postMessage( channel=event[ 'item' ][ 'channel' ], thread_ts=thread_ts, token=user_token, text=response.text, ) logger.info(result) @ app.event ( "reaction_removed" ) def reaction_removed (say, event): emoji = event[ "reaction" ] user = event[ "user" ] if emoji == "メッセージ編集" and "item_user" in event and user == event[ "item_user" ]: # DBファイルからuser_oauth_tokenの取得 conn = sqlite3.connect(os.environ.get( "DB_NAME" )) cur = conn.cursor() res = cur.execute( f "SELECT user_token FROM user WHERE user_id = '{user}'" ) user_token = res.fetchone()[ 0 ] conn.close() result = app.client.chat_delete( channel=event[ 'item' ][ 'channel' ], token=user_token, ts=event[ "item" ][ "ts" ], ) logger.info(result) @ flask_app.route ( "/slack/events" , methods=[ "POST" ]) def slack_events (): payload = request.get_json() if 'challenge' in payload: #チャレンジリクエストのとき return payload[ 'challenge' ] else : return handler.handle(request) @ app.middleware def skip_retry (logger, request, next ): if "x-slack-retry-num" not in request.headers: #再送リクエストでないとき return next () # ローカル開発用 if __name__ == "__main__" : flask_app.run(debug= True , host= "0.0.0.0" , port= int (os.environ.get( "PORT" , 3333 ))) 補足が必要そうな部分を説明します。 slack上のメッセージはchannelとtsで特定されます。 メッセージの取得にはconversations.history API、スレッド内のメッセージの取得にはconversations.replies APIを利用する必要があるため、以下のようにhistory APIで取得出来なかった場合にreplies APIに切り替えています。 #スタンプを押したmessageの取得 conversations_history = app.client.conversations_history( channel=channel, oldest=ts, latest=ts, inclusive= True ,limit= 1 ) if not conversations_history[ "messages" ]: #スレッド内のmessageへのスタンプだったとき reply_history = app.client.conversations_replies( channel=channel, ts=ts) 以下のコードのチャレンジリクエストの場合の処理がないと、後ほど説明するslack appへのEvent Subscriptionsの設定時に認証エラーとなってしまいます。 @ flask_app.route ( "/slack/events" , methods=[ "POST" ]) def slack_events (): payload = request.get_json() if 'challenge' in payload: #チャレンジリクエストのとき return payload[ 'challenge' ] else : return handler.handle(request) Slack APIには「3秒以内に応答がないとリトライされる 」という制約があります。 その制約に対処するために以下のようにリクエストヘッダーをみてリトライの場合は処理をしないようにしています。 @ app.middleware def skip_retry (logger, request, next ): if "x-slack-retry-num" not in request.headers: #再送リクエストでないとき return next () Cloud Runへのデプロイ 今回は手動でデプロイします。以下のようなDockerfileとrequirements.txtを用意します。 Dockerfile FROM python:3.12-bookworm ENV PYTHONUNBUFFERED True ENV APP_HOME /app WORKDIR $APP_HOME COPY . ./ RUN pip install -U pip && pip install -r requirements.txt ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app requirements.txt flask google-cloud-aiplatform gunicorn slack-bolt デプロイを実行します。今回はmakeファイルを用意しているので make deploy とコマンドを打てばOKです。 Makefile # 環境変数 PROJECT_ID={プロジェクトID} SERVICE_NAME={サービス名} LOCATION={リージョン} DB_NAME={DBファイル名} IMAGE_NAME=gcr.io/$(PROJECT_ID)/$(SERVICE_NAME) SERVICE_ACCOUNT=${SERVICE_NAME}@$(PROJECT_ID).iam.gserviceaccount.com # シークレット情報 SECRETS=SLACK_BOT_TOKEN={bot tokenを保存しているシークレット名}:latest,SLACK_SIGNING_SECRET={signing secretを保存しているシークレット名}:latest deploy: gcloud builds submit --tag $(IMAGE_NAME) gcloud run deploy $(SERVICE_NAME) --image $(IMAGE_NAME) \ --platform managed \ --service-account $(SERVICE_ACCOUNT) \ --region $(LOCATION) \ --update-secrets=$(SECRETS) \ --set-env-vars "PROJECT_ID=${PROJECT_ID}" \ --set-env-vars "LOCATION=${LOCATION}" \ --set-env-vars "DB_NAME=${DB_NAME}" \ gcloud run deploy コマンドの --update-secrets オプションにシークレットマネージャに保存しているtokenのパスを指定すると、デプロイ時にシークレットの値を環境変数として設定することができます。 Event Subscriptionsの設定 Slack AppのEvent Subscriptionsを有効化します。 Request URLにはCloud RunのURLに /slack/events というディレクトリ名を付与したものを設定してください。 Subscribe to bot eventsには reaction_added と reaction_removed を設定してください。 Slack Channelへインテグレーションの追加 任意のSlack Channelへ作成したSlack Appを追加したら完了です。 追加方法はいくつかあるのですが、追加したいChannelでSlack Appに対してメンションを投げることで追加する方法がお手軽かと思います。 終わりに メッセージの編集を行うプロンプトに以下のような命令を入れていました。 もし相手を傷つけてしまいそうな感情的な文章の場合は、相手を思いやった文章に編集してください。 これは“空気の読めるAI“のようなものが人間同士のコミュニケーションの間に入ってきて、受け手にとって最適な解釈ができるように“いい感じ“にしてくれるのを期待して入れています。今回はテキストコミュニケーションですが、音声コミュニケーションについてもあと何回かブレイクスルーが起きて、同様な事が出来るようになったら面白いのではないかと感じています。 著者自身はAI開発は素人ではありますが、社内の詳しい方に話を聞くたびに、そんな未来がくるかも、とワクワクしてしまいます!(妄言多謝) 最後に弊社採用もオープンしておりますので、気になりましたらお気軽にご応募くださいね!お話できることを楽しみにしています。 hrmos.co 参考 Getting started over HTTP | Bolt for Python bolt-python/examples/google_cloud_run/flask-gunicorn at main · slackapi/bolt-python · GitHub 【Slack】インストールするボットユーザーがありませんと出たときの対処方法 | THE SIMPLE Slack botをCloud Runで動かしてみた|まりーな/エンジニア Slack BoltをGoogle Cloudにデプロイするノウハウ conversations.history method | Slack conversations.replies method | Slack
はじめに Full-stack チームの豊崎です。 RevComm では、MiiTel Analytics の議事録作成をはじめ、LLM を用いた機能開発が活発に行われています。 今回、社内ユーザーが誰でも利用できる RAG 環境を作成しました。これは、RAG 環境をユーザーに提供するための PoC として実施したものです。 構成 この PoC では、プロダクトへの直接的な機能の埋め込みは行わず、以下のような構成で実装しました。 Amazon Bedrock, Amazon Bedrock Knowledge Bases ベクターストア: Pinecone データソース: S3 dynamoDB Python langchain streamlit etc… 問題点 この PoC を開始した時点での主な課題は、次の疑問から生まれました。 "Amazon Bedrock Knowledge Basesを使って、どのようにユーザーごとにRAG環境を提供できるのか?" 各ユーザーに個別の Amazon Bedrock Knowledge Bases を作成することは現実的ではなく、この課題に苦心しました。 結論として、ベクトルストアからデータを取得する際のフィルタリング機能が不可欠だと判明しました。 メタデータフィルタリング Amazon Bedrock Knowledge Bases には メタデータフィルタリング という機能があります。 これこそが、私の課題を解決する機能でした。 使い方 Knowledge Bases のデータソースに配置される各文書に対して、カスタムメタデータファイル( .metadata.json )を作成する必要があります。 このメタデータを利用して、ベクトルデータのフィルタリングを行います。 今回の目的はユーザーごとの RAG 環境提供ですが、このフィルタリングにより検索対象のチャンク数を削減でき、パフォーマンスと正確性の向上も実現できます。 以下が metadata.json のフォーマットです。 例 ) miitel_analytics_dashboard_overview . pdf をデータソースに配置した場合 // miitel_analytics_dashboard_overview.pdf.metadata.json { "metadataAttributes" : { "session_id" : "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" , "user_id" : "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" , } } metadataAttributes 配下には、フィルタリング時に利用したい key-value を設定できます。 私は、ユーザーがファイルをアップロードする際に、このメタデータファイルが自動生成されるように実装しました。 サンプル (Python) 今回、私は langchain_community.retrievers.bedrock で提供されている AmazonKnowledgeBasesRetriever を利用しました。以下にそのサンプルコードを掲載します。 先ほどの metadata.json で設定した session_id と user_id をここで指定しています。 retriever = AmazonKnowledgeBasesRetriever ( knowledge_base_id = BEDROCK_KNOWLEDGE_BASE_ID , retrieval_config = { "vectorSearchConfiguration" : { "numberOfResults" : 10 , "filter" : { "andAll" : [ { "equals" : { "key" : "session_id" , "value" : "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" , } , } , { "equals" : { "key" : "user_id" , "value" : "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" , } , } , ] } , } , } , ) まとめ 今回は、Amazon Bedrock Knowledge Bases のメタデータフィルタリング機能について紹介しました。この機能により、ユーザーごとに独自の RAG 環境を提供しながら、不要なベクトルデータを効率的に除外することが可能となりました。 当初目標とした「社内の人が誰でも使える RAG 環境」は実現できましたが、以下のような課題が残されています: データソースとの連携間隔 本番環境での実装の実現性 etc... 今後は、プロダクトへの RAG 環境の実装検討をさらに進め、MiiTel ユーザーの業務効率化により一層貢献できるよう取り組んでいきます。 ご清覧ありがとうございました。
はじめに こんにちは! RevComm のフロントエンドエンジニアの楽桑です。 私たちのコールセンターシステムでは、 GraphQL を使用してデータを管理しており、これまでは Recoil を使ってローカルステートを管理していました。 最近では、 Recoil の代わりに Apollo Client の Local Cache を採用し、サーバーデータの取得・管理をより簡潔かつ効率的に行っています。 この記事では、 Apollo Client のキャッシュ利用について紹介します。 背景 今までは Recoil を使ってグローバルな状態管理を行ってきました。Recoilには以下のようなメリットがあります: 使いやすさ :シンプルで直感的に状態管理が可能。 学習コストが低い :初学者でも簡単に扱える設計。 プロジェクト初期の段階においては、これらのメリットを活かして素早く状態管理を整えることができ、 Recoil は悪くない選択肢でした。 しかし、サーバーから取得したデータを管理するケースにおいては、以下のような課題が浮き彫りになりました: データ同期の手動実装が必要 サーバーから取得したデータをローカルの Recoil 状態と同期させるには、追加の実装が必要です。これにより、コードの冗長化や保守性の低下を招きます。 データ取得の効率化が困難 同じデータを複数のコンポーネントで使用する場合、無駄なAPIリクエストが発生しやすく、パフォーマンスが低下します。 最新データの取得とパフォーマンスのトレードオフ リアルタイム性が求められる場合、常にサーバーからデータを取得する実装ではパフォーマンスの劣化を避けられません。 こうした背景から、サーバーと連携した効率的な状態管理を実現するために、 Apollo Client の導入を決めました。 Apollo Client は、 GraphQL の強力なキャッシュ管理を活用し、データ取得の効率化と同期の手間を軽減することで、これらの課題を解決します。 Apollo Client Cache とは Apollo Client Cache は、GraphQLを使ったデータ取得の効率を最大化するためのキャッシュ機能です。 一度取得したデータをクライアント側に保存し、再利用することでネットワークリクエストの削減など多くメリットある強力な機能です。 実装例 これまでのRecoilを用いた実装では、まずRecoil Atomを定義するところから始める必要がありました。 export const userState = atom ({ key : 'userState' , default : [] , }) ; たとえば useGetUser などのフックを定義する場合、データ取得が成功したタイミングで onCompleted コールバック内から手動で Recoil State へデータをセットする必要があります。 const useFetchUsersWithRecoil = () => { const [ users , setUsers ] = useRecoilState ( userState ) ; const [ getUsers , { data , loading , error }] = useLazyQuery ( GET_USERS , { fetchPolicy : 'network-only' , onCompleted : ( data ) => { setUsers ( data . users ) ; } }) ; return { getUsers , users , loading , error } ; } ; また、 Recoil State を更新するたびに再レンダリングが発生するため、 useLazyQuery を用いて取得回数を必要最低限に抑える必要があるなど、いくつかのデメリットも存在します 一方、 Apollo Client のローカルキャッシュ機能( Local Cache )のみを用いる場合、 Recoil State の定義や初期設定といった手順は不要になります。 const useFetchUsersWithApollo = () => { const { data , loading , error } = useQuery ( GET_USERS , { fetchPolicy : 'cache-first' , }) ; const users = data ?. users ?? [] ; return { users , loading , error } ; } ; fetchPolicy を cache-first に設定すると、 Apollo Client はすでにローカルキャッシュ上に存在するデータを優先的に返し、サーバーへの新規リクエストを行わなくなります。 これは、同じデータを何度も取得する必要がない場面でのパフォーマンス最適化につながり、不要なネットワーク通信を削減することが可能になります。 サブスクリプション動作 WebSocket を使用してデータの更新をサブスクリプションで行う場合の例をご紹介します。 従来の Recoil State を使ったデータ更新では、手動で setState を呼び出す必要がありました。以下はその実装例です: export const useUserSubscription = () => { const [ users , setUsers ] = useRecoilState ( usersState ) ; // Users配列の状態を取得・更新 useSubscription ( USER_UPDATED_SUBSCRIPTION , { onSubscriptionData : ({ subscriptionData }) => { if ( subscriptionData . data ?. userUpdated ) { const updatedUser = subscriptionData . data . userUpdated ; // 現在のusersを直接参照して更新 const updatedUsers = users . map (( user ) => user . id === updatedUser . id ? { ... user , ... updatedUser } : user ) ; setUsers ( updatedUsers ) ; } } , }) ; } ; 一方、 Apollo Client が提供する Local Cache を利用する場合、コードは非常に簡潔になります。 以下はその例です: export const useUserSubscription = () => { useSubscription ( USER_UPDATED_SUBSCRIPTION ) ; } ; そして、特定のユーザー情報を更新する場合、 Cache に keyFields を追加することで、 Apollo Client はそのユーザーのキャッシュのみを自動的に更新することが可能です。 export const graphqlCache = new InMemoryCache ({ typePolicies : { User : { keyFields : [ account_id ] } } }) このように、サブスクリプションデータの更新時に特定の副作用( Side Effect )が必要ない場合、非常にシンプルな実装が可能です。 カスタムキャッシュマージ Apollo Client では、フェッチしたデータがキャッシュ上に既に存在する場合、そのデータをどのように更新・統合(マージ)するかを柔軟に制御することができます。 これを typePolicies や merge 関数を用いて実現できます。 今回は、ユーザー情報のマスキングを例として挙げます。 たとえば、ユーザー情報を管理者のみが閲覧できる仕様にしたい場合、権限判定に応じたマスキング処理をフロントエンド側で行うケースを考えてみます。 まず、ログイン中のユーザー情報を取得し、管理者かどうかを判定します。 管理者であれば、すべてのユーザー情報を開示しますが、管理者でない場合は、 maskUser 関数を使用して隠したい情報をマスキングするように実装します。 export const graphqlCache = new InMemoryCache ({ typePolicies : { User : { keyFields : [ account_id ] merge ( existing , incoming , { cache } ) { const myAccount = cache . readQuery<MyAccountQuery> ({ query : GET_MY_ACCOUNT , }) ?. myAccount ; if ( ! myAccount ) { return incoming ; } const { account_id , permissions } = myAccount ; const isAdmin = permissions ?. includes ( 'is_admin' ) || false ; if ( isAdmin ) { return incoming ; } const maskedUser = maskingUser ( incoming , account_id ) ; return { ... existing , ... maskedUser , } ; } , } , } , }) ; このように、サーバーからユーザーのデータが更新される際に、キャッシュを更新する動作をカスタマイズすることができます。 終わり 最後までお読みいただきありがとうございます! この記事では、 Apollo Client を活用した GraphQL キャッシュの活用方法について解説しました。 Recoil から Apollo Client への移行を通じて、サーバーデータの効率的な取得やキャッシュ管理がどれほど便利かをご理解いただけたと思います。 Apollo Client の強力なキャッシュ機能は、単なるデータ取得の効率化だけでなく、アプリケーション全体のパフォーマンス向上やコードの保守性の向上にも寄与します。 特に、カスタムキャッシュマージを活用することで、フロントエンドでの柔軟なデータ操作が可能になります。 プロジェクトの要件によって最適な状態管理ツールは異なりますが、サーバーデータとの連携が必要な場合、 Apollo Client は非常に強力な選択肢です。 この記事が、皆さんのアプリケーション開発の参考になれば幸いです。
こんにちは。レブコムのコーポレートエンジニアリングチームの @ken-1200 です。 この記事は、 RevComm Advent Calendar 2024 の 20 日目の記事です。 1. はじめに 2. 開発の背景・モチベーション 3. 前提条件 4. Salesforce CPQ APIの概要 5. 商談(Opportunity)の作成 6. 見積(Quote)の作成 7. 見積品目(Quote Line Items)の登録 8. ポイントの振り返り 9. 自動化により得られた効果 10. 苦労した点・ハマりどころ 11. まとめ 12. 参考文献 1. はじめに 記事の目的 本記事では、Salesforce CPQ APIを活用して、商談から見積作成、見積品目の登録までのプロセスを自動化する手順をご紹介します 対象読者 Salesforce CPQを利用するエンジニアの方を主な読者と想定しています 2. 開発の背景・モチベーション なぜ開発に至ったのか 営業プロセスでは、商談・見積・見積品目などの情報入力や修正が手動で行われ、工数がかかっていました。さらに、オンライン申込対応時には、商品の追加・削除・価格調整を都度行う必要があり、手作業によるミスや作業遅延が発生しがちでした これらの課題解決のため、Salesforce CPQ APIを用いた自動化によって、業務フローを効率化し、正確性とスピードの向上を目指しました 3. 前提条件 Salesforce環境の準備 Salesforce CPQが有効化されていること 必要なAPIアクセス権限(ユーザー権限設定)が設定されていること 開発環境のセットアップ Salesforce Sandbox環境 プログラミング言語:Python 3.10以上を推奨します 基本的な知識 Salesforce CPQの基本概念 REST APIの基礎知識 4. Salesforce CPQ APIの概要 APIの種類 REST APIとSOAP APIの2種類が存在しますが、軽量で汎用性が高く、JSON形式のデータ交換が容易なREST APIを選択します 認証方法 一般的にはOAuth 2.0を使用することで、トークンベースの認証・認可が可能です エンドポイントとリソース Salesforce CPQには特定のエンドポイントを介して見積や商品情報へアクセスできます。以下は主な例です QuoteReader : /services/apexrest/SBQQ/ServiceRouter?reader=SBQQ.QuoteAPI.QuoteReader&uid={quote_id} 指定したQuote IDの詳細情報を取得します ProductLoader : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.ProductAPI.ProductLoader&uid={product_id} 指定した商品IDに対する商品情報やオプションを取得します QuoteProductAdder : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteProductAdder 見積に商品を追加するために使用します QuoteCalculator : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteCalculator 見積品目を追加後に、見積全体の価格計算を行います QuoteSave : /services/apexrest/SBQQ/ServiceRouter 設定した見積や見積品目を保存および確定します これらのエンドポイントを組み合わせることで、商談作成→見積生成→見積品目追加→価格再計算→保存という一連の流れを自動化できます SalesforceRestApiClientクラスについて Salesforce CPQ APIやSalesforce標準APIへのアクセスを簡潔にするために、本記事では共通的に利用できる SalesforceRestApiClient クラスを用いています from collections.abc import Mapping from typing import Any import httpx class SalesforceRestApiClient : """Salesforce REST APIクライアントクラス Salesforce APIを呼び出すための基本クラスです """ def __init__ (self, path: str , additional_headers: dict | None = None ): self.base_url = "https://your-instance.salesforce.com" # SalesforceインスタンスURLを指定してください self.path = path # ここでは例としてAuthorizationヘッダを省略していますが、 # 実際にはOAuth2トークンや有効な認証ヘッダを設定してください self.headers = { "Authorization" : "Bearer your_access_token" , "Content-Type" : "application/json" } if additional_headers: self.headers.update(additional_headers) async def get (self) -> httpx.Response: """GETリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.get(f "{self.base_url}{self.path}" , headers=self.headers, timeout= 30 ) async def patch (self, json: Mapping[ str , Any]) -> httpx.Response: """PATCHリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.patch(f "{self.base_url}{self.path}" , headers=self.headers, json=json, timeout= 30 ) async def post (self, json: Mapping[ str , Any]) -> httpx.Response: """POSTリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.post(f "{self.base_url}{self.path}" , headers=self.headers, json=json, timeout= 30 ) 5. 商談(Opportunity)の作成 必要なデータ 商談名、ステージ、取引先情報など。必要に応じて追加してください APIリクエストの構築 POST メソッドを用いて、指定のエンドポイントへJSON形式でデータを送信します。Salesforce APIは Content-Length ヘッダが必要となる場合があるため、事前にJSON文字列の長さを計算して設定します エンドポイント例 path="/services/data/vXX.X/sobjects/Opportunity" ヘッダー例 headers={"Content-Length": str(len(json.dumps(data)))} サンプルコード 以下はPythonによる実装例です。非同期HTTPクライアント(httpx)を用いてSalesforce APIにPOSTリクエストを送信し、商談を作成します import asyncio class SalesforceOpportunity : async def create_opportunity (self, data: Mapping[ str , Any]) -> httpx.Response: """Salesforceの商談をAPIで作成します Args: data (Mapping[str, Any]): 作成する商談の情報を含んだ辞書型データ Returns: httpx.Response: Salesforce APIからのレスポンス """ # APIクライアントを初期化(必要なヘッダを設定) sf_api_client = SalesforceRestApiClient( path= "/services/data/vXX.X/sobjects/Opportunity" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) if __name__ == "__main__" : # 実行例:商談を作成します async def main (): sf = SalesforceOpportunity() response = await sf.create_opportunity( { "Name" : "Test Opportunity" , "StageName" : "Prospecting" , "CloseDate" : "2024-12-31" , "AccountId" : "0015g00000A3X7dAAF" , # 有効なAccountIdを設定してください } ) print (f "{response.status_code=}" ) print (f "{response.json()=}" ) asyncio.run(main()) エラーハンドリング Salesforce APIへのPOST時には、 201 Created が成功時の典型的なステータスコードです。エラー時には 400 や 404 などが返り、レスポンスボディ内に errorCode や fields などの詳細が含まれます 以下はエラーが発生した場合の例です [ { "message": "不正な種別の ID 値: 0015g00000A3X7dAAF", "errorCode": "MALFORMED_ID", "fields": ["AccountId"] } ] このようなエラーに対しては、ログ出力やリトライ、適切なエラーメッセージのユーザー通知などを行います。 6. 見積(Quote)の作成 商談との関連付け 見積と商談は、 SBQQ__Opportunity2__c フィールドで関連付けます 必要なフィールド 商談ID、価格表ID、期限日など。要件に応じて追加フィールドやカスタムフィールドを設定します APIリクエストの詳細 POST リクエストを使用して、 SBQQ__Quote__c オブジェクトにデータを送信します エンドポイント例 path="/services/data/vXX.X/sobjects/SBQQ__Quote__c" ヘッダー例 headers={"Content-Length": str(len(json.dumps(data)))} サンプルコード 以下は、Pythonを使用して見積を作成するサンプルコードです import asyncio class SalesforceQuote : async def create_quote (self, data: Mapping[ str , Any]) -> httpx.Response: """Salesforceの見積をAPIで作成します Args: data (Mapping[str, Any]): 作成する見積の情報を含んだ辞書型データ Returns: httpx.Response: Salesforce APIからのレスポンス """ # APIクライアントを初期化(必要なヘッダを設定) sf_api_client = SalesforceRestApiClient( path= "/services/data/vXX.X/sobjects/SBQQ__Quote__c" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) if __name__ == "__main__" : # 実行例:見積を作成します async def main (): sf = SalesforceQuote() # 以下は例としてのフィールド設定です。実際のIDや値は有効なものを指定してください。 response = await sf.create_quote( { "SBQQ__BillingCity__c" : "Chiyoda" , "SBQQ__BillingPostalCode__c" : "100-0000" , "SBQQ__BillingState__c" : "Tokyo" , "SBQQ__BillingStreet__c" : "1-1-1" , "SBQQ__EndDate__c" : "2024-12-31" , "SBQQ__Opportunity2__c" : "0065g00000B3X7dAAF" , # 商談ID "SBQQ__PricebookId__c" : "01s5g00000A3X7dAAF" , # 価格表ID "SBQQ__PrimaryContact__c" : None , "SBQQ__Primary__c" : True , "SBQQ__QuoteTemplateId__c" : "a1s5g0000003X7dAAF" , # テンプレートID "SBQQ__StartDate__c" : "2024-01-01" , "SBQQ__SubscriptionTerm__c" : 12 , } ) print (f "{response.status_code=}" ) print (f "{response.json()=}" ) asyncio.run(main()) エラーハンドリング エラーが発生した場合、 400 Bad Request などのステータスコードとともに、 errorCode や message が返されます。以下は一般的なエラー応答例です [ { "message": "invalid cross reference id", "errorCode": "INVALID_CROSS_REFERENCE_KEY", "fields": [] } ] このようなエラーに対しては、ログ出力やIDの再確認、必要なデータフィールドの修正を行います 7. 見積品目(Quote Line Items)の登録 手順 見積の読み取り 商品の読み取り 商品の追加 見積の計算 見積の保存 これらのステップを通じて、見積品目を自動的に追加できます 製品情報の準備 製品ID、数量、価格などのデータ。必要に応じて追加フィールドを設定できます APIリクエストの構築 見積IDを基に見積品目を追加するには、CPQ固有のエンドポイントを使用します。 QuoteReader 、 ProductLoader 、 QuoteProductAdder 、 QuoteCalculator 、 QuoteSaver といったCPQ APIエンドポイントを順番に呼び出すことで、一連の処理を自動化できます バルク操作の考慮 複数の見積品目を一度に登録する場合、一括処理用のコンテキストをまとめて送信することで、パフォーマンスを最適化できます 商品を一括追加した後、見積を再計算し、最後に保存する流れで処理を完結させます サンプルコード 以下のサンプルコードは、見積に商品を追加する一連の流れをCPQ APIで実現します import asyncio class SalesforceCpqQuote : async def get_quote (self, quote_id: str ) -> httpx.Response: """Salesforceの見積をCPQ APIで取得します""" sf_api_client = SalesforceRestApiClient( path=f "/services/apexrest/SBQQ/ServiceRouter?reader=SBQQ.QuoteAPI.QuoteReader&uid={quote_id}" , ) return await sf_api_client.get() async def get_product (self, product_id: str , data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの商品をCPQ APIで取得します""" sf_api_client = SalesforceRestApiClient( path=f "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.ProductAPI.ProductLoader&uid={product_id}" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def add_product (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積品目をCPQ APIで作成します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteProductAdder" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def calculate_quote (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積をCPQ APIで計算します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteCalculator" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def save_quote (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積をCPQ APIで保存します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) class CreateQuoteLineItem : def __init__ (self) -> None : self.salesforce_cpq_quote = SalesforceCpqQuote() async def execute ( self, quote_id: str , product_id: str , pricebook_id: str , currency_code: str , product_counts: dict , ): """処理の流れ: 1. 見積の読み取り 2. 商品の読み込み 3. 商品の追加 4. 見積の計算 5. 見積の保存 """ # 見積の読み取り cpq_quote = await self.get_quote(quote_id) print (f "Successfully get quote: {cpq_quote}" ) # 商品の読み込み product = await self.get_product(product_id, pricebook_id, currency_code) print (f "Successfully get product: {product}" ) # 商品の追加 add_product_to_quote = await self.add_product_to_quote(product_counts, cpq_quote, product) print (f "Successfully add product: {add_product_to_quote}" ) # 見積の計算 calculate_quote = await self.calculate_quote(add_product_to_quote) print (f "Successfully calculate quote: {calculate_quote}" ) # 見積の保存 save_quote = await self.save_quote(calculate_quote) print (f "Successfully save quote: {save_quote}" ) async def get_quote (self, quote_id: str ) -> dict : """Salesforceの見積を取得""" quote_response = await self.salesforce_cpq_quote.get_quote(quote_id) return json.loads(quote_response.json()) async def get_product (self, product_id: str , pricebook_id: str , currency_code: str ) -> dict : """Salesforceの商品を取得""" product_data = { "context" : json.dumps(ProductGetContext(pricebookId=pricebook_id, currencyCode=currency_code).dict()) } product_response = await self.salesforce_cpq_quote.get_product(product_id, product_data) return json.loads(product_response.json()) async def add_product_to_quote (self, product_counts: dict , quote: dict , product: dict ) -> dict : """Salesforceの商品を見積に追加""" # ProductModelやConfigurationModelなど product_model = ProductModel(**product) list_of_product_model = [] list_of_configuration_model = [] # バンドル商品の子商品を追加 for mb_op in product_model.options: product_id = mb_op.record[ "SBQQ__OptionalSKU__c" ] quantity = product_counts.get(product_id) if quantity: mb_op.record[ "SBQQ__Quantity__c" ] = quantity cf_model = ConfigurationModel( configuredProductId=product_id, optionId=mb_op.record[ "Id" ], optionData=mb_op.record, configurationData=mb_op.record, inheritedConfigurationData= None , optionConfigurations=[], configured= False , changedByProductActions= False , isDynamicOption= False , isUpgrade= False , disabledOptionIds=[], hiddenOptionIds=[], listPrice= None , priceEditable= False , validationMessages=[], dynamicOptionKey= None , ) list_of_configuration_model.append(cf_model.dict()) # バンドル商品本体へのオプション追加 if product_model.configuration is not None : if product_model.configuration.optionConfigurations is not None : product_model.configuration.optionConfigurations.extend(list_of_configuration_model) product_model.configuration.configured = True list_of_product_model.append(product_model.dict()) # 見積と商品モデルをcontextにセット context = ProductAddContext( quote={k: v for k, v in quote.items() if k != "ui_original_record" }, products=list_of_product_model, ) add_product_response = await self.salesforce_cpq_quote.add_product({ "context" : json.dumps(context.dict())}) return json.loads(add_product_response.json()) async def calculate_quote (self, quote: dict ) -> dict : """Salesforceの見積を計算""" calculate_quote_data = { "context" : json.dumps({ "quote" : {k: v for k, v in quote.items() if k != "ui_original_record" }}) } calculate_quote_response = await self.salesforce_cpq_quote.calculate_quote(calculate_quote_data) return json.loads(calculate_quote_response.json()) async def save_quote (self, quote: dict ) -> dict : """Salesforceの見積を保存""" save_quote_data = { "saver" : "SBQQ.QuoteAPI.QuoteSaver" , "model" : json.dumps({k: v for k, v in quote.items() if k != "ui_original_record" }), } save_quote_response = await self.salesforce_cpq_quote.save_quote(save_quote_data) return json.loads(save_quote_response.json()) if __name__ == "__main__" : """見積品目の追加を実行する例です。実際には有効なIDと通貨コードを設定してください""" async def main (): quote_id = "a0B5g00000DwJtEEAV" # 見積ID product_id = "01t5g00000B1Q0PAK" # 商品バンドルID pricebook_id = "01s5g0000008Q5eAAE" # 価格表ID currency_code = "JPY" # 通貨コード product_counts = { "01t5g00000B1Q0MKA" : 1 , # 見積商品IDと数量 "01t5g00000B1Q0NAAV" : 2 , } await CreateQuoteLineItem().execute( quote_id, product_id, pricebook_id, currency_code, product_counts, ) asyncio.run(main()) CPQ API のリクエストモデル定義 以下は、CPQ APIとのやりとりで使用するデータモデルの例です。 pydantic を用いてスキーマを定義し、バリデーションやコメントを明確にしています。これらのモデルは、受け取ったJSONデータを明確な型情報のあるPythonオブジェクトとして扱うことで、可読性を向上させます from pydantic import BaseModel, Field class ConfigurationModel (BaseModel): configuredProductId: str = Field(..., title= "商品ID" , description= "The Product2.Id" , example= "01t6F00000B8XZTAA3" ) optionId: str | None = Field( default= None , title= "オプションID" , description= "The SBQQ__ProductOption__c.Id" , example= "01t6F00000B8XZTAA3" ) optionData: dict = Field( ..., title= "オプションデータ" , description= "Editable data about the option, such as quantity or discount" , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurationData: dict = Field( ..., title= "構成データ" , description= "Stores the values of the configuration attributes." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) inheritedConfigurationData: dict | None = Field( default= None , title= "継承された構成データ" , description= "Stores the values of the inherited configuration attributes." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) optionConfigurations: list = Field( ..., title= "オプション構成" , description= "Stores the options selected on this product." , example=[{ "Id" : "01t6F00000B8XZTAA3" }], ) configured: bool = Field( ..., title= "構成済み" , description= "Indicates whether the product has been configured." , example= False ) changedByProductActions: bool = Field( ..., title= "商品アクションによる変更" , description= "Indicates whether a product action changed the configuration of this bundle." , example= False , ) isDynamicOption: bool = Field( ..., title= "動的オプション" , description= "Indicates whether the product was configured using a dynamic lookup." , example= False , ) isUpgrade: bool = Field( ..., title= "アップグレード" , description= "Queries whether this product is an upgrade." , example= False ) disabledOptionIds: list = Field( default= None , title= "無効なオプションID" , description= "The option IDs that are disabled." , example=[ "01t6F00000B8XZTAA3" ], ) hiddenOptionIds: list = Field( default= None , title= "非表示オプションID" , description= "The option IDs that are hidden." , example=[ "01t6F00000B8XZTAA3" ], ) listPrice: float | None = Field(default= None , title= "定価" , description= "The list price." , example= 0.0 ) priceEditable: bool = Field( ..., title= "価格編集可能" , description= "Indicates whether the price is editable." , example= False ) validationMessages: list = Field( ..., title= "検証メッセージ" , description= "Validation messages." , example=[ "Error message" ] ) dynamicOptionKey: str | None = Field( default= None , title= "動的オプションキー" , description= "Internal property for dynamic options." , example= "01t6F00000B8XZTAA3" , ) class OptionModel (BaseModel): record: dict = Field( ..., title= "オプション" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) externalConfigurationData: dict | None = Field( default= None , title= "外部構成データ" , description= "Internal property for the external configurator feature." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurable: bool = Field( ..., title= "構成可能" , description= "Indicates whether the option is configurable." , example= False ) configurationRequired: bool = Field( ..., title= "構成必須" , description= "Indicates whether the configuration of the option is required." , example= False , ) quantityEditable: bool = Field( ..., title= "数量編集可能" , description= "Indicates whether the quantity is editable." , example= False ) priceEditable: bool = Field( ..., title= "価格編集可能" , description= "Indicates whether the price is editable." , example= False ) productQuantityScale: float | None = Field( default= None , title= "商品数量スケール" , description= "Returns the value of the quantity scale field for the product being configured." , example= 0.0 , ) priorOptionExists: bool | None = Field( default= None , title= "前のオプションが存在する" , description= "Checks if this option is an asset on the account that the quote is associated with." , example= False , ) dependentIds: list = Field( ..., title= "依存するオプションID" , description= "The option IDs that depend on this option." , example=[ "01t6F00000B8XZTAA3" ], ) controllingGroups: dict = Field( ..., title= "制御グループ" , description= "The option IDs that this option depends on." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) exclusionGroups: dict = Field( ..., title= "排他グループ" , description= "The option IDs that this option is exclusive with." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) reconfigureDimensionWarning: str = Field( ..., title= "再構成次元警告" , description= "Reconfigures the warning label for an option with segments." , example= "01t6F00000B8XZTAA3" , ) hasDimension: bool = Field( ..., title= "次元がある" , description= "Indicates whether this option has dimensions or segments." , example= False ) isUpgrade: bool = Field( ..., title= "アップグレード" , description= "Indicates whether the product option is related to an upgrade product." , example= False , ) dynamicOptionKey: str | None = Field( default= None , title= "動的オプションキー" , description= "Internal property for dynamic options." , example= "01t6F00000B8XZTAA3" , ) class FeatureModel (BaseModel): record: dict = Field( ..., title= "機能" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) instructionsText: str | None = Field( default= None , title= "指示テキスト" , description= "Instruction label for the feature." , example= "01t6F00000B8XZTAA3" , ) containsUpgrades: bool = Field( ..., title= "アップグレードが含まれている" , description= "This feature is related to an upgrade product." , example= False , ) class ConfigAttributeModel (BaseModel): name: str | None = Field( default= None , title= "名前" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.Name." , example= "01t6F00000B8XZTAA3" , ) targetFieldName: str = Field( ..., title= "ターゲットフィールド名" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__TargetField__c." , example= "01t6F00000B8XZTAA3" , ) displayOrder: float | None = Field( default= None , title= "表示順" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__DisplayOrder__c." , example= 0.0 , ) columnOrder: str = Field( ..., title= "カラム順" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ColumnOrder__c." , example= "01t6F00000B8XZTAA3" , ) required: bool = Field( ..., title= "必須" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Required__c." , example= False , ) featureId: str = Field( ..., title= "機能ID" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Feature__c." , example= "01t6F00000B8XZTAA3" , ) position: str = Field( ..., title= "位置" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Position__c." , example= "01t6F00000B8XZTAA3" , ) appliedImmediately: bool = Field( ..., title= "直ちに適用" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AppliedImmediately__c." , example= False , ) applyToProductOptions: bool = Field( ..., title= "商品オプションに適用" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ApplyToProductOptions__c." , example= False , ) autoSelect: bool = Field( ..., title= "自動選択" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AutoSelect__c." , example= False , ) shownValues: list | None = Field( default= None , title= "表示値" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ShownValues__c." , example=[ "01t6F00000B8XZTAA3" ], ) hiddenValues: list | None = Field( default= None , title= "非表示値" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__HiddenValues__c." , example=[ "01t6F00000B8XZTAA3" ], ) hidden: bool = Field( ..., title= "非表示" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Hidden__c." , example= False , ) noSuchFieldName: str | None = Field( default= None , title= "存在しないフィールド名" , description= "If no field with the target name exists, the target name is stored here." , example= "01t6F00000B8XZTAA3" , ) myId: str = Field( ..., title= "ID" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.Id." , example= "01t6F00000B8XZTAA3" , ) class ConstraintModel (BaseModel): record: dict = Field( ..., title= "制約" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) priorOptionExists: bool = Field( ..., title= "前のオプションが存在する" , description= "Checks if this option is an asset on the account that the quote is associated with." , example= False , ) class ProductModel (BaseModel): record: dict = Field( ..., title= "商品" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) upgradedAssetId: str | None = Field( default= None , title= "SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c.Id" , description= "Provides a source for SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c." , example= "01t6F00000B8XZTAA3" , ) currencySymbol: str = Field( ..., title= "通貨シンボル" , description= "The symbol for the currency in use." , example= "¥" ) currencyCode: str = Field( ..., title= "通貨コード" , description= "The ISO code for the currency in use." , example= "JPY" ) featureCategories: list = Field( ..., title= "機能カテゴリ" , description= "Allows users to sort product features by category." , example=[ "01t6F00000B8XZTAA3" ], ) options: list [OptionModel] = Field( ..., title= "オプション" , description= "A list of all available options for this product." , example=[ "01t6F00000B8XZTAA3" ], ) features: list [FeatureModel] = Field( ..., title= "機能" , description= "All features available for this product" , example=[ "01t6F00000B8XZTAA3" ] ) configuration: ConfigurationModel = Field( ..., title= "構成" , description= "An object representing this product’s current configuration." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurationAttributes: list [ConfigAttributeModel] = Field( ..., title= "構成属性" , description= "All configuration attributes available for this product." , example=[ "01t6F00000B8XZTAA3" ], ) inheritedConfigurationAttributes: list [ConfigAttributeModel] | None = Field( default= None , title= "継承された構成属性" , description= "All configuration attributes that this product inherits from ancestor products." , example=[ "01t6F00000B8XZTAA3" ], ) constraints: list [ConstraintModel] = Field( ..., title= "制約" , description= "Option constraints on this product." , example=[ "01t6F00000B8XZTAA3" ] ) class ProductGetContext (BaseModel): pricebookId: str = Field( ..., title= "価格表ID" , description= "The ID of the price book to use." , example= "01s6F00000CneeJQAR" ) currencyCode: str = Field( ..., title= "通貨コード" , description= "The ISO code for the currency in use." , example= "JPY" ) class ProductAddContext (BaseModel): ignoreCalculate: bool = Field(default= True , title= "計算無視" , description= "計算無視" , example= True ) quote: dict = Field(..., title= "見積" , description= "見積モデル" , example={}) products: list = Field(..., title= "商品リスト" , description= "商品モデル" , example=[]) groupKey: int = Field(default= 0 , title= "グループキー" , description= "グループキー" , example= 0 ) エラーハンドリング エラーが発生した場合はHTTPステータスコードとエラーメッセージが返されます。たとえば、 500 Internal Server Error などが返された場合、レスポンス本文には errorCode や message フィールドが含まれます。 [ { "errorCode": "APEX_ERROR", "message": "System.AssertException: Assertion Failed: Unsupported quote object: a0B5g00000DwJtEEAV\n\n(System Code)", } ] 8. ポイントの振り返り 1. 商談(Opportunity)の作成 必要なデータ(商談名、ステージ、取引先ID、CloseDateなど)を準備します POST /services/data/vXX.X/sobjects/Opportunity エンドポイントを用い、JSON形式でデータを送信します 2. 見積(Quote)の作成 商談ID、価格表ID、期間や開始日などの必須項目を指定します POST /services/data/vXX.X/sobjects/SBQQ__Quote__c を使用し、JSON形式でデータを送信します 3. 見積品目(Quote Line Items)の登録 CPQ API固有のフロー( QuoteReader , ProductLoader , QuoteProductAdder , QuoteCalculator , QuoteSaver )を順序立てて実行します 製品IDや数量、オプション構成などを事前に用意し、バンドル構成商品に対応します 複数商品の一括追加時は、リクエストをまとめて送信し、パフォーマンスを最適化します 9. 自動化により得られた効果 自動化により、以下のような効果を得られました。 手動作業が減少し、ヒューマンエラーが抑制されました 営業担当者がコア業務に集中できる環境が整い、業務効率が向上しました 見積作成から承認までのリードタイムが短縮され、顧客対応スピードと満足度が向上しました 10. 苦労した点・ハマりどころ 開発過程では、以下のような課題に直面しました。 Salesforce CPQ APIに関するドキュメントや事例が少なく、適切なエンドポイント選定やデータモデル理解までに試行錯誤が必要でした 関連IDや依存関係の正確な把握が難しく、エラーの解消に時間がかかりました 適切なエラーハンドリングやPydanticでのデータモデル定義など、Python実装上のベストプラクティスを探りながら開発を進める必要がありました 11. まとめ 本記事では、Salesforce CPQ APIを用いて、商談から見積作成、見積品目の登録までを自動化する具体的な手順とポイントをご紹介しました。これにより、手作業を減らしてヒューマンエラーを抑え、業務スピードを向上させることで、顧客満足度を高められる可能性が見えてきたかと思います。 この記事が少しでも参考になり、読者の方々の開発や業務改善にお役立ていただければ幸いです。 12. 参考文献 Salesforce公式ドキュメント Salesforce CPQ APIガイド 参考例コード(GitHub Gist) https://gist.github.com/paustint/40b602503b6cd6ae879af7b85d910da8
はじめに Phone Div Backend チームの西園です。 私たちのチームでは、システムの性能を向上させるために k6 と Datadog を利用した負荷テストを実施しました。本記事では、その際に利用したツールや実施方法について共有します。 想定読者 負荷テストをやったことがない方 k6 を利用したことがない方 負荷テストをやってみたいが方法がわからない方 k6 の結果を Datadog と連携したい方 負荷テストとは 負荷テストは、システムに負荷を与えて挙動を観測するテストです。これにより以下のような情報を得られます: システムのボトルネックの特定。 システムが耐えられる最大負荷の確認。 システムのパフォーマンス向上に必要な改善点の発見。 負荷テストの種類 負荷テストには目的に応じていくつかの種類があります: Smoke Test : 最小負荷で基本動作を確認するテスト。 Average Load Test : 運用時の平均負荷を再現するテスト。 Stress Test : システムの限界を探るための負荷をかけるテスト。 Soak Test : 長時間負荷をかけてシステムの安定性を確認するテスト。 Spike Test : 短時間で急激に負荷を増加させた際の挙動を確認するテスト。 Breakpoint Test : 負荷を徐々に増やし、システムが壊れるポイントを特定するテスト。 注意点 負荷テストを行う際は、できるだけ本番環境と近いテスト環境を用意することが重要です。特に以下の点に注意してください: インフラリソース : テスト環境と本番環境でのCPU、メモリ、ネットワーク条件を可能な限り一致させる。 データ量 : データベース内のデータ量を本番に近い状態にすることで、より正確な結果を得られる。 k6 は Grafana Labs が提供するオープンソースの負荷テストツールです。以下の特徴があります: k6とは 幅広いプロトコル対応 : HTTP、WebSocket、gRPC などに対応。 柔軟なスクリプト作成 : JavaScript を用いて、シナリオに応じたスクリプトを作成可能。 多様な負荷パターン : Executor を利用して、一定負荷や段階的負荷増加などのシナリオを設定可能。 k6スクリプトの実装方法 では実際にどのようにスクリプトを作成するかを見てみましょう。 以下は Read 系のエンドポイントに負荷を掛けるスクリプトの例です。 import http from "k6/http" ; import { check } from "k6" ; export const options = { scenarios : { test1 : { executor : "ramping-arrival-rate" , exec : "testRequests" , startRate : 1 , timeUnit : "1s" , preAllocatedVUs : 25 , maxVUs : 50 , stages : [ { target : 25 , duration : "10s" } , { target : 25 , duration : "50s" } , { target : 0 , duration : "10s" } , ] , } , } , } ; export const setup = () => { // リクエストを送る前の事前処理を記載 } // もしsetup関数で取得したトークンなどを利用する場合は引数を設定する export const testRequests = () => { const requestParams = { headers : { // 必要なヘッダー情報を追加 } , } ; const url = "" const body = { // bodyが必要な場合は記載 } ; const res = http . post ( ` ${ url } ` , JSON . stringify ( body ) , requestParams ) ; // リクエストが想定通りかを検証 check ( res , { "status is 200" : ( r ) => r . status === 200 , }) ; } ; 上記の scenario は ramping-arraival-rate を設定しています。これは負荷を段階的に上げたり下げたりすることができます。 各パラメーターの説明は以下です。 executor : どのような負荷を与えていくか(負荷を徐々に上げていくなど) exec : 実行する関数名(testRequestsという関数を実行する) startRate : testRequests関数を実行する単位 timeUnit : どのくらいの間隔でtestRequests関数を実行するか( startRate / timeUnit でRPSを表現) preAllocatedVUs : あらかじめ用意するバーチャルユーザー数 maxVUs : 負荷テストで利用する最大のバーチャルユーザー数 stages : どのような間隔で負荷を増減させるか つまり以下の内容の負荷をかけることになります。 1RPS から負荷を与えていき、10s かけて 25RPS まで負荷を上げる。 そして 50s 間 25RPS を維持し、最終的に 10s かけて 0RPS まで負荷を落とす。 他にも選択できる Executor はありますので詳細を知りたい方は以下の公式ドキュメントを参考にしてください。 参考: https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ k6スクリプトの実行とDatadogとの連携 Datadog にダッシュボードを作成 Datadog を使用して負荷テスト結果を可視化するには、以下の手順を実施します: k6 のインテグレーションを有効化 サイドメニューIntegrationから k6 を検索して有効化してください。 必要なメトリクスを表示するダッシュボードを作成 以下は、実際に利用したダッシュボードの例です: 各メトリクスは一例ですが、以下のような設定をします。 ここで後ほど Docker コンテナで設定する DD_HOSTNAME と同じ値を host:<設定値> に設定しておきます。 後ほど説明しますがこの設定をすることで特定の負荷テストにターゲットを絞ってメトリクスを表示できます。 負荷を与える側のマシンを用意 まず前提として負荷を与える側にも負荷がかかるのである程度のスペックを用意したマシンが必要になります。 弊社では主に AWS を利用しているので、負荷テスト時はインスタンスタイプが m4.4xlarge の EC2 を用意して負荷テストを行いました。 インスタンスタイプは負荷に耐えうる、かつ少し余裕を持ったスペックを選定するのをお勧めします。 コンテナの用意 負荷をかける上で今回チームでは Docker を利用して EC2 内にコンテナを立てて負荷テストを実行しました。 まず以下のような docker compose 用のファイルを用意します。 services : k6 : container_name : k6 image : grafana/k6:latest networks : - k6 ports : - '6565:6565' environment : - K6_STATSD_ENABLE_TAGS= true - K6_STATSD_ADDR=datadog:8125 volumes : - # スクリプトのパスをマウントする depends_on : - datadog datadog : container_name : datadog-agent image : datadog/agent:latest networks : - k6 ports : - '8125:8125/udp' environment : - DD_SITE=datadoghq.com - DD_API_KEY=<YOUR_DD_API_KEY> - DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 - DD_HOSTNAME=<YOUR_HOSTNAME> volumes : - /var/run/docker.sock:/var/run/docker.sock:ro - /proc/:/host/proc/:ro - /sys/fs/cgroup/:/host/sys/fs/cgroup:ro Datadog エージェント用のコンテナの環境変数の DD_API_KEY には Datadog で利用している API key を利用してください。 また DD_HOSTNAME には特に指定はないですが、チーム名などわかりやすい名前を設定することをお勧めします。この値は Datadog のダッシュボードでメトリクスを指定する際に特定のテスト結果だけをメトリクス上に反映するために利用します。 参考: https://docs.datadoghq.com/ja/integrations/k6/ コンテナの準備ができたら以下のコマンドを実行して負荷をかけます。 docker compose run k6 run --out statsd <コンテナのスクリプトパス> ただし、現在 k6 のバージョン v0.55.0 で statsd のオプションは廃止されてしまったので xk6-output-statsd extension を利用して実行する必要があります。 詳しくは公式ドキュメントを参照ください。 参考: https://grafana.com/docs/k6/latest/results-output/real-time/datadog/ 負荷テストの実施 k6 の実行が完了すると以下のような結果の指標が表示されます。 以下の指標は今回実施した際の指標です。 k6 result /\ Grafana /‾‾/ /\ / \ |\ __ / / / \/ \ | |/ / / ‾‾\ / \ | ( | (‾) | / __________ \ |_|\_\ \_____/ INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0134] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0734] Failed on <executed api path>: expected 200 but got 500 source=console ✗ status is 200 ↳ 99% — ✓ 366602 / ✗ 8 █ setup ✓ authenticated in successfully checks.........................: 99.99% 366605 out of 366613 data_received..................: 3.1 GB 1.5 MB/s data_sent......................: 54 MB 26 kB/s dropped_iterations.............: 16827 8.005593/s http_req_blocked...............: avg=3.57µs min=1.76µs med=1.93µs max=14.21ms p(90)=2.12µs p(95)=2.34µs http_req_connecting............: avg=417ns min=0s med=0s max=8.2ms p(90)=0s p(95)=0s http_req_duration..............: avg=288.92ms min=62.39ms med=239.73ms max=2.57s p(90)=491.68ms p(95)=621.08ms { expected_response:true }...: avg=288.89ms min=62.39ms med=239.73ms max=2.57s p(90)=491.64ms p(95)=621.01ms http_req_failed................: 0.00% 8 out of 366613 http_req_receiving.............: avg=199.78µs min=18.52µs med=169.85µs max=208.51ms p(90)=304.83µs p(95)=513.89µs http_req_sending...............: avg=77.22µs min=28.42µs med=72.18µs max=1.16ms p(90)=91.57µs p(95)=100.69µs http_req_tls_handshaking.......: avg=935ns min=0s med=0s max=9.5ms p(90)=0s p(95)=0s http_req_waiting...............: avg=288.65ms min=62.24ms med=239.44ms max=2.56s p(90)=491.35ms p(95)=620.75ms http_reqs......................: 366613 174.419363/s iteration_duration.............: avg=1.44s min=643.55ms med=1.34s max=5.7s p(90)=1.96s p(95)=2.42s iterations.....................: 73322 34.883587/s vus............................: 0 min=0 max=60 vus_max........................: 60 min=50 max=60 running (35m01.9s), 00/60 VUs, 73322 complete and 0 interrupted iterations ※ executed api path の部分にはリクエストを送信したパスが表示されます。 上記の結果を見ると平均で約 174 RPS の負荷をかけた結果 99% のリクエストは成功しているが、8回リクエストが失敗 していることがわかります。 Datadog Datadog の指標も一部見てみましょう。 HTTP リクエストの動作を見ると、理想的な台形型の負荷曲線にはなっておらず、負荷上昇フェーズでリクエストが失敗する、または適切に送信されないケースが見られました。(通常、問題がなければ負荷曲線は綺麗な台形になります) さらに詳細な指標を確認した結果、データベースとのコネクションタイムアウトが発生していることが判明しました。 チームで議論した結果、以下の仮説を立てました: 「確保しているコネクションプールの上限を超えるリクエストが発生し、API と RDS Proxy 間の TLS 3way ハンドシェイクに時間がかかりタイムアウトした可能性がある。」 この問題は本番環境でも時折発生していたため、仮説に基づきプールサイズの調整を行い監視を続けた結果、これまでに出ていた 500 エラーを減らすことができました。 この負荷テストを通してエラーの原因に対して仮説を立て、システムの改善に至ることができたのでやって良かったと思っています。 その他利用できるツール 普段、Kubernetes や RDS のメトリクスを Datadog で取得しています。 なので負荷テスト時にそれぞれの指標が一緒に見れるように Datadog を利用して k6 の指標を確認しました。しかし他のツールを利用して結果を観測することもできます。 たとえば Grafana dashboards を利用すると以下の画像のようなリッチな感じで結果を見ることもできます。 引用: https://grafana.com/docs/k6/latest/results-output/grafana-dashboards/ まとめと今後の展望 k6 と Datadog を利用することで、簡単かつ効果的に負荷テストを実施できます。今後は、Write系エンドポイントへの負荷テストや、RDS Proxy間のパフォーマンス改善に取り組んでいく予定です。 もし負荷テストを検討している方がいれば、ぜひ試してみてください!
はじめに Corporate Engineering という部署で社内営業組織が業務で使用するSalesforceの運用や社内システム開発を担当している瀧山です。 RevCommではコミュニケーションツールとしてSlack、ドキュメント管理ツールとしてNotionを使用しています。 今回は、Slackで投稿された有益なスレッドにリアクション(以下、「スタンプ」と記載)をつけた際にNotion連携するアプリケーションを社内営業組織向けに作成したので概要や仕組みなどを説明したいと思います。 本ブログ内で書かないこと 処理のコーディング 使用技術の詳細な説明 想定読者 Slack Boltを使用したSlack App開発に興味がある方 Slackに投稿されたスレッドをナレッジ化したいと思っている方 Notion連携に興味がある方 開発に至る背景 依頼元の営業チームでは、不明点などがあった際にSlackやNotionにて関連のスレッドやページを検索するが情報量が多くて答えに行きつきにくく、検索に時間がかかってしまうという課題がありました。またドキュメント化を行う時間も中々取ることができなくて、以前誰かが経験したナレッジを共有することが上手くできないことから知識が属人化してしまうという課題もありました。 上記2つの課題から、テキストとして記録されているSlackのスレッドにスタンプをつけるだけで、Notionの指定のページに情報が集約される仕組みを作るのと定期的に集約された情報をチームで共有し合う時間を設けることにより知識の平準化を図ることを目的として、今回の開発に至りました。 開発したアプリケーションについて 使用した技術 Slack Bolt ユーザーのSlack内のアクションをトリガーにしてバックエンドで処理を行うために使用 Python Slack からのイベントをハンドリングして処理するバックエンドロジックを実装 Google Cloud Cloud Run を用いてサーバーレスな環境でアプリケーションをホストするために使用 Notion API Notionの指定のページに情報を登録するために使用 処理概要 全体の処理の内容は以下のシーケンスになります。 処理フロー 処理の流れだけだとイメージしづらいと思うので、キャプチャを元に補足したいと思います。 【シーケンス①〜②の処理】 連携したいSlackの投稿にスタンプを付与します。 【シーケンス③〜④の処理】 Notion に新規ページが作成されます。ページはViewとして管理されており、データテーブルとして一覧化しています。デフォルト作成時にはタイトルとタグ(情報を仕分ける際に使用)が適切に設定されていない状態になります。 補足なのですが、自動連携する情報として下記のような情報があります。 投稿者:スレッドを投稿したユーザー 推薦者:スタンプを付与したユーザー URL:スレッドのURL AI要約:Notion側で自動でページ情報の要約を生成してくれる 内容:スレッドのテキスト 【シーケンス⑤〜⑦の処理】 作成したSlack Appからスタンプを付与したユーザーに対してDMが届きます。 DMの内容には、「作成したNotionページのURL」、「作成したNotionページを更新するためのフォームボタン」があります。 「タイトルとタグを設定する」ボタンを押すと下のキャプチャの様なフォームが開きます。 適宜必要な情報を入力して送信を押下します。 【シーケンス⑧の処理】 フォームで入力した内容でタイトルとタグを更新します。 工夫したところ 営業チームの担当者から「スタンプを押してSlackからNotionへ連携する際に、タイトルとタグは手動で入力する必要があるがNotionページを開いてタイトルとタグを入力することは若干手間のため運用が回らない可能性がある。なんとかSlack内で完結できないか?」という要望をいただきました。 「Slack内で完結すること」と「なるべく手間となる作業がないこと」の二つを意識して仕組みを検討しました。 結果、スタンプを押したユーザーにDMを送信、フォームで必要事項の入力だけであれば運用が回りそうという回答をいただき実装しました。(シーケンス⑤〜⑧の内容) 詳細は割愛しますが、送信されたフォームイベントを受信するために Interactive messages を使用しました。 導入後 現在は営業チームだけでなく、Customer Successのチームでも活用されており、日々Slack内の投稿がナレッジとしてNotionへ集約されており、各チームでナレッジの共有時間を設けることで知識の平準化が行われています。 今後 アプリケーションをさらに進化させるために、Vertex AIを活用した自動化を検討しています。具体的には、自然言語処理モデルを用いてSlackの投稿内容からタイトルを自動生成し、分類モデルを用いて適切なタグを自動付与する機能の実装を目指しています。これにより、ユーザーはタイトルやタグを入力する手間が省け、より迅速かつ気軽にナレッジを集約できるようになります。また、要約モデルを活用することで、長文の投稿内容を簡潔に要約し、Notionページに表示することで、ユーザーがより効率的に情報を把握できるようにしていく予定です。
この記事は RevComm Advent Calendar 2024 の 12日目の記事です。 はじめに こんにちは、バックエンドエンジニアの矢島です。 普段は主にバックエンド領域の開発・保守・運用を行っていますが、チームのサブマネージャーとして組織の運用改善なども行っています。 多くのソフトウェア開発チームが1度は直面する課題の一つに「属人化」があります。特定の機能や領域の知識が特定のメンバーに集中してしまい、その人が不在の際に対応できない、あるいは新機能の開発スピードが落ちてしまうといった問題です。 この記事では、弊社のプロダクトMiiTel の開発チームで実施した、属人化の解消への取り組みについて紹介します。 属人化の解消の背景 RevCommでは、電話・Web会議・対面での全ての会話を最適化する音声解析AIのMiiTelを開発しています。 中でも私が所属してる電話のデータを解析・可視化するMiiTel Phone Analytics は、RevComm創業当初からあるプロダクトで様々な機能拡張が行われてきました。 製品の成長に伴い多くの機能を保守・運用することに加え、プロダクトの改善も行う必要があります。 そこで半年のうちに3名のメンバー増員があり、以下のような課題に直面しました。 各機能の仕様や実装の複雑化による新メンバーの学習コストの増加 特定のメンバーしか対応できないことによる開発や問い合わせ対応のボトルネックの発生 一部メンバーへの負担の集中や、チーム全体の生産性の低下の恐れ これらの課題に対し、「チームの全メンバーが全ての機能の調査・開発を行えるようにすることで中長期視点でのチームの生産性を向上させる」というミッションを設定し、約半年間かけて属人化の解消の取り組みを実施しました。 属人化の解消への取り組み 現状の把握と効果測定の方法を決定 属人化効果を測定するため、以下のようなアンケートを属人化の解消の取り組み前と、1ヶ月単位のスプリント終了時に実施することにしました。 各機能への経験を選択してもらう 「該当の機能の存在を知っているか」、「機能を使ったことがあるか」、「ドキュメントを読んだことがあるか」、「レビューをしたことがあるか」、「問い合わせ対応をしたことがあるか」、「実装したことがあるか」という複数回答可の選択項目を用意しました。 各機能への対応における精神的な負担度を5段階で評価してもらう 取り組み始めた当初は、精神的な負担が属人化に繋がるメインファクターであると考えており、各機能の理解が進むことで、機能対応時の精神的負担が減り、属人化解消に繋がると考えていました。この考えは後で更新されることになります。 機能ごとの理解度や不安要素を自由記述してもらう 施策1:機能共有会の実施 施策としてまず取り組んだのが、各機能の詳細な共有会の実施です。タイムパフォーマンスを考えた時に全ての機能の共有会を実施することは好ましくないため、アンケートから特に属人化している機能や新機能についての共有会を実施しました。 特に複雑な機能については、機能の概要・設計思想とシステムアーキテクチャ・コードリーディングのように複数回に分けて共有を行いました。 施策2:意図的なタスクのアサイン 学習のレベルにおいても、知っていることよりも行うことのほうがより高いレベルに位置付けられるのと同じで、ドキュメントを読むよりも実際に手を動かす方が理解が進むということはよくあると思います。そこで実際に普段触れる機会の少ない機能について、本来は優先度が高くないものも含めた以下のようなタスクを作成し、意図的にアサインしました。 既存機能の単体テスト追加:コードリーディングができることに加え、テストケース作成を通じて機能の仕様理解 ・コードの品質向上と保守性の改善を図れます。 エラーハンドリング改善タスク:コードリーディングができることに加え、保守・運用の改善を図れます。 音声ファイル処理バッチの改善:データフローの全体像の把握ができ、パフォーマンスチューニングも実現できます。 なかでも単体テストの追加は、実際のコードを動作させながらテストを書くことで、自然と機能への理解が深まるという効果がありました。顧客影響がないコードを追加できるという点でも、属人化の解消を推進するタスクとしてはおすすめです。 アンケートの結果からチームに施策の相談 施策1, 2 を実施したところで2回のスプリントが終わり、アンケートも2回実施しました。 当初の取り組みでは、精神的負担の軽減という指標で効果を測定していましたが、アンケート結果に改善が見られなかったため、チーム全体で相談の機会を設けました。 そこから新たな施策やこれまでの取り組みの改善の発案がされ、実際に以降の取り組みに活かすこととなりました。 アンケートの改善 精神的な負担が属人化に繋がるメインファクターであると考えてアンケートを作成していましたが、「精神的負担」という指標よりも「各機能に対応できるか」という具体的な指標の方が、属人化解消の目的に適していることが明確になりました。この気づきを基に、アンケートの設計を以下のように見直しました。 各機能への対応における精神的な負担度を5段階で評価してもらう → 問い合わせ調査やバグフィックスを依頼されたときに対応できるかを判断してもらう 「わからない」「対応できない」「内容の指示を貰えば対応できる」「基本1人で進め」「必要に応じて質問しながら対応できる」「1人で対応できる」という選択項目を用意しました。 これにより、対応できはするけど精神的に負担はあるという状態があっても、より属人化の解消という目的に即した効果測定が可能になりました。 施策3: オンコール制の導入 アンケート項目に問い合わせ対応経験を聞く項目があったことから、一次対応を当番制にしてよいのではないかという意見があがりました。 そこで、業務時間中に発生する問い合わせに対して迅速な一次対応と、問い合わせ対応を通じたキャッチアップの促進を行うための仕組みとして、オンコール制を導入しました。週替わりで2名のエンジニアがメインとサブに分かれてオンコールの担当となり、問い合わせ内容の初期調査や必要に応じて適切なチームメンバーへのエスカレーションを行うようになりました。 この制度により、問い合わせ対応の効率化だけでなく、各メンバーが様々な機能についてキャッチアップする機会が増えました。「問い合わせ対応を通じて自然と機能理解が深まった」という声がメンバーからも出るようになり、結果として属人化の解消にも貢献しました。 施策4:カオスエンジニアリングの実践 最後の比較的大きな施策として、意図的に障害を発生させ、その調査や対応を通じて理解を深める「カオスエンジニアリング」を実施しました。 カオスエンジニアリングとは、本番環境で起こりうる障害や異常な状態を、制御された環境で意図的に再現し、システムの回復性や耐障害性を検証する手法です。Netflixが先駆けとなり、現在では多くの組織で採用されています。 RevComm でも各開発チームでカオスエンジニアリングの取り組みが定期的に行われており、私も別チームで行ったことがありました。 属人化解消の取り組みを行うチームでは行ったことはありませんでしたが、この手法を知識共有の手段として活用することにしました。 通常のカオスエンジニアリングは本番環境の堅牢性や可用性を確認する目的で行われますが、私たちの場合は開発環境で実施し、障害対応を通じた学習に重点を置きました。これにより、メンバーは実際の障害対応に近い形で、システムの動作原理や障害発生時の調査方法を学ぶことができました。 具体的良かった点は以下の通りです。 チーム全体での障害調査と解決プロセスの実践 ・調査手法やログの見方を実践的にキャッチアップできる。 チームメンバー間での知識共有が促進される。 システムの挙動確認と知識の共有 ・システムの依存関係や連携フローを具体的に把握できる 。 トラブルシューティングのノウハウを蓄積できる。 この取り組みは特に効果的で、振り返りでは「新鮮な体験で機能理解が大きく進んだ」という評価を得ました。 結果と考察 約半年間の取り組みによって、以下のような結果が得られました。 アンケートでは4つの施策を通して精神的負担が下がることはなかった。 一方で、メンバーが対応できると言える機能が増加し続けた。 対象としていた機能については「わからない」「対応できない」と回答するメンバーはゼロとなった。 当初設定していた「精神的負担」という指標では改善が見られませんでしたが、業務の属人化の解消は十分実現できていると考えられる結果となりました。 まとめ この取り組みを通じて、以下のような重要な学びが得られました。 目的に合った適切な指標を設定することが重要。一方でOKRの Key Result のように途中で指標を変えることも必要に応じて検討することで進みながらの改善が可能。 ドキュメントやコードリーディングよりも、実際の体験を通じた学習の方が効果的。 個人の努力だけでなく、チームで会話し継続的に実施・改善していくことが重要。 今後は、これらの学びを活かしながら、新しい機能や技術が導入された際にも、スムーズに知識を展開できる体制を維持していきたいと考えています。また、この経験を他のチームとも共有し、組織全体としての改善にも繋げていければと思います。 属人化の解消は、一朝一夕には実現できない課題です。しかし、明確な目標設定と複数の施策の組み合わせ、そして適切な指標による効果測定を行うことで、着実に改善を進めることができます。皆様の組織でも、本記事で紹介した取り組みが何かしらの参考になれば幸いです。
KubernetesではAPIサーバーやバッチ処理、イベント駆動型のタスクなど、さまざまなケースに合わせた「ワークロードリソース」の種類を選択し、柔軟に運用できます。 本記事では、弊チームのシステム設計の例をもとに、ワークロードリソースの中でも、ScaledJobとScaledObject + Deploymentの違いに注目して、使い分けにおける学びを共有できればと思います。 結論 設計の背景 初期選択:ScaledJobの活用と課題 方向転換:ScaledObject + Deploymentの選択 manifestの例 学びと今後の展望 結論 イベント駆動型タスクであっても、実行間隔がPod起動にかかる時間より短いのであればScaledJobではなくScaledObject + Deploymentを選定するのが良いです。 設計の背景 ユーザーが行った通話の情報(通話時間、対応者、発信か着信か、不在かなど)を整えてデータベースに保存する仕組みを新たに構築することになりました。このデータは、サービスのさらなる利便性向上のために活用されるものです。 しかし、既存のシステムから最新の情報をスピーディーに連携することが難しく、既存の仕組みのみでは「最新情報を遅延2分以内に取得したい」という計画当初の要件を満たすことができませんでした。また、長期間のデータ集計の際に、レスポンス遅延が発生する懸念がありました。そこで、新たな仕組みを導入することになりました。 この通話情報は1000件/分以上の頻度で送られてきても処理できる必要があります。 ただし遅延は減らしたいものの、リアルタイム反映が求められるシーンとは利用シーンの切り分けができました。そこで将来的に通話情報を複数形式で保存する可能性を見越し、AWSのSNS+SQSのファンアウト形式を採用しました。この形式を取ることで、情報を柔軟に拡張し、新たな処理を追加することになっても影響を最小限に抑えることができます。 また、これにより通話情報を送信する側の通話履歴管理システムと、通話情報を利用する側の連絡先システムとで、関心ごとの分離を明確にできました。それぞれのシステムを運用している別々のチームが独立してシステムの改善や保守を進めやすい構成にできた点も大きなメリットです。 構成図 初期選択:ScaledJobの活用と課題 「イベント駆動型タスクの処理になる」と考えたことから、最初にScaledJobを選択しました。ScaledJobは、SQSをトリガーにしてジョブをスケーリングできることから最適に思えたためです。さらに、処理時間が短いタスクなので、Podの台数を増やすことで大量のSQSであってもさばけると考えました。 しかしScaledJobではトリガーごとに新しいPodを立ち上げる必要があります。つまりPod起動時間がかかります。メインとなる処理時間は短時間だったとしても、Pod起動の時間に数分かかるとすると、起動中にも次々やってくる1000件/分のSQSを処理するためには結果的に同時に何千ものPodを立ち上げていないといけないことになります。これでは、求めるスピードや効率に達することが難しいと判断し、別のアプローチを検討することになりました。 方向転換:ScaledObject + Deploymentの選択 次に選んだのは、ScaledObject + Deploymentを用いる方法です。ScaledObjectでスケーリングをして、DeploymentのPod内で、SQSのメッセージを定期的にポーリングし続け、ScaledObjectのスケーリングとは別に内部でも並列で処理する形で実装しました。これにより、Podの立ち上げ時間の無駄を削減し、大幅に効率を改善することができました。 Rolloutを選ばずDeploymentを採用した理由は、ジョブ処理ではサービスを維持しながら安全にデプロイする必要がないためです。APIのように即時性を求められるものではなく、メッセージがSQSに溜まる設計であるため、サービスを一時停止しても問題ないからです。 また、Rollout は ArgoCD によるワークロードリソースなので、特に理由がなければKubernetesの標準機能であるDeploymentを選定し、シンプルに扱えるようにしたいと思いました。 ScaledObject + Deploymentを選択したことで、最新情報の取得速度は大幅に改善され、当初目指していた以上に遅延縮小することができています。また、Deploymentの利用により、必要なPodの台数を削減し、リソース効率も向上しました。 manifestの例 今回の構成のmanifestでの定義の例と、チューニングのポイントです。 apiVersion : apps/v1 kind : Deployment metadata : name : sample-deployment spec : selector : matchLabels : app : sample-app template : metadata : labels : app : sample-app spec : terminationGracePeriodSeconds : 300 containers : - name : sample image : DOCKER_IMAGE command : [ "echo hoge" ] --- apiVersion : keda.sh/v1alpha1 kind : ScaledObject metadata : name : sample-scaledobject spec : scaleTargetRef : name : sample-deployment pollingInterval : 60 minReplicaCount : 1 maxReplicaCount : 16 triggers : - type : aws-sqs-queue Deployment terminationGracePeriodSeconds scale in発生時に処理途中で終わったSQS messageに関してはpopしたmessageをdeleteせずに終わる可能性がある メイン処理に必要な時間を考慮した上で適切に設定する必要あり 余裕をもたせると良い ScaledObject maxReplicaCount 一番SQSが多い時間帯に十分なPodが起動できるように設定 pollingInterval scale in/outが発生する頻度の調整をする 急激なSQS量の増減に備えるため 学びと今後の展望 Kubernetesは学習コストが高いと言われますが、実際に使う中で得た知識や工夫を共有し合うことで理解を深めることが重要だと感じています。また、今回のように要件に応じて最適なアプローチを模索するプロセスは非常に有意義でした。 今後は、さらにKubernetesの知識を深め、最新バージョンの機能も活用することで、よりシンプルで効率的な設計・運用を目指していきたいです。 この記事が、Kubernetesの運用に興味を持っている方々の参考になれば幸いです。
はじめまして。 RevComm でフロントエンドエンジニアをしている大石と申します。 私の所属しているチームでは、Lean スタートアップの考えを基にプロダクト開発に取り組んでおり、エンジニアが機能を実装してリリースして終わりではなく、そのリリースした機能の利用実態を定量データとして収集し、プロダクト成長の方針などの検討に活かしています。 その定量データを収集する方法として Google Analytics を利用しており、今回の記事では Google Analytics で詳細なデータを収集するための実装方法を紹介します。 ユーザープロパティを設定してユーザーをセグメントで分ける Google Analytics では、 ユーザープロパティ を使用してユーザーに関する任意の情報を付与することができます。 これによりユーザーを任意条件のセグメントとして分けることができ、特定のイベントのユーザーごとの実行割合をセグメント単位で算出したり、リリースした機能を使ってくれているかどうかを分析したりできます。 ユーザープロパティの設定 Google Analyticsの管理画面で ユーザープロパティ を設定します。 例えば、ユーザーのステータスやWebサービスにおける権限情報などを追加します。 公式ドキュメント にはいくつか方法がありますが、ここでは Google タグでの方法を紹介します。 ユーザープロパティの送信 ユーザーがサイトにアクセスした際にユーザープロパティをGoogle Analyticsに送信します。 例: gtag('set', 'user_properties', {'user_id': 'USER_ID', 'role': 'admin'}); ユーザースコープのカスタムディメンションを作成 Google Analyticsの管理画面で ユーザースコープのカスタムディメンションを作成 します。 カスタムディメンションの範囲を「ユーザー」に設定し、先ほど送信したユーザープロパティに対応するディメンションを作成します。 例: user_id や role などのカスタムディメンションを作成します。 ユーザープロパティの活用例 既存UIに対する大規模な変更を加える場合、いきなり新UIを適用するのではなく新旧両方のUIを切り替えられるようにして提供することでユーザーの混乱を抑えることができます。 この場合に、新UIがどれだけユーザーに受け入れられているのかをGoogle Analyticsのユーザープロパティを利用して調査することができます。 例として、サービス全体のリデザインに伴う新UIを提供する場合に切り替えボタンを用意しておき、そのボタンをクリックしたら新旧UIを切り替えられるような機能を想定します。 その際にブラウザリロードしても新旧どちらを選択しているのかを保持しておきたいため、ローカルストレージなどに {is_new_ui: true || false} のような形で保存しておきます。 前述したユーザープロパティとしてこのローカルストレージの値を送ることで、どれくらいのユーザーが新UIを使っているのかをGoogle Analytics上で調査することができ、これにより新UIが受け入れられているのかどうかが判断できます。 一例ではありますが、こういった活用を通じてユーザーの行動やニーズをより深く理解し、プロダクトの改善戦略の最適化に役立てることができます。 カスタムプロパティを追加しイベント実行時の詳細なデータを送る Google Analytics ではイベントをトラッキングする際にカスタムプロパティを追加することができます。 これにより、特定のイベントに関連する詳細な情報を収集することが可能です。 公式ドキュメント カスタムプロパティを使ってイベントの詳細を確認する方法 カスタムイベントの設定 フロントエンドのコードで、イベントが発生した際にカスタムプロパティを含むイベントをGoogle Analyticsに送信します。 例: gtag('event', 'action_name', {'custom_property': 'xxx'}); イベントスコープのカスタムディメンションを作成 Google Analyticsの管理画面で、 イベントスコープのカスタムディメンションを作成 します カスタムディメンションの範囲を「イベント」に設定し、先ほど送信したカスタムプロパティに対応するディメンションを作成します カスタムプロパティの活用例 大量のデータを分析するための機能としてダッシュボードがあります。 一般的にダッシュボードにはデータを絞り込むためのフォームが用意されていますが、そのフォームをサブミットした際に「サブミットした」ことだけをイベントとして収集していても絞り込み機能が使われたことしかわかりません。 ユーザーがダッシュボードで何を見ているのかを知るためには、どういう条件で絞り込みをしているのかの詳細情報まで知る必要があり、その詳細情報はカスタムプロパティとして収集することができます。 例えば、複数の選択肢から期間を選択するセレクトボックスがあり、その選択肢は以下のように定義されているとします。 const periods = [ { value : 1 , label : '今日' , } , { value : 2 , label : '先週' , } , { value : 3 , label : '先月' , } , { value : 4 , label : '過去30日' , } , ] フォームがサブミットされた時点でユーザーがどの期間を選択していたのかを把握するために、以下のような形で選択された期間の値をカスタムプロパティとして収集することができます。 gtag( 'event' , 'form_submit' , { 'selected_period' : ` ${ periods[].value } ` } ); これにより、ユーザーはどの期間のデータをよく見ているのかを分析することができます。 また、ユーザーで絞り込んでデータを見ることができる場合に、「自分自身」を絞り込み条件に含めているかどうかを収集するような使い方もできます。 const own = { id : 1 , label : '山田 太郎' , } const users = [ { id : 1 , label : '山田 太郎' , } , { id : 2 , label : '鈴木 一郎' , } , { id : 3 , label : '佐藤 花子' , } , ] const selectedUsers: typeof users = [] ユーザーの絞り込みにユニークなid情報を利用している場合、そのユーザー自身のidが絞り込み条件の中に含まれているかどうかを判定し、以下のような形でカスタムプロパティを送ることで、ユーザーが自分自身のデータを見る目的でダッシュボードを利用しているのかどうか、という分析をすることができます。 gtag( 'event' , 'form_submit' , { 'is_selected_own' : selectedUsers. some ( u => u. id === own. id ) } ); エンジニアならではの提案でプロダクトを改善していきましょう ユーザーにとって価値あるプロダクトを作るためにはユーザーのことをより深く知る必要があり、そのための方法は実際のコードレベルで実装ができるエンジニアだからこそ提案できることがあります。 この記事で紹介した Google Analytics の活用方法が、実際のプロダクト改善のための分析のお役に立てれば幸いです。
Hi, Jose here! I recently began developing a private git package to be used by many services from our organization. While the basic setup was relatively straightforward, I quickly realized how scaling it encompassed many concepts. Factors like integrating with various repositories and adapting your CI/CD pipelines can significantly raise the bar. In this blog post, I will walk you through the process of installing a private git dependency and demonstrate how to use Poetry effectively to manage packages from multiple code repositories. Requirements Python 3.10 Poetry version 1.8.4 Working with public git dependencies Installing a dependency in Poetry is simple enough. Just run poetry add package_name . This will add the respective package to the pyproject.toml file. For git dependencies, we must specify the location of the repository with the git key. Let’s install the requests library from Github. poetry add git + https: //github.com/psf/requests.git@v2.32.2 Now your pyproject.toml will look as follows requests = { git = "https://github.com/psf/requests.git" , rev = "v2.32.2" } If you don’ specify the rev property, Poetry will take up the latest commit of the main branch. Check the official docs for more information. Installing a private git dependency Poetry needs to authenticate to your git provider to install private dependencies. In the case of Github, we create a Personal Access Token (PAT). A Personal Access Token provides a secure way to authenticate to GitHub without the need of a password. Generate a PAT, set up authentication and install the package. $ poetry config repositories . git - org - project https : //github.com/org/private_lib.git $ poetry config http - basic . git - org - project username $PAT_TOKEN $ poetry add git + https: //github.com/org/private_lib.git Notice that the pyproject.toml looks almost the same as the public repo. Poetry ensures that your private credentials aren’t reflected. [ tool . poetry . dependencies ] python = ">=3.10, <3.13" requests = { git = "https://github.com/psf/requests.git" , rev = "v2.32.2" } pydantic = "^2.10.3" private_lib = { git = "https://github.com/org/private_lib.git" } Managing multiple private repositories. Now imagine your project needs your sales team’ internal libraries hosted in a private GitHub repository, but your research team maintains their libraries in an AWS CodeArtifact repository. How would you seamlessly integrate both? Enter sources . Poetry uses sources to discover and install packages in your project. The default one is PyPI. Sources enable seamless integration of internal, third-party libraries without disrupting the main dependency flow. In the example above, we would run $ poetry source add -- priority = supplemental research https : //domain.d.codeartifact.ap-northeast-1.amazonaws.com/pypi/research/ $ poetry source add -- priority = supplemental sales https : //github.com/org/sales.git Poetry will add the following to your pyproject.toml . [[ tool . poetry . source ]] name = "research" url = "https://domain.d.codeartifact.ap-northeast-1.amazonaws.com/pypi/research/" priority = "supplemental" [[ tool . poetry . source ]] name = "sales" url = "https://github.com/org/sales.git" priority = "supplemental" We use priority supplemental to tell Poetry that PyPI should still be the main code repository. You can tweak the priorities to your needs, for instance, you can disable PyPI completely . Remember that we still need to setup authentication for each source. $ poetry config repositories . research https : //domain.d.codeartifact.ap-northeast-1.amazonaws.com/pypi/research/ $ poetry config http - basic . research username ca_token $ poetry config repositories . sales https : //github.com/org/sales.git $ poetry config http - basic . sales username token Finally we install our libraries $ poetry add sales - lib -- source sales $ poetry add research - lib -- source research Conclusion In this blog post we reviewed the steps to add private git repositories into Poetry. We also looked at how to manage multiple code repositories with Poetry. In a production environment, we would containerize the application and integrate it to a CI/CD pipeline. Those steps, although similar in nature, require extra care specially when using secret tokens. References https://python-poetry.org/docs/dependency-specification/#git-dependencies https://python-poetry.org/docs/repositories/#package-sources https://medium.com/@irac.grgic/poetry-automatically-configure-credentials-for-all-private-repositories-541ce3b78759
はじめに RevComm のフロントエンドエンジニアの上川です。 MiiTel Call Center というプロダクトの開発を担当しています。 これまで、ロードマップ機能の開発では、バックエンドとフロントエンドの担当者が完全に分かれていました。 今回は、フロントエンドを担当してきた自分が、バックエンド開発に挑戦してみた経験と、そこから得た学びについて共有したいと思います。 バックエンド開発に挑戦した背景 バックエンドチームが他のタスクに注力している状況を受け、コールセンターの「ロードマップ機能」をフロントエンドチームだけで実装する提案がありました。 以前からバックエンド開発に興味があったため、この機会に挑戦することを決めました。 実装内容 コールセンターに表示する新しい項目の実装を担当しました。 具体的には、新しい項目の値をバックエンドで計算・取得し、フロントエンド側のテーブルに表示するまでの一連の実装を行いました。 この経験により、これまでのフロントエンド中心の視点から、データ取得からUI表示までの全体的なフローを把握できるようになりました。 開発の進め方 PMの主導のもと、以下のように段階的に進めていきました。余裕を持った計画のおかげで、不安なく円滑に進行することができました。 最初の1ヶ月は、Design Docsを活用しながら週1回の短いミーティングで要件を詰めていきました。 Design Docsには以下の内容を記載していきました。 項目の値の計算式 既存の類似項目の実装調査と修正箇所の特定 Redashを活用したSQL文の作成 次に、他のフロントエンドメンバーとペアで1つの機能を実装し、それぞれの担当部分をミーティングで共有しました。 最後に、1つの機能を単独で実装しました。 チームサポートのありがたさ ENUMを扱うマイグレーションやAWS Athenaでのテーブル再構成など、初めて触れる領域で苦労することもありました。 ただし、バックエンドチームのメンバーにSlackやMeetで気軽に質問できたおかげで、エラーや不明点をスムーズに解決できました。 バックエンドとフロントエンドが同じスクラムチームで活動していることや、チームオフサイトで直接交流する機会があったことで、質問しやすい環境が自然とできていたと感じています。 新たな学び Python 以前は基礎的な経験しかなかったPythonですが、今回文法を体系的に学び直し、Pydanticなどのライブラリの役割についても深く理解することができました。 GraphQL QueryやSubscriptionの仕組みについて、データベースからデータを取得し、レスポンスとして返すまでの一連の流れを理解することができました。 DB データベースマイグレーションの実践的な手法と重要な注意点について学びました。 AWS AthenaのテーブルとECSのAuto Scalingの基本的な操作を経験しました。 エラー対応 バックエンドの仕組みを理解したことで、Datadogから通知されるエラーの内容や影響範囲を正確に把握できるようになりました。 レポートのバッチ処理でエラーが発生した際も、どのデータが欠損する可能性があるのか、そしてそれがフロントエンドの表示にどう影響するのかを的確に判断できるようになりました。 ロードマップ機能を一人で実装できるようになった 今回の最大の成果は、 フロントエンドからバックエンドまで、ロードマップ機能を一人で実装できるようになった点 です。 これまで他のメンバーに頼っていた領域も自力で対応できるようになり、開発の効率性と柔軟性が大幅に向上しました。 この経験を糧に、今後はより大規模な機能開発にも挑戦していきたいです。 技術的な視野が広がったことで、複雑な課題にも自信を持って取り組めるようになりました。 おわりに 今回、バックエンド開発に挑戦したことで、新たなスキルを身につけることができました。これまでブラックボックスだった部分が明確になり、フロントエンドとバックエンドの仕組みを深く理解できるようになりました。 バックエンドメンバーやチームのサポートのおかげで、未知の領域や初歩的な課題を着実に乗り越え、自走できる範囲が広がりました。その結果、ロードマップ機能をフロントエンドからバックエンドまで一人で実装できるようになり、開発の効率性と柔軟性が向上し、自身の成長も実感できました。 今後は、この経験で得た知見とスキルを活かし、より大規模で複雑な機能開発にも積極的に挑戦していきます。さらなる学びを重ね、チームとプロダクトに貢献できるエンジニアとして成長を続けていきたいと思います。
はじめに vinxi はフルスタックアプリケーションやメタフレームワークの構築が可能なパッケージです。 開発サーバーとバンドラーに Vite を、本番サーバーには Nitro を使用しています。 SolidStart や TanstackStart で採用されています。 今回は vinxi を使ってメタフレームワークを作ってみます。 進めるにあたり、公式のサンプルや以下の記事を参考にさせていただきました。 Bullding a React Metaframework with Vinxi Simple RSC With Vinxi セットアップ package.json を作成し、 npm init -y 以下の内容を追加します。 { " name ": " try-vinxi ", " version ": " 1.0.0 ", " description ": "", " type ": " module ", " scripts ": { " dev ": " vinxi dev ", " build ": " vinxi build ", " preview ": " vinxi preview " } , " keywords ": [] , " author ": "", " license ": " ISC ", " dependencies ": { " @vinxi/react ": " ^0.2.5 ", " @vitejs/plugin-react ": " ^4.3.4 ", " react ": " ^18.3.1 ", " react-dom ": " ^18.3.1 ", " vinxi ": " ^0.5.0 " } , " devDependencies ": { " @types/react ": " ^18.3.12 ", " @types/react-dom ": " ^18.3.1 ", " typescript ": " ^5.7.2 " } } tsconfig.json を作成します。 { " compilerOptions ": { " module ": " ESNext ", " moduleResolution ": " bundler ", " jsx ": " react-jsx ", " esModuleInterop ": true , " strict ": true } } 依存関係をインストールします。 npm install SPA まずはシンプルな SPA モードを作成します。 index.ts を作成 ルートに index.ts を作成します。空の HTML を返すだけのハンドラーです。 import { eventHandler } from 'vinxi/http' ; export default eventHandler(() => { return new Response ( `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vinxi</title> </head> <body> <div id="root"></div> <script src="./src/entry-client.tsx" type="module"></script> </body> </html>` , { status : 200 , headers : { 'Content-Type' : 'text/html' , } , } ); } ); entry-client.tsx を作成 src ディレクトリを作成し、その中に entry-client.tsx を作成します。 import { createRoot } from 'react-dom/client' ; import Counter from './counter' ; createRoot( document . getElementById ( 'root' )!).render( < Counter /> ); counter.tsx を作成します。 import { useState } from 'react' ; export default function Counter () { const [ count , setCount ] = useState( 0 ); return ( < div > < h1 > Counter </ h1 > < p > { count } </ p > < button onClick = { () => setCount(count + 1 ) } > Increment </ button > </ div > ); } app.config.ts を作成 ルートに app.config.ts を作成し、 createApp 内に vinxi の設定を記述します。 @vitejs/plugin-react を追加します。vinxi は Vite を使用しているため、Vite のプラグインをそのまま使用できます。 npm install @vitejs/plugin-react spa ルーターを追加します。handler には先ほど作成した index.ts を指定します。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; export default createApp( { routers : [ { name : 'spa' , type : 'spa' , handler : './index.ts' , target : 'browser' , plugins : () => [ pluginReact() ] , } , ] , } ); 開発サーバーを起動します。 npm run dev カウンターが表示されることを確認します 🎉 SSR 次に SSR モードを作成します。 app.config.ts を編集 まずは app.config.ts を編集します。 spa ルーターを削除し、 client と ssr ルーターを追加します。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; export default createApp( { routers : [ { name : 'client' , type : 'client' , handler : './src/entry-client.tsx' , target : 'browser' , plugins : () => [ pluginReact() ] , base : '/_build' , } , { name : 'ssr' , type : 'http' , handler : './src/entry-server.tsx' , target : 'server' , plugins : () => [ pluginReact() ] , } , ] , } ); 各ファイルを作成していきます。 app.tsx を作成 src ディレクトリに app.tsx を作成します。 createAssets は各アセットを注入するコンポーネントを作成します。遅延コンポーネントになっているため、 Suspense でラップします。 import { getManifest } from 'vinxi/manifest' ; import { createAssets } from '@vinxi/react' ; import { Suspense } from 'react' ; import Counter from './counter' ; const Assets = createAssets( getManifest( 'client' ).handler, getManifest( 'client' ) ); export default function App () { return ( < html > < head > < Suspense > < Assets /> </ Suspense > </ head > < body > < div id = "root" > < Counter /> </ div > </ body > </ html > ); } entry-client.tsx を編集 createRoot を hydrateRoot に変更します。 クライアントのランタイムを読み込むために vinxi/client をインポートします。 import { hydrateRoot } from 'react-dom/client' ; import App from './app' ; import 'vinxi/client' ; hydrateRoot( document , < App /> ); entry-server.tsx を作成 src ディレクトリに entry-server.tsx を作成します。 import { getManifest } from 'vinxi/manifest' ; import { eventHandler } from 'vinxi/http' ; import { renderToPipeableStream } from 'react-dom/server' ; import App from './app' ; export default eventHandler( { handler : async ( event ) => { const clientManifest = getManifest( 'client' ); const stream = await new Promise ( async ( resolve ) => { const stream = renderToPipeableStream( < App /> , { onShellReady () { resolve(stream); } , bootstrapModules : [ clientManifest.inputs[clientManifest.handler ] .output.path, ], bootstrapScriptContent : `window.manifest = ${ JSON . stringify ( await clientManifest.json() ) } ` , } ); } ); event.node.res.setHeader( 'Content-Type' , 'text/html' ); return stream; } , } ); 開発サーバーを起動します。 npm run dev SSR された HTML が返ってきました 🎉 File system routing vinxi はファイルシステムルーティングを作成するための機能を提供しています。 FileSystemRouter を作成 vinxi の BaseFileSystemRouter を継承した FileSystemRouter を作成します。今回は app.config.ts の中に書いていきます。 toPath メソッドは引数にファイルのパスを受け取り、ルートのパスを返します。 cleanPath はディレクトリ名と拡張子を取り除く vinxi のユーティリティ関数です。 toRoute メソッドは引数にファイルのパスを受け取り、ルートオブジェクトを返します。このルートオブジェクトは vinxi/routes モジュールによってアプリケーションに提供されます。 class FileSystemRouter extends BaseFileSystemRouter { toPath ( src : string ) { const routePath = cleanPath(src, this .config) . slice ( 1 ) . replace ( /index$/ , '' ); return routePath?. length > 0 ? `/ ${ routePath } ` : '/' ; } toRoute ( filePath : string ) { return { path : this .toPath(filePath), $component : { src : filePath, pick : [ 'default' ] , } , } ; } } 各ルーターの routes プロパティに FileSystemRouter を追加します。最終的に app.config.ts は以下のようになります。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; import { BaseFileSystemRouter, cleanPath } from 'vinxi/fs-router' ; import path from 'node:path' ; class FileSystemRouter extends BaseFileSystemRouter { toPath ( src : string ) { const routePath = cleanPath(src, this .config) . slice ( 1 ) . replace ( /index$/ , '' ); return routePath?. length > 0 ? `/ ${ routePath } ` : '/' ; } toRoute ( filePath : string ) { return { path : this .toPath(filePath), $component : { src : filePath, pick : [ 'default' ] , } , } ; } } export default createApp( { routers : [ { name : 'client' , type : 'client' , handler : './src/entry-client.tsx' , target : 'browser' , plugins : () => [ pluginReact() ] , base : '/_build' , routes : ( router , app ) => { return new FileSystemRouter( { dir : path. join (__dirname, 'src/routes' ), extensions : [ 'tsx' , 'ts' ] , } , router, app ); } , } , { name : 'ssr' , type : 'http' , handler : './src/entry-server.tsx' , target : 'server' , plugins : () => [ pluginReact() ] , routes : ( router , app ) => { return new FileSystemRouter( { dir : path. join (__dirname, 'src/routes' ), extensions : [ 'tsx' , 'ts' ] , } , router, app ); } , } , ] , } ); 各ルートファイルを作成 先ほど指定した src/routes ディレクトリに、いくつかルートファイルを作成します。 src/routes/index.tsx import Counter from '../counter' ; export default function Index () { return ( < div > < h1 > Index </ h1 > < Counter /> </ div > ); } src/routes/about.tsx export default function About () { return ( < div > < h1 > About </ h1 > < p > This is the about page. </ p > </ div > ); } src/routes/docs/guide.tsx export default function Guide () { return ( < div > < h1 > Guide </ h1 > < p > This is the guide page. </ p > </ div > ); } React Router をインストール 今回はルーターライブラリに React Router v6 を使用します。 npm install react-router-dom@6 app.tsx を編集 import { getManifest } from 'vinxi/manifest' ; import { createAssets, lazyRoute } from '@vinxi/react' ; import { Suspense } from 'react' ; import fileRoutes from 'vinxi/routes' ; import { Route, Routes } from 'react-router-dom' ; const clientManifest = getManifest( 'client' ); const ssrManifest = getManifest( 'ssr' ); const routes = fileRoutes. map (( route ) => ( { ...route, component : lazyRoute(route.$component, clientManifest, ssrManifest), } )); const Assets = createAssets(clientManifest.handler, clientManifest); export default function App () { return ( < html > < head > < Suspense > < Assets /> </ Suspense > </ head > < body > < div id = "root" > < Suspense > < Routes > { routes. map (( route ) => ( < Route key = { route.path } path = { route.path } element = { < route . component /> } /> )) } </ Routes > </ Suspense > </ div > </ body > </ html > ); } entry-server.tsx を編集 App コンポーネントを StaticRouter でラップし、 event.path を location に渡します。 <StaticRouter location = { event . path } > < App /> </StaticRouter> entry-client.tsx を編集 App コンポーネントを BrowserRouter でラップします。 hydrateRoot( document , < BrowserRouter > < App /> </ BrowserRouter > ); index.tsx にリンクを追加 export default function Index () { return ( < div > < h1 > Index </ h1 > < Counter /> < Link to = "/about" > About </ Link > < Link to = "/docs/guide" > Docs </ Link > </ div > ); } 開発サーバーを起動します。 npm run dev リンクから各ページに遷移できることを確認します 🎉 おわりに 今回は vinxi を使って SSR とファイルシステムルーティングを備えたメタフレームワークを作成しました。 他にもミドルウェアや Server functions など、vinxi には様々な機能があります。 自分だけのメタフレームワークを作ってみるのも楽しそうですね。
この記事は RevComm Advent Calendar 2024 の 9 日目の記事です はじめに RevComm では Github を使用して開発を行っており、コードレビューの依頼も PR で行われます。Github の PR の画面では変更差分の表示ができるのでレビューにも使えるのですが、個人的にはエディタを使用してレビューするのがお勧めです。 この記事では、エディタを使用してレビューすることのメリットをご紹介します。 エディタでのレビューとは この記事におけるエディタでのレビューとは、エディタで変更差分を表示することを意味します。VS Code など最近のエディタでは拡張機能をインストールすることで、エディタ上で変更差分を表示することが可能です。 ちなみに私は Neovim を使っており、octo.nvim という拡張 (を 自分で改造したもの ) でレビューを行っています。Neovim 上でレビューコメントも追加・編集ができてとても便利です。 エディタでレビューすることのメリット Github 上でのレビューに対して、エディタでレビューすることのメリットをいくつか挙げます。 普段使い慣れているエディタを使用することで様々な操作をスムーズに行える (Vim を使っている人は特に) ブラウザ内検索に比べて、よりリッチな検索を行える 静的解析がおかしな点 (不要なライブラリのインポートや、スペルミスなど) を見つけてくれる これらのメリットのほかに、レビュー観点から見たメリットもあります。 以降ではレビュー観点から見たエディタを使用するメリットについて説明します。 レビューの観点から見たエディタを使用するメリット レビュー観点は様々あると思うのですが、私はレビューを行う際、コードを縦と横に見ることを意識しています。 縦に見る 縦に見るとは、変更箇所の前後を見ることです。 個人的な感想ですが、レビューで変更箇所単体に大きな問題が見つかることはほとんどないです。問題がある場合の大半は、変更箇所の前後の処理との組み合わせにあります。 説明のために以下の例を見てみます。 x: str = "Nanika no atai" # 既に定義されている変数 use_x1(x) # X を使用している箇所 + #### ここから変更箇所 ################## + # x を特定のフォーマットの文字列に変換 + x = format_to_xxx(x) + #### ここまで変更箇所 ################## use_x2(x) # X を使用している箇所 この例では、変数 x の文字列を特定のフォーマットに変換する変更を入れており、フォーマット単体ではおかしな点はありません。 しかし、この変更箇所の前後では変数 x を使用しています ( use_x1 と use_x2 の関数)。もし、 use_x2 がフォーマット前の値を期待していたらどうなるでしょうか?逆に use_x1 もフォーマット後の値を期待していた場合はどうでしょうか?このような場合、変更したコードをそれぞれの関数の呼び出し前後に移動する必要があるでしょう。 この例の場合、縦に見るとは x を変更した箇所の前後で x を使用している部分に注目するということです。 このように縦にコードを見るには、Github の PR 画面では少し大変です。Github の画面では変更箇所の前後の行はデフォルトで非表示になっているからです。これらを表示するには画面を何度もクリックする必要があります。エディタを使う場合、前後の処理を確認するのに特別な手順を必要ありません。この観点からすると、縦に見るにはエディタを使うと非常に効率的です。 横に見る 縦の次は横ですが、これは簡単に言うと変更したファイル以外のファイルも見ることです。 具体的な例をいくつか挙げます。 変更した関数等を参照している全ての箇所で、変更内容がどのような影響を与えているか確認する 変更対象と類似したコードが存在していないか確認し、存在している場合はそちらにも同様の変更が必要か確認する エディタを使用してこれらの観点でレビューを行う場合、LSP を使用して参照箇所にコードジャンプしたり、grep 相当の検索でリポジトリ内を検索すると効率的に行えます。 Github でも参照している箇所を一覧化してくれたりはしますが、エディタを使用した方が少ない手間でコードジャンプが可能です。 まとめ コードレビューにエディタを使用するメリットをレビューの観点から紹介しました。 ブラウザ上でも Github Codespaces を使うとブラウザ上で VS Code を開き、レビューをすることもできます。しかし、普段開発で使用している設定・拡張を用意する手間を考えると、手元のエディタでレビューをすることが個人的にはおすすめです。 エディタを利用することでより効率的にレビューすることが可能になると思いますので、ぜひ一度試してみてください。
はじめに Phone Divという部署でBackendを担当している中島です。 RevCommではMiiTelをはじめ、複数のマイクロサービスの外形監視をチームごとに行う必要があります。 今回は DataDog Synthetic Testing を利用したE2Eテスト・外形監視の実装、その運用について、知っていることをまとめました。テストの作り方から、Autifyとの違いなどにも触れてみたいと思います。 想定読者 DataDogによるE2Eテスト・外形監視に興味のある方 フロントエンド・バックエンド開発者 システムの保守担当者 Quality Assurance(QA)チームの方 DataDog Synthetic Testingを使ってみる 表題のE2Eテストや外形監視の作成は、DataDogの中の「 Synthetic Monitoring & Testing 」というサービスで利用可能です。 API Test を作る 「 API Test 」または「 Multistep API Test 」で作成できます。 API Testの方は一つのAPIに対するリクエストをテストする機能で、Multistep API Testの方は複数のAPIを連続的にテストする機能です。 それぞれ、「HTTP」だけでなく「gRPC」「SSL」「DNS」「WebSocket」「TCP」「UDP」「ICMP」などのプロトコルにも対応しているため、非常に幅広い用途で利用できることが分かります。 Multistep API Testは、例えば、最初のステップでHTTPリクエストを使って認証を通し、BearerなりJWTなりを取得して次のステップ以降でそれを用いてリクエストするなど、複数のAPIを利用するテストシナリオを検証するケースに対応しています。 「 Locations 」というタブからサーバの実行環境は世界中のクラウドのうち、どこから実行できるか選択できます。世界的にサービスを展開している場合に、海外からだと挙動が変わる機能があっても、しっかりテストできることが分かります。 ただ、複数選択した場合、それらのテストは並列で実行され、それぞれのテスト実行がコストに繋がりますので、様々な地理的拠点から見え方が同じであるなら、どれか一つお好きな実行環境を選ぶことになると思います。 「 Assertions 」というタブから豊富なAssertion機能を設定できます。status codeの検証はもちろん、response body中の要素をjson pathを指定して取得・確認したり、最近実装されたJavaScript Assertion機能ではChaiを用いたAssertionを埋め込むこともできたりするようです。 Browser Test を作る ブラウザのE2Eテストを作成する場合、「 Browser Test 」を選択します。E2Eテスト作成機能は「 Edit Test 」「 Edit Recording 」「 View Results 」の3つのタブから構成されます。 Edit Test 「 Edit Test 」ではE2Eテストの実行環境や実行頻度などを設定できます。 「 Starting URL 」にテストシナリオ開始時に利用するURLを指定します。この「Starting URL」を設定できる点が非常に便利で、一度テストを作ってしまえば、環境(開発環境、ステージング環境)ごとにこのURLを差し替えるだけでその環境でも同様のテストも動くようになります。これによって環境ごとにテストを用意する手間が省け、効率的にテストを作ることができます。 「 Browser & Devices 」でブラウザについてはChrome、Edge、Firefoxの3つから、デバイスについてはLaptop、Tablet、Mobileから実行環境を選べます。 「 Scheduling & Alert Conditions 」では実行間隔を設定でき、最短 5分・最長 1週間で選択することになります。 Edit Recording 「 Edit Recording 」では実際にテストする画面を触りながらテストを組み立てていきます。CSVやPDFなどファイルをフォームに設定するテストなども作成できます。 ただ、ブラウザのマイクやカメラに許可を与えるようにテストは正常に動作しなかったので、この点が少し残念な点でした。(2024年12月現在) ブラウザテストのAssertionsではHTML要素を指定した結構細かいAssertや、特定の文字列がページに含まれるか、ファイルがダウンロードされるか、なども利用できます。 例えば、以下のようなAssertができることは確認しています。 指定したHTML要素が存在する 指定したHTML要素の状態(〜が含まれる、〜から始まる、など細かく指定可能)が正しい 画面上に「XXX」という文字列が含まれる 指定したラジオボタン、チェックボックスがchecked / uncheckedになっている View Results 「 View Results 」では作成したテストの結果を見ることができます。 過去の実行結果やFailした場合はどこのAssertionがコケたのかを確認できる 各ステップごとの実行時間(Duration)、CLS(Cumulative Layout Shift)やLCP(Largest Contentful Paint)も確認することができる 結果を見る システムの状態を一目で確認できるようにチームに関係するE2Eテストをダッシュボードにして、運用することにしました。 Widgetを自分で作成することで好きな統計が載せられる 私のチームではCLS、LCPを自作して追加しました。 ブラウザ・環境・タグごとのテスト成功 / 失敗回数を一目で確認できる 実行時間、CLS、LCPに時系列で変化があるかどうか確認できる 通知をする 基本的にDataDogのMonitorと同じ機能でチャットサービスへと通知を飛ばすことができます。 今回はE2Eテストが失敗したらチームのSlack Channelに通知に飛ばすようにしました。 料金について 料金はAPIテストとブラウザテストによって異なります。以下のページを参考にしながら見積もりできるはずです。 www.datadoghq.com 注意点として、Synthetic Testingは25ステップ以内のシナリオを1回と見做し、25ステップを超えると2回テスト実行したと見做されるようです。 これを踏まえた上で、25ステップ以内テストシナリオ数を n 、1日何回実行するかを t とし、1ヶ月30日とすると以下の計算式でコストを見積もると以下のようになります。 monthly_cost =(n * t / 1000)* c * 30日 n: テストシナリオ数 t: 1日何回実行するか c: 1000回あたりの価格。オンデマンド使用料が目安 例えば、1日1回 5シナリオ実行した場合はひと月あたりカフェのコーヒーくらいの料金になるでしょう。 Datadog Synthetic Testingの強み・弱み E2Eテストといえば Autify が思い当たる方が多いと思いますが、2024年12月現在の調査結果に基づいてDataDog Synthetic Testingとの違いを紹介してみようと思います。 DataDogでは以下のことができます。 APIテストができる あくまで私が触ってみた感触にはなりますが、DataDog Synthetic TestingはHTMLの要素やAPIまで検証したい開発者向き、Autifyは外部品質を担保したい組織向きの機能が充実しているものと思っています。DataDogのAssert機能の豊富さもそれゆえなのかもしれません。 毎回のテスト実行で LCP、CLS など Core Web Vital が算出できる 実行環境を細かく指定できる 本記事の「Locations」画面を参照 Autifyでは以下のことができます。 E2Eテスト時のブラウザにデバイス使用許可を与えることができる ブラウザにデバイス使用許可(マイク、カメラなど)を与えられるかどうか大きな違いでした。MiiTelのIP電話はマイクの使用が不可欠なサービスなので、DataDogで架電のE2Eテストが作れなかったのが残念でした。ただ、Autifyでも音声を入れたり動画を映したりすることはできないのでその点は注意してください。 作成したテストシナリオを編集する際に、途中の画面から編集できる DataDogでブラウザテストのシナリオを修正したい場合、操作を一からやり直すことになります。一方Autifyでは、シナリオの途中から編集することができます。 テストシナリオを複数組み合わせで実行できる DataDogでは作ったテストシナリオのCloneはできるんですが、作ったシナリオを柔軟に組み合わせて使うことができないようです。 この辺りはDataDog、Autifyの強み・弱みがあるので、やりたいことに応じてツールを使い分けるのが良いと思いました。 感想 私個人としてはE2Eテスト実行のついでにCore Web Vitalが算出できるのが優れていると感じました。Webサービスはユーザやデータが増加するにつれて動作が遅くなることがあり、気がついた頃には原因が根深くなってしまいがちです。 DataDog Synthetic Testingでは作成したE2Eテストのダッシュボードを通して時系列でCore Web Vitalが確認できるので、外形監視には持ってこいのサービスだと思いました。 導入を検討される場合、本記事が参考になれば幸いです。
はじめに 最近、Reactに useEffectEvent という実験的APIが存在することを知りました。 弊社で提供しているMiiTel Phoneにおいては、WebSocketやWebRTCなどによってさまざまなタイミングや箇所で非同期的にイベントが発生します。 その関係もあって useEffect を広く活用しているのですが、そういった処理をこの useEffectEvent を使うことによって単純化できるのではないかと思い、調べてみることにしました。 注意 useEffectEvent は現状では実験的APIです。安定版のReactでは利用することができません。もし試してみたい場合は、下記パッケージの experimental バージョンが必要です: react@experimental react-dom@experimental eslint-plugin-react-hooks@experimental 活用例 まず useEffectEvent の活用例として、React Routerが Location の変更を検知するたびに特定のコールバック関数を実行するようなケースを元に考えてみます。 useEffectEvent を使わない場合 useEffectEvent を使わない場合、意図通りに動作させるためには、以下のように useRef や useInsertionEffect などを使って対応する必要がありそうです: import { useEffect, useInsertionEffect, useRef } from 'react' ; import { useLocation } from 'react-router-dom' ; export function useOnLocationChanged ( callback ) { const refCallback = useRef(); const location = useLocation(); useInsertionEffect(() => { refCallback. current = callback; } , [ callback ] ); useEffect(() => { if (refCallback. current ) { refCallback. current ( location ); } } , [ location ] ); } なぜこのように useRef などを使う必要があるのでしょうか?例えば、 useOnLocationChanged が以下のように実装されていたとします: import { useEffect } from 'react' ; import { useLocation } from 'react-router-dom' ; export function useOnLocationChanged ( callback ) { const location = useLocation(); useEffect(() => { callback( location ); } , [ callback, location ] ); } この実装の問題点は useEffect の dependencies として指定されている callback 関数の参照が変わるたびに、 Location が変更されていないにも関わらず意図せず callback が呼ばれてしまう点です: function SomeComponent ( props ) { useOnLocationChanged(() => { // SomeComponentが再レンダリングされるたびに、意図せずこのcallbackが呼ばれてしまいます // ... } ); // ... } この問題を防ぐためには、現状では useRef を活用するなどの工夫が必要です。 useEffectEvent を使う場合 先ほどの処理は useEffectEvent を使うと簡略化することができます。 import { experimental_useEffectEvent as useEffectEvent } from 'react' ; function useOnLocationChanged ( callback ) { const location = useLocation(); const onLocationChanged = useEffectEvent(callback); useEffect(() => { onLocationChanged( location ); } , [ location ] ); } useEffectEvent は引数として関数を受け取り、関数を返却します ( onLocationChanged ) この useEffectEvent から返却された関数には以下のようなルールがあります: Effect ( useEffect )の外で呼んではいけません (例: レンダリングフェーズなどにおいて呼ぶことはできません) useEffect の dependencies からは省略する必要があります 他のコンポーネントのpropsなどに指定することはできません useEffectEvent を使うことで意図せず何度も callback が実行されてしまうことを防止できます。上記のコードの場合、 callback はReact Routerの Location オブジェクトが変更されたタイミングでのみ呼ばれます。 先ほどの useRef などを使った方法と比較して、 useEffectEvent を使うことによって、より直感的にやりたいことが実現できます。 useEffectEvent とは 先ほど使い方を紹介した useEffectEvent について、公式ドキュメントを参考により詳しく見ていきます。 react.dev github.com React の公式ドキュメントでは以下の3つを reactiveな値 と定義しています: Props State コンポーネント関数直下で定義された変数 そして、Reactには副作用を取り扱う方法として以下の手段があります: Effect - reactiveな値の変更時に実行されるreactiveな処理 イベントハンドラー - ユーザーの操作によって実行される非reactiveな処理 useEffectEvent はこれら2つの中間に相当するもので、Effect内で非reactiveな処理を実行したい場合に使用することが想定されます。 WebSocket を使ってリアルタイムにメッセージのやり取りを行う際のケースを例に見ていきます。 function ChannelView ( { channelID } ) { const logger = useLogger(); const dispatch = useDispatch(); const handleMessage = useCallback(( message ) => { logger. info ( `received: ${ message } ` ); dispatch(appendMessage(channelID, message)); } , [ channelID, dispatch, logger ] ); useEffect(() => { const ws = new WebSocket ( `/channels/ ${ channelID } ` ); ws. addEventListener ( 'message' , ( e ) => handleMessage(e.data)); return () => ws. close (); } , [ handleMessage, channelID ] ); // 省略... } props.channelID の変更時に新しい WebSocket 接続を張る処理は、ユーザーの操作ではなく reactiveな値の変更に基づいて実行する処理であるためEffectで処理しています。 この実装には一つ問題があります。 handleMessage は useEffect の dependencies に指定されているため、この handleMessage 変数が参照する関数が変更されるたびにEffectが再実行され、 WebSocket のコネクションが一から貼り直されてしまいます。これは意図せぬ挙動です。 handleMessage 内の処理は、Effectが依存しているreactiveな値 ( props.channelID )が変更されたタイミングで実行されるものではなく、 WebSocket から message を受信したタイミングで実行される処理です(非reactiveな処理) useEffectEvent によってこういった非reactiveな処理をEffectから抽出することができ、意図せぬタイミングで何度もEffectが実行されてしまう事態を防止できます。 function ChannelView ( { url } ) { const logger = useLogger(); const dispatch = useDispatch(); const handleMessage = useEffectEvent(( message ) => { logger. info ( `received: ${ message } ` ); dispatch(appendMessage(channelID, message)); } ); useEffect(() => { const ws = new WebSocket ( `/channels/ ${ channelID } ` ); ws. addEventListener ( 'message' , ( e ) => handleMessage(e.data)); return () => ws. close (); } , [ url ] ); // ... } 補足ですが、RFCによると useEffectEvent から返却される関数は on または handle から始まる名前の変数に格納されることが想定されているようです (元々、RFCにおいては useEvent という名前で提案されていたようですが、後ほど現在の useEffectEvent という名前へリネームされたようです) github.com github.com 現状ではどうしたらいいか? useEffectEvent は便利ではありますが、現在はまだ実験的APIのため、Reactの安定バージョンにおいては利用することができません。 調べてみたところ、いくつかのOSSにおいて自前でpolyfillを実装しているようです: Superset useEffectEvent が安定化された際の移行を容易にするために、 use-event-callback パッケージをベースに useEffectEvent を実装しているようです ( apache/superset#23871 ) Bluesky useInsertionEffect + useRef + useCallback をベースに useNonReactiveCallback というカスタムフックを実装しているようです ( src/lib/hooks/useNonReactiveCallback.ts ) Radix UIのWebサイト Blueskyとほぼ同様ですが、こちらはレンダリングフェースで実行された際に例外が発生する対応が行われています ( utils/use-effect-event.ts ) これらを参照することで、 useEffectEvent を使うべき場面の参考にもなりそうです。 ただ、紹介をしておいてアレですが、現状ではpolyfillなどは用意せずに、 useRef を使った解決策で対処しておくのが無難ではないかと個人的には思っています。 便利なAPIではあるものの、現状ではまだ useEffectEvent は実験的APIであり、正式に導入されるかどうかはわかりません。今後、引数や戻り値の形式などが変更される可能性も考えられます。 また、もし useEffectEvent が正式に導入された場合、公式でマイグレーションガイドの公開や eslint-plugin-react-compiler や eslint-plugin-react-hooks から移行のためのルールの提供なども考えられるため、それらの提供を待った方がスムーズに移行できる可能性も考えられそうです。現状では、まだ useRef を使った解決策で様子をみておいたほうが安全ではないかと考えています。 おわりに 以上、 useEffectEvent の紹介でした。とても便利なAPIなので、今後のバージョンで導入されることを楽しみにしています!
はじめに Atlantis とは 背景 ディレクトリ構成 モジュールの運用 AWS IAM role の設定 おすすめの Atlantis の機能 特定の条件がパスされないと atlantis apply を実行できないようにしたい atlantis apply が実行されていない Pull Request がマージされないようにしたい まとめ はじめに platform チームの渡部です。 RevComm の platform チームでは、チームトポロジーのプラットフォームチームのように、ストリームアラインドチームの開発における負担を軽減するために、サービスやプラットフォームの提供を行っています。 今回は、その取り組みの1つである Atlantis を使用した Terraform リポジトリの提供についてご紹介します。組織の拡大とともに Terraform の管理に課題を感じている方や Atlantis の導入を検討している方の判断材料になれば幸いです。 Atlantis とは Atlantis  は Terraform のデプロイを行う Issue Ops ツールの OSS です。 GitHub の Pull Request の issue comment にコマンドを入力して、 terraform plan や terraform apply を実行し、Pull Request 上でリソースのリリースまで行います。 我々のチームでは、Terraform リポジトリへの permission を持つ Github App が、ECS Fargate にホストされた Atlantis アプリケーションと通信して terraform コマンドを実行します。 この記事では、v0.30.0 の Atlantis を使用しています。 背景 我々が管理する Terraform リポジトリは、Atlantis を導入する以前から、複数のストリームアラインドチームが開発を行うモノリシックなリポジトリでした。 それ故に、Terraform Configurations が肥大化したり、リリースが滞留したりするなどの問題を抱えるようになりました。 そのような課題を解決するために、platform チームは Atlantis を導入しました。 これによって、CI/CD ツールのコードが複雑になることなく、以下のメリットを享受できると想定しています。 変更と関係のないリソースが壊れるリスクを背負う必要がなくなる 権限や責任を分離したい粒度や並行に開発を行える粒度で柔軟に HCP Terraform workspace を作成することができる 以降、モノリシックな Terraform リポジトリに Atlantis を導入するにあたり、工夫した点を紹介します。 ディレクトリ構成 Atlantis 導入後も、我々が管理する Terraform リポジトリは、複数のストリームアラインドチームが開発を行うモノリシックなリポジトリです。 そのため、ストリームアラインドチームごとにディレクトリを用意して、各チームがその配下に自由にディレクトリを作成することができるディレクトリ構成にしました。 各チームは、権限や責任を分離したい粒度や並行に開発を行える粒度に Terraform configurations を分割して(以下、コンポーネントと表現)、コンポーネントごとにディレクトリを作成します。 コンポーネントディレクトリ配下には、そのコンポーネントのモジュールや、そのコンポーネントのリソースがリリースされる AWS アカウントごとにディレクトリが分かれて管理されています。 下記はディレクトリ構成のイメージです。 . ├── stream_aligned_team_1 │ ├── component_1 │ │ ├── aws_account_1 │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ [omitted] │ │ │ │ │ ├── aws_account_2 │ │ │ ├── main.tf │ │ │ [omitted] │ │ │ │ │ └── modules │ │ ├── main.tf │ │ ├── variables.tf │ │ [omitted] │ │ │ └── component_2 │ ├── aws_account_1 │ [omitted] │ ├── stream_aligned_team_2 │ │ │ [omitted] │ [omited] このようなディレクトリ構成にすることで、コンポーネントのデプロイ時に関係のないリソースが壊れるリスクを回避することができます。 また、後述のモジュールのバージョニングの際に、このディレクトリ構成をtag の命名規則に反映しています。 モジュールの運用 Terraform module は source に Github リポジトリを指定して使用する ことができます。 我々が管理する Terraform リポジトリでは、この機能を使用して、自リポジトリを参照することで、バージョン管理した module を使用しています。 具体的には、下記のフローでモジュールのバージョニングとその使用を行っています。 ./stream_aligned_team_1/component_1/modules  配下に  moduleA  を作成して main ブランチにマージします。 Github の Releases 機能を使用して  stream_aligned_team_1-component_1-v1.0.0  という tag を作成してリリースします。 ./stream_aligned_team_1/component_1/aws_account_1/main.tf では、以下のように  moduleA  を指定して使用します。 module "this" { source = "git::ssh://git@github.com/{org_name}/{repo_name}//stream_aligned_team_1/component_1/modules/moduleA?ref=stream_aligned_team_1-component_1-v1.0.0" } このとき、tag 名は Terraform リポジトリ内で一意になるように {stream_aligned_team_name}-{component_name}-{semantic_versioning}  の命名規則に沿うように行います。 これにより、チームごとにモジュールのバージョニングが可能になります。 また、開発環境用の AWS アカウントでは、module を Github リポジトリ参照ではなく、相対パスによる参照を行なっています。 これにより、わざわざ main ブランチへのマージと Release を行うことなく、module の動作確認やデバッグを行いながら開発を行うことができます。 AWS IAM role の設定 Atlantis は 1 つの AWS アカウントにのみホストされています。 したがって、各環境へ  terraform apply  するためには、各環境の terraform コマンドを実行するための IAM Role を assume role しなければなりません。構成は下記のようになります。 また、我々が管理する Terraform リポジトリでは、ファイルレイアウトによってステートファイルを分離する方針をとっており、各 AWS アカウントのリソースの tfstate は、その AWS アカウントの remote backend(S3 bucket と DynamoDB table) に配置されています。 各 AWS アカウントの remote backend にアクセスするときにも、下記のように IAM role を assume role して tfstate を更新しています。 これにより、各環境は完全に分離されます。 backend "s3" { region = "ap-northeast-1" bucket = "account1-tfstate" key = "stream_aligned_team_1/component_1/terraform.tfstate" dynamodb_table = "account1-locks" encrypt = true assume_role = { role_arn = "arn:aws:iam::account1:role/account1-tf-exec-role" external_id = "account1-external-id" } } おすすめの Atlantis の機能 Atlantis の機能は多岐に渡ります。 基本的な機能としては、 atlantis plan の実行前に該当のディレクトリをロックする Looking 機能や、Pull Request 作成時に atlantis plan を自動的に実行する Autoplanning 機能などがあります。 最後に、以下のようなことを実現したいときに、Atlantis のどの機能を使えばよいかを紹介して終わりにしたいと思います。 特定の条件がパスされないと atlantis apply を実行できないようにしたい Atlantis には Command Requirements 機能という機能があります。 これは、 atlantis plan や atlantis apply コマンドを実行する前に、特定の条件を満たすことを強制する機能です。 例えば、Atlantis の設定ファイルに下記のように設定すると、Pull Request が「マージ可能( Mergeable )」な状態でなければ atlantis apply を実行できなくなります。 repos : - id : /.*/ apply_requirements : [ mergeable ] 「マージ可能」な状態は、Atlantis を使用する VCS によって異なります。 Github の場合は、 branch protection rule をパスした状態を指します。 あとは、Github の Settings で有効にしたい条件をチェックして、マージ可能な状態でなければ、下記のように atlantis apply は失敗します。 atlantis apply が実行されていない Pull Request がマージされないようにしたい Atlantis を使用していると、 atlantis apply を実行する前に、誤って Pull Request をマージしてしまうことがあります。 こうなると、いちいち Pull Request を作り直すことになり、面倒です。 なので、下記のように Github の status checks に atlantis/apply を追加したくなります。 追加して、いざ atlantis apply を実行してみると、上記の Mergeable で試したときと同じように失敗します。 これは、 Mergeable を有効にすると、Atlantis が Github の status checks もパスしたかを確認してしまうためです。 つまり、 atlantis apply を実行するために atlantis/apply をパスしなければならないという八方塞がりの状態になってしまいます。 そんなときのために、Altantis には atlantis apply を実行する前に、 atlantis/apply の status check をスキップして、マージ可能かどうかをチェックすることができるオプションが用意されています。 それが --gh-allow-mergeable-bypass-apply です(環境変数でも設定可能です)。 atlantis server --gh-allow-mergeable-bypass-apply これで、Mergeable を設定した状態で、 atlantis apply が実行されていない Pull Request がマージされないようにすることができます。 まとめ Terraform のデプロイを行うツールである Atlantis  と、Atlantis を導入した Terraform リポジトリの運用について紹介しました。 Atlantis には、今回紹介した機能以外にもたくさんの機能があります。 ngrok を使用すればローカル環境でも気軽に試してみることができますので、興味のある方はぜひ動かしてみてください。
背景 弊社ではさまざまなログを DataDog に集約しているのですが、一部サービスで EKS on Fargate を利用しており、datadog-agent + fluent-bit のサイドカー構成で DataDog にログを送っています。 その中でも Job を使用した場合にうまく DataDog にログが送れず、困っていました。 Job だとサイドカーが動いてくれない + Job終了時にサイドカーが終了してくれない という状況で、Jobに関してはDataDogを諦めて kubectl や ArgoCD 経由でログを一生懸命読む、という運用が続いていました。 結論 k8s 1.29 からデフォルトで有効となったサイドカーコンテナの機能を使用することで問題は解決しました。正解は公式ドキュメントにバッチリ書いてあり、今まで我々は何をそんなに困っていたんだろう?ということをつらつらと言い訳していこうと思います。 kubernetes.io 詳細 サイドカーが機能しない件 DataDog にログが出ず...これは、起動順序に問題があるようでした。メインのコンテナよりもサイドカーを先に起動させる必要があるところまでわかり、manifest の containers で記載順序を変えて対応しようとしました。実際このとき DataDog にログは出たのですが、サイドカーが終了しない問題の解決が難しくなりました。 サイドカーが停止しない件 Job の場合、仕事を完了したメインコンテナは仕事中のサイドカーを残して一人で終了してしまいます。これだと、終わった Job がいつまでも生き残ってしまいます。 そこでメインが終了する際にサイドカーを終了したり、逆にサイドカーがメインコンテナを監視してメイン終了を検知したら自分も終了する command を書いたりの工夫をしてみたのですが、サイドカーが機能しない件の対応で containers の記載順序を変更したところうまくいかなくなりました。 サイドカーコンテナ機能の使用 行き詰ったので心を落ち着けて公式ドキュメントをじっくり読むことにしたところ、わりとすぐに答えが見つかりました。結論にも記載した通り、k8s 1.29 からはデフォルトでサイドカーコンテナの機能が使用できるようになっていて、containers ではなく initContainers にサイドカーを指定するだけでOKでした。 サイドカーコンテナは initContainrs として指定するため、起動順序はメインコンテナよりも先となります。また、サイドカーの restartPolicy は Always を指定します。こうするとメインコンテナが終了した場合のみサイドカーが停止するようになります。 なぜ自前で四苦八苦していたのか 事前に軽く調べた際に「Job には自動でサイドカーを終わらせる方法がなく、自前で用意する必要がある」というブログを参照しており、ちょっと古い記事ではあったのですが、他に情報もなく、鵜呑みにしていたんですね。 公式ドキュメントを読み込んでおくのは基本のようにも思えますが、現場でスピード感を持って判断していく上で、ライトに読めてしまうブログやAIの回答を参考にして済ませてしまうことは実際よくあると思います。 そんな忙しい我々のためにも、軽く調べたら出てくるように情報発信をしていくことが大切だなと感じました。さらに言えば、不思議な力でこの記事が数ヶ月前の我々に届いてくれたらとても嬉しい。そういったサービスには対応していませんか?サンタさん、何卒宜しくお願い致します。 以上
はじめに MiiTel Analytics Platformチームの小門です。 RevCommではサービス基盤にAWSとして利用していますが、IaCには主にTerraformを用いています。 基本的にTerraformコードはGitHubで管理され、プルリクエストを介してCI/CDを自動実行してリソースの構築、構成変更を行います。 最近、Terraformコードを管理するリポジトリを新設したり既存のコードをリファクタリングする機会があったためナレッジを共有します。 この記事では、Terraform v1.5以降に導入された機能である import / removed / moved ブロックを活用する方法を紹介します。 動機 IaCの活用度合いはサービスや会社、チームの状況に大きく左右されると思います。 必ずしもプロジェクトの初期からIaCが整備されるとは限らないし、サービスや組織の変化に応じてコード管理の都合も変わることでしょう(弊社が正にそうです)。 Terraformは機能が豊富なため上記のような事情に柔軟にアプローチできます。 例えば既存のリソースをIaC管理に取り込む場合は terraform import 、逆に特定のリソースをIaC管理から除外する場合は terraform state rm などのコマンドがあります。 しかしIaCという特性上、手動コマンド実行によるtfstateを操作するのはチーム開発において不都合があります。 例えばチームの誰かによって同じタイミングでCI/CDが起動されると、お互いの変更が競合したりどちらかの変更が後勝ちするような可能性が考えられます。 このような場合でもTerraformの機能を有効活用することでチーム開発に支障をきたさずリファクタリングすることができます。 サンプルコード AWSリソースを管理するための最小のサンプルとして、ECSクラスターを1つ管理するケースを考えてみます。 初めのディレクトリ構成: . └── provider.tf provider.tf terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-east-1" } 検証コードのバージョン Terraform v.1.9.8 AWS provider: v5.78.0 import ブロック import ブロックはTerraform v1.5以降で利用可能です。 terraform cliの terraform import [ADDR] [ID] と等価です。 import { to = [ ADDR ] id = "[ID]" } IaC管理されていないAWSリソースであるECSクラスター revcomm-2024-adventcalendar を新たにIaC管理に含めます。 ※terraform cliだと terraform import aws_ecs_cluster.foo revcomm-2024-adventcalendar # main.tf resource "aws_ecs_cluster" "foo" { name = "revcomm-2024-adventcalendar" } # import_ecs_cluster.tf import { to = aws_ecs_cluster.foo id = "revcomm-2024-adventcalendar" } . ├── import_ecs_cluster.tf ├── main.tf └── provider.tf ※好みですが、 import ブロックはいずれ削除可能なため main.tf ではなく別ファイルにすることで後で丸ごと消せるようにしています。 この状態で terraform plan を実行すると以下のようになります。 $ terraform plan ... Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. 1 to import かつ 0 to add, 0 to change のため新たにリソースは作成されず、また変更もされないことが分かります。 import ブロックはコマンドの手動実行ではなく コード変更によって IaCの管理対象を操作することができます。 ※以降のコードは terraform apply を実行 && import_ecs_cluster.tf を削除した状態とします。 removed ブロック removed ブロックはTerraform v1.7以降で利用可能です。 その名の通りリソースをIaC管理から外す(リソース実体は削除しない)ためのもので、 import ブロックとは逆の用途です。 terraform cliの terraform state rm と等価です。 terraform state rm [ADDR] removed { from = [ ADDR ] lifecycle { destroy = false } } lifecycleブロックで destroy = false としてリソースを削除しないようにできます。 # main.tf removed { from = aws_ecs_cluster.foo lifecycle { destroy = false } } # resource "aws_ecs_cluster" "foo" { # name = "revcomm-2024-adventcalendar" # } また(IaC管理から)削除するリソースのTerraformコードも合わせて削除する必要があります。 removed ブロックも後から削除可能なため、私のチームでは初めコメントアウトに留め、その後 removed ブロック自体の削除時にresourceブロックも削除する形で運用しています。 planの実行結果は以下のようになります。 $ terraform plan # aws_ecs_cluster.foo will no longer be managed by Terraform, but will not be destroyed # (destroy = false is set in the configuration) ... Plan: 0 to add, 0 to change, 0 to destroy. 0 to add, 0 to change, 0 to destroy のため、実際のリソースに変更は起きません。 movedブロック removed ブロックはTerraform v1.7以降で利用可能です。 Terraformコード上の管理名(ADDR)をリネームする場合に使用します。 terraform cliの terraform state mv と等価です。 terraform state rm [SOURCE] [DESTINATION] moved { from = [ SOURCE ] to = [ DESTINATION ] } 上記までの例で、ECSクラスター revcomm-2024-adventcalendar のTerraformコード上の管理名を(敢えて) foo としていました。 しかし、この命名では役割が分かりづらいためいずれ問題が起きることでしょう。 これを foo から api にリネームするリファクタリングを 安全に 行うことができます。 + moved { + from = aws_ecs_cluster.foo + to = aws_ecs_cluster.api + } - resource "aws_ecs_cluster" "foo" { + resource "aws_ecs_cluster" "api" { name = "revcomm-2024-adventcalendar" } planの実行結果は以下のようになります。 $ terraform plan # aws_ecs_cluster.foo has moved to aws_ecs_cluster.api ... Plan: 0 to add, 0 to change, 0 to destroy. aws_ecs_cluster.foo を aws_ecs_cluster.api に変更しつつ、実際のリソースに影響がないため安全にリファクタリングを実行できます。 まとめ Terraformのリファクタリングを チーム開発の中でも安全に 実施する方法と簡単なケーススタディを紹介しました。 特に import ブロックと removed ブロックを併用することでリポジトリを跨いだリファクタリング、IaCコードの分割などが行えるのがとても気に入っています。