TECH PLAY

株匏䌚瀟RevComm

株匏䌚瀟RevComm の技術ブログ

å…š174ä»¶

こんにちは。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 チヌムでは、チヌムトポロゞヌのプラットフォヌムチヌムのように、ストリヌムアラむンドチヌムの開発における負担を軜枛するために、サヌビスやプラットフォヌムの提䟛を行っおいたす。 今回は、その取り組みの぀である 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コヌドの分割などが行えるのがずおも気に入っおいたす。
アバタヌ
By Kenji Yamauchi (Analytics Team) In this blog post, we introduce our new Blue/Green-based upgrade strategy for our Amazon EKS powered RevComm analytics platform along with an automation to streamline the process. This is the English version of a similar blog. Please check this post for the Japanese version. RevComm’s Analytics Platform Before we get into the details of the upgrade, a brief introduction to RevComm's analytics infrastructure is in order: although RevComm offers several products, such as MiiTel and MiiTel Meetings, they all share a single infrastructure for performing analytics, such as transcription. This analysis infrastructure does not run from a single application but consists of multiple applications divided into modules such as speech recognition and other analytics functions, which are currently hosted within a single EKS cluster. AWS resources including the EKS cluster and middleware such as the AWS Load Balancer Controller are managed using Terraform. In addition, Kubernetes manifests for applications running in the cluster are managed in a separate repository, and deployed by referencing the main branch with Argo CD running in the cluster (Pull-type GitOps). Upgrade strategy for EKS cluster Since our adoption of EKS, the RevComm analysis infrastructure has been using the in-place method, i.e. upgrading the version of the existing cluster directly. As mentioned above, RevComm's analysis infrastructure uses IaC, so it can be done easily with only a few configuration changes, but there are some drawbacks. More concretely, the in-place strategy conducts rolling updates for the resources including the node groups as described in the EKS Best Practices Guides . As a result, we faced potential downtime during cluster upgrades and couldn't rollback if issues arose. Moreover, the upgrade process took several hours to complete. On the other hand, when going for a Blue/Green strategy upgrade, we prepare a new version cluster alongside the current cluster and let the traffic into the new cluster gradually. As we discard the older cluster only after confirming the behavior of the new cluster, we avoid the aforementioned problems. However, there’s overhead since we have to prepare a new cluster. Nevertheless, we opted for the Blue/Green strategy to improve the availability of the system. Implementation How to switch cluster We implemented the Blue/Green strategy following the article Blue Green Migration of Amazon EKS Blueprints for Terraform because our architecture was similar. Assuming that the version of a current (blue) cluster is 1.25 and one of a new cluster (green) is 1.28, the flow of switching clusters is as follows: Create a new cluster and install middleware through Terraform Deploy the applications on the new cluster with Argo CD Change the manifests Use external-dns to distribute traffic via Ingress annotations with Route 53’s weighted routing. Specify values of set-identifier and aws-weight Store new changes as a feature branch of the manifests repository and refer to them from the Argo CD of the new cluster. The Argo CD of the current cluster still refers to the main branch. Delete the current cluster after confirming the behavior of the new cluster Merge the feature branch into the main branch and make the Argo CD of the new cluster to refer to the main branch. Automation Although we can complete those steps as mentioned before by adjusting the variables of the Terraform scripts and modifying the parts of the manifests, the steps are relatively complicated compared to the in-place strategy. Therefore, we automated them with GitHub Actions to prevent dependence on individual members and operational errors. We automated the two processes: the creation of a new cluster and the deletion of the current cluster. Please look at the diagram below. The Terraform code for the analysis infrastructure has directories for managing backends and variables for each environment (development, staging, and production) under the vars directory, hereafter simply referred to as environment settings. Each environment setting is managed with an identifier for each environment, such as cluster-YYYYYMM . In addition, the environment settings for the current cluster are pointed to by symbolic links. When we create a new cluster with this structure, we need to generate new environment settings, create a new cluster by terraform apply , and recreate the symbolic link. On the other hand, when we remove the current cluster, we need to remove the middleware and the applications hosted in the current cluster, the current cluster and the environment settings for the current cluster. To prevent operational errors that could occur during manual processes, we automated these steps using GitHub Actions. The Actions take identifiers and cluster versions as inputs and perform the entire process—creating, switching, and deleting clusters, as well as deploying applications to the new cluster—in a matter of minutes. Conclusion In this article, we introduced our migration from an in-place EKS upgrading strategy to Blue/Green. Compared to the in-place method, we needed to automate and establish procedures to avoid increasing man-hours. Still, as we had the groundwork of IaC, we were able to introduce the Blue/Green method with maximum benefit. The analysis infrastructure had few stateful elements, and there were few things to consider on the application side, which also made it compatible. Thank you for reading and have a happy upgrading time!
アバタヌ
2024幎10月25日(金)27日(日)にむンドネシアで開催された PyCon APAC 2024 にバック゚ンド゚ンゞニアの 束土 慎倪郎、陶山 嶺、小門 照倪の3名が登壇したした。 tech.revcomm.co.jp 今回はむベントの振り返りずしお登壇資料ず登壇者の感想を玹介したす。 登壇振り返り Empowering your real life with Raspberry Pi 抂芁: 音声認識及び音声合成を掻甚しお、その日のスケゞュヌルを教えおくれる音声ボットをRaspberry Piによっお䜜りたす。初孊者の方を察象に、ハンズオン圢匏でRaspberry Piのパワヌを日垞生掻に取り入れる案内をいたしたす。 登壇者: 束土 慎倪郎 発衚資料: docs.google.com 登壇者の感想 PyCon APAC 2024で「Empowering Your Real Life with Raspberry Pi」ずいうテヌマで発衚したした。英語は埗意ではありたせんが、ハヌドりェア開発に興味を持っおもらえるよう、構成の工倫や発衚の緎習にい぀も以䞊に力を入れたした。その結果、参加者から倚くの質問や反応をいただき、関心を匕くこずができたず感じおいたす。 たた、匊瀟RevCommのゞャカルタオフィスを蚪問し、珟地のスタッフず亀流する機䌚も䜜るこずができたした。盎接顔を合わせお話すこずで、日頃の業務の぀ながりをより深めるこずができたのは倧きな収穫です。 このような貎重な経隓を䞎えおくれた䌚瀟やチヌムに感謝しおいたす。今回の孊びや気づきを、今埌の業務に還元しおいきたいず思いたす。 The power of Python's type hints: Case studies focusing on famous libraries 抂芁: 近幎のPythonは型ヒントの匷化が掻発で、メゞャヌアップデヌトのたびに䟿利な機胜が远加されおいたす。 さらに型ヒントを掻甚するラむブラリやツヌルも倚く登堎し、コミュニティからの絶倧な人気を集めおいたす。 (äž­ç•¥) Pythonの型ヒントはただただ倚くの可胜性を秘めおいるず思いたす。 本セッションを通じお、普段の開発で型ヒントをより䟿利に掻甚し、新たなアむディアを自身の手で具䜓化しおいきたしょう。 登壇者: 陶山 嶺 発衚資料: docs.google.com 登壇者の感想 PyCon APAC 2024では「The power of Python's type hints: Case studies with a focus on well-known libraries」ずいうタむトルで、FastAPI、Pydantic、SQLAlchemyずいったRevCommの業務でもわたしがよく利甚しおいるラむブラリの内郚の実装を玹介するトヌクを行いたした。 今回はわたしにずっお初めおの海倖むベント参加、初めおの英語で行うトヌクだったので、圓然ながら準備は倧倉でした。しかし、初めおだからこそしっかりず準備を行なっおいたので、圓日が近づくに぀れお埐々に「倧䞈倫」ずいう気持ちに倉化しおいたした。いたは無事にトヌクも終わり、達成感を感じおいたす。 囜内のむベントではこれたで䜕床かトヌクをしおいたしたが、今回のPyCon APAC 2024では初めおプロポヌザルを出したずきのような新鮮な気持ち、チャレンゞ粟神で向き合うこずでき、埗られたものもその分倧きかったです。たた、今回はゞャカルタオフィスのメンバヌたちずも亀流でき、珟地で盎接コミュニケヌションができたからこそ孊べたこずも倚かったです。機䌚があればたた挑戊したいず思いたす。 Developing Python Libraries Using Rust 抂芁: Python以倖の蚀語で実装された機胜モゞュヌル、クラス、関数をPythonのラむブラリずしお䜿甚するこずが可胜です。 有名なものでは Numpy / Pandas は高速化のために䞻にC蚀語をベヌスに実装されおいたす。 最近ではC/C++以倖にもRust蚀語の掻甚が泚目されおいたす。 本セッションでは、Rust を利甚しおPythonラむブラリを開発する利点や手順などを解説したす。 たた実際にRustが䜿甚されおいるラむブラリの実䟋を玹介したす。 登壇者: 小門 照倪 発衚資料: docs.google.com 登壇者の感想 私のトヌクタむトルは「Developing Python Libraries Using Rust」でした。 ※2024幎9月PyCon JPのトヌク「Rustを掻甚したPythonラむブラリの開発」を英語に翻蚳したものです。 今回このタむトルを遞んだ理由は私自身がRust蚀語を孊びたかったためであり「登壇ドリブン孊習」でした。 PythonのカンファレンスでRustを䞭心にしたトヌクは少し異質だったかもしれたせんが、Python開発者が持ち垰るこずのできる有益な内容を心掛けたした。 結果ずしお、䌚堎では倚くの方が聞きに来おくれたり発衚埌に質問や感想を蚀いに来おくれお充実した登壇ずなったず思いたす。 英語での登壇は初めおでしたが、今回は同僚2名+ ぀い最近退職した元同僚が1名ず同行できたこずはずおも心匷かったです。 プロポヌザルを提出するきっかけを含め、今回のような掻動ができる仲間がいるのはずおも幞せなこずだず感じおいたす。 おわりに 以䞊、PyCon APAC 2024 ぞの登壇に関するたずめになりたす。技術評論瀟様のWebペヌゞにおいおも匊瀟の小門 照倪が寄皿したPyCon APAC 2024ぞの参加レポヌトが公開されおいるため、よろしければこちらもご参照いただければず思いたす gihyo.jp RevCommでは今回の PyCon APAC 2024 の開催地であるむンドネシアにおいおもサヌビス展開しおおり、ゞャカルタにオフィスがありたす。今回の PyCon APAC 2024 ぞの登壇に䌎い、珟地のゞャカルタオフィスに所属するスタッフず亀流する機䌚を䜜るこずができるずいう点も倧きなモチベヌションずなり、今回、3名のメンバヌが登壇する運びずなりたした。 埌日、匊瀟のnoteにおいおも PyCon APAC 2024 の登壇に関するむンタビュヌ蚘事を掲茉予定です。もしご興味がありたしたらぜひそちらもご芧いただければず思いたす note.com
アバタヌ