PHPアプリでファイルをクライアントからAWS S3に直接JavaScriptで送付する方法

こんにちはみなさん

サーバサイドの実装やっていると、クライアントには一見すると容易いのに、サーバ側では困難な状況が発生している、ということがあります。
ファイルアップロードなんてまさにそれで、PHPだとアップロードしたファイルの分だけメモリを確保しよるので、下手に動画ファイルなんてあげられると、送信途中でメモリを使い尽くしてリクエストが落ちるなんて事態になります。
HD画質の長時間動画なんて送信された日には、数GBのメモリを使うので、使用するインスタンスのサイズもそれ相応に大きくしなければならなかったりして、費用効率もあまり良くないように思います。

というわけで、やり方を幾つか探ったところ、JavaScriptを使ってクライアントから直にS3へアップロードする技があったので、そいつを実現してみましょう。ミニマム実装なので、ダサい感じですが、例示するものとしては問題ないでしょう。

# TL;DR

- サーバはPHP、フロントはHTML+JavaScriptのいつものようなアプリ
- サーバで一時的なS3へのアクセス用のURLを発行する
- フロントでAjaxを使ってサーバから取得したURLを使ってファイルをS3にアップロードする

これを参考にしています
[AWS SDK for PHPで署名付きURLを生成する小さなサンプル](http://qiita.com/kure/items/8a5934351dffad0e4da7)

# 事前準備
AWSのマネジメントコンソールで、今回のテスト用のIDを振り出しておきましょう。
また、S3のバケットを作っておく必要があります。
(その他の実装自体は、ローカルでもできます。)

今回、私は例の如くdockerを使って環境を作っています。
環境はcomposerをグローバルインストールしたものを使っています。

```
$ docker run -it --rm -v `pwd`:/srv -p 8080:8080 niisan/php-dev bash
# php -S 0.0.0.0:8080 -t public/
```
こんなコマンドを使って、ローカルに即席のWebサーバを作っています。

# 概要
図としてはこんな感じです。
![シーケンス図](https://qiita-image-store.s3.amazonaws.com/0/102182/008d8fa9-4e96-efa4-9c73-85ff73656c70.png)
つまるところ、ファイルをアップロードする際に、一旦サーバにはファイルをアップロードしたいという意志だけを伝え、署名付きURLをもらい、そのURLにむけてファイルをアップロードするという動きです。

# 実装
## モジュールの準備
必要なモジュールはaws sdkのみです。

```
$ composer require aws/aws-sdk-php
$ composer require vlucas/phpdotenv
```
素のPHPでやろうとしているので、環境情報とかを読み出しやすいように、dotenvを導入しています。
今回必要な`.env`は以下のとおりです。

```.env
AWS_ACCESS_KEY_ID=<用意したアクセスキー>
AWS_SECRET_ACCESS_KEY=<用意したシークレットキー>
AWS_REGION=<S3を置いたリージョン>
```

## index.htmlの作成
まずは表示用のページを作ります。

```public/index.html
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
    <h1>ファイルアップロードテスト</h1>
    <div id="droppable" style="border: gray solid 1em; padding: 2em;">
      ファイルをドロップしてください。
    </div>
    <button id="hugefile" type="submit" class="btn btn-primary">送信</button>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.3/jquery.min.js" integrity="sha384-I6F5OKECLVtK/BL+8iSLDEHowSAfUo76ZL9+kGAgTRdiByINKJaqTPH/QVNS1VDb" crossorigin="anonymous"></script>
<script>
// ここにJavaScriptの動作コードを書く
</script>
</body>
</html>
```
単純にアップロード用のフォームを用意した形です

![フォーム](https://qiita-image-store.s3.amazonaws.com/0/102182/ca3c732f-7d29-3782-c920-dfa6199870ec.png)

アップロード用のフィールドを用意したら、`<script>~</script>`部分にアップロード用のスクリプトを用意します。
今回はドラッグオンドロップでファイルを選択できるようにしたので、ちょっとよけいなコードが多いですが、以下の様なものを書きます。
[ドラッグオンドロップで参考にした記事](http://www.techscore.com/blog/2012/11/12/html5-%E3%81%AE-file-api-%E3%81%A7%E3%83%89%E3%83%A9%E3%83%83%E3%82%B0%EF%BC%86%E3%83%89%E3%83%AD%E3%83%83%E3%83%97%E3%81%99%E3%82%8B/)

```public/index.html
<script>
let upload_file;
$(function() {
        var droppable = $("#droppable");

        // File API が使用できない場合は諦めます.
        if(!window.FileReader) {
          alert("File API がサポートされていません。");
          return false;
        }

        var cancelEvent = function(event) {
            event.preventDefault();
            event.stopPropagation();
            return false;
        }

        droppable.bind("dragenter", cancelEvent);
        droppable.bind("dragover", cancelEvent);

        // ドロップ時のイベントハンドラを設定します.
        var handleDroppedFile = function(event) {
          // ファイルは複数ドロップされる可能性がありますが, ここでは 1 つ目のファイルを扱います.
          var file = event.originalEvent.dataTransfer.files[0];
          $("#droppable").text("[" + file.name + "]");
          upload_file = file

          cancelEvent(event);
          return false;
        }

        droppable.bind("drop", handleDroppedFile);
      });

//////////////////////////////////////////////////////
////    ここから先がS3アップロードに使用されるコード部分   ////    
//////////////////////////////////////////////////////

// ボタンが押されたときの処理
$("#hugefile").on('click', function () {
    if (upload_file == null) {
        alert('ファイルがない')
        return false
    }

    $.ajax({
        url: "/one-time.php",
        data: {"name":upload_file.name},
        type: 'GET',
        dataType: 'json'
    }).done(data => sendFileCore(data, upload_file))

    return false
})

// Ajaxで取得した署名付きURLを使用してファイルをアップロードする処理
function sendFileCore(data, file) {
    $.ajax({
        url: data.uri,
        type: 'PUT',
        data: file,
        contentType: file.type,
        processData: false
    }).done(function(d) {
        alert('完了')
    })
}
</script>
```
前半部分はドラッグオンドロップに関する部分なので、解説はほぼ省略します。
ドラッグオンドロップで取得したファイルを`upload_file = file`でグローバルな変数に入れています

次からが本番ですが、送信ボタンを押すと、まずファイルをアップロードする事前準備として、アプリサーバに問い合わせて、アップロード用のURLを取得します。(`$(#hugefile).onclick` の部分)
ちゃんと問い合わせが完了してURLが取得できたら、そのURLを使ってファイルを送信します。(`function sendFileCore`)

## 署名付きURL発行
`index.html`のjavascriptで、`one-time.php`にアクセスしていますので、このファイルを作りましょう。

```public/one-time.php
<?php

require __DIR__.'/../bootstrap.php';

use Aws\S3\S3Client;

$s3 = S3Client::factory(['region' => getenv('AWS_REGION'), 'version' => '2006-03-01']);
$command = $s3->getCommand('PutObject');
$command['Bucket'] = 'example-test-s3';
$command['Key'] = $_GET['name'];
$result = $s3->createPresignedRequest($command, '+1 minutes');
$data = ['uri' => (string)$result->getUri()];
echo json_encode($data);
```
これだけです。
わざわざ書くまでもないですが、以下のような流れですね。

1. S3のクライアントを生成して
2. S3で今回実施したいコマンドを作成し
3. そのコマンドを元にS3から署名付きのURLをもらって
4. `{"uri": "<署名付きのURL>"}`という形式のレスポンスを返す

ここで参照している`bootstrap.php`はオートローダの読み込みと`.env`の読み込みをやっているだけです。

```bootstrap.php
<?php

require __DIR__ . '/vendor/autoload.php';
$dotenv = new Dotenv\Dotenv(__DIR__);
$dotenv->load();
```
とにかく、`one-time.php`において、**「ある特定のファイル(Key)をある特定のバケット(Bucket)にPUTするための、1分間だけ有効な署名付きのURL」**を発行しています。

## S3の設定
S3側ではCORSを設定しておく必要があります。
今回はローカルを使っているのでかなりいい加減な設定ですが、実際にはサービスのドメインを指定してあげるとよいかと思います。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>
```

# まとめ

ファイルをクライアントから直接アップロードする実装を作ってみました。
実際には一旦PHPアプリを通した上でアップロードしていますが、ファイルをサーバに通すわけではないので、大きなファイルがアップロードされても、サーバのメモリが食いつぶされることはないでしょう。

しかし、AWSって色々使える機能があるのですが、ユースケースに従ってどう実装すればいいのかを特定するのがとても大変に思います。

今回はこんなところです。