この記事は Symfony Advent Calendar 2017 15日目の記事です。

はじめに

弊社ではドキュメント管理ツールとして esa を利用しています。

サービス開始直後から3年間ずっとお世話になっていて、まあまあヘビーに使い込んでいるほうだと思っています。
そんな状況もあって、近頃はWeb UIでの操作だけでは痒いところに手が届かないケースがちょこちょこと出始めていました。

特に直近で困ったのが、記事を正規表現で検索したいということでした。
esaは 今のところ正規表現での検索はできない ので、大好きな symfony/console でパパッとCLIツールを作ってしまう ことにしました。

作ったもの

https://github.com/ttskch/esa-cli

esa-cliという名前ですが、いわゆるAPIをラップした感じのCLIツールではなく、少し複雑なアプリケーションとしての機能を提供するためのものです。今のところgrep(っぽいもの)しかありません。

sed(っぽいもの)も作って一括置換とかしたいな〜と思っているので、年末年始もしお暇な方がいらっしゃいましたらPRお待ちしています笑

インストール方法

動作にはPHP 7.1.3以上の環境が必要です。

$ composer create-project ttskch/esa-cli:@dev
$ cd esa-cli
$ cp app/parameters.php{.placeholder,}
$ vi app/parameters.php
<?php
// app/parameters.php
$container['parameters.team_name'] = 'esaチーム名';
$container['parameters.access_token'] = 'APIアクセストークン';
$container['parameters.esa_paging_limit'] = 10;   // 1度のAPIコールで何ページまで自動でページングするか

ページングについては こちらをご参照 ください。

$ ln -s $(pwd)/app/esa /usr/local/bin/esa

使い方

$ esa grep -h

grep サブコマンドのヘルプを確認できます。

image

  • -s [QUERY] で対象の記事群を絞り込む
  • <pattern> 部分で本文内をgrepしたいパターンを指定する
  • [QUERY] 部分の書式は esa公式 と同じ
  • -e オプションで正規表現での検索を有効にする
  • -e オプションなしの場合は単純な文字列の部分一致比較

実際の使い方は以下のような感じになります。

$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" -e "(あ|い|う){1,3}"
Users/t-kanemoto/tmp/テスト1:あ  
Users/t-kanemoto/tmp/テスト2:いい                                  
Users/t-kanemoto/tmp/テスト3:ううう
$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" -e "(あ|い|う){2}"
Users/t-kanemoto/tmp/テスト2:いい
Users/t-kanemoto/tmp/テスト3:ううう
$ esa grep -s "in:Users/t-kanemoto/tmp title:テスト" "ううう"
Users/t-kanemoto/tmp/テスト3:ううう

image

実装の要点

DIコンテナ

今回はDIコンテナに Pimple を使いました。
以下のような感じで必要なサービスを組み立てています。

https://github.com/ttskch/esa-cli/blob/master/app/container.php

いくつかのサービスが書かれていますが、symfony/console に関係するものは以下の2つです。

  • console サービス
    • ttskch/esa-cli という名称でコンソールアプリケーションを作成
    • grep_command サービスをコンソールアプリケーションに追加
  • grep_command サービス
    • GrepCommand クラスのインスタンスを作成
      • GrepCommandSymfony\Component\Console\Command\Command を継承している
<?php

// ...

$container = new Container();

require __DIR__ . '/parameters.php';

$container['console'] = function($container) {
    $console = new Application('ttskch/esa-cli');
    $console->add($container['grep_command']);

    return $console;
};

// ...

$container['grep_command'] = function($container) {
    return new GrepCommand($container['esa_proxy']);
};

// ...

return $container;

ブートストラップ

ブートストラップファイルは以下のようにDIコンテナから console サービスを取り出して起動するだけの簡素な内容です。

https://github.com/ttskch/esa-cli/blob/master/app/esa

#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
$container = require_once __DIR__ . '/container.php';
$container['console']->run();

grepコマンド本体

grep コマンド本体のコードは以下のような感じです。記事本文のMarkdownを愚直に preg_match しています。

結果の出力を スタイリングして、grepコマンドらしくマッチ箇所を赤太字にするようにしました。

https://github.com/ttskch/esa-cli/blob/master/src/Command/GrepCommand.php

<?php
// ...
class GrepCommand extends Command
{
    private $esaProxy;

    // ...

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     */
    protected function execute(InputInterface $input, OutputInterface $output): void
    {
        $query = $input->getOption('query');
        $posts = $this->esaProxy->getPosts($query, $input->getOption('force'));

        // convert pattern for preg_match
        $pattern = $input->getArgument('pattern');
        if ($input->getOption('regexp')) {
            $pattern = str_replace('\/', '/', $pattern);
            $pattern = str_replace('/', '\/', $pattern);
        } else {
            $pattern = preg_quote($pattern);
        }
        $pattern = sprintf('/%s/%s', $pattern, $input->getOption('ignore-case') ? 'i' : '');

        $matches = [];

        foreach ($posts as $post) {
            $fullName = sprintf('%s/%s', $post['category'], $post['name']);

            $i = 1;
            foreach (explode("\n", $post['body_md']) as $line) {
                $condition = preg_match($pattern, $line, $m);
                if ($input->getOption('invert-match')) {
                    $condition = !$condition;
                }

                if ($condition) {
                    $matches[] = [
                        'full_name' => $fullName,
                        'line_number' => $i,
                        'line' => $line,
                        'matched' => $m[0] ?? '',
                    ];
                }
                $i++;
            }
        }

        foreach ($matches as $match) {
            $fullName = $match['full_name'];
            $lineNumber = $input->getOption('line-number') ? $match['line_number'] . ':' : '';
            $line = str_replace($match['matched'], sprintf('<fg=red;options=bold>%s</>', $match['matched']), $match['line']);

            $output->writeln(sprintf('%s:%s%s', $fullName, $lineNumber, $line));
        }
    }
}

esa APIクライアント

symfony/console の使い方とは関係ない話ですが、polidog/esa-php をラップして結果をキャッシュしたりページングを隠蔽したりする Esa\Proxy というクラスを作ってあります。

esa APIでは

という制限があるため、コマンド実行時の -s [QUERY] で絞り込んだ結果の記事数があまりに大量だと、闇雲に自動でページングするとリクエスト数制限を簡単にオーバーしてしまいます。なので、自動ページング数の上限を定数で定義する 仕様にしてあります。

https://github.com/ttskch/esa-cli/blob/master/src/Esa/Proxy.php

<?php
// ...
class Proxy
{
    const CACHE_KEY_PREFIX = 'ttskch.esa_cli.esa.proxy';
    const CACHE_LIFETIME = 3600;

    private $esa;
    private $cache;
    private $pagingLimit;

    // ...

    /**
     * @param null|string $query
     * @param bool $force
     * @return array
     */
    public function getPosts(?string $query, $force = false): array
    {
        $cacheKey = self::CACHE_KEY_PREFIX . '.posts.' . hash('md5', $query);

        if ($force || !$posts = $this->cache->fetch($cacheKey)) {
            $posts = $this->mergePages('posts', ['q' => $query]);
            $this->cache->save($cacheKey, $posts, self::CACHE_LIFETIME);
        }

        return $posts;
    }

    /**
     * @param string $methodName
     * @param array $params
     * @param int $firstPage
     * @return array
     */
    public function mergePages(string $methodName, array $params = [], int $firstPage = 1): array
    {
        if (!in_array($methodName, ['posts'])) {
            throw new LogicException('Invalid method name.');
        }

        $results = [];
        $page = $firstPage;

        for ($i = 0; $i < $this->pagingLimit && !is_null($page); $i++) {
            $result = $this->esa->$methodName(array_merge($params, [
                'per_page' => 100,
                'page' => $page,
            ]));
            $results = array_merge($results, $result[$methodName]);
            $page = $result['next_page'];
        }

        return $results;
    }
}

おわりに

個人的に、CLIツールを作成するときに自力でUI(引数の扱い、出力の整形、ヘルプ、etc)を作り込むのが本当に面倒で不毛だと思っているので、symfony/console がその辺全部いい感じにやってくれるとコマンドの処理を書くことに集中できてとても幸せです。

今回のesa-cliでも、まともに実装したクラスは Esa\ProxyCommand\GrepCommand の2つだけでした :v:

とりあえずすぐに使いたかったのでテスト皆無なWIPプロダクトですが、symfony/console のおかげで便利なツールが簡単に作れて業務が捗りましたよ、というお話でした!(もしよければどなたかテスト書いてください)


カルテット開発部では、CLIツールもPHPで作成してしまうエンジニアを募集しています!