タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

Flutter/Dart で S3 へのダイレクトアップロードを実装する

f:id:uzuki05:20181215091555j:plain

ダイレクトアップロードのクライアント側実装では次の2つのステップが必要です。

  1. ダイレクトアップロード用のURLを取得する
  2. S3にダイレクトアップロードする

Flutter /Dart でどう書けば良いのか?私が試した方法を紹介します。

この記事ではサーバ側の実装については述べません。

1. ダイレクトアップロード用のURLを取得する

ダイレクトアップロードしたいファイルのハッシュ値やサイズなどをバックエンドに送ります。 バックエンドはたとえば ActiveStorage を使ったり、自前で作っても良いですね。

ファイルのハッシュ値crypto で生成して base64エンコードして作れます。 Content-Type は mime を使いました。

import 'package:mime/mime.dart' show lookupMimeType;
import 'package:path/path.dart' show basename;
import 'upload_helper.dart' show calculateMD5CheckSum, getFilzeSize;

# 省略

// たとえば ImagePicker などで選択されたファイルパスを受け取る
onSubmit: (String filePath) async {
  final filename = basename(path);
  final contentMd5 = await calculateMD5CheckSum(path);
  final contentType = lookupMimeType(filename);
  final contentLength =
  await getFilzeSize(path);

  // サーバにダイレクトアップロード用URL発行に必要な情報を送る
  // runUploadSignMutation は自前の GraphQL Mutation
  runUploadSignMutation({
    'contentLength': contentLength,
    'contentType': contentType,
    'contentMd5': contentMd5,
    'filename': filename
  });
// upload_helper.dart

import 'dart:io';
import 'package:crypto/crypto.dart' as crypto;
import 'dart:convert';

Future<String> calculateMD5CheckSum(String filePath) async {
  var ret = '';
  var file = File(filePath);
  if (await file.exists()) {
    try {
      var md5 = crypto.md5;
      var hash = await md5.bind(file.openRead()).first;
      ret = base64.encode(hash.bytes);
    } catch (exception) {
      print('Unable to evaluate the MD5 sum :$exception');
      return null;
    }
  } else {
    print('`$filePath` does not exits so unable to evaluate its MD5 sum.');
    return null;
  }
  return ret;
}

Future<int> getFilzeSize(String filePath) async {
  int ret;
  final file = File(filePath);
  if (await file.exists()) {
    try {
      ret = await file.length();
    } catch (exception) {
      print('Unable to get the length :$exception');
      return null;
    }
  } else {
    print('`$filePath` does not exits so unable to get its length.');
    return null;
  }
  return ret;
}

なお、今回はこんなGraphQLを想定しています。 ヘッダー署名に必要な情報を送ると、S3署名付きURLと署名付きのHTTPヘッダーを返すという仕様です。

mutation directUploadSignRequest($contentLength: Int!, $contentType: String!, $contentMd5: String!, $filename: String!) {
  directUploadSignRequest(
    input: {
      contentLength: $contentLength,
      contentType: $contentType,
      contentMd5: $contentMd5,
      filename: $filename
    }
  ) {
    url
    headersJson
    signedId
  }
}

2. S3にダイレクトアップロードする

バックエンドでS3アップロードに必要な

  • 署名付きURL
  • HTTPヘッダー

を得たら、その情報を使ってS3にPUTできます。

ここではこんな応答を受け取ったとします。 アップロードに必要な URL 、ヘッダーのJSON、それにアップロードしたことをサーバに伝えるための識別子ですね。

{
  "directUploadSignRequest": {
    "url": "https://xxxxxxxxxxxxxx.s3.ap-northeast-1.amazonaws.com/a3hwxj8wiyzdb503b4ley2d7smuj?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=TESTKEYID%2F20210123%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20210123T165510Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bcontent-type%3Bhost&X-Amz-Signature=c9a99a06e64bf47aa5a48d458f7af39c13f7230b8f1f71ebce65ba15db08216a",
    "headersJson": "{\"Content-Type\":\"image/jpeg\",\"Content-MD5\":\"716+n5uVJiFVKFsqCh2qHw==\",\"Content-Disposition\":\"inline; filename=\\\"test.jpg\\\"; filename*=UTF-8''test.jpg\"}",
    "signedId": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--b673084e015e2e33b3dfca2631afb1b59cb9d47a"
}

PUTには dio が便利です。

import 'package:dio/dio.dart';
import 'upload_helper.dart' show getFilzeSize;

// 省略

String url = resultData['directUploadSignRequest']['url'];
Map<String, dynamic> headers = jsonDecode(resultData['directUploadSignRequest']['headersJson']);

// Content-Length が必要
// HTTP Client が自動で付けないので
headers['Content-Length'] = await getFilzeSize(filePath);

try {
  Dio dio = new Dio();
  await dio.put(url, data: File(filePath).openRead(), ptions: Options(headers: headers));
  sendSignedIdToServer(resultData['directUploadSignRequest']['blob']['signedId']);  // 終わったのでサーバに伝えて紐付けなど適当に
 on DioError catch (error) {
  debugPrint("statusCode: ${error.response.statusCode}");
  debugPrint("request headers: ${jsonEncode(error.request.headers)}");
  debugPrint("request queryParameters: ${jsonEncode(error.request.queryParameters)}");
  debugPrint("request path: ${jsonEncode(error.request.path)}");
  debugPrint("response body: ${error.response.data}");
}