読者です 読者をやめる 読者になる 読者になる

LIVESENSE made*

リブセンスのエンジニアやデザイナーの活動や注目していることをまとめたブログです。

MENU

レガシーコードの最適化とPHPバージョンアップ

PHP 開発 運用

f:id:boscoworks:20161021132156p:plain

 少し前のことになりますが、正社員転職サービス「ジョブセンスリンク」を構成するPHPアプリケーション群のPHPバージョンアップ対応と、それに合わせてレガシーコードの大幅な整理を行いました。
 「PHPのバージョンあげて、リファクタリングしたんだ」と一言で言えば簡単ですが、日々のサービス改善を滞らせず、システムのリニューアルを同時に進めていくのは多大な労力を要しました。
 今回はその仕事を主に担当した、キャリア事業部技術基盤チーム*1の海野がお届けします。お手柔らかにどうぞ。

ミッション

 PHPのバージョン問題。レガシーコードの山積。システムが歳を重ねるにつれ、必ず直面する大きな問題です。
 システムは、初めてリリースされた数年前の数倍の規模になっているでしょう。
 売上を支えるシステムを維持し、事業を加速させる施策を阻むこと無く、システムのリニューアルを進める。これが今回のミッションとなりました。
 このミッションを進める上で解決したかったのは、以下の2点です。

  • PHPバージョンアップ
  • 全社共有ライブラリの最適化

 今回は、この課題に対する取り組みについてお話します。

古の巨人、「ジョブセンスリンク」

 弊社が運営する正社員就職サイト「ジョブセンスリンク」。アルバイト情報サイト「ジョブセンス」や企業の口コミサイト「転職会議」に並ぶ、弊社のHR系サービスのひとつです。
 弊社は2016年2月8日で設立10周年を迎えましたが、ジョブセンスリンクも歴史は長く、システムの根幹はリブセンス設立当初から連綿と続いています。
 ジョブセンスリンクのシステムを構成しているものは様々あります。最近では機械学習を用いた求人レコメンドシステムを Python を使って構築しました。
 屋台骨であるサービスのバックエンドは、いわゆるLAMP (Linux, Apache, MySQL, PHP) という、一時期ではWeb開発の王道と言われていたものです。使用しているフレームワークは Symfony、こちらもメジャーなフレームワークです。
 Symfony が導入された頃は、今ほど「マイクロサービス」思想が声高に叫ばれているわけでもなく、ジョブセンスリンクのシステムは1つのSymfonyフレームワークの上に複数のアプリケーション(「フロントエンド」「リブセンス用管理ツール」「企業用管理ツール」など)が存在するモノリシックなシステムです。
 また、「ジョブセンス」と「ジョブセンスリンク」、名前も似ていますがシステムもよく似ています。最初は同じものからスタートしていて、事業の成長に合わせて独自に進化をしていったので、古いプログラムほどよく似ています。
 フレームワークとサードパーティーモジュール (composerとPEAR) を除いた、純粋なプロダクトコード。その数6,054ファイル、929,977行。*2
 これに加え、PHP製の全社共有ライブラリも合わせて動いています。
 ジョブセンスリンクを構成するすべてのPHPプログラムを、これから相手にします。

なぜバージョンアップなのか

 PHPをバージョンアップするのは何故か、何度も説明を求められました。「今のままでも動いているんだから、膨大な時間を費やしてまで何故バージョンを上げるのか」と。
 私はこの問いかけに対してよく「10年前のWindows製品と今年のWindows製品、どちらが価値がありますか?」という問いを返しています。*3
 「今年の製品」と答えは返ってきます。そりゃそうですよね。10年前に比べたら、今のほうがずっといろんなことができるようになっているし、処理も高速です*4
 メリットをまとめると以下のような感じでしょうか。

  • 処理が高速になる
    • ウェブサイトが以前より高速に表示できるようになる→SEOに好影響する→売上が地味に上がるかも
  • エンジニアが開発を以前より高速にできるようになる(かも)
    • 最新のサードパーティーモジュールを導入した開発をしようと思ったら、最新のPHPに追随していくのは必要不可欠
  • セキュリティリスクを回避する
    • セキュリティの問題って起きないと実感わかないのですが、起きてからだと遅いですよね

PHPバージョンアップ

 バージョンアップを進めるにあたって、あらかじめ相当な時間がかかることを見込むと、新旧のバージョンのどちらでも稼働するようにプログラムを書き換えていくことは必要不可欠でした。
 万が一検証が不十分で新バージョンでうまく動かなくても、古いバージョンのサーバを動かせばサービスをすぐに復帰できるからです。

後方互換性のない変更点の確認

 例えばPHP 5.3からPHP 5.4にバージョンアップをしたいとき、PHPのどこに変更があったのか確認するには、PHPの公式ドキュメントを確認します。

 PHP 5.3からPHP 5.5であれば、上のページに加え、

も見ます。こんな感じで、マイグレーションに関する情報を集めます。
 PHPはマイナーバージョンアップでも、メジャーバージョンアップ並のダイナミックな更新があるときがあります。要注意です。
 後方互換性のない変更点を洗い出したら、そのメソッドをキーにgrepしたりしてソースコードのなかの修正箇所を調べます。

開発環境・CI環境の準備

 新旧のバージョンでそれぞれ最低1つずつは環境が必要になります。
 弊社ではopenstackを用いた開発環境の整備が進んでおり、私も普段はopenstack上の開発環境を利用していますが、vagrantを用いることで高速な環境構築をすることも可能です。
 ジョブセンスリンクではCIにJenkinsを使っており、developブランチが更新されると自動的にテストが回る仕組みになっています。
 新旧バージョンでそれぞれテストを同時に回してリリース作業を繰り返していました。
 幸運なことに、長い作業期間の中で、どちらかのバージョンだけテストが通らない事態は起きませんでした。

修正、修正、修正

 修正箇所が大きくなりすぎないように、修正の意図と範囲を明確にしつつ、ひたすら修正、テスト、プルリクエスト、リリース…。
 振り返ってみると20以上のプルリクエストを作成していました。

修正が大変だったものベスト3

第3位: preg_replace

 preg_replace() のe修飾子が E_DEPRECATED となりエラーになるようになりました。
 これを回避するには preg_replace_callback() を使えば良いわけですが、メソッドが違えば使い方も違うわけで、結局その部分については作り直しと同じ感覚でした。

$lowerName = preg_replace('/([A-Z])/e', "'_'.strtolower('$1')", $name);

こんな感じのコードを

$lowerName = preg_replace_callback('/([A-Z])/', function ($matcher) { return '_' . strtolower($matcher[0]); }, $name);

こんな感じに。やはり別物のプログラムに見えますね。しかも書き直した後の方が見にくいし長い…。

第2位: strict standards

 関数が static で宣言されてるのに $this->testMethod() みたいに呼んでしまったり、self::testMethod() みたいに呼んでるのに、呼ばれた先では static 宣言されてなかったり…。
 修正は簡単ですが数が多く大変でした。おまけにPEARライブラリの中にもあって困りました。
 PEAR自体のバージョンが古いと、PEAR::isError() すらエラーを吐く事態に。isError() って頻繁に使うと思うんですが…。
 運良く pear upgrade しても大丈夫だったので大変なことにはならずにすみました。パッチあてずに済んで本当によかった。
 PEARライブラリは最近ではあまり活発にメンテナンスされていないパッケージも多いので、使用する際には注意が必要です。
 composer パッケージで代替ができるのであればそちらを使いたいところです。

第1位: MDB2

 symfony に乗っていないプログラムの中に PEAR::MDB2 を使っている古いコードがあったのですが、MDB2内でエラーを吐いてしまい、まともに動かすこともできませんでした。
 きょうびMySQL操作にMDB2を使いつづける理由もありませんし、PDOに移すことを決意しました。

$works = $db->getAll("SELECT * FROM works WHERE company_id = ?", null, array($_GET['id']), array('integer'));

こんな感じのコードを

$id = (int)$_GET['id'];
$statement = $pdo->prepare('SELECT name FROM companies WHERE id = :id');
$statement->bindParam(':id', $id, PDO::PARAM_INT);
if ($statement->execute()) {
     $company = $statement->fetchAll(PDO::FETCH_ASSOC);
}

こんな感じに。行数はちょっと増えちゃいますが、それでもPDOなので見覚えのあるようなコードに仕上がりますね。

 厄介なのがMDB2のSQL自動生成機能です。機械的に変換するのではなく、しっかりSQLを考えてPDOに翻訳しないといけません。

$date = '2016-04-08';
$id = 12345;
$db->autoExecute('table_name', array('date' => $date), MDB2_AUTOQUERY_UPDATE, 'id  = '  . $id);

こんな感じのコードがあったとして、実行されるべきSQLは

UPDATE table_name SET date = '2016-04-08' WHERE id = 12345

のようなSQLになるはずです。これをPDOに書き直します。

$date = '2016-04-08';
$id = 12345;
$sql = 'UPDATE table_name SET date = :date WHERE id = :id';
$statement = $pdo->prepare($sql);
$statement->bindParam(':id', $id, PDO::PARAM_INT);
$statement->bindParam(':date', $date, PDO::PARAM_STR);
if ($statement->execute()) {
    echo "完了しました。";
}

全社共有ライブラリの改修と解体

 システムがよく似ている複数のサービス。それぞれがモノリシックなシステム。そういった背景から、全社共有のPHPライブラリが生まれることになりました。
 ところが、ライブラリが生まれて数年経った頃、エンジニアの中でいくつか問題がうまれます。

  • これ、誰が責任持ってメンテしてるの?
  • なんか全社横断チームしかマージ権限ないんだけど
  • デプロイもサービス担当で自由にできないの?
  • そのライブラリ、うちのサービスでどう考えても使わないよね?
  • なんかライブラリからエラー出てるけど、どうしたらいいかわからない! 従って放置!

 そうやって、いつしか共有ライブラリは、無意識のうちにエンジニアから敬遠されるようになり出来れば触りたくないプログラムに成り下がっていきました。
 その状態をまずは打開すべく、全社共有ライブラリという枠組み自体を廃止し、プログラムは各サービスで管理することになりました。

使用していないプログラムの選別

 数多くのプログラムのなかに重要なプログラムが混ざっていると、それがどれだけ重要なのかわかりません。まずは使われていないプログラムを探すところから始めます。
 Symfony のシステムの中で、ライブラリ内のプログラムが使われているかどうかを調べるには、いくつかの方法があります。

  1. Symfony の autoload.yml に定義はあるか
  2. PHPのオートローディング機能を使っているところはあるか
  3. include / include_once / require / require_once を使って読み込みしているか
  4. プログラム内でライブラリのクラス名が出現しないか
  5. クラス読み出しの際に、クラス名を動的生成していないか

これらをすべてのファイルに対して行います。

Symfony のautoload.yml に定義はあるか

 symfony のオートローディング機能にライブラリが含まれていないかを調べます。
 autoload.yml に記述があれば、Symfony 内のどこからでも呼び出し可能です。
 ジョブセンスリンクの autoload.yml を見ると、関係していそうなところが一箇所ありました。*5

autoload:
  common:
    name:           common
    path:           <?php echo sfConfig::get('app_common_lib_dir') ?>/Symfony
    recursive:      true

 上記からわかるように、sfConfig::get('app_common_lib_dir') が共有ライブラリのディレクトリを指していました。共有ライブラリ内のSymfony以下のプログラムは全てオートロードされています。

PHPのオートローディング機能を使っているところはあるか

 autoloadや spl_autoload を使っていないかを調べます。
 Symfonyのオートローディング機能を使わないイレギュラーなケースがあるかもしれません。

$ egrep -nR '__autoload|spl_autoload' ./ | egrep -v 'vendor' | wc -l
0

 ジョブセンスリンクでは独自のオートローディング機能は持っていなかったようです。

include / include_once / require / require_once を使って読み込みしているか

 PHPやSymfonyを使わず直接読み込みしているところがあるか調べます。
 PEARライブラリや別のプログラムを読み込んでいるケースがあるので、注意深く見分ける必要があります。

$ egrep -nR "require '|require\(|require \(|include '|include\(|include \(|require_once|include_once" ./ | egrep -v 'vendor|\/om\/|\/map\/' | grep php  | wc -l
695

 O/RマッパーとしてPropelを使っていますが、モデルクラス内にrequireを使っているので、あらかじめこれを除外するようにします。
 うんざりするような数が出てきますが、全て調べます。共有ライブラリのディレクトリ名に特徴的な名前が入っている場合は、grep の条件に付け加えると、調べる対象がぐっと少なくなります。

プログラム内でライブラリのクラス名が出現しないか

 ここからはイレギュラーの特定作業になります。泥臭い作業ですが根気よく進めます。
 共有ライブラリのクラス名をキーに全て調べます。

$ grep -R "class " ./ | egrep -v '\$|;|\*|\/\/' | sed -e 's/abstract//g' -e 's/final//g' | awk -F : '{print $2}' | awk -F "class " '{print $2}' | awk '{print $1}' | sort | uniq

 grep や awk を駆使してライブラリ内のクラス名を全て抽出します。grep 条件などはライブラリによってケースバイケースかと思いますので、適宜調整します。
 ここで抽出されたキーワードが、システムの中で使われていないか調べます。

クラス読み出しの際に、クラス名を動的生成していないか

 require や autoload の近辺で、クラス名を動的に生成していないかを調べます。
 require や autoload の検出に使った grep の結果で、変数を用いたクラス読み込みをしていたら、その変数の中身をくまなく調べます。
 ジョブセンスリンクではさすがにそんなトリッキーなクラス読み込みはしていませんでした。

グローバル汚染

 古いプログラムや、プロトタイプ開発のままサービスインしてしまったようなものには、グローバル変数の汚染が発生している場合があります。
 時間があるのであれば、グローバル汚染が発生しているコードのリプレイスまたは除去をしましょう。
 ただし、影響範囲が膨大で除去ができないのであれば、むやみに変更しないほうが安全です。

「共有」ではなく「プロダクト固有」なプログラムの移植

 用途が曖昧であるがゆえに、共有ライブラリであるにもかかわらず、「ジョブセンスリンク」でしか使っていないようなコードもあります。
 これは今後プロダクトで管理すべきなので、共有ライブラリから移植します。
 Symfony はプロジェクト以下のディレクトリであればどこにあってもオートローディング可能なので、今後の運用で違和感のないところにプログラムをコピーしていきます。 *6 *7

PHPバージョンアップ+ライブラリのメンテナンス。得られたものは?

 PHPのバージョンアップをしたところで、共有ライブラリをメンテナンスしたところで、インターネットの向こうにいるユーザには何もわかりません。
 ただこの作業を通して、得られたものがありました。

管理の手が行き届いたライブラリ

 良くわからないけどなぜか動いているライブラリより、ずっと安心して開発が行えます。変更前が 84 directories, 308 files だったのに比べ、改修後は 31 directories, 54 files と1/6まで縮小しました。
 また、全社共有からジョブセンスリンク専用にカスタマイズしたので、修正やデプロイも思いのままです。
 さらに、共通ライブラリからエラーが発生したとしても、ファイル数がぐっと減ったので、原因の特定が容易になりました。

新技術の導入

 PHPのバージョンがあがったことで、composerパッケージで導入できるものが格段に多くなりました。
 ちょうど別件でAWSと連携する案件を実装するところだったのですが、タイミングよくPHPのバージョンを上げることができたことで最新のSDKを導入することができました。
 ジョブセンスリンクはまだまだモノリシックで、独自実装の多いシステムです。これからどんどんコードを減らしたいし、開発効率も向上していきたいです。

処理速度の向上

 PHPのバージョンがあがったことでオペコードキャッシュの仕組みも変わり、厳密に比較は出来ないのですが、どれだけ早くなったのか? という調査を実施しました。

ab -n 1000 -c 10 <URL>
種別 旧バージョン(rps *8 ) 新バージョン(rps) 向上率
トップページ 66.90 71.65 107.10%
求人詳細ページ 37.51 58.09 154.86%
検索結果一覧ページ 18.99 21.67 114.11%

まとめ

 システムが古くなればなるほど、人の目が普段行き届かないようなところが技術的負債になっていきます。
 けれどそういった技術的負債のなかに、きちんと動作し、サービスに貢献しているプログラムもあるのです。
 古き良き遺産たちに敬意を表しつつ、たまには棚卸しをしてあげるのが、プロダクトに関わるエンジニアの責任ではないでしょうか。
 しかし、日々のサービス改善を続けながらレガシーコードを改善するには膨大な手間と時間がかかります。断続的ではありますが(途中でRailsアプリを作る案件に着手していたため、全くPHPに触れない時期も結構ありました)、やりきるまでに結局1年という月日が流れました。
 けれどこの作業を達成したことで、他のPHPエンジニアはきっと多くの恩恵を得たことでしょう。
 SEOにもわずかながら好影響していることでしょう。
 すごく遠回りをしながら、大きな貢献をしているのです。きっと。
 必要なものは決して多くはありません。「なぜメンテナンスが必要なのかを説明する力」と「古いコードを読み解き新しいコードに書き換える力」と「最後までやりきる力」です。

*1:ジョブセンスリンクのインフラ管理やシステム開発を主に担当しています

*2:バージョンアップ対応当初、2016年3月時点。当然時事刻々と変わりますが規模感はだいたい変わらずこんな感じです

*3:とある方は「今のままでも動いてる、というのは正確ではなくて、今も動くよう努力している」のだと訴えたとか

*4:エンジニアのひとにとってはツッコミどころがあるように見えますが、技術職ではないひとには割と納得感が得られているようです。もっといい説明の仕方あればコッソリ教えてください。

*5:実際はハイコンテキストな書き方をしているので、わかりやすく一部書き換えてあります

*6:Git の履歴が欲しい方は頑張って歴史を複製してもいいと思います

*7:今回移植の対象にしたプログラムは、ほぼ全てが最初のコミット以降まともに更新されていなかったので、私は原始的な方法でファイルをコピーしてリポジトリ管理に加えています

*8:※ Requests per sec: 1秒あたり何リクエスト処理できるか。数が多いほどページの表示が高速