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環境が出来上がる。

ECCUBE3カスタマイズについていくつかのこと:拡張エンティティをぶっこむためには既存の処理を置き換えねばならぬ

さて問題はここからである。ここからなはずなのだが、いささか中身としては地味かもしれぬ。

まあさておき。

背景

前回までで、商品情報や注文情報など既存のテーブル、エンティティに項目を追加したいときは直接テーブルやPHPコードに手を入れたりせず、プラグイン内に新規エンティティとして生成して運用せよというガイドラインを確認し、それに従ってテーブルとPHPコードを生成するところまでを進めた。

今回はそのエンティティを使ってサイト表示できるところまでもっていきたいと思う。

ダミーデータの挿入

さて、現時点ではテーブルはあっても中身がなく空なので拡張エンティティといっても無いも同然である。テストのやりようがない。というわけでダミーデータを入れておきたいと思う。

マイグレーション | EC-CUBE 開発ドキュメント

こちらの内容に沿って、ダミーデータ生成用のマイグレーションファイルを用意する。

<?php

namespace DoctrineMigrations;

use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
class Version20180524215000 extends AbstractMigration
{
    const NAME = 'plg_sampleplugin_productreview';

    /**
     * @param Schema $schema
     */
    public function up(Schema $schema)
    {
        if ($schema->hasTable(self::NAME)) {
            $this->addSql('INSERT INTO ' . self::NAME . ' (product_id, comment) VALUES (1, \'aaa\')');
        }
    }

    /**
     * @param Schema $schema
     */
    public function down(Schema $schema)
    {
    }
}

ダミーデータ登録のために一旦プラグインを再インストール、再有効化の必要あり。

$ php app/console plugin:develop uninstall --code SamplePlugin
$ php app/console plugin:develop install --code SamplePlugin
$ php app/console plugin:develop enable --code SamplePlugin

DB内容を確認。

MariaDB [eccube3]> SELECT * FROM plg_sampleplugin_productreview;
+------------+---------+
| product_id | comment |
+------------+---------+
|          1 | aaa     |
+------------+---------+
1 row in set (0.00 sec)

## 処理の差し込み: コントローラを挿げ替える

拡張エンティティを処理に連結するために必要な処理を追加したいが、ここに一つ大きな問題がある。それは、「差し込みたい場所にかならず差し込めるとは限らない」ということだ。

ECCUBE3になって、本体の処理をイベントフックという形でプラグイン側が受け取ることができるようになっているのだが、これを通して処理を受け取るためには当然のことながら本体側でイベントフックが実装されてないといけない。つまり、「欲しい場所に必ずイベントフックが仕込まれてないといけないが、だいたいにおいてそうとは限らない」という悲しい現実が待っている。

そうするとやはり本体のコードに手を入れるということが不可避になるのだが、やはりどうしてもそれは避けたいとなった場合、本体コントローラの処理をまるごと乗っ取るという判断になる。その時につくったプラグイン内のServiceProviderがこちら。

class SamplePluginServiceProvider implements ServiceProviderInterface
{
    public function register(BaseApplication $app)
    {
        // 管理画面定義
        $admin = $app['controllers_factory'];
        //Frontend
        $front = $app['controllers_factory'];
        // 強制SSL
        if ($app['config']['force_ssl'] == Constant::ENABLED) {
            $admin->requireHttps();
            $front->requireHttps();
        }

        $admin->match('/product/product/new', '\Plugin\SamplePlugin\Controller\Admin\Product\ProductController::edit')->bind('admin_product_product_new');
        $admin->match('/product/product/{id}/edit', '\Plugin\SamplePlugin\Controller\Admin\Product\ProductController::edit')->assert('id', '\d+')->bind('admin_product_product_edit');
        $app->mount('/'.trim($app['config']['admin_route'], '/').'/', $admin);

        $front->match('/products/detail/{id}', '\Plugin\SamplePlugin\Controller\ProductController::detail')->bind('product_detail')->assert('id', '\d+');
        $app->mount('', $front);

        //...
    }
}

だいたいこんな感じ。

プラグイン内に同じ名前でよいのでControllerを新造し、該当するアクションメソッドを複製する。そこに必要な処理を追加してルーティングに乗せると新造したコントローラが該当URLの処理を受け持ってくれるようになる。まったくもって力技である。なお置き換えないメソッド・URLはそのままでよく、新造するクラスは置き換える必要のあるメソッドだけ実装すればよいのは助かる。

次にServiceクラスを挿げ替える

これだけで済めばよいがそうとも限らない。RailsでいうところのHelperクラスに近いととらえればよいのだろうか、Service系のクラスに結構処理が並んでいてそこで処理の追加や差し替えがどうしても必要なケースというのが出てくることがある。こういうのがほんと困るんだ。どうしようか?

これも挿げ替えである。

        $app['eccube.service.cart'] = $app->share(function () use ($app) {
            return new \Plugin\SamplePlugin\Service\CartServiceEx($app);
        });
        $app['eccube.service.shopping'] = $app->share(function () use ($app) {
            return new \Plugin\SamplePlugin\Service\ShoppingServiceEx($app, $app['eccube.service.cart'], $app['eccube.service.order']);
        });
        $app['eccube.service.order'] = $app->share(function () use ($app) {
            return new \Plugin\SamplePlugin\Service\OrderServiceEx($app);
        });

前項でルーティングを置き換えたServiceProviderのregister()メソッドにつなげて、今度はServiceインスタンスの登録をカスタム化したクラスで置き換える。ECCUBE3では幸い?なことに、すべてのServiceクラスは直接クラス名を指定して起動しておらず、こうして置き換える…というか乗っ取ることができる。すごいのか何なのか。なおControllerと違ってServiceの場合はクラス単位で置き換えてしまうのですべてのメソッドを複製する必要がある点に注意。というか置き換え前のインスタンスを拡張側のServiceで参照して拡張の必要のない処理はその置き換え前インスタンスに委譲する形で実装してもよいかも。

あとは置き換えたプラグイン側の実装に必要な処理を追加すればよい。今回のProductReviewエンティティは元となるProductエンティティへのカラム追加と同じという体で実装するので、ProductへのSELECTやUPDATEが発生する箇所すべてに横付けする形になる。

最後にテンプレート側で

ここまでやってもカバーできないケースもある。というか、いざ表示するのにControllerで拡張エンティティを拾わせてそれをrender()メソッドに渡してテンプレートに…ってやるの、MVC分離としては正しいんだけども引数が増える一方で妙に相互依存が増してるしどうなんだろう?という気がする瞬間があるわけです。サイト側では更新もなく表示するだけとかだとなおさら。

qiita.com

これの、「Twig拡張関数を使わない場合」に沿って拡張エンティティを拾って表示するっていう実装で割とほぼ用が足りる。確かにテンプレートにDBアクセス相当の処理を埋め込んでしまうのはかなり筋悪な実相でここまで必死に守ってきたMVC分離に対する強烈なちゃぶ台返しと言えなくもない。が、ここまで大規模に加えてきた改変がいろいろとモラルハザードを引き起こしてしまっている自分に気づく。

MVC分離は確かに大事だしこの期に及んでも「守りましょうね」と言い切るんですけど、それを守るために投じられるコストがあまりにECCUBE3(のカスタマイズ)においては大きすぎるので、ものすごく悩ましい。イベントフックとかもう少し何とかならんだろうか。うーむ。

ECCUBE3カスタマイズについていくつかのこと:既存テーブルへの拡張は回避して新規テーブルをプラグイン内に作れ

前置き

ECサイト構築においてECCUBEここ10年単位ではわりとメジャーな選択肢であり続けたと思いますけども、ECCUBE3系に移行してsymfonyベースに切り替わってからはいろいろと勝手が違って戸惑うこともしばしば。

エンティティ、リポジトリ | EC-CUBE 開発ドキュメント

既存テーブルに対する拡張

基本的に既存テーブルに対してプラグインからカラムを追加するような拡張は推奨していません。

既存テーブルに対して、例えばdtb_customerテーブルにニックネームを追加したい時は、プラグイン側でplg_profileというようなテーブルを作成して関連付けをします。

  • plg_profile

かつてはECCUBEカスタマイズに際してはさして強力なフレームワークガイドラインも無かったため、本体のコードに直接手を入れて改修するということが多く行われてきた。それはそれで自由に手が入れられる気安さはあったが同時にバージョンアップの阻害要因となるなどデメリットも多く、3.x系になってsymfony/doctrineをコア部分に据えてからはプラグインという形でのカスタマイズにパラダイムをシフトさせることとなった。その結果がこれである。

まあ、仕方ないといえば仕方ないのだが、たとえば商品テーブルにひとつカラムを追加するだけでも大きく遠回りをしなければいけなくなって多少しんどくはある。

そんなわけでここではとりあえず「SamplePlugin」というほぼスケルトン状態のプラグインを作成し、そこにエンティティ1個だけを追加した状態まで持っていき、実際にどんな作業になるかを振り返ってみる。「プラグインジェネレータの利用方法 | EC-CUBE 開発ドキュメント」と、「エンティティ、リポジトリ | EC-CUBE 開発ドキュメント」ページの「エンティティファイルの配置場所」の項目を見ながら進めるよ。というかECCUBE3のプラグインについて、情報はそこそここの公式サイトに載っているようなんだけどもなぜか目的に沿ってまとめられてなくてあちこちに情報の欠片が散乱している。どうにもかゆいところに手が届かないかんじ。サイトの構成自体はこれはこれで整理されているように見えるけど、使う側の目線も少しは気にしてくれよう。

プラグインの生成

$ cd /var/www/html/eccube-3.0.16 # ←eccubeのディレクトリ
$ php app/console plugin:develop generate
------------------------------------------------------
---Plugin Generator
---[*]You can exit from Console Application, by typing quit instead of typing another word.
------------------------------------------------------

[+]Please enter Plugin Name
Input[1] : サンプルプラグイン
[+]Please enter Plugin Code (First letter is uppercase alphabet only. alphabet and numbers are allowed.)
Input[2] : SamplePlugin
[+]Please enter version (correct format is x.y.z)
Input[3] : 0.1.0
[+]Please enter author name or company
Input[4] : zvorak.oweleo
[+]Do you want to support old versions too? [y/n]
Input[5] : n
[+]Please enter site events(you can find documentation here http://www.ec-cube.net/plugin/)
Input[6] : eccube.event.app.request
--- your entry list
 - eccube.event.app.request

--- Press Enter to move to the next step ---
[+]Please enter site events(you can find documentation here http://www.ec-cube.net/plugin/)
Input[6] :
[+]Please enter hookpoint, sample:front.cart.up.initialize
Input[7] : front.cart.up.initialize
--- your entry list
 - front.cart.up.initialize

--- Press Enter to move to the next step ---
[+]Please enter hookpoint, sample:front.cart.up.initialize
Input[7] :
[+]Would you like to use orm.path? [y/n]
Input[8] : y

---Entry confirmation
[+]Plugin Name:  サンプルプラグイン
[+]Plugin Code:  SamplePlugin
[+]Version:  0.1.0
[+]Author:  zvorak.oweleo
[+]Old version support:  No
[+]SiteEvents:
  eccube.event.app.request
[+]hookpoint:
  front.cart.up.initialize
[+]Use orm.path:  Yes

[confirm] Do you want to proceed? [y/n] : y

[+]File system

 this files and folders were created.
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/ServiceProvider
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Controller
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Form/Type
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Resource/template/admin
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/config.yml
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/PluginManager.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/ServiceProvider/SamplePluginServiceProvider.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Controller/ConfigController.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Controller/SamplePluginController.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Form/Type/SamplePluginConfigType.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Resource/template/admin/config.twig
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Resource/template/index.twig
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/event.yml
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/SamplePluginEvent.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/LICENSE

[+]Database
 Plugin information was added to table [DB.Plugin] (id=3)

 Plugin information was added to table [DB.PluginEventHandler] (inserts number=1)
Plugin was created successfully

詳しくは「プラグインジェネレータの利用方法 | EC-CUBE 開発ドキュメント」を見ていただくとして。

  • [1]はプラグイン一覧で表示される表示名。判別しやすい名前を付けておく。日本語で構わない。
  • [2]はプラグインをシステムが識別する際のIDとなる文字列。先頭文字が英大文字で他は英数文字であればよい。
  • [3]は最初のバージョン番号。開発者が管理できれば特に制約はない。
  • [4]は開発者または企業の名前を入れる。
  • [5]はECCUBEの古いバージョン(3.0.9より前)をサポートするか?ということなのだが、上記の入力値では旧バージョン向けコードが特に出てこず「y」でも「n」でも同じコードしか生成されないようなのでとりあえず無視する。というか積極的にサポートするつもりがない限りこれから新造するプラグインは「n」でいいと思う。
  • [6]はサイトイベントという、ECCUBE3でカスタマイズをする際に拡張処理を差し込む場所を指定するための入力値。後からでも追加できるので今はとりあえず仮の値として入力。(空行を入力するまで複数登録できる)
  • [7]は上に似てしかし若干ことなる仕組みでフックポイントという、これもまたECCUBE3をカスタマイズする際の拡張処理の差し込み場所になる。同じく後からの追加に任せて今は仮の値。(同じく複数登録可能)
  • [8]はorm(ORマッパ、doctrineのこと)を使用するので「y」を入力。というかまあ「ormを使用しない」とよほど自信をもって答えられるのでない限り基本は「y」を入力しておく方がいいでしょう。

プラグインエンティティの生成

次はプラグインが持つエンティティの生成。先にymlを作成してこれをもとにエンティティクラスやレポジトリクラスを生成する流れで進める。

$ vim app/Plugin/SamplePlugin/Resource/doctrine/Plugin.SamplePlugin.Entity.ProductReview.dcm.yml
Plugin\SamplePlugin\Entity\ProductReview:
    type: entity
    table: plg_sampleplugin_producctreview
    repositoryClass: Plugin\SamplePlugin\Repository\ProductReviewRepository
    id:
        id:
            type: integer
            nullable: false
            id: true
            column: product_id
    fields:
        comment:
            type: text
            nullable: true
    lifecycleCallbacks: {}

この.dcm.ymlで注意するのは以下の点。

  • idカラム(product_id)は既存テーブルdtb_productのIDと一致を保つためauto incrementは入れない。
  • 関連してforeign key設定も行いたいところだがこれはTODOとして次回以降に。
  • プロダクト(dtb_product)一個に対して拡張コメント(plg_sampleplugin_productreview)一個というリレーション。
  • ファイル名は「Plugin.[プラグインコード].Entity.[エンティティ名].dcm.yml」のパターンを踏襲しておく。

ここまで出来たら、エンティティやレポジトリ等のコード生成に進行。

$ php app/console plugin:develop entity

[entity]How to generate entities from db schema or yml? [d => db, y => yml] : y
------------------------------------------------------
---Plugin Generator for Entity
---[*]You need to create yml file first.
---[*]You can exit from Console Application, by typing quit instead of typing another word.
------------------------------------------------------

[+]Please enter Plugin Code (First letter is uppercase alphabet only. alphabet and numbers are allowed.)
Input[1] : SamplePlugin
[+]Plese enter yml file name
Input[2] : Plugin.SamplePlugin.Entity.ProductReview.dcm.yml
--- your entry list
 - Plugin.SamplePlugin.Entity.ProductReview.dcm.yml

--- Press Enter to move to the next step ---
[+]Plese enter yml file name
Input[2] :
[+]Do you want to support old versions too? [y/n]
Input[3] : n

---Entry confirmation
[+]Plugin Code:  SamplePlugin
[+]Yml file name:
  Plugin.SamplePlugin.Entity.ProductReview.dcm.yml
[+]Old version support:  No

[confirm] Do you want to proceed? [y/n] : y

[+]File system

 this files and folders were created.
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Entity
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Repository
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Resource/doctrine/migration
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Entity/ProductReview.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Repository/ProductReviewRepository.php
 - /var/www/html/eccube-3.0.16/app/Plugin/SamplePlugin/Resource/doctrine/migration/Version20180521190045.php
  • [1]はプラグインのコード名。今回なら「SamplePlugin」。
  • [2]は直前に生成したyamlのファイル名を入力。これも空行を入れるまで複数登録が可能。
  • [3]はこれもプラグインコード生成時と同じく古い(3.0.9よりも前)ECCUBE3バージョンへの対応要否。積極的にサポートする意思や要請がないのであれば基本「n」でよい。

ここまで来たらマイグレーションを使ってテーブル作成ができるので試してみる。

$ php app/console plugin:develop uninstall --code SamplePlugin
$ php app/console plugin:develop install --code SamplePlugin
$ php app/console plugin:develop enable --code SamplePlugin

追加されたエンティティをECCUBEが認識するためには一度アンインストールする必要があるようなので注意。そうしないとエンティティの捜索パスから新しいエンティティを見つけることができずエラーになるようだ。enableまで出来たらDBの中身を確認しよう。

MariaDB [eccube3]> SHOW TABLES LIKE 'plg_%';
+---------------------------------+
| Tables_in_eccube3 (plg_%)       |
+---------------------------------+
| plg_sampleplugin_producctreview |
+---------------------------------+
1 row in set (0.00 sec)

MariaDB [eccube3]> EXPLAIN plg_sampleplugin_producctreview;
+------------+----------+------+-----+---------+-------+
| Field      | Type     | Null | Key | Default | Extra |
+------------+----------+------+-----+---------+-------+
| product_id | int(11)  | NO   | PRI | NULL    |       |
| comment    | longtext | YES  |     | NULL    |       |
+------------+----------+------+-----+---------+-------+
2 rows in set (0.00 sec)

これでプラグイン内に拡張用エンティティを保持する仕組みを用意することができた。PHPコード上でどのように扱うかは次回以降のエントリに任せてとりあえず今日はここまで。

EC2 retirement notification小噺

先日とある案件でEC2のretirement notificationが来たことがあって。stop & startの対応が必要になったときのこと。
いつもの調子でstopかけたところ、対象インスタンスがAuto Scaling Group配下だったのを忘れていて即座にterminateを食って新しいインスタンスを起動されてしまって軽く慌てたよねという話。

dev.classmethod.jp

自分が運用する場合はAuto Scaling配下のインスタンスには基本ローカルデータは置かないようにして、極端な話いつどこで配下のインスタンスが入れ替わったりしても問題ないような構成にしている。コンテンツファイルだってS3か編集機能を持つ管理サーバからの取得で対応だ。だから不意にインスタンスが消えても必要数が常に確保できていれば問題ない…が、そのインスタンスに乗っていたログが必要みたいな状態を放ったままうっかりterminateとかされてしまうと、それはそれで悲しい目には遭う。*1

そんなことがあって、じゃあstopしてもterminateされないためにはどうしたらいいかと調べて上記のようなページを見つけたりもした。しかし上記のような自分の運用方針からして、ここまでメンテナンス時の手間を増やしてしまうよりは、必要なデータだけさっさとサルベージしてterminate前提で放棄したほうがいいのではないかとも思った。これらの手法はこれはこれで重要な情報だし必要に応じて用いればいいが、私が実運用で積極的に使うとした場合のメリットがあまり多くないなと。

*1:まあ、そんなログはfluentdあたりに任せておけよともいうが

iOSの通信デバッグにはrvictlを使うといいよ

放置が過ぎてヤバイ。

とりあえず今回はゆえあってiPhoneの通信内容を傍受せんとす。

$ instruments -s devices
Known Devices:
mac [...]
iphone [...]
Apple TV (11.3) [...]
Apple Watch (11.3) [...]
iPad Air (11.3) [...]

[...]の中身がUDIDで、これを使うと次のrvictlでリモート仮想インターフェイスを作成してそこにtcpdump/wiresharkを差し込むことができる。

# rviX生成
$ rvictl -s XXXX....XXXX
# rviX削除
$ rvictl -X XXXX...XXXX

ただ、最近のATS(App Transport Security)の強化によって通信経路はHTTPSがデフォルトになってしまいこれだと通信内容がほとんど見られないケースの方が多くなってきたのがしんどい。開発中のデバッグくらいはTLS外せということか。はーん。

RailsアプリにOAuth2認証を実装する(Facebook編)

さて前回に引き続いて今度はFacebookからのOAuth2認証。

gem 'omniauth-facebook'

Gemfileにomniauth-facebookを追加して実装開始。

なお第一段階としてGoogle+での認証処理をそのまま横展開することで実装してみた。すなわちほとんどコピペレベルだが、動作の完遂を優先するためリファクタリングは次の段階でやる*1。一旦は目を瞑っていてくれたまえワトソン君。

app/controllers/users/omniauth_callbacks_controller.rbには下記のメソッドを追加。

  def facebook
    @user = User.find_for_facebook(request.env['omniauth.auth'])

    if @user.persisted?
      flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: 'Facebook'
      sign_in_and_redirect @user, event: :authentication
    else
      session['devise.facebook_data'] = request.env['omniauth.auth']
      redirect_to new_user_registration_url
    end
  end

続いて app/models/user.rb は下記のように変更。

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable,
         :confirmable, :lockable, :timeoutable, :omniauthable,
         :omniauth_providers => [:facebook, :google] # ←この行追加

omniauth_providersをキーに設定して:facebookと:googleを配列にしたものを値におく。

さらに user.rb のメソッドに find_for_facebook を追加。

  def self.find_for_facebook(auth)
    user = User.find_by(email: auth.info.email)

    unless user
      user = User.create(email:    auth.info.email,
                         provider: auth.provider,
                         uid:      auth.uid,
                         token:    auth.credentials.token,
                         password: Devise.friendly_token[0, 20])
    end
    user
  end

そして config/initializers/devise.rb に下記を追加。

  config.omniauth :facebook,
                  ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_APP_SECRET'],
                  name: :facebook, scope: %w(email)

これでOAuth2認証は動くようになる。ビューにはGoogle+の時と同じように下記のリンクを入れておく。

<%= link_to 'Sign in with Facebook', user_facebook_omniauth_authorize_path %>

コードの上ではこれで充分だが、肝心のFacebookでのAPPIDとシークレットの取得がちょい面倒。Google+にしろFacebookにしろ開発者向けサイトのUIが頻繁に変わるので過去の情報もかなりの確率でアテにならなかったりする。2017年8月時点でこうだったよというのを次のエントリあたりでまとめる予定なのでお楽しみに。

*1:逆に言えば認証ロジック自体はどこ相手でも違いがないということ。

RailsアプリにOAuth2認証を実装する(Google+編)

qiita.com

概ねこちらの内容でokだった。ただし、以下の点で変更か何かがあったようで注意。

  • user_omniauth_authorize_path(:google) が user_google_omniauth_authorize_path に変更されている模様
  • User.create()の引数に email: を渡さないといけない
  • User.create()の引数 name: に auth.info.name を渡しているが info が nil で届いてきてエラーになるので、name: は無指定にしてみた

あと、同じくUser.create()の引数 meta: にそのままyamlを渡すと、MySQLだとVARCHAR(255)で定義されているため長さオーバーでエラーが起きる。そのためmigrationの実施で

      t.string :users, :provider
      t.string :users, :uid
      t.string :users, :token
      t.string :users, :meta # ←ここを

としている箇所のt.stringを

      t.text :users, :meta # ←こうする

のようにして文字列長制限を緩めるか、metaの内容を削るかしたほうが良さそう。(とりあえず私はmetaを空にした)

それと、Google Appsを使用している社内向けログイン機能にするなどの場合、特定のドメインからのみのログインとするようにしたいこともある。そんな時は config/initializers/devise.rb の記述を一箇所このように追記すると良い。

  config.omniauth :google_oauth2,
                  ENV['GOOGLE_APP_ID'], ENV['GOOGLE_APP_SECRET'],
                  name: :google, scope: %w(email), hd: 'example.com'

blog.hello-world.jp.net
blog.bitjourney.com