Edited at

【短命に終わった】国民の祝日.csvをパースして変換するRubyプログラムとコード解説動画

More than 1 year has passed since last update.


はじめに

1週間ほど前、内閣府の「国民の祝日」CSVがひどい、みたいな話が話題になっていました。

参考: 【悲報】内閣府の「国民の祝日」CSVがひどいと話題に

なぜ「ひどい」と言われていたのかというと、普通のプログラマが期待する「日付と名前が上から下に並ぶCSV」ではなく、「2016年の列 => 2017年の列 => 2018年の列」のように年単位で列方向(横方向)に繰り返すフォーマットになっていたからです。

(しかも一番下に「月日は表示するアプリケーションによって形式が異なる場合があります。」みたいな注意書きが入ってる!)

Screen Shot 2017-03-02 at 9.24.15.png

まあ、ひどいと言えばひどいんですが、これを扱いやすいフォーマットに変換するプログラムを作るのはなかなか面白そうだなと思いました。

というわけで、そんなプログラムを作りました!

ところが、その数時間後・・・。

もう一度そのプログラムを動かしてみると、期待したデータが出てこない。

「おかしいなあ。さっきまでちゃんと動いてたのに?」と思いながら、原因を調査していたら!

あれ!?いつの間にかCSVのフォーマットが変わってる!!

がーん。

なんか「普通のCSV」になっていました。

Screen Shot 2017-03-02 at 9.23.39.png

というわけで、僕が作った変換プログラムは完成からわずか数時間で無用の長物となったのでした・・・。

とはいえ、せっかく作ったのでこのままお蔵入りさせるのももったいない。

なので、Qiitaで紹介しておきます。

また、単にコードを載せるだけでなく、なぜそんなプログラムになったのか、という思考過程から説明します。


設計:変換後のあるべき姿は何か?

最初に考えるのは「変換後のあるべき姿」です。

「え?日付と名前が上から下に並ぶ普通のCSVに変換すればいいんじゃないの?」と思う人もいるかもしれませんが、プログラマの多くはこのデータをプログラム内で利用したいから「ひどいフォーマット」に怒っているはずです。

「プログラム内で利用する」ということを第一目的にすれば、CSVとして出力する必要はありません。

なので、ここは特定の形式のファイルに出力するのではなく、「プログラム内で扱いやすいデータ構造に変換すること」を考えるのが良いと思います。

というわけで僕は次のようにデータ構造に変換するのがいいのではないかと思いました。

{

2016 => {
# 実際のキーは文字列ではなくDateオブジェクト
'2016/01/01' => '元日',
'2016/01/11' => '成人の日',
# ...
'2016/11/23' => '勤労感謝の日',
'2016/12/23' => '天皇誕生日',
},
2017 => {
'2017/01/01' => '元日',
'2017/01/09' => '成人の日',
# ...
'2017/11/23' => '勤労感謝の日',
'2017/12/23' => '天皇誕生日',
},
2018 => {
'2018/01/01' => '元日',
'2018/01/08' => '成人の日',
# ...
'2018/11/23' => '勤労感謝の日',
'2018/12/23' => '天皇誕生日',
},
}

簡単にいうと、ネストしたハッシュです。

親のハッシュは2016、2017のような「年」をキーにしています。

値には子のハッシュが入っています。

このハッシュはキーに日付(Dateオブジェクト)を、値に祝日の名前が入ります。

こんなハッシュを作っておけば、プログラム内でいろいろ使いやすくなります。

holidays = # 上のハッシュを代入する

# 2016年の祝日をまとめて取得する
holidays_in_2016 = holidays[2016]
#=> { '2016/01/01' => '元日', '2016/01/11' => '成人の日', ... }

# 2016/01/11の祝日名を取得する
date = Date.parse('2016/01/11')
holidays_in_2016[date]
#=> '成人の日'

必要に応じてCSVやYAMLなど、他のフォーマットに変換することもできます。

# 全ての祝日をCSV形式で出力する

holidays.each do |year, data|
data.each do |date, name|
puts "#{date},#{name}"
end
end
# 2016/01/01,元日
# 2016/01/11,成人の日
# ...
# 2018/11/23,勤労感謝の日
# 2018/12/23,天皇誕生日

# YAMLに変換する
require 'yaml'
holidays.to_yaml
# ---
# 2016:
# 2016-01-01: 元日
# 2016-01-11: 成人の日
# # ...
# 2016-11-23: 勤労感謝の日
# 2016-12-23: 天皇誕生日
# 2017:
# 2017-01-01: 元日
# 2017-01-09: 成人の日
# # ...
# 2017-11-23: 勤労感謝の日
# 2017-12-23: 天皇誕生日
# 2018:
# 2018-01-01: 元日
# 2018-01-08: 成人の日
# # ...
# 2018-11-23: 勤労感謝の日
# 2018-12-23: 天皇誕生日


APIを設計し、テストコードを書く(TDD)

あるべき姿(アウトプット)が決まったので、次にそれをどんなAPIで実現するか設計します。

とりあえずCSVはWebから自動的にダウンロードするのではなく、予めダウンロードしておいて特定のフォルダに配置しておくことにします。

今回のプログラムは単純なインプットとアウトプットで終わるので、インスタンスを作って状態を持たせたりする必要はなさそうです。

そこでSyukujitsuParserというクラスを作り、そこにparseというクラスメソッドを定義することにします。

csv_path = '(CSVファイルのパス)'

SyukujitsuParser.parse(csv_path)
#=> ハッシュが返る

ちなみに「"Syukujitsu"って、クラス名にローマ字を使うの!?」と思う人がいるかもしれませんが、これは何にでも使える汎用的なプログラムではなく、あくまで「"syukujitsu.csv"という特殊なCSVを扱うためのクラスですよ」という意味を込めて、あえてローマ字のままにしました。


ファイルパスに初期値を持たせる

今回作成するプログラムは以下のようなディレクトリにします。

.

├── lib
│   └── syukujitsu_parser.rb
├── resource
│   └── syukujitsu.csv
└── test
└── syukujitsu_parser_test.rb

libディレクトリの下にあるsyukujitsu_parser.rbがメインプログラムです。

また、例のCSVはresourceディレクトリの下に配置します。

特別な理由がない限り、CSVはresourceディレクトリの下にあるものを使うことになるはずなので、SyukujitsuParser.parseの引数には初期値を持たせましょう。

class SyukujitsuParser

CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

def self.parse(csv_path = CSV_PATH)
# あとでコードを書く
end
end

こうしておけば、引数無しでメソッドを実行できるようになります。

# デフォルトでresourceディレクトリの下にあるCSVを読みに行く

SyukujitsuParser.parse


テストを書く

さて、APIの設計が固まったので、最初にテストコードを書きましょう。

つまり、今回はテスト駆動開発(TDD)で実装します。

また、テスティングフレームワークはRubyで最初から使えるようになっているMinitestを使います。

test/syukujitsu_parser_test.rbは以下のようなコードになります。

require './lib/syukujitsu_parser'

require 'minitest/autorun'

class SyukujitsuParserTest < Minitest::Test
def expected
{
2016 => {
Date.parse('2016/01/01') => '元日',
Date.parse('2016/01/11') => '成人の日',
Date.parse('2016/02/11') => '建国記念の日',
Date.parse('2016/03/20') => '春分の日',
Date.parse('2016/04/29') => '昭和の日',
Date.parse('2016/05/03') => '憲法記念日',
Date.parse('2016/05/04') => 'みどりの日',
Date.parse('2016/05/05') => 'こどもの日',
Date.parse('2016/07/18') => '海の日',
Date.parse('2016/08/11') => '山の日',
Date.parse('2016/09/19') => '敬老の日',
Date.parse('2016/09/22') => '秋分の日',
Date.parse('2016/10/10') => '体育の日',
Date.parse('2016/11/03') => '文化の日',
Date.parse('2016/11/23') => '勤労感謝の日',
Date.parse('2016/12/23') => '天皇誕生日',
},
2017 => {
Date.parse('2017/01/01') => '元日',
Date.parse('2017/01/09') => '成人の日',
Date.parse('2017/02/11') => '建国記念の日',
Date.parse('2017/03/20') => '春分の日',
Date.parse('2017/04/29') => '昭和の日',
Date.parse('2017/05/03') => '憲法記念日',
Date.parse('2017/05/04') => 'みどりの日',
Date.parse('2017/05/05') => 'こどもの日',
Date.parse('2017/07/17') => '海の日',
Date.parse('2017/08/11') => '山の日',
Date.parse('2017/09/18') => '敬老の日',
Date.parse('2017/09/23') => '秋分の日',
Date.parse('2017/10/09') => '体育の日',
Date.parse('2017/11/03') => '文化の日',
Date.parse('2017/11/23') => '勤労感謝の日',
Date.parse('2017/12/23') => '天皇誕生日',
},
2018 => {
Date.parse('2018/01/01') => '元日',
Date.parse('2018/01/08') => '成人の日',
Date.parse('2018/02/11') => '建国記念の日',
Date.parse('2018/03/21') => '春分の日',
Date.parse('2018/04/29') => '昭和の日',
Date.parse('2018/05/03') => '憲法記念日',
Date.parse('2018/05/04') => 'みどりの日',
Date.parse('2018/05/05') => 'こどもの日',
Date.parse('2018/07/16') => '海の日',
Date.parse('2018/08/11') => '山の日',
Date.parse('2018/09/17') => '敬老の日',
Date.parse('2018/09/23') => '秋分の日',
Date.parse('2018/10/08') => '体育の日',
Date.parse('2018/11/03') => '文化の日',
Date.parse('2018/11/23') => '勤労感謝の日',
Date.parse('2018/12/23') => '天皇誕生日',
},
}
end

def test_parse
actual = SyukujitsuParser.parse
assert_equal expected, actual
end
end

APIの戻り値はかなり大きなハッシュになるので、test_parseメソッドの中には直接ハッシュを書かず、expectedというメソッドを用意し、そのメソッドが期待するハッシュを返すようにしました。

(メソッドにしたのはRSpecのletのような使い方をイメージしているためです)


SyukujitsuParser.parseメソッドを実装する

では、いよいよ実装に移ります。

SyukujitsuParser.parseメソッドは次のようなコードになりました。

require 'csv'

require 'date'

class SyukujitsuParser
YEAR_COL = 0
ROW_CYCLE = 2
CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

def self.parse(csv_path = CSV_PATH)
self.new.parse(csv_path)
end

def parse(csv_path)
pair_rows = generate_pair_rows(csv_path)
pair_rows.map { |pair_cols|
year = parse_year(pair_cols[YEAR_COL].first)
[year, to_data(pair_cols)]
}.to_h
end

private

def generate_pair_rows(csv_path)
CSV.read(csv_path, external_encoding: 'CP932', internal_encoding: 'UTF-8')
.transpose
.each_slice(ROW_CYCLE)
.map(&:transpose)
end

def to_data(pair_cols)
pair_cols.map { |name, date|
parsed_date = try_date_parse(date)
[parsed_date, name] if parsed_date
}.compact.to_h
end

def try_date_parse(text)
Date.parse(text) rescue nil
end

def parse_year(text)
text[/\d{4}/].to_i
end
end

それではこのコードを説明していきましょう・・・と思ったのですが、文章で書くと非常に長くなってしまうので、動画で説明することにしました!

YouTubeに解説動画をアップしたので、このプログラムがどんなロジックになっているのかはこちらをご覧ください。

https://www.youtube.com/watch?v=h0WlWPfQ0SwScreen Shot 2017-03-02 at 9.26.41.png

ちなみに動画の長さは約30分です。

1.5倍速~2倍速ぐらいのスピードで視聴すると、さくっと内容を確認できると思います。


WebからCSVをダウンロードできるようにする

さて、上のコードは予めCSVをダウンロードしてあることを前提にしていました。

しかし人間がダウンロードするよりも、プログラム内で自動的にダウンロードできる方が便利でしょう。

というわけでこのプログラムを拡張して、Webから自動的にCSVをダウンロードできるようにしたのが以下のコードです。

require 'csv'

require 'date'
require 'open-uri'
require 'tempfile'

class SyukujitsuParser
YEAR_COL = 0
ROW_CYCLE = 2
CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)
CSV_URL = 'http://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv'

def self.parse_from_web(csv_url = CSV_URL)
Tempfile.create do |file|
csv = open(csv_url).read
File.write(file, csv)
parse(file.path)
end
end

def self.parse(csv_path = CSV_PATH)
self.new.parse(csv_path)
end

# 以下省略
end

拡張といっても、parse_from_webというメソッドを追加しただけです。

このメソッドはCSVファイルをダウンロードして一時ファイルとして保存します。

あとは先ほどのparseメソッドを再利用できるので、ファイルパスをこのメソッドに渡しておしまいです。


parse_from_webのテストコード

parse_from_webはインターネットにアクセスするメソッドです。

しかし、テストコード内で毎回ネットにアクセスするといろいろと不都合があります。

というわけで、今回はVCRというgemを使ってネットへのアクセスをモック化しています。

  def test_parse_from_web

VCR.use_cassette 'test_parse_from_web' do
actual = SyukujitsuParser.parse_from_web
assert_equal expected, actual
end
end

parse_from_webメソッドの実装とテストコードも先ほど紹介した動画の中で説明しているので、詳しくは動画をご覧ください。


参考:GitHub上のコードを確認する

今回紹介したプログラムはGitHubにアップしているので、こちらも参考にしてください。

https://github.com/JunichiIto/parse-syukujitsu


参考:リファクタリング前のコードを確認する

今回紹介したコードは最初からこんなふうになっていたわけではありません。

一番最初は雑に実装し、それからどんどんリファクタリングしていきました。

一番最初の雑なコードはこんな感じです。

require 'csv'

require 'date'

class SyukujitsuParser
YEAR_COL = 0
HEADER_COL = 1
ROW_CYCLE = 2
CSV_PATH = File.expand_path('../../resource/syukujitsu.csv', __FILE__)

def self.parse(csv_path = CSV_PATH)
self.new.parse(csv_path)
end

def parse(csv_path)
pairs = to_transposed_array(csv_path)
.each_slice(ROW_CYCLE)
.map { |name_row, date_row| name_row.zip(date_row) }
ret = {}
pairs.each_with_index { |cols, row_no|
year = parse_year(cols[YEAR_COL][0])
hash = {}
ret[year] = hash
cols.each_with_index { |(name, date), col_no|
if col_no > HEADER_COL
if parsed_date = try_date_parse(date)
hash[parsed_date] = name
end
end
}
}
ret
end

def to_transposed_array(csv_path)
raw_grid = []
CSV.foreach(csv_path, encoding: 'CP932').with_index { |row, row_no|
cols = []
raw_grid << cols
row.each_with_index { |col, col_no|
val = col&.encode('UTF-8')
cols << val
}
}
raw_grid.transpose
end

def try_date_parse(text)
Date.parse(text) rescue nil
end

def parse_year(text)
text[/\d{4}/].to_i
end
end

以下のURLでも確認することができます。

https://github.com/JunichiIto/parse-syukujitsu/blob/39ba3cd056499ff1e280883c4b63242e95b75f5c/lib/syukujitsu_parser.rb

最初にテストを書いておけば、最初は雑に実装して、あとからどんどんリファクタリングしていくことができます。

これがTDDの大きなメリットのひとつです。


余談:国民の祝日.csvはプログラマ向けではない!?

個人的な感想ですが、内閣府のWebサイトを見ていると、この「国民の祝日.csv」はプログラマ向けのデータじゃないような気がします。

実際にアクセスしてみるとわかると思いますが、このページは技術者向けに用意されたようなページには見えません。

CSVについても、ぽろっとさりげなくリンクが張ってあるだけです。

http://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html

Screen Shot 2017-03-02 at 9.09.05.png

なんとなく人間がダウンロードしてExcel等で開くことを想定してそうなので、やれ「フォーマットがひどい」とか、やれ「突然フォーマットを変更するな」といった批判をするのはちょっと筋違いなんじゃないかな、という気がしています。

さらに、そういう意味では今後も突然フォーマットやURLが変わったりする恐れが十分あるので、「Webからダウンロードして自動的に処理」よりも「人間がダウンロードして、その時点のフォーマットに対して処理」とした方が安全なのではないかな、と僕は考えています。


まとめ

というわけで、この記事では悲しくも短命に終わった「国民の祝日.csvをパースして変換するRubyプログラム」を紹介しました。

プログラム自体は短命でしたが、純粋な「文字列処理のプログラム問題」として見ると、なかなか面白い問題だったと思います。

プログラマの入社試験や、新人研修のプログラム問題として出題してみると、ちょうど良いかもしれません。

そういう意味では「ひどいCSV」じゃなかったのかもね、(旧)国民の祝日.csvちゃん!