Phalcon で CLI ツールを書いてみる

# はじめに


今回は下記記事をみて phalcon でも出来るかなという感じでトライしてみました．

> Distributing a PHP CLI app with ease
http://moquet.net/blog/distributing-php-cli/


題目としてはCLI でバッチ処理用になんか作れるかという感じで考えたため「ユーザ全てに対してメールを送る」 みたいな感じのものを実装しています．


大まかな流れとしては下記を考えました．非常に単純な仕様ですね．

1. phar ファイルを起動する
2. DB 上の user テーブルを走査して email などの情報を取得する
3. メールを送る


これに加えて冒頭のリンク先のように phar 化してアップデート機能をつけてみます．






そんな感じで作ったコードは下記リポジトリにあります
https://github.com/nise-nabe/phalcon-cli-mail-sender

基本的には phalcon のコマンドラインアプリケーションの作り方と冒頭の記事をミックスした感じで作れば問題ない感じでしたが，まあ phalcon 特有の話やそれに伴って幾つか問題があったのでその説明を記事としてココに書いておきます．詳しくは上記リポジトリのコードを見てください．

今回使ったライブラリ等は下記の通りです

* Phalcon
* Swiftmailer
* Box
* Phar Update


またテーブルの中身をどこにも書いてなかったのでとりあえず下記に CREATE 文貼っときます．

```sql

CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(256) NOT NULL DEFAULT '',
  `email` varchar(256) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

# 内容


## Phalcon で CLI のコードを書く

> コマンドライン アプリケーション — Phalcon 1.3.1 ドキュメント
http://docs.phalconphp.com/ja/latest/reference/cli.html

基本的には上記に書いてあることをすれば良いです．

Phalcon 上の機構として動かすなら大まかな流れとして下記のような感じになってればいいと思います．構成とかはまあ好みで．ホントにしっかりやろうとするとちょっとしたフレームワークを Phalcon の上に乗っけて作るレベルになると思うので手を抜けるところは抜いておいたほうが良いと思います．

1. Phalcon\DI\FactoryDefault\CLI 準備する
2. Phalcon\CLI\Console 準備する
3. Console の handle() でタスクを指定して実行

エントリポイントとしては今回は main.php として書いており，大まか上記のことが行われています．



Mvc も上記と同様に DI 作って handle() するクラス作るあたりはいっしょだと思います．モデルやライブラリなどは共通のものを使うことが多いと思いますが， 今回はちょっとそこらへん考慮せず下記のように Bootstrap という CLI 用のクラスである Phalcon\CLI\Console を継承したクラスを作ってそこでいろいろ DI の登録やってます．


```php:main.php
//Create a console application
$console = new Bootstrap($di);

```

最初は services.php とかにずらずら並べて DI 登録やるほうが基本的だと思いますが， Phalcon 使ってると DI にいろいろ追加するタイミング（クラス間の処理の共通化したいとか）がたくさんあるので気づくと services.php だけずらずらとならんでめっちゃでかくなるんじゃないかなと思います．

適切な塊でうまくわけられればよさそうなんですけど，いろいろ phalcon で書かれたライブラリを見てみると CLI なら Phalcon\CLI\Console， Micro なら  Phalcon\Mvc\Micro ，Mvc なら Phalcon\Mvc\Application やら Phalcon\Mvc\Module のようなクラスを継承して中でメソッドとして切り出すみたいなことがよく行われているのかなという印象です．

Mvc と CLI で共通化して必要な部分だけ別々に分けるみたいな構成にする場合はどういうのがいいんでしょうね．

$console を作ったあとは下記のように引き数を処理して handle() に渡してタスクを動かします．

```php:main.php
/**
 * Process the console arguments
 */
$arguments = array();
$params = array();

foreach($argv as $k => $arg) {
    if($k == 1) {
        $arguments['task'] = $arg;
    } elseif($k == 2) {
        $arguments['action'] = $arg;
    } elseif($k >= 3) {
        $params[] = $arg;
    }
}
if(count($params) > 0) {
    $arguments['params'] = $params;
}

try {
    // handle incoming arguments
    $console->handle($arguments);
}
catch (\Phalcon\Exception $e) {
    echo $e->getMessage();
    exit(255);
}
```

引き数の処理については[ドキュメント](http://docs.phalconphp.com/ja/latest/reference/cli.html) の通り書いてます．他の言語のコマンドラインツールは大抵引き数の他にオプションという形で値を渡すことが出来ると思いますが， Phalcon としてはオプションを解釈する機構はついてないと思います．
やるとするなら自分で頑張って処理するか php 標準の getopt() や symfony の Console などをうまく使う方法が考えられます．
が，まあ引き数でなくてもなんとかなるので，現時点では便利さとのトレードオフとして phalcon の CLI で引き数をうまく処理するのはすっぱり諦めてしまうのも手だと思います． 




さて handle() に言ったとは渡されたは task と action を基にしてタスクを決定して実行されます．


Phalcon の Console は task や action などを指定子ない場合は main と main がデフォルトとなり MainTask の mainAction が呼ばれます．そのためコマンドをそのまま叩くと今回メール送信の処理が書かれた下記部分が呼び出されます．
（タスク名を操作に則した感じにしつつ 何も指定しない場合のデフォルト値としたい場合は handle() 時にデフォルト値を指定するか MainTask#mainAcction() を実装して forward() するなどの手があると思います．）
テキトーに実装の解説を書くと， User クラスが Phalcon\Mvc\Model を継承しており find() で全件取ってきます． swiftmailer でその name や email を用いてメールを構築して送信，送信されなければ logger でエラーログを吐くだけという感じです．



```php:src/tasks/MainTask.php
<?php

class MainTask extends \Phalcon\CLI\Task
{
    public function mainAction() {
        $users = \User::find();
        foreach ($users as $user) {
            $message = Swift_Message::newInstance('From Phalcon CLI')
                ->setFrom(array($this->config->mail->from => $this->config->mail->fromName))
                ->setTo(array($user->email, $user->email => $user->name))
                ->setBody('Hey '.$user->name.'!')
                ;
            $result = $this->mailer->send($message);
            if ($result <= 0) {
                $this->logger->error(sprintf('fail to send: {name=%s, email=%s}', $user->name, $user->email));
            }
        }
    }
}

```


## Composer のライブラリのロード方法

PHP 使うなら大抵の場合は使うと思いますが例によってここでも使っています．というかメールについてはしばらくは phalcon 上では機能追加しないという感じのことが下記イシューにかかれているため，少なくともメール送信に関してうまいことやるには外部ライブラリを使う必要があるんじゃないかなと思います．

> [NFR] Mail component · Issue #72 · phalcon/cphalcon
https://github.com/phalcon/cphalcon/issues/72
>> After further discussion, we decided not to pursue this issue for the time being.

ということでここでは swiftmailer を composer 経由でロードしています．ロードについては．自分も下記の方法を参考にして使っています．

> PHP - Phalcon のオートローダで Composer のライブラリをオートロードする - Qiita
http://qiita.com/senda-akiha/items/f6da62b3e28a264c97d9



…と言いたいところですが今回の swiftmailer で問題がありました．　swiftmailer の composer.json では下記のように Autoload 時に下記のようにファイルを読み込む方式になっており，これだと -o を使っていても classmap が生成されないため Phacon のクラスローダでは読み込むことが出来ませんでした．

```json:composer.json
    "autoload": {
        "files": ["lib/swift_required.php"]
    },
```

[phalcon の Forum の書き込み](http://forum.phalconphp.com/discussion/404/is-it-a-good-thing-to-use-composer-instead-of-the-built-in-autol) を見てみると
> Actually, an auto-loader (the process of try to realize where a path is from the class name) does not represent a major impact in performance, 

ということらしいので大丈夫なんかじゃないかなとうことで 素直に composer の autoloader を require することで解決しています．

## Phar ファイルの作成

composer は composer.phar という一つのアーカイブファイルを実行することができます．
自分のツールでも Phar にしてみましょう．そのためのするツールとして [Box](http://box-project.org/) というものがあります．



```box.json
{
	"chmod": "0755",
	"main": "main.php",
	"stub": true,
	"directories": [
		"vendor",
		"config",
		"src"
	],
	"output": "target/phalcon-cli-mail-sender-@git-version@.phar",
	"git-version": "package-version",
	"replacements": {
		"manifest_url": "http://nise-nabe.github.io/phalcon-cli-mail-sender/manifest.json"
	},
	"intercept": true
}

```


今回は composer でインストールしてあるので上記のようなファイルを作って box build を叩くと target ディレクトリ以下に phar ファイルが出力されるようにしています．

```bash
$ vendor/bin/box build
Building...
```


Phalcon の CLI も同様に box 使って phar 化してみたんですが，率直に言うとまあここがかなりハマりポイントでした．

Phar ファイルにすると必要なファイルは全部 phar ファイルの中に固められ， require などは phar:// のようなスキーマを用いたファイルパスを用いて読み込みを行いますが phalcon ではこのあたりがうまく動かないようでした．

box.json で intercept　オプション （[Phar::interceptFileFuncs](http://php.net/manual/ja/phar.interceptfilefuncs.php) あたりを使うようにするオプション）をつけてみたり phalcon の色んな所でブレークポイントいれてみていろいろ調べていくと，loader:pathFound というイベントまでは正しく動いていており，正しく phar:// も含めたパスが獲得出来ていたんですがクラスはロードされていないということがわかりました．　

そこで下記のように loader:pathFound のイベントのタイミングで偉えたパスを直接 require してしまうことで解決しています．これが正しい方法かよくわからないのでわかる人がいたら教えて下ささい．

```config/loader.php
// for Phar file
$eventsManager = new \Phalcon\Events\Manager();
$eventsManager->attach('loader', function($event, $loader, $path) {
    if ($event->getType() === 'pathFound') {
        if (Phar::running()) {
            require_once $path;
        }   
    }   
});
$loader->setEventsManager($eventsManager);
```


pathFound までは動いてるので[ココらへん](https://github.com/phalcon/cphalcon/blob/phalcon-v1.3.4/ext/loader.c#L517)が怪しいなぁとは思っていますがやる気がないのでこれ以上は何もしません．


## Phar のアップデート機構

たとえば composer.phar だと self-update を指定すると composer 自身をアップデートすることが出来ます．
これはライブラリを使ってわりと簡単に実現できます．


たぶん box 作ってる人だと思いますが下記のようなライブラリもあります．

> herrera-io/php-phar-update
https://github.com/herrera-io/php-phar-update

このライブラリを使うと phar ファイルとなっている自身を新しい phar ファイルに置き換えることができます．



この記事でのマニフェストファイルは gh-pages で適当にコミットしているものを使っています．

http://nise-nabe.github.io/phalcon-cli-mail-sender/manifest.json

```json
[
    {
        "name": "phalcon-cli-mail-sender.phar",
        "sha1": "e18dabe76ec783a08e10f8aa2b3a3b9e21369a99",
        "url": "https://github.com/nise-nabe/phalcon-cli-mail-sender/releases/download/1.1.0/phalcon-cli-mail-sender-1.1.0.phar",
        "version": "1.1.0"
    }
]

```

今回は github 上でリリースしてみました．
大まかな手順は下記のとおりです．

1. バージョンのタグを切る
2. box build する
2. github にタグを push
4. リリースページでそのタグと phar ファイルを設定してリリースする
5. manifest.json を更新

※ 今回リリースしてるものに関しては DB 設定を空にしてるので main タスクなんかは動きませんのであしからず．


> Release 1.0.0 · nise-nabe/phalcon-cli-mail-sender
https://github.com/nise-nabe/phalcon-cli-mail-sender/releases/tag/1.0.0

本当にバージョンアップできているか確認してみましょう．リポジトリには VersionTask というクラスを追加してあり， version と引き数に撮るとバージョンを表示するようにしています．

```bash
$ ./phalcon-cli-mail-sender-1.0.0.phar version
[Mon, 08 Dec 14 23:02:10 +0900][INFO] version#main: start
[Mon, 08 Dec 14 23:02:10 +0900][INFO] 1.0.0
[Mon, 08 Dec 14 23:02:10 +0900][INFO] version#main: finish
```

update コマンドを叩いてアップデートしてみます．

```bash
$ ./phalcon-cli-mail-sender-1.0.0.phar update
[Mon, 08 Dec 14 23:02:13 +0900][INFO] update#main: start
[Mon, 08 Dec 14 23:02:36 +0900][INFO] update#main: finish
```

もう一度 version タスクを実行すると 1.1.0 となっていることがわかります．


```bash
$ ./phalcon-cli-mail-sender-1.0.0.phar version
[Mon, 08 Dec 14 23:02:54 +0900][INFO] version#main: start
[Mon, 08 Dec 14 23:02:54 +0900][INFO] 1.1.0
[Mon, 08 Dec 14 23:02:54 +0900][INFO] version#main: finish
```

ファイル名なんかは変わらないので最初にバージョンのないファイル名で設置しておいて適宜 update 叩かせて更新みたいな感じの使い方になるのかな（ "curl install.php | php" のようにして中でバージョン情報を含まないファイルとしてダウンロードするなど）と思いますがここでは面倒なのでそこまでしてないです．

# おわりに

とりとめのない記事になりましたが，今回は phalcon の CLI ツールとして試しに作ってみました．
途中からは phalcon があまり関係ない話ですが phar 化してアップデートをするところまで試してみました．

作ってみたコードは下記のリポジトリになります．．
https://github.com/nise-nabe/phalcon-cli-mail-sender




# おまけ

php で CLI で作るのに使えそうなフレームワークを軽く使ってみてメモリ使用量を見てみました．

書いてみたものは下記にあります．Symfony については Event 関連のモジュールを読み込んでるので最小ではないと思いますがまあだいたいそんなもんでしょうという感じです．


| フレームワーク  | メモリ使用量 |
|:-------------|-----------:|
| Simple       |     256 KB |
| Phalcon      |     512 KB |
| Aura.CLI     |     768 KB |
| Symfony      |       2 MB |


リポジトリ: https://github.com/nise-nabe/php-cli-samples/


# 参考文献




下記の構成を phalcon に書きなおした感じのものが今回のもの

> Distributing a PHP CLI app with ease
http://moquet.net/blog/distributing-php-cli/

Phalcon の Loader で Composer のライブラリをロードすることについてのコメント

> Is it a good thing to use composer instead of the built-in Autoloader - Discussion - Phalcon Framework
http://forum.phalconphp.com/discussion/404/is-it-a-good-thing-to-use-composer-instead-of-the-built-in-autol

メール機能についての議論

> [NFR] Mail component · Issue #72 · phalcon/cphalcon
https://github.com/phalcon/cphalcon/issues/72