Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

AWS Lambda FunctionをScala.jsで書いて爆速Scalaを満喫しよう

この記事は、Scala Advent Calendar 2023の5日目の記事です。

qiita.com

大遅刻してしまいましたが、Scalaは遅延評価できるのでモーマンタイですね。

この記事では、Scalaをクラウドネイティブな環境で使う例の紹介として、ScalaをJavaScriptコードにトランスパイルしてAWS Lambdaで動作させる方法を紹介します。

愉快な遠足の始まりだ!!!

Scalaプラットフォームの発展

最近のScalaのプラットフォームをめぐる発展は著しく、ネイティブコンパイルを実現するScala Native、TypeScriptのようなAltJSとしてトランスパイルするScala.jsなどが積極的に開発されています。

例えばScala.jsはしばらく前にメジャーバージョン1を迎え、正式にプロダクションレディな品質に到達しました。

これに加えてScala 3もLTSである3.3.0がリリースされ、本格的に運用可能な状態が整っています。

また、Scala.jsなどの要素技術を組み合わせてWASMに変換する研究も模索されており、Scalaは決して古い過去の言語ではなく日々進化を続けています。

zenn.dev

Scalaはクラウドネイティブ時代に乗り遅れている?(いえいえ)

先日、Scalaの起動速度(JVMのスピンアップにかかる時間など)が遅くてクラウドネイティブな環境ではつらい、という話を見ました。

sizu.me

かなり盛り上がっていましたね。そもそも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-jsaxiosを利用する
  • npmライブラリをyarnで管理する
    • 本当はpnpmが良かったのですが、推移的な依存性をnode_modulesに保存してくれないようだったので今回は見送りました。詳しい方がいらっしゃったら教えてください。
    • また、npmライブラリの定義はいつも通りpackage.jsonに記述します。
  • Scalaライブラリの例として、Circeを利用する

ソースコードは以下に公開しています(細やかな差異があるかもしれません)。

github.com

ビルドツールなどのセットアップについては以下の記事を参考にしてください。

blog.3qe.us

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です。

scalablytyped.org

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を利用する設定
    • enablePluginsScalaJSPluginを渡します。
  • 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と遜色ない速度で動作します。

www.scala-js.org

Scala.jsコードをLambda関数の呼び出し規約に合わせる

さて、ここからはScala.jsでAWS Lambda関数の実装をしていきます。先程作ったコードをLambdaで動かせるように少しだけいじることにします。

AWS LambdaのNodeランライムには、イベントを受け渡すための呼び出し規約が定められており、JavaScriptコードはこれに従う必要があります。

docs.aws.amazon.com

規約を実装する

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関数を実行すると・・・

CirceでJSONをパースできた

無事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.org

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という名前に写される
  • サブモジュールは○○Modというsuffixがつく
    • import AnchorLong from 'antd/es/anchor/AnchorLink'
    • Scala.jsではimport typings.antd.esAnchorAnchorLinkMod.{default => AnchorLong}
  • パッケージオブジェクトに相当するようなモジュール上の定義は^というオブジェクトに写される
    • 実装の都合でこうなっている
    • 本当はpackage objectにしたいが、jsとの相互運用のために妥協している

なんでこんなことをしているかというと、TypeScriptとScalaとでは名前空間でのトップレベル定義などの扱いが異なるからです。ここだけちょっと難しいですね。

scalablytyped.org

百聞は一見に如かずということで、以下のようにライブラリを利用するコードを書いてみましょう。

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が広まってほしいなと思っています。

あわせて読みたい

blog.3qe.us

blog.3qe.us

blog.3qe.us

blog.3qe.us

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?