原文はこちら。
The original entry was written by Jiří Maršík.
https://medium.com/graalvm/improve-react-js-server-side-rendering-by-150-with-graalvm-58a06ccb45df
GraalVMは、JavaScriptを含む数多くの人気のある言語をサポートする、ハイパフォーマンスな仮想マシンです。廃止対象のNashornエンジンでJavaScriptを動かしている場合、ECMAScriptの優れた互換性をもたらすGraalVMをチェックしてください。Nashornからの簡単な移行方法も用意しています。
Migration Guide from Nashorn to GraalVM JavaScript
https://github.com/graalvm/graaljs/blob/master/docs/user/NashornMigrationGuide.md
このエントリでは、React.jsをサーバーサイドで稼働するようにNashornで既述された、既存のWebアプリケーションを例に、GraalVMに移植する方法をご紹介します。アプリケーションの移行は正確性を損なわずに簡単であり、実際にJavaScriptコンポーネントのパフォーマンスが向上する点を確認いただけることでしょう。
Talkyard.io — a React.js client application with server-side features, written in Scala and TypeScript
本日は、Talkyardを例にします。これはStackOverflowやDiscourse、Slack、その他のオンラインプラットフォームにヒントを得た、機能セットを持つディスカッションのためのWebサイトです。
Talkyard
https://www.talkyard.io/
Webサイトの利用者は質問を投稿したり、回答を投げたりできますし、お気に入りの投稿に対して投票したり、ダイレクトメッセージを交換したりできます。アプリケーションのサーバーサイドはPlayフレームワークで実装されており4万4千行のScalaコードにのぼります。クライアントサイドはReact.jsで実装されており、JavaScriptに変換される2万7千行のTypeScriptのコードが含まれています。

TalkyardのようなWebアプリケーションはReact.jsのようなリッチなクライアントサイドUIフレームワークを使っており、ページロードが遅くなるというリスクがあります。利用者のブラウザがページをレンダリングするために、ブラウザはまずアプリケーションコードをダウンロードし解析して実行する必要がありますが、アプリケーション開発者はサーバーサイドで事前にレンダリングし。すぐに表示できるHTMLをコードとともにクライアントに提供することで、この作業を回避できます。これはつまりクライアントが最初にアプリケーションコードを実行せずにページをレンダリングできるということですが、アプリケーションコードを実行してクライアントサイドのビューを進化させることが可能ゆえ、ページはまだリアクティブです。しかし、サーバーサイドのReact.jsのレンダリングを活用できるアプリケーションの場合、JavaScriptコードをサーバーサイドで実行できる必要があります。幸運にもJavaエコシステムの場合にはJavaScript実行のための複数の選択肢があります。このエントリではそのうちの2個、NashornとGraalVMを取り上げます。
NashornはJDK 8から搭載されているJavaScriptエンジンで、TalkyardがReact.jsのサーバーサイドレンダリングを実現するために現在利用しています。GraalVMはJVM上に作られた言語ランタイムで、JVMベースの言語だけでなく、JavaScriptを含む、その他のプログラミング言語も効率よく実行できます。このエントリではTalkyardのGraalVMへの移植について取り上げます。この移行を検討する理由が2個あります。
- GraalVMのJavaScriptは(Nashornに比べ)ピークパフォーマンスに優れている
- NashornはJDK 11で廃止された
JEP 335: Deprecate the Nashorn JavaScript Engine
https://openjdk.java.net/jeps/335
Migrating the application
The interface between the TalkyardアプリケーションとNashorn JavaScriptエンジン間のインターフェースは、Nashornと呼ばれる1個のScalaモジュール (Nashorn.scala) で完全にカプセル化されています。
Nashorn.scala
https://github.com/debiki/talkyard/blob/master/app/debiki/Nashorn.scala
このモジュールに対し、NashornではなくGraalVM JavaScriptエンジンを利用するために必要な変更を加えていきます。
| -import javax.{script => js} | |
| +import org.graalvm.{polyglot => graalvm} |
まず、ScriptEngine APIの代わりにGraalVM SDK APIを使用します。
Package org.graalvm.polyglot
https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/package-summary.html
GraalVM JavaScriptはScriptEngine APIをサポートします。適切な場合、これは通常、NashornアプリケーションをGraalVMに移植する最も簡単な方法なのですが、以下でご覧いただくように、この特定のコードベースは非依存ではありません。つまりScriptEngineが使用され、JavaScript実装がNashorn固有のクラスのインスタンスを返すと想定しています。この種のコードは、それぞれが内部で異なるデータ構造を使用するため、異なるScriptEngine間で互換性がありません。一方、GraalVM SDK APIには、JavaScriptランタイムの実装の詳細を知らなくてもJavaScriptオブジェクトにアクセスできる汎用インターフェイスが含まれています。
ScriptEngineを使ってJavaScriptランタイムのインスタンスを参照するかわりに、GraalVM SDK APIでGraalVM Contextを使います。
| - private def makeJavascriptEngine(): js.ScriptEngine = { | |
| + private def makeJavascriptEngine(): graalvm.Context = { |
GraalVM Contextのインスタンスを作成するには、Context.Builderを使って、アクセスしたい言語を指定します(今回の場合、JavaScriptを意味する js を指定します)。そしてNashornの場合と同じように、JavaScriptコードがJVMにアクセスできるように指定します。
| - val newEngine = new js.ScriptEngineManager(null).getEngineByName("nashorn") | |
| + val newEngine = graalvm.Context.newBuilder("js").allowAllAccess(true).build |
Context作成後、アプリケーションコードをロードしてセットアップし、ページの描画リクエストを処理できるようにする必要があります。GraalVM Contextは一般に様々な言語でコードを実行できるため、どの言語を使うか指定する必要があり、その点で以下のコードがわずかに冗長になっています。
| - newEngine.eval(script) | |
| + val scriptSource = graalvm.Source.newBuilder("js", script, "setup").buildLiteral | |
| + newEngine.eval(scriptSource) |
GraalVM Contextが準備できたので、Nashornのときと同じように関数を実行できます。
| - val htmlOrError = engine.invokeFunction( | |
| - "renderReactServerSide", reactStoreJsonString, cdnOrigin.getOrElse("")).asInstanceOf[String] | |
| + val htmlOrError = engine.getBindings("js").getMember("renderReactServerSide") | |
| + .execute(reactStoreJsonString, cdnOrigin.getOrElse("")).asString |
最後に、Scalaコードから直接JavaScriptオブジェクトにアクセスする箇所があります。Nashornの場合、変数の結果はNashornのScriptObjectMirror型であり、GraalVMの場合、GraalVMのValue型です。以下のスニペットは、JavaScriptオブジェクトの形状を照会し、そのメンバーにアクセスするためのインターフェースの両者間での違いを示しています。
| - dieIf(!result.isArray, "Not an array") | |
| - dieIf(!result.hasSlot(0), "No slot 0") | |
| - dieIf(!result.hasSlot(1), "No slot 1") | |
| - dieIf(result.hasSlot(2), "Has slot 2") | |
| + dieIf(!result.hasArrayElements, "Not an array") | |
| + dieIf(result.getArraySize != 2, "Array of size != 2") | |
| - val elem0 = result.getSlot(0) | |
| - dieIf(!elem0.isInstanceOf[String], ...) | |
| - val safeHtml = elem0.asInstanceOf[String] | |
| + val elem0 = result.getArrayElement(0) | |
| + dieIf(!elem0.isString, ...) | |
| + val safeHtml = elem0.asString | |
| - val elem1 = result.getSlot(1) | |
| - dieIf(!elem1.isInstanceOf[ScriptObjectMirror], ...) | |
| - val mentionsArrayMirror = elem1.asInstanceOf[ScriptObjectMirror] | |
| + val elem1 = result.getArrayElement(1) | |
| + dieIf(!elem1.hasArrayElements, ...) | |
| + val mentionsArrayMirror = elem1 | |
| val mentions = ArrayBuffer[String]() | |
| var nextSlotIx = 0 | |
| - while (mentionsArrayMirror.hasSlot(nextSlotIx)) { | |
| - val elem = mentionsArrayMirror.getSlot(nextSlotIx) | |
| - dieIf(!elem.isInstanceOf[String], ...) | |
| - mentions.append(elem.asInstanceOf[String]) | |
| - nextSlotIx += 1 | |
| - } | |
| + while (nextSlotIx < mentionsArrayMirror.getArraySize) { | |
| + val elem = mentionsArrayMirror.getArrayElement(nextSlotIx) | |
| + dieIf(!elem.isString, ...) | |
| + mentions.append(elem.asString) | |
| + nextSlotIx += 1 | |
| + } |
これで、NashornからGraalVM JavaScriptへのアプリケーション移植は完了です。完全な差分は以下からご覧頂けます。
| diff --git a/app/debiki/Nashorn.scala b/app/debiki/Nashorn.scala | |
| index 13f9f2eb4..bdc2604a7 100644 | |
| --- a/app/debiki/Nashorn.scala | |
| +++ b/app/debiki/Nashorn.scala | |
| @@ -22,8 +22,7 @@ import java.io.{BufferedWriter, FileWriter} | |
| import com.debiki.core._ | |
| import com.debiki.core.Prelude._ | |
| import java.{io => jio} | |
| - | |
| -import javax.{script => js} | |
| +import org.graalvm.{polyglot => graalvm} | |
| import debiki.onebox.{InstantOneboxRendererForNashorn, Onebox} | |
| import org.apache.lucene.util.IOUtils | |
| import play.api.Play | |
| @@ -31,7 +30,6 @@ import play.api.Play | |
| import scala.concurrent.Future | |
| import scala.util.Try | |
| import Nashorn._ | |
| -import jdk.nashorn.api.scripting.ScriptObjectMirror | |
| import org.scalactic.{Bad, ErrorMessage, Good, Or} | |
| import scala.collection.mutable.ArrayBuffer | |
| @@ -62,14 +60,9 @@ class Nashorn(globals: Globals) { | |
| /** The Nashorn Javascript engine isn't thread safe. */ | |
| private val javascriptEngines = | |
| - new java.util.concurrent.LinkedBlockingDeque[js.ScriptEngine](999) | |
| + new java.util.concurrent.LinkedBlockingDeque[Option[graalvm.Context]](999) | |
| - private val BrokenEngine = new js.AbstractScriptEngine() { | |
| - override def eval(script: String, context: js.ScriptContext): AnyRef = null | |
| - override def eval(reader: jio.Reader, context: js.ScriptContext): AnyRef = null | |
| - override def getFactory: js.ScriptEngineFactory = null | |
| - override def createBindings(): js.Bindings = null | |
| - } | |
| + private val BrokenEngine = None | |
| @volatile private var firstCreateEngineError: Option[Throwable] = None | |
| @@ -180,7 +173,7 @@ class Nashorn(globals: Globals) { | |
| } | |
| return | |
| } | |
| - javascriptEngines.putLast(engine) | |
| + javascriptEngines.putLast(Some(engine)) | |
| } | |
| @@ -193,12 +186,13 @@ class Nashorn(globals: Globals) { | |
| } | |
| - private def renderPageImpl[R](engine: js.Invocable, reactStoreJsonString: String) | |
| + private def renderPageImpl[R](engine: graalvm.Context, reactStoreJsonString: String) | |
| : String Or ErrorMessage = { | |
| val timeBefore = System.nanoTime() | |
| - val htmlOrError = engine.invokeFunction( | |
| - "renderReactServerSide", reactStoreJsonString, cdnOrigin.getOrElse("")).asInstanceOf[String] | |
| + | |
| + val htmlOrError = engine.getBindings("js").getMember("renderReactServerSide") | |
| + .execute(reactStoreJsonString, cdnOrigin.getOrElse("")).asString | |
| if (htmlOrError.startsWith(ErrorRenderingReact)) { | |
| logger.error(s"Error rendering page with React server side [DwE5KGW2]") | |
| return Bad(htmlOrError) | |
| @@ -232,44 +226,41 @@ class Nashorn(globals: Globals) { | |
| // The onebox renderer needs a Javascript engine to sanitize html (via Caja JsHtmlSanitizer) | |
| // and we'll reuse `engine` so we won't have to create any additional engine. | |
| oneboxRenderer.javascriptEngine = Some(engine) | |
| - val resultObj: Object = engine.invokeFunction("renderAndSanitizeCommonMark", commonMarkSource, | |
| + val result: graalvm.Value = engine.getBindings("js").getMember("renderAndSanitizeCommonMark").execute( | |
| + commonMarkSource, | |
| true.asInstanceOf[Object], // allowClassIdDataAttrs.asInstanceOf[Object], | |
| followLinks.asInstanceOf[Object], | |
| - oneboxRenderer, uploadsUrlPrefix) | |
| + oneboxRenderer, | |
| + uploadsUrlPrefix) | |
| oneboxRenderer.javascriptEngine = None | |
| - val result: ScriptObjectMirror = resultObj match { | |
| - case scriptObjectMirror: ScriptObjectMirror => | |
| - scriptObjectMirror | |
| - case errorDetails: ErrorMessage => | |
| - // Don't use Die — the stack trace to here isn't interesting? Instead, it's the | |
| - // errorDetails from the inside-Nashorn exception that matters. | |
| - debiki.EdHttp.throwInternalError( | |
| - "TyERCMEX", "Error rendering CommonMark, server side in Nashorn", errorDetails) | |
| - case unknown => | |
| - die("TyERCMR01", s"Bad class: ${classNameOf(unknown)}, thing as string: ``$unknown''") | |
| + if (result.isString) { | |
| + // Don't use Die — the stack trace to here isn't interesting? Instead, it's the | |
| + // errorDetails from the inside-Nashorn exception that matters. | |
| + debiki.EdHttp.throwInternalError( | |
| + "TyERCMEX", "Error rendering CommonMark, server side in Nashorn", result.asString) | |
| + } else if (!result.hasMembers) { | |
| + die("TyERCMR01", s"Bad class: ${classNameOf(result)}, thing as string: ``$result''") | |
| } | |
| - dieIf(!result.isArray, "TyERCMR02", "Not an array") | |
| - dieIf(!result.hasSlot(0), "TyERCMR03A", "No slot 0") | |
| - dieIf(!result.hasSlot(1), "TyERCMR03B", "No slot 1") | |
| - dieIf(result.hasSlot(2), "TyERCMR03C", "Has slot 2") | |
| + dieIf(!result.hasArrayElements, "TyERCMR02", "Not an array") | |
| + dieIf(result.getArraySize != 2, "TyERCMR03D", "Array of size != 2") | |
| - val elem0 = result.getSlot(0) | |
| - dieIf(!elem0.isInstanceOf[String], "TyERCMR04", s"Bad safeHtml class: ${classNameOf(elem0)}") | |
| - val safeHtml = elem0.asInstanceOf[String] | |
| + val elem0 = result.getArrayElement(0) | |
| + dieIf(!elem0.isString, "TyERCMR04", s"Bad safeHtml class: ${classNameOf(elem0)}") | |
| + val safeHtml = elem0.asString | |
| - val elem1 = result.getSlot(1) | |
| - dieIf(!elem1.isInstanceOf[ScriptObjectMirror], | |
| + val elem1 = result.getArrayElement(1) | |
| + dieIf(!elem1.hasArrayElements, | |
| "TyERCMR05", s"Bad mentionsArray class: ${classNameOf(elem1)}") | |
| - val mentionsArrayMirror = elem1.asInstanceOf[ScriptObjectMirror] | |
| + val mentionsArrayMirror = elem1 | |
| val mentions = ArrayBuffer[String]() | |
| var nextSlotIx = 0 | |
| - while (mentionsArrayMirror.hasSlot(nextSlotIx)) { | |
| - val elem = mentionsArrayMirror.getSlot(nextSlotIx) | |
| - dieIf(!elem.isInstanceOf[String], "TyERCMR06", s"Bad mention class: ${classNameOf(elem)}") | |
| - mentions.append(elem.asInstanceOf[String]) | |
| + while (nextSlotIx < mentionsArrayMirror.getArraySize) { | |
| + val elem = mentionsArrayMirror.getArrayElement(nextSlotIx) | |
| + dieIf(!elem.isString, "TyERCMR06", s"Bad mention class: ${classNameOf(elem)}") | |
| + mentions.append(elem.asString) | |
| nextSlotIx += 1 | |
| } | |
| @@ -289,13 +280,13 @@ class Nashorn(globals: Globals) { | |
| def sanitizeHtmlReuseEngine(text: String, followLinks: Boolean, | |
| - javascriptEngine: Option[js.Invocable]): String = { | |
| + javascriptEngine: Option[graalvm.Context]): String = { | |
| if (isTestSoDisableScripts) | |
| return "Scripts disabled [EsM44GY0]" | |
| - def sanitizeWith(engine: js.Invocable): String = { | |
| - val safeHtml = engine.invokeFunction( | |
| - "sanitizeHtmlServerSide", text, followLinks.asInstanceOf[Object]) | |
| - safeHtml.asInstanceOf[String] | |
| + def sanitizeWith(engine: graalvm.Context): String = { | |
| + val safeHtml = engine.getBindings("js").getMember("sanitizeHtmlServerSide") | |
| + .execute(text, followLinks.asInstanceOf[Object]) | |
| + safeHtml.asString | |
| } | |
| javascriptEngine match { | |
| case Some(engine) => | |
| @@ -312,13 +303,13 @@ class Nashorn(globals: Globals) { | |
| if (isTestSoDisableScripts) | |
| return "scripts-disabled-EsM28WXP45" | |
| withJavascriptEngine(engine => { | |
| - val slug = engine.invokeFunction("debikiSlugify", title) | |
| - slug.asInstanceOf[String] | |
| + val slug = engine.getBindings("js").getMember("debikiSlugify").execute(title) | |
| + slug.asString | |
| }) | |
| } | |
| - private def withJavascriptEngine[R](fn: (js.Invocable) => R): R = { | |
| + private def withJavascriptEngine[R](fn: (graalvm.Context) => R): R = { | |
| dieIf(isTestSoDisableScripts, "EsE4YUGw") | |
| def threadId = Thread.currentThread.getId | |
| @@ -337,29 +328,32 @@ class Nashorn(globals: Globals) { | |
| // Don't: Future { createOneMoreJavascriptEngine() } | |
| } | |
| - val engine = javascriptEngines.takeFirst() | |
| + val maybeEngine = javascriptEngines.takeFirst() | |
| if (mightBlock) { | |
| logger.debug(s"...Thread $threadName (id $threadId) got a JS engine.") | |
| - if (engine eq BrokenEngine) { | |
| + if (maybeEngine eq BrokenEngine) { | |
| logger.debug(s"...But it is broken; I'll throw an error. [DwE4KEWV52]") | |
| } | |
| } | |
| - if (engine eq BrokenEngine) { | |
| - javascriptEngines.addFirst(engine) | |
| - die("DwE5KGF8", "Could not create Javascript engine; cannot render page", | |
| - firstCreateEngineError getOrDie "DwE4KEW20") | |
| - } | |
| - | |
| - try fn(engine.asInstanceOf[js.Invocable]) | |
| - finally { | |
| - javascriptEngines.addFirst(engine) | |
| + maybeEngine match { | |
| + case Some(engine) => { | |
| + try fn(engine) | |
| + finally { | |
| + javascriptEngines.addFirst(maybeEngine) | |
| + } | |
| + } | |
| + case None => { | |
| + javascriptEngines.addFirst(maybeEngine) | |
| + die("DwE5KGF8", "Could not create Javascript engine; cannot render page", | |
| + firstCreateEngineError getOrDie "DwE4KEW20") | |
| + } | |
| } | |
| } | |
| - private def makeJavascriptEngine(): js.ScriptEngine = { | |
| + private def makeJavascriptEngine(): graalvm.Context = { | |
| val timeBefore = System.currentTimeMillis() | |
| def threadId = java.lang.Thread.currentThread.getId | |
| def threadName = java.lang.Thread.currentThread.getName | |
| @@ -370,7 +364,7 @@ class Nashorn(globals: Globals) { | |
| // Pass 'null' so that a class loader that finds the Nashorn extension will be used. | |
| // Otherwise the Nashorn engine won't be found and `newEngine` will be null. | |
| // See: https://github.com/playframework/playframework/issues/2532 | |
| - val newEngine = new js.ScriptEngineManager(null).getEngineByName("nashorn") | |
| + val newEngine = graalvm.Context.newBuilder("js").allowAllAccess(true).build | |
| val scriptBuilder = new StringBuilder | |
| scriptBuilder.append(i""" | |
| @@ -502,7 +496,8 @@ class Nashorn(globals: Globals) { | |
| } | |
| } | |
| - newEngine.eval(script) | |
| + val scriptSource = graalvm.Source.newBuilder("js", script, "setup").buildLiteral | |
| + newEngine.eval(scriptSource) | |
| def timeElapsed = System.currentTimeMillis() - timeBefore | |
| logger.debug(o"""... Done initializing Nashorn engine, took: $timeElapsed ms, | |
| @@ -534,13 +529,13 @@ class Nashorn(globals: Globals) { | |
| } | |
| - private def warmupJavascriptEngine(engine: js.ScriptEngine) { | |
| + private def warmupJavascriptEngine(engine: graalvm.Context) { | |
| logger.debug(o"""Warming up Nashorn engine...""") | |
| val timeBefore = System.currentTimeMillis() | |
| // Warming up with three laps seems enough, almost all time is spent in at lap 1. | |
| for (i <- 1 to 3) { | |
| val timeBefore = System.currentTimeMillis() | |
| - renderPageImpl(engine.asInstanceOf[js.Invocable], WarmUpReactStoreJsonString) | |
| + renderPageImpl(engine, WarmUpReactStoreJsonString) | |
| def timeElapsed = System.currentTimeMillis() - timeBefore | |
| logger.info(o"""Warming up Nashorn engine, lap $i done, took: $timeElapsed ms""") | |
| } | |
| diff --git a/app/debiki/onebox/Onebox.scala b/app/debiki/onebox/Onebox.scala | |
| index 77d200d08..355aaf48e 100644 | |
| --- a/app/debiki/onebox/Onebox.scala | |
| +++ b/app/debiki/onebox/Onebox.scala | |
| @@ -21,7 +21,7 @@ import com.debiki.core._ | |
| import com.debiki.core.Prelude._ | |
| import debiki.{Globals, Nashorn} | |
| import debiki.onebox.engines._ | |
| -import javax.{script => js} | |
| +import org.graalvm.{polyglot => graalvm} | |
| import scala.collection.mutable | |
| import scala.collection.mutable.ArrayBuffer | |
| import scala.concurrent.{ExecutionContext, Future} | |
| @@ -78,7 +78,7 @@ abstract class OneboxEngine(globals: Globals, val nashorn: Nashorn) { | |
| uploadsLinkRegex.replaceAllIn(safeHtml, s"""="$prefix$$1"""") | |
| } | |
| - final def loadRenderSanitize(url: String, javascriptEngine: Option[js.Invocable]) | |
| + final def loadRenderSanitize(url: String, javascriptEngine: Option[graalvm.Context]) | |
| : Future[String] = { | |
| def sanitizeAndWrap(html: String): String = { | |
| var safeHtml = | |
| @@ -157,7 +157,7 @@ class Onebox(val globals: Globals, val nashorn: Nashorn) { | |
| new GiphyOnebox(globals, nashorn), | |
| new YouTubeOnebox(globals, nashorn)) | |
| - def loadRenderSanitize(url: String, javascriptEngine: Option[js.Invocable]) | |
| + def loadRenderSanitize(url: String, javascriptEngine: Option[graalvm.Context]) | |
| : Future[String] = { | |
| for (engine <- engines) { | |
| if (engine.handles(url)) | |
| @@ -167,7 +167,7 @@ class Onebox(val globals: Globals, val nashorn: Nashorn) { | |
| } | |
| - def loadRenderSanitizeInstantly(url: String, javascriptEngine: Option[js.Invocable]) | |
| + def loadRenderSanitizeInstantly(url: String, javascriptEngine: Option[graalvm.Context]) | |
| : RenderOnboxResult = { | |
| def placeholder = PlaceholderPrefix + nextRandomString() | |
| @@ -203,7 +203,7 @@ class InstantOneboxRendererForNashorn(val oneboxes: Onebox) { | |
| // Should be set to the Nashorn engine that calls this class, so that we can call | |
| // back out to the same engine, when sanitizing html, so we won't have to ask for | |
| // another engine, that'd create unnecessarily many engines. | |
| - var javascriptEngine: Option[js.Invocable] = None | |
| + var javascriptEngine: Option[graalvm.Context] = None | |
| def renderAndSanitizeOnebox(unsafeUrl: String): String = { | |
| lazy val safeUrl = org.owasp.encoder.Encode.forHtml(unsafeUrl) | |
| diff --git a/build.sbt b/build.sbt | |
| index b89afa57f..edd2f1840 100644 | |
| --- a/build.sbt | |
| +++ b/build.sbt | |
| @@ -97,7 +97,9 @@ val appDependencies = Seq( | |
| // CLEAN_UP remove Spec2 use only ScalaTest, need to edit some tests. | |
| "org.mockito" % "mockito-all" % "1.9.0" % "test", // I use Mockito with Specs2... | |
| "org.scalatest" %% "scalatest" % "3.0.5" % "test", // but prefer ScalaTest | |
| - "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test) | |
| + "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test, | |
| + // GraalVM SDK for GraalVM JavaScript integration | |
| + "org.graalvm.sdk" % "graal-sdk" % "19.0.0") | |
| val main = (project in file(".")) |
https://gist.github.com/jirkamarsik/b282fef51075468b735105ede1c25452
上記変更とは別に、完全なパッチには以下の編集が含まれています。
- js.ScriptEngineとjs.Invocableへの全ての参照をgraalvm.Contextへの参照に置換
- Invocable.invoiceをValue.executeに置換
- Maven依存関係としてgraal-sdk/org.graalvm.sdkを包含
- AbstractStrictEngineのカスタムサブクラスをOptionに置換
最後に、Webアプリケーションを実行するイメージを作成するDockerfileを変更してオリジナルのJDK (OpenJDK) ではなくGraalVM を含めるようにします。
Comparing changes
https://github.com/jirkamarsik/talkyard/compare/0311dde…3204df9
Testing the port
Talkyardリポジトリには、ユニットテストとSelemiumを使うend-to-endテストが含まれています。
Unit test
https://github.com/debiki/talkyard/tree/master/tests/app
End to end test
https://github.com/debiki/talkyard/tree/master/tests/e2e
これらを実行すれば、GraalVMへの切り替えとGraalVMのJavaScriptエンジンへの切り替えでも、Nashornで見られた挙動を維持していることがわかります。
| s/d-cli | |
| ... | |
| [talkyard-server] $ test | |
| ... | |
| [info] ScalaTest | |
| [info] Run completed in 1 minute, 41 seconds. | |
| [info] Total number of tests run: 396 | |
| [info] Suites: completed 37, aborted 0 | |
| [info] Tests: succeeded 396, failed 0, canceled 0, ignored 0, pending 12 | |
| [info] All tests passed. | |
| [info] Passed: Total 407, Failed 0, Errors 0, Passed 407, Pending 12 | |
| [success] Total time: 106 s, completed Sep 10, 2019 8:39:22 AM |
注意)これは、このアプリケーションが標準のECMAScript機能に制限されているためです。NashornはECMAScriptをカスタム機能で拡張し、その使用はGraalVMでサポートされていますが、その場合、明示的に有効にする必要があります(GraalVM JavaScriptの互換性の詳細については、以下のドキュメントを参照してください)。
Nashorn Compatibility Mode
https://github.com/graalvm/graaljs/blob/master/docs/user/NashornMigrationGuide.md#nashorn-compatibility-mode
GraalVM JavaScript Language Compatibility
https://github.com/graalvm/graaljs/blob/master/docs/user/JavaScriptCompatibility.md
Benchmarking the port
移植がパフォーマンスに与える影響をテストするには、さまざまな実装のスループット/レイテンシを測定および比較できるように、アプリケーションが処理する再現可能なタスクを準備する必要があります。TalkyardはサーバーサイドJavaScriptを使ってユーザーにページを提供するため、単一のページを提供するときにアプリケーションのスループットをテストします。ただし、ベンチマークの全期間にわたって同じページを何度も要求すると、ランタイムが最適化されたコードを生成し結果としてベンチマークが良くなるけれど、幅広いページでのアプリケーションのパフォーマンスを表すものではなくなるというリスクが発生します。これを回避するために、ウォームアッププロセス中に一連の異なるページをランダムに切り替えて、異なるランタイムが異なるページを処理するのに十分な汎用コードになるようにします。テスト対象のページセットについては、いくつかのオープンソースリポジトリのランダムなテキストの段落とMarkdownのREADMEファイルから作成した、より冗長な質問ページを追加したデフォルトのサンプルサイトを選択しました(サーバーサイドJavaScriptの責務の1つはMarkdownをHTMLに変換することです)。このベンチマークの実行に使用したさまざまなスクリプトとサイトデータは以下からご覧頂けます。
Collecting data for the warmup curve charts
https://github.com/jirkamarsik/talkyard-benchmarking
さらに、これらのベンチマークを可能にするために、Talkyardを少し調整する必要がありました。まず、含まれているレートリミッターを無効にして、ワークロードジェネレーターからのリクエストをブロックしないようにしました。
Disable the RateLimiter for benchmarking
https://github.com/jirkamarsik/talkyard/commit/151daef85e6888bd2fed348297d5ca09da2a3445
Talkyardは、サーバーサイドレンダリングの結果をメモリとRDBMSの両方にキャッシュします。サーバーサイドレンダリングパイプラインの変更を表示するには、これらのキャッシュを無効にする必要がありました。また、各リクエストのレイテンシデータをキャプチャできるいくつかのフックも含まれています。
Disable memcache and RDB cache for rendered pages for benchmarking
https://github.com/jirkamarsik/talkyard/commit/2c8d34313a03ff53ae9062c7fd1318cf741482cb
wrkを使ってアプリケーションにリクエストを送り、スループットを測定しました。以下で示すデータはOpenJDK + NashornはOpenJDK 8u121-b03、GraalVMはGraalVM 19.3.0 Community Edition + GraalVM JavaScript、GraalVM 19.3.0 Enterprise Edition + GraalVM JavaScriptの組み合わせで稼働するワークステーションで取得したものです。

グラフから分かるように、素のJVM + Nashorn、GraalVMいずれも長時間のウォームアップのカーブが出ていますが、GraalVMのほうがピークパフォーマンスに早く到達します(GraalVM Enterprise Editionで9分、Community Editionで12分、大してNashornの場合は20分)。より重要なのは、Nashornに比べてピークパフォーマンスが25%以上も高く、GraalVM Enterprise Editionの場合Nashornを35%以上上回っています。
また、サーバーサイドJavaScriptコードの実行を担当するアプリケーションの部分でのみ費やされる時間も測定しました。これにより、React.jsワークロードでのGraalVMとNashornのパフォーマンスの違いをより集中的に確認できます。

(注意)上図では、ページレンダリングに要した平均時間でコア数(8)を割ったものとしてスループットを計算しています。
このグラフで、Nashornの場合、秒間800以下のページレンダリング(1ページレンダリングで10msec)ですが、GraalVMの場合、秒間2000以下のページレンダリング(1ページレンダリングで4msec)と、150%ものレンダリングスループットの上昇を確認できました。
最適化にあたってはパフォーマンスのウォームアップがいつも話題に上がりますので、将来よりよいウォームアップができるものと期待しています。この例は大規模なReact.jsアプリケーションをGraalVMで稼働するという最初の体験でもあるため、この種のワークロードに対するパフォーマンスの最適化はまだ試行されていません。
Summary
JVM内でサーバーサイドScalaとTypeScript/JavaScriptの両方を実行するかなり大きなアプリケーションを見てきました。JavaScriptインターフェイスをNashornからGraalVM JavaScript実装に移植する方法を見てきました。React.jsのサーバーサイドレンダリング時のように、JVMコードとJavaScriptコードの間のインターフェースが小さい場合、移植自体を簡単です。最終的に、Nashornから移植したものはすべてのテストに合格し、パフォーマンスが向上しています。
GraalVM JavaScriptに移植したいJavaScriptアプリケーションをお持ちですか?あなたの体験を聞かせてください。フィードバックによってGraalVMが改善されていきます。
GraalVMのSlack招待ページ
https://www.graalvm.org/slack-invitation/
障害やパフォーマンス上の問題がある場合は喜んでお手伝いします。ぜひGraalVM JavaScript (graaljs)でIssueを立ててください。
graaljs Issue
https://github.com/graalvm/graaljs/issues