TECH PLAY

Go

イベント

該当するコンテンツが見つかりませんでした

マガジン

技術ブログ

.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、検索基盤部の倉澤です。ZOZOTOWNの検索機能のバックエンドの開発を担当しています。検索基盤部の一部システムではGoを採用しています。 2026年2月21日(土)にGo Conference mini in Sendai 2026が開催されました。本記事では、会場の様子や個人的に印象に残ったセッション・LTについて紹介します。また、私もLT枠で登壇したため当日話しきれなかった内容もあわせて紹介します。 目次 はじめに 目次 Go Conference mini in Sendai 2026とは 会場の様子 セッション AI時代のGo開発2026 爆速開発のためのガードレール 個人的に気になった点 Go パッケージのサプライチェーン攻撃を防ぐ CI を作ってみた 個人的に気になった点 Go 1.26 で生まれ変わった go fix をプロダクト開発の運用に乗せる 個人的に気になった点 encoding/json/v2のUnmarshalはこう変わった ~内部実装で見る設計改善~ このテーマを選んだきっかけ さいごに Go Conference mini in Sendai 2026とは Go Conferenceは、プログラミング言語Goに関するカンファレンスです。今回は「東北から広がるGoコミュニティ」というテーマで仙台にて4年ぶりに開催されました。18セッション(20分)と12のLT(5分)によって構成され、Goに関するさまざまなテーマについて発表されました。参加者は117人と大盛況のうちに終わりました。 sendaigo.jp 会場の様子 会場は仙台市青葉区にあるアーバンネットビル仙台中央 カンファレンスルームでした。ワンフロアにスポンサーブースと2部屋のセッションルームがあり、同時に発表が行われました。 オープニングの様子 スポンサーブースでは、参加者向けのさまざまなコンテンツが用意されていました。 株式会社UPSIDERさんのブース 株式会社UPSIDERさんのブースでは、「Goの挑戦Goっそり教えて!」をテーマに意見が募られていました。「TinyGoを使って何かを作ってみたい」等の声があり、TinyGoへの関心の高さがうかがえました。 株式会社SODAさんのブース 株式会社SODAさんのブースでは、「あなたのやらかしエピソードや懺悔したいことを教えてください」をテーマに意見が募られていました。生成AIが書いたコードによってやらかしたエピソードが昨今の開発事情を表していて面白いなと思いました。また、SODAさんのブースではGopherの16タイプに分ける診断を実施しておりました。 snkrdunk.github.io 私は、「数学的な賢者」でした! Gopherの16タイプ診断 会場では参加者全員にステッカーなどのノベルティが配布されており、どれもとても可愛らしいデザインでした。 ノベルティのステッカー また、ネームタグの裏側には「すぐに使える仙台弁」が記載されており、参加者同士の会話のきっかけになっていました。仙台開催ならではの遊び心が感じられる演出でした。 ネームタグ さらに登壇者にはTシャツが配布され、登壇の良い記念になりました。運営の皆さまのお心遣いに感謝です。 登壇者用のTシャツ セッション AI時代のGo開発2026 爆速開発のためのガードレール www.docswell.com こちらのセッションでは、生成AIにおける開発の「速さ」と「治安(コード秩序やルール)」をいかに両立させるのかについて紹介されています。 課題 生成AIの発達・普及により実装速度が飛躍的に向上した一方で、アーキテクチャのルール違反などコードの治安が悪化しやすくなっている。 対策 Rules/Skillsのような非決定的な制約(ソフト制約)だけに頼るのではなく、決定的な制約(ハード制約)をガードレールとして整備することが重要。 紹介されているハード制約の例 Goの internal パッケージによるアクセス制御 depguard 等のLintによる依存ルールの強制 Fuzzing testやMutation testによるテスト品質の担保 個人的に気になった点 アーキテクチャの依存ルールを生成AIに守らせるという観点で、Goの internal パッケージを用いるというのは面白い発想だと思いました。一方で、ドメイン単位でパッケージを分割する Package by Feature だからこそ威力を発揮する一面もあるのかなと思いました。私が携わっているプロジェクトでは、アーキテクチャの技術的な役割(レイヤー)毎にパッケージを分割する Package by Layer を採用しています。 internal パッケージの配下に各レイヤーのパッケージを切る構成が一般的です。この場合、 internal が守れるのは外部モジュールからのアクセスであり、 internal 内部のレイヤー間の依存方向までは防げません。 発表後に登壇者の方へ質問したところ、 Package by Layer でも internal パッケージが活きるケースを共有していただきました。各層でしか使わない関数を他の層から使われないように守るという観点です。例えば、 presentation 層でレスポンスに対して処理する関数を internal に配置すれば、他の層からの誤った利用を防げるとのことでした。レイヤー間の依存方向の制御とは別に、各層の内部実装の隠蔽という観点で internal が有効に機能するというのは納得感がありました。 Go パッケージのサプライチェーン攻撃を防ぐ CI を作ってみた speakerdeck.com こちらのセッションでは、Goパッケージのサプライチェーン攻撃をCIで防ぐ取り組みについて紹介されています。 課題 typosquatting (タイプミスを狙った攻撃)や slopsquatting (AIのハルシネーションを狙った攻撃)により、悪意のあるパッケージの混入リスクがある 対策 Googleが公開している capslock を活用し、パッケージがアクセスし得る特権的操作(ファイルシステム操作、ネットワーク通信など)を静的解析で検知 PRで新しいパッケージが追加された際に、 main ブランチとのCapabilityの差分をCIで検出 その結果をClaude Code Actionに読み込ませることで、セキュリティリスクを診断する仕組みを構築 個人的に気になった点 こちらのセッションは、昨年開催されたGo Conference 2025の「 サプライチェーン攻撃に学ぶmoduleの仕組みとセキュリティ対策 」に続く内容だと感じました。昨年の発表では、Goのパッケージ管理システムを利用したサプライチェーン攻撃が3年以上見つからず、その根本的な対策も難しいという話がありました。本発表はLT枠で5分と短かったですが、昨年のGo Conferenceで発表された課題に対して対策を検討し、同じくGo Conferenceで発表するという流れにとても感心しました。 発表内容で気になったのは、新しく追加されたパッケージのCapabilityから悪意の有無をClaude Codeがどう判断しているかという点です。登壇者の方に質問したところ、依存先パッケージのメソッド名や周辺の実装をもとに判断していると考えられるとのことでした。また、サードパーティの公式パッケージを追加した際にも、依存先パッケージでCapabilityの警告が出るケースもあったそうです。ただし公式パッケージである以上、対処は難しく、まだ改善の余地があるとのことでした。 Go 1.26 で生まれ変わった go fix をプロダクト開発の運用に乗せる speakerdeck.com こちらのセッションでは、Go 1.26で大幅に刷新された go fix コマンドをプロダクト開発の現場にどう組み込むかについて紹介されています。 運用フローの設計 「検知」と「適用」を分けて考えるのがポイント 検知(毎PR): golangci-lint の modernize を有効化し、CIで古い書き方を常時警告する 適用(Goバージョン更新時): go fix ./... を2回実行して既存コードを一括変換する go fixに関する3つのアプローチと使い分け modernize :組み込みルールによるコードのモダン化。go fixを実行するだけ SuggestedFix :自作Analyzerに修正提案を追加し、プロジェクト固有のパターンを自動修正する go:fix inline :非推奨関数に //go:fix inline を付与し、利用者側でgo fixを実行するだけでAPI移行を自動化する 個人的に気になった点 先日公開された公式ブログ「 Using go fix to modernize Go code 」を読んでおり、最近私も go fix を実行した経験がありました。そのため、運用観点の話はとても興味深い内容でした。特に気になっていたのは、 go fix の「2回実行が必要」という点の仕組み化です。ある modernize ルールの適用が別のルールの適用機会を生むため、公式ブログでも2回の実行が推奨されていますが、これを仕組み化するのは難しいと感じていました。登壇者の方に質問したところ、以下のような回答をいただきました。 まだ完全な仕組み化はできていないが、 pre-commit フックでコミット前に go fix を実行する方法を検討している ただしpre-commitの導入はチームにより意見が分かれるため、Claude CodeのSkillsで実行させるのも有効ではないか 生成AIのSkillsは、こうした「毎回やるべきだが柔軟さも求められるルール」の適用に向いているという点に納得感がありました。また、 golangci-lint の modernize リンターについても質問しました。内部的にはgo fixと同じ modernize アナライザが動いているため、こちらも同様に複数回の実行が必要とのことでした。 encoding/json/v2のUnmarshalはこう変わった ~内部実装で見る設計改善~ speakerdeck.com 私も今回LT枠で登壇いたしました。このセッションでは、Go 1.25で実験的に追加された encoding/json/v2 パッケージの Unmarshal 関数を取り上げました。従来の encoding/json パッケージが抱えているパフォーマンス上の課題と、v2での改善点を内部実装の観点から紹介しました。 v1での課題点 パッケージの構成 :1つのパッケージに「JSONを解析する処理」と「Goの構造体に変換する処理」がすべて混在しており、変更時の影響範囲も広かった エラーメッセージ :JSONのパース(解析)に失敗したとき、どの項目でなぜ失敗したのかがエラーメッセージから読み取りにくかった メモリの使い方 :Unmarshalを呼ぶたびに内部で使うオブジェクト(Decoder)を毎回新しく作成しており、高頻度で呼び出すとメモリ確保やGC(ガベージコレクション)の負担が大きかった データの読み取り方 :JSONデータを読み取るたびに内部でコピーが発生しており、メモリ効率が悪かった v2での改善点 パッケージの分離 :「JSONの解析」を担う jsontext パッケージと「Goの型へのマッピング」を担う json パッケージに分離し、それぞれの役割を明確にした 構造化されたエラー :エラー情報にJSONのどの位置で、どんなJSON型が原因で失敗したかを含めるようにし、原因の特定が容易になった オブジェクトの再利用 :sync.Poolパッケージを使い、一度作った Decoder を使い回すことで、メモリ確保の回数とGCの負担を大幅に削減した 効率的なバッファ管理 :1つのバッファ(データを一時的に保管する領域)を論理的に分割して管理することで、データのコピーなしに必要な部分へアクセスできるようになった このテーマを選んだきっかけ 普段の業務ではREST APIを実装する機会が多く、 encoding/json パッケージを利用する場面も多くありました。しかし、 encoding/json には以前から課題が多く、 golang/go#71497 でも長期にわたって議論が続いています。そんな中、Go 1.25で実験的にv2が追加されました。 go-json-experiment/jsonbench のベンチマーク結果を見ると、v2の Unmarshal 関数は以下の点で大きく改善されていることがわかります。 大幅な速度改善 :具象型で2.7〜10.2倍、RawValue型では最大21.1倍と、v1から劇的に高速化されている 安全性を犠牲にしていない : unsafe パッケージを使用せず、UTF-8の検証や重複キーの拒否などRFC準拠の正確性も向上している ストリーミング対応 :v1では非対応だった Unmarshal のストリーミングにも設計当初から対応している 速度・正確性・安全性のいずれも改善されているという結果から、「なぜこれほど改善できたのか?」を内部設計から理解したいと思い、 アドベントカレンダーの記事 で調査しました。その調査がきっかけとなり、今回プロポーザルを提出しました。 さいごに 今回LT枠ではありますが、初めてGo Conferenceにプロポーザルを提出し、採択していただきました。発表後には「あと20分くらい聞きたかった」や「よく5分でまとめましたね」などとても温かいお声をいただきました。登壇を機に、さまざまな方と繋がれたことは非常に貴重な経験でした。アウトプットがきっかけで生まれる繋がりの大切さを改めて実感しました。また、登壇を機に初めて仙台へ行きました。牛タンやずんだ餅など仙台グルメも堪能でき、カンファレンスと合わせて充実した思い出となりました。 最後に、このような素晴らしい場を作ってくださった運営の皆さまに心から感謝いたします。準備から当日の進行まで、細やかな配慮が行き届いており、登壇者・参加者いずれの立場でも安心して楽しむことができました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
こんにちは @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 ソースコード
.entry .entry-content .table-of-contents > li > ul, .table-of-contents li:nth-child(2) { display: none; } はじめに こんにちは、ECプラットフォーム部の権守です。普段はZOZOTOWNの会員基盤やID基盤の開発に携わっています。 本記事では、会員基盤で導入したデータベースへの書き込みを伴う処理のテスト手法について紹介します。この手法では実行前後のデータベースの差分に注目することで特定のレコードだけでなく、データベース全体への副作用を網羅的に検知することを目的とします。 目次 はじめに 目次 従来手法の課題 差分検証によるアプローチ Goによる差分検出ツールの実装 利用イメージ 差分抽出の実装 複数データベースへの対応 導入時の工夫点 非固定値の取り扱い 期待値の正規化 差分の除外 まとめ 従来手法の課題 データベースへの書き込みを伴う処理のテストでは、一般的に以下のように関数の返り値と処理対象である特定のレコードを検証することが多いと思います。 // 1. テスト対象の関数を実行 refundedPoints, err := usecase.CancelOrder(ctx, orderID) AssertEqual(t, nil , err) // 2. 返還ポイント(返り値)を検証 AssertEqual(t, 500 , refundedPoints) // 3. 特定のレコードの状態を検証 order, _ := orderRepo.FindByID(ctx, orderID) AssertEqual(t, "CANCELLED" , order.Status) しかし、これらのテストだけではデータベースへの「期待しない副作用」を防げないことに課題を感じていました。例えば、更新や削除の条件指定に誤りがあると想定外のレコードに影響を及ぼすことが考えられます。この場合には、処理対象である特定のレコードのみを検証したとしても、その他のレコードが破壊されていることに気づくことはできません。 差分検証によるアプローチ この課題を解決するには、データベースへの副作用を網羅的に検証する必要があります。そこで、データベースの実行前後の全レコードをキャプチャして比較し、その差分を検証するアプローチを採用しました。 このアプローチでは、特定のテーブル・レコード・カラムを見るのではなく、データベース全体への副作用をテスト対象の出力の1つとして捉えます。出力の期待値として差分を指定し、期待した副作用のみが存在することを検証することで、期待しない副作用が生じた際にそれを検知できます。 差分は以下の3種類としてそれぞれ抽出します。 作成 (Create):新規レコードのカラム全体の値を保持 更新 (Update):キー情報と、変更があったカラムの「変更後の値」を保持 削除 (Delete):レコードを特定するキー情報を保持 更新の差分の表現としては、更新前後の値を含める方が一般的ですが、本手法ではあえて更新後の値のみを保持しています。テストという観点では、更新前の値は事前条件の一部であり、テストデータのセットアップ内容と重複するためです。 Goによる差分検出ツールの実装 利用イメージ 会員基盤はGoで実装されているため、テストへの組み込みやすさを考慮して今回はGoのコード上で実装しました。 以下に、どのように差分をGoの構造体で表現し、利用するかのイメージを示します。 // 差分データの構造イメージ type Diff struct { C []Record // CREATE: 追加されたレコード群を指定。各レコードは全フィールド値を指定 U map [KeyHash]Record // UPDATE: 更新のあるレコード群を主キーのハッシュ値で指定。各レコードは更新後のフィールド値を指定 D []Record // DELETE: 削除されたレコード群を指定。各レコードは主キー値を指定(主キーが存在しない場合は全フィールド値) } type Diffs map [ string ]Diff // mapのキーはテーブル名 // 挿入時の差分検出の利用イメージ var result int diffs := DiffDB(ctx, db, func () { result = insertMember(member) }) AssertEqual(t, 1 , result) AssertRecords(t, Diffs{ "members" : { C: []Record{ { "id" : 1 , "age" : 20 , "nickname" : "taro" , }, }, }, }, diffs) // 更新時の差分検出の利用イメージ var result int diffs = DiffDB(ctx, db, func () { result = updateMemberName( 1 , "jiro" ) }) AssertEqual(t, 1 , result) AssertRecords(t, Diffs{ "members" : { U: map [KeyHash]Record{ HashKey({ "id" : 1 }): { "nickname" : "jiro" , }, }, }, }, diffs) // 削除時の差分検出の利用イメージ var result int diffs = DiffDB(ctx, db, func () { result = deleteMember( 1 ) }) AssertEqual(t, 1 , result) AssertRecords(t, Diffs{ "members" : { D: []Record{ { "id" : 1 , }, }, }, }, diffs) このようにデータベースに対する副作用を出力値として検証できるため、「レコードを取得して特定のカラムを検証する」という命令的な記述を繰り返す必要がなくなり、テストコードの可読性と保守性が向上します。 具体的には、Goで広く採用されているテーブル駆動テストのスタイルと親和性が高く、複数のテストケースを簡潔に記述できます。例えば、条件分岐で書き込むテーブルが変わる関数をテストする場合、従来の手法では、テストケースによって検証処理も分岐するか、テストケースの構造体に検証処理を持つ必要がありました。検証処理の分岐はテストコードの複雑化を招き、テストケースの構造体に検証処理を持たせることはテーブル駆動テストのメリットである宣言的な記述を損ないます。 しかし、今回導入した手法であれば宣言的な記述を維持できます。以下にそれぞれの手法の例を示します。 // 従来手法その1(検証処理の条件分岐) tests := [] struct { name string orderID string expectRefund bool // 返金テーブルを確認するかどうかのフラグ expectPointReset bool // ポイント更新を確認するかどうかのフラグ }{ { name: "クレジットカード決済のキャンセル(返金あり)" , orderID: "order_card" , expectRefund: true , expectPointReset: false , }, { name: "全額ポイント払いのキャンセル(ポイント還元あり)" , orderID: "order_point" , expectRefund: false , expectPointReset: true , }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { // 1. テスト対象の実行 err := usecase.CancelOrder(ctx, tt.orderID) AssertEqual(t, nil , err) // 2. 注文ステータスの検証(共通) order, err := orderRepo.FindByID(ctx, tt.orderID) AssertEqual(t, nil , err) AssertEqual(t, "CANCELLED" , order.Status) // 3. 条件分岐による個別テーブルのアサーション // テストケースが増えるたびに、この分岐ロジックのメンテナンスが必要になる if tt.expectRefund { refund, err := refundRepo.FindByOrderID(ctx, tt.orderID) AssertEqual(t, nil , err) AssertEqual(t, Refund{OrderID: tt.orderID, amount: 1000 }, refund) } if tt.expectPointReset { user, err := userRepo.FindByID(ctx, order.UserID) AssertEqual(t, nil , err) AssertEqual(t, 1500 , user.Points) } }) } // 従来手法その2(テストケースごとに検証処理を持つ) tests := [] struct { name string orderID string assertFunc func (t *testing.T, orderID string ) }{ { name: "クレジットカード決済のキャンセル(返金あり)" , orderID: "order_card" , assertFunc: func (t *testing.T, orderID string ) { // 注文ステータスの検証 order, err := orderRepo.FindByID(ctx, orderID) AssertEqual(t, nil , err) AssertEqual(t, "CANCELLED" , order.Status) // 返金テーブルの検証 refund, err := refundRepo.FindByOrderID(ctx, orderID) AssertEqual(t, nil , err) AssertEqual(t, Refund{OrderID: orderID, amount: 1000 }, refund) }, }, { name: "全額ポイント払いのキャンセル(ポイント還元あり)" , orderID: "order_point" , assertFunc: func (t *testing.T, orderID string ) { // 注文ステータスの検証 order, err := orderRepo.FindByID(ctx, orderID) AssertEqual(t, nil , err) AssertEqual(t, "CANCELLED" , order.Status) // ユーザーポイントの検証 user, err := userRepo.FindByID(ctx, order.UserID) AssertEqual(t, nil , err) AssertEqual(t, 1500 , user.Points) }, }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { // テスト対象の実行 err := usecase.CancelOrder(ctx, tt.orderID) AssertEqual(t, nil , err) // テストケース固有の検証処理を実行 tt.assertFunc(t, tt.orderID) }) } // 差分検出を用いた手法 tests := [] struct { name string orderID string expectedDiff Diffs }{ { name: "クレジットカード決済のキャンセル(返金あり)" , orderID: "order_card" , expectedDiff: Diffs{ "orders" : { U: map [KeyHash]Record{ HashKey({ "id" : "order_card" }): { "status" : "CANCELLED" , }, }, }, "refunds" : { C: []Record{ { "order_id" : "order_card" , "amount" : 1000 , }, }, }, }, }, { name: "全額ポイント払いのキャンセル(ポイント還元あり)" , orderID: "order_point" , expectedDiff: Diffs{ "orders" : { U: map [KeyHash]Record{ HashKey({ "id" : "order_point" }): { "status" : "CANCELLED" , }, }, }, "users" : { U: map [KeyHash]Record{ HashKey({ "id" : "user_1" }): { "points" : 1500 , }, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { // 全テーブルの差分をキャプチャしつつ実行 var err error diffs := DiffDB(t.Context(), db, func () { err = usecase.CancelOrder(ctx, tt.orderID) }) // 返り値の検証 AssertEqual(t, nil , err) // 差分の検証 AssertRecords(t, tt.expectedDiff, diffs) }) } 差分抽出の実装 差分抽出は、以下の手順で行います。 全テーブルの主キー情報を含むスキーマ情報を取得 テスト対象の関数実行前に、対象データベースの全テーブルの全レコードを取得し、メモリ上に保存 テスト対象の関数を実行 関数実行後に、再度全テーブルの全レコードを取得 実行前後のレコードを比較し、差分を抽出 スキーマ情報の取得や全レコードの取得は、データベースの種類に依存するため、DBSourceインタフェースを定義し、各データベースに応じた実装を用意しました。 ここでは、スキーマ情報と全レコードの取得の実装については割愛し、差分抽出のコアロジックを示します。 func createDiff(source DBSource, before, after map [ string ] map [KeyHash]Record) Diffs { diffs := Diffs{} // 各テーブルごとに差分を抽出 for tableName := range before { diff := Diff{U: map [KeyHash]Record{}} // 各テーブルのスキーマ情報を取得 schema := source.schemata()[tableName] // レコードごとに差分を比較 // keyは各レコードの主キーのハッシュ値 for key, record := range before[tableName] { // 実行前にあったレコードが実行後にも存在する場合 if afterRecord, ok := after[tableName][key]; ok { updates := Record{} for k, v := range record { // 値が異なるカラムのみを抽出 if !reflect.DeepEqual(v, afterRecord[k]) { updates[k] = afterRecord[k] } } // 更新があった場合のみdiffに追加 if len (updates) != 0 { diff.U[key] = updates } // Createを抽出するために、afterから既存レコードを削除 delete (after[tableName], key) continue } keyValues := map [ string ]any{} for _, key := range schema.keys { keyValues[key] = record[key] } diff.D = append (diff.D, keyValues) } // 残ったafterのレコードは新規作成されたレコード for _, record := range after[tableName] { diff.C = append (diff.C, record) } // 期待値を書く際に差分がない場合は省略可能にするため、空スライスはnilに変換 if len (diff.U) == 0 { diff.U = nil } // テーブルに何らかの差分があった場合のみdiffsに追加 if len (diff.C) != 0 || len (diff.U) != 0 || len (diff.D) != 0 { diffs[tableName] = diff } } // 期待値を書く際に差分がない場合は省略可能にするため、空のDiffsはnilに変換 if len (diffs) == 0 { return nil } return diffs } 複数データベースへの対応 ZOZOTOWNではリプレイスを進めるにあたり、一時的に既存環境と新環境それぞれのデータベースに書き込むケースが存在します。それぞれで期待した差分があるかを検証できるように複数データベースにも対応しました。DiffExtractorにラベルを付けて複数設定することで、差分出力時にそれぞれどのデータベースで生じた差分かを判定できます。 // 複数データベースに対応した実装 type DiffExtractor struct { // 複数のデータベースを抽出対象とする // mapのキーはデータベースを特定するためのラベル sources map [ string ]DBSource } func NewDiffExtractor(sources map [ string ]DBSource) DiffExtractor { return DiffExtractor{sources: sources} } func (de DiffExtractor) Diff(ctx context.Context, f func ()) map [ string ]Diffs { diffs := map [ string ]Diffs{} before := map [ string ] map [ string ] map [KeyHash]Record{} for name, source := range de.sources { // テスト対象実行前の各データベースをキャプチャ before[name] = source.dump(ctx) } f() after := map [ string ] map [ string ] map [KeyHash]Record{} for name, source := range de.sources { // テスト対象実行後の各データベースをキャプチャ after[name] = source.dump(ctx) // 各データベースの差分抽出 diffs[name] = createDiff(source, before[name], after[name]) } return diffs } // 複数データベースで利用する場合のテストヘルパー例 func DiffDBForDoubleWrite(ctx context.Context, mysqlDB *sql.DB, mssqlDB *sql.DB, f func ()) map [ string ]Diffs { mysqlSource := dd.NewMySQLSource(ctx, mysqlDB) mssqlSource := dd.NewMSSQLSource(ctx, mssqlDB) extractor := dd.NewDiffExtractor( map [ string ]dd.DBSource{ "mysql" : mysqlSource, "mssql" : mssqlSource, }) return extractor.Diff(ctx, func () { f() }) } 導入時の工夫点 差分検出を導入するにあたり、テストの安定性を保つためにいくつか工夫しました。 非固定値の取り扱い 本手法では、特定のカラムだけでなくレコード全体を対象とするため、自動採番されたIDや現在時刻、乱数など実行のたびに値が変わるカラムについても常に考慮する必要があります。 IDの自動採番については、各テストケースの実行前にオートインクリメントなどのシーケンスをリセットするために、TRUNCATE文を実行することで対応しました。これにより、発行されるIDを固定し、期待値を固定できます。 // テスト実行前のセットアップ例 func SetupTestDB(t *testing.T, db *sql.DB) { t.Helper() // ユーザー定義された全テーブル名の取得 tables := GetTableNames(db) for _, table := range tables { _, err := db.Exec(fmt.Sprintf( "TRUNCATE TABLE %s" , table)) if err != nil { t.Fatal(err) } } } 実際にTRUNCATE文を実行するには外部キーの制約チェックを一時的に解除する、もしくはテーブルの処理順序を制御するといったことも必要になります。 現在時刻については、関数内で time.Now() を使わず、時刻を引数として渡すか、インタフェースを介して注入することでテスト内の時刻を固定しています。これにより、時刻に関する期待値も固定できます。 乱数については、乱数生成の箇所をインタフェース化して期待値を固定する方法などが考えられますが、それが難しい場合も考慮して、アサーション関数において値一致以外も可能にしました。具体的には文字列に対する期待値に *regexp.Regexp を指定した場合には正規表現マッチを行うようにしました。 // 乱数を含むフィールドの検証例 expectedDiffs := Diffs{ "orders" : { C: []Record{ { "order_id" : regexp.MustCompile( `\A[0-9a-f]{32}\z` ), // 乱数を正規表現で表現 "amount" : 10 , "order_at" : "2026-01-01T00:00:00Z" , }, }, }, } AssertRecords(t, expectedDiffs, actualDiffs) 現状は使うケースがなかったため用意していませんが、数値型の乱数を利用する場合にはそれぞれ専用の型を用意して、検証処理を切り替えることも検討しています。 期待値の正規化 会員基盤ではテストデータのセットアップにレコードデータではなくモデルデータを利用しているため、データベースから抽出した差分の値とテストケースの期待値とでは形式が異なることもあります。例えば、モデルデータではbool型のフィールドが、データベースからの出力時はint型の0もしくは1になるケースがあります。他にもモデルデータでは値オブジェクトとして定義されているフィールドが、データベースからの出力時はその値オブジェクトの内部の値になるケースもあります。 このような場合に、テストケースの期待値をデータベースからの出力に合わせた形式で記述するのは、テストケースの可読性を損なうため、アサーション関数内で比較時に正規化する方針としました。実装の詳細は割愛しますが、リフレクションを用いて reflect.ValueOf 関数で reflect.Value に変換した後、 Kind() メソッドで元となる型を判定して正規化を行っています。 差分の除外 データベースのトリガー処理による時刻の挿入などアプリケーション側から制御できない値や、もうアプリケーション上から利用していないカラムのような例外的に差分から除外したいケースが存在します。そこで、抽出した差分からカラムを指定して除外するための Ignore() メソッドを用意しました。また、用意されていない方法で特定のカラムを検証するために一旦、差分から取り除いた上で別途検証するという場合にも利用できます。 diffs := DiffDB(ctx, db, func () { someFunc() }) // hogeは廃止済みのカラムで期待値の管理対象外とする AssertRecords(t, expectedDiffs, diffs.Ignore( "hoge" )) また、特定のテストケースによらず、アプリケーション全体で除外したい条件があるような場合に対応するため、DiffExtractorに除外用の関数をオプションで設定できるようにしました。 var someIgnoreColumnFunc = func (tableName, columnName string ) bool { // 例えば、全テーブルのhogeカラムを常に除外する場合 return columnName == "hoge" } // 除外関数をオプションに設定したテストヘルパー例 func DiffDB(ctx context.Context, db *sql.DB, f func ()) dd.Diffs { source := dd.NewMySQLSource(ctx, db) source.WithIgnoreColumnFunc(someIgnoreColumnFunc) extractor := dd.NewDiffExtractor( map [ string ]dd.DBSource{ "mysql" : source, }) diffs := extractor.Diff(ctx, func () { f() }) return diffs[ "mysql" ] } まとめ 本記事では、Goを用いたデータベースのレコード差分検出によるテスト手法について紹介しました。 複雑なテストケースになるほど、データベースへの副作用を網羅的に検証することの重要性が増します。本手法を導入することで、期待しない副作用を検知しやすくなり、テストコードの可読性と保守性も向上しました。今後も、より良いテスト手法の模索と改善を続けていきたいと思います。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co

動画

該当するコンテンツが見つかりませんでした

書籍