序
“ソフトウェアは放っておくと腐敗するし、拡張しているといずれ崩壊する”
これは、私が前職で社長と1on1しているときに話したことだ。
新規サービスの立ち上げ期で、5人しかいない開発部で新規サービスにリソースを回すにあたり、既存サービスはどうするのか? という話の中で私が言ったことである。 そして、私がこれを言ったのは、
- 初期設計に時間とコストを割き、慎重に行う必要性
- 安易に流行りや手早さに飛びつくことのリスク
- 将来的なプロダクトビジョン、先見性の必要性
といったことを説くためである。
これは基本的にあらゆるソフトウェアで発生し、特にSaaSを展開するならば必ず意識しなければならないことだ。 本稿ではこれを解説していこう。
ソフトウェアの腐敗
必然的に発生するものである
ここでいうソフトウェアの腐敗は、ソフトウェアに変更を加えなくてもソフトウェアが動かなくなる、あるいは問題がある状態となることを意味している。
発生原因はかなり様々あるが
- 処理系のバージョンアップによる非互換
- ライブラリのバージョンアップによる非互換
- 連携している外部サービスの仕様の変更
- インフラ環境の仕様の変更
などが主な要因ではある。
これを防ぐために、現代では実行環境を固定する術が豊富に用意されている。 最も広く使われるのはDockerであるし、それである程度動かなくならないように抑制することはできる。
だが、それは完全ではないし、いわば「実は腐敗しているけど香辛料で誤魔化している」ような状態である。 そもそも古いイメージを使い続けること自体が良くはないし、日々負債を積み重ねているのと変わりない。 特にSaaSのようなものでは日々周辺状況に合わせてメンテナンスし更新していくことが不可欠なのである。
この問題はSaaSなどでは完全になくすことはできない。 が、ある程度軽減することはできる。
特に大きいのは
- 変化に強い環境で構築する
- 依存関係を減らす
- 変化に強いコードを書く
依存関係の低減
だけれども、最も簡単でやるべきことは「依存関係を減らす」だ。
近年は多くの環境で巨大なフレームワークを使い、ブラックボックス化されたメカニズムの中で開発している。 というよりももはや、「何のフレームワークを選ぶか」などという言い方になってしまい、そのトレードオフについては考えることすらしないということがほとんどだ。
だが、よく考えるべきだ。 巨大なフレームワークを使って書くということは、そのプロダクトはずっとそのフレームワークの意向に振り回されることを意味するし、大量の依存ライブラリの事情にも気を配り、丁寧に維持していく必要がある。 その維持・保守にかかる工数のことは考えているのだろうか? あるいは、それにより開発が意図しないタイミングで停滞するリスクは考えているのだろうか?
特にコンパクトに書かれたプロダクトではそのトレードオフが大きく赤字に傾く可能性は大いにある。 最初の導入にかかる時間であったり、新人が学ぶ手間がいくらか増えたとしても、メンテナンスフリーに近づき、ソフトウェアの腐敗を抑えられるならそのほうがいいディールである可能性は全く低くないのだ。
ソフトウェア腐敗の最大の原因は依存しているライブラリである。 依存しているライブラリが増えれば増えるほど腐敗速度は上がる。 その代償がどのようなものであるかということを無視するならば、依存関係を減らすことは常に善だ。
また、フレームワークを使うことはプロダクトの舵取りを奪いとる荒波を呼び込むことでもある。 思うがままの航路をとりたいのであれば、フレームワークは可能な限り避けたほうが良いし、採用するにしても影響力の低いものにしたほうが良い。
そして、これはほとんど最初ですべてが決まる。 採用するフレームワークの影響を廃止するには作り直すしかない、などというのはよくあることだ。
さらに、その後も安易なライブラリ追加を抑制するということも欠かせない。 だいたい、チーム開発などというものは気づけば依存ライブラリが増えてぶくぶくと膨れ上がるものなのだ。
依存ライブラリは存在が見えにくいので、かなりちゃんと意識しづつけることが必要になる。 そして、依存ライブラリの非互換変更であったり脆弱性の発覚であったりといったことは大抵の場合予期せぬタイミングで降ってくるので、コストをかけるタイミング、機能開発が停滞するタイミングがコントロールできない形で訪れるというリスクもある。
依存関係の複雑さの影響は腐敗だけでなく崩壊にまで及び、いずれにせよプロダクトの寿命を縮める主因となりうるものである。 基本的にはライブラリの更新停止や脆弱性発覚、仕様変更など「その瞬間にコストを投入すれば乗り越えられる」ものであることが多いが、逆に言えばそのコストを投入できなければプロダクトの終焉を招き入れることにもなる。 膨れ上がるコストはいずれ支えられなくなる。
そもそも変化への耐性を意識する
環境としての変化への耐性というのは、主に2種類がある
- 最新環境にすると既存コードが動かなくなる
- 処理系や標準ライブラリ側から推奨される書き方が変わったりdeprecatedになる
問題としては同じようなものなのだが、結果はかなり違う。 前者は動かなくなるので非常に分かりやすいし、ロールバックや緊急対応を強いられる。 後者は動かなくなるわけではないので問題はないといえばないのだが、コードに対して何も変更を加えていないのに勝手に技術的負債が増えていく(そしていつか動かなくなるかもしれない)。
そして、ここに対してどう考えるべきかは、まず変化に弱い環境を挙げれば分かりやすい。
例えばJavaScriptやTypeScriptは大変よく仕様が変わる言語である。 「動かなくなる」もある程度あるが多くはない。が、仕様自体は大きく変わり、推奨の方法も変わるので、基本的には「最新の情報を置い続け、最新のコードにアップデートし続ける」を求められる。
私が愛するRubyも相当きつい部類だ。
一番は、Rubyのバージョンアップを同一環境内で行う場合に発生する問題で、RubyのGemはシステムであろうとBundlerであろうとライブラリのパスにRuby自体のバージョンを含むため、Rubyをバージョンアップするとライブラリの再導入をしないと動かなくなる。 さらにRubyがバージョンアップすると標準ライブラリからライブラリが消えたりするので、今までと同じGemをインストールしても動かないなんてことも普通にある。
組み込みライブラリで非互換を発生させることは比較的少ないが、標準ライブラリのAPIが変わるとかいうことは普通にあり、結構よく動かなくなる。
さらには割とコアなものではないかと思うようなライブラリがいつの間にか開発停滞して動かなくなるなんてことが非常によくあり、ライブラリに依存するリスクが他のメジャーな言語と比べても高め。もちろん、その問題がRuby自体のバージョンアップによって発火することも多い。
変化に強い環境というのは、
- コード自体は変更せず
- 処理系やライブラリを継続的にバージョンアップして
- 憂いなく動き続ける
という性質を持っている言語やエコシステムを指している。
個人的に安定している環境で思い浮かぶのはPerlだ。 かなり長年に渡って、「追加こそあれど破壊的変更はない」が続いてあり、CPANライブラリも「定番として使えるものは結構限られている」ことが逆に安定に繋がっている。 さらに、「どのバージョンの振る舞いをするか」を指定するフラグまであり、「いつまでも動き続けられるレガシー」を築き上げていたりする。
Javaも強度はかなり高い。 IcedTeaとかJavaアプレットとか終わってしまったものはあったりはするけれども、サーバー側で言えばサーバーコードを変える必要性が生じることはかなり少ない。
Pythonも結構強め。 Python2からPython3は大騒動だったけれど、「Python2を互換環境として使い続ける」で結構延命されたし、Python3のほうは書いたきり放置されているようなコードが長きに渡って動いていたりする。
ただ、この環境への強度に基づいた選択は、依存ライブラリを減らすのと比べてトレードオフ要素が大きい。
Perlは現代において使うと結構なwrite only languageで、書くのはいいけど読むのは辛い。 特にリファレンスとオブジェクト関連はカオスで、非常にきつい。 相当がんばっても保守性はあまり高くない。Perlコードの保守には高度なPerlセンスが求められる。そして、それは大抵の場合無駄な労力を割いている。
もっと「変わらないという堅牢さ」に振ったような言語もあるけど、そういうところでマイナーな言語、あるいは書きづらい言語を選択すると後で苦しむことになる。とてもおすすめできない。
一方、JavaScriptやRubyの書き心地は非常に良く、高い生産性と良好な保守性をもたらすことができる。 最新に更新し続けることが前提とみなせるのであれば、環境変化に対する弱さなど気にならないだろう。
だから、私が言っているのは「変化に強い選択をしろ」ということではないのだ。 「環境の変化への耐性というものを意識しろ」という話だ。
もし「ある程度成長し、完成したらその後はあまり手間をかけず最小限の保守コストで運営しつづけたい」とか、 「あくまで機能開発に集中して保守にコストを割きたくない」と考えているなら安定している環境を選択したほうが良いし、「ずっと磨き続けて更新していくんだ」という意思があるなら安定性よりも生産性を優先したほうがいい。 「保守しづらいコード」もそれは「コードを理解し変更するのが辛い」ということなので、全くコードに変更を加えないなら書きっぱなしでも問題なかったりする。そして、変化に強い環境で書かれたコードは大抵の場合保守性も生産性もあまり高くない。Pythonはその意味では優秀だが、それでいえば変化への強さに関しては本当に堅牢な言語ほどではない。
結局、どれだけ維持の手間をかけられるかなのだ。 最新を追い続けることを求められる言語を使うなら、コードへの理解度が高い状態を維持し、最新情報の調査と更新のための工数を支払い、プロダクトのスケールに応じてその量が増加していくというコストを受け入れるならば、生産性の高さは新機能を追加する場合でも、保守・更新する場合でもコストが安く済む。 だが、そのコストを支払うことを渋れば腐敗速度は速い。
ここには技術的判断よりも、経営判断が求められる部分でもあるのだ。
変化に強いコード
変化に強いコード、というのは割とテクニックレベルの話である。 そして、チーム開発で徹底するのは難しい。
基本はこうだ
- 流行りに乗らず、枯れた仕様で書く
- 依存ライブラリを減らす
- 使用するライブラリを「変更されたり廃止されると社会的にすごい影響が出る」ものにする
と、言語やライブラリの仕様変更などどこ吹く風、「うちは関係ないね」で済ませられる頻度が上がり、結果的に保守コストが減る。
のだが、これは言語とそのエコシステムに対する理解度が求められたり、さらには「どれだけ技術に対して鼻が利くか」が求められたりするので結構個人のセンスに依存する。 私は本当に長年変わりなく動作し続けるコードを結構書いたりしているが、じゃあそのコツを伝授できるかというと大変難しい。クライアントサイドJavaScriptのような特殊な限られた環境なら「JavaScript 1.5に準じて書く」とかいう明確な方法があっりたするのだが、そういう分かりやすい回答を用意できるのは異例中の異例である。
だからこの部分は、「堅実なコードを書くように意識していると振り回される保守が減るかもしれない」くらいにしておこう。
ソフトウェアの崩壊
当たり前の話
例えば、1行のコードがあるとする。
print ARGF.read.gsub("foo", "bar")ストリームを読んでfooをbarにするだけ。
とても単純なコードだ。
ここで、barではなくbarbarにする必要ができたとする。
すると、こうなる。
print ARGF.read.gsub("foo", "barbar")とても簡単な改修だ。 そして、このコードには改修の入り込む余地が非常に少ない。 本質的にやってることは「読んで、何かを変更し、変更した結果を出力する」だ。 改修するとしたら、変更する内容を変更するくらいしかない。
では、ここでもう1工程増やしたとする。
print ARGF.read.gsub("bar", "baz").gsub("foo", "bar")barをbazに置き換え、fooをbarに置き換える。
置き換えが2回になった。
そして、これは1回のときにはなかった問題を抱え込んだ。 次のコードを見てほしい。
print ARGF.read.gsub("foo", "bar").gsub("bar", "baz")fooをbarに置き換え、barをbazに置き換える。
つまり、2回の置き換えの順序が変わったのだが、この結果、fooからbarに置き換えたものもbarからbazに置き換えられるため、fooもbarもbazに置き換えられるようになった。
これはおそらく――――バグの誕生である。
さらに、ここにさっきの更新が組み合わせられると
print ARGF.read.gsub("foo", "barbar").gsub("bar", "baz")fooはbarbarになるけれどbarはbazに置き換えられるため、結果としてはfooはbazbazになり、barはbazになる。ああ悲しきかな――バグだ。
これは一体何が起きたのか。単純な話だ。 複雑度が増した。
複雑度は潜在的に問題が起こる可能性とそのパターンを増加させる。 複雑度が増えるとバグの発生可能性は増加し、コードを理解するコストは高くなり、保守のコストも高くなる。 しかも、複雑度の増加は増えたコードの分足されるのではない。掛けられるのだ。
崩壊は避けられない
SaaSはだいたい機能は追加され、コードも追加される。 減ったりはしないものだ。
コードが追加されるということは複雑度は増す。 つまり、保守するにしても、さらに機能を追加するにしても、必要なコストは増える。 ゲームが好きな人なら馴染みがあるだろう。「レベルアップに必要なコストがレベルを増加させるごとに別の項目も含めて全体で増える」という、アレだ。
そして、いつか機能追加にとんでもないコストが必要になり、保守は人員追加しても間に合わない、という時がやってくる。
これが崩壊――――プロダクトの終焉である。
もし終焉を迎えることがあったら、早めにきれいに終わらせてあげたほうがいい。 執着は惨劇の始まりだ。
取捨選択と複雑度の低減
「機能を追加する」ということは複雑度を増加させ、プロダクトを終わりに近づける、ということは揺るがない。 だからこそ、ちゃんと重要な機能を追加していくべきだ。 どうでもいいような機能をいくつも追加したとして、結局そいつらも複雑度は増加させているから「どうでもいいようなことのためにプロダクトの寿命を縮める」ことにつながる。
また、複雑度の増加はどんな機能でも一定というわけではない。 もちろん、複雑な機能ほどコードとしても複雑度が増加する傾向はあるが、それ以上に「もともとのソフトウェアとしての思想や設計とのマッチ度」のほうが重要である。 つまり、設計的に歪な機能の追加は複雑度を著しく増加させ、プロダクトの寿命を縮める。
「プロダクトとして必要な機能であり、かつプロダクトの思想や設計に沿った一貫性のある拡張をしていく」ことが、プロダクトの寿命を伸ばすことにつながる。
そして、プロダクトの寿命を伸ばすために「複雑度を低減するための改修」というのも存在する。 その典型がリファクタリングである。
リファクタリングは多くの場合、表面的な利益をもたらさない。 新しい機能が提供されるわけではないし、金銭的コストを減らすことにつながることも多くはない。
だが、保守コストは減るし、プロダクトの寿命は伸びる。 上手にリファクタリングすれば、新しい機能を受け入れたときの複雑度の増加も低減できる。
が、ここでも重要なのは「設計に沿わない歪な機能がないこと」だ。 そういう機能があるとリファクタリングが制限され、小手先でしか改善できないという状態になったりする。
リファクタリングは小さな改善であり、急速にプロダクトが崩壊することを抑制できるが、崩壊をゆっくりにしているだけで崩壊を遠ざけているわけではない、というような働きになる。 プロダクト全体での複雑度は必ずしもコードの複雑度のかけ合わせというわけではなく、後になるほど係数がかかったようにより複雑化する傾向がある。
これは何が起きているのかというと、リリースされる前、作り始めたときの設計というのはまだ実際に使われていないものを作るものであり、リリースしたあと実際の反応を見ながら成長していく段階で正しく成長させていても設計というのはズレていくものなのである。 この場合、最初の設計に無理に合わせるよりは現実に沿って設計を変えていくほうが正しくはある。正しくはあるのだが、それとは別にコードには軋みが生まれる。そして、無駄な互換レイヤーを生産したり、機能の使われ方がもともとの想定と違うものになったり、同じような機能が呼ばれ方の違いで複数生まれたり……とにかく、そういう「無理やりに適合させる」「魔改造して希望に沿うようにする」みたいなのが生まれて複雑度が急激に増加していく。
そこで必要になるのは再設計。現状に合わせてコードのあり方から作り直すのだ。 どのようなモジュールに分け、どのような機能をもたせ、どのような拡張を想定するのか、といったことを現状を踏まえた上でイチから引き直すことでプロダクト全体の設計適合性を高めることができる。 私はこれをコードリノベーションと呼んでいる。
また、リファクタリングと並行して技術的負債の返済も忘れてはいけない。 経営状況や株主への説得力のために急いで機能が欲しい、みたいなことはよくあることだし、「常に十分な時間とコストを割けるわけではない」というのは現実だろう。 そこに技術的負債が誕生する。 これは機能としての必然性以上に複雑性を増加させているようなものなので、放っておくとプロダクトを早々に崩壊させる。
ちなみに、最近もてはやされているスクラム開発だが、あれは基本的に大概的な進展アピールのために変わったという事実が欲しいという前提のもの、もっと悪いケースでいえばKPIを満たすために開発が進捗したという証跡を出すためのものとかいうものなので、性質的に極めて技術的負債を生みやすく、プロダクト寿命を縮めやすい。 長期的視野をもった動きはそもそもしづらいやり方なので、抜本的な改善も難しい。そのトレードオフはちゃんと認識した上で選択したほうがいいだろう。
作り直して次世代へ
実はプロダクトデザインとしての設計だけでなく、プロダクトの展開としての最適というものも段階によって変わってくる。 ユーザーが10人なのと1000万人なのでは「最適な設計」は全く異なるものになるのだ。
こういうものは、リファクタリングやコードリノベーションを駆使してももはや追いつかないということはよくある。 だが、基本的にこれは喜ぶべきものだ。当初の想定をはるかに越える成功を収めたとも言えるし、いわば赤子が成長して成人になるようなものだとも言える。 ――――が、それはそれとして、プロダクトの状態としては非常に辛い。そういう成長を簡単に支えられるほどソフトウェアというものは柔軟にできていないのだ。
そういうプロダクトではいずれ「作り直す」という決断が必要となる。
この「作り直す」はそのプロダクトを破棄するということではなく、そのプロダクトを維持するために中身を全く別のものにするということだ。 既存コードを使わず、成長したプロダクトに相応しい設計・技術選定をイチからやり直し、そのプロダクトの新しい時代を迎える。
使用するソフトウェア、連携するサービス、さらには言語まで見直すことができるので、プロダクト開発が持っている性質を大きく変化させることができる。 そして、この設計や技術選定を上手くやることができれば、プロダクトの寿命を大きく伸ばすことができる。
結局成長によって連続的に崩壊へ向かっていくということには変わりないが、崩壊間近のプロダクトをまだまだ成長できるプロダクトに生まれ変わらせることができる可能性もある。 そんな手を尽くしても崩壊してしまったプロダクトも無数に存在するが、崩壊しないように細心の注意を払い、作り直して成長を続けたプロダクトにとっての「いかなる手を尽くしても崩壊は免れない段階」がどこに存在するのかは、未だ知れぬ次元のものだ。
プロダクト完成の日
100巻続く漫画の結末が分からないように、あるいはソシャゲのストーリーは完結することなくサービス終了を迎えるように、だいたいSaaSというものは「完成」という概念を持たぬままいつか終焉を迎えるものだ。 そもそもクラウドサービスというのが進化し続け完成しないものだと言われることすらある。
が、別にそれはそうでなければならないわけではない。SaaSの完成の日があっても良いのだ。
必要とされる機能がすべて揃っているならば、それ以上の機能追加は蛇足というものだ。 一貫した素晴らしい操作体系を持っているのならば、新たなる操作の導入は秩序を乱すだけだ。
「完璧」というものはだいたいにしてほど遠く、たどり着けない道のりではある。 が、永遠に続くと思われた連載が最終話を迎えるように、終わりを迎えることは決めて良いものなのだ。 そして、その終わりはサービス終了でなければいけないわけでもない。プロダクトの完成でも良い。
先の章で述べたように、ソフトウェアは勝手に腐敗するので、進化を止めたところで保守は必要だ。 何もしなくてよくなるわけではない。 だが、無限に膨れ上がることは止めることができる。
そこに多くのユーザーがいるのであれば、ユーザーは現状でもう満足しているかもしれない。 SaaSにおいては「停滞は死を意味する」という強迫観念を持っている人は多いが、別にそんなこともない。ずっと変わらないが、存在していることで助かっているというサービスだってある。
「これ以上の機能追加はしない。かといって終了に向かっているわけでもない。これで完成とし、今後は保守しながらこのままでありつづける」、これもひとつの選択だ。
もしユーザーの増加がソフトウェアに大きな改修を迫るのなら、「完売しました」としたっていい。
とても難しく勇気が必要となることではあるが、「完成」という言葉を使う日がくるのも、ひとつの美しい結末だ。
長く続く良いプロダクトのために
“ソフトウェアは放っておくと腐敗するし、拡張しているといずれ崩壊する”
それは必然であり、避けられないことだ。 だが、別にそれが悪いという話ではない。それを理解した上で、長く続く良いプロダクトであるためには払うべき注意、考えるべきことがあるというだけのことである。
本稿の内容は、技術者向け3割、経営者向け7割くらいの内容となった。 まあ、もともとが私が社長に対して話した話なのだから、そうなるのは当然ではある。
が、あなたがエンジニアならば、このことを覚えておくと、良いプロダクトを始めるために有意義な提案ができるかもしれない。 あるいは、それを説明するための資料としてこの記事を使ってもよいだろう。
あなたが経営者ならば、あなたの思い描く世界を実現するプロダクトであるために、目先のことにとらわれずこうして多くのことを考えることで良い未来を迎えることができるだろう。 もしかしたら、エンジニアの習慣的思考から引き剥がすためにこの記事を使う日がくるかもしれない。
私は、プロダクトは「世で言われているもので、世で言われているように、世にあるものを作り、そして捨てる」というようなものではなく、良い社会の実現のために息長くあるものであってほしいと思っている。
そのための一助になれば幸いだ。