Scala命令行参数解析库对比

命令行是我们与程序之间最基础最朴素的交互方式,命令行参数是工具的灵魂,程序的功能有很多,指定不同的参数可以实现不同的功能。我写过无数解析命令行参数的代码,大部分都非常简单,满足基本功能要求即可。最近刚好需要实现一个相对复杂的命令行工具,简单的解析无法满足需求。因此重新考察了几个命令行参数解析库,做一下横向对比。

因为主要开发scala的程序,这里仅讨论适用于JVM的库。包括scopt、mainargs、scallop和picocli。对比下来,picocli完胜

命令行解析的需求

最开始并不清楚自己的需求到底是什么,这些库看起来都挺好,没什么区别。在实际开发的过程中,就逐渐发现这些库的缺点。好的命令行解析库应该满足下面这几点要求:

支持sub command(子命令),并且支持嵌套。 就像git一样,一个命令实现多种功能,git commitgit push等等。嵌套子命令的能力也很重要,比如我需要开发一个封装各大平台api的命令行工具,我希望的使用方式是这样的:

> cli twitter list
> cli instagram get-user
> cli unsplash random
> cli unsplash search

这样避免每个api一个单独的命令,可以给用户提供统一的体验。

参数定义复用。 一个参数可能在多个子命令中都会用到,复用参数可以减少很多重复代码。比如某个参数有非常复杂的业务逻辑,针对这个参数定义了很多校验和转换的规则,不希望在每个子命令中都重复大段的代码。

灵活表达参数之间的关系。 有些参数是互斥的,不能同时出现。比如日期参数,要么是一个独立的日期-d 2023-05-01,要么是一段日期范围-r 2023-05-01 2023-05-10,如果两者都填了那就是有问题的。另外有些参数是互相依赖的,需要一起填才有意义。

完善的参数校验逻辑。 参数校验尽可能的都通过解析逻辑本身完成,不需要再额外的验证。而且校验规则最好能自动的在usage中表达出来。如果把命令行工具分为多层,参数解析就是第一层,真正的功能逻辑在第二层或者第三层。最好在第一层就可以执行完所有的校验。避免在功能逻辑中又去校验逻辑。

完善的usage提示。 能够让用户仅仅通过 -h 参数就可以知道命令怎么用,而不再需要去查阅更多的文档,甚至是看代码才能知道怎么使用。默认值,校验规则等等。

scopt

scopt可以说是scala默认的命令行解析库,搜索到所有的文章都推荐使用scopt。从开始写scala就一直在用,所以我对scopt的缺点非常了解。简单场景scopt还是可以应对的,但是稍微复杂的逻辑scopt就力不从心了,尤其是对子命令的支持非常差劲。我经常在它的文档里面找一些我认为非常基础的某个功能,但最后发现居然没有。

Scopt需要用一个case class来承接所有的参数,比如下面这个case class。

  case class CliArg(
    cmd: String = "", // 子命令
    ds: String = "", // 日期
    configFile: File = new File(".") // 配置文件路径
  )

scopt的子命令

下面来看看如何要实现类似git的子命令。

object ScoptCli {
  def parseArg(args: Array[String]): Option[CliArg] = {
    val builder = OParser.builder[CliArg]
    import builder._

    val cmds = OParser.sequence(
      cmd("status").action((_, c) => c.copy(cmd = "status")),
      cmd("push").action((_, c) => c.copy(cmd = "push")),
      cmd("pull").action((_, c) => c.copy(cmd = "pull"))
    )

    OParser.parse(cmds, args, CliArg())
  }

  def main(args: Array[String]): Unit = {
    parseArg(args) match {
      case Some(cliArg) =>
        cliArg.cmd match {
          case "status" =>
          case "push"   =>
          case "pull"   =>
          case _        =>
        }
      case _ =>
    }
  }
}

scopt只是很简单的把子命令copy到cmd字段上。实现一个子命令,这个字符串至少需要重复3遍。当子命令多起来之后,增加一个子命令就很容易遗漏。当然可以把push pull status这些字符串都用对应的变量表示,但这样也不会减少复杂度。

另外scopt输出的usage信息让人非常蛋疼。带上-h参数时,无论这个参数出现在哪里,scopt都会把所有子命令的usage全部打出来。子命令多了之后根本找不到自己想要的内容。也有人提了这个issue:scopt#134,但是一直没有解决。

scopt的参数复用

Scopt通过functional DSL实现参数复用。但是它的文档写的非常差,在github文档中根本看不到如何使用。只有在作者博客上才能找到一点示例。

scopt的参数校验

scopt的文档中有提到checkConfig,看起来好像可以校验参数间的互斥和依赖关系:

cmd("update")
  .action((_, c) => c.copy(mode = "update"))
  .text("update is a command.")
  .children(
    opt[Unit]("not-keepalive")
      .abbr("nk")
      .action((_, c) => c.copy(keepalive = false))
      .text("disable keepalive"),
    opt[Boolean]("xyz")
      .action((x, c) => c.copy(xyz = x))
      .text("xyz is a boolean property"),
    opt[Unit]("debug-update")
      .hidden()
      .action((_, c) => c.copy(debug = true))
      .text("this option is hidden in the usage text"),
    checkConfig(
      c =>
      if (c.keepalive && c.xyz) failure("xyz cannot keep alive")
      else success)
  )

实际体验发现checkConfig是全局的,并不只针对update子命令。这是不符合预期的,规则明明写在子命令下面,却是全局生效的。虽然checkConfig可以校验互斥和依赖关系,但会带来副作用,不可靠。

再来回顾前面的5个需求点,scopt只能满足参数复用这一个条件。我首先怀疑是不是自己使用的姿势不对,但是在issue中也看到有同样的疑问。还想过基于scopt的代码改一改,但看代码发现很难扩展。

经过scopt的折磨,开始研究有没有其他替代品。每个库都有自己的逻辑,上手有一定成本。因为需求已经非常明确,所以在考察这些库的时候,为了节省时间,只要发现有一项不能满足,并且没有官方的解决方案,就会快速略过,不必把所有功能都考察完。

scallop、mainargs、decline

这三个库对子命令的支持都比scopt强一些,但每个库又都有些无法忍受的缺点。

scallop中,如果多个子命令复用一个参数,那这个参数必须出现在子命令之前。非常的不灵活。例如,我所期望的参数复用是这样的:

cli cmd1 -t <arg>
cli cmd2 -t <arg>

但实际上只能是这样的:

cli -t <arg> cmd1
cli -t <arg> cmd2

mainargs 是大神lihaoyi的作品,但它不支持嵌套子命令,也不符合需求。

decline 是基于cats的函数式的解析库。用decline实现子命令是这样的:

val cmd1 = Opts.subcommand("cmd1") {}
val cmd2 = Opts.subcommand("cmd2") {
  tailOptions
}
val cmd = Command("Cli", "Cli") {
  cmd1.orElse(cmd2)
}

和 scopt 类似,写着写着就变得非常冗长,也不支持嵌套子命令。另外基于cats实现,引入外部依赖,部署到生产环境时会遇到一些小麻烦。比如写spark应用,spark自身使用了老版本的cats。使用新版本的decline就有版本冲突。当然,这种小问题只要肯花时间一定可以解决,但我有必要在这些琐碎的事情上浪费时间吗?

Picocli

通过实际体验对比,发现上面这些库都有一些无法忍受的缺点。有些问题通过奇技淫巧可以解决,但要花点额外的时间。还有些问题是没办法解决的,只能忍着。

既然找不到完美的库,是不是可以自己造轮子呢?后来想想还是放弃了,造这个轮子的复杂度可能超过我要写的工具本身了,有点南辕北辙。而且说不定自己造出来的轮子还不如这些好用。

以上考察的都是scala原生的库,有没有其他java的库呢?后来发现了picocli,被它丰富的文档震惊了。文档其实从一个侧面说明,它充分考虑了各种复杂的场景。Github上的issue的数量也很多,说明用户也很多。这看起来是经受住了市场考验的。

Picocli的子命令

picocli子命令定义非常灵活,既可以通过类实现,也可以把类的单个方法作为子命令。想嵌套多少层都可以。最关键的是采用了标注的方式,子命令的定义和实现是在一起的,这样写出来的代码非常的清晰,没有任何的重复代码。

下面这段代码实现了一个嵌套两层子命令的工具:

@Command(name = "cli", subcommands = Array(classOf[Cmd1], classOf[Cmd2]),
  mixinStandardHelpOptions = true)
class Cli {
  @Option(names = Array("-x"))
  var x = 0
}

@Command(name = "cmd-1", description = Array("cmd-1"),
  mixinStandardHelpOptions = true, scope = ScopeType.INHERIT)
class Cmd1 {
  @Command(name = "cmd-1-a", description = Array("cmd-1-a"))
  def cmd1a(): Unit = {}

  @Command(name = "cmd-1-b", description = Array("cmd-1-b"))
  def cmd1b(): Unit = {}
}

@Command(name = "cmd-2", description = Array("cmd-2"),
  mixinStandardHelpOptions = true, scope = ScopeType.INHERIT)
class Cmd2 {
  @Command(name = "cmd-2-a", description = Array("cmd-2-a"))
  def cmd2a(): Unit = {}

  @Command(name = "cmd-2-b", description = Array("cmd-2-b"))
  def cmd2b(): Unit = {}
}

第一层子命令(cmd1、cmd2)通过类实现,第二层(cmd1a、cmd1b等)通过方法实现。

Picocli的参数复用与校验

picocli的参数复用有很多种方式。

可以通过继承复用参数。假设我们需要子命令都继承-a这个参数,可以这样实现:

class CommArgs {
  @Option(names = Array("-t"))
  var a = 0
}

@Command(name = "cmd1", description = Array("cmd1"),
  mixinStandardHelpOptions = true, scope = ScopeType.INHERIT)
class Cmd1 extends CommArgs {
  @Command(name = "cmd1a", description = Array("cmd1a"))
  def cmd1a(): Unit = {}

  @Command(name = "cmd1b", description = Array("cmd1b"))
  def cmd1b(): Unit = {}
}

继承的参数默认不会被嵌套的子命令继承,也就是只能这样用:cli cmd1 -t <arg> cmd1a。如果需要达到cli cmd1 cmd1a -t <arg>这样的效果,只需要在对应的参数定义中增加scope = ScopeType.INHERIT选项。

除了继承之外,还可以通过@Mixin实现参数复用,这种方式更加灵活。比如某个子命令需要指定配置文件参数,可以这么定义:

class ConfigFile {
  @Option(names = Array("-c", "--config"), required = true)
  var file: File = null
}

@Command(name = "cmd1", description = Array("cmd1"),
  mixinStandardHelpOptions = true, scope = ScopeType.INHERIT)
class Cmd1 extends CommArgs {
  @Command(name = "cmd1a", description = Array("cmd1a"))
  def cmd1a(): Unit = {}

  @Command(name = "cmd1b", description = Array("cmd1b"))
  def cmd1b(@Mixin configFile: ConfigFile): Unit = {}
}

在Picocli中可以用@ArgGroup来表达参数的互斥和依赖关系。比如有一组参数a b c,要么同时指定要么都不指定,可以这样实现:

class DependentArgs {
  @Option(names = Array("-a"), required = true)
  var a = 0
  @Option(names = Array("-b"), required = true)
  var b = 0
  @Option(names = Array("-c"), required = true)
  var c = 0
}

class DependentArgsMixin {
  @ArgGroup(exclusive = false)
  var args: DependentArgs = new DependentArgs()
}

@Command(name = "cmd1", description = Array("cmd1"),
  mixinStandardHelpOptions = true, scope = ScopeType.INHERIT)
class Cmd1 {
  @Command(name = "cmd1a", description = Array("cmd1a"))
  def cmd1a(@Mixin x: DependentArgsMixin): Unit = {
    println(s"${x.args.a} ${x.args.b} ${x.args.c}")
  }
}

Picocli的Usage信息

与scopt相比,Picocli的usage就非常完善了。指定-h参数只会输出单个子命令的usage。输出甚至还是带颜色的。上面的ArgGroup也在usage中体现出来。

Picocli的缺点

世界上没有任何东西是完美的,Picocli也不例外。目前比较困扰的一个问题就是在复用参数时不能动态的更新参数定义。比如同一个参数,在A命令中是必须的,但在B命令中是可选的。但是在Mixin类定义好参数后,required属性就无法修改了。又比如,同一个参数在不同子命令中的默认值可能不同,defaultValue也是一样无法修改。

目前我是定义不同的Mixin来解决,代码有少量重复。可能有更高级的用法来解决这个问题。