6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

HameeAdvent Calendar 2017

Day 11

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

Last updated at Posted at 2017-12-10

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 {
}
6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?