「書き直したい」 をグッと抑えて小さな改善を積み重ねよう
nobuoka
この記事はRECRUIT MARKETING PARTNERS Advent Calendar 2017の投稿記事です。
こんにちは、2016 年中途入社の nobuoka です。 ソフトウェアエンジニアとしてキッズリーの開発に携わっています。
先日読んだ 『「書き直した方が早い」 は 9 割のケースで間違いだった』 というブログ記事に触発され、最近考えていた 「小さな改善を繰り返すこと」 について書き記します。 意識の話に寄っていて技術的な話ではないですが、是非読んでみてご意見をお寄せいただければと思います。 後半では、実際の製品開発での取り組みも紹介しています。
伝えたいことは一つだけ。 タイトルにある通り 「どうにも手が負えないから新たに書き直したい」 という気持ちに対して、一度それをグッと抑えて小さな改善を積み重ねよう ということです。
背景 : 新しく書き直すか、小さな改善を繰り返すか
難解なコード、複雑に絡み合ったクラス群、扱いづらい DB 設計 ―― そのようなものが目の前に現れることはよくあることです。 短期間でプロトタイプとして作られたものがそのまま正式な製品として継続開発される場合や、長期間開発を続けているうちに徐々に設計の一貫性が崩れていった場合、納品された製品の品質が想定よりも低かった場合など、様々な理由で目の前に現れてきます。
ひどいコードやひどい設計のソフトウェアに手を入れていかなければならないとき、開発者が取る道は大きく分けて 2 つあるでしょう。 一つは、既存の部分にはできるだけ手を入れずに継ぎはぎで動くものを作っていく道。 もう一つは、既存の仕組みを扱いやすく変更したうえで手を入れていく道です。
さらに、既存の仕組みを変更する方法も 「新しく (大きく) 書き直す」 か 「小さな改善を繰り返していく」 の 2 通りに分けて考えられることが多いように思います。 前者は、扱う対象全体 (アプリケーション全体が対象であればアプリケーション全体だし、メソッドが対象ならメソッド全体) を丸ごと作り直したり、大きな粒度で書きかえる (アプリケーションの一部を別のアプリケーションに乗せ換える、など)、というような大きな粒度の変更です。 最近の事例としては、チャットワークの Scala 移行などがあります。 後者は、もう少し小さな変更を繰り返していくものを想像してください。
いくつか取り得る道があるわけですが、我々は往々にして 「新しく書き直す」 という選択をしたがらないでしょうか。 最終的には現実的かどうかで判断し、その選択肢を除外することも多いと思いますが、気持ちとしては 「新しく書き直したい」 という気持ちを持つことが多いように感じます。
職業プログラミングに従事していると一度は「これ書き直した方が早いっす」とか言ったことある気がする。自分の場合、多くは歴史のあるレガシーコードを読んだときだ。
私が携わっているキッズリーも初期の開発を外注していて、残念なことに社内で開発を引き継いだ時点で品質は低いものでした。 それに対して 「このコードには手を入れたくない」 「新しく作り直したい」 という声があり、実際に一部の機能を新しく作り直したりしています。
小さな改善を積み重ねよう
ここまで、「新しく書き直す」 のと 「小さな改善を繰り返す」 のを対比して語ってきましたが、私としてはこれら 2 つは二項対立の概念ではないと思っています。 どちらも 「書き換え」 であり、粒度が大きいか小さいかの違いです。 そして、書き換えをどの粒度で行うかは、理想的には、何を解決するかや、コスト、開発のやりやすさといった判断軸を用いて決定されるでしょう。 そのようにして決定された 「新しく作り直す」 ことについては、何も問題ないと思います。
一方で、書き換えの粒度を考慮せず、ただ純粋に 「新しく書き直したい」 と思うこともあるはずです。 それはどのようなときでしょうか。 おそらく下記のような理由ではないかと推測します。
- 問題箇所が多すぎて、何から手を付けていけばいいのかわからない。 あるいは、問題が多すぎてすべてを解決できそうにないと感じる。
- コードを読んでも仕様や設計を読み取ることが困難で、変更しようにもどこに手を付ければいいかわからない。
- クラス間の依存関係が複雑で、コードの変更がどこに影響するのか追いかけられない。
- などなど、とにかくどうしたらいいのかわからない。
一言で言ってしまえば、解決すべき課題が明らかになっていない、ということです。 そんな状態では、出発点からして不明瞭なわけなので詳細な議論ができません。 「手に負えない」 「つらい」 という気持ちが前面に出てきて、結果、「新しく書き直したい」 という思いに駆られるのではないでしょうか。
既存のソフトウェアとの互換性を無視し、完全に新たに実装することができるならば、既存の問題を無視して新しく書き直すという選択肢は考慮に値するものでしょう。 既存のソフトウェアが全く使い物にならず、ユーザーに何の価値も提供していない状態であればサービスを止めることも可能です。 しかし、現実にはそのソフトウェアは現時点で動作しており、何かしらの価値をユーザーに提供しています。 そういう状況ではほとんどの場合、既存の振る舞いを維持し、データを引き継ぐ必要があります。 そのために、仕様がわからないのであれば明らかにする必要があるし、データ構造が今後の仕様変更に耐えられないものであれば改善していく必要があります。 新しく作り直すというのはそういった課題を一挙に引き受けることであり、想像以上に費用がかかるでしょう。
「手を入れるのが大変だから全部書き換える」 のではなく、まずやるべきことは解決すべき課題を明確にすることです。 理想的には、開発を進めていくうえでの一番のボトルネックになっている課題を見出したい。 実際のところは、最も解決すべき課題を見出すことをいきなり実現することは難しいので、開発を進めながら、解決すべき課題に対して 「小さな改善を積み重ねる」 ということをしていくと良いでしょう。 そうすることで徐々に見通しが良くなり、アプリケーションに対する理解も深まり、真に解決すべき課題が見えてくるはずです。 もしくは、いくつか課題を解決した時点で 「なんともならないと思ったけど、やってみると普通に改善していける」 ということに気付くかもしれません。
課題を明確にしてそれを解決する、ということを繰り返す
「小さな改善を積み重ねる」 と言いましたが、むやみやたらに改善すれば良いというものではありません。 例えば 「テストが全く書かれていない」 という点に問題意識を感じたとして、むやみに全てのコードに対してテストを書いていくと状況が良くなるかというと、必ずしもそうではないでしょう。 また、設計がイケてないと感じるコードを目にした時に、すぐにそのコードをリファクタリングするというのも、必ずしも適切とは限りません。
大量にある課題の中で、プロジェクト内の一番のボトルネックではなくとも開発をしていくうえで障害となっているもの ―― それを解決することで開発速度が向上したり、品質が向上したりするようなものを見出すこと。 そのような課題を解決していくべきです。
さらに、課題に対しては仮説を立て、検証し、実際に解決していくという態度が大事です。 「推測するな、計測せよ」 という言葉を実践するにあたっても、何を計測するかを決めるために仮説を立てた方がやりやすいでしょう。
このような話は 『イシューからはじめよ』 で語られています。 この本は、知的生産における課題解決のプロセスの質を 「課題の質」 と 「解の質」 の 2 軸で考えたときに、まず質の高い課題を選択したうえで、解の質を高めるべきであると述べています。 詳細は私が書いた感想などを参照してください。
余談ですが、リクルート用語として有名な 「圧倒的当事者意識」 と並び、リクルートで重要視されているスキル・スタンスを表す言葉の中に 「見立てる」 「仕立てる」 「動かす」 というものがあります。 私は入社当初これらの言葉が何を意味しているのか全く理解できなかったのですが、「見立てる」 というのは解決すべき課題を見出すことで、「仕立てる」 というのは筋の良い仮説を立て、それを検証するための方法を確立することである、ということがわかってきました。 細かな方法は職種によって異なりますが、課題を見出し、仮説検証を行う、というのは、多くの仕事に共通する大事な態度なんですね。
意識の話はこれぐらいにして、コードの改善に役立つ書籍も紹介しておきます。
1 冊目は 『レガシーコード改善ガイド』。 本書は 「レガシーコードとは、単にテストのないコードです」 と述べており、何故テストを書く必要があるのかや、テストを書いてリファクタリングすることの意義、いかにしてテストを書きやすい設計にするか、といった内容を説明します。 リファクタリングとその他の変更 (機能の追加・変更やバグ修正、最適化) を明確に区別することも主張していました。 「既存のコードの改善が難しい」 「どう改善していけばいいかわからない」 と感じている人におすすめです。
2 冊目は 『CODE COMPLETE 第 2 版 下 — 完全なプログラミングを目指して』。 こちらはコード改善だけでなくコンストラクション全般についての書籍で、リファクタリングなどのコードの改良についても書かれています。 チームメンバーとの協働でコードを読み解くといったコラボレーティブコンストラクションについても書かれており、『レガシーコード改善ガイド』 とはまた違った知見を得られるでしょう。
事例 : キッズリーでの事例紹介
以降は、実際に私がキッズリーのサーバーサイド (Dropwizard を使用した Java アプリケーション) で取り組んできた課題をいくつか紹介していきます。
テスト時間の短縮やパフォーマンス改善 (「推測するな、計測せよ」)
入社して一番初めに取り組んだ改善がコードテスト時間の短縮でした。
CircleCI 上でコードテストの実行を行っているのですが、実行にかかる時間が 40 分を超えるようになっていました。 これにより、ちょっとした変更でも pull request をマージするまでの待ち時間が長くなってしまったり、CircleCI 上でのテスト実行が同時に何個も走ってジョブがキューに溜まってしまうというような状況が発生してしまったりして、開発効率が落ちてしまうという状況が発生していたのです。
テストケース数に対してテスト時間が長すぎたため、まずはテスト時間を短くすることを課題として捉えました。 この課題に対して、テスト実行のログやローカルホスト上での実行時の挙動から、「テスト実行のための DB の初期化 (ユニットテストでも MySQL に接続している) に時間がかかっており、その時間を短縮できればコードテスト実行の時間を短縮できるのではないか」 という仮説を立てました。 この課題の対応を考えていたときに、実際に Qiita:Team に書いた文章が下記のものです。
- ほかに時間がかかってるのは、アプリケーションの起動とかテストごとに DB の初期化を行っている箇所な気がしてる。
- アプリケーションの起動はもうしょうがないとして、DB の初期化はなんとかなるのでは?
- テスト時に使用するコネクションで、テストメソッドの開始前にトランザクションを開始して、終了後にトランザクションのロールバックを行うようにすれば毎回初期化しなくてよいのでは!?
- と思ったけど Dropwizard では難しそうな気がする。
- Spring Framework だと、フレームワーク側がそういう仕組みを提供してくれてる! すごい!! : http://docs.spring.io/spring/docs/current/spring-framework-reference/html/integration-testing.html#testing-tx
そして、その仮説を検証するためにテストコードの中の DB 初期化処理の時間を計測することを行いました。 下記が、実際に測定した結果です。
CircleCI で Liquibase を使ったテーブルの準備にかかる時間を計測しました。
結果は以下のような感じです。
130 ← テーブル準備を行った回数 1321258 ← 全テーブル準備にかかった時間 [ms]1321 s なので、大体 20 分と少しかかっていました。 Circle CI 上のビルド全体では 42 分なので、半分はここでかかっているようです。
この検証により仮説が正しいことを確認したので、さらに 「Liquibase によるデータマイグレーションに時間がかかっているので、その部分を mysql コマンドと mysqldump コマンドを使って置き換えれば時間が短縮されるのではないか」 という仮説を立て、実際に置き換えるということを行っていきました。 結果としては CircleCI 上で 40 分程度かかっていたビルドが 20 分程度にまで短縮され、ビルドの待ち時間の短縮や CircleCI のジョブキューにジョブがたまる問題が緩和されました。
レスポンスタイムのパフォーマンスも似たような手法で改善していくことができます。 あるとき、レスポンスタイムが数秒から十数秒かかっているようなエンドポイントがあり、ユーザー体験が非常に悪くなっていることが分かりました。 この問題に対して、私は 「エンドポイントがわかっていて、そのエンドポイントから呼ばれるメソッドを見ると N + 1 問題がいくつかあるので全部書き直せばよいだろう」 という気持ちだったのですが、チームメンバーから 「単にログ出力するだけでいいからどのメソッドで一番時間がかかっているのか計測した方が良い」 という助言をもらい、計測してから改善するということを行いました。 計測することでボトルネックになっているメソッドを特定することができたため、最終的にエンドポイントの中の全ての N + 1 問題を解決するよりも安いコストで目標としていた水準に達することができました。
この時、私は 「計測のための基盤が整っていないので、安いコストで計測することは難しい」 と思っていたのですが、実際にはログ出力からいくつかサンプルとして取り出すことで、ボトルネックになっているメソッドを見つけることができました。 仕組みが整っていなくとも、まずは既存の仕組みで欲しいデータが取れないか検討するというのも重要ということも重要ということですね。
簡単な例ですが、仮説を立てて検証していく事例を 2 つ紹介しました。 改善に際して常に計測する必要があるわけではありませんが、行った変更がどのような効果を発揮するのかを明らかにしておく必要はあるでしょう。 そういう意味で、パフォーマンスチューニングにおいては計測が重要です。
これらの事例では、Qiita:Team で文書化したことやチームメンバーと議論したことも紹介しました。 一人で仮説を立てたり検討したりするよりも、他者の視点を取り入れた方がより良い仮説作りや検討ができる場合が多いので、チームメンバーへの共有や議論というのも大事にしていきましょう。
認証周りのクラス設計の見直し (複雑な実装をドキュメント化しよう)
クラス同士の依存関係が複雑に絡み合っている状況では、現状がどうなっているのか理解できず、変更しようにもどこに手を入れればいいかわからない、という問題に直面しやすい。 キッズリーでは、ユーザー認証周りの処理がまさにこの状況になっていました。
セッションは Cookie により管理されており、セッションの有効期限は最後にリクエストされた時刻から指定時間で切れる、という仕様です。 これを実現するために、リクエストごとに Set-Cookie
レスポンスヘッダフィールドでセッションクッキーを更新する仕組みになっていました。
仕様的には単純なはずですが、実装は複雑怪奇でした。 リクエストの処理にはとあるライブラリの Servlet フィルタを継承した独自の Servlet フィルタが使われており、レスポンスの処理にはそれとは別に定義された JAX-RS のレスポンスフィルタが使われていて、リクエストとレスポンスでフィルターの実装が大きく違っているし、認証処理には Jersey の内部用のクラスを継承したクラスが使われていたりと、依存関係が複雑でした。 セッション Cookie の暗号化処理の知識は複数箇所に分散しており、セッション情報を扱う知識も複数個所に分散していました。 一度丁寧にコードを追ってみるも 1 回目だけでは完全には理解できず、2 回目のコード探索によって何とか全体の設計がどうなっているかを理解できたぐらいです。
このような場合は、まず 「現状がどうなっているのか (すぐには) 理解できない」 という点を解決するために、調査を行った結果をドキュメント化しておくべきでしょう。 将来の自分のために、そしてチームメンバーのために、ソースコード中にドキュメントコメントとして残したり、わかりやすく図にして共有するとわかりやすい。 今回の例では、ドキュメントコメントの追加や下の図 (左) の共有などを行いました。
実際にコードを追うだけでは理解が難しくとも、上のようなちょっとした図があるだけでも理解しやすくなります。 また、「どのクラスがどういう知識を持っているのか」 というのも記述しておくと、複数のクラスが同様の知識を持っていること (すなわち、設計が良くないこと) を把握しやすくなります。
さらに、可能であればどのような設計に変更すべきかも合わせてドキュメント化しておくと良いでしょう。 今回の例では、上の図 (右) を書いてチームに共有しました。 現状の調査と同時に理想の設計もドキュメント化しておくと良い理由は、下記の 2 点があります。
- 現状調査をした直後の方が既存の仕組みに対する理解も深いため、理想的にどう変更すべきかも考えやすい。
- 早い段階で理想像を描けていると、その後に変更を加える際に理想に近づける方向に変更しやすい。 (理想像が描けていないと、どう変更するのが理想に近づく変更なのかがわからない。)
最近も、アーキテクチャを変更していくにあたって、以前のチームメンバーが残してくれた理想像のドキュメントが役に立っています。
アプリケーションのレイヤ化 (一度の変更箇所を小さくする)
アプリケーション全体の設計の改善にも取り組んできました。
キッズリーの Java サーバーサイドでは、JAX-RS で web 層を扱い、jDBI で DB アクセスを行っています。 もともとの設計では、JAX-RS のリソースクラスから直接 jDBI の SQL オブジェクトを触る構成になっており、非常にテストしづらく、共通化もしづらくなっていました。 ひいては、変更に対して強い設計になっておらず、変更を行うのにコストがかかってしまうという問題がありました。 この問題を解決するために、アプリケーション層を導入し、Web 層、アプリケーション層、DB アクセス層の 3 層構造にする方針を立て、変更を進めています。
アプリケーション全体に関わる変更ですが、最初に方針を明確にして、必要な仕組みを作るというという部分をしさえすれば、後は少しずつ変更を加えていくことができます。 具体的には、機能の変更を加えることになったエンドポイントに対してまずはテストを書き (テストによる保護)、そしてレイヤ化し (リファクタリング)、その後に機能を変更する、という流れです。 この例においては、レイヤ化はほぼコピー & ペーストで済むので、コストは高くありません。
一気に全体を変更するコストは大きく、その間に他の変更を行おうとすると変更が衝突する可能性も高い。 広範囲に対する変更についても、小さい範囲で区切って少しずつ進めていくという手法を検討すると良いでしょう。 ここではアプリケーション内部という比較的やりやすい変更について述べたが、別アプリケーションに乗せ換えるような難しい変更についても、小さい範囲で少しずつ進められないか検討する価値はあると思います。
終わりに
本記事では、「小さな改善を積み重ねる」 ことについて語ってきました。 上でも述べた通り、「新たに書き直す」 ことを否定しているわけではなく、それが適切な判断のもと下された決定であれば問題ないという立場です。 しかし、「どうにも手が負えないから新たに書き直したい」 という気持ちに対しては、一度それをグッと抑えて、「小さな改善を積み重ねる」 「解決すべき課題を見出す」 という道を探っていくことが重要であると考えています。
細かな実践の話としては、いくつか事例を紹介しながら下記の内容を説明しました。
- 仮説を立てて検証する。
- 推測せずに計測する。
- チームでの共有・議論が有効である。
- 複雑な部分をドキュメント化して共有する。
- 対象が広範囲にわたる変更も、分割して少しずつ進めていく。
最初からより良く設計されていれば、このような課題解決の苦労をする必要はなかったはずですが、現実に問題は目の前に存在していて、開発を続けていくために避けては通れません。 「最初からより良い設計だったなら」 と思って新しく書き直したくなったとしても、本当の意味で一から作り直すことは不可能なのです。 なぜなら、そこにあるコードは既に動いていて、何かしらの価値をユーザーに提供しているのですから。
最後に、最近読んだ書籍 『人を伸ばす力 ― 内発と自律のすすめ』 の中の、印象に残った一節を紹介して終わりとします。
真の自由とは、環境をかえることに前向きであることと、環境に敬意を表することのあいだにバランスを見いだすことである。
この文自体はソフトウェア開発とは全く関係のない文脈のものですが、ソフトウェア開発においても 「既存のコードに対して敬意を表することと、それをかえていくことに前向きであることの間にバランスを見出す」 という態度が重要ではないでしょうか。