言いたいこと
1年分の試合データ取得&作成に20分ちょっとかかっていたPythonスクリプトを、15年分のデータを10分で作れる所まで改良しました。
成果はこちらのリポジトリを御覧ください。
Shinichi-Nakagawa/py-retrosheet
オリジナルと比べ約20倍の性能改善に成功しています!
が、ドヤるほど大したテクは使っていませんので意見ツッコミお待ちしています。
なおこのネタはPyConJP 2015野球ネタの続編です。
試合開始~PyCon JP発表後
Qiita初投稿です。どうぞ宜しくお願いします。
PyCon JP 2015で、野球Hack!~Pythonを用いたデータ分析と可視化というネタでMLBのデータをダウンロード&マイグレしてIPython notebookやpandasを使って分析と可視化したぜ!っていうネタを紹介させてもらいました。
その中で、「py-retrosheet」という、
MLBの試合&選手成績データ「RETROSHEET」をダウンロードして好きなデータベースにマイグレしてくれるんだぜ!
っていうライブラリをドヤ顔で紹介させてもらいましたが、こちらのライブラリにはバグという程では無いのですが、色々と嫌なモノを見つけてしまいました。
ピンチ!~py-retrosheetの闇
py-retrosheetが無ければPyConに登壇できなかったしジョーイ・ボットやアダム・ダンで弄るネタも出来なかったのでライブラリの作者とコントリビューターには大変感謝しているのですが、いざ使ってみると以下の無視できない問題がありました。
- とにかく処理が遅い!
- コードそのもののメンテナンス性が非常に悪い!
- Python2系、通称「Legacy Python」のみの対応、Python 3対応がされていない
Retrosheetのデータは1シーズンあたりだいたい19~20万行近くのレコードで構成されているのですが、どこで寄り道をしているのか、
データをダウンロードしてからデータベースを作るまで20分も掛かっていました。
※Macbook Pro 13inch(Late 2013、2.8GHz Intel Core i7)での計測結果(以下も同じです)、DBはDocker上に作成(MySQL 5.7)
1万行のデータを作るのに1分です、あり得ません。
ちょっとコードを読んでみると、余計なスクレイピングをしたり、謎のsql文(真っさらな状態からデータベース作るのにunique keyチェックでselect文投げたりとか)を全レコード分発行してたり、お行儀よく1行ずつinsertしたり、、、ということを各所でやっていました。
しかもほとんどコピペで!
また、個人的にはPython 3を開発のメインにしたい人なので、Python 2通称「Legacy Python」にしか対応していないライブラリもイケてないしと思い、ライブラリの改善を決断しました。
改善!~Python 3対応と作り直し
という訳で、早速元のリポジトリをforkし、自分もコントリビュート準備を整え、改善に着手しました。改善は以下の順番で行いました。
- 元のコードの体裁を維持したままPython 3対応
-
元のコードの体裁をリファクタリング(を諦めて)Python 3.5対応のNew py-retrosheetを作成
処理が遅い問題が何よりも深刻で、そちらから先に手を付けるのが本来の順番なのですが、コードの中身を正確に理解、ボトルネックを見つけるのを目標と決め、まずは元のコードをPython 3対応する所から始めました。仕様の把握のほか、テスト条件も明確だったので(元と同じ動きをすればOK)。
その後、元のコードをリファクタしつつメンテナンス性を上げて速度遅延のボトルネックを攻める、、、つもりでしたが、
コードがあまりにもアレ様だったので腹をくくって作りなおすことにしました。
Python 3対応
Python 2から3に移行するときの定番、2to3を使ってコードを変換、地道にExceptionを消しながら対応しました。ついでに、pep8に従っていないかつ直したほうが良さそう(又は直せそう)なところは極力修正しました。
辛かったのは、致し方なくこんなimport文が乱発されたことですね。
import os
import requests
try:
# Python 3.x
from configparser import ConfigParser
import queue
except ImportError:
# Python 2.x (書きたく無いンゴ...orz)
from ConfigParser import ConfigParser
import Queue as queue
importの単位を変えたり、aliasを切ったりしてなんとか上手く逃げ切りました。
この改修が終了後、原作者にプルリクを出しました。
Python 3.5対応のNew py-retrosheetを作成
Python 3対応後、テストを書いてリファクタリングしようと思ったのですが、そもそもテストを書きにくい・コードもイケてないという事で腹をくくって書きなおすことにしました。
py-retrosheetは
- データをRETROSHEETからダウンロードする
- Chadwickというライブラリを使って、データをマイグレ可能な形式に作り直す
- マイグレ実施
というやってることは実にシンプルなのですが、このシンプルな事をわざと難しくやってる感があったので、構成そのものから見直すと同時に、(個人的に試したいという理由で)Python 3.5から加わった仕様を活用したライブラリにしよう!という事で改良を加えました。
また、元のスクリプトは1実行あたり1シーズン(1年)のデータのみ取得&作成という仕様でしたが、現実問題2年分以上のデータが欲しい要望があったのでこちらも対応することにしました。
まずダウンロードはこんな感じ。ここは実は元のコードとほとんど変わっていません。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Download .
Python 3.5.0+ (don't know about 3.4- and 2.x, sorry)
MySQL 5.6.0+ (don't know about 5.5- , sorry)
"""
import os
import click
from configparser import ConfigParser
from classes.fetcher import Fetcher
from queue import Queue
__author__ = 'Shinichi Nakagawa'
class RetrosheetDownload(object):
FILES = (
{
'name': 'eventfiles',
'config_flg': 'dl_eventfiles',
'url': 'eventfiles_url',
'pattern': r'({year})eve\.zip',
'download_url': 'http://www.retrosheet.org/events/{year}eve.zip',
},
{
'name': 'gamelogs',
'config_flg': 'dl_gamelogs',
'url': 'gamelogs_url',
'pattern': r'gl({year})\.zip',
'download_url': 'http://www.retrosheet.org/gamelogs/gl{year}.zip',
}
)
def __init__(self, configfile: str):
"""
initialize
:param configfile: configuration file
"""
# configuration
self.config = ConfigParser()
self.config.read(configfile)
self.num_threads = self.config.getint('download', 'num_threads')
self.path = self.config.get('download', 'directory')
self.absolute_path = os.path.abspath(self.path)
def download(self, queue):
"""
Download & a Archives
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
threads = []
for i in range(self.num_threads):
t = Fetcher(queue, self.absolute_path, {'verbose': self.config.get('debug', 'verbose')})
t.start()
threads.append(t)
for thread in threads:
thread.join()
@classmethod
def run(cls, from_year: int, to_year: int, configfile: str):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
client = RetrosheetDownload(configfile)
if not os.path.exists(client.absolute_path):
print("Directory %s does not exist, creating..." % client.absolute_path)
os.makedirs(client.absolute_path)
urls = Queue()
for year in range(from_year, to_year + 1):
for _file in RetrosheetDownload.FILES:
urls.put(_file['download_url'].format(year=year))
client.download(urls)
@click.command()
@click.option('--from_year', '-f', default=2001, help='From Season')
@click.option('--to_year', '-t', default=2014, help='To Season')
@click.option('--configfile', '-c', default='config.ini', help='Config File')
def download(from_year, to_year, configfile):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
# from <= to check
if from_year > to_year:
print('not From <= To({from_year} <= {to_year})'.format(from_year=from_year, to_year=to_year))
raise SystemExit
RetrosheetDownload.run(from_year, to_year, configfile)
if __name__ == '__main__':
download()
不要なページの取得とスクレイピングをやめました。
また、py-retrosheet全般でメンテナンス課題となっていた、「コマンドラインとconfig周りの引数の扱いが煩雑」という問題について、前者はclickを使って簡素化、後者はクラスの初期処理などでconfigの取得場所を集約することにより解決しました。
これは後に紹介するデータ取得と作成についても同様です。
次はchadwickを使ってデータを取得する部分。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Parse to Event Files, Game Logs and Roster.
Python 3.5.0+ (don't know about 3.4- and 2.x, sorry)
"""
import os
import subprocess
import click
from configparser import ConfigParser
__author__ = 'Shinichi Nakagawa'
class ParseCsv(object):
CW_EVENT = '{chadwick_path}cwevent'
CW_GAME = '{chadwick_path}cwgame'
CW_EVENT_CMD = '{chadwick_path}cwevent -q -n -f 0-96 -x 0-62 -y {year} {year}*.EV* > {csvpath}/events-{year}.csv'
CW_GAME_CMD = '{chadwick_path}cwgame -q -n -f 0-83 -y {year} {year}*.EV* > {csvpath}/games-{year}.csv'
EV_FILE_PATTERN = '{path}/{year}*.EV*'
EVENT_FILE = '{csvpath}/events-{year}.csv'
GAME_FILE = '{csvpath}/games-{year}.csv'
CSV_PATH = 'csv'
def __init__(self):
pass
@classmethod
def exists_chadwick(cls, chadwick_path: str):
"""
exists chadwick binary
:param chadwick_path: chadwick path
:return: True or False
"""
if os.path.exists(chadwick_path) \
& os.path.exists(cls.CW_EVENT.format(chadwick_path=chadwick_path)) \
& os.path.exists(cls.CW_GAME.format(chadwick_path=chadwick_path)):
return True
return False
@classmethod
def generate_files(
cls,
year: int,
cmd_format: str,
filename_format: str,
chadwick_path: str,
verbose: bool,
csvpath: str
):
"""
Generate CSV file
:param year: Season
:param cmd_format: Command format
:param filename_format: Filename format
:param chadwick_path: Chadwick Command Path
:param verbose: Debug flg
:param csvpath: csv output path
"""
cmd = cmd_format.format(csvpath=csvpath, year=year, chadwick_path=chadwick_path)
filename = filename_format.format(csvpath=csvpath, year=year)
if os.path.isfile(filename):
os.remove(filename)
if verbose:
print('calling {cmd}'.format(cmd=cmd))
subprocess.call(cmd, shell=True)
@classmethod
def generate_retrosheet_files(
cls,
from_year: int,
to_year: int,
chadwick_path: str,
verbose: str,
csvpath: str
):
"""
Generate CSV file
:param from_year: Season(from)
:param to_year: Season(to)
:param chadwick_path: Chadwick Command Path
:param verbose: Debug flg
:param csvpath: csv output path
"""
# generate files
for year in [year for year in range(from_year, to_year + 1)]:
# game
ParseCsv.generate_files(
year=year,
cmd_format=ParseCsv.CW_GAME_CMD,
filename_format=ParseCsv.GAME_FILE,
chadwick_path=chadwick_path,
verbose=verbose,
csvpath=csvpath
)
# event
ParseCsv.generate_files(
year=year,
cmd_format=ParseCsv.CW_EVENT_CMD,
filename_format=ParseCsv.EVENT_FILE,
chadwick_path=chadwick_path,
verbose=verbose,
csvpath=csvpath
)
@classmethod
def run(cls, from_year: int, to_year: int, configfile: str):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
config = ConfigParser()
config.read(configfile)
verbose = config.getboolean('debug', 'verbose')
chadwick = config.get('chadwick', 'directory')
path = os.path.abspath(config.get('download', 'directory'))
csv_path = '{path}/csv'.format(path=path)
# command exists check
if not cls.exists_chadwick(chadwick):
print('chadwick does not exist in {chadwick} - exiting'.format(chadwick=chadwick))
raise SystemExit
# make directory
os.chdir(path)
if not os.path.exists(ParseCsv.CSV_PATH):
os.makedirs(ParseCsv.CSV_PATH)
# generate files
cls.generate_retrosheet_files(
from_year=from_year,
to_year=to_year,
chadwick_path=chadwick,
verbose=verbose,
csvpath=csv_path
)
# change directory
os.chdir(os.path.dirname(os.path.abspath(__file__)))
@click.command()
@click.option('--from_year', '-f', default=2001, help='From Season')
@click.option('--to_year', '-t', default=2014, help='To Season')
@click.option('--configfile', '-c', default='config.ini', help='Config File')
def create_retrosheet_csv(from_year, to_year, configfile):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
# from <= to check
if from_year > to_year:
print('not From <= To({from_year} <= {to_year})'.format(from_year=from_year, to_year=to_year))
raise SystemExit
ParseCsv.run(from_year, to_year, configfile)
if __name__ == '__main__':
create_retrosheet_csv()
試合データ(game log)と打席や盗塁といった試合中のイベントデータ(events log)、別々に処理していたのですが、やってることは同じ(使うコマンドと出力先が違うだけ)だったので、処理をメソッドにまとめて定義を外から取る形にしました。これはデータ作成の実装も同じです。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Migrate Database, Game Logs and Roster.
Python 3.5.0+ (don't know about 3.4- and 2.x, sorry)
MySQL 5.6.0+ (don't know about 5.5- , sorry)
"""
import os
import csv
import click
import sqlalchemy
from glob import glob
from configparser import ConfigParser, NoOptionError
__author__ = 'Shinichi Nakagawa'
class RetrosheetMySql(object):
DATABASE_ENGINE = 'mysql+pymysql'
ENGINE = '{engine}://{user}:{password}@{host}/{database}'
TABLES = (
{
'name': 'teams',
'header': False,
'year': False,
'mask': '{path}/TEAM{year}*',
'select': "SELECT * FROM teams WHERE team_id = '{key_0}'",
'where_index': [0],
'insert': 'INSERT INTO teams VALUES {values}',
},
{
'name': 'rosters',
'header': False,
'year': True,
'mask': '{path}/*{year}*.ROS',
'select': "SELECT * FROM rosters WHERE year = {year} AND player_id = '{key_0}' AND team_tx = '{key_1}'",
'where_index': [0, 5],
'insert': 'INSERT INTO rosters VALUES {values}',
},
{
'name': 'games',
'header': True,
'year': False,
'mask': '{path}/csv/games-{year}*.csv',
'select': "SELECT * FROM games WHERE game_id = '{key_0}'",
'where_index': [0],
'insert': 'INSERT INTO games({columns}) VALUES {values}',
},
{
'name': 'events',
'header': True,
'year': False,
'mask': '{path}/csv/events-{year}*.csv',
'select': "SELECT * FROM events WHERE game_id = '{key_0}' AND event_id = '{key_1}'",
'where_index': [0, 96],
'insert': 'INSERT INTO events({columns}) VALUES {values}',
},
)
def __init__(self, configfile: str):
"""
initialize
:param configfile: configuration file
"""
# configuration
config = ConfigParser()
config.read(configfile)
self.path = os.path.abspath(config.get('download', 'directory'))
self.record_check = config.getboolean('retrosheet_mysql', 'record_check')
self.multiple_insert_rows = config.getint('retrosheet_mysql', 'multiple_insert_rows')
# connection
self.connection = self._generate_connection(config)
@classmethod
def _generate_connection(cls, config: ConfigParser):
"""
generate database connection
:param config: ConfigParser object
:return:
"""
try:
database_engine = cls.DATABASE_ENGINE
database = config.get('database', 'database')
host = config.get('database', 'host')
user = config.get('database', 'user')
password = config.get('database', 'password')
except NoOptionError:
print('Need to define engine, user, password, host, and database parameters')
raise SystemExit
db = sqlalchemy.create_engine(
cls.ENGINE.format(
engine=database_engine,
user=user,
password=password,
host=host,
database=database,
)
)
return db.connect()
@classmethod
def _exists_record(cls, year: int, table: dict, csv_row: list, connection):
where = {'key_{i}'.format(i=i): csv_row[v] for i, v in enumerate(table['where_index'])}
where['year'] = year
sql = table['select'].format(**where)
res = connection.execute(sql)
if res.rowcount > 0:
return True
return False
def _multiple_insert(self, query: str, columns: list, values: list):
params = {
'columns': columns,
'values': ', '.join(values),
}
sql = query.format(**params)
try:
self.connection.execute(sql)
except Exception as e:
print(e)
raise SystemExit
def _create_record(self, year: int, csv_file: str, table: dict):
reader = csv.reader(open(csv_file))
headers = []
values = []
if table['header']:
headers = next(reader)
columns = ', '.join(headers)
for row in reader:
# Record Exists Check
if self.record_check and RetrosheetMySql._exists_record(year, table, row, self.connection):
continue
# append record values
if table['year']: row.insert(0, str(year))
values.append('("{rows}")'.format(rows='", "'.join(row)))
if len(values) == self.multiple_insert_rows:
# inserts
self._multiple_insert(table['insert'], columns, values)
values = []
if len(values) > 0:
# inserts
self._multiple_insert(table['insert'], columns, values)
def execute(self, year: int):
for table in self.TABLES:
for csv_file in glob(table['mask'].format(path=self.path, year=year)):
self._create_record(year, csv_file, table)
@classmethod
def run(cls, from_year: int, to_year: int, configfile: str):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
client = RetrosheetMySql(configfile)
for year in range(from_year, to_year + 1):
client.execute(year)
client.connection.close()
@click.command()
@click.option('--from_year', '-f', default=2001, help='From Season')
@click.option('--to_year', '-t', default=2014, help='To Season')
@click.option('--configfile', '-c', default='config.ini', help='Config File')
def migration(from_year, to_year, configfile):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
# from <= to check
if from_year > to_year:
print('not From <= To({from_year} <= {to_year})'.format(from_year=from_year, to_year=to_year))
raise SystemExit
RetrosheetMySql.run(from_year, to_year, configfile)
if __name__ == '__main__':
migration()
こちらの実装の改善点が一番多く、
- insertを複数行insert(mulutiple insert)に変更
- select文でのunique keyチェックをデフォルトでOFF
- 定義(テーブル定義と紐づく設定)と実装(データを作る・入れる)を分離
select文チェックは一応実装していますが使っていません(config設定を変えることにより利用可能)。
お行儀良く1行ずつ入れていたinsert文を1000行単位(こちらもconfigで調整可能)とかにしただけで速度が劇的に向上しました。
また、コード全体の見通しも良くなったので、まだ使っていないオールスターやプレーオフのデータが加わっても簡単に対応できそうです!
ベンチマーク
と、ここまで改善した後に、2014年のデータ(約19万行)を対象にベンチマークをとってみました。
対象は、
- Python 3対応済みの元コード
- Python 3対応 + 作りなおしたバージョン
です。計測は、
- Macbook Pro 13inch(Late 2013、2.8GHz Intel Core i7)
- Python 3.5.0(pyenv上にインストール)
- MySQL 5.7(Dockerにインストール、Mac上で実行)
という条件で行いました。
# 改善前
$ time python download.py -y 2014
Queuing up Event Files for download (2014 only).
Queuing up Game Logs for download (2014 only).
Fetching 2014eve.zip
Fetching gl2014.zip
Zip file detected. Extracting gl2014.zip
Zip file detected. Extracting 2014eve.zip
real 0m5.816s
user 0m0.276s
sys 0m0.066s
$ time python parse.py -y 2014
calling '/usr/local/bin//cwevent -q -n -f 0-96 -x 0-62 -y 2014 2014*.EV* > /Users/shinyorke_mbp/PycharmProjects/py-retrosheet/scripts/files/csv/events-2014.csv'
calling '/usr/local/bin//cwgame -q -n -f 0-83 -y 2014 2014*.EV* > /Users/shinyorke_mbp/PycharmProjects/py-retrosheet/scripts/files/csv/games-2014.csv'
processing TEAM2014
processing ANA2014.ROS
processing ARI2014.ROS
processing ATL2014.ROS
processing BAL2014.ROS
processing BOS2014.ROS
processing CHA2014.ROS
processing CHN2014.ROS
processing CIN2014.ROS
processing CLE2014.ROS
processing COL2014.ROS
processing DET2014.ROS
processing HOU2014.ROS
processing KCA2014.ROS
processing LAN2014.ROS
processing MIA2014.ROS
processing MIL2014.ROS
processing MIN2014.ROS
processing NYA2014.ROS
processing NYN2014.ROS
processing OAK2014.ROS
processing PHI2014.ROS
processing PIT2014.ROS
processing SDN2014.ROS
processing SEA2014.ROS
processing SFN2014.ROS
processing SLN2014.ROS
processing TBA2014.ROS
processing TEX2014.ROS
processing TOR2014.ROS
processing WAS2014.ROS
processing /Users/shinyorke_mbp/PycharmProjects/py-retrosheet/scripts/files/csv/games-2014.csv
processing /Users/shinyorke_mbp/PycharmProjects/py-retrosheet/scripts/files/csv/events-2014.csv
real 19m53.224s
user 13m16.074s
sys 0m26.294s
# 改善後
$ time python retrosheet_download.py -f 2014 -t 2014
Fetching 2014eve.zip
Fetching gl2014.zip
Zip file detected. Extracting gl2014.zip
Zip file detected. Extracting 2014eve.zip
real 0m4.630s
user 0m0.217s
sys 0m0.055s
$ time python parse_csv.py -f 2014 -t 2014
calling /usr/local/bin/cwgame -q -n -f 0-83 -y 2014 2014*.EV* > /Users/shinyorke_mbp/PycharmProjects/py-retrosheet/scripts/files/csv/games-2014.csv
calling /usr/local/bin/cwevent -q -n -f 0-96 -x 0-62 -y 2014 2014*.EV* > /Users/shinyorke_mbp/PycharmProjects/py-retrosheet/scripts/files/csv/events-2014.csv
real 0m8.713s
user 0m8.321s
sys 0m0.221s
$ time python retrosheet_mysql.py -f 2014 -t 2014
real 0m21.435s
user 0m3.580s
sys 0m0.416s
元々20分かかってた処理が1分を切る所まで改善できました!
これで大量の試合・打席データが欲しくなっても気軽に入手したり作れたりできそうです。
試しに15年分のデータを作ってみる
面白くなってきたので、2000~2014年までの15年分のデータを作ってみることにしました。
総レコード数は約290万、300万行かない程度です。
まとめて実行する用のscriptを新たに書き起こしました。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Migrate Retrosheet Database.
Python 3.5.0+ (don't know about 3.4- and 2.x, sorry)
MySQL 5.6.0+ (don't know about 5.5- , sorry)
"""
import logging
import click
from retrosheet_download import RetrosheetDownload
from retrosheet_mysql import RetrosheetMySql
from parse_csv import ParseCsv
__author__ = 'Shinichi Nakagawa'
@click.command()
@click.option('--from_year', '-f', default=2010, help='From Season')
@click.option('--to_year', '-t', default=2014, help='To Season')
@click.option('--configfile', '-c', default='config.ini', help='Config File')
def main(from_year, to_year, configfile):
"""
:param from_year: Season(from)
:param to_year: Season(to)
:param configfile: Config file
"""
# from <= to check
if from_year > to_year:
print('not From <= To({from_year} <= {to_year})'.format(from_year=from_year, to_year=to_year))
raise SystemExit
# logging setting
logging.basicConfig(
level=logging.INFO,
format="Time:%(asctime)s.%(msecs)03d\t message:%(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# Download
logging.info('File Download Start({from_year}-{to_year})'.format(from_year=from_year, to_year=to_year))
RetrosheetDownload.run(from_year, to_year, configfile)
logging.info('File Download End')
# Parse Csv
logging.info('Csv Parse Start({from_year}-{to_year})'.format(from_year=from_year, to_year=to_year))
ParseCsv.run(from_year, to_year, configfile)
logging.info('Csv Parse End')
# Migrate MySQL Database
logging.info('Migrate Database Start({from_year}-{to_year})'.format(from_year=from_year, to_year=to_year))
RetrosheetMySql.run(from_year, to_year, configfile)
logging.info('Migrate Database End')
if __name__ == '__main__':
main()
結果はこちら
$ python migration.py -f 2000 -t 2014
Time:2015-11-15 15:23:48.291 message:File Download Start(2000-2014)
Directory /Users/shinyorke_mbp/PycharmProjects/hatteberg/py-retrosheet/scripts/files does not exist, creating...
Time:2015-11-15 15:23:59.673 message:File Download End
Time:2015-11-15 15:23:59.673 message:Csv Parse Start(2000-2014)
Time:2015-11-15 15:26:37.219 message:Csv Parse End
Time:2015-11-15 15:26:37.220 message:Migrate Database Start(2000-2014)
Time:2015-11-15 15:32:50.070 message:Migrate Database End
データ取得からマイグレ完了まで10分を切りました!!!
まとめ
- 人のコードを鵜呑みにするな
- 一番重要な課題から説くの大切
- とはいえ「急がばまわれ」の精神も大切
これでもう少し楽しい野球Hackが出来そうです!