継続的Webサービス改善ガイド

第2章開発環境の改善~技術的負債の返済と、レガシーコードの仕様化テスト

技術的負債と開発環境の改善

本章では、サービスの成長とともに大きくなる「技術的負債」に着目し、筆者が勤務するpaperboy&co.(以下、ペパボ)で取り組んでいる開発環境の技術的負債を返済していく具体的な方法について紹介します。

技術的負債とは

技術的負債は、英語でTechnical Deptと呼ばれます。技術的負債の「概念」が最初に登場したのはWikiの開発者として知られるWard Cunninghamが1992年に発表したThe WyCash Portfolio Management Systemという報文の中です。そこから年を経ること17年後の2009年に、アジャイルソフトウェア開発宣言などで知られるMartin Fowlerによって技術的負債という名前が付けられました。

Webサービス開発での技術的負債の例

技術的負債は、サービスを構成するソースコードそのものであるアプリケーション層と、それらのアプリケーションを動かしているハードウェア層それぞれに存在します。本章では前者のアプリケーション層の技術的負債を対象とします。ハードウェア層の技術的負債の例や改善方法については、第4章(2/20公開)を参照してください。

さて、アプリケーション層における技術的負債にはどのようなものがあるでしょうか。筆者が思い浮かべるものを以下に列挙します。

  • 障害対応のためにテストを書かずにその場しのぎで入れたコード
  • 「TODO: あとで直す」というコメント付きのコード
  • とりあえず動けばいいと言われたので設計などを推敲せずに書いたコード
  • 検索して出てきたコード断片をコピー&ペーストしたコード
  • 脆弱性が公開されているバージョンのライブラリを使用しているコード

上記以外にも、エンジニアとして日々コードを書いたり読んだりしていると、⁠このコードはなんとなく良くないような気がする……」という直感に遭遇することがあるはずです。まさにそのときこそが技術的負債を生んでいる瞬間なのです。

技術的負債はサービスの負債

筆者はこの「技術的負債」という表現が実に絶妙なものであると考えています。たとえば障害が発生した瞬間では「きれいだけど作るのに時間がかかるコード」よりも「汚いけどすぐ動くコード」のほうが機会損失の度合いが低いと言えます。しかし、不具合改修後の3年、5年というスパンで考えた場合、後者のコードは「汚い」という可読性の負債だけではなく、次のような利子を積み上げてきます。

  • どう見ても必要ない処理のように見えるが、消してよいのかわからない
  • どう見ても非効率なやり方をしているが、直してよいのかわからない
  • 担当者が変わったときにその都度、何のために必要だったのか聞かなければわからない

しかし、負債のすべてが悪ではないことも理解する必要があります。たとえば障害発生時にはちゃんと網羅的なテストを書いてからリリースすることよりも、障害の収束を優先することが多いはずです。これは今現在において、未来への負債を生じさせることよりも障害による機会損失のほうが大きいという判断が働くためです。

さて、このように技術的負債の利子がどんどんたまり続けると、どのようなことが起きるのでしょうか? 技術的負債は生み出された瞬間はプログラマのレベルの問題であることが多いですが、利子がたまり続けることでサービスの負債となります。

技術的負債が爆発するとき

技術的負債は動いているコードの裏側にひっそりとたまり続けるものです。この動いているコードというのが問題を見つけにくくしていると筆者は考えます。動かないコードであれば動かすためのモチベーションは自然と沸いてきますが、動いているコードについてはなかなか手を付けようというモチベーションは沸きません。

そのような状況で技術的負債がたまり続けるとどのような状況を引き起こしてしまうのでしょうか。筆者が経験したものをいくつか紹介します。

リリースサイクルの長期化

技術的負債が蓄積すると、システムの複雑性が増します。システムの複雑性が増すと、機能を追加しようと思ってもコードのどこを変更してよいかわかりにくくなり、コードを変更した際に自分が意図しなかった振る舞いまでをも変更させてしまう、つまり不具合を生み出してしまうということがしばしば発生します。

これは結果として、リリースまでにかかる時間を増やしてしまうことを意味します。さらに不具合を迅速に修正するために推敲されていない設計を採用してしまいがちになり、さらなる技術的負債を増やしてしまうという、悪循環に陥ります。

システム障害の発生や長期化

リリースサイクルが長期化しても、担当者を増やしたり、リリース期間を延長することで何とか毎日を過ごすことは可能です。しかし技術的負債がたまり続けると、前述した不具合を、開発時点ではなくユーザに対してリリースしたあとに発生させてしまったり、セキュリティ上の致命的な欠陥などを引き起こしてしまうことがあります。

技術的負債は障害発生時にも影響を及ぼします。リリースサイクルの長期化と同じ理由により、障害の根本的な原因を見つけようにもシステムが複雑化してしまっているために時間がかかってしまいます。障害発生中はサービスから得られる利益も停止してしまうことを考えると、障害復旧の時間が増えてしまうことは望ましい状況ではありません。

継続的な現場改善

技術的負債がたまり続けることで、サービスにとって良くない影響を及ぼすことがわかりました。では、ビジネス要求を解決し、社会状況の変化に対応しながら技術的負債を減らしていくために、私たちは何ができるのでしょうか?

技術的負債返済の習慣化としくみ作り

技術的負債を返すために最も手っ取り早い手段は技術的負債の返済を開発プロセス、すなわち毎日の作業に組み入れてしまうことです。たとえば毎朝30分や、毎週金曜の午後は技術的負債の返済を行う時間として確保し、テストがないコードにテストを書いたり、テストがあるコードをリファクタリングするのがよいでしょう。

改善活動を毎日の作業に組み入れると同時に、技術的負債を生み出したり、技術的負債が残ることをできるだけ見逃さないしくみ(プロセス)を作ることも必要です。明日からでも始めることができ、すぐに効果を出すことができる筆者お勧めのしくみを2つ紹介します。

CIの導入

負債を放置しないしくみとして最も簡単に導入できるのがCIContinuous Integration継続的インテグレーション)です。CIは継続的に何らかの処理を実行するプロセスのことを指します。CIで実行する代表的なものとして、テストの実行と、その失敗の検知があります。自分たちでサーバを準備できるのであればJenkinsを用いてCI環境を構築したり、外部のサービスを利用可能な場合はTravis CIを用いることで簡単にCI環境を作ることができます。

近年ではテストの実行だけではなく、テストのカバレッジを継続的に記録するCoverallsや、使用しているライブラリの脆弱性を検知するGemnasiumなどの外部サービスも登場しているので、積極的に導入するとよいでしょう。これらのツールやサービスを活用することで、開発メンバーが意識しなくても負債がたまった瞬間を検知できるようになります。

コードレビュー

CIの導入はツールを活用することによって継続的な改善を支援するしくみでした。それに対してコードレビューは、チーム内のコミュニーケーションを促し、結果としてコードの品質向上や不具合の検出が期待できるしくみです。筆者はすべてのコードを書いた本人以外がコードレビューすべきと考えています。ペパボではすべての新しいコードをGitHubまたはGitHub Enterpriseのpull requestを用いたレビューを行うことで、本人には気づかない設計判断や不具合に気がつけるしくみを作っています。

開発環境の改善

技術的負債を返済しようにも手を出しにくいときがあります、このようなときは多くの場合、開発環境に負債が蓄積しています。たとえば次のような場合、技術的負債の返済は困難な状況にあると考えてよいでしょう。

  • 本番環境で動いているコードの中に、バージョン管理されていないものがある
  • テスト環境、ステージング環境が存在しない
  • 開発を行う環境が物理サーバ、仮想サーバ上にしか存在せず、開発者個人のマシンで確認できない

上記のような場合、技術的負債を返すために必要なコードの変更が極めて行いにくい状況にあります。その結果、技術的負債がたまり続け、さらにコードの変更がしにくくなるという悪循環を生み出します。

このような状況はどこかの時点で返済しようと思っても、いっぺんに返すことは極めて困難です。筆者の経験上、上記の状況を改善するためにはエンジニア数人で作業して数ヵ月から半年かかります。

バージョン管理

開発環境を語るうえでなくてはならないのがバージョン管理です。サービスを運用していると、サーバ上でコードや設定ファイルを直接編集して対応したり、メンテナンススクリプトをサーバ上で作成して実行したりするようなことが発生しがちです。そのようなスクリプトもすべてバージョン管理システムに保存することで、なぜこのような変更を加えたのか、将来再利用するときに加えるべき変更点を議論することが可能になります。

Railsのようにデータベースのマイグレーションスクリプトがフレームワークに内包されている場合は、意識せずに上記のバージョン管理が行われている場合が多いです。そうではないフレームワークを用いている場合は、データベースに対して発行するSQLはすべてバージョン管理の対象とすべきでしょう。

開発環境の仮想化

近年では、開発環境の仮想化がちょっとしたブームになっています。たとえばPuppetやChefを用いてサーバの設定作業(プロビジョニング)を自動化し、VagrantのようなVMVirtual Machineを簡単に作成するツールなどを組み合わせて使うことで、アプリケーションやサービスを開発するうえで必要とされるサーバ群を気軽かつ高速に構築できます[1]⁠。ペパボではテクニカルマネージャの宮下剛輔が開発したMaglicaを用いて誰でも仮想サーバを構築することができるため、本番環境と同等の環境をすぐに構築しテストすることが可能となっています。

技術的負債を返済するうえで、この本番環境を気軽かつ高速に開発環境として再現できることが重要であると筆者は考えています。本番環境の変更は気軽に行えるものではありません。そのため、開発者が何かしら実験的なコードを入れたりすることで内部を良くしようと思っても、後回しになりがちです。このときに開発環境を気軽に用意できれば、改善活動をやりやすくなります。ペパボでは改善活動の一歩目として、本番環境や物理サーバでしか動かないようなサービスを積極的に仮想化しています。

レガシーコードの継続的な改善

アプリケーション層の技術的負債の改善活動のしくみ作りに続いて、アプリケーションのコードそのものの改善のために、次の2つの問題に着目します。

  • テストを用意しないまま書いたその場しのぎコード
  • 言語仕様やセキュリティ面で良くないコード

コードにはほかにも改善すべき個所や項目はありますが、本特集では上記に絞って具体的な解決方法を紹介します。

レガシーコードと仕様化テスト

レガシーコードという言葉があります。⁠レガシーコード改善ガイド』注2によれば、⁠テストがないコードはレガシーコード」という定義がなされています。すなわち、10年前に書かれたコードも1時間前に書いたコードも、テストがないコードはすべてレガシーコードと言っています。若干、奇妙な印象を受けますが、テストがないコードはリファクタリングを始めとするコードの改善が行えないため、遅かれ早かれレガシーコードになってしまうということを言っているのだと筆者は理解しています。

仕様化テストとは

レガシーコードとはテストのないコードだということはわかりました。それでは、目の前にあるレガシーコードをどのように改善していけばよいのでしょうか。筆者が最もお勧めする方法は「仕様化テスト」と呼ばれるテストを書く活動です。

通常テストコードはこれから作ろうとしているもの、つまり未来に向けた仕様をテストとして表現したあと、実際に動くコードを書くというのが近年の主要なプラクティスとなっています。仕様化テストとは、逆にすでにあるコードの挙動こそが仕様であるとして書くテストコードです。

なぜ仕様化テストが必要なのか

では、なぜ仕様化テストが必要なのでしょうか。

これから新しく作るサービス、まだ世に出ていないコードであれば、コードの改善だけでなく、改善の結果から生まれる仕様自体の改善も検討の余地があります。しかし、すでにリリース済みのサービス、すでに世に出ているコードの場合、仕様の変更は慎重に行う必要があります。

たとえ開発チームが良くない仕様であると判断した場合であっても、それがユーザにとっては使いやすい仕様であることは多々あります。そのような仕様変更が、開発チームの意図しないところでコードの変更を原因として発生させてしまっては、コードの改善の意味がなくなってしまいます。コードの継続的改善はあくまでもビジネス目的の達成のために行うのであって、開発者の自己満足のためだけに行うものではないことを意識しなくてはなりません。

そのような状況下で今ある動きをまず固定化し、テストコードとして外部から見た振る舞いを表現する方法が仕様化テストです。

テストライブラリCapybaraと仕様化テスト

それでは仕様化テストはどのように作成すればよいのでしょうか。

仕様化テストを記述するのに便利なライブラリとしてCapybaraがあります。CapybaraはRubyのテスティングフレームワークであるtest-unitやRSpecと用いることで、ブラウザから見たままの振る舞いをテストコードとして記述できます。

Capybaraには仮想的なブラウザ操作を行うために、次のようなライブラリが用意されています。

  • Rack
  • PhantomJS
  • Selenium WebDriver
  • Mechanize

開発しているアプリケーションがRailsの場合Rackでテストを行い、JavaScriptの動きも確認する必要があるときはPhantomJSを用いることが最近は多いようです。Selenium WebDriver、Mechanizeは速度がRackやPhantomJSより劣りますが、実際にブラウザを起動したりと、より本番環境に近い状態でテストを実行できます。Selenium WebDriver、Mechanizeはさらに、RubyやRailsに限らずPerlやPHPのWebアプリケーションのテストも書くことができます。

ペパボでは、新しいサービスはRailsで作っていますが、PHPで書かれているレガシーコードが大量に存在していました。以降ではPHPアプリケーションに対して仕様化テストを書く方法を紹介します。

PHPアプリケーションの仕様化テスト

仕様化テストの前提条件

ペパボではテストコードが存在しないPHPアプリケーションが多数存在していました。しかし現在は、RSpecとCapyparaを用いることでPHPアプリケーションであっても仕様化テストという形で自動テストを作成し、コードの変更による予期せぬ不具合を事前に検出するしくみを構築しています。Capybaraを用いてPHPのアプリケーションをテストするには、次の2つを満たす必要があります。

  • 手元にあるPHPコードがローカルマシン内またはサーバで動作する環境がある
  • 上記の環境へローカルマシンからアクセスできる

RailsアプリケーションをRSpecとCapybaraを用いてテストする場合は、テストプログラム自体がアプリケーションサーバをローカルマシンに起動することでテストを実行しています。しかし、PHPアプリケーションの場合はアプリケーションサーバをテストプログラムで起動できません。そのため、テストプログラムとは独立したPHPが動作するサーバやローカルマシンを用意する必要があります。

BundlerとRSpecの準備

上述の環境を用意したあとに、PHPアプリケーションのトップディレクトリに次の内容のGemfileを作成します。

source 'https://rubygems.org'

gem 'rspec'
gem 'capybara'
gem 'capybara-mechanize'
gem 'launchy'

作成後にgemコマンドを利用してBundlerをインストールし、Gemfileに記載されているライブラリのインストールを実行します。

$ gem install bundler
$ bundle install

インストールが完了したらspecという名前のディレクトリを作成し、その中にspec_helper.rbというファイルを作成します。

require 'capybara/rspec'
require 'capybara-mechanize'

Capybara.default_driver = :mechanize
# PHP アプリケーションが動作するサイトのURL
Capybara.app_host = 'http://test.host'

RSpec.configure do |c|
  c.include Capybara::DSL
end

このファイルは主にテストの設定内容を記述するファイルです。

仕様化テストを書く

続いて、実際にテストを実行するテストコードとしてvisit_top_page_spec.rbという名前のファイルをspecディレクトリの中に作成します。

# -*- coding: utf-8 -*-
require "spec_helper"

describe "TOP ページ" do
  before do
    visit '/'
  end

  it " ナビゲーションメニューが表示されていること" do
    page.should have_content ' メニュー'
  end
end

作成後にPHPアプリケーションのトップディレクトリでbundle exec rspec specというコマンドを実行します。このテストでは単純にトップページにアクセスしてメニューが表示されていることを確認しています。このように稼働中のアプリケーションが持っているURLすべてに対してアクセスし、ページ内で表示されているべき文字列を検証することで、そのURLが動作しているかどうかの確認を自動で行うことが可能となります。

今回の例はごく単純なものですが、Capybaraの強力なDSLDomain Specific Languageドメイン特化言語)を用いることで、PHPアプリケーションであっても仕様化テストを記述できますので、テストコードの第一歩目としてチャレンジしてみることをお勧めします。実際に仕様化テストを書く際に注意すべきポイントを以下に述べます。

  • 本体のプログラムの良くない個所を見つけても修正せずに、現在の挙動に忠実に仕様化テストを書く
  • 最初からすべての挙動をテストにしようとせずに、表示文言のチェックから書く
  • 書いたテストを毎日実行する環境を整える

仕様化テストの用意は新しい機能をユーザに提供するものではないので、なかなかモチベーションが上がらない作業です。ですが、将来にわたって機能を追加したり、言語やフレームワークのバージョンアップを行う場合に必ず役に立つので、根気よく続けていきましょう。

言語やフレームワーク.のバージョンアップ

バージョンアップの必要性

なぜ言語やフレームワークをバージョンアップする必要があるのでしょうか? ユーザにとって何も変化がないのに言語やフレームワークをバージョンアップするのは無駄だという意見もあります。しかし、5年、10年という期間で考えたときに、リリース当初の状態のままとすることでユーザに影響する事象を3つ紹介します。

セキュリティの問題

1つ目は、セキュリティサポートの終了です。1章で述べたように、2013年6月にはRuby 1.8がセキュリティリリースを終了します。PHPも次期バージョンPHP 5.5リリースの1年後に、PHP 5.4のセキュリティサポートは終了予定です。言語だけでなくフレームワークにもセキュリティサポートの終了はあります。たとえばRailsのセキュリティリリースは、最新の3.2と1つ前のマイナーバージョン 3.1、そして利用者が多い2.3が例外的に対象です。

セキュリティリリースが行われないバージョンを使い続けることは、まさに技術的負債の典型です。セキュリティの問題については、言語やフレームワークだけでなくOSのセキュリティサポートにも気を配りつつ、いつセキュリティサポートが切れるのか、いつからアップデート作業にとりかかる必要があるのかなどを考える必要があります。

パフォーマンス、安定性の問題

2つ目は、言語本体の改善によりパフォーマンスや安定性が向上することです。たとえばRuby 1.9.2→1.9.3→2.0へのバージョンアップでは、ライブラリのロード時間の短縮やRails使用時の安定性向上などの改善が図られているため、容易にアップデート可能であればアップデートするにこしたことはありません。

新機能による生産性向上

3つ目は、言語の進化により新しい機能が使えるようになることです。たとえばPHP 5.3.2以降であれば、Composerと呼ばれるライブラリを管理するしくみが使えます。Composerを使うことで、これまではサーバにすべてインストールしていた外部ライブラリをアプリケーション層で管理できるようになり、より疎結合なしくみを作ることが可能になります。

言語やフレームワークごとのバージョンアップ戦略

それでは仕様化テストがすでに用意されているという前提で、Rubyを例に旧バージョンで動いているサービスをアップデートしていく方法を紹介します。

Rubyのバージョンアップを行う場合、大きく分類すると1.8系から1.9系へのバージョンアップと、1.9系から2.0系へのバージョンアップの2つがあります。前者はセキュリティサポートが切れることによるバージョンアップなのに対し、後者は主にパフォーマンス、新機能の利用を目的としたバージョンアップです。

Ruby.1.8から1.9へのバージョンアップ戦略

もしみなさんが使用しているRubyのバージョンが、1.8系かつOSが提供していない独自にビルドしたものであるならば、バージョンアップを行う必要があります。逆に現在使用しているRubyがOSに付属しているものであり、機能として満足しているのであれば、無理にバージョンアップを行う必要はありません。

Ruby 1.8と1.9の最大の違いは、文字であるStringクラス自身がエンコーディング情報を持ったことです。1.8では文字列はバイナリデータでしたが、1.9では文字データとして扱われます。

この違いにより注意しなければならないのは、Ruby1.8系のアプリケーションでデータベースやRubyのPStoreデータにStringクラスが保存されている場合に、それらのデータをRuby 1.9以降で扱おうとすると、Stringクラスのインスタンスであっても文字エンコーディング情報が存在しないためにEncoding::CompatibilityErrorを発生させてしまうことです。そのような場合は、force_encodingを用いることで強制的にエンコーディング情報を付加できます。

筆者がOSSとして開発しているtDiaryでは、Ruby 1.8で作成されたデータを1.9で扱ったときにEncoding::CompatibilityErrorが発生したら、次のような処理を行って自動で文字データにエンコーディング情報を付加しています。

if obj.respond_to?('force_encoding') &&
    obj.encoding == Encoding::ASCII_8BIT
  obj.force_encoding('UTF-8')
end

変換後にobjを改めて保存することで、Ruby 1.9で利用可能なエンコーディング情報を保持したデータとなります。

ほかの非互換については、クックパッドの村田賢太氏が大江戸Ruby 会議03で発表した資料What a hard work to make the recipe sharing ser vice available on Ruby 1.9.3!に細かくまとめられていますので参照してください。

なお、Rubyの後方互換性の維持はバージョンによりポリシーが異なります。Rubyの次期リリース候補版がリリースされた際は、ぜひ手元で動かして、もし日常的に使用しているライブラリが動かなくなっている個所を見つけたらRuby Issue Tracking Systemまでレポートしてもらえると、将来の技術的負債を抑えることができます。

Ruby.1.9から2.0へのバージョンアップ戦略

Ruby 1.9から2.0へのバージョンアップは、1.8から1.9へのバージョンアップの際に発生したような大きな非互換はなくなり、主にパフォーマンスの改善や新機能の追加が中心となっています。Ruby 2.0へ移行することによってrequire実行時の高速化などが期待できますが、現在1.9系で安定して稼働しているサービスの2.0へのアップデートはまだ実験的な段階と言えるので、動作検証を着実に行ったあとに行うべきでしょう。

本章のまとめ

本章では「技術的負債」というキーワードを中心に据え、アプリケーションレイヤの技術的負債の返済方法の具体例を紹介しました。技術的負債は日々の忙しさの中で少しずつたまっていき、じわじわと開発速度の低下や障害を引き起こす原因となります。サービスを成長させ持続させるためには、技術的負債を継続的に返済し続けることが重要です。

おすすめ記事

記事・ニュース一覧