Lambdaカクテル

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

Invite link for Scalaわいわいランド

ScalaのRPCライブラリAirframe RPCを使うとNext.js風に簡単にサーバ=クライアント通信できる

Scalaの便利かつモジュラーなライブラリ群であるAirframeシリーズに収録されているAirframe RPCで遊んでみたところ、とても面白かったので紹介。

wvlet.org

wvlet.org

あらすじ

先日社内でちょっとした理由があってNext.jsを集中的に書く機会があった。最近のNext.jsにはServer Actionsという機能があり、クライアントサイドからサーバサイドの関数をほぼ透過的に呼び出すことができる。これを使って、Prismaを使ったCloudSQLへの操作がものすごく短距離で可能になり、感銘を受けた。

nextjs.org

Server Actionsを使うと、クライアントサイドのTypeScriptコードから簡単にサーバサイドのコードを実行できる:

'use client';

// client.ts

import { saveToDB } from './actions'

const onClickSaveButton = () => {
  const data = ...
  saveToDB(data);
};
'use server';

//actions.ts

type Data = ...

const saveToDB = (data: Data) => {
  // call Prisma, etc.
};

このような仕組みが可能なのは、Next.jsが内部的にこの呼び出しをPOSTリクエストに変換して送信し、その結果を自動的に変換しているためだ。

このような仕組みをScala.jsでもできないかと思っていたところ、Airframe RPCというライブラリがあれば同じようなことができるらしいことを知った。このライブラリを使うと、REST APIを自動的に構成し、パラメータや返り値を自動的にマーシャル・アンマーシャルしてくれるらしい。

結論から言うと、Airframe RPCを使うことで以下のように透過的な通信ができるようになった!

サンプルコードは以下に公開している。

github.com

Airframeとは

Airframe RPCが収録されているAirframeについても少し説明しておくと、Scalaで良く使われる汎用的な部品を集めたライブラリの集まりがAirframeだ。Treasure Data社によって開発・運用されているらしい。

logmi.jp

Airframeという単体のライブラリがあるのではなく、Airframe DIやAirframe Parquetなど、Airframeの名をブランドとして冠したライブラリがたくさんあって、それらの相互運用性が確保されており自由に組み合わせられるという感じだ。その中でもAirframe HTTPやAirframe RPC、Airframe DIが主役っぽい。

Airframe RPC

Airframe RPCはその名の通りScalaでRPC(Remote Procedure Call)を行うためのライブラリ。RPCとはおおざっぱに言うと遠隔地にあるマシンのメソッドを呼び出すことであり、例えばREST APIもHTTPを使ったRPC技術のうちの一つと考えるとわかりやすい。

Airframe RPCを使うとScalaのtraitで定義したAPIに対して自動的にRPC用インターフェイスを実装してくれる。これによって、ほぼ透過的にScala間で通信できるようになる。しかも、同じScalaを使っているScala.jsにも対応しており、APIインターフェイスをクロスコンパイルすることでサーバ=クライアント通信も行える。今回はこのサーバ=クライアント通信をやってみる。

Airframe RPCは基盤技術としてREST APIとgRPCとの2つの通信手段を利用できるようになっており、例えばJVM間通信ではgRPCを、Scala.jsからはブラウザで楽に利用できるREST APIを使うといった使い分けができる。この設定はbuild.sbtで行える。 REST APIは簡素でありどこからでも呼び出せる一方、gRPCはHTTP/2を利用したより高速で高効率な通信を行えるという特長がある。

Scala(JVM)とScala.jsとでサーバクライアント通信を行う

今回は例として、サーバ側のJVMで動作するScalaのAPIを、ブラウザ上のJSエンジンで動作するScala.jsから透過的に呼び出してみる。例として、「ユーザIDを渡すと対応するユーザ情報をOption[User]で返してくれる」という典型的なAPIを実装してみよう。

プロジェクト構成

以下のような構造のプロジェクトを作る。

apiインターフェイスにclientとserverが依存する

今回はScalaのデファクトなビルドツールであるsbtでプロジェクトを構成する。使用するScalaは3.3.1とする。

また今回はJVMとJSが関わるため、一部のモジュールをクロスコンパイルする。このためsbt-crossprojectを使って、APIインターフェイスはJVMとJSの両方にビルドする。加えてAirframe RPCのためのプラグインと、Scala.jsのためのプラグインをproject/plugins.sbtに導入する。

//  project/plugins.sbt

// For RPC client generation
addSbtPlugin("org.wvlet.airframe" % "sbt-airframe" % "23.11.1")

// For Scala.js
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.14.0")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")

次にビルド設定を記述する。プロジェクトは3つのサブプロジェクトで構成し、それぞれにサーバ側コード、クライアント側コード、そして両者から参照されるAPI定義コードを実装する。だいたい公式ドキュメントの引き写しだが、build.sbtに以下のように記述する:

import org.scalajs.linker.interface.ModuleInitializer
import org.scalajs.linker.interface.ModuleSplitStyle

val scala3Version = "3.3.1"
val AIRFRAME_VERSION = "23.11.1"

// Common build settings
val buildSettings = Seq(
  organization := "io.github.windymelt",
  scalaVersion := "3.3.1"
  // Add your own settings here
)

// RPC API definition. This project should contain only RPC interfaces
lazy val api =
  crossProject(JSPlatform, JVMPlatform)
    .crossType(CrossType.Pure)
    .in(file("api"))
    .settings(
      buildSettings,
      libraryDependencies ++= Seq(
        "org.wvlet.airframe" %%% "airframe-http" % AIRFRAME_VERSION
      )
    )

// RPC server project (JVM)
lazy val server =
  project
    .in(file("server"))
    .settings(
      buildSettings,
      libraryDependencies ++= Seq(
        // Add Netty backend
        "org.wvlet.airframe" %% "airframe-http-netty" % AIRFRAME_VERSION
      )
    )
    .dependsOn(api.jvm)

// RPC client project (JVM and Scala.js)
lazy val client =
  crossProject(JSPlatform, JVMPlatform)
    // CAVEAT: By default, crossProject selects CrossType.Full; you should create client/{js,jvm,shared} directory
    .in(file("client"))
    .enablePlugins(AirframeHttpPlugin)
    .enablePlugins(ScalaJSPlugin)
    .jsSettings(
      scalaJSUseMainModuleInitializer := true,
      scalaJSLinkerConfig ~= {
        _.withModuleKind(ModuleKind.ESModule)
          .withModuleSplitStyle(
            ModuleSplitStyle.SmallModulesFor(
              List("io.github.windymelt.airframeexercise.client")
            )
          )
      }
    )
    .settings(
      buildSettings,
      airframeHttpClients := Seq(
        // APIが定義されているパッケージ:rpc:生成するクラス名 の順に書く
        // gRPCを利用する場合はrpcの代わりにgrpcと書く(Scala.jsでは使えなそう)
        "io.github.windymelt.airframeexercise.api.v1:rpc:ServiceRPC"
      )
    )
    .dependsOn(api)

今回はgRPCではなくRESTを使ったRPCで行くことにする。

これに合わせて、プロジェクトのルートディレクトリにapiserverclientの3つのディレクトリを用意する。

以下のようなディレクトリ構造になるはず。clientのクロスビルド設定がCrossType.Full(デフォルト)になっているため、client以下にjsjvmsharedを用意する必要があることに注意*1

% tree -L 3 -d
.
├── api
│   └── src
│       └── main
├── client
│   ├── js
│   ├── jvm
│   └── shared
│       └── src
├── project
├── server
│   └── src
│       └── main
...

API定義

Airframe RPCで通信するためには、まずインターフェイスを定義する必要がある。インターフェイスはapiサブプロジェクトに特定のトレイトを置くだけで定義できる。

api/src/main/scala/io.github.windymelt.airframeexercise/api/v1/App.scalaを作成して、以下のようにしてインターフェイスを定義する:

package io.github.windymelt.airframeexercise.api.v1

import wvlet.airframe.http._

// 必要なケースクラスもここに定義すればよい
case class User(id: Long, name: String, born: Int)

// RPCインターフェイスにしたいtraitに@RPCを置く
@RPC trait MyService:
  def getUserById(id: Long): Option[User]

// sbtプラグインに「RPCですよ」と教えるため?にコンパニオンオブジェクトを配置する必要があるっぽい
object MyService extends RxRouterProvider:
  override def router: RxRouter = RxRouter.of[MyService]

これだけでAPIの定義は完了。Next.jsほど楽ではないが、簡単!

サーバ実装

さて、今度はサーバ側の実装を進める。といっても、「受け取ったidが42のときUserを返す」くらいの適当な実装しかしていない。ここは普段のScalaと全く同じ世界だ。

server/src/main/scala/io.github.windymelt.airframeexercise/api/v1/App.scalaを作成して、MyServiceを実装する:

package io.github.windymelt.airframeexercise.api.v1

class MyServiceImpl extends MyService:
  override def getUserById(id: Long): Option[User] = id match
    case 42L =>
      println("got 42")
      Some(User(42L, "Windymelt", 1993))
    case _   => None

まぁ何のことはない。ただインターフェイスを実装しているだけだ。

最後にHTTP通信という形でRPCを仲介してくれるNettyサーバを起動する処理を組込めば、サーバ側実装は終わり。

server/src/main/scala/io.github.windymelt.airframeexercise/Server.scalaを作成して、エントリポイントを作成する:

package io.github.windymelt.airframeexercise

import wvlet.airframe.http.netty.Netty
import wvlet.airframe.http._

object Server extends App {
  // Create a Router
  val router = RxRouter.of[api.v1.MyServiceImpl]

// Starting a new RPC server.
  Netty.server
    .withRouter(router)
    .withPort(8080)
    .start { server =>
      server.awaitTermination()
    }
}

フロントエンド実装

フロントエンドは以下のように構成する:

  • Scala.jsでScalaをJSにトランスパイルする
  • バンドラとしてViteを利用する

まずVite導入のために、プロジェクト内でいろいろ導入する:

% npm init
% npm i -D vite @scala-js/vite-plugin-scalajs

次にpackage.jsonvite実行用のスクリプトを追加しておく:

{
  "name": "airframe-rpc-exercise",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "@scala-js/vite-plugin-scalajs": "^1.0.0",
    "vite": "^4.1.0"
  }
}

次にViteのバンドルとサーバの設定を行う。JSを配信するサーバとAPIサーバとが別ホストの場合、CORSの影響を受けてローカルで確認しづらいのでViteにAPIサーバへの通信をプロキシしてもらう。vite.config.jsを作成して以下のように記述する:

import { defineConfig } from "vite";
import scalaJSPlugin from "@scala-js/vite-plugin-scalajs";

export default defineConfig({
    plugins: [scalaJSPlugin({
        projectID: 'clientJS', // sbt-scalajs-crossproject は自動的に JS をクロスビルドしたプロジェクトの末尾に付けるので、ここはclientJSでよい
    })],
    server: {
        proxy: {
            '^/io\.github\.windymelt\.airframeexercise\.api\.v1\.MyService/.*': 'http://localhost:8080', // APIサーバ宛通信をプロキシさせる
        }
    },
});

Viteにバンドルさせるmain.jsを作成する。実質的な中身はScala.jsに任せるので、1行だけでよい。

import 'scalajs:main.js'

バンドルされたスクリプトを呼び出すindex.htmlを作成する:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <script type="module" src="/main.js"></script>
  </body>
</html>

最後に、client/shared/src/main/scala/App.scalaにクライアントコードを実装する:

package io.github.windymelt.airframeexercise.client

import scalajs.js.annotation.*
import io.github.windymelt.airframeexercise.api.v1._
import wvlet.airframe.http.Http

object ClientMain {
  val client =
    ServiceRPC.newRPCAsyncClient(Http.client.newAsyncClient("localhost:5173")) // Vite proxy
  @main def main(): Unit = {
    println("Hello, Scala.js!")
    println(client)
    val result = client.MyService
      .getUserById(42L)
      .run(result => println(s"got $result"))
    println(result)
  }
}

RPCは非同期に結果を返すため、runを使って結果をコンソールに出力させている。

ここまで準備ができたら、npm run devを実行するとViteがsbtを経由してScala.jsをトランスパイルし、開発サーバが立ち上がる。

% npm run dev
VITE v4.5.0  ready in 11148 ms
Local:   http://127.0.0.1:5173/

JVMサイドでもサーバを立ち上げる。

% sbt server/run
[info] running io.github.windymelt.airframeexercise.Server
2023-11-07 00:04:54.952+0900  info [LifeCycleManager] [session:39223900] Starting a new lifecycle ...  - (LifeCycleManager.scala:284)
2023-11-07 00:04:54.955+0900  info [LifeCycleManager] [session:39223900] ======== STARTED ========  - (LifeCycleManager.scala:288)
2023-11-07 00:04:55.084+0900  info [NettyServer] Starting default server at localhost:8080  - (NettyServer.scala:172)

ブラウザで http://127.0.0.1:5173/ を開くと、開発者コンソールにサーバが返したレスポンスが出力されるはずだ。

Scala.jsからサーバに通信できている

メソッド名の突合や引数と返り値のマーシャリング・アンマーシャリングは全てAirframe RPCがやってくれている。呼び出す側からは、普通にメソッドを呼んでいるようにしか見えない。

ネットワークペインを開くと、中ではJSONがやりとりされていることがわかる。

API名がパスに含まれている

レスポンスは自動的にJSONに変換される

ケースクラスなどの複雑な型を利用しても、Airframe RPCがうまく型を導出してJSONに変換できるようにしてくれている。

まとめ

  • Airframe RPCを使うと、ほぼ透過的にサーバサイドのAPIをScala.jsから呼び出せることを示した。
  • Next.jsのServer Actionsほど手軽ではないが、手でHTTPエンドポイントなどの定義を一切行なわずにサーバサイドのAPIを呼び出せるため、Scala.jsから活用できることを示した。
  • シリアライズフォーマットとしてMessagePackも使えるらしいので調査したい。
    • 手元でAcceptを書き換えたら勝手にMessagePackにしてくれた。
    • クライアントコードをHttp.client.withMsgPackEncoding.newAsyncClient("localhost:5173")のように書き換えたら動いた。
    • ちょっと手を加えたらCBOR対応もできそうだな〜

ところでAirframe RPCにはフィルタ機能があって、やろうと思えばこれで認証などもできるようだ。

wvlet.org

かなり使い味が良かったので、今後もちょっとした用途に使ってみたい。Laminarとの組み合わせがどうなるかなど気になる。

*1:今回はJSしか使わないのでややオーバー気味だけど

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