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

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

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 が現れるまでにどのように分岐してきたのか。 他の生き物との生物としての距離が、日常的に感じるものとは、違っていたりいなかったり。