PHP5系からのバージョンアップ

弊社で開発・運用しているシステムには、未だにPHP5.4環境で動いているものが存在する。
これは、そんな古のシステムのバージョンアップを目指したときのお話……。

# やったこと

* MySQL関数撲滅

* $\large{\mathrm{MySQL関数撲滅！}}$

* $\huge{\mathrm{MySQL関数撲滅！！}}$

その他下位互換性のない変更点の調査が少々

# そもそもMySQL関数とは？

mysql_connectに代表される「mysql_○○」という名前の関数群のこと。
全てPHP7.0で削除されるため、PDOかmysqliへの置き換えていく必要がある。

今回は対象関数の使用箇所をひたすらPDOへ置き換えていくこととなった。

# PDOへの置換時に発生した問題と解決方法

## SQL実行エラー時の挙動

MySQL関数で実装された以下のようなコードがあったとする。

```php5
$user_dao = new UserDao($connection);

$result = $user_dao->create($email, $password, $name);
if ($result) {
    // 正常系:ユーザー登録成功後の処理
} else {
    // 異常系:ユニークキー重複、ネットワーク的な問題等発生時の処理
}
```

```php5
// MySQL関数版
class UserDao
{
    /**
     * @var resource
     */
    private $connection;

    // ...略...

    /**
     * ユーザー登録
     * @param string $email
     * @param string $password
     * @param string $name
     * @return bool 登録成否
     */
    public function create($email, $password, $name)
    {
        // ...略...
        return mysql_query($sql, $this->connection);
    }
}
```

このUserDaoをそのままPDOに置き換えた場合……

```php5
// PDO版
class UserDao
{
    /**
     * @var PDO
     */
    private $connection;

    // ...略...

    /**
     * ユーザー登録
     * @param string $email
     * @param string $password
     * @param string $name
     * @return bool 登録成否
     */
    public function create($email, $password, $name)
    {
        // ...略...
        return $this->connection->prepare($sql)->execute($values);
    }
}
```

これでMySQL関数は消えたし、登録処理も今まで通り行えるので問題なし！

……とはならない。
なぜなら問題発生時にPDOが例外を投げ、異常系の処理が全てスキップされてしまうためである。

### 解決方法

実現可能な解決方法として以下の2つの案を考えた。

* A案:既存のインターフェイスを尊重し、登録成否をboolで返す。
```php5
    public function create($email, $password, $name)
    {
        // ...略...
        try {
            $this->connection->prepare($sql)->execute($values);
            return true;
        } catch (Exception $e) {
            return false;
        }
    }
```

* B案:インターフェイスを定義しなおし、登録失敗時は例外を投げて検知させる。
```php5
    public function create($email, $password, $name)
    {
        // ...略...
        $this->connection->prepare($sql)->execute($values);
    }
```

B案は呼び出し元の修正こそ必要となるが「（A案と比べて）呼び出し元のネストが深くなりにくい」というメリットが大きかったため、基本的にはこちらの方法で修正していった。

※ちなみに本事象は対象システムの`PDO::ATTR_ERRMODE`が`PDO::ERRMODE_EXCEPTION`に設定されていることに起因する。全く推奨はできないが`PDO::ERRMODE_SILENT`を使えばMySQL関数と挙動を合わせることもできる。


## 二重のエスケープ処理

MySQL関数にはプレースホルダが備わっていないため、入力値のエスケープ処理を自前で行う必要がある。
一方PDOにはプレースホルダが備わっているため、エスケープ処理を自前で行ってはいけない。
つまりMySQL関数からPDOへ移行する際には、不要となるエスケープ処理の除去対応も必要となる。

MySQL関数の呼び出しと同じスコープ内（DAOメソッド内）にあれば除去も容易なのだが、実際にはバラバラに実装されているコードがごく稀に存在した。

例えば、
```php5
$user_name = addslashes($_POST['user_name']);

// ...なんかやたらと長い処理...

$user_dao = new UserDao($connection);
$user_dao->create($email, $password, $user_name);
```

というようなコードがあったとき、エスケープ処理（addslashes関数）を見逃してPDOに切り替えてしまうと二重にエスケープが行われてしまう。

※そもそもaddslashesをSQLインジェクション対策に使ってること自体が大変よろしくないのだが、太古のコードなので仕方がない:upside_down:

### 解決方法

エスケープ処理に使われそうな関数
* addslashes
* addcslashes
* mysql_escape_string
* mysql_real_escape_string
* str_replace
* preg_replace

などを、ソースコード内から地道に探し出すしかない。

grepこそ正義！

## NULLの扱い

こちらもMySQL関数にプレースホルダが備わっていないために発生する問題。あと手抜き実装のせい……。

NULL許容しているカラムにデータを登録する（つもりで書かれた）以下のようなメソッドがあったとする。

```php5
// MySQL関数版
/**
 * @param int $event_id
 * @param int|null $reference_id
 * @param string|null $note
 */
public function create($event_id, $reference_id, $note)
{
    $sql_template = "
        INSERT INTO event_log (`event_id`, `reference_id`, `note`)
        VALUES (%d, %d, '%s');
    ";
    $sql = sprintf($sql_template, $event_id, $reference_id, mysql_real_escape_string($note));
    // ...
}
```

これをPDOに置き換えた場合……

```php5
// PDO版
/**
 * @param int $event_id
 * @param int|null $reference_id
 * @param string|null $note
 */
public function create($event_id, $reference_id, $note)
{
    $sql = "
        INSERT INTO event_log (`event_id`, `reference_id`, `note`)
        VALUES (:event_id, :reference_id, :note);
    ";
    $statement = $this->connection->prepare($sql);
    $statement->bindValue(':event_id', $event_id, PDO::PARAM_INT);
    $statement->bindValue(':reference_id', $reference_id, PDO::PARAM_INT);
    $statement->bindValue(':note', $note, PDO::PARAM_STR);
    // ...
}
```

MySQL関数版とPDO版は一見等価なように見えるが、実は引数にNULLを渡した場合の挙動が異なる。

MySQL関数版にNULLを渡した場合、対象のカラムに`0`や空文字が登録される。一方PDO版では、対象のカラムにそのままNULLが登録される。

この差によって以下のような問題が発生することがある。
* （実はカラムにNOT NULL制約がかかっていて）実行時にSQLエラーになる。
* 対象カラムを条件とする検索クエリ等が正常に動作しなくなる。

### 解決方法

<b>あきらめて既存同様の挙動（`0`や空文字として登録）に合わせる！</b>

テーブル定義や過去データの修正によって全てを正すことも出来なくはないが、対象のテーブル数やデータ量を考えると非現実的過ぎた……。


# やってみて思ったこと

200ファイル超を黙々と修正し、やっとのことでバージョンアップに漕ぎつけました

……が、PHP7の最新（PHP7.4）ですら今月末頃（2022/11/28）にはセキュリティサポートが切れるんですよねえ……。

いつか追いつける日が来るといいデスネ。