LaravelでCSVファイルの中身のバリデーション
※(2019/06/01追記)
サンプルの中身がログイン必要になった、テーブルの中身が少し変わったなど記事を書いた当初と変わっていますが基本的なことは変わってません!
関連
こちらもCSVに関するものなので合わせてどうぞ
概要
- 最近、CSVインポート、ダウンロードを実装する機会があった。
- CSVの中身のバリデーションは面倒だけど、ググっても中々実装例が無かった。
- なので私はこんな風に実装してみましたを投稿しました。
ソースは上記のGitHubの通りなので、正直なところ以上でおしまい!なのですが、
一応は掻い摘んでこういう感じに考えていますとかこういう考慮をしたほうが良いかもとかつたない解説をしようかなとw
こういうの書いたこと無いので読みづらいかとは思いますがご了承を…
仕様
ユーザーを一覧できるシステムに対して以下を実装します
- ユーザーが一覧できるCSVのダウンロード機能
- CSVでユーザーの一括登録、編集、削除ができるインポート機能
※実際に自分が業務で実装したものとは全然違うサンプル用の仕様なので穴だらけな気がしますがご容赦ください…
Usersテーブル
DBはMySQLを使用します。
CREATE TABLE IF NOT EXISTS `users` (
`id` int(10) unsigned NOT NULL COMMENT 'id',
`name` varchar(255) COLLATE utf8_unicode_ci NOT NULL COMMENT 'ユーザー名(半角英字のみ)',
`email` varchar(255) COLLATE utf8_unicode_ci NOT NULL COMMENT 'メールアドレス(RFC基準)',
`password` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'パスワード',
`age` int(11) DEFAULT NULL COMMENT '年齢',
`status` int(11) NOT NULL COMMENT 'ステータス(0:会員 1:仮会員 9:退会)',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
要件次第ではありますがLaravelなのでdeleted_atとか入れても良いかもしれません。
その場合statusとか持ってるとそことごっちゃになるので気をつけましょう。
CSVファイル
登録、編集、削除を1つのファイルにまとめられる系にします
type,id,name,email,password,age
"1","","test","test@example.com","password","20"
"3","6","test4","test4@example.com","password","30"
"1","","test2","test2@example.com","password","15"
"2","5","test3","test3@example.com","password","25"
type=1 :登録
type=2 :編集
type=3 :削除
って感じです。
CSVダウンロード
コントローラー
ページのダウンロードボタン押下時にpostでこのアクションを呼びます
/**
* CSVダウンロード処理
*/
public function download()
{
$path = env('DOCUMENT_ROOT').'/crud/storage/app/csv/';
$filename = 'user_'.date('YmdHis').'.csv';
if (touch($path.$filename)) {
$csv = $this->csv->getCsvAllUserList();
$stream = fopen($path.$filename, 'w');
foreach ($csv as $line) {
$output = implode(',', $line);
$output = rtrim($output, ',');
$output .= "\n";
fwrite($stream, $output);
}
fclose($stream);
} else {
// touchできなかった場合、失敗
return redirect()->route('csv.index')->withInput(['csv_errors' => ['CSVの生成に失敗しました。']]);
}
return Response::download($path.$filename, $filename, ['Content-Type' => 'text/csv'])->deleteFileAfterSend(true);
}
今回は半角英数字しか入力されないので特にエンコードを考慮していません。
全角文字や外国語は文字化けしないようにファイル形式UTF-8にすべきか、Shift-JISにするのかの考慮が必須です。
特にサービス利用者がCSVをExcelで作成するとなると大抵、作成時に自動でShift-JISになってしまうのでよく要件を練ったほうが良いです。
あとは要件によっては/storage/app/csv/下に置いたファイルをAWSのS3に移動させ、
成功したら/storage/app/csv/のファイル削除ってやっても良いと思います。
$path = Storage::disk('s3')->putFile('name', 'path', 'private');
if (Storage::disk('s3')->exists('path')) {
// /storage/app/csv/のファイル削除
} else {
// 設置失敗をログに残すなど
}
サービス
/**
* CSV全件出力用のuser一覧を取得
*/
public function getCsvAllUserList()
{
// CSVのヘッダー作成
$header = [];
foreach (config('const.CSV_HEADER_NUM') as $val) {
$header[] = $val['NAME'];
}
// DBセレクトが必要なカラム配列を作成
$select_values = $header;
unset($select_values[config('const.CSV_HEADER_NUM.TYPE.INDEX')]);
// DBからCSVへ出力する配列取得
$data = $this->user->select($select_values)->get()->toArray();
// CSV出力形式へ整形
$csv[0] = $header;
foreach ($data as $key => $val) {
foreach ($header as $header_val) {
if (config('const.CSV_HEADER_NUM.TYPE.NAME') === $header_val || config('const.CSV_HEADER_NUM.PASSWORD.NAME') === $header_val) {
// CSVのtypeはDBには存在しないので空文字で出力
// CSVにパスワードを表示させないため空文字で出力
$csv[$key + 1][$header_val] = '""';
} else {
// 他の項目はDBの値を""で囲って出力
$csv[$key + 1][$header_val] = '"'.$val[$header_val].'"';
}
}
}
return $csv;
}
constにCSVのヘッダーの並び順とテーブルのカラム名を定義しておき、それを元にselectとデータの整形をします。
カラムの中で特殊な整形が欲しい場合はforeach内の分岐を増やす、
もし仕様変更でDBにカラムが増えてもconstに入れれば勝手に取ってくれる、
的な感じです。(理想はですがw
バリデーション
CSVのバリデーション、非常に面倒だなあと思います(直球
自分は以下を考慮することにしました。
- CSVアップロード時のバリデーション
- CSVファイル1行のそれぞれの入力値のバリデーション、CSVファイル前後の行の関係でのバリデーション
CSVアップロード時のバリデーション
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'csv_file' => [
'required',
'max:1024', // php.iniのupload_max_filesizeとpost_max_sizeを考慮する必要があるので注意
'file',
'mimes:csv,txt', // mimesの都合上text/csvなのでtxtも許可が必要
'mimetypes:text/plain',
],
];
}
どれくらい一括で操作したいか?という要件をよく練りましょう。
今回、インサートに関してはSQLの発行回数を減らすようバルクインサートにしているので、
php.iniでは
- upload_max_filesize
- post_max_size
- max_execution_time
MySQLでは
- max_allowed_packet
などがCSV全ての入力値が最大かつ最大行で実行できるかどうかテストしましょう。
今回だったら仮に最大50件としているので、50件全てtypeの登録にして、全ての入力値を最大に、と言う感じです。
駄目だったらよく相談して設定ファイルの上限を上げる、一括に登録できる上限件数を減らすなどで対処しましょう。
CSVファイル1行のそれぞれの入力値のバリデーション、CSVファイル前後の行の関係でのバリデーション
ここがハイパー面倒です(
ここに関してはもっともっと上手い方法がないかと模索中…
仕様変更でDBにカラムが増えてもconstに入れる、バリデーションルールの配列に加える、バリデーションメッセージの配列に加える、で済むのが理想としています。
バリデーションの大まかな順番としては、
- CSVの件数チェック
- CSV1行のそれぞれの入力値をチェック
- CSVファイル前後の行の関係をチェック
でやっています。
今回は「CSVファイル内で同じIDに対して登録・編集・削除されると色々めんどくさそうだから、それはやめてね」ということで、
CSVファイル前後の行での重複をチェックしてたりします。
その辺も要件に寄ると思うんでよく相談しましょう。
/**
* CSVファイル内のバリデーション
*
* @param $file readしたCSVファイル
*
* @return $csv_errors エラーメッセージの配列
*/
public function validateCsvData($file)
{
// CSVファイルがヘッダー行を除いて最大行以上の場合はエラー
$file->seek($file->getSize());
$total = $file->key() - 1;
if (1 > $total) {
return ['CSVに値を入力してください'];
} elseif (config('const.CSV_IMPORT_MAX_LINE') < $total) {
return ['CSVの最大件数は'.config('const.CSV_IMPORT_MAX_LINE').'までです'];
}
// バリデーションルール生成
$rules = $this->makeCsvValidationRules();
$csv_errors = [];
$csv_id_list = [];
$csv_email_list = [];
// CSVファイル内のバリデーション
foreach ($file as $line_num => $line) {
if (0 === $line_num || 1 === count($line)) {
// 最初の行または空行など余分な空白がCSVの途中に混ざっている場合は無視
continue;
}
if (count($line) !== count(config('const.CSV_HEADER_NUM'))) {
$csv_errors = array_merge($csv_errors, ['Line '.$line_num.' カラム数が不正です']);
}
// 入力値バリデーション
$validator = Validator::make($line, $rules, $this->makeCsvValidationMessages($line_num));
if ($validator->fails()) {
$csv_errors = array_merge($csv_errors, $validator->errors()->all());
continue;
}
// Validatorのみだと補えないバリデーション
switch ($line[config('const.CSV_HEADER_NUM.TYPE.INDEX')]) {
case config('const.CSV_TYPE.REGISTER'):
// 登録の場合はパスワードの入力が必須なのでここで補完
if ('' === $line[config('const.CSV_HEADER_NUM.PASSWORD.INDEX')]) {
$csv_errors = array_merge($csv_errors, ['Line '.$line_num.' '.config('const.CSV_HEADER_NUM.PASSWORD.NAME').'は必須です']);
} elseif (!preg_match('/^[0-9a-zA-Z]+$/', $line[config('const.CSV_HEADER_NUM.PASSWORD.INDEX')])) {
$csv_errors = array_merge($csv_errors, ['Line '.$line_num.' '.config('const.CSV_HEADER_NUM.PASSWORD.NAME').'は半角英数字で入力してください']);
} elseif (8 > mb_strlen($line[config('const.CSV_HEADER_NUM.PASSWORD.INDEX')]) || 16 < mb_strlen($line[config('const.CSV_HEADER_NUM.PASSWORD.INDEX')])) {
$csv_errors = array_merge($csv_errors, ['Line '.$line_num.' '.config('const.CSV_HEADER_NUM.PASSWORD.NAME').'は8~16文字以内で入力してください']);
}
break;
case config('const.CSV_TYPE.EDIT'):
case config('const.CSV_TYPE.DELETE'):
// 編集・削除の場合はID存在チェック
if (!$this->user::where('id', $line[config('const.CSV_HEADER_NUM.ID.INDEX')])->exists()) {
$csv_errors = array_merge($csv_errors, ['Line '.$line_num.' '.config('const.CSV_HEADER_NUM.ID.NAME').'のユーザーが存在しません']);
}
break;
}
// CSV内でIDが重複していないかチェック
if ($line[config('const.CSV_HEADER_NUM.ID.INDEX')]) {
if (!isset($csv_id_list[$line[config('const.CSV_HEADER_NUM.ID.INDEX')]])) {
$csv_id_list[$line[config('const.CSV_HEADER_NUM.ID.INDEX')]] = $line[config('const.CSV_HEADER_NUM.ID.INDEX')];
} else {
$csv_errors = array_merge($csv_errors, ['Line '.$line_num.' '.config('const.CSV_HEADER_NUM.ID.NAME').'がCSV内で重複しています']);
}
}
}
return $csv_errors;
}
/**
* CSVファイル内のバリデーションルール作成
*
* @return $rules バリデーションルールの配列
*/
private function makeCsvValidationRules()
{
$rules = [];
foreach (config('const.CSV_HEADER_NUM') as $val) {
switch ($val['INDEX']) {
case config('const.CSV_HEADER_NUM.TYPE.INDEX'):
$rules[$val['INDEX']] = 'required|in:'.implode(config('const.CSV_TYPE'), ',');
break;
case config('const.CSV_HEADER_NUM.ID.INDEX'):
$rules[$val['INDEX']] = 'nullable|numeric|digits_between:0,10';
break;
case config('const.CSV_HEADER_NUM.NAME.INDEX'):
$rules[$val['INDEX']] = 'required|regex:/^[a-zA-Z]+$/|max:255';
break;
case config('const.CSV_HEADER_NUM.EMAIL.INDEX'):
$rules[$val['INDEX']] = 'nullable|email|max:255';
break;
case config('const.CSV_HEADER_NUM.PASSWORD.INDEX'):
$rules[$val['INDEX']] = 'nullable|regex:/^[0-9a-zA-Z]+$/|between:8,16';
break;
case config('const.CSV_HEADER_NUM.AGE.INDEX'):
$rules[$val['INDEX']] = 'nullable|numeric|digits_between:0,2';
break;
default:
break;
}
}
return $rules;
}
/**
* CSVファイル内のバリデーションメッセージ作成
*
* @param $line_num CSVファイルの該当行
*
* @return $messages バリデーションメッセージの配列
*/
private function makeCsvValidationMessages($line_num)
{
$messages = [];
foreach (config('const.CSV_HEADER_NUM') as $val) {
switch ($val['INDEX']) {
case config('const.CSV_HEADER_NUM.TYPE.INDEX'):
$messages[$val['INDEX'].'.required'] = 'Line '.$line_num.' '.$val['NAME'].'は必須です';
$messages[$val['INDEX'].'.in'] = 'Line '.$line_num.' '.$val['NAME'].'は:valuesの中から入力してください';
break;
case config('const.CSV_HEADER_NUM.ID.INDEX'):
$messages[$val['INDEX'].'.numeric'] = 'Line '.$line_num.' '.$val['NAME'].'は半角数字で入力してください';
$messages[$val['INDEX'].'.digits_between'] = 'Line '.$line_num.' '.$val['NAME'].'は:max桁以内で入力してください';
break;
case config('const.CSV_HEADER_NUM.NAME.INDEX'):
$messages[$val['INDEX'].'.required'] = 'Line '.$line_num.' '.$val['NAME'].'は必須です';
$messages[$val['INDEX'].'.regex'] = 'Line '.$line_num.' '.$val['NAME'].'は半角英字で入力してください';
$messages[$val['INDEX'].'.max'] = 'Line '.$line_num.' '.$val['NAME'].'は:max文字以内で入力してください';
break;
case config('const.CSV_HEADER_NUM.EMAIL.INDEX'):
$messages[$val['INDEX'].'.email'] = 'Line '.$line_num.' '.$val['NAME'].'の形式が不正です';
$messages[$val['INDEX'].'.max'] = 'Line '.$line_num.' '.$val['NAME'].'は:max文字以内で入力してください';
break;
case config('const.CSV_HEADER_NUM.PASSWORD.INDEX'):
$messages[$val['INDEX'].'.regex'] = 'Line '.$line_num.' '.$val['NAME'].'は半角英数字で入力してください';
$messages[$val['INDEX'].'.between'] = 'Line '.$line_num.' '.$val['NAME'].'は:min~:max文字以内で入力してください';
break;
case config('const.CSV_HEADER_NUM.AGE.INDEX'):
$messages[$val['INDEX'].'.numeric'] = 'Line '.$line_num.' '.$val['NAME'].'は半角数字で入力してください';
$messages[$val['INDEX'].'.digits_between'] = 'Line '.$line_num.' '.$val['NAME'].'は:max桁以内で入力してください';
break;
default:
break;
}
}
return $messages;
}
ここのバリデーションメッセージなのですが、多言語対応がある場合はこんな感じでLangのファイルを読むようにしましょう。
$messages[$val['INDEX'].'.required'] = 'Line '.$line_num.' '.$val['NAME'].Lang::get('hoge');
しかしながら、各言語ごとに語順がこの場合だとわかりづらかったり、そもそもその言語知らなかったりするので究極に面倒くさいです。
英語だけならエンジニアさんならなんとかなると思いますが、全然知らない外国語の考慮は中々工数もかかって大変でした(泣
なのでここだけは日本語か英語だけにするなど仕様を寄せるのもありよりありだと思います。
実際、CSVをインポートする人って結構限られると思うんで。
入力値の配列作成
バリデーション時に同時に登録・編集・削除用の配列を作っても良いと思いますが、
ややこしくなると思うのでここでやってます。
ここでCSV入力値以外に加えたいカラムがあれば、typeのカラムを参照したときに同時に加えてあげます。
今回であれば、
- 登録の場合はcreated_at、status:会員
- 編集の場合はupdated_at
- 削除の場合はupdated_at、status:退会
みたいな感じです。
/**
* CSVファイルの値整形
*
* @param $file readしたCSVファイル
*
* @return $records 登録・編集・削除用に整形したCSVの値の配列
*/
public function makeCsvRecords($file)
{
$now = date('Y-m-d H:i:s');
$records = [];
foreach ($file as $row => $line) {
if (0 === $row || count($line) !== count(config('const.CSV_HEADER_NUM'))) {
// 最初の行と、末尾の行や空行といった項目の合わない行は無視
continue;
}
// 決められたヘッダーの個数に沿ってインポートしたCSVを参照していく
foreach (config('const.CSV_HEADER_NUM') as $val) {
$type = $line[config('const.CSV_HEADER_NUM.TYPE.INDEX')];
if (0 === $val['INDEX']) {
if (config('const.CSV_TYPE.REGISTER') === $line[$val['INDEX']]) {
// 登録の場合
$records[$type][$row]['status'] = config('const.USER_STATUS.MEMBER');
$records[$type][$row]['created_at'] = $now;
} elseif (config('const.CSV_TYPE.EDIT') === $line[$val['INDEX']]) {
// 編集の場合
$records[$type][$row]['updated_at'] = $now;
} elseif (config('const.CSV_TYPE.DELETE') === $line[$val['INDEX']]) {
// 削除の場合
$records[$type][$row]['status'] = config('const.USER_STATUS.UNSUBSCRIBE');
$records[$type][$row]['updated_at'] = $now;
}
} else {
// 他のレコードはCSVの内容をそのまま代入
$records[$type][$row][$val['NAME']] = !empty($line[$val['INDEX']]) ? $line[$val['INDEX']] : null;
}
}
}
return $records;
}
入力値の登録
最後にコントローラーを抜粋するのも順番的にどうなのよって感じですが…w
/**
* CSVインポート処理
*
* @param CsvRequest $request
*/
public function import(CsvRequest $request)
{
$path = env('DOCUMENT_ROOT').'/crud/storage/app/';
// ファイル名を現在時刻で設定
$filename = 'csv_import_'.date('YmdHis').'.csv';
// 一時領域保存場所にCSVファイルを配置
$path .= $request->file('csv_file')->storeAs('csv', $filename);
// CSV読み込み
$file = new SplFileObject($path);
$file->setFlags(SplFileObject::READ_CSV);
// CSVの中身に対するバリデーションを実施
$csv_errors = $this->csv->validateCsvData($file);
if (count($csv_errors) >= 1) {
$file = null;
unlink($path);
return redirect()->route('csv.index')->withInput(['csv_errors' => $csv_errors]);
}
// 登録、編集、削除ごとに配列整形
$records = $this->csv->makeCsvRecords($file);
// 配列整形後は不要なのでファイル削除
$file = null;
unlink($path);
// 登録
if (isset($records[config('const.CSV_TYPE.REGISTER')])) {
$this->csv->createCsvData($records[config('const.CSV_TYPE.REGISTER')]);
}
// 編集
if (isset($records[config('const.CSV_TYPE.EDIT')])) {
foreach ($records[config('const.CSV_TYPE.EDIT')] as $update_val) {
$this->csv->updateCsvData($update_val['id'], $update_val);
}
}
// 削除
if (isset($records[config('const.CSV_TYPE.DELETE')])) {
foreach ($records[config('const.CSV_TYPE.DELETE')] as $delete_val) {
$this->csv->deleteCsvData($delete_val['id'], $delete_val);
}
}
return redirect()->route('user');
}
配列整形後のファイル削除ですが、こちらも要件によってはAWSのS3にファイルを配置してから削除、とやっても良いと思います。
また、今回は全くトランザクションを貼っておりませんが、登録・編集・削除まとめてトランザクションを貼ったり、それぞれで貼ったりしてよいと思います。
オススメは登録・編集・削除まとめてトランザクションです。
DB::beginTransaction();
try {
// 登録
if (isset($records[config('const.CSV_TYPE.REGISTER')])) {
$this->csv->createCsvData($records[config('const.CSV_TYPE.REGISTER')]);
}
// 編集
if (isset($records[config('const.CSV_TYPE.EDIT')])) {
foreach ($records[config('const.CSV_TYPE.EDIT')] as $update_val) {
$this->csv->updateCsvData($update_val['id'], $update_val);
}
}
// 削除
if (isset($records[config('const.CSV_TYPE.DELETE')])) {
foreach ($records[config('const.CSV_TYPE.DELETE')] as $delete_val) {
$this->csv->deleteCsvData($delete_val['id'], $delete_val);
}
}
DB::commit();
} cache (\Exception $e) {
DB::rollback();
// ~エラーログに出力する処理~
// ~エラーページに出力する処理~
}
まとめる理由は単純に不慮の事故が起きた場合、
「CSVファイル内でここまで成功してるからここからは遡及しないといけない」とかなると面倒だからです。
「このCSV入れたらエラーになりました」ならその間運用は止まるかもしれませんがDB遡及は免れますしおすし。
総評
CSVは面倒(