LoginSignup
6
1

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-09-15

はじめの余談

リアルタイム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 という名前で、アプリケーションを実行するディレクトリに置く。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1