Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scalaをスクリプト言語にしよう! Ammonite文法最速マスター

追記(2023-04-04): Scala CLIを使いましょう

Ammoniteをベースにより洗練されたScala CLIが開発され、Scalaのデファクトスタンダードとして定着しつつあります。公式にscalaと入力した際の標準アプリケーションとなることも予見されているため、こちらを学ぶとよいでしょう。

scala-cli.virtuslab.org

tanishiking24.hatenablog.com

zenn.dev

Ammonite文法最速マスター

この記事は、Scalaのスクリプティング環境でありREPLでもあるAmmoniteの基礎的な利用方法をマスターし、ちょっと凝ったシェルスクリプトをAmmoniteスクリプトに書き換えられる程度のAmmonite力を手に入れるためのものです。

想定読者

  • Scalaの文法を理解している
  • 基本的なシェルスクリプトを書ける

Ammoniteってなに?

github.com

Ammoniteは、ScalaをLLみたいに使うためのツールです。Scalaプログラミングを行うためには、通常であればsbtといったプロジェクト管理ツールを一緒に使いますが、ちょっとしたスクリプティングやシェルスクリプトの置換として使うには大変すぎます。その一方で、Scalaを普段から利用している人ならば書き慣れたScalaでスクリプティングを行うことで、既存の知識と強力な言語機能・型システムを活用できるので有利です。Ammoniteは、このギャップを埋めるツールです。おおまかには、REPL機能・スクリプト実行機能・コマンド呼び出しやファイル操作等を透過的に行うための小さなライブラリから成り立っています。

Ammoniteを触ってみる

Ammoniteをインストールするには、公式ページの手順に従います:

$ sudo sh -c '(echo "#!/usr/bin/env sh" && curl -L https://github.com/com-lihaoyi/Ammonite/releases/download/2.5.4/2.13-2.5.4) > /usr/local/bin/amm && chmod +x /usr/local/bin/amm' && amm

もしくは、Scalaの公式インストール手順を踏んだユーザは勝手にAmmoniteもインストールされるので、何もしなくて大丈夫です:

docs.scala-lang.org

Ammoniteは、ammコマンドをユーザに提供します。ammを単独で使うとREPLが起動します:

$ amm
Loading...
Welcome to the Ammonite Repl 2.5.4 (Scala 2.13.8 Java 1.8.0_342)
@

ammのREPLでは、@がプロンプトです。アンモナイトですね!

ja.wikipedia.org

REPLにScalaの式を与えると、それが評価されます:

@ def fib(n: Int): Int = n match {
  case 1 => 1
  case 2 => 1
  case n => fib(n-1) + fib(n-2)
  }
defined function fib

@ fib(10)
res1: Int = 55

REPLは、ヒストリ機能や補完機能、カラー表示機能を備えており高機能です。Ctrl+Rといった操作にも対応します。Ctrl+DでREPLを終了できます。

@ Bye!
$

Scala script

先程はammを単独で使うとREPLが起動するという説明を行いました。そして、ammにファイルを渡すとスクリプトを実行できます:

@main
def main() = {
  println("Hello, Ammonite!")
}
$ amm hello.sc
Compiling /home/windymelt/hello.sc
Hello, Ammonite!
$

ammは、渡されたファイルを Scalaスクリプト として認識し、@mainアノテーションを持つメソッドをエントリポイントとして実行します。Scalaスクリプトは、@mainが要求されるといった小さな差異を除けば、何の変哲も無いScalaのファイルです。

Scalaスクリプトは、シェルスクリプト同様、shellbangに対応しています:

#!/usr/bin/env amm
@main
def main() = {
  println("Hello, Ammonite!")
}
$ chmod u+x hello.sc
$ ./hello.sc
Hello, Ammonite!
  • ammはScalaスクリプトを実行できる
  • Scalaスクリプトの拡張子は.sc
  • shellbangに対応している

スクリプトの引数宣言

@mainアノテーションを持つメソッドが引数を持つ場合、これはそのままスクリプトの引数を宣言したことになります:

@main
def main(name: String, n: Int) = {
  println(s"My name is $name, n is $n")
}
$ amm hello.sc
Compiling /home/windymelt/hello.sc
Missing arguments: --name <str> -n <int>
Expected Signature: main
  --name <str>
  -n <int>

$ amm hello.sc --name Windymelt -n 666
My name is Windymelt, n is 666
$

しかも、型チェックもしてもらえました!二回目以降の実行はAmmoniteがコンパイル結果をキャッシュしているので、再コンパイルは行われていないことにも注目してください。

  • エントリポイントとなるメソッドに引数を設定すると、そのままスクリプトの実行時引数を宣言できる
  • 実行時には--名前 値の形で与える(--名前=値では動かない様子)
  • 引数の型をOption[A]にすると、それが指定されなかったときはNoneがフィルインされる

import $ivy記法による依存ライブラリの自動インストール

スクリプトが外部ライブラリに依存している場合、import $ivy記法で自動的にammにライブラリをダウンロードしてもらうことができます:

import $ivy.`org.typelevel::cats-core:2.8.0`, cats._, cats.implicits._

@main
def main() = {
  // catsの機能を使える
  val lis = List(1,2,3) |+| List(4,5,6)
  println(lis)
}
$ amm script.sc
List(1, 2, 3, 4, 5, 6)

ライブラリは、以下のような順で変形した表記で指定します:

  • build.sbtlibraryDependencies += で書くときの記法から、ダブルクオートを取り除く
  • %:で置換する(%%::で置換する)
  • スペースを取り除いて、バッククオートで括る
  • import $ivy.を接頭する

元々の形:

"com.chuusai" %% "shapeless" % "2.3.3"

ダブルクオートを除去:

com.chuusai %% shapeless % 2.3.3

%:で置換する:

com.chuusai :: shapeless : 2.3.3

スペースを除去してバックオートで括り、import $ivy.を前に置く:

import $ivy.`com.chuusai::shapeless:2.3.3`

完成!

import $file記法による外部スクリプトの読み込み

import $file.Fooと書くことで、同じディレクトリにあるFoo.scの内容をFoo名前空間以下に読み込むことができます:

// Basic.sc
val basicValue = 31337
// FileImport.sc
import $file.Basic

val fileImportVal = Basic.basicValue + 1

上の階層のファイルを指定するには、^を使ってimport $file.^.Filenameのように指定します。

使用されるJVMやScalaのバージョンなどを指定する

今のところ、ammはシステムデフォルトのJVMを起動し、 固定されたバージョンのScalaを起動するようですが、 サードパーティのツールであるammonite-runnerを使うことで実行されるAmmoniteとScalaのバージョンとを固定できるようです:

// Ammonite 2.1.4, scala 2.12.11
// ↑Scalaコードよりも前にバージョンを記述する
@main
def main() = { ... }
# csはCoursierコマンド
$ cs launch \
  io.github.alexarchambault.ammonite::ammonite-runner-cli:latest.release \
  -- script.sc

しかし、このやり方だと毎回コンパイルが走るようなので、一長一短という感じ。

ちなみにammコマンドは、JAVA_OPTS環境変数を受け付けます:

$ JAVA_OPTS="-Xmx1G" amm ...

シェルスクリプトっぽくする

この節では、Scalaスクリプトで既存のシェルスクリプトを置換する際に必要になるであろう仕草を紹介します。

基礎

シェルスクリプトで使うであろう多数の機能、例えば一時ファイルの作成やファイルの削除、ディレクトリトラバーサルなどはos.名前空間に定義されています。

これはos-libライブラリによって提供されています。

外部コマンド実行

外部コマンドはos.proc(...).call()で起動し、終了するまでブロックされます:

val proc = os.proc("curl", "-sSL", "https://example.com").call()

ブロッキングさせない場合はspawn()call()の代わりに使います。

デフォルトではコマンドの出力は見えないので、call()に引数を与えることでstdin / stdoutの扱いや、cwdの扱いを変更します:

val proc = os.proc("curl", "-sSL", "https://example.com").call(stdout = os.Inherit)
$ amm curl.sc
<!doctype html>
<html>
<head>
    <title>Example Domain</title>
    ...
$

call() / spawn()には多様な引数を与えることができます。よく使うものを以下に示します:

  • stdout
    • stdout = os.Pipe
      • 親プロセスであるammに対してパイプで接続され、.stdoutといったメソッドで取り出せるようになる
      • デフォルトの挙動(stderr/stdinでも同様)
    • stdout = os.Inherit
      • 実行中のammコマンドのものを再利用する
      • 振舞いとしては、標準出力が今の標準出力に吐き出される
  • stderr
  • mergeErrIntoOut
    • trueにするとシェルスクリプトにおける2>&1と同義になる
    • デフォルトはfalse
  • stdin
  • cwd
    • コマンドが起動する際に渡される環境変数$PWDに渡すディレクトリ。これを指定するとcdしてからコマンドを実行したのと同義になる
    • Scala Scriptにはcdするという概念が無い(状態を持たせないため?)
  • check
    • コマンドが失敗(exit code != 0)した場合に例外を投げるかどうか
    • デフォルトはtrue(例外を投げる)

コマンドの出力を文字列として得る

シェルスクリプトにおける$()記法(コマンドの標準出力を文字列で得る)は、 os.proc(...).call(...).out.trimを使います:

val result = os.proc("curl", "-sSL", "https://example.com").call().out.trim

.trimしなければ、末尾の改行コードがそのまま保持されます。

また、out.linesを使うとVector[String]が得られます:

val lines = os.proc("sha256sum", "hello.sc", "photo.jpg").call().out.lines

out.bytesArray[Byte]を返します:

val bytes = os.proc("gzip", "-f", "-").call(stdin = "This text should be compressed").out.bytes

複数のコマンドを組み合わせる

val curl = os.proc("curl", "-sSL", "https://example.com").spawn()
val xmllint = os.proc("xmllint", "--html", "--xpath", "//h1/text()", "-").call(stdin = curl.stdout, stdout = os.Inherit)
$ amm curl2.sc
Example Domain
$

spawn()したコマンドのstdoutを他のコマンドをspawn/callするときにstdinとして渡すことで、パイプ同様の働きができます。 パイプラインの前段で起動するコマンドはspawn()する必要があります(call()するとそこでブロックしてしまうため)。

個人的には、このへんはモナドとかにして扱えるようになっていると面白そうだけれど、os-libcatsscalazも使っていない(そういう方針?)。アダプタを書くと面白そう。

環境変数

環境変数を扱うための仕組みは用意されていないが、sys.env.get()すればよいはず:

@ sys.env.get("HOME")
res0: Option[String] = Some(value = "/home/windymelt")

Optionで環境変数が得られるので、このあたりはシェルスクリプトよりも安全だな〜と思う。

スクリプトのパスを得る / cdする

シェルスクリプトでは、$0を使ってスクリプトがあるディレクトリにcdするという手法が頻出します。これをAmmoniteで行うにはどうすればよいでしょう?

cd $(dirname "$0")

os-libでは、$0の代わりにsourcecode.File()によって実行中のスクリプトの絶対パスを得ることができます。

val scriptPathString: String = sourcecode.File()

そして、os.upと組み合わせることでスクリプトが存在するディレクトリの絶対パスを得ることができます。

val scriptDirectory: os.Path = os.Path(sourcecode.File()) / os.up

ちなみにScalaスクリプトには「現在のディレクトリ」の概念は無いので、コマンドが実行されるときにcwdとしてディレクトリを渡すという手法を使う必要があります:

val bytes = os.proc("gzip", "file.txt").call(cwd = scriptDirectory).out.bytes

エラーハンドリング

ScalaスクリプトはScalaのコードとして実行されるので、全ての実行時エラーは例外を投げ、そこで実行は中断します(シェルスクリプトでは、set -eしない限り次のコマンドを実行します)。

@ ???
scala.NotImplementedError: an implementation is missing
  scala.Predef$.$qmark$qmark$qmark(Predef.scala:344)
  ammonite.$sess.cmd3$.<clinit>(cmd3.sc:1)

エラーを意図的に無視するには、scala.util.Tryを使うことになりそう:

@ import scala.util.Try
import scala.util.Try
@ import scala.language.postfixOps
import scala.language.postfixOps
@ Try {???} toOption
res2: Option[Nothing] = None

他のツールとのかかわり

ちなみに、Metalsの機能を使ったworksheetというものがあり、 スクリプトではなく逐行評価して様子を確認したいときにはこちらのほうが便利です。

blog.3qe.us

動機

いかがでしたか?(アフィサイト)

Ammoniteを試しに使ってみるにあたって、日本語で入手可能なbootstrap的なドキュメントが存在しなかった事、そしてAmmoniteの公式ドキュメントがちょっと古びていてそのままでは使えない箇所があったので、all-in-one的なドキュメントとして文法最速マスターを書くことにした。

具体的に古びている箇所とは、Ammonite-opsというサブモジュールは既に存在せずos-libというライブラリで置換されているが、ドキュメントではそのままになっているという点である。

これはどうするの?といった点はブコメなどで指摘いただけると幸いです。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?