FuelPHPでCSVファイルをなるべくシンプルに扱う

[Hamee Advent Calendar 2017](https://qiita.com/advent-calendar/2017/hamee) 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

### ソースコード

#### 呼び出し側

```php5
<?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階')
//)

```

#### ヘルパーのソースコード

```php5
<?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}がありません。取り込みを中止しました。");
			}
		}
	}
}

```

#### 例外クラスも実装しておきます。

```php5
<?php
/**
 * 例外クラスは空実装でも定義しておいた方が良いです。
 * Common_CsvクラスではCommon_Csv_Exceptionしかthrowしないルールにしておけば、
 * 例外になったとき、Common_Csvクラスがthrowした例外（バリデーションエラー）なのか、
他の箇所がthrowした例外なのか、呼び出し元で識別できるようになります。
 */
class Common_Csv_Exception extends Exception {
}
```