PHPプログラミング診断室

第5回永遠のPHP 4(診断編)

めっきり寒い毎日ですが、皆さんいかがお過ごしでしょうか。1月はあっという間に終わってしまい、2月に突入しました。2月は逃げる、3月は去ると言いますが、時間が過ぎる速度が年々増しているような気がする今日このごろです。春は早く来てほしいのですが、時間が過ぎ去ってしまうのは寂しいものです。

きみ、それPHP 4やで

すっかり感傷にふけったところで、診断のお時間です。今回のコードは2004年に書かれたものです。2004年といえばPHP 4真っ盛りの時代ですね。年代もののコードを扱うことが多い本連載ですが、このコードは今も現役で(PHP 4環境で!)稼働しているというのがこれまでと違う点です。えー、わかります、言いたいことはわかります。PHP 4は、もはやメンテナンスが行われていないので、もしセキュリティホールがあったとしても、どうしようもありません。攻撃され放題です。早急にPHP 5へのアップグレードを行っていただきたいところです。

今回は、PHP 5にアップグレードするにあたって、このコードをどう変えていくのがよいか、という視点で診断を行ってみます。

診断するのはログイン処理を行うlib/Login.phpcontroller/LoginController.phpの2ファイルです。では、PHP 4ながら現役バリバリのコードを見てみましょう。さあ、お入りください。

controller/LoginController.php
<?php
require_once('lib/BasicController.php');
require_once('lib/CheckExecuter.php');
require_once('lib/Login.php');
require_once('model/UserModel.php');

define_once('LOGIN_ID',  'login_id');
define_once('PASSWORD',     'password');

/**
 * <b>LoginController</b>
 * ログインコントローラ
 *
 * @author hoge
 * @version $Id: LoginController.php,v 1.1.1.1 2004/10/01 08:46:04 hoge Exp $
 */
class LoginController extends BasicController {
  var $NEXT_PAGE = 'top.php';

  /**
   * コンストラクタ
   *
   * @param $values 値連想配列(フォームからの入力の際は、$_POST)
   */
  function LoginController() {
    $this->init();

    // セッションクリア
    unset($_SESSION[SESSION_USER]);

    if (Http::isPost()) {
      $this->values = $_POST;
    } else {
      $this->values = null;
    }
  }

  /**
   * メイン処理
   */
  function execute() {
    if (Http::isPost()) {
      // 値チェック
      $checker = new CheckExecuter($this);
      $checker->check();
      $this->errorMessages = $checker->getErrorMessages();

      // 正常なら処理を行う
      if (!$this->isError()) {
        // セッションクリア
        $_SESSION = array();

        // ログイン処理
        $userModel = new UserModel();
        $userModel->setLoginId($this->values[LOGIN_ID]);
        $userModel->setPassword($this->values[PASSWORD]);
        if (Login::auth($userModel)) {
          unset($_SESSION[SESSION_USER]);
          $_SESSION[SESSION_USER] = $userModel;

          // 次ページへ
          $this->transferPage($this->NEXT_PAGE);
        } else {
          $this->addErrorMessage('error_not_login');
          return;
        }
      }
    }
  }

  /**
   * ログインIDチェック
   */
  function checkLoginId() {
    if (isset($this->values[LOGIN_ID])
      && $this->values[LOGIN_ID]) {
      return null;
    }
    else {
      return "check_login_id";
    }
  }

  /**
   * ログインIDチェック
   */
  function checkLoginId() {
    if (isset($this->values[LOGIN_ID])
      && $this->values[LOGIN_ID]) {
      return null;
    }
    else {
      return "check_login_id";
    }
  }

  /**
   * パスワードチェック
   * 半角英数8桁以内
   */
  function checkPassword() {
    if (isset($this->values[PASSWORD])
       && $this->values[PASSWORD]
       && ereg('^[a-zA-Z0-9]+$', $this->values[PASSWORD])
       && strlen($this->values[PASSWORD]) <= 8) {
      return null;
    }
    else {
      return "check_password";
    }
  }
}
?>
lib/Login.php
<?php
require_once('lib/Log.php');
require_once('lib/Database.php');

/**
 * <b>Login</b>
 * ログインクラス
 * ログイン管理を行う
 *
 * @author hoge
 * @version $Id: Login.php,v 1.1.1.1 2004/10/01 08:46:04 hoge Exp $
 */
class Login {
  /**
   * コンストラクタ
   */
  function Login() {
  }

  /**
   * 認証
   *
   * @param &$userModel ユーザ情報Model
   *                   IdCardNoとPasswordをセットしておく
   * @return 認証OKならtrue
   *         エラーならfalse
   */
  function auth(&$userModel) {
    if (get_class($userModel) != 'usermodel') {
      return false;
    }

    $handleName = addslashes($userModel->getHandleName());
    $password = addslashes($userModel->getPassword());
    $where = sprintf('handle_name=\'%s\' and password =\'%s\'', $handleName, $password);

    $db =& Database::getInstance();
    if (($record = $db->getOneRecord('*', 'v_user', $where))) {
      // ユーザ情報をセット
      $userModel->setId($record['id']);
      $userModel->setIdCardNo($record['id_card_no']);
      $userModel->setEmail($record['email']);
      $userModel->setFirstName($record['first_name']);
      $userModel->setLastName($record['last_name']);
      $userModel->setFirstNameKana($record['first_kana']);
      $userModel->setLastNameKana($record['last_kana']);
      $userModel->setPrefecture($record['prefecture']);
      $userModel->setAddress($record['address']);
      $userModel->setSubAddress($record['sub_address']);
      $userModel->setZip($record['zip']);
      $userModel->setTel($record['tel']);
      $userModel->setBirthdayYear($record['birthday_year']);
      $userModel->setBirthdayMonth($record['birthday_month']);
      $userModel->setBirthdayDay($record['birthday_day']);
      $userModel->setLangCode($record['lang_code']);

      return true;
    } else {
      return false;
    }
  }
}
?>

診断

1. PHP 4らしいコード

まずコードを見た印象としては、実にPHP 4らしいコードだな、ということを感じました。

ざっと見渡しただけでも、require_once文による外部PHPファイルの読み込み、メンバ変数のvarキーワードによる定義、クラス名と同名のコンストラクタ、メソッドのアクセス修飾子がないなどの特徴があります。また、引数の参照渡しやstatic指定なしのクラスメソッドなどもそうですね。

これらは、PHP 4では問題のない書き方だったのですが、時を経て、変化したPHP 5にはそぐわない書き方になりました。これは言語の変化だけではなく、開発手法や利用するライブラリ、フレームワークが変化したというのもその一因です。こうした環境の変化に合わせて、コードの書き方を変えていくことで、PHPやその周辺ツールの良さをより享受することができます(PHP 5の中でも、5.3未満とそれ以降でまた異なるのですが、それはまた別の機会に⁠⁠。

では、それぞれの個所について見てみましょう。

1-1. require_once文による外部ファイルの読み込み

両コードとも、ファイルの先頭でrequire_once文で外部のクラスファイルを読み込んでいます。当時、複数のクラスファイルで構成されるアプリケーションでは定石だったこの方法ですが、オートローダが普及した今では、オートローダ自身や設定ファイルなど最低限のファイルのみ自分で読み込んで、あとはオートローダに自動で読み込ませるという手法を取ることが多くなりました。

require_once文は悪くない方法なのですが、ファイル位置を相対パスで指定したり、include_pathを別途指定する必要があるなど、少し煩わしさがあります。オートローダを使えば物理的なファイルパスを気にすることなくクラスファイルを読み込むことができるので便利です。

オートローダは自作することもできますが、Composer[1]のオートローダを利用するのが簡単です。Composerのオートローダを利用することで、ほかのライブラリやフレームワークを利用する際も同じオートローダを使うことができ、連携しやすい構成となります。

1-2. 名前空間

Composerによるオートローダを導入して、ほかのライブラリやフレームワークと組み合わせる際に重要になるのが、名前空間の設定です。複数のソフトウェアを組み合わせると、同じ名前のクラスがそれぞれに存在して、競合してしまう場合があります。これはソフトウェア同士だけではなく、開発した時点ではオリジナルなクラス名でも、同名のクラスがPHP言語に導入されてしまう場合もあります(最近では、PHP 5.4からSessionHandlerクラスが導入されて、アプリケーションに存在するクラスと競合するということがありました⁠⁠。

名前空間を導入しておくと、こうした名前の競合を避けることができます。たとえば、下記のように同じUserクラスでも、名前空間をAppFooLibのように別にしておけば、名前空間を含めた完全なクラス名は\App\UserFooLib\Userとなり、同居することできます。

namespace App;

class User {}
namespace FooLib;

class User {}

1-3. クラス名と同名のコンストラクタ

コンストラクタのメソッド名がクラス名と同じ名前になっています。

PHP 4のコンストラクタはこのように記述するしかなかったのですが、PHP 5では、__constructというメソッド名がコンストラクタとして記述できるようになりました。従来の同名コンストラクタも利用できるのですが、PHP 5.3.3以降、名前空間が設定されたPHPファイルでは同名コンストラクタが呼ばれなくなっているので、__constructを使いましょう。

1-4. オブジェクトの参照渡し

PHP 4らしい書き方と言えば、このオブジェクトの参照渡しです。たとえば、Loginクラスのauth()メソッドの引数が参照渡しとなっています。

  function auth(&$userModel) {

また、インスタンスを変数に代入する場合も下記のように参照で受け取るように書かれています。

    $db =& Database::getInstance();

PHP 4では、変数の代入やメソッド呼び出しで引数を渡す際、デフォルトは値渡しとなっていました。そのため、オブジェクトを代入する際は処理速度やメモリ効率を考慮して、参照渡しを行うことがTipsとして知られていました。しかし、PHP 5からは、オブジェクトの実体ではなく、参照のみが渡されるようになったため、パフォーマンスを考慮した参照渡しを行う必要がなくなりました。

また、Loginクラスのauth()メソッドの場合、引数で受け取ったUserModelクラスのオブジェクトに対してセッターメソッドで値をセットしています。このオブジェクトの変更を呼び出し元でも利用するため、参照渡しで引数を渡しています。これもPHP 5であれば、前途のとおりオブジェクトの参照を渡すので、メソッド内のオブジェクトと呼び出し元のオブジェクトは同一のオブジェクトとなります。よって、この理由による参照渡しを行う必要もありません。

このようにPHP 5でオブジェクトを代入する際、&を使った明示的な参照渡しは必要ありません。特にパフォーマンスについては、参照渡しを行うことでかえってパフォーマンスが落ちるケースもあるので、&による参照渡しは基本的には使わないほうがよいです。

1-5. メソッドの重複

LoginControllerクラスを見てみると、checkLoginId()というメソッドが2つ定義されています。PHP 5では、1つのクラスに同じ名前のメソッドは定義できないのですが、PHP 4ではこういった実装が認められていました。同じ名前のメソッドを2つ書くメリットは特にないと思うので、おそらくコピペしてそのまま放置されたのでしょう。

1-6. get_class()関数の挙動の違い

Loginクラスのauth()メソッドでは、処理の先頭で引数のクラスをチェックしています。get_class()関数でクラス名を取得して、期待すべきクラスでなければ、returnするようになっています。

PHP 4では、このget_class()関数の戻り値はクラス名を小文字にした文字列でした。よって、このコードでは期待すべきクラス名UserModelを小文字にしたusermodelと比較しています。しかし、PHP 5ではクラス名がそのまま返ってくるので、戻り値はUserModelとなります。つまり、このままこのコードをPHP 5で実行すると、$userModel変数がUserModelクラスのオブジェクトでも、この式はtrueになりません。

PHP 5で動かすなら、比較文字列をUserModelにするか、get_class()の戻り値をstrtolower()関数で小文字に変換する必要があります。

単に引数の型をチェックしたいということであれば、PHP 5のタイプヒンティングを使う方法もあります(下記⁠⁠。こちらのほうが意図がわかりやすいコードになりますね。

  function auth(UserModel $userModel) {

1-7. ereg()は非推奨

LoginControllerクラスのcheckPassword()メソッドで使われているereg()関数は、PHP 5では非推奨(E_DEPRECATED)となっています。これはpreg_match()関数で代用しましょう。

  ereg('^[a-zA-Z0-9]+$', $this->values[PASSWORD])
  
  preg_match('/^[a-zA-Z0-9]+$/', $this->values[PASSWORD])

1-8. クラスメソッドにstaticが付いていない

Loginクラスのauth()メソッドは、LoginControllerクラスでの呼び出し方を見るとクラスメソッドを想定しているようです。

        if (Login::auth($userModel)) {

このようにクラスメソッドとして呼び出しているメソッドの定義にstaticキーワードが付いていない場合、Strictエラー(Non-static method Foo::hello() should not be called statically)が発生します。

コードを読む際もメソッドがインスタンスメソッドなのかクラスメソッドなのかの判別ができないので、クラスメソッドの定義にはstaticキーワードを付けておきしょう。

2. addslashes()によるエスケープ処理

PHP 4固有のことではないのですが、気になる個所があります。

Loginクラスのauth()メソッドでは、データベースへの問い合わせが実行されているのですが、検索条件を組み立てている個所で値のエスケープにaddslashes()関数が使われています。addslashes()関数はマルチバイト文字などを考慮せずに機械的にエスケープを行ってしまうため、利用する文字エンコーディングによっては、文字列が壊れてしまったり、本来は無効化すべき特殊文字をエスケープできない場合があります。

SQLに含める値をエスケープするにはaddslashes()関数ではなく、データベースドライバに定義されている専用のエスケープ関数(PostgreSQLならpg_escape_string()/pg_escape_literal()、MySQLならmysqli_real_escape_string()など)を用いるほうが安全です。また、もし可能なのであれば、PDO(PHP Data Object)を使い、プリペアドステートメントで値をバインドする方式に変えるほうがよりよいでしょう。

addslashes()関数を使う場面はおおよそないとも言えるので、この関数が登場した場合は要注意です。

3. 連続したセッターメソッド

これは何かマズイことがあるというわけではないのですが、実装について気になった個所です。

Loginクラスのauth()メソッドでは、$userModel変数に対してセッターメソッドが15行ほど並んでいます。各セッターメソッドに対して連想配列$recordの対応するキーの値をひたすら渡しています。まあ丁寧でよいのですが、冗長なので、下記のように連想配列ごと引数で渡して、UserModelクラス内で値を一括でセットしてくれるようなヘルパーメソッドが欲しくなるところです。

// 連想配列の値を属性にセットする
$userModel->setAttributes($record);

今回は、主にPHP 4の書き方に注目して治療するポイントを見てきました。このポイントをベースに、次回はPHP 5らしい書き方に治療していきます。お楽しみに。

おすすめ記事

記事・ニュース一覧