超PHPerになろう

Enjoy PHP Programming

PSR-7と生PHPに対応したSet-Cookieライブラリを作った

Cookies default to SameSite=Lax - Chrome Platform Statusということで、何もなければ2020年2月4日にはリリースされる見込みのChrome 80(参考: Chrome Platform Status)を皮切りにCookieにデフォルトがSameSite=Lax相当になるということで、駆け込みでSameSite=Noneを付けて回る需要があるらしいですね。

PHP 7.3が正式リリースされる前に書いたPHPでSame-site cookie - Qiitaが未だに参照されていて厳しい気持ちがあり、さりとて邪悪なPolyfillをまた作ってコピペさせるのも抵抗があり、と悩んでいるうちにバッドノウハウを堂々と書いた記事が出てきてしまったので、仕方なくライブラリにすることにしました。

github.com

週末は具合が悪くて昨晩から衝動的に作ったので設計が練りきれてないところはありますが、ちゃんと考えて書いたので、たぶん動くと思います。

問題の背景

PHPからHTTPレスポンスヘッダのSet-Cookieを送るには、ちょっとした問題がいくつかあります。

  • PHPsetcookie()関数は7.3.0から'SameSite'属性に対応した
  • PHP 7.3.0未満のPHPSameSite属性をセットするにはひどいハックが必要になる
  • PSR-7はそもそもCookieを操作するための高レベルの機能がない
  • 当然というかなんというか、setcookie()はPSR-7のために何もしてくれない

Symfony\HttpFoundation ComponentなんかはCookieのための機能を持ってますが、そうでないPSR-7のPsr\Http\Message\ResponseInterfaceだけに依存した良さげなCookie実装がなかったので作りました。いやdflydev/dflydev-fig-cookies: Cookies for PSR-7 HTTP Message Interface.とかhansott/psr7-cookies: 🍪 bakes cookies for PSR-7 messagesとかあるんですけど、どっちもあんまり好きじゃないなあと。

そんなわけでPSR-7を基盤にしたフレームワークでもsetcookie()関数を直接呼ぶような治安のないバニラPHPでも使えるライブラリを自分で実装したいと思ったのでした。

Bag2\Cookieの使いかた

このパッケージはPackagistで公開したのでcomposer require bag2/cookieでインストールできます。操作の軸になるのはCookie Ovenクラスです。

<?php
// デフォルト設定を持った Bag2\Cookie\Oven クラスを作成
$cookie = Bag2\Cookie\oven(['path' => '/', 'httponly' => true]);
$cookie->add('Name1', 'Value', ['expires' => \time() + 120]);
$cookie->add('Name2', '', ['expires' => \time() - 1]); // delete

ここまではBag\Cookie\Ovenクラスにセットしたいクッキーを詰めただけなので、副作用は何もありません。

バニラPHP (フレームワークなし)

header()関数やsetcookie()関数を生で呼んでいるようなプロジェクトでは、以下のように関数を呼ぶとSet-Cookieヘッダを発行できます。

<?php
Bag2\Cookie\emit($cookie); // この $cookie は上記のコード片で作ったOvenオブジェクト

内部ではPHPのバージョンで分岐してsetcookie()関数を呼び分けています。

PSR-7

通常、PSR-7(HTTP Message Interface)オブジェクトには以下のようにHTTPヘッダを設定します。

<?php
$response = $factory->createResponse();
$response = $response->withHeader('Set-Cookie', 'name=value');

すでにPSR-7を使っている皆様には釈迦に説法ですが、重要なのはPSR-7のオブジェクトは原則としてイミュータブルであり、メソッドを呼んでもオブジェクトの内部状態が破壊されることはないということです。そのため、withHeader()メソッドで状態を付け足すことはできますが、その結果は受け取らないと残らないということです。

また、withHeader()メソッドはその既に同じヘッダが設定済みの時に上書きするので、複数の設定は厄介です。コントローラで設定する際は新しく作られたばかりのResponseオブジェクトでは問題ないでしょうが、PSR-15: HTTP Server Request Handlers(ミドルウェア)でSet-Cookieを足したりするときは不用意に行うと問題です。そのためOven::appendTo()Oven::setTo()という2種類のメソッドを提供しています。

Oven::appendTo()はResponseオブジェクトに既にSet-Cookieヘッダが設定済みだったとき、同名のcookieをOvenにあるcookieを優先してセットしたオブジェクトを返します。 Oven::setTo()はResponseオブジェクトに設定済みのSet-Cookieは全て捨ててOvenにあるcookieをセットしたオブジェクトを返します。

<?php
$response = $cookie->appendTo($response);

特にこだわりがなければappendTo()で良いでしょう。

Bag2\Cookie\setcookie()

ところでPHP 7.3.0からsetcookie()の仕様が変わったということですが、それはPHP RFC: Same Site Cookieという提案と表決に基くものです。変更前のsetcookie()関数は以下のようなオプショナルな7引数をとる関数でした。

<?php
bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, string $path = "" [, string $domain = "" [, bool $secure = false [, bool $httponly = false]]]]]] )

このような関数に$samesiteオプションを追加するにはどうすればよいでしょうか。上記のRFCでは以下の二種類の案が投票されました。

setcookie

1 setcookieに引数を追加する

bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, string $path = "" [, string $domain = "" [, bool $secure = false [, bool $httponly = false [, string $samesite = "" ]]]]]]] )

2 setcookieがオプションを配列でとれるようにする。$options配列のキーはSet-Cookieヘッダに対応するpath, domain, secure, httponlyおよびsamesite。それぞれのオプションのデフォルト値は変更しない。samesiteのデフォルト値は空文字列。

bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, string $path = "" [, string $domain = "" [, bool $secure = false [, bool $httponly = false]]]]]] )
bool setcookie ( string $name [, string $value = "" [, int $expire = 0 [, array $options ]]] )

結果はみなさまご存じの通り、1は満場一致で否決、2の方向で採用されました。(ただしExpiresは$optionsの中に入りました)

話が長くなりましたが、Bag2\Cookie\setcookie()RFCで否決された8引数版の実装を採用しています。

<?php

namespace Bag2\Cookie
{
    /**
     * Send a cookie by legacy style \setcookie() like function
     */
    function setcookie(
        string $name,
        string $value = '',
        int $expires = 0,
        string $path = '',
        string $domain = '',
        bool $secure = false,
        bool $httponly = false,
        string $samesite = ''
    ): bool {
        return emit(oven()->add($name, $value, [
            'expires' => $expires,
            'path' => $path,
            'domain' => $domain,
            'secure' => $secure,
            'httponly' => $httponly,
            'samesite' => $samesite
        ]));
    }
}

もし既存のsetcookie()を配列呼び出しに書き換えるのがだるかったらBag2\Cookie\setcookie()にするのが簡単かもしれないですね。

設計の背景

配列オプション VS withXXX()

PHP 7.3のsetcookie()は一個のオプション引数を受け取るスタイルですが、dflydev/fig-cookiesパッケージは以下のようなスタイルをとります。

<?php

use Dflydev\FigCookies\Modifier\SameSite;
use Dflydev\FigCookies\SetCookie;

$setCookie = SetCookie::create('lu')
    ->withValue('Rg3vHJZnehYLjVg7qi3bZjzg')
    ->withExpires('Tue, 15-Jan-2013 21:47:38 GMT')
    ->withMaxAge(500)
    ->rememberForever()
    ->withPath('/')
    ->withDomain('.example.com')
    ->withSecure(true)
    ->withHttpOnly(true)
    ->withSameSite(SameSite::lax())
;

このスタイルは縦にも横にも長めに見える一方、PhpStormのような入力補完で次々に入力できるというメリットがあります。一方でBag2\Cookiesetcookie()スタイルのオプションをPSR-7 Responseに適用することを念頭に置いたデザインになっています。

dflydev/fig-cookiesにはPSR-7に通じるコンセプトを感じる一方で、若干のやりすぎ感も感じます。ミュータブル・イミュータブルの以前に「一度作ったCookieに属性を足したり消したりしたいことなんてないのでは?」(必要ならSetCookieオブジェクトを作る前に$optionsを組み立てる処理を書けばいいのでは?)という疑問のもと、そのスタイルのインターフェイス提供していません

CookieOven vs SetCookie

dflydev/fig-cookiesパッケージではSetCookieクラスまたはSetCookiesクラスを直接扱うのに対して、Bag2\Cookieは最初にOvenを生成します。これは「一つのSet-Cookie」と「複数のSet-Cookie」というものを同列に扱えるようにしたかったからです。また、前述したコンセプトの通り、Oven内のCookieを同名の別オプジェクトで上書きすることはあっても、一度作ったSetCookieオブジェクトの内容を変更することはありません。もとより、Bag2\Cookieではエンドユーザーが意識する必要のあるオブジェクトはBag2\Cookie\OvenPsr\Http\Message\ResponseInterfaceだけです。

関数 vs 静的メソッド

PHPの関数とメソッドは別の機能です。関数とクラスはどちらも名前空間で階層化することができます。クラスはオートローダーで定義の読み込みを遅延できますが、関数は実行前にあらかじめ明示的にロードしておかないといけません。Bag2\Cookieは関数定義ファイルのみ同期的にロードさせていますが、現在のところ提供する機能は小さく留めています。

まとめ

  • Bag2\CookieはPSR-7とsetcookie()の両方に対応したSet-Cookieライブラリです
  • PHP 7.3の配列オプションスタイルのsetcookie()互換の機能を7.3未満でも使うこともできます
  • 逆に8引数スタイルの関数も用意したので、配列オプションに置き換えにくければ、こちらを使ってください

深夜のテンションでうっかりCookieライブラリと説明書を書いてしまってとても眠いので、tadsanの消滅した睡眠時間を慮れる皆様はGitHubまたはpixiv FANBOXでサポートしていただけると、とてもありがたいでえす ヾ(〃><)ノ゙

github.com

www.pixiv.net

次回予告

PHPerKaigi 2020が2月9日から開催されますのでtadsanと握手!

phperkaigi.jp

3月後半にはLaravel JP Conferenceもあります。

conference2020.laravel.jp