LoginSignup
13
3

More than 1 year has passed since last update.

dialyzerを使った静的解析

Last updated at Posted at 2021-12-23

こんにちは、そしてはじめまして 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を追加します。

mix.exs
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プロジェクトにアカウントテーブルを作成して関係する関数やスキーマのフィールドに対して静的解析をしていきます。
まずはマイグレーションファイルから

20211221003013_create_accounts.exs
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

次にスキーマ

account.ex
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コード

accounts.ex
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関数を呼び出すように追加します。

page_controller.ex
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コマンドを使用するときはシェルスクリプトを経由して実行しています。

dialyzer.sh
#!/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では何のエラーも発生しません。コントローラ側で呼び出しの引数をなくしてみます。

page_controller.ex
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を追加していきます。

accounts.ex
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の部分が可読性にかけるのであとで直します。

コントローラのコメントアウトを消してください。

page_controller.ex
def index(conn, _params) do
  account_id = Ecto.UUID.generate()
  Accounts.get_account(account_id)

  render(conn, "index.html")
end

dialyzerを実行します。
特にエラーは発生しませんでした。
ではコントローラの呼び出しの引数の型をUUIDではない型にしたらどうなるか実行してみます。

page_controller.ex
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を使って新しい型を作成します。

account.ex
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ではこれを呼び出します。

accounts.ex
@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 を使用することでコードの可読性も上がるはずです。

初めての投稿で汚い文だった思いますが、最後まで読んでいただきまして、ありがとうございました:innocent:

明日(12/25)のElixir Advent Calendar 2021@zacky1972さんです。
お楽しみに:relaxed:

お知らせ

The Waggle では研修講師になりたい人を絶賛募集中です。
JavaやPHP、C、C#, Python、機械学習、統計などの分野でも募集しています。自身のスキルの棚卸しや、コーチングやファシリテーションのスキルなども身につきますので、自身のスキルアップとしてチャレンジしたい方もいましたらお問い合わせフォームにご連絡ください。

13
3
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
13
3