脱Firestoreするために考えていること(追記あり)

FirebaseのFirestoreをやめることにしたので雑なメモを残しておく。なお、まだ走り始めたばかりなので、内容には間違いや考慮不足も多数含まれる可能性があるので読む人はその点注意を。あと、あくまでも雑なメモなので細かいところは書いていない。

なぜ脱Firestoreするのか?

まず、脱Firestoreする理由は

  • ユースケースとしてFirestoreでは対応できないケースが出てきた
  • ニーズが変わってきて機能拡張の足かせと感じることが増えた
  • 将来的なビジネスロードマップ上の布石
  • モバイルとWebアプリケーションが存在する環境であるため、共通のロジックをサーバーサイドにまとめたほうが効率的と感じることも増えた

1つめ、2つめは一番わかりやすいこととしてSQLで言うところの集約関数が必要になったということ。これまでは運用的に月イチでの集計で良かったため、月イチでBigQuery(BQ)にインポートしてBQ側でSQL使って実行していた。しかし、これらに加えてユーザ操作によってその時点での集計結果を得たいという要件が生まれた。

通常こういったケースであってもリアルタイム性を問わないのであればバッチ処理的にであったり、更新等をトリガーとしたFunctionsによるバックエンドでの集計処理を行い、その集計結果を別のコレクションに格納しておくといったことで対応することも多いと思う。しかし、今回は値の更新頻度がもう少し高いこともあってFirestoreに依存した処理を継ぎ接ぎ的に実装するよりもRDBを利用したほうがいいと結論づけている。

3つめは詳細についてはあまり言えないのだがそもそも政治的・環境的理由でFirestoreを使えないケースが近い将来に見えつつあるということだ。

4つめは単純に単一コレクションをクエリして表示するだけなら特に問題ないと思っている。だが、前述の集計機能であったり、単にDBの値をクエリするだけでなくクエリの前後にロジックを含むような場合や複数のコレクションの値を使って処理をする場合にAPIとしてまとめられるといいと感じている。

というような理由であり決してFirestoreそのものがダメだとかそういう話ではない。あくまでもマッチしなくなってきたということだけだし、人によってはFirestoreでもっと頑張れると思う人もいるだろうが自分はそこで頑張らない選択をするだけだ

なぜGraphQLではなくREST APIなのか?

さて、脱FirestoreするにあたってGraphQLという選択肢もあったが今回はREST APIという選択肢をとった。

まず、GraphQLの利点として一般的に以下のようなものがあげられる。

  • クライアントで取得したいデータを決められる。その結果、ちょっとした仕様変更にAPI側の修正とリリースが不要。
  • RESTのように固定的なリクエスト/レスポンスに従うわけではないので必要なデータのみを含むレスポンスとなり効率がいい
  • 型がある
  • フロントエンドのためのゲートウェイとして振る舞える。いわゆるフェデレーション的なこともできる

正直なところRESTと比べると利点しかない気がしている。だが、今回はRESTを採用した。一番の理由はGraphQLでアプリケーション開発をするだけのエコシステムがまだこなれていないと感じているからだ。例えば自分の場合はチーム体制などの問題もありできるだけマネージド・サービスを使いたいと考えている。そうするとこれっていうサービスがないのが実態だ。

もちろんいくつかはあるのは知ってるし、普段メインで使っているAWSにもAppSyncというサービスがあるのも知っている。いくつかあるサービスを使うのもいいかもしれないがメインの部分に使うのはさすがに感情的に不安なところもあるし、AppSyncはとある機能のためにGraphQLのsubscriptionの機能が必要で利用しているがVTLとか書く時点でなかなか辛い。VTLは特にデバッグが辛い。というわけでRESTである。これには異論・反論あるだろうが。

移行にあたって検討したこと、決め事

実際に移行するにあたり以下のようなスタックでAPIを実装していくことにした。

RDBPostgreSQLなのはPostGISを使う必要があったからだ。

実際に上記のようなスタックで移行していくにあたり検討の多くはデータ格納先となるRDBに既存のデータをどう格納していくか、だ。つまりテーブル設計だがFirestoreはご存知のとおりNoSQLと呼ばれるタイプのDBであり、RDBではない。今回のようなこともあろうかとあまりFirestoreに最適化しすぎていない、比較的シンプルな構成で使ってきたがいざ考え出すとそれなりに悩ましいところも多い。

具体的には以下のような点が自分たちの場合は検討が必要だった。

  • ドキュメントIDをどう扱うか
  • サブコレクションをどう扱うか
  • 配列やマップといったフィールドのタイプをどう扱うか
  • Firebase Authenticationとセキュリティルールで実現しているセキュリティ機能をどうするか

ここからの話をする前にFirestoreのデータの格納についておさらいがてら振り返っておく。

FirestoreはRDBではないのでRDBには存在するテーブルや行といったものがない。その代わりにデータは『ドキュメント』として扱われ、ドキュメントをまとめたものとして『コレクション』という概念がある。そして『ドキュメント』はJSのオブジェクトのようなものでフィールドとその値が含まれている。そしてこのフィールドは可変だ。

ドキュメントIDをどう扱うか

これは移行後のRDBのテーブルでプライマリーキーをどうするかという問題だ。

Firestoreには相当するものとしてドキュメントIDというものがある。このドキュメントIDは自動でセットされるものを利用している場合、06XWvXOqtUmLR2BnC7fZみたいな文字列となる。RDBのテーブルではプライマリーキーをどうするか考える必要があるが、DBのシーケンスなどの機能に任せたい場合は数値の型になるので使えない。そして別の値をキーにして再設定する場合、微妙にリレーションっぽいものが存在すると全てを書き換える必要も出てくる。

そうするとプライマリーキーとして文字列型で用意し、既存データはドキュメントIDをそのまま移行し、新規のデータについては別途キーを生成して払い出すしかなさそうだ。というわけでそうするのだがこのときRDB側のプライマリーキーをどうするか。これは別途検討を行った結果、今回はCUIDを使うことにする。

一意な識別子の生成でUUID/ULID/CUID/Nano IDなど検討してみた - Sweet Escape

ちなみに型についてはVARCHARもしくはTEXTを使う。ID列なので結果的に固定長になるものの、公式ドキュメントにも記載の通りPostgreSQLの場合はTEXTVARCHARはパフォーマンス的には同等だし、他のDBと異なりCHARが一番遅いとのことで使う利点はない。

There is no performance difference among these three types, apart from increased storage space when using the blank-padded type, and a few extra CPU cycles to check the length when storing into a length-constrained column. While character(n) has performance advantages in some other database systems, there is no such advantage in PostgreSQL; in fact character(n) is usually the slowest of the three because of its additional storage costs. In most situations text or character varying should be used instead.

VARCHARTEXTはパフォーマンス的に同等ということでありどちらでもいいと思う。実際のところ文字数を制限したところで見積もりが楽になるくらいなのでは?あとは標準SQLに含まれるかどうかとか?ORMを使うので独自方言を使うと互換性の問題でORMの利点を得られないケースがあると思うが、TEXTPrismaでもサポートされてるしって感じ。というわけで自分の場合は基本的にTEXTを使います。

外部キー制約については要検討だけど、基本的に既存のドキュメントIDをそのままIDとして格納するので制約を用意しても問題ないはず。後から設定するつもり。

サブコレクションをどう扱うか

サブコレクションとは特定のドキュメントに関連付けられたコレクションのことだ。つまり特定のドキュメントにぶら下がる形で定義されたコレクション。特定のドキュメントに配列とかMapでデータをネストして格納するのと何が違うかというとネストするデータのサイズが増えても親のドキュメントのサイズが変わらないということがある。ネストの場合はちょっとしたサイズなら問題ないがそれなりに大きいものを格納すると親ドキュメントをクエリするだけでまるっとデータが取得されるためかなり効率が悪くなる。

さて、そんなサブコレクションを使っている箇所がいくつかある。これをRDBに持っていくにあたってどうするかだが、ドキュメントをレコード、コレクションをテーブルだと見立てるとシンプルに関連する別テーブルと見れなくもない。というわけでサブコレクションに関しては別テーブルとして切り出し、コレクションの移行先を親テーブルとしてその子テーブルとする。

配列やマップといったフィールドのタイプをどう扱うか

サブコレクションのところで少し触れたが、Firestoreではデータを構造化するにあたり特定のドキュメントにネストすることも可能だ。その際、フィールドの型としては配列もしくはMapなどを利用する。サブコレクションを使うのではなくこちらを使うのは単純な構造のときやそれほどデータの量が大きくない場合、可変でない場合があげられると思う。

では、そんな配列やMapのデータを持つドキュメントをRDBに持っていくにはどうするか。正直なところ少し悩ましい。先のサブコレクションと異なりデータの数量は大きくないものの一つのドキュメントに複数存在しているという状況がほとんどだ。そんな状況でこれをサブコレクション同様に別テーブルに切り出すと親子関係を持つテーブルが大量になってしまうし、大量のJOINが発生してしまう。

配列に関してはPostgreSQLには配列型というものがあるのでこれを利用するのも一つの手だ。Prismaでも普通にサポートしている。MapについてはJSON型にするのがいいのだろうか。JSON型についてもPrismaはサポートしているようだし。

だがしかし、この配列型とJSON型を使うことにどうにも抵抗がある。そもそもこれらを使うと『正規化とは?』みたいなことになってしまう。

なお、配列に関してはGINインデックスで配列の各要素にインデックスを張れるがJSON型ではインデックスはサポートされない。その代わりJSONBという型が用意されていてこちらであればGINインデックスがサポートされている模様。JSON型は入力テキストのコピーがそのまま格納されるのに対して、JSONBはバイナリ形式で保存されるとのこと。JSONBは入力テキストを分解してバイナリにするため、入力時は少しオーバーヘッドがあるものの先のとおりGINインデックスの利用がサポートされるのだ。このJSONB型もPrismaではサポートしているのでJSON型を使うならば基本的にはJSONB型を利用するのがいいと思われる。

さて、それらを踏まえてである。それらを踏まえて今回は以下のとおりとした。

  • 配列フィールドはRDBにも配列型の列を用意してそのままそこに
  • Mapに関してはJSON/JSONB型を使うのではなく、展開して列として定義する

理由だが、まず既存のFirestore上のドキュメントに含まれる配列とMapのフィールドはどちらも要素の数としては多くないし、更新も行っていないケースが多かったのでそれらを別テーブルにするほうがSQL的なコストがかさむと考えた。

配列については要素数が可変なこともあるのでそのまま配列型で格納することとしている。

さて、Mapである。当初はJSON/JSONB型で格納することも考えていたが以下の理由でやめることにした。

  • 正規化崩れる(これは配列型も同じ)
  • 型が指定できない
  • クエリにRDBごとの方言が強めで、結果的にSQLの可読性が悪い
  • スキーマレスなのでJSON/JSONB型の列に何が格納されているかわかりづらい
  • 既存データではMapのキーの個数が可変なものがない

SQLの可読性に関してはORMであるPrismaが吸収してくれる部分もあるとは思うものの、$queryRawで生SQLに近い形で書くシチュエーションもまだまだあると思われる。

というわけでJSON/JSONB型を使うのはやめ、Mapで格納されているものについては列として展開して格納することにする。そうすると型も指定できるし。

追記: Mapの配列をどうするか

上記で配列はそのまま、Mapについては列として展開するとしたがその後早々に壁にあたってしまった。それは配列の要素としてMap型のデータを持っているケースである。例えばこんなデータ。

[
    { "name": "Scott", "age": 30},
    { "name": "John", "age": 35},
    { "name": "Bill", "age": 25}
]

JSON/JSONB型の誘惑に屈しそうである。JSON型の配列にすれば解決ではある。だが上記理由もあり使いたくない。

そこで悩んだり参考書籍にあたった結果、Mapの配列に関してはこういう構造に置き換えることとした。

列名 データ型 備考
id text 主キー、CUID
sequense integer いわゆる配列の要素番号に相当
name text Mapに含まれるキー
age integer Mapに含まれるキー

これはいわゆる『行持ちのテーブル』などと呼ばれる方式ですね。

なお、今回の事例ではMapの配列として保持されているデータとしては位置情報が多かったのだけれども、これに関してはPostGISのGEOMETRY型の列を用意するだけで解決する。例えば以下のようなデータ構造の場合。これはとあるルートの情報。

[
  {
    "latitude": -73.993433,
    "longitude": 40.736274
  },
  {
    "latitude": -73.993632,
    "longitude": 40.736007
  },
  {
    "latitude": -73.984937,
    "longitude": 40.732353
  },
  {
    "latitude": -73.986374,
    "longitude": 40.730382
  },
  {
    "latitude": -73.98686,
    "longitude": 40.730587
  }
]

実はFirestoreでは座標を扱うためのgeopointという型があるのだが使っていなかったりする。その代わりに上記のようなlongitudelatitudeというキーを持つMapの配列として保存されている。

これがPostGISであればGEOMETRY(LineString, 4326)みたいな列を一つ用意するだけで済む。

Firebase Authenticationとセキュリティルールで実現しているセキュリティ機能をどうするか

これまでFirestoreだけでなく認証にはFirebase Authenticationを利用してきており、Firestoreに格納されたデータへの権限チェックはセキュリティルールを使って実現されていた。シンプルに認証済のユーザであるかのチェックの後、自身の権限をチェックして操作対象のドキュメントを触れるかどうかをチェックして弾いていただけである。

これに関してはセキュリティルールは使えなくなるので自前で相当のものを実装するしかないと考えている。ここはやむなしかと。また、あくまでも今回やめるのはFirestoreだけでありFirebase Authenticationについては使い続ける予定だ。

どうするか。

現時点ではFirebase Authenticationでサインインすると取得できるID Tokenをサーバーサイドに送り、サーバーサイドではFirebase Admin SDKを利用してそのTokenを検証、問題なければログイン済ユーザ情報を元に権限チェックということを考えている。

クライアントサイドではID Tokenは firebase.auth().currentUser.getIdToken()で取得可能だし、サーバーサイドでは getAuth().verifyIdToken(idToken)で検証ができる。

また、Admin SDKを使わずとも任意のJWTライブラリを用いて検証することも可能だ。送られてきたIDトークンをデコードするとペイロードsubおよびuser_idというキーとその値があることがわかる。ここにはFirebase AuthenticationのUIDが格納されているのでそれを用いてRDBに保存したユーザ情報を引っ張るなんてことができる。また、emailとパスワードで認証している場合などはemailというキーにサインインに使用されたemailアドレスが格納されているのでそれを使うことも可能。

いずれにせよ、Firebase AuthenticationのID TokenとRDBに保管したユーザ情報を用いて権限管理を自前で実装することはそんなに難しくなさそう。

では実際にどんなテーブル設計にするのか

基本的には上記の方針に従いつつ、既存のコレクションをドキュメントIDを主キーとして設定したテーブルとして用意していく。テーブルのカラムはドキュメントのフィールドに対応させる。カラム名については既存のものをそのまま使う感じでひとまず機械的にやってしまう。

テーブルができたらあとはCRUD作成おじさんとなって一通りのテーブルに対するRESTfulなCRUDAPIを用意していく。もちろん既存のアプリケーションのクエリを確認しつつ、検索条件にあわせたAPIも用意していく場合もあるがここまではそんなに難しくないと思っている。

なお、このタイミングで負債となっているまずいDB設計についても直してしまいたいところ。

次にやること

それは移行の過渡期のデータをどうするかの検討だ。一通りのAPIができたら実際の移行、特にデータの移行について検討を行う必要がある。Firestoreを使ったアプリケーションはすでに利用されている。なので既存データをどうサービスの中断を限りなく少なく移行していくかという問題がある。

これから検討していくのだが、基本的にバッチ処理的にデータを移行した上でFirestoreのトリガーを用いたファンクションで連携しつつどこかのタイミングでアプリを切り替えるということになると思う。問題はモバイルアプリだがそれも含めてこのあたりに関しては内容的に公開できるような話ではない気がしている。

Amplify ConsoleでCORSの設定を行う

AWSのAmpify ConsoleでCORSの設定が必要になったんだけど、やり方についてググっても意外とドンピシャな情報がなかったのでメモ。

結論から言うと特段それようの設定があるわけではなくベタにヘッダを指定するだけだった。これはAmplify ConsoleのカスタムヘッダでCORSで必要となる一連の設定をするだけでいい。

この設定はマネージメントコンソールからもできるし、プロジェクトのトップディレクトリ直下にcustomHeaders.ymlというファイルに記述しておくことも可能。

マネージメントコンソールからやる場合はアプリを選択してカスタムヘッダの設定画面を開けばエディタがあるのでそこに直接記述する。記述したものを後からダウンロードすることも可能。

こんな感じの内容をYAML形式で記述する。

customHeaders:
  - pattern: '*.json'
    headers: 
    - key: 'Access-Control-Allow-Headers'
      value: '*'
    - key: 'Access-Control-Allow-Methods'
      value: 'GET'
    - key: 'Access-Control-Allow-Origin'
      value: '*'

patternでは対象となるリソースを指定する。ここではルートディレクトリにあるjsonファイルすべてを対象にしているが、特定のパス配下のjsonファイルだけを指定したい場合はパスも含めて記述すればいい。当然jsonではなくJSのファイルも指定可能だ。

また、今回はサンプルなのでOriginはワイルドカードで指定している。特定のオリジンのみ許可したい場合はここをhttps://www.example.com/みたいな感じで指定すればいい。

メソッドも同様だ。サンプルではGETのみ許可しているが他のメソッドも許可する場合はカンマ区切りで指定する。GET, POST, PUT, OPTIONS, DELETEみたいな感じで。

今回は省略しているが、もちろんAlllow-Control-Expose-Headersなんかも指定可能。

マネージメントコンソールの場合は記述して、保存したらその時点で有効になる。

Amazon Cognito Identity Poolの外部プロバイダとしてFirebase Authenticationを使う

f:id:Keisuke69:20220411153224p:plain

はじめに

AWSには認証・認可のサービスとしてAmazon Cognitoというものが存在します。ややこしいのですが、認証のためのコンポーネントAmazon Cognito user pools(以下、user pool)で認可のためのコンポーネントAmazon Cognito identity pools (以下、identity pool)です。ちなみにidentity poolのほうはfederated identityと表記されている場合もあります。

そのうち、今回はidentity poolの話です。

identity poolは認証機構は持たず、大雑把にいうと任意のログインプロバイダで認証されたユーザに対してIAMロールが設定されたidを紐付けた上でテンポラリのAWSクレデンシャルを提供するといったサービスです。

この任意のログインプロバイダとしてFacebookTwitterなどがあるんですが、それらに加えてOpenID Connect(以下、OIDC)というオープンな標準に準拠したログインプロバイダであれば利用できることになってます、一応。

今回はこのログインプロバイダとしてFirebase Authenticationを使ってCognitoと連携していきたいと思います。

IdPの設定

Amazon Cognito identity poolsでログインプロバイダとしてOIDCのプロバイダを利用するには先にIAM側でIdentity Providerとして登録しておく必要があります。Cognito側ではその登録済のIdentity Providerを選択するという流れになります。

というわけでまず、IAMで登録します。IAMで登録しようとするとこんな画面で情報入力が求められます。

f:id:Keisuke69:20220411145522p:plain

だがしかし、Firebase Authenticationを使う場合に何をどう入力したらいいのかわかりません。Firebaseのドキュメントにもこのあたりあまり深く書かれてないのです。

が、ググりまくったのと試行錯誤の結果わかりました。以下のように入力すれば大丈夫です。

Provider URL: https://securetoken.google.com/ Audience:

ポイントはAudienceにFirebase Client IDを入れることです。これで保存したら完了です。

あとは、Amazon Cognitoのidentity poolの編集画面で、Authenticate ProvidersでOpenIDを選択し Enable identity providerのチェックを入れるだけです。

アプリ側を実装する

AWSの設定はぶっちゃけ大したことないです。問題はアプリ側。今回はJSでやります。SDKなんですがAmplifyではなくAWS SDKを直接使ってます。JSのSDKはv2とv3があってネットに転がってるサンプルはv2のものも多いのですが、今回はv3を使っています。なお、FirebaseのSDKもv8とv9ありますが今回はv9をインストールした上でv8互換の書き方になってます。

Firebase Authを使う場合(というかプロバイダの場合も)、大まかな流れとしては以下になります。

  1. Firebase Authで認証する
  2. Firebase Authからログイン中のユーザのIDトークンを取得
  3. Firebase Authから取得したトークンをCognitoのIDに紐づけて取得
  4. クレデンシャルを取得
  5. 取得したクレデンシャルを使ってAWSのリソースを操作
  6. (Firebase Authでサインアウト)

1は普通にFirebase Authで認証するのと変わりないです。よくあるメールアドレスとパスワードならログイン画面を用意してsignInWithEmailAndPasswordを実行する感じです。 3, 4がちょっと面倒な手続きですがそれさえ終われば、5は普通にクレデンシャル(Access KeyとSecret Access Key)を使ってAWSAPIを呼び出すだけです。ここも変わりません。

というわけで関連するソースコードのサンプルだけ以下に。ここでは既存のFirebase Authのプロジェクトを使ったのとログイン画面を用意するのが面倒だったのでメールアドレスとパスワードをベタ書きして認証処理に渡していますが、普通はこんなことしてはいけません。また認証にメールアドレスとパスワード以外を使う場合は適宜読み替えてください。このあたりはFirebaseのドキュメントを参考にしてください。

また、今回はあくまでも認証認可周りだけのサンプルなのでまともにreturnもしていません。reactのApp.tsxApp内に'signIn'という関数をとりあえずその中で全部やってますが実際にはもうちょっとまともに考えたほうがいいと思います。

あと、このサンプルではS3を使うことを想定してますがその他のAWSのサービスはどれも同じ流れだと思います。クライアントがS3かそうじゃないかだけ。

事前に関連パッケージをyarnもしくはnpmでインストールしておいてください。このあたりを入れておきます。

@aws-sdk/client-cognito-identity
@aws-sdk/client-s3
@aws-sdk/credential-provider-cognito-identity
firebase

TypeScriptの場合は追加で以下も入れておきます。

@aws-sdk/types
@types/node

ソースコードはこんな感じです。一応Reactで書いてます。

import React from 'react';
import {S3Client} from '@aws-sdk/client-s3';
import {
  CognitoIdentityClient,
  GetIdCommand,
} from '@aws-sdk/client-cognito-identity';
import {fromCognitoIdentity} from '@aws-sdk/credential-provider-cognito-identity';

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

import firebase from 'firebase/compat/app';
import 'firebase/compat/auth';

const firebaseConfig = {
  apiKey: 'xxxxxxxxxxxxxxxxxxxxxxxxx',
  authDomain: 'xxxxxxxxxxxxxxxxx.firebaseapp.com',
  databaseURL: 'https://xxxxxxxxxxxxxxxxx.firebaseio.com',
  projectId: 'xxxxxxxxxxxxxxxxx',
  storageBucket: 'xxxxxxxxxxxxxxxxx.appspot.com',
  messagingSenderId: '1234567812345',
  appId: '1:1234567812345:xxx:abcderghijklmnopqr12345',
  measurementId: 'G-XXXXXXXXX',
};

firebase.initializeApp(firebaseConfig);

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

  const signIn = async () => {
    const auth = firebase.auth();
    let token;
    try {
      const user = await auth.signInWithEmailAndPassword(
        'ユーザID',
        'パスワード',
      );
    } catch (error) {
      console.log({error});
    }
    token = await firebase.auth().currentUser?.getIdToken();

    const getIdParmeters = {
      IdentityPoolId: poolId,
      Logins: {
        'securetoken.google.com/xxxxxxxxxxxxxxxxx': token!,
      },
    };

    const cognitoClient = new CognitoIdentityClient({region: region});
    const res = await cognitoClient.send(new GetIdCommand(getIdParmeters));

    const s3client = new S3Client({
      region: region,
      credentials: fromCognitoIdentity({
        client: cognitoClient,
        identityId: res.IdentityId!,
        logins: {
          'securetoken.google.com/xxxxxxxxxxxxxxxxx': token!,
        },
      }),
    });
    await firebase.auth().signOut();
  };

  return <></>;
};

export default App;

一応最後にサインアウトするのをお忘れなく。

まとめ

というわけであまり情報がないなかで試したものの無事にCognitoの外部プロバイダとしてFirebaseを利用することができました。いろんな事情でこのあたりは情報が少ないんだと思うのですがまあ少しでも役に立てば幸い。

そもそもFirebase AuthをOIDCプロバイダとして使えるっていうのがドキュメントでは少し曖昧な感じだったのですがいけそうです。

今回は試してないけれど、きっとUser PoolのFederation先としてFirebaseを連携することも同じような感じで可能なんだと思う。でもこれはあまりやる意味がないよね、きっと。

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;

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;

個人的に思うAWS Amplifyのいいところ、いまいちなところ

ちょっとそんな話をする機会があったのでついでにまとめておく。技術的な内容というよりはポエムに近いなぐり書きなので甘い部分は多々あると思う。

前提

大前提としてあくまでも僕個人の感覚だし、自分の置かれている状況を踏まえての話なのでフラットな比較評価ではない部分は多々あるし、異論は多いにあると思う。そしてバイアスがかなりかかった意見でもあるとは思っている。

まず、このまとめの前提となる環境は以下のような環境だ。

  • GraphQLを使ったプロダクトではない
  • フロントエンドとバックエンドはそれぞれ別のエンジニアがいる
  • フロントエンドはReact、TypeScript、Next.jsあたり、バックエンドはPythonでコンテナ。つまりLambdaとかのサーバーレスではない
  • 一言でAWS Amplifyと言っているがこの記事で述べていることの多くはAmplify LibraryおよびAmplify CLIについて

あと、以前にも同じような話を書いています。

www.keisuke69.net

今回、限定的とはいえ改めてAmplifyを使う機会があったのでそのインプレッションも含めて記載する。今回使ったのはAmplify LibraryとAmplify CLIだが、一部Amplify Consoleについても言及している。まとまりなくて申し訳ない。

いいところ

  • フロントエンドの人だけでもサーバーレスなバックエンドをなんとなく作れる(とはいえ本当に?)
  • Amplify Consoleを使うと静的サイトはもちろんSPAやSSRのサイトも簡単に環境を作れる
  • ユースケースをもとにフロントエンドに機能追加ができること
  • AWS AppSyncでGraphQLなアプリケーションの場合はとても良い体験を得られる

いまいちなところ

  • 楽になるかと思いきやバックエンドとなるAWSコンポーネントが透けて見えるので嫌でも意識せざるを得ない
  • ビルディングブロックなAWSの各サービスの上に無理やり載せている感が強く体験としてそれほど良くはない
  • 便利関数とかが用意されているが、それはむしろAmplify LibraryとかではなくSDK自体で実装してほしい
  • ユースケースからはみ出た途端にどう扱えばいいかわからなくなる

AWS Amplifyは何であって何でないのか

実際のところ、以前に書いたときの印象と大きく変わることはない。やはり、改めて考えてみても基本的にはAWS AppSync使ってGraphQLをやりたい場合以外は不要だと思った。

ではAWS Amplifyはいったい何を目指しているのか。

これについて、個人的な意見を述べると複雑化しすぎたAWSを専門家不要で使えるようにしたいんだと思う。

特に今のAWSデベロッパー体験があまりよくない。各種マネージドサービスが乱立した結果、インフラの運用管理がなくなって楽になったように思える反面でアプリケーションサイドの人からは何をどう使えばいいのかわからない状態だと言える。これはAWSの各サービスはビルディングブロックであるという思想も相まって、やりたいことをどう実現すればいいのかわからないという状況に陥りがちだ。調べればいいんだけどアプリケーションサイドの人間にはAWSのような足回り部分に興味がない人も多いのではないだろうか。

ちなみにAWSがちょっと得意な僕であっても面倒に思うことは多々ある。AWS AmplifyはそんなAWSをバックエンド、ひいてはそれらを構成するビルディングブロックとなる各AWSのサービスを意識せずに特にフロントエンドのアプリケーションエンジニアに使いやすくするために存在しているのだと思う。

事実、使いやすくはなっているのだろう。ただしそれはAWS Amplifyが想定するユースケースの範囲を出ない限定的な範囲においてはということになると思う。

対抗としてGoogleのFirebaseが挙げられると思うが、正直なところFirebaseのほうが何倍も使い勝手はいい。もちろんFirebaseにはFirebaseの問題点もある。例えばSDKのサイズが大きいことなどはよく言われる。また、サービスの信頼性の観点でも言及されることが多い。これに関しては数年前はたしかによくDBが止まったりしていた記憶はある。しかも何時間もだ。ただし、最近ではそういったことは起きていないように思われる。記憶の範囲、かつ実際に自分がプロダクションとして使っている範囲に限った話ではあるが。 また、普通に使っていると遅いなと感じることも多い。特にFirestore。この話をするとうまく使えてない、正しく使えていないからだとい言われることが多いんだろうけれど。

では、Firebaseのほうが使い勝手がいいのはどういうところなのか考えてみる。FirebaseとAWSではなくFirebaseとAWS Amplifyから利用するAWSという感じで。

個々のサービス自体はAWSにも相当するものが多い。もちろんRemote Configのようなそもそも対抗が存在しないものもあるが。ただ、それ以上にSDKからサービスまでがシームレスな体験を得られていると思う。とても定性的で数値等の根拠ある指標で示せないのが心苦しいが、少なくともSDKから扱うにあたってSDKの使い方とサービスの使い方の両方を学ぶ必要はない。このあたりどう伝えようとしても誤解を生みそうだが、サービスの使い方=SDKの使い方になっているとも言えるかもしれない。

AWS Amplifyの場合、とあるサービスを使うにあたってそのサービスの使い方をしっかり理解しつつAWS Amplifyではどう扱えばいいのかを学ぶ必要がある。AWS Amplifyの想定するユースケースにドンピシャでAmpify CLIとAmplify Libraryだけで諸々が完結する場合はいい。だが、少しはみ出ようとすると途端にこうなる印象が強い。これが意外とストレスに感じる。

自分の場合、どうせ各サービスのことを個別にちゃんと理解する必要があるならば各サービスのプロビジョニングなんかもCDKなりで自分でコントロールすればいいって気になってくる。むしろ、こうなってくるとAWS Amplifyがやってくれることは余計なお世話感が強い。こっちはAmplify Libraryの用意するユーティリティ関数を使いたいだけなのにCognitoが登場してきたりね。

まとめ

基本的にはAWS AppSyncでGraphQLをやりたい場合以外は不要だと思う。S3周りの実装が楽になるかと思いきや逆に微妙な実装で自分たちのユースケースには合わなかったし、そもそもこのあたりの実装はやはりSDKでやってほしいという気持ち。

アプリケーションエンジニアが楽に利用できるっていう路線は諦めて、アプリケーションエンジニアにAWSをもっと知ってもらう方向に舵を切ったほうがいいのではないかとも思うがそれはさすがに余計なお世話か。

JS SDKでもっとHigh level APIを充実させてくれたら何も言うことはない。

Amazon Aurora PostgreSQLでPostGISを使う

f:id:Keisuke69:20180318142151j:plain

はじめに

仕事柄、地理情報を扱うですがこれまでは件数も少なく込み入った処理もなかったのでファイルで出力されたものを参照するくらいでした。ただ、これだと今後の拡張性とかちょっと込み入ったことをするのになかなか難しいなーと思っていたこともありPostGISを試してみることに。もう一つ大事なきっかけとしてこれまでFirestoreに地理情報を一部保存していたのですが、これもちょっと込み入ったことするには全く向いてなくてそのために回りくどいことをゴニョゴニョと実装する必要性に迫られていたのです。

この「ちょっと込み入ったこと」ってのは例えばある地点から何メートル以内の情報だけ抽出するとかそういうのです。

PostGISって何?

PostGISってのはすごーくざっくり説明すると、PostgreSQLで地理情報を扱えるようにするための拡張モジュールです。地理情報を扱える、つまり地理情報システム(GIS)としてPostgreSQLを使うためのものだと思ってください。

緯度経度の情報などを単なる文字列とか数値として格納して扱うこともできますが、そうではなく地理情報を格納するためのデータ型が追加されるのでいい感じに扱えるようになります。また、単に地理情報を格納できるようになるだけでなく非常に多くの関数が用意されていて、文字列とか数値として格納するだけだとアプリ側で頑張って計算する必要があったものも関数だけで簡単に扱えます。例えば先にあげた特定の地点の範囲内の情報をクエリするなんてのもSQLと組み合わせて簡単にできるようになります。

開発はPostgreSQLとは独立していて、カナダのRefractions Research Inc.というところが開発しています。もちろんOSSです。

というわけでPostGISが使えそうと思った僕は早速本を買ったのですが日本語の本はあまりなくて結果的にこの本を買いました。困ったときのManning Publications。どうでもいい話ですがオライリーよりManning Publicationsの本のほうが買ってるの多い。SpringとかRabbitMQ、かつてはMongoDBなんかも買った記憶がある。

Amazon Aurora ?

Amazon AuroraってのはAmazon Web ServicesAWS)が提供するRDBMSのマネージドサービスです。詳細はAWSのサイトに譲りますが、エンジンとしてMySQLPostgreSQLを選べます。そしてこのPostgreSQLが昨年の秋くらいにPostGISに対応したのです。

Amazon Aurora PostgreSQL が PostGIS 3.1 をサポート

というわけで、PostGISが使えるならAuroraを使うに決まってるということで今回はAuroraでPostGISを試していきました。

セットアップ

まず、Auroraを使うのでそのインスタンス作ったりを最初にやる必要があるのですが、それ自体は本題じゃないので割愛します。ぶっちゃけマネジメントコンソールで画面ポチポチしてれば起動されます。素晴らしい。

インスタンスができたら早速接続するのですがここも割愛します。これは単にPostgreSQLのクライアントで接続するだけなので何も難しいことはありません。ドキュメントとおりにやればOK。

さて、インスタンスを作っただけではPostGISは有効になっていないようです。PostGISを使いたい場合はエクステンションを読み込む必要があるとのこと。

というわけでやっていきます。読み込むのはこの4つでいいらしい。以下の4つを psqlでデータベースにつないだ状態で実行していけばいいです。

CREATE EXTENSION postgis;
CREATE EXTENSION fuzzystrmatch;
CREATE EXTENSION postgis_tiger_geocoder;
CREATE EXTENSION postgis_topology;

読み込み終わったら有効になってるか確認してみます。

postgres=> select * from postgis_version();
            postgis_version            
---------------------------------------
 3.1 USE_GEOS=1 USE_PROJ=1 USE_STATS=1
(1 row)

無事に有効になったようだ。

続いて rds_superuser ロールにエクステンションの所有権を移してあげる必要があるらしい。何を言ってるかさっぱりですね。このあたりはAWSのAuroraを使う場合に必要となる作業です。

まず、\dn で所有権のリストが確認できます。

postgres=> \dn
    List of schemas
    Name    |  Owner   
------------+----------
 public     | postgres
 tiger      | rdsadmin
 tiger_data | rdsadmin
 topology   | rdsadmin

このtigerとかそのあたりのオーナーを rds_superuser にしてあげる必要があるということらしい。と言ってもこれも簡単で先ほどと同じく以下を実行するだけです。もちろん psql等でデータベースに接続した上で、です。

ALTER SCHEMA tiger OWNER TO rds_superuser;
ALTER SCHEMA tiger_data OWNER TO rds_superuser; 
ALTER SCHEMA topology OWNER TO rds_superuser;

終わったら、もう一度 \dn を実行。

postgres=> \dn
      List of schemas
    Name    |     Owner     
------------+---------------
 public     | postgres
 tiger      | rds_superuser
 tiger_data | rds_superuser
 topology   | rds_superuser

無事に変更されたようです。

続いて rds_superuser ロールにオブジェクトの所有権を転送する。何を言ってるからわからないがドキュメントにそう書いてある。これもドキュメントのコピペで実行します。

postgres=> CREATE FUNCTION exec(text) returns text language plpgsql volatile AS $f$ BEGIN EXECUTE $1; RETURN $1; END; $f$;
CREATE FUNCTION
postgres=> SELECT exec('ALTER TABLE ' || quote_ident(s.nspname) || '.' || quote_ident(s.relname) || ' OWNER TO rds_superuser;')
  FROM (
    SELECT nspname, relname
    FROM pg_class c JOIN pg_namespace n ON (c.relnamespace = n.oid) 
    WHERE nspname in ('tiger','topology') AND
    relkind IN ('r','S','v') ORDER BY relkind = 'S')
s;
                                exec                                
--------------------------------------------------------------------
 ALTER TABLE tiger.loader_variables OWNER TO rds_superuser;
 ALTER TABLE tiger.loader_lookuptables OWNER TO rds_superuser;
 ALTER TABLE tiger.zip_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.tract OWNER TO rds_superuser;
 ALTER TABLE tiger.geocode_settings OWNER TO rds_superuser;
 ALTER TABLE tiger.tabblock OWNER TO rds_superuser;
 ALTER TABLE tiger.county OWNER TO rds_superuser;
 ALTER TABLE tiger.bg OWNER TO rds_superuser;
 ALTER TABLE tiger.geocode_settings_default OWNER TO rds_superuser;
 ALTER TABLE tiger.pagc_gaz OWNER TO rds_superuser;
 ALTER TABLE tiger.state_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.pagc_lex OWNER TO rds_superuser;
 ALTER TABLE tiger.state OWNER TO rds_superuser;
 ALTER TABLE tiger.pagc_rules OWNER TO rds_superuser;
 ALTER TABLE tiger.direction_lookup OWNER TO rds_superuser;
 ALTER TABLE topology.topology OWNER TO rds_superuser;
 ALTER TABLE topology.layer OWNER TO rds_superuser;
 ALTER TABLE tiger.place OWNER TO rds_superuser;
 ALTER TABLE tiger.zip_state OWNER TO rds_superuser;
 ALTER TABLE tiger.zip_state_loc OWNER TO rds_superuser;
 ALTER TABLE tiger.secondary_unit_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.cousub OWNER TO rds_superuser;
 ALTER TABLE tiger.street_type_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.edges OWNER TO rds_superuser;
 ALTER TABLE tiger.place_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.addrfeat OWNER TO rds_superuser;
 ALTER TABLE tiger.county_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.faces OWNER TO rds_superuser;
 ALTER TABLE tiger.countysub_lookup OWNER TO rds_superuser;
 ALTER TABLE tiger.featnames OWNER TO rds_superuser;
 ALTER TABLE tiger.zip_lookup_all OWNER TO rds_superuser;
 ALTER TABLE tiger.addr OWNER TO rds_superuser;
 ALTER TABLE tiger.zip_lookup_base OWNER TO rds_superuser;
 ALTER TABLE tiger.zcta5 OWNER TO rds_superuser;
 ALTER TABLE tiger.tabblock20 OWNER TO rds_superuser;
 ALTER TABLE tiger.loader_platform OWNER TO rds_superuser;
 ALTER TABLE tiger.pagc_lex_id_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.county_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.state_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.place_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.cousub_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.edges_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.addrfeat_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.faces_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.featnames_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.addr_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.zcta5_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.tract_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.tabblock_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.bg_gid_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.pagc_gaz_id_seq OWNER TO rds_superuser;
 ALTER TABLE tiger.pagc_rules_id_seq OWNER TO rds_superuser;
 ALTER TABLE topology.topology_id_seq OWNER TO rds_superuser;
(53 rows)

終わったらこれもテストしてみます。

postgres=> SET search_path=public,tiger;
SET
postgres=> SELECT na.address, na.streetname, na.streettypeabbrev, na.zip
FROM normalize_address('1 Devonshire Place, Boston, MA 02109') AS na;
 address | streetname | streettypeabbrev |  zip  
---------+------------+------------------+-------
       1 | Devonshire | Pl               | 02109
(1 row)

postgres=> SELECT topology.createtopology('my_new_topo',26986,0.5);
 createtopology
 createtopology 
----------------
              1
(1 row)

というわけで下準備は完了。

早速試してみる

では早速テーブルを作ってみます。といっても普通のテーブル同様に create table で作成するだけ。地理情報をもたせる列はgeometry型で、ジオメトリタイプとSRIDというものも指定する。 ジオメトリタイプというのは地図情報を扱うとよく目にするPOINT、 LINESTRING、 POLYGONとかってやつ。それぞれMulti〜ってのもある。ここではとりあえずPOINTでやってみた。

また、SRIDってのは空間参照IDというものらしく、ArcGISのページによると

空間参照 ID (SRID) は、特定の座標系、許容値、および解像度に関連付けられた一意の ID です。

とのこと。こっちのブログにはもうちょっと詳しく載ってる。

PostGISで使用されるSRIDについて | JURI★GIS

正直なところ現時点では知識不足で何を選択したらいいかわからないが、このブログいわく世界でメジャーと言われる測地系WGS84と地理座標系の4326を選択してみよう。

postgres=> CREATE TABLE sample1 (
  gid SERIAL PRIMARY KEY,
  geo GEOMETRY(POINT, 4326)
);

DBに存在するジオメトリなカラムの一覧も検索できる。

postgres=> SELECT * FROM geometry_columns;
 f_table_catalog | f_table_schema | f_table_name | f_geometry_column | coord_dimension | srid  |      type       
-----------------+----------------+--------------+-------------------+-----------------+-------+-----------------
 postgres        | tiger          | county       | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | state        | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | place        | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | cousub       | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | edges        | the_geom          |               2 |  4269 | MULTILINESTRING
 postgres        | tiger          | addrfeat     | the_geom          |               2 |  4269 | LINESTRING
 postgres        | tiger          | faces        | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | zcta5        | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | tabblock20   | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | tract        | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | tabblock     | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | tiger          | bg           | the_geom          |               2 |  4269 | MULTIPOLYGON
 postgres        | my_new_topo    | face         | mbr               |               2 | 26986 | POLYGON
 postgres        | my_new_topo    | node         | geom              |               2 | 26986 | POINT
 postgres        | my_new_topo    | edge_data    | geom              |               2 | 26986 | LINESTRING
 postgres        | my_new_topo    | edge         | geom              |               2 | 26986 | LINESTRING
 postgres        | public         | sample1      | geo               |               2 |  4326 | POINT

作ったテーブルに試しにデータ入れてみる。

SQLで入れるにはST_GeomFromText()っていう関数を使う。これはwkt形式で記述されたテキストを変換する関数。POINTに入れる座標はlongitude(経度)、latitude(緯度)の順で指定する。とりあえず東京駅の座標を入れてみます。

各地の座標はGoogle Mapで調べられる。Google Mapで座標を知りたい場所を右クリックすると表示されるのでそれをコピーすればいい。ちなみにGoogle Mapはlatitude、longitudeで表示されているので注意。

f:id:Keisuke69:20220309115317p:plain

実際に入れます。

postgres=> INSERT INTO sample1 (gid, geo) values(1,ST_GeomFromText('POINT(139.7671677130547 35.681436619443254)', 4326));
INSERT 0 1

無事にインサートできたので、この時点でselectしてみるとこんな感じです。

postgres=> select * from sample1;
 gid |                        geo                         
-----+----------------------------------------------------
   1 | 0101000020E6100000BFC34DA38C7861401D67AD5039D74140
(1 row)

ジオメトリ型のカラムに入ってるデータはそのままだと上記のような値になってしまう。ここは出力を変換する必要があるんだけどその関数も用意されている。それについては後ほど。

また、冒頭で述べたある地点から指定した距離内のレコードだけをselectするとかも簡単にできる。ここでは1件しかまだ入れていないんだけど、試しにGoogle Map上では900mほど離れているコレド東京の座標(経度139.77458133855586、緯度35.68270896117478)を使って試す。指定距離内を検索するのはST_DWithinという関数でできる。

postgres=> SELECT * FROM sample1 WHERE ST_DWithin(geo, ST_GeomFromText('POINT(139.77458133855586 35.68270896117478)', 4326), 500, true);
 gid | geo 
-----+-----
(0 rows)

注意が必要なのはこの ST_DWithin という関数は距離を指定できるんだけどその単位はSRIDによって異なる。今回使った4326だとが単位でその値は実際にはメートルだそうだ。なので指定したい距離をメートルで普通に入れる。

500m以内で検索すると当然引っかからない。というわけで1000mにしてみる。

postgres=> SELECT * FROM sample1 WHERE ST_DWithin(geo, ST_GeomFromText('POINT(139.77458133855586 35.68270896117478)', 4326), 1000, true);
 gid |                        geo                         
-----+----------------------------------------------------
   1 | 0101000020E6100000BFC34DA38C7861401D67AD5039D74140
(1 row)

無事に検索できた。でもこれだとなんだかわからない。というわけでジオメトリ型をGeoJSONに変換する関数である ST_AsGeoJSON()` を使って出力する。

postgres=> SELECT gid, ST_AsGeoJson(geo) FROM sample1 WHERE ST_DWithin(geo, ST_GeomFromText('POINT(139.77458133855586 35.68270896117478)', 4326), 1000, true);
 gid |                        st_asgeojson                         
-----+-------------------------------------------------------------
   1 | {"type":"Point","coordinates":[139.767167713,35.681436619]}
(1 row)

うむ、いい感じ。

CSVファイルをインポートしてみる

さて、もうちょっとガッツリとデータを入れたいなと思ったので手持ちのデータをインポートしてみることに。なお、PostgreSQLに用意されている COPYCSVファイル中にwkt形式で書かれた情報があれば自動的にジオメトリ型に変換してくれるらしいが、自分は横着してTwitterで教えてもらったposticoを利用した。超絶簡単に取り込めた。

こんな感じのCSVファイルを取り込む。

gid,geo
06dmUFSUzYzTZA8NodFi, LINESTRING (142.3849795 43.7489348,142.3849795 43.7489348,142.3850022 43.7489459,142.3850022 43.7489459,142.3850022 43.7489459,142.3849791 43.7489387,142.3849791 43.7489387,142.384979 43.7489386,142.384979 43.7489386,142.3849791 43.7489387,142.3849791 43.7489387,142.384979 43.7489386,142.384979 43.7489386)
09MSZxBYpOujH17lhogX, LINESTRING (142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849739 43.7489202,142.3849739 43.7489202,142.3849739 43.7489202,142.3849739 43.7489202,142.3849736 43.7489202,142.3849736 43.7489202,142.3849735 43.7489203,142.3849735 43.7489203,142.384973 43.7489204,142.384973 43.7489204,142.384967 43.7489213,142.384967 43.7489213,142.3849711 43.7489221,142.3849711 43.7489221,142.3849678 43.7489224,142.3849678 43.7489224,142.3849716 43.7489207,142.3849716 43.7489207)

このデータではidが文字列になってますが気にしないでください。実際には自分が定義したテーブルの型にあわせてもらえればと思います。

このLINESTRING(lon1 lat1, lon2 lat2)ってのがwkt形式です。経度と緯度の組み合わせを空白区切りで書いて、各地点をカンマ区切りで記す感じです。これはLINESTRING(つまり地点を結んだ線の表現)の場合ですがPOLYGONとかも同じような感じで記述します。

wkt形式ってのは Well-known textのことで ベクタ形式幾何学オブジェクトを投影法 (地図)を基に変換し地図上に表現させるマークアップ言語であるWikipediaに書いてありました。

なお、注意点というか当たり前ではあるんだが地理情報がPOINTとLINSTRINGとかPOLYGONとMULTIPOLYGONで混在しているとダメなのでそれだけ気をつけてください。まあ同じ列に異なる型の値が入らないってだけなので当たり前の話なんだけどね。

最後にこれはPostgreSQLPostGISも関係ないんだけど今回CSVインポートに使ったGUIPostgreSQLクライアントツールであるposticoではなぜか上記のCSVだとうまく認識されず、wktの中身がカンマ区切りで認識されてしまった。LINESTRING全体をダブルクオーテーションでくくったりしたけどダメ。でも似たデータのほうは問題なく認識される。何が原因かはわからなかったけど今回は列の区切り文字をカンマではなくセミコロンにすることで対応しました。つまりComma Separated ValueではなくSemicolon Separated Valueになってしまった。だが特に支障はない。

インデックスを作成する

もちろん普通にインデックスを作成することもできる。通常の列に対するインデックスは普通のDBと同じように作ればいい。

PostGISの場合にはジオメトリ型の列を対象にした空間インデックスも作成が可能になっていて、この場合はGiSTインデックスを作成することになる。GiSTってのはGeneralized Search Treeという意味らしい。詳細はこちら。

汎用検索ツリー - Wikipedia

とにもかくにもGiSTインデックスを作るにはこんな感じでDDL分を実行すればいい。

CREATE INDEX ix_sample1_geo ON sample1 USING GiST (geo);

まとめ

というわけでAWSのAurora PostgreSQLを使ってPostGISを試してみた。試してみた結果、どうしようかなーと思っていた実際のワークロード上の課題もPostGISを使うことで簡単に解決できそうなことがわかったので実際に導入してみようと思う。

ちなみに、試すにあたってはこのブログに書いた内容の後にPostGISをバックエンドにしたAPIをいくつか実装した。

AWS Amplifyを使ったReact NativeなアプリをBitriseでビルド

はじめに

今回、React Nativeで開発しているとあるアプリでAWS Amplifyを使ったのですがBitriseでビルドしたりチーム開発で使うにあたってちょっと困ったりしたのでメモ。

前提

もともと自分は以前にこちらの記事でも書いたようにAWS Amplifyについてはそんなにポジティブではないです。なのでこの記事以降も基本的にはあまりwatchしてきませんでした。なので今回ちょっと苦労したのはAmplify弱者だったことも理由かも知れない。

www.keisuke69.net

ただし、Amplify Consoleはいいぞ。これはプロダクションでも2,3使ってる。

また、React Nativeで開発したアプリはAndroidiOSともにBitriseを使ってビルドをしています。これはリポジトリの特定ブランチにpushされたことをトリガーに毎回自動的に実行され、常に最新のビルドが確認可能という状況にしています。 なお、App Storeへの配布まではまだ自動化していないです。

なぜAmplifyを使ったか

先の自分のブログ記事にもあるように基本的にAmplifyはAppSyncとの組み合わせ以外で積極的に使う理由はないと思っています。

ではなぜ今回AWS Amplifyを使ったかというとS3に対してマルチパートアップロードを実装するにあたり、素のAWS SDKを使って実装するより楽に実装できそうだったからです。具体的にはこのあたりに書かれています。JavaScriptの場合です。

docs.aws.amazon.com

マルチパートアップロードを行おうと思うといろんなお作法が必要になってくるんですね。さらに一時停止とか再開とかそういうのも。

それがAWS Amplifyではユーティリティが用意されていてかんたんに実装できるんですね。このあたりの話です。

aws.amazon.com

この発表はAmplify for JavaScriptとなっていますが、React Nativeでも使えます。ドキュメントはこのあたり。

https://docs.amplify.aws/sdk/storage/transfer-utility/q/platform/ios/#upload-a-file

圧倒的に楽そう。ということでAWS Amplifyを使うことにしたのです。

Bitriseでビルドする

AWS Amplifyを使った実装自体はそんなにこれといった話はないのですが、いざある程度出来上がったのでリポジトリにpushしてBitriseでのビルドパイプラインに載せます。

そうすると、aws-exports.jsというファイルがなくてビルドでエラーになります。実はこれはBitriseだけの話ではなく、Amplifyを初期セットアップしたときのままGitとかで管理していて他の環境でcloneしたときにも同様の問題はおきます。

原因はこのaws-exports.jsというファイルが.gitignoreに追加されていてコミットされていないからです。

ではこのファイルは何なのかというと、AmplifyのEnvごとのAWSリソースのリソース名とかが記述されたファイルです。つまり、アップロード先のS3バケットの名前とかが書かれている。ではなぜこのファイルが標準では.gitignoreに追加されていてコミットされないようになっているのか。当初、リソース名が記載されている、つまりクレデンシャル自体の記述はないもののいわゆる秘匿情報みたいなものとして扱われていて事故防止のためかと思いました。

ですが、同じように利用するAWSのリソース名のような情報が記載されているamplify/team-provider-info.json.gitignoreに含まれていません。どうやら、AmplifyにはenvというGitのブランチのような機能があり、aws-exports.jsはこのenvが切り替わるたびに中身が自動的に書き換えられるみたいなんですね。だからコミットの対象外にされているだけのようです。

というわけで今回の問題の対処はリポジトリには含まれていないにも関わらずソースコード内からは参照されるaws-exports.jsをcloneしたあとにどうやって生成するかになります。

ではどう対応するか

Amplifyをチーム開発する場合にどう使っていけばいいのかの知見がなくて、このあたりよくわかっていませんでした。またaws-exports.jsenvの内容が記載されているということで当然ながら開発時と本番では接続するAWSアカウントも違えばAWSリソースも異なるのでその切替も必要になるのでどうしたものかと悩んでたものの、Twitterで聞いたところいろんなアイデアが得られました。

いくつかやり取りした結果、aws-exports.jsの中身が単なるAWSリソース情報がベタ書きされていて、オブジェクトをexportしているだけのものであるということから同様の値を環境変数で設定し、それを読み取って自分でオブジェクトとして組み立ててAmplify.configure()に渡す形で実装を変更しようかなと考えました。これなら開発と本番(≒Bitriseでビルドしたもの)で切り替えるのも容易かなと思ったわけです。

とはいえ、Amplifyのためにこんな実装入れるのもなーとか思ったり、セットする環境変数の数も多いしなーということでなかなか前向きではなかったんですね。

ここでそもそもチーム開発しているときにこの辺どうやればいいのかを調べたところ、どうやらリポジトリをcloneしてamplify pullをすればこのあたりのファイルも生成されるということがわかりました。

ですが、これをやるにはAWSのクレデンシャルやらが必要なのでamplify initやらをしなければいけないのかな?と思っていたところどうやらAmplify CLIのヘッドレスモードだと大丈夫そうということでこの方向でやることにします。

Bitriseでどうやるか

とりあえずリポジトリをcloneしたらAmplify CLIを使ってpullすればいいということでこれをBitriseのWorkflow上で実現していきます。

まず、その前にプロジェクト上で開発と本番それぞれのenvを作成しておきます。自分の場合、もともと開発初期のセットアップ時にdevというenvを用意していてこれが開発用のAWSアカウントに紐付いていました。AWSリソースもこの開発用AWSアカウント上で作られています。

なので、まずは本番環境用のenvとしてprodというものを作っておきます。このときAWSアカウントごとわけられるのか気になったのですが、結果的には問題なくできているようです。実際にはamplify env add prodをするときにAWSアカウントを聞かれたので本番環境のAWSのアカウントで作成したクレデンシャル情報(Acesss KeyとSecret Access Key)を指定することができたんですね。で、envを作成したらamplify pushをして反映しておきます。実際に本番環境のAWSアカウント上に各種リソースが作成されたことが確認できました。

というわけでようやくここからBitriseの設定をしていきます。

実際にはWorkflowでビルドのステップの前にscriptのステップを追加し、ここでenvの情報をpullするという処理を入れます。というわけで実際に入れた処理がこちら。

#!/usr/bin/env bash
set -e
set -x

npm install -g @aws-amplify/cli

AMPLIFY="{\
\"appId\":\"<appId>\",\
\"envName\":\"prod\",\
\"defaultEditor\":\"code\"\
}"

AWSCLOUDFORMATIONCONFIG="{\
\"configLevel\":\"project\",\
\"useProfile\":false,\
\"accessKeyId\":\"<AWS_ACCESS_KEY_ID>\",\
\"secretAccessKey\":\"<AWS_SECRET_ACCESS_KEY_ID>\",\
\"region\":\"ap-northeast-1\"\
}"

PROVIDERS="{\
\"awscloudformation\":$AWSCLOUDFORMATIONCONFIG\
}"

REACTNATIVECONFIG="{\
\"SourceDir\":\"src\",\
\"DistributionDir\":\"build\",\
\"BuildCommand\":\"yarn build\",\
\"StartCommand\":\"yarn start\"\
}"

FRONTEND="{\
\"frontend\":\"javascript\",\
\"framework\":\"react-native\",\
\"config\":$REACTNATIVECONFIG\
}"

amplify pull --amplify $AMPLIFY --frontend $fRONTEND --providers $PROVIDERS --yes

冒頭でAmplify CLIをBitriseの環境にインストールしています。ちなみにyarnでAmplify CLIをインストールしようとすると変なエラーに悩まされることが多いのでnpmで入れたほうが無難。自分はこれで数時間溶かしました。

各値は各々の環境の値で置き換えてください。

補足すると、AMPLIFY内で指定しているappIdはAmplifyのプロジェクトのappIdAWSのマネージメントコンソールやteam-provider-info.jsonに記載があるのでそちらを指定。PROVIDERおよびAWSCLOUDFORMATIONCONFIGで指定してるaccessKeyIdsecretAccessKeyIdAWSアカウントのクレデンシャル情報ですね。あとはFRONTENDのところで今回はReact Nativeのプロジェクトなのでframeworkreact-nativeと指定しています。ただ、このあたりはドキュメント読んでも正直よくわからない。

最後のamplify pullコマンドで上で指定した値をすべてパラメータとして渡した上で--yesというオプションを付与して実行します。この--yesをつけることでプロンプトが表示されなくなるというものです。

とはいえ、このパラメータはちょっと面倒すぎるしもうちょっと説明が欲しいところ。面倒くさいぞAmplify CLI!

このscriptステップをBitriseのWorkflow上の適当な位置に追加します。自分はこんな感じでCocoapodのインストールが終わったあとに追加しましたがXCodeでのArchiveより手前であればどこでも大丈夫かと思います。

f:id:Keisuke69:20220209103713p:plain

まとめ

というわけでAWS Amplifyを使ったアプリをBitriseのようなCI/CD環境でビルドしようとするとそのままだとaws-exports.jsが存在しないためビルドに失敗します。そのため、ビルド前にamplify pullをして環境にあったaws-exports.jsを生成する必要があります。今回これはCI/CD環境でのビルドの話として書いたけど、これはチーム開発で別の人がリポジトリをcloneしたときに必ず発生する問題かと思います。

率直な感想としてやっぱりAmplifyは面倒だなと感じました。簡単にしようとして余計なことしてくれてる感じ。やっぱりそんなに便利には感じないな。

今回使った理由がS3のマルチパートアップロードのユーティリティを使いたいがためだけというものだったので余計にそう感じました。というかこのユーティリティをAWS SDK自体で用意してほしい。

Amplify ConsoleでNode.jsのバージョンをアップデートする (追記あり)

f:id:Keisuke69:20180529112807j:plain

(Update)このブログは最後の追記だけを見れば事足ります。

Amplify ConsoleでとあるReact、Next.jsベースのアプリケーションをホストしているんだけど、最新のプッシュで動いたデプロイがビルドのフェーズで落ちてしまった。

ログは以下。

2022-01-12T01:20:42.950Z [WARNING]: error @typescript-eslint/experimental-utils@5.9.0: The engine "node" is incompatible with this module. Expected version "^12.22.0 || ^14.17.0 || >=16.0.0". Got "12.21.0"
2022-01-12T01:20:42.958Z [WARNING]: error Found incompatible module.
2022-01-12T01:20:42.958Z [INFO]: info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command.
2022-01-12T01:20:42.979Z [ERROR]: !!! Build failed
2022-01-12T01:20:42.979Z [ERROR]: !!! Non-Zero Exit Code detected

これはつまりインストールしようとしているライブラリ(ここでは@typescript-eslint/experimental-utils@5.9.0)と互換性のあるNode.jsのバージョンがインストールされていないってこと。どうやらAmplify Consoleで使われているNode.jsのバージョンはこのメッセージに表示されているように 12.21.0 のようだ。本記事執筆時点のv12系の最新LTSが 12.22.9 なのでちょっと古い。そしてできれば14と16も選べるようにしてほしいところ。

そもそも @typescript-eslint/experimental-utils@5.9.0 をAmplify Consoleに本番デプロイするときにインストールする必要ないよねって話もあるのでそこは後ほどちゃんとするとして。

この問題に対応する方法として大きく2つ。1つはAmplify Consoleはカスタムイメージ使えるのでそれを使うパターン。もう1つはAmplify Consoleが使うNode.jsのバージョンを自分の使いたいものにアップデートすること。

カスタムイメージの方法は自分でコンテナイメージ作ったりするのが面倒なので今回はナシかな。普段Remote Containersで使ってるDockerfileをそのまま使えばいいのかもだけど。

後者のAmplify Consoleで使うNode.jsのバージョンをあげる方法については以下でも言及されている。

github.com

要約すると、

  • Amplifyのbuild settingで使いたいバージョンをインストールするよう指定する
  • preBuildのフェーズで行う

というわけで早速やっていく。

Amplifyのビルド周りの設定は amplify.ymlというファイルに定義していく。このファイルはAWSのマネジメントコンソール上で編集することも可能だけどソースリポジトリのルートフォルダに置いておくことも可能だ。バージョン管理の観点では手元で管理したほうがいいだろう。

これが変更前の amplify.yml

version: 1
frontend:
  phases:
    preBuild:
      commands:
        - yarn install
    build:
      commands:
        - yarn build
  artifacts:
    baseDirectory: .next
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*

見ての通り、preBuildのコマンドでyarn installとしか指定していないためdevDependenciesに指定されているライブラリもインストールされてしまっている。なのでここもついでに変更する。

実際にNode.jsのバージョンを変更するには同じくpreBuildnvmを使って使いたいバージョンをインストールするだけだ。

というわけでこんな感じにする。

    preBuild:
      commands:
        - nvm install --lts --latest-npm
        - nvm use stable

実際にこれで実行してみるとこんな感じでログが出力されていて最新のLTSである16.13.2が無事にインストールされたことがわかる。

                                 # Starting phase: preBuild
                                 # Executing command: nvm install --lts --latest-npm
2022-01-12T03:07:17.052Z [INFO]: Installing latest LTS version.
2022-01-12T03:07:17.192Z [INFO]: Downloading and installing node v16.13.2...
2022-01-12T03:07:17.253Z [WARNING]: Downloading https://nodejs.org/dist/v16.13.2/node-v16.13.2-linux-x64.tar.gz...
2022-01-12T03:07:17.368Z [WARNING]: #########################
2022-01-12T03:07:17.369Z [WARNING]: 35.3%
2022-01-12T03:07:17.439Z [WARNING]: ####################
2022-01-12T03:07:17.439Z [WARNING]: #################################################### 100.0%
2022-01-12T03:07:17.453Z [WARNING]: Computing checksum with sha256sum
2022-01-12T03:07:17.563Z [WARNING]: Checksums matched!
2022-01-12T03:07:18.825Z [INFO]: Now using node v16.13.2 (npm v8.1.2)
2022-01-12T03:07:18.855Z [INFO]: Attempting to upgrade to the latest working version of npm...
2022-01-12T03:07:19.112Z [INFO]: * Installing latest `npm`; if this does not work on your node version, please report a bug!
2022-01-12T03:07:22.965Z [INFO]: removed 8 packages, changed 24 packages, and audited 215 packages in 4s
2022-01-12T03:07:22.969Z [INFO]: 10 packages are looking for funding
                                 run `npm fund` for details
                                 3 moderate severity vulnerabilities
                                 To address all issues, run:
                                 npm audit fix
                                 Run `npm audit` for details.
2022-01-12T03:07:23.202Z [INFO]: * npm upgraded to: v8.3.0
2022-01-12T03:07:23.203Z [INFO]: # Executing command: nvm use stable
2022-01-12T03:07:23.797Z [INFO]: Now using node v16.13.2 (npm v8.3.0)

特定のバージョンを指定したい場合はnvm installでそれを指定すればいい。これだけで今回の問題は解決しているものの冒頭でも書いたようにそもそもdevDependenciesの内容も含めてインストールされているのも少し問題なのでそこも修正した。が、よく考えてみるとAmplify ConsoleでのデプロイのビルドプロセスではNext.jsのビルド処理も含まれているのでやっぱりdevDependencies必要じゃん!ってなったのでやっぱり修正しない。というかできない。

というわけで最終的には以下のようなamplify.ymlになった。

version: 1
frontend:
  phases:
    preBuild:
      commands:
        - nvm install --lts --latest-npm
        - nvm use stable
        - yarn install
    build:
      commands:
        - yarn build
  artifacts:
    baseDirectory: .next
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*

こんな感じでひとまず今回の問題は解決したが、気になるのはビルドのたびに毎回インストールするのか?ってこと。実際に今回試した感じではそれほど時間はかからなかったもののビルドの時間がかかるようになるのは嫌だなとも思う。とはいえAmplify自体がお目当てのバージョンに対応するまでだけの暫定対応とも言えるが。

でもできればLambdaみたいにNode.jsのバージョンを選べるようになると嬉しい。 => 追記参照

最後に余談だけどamplify.ymlで指定できるcacheとしてnvmでインストールしたNode.jsのランタイムのフォルダとかを指定しておくと毎回ビルドでインストールされる問題は解決するのかもしれない、なんて話をAWSの人とした。でもまだ試してない。

ここから追記

上記のような感じで対応して満足してたところ、ツイッターで僕の心の友からこんな情報が寄せられた。

そういえば、Next.jsのSSRに対応したって話のときにこのあたりの設定をしたことがある遠い記憶が。

f:id:Keisuke69:20220112125648p:plain

これはLive package updatesというものでAmplify Consoleが利用するデフォルトイメージのライブラリとかをオーバーライドできるそう。そしてここにNode.js versionというのがあった!

というわけで早速ここを16.13.2に指定して再度デプロイしてみることに。

f:id:Keisuke69:20220112125756p:plain

f:id:Keisuke69:20220112125811p:plain

無事に通った!というわけで

Amplify ConsoleでNode.jsのバージョン選ぶことできました!

というわけでこのブログの前半部分はほぼ意味がないものにw

でもこの設定をバージョン管理できないのでamplify.ymlで指定できるようになるといいね。実はできるのかな?

Monthly Serverless Update 最終回

f:id:Keisuke69:20210701084133j:plain 2021年11月のサーバーレス関連まとめです。こちらのイベントの内容です。

serverless-newworld.connpass.com

イベントタイトルにあるように、2020年9月からやってきたこのアップデートまとめシリーズは今回で最後です。

とりあげるサービス

クラウドのサービスは大体このあたりを中心に取り上げます。

AWS

11月末からAWS re:Inventが開催されるのですが、毎年その少し前から大型アップデートが発表され始めます。細々したのがいっぱいあった感じ。

AWS Lambda は Amazon Elastic Container Registry からのクロスアカウントコンテナイメージのプルをサポートするようになりました

Amazon SNS で APN モバイルプッシュ通知のトークンベース認証に対応

AWS CDK が AWS App Runner 向けの高レベル API および Amazon ECS、AWS Step Functions のホットスワップをサポートする v1.126.0 - v1.130.0 をリリース

AWS Step Functions の Synchronous Express Workflow が AWS PrivateLink のサポートを開始

Amazon SNS が、単一の API リクエストで最大10個のメッセージのバッチを発行するサポートを開始

AWS Lambda が Amazon MSK のイベントソースとしての mTLS 認証のサポートを開始

Amazon CloudWatch Lambda Insights が、AWS Graviton2 プロセッサによる AWS Lambda 関数のサポートを開始 (一般使用可能)

AWS Lambda が、Amazon MSK、セルフマネージド Kafka、AmazonMQ、および RabbitMQ のメトリクス OffsetLag のサポートを開始

Amazon Athena が AWS Step Functions のワークフローを可視化するコンソールサポートを追加

Amazon SQS が Amazon SQS マネージドの暗号化キーによるサーバー側の暗号化を発表

AWS Lambda が、イベントソースとしての SQS への部分バッチ応答のサポートを開始

AWS Lambda は、Amazon SQS、Amazon DynamoDB、Amazon Kinesis をイベントソースとするイベントフィルタリングをサポートしました

Google Cloud

Google Cloudはもっとアップデートがなかった。CloudFunctionsはリージョンが1つ追加になったのとNode.jsとGoの新しいバージョンをサポートするようになっただけ。Cloud Runもリージョン追加とメモリが増えた話以外はこの1つだけ。

Cloud Run support for referencing Secret Manager Secrets is now at general availability (GA).

Get started with Flutter and Firebase with just a browser

Securely store secrets for Firebase Extensions

  • Extensionが使う各種SecretをCloud Secret Mangerに保存することができるようになった
  • 公式のextensionはすべてsecret使う形に移行中

Search Firestore with an Elastic App Search extension

  • FirestoreのコレクションからElastic App Searchにドキュメントのインデックスを作成し、同期できる

Unaccepted invitations from App Distribution expire after 30 days

  • テストリリースの招待を 30日以内にアクセプトしなかった場合は自動的に期限切れになる

Personalize content with Remote Config (beta)

  • Google I/Oでアナウンスしたやつがベータに

Azure

General availability of custom OpenID providers in App Service and Azure Functions | Azure updates | Microsoft Azure

General availability: Azure API Management updates- October 2021 | Azure updates | Microsoft Azure

Azure Web PubSub service now generally available | Azure updates | Microsoft Azure

Public preview: Azure SQL bindings for Azure Functions | Azure updates | Microsoft Azure

PowerShell on Linux SKU in Azure Functions is now available in public preview | Azure updates | Microsoft Azure

Introducing Azure Container Apps: a serverless container service for running modern apps at scale

  • サーバーレスなコンテナ実行環境
  • アプリケーションをパッケージングしたコンテナがイベントドリブンに実行される
  • 当然ながら負荷に応じてスケールアウトするし、ゼロまでスケールインする

その他

まずはVercel。

IP Geolocation now available for all plans – Vercel

Remix projects can now be deployed with zero configuration – Vercel

  • 話題のRemixがVercel上にもデプロイ可能に

そしてNetlify

Netlify Launches $10 Million Jamstack Innovation Fund

Netlify Acquires OneGraph, A Powerful GraphQL Platform for Connecting APIs and Services

最近注目なCloudflare

Cloudflare Pages now partners with your favorite CMS

  • いくつかのCMSサービスプロバイダとパートナーシップ
  • デプロイフックによる更新

Cloudflare Pages now offers GitLab support

  • GitLabとのインテグレーション

Introducing Relational Database Connectors

Introducing Workers KV

Durable Objects — now Generally Available

  • 強い一貫性がサポートされる
  • エッジで分散してる環境で強整合性がサポートされるのはなかなかすごいことでは

Announcing Serverless Framework v3 Beta

その他ブログなど

Home | SOULs - Ruby Serverless Framework

  • Rubyのサーバーレスフレームワーク
  • Google Cloudでの利用が前提ぽい
  • オランダ政府の先端研究開発として認定されてる

Calendar for AWS LambdaとServerless Advent Calendar 2021 | Advent Calendar 2021 - Qiita

Azure Container Apps の特徴と Azure Web Apps / Azure Functions との違い - しばやん雑記

Aurora Serverlessについての整理 | DevelopersIO

AWS Lambdaによる進化的アーキテクチャの構築 | Amazon Web Services ブログ

AWSサーバーレスバッチ処理アーキテクチャの構築 | Amazon Web Services ブログ

Aqua NanoEnforcerを利用してLambdaのランタイム保護を利用してみる | DevelopersIO

触ってみてわかったNext.jsと比べた時のRemixの特徴

AWS Amplify(Console、CLI)、AWS CDK、AWS CloudFormationの特徴と比較 -仕様と実装から鑑みるユースケース・使い所 - NRIネットコム Design and Tech Blog

加速するEdge Computing - Speaker Deck

最後に

少なくともAWSについてはサーバーレスも成熟しつつありあまり心踊るようなアップデートがなくなってきたのも事実です。もしかしたら現在開催中のAWS re:Inventでなにかあるかもですが。

サーバーレスっていうのはAWSがLambdaというサービスを中心に言い始めた概念です。そうなんです、具体的な技術ではなく概念なんです。そこがなかなか広まりきらない要因なのでは?と最近では思いつつあります。もちろん各クラウドベンダーごとにサーバーレスを謳うサービスはあります。でもそれはGenericな技術ではなくベンダー固有な技術になってしまいます。そんななかでここまで広がったAWS Lambdaはすごいなと思う一方で、それ以外については他のクラウドベンダを含めて出てきていないと思っています。やっぱりGenericな技術ではないことがその原因なのではと思っています。

そうはいいつつも周辺を取り囲むエコシステムでは新たなプレイヤーも出てきていて楽しみは続きますね。最近はエッジコンピューティングに注目。

さて、このアップデートまとめは終了するのですがBLASTOFFっていう新しいシリーズを先月から始めています。次回は12月15日でre:Inventの私見による振り返りをやろうと思っています。申し込みはこちらから。どうぞよろしく

serverless-newworld.connpass.com

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