HHVM/Hack : Building your own Micro Frameworkでは、
Webアプリケーション開発に重きを置いた内容をお届けしました。
実際の開発ではWebアプリケーション以外に
CLIアプリケーションを開発しなければならないことも多くあります。
HackでCLIアプリケーションを開発する場合はどうしたらいいのでしょうか。
hh-clilib
そんな方にオススメなのがfacebook/hh-clilib です。
これはFacebookから提供されているHack製のCLI系のライブラリは、
全てこれをベースに開発されていますので、十分商用などにも利用できます。
PHPでCLIといえばSymfonyのThe Console Componentが定番ですが、
hh-clilibは、symfony/consoleとはまた違ったHack専用の実装になっています。
今回は利用法を紹介します。
Hello World
まずは定番のHello Worldを出力してみましょう。
CLIアプリケーションの基底となるクラスはFacebook\CLILib\CLIBase
クラスです。
これを継承してアプリケーションクラスを実装します。
アプリケーション実行時に起動するのはmainAsync
メソッドになりますので、
Overrideして実装します。
*HackでOverrideするときはメソッドに<<__Override>>
と書きます
コンソールへの出力は、getStdoutメソッドから出力に渡します。
このメソッドのインスタンスは、
以前のエントリで紹介したHH\Lib\Experimental\IO\WriteHandle
になりますので、
使い方は同じです。
<?hh // strict
namespace Acme\Sample\Command;
use type Facebook\CLILib\CLIBase;
use namespace Facebook\CLILib\CLIOptions;
class Application extends CLIBase {
<<__Override>>
public async function mainAsync(): Awaitable<int> {
$stdout = $this->getStdout();
await $stdout->writeAsync("Hello, world!");
await $stdout->flushAsync();
return 0;
}
<<__Override>>
protected function getSupportedOptions(): vec<CLIOptions\CLIOption> {
return vec[];
}
}
アプリケーションクラスができたら、エントリポイントを用意します。
ここではファイル名をboot
として作成します。
#!/usr/bin/env hhvm
<?hh // strict
require_once(__DIR__.'/vendor/hh_autoload.hh');
use type Acme\Sample\Command\Application;
<<__Entrypoint>>
function main(): void {
Application::runAsync();
}
ここで下記のコマンドで実行してみましょう。
$ hhvm ./boot
Hello, world!
無事に出力されたら完了です。
非常に簡単に利用することができます。
続いて引数などを利用してみましょう。
オプションにflagを使ってみよう
オプションを利用する場合は、getSupportedOptionsメソッドをOverrideします。
CLIOptionsをvec配列げ返却すれば如何様にもできます。
先ほどのhello worldに追加してみましょう。
<?hh // strict
namespace Acme\Sample\Command;
use type Facebook\CLILib\CLIBase;
use namespace Facebook\CLILib\CLIOptions;
class Application extends CLIBase {
private int $increment = 1;
<<__Override>>
public async function mainAsync(): Awaitable<int> {
$stdout = $this->getStdout();
await $stdout->writeAsync("Hello, world!" . $this->increment);
await $stdout->flushAsync();
return 0;
}
<<__Override>>
protected function getSupportedOptions(): vec<CLIOptions\CLIOption> {
return vec[
CLIOptions\flag(
() ==> {
$this->increment++;
},
"Increment value",
'--increment',
'-i',
),
];
}
}
実行すると下記のようになります。
$ hhvm ./boot
Hello, world!1
$ hhvm ./boot -i
Hello, world!1
オプションにEnumを使う
オプションにEnumを使いたい、というケースは多いかもしれません。
hh-clilibではEnumでオプションを作ることもできます。
例えば下記の様なEnumを扱う場合はFacebook\CLILib\CLIOptions\with_required_enum関数を
getSupportedOptionsメソッドのvecで返却する様に記述します。
enum OutputFormat: string {
MARKDOWN = 'markdown';
HTML = 'html';
}
追加すると下記の通りになります。
<<__Override>>
protected function getSupportedOptions(): vec<CLIOptions\CLIOption> {
return vec[
CLIOptions\flag(
() ==> { $this->increment++; },
"Increment value",
'--increment',
'-i',
),
CLIOptions\with_required_enum(
OutputFormat::class,
$f ==> { $this->format = $f; },
Str\format(
"Desired output format (%s). Default: %s",
Str\join(OutputFormat::getValues(), '|'),
(string) $this->format,
),
'--format',
'-f',
),
];
}
with_required_enumの第一引数は、Enumを文字列で渡します。
EnumはGenericsなどで以下の様に記述されていますので、
Enumのクラス名以外は利用することができません。Hackならではと云えます。
type enumname<T> = classname<\HH\BuiltinEnum<T>>;
実行時は下記の様にEnumに記載されたオプションを使わなければなりません。
$ hhvm ./boot -f html
オプションにEnumを利用するもの以外に、オプションの値に文字列を指定する関数も用意されています。
*CLIOptions\with_required_string関数
引数必須
ここまでFacebook\CLILib\CLIBase
クラスを継承した例を紹介しましたが、
CLIアプリケーションで引数を必須したい場合はFacebook\CLILib\CLIWithRequiredArguments
クラスを継承する必要があります。
このクラス自身はFacebook\CLILib\CLIBase
クラスを継承したものですので、
大きな差はありません。
引数必須にする場合は、
getHelpTextForRequiredArgumentsメソッドを用意しなければなりません。
このメソッドは引数必須指定ではありますが、
CLI実行時に-h
を与えて実行すると現れるヘルプ画面に、
getHelpTextForRequiredArgumentsメソッドで返却されるvecの中身が表示されます。
下記の通りです。
class Application extends CLIWithRequiredArguments {
private int $increment = 1;
private OutputFormat $format = OutputFormat::HTML;
public static function getHelpTextForRequiredArguments(): vec<string> {
return vec['hello!'];
}
// 省略
}
実行時のイメージは下記のものです。
$ hhvm ./boot -h
Usage: ./boot [OPTIONS] hello! [hello! ...]
Options:
-i, --increment
Increment value
-f VALUE, --format=VALUE
Desired output format (markdown|html). Default: html
-h, --help
display this text and exit
ヘルプに表示されるだけなのでhello!
を引数に与える必要はありませんが、
引数を指定していないことでCLI実行時にエラーが発生する様になります。
$ hhvm ./boot
hello! must be specified.
引数指定のクラスを継承した場合は、引数へのアクセスは$this->getArguments()
で行えます。
これとHack Factory Pattern Explainedで紹介した例と、
Hackで作る簡単サービスロケーター / Generics
を組み合わせることで簡単なCLIアプリケーションフレームワークを作ることもできます。
雛形ライクに用意する場合は次の様にするといいかもしれません。
AbstractCommandクラス
このクラスを継承することで、様々な振る舞いを提供できる様にします。
インターフェースでも構いませんが、ここではコンスタラクタに制約を設けたいため抽象クラスを利用します。
<?hh // strict
namespace Acme\Sample\Command;
use namespace HH\Lib\Experimental\IO;
<<__ConsistentConstruct>>
abstract class AbstractCommand {
public function __construct(
protected IO\WriteHandle $write
) {}
abstract public function runAsync(): Awaitable<void>;
}
PhpCommandクラス
この例ではこのクラスしか作りませんが、
引数に合わせて実行内容が変わるサンプルのクラスです
AbstractCommandクラスを継承します。
<?hh // strict
namespace Acme\Sample\Command;
use namespace HH\Lib\Experimental\IO;
class PhpCommand extends AbstractCommand {
public async function runAsync(): Awaitable<void> {
$write = $this->write;
await $write->writeAsync('php');
await $write->flushAsync();
}
}
CLIアプリケーションクラス
次のEnumを用意します。
enum OutputFormat: string {
PHP = 'php';
}
ここで指定した値以外は利用できない様にするためのものです。
クラスのプロパティとして、下記のものも記述します。
private ImmMap<OutputFormat, classname<AbstractCommand>> $m = ImmMap{
OutputFormat::PHP => PhpCommand::class,
};
phpで取得したクラスは、AbstractCommandクラスを継承している必要があり、
かつコンストラクタでHH\Lib\Experimental\IO\WriteHandleクラスを指定しなければならない、
という制約があるもののみを利用できる様に制限する、ということになります。
これを利用することで、mainAsyncメソッドの内部でファクトリとして実行させることができる様になります。
public static function getHelpTextForRequiredArguments(): vec<string> {
return vec['command'];
}
<<__Override>>
public async function mainAsync(): Awaitable<int> {
$command = C\first($this->getArguments());
if(OutputFormat::isValid($command)) {
$commandClass = $this->m->at(OutputFormat::assert($command));
await new $commandClass($this->getStdout())|> $$->runAsync();
return 0;
}
$this->getStdout()->rawWriteBlocking('lol');
return 1;
}
下記の部分が、制限付きのコンストラクタをもつクラスのインスタンス生成を行なっている部分ですが、
上記の記述方法でなければインスタンス生成方法が保証できないためTypecheckerに怒られるパターンとなってしまいますので、
こういったパターンを取り入れたい場合は、PHPと同じ様に記述するだけでは実行ができませんので、
注意しておきましょう。
await new $commandClass($this->getStdout())|> $$->runAsync();
こうすると、下記のコマンド以外では実行できない様に実装することができます。
$ hhvm ./boot php
php
違う引数を与えると、Enumに記述されていないため lol
が返却される、ということになります。
アプリケーションクラス全体のコードは下記の通りです。
<?hh // strict
namespace Acme\Sample\Command;
use namespace HH\Lib\C;
use type Facebook\CLILib\CLIBase;
use type Facebook\CLILib\CLIWithRequiredArguments;
use namespace Facebook\CLILib\CLIOptions;
enum OutputFormat: string {
PHP = 'php';
}
class Application extends CLIWithRequiredArguments {
private ImmMap<OutputFormat, classname<AbstractCommand>> $m = ImmMap{
OutputFormat::PHP => PhpCommand::class,
};
public static function getHelpTextForRequiredArguments(): vec<string> {
return vec['command'];
}
<<__Override>>
public async function mainAsync(): Awaitable<int> {
$command = C\first($this->getArguments());
if(OutputFormat::isValid($command)) {
$commandClass = $this->m->at(OutputFormat::assert($command));
await new $commandClass($this->getStdout())|> $$->runAsync();
return 0;
}
$this->getStdout()->rawWriteBlocking('lol');
return 1;
}
<<__Override>>
protected function getSupportedOptions(): vec<CLIOptions\CLIOption> {
return vec[];
}
}
hh-clilibは今回紹介したもの以外に、REPLを実装することもできますので
是非実装してみてください。