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

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

Elixirで国民の祝日サーバを作る

ただしサーバとは言っても

Web サーバ等ではなく、Elixir のサーバプロセスのことですのでその点はご了承を。

国民の祝日については、内閣府から情報が提供されています。

www8.cao.go.jp

また、翌年までの祝日の一覧は CSV 形式のデータで提供されています。

今回はこのデータを使って、日付から祝日を取得するサーバプロセスを作ってゆきます。

まず新しいプロジェクトを用意してください。

$ mix new holiday
$ cd holiday

ここから順番に機能を追加してゆきます。

祝日一覧をダウンロードする

HTTP クライアントには Req を利用します。

hex.pm

mix.exsreq を追加し、パッケージを取得します。

  defp deps do
    [
      {:req, "~> 0.5"}
    ]
  end
$ mix deps.get

IEx 上で CSV データをダウンロードできることを確認します。

$ iex -S mix
iex> Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv")
{:ok,
 %Req.Response{
   status: 200,
   headers: %{
     ...
   },
   body: <<141, 145, 150, 175, 130, 204, 143, 106, 147, 250, 129, 69, 139, 120,
     147, 250, 140, 142, 147, 250, 44, 141, 145, 150, 175, 130, 204, 143, 106,
     147, 250, 129, 69, 139, 120, 147, 250, 150, 188, 143, 204, 13, 10, 49, 57,
     53, 53, 47, 49, 47, 49, 44, 140, 179, 147, 250, 13, 10, 49, 57, 53, 53, 47,
     49, 47, 49, 53, 44, 144, 172, 144, 108, 130, 204, 147, 250, 13, 10, 49, 57,
     53, 53, 47, 51, 47, 50, 49, 44, 143, 116, 149, 170, 130, 204, 147, 250, 13,
     10, 49, 57, 53, 53, 47, 52, 47, 50, 57, 44, 147, 86, 141, 99, 146, 97, 144,
     182, 147, 250, 13, 10, 49, 57, ...>>,
   trailers: %{},
   private: %{}
 }}

データは取得できましたがエンコーディングUTF-8 でないため、具体的には SHIFT JIS であるために、このままでは Elixir の文字列として扱えません。

エンコーディングを変換する

UTF-8 に変換するたに iconv を利用します。

hex.pm

iconv は NIF を利用していますのでクロス環境で開発する場合は注意が必要です。

ちなみに iconv は Erlang のパッケージであるためモジュール名は :iconv になりますが、引数はバイナリで与えるため Elixir の関数と同じ感覚で利用することができます。

mix.exsiconv を追加したら、パッケージを取得し IEx を起動します。

  defp deps do
    [
      {:req, "~> 0.5"},
      {:iconv, "~> 1.0"}
    ]
  end
$ mix deps.get
$ iex -S mix

ダウンロードしたデータを :iconv.convert/3 で変換します。

iex> {:ok, resp} = Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv")
iex> :iconv.convert("cp932", "utf-8", resp.body)
"国民の祝日・休日月日,国民の祝日・休日名称\r\n1955/1/1,元日\r\n1955/1/15,成人の日\r\n1955/3/21,春分の日\r\n1955/4/29,天皇誕生日\r\n1955/5/3,憲法記念日\r\n1955/5/5,...

CSV をパースする

CSV のパースには NimbleCSV を利用します。

hex.pm

  defp deps do
    [
      {:req, "~> 0.5"},
      {:iconv, "~> 1.0"},
      {:nimble_csv, "~> 1.2"}
    ]
  end
$ mix deps.get
$ iex -S mix

実は。 ここで一つ問題が発生します。

iex> {:ok, resp} = Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv")
{:ok,
 %Req.Response{
   status: 200,
   headers: %{
     ...
   },
   body: [
     [
       <<141, 145, 150, 175, 130, 204, 143, 106, 147, 250, 129, 69, 139, 120,
         147, 250, 140, 142, 147, 250>>,
       <<141, 145, 150, 175, 130, 204, 143, 106, 147, 250, 129, 69, 139, 120,
         147, 250, 150, 188, 143, 204>>
     ],
     ["1955/1/1", <<140, 179, 147, 250>>],
     ["1955/1/15", <<144, 172, 144, 108, 130, 204, 147, 250>>],
     ["1955/3/21", <<143, 116, 149, 170, 130, 204, 147, 250>>],
     ["1955/4/29", <<147, 86, 141, 99, 146, 97, 144, 182, 147, 250>>],
     ["1955/5/3", <<140, 155, 150, 64, 139, 76, 148, 79, 147, 250>>],
     ...

見ての通り Req.get/1 で取得したデータが CSV としてパース済みとなっています。

以前記事に書いたように、Req は NimbleCSV と一緒に利用したばあい、コンテンツの種類が CSV であると自動的にパースしてしまいます。

blog.emattsan.org

リストのリストに分解された各要素ごとにエンコーディングを変換することもできますが、手間を考えると取得したバイナリデータのエンコーディングを一括で変換してからパースするのがよさそうです。

自動的にパースされるのを防ぐには Req.get/2:decode_body オプションに false を指定します。

iex> {:ok, resp} = Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv", decode_body: false)

また NimbleCSV は「CSV パーサを定義するパッケージ」であるため、あらかじめパーサを定義する必要があります。

iex> NimbleCSV.define(Holiday.Parser, [])

これで CSV パーサモジュール Holiday.Parser が利用できるようになりました。 第二引数のオプションでパーサのふるまいを指定することができますが、今回は特別なふるまいは不要ですので空で指定しています。

ダウンロードした CSV データを :iconvエンコーディング変換し、定義したパーサ Holiday.Parser でパースします。

iex> Holiday.Parser.parse_string(:iconv.convert("cp932", "utf-8", resp.body))
[
  ["1955/1/1", "元日"],
  ["1955/1/15", "成人の日"],
  ["1955/3/21", "春分の日"],
  ...

無事、UTF-8 の文字列のリストのリストを得ることができました。

データを ETS に格納する

検索を簡単にするために、今回は ETS を利用することにします。

www.erlang.org

簡単に使い方をおさらいします。

まず、プロセスを起動します。

iex> table = :ets.new(:holiday, [])
#Reference<0.2088135362.3750625287.239169>

次にデータを投入します。 データはタプルである必要があります。 またタプルの一番最初の要素がキーになります。 ここでは Erlang 形式の日付データ(年月日の数値からなるタプル) {2024, 8, 11} がキーになります。

iex> :ets.insert(table, {{2024, 8, 11}, "山の日"})
true

検索してデータを取得します。

iex> :ets.select(table, [{{{2024, 8, 11}, :"$1"}, [], [:"$1"]}])
["山の日"]

ETS が敬遠される一番の原因が、この検索書式の面妖さではないかと思われるのですが。

今回も、関数形式から検索書式に変換してくれる :ets.fun2ms の助けを借りて切り抜けることにします。

ここで使った検索書式は次のようにして得ることができます。

iex> :ets.fun2ms(fn {{2024, 8, 11}, name} -> name end)
[{{{2024, 8, 11}, :"$1"}, [], [:"$1"]}]

では、データを投入してゆきます。

各祝日の日付は String.split/2 で分割し String.to_integer/1 で整数値に変換します。 それらと名前を合わせて一つのタプルの形式に変換し :ets.insert/2 で登録します。

iex> Holiday.Parser.parse_string(:iconv.convert("cp932", "utf-8", resp.body))
iex> |> Enum.each(fn [date, name] ->
...>   [year, month, day] =
...>     date
...>     |> String.split("/")
...>     |> Enum.map(&String.to_integer/1)
...>   :ets.insert(table, {{year, month, day}, name})
...> end)

いくつか検索してみます。

iex> :ets.select(table, [{{{2024, 8, 11}, :"$1"}, [], [:"$1"]}])
["山の日"]
iex> :ets.select(table, [{{{2024, 1, 1}, :"$1"}, [], [:"$1"]}])
["元日"]

うまく投入できたようです。

データを検索する

単純にキーになる年月日を指定して検索するだけでなく、もう少し複雑な検索もすることができます。

たとえば 2024 年 5 月の祝日をすべて取得してみます。

:ets.fun2ms/2 で検索の書式を調べます。

iex> :ets.fun2ms(fn {{2024, 5, d}, n} -> {{2024, 5, d}, n} end)
[{{{2024, 5, :"$1"}, :"$2"}, [], [{{{{2024, 5, :"$1"}}, :"$2"}}]}]

これを :ets.select/2 に指定して検索します。

iex> :ets.select(table, [{{{2024, 5, :"$1"}, :"$2"}, [], [{{{{2024, 5, :"$1"}}, :"$2"}}]}])
[
  {{2024, 5, 6}, "休日"},
  {{2024, 5, 3}, "憲法記念日"},
  {{2024, 5, 4}, "みどりの日"},
  {{2024, 5, 5}, "こどもの日"}
]

5 月の祝日の一覧を取得することができました。

…が。 順序が日付順になっていません。 ETS は無指定では順序を考慮しないことが原因です。

これは :ets.new/2 のオプションに :ordered_set を指定することで解決します。

iex> table = :ets.new(:hoiday, [:ordered_set])

これで準備が整いました。

サーバを作る

あとはここまでの要素をすべて一つにまとめるだけです。

定番の GenServer を使ってサーバに仕立てます。

defmodule Holiday do
  use GenServer

  NimbleCSV.define(Holiday.Parser, [])

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts)
  end

  def lookup(pid, year, month, day) do
    case GenServer.call(pid, {:lookup, year, month, day}) do
      [result] ->
        result

      [] ->
        nil
    end
  end

  def lookup(pid, year, month) do
    GenServer.call(pid, {:lookup, year, month, :"$3"})
  end

  def lookup(pid, year) do
    GenServer.call(pid, {:lookup, year, :"$2", :"$3"})
  end

  def init(_opts) do
    Process.send_after(self(), :init_table, 0)

    table = :ets.new(:holiday, [:ordered_set])

    {:ok, %{table: table}}
  end

  def handle_call({:lookup, year, month, day}, _from, state) do
    match_spec = [{{{year, month, day}, :"$4"}, [], [{{{{year, month, day}}, :"$4"}}]}]

    result =
      :ets.select(state.table, match_spec)
      |> Enum.map(fn {date, name} ->
        {Date.from_erl!(date), name}
      end)

    {:reply, result, state}
  end

  def handle_info(:init_table, state) do
    {:ok, resp} =
      "https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv"
      |> Req.get(decode_body: false)

    :iconv.convert("cp932", "utf-8", resp.body)
    |> Holiday.Parser.parse_string()
    |> Enum.each(fn [date, name] ->
      [year, month, day] =
        date
        |> String.split("/")
        |> Enum.map(&String.to_integer/1)

      :ets.insert(state.table, {{year, month, day}, name})
    end)

    {:noreply, state}
  end
end

インタフェースには、年のみ、年月のみ、年月日を指定できる Holiday.lookup/1,2,3 の三種類を用意しました。 また年月日を指定した場合はリストでなく見つかった要素そのものを返すようにしています。 加えて日付は Date 型に変換することにしました。

初期化時に Process.send_after/2 を使ってサーバ自身にメッセージを送り、非同期でダウンロードしています。 これにより初期化処理がブロックすることを防いでいます。

サーバを起動するとダウンロードが実行されるのでご注意ください。

動作を確認します。

$ iex -S mix

Holiday_start/0 でサーバを起動します。

iex> {:ok, pid} = Holiday.start_link()

年月日を指定してデータを取得します。 指定した日付が祝日でない場合は nil を返します。

iex> Holiday.lookup(pid, 2024, 8, 11)
{~D[2024-08-11], "山の日"}
iex> Holiday.lookup(pid, 2024, 8, 12)
{~D[2024-08-12], "休日"}
iex> Holiday.lookup(pid, 2024, 8, 13)
nil

年月のみを指定してデータを取得します。 祝日がない月が指定された場合は空のリストを返します。

iex> Holiday.lookup(pid, 2024, 8)
[{~D[2024-08-11], "山の日"}, {~D[2024-08-12], "休日"}]
iex> Holiday.lookup(pid, 2024, 6)
[]

年のみを指定してデータを取得します。

iex> Holiday.lookup(pid, 2024)
[
  {~D[2024-01-01], "元日"},
  {~D[2024-01-08], "成人の日"},
  {~D[2024-02-11], "建国記念の日"},
  {~D[2024-02-12], "休日"},
  {~D[2024-02-23], "天皇誕生日"},
  {~D[2024-03-20], "春分の日"},
  {~D[2024-04-29], "昭和の日"},
  {~D[2024-05-03], "憲法記念日"},
  {~D[2024-05-04], "みどりの日"},
  {~D[2024-05-05], "こどもの日"},
  {~D[2024-05-06], "休日"},
  {~D[2024-07-15], "海の日"},
  {~D[2024-08-11], "山の日"},
  {~D[2024-08-12], "休日"},
  {~D[2024-09-16], "敬老の日"},
  {~D[2024-09-22], "秋分の日"},
  {~D[2024-09-23], "休日"},
  {~D[2024-10-14], "スポーツの日"},
  {~D[2024-11-03], "文化の日"},
  {~D[2024-11-04], "休日"},
  {~D[2024-11-23], "勤労感謝の日"}
]

うまくいったようです。

いつか読むはずっと読まない:全史ならぬ前史

Homo sapiens が現れるまでにどのように分岐してきたのか。 他の生き物との生物としての距離が、日常的に感じるものとは、違っていたりいなかったり。

Phoenixの遊び場でちょっとしたモニタリングツールを作る

遅ればせながら。 Phoenix Playground の存在に気づきました。

hex.pm

以前から。 状態の変化を LiveView を使ってブラウザ上で閲覧できるようにすることを考えているのですが、ごく簡単な情報を表示するために Phoenix app を構築するのが割に合わず、結局コンソール表示で妥協するのが常となっています。

Phoenix Playground を使えば、1 ファイルで LiveView のページが作れるので、シェルスクリプトを書くくらいの気持ちでそういったツールを作れるのではないか、と思った次第。

と、いうわけで。

試しにディレクトリの状態を監視するツールを雑な感じに書いてみました。

ディレクトリの監視には file_system を使いました。

hex.pm

また、時刻を日本時間で表示するために tzdata を使っています。

hex.pm

そして書いたコードがこちら。

Mix.install(
  [
    {:phoenix_playground, "~> 0.1.0"},
    {:file_system, "~> 1.0"},
    {:tzdata, "~> 1.1"}
  ],
  config: [
    elixir: [time_zone_database: Tzdata.TimeZoneDatabase]
  ]
)

defmodule DirWatcherLive do
  use Phoenix.LiveView

  require Logger

  def mount(_params, _session, socket) do
    if connected?(socket) do
      FileSystem.subscribe(:dir_watcher)
    end

    {:ok, assign(socket, items: [])}
  end

  def render(assigns) do
    ~H"""
    <table>
      <thead>
        <th style="width: 180px;">timestamp</th>
        <th style="width: 240px;">filename</th>
        <th>events</th>
      </thead>
      <tbody>
        <tr :for={item <- @items}>
          <td style="width: 180px;"><tt><%= item.time %></tt></td>
          <td style="width: 240px;"><tt><%= item.basename %></tt></td>
          <td><%= item.events %></td>
        </tr>
      </tbody>
    </table>

    <style type="text/css">
      body { padding: 1em; }
    </style>
    """
  end

  def handle_info({:file_event, _worker_pid, {file_path, events}}, socket) do
    Logger.info("file_path: #{file_path}, events: #{inspect(events)}")
    
    {:noreply, assign(socket, :items, [to_item(file_path, events) | socket.assigns.items])}
  end

  defp to_item(file_path, events) do
    %{
      basename: Path.basename(file_path),
      time: current_time(),
      events: events_to_string(events)
    }
  end

  defp current_time do
    DateTime.now!("Asia/Tokyo") |> Calendar.strftime("%Y-%m-%d %H:%M:%S")
  end

  defp events_to_string(events) do
    for event <- events, event in [:created, :modified, :removed, :renamed] do
      event
    end
    |> Enum.join(", ")
  end
end

{:ok, _pid} = FileSystem.start_link(dirs: ["."], name: :dir_watcher)

PhoenixPlayground.start(live: DirWatcherLive)

Phoenix Playground のリポジトリにある demo_live.exs に FileSystem プロセスを加え、LiveView プロセス内でFileSystem.subscribe/1 を使って購読を登録し、handle_info/2 でイベントをハンドリングする、という愚直な実装です。

スクリプトを実行します。

$ elixir dir_watcher.exs

デフォルトの指定では、自動的に作成したページがブラウザで開きます。

スクリプトを実行したディレクトリでファイルの操作をしてみます。

$ echo Hi > hi.txt
$ echo Hello >> hi.txt
$ mv hi.txt hello.txt
$ rm hello.txt 

ブラウザに表示されている LiveView の内容が更新されることが確認できると思います。

分岐をハードコーディングしない方法についての考察

条件による分岐をハードコーディングせずに実現する方法について考えてみました。

ただし、ここで試してみた方法はライブラリやフレームワークとして再利用するというよりも、再実装せずにふるまいを変えることができる電子回路のジャンパピンのような仕組みのイメージです。

最初に分岐をハードコーディング

まずはハードコーディングして実現したいことを整理します。

例は入力で与えられる Map の中の path の値を元に呼び出す関数を切り替える簡単な実装です。

本体。 params.path の値によって Foo, Bar, Baz を呼び分けます。 該当しない場合は Error の関数を呼び出します。

defmodule MyApp do
  def create(params) do
    case params.path do
      "/foo/" <> _ -> MyApp.Foo.create(params)
      "/bar/" <> _ -> MyApp.Bar.create(params)
      "/baz/" <> _ -> MyApp.Baz.create(params)
      _ -> MyApp.Error.create(params)
    end
  end

  def update(params) do
    case params.path do
      "/foo/" <> _ -> MyApp.Foo.update(params)
      "/bar/" <> _ -> MyApp.Bar.update(params)
      "/baz/" <> _ -> MyApp.Baz.update(params)
      _ -> MyApp.Error.update(params)
    end
  end
end

分岐後に呼び出される関数を定義した MyApp.Foo の実装です。 Bar, Baz, Error は名前が異なるだけの同じ内容になるので省略します。

defmodule MyApp.Foo do
  def create(_params) do
    "Foo.create/1"
  end

  def update(_params) do
    "Foo.update/1"
  end
end

テストコード。

defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "create/" do
    test "call create /foo/1", do: assert MyApp.create(%{path: "/foo/1"}) == "Foo.create/1"
    test "call create /bar/1", do: assert MyApp.create(%{path: "/bar/1"}) == "Bar.create/1"
    test "call create /baz/1", do: assert MyApp.create(%{path: "/baz/1"}) == "Baz.create/1"
    test "call create /abc/1", do: assert MyApp.create(%{path: "/abc/1"}) == "Error.create/1"
  end

  describe "update/1" do
    test "call update /foo/1", do: assert MyApp.update(%{path: "/foo/1"}) == "Foo.update/1"
    test "call update /bar/1", do: assert MyApp.update(%{path: "/bar/1"}) == "Bar.update/1"
    test "call update /baz/1", do: assert MyApp.update(%{path: "/baz/1"}) == "Baz.update/1"
    test "call update /abc/1", do: assert MyApp.update(%{path: "/abc/1"}) == "Error.update/1"
  end
end

ここから分岐をはがしてゆきます。

呼び出しを値に置き換える

最初に、関数の呼び出しを分岐から分離します。

分岐は利用するモジュールの取得のみにして、呼び出しには Kernel.apply/3 を利用するように変更しました。

defmodule MyApp do
  def create(params) do
    module =
      case params.path do
      "/foo/" <> _ -> MyApp.Foo
      "/bar/" <> _ -> MyApp.Bar
      "/baz/" <> _ -> MyApp.Baz
        _ -> MyApp.Error
      end

    apply(module, :create, [params])
  end

  def update(params) do
    module =
      case params.path do
      "/foo/" <> _ -> MyApp.Foo
      "/bar/" <> _ -> MyApp.Bar
      "/baz/" <> _ -> MyApp.Baz
        _ -> MyApp.Error
      end

    apply(module, :update, [params])
  end
end

分岐を値に置き換える(1)

次に、分岐を表の検索に置き換えます。

条件と利用するモジュールの対応を表として分離し、条件を元にモジュールを検索することで分岐を置き換えます。

defmodule MyApp do
  def create(params) do
    table = 
      [
        [~r"^/foo/", MyApp.Foo],
        [~r"^/bar/", MyApp.Bar],
        [~r"^/baz/", MyApp.Baz],
        [~r".*",  MyApp.Error]
      ]

    [_, module] =
      Enum.find(table, fn [pattern, _] ->
        params.path =~ pattern
      end)

    apply(module, :create, [params])
  end

  def update(params) do
    table = 
      [
        [~r"^/foo/", MyApp.Foo],
        [~r"^/bar/", MyApp.Bar],
        [~r"^/baz/", MyApp.Baz],
        [~r".*",  MyApp.Error]
      ]

    [_, module] =
      Enum.find(table, fn [pattern, _] ->
        params.path =~ pattern
      end)

    apply(module, :update, [params])
  end
end

分岐を値に置き換える(2)

二つの関数でそれぞれ表を作成しましたが、これを一段入れ子を深くして一つの表にまとめてしまいます。

defmodule MyApp do
  @table %{
    create: [
        [~r"^/foo/", MyApp.Foo, :create],
        [~r"^/bar/", MyApp.Bar, :create],
        [~r"^/baz/", MyApp.Baz, :create],
        [~r".*",  MyApp.Error, :create]
    ],
    update: [
        [~r"^/foo/", MyApp.Foo, :update],
        [~r"^/bar/", MyApp.Bar, :update],
        [~r"^/baz/", MyApp.Baz, :update],
        [~r".*",  MyApp.Error, :update]
    ]
  }

  def create(params) do
    dispatch(:create, params)
  end

  def update(params) do
    dispatch(:update, params)
  end

  def dispatch(action, params) do
    [_, module, function] =
      Enum.find(@table[action], fn [pattern, _, _] ->
        params.path =~ pattern
      end)

    apply(module, function, [params])
  end
end

値を設定ファイルに移動する

最後に、分離した表をモジュールの実装から移動します。

ここで、移動先は config ファイルにします。 中でも config/runtime.exs はアプリケーションの起動時に評価されるので、再コンパイルすることなしにふるまいを変更するのに最適です。

defmodule MyApp do
  def create(params) do
    dispatch(:create, params)
  end

  def update(params) do
    dispatch(:update, params)
  end

  def dispatch(action, params) do
    table = Application.fetch_env!(:my_app, :routing_table)

    [_, module, function] =
      Enum.find(table[action], fn [pattern, _, _] ->
        params.path =~ pattern
      end)

    apply(module, function, [params])
  end
end

config/runtime.exs の定義です。

import Config

config :my_app, :routing_table, %{
  create: [
    [~r"^/foo/", MyApp.Foo, :create],
    [~r"^/bar/", MyApp.Bar, :create],
    [~r"^/baz/", MyApp.Baz, :create],
    [~r".*", MyApp.Error, :create]
  ],
  update: [
    [~r"^/foo/", MyApp.Foo, :update],
    [~r"^/bar/", MyApp.Bar, :update],
    [~r"^/baz/", MyApp.Baz, :update],
    [~r".*", MyApp.Error, :update]
  ]
}

抜き身の Map を使う素朴な実装で、ライブラリなどで利用する仕組みとしてはあまりよいものではありません。 それでも、冒頭の話のようにジャンパピンとして割り切れば悪くはなさそうです。

一緒にインストールされるパッケージによってふるまいを変える Elixir の Req パッケージの覚書

ネット上の CSV データを、Req パッケージを使ってダウンロードし、NimbleCSV でデコードしようとしていたのですが。

hex.pm hex.pm

二つのパッケージをインストールして、Req のレスポンスのボディを NimbleCSV でデコードしたら失敗し、ボディがテキストデータでないことに気がつきました。 なにやらボディの内容がリストになっています。

最初は iodata になっているのかと勘違いしたのですが、実はレスポンスのボディがすでに CSV としてデコードされ、リストのリストになっているのでした。

よくよく Req のドキュメントを調べてみると、JSON や ZIP などは自動的にデコードする仕組みになっているのですが、NimbleCSV を一緒にインストールした場合には CSV も自動的なデコードの対象になる仕組みになっていることがわかりました。

実現方法はいたって単純で、

  • Code.ensure_loaded?/1 で NimbleCSV がロードされているか調べる
  • ロードされていれば NimbleCSV を使ってボディをデコードする、ロードされていなければ何もしない

というもの。 ただし、これだけではコンパイル時に NimbleCSV が見つからないと警告が出てしまいます。 警告を抑えるために、

  • mix.exsproject/0:xref を使って対象外にする

という細工がされていました。 なお :xref の仕様を調べきれていないので具体的な機序はまだ確認できていません。

とはいえ。 実現方法がわかり実践することは可能なので、サンプルを書いてみることにしました。

最初に、二つのパッケージを利用する MyApp と、利用される Foo, Bar を作成します。

$ mix new my_app
$ mix new foo
$ mix new bar

my_app/lib/my_app.ex を編集して Foo を利用する関数を追加します。

defmodule MyApp do
  def do_something do
    Foo.do_something() |> IO.puts()
  end
end

my_app/mix.exs の依存パッケージの記述に Foo を追加します。

defmodule MyApp.MixProject do
  use Mix.Project

  # 略

  defp deps do
    [
      {:foo, path: "../foo"}
    ]
  end
end

次に foo/lib/foo.ex を編集します。

Bar がロードされているか Code.ensure_loaded?/1 で判定します。 ロードされていれば Bar の関数を実行し、そうでなければ自分で値を返します。

defmodule Foo do
  def do_something do
    if Code.ensure_loaded?(Bar) do
      Bar.do_something()
    else
      "do something by Foo"
    end
  end
end

最後に bar/lib/bar.ex を編集します。

defmodule Bar do
  def do_something do
    "do something by Bar"
  end
end

my_app ディレクトリに移動して MyApp.do_something/0 を実行します。

$ cd my_app
$ mix run -e 'MyApp.do_something()'

Foo.do_something/0 が呼び出され、do something by Foo が表示されますが、Bar が未定義であることの警告が表示されてしまいます。

==> foo
Compiling 1 file (.ex)
    warning: Bar.do_something/0 is undefined (module Bar is not available or is yet to be defined)
    │
  4 │       Bar.do_something()
    │           ~
    │
    └─ (foo 0.1.0) lib/foo.ex:4:11: Foo.do_something/0

Generated foo app
do something by Foo

そこで foo/mix.exs を編集して以下の設定を追加します。

defmodule Foo.MixProject do
  use Mix.Project

  def project do
    [
      # 略
      xref: [
        exclude: [
          Bar
        ]
      ],
      # 略
    ]
  end
  # 略
end

これで再度実行すると Foo が再コンパイルされますが、未定義の警告は表示されなくなりました。

$ mix run -e 'MyApp.do_something()'
==> foo
Compiling 1 file (.ex)
Generated foo app
do something by Foo

もう一度 my_app/mix.exs を編集して依存パッケージの記述に Bar を追加します。

defmodule MyApp.MixProject do
  use Mix.Project

  # 略

  defp deps do
    [
      {:foo, path: "../foo"},
      {:bar, path: "../bar"}
    ]
  end
end

もう一度再実行します。

MyApp や Foo はコンパイルされず、Bar だけがコンパイルされるのがわかります。 そして MyApp.do_something/0 から Foo.do_something/0 が、Foo.do_something/0 から Bar.do_something/0 が呼び出され、最終的に do something by Bar が表示されました。

$ mix run -e 'MyApp.do_something()'
==> bar
Compiling 1 file (.ex)
Generated bar app
do something by Bar

Elixirの演算子多重定義に関する覚書

Elixir には演算子を再定義できる機能が備わっています。

defmodule Foo do
  def lhs + rhs do
    String.to_integer(Integer.to_string(lhs) <> Integer.to_string(rhs))
  end
end

普通の関数と同じように利用することができます。

Foo.+(123, 456)

モジュール名で修飾することなく、一般的な演算子として利用するには import する必要があります。

import Foo, only: [+: 2]

しかしこれだけでは正しく機能しません。

123 + 456
#=> error: function +/2 imported from both Foo and Kernel, call is ambiguous

Kernel.+/2 も同時に定義されているために、どちらの演算子を利用したらよいか判別がつかないからです。

再定義する演算子の引数の型を限定すれば、型を元に判別ができるのではと思うのですが、

defmodule Foo do
  defstruct [:value]

  def new(value \\ 0) do
    %__MODULE__{value: value}
  end

  def lhs + rhs when is_struct(lhs, Foo) and is_struct(rhs, Foo) do
    new(Kernel.+(lhs.value, rhs.value))
  end
end

残念ながら結果は同じです。

import Foo, only: [+: 2]
Foo.new(123) + Foo.new(456)
#=> error: function +/2 imported from both Foo and Kernel, call is ambiguous

このようなばあい、衝突している関数の import を解除することで解消することができます。

import Kernel, except: [+: 2]
import Foo, only: [+: 2]
Foo.new(123) + Foo.new(456)
#=> %Foo{value: 579}

ただし、当然ですが、解除した関数を利用することができなくなります。

123 + 456
#=> ** (FunctionClauseError) no function clause matching in Foo.+/2    

これを解消する方法として、再定義した演算子を適用しない「その他のケース」のばあいに元の関数を呼び出せばよさそうです。

defmodule Foo do
  defstruct [:value]

  def new(value \\ 0) do
    %__MODULE__{value: value}
  end

  def lhs + rhs when is_struct(lhs, Foo) and is_struct(rhs, Foo) do
    new(Kernel.+(lhs.value, rhs.value))
  end

  def lhs + rhs when is_struct(lhs, Foo) and is_number(rhs) do
    new(Kernel.+(lhs.value, rhs))
  end

  def lhs + rhs when is_number(lhs) and is_struct(rhs, Foo) do
    new(Kernel.+(lhs, rhs.value))
  end

  # その他のケース
  def lhs + rhs do
    Kernel.+(lhs, rhs)
  end
end
import Kernel, except: [+: 2]
import Foo, only: [+: 2]

Foo.new(123) + Foo.new(456)
#=> %Foo{value: 579}

Foo.new(123) + 456
#=> %Foo{value: 579}

123 + Foo.new(456)
#=> %Foo{value: 579}

123 + 456
#=> 579

最後に。 import Kernelimport Foo はいつも組で利用するので、一つの記述で済ませられるようにマクロを定義すると便利です。

defmodule Foo do
  defstruct [:value]

  defmacro __using__(_) do
    quote do
      import Kernel, except: [+: 2]
      import Foo, only: [+: 2]
    end
  end

  # 略
end

これで use するだけで利用できるようになりました。

use Foo

Foo.new(123) + Foo.new(456)
#=> %Foo{value: 579}

123 + 456
#=> 579

PureScript と Erlang、と Elixir、の覚書

PureScript というプログラミング言語があります。

Haskell のような構文で記述でき JavaScript を出力できる、ということを半年ほど前に知ったのですが。

www.purescript.org

最近になって、バックエンドを切り替えれば Erlangソースコードを出力できるということを知りました。

元々は Phoenix のフロントエンドのプログラミングで PureScript を使うための方法を調べようとしていたのですが、あまりに面白そうだったので先にこちらに手を出した次第。

なお、ここから先は PureScript の開発環境は別途準備できている前提で話をしてゆきます。

Alternate backends

PureScript に利用できるバックエンドは、ドキュメントにまとめられています。

github.com

Erlang をターゲットにした purerl は今も開発が続けられ、現時点では一つ前のバージョン PureScript 0.15.14 まで対応されています。

github.com

Installation

purerl のインストールは、利用する環境のバイナリを GitHub からダウンロードするのが今のところ一番簡単な方法のようです。

github.com

ダウンロードできたら、 purerl コマンドを実行できるようにパスを設定するかリンクを作成するなどします。

purerl を利用した PureScript プロジェクトのサンプル

PureScript のパッケージマネジャの spago を使って、新しい PureScript プロジェクトを作成します。

$ mkdir example
$ cd example
$ spago init

purerl を設定する

spago.dhall を編集してバックエンドの指定を追加します。

{ name = "my-project"
, packend = "purerl" -- この行を追加する
, dependencies = [ "console", "effect", "prelude" ]
, packages = ./packages.dhall
, sources = [ "src/**/*.purs", "test/**/*.purs" ]
}

packages.dhall を編集して利用するパッケージの指定を purerl のものに変更します。

let upstream =
      https://github.com/purerl/package-sets/releases/download/erl-0.15.3-20220629/packages.dhall
        sha256:48ee9f3558c00e234eae6b8f23b4b8b66eb9715c7f2154864e1e425042a0723b

spago run コマンドを実行すると、パッケージのインストールやビルドが実行され、 src/Main.purs に書かれたコードの実行結果が出力されることを確認できると思います。

$ spago run
... パッケージのインストールやビルドのログ ...
🍝

生成された Erlang のコードは output/ に出力されています。

$ ls output/Main/ 
corefn.json     externs.cbor        main.hrl        main@foreign.hrl    main@ps.erl

また .beam ファイルは ebin/ に出力されます。 src/Main.pursコンパイル結果は main@ps.beam に出力されています。

$ ls ebin/main*       
ebin/main@ps.beam

インストールされたパッケージのソースコードやバイナリも output/ebin/ に格納されていることが確認できると思います。

Erlang から利用する

erl を起動します。 このとき、検索対象のパスに .beam ファイルが格納されたディレクトリを指定します。

$ erl -pa ebin

.beam ファイルのファイル名から、モジュール名は main@ps とわかるので、main@ps:main を実行してみます。

1> main@ps:main().
#Fun<effect_console@foreign.0.108104793>

main の型は Effect Unit と定義されていますが、 Effect モナドの値は Erlang からは関数に見えるようです。

戻り値の関数を実行してみます。

2> (main@ps:main())().
🍝
ok

Elixir から利用する

iex からも利用しています。 やり方は erl と同じです。

$ iex -pa ebin     
iex(1)> :main@ps.main()
#Function<0.108104793/0 in :effect_console@foreign.log/1>
iex(2)> :main@ps.main().()
🍝
:ok

elixir コマンドで直接実行することもできます。

$ elixir -pa ebin -e ':main@ps.main().()'
🍝

purerl を利用した Elixir プロジェクトのサンプル

Hex を検索すると、Elixir から purerl を利用するためのパッケージ purerlex を登録してくださっている方がいます。

hex.pm

これを利用させてもらうことにしました。

まず Elixir のプロジェクトを作成します。

$ mix new my_app
$ cd my_app

続いて同じディレクトリで PureScript のプロジェクトを作成します。

$ spago init

先の purerl のサンプルと同様に spago.dhallpackages.dhall を編集してバックエンドとパッケージの取得先を指定します。

purerlex を利用する

mix.exs を次のように編集します。

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.16",
      start_permanent: Mix.env() == :prod,
      erlc_paths: ["output"],                  # 追加: Erlang のソースコードのパスとして output を指定
      compilers: [:purerl] ++ Mix.compilers(), # 追加: Elixir のコンパイル時に purerl の実行を指定
      deps: deps()
    ]
  end

  def application do
    [
      extra_applications: [:logger]
    ]
  end

  defp deps do
    [
      {:purerlex, "~> 0.12.2"}                 # 追加
    ]
  end
end

ちなみに。 ここでコンパイラに指定している purerl は、 purerl コマンドではなく、purerlex が定義しているタスクです。

パッケージを取得します。

$ mix deps.get

iex を起動します。

$ iex -S mix                                                                                  

-S mix を指定して iex を起動すると自動的にコンパイルが実行されますが、このとき PureScript のパッケージの取得やビルドも実行されていることが確認できると思います。

iex(1)> :main@ps.main()
#Function<0.108104793/0 in :effect_console@foreign.log/1>
iex(2)> :main@ps.main().()
🍝
:ok

Elixir から利用する

Elixir のプロジェクトを作成したときに作成される MyApp.hello/0 を PureScript で書いたものに置き換えてみます。

MyApp.hello/0 はアトムを返すので、アトムを利用できるように PureScript のパッケージを追加します。 このパッケージは packages.dhall で指定した package-sets を元に検索されます。

$ spago install purescript-erl-atom

新しく src/MyApp.purs を作成します。

module MyApp where

import Erl.Atom (atom)

hello = atom "world"

コンパイル

$ mix compile

コンパイルの結果出力される .beam ファイルが格納される _build/dev/lib/my_app/ebin/ を確認すると myApp@ps.beam という名前で出力されていることがわかります。

実行してみます。

$ iex -S mix
iex(1)> :myApp@ps.hello()
:world

期待する値が得られることが確認できました。

MyApp の呼び出しを委譲してみます。

defmodule MyApp do
  defdelegate hello, to: :myApp@ps
end

当然ですが期待する結果がえられますしテストもパスします。

$ iex -S mix
iex(1)> MyApp.hello()
:world
$ mix test
1 test, 0 failures

Elixir のプロジェクトで PureScript を利用できることが確認できました。

フォントデータを ETS で保存する

前回の続きです。

前回は BDF ファイルをパースして読み込む話をしました。

そして、BDF ファイルは単純なテキストファイルだし、Nerves アプリケーションへそのまま持っていっても大丈夫だろう、と高を括っていたのですが。

Raspberry Pi ZERO W の非力さを甘くみていました。

起動時に BDF ファイルを読み込ませるようにしたら、電源を入れてもなかなか入力に反応しない。 何かを壊したかとあせりもしたのですが、結局テキストのパースに時間がかかっている様子でした。

一旦読み込み終えてしまえば、あとはメモリ上のアクセスのみになるので、その後の動作には影響しません。 しかし電源を入れてから使えるようになるまで時間がかかるのは問題です。 そのため、読み込んだデータを別の形式で保存しておきすぐに読み出せるようにできる方法を模索しました。

せっかくなら文字コードをキーにアクセスできるように、key-value ストレージのようなものでなにか扱いやすいもの。

Hex も検索してみたのですが。 よく考えてみればあるではないですか、標準装備のストレージが。

ETS です。

www.erlang.org elixirschool.com

どのように利用するとデータが扱いやすくなるのか、いくつか格納方法を変えて試してみました。

今回もフォントデータには 16 ドットの東雲フォントを利用しています。 ファイルサイズは約 1.1 M バイト。

-rw-r--r--@ 1 matsumotoeiji  staff  1135668  9 15  2004 shnmk16.bdf

構造体で保存する

Erlang Term Storage の名の通り、Erlang Term であればなんでも格納できるとあって、まずは構造体をそのまま格納。

BDF モジュールは前回の記事で登場したモジュールです。

{:ok, fonts} = BDF.load("shinonome-0.9.11/bdf/shnmk16.bdf")

table = :ets.new(:shnmk16, [:set])

Enum.each(fonts, fn font ->
  :ets.insert(table, {font.encoding, font})
end)

:ets.tab2file(table, ~c"priv/fonts/shnmk16.ets")

できたファイルのサイズは約 2 M バイト。

$ mix run bdf2ets-1.exs
$ ls -l priv/fonts/shnmk16.ets
-rw-r--r--  1 matsumotoeiji  staff  2080992  2 27 19:44 priv/fonts/shnmk16.ets

tab2file/2 で書き出したデータは、file2tab/1 で簡単に復元でき、lookup/2 で検索できます。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.513635613.2690514945.38635>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078,
   %BDF.Font{
  encoding: 0x4E6E,
  dwidth: %BDF.Font.DWIDTH{dwx0: 16, dwy0: 0},
  bbx: %BDF.Font.BBX{bbw: 16, bbh: 16, bbxoff0x: 0, bbyoff0y: -2},
  bitmap: [0x0000, 0x3FFC, 0x0100, 0x7FFE, 0x4102, 0x7D7A, 0x4F3E, 0x0000, 0x1FF8, 0x0000, 0x7FFE, 0x0248, 0x1248, 0x0A50, 0x7FFE, 0x0000]
}
}
]

ただしこれは BDF モジュールが定義されている環境のばあい。

モジュールが定義されていない環境で読み込んでみると。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.217574499.542769155.19734>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078,
   %{
     encoding: 20078,
     __struct__: BDF.Font,
     dwidth: %{__struct__: BDF.Font.DWIDTH, dwx0: 16, dwy0: 0},
     bbx: %{
       __struct__: BDF.Font.BBX,
       bbh: 16,
       bbw: 16,
       bbxoff0x: 0,
       bbyoff0y: -2
     },
     bitmap: [0, 16380, 256, 32766, 16642, 32122, 20286, 0, 8184, 0, 32766, 584,
      4680, 2640, 32766, 0]
   }}
]

読み込めないわけではないものの、構造体が定義されていないので、内部構造が丸見えになったマップとして扱われています。

タプルで保存する

一度構造体に格納したものを、ただのタプルにしてしまうのもどうかと思いましたが。 内容の単純さや利用シーンを考えるとタプルでもさほど不便はないかと思い直すなど。

{:ok, fonts} = BDF.load("shinonome-0.9.11/bdf/shnmk16.bdf")

table = :ets.new(:shnmk16, [:set])

Enum.each(fonts, fn font ->
  :ets.insert(
    table,
    {
      font.encoding,
      font.dwidth.dwx0,
      font.dwidth.dwy0,
      font.bbx.bbw,
      font.bbx.bbh,
      font.bbx.bbxoff0x,
      font.bbx.bbyoff0y,
      font.bitmap
    }
  )
end)

:ets.tab2file(table, ~c"priv/fonts/shnmk16.ets")
$ mix run bdf2ets-2.exs
$ ls -al priv/fonts/shnmk16.ets
-rw-r--r--  1 matsumotoeiji  staff  767103  2 27 19:52 priv/fonts/shnmk16.ets

約 767 k バイト。構造体で保存した時の 3 分の 1 程度。

構造から名前が失われ、読みだせばたただの数字の羅列。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.2786847046.275644418.110991>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078, 16, 0, 16, 16, 0, -2,
   [0, 16380, 256, 32766, 16642, 32122, 20286, 0, 8184, 0, 32766, 584, 4680,
    2640, 32766, 0]}
]

しかしタプルの中の位置がわかっていればよいので、これでも構わない気もしてくる。

バイナリで保存する

フォントデータはビット操作で加工したりするわけだし、いっそのこと全体をバイナリにしてしまっても構わないのでは? と思ってやってみました。

ETS に格納できるのは先頭の要素をキーとしたタプルのみなので、文字コードだけをそのままに、残りのデータを一つのバイナリに詰め込み。

{:ok, fonts} = BDF.load("shinonome-0.9.11/bdf/shnmk16.bdf")

table = :ets.new(:shnmk16, [:set])

Enum.each(fonts, fn font ->
  bitmap = for line <- font.bitmap, into: <<>>, do: <<line::size(font.bbx.bbw)>>

  :ets.insert(
    table,
    {
      font.encoding,
      <<
        font.dwidth.dwx0::8,
        font.dwidth.dwy0::8,
        font.bbx.bbw::8,
        font.bbx.bbh::8,
        font.bbx.bbxoff0x::8,
        font.bbx.bbyoff0y::8,
        bitmap::binary
      >>
    }
  )
end)

:ets.tab2file(table, ~c"priv/fonts/shnmk16.ets")
$ mix run bdf2ets-3.exs
$ ls -l priv/fonts/shnmk16.ets
-rw-r--r--  1 matsumotoeiji  staff  406276  2 27 20:01 priv/fonts/shnmk16.ets

約 406 k バイト。タプルで保存したときの半分強くらい。構造体で保存したときの 5 分の 1 くらい。 コンパクトにはなりました。

読みだせば、値と値の区切りも喪失した、タプルで格納したときよりもさらに面妖な状態。

iex(1)> {:ok, table} = :ets.file2tab(~c"priv/fonts/shnmk16.ets")
{:ok, #Reference<0.3552940925.8519682.187654>}
iex(2)> :ets.lookup(table, 0x4E6E)
[
  {20078,
   <<16, 0, 16, 16, 0, 254, 0, 0, 63, 252, 1, 0, 127, 254, 65, 2, 125, 122, 79,
     62, 0, 0, 31, 248, 0, 0, 127, 254, 2, 72, 18, 72, 10, 80, 127, 254, 0, 0>>}
]

ベンチマークは未実施

ライブラリの関数を使ってデータを一括で読み込むので時間もかからず、格納のしかたによってファイルサイズを小さくできることもわかりました。 が。 利用時の効率はまだ測れていません。

Elixir がバイナリ操作を得意としているとはいえ、構造を持つタプルの操作と比べると、バイナリの操作には余分に時間がかかるのではと想像します。 その差は微々たるものなのでしょうが、Raspberry Pi でテキストファイルを読み込ませたら存外時間が取られたという前科があるので、油断はできません。 あるいは、表示デバイスとの IO の方の影響の方がずっと大きくて、バイナリの操作にかかる時間は気にするほどのことでないのかもしれません。

こればかりはベンチマークするしかないので、もう少し調べてみたいと思います。