Scala.jsをnodeプロジェクトで扱おうとして挫折した話です。
モチベーション
- 表現力の高いScalaで書ける
- JS特有の処理(特にDOMとかHTML5系API)を別言語で書くのは地雷臭が半端ない
- 変化の早いJSフレームワークにScalaでついていくは無理だと思う
- 依存の少ないモデルやアルゴリズム系だけをScalaで書ければ生産性高そう
- サーバーサイドもScalaで書けばisomorphic実現出来る気がする
本音を言えば誰もやってないから、取り敢えず地雷踏んでみるかというモチベーションです。
プロジェクト方針
基本的にnodeベースのプロジェクト構成を目指します。
これはnode関係のエコシステムが揃っていること、TSやbabelのようにaltJSで違う言語を使いつつエコシステムに乗せるノウハウがあるということでこういう方針にしています。
あと、個人的にあまりsbtが好きじゃないので、そちらに寄せたくないという気持ちもあります。
scala、sbtの準備
Scala.jsを実行するのにscalaとsbtが必要なため準備します。(環境を作ってから気づきましたがsbtだけ入れれば扱えるようです)
Macであればbrew install scala sbt
でまとめてインストールすることができますが、それでは他環境(特にCI環境)でも同様に事前にインストールしなければいけなくなります。
そこで、bin-wrapperを使ってnodeプロジェクトにscalaとsbtを取り込みます。
で、作ったのがこちらになります。
npm install --save-dev scala-bin sbt-bin
npm経由でscalaとsbtをインストールすることで、npmのscriptでコマンドを実行できるようになります。
余談ですがScala.jsはスタンドアローン版が配布されているため、sbtを使わずに単独で実行することができるようです。
こっちで作ろうと思いましたが上手く実行できなかったで諦めました。
まぁIntelliJやScala IDEのようにIDEでScalaを書くときはsbtを使った方が扱いやすいので構いませんが。
Scala.jsの実行準備
Scala.jsのドキュメントを参考に設定とコードを書きます。
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.3")
enablePlugins(ScalaJSPlugin)
scalaVersion := "2.11.6"
import scala.scalajs.js
import js.annotation.JSExport
@JSExport
class Hello {
@JSExport
def world = {
println("Hello World!")
}
}
こんな感じScala.JSを追加して、有効にして、コードを書けば準備完了です。
コードと設定が上手くできているか確認するには
"scripts": {
"compile": "sbt compile"
}
と追加して
npm run compile
とすることで確認ができます。
問題がある場合はエラーが出るのでメッセージをよく読んで格闘してください。
Scala.jsの実行
ここまで出来たらあとはScala.jsでScalaコードをJavascriptのコードに変換するだけです。
"scripts": {
"build": "sbt fastOptJS"
}
と追加して
npm run build
と実行すればJavascriptのコードがtarget/scala-2.11/project-name-fastopt.js
に出力されます。
.map
も出力されるので、出力したコードを扱う場合はセットで扱うと良いと思います。
今回はfastOptJS
を使って出力を行いましたが、Scala.jsではfullOptJS
を使うことでminifyされたコードを出力することができます。
速度に差があるので、開発中なのかプロダクション用の出力なのかで切り替えてください。
今回は実用まで持っていけなかったので諦めましたが、gulpにこのあたりの処理を乗せて引数で切り替えたりすると良さそうです。
nodeライブラリとして扱えるようにする
Scala.jsで出力したJavascriptコードは
(function(){
// ...
}).call(this);
という形でブラウザで実行されることが前提の形になります。
persistLauncher := true
と設定を追加して、js.JSApp
を継承したメイン関数をを作るとproject-name-launcher.js
が出力されて読み込み時に即実行を行えますが、nodeで扱う場合にはrequireでScalaで書いたクラスを取れないし、global汚染が発生するので使い難いです。
そこで、出力されたJavascriptコードをラップしてnodeで扱いやすい形にします。
'use strict';
var fs = require('fs');
var glob = require('glob');
var vm = require('vm');
var files = glob.sync('./target/*/*opt.js');
if (files.length === 0) {
throw new Error('Not found script.');
}
var code = fs.readFileSync(files[0]);
var context = vm.createContext({
global: global,
console: console
});
vm.runInContext(code, context);
module.exports = context.Hello;
詰め切ってないので、もう少し良い書き方がありそうですが、取り敢えずはsandboxを作って実行し、そこからglobalに展開された作成したクラスをmodule.exports
で返します。
こうすることで他からのコードからでも呼びやすくなります。
(複数のクラスを扱うことを考えるとsbtプラグイン作ってnode用のlauncherを出力するのが良さそうですが、そこに行く前に挫折しました)
合わせてpackage.json
もnpmで配布できるように設定を変更します。
"main": "index.js",
あとは.npmignore
かpackage.json
のfiles
フィールドを使って、index.js
と生成したJavascriptコードのみをnpmにpublishできるようにします。
(このあたりも挫折してたどり着けなかったので細かい設定がないです)
テスト
Scala.jsを使う場合はScala定番のScalaTestを使うことができません。
(sbtベースで使おうとするとテストケースを発見することができません。IntelliJなどのIDE経由だと動かせます)
Scala.jsのトップページのTesting frameworksでどれもあまり聞かないフレームワークであまり食指が動きません。
今回の場合だと、どうせ変換したJavascriptコードに対してテストを書く必要があるし、あまり聞かないものを使って苦労するぐらいならmocha+power-assert構成でいいやという感じです。
鉄板構成すぎてこれといって構成が思いつかないときは、この構成にしておけば安パイですね。
テスト方法としては先ほど作ったラッパーに対して行います。
基本的にScalaでコードを書くときはイミュータブルにするので、他の補助ツールはいらない印象です。
(これも挫折して辿り着けませんでしたが非同期処理系は何かツールが必要になるかもです)
挫折した話
ちょっと長い前振りも終わってここからが本題です。
取り敢えず、構成も一通りできたので、実際にコードを書いてみよう!ということでscala-quizのMyListでも移植してみたところ見事に挫折しました。
applyを使えない
Scalaでapplyを定義するとMyList(1, 2, 3)
と呼び出すことができます。
https://github.com/k-kinzal/scala-js-example-app/blob/master/src/main/scala/MyList.scala#L191-L204
https://github.com/k-kinzal/scala-js-example-app/blob/master/src/test/scala/MyListSpec.scala#L8
しかし、Scala.jsで変換したコードを扱う場合にMyList.apply(1, 2, 3)
というようにapply
を明示的に呼び出さないといけません。
https://github.com/k-kinzal/scala-js-example-app/blob/master/test/MyList.spec.js#L9
地味に面倒臭いです。
objectをインスタンス化する必要がある
Scalaでobjectキーワードを使うとシングルトンのオブジェクトを作成することができます。
https://github.com/k-kinzal/scala-js-example-app/blob/master/src/main/scala/MyList.scala#L191-L204
しかし、Scala.jsで変換したコードを扱う場合に一度newしてインスタンス化してあげる必要があります。
https://github.com/k-kinzal/scala-js-example-app/blob/master/index.js#L18
地味に面倒臭いです。
プレースホルダがない
https://github.com/k-kinzal/scala-js-example-app/blob/master/src/test/scala/MyListSpec.scala#L15
https://github.com/k-kinzal/scala-js-example-app/blob/master/test/MyList.spec.js#L18-L20
ScalaとJSコードを見比べると分かるのですが、JSではプレースホルダがないので関数を渡す関数がダルいです。
JSからScalaを呼ぶとかしているので、こう感じてしまうのは仕方ないことなのですが、他にも細かくScalaの良い書き方ができないのでMPをガリガリ削られます。
Scala.jsは別に悪くない。
オーバーロードしたメソッドを出力できない
オーバーロードしたメソッドに@JSExport
で出力を指定するとエラーが発生してビルドに失敗します。
https://github.com/k-kinzal/scala-js-example-app/blob/master/src/main/scala/MyList.scala#L164-L172
[error] /Users/Kinzal/Dropbox/Projects/scala-js-example-app/src/main/scala/MyList.scala:186: Exported method mkString conflicts with MyCons.$js$exported$prop$mkString
対応のしようがないので、オーバーロードしないか、1つのメソッドだけに@JSExport
を指定するしかないです。
Scala.jsで変換したコードに無名関数を渡せない
https://github.com/k-kinzal/scala-js-example-app/blob/master/src/main/scala/MyList.scala#L17-L21
https://github.com/k-kinzal/scala-js-example-app/blob/master/test/MyList.spec.js#L18-L20
Scala.jsで変換したコードに無名関数を渡そうとするとTypeError
が発生します。
{ 's$1': 'An undefined behavior was detected: function (a, b) {\n return a + b;\n } is not an instance of scala.Function2',
'e$1':
{ 's$1': 'function (a, b) {\n return a + b;\n } is not an instance of scala.Function2',
'e$1': null,
'stackTrace$1': null,
stackdata: [TypeError: undefined is not a function] },
'stackTrace$1': null,
stackdata: [TypeError: undefined is not a function] }
is not an instance of scala.Function2
と言われている通り、たぶんScala.jsで準備された関数しか渡せないんだろうなと思います。
どうやっても渡せなかったのでここで心が折れました。
おわりに
JS側に公開されるインターフェースを意識して、上手くやればnodeとScalaを共存させてあげることはできそうな雰囲気はあります。
ただ、自分はもう心が折れてもうダメです。
残骸を置いておくので、誰か自分の代わりにMyListのテストをmochaで全て通してください・・・。
たぶん、ここが全て通せるようになればTSやbabelを使うときにScala.jsを使うという選択肢を取れるようになってくると思います。
Scala関西 Summit 2015にScala.jsでLTに応募しようと思ったけどScala.js難しすぎた。
追記(2015/05/31)
無名関数用のクラスがあるので、Scala.jsで変換済みのコードを、さらに変換して抜き出せるようにすれば良い訳ですよ。JSはAST文化あるので普通にやれそうな気がする。 pic.twitter.com/Q5Uro532P5
— 左傾化 (@k_kinzal) 2015, 5月 31
foldLeftで無名関数渡せない・・・と思ってたけど、まだやりようがありそうな予感です。