原文はこちら。
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