PHPerKaigi2021で学んだセッション管理について

PHPにおいて、セッションを使うには、このように使うのが一番簡単。

```php:index.php
<?php
session_start();
// セッションにはSerialize可能なものなら文字列に限らず入れられる
$_SESSION['SESSION_DATA_KEY'] = 'SESSION_DATA_VALUE';
```

デフォルトでは、セッションはファイルに保存される。だが、それは何を意味するかというと、複数サーバをロードバランサでバランシングしている場合、振り分け先のサーバによっては、セッション管理に失敗する、ということだ。であるから、それに対しての対策が必要となる。

## ロードバランサのSticky Sessionを利用する

姑息的な対応として、ロードバランサのSticky Sessionを用いるという手段がある。例えば、Amazon AWSでALBを使う場合、それを有効にすれば良い。だが、ルーティングアルゴリズムを上書きしてしまうため、振り分けしか行わない場合に実質的に限られる。他にも様々な問題が発生する。

## Cookieにセッションをもたせる

結構邪道であるが、このような手段で回避することも可能である。ただしこれを行う場合、セッションデータが極めて小さいこと、セッションデータに機密情報が含まれていないことを担保する必要がある。

## データベースを用いてセッションを管理する

データベースでセッションを管理すると、新たにサーバを借りることなく、セッションを管理することが可能になる。では、どのように管理するか見ていくことにしよう。

今回は話を簡単にするために、mysqliを用いることにする。また、MySQLのバージョンは5.6以降とする。

```php:session_manager.php
<?php
/* 参考URL
https://www.keicode.com/cgi/save-session-in-mysql.php
*/
class DbSessionHandler implements SessionHandlerInterface {
    public function __construct($db, $table_name = 'sessions') {
        $this->db = $db;
        $this->table_name = $table_name;
        $this->db->query('CREATE TABLE IF NOT EXISTS `' .
            $table_name . '` (' .
            // 255でテーブルが作れない場合、191に修正すること
            '`session_id` VARCHAR(255) NOT NULL PRIMARY KEY, ' .
            '`session_data` LONGTEXT, ' .
            '`session_updated` BIGINT NOT NULL DEFAULT 0' .
            ') CHARACTER SET utf8mb4 Engine=InnoDB');
    }
    public function open($path, $name) {
        return true;
    }
    public function close() {
        return true;
    }
    public function read($id) {
        $result = '';
        $stmt = $this->db->prepare('SELECT `session_data` FROM `' .
            $this->table_name . '` WHERE `session_id` = ?');
        if (!$stmt) {
            return '';
        }
        $stmt->bind_param('s', $id);
        $stmt->execute();
        $stmt->store_result();
        $stmt->bind_result($result);
        $stmt->fetch();
        $stmt->close();
        return $result;
    }
    public function write($id, $data) {
        $stmt = $this->db->prepare('INSERT INTO `' .
            $this->table_name . '` (`session_id`, ' .
            '`session_data`, `session_updated`) ' .
            'VALUES (?, ?, ?) ' .
            'ON DUPLICATE KEY UPDATE `session_data` = ?, ' .
            '`session_updated` = ?');
        if (!$stmt) {
            return false;
        }
        // timeはUNIXタイムスタンプを返すのでタイムゾーン非依存
        $cur_time = time();
        $stmt->bind_param('ssisi', $id, $data, $cur_time, $data,
            $cur_time);
        $stmt->execute();
        $ret = ($stmt->affected_rows > 0);
        $stmt->close();
        return $ret;
    }
    public function destroy($id) {
        $stmt = $this->db->prepare('DELETE FROM `' .
            $this->table_name . '` WHERE `session_id` = ?');
        if (!$stmt) {
            return true;
        }
        $stmt->bind_param('s', $id);
        $stmt->execute();
        $stmt->close();
        return true;
    }
    public function gc($max_lifetime) {
        $th_time = time() - $max_lifetime;
        $stmt = $this->db->prepare('DELETE FROM `' .
            $this->table_name . '` WHERE `session_updated` < ?');
        if (!$stmt) {
            return 0;
        }
        $stmt->bind_param('i', $th_time);
        $stmt->execute();
        $ret = $stmt->affected_rows;
        $stmt->close();
        return $ret;
    }
}
// 接続パラメータを適宜調整する
$db = new mysqli();
$handler = new DbSessionHandler($db);
session_set_save_handler($handler);
session_start();
// 以下、セッションを実際に操作する
```

(動作は記事投稿後に確認済み。2021年3月28日21時ごろ修正)

だいたいこんな感じである。これにより、そこそこ大きな規模まで耐えられるようになるのだが、必ず初期化するときにハンドラセットを呼び出すことを担保する必要がある。それを怠るとうまく動作しない。また、専用に作られたセッション管理ではないのでその点が弱かったりする。

## memcachedを用いる

peclでmemcachedを入れた上で、

```ini:php.ini
session.save_handler = memcached
session.save_path = host:port
```

と指定してあげるだけでセッションの設定は完了である。あとは通常の呼び出しと同じフローで実行可能である。

なんと言ってもキャッシュに特化したエンジンを使うので非常に強い。大規模であれば間違いなくこれを採用すべきだろう。ただし、お値段も相応にする。Amazon AWSで本番環境用にm系インスタンスを借りる場合、cache.m6g.largeというインスタンスで1時間あたり0.191ドルという目玉が飛び出るほどの価格になる(1ヶ月744時間稼働させると税抜き142.104ドルほどかかり、2021年3月28日時点の相場を適用すると1ヶ月税込みで17000円ほどになってしまう)。

なので、多くのアクセスが見込まれるサイト以外では導入するのは難しいだろう。

## セッションCookieの取り扱いにあたっての注意事項

セッションのCookieは、デフォルトでは`PHPSESSID`という名前のCookieで格納される。また、ほとんどのパラメータはデフォルトで最低限動くようにしか入らないため、このように設定することできちんと動くようになるはずである。

```php:index.php
// Cookie名を変更する場合
session_name('COOKIENAME');
// Cookieのパラメータを変更する場合(PHP7.3以降用)
session_set_cookie_params(array(
    'lifetime' => 0,
    'path' => '/',
    'domain' => 'domain.tld',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Strict' 
));
// Cookieのパラメータを変更する場合(PHP7.2以前用)
session_set_cookie_params(0, '/', 'domain.tld', true, true);
session_start();
```

## 結論

ロードバランサを導入することを念頭に置くなら、セッション管理はきちんと考えよう。