OpenPNE+MySQL5.7環境でutf8mb4を素直に使わせてくれない件について

TL;DR

OpenPNE死すべし慈悲はない

概要

  • すでに生ける屍として行先の展望を失ったOpenPNEだが、まま使われる現場もあるようで、その際今後必須となるであろうutf8mb4対応について記す。
  • mysqlにおいて過去に存在したUTF8処理問題に対して行われたOpenPNE側の対応によって、UTF8の4バイト文字(U+1xxxx〜)が勝手にかつ強制的にU+FFFDに置き換えられてしまうという中途半端かつ奇天烈な処理がされている。
  • utf8mb4が実装されこれが主流となった今でもこの処理が生きており、ただひたすら邪魔なだけである。問答無用でこれを無効化する必要がある。
  • 当記事執筆時点でのOpenPNEのバージョンは3.8.30である。異なるバージョンをセットアップする場合は注意されたい。

対処手順

所定のインストール手順の前に下記を行うこと。

$ sed -i '' -e 's#charset: utf8$#charset: utf8mb4#' -e 's#collate: utf8_#collate: utf8mb4_#' ./config/doctrine/schema.yml 
$ find ./data -exec grep -ol "'charset' => 'utf8'" {} \; | xargs sed -i '' -e "s#\\('charset' =>\\) 'utf8'#\\1 'utf8mb4'#" 
$ find ./data -exec grep -ol "'collate' => 'utf8_" {} \; | xargs sed -i '' -e "s#\\('collate' =>\\) 'utf8_#\\1 'utf8mb4_#"
$ sed -e "s#\\('encoding'[[:space:]]*=>\\) 'utf8'#\\1 'utf8mb4'#" -e "/Doctrine::ATTR_USE_DQL_CALLBACKS => true,/a\\ 
999 => true," ./lib/task/openpneInstallTask.class.php
$ sed -e "s#\\('encoding'[[:space:]]*=>\\) 'utf8'#\\1 'utf8mb4'#" -e "/Doctrine::ATTR_USE_DQL_CALLBACKS => true,/a\\ 
999 => true," ./lib/task/openpneFastInstallTask.class.php

説明

その1 - DBとテーブルのDEFAULT CHARSET修正

ここまではだいたい誰でも想像がつく。ただ厄介なのはDBのエンコーディングを一括で指定できる設定がどこにもなく、doctrineのschema.ymlやmigrationファイルに散在するcharset/collate指定を逐一変更する必要があるということ。findで当該箇所を全て網羅しながらsedをかける。

その2 - DB接続時のエンコーディングを指定

次にDBコネクション時のパラメータでencoding指定をする。セットアップ後に生成される config/databases.yml がこちら。

all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:dbname=xxx;host=xxx'
      username: xxx
      encoding: utf8
      attributes: { 164: true }
      password: xxx

インストール直後の状態はこのような内容になる。この encoding 行に手を入れ、utf8 から utf8mb4 に置き換えれば良い。lib/task/openpneInstallTask.class.php と lib/task/openpneFastInstallTask.class.php にその箇所があるので utf8mb4 に置き換えれば目的の encoding で出力してくれて作業完了、…のはずだったのだが。

その3 - 古のUTF8 4バイト文字独自処理の撲殺

ここまでで設定変更はOKかと思いきや、どうにも文字化けが治らない。DBに直接絵文字を入れて見ると問題ないのだが、フォーム入力で絵文字を送信すると見事に 0xFFFD(REPLACEMENT CHARACTER) に潰されてしまう。

仕方がないので OpenPNE の各所に定番の var_dump debug を仕込んでどこまで文字化けせずに渡っているかを調査に入った。すると程なく lib/vendor/symfony/lib/plugins/sfDoctrinePlugin/lib/vendor/doctrine/Doctrine/Record.php の Doctrine_Record クラスの set() メソッドで下記のような箇所にたどり着く。

    public function set($fieldName, $value, $load = true)
    {
        if ($this->_table->getAttribute(Doctrine_Core::ATTR_AUTO_ACCESSOR_OVERRIDE) || $this->hasMutator($fieldName)) {
            $componentName = $this->_table->getComponentName();
            $mutator = $this->hasMutator($fieldName)
                ? $this->getMutator($fieldName):
                'set' . Doctrine_Inflector::classify($fieldName);

            if ($this->hasMutator($fieldName) || method_exists($this, $mutator)) {
                $this->hasMutator($fieldName, $mutator);
                return $this->$mutator($value, $load, $fieldName);
            }
        }
        return $this->_set($fieldName, $value, $load);
    }

    protected function _set($fieldName, $value, $load = true)
    {
        if (array_key_exists($fieldName, $this->_values)) {
            $this->_values[$fieldName] = $value;

この $value 変数にフォームの入力値が入ってくるのだが、この set() から _set() に処理が渡る際になぜか文字化けが発生していた。

なんで?と訝しんでいるとこの _set() に直接処理が渡されているわけではなく、Doctrine_Record クラスを継承した opDoctrineRecord クラスにオーバーライドした _set() メソッドの存在に行き着く。 lib/util/opDoctrineRecord.class.php の当該部分がこちら。

  protected function _set($fieldName, $value, $load = true)
  {
    // In setter, empty value must be handled as opDoctrineRecord::UNDEFINED_DATETIME
    if ($this->checkIsDatetimeField($fieldName) && empty($value))
    {
      $value = self::UNDEFINED_DATETIME;
    }

    $definition = $this->_table->getColumnDefinition($fieldName);

    // "utf8", a type of character set in MySQL, can't handle 4 bytes utf8 characters
    // so we replace such a character to "U+FFFD" (A unicode "REPLACEMENT CHARACTER").
    if (!$this->isReadyFor4BytesUtf8())
    {
      if ($this->checkIsNonBinaryStringField($fieldName))
      {
        $value = $this->replace4BytesUtf8Characters($value);
      }
    }

    return parent::_set($fieldName, $value, $load);
  }

何だよこれ。

MySQLのutf8 encoding使用時に4バイト以上のUTF8文字が入ってきた時に正しく扱われない問題があった際に備えて、4バイト以上のUTF8文字は無理矢理U+FFFDにして潰してしまう処理がここに存在していた。

何だよこれ。(大事なことなので二度)

  protected function isReadyFor4BytesUtf8()
  {
    $conn = $this->_table->getConnection();
    if ($conn->getAttribute(self::ATTR_4BYTES_UTF8_READY))
    {
      return true;
    }

    return !($conn instanceof Doctrine_Connection_MySQL);
  }

この isReadyFor4BytesUtf8() メソッドが true を返せば、このトンチキな処理をスキップしてくれるらしい。

でついにそのものズバリなやり取りを見つけたのがこちら。

redmine.openpne.jp

6年前かよ。

all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:dbname=xxx;host=xxx'
      username: xxx
      encoding: utf8mb4
      attributes: { 164: true, 999: true }
      password: xxx

というわけで、 config/databases.yml (再掲、ただしutf8mb4化版)のattributes:行に 999: true が追加になるよう指定を行う。それが冒頭の対処手順

$ sed -e "s#\\('encoding'[[:space:]]*=>\\) 'utf8'#\\1 'utf8mb4'#" -e "/Doctrine::ATTR_USE_DQL_CALLBACKS => true,/a\\ 
999 => true," ./lib/task/openpneInstallTask.class.php
$ sed -e "s#\\('encoding'[[:space:]]*=>\\) 'utf8'#\\1 'utf8mb4'#" -e "/Doctrine::ATTR_USE_DQL_CALLBACKS => true,/a\\ 
999 => true," ./lib/task/openpneFastInstallTask.class.php

これ。Doctrine::ATTR_USE_DQL_CALLBACKSというのがymlに出力されると164になるようだ。ATTR_4BYTES_UTF8_READYは999に等しい。

これであとはOpenPNE本来のインストール手順に戻れば完了。utf8mb4対応済みのOpenPNE環境が出来上がる。