この記事は、Scala Advent Calendar 2023の5日目の記事です。
大遅刻してしまいましたが、Scalaは遅延評価できるのでモーマンタイですね。
この記事では、Scalaをクラウドネイティブな環境で使う例の紹介として、ScalaをJavaScriptコードにトランスパイルしてAWS Lambdaで動作させる方法を紹介します。
愉快な遠足の始まりだ!!!
- Scalaプラットフォームの発展
- Scalaはクラウドネイティブ時代に乗り遅れている?(いえいえ)
- 想定環境
- Scala.jsで快適にLambda関数を書いてみよう
- Scala.jsでできないこと
- まとめ
- あわせて読みたい
Scalaプラットフォームの発展
最近のScalaのプラットフォームをめぐる発展は著しく、ネイティブコンパイルを実現するScala Native、TypeScriptのようなAltJSとしてトランスパイルするScala.jsなどが積極的に開発されています。
例えばScala.jsはしばらく前にメジャーバージョン1を迎え、正式にプロダクションレディな品質に到達しました。
これに加えてScala 3もLTSである3.3.0がリリースされ、本格的に運用可能な状態が整っています。
また、Scala.jsなどの要素技術を組み合わせてWASMに変換する研究も模索されており、Scalaは決して古い過去の言語ではなく日々進化を続けています。
Scalaはクラウドネイティブ時代に乗り遅れている?(いえいえ)
先日、Scalaの起動速度(JVMのスピンアップにかかる時間など)が遅くてクラウドネイティブな環境ではつらい、という話を見ました。
かなり盛り上がっていましたね。そもそもJVMは言語基盤としてはかなり速いんですが、起動とJITが通りきるまではあまりパフォーマンスが出ません。その点がクラウド全盛のウェブテクノロジーとは噛み合っていないのかな、と思います。 常に全力運転してほしい、というユースケースよりも、必要に応じて瞬間的に動作する便利API、みたいな用途が最近は重宝されるのだと思います。
そんな中実は、「ScalaはもはやJVM言語ではなく、JVMもサポートするマルチプラットフォーム言語」と呼んでもさしつかえない程、ネイティブコンパイルやJSへのトランスパイル技術が進んでいます。
しかしながら日本語の情報が比較的少ないのと、「ScalaはJVMで動く鈍重な言語である」という強固なイメージがあることにより、Scala NativeやScala.jsの良さに気が付く人は比較的少数派です。
そこで、この記事ではいくつかのnpmライブラリを呼び出すLambda関数をScalaで記述し、Node.js 20ランタイムで動作させてみましょう。JVMがNode.jsに変わるだけであり、Java bytecodeがJavaScriptになるだけです。Scala.jsはコンパイラなので、Scala風のサブセット言語を書くのではなく、正真正銘のScalaが動きます。
まぁ要するに、アンサーソングですね。
想定環境
この記事では、以下の環境を想定します。
- ビルドツールとしてsbtを利用する
- AWS Lambda Node.js 20ランタイム(執筆時点で最新)を利用する
- npmライブラリの例として、
@aws-crypto/sha256-js
とaxios
を利用する - npmライブラリを
yarn
で管理する- 本当は
pnpm
が良かったのですが、推移的な依存性をnode_modules
に保存してくれないようだったので今回は見送りました。詳しい方がいらっしゃったら教えてください。 - また、npmライブラリの定義はいつも通り
package.json
に記述します。
- 本当は
- Scalaライブラリの例として、Circeを利用する
ソースコードは以下に公開しています(細やかな差異があるかもしれません)。
ビルドツールなどのセットアップについては以下の記事を参考にしてください。
Scala.jsで快適にLambda関数を書いてみよう
さっそく書いていきましょう。すぐ書けますよ。
最低限のScala.jsを実装する
まずはsbtプロジェクトを作成しましょう。特に何の変哲もないScala 3のプロジェクトを作成します。
% sbt new scala/scala3.g8
今回のプロジェクト名はaws-lambda-scala-js-exercise
です。今後パス名にこれが登場したら適宜置き換えてください。
自動的に生成されるsrc/main/scala/Main.scala
にはいったん手を加えません。
@main def hello: Unit = println("Hello world!") println(msg) def msg = "I was compiled by Scala 3. :)"
設定
Scala.jsを利用するにはいくつかのプラグインをsbt依存性に加えます。
まずはScala.js本体です。
そしてこれは後程扱いますが、TypeScript型定義をScalaの型定義に取り込むScalablyTypedです。
project/plugins.sbt
に以下のように記述します。
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta43")
続いてbuild.sbt
を開き、まずはScala.jsのプラグインを有効化し、いくつか最低限の設定を行います。
val scala3Version = "3.3.1" lazy val root = project .in(file(".")) .enablePlugins( ScalaJSPlugin, ) .settings( name := "scalajs-test", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, scalaJSUseMainModuleInitializer := true, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }, )
各設定の解説は以下の通りです:
- Scala.jsを利用する設定
enablePlugins
にScalaJSPlugin
を渡します。
- CommonJSを利用する設定
- Scala.jsとAWS LambdaはESModulesとCommonJSとの両方に対応していますが、ScalablyTypedはCommonJSに対応しています。
scalaJSLinkerConfig
の中で_.withModuleKind(ModuleKind.CommonJSModule)
を指定します。
- エントリポイントを指定する設定
- エントリポイントからプログラムを実行するかを指定します。
- 手元での動作紹介のために指定しています。後で外します。
scalaJSUseMainModuleInitializer := true
を指定します。- エントリポイントはコード中で
@main
を渡して指定します。
- エントリポイントからプログラムを実行するかを指定します。
これだけでScala.jsを手元で実行する環境が整いました。
トランスパイル
sbt fastOptJS
を実行するとNode.jsで実行可能なjsファイルを生成できます。これは、ScalaをJSにトランスパイルし、一定の最適化を施したファイルを出力します。
% sbt fastOptJS ...
node target/scala-3.3.1/scalajs-test-fastopt/main.js
を実行すると、メッセージが表示されます。
% node target/scala-3.3.1/scalajs-test-fastopt/main.js Hello world! I was compiled by Scala 3. :)
これだけでScalaを使ってNode.jsで動作するJavaScriptを生成できました。TypeScriptと比べてもあまり難しくないでしょ?
ちなみにDOM APIなどもしっかりあるのでフロントエンドで動かすこともできるんですよ。JavaScriptのネイティブなコードを呼び出したり、またJavaScript側から呼び出すためのファサードを提供することも可能です。
加えて、Scala.jsはビルド時に最適化が(minimizeも)行なわれるため、手書きのJSと遜色ない速度で動作します。
Scala.jsコードをLambda関数の呼び出し規約に合わせる
さて、ここからはScala.jsでAWS Lambda関数の実装をしていきます。先程作ったコードをLambdaで動かせるように少しだけいじることにします。
AWS LambdaのNodeランライムには、イベントを受け渡すための呼び出し規約が定められており、JavaScriptコードはこれに従う必要があります。
規約を実装する
CommonJSモジュールの場合、2つの呼び出し規約を守ればOKです。
- 呼び出されるハンドラメソッドは
handler
という名前でexportされていなければならない。 handler
をexportするモジュール名はindex
でなければならない。
またLambda関数の場合はmainメソッドは不要なのでMainModuleInitializerは外してしまいます。
scalaVersion := scala3Version,
- scalaJSUseMainModuleInitializer := true,
scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.CommonJSModule)
},
メソッドにアノテーションを追加して、exportまわりの設定を行いましょう。また、名前空間を切りたいのでobject
でくくります。
import scala.scalajs.js.annotation._ object Index { // ここでexportするときの名前とモジュール名を教える @JSExportTopLevel(name = "handler", moduleID = "index") def hello(): Unit = println("Hello world!") println(msg) def msg = "I was compiled by Scala 3. :)" }
Lambda関数で呼び出すための準備はこれだけで完了です。
基本的にScala.jsでは、JSのネイティブな部分に触るときはアノテーションを付けることでコンパイラに情報を教える、というやりかたで行います。
Lambda関数を作成する
設定が終わったのでLambda関数を作成してみましょう。またScalaをトランスパイルします。
% sbt fastOptJS
手でアップロードする場合はzipファイルにjsファイルを固める必要があるので、cd target/scala-3.3.1/scalajs-test-fastopt/
に移動してからzip -r ../../../lambda.zip *
を実行します。
% cd target/scala-3.3.1/scalajs-test-fastopt/ % zip -r ../../../lambda.zip *
のちほどこの工程はスクリプト化します。
次にAWSコンソールを開いてLambda関数を作成します。ランタイムはNode.js 20.xを選択します。アーキテクチャはなんでもいいです。
関数を作成したら、「コード」「コードソース」「アップロード元」「.zipファイル」を選択し、先程作成したzipファイルをアップロードしましょう。
アップロードが終わったら「テスト」タブから適当なイベントを生成して送信してみましょう(今の段階ではイベントは何も処理しないので、何でも大丈夫です)。
するとログにメッセージが出力され、一瞬で実行が完了します。
Hello world! I was compiled by Scala 3. :)
おめでとう!正真正銘のScalaをNodeランタイムで動作させることができました。
イベントなどの入力
ハンドラに渡ってきた引数を使うには、Scala側でjs.Object
を継承した型を定義してやれば大丈夫です。こうした定義は自動的にJSのオブジェクト型としてNodeからは見えるようになります。
import scala.scalajs.js import scala.scalajs.js.annotation._ @js.native trait Event extends js.Object { val key1: String val key2: String val key3: String } @js.native trait Context extends js.Object { val functionName: String } object Main { @JSExportTopLevel(name = "handler", moduleID = "index") def handler(event: Event, context: Context) = { // ...
ここまでのまとめ
- Scala.jsでトランスパイルしたScalaをAWS Lambdaで実行できた。
- Node.jsランタイムで動作するためきわめて高速な起動時間を達成した。
Scala.jsでライブラリを利用する
ScalaコードをLambda functionにしてNode.jsランタイムで動作させる方法について前節で説明しました。
しかしながら、一般的なLambda functionではいくつかのライブラリを利用するのが普通です。
この節ではScala / npmライブラリをScala.jsで利用する方法を紹介します。
Scalaライブラリの場合
ScalaライブラリがScala.jsに対応している場合、特に何の設定をしなくても普通にライブラリに追加することで利用できます。
唯一の差分は、%%
のかわりに%%%
を利用することです。これにより、sbtはプラットフォームに適した実装を自動的に選んでくれるようになります。JVMで動かす場合でもこれで動くので、普段から%%%
を使うようにすると良いでしょう。
// build.sbt val circeVersion = "0.14.1" // ... libraryDependencies ++= Seq( "io.circe" %%% "circe-core", "io.circe" %%% "circe-generic", "io.circe" %%% "circe-parser" ).map(_ % circeVersion),
有名なScalaライブラリの殆んどはScala.jsに対応しており、Pure Scalaなライブラリであれば間違いなくScala.jsで利用できます。
今回は、試しに定番のJSONライブラリであるCirceを呼び出してみます。といっても、これ以外は何も普段のScalaコードと変わらないですが・・・。
import scala.scalajs.js.annotation._ import io.circe._, io.circe.generic.auto._, io.circe.parser._, io.circe.syntax._ object Index { @JSExportTopLevel(name = "handler", moduleID = "index") def hello(): Unit = println("Hello world!") println(msg) println(parse(j)) def msg = "I was compiled by Scala 3. :)" val j = """{"foo":[42,43,44]}""" }
またsbt fastOptJS
してからzipファイルを生成し、アップロードしたLambda関数を実行すると・・・
無事JSONをパースできました。ちなみにCirceはJVM環境では自前のパーサを利用し、Scala.js環境ではJavaScript標準のJSON.parse
を自動的に利用します(インターフェイスはそのまま)。賢いですね。
速度
ここで所要時間を測ってみました。
- コールドスタート: 836.46 ms
- ホットスタート: 82.88 ms
通常のJVMを利用したLambda関数よりも非常に高速に立ち上がることがわかります。
npm(TypeScript)ライブラリの場合
Scala.jsからnpmライブラリを呼び出すには、ScalablyTypedを使ってTypeScriptの型定義をScalaに取り込みます。ScalablyTypedは型を写すことでTypeScriptライブラリへのファサードを自動生成し、これらへのアクセスはネイティブの呼び出しコードに変換されます。
あくまで型を写すだけなので、ライブラリ自体は別にバンドルします。
ScalablyTypedにライブラリを変換してもらう方法はいくつかありますが、一番(個人的に)使いやすい方法は外部のnpm/yarnをそのまま利用する方法です。必要なものは以下の通りです:
yarn
がインストールされていることpackage.json
があること(これから作成します)- npmライブラリがTypeScript型定義を含んでいること(場合によっては
@types/
をインストールする必要がある)
もちろん、ScalablyTypedなどを使わずに手で型定義を書いて呼び出すこともできますが、大変だと思います。
事前準備
ScalablyTypedを利用するには、build.sbt
でプラグインを有効化します。
.enablePlugins(
ScalaJSPlugin,
+ ScalablyTypedConverterExternalNpmPlugin,
)
これに加えて、yarnを呼び出す設定を追加しておきます。
import scala.sys.process.Process // ... externalNpm := { Process(Seq("yarn"), baseDirectory.value).! baseDirectory.value },
また、デフォルトではScalablyTypedは全ての型定義ファイルを変換してしまうので、時間がかかりすぎることを防ぐために変換するパッケージを絞っておきます。
Compile / stMinimize := Selection.AllExcept( "@aws-crypto/sha256-js", "axios" ),
最後にどのパッケージに型定義を出力するかを指定します。デフォルトではtypings.
以下に出力されますが、今回は分かりやすさのためにnpm.
以下に出力してもらいます。
stOutputPackage := "npm",
書き直したbuild.sbt
の全体像は以下の通りになります:
import scala.sys.process.Process lazy val root = project .in(file(".")) .enablePlugins( ScalaJSPlugin, ScalablyTypedConverterExternalNpmPlugin ) .settings( name := "aws-lambda-scala-js-exercise", scalaVersion := "3.3.1", scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }, libraryDependencies ++= Seq( "io.circe" %%% "circe-core", "io.circe" %%% "circe-generic", "io.circe" %%% "circe-parser" ).map(_ % circeVersion), externalNpm := { Process(Seq("yarn"), baseDirectory.value).! baseDirectory.value }, stOutputPackage := "npm", Compile / stMinimize := Selection.AllExcept( "@aws-crypto/sha256-js", "axios" ), )
次にyarn init
を実行しておきます:
% yarn init
yarn add
を利用してpackage.json
にライブラリを追加させます:
% yarn add axios @aws-crypto/sha256-js
実装
この状態でコンパイルすると、ScalablyTypedは指定したパッケージ以下にTypeScriptの型定義を写してくれます。今回はnpmパッケージ以下にaxiosパッケージなどが生えてきます。
ライブラリによってはビルド時にTypeScriptのライブラリを要求することがあります。yarn add -D typescript
しておきましょう。
% yarn add -D typescript
TypeScriptでのimportとの対応関係は以下の通りです。
- トップレベルのモジュールは
mod
という名前に写されるimport {Sha256} from '@aws-crypto/sha256-js'
- Scala.jsでは
import npm.awsCryptoSha256Js.mod.Sha256
import axios from 'axios'
- Scala.jsでは
import npm.axios.mod.default as axios
- default exportは
default
という名前に写される
- Scala.jsでは
- サブモジュールは
○○Mod
というsuffixがつくimport AnchorLong from 'antd/es/anchor/AnchorLink'
- Scala.jsでは
import typings.antd.esAnchorAnchorLinkMod.{default => AnchorLong}
- パッケージオブジェクトに相当するようなモジュール上の定義は
^
というオブジェクトに写される- 実装の都合でこうなっている
- 本当はpackage objectにしたいが、jsとの相互運用のために妥協している
なんでこんなことをしているかというと、TypeScriptとScalaとでは名前空間でのトップレベル定義などの扱いが異なるからです。ここだけちょっと難しいですね。
百聞は一見に如かずということで、以下のようにライブラリを利用するコードを書いてみましょう。
import scala.scalajs.js import scala.scalajs.js.Promise import scala.scalajs.js.annotation._ object Main { @JSExportTopLevel(name = "handler", moduleID = "index") def handler(event: Event, context: Context): Promise[String] = { import concurrent.ExecutionContext.Implicits.global import js.JSConverters._ // jsのPromiseとScalaのFutureを相互変換する println("Hello world!") // event/contextを利用した入出力ができる println(s"I am ${context.functionName} !") println(s"event is ${event}") println(s"event.key1 is ${event.key1}") // もちろん、Scalaの標準ライブラリも利用できる val x = (0 to 50).toSeq.map(_ * 2).reduce(_ + _) println(s"sum of 0 to 100 is ${x}") // npmライブラリを呼べる import npm.awsCryptoSha256Js.mod.Sha256 val s256 = new Sha256() s256.update("All your base are belong to us.") val digest = s256.digest().toFuture.map(_.toSeq.map(_.toString).mkString(" ")) digest.andThen(d => println(s"SHA256 is $d")) // npmライブラリを呼べる 2 import npm.axios.mod.default as axios import npm.axios.mod.AxiosResponse val got: Promise[AxiosResponse[String, ?]] = axios.get("https://example.com") val showResult = got.`then` { (r) => println(r.data) }.toFuture val all = for { _ <- digest _ <- showResult } yield "Hello, response!" // レスポンスするにはPromiseを返せばよい return all.toJSPromise } }
ビルド
Lambda functionにnpmライブラリを持っていくには、zipファイルにnode_modulesディレクトリを同梱します。とはいえ手でやると面倒なのでビルドスクリプトを作りましょう。
build.sh
として以下のようなファイルを作成します。
#!/bin/sh set -eu cd $(dirname $0) sbt fullOptJS if [[ -f lambda.zip ]]; then rm lambda.zip fi if [[ -d lambda ]]; then rm -rf lambda fi mkdir lambda cp target/scala-3.3.1/aws-lambda-scala-js-exercise-opt/* lambda/ cp -r node_modules lambda/ cd lambda zip -r ../lambda.zip *
ここではfastOptJS
ではなくfullOptJS
を使うことで、最大限の最適化を行っています。
build.sh
を実行するとその場にlambda.zip
が作成されます。これをアップロードして実行しましょう。
このコードを実行すると、SHA256を計算した結果と、axiosを利用してHTTPリクエストを行った結果が返ってきます。ちなみにScala.jsではsttpなどのHTTPライブラリが利用できるので、わざわざaxiosを利用する理由はあまりないと思います。あくまで有名なライブラリのデモという感じで使っています。
無事TypeScriptのライブラリを呼べましたね。
ここまでのまとめ
- Scala.jsでScalaのライブラリを利用できた。
- Scala.jsでTypeScriptのライブラリを利用することができた。
- TypeScriptのライブラリを利用するにはScalablyTypedを用いて型定義をインポートする。
- TypeScriptのライブラリは
node_modules
ごとzipファイルにバンドルする必要がある。
Scala.jsでできないこと
さて、Scala.jsによるLambda functionの作成について紹介してきましたが、Scala.jsでは今のところ不可能なこともいくつかあります。
例えば、Javaライブラリへの依存ができません(厳密には、Java bytecodeに依存できない)。これは、Scala.jsはScalaのコードからJVMを経由せずに直接JSコードを生成するという都合上避けられないことです。
しかしながらScala.jsは多くのJava APIに対するpolyfillを提供しており、例えばBufferedReaderなどがScala.jsで書き直されています。このため、Java APIに依存したScalaライブラリはScala.jsにポート可能な場合があります。
まとめ
意外と簡単だったでしょう?TypeScriptと同様にAltJSとしてトランスパイルできるScalaの魅力を感じていただけたでしょうか。
TypeScriptはJavaScriptを拡張しているという都合によって型システムがやや制約を受けるのに対して、Scala.jsはScalaの型システムを直接利用できるため、より強力な表現力を得られるのが特長です。
またTypeScriptにない特長として、Scalaが提供する強力な標準ライブラリを直接利用できるため、非常に便利なコレクションメソッドを利用できるというものがあります。lodashを使うとかどのコレクションライブラリを使うかであくせく悩むこともありません。
起動やスピンアップに時間がかかるというJVMの弱点を見事克服し、もう一つのScalaとして注目が集まっているScala.jsを業務で使ってみてはいかがでしょうか。
僕もScalaが大好きなので、TypeScriptの機動性についていけないことに危機感を抱いていたのですが、Scala.jsを利用することによって全く遜色ない起動速度でScalaの表現力の恩恵に与れることがわかってからは、もっとScala.jsが広まってほしいなと思っています。