🥰

scala native ことはじめ その2 json/csv を成形するCLI を作ろう

2021/09/07に公開

https://zenn.dev/110416/articles/e3234fdc2d1aa7

この記事の続き.

百聞は一見に如かずというわけで scala native を使って簡単な CLI を作ってみよう.

以前 scala 3 と graal native で作ったものを scala 2.13.6, scala native に移植する. また、下の記事のように json table も表示できるように拡張する.

https://zenn.dev/uzimaru0000/articles/look-at-json-pretty

つまり次のような csv, json を下のようにイイ感じに成形する小さなアプリケーションを scala native で作る.

test.csv
id,name,icon,comment,favorite
1,tama,😼,meow,🐟
2,pochi,🐶,bowwow,🦴
$ prettytable test.csv
id|name |icon|comment|favorite
==+=====+====+=======+========
1 |tama |😼  |meow   |🐟
--+-----+----+-------+--------
2 |pochi|🐶  |bowwow |🦴
prettytable test.json
website        |name                      |email                      |username          |company|id|address|phone
===============+==========================+===========================+==================+=======+==+=======+=======================
"hildegard.org"|"Leanne Graham"           |"Sincere@april.biz"        |"Bret"            |...    |1 |...    |"1-770-736-8031 x56442"
---------------+--------------------------+---------------------------+------------------+-------+--+-------+-----------------------
"anastasia.net"|"Ervin Howell"            |"Shanna@melissa.tv"        |"Antonette"       |...    |2 |...    |"010-692-6593 x09125"
---------------+--------------------------+---------------------------+------------------+-------+--+-------+-----------------------
"ramiro.info"  |"Clementine Bauch"        |"Nathan@yesenia.net"       |"Samantha"        |...    |3 |...    |"1-463-123-4447"
---------------+--------------------------+---------------------------+------------------+-------+--+-------+----------------------

ヘッダーはansi カラーで色を付ける.

また、ヘルプコマンド prettytable --help で使い方が分かるようにする.

prettytable --help

help
Usage:
    pretttable [--no-header] <FILENAME>
    pretttable version

pritty print table in terminal

Options and flags:
    --help
        Display this help text.
    --no-header
        determine whether or not to use the first line of csv file as header row.
        The header is decorated with bold horizontal separator.

Subcommands:
    version
        display version

コードは以下のレポジトリに置いてある.

https://github.com/ItoYo16u/prettytable-native

ながれ・セットアップ

大まかな流れは以下のようになる.

  1. declineというライブラリを使ってコマンド引数をパースする
  2. 引数から指定したファイルを読み込む
  3. csv を小奇麗に出力する

コマンド引数のパーサーには decline を、ansi color には fansi を、ファイル読み込みには os-lib というライブラリを使う.
decline はなかなか使い勝手がいいが少し癖があるので使い方の解説を実装の前に挟む. 興味がない方は実装まで斜め読みしてもらえばいい.

decline は scala native を一応サポートしているものの scala native 用のアーティファクトが公開されていないようなのでローカルに落としてビルドする. 以下の手順を踏んで ivy ローカルキャッシュに decline_native0.4_2.13 を生成しておく.

git clone git@github.com:bkirwi/decline.git
cd decline
sbt
> +declinenative/compile
> +declinenative/publishLocal

準備ができたらsbtを使ってプロジェクトを作成する.

sbt new scala-native/scala-native.g8
cd <projectdir>
vim build.sbt
build.sbt
scalaVersion := "2.13.6"

nativeLinkStubs := true

libraryDependencies ++=Seq(
        "org.typelevel" %%% "cats-core" % "2.6.1",
        "com.lihaoyi"  %%% "os-lib"       % "0.7.2",
        "com.monovore"  %%% "decline" % "2.1.0",
        "com.lihaoyi"  %%% "fansi"        % "0.2.10",
  )

enablePlugins(ScalaNativePlugin)

decline について

まず先に decline について説明しよう.

decline は 関数型のコマンド引数のパーサーで、合成可能なdslライクな文法でdryに書くことができる.

ネットを回遊していたら Free Applicative を使ったコマンドラインパーサーの日本語記事を見つけたのでついでに貼っておく.

https://halcat.org/scala/freeap/index.html

さて、コマンドは複数の引数・オプションから構成される.

decline では引数・オプションを Opts で表現する.

cat example.txt のような単純な引数は Opts.argumentで表現する. 型パラメタにはプリミティブな型以外にもjava.time.Duration ,java.time.LocalDate,ライブラリを使えば refined type や enumeratum なども渡せる.

val file = Opts.argument[Path](metavar="file")

command example1.txt example2.txt ...と複数の引数を指定する場合は以下のようにする.

val files = Opts.arguments[String]("file")
// files: Opts[cats.data.NonEmptyList[String]] = Opts(<file>...)

metavar はコマンドのヘルプに次のように表示される.

command <file>

引数をとらないフラグは次のように設定する.

val hogeFlag = Opts.flag("flagname",help="description")
// Opts[Unit] = Opts(--flagname)

Boolean に変換したい場合は次のように map する.

val hogeFlag = Opts.flag("flagname",help="description")
    .map(_ => true)
    .withDefault(false)

オプション(--ではじまり引数をとるフラグ)は次のように設定できる.

val exampleOption = Opts.option[T](long="longname",short="s",metavar="arg",help="do something awesome")

これは以下のような, T 型にキャスト可能な引数をとるコマンドを表現する. 引数のパースエラーはdeclineがよしなにやってくれる.

例えば、TIntのときにcommand --longname "a" を渡すと、Left(Help(errors=List("Invalid integer: a"),... を返す.

> command --longname argument
// short-hand で書くと
> command -sargument

Scala プログラムに渡されるとそれぞれSeq("--longname","argument"),Seq("-sargument") になる.

オプションからコマンドを作ろう.

オプションを受け取ってなんらかの処理をするプログラムを考える.

def main(args:Seq[String]):Unit = {
  val cmd = Command.apply("command-name","header description",true)(exampleOption)
  cmd.parse(args) match {
    case Right(t) => // do something with t of type T
    case Left(help) => // print help message or do something to give error feedback to user
  }
}

上のコードのように Command.parse(args) はコマンドを構成するオプションに応じて Either[Help,T] を返す.

オプションは合成可能だ.

val option1: Opts[Int] = ???
val option2: Opts[String] = ???
val options = (option1,option2).mapN{(a,b)=> ???}
// Opts[???]
val tupledOptions = (option1,option2).tupled
// Opts[(Int,String)]
val commandFromTupled = Command("command-name","header description",true)(tupledOptions)

commandFromTupled.parse(args) match {
  case Right(intValue,strValue) => ???
  case Left(help) => ???
}

本当は入出力の副作用を cats-effect の IO で表現したかったのだが scala native には対応していないので諦めて素直に Unit を使う.
(IOが使えたところで Haskell と異なり副作用を明示するには外部のライブラリが必要な点、型シグニチャが純粋でも内部で副作用を起こしてしまえる(かつ追跡できない)点は Scala の辛さかもしれない.)

実装

本題に戻ろう. 以下のようにコマンドパーサーをガっと書く.

ファイル名をコマンドの引数とし、--no-header をオプションフラグとする.
また、 --version でバージョン情報を表示する.

command --no-header example.csv
command --version

上のような使い方を想定している.

package commands

import com.monovore.decline.{Command, Opts}
import cats.syntax.all._

sealed trait CmdOptions extends Product with Serializable

final case class ShowCmdOptions(fileName: String) extends CmdOptions

final case class VersionCmdOptions() extends CmdOptions

final case class GlobalFlags(noHeader: Boolean)

object Commands {
  val flags: Opts[GlobalFlags] = {
    val noHeaderOpt = booleanFlag(
      "no-header",
      help = "determine whether or not to use the first line of csv file as header row.\n" +
        "The header is decorated with bold horizontal separator."
    )
    noHeaderOpt.map(noHeader => GlobalFlags(noHeader))
  }

  val filePathName = Opts.argument[String]("FILEPARH")

  val show: Opts[CmdOptions] =
    Opts.argument[String]("FILENAME").map(ShowCmdOptions.apply)
  val version =
    Command(
      name = "version",
      header = "display version"
    )(Opts.unit.map(_ => VersionCmdOptions()))

  val commands: Opts[CmdOptions] = show.orElse(Opts.subcommand(version))

  val appCmd: Opts[(GlobalFlags, CmdOptions)] = (flags, commands).tupled

  private def booleanFlag(long: String, help: String): Opts[Boolean] =
    // if args:Seq[String] contains "--<long>" returns true, else false
    Opts.flag(long = long, help = help).map(_ => true).withDefault(false)
}

次にエラーを扱いやすくするために sealed class であらわそう.
想定されるエラーは例えば、1. 与えられたパスがファイルではないケース、2. csv のパースに失敗するケースがある.
余談だがアプリケーションレイヤーの例外とシステムレイヤーのエラーをうまく分けるとコードが奇麗になることが多い.

sealed trait ShowCSVError extends Product with Serializable {
  def msg: String
}
final case class IsNotFile(path: Path) extends ShowCSVError {
  override def msg: String =
    s"User error: $path is not a file."
}
final case class ParseError() extends ShowCSVError {
  override def msg: String = s"Parse error:  failed to parse file"
}

次にファイルからテーブルを生成する処理をガっと書く. Table は Row から、Row は Cell から成る.

 // ※ csv を Table オブジェクトであらわす

def genTable(noHeader:Boolean,cmd: ShowCmdOptions): Either[ShowCSVError, Table] = {
    val path = os.pwd / cmd.fileName
    if (os.isFile(path)) {
      // file => string => Table
      // Either[ShowCSVError,Table] を返す処理
      ???
    } else {
      IsNotFile(path).asLeft
    }
  }

String => Table へのパースは以下のような処理になる.(一部抜粋)

 var columnWidths = MArrayBuffer[Int]()
    var rows = MArrayBuffer[Row]()
    os.read.lines.stream(filePath).foreach { line =>
      val fields = line.stripLineEnd.split(",")
      if (columnWidths.isEmpty) {
        columnWidths = MArrayBuffer.fill[Int](fields.length)(0)
      }
      var cells = MArrayBuffer[Table.Cell]()
      fields.zip(columnWidths).zipWithIndex.foreach {
        case ((field, columnWidth), idx) =>
          if (field.length > columnWidth) {
            columnWidths.update(idx, field.length)
          }
          cells += Table.Cell(field)
      }
      rows += Row(cells.toArray)
    }
    val table = Table(columnWidths.toSeq, hasHeader, rows.toSeq)
    Right(table)

あとは prettyprint するためのメソッドとエントリーポイントを書けばいい.

  def prityPrint(): Unit = {
    val header = rows.headOption
      .flatMap { row => if (hasHeader) Some(row) else None }
      .fold(new StringBuffer("")) { row =>
        val sb = new StringBuffer()
        sb.append(Color.LightBlue(row.prettyFormat(columnWidths)))
        sb.append(
          columnWidths
            .map(width => fansi.Color.LightBlue("=").toString * width)
            .mkString(fansi.Color.LightBlue("+").toString())
        ).append(lineEnd)
        sb
      }
    val styled = rows
      .drop(if (hasHeader) 1 else 0)
      .take(if (hasHeader) rows.length - 2 else rows.length - 1)
      .foldLeft(new StringBuffer("")) { (acc, row) =>
        acc.append(row.prettyFormat(columnWidths))
        val rowSep =
          columnWidths.map(width => rowSeparator * width).mkString("+")
        acc.append(rowSep).append(lineEnd)
        acc
      }
      .append(rows.last.prettyFormat(columnWidths))
      .append(lineEnd)
    print(header.append(styled))
  }
  def main(args: Array[String]): Unit = {
    val command = Command(
      name = "pretttable",
      header = "pritty print table in terminal",
      helpFlag = true
    )(Commands.appCmd)
    val usecase = new GenerateTableUsecase()
    val route = new Route(usecase)
    command.parse(args) match {
      case Right((globalFlags, commandOptions)) =>
        dispatchCmd(globalFlags, commandOptions, route)
      case Left(help) => println(help)
    }
  }

(少しDDDぽい雰囲気のコードにハマっていた時に書いたコードを移植しているのでそこはかとなくエンタープライズfizzbuzz の香りがするのはご容赦願いたい. )

あとは sbt run してやれば、target/scala2.13 以下にバイナリが生成される.

これで csv が pretty print 出来るようになった. 同様にして json 版も実装しよう.

まとめ

graal native × Scala 3 で書いた時と比べて素直に Scala コードを書けばすぐに動くアプリケーションが作れるのはさすが Scala native といったところである. graal native の場合はいくつかの設定ファイルやコンパイルオプションを渡してやらないとビルドに失敗していた.

Scala native を使えばこのようにサクッと CLI ツールを作ることもできる. AWS lambda や GCP cloud functions とも食い合わせがよさそうですね(^ω^) さあ、あなたも Scala 使いになって Scalable なプログラムを書こう(^ω^)

面白かった、参考になったらサポートしていただけると励みになります❤

もし需要がありそうなら c++ や Rust とのバインディングについても書こうかなと思っている.

Discussion