TECH PLAY

株式会社マイナビ デジタルテクノロジー戦略本部

株式会社マイナビ デジタルテクノロジー戦略本部 の技術ブログ

229

本記事は【 Advent Calendar 2025 】の14日目の記事です。 デジタルプラットフォーム統括本部データソリューション統括部データアーキテクト部アナリティクス推進課のA・Kです。 そういえば自分でも久しく忘れていたのですが、ミステリーや推理小説、叙述トリック物の小説が好きで昔よく読んでいました。タイトルで分かった方もいるかもしれませんが、好きな作家は工学博士の森博嗣先生です。 ということで、今回はBIツールTableauのIF文にまつわるミステリーを書こうと思います。 0. プロローグ Tableauには「計算フィールド」という概念があります。計算フィールドを使うと、定数・変数を交えた算術計算や文字列操作などの結果をフィールドに出力することができ、主に動的な出力を得たい場合に利用します。 例えば、顧客への商品販売履歴が記録されている以下のようなテーブルがあるとします。 注文ID 注文日 ユーザーID ユーザー名 都道府県 定価 購入額 2025-645614 2025/1/30 U-562596 鈴木 花子 富山県 40000 40000 2025-748109 2025/3/29 U-585788 吉田 直樹 新潟県 180000 180000 2025-388360 2025/4/18 U-577077 高橋 健 佐賀県 260000 208000 2025-355111 2025/5/3 U-750671 斎藤 真紀 青森県 250000 250000 2025-420588 2025/5/4 U-362222 中村 真由 宮城県 200000 200000 2025-290629 2025/5/16 U-013025 佐藤 太郎 沖縄県 130000 104000 2025-277039 2025/6/22 U-715673 渡辺 彩 茨城県 200000 40000 2025-864082 2025/7/28 U-577077 高橋 健 佐賀県 300000 300000 2025-973922 2025/9/30 U-909473 小林 誠 東京都 180000 180000 2025-091362 2025/11/22 U-446022 加藤 結衣 栃木県 30000 24000 このテーブルをTableauで読み込み、「どのくらいの割引率で購入してもらったのか」の列を追加して可視化する場合、以下の計算フィールドを作成し新たな列として追加することで実現できます。 割引率([定価] - [購入額]) / [定価] 1. 双頭の悪魔 まず、以下の記述をご覧ください。 IF SIZE() = 1 THEN (SUM([定価] - [購入額])) / SUM([定価])ELSE (SUM([定価] - [購入額])) / SUM([定価])END Tableauの計算フィールドにおけるIF文のフォーマット(今回のケースに相当するもの)は、以下のとおりです。 IF <条件> THEN <条件に合致する場合の出力>ELSE <条件に合致しない場合の出力>END また、SUM関数は指定したカラムの値をさまざまな粒度(全体、一部のグループにおいて、など)で合計することができます。 つまり、はじめに掲載した記述は、条件「SIZE() = 1」に合致する場合 (SUM([定価] - [購入額])) / SUM([定価]) (=複数レコードの割引率) を返し、条件に合致しない場合 (SUM([定価] - [購入額])) / SUM([定価]) を返します。 …って、え?これどっちの計算式も同じくない????? はい、そうです。この条件に合致する場合としない場合の計算式は、 まったく同じ計算式 です。 「分かった!これ本当はIF文を使わなくても実現できるけど、無理やりIF文にしてるだけだろ!」と予想した方がいるかもしれませんが、残念ながら今回のケースでは IFを使い条件分岐させないと正しい結果を得られません 。 5. ここでいきなりエピローグ …こうして作成した計算フィールドを使い、以下のような集計結果の表示を実現することができました。めでたしめでたし。 総計という行が追加されていることが分かると思います。総計はTableauがデフォルトで持つ集計機能で、各レコードをさまざまな粒度で合計したり平均したりすることができます。 今回は定価や購入額の平均単価、平均割引率を表示したかったので、「平均により集計を行った総計」を最終行に表示することにしています。 2. 真夏の総計式 さて、総計はTableauのデフォルトの機能とお伝えしましたが、実はトラップがあります。以下の表は、平均による総計機能を単純に適用したときの集計結果です。 一見どこがおかしいのか分かりにくいですが、前項で掲載したキャプチャとの違いは割引率の総計です。前項キャプチャでは13.8%だったのに対し、今回は14.0%となっています。これはどちらが正しいのでしょうか? ということで正しい計算をしてみます。割引率の平均とは、いわば全体に均した(ならした)ときの割引率にあたるので 割引金額の合計 ÷ 定価の合計 で算出することができます。実際に計算してみます。 割引金額の合計 = 244,000 定価の合計 = 1,770,000 割引金額の合計 ÷ 定価の合計 = 0.1378 ≒  13.8% ということで、正しい値は13.8%でした。え…?では、14.0%というのはどこから来た数値なのでしょうか? 実は、総計機能の平均は以下赤枠部分の平均を集計しています。 (0 + 0 + 20 + … + 20)÷ 10 =14.0 ですね。このように、総計機能は各レコードの値を単純に算術計算して集計を行うのですが、 割合の平均は算術平均で求めることができません 。これが総計のトラップです。 では、定価と購入額の集計には算術平均を使い、割合の平均を集計するときは別の計算を行うにはどうすればいいのでしょうか? 3. GYOTH あらためて、冒頭に出てきた計算式を再度見てみます。 IF SIZE() = 1 THEN (SUM([定価] - [購入額])) / SUM([定価])ELSE (SUM([定価] - [購入額])) / SUM([定価])END ここで気になるのが  SIZE() = 1  という条件です。SIZE() とはどのような関数なのでしょうか? 結論、 SIZE関数は 各レコードが属するパーティション内の行数を返します 。パーティションという単位がどこを表すかというと、以下の図のとおりです。 従って、各行に SIZE関数を適用すると、以下の値が返ってきます。 犯人が見えてきましたね。 4. イニシエーション・サム 従って、 割引率IF SIZE() = 1 THEN (SUM([定価] - [購入額])) / SUM([定価])ELSE (SUM([定価] - [購入額])) / SUM([定価])END この計算フィールドを作成することにより、SIZE() = 1 、つまり総計の行において各レコードの平均を計算せず (SUM([定価] - [購入額])) / SUM([定価]) を計算し、また SIZE() ≠ 1 、つまり総計以外のすべての行においても (SUM([定価] - [購入額])) / SUM([定価]) を計算することができるようになります。 ここで1つ、最後の疑問が残ります。「総計以外の行はすべて1行ごとに割引率を計算するんだから、わざわざSUMを使って合計する必要はないのでは?」 その答えは2つあります。1つは、そういう仕様だからです(SUMなど集計関数をつけないとエラーが出ます)。なんだそりゃ、と思う方もいるかもしれませんが、これは合理的な仕様です。その合理性が分かるもう1つの理由が、Tableauはデータ分析に使うツールなので、以下のように別の切り口でも分析してみたいというニーズがありえる点です。この場合、地域ごとに定価の合計、割引額の合計を算出するためにSUMを行う必要があるのは明白です。 5. エピローグ …こうして作成した計算フィールドを使い、以下のような集計結果の表示を実現することができました。めでたしめでたし。 6. あとがき ということで、Tableau IF文ミステリィでした。今回のケースは、実際に業務でのレポート作成中に直面した問題がベースになっています。私自身、IF文の条件に合う場合と合わない場合でまったく同じ計算式を使う、という経験がこれまでなかったので、このような方法で解決できたことが非常に興味深かったです。 Tableauはこのような形で計算フィールドを活用し、集計を行うことができます。このミステリーもどきでTableauに興味を持たれた方は、さまざまな機能や計算フィールドを使ってデータの可視化にチャレンジしてみてください!新たな発見があるかもしれません。 以上、ヴァン・ダインでした。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の13日目の記事です。 Autogen とは? Autogenは、AIを活用したマルチエージェントシステム構築のためのフレームワークです。 簡単に言うと AIエージェントを相互に会話させ、協働させ、タスクを自動的に解決させることができます。 1つのAIがすべてを行う代わりに、複数の専門化されたエージェントが存在します。 これらのエージェントは、複雑なワークフローを完了するために通信し、情報を共有し、協力して作業できます。 特徴 LLM統合 :GPTなどのモデルと連携します。 スケーラブルなアーキテクチャ :複雑な多段階プロセスを処理します。 カスタムエージェント :ニーズに合わせたエージェントを構築します。 歴史 Autogenは当初、Microsoftのみで開発されていました。 その後、AG2とバージョン0.4に分割されました。 AG2は全く異なるフレームワークであり、別個に管理されています。 ChatGPTやその他のコード生成エージェントはAG2向けのコードを提供します。 Autogenのコードを生成するようChatGPTに依頼しても正しいコードが得られないです。 AIから作成したAutogenのコード ユースケース ソフトウェア開発アシスタント エージェント: 1)コード生成エージェント(初期コードを生成) 2)コードレビューエージェント(バグとベストプラクティスをチェック) 3)ドキュメントエージェント(使用方法ドキュメントを作成) 目標 :開発を加速し、品質を維持する。 旅行計画 エージェント: 1)旅程プランナー(旅行の日ごとの旅程を作成します) 2)費用見積もり担当者(旅行のおおよその費用を計算します) 3)レビュー担当者(計画が現実的かつ実践的かどうかを確認する) 目標 :予算内で現実的な旅行計画を作成する。 コンテンツ制作 エージェント: 1)ライター(ブログや記事の草稿作成) 2)編集者(文法やトーンのチェック) 3)SEOスペシャリスト(キーワードの最適化) 目標 :高品質でSEO対策に優れたコンテンツを作成する。 データ分析と報告 エージェント: 1)データコレクター(APIからデータを取得) 2)アナリスト(インサイトとチャートを生成) 3)レポートライター(調査結果を平易な言葉で要約) 目標 :エンドツーエンドのレポート作成を自動化する。 APIキーについて OpenAI APIキーは数年前は無料でしたが、現在は無料ではありません。 推奨アプローチ Gemini APIキーを使用する( https://ai.google.dev/gemini-api/docs/api-key ) Ollamaを使用する(LLMモデルをローカルで実行するために使用) OpenRouter APIキーを使用する Ollama は自分の所属する会社ルールに従って利用して下さい。 一定期間無料で使用できるGemini APIキーまたはOpenRouter APIキーを使用するのが最適です。 私はGeminiキーを使用します。Geminiキーを使用する場合、以下のコードでAutogenを試すことができます Geminiにログインし、パーソナルアカウントを使用してAPIキーを生成してください。 重要なポイント Autogenでは非同期でエージェントへの呼び出しが行われる。Pythonライブラリで非同期コーディングを実現するためにAsyncioが使用される。 Autogenでは、各エージェントがどの分野の専門家であるか私たちが指定します 初めてのオートジェンエージェントを作成しましょう 手順 Autogen用の環境を作成する (Autogent用の環境の作成) python3 -m venv autogensource autogen/bin/activate requirements.txt ファイルを作成する (必要なパッケージをすべて1つのファイルで定義する) requirements.txt autogen-agentchatautogen-coreautogen-extasynciodotenvopenaitiktoken autogen - agentchat autogen - core autogen - ext asyncio dotenv openai tiktoken pip install -r requirements.txt GEMINI APIキーの環境変数を設定する(ご自身のAPIキーを追加してください) (APIキーの定義) .env GEMINI_API_KEY = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" GEMINI_API_KEY = " xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx " 単一エージェントの作成 (質問に対して出力結果を返すシンプルな単一エージェントを作成する) agent.py import asynciofrom autogen_agentchat.agents import AssistantAgentfrom autogen_ext.models.openai import OpenAIChatCompletionClientfrom autogen_agentchat.messages import TextMessagefrom autogen_core.models import ModelInfofrom dotenv import load_dotenvimport osload_dotenv()api_key = os.getenv("GEMINI_API_KEY")model_client = OpenAIChatCompletionClient( model="gemini-2.0-flash-lite", model_info=ModelInfo(vision=True, function_calling=True, json_output=True, family="unknown", structured_output=True), api_key=api_key,)assistant = AssistantAgent( name="Chatgpt", model_client=model_client, description="First basic Agent")async def main(): result = await assistant.run(task="What is the capital of Japan?") print(result.messages[-1].content)asyncio.run(main()) import asyncio from autogen_agentchat . agents import AssistantAgent from autogen_ext . models . openai import OpenAIChatCompletionClient from autogen_agentchat . messages import TextMessage from autogen_core . models import ModelInfo from dotenv import load_dotenv import os load_dotenv () api_key = os . getenv ( " GEMINI_API_KEY " ) model_client = OpenAIChatCompletionClient ( model = " gemini-2.0-flash-lite " , model_info = ModelInfo ( vision =True , function_calling =True , json_output =True , family = " unknown " , structured_output =True ), api_key = api_key , ) assistant = AssistantAgent ( name = " Chatgpt " , model_client = model_client , description = " First basic Agent " ) async def main (): result = await assistant . run ( task = " What is the capital of Japan? " ) print ( result . messages [ - 1 ]. content ) asyncio . run ( main ()) python3 agent.py アウトプット マルチエージェントAIを作成しましょう (指定された予算と指定期間内で旅行の旅程を生成するマルチエージェントシステムを構築する。) itinerary_agent - 指定された都市と期間に対して、詳細な日ごとの旅行計画を作成します。 cost_agent - 旅程を受け取り、おおよその費用を計算します(交通費、食費、観光費、宿泊費)。 review_agent - 旅程と費用見積もりが現実的かどうかを確認する 三人のエージェントが互いに連携し、最終的に完璧な旅程を生成する multiAgent.py import asynciofrom autogen_ext.models.openai import OpenAIChatCompletionClientfrom autogen_agentchat.agents import AssistantAgentfrom autogen_agentchat.teams import RoundRobinGroupChatfrom autogen_agentchat.messages import TextMessagefrom autogen_core.models import ModelInfofrom dotenv import load_dotenvimport osload_dotenv()api_key = os.getenv("GEMINI_API_KEY")model_client = OpenAIChatCompletionClient( model="gemini-2.0-flash-lite", model_info=ModelInfo(vision=True, function_calling=True, json_output=True, family="unknown", structured_output=True), api_key=api_key)itinerary_agent = AssistantAgent( name="itinerary_agent", model_client=model_client, system_message=( "あなたは旅行日程の専門家です。 " "特定の都市と滞在日数が与えられた場合、詳細な日別旅行計画を作成してください。 " "時間割、訪問場所、交通手段の提案、およびその理由を含めてください。" ))cost_agent = AssistantAgent( name="cost_agent", model_client=model_client, system_message=( "あなたは旅行費用見積もり担当者です。" "旅程が与えられた場合、交通費、食費、観光費、宿泊費のおおよその費用を計算してください。 " "合計がユーザーの予算を超えた場合、より安価な変更案を提案してください。" ))review_agent = AssistantAgent( name="review_agent", model_client=model_client, system_message=( "旅程と費用見積もりを検証する審査員です。 " "計画が現実的か確認してください(移動時間、営業時間、順序)。 " "問題がある場合は、前の担当者に修正を依頼してください。" ))team = RoundRobinGroupChat( participants=[itinerary_agent, cost_agent, review_agent], max_turns=6)async def run_team(): user_task = TextMessage( content="京都への3日間の旅行を5万円以内で計画してください。予算内に収めつつ、楽しめるプランにしましょう。内容は200語以内に収めてください。", source="user") result = await team.run(task=user_task) for msg in result.messages: print(f"{msg.source}:\n{msg.content}\n")async def main(): await run_team()asyncio.run(main()) import asyncio from autogen_ext . models . openai import OpenAIChatCompletionClient from autogen_agentchat . agents import AssistantAgent from autogen_agentchat . teams import RoundRobinGroupChat from autogen_agentchat . messages import TextMessage from autogen_core . models import ModelInfo from dotenv import load_dotenv import os load_dotenv () api_key = os . getenv ( " GEMINI_API_KEY " ) model_client = OpenAIChatCompletionClient ( model = " gemini-2.0-flash-lite " , model_info = ModelInfo ( vision =True , function_calling =True , json_output =True , family = " unknown " , structured_output =True ), api_key = api_key ) itinerary_agent = AssistantAgent ( name = " itinerary_agent " , model_client = model_client , system_message = ( " あなたは旅行日程の専門家です。 " " 特定の都市と滞在日数が与えられた場合、詳細な日別旅行計画を作成してください。 " " 時間割、訪問場所、交通手段の提案、およびその理由を含めてください。 " ) ) cost_agent = AssistantAgent ( name = " cost_agent " , model_client = model_client , system_message = ( " あなたは旅行費用見積もり担当者です。 " " 旅程が与えられた場合、交通費、食費、観光費、宿泊費のおおよその費用を計算してください。 " " 合計がユーザーの予算を超えた場合、より安価な変更案を提案してください。 " ) ) review_agent = AssistantAgent ( name = " review_agent " , model_client = model_client , system_message = ( " 旅程と費用見積もりを検証する審査員です。 " " 計画が現実的か確認してください(移動時間、営業時間、順序)。 " " 問題がある場合は、前の担当者に修正を依頼してください。 " ) ) team = RoundRobinGroupChat ( participants = [ itinerary_agent , cost_agent , review_agent ], max_turns = 6 ) async def run_team (): user_task = TextMessage ( content = " 京都への3日間の旅行を5万円以内で計画してください。予算内に収めつつ、楽しめるプランにしましょう。内容は200語以内に収めてください。 " , source = " user " ) result = await team . run ( task = user_task ) for msg in result . messages : print ( f " { msg . source } : \n{ msg . content }\n " ) async def main (): await run_team () asyncio . run ( main ()) python3 multiAgent.py アウトプット user:京都への3日間の旅行を5万円以内で計画してください。予算内に収めつつ、楽しめるプランにしましょう。内容は200語以内に収めてください。itinerary_agent:承知いたしました。京都3日間5万円以内の旅程をご提案します。**1日目:*** **午前:** 伏見稲荷大社へ。朱色の鳥居が圧巻です。* **午後:** 清水寺周辺を散策。清水の舞台からの眺めを堪能し、二年坂・産寧坂でお土産探し。* **夕食:** 予算を考慮し、駅周辺の定食屋で。* **交通:** 市バス(1日乗車券利用がお得)**2日目:*** **午前:** 金閣寺へ。金箔が輝く姿は必見。* **午後:** 嵐山へ。竹林の道や渡月橋を散策。人力車は予算に応じて。* **夕食:** 嵐山で食べ歩きや、手頃な価格のカフェで夕食を。* **交通:** 嵐電、JR**3日目:*** **午前:** 錦市場で食べ歩き。* **午後:** 京都御所を散策(事前予約推奨)。* **夕食:** 京都駅周辺で新幹線に乗る前に、お土産を購入し、駅ビル内のレストランで最後の食事を。* **交通:** 市バス、電車、新幹線**宿泊:** ゲストハウスや格安ホテルを選択。食事は、安価な店やコンビニを活用することで予算内に収まるでしょう。cost_agent:素晴らしいプランですね! 伏見稲荷大社、清水寺、金閣寺、嵐山と、京都の主要な観光スポットをバランス良く網羅しています。市バスの一日乗車券を利用するなど、交通費を抑える工夫も素晴らしいです。**詳細な費用概算*** **交通費:** * 新幹線(往復):約20,000円 (出発地によります) * 市バス1日乗車券:1,100円 × 2日 = 2,200円 * 嵐電、JRなど:3,000円 * **合計:約25,200円*** **宿泊費:** ゲストハウスまたは格安ホテル (3泊):1泊あたり5,000円と仮定して、15,000円* **食費:** 1日あたり3,000円 × 3日 = 9,000円* **観光費:** 各施設の入場料、お土産代など:約5,000円* **合計:約54,200円**この概算では、残念ながら5万円の予算を若干オーバーしてしまいます。 そこで、いくつか調整案をご提案します。**調整案**1. **交通費の見直し:** * 新幹線の代わりに、夜行バスを利用する (往復10,000円程度で済む可能性があります)。2. **宿泊費の見直し:** * カプセルホテルや、より格安なゲストハウスを探す。 * Airbnbなどを利用する。3. **食費の見直し:** * 自炊できるゲストハウスを選ぶ。 * ランチは軽食にする。4. **観光費の見直し:** * 入場料が無料の場所も活用する(例:伏見稲荷大社)。 * お土産は、少なめにするか、リーズナブルなものを選ぶ。これらの調整を行うことで、5万円の予算内に収まる可能性があります。review_agent:ありがとうございます。詳細な費用概算と、的確な調整案のご提示、大変助かります。確かに、新幹線を夜行バスに変更することや、宿泊費を抑えることが、予算内に収めるための重要なポイントですね。**修正後のプラン(調整案を反映)****1日目:*** **午前:** 伏見稲荷大社へ。* **午後:** 清水寺周辺を散策。清水の舞台からの眺めを堪能し、二年坂・産寧坂でお土産探し。* **夕食:** 予算を考慮し、駅周辺の定食屋で。* **交通:** 市バス(1日乗車券利用)**2日目:*** **午前:** 金閣寺へ。* **午後:** 嵐山へ。竹林の道や渡月橋を散策。人力車は今回は見送り。* **夕食:** 嵐山で食べ歩きや、手頃な価格のカフェで夕食を。* **交通:** 嵐電、JR**3日目:*** **午前:** 錦市場で食べ歩き。* **午後:** 京都御所を散策(事前予約推奨)。* **夕食:** 京都駅周辺で新幹線に乗る前に、お土産を購入し、駅ビル内のレストランで最後の食事を。* **交通:** 市バス、電車、夜行バス**費用の再概算(調整案反映後)*** **交通費:** * 夜行バス(往復):約10,000円 * 市バス1日乗車券:1,100円 × 2日 = 2,200円 * 嵐電、JRなど:3,000円 * **合計:約15,200円*** **宿泊費:** カプセルホテルまたは格安ゲストハウス (3泊):1泊あたり4,000円と仮定して、12,000円* **食費:** 1日あたり2,500円 × 3日 = 7,500円(ランチは軽食中心)* **観光費:** 各施設の入場料、お土産代など:約10,300円 (伏見稲荷大社、清水寺、金閣寺は入場料がかかりますが、それ以外の場所では無料の場所も活用し、お土産代を調整)* **合計:約45,000円**これで、5万円の予算内に収まりそうですね!このプランは、夜行バスの利用、宿泊費の節約、食費の見直し、観光費の調整によって、予算をクリアできる可能性が高くなりました。素晴らしいです。itinerary_agent:完璧ですね!修正案が反映され、5万円以内の予算で、京都の主要観光地を巡る3日間の旅程が実現可能になりました。**補足事項:*** **夜行バスの予約:** 確実に予約を確保するため、早めの予約をお勧めします。* **宿泊施設の予約:** ゲストハウスやカプセルホテルも、人気のある施設はすぐに満室になることがあります。こちらも早めの予約を。* **京都御所の予約:** 事前予約が必要ですので、忘れずに手続きしてください。* **観光施設の開館時間:** 各施設の開館時間を事前に確認し、効率よく観光できるように計画を立てましょう。* **移動時間:** 市バスや電車での移動時間を考慮し、無理のないスケジュールを立てましょう。* **持ち物:** 歩きやすい靴、防寒具、雨具などを忘れずに準備しましょう。このプランで、京都の魅力を存分に楽しんでください! 良い旅になりますように!cost_agent:ありがとうございます! 完璧なプランニングですね。補足事項も非常に的確で、旅行者が安心して旅を楽しめるよう、細やかな配慮がされています。このプランを参考に、楽しい京都旅行を満喫してください! 何か他に質問があれば、お気軽にお尋ねください。review_agent:ありがとうございます! こちらこそ、素晴らしいプランを一緒に作り上げることができ、大変光栄です。何かお役に立てることがあれば、いつでもお声がけください。 良い旅になりますように! user : 京都への3日間の旅行を5万円以内で計画してください。予算内に収めつつ、楽しめるプランにしましょう。内容は200語以内に収めてください。 itinerary_agent : 承知いたしました。京都3日間5万円以内の旅程をご提案します。 ** 1日目 : ** * ** 午前 : ** 伏見稲荷大社へ。朱色の鳥居が圧巻です。 * ** 午後 : ** 清水寺周辺を散策。清水の舞台からの眺めを堪能し、二年坂・産寧坂でお土産探し。 * ** 夕食 : ** 予算を考慮し、駅周辺の定食屋で。 * ** 交通 : ** 市バス( 1日乗車券利用がお得 ) ** 2日目 : ** * ** 午前 : ** 金閣寺へ。金箔が輝く姿は必見。 * ** 午後 : ** 嵐山へ。竹林の道や渡月橋を散策。人力車は予算に応じて。 * ** 夕食 : ** 嵐山で食べ歩きや、手頃な価格のカフェで夕食を。 * ** 交通 : ** 嵐電、JR ** 3日目 : ** * ** 午前 : ** 錦市場で食べ歩き。 * ** 午後 : ** 京都御所を散策(事前予約推奨)。 * ** 夕食 : ** 京都駅周辺で新幹線に乗る前に、お土産を購入し、駅ビル内のレストランで最後の食事を。 * ** 交通 : ** 市バス、電車、新幹線 ** 宿泊 : ** ゲストハウスや格安ホテルを選択。食事は、安価な店やコンビニを活用することで予算内に収まるでしょう。 cost_agent : 素晴らしいプランですね! 伏見稲荷大社、清水寺、金閣寺、嵐山と、京都の主要な観光スポットをバランス良く網羅しています。市バスの一日乗車券を利用するなど、交通費を抑える工夫も素晴らしいです。 ** 詳細な費用概算 ** * ** 交通費 : ** * 新幹線(往復):約20 , 000円 ( 出発地によります ) * 市バス1日乗車券: 1 , 100円 × 2日 = 2 , 200円 * 嵐電、JRなど: 3 , 000円 * ** 合計:約25 , 200円 ** * ** 宿泊費 : ** ゲストハウスまたは格安ホテル ( 3泊 ) : 1泊あたり5 , 000円と仮定して 、 15 , 000円 * ** 食費 : ** 1日あたり3 , 000円 × 3日 = 9 , 000円 * ** 観光費 : ** 各施設の入場料、お土産代など:約5 , 000円 * ** 合計:約54 , 200円 ** この概算では、残念ながら5万円の予算を若干オーバーしてしまいます。 そこで、いくつか調整案をご提案します。 ** 調整案 ** 1 . ** 交通費の見直し : ** * 新幹線の代わりに、 夜行バスを利用する ( 往復10 , 000円程度で済む可能性があります ) 。 2 . ** 宿泊費の見直し : ** * カプセルホテルや、より格安なゲストハウスを探す。 * Airbnbなどを利用する。 3 . ** 食費の見直し : ** * 自炊できるゲストハウスを選ぶ。 * ランチは軽食にする。 4 . ** 観光費の見直し : ** * 入場料が無料の場所も活用する(例:伏見稲荷大社)。 * お土産は、少なめにするか、リーズナブルなものを選ぶ。 これらの調整を行うことで、 5万円の予算内に収まる可能性があります 。 review_agent : ありがとうございます。詳細な費用概算と、的確な調整案のご提示、大変助かります。確かに、新幹線を夜行バスに変更することや、宿泊費を抑えることが、予算内に収めるための重要なポイントですね。 ** 修正後のプラン(調整案を反映) ** ** 1日目 : ** * ** 午前 : ** 伏見稲荷大社へ。 * ** 午後 : ** 清水寺周辺を散策。清水の舞台からの眺めを堪能し、二年坂・産寧坂でお土産探し。 * ** 夕食 : ** 予算を考慮し、駅周辺の定食屋で。 * ** 交通 : ** 市バス( 1日乗車券利用 ) ** 2日目 : ** * ** 午前 : ** 金閣寺へ。 * ** 午後 : ** 嵐山へ。竹林の道や渡月橋を散策。人力車は今回は見送り。 * ** 夕食 : ** 嵐山で食べ歩きや、手頃な価格のカフェで夕食を。 * ** 交通 : ** 嵐電、JR ** 3日目 : ** * ** 午前 : ** 錦市場で食べ歩き。 * ** 午後 : ** 京都御所を散策(事前予約推奨)。 * ** 夕食 : ** 京都駅周辺で新幹線に乗る前に、お土産を購入し、駅ビル内のレストランで最後の食事を。 * ** 交通 : ** 市バス、電車、夜行バス ** 費用の再概算(調整案反映後) ** * ** 交通費 : ** * 夜行バス(往復):約10 , 000円 * 市バス1日乗車券: 1 , 100円 × 2日 = 2 , 200円 * 嵐電、JRなど: 3 , 000円 * ** 合計:約15 , 200円 ** * ** 宿泊費 : ** カプセルホテルまたは格安ゲストハウス ( 3泊 ) : 1泊あたり4 , 000円と仮定して 、 12 , 000円 * ** 食費 : ** 1日あたり2 , 500円 × 3日 = 7 , 500円 (ランチは軽食中心) * ** 観光費 : ** 各施設の入場料、お土産代など:約10 , 300円 ( 伏見稲荷大社、清水寺、金閣寺は入場料がかかりますが、それ以外の場所では無料の場所も活用し、お土産代を調整 ) * ** 合計:約45 , 000円 ** これで、 5万円の予算内に収まりそうですね ! このプランは、夜行バスの利用、宿泊費の節約、食費の見直し、観光費の調整によって、予算をクリアできる可能性が高くなりました。素晴らしいです。 itinerary_agent : 完璧ですね!修正案が反映され、 5万円以内の予算で 、京都の主要観光地を巡る3日間の旅程が実現可能になりました。 ** 補足事項 : ** * ** 夜行バスの予約 : ** 確実に予約を確保するため、早めの予約をお勧めします。 * ** 宿泊施設の予約 : ** ゲストハウスやカプセルホテルも、人気のある施設はすぐに満室になることがあります。こちらも早めの予約を。 * ** 京都御所の予約 : ** 事前予約が必要ですので、忘れずに手続きしてください。 * ** 観光施設の開館時間 : ** 各施設の開館時間を事前に確認し、効率よく観光できるように計画を立てましょう。 * ** 移動時間 : ** 市バスや電車での移動時間を考慮し、無理のないスケジュールを立てましょう。 * ** 持ち物 : ** 歩きやすい靴、防寒具、雨具などを忘れずに準備しましょう。 このプランで、京都の魅力を存分に楽しんでください! 良い旅になりますように! cost_agent : ありがとうございます! 完璧なプランニングですね。補足事項も非常に的確で、旅行者が安心して旅を楽しめるよう、細やかな配慮がされています。 このプランを参考に、楽しい京都旅行を満喫してください! 何か他に質問があれば、お気軽にお尋ねください。 review_agent : ありがとうございます! こちらこそ、素晴らしいプランを一緒に作り上げることができ、大変光栄です。何かお役に立てることがあれば、いつでもお声がけください。 良い旅になりますように! 単一エージェント(ChatGPTのような)の使用とマルチエージェントの使用の比較 同じ質問に対するChatGPTの出力 【1日目:清水寺と祇園】 清水寺(400円)を参拝。三年坂・二年坂を散策。昼食はカフェで約1,200円。午後は祇園を歩く。夕食はうどん(約900円)。 合計:約3,000円 【2日目:嵐山エリア】 JRで嵐山へ(240円)。竹林の小径(無料)、天龍寺庭園(500円)。川辺でお弁当(900円)。希望があれば猿山(600円)。夕食は1,000円ほど。 合計:約3,500円 【3日目:伏見稲荷と錦市場】 伏見稲荷(無料)。朝食600円。錦市場へ(210円)。食べ歩き1,200円。河原町を散策。夕食1,200円。 合計:約3,200円 【総費用:約1〜1.5万円(宿泊除く)】 余裕で5万円以内に収まります。 安いバージョン、豪華バージョン、が必要であればお知らせください。 特徴 単一エージェント(ChatGPT) マルチエージェント タスク 一つのモデルがすべてをこなす 専門家ごとに異なる業務を担当する エラーチェック なし レビュー担当者が誤りを発見する 反復 ユーザーは改善を要求しなければならない エージェントは自動的に互いを洗練させる 出力品質 良いが、単調である 構造化された、階層化された、より深い出力 結論 オートジェンはAIの活用方法におけるパラダイムシフトです。協調知能を実現することで、単一エージェントシステムでは達成できない可能性を解き放ちます。開発者、研究者、技術愛好家の方々に、オートジェンは力になります。 オートゲンの真価は、モデルが賢くなることではなく、ワークフローが効率化されることにある。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の12日目の記事です。 結論 フロントエンドでは、OrvalでzodではなくFetch Clientを生成することを検討してみてはいかがでしょうか? Orvalとは Orval とは、OpenAPIからTypeScriptのコードを生成できるツールです。 例えば、以下のようなコードが生成できます。 Fetch Client React Query Zod Hono このように、Orvalは様々な種類のコードを生成できるとても便利なツールです。 しかし、使い方によっては、逆に保守性の低下を引き起こす可能性があります。 特に、「フロントエンドでzodを生成させている」ことが課題となるケースがあります。 そこで、本記事ではフロントエンドでOrvalにzodを生成させることについて、いくつかの観点から考察していきます。 Fetch Clientという選択肢 フロントエンドにおいて、なぜOrvalを使用するのでしょうか? それは、APIのレスポンスに型が欲しいからです。 zodを生成させることで型を得ることができますが、Fetch Clientを生成させることでも同様に実現でき、より簡潔にできる可能性があります。 zodとFetch Clientの比較 では、zodを生成させた場合とFetch Clientを生成させた場合のコードを比較してみましょう。 OpenAPI openapi: 3.0.0servers: - url: 'http://petstore.swagger.io/v2'info: description: >- This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. version: 1.0.0 title: OpenAPI Petstore license: name: Apache-2.0 url: 'https://www.apache.org/licenses/LICENSE-2.0.html'tags: - name: pet description: Everything about your Pets - name: store description: Access to Petstore orders - name: user description: Operations about userpaths: /pet: post: tags: - pet summary: Add a new pet to the store description: '' operationId: addPet responses: '200': description: successful operation content: application/xml: schema: $ref: '#/components/schemas/Pet' application/json: schema: $ref: '#/components/schemas/Pet' '405': description: Invalid input security: - petstore_auth: - 'write:pets' - 'read:pets' requestBody: $ref: '#/components/requestBodies/Pet' put: tags: - pet summary: Update an existing pet description: '' operationId: updatePet externalDocs: url: "http://petstore.swagger.io/v2/doc/updatePet" description: "API documentation for the updatePet operation" responses: '200': description: successful operation content: application/xml: schema: $ref: '#/components/schemas/Pet' application/json: schema: $ref: '#/components/schemas/Pet' '400': description: Invalid ID supplied '404': description: Pet not found '405': description: Validation exception security: - petstore_auth: - 'write:pets' - 'read:pets' requestBody: $ref: '#/components/requestBodies/Pet' /pet/findByStatus: get: tags: - pet summary: Finds Pets by status description: Multiple status values can be provided with comma separated strings operationId: findPetsByStatus parameters: - name: status in: query description: Status values that need to be considered for filter required: true style: form explode: false deprecated: true schema: type: array items: type: string enum: - available - pending - sold default: available responses: '200': description: successful operation content: application/xml: schema: type: array items: $ref: '#/components/schemas/Pet' application/json: schema: type: array items: $ref: '#/components/schemas/Pet' '400': description: Invalid status value security: - petstore_auth: - 'read:pets' /pet/findByTags: get: tags: - pet summary: Finds Pets by tags description: >- Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. operationId: findPetsByTags parameters: - name: tags in: query description: Tags to filter by required: true style: form explode: false schema: type: array items: type: string responses: '200': description: successful operation content: application/xml: schema: type: array items: $ref: '#/components/schemas/Pet' application/json: schema: type: array items: $ref: '#/components/schemas/Pet' '400': description: Invalid tag value security: - petstore_auth: - 'read:pets' deprecated: true '/pet/{petId}': get: tags: - pet summary: Find pet by ID description: Returns a single pet operationId: getPetById parameters: - name: petId in: path description: ID of pet to return required: true schema: type: integer format: int64 responses: '200': description: successful operation content: application/xml: schema: $ref: '#/components/schemas/Pet' application/json: schema: $ref: '#/components/schemas/Pet' '400': description: Invalid ID supplied '404': description: Pet not found security: - api_key: [] post: tags: - pet summary: Updates a pet in the store with form data description: '' operationId: updatePetWithForm parameters: - name: petId in: path description: ID of pet that needs to be updated required: true schema: type: integer format: int64 responses: '405': description: Invalid input security: - petstore_auth: - 'write:pets' - 'read:pets' requestBody: content: application/x-www-form-urlencoded: schema: type: object properties: name: description: Updated name of the pet type: string status: description: Updated status of the pet type: string delete: tags: - pet summary: Deletes a pet description: '' operationId: deletePet parameters: - name: api_key in: header required: false schema: type: string - name: petId in: path description: Pet id to delete required: true schema: type: integer format: int64 responses: '400': description: Invalid pet value security: - petstore_auth: - 'write:pets' - 'read:pets' '/pet/{petId}/uploadImage': post: tags: - pet summary: uploads an image description: '' operationId: uploadFile parameters: - name: petId in: path description: ID of pet to update required: true schema: type: integer format: int64 responses: '200': description: successful operation content: application/json: schema: $ref: '#/components/schemas/ApiResponse' security: - petstore_auth: - 'write:pets' - 'read:pets' requestBody: content: multipart/form-data: schema: type: object properties: additionalMetadata: description: Additional data to pass to server type: string file: description: file to upload type: string format: binary /store/inventory: get: tags: - store summary: Returns pet inventories by status description: Returns a map of status codes to quantities operationId: getInventory responses: '200': description: successful operation content: application/json: schema: type: object additionalProperties: type: integer format: int32 security: - api_key: [] /store/order: post: tags: - store summary: Place an order for a pet description: '' operationId: placeOrder responses: '200': description: successful operation content: application/xml: schema: $ref: '#/components/schemas/Order' application/json: schema: $ref: '#/components/schemas/Order' '400': description: Invalid Order requestBody: content: application/json: schema: $ref: '#/components/schemas/Order' description: order placed for purchasing the pet required: true '/store/order/{orderId}': get: tags: - store summary: Find purchase order by ID description: >- For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions operationId: getOrderById parameters: - name: orderId in: path description: ID of pet that needs to be fetched required: true schema: type: integer format: int64 minimum: 1 maximum: 5 responses: '200': description: successful operation content: application/xml: schema: $ref: '#/components/schemas/Order' application/json: schema: $ref: '#/components/schemas/Order' '400': description: Invalid ID supplied '404': description: Order not found delete: tags: - store summary: Delete purchase order by ID description: >- For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors operationId: deleteOrder parameters: - name: orderId in: path description: ID of the order that needs to be deleted required: true schema: type: string responses: '400': description: Invalid ID supplied '404': description: Order not found /user: post: tags: - user summary: Create user description: This can only be done by the logged in user. operationId: createUser responses: default: description: successful operation security: - api_key: [] requestBody: content: application/json: schema: $ref: '#/components/schemas/User' description: Created user object required: true /user/createWithArray: post: tags: - user summary: Creates list of users with given input array description: '' operationId: createUsersWithArrayInput responses: default: description: successful operation security: - api_key: [] requestBody: $ref: '#/components/requestBodies/UserArray' /user/createWithList: post: tags: - user summary: Creates list of users with given input array description: '' operationId: createUsersWithListInput responses: default: description: successful operation security: - api_key: [] requestBody: $ref: '#/components/requestBodies/UserArray' /user/login: get: tags: - user summary: Logs user into the system description: '' operationId: loginUser parameters: - name: username in: query description: The user name for login required: true schema: type: string pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' - name: password in: query description: The password for login in clear text required: true schema: type: string responses: '200': description: successful operation headers: Set-Cookie: description: >- Cookie authentication key for use with the `api_key` apiKey authentication. schema: type: string example: AUTH_KEY=abcde12345; Path=/; HttpOnly X-Rate-Limit: description: calls per hour allowed by the user schema: type: integer format: int32 X-Expires-After: description: date in UTC when token expires schema: type: string format: date-time content: application/xml: schema: type: string application/json: schema: type: string '400': description: Invalid username/password supplied /user/logout: get: tags: - user summary: Logs out current logged in user session description: '' operationId: logoutUser responses: default: description: successful operation security: - api_key: [] '/user/{username}': get: tags: - user summary: Get user by user name description: '' operationId: getUserByName parameters: - name: username in: path description: The name that needs to be fetched. Use user1 for testing. required: true schema: type: string responses: '200': description: successful operation content: application/xml: schema: $ref: '#/components/schemas/User' application/json: schema: $ref: '#/components/schemas/User' '400': description: Invalid username supplied '404': description: User not found put: tags: - user summary: Updated user description: This can only be done by the logged in user. operationId: updateUser parameters: - name: username in: path description: name that need to be deleted required: true schema: type: string responses: '400': description: Invalid user supplied '404': description: User not found security: - api_key: [] requestBody: content: application/json: schema: $ref: '#/components/schemas/User' description: Updated user object required: true delete: tags: - user summary: Delete user description: This can only be done by the logged in user. operationId: deleteUser parameters: - name: username in: path description: The name that needs to be deleted required: true schema: type: string responses: '400': description: Invalid username supplied '404': description: User not found security: - api_key: []externalDocs: description: Find out more about Swagger url: 'http://swagger.io'components: requestBodies: UserArray: content: application/json: schema: type: array items: $ref: '#/components/schemas/User' description: List of user object required: true Pet: content: application/json: schema: $ref: '#/components/schemas/Pet' application/xml: schema: $ref: '#/components/schemas/Pet' description: Pet object that needs to be added to the store required: true securitySchemes: petstore_auth: type: oauth2 flows: implicit: authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' scopes: 'write:pets': modify pets in your account 'read:pets': read your pets api_key: type: apiKey name: api_key in: header schemas: Order: title: Pet Order description: An order for a pets from the pet store type: object properties: id: type: integer format: int64 petId: type: integer format: int64 quantity: type: integer format: int32 shipDate: type: string format: date-time status: type: string description: Order Status enum: - placed - approved - delivered complete: type: boolean default: false xml: name: Order Category: title: Pet category description: A category for a pet type: object properties: id: type: integer format: int64 name: type: string pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' xml: name: Category User: title: a User description: A User who is purchasing from the pet store type: object properties: id: type: integer format: int64 username: type: string firstName: type: string lastName: type: string email: type: string password: type: string phone: type: string userStatus: type: integer format: int32 description: User Status xml: name: User Tag: title: Pet Tag description: A tag for a pet type: object properties: id: type: integer format: int64 name: type: string xml: name: Tag Pet: title: a Pet description: A pet for sale in the pet store type: object required: - name - photoUrls properties: id: type: integer format: int64 category: $ref: '#/components/schemas/Category' name: type: string example: doggie photoUrls: type: array xml: name: photoUrl wrapped: true items: type: string tags: type: array xml: name: tag wrapped: true items: $ref: '#/components/schemas/Tag' status: type: string description: pet status in the store deprecated: true enum: - available - pending - sold xml: name: Pet ApiResponse: title: An uploaded response description: Describes the result of uploading an image resource type: object properties: code: type: integer format: int32 type: type: string message: type: string openapi : 3.0.0 servers : - url : ' http://petstore.swagger.io/v2 ' info : description : >- This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. version : 1.0.0 title : OpenAPI Petstore license : name : Apache-2.0 url : ' https://www.apache.org/licenses/LICENSE-2.0.html ' tags : - name : pet description : Everything about your Pets - name : store description : Access to Petstore orders - name : user description : Operations about user paths : /pet : post : tags : - pet summary : Add a new pet to the store description : '' operationId : addPet responses : ' 200 ' : description : successful operation content : application/xml : schema : $ref : ' #/components/schemas/Pet ' application/json : schema : $ref : ' #/components/schemas/Pet ' ' 405 ' : description : Invalid input security : - petstore_auth : - ' write:pets ' - ' read:pets ' requestBody : $ref : ' #/components/requestBodies/Pet ' put : tags : - pet summary : Update an existing pet description : '' operationId : updatePet externalDocs : url : " http://petstore.swagger.io/v2/doc/updatePet " description : " API documentation for the updatePet operation " responses : ' 200 ' : description : successful operation content : application/xml : schema : $ref : ' #/components/schemas/Pet ' application/json : schema : $ref : ' #/components/schemas/Pet ' ' 400 ' : description : Invalid ID supplied ' 404 ' : description : Pet not found ' 405 ' : description : Validation exception security : - petstore_auth : - ' write:pets ' - ' read:pets ' requestBody : $ref : ' #/components/requestBodies/Pet ' /pet/findByStatus : get : tags : - pet summary : Finds Pets by status description : Multiple status values can be provided with comma separated strings operationId : findPetsByStatus parameters : - name : status in : query description : Status values that need to be considered for filter required : true style : form explode : false deprecated : true schema : type : array items : type : string enum : - available - pending - sold default : available responses : ' 200 ' : description : successful operation content : application/xml : schema : type : array items : $ref : ' #/components/schemas/Pet ' application/json : schema : type : array items : $ref : ' #/components/schemas/Pet ' ' 400 ' : description : Invalid status value security : - petstore_auth : - ' read:pets ' /pet/findByTags : get : tags : - pet summary : Finds Pets by tags description : >- Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. operationId : findPetsByTags parameters : - name : tags in : query description : Tags to filter by required : true style : form explode : false schema : type : array items : type : string responses : ' 200 ' : description : successful operation content : application/xml : schema : type : array items : $ref : ' #/components/schemas/Pet ' application/json : schema : type : array items : $ref : ' #/components/schemas/Pet ' ' 400 ' : description : Invalid tag value security : - petstore_auth : - ' read:pets ' deprecated : true ' /pet/{petId} ' : get : tags : - pet summary : Find pet by ID description : Returns a single pet operationId : getPetById parameters : - name : petId in : path description : ID of pet to return required : true schema : type : integer format : int64 responses : ' 200 ' : description : successful operation content : application/xml : schema : $ref : ' #/components/schemas/Pet ' application/json : schema : $ref : ' #/components/schemas/Pet ' ' 400 ' : description : Invalid ID supplied ' 404 ' : description : Pet not found security : - api_key : [] post : tags : - pet summary : Updates a pet in the store with form data description : '' operationId : updatePetWithForm parameters : - name : petId in : path description : ID of pet that needs to be updated required : true schema : type : integer format : int64 responses : ' 405 ' : description : Invalid input security : - petstore_auth : - ' write:pets ' - ' read:pets ' requestBody : content : application/x-www-form-urlencoded : schema : type : object properties : name : description : Updated name of the pet type : string status : description : Updated status of the pet type : string delete : tags : - pet summary : Deletes a pet description : '' operationId : deletePet parameters : - name : api_key in : header required : false schema : type : string - name : petId in : path description : Pet id to delete required : true schema : type : integer format : int64 responses : ' 400 ' : description : Invalid pet value security : - petstore_auth : - ' write:pets ' - ' read:pets ' ' /pet/{petId}/uploadImage ' : post : tags : - pet summary : uploads an image description : '' operationId : uploadFile parameters : - name : petId in : path description : ID of pet to update required : true schema : type : integer format : int64 responses : ' 200 ' : description : successful operation content : application/json : schema : $ref : ' #/components/schemas/ApiResponse ' security : - petstore_auth : - ' write:pets ' - ' read:pets ' requestBody : content : multipart/form-data : schema : type : object properties : additionalMetadata : description : Additional data to pass to server type : string file : description : file to upload type : string format : binary /store/inventory : get : tags : - store summary : Returns pet inventories by status description : Returns a map of status codes to quantities operationId : getInventory responses : ' 200 ' : description : successful operation content : application/json : schema : type : object additionalProperties : type : integer format : int32 security : - api_key : [] /store/order : post : tags : - store summary : Place an order for a pet description : '' operationId : placeOrder responses : ' 200 ' : description : successful operation content : application/xml : schema : $ref : ' #/components/schemas/Order ' application/json : schema : $ref : ' #/components/schemas/Order ' ' 400 ' : description : Invalid Order requestBody : content : application/json : schema : $ref : ' #/components/schemas/Order ' description : order placed for purchasing the pet required : true ' /store/order/{orderId} ' : get : tags : - store summary : Find purchase order by ID description : >- For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions operationId : getOrderById parameters : - name : orderId in : path description : ID of pet that needs to be fetched required : true schema : type : integer format : int64 minimum : 1 maximum : 5 responses : ' 200 ' : description : successful operation content : application/xml : schema : $ref : ' #/components/schemas/Order ' application/json : schema : $ref : ' #/components/schemas/Order ' ' 400 ' : description : Invalid ID supplied ' 404 ' : description : Order not found delete : tags : - store summary : Delete purchase order by ID description : >- For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors operationId : deleteOrder parameters : - name : orderId in : path description : ID of the order that needs to be deleted required : true schema : type : string responses : ' 400 ' : description : Invalid ID supplied ' 404 ' : description : Order not found /user : post : tags : - user summary : Create user description : This can only be done by the logged in user. operationId : createUser responses : default : description : successful operation security : - api_key : [] requestBody : content : application/json : schema : $ref : ' #/components/schemas/User ' description : Created user object required : true /user/createWithArray : post : tags : - user summary : Creates list of users with given input array description : '' operationId : createUsersWithArrayInput responses : default : description : successful operation security : - api_key : [] requestBody : $ref : ' #/components/requestBodies/UserArray ' /user/createWithList : post : tags : - user summary : Creates list of users with given input array description : '' operationId : createUsersWithListInput responses : default : description : successful operation security : - api_key : [] requestBody : $ref : ' #/components/requestBodies/UserArray ' /user/login : get : tags : - user summary : Logs user into the system description : '' operationId : loginUser parameters : - name : username in : query description : The user name for login required : true schema : type : string pattern : ' ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$ ' - name : password in : query description : The password for login in clear text required : true schema : type : string responses : ' 200 ' : description : successful operation headers : Set-Cookie : description : >- Cookie authentication key for use with the `api_key` apiKey authentication. schema : type : string example : AUTH_KEY=abcde12345; Path=/; HttpOnly X-Rate-Limit : description : calls per hour allowed by the user schema : type : integer format : int32 X-Expires-After : description : date in UTC when token expires schema : type : string format : date-time content : application/xml : schema : type : string application/json : schema : type : string ' 400 ' : description : Invalid username/password supplied /user/logout : get : tags : - user summary : Logs out current logged in user session description : '' operationId : logoutUser responses : default : description : successful operation security : - api_key : [] ' /user/{username} ' : get : tags : - user summary : Get user by user name description : '' operationId : getUserByName parameters : - name : username in : path description : The name that needs to be fetched. Use user1 for testing. required : true schema : type : string responses : ' 200 ' : description : successful operation content : application/xml : schema : $ref : ' #/components/schemas/User ' application/json : schema : $ref : ' #/components/schemas/User ' ' 400 ' : description : Invalid username supplied ' 404 ' : description : User not found put : tags : - user summary : Updated user description : This can only be done by the logged in user. operationId : updateUser parameters : - name : username in : path description : name that need to be deleted required : true schema : type : string responses : ' 400 ' : description : Invalid user supplied ' 404 ' : description : User not found security : - api_key : [] requestBody : content : application/json : schema : $ref : ' #/components/schemas/User ' description : Updated user object required : true delete : tags : - user summary : Delete user description : This can only be done by the logged in user. operationId : deleteUser parameters : - name : username in : path description : The name that needs to be deleted required : true schema : type : string responses : ' 400 ' : description : Invalid username supplied ' 404 ' : description : User not found security : - api_key : [] externalDocs : description : Find out more about Swagger url : ' http://swagger.io ' components : requestBodies : UserArray : content : application/json : schema : type : array items : $ref : ' #/components/schemas/User ' description : List of user object required : true Pet : content : application/json : schema : $ref : ' #/components/schemas/Pet ' application/xml : schema : $ref : ' #/components/schemas/Pet ' description : Pet object that needs to be added to the store required : true securitySchemes : petstore_auth : type : oauth2 flows : implicit : authorizationUrl : ' http://petstore.swagger.io/api/oauth/dialog ' scopes : ' write:pets ' : modify pets in your account ' read:pets ' : read your pets api_key : type : apiKey name : api_key in : header schemas : Order : title : Pet Order description : An order for a pets from the pet store type : object properties : id : type : integer format : int64 petId : type : integer format : int64 quantity : type : integer format : int32 shipDate : type : string format : date-time status : type : string description : Order Status enum : - placed - approved - delivered complete : type : boolean default : false xml : name : Order Category : title : Pet category description : A category for a pet type : object properties : id : type : integer format : int64 name : type : string pattern : ' ^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$ ' xml : name : Category User : title : a User description : A User who is purchasing from the pet store type : object properties : id : type : integer format : int64 username : type : string firstName : type : string lastName : type : string email : type : string password : type : string phone : type : string userStatus : type : integer format : int32 description : User Status xml : name : User Tag : title : Pet Tag description : A tag for a pet type : object properties : id : type : integer format : int64 name : type : string xml : name : Tag Pet : title : a Pet description : A pet for sale in the pet store type : object required : - name - photoUrls properties : id : type : integer format : int64 category : $ref : ' #/components/schemas/Category ' name : type : string example : doggie photoUrls : type : array xml : name : photoUrl wrapped : true items : type : string tags : type : array xml : name : tag wrapped : true items : $ref : ' #/components/schemas/Tag ' status : type : string description : pet status in the store deprecated : true enum : - available - pending - sold xml : name : Pet ApiResponse : title : An uploaded response description : Describes the result of uploading an image resource type : object properties : code : type : integer format : int32 type : type : string message : type : string orval.config.ts /** * @summary Add a new pet to the store */export const addPetBodyCategoryNameRegExp = new RegExp( '^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',);export const addPetBody = zod .object({ id: zod.number().optional(), category: zod .object({ id: zod.number().optional(), name: zod.string().regex(addPetBodyCategoryNameRegExp).optional(), }) .optional() .describe('A category for a pet'), name: zod.string(), photoUrls: zod.array(zod.string()), tags: zod .array( zod .object({ id: zod.number().optional(), name: zod.string().optional(), }) .describe('A tag for a pet'), ) .optional(), status: zod .enum(['available', 'pending', 'sold']) .optional() .describe('pet status in the store'), }) .describe('A pet for sale in the pet store');export const addPetResponseCategoryNameRegExp = new RegExp( '^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',);export const addPetResponse = zod .object({ id: zod.number().optional(), category: zod .object({ id: zod.number().optional(), name: zod.string().regex(addPetResponseCategoryNameRegExp).optional(), }) .optional() .describe('A category for a pet'), name: zod.string(), photoUrls: zod.array(zod.string()), tags: zod .array( zod .object({ id: zod.number().optional(), name: zod.string().optional(), }) .describe('A tag for a pet'), ) .optional(), status: zod .enum(['available', 'pending', 'sold']) .optional() .describe('pet status in the store'), }) .describe('A pet for sale in the pet store'); /** * @ summary Add a new pet to the store */ export const addPetBodyCategoryNameRegExp = new RegExp ( ' ^[a-zA-Z0-9]+[a-zA-Z0-9 \\ . \\ -_]*[a-zA-Z0-9]+$ ' , ) ; export const addPetBody = zod . object ( { id : zod . number () . optional () , category : zod . object ( { id : zod . number () . optional () , name : zod . string () . regex ( addPetBodyCategoryNameRegExp ) . optional () , } ) . optional () . describe ( ' A category for a pet ' ) , name : zod . string () , photoUrls : zod . array ( zod . string ()) , tags : zod . array ( zod . object ( { id : zod . number () . optional () , name : zod . string () . optional () , } ) . describe ( ' A tag for a pet ' ) , ) . optional () , status : zod . enum ([ ' available ' , ' pending ' , ' sold ' ]) . optional () . describe ( ' pet status in the store ' ) , } ) . describe ( ' A pet for sale in the pet store ' ) ; export const addPetResponseCategoryNameRegExp = new RegExp ( ' ^[a-zA-Z0-9]+[a-zA-Z0-9 \\ . \\ -_]*[a-zA-Z0-9]+$ ' , ) ; export const addPetResponse = zod . object ( { id : zod . number () . optional () , category : zod . object ( { id : zod . number () . optional () , name : zod . string () . regex ( addPetResponseCategoryNameRegExp ) . optional () , } ) . optional () . describe ( ' A category for a pet ' ) , name : zod . string () , photoUrls : zod . array ( zod . string ()) , tags : zod . array ( zod . object ( { id : zod . number () . optional () , name : zod . string () . optional () , } ) . describe ( ' A tag for a pet ' ) , ) . optional () , status : zod . enum ([ ' available ' , ' pending ' , ' sold ' ]) . optional () . describe ( ' pet status in the store ' ) , } ) . describe ( ' A pet for sale in the pet store ' ) ; zodの場合: /** * @summary Add a new pet to the store */export const addPetBodyCategoryNameRegExp = new RegExp( '^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',);export const addPetBody = zod .object({ id: zod.number().optional(), category: zod .object({ id: zod.number().optional(), name: zod.string().regex(addPetBodyCategoryNameRegExp).optional(), }) .optional() .describe('A category for a pet'), name: zod.string(), photoUrls: zod.array(zod.string()), tags: zod .array( zod .object({ id: zod.number().optional(), name: zod.string().optional(), }) .describe('A tag for a pet'), ) .optional(), status: zod .enum(['available', 'pending', 'sold']) .optional() .describe('pet status in the store'), }) .describe('A pet for sale in the pet store');export const addPetResponseCategoryNameRegExp = new RegExp( '^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',);export const addPetResponse = zod .object({ id: zod.number().optional(), category: zod .object({ id: zod.number().optional(), name: zod.string().regex(addPetResponseCategoryNameRegExp).optional(), }) .optional() .describe('A category for a pet'), name: zod.string(), photoUrls: zod.array(zod.string()), tags: zod .array( zod .object({ id: zod.number().optional(), name: zod.string().optional(), }) .describe('A tag for a pet'), ) .optional(), status: zod .enum(['available', 'pending', 'sold']) .optional() .describe('pet status in the store'), }) .describe('A pet for sale in the pet store'); /** * @ summary Add a new pet to the store */ export const addPetBodyCategoryNameRegExp = new RegExp ( ' ^[a-zA-Z0-9]+[a-zA-Z0-9 \\ . \\ -_]*[a-zA-Z0-9]+$ ' , ) ; export const addPetBody = zod . object ( { id : zod . number () . optional () , category : zod . object ( { id : zod . number () . optional () , name : zod . string () . regex ( addPetBodyCategoryNameRegExp ) . optional () , } ) . optional () . describe ( ' A category for a pet ' ) , name : zod . string () , photoUrls : zod . array ( zod . string ()) , tags : zod . array ( zod . object ( { id : zod . number () . optional () , name : zod . string () . optional () , } ) . describe ( ' A tag for a pet ' ) , ) . optional () , status : zod . enum ([ ' available ' , ' pending ' , ' sold ' ]) . optional () . describe ( ' pet status in the store ' ) , } ) . describe ( ' A pet for sale in the pet store ' ) ; export const addPetResponseCategoryNameRegExp = new RegExp ( ' ^[a-zA-Z0-9]+[a-zA-Z0-9 \\ . \\ -_]*[a-zA-Z0-9]+$ ' , ) ; export const addPetResponse = zod . object ( { id : zod . number () . optional () , category : zod . object ( { id : zod . number () . optional () , name : zod . string () . regex ( addPetResponseCategoryNameRegExp ) . optional () , } ) . optional () . describe ( ' A category for a pet ' ) , name : zod . string () , photoUrls : zod . array ( zod . string ()) , tags : zod . array ( zod . object ( { id : zod . number () . optional () , name : zod . string () . optional () , } ) . describe ( ' A tag for a pet ' ) , ) . optional () , status : zod . enum ([ ' available ' , ' pending ' , ' sold ' ]) . optional () . describe ( ' pet status in the store ' ) , } ) . describe ( ' A pet for sale in the pet store ' ) ; Fetch Clientの場合: /** * @summary Add a new pet to the store */export type addPetResponse200 = { data: Pet; status: 200;};export type addPetResponse405 = { data: null; status: 405;};export type addPetResponseComposite = addPetResponse200 | addPetResponse405;export type addPetResponse = addPetResponseComposite & { headers: Headers;};export const getAddPetUrl = () => { return `/pet`;};export const addPet = async ( petBody: PetBody, options?: RequestInit,): Promise<addPetResponse> => { const res = await fetch(getAddPetUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify(petBody), }); const body = [204, 205, 304].includes(res.status) ? null : await res.text(); const data: addPetResponse['data'] = body ? JSON.parse(body) : {}; return { data, status: res.status, headers: res.headers } as addPetResponse;}; /** * @ summary Add a new pet to the store */ export type addPetResponse200 = { data : Pet ; status : 200 ; } ; export type addPetResponse405 = { data : null ; status : 405 ; } ; export type addPetResponseComposite = addPetResponse200 | addPetResponse405 ; export type addPetResponse = addPetResponseComposite & { headers : Headers ; } ; export const getAddPetUrl = () => { return ` /pet ` ; } ; export const addPet = async ( petBody : PetBody , options ?: RequestInit , ) : Promise < addPetResponse > => { const res = await fetch ( getAddPetUrl () , { ... options , method : ' POST ' , headers : { ' Content-Type ' : ' application/json ' , ... options ?. headers }, body : JSON . stringify ( petBody ) , } ) ; const body = [ 204 , 205 , 304 ] . includes ( res . status ) ? null : await res . text () ; const data : addPetResponse [ ' data ' ] = body ? JSON . parse ( body ) : {} ; return { data , status : res . status , headers : res . headers } as addPetResponse ; } ; zodを生成させた場合、生成されるのはあくまでもzodのスキーマなので、実際にfetchする処理は自分で書く必要があります。 それに対して、Fetch Clientを生成させた場合はfetchする処理まで生成してくれるので、ボイラープレートを減らすことができます。 型安全性についての考察 Fetch Clientで生成させたコードを見てもらうとわかりますが、 as ​を使用して型のアサーションを行っています。 そうです。Fetch Clientで生成したコードは厳密には型安全ではありません。 しかし、ここで考えてみたいことがあります。 型安全ではないことによって、問題が発生するのはどのようなケースでしょうか? それは、OpenAPIのスキーマと実際にバックエンドから返ってくるスキーマが異なるケースです。 そして、それは果たしてフロントエンドの、しかもランタイム上で検知すべきことなのでしょうか? それを踏まえると、OpenAPIとバックエンドの齟齬がフロントエンドのランタイム上で判明するのは理想的なタイミングとは言えないかもしれません。 したがって、この問題はバックエンドの責務と考え、バックエンド側のテストで対処する方が適切だと考えられます。 バックエンドはフロントエンドを信頼してはいけませんが、フロントエンドはバックエンドを信頼するという考え方もできます。 ランタイム検証についての考え方 実際問題、ランタイムエラーが発生したらどうするのか? という懸念もあるかと思います。 その場合、素直にエラーをthrowするという選択肢があります。 OpenAPIとバックエンドに齟齬があるという致命的な問題が発生している場合、フロントエンド側でできることは限られています。 また、Next.jsなら error.tsx ​を配置しておくことで、エラー画面を表示することができます。 catchした後どうするのか? を念頭においてエラーハンドリングを設計しましょう。 クエリパラメータやリクエストボディのバリデーションについて フロントエンドからバックエンドのAPIにリクエストを送る前に、クエリパラメータやリクエストボディのバリデーションを行いたいというユースケースがあると思います。 ここでは、一つの考え方として、その段階でのバリデーションの必要性について検討してみます。 実際は、その直後にバックエンドがバリデーションを行います。 したがって、フロントエンド側での重複したバリデーションは省略できる場合が多いです。 また、フロントエンドで行うバリデーションはUXのためであるという視点を持つことが重要です。 つまり、セキュリティや不正な値を防ぐためのバリデーションはバックエンドで行い、フロントエンドとバックエンドで二度同じバリデーションを行う必要性は低いと考えることができます。 フォームのバリデーションについて UXのためのフォームのバリデーションで、zodのスキーマが欲しくなるケースがあるかもしれません。 その場合は、フォームのスキーマをAPIのスキーマとは別に定義することをおすすめします。 なぜなら、フォームのスキーマはAPIのスキーマと必ず対応しているとは限らないからです。 例えば、郵便番号を入力するとき、API側では半角数字の文字列を期待しますが、フォーム側ではUXのために全角数字の文字列も受け取れるようにしたい場合があります。 また、数値入力でも、API側では数値型を期待しますが、フォーム側では一時的に文字列として扱い、カンマ区切りの表示に対応したい場合があります。 それ以外でも、API側は完成形のオブジェクトを期待しますが、フォームでは段階的に異なる形状のデータを扱うような場合があります。 このような場合、API側のスキーマは流用できません。フロントエンド側で独自に定義する必要があります。 こうなると、APIのスキーマを流用するものと、しないものが混在することになります。 そして、現状流用できているスキーマも、後から変更される可能性があります。 つまり、APIのスキーマとフォームのスキーマは本質的に異なるものであると考えることができます。 したがって、フォームのスキーマは仮にAPIのスキーマと一致していても、別で定義することを検討してみてはいかがでしょうか。 zodを生成した方が適しているケース ここまで、zodを生成しない選択肢について考察してきましたが、zodを生成した方が適しているケースもあります。 外部サービスのAPIを使用する場合 自分たちが管理していない外部APIを使用する場合は、zodによるランタイム検証が有効な選択肢となります。 なぜなら、OpenAPIスキーマとバックエンドの実装に齟齬があっても、バックエンド側で修正することができないためです。 また、外部APIは予告なく仕様が変更されることもあります。 このような場合、フロントエンド側で防御的にランタイム検証し、不正なデータを早期に検出することで、予期しないエラーを防ぐことができます。 バックエンドでTypeScriptを使用している場合 バックエンドでTypeScriptを使用している場合、Orvalによるzod生成を活用することで、バックエンド側でバリデーションを行うことができます。 ただし、これはフロントエンドの話ではなく、バックエンドの話です。 (ここまで「フロントエンドにおける」と強調してきたのはこのためです) バックエンドでは、フロントエンドから送られてくるリクエストボディやクエリパラメータを信頼すべきではありません。 したがって、バックエンド側でランタイムバリデーションを行う必要があります。 このとき、OpenAPIからzodスキーマを生成することで、バリデーションのコードを自動生成でき、保守性を向上させることができます。 補足: APIの型の使い方について zodの話とは少し逸れますが、Orvalを使う際に注意したい点として、APIの型をコンポーネントからAPIクライアントまで使い回すというアンチパターンがあります。 前提として、APIのレスポンスはJSONであり、それはシリアライズされたDTOに過ぎません。 JSONはドメイン知識を持たず、ドメインモデルとして機能しないため、Orvalが生成した型をフロントエンド側であたかもドメインモデルであるかのように直接依存すると、様々な箇所で不整合が発生する可能性があります。 そのため、フロントエンドではフロントエンド用のドメインモデルを定義することをおすすめします。 そして、DTOをドメインモデルに変換するMapperを実装することも、一つの有効なアプローチです。 この辺りの話は以下の記事が参考になるため、よろしければ読んでみてください。 フロントエンドエンジニアが「自分はJSON色付け係」と自虐する理由を考察した フロントエンドにおける「型」の責任分解に対する1つのアプローチ まとめ 本記事では、フロントエンドでOrvalを使用する際に、zodではなくFetch Clientを生成するという選択肢について考察しました。 重要なポイントは以下の4つです。 Fetch Clientを生成することで、zodよりも簡潔に型がついたAPI通信の処理を書ける ランタイム上でのOpenAPIとバックエンドの齟齬検出は、バックエンドのテストで対処する考え方もある フォームのスキーマとAPIのスキーマは本質的に異なる目的を持つ APIのレスポンスはシリアライズされたDTOであり、ドメインモデルとは区別して扱うことが望ましい これらは一つの考え方であり、プロジェクトの状況やチームの方針によって最適な選択は異なります。 それぞれのアプローチのトレードオフを理解した上で、プロジェクトに適した方法を選択することをおすすめします。
本記事は【 Advent Calendar 2025 】の11日目の記事です。 はじめに こんにちは!マイナビのN.Yです。 2025年12/1(月)から12/5(金)に開催された「re:Invent2025」に参加しました。 自分にとって初海外出張、初アメリカで不安もありましたが、 結果的に多くの学びがあり、あっという間に過ぎた1週間をご紹介したいと思います。 筆者について 新卒入社3年目 社内のクラウド支援部署所属 英語とは大学受験でお別れ、英語圏への渡航経験ゼロ re:Inventとは AWSが主催する世界最大規模のクラウドカンファレンスで、毎年12月に米国ネバダ州ラスベガスで開催されます。 今年は14回目の開催となり、世界中から約60,000人、日本からも1900人近くが参加したらしいです。 基調講演(Keynote)をはじめ、3000以上のセッション、パートナー企業の展示会(EXPO)、たくさんのイベント、ノベルティグッズなど、学びからエンタメまで幅広く展開されます。 会場 「ラスベガス・ストリップ」と呼ばれるカジノ街にある複数のホテルが会場になります。 最も離れているホテルは歩いて1時間かかるほどの距離があります。 開催期間中、参加者は会場間をつなぐシャトルバスやモノレールが無料で利用できるため、それらを駆使して移動します。 ホテル自体も巨大なので、スケジュールを組む際には移動時間も考慮する必要があります。 今回、マイナビメンバーが宿泊した Harrah's は、メイン会場のVenetianへも歩いて行ける距離にあり、アクセス良好でとても便利でした。 一週間の過ごし方 ここからは開催期間中の様子を、写真を交えながらご紹介します。 前段で述べたように、期間中さまざまなイベントやセッションが開催されています。 あくまでも初めて参加した人間が体験した一例として眺めていただければと思います。 ざっくりスケジュール 11/30 羽田空港出発 -- 日付変更線 -- 11/30 サンフランシスコ経由でラスベガス到着 12/1 ~ 12/4 re:Invent参加 12/5 ラスベガス出発、サンフランシスコ空港経由 -- 日付変更線 -- 12/6 羽田空港到着 2025/11/30(0日目) 15:00 羽田空港に集合 指定されていた集合時間は15:00でしたが、心配性のマイナビメンバーは13:00くらいには全員集合していました。 今回はJTBツアーで参加したため、案内に従って搭乗券発行、荷物預け、保安検査へと大きなトラブルなく進むことができました。 18:00(JTC) 羽田空港→サンフランシスコへ出発 約9時間の長距離フライトです。機内食が2回提供されました。 有料で機内Wi-Fiが利用でき、過去のレポート記事を読んだり、セッションスケジュールを組み直したりした後は、寝て過ごしました。 ーー日付変更線ーー 19:00(PST) サンフランシスコ→ラスベガス到着 トランジットのサンフランシスコで約6時間待機の後、 1時間ほど飛行機の出発が遅れ、現地時間の19時頃にラスベガス(ハーリーヤード)空港に到着しました。 re:Invent参加者は、はじめに入場バッジを取得する必要があり、空港や会場で手続きができるのですが、この日は間に合わず翌日に取得することにしました。 ホテルに着いた後は、荷物を置いて早速ラスベガスの街を軽く散策しました。 翌日に備えて早めにホテルに戻って就寝しました。 2025/12/0(1日目) 8:00 ホテルベネチアンへ移動 初めてのラスベガスの朝! 先輩と、すごい、でかい、すごい、とか言いながら本会場の Venetian へ移動しました。 入場バッジを取得し、まずは朝食。 果てしなく広い会場に円卓が広がっており、ビュッフェ形式で朝食が用意されていました。 予約していたセッションまで時間があったため、毎年恒例となっているDatadog社提供の滑り台を滑ったり、巨大黒板に社名を刻んだりしました。 SWAGエリアに行くと、re:Invent参加者特典のパーカーとボトルをゲットできました。 会場の至る所に給水できるところがあるので、もらったボトルは持ち歩いておくと便利です。 パーカーは裏起毛で、ラスベガスの夜や なぜか冷房が効いている会場では非常に助けられました。 10:00 Mandalay Bayへ移動 Venetianから、最南の会場であるMandalay Bayまでシャトルバスに乗って20分ほどで到着しました。 午後は、参加者が4名1チームに分かれて他チームと対戦する形式のセッションである「Game Day」に参加したり、いくつかの実践型セッションを受講しました。 18:00 セッション終了 全てのセッションを終えて、開催前に日本での re:Invent参加者交流会で知り合った方と合流しました。 開催期間中は日本人が集まりがちと噂のお店「noodle asia」に行きました。 私たち以外に日本人はおらず、時間帯によるのかなと思いつつ、 3000円くらいするワンタンメンを、味わいながらいただきました。 2025/12/2(2日目) 7:00 KeyNote この日は re:Invent メインコンテンツの1つである AWS CEO Matt Garman氏の Keynote が午前中にあるため、早めにホテルを出てVenetian会場へ向かいます。 朝食を食べてから会場へ向かったところ、すでに本会場は満員だったため中継会場へ案内されました。 本会場に入るためには1時間前くらいから並んでおく必要がありそうです、、 CEOのKeynoteでは、新しい機能やサービスが次々に発表されました。 サービスアップデート紹介タイムアタックが始まったときは、聴衆をワクワクさせる演出に感動しました。 興味のある方はAWS の公式Youtubeに公開されているので見てみてください。 会場へ向かう途中、バケットくんに遭遇!! 気さくな方で、一緒に写真を撮ってくれました。 当日に Keynote でGAが発表された、S3 Vectors のジャケット着用バージョンでアツいです。 バケットくんについてもっと知りたい方はこちらも参照: 【AWS re:Invent2024】S3バケットくん徹底解剖 | マイナビエンジニアブログ Keynoteが終わった後は Caesars Forum へ向かい、AWSについて楽しみながら学べるカードゲーム「BuilderCards」をゲットしました。 社内でメンバーを募って遊ぼうと思います。 11:00~ セッション 午後のセッションのため、Mandalay Bayへ移動。 「DDos攻撃からAWS WAFを活用してサービスを守り品質を保つ」というシナリオのGameDayにマイナビエンジニア2名で参加しました。 セッション名:AWS GameDay - Winning the DDos Game (SEC403-R) GameDayは、参加者が4人1チームに分かれて競うセッションです。 結果は17チーム中4位でした。(チームメイトの外国人がつよつよだった) 高めの言語の壁はありましたが、WAFコンソール画面での操作や、攻撃的なアクセスを特定して制御する方法は勉強になりました。 終了後、同じチームだったイスラエル人にLinkedInのアカウントを持ってるか聞かれました。 海外の方との交流は名刺交換ではなく、LinkedInのアカウントを作成しておくと良さそうです。 19:00 Japan Night 夜は今回ツアーに参加させていただいた JTBさん主催の日本人向け交流イベントに参加しました。 普段、他社のエンジニアと交流する機会があまりないのですが、交流会ならではの情報交換ができて有意義な時間となりました。 ネットワーキングも re:Invent の醍醐味であることを痛感しました。 2025/12/3(3日目) 1日を通してセッションに参加しました。 参加したセッション Accelerating Incident Resolution: AI-Driven Root Cause Analysis (COP320-R2) AIによる自動インシデント調査機能である CloudWatch Investigation を使用してアラート発生時のトラブルシューティング体験 Building Serverless applications with Terraform workshop (CNS312-R) Terraformを使ったサーバレスアーキテクチャの構築体験ワークショップ Amazon ECS observability patterns and design decisions (CNS351-R1) ECSのオブザーバビリティやベストプラクティスな運用方法についてスピーカーと参加者がディスカッションするChalk talk。翻訳機がないと、ちょっと何言ってるか分かんない状態でしたが、アメリカならではの熱いディスカッションを味わえたのは良い経験でした。 Build Streaming Analytics Dashboards in Minutes with Kiro CLI (IND312-R1) Kiro CLIを使用して自然言語でのCDKアプリケーションの構築を行うハンズオン。デプロイ待ち時間が長く、スピーカーと参加者が生成AIのアンチテーゼについて議論しているのを翻訳機で聞いていました。 この日は他に特筆することがないので、 午後になると会場に現れるおやつコーナーの写真を載せます。 おかげさまで日中帯は食事に困りませんでした。 2025/12/4(4日目) 8:00 朝食、EXPO 我々が参加するのはこの日が最後でした。 いつも通り朝食を済ませた後は、時間に余裕があったのでEXPOブースを巡りました。 自分は参加できなかったのですが、ブースを周りながらスタンプを集めることで豪華ノベルティがもらえる催しなどもあったみたいです。 AWSのデータセンターで管理されている物理サーバーが展示されていました。 業務で物理サーバーを見る機会がないので新鮮でした。 12:00~ セッション 午後はハンズオン形式のセッションであるWorkShopと、AWS Japanが主催する日本人向けのセッション「Japan Wrap-up Session」を聴講しました。 日本語でre:Inventを振り返ることができます。 16:00 認定者ラウンジ Venetian の2階にあるAWS認定資格保持者のみが入場できる認定者ラウンジに寄ってみました。 事前に認定資格アカウントの登録をすることで、最初からバッジに印(チェックマーク)がつきます。 自分は登録を忘れていたので、受付で説明して Credly の認定証画面を見せると快く印シールを貼ってくれました。 中に入ると軽食や、いくつかの椅子とテーブルが用意されていて、多くの人がくつろぎながらKeynoteの中継を観ていました。 20:00 re:Play re:Inventを締めくくる、大規模屋外パーティです。 PM19:00からAM0:00まで開かれていて、東京ドームくらいの広さの会場に飲食ブースや体験型イベントブース、ライブ会場が併設されていました。 2025/12/5(帰国) 現地時間の AM3:30 頃にホテルのロビーに集合し、ラスベガス空港へ。 行きと同じくサンフランシスコ経由で帰ります。 帰りの飛行機は11時間くらいのフライトでした。 1週間の疲労と緊張がほどけ、着席した瞬間からの記憶がありません。 日本時間の12月6日18:00頃、無事に羽田空港に到着しました。 さようならラスベガス、ただいま日本。 最後に 今回参加してみて、会場、イベント、AWSが創造する世界のスケールの大きさに圧倒されつつ、世界中から参加しているエンジニアやAWSエキスパートとの交流を通して、普段の業務では得られない刺激を受けました。 エンジニアはもちろん、非エンジニアの方や勉強中の方など、あらゆる立場の方にとって参加する価値があると思います。 とは言え、費用や渡航準備、体力などを考えると気軽に参加できないのも事実です。 参加する機会を与えてくれた会社、そして一緒に参加したメンバーにはとても感謝しています! 最後までお読みいただき、ありがとうございました! イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の10日目の記事です。 はじめに ITD2-2-1のH・Tです。マイナビで内製開発しています。 開発現場で作業効率が落ちるタスクがあったので、自分でライブラリを作ったのでその紹介をします。 今後の内製開発でも大いに役立つかなと思ってます。 なにをつくったか Tatsumaki - Rails to TypeSpec Generator npmにすでに公開しています。 https://www.npmjs.com/package/@tyranno269/tatsumaki ライブラリが提供する価値 RailsのDBスキーマからTypeSpecモデルを自動生成するnpmライブラリでスキーマ駆動開発を簡易化できます。 開発モチベーション マイナビの開発現場では、Rails API + Next.js + zodによる型安全な開発が多いです、ただ以下の課題がありました。 手動変換の煩雑さ: Rails schema.rb → TypeSpec → OpenAPI → zodの変換チェーンが手動 型同期の遅延: Rails側の変更をフロントエンドに反映するのに時間がかかる 型不整合のリスク: 手動変換によるランタイムエラーの発生 OpenAPIからZod生成では Orval というライブラリを使用しています。 これによってOpenAPIからシームレスに実装を進められます。一方でバックエンドのRailsからTypeSpecに書き出すのは少々手間をかけていました。ここが自分は煩雑だなと感じてました。これを解決しようと思ったのがライブラリ開発のモチベーションです。 Tatsumakiによる解決 # docsディレクトリで実行 npx @tyranno269/tatsumaki # → rails.tsp自動生成 → TypeSpec → OpenAPI → orval → zod型定義 機能 Rails schema.rbから自動でTypeSpec生成 Rails enum完全対応 - モデルファイルからenum定義を自動抽出・生成 monorepo対応で柔軟なプロジェクト構造をサポート 型安全性をRailsからフロントエンドまで一貫して確保 Rails互換の単数形化 ( company_branches ​ → CompanyBranch ​) 参考: activesupport/lib/active_support/inflections.rb 想定プロジェクト構造 project/ ├── backend/     # Rails API │   ├── app/models/  # Rails enum定義 │   └── db/ │       └── schema.rb ├── docs/        # TypeSpec (実行場所) │   └── rails.tsp (生成される) └── frontend/    # Next.js + zod Rails Enum RailsにはModel層にEnum値を定義します。書き方が多様にあります。Schema.rbではintegerですが、APIレスポンスとして返す値はStringのケースが多くあるので対応が必要でした。ただしEnumもi18nで翻訳ファイルを定義するとバックエンドから日本語化して返せますが、フロントエンドメンバーと相談した結果、翻訳はFE側で対応すると良いとの結論になり、純粋にmodelファイルの定義をTypeSpecに出力します。 対応するenum形式 class Company < ApplicationRecord # ハッシュ形式 enum :company_status, { disabled: 0, enabled: 1, suspended: 9 } # 配列形式 enum :status, [ :active, :archived ] # %i記法 enum :priority, %i(low medium high) # キーワード引数 enum priority: { low: 0, medium: 1, high: 2 } end class Book < ApplicationRecord   enum :status, [ :draft, :published, :archived ] end 生成されるTypeSpec namespace CompanyEnums {   enum CompanyStatus {     disabled,     enabled,     suspended,   }   enum Status {     active,     archived,   } } namespace BookEnums {   enum Status {     draft,     published,     archived,   } } model Company {   id: int64;   company_status: CompanyEnums.CompanyStatus; // default: "enabled"   status: CompanyEnums.Status; // default: "active"   created_at: utcDateTime;   updated_at: utcDateTime; } model Book {   id: int64;   name: string;   status: BookEnums.Status; // No naming conflict with CompanyEnums.Status   created_at: utcDateTime;   updated_at: utcDateTime; } enum機能の特徴 名前空間による衝突回避 - CompanyEnums.Status ​ vs BookEnums.Status ​ schema.rbとの連携 - テーブル定義があるモデルのみ処理 型置換 - int32 ​フィールドを適切な enum​ 型に変換 全Rails enum構文対応 - ハッシュ、配列、%i記法、キーワード引数 その他にも、 a_matsuda さんの Stateful_enum といったようにenum定義からブロックでイベントを記述するといったGemもあります。TypeSpecの出力ではブロック部分は回避する工夫もしました。 開発フロー統合 Tatsumakiの設計により、Rails schema.rb + enum → TypeSpec → OpenAPI → zod の完全自動化チェーンを実現し、型安全なフルスタック開発を支援可能になりました。 インストール・使用方法 # 実行(最新版が自動取得される) npx @tyranno269/tatsumaki # 既存ファイル上書き npx @tyranno269/tatsumaki --force # カスタム出力ファイル npx @tyranno269/tatsumaki --out models.tsp --force # 既存ファイルに追記 npx @tyranno269/tatsumaki --append マイナビでの今後の活用方法 TypeSpecディレクトリ構成次第にはなりますが様々なパターンの開発で応用できうるかとおもいます。 パターン1 project/ ├── backend/     # Rails API │   ├── app/models/  # Rails enum定義 │   └── db/ │       └── schema.rb ├── docs/        # TypeSpec (実行場所) │   └── rails.tsp (生成される) └── frontend/    # Next.js + zod 生成されるrails.tspにroutesになるオペレーション情報を記述していくパターン パターン2 project/ ├── backend/     # Rails API │   ├── app/models/  # Rails enum定義 │   └── db/ │       └── schema.rb ├── docs/        # TypeSpec (実行場所) │   └── rails.tsp (生成される) │   └── routes │       └── admin │           └── admin.tsp (ex:管理サイトの管理者一覧・詳細など) │       └── user │           └── notice.tsp (ex:ユーザーサイトのお知らせ一覧・詳細など) └── frontend/    # Next.js + zod 生成されるrails.tspにroutesで定義したオペレーションをimportして組み込んでいくパターン パターン3 project/ ├── backend/     # Rails API │   ├── app/models/  # Rails enum定義 │   └── db/ │       └── schema.rb ├── docs/        # TypeSpec (実行場所) │   └── rails.tsp (生成される) │   └── main.tsp (出力の親となるファイル) │   └── models │       └── admin.tsp (rail.tspからnamespaceの該当部分をコピペで定義) │       └── notice.tsp (rail.tspからnamespaceの該当部分をコピペで定義) │   └── routes │       └── admin │           └── admin.tsp (ex:管理サイトの管理者一覧・詳細など) │       └── user │           └── notice.tsp (ex:ユーザーサイトのお知らせ一覧・詳細など) └── frontend/    # Next.js + zod 生成されるrails.tspからコピペで抽出し利用していくパターン 最後に 実際のRailsのプロジェクトで使用される主要機能をカバーできたかなと思っています。よかったら利用してみてください。今回は自分の作業時間を減らしたいモチベーションで生み出したライブラリなので実際のユースケースで対応できていないこともあるかなと思います。要望があれば Github Issue に上げて頂けますと嬉しいです!! イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 】の9日目の記事です。 AI戦略室のM・Wです。この記事を書いているのは12月1日です。 11月30日にジャパンカップで カランダガン の単勝と三連単を取りました。現地で観戦をしていたのですが、大変興奮したレースでした。ゴール間際の2頭の叩き合いに混ざる空馬。 ぜひYoutubeで動画を見てください。以上、12月11日の記事でした! 沼の淵 突然ですが、forkやspawnの違いについて知っていますでしょうか。 私は知らなかったです。いまも正直わかりません。 その結果、沼にハマってしまいました。抜け出せないまま今日を過ごしています。 これは自分用のメモ+同じようなことを実装する人がいた際の一助になればいいなと思って執筆しています。 まずとあるAgentを考えます。このAgentは「環境」を知覚し「行動」を決定するAgentです。 Agentは「行動」を行うと「環境」から「報酬」を手にすることができ、「環境」は次の「状態」に遷移します。 図にするとこんな感じです。これは基本的な強化学習を説明する際に用いられる簡略図です。 今回、ボン◯ーマンのようなPvPをするゲームについて、強化学習を使ってエージェントを作る機会がありました。つまり上の画像に照らし合わせると下記の表となります。 用語 対応 環境 ゲーム自体 エージェント 操作キャラクター 状態 その時時の盤面 行動 移動+ボム配置+キック 報酬 勝利/敗北など レギュレーションとして自陣営は2つのBotを用意する2vs2の対決と提示されたため、上記のエージェントが2つ必要なマルチエージェント学習を行う必要がありました。 (ちなみに特に実装は指定されなかったのでロジックで記述するでもOKです。AI部署なので強化学習で挑みました) 沼にダイブ ここでいよいよ掲題に出てきたマルチプロセス学習の話ができてます。 最新の強化学習アルゴリズムに疎かったので、一旦Actor-CriticベースのA3Cアルゴリズムを選択しました。実装したモデル構造は下記となります。 盤面のエンコードやモデル設計については以下のモデルを参考としています。Cursor先生ありがとう! Multi-Agent Training for Pommerman: Curriculum Learning and Population-based Self-Play Approach: https://arxiv.org/abs/2407.00662 マルチプロセス学習の全体感としては下記の図です。 ゲーム盤面を提供するホスト側とエージェント間はWebSocketによる疎通を行う必要があるため、各BotごとにWebSocketサーバを子プロセスとして作成します。 つまり子プロセス上では、ゲーム開始から終了まで実行されますが、そこで収集したデータについては何かしらの方法を使って親プロセス上に移動させないと、子プロセスがterminateされた際に破棄されてしまう問題があります。 そのため 共有メモリ を使って誤差勾配を親プロセス側と共有を行い、最適化器を使って共有モデルのパラメータを更新、次の学習ループで子プロセスへ配分を繰り返し学習を行っていきます。 沼の底 学習モデルを共有メモリに配置するためには model.share_memory() を実施しますが、今回の実装のケースにおいてたびたび子プロセスの model.backward() が失敗する事象がありました。 いくつか問題はありましたが、 ・下記のログが出力され、backward処理が失敗するケース objc[8623]: +[MPSGraphObject initialize] may have been in progress in another thread when fork() was called.objc[8623]: +[MPSGraphObject initialize] may have been in progress in another thread when fork() was called. We cannot safely call it or ignore it in the fork() child process. Crashing instead. Set a breakpoint on objc_initializeAfterForkError to debug. ・backward()が呼び出された際に子プロセスがクラッシュして音もなく消える 使用してる端末がMacOSであるため、前者のケースでは $export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES を実行することでエラー文が出力されなくなります。しかしながらこれはワークアラウンドとなります。 MacOSでは fork() が実行された際に、親プロセスで既に初期化されつつあったObjective-Cのデータ構造が子プロセスに引き継がれると、子プロセス側でその初期化が安全に完了できない状況が発生することがあります。これにより、子プロセスがクラッシュ(通常はEXC_BAD_ACCESS)したり、デッドロックしたりします。 Pythonのmultiprocessingは、デフォルトでは「fork」方式を使って新しいプロセスを生成するため、この問題に直面しやすいです。そのため2点目の音もなく子プロセスが消えた原因も含めて、backward()処理がうまく動作しないのは fork() による子プロセスの生成に問題があると結論付けました。 ちなみにPyTorchからマルチプロセス学習する際のベストプラクティスでも「Use an alternative process start methods, such as spawn or forkserver, which ensures a clean initialization of each process.」と記載があります。 https://docs.pytorch.org/docs/stable/notes/multiprocessing.html#poison-fork-in-multiprocessing spawn は fork とは違い、Pythonインタープリタごとプロセスを生成します。起動が遅いというデメリットはありますが、必要な情報のみを親プロセスから引き継ぐ点や上記の初期化に起因するようなエラーを回避できるメリットがあります。 実験 簡単なNNを構築してspawnを使用したbackward()がうまくいくかどうかを実験してみます。 import torchimport torch.nn as nnimport torch.multiprocessing as mpimport torch.nn.functional as Fimport torch.optim as optimclass ExampleNN(nn.Module): def __init__(self): super(ExampleNN, self).__init__() self.fc = nn.Linear(10, 1) def forward(self, x): out = self.fc(x) return F.sigmoid(out)def train(xs, target): model = ExampleNN() optimizer = optim.SGD(model.parameters()) out = model(xs) loss = F.binary_cross_entropy(out, target) optimizer.zero_grad() loss.backward() optimizer.step() print('trainメソッドが終了したよ') 上記のtrainメソッドをforkで動かしてみます。 if __name__ == '__main__': x = torch.rand(1, 5, 10) t = torch.Tensor([0, 1, 1, 0, 1]).reshape(1, 5, 1) train(x, t) print('-'*54) ctx = mp.get_context('fork') processes = [] for i in range(5): p = ctx.Process(target=train, args=(x, t)) p.start() processes.append(p) for p in processes: p.join()>>> trainメソッドが終了したよ------------------------------------------------------RuntimeError: Unable to handle autograd's threading in combination with fork-based multiprocessing. See https://github.com/pytorch/pytorch/wiki/Autograd-and-Fork 無事エラーが出ました。 ちなみに途中の train(x, t) をコメントアウトすると正常に動作します。これは上記のGithubにも記載の通り、backward()で呼び出されているAutogradがすでに使用されている状態でfork()されたことに起因するエラーとなります。 Autograd engine relies on threads pool, which makes it vulnerable to fork. We detect such situations and warn users to use spawn method of multiprocessing. 指示通りspawnでやってみましょう。 if __name__ == '__main__': x = torch.rand(1, 5, 10) t = torch.Tensor([0, 1, 1, 0, 1]).reshape(1, 5, 1) train(x, t) print('-'*54) ctx = mp.get_context('spawn') processes = [] for i in range(5): p = ctx.Process(target=train, args=(x, t)) p.start() processes.append(p) for p in processes: p.join() >>> trainメソッドが終了したよ------------------------------------------------------trainメソッドが終了したよtrainメソッドが終了したよtrainメソッドが終了したよtrainメソッドが終了したよtrainメソッドが終了したよ 無事エラーなく実行することができました。 この結果から spawn を明示的に指定することでbackward()処理のエラーを回避できることがわかります。 実際の実装は下記のような形となっています。 ctx = mp.get_context('spawn') # mp.set_start_method('spawn', force=True) # エピソードループ for episode in tqdm(range(num_episodes)): clients: List[BombermanClient] = self.create_clients() process_pool = [] for c in clients: p = ctx.Process(target=c.run) p.start() process_pool.append(p) # エピソードの学習 loop = asyncio.get_event_loop() loop.run_until_complete(self.train_episode(max_steps)) print("ロールアウト終了") # 子プロセスの終了を待つ for p in process_pool: if p.is_alive(): p.terminate() p.join(timeout=3) # 最大3秒待つ if p.is_alive(): # それでも終了しない場合は強制終了 print(f"Warning: Process {p.pid} did not terminate gracefully, forcing...") p.kill() p.join() そもそもMacOSだとデフォルトで spawn だそうです。つまりわざわざ明示的に fork を使ったところから全ては始まったのです。Cursorくん、君は頑なにforkを推していたよね・・・。 これで 戦 に臨めます! 終わりに 元々のスタート地点として、マイナビグループの中の一つである Mynavi Techtus Vietnam さんから挑戦状を叩きつけられたところからこの沼は始まりました。 Techtusさん「ボン◯ーマンAIを作成してPvPをやろう!それでどこのチームが強いか勝負だ!」 結果は・・・ 全敗 でした。 そもそもこの問題を解決したのは戦いが終わった3週間後ぐらいなんです! つまり、戦には裸同然で挑んだことになる・・・?すでに次の戦は始まっているという決意を胸に日々沼の中で生きたいと思います! イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の8日目の記事です。 ITD 1-2 開発課のO・Aです。最近は流行りの3周遅れくらいで麻辣湯にハマっているのと、データベース周りに興味があります。 今月11月4日に、 国土交通省が無償でMCP serverを公開 したことが話題(たぶん)になっていて気になったので、触ってみました。 MCPとは 「MCP」(Model Context Protocol)は、大規模言語モデル(LLM)などを使ったAIアプリケーションと、外部のツールやデータとの連携を標準化するプロトコル。2024年にAnthropicが発表したもの。 国土交通省とは 日本の国土の総合的な利用・開発・保全、社会資本整備、交通政策、気象業務、海上の安全確保などを担う中央省庁。 現在の国土交通大臣は 金子 恭之 氏 国土交通データプラットフォームとは https://www.mlit-data.jp 2020年4月に1.0版として国土交通省によって一般公開された。 交通、河川、港湾、航空、都市開発など、国土交通省及び民間の様々なデータを、一元的に検索・可視化・ダウンロードを可能にするデータプラットフォーム。 略して 国交DPF Webブラウザを通した検索・取得だけでなく、様々 API機能 の開発・提供も行っている。 APIリファレンスや開発環境を構築せず簡易にAPIを利用したい時のための GraphiQL (GraphQL作成支援UIツール)環境の提供も行っている。 主な活用 インフラ維持管理と計画策定 都市環境の改善 物流効率化 観光復興の推進 防災・減災 etc… 参考 https://www.mlit.go.jp/tec/tec_tk_000066.html https://www.mlit.go.jp/report/press/content/001362442.pdf 国土交通データプラットフォーム MCPとは 国土交通省が保有するデータと民間等のデータを連携し、一元的に検索・表示・ダウンロードを可能にする国土交通データプラットフォームが提供する利用者向けAPIと接続するMCP(Model Context Protocol)サーバー (リポジトリの概要欄より) これまではAPI利用のために、API仕様を調べる -> GraphQLでクエリを書く -> APIに投げる必要がありましたが、MCPサーバーの活用で自然言語の対話のみでデータが取得可能になりました。 準備 リポジトリ のREADMEに大体わかりやすく記載されています。 何が必要? Claude Desktop Python 3.10以上 国土交通省DPFのAPIキー( こちら から取得。アカウント作成必要あり。) 1.リポジトリをcloneして仮想環境を有効化 git clone https://github.com/MLIT-DATA-PLATFORM/mlit-dpf-mcp.gitcd mlit-dpf-mcppython -m venv .venv.venv\Scripts\activate # Windowssource .venv/bin/activate # macOS/Linux 2.依存ライブラリインストール pip install -e .pip install aiohttp pydantic tenacity python-json-logger mcp python-dotenv 3. .env.sample を .env にコピー + 環境変数設定 MLIT_API_KEY=`取得したAPIキー`MLIT_BASE_URL=https://www.mlit-data.jp/api/v1/ 4.mcpサーバー起動 python -m src.server 5.Claude Desktopの設定を開いて claude_desktop_config.json に MCP構成を追加 { "mcpServers": { "mlit-dpf-mcp": { "command": "....../mlit-dpf-mcp/.venv/Scripts/python.exe", "args": [ "....../mlit-dpf-mcp/src/server.py" ], "env": { "MLIT_API_KEY": "取得したAPIキー", "MLIT_BASE_URL": "https://www.mlit-data.jp/api/v1/", "PYTHONUNBUFFERED": "1", "LOG_LEVEL": "WARNING" } } }} ※commandとargsの .... の部分は実際のパスに変更する 6.保存してClaude Desktop再起動 試してみる 「新宿駅近くの避難所を5件教えて」 Claude Desktop上では以下のようなリクエストとレスポンスがMCPサーバーから返っていることがわかります リクエスト { `size`: 5, // 取得件数上限 `term`: `避難所`, // 検索キーワード `location_lat`: 35.6896, // 中心地点の緯度(新宿駅周辺) `location_lon`: 139.7006, // 中心地点の経度(新宿駅周辺) `location_distance`: 1000 // 検索半径(1km)} レスポンス { "search": { "totalNumber": 6, "searchResults": [ { "id": "fb4a919a-09f6-4a3b-b7ca-9fb294f16dc8", "title": "新宿中央公園・高層ビル群一帯", "lat": 35.689666, "lon": 139.689875, "year": "2020", "dataset_id": "nlni_ksj-p20", "catalog_id": "nlni_ksj" }, { "id": "fe039eed-bf70-4638-89d7-283da22739c2", "title": "西新宿中学校", "lat": 35.695942, "lon": 139.694819, "year": "2020", "dataset_id": "nlni_ksj-p20", "catalog_id": "nlni_ksj" }, { "id": "161a9969-7d98-4ea9-b239-2196c8e4ac0e", "title": "鳩森小学校", "lat": 35.682625, "lon": 139.705994, "year": "2020", "dataset_id": "nlni_ksj-p20", "catalog_id": "nlni_ksj" }, { "id": "feeeaa72-79be-4b02-89ef-ad82e7a64986", "title": "都立新宿高等学校", "lat": 35.688478, "lon": 139.705121, "year": "2020", "dataset_id": "nlni_ksj-p20", "catalog_id": "nlni_ksj" }, { "id": "b69d0381-a860-45c7-aad0-87d3f9e076fd", "title": "新宿御苑", "lat": 35.685518, "lon": 139.709165, "year": "2020", "dataset_id": "nlni_ksj-p20", "catalog_id": "nlni_ksj" } ] }} 実際に何が起こっているのか ↑geminiに作成してもらった 内部処理を見てみる STEP 1: Claude Desktopがリクエスト { "method": "tools/call", "params": { "name": "search_by_location_point_distance", "arguments": { "size": 5, "term": "避難所", "location_lat": 35.6896, "location_lon": 139.7006, "location_distance": 1000 } } } STEP 2: handle_call_toolが呼ばれる @server.call_tool() # ← MCPサーバーSDKが自動的にルーティングasync def hasync def handle_call_tool(name: str, arguments: dict) -> List[types.TextContent]: # name = "search_by_location_point_distance" # arguments = {size: 5, term: "避難所", location_lat: 35.6896, ...} rid = new_request_id() # リクエストIDを生成 cfg = load_settings() # 設定読み込み(未使用だがロード) client = MLITClient() # API クライアント作成 STEP3: バリデーション + Pydanticモデルに変換 elif name == "search_by_location_point_distance": p = SearchByPoint.model_validate({ "term": arguments.get("term"), "first": arguments.get("first", 0), "size": arguments.get("size", 50), "phrase_match": arguments.get("phrase_match", True), "prefecture_code": arguments.get("prefecture_code"), "point": { "lat": arguments["location_lat"], "lon": arguments["location_lon"], "distance": arguments["location_distance"], } }) STEP4: APIクライアント呼び出し data = await client.search_by_point( p.point.lat, p.point.lon, p.point.distance, term=p.term or "", first=p.first, size=p.size, phrase_match=p.phrase_match, ) search_by_point メソッドの中で、GraphQLを組み立てている async def search_by_point(self, lat: float, lon: float, distance_m: float, **kw) -> Dict[str, Any]: loc = self.make_geodistance_filter(lat, lon, distance_m) # make_geodistance_filterで地理オブジェクトに整形 { "geoDistance": { "lat": 35.6896, "lon": 139.7006, "distance": 1000 } } # クエリを構築 q = self.build_search(location_filter=loc, **kw) return await self.post_query(q) # ==== (GraphQL): operator 'is' ==== async def search_by_attribute_raw( self, *, term: Optional[str] = None, first: int = 0, size: int = 20, phrase_match: bool = True, attribute_name: str, attribute_value: Any, fields: Optional[str] = None, ) -> Dict[str, Any]: af = self.make_single_attribute_filter(attribute_name, attribute_value) effective_term = term if term is not None else "" q = self.build_search( term=effective_term, first=first, size=size, phrase_match=phrase_match, attribute_filter=af, fields=fields or self._fields_basic(), ) # API送信 return await self.post_query(q) ここで以下のGraphQLクエリが構築される↓ { search( term: "避難所" phraseMatch: true first: 0 size: 5 locationFilter: { geoDistance: { lat: 35.6896 lon: 139.7006 distance: 1000 } } ) { totalNumber searchResults { id title lat lon dataset_id catalog_id } } } STEP5: 最終的にjson形式に整形してClaudeDesktopに返却 # 1481行目 text = json.dumps(data, ensure_ascii=False) if len(text.encode("utf-8")) > 1024 * 1024: text = text[:1024 * 512] + "\n...<truncated>" logger.info("tool_done", extra={"rid": rid, "tool": name, "elapsed_ms": t.elapsed_ms}) return [types.TextContent(type="text", text=text)] おわりに 業務での活用方法は今の所思いつかないですが・・・。 こうしたMCPサーバーが無償で公開されるおかげで、国のデータ取得のハードルが相当下がって便利になっていることを実感しました! PLATEAU(プラトー) などの3Dモデルデータのダウンロードもできるようなので、何かに活かしたいですね。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 】の7日目の記事です。 組織でのAI活用をどう進めるのか。その土台整備について調査を進める中で、1つの結論に至りました。国内外の著名なエンジニアや調査レポートが一貫して指摘していたのは、従来のソフトウェア開発の原理原則がこれまで以上に重要になるということでした。AI時代だから何か特別なことをするのではなく、今まで良いとされてきたことを、地道に学習と実践をしていくしかないと考えております。 以下では、この考えに至った情報について、述べていきます。 AIは「増幅器」である 2025年のDORAレポートは、AIの本質を「増幅器」と表現しています [1] 。高パフォーマンス組織の強みを拡大する一方で、苦戦している組織の機能不全も拡大します。強力な自動テスト、成熟したバージョン管理、迅速なフィードバックループといった堅牢な制御システムがなければ、AIによる変更量の増加は技術負債につながる危険性が高くなります。 ファインディ株式会社CTO室の高橋裕之氏は、日本の現状に警鐘を鳴らしています [2] 。IT従事者798名への調査で、開発フレームワークについて「よくわからない」と「ウォーターフォール」が合わせて55%を占めました。旧式のバージョン管理ツールを使用している割合も高く、GitHub Copilotなどを活用できる土台すら整っていません。高橋氏は「これらは積み重ね。一足飛びには手に入らない」と述べています。DevOpsやAgileといった基盤がなければ、AI導入は逆効果になるとの考えを述べていました。 本質的な課題は変わっていない 株式会社LayerXが主催のAI Coding Meetup #2, #3で和田卓人氏は、AIによってテストを書くハードルが大きく下がったと述べています [3] [4] 。学習コストも実装コストも、AIの力でクリアできるようになりました。しかし、0からAIに書かせるとテストの品質はあまり良くないといいます。 問題は、AIがモックオブジェクトを多用してカバレッジを上げようとすることです。和田氏はこれを 2011年から13年頃にソフトウェアエンジニアリングの歴史で起こったことを綺麗にトレースしている と指摘しています。当時、ユニットテストに情熱的な人たちがテストを書きまくった結果、モック地獄に陥りました。AIは今、同じ轍を踏もうとしています。 では、どうすればいいのか。和田氏の答えは、人間側が良いお手本を示し、それを模倣させること。AIは模倣が上手なので、良いテストコードを見せれば良いテストを書いてくれます。逆に言えば、構図は変わらないのです。プロジェクトは最初に書かれた品質を薄く引き延ばす方向にしか進まない。人間側がどういうお手本を見せるかに、今のところはだいぶ影響されている、と和田氏は述べています。これはDORAレポートの「増幅器」という指摘と方向性が一致しています。良いものは良く増幅され、悪いものは悪く増幅される。結局、分かっている人でなければ、ある程度のクオリティのコードをAIに書かせることすらできないという結論に到達するのではないかと考えられます。 基礎知識がないと問題に気づけない 合同会社DMM.comのアプリケーションアーキテクト・ミノ駆動氏は 日進月歩なAI技術に右往左往せず。今こそ基礎に立ち返ろう と述べています [5] 。的確に指示する専門知識があっても、不十分な回答が返ってくることがあります。その不十分な回答を判断できず、技術的負債を知らず知らずのうちに拡大させてしまう。そうなるとAIを使っても開発がスケールしない。 元マイクロソフトのエンジニアの中島聡氏も同様の見解を示しています [6] 。AIは「動くコードを書く」ことには長けていますが、必ずしも保守性や拡張性まで考慮された「良いコード」を書いてくれるわけではない。エンジニアには「コードが汚くなってきたな、リファクタリングしなければ」と自ら気付き、能動的に行動することが要求されます。この判断には、設計の原理原則やソフトウェア品質特性への理解が不可欠です。 海外の視点 オライリーメディアの創立者であるティム・オライリー氏は 新しく学び、行うべきことがたくさんあります。 と述べています [7] 。また、同じ記事内で、Palantirのシャム・サンカー CTOの言葉も記載されていました。 プログラマーの役割は、従来型ソフトウェアで可能なこと、AIで可能なこと、依然として人間が行う必要があること、そしてそれらをどう組み合わせて実際にワークフローを達成するかを理解することだ Zedエディターの創設者ネイサン・ソボ氏は本質的な問いを投げかけています [8] 。 職人として、私たちはAIに注目し、「AIはより優れたソフトウェアの開発にどのように役立つのか?」と自問すべき コード生成が容易になっても、技術的負債が積み上がるのに対応する必要性は常に存在し続ける。それが周り回ってAIの活用も妨げる要因になることを指摘しています。 Microsoftは「エンジニアリングの基礎」を基盤の1つとして位置づけています。AIによる効率的で高品質な開発に注力することに加えて、コードリポジトリ、ビルド/リリースパイプライン、開発およびテスト環境のセキュリティのベストプラクティスに従い続けると宣言しています [9] 。AIを積極的に導入しながらも、従来のエンジニアリングの基礎を土台にしていることがわかります。 学ぶべき内容は変わっていない 調査を通じて感じたのは、組織も個人も、学ぶべき内容はAI登場以前と大きく変わっていないということです。変わったのは、学ぶべき量がまた増えたこと、そして基礎の重要性がより明確になったことです。 重要視されていた要素を整理すると、以下のようになります。 設計の原理原則 ソフトウェア品質特性の理解 テスト戦略とCI/CDの成熟 バージョン管理とコードレビューの文化 DevOpsやAgileといった開発プロセスの基盤 これらはすべて、AI登場以前から「良い開発」の条件とされてきたものです。 高橋氏の言葉を借りれば、DevOpsもAgileも「技術」ではなく「文化・方法論」です。文化は購入できず、組織自身が育てるしかありません。同様に、エンジニアとしての基礎力も、自分自身の手で築き上げるしかありません。 AIは、技術的な課題に加えて、組織課題まで把握して解決してくれるようになるのでしょうか? 少なくとも現時点では、その土台は人間が築く必要があると感じています。AIは強力なツールですが、それを使いこなすための土台は、地道な学習と実践の積み重ねでしか手に入らないと考えております。合同会社DMM.comのAX戦略 [10] やフリー株式会社の取り組み [11] など、他社の取り組みも参考にしながら改善していきたいです。 ジュニアエンジニアとして、AI時代でも人間が基礎を学ぶことの重要性がわかり、嬉しくもあり、自分の足りないところをたくさん感じて悲しくもなりました。地道に頑張っていきます。 最後まで読んでいただきありがとうございました。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 】の6日目の記事です。 GitHub Universe'25 Recap Tokyo こんにちは、Webアプリケーションエンジニアをしている新卒2年目のH.Sです。 最近、人生初のバンジージャンプを岐阜で飛んできました。 高所恐怖症が治るかと期待したのですが、全く治りませんでした...... さて、今回は11/26(水)に開催されたGitHub Universe'25 Recap Tokyo​のイベント参加レポートになります! カンファレンス概要 情報区分 カンファレンス詳細 イベント名 GitHub Universe'25 Recap Tokyo 開催日 2025/11/26 開催場所 ベルサール渋谷ファースト イベントページ https://github.registration.goldcast.io/events/881d0dd3-da25-4d1b-9a73-7b533dcd9aa4 タイムテーブル セッション 時間 セッションタイトル 12:00 PM - 1:00 PM 受付 / Registration 1:00 PM - 1:10 PM ようこそ! / Welcome! 1:10 PM - 2:10 PM GitHub基調講演 / GitHub Keynote 2:10 PM - 2:30 PM 質疑応答 / QA 2:30 PM - 2:50 PM 20分休憩 / 20 min break 2:50 PM - 3:15 PM 今日から使える!GitHub Copilotをカスタマイズ 3:15 PM - 3:40 PM GitHub Copilotで作るGitHub - 自社プロダクトで開発する究極のドッグフーディング 3:40 PM - 4:00 PM 20分休憩 / 20 min break 4:00 PM - 4:20 PM VS Codeでエージェント開発:GitHub CopilotとAgent Framework 4:20 PM - 4:50 PM 「安心・簡単・納得」を実現するバンダイナムコスタジオのGitHub&GitHub Copilot導入推進 4:50 PM - 5:10 PM 20分休憩 / 20 min break 5:10 PM - 5:30 PM GitHub管理者向け最新アップデート 5:30 PM - 6:00 PM GitHub を組織的に使いこなすためにソニーが実践した全社展開のプラクティス 6:00 PM - 6:03 PM 終わりの挨拶 / Closing 6:03 PM - 6:25 PM DJ&VJタイム(ネットワーキングパーティー) 6:25 PM - 7:30 PM ネットワーキングパーティー ライトニングトーク 時間 登壇企業/団体 セッションタイトル 2:35 PM - 2:45 PM Figma Japan株式会社 FigmaとGitHub Copilotでコードとデザインの意図をつなぐ 3:45 PM - 3:55 PM JFrog Japan株式会社 JFrog×GitHub:コードからバイナリ、セキュリティ、配布まで一元管理し、ソフトウェアサプライチェーンの全体制御で効率かつセキュアな開発プロセスを確立 4:55 PM - 5:05 PM 株式会社エーピーコミュニケーションズ GitHubをスーパーチャージ!カタログ、標準化、可視化で開発を加速するBackstageポータルとの連携 6:25 PM - 6:35 PM アバナード株式会社 AI-Native時代のDevOpsとGitHub活用 ― Avanadeが導く「価値×速度」の新標準 6:40 PM - 6:50 PM グラファナラボ日本合同会社 OSS だけじゃない!Grafana Cloud によるオブザーバビリティの実現 6:55 PM - 7:05 PM ギットハブ・ジャパン合同会社 Octoverse 2025のご紹介 各種セッション タイムテーブルにある通り、様々な企業様による貴重なセッション公演が行われていました。 その中でも個人的に面白かったもの・印象に残ったものを2つほど共有させていただきます! 今日から使える!GitHub Copilotをカスタマイズ こちらのセッションでは自分に合ったGitHub Copilotへカスタマイズするためにできることを入門から最新アップデートの内容まで、レベル1~5の段階に分けて解説をしていただきました。 Lv.1 Custom Instruction 公式ドキュメント Github Copilotがどのように振る舞ってほしいのか、性格やルールを定める機能です。 設定の登録方法はとてもシンプルで、プロジェクトのルート直下に .github/copilot-instructions.md ​または ./github/instructions/XX.instructions.md ​というマークダウンファイルを生成し、その中でドキュメントのように性格やルールを定めることで、copilotが自動でそれを読み取ってその通りに振る舞ってくれるという機能です。 また、少し前にOpenAIから発信された AGENTS.md という各種AIエージェント設定ファイル共通化という思想も取り入れられており、 AGENTS.md ​もこの機能で読み取ってくれるとのことです。 Lv.2 プロンプトファイル 公式ドキュメント いわゆるカスタムコマンドと呼ばれる /コマンド ​を作成する機能です。 普段、GitHub copilotに機能実装を依頼する際、「~~という条件で、〇〇.mdの仕様に沿って......」といった命令を毎回伝えることに対して手間を感じますよね。 この機能では、その実装命令を出す時に共通して伝えている指示​を /custom_implementation ​のように好きな名前で設定していつでも呼び出すことができます。 方法はとてもシンプルで、 .github/prompts/XX.prompt.md ​というマークダウンファイルを作成し、その中に実装命令を出す時に共通して伝えている指示​を書き込んでおくだけ そうすることで、いつもの命令を /コマンド ​に省略して伝えることができるようになります。 Lv.3 MCPs & Tools MCP 公式ドキュメント MCPとは、AI界隈における通信規格です。この規格はAIとAIに接続したいサーバーのためのもので、様々なサーバーで採用されているとても汎用的な概念です。 そしてMCPにおけるToolとは、各MCPサーバーが提供している機能のことを指します Lv.4 カスタムエージェント 公式ドキュメント Lv.1〜3を合体させたような仕組みです。 独自の /コマンド ​やMCP, Toolのどれを使っていいのか、どのような役割や性格をしたAIエージェントか、を全てカスタマイズして、〇〇専門AIエージェント​を作成する機能です。 調整次第では、リファクタリング専用 AIや、テストファイル作成AIなど、人の手で対応することがかなり手間になる業務を一任することができ、「チームの開発プロセスを守ってエージェントが開発を進めてくれる」ようになる魅力に溢れています。 Lv.5 Agentic Workflow 公式ドキュメント GitHub Agentic Workflows は、自然言語でGitHubの自動化ワークフローを記述できることを目指した機能です。 つまり、 .github/workflow/XX.yml ​に書いたワークフローをGitHub Actionsで動作させるように、AIエージェントでもGitHub Agentic Workflowsを使うことでGitHub Actionsを動かすことができるようになる、とのことです。 この機能のおかげで、PRのレビューや脆弱性調査など、人の手で行っていたものがGitHubブラウザ上でAIに任せることができそうです。 おまけ 今回のセッションの中で登壇者の服部さんがとても興味深い情報を共有されていたので、そちらについても触れさせていただきます! awesome-copilot 以下の4つについて学ぶことのできるGitHubリポジトリです。 AIエージェントのカスタマイズ手法 プロンプトエンジニアリングの実践例 コーディング規約の適用パターン MCP統合などの最新技術 こちらは、GitHub組織による管理下の元、MicrosoftとGitHubスタッフが保守しており、GitHubの公式セキュリティポリシーに準拠しているため、ガバナンス面にも優れています。 githubnext.com このページはGitHubが将来の開発者体験やツールについて研究・実験を行っている部門の公式サイトであり、様々な実験的プロジェクトや研究成果を紹介しています。 例えば、GitHub Copilot関連の実験的機能に関しても紹介されているため、「より最先端の情報が欲しい!」という方には宝の山かもしれません。 GitHub Copilotで作るGitHub こちらのセッションでは、GitHubを運営しているGitHub社の中でどのようにAIを使った開発を行っているのか、またその中で起きた秘話などについてお話をしていただきました。 アーキテクチャと開発体制 GitHubが Ruby on Railsで動作している ことや、開発手法はアジャイルに強制しておらず、各自適切な選択を取り入れることができるそうです。 開発手法についてはちょっとソースとなりそうな声明や記事を見つけられませんでした... 仮想環境開発 今回のお話の中で個人的なインパクトの大きかったテーマはCodespaces上で多くの開発プロジェクトが走っているという話です。 GitHubのサービスであるCodespacesを自社自らが採用しており、もしGitHubのサービスが止まってしまったらどうなるのか、というインシデントケースにも触れており、かなり貴重な学びになりました。 どうやら、Azureサーバーにミラーリングするようにサーバーを毎週立ち上げており、サービスが止まるような問題が起きた時にはそこから復旧可能とのことでした。 余談ですが、GitHub CodespacesといえばVS Codeの印象が強いため、Codespacesを使っている方でvimも使っている社員さんはいらっしゃるのかお伺いしたところ、その方の周りではほとんどいなかったそうです。 社内エンジニアが使っているGitHub copilotの機能分布 Web BrowserのCopilot Chat 17.6% Code Completion(コード補完) 16.8% Agent Mode(IDE) 16.5% 上位3つは本当に拮抗していて各機能がしっかり役割に対して有効で、どれも同じぐらい使われているという印象を受けました。 各社展示エリア・ブース 今回のカンファレンスにはセッション以外にもスポンサーをされた各社の展示エリアがありました。 各エリアなどでいただいた戦利品など Figma 私自身、最近登場したFigma makeを使うためにProプランの月3000円コースに入ったばかりで、そこまでFigmaについて詳しくなかったのですが、プランごとで使える機能の違いやその詳細を丁寧に教えていただくことができました。 Figmaを使ったプリクラ生成の体験も行っており、せっかくなのでお話を伺っていた社員の方と撮らせていただきました笑 Airdrop共有もできるため、この記事を書くにあたってのデータ取り込みがとても簡単で助かりました Figma Code Connectという機能 今回はその中でもFigma Code Connectの機能について触れさせていただきます。 Figma Code Connectはビジネスプランやエンタープライズプランで利用可能な機能で、主に企業向けのサービスのようです。 具体的には、デザイナーが作ったデザインと開発者が書いた実際のコードを紐付く機能で、開発者は自動生成されたコードではなく、実際のデザインシステムのコードをFigma上で確認できるようになるそうです。 この機能を使うことで、いわゆる企業独自のデザインシステムで問題だった「デザインとコードの分断」を解消することができ、各サービスでのデザイン統一や保守運用の連携がかなり改善されるようになります。 ネイルアート 展示スペースの端にネイルアートが体験できるエリアもありました。それもMicrosoftのロゴが入りで... なぜGitHubの情報発信の場に?と疑問に思い、担当の方にお話を伺ったところ、物珍しさとSNSでの話題を狙って毎年行っているとのことです。 デザインもとても可愛く、どの指につけるか選んでお願いできるようです。 しかも、本職のネイルアーティストさんをお呼びしているとのことで、イベントに対する本気度が伺えました。 私も試しにお願いしようと思ったのですが、かなり人気で予約状況を確認した時にはすでに最後の予約枠の時間しか空いておらず時間の都合で断念...... 来年こそは まとめ 今回のイベントは部署内でGitHubのイベントがあるらしいとの案内があり、そこから参加させていただきました。 半年ほど前に参加したTS Kaigiと異なり、現場ベースというより、GitHubサービスについてや、各社サービスがGitHubとどのように連携しているのか、といったサービス面の発信が多く、どちらかといえばマネージャー寄りの視点での学びが多かったように感じます。 自分のプロジェクトで採用しておらず、馴染みのないサービスも多かったものの、各スペースの社員さんから丁寧に教えていただくことができ、メンバー側の人間にとってもとても楽しいイベントになりました。 他にもこのGitHub Universeというイベント名物らしいドーナツがとても人気で争奪戦のようになっていたり、イベント中に開催されたスタンプラリーにて、メインセッションのステージ1のスタンプを撮り忘れて制覇を逃したり、イベント後のネットワーキングパーティではDJによるフロアの盛り上げなど、イベントを満喫できたんじゃないかなと感じています。
本記事は【 Advent Calendar 】の5日目の記事です。 はじめに Hello! 毎年ラスベガスで開催されるAWS re:Invent2025に現地参戦中のT.Kです。 今年は私含む4名のエンジニアがマイナビから参加しています! まずは、執筆にあたっての Best partner  Mr.プロテイン氏 を紹介させてください。 眠らない街ラスベガスで、ホテルに籠って執筆する悲しみを癒してくれています。合計で  $22.35  しました。 あっ!このサンドイッチ中身がぎっしりで美味しいですね 本記事の目的 re:Inventは非常に注目度が高いイベントのため、記事も豊富です。 しかしながら、出張記事=オフィシャルな発信の場合が多く、礼儀正しく素敵に飾られた記事が多いのも事実です。 本記事においては最低限の体裁を保ちつつ、あまり飾らない生身な情報を発信したいと思います! re:Inventのスケールについて 最初に紹介したいのが”スケール”ですね。 とても巨大なイベントです。人数や規模感もですが、そのままサイズの意味でも巨大なんです。 こちらが5つある会場のうちの2つ。WynnとEncoreです。 高さに目が行きがちですが、 注意すべきは横サイズです。 根っこの部分に広大な施設があり、そこが会場となっています。写真手前の小規模な森あたりからWynnホテルです! ホテル内部からの写真はこれです。ラスベガスのど真ん中にゴルフ場!? そんな広大な地をラップトップPCを持参しての移動となるため、参加者はアメリカ水準での健脚が前提です。 脚や腰に不安のある人間は参加辞退を検討するか、現地での移動手段を徹底的にシミュレーションすべきです。本当にリスクになり得ます。 re:Inventの街について アメリカです。ラスベカスです。私の事前知識は カジノ!! だけでした。 もちろん正解でしたが、1つ現地で追加になったものがあります。 ”富”  です。画像はホテル内と前の画像3枚です。(真ん中もホテル”内”です。川、流れてます) re:Invent中の導線で出会える店や施設は全部こんな感じです。 一般市民の私は、 ラグジュアリー感を感じる前に場違い感でしんどく なりました… 庶民向けのウォルマート等には全く出会わないイベントになっているので、行く時間を2日目/3日目あたりに確保しておくと、お土産や足りていないものの補充が出来て良いと思います! re:Inventの遊びについて イベント開催中はセッション等飛び回りつつ予約・移動・準備等を行うため、遊ぶ余裕は一切ありません。 イベント参加=業務時間は8:00~18:30くらいまでは見ておいたほうが良いと思います。 とはいえ若干の自由時間を捻出して、 本場のシルクドソレイユを見てきました。とても面白かったです! しかし、 非常に大きな注意点があります。 シルクドソレイユやスフィア等の鑑賞する娯楽は、re:Inventに不向きです。 なぜなら…非常に疲れている💤薄暗い💤椅子が快適💤いい香り💤両サイドには自社の仲間がいて安心💤 私はギリ耐えでしたが、一緒に行った仲間はもしかしたら...? ショーそのものには感動するのですが、 re:Inventとの相性が最悪 という点は覚えておいてください。 おわりに 飾らず移動の苦労といった部分まで綴ってみましたが、いかがだったでしょうか? 頼れる仲間と共に無事re:Inventを乗り越えます! 読んでいただきありがとうございました! 胃の中にいるMr.プロテインにも感謝を。Thank you! イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 】の4日目の記事です。 こんにちは、マイナビに業務委託で参画しているY.Hです。 現在携わっているプロジェクトでは、Playwright MCP を活用して E2E テストを実施しています。 しかし、MCP 単体だけでテストを書き続けると、どうしても「つらみ」が出てきます。 Playwright MCP だけで書き続けると起きる「つらみ」 Playwright MCPはテストコードを爆速で書ける一方で、"プロジェクト全体としてのテスト設計"までは面倒を見てくれません。 要素の取得ロジックが各テストケースに散らばったり、テストデータの準備が毎回バラバラになったりして、気づくとメンテナンスが大変な状態に陥ります。 // tests/todo-create.spec.tsimport { test, expect } from '@playwright/test';test('ユーザーがToDoを1件作成できる', async ({ page }) => { // ログイン処理(テストごとにコピペされがち) await page.goto('https://example.com/login'); await page.fill('input[type="email"]', 'user+e2e@example.com'); await page.fill('input[type="password"]', 'P@ssw0rd!'); await page.click('button:has-text("ログイン")'); // 画面遷移待ち(毎回ちょっとずつ書き方が違う) await page.waitForURL('**/dashboard'); // 要素のセレクタがテストケースごとにバラバラ await page.click('text=TODO一覧'); await page.click('button.add-todo'); // クラスセレクタ直書き // テストデータもその場でベタ書き const title = '牛乳を買う'; const description = '明日の朝までにスーパーで買う'; await page.fill('input[placeholder="タイトル"]', title); await page.fill('textarea[name="description"]', description); await page.click('button:has-text("保存")'); await expect(page.getByText(title)).toBeVisible();}); // tests/todo-create.spec.ts import { test , expect } from ' @playwright/test ' ; test ( ' ユーザーがToDoを1件作成できる ' , async ({ page }) => { // ログイン処理(テストごとにコピペされがち) await page . goto ( ' https://example.com/login ' ) ; await page . fill ( ' input[type="email"] ' , ' user+e2e@example.com ' ) ; await page . fill ( ' input[type="password"] ' , ' P@ssw0rd! ' ) ; await page . click ( ' button:has-text("ログイン") ' ) ; // 画面遷移待ち(毎回ちょっとずつ書き方が違う) await page . waitForURL ( ' **/dashboard ' ) ; // 要素のセレクタがテストケースごとにバラバラ await page . click ( ' text=TODO一覧 ' ) ; await page . click ( ' button.add-todo ' ) ; // クラスセレクタ直書き // テストデータもその場でベタ書き const title = ' 牛乳を買う ' ; const description = ' 明日の朝までにスーパーで買う ' ; await page . fill ( ' input[placeholder="タイトル"] ' , title ) ; await page . fill ( ' textarea[name="description"] ' , description ) ; await page . click ( ' button:has-text("保存") ' ) ; await expect ( page . getByText ( title )) . toBeVisible () ; } ) ; この問題を解決するために、POM(Page Object Model)と Fixture を組み合わせた構成を導入しました。 POM(Page Object Model)とは? POM(Page Object Model)とは、ページごとの操作を1つのクラスにまとめ、テストコードからDOM操作を"隠蔽(カプセル化)"する設計パターンです。 UIテストはDOM構造やセレクタの変更に影響を受けやすく、page.click(...) や page.fill(...) がテスト内に散乱すると、UI変更のたびに大量のテストが壊れてしまいます。 POMを使うことで、UI変更の影響をページクラス1か所に閉じ込められ、テストコード自体は安定して保てるようになります。 POM を使わないコード import { test, expect } from '@playwright/test';test('ログインできること', async ({ page }) => { await page.goto('https://example.com/login'); <em>// セレクタがテスト内にベタ書き</em> await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="password"]', 'password123'); <em>// ボタン文言も直書き</em> await page.click('button:has-text("ログイン")'); <em>// UI 変更(文言/属性)があると即壊れる</em> await expect(page.getByText('ダッシュボード')).toBeVisible();}); import { test , expect } from ' @playwright/test ' ; test ( ' ログインできること ' , async ({ page }) => { await page . goto ( ' https://example.com/login ' ) ; < em > // セレクタがテスト内にベタ書き</em> await page . fill ( ' input[name="email"] ' , ' test@example.com ' ) ; await page . fill ( ' input[name="password"] ' , ' password123 ' ) ; < em > // ボタン文言も直書き</em> await page . click ( ' button:has-text("ログイン") ' ) ; < em > // UI 変更(文言/属性)があると即壊れる</em> await expect ( page . getByText ( ' ダッシュボード ' )) . toBeVisible () ; } ) ; POM を使ったコード(良い例) まずは LoginPage クラスを作り、DOM操作をすべてそこへ隠蔽します。 import { Page } from '@playwright/test';export class LoginPage { constructor(private page: Page) {} async goto() { await this.page.goto('https://example.com/login'); } async fillEmail(email: string) { await this.page.fill('input[name="email"]', email); } async fillPassword(password: string) { await this.page.fill('input[name="password"]', password); } async submit() { await this.page.click('button:has-text("ログイン")'); } async login(email: string, password: string) { await this.goto(); await this.fillEmail(email); await this.fillPassword(password); await this.submit(); }} import { Page } from ' @playwright/test ' ; export class LoginPage { constructor ( private page : Page ) {} async goto () { await this . page . goto ( ' https://example.com/login ' ) ; } async fillEmail ( email : string ) { await this . page . fill ( ' input[name="email"] ' , email ) ; } async fillPassword ( password : string ) { await this . page . fill ( ' input[name="password"] ' , password ) ; } async submit () { await this . page . click ( ' button:has-text("ログイン") ' ) ; } async login ( email : string , password : string ) { await this . goto () ; await this . fillEmail ( email ) ; await this . fillPassword ( password ) ; await this . submit () ; } } 上記で作成したPOMを使ってテストを作成します。 import { test, expect } from '@playwright/test';import { LoginPage } from '../src/pages/LoginPage';test('ログインできること', async ({ page }) => { const loginPage = new LoginPage(page); <em>// DOMの詳細を触らずに「意図」だけを書く</em> await loginPage.login('test@example.com', 'password123'); <em>// テスト側は UI 変更の影響を受けにくい</em> await expect(page.getByText('ダッシュボード')).toBeVisible();}); import { test , expect } from ' @playwright/test ' ; import { LoginPage } from ' ../src/pages/LoginPage ' ; test ( ' ログインできること ' , async ({ page }) => { const loginPage = new LoginPage ( page ) ; < em > // DOMの詳細を触らずに「意図」だけを書く</em> await loginPage . login ( ' test@example.com ' , ' password123 ' ) ; < em > // テスト側は UI 変更の影響を受けにくい</em> await expect ( page . getByText ( ' ダッシュボード ' )) . toBeVisible () ; } ) ; テストコードから page.fill(...) や page.click(...) が消える DOM変更(セレクタ・文言)があっても LoginPage.ts の中だけ直せば良い Fixture とは? Fixture(フィクスチャ)とは、 テスト実行前に必要な「前準備」を共通化し、再利用しやすくするPlaywright の仕組みです。 E2E テストでは、ログイン・初期データ作成・画面遷移など「どのテストでも毎回必要な前処理」が発生しがちです。 これらをテストごとに書くと、以下のような問題が発生します: ログイン処理がテスト間でコピペされてバラバラになる データ準備の仕方がテストファイルごとで微妙に異なる UIやAPIが変更されたとき、修正箇所が全テストに波及して壊滅する MCP にテスト生成させるほど、重複コードが爆増しやすい Fixture を使うと、これらの前処理を 1ヶ所にまとめて隠蔽し、テスト側では "準備済みの状態" をそのまま使える というメリットがあります。 Fixture を使わない例(前処理が毎回バラバラになる) import { test, expect } from '@playwright/test';test('プロフィールを更新できる', async ({ page }) => { await page.goto('/login'); await page.fill('input[name=email]', 'user@example.com'); <em>// 毎回書く</em> await page.fill('input[name=password]', 'password123'); await page.click('button:has-text("ログイン")'); await page.waitForURL('/dashboard'); await page.goto('/profile'); await page.fill('#name', '新しい名前'); await page.click('button:has-text("保存")'); await expect(page.getByText('更新完了')).toBeVisible();}); import { test , expect } from ' @playwright/test ' ; test ( ' プロフィールを更新できる ' , async ({ page }) => { await page . goto ( ' /login ' ) ; await page . fill ( ' input[name=email] ' , ' user@example.com ' ) ; < em > // 毎回書く</em> await page . fill ( ' input[name=password] ' , ' password123 ' ) ; await page . click ( ' button:has-text("ログイン") ' ) ; await page . waitForURL ( ' /dashboard ' ) ; await page . goto ( ' /profile ' ) ; await page . fill ( ' #name ' , ' 新しい名前 ' ) ; await page . click ( ' button:has-text("保存") ' ) ; await expect ( page . getByText ( ' 更新完了 ' )) . toBeVisible () ; } ) ; どのテストにもログインが散らばる → UI変更があれば全滅 ページ遷移や待機処理も統一されない → バグが出た時に追いにくい MCPに生成させるとバリエーション違いのログインコードが量産される → 地獄 Fixture を導入するとどうなる? Fixture を使うと、テスト側では次のように一瞬でログイン済み状態を利用できます: <em>// fixtures/auth.ts</em>import { test as base } from '@playwright/test';export const test = base.extend({ loggedInPage: async ({ page }, use) => { await page.goto('/login'); await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="password"]', 'password123'); await page.click('button:has-text("ログイン")'); await page.waitForURL('/dashboard'); <em>// ログイン済みの page をテストに注入</em> await use(page); },});test('プロフィールを更新できる', async ({ loggedInPage }) => { await loggedInPage.goto('/profile'); await loggedInPage.fill('#name', '新しい名前'); await loggedInPage.click('button:has-text("保存")'); await expect(loggedInPage.getByText('更新完了')).toBeVisible();}); < em > // fixtures/auth.ts</em> import { test as base } from ' @playwright/test ' ; export const test = base . extend ( { loggedInPage : async ({ page }, use ) => { await page . goto ( ' /login ' ) ; await page . fill ( ' input[name="email"] ' , ' test@example.com ' ) ; await page . fill ( ' input[name="password"] ' , ' password123 ' ) ; await page . click ( ' button:has-text("ログイン") ' ) ; await page . waitForURL ( ' /dashboard ' ) ; < em > // ログイン済みの page をテストに注入</em> await use ( page ) ; }, } ) ; test ( ' プロフィールを更新できる ' , async ({ loggedInPage }) => { await loggedInPage . goto ( ' /profile ' ) ; await loggedInPage . fill ( ' #name ' , ' 新しい名前 ' ) ; await loggedInPage . click ( ' button:has-text("保存") ' ) ; await expect ( loggedInPage . getByText ( ' 更新完了 ' )) . toBeVisible () ; } ) ; ログイン処理は fixtures/auth.ts に隔離されており、UI変更やログインフロー変更の影響はその1ファイルに閉じ込められます。 MCP に「loggedInPage を使ってテストを書いて」と指示すると、生成されるテストの質も一気に安定します。 POM 作成における「人間」と「AI」の役割分担 POM を導入するときにいきなり「POMもテストも全部 MCP にお任せ!」としてしまうと、短期的には便利でも、中長期的にはカオスになります。 このプロジェクトでは、 POM の設計(どのページをどう抽象化するか)は人間が決める 決めた設計に沿ってコードを書く作業は AI(MCP)にやってもらう という役割分担にしています。 まずは人間が POM の設計・責務を決める どの画面を 1 POM とするか どの操作をメソッドに切り出すか どの UI 要素に  data-test-id ​ を振るべきか 命名規則をどうするか(例: <page>-<role>-<name> ​) この「抽象化」と「テスト観点の整理」は、プロダクトの設計意図を理解していないとできないため人間の仕事です。 例えば「ログイン画面」なら: email 入力欄: login-email-input​ password 入力欄: login-password-input ​ ログインボタン: login-submit-button ​ のように どの要素をテストの観点で識別するか を人間が確定させます。 その上で、data-test-id は以下の専用のファイルにまとめて一元管理しています。 <em>// testIds.ts</em>export const testIds = { login: { emailInput: 'login-email-input', passwordInput: 'login-password-input', submitButton: 'login-submit-button', },} as const; < em > // testIds.ts</em> export const testIds = { login : { emailInput : ' login-email-input ' , passwordInput : ' login-password-input ' , submitButton : ' login-submit-button ' , }, } as const; 設計が決まったら、実装(POM 化 & data-test-id の埋め込み)は AI に任せる UI のどの要素に  data-test-id ​ を付けるか、 POM でどのメソッドを持つか、 まで決まればそこからは AI(MCP)に実装してもらう方が速く、正確です。 MCP にはこんな指示を与えます: この testIds に従って UI コンポーネントに  data-test-id ​ を追加してください 同じ命名規則で POM クラスを作成してください POM には「login(email, password)」操作を実装してください ログインが成功したら Dashboard に遷移する前提です すると AI は以下のような実装を自動生成できます: UI コンポーネント側(AI生成例) <input data-test-id="login-email-input" type="email" .../><button data-test-id="login-submit-button"> ログイン</button> < input data - test - id = " login-email-input " type = " email " ... /> < button data - test - id = " login-submit-button " > ログイン </ button > POM(AI生成例) import { Page } from '@playwright/test';import { testIds } from '../testIds';export class LoginPage { constructor(private page: Page) {} async login(email: string, password: string) { await this.page.getByTestId(testIds.login.emailInput).fill(email); await this.page.getByTestId(testIds.login.passwordInput).fill(password); await this.page.getByTestId(testIds.login.submitButton).click(); }} import { Page } from ' @playwright/test ' ; import { testIds } from ' ../testIds ' ; export class LoginPage { constructor ( private page : Page ) {} async login ( email : string , password : string ) { await this . page . getByTestId ( testIds . login . emailInput ) . fill ( email ) ; await this . page . getByTestId ( testIds . login . passwordInput ) . fill ( password ) ; await this . page . getByTestId ( testIds . login . submitButton ) . click () ; } } この方式のメリット data-test-id の命名が統一される → 人が設計し、AIが実装するのでブレない POM の責務が明確になる → どこまで抽象化するかの境界を人間が決める 実装作業(単純作業)は AI が自動化 → 開発者は設計・レビューに集中できる テスト資産が中長期で保守しやすくなる → UI の変更に強い 最終的に Playwright MCP へどう指示すればいいのか? ここまでで、 POM の設計/data-test-id の設計は人間が行う 実装作業(POM 化・UI への data-test-id 埋め込み)は AI が担当する テストケースは POM と Fixture を前提に書く という役割分担を確立しました。 では、実際に Playwright MCP にどう指示すれば"質の高いテスト"を生成してくれるのか? ここが一番重要なポイントになります。 MCP に依頼するときの指示テンプレート 以下の 4 点をセットで渡すのが効果的です: POM の定義(クラス名・メソッド・責務) data-test-id のルール & testIds.ts の構造 利用する Fixture(例:loggedInPage) 欲しいテストケースの「意図」だけを書く(UI操作は書かない) これを守ると、MCP は "テストの意図" を読み取った上で、POM と Fixture を前提にした理想的なテストコード を返してくれます。 MCP へ渡す具体的なプロンプト例 以下のルールに従って Playwright のテストコードを生成してください。【1. 使用する POM】- LoginPage: login(email, password) を提供- ProfilePage: updateName(name), goto() を提供- POM 内の DOM 操作は testIds.ts の data-test-id のみを使用すること- POM 以外で page.locator や page.fill を直接使わない【2. data-test-id の命名規則】- <page>-<role>-<name> の形式で統一- 全 test-id は testIds.ts に定義済み- テストコードからは getByTestId(testIds.xx.yy) を必ず使うこと【3. Fixture の利用】- ログイン済みの状態が必要な場合は「loggedInPage」を利用すること- ページの初期化は const profilePage = new ProfilePage(loggedInPage) のように行う【4. 作成してほしいテストの意図】- 「プロフィール画面で名前を変更できること」- 名前欄に「新しい名前」を入力し、保存後に「更新完了」のトーストが表示されることをテストするこれらを踏まえて、適切なテストコードを生成してください。 以下のルールに従って Playwright のテストコードを生成してください 。 【 1. 使用する POM 】 - LoginPage : login ( email , password ) を提供 - ProfilePage : updateName ( name ) , goto () を提供 - POM 内の DOM 操作は testIds . ts の data - test - id のみを使用すること - POM 以外で page . locator や page . fill を直接使わない 【 2. data - test - id の命名規則 】 - < page >- < role > - < name > の形式で統一 - 全 test - id は testIds . ts に定義済み - テストコードからは getByTestId ( testIds . xx . yy ) を必ず使うこと 【 3. Fixture の利用 】 - ログイン済みの状態が必要な場合は 「 loggedInPage 」 を利用すること - ページの初期化は const profilePage = new ProfilePage ( loggedInPage ) のように行う 【 4. 作成してほしいテストの意図 】 - 「 プロフィール画面で名前を変更できること 」 - 名前欄に 「 新しい名前 」 を入力し 、 保存後に 「 更新完了 」 のトーストが表示されることをテストする これらを踏まえて 、 適切なテストコードを生成してください 。 MCP が返す理想的なアウトプット(例) 上記の指示に対して、MCP は以下のようなテストを生成できます import { test, expect } from '../fixtures/auth';import { ProfilePage } from '../pages/ProfilePage';test('プロフィールの名前を更新できる', async ({ loggedInPage }) => { const profilePage = new ProfilePage(loggedInPage); await profilePage.goto(); await profilePage.updateName('新しい名前'); await expect( loggedInPage.getByText('更新完了') ).toBeVisible();}); import { test , expect } from ' ../fixtures/auth ' ; import { ProfilePage } from ' ../pages/ProfilePage ' ; test ( ' プロフィールの名前を更新できる ' , async ({ loggedInPage }) => { const profilePage = new ProfilePage ( loggedInPage ) ; await profilePage . goto () ; await profilePage . updateName ( ' 新しい名前 ' ) ; await expect ( loggedInPage . getByText ( ' 更新完了 ' ) ) . toBeVisible () ; } ) ; page.fill() や page.click() は一切ない POM のメソッドだけを使っている Fixture により "ログイン状態" が保証されている 変更に強いテストコードになっている 最終的に Playwright MCP にはどう指示を出せばいいのか ここまで書いたとおり、Playwright MCP にそのままテストを書かせると、 その場しのぎのセレクタを勝手に推測する ログインや前処理を毎回ベタ書きする ページ遷移や待機の書き方がテストごとにバラバラになる という形で、短期的にはテストが増えるけれど、中長期では破綻しがちです。 そこでこのプロジェクトでは: UI 操作は必ず POM 経由 前処理は Fixture 経由 要素の取得は data-test-id 経由 という「テスト設計上のルール」をまず人間が決め、そのうえで MCP にはそのルールを"絶対に守らせる"形で指示を出す ようにしています。 MCP に期待しているのは「設計済みのレールの上で、ひたすらコードを書いてくれるジュニアエンジニア」に近い役割です。 まとめ:MCP × POM + Fixture は「AI任せにしないための設計」 Playwright MCP はとても強力ですが、そのまま自由に書かせると、テストが必ず崩壊します。 理由はシンプルで、MCP は「プロジェクト横断の設計意図」を理解していないからです。 セレクタの使い方や命名に一貫性を持たせる 前処理(ログインなど)を共通化する UI 変更の影響を最小化する といった"テスト設計として当たり前の基盤"は、AI 任せにすると必ず壊れます。 だからこそ、POM(抽象化・隠蔽の境界)と Fixture(前処理の共通化)を人間が設計し、実装を AI に任せる という役割分担が最も安定します。 この協業パターンにすると: テストコードは「意図を書くだけ」で済む data-test-id / POM / Fixture の運用がブレなくなる UI 変更にも強い "腐らない E2E テスト" が作れる AI は「ジュニアエンジニア的なコーディング作業」を高速にこなす という状態が実現できます。力の両方を最大化した、壊れにくいテスト基盤が手に入ります。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の3日目の記事です。 はじめに ジョブサーチのWeb版の開発を担当しておりますK.Kです。 今回は、ジョブサーチにおいてSSR Streamingを導入したので、SSR Streaming自体の解説と、導入してどのような効果があったのかお話できればと思います。 ジョブサーチでは求人一覧ページにおいて、求人検索や内部リンクを生成するといったような重たい処理をしている箇所があり、全てのデータ取得を待つとページ描画の開始が非常に遅くなってしまうという課題がありました。 それを改善するため、まずはファーストビューに当たるデータを返し、その後、残りのデータを読み込むという手法を検討しました。 しかし、 ジョブサーチはオーガニック流入が大事、、、 CSRにすると検索エンジンに読み取ってもらえないかも、、、 ということで、 検索エンジンに読み取ってもらえそう & 部分的にデータを返せるSSR Streamingを導入しました! SSR Streamingとは? SSR Streamingは、Next.js 13のApp Routerで導入されました。 従来のSSRでは、サーバーがHTMLを完全に生成してからクライアントに送信していましたが、SSR Streamingでは、HTMLを部分的にストリーミングしながらクライアントに送信できます。 この技術により、ユーザーは完全なページの読み込みを待つことなく、コンテンツを段階的に表示できるため、体感的なパフォーマンスが大幅に向上します。 ジョブサーチでのSSR Streaming導入箇所 ジョブサーチにおいては、求人一覧画面において、SSR Streamingを導入しています。 画像のように、最初の2求人のみをレンダリングした状態でまずはHTMLを返し、その後、内部リンクや他の求人も返すという流れになっています。 Next.jsのレンダリング手法 さて、これからSSR Streamingについて解説していこうと思います、、、が、 その前に、そもそもNext.jsにはどんなレンダリング方法があるのでしょうか? Next.jsには複数のレンダリング手法があり、今回取り上げているSSRもその一つです。 まずはSSRが何かを知るために、他のレンダリング方法も含めて解説します! SSR(Server-Side Rendering) SSRは、サーバー側でReactコンポーネントをHTMLに変換してクライアントに送信する技術です。 export default async function SSRPage() { const data = await fetchData(); return <div>{data.content}</div>;} export default async function SSRPage () { const data = await fetchData () ; return < div > { data. content } </ div >; } SSRの特徴 初期表示が高速(完成されたHTMLを送信) SEOに有利(検索エンジンがコンテンツを認識可能) サーバー負荷が高い(リクエストごとにHTML生成) 動的なコンテンツに適している CSR(Client-Side Rendering) CSRは、ブラウザ上でJavaScriptを実行してコンテンツを生成する手法です。初期HTMLは最小限で、データ取得と描画はクライアントサイド(ブラウザ)で行われます。 "use client";import { useEffect, useState } from 'react';export default function CSRPage() { const [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, []); if (!data) return <div>読み込み中...</div>; return <div>{data.content}</div>;} " use client " ; import { useEffect , useState } from ' react ' ; export default function CSRPage () { const [ data , setData ] = useState ( null ) ; useEffect ( () => { fetchData () . then ( setData ) ; }, []) ; if ( ! data ) return < div > 読み込み中 ...</ div >; return < div > { data. content } </ div >; } CSRの特徴 初期表示が遅い(JavaScriptの読み込み・実行が必要) SEOに不利(初期HTMLにコンテンツが含まれない) インタラクティブな操作が高速 サーバー負荷が軽い SSG(Static Site Generation) SSGは、ビルド時に静的なHTMLファイルを事前生成する手法です。 // 静的生成export default async function SSGPage() { const data = await fetchStaticData(); return <div>{data.content}</div>;}// 動的ルートでの静的生成export async function generateStaticParams() { const posts = await getPosts(); return posts.map((post) => ({ slug: post.slug }));} // 静的生成 export default async function SSGPage () { const data = await fetchStaticData () ; return < div > { data. content } </ div >; } // 動的ルートでの静的生成 export async function generateStaticParams () { const posts = await getPosts () ; return posts . map ( ( post ) => ( { slug : post . slug } )) ; } SSGの特徴 最高の表示速度(事前生成されたHTML) SEOに最適(静的コンテンツ) サーバー負荷が最小(静的ファイル配信) 更新頻度の低いコンテンツに適している ISR(Incremental Static Regeneration) ISRは、SSGの利点を保ちながら、指定した間隔、または、任意のタイミングでコンテンツを再生成する手法です。 export default async function ISRPage() { const data = await fetchData(); return <div>{data.content}</div>;}// 60秒ごとに再生成export const revalidate = 60; export default async function ISRPage () { const data = await fetchData () ; return < div > { data. content } </ div >; } // 60秒ごとに再生成 export const revalidate = 60 ; ISRの特徴 高速な表示速度(静的ファイルを配信) SEOに有利(静的コンテンツ) サーバー負荷が軽い(定期的な再生成のみ) 動的コンテンツと静的配信の両立が可能 手法の比較 手法 初期表示 SEO サーバー負荷 適用場面 CSR 遅い 不利 軽い SPA、管理画面 SSR 高速 有利 重い 動的コンテンツ SSG 最高速 最適 最軽 ブログ、LP ISR 高速 有利 軽い ECサイト、ニュース SSR Streamingの解説 さて、ここまでで、各レンダリング手法の仕組みや、メリット・デメリットがお分かりいただけたかと思います。 私が担当しているプロジェクトのジョブサーチでは、これまで単にSSRを使用していました。 それは、動的なコンテンツであり、かつ、SEOが大事なサイトだからです。 しかし、今回お話ししているSSR Streamingでは、段階的に表示をさせながら、検索エンジンにコンテンツを読み取らせることができます。 HTTPストリーミング SSR Streamingを理解するには、HTTPの仕様が鍵となります。 単なるSSRのHTTPレスポンスでは、サーバーは完全なレスポンスを生成してからクライアントに送信していました。 // 従来のHTTPレスポンスHTTP/1.1 200 OKContent-Type: text/htmlContent-Length: 1024<!DOCTYPE html><html>...</html> // 従来のHTTPレスポンス HTTP / 1.1 200 OK Content - Type : text / html Content - Length : 1024 <! DOCTYPE html > < html > ...</ html > HTTP/1.1では Transfer-Encoding: chunked​ ヘッダーを使用することで、1回のHTTPレスポンスを複数のチャンクに分割して送信できます。 よって、CSRのようにHTMLの読み込みが完了した後に別の情報を取得するのではなく、1回のHTTPレスポンス内で遅延させた情報も読み込むことが可能になります! // ストリーミングHTTPレスポンスHTTP/1.1 200 OKContent-Type: text/htmlTransfer-Encoding: chunked// チャンク11A<!DOCTYPE html><html><head>// チャンク22F<title>Page Title</title></head><body>// チャンク315<h1>Hello World</h1>// チャンク4E</body></html>// 終了チャンク0 // ストリーミングHTTPレスポンス HTTP / 1.1 200 OK Content - Type : text / html Transfer - Encoding : chunked // チャンク1 1 A <! DOCTYPE html > < html >< head > // チャンク2 2 F < title > Page Title </ title > </ head >< body > // チャンク3 15 < h1 > Hello World </ h1 > // チャンク4 E </ body > </ html > // 終了チャンク 0 Next.jsでのStreaming実装 では、これをどうやって実装したら良いのでしょうか? Next.js 13のApp Routerでは、簡単に実装することができます! Suspenseを使用することで、Suspense内部のコンポーネントは描画が完了した後に配信され、ブラウザは受信したHTMLチャンクを段階的に解析・表示します。 また、データが送られて来るまでの間は、fallbackの内容を表示しておくことができます。 import { Suspense } from 'react';export default function StreamingPage() { return ( <div> {/* 即座に送信される部分 */} <h1>即座に表示される部分</h1> {/* 非同期で読み込まれる部分 */} <Suspense fallback={<div>読み込み中...</div>}> <SlowComponent /> </Suspense> {/* 複数の非同期コンポーネント */} <Suspense fallback={<div>データ取得中...</div>}> <DataComponent /> </Suspense> </div> );}async function SlowComponent() { await new Promise(resolve => setTimeout(resolve, 2000)); return <div>時間のかかる処理の結果</div>;}async function DataComponent() { const data = await fetch('https://api.example.com/data'); return <div>{data.content}</div>;} import { Suspense } from ' react ' ; export default function StreamingPage () { return ( < div > { /* 即座に送信される部分 */ } < h1 > 即座に表示される部分 </ h1 > { /* 非同期で読み込まれる部分 */ } < Suspense fallback = { <div>読み込み中 ...</ div > } > < SlowComponent /> </ Suspense > { /* 複数の非同期コンポーネント */ } < Suspense fallback = { <div>データ取得中 ...</ div > } > < DataComponent /> </ Suspense > </ div > ) ; } async function SlowComponent () { await new Promise ( resolve => setTimeout ( resolve , 2000 )) ; return < div > 時間のかかる処理の結果 </ div >; } async function DataComponent () { const data = await fetch ( ' https://api.example.com/data ' ) ; return < div > { data. content } </ div >; } Suspenseで囲んでfallbackで指定するだけで後からHTMLを書き換えてくれるのは非常に便利ですね! ジョブサーチでの実装例 ジョブサーチでは、最初の2件の求人のみを返し、その下の求人や内部リンクはfallbackにスケルトンを指定しています。 export const SearchPage = async ({ searchParams }) => { // 初期表示するための求人情報を取得 const initialData = await getInitialSearchResults(searchParams); return ( <div> {/* 即座に表示される初期結果 */} <InitialResultsList data={initialData} /> {/* 追加の求人を非同期で読み込み */} <Suspense fallback={<LoadingSkeleton />}> <AdditionalResultsWrapper searchParams={searchParams} /> </Suspense> </div> );}; export const SearchPage = async ({ searchParams }) => { // 初期表示するための求人情報を取得 const initialData = await getInitialSearchResults ( searchParams ) ; return ( < div > { /* 即座に表示される初期結果 */ } < InitialResultsList data = { initialData } /> { /* 追加の求人を非同期で読み込み */ } < Suspense fallback = { <LoadingSkeleton /> } > < AdditionalResultsWrapper searchParams = { searchParams } /> </ Suspense > </ div > ) ; } ; 以下の部分で残りのデータを取得しています。 const AdditionalResultsWrapper = async ({ searchParams }) => { // 時間のかかるデータ取得処理 const additionalData = await getAdditionalResults(searchParams); const relatedData = await getRelatedContent(searchParams); return ( <> <AdditionalResultsList data={additionalData} /> <Pagination totalCount={additionalData.count} /> <RelatedContent data={relatedData} /> </> );}; const AdditionalResultsWrapper = async ({ searchParams }) => { // 時間のかかるデータ取得処理 const additionalData = await getAdditionalResults ( searchParams ) ; const relatedData = await getRelatedContent ( searchParams ) ; return ( <> < AdditionalResultsList data = { additionalData } /> < Pagination totalCount = { additionalData. count } /> < RelatedContent data = { relatedData } /> </> ) ; } ; スケルトンには実際のコンテンツと同じような大きさを持つようにして実装しています。 const LoadingSkeleton = () => { return ( <div> {Array.from({ length: 10 }).map((_, i) => ( <div key={i} className="skeleton-item"> <div className="skeleton-title" /> <div className="skeleton-description" /> </div> ))} </div> );}; const LoadingSkeleton = () => { return ( < div > { Array . from ( { length : 10 } ). map (( _ , i ) => ( < div key = { i } className = " skeleton-item " > < div className = " skeleton-title " /> < div className = " skeleton-description " /> </ div > )) } </ div > ) ; } ; これにより、以下の画像のように段階的にページを表示させることができます。 導入結果 ページ表示速度 さて、今回の変更で、ページ表示は早くなったのでしょうか? 結果は、、、 体感めっちゃ早くなりました! が、サーチコンソールの平均応答時間に変化はありませんでした。 いくつかの条件で調べた結果、おおよそ1秒以内でHTMLが返ってきていました。 会社名: 株式会社マイナビ :0.35秒 エリア: 埼玉県 熊谷市 :0.45秒 フリーワード: ITエンジニア :0.98秒 これまでは2~3秒程度かかっていたので、だいぶ時間が短縮されています。 一方で、サーチコンソールでの平均応答時間は、以前として大きな値のままになっています。 おそらく、HTMLの読み込みが終わるタイミングを平均応答時間として表現しているのではないかと思います。 検索エンジンでの認識 サーチコンソール上でURL検査を見たところ、後から読み込ませている部分もHTMLとして認識されていることがわかりました。 これについては、Vercelの方でも言及されている記事がありました。 参考記事 まとめ 今回の変更を行ったことで、ユーザー体験に大きな向上があったと考えています。 検索エンジンにコンテンツを認識させながらも、ファーストビューの表示をとにかく早くすることができました! SEO対策として検索エンジンに読み取ってもらうコンテンツを作成するためにページ表示速度が犠牲になっているサイトには、SSR Streamingはユーザー体験向上に非常に効果的な施策だと思います。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の2日目の記事です。 クラウドインテグレーション統括部 クラウドエンジニアリング部 エンジニアリング3課のO.K.です。マイナビに入社して約1年半ほど経過し、現在は AWS Organizations 周りの統制を担当しています。 概要 2025年9月頃から AWS re:Invent の参加に向けて準備を進めていました。マイナビとしての参加はおそらく3回目になりますが、「初参加 + クラウドエンジニア目線」で準備したことなどをまとめてみます。 セッションの参加に向け考慮すべきことや、事前に準備していることなどを記載します。 セッション予約 事前情報 AWS re:Invent 公式ページ セッション数 2564 セッション(多!セッション予約開始時は1500件ほどだった気がします) ※画像は 11/13 時点のもの 会場全体 Encore Las Vegas/Wynn Las Vegas と最南端の Mandalay Bay Resort and Casino の間で、 約 3 マイル ≒ 4.8 km(遠! 参考: https://markrosscloud.medium.com/how-i-re-invent-2f9f7de0f1bb 思った以上に難しいスケジュール登録 規模感は分かったのですが、公式サイトから気になるセッションを検索・予約をしていくと、中々都合が悪くなってしまいます。 例えば 気になるセッションの時間帯が重なる 中途半端に空いた時間ができる 会場移動を考慮しづらい といった具合です。うまいことフィルターを活用し、頑張れば調整できるのですが、中々難しい。 セッションの中には早期に予約しないとすぐに埋まってしまうものがあるので、早めに暫定スケジュールを組んでしまいたいところでした。 そんな時に出会ったのが AWS re:Invent Planner 2025 ※AWS 公式のツールではないです re:Invent Planner どのようなことができるか 公式サイトと同様、左側のナビゲーションバー的な箇所でセッションをフィルタリングすることができるのですが、特に便利だったのが時間指定です。 公式でフィルタできる情報に加え、特定の時間でどういったセッションがあるのか調べることができます。例えば以下のように設定すると Level:300 -Advanced Type:Bulders'session Day:Monday (12/1) Time:開始時間:8:00 以降、終了時間:13:00 前 Venue:Mondalay Bay 出てくるセッションは以下のような感じになります。 Venue and Day/TimeMonday @ Mandalay Bay | Level 2 South | Oceanside C | Content Hub | Builders' Session 210:30 - 11:30 Venue and Day / Time Monday @ Mandalay Bay | Level 2 South | Oceanside C | Content Hub | Builders ' Session 2 10 : 30 - 11 : 30 指定通りのセッションになっていますね。 この調子で気になるセッションを埋めていくと… 12/1 は一つの会場(Mandalay Bay はオレンジ色)でセッションスケジュールを立てることができました。お昼は Mandalay Bay 会場の昼食を楽しみつつ、ブースを見て回りながら雰囲気を堪能できそうです。 ※ 初日は参加メンバー全員が同じ会場で行動してみようと決めていました。(午後の Game Day に全員で参加するため) 事前に準備していたため、メンバー全員がセッション予約開始と同時(10/15 AM2:00)に気になるセッションを予約できました! セッション参加に向けた準備 参加する主なセッション 私が参加しようと思っているセッションは大体こんな感じになりました。 Kiro AWS Bedrock, Amazon Q Developer の活用(開発~運用、インシデント分析) モダンな CI/CD(ゲーム形式) 8, 9割は AI が関連するセッションといった感じです。個人的にも意識して選びましたが、全体的に AI 関連のセッションがかなり多いです。 恥ずかしながら、私はまだまだ AI を活用した経験が少なく、Amazon Q Developer や Bedrock を活かしきれていないと思っていたので、このようなセッションを重点的に学ぼうと思いました。 Kiro 数か月前から話題になっている AWS が開発した統合開発環境(IDE)です。VScode に似た UI で、AIが標準で統合されています。(Cursor と似ていますね) 個人的には仕様駆動型開発を前提に作られているのがめちゃくちゃ好きになりまして、プライベートではこのエディタに移行しようと思っています。 仕様駆動型 ドキュメントベースで「何を作るのか」を明確にし、出来上がった要件、設計に従って AI が機械的にタスクを実行してくれます。 これがかなり良さそうだと思っています。例えば Kiro に伝えるための「AWS のインフラ構築に関するテンプレート」を社内で用意 テンプレートを渡して  requirements.md ​を作成 設計となる  design.md ​ を作成:2⇔3 でいい感じに調整 tasks.md ​ のタスク実行 みたいな感じで行けそうかなと。Kiro は AWS が開発してますし、親和性はかなり高い…はず! https://kiro.dev/ AWS Bedrock Bedrock に登録されている汎用的なモデルを API で呼び出すといった形で使用することはありましたが、 RAG 構成を実装したりしたことはなかったため勉強しておくことにしました。下記の書籍を読み進める中で参考になる情報はたくさんありましたが、AWS で構築する RAG 構成を簡単に紹介します。 参考: Amazon Bedrock 生成AIアプリ開発入門  ※めちゃくちゃオススメ Knowledge bases (RAG) 細かい説明は割愛しますが、要するに「社内文書などの情報を LLM に与えて回答させる」といった構成の一つです。 具体的な処理の流れとしては以下のような感じです。 S3 から社内文書を読み込み、埋め込みモデルがベクトルに変換してベクトルDBに保存 ユーザーの質問を埋め込みモデルがベクトルに変換、ベクトルDBの中で近い内容(ベクトルの類似性を評価)があるか検索 検索結果を LLM に渡す LLM がユーザーにレスポンスを返す 「細かいことはよく分からない!」といった場合にも、緑色の枠の部分を1つのLLMとして考えることで、シンプルにS3から社内文書を参照して回答しているように見えます。 まとめ 今回は初参加目線のセッション選びと、その事前準備についてまとめてみました。その他にも、参加するセッション (Game Day) のモダンな CI/CD 構成について事前予想を立ててみたり、ツアー会社とのやり取りや社内調整、さらには英語を勉強し直したり等々… 色々書きたいことはあるのですが、大分長くなりそうなので以上にしようかなと思います。 イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
本記事は【 Advent Calendar 2025 】の1日目の記事です。 はじめに 直近数年くらいで、プロトタイピングの手法がかなり発達してきたなと感じています。 数年前に、FigmaやAdobe XDといったデザインツールが登場し、 Figmaのプロトタイプ機能を使えば、デザインツール上でインタラクションまで作れる ようになりました。 そして最近、AIを活用したプロトタイピングがかなり進化してきています。 個人的に思うのは、 「今まで数日や数時間かかったプロトタイピングが一瞬で終わる時代になってきた」 ということです。 最初から完璧なコードを書くよりも、プロトタイプをサクッと作って価値検証していく方が圧倒的に楽だし、いろんなアイデアを試せるんじゃないかなと思っています。 今回は、そんなAIを使った新しいプロトタイピング手法について、 Figma Make を題材にまとめていきます。 新旧のプロトタピングを比較する題材 新旧のスピード感や労力を比較していくので、題材を合わせてみます。 今回は、 「名言をスワイプしてみれるアプリ」 を題材にやってみようと思います。 従来のプロトタイピング手法:Figma prototype まずは従来のパターンで作っていきます。 作成風景も載せてみたので、なんとなくこんな感じにで作っているんだなと思ってみてみてください。(100倍速) ① 要素を作る まずは、枠を作ります。これは普通のFigmaファイルを作るの大体同じです。 ② バリエーションを作る 今回は5つの名言があるので、5パターン作ります。 ③ ナビゲーションで繋げる ナビゲーションで5画面を繋げます。 スワイプみたいな便利(おそらくない?)は、使わずクリックイベントで擬似的に作ってます。 インタラクションは全部同じです。 ちなみに、 Animation: Smart animate を使うといい感じにアニメーションしてくれるので大変便利です。 かかった時間と労力 動画の時間が、 大体17分 くらいでした。(初めての人はもう少しかかると思います。) 作業の労力としては、慣れてればそんなに辛くはないのですが、画面にはずっと張り付きっぱなしになると思います。 新しいプロトタイピング手法:Figma Make (AI) を使う ここからが本題です。 AIを使った新しいプロトタイピング手法について紹介していきます。 0→1(ゼロから作る)でも使えるし、既存のFigmaファイル がある状態から修正していくこともできます。 今回はFigma Make AIを使いますが、V0やLovableでも似たようなことはできます。 ただ、今回は、Figma Make AIに絞って話を進めていきます。 0→1 パターン まず、何もない状態から作る場合の流れです。 ①AIと壁打ちをする 最初にやるのは、 ClaudeやChatGPTと壁打ち です。 Figma Make AIに投げる前に、まず要件を整理しておくとスムーズに進みます。 欲しいもの: 要件定義 仕様の部分 大体の画面一覧 この3つがあれば、AIは割といい感じに作ってくれます。 ちなみに、音声入力で2〜3分くらい、だらだらと喋るのがおすすめです。 「こういうアプリを作りたいんですよね。ユーザーは〜で、機能としては〜があって、画面は〜と〜が必要で・・・」 みたいな感じで、思いついたことを全部喋っちゃいます。 情報量は多い方がいいので、遠慮せずに全部話しましょう。 ※会社では、claudeは使用できないので、余暇で実施しています。 ②AIに整理してもらう 壁打ちした内容をAIに投げたら、次は整理してもらいます。 【プロンプト例】 今話した内容を整理して、足りない部分確認が必要な事項をまとめてください。その後、画面定義と要件定義を生成してください。 こんな感じで投げると、AIがいい感じに整理してくれます。 足りない部分があれば質問してくれるので、それに答えながら詰めていきます。 ③Figma Makeに流し込む 要件が固まったら、いよいよFigma Make AIに投げます。 ここはもう本当にシンプルで、 【プロンプト例】 Figma Make AIに流したいので、要件定義書とドキュメントを作ってください。 これだけです。 するとAIが、Figma Makeに投げやすい形式で整理してくれます。 ↓出力されたマークダウン そのままコピペでFigma Makeに投げればOKです。 ④チャットで調整 Figma Makeがプロトタイプを生成してくれたら、あとはチャットで調整していくだけです。 「このボタンの色を変えて」 「この画面に〜の要素を追加して」 「レイアウトをもう少し広くして」 みたいな感じで、対話的に修正できます。 (今回は背景だけ夜空に変えてもらいました。) ↓こんな感じになりました。 かかった時間と労力 動画の時間が、 大体10分 くらいでした。 時間も早かったですし、 アニメーションの融通が利きやすい (スワイプ可能) AI待ちの時間が多いので、ながらで進めやすい 画面を複製する手間がない (複製すればするほど細かい調整が大変になってくる) あたりが良かったなという印象ですね。 やはりプロトタイピングは、Figma Make 系のツールで良いのではと思ってしまいました。 既存のUIがあるケース 既存のUIもうすでにFigmaにあるケースで、それのプロトタイプをしたい場合も、 FigmaMakeが強いです。 Lovableやboltなどは、Figmaの連携機能がなかったり、乏しかったりします。 (v0は課金プランで利用可能) やり方は簡単でこな感じで、Figmaから、 Send to > Figma Make とするだけです。 送信すると、テキストボックスに 該当のFigmaが入っていくので、プロンプトを入力してみます。 今回は、雑にスクロールのアニメーションを改善するという課題にしました。 この “雑に“ 投げられるというのが、かなりポイントです。 「このUIどうしようかな・・・」と思った時にとりあえず、「FigmaMakeに投げてみて、発散してみよう」みたいなことができます。 ちなみにできのたがこんな感じです。リッチなアニメーションになりました。 どんなツールを使うべきか・・・? 色々なツールがある中、今回は、Figma Make を解説してきました。 個人的には、AIでアプリケーションの精度としては、あまり差はないように思えます・・・。 そのため、周辺機能などで、利用するものをかけることが多いですね。 個人的な使い分け方 個人差ありますが、自分はこんな感じで使い分けています。 ケース① Figmaとの親和性 既存Figmaがある、最終的にFigmaファイルに落としたい → Figmaのインポートエクスポートができる(FigmaMake) ケース② DBやGitHubとの連携 個人開発くらいの場合(DBまで連携してリリースを想定) → SupabaseやGitHub連携がしやすい(v0・Lovable) ケース③ モバイルアプリ モバイルアプリを作りたい → ReactNative が書き出せる(bolt) → flutter が書き出せる(FlutterFlow) FigmaMakeにとらわれずに、いろんなツールを触ってみて、良し悪し研究していきたいですね。 まとめ 最後まで見ていただいてありがとうございました。 AIの精度もここ一年くらいで、上がってきたので、来年はどのくらいになっているのでしょうか・・・? (正直想像もしたくないくらい怖いですが。) なるべく、この波に乗るべく、色々なツールを使っていきたいところですね・・・! イベント告知 12月23日にイベントを開催します!申し込みはこちらから▼ https://mynaviit.connpass.com/event/376769
Advent Calendar(アドベント カレンダー)について Advent Calendarは、クリスマス(12/1~12/25)までの日数をカウントダウンするカレンダーのことですが、IT業界では「技術記事を日替わりで公開する企画」として定着しています。 そこで、マイナビエンジニアブログでも、明日からクリスマスまでの期間、有志のエンジニアやマネージャーが参加し、ITに関する記事を毎日1本ずつ投稿していきます。 ぜひご覧ください! 記事一覧 記事公開後、順次更新予定となっております。 日付 記事 12/1(月) Figma Make (AI) を使った新しいプロトタイピング手法 12/2(火) Coming Soon 12/3(水) Coming Soon 12/4(木) Coming Soon 12/5(金) Coming Soon 12/8(月) Coming Soon 12/9(火) Coming Soon 12/10(水) Coming Soon 12/11(木) Coming Soon 12/12(金) Coming Soon 12/15(月) Coming Soon 12/16(火) Coming Soon 12/17(水) Coming Soon 12/18(木) Coming Soon 12/19(金) Coming Soon 12/22(月) Coming Soon 12/23(火) Coming Soon 12/24(水) Coming Soon 12/25(木) Coming Soon
※こちらは、社内LT会にて発表した内容になります!マイナビではこういったLT会を定期的に開催しております! 発表資料は こちら 最近自分の身の回りや自分でも、 生成AIがやってくれるからやる気が出ない 無気力になる なんか自分で考えなくなった気がする いわゆる  「生成AI鬱」  みたいな人が増えてきたような気がしています... 最近色々試してみて若干改善してきたのでそれを共有できればなと思います。 まず初めに自分がどうだったのか とりあえず、モチベーショングラフを作ってみました。 何も無しの状態(約1年前)から現在に至るまではざっくりこんな感じ チャット系の機能で利用するようになる: 簡単なデータ整形作業とかをしてもらう AIコード保管系が出てくる: スタイル書く時にめっちゃ使って捗り始める AIエージェントを使い始める(cursor) :個人開発がめっちゃ捗る ← 最高潮 超自立型(claude code)が出る: バイブコーディングの割合が増えて徐々にやる気がなくなってくる 何も自分で考えなくなる 少しマインドを変えると捗るようになる ← 今ここ →エージェントがいい感じになり始めてから、自分で考えて手を動かすということがなくなっていきました... その中で、記事を読み漁ると 「生成AIを使うと頭が悪くなっていく問題」をどうするか? のこの画像が話題になっているのを見つけました →  今自分が陥っているのはこれだ!  となりました。 AIに指示出している間気づいたら何も考えていない... というか何もしてない... 今回はこの記事にインスピレーションを受けつつ、 「統計と研究的にどうなっているのか」 「試して良かったこと」 をまとめていこうと思います。 研究的にはどうだったのか 世界的にも、生成AIを使うと頭が悪くなる問題ついての研究があったりします。 METR(Model Evaluation & Threat Research)という非営利研究機関が2025年に行った研究で、経験豊富なオープンソース開発者にAIツールを使わせたところ、逆に19%も作業時間が長くなったのに、20%作業が早くなったと認識するという驚きの結果が出ました。 こんな感じで自立して考えられなくなる 経験豊富なオープンソース開発者にAIツールを使わせる実験 (AI要約) 経験豊富なオープンソース開発者にAIツールを使わせる実験 METR(Model Evaluation & Threat Research)という非営利研究機関が2025年に行った研究で、経験豊富なオープンソース開発者にAIツールを使わせたところ、逆に19%も作業時間が長くなったという驚きの結果が出ました。 実験の内容 対象者: 16人の経験豊富なオープンソース開発者 条件: 自分が長年携わっているプロジェクト(平均5年の経験)で246のタスクを実行 方法: 各タスクをランダムにAI使用可・不可に割り当て 使用ツール: 主にCursor Pro + Claude 3.5/3.7 Sonnet 認識と現実のギャップ 最も興味深いのは、開発者自身の認識と実際の結果に大きなギャップがあったことです: 事前の予想: AIで24%速くなると予測 事後の認識: AIで20%速くなったと感じた 実際の結果:  19%遅くなった なぜ「頭が悪くなる」と言えるのか 認知能力の誤判断 開発者は自分の生産性が向上したと錯覚していましたが、実際は低下していました。これは、AIに依存することで自分の能力を正しく評価できなくなっていることを示しています。 問題解決能力の低下 経験豊富な開発者が、慣れ親しんだプロジェクトで逆に時間がかかるようになったということは、本来持っていた問題解決能力が阻害された可能性があります。 学習効果の阻害 AIに頼ることで、本来なら経験から学習できるはずの部分が阻害される可能性があります。 考えられる原因 研究では以下の要因が特定されました: 過度な依存: AIの提案を検証・修正する時間が余計にかかる コンテキストの複雑さ: 大規模で品質要求の高いプロジェクトではAIが十分に理解できない 学習カーブ: AI支援ツールを効果的に使うには長期間の習熟が必要 品質基準の維持: 高品質なコードを書く際の暗黙的要求事項への対応 認知負荷の増加: AI出力の評価・統合に余計な脳力を消費 より広い意味での「頭が悪くなる」問題 この研究結果は、生成AIの使用が以下のような認知的な問題を引き起こす可能性を示唆しています: 本質的理解の欠如: 表面的な解決に満足してしまう 判断力の低下: 自分の能力を正確に評価できない 依存症的思考: 実際には効率が悪いのにAIに頼り続ける 批判的思考の減退: AI出力を十分に検証しない傾向 他にも面白い記事があったりするのでよければ見てみてください! 試して良かったこと 個人的に試して良かったこと2つを具体例とともに紹介していこうかなと思います。 AIに対して前のめりに接していく AIに自分がわかるまで説明してもらう ①AIに対して前のめりに接していく (丸投げ禁止) 考えることを放棄しないために、意識的にAIに切り込んでいくことを意識してプロンプトを書いてみてます。 ポイントは、 オープンクエスチョンを避けて、+aを加える  ということです。 【NG例】 ① ${エラーコピペ}を解決してください。 ② Buttonコンポーネントをよしなに作ってください。 ※ ↑カスタムインストラクションに全てを書いているとかならOK 【OK例】 ① ${エラーコピペ}を解決してください。私は、このファイルあたりのこの処理が怪しいと思っていて、**の方針で修正して欲しいです。 → 返答が早くなる。AIが暴走しにくくなる。 ② ButtonコンポーネントをBadgeのコンポーネントのような書き方で実装してください。ボタンにバリエーションはなくシンプルなProps設計にしてください → AIが迷わなくなる。余計なものを作らなくなる。 実際にやってみよう ②のようなユースケースで両者比較してみましょう。 Reactのプロジェクトを作って、サンプルでBadgeの実装をしておきました。×よしなにボタンを作ってください ×よしなにボタンを作ってください button.tsx import React from 'react';import './Button.css';interface ButtonProps { children: React.ReactNode; onClick?: () => void; variant?: 'primary' | 'secondary' | 'outline'; disabled?: boolean; className?: string;}const Button: React.FC<ButtonProps> = ({ children, onClick, variant = 'primary', disabled = false, className = '',}) => { return ( <button className={`custom-button ${variant} ${className}`} onClick={onClick} disabled={disabled} > {children} </button> );};export default Button; import React from ' react ' ; import ' ./Button.css ' ; interface ButtonProps { children : React . ReactNode ; onClick ?: () => void ; variant ?: ' primary ' | ' secondary ' | ' outline ' ; disabled ?: boolean ; className ?: string ; } const Button : React . FC < ButtonProps > = ( { children , onClick , variant = ' primary ' , disabled = false , className = '' , } ) => { return ( < button className = { ` custom-button ${ variant } ${ className }` } onClick = { onClick } disabled = { disabled } > { children } </ button > ) ; } ; export default Button ; 〇ボタンを作ってください +a プロンプト.txt ボタンコンポーネントを作成してください書き方はBadgeを参考にしてくださいPropsの中のボタンのパターンは、variant: solid,outline と color: red, yellow, blue で作成して欲しいです。 ボタンコンポーネントを作成してください 書き方はBadgeを参考にしてください Propsの中のボタンのパターンは 、variant : solid , outline と color : red , yellow , blue で作成して欲しいです 。 button.tsx import React from 'react';import './Button.css';type ButtonColor = 'red' | 'yellow' | 'blue';type ButtonVariant = 'solid' | 'outline';interface ButtonProps { children: React.ReactNode; color?: ButtonColor; variant?: ButtonVariant; onClick?: () => void; disabled?: boolean; type?: 'button' | 'submit' | 'reset';}export function Button({ children, color = 'blue', variant = 'solid', onClick, disabled = false, type = 'button',}: ButtonProps) { return ( <button className={`button ${variant} ${color}`} onClick={onClick} disabled={disabled} type={type} > {children} </button> );} import React from ' react ' ; import ' ./Button.css ' ; type ButtonColor = ' red ' | ' yellow ' | ' blue ' ; type ButtonVariant = ' solid ' | ' outline ' ; interface ButtonProps { children : React . ReactNode ; color ?: ButtonColor ; variant ?: ButtonVariant ; onClick ?: () => void ; disabled ?: boolean ; type ?: ' button ' | ' submit ' | ' reset ' ; } export function Button ({ children , color = ' blue ' , variant = ' solid ' , onClick , disabled = false , type = ' button ' , } : ButtonProps ) { return ( < button className = { ` button ${ variant } ${ color }` } onClick = { onClick } disabled = { disabled } type = { type } > { children } </ button > ) ; } どちらもBadgeを参照していますが、意図通りになったところとそうでなかったところがあります。 両者問題なかったところ →何も言わなくても、Badgeの形式と実装方法を参考にしてくれた◎ 問題があったところ →Propsの形式の意図を伝えないと、後で修正してめんどくさくなる… ×よしなにボタンを作ってくださいだと... →よしなに作ってくれるが修正が必要 variant?: 'primary' | 'secondary' | 'outline'; variant ?: ' primary ' | ' secondary ' | ' outline ' ; 〇ボタンを作ってください +a →ちゃんとPropsを伝えているので修正要らず color: 'red' | 'yellow' | 'blue'; variant: 'solid' | 'outline'; color : ' red ' | ' yellow ' | ' blue ' ; variant : ' solid ' | ' outline ' ; ちなみに今回は、Buttonという一般的(AIがよく学習してそう)なUIなので、出しやすいですが、もう少し癖があると、もっと差が出ます。 +a 本当に何もわからない場合は、プランニング(丸投げ) + 指摘 もおすすめ 先ほど共有したOK例では、ある程度内部構造や原因がわかっている程の指示でした。 しかしながら、「何が原因かわからない」「何から始めたらいいかわからない」みたいなケースもあると思うので、プランニング (丸投げ) + 指摘 もおすすめです。 【プランニング(丸投げ)】 [私] **を実装して欲しいのですが、解決策を複数案検討してくれませんか? [AI] 解決策としては、A案とB案があって...(以下略) 【指摘】 [私] A案良さそうなのでA案ベースで進めてください!A案の**の部分はもう少しシンプルな実装でいいかなと思いました。**の関数は共通で実装されているのでそれを活用してください! [AI] 対応します! (以下略) ②AIにわかるまで説明してもらう 先ほどもちらっと触れましたが、課題の解像度を上げることが良いプロンプト出すために一番必要な部分だと思います。 ですが、ネットで調べたり本で読んだり、みたいな知識の定着方法もありますが、AIのスピード感に自分の学習がついてかないみたいなケースがあるので、AIに解説してもらうっていうのもありです。 **ってなにみたいな感じで雑に聞くのもありですが、一工夫すると、AIがもっとわかりやすく説明してくれるようになります。 【ただ聞くだけ】 **ってなんですか。 【おすすめ】 **ってなんですか。友達の初心者にもわかりやすいように、例え話を交えながら解説してください。 これだけです。 ただ聞くだけだとモノによっては専門用語まみれになって、わからなくなってしまうので、初心者 + 例え話 がポイントです。 詳細はお好みで調整してください。ちなみに友達にした方が、例え話がわかりやすい気がするので加えてます。 実際にやってみよう 今回は、 ストリーミングSSR について解説してもらいました。 Webエンジニアでもギリ聞きなれない単語だと思い選びました。 ↓実際の出力 claude-4-sonet ストリーミングSSRってなんですか? ストリーミングSSR(Server-Side Rendering)は、従来のSSRを改良した技術で、サーバーサイドでレンダリングしたHTMLコンテンツを段階的にブラウザに送信する手法です。 従来のSSRとの違い 従来のSSRでは、サーバーですべてのHTMLを生成し終わってから一度にブラウザに送信していました。 ストリーミングSSRでは、HTMLの生成が完了した部分から順次ブラウザに送信します。これにより、ユーザーは完全なページの読み込みを待つ必要がなく、コンテンツを段階的に見ることができます。 主な利点 初期表示の高速化: ページの一部が早期に表示される 体感パフォーマンスの向上: ユーザーが待ち時間を感じにくい SEOフレンドリー: 検索エンジンが段階的にコンテンツをクロールできる リソース効率: メモリ使用量の最適化 実装例 React 18のSuspenseを使った例: jsxfunction App() { return ( <html> <body> <Header /> {/* すぐに送信 */} <Suspense fallback={<Spinner />}> <SlowComponent /> {/* 準備できたら送信 */} </Suspense> <Footer /> {/* すぐに送信 */} </body> </html> );} jsxfunction App () { return ( < html > < body > < Header /> { /* すぐに送信 */ } < Suspense fallback = { <Spinner /> } > < SlowComponent /> { /* 準備できたら送信 */ } </ Suspense > < Footer /> { /* すぐに送信 */ } </ body > </ html > ) ; } 対応フレームワーク React: React 18のSuspenseとrenderToPipeableStream Next.js: App Routerでデフォルト対応 Remix: 標準でサポート SvelteKit: ストリーミング機能あり ストリーミングSSRは、特にデータ取得に時間がかかるアプリケーションで、ユーザー体験を大幅に改善できる技術です。 ストリーミングSSRってなんですか?初心者の友達に教えるように、例とか使いながらおしえて ストリーミングSSRについて、友達に説明するみたいに話すね! まず、SSRって何? SSR(Server-Side Rendering)っていうのは、サーバー側でHTMLを作ってからブラウザに送る仕組みのこと。普通のReactアプリ(CSR:Client-Side Rendering)だと、ブラウザが空っぽのHTMLを受け取ってから、JavaScriptでページを組み立てるんだけど、SSRはサーバーで最初からHTMLを完成させちゃう。 例えば: CSR: 「空の箱を送るから、中身は君が組み立ててね」 SSR: 「完成品の箱を送るよ、すぐ使えるよ」 じゃあ、ストリーミングSSRって? 従来のSSRは、ページ全体が完成するまで待ってから一気にブラウザに送ってた。でもストリーミングSSRは、できた部分から順番に送ってくれるんだ。 料理で例えると: 従来のSSR: 全ての料理が完成してから一気にテーブルに運ぶ ストリーミングSSR: できた料理から順番にテーブルに運ぶ 具体例で見てみよう ブログページを表示する場合: ヘッダー(すぐできる)→ 即座に表示 ナビゲーション(すぐできる)→ 即座に表示 記事本文(データベースから取得中...)→ 少し待つ コメント欄(API呼び出し中...)→ さらに少し待つ 関連記事(推薦システムから取得中...)→ 最後に表示 従来だと全部揃うまで真っ白な画面だったけど、ストリーミングなら上から順番に表示されていく! メリット 体感速度が速い:ユーザーがすぐに何かを見られる SEO効果:検索エンジンもすぐに内容を読める ユーザー体験向上:待ってる感じが少ない 実際の技術例 React 18のSuspense + streaming Next.js 13+のApp Router Remix こんな感じで、ストリーミングSSRは「待たせない優しい技術」って覚えておくといいよ!ユーザーが退屈しないように、できたところから見せてくれるんだ。 何か他に気になることある? 【ただ聞くだけの場合】 ノーマルの場合だと、Reactがちゃんとわかっていれば、なんとなく理解できる感じだと思います。 【おすすめ】 ただ聞くだけよりもかなりわかりやすくなったんじゃないでしょうか...? まず、SSRって何? おすすめのプロンプトの方だと、まずそもそも SSRとは何かを解説してくれてますね。 料理で例えると: 従来のSSR: 全ての料理が完成してから一気にテーブルに運ぶ ストリーミングSSR: できた料理から順番にテーブルに運ぶ HTMLの分割送信をするみたいなのを料理に例えて解説しているみたいです 具体例で見てみよう ブログページを表示する場合: 料理で説明した上で実際のWebサイトではどうなのかみたいなところも書いてあってよりわかりやすいですね。 試してみてどうだったのか 生成AI鬱の状態では、 受け身で解題の解像度が低く ふわっとしたプロンプトを書く ふわっとした結果が返ってくる AIを使う技術が身につかない + 知識もつかない AIを使いこなせると、 前のめりでで解題の解像度が高いから 具体的なプロンプトを書く イメージに近い結果が返ってくる AIを使う技術が身につく + 知識もつく 最後に 最後まで見ていただきありがとうございました。 「実際こんなの当たり前にやっているよ」「今更ですか」みたいなところもあると思いますが、気づいたら何も考えてないみたいなこともあったりするので、もしそうなった方の一助になれればと思います! 参考 インスピレーション元:  「生成AIを使うと頭が悪くなっていく問題」をどうするか?
こんな “結合テストの悩み”、ありませんか? 古典的な Docker 運用(docker-compose 等・固定ポート)だと並列しづらく、逐次実行で遅い mock は本番相当から遠く、信頼しきれない(mockがグリーンでも本番で落ちる) ローカルとCIの環境差やポート競合でフレークが多く、再現性が低い そこで、 testcontainers-go 導入企業:Spotify、 Intel、Shopify、ElasticSearch、OpenTelemetry、Netflix、Uber 対応言語: Java、Go、.NET、Node.js、Python、Rust、Haskell、Ruby 対応ツール:PostgreSQL、MySQL、 ...etc TL;DR 時間がない方へ:結論 testcontainers-go はテストコード(Go)だけで完結。YAMLや手動のDocker操作なしで、起動・待機・破棄を一気通貫 mockはユニットには有効だが、統合の信頼性は落ちやすい。結合テストは実コンポーネントで検証しよう Docker Compose等でダミー環境を手作りするより、ライフサイクル管理・待機戦略・並列実行・CI統合に強い 根本をtestcontainersで、再現しにくいエラーはmockで、ハイブリッドが最も有効 Testcontainers Testcontainersとは、データベース、メッセージブローカー、ウェブブラウザ、あるいはDockerコンテナ内で実行可能なほぼあらゆるものの使い捨てで軽量なインスタンスを提供するオープンソースライブラリです。 テストコードから「必要な依存ミドルウェアのコンテナ」をオンデマンドで立ち上げ、準備完了まで待って、テスト終了時に自動破棄する仕組み(ライブラリ)です。mockでは埋まらない本番差分(DB・ネットワーク・シリアライズなど)を、実コンテナで素早く再現できます。 Testcontainers vs Gomock vs 古典的Dockerコンテナ 従来の手法と比較してみました。 観点 testcontainers-go Gomock 古典的dockerコンテナ 本番近さ・信頼性 実コンテナで高い。ネットワーク/シリアライズ差も拾える 低い。本番差を取りこぼしやすい 高いが、人手運用や待機ズレで事故が出やすい 速度・並列性 動的ポートで高並列。CI時間30〜70%短縮の報告(例: 14→5分) 単体は最速だが結合価値は低い 並列化が難しく逐次実行になりがちで遅い 待機・安定性 待機戦略で2〜5秒/サービスに収束。フレーク40〜80%減 待機不要だが現実差を検出しにくい 固定sleep/順序依存でフレーク多め セットアップ/破棄 テストコードで自動起動・自動クリーンアップ 低コスト(mock定義のみ) YAML管理・手動起動/停止・掃除が必要 デバッグ性 起動ログ/ヘルスチェックをテストから取得しやすい 再現しないバグが多く原因特定が難しい ログ収集や再現が手間 LK2でのTestcontainers-go実装 自分が参加しているPJ、LK(営業さん向けサービス)では、testcontainers-goをスモークテストとして導入 スモーク(Smoke test) ソフトウェアが起動し、基本的な機能が動作するかどうかを迅速に確認する予備的なテストです。 本格的なテストを行う前に、システムに深刻な不具合(ブロッキングバグ)がないことを確認し、テスト工程全体の効率を上げることを目的としています。電気製品で電源投入時に煙が出ないかを確認するテストに由来し、ソフトウェアにおいては「テストするに値するか」を判断するのに役立ちます LK2での実装 フォルダ構成 /testutils /testcontainers.go / testutils / testcontainers . go コンテナを設定するコード ```golang// testcontainers.go ```type MySQLContainer struct {Container testcontainers.ContainerDB *gorm.DBDSN string}func SetupMySQLContainer(ctx context.Context) (*MySQLContainer, error) {currentDir, err := os.Getwd()if err != nil {return nil, fmt.Errorf("failed to get current directory: %w", err)}backendDir := filepath.Join(currentDir, "..", "..", "..")// Load configuration from backend folderconfig, err := configs.LoadFromPath(backendDir)if err != nil {return nil, fmt.Errorf("failed to load config: %w", err)}req := testcontainers.ContainerRequest{Image: "mysql:8.0.32",Env: map[string]string{"MYSQL_ROOT_PASSWORD": config.TestDB.Password,"MYSQL_DATABASE": config.TestDB.Name,"MYSQL_USER": config.TestDB.User,"MYSQL_PASSWORD": config.TestDB.Password,},ExposedPorts: []string{config.TestDB.Port},WaitingFor: wait.ForLog("port: 3306 MySQL Community Server").WithStartupTimeout(90 * time.Second),}container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req,Started: true,})if err != nil {return nil, err}mappedPort, err := container.MappedPort(ctx, "3306")if err != nil {return nil, err}hostIP, err := container.Host(ctx)if err != nil {return nil, err}dsn := fmt.Sprintf("dsn作成")return &MySQLContainer{Container: container,DB: nil,DSN: dsn,}, nil}``` ``` golang // testcontainers.go ``` type MySQLContainer struct { Container testcontainers . Container DB * gorm . DB DSN string } func SetupMySQLContainer ( ctx context . Context ) ( * MySQLContainer , error ) { currentDir , err := os . Getwd () if err != nil { return nil , fmt . Errorf ( " failed to get current directory: %w " , err ) } backendDir := filepath . Join ( currentDir , " .. " , " .. " , " .. " ) // Load configuration from backend folder config , err := configs . LoadFromPath ( backendDir ) if err != nil { return nil , fmt . Errorf ( " failed to load config: %w " , err ) } req := testcontainers . ContainerRequest { Image : " mysql:8.0.32 " , Env : map [ string ] string { " MYSQL_ROOT_PASSWORD " : config . TestDB . Password , " MYSQL_DATABASE " : config . TestDB . Name , " MYSQL_USER " : config . TestDB . User , " MYSQL_PASSWORD " : config . TestDB . Password , }, ExposedPorts : [] string { config . TestDB . Port }, WaitingFor : wait . ForLog ( " port: 3306 MySQL Community Server " ). WithStartupTimeout ( 90 * time . Second ), } container , err := testcontainers . GenericContainer ( ctx , testcontainers . GenericContainerRequest { ContainerRequest : req , Started : true , }) if err != nil { return nil , err } mappedPort , err := container . MappedPort ( ctx , " 3306 " ) if err != nil { return nil , err } hostIP , err := container . Host ( ctx ) if err != nil { return nil , err } dsn := fmt . Sprintf ( " dsn作成 " ) return & MySQLContainer { Container : container , DB : nil , DSN : dsn , }, nil } ``` テストコード ```golang```var (mysqlContainer *testutils.MySQLContainermodel models.CallStatuserr error)type expectedResult struct {statusCode intisErr bool}func TestMain(m *testing.M) {ctx := context.Background()// コンテナの立ち上げmysqlContainer, err = testutils.SetupMySQLContainer(ctx)if err! = nil {log.Fatalf("Failed to setup MySQL container: %v", err)}// DBのマイグレーションerr = mysqlContainer.MigrateDB(&model)if err! = nil {log.Fatalf("Failed to initialize DB: %v", err)}defer func() {err = mysqlContainer.Terminate(ctx)if err != nil {log.Fatalf("Failed to terminate MySQL container: %v", err)}}()m.Run()}func NewTestEcho(db *gorm.DB) *echo.Echo {repo := callstatusInfra.NewRepository(db)usecase := callstatusUsecase.NewUseCase(repo)callstatusController := controller.NewCallStatusController(usecase)e := restecho.CreateMux()controller.InitRouting(e, controller.Controllers{CallStatusController: callstatusController,})return e}func Test_GetAllCallStatuses(t *testing.T) {testCases := []struct {name stringseed []*models.CallStatustable testutils.WithTypeexpected expectedResult}{{name: "【200】架電ステータス一覧取得",table: testutils.WithSeed,seed: callstatus.GenerateRandomSeeds(5),expected: expectedResult{statusCode: http.StatusOK, isErr: false},},{name: "【200】架電ステータス(データなし)",table: testutils.WithEmpty,seed: callstatus.GenerateRandomSeeds(0),expected: expectedResult{statusCode: http.StatusOK, isErr: false},},{name: "【200】架電ステータス(1個のみ)",table: testutils.WithSeed,seed: callstatus.GenerateRandomSeeds(1),expected: expectedResult{statusCode: http.StatusOK, isErr: false},},{name: "【500】DB接続エラー",table: testutils.WithNoTable,seed: nil,expected: expectedResult{statusCode: http.StatusInternalServerError, isErr: true},},}for _, tc := range testCases {t.Run(tc.name, func(t *testing.T) {db := testutils.CreateNewDB(t, mysqlContainer, tc.table, model, tc.seed)e := NewTestEcho(db)recorder := httptest.NewRecorder()request := callstatus.NewJSONRequest(t, http.MethodGet, URL, nil)e.ServeHTTP(recorder, request)require.Equal(t, "application/json", recorder.Header().Get("Content-Type"))require.Equal(t, tc.expected.statusCode, recorder.Code)if tc.expected.isErr {// ★ JSONデータの確認var responseData map[string]interface{}err := json.Unmarshal(recorder.Body.Bytes(), &responseData)require.NoError(t, err)if len(responseData) > 0 {require.Contains(t, responseData, "code")require.Contains(t, responseData, "message")}} else {// ★ JSONデータの確認var responseData []map[string]interface{}err := json.Unmarshal(recorder.Body.Bytes(), &responseData)require.NoError(t, err)// ★ データ件数の確認require.Equal(t, len(tc.seed), len(responseData))if len(responseData) > 0 {require.Contains(t, responseData[0], "id")require.Contains(t, responseData[0], "name")require.Contains(t, responseData[0], "order")require.Contains(t, responseData[0], "createdAt")require.Contains(t, responseData[0], "updatedAt")require.Contains(t, responseData[0], "deletedAt")}}})}}``` ``` golang ``` var ( mysqlContainer * testutils . MySQLContainer model models . CallStatus err error ) type expectedResult struct { statusCode int isErr bool } func TestMain ( m * testing . M ) { ctx := context . Background () // コンテナの立ち上げ mysqlContainer , err = testutils . SetupMySQLContainer ( ctx ) if err ! = nil { log . Fatalf ( " Failed to setup MySQL container: %v " , err ) } // DBのマイグレーション err = mysqlContainer . MigrateDB ( & model ) if err ! = nil { log . Fatalf ( " Failed to initialize DB: %v " , err ) } defer func () { err = mysqlContainer . Terminate ( ctx ) if err != nil { log . Fatalf ( " Failed to terminate MySQL container: %v " , err ) } }() m . Run () } func NewTestEcho ( db * gorm . DB ) * echo . Echo { repo := callstatusInfra . NewRepository ( db ) usecase := callstatusUsecase . NewUseCase ( repo ) callstatusController := controller . NewCallStatusController ( usecase ) e := restecho . CreateMux () controller . InitRouting ( e , controller . Controllers { CallStatusController : callstatusController , }) return e } func Test_GetAllCallStatuses ( t * testing . T ) { testCases := [] struct { name string seed [] * models . CallStatus table testutils . WithType expected expectedResult }{ { name : " 【200】架電ステータス一覧取得 " , table : testutils . WithSeed , seed : callstatus . GenerateRandomSeeds ( 5 ), expected : expectedResult { statusCode : http . StatusOK , isErr : false }, }, { name : " 【200】架電ステータス(データなし) " , table : testutils . WithEmpty , seed : callstatus . GenerateRandomSeeds ( 0 ), expected : expectedResult { statusCode : http . StatusOK , isErr : false }, }, { name : " 【200】架電ステータス(1個のみ) " , table : testutils . WithSeed , seed : callstatus . GenerateRandomSeeds ( 1 ), expected : expectedResult { statusCode : http . StatusOK , isErr : false }, }, { name : " 【500】DB接続エラー " , table : testutils . WithNoTable , seed : nil , expected : expectedResult { statusCode : http . StatusInternalServerError , isErr : true }, }, } for _ , tc := range testCases { t . Run ( tc . name , func ( t * testing . T ) { db := testutils . CreateNewDB ( t , mysqlContainer , tc . table , model , tc . seed ) e := NewTestEcho ( db ) recorder := httptest . NewRecorder () request := callstatus . NewJSONRequest ( t , http . MethodGet , URL , nil ) e . ServeHTTP ( recorder , request ) require . Equal ( t , " application/json " , recorder . Header (). Get ( " Content-Type " )) require . Equal ( t , tc . expected . statusCode , recorder . Code ) if tc . expected . isErr { // ★ JSONデータの確認 var responseData map [ string ] interface {} err := json . Unmarshal ( recorder . Body . Bytes (), & responseData ) require . NoError ( t , err ) if len ( responseData ) > 0 { require . Contains ( t , responseData , " code " ) require . Contains ( t , responseData , " message " ) } } else { // ★ JSONデータの確認 var responseData [] map [ string ] interface {} err := json . Unmarshal ( recorder . Body . Bytes (), & responseData ) require . NoError ( t , err ) // ★ データ件数の確認 require . Equal ( t , len ( tc . seed ), len ( responseData )) if len ( responseData ) > 0 { require . Contains ( t , responseData [ 0 ], " id " ) require . Contains ( t , responseData [ 0 ], " name " ) require . Contains ( t , responseData [ 0 ], " order " ) require . Contains ( t , responseData [ 0 ], " createdAt " ) require . Contains ( t , responseData [ 0 ], " updatedAt " ) require . Contains ( t , responseData [ 0 ], " deletedAt " ) } } }) } } ``` まとめ Testcontainersは、mockの速さと本番近さの「いいとこ取り」を、テストコードからの動的起動・確実な待機・自動破棄で実現する実践的な選択肢です。 ユニットはMockで素早く、結合はTestcontainersで確かに、長寿命の手動環境やE2EはCompose等に限定するのが合理的な使い分けです。 Docker必須・初回pullなどの前提はあるものの、並列実行と待機戦略を組み込めば、CI時間短縮、フレーク減、ローカル=CIの再現性向上が期待できます。 Happy coding!!
はじめに 皆さんこんにちは!クラウドエンジニアリング部のS.Mです。 AIの急速な発展とともにクラウド技術の重要性がますます高まっている昨今、当社でも多くのエンジニアの皆さんが次のような悩みを抱えていらっしゃいました。 「AWSを実務レベルでしっかり学びたいが、実サービスを触るわけにはいかず悩んでいる」 「個人アカウントではリソ-ス削除漏れによる費用請求が不安」 このような悩みを抱えている方々のために、私たちが社内に正式リリースしたAWS Sandbox完全自動化システムの実際の実装事例をご紹介したいと思います。 Sandboxとは ? AWS Sandbox環境は、個人またはチームがAWSサービスを学習し、実験できる空間を提供します。 各ユーザーは個人の金銭的費用負担なく、一定の期間・金額の範囲で多様なAWSサービスを自由に触ることができます。 つまり、砂の城のように簡単に作って簡単に消すことができるAWS学習および開発環境です! 使用例 個人の学習・ハンズオンに使う AWSの新しいサービス・機能を試す 検証のためのサーバーやコンテナ環境を作る 特徴 月額$50まで自己負担なし リージョン制限 基本的なサービスが一通り使える(一部の高額なサービスは制限している) xlarge以上インスタンスタイプ禁止 なぜ独自システムを構築したのか? 読者の皆さんの中には「AWSが2025年5月に Innovation Sandbox on AWS という公式ソリューションを提供しているのに、なぜ独自開発したのか?」と疑問に思われる方もいらっしゃるかもしれません。 実は、私たちは2024年3月にこのシステムを社内に正式リリースしており、AWSの公式ソリューションが登場する約1年前から独自のSandbox環境を運用していました。当時はこのような包括的なソリューションが存在しなかったため、社内のニーズ(Microsoft Forms連携、既存の承認ワークフロー統合)に合わせて一から設計・構築する必要があったのです。 AWS Sandbox自動化システムを構築した話 上記の通り2024年3月から運用を行っておりますが、リリースの時点から段階的に運用の自動化を行い、現在はほぼすべての運用を自動化できています。 以下、この自動化を中心とした構築内容について紹介します。 既存環境の限界 AWS学習と実習環境構築には、次のような根本的な問題がありました。 複雑な手動プロセス: アカウント作成から権限設定まで数日を要し、運用負担となっていた 分散したセキュリティポリシー:個別アカウントごとの異なるセキュリティ設定によるリスク 予測不可能なコスト:個別ユーザーのリソース放置による予想外の課金 これらの課題を解決するため、私たちは 「最初から最後まで人の介入なし」 をモットーに完全自動化システムを設計しました。 【ポイント1】 完全自動化されたアカウント作成ワークフロー 私たちのシステムの最大の特徴は 完全自動化されたアカウントプロビジョニング です。  Microsoft Forms 利用申請  ↓  Step Functions起動  ↓  社内承認システム連携(ワークフローベース承認)  ↓  Step Functions 後続処理  ↓  AWSアカウント作成 + 権限設定  ↓  利用者に作成完了メールを送信 ※ 運用担当は承認の1クリックをするだけでアカウント作成が完了します! StepFunctionsベースワークフロー // 承認/拒否による自動分岐処理const definition = applicationHandlerTask.next( choice .when( sfn.Condition.stringEquals('$.application_lambda_result.result', 'approve'), accountManagementHandlerTask.next(sendgridExecuteHandlerTaskApprove) ) .when( sfn.Condition.stringEquals('$.application_lambda_result.result', 'reject'), sendmailExecuteHandlerTaskReject )); Lambdaマイクロサービス構造 申請リクエスト用Lambda:Microsoft Formsデータ処理および社内承認システム連携 Account作成用Lambda:AWS Organizationsアカウント作成およびIAM権限設定 申請者にメール送信用Lambda:自動メール通知システム 承認リクエスト用Lambda:API Gatewayを通じた承認フロー処理 【ポイント2】AWS Organizations基盤アカウントライフサイクル管理 体系的なOU構造となるように設計しています。 Root Organization├── Master OU(組織管理アカウント)├── All OU │ ├── Management OU(運用アカウント群)│ │ ├── Audit Account(監査・ログ集約)│ │ ├── StackSets Account(デプロイ管理) │ │ └── SSO Account(IAM Identity Center)│ └── Main OU(アクティブSandboxアカウント群)└── Separation OU(アカウント閉鎖管理) ├── Closed OU(閉鎖完了) └── Nuke OU(リソース削除実行) 【ポイント3】自動化されたコスト管理システム コスト管理は以下のように閾値を設けて自動化しています。 1段階 - 予防的警告(80%到達) AWS Budgetsベース実際コストモニタリング 予算対比80%到達時自動メールアラート 予測コスト100%超過時早期警告送信 2段階 - 自動リソース制御(90%到達) SNS Topicベース自動アラートシステム EC2インスタンス自動停止(データ保存) RDSインスタンスおよびクラスター自動停止 管理アカウントからCross-Account Roleを通じたリモート制御 3段階 - アカウント隔離(100%超過) SNS Topicを通じた自動アカウント閉鎖プロセス開始 Separation OUに自動移動 新しいリソース作成完全遮断 既存リソースは読み取り専用で保存 【ポイント4】予想外の課金防止 個別ユーザーのリソース放置や過剰な利用にによる予想外の課金は防止されています。 Service Control Policy(SCP)ベースポリシー制限 # リージョン制限ポリシー例- Effect: Deny NotAction: ["chatbot:", "iam:PassRole"] Resource: "" Condition: StringNotEquals: aws:RequestedRegion: ["ap-northeast-1", "us-east-1"]] ※ 全アカウント共通制限 Sandboxアカウント専用制限 xlarge以上インスタンスタイプ禁止(予算$50保護) 高コストサービス制限(予算超過防止) AIサービス安全制御 AWS Bedrock等AIサービスオプトアウトポリシー適用 学習データ流出防止のための自動オプトアウト設定 実際の運用成果について システムリリース後定量的成果として運用効率性とセキュリティ面で以下のように向上されました。 運用効率性 アカウント作成時間: 短縮(完全自動化) 運用業務自動化率: 自動化適用 設定エラー: 標準化による減少 コストオーバーラン: 予算管理システムで防止 セキュリティ ポリシー一貫性: 標準化適用 コンプライアンススコア: 向上 セキュリティ事故: 安定運用維持 そして技術文化については以下のように変化しました。 IaC(Infrastructure as Code)基盤文化導入 すべてのインフラ変更のコードレビュー義務化 CDK TypeScriptベースタイプセーフなインフラ管理 Gitベースインフラ変更履歴完全追跡 自動化優先思考方式 Manual Last: 手動作業を最後の手段として考慮 Fail Fast: 迅速な失敗を通じた素早い学習 Observability: すべてのシステムの完全な可視性 おわりに フロントエンド、バックエンド、モバイル開発など、どの分野のエンジニアでも、現代のアプリケーション開発にはクラウドの知識が不可欠です。 私たちのSandbox環境では、個人専用のAWSアカウントで安全にクラウドサービスを学習でき、実際のプロジェクトで活用できるスキルを身につけることができます。 インフラの専門知識がなくても大丈夫です! S3でのファイル保存、Lambda関数の作成、RDSでのデータベース構築など、アプリケーション開発に直結するAWSサービスから始めて、段階的にクラウドスキルを向上させていく事ができます。 マイナビには資格取得支援制度があるため、Sandboxを活用することで、自己負担なしでAWSを触って勉強し、さらに資格取得まで行うことができます! 私たちと一緒に成長したいエンジニアの皆さんをいつでも歓迎します。
ビジネスイノベーション統括本部ITディベロップメント第2統括部のM.Tです。 9月に入りましたがまだまだ暑くて残暑が厳しい季節です。 体調管理には気を付けてください。 突然ですがJava界隈で「BoxLang」という言語が話題になっていました。 2025年5月に最初の安定版となるv1.0.0がリリースされたようです。 若いですね。ひよっこですね。 2025年9月現在、v1.5.0がLatestバージョンになっています。 BoxLangについてCopilotに聞いてみました。 BoxLangって何なの? BoxLangは、Ortus Solutionsが開発したJVMベースのモダンなスクリプト言語です。ColdFusionの代替としても注目されており、軽量かつ高機能な開発環境を提供します。 公式サイト https://www.boxlang.io/ BoxLangの特徴を教えて マルチプラットフォーム対応 Windows、macOS、Linux、Docker、サーバーレス(AWS Lambdaなど)、WebAssembly、Android/iOSなど、幅広い環境で動作可能 Javaとの高い互換性 Javaライブラリをそのまま利用でき、既存のJava資産を活かした開発が可能 モダンで柔軟な構文 PythonやRuby、Goなどの言語の良いところを取り入れた直感的で表現力の高い構文 軽量で高速 ランタイムサイズはわずか6MB。 モジュール構造で必要な機能だけを取り込めるため、効率的な開発が可能 ColdFusionとの互換性 既存のCFMLコードを変更なしで実行可能。ColdFusionやLuceeからの移行がスムーズ 開発ツールが充実 VSCode対応IDE拡張、CLIツール(CommandBox)、バージョン管理ツール(BVM)など、開発を支援するツールが豊富 うん、良いことしか言わないですね。 主要言語の良いとこ取りなのであれば最強言語と言えるのでは? そこまで言うんだったら実際に触ってみましょう。 BoxLangをインストールしてみる Window環境でインストールしてみました。 JVMで実行される言語なのでJREが必要です。 もちろん普段からJava開発している民はJDKが入っていると思うのでインストール不要です。 ただしJRE 21が必要なのでレガシーバージョンで開発している方は大変ですね。 PowerShellを管理者として実行後、以下のコマンドを実行してJRE 21をインストールします。 powershell -NoExit -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://downloads.ortussolutions.com/ortussolutions/boxlang-quick-installer/helpers/install-jre.ps1'))" powershell - NoExit - Command " iex ((New-Object System.Net.WebClient).DownloadString('https://downloads.ortussolutions.com/ortussolutions/boxlang-quick-installer/helpers/install-jre.ps1')) " インストールが完了したら以下のコマンドを実行してBoxLangをインストールします。 powershell -NoExit -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://install-windows.boxlang.io'))" powershell - NoExit - Command " iex ((New-Object System.Net.WebClient).DownloadString('https://install-windows.boxlang.io')) " 途中でCommandBoxをインストールするか聞かれます。CLIを利用する場合は「Y」を入力します。 Would you like to install CommandBox? [Y/n]:Y Would you like to install CommandBox ? [ Y / n ] : Y PATHを追加するか聞かれるので「Y」を入力します。 Would you like to automatically add c:\boxlang\bin to your PATH? [Y/n]:Y Would you like to automatically add c : \ boxlang \ bin to your PATH ? [ Y / n ] : Y 無事にインストールが完了しました。 簡単ですね。 BoxLangを実行してみる REPLを起動してみます。 C:\Users\user>boxlang?????? ?????? ?? ?? ?? ????? ??? ?? ???????? ?? ?? ?? ?? ?? ?? ?? ?? ???? ?? ???????? ?? ?? ??? ?? ??????? ?? ?? ?? ?? ????? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ???????? ?????? ?? ?? ??????? ?? ?? ?? ???? ??????Enter an expression, then hit enterPress Ctrl-C to exit C : \ Users \ user > boxlang ?????? ?????? ?? ?? ?? ????? ??? ?? ?????? ?? ?? ?? ?? ?? ?? ?? ?? ?? ???? ?? ?? ?????? ?? ?? ??? ?? ??????? ?? ?? ?? ?? ??? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?????? ?????? ?? ?? ??????? ?? ?? ?? ???? ?????? Enter an expression , then hit enter Press Ctrl - C to exit ??? めっちゃ文字化けしてると思いきや「BOXLANG」と表現されてます シンプル足し算 BoxLang> 2+24 BoxLang > 2 + 2 4 日付フォーマット BoxLang> dateFormat( now(), "full" )2025年9月4日木曜日 BoxLang > dateFormat ( now () , " full " ) 2025 年9月4日木曜日 変数代入 BoxLang> a=33BoxLang> b=55BoxLang> a+b8 BoxLang > a = 3 3 BoxLang > b = 5 5 BoxLang > a + b 8 文字列反転 BoxLang> str = "BOXLANG"BOXLANGBoxLang> str.reverse()GNALXOB BoxLang > str = " BOXLANG " BOXLANG BoxLang > str . reverse () GNALXOB 配列 BoxLang> nums = [1, 2, 3, 4, 5][1, 2, 3, 4, 5]BoxLang> nums.map(n -> n * 2)[2, 4, 6, 8, 10]BoxLang> nums.filter(n -> n % 2 == 0)[2, 4] BoxLang > nums = [ 1 , 2 , 3 , 4 , 5 ] [ 1 , 2 , 3 , 4 , 5 ] BoxLang > nums . map ( n -> n * 2 ) [ 2 , 4 , 6 , 8 , 10 ] BoxLang > nums . filter ( n -> n % 2 == 0 ) [ 2 , 4 ] みんな大好きハローワールド BoxLang> println( "Hello World!" )Hello World! BoxLang > println ( " Hello World! " ) Hello World ! プログラム構造を知る BoxLang は次の 3 種類のファイルで記述できます。 スクリプト(*.bxs、または互換モードでは *.cfs) テンプレート(*.bxm、または互換モードでは *.cfm) クラス(*.bx または互換モードでは *.cfc) Classes have a .bx extension and can be executed via the CLI if they have a main() method by convention. (クラスは.bx拡張子を持ち、慣例としてmain()メソッドを持つ場合、CLI経由で実行可能です。) スクリプトファイルを実行してみます。 拡張子*.bxがmain()メソッドを持つBoxLangクラスのようなのでファイルを用意します。 class { function main( args = [ ] ) { println( "Hola from my task! #now()#" ); println( "The passed args are: " ); println( args ); }} class { function main ( args = [ ] ) { println ( " Hola from my task! #now()# " ) ; println ( " The passed args are: " ) ; println ( args ) ; } } 絶対パスを指定してファイルを実行します。 C:\Users\user>boxlang C:\Users\user\Desktop\task.bx 毎日 暑くて 汗だくだくHola from my task! {ts '2025-09-04 19:44:31'}The passed args are:[ 毎日, 暑くて, 汗だくだく] C : \ Users \ user > boxlang C : \ Users \ user \ Desktop \ task . bx 毎日 暑くて 汗だくだく Hola from my task ! { ts ' 2025-09-04 19:44:31 ' } The passed args are : [ 毎日 , 暑くて , 汗だくだく ] 実行できました。 お父さんのジョークスクリプト 公式ドキュメントにあったジョークスクリプトを最後に紹介します。 Thanks to our evangelist Raymond Camden, we have a cool dad joke script you can find in our 私たちの伝道師 Raymond Camden のおかげで、デモで見つけられるクールなお父さんジョークのスクリプトができました class { variables.apiURL = "https://icanhazdadjoke.com/"; /** * The first argument is a term to search dad jokes on, if not provided, a random dad joke will be fetched. * Example: boxlang DadJoke.bx dad * Example: boxlang DadJoke.bx */ function main( args = [ ] ) { // Use elvis operator to check if a term was passed, else, use an empty string var term = ( args[ 1 ] ?: "" ).trim( ); if ( !term.isEmpty( ) ) { apiURL &= "search?term=" & term.urlEncodedFormat( ); } println( "Getting dad joke for term [#term#], please wait..." ); bx:http url=apiURL result="result" { bx:httpparam type="header" name="Accept" value="application/json"; } var data = JSONDeserialize( result.fileContent ); // possible none were found, use safe navigation operator if ( data?.results?.len( ) == 0 ) { println( "No jokes found for term: #term#" ); return cliExit( ); } // If we searched for a term, we need to get a random joke from the results, otherwise, just .joke var joke = term.isEmpty( ) ? data.joke : data.results[ randRange( 1, data.results.len( ) ) ].joke; println( joke ); }} class { variables . apiURL = " https://icanhazdadjoke.com/ " ; /** * The first argument is a term to search dad jokes on, if not provided, a random dad joke will be fetched. * Example: boxlang DadJoke.bx dad * Example: boxlang DadJoke.bx */ function main ( args = [ ] ) { // Use elvis operator to check if a term was passed, else, use an empty string var term = ( args [ 1 ] ?: "" ) . trim ( ) ; if ( ! term . isEmpty ( ) ) { apiURL &= " search?term= " & term . urlEncodedFormat ( ) ; } println ( " Getting dad joke for term [#term#], please wait... " ) ; bx : http url = apiURL result = " result " { bx : httpparam type = " header " name = " Accept " value = " application/json " ; } var data = JSONDeserialize ( result . fileContent ) ; // possible none were found, use safe navigation operator if ( data ?. results ?. len ( ) == 0 ) { println ( " No jokes found for term: #term# " ) ; return cliExit ( ) ; } // If we searched for a term, we need to get a random joke from the results, otherwise, just .joke var joke = term . isEmpty ( ) ? data . joke : data . results [ randRange ( 1 , data . results . len ( ) ) ] . joke ; println ( joke ) ; } } 実行してみました。 C:\Users\user>boxlang C:\Users\user\Desktop\DadJoke.bxGetting dad joke for term [], please wait...Why can't your nose be 12 inches long? Because then it'd be a foot! C : \ Users \ user > boxlang C : \ Users \ user \ Desktop \ DadJoke . bx Getting dad joke for term [] , please wait ... Why can ' t your nose be 12 inches long? Because then it ' d be a foot ! Why can't your nose be 12 inches long? Because then it'd be a foot! なぜ君の鼻は12インチ(約30cm)も長くなれないんだ?だってそうなったら1フィート(約30cm)になっちゃうからさ! ちょっと理解できなかったのでCopilotに聞いてみました。 このジョークは 英語の「foot(フィート)」という単語の二重の意味を利用した言葉遊び(ダジャレ)です。 「12インチ」=「1フィート」(長さの単位) 「foot」=「足」(体の部位) つまり、「鼻が12インチ(約30cm)になったら、それは“1フィート”になる」というのは、 「鼻が“足”になっちゃうよ!」 という言葉のひっかけになっていて、意味が変わってしまうのが面白いポイントです。 うん、面白いですね(チョットムズカシイ) まとめ 今回はインストールからの簡単なスクリプト実行まで体験してみました。 構文は分かりやすくて初学者でもすんなり入り込める言語のような印象です。 まだまだ生まれたての言語ということですが、Javaとの互換性があるので今後大きく伸びていく可能性を秘めいています。 コミュニティも盛り上がっていけば近い将来普通に使われる言語かもしれません。 今回はこんな言語もあるんだよっていうのだけでも覚えていってもらえれば光栄です。
Google Cloud Next Tokyo '25 に登壇しました! 2025/8/5から2日間にわたって開催されたGoogleのクラウドテックカンファレンスである Google Cloud Next Tokyo '25  に、マイナビから4名、セッション登壇者として参加しました。 改めまして、ご来場くださった皆様、本当にありがとうございました。 マイナビエンジニアブログ/イベント登壇情報: 【イベント登壇のお知らせ】8/5~8/6開催/Google Cloud Next Tokyo 2025に登壇します | マイナビエンジニアブログ 当日の様子 こちらは言うもさらなりという感じですが、Expo会場やセッション会場もたくさんの人でごった返していました。 私たちのセッションはというと、、、 ほぼ満席 でした! たくさんの方にご来場いただき幸いはなはだでございます。本当にありがとうございました! アーカイブ スライド  D2-DA-14_株式会社マイナビ _ Google Cloud Next Tokyo Session Slides BO.pdf セッション レコード こちら より、アーカイブ動画を視聴いただけます。 なお、Cloud Next Tokyo 25への登録が必要となります。 Ask the speaker セッション後、オーディエンスの方が質問できるコーナーがありましたが、 どんなデータに対して適用できるのか。例えば自社サイトのデータも検索対象にできるのか Vertex AI Searchで検索できるために具体的にどのような処理を実装したのか どうやってデータ取り込みを自動化したのか 内製組織を構築していくためにはどうすればよいのか どんなデータを用いて学習したのか このように、最新AIプロダクトの内容だけではなく、内製開発を進めていくための組織風土の改革にも関心を持っていただけているようでした。 スライドトラブル 発表直前になってトラブルが発生してしまいました(リハではうまくいってたのに...) 作成したGoogleスライドにはこのように動画(YouTube)を埋め込んでいたのですが スライドショー中にこれを全画面表示しようとすると... 上部にMacのメニューバーが映りこんでしまう! 今回はしかたなく映りこんだまま投影しました。 Googleの方からは、「YouTubeの全画面表示機能ではなく、別スライドに画面いっぱいの動画オブジェクトを貼り付けておく」方法をおすすめされました。 その他にもトラブルがあったので、次回登壇者のためにも対処法とともにまとめておきます。 クリッカーを使った時に動画が再生されないまま次のスライドに送られてしまう 上述の方法で設定した上で「動画を右クリック」→「書式設定オプション」→「動画の再生」→ 「再生(クリック時)」に設定 Windowsで閲覧するとフォントが上書きされてしまう Google はGoogleの製品や関連イベントのスライドでのみ使用を許可しているフォント「Google Sans」があるのですが、こちらは日本語フォントについてはChromeの「標準フォント」がそのまま利用されます。 Windowsを当日のプレゼンでも利用する場合、Chromeの「 フォントをカスタマイズ 」から、[ 標準フォント ]を[ Noto Sans JP ]にしておきます。 Macでプレゼンする(Google社員もMac使ってます)。 また、Google スライドは、基本オンラインで利用するアプリケーションであるため、当日会場が混雑していたりすると背景が読み込めず、alt画像になってしまうことがあります。 そうなったときのため、 スライドを「オフライン」で利用できるようにしておく。( 手順 ) デモ動画を流す場合、(YouTube Playerに接続できない場合を想定して)ローカルで事前に開いておき、別の仮想デスクトップで全画面表示しておく。セッション中に動画を流すパートになったら、その仮想デスクトップに切り替えて動画を流す。 という進め方をするとよいと思います。 感想 (S.T) 5年前に入社したときは内製組織も立ち上がったばかりで、テックイメージもまだなかったような時代から、このような大きなテックカンファレンスに登壇するまでになったんだなと思うと、感慨深い気持ちになりますし、その成長過程に一役買えたという達成感があります。私個人としてはGoogle Cloud関連のイベントで登壇するという夢があったので、マイナビでやりたいことの一つが叶ったと思うととてもうれしい気持ちになりました。いつか基調講演に登壇できるぐらいの成果上げたいですね。 (I.S) 自分が関わったPJがこうして社外にまで広報できる機会をいただけて、身に余る光栄です。企画から開発までひととおり携わったので達成感も感じています。Ask The Speaker ではMMMパートの質問はほとんど飛んで来ずちょっと寂しい部分はありましたが、MMMを導入するに見合う広告投資規模を持つ企業はなかなか少ないのかもしれません。 (H.Y)セッションに当日ご参加いただいた皆様・アーカイブで視聴していただいた皆様、ありがとうございました!Google Cloudのイベントだったためエンジニアリング寄りの紹介が多かったと思いますが、課題から解決策までのストーリーも伝わっていれば幸いです。私としても今回のように業務の成果を事例として社外に共有できたことで、ひとつ自信にもつながりました。 (S.H)今回取り上げた施策は多くの方が携わって進んでいた中で、マーケを代表して登壇させていただきありがとうございました。個人的には当日のAskTheSpeakerが印象深いです。登壇前は「この発表にだれか興味を持ってくださるのか?」と半信半疑でしたが、AskTheSpeakerにて他社の方が前のめりに質問してくださっている姿を見て、我々の施策が意義のあるものだったことを実感できました。内側からでは施策の価値を測ることが難しいかもしれませんが、マイナビにはいい事例がたくさん眠っていると思いますのでもっと社外にアピールしていけたらよいのかなと感じました。今回発表した施策はまだまだこれからという段階なので、さらにビジネスインパクトをもたらせるよう広げていけたらと思います。