Accept-Languageの実装例

[ここではリントの言葉で話せ](https://www.google.co.jp/search?q=%E3%81%93%E3%81%93%E3%81%A7%E3%81%AF%E3%83%AA%E3%83%B3%E3%83%88%E3%81%AE%E8%A8%80%E8%91%89%E3%81%A7%E8%A9%B1%E3%81%9B)。

はじめに
--------

`Acecpt-Language`はHTTPヘッダで「できれば日本語か英語で読みたいなー(ﾁﾗｯﾁﾗｯ」みたいな感じで期待する言語を指定する仕組みのこと。

言語の指定には[IETF言語タグ](https://ja.wikipedia.org/wiki/IETF%E8%A8%80%E8%AA%9E%E3%82%BF%E3%82%B0)を利用するので、各自調べてほしい。 *(これもおもしろい話題だが、今回は対象にしない)*

設計
----

Webサイトをどう多言語化するかは難しい課題だが、とりあえず今回は以下のような戦略をとることにする。

 1. 言語が指定されなかった場合または`*`が指定されたら、 **日本語のコンテンツ**を返す
 2. コンテンツが存在する言語(中国語`zh`を除く)が指定されたら、 **その言語のコンテンツ**を返す
 3. コンテンツが存在しない言語が指定されたら、 **英語のコンテンツ**を返す
 4. 中国語(`zh`)が指定された場合の追加条件
   1. 簡体/繁体のどちらかのコンテンツしかないときは、**簡体字または繁体字の存在する方のコンテンツ**を返す
   2. 用字系(Script)に`Hans`または`Hant`が指定されたときは、**繁体字または簡体字のコンテンツ**を返す
   3. 地域(Region)に`TW`が指定されたときは、**繁体字のコンテンツ**を返す
   4. 上記に合致しなかった場合は **簡体字のコンテンツ**を返す

もちろんこれは全ての利用者の需要を充足できる戦略ではないだろう。

PHPでの実装
-----------

PHPには[Localeモジュール](http://php.net/locale)があり、これを活用できる。

### はじめに

`Locale`モジュールはサブタグをパースする仕組みだが`Accept-Language`の入力形式をよしなに扱ってくれるわけではない。

[HTTP/1.1: Header Field Definitions](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4)を読めば適当に分解できる。

手抜き実装だとこんな感じ。

```php
<?php
var_export($_SERVER['HTTP_ACCEPT_LANGUAGE']);
// 'ja;q=1.0, en, zh-TW'

// めんどくさいので`q`値を無視して、順番だけを考慮する
$accept_languages = array_filter(array_map(function($tag){
            return array_shift(array_map('trim', explode(';', $tag)));
        },
        explode(',', $_SERVER[$_SERVER['HTTP_ACCEPT_LANGUAGE']])));

var_export($accept_languages);
// array (
//   0 => 'ja',
//   1 => 'en',
//   2 => 'zh-TW',
// )
```

`q`値を考慮したきちんとした実装については後述する。

### `Locale::lookup()`

[Locale::lookup](http://jp1.php.net/manual/ja/locale.lookup.php)は一見して活用できるように見え、実際は *サブタグを考慮しなければ*、つまり**4の条件を満たす必要がない場合** ならば利用できる。

> ```php
> public static string Locale::lookup ( array $langtag , string $locale [, bool $canonicalize = false [, string $default ]] )
> ```

`$langtag`にはコンテンツが存在する言語タグ一覧の配列を、`$locale`にはサブタグを指定する。

```php
<?php
$locale = 'en-GB';
var_export(Locale::lookup(["zh-Hant-TW", "zh-Hans", "en", "ja"], $locale, true, "ja"));
// 'en'

$locale = 'zh-Hant';
var_export(Locale::lookup(["zh-Hant-TW", "zh-Hans", "en", "ja"], $locale, true, "ja"));
// 'ja'
```

下の`zh-Hant`が`zh-Hant-TW`にマッチしないのは[RFC 4647 - Matching of Language Tags #3.4.  Lookup](http://tools.ietf.org/html/rfc4647#section-3.4)に紹介される “Example of a Lookup Fallback Pattern” の通りなのだけれど、そもそも微妙なので頼らない方が良い。

### `Locale::parseLocale()`

> ```php
> public static array Locale::parseLocale ( string $locale )
> ```

[Locale::parseLocale](http://jp1.php.net/manual/ja/locale.parselocale.php)はPHPらしからず、簡潔なインタフェイスで、きちんと動く。

このメソッドの素晴らしい特徴として、ISO 3166-1 alpha-3 (`JPN`のような3文字形式) を alpha-2 (`JP`のような2文字形式)に正規化してくれる。

あへて欠点を挙げるならば、これはあくまでサブタグのパーサなので、 [RFC 4647 - Matching of Language Tags](http://tools.ietf.org/html/rfc4647#section-3.3.2) にあるようなワイルドカード(`*`)をサポートしないくらゐか。

ライブラリ
----------

 * [zonuexe/http-accept-language - Packagist](https://packagist.org/packages/zonuexe/http-accept-language)

以上のようなことがめんどくさかったのでライブラリ化した。このライブラリは`Locale::parseLocale()`をラップして、`Accept-Language`形式からおてがるに最適な言語を選択するおてつだいをする。

 * quality指定(`ja;q=1`のような形式)をパースしてソート
 * 言語のワイルドカード(`*-Latn`のような形式)をサポート

```php
$known_languages = ['ja', 'en', 'es', 'ko'];
$strategy = function (array $locale) use ($known_languages) {
    $is_wildcard = isset($locale['language']) && $locale['language'] === '*';
    if (empty($locale['language']) && !$is_wildcard) {
        return null;
    }
    if ($is_wildcard || $locale['language'] === 'zh') {
        if (!empty($locale['region']) && $locale['region'] == 'TW') {
            return 'zh_tw';
        }
        if (!empty($locale['script']) && $locale['script'] == 'Hant') {
            return 'zh_tw';
        }
        if ($locale['language'] === 'zh') {
            return 'zh_cn';
        }
    }

    if (in_array($locale['language'], $known_languages)) {
        return $locale['language'];
    }

    return null;
};

$choise = \Teto\HTTP\AcceptLanguage::detect($strategy, $default);
```

`$strategy`は`Locale::parseLocale()`形式の連想配列を受け取り、受理しない場合は **偽として評価される値** を、受理する場合は **真として評価される任意の値** を返せば良い。マッチするものがなければ `$default` をそのまま返す。


## あとがき

すこし前に調べたところでは、あの[zh.Wpすらワイルドカードはきちんと解釈してない](https://gist.github.com/zonuexe/a45ecaf517f99a53155a)ので、僕個人としても、サポートがめんどくさいときはがんばらなくてもいいかなって気持ちも多少はある。とは言ってもがんばりたいときはがんばりたい。