はじめに
1週間ほど前、内閣府の「国民の祝日」CSVがひどい、みたいな話が話題になっていました。
参考: 【悲報】内閣府の「国民の祝日」CSVがひどいと話題に
なぜ「ひどい」と言われていたのかというと、普通のプログラマが期待する「日付と名前が上から下に並ぶCSV」ではなく、「2016年の列 => 2017年の列 => 2018年の列」のように年単位で列方向(横方向)に繰り返すフォーマットになっていたからです。
(しかも一番下に「月日は表示するアプリケーションによって形式が異なる場合があります。」みたいな注意書きが入ってる!)
まあ、ひどいと言えばひどいんですが、これを扱いやすいフォーマットに変換するプログラムを作るのはなかなか面白そうだなと思いました。
というわけで、そんなプログラムを作りました!
国民の祝日.csvをパースするプログラム、とりあえずコードは書いた。https://t.co/2e54pF0eRl
— Junichi Ito (伊藤淳一) (@jnchito) March 1, 2017
ところが、その数時間後・・・。
もう一度そのプログラムを動かしてみると、期待したデータが出てこない。
「おかしいなあ。さっきまでちゃんと動いてたのに?」と思いながら、原因を調査していたら!
あれ!?いつの間にかCSVのフォーマットが変わってる!!
ちょっと、今さっき「国民の祝日.csv」をダウンロードしてみたら、形式が変わってる!!(CSVらしいCSVになった!!)https://t.co/WTKV2O9deg pic.twitter.com/JyJbHbycx9
— Junichi Ito (伊藤淳一) (@jnchito) March 1, 2017
がーん。
なんか「普通のCSV」になっていました。
というわけで、僕が作った変換プログラムは完成からわずか数時間で無用の長物となったのでした・・・。
とはいえ、せっかく作ったのでこのままお蔵入りさせるのももったいない。
なので、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=h0WlWPfQ0Sw
ちなみに動画の長さは約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にアップしているので、こちらも参考にしてください。
参考:リファクタリング前のコードを確認する
今回紹介したコードは最初からこんなふうになっていたわけではありません。
一番最初は雑に実装し、それからどんどんリファクタリングしていきました。
一番最初の雑なコードはこんな感じです。
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でも確認することができます。
最初にテストを書いておけば、最初は雑に実装して、あとからどんどんリファクタリングしていくことができます。
これがTDDの大きなメリットのひとつです。
余談:国民の祝日.csvはプログラマ向けではない!?
個人的な感想ですが、内閣府のWebサイトを見ていると、この「国民の祝日.csv」はプログラマ向けのデータじゃないような気がします。
実際にアクセスしてみるとわかると思いますが、このページは技術者向けに用意されたようなページには見えません。
CSVについても、ぽろっとさりげなくリンクが張ってあるだけです。
なんとなく人間がダウンロードしてExcel等で開くことを想定してそうなので、やれ「フォーマットがひどい」とか、やれ「突然フォーマットを変更するな」といった批判をするのはちょっと筋違いなんじゃないかな、という気がしています。
さらに、そういう意味では今後も突然フォーマットやURLが変わったりする恐れが十分あるので、「Webからダウンロードして自動的に処理」よりも「人間がダウンロードして、その時点のフォーマットに対して処理」とした方が安全なのではないかな、と僕は考えています。
まとめ
というわけで、この記事では悲しくも短命に終わった「国民の祝日.csvをパースして変換するRubyプログラム」を紹介しました。
プログラム自体は短命でしたが、純粋な「文字列処理のプログラム問題」として見ると、なかなか面白い問題だったと思います。
プログラマの入社試験や、新人研修のプログラム問題として出題してみると、ちょうど良いかもしれません。
そういう意味では「ひどいCSV」じゃなかったのかもね、(旧)国民の祝日.csvちゃん!