Elmでサーバサイドで動くワーカープログラムを作る

  • 11
    Like
  • 0
    Comment

1. Webアプリ以外もElmで書ける(EXPERIMENTAL)

Elmのいいところはバランスの良いところで、Haskellの持つ文法的・言語機能的にcrypticな部分を設計思想として意図的に削ぎ落としつつも1、純粋関数型・静的型付け言語の利点を存分に享受できる点だと思っています。コンパイラは非常に開発者に優しく(!)、リファクタリングもデバッグも安心してできます。安心感大事。

そんな言語なので、隙あらば使いたくなるわけです。ちなみに筆者は軟派なのでHaskellは眺めるだけのことが多いですが、Elmに機能的に満足できくなった人はHaskellに行ったりPureScriptに行ったりする選択肢があるのでそこそこ潰しも効くと思います。Elmをいっぱい書いていると自然と型クラスが何故嬉しいのかわかるようになる(欲しくなる)し、何故か雰囲気でモナドもわかるようになるかもしれません。

公式でもreliableなWebアプリがdelightfulに開発できる言語と謳っているので、ElmはWebアプリを書くためのDSLなのか? などという議論もあったりしますが、もちろんそれだけってことはなくて、汎用言語としてなんでも好きなプログラムをElmで書けます。

ただし、ブラウザ以外の場所で動くプログラムを作ろうと思うと、若干nodejsの助けを多めに受ける必要があり、Elmで書いたプログラムはnodejsプログラムの中で「エンジン」として働かせるようなイメージになります。この記事ではその部分のやり方をまとめます。

1.1. Webアプリ以外もElmで書く「べき?」

べき、ではないです。適材適所という言葉があります。
実験的にElmでサーバ・クライアント両方書いているeeue56/take-homeのREADMEから引用すると、

As an Elm programmer, I like to write Elm! As a server-side Elm programmer, I hate writing yet another integration library that wraps around a Node library that uses mutable objects and callbacks in a weird way.

Elmは今のところWebアプリを開発する言語として活発に使われ始めているところで、そちらの方面でのコア開発やpackage開発は様々に行われていますが、サーバサイドの開発で必要になるような機能はないことのほうが多いです。ちょっとしたことをやろうと思うだけでも思わぬ苦労をすることがあるでしょう。Nodeライブラリへのportを書くのもすんなりとはいかないことも多いようです。

追記: また、EvanもOn “General-Purpose” LanguagesでElmの現状の方向性をはっきりと表明しています。(言語コア開発やアカデミックに関わるトピックをまとめているelm-lang/projectsレポジトリの内容なので、エンドユーザ向けの提言というわけではありませんが)

I also think it is important to clearly inform people what the language is decent at right now. That way they do not waste their time on paths we already know that Python or Erlang or C can do better right now.

ということで、あくまでElmの強みは「今は」Webアプリ開発です。そしてそこにおいて非常に強力だと思います。

でも他の分野への適用も、趣味・実験の範囲でやってみてもいいと思います。筆者はやろうとしています。
(Evanのメッセージにならって、項目名にEXPERIMENTALとでっかく書いておきました)

2. Elmでワーカープログラム

ちょうど今、AmazonのProduct Advertising APIをクロールしてデータを集め、Kindle本の推薦を行うようなアプリを作ろうかと思っており、せっかくなのでElmでサーバサイドをどの程度書けるか試してみる、というのが動機です。

クローラは要はサーバ上でずっと動いているワーカー的なプログラムになるわけで、よくあるElmでWebアプリを作るチュートリアルとは少し毛色が違います。しかしElmは0.18からPlatform.programというAPIをcoreの中に持っています。これを使うと、viewを持たない"headless"プログラムを書くことができ、外部のnodeプログラムからmoduleを参照して使うことができるのです。

ここで面白いのが、headlessだからといってWebアプリのElmプログラムと変わらず、ほとんど同じようにTEAを使って書ける点です。外部コードとの対話をportで実現する点も同じです。むしろportを初めて使うきっかけにheadlessプログラムを書いてみるのはありかもしれません。

2.1. 準備

まず最低限:

$ npm i elm -D

Elmはグローバルにインストールしたものを使ってもいいのですが、ここではローカルインストールします。
package.jsonにnpm scriptsを書いておき、node_modules/.bin/以下に配置されるバイナリにnpm run経由でアクセスできるようにしておくのが最近よく見る手法ですね(要出典)

package.json(elm-make)
{
  ...

  "scripts": {
    "build": "elm-make --yes --debug --warn --output=dist/worker.js src/Worker.elm",
    "elm-package": "elm-package"
  },
  "devDependencies": {
    "elm": "^0.18.0"
  }
}

Headlessプログラムであればビルドには上記のようにelm-makeを生で使ってもいいです。その場合、Elmプログラムと我らがV8との間を繋ぐもう一つのnodeプログラム(エントリポイントとなるもの)を用意し、生成物(dist/worker.js)を呼び出して使うことになります。が、どっちにしろ複数ファイル間に依存関係が生じるのが確定しているのであれば、最初からWebpackとelm-webpack-loaderを使っておくほうがいいと思います。その場合以下のようになります。

$ npm i webpack elm-webpack-loader -D
package.json(Webpack)
{
  ...

  "scripts": {
    "build": "webpack",
    "clean": "rm -rf dist/",
    "elm-package": "elm-package"
  },
  "devDependencies": {
    "elm": "^0.18.0",
    "elm-webpack-loader": "^4.3.1",
    "webpack": "^3.6.0"
  }
}

webpack.config.js
const path = require('path')

module.exports = {
  entry: {
    worker: ['./src/worker.js'],
  },

  target: 'node',

  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
  },

  module: {
    rules: [
      {
        test: /\.elm$/,
        exclude: [
         /elm-stuff/,
         /node_modules/,
        ],
        use: {
          loader: 'elm-webpack-loader',
          options: {
            warn: true,
          },
        },
      },
    ],
  }
}

ElmチュートリアルでWebpackで開発する場合の流れが丁寧に解説されていますが、ここではさらにそれを削り込んでNodeプログラムの中で動かす場合の最小の構成にしました。見て分かる通りチュートリアルのwebpack.config.jsより小さくまとまるので、Elmへの導入としてこれもありではないかと思います。

target: 'node'はWebpackに、対象プログラムをブラウザではなくNode環境向けにビルドするよう指示するプロパティで、こうするとライブラリによってはNode環境専用の変数やAPIが読み込まれます(aws-sdkなど)。今回は不要ですが一応書いておきます。

以下Webpackを使った環境を前提とします。生成物のdist/worker.jsは、それ自体がエントリポイントとなりつつ、Elm由来のプログラムも含みます。

2.2. Hello World! (sort of)

早速Elmを書きます。

src/Worker.elm
module Worker exposing (main)

import Platform


type alias Model =
    {}


type Msg
    = NoOp


init : ( Model, Cmd Msg )
init =
    ( {}, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NoOp ->
            ( model, Cmd.none )


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none


main : Platform.Program Never Model Msg
main =
    Platform.program
        { init = init
        , update = update
        , subscriptions = subscriptions
        }

(Elmのシンタックスハイライトがねえ。```haskell使ってます)

見ての通り何もしないプログラムですが、ポイントはPlatform.programを使っているところです。引数recordの中にviewが不要ですね。

そしてこれを呼び出すグルーコードを用意します。

src/worker.js
var Elm = require('./Worker.elm')

var worker = Elm.Worker.worker()

module名Workerにしたのは失敗だったけど気にしない。Html.programの場合はElm.<ModuleName>.fullscreen()Elm.<ModuleName>.embed()していましたが、headlessプログラムの場合はworker()を呼びます。

ビルド・実行してみます。

$ npm run build

Hash: 754d4db0c118b4785ca3
Version: webpack 3.8.0
Time: 891ms
    Asset     Size  Chunks             Chunk Names
worker.js  72.3 kB       0  [emitted]  worker
   [0] multi ./src/worker.js 28 bytes {0} [built]
   [1] ./src/worker.js 68 bytes {0} [built]
   [2] ./src/Worker.elm 69.3 kB {0} [built]

$ node dist/worker.js

$ echo $?
0

実行しても何も出ません。当然ですね。

が、ここでもう一つポイントがあって、このプログラムは実行後すぐ返ってきます。つまりworker()というそれっぽい関数を呼んだからといってElmで書いたプログラム部分がそれだけで何らかの待受ループを開始して常駐するということはありません。今のままでは本当に何もしないmoduleがそこに存在しているというだけです。

2.3. Hello World! (really)

つまりWorkerモジュールはエンジンですから、点火する必要があるわけです。

このプログラムのエントリポイントはあくまでworker.js(ビルド前も、ビルド後も)ですから、そこからElmのコードに命令を送り込むことになります。JavaScriptとElmとの間で対話するには? portですね。ということでportを書きましょう。

src/Worker.elm
@@ -1,4 +1,4 @@
-module Worker exposing (main)
+port module Worker exposing (main)

 import Platform

@@ -8,7 +8,7 @@ type alias Model =


 type Msg
-    = NoOp
+    = StartMsg String


 init : ( Model, Cmd Msg )
@@ -19,13 +19,18 @@ init =
 update : Msg -> Model -> ( Model, Cmd Msg )
 update msg model =
     case msg of
-        NoOp ->
-            ( model, Cmd.none )
+        StartMsg text ->
+            text
+                |> Debug.log "Started"
+                |> always ( model, Cmd.none )


 subscriptions : Model -> Sub Msg
 subscriptions model =
-    Sub.none
+    start StartMsg
+
+
+port start : (String -> msg) -> Sub msg


 main : Platform.Program Never Model Msg
src/worker.js
@@ -1,3 +1,5 @@
 var Elm = require('./Worker.elm')

 var worker = Elm.Worker.worker()
+
+worker.ports.start.send('Hello World!')

portについてはofficial guideのこちらに導入があります。
こちらも参考: ElmのPortでJSを使う。

ポイントとしては、ここでsubscriptionsを使うことになります。外部環境から何らかの入力を得て、それをmessageとして受け取る仕組みで、Elmプログラムの中で重要な役割を担います。

ビルドして実行してみましょう。

$ node dist/worker.js
/Users/yumatsuzawa/workspace/blaze/dist/worker.js:3271
var _ymtszw$blaze$Worker$start = _elm_lang$core$Native_Platform.incomingPort('start', _elm_lang$core$Json_Decode$string);
                                                                                      ^

ReferenceError: _elm_lang$core$Json_Decode$string is not defined
    at Object.<anonymous> (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:3271:87)
    at Object.<anonymous> (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:3317:4)
    at __webpack_require__ (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:20:30)
    at Object.<anonymous> (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:77:11)
    at __webpack_require__ (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:20:30)
    at Object.<anonymous> (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:70:18)
    at __webpack_require__ (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:20:30)
    at /Users/yumatsuzawa/workspace/blaze/dist/worker.js:63:18
    at Object.<anonymous> (/Users/yumatsuzawa/workspace/blaze/dist/worker.js:66:10)
    at Module._compile (module.js:570:32)

大ショック!!! ランタイムエラーです。
Elmではランタイムエラーは起こらないんじゃなかったのかよ! 裏切られた気持ちになるのも仕方ないですね。Googleさんだってこう言ってる:
スクリーンショット 2017-10-20 3.02.53.png
(それっぽい候補が釣れるような単語を探したけど)

まあ落ち着きます。portはJavaScriptとの界面ですからね。こういうところではエラーが起きるのも仕方ない。

調査するとこのような Issueが見つかります。どうもmodule依存関係の解決のバグのようだ。じゃあJavaScript関係ねえじゃん! Fixは0.19に向けてすでに入っているとのこと。ここではワークアラウンドします。

src/Worker.elm
@@ -1,6 +1,7 @@
 port module Worker exposing (main)

 import Platform
+import Json.Decode


 type alias Model =

使ってないようにみえるけれど、port処理の中身でJavaScriptの値をElmの値に変換するために使われているJson.Decodeimportしておきます。ビルドして実行すると、

$ node dist/worker.js
Started: "Hello World!"

$ echo $?
0

無事Elmプログラムが呼ばれていることを確認できました。しかし、すぐ返ってくるのはそのままですね。

2.4. ループさせる

クローラのようなプログラムでは、適当な間隔で無限に(interruptされるまで)処理を繰り返す事になるでしょう。つまりループさせたい。

JavaScriptでナイーブにこれを実現しようとするなら、setTimeoutで関数を再帰的に呼ぶとか、scheduler系のライブラリを使うとかでしょうか。
一方Elmでは、このような時間依存のコードはsubscriptionを使って書けます。

src/Worker.elm
@@ -2,19 +2,21 @@ port module Worker exposing (main)

 import Platform
 import Json.Decode
+import Time exposing (Time)


 type alias Model =
-    {}
+    { running : Bool }


 type Msg
     = StartMsg String
+    | TickMsg Time


 init : ( Model, Cmd Msg )
 init =
-    ( {}, Cmd.none )
+    ( { running = False }, Cmd.none )


 update : Msg -> Model -> ( Model, Cmd Msg )
@@ -23,12 +25,20 @@ update msg model =
         StartMsg text ->
             text
                 |> Debug.log "Started"
+                |> always ( { model | running = True }, Cmd.none )
+
+        TickMsg time ->
+            time
+                |> Debug.log "Tick"
                 |> always ( model, Cmd.none )


 subscriptions : Model -> Sub Msg
-subscriptions model =
-    start StartMsg
+subscriptions { running } =
+    if running then
+        Time.every (5 * Time.second) TickMsg
+    else
+        start StartMsg


 port start : (String -> msg) -> Sub msg

少し工夫して、modelの中にループが実行中かどうか示すフラグ(running)を導入し、実行中であればTime.everyで経過時間イベントに対してsubscribeし、実行中でなければstart portにsubscribeしておくようにしています。
modelの状態に応じてsubscribeするイベントを絞り込むことで、アプリケーションが内部的に管理する状態を減らすことができます。2

これをビルドして実行すると、

$ node dist/worker.js
Started: "Hello World!"
Tick: 1508440727195
Tick: 1508440732201
Tick: 1508440737201
Tick: 1508440742203
Tick: 1508440747206
Tick: 1508440752206
Tick: 1508440757209
Tick: 1508440762215
Tick: 1508440767221
Tick: 1508440772223
Tick: 1508440777229
^C⏎

無事、ループするプログラムを作れました。あとはそれぞれのtickごとに好きな処理をさせればいいということで、ワーカープログラムを作る土台ができました。

実際の環境で実行する際のプロセスの死活監視などについては、Foreverやpm2など、一般的なツールを使えばいいと思います。スケールアウトできるようなプログラムであれば、pm2で複数インスタンスをコア数分だけparallelに走らせるのもいいでしょう。

2.5. (おまけ) Graceful Stop

今のままだとプログラムはCtrl + Cで強制停止させることになります。それ自体は問題なくても、Tickごとの処理が一通り終わるのを待ってgracefulに止めたいかもしれません。

色々なやり方がありそうですが、シンプルにworker.jsの中でSIGINTにハンドラを仕込んでみます。

src/worker.js
@@ -3,3 +3,7 @@ var Elm = require('./Worker.elm')
 var worker = Elm.Worker.worker()

 worker.ports.start.send('Hello World!')
+
+process.on('SIGINT', () => {
+  worker.ports.interrupt.send(null)
+})

interrupt portからElmに伝達しています。せっかくElmで書くからには、可能な限り処理をすぐに移譲して、あとはElmで書きたいですね。Elm側も終了処理のコードを書きます。

src/Worker.elm
@@ -7,17 +7,19 @@ import Time exposing (Time)

 type alias Model =
     { running : Bool
+    , terminating : Bool
     }


 type Msg
     = StartMsg String
+    | InterruptMsg ()
     | TickMsg Time


 init : ( Model, Cmd Msg )
 init =
-    ( { running = False }, Cmd.none )
+    ( { running = False, terminating = False }, Cmd.none )


 update : Msg -> Model -> ( Model, Cmd Msg )
@@ -26,25 +28,36 @@ update msg model =
         StartMsg text ->
             text
                 |> Debug.log "Started"
-                |> always ( { model | running = True }, Cmd.none )
+                |> always ( { model | running = not model.terminating }, Cmd.none )

         TickMsg time ->
             time
                 |> Debug.log "Tick"
                 |> always ( model, Cmd.none )

+        InterruptMsg () ->
+            Debug.log "Interrupted" "Terminating now!"
+                |> always ( { model | running = False, terminating = False }, Cmd.none )
+

 subscriptions : Model -> Sub Msg
 subscriptions { running } =
-    if running then
-        Time.every (5 * Time.second) TickMsg
-    else
-        start StartMsg
+    Sub.batch
+        [ interrupt InterruptMsg
+        , (if running then
+            Time.every (5 * Time.second) TickMsg
+           else
+            start StartMsg
+          )
+        ]


 port start : (String -> msg) -> Sub msg


+port interrupt : (() -> msg) -> Sub msg
+
+
 main : Platform.Program Never Model Msg
 main =
     Platform.program

少々こまっしゃくれたコードになりました。

根幹としては、interrupt portからのトリガーをInterruptMsgで受け、runningFalseにしています。これによってsubscriptionの状態が変わり、Time.everyがなくなりますので、アプリケーション内に生きているタイマーがなくなり、プログラム全体が停止します。3

ただし、SIGINTの受け入れはアプリケーションのライフサイクルの中でいかなるタイミングでも行いたいという注文があるかと思います。そこでterminatingという新たな状態を導入して、たとえstartがトリガーされてもterminating = Trueであればrunning = Trueに遷移しないようロックしています。(running = not model.terminating
その上で、interrupt portのsubscribeは常に行うようにし、Sub.batchを使ってその他のsubscriptionとまとめています。

実行して、割り込んでみましょう。

$ node dist/worker.js
Started: "Hello World!"
Tick: 1508449990436
Tick: 1508449995442
^CInterrupted: "Terminating now!"

$ echo $?
0

Time.everyを剥がすだけで、安全にプログラムを停止させられました。少しいじってみるとわかりますが、Time.everyを剥がさなければもちろん停止しません。SIGINTをトラップしてしまっているので、その場合Ctrl+Cでは止められないプログラムの出来上がりです。SIGKILLしましょう。

実際のプログラムでは、真にgraceful stopするためにはもう少し細かいチェックをしたりするかもしれません。とはいえ、必要な状態変数をmodelに適宜導入しつつ、TEAの枠組みの中でこのように書いていけるでしょう。

3. まとめ

ElmでWebアプリ以外もまあまあ書けそうです。
追記: とはいえ、今はデッカく"EXPERIMENTAL"が付くことは忘れずに。

記事内のコードは https://github.com/ymtszw/blaze に置いてあります。
文中で言及した、余暇開発中のKindle本レコメンダアプリのレポジトリなので、他のコードがどんどん増えていくかと思いますが、Worker.elmとworker.jsはキープしておく予定です。他にもネタがあったら書く予定。


  1. .とか見ても初心者何もわからんだろ! でも>>ならなんとなく記号の向き的に処理の流れがわかるんじゃなーい?(前方関数合成)」とか。$の代わりに<|(後方関数適用)とか。 

  2. ただ、外部のJavaScript環境から任意のタイミングで呼ばれる可能性のあるport関数のsubscriptionを外してしまうと、当然ながらその後その関数に対して値がsendされた場合何も起こりません。(とはいえ、幸いRuntime errorにはならないようです。ありがとうElm!!!!) 

  3. ちなみに、特にJavaScriptから値を受け取る必要がないケースでは、Elm側は()つまりUnit型を使い、JavaScriptからはnullを送ることができます。ここでは大きな意味はありません。