Edited at

ElixirとPlugで静的Webサーバを作ってみる

More than 1 year has passed since last update.


はじめの余談

リアルタイムWebのサーバを構築するために、最初はnode.jsを利用していたのだが、取っつきやすい反面、(一言では言いにくいが)プログラムにどのようなデータがやってくるのかわからない面倒があり、サービスに持ってゆくのに不安があった。そんなこんなで別のものを探った先に Elixir にたどり着いたのだが、これのパターンマッチング最高、自然と想定外のデータが排除されていき、堅牢になるではないか。Erlang だと単一代入の原則で、1回毎に変数名を変えなければならないが、Elixirだと同じ名前でも別の変数だとわかってくれるので、これも最高。関数型プログラミングもこれなら怖くない。

とはいえ、Elixir でのプログラミングにおいて、Hello World 的な簡単な例であれば簡単に情報は得られるが、実際に Web アプリケーションを構築しようとして、Plug を使おうとしても、どう使っていけばよいかということを汲み取ることは、なかなか困難であった。あれこれ調べた結果、どのようにプログラムを構成してゆけばよいかということについて、たどり着いた知見をここに共有する。


アプリケーション作成環境

Elixir で Plug モジュールを利用して、あるディレクトリ下に展開された静的 Web コンテンツを配信する Web サーバを制作する。

プログラミングに使用したElixir、ライブラリのバージョンは以下の通りである。

アプリ/ライブラリ
バージョン

Elixir
1.7.3

Plug
1.6.2

Cowboy
2.4.0


プロジェクト作成

Elixir プログラミングということで、mix から始める。まず、mix new コマンドで自動作成されるテンプレートにコードを追加する。

mix new に --sup オプションをつけて、Supervisor タイプのひな形を作成する。

$ mix new simple_httpd --sup

* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating config
* creating config/config.exs
* creating lib
* creating lib/simple_httpd.ex
* creating lib/simple_httpd/application.ex
* creating test
* creating test/test_helper.exs
* creating test/simple_httpd_test.exs

Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:

cd simple_httpd
mix test

Run "mix help" for more commands.
$ cd simple_httpd

アプリケーションが依存するプロジェクトとして、mix.exs の deps ファンクション内に、Cowboy と Plug を追加する。Plug は Cowboy に依存するので両方を書く。


mix.exs

  defp deps do

[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
{:cowboy, "~> 2.4.0"}, # CowboyをHEXからダウンロード
{:plug, "~> 1.6.2"}, # PlugをHEXからダウンロード
]
end

同時に、Cowboy プロジェクトと Plug プロジェクトを起動するために、application ファンクションの extra_application にも :cowboy と :plug を追加する。そして、アプリケーションのスタートポイントとなる SimpleHttpd.Application モジュールの呼び出しパラメータを追加する。このプログラムでは、アプリケーション名のアトム :simple_httpd をパラメータとした。これは、Application.get_env/3 ファンクションで、環境から設定パラメータを読み取るために使用する。


mix.exs

  def application do

[
extra_applications: [:logger, :cowboy, :plug], # cowboyとplugを実行
mod: {SimpleHttpd.Application, [app: project()[:app]]} # SimpleHttpd.Application.start/2を起動
]
end

この後、依存するパッケージをダウンロードするためにコマンドを実行する。

$ mix deps.get

Webサーバのパラメータは、Mix.Config 形式で記述した。ここでは、httpのポート番号、https のポート番号、サーバ証明書ペア、静的コンテンツを収めたディレクトリ、インデックスファイルの候補リストを指定した。


config/config.exs

config :simple_httpd, port: 8080

config :simple_httpd, sslport: 8443
config :simple_httpd, keyfile: "host.key"
config :simple_httpd, certfile: "host.crt"
config :simple_httpd, document_root: "/contents/directory"
config :simple_httpd, directory_index: ["index.html", "index.htm"]


Plug モジュールの基本


モジュール Plug とファンクション Plug

Plug モジュールによるプログラミングでは、主に、Web サーバに対してクライアントがリクエストを発行した時起動されるハンドラを記述する作業になる。

ハンドラは、モジュール形式のモジュール Plug、またはファンクション形式のファンクション Plug として書く。

モジュール Plug のインターフェースは単純で、ファンクション init/1 とファンクション call/2 の2つのファンクションを持つモジュールを定義するだけである。call/2 のパラメータは、Plug.Conn 構造体とファンクション固有のオプションパラメータで、init/1 はオプションパラメータを初期化するファンクションである。

ファンクション Plug の場合は、更に単純で、Plug.Conn 構造体とオプションパラメータを取るアリティ2のファンクションである。

defmodule PlugModuleExample do

def init(opt) do
# パラメータの初期化
...
opt
end
def call(conn, opt) do
# ハンドラ本体
...
conn
end
end

Plugは、Plug.Conn 構造体のデータを受け渡しながらパイプラインとして実行するよう設計されている。


Plugモジュールのパイプライン

    conn = PlugModule1.call(conn, opt1)

conn = PlugModule2.call(conn, opt2)
...
conn

そして、Plug.Conn 構造体の halted フィールドが true の時、パイプラインの実行を中断することを期待している。


中断を考慮したパイプライン

    try do

conn = PlugModule1.call(conn, opt1)
if conn.halted, do: throw conn
conn = PlugModule2.call(conn, opt2)
if conn.halted, do: throw conn
...
conn
catch
conn -> conn
end

Plug.Router や Plug.Builder は、このようなパイプラインを実行する Plugモジュールを作成するためのマクロである。


HTTPリクエストの待ち受けとハンドラの登録

クライアントからのコネクションを待ち受ける機能は、Plug.Adapter が受け持つ。Supervisor が Plug.Adapter を起動するように children に記入する。Supervisor の詳しい説明は、Elixir に関する解説を参照のこと。

Cowboy2 を起動する Supervisor 用のパラメータは、Plug.Adapters.Cowboy2.child_spec/1 ファンクションで生成できる。child_spec/1 ファンクションのパラメータは、キーワードパラメータで、以下のキーを設定する。

キー
説明

:scheme
:http または、:https

:plug
クライアントからのリクエストハンドラのモジュール

:options
ポート番号やサーバ証明書ファイルなどを指定する

パラメータの詳細については、Plug のドキュメントを参照のこと。

ここでは、http 用と https 用の2つのサービスを登録した。また、サーバ証明書の鍵ファイルと証明書ファイルは、Path.expand(path, System.cwd())を使って、カレントディレクトリからの相対パス、または絶対パスで指定できるようにした。


lib/simple_httpd/application.ex

  def start(_type, [app: app]) do

# Applicationの環境からサーバオプションを取り出す
app_env = Application.get_all_env(app)
port = Keyword.get(app_env, :port, 80)
sslport = Keyword.get(app_env, :sslport, 443)
keyfile = Application.get_env(app, :keyfile) |> Path.expand(System.cwd())
certfile = Application.get_env(app, :certfile) |> Path.expand(System.cwd())

# List all child processes to be supervised
children = [
# Starts a worker by calling: SimpleHttpd.Worker.start_link(arg)
# {SimpleHttpd.Worker, arg},
Plug.Adapters.Cowboy2.child_spec([scheme: :http,
plug: {SimpleHttpd.PlugTop, [app: app]},
options: [port: port]]), # http用サービス
Plug.Adapters.Cowboy2.child_spec([scheme: :https,
plug: {SimpleHttpd.PlugTop, [app: app]},
options: [port: sslport, keyfile: keyfile, certfile: certfile, otp_app: app]]), # https用サービス
]
...



Plug.Builder を使う


Plug.Builder による Plug モジュールの作成

Plug.Adapters からコールされるリクエストハンドラとして呼び出されるモジュール SimpleHttpd.PlugTop を Plug.Builder を使って作成する。

モジュール内で use Plug.Builder を呼び出し、plug/2 マクロで Plug モジュールを指定し、Plug のパイプラインを構成する。ここでは、Plug.Logger モジュール、Plug.Static モジュール、後述の PlugNotFound モジュールを書く。

Plug.Builder のデフォルトのオプションでは、Plug モジュールの init/1 ファンクションは、コンパイル時に呼び出される。したがって、この場合、実行時に設定ファイルから読み出した値を利用することはできない。

静的コンテンツの配信に Plug.Static モジュールを利用する。plug/2 マクロは、1番目の引数にモジュール名、2番目の引数にモジュールの init/1 ファンクションに渡す引数を与える。Plug.Static モジュールの必須パラメータは、at: と from: で、パスのプレフィクスとコンテンツのディレクトリを指定する。詳しくは、Plug.Static モジュールのリファレンスマニュアルを参照のこと。

以下のように Plug.Adapters のハンドラを Plug.Builder で作成すると、"/contents/directory" ディレクトリの下に配置した静的コンテンツを配信する Web サーバとして実行できる。


lib/simple_httpd/plug_top.ex

defmodule SimpleHttpd.PlugTop do

use Plug.Builder

plug Plug.Logger, log: :debug
plug Plug.Static, at: "", from: "/contents/directory"
plug SimpleHttpd.PlugNotFound
end



Plug モジュールを自作する

PlugNotFound モジュールを作る。この Plug モジュールは、404 Not Found リプライを返すだけのモジュールである。Plug モジュールパイプラインの最後に実行し、返すべきコンテンツがなかったときのリプライを出力する。

リプライを返したモジュールは、Plug.Conn.halt/0 を実行し、Plug.Conn構造体の halted フラグをセットする。こうすることで、Plug モジュールパイプラインの実行を終了する。


lib/simple_httpd/plug_not_found.ex

defmodule SimpleHttpd.PlugNotFound do

def init(opts) do
opts
end

def call(conn, _opts) do
Plug.Conn.put_resp_content_type(conn, "text/html") # charset=utf-8が付加される
|> Plug.Conn.send_resp(404, """
<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL #{Plug.HTML.html_escape(conn.request_path)} was not found on this server.</p>
</body></html>
"""
)
|> Plug.Conn.halt()
end
end



Plug.Builder モジュールで実行時にパラメータを評価する

前述の PlugTop モジュールでは、コンパイル時に Plug モジュールの init/1 ファンクションを実行したが、init_mode: オプションをつけることで、init/1 ファンクションを実行時に評価させることができる。ただし、プログラムで指定したパラメータがマクロ展開されるだけなので、このモジュールの call/2 ファンクションのパラメータを init/1 のパラメータにすることができない。

アプリケーション名のアトム :simple_httpd をリテラルで指定しているが、以下のようにして、コンテンツのトップディレクトリをアプリケーション設定データから取り込むことができる。


lib/simple_httpd/plug_top.ex

defmodule SimpleHttpd.PlugTop do

use Plug.Builder, init_mode: :runtime

plug Plug.Logger, log: :debug
plug Plug.Static, from: Application.get_env(:simple_httpd, :document_root), at: ""
plug SimpleHttpd.PlugNotFound
end


以上で、config.exs で指定したコンテンツディレクトリ下の静的コンテンツを公開する Web サーバを構成できた。


Plug.Builder を使わない構成


Plug パイプラインの作成

Plug.Builder では、call/2 ファンクションのパラメータを利用することができないので、リクエストハンドラの Plug モジュールを自作し、Plug パイプラインを作成する。前述のように、Plug パイプラインは、halted フラグをチェックしながら Plug モジュールを順に呼び出すだけである。Application モジュール内では、リクエストハンドラを登録する際に予めアプリケーション名をモジュールのオプションパラメータで渡している。

この後の処理で、コンテンツへのパスを利用することが多いので、Plug.Conn 構造体から得たコンテンツへのパスを Plug.Conn 構造体に含めて共有した。Plug.Conn 構造体にはユーザデータの保存用に、assigns フィールドが用意されている。この中に Map 形式でデータを保存できる。call/2 ファンクションの冒頭で、set_path_params/2 ファンクションを呼び出し、このファンクション内でコンテンツファイルへのパスを求め、assigns フィールドに保存した。

call/2 ファンクションの本体では、順にログモジュール、ディレクトリ関係のモジュール、静的ファイル用のモジュール、404 コード出力モジュールを呼び出す。前述のように、halted フィールドをチェックして、リプライが完了した時点でパイプラインの処理を終了する。


lib/simple_httpd/plug_top.ex

defmodule SimpleHttpd.PlugTop do

def init(opts) do
opts
end

defp set_path_params(conn, app) do
app_env = Application.get_all_env(app)
doc_root = Keyword.get(app_env, :document_root)
path = conn.request_path |> Path.expand()
path_info = :binary.split(path, "/", [:global]) |> tl()
abs_path = doc_root <> path
Plug.Conn.merge_assigns(conn, [app_env: app_env,
doc_root: doc_root,
path: path,
path_info: path_info,
abs_path: abs_path])
end

def call(conn, [app: app]) do
conn = set_path_params(conn, app)
try do
conn = Plug.Logger.call(conn, :debug)
if conn.halted, do: throw conn

conn = SimpleHttpd.PlugPathCheck.call(conn, [])
if conn.halted, do: throw conn

conn = SimpleHttpd.PlugDirectoryPortal.call(conn, [])
if conn.halted, do: throw conn

conn = SimpleHttpd.PlugDirectoryIndex.call(conn, [])
if conn.halted, do: throw conn

opts = Plug.Static.init([at: "", from: conn.assigns.doc_root])
conn = Plug.Static.call(conn, opts)
if conn.halted, do: throw conn

conn = SimpleHttpd.PlugNotFound.call(conn, [])
conn
catch
conn -> conn
end
end
end



SimpleHttpd.PlugPathCheck モジュール

URL がディレクトリを指しているが、パスの末尾がスラッシュで終わっていない時、スラッシュを付けてリダイレクトするリプライを返すモジュール。例えば、

http://example.com/dir1/dir2 は、 http://example.com/dir1/dir2/ にリダイレクトさせる。


SimpleHttpd.PlugDirectoryPortal モジュール

URL がディレクトリを指していて、ディレクトリ内に index.html など、config.exs の :directory_index に設定されたファイルが含まれる時、このファイルを出力する。


SimpleHttpd.PlugDirectoryIndex モジュール

URL がディレクトリを指しているとき、ファイル一覧のコンテンツを出力する。


完成版のソースコード

完成版のソースコードは GitHub のリポジトリにある。この SSL サーバ証明書は別途作成し、(ソースコードの設定では)host.key、host.crt という名前で、アプリケーションを実行するディレクトリに置く。