こんにちは、そしてはじめまして suzuki です。
今回Elixir Advent Calendar 2021の24日目を書かせていただいています。昨日は@mnishiguchiさんでした。
はじめに
今回、初投稿なので軽く自己紹介をします。
The Waggleという会社に今年から新入社員として入社しました。
自分がElixirを初めてまだ1年経っていない(約半年ぐらい)ですが簡単なwebアプリケーションを開発できるまでになったと思っています。
この記事では開発をする上で便利だったライブラリ「Dialyxir」を紹介します。
Dialyxir
DiallyxirはDialyzerという Erlang の静的解析ツールを Elixir の mix タスクとしてさくっと実行可能にしたライフラリです。
Dialyzerは静的解析を行いますが、Elixirは動的型付け言語です。普通に開発をすると型に関与せずにプログラミングをすることになります。
Elixirにはtypespecという機能が存在します。このtypespecを利用することで型を利用した解析まで行ってくれます。
Dialyzerとtypespecの2つを組み合わせることで保守性が上がります。
※個人的には使ってみてすごくElixirのコードが読みやすかったです。
Dialyxirの導入
mix.exs
にDialyxirを追加します。
defp deps do
[
#・・・省略
{:dialyxir, "~> 1.1", only: [:dev], runtime: false}
]
end
以下のコマンドを実行して依存関係を取得してコンパイルします。
$ mix dpes.get
$ mix dpes.compile
これでDialyxirが使えるようになりました。
Dialyxirの静的解析を使うにはmix dialyzer
コマンドを使う必要があります。←これややこしいですよねw
mix dialyxir コマンドではないので注意が必要です。
Dialyxir経由してDialyzerを使用しているのでこの先はDialyxirではなくDialyzer
と表現します。
下準備
Phoenixプロジェクトにアカウントテーブルを作成して関係する関数やスキーマのフィールドに対して静的解析をしていきます。
まずはマイグレーションファイルから
defmodule DialyxirSample.Repo.Migrations.CreateAccounts do
use Ecto.Migration
def change do
create table(:accounts, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
timestamps()
end
create unique_index(:accounts, [:name])
end
end
次にスキーマ
defmodule DialyxirSample.Accounts.Account do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "accounts" do
field :name, :string
timestamps()
end
end
Repoコード
defmodule DialyxirSample.Accounts do
alias DialyxirSample.Accounts.Account
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias DialyxirSample.Repo
def get_account(id), do: Repo.get(Account, id)
end
dialyzerの実行
以下のコマンドでdialyzerを実行します。
mix dialyzer
初回は時間がかかると思います。解析が終わると以下のように表示されると思います。
Starting Dialyzer
・・・省略
Total errors: 0, Skipped: 0, Unnecessary Skips: 0
done in 0m1.47s
done (passed successfully)
typespecを使わない解析
コントローラに先程作ったRepoコードのget_account/1
関数を呼び出すように追加します。
defmodule DialyxirSampleWeb.PageController do
use DialyxirSampleWeb, :controller
alias DialyxirSample.Accounts
def index(conn, _params) do
account_id = Ecto.UUID.generate()
Accounts.get_account(account_id)
render(conn, "index.html")
end
end
Ectu.UUID.generate/0
関数はランダムなUUIDを生成してくれます。
dialyzerを実行した結果をファイルに書き写して置くと便利です。
自分はmix dialyzer
コマンドを使用するときはシェルスクリプトを経由して実行しています。
#!/bin/bash
set -eu
mix compile
mix dialyzer > log/dialyzer.log 2> log/dialyzer.err.log
log
フォルダ、dialyzer.log
ファイル、dialyzer.err.log
ファイルを事前に作成しておいてdialyzer.sh
を実行するとファイルに実行結果が入力されるようになります。
ファイルのアクセス権限を変更しないと以下のコマンドでは実行できないのでファイルのアクセス権限を変更します。
bin/dialyzer.sh
ファイルのアクセス権限変更
cd bin
chmod 764 dialyzer.sh
少しそれましたが先程コントローラにget_account/1
関数を呼び出すように追加したのでdialyzerで解析してみます。
mix dialyzer
シェルスクリプトを追加した場合
bin/dialyzer.sh
特にDialyzerでは何のエラーも発生しません。コントローラ側で呼び出しの引数をなくしてみます。
def index(conn, _params) do
# account_id = Ecto.UUID.generate()
Accounts.get_account()
render(conn, "index.html")
end
ではDialyzerを実行してみます。
Total errors: 1, Skipped: 0, Unnecessary Skips: 0
done in 0m1.54s
lib/dialyxir_sample_web/controllers/page_controller.ex:8:call_to_missing
Call to missing or private function DialyxirSample.Accounts.get_account/0.
________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
このように出力されました。
エラーメッセージを見るとget_account/0
関数はプライベートか不明な呼び出しと言われています。
ついでにファイルの場所と行も教えてくれています。
便利ですね!
typespecを使った解析
typespecを使った解析をしていきます。
get_account/1
関数に@spec
を追加していきます。
defmodule DialyxirSample.Accounts do
alias DialyxirSample.Accounts.Account
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias DialyxirSample.Repo
@spec get_account(Ecto.UUID.t()) :: %Account{
id: Ecto.UUID.t(),
inserted_at: NaiveDateTime.t(),
name: String.t(),
updated_at: NaiveDateTime.t()
}
| nil
def get_account(id), do: Repo.get(Account, id)
end
Ecto.UUID.t()
はEcto.UUID.t()
NaiveDateTime.t()
はNavieDateTime.t()
String.t()
はString.t()
その他はこれ
を見てください。
@spec
の部分が可読性にかけるのであとで直します。
コントローラのコメントアウトを消してください。
def index(conn, _params) do
account_id = Ecto.UUID.generate()
Accounts.get_account(account_id)
render(conn, "index.html")
end
dialyzerを実行します。
特にエラーは発生しませんでした。
ではコントローラの呼び出しの引数の型をUUIDではない型にしたらどうなるか実行してみます。
def index(conn, _params) do
# account_id = Ecto.UUID.generate()
account_id = 1
Accounts.get_account(account_id)
render(conn, "index.html")
end
dialyzerを実行します。
エラーが発生しました。
Total errors: 2, Skipped: 0, Unnecessary Skips: 0
done in 0m1.58s
lib/dialyxir_sample_web/controllers/page_controller.ex:6:no_return
Function index/2 has no local return.
________________________________________________________________________________
lib/dialyxir_sample_web/controllers/page_controller.ex:9:call
The function call will not succeed.
DialyxirSample.Accounts.get_account(_account_id :: 1)
breaks the contract
(Ecto.UUID.t()) ::
%DialyxirSample.Accounts.Account{
:__meta__ => term(),
:id => Ecto.UUID.t(),
:inserted_at => NaiveDateTime.t(),
:name => String.t(),
:updated_at => NaiveDateTime.t()
}
| nil
________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
関数の呼び出しは成功しませんという文脈を出力してくれました。
しかもご丁寧に@spec
で記述した型を出力してくれています。
ではget_account/1
の@spec
の記述が長いので直していきます。
まず@tyep
を使って新しい型を作成します。
defmodule DialyxirSample.Accounts.Account do
use Ecto.Schema
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
inserted_at: NaiveDateTime.t(),
name: String.t(),
updated_at: NaiveDateTime.t()
}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "accounts" do
field :name, :string
timestamps()
end
end
@type
でDialyxirSample.Accounts.Account.t()という型ができたので@spec
ではこれを呼び出します。
@spec get_account(Ecto.UUID.t()) :: Account.t() | nil
def get_account(id), do: Repo.get(Account, id)
これで短く記述することができました。
※この状態でdialyzerの実行結果を見るとこうなります。
lib/dialyxir_sample_web/controllers/page_controller.ex:6:no_return
Function index/2 has no local return.
________________________________________________________________________________
lib/dialyxir_sample_web/controllers/page_controller.ex:9:call
The function call will not succeed.
DialyxirSample.Accounts.get_account(_account_id :: 1)
breaks the contract
(Ecto.UUID.t()) :: DialyxirSample.Accounts.Account.t() | nil
________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
先程とは表示が違いますが内容は同じです。
こちらのほうが何個もエラーがあった場合は見やすいと思います。
注意点
以下の例を見てください。
@spec get_account_by_name(String.t()) :: Account.t() | nil
def get_account_by_name(name), do: Repo.get_by(Account, name: name)
呼び出し元
Accounts.get_account_by_name(1)
dialyzerは、これで解析をするとエラーを発生させてくれます。
しかし普通に実行するとこの関数は実行されてしまいます。
iex> Accounts.get_account_by_name(1)
** (Ecto.Query.CastError) deps/ecto/lib/ecto/repo/queryable.ex:451: value `1` in `where` cannot be cast to type :string in query:
from a0 in DialyxirSample.Accounts.Account,
where: a0.name == ^1,
select: a0
(elixir 1.12.2) lib/enum.ex:2385: Enum."-reduce/3-lists^foldl/2-0-"/3
(elixir 1.12.2) lib/enum.ex:1704: Enum."-map_reduce/3-lists^mapfoldl/2-0-"/3
(elixir 1.12.2) lib/enum.ex:2385: Enum."-reduce/3-lists^foldl/2-0-"/3
(ecto 3.7.1) lib/ecto/repo/queryable.ex:203: Ecto.Repo.Queryable.execute/4
(ecto 3.7.1) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
(ecto 3.7.1) lib/ecto/repo/queryable.ex:146: Ecto.Repo.Queryable.one/3
(stdlib 3.16.1) erl_eval.erl:685: :erl_eval.do_apply/6
(iex 1.12.2) lib/iex/evaluator.ex:298: IEx.Evaluator.handle_eval/3
理由は、ガード句で指定しない限りあらゆる変数の型を受け付けると判断してしまうからです。
dialyzerは強い静的型付け言語にはしません。
この問題を解決したい場合はガード句で以下のように記述します。
@spec get_account_by_name(String.t()) :: Account.t() | nil
def get_account_by_name(name) when is_binary(name) do
Repo.get_by(Account, name: name)
end
iex> Accounts.get_account_by_name(1)
** (FunctionClauseError) no function clause matching in DialyxirSample.Accounts.get_account_by_name/1
The following arguments were given to DialyxirSample.Accounts.get_account_by_name/1:
# 1
1
Attempted function clauses (showing 1 out of 1):
def get_account_by_name(name) when is_binary(name)
(dialyxir_sample 0.1.0) lib/dialyxir_sample/accounts.ex:15: DialyxirSample.Accounts.get_account_by_name/1
実行すると functionclauseerror が発生するようになりました。
最後に
dialyzer(dialxir)で簡単に解析できる事が理解していただいたでしょうか。
dialyzer + typespec を使用することでコードの可読性も上がるはずです。
初めての投稿で汚い文だった思いますが、最後まで読んでいただきまして、ありがとうございました
明日(12/25)のElixir Advent Calendar 2021は@zacky1972さんです。
お楽しみに
お知らせ
The Waggle では研修講師になりたい人を絶賛募集中です。
JavaやPHP、C、C#, Python、機械学習、統計などの分野でも募集しています。自身のスキルの棚卸しや、コーチングやファシリテーションのスキルなども身につきますので、自身のスキルアップとしてチャレンジしたい方もいましたらお問い合わせフォームにご連絡ください。