あるSEのつぶやき・改

ITやシステム開発などの技術に関する話題を、取り上げたりしています。

Reactで画像やExcelをAPI Gateway+Lambda(C#)でアップロードしてS3に保存する方法

はじめに

React を使い画像ファイルや Excel ファイルなどのバイナリファイルを、AWS の API Gateway と Lambda (C#) を経由してアップロードし、S3に保存する方法を調べたので記事として残しておきます。

この方法ですが、ネット上に全くといっていいほど情報がなく試行錯誤して非常に苦労しました。どの辺でハマったかなどは後述しますので、参考にしてみてください。

なお、開発環境は以下のようになります。

  • Windows 10
  • Visual Studio 2017 Community Edition
  • Visual Studio Code

目次

システム構成

システム構成は、以下のイメージになります。

React -> (File Upload) -> API Gateway -> Lambda(C#) -> (File Put) -> S3

なぜ API Gateway から S3 に直接アップロードするのではなく一度 Lambda (C#) をかませているのかというと、ファイルアップロード直後にファイル編集を行う自由度の高い実装をしたかったためです。

なお、File Upload はバイナリ形式ではなく Base64 形式にバイナリファイルをエンコードしたものになります。

これは、API Gateway はバイナリファイルをサポートしているのですが、ブラウザから POST 形式でファイルをアップロードすることができないと思われるためです。

以下の記事にあるように API Gateway はバイナリファイルをサポートしていますが、実際にブラウザからファイルをアップロードするとエラーが発生してしまいどうしても解決できませんでした。

aws.amazon.com

おそらくですが、ブラウザからファイルをアップロードする方法はサポート対象外の使い方なのではないかと思います(2019/03/23時点)。

そのため発想を変えて、バイナリファイルを Base64 にエンコードして POST することにしました。

この方法も苦労はしましたが、最終的にうまくいったのでこの記事では Base64 の方法をご紹介します。

Lambda 関数(C#)の作成

Lambda 関数(C#) を作成するには、Visual Studio に AWS Toolkit for Visual Studio をインストールする必要があります。

また、Lambda(C#) のプロジェクトを新規作成したあとで、以下のパッケージを Nuget からインストールします。

  • AWSSDK.S3
  • AWSSDK.APIGateway
  • Amazon.Lambda.APIGatewayEvents

その上で、Function.csに以下のようにコードを記述します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.IO;
using System.Threading.Tasks;
using Amazon.Lambda.Core;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.Lambda.APIGatewayEvents;
using Newtonsoft.Json;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]
namespace AWSLambda1
{
    public class Function
    {
        public async Task<APIGatewayProxyResponse> FunctionHandler(APIGatewayProxyRequest request, ILambdaContext context)
        {
            APIGatewayProxyResponse response = null;
            
            try
            {
                // JSONをデシリアライズ
                var desializedJson = JsonConvert.DeserializeObject<DesilializedFile>(request.Body);

                // 複数ファイルに対応
                foreach (var file in desializedJson.Files)
                {
                    // paddingを取り除くための処理("data:image/png;base64,iVBORw0KGgo...")
                    string[] splitStrings = file.Split(",");

                    // Content-Typeを取得
                    var contentType = GetContentType(splitStrings[0]);

                    // Base64の慣習に合わせ文字列置換
                    var base64String = splitStrings[1].Replace("-", "+").Replace("_", "/");

                    // Base64ファイルをデコード
                    var decodedBytes = Convert.FromBase64String(base64String);

                    using (var ms = new MemoryStream(decodedBytes))
                    { 

                        // S3のリージョンを指定する
                        var s3 = new AmazonS3Client(Amazon.RegionEndpoint.APNortheast1);

                        // 保存ファイル名の設定
                        var fileName = (DateTime.Now).ToString("yyyyMMdd_HHmmss_ffff") + "_file." + GetFileExtension(contentType);

                        // S3リクエストの作成
                        var s3Request = new PutObjectRequest()
                        {

                            BucketName = "Your BacketName",
                            Key = fileName,
                            ContentType = contentType,
                            InputStream = ms
                        };

                        // S3にファイル保存
                        var s3Response = await s3.PutObjectAsync(s3Request);
                    }
                }

                // レスポンスの作成
                response = new APIGatewayProxyResponse
                {
                    StatusCode = (int)HttpStatusCode.OK,
                    Body = "ファイルが保存されました。",
                    Headers = new Dictionary<string, string> {
                        { "Content-Type", "application/json" },
                        { "Access-Control-Allow-Origin", "*" },
                        { "Access-Control-Allow-Credentials", "true" }
                    }
                };

            }
            catch (Exception e)
            {
                // CloudWatchにログ出力
                context.Logger.Log(e.Message);
                context.Logger.Log(e.StackTrace);

                response = new APIGatewayProxyResponse
                {
                    StatusCode = (int)HttpStatusCode.InternalServerError,
                    Body = e.Message,
                    Headers = new Dictionary<string, string> {
                        { "Content-Type", "application/json" },
                    }
                };
            }

            return response;
        }

        /// <summary>
        /// Content-Typeを抽出する
        /// </summary>
        /// <param name="padding">padding</param>
        /// <returns>Content-Type</returns>
        private string GetContentType(string padding)
        {
            return padding.Split(":")[1].Split(";")[0];
        }

        /// <summary>
        /// 拡張子を取得する
        /// </summary>
        /// <param name="contentType">Content-Type</param>
        /// <returns>拡張子</returns>
        private string GetFileExtension(string contentType)
        {
            var ret = "";

            switch (contentType)
            {
                case "image/jpeg":
                    ret = "jpg";
                    break;
                case "image/png":
                    ret = "png";
                    break;
                case "application/vnd.ms-excel":
                    ret = "xls";
                    break;
                case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
                    ret = "xlsx";
                    break;
                default:
                    ret = "tmp";
                    break;
            }

            return ret;
        }

    }

    /// <summary>
    /// JSON をデシリアライズするクラス
    /// </summary>
    [JsonObject]
    public class DesilializedFile
    {
        [JsonProperty(PropertyName = "files")]
        public string[] Files { get; set; }

    }

}

基本的な流れとしては、JSON 形式のリクエストデータをデシリアライズし、Base64 デコードしたあとで、S3 にファイルを保存しています。

API Gateway からのデータは、APIGatewayProxyRequest で受けています。

S3 にファイルを保存する際は、Content-Type を忘れずに指定するようにします。指定しないとファイルを読み込めなくなってしまうらしいので。

また、レスポンスをリターン時に CORS のエラーが発生しないように、レスポンスヘッダーに Access-Control-Allow-Origin, Access-Control-Allow-Credentials を指定しています。

Lambda(C#) 関数にパッケージを追加したので、Visual Studio の機能では作成したプログラムを Lambda にアップロードできません。アップロードするには以下の記事を参考にしてください。

www.aruse.net

API Gateway の設定

API Gateway の管理画面から、新規 API を「MyApi」という名前で作成します。

f:id:fnyablog:20190323091655p:plain:w480

API > リソース > アクション > メソッドの作成から、POST メソッドを作成します。

「統合タイプ」に「Lambda関数」、「Lambda統合プロキシの使用」にチェック、「Lambdaリージョン」に Lambda 関数をアップロードしたリージョン、「Lambda関数」にアップロードした Lambda 関数名を指定して保存します。

f:id:fnyablog:20190323092221p:plain:w480

クロスドメインで HTTPXMLRequst を有効にするためには、CORS の設定を行う必要があります。

API > リソース > アクション > CORS の有効化 から、CORS を有効にします。

f:id:fnyablog:20190323092951p:plain:w480

API > リソース > アクション > API のデプロイから、API を指定したステージにデプロイします。ここでは、「prod」と指定しています。API の設定を変更したら必ずデプロイする必要があるので、ご注意ください。

f:id:fnyablog:20190323093430p:plain:w320

API の URL を控えます。

https://*.execute-api.ap-northeast-1.amazonaws.com/prod

React プロジェクトの作成

React プロジェクトでは、今回は TypeScript を使いたいので、以下のコマンドでプロジェクトを作成します。

> npx create-react-app プロジェクト名 --scripts-version=react-scripts-ts

XMLHTTPRequest には axios、ブラウザでファイルのドラッグ&ドロップに対応するために react-dropzone を使用するので、以下の npm コマンドでインストールします。

> npm install --save @types/react-dropzone
> npm install --save axios

App.tsx は以下のように記述します。

import axios from 'axios';
import * as React from 'react';
import Dropzone from 'react-dropzone'
import './App.css';

class App extends React.Component {

  public onDrop = async (files: any[]) => {
    // アップロードするBase64文字列を格納する配列
    const uploadFiles: string[] = [];

    // Base64 にファイルをエンコード
    for (const file of files)  {
      const result = await toBase64(file);
      uploadFiles.push(result);
    };

    // JSON を stirng に変換
    const data = JSON.stringify({'files': uploadFiles});

    // XMLHTTPRequest で POST 送信を行う
    axios.post('https://*.execute-api.ap-northeast-1.amazonaws.com/prod',
    data,
    {
      headers: {
        'Content-Type': 'application/json'
      }
    }
    ).then((res) =>  {
      alert('Success!');
    }).catch((e) => {
      alert('Error!' + e);
    });    

  }

  public render() {
    return (
      <Dropzone onDrop={this.onDrop}>
        {({getRootProps, getInputProps}) => (
          <section>
            <div className="fileArea" {...getRootProps()}>
              <input {...getInputProps()} />
              <p>Drag 'n' drop some files here, or click to select files</p>
            </div>
          </section>
        )}
      </Dropzone>
    );
  }
}

export default App;

// Convert file to base64 string
export const fileToBase64 = async (file: File) => {
  return new Promise(resolve => {
    const reader = new FileReader();

    // Read file content on file loaded event
    reader.onload = (event: any) => {
      resolve(event.target.result);
    };
    
    // Convert data to base64 
    reader.readAsDataURL(file);
  });
};

// Encode Base64
export const toBase64 = async (file: File) => {
  return fileToBase64(file).then((result: string) => {
    return result;
  });
}

App.cssは以下のように記述します。

.App {
  text-align: center;
}

.fileArea {
  width: 100%;
  height: 200px;
  background-color: skyblue;
}

実行してみる

Visual Studio Code で、以下のコマンドでプロジェクトをビルド&実行します。

> yarn start

以下の画面が表示されます。

f:id:fnyablog:20190323101040p:plain:w480

JPEG、PNG、Excel の複数ファイルを選択します。

f:id:fnyablog:20190323101443p:plain:w480

処理成功のメッセージボックスが表示されました。

f:id:fnyablog:20190323101530p:plain:w320

S3 で確認すると4ファイル正しくアップロードされていますね。

f:id:fnyablog:20190323101822p:plain:w480

念のため PNG 画像を表示すると、問題なくアップロードされていることが分かります。

f:id:fnyablog:20190323102117p:plain:w480

ハマりどころ

概要

React + API Gateway + Lambda(C#) + S3 でなにがハマるかいうと、情報がとことんないところです。

本当にこの方法はサポートされいるのかと不安になるくらい情報がありません。

また、エラーが発生しても解決するのが困難で時間がかかってしまいます。

どこで問題が発生しているのか切り分けるのも難しいですしね。

ハマりどころだらけでしたが、実際の実装でハマったところをご紹介します。

API Gateway

API Gateway の情報が本当になく、バイナリファイルをサポートしているという情報を元にいろいろ調べたり試したりしたのですが結局うまくいきませんでした。これだけで何日も使いました。

結局、ブラウザの POST リクエストによるファイルアップロードはそもそもサポートしていないという結論に落ち着きました(2019/03/23現在)。仕方がないので JavaScript でファイルを Base64 にエンコードしてそれをアップロードすることにしました。

API の設定でも、「Lambda統合プロキシの使用」にチェックを入れないという情報がネットに多く、マッピングテンプレートを使用する旨の情報が多いです。

docs.aws.amazon.com

もしかしたらマッピングテンプレートの方法でもうまくいく方法があるのかもしれませんが、Base64 にエンコードされたファイルはどうも JSON にパースすることができないらしく断念しました。

「Lambda統合プロキシの使用」にチェックを入れる方法を試してみたところ、苦戦はしたもののなんとか Base64 にエンコードされたファイルを JSON にパースすることに成功しました。

これでようやく Lambda 関数に情報を引き渡すことができた訳です。

Lambda(C#)

Lambda(C#) の情報はさらになく、API Gateway から渡される情報が一体なんのクラスにマッピングされるかの情報すらろくにありませんでした。

結局、APIGatewayProxyRequest で情報を受け取ることができたのですが、これも明確な情報はどこにもなく、海外の掲示板のサンプルコードに記述があったので試してみたらたまたま動いたという感じです。

また、API Gateway と Lambda は複合技で難しくなってしまうので、正解の組み合わせを見つけることも困難でしたね。

あと、Base64 にエンコードする際、padding という余計な情報が先頭に入っているので、Base64 をエンコードしたテキストをそのままではエンコードできないのもきつかったですね。

Base64 でエンコードされた文字列は、data:image/png;base64,iVBORw0KGgo...のようになっていて、data:image/png;base64,の部分が padding で余計です。なので、デコードする場合は padding を取り除いてあげないといけなくて、しかも'-'を+に、_/にデコード前に置換する必要があり、なにそれ?という感じです。

stackoverflow.com

React

React もハマりましたね。

TypeScript でプロジェクトを作成したこともあるのですが、TypeScript で React 関連のパッケージが必ず動くとは限らず、ものによっては使用を断念したりしました。

Base64 にエンコードする方法も結構難しかったです。

結局、自分では解決できず以下の記事を参考にさせてもらいました。

medium.com

地味にハマったのが、forEach 文で async/await を使ったのですが、どうしても非同期処理がうまくいきませんでした。

async/await は forEach 文では正常に動作せず、以下の記事にあるように for (const xxx of zzz) と書かないとうまくいきません。

qiita.com

便利なツール

API の開発では、サーバーサイドに問題があるのか、クライアントサイドに問題があるのか切り分けるのがなかなか困難です。

そこで、便利なのが Fiddler と Postman というツールです。

Fiddler は Web サーバーとの HTTP(HTTPS) 通信をキャプチャし、リクエストが期待通りの内容になっているのかを調査することができます。HTTPS の通信についても、Fiddler の証明書をインストールすればキャプチャ可能です。

Postman は、API をテストするためのクライアントツールで、リクエスト内容を簡単にカスタマイズして API をテストすることができます。これはかなり便利ですね。但し、個人利用と小規模チームは無料ですが、仕事で使うなら通常は有料ツールになるのでご注意ください。

また、以下のサービスのおかげで Base64 のデバッグをすることができました。

lab.syncer.jp

おわりに

React を TypeScript で作成して、画像や Excel といったバイナリファイルを API Gateway + Lambda(C#) を経由してアップロードして S3 に保存するのは、かなり難しかったですが、一度サンプルができてしまえば何とかなります。

この記事が、迷える子羊達のお役に立ちますように。