CLOVER🍀

That was when it all began.

Scala Compilerで遊ぶ

Scalaは比較的DSLに向いている言語と言われていて、設定ファイルとかもScalaで書くといったような流れが各種著名なライブラリにはあるようです。

例えば、LiftのBoot.scalaとか…。

とはいえ、これらの設定ファイルってソースの一部ですよね。それって、都度コンパイルがいるってことじゃない?アプリケーションの再起動は仕方がないとしても、設定ファイルを変えるとコンパイルしなくちゃいけないってあんまりなんじゃ?

で、Scala CompilerってJavaVM上で動くわけで…とすれば、Scala CompilerのAPIを直接呼び出すことで動的にコンパイルとかできないものかしら…と思ってちょっと調べてみたら、やっぱりやっている人達いましたよ。

Twitterだったけどね…。

EvalなんてクラスをTwitterが作っていたので、これを参考にScala Compiler APIで遊んでみました。
https://github.com/twitter/util
https://github.com/twitter/util/blob/master/util-eval/src/main/scala/com/twitter/util/Eval.scala

目標は、以下の2つ。

まずは、Scala CompilerのAPI Documentが欲しいなぁと思い、Scala 2.9.0.1のソースからScalaDocを作成。

$ ant -projecthelp
Buildfile: /xxxxx/scala-2.9.0.1-sources/build.xml
     [echo] Forking with JVM opts: -Xms1536M -Xmx1536M -Xss1M -XX:MaxPermSize=192M -XX:+UseParallelGC 

SuperSabbus for Scala core, builds the scala library and compiler. It can also package it as a simple distribution, tests it for stable bootstrapping and against the Scala test suite.
  
Main targets:

 build              Builds the Scala compiler and library. Executables are in 'build/pack/bin'.
 … 省略 …
 docscomp           Builds documentation for the Scala compiler and library. Scaladoc is in 'build/scaladoc'.
 … 省略 …

ふむ、Antでdocscompとやらを実行すれば、作成できそうですね。というわけで、実行。

$ ant docscomp
Buildfile: /xxxxx/scala-2.9.0.1-sources/build.xml
 … 省略 …

docscomp:

BUILD SUCCESSFUL
Total time: 31 minutes 7 seconds

作成にかなり膨大な時間がかかるので、気長に待ちましょう。Nightly Buildとかのドキュメントを見た方が早いかも…。自分の環境では、30分ほどかかりました…。

できあがったScaladocは


build/scaladoc/compiler
に配置されています。

では、まずは作成した動的コンパイルのサンプルプロジェクト。
build.sbt

name := "dynamic-compiler"

version := "0.0.1"

scalaVersion := "2.9.0"

organization := "littlewings"

libraryDependencies += "org.scala-lang" % "scala-compiler" % "2.9.0"

依存ライブラリに、Scala Compilerが入っていることがポイントです。

続いて、Compiler APIを利用するクラス。
DynamicCompiler.scala

import scala.io.Source
import scala.util.Random

import scala.tools.nsc.{Global, Settings}
import scala.tools.nsc.interpreter.AbstractFileClassLoader
import scala.tools.nsc.io.VirtualDirectory
import scala.tools.nsc.reporters.ConsoleReporter
import scala.tools.nsc.util.BatchSourceFile

import java.io.File

class DynamicCompiler {
  private val SOURCE_ENCODING: String = "UTF-8"

  private val virtualDirectory: VirtualDirectory = new VirtualDirectory("[memory]", None)

  private val scalaCompilerPath: List[String] = jarPathOfClass("scala.tools.nsc.Global")
  private val scalaLibraryPath: List[String] = jarPathOfClass("scala.ScalaObject")

  private val bootClassPath = scalaCompilerPath ::: scalaLibraryPath

  private val settings: Settings = new Settings
  settings.deprecation.value = true  // 非推奨の警告を有効に
  settings.unchecked.value = true  // unchecked警告を有効に
  settings.outputDirs.setSingleOutput(virtualDirectory)  // 結果の出力先はメモリ上
  settings.bootclasspath.value = bootClassPath mkString (File.pathSeparator)
  //settings.classpath.value = bootClassPath mkString (File.pathSeparator)

  private val global: Global = new Global(settings, new ConsoleReporter(settings))  // Reporterはコンソール上に出力

  private val classLoader: AbstractFileClassLoader =
    new AbstractFileClassLoader(virtualDirectory, getClass.getClassLoader)
    // rootをメモリ上に、このクラスを読み込んだClassLoaderを親ClassLoaderに設定

  def compileClassFromFile(sourcePath: String, className: String): Option[Class[_]] = {
    val source = Source.fromFile(sourcePath, SOURCE_ENCODING)
    try {
      compileClass(className, source.mkString, sourcePath)
    } finally {
      if (source != null) source.close()
    }
  }

  def compileClass(className: String, source: String, sourcePath: String = "[dynamic compiler]"): Option[Class[_]] = try {
    val compiler = new global.Run
    compiler.compileSources(List(new BatchSourceFile(sourcePath, source)))
    
    Some(classLoader.findClass(className))
  } catch {
    case th: Throwable =>
      th.printStackTrace()
      None
  }

  def runScriptFromFile(sourcePath: String): Unit = {
    val source = Source.fromFile(sourcePath, SOURCE_ENCODING)
    try {
      runScript(source.mkString)
    } finally {
      if (source != null) source.close()
    }
  }

  def runScript(source: String): Unit = try {
    val scriptClassName = wrapScriptClassName
    val wrappedSource = wrapScript(source, scriptClassName)

    compileClass(scriptClassName, wrappedSource) foreach { clazz =>
      clazz.newInstance.asInstanceOf[() => Any].apply()
    }
  } catch {
    case th: Throwable => th.printStackTrace()
  } finally {
    virtualDirectory.clear
  }

  private def wrapScriptClassName: String = {
    val random = new Random
    "WrappedScript_" + random.nextInt(Integer.MAX_VALUE)
  }

  private def wrapScript(code: String, className: String): String = {
    """|class %s extends (() => Any) {
      |  def apply() = {
      |    %s
      |  }
      |}
      |""".stripMargin.format(className, code)
  }

  private def jarPathOfClass(className: String): List[String] = {
    val resource = className.split('.').mkString("/", "/", ".class")
    val path = getClass.getResource(resource).getPath
    val indexOfFileScheme = path.indexOf("file:") + 5
    val indexOfSeparator = path.lastIndexOf('!')
    List(path.substring(indexOfFileScheme, indexOfSeparator))
  }
}

かなりTwitterのEvalに似ていますが、気にしない。

簡単に解説します。

まず、Compiler APIを利用する時には、いくつか設定をしてあげる必要があります。これをまとめているのが、scala.tools.nsc.Settingsクラスになります。警告を有効にするとかいろいろ設定がありますが、その辺りはドキュメントでAPIを見てくださいね。

このSettingsのインスタンスに対して、コンパイルして生成したクラスの先を指定します。今回は、メモリ上に吐き出すようにするので、VirtualDirectoryを指定しています。

  private val virtualDirectory: VirtualDirectory = new VirtualDirectory("[memory]", None)

また、コンパイルするためのクラスパスの設定が必要なのですが、何も設定してあげないとScalaの基本的なクラスすら見えていないため、実行すると


scala.tools.nsc.MissingRequirementError: object scala not found.
こういう悲しいエラーを見ることになります。
そこで、まずBootstrapクラスパスに、Scala Libraryへのパスを設定します。この部分でScala Libraryへのファイルパスを検索して

  private val scalaCompilerPath: List[String] = jarPathOfClass("scala.tools.nsc.Global")
  private val scalaLibraryPath: List[String] = jarPathOfClass("scala.ScalaObject")

  private val bootClassPath = scalaCompilerPath ::: scalaLibraryPath

クラスパスと一緒にSettingsに設定します。Compilerにパスを通さなくても動くのですが、一応…。JARファイルの検索コードは、貼っているコードにあるのでそちらを見てくださいね。

  private val settings: Settings = new Settings
  settings.deprecation.value = true  // 非推奨の警告を有効に
  settings.unchecked.value = true  // unchecked警告を有効に
  settings.outputDirs.setSingleOutput(virtualDirectory)  // 結果の出力先はメモリ上
  settings.bootclasspath.value = bootClassPath mkString (File.pathSeparator)
  //settings.classpath.value = bootClassPath mkString (File.pathSeparator)

コンパイルするコードで依存するライブラリがあれば、Settings.classpathを利用すればよいと思われます。

続いて、Globalクラスのインスタンス生成。先ほど生成したSettingsクラスのインスタンスを登録しつつ、合わせてReporterも登録します。

  private val global: Global = new Global(settings, new ConsoleReporter(settings))  // Reporterはコンソール上に出力

今回はコンソール出力してくれればいいので、ConsoleReporterを利用しました。

下準備の最後として、Compilerが出力したクラスを読むためのClassLoaderを用意します。

  private val classLoader: AbstractFileClassLoader =
    new AbstractFileClassLoader(virtualDirectory, getClass.getClassLoader)
    // rootをメモリ上に、このクラスを読み込んだClassLoaderを親ClassLoaderに設定

コンパイルしたからといって、これを使わずにClass.forNameとかしても当然見つかりません。

続いて、コードをコンパイル部分へ。ぶっちゃけ、こんだけです。

    val compiler = new global.Run
    compiler.compileSources(List(new BatchSourceFile(sourcePath, source)))

先ほど作成したGlobalクラスのインスタンスから、さらにRunクラスを生成してcompile系のメソッドを呼び出します。コンパイルに成功すれば、ClassLoaderからロード可能になります。

    Some(classLoader.findClass(className))

では、動かしてみましょう。まずはコンパイルして利用する想定のコードを用意します。
HelloWorld.scala|

class HelloWorld {
  println("Hello World")
}

sbtのプロジェクト内に配置したりすると、勝手にコンパイルされてしまうので、どこか別の場所に置いておきましょう。

続いて、mainメソッドを持ったクラス。
CompileSourceRunner.scala

object CompileSourceRunner {
  def main(args: Array[String]): Unit = {
    args.headOption match {
      case None =>
        println("""|Required One or more Arguments
                   |  Usage: [SourcePath]:[ClassName]*""".stripMargin)
        sys.exit(1)
      case _ =>
        val compiler = new DynamicCompiler
	args foreach { pair =>
          val (path, className) = (pair.split(":")(0), pair.split(":")(1))
          compiler.compileClassFromFile(path, className) match {
              case Some(c) =>
                println("Compile Success[%s]".format(c.getName))
                println("---------- Call Default Constructor [%s]----------".format(c.getName))
                c.newInstance
              case None => println("Compile Fail")
          }
        }
    }
  }
}

使い方は、[コンパイル対象のソースパス]:[クラス名]でパスとクラス名を指定します。

実行すると、こうなります。

> run-main CompileSourceRunner ../HelloWorld.scala:HelloWorld
[info] Running CompileSourceRunner ../HelloWorld.scala:HelloWorld
Compile Success[HelloWorld]
---------- Call Default Constructor [HelloWorld]----------
Hello World
[success] Total time: 1 s, completed Aug 7, 2011 8:20:15 PM

続いて、スクリプトを用意します。
HelloWorldScript.scala

println("Hello World Script")

mainクラス。
ScriptSourceRunner.scala

object ScriptSourceRunner {
  def main(args: Array[String]): Unit = {
    args.headOption match {
      case None =>
        println("""|Required One or more Arguments
                   |  Usage: [ScriptSourcePath]*""".stripMargin)
        sys.exit(1)
      case _ =>
        val compiler = new DynamicCompiler
	args foreach (compiler.runScriptFromFile(_))
    }
  }
}

実行すると、こうなります。

> run-main ScriptSourceRunner ../HelloWorldScript.scala      
[info] Running ScriptSourceRunner ../HelloWorldScript.scala
Hello World Script
[success] Total time: 1 s, completed Aug 7, 2011 8:22:03 PM

スクリプトを実行する場合は、コード片自体をクラスで包んでおき、そのapplyメソッドを呼び出すようにして動かしています。もちろん、Evalをパクりました(笑)。

この部分ですね。

  private def wrapScript(code: String, className: String): String = {
    """|class %s extends (() => Any) {
      |  def apply() = {
      |    %s
      |  }
      |}
      |""".stripMargin.format(className, code)
  }

もっと汎用化しようとするといろいろ大変だと思いますが…まあ、やってみるとけっこう面白かったですわ。

なお、コンパイルに失敗するコードを渡すと、こうなります。

class HelloWorld {
  println("Hello World" + )
}
> run-main CompileSourceRunner ../HelloWorld.scala:HelloWorld
[info] Running CompileSourceRunner ../HelloWorld.scala:HelloWorld
../HelloWorld.scala:2: error: missing arguments for method + in class String;
follow this method with `_' if you want to treat it as a partially applied function
  println("Hello World" + )
                        ^

ConsoleReporterを利用しているので、コンパイルエラーがコンソール上に出力されます。エラーの先頭には、BatchScriptFileで設定した名前が出力されるようです。
※このサンプルコードは、コンパイル失敗後スタックトレースが出力されますが、こちらは端折りました