ORMをアップグレードするために行った大改修

daisuzu
Eureka Engineering
Published in
Dec 8, 2021

この記事は「Eureka Advent Calendar 2021」の8日目の記事です。

こんにちは、Backendチームのdaisuzuです。

本記事ではPairsのバックエンドで使用していたgithub.com/go-xorm/xorm(v0)をどのようにしてxorm.io/xorm(v1)にアップグレードしたのかを紹介します。

アップグレードの目的としては主に以下の2点を考えていました。

  • データベースに起因する諸々のトラブルが解消するかもしれないという期待
  • 今後も開発が続いていくのにいつまでも古いバージョンを使い続けるのは不健全

ここでいうデータベース起因のトラブルとは、1リクエストで複数のセッションが使われるようになっていたため、常にセッション数が多い状態になっていたことや、セッションの制御がうまく効かなかったせいか度々デッドロックやデータ不整合が発生していたことです。

これを去年の10月にチケットに起票し、先月ようやく完了することができました。
やり方次第ではもっと短期間でアップグレードできたかもしれませんが、他の開発への影響をなるべく少なくしつつ安全を重視して段階的に進めた結果、約1年がかりとなりました。

また、Pairsのバックエンドは元々かなり巨大なリポジトリだったため、変更量自体が多かったことも挙げられます。
関係性の低い変更もいくつか含まれていますが、概ね以下のようなボリュームです。

段階は大まかに分けると以下の4つです。

  1. ログの整備
  2. Contextの変更
  3. WAF(Web Application Framework)の移行
  4. xormのアップグレード

なぜこのような流れになったかというと、実はxormのアップグレードはここ数年1度も成功しておらず、挑戦すること自体が非常に困難なタスクとなっていました。
うまくいかなかったのは一部のAPIが異なる挙動になってしまうというものでしたが、根本原因と正確な影響は結局わからないままの状態でした。

そのため、まずはテストを整備してアップグレード前後で何が変わるのか確認できるようにしようと考えました。
もちろん今までもテストはありましたが、残念ながら問題を検出できるようにはなっていなかったため、net/http/httptestを使ったテストを拡充することにしました。
他のレイヤのテストを拡充することも考えましたが、単体テストでは効果が低そうだったのと、インタフェースが変わる際に修正が必要になってしまうため、HTTPリクエストとHTTPレスポンスだけを使うようなテストにすることにしました。

しかし、当時はWAFにginをベースとした独自フレームワークを使っており、net/http/httptestを使ったテストが書けないような作りになっていました。
さらに、この独自フレームワークが提供するContextには標準パッケージが提供しているcontext.Context以上の機能が内包されており、データベースのセッションを管理するためのパッケージとも密結合していました。
そしてこのパッケージはxormのv0でも特定のバージョンしかサポートしていなかったため、依存を断ち切りつつWAFを他のものに移行することにしました。

ところでWAFのContextをどのようにデータベースのセッション管理に使っていたかというと、内部でセッションを格納するマップのキーとして使っていました。
そのためリクエストの中ではContextを変更してはならず、もしcontext.WithCancelcontext.WithValueなどを使ってしまうとセッションが切り替わってしまうことになります。
代替機能はあったものの、標準パッケージとは異なる使い方をしなければならず、もし使い方を間違えても問題に気付きにくかったため、これを機に直してしまうことにしました。

ここまでがxormと直接関係のある変更ですが、もう1つやっておきたいことがありました。
それはログをより見やすくすることです。
これまではログを構造化して出力しているだけでログ同士の関連がわからなかったため、問題が起きた際にすぐ一連の処理が終えるようにしておいた方が良いと考えました。

各詳細は以下の通りで、これらの中でもさらに変更を分割しながら少しずつ改修を進めてきました。

1. ログの整備

ロギングにはCloud Loggingを利用していたため、ドキュメントに記載されているようにリクエストログとアクセスログをTRACE-IDでグルーピングして一連の処理を追えるようにしました。
具体的にはTRACE-IDを発行するためのHTTPミドルウェアを実装し、これまで使っていた独自のlogパッケージに新たな関数を追加し、既存の関数を一気に書き換えました。
goplsやIDEの機能などで書き換えられれば楽だったかもしれませんが、シグネチャが変わる都合上それができず、Vimの機能を応用して書き換えていきました。

2. Contextの変更

ログが整備できたので次はContextです。
困ったことに、Contextは引数で受け渡さずに構造体のフィールドになっていたため、HTTPハンドラより先のパッケージに存在するContextフィールドを全て引数に変えていく必要がありました。
これが膨大な数になるものの、効率的な方法が思い浮かばなかったのでかなり地道な書き換えをすることになりました。
途中に静的解析でなんとかできればと思ったんですが、引数が変更されたことでコンパイルが通らない状態になると処理が止まってしまうのでこの方法は断念しました。

まずは独自Contextを構造体のフィールドから引数へ変更し、標準Contextへの置き換えは行いませんでした。
というのも、置き換える前に独自Contextだけの機能は全て標準Contextの機能に置き換える必要があったからです。
例えば独自Contextにはインスタンスを作り直さずに値をセットする機能がありましたが、実装の関係で関数やメソッドの呼び出し先で値をセットすると呼び出し元でもその値を取り出せるようになっていました。
この特性を利用しているコードが多々あり、自信を持って直せるところは直したものの、大半は影響範囲が調べきれなかったので同等のことを標準Contextでもできるようにして独自Contextへの依存だけ断つことにしました。

またデータベースのセッション管理についてはこのタイミングでContextそのものではなく、ログのグルーピングに使用したTRACE-IDに変更しました。
これらの変更が全て終わった後にようやく独自Contextを標準Contextへと置き換えることができました。

個人的にはこの修正が最も大変で、ちょうど修正が終わったくらいのタイミングで Contexts and structs が公開され Storing context in structs leads to confusion を身をもって体感したなと思った記憶があります。

3. WAF(Web Application Framework)の移行

次に、WAFをchiに移行することにしました。 必要最低限の機能さえあれば何にするかは特にこだわりはありませんでしたが、Pairsエンゲージではchiが使われていたので技術スタックを揃えることにしました。

独自Contextという大物は片付いたものの、まだ独自フレームワーク特有の機能があったため、それらを改修していきました。
例えばルーティングをgo generateで生成していたのを直接Goのコードとして書くようにしたり、独自形式のHTTPミドルウェアをhttp.Handlerに対応させたりです。
他にも、なぜかHTTP関連の処理(リクエストからパラメータを取り出したり、レスポンスを返したり)をHTTPハンドラより先のレイヤで行うための機能があったので、そういったものも1つずつ修正していきました。

この頃からgoplsのCall Hierarchyを活用するようになり、改修がだいぶ楽になってきました。

4. xormのアップグレード

次はxormのアップグレードですが、その前にテストを拡充していきました。
カバレッジを確認しながら最低限のテストは用意できましたが、それでもまだ不安があったので発行されるSQLの変化を確認するためのテストを追加することにしました。
手法としては独自のドライバを実装し、クエリの記録と比較をするテストを静的解析ツールで生成するというものです。

これらのテストのおかげで、xormをアップグレードする際には以下に注意しなければいけないことがわかりました。

  • 構造体のxormタグにtypoがあるとエラーが返ってくるようになる
  • IN句に空のスライスを渡すと生成されるSQLがIN ()だったのがAND 0=1に変わる
  • LIMIT句に0を渡すとSQLに反映されなかったのがLIMIT 0として入る

そのため、まずはv1でもv0でも挙動が変わらないようにするための修正を行いました。
構造体のタグは直すだけですが、SQLが変わるところはGoのコードに分岐を入れて対応しました。
こちらは静的解析ツールを作成したことで、機械的に直せるだけでなく、CIに組み込んで問題が起きるコードが新たに追加されないようしました。

また、時間を自動的に型変換してくれなくなるので予めどちらのバージョンでも正しく変換できるように修正しておいたり、Contextに対応するとcontext.Canceledが発生するようになるので適切にエラーハンドリングできるようにしておきました。
context.Canceledのエラーハンドリングも同様に静的解析ツールを作成し、CIに組み込みました。

あとはセッションの管理です。
こちらは複数のデータベースを使っており、それぞれにRead用とWrite用の接続先があるので、その全てに個別のセッションを用意してクエリを発行するタイミングで適切なセッションを選択するというものでした。
それを1つのinterface化された構造体で行っていたため、interface自体そのままで内部実装だけ変更するのが理想です。

幸い接続先のホスト自体は全データベースで共通となっていたため、データベースはTableMapperでRead用とWrite用の2つに統合し、Read用とWrite用はEngineGroupを使ってまとめました。

TableMapperxormをアップグレードしなくても使えたのでここまではバージョンはそのままにしておき、EngineGroupに移行するタイミングでアップグレードを行いました。

以上のようにして、大きな問題を起こすこともなくアップグレードを終えることができました。
狙い通りデータベース起因のトラブルが減ってセッション数を大幅に減らしただけでなく、パッケージをアップグレードする際にエラーを起こすパッケージがなくなったのでgo get -u ./...が使えるようにもなりました。

また、各種制約を気にする必要がなくなり、テストも書きやすくなったので効率的に実装が進められるようになったと思います。
ただこれは感覚的な部分もあるのと、いざ問題にぶつかった時には強く印象に残りますが、何もなければ気にすることもないので意識しにくいところかもしれません。

それでも継続的にサービスを開発していくにあたり、躓きを減らすというのはとても大切です。
泥臭い対応はつい後回しにされがちではありますが、これからもバランス良く新規開発と改善を回していければと考えています。

最後に、一連のPull Requestのレビューをしてくださったチームメンバーの方は本当にありがとうございました。
当分はこんなに巨大なPull Requestを作ることはないはずです。

--

--