Scalaの便利かつモジュラーなライブラリ群であるAirframeシリーズに収録されているAirframe RPCで遊んでみたところ、とても面白かったので紹介。
あらすじ
先日社内でちょっとした理由があってNext.jsを集中的に書く機会があった。最近のNext.jsにはServer Actionsという機能があり、クライアントサイドからサーバサイドの関数をほぼ透過的に呼び出すことができる。これを使って、Prismaを使ったCloudSQLへの操作がものすごく短距離で可能になり、感銘を受けた。
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を使うことで以下のように透過的な通信ができるようになった!
Airframe-RPC * Scala 3 * Scala.jsでサーバクライアント間通信に成功した。Next.jsのServer Actionsみたいな感じでサーバ側のメソッドをScala.js側から自由に呼び出せて面白い pic.twitter.com/CPhjvo0CLg
— Windymelt(めるくん)🚀❤️🔥 (@windymelt) 2023年11月5日
サンプルコードは以下に公開している。
Airframeとは
Airframe RPCが収録されているAirframeについても少し説明しておくと、Scalaで良く使われる汎用的な部品を集めたライブラリの集まりがAirframeだ。Treasure Data社によって開発・運用されているらしい。
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を実装してみよう。
プロジェクト構成
以下のような構造のプロジェクトを作る。
今回は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で行くことにする。
これに合わせて、プロジェクトのルートディレクトリにapi
、server
、client
の3つのディレクトリを用意する。
以下のようなディレクトリ構造になるはず。client
のクロスビルド設定がCrossType.Full
(デフォルト)になっているため、client
以下にjs
、jvm
、shared
を用意する必要があることに注意*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.json
にvite
実行用のスクリプトを追加しておく:
{ "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/
を開くと、開発者コンソールにサーバが返したレスポンスが出力されるはずだ。
メソッド名の突合や引数と返り値のマーシャリング・アンマーシャリングは全てAirframe RPCがやってくれている。呼び出す側からは、普通にメソッドを呼んでいるようにしか見えない。
ネットワークペインを開くと、中では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にはフィルタ機能があって、やろうと思えばこれで認証などもできるようだ。
かなり使い味が良かったので、今後もちょっとした用途に使ってみたい。Laminarとの組み合わせがどうなるかなど気になる。
*1:今回はJSしか使わないのでややオーバー気味だけど