isomorphic web development with scala and scala.js
TRANSCRIPT
Isomorphic Web development
with Scala & Scala.jsTanUkkii
isomorphic tokyo meetup 2015/4/30
I am ...• @TanUkkii007 on Twitter
• Web frontend engineer
• だったけどゲーム開発が辛くてサーバーサイドをScalaで開発する人に
• Scala業務歴4ヶ月
Agenda
1. 開発環境を共有する
2. コードを共有する
3. アーキテクチャを共有する
クライアントーサーバー間で
Motivation
• Scala + Akka + SprayでAPIサーバーを開発
• StrongLoopのApi Exprolerみたいなのを作りたい
• Scala.jsでisomorphicにつくる!
REST/HTTP server build on Akka Actors
Why Scala.js?
• クライアントーサーバーで同一の開発環境
• クライアントーサーバーでコードの共有
• 片手間クライアント開発
1. Sharing development environment:
Building applications with sbt
• プロジェクト定義
• Scalaのバージョン
• 依存ライブラリ
• コンパイルオプション
Java/Scalaのビルドツール
設定 タスク
name := “your_project_name”scalaVersion := "2.11.6"libraryDependencies ++= Seq( "com.typesafe.akka" %% “akka-actor" % "2.3.10") !scalacOptions in ThisBuild ++= Seq("-feature")
build.sbt
• 依存ライブラリの解決
• コンパイル
• テスト
• REPLの起動
Multi-project build
- client
- server
- root
import sbt._import Keys._object IsomorphicBuild extends Build { lazy val root = project.in(file(“.")) lazy val server = Project(“server", file(“server")) lazy val client = Project("client", file(“client”)) !}
project/Build.scala
sbtではサブプロジェクトを複数定義できる
↓サーバーもクライアントも サブプロジェクトとして定義
相似のプロジェクト構造ができる→
import sbt._import Keys._import org.scalajs.sbtplugin.ScalaJSPluginimport org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._object IsomorphicBuild extends Build { lazy val root = project.in(file(".")).aggregate(server, client) lazy val server = Project(“server", file(“server")) lazy val client = Project("client", file(“client")).enablePlugins(ScalaJSPlugin) .settings( persistLauncher in Compile := true, skip in packageJSDependencies := false ) }
project/Build.scala
Make Scala.js project
!addSbtPlugin(“org.scala-js" % "sbt-scalajs" % "0.6.2")
project/plugins.sbt Scala.jsプラグインを追加
clientプロジェクトで Scala.jsプラグインを有効化
Make Scala.js project isomorphic
!import sbt._import Keys._import org.scalajs.sbtplugin.ScalaJSPluginimport org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._object IsomorphicBuild extends Build { lazy val root = project.in(file(".")).aggregate(server, client) lazy val server = Project(“server", file(“server")) lazy val client = Project("client", file(“client")).dependsOn(server).enablePlugins(ScalaJSPlugin) .settings( unmanagedSourceDirectories in Compile += (sourceDirectory in server).value/"main"/"scala"/"jp.isomorphic.example" / “shared", packageJSDependencies in Compile := { val base = (packageJSDependencies in Compile).value IO.copyFile(base, (baseDirectory in server).value / "src/main/resources/js" / base.getName) base }, persistLauncher in Compile := true, skip in packageJSDependencies := false ).settings(Seq(fastOptJS, fullOptJS) map { packageJSKey => crossTarget in (Compile, packageJSKey) := (baseDirectory in server).value / "src/main/resources/js" }) }
project/Build.scalaサーバーからクライアントに クラスパスを通す
(Scalaコンパイルが可能に)
コンパイル対象に サーバー側のソースの一部を追加
(Scala.jsコンパイルが可能に)
Scala.jsのコンパイル結果を サーバー側にコピー
Using CrossProject to build isomorphic project structure
-shared
- js
- jvm
import sbt.Keys._import sbt._ import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._!!object ApplicationBuild extends Build { lazy val root = project.in(file(".")) lazy val sharedProject = crossProject.in(file(".")) .settings() .jvmSettings() .jsSettings() ) lazy val js: Project = sharedProject.js.settings() lazy val jvm: Project = sharedProject.jvm.settings()} !
ScalaJSPluginの CrossProjectを使えば 簡単にisomorphicな
プロジェクト構造を作れるscalajs-spa-tutorialを参照scalajs-cross-compile-example,
2. Sharing codes between Client and Server
• Scala.jsで利用可能なライブラリ
• シリアライゼーションによるScalaデータ型の通信
• 型安全なAPIの呼び出し
• マクロ
Available Libraries
• DOM
• jQuery
• React.js
• AngularJS
www.scala-js.orgにもっと多く載っている
JSライブラリの 型付けされたインターフェース
• Scalaz
• NICTA/rng
Scalaライブラリのポート
• Scala.Rx
• Monifu
• autowire
• uPickle
最初からクロスコンパイル前提で 作られたライブラリ※Scalaは型だけ提供。実装はJS。
※本家のクロスコンパイルできない 部分を修正してJSを提供
※ScalaとJSを提供
Pickling (serialization)
• クラス階層情報の喪失
Scala.jsの大問題:可逆的なJSONのシリアライズ
リフレクションを使わずにコンパイル時に少ないコードで解決しなければならない。
サーバー クライアントJSON
{"fruits": [{"color": "yellow"}, {"color": "red"}]}
{"points": [{"x": 1, "y": 2}, {"x": 1, "y": 2}]}
null [[]]
val fruits: List[Fruit] = List(Banana("yellow"), Apple("red"))
val p = Point(1,2); val points = List(p, p)
val option: Option[Option[Int]] = None
通信
• 参照同一性の喪失
• Optionの扱い
Scalaデータ型 Scalaデータ型
Cross-compiled pickling libraries
きれいなJSON形式
クラス階層の保持
参照同一性の保持
Anyの解決
uPickle ○難しいことは忘れて
きれいなJSONを吐くことに注力 Optionが配列として表現される
Prickle ○ ○ 可逆性を高めるため メタ情報をJSONに保持させている
Scala.js Pickling ○ ○ 型の登録処理が事前に必要
サポートがあまりよくない
Binary serialization with BooPickle and XHR2
def request(method: String, url: String, body: ArrayBuffer): Future[ByteBuffer] = { val promise = Promise[ByteBuffer] val xhr = new XMLHttpRequest() xhr.onreadystatechange = { (e: Event) => if (xhr.readyState == 4) { if (xhr.status >= 200 && xhr.status < 300) { val byteBuffer = TypedArrayBuffer.wrap(xhr.response.asInstanceOf[ArrayBuffer]) promise.success(byteBuffer) } else promise.failure(AjaxException(xhr)) }} xhr.open(method, url) xhr.responseType = "arraybuffer" xhr.setRequestHeader("Content-Type", "application/octet-stream") xhr.setRequestHeader("Accept", "application/octet-stream") xhr.send(body) promise.future}
val byteBuffer = Pickle.intoBytes(SampleRequest("Hello"))request(method, url, byteBuffer.arrayBuffer()).map(Unpickle[SampleResponse].fromBytes(_))
• JSONではなく、バイナリにシリアライズ • XHR level2のバイナリサポートを利用 • JSのArrayBufferを使う • メディアタイプは application/octet-stream • クラス階層、参照の同一性も復元可能
Client-server communication with Autowire
object AutowireClient extends autowire.Client[String, Reader, Writer]{ override def doCall(req: Request): Future[String] }
trait MyApi { def sampleRequest(id: Int): SampleResponse}
object MyApiImpl extends MyApi{ def sampleRequest(id: Int) = SampleResponse(id)}
AutowireClient[MyApi].sampleRequest(1).call().map(println)
path("api" / Segments){ s => extract(_.request.entity.asString) { e => complete { AutowireServer.route[MyApi](MyApiImpl)( autowire.Core.Request(s, upickle.read[Map[String, String]](e)))}}}
object AutowireServer extends autowire.Server[String, Reader, Writer]{ val routes = AutowireServer.route[MyApi](MyApiImpl)}
Autowire
マクロマジック!!
AutowireはAjaxにおける クライアントーサーバー間のAPIの煩雑さを
RPCスタイルのメソッド呼び出しで 解決するライブラリ
• なぜかRPCは自動で解決される • API呼び出しにおける間違いはコンパイル時に発見される
1. sharedでRPCインターフェースを定義
2. serverでRPCインターフェースを実装
3. serverでルーティング部分関数をマクロにより生成
4. serverでルーティング部分関数を呼び出してレスポンスを返す
5. clientでAjaxの通信の仕方を実装
6. clientでRPC関数を呼び出す
Macroperforming macro expansion AutowireServer.route[jp.isomorphic.example.MyApi] (<empty> match { case autowire.Core.Request(Seq("jp", "isomorphic", "example", "MyApi", "sampleRequest"), (args$macro$1 @ _)) => autowire.Internal.doValidate({<synthetic> <artifact> val x$2 = autowire.Internal.read[String, Int](args$macro$1, scala.util.Left(autowire.Error.Param.Missing("id")), "id", ((x$1) => AutowireServer.read[Int](x$1)));Nil.$colon$colon(x$2)}) match { case scala.$colon$colon((id @ (_: Int @unchecked)), Nil) => scala.concurrent.Future.successful(MyApiImpl.sampleRequest(id)).map(((x$3) => AutowireServer.write(x$3)))case _ => $qmark$qmark$qmark} }: autowire.Core.Router[String])
種明かし:-Ymacro-debug-liteコンパイルオプションでマクロを展開する
package jp.isomorphic.example trait MyApi { def sampleRequest(id: Int): SampleResponse}
マクロは ←から、関数のシグニチャに対して パターンマッチをかける部分関数 を作っていた
※Scalaのマクロは抽象構文木を操るすごいやつです
3. Sharing Application Architecture
Scalaでフロントエンドのアプリケーションをいざ書くときに
今までやってきたことをScalaでどう表現するとよいか迷う
オブザーバーパターン → ?
アーキテクチャをシェアして コンテクストスイッチのコストを減らそう!
Common Practice: Unidirectional Data Flow
• Tell, don’t ask.
• Fire and forget.
Flux
Scalajs SPA Tutorialがこの共通点からアプローチしている
• unidirectional data flow
• message passing
複雑さに対抗する手段のコンセプトは同じ
Flux in Scala
つまりStoreがActorになったScalajs SPA Tutorial* image from
trait Actor { type Receive = PartialFunction[Any, Unit] def receive: Receive def !(message: Any) = { receive(message) }}
trait Dispatcher { var actors = Set.empty[Actor] def dispatch(message: Any) = { actors.foreach { actor => actor ! message } } def register(actor: Actor) = { actors = actors + actor } def unregister(actor: Actor) = { if (actors.contains(actor)) { actors - actor } }}
Dispatcherは Actorプログラミングにおける
Recipient Listパターン
var Dispatcher = { _listeners: [], register: function(callback) { this._listeners.push(callback); return this; }, unregister: function(callback) { var index = this._listeners.indexOf(callback); if (index !== -1) { delete this._listeners[index]; } return this; }, dispatch: function(...args) { this._listeners.forEach(callback => callback.apply(this, args) ); return this; } };
※Recipient List: メッセージを複数のアクターに拡散し仕事を分散して処理する際に、拡散先の受信者である
アクター参照の一覧を保持しているもの
object SampleDispatcher extends Dispatcherobject SampleActorProtocol { case class Foo(message: String) case class Bar(message: String) } object SampleActor extends Actor { import SampleActorProtocol._ SampleDispatcher.register(this) def receive: Receive = { case Foo(message) => println(message) case Bar(message) => println(message) }} !import SampleActorProtocol._SampleDispatcher.dispatch(Foo("Hello"))
Dispatcher.register(function(payload) { if (payload.type === "FOO") { console.log(payload.message); } else if (payload.type === "BAR") { console.log(payload.message); } }); !Dispatcher.dispatch({type: "FOO", message: "Hello"}); !!!!!!!!!!!
• Dispatcherに関数ではなくActorオブジェクトを登録する • payloadを処理する関数はActorのReceive部分関数に相当 • payloadはメッセージ
Conclusion
• Scala + Scala.jsでクライアントーサーバーを高度に統合できる
• (ただし他のシステムとのinteropが犠牲になるかも
• みんなScala.jsを使おう!!!