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

iTunesで現在再生中の曲をslackに投げるMacアプリをswiftで組んだ話

仕事場でiTunesからの自動選曲を使ってBGM流してるので、再生中の曲をslackで確認できるようにするbotを作った時の話。

github.com

これを書いた前後でswift 3に移行したりしたのでコードの内容が中途半端なんだけどもそこは見なかったことに。

このネタの肝は以下の二つ。

  • DistributedNotificationCenter経由でiTunesからの再生中トラックの変更通知を受け取る
  • 受け取った通知から曲情報を取り出してslack APIを叩く

再生中のトラックの変更通知

qiita.com

にほぼまま倣い。

        DistributedNotificationCenter.default().addObserver(self,
                                                                                                selector: #selector(AppDelegate.onChangeTrack(_:)),
                                                                                                name: Notification.Name(rawValue: "com.apple.iTunes.playerInfo"),
                                                                                                object: nil)

曲情報の取得

    func onChangeTrack(_ notification: Notification?) {
        let userInfo = (notification as NSNotification?)?.userInfo
        print(userInfo ?? "")
        
        if (userInfo!["Player State"] as! String) == "Playing" {
            guard let
                displayLine0: String = userInfo!["Display Line 0"] as? String,
                let displayLine1: String = userInfo!["Display Line 1"] as? String,
                let storeURL: String = userInfo!["Store URL"] as? String else {
                    return
            }

NotificationのuserInfoに諸情報が収納されているのだが、このDictionaryオブジェクト内に「どの情報がどのキーで」収められているのかの明確な定義情報がどうやら見当たらない。なので上の実装は「とりあえずのもの」である。

userInfo["Player State"]が"Playing"かどうかで場合分けをしているのも、これ以外の状態が入った通知が飛んできた場合にも通知を受け取ってしまって予期しないslack送信にならないようにしたものだが、この辺の挙動も明確なドキュメントがないのが辛いところ。

slack APIへの送信

            let hookURL = UserDefaults.standard.string(forKey: "hookURL")!
            let params: [String: Any] = [
                "channel": UserDefaults.standard.string(forKey: "channel")! as AnyObject,
                "username": "nowplayingbot" as AnyObject,
                "text": String(format: "%@ %@", arguments: [formatter.string(from: Date()), displayLine0]) as AnyObject,
                "attachments": [[
                    "title": displayLine1,
                    "title_link": httpStoreURL // 上で取得したstoreURLのschemeを"itms://"から"http://"に差し替えたもの
                    ]]
            ]
            let request: NSMutableURLRequest = NSMutableURLRequest(url: URL(string: hookURL)!)
            
            request.httpMethod = "POST"
            request.addValue("application/json", forHTTPHeaderField: "Content-Type")
            request.addValue("application/json", forHTTPHeaderField: "Accept")
            do {
                request.httpBody = try JSONSerialization.data(withJSONObject: params, options: JSONSerialization.WritingOptions.init(rawValue: 2))
            } catch {
                // Error Handling
                print("NSJSONSerialization Error")
                return
            }
            session.dataTask(with: request as URLRequest, completionHandler: { data, response, error in
                // code
            }).resume()

まず、このhookURLというのはslackで用意される「Custom Integrations」「Incoming Webhooks」を使って生成する。このAPI hookなど認証情報が絡む箇所はUserDefaultsに逃して(さらに初期plistファイルをgitignoreに入れてリポジトリに入らないようにして)ある。

で、生成したNSURLRequestインスタンスにbodyとしてJSON化したparamsを付与しPOST送信。ご覧の通り送信完了時及びエラー時処理は簡易実装のため入れてない点は許して。

qiita.com

なおparamsでご覧の通りattachmentsを使っているのでちょっとリッチなメッセージを送ることができる。

f:id:zvorak:20170704155847p:plain

たまに訳のわからない選曲がぶっこまれても安心。