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

f:id:Keisuke69:20120630174845j:plain

はじめに

こちらの投稿の続きにあたります。

React NativeでAWSのS3にファイルアップロードする処理を実装するにあたり、AWS Amplifyを使わずにAWS SDKを使って実装しようというものです。理由などは前回の記事に書いているのでそちらを参照してください。

前回は普通にアップロードするところまでは簡単にできるっていうところまでやったので、今回は本命のマルチパートアップロードを実装してみます。

マルチパートアップロードをするには

AWS SDKを使ってマルチパートアップロードをするには、1. マルチパートアップロードの開始、 2. 各パートのアップロード、3. マルチパートアップロードの完了、という大きくわけて3つのステップが必要です。それぞれJSのAWS SDKだと、CreateMultipartUploadCommandUploadPartCommandCompleteMultipartUploadCommandというものが用意されていますのでそれを実行していくだけなんですが、ポイントはUploadPartCommand周りの処理かと思います。

基本的にはアップロードしたいファイルを読み込んでそれを一定のサイズごとに分割した上でアップロードするという処理が必要になります。

さっそく実装する

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

S3への接続とかして、ファイルの読み込みまでも前回と変わらないのでそちらを参照するか、本記事の最後にソースコード全部載せるのでそちらを見ていただきたい。以下はそれらが終わっている前提。

まずは先のとおりCreateMultipartUploadしていく。

      const createUploadParams = {
        Bucket: bucket,
        Key: file.name,
      };
      const multipartUploadResponse = await client.send(
        new CreateMultipartUploadCommand(createUploadParams),
      );

      const uploadId = multipartUploadResponse.UploadId;

ここはなんてことはない。レスポンスに含まれるUploadIdの値が後続の処理で必要になるくらい。

続いて本日のメインパートであるファイルを分割して送る箇所。大まかな流れとしては、事前に読み込んだファイルを任意のサイズで分割し、それらを先のUploadIdを付与して送りつける。

      const chankSize = 1024 * 1024 * 5;
      const fileSize = file.size;

      //最後のチャンク(余り)はチャンクサイズ以下になる
      const numOfChank = Math.ceil(fileSize / chankSize);
      let remainSize = fileSize;

      const uploading = [];

      for (let i = 1; i <= numOfChank; i++) {
        let start = fileSize - remainSize;
        let end =
          chankSize < start + remainSize ? chankSize : start + remainSize;

        if (i > 1) {
          const remaining = chankSize < remainSize ? chankSize : remainSize;
          end = start + remaining;
          start += 1;
        }

        const uploadParams: UploadPartCommandInput = {
          Body: buffer.slice(start, end + 1),
          Bucket: bucket,
          Key: file.name,
          UploadId: uploadId,
          PartNumber: i,
        };

        uploading.push(client.send(new UploadPartCommand(uploadParams)));

        remainSize =
          remainSize - (chankSize < remainSize ? chankSize : remainSize);
      }

      const result = await Promise.all(uploading);

      const uploaded = [];
      result.map((uploadPartResponse, i) => {
        uploaded.push({PartNumber: i + 1, ETag: uploadPartResponse.ETag});
      });

ファイルを分割するところについては、元のファイルを格納しているBufferから指定したサイズごとに取り出している感じだ。取り出すのは普通にArrayBufferをsliceで取り出しているだけなので、そのsliceで指定するインデックスを求めていけばいい。

基本的には元のファイルサイズを元に何分割するかをまず計算(numOfChank)し、その数だけforでループしている。ファイルサイズから残りサイズを引いてスタートを決めてそれにチャンクサイズ分を足しているだけ。あとは最後のパートの端数の処理をちょっとしてるだけ。

それらが求められたらUploadPartする。その際、先で求めたUploadIdとパートの番号を指定する。パートの番号はforループのインデックスと同じにしている。

もう一つだけあるとすれば、forループの中でUploadPartを普通に実行してしまうとそれは直列に実行されてしまう。せっかくのマルチパートアップロードなのにそれはもったいないので非同期に並列で実行するようにする。uploadingという配列にpushして後でawait Promise.all(uploading);してるあたり。

そしてアップロード済の結果をuploadedとう配列にパート番号とそのETagの値をペアで格納していく。これは最後にCompleteMultipartUploadするときに必要になる。

あと、ここらの変数名がuploaduploadinguplodedみたいに適当につけた似た名前になっているのは分かりづらくて申し訳ない。

そして最後はCompletMultipartUploadする。

      const completeParams = {
        Bucket: bucket,
        Key: file.name,
        UploadId: uploadId,
        MultipartUpload: {
          Parts: uploaded,
        },
      };
      const completeUploadCommand = new CompleteMultipartUploadCommand(
        completeParams,
      );
      const response = await client.send(completeUploadCommand);

先ほどの各パートのアップロード結果をパラメータに入れて送るくらいかな。

まとめ

以上、こんな感じでマルチパートアップロードまではできるようになった。実際に手元の環境だとシングルパートだと270秒ほどかかった大きい動画ファイルが90秒くらいでアップロードできるようになった。なお、このうち10秒ちょっとはファイルを読み込んだりbase64のデコードをしたりのにかかる時間だ。ただしこれらはシミュレータで実行しているので実機だともう少し速いかもしれない。

さて、ここまで来たら残すはマルチパートアップロードの中断と再開、中止、進捗の確認あたりの実装を残すだけだ。といっても実はこの辺が一番面倒そうだったりする。

このあたり、AWS AmplifyだとStorage.put()だけでやってくれるのでたしかに簡単・お手軽ではある。なので普通にAWS Amplifyで問題ないユースケースならそちらを使うのがいいとは思う。

ソースコード

余計なコンソール出力が仕込まれているけど気にしないで。

import React from 'react';
import {Button, StyleSheet, View} from 'react-native';
import {
  S3Client,
  CreateMultipartUploadCommand,
  UploadPartCommandInput,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
} 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';

import 'react-native-get-random-values';
import 'react-native-url-polyfill/auto';

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,
        };
        console.log({file});
        uploadVideo(file);
      }
    });
  };

  const uploadVideo = async file => {
    const upload_start = new Date();

    const fileData = await RNFS.readFile(file.uri, 'base64');
    const read_end = new Date();
    const buffer = decode(fileData);
    const decode_end = new Date();

    console.log(`Read: ${read_end - upload_start}`);
    console.log(`Decode: ${decode_end - read_end}`);

    try {
      const createUploadParams = {
        Bucket: bucket,
        Key: file.name,
      };
      const multipartUploadResponse = await client.send(
        new CreateMultipartUploadCommand(createUploadParams),
      );

      const uploadId = multipartUploadResponse.UploadId;
      console.log(`Upload initiated. UploadId: ${uploadId}`);

      const chankSize = 1024 * 1024 * 5;
      const fileSize = file.size;
      console.log({fileSize});

      //最後のチャンクはチャンクサイズ以下になる
      const numOfChank = Math.ceil(fileSize / chankSize);
      let remainSize = fileSize;

      const uploading = [];

      for (let i = 1; i <= numOfChank; i++) {
        let start = fileSize - remainSize;
        let end =
          chankSize < start + remainSize ? chankSize : start + remainSize;

        if (i > 1) {
          const remaining = chankSize < remainSize ? chankSize : remainSize;
          end = start + remaining;
          start += 1;
        }

        const uploadParams: UploadPartCommandInput = {
          Body: buffer.slice(start, end + 1),
          Bucket: bucket,
          Key: file.name,
          UploadId: uploadId,
          PartNumber: i,
        };

        uploading.push(client.send(new UploadPartCommand(uploadParams)));

        remainSize =
          remainSize - (chankSize < remainSize ? chankSize : remainSize);
      }

      const result = await Promise.all(uploading);

      const uploaded = [];
      result.map((uploadPartResponse, i) => {
        uploaded.push({PartNumber: i + 1, ETag: uploadPartResponse.ETag});
      });

      const completeParams = {
        Bucket: bucket,
        Key: file.name,
        UploadId: uploadId,
        MultipartUpload: {
          Parts: uploaded,
        },
      };
      const completeUploadCommand = new CompleteMultipartUploadCommand(
        completeParams,
      );
      const response = await client.send(completeUploadCommand);
      const upload_end = new Date();

      console.log('success');
      console.log(`Done in ${upload_end - upload_start} msec`);
    } 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, 2023   プライバシーポリシー