React NativeでAWS Amplifyを使わずS3にファイルアップロードしたい

f:id:Keisuke69:20120630174845j:plain

はじめに

AWSのS3にファイルをアップロードするにあたり、今だとAWS Amplifyを使ったアップロードを進められることが多いと思う。

だが、今回はAWS Amplifyを使わずにReact NativeでS3にファイルアップロードしてみようという話。

AWS Amplifyでアップロードする場合

AWS Amplify Libraryを使ってアップロードすることのメリットは一応ある。それはマルチパートアップロードの実装がとても簡単にできるということだ。というよりも、そもそもマルチパートでアップロードをするかどうかを意識する必要がほとんどない。

AWS Amplify Libraryに用意されているStorage.put()を使えば必要に応じてチャンクに分割して送ってくれるのだ。しかもResume処理なんかも自前で実装しなくてもいい。

なぜAWS Amplifyを使わずにアップロードしたいのか

先にあげたように普通に考えればAWS Amplifyを使ってS3にアップロードする処理を書いたほうが圧倒的に楽だ。いや、楽だと思っていた。

これに関しては半分正解だ。確かにあまり深く考えずともマルチパートでアップロードしてくれるし、一時停止や再開などの実装をする必要もない。

だが、自分の場合は大きなファイルのアップロードで問題が発生した。詳細は割愛するがAWS AmplifyのStorage.put()で大きいサイズのファイルを扱おうとするとOut of Memoryが発生してしまう。この問題はIssueもあがっているみたいで詳細も調べたものの、今回の本題ではないので細かく説明などはしない。

ファイルサイズを小さくすれば問題ないのでは?とか分割したらどうか?とかも思いついたしやってみたが、一回の処理は問題ないものの連続すると結局AmplifyのStorage.putの実装方法の兼ね合いでそのうちOOMで死ぬことになった。

ちなみにReact Nativeではfetchにも少し問題があって160MB以上くらいのファイルを扱おうとするとこちらもエラーになる。これもIssueあがってるので興味ある人はググってもらうといい。

というわけでAWS Amplifyを使わずにアップロードをする必要に迫られたというのが正しい。

さっそくReact Nativeからアップロードしてみる

AWS Amplifyを使わないということはつまりAWS SDKを使って自前で実装するということだ。

React Nativeの場合はiOSAndroid向けのSDKではなく、普通にJavaScript向けのSDKを利用すればいい。とりあえず今回はマルチパートではなくシンプルに動画をアップロードする処理を試してみる。

前提としてAWSアカウントはもちろん、アップロード対象のS3バケット、認証のためのCognito Identity Pool、それに紐付けるIAMロールを事前に用意しておく必要がある。このあたりは本題ではないので割愛。

さて、適当なReact Nativeなアプリを用意したら、まずはこのあたりのパッケージをインストールする。 AWS関連に加えて、アップロードする動画をiOSでいうところのカメラロールから選択するためのピッカーやファイル読み込み周りで必要になるものも入れている。ピッカーはreact-native-image-pickerファイルシステムを扱うのにreact-native-fsbase64で読み込んだファイルをデコードしたりするためのバッファ関連はbufferを使ってもいいが今回はbase64-arraybufferを使うことにした。

yarn add @aws-sdk/client-cognito-identity
yarn add @aws-sdk/credential-provider-cognito-identity
yarn add @aws-sdk/client-s3
yarn add react-native-fs
yarn add react-native-image-picker
yarn add base64-arraybuffer

で、肝心の処理はこうだ。S3のクライアント接続周りはどこかで済ませておく。

  const region = 'ap-northeast-1';
  const bucket = 'sample-bucket';
  const poolId = 'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

  const client = new S3Client({
    region,
    credentials: fromCognitoIdentityPool({
      client: new CognitoIdentityClient({region}),
      identityPoolId: poolId,
    }),
  });

リージョンやバケット名、Cognito Identity PoolのIDなんかは自分のもので。

次に、react-native-image-pickerを使ってアップロードするビデオを選択する部分。ちょっと前のサンプルとかだとlaunchImageLibrary()ではなく、showImagePicker()を使ったものが多いが最近のバージョンからはこのAPIは削除されていて、今だとlaunchImageLibrary()を使うようになっているので注意。

  const chooseMovie = () => {
    let options = {
      mediaType: 'video',
    };

    launchImageLibrary(options, response => {
      if (response.didCancel) {
        console.log('Cancelled');
      } else if (response.error) {
        console.log('Error: ', response.error);
      } else {
        const file = {
          uri: response.assets[0].uri,
          name: response.assets[0].fileName,
          type: 'video/quicktime',
          size: response.assets[0].fileSize,
        };
        uploadVideo(file);
      }
    });
  };

で、この中から呼び出しているuploadVideo()がこちら。

  const uploadVideo = async file => {
    let contentType = 'video/quicktime';
    let contentDeposition = 'inline;filename="' + file.name + '"';
    const fileData = await RNFS.readFile(file.uri, 'base64');
    const buffer = decode(fileData);

    const params = {
      Bucket: bucket,
      Key: file.name,
      Body: buffer,
      ContentDisposition: contentDeposition,
      ContentType: contentType,
    };

    try {
      const response = await client.send(new PutObjectCommand(params));
      console.log('success');
    } catch (error) {
      console.log({error});
    }
  };

やっていることはシンプル。

  1. react-native-fsreadFileで選択したビデオファイルをbase64で読み込み、
  2. それをデコードしてArrayBufferに格納する
  3. デコードしたオブジェクトを含むパラメータを設定して、
  4. PutObjectCommendをnewしてsendに渡す

これだけでひとまずできあがり。 上記を含むソースコード全文は文末に。

実行

ここまで来たらあとは実行するだけ。僕の場合はiOSのシミュレータで試す。プロジェクトのiOSフォルダに移動していつものやつを実行するだけだ。

pod install
yarn ios

こんな感じで動く。なお、この例ではアップロード後のメッセージとかを画面上でハンドリングしていないので成否はコンソールのログを見る必要がある。

f:id:Keisuke69:20220330112427p:plain

f:id:Keisuke69:20220330143337p:plain

子どもが写ってるが気にしないで欲しい。ちなみにこのテストをしているときにシミュレータもiCloudでカメラロールの同期が可能なことを知った。

まとめ

という感じでマルチパートでなければAmplifyのStorage.put()を使うのと大差ない。Storage.put()も結局ファイル読み込み周りは自分で書かないといけないので。

なので、次はマルチパートを実装してみよう。どのくらい面倒なのか。

ソースコード

ソースコード全文はこちら。今回は適当に試すだけなのでApp.jsxにベタ書きしている。

import React from 'react';
import {Button, StyleSheet, View} from 'react-native';
import {S3Client, PutObjectCommand} from '@aws-sdk/client-s3';
import {CognitoIdentityClient} from '@aws-sdk/client-cognito-identity';
import {fromCognitoIdentityPool} from '@aws-sdk/credential-provider-cognito-identity';
import {launchImageLibrary} from 'react-native-image-picker';
import RNFS from 'react-native-fs';
import {decode} from 'base64-arraybuffer';

const App = () => {
  const region = 'ap-northeast-1';
  const bucket = 'sample-bucket';
  const poolId = 'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

  const client = new S3Client({
    region,
    credentials: fromCognitoIdentityPool({
      client: new CognitoIdentityClient({region}),
      identityPoolId: poolId,
    }),
  });

  const chooseMovie = () => {
    let options = {
      mediaType: 'video',
    };

    launchImageLibrary(options, response => {
      if (response.didCancel) {
        console.log('Cancelled');
      } else if (response.error) {
        console.log('Error: ', response.error);
      } else {
        const file = {
          uri: response.assets[0].uri,
          name: response.assets[0].fileName,
          type: 'video/quicktime',
          size: response.assets[0].fileSize,
        };
        uploadVideo(file);
      }
    });
  };

  const uploadVideo = async file => {
    let contentType = 'video/quicktime';
    let contentDeposition = 'inline;filename="' + file.name + '"';
    const fileData = await RNFS.readFile(file.uri, 'base64');
    const buffer = decode(fileData);

    const params = {
      Bucket: bucket,
      Key: file.name,
      Body: buffer,
      ContentDisposition: contentDeposition,
      ContentType: contentType,
    };

    try {
      const response = await client.send(new PutObjectCommand(params));
      console.log('success');
    } catch (error) {
      console.log({error});
    }
  };

  return (
    <View style={styles.container}>
      <View>
        <Button
          backroundColor="#68a0cf"
          title="Choose file"
          onPress={chooseMovie}
        />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default App;

©Keisuke Nishitani, 2020   プライバシーポリシー