Amplify for Androidでより直感的になったGraphQLを試した

TL;DR

本エントリはAWS Amplify Advent Calendar 2019の24日目です!

今回は新しくリリースされたAmplify for Androidを使って、前回と同じく下のようなチャットアプリを作るので、興味のある方は前口上を飛ばして後半をお読みください🎄

f:id:fushiroyama:20191201023816g:plain

Amplifyとは

本題に入る前に、Amplifyを最近よく耳にするようになったけど何かよく分かっていないという人向けの説明をします。

Amplifyはモバイルバックエンドを爆速で作るためのサービスです。 https://aws.amazon.com/jp/amplify/

他のいわゆるモバイルバックエンドとの際立った違いのうち、個人的に強調したいのは以下2点です。

  • バックエンドはAWSのサービスであり、真の意味でスケールする
  • GraphQLのマネージドサービスを利用できる本日時点で唯一のプラットフォームである

Amplifyの良い点として、フロントエンドから利用する時はそれと意識せずに簡単に構築できるけれども、バックエンドはAWSが個別に提供するサービスで構成されているという点です。これは、バックエンドをキャパシティのよく分からないブラックボックスとして扱うことなく、必要であればAWSを使い慣れたインフラ部隊と協力して本当の意味でスケールするようにチューニング可能だということです。

さらに、AmplifyはGraphQLのマネージドサービスを提供する今日時点で唯一のプラットフォームです。GraphQLの良さは他の記事に譲りますが、これを自前で構築するのは中々骨が折れます。AmplifyでAPIを追加するときにGraphQLを選択すると、裏ではAWS AppSyncが使われますが、これはGraphQLのマネージドサービスであり、サーバのプロビジョニングやチューニングを気にすることなくGraphQLのメリットを享受できます。

Amplifyの構成要素

Amplifyは主に次の要素で構成されています

  • バックエンドプロビジョニングのためのCLI
  • ウェブ、iOS/Androidに組み込むためのフレームワーク
  • CI/CDや管理のためのコンソール

f:id:fushiroyama:20191229142819p:plain
AWS re:Invent 2019のセッション動画より引用

後述しますがAmplify for iOS/AndroidのリリースでXcode/Android Studioとの統合がすすみ、CLIを単独で使用することは今後かなり少なくなります。したがって、基本的には普段開発しているときのようにCocoaPodsやGradleでライブラリを導入するように使い始めることができます。具体的な使い方はこのエントリの後半で解説します。

Amplifyのカテゴリ

Amplifyはカテゴリという概念があり、

  • APIREST APIやGraphQL
  • Auth…認証と認可
  • Storage…ストレージ
  • Analytics…分析とユーザエンゲージメント
  • Predictions…AI/MLの組み込み
  • XR…AR/VR

のような機能のうち、使いたいものを好きなだけ選んで使うことができます。

これまでもモバイルからAWSのサービスを使うことは当然できました。これはAWS Mobile SDKによって簡単に実現できます。 ただ、これはどちらかと言えば「AWSのこのサービスを組み込みたい」というマインドセットで使います。対してAmplify for iOS/Androidでは「APIを組み込みたい」というように、ユースケースの側面から使うことができます。

f:id:fushiroyama:20191229144655p:plain
AWS re:Invent 2019のセッション動画より引用

前置きが長くなりました。ここまでがAmplifyの概要です。次からはAmplify for iOS/Androidについて解説します。

Amplify for iOS/Androidとは

先日のAWS re:Invent 2019で新しく発表された、Amplifyをモバイルからより簡単に使うためのアップデートです(公式ブログ)。

fushiroyama.hatenablog.com

先日、上記エントリで AWSAppSyncClient を使ってGraphQLにつなぐ方法を紹介しましたが、これが新しいAPIでどんな感じになるのか見てみましょう。

注意点

公式サイトに明示されていますが、Amplify for iOS/Androidはプレビューリリースで、本番環境での利用はまだ想定されていません。他の多くのOSSがそうであるように、開発者が想定していないと思われることをやろうとするとまだ粗削りな部分が見て取れますが、コントリビューションのチャンスでもあります!本エントリで興味を持ってくださった方は是非お試しください!

Androidクイックスタート

それではAmplify for Androidを使ってGraphQLの読み書きをしてみたいと思います。序盤は公式サイトの手順に準じます。

まずはプロジェクトの build.gradleプラグインをインストールします。

buildscript {
    ext.kotlin_version = '1.3.61'
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.3'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        // Amplify
        classpath 'com.amplifyframework:amplify-tools-gradle-plugin:0.1.0'
    }
}

// Apply Plugin
apply plugin: 'com.amplifyframework.amplifytools'

allprojects {
    repositories {
        google()
        jcenter()

    }
}

次にアプリケーションレベルの build.gradle にJava8の設定と依存関係のインストールをします。

android {
  compileOptions {
        sourceCompatibility 1.8
        targetCompatibility 1.8
    }
}

dependencies {
  implementation 'com.amplifyframework:core:0.9.0'
  implementation 'com.amplifyframework:aws-api:0.9.0'
}

次に、Android StudioをProjectビューにして amplify/backend/api/amplifyDatasource/schema.graphql を編集します。これはGraphQLのスキーマ定義で、これを元にAndroid用のモデルが自動生成されます。せっかくなので、以前のエントリと同じくリアルタイムチャットアプリを開発してみます。まずは、前回と同じ次の定義で作ってみましょう。

type Message @model {
  id: ID!
  username: String!
  content: String!
}

次に、Amplify CLIをインストールします。npmを使うのでNode.jsが入っていない人はインストールしてください。

$ npm install -g @aws-amplify/cli
$ amplify -v
4.7.0

初めてAmplifyを利用する場合は、CLIAWSのクレデンシャルを設定する必要があるので amplify configure コマンドを実行します。AWSのマネジメントコンソールにリダイレクトされるので、そこでユーザを作成してキーを発行してください。ここは次のチュートリアルに非常に詳細な解説があります。

この状態でAndroid Studioに戻ってくると、Build > Make Project でプロジェクトをビルドします。

f:id:fushiroyama:20191229153500p:plain

すると、Graldeメニューに modelgenamplifyPush のタスクが追加されます。

f:id:fushiroyama:20191229153610p:plain

まず、modelgen を実行すると先程のGraphQLのスキーマから com.amplifyframework.datastore.generated.model にメッセージを表すモデルの Message.java が生成されます。 次に amplifyPush を実行すると、Amplify CLIが必要なバックエンドをプロビジョニングしてくれます。この作業は数分かかることがあります。 完了したら、これらを使ってチャットのメッセージを読み書きしてみましょう。

最初にAmplifyを初期化するための設定をします。カスタムApplicationクラスを作成し、次の内容を追記します。

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        try {
            Amplify.addPlugin(AWSApiPlugin())
            Amplify.configure(applicationContext)
        } catch (e: AmplifyException) {
            Log.e(TAG, e.message)
            throw RuntimeException(e)
        }
    }
}

MyApplicationAndroidManifest.xml に追加するのを忘れないでください。

    <application
        android:name=".MyApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

あとは読み書きをするだけです!まずはミューテーションから。

modelgen によって作られたモデルは Message.builder() のようなビルダーがついてきます。ユーザの入力に合わせて簡単にモデルを組み立てることができます。 あとは Amplify.API.mutate メソッドを呼ぶだけです。結果を受け取りたい場合は ResultListener も一緒に渡します。

private fun mutation(
    username: String,
    content: String
) {
    val message: Message =
        Message.builder()
            .username(username)
            .content(content)
            .build()

    Amplify.API.mutate<Message>(
        message,
        MutationType.CREATE,
        object :
            ResultListener<GraphQLResponse<Message>> {
            override fun onResult(response: GraphQLResponse<Message>) {
                response.data?.let {
                  // do things with message
                }
            }

            override fun onError(e: Throwable) {
            }
        })
}

次はクエリです。こちらも Amplify.API.query メソッドを呼ぶだけです。 結果は同じように ResultListener で受け取れるので、これを RecyclerView で描画するといった使い方が一般的でしょう。

private fun query() {
    Amplify.API.query(
        Message::class.java,
        object :
            ResultListener<GraphQLResponse<Iterable<Message>>> {
            override fun onResult(response: GraphQLResponse<Iterable<Message>>) {
                val messages: MutableList<Message> = mutableListOf()
                for (message in response.data) {
                    messages.add(message)
                }

                // update RecyclerView
                updateMessages(messages)
            }

            override fun onError(e: Throwable) {
            }
        })
}

最後にサブスクリプションです。なんとこちらも Amplify.API.subscribe メソッドを呼ぶだけです。 これだけでメッセージの作成をリアルタイムに通知してもらえるので、チャットアプリのようなものがいとも簡単に作成できます。

private fun subscribe() {
    Amplify.API.subscribe(
        Message::class.java,
        SubscriptionType.ON_CREATE,
        object :
            StreamListener<GraphQLResponse<Message>> {
            override fun onNext(response: GraphQLResponse<Message>) {
                response?.data?.let {
                    // update RecyclerView
                    addMessage(it)
                }
            }

            override fun onComplete() {
            }

            override fun onError(e: Throwable) {
            }
        }
    )
}

めちゃくちゃ簡単ですね!ここまでが公式のチュートリアルに相当する内容です。

時系列でメッセージを取得

ここからは少し発展的な内容を扱います。

AmplifyでGraphQLを使う場合、デフォルトではAppSyncとDynamoDBが使われます。DynamoDBは扱うデータサイズや規模によらず、うまく設計すればミリ秒単位のパフォーマンスを維持できるKVSおよびドキュメントデータベースですが、一般的なRDBMSを1台だけで扱うときとは違う点を考慮する必要がある場合があります。たとえば「メッセージを投稿順で取得し、ページングさせたい」という場合には、DynamoDBではいくつか実現方法がありますが、

として設計するのがよくあるパターンです。ここではDynamoDBの詳細なデザインパターンには触れませんが、チャットサービスといえば普通はチャンネル(部屋)を複数持てるのが普通なので、各部屋を表現する roomId + メッセージの日付 createdAt を使ってメッセージを時系列に取得することにします。

まずは前出のGraphQLのスキーマを次のように変更します。

type Message
  @model
  @key(name: "SortByCreatedAt", fields:["roomId", "createdAt"], queryField: "listMessagesSortedByCreatedAt" )
{
  id: ID!
  username: String!
  content: String!
  roomId: String!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime
}

name: "SortByCreatedAt", fields:["roomId", "createdAt"] の部分で時系列に取得するためのキーを追加しています。 また queryField: "listMessagesSortedByCreatedAt" の部分で、このキーを使ったクエリをする際の名前を指定しています。

できたら、以前同様 modelgenamplifyPush を実行してモデルファイルを再生成し、バックエンドにプロビジョニングします。 これで、GraphQL的には次のようなクエリで時系列で取得できるはずです。

query GetPost(
  $roomId: String!
  $limit: Int!
) {
  listMessagesSortedByCreatedAt(
    roomId: $roomId
    sortDirection: ASC
    limit: $limit
  ) {
    items {
      id
      content
      username
      roomId
      createdAt
    }
  }
}

ただし、現状のAmplify for Androidではこのような任意のクエリを簡単に送る方法がないので、GraphQLRequest を利用します。 GraphQLRequest(query, variables, Message::class.java, GsonVariablesSerializer()) のように使います。

注意点として、スキーマcreatedAt: AWSDateTime! のように定義した場合、自動生成された Message クラスでは日付は java.util.Date 型として生成されますが、このモデルを透過的にGraphQLのクエリに変更してくれるライブラリ組み込みの GsonVariablesSerializerjava.util.Date を雑に yyyy-MM-dd に変換します。 これでは精度的に問題なので、ISO 8601 にするために独自の GsonVariablesSerializer を作成します。

class GsonVariablesSerializer : VariablesSerializer {
    override fun serialize(variables: Map<String, Any>): String {
        return GsonBuilder()
            .registerTypeAdapter(
                Date::class.java,
                DateSerializer()
            )
            .create()
            .toJson(variables)
    }

    internal inner class DateSerializer : JsonSerializer<Date?> {
        override fun serialize(
            date: Date?,
            typeOfSrc: Type,
            context: JsonSerializationContext
        ): JsonElement {
            val df: DateFormat = SimpleDateFormat(
                "yyyy-MM-dd'T'HH:mm:ssXXX",
                Locale.getDefault()
            )
            return JsonPrimitive(df.format(Date()))
        }
    }
}

あとは次のようにクエリを組み立てて、GraphQLRequest を作成して Amplify.API.query します。 残念ながらKotlinのraw stringsでは$自体の文字として扱えないので${'$'}のようにしています。

val query = """
    query GetPost(
      ${'$'}roomId: String!
      ${'$'}limit: Int!
    ) {
      listMessagesSortedByCreatedAt(
        roomId: ${'$'}roomId
        sortDirection: ASC
        limit: ${'$'}limit
      ) {
        items {
          id
          content
          username
          roomId
          createdAt
        }
      }
    }
    """.trimIndent()
val variables: Map<String, Any> = mutableMapOf(
    "roomId" to DEFAULT_ROOM,
    "limit" to DEFAULT_LIMIT
)
val request =
    GraphQLRequest(query, variables, Message::class.java, GsonVariablesSerializer())

val listener = object :
    ResultListener<GraphQLResponse<Iterable<Message>>> {
    override fun onResult(response: GraphQLResponse<Iterable<Message>>) {
        val messages: List<Message> = response?.data?.map { it }.orEmpty()
        // do things with messages
    }

    override fun onError(e: Throwable) {
    }
}
Amplify.API.query(request, listener)

ミューテーションもまったく同様です。

val query = """
    mutation CreateMessage(
      ${'$'}id: ID!
      ${'$'}content: String!
      ${'$'}roomId: String!
      ${'$'}username: String!
      ${'$'}createdAt: AWSDateTime!
    ) {
      createMessage(input: {
        id: ${'$'}id
        content: ${'$'}content
        roomId: ${'$'}roomId
        username: ${'$'}username
        createdAt: ${'$'}createdAt
      }) {
        id
        content
        roomId
        username
        createdAt
      }
    }
    """.trimIndent()
val variables: Map<String, Any> = mutableMapOf(
    "id" to id,
    "content" to content,
    "roomId" to roomId,
    "username" to username,
    "createdAt" to createdAt
)
val request =
    GraphQLRequest(query, variables, Message::class.java, GsonVariablesSerializer())

Amplify.API.mutate(
    request,
    object :
        ResultListener<GraphQLResponse<Message>> {
        override fun onResult(response: GraphQLResponse<Message>) {
        }

        override fun onError(e: Throwable) {
        }
    }
)

これで、時系列にメッセージを読み書きするための対応ができました。

改善を望む内容

最後に今回気付いたいくつかの今後改善されるであろう内容を取り上げます。

まず、Amplify.API.query, Amplify.API. mutate, Amplify.API.subscribe はいずれも現在のところブロッキングAPIとなっています。これは、LoaderObservable 等でラップして扱うことを意図しているのか、それとも単に設計上のミスなのか判然としません。とにかく、このままではUIをブロックしてしまうので、RxJavaの Single, Completable でラップして使いました。

// query
val source: SingleOnSubscribe<List<Message>> = SingleOnSubscribe {
    val listener = object :
        ResultListener<GraphQLResponse<Iterable<Message>>> {
        override fun onResult(response: GraphQLResponse<Iterable<Message>>) {
            val messages: List<Message> = response?.data?.map { it }.orEmpty()
            it.onSuccess(messages)
        }

        override fun onError(e: Throwable) {
        }
    }
    Amplify.API.query(request, listener)
}
return Single.create(source)

// mutation
return Completable.create { emitter ->
    Amplify.API.mutate(
        request,
        object :
            ResultListener<GraphQLResponse<Message>> {
            override fun onResult(response: GraphQLResponse<Message>) {
                response.data?.let {
                    emitter.onComplete()
                }
            }

            override fun onError(e: Throwable) {
                emitter.onError(e)
            }
        })
}

// subscription
return Single.create { emitter ->
    Amplify.API.subscribe(
        Message::class.java,
        SubscriptionType.ON_CREATE,
        object :
            StreamListener<GraphQLResponse<Message>> {
            override fun onNext(response: GraphQLResponse<Message>) {
                response?.data?.let {
                    emitter.onSuccess(it)
                }
            }

            override fun onComplete() {
            }

            override fun onError(e: Throwable) {
                emitter.onError(e)
            }
        }
    )
}

利用する側は次の通り subscribeOn(Schedulers.io()) とすればワーカスレッドで処理が実行されます。

compositeDisposable.add(
    mutation(
        name,
        editTextContent.text.toString(),
        DEFAULT_ROOM
    )
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribeBy(
            onComplete = {
                editTextContent.setText("")
            },
            onError = {
                Log.e(TAG, it.message, it)
            }
        )
)

APIとしてListenerを受け取る以上、設計としては非同期なコールバックとして意図されている気がします。この辺りは折角のオープンソースなので何らかのフィードバックを行いたいと思います。

それから、前回のエントリ同様に、AWSMobileClient を使うことで認証機能を簡単に追加することができますが、認証にAWSのCognito User Poolsを使った場合Subscriptionがうまく動かないようです。SubscriptionAuthorizationHeader.from の実装を見る限り、まだAPI Keyにしか対応していないのかも知れません。この辺り、やはりまだまだプロダクション・レディとは言えません。

まとめ

ということで、駆け足になりましたが、今月プレビューリリースされたばかりのAmplify for Androidをざっと触ってみた感じをご紹介しました。 ライブラリとしてはまだまだこれから成熟していくのに期待しますが、AmplifyはたとえばAppSyncのコンソールで非常に簡単にクエリを実行して確認できるなど、デベロッパー体験が非常によいです。Amplifyは正直もっと流行っていいと思っています。これからも情報発信してゆくのでどうかお楽しみに。

それではよいお年を。