PHP8.1でのマルチバイト文字列関数の文字エンコーディングの挙動について

# 概要

PHP8.1の変更点でマルチバイト文字列関数の文字エンコーディング処理の変更が公式マニュアルには記載がなく手探りで調査することとなり苦労しました。
特に**文字エンコーディングの自動検出**の挙動が変わっている点の調査が大変で、HPのバージョンアップ後に影響調査漏れが発覚し本番トラブルにも発展しました。    

今回は、前述の調査で得られた情報の中で**文字エンコーディングの自動検出**の挙動変更について重点的に紹介しようと思います。

下記の両方に当てはまる方は、同じ課題に直面する可能性が高いため、この記事の内容で理解をしてもらいつつ、ご自身でも詳しく調査することを推奨いたします。

-  文字エンコーディングの自動検出を使用している
-  PHP8.1以上にバージョンアップを予定している



# PHP8.1のマルチバイト文字列関数

PHPの公式マニュアルには記載がない変更が、PHP8.1でマルチバイト文字列関数の挙動が変わっているものが多数あります。
しかし、それぞれに個別の変更点としては軽微なものが多くバグ修正のようなものがほとんどです。  

- [PHP: PHP 8.0.x から PHP 8.1.x への移行 - Manual](https://www.php.net/manual/ja/migration81.php)
- [PHP: マルチバイト文字列 関数 - Manual](https://www.php.net/manual/ja/ref.mbstring.php) 

PHPの某コントリビュータの方がまとめくださっているブログにまとまっているのでそこを参照してもうのがよいかと思います。

https://tekitoh-memdhoi.info/views/864

# PHP8.1のマルチバイト文字列関数の不具合

PHP8.1のマルチバイト文字列関数ではいくつかの不具合が報告されています。
ほとんどは修正されていますが、対応バージョンが違いますので利用される方はご注意ください。

## 不具合①.コンバート処理の不具合

文字エンコーディングをSJIS→UTF-8に変換する際に、**半角**のチルダ・バックスラッシュが**全角**になるという不具合です。

発生バージョン：`8.1.0 - 8.1.7`  
※ `8.1.8~`,`8.2.x` では修正済み


[https://3v4l.org/klhTh#v]

```diff
 変換前「~ \」(文字エンコーディング：SJIS)
 変換後「~ \」(文字エンコーディング：UTF-8)

- 8.1.0: ～ ＼
+ 8.1.8: ~ \
```

> 参考  

https://tekitoh-memdhoi.info/views/856


## 不具合②.文字エンコーディングの自動判定処理の不具合

PHP8.1では文字エンコーディングの自動判定処理に修正があったことにより、いくつかの不具合が発生しています。
下記の不具合はその一部であり、他にも存在するためPHP8.1を利用する際はできる限り最新を利用することを推奨します。
また、特定のバージョンを利用する際はさらなる調査が必要となります。

ここではその一部の不具合を紹介します。

発生バージョン：`8.1.0 - 8.1.7`  
※ `8.1.8~`,`8.2.x` では修正済み


> https://3v4l.org/9i19F

```php
<?php
$jis_bytes_without_esc = '1b24422422'; // 'あ' in ISO-2022-JP without escape sequence
var_dump(mb_check_encoding(hex2bin($jis_bytes_without_esc), 'JIS'));
// 8.1.0: true
// 8.1.18: false
```

参考
- https://github.com/php/php-src/issues/10648
- https://speakerdeck.com/pakutoma/problems-encountered-with-mbstring-in-php-8-dot-1?slide=9

# PHP8.1のマルチバイト文字列関数の文字エンコーディングを自動検出の挙動変更

PHP8.1では文字エンコーディングの自動検出処理が大きく変更されており、PHP8.0以前とでは挙動が異なるケースがあり、
PHP8.0の挙動を正常であるとして運用されている場合に、予期せぬ挙動変更となりトラブルにつながります。
下記のような関数を利用し、文字エンコーディングの自動検出を使用している方はよく調査することをおすすめします。  

- [PHP: mb_detect_encoding - Manual](https://www.php.net/manual/ja/function.mb-detect-encoding.php)
- [PHP: mb_check_encoding - Manual](https://www.php.net/manual/ja/function.mb-check-encoding.php)
- [PHP: mb_convert_encoding - Manual](https://www.php.net/manual/ja/function.mb-convert-encoding.php)
- [PHP: mb_convert_variables - Manual](https://www.php.net/manual/ja/function.mb-convert-variables.php)

変更点に関して検出順位の変更と検出アルゴリズムの変更に分けて紹介します。

## 検出順位の変更

検出対象の文字エンコーディングを複数指定いる場合に発生します。  
PHP8.1では検出処理が大きく見直されており、PHP8.0以前と挙動が完全に異なっています。  
詳しくは[issue#8279](https://github.com/php/php-src/issues/8279)に記載があります。

### 変更内容

mb_detect_encoding の 公式マニュアルには

>  エンコーディングの候補の一覧から、文字列 string の**もっとも可能性が高い**文字エンコーディングを検出します。

とありますが、PHP8.0以前は

> エンコーディングの候補の一覧から、 文字列 string の**候補の中で最初に一致した**文字エンコーディングを検出する。

という挙動でした。PHP8.1以降ではこれが公式マニュアル通りの挙動へと変更になりました。


- `mb_detect_encoding` の挙動

``` diff
  エンコーディングの候補の一覧から、
- (PHP8.0以前)  文字列 string の候補の中で最初に一致した文字エンコーディングを検出
+ (PHP8.1以降)  文字列 string のもっとも可能性が高い文字エンコーディングを検出
```

※ mb_convert_encoding も内部上はmb_detect_encodingと同等の処理が実施されているため、同様の影響があります。

### 発生例

特定の文字ではなく、特定の文字並び・文字エンコーディング組合せ・文字エンコーディングの並び順によりバージョンアップ前後で差分が発生します。  
下記は具体例ですが、他の組み合わせでも発生するはずですべてを調査することは現実的ではないと思います。

>https://3v4l.org/686vj
```php
<?php
var_dump(mb_detect_encoding('東舘町', ['UTF-8', 'SJIS-WIN']));
// PHP8.0："UTF-8"
// PHP8.1："SJIS-WIN"
```

### 対策

前述したとおり、影響のある組合せを調査することは現実的ではありません。  
[issue](https://github.com/php/php-src/issues/8279#issuecomment-1083613035)にも記載がありますが下記のように順にmb_detect_encodingを呼び出す関数を作成すれば、PHP8.0以前の動作に**近い**挙動を再現することができます。  
ただし、後述の変更点(検出アルゴリズムの変更)と合わせると、この対策では**不十分**であり**別の不具合**が発生する可能性があります。

> https://3v4l.org/C149t

```php
<?php
function mb_detect_encoding_php80($string, $encodings) {
	foreach($encodings as $encoding) {
		$result = mb_detect_encoding($string, $encoding, true);
		if ($result !== false) {
			return $result;
		}
	}
}
```

> ※ 上記の実装は簡易版であり、実際は 第二引数`$encodings`の型が`array|string|null`のためもうパース処理などが必要になります。

## 検出アルゴリズムの変更

検出アルゴリズムについても変更がされており、文字列・エンコーディングの組み合わせによってPHP8.0から挙動が変わっています。
下記のスライドにもまとめられていますが、一部の組み合わせにより発生します。

https://speakerdeck.com/pakutoma/how-we-have-responded-to-the-php-8-dot-1-mbstring-specification-changes


### 発生例

発生しやすい条件については下記になります。

- 検証対象の文字列が少ない
- エンコーディングにJIS関連が含まれる

> https://3v4l.org/OUFRn
```php
<?php
$str = mb_convert_encoding("ｱ", 'JIS', 'UTF-8');
var_dump(mb_detect_encoding($str, 'ASCII', true));
// PHP8.0：false
// PHP8.1："ASCII"
```

## まとめ

### 課題

「検出順位の変更」の変更を以前の挙動（PHP8.0相当）に戻したい場合は、エンコーディングを個別に指定してmb_detect_encodingを実行する必要があるが、
しかし、「検出アルゴリズムの変更」の影響により一部の文字／エンコーディングによって結果が変わるケースがあるため、
PHP8.0相当の挙動を再現できない という状態になってしまいます。

### 対策案

対策案としては、下記が考えられるが現実的に対応できる選択肢は少ない

- すべての文字でmb_detect_encodingの結果が変わっているパターンを調査し、文字バイトを指定してフィルタ処理を実装する
- エンコーディングの並びを変更し、挙動が近いケースを見つける
- PHPのソースコードを独自に修正・コンパイルし、旧挙動を再現する
- 文字エンコーディング処理のみPHP8.0の環境で実施する

### 結論

事実上は、ある程度動作変わることを「妥協する」しかないということになります。

実際の運用では、バージョンアップを前後で挙動変更が少ないようにエンコーディングのリストを調整を実施しました。

# その他

## 文字バイトデータと文字エンコーディングについて

utf-8 の「α」と sjis-win の「ﾎｱ」は、データ上は完全に同じ値であるため、
この単語だけの場合、システムでエンコーディングを自動判別することは原理的に不可能である。

> https://3v4l.org/uHQNT

```php
<?php
var_dump(bin2hex(mb_convert_encoding('ﾎｱ', 'SJIS-WIN', 'UTF-8')));
var_dump(bin2hex(mb_convert_encoding('α',  'UTF-8',    'UTF-8')));
// string(4) "ceb1"
// string(4) "ceb1"
```

## PHPで利用できる文字エンコーディング一覧

PHPの公式マニュアル([サポートされるエンコーディングの概要 - Manual](https://www.php.net/manual/ja/mbstring.encodings.php))にも記載がありますが、エイリアスも存在するため下記の関数を利用すれば確認できます。

- [PHP: mb_list_encodings - Manual](https://www.php.net/manual/ja/function.mb-list-encodings.php)
- [PHP: mb_encoding_aliases - Manual](https://www.php.net/manual/ja/function.mb-encoding-aliases.php)

また、文字エンコーディング一覧やエイリアスにも存在しない文字エンコーディングを利用することができます。  
具体例としては、`shift_jis` などがあります。

- 一覧で文字エンコーディングを作る処理

> https://3v4l.org/oXR8N
```php
<?php
function getEncodings() {
  return array_map(
    'strtolower',
    array_unique(
      array_merge(
        $enc = array_diff(mb_list_encodings(), ['BASE64', 'UUENCODE', 'HTML-ENTITIES', 'Quoted-Printable']),
        call_user_func_array(
          'array_merge',
          array_map(
            "mb_encoding_aliases",
            $enc
          )
        )
      )
    )
  );
}
```

> 参考
> https://github.com/php-mime-mail-parser/php-mime-mail-parser/blob/main/src/Charset.php

## PHP8.3の変更点

PHP8.3 においても自動判定処理に挙動変更がされており、次期バージョンアップでも注意が必要となっています。  
次期も公式マニュアルにも記載がない変更も含まれます。

- mb_detect_encoding() の"$strict に true を指定しない" モードの挙動変更
- 検出アルゴリズムの変更

### mb_detect_encoding() の"$strict に true を指定しない" モードの挙動変更

PHP8.3以前は一部のパターンにおいて、マニュアルに記載がある「もっとも近いと判定された文字エンコーディングが返されます」でない動作があったようで、それがマニュアル通りに変更となるようです。
バージョンアップ前の挙動を是としている実装がないかを調査する必要がありそうです。
(~~ないと信じたいが...~~)

[https://www.php.net/manual/ja/migration83.other-changes.php#migration83.other-changes.functions.mbstring:embed:cite]


### 検出アルゴリズムの変更

PHP8.1に引き続き変更があるようです。
マルチバイト関数関連に詳しい方が紹介して頂いている内容は下記になります。

https://speakerdeck.com/youkidearitai/php-8-dot-3nombstringnojin-hua-wojian-tekudasai-kontoribiyutatositenokakawari?slide=26

https://github.com/php/php-src/pull/10373


この対応としては`UTF-8`の判定が変更となっているようです。

[他にも変更](https://github.com/php/php-src/pulls?q=is%3Apr+label%3A%22Extension%3A+mbstring%22) はある可能性があるため、バージョンアップ時には個別にPHPのソースコードを見るほうが確実だと考えられます。


# 感想

今回、文字エンコーディングやPHPの挙動について調査する中で

- 完全な文字エンコーディングの**自動検出は不可能** 
- PHPの**公式マニュアルにも記載がない**後方互換のない変更点を詳しく調査するにはソースコードを見るしかない

という知見が得られました。

基本的に、アプリケーションを運用する上でミドルウェアのバージョンアップ等では後方互換性を担保する必要となることが多いはずです。  
しかし、特にマルチバイト文字関連ではミドルウェアの変更による影響を受けやすいものとなっているので、今後もバージョンアップ等を実施する際は注意する必要があるなと思いました。  

また、自身がアプリケーションの仕様検討で「文字エンコーディングの自動判定」の話題に関わるようなことがあれば、「絶対にメンテできないのでやめましょう！！！」と提案することを心に誓いました。

> ※ 備考
> PHP8.0のEOLになってるので時期外れとはなる内容になっていますが、整理するために時間がかかったのでこの時期となりました...。