Google Drive のファイルを、Laravel-adminからダウンロードする方法

## 対象とする読者
- Laravel と Laravel-admin の基本的な仕組みを理解している人。
- Laravel-admin から Google Drive にアクセスしたいけど、どうすれば良いかよく分からない人。
（Laravel からアクセスしたい人にも参考になるはず）

## 何をしたいか

まずは「どんな機能を作りたいか」を簡単な図にしてみる。
![[要求ざっくり]Laravel-adminからGoogleドライブのファイルを取得.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1811865/993e49f1-20df-495a-ced4-c468e8f8c50c.png)

1. ユーザが、Laravel-admin画面でリンク（もしくはボタン）をクリック。（図①）
1. LaravelコントローラからGoogle Drive にアクセスし、任意のファイルを取得する。（図②③）
1. 取得したファイルをブラウザでダウンロードさせる。（図④）

## なぜ、こんなことをしたいのか

Google Drive でフォルダやファイルを共有する際に、「ユーザとフォルダ」を「多：多」で設定したいことがある。
![[問題点]Laravel-adminからGoogleドライブのファイルを取得.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1811865/2eafac5f-962b-2ac3-60c6-c8af501e0057.png)
この図の共有環境を全て手作業で Google Drive に設定・管理する手間を想像してもらえれば、その悲惨さを分かってもらえると思う。さらに数が増えれば共有ミスのリスクも増える。

この問題点を、Laravel-admin を経由することで解決しようというのが今回の目的。Laravel-admin のユーザ認証機能は便利で、ユーザのロール（役割や所属）でもアクセス制御できる。なので「この組織のユーザは全員まとめて共有」みたいな設定もできる。

## 設計に落とし込む
さてここからが本題。前述した機能を設計に落とし込む。今回は次の3つの設計図を作る。

- Laravel-admin の操作画面
- データフロー図
- ファイル関連図

### Laravel-admin の操作画面
**＜管理者用画面＞**
![[画面]管理者用.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1811865/4ebce5bf-56b8-9432-80df-30422236aa5f.png)
管理者用の画面では、データベースに「共有ユーザ」と「フォルダID」を表示＆登録できるようにする。そして、そのフォルダ配下のファイルがクリック可能なリストを表示させる。

**＜ユーザ用画面＞**
![[画面]ユーザA用.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1811865/cb5ef1ef-c8b6-0de1-17b2-504dc8f72913.png)
ユーザ用の画面では、管理者に共有が許可されたレコードのみを表示。カラムはファイルリストのみを表示。上図はユーザAが見る画面の例なので、共有されていないID:2のレコードは見れない。

### データフロー図（アクティビティ図）

「ユーザがクリック」〜「ファイルがダウンロードされる」までの処理をデータフロー図（アクティビティ図）にするとこうなる。
![[DFD1-1]Laravel-adminからGoogleドライブのファイルを取得.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1811865/010eda2b-1f96-54ae-0435-08c99ad68c80.png)
いきなりこんなでかい図を見せられて困惑している人もいると思うので、簡単に解説する。

- 左上の●が開始で、左下の◉が終了。
- 基本的には「データ（青□）」と「処理（黄□）」が交互に繋がっている。これは「どんなデータを入力して、どんな処理をして、どんなデータを出力するか」を表してる。
- あとは「前段の出力データが、次の処理の入力データ」となり繰り返しているだけ。
- 黒いバー（━）は分岐と合流を表している。
  - 分岐：全ての分岐先に並列に遷移している点に注意（条件分岐ではない）。
  - 合流：全てのフローが到達しないと次に進めない。
- 本図は「アクティビティ図を使ってデータフロー図を表している」ので、UMLで定義してるデータフロー図とは違うので注意。
- 本図は厳密にはUML2.0に準拠してないので注意（厳密さより分かりやすさを重視）。

データフロー図の右上にも示したが、外部からGoogle Drive にアクセスするには、下記３点の事前準備が必要になる。

- Google Cloud Platform にサービスアカウントを作っておく。
- サービスアカウントから秘密鍵を作成し、LaravelのStorageに格納しておく。
- Google Driveのフォルダの共有アカウントに、サービスアカウントのメアドを登録しておく。

↓事前準備の詳しい手順は、こちらの記事が分かりやすい。

https://qiita.com/kazama1209/items/94db0ff9d10df249bf14

### ファイル関連図

「Grid表示」〜「ファイルがダウンロードされる」までの処理をファイル関連図に落とし込むとこうなる。
![[FCD1-1]Laravel-adminからGoogleドライブのファイルを取得.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1811865/36828f7e-c638-fe70-e478-f3b6369d8aa5.png)

「ファイル関連図」というものは一般的に定義されてはいない。UMLで言えばコンポジット構造図やコンポーネント図に該当するものを、UMLが読めない人にも分かりやすいように私が単純化したもの。

この図の目的は、「どのメソッドとデータをどのファイルに実装し、どのファイル（のメソッドやデータ）にアクセスするか？」を表現＆共有すること。なので、本来はファイルの中にメソッドやデータも書くべきだけど、ごちゃごちゃして見にくかったので今回は省略した。

前述のデータフロー図と見比べてもらえば理解しやすいと思う。

できれば vendor 配下を変更したくなかったが、Laravel-admin の Colum Action の機能ではファイルをブラウザにダウンロードさせるスクリプトを組み込めず、諦めて手を加えることにした。

↓【参考】Laravel-admin公式ドキュメント｜Colum Display｜Colum Action

https://laravel-admin.org/docs/en/model-grid-column-display#Column%20action

## 実装

実装コードを、ファイル関連図のフロー順に書き出していく。前述の設計図と見比べながら読んでもらえると、理解しやすいと思う。
（ちなみに、私のコードは不要なelse文があって読みにくいかもしれないけど悪しからず。これは意図しないバグを予防するための１つの手法なので。）

### Gridを表示するコントローラ (DemoController.php)

```php:app/Admin/Controllers/DemoController.php
class DemoController extends AdminController
{
    protected function grid()
    {
        $grid = new Grid(new Demo());
        $grid->column('id', __('ID'))->sortable();
        $grid->column('name', __('案件'))->sortable();
        // Google Drive ファイルを表示するカラム
        $grid->column('google_drive', __('Google Drive ファイル'))
            ->googledownloadable(); // 新しくlaravel-adminのカラム拡張表示に追加したメソッドをコール
        return $grid;
    }
}
```
grid メソッド内の Google Drive のフォルダIDを表示するカラムの処理で、今回追加したカラム拡張表示メソッドをコールする。
（このファイルの基本実装は、Laravel-admin の標準的なコントローラの実装と同じ。なので、本記事の主機能と関係ないコードは省略している。）

### Laravel-adminのカラム拡張表示機能 (ExtendDisplay.php)

```php:vendor/encore/laravel-admin/src/Grid/Column/ExtendDisplay.php
trait ExtendDisplay
{
    public static $displayers = [
        'editable'      => Displayers\Editable::class,
        'image'         => Displayers\Image::class,
        'label'         => Displayers\Label::class,
        'button'        => Displayers\Button::class,
        'link'          => Displayers\Link::class,
        'badge'         => Displayers\Badge::class,
        'progressBar'   => Displayers\ProgressBar::class,
        'progress'      => Displayers\ProgressBar::class,
        'orderable'     => Displayers\Orderable::class,
        'table'         => Displayers\Table::class,
        'expand'        => Displayers\Expand::class,
        'modal'         => Displayers\Modal::class,
        'carousel'      => Displayers\Carousel::class,
        'downloadable'  => Displayers\Downloadable::class,
        'copyable'      => Displayers\Copyable::class,
        'qrcode'        => Displayers\QRCode::class,
        'prefix'        => Displayers\Prefix::class,
        'suffix'        => Displayers\Suffix::class,
        'secret'        => Displayers\Secret::class,
        'limit'         => Displayers\Limit::class,
        'googledownloadable'  => Displayers\GoogleDownloadable::class,  // このコードを追加
    ];
}
```
このファイルは各拡張表示機能のハブになっているっぽい。今回追加した表示機能のファイルパスを追加するのみ。

### Googleファイルダウンロード用のカラム表示機能 (GoogleDownloadable.php)

```php:vendor/encore/laravel-admin/src/Grid/Displayers/GoogleDownloadable.php
<?php

namespace Encore\Admin\Grid\Displayers;

use Encore\Admin\Facades\Admin;

/**
 * Class GoogleDownloadable.
 */
class GoogleDownloadable extends AbstractDisplayer
{
    /******
     * Script
     */
    protected function addScript()
    {
      $script = <<<SCRIPT
$('#{$this->grid->tableID}').on('click','.grid-column-google-drive-downloadable',(function (event) {
    // クリックされた要素を無効化 (連続クリック防止のため)
    event.target.classList.add('disabled');
    // クリックされた要素の中身を一時退避 (最後に元に戻すため)
    let inner_html_back = event.target.innerHTML;
    // クリックされた要素のテキストを「処理中」に設定
    event.target.textContent = "ダウンロード処理中...";

    // クリックされた要素のGoogle_File_IDを取得し、JSON形式に変換
    const tx_data = JSON.stringify ({
      google_file_id: event.target.nextElementSibling.value
    });

    //--- XMLHttpRequest による非同期通信の準備 ---
    // 非同期通信URL
    let url = "/download"
    // XMLHttpRequest オブジェクト作成
    let xhr = new XMLHttpRequest();
    // リクエストを初期化
    xhr.open('POST', url, true);
    // CSRFトークンを設定
    xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content'));
    // レスポンスタイプは'blob'に設定 (ファイルデータを受け取るため)
    xhr.responseType = 'blob';
    // 送信データのヘッダを設定 (JSONデータ)
    xhr.setRequestHeader("Content-Type", "application/json");

    // xhr通信成功時の処理を定義
    xhr.onload = function (event) {
      // 応答データからコンテンツタイプを取得
      let content_type = xhr.getResponseHeader('Content-Type');

      // コンテンツタイプによって処理を分岐
      switch (content_type) {
        // [正常]ダウンロード対象のファイル形式
        case "image/png":
        case "image/jpg":
        case "application/pdf":
          // 応答データから blob オブジェクト作成
          const blob = new Blob([xhr.response], {type: xhr.response.type});

          // レスポンスヘッダからファイル名を取得する
          const disposition = xhr.getResponseHeader('Content-Disposition');

          // ヘッダー情報の有無で処理を分岐
          if (disposition && (disposition.indexOf('attachment') !== -1)) { // ヘッダーに'attachment'情報がある場合
              // ファイル名を取得するための正規表現を作成
              let filename_regex = /filename=".*"/;
              // 正規表現で検索実行
              let matches = filename_regex.exec(disposition);
              // ファイル名の有無で分岐
              if ((matches != null) && matches[0]) { // [正常]ファイル名があった場合
                // 不要文字列を消して、デコードしてサーバからのファイル名を取得
                let file_name_decode = decodeURI(matches[0].replace(/['"]/g, '').replace('filename=', ''));
                // blobオブジェクトのURLを作成
                let download_url = window.URL.createObjectURL(blob);
                // ダウンロードする'a'タグを作成
                let link = document.createElement('a');
                link.href = download_url;
                link.download = file_name_decode;
                // 'a'タグをクリックしてダウンロード実行
                link.click();
                // blobオブジェクトのURLを削除
                window.URL.revokeObjectURL(download_url);
              } else { // [エラー]ファイル名がない場合
                // [NOP]不正データと判断してダウンロードしない。
              }
          } else { // ヘッダーに'attachment'情報がない場合
            // [NOP]不正データと判断してダウンロードしない。
          } // if-else ヘッダー判定
          break;

        // [エラー]JSONデータ (エラーデータを想定)
        case "application/json":
          // Blobからデータを読み込み (JSONデータを想定)
          const reader = new FileReader()
          // 読み込みが完了時の動作を定義
          reader.onload = () => {
            // JSONデータを取り出す
            let response_json = JSON.parse(reader.result);
            // アラート表示
            alert('【エラー】\\n・' + response_json.message.error.message + '\\n・システム管理者に問い合せてください。');
          }
          // 読み込み開始
          reader.readAsText(this.response)
          break;

        // [想定外]
        default:
          // [NOP]何もしない
          break;
      }
    };  // function onload

    // xhrのエラー処理を定義
    xhr.onerror = function (e) {
      console.error(xhr.statusText);
    };

    // xhr通信状態に応じた処理
    xhr.onreadystatechange = function () {
      if (this.readyState == 4) { // リクエスト完了
        // クリックされた要素のテキストを元に戻す
        event.target.innerHTML = inner_html_back;
        // クリックされた要素の無効を解除
        event.target.classList.remove('disabled');
      } else { // その他の状態
        // [NOP]何もしない
      }
    }

    // xhrリクエスト送信
    xhr.send(tx_data);
}));
SCRIPT;

      // スクリプト実行
      Admin::script($script);
    }

    /******
     * display
     */
    public function display()
    {
      $this->addScript();
      // カラムの値 (Google Drive のファイルID)を取得
      $content = $this->getColumn()->getOriginal();
      // 選択行のIDを取得
      $key = $this->getKey();

      // 表示内容を書き込む変数
      $response_get_file_property = "";
      $view_files = []; // bladeに渡すファイル情報の配列

      // データの有無で表示を切り替え
      if (is_null($content)) { // データなしの場合
        // 何も表示しない
        $html = "";
      } else { // データありの場合
        // Google認証の設定
        $client = new \Google_Client();
        $client->setApplicationName('FileDownloadSampleApp');
        $client->setScopes(['https://www.googleapis.com/auth/drive']);
        $client->setAccessType('offline');
        $client->setAuthConfigFile(config_path('client_secrets.json')); // クライアント認証JSONを設定
        // Googleドライブサービスを作成
        $service = new \Google_Service_Drive($client);

        // ファイルリスト取得用のパラメータ作成 (フォルダIDを親フォルダとする場合のファイル一覧)
        $param = "'".$content."' in parents";
        // ファイルリスト取得
        $results = $service->files->listFiles([
          "q" => $param
        ]);
        // ファイル配列のみを取り出す
        $files = $results->getFiles();

        // ファイルがあるか判定
        if (0 < count($files)) { // ファイルが1つ以上ある
          // ファイル数だけループ
          foreach ($files as $file) {
            // ファイルタイプを取得
            $mimetype = $file->getMimeType();
            // ファイルタイプで処理を分岐
            if (strpos($mimetype, 'folder')) { // フォルダの場合
              // [NOP]何もせず次のループへ
              ;
            } else { // ファイルの場合 (フォルダ以外)
              // 「ファイルID、ファイル名」の連想配列を作成
              $tmp_ary = [[
                'id' => $file->getId(),
                'name' => $file->getName(),
              ]];
              // ビュー配列に追加
              $view_files = array_merge($view_files, $tmp_ary);
            } // if-else ファイルの種類
          } // foreach

          // ファイルダウンロード用のビューを描画
          return view('admin.customs.google_drive', compact('view_files'));

        } else { // ファイルがない
          // [NOP]何も表示しない
          ;
        } // if-else ファイルの有無チェック
      } // if-else データの有無チェック

      // データがない場合は何も表示しない
      return;
    }
}
```
このファイルには、2つのメソッド「addScript ()」「display ()」を実装する。

「addScript ()」は、カラムのリンクがクリックされたときに呼び出される関数。
この中のスクリプトで「非同期POST通信でコントローラにファイル取得要求」を出し、「応答で返ってきたファイルをブラウザにダウンロード」させている。

「display ()」は、Grid画面ロード時に呼び出される関数。
Google Drive にアクセスし、フォルダIDを使って配下のファイル一覧を取得後、blade に「ファイルID、ファイル名」の配列を渡して描画させている。

### Googleファイルダウンロード用のカラムの描画コード (Google_drive.blade.php)

```php:resource/view/Google_drive.blade.php
<meta name="csrf-token" content="{{ csrf_token() }}">
<div>
  @foreach ($view_files as $file)
    <div>
      <a href="javascript:void(0);" class="btn btn-link btn-sm grid-column-google-drive-downloadable" title="ファイルをダウンロード" data-placement="bottom" id="">
        <i class="fa fa-download"></i>&nbsp;&nbsp;{{ $file['name'] }}
      </a>
      <input type="hidden" value="{{ $file['id'] }}">
    </div>
  @endforeach
</div>
```
前段の拡張表示ファイルから呼び出されるカラム描画用のファイル。
Gridカラムの中に表示する内容をhtmlで実装する。
aタグのhref要素にjavascriptを指定することで、前述のスクリプトがコールされる仕組みになっているっぽい。
aタグの下にinputタグでファイルIDを用意しておく（スクリプトでファイルIDを読み込むため）。

:::note warn
【セキュリティ上の注意点】
「ファイルIDをhtml上に記述すると、そのファイルIDが漏洩してしまうし、共有してない人もファイルにアクセスできちゃうのでは？」
と思った人はセキュリティのことを考えており素晴らしい。その通り。

なので、実際の運用環境では「htmlに表示するファイルIDは何かしらの暗号化をしておき、クリック後のスクリプト側で復号化する」などの、何らかのセキュリティ対策が必要になる。今回は分かりやすさ重視でファイルIDをそのまま取り扱っている。
:::

### 非同期通信の要求をルーティング (web.php)

```php:route/web.php
<?php
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::post('download', 'FileDownloadController@download'); // このコードを追加
```
このファイルはスクリプトからの非同期POST通信をコントローラにルーティングしているだけ。

### Google Drive ファイル取得コントローラ (FileDownloadController.php)

```php:app/Http/Controllers/FileDownloadController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Google\Auth\Credentials\UserRefreshCredentials; // Googleドライブアクセス

class FileDownloadController extends Controller
{
  public function download (Request $request) {
    // Google Drive のファイルIDをリクエストから取り出す
    $file_id = $request->input('google_file_id');

    // Google 認証の設定
    $client = new \Google_Client();
    $client->setApplicationName('Google Drive File Downloader'); // アプリ名はなんでも良い
    $client->setScopes(['https://www.googleapis.com/auth/drive']); // アクセス先はGoogle APIのDrive用のアドレスを設定
    $client->setAuthConfigFile(config_path('client_secrets.json')); // 秘密鍵JSONファイルのパスを設定

    // Googleドライブサービスを作成
    $service = new \Google_Service_Drive($client);

    // ファイル取得処理の成否で処理を分岐
    try { // 正常処理
        // ファイル本体をGoogleドライブから取得
        $response_file_media = $service->files->get($file_id, ['alt' => 'media']);
        // ファイルデータを取り出す
        $file_body = $response_file_media->getBody();

        // ファイル情報をGoogleドライブから取得 (ファイル名とメディアタイプ)
        $response_file_property = $service->files->get($file_id, ['fields' => 'name, mimeType']);
        // ファイル名を取り出す
        $file_name = $response_file_property->name;
        // ファイル名をエンコード (日本語の文字化け防止のため)
        $file_name_encoded = rawurlencode($file_name);
        // メディアタイプを取り出す
        $mime_type = $response_file_property->getMimeType();

        // 応答用のヘッダー設定
        $headers = [
          'Content-Type' => $mime_type,
          'Content-Disposition' => 'attachment; filename="'.$file_name_encoded.'"'
        ];

        // ファイル実体を返送する
        return response()->make($file_body, 200, $headers); 

    } catch (\Exception $exception) { // エラー処理
      // エラーメッセージを取得
      $except_msg = $exception->getMessage();
      // エラーログを残す
      \Log::error( $except_msg );
      // エラーデータをJSON文字列からオブジェクトに変換
      $except_json_msg = json_decode($except_msg, false);
      // エラーデータを返す
      return [
        'status_code' => $exception->getCode(),
        'message' => $except_json_msg,
      ];
    } // try-catch
  } // func
}
```
非同期POST通信を受けて、Google Drive からファイルを取得するコントローラのコード。
データフロー図にも書いてあるとおり、ファイル本体とファイル情報を１つのメソッドで取得できないので、個別に取得している。

↓google-api-php-client の使い方は、GitHub のドキュメントを参照（ただし説明が不十分なところも…）。

https://github.com/googleapis/google-api-php-client

↓Google Drive API のドキュメントと合わせて読むと理解しやすいかも。

https://developers.google.com/drive/api/v3/reference

## あとがき
自分用の再理解＆備忘録として書いたけど、私と同じように「Laravel-admin や Laravel から Google Drive にアクセスしたいけどどうすればいいか全然分からない…」って人の助けになれば幸い。

単純に Laravel からアクセスしたい場合は、本記事の内容をベースに「ユーザがクリックする画面」を各自の環境に合わせて作ってもらえればできるはず。

「Laravel の storage 機能で格納先を Google Drive に設定する」という方法もある。しかし、今回は「管理者側はすでに Google Drive でファイル管理しており、ユーザへの共有方法をどうにかしたい」に対する解決策なので、この記事の方法にした。

最後に、「データフロー図とかがよく分からない」って人もいると思うけど、これを機にUMLに興味を持って読み書きできる技術者が1人でも増えてくれると、個人的には嬉しい。