エンコードが混在しまくりな環境でカスタムセッションハンドラを使ってハマったのでメモ

今までセッションをテキストファイルに保存してたけど、ストレージ（EFS）のパフォーマンスに影響が出るので、カスタムセッションハンドラを使ってDB（PostgreSQL）に保存させようぜ、って話になって対応したときのメモ。

なおスクリプトファイルのエンコードはSJIS、EUC-JP、UTF-8が混在しまくりなカオスな環境。

とりあえず普通にカスタムセッションハンドラを作って登録してみた

```php:DBSessionHandler.php
class DBSessionHandler implements SessionHandlerInterface
{
    public function __construct($hostname, $port, $user, $pass, $option)
    {
        // DBに繋げたり
    }
    public function open($name, $name)
    {
        // コンストラクタで接続するので特に何もしない
        return true;
    }
    public function close()
    {
        // 終了時点で接続は切れる（とリファレンスに書いてた気がする）ので特に何もしない
        return true;
    }
    public function read($session_id)
    {
        // 検索する
    }
    public function write($session_id, $session_data)
    {
        // ADDやらUPDATEやら
    }
    public function destroy($session_id)
    {
        // session idで引っ掛けて消す
    }
    public function gc($lifetime)
    {
        // timestamp見て消す
    }
}

$handler = new DBSessionHandler($hostname, $port, $user, $pass, $option);
session_set_save_handler($handler, true);
```

こんな感じ。で、EUC-JPとUTF-8でテスト。

```php:test_euc.php,test_utf.php
<?php
require_once('DBSessionHandler.php');
session_start();

echo $_SESSION['test'];
$_SESSION['test'] = 'あばばばば';
```

まぁ当然ながら後から踏んだ方ではechoするときに化けちゃう。

ということで、DBSessionHandlerをrequire_onceするときのファイルのエンコードに合わせてやる感じで各々用意して
read / writeでスクリプトファイルのエンコードに合わせてコンバートしてやることにした。

```php:DBSessionHandler.php
// read / write あたりで上手いこと文字コードを変えてやる
class DBSessionHandler_EUC implements SessionHandlerInterface
{
}

class DBSessionHandler_UTF8 implements SessionHandlerInterface
{
}

// 読み込み元のファイルのエンコードを特定
$parent_file_name = debug_backtrace()[0]['file'];
$parent_file_contents = file_get_contents($parent_file_name);
// 読み込み元のファイルに全角文字がなかったらアウトなんだけどね
$parent_file_encoding = mb_detect_encoding($parent_file_contents, 'ASCII,JIS,UTF-8,EUC-JP,SJIS');

$handler = null;
switch (strtolower($parent_file_encoding)) {
    case 'euc-jp':
        $handler = new DBSessionHandler_EUC(...);
        break;
    case 'utf-8':
        $handler = new DBSessionHandler_UTF8(...);
        break;
    case 'sjis':
        $handler = new DBSessionHandler_SJIS(...);
        break;
}

if ($handler) {
    session_set_save_handler($handler, true);
}
```

こんな感じでやれば上手く行くはずやー、って思って実験したら

> Warning: session_start(): Failed to decode session object. Session has been destroyed

なんて感じで怒られまして、色々と調べてみたら、データをシリアル化するときにデータ長も持つんですが、
EUC-JPとUTF-8とでデータ長が違うんですね。

実験コードの「あばばばば」なら、EUC-JPだと10バイト（1文字2バイト）、UTF-8だと15バイト（1文字3バイト）になるのですが、read()した戻り値から復元（unserialize）したとき、データ長が違うと「元は10バイトって言ってんのに、15バイトもあるごわす」って怒られてるみたい。

で、苦肉の策として、read() / write() するときにシリアル化したデータをそのまま使うんじゃなくて、JSONに変えて保存しちゃえ、と。

```php:DBSessionHandler.php
public function read($session_id)
{
    // SQLで取った結果が$session_data変数に入ってる
    $decoded_session_data = json_decode($session_data);
    // 呼び出し元の文字コードに合わせて変換してあげるなど

    // シリアル化した文字列を返す
    return serialize($decoded_session_data);
}

public function write($session_id, $session_data)
{
    // シリアル化された状態で来るので配列に戻す
    $unserialized_session_data = unserialize($session_data);
    // JSONは必ずUTF-8でないといけないので変換してあげるなど

    return json_encode($unserialized_session_data);
}
```

実験用スクリプトのどっちからも文字化けしなかったので、とりあえずOKってことで。

完成形

```php:DBSessionHandler.php
<?php
/**
 * PHP session handler keep session data within PostgreSQL database
 *
 * Installation
 * --------------------
 * Create the session table in your database
 * CREATE TABLE php_sessions (
 *     session_id VARCHAR(255) NOT NULL,
 *     session_data TEXT NOT NULL,
 *     saved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 *     PRIMARY KEY (session_id)
 * );
 *
 * Include the following code to start your session
 * <?php
 * require_once 'PATH_TO_LIB_DIR' . '/DBSessionHandler.php';
 * session_start();
 *
 * @author  M2G.Uchikoba <uchikoba@gmail.com>
 * @version 1.0 2019-01-16 Created
 */
abstract class DBSessionHandler implements SessionHandlerInterface
{
    protected $conn;

    public function __construct($host_name, $session_db_name, $session_db_user, $session_db_pass, $option)
    {
        $dsn = sprintf('pgsql:host=%s;dbname=%s%s', $host_name, $session_db_name, $option);
        $this->conn = new PDO($dsn, $session_db_user, $session_db_pass);
    }

    /**
     * Open the session
     *
     * @return bool
     */
    public function open($path, $name)
    {
        return true;
    }

    /**
     * Close the session
     *
     * @return bool
     */
    public function close()
    {
        return true;
    }

    /**
     * Read the session data
     *
     * @param string session id
     * @return string session data
     */
    public function read($session_id)
    {
        $sql = 'SELECT session_data FROM php_sessions WHERE session_id = ' . $this->conn->quote($session_id);
        $cursor = $this->conn->query($sql);
        $session_data = $cursor->fetchColumn();
        $cursor->closeCursor();

        return $this->decode_session($session_data);
    }

    /**
     * Write the session data
     *
     * @param string session id
     * @param string session data
     * @return bool
     */
    public function write($session_id, $session_data)
    {
        $session_data = $this->encode_session($session_data);

        $sql_upd = sprintf('UPDATE php_sessions SET session_data = %s, saved_at = CURRENT_TIMESTAMP WHERE session_id = %s',
                           $this->conn->quote($session_data),
                           $this->conn->quote($session_id));
        $sql_add = sprintf("INSERT INTO php_sessions (session_id, session_data) SELECT %s, %s WHERE NOT EXISTS (SELECT ' ' FROM php_sessions WHERE session_id = %s)",
                           $this->conn->quote($session_id),
                           $this->conn->quote($session_data),
                           $this->conn->quote($session_id));

        $ret_upd = $this->conn->query($sql_upd);
        $ret_add = $this->conn->query($sql_add);

        return true;
    }

    /**
     * Destroy the session
     *
     * @param string session id
     * @return boolean
     */
    public function destroy($session_id)
    {
        $sql = sprintf('DELETE FROM php_sessions WHERE session_id = %s', $this->conn->quote($session_id));
        setcookie(session_name(), "", time() - 3600);

        if ($this->conn->query($sql)) {
            return true;
        }
        return false;
    }

    /**
     * Garbage collector
     *
     * @param int life time (sec.)
     * @return boolean
     * @see session.gc_divisor      100
     * @see session.gc_maxlifetime 1440
     * @see session.gc_probability    1
     * @usage execution rate 1/100
     *        (session.gc_probability / session.gc_divisor)
     */
    public function gc($lifetime)
    {
        $sql = sprintf("DELETE FROM php_sessions WHERE saved_at < NOW() - INTERVAL '%d second'", $lifetime);
        if ($this->conn->query($sql)) {
            return true;
        }
        return false;
    }

    /**
     * Return decodes JSON and convert it to client encoding and serialized session data
     *
     * @param string session data
     * @return string
     */
    protected abstract function decode_session($session_data);

    /**
     * Return encodes JSON and convert it to database encoding
     *
     * @param string session data
     * @return string
     */
    protected abstract function encode_session($session_data);
}

/**
 * A DBSessionHandler adapted to 'EUC-JP'
 */
class DBSessionHandler_EUC extends DBSessionHandler
{
    /**
     * Return decodes JSON and convert it to client encoding and serialized session data
     *
     * From JSON(UTF-8) to Client encoding(EUC-JP)
     *
     * @param string session data
     * @return string
     */
    protected function decode_session($session_data)
    {
        $decoded_data = json_decode($session_data, true);
        if (empty($decoded_data)) {
            return '';
        }

        array_walk_recursive($decoded_data, function (&$item) {
            $item = mb_convert_encoding($item, 'EUC-JP', 'UTF-8');
        });

        return serialize($decoded_data);
    }

    /**
     * Return encodes JSON and convert it to database encoding
     *
     * From Client encoding(EUC-JP) to JSON(UTF-8)
     *
     * @param string session data
     * @return string
     */
    protected function encode_session($session_data)
    {
        $session_data = unserialize($session_data);

        array_walk_recursive($session_data, function (&$item) {
            $item = mb_convert_encoding($item, 'UTF-8', 'eucJP-win');
        });
        return json_encode($session_data);
    }
}

/**
 * A DBSessionHandler adapted to 'UTF-8'
 */
class DBSessionHandler_UTF8 extends DBSessionHandler
{
    /**
     * Return decodes JSON and convert it to client encoding and serialized session data
     *
     * From JSON(UTF-8) to Client encoding(UTF-8)
     *
     * @param string session data
     * @return string
     */
    protected function decode_session($session_data)
    {
        $decoded_data = json_decode($session_data, true);
        if (empty($decoded_data)) {
            return '';
        }

        return serialize($decoded_data);
    }

    /**
     * Return encodes JSON and convert it to database encoding
     *
     * From Client encoding(UTF-8) to JSON(UTF-8)
     *
     * @param string session data
     * @return string
     */
    protected function encode_session($session_data)
    {
        $session_data = unserialize($session_data);
        return json_encode($session_data);
    }
}

/**
 * A DBSessionHandler adapted to 'SJIS'
 */
class DBSessionHandler_SJIS extends DBSessionHandler
{
    /**
     * Return decodes JSON and convert it to client encoding and serialized session data
     *
     * From JSON(UTF-8) to Client encoding(SJIS)
     *
     * @param string session data
     * @return string
     */
    protected function decode_session($session_data)
    {
        $decoded_data = json_decode($session_data, true);
        if (empty($decoded_data)) {
            return '';
        }

        array_walk_recursive($decoded_data, function (&$item) {
            $item = mb_convert_encoding($item, 'SJIS', 'UTF-8');
        });

        return serialize($decoded_data);
    }

    /**
     * Return encodes JSON and convert it to database encoding
     *
     * From Client encoding(SJIS) to JSON(UTF-8)
     *
     * @param string session data
     * @return string
     */
    protected function encode_session($session_data)
    {
        $session_data = unserialize($session_data);
        array_walk_recursive($session_data, function (&$item) {
            $item = mb_convert_encoding($item, 'UTF-8', 'SJIS-win');
        });
        return json_encode($session_data);
    }
}

$calling_file_name = debug_backtrace()[0]['file'];
$calling_file_contents = file_get_contents($calling_file_name);
$calling_file_encoding = mb_detect_encoding($calling_file_contents, 'ASCII,JIS,UTF-8,SJIS,EUC-JP');

$class_name = '';
switch (strtolower($calling_file_encoding)) {
    case 'euc-jp':
        $class_name = 'DBSessionHandler_EUC';
        break;
    case 'utf-8':
        $class_name = 'DBSessionHandler_UTF8';
        break;
    case 'shift_jis':
    case 'sjis':
        $class_name = 'DBSessionHandler_SJIS';
        break;
}

if ($class_name != '') {
    session_write_close();
    // この設定を変えておかないと、unserializeするときに結局エラーになった
    ini_set('session.serialize_handler', 'php_serialize');
    $session_handler = new $class_name('host', 'db', 'uesr', 'pass', 'option');
    session_set_save_handler($session_handler, true);
}
```

仕事で書いたコードから色々と削っちゃってるけど、まぁニュアンスが伝われば良いかな、と。。。