🥰

Scala のプログラムのインタラクティブなデバッグ方法あれこれ

2023/10/13に公開

Scala のプログラムのインタラクティブなデバッグ方法あれこれを紹介する.

次のようなパッケージを開発しているとしよう.

src/main/scala/com/example/Lib.scala
package com.example
object Lib {
  def doSomething(): Int = 42
}
build.sbt
ThisBuild / organization := "com.example"

val lib = project.in(file("."))
  .settings(
    scalaVersion := "3.3.1",
    version := "0.1.0-SNAPSHOT",
    libraryDependencies ++= Seq(
      "org.scalameta" %% "munit" % "1.0.0-M7" % Test
    )
  )

予習

  • sbt shell: 通常のシェル(zsh、fish や bash)で sbt と入力したときに開始される sbt のインタラクティブなインターフェース.
  • sbt console: sbt shell から呼び出せる Scala REPL 環境

以下の例ではコードブロックの行頭が sbt> と書かれている場合 sbt shell 内でのコマンド実行を、scala> と書かれている場合は Scala REPL 内でのコマンド実行を意味する.

sbt console

小さなプログラムであれば手っ取り早いのは sbt console(REPL) に入って実際にプログラムを動かしてみる方法.

build.sbt があるディレクトリで sbt console コマンドを呼ぶ.

sbt console
scala>import com.example.Lib
scala>Lib.doSomething()
res0: Int = 42
scala>

複数行の入力

scala>:paste
// Entering paste mode (ctrl-D to finish)

object DeepThought {
  val theAnswer = 42
}
// ctrl-D

ファイルの読み込み

// DeepThought.scala
object DeepThought {
  val theAnswer = 42
}
scala>:load DeepThought.scala

sbt console でデバッグ: 初期化コマンドの設定

console/initialCommands を設定すれば sbt console の初期化処理を追加できる.
毎回特定のモジュールやライブラリやインポートする場合は設定しておくと楽.

// build.sbt
val lib = project.in(file("."))
  .settings(
    scalaVersion := "3.3.1",
    version := "0.1.0-SNAPSHOT",
    console / initialCommands := "import java.nio.file._",
    libraryDependencies ++= Seq(
      "org.scalameta" %% "munit" % "1.0.0-M7" % Test
    )
  )

sbt console でデバッグ: サブプロジェクト

特定のサブプロジェクトのデバッグをしたい場合は sbt shell 内で projects コマンドを実行しサブプロジェクトの名前を確認し projectname/console とすればいい.

例えば core、contrib サブプロジェクトがあって、contrib サブプロジェクトのコードをインポートできる Scala REPL に入るには sbt contrib/console コマンドを実行する.

├── build.sbt
├── contrib/src/main/scala/...
├── core/src/main/scala/...
└── project
build.sbt
lazy val core = project.in(file("core"))
  ...
lazy val contrib = project.in(file("contrib"))
  ...

sbt console でデバッグ: Pros & Cons

Pros

  • 楽. セットアップや(ローカル)リリースが必要ない.
  • 期待する動作がそこまで定まっていなくてもいい.

Cons

  • パッケージやアプリケーション全体をデバッグするのには向かない.
  • JS や Native ではデバッグできない.
  • コンパイルが通っている必要がある(consoleQuick を使えば回避できる)

sbt test & sbt testOnly

src/test/scala 以下にテストを書いて動作確認する.

src/test/scala/com/example/Test.scala

package com.example

class Test extends munit.FunSuite {
  test("Lib.doSomething returns 42") {
    assertEquals(Lib.doSomething, 42)
  }
}

sbt shell でテストコマンドを打つことで実行できる.

sbt>test

test コマンドは全てのテストを実行する.
testOnly コマンドを使って特定のテストケースだけ実行することも可能.

sbt>testOnly com.example.Test

ScalaTest を利用している場合 -- -z <pattern> オプションを指定することでパターンにマッチするテストだけ実行できる.

sbt>testOnly com.example.Test -- -z doSomething

testQuick コマンドで失敗したテストケースだけ再実行することもできる.

sbt>testQuick

watch

~ をコマンドの前につけるとソースコードの変更を監視して、変更があれば自動でコマンドを再実行してくれる.

sbt>~testOnly com.example.Test

test でデバッグ: Pros & Cons

Pros

  • JS や Native でも動作確認できる.
  • console より IDE のサポートを活用できる.

Cons

  • コンパイルを通さないといけない.
  • テストを書かないといけない.
    • ユニットテストが書きにくいコードベースだと辛い.
  • ある程度期待する動作が定まっている必要がある.

sbt publishLocal

パッケージをローカルに配布して Scala CLI や ammonite からデバッグする.

publishLocal コマンドを実行すると、~/.ivy2/local/com.example/lib/0.1.0-SNAPSHOT のようなローカルのファイルシステム上にパッケージを配布できる.

sbt
sbt>publishLocal

ローカルに配布した後は通常のパッケージのように sbt の libraryDependencies や Scala CLI の using dep ディレクティブ、ammonite のマジックインポートで利用できる.

sbt project の場合

build.sbt
libraryDependencies ++= Seq(
  "com.example::lib:0.1.0-SNAPSHOT"
)

Scala CLI の場合

debug.scala
//> using dep "com.example::lib:0.1.0-SNAPSHOT"
import com.example.Lib

@main
def run = println(Lib.doSomething())
scala-cli debug.scala
42

ammonite REPL の場合

@ import $ivy.`com.example::lib:0.1.0-SNAPSHOT`
@ import com.example.Lib
@ Lib.doSomething()
res0: Int = 42
@

Private Maven Repository に配布されたパッケージをデバッグ

Private Maven Repository にパッケージを配布&Private Maven Repository からパッケージを取得してローカルでデバッグする例

Google Artifact Registry に配布する場合

以下では GCP の asia-northeast1 の example-project プロジェクトに example という artifact registry がある想定で説明する. GitHub Packages なども同様のアイディアでできる.

sbt-gcs-resolver を使って GCP Artifact Registry にパッケージを配布する

project/plugins.sbt
addSbtPlugin("org.latestbit" % "sbt-gcs-plugin" % "1.8.0")
build.sbt
ThisBuild / organization := "com.example"

val publishSettings = Seq(
  publishTo := Some(
    "My Maven Artifact Registry" at "artifactregistry://asia-northeast1-maven.pkg.dev/example-project/example"
  )
)

val lib = project.in(file("."))
  .settings(
    scalaVersion := "3.3.1",
    version := "0.1.0-SNAPSHOT",
    libraryDependencies ++= Seq(
      "org.scalameta" %% "munit" % "1.0.0-M7" % Test
    )
  )
  .settings(publishSettings)

自分のアカウントに artifact registry へのアクセス権限がある場合は、以下のようにしてデフォルト認証情報を設定すればプラグインがよしなに認証してくれる.

gcloud auth application-default login
sbt publish

サービスアカウントのファイルを指定する場合は以下のように設定する.

build.sbt
googleCredentialsFile := Some(new File("<your-account-file>"))

sbt project から Private パッケージの取得

project/plugins.sbt
addSbtPlugin("org.latestbit" % "sbt-gcs-plugin" % "1.8.0")
build.sbt
resolvers += "My Maven Artifact Registry" at "artifactregistry://asia-northeast1-maven.pkg.dev/example-project/example"

libraryDependencies ++= Seq(
  "com.example::lib:0.1.0-SNAPSHOT"
)

Scala CLI から Private パッケージの取得

asia-northeast1 の example-project プロジェクトの example registry に接続する.

以下では、Google Artifact Registry へのアクセス権限があるサービスアカウント mypkg-user@example-project.iam.gserviceaccount.com がある想定で説明する.

認証情報を設定する.

cat <service-account-credentials>.json| tr -d '\n' | base64   
scala-cli --power config repositories.credentials "asia-northeast1-maven.pkg.dev" value:_json_key_base64 value:eyXXInR5cXXiOiXXc2.....

設定を間違えた場合は以下のコマンドでリセットできる.

scala-cli --power config --remove repositories.credentials

認証情報をハードコードしたくない場合は value:eyXXInR5cXXiOiXXc2..... の部分を env:<ENV_VAR_KEY> に変える. こうすると環境変数 <ENV_VAR_KEY> からよしなに読み取ってくれる.

scala-cli --power config repositories.credentials "asia-northeast1-maven.pkg.dev" value:_json_key_base64 env:MVN_PKG_CREDENTIAL
export MVN_PKG_CREDENTIAL=eyXXInR5cXXiOiXXc2.....

これで https://asia-northeast1-maven.pkg.dev/examle-project/example からパッケージを取得できるようになる.

//> using repository "https://asia-northeast1-maven.pkg.dev/examle-project/example"
//> using dep "com.example::lib:0.1.0-SNAPSHOT"

import com.example._

@main
def run =
  println(Lib.doSomething())

Refs

Discussion