TECH PLAY

株式会社エブリー

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

410

目次 はじめに AIの発展と開発スピードの変化 PRレビューの負荷 レビューに要する時間の増加 レビューの何が負荷なのか レビュー負荷への対処 仕組みでの対処 Claude Code plugin を活用したレビューの効率化 エージェント構成の概要 レビュー結果の出力イメージ 仕組み 課題 おわりに はじめに こんにちは。 開発本部開発1部トモニテ開発部所属の庄司( @ktanonymous )です。 最近では、AI の性能向上や開発フレームワークの進化による開発スピードの向上に伴い、 これまで以上に大量のコードをレビューしていく中で、負荷が大きくなっていると感じています。 本記事では、どういった点で負担を感じているのか、それに対してどのようなアプローチができるのか、 自分なりに考えてみたことをまとめていきたいと思います。 AIの発展と開発スピードの変化 AIの性能向上という面では、CursorやClaude Codeをはじめとするコーディングエージェントやモデル自体の進化により、 AIにコンテキストを渡して実装を依頼するだけで、実装計画の策定からコーディング、テストの追加までを自律的に行わせることが現実的になっています。 さらに、こうしたAIの性能向上に加えて、ドキュメント駆動開発/仕様駆動開発のような実装以外のフェーズにもAIを組み込むフレームワークの発展も目覚ましいです。 以前の記事ではAI駆動開発を見据えたドキュメント運用について整理してみましたが、 弊社の別チームでもエンジニアが仕様書を主導して書くことでAIによる設計・実装を加速させる取り組みが紹介されています。 tech.every.tv tech.every.tv 実装フェーズのスピードアップに加え、ドキュメント運用や仕様策定といった上流工程にもAIが入ることで、開発フロー全体が大きく加速しています。 こうした変化自体は非常にポジティブですが、一方で開発フローの中で人間が直接対応している部分がボトルネックとしてこれまで以上に顕在化しているように感じています。 PRレビューの負荷 レビューに要する時間の増加 レビュー対象には大きく分けると、以下の2種類に分類できます。 自分のアウトプット 他のメンバーのアウトプット また、個人的に、AIの出力を100%信頼できる状況にはまだなっていないと思っていて、 その他にもレビュー活動は以下のような役割を担ってくれているのが現状なのではないかと考えています。 コードの品質担保 : バグや設計上の問題を早期に発見する チーム内の知識共有 : 他のメンバーの変更を把握し、コードベースへの理解を深める機会になる コードベースの一貫性維持 : チームとしてのコーディング方針や設計思想を保つ ビジネスロジックの妥当性判断 : 仕様やドメイン知識に基づく判断は、プロジェクトの文脈を深く理解している人間でないと精度が出しにくい 実装速度の向上に伴い、PRの作成頻度は高まっていますし、適切にAIをコントロールしないと1回の変更量や粒度なども大きくなってしまいがちです。 実際に自分の業務の中でも、コーディングとレビューの比率が以前までは 7:3 程度だったのが、最近は 3:7 くらいになっているように感じています。 特に、自分のアウトプットに関しては、自分は指示役でAIがアウトプットするようになって他の人のアウトプットと近い感覚でレビューをするようになりました。 その結果、ここにかかる労力や時間も少なからず増えています。 そのような背景もあり、レビューがボトルネックになっているという感覚やレビューを通じての疲労感を以前にも増して感じています。 理想的にはPRレビューを全てAIに委譲できれば、レビュー待ちによるブロッキングはなくなります。 しかし、先ほども述べたように、現時点で人間のレビューを完全になくすのは難しいと感じています。 一方で、レビューの全てが人間にしかできないわけではないとも思っています。 「レビューをなくす」のではなく「レビューの負荷を分解して、適切に分担する」という考え方で、 機械的にチェックできる部分についてはツールやAIに委譲し、本質的なレビューに人間が集中できるようにする体制を多くの人が整備しているのではないかと思います。 レビューの何が負荷なのか そもそもレビューのどういった部分に負荷を感じているのかを考えた時、 個人的には、最近レビューをしている中で、以下のような辛みポイントがあるかなと感じています。 レビューするべきものが多い コンテキストの把握(PRの背景や仕様の確認) (以前より)レビューの優先度を考えないといけない 1つ目はシンプルで、レビューするべきものが多いほど負担が増えます。 2つ目に関しては、1つ目とも相まって、時間に対して把握する必要のあるコンテキストが多くなっているので、 これまで以上に負荷のある作業となっています。 3つ目に関しては、レビューするべきものが複数ある状況において、どれからレビューしていくのか、 その中でもどこからレビューしていくのか、優先度や効率を以前よりも意識しないといけないように感じています。 レビュー負荷への対処 ここで、仕組みの面での対処の例を挙げてみます。 仕組みでの対処 ルールやCIを整備することでレビュー負荷の軽減が見込めます。 コーディング規約・スタイル linterやformatterの導入・徹底はやはり重要で、CIでの強制など、 レビュー以前の段階で静的検査が完了していれば、レビュアーはその部分を気にする必要がなくなります。 テストの充実化 テストが要件を適切に表現できていれば、レビュアーとしても 実装が正しく動くか、要件を満たしているのかを判断しやすくなります。 AIレビューツールの活用 GitHub ActionsなどでAIによる自動レビューを組み込むことで、 人間のレビュアーが確認すべきポイントをある程度絞ることができます。 このように、ルールやCIの整備によって「本質的な判断」に集中できる状態に近づけられます。 (なお、レビュー対象の粒度や背景となる情報など、レビュアーに与えるコンテキストの調整によっても負荷の軽減は可能であり、 人間がAIをコントロールして、これらの仕組みがより効果的に働くように意識することも重要だと思います。) Claude Code plugin を活用したレビューの効率化 個人的には先に挙げたような点がレビューをしていて辛いなと感じていたので、 仕組みでの対処とは別に、Claude CodeのカスタムPluginを利用してレビューの効率化を図っています。 Claude Codeには公式のcode-reviewプラグイン 1 が提供されています。 これの構成を踏襲しつつ、個人的に以前から利用しているレビューフォーマットやドキュメントなどの外部コンテキストの取り込みを含めたプラグインを作成して利用しています。 1次レビューをAIに任せるようにしたことで、自分の作業をブロックせずにレビューの基盤を整理しておくことができるようになり、 全体感や内容の把握、優先度の判断に要する負担がある程度減らせました。 エージェント構成の概要 プラグインの処理は、情報収集・外部コンテキスト取得から、専門エージェントによる並列レビュー、信頼度の再評価を経て、レポート生成に至る流れになっています。 エージェント構成の概要 レビュー結果の出力イメージ プラグインは、レビュー結果をマークダウン形式のレポートとしてローカルに出力します。 GitHubにコメントを直接投稿しない設計にしており、レビュー結果を自分で確認・判断してからフィードバックを行うことを想定しています。 実際のレポートは以下のような構成で出力されます。 # PR Review: everytv/repo#123 — PRタイトル ## 変更の概要 ### 背景 / 変更内容 / 実現できる根拠 / メリット・デメリット ## 指摘事項 ### Critical — 信頼度 90-100 ### Important — 信頼度 80-89 ### Suggestion — 信頼度 60-79 ## 良い点 ## レビューガイド ### 推奨レビュー順序 以下では、このレポートの主要な部分がどのように生成されるかを簡単に紹介します。 仕組み コンテキストの自動収集 レビュー時の「コンテキストの把握」の負荷を軽減するために、PRの基本情報(概要・コミット履歴・変更ファイル一覧)に加え、 PR本文に含まれるissueやConfluenceなどの関連リンクを解析して外部コンテキストを取得します。 指摘事項 指摘事項は、構成図に示した3つの専門エージェント(code-reviewer、test-analyzer、design-reviewer)が並列にレビューを行い、その結果を信頼度に基づいてフィルタリングすることで生成されます。 code-reviewerはバグ検出やセキュリティ、パフォーマンスの観点で、test-analyzerはテストカバレッジや設計品質の観点で、design-reviewerは設計原則や既存コードとの整合性の観点で、それぞれレビューを行います。 公式プラグインと同様に0-100の信頼度スコアを導入し、confidence-verifierというエージェントが各指摘を独立した視点で再検証します。 レビュー結果のレポートには、信頼度スコアおよびその判定理由を記述させています。 レビューガイド レポートの「レビューガイド」では、推奨レビュー順序と各ファイルの注目ポイントを示します。 どこからレビューすればよいかの判断をツールに任せることで、優先順位付けの負荷の軽減を狙っています。 複数PRの並列レビュー レビューするべきものが多い場合に対応するため、batch-reviewスキルも用意しています。 複数のPR番号を指定すると、各PRを独立したエージェントが並列にレビューしてリポジトリごとのディレクトリに個別出力します。 まとまった数のPRをレビューする際に、事前にポイントを把握するための情報源として活用しています。 課題 プラグインはあくまでレビューの「補助」であり、完璧なレビューではありません。 そのため、ツールの出力をそのまま信頼してレビュー完了とはせず、内容を確認する必要があります。 おわりに 今回の記事では、PRレビューの負荷について自分が感じていることや、それに対して取り組んでいることを紹介しました。 AIの活用が進む中で、レビューの量やコンテキストの把握、優先順位付けといった部分に辛さを感じるようになりましたが、 仕組みの整備やプラグインの活用で少しずつ改善できている実感があります。 まだまだ試行錯誤の段階ではありますが、同じような課題感を持っている方の参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。 Claude Code 公式 code-review plugin (2026年3月23日閲覧) ↩
アバター
はじめに こんにちは!トモニテで開発をしている吉田です。 2026/3/20〜2026/3/22に開催されたPHPerKaigi 2026にスタッフとして参加してきました! PHPerKaigi(ペチパーカイギ)とは以下のようなイベントです! PHPerKaigi(ペチパーカイギ)は、PHPer、つまり、 現在PHPを使用している方、過去にPHPを使用していた方、 これからPHPを使いたいと思っている方、そしてPHPが大好きな方たちが、 技術的なノウハウとPHP愛を共有するためのイベントです。 phperkaigi.jp しかしながら私自身、普段の業務でPHPは書いていません。どうして私が今回参加したのか、PHPを書いていない私が参加してどうだったのか書いていきます。 参加経緯 一番初めのきっかけは昨年開催されたiOSDC 2025で当日スタッフをしたことです。普段はGo言語やTypeScriptを書いていてiOSとは無縁でしたが、スタッフとして参加できました。 iOSDCとPHPerKaigiは実行委員長が同じです。その関係でiOSDC 2025終了後にスタッフの方からPHPerKaigiのスタッフについても話を聞いて、ぜひやってみたいと思い参加させてもらうことにしました! そんな私がどうして普段触る技術領域以外のカンファレンスでスタッフをしているのか。それは 仕事をしてるだけだと出会わない人たちと出会ってみたい!新しい世界を知りたい! と思ったからです。 ここからは実際に参加してみての感想を書いていきます。 参加してみて スタッフについて スタッフにはコアスタッフと当日スタッフの2種類があります。当日スタッフは会期前に事前の顔合わせを行い、当日の運営を担当します。コアスタッフは開催に向けて早い段階から事前準備を進めていてます。 私はコアスタッフとして参加しました。事前準備では名札を首から下げるためのストラップ制作を担当しました。 会期中はTrack Aを担当し、セッションごとに司会や演出を担当したりしていました。演出は場面に応じてスクリーンに映す内容を切り替えるといった役割です。 Track Aではオープニングや通常セッションに加えて、PHPer コードバトルやルーキーズLT、LT大会といった多様なコンテンツが行われていました。 もちろんメインは担当としての仕事ですが、シフトの合間にはスポンサーブースを回ったり、セッションも聞いていました。PHPのカンファレンスではありますが、スピーカーが話す内容はPHPを書いていないと分からないということはなく、それぞれが課題に対してどういうアプローチを取ったのかという手法の部分は、普段の技術領域にも活かせることがあるのではと感じました。 聞いたもの全て興味深かったのですが、特に面白かったものを紹介します。 PHPer コードバトル PHPer コードバトルは、指示された動作をする PHP コードをより短く書けた方が勝ちという 1 対 1 の対戦コンテンツです。予選を勝ち上がったプレイヤー6名がトーナメント形式で対決します。 スコアはコードの空白を除去したバイト数になります。 普段のサービスを動かすためのコードとはまた違うテクニック的な要素も必要になります。 ルールは分かるのですが、正直プレイヤーが具体的にどんなテクニックを使っているのかは分かりません(笑)。 ただ、会場のスクリーンにはリアルタイムにプレイヤーが書いているコードやその瞬間のプログラムサイズが表示され、解説者による解説があります! 個人的にはさながらスポーツ観戦をしているような臨場感で、プレイヤーが大きくプログラムサイズを減らすと会場がどよめくような瞬間もありました。 何より解説があるのでプレイヤーがどういう工夫をしているのか観戦者も知ることができます。 私がコードバトルで学んだのは && と and は優先順位が違うということです。いつかどこかで役立てたいと思います。 参考: PHP: 論理演算子 - Manual コードバトルはシフトが当たっていなくても会場に見に行っていたくらい面白かったです。Track Aの担当にならなければ見ることはなかったと思うので、スタッフをやったからこそ知れた面白さでした! ルーキーズLT/LT大会 ルーキーズLT大会はPHPerKaigiで初めてトークする「ルーキー」たちによる5分のショートトーク、LT大会はスピーカーを限定しないLT大会です。LT大会では参加者がペンライトを振る場面があるのですが、これが会場にとても綺麗な彩りを添えていました! 特に印象に残ったトークを2つ紹介します。まずルーキーズLTから: AI時代の脳疲れと向き合う「言語学としてのPHP」 - プロポーザル / 登壇資料 AI疲れは私自身実感していましたが、それを言語学の観点から考察しているのが新鮮で勉強になりました。ハイコンテキストな日本語話者がローコンテキストな指示を出そうとしていて、これが疲れの原因らしいです...。 「なんでこんな疲れるんだろう...」の原因を知ることができたので、これからは対策が取れそうです。 LT大会からは以下のトークです。 よし、PHPで円でも描いてみるか - プロポーザル / 登壇資料 PHPerKaigi 2024の登壇でもらった質問から「PHPで円を書いてみよう」ということになったそうです! Webやコンソールで描いてみたり、途中では電子工作をされていたり、最終的にはアニメーションする円を実現されていたりと、多種多様な円をPHPで描かれていました。実現過程も面白かったですが話術もすごくてたくさん笑わせてもらいながら聞いていました。 最後に PHPを書いていない私でも参加してみてどうだったかというと、十分に楽しめたし学びもありました。セッションで語られる課題へのアプローチは言語を問わず通じるものが多く、普段の開発にも持ち帰れる気づきがありました。また、コードバトルのようにスタッフとしてTrack Aを担当したからこそ出会えたコンテンツもあり、「仕事をしてるだけだと出会わない人たちと出会ってみたい、新しい世界を知りたい」という動機は十分に満たされました。 これからもカンファレンスのスタッフ活動を続けていきたいと思います!
アバター
はじめに こんにちは、デリッシュキッチンのバックエンドエンジニアの鈴木です。 先日、プロダクトのGoのバージョンを 1.25.4 から 1.26.0 へ アップデートしたところ、CI上の自動テストが一部落ちる(失敗する)問題に直面しました。 原因を調べてみると、テストデータの初期化で使っている TRUNCATE 処理において、これまで発生していなかった外部キー制約(Foreign Key Constraint)のエラーが頻発していることがわかりました。 コード自体はいじっていないにもかかわらず、なぜGoのバージョンを上げただけでデータベース操作が失敗するようになったのか。本記事では、このエラーの調査を通して改めて気付かされた、Goの database/sql パッケージにおけるコネクションプールの仕様と、安全なコネクション管理について共有します。 概要 Goの database/sql は内部でコネクションプーリングを行っており、 db.Exec() などのクエリ実行ごとに、プールからアイドル状態のコネクションを動的に取得・返却する仕組みになっている。 一方、MySQLの SET foreign_key_checks = 0 のような設定は、同一セッション(コネクション)内でのみ有効。 そのため、 db.Exec() を連続して呼んでも、同じコネクションで実行される保証はなく、別のコネクションが割り当てられると設定が反映されずにエラーになる。 解決策として、 db.Conn() を使ってコネクションを明示的に取得(占有)し、一連の処理が終わるまで同じコネクションを使い回す必要がある。 Go 1.26.0 へのアップデートと TRUNCATE の失敗 Goを 1.26.0 に上げたタイミングで、テストのクリーンアップ処理(テーブルのTRUNCATE)において、MySQLから以下のエラーが返るようになりました。 Error 1701: Cannot truncate a table referenced in a foreign key constraint ... MySQLで外部キー制約が張られているテーブルを TRUNCATE する場合、一時的に SET foreign_key_checks = 0 を実行して制約を無効化するのが一般的です。私たちのコードでもこの処理を入れていたはずですが、なぜか制約違反のエラーが発生していました。 問題となった実装 エラーが発生していた箇所のコードです。標準の *sql.DB を使って、3つのクエリを順番に実行していました。 // 外部キー制約を一時的に無効化してTRUNCATEを実行する実装 func (e *DBEngine) TruncateTable(tableName string ) error { // 1. 制約チェックの無効化 if _, err := e.db.Exec( "SET foreign_key_checks = 0" ); err != nil { return err } // 2. TRUNCATEの実行(ここで Error 1701 が発生) if _, err := e.db.Exec( "TRUNCATE TABLE " + tableName); err != nil { return err } // 3. 制約チェックの有効化 if _, err := e.db.Exec( "SET foreign_key_checks = 1" ); err != nil { return err } return nil } 一見すると上から順番に実行されるため問題なさそうに見えますが、この実装は各クエリが別々のコネクションで実行される可能性を考慮できていませんでした。 原因:コネクションプールとセッション変数の仕様の違い 今回の問題は、MySQLのセッション変数の仕様と、Goのコネクションプールの挙動のミスマッチが原因でした。 MySQLのセッション変数 MySQLの SET foreign_key_checks はセッション変数であり、その設定は 現在のセッション(コネクション)内でのみ 有効です。別のコネクションから繋ぎ直した場合、デフォルトの設定(通常は有効)に戻ってしまいます。 Goの database/sql の挙動 Goの db.Exec() は、実行されるたびにコネクションプールから空いているコネクションを1つ取得し、クエリを実行し終えるとすぐにプールへ返却します。 つまり、 コード上で連続して db.Exec() を書いても、同じコネクションで実行される保証はどこにもありません。 内部では以下のように、コネクションのすれ違いが発生していました。 fig.1: コネクションが切り替わることで設定が引き継がれずエラーになるフロー なぜ今までエラーにならなかったのか? これまでの環境(Go 1.25.4)では、このコードでも特にエラーは起きていませんでした。しかしこれは仕様として保証されていたわけではなく、単なる 実行タイミングの偶然 でした。 これまでは、以下の流れがたまたま成立していました。 SET foreign_key_checks = 0 を実行。 使い終わったコネクションが即座にプールへ返却される。 直後の TRUNCATE でプールからコネクションを取得する際、たった今返却されたばかりのコネクション(設定変更済み)がそのまま使い回される。 このように、他に並行して走っているクエリがない限り、実質的に同じコネクションが連続して割り当てられやすい状態になっていたに過ぎません。 なぜ Go 1.26.0 で顕在化したのか? Go 1.26.0 では、ランタイムのパフォーマンスが大きく向上しています。特に、デフォルトで有効化された新しいガベージコレクタ Green Tea GC によるスキャン待ち時間の削減や、メモリアロケーションの高速化などが含まれています。 こうしたランタイムの最適化によって、プログラム全体の実行速度やゴルーチンの切り替わりといった、マイクロ秒単位のスケジュールタイミングが微妙に変化しました。 その結果、 SET クエリを実行してコネクションが完全にプールへ戻る前に、次の TRUNCATE の処理が走り出し、プール側がいま空いている別のコネクション(設定変更されていないもの)を割り当ててしまうケースが増加したと考えられます。 つまり、Goのバージョンアップによるバグではなく、 Goのランタイムが高速化・効率化されたことで、アプリケーション側に潜んでいた実装上の不備が表面化した というのが真相です。 解決策:sql.Conn を使ってコネクションを固定する 同じコネクションを使って一連のクエリを確実に実行するには、Go 1.9から導入された db.Conn(ctx) を使用します。これを使うことで、プールから特定のコネクションを明示的に取得(占有)できます。 修正後の実装 func (e *DBEngine) TruncateTable(ctx context.Context, tableName string ) error { // 1. コネクションを明示的に取得(チェックアウト)する conn, err := e.db.Conn(ctx) if err != nil { return err } // 使い終わったら必ずプールへ返却する defer conn.Close() // 2. 確保した同一のコネクション(conn)に対してクエリを実行する if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 0" ); err != nil { return err } // 同じコネクションなので、設定が反映された状態で実行できる if _, err := conn.ExecContext(ctx, "TRUNCATE TABLE " + tableName); err != nil { return err } if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 1" ); err != nil { return err } return nil } sql.Conn オブジェクトに対してメソッドを呼び出し、処理が終わった後に Close() を呼ぶことで、セッション変数の設定を維持したまま安全にクエリを実行できるようになります。 まとめ MySQLの SET 構文のようなセッション依存の設定を行う場合、単純な db.Exec() の連続呼び出し(コネクションプール任せ)にしてはいけません。必ず sql.Conn などを使い、明示的にコネクションを占有して処理を行う必要があります。 今回のケースのように、言語やランタイムのパフォーマンスが向上した結果、これまでたまたま動いていたコードの潜在的なバグが顕在化することがあるため、仕様を正しく理解して実装することの重要性を再認識しました。
アバター
目次 はじめに 2つの課題と、目指すアーキテクチャ 手法1 — UIKit の中に SwiftUI を埋め込む 手法2 — ViewModel の Protocol と実装の分離 手法3 — UIKit 依存の画面遷移を列挙型で集約する 手法4 — SwiftUI から UIKit の画面を呼ぶ 手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む まとめ — 制約の中で前に進む はじめに デリッシュキッチンで iOS エンジニアをしている谷口恭一です。 デリッシュキッチンは今年で10年目を迎えるアプリです。この約1年間、2つの取り組みを並行して進めています。 SwiftUI 化 — UIKit で書かれた既存画面を SwiftUI に置き換える マルチパッケージ化 — 本体プロジェクトからコードを SPM パッケージに切り出す どちらも一括でやれるものではなく、通常の機能開発と並行しながら少しずつ進めるしかありません。本記事では、この2つの取り組みを同時に進めるために実践している 5つの手法 を紹介します。 2つの課題と、目指すアーキテクチャ 具体的な手法の話に入る前に、それぞれの課題と目指す方向を整理します。 SwiftUI 化の課題 多くの既存画面は UIKit と RxSwift で構成されています。新規の画面は SwiftUI で作っていますが、既存画面の SwiftUI 化はまだ道半ばです。 SwiftUI 化を進めるうえで最大の障壁は、ナビゲーションです。アプリ全体の画面遷移は UINavigationController の push/pop で成り立っています。SwiftUI には NavigationStack がありますが、アプリ全体のナビゲーションを一気に置き換えるのは現実的ではありません。画面数が多く、各画面の遷移ロジックが UINavigationController に深く依存しているためです。 また、各画面の ViewModel は API クライアント、永続化層、広告 SDK など、本体プロジェクトの様々なサービスに依存しています。UIKit の画面を単純に SwiftUI に書き換えるだけでは済まず、こうした依存関係をどう扱うかという設計上の判断が必要になります。 マルチパッケージ化の課題 一部のパッケージ化は進んでいるものの、まだ多くのソースコードが本体プロジェクト( .xcodeproj )のメインターゲットに含まれている状態です。依存管理は CocoaPods と SPM を併用しています。 本体プロジェクトのメインターゲットにコードが集中している構成には、チーム開発で厄介な問題があります。 ファイルを追加・削除するたびに .xcodeproj 内の project.pbxproj に差分が出て、ブランチ間のコンフリクトの原因になる ことです。 Xcode 16 で導入された フォルダベースのグループ管理 を使えば、ファイルシステムとプロジェクト構造が自動同期されるため、この問題は解消できます。しかし、CocoaPods で管理されている古い依存がフォルダベースのグループに対応しておらず、現時点ではフォルダへの移行がまだ行えません。 方針:SwiftUI 化のタイミングでパッケージにも切り出す この2つの課題は別々のものですが、同時に取り組むことで互いを補い合えます。 具体的には、 UIKit の画面を SwiftUI に書き換えるタイミングで、書き換えた SwiftUI のコードを本体プロジェクトに残すのではなく、 SPM(Swift Package Manager) の Feature パッケージに切り出す という方針を取っています。 こうすることで、SwiftUI 化によってコードが新しくなると同時に、パッケージへの移動によって本体プロジェクトのメインターゲットからコードが減っていきます。SPM パッケージ内のファイル操作は .xcodeproj に影響しないため、コンフリクト問題も根本的に回避できます。 本記事ではこの2つの場所を以下の用語で呼び分けます。 本体プロジェクト ( .xcodeproj のメインアプリターゲット) - UIKit の ViewController - ViewModel の実装クラス(各種サービスに依存) - Networking / Services / Repository Feature パッケージ (SPM で管理する独立したパッケージ) - SwiftUI の View - ViewModel の Protocol - Action の列挙型 - UI コンポーネント 依存の方向は当然 本体プロジェクト → Feature パッケージ の一方向です。 理想像:本体プロジェクトをエントリーポイントにする 最終的には、UI 層だけでなく、責務ごとに適切なパッケージへコードを隠蔽し、本体プロジェクトはそれらを組み合わせるエントリーポイントとしての役割に留めるのがゴールです。今回の Feature パッケージへの UI 層切り出しは、その最初の一歩にあたります。 前提:開発リソースの制約 なお、SwiftUI 化やパッケージ化だけに専念できる時期はありません。通常の機能開発・改善と並行して、できるところから少しずつ進めるしかないのが現実です。だからこそ、1画面ずつ、1コンポーネントずつ着実に移行していける手法が必要になります。 以下、5つの考え方とそれに対応する手法を順に紹介します。 手法1 — UIKit の中に SwiftUI を埋め込む 最も基本的な手法です。アプリ全体の画面遷移を一気に NavigationStack に置き換えるのは現実的ではありません。そこで、 画面遷移は UIKit のまま諦めて、1画面ずつ中身だけを SwiftUI に置き換えていく という考え方を取ります。 Feature パッケージ側では、純粋な SwiftUI View を定義するだけです。 public struct FeatureView < VM : FeatureViewModel >: View { @ObservedObject var viewModel : VM var body : some View { ... } } 本体プロジェクト側では、この SwiftUI View を UIHostingController 経由で既存の UIKit 画面に埋め込みます。 let hosting = UIHostingController(rootView : FeatureView (viewModel : viewModel )) addChild(hosting) view.addSubview(hosting.view) hosting.didMove(toParent : self ) ナビゲーション階層には一切手を加えないため、 影響範囲がその画面だけ に限定されます。既存のナビゲーションバーの設定や画面遷移ロジックをそのまま活用できます。 なお、シートやフルスクリーンカバーによるモーダル表示は、ナビゲーションの push/pop 階層とは独立しています。したがって、ホスティングされた SwiftUI View の中で .sheet() ) や .fullScreenCover() ) を使ったモーダル遷移は、完全に SwiftUI 内で完結できます。 手法2 — ViewModel の Protocol と実装の分離 SwiftUI View を Feature パッケージに移すとき、最初にぶつかるのが ViewModel の依存関係です。ViewModel の実装クラスは API クライアントや永続化層など、本体プロジェクトの様々なサービスに依存しています。これをそのままパッケージに持っていくことはできません。 解決策は、ViewModel を Protocol(インターフェース)と実装に分離 することです。 Feature パッケージには Protocol だけを置きます。 @MainActor public protocol FeatureViewModel : ObservableObject { var items : [ Item ] { get } var isLoading : Bool { get } func fetch () async } public struct FeatureView < VM : FeatureViewModel >: View { @ObservedObject var viewModel : VM } SwiftUI View はジェネリクスで ViewModel Protocol に依存し、具象型を知りません。 実装は本体プロジェクトに残します。 final class FeatureViewModelImpl : FeatureViewModel { @Published private ( set ) var items : [ Item ] = [] @Published private ( set ) var isLoading = false private let service : ItemService func fetch () async { isLoading = true items = ( try ? await service.fetchItems()) ?? [] isLoading = false } } サービス層への依存は本体プロジェクトの実装クラスに閉じ込められ、Feature パッケージは Protocol が定義するインターフェースだけを相手にします。 そして、この2つを繋ぐのが本体プロジェクトの UIKit ViewController です。手法1と組み合わせて、実装クラスを生成し SwiftUI View に渡します。 final class FeatureViewController : UIViewController { override func viewDidLoad () { super .viewDidLoad() let viewModel = FeatureViewModelImpl(service : .shared) let hosting = UIHostingController(rootView : FeatureView (viewModel : viewModel )) addChild(hosting) view.addSubview(hosting.view) hosting.didMove(toParent : self ) } } Feature パッケージの FeatureView は FeatureViewModel Protocol しか知りませんが、本体プロジェクトが具象型 FeatureViewModelImpl を生成して渡すことで、依存関係が解決されます。 Protocol の定義はパッケージに、実装の生成は本体プロジェクトに — この役割分担がパッケージ境界を成立させます。 この分離のもう一つの利点は、 将来の拡張性 です。Service 層や Repository 層のパッケージ化が進めば、ViewModel の実装クラスもいずれ Feature パッケージに移すことができます。今の時点では Protocol と実装を分けておくことで、将来その選択肢を確保しておけるということです。 手法3 — UIKit 依存の画面遷移を列挙型で集約する UI 層が Feature パッケージに移ると、次の問題が浮上します。SwiftUI の画面からユーザーが「詳細を見る」「検索画面を開く」といった操作をしたとき、その遷移先がまだ本体プロジェクトに UIKit で実装されたままのケースです。依存の方向は本体プロジェクト → Feature パッケージの一方向なので、Feature パッケージから本体プロジェクトの画面を直接呼ぶことはできません。 このギャップを埋めるために、Feature パッケージで Action 列挙型 を定義し、本体プロジェクトのクロージャで処理します。 public enum FeatureAction { case showDetail(Item) case showSearch case showSettings } public protocol FeatureViewModel : ObservableObject { var actionHandler : (( FeatureAction ) -> Void ) ? { get set } } Feature パッケージが知っているのは「こういうアクションが起きうる」という列挙型の定義だけです。それをどう処理するかは、本体プロジェクトに委ねます。 viewModel.actionHandler = { [ weak self ] action in switch action { case .showDetail( let item ) : self? .navigationController?.pushViewController(DetailVC(item : item ), animated : true ) case .showSearch : SearchVC.present (from : self ) case .showSettings : SettingsVC.push (from : self ) } } UIKit に依存する処理はこの switch 文の中に集約されます。新しい遷移が増えたら enum にケースを追加し、 switch にハンドリングを書くだけです。遷移先が SwiftUI 化されたら、対応する case の処理を SwiftUI 内の .navigationDestination ) 等に移せばいい。enum の associated values によって遷移に必要なパラメータが型安全に保証されるため、実行時エラーのリスクも低くなります。 なお、このアクションハンドリングの処理を ViewController に直接書くのではなく、Coordinator に切り出して責務を閉じ込めるという選択肢もあります。画面遷移のパターンが多い画面では、そちらのほうが見通しが良くなるかもしれません。 手法4 — SwiftUI から UIKit の画面を呼ぶ 手法3は、主にナビゲーション(push)ベースの画面遷移で機能します。一方で、SwiftUI の .fullScreenCover() や .sheet() によるモーダル遷移は事情が異なります。 シートやフルスクリーンカバーによるモーダル遷移は、ナビゲーションの push/pop 階層から独立しています。つまり、理想的にはモーダルで表示する画面とその先をまるごと SwiftUI 化できる領域です。しかし現実には、 .fullScreenCover() の遷移先にまだ UIKit の画面が残っていることがあります。 ここでの考え方は、 遷移先が UIKit のままでも、呼び出し元の SwiftUI 化を止めない ということです。UIKit の画面を UIViewControllerRepresentable でラップして SwiftUI から呼べるようにし、UIKit → SwiftUI → UIKit という「サンドイッチ」構造を移行の過渡期として許容します。 具体的な手法は3つのステップで構成されます。 UIKit 画面を Representable でラップする まだ SwiftUI 化されていない UIKit の画面を、 UIViewControllerRepresentable で薄くラップします。 extension LegacyDetailViewController { struct Representable : UIViewControllerRepresentable { let item : Item func makeUIViewController (context : Context ) -> LegacyDetailViewController { . init (item : item ) } func updateUIViewController (_ vc : LegacyDetailViewController , context : Context ) {} } } このラッパーは本体プロジェクトに置きます。 Feature パッケージは遷移先を外から受け取る ここが最も重要なポイントです。Feature パッケージの SwiftUI View は、遷移先の具体的な画面を自分では持たず、 ジェネリクスの @ViewBuilder クロージャ として外部から受け取ります。 public struct FeatureRootView < Destination : View >: View { @ViewBuilder let detailDestination : ( Item ) -> Destination var body : some View { content .fullScreenCover(isPresented : $viewModel .showDetail) { if let item = viewModel.selectedItem { NavigationStack { detailDestination(item) } } } } } Destination: View というジェネリクス制約だけがあり、具体的にどんな View(あるいは Representable)が来るかは知りません。Feature パッケージは本体プロジェクトのレガシー画面に一切依存していません。 本体プロジェクトで Representable を注入する 組み立ては本体プロジェクトが担当します。 let rootView = FeatureRootView( viewModel : viewModel , detailDestination : { item in LegacyDetailViewController.Representable(item : item ) } ) present(UIHostingController(rootView : rootView ), animated : true ) レガシーな UIKit 画面への依存があるのは、この注入の一箇所だけです。 この構造の大きな利点は、 遷移先が SwiftUI 化されたときの変更が最小限 で済むことです。注入するクロージャの中身を差し替えるだけで、Feature パッケージのコードには一切触れる必要がありません。 detailDestination : { item in NewDetailView(item : item ) } サンドイッチ構造はあくまで移行の過渡期のものであり、最終的には UIKit の層が消えて自然な SwiftUI のコードになります。 いつこの手法を使うか この手法を使わず「遷移先もすべて SwiftUI 化してからでないと手を付けられない」と考えてしまうと、SwiftUI 化できる範囲がなかなか広がりません。たとえば画面 A から .fullScreenCover() で画面 B を表示していて、画面 B がまだ UIKit だとします。画面 A の SwiftUI 化は画面 B の完了を待つことになり、画面 B にも UIKit の遷移先があれば、さらにその先を待つ…と連鎖してしまいます。 ただし、すべてのモーダル遷移にこの手法を適用すべきというわけではありません。遷移先の UIKit 画面が少数であればラップして先に進めるメリットがありますが、遷移先の大半が UIKit であれば、ラップのコストが見合わないのでその画面の SwiftUI 化自体を後回しにする判断もありえます。ラップにかかるコストと、SwiftUI 化を先に進められるメリットを天秤にかけて判断することが重要です。 手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む 手法1〜4で多くの UI を Feature パッケージに移せますが、もう一つ厄介なケースがあります。 画面の一部に、本体プロジェクトの依存がないと成立しないコンポーネント が含まれている場合です。 典型的な例がインフィード広告です。広告の表示には広告 SDK への依存が必要ですが、これは本体プロジェクトにしかありません。だからといって、広告を含む画面全体を Feature パッケージに移せないとなると、移行が大きく停滞してしまいます。 考え方は手法4と同じです。Feature パッケージ側では「ここに何かの View が入る」というジェネリクスの枠だけを定義し、具体的な実装は本体プロジェクトから差し込みます。 Feature パッケージの View は、広告コンポーネントの表示位置をジェネリクスの @ViewBuilder パラメータとして受け取ります。 public struct FeatureTabView < VM : FeatureTabViewModel , AdContent : View >: View { @ObservedObject var viewModel : VM private let adContent : () -> AdContent public init ( viewModel : VM , @ViewBuilder adContent : @escaping () -> AdContent ) { self .viewModel = viewModel self .adContent = adContent } public var body : some View { ScrollView { LazyVStack(spacing : 8 ) { SomeSection(viewModel : viewModel ) adContent() AnotherSection(viewModel : viewModel ) } } } } Feature パッケージはこの AdContent が何であるかを一切知りません。広告でもプレースホルダーでも EmptyView でも構わないという設計です。 本体プロジェクト側では、広告 SDK に依存する具体的な View を差し込みます。 let view = FeatureTabView(viewModel : viewModel ) { InFeedAdSectionView(adType : .infeed) } let hosting = UIHostingController(rootView : view ) InFeedAdSectionView は本体プロジェクトにあり、内部で広告 SDK を使って広告をロード・表示します。Feature パッケージにはこの View の存在も広告 SDK の存在も見えていません。 この手法のポイントは、 1つのコンポーネントが Feature パッケージに移せないからといって、画面全体のパッケージ移行を諦めない ということです。移せない部分だけを抽象化して外から差し込めば、画面の大部分は Feature パッケージに移すことができます。 まとめ — 制約の中で前に進む 本記事で紹介した5つの手法を整理します。 手法 主に解決する課題 いつ不要になるか 手法1: UIKit に SwiftUI を埋め込む SwiftUI 化 NavigationStack 全面移行時 手法2: ViewModel の Protocol / 実装分離 マルチパッケージ化 Service 層のパッケージ化完了時 手法3: UIKit 依存処理の列挙型集約 マルチパッケージ化 UIKit 画面の SwiftUI 化完了時 手法4: SwiftUI から UIKit を呼ぶ SwiftUI 化 遷移先の SwiftUI 化完了時 手法5: 依存のあるコンポーネントを外から差し込む マルチパッケージ化 依存のパッケージ化完了時 重要なのは、 すべての手法に「不要になる日」がある ということです。これらは最終的なアーキテクチャではなく、移行期を乗り越えるための手法です。 これらの手法を組み合わせることで、通常の機能開発と並行しながら少しずつ SwiftUI 化とマルチパッケージ化を進められています。一気に大きな時間を確保しなくても、1画面ずつ、1コンポーネントずつ着実に移行を進めていける実感があります。 大規模アプリの SwiftUI 移行は、短距離走ではなくマラソンです。今日の制約の中で最善の一歩を選び、長い戦略スパンで着実に前に進めていく。本記事がその一助になれば幸いです。
アバター
Go 1.26で追加されたnew(expr)はなぜこの形なのか こんにちは、開発1部の @uho-wq です。 本記事ではGo 1.26で追加された new(expr) がどのような議論の末にこの形に落ち着いたのかを説明しようと思います。 go.dev new(expr) Go 1.26で、組み込み関数 new が式(expression)を受け取れるようになりました。 p := new ( 42 ) // *int, 値は42 s := new ( "hello" ) // *string, 値は"hello" b := new ( true ) // *bool, 値はtrue とてもシンプルな構文追加に思えますが、実はこの結論に至るまで2014年から2025年までの 11年 もかかりました。 この記事では、以下の2つのissueをもとに議論の流れを追っていきます。 github.com github.com ※ この記事を作成するにあたり、これらのissueに付いたコメントすべてに目を通しました。11年分の議論は非常に膨大なため本記事では要点を絞って紹介しており、解釈の違いや抜け漏れがある可能性がありますがご了承ください。 そもそも何が問題だったのか Goではcomposite literalは直接ポインタを取得できますが、プリミティブ型は宣言時にポインタを得ることができません。 p := &Point{X: 1 , Y: 2 } // OK: composite literalは&を取れる p := & 42 // コンパイルエラー: cannot take address of 42 よって従来では以下のように一度変数に代入してポインタを得る書き方をするか、ヘルパー関数を定義するしかありませんでした。 v := 42 p := &v // ヘルパー関数 func IntPtr(v int ) * int { return &v } 例えば、AWS SDK for Goでは aws.String() 、 aws.Int64() といった ヘルパー関数 が大量に定義されています。構造体の値をaws.String()で囲むといった作業はAWS SDK for Goを使ったことがある方は経験済みなのかなと思います。 Go 1.18でGenericsが導入されたことによって、ヘルパー関数を汎用的に記述することができるようになりました。 func Ptr[T any](v T) *T { return &v } しかし、これもcomposite literalのみ直接ポインタを取れるという問題の回避策にはなりましたが、根本解決には至りませんでした。 こうした背景から、言語レベルでの解決策が長年にわたって議論されてきました。以降では、その議論がなぜ最終的に new(expr) という形に落ち着いたのかを時系列で追っていきます。 proposal: spec: add &T(v) to allocate variable of type T, set to v, and return address #9097 2014年11月にchai2010氏により最初の提案が行われました。 提案は、以下の2つの構文を追加する、というものでした。 new 関数の拡張: func new(Type, value ...Type) *Type &Type(value) 構文の追加 例: px := new ( int , 9527 ) px := & int ( 9527 ) 当初は大きな反響もなくissueは放置されていましたが、2018年にIan Lance Taylor氏が 提案に再度言及 しました。 &int(5) を許すなら new(int, 5) は不要であり、 new を完全に削除することすら検討すべき だと述べました。そして任意の式に & を適用する際の問題点を2つ指摘しています。 1つ目は任意の式 v に対して &v を取れるとした場合、論理的にはアドレスのアドレス &&v を取れるべきだが、 && は異なる意味を持つ演算子なので動作しない 2つ目は &var はループ内で呼び出しても毎回同じ値に解決されるが、 &expr は毎回新しいインスタンスを確保するので異なる値に解決される また2020年には、Ian Lance Taylor氏自身が「ジェネリクスが入れば新しい言語機能を必要としないので、ジェネリクスを得るまで待って、そのようなアプローチが十分かどうかを見たいと思う」とも 述べています 。 結局#9097は2023年8月に#45624を優先する形でクローズされました。9年間で40件のコメントが寄せられ、Ian Lance Taylor氏が提示した論点は#45624でも継続して議論されます。 spec: expression to create pointer to simple types #45624 2021年4月にRob Pike氏によってissueが立てられました。 Pike氏はissueを再オープンする代わりに、新たに2つの選択肢を提示しました。 Option 1: new に第2引数を追加する p1 := new ( int , 3 ) p2 := new ( rune , 10 ) p3 := new (Weekday, Tuesday) Option 2: 型変換の結果をアドレス可能にする p1 := & int ( 3 ) p2 := & rune ( 10 ) p3 := &Weekday(Tuesday) Pike氏は「両方入れてもいいかもしれない」とも述べています。 注目すべきは、この時点では最終形となる new(expr) はまだ提案されていなかったということです。Pike氏の提案はあくまで new(T, v) (型と値の2引数)と &T(v) の2択でした。 new(1) の提案 (2021年4月) Pike氏の提案から数日後、Go Teamの Russ Cox氏のコメント が多くの賛同を得ました。 The overloading of & for "take address of existing value" and "allocate copy of composite literal" has always been unfortunate. An alternative to expanding the overloading of & would be to overload new instead, so that it is the generic ptrTo function as well as the original new(T), as in new(1). Then &T{...} can be explained retroactively as mere syntactic sugar for new(T{...}). & 演算子は既に「既存の値のアドレスを取得する &v 」と「composite literalのコピーを割り当てる &T{...} 」という2つの異なる意味を持っています。ここにさらに意味を追加するのではなく、 new を拡張して new(1) のように書けるようにすべきではないか。そうすれば &T{...} は new(T{...}) の糖衣構文として説明できる、という主張です。 これが最終形 new(expr) の原型でした。 ジェネリクスの提案 (2021年4月) 一方でRoger Peppe氏は 言語変更そのものに異を唱えました 。 Given this possibility, I don't see that there's any need to change new or the language syntax itself to accommodate this functionality. Goのジェネリクスを使えば以下のように書けるのだから、newや言語仕様自体を変える必要はないのでは、というものです。 // ref returns a pointer to the value of t. func ref[T any](t T) *T { return &t } このジェネリクス案は、その後4年にわたって繰り返される反論の原型となりました。 膠着状態 (2021年9月) 2021年9月、Ben Hoyt氏が 議論の停滞を指摘 し、再検討を求めました。 Looks like this was last discussed in the proposal review meeting on May 5. While there's no clear consensus here, there are a number of good options. It seems like there's a fair bit of enthusiasm for Russ's simple new(1) form, and a decent amount of support for a new builtin generic function like Roger Peppe's ptr(1) suggestion. My vote would be for ptr(1) as it just uses "ordinary" generics, but I like new(1) too. Could this be discussed at the review meetings again? この時点で支持が集まっていたのはnewの拡張である new(1) とジェネリクスを使用した ptr(1) の2案でしたが、コンセンサスには至りませんでした。ジェネリクスの正式リリース(Go 1.18、2022年3月)を待つ形で、議論は一時休止に入ります。 PtrTo[T any] vs &T(v) vs new の拡張 (2023年6月) 2023年6月、Go TeamのIan Lance Taylor氏がissueに戻り、 選択肢を3つに絞りました 。 PtrTo[T any] のような標準ライブラリ関数 &T(v) 構文 new(v) / new(T, v) の拡張 そしてGo Teamの立場を明確にしました。 @griesemer, @bradfitz, and @ianlancetaylor prefer permitting both new(v) and new(T, v) . この時点では、Go Teamの主要メンバー3人が new 拡張を支持していました。 ただし new(v) と new(T, v) の 両方 を許可する案であり、 new(v) 単独ではありませんでした。 また、依然として &T(v) を支持する声はあったものの、批判的な意見も支持されるようになってきました。Ben Hoyt氏の 主張 が端的に示しています。 I slightly prefer new(v) over &T(v) because it eliminates stuttering in cases like new(time.Now()) -- that would be &time.Time(time.Now()) with the other syntax. If new(T, v) is supported in addition for clarity in certain cases, that's fine. new() is also a bit clearer that it always creates a "new" thing. new(time.Now()) のようなケースだと冗長な繰り返しがなくなりますが、&T(v)の構文だと &time.Time(time.Now()) になってしまいます。明確さが必要な場合に new(T, v) が追加でサポートされるのは問題にはならず、new() は常に「新しい」ものを作成することがより明確である、と主張しています。 さらにHoyt氏も &演算子が概念的に同等でないこと も指摘しています。 When you do &Struct{} Go creates a new value every time and returns its address, but when you do &s Go returns the address of that same variable each time. &Struct{}を行うと、Goは毎回新しい値を作成してそのアドレスを返しますが、&sを行うとGoは毎回その同じ変数のアドレスを返します。 この後も &T(v) 案は依然として支持されるものの、議論の焦点はnewの拡張方法とジェネリクスの活用に移っていきます。 new(T, v) vs new(v) (2023年7月) Goチームが支持しているnewの拡張方法は new(T, v) と new(v) の2パターンありました。 2023年7月、Ian Lance Taylor氏が 方針転換を報告 しました。Rob Pike氏とRoger Peppe氏などから「 new(v) と new(T, v) の両方ではなく、 new(T, v) のみにすべき」という意見が出ました。 また型名が長くなるケースの大半は構造体であり、構造体には既に &S{} 表記があります。単純な値 v に対して複雑な型 T を書く new(T, v) のケースはそもそもほとんど発生しないと考え、 new(T, v) でも混乱を招くことは少ないだろう、という見解を示しています。 これに対してMerovius氏が 具体例 で切り返しました。 new(int64(42)) isn't any more to type or read than new(int64, 42), but new(time.Second) is significantly better than new(time.Duration, time.Second). I don't think having the type in there really adds anything. We are already kind of used to inferring the type from a constant literal. new(int64(42)) は new(int64, 42) と比べてタイプ量も読む量も変わりませんが、 new(time.Second) は new(time.Duration, time.Second) よりもはるかに良いです、と述べています。 このコメントが賛同を集めた一方で、 new(v) を見たときに v が型なのか値なのかを読者が把握している必要があるのでnew(v)を好まない、という意見も複数ありました。 new(T, v) は書き方として冗長である一方で明確に記述でき、 new(v) は書き方として簡潔である一方で表現として曖昧であるとし、この時点ではコンセンサスには至りませんでした。 ジェネリクスの限界 (2023年 - 2024年) 「ジェネリクスで Ptr[T] が書ける」という反論は依然として主張されていました。 しかし2023年12月、Rob Pike氏が 改めてこの問題の本質を言い直しています 。 it's easier to build a pointer to a complex thing than to a simple one. 「複雑な構造体へのポインタは &T{...} で簡単に作れるのに、単純な int へのポインタは面倒」 ジェネリクスを用いたヘルパー関数を書くことは、この非対称性の問題の根本的な解決にはなっていないと言及しています。 ジェネリクスが根本の解決になっていないとするエピソードとして、perj氏の 体験談 が象徴的でした。 I appear to be writing this function about once every second month, when I need it in a new package. It's not very annoying, but does feel a bit like I'm littering my packages with this function, so not having to write it would be welcome. I do realise I can put it in a package I import, but that also seems overkill for a one-liner. 2ヶ月に1度、新しいパッケージでこのヘルパー関数を書いている パッケージをこの関数で散らかしているような感じがするので、書かなくて済むなら歓迎 importするパッケージに入れることもできるが、たった1行のコードのためにそれをするのはやりすぎな気がする このコメントは20ものGood評価を集めており、ジェネリクス案の限界を端的に示しているといえます。 new(T, v) は解決策にならない (2025年3月-8月) 2025年3月、かつて「ジェネリクスで十分」と主張していたRoger Peppe氏が、 new(T, v) 案に対して 批判 を投じました。 Replacing, for example, ref(someMap[x]) with new(SomeType, someMap[x]) would be a net loss because it makes the code more verbose and a little bit more fragile, requiring update should the type of the map's values change. ref(someMap[x]) を new(SomeType, someMap[x]) に書き換えるのはコードが冗長になるだけでなく、mapの値の型が変わるたびに修正が必要になる。 型を2回書く new(T, v) では、ジェネリクスのヘルパー関数からの移行メリットがない、という指摘です。 その後、2025年8月にGo TeamのAlan Donovan氏が 決めてとなるコメント を投じました。 it is important not to have to redundantly state the type and the value, making new(T, v) a non-solution. 型と値を冗長に並べる必要がないことが重要であり、 new(T, v) は解決策にならない、と主張し、Donovan氏は3月のPeppe氏のコメントに納得して new(value) を支持する立場を表明しました。 デフォルトの型が合わない場合は new(T(v)) とキャストを組み合わせればよく、 new(T, v) のような複雑なルールは不要だ、としています。 The proposal committeeの承認 (2025年8月) 2025年8月15日、The proposal committeeを代表してAustin Clements氏が 宣言 しました。 The proposal committee is happy with new(expr) . new(T) (型を渡す)と new(expr) (式を渡す)は動作が異なり、構文的な曖昧さを欠点として持つものの、どちらも「新しいストレージを確保して返す」点で一貫しています。 そしてDonovan氏が 収集したデータ が決め手となりました。 the data @adonovan collected indicates that, while this can be written as a generic function, there are so many instances that it seems well-worth a standardized built-in. ジェネリクスを用いた関数として記述することも可能ですが、その利用箇所が非常に多いため、標準化された組み込み関数として実装する価値は十分にある、としています。 Accepted (2025年9月) 2025年9月17日、Austin Clements氏が 正式に採択を宣言 しました。 そして2025年10月27日、実装を完了したAlan Donovan氏が issueを締めくくりました 。 All done, in only eleven years since #9097. ;-) Go 1.26での仕様 The Go Programming Language Specification では、 new は以下のように定義されています。 The built-in function new creates a new, initialized variable and returns a pointer to it. It accepts a single argument, which may be either an expression or a type. 引数が型 T の式(または、デフォルト型が T のuntyped定数式)である場合、 new(expr) は型 T の変数を確保し、 expr の値で初期化し、そのアドレス(型 *T の値)を返します。 type Config struct { Timeout *time.Duration Retries * int Verbose * bool } cfg := Config{ Timeout: new ( 30 * time.Second), Retries: new ( 3 ), Verbose: new ( true ), } 関数の戻り値も渡せます。 p := new (time.Now()) // *time.Time q := new (strconv.Itoa( 42 )) // *string 注意点: untyped constantの挙動 ただし1つ注意点があります。 new() に定数を渡した場合、default typeが使われます。 var ui uint = 10 // OK: untyped constant 10はuintに暗黙変換される // しかし... uip := new ( 10 ) // *int(10のdefault typeがint) var ui2 uint = *uip // コンパイルエラー: cannot use *uip (type int) as type uint 定数 10 がそのまま変数宣言で使われる場合はuntyped constantとして柔軟に型推論されますが、 new(10) の時点で *int に確定してしまいます。明示的な型が必要な場合は型変換を組み合わせましょう。 uip := new ( uint ( 10 )) // *uint まとめ 最後に、11年の議論で登場した各提案の結論についてまとめます。 提案 結論 &T(v) & 演算子の意味の不連続性。 &2 は毎回新しいアドレスを返すが &v は同じアドレスを返す。混乱を招く ref(v) / ptr(v) ジェネリクスで1行で書ける。だが逆に「全員が書いている」。組み込みとして標準化する方が合理的 new(T, v) 冗長。 new(time.Duration, time.Second) はジェネリクスの ref(time.Second) より後退する new(expr) 採用。 & のセマンティクスを変えず、既存の new 関数の自然な拡張 個人的には、議論全体を通して new(expr) という結論に至ったことがとても腑に落ちました。ジェネリクスの導入を見越して一度議論を止め、導入後も便利さに飛びつかず実運用の課題を吸い上げた上で、本質的な解決策に辿り着いています。 最終形の new(expr) は、2021年にRuss Cox氏が投じた new(1) の発想そのものでした。4年の間に &T(v) や new(T, v) が検討され、結局最もシンプルな案に戻ってきたのが面白いなと思いました。
アバター
1. はじめに:Liquid Glass で変わる「検索」の体験 WWDC25 で発表された Liquid Glass は、iOS 26 の目玉となるデザインシステムです。ナビゲーションバーやタブバーがガラスのような半透明素材になり、コンテンツがその裏側に透過して見えるようになります。 見た目の変化も大きいですが、Liquid Glass がアプリの体験として特に大きく変えたのは 検索 です。iOS 26 では、タブバーの右端に検索アイコンが配置され、タップするとタブバー自体が検索フィールドに変わります。設定アプリや App Store など Apple 純正アプリではこの新しい検索 UI が標準になっており、ユーザーはどのアプリでも同じ操作で検索にアクセスできるようになりました。 これはサードパーティアプリにとっても重要な変更です。この新しい検索パターンを採用することで、iOS の標準的な検索体験と統一感のある UI を提供できます。 Liquid Glass の対応ポイントは多岐にわたりますが、この検索タブの変更は特にユーザー体験への影響が大きいと感じたため、既存の UIKit アプリでどう実現するかを調べて実装してみました。 SwiftUI であれば、検索タブの Liquid Glass 対応は驚くほど簡単です。 TabView { Tab( "ホーム" , systemImage : "house" ) { HomeView() } Tab(role : .search) { SearchView() } } Tab(role: .search) の 1 行で、タブバーの右端に検索アイコンが分離配置され、タップするとタブバー内に検索フィールドが展開する動きを実現できます。 この記事では、UIKit の UITabBarController + UINavigationController を基盤とした既存アプリに Liquid Glass の検索タブを適用する方法を紹介します。 2. ゴール:タブバー内に検索フィールドが展開する UI 実装するのは以下の動きです。 タブバーの右端に検索アイコンが配置される 検索アイコンをタップすると、タブバー内に検索フィールドがスライドして展開する テキストを入力して検索できる キャンセルすると元のタブバー表示に戻る これは iOS 26 の設定アプリや App Store で見られる標準的な動きで、 UISearchTab というクラスを使って実現します。 3. UISearchTab — UIKit 版の Tab(role: .search) iOS 18 で UITab API が導入されました。従来の viewControllers 配列ベースのタブ管理に代わり、 UITab オブジェクトを使ってタブを構成する新しい方式です。 UISearchTab は UITab のサブクラスで、検索専用のタブを表します。SwiftUI の Tab(role: .search) に対応する UIKit のクラスです。 @available ( iOS 18.0 , * ) private func setupWithUITabAPI () { // 検索以外のタブを UITab として生成 var uiTabs : [ UITab ] = nonSearchItems.map { item in UITab( title : item.title , image : item.tabImage , identifier : item.identifier ) { _ in item.controller } } // 検索タブは UISearchTab を使う let searchTab = UISearchTab { [ weak self ] _ in guard let self else { return UIViewController() } return TabBarItemType.search.controller } uiTabs.append(searchTab) tabs = uiTabs } UISearchTab のポイントは以下の通りです。 タイトルや画像の指定が不要 です。システムが検索アイコンを自動で提供します タブバーの右端(trailing 端)に自動で分離配置 されます。他のタブとは異なる位置に置かれます tabs プロパティに設定するだけで、タブバー内の検索フィールド展開がシステム標準で動作します 4. 検索 ViewController の対応 UISearchTab を設定すると、タブバーに検索アイコンが表示されます。しかし、タップしたときに検索フィールドをタブバー内に展開させるには、ViewController 側の対応も必要です。 UISearchTab がタブバー内で検索フィールドを展開するには、検索コントローラーが navigationItem.searchController に設定されている必要があります。 navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false navigationItem.titleView にカスタム配置した検索バーでは、 UISearchTab のタブバー展開とは連携しません。既存のアプリで titleView ベースの検索バーを使っている場合は、 navigationItem.searchController への移行が必要です。 5. iOS バージョンだけでなく Info.plist も見て分岐する サンプルコード中で使用している LiquidGlassAvailability.isEnabled について説明します。 iOS 26 以降でも UIDesignRequiresCompatibility を true に設定している場合 、アプリは Liquid Glass ではなく従来のデザインで表示されます。この場合、 UISearchTab を使ったタブ構成にすると見た目と挙動が噛み合わなくなります。 そこで、iOS バージョンと Info.plist のフラグを両方チェックするヘルパーを用意しました。 enum LiquidGlassAvailability { static var isEnabled : Bool { guard #available(iOS 26.0 , * ) else { return false } // UIDesignRequiresCompatibility が true なら互換モード → Liquid Glass 無効 if let requiresCompatibility = Bundle.main.object( forInfoDictionaryKey : "UIDesignRequiresCompatibility" ) as? Bool , requiresCompatibility { return false } return true } } 判定ロジックは以下の通りです。 条件 isEnabled iOS 25 以前 false iOS 26+ / UIDesignRequiresCompatibility = true false iOS 26+ / UIDesignRequiresCompatibility 未設定 or false true なぜこれが必要か UIDesignRequiresCompatibility は、Liquid Glass への移行を段階的に進めるための Apple 公式の仕組みです。Info.plist にこのキーを true で設定すると、iOS 26 でもアプリは従来のデザインで表示されます。つまり 「iOS 26 以降 = Liquid Glass」ではない のです。 開発中は Liquid Glass を有効にして動作確認し、問題があればこのフラグを true にして一時的に互換モードに戻す、という使い方ができます。コード側もこのフラグに連動して分岐しておけば、フラグひとつで Liquid Glass の ON/OFF を切り替えられます。 Liquid Glass 対応は多岐にわたるため、ある程度長い開発期間が必要になると考えられます。他の施策の開発と並行して進められるように、このような仕組みを用意しました。 6. まとめ UIKit アプリで Liquid Glass の検索タブを実装するための要点を整理します。 UISearchTab を使う — UITab のサブクラスで、SwiftUI の Tab(role: .search) に対応する UIKit のクラスです。タイトルや画像は不要で、タブバー右端への分離配置と検索フィールドの展開がシステム標準で動作します。 検索バーは navigationItem.searchController に設定する — navigationItem.titleView にカスタム配置した検索バーでは、 UISearchTab のタブバー展開と連携しません。 iOS バージョンだけでなく UIDesignRequiresCompatibility も見る — #available だけでは不十分です。Info.plist のフラグと組み合わせた LiquidGlassAvailability.isEnabled を用意し、Liquid Glass の ON/OFF にコードが追従するようにしました。 #available で後方互換を維持する — UITab API は iOS 18+、 automaticallyActivatesSearch は iOS 26+ です。既存の viewControllers ベースのコードと UITab API ベースのコードを分岐で共存させます。 SwiftUI への全面移行を待たなくても、UIKit アプリに段階的に Liquid Glass を取り入れることは可能です。 UISearchTab はその第一歩として取り組みやすい対応だと思います。
アバター
はじめに こんにちは、開発1部で食事管理アプリ「ヘルシカ」の開発をしている新谷です。 ヘルシカ - ダイエット・食事管理のための簡単カロリー計算 every, Inc. ヘルスケア/フィットネス 無料 apps.apple.com 社内でAIツールを使って開発を進める中で、個々のタスクは確実に速くなっているものの、開発フロー全体としてはまだ思ったほど生産性が上がっていないと感じています。この記事では、その原因を分析し、「エンジニアが仕様書を主導して書く」という開発フローの改善に取り組んだ話を紹介します。 現状の開発フローと課題 これまでの開発フロー ヘルシカチームでは、以下のような流れでプロダクト開発を行っています。 施策立案 → 認識合わせ → デザイン作成 → 設計 → 実装 → コードレビュー → QA → リリース 認識合わせのために、PdMがPRD(Product Requirements Document)を作成しています。PRDの構成は大まかに以下のような形です。 ## 背景・目的・仮説 なぜこの施策をやるのか(Why) ## 分析・検証 確認したい指標(定量面) ## 要件・仕様 ユーザーは〇〇にアップロードした画像を保存できる(What) このPRDには、WhyとWhatが書かれています。これをもとにチーム全員で認識を合わせ、担当のエンジニアがシステム設計(How)を作成して実装に入ります。 タスク規模によるボトルネックの違い AIツールの導入によって、小さなタスクでは設計・実装がボトルネックになりにくくなりました。むしろ認識合わせやコードレビューの方が相対的にボトルネックになってきています。 しかし、中規模〜大規模なタスクでは、依然として設計・実装がボトルネックです。具体的には以下の2つの課題があります。 設計の課題 PRDの仕様からシステム設計に落とすのに時間がかかる 仕様の考慮漏れや認識齟齬が設計段階で発覚し、手戻りが発生する 実装の課題 AIが生成したコードの確認・修正に時間がかかる 課題の深掘り 実装の課題について掘り下げると、AIが期待通りのコードを出せない原因の多くは、設計書(= AIへのプロンプト)の不備に行き着きます。 もちろん、AIが力を発揮するための環境整備(リンターや自動テストの整備、ルールファイルの充実、既存コードの品質改善など)も大切です。しかし、これらを整えたとしても、不備のある設計書を渡せば不備のある実装が出てきます。 つまり、設計フェーズで短時間かつ考慮漏れのない設計を作れるかが、AI時代の開発生産性を左右するポイントだと考えました。 では、設計の課題をもう少し細かく見てみます。 設計に時間がかかる:技術的知識やドメイン知識に基づく設計力・スピードに依存する 手戻りが発生する:PRDの仕様に考慮漏れがあり、設計や実装の段階で初めて問題が発覚する この2つの課題を解決するために、「エンジニアがPRDの仕様(What)を主導して作成する」というアプローチを考えました。 エンジニアが仕様を主導する なぜエンジニアが仕様を書くのか エンジニアは実際のコードベースを理解しています。そのため、仕様を考える段階で「この機能を実現するには、既存の〇〇の処理にも影響がある」「この条件分岐は仕様として明確にしておく必要がある」といった技術的な観点を織り込めます。 これにより、仕様段階での考慮漏れが減り、後続のシステム設計で大きな手戻りが発生しにくくなります。結果として、設計にかかる時間も短縮されます。 また、エンジニアが仕様を引き受けることで、PdMはマーケティングや数値分析など、本来注力すべき領域により多くの時間を使えるようになるのではないかと考えています。 AI時代ならではのメリット さらに、AI時代ならではのメリットもあります。エンジニアが仕様書を作ることで、「どういう仕様書を書けば、後段のシステム設計書を楽に作れるか」「どういう粒度で書けば、AIで設計書の生成を自動化できるか」といったPDCAを回しやすくなります。 仕様書のフォーマット自体を改善していくことで、仕様書→システム設計書→実装の一連の流れを最適化できる可能性があります。 AI-DLCとの共通点 この考え方は、AWSが提唱している AI-DLC(AI-Driven Development Life Cycle) のINCEPTION PHASE(AIと要件を深掘りして決めていくフェーズ)に通じるものがあります。AI-DLCでは、最初のフェーズでPdM・デザイナー・エンジニアが一緒に要件を詰めていくことを推奨しており、今回の取り組みと方向性が近いと感じています。弊社でも別チームがAI-DLCを試した事例があるので、興味のある方は こちらの記事 もご覧ください。 難しい点 一方で、エンジニアが仕様作成を主導するには、PdMと同じくらいの解像度でビジネスや数値を理解する必要があります。これは簡単ではありません。 しかし、AIによって職種間のオーバーラップが進む中、エンジニアにもビジネス理解が強く求められるようになっていくと考えています。仕様を主導することは、エンジニアのスキルセットを広げる機会にもなるのではないかと思っています。 新しい開発フロー 最終的に、以下のフローに変更しました。 PdMがPRDのWhy(背景・目的・仮説)を作成 PdM・デザイナー・エンジニアで認識合わせ → エンジニアが主導してWhat(仕様)を詰める エンジニアが仕様書を作成 仕様書のレビュー エンジニアがシステム設計書(How)を作成 実装 これまでシステム設計書はドキュメントとして残すことを必須としていませんでしたが、仕様書→システム設計書の変換をAIで自動化するPDCAを回すために、今回から残すことを必須としました。 実際の成果物の例 具体例として、「プレミアム機能無料開放」施策での成果物の一部を紹介します。 まず、PdMが作成したPRDの一部です。施策の背景・目的・仮説が記載されています。 ## 概要 新規ユーザーに対し、プレミアム機能を一定期間無料開放することで、 アプリの価値を早期に体験してもらう。 ## 仮説 新規ユーザーはプレミアム機能がロックされているため、 アプリの価値を体験できず、継続利用につながっていない。 次に、エンジニアが作成した仕様書の一部です。PdMとの認識合わせを経て、具体的な仕様に落とし込んでいます。 ## 無料開放期間 - 期間: 初回ホーム到達からx日間 ## 機能要件 ### プレミアム機能の無料開放 - 無料開放期間中、全ユーザーがプレミアム機能を利用できるようにする ### バナー表示(ホーム画面) - 対象: 非プレミアムユーザーのみ - 無料開放期間中: 無料開放中であることを示すバナーを表示 - 無料開放期間終了後: 終了のアナウンスに切り替え そして、この仕様書をもとに作成したシステム設計書の一部です。 ## 設計方針 ### 意味の分離 フリーミアム導入により、「課金しているか」と「プレミアム機能を使えるか」の 意味が同じではなくなるので、コード内でこれらを分離する。 | プロパティ | 意味 | 用途 | | isPremium | 実際に課金しているか | ログ送信パラメータ | | freemiumLastDateTime | フリーミアム期間終了日時 | バナー表示 | | hasFullAccess | プレミアム機能を使えるか | UI表示・遷移制御 | 試してみての所感 この取り組みはまだ実験段階で、試し始めて2週間ほどです。 よかった点 エンジニアがWhatの段階から積極的に介入できる構造になった 仕様書→システム設計書→実装の流れで、エンジニア同士の議論が増えた システム設計書に着手する段階で、大きな手戻りは発生していない 課題 タスクの並列度が上がり、頭の切り替えコストが高い これまでは「実装」と「認識合わせMTG・コードレビュー」の並行だったが、そこに「次のタスクの仕様書作成」が加わる 実装中のタスクと仕様を書くタスクはまったく別の内容なので、コンテキストスイッチが頻繁に発生する 仕様書にどこまで書くべきかの基準がまだ定まっていない どの粒度で書けば後段のシステム設計書や実装がAIで精度よく出てくるのか、引き続き検証が必要 正直なところ、現時点では開発速度が上がったという実感はまだない ただし、仕様書・設計書をドキュメントとして残す運用にしたことで、「何を書けばAIの出力精度が上がるか」を振り返れる状態にはなった。この改善サイクルを回していくことに意味があると考えている まとめ AIツールによって個々のタスクの開発速度は向上していますが、中規模以上のタスクでは設計・実装がボトルネックになっています。この課題に対して、エンジニアがPRDの仕様(What)を主導して書くというアプローチを導入しました。 まだ試し始めたばかりで、タスク並列度の増加や仕様書の粒度の最適化など、新たな課題も見えてきています。 今後は仕様書のフォーマットを改善しながら、仕様書→システム設計書のAI自動生成についてもPDCAを回していきたいと思います。
アバター
目次 はじめに UserMatching(UM)とは QAの課題 Agent Skillsというアプローチ QA手順をSkillsに落とし込む 設計した5つのSkills Skill設計のポイント LPのQA: 表示条件のパターンを自動で網羅する シナリオの自動生成 Claude in Chromeによる自動確認を断念した経緯 ETLのQA: テストデータ設計から検証まで一気通貫 テストデータの自動設計 検証フロー 導入効果と課題 効果 課題 おわりに はじめに こんにちは、デリッシュキッチンのUserMatching事業でエンジニアをしている惟高です。 今回は、Anthropicが提供する Agent Skills を活用して、UserMatching(UM)のQA業務を効率化した取り組みを紹介します。 QAに必要な手順や判断基準をSkillとして定義し、「△△のLPをQAして」「△△のETLをQAして」といった一言で複雑なQAフローを進められる仕組みを構築しました。 最終的には、LPのQAでは設定分析とテストシナリオ生成までを、ETLのQAではテストデータ設計から実行結果の照合までをSkill化しました。 UserMatching(UM)とは デリッシュキッチンやトモニテでは複数のプレゼントキャンペーンがあり、ユーザー様はLP(ランディングページ)経由でいくつかの質問に回答することで応募できるようになっています。クライアント様の要望に応じたLPを個別で作成しているため、案件ごとにフォームの構成や表示条件が異なります。 案件ごとの基本的な流れは、LPで回答を収集し、その回答データがDBに保存され、最後にETLでクライアント向けの出力形式へ変換する、というものです。 UMでの主なQA対象は以下の2つです。 LP(ランディングページ) :案件ごとにフォームの設問・表示条件・応募完了条件が異なり、ユーザーの回答によって後続の設問が動的に変わるなど、複雑な条件分岐が組まれています。 ETL(データ変換処理) :応募データをクライアント企業が求める形式に変換する処理です。変換ルールやフィルタリング条件が案件ごとに設定されます。 参考: DELISH KITCHEN×パルシステム神奈川 プレゼントキャンペーン|DELISH KITCHEN QAの課題 LP・ETLともにビジネス側が管理画面から設定とQAを担当していますが、案件ごとの設定が複雑で、初見では確認すべきポイントをつかみにくい状況でした。 たとえばLPでは、「神奈川県在住」「26歳以上」「現在サービス未利用」といった応募条件に加え、ある設問への回答によって次の設問の表示有無が変わることがあります。そのため、単に入力項目を埋めるだけではなく、「どの回答パターンでどの設問が表示されるか」「どの条件で応募対象外になるか」まで含めて確認する必要があります。 またETLでも、応募データをクライアントごとの指定フォーマットに合わせて変換するため、日付形式の変換、項目名のマッピング、特定条件のデータ除外などを案件ごとに確認する必要があります。1つの設定ミスでも、出力結果全体が意図とずれてしまいます。 こうした背景から、主に以下の3つが課題でした。 パターンの多さ : LPは表示条件の組み合わせが、ETLは変換ルールとフィルタリング条件の組み合わせが多く、網羅すべきテストパターンが膨大になります。 人依存の網羅性 : 確認すべきパターンの優先度や網羅範囲の判断が担当者の経験に委ねられており、案件ごとに確認観点がばらつく状況でした。 作業コスト : テストパターンの設計・実施から結果照合まで手動工程が多く、案件数が増えるほどQAがボトルネックとなっていました。 Agent Skillsというアプローチ Agent Skills は、Anthropicが提供する再利用可能なプロンプトテンプレートの仕組みです。本記事でいうSkillは、Claudeへの指示手順と補助スクリプトをまとめて再利用できる単位、と捉えてもらうとイメージしやすいと思います。 業務手順や判断基準をSkillとして定義しておくことで、自然言語の指示を起点に複雑なタスクを進めやすくなります。Claude CodeとClaude Desktopの両方で利用できますが、今回はビジネス側のメンバーも使えるよう、GUIで操作できるClaude Desktop上で運用しています。 QA支援の仕組みとしては管理画面にQA機能を組み込む選択肢もありましたが、以下の理由でClaude Desktop上のAgent Skillsを選択しました。 開発コストの低さと柔軟性 : 管理画面への実装と比べてSkillを作成するだけで開発コストが低く、QA観点の変更もSkillの修正だけで対応できます。 一定の手順で進められる : Skillとして手順と判断基準を定義することで、担当者による観点のばらつきを抑えつつ、一定の流れでQAを進められるようになりました。 ビジネス側のメンバーが直接使える : 弊社ではビジネス側にもClaude Desktopを配布しており、Skillsは自然言語で呼び出せるため、QA担当者が「△△のLPをQAして」と指示するだけでQAフローを開始できます。 QA手順をSkillsに落とし込む 設計した5つのSkills まず完成形のイメージを紹介します。たとえばLPのQAの場合、以下のような対話になります。 QA担当者 : 「△△のLPをQAして」(△△は案件名) Claude : 案件の設定を読み込み、フォームの表示条件を分析します。 Claude : 設定ミスが1件見つかりました。テストシナリオを8パターン生成しました。確認してください。 QA担当者 : シナリオ5を修正して、あとはOK Claude : 修正しました。チェックリストを出力します。 このように、 設定の読み込み→シナリオ生成→人間のレビュー→チェックリスト出力 を一連の対話で進められます。これを実現するために、以下の5つのSkillを設計しました。 # 分類 スキル名 役割 1 設定取得 campaign-lp-lookup 案件の設定情報を取得する(LP/ETL共通) 2 導出 lp-column-resolver フォーム定義からDBの保存形式を導出する 3 設定取得 etl-config-lookup ETLの変換設定とフィルタリング条件を取得・整形する 4 分析 lp-form-checker 設定の整合性チェック・テストシナリオ生成・チェックリスト出力を行う 5 実行 etl-qa-runner テストデータの設計フェーズと、投入・実行・検証フェーズを持つ LPのQAでは主に campaign-lp-lookup lp-column-resolver lp-form-checker を使い、ETLのQAではそれに etl-config-lookup etl-qa-runner を組み合わせて進めます。 これらのスキルが、LPのQAとETLのQAでどのように連携するかを図にすると、次のようになります。 Agent SkillsによるQAフロー全体像 Skill設計のポイント 再利用性 : 「案件の設定を取得する」スキルはLPのQAとETLのQAの両方で使う共通部品にしました。 段階的な実行 : AIが生成したテストシナリオをそのまま実行せず、人間の確認を挟むことで信頼性を担保しています。 確定的な処理はスクリプトに追い出す : Agent Skillsでは、Skill本体のMarkdownファイルとは別にスクリプトをバンドルできます。「APIから設定データを取得する」「フォーム定義からDBのカラム構造を導出する」といった、入力が決まれば出力も一意に定まる処理はPythonスクリプトとして実装し、Skillから呼び出す形にしています。判断や要約のような曖昧さを含む部分はLLMに任せつつ、データ取得や構造変換のような確定処理はスクリプトで実行することで、再現性を高めています。 ドメイン知識の局所化 : 「フォーム項目がDBにどう保存されるか」の変換ルールはデータ構造導出のスキルに、「ETLの変換タイプごとの仕様」は変換設定取得のスキルに閉じ込めています。各スキルが担当する知識のスコープを明確にすることで、メンテナンスしやすくなります。 LPのQA: 表示条件のパターンを自動で網羅する シナリオの自動生成 LPでは、ユーザーの回答内容によって後続の設問が表示されたり、応募対象外になったりします。こうした条件分岐を人手だけで漏れなく追うのは難しく、QAでは「どの回答パターンで何が表示されるべきか」を整理する必要があります。 たとえば、同じLPでも回答内容によってフォームの表示内容や応募可否が変わります。 応募対象外になるパターン 応募対象外になるパターン 後続の設問が表示されるパターン 後続の設問が表示されるパターン このような分岐が複数の設問にまたがって存在するため、確認すべき表示パターンを体系的に洗い出すことがLPのQAでは重要になります。 まず案件の設定データを取得し、以下の2つの分析を行います。 設定ミスの自動検出 : フォーム設定とフロントエンドの実装仕様を突合し、矛盾や不整合がないかをチェックします。 表示条件テストシナリオの生成 : 応募条件と表示条件を分析し、テストすべき表示パターンを整理します。各シナリオには「どの選択肢を選ぶか」「何が表示されるべきか」「応募不可になるか」といった期待結果を付与します。最終的な過不足の確認は担当者がレビューして補います。 生成されたシナリオはユーザーに提示され、追加・修正・削除を経て承認後、チェックリストが出力されます。 LPのQAチェックリスト Claude in Chromeによる自動確認を断念した経緯 当初はClaude in Chrome(Claude Desktopのブラウザ操作機能)を使い、承認済みシナリオの実機確認まで自動化する設計で、実際に動作する仕組みも構築しました。しかし、1シナリオあたり3〜5分ほどかかり、途中で処理が止まってしまうこともありました。特に各操作でスクリーンショット取得を挟む点が律速となり、実運用には向きませんでした。 結果としてこのSkillの運用は取りやめ、 パターンの洗い出しとチェックリスト出力までをlp-form-checkerに集約し、実機確認は担当者が行う フローに落ち着きました。パターンの網羅的な洗い出しこそがQAの課題の本質だったため、この分担でも十分に価値がありました。 ETLのQA: テストデータ設計から検証まで一気通貫 ETLのQAでは、テストデータの設計から投入、ETL実行、結果検証までを一連の流れで実行します。 ETLのQAフロー テストデータの自動設計 ETLのQAで最も手間がかかるのがテストデータの準備です。LPのフォームから毎回手入力するのは非効率なため、必要なテストデータを開発環境のDBに直接投入する方針を取りました。投入先は本番とは分離された開発環境の検証用データに限定しており、本番データや本番環境には触れない前提で運用しています。ここでの目的はETLの変換処理とフィルタリング条件の検証に絞っており、UI経由の入力体験やLP側の表示制御の確認はLPのQAで担保しています。 直接投入にはデータの「形」を正確に知る必要があり、LP側で応募不可になるパターンの除外判断にもLPの表示条件の知識が必要です。Skillはこうした複雑さを吸収し、以下のテストパターンを自動生成します。 変換テスト : 正常値・境界値・異常値で各変換タイプが正しく動作するか フィルタリングテスト : 全条件クリア(有効)、各条件で無効になるパターン 共通バリデーション : 電話番号・メール・郵便番号・日時の形式チェック 検証フロー 承認後は、テストデータの投入、ETLの再実行、結果取得、期待値との照合までを一連の流れで進めます。不一致が見つかった場合は、ETL設定の問題かテストデータの問題かを切り分けて報告します。 導入効果と課題 効果 最も大きな効果は、QA担当者が毎回ゼロから確認観点を考えなくてよくなったことです。従来は案件ごとに設定画面を読み解き、表示条件や変換条件を見ながらテストパターンを組み立てていましたが、Skill導入後はその整理とたたき台作成を自動化できるようになりました。 自動化の範囲と人間の介在ポイントを整理すると、以下のようになります。 工程 従来(手動) Skill導入後 設定内容の読み解き 人間が管理画面を目視確認 Skillが自動取得・整形 テストパターンの設計 人間が経験ベースで洗い出し Skillがたたき台を自動生成 パターンの妥当性確認 — 人間がレビュー・承認 テスト実行 人間が手動で1パターンずつ LP: チェックリストに沿って人間が確認 / ETL: Skillが自動実行 結果の判定 人間が目視で照合 LP: 人間が目視で判定 / ETL: Skillが期待値と自動照合 LP・ETLに共通してSkillに任せているのは「設定の読み解き」と「パターンのたたき台作成」で、ETLではさらに「テスト実行」と「結果照合」も自動化しています。一方、 パターンの妥当性を判断する工程は担当者に残しています 。完全自動化ではなく、担当者のレビューを挟むことで安全性を高めています。 実際に複数案件で試したところ、特に負荷軽減につながったのは「設定の整合性確認」「パターンの洗い出し」「テストパターンの設計」でした。たとえばLPのQAでは、Skillが最初に複数の表示パターンを整理した状態から確認を始められるため、担当者は不足分の追加や期待結果の見直しに集中できます。 また、LPのQAでは表示条件の整合性を確認する過程で、実際に設定ミスを事前に検出できたケースもありました。見落としやすい条件分岐を人手だけで追う負担を減らしつつ、確認精度の底上げにもつながっています。 ETLの設定でも、変換対象のカラムが想定とは異なるカラムを参照している設定ミスに事前に気づけたケースがありました。設定画面では見落としやすい差分でも、Skillが設定を整理して確認観点を提示することで発見しやすくなりました。 ETLでも、従来はLPから実際に応募して用意していた検証データを、開発環境に直接投入する形へ置き換えられたため、テストデータ準備の手間を減らせました。フォーム入力を何度も繰り返さずに必要な検証ケースを作れるようになり、変換条件やフィルタリング条件の確認に集中しやすくなっています。 課題 今回の取り組みを通じて、以下の2点を課題として感じました。 Claude in Chromeの安定性 : 実機確認の自動化を試みましたが、1シナリオあたり3〜5分ほどかかり、途中で処理が止まってしまうこともあったため断念しました。 Skillの保守コスト : 管理画面の仕様変更や新しい変換タイプの追加があった場合、Skillファイルの更新が必要です。各利用者のローカル環境に配置されるため変更の配布・反映を自動化しにくく、現状は手動更新を前提に運用しています。 おわりに QAに必要な手順と判断基準をAgent Skillsとして定義することで、「LPの表示パターンの整理と確認観点の洗い出し」「ETLのテストデータ設計から検証まで」という複雑なQA業務を効率化できました。 今後はQA以外の業務にもSkillを活用していきたいと考えています。案件のセットアップ作業など、手順や判断基準をSkillとして定義できる場面は多く、幅広い業務への展開が期待できます。 最後までお読みいただきありがとうございました。
アバター
はじめに Gson について Gson の課題 1. Null 安全が破壊されるリスク 2. デフォルト引数が無視される Kotlin Serialization について 具体的な修正内容 1. Data Class の書き換え 2. Retrofit の Converter の置き換え まとめと今後の課題 はじめに こんにちは、株式会社エブリーで Android アプリ開発を担当している岡田です。 弊社が提供する デリッシュキッチン の Android アプリでは、アプリの堅牢性向上とモダンな開発体験のための選択として、JSON パーサーを従来の Gson から Kotlin Serialization への移行を検討しています。 今回は弊社で行なっているイベント「挑戦WEEK」にて、Gson から Kotlin Serialization への移行を、Android のコードベース変更に限定して挑戦してみました。こちらについて、少しお話しさせていただければと思います。 弊社の挑戦WEEKの取り組みについては以下の記事をご覧ください! tech.every.tv Gson について Android アプリの開発において、API との通信で受け取った JSON をデータクラスに変換する「JSON パース」は避けては通れない実装です。 デリッシュキッチン の Android アプリでは、長らく JSON パーサーとして Google 製の「Gson」を利用してきました。Gson は非常に歴史が長く、Android アプリ開発の黎明期からデファクトスタンダードとして広く使われており、Retrofit などのネットワークライブラリとも標準で連携しやすいという特徴があります。 長年アプリの通信基盤を支えてくれた Gson ですが、プロジェクトのフル Kotlin 化が進み、よりモダンな言語仕様を活用していく中で、実は Android アプリを開発する上でいくつかの大きな課題を抱えるようになっていました。 Gson の課題 Java 時代には非常に優秀だった Gson ですが、Kotlin で構成された現代のアプリにおいては、Kotlin の強みである言語仕様とコンフリクトを起こすケースが目立つようになってきました。 1. Null 安全が破壊されるリスク Gson は内部でリフレクション( sun.misc.Unsafe など)を用いてインスタンスを生成します。そのため、Kotlin のデータクラスでプロパティを「非 Null( String など)」で定義していても、サーバーから返ってくる JSON 側にそのキーが存在しない場合、Gson は強制的に null を代入してしまいます。 これにより、Kotlin コンパイラが保証しているはずの「Null 安全」がランタイムで破壊され、アプリの思わぬところで NullPointerException を引き起こす原因となっていました。 2. デフォルト引数が無視される Kotlin のデータクラスでは val isPremium: Boolean = false のようにデフォルト引数を設定できます。しかし、Gson はコンストラクタを経由せずにインスタンスを生成することがあるため、JSON に該当のキーが含まれていない場合、このデフォルト値が適用されません。結果として、意図しない型の初期値( Int なら 0 、参照型なら null )が入ってしまうという問題がありました。 これらの挙動は、開発者が意図しない「不正な状態を持ったインスタンス」がアプリ内を回遊することを意味しており、結果として予期せぬクラッシュの温床になり得ます。 Kotlin Serialization について 最終的に、これらの課題を根本から解決するために、Kotlin 公式が提供しているシリアライズライブラリ「Kotlin Serialization( kotlinx.serialization )」へ移行を検討しています。 Kotlin Serialization は、コンパイル時にシリアライズ・デシリアライズのためのコードを自動生成する仕組みを持っています。実行時に重いリフレクションを行わないため、非常にモダンで Kotlin ライクな設計となっています。 このライブラリへ切り替えることで、以下のような大きな恩恵を受けることができます。 厳格な Null 安全の保証 非 Null として定義したプロパティに対して JSON に値が存在しない場合、強制的に Null を入れるのではなく、パース時に明確に例外( SerializationException )を投げてくれます。これにより、不正なデータによる後続処理でのクラッシュを防ぐことができます。 デフォルト値の完全なサポート JSON にキーが存在しない場合、Kotlin 側で定義したデフォルト引数が正しく適用されます。 パフォーマンス向上とアプリサイズ削減 リフレクションに依存しないため、パース速度が向上します。また、ProGuard/R8 による最適化とも相性が良く、アプリのバイナリサイズの削減にも繋がります。 具体的な修正内容 実際に Gson から Kotlin Serialization へ移行するにあたり、行った具体的な修正内容をご紹介します。 1. Data Class の書き換え Gson の @SerializedName アノテーションを、Kotlin Serialization の @SerialName に変更し、クラスに @Serializable アノテーションを付与します。 【従来の Gson での実装】 data class UserResponse( @SerializedName ( "id" ) val id: Long , @SerializedName ( "user_name" ) val userName: String , @SerializedName ( "profile_image_url" ) val profileImageUrl: String ? ) 【新しい Kotlin Serialization での実装】 @Serializable data class UserResponse( @SerialName ( "id" ) val id: Long , @SerialName ( "user_name" ) val userName: String , // サーバーからキーが送られてこない可能性がある場合はデフォルト値を設定 @SerialName ( "profile_image_url" ) val profileImageUrl: String ? = null , @SerialName ( "is_premium" ) val isPremium: Boolean = false ) 2. Retrofit の Converter の置き換え API 通信に Retrofit を使用しているため、Gson の ConverterFactory を Kotlin Serialization 用のものへ差し替えました。 この際、サーバーからのレスポンスにおいて、アプリ側で定義していない未知のキーが含まれていてもパースエラーにならないよう、 ignoreUnknownKeys = true を設定しています。 // Json パーサーの設定 val json = Json { ignoreUnknownKeys = true // 未知のキーを無視する coerceInputValues = true // null が来た場合にデフォルト値があればフォールバックする } val contentType = "application/json" .toMediaType() val retrofit = Retrofit.Builder() .baseUrl( "https://api.example.com/" ) // GsonConverterFactory.create() からの置き換え .addConverterFactory(json.asConverterFactory(contentType)) .build() 主にこれらの修正を、API レスポンスを受け取る全てのデータクラスと Retrofit クライアントに対して適用し、段階的に移行を進めました。 また他にも com.google.gson.internal.bind.util.ISO8601Utils を利用している箇所や、 JsonUtil という Android アプリ側で Json を扱う際に使用するクラスの修正など、細かい修正も行いました。 総差分ファイル数はおよそ 500 ファイルと、大規模な改修になりました。 まとめと今後の課題 今回の改修で JSON パーサーを Kotlin Serialization に移行したことにより、Kotlin の言語仕様に沿った厳格な型安全性が担保されます。Android のコードベース上での堅牢性は大きく向上しました。 しかし、ライブラリが「厳格」になったからこそ直面する新たな課題もあります。 それは、 サーバーからのレスポンス仕様(スキーマ)の正確な把握 です。 Gson の時代は「JSON にキーがなくても、とりあえず Null を入れてクラッシュさせない」という緩さがありました。しかしこれからは、非 Null プロパティのキーが JSON に存在しなければ、即座にパース失敗となってしまいます。 これを防ぐためには、以下のような対応をサービス全体で意識していく必要があります。 サーバーレスポンスで Null が返る、またはキーが省略される可能性のあるフィールドには、適切な Nullable 定義やデフォルト値を設定する クラッシュログを監視し、パースエラーが発生した場合は迅速にデータクラスの定義をチューニングする サーバーサイドのエンジニアと密に連携し、API 仕様書とクライアント実装の乖離をなくす デリッシュキッチンは歴史のあるサービスですから、型安全に API レスポンスをパースするには、この辺りの見直しは避けて通れません。 時間と根気がいる作業にはなりますが、徐々にでも整備できればと思います。 もしまだ Gson を利用している方で、「データクラスの Null 安全が担保できずに困っている」「原因不明の NullPointerException に悩まされている」と感じているなら、一度 JSON パーサーの移行を検討してみてはいかがでしょうか。 今後も、Kotlin の厳格な型安全性を武器に、より品質が高く安定した デリッシュキッチン をユーザーの皆様にお届けできるよう、改善を続けていきます。
アバター
はじめに こんにちは。開発本部 開発1部 デリッシュリサーチチームの 江﨑 です。 本記事では、これまでHive Metastore上のDeltaテーブルで管理していたデリッシュリサーチ用データ(約40テーブル)をUnity Catalogへ移行したプロジェクトの全体像を、インフラ整備からAthena連携・Databricks Managed MCP活用まで紹介します。 はじめに 背景:なぜ Unity Catalog に移行したか 課題 1:テーブルスキーマが「コードを読まないと分からない」 課題 2:データリネージを Mermaid で管理していた Unity Catalog とは マネージドテーブル vs 外部テーブル 移行手順の全体像 Step 1:インフラ整備 IAMロールの設定 Catalogの作成 Step 2:Unity Catalog テーブルの作成 移行スクリプトの流れ Step 3:既存 ETL コードの変更 主な変更:テーブル参照パスの書き換え Step 4:Athena から Unity Catalog のデータを参照する 実際に行った変更 Step 5:Quick Suite のデータセット移行 移行結果 Before / After Before(Hive Metastore 時代) After(Unity Catalog 移行後) Databricks Managed MCP Databricks Managed MCP とは DBSQL MCP:ETL 開発が変わる 具体的な開発体験 まとめ 背景:なぜ Unity Catalog に移行したか 移行前、デリッシュリサーチではDatabricksのHive Metastore上で約40個のDeltaテーブルを運用していました。その中で、運用上の課題が積み重なってきていました。 課題 1:テーブルスキーマが「コードを読まないと分からない」 「このテーブルに user_id カラムってあったっけ?」という確認をするたびに、Notebookを開いて display(spark.table("schema.table")) を実行するか、ETLコードを読み返す必要がありました。テーブルが増えるほど、この手間もかさんでいきます。 課題 2:データリネージを Mermaid で管理していた テーブル間の依存関係(「このテーブルはどのテーブルから作られているか」)をMermaidのコードで手作業管理していましたが、40テーブルを超えると複雑すぎてメンテナンスが限界になり放置されていました。テーブルを追加するたびにMermaidを手で更新する運用は、明らかにスケールしません。 これらの課題を解消するためにUnity Catalogへの移行を決めました。 Unity Catalog とは Unity CatalogはDatabricksの統合データガバナンスソリューションです。Hive Metastoreとの最大の違いは、 三層の名前空間(Catalog > Schema > Table) を持つ点です。 Catalog(例: marketing_research) └── Schema(例: search) └── Table(例: search_count) Hive Metastoreでは schema.table の二層構造でしたが、Unity Catalogでは catalog.schema.table の三層になります。この変更によって、チームやプロジェクトを跨いだデータの整理がしやすくなります。 主な機能は以下の3点です: データカタログ :テーブルのスキーマ・カラムの説明文をUI上で管理・参照できる データリネージ :データの流れ(どのテーブルがどのテーブルを参照しているか)を自動追跡・可視化 アクセス制御 :行・列レベルの細粒度なセキュリティ設定 Unity Catalogのオブジェクト階層。今回の移行ではCatalog > Schema > Tableの三層構造を利用。(出典: Databricks公式ドキュメント ) Unity CatalogのUI。テーブルを選択するとカラム名・データ型・コメントを一覧で確認できる。 マネージドテーブル vs 外部テーブル Unity Catalogへの移行を検討するとき、決めなければならないのが テーブルタイプ です。 観点 マネージドテーブル 外部テーブル データ保管場所 Unity Catalogが管理するパス 任意のストレージパス(S3など) S3パスの形式 自動生成されたID形式になる 任意のS3パスを指定できる テーブル削除時 データも削除される データは残る Databricksの推奨 ほとんどのユースケース 既存ストレージとの互換性が必要な場合 Databricksはマネージドテーブルを推奨していますが、本プロジェクトでは 外部テーブル を選択しました。 その理由は、データ参照構成にあります。デリッシュリサーチではダッシュボードを Amazon Quick Suite(以下Quick Suite) → Athena → Glue Crawler → S3 という構成で構築しています。Glue Crawlerはクロール先のS3パスを読み取ってテーブル名を付与します。 ここで問題になるのが、マネージドテーブルのS3パス形式です。マネージドテーブルに移行すると、S3パスは s3://unity-catalog-metastore/__unitystorage/... のような __unitystorage 配下のシステム管理ディレクトリ(ランダム生成IDを含むパス) に配置されます。Glue CrawlerはS3プレフィックス/フォルダ名ベースでテーブル名を付けるため、Athena側のテーブル名が人間可読でない名前になり、現実的な運用が難しくなります。 外部テーブルであればS3パスを s3://<バケット名>/table_name のようにテーブル名ベースで保持できるため、Athena上のテーブル名が人間にとって分かりやすい名前のままになり、移行コストを抑えつつ今後の管理も楽になります。 移行手順の全体像 移行は以下の5ステップで実施しました。 Step 1 :インフラ整備(IAM・Catalogの作成) Step 2 :Unity Catalogテーブルの作成 Step 3 :既存ETLコードの変更(テーブル参照パスの更新) Step 4 :AthenaからUnity Catalogのデータを参照できるようにする Step 5 :Quick Suiteのデータセット移行 Step 1:インフラ整備 Unity Catalogを有効化するにあたって、AWS・Databricks側でいくつかの設定が必要でした。 IAMロールの設定 DatabricksがS3バケットにアクセスするためのIAMロールと、それをDatabricks側に登録するストレージクレデンシャルを新規作成しました。インフラ変更はTerraformで管理しています。概念的には以下のような構成です。 resource "aws_iam_role" "databricks_unity_catalog" { name = "<ロール名>" assume_role_policy = jsonencode ( { Statement = [{ Effect = "Allow" Principal = { AWS = "arn:aws:iam::<Databricks の AWS アカウント ID>:role/unity-catalog-prod-role" } Action = "sts:AssumeRole" Condition = { StringEquals = { "sts:ExternalId" = <databricks_sts_external_id> } } }] } ) } Catalogの作成 Unity Catalogを利用するには、Databricks上にCatalogを作成する必要があります。Catalog作成時のmanaged storage location指定は任意ですが、今回は運用上の理由から指定のS3バケットをmanaged storage locationとして指定しました。 参照: Unity Catalog の Catalog を作成する(Databricks 公式ドキュメント) Step 2:Unity Catalog テーブルの作成 インフラが整ったら、既存のHive MetastoreテーブルのデータをUnity Catalogの外部テーブルとして再作成します。旧パスから schema/table というパス構成に移行するため、CTAS(CREATE TABLE AS SELECT)を使いました。 移行スクリプトの流れ 旧パスのDeltaテーブルを SELECT * で読み取る schema/table 形式のパスにDelta形式で書き込みながらUnity Catalogの外部テーブルを作成 # 旧 Delta からデータをコピーしながら UC 外部テーブルを作成 spark.sql(f """ CREATE TABLE IF NOT EXISTS {catalog}.{schema}.{table_name} USING DELTA LOCATION '{new_s3_path}' AS SELECT * FROM delta.`{old_s3_path}` """ ) Step 3:既存 ETL コードの変更 主な変更:テーブル参照パスの書き換え Hive MetastoreではS3パスを直接指定してデータを読み書きしていましたが、Unity Catalogでは catalog.schema.table の三層構造で参照するよう書き換えました。 # Before(Hive Metastore):S3 パスを直接指定 df = spark.read.format( "delta" ).load( "s3://path/to/delta" ) df.write.format( "delta" ).save( "s3://path/to/delta" ) # After(Unity Catalog):カタログ名で参照 df = spark.table( "catalog.schema.table" ) df.write.saveAsTable( "catalog.schema.table" ) Step 4:Athena から Unity Catalog のデータを参照する 前述の通り、Quick Suiteのデータソースは Athena → Glue Crawler → S3 という構成です。Unity Catalogに移行しても、この構成を維持する必要があります。 実際に行った変更 Glue Crawlerのクロール先を、Unity Catalog外部テーブルのS3パスに変更しました。 Before: 旧 S3 バケット(Hive Metastore 用)→ Glue Crawler → Athena After: Unity Catalog 外部テーブルの S3 バケット → Glue Crawler → Athena 具体的な変更内容: Glue Crawlerのクロール先S3パスをUnity Catalog外部テーブルのパスに変更 GlueのIAMポリシーにS3バケットへのアクセス権限を追加 Terraformで管理しているIAMポリシーとGlueリソースを更新し、 terraform apply 後にGlueコンソールからクローラーを手動実行してテーブルが正しく作成されることを確認しました。 Step 5:Quick Suite のデータセット移行 Step 4でGlue → Athena側の変更が完了したら、Quick Suiteのデータセット参照先を新しいAthenaテーブルに切り替えます。 変更自体はQuick SuiteのUIから接続設定を変更するだけで完結しますが、切り替え前に テーブルスキーマの互換性確認 が重要です。カラム名・データ型が一致していないと、ダッシュボードの集計が壊れます。 移行結果 Before / After 移行の成果をBefore / Afterでまとめます。 Before(Hive Metastore 時代) 項目 状態 テーブルスキーマ確認 Notebookを実行するかコードを読む必要がある データリネージ Mermaidで手作業管理 After(Unity Catalog 移行後) 項目 状態 テーブルスキーマ確認 Unity CatalogのUIでカラム一覧・データ型・説明文を即時参照 データリネージ UI上で自動生成・可視化(Mermaidでの手作業管理が不要に) 特にデータリネージの自動可視化は、Mermaidの維持コストをゼロにしてくれる大きな恩恵でした。Unity CatalogにETLジョブが書き込むと、どのテーブルがどのテーブルから作られているかが自動でグラフとして記録されていきます。 Unity Catalogのデータリネージ画面。どのテーブルがどのテーブルから作られているかが自動でグラフ表示される。 Databricks Managed MCP Unity Catalogへの移行をきっかけに、Databricks Managed MCPも使えるようになったので紹介します。 Databricks Managed MCP とは Databricks Managed MCPとは、DatabricksがホストするMCP(Model Context Protocol)サーバーです。Claude CodeなどのAIエージェントからDatabricksのリソースをツールとして呼び出せるようにする仕組みです。 Databricks Managed MCPはUnity Catalogとの統合を前提に設計 されています。今回の移行後、実際に使えるようになりました。 DBSQL MCP:ETL 開発が変わる Databricks Managed MCPの中でも、ETL開発が中心のデリッシュリサーチにとって特に相性が良さそうだと感じたのが DBSQL MCP です。これはClaude CodeやCursorのMCPとして設定することで、ETL開発中にSQLをその場で実行・確認できるようになるツールです。 提供されるツール: execute_sql_read_only :テーブルの内容・カラム定義・データ分布をその場で確認 execute_sql :SQLの実行 poll_sql_result :長時間クエリの結果をポーリング ※ ツール名や提供機能はアップデートで変更される可能性があります。上記は執筆時点(2026年3月6日)で使用できるツールです。 具体的な開発体験 ETLコードを書きながら、Claude Codeに対して次のような依頼ができるようになります: 「 catalog.schema.table のカラム構成を確認して」 「 product_id カラムのユニーク数を調べて」 「このテーブルとJOINするテーブルのスキーマを見せて」 DBSQL MCPを使うことでCursorやVS CodeなどのエディタからDatabricksのテーブルの中身・スキーマをリアルタイムで確認しながらコーディングができます。 なお、DBSQL MCP以外にもUnity Catalog functionsやGenie spaceなども使えるようになっています。 参照: Databricks Managed MCP 公式ドキュメント MCPの詳細な設定方法や活用例については、 こちらの記事 も参照してください。 まとめ 約40テーブルのHive Metastore → Unity Catalog移行を通じて得た主な成果を3点でまとめます。 データカタログとデータリネージの整備 :Unity CatalogのUIでスキーマ情報を参照でき、データリネージも自動管理されるため、「コードを読まないと分からない」問題が解消されました 外部テーブルで構成互換性を維持 :Athena連携(Glue Crawler)がある場合は外部テーブルを選ぶことで、既存のBI構成に影響を与えずに移行できました Databricks Managed MCPが使えるようになった :ETL開発中にエディタから出ずにテーブル情報を確認できるようになり、開発体験が向上しました 今後は、Unity Catalogへの移行によって使えるようになった機能の検証・活用をしていきたいと考えています。 Unity Catalogへの移行を検討している方の参考になれば幸いです。
アバター
こんにちは @kyo です! 2026年2月21日に開催された Go Conference mini in Sendai 2026 にて、「GoとWasmでつくる軽量ブラウザUI」というタイトルで登壇させていただきました。この記事では、発表中にいただいたフィードバックについて深掘りをして得られた知見をご共有できたらと思います。 フィードバック: 「 (*js.Value).Call は遅いので、 bind したうえで Invoke するといいですよ」 from Hajime Hoshi さん、Go製ゲームエンジン Ebitengine の作者 発表スライド speakerdeck.com 背景 Go の syscall/js パッケージでは、JS のメソッドを呼び出す方法が2つあります。 方法 Go コード 特徴 Call document.Call("getElementById", "myDiv") シンプルだが毎回オーバーヘッドあり bind + Invoke getElementById.Invoke("myDiv") 初期化が必要だが高速 Call が遅い理由 前提知識: Go Wasm の仕組み Go で書いた Wasm コードがブラウザの JS を呼び出すとき、直接呼べるわけではありません。 間に Wasm メモリ と wasm_exec.js (Go 公式提供の橋渡しスクリプト)を挟んでやりとりします。 Wasm メモリ(Linear Memory)とは? Wasm メモリは WebAssembly の仕様で定義された WebAssembly.Memory オブジェクトで、 実体は Go(Wasm)と JavaScript の 両方からアクセスできる巨大なバイト配列 ( ArrayBuffer )です。 「リニアメモリ(Linear Memory)」とも呼ばれます。 developer.mozilla.org wasmbyexample.dev 普通、Go と JS はお互いの変数を直接見ることができませんが、 この共有のバイト配列を「伝言板」のように使うことで、データをやりとりできます。 例: document.Call("getElementById", "myDiv") の場合 Go 側が "getElementById" という文字列をバイト列に変換して Wasm メモリに書き込む JS 側( wasm_exec.js )が Wasm メモリからそのバイト列を読み出す TextDecoder で JS の文字列に変換する(= loadString() ) その文字列を使って document["getElementById"] を探す(= Reflect.get() ) 見つけた関数を実行する Invoke が速い理由は、このステップ 1〜4 を丸ごとスキップできるからです。 事前に関数への参照を取得しておけば、Wasm メモリを経由した文字列のやりとりが不要になります。 Call の処理の流れ Go 側で document.Call("getElementById", "myDiv") を呼ぶと、 wasm_exec.js の以下のコードが実行されます: // wasm_exec.js "syscall/js.valueCall" : ( sp ) => { sp >>>= 0 ; try { const v = loadValue ( sp + 8 ) ; // ① オブジェクトを取得(例: document) const m = Reflect . get ( v , loadString ( sp + 16 )) ; // ② ここが遅い(後述) const args = loadSliceOfValues ( sp + 32 ) ; // ③ 引数を取得(例: "myDiv") const result = Reflect . apply ( m , v , args ) ; // ④ 関数を実行 sp = this. _inst . exports . getsp () >>> 0 ; storeValue ( sp + 56 , result ) ; // ⑤ 結果をメモリに書き戻す this. mem . setUint8 ( sp + 64 , 1 ) ; // ⑥ 成功フラグ } catch ( err ) { // エラー処理... } } , ② が遅い理由には二つの原因があります const m = Reflect . get ( v , loadString ( sp + 16 )) ; // ^^^^^^^^^^^^^^^^^^ ← (A) 文字列デコード // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^← (B) プロパティ検索 (A) loadString() — 文字列デコード const loadString = ( addr ) => { const saddr = getInt64 ( addr + 0 ) ; // Wasmメモリ上の文字列の開始位置 const len = getInt64 ( addr + 8 ) ; // 文字列の長さ(バイト数) return decoder . decode ( // TextDecoder で バイト列 → JS文字列に変換 new DataView ( this. _inst . exports . mem . buffer , saddr , len ) , ) ; } ; Go が Wasm メモリに書き込んだバイト列を、 TextDecoder を使って JavaScript の文字列 "getElementById" に変換しています。 この処理では毎回 new DataView の生成と decoder.decode() が走っています。 (B) Reflect.get() — プロパティ検索 補足: プロパティとプロパティ検索とは? JavaScript のオブジェクトは、名前(キー)と値のペアの集まり です。 この「名前と値のペア」1つ1つを プロパティ と呼びます。 // document オブジェクトのイメージ(実際はもっと多い) document = { "getElementById" : function ( ... ) { ... } , // ← プロパティ "createElement" : function ( ... ) { ... } , // ← プロパティ "querySelector" : function ( ... ) { ... } , // ← プロパティ "title" : "My Page" , // ← プロパティ // ... 他にも数百のプロパティがある } ; プロパティ検索 とは、この中から名前を指定して値を探す処理です。 Go でいえば `map[string]any` から `map["getElementById"]` でキーを探すのに近いイメージです。 // プロパティ検索の例(どれも同じ意味) document .getElementById ; // ドット記法 document [ "getElementById" ] ; // ブラケット記法 Reflect . get ( document , "getElementById" ) ; // Reflect API(wasm_exec.js が使う方法) Reflect . get ( v , "getElementById" ) ; // これは実質的に v["getElementById"] と同じ // = document オブジェクトから "getElementById" という名前の関数を探す JavaScript のオブジェクトからプロパティ名で関数を検索します。 ここの処理でも毎回この探索処理が走ります。 Invoke の処理の流れ 一方、 getElementById.Invoke("myDiv") を呼ぶと // wasm_exec.js "syscall/js.valueInvoke" : ( sp ) => { sp >>>= 0 ; try { const v = loadValue ( sp + 8 ) ; // ① 関数そのものを取得(文字列ではない) const args = loadSliceOfValues ( sp + 16 ) ; // ② 引数を取得 const result = Reflect . apply ( v , undefined , args ) ; // ③ 関数を直接実行 sp = this. _inst . exports . getsp () >>> 0 ; storeValue ( sp + 40 , result ) ; // ④ 結果をメモリに書き戻す this. mem . setUint8 ( sp + 48 , 1 ) ; // ⑤ 成功フラグ } catch ( err ) { // エラー処理... } } , Call との違い loadString() がない → 文字列デコードが不要 Reflect.get() がない → プロパティ検索が不要 v はすでに関数への参照なので、 Reflect.apply() で直接呼ぶだけ 処理の違いまとめ Call の処理: Go → [メソッド名をメモリに書く] → JS: loadString() → Reflect.get() → Reflect.apply() ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~ ~~~~~~~~~~~~~ 毎回発生するオーバーヘッド 文字列デコード プロパティ検索 Invoke の処理: Go → JS: Reflect.apply() 図解 1. Call パターン(毎回のオーバーヘッド) 2. bind + Invoke パターン(初回のみオーバーヘッド) 3. 処理ステップの比較 4. bind が必要な理由 JS ではメソッドをオブジェクトから切り離すと this コンテキストが失われます。 bind で this を固定しないと Invoke 時にエラーになります。 コード例 遅いパターン(毎回 Call ) document := js.Global().Get( "document" ) for i := 0 ; i < 1000 ; i++ { // 毎回: 文字列書き込み → デコード → プロパティ検索 → 実行 element := document.Call( "getElementById" , "myElement" ) element.Call( "setAttribute" , "data-index" , i) } 速いパターン( bind + Invoke ) document := js.Global().Get( "document" ) // 初期化: bind で this を固定 getElementById := document.Get( "getElementById" ).Call( "bind" , document) for i := 0 ; i < 1000 ; i++ { // 毎回: 関数実行のみ(文字列処理・プロパティ検索なし) element := getElementById.Invoke( "myElement" ) // ... } 実用的なパターン: よく使うメソッドをまとめて事前バインド var ( document = js.Global().Get( "document" ) getElementById = document.Get( "getElementById" ).Call( "bind" , document) createElement = document.Get( "createElement" ).Call( "bind" , document) querySelector = document.Get( "querySelector" ).Call( "bind" , document) consoleLog = js.Global().Get( "console" ).Get( "log" ).Call( "bind" , js.Global().Get( "console" )) ) func getElement(id string ) js.Value { return getElementById.Invoke(id) } func newElement(tag string ) js.Value { return createElement.Invoke(tag) } オーバーヘッド比較表 処理 Call bind + Invoke 文字列の Wasm メモリ書き込み 毎回 初回のみ TextDecoder によるデコード 毎回 初回のみ Reflect.get (プロパティ検索) 毎回 初回のみ Reflect.apply (関数呼び出し) 毎回 毎回 makeArgSlices + storeArgs 毎回 毎回 ベンチマーク結果(10,000回呼び出し) 各メソッドを計測した実測結果 DOM操作(実際のJS API) JS API自体の実行コストが含まれるため、相対的な差は小さい。 // Call パターン document.Call( "getElementById" , "myElement" ) // bind+Invoke パターン getElementById := document.Get( "getElementById" ).Call( "bind" , document) getElementById.Invoke( "myElement" ) 対象メソッド Call (ms) bind+Invoke (ms) 差分 速度比 document.getElementById 48.7 46.6 +2.1 ms 1.05倍 console.log 68.3 59.3 +9.0 ms 1.15倍 element.setAttribute 26.8 25.8 +1.0 ms 1.04倍 DOM操作自体のコストが大きいため、Call と bind+Invoke の差は 3〜15% 程度に留まる。 純粋なオーバーヘッド検証 JS側の処理コストを排除し、 Call 固有のオーバーヘッドを可視化。 空の関数(何もしない関数) JS側に何もしない関数を用意し、呼び出しオーバーヘッドだけを測定。 // Call パターン: 毎回「文字列デコード → プロパティ検索 → 関数実行」 noopObj.Call( "noop" ) // bind+Invoke パターン: 事前バインド済みなので「関数実行」のみ noop := noopObj.Get( "noop" ).Call( "bind" , noopObj) noop.Invoke() 対象 Call (ms) bind+Invoke (ms) 差分 速度比 noop 1.90 0.40 +1.50 ms 4.76倍 JS側の処理コストがないため、 Call 固有のオーバーヘッド(文字列デコード + プロパティ検索)が約4〜5倍の差としてはっきり現れる。 メソッド名の長さによる影響 Call は毎回メソッド名を文字列デコードするため、名前が長いほどコストが増えるか検証。 // 短いメソッド名(1文字) obj.Call( "a" ) // 長いメソッド名(30文字) obj.Call( "abcdefghijklmnopqrstuvwxyz1234" ) // bind+Invoke はどちらも同じ(事前バインド済み) fn := obj.Get( "a" ).Call( "bind" , obj) fn.Invoke() 対象 Call (ms) bind+Invoke (ms) 差分 速度比 メソッド名 "a"(1文字) 1.80 0.50 +1.30 ms 3.61倍 メソッド名 "abcdefghij...1234"(30文字) 2.10 0.60 +1.50 ms 3.50倍 メソッド名の長さはほぼ影響しない。 TextDecoder のコストは小さく、Go↔JS間の境界越え自体( valueCall のスタック操作 + Reflect.get )の方がはるかに大きい。(メソッドをたくさん増やしたらもっと差が出るかも) いつ使い分けるか シナリオ 推奨 理由 高頻度呼び出し(60fps 描画、大量 DOM 操作) bind + Invoke オーバーヘッド削減の効果が大きい 低頻度呼び出し(ボタンクリック等) Call でOK 可読性を優先、パフォーマンス差は体感できない 同じメソッドをループで繰り返し呼ぶ bind + Invoke 最もメリットが出るケース まとめ Call は毎回「文字列の Wasm メモリ書き込み → TextDecoder によるデコード → Reflect.get によるプロパティ検索」という3つのオーバーヘッドが発生する bind + Invoke は事前に関数参照を取得・固定しておくことで、これらのオーバーヘッドをすべてスキップし、 Reflect.apply で直接関数を実行できる 純粋なオーバーヘッド比較では約4〜5倍の差があり、高頻度呼び出し(描画ループや大量DOM操作)では効果が大きい 一方、DOM操作自体のコストが大きい場面では差は数%程度に留まるため、低頻度の呼び出しでは Call のシンプルさを優先して良さそう よく使うメソッドを var でまとめて事前バインドしておくのが実用的なパターン 最後に Go Conference mini in Sendai で Hajime Hoshi さんからいただいた「 Call は遅いので bind + Invoke がいいですよ」というフィードバックは、最初は「そういうテクニックがあるんだな」程度の理解でした。しかし実際に wasm_exec.js のソースコードを読んでみると、 Call が遅い理由は単なる「関数呼び出しの方法の違い」ではなく、Go と JavaScript という2つの異なるランタイムが Wasm メモリという共有バイト配列を介してやりとりする仕組みそのものに起因していることがわかりました。 普段 Go を書いているだけでは意識しない「文字列がバイト列として Wasm メモリに書き込まれ、JS 側で TextDecoder によってデコードされる」という一連の流れを知ったことで、Go Wasm が裏側でどれだけの処理をしているのかを実感できました。と同時に、 wasm_exec.js がたった1つのファイルで Go と JS の橋渡しをすべて担っていることに、改めてすごさを感じました。 カンファレンスでのたった一言のフィードバックが、ここまで深い学びにつながるとは思っていませんでした。発表して、フィードバックをもらって、それを深掘りする——このサイクルの価値を改めて実感しています。 参考 golang/go#32591 — syscall/js: performance considerations golang/go#39740 — syscall/js: increase performance of Call, Invoke, and New golang/go#44006 — syscall/js: remove Wrapper type to avoid extreme allocations Go syscall/js ソースコード Go wasm_exec.js ソースコード
アバター
はじめに こんにちは。リテールハブ開発部小売アプリチームの池です。 エンジニアチームのマネージャーになってから、気づけば1年半が経ちました。 この1年半を振り返ると、悩みながら行動を続けてきた時間でした。マネージャーとしてどう行動すべきか日々悩みながら試行錯誤し、周りの支援を借りつつ、自分なりにこれだと思うことを試しては失敗を重ね、走り続けてきました。その中で意識していたのは、ただ失敗を繰り返すだけではなく、そこから得られる学びを積み重ねて次に活かすことです。 この記事では、マネージャーが日々何を考え、どんな判断をしているのかを共有したいと思います。また、失敗談が中心にはなりますが、同時にマネージャーという仕事は「人やチームと向き合う仕事」であり、多くの魅力とやりがいがあることも伝えられたらと思っています。マネージャーに興味があるエンジニアの方、同じ立場で悩んでいるマネージャーの方、あるいはマネージャーが何を考えているか知りたいチームメンバーの方にとって、少しでも参考になれば幸いです。 今回は、自身の振り返りも兼ねて、その中でも特に強く心に残っているマネージャーとしての意思決定に関する学びをピックアップして振り返ります。 背景 私がマネージャーになった当時のチーム状況を説明します。 1年半前の当時、私のチームでは以下の業務を並行して進めていました。 自社でゼロから新規開発している小売アプリの開発 事業譲渡で引き継いだ5つの小売アプリの運用保守 エンジニアは私を含めて3名、デザイナー1名、PdM1名の計5名を中心に、上長の支援を得ながらそれらの業務をこなす必要がありました。 引き継いだアプリそれぞれの全容は十分に把握できておらず、わからないことだらけです。コードから仕組みを読み解きながら運用保守、顧客からの問い合わせ・要望に対応する日々でした。チームも発足して間もなく体制が整っておらず、PdMも入れ替わりで着任したばかり。そこに新規開発も並行して進めていて、カオスな状態でした。 そんな中で、私は初めてマネージャーを担うことになりました。 失敗①:チームの方針を明確に示さなかった 一つ目の失敗は、マネージャーとして方針を明確に示すことの重要性と、それがチームに与える影響の大きさを理解していなかったことです。 その当時、少ない人数のチームで新規開発と既存運用が同時に走り、毎日やることが尽きません。私自身もプレイヤーとして動かないと回りませんでした。「まずは足元の開発を回すこと」が最優先で、開発や調整、障害対応と、自身も一人のプレイヤーとして目の前のタスクを一つずつ処理し、なんとか回すことに必死でした。 一方で、私はマネージャーになったばかりです。方針を示すことの大事さを理解していませんでした。どこを目指すのか、何を優先するのか。そういったことを言語化するという発想自体が薄かったのだと思います。 そして、方針を明確に示さずに全てをうまく回そうという意識のまま、大きな対策を打たずに走り続けていました。その結果、チームはどこに向かっていいかわからない状態になっていきました。 重要でないことに時間を使ってしまう 自律的な判断が難しくなる 方針がないと、迷いながら働くことになります。技術負債をどこまで許容するのか、属人化をどこまで受け入れるのか、ドメイン理解にどれだけ時間をかけるのか、作り込みすぎないラインはどこか、各々の判断のズレが積み重なりチームはさらに忙しくなっていきました。 結果的に「全部をそのままやる」ということが暗黙の方針となり、当然ながら、すべてが中途半端になり目標達成も遠のきます。メンバーの不満も溜まり、私自身も時間で解決しようと夜遅くまで働くことが増えました。疲弊するばかりで状況は良くなりません。 方針が全てを解決するわけではないですが、方針を示さなかったからこそ、余計な忙しさを生んでいたのだと思います。 学び 忙しくても、方針を示すことだけは省いてはいけません。 何を最優先にするのか 何を後回しにするのか どこまでやれば十分か このような方針があるだけで、チームは「何に時間を使うべきか」を考えられるようになります。 今は、「やること/やらないこと」を明確にすることを意識し、忙しい時ほど立ち止まるようにしています。 失敗②:チームを見ずに手法を当てはめた 2つ目の失敗は、解決策から入ってしまったことです。 マネージャーに役割が変わると求められるスキルは変わり、人やチームを動かすスキルが必要になります。しかし当時の私は、その変化を十分に受け止めきれていませんでした。マネージャーとしての理解も引き出しもなく、何をすればいいのかわからない。そんな状態です。 わからないなら学ぶしかないと思い、本や記事を読み、過去の自身の成功体験や他社の成功事例に答えを求め、「これが正解だろう」と思ったものをチームに適用しました。 その一つがスクラム開発の導入です。自分の過去の経験からスクラムをやることでチームが良い方向に進むと、どこかで信じていました。マネジメントに自信が持てない中で、実績のある手法を頼ろうとしていたのだと思います。 スクラム自体は良い手法ですが、そのときのチームのフェーズや状況には合っていませんでした。本来私がやるべきだったのはプロセス改善ではなく、チームの課題を見つけてどう解決するかを考えることです。 スクラムをうまく運用できなかったことにも問題はありますが、チームの課題を見ずに形式的に導入しても効果は限定的になります。その結果、重要ではない会議や作業、議論が増えていきました。 たまたまチームの問題とスクラムの手法がマッチしていた箇所では効果が出たものの、全体としては納得感も高まらず、次第に形骸化して空回りしていき、最終的にはスクラムをやめる判断をしました。 失敗の原因は手法そのものではなく、チームを見ていなかったことでした。 学び まずやるべきことは、手法を探すことではなく、チームの状態を観察して明らかにすることでした。 何が一番のボトルネックなのか どこにエネルギーを割くべきなのか メンバーは何に困っているのか それらを言語化した上で解決策を考えるべきでした。 チームはそれぞれ、プロダクトのフェーズや事業状況も、メンバーの性格・スキルも異なります。当然、課題やボトルネックもチームごとに違います。同じ状況のチームは存在しません。だからこそ、マネジメントにどのチームにも当てはまる画一的な手法はありません。 特に、ベンチャー企業の新規事業で限られたリソースと期間で目標を達成する必要がある環境を踏まえると、何を優先し何を捨てるかの判断は大きく変わってきます。そのためにも、まずはチームを観察し、置かれた状況を把握した上で、行動を考える必要がありました。 一方で、考え過ぎて動けなくなるのもまた問題です。すべてを理解してから動くことはできません。実際には、軽く試し、軽く失敗し、そこから学ぶことも多くあります。行動してみて初めて見えてくる課題もあれば、後から納得感がついてくるケースもあります。 今は、観察しながら動き、チームの反応を見て調整することを意識しています。 失敗③:一度決めた方針を続け過ぎてしまった 3つ目の失敗は方針を見直さなかったことです。 失敗①②を経て、私はチームの状況を見て方針や行動の意思決定を意識するようになりました。 たとえば、初期フェーズで作るものがある程度決まっている状況では、ドメイン理解を一定に留めることや属人化を許容するという判断をしました。その時点では合理的な判断だったと思います。 しかし問題は、その方針を見直すべきタイミングで見直さなかったことでした。 プロダクトの状況は変化し、チームの構成も変わり、メンバーも成長しているのにもかかわらず、その判断だけが更新されないままになっていました。 その結果、以下のような影響が徐々に現れてきました。 技術的な判断の拠り所が持てない場面が増えていく 「自分たちが作っているものは本当に価値があるのか」という空気がチームに漂い始める 属人化が固定して急な休みが取りづらくなる アラート対応の担当者が偏る 変化の兆しには気づいていましたが、対策を打てていない自分もいました。方針を決めた後の運用ができていなかったのです。 学び 方針にもメンテナンスが必要です。定期的に見直さないと現実との乖離が大きくなっていきます。 チームが不健全な状態になっていないか チームの熱量や納得感は下がっていないか このような、出ていたはずのシグナルをしっかりと見逃さず、短いサイクルで方針が実状に合っているかを問い続ける必要があります。 方針は一度決めて終わりではなく、状況の変化に合わせて更新し続けるものだと学びました。 おわりに 本記事では、マネージャーとしての意思決定に関する3つの失敗を振り返ってみました。実際にはもっと多くの失敗をしています。 大事なことは、失敗しないことではないと思っています。 マネージャーの仕事に正解はないと、この1年半で実感しました。完璧な判断を下し続けることはできません。それでも、打席に立ち続けることはできます。迷いながらでも決める。うまくいかなければ振り返って次に活かす。その繰り返しで前に進めると思っています。 一方で、失敗ばかりを書いてきましたが、マネージャーの仕事にはそれ以上のやりがいがあると感じています。一人では到底成し遂げられないことをチームで実現できたときの達成感。エンジニアとは異なる視点やスキルが求められる中で、自分自身が成長していく実感。そして何より、メンバーの成長や変化に向き合いながら、チームが前に進んでいく過程を間近で見られることの面白さ。マネージャーの醍醐味だと思っています。 まだまだ未熟ですが、これからも打席に立ち続け、学び続けていければと思います。
アバター
はじめに こんにちは。リテールハブ開発部の清水です。 先日 SRE Kaigi 2026 に参加してきまして、私の中でSRE熱がかなり高まっています。 私たちはDatadogをオブザーバビリティ基盤として使用しているのですが、私自身はDatadogをまともに触った経験がありませんでした。 Datadogの画面を開くと左のメニューだけでも大量の項目があって、何ができるのか把握すること自体に大きなハードルを感じていました。 そのような中で、Datadog Learning Centerというものを知りました。無料でハンズオン形式の学習ができるとのことだったので、実際にやってみることにしました。 Datadog Learning Centerとは Datadog Learning Center は、Datadogが公式に提供している無料のオンライン学習プラットフォームです。 ブラウザ上でハンズオン形式でDatadogの各機能を実際に操作して学ぶことができます。 コースはGetting Started、APM、Logs、Kubernetes、Securityなどのカテゴリに分類されていて、初心者向けの入門コースから特定のケースにフォーカスしたコースまで幅広く用意されています。 学習環境について ラボという学習環境が提供されるので、ブラウザさえあれば学習できる形になっていました。 ラボの画面では左半分にターミナル、右半分に学習教材が表示されます。 学習用のDatadogアカウントが自動作成されて、IDとパスワードがターミナル上に表示される仕組みです。 これをDatadogのログイン画面に入力すれば、学習用のアカウントで実際のDatadog環境を自由に触ることができます。 自分の本番環境を壊す心配がないので、気軽に色々試せるのが良いところです。 なお、コンテンツはすべて英語です。英語が苦手な方にとってはややハードルが高いかもしれません。 最初に取り組んだコースについて Datadog Learning Centerの コース一覧画面 を開くと多数のコースがずらりと並んでおり、圧倒されます。 画面上部に LEARNING PATHS というリンクがあるので、ここを開くと目的別に整理された学習順序が紹介されています。 私はこの画面で紹介されている Core Skills Learning Path から始めました。 以下の6コースで構成されており、Datadogの基本的な操作スキルを身につけるための入門パスです。 Datadog Quick Start Tagging Best Practices Getting Started with Metrics Getting Started with Monitors Introduction to Dashboards Getting Started with Notebooks 所要時間 じっくり内容を確認しながら進めて、各コースで30分~1時間程度、合計4時間で終わりました。 学習した感想 Datadogに対する恐怖感が薄れた 一番大きな収穫は、Datadogでざっくりどんなことができるのかわかったことです。 学習前はDatadogの画面を開いても、左メニューに並ぶ項目が何を意味しているのかわからず、触ること自体に抵抗がありました。 学習後は、メトリクス、ダッシュボード、モニター、ログ、APM、トレース、Software Catalog、ノートブックといった用語がそれぞれ何を指しているのか、なんとなく掴めるようになりました。 正直なところ、すべてを完璧に理解できたわけではありません。ただ、「何がわからないかがわからない」状態から「各機能の役割はわかった上で、詳細はこれから深掘りしていけばいい」という状態になれたのは大きな進歩でした。 今回学習したCore Skills Learning Pathはよく使う部分をざっと紹介してもらうような内容となっており、他のコースで実践的な内容を学んでいけるのではないかと思います。 学習用Datadogアカウントの中身を見るだけで勉強になった 学習用Datadogアカウントには、架空のWebサービスを題材として本番さながらの設定やデータが最初から用意されています。 AIに「これはどんな機能ですか?」と会話しながら各画面を見ていくと各種機能について素早く理解が進むと感じました。 特にダッシュボードの作り方がすぐ真似できる要素があって参考になりました。 元々、私たちはテナントごとに別々のダッシュボードを用意していて、それぞれのダッシュボードの中でも各種リソース状況がフラットに並んでいるだけの状態でした。 学習用Datadogアカウントのダッシュボードから以下の内容を真似することにしました: ダッシュボードを全テナント共通にして、テンプレート変数で切り替える 画面上部にはリンク集とMonitor Summaryを設置する 画面要素をGroupウィジェットでグループ分けして表示する 改善前と見比べると、情報の整理や視認性がだいぶ向上したのではないかと思います。同じようにダッシュボードの構成に悩んでいる方の参考になれば幸いです。 おわりに Core Skills Learning Pathは、Datadogを初めて触る人にとって良いスタート地点でした。4時間程度の投資で、Datadogの主要機能の全体像と基本操作を一通り学ぶことができます。 Learning Centerにはこのパス以外にも、さまざまな機能に特化したコースが多数用意されています。今後も引き続き他のコースに取り組み、Datadogの活用の幅を広げていきたいと考えています。 Datadogを使っているけれどちゃんと学んだことがないという方は、ぜひCore Skills Learning Pathから始めてみてはいかがでしょうか。
アバター
はじめに こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと申します。 現在、Laravel などを利用しながら小売アプリ開発に取り組んでいます。 先日、サービスのリリースに伴い、旧サービスの外部システムから当社のMySQL DBへユーザーデータ移行を行う機会がありました。 ただ今回、今まで行ったデータ移行と大きく違うのは、ユーザーの個人情報を含んだデータ移行でした。 データ移行自体はこれまでも経験していましたが、個人情報を含む移行は前提が異なり、多くの学びと反省点がありました。 そこで本記事では以下の点についてお話できればと思います。 実データを自由に扱えない状況での事前準備の進め方 リハーサルの重要性と、実施できる場合の考え方 今回採用した移行方式と「変換できたのに正しく入っていない」問題と確認点 移行当日に意識しておくべき判断と心構え 次回のデータ移行に向けた改善点 それぞれの中で経験したことや反省点を記載しながら、今後に向けた改善点などを記載しています。 また、個別の移行内容というよりは、データ移行を進める際の準備・検証・判断の考え方に焦点を当てています。 1. 実データを自由に扱えない状況での事前準備の進め方 個人情報を含むデータは「自由に触れない」 個人情報が含まれる場合の制約 開発環境・検証環境に簡単に持ち込めない ログ出力、スクリーンショット、共有方法にも制限がある データ配置・保管場所にも慎重な判断が必要 事前確認が十分にできない 「本番で初めて見るデータ」が発生しがち など通常とは異なる制約がある中で行う必要がありました。 この部分をもっと考慮した上で、事前準備が必要であったところは大きな反省点でした。 「できないから仕方ない」で済ませると危険 事前にできることはできる限りやり、仕様書・IF定義だけで満足しないことが大事です。 とはいえ対応できる範囲も限界はあるので以下の点を特に注意できればと思っています。 NULL / 空文字 / 不正値の扱いは各項目でどうなっているのかを明確にする。 桁数・文字種・フォーマットのばらつきがあることを前提とした考慮をする。 仕様上はOKでも、実データでは違う可能性を考える。 (ここは正直難しいですが、データパターンを確認し、できる限り考慮したいです。) あるあるの話ですが、通常は仕様に基づいて対応するのが当たり前ですが、 こういった大量データになるとおかしなデータはほぼ混ざっているのが逆に普通かと思っています。 2. リハーサルの重要性と、実施できる場合の考え方 今回は事前にリハーサルが実施できる状況でした。 しかし、以下の点でしか検証を行えていませんでした。 データ移行手順が問題ないこと(スムーズに移行作業が完了できる) 個人情報データの取り扱いに問題がないこと データ変換がエラーなく行われ、正常にDBへ投入できること(想定通りのフォーマットであること) 実際に移行したデータを使用して簡単な動作確認を実施し問題ないこと 上記はどれもリハーサルで行う必要な項目で、どれも大事なのですが、 ただ、本番データを想定した観点としては以下の点でもっと時間をかけて行うべきでした。 DBに入った内容に想定していないデータが入っていないか データパターンを各項目で洗い出し、システム上問題ない値であること 想定していないパターンの場合、どう対処するかを事前に明確にすること しかし、これも個人情報のデータとなると、あまり時間をかけてのリハーサルや ローカルに保存して後ほど詳細に確認もしにくいのもあり、簡単な確認で済ませてしまっていました。 元データを最大限に利用する 一番確実なのはやはり元データを使用できることです。 当日のデータ移行の検証という意味では、これを利用した検証が一番確実だと思います。 リハーサル当日だけで出来ない部分は、別日に詳細にできればと良いと思います。 しかし、今回のような個人情報データは自由に扱えないことを理由にうまく利用できていなかったのも反省点です。 いかにここでそのファイルを活用したダミーデータを作れるかも非常に有効だったのではと思っています。 3. 今回採用した移行方式と「変換できたのに正しく入っていない」問題と確認点 どうやって移行を行おうとしたか 今回は以下の方法で行いました。 移行元データ ↓ phpでデータを読み込み、MySQLのLoadData用TSVファイルにコンバートするコードを作成 ↓ Load DataでTSVデータをDBへ投入 ↓ データ移行完了 特別な処理は特になく、シンプルな変換処理でした。 「変換できたのに正しく入っていない」問題と確認点 コンバートまでは特に問題なく変換できて、件数・エラーチェックは行えていましたが、 以下の点が見落としポイントでした。 データ投入できていて、かつ全件「自動変換」など発生なく投入できているか? Load時にWarningなどが発生していないか?(投入はできていても実は正常に入っていないケースがある) カラム定義上は問題なく入っているが、値が想定していないものではないか? Load Data はデータ投入時に非常に便利ですが、上記の点をしっかりと考慮した上で使用しないとデータ移行時の確認内容としては不十分になってしまいます。 特に日付はNULLの場合と「0000-00-00」で入る場合で全く挙動が異なります。 MySQLでは「0000-00-00」はNULLではなく「値」として扱われます。 そのためアプリケーション側では未設定ではなく異常値として存在してしまうことになり、 日付計算・バリデーション・ORM変換で後から問題を引き起こす可能性があるので特に注意が必要です。 今回の移行処理の責務を分離して考える 本来は以下のようにそれぞれのフェーズの責務を明確にして、対応をしていく必要がありました。 フェーズ 役割 保証すること 保証できないこと コンバーター 生データをLoad Data用形式へ変換 TSV構造・文字コード・基本的な値変換 DBがどう解釈するか、業務的な正しさ Load Data DBへ高速に一括投入 指定件数の取り込み、物理的な格納 型変換の影響、暗黙変換、Warningの発生 DB格納後の確認 実データの妥当性検証 業務的に正しい値であることの確認 この確認を行わないと移行成功とは言えない 特に今回は、Load Data の部分とDB格納後の確認が混在した確認になっていたと反省しています。 実際のデータパターンの検証例 パターンは膨大にあるようで、実はシンプルなものをいくつか用意しておくだけでも違うと思っています。 -- 不正日付確認 SELECT COUNT (*) FROM users WHERE birthday = ' 0000-00-00 ' ; -- NULL発生率確認 SELECT COUNT (*), SUM (email IS NULL ) FROM users; -- 想定外形式確認(都道府県以外のパターン確認) SELECT prefecture, COUNT (*) FROM users GROUP BY prefecture; AIを活用したパターン洗い出し・事前テストの試み 今回、AIをうまく使いこなせていなかった点も反省点だったと思っています。 まさにAIをうまく活用できる点として、 前述したデータパターンを様々な観点で出すことができる 個人情報部分は適当なパターンデータに変換して検証することもできる 当時は本番データをそのまま使用できないことで、ちょっとしたサンプルデータ、パターンデータで行い、 十分な事前検証も不足していました。 4. 移行当日に意識しておくべき判断と心構え 今までいくつか事前準備と慎重な検証が必要と記載をしていますが、 当日は必ず想定外が起きると思って計画を立てた方が良いかと思っています。 特に膨大なデータや内容が多いほど顕著になるかと思います。 以下の点に注意して当日は望めるようにしておきたいです。 1つ1つの作業完了条件を明確にしておくこと。 ロジックや仕様変更をその場でする修正は原則しないこと。 移行を中止、延期する、または後日対応でOKかなどの判断が当日できる人がいること。 移行がもし出来ない場合のリカバリ方法があること。 また、時間も想定しているよりも掛かってしまうことが多いと思います。 なるべく余裕をもった計画にしたいです。 今回は投入データの件数確認でさえ、すぐに終わるつもりが件数の正解を事前に用意していなかったため、 非常に大きく時間をかけてしまったのも今回の反省点でした。(完了条件が明確になっていない) 5. 次回のデータ移行に向けた改善点 今回の経験から以下の点を意識し改善できるようにしていきたいです。 一連の手順、動作確認は事前リハーサルでできる限り行うこと。 本番当日に向けた事前準備はしっかり時間を確保して対応する。(当日は必ず何か起きる前提) 当日の作業の完了基準の明確化(データ件数、エラー有無、動作確認など) Load Dataなどデータ投入後の部分が一番大事かつ明確にする部分であること。 仕様通りに作成しても、不正データは必ずある前提で行い、それらも考慮すること。 個人情報を扱うデータの場合は、同等のダミーデータを用意できると検証時に非常に有効。 検証パターン、データ生成などはAIも活用し、検証精度やコスト減に役立てる。 6. まとめ 通常、データ移行は当日一度きりの作業で、 同じ内容で再度実施するケースは少なく、個別のノウハウは蓄積しにくいところもありますが、 共通の考え方、対応として、 事前準備の重要性 検証設計の明確化 当日の判断基準 何かあった時のリカバリ方法 これらを意識することで、当日の障害確率は下げられると思っています。 改めて今回を通じて、データ移行は技術作業というより、検証設計と判断設計の仕事だったと感じています。 正直、完璧に準備をして全てスムーズに完了させることはなかなか難しいところですが、 もし今後データ移行などの作業をする際に本記事が少しでも参考になれば幸いです。 最後までお読みいただきありがとうございました。
アバター
Go の JSON Schema ライブラリたちはどのように JSON Schema を表現しているか 目次 はじめに JSON Schema について ライブラリごとの JSON Schemaの表現の比較 google/jsonschema-go invopop/jsonschema santhosh-tekuri/jsonschema ianlancetaylor/jsonschema 横断比較 まとめ はじめに こんにちは、開発本部開発1部の あかがわまさとも です。 2026年2月21日に開催された Go Conference mini in Sendai 2026 にて、「google/jsonschema-goのこれまでとこれから」というタイトルで登壇させていただきました。本記事では、調査の過程で行った、Go の JSON Schema ライブラリたちが、それぞれどのように JSON Schema を表現しているかの比較について述べます。 当日の発表では、JSON Schemaのおさらいから、 google/jsonschema-go の登場背景、機能についてお話ししました。よければご覧ください。 JSON Schema について JSON Schema は、JSONデータの構造や型、制約を定義するための言語です。JSON Schema それ自体もJSONで書かれます。 { " type ": " object ", " properties ": { " name ": { " type ": " string " } , " age ": { " type ": " integer ", " minimum ": 0 } } , " required ": [ " name " ] } 上に挙げた例はシンプルに見えますが、JSON Schema を Go のデータ構造で表現しようとすると、いくつかの難しさがあります。 キーワードの多さ : JSON Schema のキーワードは type 、 properties 、 items 、 minimum 、 maximum 、 pattern 、 allOf 、 anyOf 、 $ref など非常に多岐にわたります。これらをまとめて Go の型、例えば構造体として表現するには、多くのフィールドを用意する必要があります。そして、付随する機能たちの実装も比例して大きくなります。 バージョン間のキーワード差分 : JSON Schema にはいくつかのドラフトバージョンがあり、現在は Draft 2020-12 が最新です。バージョン間ではキーワード名やセマンティクスが変わることがあります。例えば、スキーマ定義の格納先は Draft 7 では definitions ですが Draft 2020-12 では $defs です。タプルのバリデーションは Draft 7 では items (配列形式)ですが Draft 2020-12 では prefixItems に変わりました。同じデータ構造で複数バージョンをどう扱うかは設計上の判断が分かれるところです。 $ref による参照 : JSON Schema ではスキーマ同士が $ref で参照し合います。この参照を Go のデータ構造上でどう保持するかも、ライブラリの設計思想が現れるポイントです。 ライブラリごとの JSON Schemaの表現の比較 今回は著名さと自分の興味から、以下の4つを比較対象としました。 google/jsonschema-go reference 元々 Go の公式 MCP SDK 内で作られていた、google が開発している JSON Schema ライブラリ JSON Schema バージョンは Draft 2020-12, Draft 7 をサポート invopop/jsonschema reference Inference(Goの型からのスキーマ生成)に強みを持つ JSON Schema バージョンは Draft 2020-12 をサポート santhosh-tekuri/jsonschema reference 2017年にv1がリリースされるなど歴史が長く、現在v6まで出ている JSON Schema バージョンは最新を含むほぼ全てをサポート ianlancetaylor/jsonschema reference Go の 元コアコミッターである、Ian Lance Taylor 氏が実装したライブラリ JSON Schema バージョンは Draft 2020-12, Draft 2019-09, Draft 7 をサポート では、各ライブラリを見ていきましょう。 google/jsonschema-go 約70個のフィールドを持つ Schema 構造体を使用しています。JSON Schema のキーワードが1対1でフィールドにマッピングされており、 Extra map[string]any で非標準のキーワードも格納できます。 type Schema struct { Type string Properties map [ string ]*Schema Items *Schema Required [] string Minimum * float64 Maximum * float64 // ... 約70フィールド Extra map [ string ]any } Draft 7 と Draft 2020-12 のバージョン差分は、単一の構造体の中にバージョン固有のフィールドを両方持つことで吸収しています。例えば、Draft 7 の definitions と Draft 2020-12 の $defs に対応する Definitions と Defs が共存しています。 type Schema struct { // draft 7 Definitions map [ string ]*Schema // "definitions" ItemsArray []*Schema // "items" (配列形式) DependencySchemas map [ string ]*Schema // "dependencies" (スキーマ) // draft 2020-12 Defs map [ string ]*Schema // "$defs" PrefixItems []*Schema // "prefixItems" DependentSchemas map [ string ]*Schema // "dependentSchemas" // ... } Marshal/Unmarshal 時に $schema の値からドラフトを判定し、適切なフィールドへの振り分けやキーワードの出し分けを行います。 素朴かつ全てをサポートした巨大な構造体なので、構造体リテラルでの手動構築、 json.Unmarshal によるパース、 For[T]() による Go の型からの推論と、スキーマの構築方法を幅広くサポートしています。 $ref の解決は明示的な Resolve() メソッドで行い、スキーマのバリデーションにも対応しています。 invopop/jsonschema 約60個のフィールドを持つ Schema 構造体を採用しています。 Properties には orderedmap.OrderedMap[string, *Schema] を使用しており、順序も含めて管理することができます。 type Schema struct { Type string `json:"type,omitempty"` Properties *orderedmap.OrderedMap[ string , *Schema] `json:"properties,omitempty"` Items *Schema `json:"items,omitempty"` Required [] string `json:"required,omitempty"` // ... 約60フィールド Extras map [ string ]any `json:"-"` } Draft 2020-12 のみをサポートしており、バージョン差分の吸収は行いません。フィールドは $defs 、 prefixItems など 2020-12 のキーワードに対応しています。 google/jsonschema-go と同じく素朴な構造体ですが、フィールドの型に違いがあります。数値制約( minimum など)には *float64 ではなく json.Number を、整数値キーワード( maxLength 、 minLength 、 maxItems など)には *int ではなく *uint64 を使用しており、JSON の元表現の保持や、負の数の型レベルでの排除を意識した設計です。 一方で、Draft 2020-12 のキーワードでも 現時点では unevaluatedItems 、 unevaluatedProperties 、 $dynamicAnchor 、 $vocabulary には対応していません。 こちらはスキーマ 生成 に特化したライブラリです。主要な構築方法は Reflector を使った Go の型からの推論で、構造体タグで title 、 minLength 、 enum などを細かく制御できます。一方、 $ref の解決やバリデーション機能は提供していません。 santhosh-tekuri/jsonschema Schema 構造体を使います。他のライブラリと異なる点が2つあります。 1つ目は、数値制約に *big.Rat を使っており、浮動小数点誤差を回避していることです。2つ目は、 $ref が文字列ではなく、コンパイル時に解決された *Schema ポインタとして保持されることです。 Compiler が JSON をパースしてスキーマを構築する際に、参照の解決も同時に行われます。 type Schema struct { Ref *Schema // 解決済みの直接ポインタ Types *Types Minimum *big.Rat Maximum *big.Rat Properties map [ string ]*Schema // ... } バージョン差分は Draft 型で管理されます。各 Draft はキーワードの集合やサブスキーマの場所、 $id のフィールド名(Draft 4 は id 、Draft 6 以降は $id )などをまとめて管理しており、Draft 4 から Draft 2020-12 まで、ほぼすべてのバージョンをサポートしています。 Compiler が $schema キーワードからドラフトを自動検出し、異なるドラフトのスキーマを混在させることも可能です。 var ( Draft4 *Draft Draft6 *Draft Draft7 *Draft Draft2019 *Draft // draft 2019-09 Draft2020 *Draft // draft 2020-12 ) スキーマの構築は Compiler 経由のみで、プログラマティックな組み立てや Go の型からの推論は提供されていません。バリデーションがこのライブラリの主機能で、JSON Schema 仕様に準拠した複数のエラー出力フォーマットに対応しています。 ianlancetaylor/jsonschema 他の3つとは全く異なるアプローチを取っています。固定フィールドの構造体でも map[string]any でもなく、キーワードと型付き値のペアのリストで表現します。 type Schema struct { Parts []Part } type Part struct { Keyword *Keyword Value PartValue // 12種類の型付きユニオン } PartValue は PartString 、 PartInt 、 PartSchema 、 PartSchemas など12種類の型からなる型付きユニオンです。 この設計はバージョン差分の吸収と密接に結びついています。ドラフトごとに別の Go パッケージが用意され、各パッケージが独自の Vocabulary を登録します。 Vocabulary はドラフトの名前、 $schema の URI、キーワードの集合、参照解決関数などをまとめた型です。 type Vocabulary struct { Name string // "draft2020-12" など Schema string // $schema の URI Keywords map [ string ]*Keyword // キーワード名 → 定義 Resolve func (*Schema, *ResolveOpts) error Cmp func ( string , string ) int } type Keyword struct { Name string ArgType ArgType // PartString, PartSchema など期待する値の型 Validate func (arg PartValue, instance any, state *ValidationState) error } 各ドラフトパッケージは init() で自身の Vocabulary をグローバルレジストリに登録します。キーワードは JSON の定義ファイルからコード生成されるため、ドラフト間の条件分岐が一切ありません。スキーマの構造がバージョンに依存せず、異なるドラフトバージョンは単に異なるキーワードセットを登録するだけでよくなります。ただし、結局キーワードセットは巨大になるので、そう言う意味では他のとあまり変わらない、とも言えるかもしれません。 // ドラフトごとに別パッケージ import "github.com/ianlancetaylor/jsonschema/draft202012" import "github.com/ianlancetaylor/jsonschema/draft7" 内部表現がキーワードリストであるため、構築 API も独特です。構造体リテラルではなく、ドラフトバージョン別パッケージが提供する API を使って Builder パターンでスキーマを組み立てます。JSON のパースや Infer[T]() による Go の型からの推論にも対応しています。 $ref の解決はアンマーシャル時に自動で行われ、バリデーションもサポートされています。 横断比較 ライブラリ 内部表現 スキーマ構築 バージョン差分 参照解決 バリデーション google/jsonschema-go struct(両バージョンのフィールド共存) struct リテラル / JSON / For[T]() 単一structに両バージョン 明示的 Resolve() あり invopop/jsonschema struct struct リテラル / JSON / Reflector (主力) Draft 2020-12 のみ 生成のみ なし santhosh-tekuri/jsonschema struct( *big.Rat ) Compiler 経由のみ Draft 型 + 自動検出 コンパイル時にポインタ解決 あり(主機能) ianlancetaylor/jsonschema []Part Builder / JSON / Infer[T]() ドラフト別パッケージ アンマーシャル時に自動 あり まとめ 4つのライブラリを比較してみると、JSON Schema の表現方法にはいくつかのアプローチがあることがわかります。 google/jsonschema-go と invopop/jsonschema は、JSON Schema のキーワードを構造体のフィールドに直接マッピングする素朴なアプローチです。google/jsonschema-go は両バージョンのフィールドを共存させてバージョン差分を吸収し、invopop/jsonschema は Draft 2020-12 に絞ることでシンプルさを保っています santhosh-tekuri/jsonschema も構造体ベースですが、コンパイル時の $ref のポインタ解決など、バリデーション用途に最適化された表現を選んでいます ianlancetaylor/jsonschema は、キーワードとペアのリストという独自の表現で、バージョン差分をデータ構造レベルで吸収する大胆な設計です 同じ「Go で JSON Schema を扱う」という目的でも、内部表現の選択がバージョン対応や API 設計にまで影響を及ぼしていることが面白かったです。この記事を通して、Go で JSON Schemaを扱うことに興味を持っていただければ幸いです。
アバター
はじめに こんにちは!デリッシュキッチンで主にバックエンドの開発を担当している秋山です。 最近GitHub公式ブログで発表された GitHub Agentic Workflows というツールを知り、使い心地が気になったので試してみました。本記事では、CI/CDパイプラインにAIエージェントを組み込んで、テスト失敗時の原因調査からIssue作成までを自動化するワークフローを試しに構築した体験を紹介します。 目次 はじめに GitHub Agentic Workflowsとは 今回試してみたこと セットアップ 実際に作ったもの テスト対象のGoコード GitHub Agentic Workflowsを使ったワークフロー定義 コンパイル 結果 GitHub Agentic Workflowsのセキュリティ設計 Safe Outputs 脅威検出(Threat Detection) 豊富なデザインパターン まとめ ご参考 GitHub Agentic Workflowsとは GitHub Agentic Workflows は、ワークフローを自然言語で定義し、コーディングエージェントに実行させる仕組みです。従来のGitHub ActionsではYAMLでワークフローを記述していましたが、GitHub Agentic Workflowsでは マークダウン形式 で自然言語を使ってワークフローを定義できます。定義したマークダウンは gh aw compile コマンドでGitHub Actions用のYAMLにコンパイルされ、GitHub Actions上で実行されます。 主な特徴は以下の通りです。 マークダウンベースのワークフロー定義 : YAMLの代わりにマークダウンで自動化を記述。直感的で柔軟 選択可能なAIエンジン : GitHub Copilot、Claude、OpenAI Codexなど、使用するコーディングエージェントをワークフローごとに選択可能 セキュリティ重視の設計 : デフォルトで読み取り専用権限。書き込み操作には明示的な許可が必要。さらに脅威検出による自動セキュリティ分析も実行される ※Agentic Workflowsは2026年2月現在 テクニカルプレビュー段階 にあり、今後大きな変更が入る可能性があります。 今回試してみたこと GitHub Agentic Workflowsを使って、 CI上の自動テスト失敗時に原因を調査し、Issueを作成してくれるワークフロー を構築しました。Claude Codeに手伝ってもらいつつ、初回ワークフロー実行まで約30分で完了しました。 セットアップ まずは拡張機能をインストールし、initで初期設定を行います。 # Agentic Workflows拡張機能のインストール gh extension install github/gh-aw # Agentic Workflowsのワークフローの初期化 gh aw init 実際に作ったもの テスト対象のGoコード まず、検証のために 意図的にデータレースのリスクがあるGoコード を用意しました。 // cache.go package main type Cache struct { data map [ string ] int } func NewCache() *Cache { return &Cache{data: make ( map [ string ] int )} } func (c *Cache) Set(key string , val int ) { c.data[key] = val } func (c *Cache) Get(key string ) int { return c.data[key] } // Increment increments the value for key by 1. func (c *Cache) Increment(key string ) { c.data[key]++ } sync.Mutex を使わず map[string]int に直接アクセスしているため、並行アクセス時にデータレースが発生します。 また、テストでは100個のgoroutineから同時にIncrementを呼び出します。 // cache_test.go func TestCacheConcurrentIncrement(t *testing.T) { c := NewCache() c.Set( "counter" , 0 ) var wg sync.WaitGroup const workers = 100 for i := 0 ; i < workers; i++ { wg.Add( 1 ) go func () { defer wg.Done() c.Increment( "counter" ) }() } wg.Wait() got := c.Get( "counter" ) if got != workers { t.Errorf( "expected counter=%d, got %d (lost %d increments due to race)" , workers, got, workers-got) } } 自動テストワークフロー( test.yml )の定義です。 # .github/workflows/test.yml name : Tests on : push : branches : [ "**" ] pull_request : jobs : test : runs-on : ubuntu-latest steps : - uses : actions/checkout@v4 - uses : actions/setup-go@v5 with : go-version : "1.23" - name : Run tests (race detector + repeat) run : go test -v -race -count=5 -timeout 120s ./... GitHub Agentic Workflowsを使ったワークフロー定義 続いてGitHub Agentic Workflowsを使ったワークフローの定義です。 定義は マークダウン で書きます。 Front Matterでワークフローの細かい設定ができます(engineなどの部分です)。 メインの内容に、自然言語でワークフローの定義をしています。今回は英語で書いてますが、日本語でも問題ないと思います。 --- engine: copilot on: workflow_run: workflows: ["Tests"] types: [completed] branches: - main permissions: contents: read actions: read safe-outputs: create-issue: ~ --- # CI Doctor Automatically investigate failures in the "Tests" workflow and create a GitHub Issue with a diagnostic report. ## Instructions When triggered, check if the "Tests" workflow run that just completed has a conclusion of `failure`. If it succeeded, do nothing. For failed runs: 1. **Fetch the workflow run logs** for the failed "Tests" run using the GitHub Actions API. 2. **Classify the failure type** by scanning the logs: - `DATA RACE` → **Race condition** - `too long` or `took too long` → **Time-dependent flakiness** - `panic:` → **Panic / crash** - `FAIL` with a specific test name → **Assertion failure** 3. **Identify the affected tests** by extracting lines matching: - `--- FAIL: TestXxx` - `FAIL\tgithub.com/...` 4. **Extract the root cause evidence** — copy the relevant log lines verbatim as a code block. 5. **Create a GitHub Issue** with the following structure: **Title**: `CI Failure: <TestName> — <failure type>` **Body**: - Summary - Failed Test(s) - Failure Type - Log Evidence - Root Cause Analysis - Suggested Fix Add labels: `bug`, `flaky-test`. Front Matterの設定について補足します。 engine: copilot でAIエンジンを指定。Copilot以外にもClaudeやCodexを選択でき、使用するモデルの指定も可能( エンジン設定リファレンス ) workflow_run トリガーでTestsワークフローの完了を監視 permissions で contents: read と actions: read のみに権限を限定 safe-outputs に create-issue を設定し、Issue作成のみを許可。それ以外の書き込み操作は行えない コンパイル GitHub Agentic Workflowsのワークフロー( .md )は、 gh aw compile コマンドで .lock.yml にコンパイルされます。 gh aw compile 検証ではci-doctorという名前でワークフローを作っていたので、 .github/workflows/ci-doctor.lock.yml が生成されました。このYAMLファイルが実際にGitHub Actionsで実行されるワークフローのようです。 これでワークフローの準備は完了です。 結果 Testsワークフローが失敗すると、定義したワークフローが自動的に起動しました。 GitHub Agentic Workflowsで定義したワークフロー また、以下のようなIssueが作成されました。 自動作成されたissue bug と flaky-test ラベルも自動で付与されています。 また、問題の説明や具体的な修正案まで提示してくれました。 今回は簡単な例でしたが、より複雑なケースでどうなるのかは今後確認していきたいです。 GitHub Agentic Workflowsのセキュリティ設計 AIエージェントにCI/CDパイプライン上で作業させるとなると、気になるのがセキュリティです。GitHub Agentic Workflowsはこの点について手厚い設計がされています。 Safe Outputs GitHub Agentic Workflowsのワークフローは デフォルトで読み取り専用権限 で実行されます。書き込み操作(Issue作成、PR作成など)を行うには safe-outputs で明示的に許可する必要があります。 safe-outputs : create-issue : ~ # Issue作成を許可 create-pull-request : ~ # PR作成を許可 ワークフローごとに許可する操作を限定できるため、「このワークフローはIssue作成だけ」「このワークフローはPR作成まで」といった細かい権限管理が可能です。 脅威検出(Threat Detection) safe outputsが設定されている場合、GitHub Agentic Workflowsは自動的に 脅威検出 を実行します。これはエージェントの出力が実際にGitHubに書き込まれる前に、セキュリティ分析を行う追加のレイヤーです。デフォルトでは有効になっています。 検出フローは以下の順序で動作します。 エージェントジョブ (読み取り専用権限で実行) 脅威検出ジョブ (エージェント出力のセキュリティ分析) Safe Outputsジョブ (安全確認後に書き込み権限で実行) プロンプトインジェクション、シークレットの漏洩、悪意のある変更を検出します。 脅威が検出された場合、ワークフローは失敗し、safe outputsジョブはブロックされます。 詳細は 脅威検出リファレンス を参照してください。 豊富なデザインパターン GitHub Agentic Workflowsのリファレンスには具体的なデザインパターン/使用例がいくつも載っています。 その中で僕が気になったのは MultiRepoOpsパターン という、複数リポジトリを横断した自動化パターンです。 例えば、PRD(プロダクト要求仕様書)を別リポジトリで管理しているケースでは、PRDの変更に連動して開発リポ側にIssueを作成したり、実装状況をPRDリポ側にフィードバックしたりといった連携ができるようになるのではないかと思いました。 まとめ GitHub Agentic Workflowsを使って、ワークフローを作成しました。 リファレンスには今回紹介した他にも細かい設定やユースケースが書かれています。 気になった方はぜひ 公式ドキュメント を見て、試してみてください。 ご参考 GitHub Agentic Workflowsを発表 - リポジトリの自動化を実現 - GitHubブログ Blog | GitHub Agentic Workflows About Workflows | GitHub Agentic Workflows GitHub - github/gh-aw: GitHub Agentic Workflows
アバター
開発2部の内原です。 Goは静的型付けで事前コンパイルされる言語なので、WebAssembly(WASM)にコンパイルしておけば、JavaScriptのJust-In-Time(JIT)コンパイルより速度的に有利であるように思えます。 なんとなくGoをWASMにすればJSより速くなるくらいのふわっとした認識でいましたが、果たしてどのような実装でも速くなるのかそうでないのか、速くなるとしたらどれくらいの差が出るのか、という疑問を持ったので調べてみました。 そこで、いくつかのアルゴリズムで実際にベンチマークを取って検証してみましたが、アルゴリズムの特性によって結果が様々であることがわかりました。 事前準備 実行環境 MacOS 26.2 Go 1.25 Node.js v25 Chrome (144) Go WASM のビルドと関数公開 Go側の関数公開は以下のように js.FuncOf でラップしてグローバルに登録します。 import "syscall/js" func main() { js.Global().Set( "goAdd" , js.FuncOf( func (this js.Value, args []js.Value) any { n1 := args[ 0 ].Int() n2 := args[ 1 ].Int() return add(n) })) select {} } Go側では syscall/js パッケージを使って関数をグローバルに公開し、以下のコマンドでWASMバイナリをビルドします。 $ GOOS=js GOARCH=wasm go build -o main.wasm main.go $ ls -lh main.wasm -rwxr-xr-x@ 1 uchihara staff 2.1M Feb 11 16:00 main.wasm 生成されるWASMバイナリのサイズは約2MBです。Goランタイムが含まれるため、それなりのサイズになります。 JS側では WebAssembly.instantiateStreaming でWASMをロードし、 go.run(instance) を呼ぶと、上記で登録した関数がグローバルから呼び出せるようになります。 const go = new Go () ; const { instance } = await WebAssembly . instantiateStreaming ( fetch ( "main.wasm" ) , go . importObject ) ; go . run ( instance ) ; const r = goAdd ( 1 , 2 ) ; 計測方法 ブラウザ版とCLI版(Node.js)の両方で計測(ただし一部を除いて性能差はさほど出なかった) 各テストは複数回計測の平均を採用 ベンチマーク関数は以下です。 function bench ( fn , args , iters ) { const times = [] ; for ( let i = 0 ; i < iters ; i ++ ) { const start = performance . now () ; fn ( ... args ) ; const end = performance . now () ; times . push ( end - start ) ; } return times . reduce (( a , b ) => a + b , 0 ) / times . length ; } フィボナッチ関数 まずはシンプルなフィボナッチ数列の計算で比較しました。その際、関数呼び出しのオーバーヘッドが性能に影響を与える可能性があると考えたため、再帰版とループ版の2パターンで確認します。 Go側とJS側でほぼ同一のロジックを実装しています。 func goFibRecursive(n int ) int { if n <= 1 { return n } return goFibRecursive(n- 1 ) + goFibRecursive(n- 2 ) } func goFibIterative(n int ) int { if n <= 1 { return n } a, b := 0 , 1 for i := 2 ; i <= n; i++ { a, b = b, a+b } return b } function jsFibRecursive ( n ) { if ( n <= 1 ) return n ; return jsFibRecursive ( n - 1 ) + jsFibRecursive ( n - 2 ) ; } function jsFibIterative ( n ) { if ( n <= 1 ) return n ; let a = 0 , b = 1 ; for ( let i = 2 ; i <= n ; i ++ ) { [ a , b ] = [ b , a + b ] ; } return b ; } 再帰版 fibRecursive(40) 実装 実行時間 倍率 JavaScript 660ms 1.0x Go WASM 1,560ms 2.4x ループ版 fibIterative(10000000) 実装 実行時間 倍率 JavaScript 9ms 1.0x Go WASM 15ms 1.7x CLI版だとどちらのパターンでもJSのほうが高速という結果になりました。 ただブラウザ版だとGo WASMのほうが3倍ほど速くなっていました。JSエンジンの最適化による差分かもしれません。 原因分析 フィボナッチ計算は計算自体が軽量で、関数呼び出しのオーバーヘッドが支配的になります。JITコンパイラはこの種のシンプルな数値計算を最適化している可能性が考えられます。 どうやら「WASMにすれば速くなる」という単純な話でもなさそうです。 行列乗算 計算量をもう増やせば差が出るかもと考えたので、比較的計算量の大きい512x512の行列乗算で試してみました。 Go/JS双方でikjループ順を使い、同一の決定的データを生成して計算しています。 Go側は []float64 スライスを使い、JS側では Float64Array を使っています。 func goMatMul() { n := 512 a := make ([] float64 , n*n) b := make ([] float64 , n*n) for i := 0 ; i < n*n; i++ { a[i] = float64 (i% 97 ) * 0.01 b[i] = float64 (i% 89 ) * 0.01 } c := make ([] float64 , n*n) for i := 0 ; i < n; i++ { for k := 0 ; k < n; k++ { aik := a[i*n+k] for j := 0 ; j < n; j++ { c[i*n+j] += aik * b[k*n+j] } } } sum := 0.0 for _, v := range c { sum += v } return sum } function jsMatMul () { const n = 512 ; const a = new Float64Array ( n * n ) ; const b = new Float64Array ( n * n ) ; for ( let i = 0 ; i < n * n ; i ++ ) { a [ i ] = ( i % 97 ) * 0 . 01 ; b [ i ] = ( i % 89 ) * 0 . 01 ; } const c = new Float64Array ( n * n ) ; for ( let i = 0 ; i < n ; i ++ ) { for ( let k = 0 ; k < n ; k ++ ) { const aik = a [ i * n + k ] ; for ( let j = 0 ; j < n ; j ++ ) { c [ i * n + j ] += aik * b [ k * n + j ] ; } } } let sum = 0 ; for ( let i = 0 ; i < n * n ; i ++ ) sum += c [ i ] ; return sum ; } 実装 実行時間 倍率 JavaScript 190ms 1.0x Go WASM 208ms 1.1x 差は縮まりましたが、まだ若干JSが優勢です。 原因分析 JS側ではTypedArrayに対して最適化が行われている可能性があります。 またGo WASM側では以下箇所がオーバーヘッドになっている可能性があります。 WASMではSIMD命令を十分に活用できない? Goのスライスにおけるbounds checkのコストがある? 行列乗算は計算量が大きいためJS-WASM境界のオーバーヘッドは相対的に小さくなりますが、依然としてJSが有利でした。 SHA-256 計算量をもっと増やすと変化が出てくるかもと考えたので、SHA-256関数を利用することにします。 その際、JSによる純粋な実装よりネイティブAPIによる実装のほうが効率的である可能性が高いと考えたため、SubtleCrypto:digest()も比較対象に含めました。 ただ、SubtleCrypto:digest()は非同期関数であり、ベンチ時に同期的に呼び出しを行う必要がある点に注意が必要でした。 チェインハッシュ 小さなデータのハッシュ結果を次のハッシュの入力にする、という処理を10万回繰り返しました。 実装 実行時間 倍率 Go WASM 41ms 1.0x JS 純粋実装 114ms 2.8x SubtleCrypto 200ms 4.9x Go WASMがJS純粋実装の約2.8倍速く、最速という結果になりました。また、SubtleCryptoはさらに遅いという結果になりました。 Go WASMが速い理由 SHA-256はビット演算・整数演算が中心のアルゴリズムで、Goのコンパイル済みWASMコードが有利な立場だったと言えそうです。また、10万回のハッシュ計算を1回の関数呼び出しでWASM内で完結させている点で、呼び出しオーバーヘッドの影響がほぼなく、効率的だったと考えられます。 js.Global().Set( "goSHA256" , js.FuncOf( func (this js.Value, args []js.Value) any { data := [] byte (args[ 0 ].String()) iterations := args[ 1 ].Int() h := sha256.Sum256(data) for i := 1 ; i < iterations; i++ { h = sha256.Sum256(h[:]) } return hex.EncodeToString(h[:]) })) JS-WASM境界を跨ぐのは最初の呼び出しと結果の返却の1往復だけで、ループ全体がWASM内で実行されます。これにより crypto/sha256 標準ライブラリの実装がそのまま適用されます。 フィボナッチではJS側から関数を1回呼ぶという意味では同じでしたが、計算自体が軽いためランタイムオーバーヘッドが目立ちました。SHA-256チェインでは1回の呼び出しの中で重い計算を行うため、オーバーヘッドが相対的に無視できるようになります。 SubtleCryptoが遅い理由 ネイティブAPIの crypto.subtle.digest() が最も遅い結果となりました。 async function jsSHA256Subtle ( str , iterations ) { const encoder = new TextEncoder () ; let data = encoder . encode ( str ) ; data = new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , data )) ; for ( let i = 1 ; i < iterations ; i ++ ) { data = new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , data )) ; } return Array . from ( data , b => b . toString ( 16 ) . padStart ( 2 , "0" )) . join ( "" ) ; } SubtleCrypto はasync APIのみを提供しているため、10万回のチェインハッシュでは毎回Promise生成 → microtask enqueue → await復帰を繰り返します。ハッシュ計算自体よりも非同期ディスパッチのコストが支配的になっているようです。 巨大バッファハッシュ SubtleCryptoは大きなデータを一括処理するケースで優位であることが予想されます。64MBのバッファを1回だけハッシュする形式に変更して計測しました。 async function jsSHA256BulkSubtle ( size ) { const data = new Uint8Array ( size ) ; for ( let i = 0 ; i < size ; i ++ ) data [ i ] = i % 251 ; const hash = new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , data )) ; return Array . from ( hash , b => b . toString ( 16 ) . padStart ( 2 , "0" )) . join ( "" ) ; } 実装 実行時間 倍率 SubtleCrypto 350ms 1.0x Go WASM 430ms 1.2x JS 純粋実装 980ms 2.8x 非同期呼び出しは1回だけなのでネイティブの速度がそのまま活かされ、SubtleCryptoが最速という結果になりました。 対して、GO WASM版にはなんらかのオーバーヘッドが存在しているのか、もしくはダイジェスト関数実装における性能差があるのかもしれません。 SubtleCryptoは大きなデータを一括処理する用途向きであり、小さなデータを繰り返しハッシュするような用途には向いていなさそう、ということがわかりました。 おまけ 計測中に興味深い現象を発見しました。Mac Chrome(144.0.7559.110)で、DevToolsを開いた状態では閉じた状態に比べてGo WASMの性能が低下するというものです。 テスト Go WASM(DevTools閉) Go WASM(DevTools開) 劣化率 再帰 fib(40) 1,550ms 3,150ms 2.0x 行列乗算 512x512 203ms 453ms 2.2x SHA-256 チェイン 41ms 136ms 3.3x SHA-256 64MB 428ms 1,461ms 3.4x 一方、JS側にはほとんど影響がありませんでした。 原因 DevToolsを開くと、Chromeが内部的にChrome DevTools Protocol(CDP)の Debugger.enable() を発行するようです。これによりWASMバイトコードにデバッグ用コード(ブレークポイント判定等)を挿入するため、WASMの実行速度が大幅に低下しますが、一方JSのJITコードには同等の影響があまり発生しないようです。 WASMのベンチマーク時はDevToolsを閉じた状態で行う、またはDevToolsを開いている場合にはDebuggerを無効化した状態で行う必要があることが分かりました。 まとめ Go WASMがJSより優位なのは、1回の関数呼び出しで大量の計算をWASM内で完結させるパターン(SHA-256チェインなど) 逆に、関数呼び出しが頻繁・計算が軽い場合は、JS JITが有利(フィボナッチなど) SubtleCryptoなどのasync APIは呼び出し回数に注意が必要。大バッファの一括処理なら効果的 WASMのベンチマーク時はDevToolsを閉じるorDebugger無効化。そうしないと2〜3倍の劣化が発生する 「GoをWASMにすればJSより速くなる」、というのは条件次第で真偽いずれもあり得ることが分かりました。 JS-WASM境界を跨ぐ回数を最小化し、WASM内でまとまった計算を完結させる設計にすることが重要そうです。 WASMの導入を検討する際は、対象のアルゴリズムがこのパターンに合致するかを事前に見極める必要があります。
アバター
こんにちは、エブリーでデリッシュキッチンの開発を主に担当している塚田です。 WebやAPIを運用する中で、セキュリティ強化は継続的な課題の一つです。 今回は、AWS WAF (Web Application Firewall) を導入する場合のアーキテクチャ選定と、そこで直面した技術的な検討事項について紹介します。 特に、 「CloudFront -> ALB -> ECS」という標準的な構成において、「WAFをどこに適用するか(Edge vs Regional)」 という議論にフォーカスします。 はじめに 今回は、静的コンテンツの配信効率化と負荷分散のために、以下のような構成をとっている前提で検討します。 また、セキュリティグループやネットワークACLによる防御や、アプリケーション側で行える基本的な攻撃への対処(SQLインジェクションやXSSなど)は実現できているものとします。 User -> CloudFront -> ALB (Application Load Balancer) -> ECS (Amazon ECS) そこで最初の岐路となったのが、 「CloudFront(Edge)にWAFを適用するか、ALB(Regional)に適用するか」 という問題です。 CloudFront vs ALB どちらにWAFを置くか? AWS WAFは、CloudFrontとALBのどちらにもアタッチ可能です。それぞれのメリット・デメリットを整理し、検討を行いました。 比較項目 CloudFront (Edge) に適用 ALB (Regional) に適用 防御範囲 全リクエストをエッジでブロック キャッシュミスし、オリジンに到達したリクエストのみブロック コスト 高い (全リクエストが課金対象) 安い (オリジン到達分のみ課金対象) オリジン負荷 攻撃リクエストがオリジンに届かないため負荷減 攻撃リクエストもALBまでは到達する 直接アクセス対策 ALBへ直接アクセスされるとWAFを回避される ALB自体を守るため、直接アクセスでもWAFが機能する IP制限 クライアントIPで直接制限可能 X-Forwarded-For ヘッダーの参照が必要 単純に以下の観点で検討した場合ALBの方が有利であると感じました。 圧倒的なコストメリット メディアサービスの特性上、画像や動画などの静的リクエスト数が膨大です。 CloudFront側でWAFを有効にすると、静的リソースへの正常なアクセスも含めた「全リクエスト」に対してWAFの料金(Web ACL使用料 + リクエスト数課金)が発生してしまいます。 ALB側であれば、CloudFrontでキャッシュアウトした「動的処理が必要なリクエスト」のみが検査対象となるため、コストを大幅に最適化できます。 防御対象の絞り込み 本当に守りたいのは、データベース接続やビジネスロジックを持つECS上のアプリケーションです。静的ファイルへのリクエストを除外した、純粋なアプリケーションリクエストのみを検査対象とすることで、運用時のログ分析ノイズも減らせると判断しました。 技術的検討事項と実装のポイント 有利であると感じたALB側にWAFを適用する場合ですが、いくつか特有の技術的課題が発生します。 1.クライアントIPの識別(IP制限) ALBにWAFを適用する場合、WAFが見る「送信元IPアドレス」はCloudFrontのIPレンジになってしまいます。攻撃者のIPや、社内からのアクセス許可(ホワイトリスト)をIPベースで行う場合、そのままでは機能しません。 対策: AWS WAFのルール設定において、IP判定に X-Forwarded-For ヘッダーを使用するように構成することで、IP制限を実現。 方法: AWS WAFの設定(コンソールやTerraform等)で、IPセットの一致条件を「IP address in header」とし、ヘッダー名に X-Forwarded-For を指定します。 ※ X-Forwarded-For は改ざん可能なヘッダーであることに注意が必要です。 2.AWSマネージドルール(IPレピュテーション等)の制約 ここが大きな注意点ですが、AWSが提供する AWSManagedRulesAmazonIpReputationList などのIPアドレスベースのマネージドルールは、基本的に「送信元IPアドレス」を検査対象とします。 ALB上のWAFから見ると送信元はすべてCloudFrontのIPとなるため、これらのルールはクライアントのIP(攻撃者)に対して正しく機能しません(CloudFrontのIPを評価してしまいます)。 SQLインジェクションやXSSなど「アプリケーション脆弱性への攻撃(リクエストの中身)」への防御やレートリミットでの制限を最優先とする場合は、この制約を許容できるかもしれません。 しかし、AWS提供のIPレピュテーションリストによる防御が必須要件である場合は、コスト増を許容してでもCloudFront側にWAFを適用する必要があります。 3.CloudFront以外からのアクセス遮断(WAF回避の防止) ALBにWAFがあるため、攻撃者がCloudFrontを経由せずにALBのDNS名に直接アクセスしてきた場合でも、WAF自体は機能します。しかし、キャッシュを介さない不必要な負荷を避けるため、ALBへのアクセスはCloudFront経由のみに限定すべきです。 対策: ALBのセキュリティグループ(SG)で、インバウンドルールを 「 CloudFrontのManaged Prefix List からのHTTPSのみ許可」 に設定しました。 これにより、攻撃者がALBのIPやDNSを特定して直接攻撃を仕掛けてきても、ネットワーク層で遮断されます。WAF以前の段階で不正アクセスを防ぐ重要な設定となります。 4.静的・動的コンテンツの分離によるコスト最適化 ALBレイヤーでの適用と同様の効果(コスト削減)を得るための別のアプローチとして、CloudFrontディストリビューションを静的コンテンツ用と動的コンテンツ用に分割する構成も有効です。 静的コンテンツ用CloudFront: WAFを適用しない(または安価なルールのみ)。常にキャッシュさせる 動的コンテンツ用CloudFront: WAFを適用する このように構成することで、エッジ(CloudFront)での防御メリットを享受しつつ、検査対象を動的リクエスト(ALBと同等のアクセス量)に絞り込んでコストを最適化できます。ただし、ドメイン設計やフロントエンドの実装変更が必要になるため、対応に必要なコストと防御したい内容を天秤にかけて検討すると良いでしょう。 実際に導入する際の運用考慮事項 実際にWAFを本番環境へ導入する場合には、技術的な設定だけでなく、誤検知(False Positive)への運用フローなどの考慮も必要です。 この点については、過去の記事でも詳しく紹介していますので、ぜひ参考にしてください。 tech.every.tv tech.every.tv まとめ 今回のWAF導入では、「どこで守るか」と「コスト最適化」という観点で検討を行いました。 CloudFront (Edge): 全リクエスト防御、DDoS対策重視、設定がGlobal ALB (Regional): コスト最適化(キャッシュ済みリクエスト除外)、アプリ保護重視 構成図だけ見れば「前段(Edge)で止めるのがベストプラクティス」とされることが多いですが、実際のリクエスト量や守るべき対象のリスク許容度を計算すると、他の方法も検討の余地があります。 今後も、サービスの成長に合わせて、セキュリティとパフォーマンス、そしてコストの最適なバランスを追求していきたいと思います。
アバター
Flutter3.38アップグレードにおけるiOSとAndroidの影響範囲 背景 Flutter3.38アップグレードの手順 パッケージのバージョン依存関係調整 依存関係の解消によって副次的問題が発生 1. Dart SDK バージョン 2. Ferryエコシステム(GraphQLクライアント) 3. Freezed(コード生成) 4. Firebase(iOS 13対応のため2.x系を使用) 5. その他の重要な更新 6. Isar Plus(データベース)モデル修正 解決した依存関係の競合 競合1: isar_db_generator 競合2: intl バージョン 競合3: source_gen バージョン 競合4: build バージョン 競合5: gql_exec バージョン 競合6: ferry バージョン 競合7: web パッケージ(iOS 13対応のための調整) 競合8: Firebase iOS 15要件 参考リンク 既存コードの改修作業 1. isar_plus 移行に伴うimport文の変更 2. isar_plus 移行に伴うPodfileの変更 3. NDKバージョンの明示的指定 4. Android app の namespace(AGP 8.x 必須) 5. サブプロジェクトへの namespace 注入 6. BuildConfig の有効化 7. Freezed 3.x マイグレーション(全モデルに abstract 修飾子) 7.1. 7のLintエラー対応 8. retrofit / ParseErrorLogger 対応 9. Android 旧埋め込み (PluginRegistry.Registrar) 削除対応 10. iOS CocoaPods更新 11. dart formatの修正 Flutter バージョン別 カート追加時パフォーマンス比較レポート 比較サマリー 結論 主な改善要因(推測) 今後の検討 まとめ 1. テスト 2. iOS 11-12ユーザーへの対応検討 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。 背景  現在提供しているネットスーパーアプリはFlutter+Dartで実装しております。一方で、昨年2025年11月1日までに対応が必要であったGoogleからの メモリの16KBページサイズをサポートせよ という通知を延長申請して2026年5月31日まで対応を保留にしていました。2026年2月現在、この延長期限も近づいてきていることを理由にFlutterのバージョンを3.38系にアップグレードすることになりました( こちら の公式ブログにも3.38から標準対応したとあります)。 Flutter3.38アップグレードの手順  通常、Flutterのバージョンをアップグレードする際の流れとしては、以下の2点を気にする必要があり実際にそのプロセスを踏まないといけないことも少なからずあります。 パッケージ依存関係の解消 既存ソースコードで改修  今回、対象のソースコードで利用しているFlutterバージョンは3.24.1と古めのバージョンであるためバージョン差分が大きく、2点とも対応が発生しました。以下にその2点の対応について詳細を記していきます。 パッケージのバージョン依存関係調整  目的はあくまでAndroidアプリの16KBページサイズに対応することなのでAndroidアプリのビルドをまず意識した以下のバージョンを調整しました。 Flutter,Dartのバージョンを上げる Flutter3.38.9、Dart3.10.8にしました DevTools 2.51.1 Gradleのバージョンを上げる 8.7にしました AGPのバージョンを上げる 8.5.1にしました Kotlinのバージョンを上げる 2.0.21にしました NDKバージョンを明示指定 29.0.14206865にしました その他利用ライブラリの対応バージョンをtrial and errorで上げる 現状のバージョンのままでビルドしてみてエラーが出たら上げるを繰り返しました 依存関係の解消によって副次的問題が発生  今回、Androidアプリのためにアップグレードをするので必要なライブラリパッケージも必要最低限のものだけを最小限でアップグレードすることを心がけたのですが、意図せずiPhoneアプリ側の方にも影響を及ぼしてしまうことが判明しました。具体的には以下のことが発生しました。 iOSの最小メジャーバージョン番号が12から13以上に引き上げ  直接的な引き上げ条件は、 Flutter公式 によれば、swiftコードを利用する場合とありますがFlutter3.38自体がそれに該当するということ( 参考ページ )でした。というわけで、必然的にFlutter3.38にしないといけない場合はiOS最小対応バージョンも13以上になるということでした。  最終的には以下に挙げるバージョン対応で一旦、すべての依存関係の競合を解決し、 pub get 、コード生成、iOS CocoaPodsインストールがすべて成功しました。 1. Dart SDK バージョン 変更 : >=3.5.0 <4.0.0 → >=3.10.0 <4.0.0 理由 : json_serializable と freezed の最新版が要求 2. Ferryエコシステム(GraphQLクライアント) パッケージ 最終バージョン 理由 ferry ^0.16.1 ferry_generator 0.12.0+3との互換性 ferry_generator ^0.12.0+3 build 4.0対応 build_runner ^2.10.3 build 4.0対応 gql_code_builder_serializers ^0.1.0 ferry_generator 0.12.0+で必須 gql_exec ^1.0.0 ferry_generator依存関係 gql_http_link ^1.0.0 gql_execとの互換性 gql_transform_link ^1.0.0 gql_execとの互換性 3. Freezed(コード生成) パッケージ 最終バージョン 理由 freezed ^3.2.5 build 4.0対応 freezed_annotation ^3.0.0 freezed 3.x対応 4. Firebase(iOS 13対応のため2.x系を使用) パッケージ 最終バージョン 理由 firebase_core ^2.32.0 iOS 13サポート(3.x+はiOS 13必須) firebase_analytics ^10.10.7 firebase_core 2.x互換 firebase_crashlytics ^3.5.7 firebase_core 2.x互換 firebase_messaging ^14.7.10 firebase_core 2.x互換 & webパッケージ互換 firebase_remote_config ^4.4.7 firebase_core 2.x互換 firebase_app_installations ^0.2.5+7 firebase_core 2.x互換 Firebase iOS SDK : 10.25.0(iOS 13+をサポート) 5. その他の重要な更新 パッケージ 最終バージョン 理由 intl ^0.20.2 flutter_localizations要求 retrofit_generator ^10.2.1 source_gen 4.0対応 json_annotation ^4.9.0 json_serializable要求(dependenciesに追加) 6. Isar Plus(データベース)モデル修正 isar_plus v4のAPI変更に対応: 変更後(isar_plus v4スタイル): @collection class EventLog { EventLog({required this .id}); final int id; // Auto-increment id (isar_plus v4) late String data; } 主な変更点: @Collection() (大文字)→ @collection (小文字) Id id = Isar.autoIncrement → final int id とコンストラクタで受け取る 実際のauto-increment IDは isar.collection.autoIncrement() で生成 解決した依存関係の競合 競合1: isar_db_generator 問題 : isar_db_generator パッケージが存在しない 解決 : isar_db は元の isar_generator を使用することを確認 競合2: intl バージョン 問題 : flutter_localizations が intl 0.20.2 を要求 解決 : intl を ^0.20.2 に更新 競合3: source_gen バージョン 問題 : isar_plus が source_gen ^4.0.2 を要求、 retrofit_generator ^8.1.0 が source_gen ^1.3.0 を要求 解決 : retrofit_generator を ^10.2.1 に更新(source_gen 4.0対応版) 競合4: build バージョン 問題 : build_runner >=2.9.0 が build ^4.0.0 を要求、 freezed ^2.x が build ^2.3.1 を要求 解決 : freezed を ^3.2.5 に更新(build 4.0対応版) 競合5: gql_exec バージョン 問題 : gql_transform_link が古い gql_exec を要求、 ferry_generator 0.12.0+3 が gql_exec ^1.0.0 を要求 解決 : gql_exec 、 gql_http_link 、 gql_transform_link をすべて1.x系に更新 競合6: ferry バージョン 問題 : ferry ^0.14.2+1 が ferry_exec ^0.3.1 を要求、 ferry_generator 0.12.0+3 が ferry_exec ^0.7.0 を要求 解決 : ferry を ^0.16.1 に更新 競合7: web パッケージ(iOS 13対応のための調整) 問題 : isar_plus が web ^1.1.0 を要求、Firebase 4.x系が web ^0.5.1 を要求 解決 : Firebaseパッケージを2.x系にダウングレード(iOS 13サポートのため) 競合8: Firebase iOS 15要件 問題 : Firebase 4.x系(firebase_core 4.0+)はiOS 15を最小要件とする 解決 : Firebase 2.x系(firebase_core 2.32.0)を使用してiOS 13をサポート 参考リンク isar_plus v4 ドキュメント ferry_generator changelog freezed 3.0 migration Firebase Flutter changelog firebase_core 3.0.0 breaking changes 既存コードの改修作業  前述にある依存パッケージライブラリをアップグレードすることで破壊的変更が発生してしまった全箇所をエラーがなくなるまで対応していくという作業も相当数発生しました。 1. isar_plus 移行に伴うimport文の変更   package:isar/isar.dart → package:isar_plus/isar_plus.dart に変更 2. isar_plus 移行に伴うPodfileの変更 ios/Podfile で isar_flutter_libs → isar_plus_flutter_libs に変更 3. NDKバージョンの明示的指定 追加 : ndkVersion "29.0.14206865" (安定版最新。r28+ で 16KB アライメント対応のため r29 を使用) ファイル : app/build.gradle 理由 : NDK r28以上で16KBアライメントがデフォルト対応。固定していた 28.0.12674087 ではなく、安定版最新 29.0.14206865 を推奨 4. Android app の namespace(AGP 8.x 必須) 追加 : namespace "tv.every.fresh" ファイル : app/build.gradle 理由 : AGP 8.x ではモジュールに namespace の指定が必須。未指定だと「Namespace not specified」でビルド失敗する。 5. サブプロジェクトへの namespace 注入 追加 : Android library プラグインで namespace 未指定のサブプロジェクトに、 AndroidManifest.xml の package を namespace として設定 ファイル : build.gradle (root) 理由 : AGP 8.x では全モジュールに namespace 必須。古いパッケージは namespace 未指定のため「Namespace not specified」でビルド失敗する。pub cache は編集しないため、root の subprojects.plugins.withId("com.android.library") で manifest の package を注入する。 6. BuildConfig の有効化 追加 : 全 Android サブプロジェクトで buildFeatures.buildConfig true ファイル : build.gradle (root) 理由 : AGP 8.x では BuildConfig がデフォルト無効。custom BuildConfig を使うパッケージがあったため「defaultConfig contains custom BuildConfig fields, but the feature is disabled」でビルド失敗する。root の subprojects.afterEvaluate で全モジュールに有効化する。 7. Freezed 3.x マイグレーション(全モデルに abstract 修飾子) 対象 : @freezed を付けた全てのモデルクラス 対応 : Freezed 3.0 マイグレーションガイドに従い、 全てのクラス定義に abstract 修飾子を追加 変更例 : // 変更前 @freezed class Shop with _$Shop {...} // 変更後 @freezed abstract class Shop with _$Shop {...} 理由 : Dart 3.10 コンパイラ下で non_abstract_class_inherits_abstract_member エラーを解消するため。Freezed 3.x の生成コード(mixin の抽象メンバー)と互換させるには、公開クラスを abstract class にすることが必要。 実施方法 : 上記のとおり、該当する全モデルファイルで class → abstract class に手動で置換。 検証 : 本対応後に iOSビルド成功し、iOS Simulator(26.2)でアプリ起動を確認済。 このマイグレーションでLintエラー問題が発生したので以下に経緯と対応を追記しておきます。 最初の実装でLintエラーが発生していたので「 @JsonSerializable(...) を factory constructor からクラスレベルに移動」しましたが、この変更により build_runnerが失敗したため、この修正では不十分であることが判明しました。 @JsonSerializable をクラスレベル(abstract class)に置くと、 json_serializable ジェネレーターは、以下の動きをします。 abstract class のコンストラクターを探す const Xxx._() (private constructor)しか見つからない、またはコンストラクターなしと判断 Freezed が生成した factory constructor( = _Xxx )との対応を解決できない 「required constructor argument を populate できない」エラーになる Freezed + json_serializable の正しいパターンでは、 @JsonSerializable は factory constructor に置く必要がありました 。Freezed はこのパターンを前提に .g.dart ファイルを生成する必要がありました。 7.1. 7のLintエラー対応 @JsonSerializable は factory constructor に残したまま 、 // ignore: invalid_annotation_target を @JsonSerializable の 直前の行 に追加しました。 ポイント: // ignore: <rule> は 次の1行 に適用されます。旧コードでは ignore コメントがクラス宣言行にあったため、実際に警告が出る @JsonSerializable 行をカバーできていませんでした。 8. retrofit / ParseErrorLogger 対応 対象 : retrofit パッケージをimportしているREST APIを実装したdartファイル(ex. rest_api.dart) 現象 : 旧 retrofit 4.1.0 + retrofit_generator 10.2.1 で生成したコードが型 ParseErrorLogger を参照するが、 package:retrofit/http.dart のみの import では参照できずコンパイルエラーになる 対応 : retrofit : ^4.1.0 → ^4.9.2 に更新(Dart 3.8 対応バージョン、ParseErrorLogger は package:retrofit/retrofit.dart で提供) rest_api.dart : import 'package:retrofit/retrofit.dart'; を追加し、生成コード(part ファイル)から ParseErrorLogger を参照可能にする。 package:retrofit/http.dart は retrofit.dart に含まれるため削除可 結果 : build_runner 再生成後も手動修正不要でビルド可能 9. Android 旧埋め込み (PluginRegistry.Registrar) 削除対応 Flutter 3.38 では v1 Android 埋め込み API( PluginRegistry.Registrar / registerWith )が削除されている。以下のパッケージを更新済み。 path_provider : ^2.1.3 → ^2.1.5 (path_provider_android 2.2.5+ を要求し、v1 削除済み) shared_preferences : ^2.2.3 → ^2.3.4 (shared_preferences_android 2.2.3+ で v1 削除済み) url_launcher_android : ^6.0.38 → >=6.3.3 <6.3.27 (6.3.3 で v1 削除。6.3.27+ は androidx.browser 1.9.0 が AGP 8.9.1 を要求するため 6.3.26 以下に制限) compileSdkVersion : 34 → 36(path_provider_android 等が SDK 36 を要求。 app/build.gradle ) 10. iOS CocoaPods更新 iOS最小デプロイメントターゲットをiOS 13.0にFixしました。 platform :ios , ' 13.0 ' config.build_settings[ ' IPHONEOS_DEPLOYMENT_TARGET ' ] = ' 13.0 ' 11. dart formatの修正 Dart SDK が 3.5→3.10 に上がったことで、同梱の dart_style が 2.3.x→3.x に major version upgrade されました。dart_style 3.x では "tall style" と呼ばれる新しいフォーマット戦略が導入され、trailing comma の扱いや multi-line expression のレイアウトルールが根本的に変わったため、コードに問題がなくても 100 ファイル以上が再フォーマット対象となり、大量のフォーマット修正を余儀なくされました。 Flutter バージョン別 カート追加時パフォーマンス比較レポート  新しいバージョンになったのでそれだけでどれだけ既存アプリのパフォーマンスにも影響を及ぼしたか気になったのでFlutter DevToolsでプロファイリングしてTimeline Eventsを3.24.1と3.38.9でベンチマーク比較してみました。  以下は、 同じカート追加アクション で取得したDevToolsの計測データを用い、 Flutter 3.24.1 と Flutter 3.38.9 のパフォーマンスを比較したレポートです。 項目 Flutter 3.24.1 Flutter 3.38.9 総フレーム数 96 107 比較サマリー 指標 Flutter 3.24.1 Flutter 3.38.9 差分 傾向 平均FPS 19.8 fps 48.3 fps +28.5 fps ✅ 大幅改善 平均フレーム時間 50.47 ms 20.71 ms -29.76 ms ✅ 約59%短縮 平均ビルド時間 7.23 ms 1.25 ms -5.98 ms ✅ 約83%短縮 平均ラスター時間 26.86 ms 15.32 ms -11.54 ms ✅ 約43%短縮 平均Vsyncオーバーヘッド 5.96 ms 1.46 ms -4.50 ms ✅ 約75%短縮 最大フレーム時間 274.89 ms 85.51 ms -189.38 ms ✅ 約69%短縮 最大ビルド時間 115.16 ms 18.66 ms -96.50 ms ✅ 約84%短縮 最大ラスター時間 145.56 ms 41.46 ms -104.10 ms ✅ 約72%短縮 Janky率 100.0% 88.8% -11.2pt ✅ 改善 重度Jank率 (>33ms) 38.5% 4.7% -33.8pt ✅ 大幅改善 結論 Flutter 3.38.9 は 3.24.1 と比較して、カート追加時のパフォーマンスが全体的に大きく改善しています。 平均FPSが 19.8 → 48.3 と約2.4倍になり、体感の滑らかさが向上しています。 ビルド時間・ラスター時間・Vsyncオーバーヘッドのいずれも短縮。 最大フレーム時間は 274.89ms → 85.51ms(約69%短縮) と、改善が確認できます。 重度Jank率は 38.5% → 4.7% と約1/8に減少。 主な改善要因(推測) Flutterエンジン・Skiaの最適化 ビルドパイプラインの効率化(ビルド時間の大幅短縮) Vsyncまわりのオーバーヘッド低減 今後の検討 3.38.9 時点でも Janky率 88.8%、平均FPS 48.3 であり、60fps目標にはまだ余裕があります。 ラスターが主なボトルネックのため、画像最適化・RepaintBoundary・Clip削減などの施策を続けると、さらに改善の余地があります。 まとめ  本ブログでは、Android15以降でサポートされている16KBページサイズに対応するためFlutterのバージョンを3.38系にアップグレードすると既存アプリにどのような影響を及ぼすことになるかについてお話ししました。  一旦は、両OSともビルドが成功してアプリ起動までは確認が取れたのでこれから5月31日まであまり日がないですが以下のようなThe next stepsに基づいて進めていく予定であることをお知らせして結びとさせていただきます。 1. テスト 単体テストの実行 統合テストの実行 手動テスト(特にデータベース操作とGraphQL操作) 2. iOS 11-12ユーザーへの対応検討 アプリストアで古いバージョンを継続提供 ユーザーへの事前通知 段階的な移行計画 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
はじめに デリッシュキッチンの 鈴木 です。 UX 体験向上のために Web フロントエンドのパフォーマンスを計測することもあるでしょう。その際に、計測結果をその都度サーバーへ送信すると、ネットワーク通信やシリアライズ処理が増え、画面描画やユーザー操作の体感に影響しやすくなります。これは避けなければなりません。 そこで実運用では、計測データをいったんメモリ上のバッファに溜め、一定間隔または一定件数でまとめて送信するバッチ送信が一般的です。しかしこの方式では、ページ遷移やタブクローズが起きた時点でバッファに未送信データが残っていると、送信開始前に失われたり、送信中の通信が中断されたりして欠損が起きる可能性があります。 さて、この問題にどう対処するべきなのでしょうか?今回は、ページ終了時に未送信データをできるだけ取りこぼさないために、パフォーマンス計測で使用される OpenTelemetry JS がどのように設計・実装して問題に対処しているかを、コードを手がかりに整理していきます。 課題: ページ遷移・クローズ時にデータが欠損する 送信完了前に通信が中断される問題 たとえばユーザーがボタンをクリックしてから画面遷移が完了するまでの時間(E2E レイテンシ)を計測する場合、まず正常系では次の流れになります。 ユーザー操作(計測開始) 処理完了(計測終了 → データ確定) 計測データをメモリ上のバッファに保存する 一定間隔または一定件数で、バッファの内容をまとめて送信する ここで問題になるのは、3 と 4 のあいだ、または 4 の途中にページ遷移やタブクローズが割り込むケースです。この時以下の問題が起こり得ます。 バッファに未送信データが残っていると欠損する 送信中の通信がページ終了により中断されることがある 一般的な非同期通信(fetch / XMLHttpRequest)は、ページ終了に伴ってブラウザ側で中断されることがあります。その結果、バッファ内に残っていたデータや送信途中のデータが Collector に届かず、データ欠損が起きます。 以下は、欠損が起きる典型的な流れを、シーケンス図として表したものです。 Fig 1: バッチ送信における正常系と、ページ終了割り込みによる欠損パターン(シーケンス図) ※ 図中の Normal は正常系、残り 2 つはページ終了が割り込むことで欠損が起こり得るケースです。 OpenTelemetry の対策 この問題に対して OpenTelemetry JS は、Web 標準 API を活用した 2 つのアプローチを組み合わせ、タブクローズ時の送信成功率を高めています。 検知とトリガー: ページ終了の直前に発火するイベントを検知し、バッファに残っているデータの送信をただちに開始する 送信継続: ページ終了後も送信が完了しやすい Web API に委譲する 全体像をレイヤに分けると次のようになります。 Fig 2: OpenTelemetry JS による二段構えの送信設計(検知とトリガー/送信継続) それでは、この 2 つの対策が具体的にどのコードで実現されているかを追っていきたいと思います。 実装詳細をコードで追う フェーズ1: 【検知】ページライフサイクルイベントの監視 通常、スパン(計測データ)はパフォーマンスへの影響を抑えるため、一定数をバッファに溜めてからまとめて送信(バッチ処理)します。しかし、ページ終了時に通常の周期的な送信タイミングを待っていると、その前にページが破棄されてしまった場合にスパンが失われる可能性があります。 そこで BatchSpanProcessor は、ページが終了する兆候を示すイベントを監視し、発火したら forceFlush() を呼んで今ある分を即座に送る方針を取ります。 該当コード( BatchSpanProcessor.ts ) private onInit(config?: BatchSpanProcessorBrowserConfig): void { if ( config?.disableAutoFlushOnDocumentHide ! == true && typeof document !== 'undefined' ) { this ._visibilityChangeListener = () => { if ( document .visibilityState === 'hidden' ) { this .forceFlush(). catch ( error => { globalErrorHandler(error); } ); } } ; this ._pageHideListener = () => { this .forceFlush(). catch ( error => { globalErrorHandler(error); } ); } ; document . addEventListener ( 'visibilitychange' , this ._visibilityChangeListener); // use 'pagehide' event as a fallback for Safari; see // https://bugs.webkit.org/show_bug.cgi?id=116769 document . addEventListener ( 'pagehide' , this ._pageHideListener); } } visibilitychange : document.visibilityState === 'hidden' になった瞬間を検知 pagehide : Safari 向けのフォールバック(コメントにもある通り) これらのイベントが発火すると forceFlush() が呼ばれ、バッファ内のスパンがエクスポート処理へ回されます。 該当コード( BatchSpanProcessorBase.ts ) forceFlush(): Promise < void > { if ( this ._shutdownOnce.isCalled) { return this ._shutdownOnce.promise; } return this ._flushAll(); } ここで重要なのは、 forceFlush() 自体は非同期であり、送信完了までページの終了を止められるわけではない点です。JavaScript には送信が完了するまでページ遷移を確実に止めるための一般的な仕組みがありません。したがって、検知してただちに送信を開始しても、なお送信中にページが閉じてしまう可能性は残ります。その穴を埋めるのが次のフェーズです。 フェーズ2: 【送信継続】ページ終了後も通信を継続しやすい API の活用 ページ終了後も送信を完遂するには、ページの寿命とネットワークリクエストの寿命を切り離せる API が必要です。OpenTelemetry JS の Transport 層は、状況に応じて次の 2 つを使い分けます。 navigator.sendBeacon() (ヘッダ不要の場合) fetch(..., { keepalive: true }) (認証などでヘッダが必要な場合) sendBeacon はページ終了時の送信継続に適した API ですが、リクエストにカスタム HTTP ヘッダー(例: Authorization )を付与できないという制約があります。そのため、認証等でヘッダーが必要なケースでは fetch を使う必要があり、ページ終了後も送信継続が期待できるよう keepalive: true を併用する設計になります。 選択肢A: navigator.sendBeacon() sendBeacon は、ページアンロード時の送信を想定して設計された API です。ノンブロッキングで送信を開始でき、ページが閉じた後もブラウザが送信継続を試みます。ただし、 sendBeacon の戻り値は送信完了を保証するものではなく、あくまでブラウザが送信処理の開始(キュー投入)を受け付けたかどうかの成否に近い点には注意が必要です。つまり、 sendBeacon を使っても確実に届くわけではなく、ページ終了時の到達率を上げるためのベストエフォートな手段だと捉えるのが正確です。 該当コード( send-beacon-transport.ts ) async send(data: Uint8Array ): Promise < ExportResponse > { const blobType = ( await this ._params. headers ()) [ 'Content-Type' ] ; return new Promise < ExportResponse >( resolve => { if ( navigator . sendBeacon ( this ._params. url , new Blob ( [ data ] , { type : blobType } ) ) ) { // no way to signal retry, treat everything as success diag.debug( 'SendBeacon success' ); resolve( { status : 'success' } ); } else { resolve( { status : 'failure' , error : new Error ( 'SendBeacon failed' ), } ); } } ); } 選択肢B: fetch の keepalive: true 認証ヘッダーが必要な場合は fetch を使いますが、ポイントは keepalive: true を付けることです。これにより、ページの破棄後もリクエストが一定の条件で継続されることが期待できます。 該当コード( fetch-transport.ts ) const isBrowserEnvironment = !!globalThis. location ; const url = new URL ( this ._parameters. url ); const response = await fetch (url. href , { method : 'POST' , headers : await this ._parameters. headers (), body : data, signal : abortController.signal, keepalive : isBrowserEnvironment, mode : isBrowserEnvironment ? globalThis. location ?. origin === url. origin ? 'same-origin' : 'cors' : 'no-cors' , } ); OpenTelemetry JS は、ブラウザ環境であることを検知した場合に自動で keepalive: true を付与するため、利用者側で特別な設定をしなくてもページ終了時に強い送信経路を取りやすい設計になっています。 なお keepalive (および sendBeacon )は、実装上おおむね 数十KB(典型的には約 64KiB 前後)の送信サイズ上限に当たりやすく、バッチが肥大化すると送信に失敗する可能性があります。したがって実運用では、ページ終了時のフラッシュ対象を未送信すべてにするのではなく、イベントを小さく保つ、分割する、重要度で間引くといった設計上の工夫も合わせて検討すると安全です。 まとめ: タブクローズ時のデータ損失はどこまで回避できるか OpenTelemetry JS は、ブラウザ仕様の範囲内で ベストエフォートにデータ欠損を減らす設計を取っています。 検知( BatchSpanProcessor ): ページが非表示・終了に向かうイベントを検知し、即座に forceFlush() を起動する 継続(Transport): sendBeacon または fetch(keepalive) を用い、ページ破棄後の通信継続をブラウザに委譲する この二段構えにより、タブクローズ時のデータ到達率は現実的に大きく改善します。一方で、開発者が理解しておくべき限界もあります。 開発者が知っておくべきポイント 設定不要で動く: 既定設定の範囲で、この仕組みは動作する 完全な保証ではない: ブラウザのクラッシュ、ネットワーク断、OS 側の強制終了などでは失敗し得る サイズ上限の影響がある: keepalive には送信サイズの上限があり、上限を超えると送信に失敗する可能性がある 認証環境でも破綻しにくい: 認証ヘッダーが必要な場合は fetch(keepalive) が選択されるため、現代のブラウザでは一定の実用性が期待できる 以上が、OpenTelemetry JS がページ終了時に欠損しやすい計測データを守るために採用している設計の要点です。 おわりに 今回 Opentelemetry JS のコードを追ってみましたが、かなり SDK レベルで頑張ってくれている印象がありますね。 ただ、SDK だけで完全に永続性を担保することは難しいので、どうしても失いたくないデータがある場合は、ブラウザのストレージ(IndexedDB など)にいったん永続化し、Service Worker などを用いてバックグラウンドで再送する、といった設計も選択肢になります。OpenTelemetry JS の仕組みはあくまでベストエフォートであるため、要件に応じて永続化を組み合わせるとより堅牢になるでしょう。
アバター