この記事は 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
サブコマンドのヘルプを確認できます。
-
-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:ううう
実装の要点
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
クラスのインスタンスを作成-
GrepCommand
はSymfony\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\Proxy と Command\GrepCommand の2つだけでした
とりあえずすぐに使いたかったのでテスト皆無なWIPプロダクトですが、symfony/console
のおかげで便利なツールが簡単に作れて業務が捗りましたよ、というお話でした!(もしよければどなたかテスト書いてください)