Hamee Advent Calendar 2017 11日目の記事です!
いよいよ年の瀬も迫ってきて、仕事も忙しくなってきましたね!
ウェブアプリを作っていると何度もCSVの処理を書く仕事が発生します。
今回は、「なるべくシンプルに扱えるようにする」「どんなCSVでも読み込めるようにする」というのを目標にFuelPHPベースのCSVヘルパーを考えてみました。
try~catchを入れなければ2行でCSV読み込みができるようになっています。
お役に立てれば光栄です。
「これだと読めないCSVがあります!」とかありましたらコメントで教えていただけると嬉しいです。
(\\
とか\"
とか入ってくると怪しいかも……)
参考にさせていただいた記事
https://qiita.com/mpyw/items/939964377766a54d4682
https://qiita.com/hkomo746/items/0418be9be922aecd6ec1
http://fuelphp.jp/docs/1.8/classes/upload/usage.html
http://php.net/manual/en/class.splfileobject.php
ソースコード
呼び出し側
<?php
//呼び出し側の処理
try{
$csv = new Common_Csv('file.csv', ['名前', '年齢', '住所']);
$results = $csv->toArray();
} catch (Common_Csv_Exception $e){
//適当なエラー処理をしましょう。
//今回はエラーメッセージを画面に表示するように実装してみました。
//'/'が使っているviewにSession::get_flash('error')の値を表示する処理を書く必要があります。
Session::set_flash('error' $e->getMessage());
Response::redirect('/');
}
var_dump($results);
//=> array(
// 0 => array(
// '名前' => 'Hamee太郎',
// '年齢' => '25',
// '住所' => '神奈川県小田原市栄町2-12-10 Square O2'),
// 1 => array(
// '名前' => 'Hamee花子',
// '年齢' => '20',
// '住所' => '東京都港区白金台3-19-6 白金台ビル3階')
//)
ヘルパーのソースコード
<?php
/**
* CSVのバリデーションに使用するクラスです。
* ファイル自体の検査とヘッダの検査が含まれます。
* 値の検査は各々のModelで行います。
*
* 仕様:
*
* 対応エンコーディング : ASCII,JIS,UTF-8,CP51932,SJIS-win
* 対応改行コード : CR,LF,CRLF
* ダブルクォート : あってもなくても可。部分的にあるのも可。レコード内で改行があるときは必須。
* 空白行 : 読み飛ばす
* カラム順序 : 任意
*
* ただし、toArray()で出力される文字列は、必ずUTF-8となります。
*/
class Common_Csv {
private $file;
private $valid_headers = [];
private $ext_whitelist = ['csv'];
private $type_whitelist = ['text']; // text/comma-separated-valuesとか /text/csv とかも仕様上あるので、いけそうなものですが、取れたり取れなかったり不安定です。text/plain等になってしまったりします。
private $mime_whitelist = [];
private $max_size_byte = 1048576;
/**
* @param $file_path ファイルパス
* @param $valid_headers 有効なヘッダのリスト
* @throws Common_Csv_Exception
*/
public function __construct($file_path, array $valid_headers){
$this->validateFile();
setlocale(LC_ALL, 'ja_JP.UTF-8');
$content = file_get_contents($file_path); //OPTIMIZE: 一旦ファイルを読み込み、変換して再度ファイルに書きだしているので、CSVのサイズが大きくなるとI/O処理でボトルネックになるかもしれない。
try{
$content = mb_convert_encoding($content, 'UTF-8', 'ASCII,JIS,UTF-8,CP51932,SJIS-win');
} catch (\Fuel\Core\PhpErrorException $e){
if($e->getCode() === 2){
//UTF-16, UTF-32等の場合ここに入ります。
throw new Common_Csv_Exception('このエンコードには対応していません。');
}
}
$content = self::convertEOL($content);
file_put_contents($file_path, $content);
$this->file = new SplFileObject($file_path);
$this->file->setFlags(
SplFileObject::DROP_NEW_LINE |
SplFileObject::READ_AHEAD |
SplFileObject::SKIP_EMPTY |
SplFileObject::READ_CSV);
$this->valid_headers = $valid_headers;
}
/**
* 指定されたパスに存在するCSVファイルを配列にする。(入力が何であっても必ずUTF-8になる)
* @return array 配列
* @throws Common_Csv_Exception
*/
public function toArray(){
$rows = [];
$line_number = 1;
foreach ($this->file as $line){
//CSVの内容を変数に出力
//項目数の確認
if(isset($rows[0]) && count($rows[0]) > count($line)){
throw new Common_Csv_Exception("{$line_number}行目の項目数が不足しているため、取り込みを中止しました。");
}
if(isset($rows[0]) && count($rows[0]) < count($line)){
throw new Common_Csv_Exception("{$line_number}行目の項目数が多いため、取り込みを中止しました。");
}
$rows[] = $line;
$line_number ++;
}
$this->validateHeaders($rows[0]);
return $this->setHeaders($rows);
}
///////////////ファイル自体の検査向けのメソッド///////////////
/**
* ファイルタイプ、拡張子、サイズを確認し、許容されていないものであればエラーを出力する
* スーパーグローバル変数$_FILESを内部的に使っています。
*/
private function validateFile(){
//FuelPHPのUploadクラスによるバリデーション。
//mimetypeは偽装できてしまうのであくまで補助的なもの。
$upload_config = array(
'ext_whitelist' => $this->ext_whitelist,
'type_whitelist' => $this->type_whitelist,
'mime_whitelist' => $this->mime_whitelist,
'max_size' => $this->max_size_byte,
);
Upload::process($upload_config);
if (!Upload::is_valid()){
$this->get_error_types();
}
}
// *
// * エラーメッセージを取得する
// * スーパーグローバル変数$_FILESを使います。
private function get_error_types(){
foreach(Upload::get_errors()[0]['errors'] as $error){
switch ($error['error']){
case Upload::UPLOAD_ERR_INI_SIZE:
case Upload::UPLOAD_ERR_FORM_SIZE:
case Upload::UPLOAD_ERR_MAX_SIZE:
throw new Common_Csv_Exception($this->max_size_byte / 1024 / 1024 . 'MB以内のファイルを登録してください。');
case Upload::UPLOAD_ERR_EXT_NOT_WHITELISTED:
case Upload::UPLOAD_ERR_TYPE_NOT_WHITELISTED:
throw new Common_Csv_Exception('このファイル形式はサポートされていません。');
case UPLOAD_ERR_NO_FILE:
throw new Common_Csv_Exception('ファイルがありません。');
default:
throw new Common_Csv_Exception('アップロードに失敗しました。ファイルの内容をご確認の上、もう一度アップロードしてください。');
}
}
}
///////////////ヘッダ検査向けのメソッド///////////////
/**
* 二重添字配列の先頭要素をヘッダとみなし、そのヘッダに対応したキー名をつけた連想配列を返す(定義ファイルのvalue値をkeyに変換する)
* @param array $rows self::getRowsFromCsv()の戻り値
* @return array キー名を付与した二重配列
*/
private function setHeaders(array $rows){
$headers = array_shift($rows);
$ret = [];
foreach($rows as $rows_key => $row){
foreach($row as $key => $value){
if(!in_array($headers[$key], $this->valid_headers, true)){
continue;
}
$ret[$rows_key][$headers[$key]] = $value;
}
}
return $ret;
}
/**
* ヘッダ名の重複、不足を検出する。(多い場合は無視)
* @param array $headers(array('key' => string 'value')) バリデーションを行う配列
*/
private function validateHeaders(array $headers){
if(array_unique($headers) !== $headers){
throw new Common_Csv_Exception('ヘッダが重複しているため、取り込みを中止しました。');
}
foreach($this->valid_headers as $valid_header){
if(!in_array($valid_header, $headers, true)){
throw new Common_Csv_Exception("{$valid_header}がありません。取り込みを中止しました。");
}
}
}
}
例外クラスも実装しておきます。
<?php
/**
* 例外クラスは空実装でも定義しておいた方が良いです。
* Common_CsvクラスではCommon_Csv_Exceptionしかthrowしないルールにしておけば、
* 例外になったとき、Common_Csvクラスがthrowした例外(バリデーションエラー)なのか、
他の箇所がthrowした例外なのか、呼び出し元で識別できるようになります。
*/
class Common_Csv_Exception extends Exception {
}