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で設定した名前が出力されるようです。
※このサンプルコードは、コンパイル失敗後スタックトレースが出力されますが、こちらは端折りました