ただしサーバとは言っても
Web サーバ等ではなく、Elixir のサーバプロセスのことですのでその点はご了承を。
国民の祝日については、内閣府から情報が提供されています。
www8.cao.go.jp
また、翌年までの祝日の一覧は CSV 形式のデータで提供されています。
今回はこのデータを使って、日付から祝日を取得するサーバプロセスを作ってゆきます。
まず新しいプロジェクトを用意してください。
$ mix new holiday
$ cd holiday
ここから順番に機能を追加してゆきます。
祝日一覧をダウンロードする
HTTP クライアントには Req を利用します。
hex.pm
mix.exs
に req
を追加し、パッケージを取得します。
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.exs
に iconv
を追加したら、パッケージを取得し 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() 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}, , 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 が現れるまでにどのように分岐してきたのか。
他の生き物との生物としての距離が、日常的に感じるものとは、違っていたりいなかったり。