JavaScriptでちょっと複雑なcliを作るのに便利なEnquirer

LAPRAS アウトプットリレー の...何日目だっけ?3/25の記事です! こんにちは!LAPRAS エンジニアの @rockymanobi です!

最近Node.jsでCLIを作る機会があり、その時に触ったEnquirerというライブラリが便利だったので、軽く紹介してみようというものです。ツールそのものについて軽くふれつつ、制作過程で出てきた「こんなことしたいけど、どう実現すれば良いんだろう」と試行錯誤して分かった使い方などを共有できればなと思います。

Enquirerとは

Enquirerは CLIアプリケーションにおける対話的インターフェイスの実装を楽にしてくれるライブラリです。単純なテキスト入力の受付はもちろん、リストからの選択、チェックボックス、パスワード、入力補完、など、様々な入力方式を手軽に組み込むことができます。Node.js製です。JavaScript(TypeScript)万歳!

公式サイトによるとこんなのもできるそうです(凄い!! いつ使うんだろう

類似のツールとしては先発の Inquirer.js などがあります。公式サイトに「Inquirerより速いぜ!」とあったり、Inquirerとほぼ同じような記述方式をサポートしていることから、かなり意識して作っているように見えます。どちらも利用者が多く、メンテも続いているのでどちらを使っても良いでしょう。あえて比較をするならば、Enquirerの方が多少全体や中身を把握しやすかったのと、少し凝ったことをやろうとしたときに素直そうな印象でした(個人の感想です)。

基本的な使い方

例えばこんなものを作りたい場合は...

f:id:rocky_manobi:20200326022503g:plain:w400

const Enquirer = require('enquirer');

(async ()=> {
  const question = {
    type: 'select',
    name: 'favorite',
    message: '好きな乗り物は?',
    choices: ['パトカー', '救急車', '消防車'],
  };
  const answer = await Enquirer.prompt(question);
  console.log(`僕も${answer.favorite}が好きだよ`);
})();

このようにpromptメソッドにオプションを渡してやると、それに応じた方式で入力を受け付ける描画をしたのち、ユーザの入力が完了したときにPromiseresolveしてくれます。結果はオプションnameに指定したキーにぶら下がってきます。

この要領で公式ドキュメントを見ながら使えば、大抵のことは実現できると思います(少し違う使い方もありますが後で触れます)

少し複雑なケースの実装

ここからは少しだけ複雑な要件に対応してみます。 複雑な入力と言われて最初に思い浮かぶのはポケモンバトルの選出画面です。故にここからは「ポケモンバトルの選出画面をCLIで実装するとしたら」をテーマに少しづつ進めていきたいと思います。

リストから要素を複数選択(これは単純

ポケモンの対戦は、基本的に以下のような流れで進行します。

1. ポケモン6匹でパーティを構築する
2. 対戦前にお互いにパーティを見せ合う
3. 6匹のうち3匹を選出し、3vs3で対戦

今回の対応範囲である「選出」というのはこの3番めにある「6匹のうち3匹を選ぶ」作業のことを指します。

以上を踏まえると、選出画面のCLI版は以下のようなものになりそうです。

f:id:rocky_manobi:20200326022638g:plain:w400

実装は以下のようになります。

const Enquirer = require('enquirer');

const myPokemonNames = [
  'フシギバナ',
  'リザードン',
  'カメックス',
  'ゴリランダー',
  'エースバーン',
  'インテレオン',
];

(async ()=> {
  const question = {
    name: 'selections',
    type: 'select',
    multiple: true,
    message: '誰を出す?',
    choices: myPokemonNames,
    validate: (selectedItems) => {
      if(selectedItems.length === 3){
        // true/falseを返すとOK/NGのみを表現
        return true;
      }
      // 文字列を返すとエラーメッセージになる
      return '3匹選んでください';
    },
  };
  const answer = await Enquirer.prompt(question);
  console.log(`${answer.selections.join(',')} を選出しました`);
})();

オプションmultiple: trueを渡すことで、複数選択可能なチェックボックス式の入力を受け付けます。

また、validateにバリデーション用の関数を渡すことで、ユーザ入力を検証して、不適合な入力を弾き、入力画面をキープすることができます。エラーメッセージを独自のものにしたい場合は、true/falseではなく文字列を返すようにすることで、判定をNGとしたうえで、関数が返した文字列をエラーメッセージとして表示してくれます(errorMessageってオプションあったほうがわかりやすいきがしますが)。

タイマーで入力をキャンセルする

ここまでは公式Readmeにもしっかり書いてあるので難なく対応できました。が、要件を一つ忘れていたのでここで追加します。

ポケモンの対戦では遅延行為を防ぐため、あらゆる行動に制限時間が設けられています。もちろん選出も例外ではなく、すべてのプレイヤーは1分30秒(記事執筆時点)以内で3匹のポケモンを選び出す必要があります。これに間に合わない場合は、強制的に上から順に3匹のポケモンが選出されます。

この要件をCLIに反映させてみます。

f:id:rocky_manobi:20200326022748g:plain:w400

const Enquirer = require('enquirer');

// (信じられないことに)配列 myPokemonNamesをchoicesオプションとして渡すと破壊的に配列が変更されるので、
// 違うArrayインスタンスを返すようにFunctionに包んでいる
const myPokemonNames = () =>{
  return [
    'フシギバナ',
    'リザードン',
    'カメックス',
    'ゴリランダー',
    'エースバーン',
    'インテレオン',
  ];
};

(async ()=> {
  const prompt = new Enquirer.MultiSelect({
    name: 'selections',
    message: '誰を出す?',
    choices: myPokemonNames(),
    validate: (selectedItems) => {
      if(selectedItems.length === 3){
        return true;
      }
      return '3匹選んでください';
    },
  })

  let timer;
  prompt.once('run', ()=>{
    timer = setTimeout(()=>{
      prompt.cancel()
    }, 5000)
  })
  prompt.once('close', ()=>{ clearTimeout(timer); });

  const answer = await prompt.run().catch(() => {
    // 時間切れです
    return myPokemonNames().slice(0,3);
  });

  console.log(`${answer.join(',')} を選出しました`);

})();

これまでとは少し実装方法を変えています。これまではInquirer.js風の実装をしていましたが、ここではコチラのように、ライブラリにビルトインで入っているPromptの子クラスを用いた実装にしています。

コンストラクタの形式はこれまでpromptメソッドに渡していたものに似ていますが、各クラスごとに自明なものMultipleSelectクラスにおけるtypemultiple:trueなど)が不要になっています(TypeScriptなら型チェックも効いて快適)。

この方式ではPromptクラスのrun()メソッドを実行したときにユーザの入力を受け付けるようになり、同cansel()メソッドを呼んでやることで、強制的に入力を終了させることが可能です。

PromptクラスはEventEmitterを継承しており、上記コードでは入力受付開始時のrunイベントに反応してタイマーを作動させ、一定時間経過後にキャンセルするようにしています。入力終了時(キャンセル/タイムアウト含む)に発生するcloseイベントが発生したタイミングでは、タイマーを止める処理を実行するようにしています。

最後に、プロンプトをキャンセルしたときにはPromiserejectされるので、catch節でエラーを拾って、「時間切れの場合は先頭の3匹強制選出」を示す結果を返すようにしています(ちなみにcatch節のコールバックには何も引数が入って来ません)

カウントダウンを表示する

制限時間を超過すると時間切れになるようにはできましたが、選出中に「後何秒?」が分からないのは辛いものがあります。これも対応しましょう。

f:id:rocky_manobi:20200326022819g:plain:w400

(async ()=> {
  let timeRemaining = 10;
  const prompt = new Enquirer.MultiSelect({
    name: 'selections',
    message: () => { return `誰を出す? 残り ${timeRemaining} 秒` },
    choices: myPokemonNames(),
    validate: (selectedItems) => {
      if(selectedItems.length === 3){
        return true;
      }
      return '3匹選んでください';
    },
  })

  let interval;
  prompt.once('run', ()=>{
    interval = setInterval(()=>{
      timeRemaining -= 1;
      if(timeRemaining <= 0){
        prompt.cancel()
      } else {
        prompt.render()
      }
    }, 1000)
  })
  prompt.once('close', ()=>{ clearInterval(interval); });

  const answer = await prompt.run().catch(()=>{
    console.log('時間切れです');
    return myPokemonNames().slice(0,3);
  });
  console.log(`${answer.join(',')} を選出しました`);
})();

タイマー処理をsetTimeoutsetIntervalに変えて、1秒毎にカウントダウンするように変更しつつ、メッセージに「残り n 秒」を表示させるために、以下の修正を施しています。

  • messageオプションに残り秒数を表示する文字列を返す関数を渡す
  • 1秒毎に prompt.render()メソッドを実行する

これにより、プロンプトの内容が毎秒再描画され、残り時間がカウントダウンされていく様子を表示することができました。カウントダウン部分のみを他のライブラリや独自実装などで代替しようとすると、カーソルの状態が衝突して表示がおかしくなったりするので、このあたりをサポートしてくれているのは有り難い限りです。

複数の質問をする & 前の回答を考慮して選択肢を変更する

これで完成かと思いましたが、そうはいきません。確かに選出は6匹から3匹を選び出す作業ですが、同時に「誰を先発させるか」を決める作業でもあることを忘れていました。

最初に選択する要素には特別な意味をもたせる必要がありそうなので、最初に先発を聞いて選んでもらった後に、控えの二匹を選出してもらうようにしてみます。

f:id:rocky_manobi:20200326022904g:plain:w400

(async ()=> {
  let timeRemaining = 10;
  let currentPrompt;
  let interval;

  // Enquirerインスタンスの参照が欲しいのでstaticメソッドのpromptではなく
  // new Enquirer()して、そいつのpromptメソッドを呼ぶようにする
  const enquirer = new Enquirer();
  enquirer.on('prompt', (prompt) => {
    currentPrompt = prompt;
    prompt.once('run', ()=>{
      interval = setInterval(()=>{
        timeRemaining -= 1;
        if(timeRemaining <= 0){
          currentPrompt.cancel()
        } else {
          currentPrompt.render()
        }
      }, 1000)
    });
    prompt.once('close', ()=>{ clearInterval(interval); });
  })

  const answer = await enquirer.prompt([
    {
      type: 'select',
      name: 'starter',
      message: () => { return `先発は誰にする? 残り ${timeRemaining} 秒` },
      choices: myPokemonNames(),
    },
    {
      type: 'select',
      multiple: true,
      name: 'reserves',
      message: () => { return `控えは誰にする? 残り ${timeRemaining} 秒` },
      choices() {
        return myPokemonNames().filter((name) => {
          return this.state.answers.starter !== name;
        })
      },
      validate: (selectedItems) => {
        if(selectedItems.length === 2){
          return true;
        }
        return '2匹選んでください';
      },
    }
  ]).catch(console.error);

  if (answer) {
    const selected = [answer.starter].concat(answer.reserves);
    console.log(`${selected.join(',')} を選出しました`);
  } else {
    console.log('時間切れです')
    console.log(`${myPokemonNames().slice(0,3).join(',')} を選出しました`);
  }
})();

公式ドキュメント によると、Enquirerは複数の質問を連続して表示することに対応しているようですが、Enquirer.promptメソッドにオプションの配列を渡してあげる形式にする必要があります。このままだとタイマー処理によってキャンセルすることができないので、どうにかしてPromptクラスのインスタンスを参照する必要があります。

ということをやろうとしているのが上の方にあるこの処理です。

  // Enquirerインスタンスの参照が欲しいのでstaticメソッドのpromptではなく
  // new Enquirer()して、そいつのpromptメソッドを呼ぶようにする
  const enquirer = new Enquirer();
  enquirer.on('prompt', (prompt) => {

Enquirerクラスは内部的に保持しているPromptインスタンスを処理するタイミングでpromptイベントを発火しつつPromptインスタンスを渡してくれるので、そこでこれまでのケースと同じようにイベントハンドルを仕掛けています。

先発で選んだポケモンを控えの選択肢に出さない

messageオプションなどと同様にchoicesオプションにも関数を指定することが可能です。そして、この関数内部でthis.state.answersを参照することで前の質問に対する入力の値を得ることができます。コレを利用して、控えポケモンの選択肢から、先発に選んだポケモンを除外しています。

      choices() {
        return myPokemonNames().filter((name) => {
          return this.state.answers.starter !== name;
        })
      },

その他

今回の例ではchoicesにはString配列を渡していましたが、{ name: '興梠', value: 'rocky' }のようなオブジェクトの配列を渡すことで、見た目上はnameに指定した値を表示しつつ、実際にanswerで得られるのはvalueに指定した値にする、ということも可能です(多分大体そうする)。

加えて、このようにオブジェクトを渡す方式にしている場合は、最後の例を実現するにあたってchoicesをフィルタする代わりに、各choiceのオブジェクトにdisabled: trueなどを渡すことで選択不能な状態にすることができるみたいです。

(というか複数聞きたいなら複数回prompt呼んでしまえばいいじゃないのって思ったけど違うのかな)

まとめ

無事、ポケモン選出画面の要件を満たすことができました。 技術的には以下のあたりがリポジトリ検索したり調べたりコード見てみたりしないと見えてこなかった印象があるので、実現したい方は参考にしてみると良いでしょう。(PullRequestチャンスでもある)

  • タイマーでプロンプトを終了させるためにはPrompt#cancel
  • Promptクラスの参照は、最初からPromptクラスを直接使った方法で実装するか、Enquirerクラスのpromptイベントをリスニングして降ってくるのを拾う

最後に

この謎チュートリアルはノンフィクションです(実際に勢いでポケモン対戦できるCLIを書いているときの展開をほぼそのまま再現しました)