Elixir + PhoenixでAPI開発

Elixir + PhoenixでAPI開発

概要

普段UZUMAKIではRailsのお仕事が多いのですが、ここではElixirベースのフレームワークであるPhoenixを使ったAPI開発のノウハウを共有していきたいと思っています。チュートリアルではあまり紹介されないテストの方法や、定番のライブラリの紹介・設定も説明します。

ElixirはErlang VM上で動く関数型言語で、RailsコミッターのJoséさんが作ったプログラミング言語です。

Elixirの特徴としては、

  • 並列処理を安定的に実行できる
  • 耐障害性が高い
  • シンプルでとっつきやすい言語仕様

などが挙げられます。

詳しくは以下の辺りに分かり易く纏まっています。

【メモ】私が愛する Elixir/Erlang の楽しさと辛さ - Taku's Teckブログ

元資料が素晴らしいためまとめる必要など全くないのですが、自己の整理のため、箇条書きにて書き起こししているものです Web開発のための優秀なツール完備 サーバ構成をシンプルに保てる コードをシンプルに保てる 夜眠れる なぜ流行らないのか? 学習コストが高い(と思われれている) × 納期が短い = 採用されない 学習コストが高い(と思われれている)・・並行処理、関数型、マクロと難しそうな概念を学ぶ必要がありそう Webシステムの開発ならば、学習コストは高くも低くもない 加えて、安全性が高く、生産性が高く、それ以外の性質は程良い →Elixirは学習コストのわりには恩恵が大きい 難しいのであれば学ぶことを取捨選択して学習コストを下げる 以下、選択して学習することで学習コストを下げてElixirの恩恵を受けることができる 並行処理、マクロ、ビヘイビア、プロトコルは最後に学ぶ ここで紹介した以外はリスト操作に習熟してから学ぶ EVMの並行処理について 巷の良い話 巷の悪い話 概念自体の学習にコストが必要 (共有メモリ+スレッドより楽だけど)メッセージパッシングですら人類には早かった → 前提条件を変更する PhoenixでWebサービスを開発するなら、逐次処理を書くだけで、軽量プロセスの恩恵を享受できる →よってしばらく並行処理は忘れて良い、学習コストを払うのも後回しで良い 以下のように考えるとわかりやすい 「 Erlang で作られたWebサーバは一つの接続ごとに一つのプロセスを生成する。10万個のチャットのセッションが必要なら10万個のWebサーバが起動する。仮にその中の一つが落ちたとしても、誰も気にしない」 EVM=OSと似たプロセスの仕組みを持つ プロセスとは どういうことか EVMは各軽量プロセスにCPUを使う権限を与える 軽量プロセスが一定量処理すると別の軽量プロセスに権限が移る  メリット 一つの軽量プロセスがCPUを占有しづらい (極論)軽量プロセスないで無限ループしてもシステム全体に影響が出にくい 軽量プロセス内はシンプルな逐次処理となる Elixirであれば同じ処理を簡単な逐次処理として記述できる →「どこでコンテキストが切り替わるのか」考慮不要 ネットワーク上の一意な住所を持つとは? EVMは各軽量プロセスに一意なアドレスを割り当てる PIDは複数のEVMで構成されたクラスタ上で一意  (利点) 軽量プロセス同士がメッセージを送受信し、共有メモリを使わずに連動できる 軽量プロセス同士がシグナルを送受信し監視や管理(再起動など)ができる  (注意点) とはいえ、高頻度に再起動するとCPU負荷が上がるため注意 プロセスを使いまわすと、プロセス停止によるメモリ解放が行われないため、メモリリークしないように気を配る必要がある 速さはおまけ 軽量プロセスは耐障害性の向上するための仕組みであり、速さはおまけ => OSと似たことをしているのに早いわけがない Node.jsは他の動的言語と比較すると圧倒的に速い(V8というものが頑張っているらしい) 結果として、Elixir => 並行処理の学習コスト不要で、ほぼほぼに遅くないとても楽で安全な言語 巷の良い話 巷の悪い話 Elixirは純粋な関数型ではない そのため関数型由来の仕組みが少ない ○○由来の仕組みが少ない 複雑さが排除されていて、理解するための文脈が少ない 他者の記述したコードを読むのが楽  (注意)マクロ自体やマクロ由来の仕組みは複雑さを招きやすい 純粋だと言われる条件 参照透過な関数で表現できることが多い → ? e.g.

【メモ】私が愛する Elixir/Erlang の楽しさと辛さ - Taku's Teckブログ
並列プログラミング言語 Elixir (エリクサー) におけるソフトウェアテスト〜基礎から最新展望まで - Qiita

(この記事は 「ソフトウェアテスト #2 Advent Calendar 2018」 の7日目です) 「ソフトウェアテスト #2 Advent Calendar 2018」6日目は @kokotatata さんの 「アジャイルもテスト自動化も当たり前?! ~AIがテスト設計をする日が来るかも~」 でした。 「ソフトウェアテスト #2 Advent Calendar 2018」7日目の今日は,残念ながら行けなかった JaSST'18 Kyushu にて事例発表を検討していたネタを披露しようと思います。最近「推し」の並列プログラミング言語 Elixir (エリクサー)におけるソフトウェアテストについてです。 なぜ Elixir に注目しているかというと,Elixir の持つ 並列処理性能と 耐障害性が高い上に, 文法が平易で記述が容易であるから,そして Phoenix(フェニックス)という 最速のウェブフレームワーク を持つからです。 まず Elixir における並列処理について説明しましょう。 1行目の1..1_000_000は,1から1,000,000までの要素からなるリストを生成します。なお,数字の間の_(アンダースコア)によって,数字を分割するコンマを表します。 2,3行目の先頭にある|>はパイプライン演算子で,パイプライン演算子の前に書かれている記述の値を,パイプライン演算子の後に書かれた関数の第1引数として渡します。すなわち,このような記述と等価です。Enum.map(Enum.map(1..1_000_000, foo), bar) 2,3行目に書かれている Enum.mapは,第1引数に渡されるリスト(など)の要素1つ1つに,第2引数で渡される関数を適用します。ここでは関数 foo を各要素に適用した後,関数 bar を各要素に適用します。もし,fooが2倍する関数で,barが1加える関数だった時には,これらの記述により,2倍してから1加える処理を1から1,000,000までの要素に適用したリスト,[3, 5, 7, ...]

並列プログラミング言語 Elixir (エリクサー) におけるソフトウェアテスト〜基礎から最新展望まで - Qiita

性能面ではRailsを利用しているプロジェクトと比べて、サーバー台数を半分程度で抑えられているという事例もあるようです。

Elixirは構文がRubyに似てるところもありRubyistにとってはとっつきやすいかもしれません。

今回はdockerを使ってPhoenixのAPIサーバーを構築し、さらにElixir + Phoenix開発では必須と言っても良い便利なツールなどを紹介していければと思っています。

この記事がElixirとPhoenixに興味を持つきっかけになれば嬉しいです。

目次

対象読者

  • RubyやRailsに少し飽きてきて他の言語やフレームワークにも触れてみたいと思っている人
  • ElixirやPhoenixに興味はあったけど実際動かすまではやってない人

PR

UZUMAKIではアジャイル開発で新規事業の開発から、大規模Webアプリケーションのアーキテクチャ更新などの開発をしています。

お問い合わせはUZUMAKIのHPのお問合せフォームから

本文

サーバー構築

まずはdockerを使いPhoenixサーバーが応答を返すところまで構築します。

(※ Docker for Mac 以外での動作確認はしていません。)

環境

MacBook Pro (Intel, 2020)

OS: macOS Monterey 12.6

Docker version 20.10.17

サーバー構成

言語: Elixir 1.14

フレームワーク: Phoenix 1.6.15

DB: mysql 8

作業ディレクトリ作成

$ mkdir todo-phoenix
$ cd todo-phoenix

Dockerfileとdocker-compose.ymlを用意

Dockerfile

FROM elixir:1.14-alpine

RUN apk update && apk add \
  inotify-tools git build-base npm bash

RUN mix do local.hex --force, local.rebar --force, archive.install --force hex phx_new

WORKDIR /app

docker-compose.yml

version: '3'
services:
  web:
    build:
      context: .
      dockerfile: ./Dockerfile
    tty: true
    environment:
      - MIX_ENV=dev
      - PORT=4000
      - DB_HOST=db
    links:
      - db
    ports:
      - 4000:4000
    command: mix phx.server
    volumes:
      - .:/app
  db:
    image: mysql:8
    environment:
      - MYSQL_ROOT_PASSWORD=password
    ports:
      - 3306:3306
    volumes:
      - mysql_volume:/var/lib/mysql
volumes:
  mysql_volume:

build

$ docker-compose build

ElixirとPhoenixのバージョンを確認。

# Elixir
$ docker compose run --rm web elixir -v

[+] Running 2/2
 ⠿ Volume "todo-phoenix_mysql_volume"  Created                                                                                            0.0s
 ⠿ Container todo-phoenix-db-1         Created                                                                                            0.1s
[+] Running 1/1
 ⠿ Container todo-phoenix-db-1  Started                                                                                                   0.4s
Erlang/OTP 25 [erts-13.0.4] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1] [jit]

Elixir 1.14.0 (compiled with Erlang/OTP 25)

# Phoenix
docker compose run --rm web mix phx.new -v

[+] Running 1/0
 ⠿ Container todo-phoenix-db-1  Running                                                                                                   0.0s
Phoenix installer v1.6.15

Phoenixプロジェクト作成

$ docker-compose run web mix phx.new . --app todo_phoenix --database mysql --no-assets --no-html --no-gettext

# The directory /app already exists. Are you sure you want to continue? [Yn] Y
# Fetch and install dependencies? [Yn] Y

phx.newのoptionsはこちらを参照してください。

プロジェクト作成後のディレクトリ構造は以下のようになっています。

├── Dockerfile
├── README.md
├── _build # 階層が深いので省略。ビルドしたファイルを格納
├── config
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── runtime.exs
│   └── test.exs
├── deps # 階層が深いので省略。ライブラリファイルを格納
├── docker-compose.yml
├── lib
│   ├── todo_phoenix
│   │   ├── application.ex
│   │   ├── mailer.ex
│   │   └── repo.ex
│   ├── todo_phoenix.ex
│   ├── todo_phoenix_web
│   │   ├── controllers
│   │   ├── endpoint.ex
│   │   ├── router.ex
│   │   ├── telemetry.ex
│   │   └── views
│   │       ├── error_helpers.ex
│   │       └── error_view.ex
│   └── todo_phoenix_web.ex
├── mix.exs
├── mix.lock
├── priv
│   └── repo
│       ├── migrations
│       └── seeds.exs
└── test
    ├── support
    │   ├── conn_case.ex
    │   └── data_case.ex
    ├── test_helper.exs
    └── todo_phoenix_web
        ├── controllers
        └── views
            └── error_view_test.exs

ディレクトリ構造に関してはこちらに詳しく書かれています。

設定変更

config/dev.exsの以下を修正します。

@@ -3,8 +3,8 @@
 # Configure your database
 config :todo_phoenix, TodoPhoenix.Repo,
   username: "root",
-  password: "",
-  hostname: "localhost",
+  password: "password", # dockerの設定情報に合わせてDBの接続情報を編集
+  hostname: "db", # dockerの設定情報に合わせてDBの接続情報を編集
   database: "todo_phoenix_dev",
   stacktrace: true,
   show_sensitive_data_on_connection_error: true,
@@ -19,7 +19,7 @@
 config :todo_phoenix, TodoPhoenixWeb.Endpoint,
   # Binding to loopback ipv4 address prevents access from other machines.
   # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
-  http: [ip: {127, 0, 0, 1}, port: 4000],
+  http: [ip: {0, 0, 0, 0}, port: 4000], # コンテナの外からアクセスできるようにする
   check_origin: false,
   code_reloader: true,
   debug_errors: true,

DB作成

$ docker-compose run --rm web mix ecto.create
Creating todo-phoenix_web_run ... done
The database for TodoPhoenix.Repo has been created

動作確認

Phoenix Serverから200のHTTPステータスが返ることを確認します。

# コンテナを起動
$ docker-compose up

# 別窓でアクセス確認
$ curl -I http://localhost:4000/dashboard/home

HTTP/1.1 200 OK

API作成

ここからは実際に簡単なAPIを作成し動かすところまでやっていきます。

今回はtodoアプリケーションのtask追加と取得ができるようにします。

スキーマ作成

以下の情報でスキーマを作成していきます。

テーブル名: todo_tasks

カラム: content, state

$ docker-compose run --rm web mix phx.gen.schema Todo.Task todo_tasks content:string state:integer
Creating todo-phoenix_web_run ... done
* creating lib/todo_phoenix/todo/task.ex
* creating priv/repo/migrations/20221030080244_create_todo_tasks.exs

コマンドを実行するとスキーマファイルとマイグレーションファイルの2ファイルが作成されます。

スキーマファイルを開いてみます。

lib/todo_phoenix/todo/task.ex

defmodule TodoPhoenix.Todo.Task do
  use Ecto.Schema
  import Ecto.Changeset

  schema "todo_tasks" do
    field :content, :string
    field :state, :integer

    timestamps()
  end

  @doc false
  def changeset(task, attrs) do
    task
    |> cast(attrs, [:content, :state])
    |> validate_required([:content, :state])
  end
end

カラムの型や簡単なバリデーションが定義されていることが分かります。

マイグレーション実行

$ docker-compose run --rm web mix ecto.migrate
Creating todo-phoenix_web_run ... done
Compiling 1 file (.ex)
Generated todo_phoenix app

08:07:15.936 [info] == Running 20221030080244 TodoPhoenix.Repo.Migrations.CreateTodoTasks.change/0 forward

08:07:16.000 [info] create table todo_tasks

08:07:16.050 [info] == Migrated 20221030080244 in 0.0s

mysqlに接続し、テーブルを確認してみます。

$ mysql -h 127.0.0.1 -P 3306 -u root -p todo_phoenix_dev

mysql> desc todo_tasks;
+-------------+-----------------+------+-----+---------+----------------+
| Field       | Type            | Null | Key | Default | Extra          |
+-------------+-----------------+------+-----+---------+----------------+
| id          | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| content     | varchar(255)    | YES  |     | NULL    |                |
| state       | int             | YES  |     | NULL    |                |
| inserted_at | datetime        | NO   |     | NULL    |                |
| updated_at  | datetime        | NO   |     | NULL    |                |
+-------------+-----------------+------+-----+---------+----------------+
5 rows in set (0.01 sec)

テーブルが作成されていますね!

ではいよいよコーディングをしていきましょう。

コンテキスト作成

コンテキストとは関連する関数を纏める専用モジュールです。詳しくはこちらを参照してください。

ちなみに先に出たスキーマは、リレーション定義も書くのでRailsのモデルに似ていますが、ビジネスロジックに関してはスキーマではなくコンテキストに書くことが多いです。

コンテキストを作成する前にlib下のディレクトリ構造を見てみます。

(Phoenixでは主にlib下をさわります。

lib
├── todo_phoenix
│   ├── application.ex
│   ├── mailer.ex
│   ├── repo.ex
│   └── todo
│       └── task.ex
├── todo_phoenix.ex
├── todo_phoenix_web
│   ├── controllers
│   ├── endpoint.ex
│   ├── router.ex
│   ├── telemetry.ex
│   └── views
│       ├── error_helpers.ex
│       └── error_view.ex
└── todo_phoenix_web.ex

5 directories, 11 files

アプリケーション名(todo_phoenix)の下には先ほど確認したスキーマ定義ファイル(task.ex)がありますね。

また、controllerやviewはtodo_phoenix_web下に配置する構造になっています。

今回コンテキストはtodo_phoenix下にtodo.exという名前で作成してみます。

lib/todo_phoenix/todo.ex

defmodule TodoPhoenix.Todo do
  import Ecto.Query, warn: false
  alias TodoPhoenix.Repo
  alias TodoPhoenix.Todo.Task

  @doc """
  タスク一覧を取得する。
  """
  def list_tasks do
    Repo.all(Task)
  end

  @doc """
  タスクを作成する。
  """
  def create_task(attrs \\ %{}) do
    %Task{}
    |> Task.changeset(attrs)
    |> Repo.insert()
  end
end

コンソールから動作を確認してみます。

iexコンソールへの接続

# コンテナに接続
$ docker exec -it todo-phoenix_web_1 bash

$ iex -S mix
Erlang/OTP 25 [erts-13.0.4] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1] [jit]

Compiling 1 file (.ex)
Generated todo_phoenix app
Interactive Elixir (1.14.0) - press Ctrl+C to exit (type h() ENTER for help)

iexコンソール

# コンテキストに定義した関数を使いtaskを取得
iex(1)> TodoPhoenix.Todo.list_tasks
[debug] QUERY OK source="todo_tasks" db=0.5ms decode=12.6ms queue=17.3ms idle=1085.5ms
SELECT t0.`id`, t0.`content`, t0.`state`, t0.`inserted_at`, t0.`updated_at` FROM `todo_tasks` AS t0 []
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298
[]
# 登録が無いので空の配列を返す

# コンテキストに定義した関数を使いtaskを作成
iex(2)> TodoPhoenix.Todo.create_task(%{content: "hoge", state: 0})
[debug] QUERY OK db=4.0ms queue=1.0ms idle=1991.9ms
INSERT INTO `todo_tasks` (`content`,`state`,`inserted_at`,`updated_at`) VALUES (?,?,?,?) ["hoge", 0, ~N[2022-10-30 10:27:00], ~N[2022-10-30 10:27:00]]
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298
{:ok,
 %TodoPhoenix.Todo.Task{
   __meta__: #Ecto.Schema.Metadata<:loaded, "todo_tasks">,
   id: 1,
   content: "hoge",
   state: 0,
   inserted_at: ~N[2022-10-30 10:27:00],
   updated_at: ~N[2022-10-30 10:27:00]
 }}

iex(3)> TodoPhoenix.Todo.list_tasks
[debug] QUERY OK source="todo_tasks" db=2.9ms queue=0.1ms idle=1543.0ms
SELECT t0.`id`, t0.`content`, t0.`state`, t0.`inserted_at`, t0.`updated_at` FROM `todo_tasks` AS t0 []
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298
[
  %TodoPhoenix.Todo.Task{
    __meta__: #Ecto.Schema.Metadata<:loaded, "todo_tasks">,
    id: 1,
    content: "hoge",
    state: 0,
    inserted_at: ~N[2022-10-30 10:27:00],
    updated_at: ~N[2022-10-30 10:27:00]
  }
]
# 正常に登録されている

ついでにtask.stateをEnumの管理に変更してみます。

lib/todo_phoenix/todo/task.ex

# 修正前
field :state, :integer

# 修正後
field :state, Ecto.Enum, values: [new: 0, doing: 1, done: 2], default: :new

再度コンソールから実行してみましょう。

# 既にコンソールを開いてる場合は以下で再コンパイルをお願いします。
iex(4)> recompile

# stateはアトム型で指定できる
iex(5)> TodoPhoenix.Todo.create_task(%{content: "fuga", state: :doing})
[debug] QUERY OK db=5.4ms queue=1.2ms idle=1078.8ms
INSERT INTO `todo_tasks` (`content`,`state`,`inserted_at`,`updated_at`) VALUES (?,?,?,?) ["fuga", :doing, ~N[2022-10-30 11:04:25], ~N[2022-10-30 11:04:25]]
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298
{:ok,
 %TodoPhoenix.Todo.Task{
   __meta__: #Ecto.Schema.Metadata<:loaded, "todo_tasks">,
   id: 2,
   content: "fuga",
   state: :doing,
   inserted_at: ~N[2022-10-30 11:04:25],
   updated_at: ~N[2022-10-30 11:04:25]
 }}

# stateを指定しない場合デフォルト値のnewが入る
iex(6)> TodoPhoenix.Todo.create_task(%{content: "piyo"})
[debug] QUERY OK db=8.4ms queue=1.8ms idle=1398.0ms
INSERT INTO `todo_tasks` (`content`,`state`,`inserted_at`,`updated_at`) VALUES (?,?,?,?) ["piyo", :new, ~N[2022-10-30 11:04:32], ~N[2022-10-30 11:04:32]]
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:298
{:ok,
 %TodoPhoenix.Todo.Task{
   __meta__: #Ecto.Schema.Metadata<:loaded, "todo_tasks">,
   id: 3,
   content: "piyo",
   state: :new,
   inserted_at: ~N[2022-10-30 11:04:32],
   updated_at: ~N[2022-10-30 11:04:32]
 }}

railsでもEnumを使うことが多いですがPhoenixでも同じように使えますね。

さて、コンソールで確認した限りコンテキストの動作に問題は無さそうです。

次はコントローラ、ビューを作成していきます。

コントローラ、ビュー作成

lib/todo_phoenix_web/controllers/task_controller.ex

defmodule TodoPhoenixWeb.TaskController do
  use TodoPhoenixWeb, :controller

  alias TodoPhoenix.Todo
  alias TodoPhoenix.Todo.Task

  def index(conn, _params) do
    tasks = Todo.list_tasks()
    render(conn, "index.json", tasks: tasks)
  end

  def create(conn, %{"task" => task_params}) do
    case Todo.create_task(task_params) do
      {:ok, %Task{}} ->
        send_resp(conn, :created, "")
      {:error, _} ->
        send_resp(conn, :unprocessable_entity, "")
    end
  end

  def create(conn, _), do: send_resp(conn, :bad_request, "")
end

lib/todo_phoenix_web/views/task_view.ex

defmodule TodoPhoenixWeb.TaskView do
  use TodoPhoenixWeb, :view
  alias TodoPhoenixWeb.TaskView

  def render("index.json", %{tasks: tasks}) do
    %{data: render_many(tasks, TaskView, "task.json")}
  end

  def render("task.json", %{task: task}) do
    %{
      id: task.id,
      content: task.content,
      state: task.state
    }
  end
end

lib/todo_phoenix_web/router.ex

  scope "/api", TodoPhoenixWeb do
    pipe_through :api

    resources "/tasks", TaskController, only: [:create, :index] # 追加
  end

API動作確認

動作確認する前に、分かり易いようにコンテキストのテスト時に作成したtodo_tasksのデータを予め削除しておきましょう。

また、コンテナが起動してない場合はdocker-compose upで起動してください。

# GET tasks
curl http://localhost:4000/api/tasks | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    11  100    11    0     0     53      0 --:--:-- --:--:-- --:--:--    56
{
  "data": []
}
 # データは空

# POST tasks
$ curl -v http://localhost:4000/api/tasks -H "Content-Type: application/json" -d '{"task":{"content": "foo"}}'
*   Trying 127.0.0.1:4000...
* Connected to localhost (127.0.0.1) port 4000 (#0)
> POST /api/tasks HTTP/1.1
> Host: localhost:4000
> User-Agent: curl/7.79.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 27
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created

# GET tasks
curl http://localhost:4000/api/tasks | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    50  100    50    0     0    277      0 --:--:-- --:--:-- --:--:--   294
{
  "data": [
    {
      "content": "foo",
      "id": 4,
      "state": "new"
    }
  ]
}
# データが作成されている

# POST tasks
curl -v http://localhost:4000/api/tasks -H "Content-Type: application/json" -d '{"task":{"content": "bar"}}'
*   Trying 127.0.0.1:4000...
* Connected to localhost (127.0.0.1) port 4000 (#0)
> POST /api/tasks HTTP/1.1
> Host: localhost:4000
> User-Agent: curl/7.79.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 27
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created

# GET tasks
curl http://localhost:4000/api/tasks | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    90  100    90    0     0    511      0 --:--:-- --:--:-- --:--:--   545
{
  "data": [
    {
      "content": "foo",
      "id": 4,
      "state": "new"
    },
    {
      "content": "bar",
      "id": 5,
      "state": "new"
    }
  ]
}
 # 追加で1件データが作成されている

良さそうですね!

次はテストを書いていきます。

テスト

設定変更

config/test.exsの以下を修正します。

@@ -7,8 +7,8 @@
 # Run `mix help test` for more information.
 config :todo_phoenix, TodoPhoenix.Repo,
   username: "root",
-  password: "",
-  hostname: "localhost", 
+  password: "password", # dockerの設定情報に合わせてDBの接続情報を編集
+  hostname: "db", # dockerの設定情報に合わせてDBの接続情報を編集
   database: "todo_phoenix_test#{System.get_env("MIX_TEST_PARTITION")}",
   pool: Ecto.Adapters.SQL.Sandbox,
   pool_size: 10

fixturesを準備

$ mkdir test/support/fixtures

test/support/fixtures/todo_fixtures.ex

defmodule TodoPhoenix.TodoFixtures do
  def task_fixture(attrs \\ %{}) do
    {:ok, task} =
      attrs
      |> Enum.into(%{
        content: "hoge",
        state: :new
      })
      |> TodoPhoenix.Todo.create_task()

    task
  end
end

コンテキストテスト作成

$ mkdir test/todo_phoenix

test/todo_phoenix/todo_test.exs

defmodule TodoPhoenix.TodoTest do
  use TodoPhoenix.DataCase
  import TodoPhoenix.TodoFixtures

  alias TodoPhoenix.Todo
  alias TodoPhoenix.Todo.Task

  describe "task.list_tasks" do
    test "taskが配列で取得できること" do
      task = task_fixture()
      assert Todo.list_tasks() == [task]
    end
  end

  describe "task.create_task" do
    @valid_attrs %{content: "bar", state: :doing}
    @invalid_attrs %{content: nil}

    test "taskが登録できること" do
      assert {:ok, %Task{} = task} = Todo.create_task(@valid_attrs)
      assert task.content == "bar"
      assert task.state == :doing
    end

    test "パラメータが不正な場合、Changesetエラーが返却されること" do
      assert {:error, %Ecto.Changeset{}} = Todo.create_task(@invalid_attrs)
    end
  end
end

テスト実行

# コンテナ内で実行しています
# 初回はコンパイルが走ってテスト実行まで時間がかかります。
$ MIX_ENV=test mix test test/todo_phoenix/todo_test.exs
...
Finished in 0.4 seconds (0.00s async, 0.4s sync)
3 tests, 0 failures

コントローラテスト作成

test/todo_phoenix_web/controllers/task_controller_test.exs

defmodule TodoPhoenixWeb.TaskControllerTest do
  use TodoPhoenixWeb.ConnCase

  @create_attrs %{
    content: "bar",
    state: :new
  }
  @invalid_attrs %{content: nil}
  @blank_params %{}

  setup %{conn: conn} do
    {:ok, conn: put_req_header(conn, "accept", "application/json")}
  end

  describe "index" do
    test "ステータスコード200を返却すること", %{conn: conn} do
      conn = get(conn, Routes.task_path(conn, :index))
      assert json_response(conn, :ok)["data"] == []
    end
  end

  describe "create task" do
    test "登録に成功した場合、ステータスコード201を返却すること", %{conn: conn} do
      conn = post(conn, Routes.task_path(conn, :create), task: @create_attrs)
      assert response(conn, :created)
    end

    test "パラメータが不正な場合、ステータスコード422を返却すること", %{conn: conn} do
      conn = post(conn, Routes.task_path(conn, :create), task: @invalid_attrs)
      assert response(conn, :unprocessable_entity)
    end

    test "パラメータが空の場合、ステータスコード400を返すこと", %{conn: conn} do
      conn = post(conn, Routes.task_path(conn, :create), @blank_params)

      assert response(conn, :bad_request)
    end
  end
end

テスト実行

$ MIX_ENV=test mix test test/todo_phoenix_web/controllers/task_controller_test.exs
....
Finished in 0.7 seconds (0.00s async, 0.7s sync)
4 tests, 0 failures

全てのテストを実行

$ MIX_ENV=test mix test
.........
Finished in 0.9 seconds (0.4s async, 0.4s sync)
9 tests, 0 failures
 # 内2つはデフォルトで実装されているテストです。

ここまででAPIサーバーの構築、テストの実装まで行いました。

次はElixir + Phoenix開発では必須のライブラリ(hex)を導入してみます。

Credo

credoはrailsではお馴染みのrubocopのような構文チェックツールです。

では導入していきます。

mix.exs

defp deps do
    [
      {:phoenix, "~> 1.6.15"},
      {:phoenix_ecto, "~> 4.4"},
      {:ecto_sql, "~> 3.6"},
      {:myxql, ">= 0.0.0"},
      {:phoenix_live_dashboard, "~> 0.6"},
      {:swoosh, "~> 1.3"},
      {:telemetry_metrics, "~> 0.6"},
      {:telemetry_poller, "~> 1.0"},
      {:jason, "~> 1.2"},
      {:plug_cowboy, "~> 2.5"}, # カンマを追加
      {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, # 追加
    ]
  end

インストール

$ docker-compose run --rm web mix deps.get

実行する前に、警告を出してくれることを期待してコード中にデバッグ文を入れてみます。

lib/todo_phoenix_web/controllers/task_controller.ex

12   def create(conn, %{"task" => task_params}) do
13     IO.inspect(task_params) # こちらを追加

実行

$ docker-compose run --rm web mix credo

結果

image

先ほど入れたデバッグ文は[W]でちゃんと警告してくれてますね。

Dialyzer

Elixirはrubyと同じく動的型付け言語ですが、Typespecsという型と仕様を宣言するための記法を提供しており、dialyzerはそちらをチェクしてくれるツールです。

では導入していきます。

mix.exs

  defp deps do
    [
      {:phoenix, "~> 1.6.15"},
      {:phoenix_ecto, "~> 4.4"},
      {:ecto_sql, "~> 3.6"},
      {:myxql, ">= 0.0.0"},
      {:phoenix_live_dashboard, "~> 0.6"},
      {:swoosh, "~> 1.3"},
      {:telemetry_metrics, "~> 0.6"},
      {:telemetry_poller, "~> 1.0"},
      {:jason, "~> 1.2"},
      {:plug_cowboy, "~> 2.5"},
      {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
      {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, # 追加
    ]
  end

インストール

$ docker-compose run --rm web mix deps.get

型を定義

lib/todo_phoenix/todo/task.ex

defmodule TodoPhoenix.Todo.Task do
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{} # 追加。これを追加することでmodule名.t()で他から参照できるようになります。

  schema "todo_tasks" do

lib/todo_phoenix/todo.ex

defmodule TodoPhoenix.Todo do
  import Ecto.Query, warn: false
  alias TodoPhoenix.Repo
  alias TodoPhoenix.Todo.Task

  @doc """
  タスク一覧を取得する。
  """
  @spec list_tasks() :: [Task.t()] # 追加
  def list_tasks do
    Repo.all(Task)
  end

  @doc """
  タスクを作成する。
  """
  @spec create_task(map()) :: {:ok, Task.t()} # 追加
  def create_task(attrs \\ %{}) do
    %Task{}
    |> Task.changeset(attrs)
    |> Repo.insert()
  end
end

実行

$ docker-compose run --rm web mix dialyzer

結果

image

エラーになりましたね。エラー内容をみると、コントローラの16行目でerrorのパターンマッチの記載が有るが、コンテキスト側の返却型定義にerrorパターンが無いと警告されていることが分かります。

lib/todo_phoenix_web/controllers/task_controller.ex

13     case Todo.create_task(task_params) do
14       {:ok, %Task{}} ->
15         send_resp(conn, :created, "")
16       {:error, _} ->
17         send_resp(conn, :unprocessable_entity, "")
18     end

コンテキストのcreate_task関数のspecを以下のように複数の返却型があるように修正してみましょう。

この関数はRepo.insert()の結果を返してるのでそちらを参考にエラーパターンを追加します。

lib/todo_phoenix/todo.ex

@doc """
  タスクを作成する。
  """
  @spec create_task(map()) :: {:ok, Task.t()} | {:error, Ecto.Changeset.t()} # エラーパターンを追加
  def create_task(attrs \\ %{}) do
    %Task{}
    |> Task.changeset(attrs)
    |> Repo.insert()
  end

再度実行してみます。

$ docker-compose run --rm web mix dialyzer
Creating todo-phoenix_web_run ... done
Compiling 1 file (.ex)
Finding suitable PLTs
Checking PLT...
[:asn1, :castore, :compiler, :connection, :cowboy, :cowboy_telemetry, :cowlib, :crypto, :db_connection, :decimal, :ecto, :ecto_sql, :eex, :elixir, :jason, :kernel, :logger, :mime, :myxql, :phoenix, :phoenix_ecto, :phoenix_html, :phoenix_live_dashboard, :phoenix_live_view, :phoenix_pubsub, :phoenix_template, :phoenix_view, :plug, :plug_cowboy, :plug_crypto, :public_key, :ranch, :runtime_tools, :ssl, :stdlib, :swoosh, :telemetry, :telemetry_metrics, :telemetry_poller, :xmerl]
PLT is up to date!
No :ignore_warnings opt specified in mix.exs and default does not exist.

Starting Dialyzer
[
  check_plt: false,
  init_plt: '/app/_build/dev/dialyxir_erlang-25.0.4_elixir-1.14.0_deps-dev.plt',
  files: ['/app/_build/dev/lib/todo_phoenix/ebin/Elixir.TodoPhoenix.Application.beam',
   '/app/_build/dev/lib/todo_phoenix/ebin/Elixir.TodoPhoenix.Mailer.beam',
   '/app/_build/dev/lib/todo_phoenix/ebin/Elixir.TodoPhoenix.Repo.beam',
   '/app/_build/dev/lib/todo_phoenix/ebin/Elixir.TodoPhoenix.Todo.Task.beam',
   '/app/_build/dev/lib/todo_phoenix/ebin/Elixir.TodoPhoenix.Todo.beam', ...],
  warnings: [:unknown]
]
Total errors: 0, Skipped: 0, Unnecessary Skips: 0
done in 0m3.12s
done (passed successfully)

警告無く完了しましたね。

このようにtypespec + dialyzer を使用することで、型安全で可読性の高いコードを書いていけそうです。

まとめ

ElixirとPhoenixを使ってサクッとAPIサーバーを作成することができました。

静的解析ツールのcredoやdialyzerはCIで設定すれば便利そうですね。

Elixirは今年仕事で少し書く機会がありましたが、パイプライン演算子やパターンマッチなど、凄く魅力的で書いてて楽しい言語です。

最近話題になっているリアルタイムUIが作れるLiveViewも面白そうですね。

やってみた系の記事では以下が詳しく書かれてそうです。

そして2022年11月現在、WEB+DB PRESS最新号の Vol.131では、何とRustと並んで表紙にElixirが載っています。

ElixirとPhoenixは今後ますます盛り上がってくるのではないかと思っています。

参考リンク

PR

UZUMAKIではRailsエンジニアを絶賛募集中です。

↓の記事を読んでご興味を持っていただいた方は、

是非応募宜しくおねがいします!