JavaScript
CakePHP
AWS
rest
勉強会

ダウンロード・アップロード機能の実装 - AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~【第9回】マニュアル

More than 3 years have passed since last update.

:large_blue_circle: はじめに

本投稿は、2015/9/25に行われた、ダウンロード・アップロード機能の実装 - connpassの内容についてまとめた資料です。

:warning:今後の予定は以下に掲載されますのでよろしくお願いします!
AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~ - connpass

今回は、よくある機能、ファイルのダウンロードとアップロードを実装してみます。

:large_blue_circle: なぜ、ダウンロード/アップロードを今回取り上げたのか

業務システムではよくある機能なので、実装方法を勉強しておこう、というのも目的の一つですが、Webシステムは「HTTP」の上で動いているということを改めて思い出しておこう、というのが今回の狙いの一つです。フレームワークやライブラリによって高度に抽象化された上の方のレイヤのみ扱うことの多いWebプログラミングですが、今回は"少しだけ"下のレイヤに「潜って」みましょう!
プログラムの前に、まずHTTPについて見ておきますよ!。

:large_blue_circle: ダウンロードのしくみ

まず、ダウンロード。ダウンロードというと、「サーバ上から受信したコンテンツをローカルに保存する」ことですが、「ローカルに保存する」のを実際にやっているのはブラウザです。
「サーバ上から受信したコンテンツ」はどういう構造になっているのか、まず見ておきましょう。

HTTPレスポンスの構造

「サーバ上から受信したコンテンツ」は、サーバから返ってくるHTTPレスポンスのうち、「レスポンスボディ」と呼ばれる部分に含まれています。
下記はyahooのトップページを要求した際のレスポンスですが、HTTPレスポンスの構造は、

  • 1行目: プロトコル/バージョン レスポンスコード -> HTTP/1.1 200 OK
  • 2行目以降: レスポンスヘッダ(複数行に渡る) -> Server: nginx, Date: Thu, 24 Sep 2015 09:11:53 GMT, Content-Type: text/html; charset=UTF-8...
  • 空行
  • 空行の後が全てレスポンスボディ -> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML...

となっています。

例)yahooのトップページのレスポンス
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 24 Sep 2015 09:11:53 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: close
P3P: policyref="http://privacy.yahoo.co.jp/w3c/p3p_jp.xml", CP="CAO DSP COR CUR ADM DEV TAI PSA PSD IVAi IVDi CONi TELo OTPi OUR DELi SAMi OTRi UNRi PUBi IND PHY ONL UNI PUR FIN COM NAV INT DEM CNT STA POL HEA PRE GOV"
Cache-Control: private, no-cache, no-store, must-revalidate
Expires: -1
Pragma: no-cache
X-XRDS-Location: https://open.login.yahooapis.jp/openid20/www.yahoo.co.jp/xrds
Vary: Accept-Encoding
X-Frame-Options: SAMEORIGIN

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta http-equiv="content-style-type" content="text/css">
<meta http-equiv="content-script-type" content="text/javascript">
<meta name="description" content="日本最大級のポータルサイト。検索、オークション、ニュース、メール、コミュニティ、ショッピング、など80以上のサービスを展開。あなたの生活をより豊かにする「ライフ・エンジン」を目指していきます。">
<meta name="robots" content="noodp">
<title>Yahoo! JAPAN</title>

〜以下省略〜

「ダウンロード」と行ってもあくまでHTTPレスポンスボディ部にコンテンツが埋め込まれるだけで、このフォーマットは変わりません。
ダウンロードとはつまり、「サーバから受け取ったレスポンスボディ部をブラウザがローカルファイルシステムに保存すること」と言えそうです。

ブラウザにレスポンスボディを保存させる

というわけで、「ダウンロード」する為には、ブラウザに「レスポンスボディ」を保存させる、という動作をさせなければなりません。
上のyahooのトップページの例では、レスポンスボディにHTMLが返ってきています。この時、ブラウザはこれをローカルに保存(一時ファイルは除く)はせず、ブラウザウィンドウ上に描画します。
では、ブラウザは何を元に受信したレスポンスボディを「表示する」か「保存する」かを決めるのでしょうか。
ヒントになるものは、レスポンスに関するメタ情報を持つ「レスポンスヘッダ」しかありません。
そのうちブラウザが主に判断材料とするのは、Content-TypeContent-Dispositionの2つです。

  • Content-Type
    • レスポンスボディがHTMLならtext/html、jsonならapplication/jsonなどとサーバ側で設定します。 ブラウザは、自分が知っているContent-Typeであれば、そのコンテンツに対する既定の動作をします。そのブラウザが「表示」が既定であれば表示し、「保存」が既定であれば保存します。
  • Content-Disposition
    • attachmentまたは inlineをサーバ側で設定します。保存させたい場合はattachment, 表示させたい場合は inline、とサーバ側で設定してブラウザにその動作を要求するわけです。ブラウザのデフォルトの挙動はブラウザによって微妙に違いますので、Content-Typeだけ設定すれば期待する動作になる場合もありますが、Content-Dispositionも指定しておくとより確実にブラウザに「ダウンロード」させることが出来ます。

今回のワークショップでは、

  • ダウンロードするコンテンツをレスポンスボディに設定する
  • レスポンスヘッダを適切に設定する

の具体的な実装方法を見ていくことになります。

:large_blue_circle: アップロードの仕組み

次にアップロード。実際にどのようにファイルが送られるのでしょうか。
HTTPリクエストの構造を見ておきましょう。

HTTPリクエストの構造

HTTPリクエストの構造はHTTPレスポンスの構造と似ています。
送信するファイルは、送信されるリクエストのうち、「リクエストボディ」と呼ばれる部分に含まれて送られます。
今回作成するファイルアップロード機能で実際にファイルを送信した際のリクエストは下記のようになっています。
todolist1.txt, todolist2.txtの2ファイルを同時にアップロードした例です。

例)ファイルアップロードのリクエスト
POST /rest-study/todo_lists/upload.json HTTP/1.1
Host: 10.0.1.206
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:42.0) Gecko/20100101 Firefox/42.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: http://10.0.1.206/rest-study/
X-Requested-With: XMLHttpRequest
Content-Length: 779
Content-Type: multipart/form-data; boundary=---------------------------1395431092341454357747073315
Cookie: CAKEPHP=oo6gj167d4fltodm3nre2lskd2
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache

-----------------------------1395431092341454357747073315
Content-Disposition: form-data; name="0"; filename="todolist1.txt"
Content-Type: text/plain

todolist1.txtからアップロードしたTODO1
todolist1.txtからアップロードしたTODO2
todolist1.txtからアップロードしたTODO3
todolist1.txtからアップロードしたTODO4
todolist1.txtからアップロードしたTODO5


-----------------------------1395431092341454357747073315
Content-Disposition: form-data; name="1"; filename="todolist2.txt"
Content-Type: text/plain

todolist2.txtからアップロードしたTODO1
todolist2.txtからアップロードしたTODO2
todolist2.txtからアップロードしたTODO3


-----------------------------1395431092341454357747073315--

HTTPメソッドはPOSTで、/rest-study/todo_lists/upload.jsonというURLにアクセスしています。
ポイントは、

  • Content-Type: multipart/form-data; boundary=---------------------------1395431092341454357747073315
  • Content-Length: 779

の2つのHTTPリクエストヘッダです。

まず、HTTPリクエストのフォーマットを確認しておきましょう。

  • 1行目: HTTPメソッド URI プロトコル/バージョン -> POST /rest-study/todo_lists/upload.json HTTP/1.1
  • 2行目以降: リクエストヘッダ(複数行に渡る) -> Host: 10.0.1.206, User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:42.0) Gecko/20100101 Firefox/42.0...
  • 空行
  • 空行の後が全てリクエストボディ -> -----------------------------1395431092341454357747073315

となっています。

multipart/form-data

Content-Typemultipart/form-dataでない場合、例えばapplication/jsonだった場合、下記のようになります。

追加ボタンでTODOを追加した際のリクエストの例
{"todo":"TODOを追加ボタンで登録","status":0,"assignee":"67"}

multipart/form-data以外では、送信するJSONデータはこのようにフラットにリクエストボディに格納されています。

multipart/form-dataでは、リクエストボディ部が、バウンダリ文字列とよばれる、上記の例では-----------------------------1395431092341454357747073315のような文字列で囲まれています。
この形式が、リクエストヘッダContent-Typeで指定したmultipart/form-dataです。
その名の通り、リクエストボディ部がバウンダリ文字列を区切りとして複数のパートに分割されています。
そしてそれぞれのパートにリクエストヘッダとリクエストボディが含まれるというネストした構造になっています。

今回のワークショップでは、

  • Ajaxでファイルアップロード処理を行う
  • リクエストからファイルコンテンツを取り出す
  • 取り出したコンテンツからTODOを作成する

の具体的な実装方法を見ていくことになります。
ですが、「リクエストからファイルコンテンツを取り出す」部分はPHP及びCakePHPがほとんどやってくれますのでカンタンです!
「Ajaxでファイルアップロード」は頑張って実装しましょう!詳しくは後述!

:large_blue_circle: 今回の内容

完成イメージです。

feature.jpg

  • ダウンロード用リンク
    • クリックすると表示されているTODO一覧をCSV形式でダウンロードします。
  • アップロードファイル選択
    • クリックするとファイル選択ダイアログが開き、アップロードするファイルを選択(複数可)します。
  • アップロードボタン
    • クリックするとアップロードを実行します。

なお、アップロードするファイルは下記の例の通りで、一行をひとつのTODOとし、自分がオーナ、自分が担当者、未完了の状態で追加するものとします。
追加する際は、「追加ボタン」で手作業で追加する場合と同様のバリデーションを効かせます。

アップロードファイルの例(todolist1.txt)
todolist1.txtからアップロードしたTODO1
todolist1.txtからアップロードしたTODO2
todolist1.txtからアップロードしたTODO3
todolist1.txtからアップロードしたTODO4
todolist1.txtからアップロードしたTODO5

ワークショップメニュー

  • 事前準備
  • Lesson1 ダウンロード
  • Lesson2 アップロード

という感じですすめます。

:large_blue_circle: 事前準備

事前準備は毎回同じなので、別エントリにまとめています。
全12回の勉強会でやっているGitの使い方 - AWS上で構築するRESTfulアプリ勉強会~Web開発ワークショップ~ - Qiitaを参照してください。
やることは、

  • gitのブランチを整えて今回用ブランチvol/09を作成する

です。まずこれをやりましょう。

:warning: それと、第5回と第6回に不参加の方は、テーブルの修正が必要です。

  • 第5回
    • ユーザ登録用のテーブル作成(ログイン機能実装のため)
  • 第6回
    • TODO一覧テーブルへの列追加(owner列とassignee列。担当者アサイン機能実装のため)

をやってますので、それぞれ下記リンク先を参照して実施して下さい。
ユーザ登録用のテーブル作成
TODO一覧テーブルへの列追加

準備ができたら、Lesson1です!

:large_blue_circle: Lesson1 ダウンロード

  • クライアント側
    • ダウンロードリンクの追加
  • サーバ側
    • TODOをデータベースから読み込んでCSVファイルコンテンツとしてレスポンスボディに入れて返す

をそれぞれ実装します。

ダウンロードするファイル形式は下記のとおり、カンマ区切りで、

  • id
  • todo: TODOの内容
  • status: 状態
  • owner: オーナの名前
  • assignee: 担当者の名前

を出力するものとします。一行目は項目のタイトル行とします。

ダウンロードするファイル形式
id,todo,status,owner,assignee
1,牛乳を買う,1,山田太郎,山田太郎
2,家に電話,1,田中花子,山田太郎
3,犬の散歩,1,山田太郎,山田太郎

編集するファイル一覧

編集 file 編集概要
修正 app/Config/routes.php ダウンロード処理へのルーティング追加
修正 app/View/Layouts/default.ctp ダウンロードリンク追加
修正 app/Controller/TodoListsController.php ダウンロード処理追加

app/Config/routes.php

ダウンロード処理のURLを追加します。

app/Config/routes.php
〜略〜

 /*
  * API
  */

 // ログイン
 Router::connect('/users/login', array (
    'controller' => 'users',
    'action' => 'login',
    'method' => array (
        'POST'
    )
 ));

 // ログアウト
 Router::connect('/users/logout', array (
    'controller' => 'users',
    'action' => 'logout',
    'method' => 'POST'
 ));

 // ログインチェック(ログイン情報取得)
 Router::connect('/users/loggedin', array (
    'controller' => 'users',
    'action' => 'loggedIn',
    'method' => 'GET'
 ));

 // サインアップ
 Router::connect('/users/signup', array (
    'controller' => 'users',
    'action' => 'signUp',
    'method' => array (
        'POST'
    )
 ));

+// CSVファイルダウンロード
+Router::Connect('/todo_lists/download', Array(
+   'controller' => 'todo_lists',
+   'action' => 'download',
+   'method' => 'GET'
+));

 Router::mapResources(array (
    'todo_lists',
 ));

〜以下略〜

  • /todo_lists/downloadを追加します。
  • Getメソッドでアクセス時にTodoListController.phpdownloadメソッドにルーティングします。

app/View/Layouts/default.ctp

ダウンロード用リンクを追加します。

app/View/Layouts/default.ctp
〜略〜

    <!-- TODO一覧表示のテンプレート -->
    <script type="text/template" id="todo-composite-template">
        <div class="row">
            <div class="col-xs-12">
                <span class="row form-inline">
                    <div class="input-group col-sm-6 col-xs-12">
                        <label for="new-todo" class="visible-xs">Todo</label>
                        <textarea class="form-control todo-item-text" rows="3" id="new-todo" placeholder="Todo?" autofocus></textarea>
                    </div>
                    <div class="input-group col-sm-3 col-xs-12">
                        <label for="user-list" class="visible-xs">担当者</label>
                        <select class="form-control" name="assignee" id="user-list"></select>
                    </div>
                    <div class="input-group col-sm-3 col-xs-12">
                        <label class="visible-xs"></label>
                        <input type="button" id="addTodo" class="btn btn-primary btn-md todo-action-button" value="追加">
                    </div>
                </span>
            </div>
        </div>
        <div class="row">
            <div class="col-xs-12">
                <table class="table table-striped table-bordered table-hover">
                    <thead>
                        <tr class="success">
                            <th class="col-sm-6" colspan="2">ToDo</th>
                            <th class="col-sm-2">オーナ</th>
                            <th class="col-sm-2">担当</th>
                            <th class="col-sm-2" colspan="2"></th>
                        </tr>
                    </thead>
                    <tbody></tbody>
                </table>
            </div>
        </div>
+       <div class="row">
+           <div class="col-xs-6">
+               <a href="/rest-study/todo_lists/download" class="alert alert-default pull-left" id="downloadTodo">TODOリストをダウンロード</a>
+           </div>
+       </div>
    </script>

〜以下略〜

  • <a>タグの追加のみです。リンク先をapp/Config/routes.phpで追加した/rest-study/todo_lists/downloadに設定しています。

app/Controller/TodoListsController.php

ダウンロード処理を行います。

app/Controller/TodoListsController.php
〜略〜

    public function index() {
        $query = array (
            'fields' => $this->fields,
            'order' => "TodoList.id"
        );
        $res = $this->TodoList->find('all', $query);
        // 整形
        if (count($res) > 0) {
            $loginUserId = $this->Auth->user()['id'];
            foreach ( $res as $key => $row ) {
                //「ログインユーザがオーナである」フラグ
                $res[$key]['TodoList']['owned'] = $row['Owner']['id'] === $loginUserId;
                //「ログインユーザが担当である」フラグ
                $res[$key]['TodoList']['assigned'] = $row['Assignee']['id'] === $loginUserId;
            }
        }
        $this->set(compact('res'));
        $this->set('_serialize', 'res');
    }

〜中略〜

    public function edit($id) {
        $this->TodoList->id = $id;
        $data = $this->request->data;
        $res = $this->TodoList->save($this->request->data);
        $res = !empty($res);
        $response = $this->editResponse($res);
        $this->set(compact('response'));
        $this->set('_serialize', 'response');
    }

+   public function download() {
+       //id順で一覧取得
+       $query = array (
+           'fields' => $this->fields,
+           'order' => "TodoList.id"
+       );
+       $res = $this->TodoList->find('all', $query);
+       // CSVファイルに整形
+       if ($res && is_array($res)) {
+           $fp = fopen('php://temp', 'w+');
+           //タイトル
+           $fields = array('id', 'todo', 'status', 'owner' ,'assignee');
+           fputcsv($fp, $fields);
+           //データ
+           foreach ( $res as $record ) {
+               $fields = array();
+               $fields[] = $record['TodoList']['id'];
+               $fields[] = $record['TodoList']['todo'];
+               $fields[] = $record['TodoList']['status'];
+               $fields[] = $record['Owner']['name'];
+               $fields[] = $record['Assignee']['name'];
+               fputcsv($fp, $fields);
+           }
+           //ポインタを先頭に
+           rewind($fp);
+           //読み込み
+           $content = stream_get_contents($fp);
+           //このままだとエンコーディングはUTF-8, 改行コードはLFとなり、
+           //Excelでひらけないので、開きたい場合は下記コメントインしてエンコーディングをSJIS-winにする
+           //$content =  mb_convert_encoding($content, 'sjis-win', 'UTF-8');
+           fclose($fp);
+           //Viewを使用しない
+           $this->autoRender = false;
+           //ダウンロードファイル名を設定
+           $this->response->download('todo.csv');
+           $this->response->type('csv');
+           $this->response->body($content);
+       }
+   }
+
    //レスポンスを編集
    private function editResponse($res){
        if($res){
            $response = $res;
        }else{
            $this->setStatusValidationError();
            $respnse = array();
            if(count($this->TodoList->validationErrors) > 0){
                $response = $this->editErrors($this->TodoList->validationErrors);
            }else{
                $response = $this->editErrors('エラーが発生しました。');
            }
        }
        return $response;
    }

 〜以下略〜

download関数を追加

データ取得

  • $this->TodoList->find('all', $query);でtodoリストを取得。これは上のindex関数でやっていることと同じです。

phpのファイルアクセス関数たち

  • 下記関数たちを使用し、Todoリストのデータをいったんcsv形式でphp;//tempストリームに書きだします。
    • fopen関数で、php://temp(メモリ上にファイル出力するストリーム)を開きます。
    • CSV形式の出力はfputcsv関数にまかせます。
    • rewind関数でファイルポインタを先頭に戻し、
    • stream_get_contents関数で出力したデータを配列に読み込み直します。
    • fclose - Manual関数でファイルをクローズします。

:point_up: ポイント

ここがキモです!
ブラウザにファイルを「ダウンロード」させるため、

  • Content-Type にCSVファイルであることを設定
  • Content-Dispositionにダウンロード指定(attachment)とファイル名を指定

の2つをやる必要があります。

  • $this->response->download('todo.csv');
    • CakePHPのCakeResponse->downloadメソッドをこのように使用すると、Content-Disposition: attachment; filename="todo.csv"が設定されます
  • $this->response->type('csv');
    • CakePHPのCakeResponse->typeメソッドをこのように使用すると、Content-Type: text/csv; charset=UTF-8が設定されます
  • $this->response->body($content)
    • CakePHPのCakeResponse->bodyメソッドをこのように使用すると、レスポンスボディに$contentの内容が設定されます。

以上でブラウザが「ダウンロード」してくれます。

:warning: $this->response->download('todo.csv');$this->response->type('csv')をコメントアウトしたり戻したりして、いろんなブラウザで挙動をためしてみると、微妙な違いを確認できますよ。

実装

では、実際に修正してTODOをダウンロードしてみましょう。

  • :white_check_mark: app/Config/routes.phpを上記の通り修正。
  • :white_check_mark: app/View/Layouts/default.ctpを上記の通り修正。
  • :white_check_mark: app/Controller/TodoListsController.phpを上記の通り修正。
  • :white_check_mark: 動作確認!
  • :white_check_mark: Gitにコミット

:warning: GitHubでのdiff表示へのリンク

第9回 Lesson1 ダウンロード · suzukishouten-study/rest-study@ccb6d14

ダウンロードできたらLesson2へ!

:large_blue_circle: Lesson2 アップロード

  • クライアント側
    • ファイル選択とアップロードボタンの追加
    • Ajaxでのファイルアップロード処理の追加
  • サーバ側
    • アップロードされたファイルを読み込んでTODOに追加

をそれぞれ実装します。

ダウンロードするファイル形式は下記のとおり1行1Todoとします。

アップロードファイルサンプル(todolist1.txt)
todolist1.txtからアップロードしたTODO1
todolist1.txtからアップロードしたTODO2
todolist1.txtからアップロードしたTODO3
todolist1.txtからアップロードしたTODO4
todolist1.txtからアップロードしたTODO5

編集するファイル一覧

編集 file 編集概要
修正 app/Config/routes.php ダウンロード処理へのルーティング追加
修正 app/View/Layouts/default.ctp ファイル選択とアップロードボタン追加
修正 app/webroot/js/views/todo-composite-view.js アップロード処理追加
修正 app/Controller/TodoListsController.php アップロードされたファイルの処理追加

app/Config/routes.php

アップロード処理のURLを追加します。

app/Config/routes.php
〜略〜
 // CSVファイルダウンロード
 Router::Connect('/todo_lists/download', Array(
    'controller' => 'todo_lists',
    'action' => 'download',
    'method' => 'GET'
 ));

+// CSVファイルアップロード
+Router::Connect('/todo_lists/upload', Array (
+   'controller' => 'todo_lists',
+   'action' => 'upload',
+   'method' => 'POST'
+));
+
 Router::mapResources(array (
    'todo_lists',
 ));

 〜以下略〜

app/View/Layouts/default.ctp

ファイル選択とアップロードボタンを追加します。

app/View/Layouts/default.ctp
〜略〜
    <!-- TODO一覧表示のテンプレート -->
    <script type="text/template" id="todo-composite-template">
        <div class="row">
            <div class="col-xs-12">
                <span class="row form-inline">
                    <div class="input-group col-sm-6 col-xs-12">
                        <label for="new-todo" class="visible-xs">Todo</label>
                        <textarea class="form-control todo-item-text" rows="3" id="new-todo" placeholder="Todo?" autofocus></textarea>
                    </div>
                    <div class="input-group col-sm-3 col-xs-12">
                        <label for="user-list" class="visible-xs">担当者</label>
                        <select class="form-control" name="assignee" id="user-list"></select>
                    </div>
                    <div class="input-group col-sm-3 col-xs-12">
                        <label class="visible-xs"></label>
                        <input type="button" id="addTodo" class="btn btn-primary btn-md todo-action-button" value="追加">
                    </div>
                </span>
            </div>
        </div>
        <div class="row">
            <div class="col-xs-12">
                <table class="table table-striped table-bordered table-hover">
                    <thead>
                        <tr class="success">
                            <th class="col-sm-6" colspan="2">ToDo</th>
                            <th class="col-sm-2">オーナ</th>
                            <th class="col-sm-2">担当</th>
                            <th class="col-sm-2" colspan="2"></th>
                        </tr>
                    </thead>
                    <tbody></tbody>
                </table>
            </div>
        </div>
        <div class="row">
            <div class="col-xs-6">
                <a href="/rest-study/todo_lists/download" class="alert alert-default pull-left" id="downloadTodo">TODOリストをダウンロード</a>
            </div>
+           <div class="col-xs-6">
+               <form class="form-inline">
+                   <input type="file" id="uploadFile" name="uploadFile" multiple class="form-control">
+                   <a class="btn btn-info pull-right" id="uploadButton">TODOリストファイルアップロード</a>
+               </form>
+           </div>
        </div>
    </script>

 〜以下略〜

  • ダウンロードリンクの下に、input type="file"と、<a>タグでボタンを追加します。
    • input type="file"multipleを設定して複数ファイルアップロードに対応しておきます。

app/webroot/js/views/todo-composite-view.js

Ajaxによるアップロードを実装します。。

app/webroot/js/views/todo-composite-view.js
〜略〜
        ui : {
            addTodo : '#addTodo',
            newTodo : '#new-todo',
-           userList : '#user-list'
+           userList : '#user-list',
+           uploadButton : '#uploadButton',
+           uploadFile : '#uploadFile'
        },

        events : {
            'click @ui.addTodo' : 'onCreateTodo',
+           'click @ui.uploadButton' : 'onClickUploadButton',
        },

        initialize: function(options){
 @@ -74,7 +77,34 @@ define(function(require) {
                message += errors.validationError[key];
            }
            alert(message);
-       }
+       },
+       
+       onClickUploadButton : function() {
+           var i;
+           var form = new FormData();
+           var files = this.ui.uploadFile[0].files;
+           for ( i = 0; i < files.length; i++) {
+               form.append(i, files[i]);
+           }
+           var that = this;
+           $.ajax({
+               url : "todo_lists/upload.json",
+               type : "POST",
+               data : form,
+               processData : false,
+               contentType : false,
+               dataType : 'json'
+           }).done(function(data) {
+               alert(data);
+           }).always(function(){
+               that.collection.fetch({
+                   reset : true
+               });
+               that.ui.uploadFile.attr('type', 'text');
+               that.ui.uploadFile.attr('type', 'file');
+           });
+           return false;
+       },
    });
    return TodoCompositeView;
 }); 

〜以下略〜
  • ui変数に、追加したファイル選択、アップロードボタンを追加します。
  • events変数にアップロードボタンのクリックイベントを登録します。
  • onClickUploadButton関数がAjaxによるアップロード処理の実装です。

Jquery.ajax()によるアップロード

今回はアップロードにはjqueryのajax関数を使用します。ポイントは以下。

  • html5のFormDataオブジェクトでファイル選択のフォームデータをラッピングします。
  • FormDataに追加するのは、this.ui.uploadFile[0].files配列の要素。ファイルを2つ選択すると、this.ui.uploadFile[0].files[0]と``this.ui.uploadFile[0].files[1]に格納されるので、for文で処理しています。
  • that.ui.uploadFile.attr('type', 'text'); -> that.ui.uploadFile.attr('type', 'file');というトリッキーなことをしてますが、これはアップロード完了後、ファイルを未選択状態に戻したいため、いったんフォームをtextにしてからfileに戻すというハックです。ブラウザによってうまくいかない場合もあるかもしれません!

app/Controller/TodoListsController.php

アップロードされたファイルを処理してTODOを登録します。。

app/Controller/TodoListsController.php
〜略〜
    public function download() {


       〜略〜

    }

+   public function upload() {
+       $files = $this->request->params['form'];
+       $owner = $this->Auth->user()['id'];
+       $numTodos = 0;
+       foreach ( $files as $file ) {
+           $fileName = $file['name'];
+           $filePath = $file['tmp_name'];
+           $todos = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+           $assignee = $owner;
+           $errors = array ();
+           $lineNo = 1;
+           foreach ( $todos as $todo ) {
+               $data = array ();
+               $data['todo'] = $todo;
+               $data['status'] = 0;
+               $data['owner'] = $owner;
+               $data['assignee'] = $assignee;
+               $res = $this->TodoList->save($data);
+               if ($res) {
+                   $numTodos++;
+               } else {
+                   if (count($this->TodoList->validationErrors) > 0) {
+                       foreach ( $this->TodoList->validationErrors as $validationErrorsOfLine ) {
+                           $title = 'file:' . $fileName . ' - line: ' . $lineNo . ': ';
+                           foreach ( $validationErrorsOfLine as $validationError ) {
+                               $errors[] = array (
+                                               $title . $validationError
+                               );
+                           }
+                       }
+                   }
+               }
+               $this->TodoList->create();
+               $lineNo++;
+           }
+       }
+       if (count($errors) > 0) {
+           $this->TodoList->validationErrors = $errors;
+           $response = $this->editResponse(false);
+           array_unshift($response['errors'], array (
+                           '以下のエラーが発生しました。'
+           ));
+           if ($numTodos > 0) {
+               array_unshift($response['errors'], array (
+                               $numTodos . '件のTODOを登録しました。'
+               ));
+           }
+       } else {
+           $response = $numTodos . '件のTODOを登録しました。';
+       }
+       $this->set(compact('response'));
+       $this->set('_serialize', 'response');
+   }
+
    //レスポンスを編集
    private function editResponse($res){
        if($res){
            $response = $res;
        }else{
            $this->setStatusValidationError();
            $respnse = array();
            if(count($this->TodoList->validationErrors) > 0){
                $response = $this->editErrors($this->TodoList->validationErrors);
            }else{
                $response = $this->editErrors('エラーが発生しました。');
            }
        }
        return $response;
    }

〜以下略〜

アップロードされたファイルを取得し、読み込み、各行をTodoとして登録します。画面上で「追加」ボタンで登録したときと同じバリデーションを効かせ、エラーがあればメッセージを返すようにしています。

アップロードファイル取得

  • $this->request->params['form']にアップロードされたファイルの情報が配列で入ってきます。ファイルが2つアップロードされたなら、$this->request->params['form'][0]$this->request->params['form'][1]に入ってきます。
  • $this->request->params['form'][0]['name']でファイル名、$this->request->params['form'][0]['tmp_name']で、サーバ上にテンポラリファイルとして作成されたアップロードファイルのパスが格納されます。

:warning: リクエストボディにマルチパートで格納されたファイルのファイル名とファイル内容がこれで取れるわけです。phpとCakePHPがこの辺の面倒なところをやってくれています。カンタンですね!

phpのファイルアクセス関数たち

  • file関数でアップロードされたファイルを全て読み込んで配列に格納します。

:point_up: ポイント

やっていることは、

  • アップロードされたファイルをまとめて読み込む
  • すべての行に対し、一行をそのままtodoとし、その他の項目は、status=0, owner=ログインユーザのid, assignee=ログインユーザのidを設定し、saveメソッドでデータベースに保存。これでバリデーションも動く
  • 登録できた件数をメッセージに埋め込んでレスポンスボディとして返す。
    • 途中の行でエラーが有っても最後まで実行しますので、「n件登録できてm件エラーで登録できず」という状況が有ります。この辺はソースを頑張って読み解いてみましょう!

実装

では、実際に修正してTODOをアップロードしてみましょう。

  • :white_check_mark: app/Config/routes.phpを上記の通り修正。
  • :white_check_mark: app/View/Layouts/default.ctpを上記の通り修正。
  • :white_check_mark: app/webroot/js/views/todo-composite-view.jsを上記の通り修正。
  • :white_check_mark: app/Controller/TodoListsController.phpを上記の通り修正。
  • :white_check_mark: 動作確認!
  • :white_check_mark: Gitにコミット

:warning: GitHubでのdiff表示へのリンク

第9回 Lesson2 アップロード · suzukishouten-study/rest-study@3edd59f

以上です!

次回予告

次回は「リファクタリング(サーバー編)」です。
小さなTodoアプリではありますが、少しずつコード量が増えてきました。
次回はもっとおおきな開発に備えてコードをキレイにする方法を学んでいきます。
ぜひご参加下さい!
:warning: ひょっとすると「リファクタリング(クライアント編)」も一緒にやっちゃうかも...

コメント/フィードバックお待ちしております。

参加者の方も、そうでない方もお気づきの点があればお願い致します。