超高速なJSON APIをElixirフレームワークのPhoenixでビルドしてテストしよう

連絡先リストの役割を果たすJSON APIをビルドしてみましょう。ElixirとPhoenixを使って書きます。PhoenixはElixirベースのフレームワークで、記述の高速化と、低遅延のWebアプリケーションをできる限り楽しく作成する目的で作られています。ElixirやPhoenixのインストール手順についてはここでは触れません。まず初めにPhoenixのガイドを読んでください。

なぜElixirとPhoenixなのか

Erlangは、ポンコツの板金にくるまれたフェラーリのようなものです。ものすごいパワーを持っていますが、見た目が悪いと感じる人が多いのです。WhatsAppはこれを使って膨大な数の接続を処理していますが、見慣れないシンタックスとツールの不足のせいで多くの人が苦労しています。Elixirはそこを改善したものです。Erlang上に構築されていますが、美しくかつ楽しめるシンタックスを備え、mixなどのツールが、効率よくビルド、テスト、アプリケーションとの連携を促進してくれます。

Phoenixでは、Elixir上で楽しみながら非常に低遅延のWebアプリケーションを作成し構築することができます。超高速のアプリケーションと楽しめる開発環境は、もはや相互矛盾するものではありません。ElixirとPhoenixはその両方を与えてくれます。Phoenixのレスポンスタイムはミリ秒ではなくマイクロ秒で計測されることも少なくありません。

さて、このフレームワークを使ってビルドしてみたい理由をお話ししました。今度は実際にビルドしてみましょう。

テストを書く

PhoenixのWebサイト上のはじめにを参照して、HelloPhoenixという名前の新しいアプリを作成してみましょう。この演習にはPhoenix 0.10.0を使います。

Phoenixのアプリケーションが設定できたら、まずはテストを書いてみましょう。次のファイルを作成します。
test/controllers/contact_controller_test.exs

defmodule HelloPhoenix.ContactControllerTest do
  use ExUnit.Case, async: false
  use Plug.Test
  alias HelloPhoenix.Contact
  alias HelloPhoenix.Repo
  alias Ecto.Adapters.SQL

  setup do
    SQL.begin_test_transaction(Repo)

    on_exit fn ->
      SQL.rollback_test_transaction(Repo)
    end
  end

  test "/index returns a list of contacts" do
    contacts_as_json =
      %Contact{name: "Gumbo", phone: "(801) 555-5555"}
      |> Repo.insert
      |> List.wrap
      |> Poison.encode!

    response = conn(:get, "/api/contacts") |> send_request

    assert response.status == 200
    assert response.resp_body == contacts_as_json
  end

  defp send_request(conn) do
    conn
    |> put_private(:plug_skip_csrf_protection, true)
    |> HelloPhoenix.Endpoint.call([])
  end
end

まず、setupの関数を書きます。テストを始める時に常にデータベースが空であるということを保証するための処理の中で、Ecto callsをラップします。

テスト自体は、特別なものではありません。use Plug.Testによって、接続を生成するconn/2関数にアクセスできます。このテストでは、新規の連絡先を1件挿入し、1つのリストにラップしてエンコードします。次に、新たな接続を生成し、リクエストを送ります。レスポンスの成功と、ボディがJSONにエンコードされた連絡先リストを含むことをアサートしましょう。

mix testを実行すると、HelloPhoenix.Contact.struct/0 is undefined, cannot expand struct HelloPhoenix.Contactというエラーが表示されます。これは、まだモデルが生成されていないためです。Ecto を用いて、Postgresデータベースに接続しましょう。

データベースを構築する

Ectoは、データベースのデータ保存と取り出しにリポジトリを使います。Phoenixにはあらかじめリポジトリがインストールされ、初期設定がなされています。config/dev.exsconfig/test.exsの実行で、Postgresのユーザとパスワードが正しいことを確認してください。

mix -h | grep ectoの実行によって、Ectoから新規に取得したmixのタスクを見てみましょう。

複数のタスクが使えることが分かったら、開発環境とテスト用データベースを構築しましょう。その後、最初のモデルを追加します。

# This will create your dev database
$ mix ecto.create
# This will create your test database
$ env MIX_ENV=test mix ecto.create

連絡先モデルを追加する

web/models/contact.ex内のContactにスキーマを追加しましょう。

defmodule HelloPhoenix.Contact do
  use Ecto.Model

  schema "contacts" do
    field :name
    field :phone

    timestamps
  end
end

次に、mix ecto.gen.migration create_contactsでマイグレーションを作ります。新規生成したマイグレーションに、次のように記述します。

defmodule HelloPhoenix.Repo.Migrations.CreateContacts do
  use Ecto.Migration

  def change do
    create table(:contacts) do
      add :name
      add :phone

      timestamps
    end
  end
end

Ectoマイグレーションに初期設定されているコラムは、:stringです。その他のオプションは、Ecto.Migration docsを参照してください。

では、mix ecto.migrateを実行して新規テーブルを生成し、もう一度MIX_ENV=test mix ecto.migrateでテストします。

ルートとコントローラを追加する

/api/contactsといったルートを用いて、APIのエンドポイントにつなぎましょう。

# In our web/router.ex
defmodule HelloPhoenix.Router do
  use Phoenix.Router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/api", HelloPhoenix do
    pipe_through :api

    resources "/contacts", ContactController
  end
end

Railsを知っている人なら、/api/contacts.jsonが404 not foundを返すことに気づくでしょう。ここでは、適切なリクエストヘッダを設定する必要があります。ひとまず、/api/contacts?format=jsonで実行できますが、おすすめしません。パフォーマンスの観点からパラメータの文末指定は避けたいこと、またHTTPヘッダが既にその機能を備えていることがその理由です。

ただ、mix testを実行した後も、ContactControllerが必要です。

** (UndefinedFunctionError) undefined function: HelloPhoenix.ContactController.init/1 (module HelloPhoenix.ContactController is not available)

web/controllers/contact_controller.exでコントローラを作成しましょう。

defmodule HelloPhoenix.ContactController do
  use HelloPhoenix.Web, :controller
  alias HelloPhoenix.Repo
  alias HelloPhoenix.Contact

  plug :action

  def index(conn, _params) do
    contacts = Repo.all(Contact)
    render conn, contacts: contacts
  end
end

まず、Repo.all(Contact)の全てのコンタクトを用意してください。次にPhoenix.Controller.render/2でJSONをレンダリングします。Use HelloPhoenix.Web, :controllerを呼び出せば、関数は自動的に取り込まれます。Web/web.exをチェックして、他に何が呼び出されているかを確認してみましょう。

ここでMix testを実行しても、まだパスしません。

** (UndefinedFunctionError) undefined function: HelloPhoenix.ContactView.render/2 (module HelloPhoenix.ContactView is not available)

JSONをレンダリングするには、viewが必要なのです。

viewでJSONをレンダリングする

viewはJSONの出力方法を処理します。今現在これは非常にシンプルな操作ですが、将来的には例えば、ユーザのパーミッションに基づいて、送信するものを変更することもできます。

ではWeb/views/contact_view.exにファイルを作成しましょう。

defmodule HelloPhoenix.ContactView do
  use HelloPhoenix.Web, :view

  def render("index.json", %{contacts: contacts}) do
    contacts
  end
end

これはパターンマッチングを使用して設定し、contactsを返します。PhoenixはJSONへのコンタクトの配列を自動的にエンコードします。このviewの関数を使ってJSONの表示方法をカスタマイズすることができますが、これについては、また別の記事で説明します。

ここでmix testを実行すれば、全てのテストをパスするでしょう。

クリーンアップ

web/web.ex内のHelloPhoenix.Webをチェックして、もう少しアプリケーションをクリーンアップしてみましょう。このファイルを開くと、既にコントローラの関数にはHelloPhoenix.Repoのエイリアスがあることが分かります。

  def controller do
    quote do
      # Auto generated - This imports all the macros and functions that a controller needs.
      use Phoenix.Controller

      # Auto inserted - The app was generated with an alias to Repo as a convenience.
      alias HelloPhoenix.Repo

      # This imports the router helpers so you can generate paths like
      # `api_contacts_path(conn)`
      import HelloPhoenix.Router.Helpers
    end
  end

つまり、コントローラでHelloPhoenix.Repoのエイリアスを取り除けるということです。

ExUnit.CaseTemplateを使って、テストを少しクリーンアップします。test/test_helper.exsでは次のようになります。

# Add this above `ExUnit.start`
defmodule HelloPhoenix.Case do
  use ExUnit.CaseTemplate
  alias Ecto.Adapters.SQL
  alias HelloPhoenix.Repo

  setup do
    SQL.begin_test_transaction(Repo)

    on_exit fn ->
      SQL.rollback_test_transaction(Repo)
    end
  end

  using do
    quote do
      alias HelloPhoenix.Repo
      alias HelloPhoenix.Contact
      use Plug.Test

      # Remember to change this from `defp` to `def` or it can't be used in your
      # tests.
      def send_request(conn) do
        conn
        |> put_private(:plug_skip_csrf_protection, true)
        |> HelloPhoenix.Endpoint.call([])
      end
    end
  end
end

usingにコードを追加すると、これらの関数とエイリアスは全てのテストで利用可能になります。なので、send_request/1とテストの他のエイリアスを取り除き、use HelloPhoenix.Caseで置き換えることができます。

defmodule HelloPhoenix.ContactControllerTest do
  use HelloPhoenix.Case, async: false
  # We removed the other aliases since they're already included in
  # `HelloPhoenix.Case`. We also removed the `setup` macro.

  test "/index returns a list of contacts" do
    contacts_as_json =
      %Contact{name: "Gumbo", phone: "(801) 555-5555"}
      |> Repo.insert
      |> List.wrap
      |> Poison.encode!

    response = conn(:get, "/api/contacts") |> send_request

    assert response.status == 200
    assert response.resp_body == contacts_as_json
  end

  # We also removed the function definition for `send_request/1`
end

まとめ

PhoenixでJSON APIを作成して、テストする方法を見てきました。また、ファイルをクリーンアップして、将来的にHelloPhoenix.WebEXUnit.CaseTemplateを使うことで、他のコントローラとテストでモジュールの利用を簡単にする方法を説明しました。これで、このアプリケーションをElixir buildpacでHerokuに展開することができます。