Edited at

HHVM/Hack : hh-clilibでCLIアプリケーション開発

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になりますので、

使い方は同じです。


src/Command/Application.php

<?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として作成します。


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を実装することもできますので

是非実装してみてください。