LoginSignup
5
6

More than 5 years have passed since last update.

STEP08:Laravel5.7 + Vue2.5 でユーザ一覧をCSVアップロードして一括更新

Last updated at Posted at 2018-07-29

※ 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

インストール後

composer.json
{
    "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読み込み処理を追加します

app/Services/CSV.php
<?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 がユーザテーブルになければユーザを追加
ユーザテーブルにすでに存在すればデータの更新を行います
データにエラーがあっても処理を継続して最後にまとめてエラー情報を戻すようにしときます

app/Http/Controllers/UserController.php
<?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 のソースを参照のこと

ルートも追加

routes/web.php
~~
   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ファイルのアップロード時に自動的にチェックして、エラーなら自動的に処理をブラウザに戻します

app/Http/Requests/UploadCsvFile.php
<?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 であること
            ],
        ];
    }
}

バリデーションメッセージの追加

チェックエラー時のメッセージを日本語で追加しときます

resources/lang/ja/validation.php
~~~省略~~~
    /*
    |--------------------------------------------------------------------------
    | 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 部分をば

resources/js/components/Admin/CsvUpload.vue
<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>

ファイルの選択時に自動的にアップロード処理を実行させるためちょっとトリッキー?なこともしてます
検証結果ではうまく動いているけど、環境によってはアップロードできないこともあるかも。。

  1. アップロードボタンを押すと "<input type=file" を実行してファイル選択ダイアログを開く
  2. ファイル選択ダイアログで何らかの動き(change)があると関数(onFilePicked)を呼ぶ

次に Javascript部分

resources/js/components/Admin/CsvUpload.vue
<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のファイルアップロード結果表示ダイアログコンポーネント

ファイルアップ結果を表示するダイアログです

resources/js/components/Admin/CsvUploadDialog.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を参照

resources/js/components/Admin/UserComponent.vue
<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

アップロードボタン

z01.png

アップロード実行

z02.png

新規登録も、更新も、エラー判定もうまく動いているようです

以上
CSVアップロードして一括更新ができました

ソースはこちら
https://github.com/u9m31/u9m31/tree/step08

5
6
13

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
5
6