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

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

クエリをオブジェクトにして ActiveRecord から分離する

仕事では Ruby on Rails を主戦場としているわけですが。 最近、クエリメソッドについて考えています。

Rails のクエリメソッドといえば「スコープ」を定義するのが常套手段です。

# app/models/user.rb
class User < ApplicationRecord
  scope :created_at_between, -> (from, to) { where(created_at: from..to) }
end
User.created_at_between('2024-11-01', '2024-11-30')

ここで、クエリメソッドがクラスと一体になっていることが便利なのか否か、このところ考えをめぐらせています。

たとえば、同じクエリメソッドを別のクラスで利用したいばあいはどのように実装するのがよいのか。

Book.created_at_between('2024-11-01', '2024-11-30')

Ruby らしい解としては、モジュールに分離して必要なクラスが mixin するのがよさそうです。

# app/models/concerns/created_at_between.rb
module CreatedAtBetween
  def created_at_between(from, to)
    where(created_at: from..to)
  end
end
# app/models/book.rb
class Book < ApplicationRecord
  extend CreatedAtBetween
end

しかし。 混ぜ合わせるのでなく、もっともっと互いに独立した関係にできないか?

検索すれば、クエリオプジェクトというパタンがすぐにヒットします。

martinfowler.com

これを Ruby に当てはめてみようと思います。

実装は、たとえばこんな感じ。

# app/models/created_at_between.rb
class CreatedAtBetween
  def initialize(from, to)
    @from = from
    @to = to
  end

  def range
    @from..@to
  end

  def apply(query)
    query.where(created_at: range)
  end
end

こんな感じで使えます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User)

#apply には ActiveRecord のクラスだけでなくリレーションも渡せます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User.limit(3))

クエリの内容が適合すれば、ActiveRecord の種類は問いません。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(Book)

別のクエリオブジェクトも作ってみましょう。

# app/models/order_by.rb
class OrderBy
  def initialize(key, direction)
    @key = key
    @direction = direction
  end

  def apply(query)
    query.order(@key => @direction)
  end
end
OrderBy.new(:age, :asc).apply(User)

#apply の結果もまたクエリなので、#reduce などで畳み込むこともできます。

criteria = [
  CreatedAtBetween.new('2024-11-01', '2024-11-30'),
  OrderBy.new(:age, :asc)
]

criteria.reduce(User) { |query, criterion| criterion.apply(query) }

複数のクエリオブジェクトを集約して、畳み込むためのインタフェースを提供するクラスも考えることができそうです。

# app/models/query.rb
class Query
  def initialize(criteria = [], criterion = nil)
    @criteria = [*criteria, *Array(criterion)]
  end

  def created_at_between(from, to)
    Query.new(@criteria, CreatedAtBetween.new(from, to))
  end

  def order_by(key, direction = 'asc')
    Query.new(@criteria, OrderBy.new(key, direction))
  end

  def apply(query)
    @criteria.reduce(query) do |query, criterion|
      criterion.apply(query)
    end
  end
end

メソッドの戻り値も同じクラスのオブジェクトなので、メソッドをチェインして呼び出せませす。

Query.new
  .created_at_between('2024-11-01', '2024-11-30')
  .order_by(:age, :asc)
  .apply(User)

メソッドを呼び出してもオブジェクトの状態は変わらず、新しいオブジェクトを作成して返すので、途中までのクエリを共用できます。

query = Query.new.created_at_between('2024-11-01', '2024-11-30')

query.order_by(:age, :asc).apply(User)
query.order_by(:title, :asc).apply(Book)

なんとなく ActiveRecord とクエリの分離ができましたが、このままだと ActiveRecord がただのデータの塊のようで Ruby っぽい感じがしません。

小さいメソッドを追加して外から見える呼び出しの方向を変えてみます。

# app/models/user.rb
class User < ApplicationRecord
  def self.match(query)
    query.apply(self)
  end
end
query = Query.new.created_at_between('2024-11-01', '2024-11-30').order_by(:age, :asc)

User.match(query)

ActiveRecord に操作が移って Ruby っぽくなったと思います。

もちろんリレーションからも利用できます。

User.where('name LIKE ?', '%A%').match(query)

さらにこのメソッドをスーパークラスで定義すれば、どの ActiveRecord からも利用できるようになるはずです。

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.match(query)
    query.apply(self)
  end
end
query = Query.new
          .created_at_between('2024-11-01', '2024-11-30')
          .order_by(:created_at, :desc)

User.match(query)
Book.match(query)

悪くなさそうです。

調べればもっと洗練された実装が見つかると思うのですが、自身の理解の最初の一歩としては悪くない感じです。

Reqのテストを書く覚書

HTTP クライアントとして Req をよく利用するのですが。

hex.pm

こういった外部の環境と接続する操作はテストが面倒なもの。

その点において Req はテストのための仕組みをパッケージ自身が提供してくれています。 その仕組みの使い方と、ちょっとした工夫の覚え書きです。

新しいプロジェクトを作って順を追って説明します。

$ mix new my_app
$ cd my_app

Req を使う

まず Req の使い方のおさらいから。

mix.exs の依存パッケージに Req を追加し、パッケージを取得します。

  # ...

  defp deps do
    [
      {:req, "~> 0.5"}
    ]
  end

  # ...
$ mix deps.get

Req を使う関数を追加します。

  • lib/my_app.ex
defmodule MyApp do
  def get(url) do
    Req.request(url: url)
  end
end

追加した関数のテストを書きます。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "get/1" do
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

これでもテストとして実行できますが、実行するたびに指定した URL へのアクセスが発生します。 まずこれをスタブにします。

スタブを使う

Req はテストのために Req.Test というモジュールを用意しています。

hexdocs.pm

まずこのモジュールの利用を指定する設定をします。

plug: {Req.Test, MyApp} は、HTTP リクエストのアダプタとして Req.TestMyApp という名前で指定することを表しています。 指定の詳細については Req.new/1 のオプションの説明を参照してください。

設定を config/config.exs に直接書いてもよいのですが、Config.import_config/1 を使った環境ごとに分離する定石に従うことにします。

  • config/test.exs
import Config

config :my_app,
  req_options: [
    plug: {Req.Test, MyApp}
  ]
  • config/config.exs
import Config

import_config "#{config_env()}.exs"

また devprod のために config/dev.exsconfig/prod.exs も作成しておきます。 これらは空のファイルで大丈夫です。

次に MyApp.get/1 を編集して設定した内容を利用するように変更します。

  • lib/my_app.ex
defmodule MyApp do
  def get(url) do
    [url: url]
    |> Keyword.merge(Application.get_env(:my_app, :req_options, []))
    |> Req.request()
  end
end

テストもスタブを利用するように変更します。

スタブは Req.Test.stub/2 で設定します。 第 1 引数は設定で指定した名前です。 第 2 引数は、Phoenix でもおなじみの Plug.Conn の構造体を受け取り、レスポンスを返す関数です。

ここでは Req.Test.text/2 を使ってプレーンテキストを返していますが、他にも html/2json/2 といった関数が用意されています。

また Plug.Conn.put_status/2 などの Plug の関数を利用することも可能です。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "get/1" do
    test "get example.com" do
      Req.Test.stub(MyApp, fn conn ->
        Req.Test.text(conn, "Hello Req stub!")
      end)

      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

最後に、依存パッケージに Plug を追加します。

hex.pm

今回はテストでだけ利用するので [only: :test] オプションを指定しています。

  • mix.exs
  # ...

  defp deps do
    [
      {:req, "~> 0.5"},
      {:plug, "~> 1.16", only: :test}
    ]
  end

  # ...

パッケージを取得してテストを実行します。 Req.Test.text/2 で指定したテキストが返されることが確認できると思います。

$ mix deps.get
$ mix test

setup を使う

スタブを設定するコードを ExUnit.Callbacks.setup/2 に移動して、繰り返し利用できるようにします。 加えてレスポンスのステータスとテキストもテストごとに設定できるように、 @tag で指定できるようにしています。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  setup context do
    body = Map.get(context, :body, "")
    status = Map.get(context, :status, 200)

    Req.Test.stub(MyApp, fn conn ->
      conn
      |> Plug.Conn.put_status(status)
      |> Req.Test.text(body)
    end)
  end

  describe "get/1" do
    @tag body: "Hello Req stub!"
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

Stub module を使う

setup も繰り返し利用できるように、モジュールに分離してみます。

まず、モジュールを追加して setup に書いた内容を移動します。

  • test/support/my_app_stub.ex
defmodule MyApp.Stub do
  defmacro __using__(_) do
    quote do
      setup context do
        body = Map.get(context, :body, "")
        status = Map.get(context, :status, 200)

        Req.Test.stub(MyApp, fn conn ->
          conn
          |> Plug.Conn.put_status(status)
          |> Req.Test.text(body)
        end)
      end
    end
  end
end

今回はモジュールを use することで利用できるように __using__/1マクロを利用しましたが、他にもっとよい方法があるかもしれません。

テストでは setup を削除して、追加したモジュールを use します。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  use MyApp.Stub
  doctest MyApp

  describe "get/1" do
    @tag body: "Hello Req stub!"
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

最後に、追加したモジュールがテストのときにだけコンパイルされるようにする設定を追加します。

  • mix.exs
  # ...

  def project do
    [
      # ...
      elixirc_paths: elixirc_paths(Mix.env()),
      # ...
    ]
  end

  # ...

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  # ...

これでスタブを定義したモジュールを use するだけで繰り返しスタブが利用できるようになりました。

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