※ 2018/11/16 Laravel5.6 から 5.7 に記述を更新
Laravelのユーザテーブル情報をVueの画面からダウンロードできるようになったので
ダウンロードしたCSVファイルを更新してアップロード(インポート)もできるようにしときます
アップロードは別コンポーネントに切り出して、アップロード結果も別コンポーネントでダイアログ表示するようにしときます
環境設定他関連記事はこちら
Laravel + Vue + Vuetify で業務サイト作ってみる
https://qiita.com/nobu-maple/items/d1e7170d62ab07890a7a
CSVファイルの取り込みには
github: goodby/csv を使わせてもらいます
詳しい使い方は以下を参考に(皆様に多謝)
【Laravel】Goodby CSVライブラリを使ってCSVファイルを読み込む
Qiita: 【PHP】Goodby CSVを使って高メモリ効率でCSVをパースする
#1.goodby/csvのインストール
composer require goodby/csv
composer update
composer dump-autoload
インストール後
{
"name": "laravel/laravel",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"license": "MIT",
"type": "project",
"require": {
"php": "^7.1.3",
"fideloper/proxy": "^4.0",
★ "goodby/csv": "^1.3",
"laravel/framework": "5.7.*",
"laravel/tinker": "^1.0"
},
"require-dev": {
"beyondcode/laravel-dump-server": "^1.0",
"filp/whoops": "^2.0",
"fzaninotto/faker": "^1.4",
"mockery/mockery": "^1.0",
"nunomaduro/collision": "^2.0",
"phpunit/phpunit": "^7.0"
},
~~~~
1.3 が入りました
#2.goodby/csvを使ってCSVを読み込み
サーバのLaravel側にCSV読み込み処理を追加します
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Response;
use Goodby\CSV\Import\Standard\Lexer;
use Goodby\CSV\Import\Standard\Interpreter;
use Goodby\CSV\Import\Standard\LexerConfig;
class Csv
{
/**
* CSVダウンロード
* @param array $csv_data
* @param array $csv_header
* @param string $csv_filename
* @return \Illuminate\Http\Response
*/
public function download($csv_data, $csv_header, $csv_filename)
{
// ヘッダー指定あれば1行目にヘッダーをセット
if (count($csv_header) > 0) {
array_unshift($csv_data, $csv_header);
}
// ストリームでレスポンス ::
// vendor/laravel/framework/src/Illuminate/Routing/ResponseFactory.php
// streamDownload($callback, $name = null, array $headers = [], $disposition = 'attachment')
return response() -> streamDownload(
function () use($csv_data) {
$file = new \SplFileObject('php://output', 'w');
foreach ($csv_data as $row) {
$file->fputcsv($row);
}
},
$csv_filename,
array('Content-Type' => 'application/octet-stream')
);
}
/**
* CSVアップロード(CSV 取り込み)
* @param file $file
* @param array $header
* @return rows
**/
public function parse($file)
{
// Goodby CSVのconfig設定
$config = new LexerConfig();
$interpreter = new Interpreter();
$lexer = new Lexer($config);
// CharsetをUTF-8に変換
$config->setToCharset("UTF-8");
$config->setFromCharset("sjis-win");
// CSVデータをパース
$rows = array();
try {
$interpreter->addObserver(function(array $row) use (&$rows) {
$rows[] = $row;
});
$lexer->parse($file, $interpreter);
} catch (\Exception $e) {
throw $e;
}
// 1行ずつ処理
$data = array();
foreach ($rows as $key => $value) {
// 最初の行はヘッダー
if($key == 0) {
$header = $value;
continue;
}
// 配列化 - 2行目以降はヘッダーに沿って配列に
foreach ($value as $k => $v) {
$data[$key][$header[$k]] = $v;
}
}
// CSVを配列で戻す
return $data;
}
}
CSVの1行目はヘッダーであること
1行目のヘッダーによって配列を組み立ててます
変なファイルをアップされたらどうなることやら・・
文字コード変更も SJIS -> UTF8 を入れちゃってるけど、macとかだとこの処理で文字壊れそうな気が。。
ちゃんと文字コード判定をしたほうがよさげだけど。。そのうち対応する。。かも?
#3.読み込んだCSVでユーザテーブルを更新
CSVファイルのアップロード確認を外だししてます(Laravelステキ)
キーにしている loginid がユーザテーブルになければユーザを追加
ユーザテーブルにすでに存在すればデータの更新を行います
データにエラーがあっても処理を継続して最後にまとめてエラー情報を戻すようにしときます
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use App\User;
use Validator;
use App\Facades\Csv;
use App\Http\Requests\UploadCsvFile;
class UserController extends Controller
{
public function index()
{
~~省略~~
}
public function download(Request $request)
{
~~省略~~
}
//public function upload(Request $request)
★ public function upload(UploadCsvFile $request)
{
Log::Debug(__CLASS__.':'.__FUNCTION__, $request->all());
// 拡張子チェックがうまく動かないことがあるので独自で実施
// -- https://api.symfony.com/3.0/Symfony/Component/HttpFoundation/File/UploadedFile.html
$file = $request -> file('csvfile');
if ($file ->getClientOriginalExtension() != 'csv') {
Log::Debug(__CLASS__.':'.__FUNCTION__.' File Name: '. $file ->getClientOriginalName());
Log::Debug(__CLASS__.':'.__FUNCTION__.' File Extension: '. $file ->getClientOriginalExtension());
Log::Debug(__CLASS__.':'.__FUNCTION__.' ClientMimeType: '. $file ->getClientMimeType());
Log::Debug(__CLASS__.':'.__FUNCTION__.' MimeType: '. $file ->getMimeType());
return new JsonResponse(['errors' => [ 'csvfile' => 'CSVファイルを指定してください']], 422);
}
// CSV をパース
try {
$rows = Csv::parse($file);
} catch (\Exception $e) {
Log::Debug(__CLASS__.':'.__FUNCTION__.' parse Exception : '. $e -> getMessage());
return new JsonResponse(['errors' => [
'csvfile' => 'CSVファイルの読み込みでエラーが発生しました',
'exception' => $e -> getMessage()
]]
, 422);
}
// 1行ずつ処理
$ret = array();
foreach ($rows as $line => $value) {
// 行データに対してのバリデート(必須・内容の確認)
$validator = $this->validator($value);
// データに問題があればエラーを記録 => 処理は継続
if ($validator -> fails()) {
foreach ($validator -> errors() -> all() as $message) {
Log::Debug(__CLASS__.':'.__FUNCTION__.' ERROR line('.$line.') '.$message);
$ret['errors'][] = ['line' => $line, 'message' => $message];
}
continue;
}
// CSVに問題がなければ 更新 or 挿入
$user = User::where('loginid', $value['loginid'])->first();
// 存在したら、更新
if ($user) {
Log::Debug(__CLASS__.':'.__FUNCTION__.' UPDATE line('.$line.') '.$value['name']);
$user->fill($value)->save();
$ret['update'][] = ['line' => $line, 'message' => $value['name']];
}
// DB未登録なら新規登録
else {
Log::Debug(__CLASS__.':'.__FUNCTION__.' INSERT line('.$line.') '.$value['name']);
$value['password'] = Hash::make($value['loginid']); // とりあえず初期パスワードは loginID にしとく
User::create($value);
$ret['insert'][] = ['line' => $line, 'message' => $value['name']];
}
} // 1行ずつ処理
// 結果を戻す
return ['import' => $ret];
}
public function store(Request $request)
{
~~省略~~
}
public function destroy(Request $request)
{
~~省略~~
}
private function storeUser(array $data)
{
~~省略~~
}
private function validator(array $data)
{
Log::Debug(__CLASS__.':'.__FUNCTION__, $data);
// ログインIDに許可する 「 記号 」
// ,-.@_ |
$ID_KIGO = ',-.@_\\|';
// パスワードに許可する「 記号 」
// !"#$%& '()*+,-. /:;<=>?@ \[ ]^_`{ |}~
$KIGO = '!"#$%&\'()*+,-.\/:;<=>?@\\\\[\\]^_`{\\|}~';
// 入力項目チェック(必須やら文字数やら)
$validator = Validator::make($data, [
// 氏名:
'name' => [
'required', // 必須
'min:2', // 最低2文字 (中国人でも2文字はあるよね)
'max:64', // 最大64文字 (アルファベットの名前だと足りないか?)
],
// ログインID:
'loginid' => [
'required', // 必須
'min:6', // 最低6文字(a@a.aa の形でも最低6文字は必要だよね)
'max:128', // 最長128文字(なんとなく)
'unique:users,loginid,'.$data['loginid'].',loginid', // 同じログインIDは登録不可
'regex:/[a-zA-Z\d'.$ID_KIGO.']+\z/',
// 英小文字(a-z) or 英大文字(A-Z)、数字(\d)、記号($ID_KIGO)以外の文字はエラー
],
// パスワード:
'pass' => [
'nullable', // 空でもOK
'min:4', // 最低4文字
'max:128', // 最長128文字(なんとなく)
'regex:/\A(?=.*?[a-zA-Z])(?=.*?\d)(?=.*?['.$KIGO.'])[a-zA-Z\d'.$KIGO.']+\z/',
// 必ず英小文字(a-z)or英大文字(A-Z)、数字(\d)、記号($KIGO)を1文字含む(\A)こと
],
// 権限:
'role' => [
'nullable', // 空でもOK
'numeric', // 数値であること
Rule::in([5, 10]), // role値は 5 か 10 であること
],
]);
// チェック結果を戻す
return $validator;
}
}
★の部分
function upload(Request $request)
↓
function upload(UploadCsvFile $request)
と、Request を UploadCsvFile に書き換えておくと、
チェック処理をUploadCsvFileが自動的に行って
チェック結果OKのものだけが UserController@upload にわたる仕組み
Laravel ステキ
~~省略~~ のところは github のソースを参照のこと
ルートも追加
~~
Route::post('/api/admin/user/destroy', 'UserController@destroy')->name('admin/user/destroy');
Route::post('/api/admin/user/download', 'UserController@download')->name('admin/user/download');
★ Route::post('/api/admin/user/upload', 'UserController@upload')->name('admin/user/upload');
});
~~
#4.アップロードファイルのバリデーション
CSVファイルのアップロード時に自動的にチェックして、エラーなら自動的に処理をブラウザに戻します
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadCsvFile extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'csvfile' => [
'required', // 必須
'file', // ファイルであること
'mimetypes:text/plain', // ファイルタイプは text であること
],
];
}
}
バリデーションメッセージの追加
チェックエラー時のメッセージを日本語で追加しときます
~~~省略~~~
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
'pass' => [
'regex' => '必ず英文字と数字、記号を1文字含むこと',
],
'role' => [
'in' => ':attributeは 5 か 10 を指定してください',
],
★ 'csvfile' => [
'required' => 'ファイルを選択してください。',
'file' => 'ファイルアップロードに失敗しました。',
'mimetypes' => 'ファイル形式が不正です(CSVファイルを選択してください)',
],
],
~~~省略~~~
#5.Vueのファイルアップロードコンポーネント
ファイルのダウンロードと同じく、アップロードもコンポーネントにして処理を閉じ込めときます
ちょっと長いのでまずは HTML 部分をば
<template>
<v-btn block flat
:color="color ? color : 'primary'"
:loading="csvuploading"
:disabled="csvuploading"
@click="$refs.input_csvup.click()"
>
<v-icon dark class="mr-1">{{icon ? icon : 'cloud_upload' }}</v-icon> {{title ? title : 'CSV アップロード'}}
<v-progress-circular slot="csvuploading" indeterminate color="primary" dark></v-progress-circular>
<input
name="file"
:value="csvupfile"
type="file"
style="display: none"
ref="input_csvup"
accept=".csv,.txt"
:multiple="multiple"
@change="onFilePicked"
>
<upload-dialog ref="UploadDialog"></upload-dialog>
</v-btn>
</template>
ファイルの選択時に自動的にアップロード処理を実行させるためちょっとトリッキー?なこともしてます
検証結果ではうまく動いているけど、環境によってはアップロードできないこともあるかも。。
- アップロードボタンを押すと "<input type=file" を実行してファイル選択ダイアログを開く
- ファイル選択ダイアログで何らかの動き(change)があると関数(onFilePicked)を呼ぶ
次に Javascript部分
<script>
import upload_dialog from './CsvUploadDialog.vue'
export default {
name: 'CSVUpload',
components: {
'upload-dialog': upload_dialog,
},
props: {
color: String,
icon: String,
title: String,
url: String,
multiple: String,
updata: Object,
},
data: () => ({
csvuploading: false,
csvupfile: null,
}),
created() {
if (process.env.MIX_DEBUG) console.log('CSV Upload Btn created.')
},
methods: {
// アップロードボタンを押すと <input type=file> を実行してファイル選択ダイアログを開く
// ファイル選択ダイアログで何らかの動き(change)がるとこの関数(onFilePicked)が呼ばれる
// 複数ファイル選択の指示(multple)があった場合に備えてファイル1つずつを非同期で処理しとく
async onFilePicked(e) {
if (process.env.MIX_DEBUG) console.log('CSV Upload onFilePicked')
const files = e.target.files
if(files[0] == undefined) return // ファイル未選択は何もしない
// ファイルを1つずつ送信 - 非同期処理
for (var i=0 ; i<files.length ; i++) {
if (process.env.MIX_DEBUG) console.log("FILE:" + files[i].name + " (" + files[i].size + " byte)")
await this.csvupload(files[i])
}
},
csvupload(file) {
return new Promise((resolve, reject) => {
if (process.env.MIX_DEBUG) console.log('CSV Upload csv upload')
var config = {
headers: {'Content-Type': 'multipart/form-data'}
}
// 送信ファイル設定
var formData = new FormData()
formData.append('csvfile', file)
// 追加送信パラメータ設定(指定があれば)
if (this.updata) {
formData.append(this.updata.key, this.updata.value)
}
// アップロード実行
this.csvuploading = true
axios.post(this.url, formData, config)
// アップロード 正常
.then( function (response) {
this.csvuploading = false
if (process.env.MIX_DEBUG) console.log("CSV Upload success")
if (process.env.MIX_DEBUG) console.log(response.data)
this.resultDialog(file, response.data.import)
}.bind(this))
// アップロード 異常
.catch(function (error) {
this.csvuploading = false
if (process.env.MIX_DEBUG) console.log("CSV Upload error")
if (process.env.MIX_DEBUG) console.log(error.response)
if (error.response && [401, 419].includes(error.response.status)) {
this.$emit('axios-logout')
}
else if (error.response && [422].includes(error.response.status)) {
if (error.response.data.errors) {
for (let key in error.response.data.errors) {
if (error.response.data.errors[key]) {
alert(file.name + ' : ' + error.response.data.errors[key])
}
}
}
}
}.bind(this))
return resolve(file)
})
},
resultDialog(file, data) {
var res = []
// ERROR
res.error = ''
if (data.errors) {
res.error = this.getResult(data.errors, 'データエラー: ', ' 箇所')
}
// UPDATE
res.result = ''
if (data.update) {
res.result = this.getResult(data.update, '更新 : ', ' レコード')
res.result += '\n'
}
// INSERT
if (data.insert) {
res.result += this.getResult(data.insert, '新規 : ', ' レコード')
}
// ダイアログを開く
this.$refs.UploadDialog.open(file, res)
// 一覧を再読み込み
this.$emit('reload')
},
getResult(data, comment1, comment2) {
var res = comment1 + data.length + comment2 + '\n'
for (var i=0; i<data.length; i++) {
res += ' ' + data[i].line + ' 行目 : ' + data[i].message + '\n'
}
return res
},
},
}
</script>
ファイルをサーバに送信して、送信結果を受け取ると内容をまとめてダイアログに表示してます
#6.Vueのファイルアップロード結果表示ダイアログコンポーネント
ファイルアップ結果を表示するダイアログです
<template>
<transition name="fade">
<v-dialog v-model="dialog" max-width="650px" persistent>
<v-card>
<v-toolbar :color="color ? color : 'primary'" dark>
<v-toolbar-title>
<v-icon class="pb-1">{{ icon ? icon : 'cloud_upload' }}</v-icon>
{{ title ? title : 'CSV アップロード'}}
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<h2 class="headline">{{filename}}</h2>
<v-divider></v-divider>
<v-alert v-model="error.length != 0" type="error" outline><pre class="error--text">{{error}}</pre></v-alert>
<v-alert v-model="result.length != 0" type="success" outline><pre>{{result}}</pre></v-alert>
</v-card-text>
<v-card-actions>
<v-btn color="gray darken-1" flat block @click.native="close">閉じる</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</transition>
</template>
<script>
export default {
name: 'CsvUploadDialog',
props: {
color: String,
icon: String,
title: String,
},
data: () => ({
dialog: false,
result: '',
error: '',
filename: '',
}),
created() {
if (process.env.MIX_DEBUG) console.log('Csv Upload Dialog created.')
},
methods: {
close() {
if (process.env.MIX_DEBUG) console.log("Csv Upload Dialog func close")
this.dialog = false
},
open(file, data) {
if (process.env.MIX_DEBUG) console.log("Csv Upload Dialog func open")
this.dialog = true
this.filename = file.name
this.result = ''
this.error = ''
if (data.result) this.result = data.result
if (data.error) this.error = data.error
},
},
}
</script>
受け取った情報をそのまま表示しているだけですね
#7.CSVアップロードボタンを組み込み
ユーザ一覧画面にアップロードボタンを組み込みます
ソースは抜粋の該当箇所のみ記載。全文は githubを参照
<template>
~~~省略~~~
<v-card-actions>
<v-btn flat block color="primary" @click="dialogOpen(null)"><v-icon>person_add</v-icon>新規追加</v-btn>
<v-spacer></v-spacer>
<csv-download url="/api/admin/user/download" color="primary" @axios-logout="$emit('axios-logout')"></csv-download>
★ <csv-upload url="/api/admin/user/upload" multiple="false" @reload="reload" @axios-logout="$emit('axios-logout')"></csv-upload>
</v-card-actions>
</v-card>
</v-flex>
</template>
<script>
import user_dialog from './UserDialog.vue'
import csv_download from './CsvDownload.vue'
import csv_upload from './CsvUpload.vue'
export default {
name: 'UserComponent',
components: {
'user-dialog': user_dialog,
'csv-download': csv_download,
'csv-upload': csv_upload,
},
~~~省略~~~
</script>
#8.動作確認
用意したCSVファイル
loginid,name,role
takahashi.naoki@example.org,鈴木 ヨハネ 康弘,5
yakobu@example.com,ヤコブ 治,10
p,p,1
新規登録も、更新も、エラー判定もうまく動いているようです
以上
CSVアップロードして一括更新ができました