エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Phoenix 1.7 とともに LiveView Streams がやってきた

Elixir の Web フレームワークである Phoenix Framework に、待望の 1.7 がやってきました。

phoenixframework.org

やはり今回のバージョンの最大のポイントは、Controller を用いた静的な View と LiveView のテンプレートが、コンポーネントという形で統合されたところだと思います。 これで同じ部品を Controller でも LiveView でも利用できるようになりました。

これについては、いろいろな人がいろいろな記事を書いてくださると思うので、わたしは別のポイントに注目したいと思います。

リリース記事のタイトルにもある「LiveView Streams」です。

これまでの LiveView は要素の削除が苦手だった

苦手というよりも、フレームワークとして専用の機能が提供されていなかった、が正確かもしれません。 フレームワークが持つ機能を組み合わせ、たりない部分をコードで補うことで実現しなければなりませんでした。

このことについては 2020年12月に記事にも書いています。 実に2年以上前。

blog.emattsan.org

コレクションを扱う

上記のような削除の問題は、ある集まり、コレクションを扱うときに重大になってきます。 データベースを扱うケースは、まさにそのようなケースです。 Web アプリケーションのフレームワークとして「ちょっと遅れをとっている」と、個人的に感じていた部分でした。

それも、今回のバージョンアップで、それも解消されたのです。

内部的には、コレクションを抱え込むモジュールが定義され、挿入や削除の操作が標準装備されました。 フロントエンド側にも対応する操作が実装されているため、最小限のデータのやり取りで、コレクションの要素の挿入や一部の更新や削除ができるようになっています。

さっそくその恩恵を、簡単なコードで見てゆきます。

サンプルアプリケーション MyAPP

まずは mix phx.new コマンドを最新の 1.7 に更新して、サンプルのプロジェクトを作成してください。

$ mix archive.install hex phx_new
$ mix phx.new my_app
$ cd my_app

データを扱うモジュール

本格的にデータベースを利用してもよいのですが、簡単に済ませたいのと、データベースなどに影響されない本質だけを見てゆきたいので、データを扱うモジュールを一から書いてゆきます。

ここで MyApp.Items.Item はデータを格納する構造体、 MyApp.Items はデータを扱うモジュールです。 MyApp.Items.Item のリストを LiveView Stream のコレクションとするわけですが、コレクションの要素になるデータには :id という名前のキーが必要になるようです(要素自体に ID を持たせずに済ませる方法もありますが今回は省略)。

また LiveView Streams の効果がわかりやすいように、 MyApp.Items を使ったデータの更新は非同期で、結果はメッセージで受け取るようにしました。 結果を受け取りたいプロセスは、MyApp.Items.subscribe_items/0 で購読登録をし、更新時にブロードキャストされるメッセージを処理するようにします。

defmodule MyApp.Items.Item do
  defstruct [:id, :name]
end
defmodule MyApp.Items do
  use GenServer

  alias MyApp.Items.Item

  @name __MODULE__

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: @name)
  end

  def subscribe_items do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "items")
  end

  def list_items do
    GenServer.call(@name, :list_items)
  end

  def create_item do
    GenServer.cast(@name, :create_item)
  end

  def delete_item(id) when is_binary(id) do
    delete_item(String.to_integer(id))
  end

  def delete_item(id) do
    GenServer.cast(@name, {:delete_item, id})
  end

  def init(opts) do
    size = opts[:size] || 10

    items =
      1..size
      |> Enum.map(fn id ->
        %Item{id: id, name: "item-#{id}"}
      end)

    {:ok, %{items: items, next_id: size + 1}}
  end

  def handle_call(:list_items, _from, %{items: items} = state) do
    {:reply, items, state}
  end

  def handle_cast(:create_item, %{items: items, next_id: next_id} = state) do
    created_item = %Item{id: next_id, name: "item-#{next_id}"}

    Phoenix.PubSub.broadcast(MyApp.PubSub, "items", {:item_created, created_item})

    {:noreply, %{state | items: items ++ [created_item], next_id: next_id + 1}}
  end

  def handle_cast({:delete_item, id}, %{items: items} = state) do
    case Enum.split_with(items, &(&1.id == id)) do
      {[deleted_item], rest} ->
        Phoenix.PubSub.broadcast(MyApp.PubSub, "items", {:item_deleted, deleted_item})
        {:noreply, put_in(state.items, rest)}

      _ ->
        {:noreply, state}
    end
  end
end

最後に、アプリケーションの起動時にプロセスを起動するため MyApp.Application の children に MyApp.Items を追加します。

defmodule MyApp.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      MyApp.Items, # 追加
      # ...
    ]

    # ...
  end
end

LiveView 本体

次にデータを表示する LiveView のモジュールを追加します。 コレクションの要素を一覧するだけのシンプルなものです。

最上部に表示されている「create」ボタンで要素を追加し、各要素欄にある「delete」ボタンでその要素を削除します。 データのところで説明したように処理を非同期にしているので、ボタンを押した時の処理は MyApp.Items.Item の関数を呼び出すだけで、表示の更新はブロードキャストされたメッセージを受け取った時に行うようにしています。

ここでポイントは 4 つ。

  1. コレクションの割り当てに Phoenix.IiveView.stream/4 を使う
  2. 要素の構築は :for={{dom_id, item} <- @streams.items} id={dom_id} のように、@streams.items から ID とデータのペアを取り出し、ID を設定する
  3. 要素の追加に Phoenix.IiveView.stream_insert/4 を使う
  4. 要素の削除に Phoenix.IiveView.stream_delete/3 を使う

ちなみに、要素の更新は Phoenix.IiveView.stream_insert/4 で行います。 ID が同じ要素があるばあいは更新し、ないばあいは挿入するという動きをします。 そのために ID の指定が必須になっています。

2年あまり前に苦戦した要素の削除が、わずか一行ですんでしまいました。 またコレクションの更新を非同期の PubSub で実現したので、すべての LiveView で同時に要素の追加や削除が行われます。

defmodule MyAppWeb.ItemLive do
  use MyAppWeb, :live_view

  alias MyApp.Items
  alias MyApp.Items.Item

  def mount(_params, _session, socket) do
    items =
      if connected?(socket) do
        Items.subscribe_items()
        Items.list_items()
      else
        []
      end

    socket = stream(socket, :items, items)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-4xl font-bold mb-2">Items</h1>
    <div class="my-2"><.button phx-click="create-item">create</.button></div>
    <div class="border rounded-lg grid grid-cols-1 divide-y">
      <div :for={{dom_id, item} <- @streams.items} id={dom_id} class="h-12 p-2 flex items-center">
        <span class="flex-1"><%= item.name %></span>
        <.button phx-click="delete-item" phx-value-id={item.id}>delete</.button>
      </div>
    </div>
    """
  end

  def handle_event("create-item", _params, socket) do
    Items.create_item()

    {:noreply, socket}
  end

  def handle_event("delete-item", %{"id" => id}, socket) do
    Items.delete_item(id)

    {:noreply, socket}
  end

  def handle_info({:item_created, created_item}, socket) do
    {:noreply, stream_insert(socket, :items, created_item)}
  end

  def handle_info({:item_deleted, deleted_item}, socket) do
    {:noreply, stream_delete(socket, :items, deleted_item)}
  end
end

ルーティング

最後にルーティングの追加です。

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  # ...

  scope "/", MyAppWeb do
    pipe_through :browser

    # ...

    live "/items", ItemLive # 追加
  end

  # ...
end

実行

mix phx.server コマンドでアプリケーションを起動し、ブラウザで http://localhost:4000/items を開くと、次のような一覧が表示されると思います。 「create」ボタンを押すと要素が一つ追加され、「delete」ボタンを押すとその要素が削除されます。 ブラウザの複数のウィンドウでこのページを開いて追加したり削除したりすると、連動するのが見て取れると思います。

動作としては「ただそれだけ」のことなのですが、ただそれだけのことを、ただこれでだけで実現できるインパクトは決して小さくありません。

LiveView Streams にかぎらず、今回の 1.7 に搭載された機能は Phoenix をもう一段高いレベルに押し上げた感じが、個人的にはしています。

余談

このサンプルコードを書き上げたあと、Elixir Forum の Phoenix 1.7 のリリースを告げるスレッドの中に、次の投稿を見つけて「やはり」と思ったのでした。

josevalim
The win for streams will come when pushing updates to LV via PubSub. It is not in the generated code but streams get us closer to that and adding the remaining PubSub bits should be easy.

elixirforum.com

ElixirのログをOTP標準装備のハンドラでファイルに出力する覚え書き

数年前に、Elixir の Logger モジュールのカスタムバックエンドを書く、という記事を書きました。

blog.emattsan.org

ごく最近になって。 Elixir Forum で Logger の設定の記事を読み、OTP が標準で用意しているハンドラを使えば、バックエンドを書かなくてもログをファイルに出力できると知りました。 調べてみると、ハンドラは OTP 21.0 で追加された模様。

奇しくも OTP 21.0 のリリース日は、先の記事の公開日と同じ 2018/06/19 だったことに何かの縁を感じたり感じなかったり。

いまだ全貌を掴みきれていないのですが、ログをファイルに出力する最低限の設定を確認したので、忘れないうちに記録しておきます。

道具立て

logger_std_h モジュール

ハンドラには Erlang のドキュメントの次のページで紹介されている logger_std_h モジュールを利用します。

www.erlang.org

設定項目がいくつかあるのですが、ここでは出力先のファイル名を指定する file という項目のみ利用します。

ハンドラを追加する方法

Erlanglogger モジュールの add_handlers/1 関数を利用してハンドラを追加登録します。

www.erlang.org

ドキュメントに "Reads the application configuration parameter logger and calls add_handlers/1 with its contents." とあるように、設定は config ファイルに記述することになります。

設定の記述法

設定はどのように記述するのか。 logger:add_handlers/1 関数をどこで呼び出すのか。 これらは、Elixir の Logger モジュールのドキュメントに記述があります。

hexdocs.pm

Erlang/OTP handlers must be listed under your own application:

config :my_app, :logger, [
  {:handler, :name_of_the_handler, ACustomHandler, configuration = %{}}
]

And then, explicitly attached in your Application.start/2 callback:

:logger.add_handlers(:my_app)

見ての通り、ドキュメントの記述は Application モジュールを利用することを前提としています。 logger:add_handlers/1 関数を適当なタイミングで呼び出せれば Application モジュールを使わなくてもよいようですが、ここではドキュメントにならって Application ありで書いてゆきます。

実際にログをファイルに出力してみる

プロジェクトを用意する

Application モジュールを利用する雛形を生成するため、--sup オプション付きで mix new コマンドを実行します。

$ mix new my_app --sup
$ cd my_app

config/config.exs ファイルを書く

logger_std_h モジュールを利用する設定を config/config.exs に記述します。

コード中 config: %{...} で記述しているオプションは、Erlang のドキュメントにあったオプションの内容です。 Erlang のオプションであるため、ファイル名は Elixir の文字列(バイナリ)ではなく、Erlang の文字列(文字リスト)で指定する必要があります。

import Config

config :my_app, :logger, [
  {
    :handler,
    :my_log,       # ハンドラを識別するための ID
    :logger_std_h, # 利用するハンドラ
    %{
      config: %{
        # 注意! ファイル名は、String (binary) でなく charlist で指定する
        file: 'log/my_log.log'
      }
    }
  }
]

lib/my_app/application.ex にハンドラを追加するコードを書く

logger:add_handlers/1 関数を呼び出すコードを lib/my_app/application.ex に追加します。

defmodule MyApp.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    :logger.add_handlers(:my_app) # この行を追加

    children = [
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

ここまで記述できたら IEx を起動します。

$ iex -S mix

ハンドラが追加されていることを確認する

登録されているハンドラは logger:get_handler_ids/0 関数で確認することができます。

iex> :logger.get_handler_ids()
[:my_log, Logger]

config/config.exs に記述したハンドラの ID が表示されると思います。 そして、見ての通り、Elixir 標準の Logger も登録されていることがわかります。

この方法は、起動済みのハンドラを変更することはせず、あくまで追加で登録するためのもののようです。

ログを出力してみる

準備ができたのでログを出力して確認してみます。

iex> require Logger                       
Logger
iex> Logger.info("Hello")                 

19:28:42.693 [info] Hello
:ok

標準のハンドラも有効なので、コンソールにログが出力されました。

ファイルを確認してみます。

iex> File.read!("log/my_log.log") |> IO.puts()
2023-01-30T19:28:37.413517+09:00 info: Application: my_app. Started at: nonode@nohost.
2023-01-30T19:28:42.693326+09:00 info: Hello

:ok

ファイルには、Logger.info/1 で出力する前にアプリケーションが起動した時のログも記録されていました。

ディスク用のハンドラもある

ここでは logger_std_h モジュールを使いましたが、それとは別にディスク出力向けのハンドラ logger_disk_log_h も用意されています。

www.erlang.org

ドキュメントを見てみると、ログローテーションをはじめディスクに出力することを意識したとおぼしきオプションが並んでいます。 こちらのハンドラも折を見て試してみようと思います。

いつか読むはずっと読まない:Science と Fiction のはざま

しまった。 まだ「三体」読んでない。

LiveViewでTaskの結果はhandle_infoで受ければよいという話

LiveViewでTaskの結果はhandle_infoで受ければよいという話

ElixirWeekly の何号だったか失念してしまったのですが。

LiveView のプロセスで、非同期処理を Task.async/1 で実行したならば、Task.async/1 が終了時に送信するメッセージを受け取ればよい、というお話。

プロセスのふるまいを学んだときに、どのようなメッセージが送られるか問いことを覚えたはずなのに、文脈が変わっただけでこうも気付けなくなるものかと思い知った次第。

おさらい Task.async/1

Task.async/1 を実行すると、Task 構造体が返ります。

構造体の中のキー :ref にタスクを識別するリファレンスが格納されています。

iex(1)> Task.async(fn -> :timer.sleep(100); 123 end)
%Task{
  owner: #PID<0.109.0>,
  pid: #PID<0.111.0>,
  ref: #Reference<0.1023354925.2953576457.207757>
}

タスクが終了すると、リファレンスとタスクの最後の値のペアがメッセージとして送られてきます。

最後にタスクのプロセスが終了したことの通知 :DOWN が送られます。

iex(2)> flush
{#Reference<0.1023354925.2953576457.207757>, 123}
{:DOWN, #Reference<0.1023354925.2953576457.207757>, :process, #PID<0.111.0>,
 :normal}
:ok

これをふまえて

初期状態 stopping のときに START ボタンを押すと、starting と表示されのち1秒後に running の表示に換わるサンプルです。

Task.async/2 の戻り値に含まれるリファレンスを保存しておきます。 受信したメッセージにタスクのリファンレンスが含まれていれば、先に起動したタスクの結果のメッセージなので、処理をします。

プロセス終了時の :DOWN を含むメッセージも送られてくるので、そのメッセージにマッチする handle_info/2 を用意しておかなければなりません。 ここでは他のすべてのメッセージは無視するようにしています。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, status: "stopped", ref: nil)}
  end

  def render(assigns) do
    ~H"""
    <button phx-click="start">start</button>
    <div><%= @status %></div>
    """
  end

  # ボタンの phx-click="start" のイベントハンドラ
  def handle_event("start", _, socket) do
    task = Task.async(fn ->
      # (時間のかかる処理の代わり)
      Process.sleep(100)
      123
    end)

    {:noreply, assign(socket, status: "starting", ref: task.ref)}
  end

  # タスクの結果を受け取る
  # (socket に格納したリファレンスと受信したメッセージに含まれるリファレンスが一致をチェックする)
  def handle_info({ref, _result}, socket) when socket.assigns.ref == ref do
    {:noreply, assign(socket, status: "running")}
  end

  # :DOWN のメッセージ等を無視する
  def handle_info(_, socket) do
    {:noreply, socket}
  end
end

aws-sdkで取得できるタグを扱いやすくするための覚書

動機

AWS のリソースの多くは key-value の組みをタグとして設定できるようになっているのですが。

例えば EC2 インスタンスを取得する aws-sdk のメソッド Aws::EC2::Client#describe_instances のレスポンスは次のようになっています。

resp.reservations[0].instances[0].tags #=> Array
resp.reservations[0].instances[0].tags[0].key #=> String
resp.reservations[0].instances[0].tags[0].value #=> String

docs.aws.amazon.com

つまり辞書形式になっているわけではなく、単に key と value のペアを格納するクラスの配列にすぎません。

実装を紐解いてみると、次のような構造になっていました。

class Tag < Struct.new(:key, :value)
end

key から value を引きたい場合、たとえば次のようなコードを書くことになります。

tags = [
  Tag.new("foo", 123),
  Tag.new("bar", 456),
  Tag.new("baz", 789)
]

pp tags.find { |tag| tag.key == "foo" }&.value  # => 123
pp tags.find { |tag| tag.key == "bar" }&.value  # => 456
pp tags.find { |tag| tag.key == "baz" }&.value  # => 789
pp tags.find { |tag| tag.key == "hoge" }&.value # => nil

これがもう少しどうにかならないか、というのが今回のお題です。

クラスで包む

Tag の配列を内部に持ち、key-value アクセスを容易にするメソッドを用意するパタンです。

class Tags
  def self.[](tags)
    new(tags)
  end

  def initialize(tags)
    @tags = tags
  end

  def [](key)
    @tags.find { |tag| tag.key == key }&.value
  end
end

pp Tags[tags]["foo"]  # => 123
pp Tags[tags]["bar"]  # => 456
pp Tags[tags]["baz"]  # => 789
pp Tags[tags]["hoge"] # => nil

これは C++ のときに割と好んで利用していた方法です。

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

struct Tag {
    std::string key;
    int value;
};

class Tags {
public:
    Tags(const std::vector<Tag>& tags) : tags_(tags) {}

    int operator [] (const std::string& key) {
        auto tag = std::find_if(tags_.begin(), tags_.end(), [&](const Tag& tag) { return tag.key == key; });
        return tag->value;
    }

private:
    const std::vector<Tag>& tags_;
};

int main(int, char*[]) {
    auto tags = {
        Tag { "foo", 123 },
        Tag { "bar", 456 },
        Tag { "baz", 789 }
    };

    std::cout << Tags(tags)["foo"] << std::endl;
    std::cout << Tags(tags)["bar"] << std::endl;
    std::cout << Tags(tags)["baz"] << std::endl;
}

これは、動的にクラスのメソッドを変更できない環境ではよくある方法と思いますが、Ruby では個々のオブジェクトにも固有のメソッドを追加できることを利用して、tags オブジェクトにメソッドを追加してみようと思います。

オブジェクトを拡張する (1)

今回はメソッドを定義する Tags をモジュールとして定義し、Object#extend を利用して tags オブジェクトにメソッドを組み込んでいます。

module Tags
  def [](key)
    find { |tag| tag.key == key }&.value
  end
end

tags.extend(Tags)

pp tags["foo"]  # => 123
pp tags["bar"]  # => 456
pp tags["baz"]  # => 789
pp tags["hoge"] # => nil

ただし #[] を上書きしてしまうため Array として機能しなくなるというかなり重度な欠点を抱えます。 Array と被らない名前を選べばよい話ではあるのですが。

pp tags[0] # => nil
pp tags[1] # => nil
pp tags[2] # => nil

オブジェクトを拡張する (2)

より Ruby ならではの方法といえば、やはり BasicObject#method_missing のオーバーライド。 アクセスできるキーがメソッドとして記述できる識別子に限られるという制約がありますが、オブジェクトの属性のように扱うことができるのでとても強力です。

module Tags
  def method_missing(name)
    key = name.to_s
    find { |tag| tag.key == key }&.value
  end
end

tags.extend(Tags)

pp tags.foo  # => 123
pp tags.bar  # => 456
pp tags.baz  # => 789
pp tags.hoge # => nil

Phoenix LiveView 0.18 の新しい構文の覚書とQRコード

Phoenix LiveView 0.18 の構文をいじっています。

具体的にはこれ。

hexdocs.pm

:if and :for

It is a syntax sugar for <%= if .. do %> and <%= for .. do %> that can be used in regular HTML, function components, and slots.

For example in an HTML tag:

<table id="admin-table" :if={@admin?}>
  <tr :for={user <- @users}>
    <td><%= user.name %>
  </tr>
<table>

この形の構文は、従来の Phoenix でも、ふだん仕事で使っている Ruby on Rails でも出てこない形なので、今でもこれを見るとむずむずする感じがあるのですが、入れ子が浅くなることで、反復する要素を実際の深さのレベルで書けるという点はわかりやすくてよいですね。

と、いうわけで。 LiveView 0.18 でQRコードを表示させてみました。

defmodule MyAppWeb.QrLive do
  use MyAppWeb, :live_view

  def mount(_, _, socket) do
    {:ok, assign(socket, view_box: "", size: 0, cells: [])}
  end

  def render(assigns) do
    ~H"""
    <form phx-change="change"><textarea name="qr[text]" /></form>

    <svg viewBox={@view_box} xmlns="http://www.w3.org/2000/svg" fill="black">
      <rect x="0" y="0" width={@size} height={@size} fill="white" />
      <rect :for={{x, y} <- @cells} x={x} y={y} width="1" height="1" />
    </svg>
    """
  end

  def handle_event("change", %{"qr" => %{"text" => text}}, socket) do
    socket =
      case QRCode.create(text) do
        {:ok, %QRCode.QR{matrix: matrix}} ->
          size = length(matrix)

          cells =
            for {row, y} <- Enum.with_index(matrix),
                {cell, x} <- Enum.with_index(row),
                cell == 1,
                do: {x, y}

          assign(socket, view_box: "0 0 #{size} #{size}", size: size, cells: cells)

        _ ->
          socket
      end

    {:noreply, socket}
  end
end

実行例。

コンソールにQRコードを表示したい

Webアプリケーションを開発しているとき、携帯端末での表示を確認したくなるときがあります。 ブラウザのレスポンシブ・デザイン・モードを利用すれば、デスクトップでも見た目の確認はできますが、やはり手のひらの中でどのように表示されるかを知るには、端末そのものに表示させるのが一番です。

そのようなときにURLを携帯端末に送るため、コンソールにQRコードを表示する簡単なスクリプトを書いてみました。

自分で書かなくても、完成度の高いツールは巷に溢れていると思いますが、今後QRコードをブラウザに表示したり、メールで送信したりする必要に迫られることもないとは言えないので、そのトレーニングの意味合いも込めて。

とは言え、QRコードエンコーディングをすべて書くのは大変なので、そこはパッケージを利用し、表現のところだけ自分で実装しています。

エンコーディングのパッケージは Hex に公開されている qr_code を利用しました。

hex.pm

Elixir 1.12 からは Mix.install/2 が実装されて、パッケージを簡単に利用できるようになりました。 これも、スクリプトを書くハードルを下げてくれた気がします。

# qr.exs

Mix.install([:qr_code])

defmodule QR do
  @white IO.ANSI.light_white_background()
  @black IO.ANSI.black_background()

  def show_as_qr_code(str) do
    IO.puts(str)

    {:ok, %{matrix: matrix}} =
      str
      |> QRCode.create()

    len = length(hd(matrix))

    edge = [@white, String.duplicate("  ", len + 2), @black]

    IO.puts(edge)

    matrix
    |> Enum.each(fn row ->
      IO.write([@white, "  ", @black])

      row
      |> Enum.map(fn
        1 -> [@black, "  "]
        0 -> [@white, "  "]
      end)
      |> IO.write()

      IO.puts([@white, "  ", @black])
    end)

    IO.puts(edge)
  end
end

System.argv()
|> Enum.each(&QR.show_as_qr_code/1)

スクリプトを書いたら elixir コマンドで実行です。

$ elixir qr.exs https://elixir-lang.org

初回だけ、パッケージのインストールが実行された後にQRコードが表示されます。

2回目以降は、すぐに結果を表示してくれるはずです。

Elixirのドキュメントでガードをグルーピングするときの覚書

ドキュメントを生成した時に、defguard で定義するガードをグルーピングするときの設定について、いつも忘れてしまい自分の以前のリポジトリを見返すことがたびたびなので、こちらの覚書として記録しておきます。

ガードと関数をモジュールに記述した場合、

defmodule FizzBuzz do
  defguard is_pos_integer(n) when is_integer(n) and n > 0
  defguard is_fizz(n) when is_pos_integer(n) and rem(n, 3) == 0
  defguard is_buzz(n) when is_pos_integer(n) and rem(n, 5) == 0

  def fizz_buzz(n) when is_fizz(n) and is_buzz(n), do: "Fizz Buzz"
  def fizz_buzz(n) when is_fizz(n), do: "Fizz"
  def fizz_buzz(n) when is_buzz(n), do: "Buzz"
  def fizz_buzz(n) when is_pos_integer(n), do: to_string(n)
end

何も指定せず ExDoc でドキュメントを生成すると、ガードも Functions にまとめられます。

ドキュメントを紐解くと、@doc 属性と :groups_for_functions を指定することで関数を任意のグループにグルーピングできると書かれています。

hexdocs.pm

ドキュメントにはガードへの言及はないのですが、defguard の実装を確認してみると、@doc guard: true のの設定が確認できます。

結論として、mix.exs で次のように :groups_for_functions を指定すると、ガードを独立したグループにグルーピングすることができるようになります。

defmodule FizzBuzz.MixProject do
  use Mix.Project

  def project do
    [
      app: :fizz_buzz,
      version: "0.1.0",
      elixir: "~> 1.13",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      docs: docs() # 追加
    ]
  end

  # ...中略...

  # 追加
  defp docs do
    [
      groups_for_functions: [
        Guards: & &1[:guard]
      ]
    ]
  end
end