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

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

Elixirの関数っぽい関数でない何かと、Prologの述語っぽい述語でない何か

Canada という小さな実装のライブラリがあります。

hex.pm

Ruby でいうところの CanCanCan のような権限判定のためのライブラリなのですが、とても興味深い実装をしています。

例えば userarticleread できるか判定するとき、

can?(user, read(article))

あるいは

user |> can?(read(article))

のような書き方をするのですが、このとき read/1 という関数は定義しません。 定義する必要がありませんというのがより正しいかもしれません。

何をやっているのか、その仕組みをなぞるコードを書いて確認してみましょう。

Elixir のばあい

まず、構造体 User と Article を定義します。

defmodule User do
  defstruct [:id, :role, :name]
end
defmodule Article do
  defstruct [:user_id, :title, :body]
end

次に判定のための関数 available?/3 を用意します。 この関数は User の値と Article の値、および atom で操作を受け取り、その組み合わせで操作の可否を返します。

ここでは任意の User は Article を read でき、role が editor である User あるいは所有者である User は Article を更新でき、所有者である User は Article を削除できる、としています。 それ以外の操作はできません。

User の種類 read write delete
任意の User 不可 不可
編集者 (role = editir) 不可
所有者 (User.id = Article.user_id)
defmodule Can do
  def available?(%User{}, :read, %Article{}), do: true
  def available?(%User{role: :editor}, :update, %Article{}), do: true
  def available?(%User{id: id}, :update, %Article{user_id: id}), do: true
  def available?(%User{id: id}, :delete, %Article{user_id: id}), do: true
  def available?(%User{}, _, %Article{}), do: false

  # 後半に続く

最後に、マクロ can?/2 を定義します。 ここで第 2 引数は「関数呼び出し」を受け取るようにします。

関数を呼び出した結果ではなく、関数呼び出しそのものを受け取るという点が要点です。

マクロでは関数呼び出しは関数名と引数に分解されます。

iex> quote do: read(foo)
{:read, [], [{:foo, [], Elixir}]}

マクロの引数に関数呼び出しを渡すと、この分解された形で受け取ることになるので、分解された関数名と引数を使って available?/3 を評価します。

  # 前半からの続き

  defmacro can?(user, {action, _, [article]}) do
    quote do
      available?(unquote(user), unquote(action), unquote(article))
    end
  end
end

マクロを有効にするために import して判定をしてみます。

import Can

# 任意の User
user = %User{id: 123}

# 編集者
editor = %User{id: 234, role: :editor}

# 所有者
owner = %User{id: 345}

article = %Article{user_id: 345}

user |> can?(read(article))     #=> true
user |> can?(update(article))   #=> false
user |> can?(delete(article))   #=> false

editor |> can?(read(article))   #=> true
editor |> can?(update(article)) #=> true
editor |> can?(delete(article)) #=> false

owner |> can?(read(article))    #=> true
owner |> can?(update(article))  #=> true
owner |> can?(delete(article))  #=> true

read/1update/1delete/1 といった関数の呼び出しが現れますが、それらを呼び出した結果でなく呼び出しそのものがマクロの引数となるため、関数の定義は存在しないという興味深い実装になっています。

Canada ではさらに available?/3 に相当する部分がプロトコルで実現されているために、任意の構造体に対して判定を定義することが可能になっています。

Prolog のばあい

同じようなことを Prolog でも書いてみました。

Elixir の母体である Erlang は最初は Prolog で書かれ Prolog の影響を受けていることは知られています。 実際 Prolog で何が起こるか見てみることで、似ているところ違うところを感じてみましょう。

次のコードを can.prolog と言うファイル名で保存します。

can(user(id:_, role:_), read(article(user_id:_, title:_, body:_))) :- !.
can(user(id:_, role:editor), update(article(user_id:_, title:_, body:_))) :- !.
can(user(id:ID, role:_), update(article(user_id:ID, title:_, body:_))) :- !.
can(user(id:ID, role:_), delete(article(user_id:ID, title:_, body:_))) :- !.

GNU Prolog を起動します。

$ gprolog

Prolog のプロンプトが表示されたら ['can.prolog']. と入力してコードを読み込みます。

| ?- ['can.prolog'].
yes

Elixir で書いた時と同じように、任意の User、編集者、所有者それぞれに対して read, update, delete が可能か判定させてみます。

| ?- can(user(id: 123, role: reader), read(article(user_id: 345, title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 123, role: reader), update(article(user_id: 345, title: "Foo", body: "Bar"))).
no
| ?- can(user(id: 123, role: reader), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
no

| ?- can(user(id: 234, role: editor), read(article(user_id: 345, title: "Foo", body: "Bar"))).  
yes
| ?- can(user(id: 234, role: editor), update(article(user_id: 345 title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 234, role: editor), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
no

| ?- can(user(id: 345, role: reader), read(article(user_id: 345, title: "Foo", body: "Bar"))).  
yes
| ?- can(user(id: 345, role: reader), update(article(user_id: 345, title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 345, role: reader), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
yes

同じように判定することができました。

ここで read/1update/1delete/1 といった述語は定義していません。 加えて user/2article/3 も定義していません。 さらに言うと、Prolog には : という演算子は定義されていません。

Prolog は遅延評価であるため、明示的に評価するまで字面のまま扱われます。

そこで user(id: 123, role: reader) と言う記述は述語の定義の user(id:ID, role:_) にマッチし、変数 ID123 が束縛されます。 あとパタンマッチングによって can/2 の定義に適えば yes をそうでなければ no を返すと言うふるまいをします。

Elixir ではマクロという仕組みを使って「関数呼び出し」を引数として受け取れるようにしましたが、Prolog のばあいは逆に明示的に評価するまでは渡された引数の形のまま扱われるため、評価したときにどのような値が得られるかという定義がなくてもパタンマッチに利用できるという面白さがあります。

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 を利用できることが確認できました。