Laravel+Alamofireで画像のアップロードがうまくいかない件

f:id:rasukarusan:20190202192844p:plain:w400

iOSのインカメラでキャプチャを撮ってサーバー側に保存する、といった処理をしようとした際、どうもサーバー側で画像が受け取れない。
なぜか画像が$_FILEではなく$_POSTに入ってきてしまい、かつバイナリ文字列のような形で来てしまう。

結論としてはAlamofireでuploadする際、filenameを指定していなかったからだった。laravelは関係ない。
ちなみに以下に調査内容を書いていくが、結局根本的な原因は判明していない。

環境

  • Swift 4.0
  • PHP 7.0.9
  • Laravel 5.5.44
  • Alamofire 4.7.3

クライアント(Alamofire)側

巷でよく見る以下のようなコード。
画像とテキストをmultiPartFormDataでPOSTする。 しかしこのコードだとうまくアップロードできなかった。

Alamofire.upload(
    multipartFormData: { multipartFormData in
        multipartFormData.append(capture, withName: "capture", mimeType: "image/jpeg")
        multipartFormData.append(comment, withName: "comment")
    },
    to: "https://hogehoge/api/image", 
    method: .post,
    encodingCompletion: { encodingResult in
        switch encodingResult {
        case .success(let upload, _, _):
            upload.responseJSON { response in
                let responseData = response
            }
        case .failure(let encodingError):
            print(encodingError)
        }
    }
)

以下のようにappendの際filenameを指定すると画像のアップロードに成功する。 ファイル名はサーバー側で変更するし指定しなくていいだろうと思っていたのがいけなかったみたい。

multipartFormData.append(capture, withName: "capture", mimeType: "image/jpeg")
↓
multipartFormData.append(capture, withName: "capture", fileName: "upload.jepg", mimeType: "image/jpeg")

サーバー(laravel)側

サーバー側はシンプルに以下のようなコード。画像アップロードのためのAPIをlaravelで作成。
Requestからアップロードされたファイルを取得して日付ファイル名で保存する。

<?php

public function store(Request $request) {
    // 画像の保存
    if($request->file('capture')->isValid()) {
        $file_name = Carbon::now()->format('YmdHis').'.jpeg';
        $request->capture->storeAs('images', $file_name);
        return json_encode(['result' => true]);
    }
    return json_encode(['result' => false]);
}

問題の切り分け

クライアントのコードが悪いのか、サーバー側のコードが悪いのかを調査する。

laravelでAPIが叩かれたときにログを出す

なにはともあれログを出さないことには原因追求ができないため、APIが叩かれたときにログを出すように設定した。 設定は以下を参考にさせていただいた。めっちゃ簡単。 qiita.com

curlで画像アップロードをしてみる

curl -X POST http://0.0.0.0:8080/api/images \
-F comment=hoge \
-F capture=@/Users/rasukarusan/Desktop/home.png \

ログ

[2019-01-21 22:43:44] local.DEBUG: POST {"path":"/","query":"comment=hoge"}

画像の指定キーcaptureがないが、どうやら正常っぽい。サーバー側にはちゃんと画像が保存されていた。

# 画像の確認
$ ls /Users/rasukarusan/Desktop/laravel-app/storage/app/images

20190121224359.jpeg

ということはサーバー側の処理は問題ない。クライアントのコードが悪いと思われる。

Alamofireで画像アップロードをしてみる

おそらくAlamofireでの送り方が悪いのだろうと思いつつ、試しに送信してみる。

ログ

[2019-01-21 23:16:26] local.DEBUG: POST {"path":"/","query":"capture=%FF%D8%FF%E0%00%10JFIF%00%01%01%00%00H%00H%00%00%FF%E1%00XExif%00%00M00%00%FF%E1%00XExif%00%00MM%00%2A%00%00%00%08%00%02%01%12%00%03%00%00%00%01%00%01%00%00%87i%00%04%00%00%00%01%00%00%00%26%00%00%00%00%00%03%A0%01
%00%03%00%00%00%01%00%01%00%00%A0%02%00%04%00%00%00%01%00%00%01h%A0%03%00%04%00%00%00%01%00%00%01%E0%00%00%00%00%FF%ED%008Photoshop+3.0%008BIM%04%04%00%00%00%00%00%008BIM%04%25%00%00%00%00%00%10%D4%1D%8C%D9%8F.....%FF%DD%00%04%00%1,"comment:hoge"
[2019-01-21 23:16:26] local.ERROR: Undefined index: capture {"exception":"[object] (ErrorException(code: 0): Undefined index: capture at /Users/rasukarusan/Desktop/laravel-app/app/Http/Controllers/HogeController.php:41)
[stacktrace]

ものすごい量の文字列が出力が出力され、「captureなんていうキー見つからないよ」と言われる。
いやログにcaptureってあるがな、、、てかcurlの時はcaptureなかったのになんでAlamofireで送信するといきなりでてくるんや、、、。

画像の大量文字列はサーバー側でどこに格納されているのか?

エラーログが吐き出されているのはif($request->file('capture')->isValid()) {の部分。 ここの$request->file('capture')でファイルを取得しようと試みるも'capture'キーがないと怒られている。 そもそもlaravelの$request->file('capture')$_FILESに格納されているものを取得しているはずなので、「もしかしたら$_FILESに画像が入ってないんじゃね?」と推測。

以下のコードで$_FILES, $_POST, $_GETのどこに格納されているか検証した。

<?php 
public function store(Request $request) {
    \Log::debug(isset($_FILES));
    \Log::debug(isset($_POST));
    \Log::debug(isset($_GET));
    exit;
    // 画像の保存
    if($request->file('capture')->isValid()) {
        ....
    }
}

ログ

[2019-01-21 23:18:52] local.DEBUG: 
[2019-01-21 23:18:52] local.DEBUG: 1
[2019-01-21 23:18:52] local.DEBUG: 

$_FILESじゃなくてPOSTに入ってた。試しに$_POST['capture']をデバッグしてみた。

<?php 

public function store(Request $request) {
    \Log::debug(isset($_POST));
    \Log::debug($_POST['capture']);
    exit;
}

ログ

[2019-01-22 00:03:25] 1
[2019-01-22 00:03:25] local.DEBUG: array (
  'capture' => '<FF><D8><FF><E0>' . "\0" . '^PJFIF' . "\0" . '^A^A' . "\0" . '' . "\0" . 'H' . "\0" . 'H' . "\0" . '' . "\0" . '<FF><E1>'...

うっほ、、、バイナリ文字列で入ってきとるがな。 ってことはfile_get_contentsで保存できるのではと思いやってみるとちゃんと保存できた。

<?php 

public function store(Request $request) {
    // 画像の保存
    $file_name = Carbon::now()->format('YmdHis').'.jpeg';
    $file_path = $_SERVER['DOCUMENT_ROOT'].self::PATH_SAVE_IMAGE.$file_name;
    file_put_contents($file_path, $request->capture);
}
# 画像の確認
$ ls /Users/rasukarusan/Desktop/laravel-app/storage/app/images

20190122000359.jpeg

ということで本来の目的の「画像の保存」は達成できた。
がしかし全然納得していないので原因を探ってみた。ここから闇が始まる。

Alamofireの挙動を追う

なぜfileNameを指定するとちゃんと$_FILESに入り画像がアップロードできるのか、そもそもどういう経路で画像のアップロードが行われるのか調査する。 さあ、楽しいライブラリ探索の時間だ。

・Alamofire.uploadの流れ

今回肝になりそうなのはAlamofire.uploadの第一引数であるmultipartFormDataなので、ここを追う。

Alamofire.upload(
    multipartFormData: { multipartFormData in
        multipartFormData.append(capture, withName: "capture", mimeType: "image/jpeg") // ←特にappendの挙動が知りたい
        multipartFormData.append(comment, withName: "comment")
    },

定義元はMultipartFormData.swift。ここでappend(で検索。

public func append(_ data: Data, withName name: String) 
public func append(_ data: Data, withName name: String, mimeType: String) // ←fileNameを指定しない場合に実行される
public func append(_ data: Data, withName name: String, fileName: String, mimeType: String)  // ←fileNameを指定する場合に実行される
public func append(_ fileURL: URL, withName name: String) 
public func append(_ fileURL: URL, withName name: String, fileName: String, mimeType: String) 
public func append(_ stream: InputStream, withLength length: UInt64, name: String, fileName: String, mimeType: String)

検索してみてふと気づいたが、fileNameを指定した場合と指定しない場合で実行されるメソッドが違うということ。
いや至極当然のことなんだがappendでメソッド名が統一されているのでつい全部同じメソッドで実行されていると勘違いしてしまった。

ということは

  1. 指定した場合に実行されるメソッド
  2. 指定しない場合に実行されるメソッド

の挙動を調べれば原因がわかるはず。

append()の挙動(fileNameを指定した場合と指定しない場合)

各メソッドの中身は以下。

// fileNameを指定しない場合
public func append(_ data: Data, withName name: String, mimeType: String) {
    let headers = contentHeaders(withName: name, mimeType: mimeType)
    let stream = InputStream(data: data)
    let length = UInt64(data.count)

    append(stream, withLength: length, headers: headers)
}

// fileNameを指定する場合
public func append(_ data: Data, withName name: String, fileName: String, mimeType: String) {
    let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
    let stream = InputStream(data: data)
    let length = UInt64(data.count)

    append(stream, withLength: length, headers: headers)
}

ほとんど同じ処理をしている。が、唯一違うところがlet headers = contentHeaders()で渡す引数。 つまりcontentHeaders()内で何かfileName特有の処理をしているはず。

contentHeaders()の挙動

contentHeaders()の中身は以下。
appendのように同名メソッドで複数用意されているかと思ったが、contentHeaders()はデフォルト引数が設定されているので共通っぽい。

private func contentHeaders(withName name: String, fileName: String? = nil, mimeType: String? = nil) -> [String: String] {
    var disposition = "form-data; name=\"\(name)\""
    if let fileName = fileName { disposition += "; filename=\"\(fileName)\"" }

    var headers = ["Content-Disposition": disposition]
    if let mimeType = mimeType { headers["Content-Type"] = mimeType }
    print(headers)
    return headers
}

処理から推測するに、fileNameを指定した場合と指定しない場合ではContent-Dispositionの値が変わるようだ。

  • fileNameを指定する場合
"Content-Disposition": "form-data; name="capture"; filename="upload.jpeg""
  • fileNameを指定しない場合
"Content-Disposition": "form-data; name="capture""

むむむなるほど。ということはContent-Dispositionにfilenameが存在しない場合、サーバー側はどういう解釈をするのかが鍵な気がしてきた。 (Content-Dispositionって何やねん、、)

Content-Dispositionとは

developer.mozilla.org を見ていて気になる一文を発見。

通常の HTTP レスポンスにおける Content-Disposition レスポンスヘッダーは、コンテンツがブラウザーでインラインで表示されることを求められているか、つまり、ウェブページとして表示するか、ウェブページの一部として表示するか、ダウンロードしてローカルに保存する添付ファイルとするかを示します。

よくブラウザで見るダウンロードダイアログと画像プレビューの話やな。

結局謎

headerをContent-Disposition: inlineで送信しているのならなんとなくわかる。$_POSTの値がバイナリだったこともプレビューのためかもしれない。

ただAlamofireではContent-Disposition: form-data; name="hoeg"のようにfileNameを指定していようがいまいがinlineでは送らない。必ずform-dataを指定している。
また、RFC7578を見てみても、filenameを指定しない時はinlineで読み込むといった挙動もしていない。

結局自分の見るべき箇所が間違っていたのか、、、わからない。