Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Hexパッケージ

7 Use hex packages.livemd

Hexパッケージ

Mix.install([
  {:jason, "~> 1.4"},
  {:croma, "~> 0.11.3"},
  {:httpoison, "~> 2.1"},
  {:meck, "~> 0.9.2"}
])

Hexパッケージ

ElixirにはHexというパッケージマネージャーがある。

Elixirではmixというツールでプロジェクト管理をする。

  • このようにmix.exsというプロジェクト定義ファイルに依存パッケージを記述する

Erlang向けのHexパッケージもあり、Elixirでも問題なく使用可能。

ここではいくつかのHexパッケージを紹介する。

  • Jason: 高速なJSONパーサー・ジェネレーター
  • Croma: Antikythera創始者の方が作成した、型ベースプログラミングのためのマクロ集
  • meck: Erlang向けのモッキングライブラリ

なお、AntikytheraもHexパッケージとして公開されている。が、ここでは扱わない。

Livebook上での使用

動的にパッケージのインストールを行うため、Mix.install/2を利用する。

Livebookではインストール専用のセルがノートブック上部に配置されているため、そこに必要なパッケージを追加してsetupボタンを押す。

Jason

  • Jason.decode/1 でJSON形式の文字列をElixirの値にパースする
    • Jason.decode!/1 はパースに失敗するとエラー
  • Jason.encode/1 でElixirの値をJSON文字列にエンコードする
    • Jason.encode!/1 はエンコードに失敗するとエラー

WebサーバーではHTTPリクエストやレスポンスのbodyに対して適用することが多い。

json = """
{
  "x": 0,
  "y": "a",
  "array": [0, 1, 2],
  "nested": {
    "inner": {}
  }
}
"""

{:ok, map} = Jason.decode(json)
map
File.read!("./resources/7/test.json")
|> Jason.decode!()
{:error, reason} = Jason.decode("{[]}")
result = Jason.decode!("{[]}")
# => Jason.DecodeError
{:ok, json} = Jason.encode(%{a: 0})
json

Croma

  • Elixirで型ベースプログラミングを行うのを楽にするマクロ集

バリデーションつきStructの定義

# 例1. フィールドのバリデーションつきStructを定義
# new/1 や new!/1 関数でStructを生成する時、フィールドのvalidationが自動で行われる

defmodule TestStruct do
  # xは整数でなければならない
  use Croma.Struct,
    recursive_new?: true,
    fields: [
      x: Croma.Integer
    ]
end

defmodule OtherStruct do
  # xは整数でなければならない
  use Croma.Struct,
    recursive_new?: true,
    fields: [
      x: Croma.Integer
    ]
end
TestStruct.new(%{x: 0})
# xのvalueが整数ではないので失敗
TestStruct.new(%{x: 1.0}
# Structの種類を考慮したパターンマッチ
%TestStruct{x: x} = TestStruct.new!(%{x: 0})

x
# Cromaの使用に関わらず、異なるStructどうしはマッチしない
%OtherStruct{x: x} = TestStruct.new!(%{x: 0})
# => MatchError
# Structのフィールドをより詳細に定義する例

defmodule Food do
  # 特定のatomだけ許可
  defmodule Category do
    use Croma.SubtypeOfAtom, values: [:meat, :vegitable, :fruit]
  end

  # 正規表現で長さ1~50の文字列を許可
  defmodule Name do
    use Croma.SubtypeOfString, pattern: ~r/\A.{1,50}\z/
  end

  use Croma.Struct,
    recursive_new?: true,
    fields: [
      category: Category,
      name: Name
    ]
end

defmodule Eater do
  # Food struct を引数に取る関数
  def eat(%Food{category: category, name: name}) do
    case category do
      :vegitable -> "I do not like #{name}, but I eat it..."
      _ -> "I love #{name}!"
    end
  end
end
[
  %{category: :meat, name: "pork"},
  %{category: :vegitable, name: "tomato"},
  %{category: :fruit, name: "apple"}
]
|> Enum.map(&Food.new!/1)
|> Enum.map(&Eater.eat/1)
# nameの文字数が50より大きい場合にはエラー
Food.new(%{
  category: :meat,
  name: "this meat is something having too long name and we cannot pronounce it"
})
# nameの文字数が0の場合にエラーになることを確かめよう
Food.new(%{})
# categoryに未定義のatomが渡される場合もエラーになることを確かめよう
Food.new(%{})

関数定義における利用例

こちらは参考までに。

関数定義時に関数の型スペックを簡潔に表したり、引数や返り値が期待した型であることのvalidationをしたりできる。

  • defに代わるdefun
  • defpに代わるdefunp
defmodule CromaTestModule do
  # Cromaが提供するマクロを利用するために必要
  use Croma

  @moduledoc """
  `defun`や`defunp`で引数に続けて`:: type`のように型を書く。
  `v[]`で型を囲むと、ランタイム時に引数の型が仕様にあっていることのvalidationが行われる。

  返り値も同様に表現する。

  `defun`や`defunp`で多重定義する際は、無名関数の多重定義のような書き方をする必要がある。
  """

  # Elixir標準の記法
  @spec add(integer, integer) :: integer
  def add(x, y) do
    x + y
  end

  # Cromaを使った記法(型バリデーション付き)
  defun add_integers(x :: v[integer], y :: v[integer]) :: v[integer] do
    x + y
  end

  # Cromaを使った記法(型スペックのみ)
  defun add_integers_without_validation(x :: integer, y :: integer) :: integer do
    x + y
  end

  defun accept_hello_atom(value :: atom) :: Croma.Result.t(:hello, String.t()) do
    :hello -> {:ok, :hello}
    other_atom -> {:error, format_message(other_atom)}
  end

  defunp format_message(value :: v[atom]) :: v[String.t()] do
    "#{value} is not :hello atom"
  end
end
CromaTestModule.add_integers(0, 1)
CromaTestModule.add_integers(0.0, 1)
# => %RuntimeError{message: "validation error: x is not a valid integer"}
CromaTestModule.add_integers_without_validation(0.0, 1)
CromaTestModule.accept_hello_atom(:hello)
CromaTestModule.accept_hello_atom(:world)

meck

Erlangのモッキングライブラリ。

モジュールの関数の振る舞いを動的に変えたり、引数のテストを行ったり、呼び出し回数を計測したりできる。

テストコードでよく使用される。

例えば外部サービスに依存する関数・副作用のある関数のテストを行いたいとき、モックを使用したくなる。

  • 後のHttpBin.send_request/3を使用し、リクエスト先のサーバーがダウンしている状況のテストをしたい
  • 外部サービスがダウンしている時(HTTP status 500が返るとする)の処理が期待どおりか確かめたい
    • しかし、外部サービスを実際にダウンさせることはできない
  • そもそもテストで、外部サービスにリクエストを送りたくない

モックを使うと, 関数呼び出しを監視することができ、必要ならば同じインターフェースで異なる振る舞いをする別の関数に差し替えることができる。

つまり、下の図のようにあるテストケースのときだけHttpBin.send_request/3を常に%{status: 500}を返す別の関数に差し替えることができる。

defmodule HttpBin do
  # HTTPリクエストを行うための事前準備
  HTTPoison.start()

  @endpoint "https://httpbin.org"

  def send_request(method, body_map, header_map) do
    url = @endpoint <> "/#{method}"

    case HTTPoison.post!(url, Jason.encode!(body_map), header_map) do
      %HTTPoison.Response{body: res_body, status_code: 200} ->
        %{status: 200, body: Jason.decode!(res_body)}

      %HTTPoison.Response{status_code: 500} ->
        %{status: 500}
    end
  end
end
HttpBin.send_request(:post, %{foo: "bar"}, %{"Content-Type" => "application/json"})

meckモジュールを使ってHttp.send_request/3が常に%{status: 500}を返すよう振る舞いを変える。

# Erlangのモジュール名は、atomで表す決まりになっている => :meck

# モックするための事前準備
:meck.new(HttpBin, [:passthrough])
# HttpBin.send_request/3 を任意の関数に差し替える
:meck.expect(
  # 対象モジュール
  HttpBin,
  # 関数名のatom
  :send_request,
  # 関数と同じarityを持つ無名関数
  fn _method, _body, _header -> %{status: 500} end
)
HttpBin.send_request(:post, %{foo: "bar"}, %{"Content-Type" => "application/json"})

Http.send_request/3の振る舞いが無名関数fn _method, _body, _header -> %{status: 500} endに差し替わったようだ!

本当にそうなのか、さらに確かめてみよう。

:meck.expect(HttpBin, :send_request, fn method, _body, _header ->
  IO.puts("Mocked function is called 😁")
  IO.puts("Tried to request by #{method} method")
  %{status: 500}
end)
HttpBin.send_request(:post, %{foo: "bar"}, %{"Content-Type" => "application/json"})

😁

モックは他の副作用、例えば日時や乱数が関わるようなテストを実施する際にもよく使われる。

# モックしたモジュールを削除
:meck.unload(HttpBin)