🥰

Scala JS で GCP Cloud Functions を書き、Terraform でデプロイする

2022/05/11に公開

https://github.com/i10416/scalajs-cloudfunctions.g8

  1. g8 i10416/scalajs-cloudfunctions.g8
  2. REAME に従って、Terraform と GitHub Actions の環境変数を設定設定する

以上の手続きでサンプルをシュッとデプロイできるようにしてあります.

概要

cloud functions は(Request, Response) 型( express の Request, Response に似ているが少し仕様が違う )を受け取って Any または Promise[Any] を返す関数を実行します.

この型にあう適当な関数が js ファイルからエクスポートされていれば、その関数を読み取って実行してくれます. 関数の名前は cloud functions の entrypoint に指定する名前と同じにする必要があります.

なので、次のような JavaScript へのファサードを定義してやれば ScalaJS でもそこそこ快適に cloud functions を書くことができます.

src/main/scala/Facade.scala
import scala.scalajs._
import scala.scalajs.js.annotation._
import io.circe.Decoder
import org.http4s.Uri
@js.native
trait Request extends js.Object {
  val method: String = js.native
  @JSName("url")
  val rawUrl: String = js.native
  @JSName("query")
  val unsafeQuery: js.Any = js.native
  val connection: Connection = js.native
  @JSName("body")
  val unsafeBody: js.Any = js.native
}
extension (r: Request)
  def show: String =
    s"Request(method:${r.method},url:${r.rawUrl},query:${r.query},conn:${r.connection})"
  def url: Uri = Uri.unsafeFromString(r.rawUrl)
  def body: String = JSON.stringify(r.unsafeBody)
  def json: Either[io.circe.ParsingFailure, io.circe.Json] =
    io.circe.parser.parse(body)
  def query: String = JSON.stringify(r.unsafeQuery)
  def queryAs[T: Decoder]: Either[io.circe.Error, T] =
    io.circe.parser.parse(query).flatMap(_.as[T])
  def bodyAs[T: Decoder]: Either[io.circe.Error, T] =
    io.circe.parser.parse(body).flatMap(_.as[T])

@js.native
trait Connection extends js.Object {
  val remoteAddress: String = js.native
}

@js.native
trait Response extends js.Object {
  def set(headerKey: String, value: String): Response = js.native
  def status(statusCode: Int): Response = js.native
  def send(content: String): Unit = js.native
  @JSName("send")
  def send(content: js.Object): Unit = js.native
}

関数の本体は次のようにかけます.

src/main/scala/Function.scala
  @JSExportTopLevel("main")
  val run: JSFunction2[Request, Response, Any] =
    (req: Request, res: Response) =>
      res.status(200).send("OK")

これを ScalaJS でコンパイル(sbt fullOptJS)すると次のような JS が生成されます.

path/to/index.js
exports.main = ...

あとはこの JS ファイルを zip して、cloud functions (正確には cloud functions が読み取る gcs bucket) に up すればOKです. このあたりの処理は 先のテンプレートでは terraform を使って自動化してあります.

注意点

JS へのコンパイル時に common js のモジュールを生成するように指定する必要があります.

build.sbt
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) },

ライブラリのインポート

以下のように "org" %%% "artifact" % "version" の形式でライブラリを指定する必要があります. JVM で使う場合は "org" %% "artifact" % "version" というふうに % が 2つですが, JS や Native を対象にしたライブラリを利用する場合は % を3つにしなければなりません.

build.sbt
    libraryDependencies ++= Seq(
      "io.circe" %%% "circe-core" % "0.15.0-M1",
      "io.circe" %%% "circe-parser" % "0.15.0-M1",
      "org.typelevel" %%% "cats-core" % "2.7.0",
      "org.typelevel" %%% "cats-effect" % "3.3.11",
    )

Java の System.env, Scala の sys.env からは JavaScript ランタイムの環境変数を取得することはできません. nodeProcess.env にアクセスするようなコードを書かないといけません.

例えば環境変数にあるアクセストークンを取得するために私は次のようなヘルパーを書いて使っています.

trait Authorization:
  protected def getToken[F[_]: Sync, E]: EitherT[F, E, String] =
    EitherT.liftF[F, E, String](
      js.Dynamic.global.process.env.MY_TOKEN
        .asInstanceOf[js.UndefOr[String]]
        .toOption
        .liftTo[F](
          new Exception("No Token found from environment variables")
        )
    )
  extension [F[_]](request: org.http4s.Request[F])
    def withToken(token: String) = request.putHeaders(
      Authorization(Token(AuthScheme.Bearer, token))
    )

JS の nullable な値にアクセスするコードを書く際は asInstanceOf[js.UndefOr[String]].toOption でアクセスします. JS の値から直接 Option[String] にはできません😖

同様に、JavaScript はシングルスレッドのイベントループモデルなので、JVMのスレッドモデルやマルチスレッドを前提としたクラスなどを継承しても期待した動作をしてくれません.(例: scala.concurrent.Await や cats-effect の WorkStealingThreadPool.scala)

Scala.js で cloud functions を書くと何がうれしいか

  • Scala の強力な型が使えます.
    - ScalaJS は Scala 3 にも対応しているので Sealed Enum, Union 型・交差型, Opaque Type や Singleton 型、使いやすくなった型クラス、Sigleton 型、マクロなど、Scala の楽しい機能が使って インデント記法でスッキリかけます.
  • Metals や IntelliJ などの高性能な Language Server や IDE が使えます
  • webpack や prettifier などのビルドツールチェーンが必要ない
    • Scala (やJVM 言語)の まともな モジュールシステムが使える
    • minify や uglifiy は ScalaJS がやってくれる
    • 設定は 謎の DSL ではなく Scala で書けるので補完も効いて嬉しい
  • cats-effect や http4s など、高品質な Scala のライブラリを使える
    • JavaScript のライブラリはシェアのわりに内部の実装が"おやおや🤔"となるものに出会う頻度が高い(主観)
  • もし cloud functions を JVM 上で動くサーバーに変更したくなっても書き直す量が少ない
  • フロントエンドと比べて バンドルサイズの大きさが気にならない(もちろん AWS Lambda の250MBサイズ制限などがあるので無制限とは言わないが...)

※ Sealed Enum について

※ Singleton 型について

Terraform で GCP にデプロイする

Scala の sbt fastOptJSfullOptJS で生成した js ファイルを zip 圧縮してストレージに上げて cloud functions の設定をちょっとゴニョゴニョするだけです.

terraform のソースと GitHub Actions をチェックしてください. また、 g8 i10416/scalajs-cloudfunctions.g8 コマンドで生成されるプロジェクトの REAME.md に手順が載っています.

https://github.com/i10416/scalajs-cloudfunctions.g8/tree/main/src/main/g8/terraform

https://github.com/i10416/scalajs-cloudfunctions.g8/tree/main/src/main/g8/.github/workflows

テンプレートを生成する際に

まとめ

Scala ならこのように Scala で書いたコードを ScalaJS を使って JavaScript にコンパイルすることで node のランタイムで動く(JVMの立ち上げ時間を気にせずに実行できる) cloud functions (や lambda) を使えます. 他のサーバーが Scala で書かれているなら型を共有することができるので json をパースしたり詰め替えたりする手間がひとつへります. うれしいですね. まさに Write Once, Run Anywhere ですね

Scala (or 関数型言語)は実用的じゃないなんて言わせないぞ(^ω^)?

Discussion