15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Chatopts のための node.js のコマンドオプションパーサーを選定する

Last updated at Posted at 2017-10-08

背景

例えばSlackからChatoptsで何かを実行するときに、最初は正規表現マッチで単純な情報だけを抽出してという形で簡単にできるのですが、使っているうちにあれもやりたいこれもやりたいとなって、正規表現がどんどん複雑化していくともうメンテできない、ぐぬぬってなってしまいます。

要はCLIでコマンドを実行するくらい、複雑なことになりえるので、じゃあそれ用のコマンドラインのオプションパーサーを使いましょうという発想になります。

ところが、CLIのためのコマンドオプションパーサーは、Chatoptsでよく利用される常駐型のbotへのメッセージに対するパースでのユースケースが考慮されて作られているわけではないので、様々な問題があります。

例えば、間違ったオプションがついていたときに、自動でhelpを表示し、exitするようなインターフェースしかもたないライブラリがあります。これは、CLIでは親切な挙動かもしれないのですが、常駐型のbotでは余計なお世話になります。

Chatoptsのためのbotからの要求

そこで、今回調査する項目ですが、

  • 例題の要件を満たすコマンドラインパーサーが実現できること
  • コマンドラインパーサーインスタンスがシングルトンではなく、いくらでも生成できること
  • 間違ったオプションのパース時にexitしないこと
  • helpがstdoutにではなく、文字列として取得できること

になります。

今回調べるライブラリ

ぐぐったり、npmjsで調べたりをしてnodeの一般的なコマンドラインパーサーを調べてみました。

  • yargs
  • commander
  • minimist
  • nopt
  • meow
  • nomnom
  • coa

http://www.npmtrends.com/yargs-vs-commander-vs-minimist-vs-meow-vs-nomnom-vs-coa
みると、上位3つが少し他を引き離してますね。
とりあえず yargs,commander,minimistに関して調査してみます。

例題

@bot create-todo <title> [remarks] --due <due> [--assign <tomorrow>]

[]はオプションという意味です

というような感じのTODO作成コマンドをそれぞれのライブラリで上記の要求を満たしつつ実装できるかを調査します。

yargs

シングルトンではないパーサーでのパース

require("yargs") だけだと、シングルトンでパーサーインスタンスが生成されるので、
require("yargs/yargs")() します。
インスタンス作成時に、require("yargs/yargs")(process.argv.slice(2)) して、最後にargvでパース結果を取得するやり方もあるのですが、
.parse(process.argv.slice(2))するほうが今回のユースケースにあってます。
parseの方は、ソースを読む限りは並列的にparseが走っても競合しないように見えます。

const parseCallback = (exitError, parsed, output) => {
  if (exitError) {
    throw new Error(output)
  }
}

const parser =
  require('yargs/yargs')()
    .usage("Usage: <title> [remarks] [Options]")
    .option("due", {
      demandOption: true
    })
    .option("assign")
    .check(args => {
      if (args._.length < 1 || args._.length > 2) {
        return false
      } else {
        args.title  = args._[0]
        args.remark = args._[1]
        return true
      }
    })
    .version(false)

let args
try {
  args = parser
           .parse(["this is a title", "this is a remark", "--due", "tomorrow"], parseCallback)
} catch(err) {
  // parse error 時の対応を行う
}
console.log(args)

console.log(argv)
// { _: [ 'this is a title', 'this is a remark' ],
//   help: false,
//   due: 'tomorrow',
//   '$0': 'yargs.js',
//   title: 'this is a title',
//   remark: 'this is a remark',
//   assign: undefined }

arguments に関するAPIが見つからなかったので、checkを使って無理やりチェックおよび、argvのpropertyに代入しています。
ただし、このcheckに対して、Human Readableなdescriptionをつけることができません。checkが失敗すると、error 時の output にコードが無慈悲に出力されます。。

// ...snip...
           .parse(["--due", "tomorrow"], paseCallback)
} catch(err) => {
  console.error(err.message)
}

// Usage: <title> [remarks] [Options]
// 
// オプション:
//   --help  ヘルプを表示                                                    [真偽]
//   --due                                                                   [必須]
//
// 引数のチェックに失敗しました: args => {
//       if (args._.length < 1 || args._.length > 2) {
//         return false
//       } else {
//         args.title  = args._[0]
//         args.remark = args._[1]
//         return true
//       }
//     }

まぁHuman Reaableなfunctionを用意しておいて、 .check( _ => titleShouldExistAndRemarksIsOptional(_)) とかってするくらいしかできないでしょうね。。

間違ったオプションのパース時にexitしないこと

parseCallbackparseで指定することにより、勝手にexitしなくなります。
parseCallback内でexitErrorがあるときに、例外を投げることで、パースエラーをexitせずにハンドリングすることができます。

help文字列を取得する

showHelpというメソッドは、名前の通りコンソールに出力しますので、有用ではありません。また、helpを表示する前に、parseを行おうとしますので、前回のパース結果によっては不必要な表示がされてしまうことがあります。

そこで、--help を明示的に parse するというワークアラウンドで解決できます。。

const helpCallback = (exitError, parsed, output) => {
  throw new Error(output)
}

// ...snip...
           .parse(["--help"], helpCallback)
} catch(err) => {
  console.error(err.message)
}

// Usage: <title> [remarks] [Options]
// 
// オプション:
//   --help  ヘルプを表示                                                    [真偽]
//   --due                                                                   [必須]

--help をつけた場合はエラーではないので、exitErrorはないです。先程のparseCallbackと混ぜてもいいと思います。

ただし、このhelpはwindowSizeが最大80までとして表示されてしまいます。
したがって、Slackに長いhelpが投稿されると、いろいろ折り返されてつらくなります。。
そういった場合は、parserに対して .wrap(null) とか設定してやると折り返しされなくなります

yargs のまとめ

  • 上記3つの要求は一応publicなAPIで対応できた
  • だが、例外処理機構を使わないといけないところがあるので若干きれいではない(特にhelp)
  • argumentsに関するAPIが弱く、パーサーに関する宣言的な記述以外にユーザに示す情報を別で保持しないといけない

commander

シングルトンではないパーサーでのパース

require("commander")としてしまうと、シングルトンのパーサーが返ってくるので、Commandクラスを取得して毎回newしてあげるとよいです。

const Command = require("commander").Command
const program = new Command()

program
  .arguments('<title> [remarks]')
  .action((title, remarks, args) => {
    args.title   = title
    args.remarks = remarks
  })
  .option("--due <due>")
  .option("--assign [assign]")

const args = program
              .parse(["", "", "this is a title", "this is a remark", "--due", "tomorrow"])

if (args.args.length > 2 + 1 || typeof args.title === "undefined" || typeof args.due === "undefined") {
  // See the reason of +1: https://github.com/tj/commander.js/issues/386#issuecomment-94589519
  // parse error のハンドリングをする
}
console.log(args)
// Command {
//   commands: [],
//   options:
//    [ Option {
//        flags: '--due <due>',
//        required: -7,
//        optional: 0,
//        bool: true,
//        long: '--due',
//        description: '' },
//      Option {
//        flags: '--assign [assign]',
//        required: 0,
//        optional: -10,
//        bool: true,
//        long: '--assign',
//        description: '' } ],
//   _execs: {},
//   _allowUnknownOption: false,
//   _args:
//    [ { required: true, name: 'title', variadic: false },
//      { required: false, name: 'remarks', variadic: false } ],
//   _name: '',
//   _events:
//    { 'command:*': [Function: listener],
//      'option:due': [Function],
//      'option:assign': [Function] },
//   _eventsCount: 3,
//   rawArgs:
//    [ '',
//      '',
//      'this is a title',
//      'this is a remark',
//      '--due',
//      'tomorrow' ],
//   due: 'tomorrow',
//   args: [ 'this is a title', 'this is a remark', [Circular] ],
//   title: 'this is a title',
//   remarks: 'this is a remark' }

arguments に関しては、yargsと同様にparse後に自前での処理が必要になります。
また、parseの内部で引数をslice(2)するので、CLIからの入力を直接入れる場合は便利なのですが、こういう用途では、ダミーのデータをいらないといけないので見通しが悪くなります。

間違ったオプションのパース時にexitしないこと

上の例にあるように、実は commander<title> というような形で必須パラメータは定義できるものの、バリデーションはしてくれませんので自前でする必要があります。

しかし一方で知らないオプションが渡ってきたときはエラーを吐きつつ無慈悲にexitしてくれます。そこで上の例のように .allowUnknownOption(true)をつけておくとexitはしなくなります。その場合、自前で知らないオプションがあったらエラーにするか、知らないオプションがあっても気にしないというような実装になってしまします。

個人的にはtypoが多いため明示的にフィードバックしてくれるとありがたいので、知らないオプションをわざわざエラーにする方向が好みです。
その場合、commanderを利用すると、自前で実装することが多くなってしまい大変です。。

help文字列を取得する

commanderにおいてhelp文字列の取得はhelpInformationというメソッドから取得できます。これは簡単ですね

console.log(program.helpInformation())
// Usage:  [options] <title> [remarks]
//
//
// Options:
//
//   --due <due>
//   --assign [assign]
//   -h, --help         output usage information

commanderのまとめ

  • 上記3つの要求は素直に対応できた
  • ただし arguments にたいするaction周りにバグがある
  • argumentsにかぎらずvalidationを自前で実装しないといけないので大変すぎる

commanderは、arguments周りのバグ?があるのと、validationをやってくれないので自前での実装が多くなってしまうという特徴があります。でも一応機能は満たせています。

minimist

minimistはそもそもものすごいシンプルなparserなので、parseしてから自前でなんとかやる感じになります。なので、結果的にまぁやりたいことはなんでもできるんだが、結局helpの文字列をつくるところまで含めて全部やる必要があるので、これ使うくらいだったらはじめから全部実装してもいいのでは感ありますw

まとめ

どれもあわないけど、yargsが一番手数少なくやりたいことができそうだった

15
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?