みずぴー日記

月に行こうという目標があったから、アポロは月に行けた。飛行機を改良した結果、月に行けたわけではない。

OCamlで作ったWebアプリをHerokuで動かす方法(2) 〜Ocsigen/Eliom編〜

前エントリでは Heroku上でOCamlを動かす方法を紹介したので、このエントリではOCaml製のWebフレームワークであるOcsigen/Eliom を動かす方法を紹介します。

Ocsigen/Eliomとは

OCamlで書かれたWebフレームワークです。

Ocsigenがプロジェクト名かつサーバ名なので、ちょっとややこしいですが、たぶんOcsigenが全体をまとめるプロジェクト名で、EliomはそのうちWebフレームワーク部分です。 他にはJavascriptを生成するjs_of_ocamlや、HTMLを生成するTyxmlなどのサブプロジェクトがあります。

js_of_ocamlを作っていることからも分かるように、SQL,HTML,Javascriptまでを一貫してOCamlだけで書くことができます。そのおかげで表示しているHTMLとそれを操作するJavascriptの間に矛盾がないかなどを型レベルでチェックできたりします。

主な使い方はOcsigen tutorialを読めばだいたい分かります。というか、これぐらいしかドキュメントがない気がします。

Eliomのインストール

まずはopam経由でeliomをインストールします。 結構時間かかるので、コーヒーでも飲みながら待ちましょう。

$ opam install eliom

Hello, worldの作成

必要なファイルの生成

eliom-destillery コマンドで必要なファイルを生成します。

$ eliom-destillery -name hello -destination sample-eliom

起動は make test.opt もしくは make test.byte でできます。

$ make test.opt

デフォルトでは 8080番で起動するので、 http://localhost:8080/ にアクセスして動作を確認します。

あとは適当に .gitignore を書いてコミットしときます。

$ wget
$ git init
$ git add .
$ git commit -m 'hello from type safe world'

Herokuへのデプロイ

さてherokuへのデプロイはちょっとややこしいです。

eliomはサイズが大きいにで、デプロイ枚にビルドするのはキツいので、ビルド済みのパッケージを使うようにします。

setup-opam に以下のように書きます。

#!/usr/bin/env bash
# ビルド済みのeliomが http://codefirst.org/mzp/eliom-3.0.3.tgz に
# 置いてあるので、これを使う

# このディレクトリにいれておけば、複数のビルドの間でファイルを共有できる
CACHE_DIR=$1

function setup() {
  dir=$1
  url=$2

 cache=$CACHE_DIR/$(basename $url)

 if [ -f $cache ]; then
   # キャッシュされたファイルがあるならそれを使う
   echo "use from $cache"
   tar xzf $cache -C $dir
 else
   # 指定されたURLから取得して、キャッシュにも格納する
   echo "fetching $url"
   curl -L $url -s -o - | tee $cache | tar xzf - -C $dir
  fi
}
setup /app/vendor/ocamlbrew/opamlib/system http://codefirst.org/mzp/eliom-3.0.3.tgz

以下のようにheroku用に調整した設定ファイルを作ります。 名前は heroku.conf にしておきます。 ポイントはポート番号を適当な文字列にするのと、一時ファイルの生成先をデプロイ後に唯一書き込めるディレクトリである ./tmp にすることです。 /tmp だとダメです。

<ocsigen>
  <server>  
    <!-- PORTは適当な文字列にしておいてあとで置換する -->
    <port>HEROKU_PORT</port>

    <!-- 一時ファイルの生成先を、./tmpにする -->
    <logdir>./tmp/</logdir>
    <datadir>./tmp/</datadir>
    <commandpipe>./tmp/hello-cmd</commandpipe>

    <charset>utf-8</charset>
    <extension findlib-package="ocsigenserver.ext.staticmod"/>
    <extension findlib-package="ocsigenserver.ext.ocsipersist-dbm"/>
    <extension findlib-package="eliom.server"/>

    <host hostfilter="*">
      <static dir="static" />
      <!-- この辺はアプリケーション名にあわせて書き換える -->
      <static dir="./local/var/www/hello/eliom" />
      <eliommodule module="./local/var/lib/hello/hello.cma" />
      <eliom/>
    </host>
  </server>
</ocsigen>

次に Procfile ですが、ここに長々と書くのはつらいので、起動用のシェルスクリプトをキックするだけにします。

web: ./run.sh

そして ./run.sh に起動方法を書きます。 ポイントはポート番号にしていた適当な文字列を $PORT で置き換えるところです。

#!/usr/bin/env bash
mkdir -p ./tmp
sed "s/HEROKU_PORT/$PORT/" heroku.conf > ./tmp/heroku.conf
/app/vendor/ocamlbrew/opamlib/system/bin/ocsigenserver.opt -c ./tmp/heroku.conf -v

あとは普通にHerokuにpushして、プロセスを立ち上げるだけです。

$ git add .
$ git commit -am 'settings for Heroku'
$ git push heroku master
$ heroku ps:scale web=1

うまく動的リンクライブラリが見つけれない場合は、LD_PRELOADを設定します。 buildpack側でも指定していますが、初回デプロイ以降はいじらないので、途中から切り替えた場合などは必要になります。

$ heroku config:set LD_PRELOAD="/app/vendor/pcre/lib/libpcre.so.1 /app/vendor/gdbm/lib/libgdbm_compat.so.4"

ソースコード

https://github.com/heroku-buildpack-ocaml/sample-eliom

Heroku Postgresを使いたい

Eliomは起動するようになりましたが、HTMLを返すだけだとWebアプリケーションっぽくないですよね。

Herokuが提供しているPostgresqlサーバを使ってみます。

Addonの設定

基本的に Heroku Postgres | Heroku Dev Center の手順に従うだけです。

heroku-postgresqlのaddonを有効にします。

$ heroku addons:add heroku-postgresql:dev

ここの仕組みがよく分っていませんが、データベースに色の名前が割り当てられるので、その値をDATABASE_URLに割り当てます。

$ heroku config | grep HEROKU_POSTGRESQL
HEROKU_POSTGRESQL_RED_URL: postgres://user3123:passkja83kd8@ec2-117-21-174-214.compute-1.amazonaws.com:6212/db982398
$ heroku pg:promote HEROKU_POSTGRESQL_RED_URL
Promoting HEROKU_POSTGRESQL_RED_URL to DATABASE_URL... done

テーブルの定義

heroku pg:psql でDBに接続できるので、テーブルを定義します。

$ heroku pg:psql
Connecting to HEROKU_POSTGRESQL_RED... done
psql (9.1.3, server 9.1.3)
SSL connection (cipher: DHE-RSA-AES256-SHA, bits: 256)
Type "help" for help.

rd2lk8ev3jt5j50=> CREATE TABLE task (id INTEGER PRIMARY KEY,name VARCHAR(255) NOT NULL);

依存ライブラリの追加

EliomでDBに接続する際には、macaqueを利用します。 プロジェクトのページにいっても使い方が書いてなくて寂しいですが、 Ocsigen Tutorial - Type safe database requests using macaqueに比較的まとまっています。

まずは opam 経由でインストールします。

$ opam install macaque

Makefile.options を修正して、macaqueを利用するようにします。

……
# SERVER_PACKAGESにmacaque.syntaxを追加する
SERVER_PACKAGES := macaque.syntax
……

また必須ではありませんが、テスト用にローカルにPostgesサーバを建てておくとよいと思います。

DBへ接続するコードを書く

さて、準備ができたのでOCamlでコードを書きましょう。 macaqueは文法拡張をしてるので、ちょっと独特の見た目になります。

まずはfunctorを使ってPGOCamlとLwtをくっつけます。

module Lwt_thread = struct
    include Lwt
    include Lwt_chan
end
module Lwt_PGOCaml = PGOCaml_generic.Make(Lwt_thread)
module Lwt_Query = Query.Make_with_Db(Lwt_thread)(Lwt_PGOCaml)

テーブルの定義をOCamlにわかる形で書きます。

let table = <:table< task(
   id integer NOT NULL,
   name text NOT NULL
 ) >>

DBに接続するコードは 環境変数 DATABASE_URL の有無で、Heroku用とローカル用の動作を切り替えるようにします。

let connect () =
  try
    (* DATABASE_URLの示すDBに接続しようとする *)
    let url =
      Sys.getenv "DATABASE_URL"
    in
    let rex =
      Pcre.regexp "postgres://(.*):(.*)@(.*):(.*)/(.*)"
     in
     match Pcre.extract ~rex url with
     | [| _; user; password; host; port; database |] ->
      Lwt_PGOCaml.connect ~user ~password ~host ~port:(int_of_string port) ~database ()
     | _ -> raise Not_found
  with Not_found ->  
    (* 失敗したら、ローカルのDBに接続する *)
    Lwt_PGOCaml.connect ~database:"test" ~user:"postgres" ()

毎回接続を貼るとすぐにDBのコネクション数が足りなくなるので、プロセスが生きている間は接続を保持しつづけるようにします。

let get_db : unit -> unit Lwt_PGOCaml.t Lwt.t =
  let db_handler =ref None in
  fun () ->
    match !db_handler with
    | Some h -> 
        (* 確立された接続があるなら、それを使いまわす *)
        Lwt.return h
    | None ->
        let open Lwt in
        connect ()
        >>=  begin fun dbh ->
          (* 接続を確立したらdb_handlerに代入する *)
          db_handler := Some dbh;
          Lwt.return dbh
        end

あとはDBにクエリを投げるだけですが、あるクエリが完了する前に次のクエリが大量に発行されてしまうとリソースが足りなくなるので、クエリを発行してから結果を受け取るまではmutexで保護します。

let mutex = Lwt_mutex.create()

let tasks () =
  get_db ()
  >>= fun dbh ->
    (* Lwt_Query.view の呼び出しは mutex で保護する *)
    Lwt_mutex.with_lock mutex begin fun () ->
      Lwt_Query.view dbh
      <:view< {name = task.name; id = task.id} | task in $table$ >>
    end
  >>= fun results ->
    (* 結果はオブジェクト にする *)
    Lwt.return (List.map (fun obj ->  (obj#id, obj#name))

Herokuへデプロイしてみる

heroku.conf<extension findlib-package="macaque.syntax" /> を追加します。

<ocsigen>
   <server>
     …
     <extension findlib-package="ocsigenserver.ext.staticmod"/>
     <extension findlib-package="ocsigenserver.ext.ocsipersist-dbm"/>
     <extension findlib-package="eliom.server"/>
     <!-- extensionを追加する -->
     <extension findlib-package="macaque.syntax" />
     <host hostfilter="*">
       ……
     </host>
   </server>
 </ocsigen>

あとはこれまで通りgit pushするだけです。

ソースコード

上記のコードを用いて、適当なビューをつけたアプリケーションがheroku-buildpack-ocaml/sample-todo-app以下にあります。 Heroku上で動かしているデモはここにあります。

その他の情報

  • ビルド済みのeliomパッケージを作るスクリプトは (build-scripts/tree/master/eliom)(https://github.com/heroku-buildpack-ocaml/build-scripts/tree/master/eliom) にあります。 他の重いパッケージのバイナリ版を作るときに参考にしていただくとよいと思います。基本的にopam installしてるだけですが。
  • eliomをいれる際に、dbmにパッチをあててます。 どうするのがいいかかなり迷ったんですが、 野良OPAMレポジトリをたてて、そこでパッチを配布しています。
  • 何か不具合がありましたら heroku-buildpack-ocaml までご報告ください。 あなたの pull requestをお待ちしております。