AWS Amplifyで30分でGraphQL環境を用意してAndroidとつなぐぞっ!つかまれッ!

こんにちは!本エントリは「AWS Amplify Advent Calendar 2019」の記念すべき第1日目です!

このエントリではAWS Amplifyを使ってユーザ認証機能付きのリアルタイムチャットアプリを30分で作ってみたいと思います。

AWS Amplifyとは

AWS Amplify(以下Amplify)を初めて耳にする方、ご安心ください!
Amplifyはスケールするモバイルアプリおよびウェブアプリを最速で構築するためのサービス/ツール群です。

aws.amazon.com

Amplifyは

  • API機能の簡単な追加
  • 認証機能の簡単な追加
  • 画像/文字認識などのAI機能の簡単な追加
  • オフラインのデータ同期
  • これらを利用するためのライブラリやUIコンポーネント

などを含んでおり、モバイルアプリやウェブアプリを開発して実際にサービスを提供するのに必要なフロントエンド/バックエンドを非常に簡単に用意することができます。実際にその様子をお見せしましょう!

今回作るアプリ

冒頭で述べたように、ユーザ認証機能付きのリアルタイムチャットアプリを作ってみたいと思います。 このアプリは次のような機能を備えています。

  • アプリから簡単にユーザ登録してログインできる
  • ログインしたらこれまでのチャット履歴が表示される
  • 好きな文章を入力して送信できる
  • 任意の人数がチャットに参加でき、参加者のコメントは能動的にリロードする必要なくリアルタイムに表示される
  • ログアウトしてチャットを終了できる

f:id:fushiroyama:20191201023816g:plain
Amplifyで作ったリアルタイムチャットアプリ

APIにGraphQLを使ってみる

AmplifyでAPIを追加するとき、主に2つの選択肢があります。

RESTful APIについては言わずもがな。これは裏ではAmazon API GatewayAWS Lambdaによって実現されますが、こちらは想像がつきやすいので今回は利用しません。

今回はRESTにかわる規格として注目を集めているGraphQLを利用してみたいと思います。GraphQLに関しては「GraphQL」徹底入門 ─ RESTとの比較、API・フロント双方の実装から学ぶが素晴らしい記事ですので別途ご覧ください。

AWSではAWS AppSyncというGraphQLのマネージドサービスが提供されています。マネージドサービスということは、自分たちでサーバを用意しなくても簡単にGraphQLを利用できるということです!

AmplifyでGraphQLをAPIに選択すると裏ではこのAppSyncが使われますが、今回のアプリを作るにあたってはバックエンドで何が使われているかといった内容はほとんど意識することなく簡単に開発することができます。

Androidプロジェクトを作成

今回はAndroidアプリでGraphQLを使ってみたいと思います。本エントリのiOS対応版も近い内に公開予定なので楽しみにしてください!☺️

AndroidアプリはAndroid Studioのウィザードから普通に作成して、アプリが起動するのを確認できればOKです。ここを便宜上 ${PROJECT_ROOT} とします。

Amplify CLIのインストール

Amplify CLIというコマンドラインツールをインストールして、これで対話的に機能を追加したり編集するのがAmplifyを利用する基本的な流れとなります。

ターミナルから次のコマンドでAmplify CLIをインストールします。環境によっては sudo が必要です。

$ npm install -g @aws-amplify/cli
$ amplify configure

amplify configureAWSにIAMユーザを作成し、そのユーザ権限でCLIを実行できるようになります。 この辺りに少し不慣れな方はAWS Amplify ハンズオン 基本ステップに画像つきで詳細に解説されているので参考にしてください!

amplify init

次に ${PROJECT_ROOT}amplify init コマンドを実行し、AndroidプロジェクトにAmplifyをセットアップします。

  • Enter a name for the project AmplifyAndroid
  • Enter a name for the environment dev
  • Where is your Res directory: app/src/main/res

あたりを選択/入力すれば、あとはデフォルトで問題ありません。

$ amplify init

Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project AmplifyAndroid
? Enter a name for the environment dev
? Choose your default editor: Vim (via Terminal, Mac OS only)
? Choose the type of app that you're building android
Please tell us about your project
? Where is your Res directory:  app/src/main/res
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default
⠋ Initializing project in the cloud...
✔ Successfully created initial AWS cloud resources for deployments.
✔ Initialized provider successfully.
Initialized your environment successfully.

Your project has been successfully initialized and connected to the cloud!

Some next steps:
"amplify status" will show you what you've added already and if it's locally configured or deployed
"amplify <category> add" will allow you to add features like user login or a backend API
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

Pro tip:
Try "amplify add api" to create a backend API and then "amplify publish" to deploy everything

ここで何が行われているかは、AWS Amplify ハンズオン 基本ステップの「このとき何が起きているか」を参照してください。

無事に完了すると amplify status でこのプロジェクトのAmplifyの設定を見ることができます。

$ amplify status
| Category | Resource name | Operation | Provider plugin |
| -------- | ------------- | --------- | --------------- |

いまは何もしていないのでこれで問題ありません。

GraphQL(AppSync)セットアップ

このプロジェクトにGraphQLをセットアップします!
amplify add api で「GraphQL」を選択します。 本アプリでは認証機能を利用するので、認証は API Key ではなく API Amazon Cognito User Pool を選択します。

$ amplify add api

? Please select from one of the below mentioned services GraphQL
? Provide API name: amplifyandroid
? Choose an authorization type for the API Amazon Cognito User Pool
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito.

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in when using your Cognito User Pool? Email
 Warning: you will not be able to edit these selections.
 What attributes are required for signing up? (Press <space> to select, <a> to toggle all, <i> to invert selection)Email
Successfully added auth resource

次にGraphQLのスキーマを変更します。今回はチャットのメッセージとして

  • 一意なID
  • メッセージの送信者
  • メッセージ本文

を持ったMessageというモデルを作ることにします。

次の例のように選択して進んでいき、 ${PROJECT_ROOT}/amplify/backend/api/amplifyandroid/schema.graphql を編集します。

? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? Yes
? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? Yes
Please edit the file in your editor: /Users/shiroyaf/git/amplify/AmplifyAndroid/amplify/backend/api/amplifyandroid/schema.graphql
? Press enter to continue

モデルは次のようにします。

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

編集したらウィザードを進めます。

GraphQL schema compiled successfully.
Edit your schema at /Users/shiroyaf/git/amplify/AmplifyAndroid/amplify/backend/api/amplifyandroid/schema.graphql or place .graphql files in a directory at /Users/shiroyaf/git/amplify/AmplifyAndroid/amplify/backend/api/amplifyandroid/schema
Successfully added resource amplifyandroid locally

Some next steps:
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

ここまで来たらセットアップ完了です! amplify status するとローカルに「Auth機能」と「Api機能」が追加されています。

| Category | Resource name   | Operation | Provider plugin   |
| -------- | --------------- | --------- | ----------------- |
| Auth     | xxxxxxxxxxxxxxx | Create    | awscloudformation |
| Api      | amplifyandroid  | Create    | awscloudformation |

amplify push することで、このバックエンド構成がそのままAWS上にプロビジョニングされます。
とっつきにくいのはここまでです!ここからは楽しい楽しいアプリ開発です!

Androidアプリへの組み込み

このあと慎重な人であれば、AWSマネジメントコンソールから認証機能の実態である Cognito User Pool にユーザを作成してログインを試みたり、GraphQLのコンソールからクエリを発行したりしたいところですが、今回は敢えてそのあたりに可能な限り触れません。その辺りを意識しなくても開発できるのがAmplifyの良さだと思うからです。

なので、いきなりアプリを書き始めます。 まずGetting Startedを参考に必要なライブラリや設定を追加します。 どれも、Androidエンジニアにはお馴染みの設定なので特に詰まることもないでしょう。

// project's build.gradle
classpath 'com.amazonaws:aws-android-sdk-appsync-gradle-plugin:2.9.+'
// app's build.gradle
apply plugin: 'com.amazonaws.appsync'

dependencies {
    //AWS Base SDK
    implementation 'com.amazonaws:aws-android-sdk-core:2.15.+'

    //AppSync SDK
    implementation 'com.amazonaws:aws-android-sdk-appsync:2.8.+'
    implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.0'
    implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'

    // Needed for AppSync Subscription https://github.com/eclipse/paho.mqtt.android/issues/321
    implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'

    // Cognito
    implementation 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.9.+'
    //For AWSMobileClient only:
    implementation 'com.amazonaws:aws-android-sdk-mobile-client:2.15.+'

    //For the drop-in UI also:
    implementation 'com.amazonaws:aws-android-sdk-auth-userpools:2.15.+'
    implementation 'com.amazonaws:aws-android-sdk-auth-ui:2.15.+''
}
// AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>


    <application
        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">

        <service android:name="org.eclipse.paho.android.service.MqttService" />

    </application>

認証機能の実前

前出の手順で amplify init を正しくセットアップできていると、 ./app/src/main/res/raw/awsconfiguration.json というファイルが作成されているはずです。これはAndroidアプリからAmplifyを利用するための設定を中央集権的にするための設定ファイルです。

念の為中を確認し、CognitoUserPool など設定がきちんとされているか確認してください。PoolIdAppClientId 等はAWSマネジメントコンソールの Cognito User Pool の設定画面で見つけることができます。

$ cat ./app/src/main/res/raw/awsconfiguration.json

{
    "UserAgent": "aws-amplify-cli/0.1.0",
    "Version": "1.0",
    "IdentityManager": {
        "Default": {}
    },
    "CognitoUserPool": {
        "Default": {
            "PoolId": "ap-northeast-1_xxxxxxxxx",
            "AppClientId": "xxxxxxxxxxxxxxxxxxxxxxxxxx",
            "AppClientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "Region": "ap-northeast-1"
        }
    },
    "AppSync": {
        "Default": {
            "ApiUrl": "https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql",
            "Region": "ap-northeast-1",
            "AuthMode": "AMAZON_COGNITO_USER_POOLS",
            "ClientDatabasePrefix": "amplifyandroid-dev_AMAZON_COGNITO_USER_POOLS"
        }
    }
}

いよいよAndroidのコードに取り掛かります。 まずはGraphQLのクエリ(取得)やミューテーション(追加/変更)などの操作のすべての起点となる AWSAppSyncClient を作ります。
次の例を参考に、ビルダーには cognitoUserPoolsAuthProvider を指定するのを忘れないようにします。

lateinit var awsAppSyncClient: AWSAppSyncClient

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val awsConfig = AWSConfiguration(applicationContext)
    val cognitoUserPool = CognitoUserPool(applicationContext, awsConfig)
    val basicCognitoUserPoolsAuthProvider = BasicCognitoUserPoolsAuthProvider(cognitoUserPool)

    awsAppSyncClient = AWSAppSyncClient.builder()
        .context(applicationContext)
        .awsConfiguration(awsConfig)
        .cognitoUserPoolsAuthProvider(basicCognitoUserPoolsAuthProvider)
        .build()
}

次に、AWSのモバイルSDKでJWTトークンやAWSクレデンシャルを扱うための AWSMobileClient を初期化します。

AWSMobileClient.getInstance()
    .initialize(applicationContext, object : Callback<UserStateDetails> {
        override fun onResult(userStateDetails: UserStateDetails) {
            Log.i(TAG, "onResult: " + userStateDetails.userState)
        }

        override fun onError(e: Exception) {
            Log.e(TAG, "INIT: Initialization error.", e)
        }
    })

次に、認証画面を表示する部分を作ります。

先にインストールしたdependenciesのおかげで、認証用の画面は自分で作らなくても利用することができます。呼び出しは AWSMobileClient.getInstance().showSignIn(context) だけです。

問題は呼び出す場所ですが、AWSMobileClient には認証状態に合わせてコールバックしてもらえるリスナが用意されているので次のように登録します。 あとは、ユーザがサインインしていない状況で showSignIn メソッドを呼んでやるだけです。

リスナは、ログインの前後でActivityを行ったり来たりしても多重登録されないように onStart/onStop 辺りで登録解除してやるとよいでしょう。

private val userStateListener = UserStateListener { details ->
    Log.i(TAG, "onUserStateChanged: " + details.userState)
    when (details.userState) {
        UserState.SIGNED_IN -> {
            Log.i(TAG, "userState: SIGNED_IN")
        }
        else -> {
            Log.i(TAG, "userState: else: " + details.userState)
            AWSMobileClient.getInstance().showSignIn(this@MainActivity)
        }
    }
}

override fun onStart() {
    super.onStart()
    AWSMobileClient.getInstance().addUserStateListener(userStateListener)
    query()
}

override fun onStop() {
    AWSMobileClient.getInstance().removeUserStateListener(userStateListener)
    super.onStop()
}

これでログイン機能は実装できました!

サインイン画面にはユーザの新規作成、確認コードの送信なども最初から組み込まれており完全に動作します。ここで早速ユーザを作成してログインを試してみてください。

f:id:fushiroyama:20191201052622j:plain
サインイン画面
f:id:fushiroyama:20191201052751j:plain
ここからサインアップもできる
f:id:fushiroyama:20191201052828j:plain
確認コードも提供される

これでログイン状態でGraphQLのAPIを自由にアクセスすることができるようになりました。

ミューテーションを実装する

GraphQLではデータの作成や変更、削除などをミューテーションと呼びます。

先にウィザードでMessageモデルを作ったことで、Amplifyが自動的にそのモデルを使って行うミューテーションのためのデータ型などを自動生成してくれます。なのでアプリ作者は

  1. CreateMessageMutation オブジェクトを作る
  2. AwsAppSyncClient#mutate に対してエンキューする

だけでミューテーションを簡単に行うことができます。

次のようなメソッドを作って、ボタンクリックなどをトリガとして実行することで簡単にデータをGraphQL越しに永続化することができます。

private fun mutation(
    id: String = System.currentTimeMillis().toString(),
    username: String,
    content: String
) {
    val callback: GraphQLCall.Callback<CreateMessageMutation.Data> =
        object : GraphQLCall.Callback<CreateMessageMutation.Data>() {
            override fun onFailure(e: ApolloException) {
                e.message?.let {
                    toast(it)
                    Log.e(TAG, it, e)
                }
            }

            override fun onResponse(response: Response<CreateMessageMutation.Data>) {
                response.data()?.createMessage()?.let {
                    Log.i(TAG, it.toString())
                    editTextContent.setText("")
                }
            }
        }

    val input = CreateMessageInput
        .builder()
        .id(id)
        .username(username)
        .content(content)
        .build()

    awsAppSyncClient
        .mutate(CreateMessageMutation.builder().input(input).build())
        .enqueue(callback)
}

Amplifyのウィザードでセットアップした場合、デフォルトでAmazon DynamoDBをデータソースとして扱います。したがってミューテーション後はマネジメントコンソールからデータが格納された様子を確認することができます。

クエリを実装する

同様に、GraphQLではデータ取得操作をクエリと呼びます。
クエリもまったく同じように

  1. 型安全にリクエストやコールバックを作成し、
  2. AwsAppSyncClient#query

するだけでデータを取得することができます。

private fun query() {
    val callback: GraphQLCall.Callback<ListMessagesQuery.Data> =
        object : GraphQLCall.Callback<ListMessagesQuery.Data>() {
            override fun onFailure(e: ApolloException) {
                e.message?.let {
                    toast(it)
                    Log.e(TAG, it, e)
                }
            }

            override fun onResponse(response: Response<ListMessagesQuery.Data>) {
                val messages: MutableList<ListMessagesQuery.Item> =
                    response.data()?.listMessages()?.items() ?: mutableListOf()

                updateMessages(messages)

                val text = messages.joinToString()
                Log.i(TAG, text)
            }
        }
    awsAppSyncClient.query(
        ListMessagesQuery
            .builder()
            .limit(DEFAULT_LIMIT)
            .build()
    )
        .responseFetcher(AppSyncResponseFetchers.CACHE_AND_NETWORK)
        .enqueue(callback)
}

取得したデータは RecyclerView などに渡して画面に反映するのが典型的な使い方でしょう。

サブスクリプションを実装する

最後に、リアルタイムにミューテーションを通知してもらうためのサブスクリプションを実装します。 もうお気づきだと思いますが、これもまったく作法は同じです。

private fun subscription() {
    val subscription: OnCreateMessageSubscription =
        OnCreateMessageSubscription.builder().build()
    val callback = object : AppSyncSubscriptionCall.Callback<OnCreateMessageSubscription.Data> {
        override fun onFailure(e: ApolloException) {
            e.message?.let {
                toast(it)
                Log.e(TAG, it, e)
            }
        }

        override fun onResponse(response: Response<OnCreateMessageSubscription.Data>) {
            response.data()?.onCreateMessage()?.let {
                val item = ListMessagesQuery.Item(
                    it.__typename(),
                    it.id(),
                    it.username(),
                    it.content()
                )
                addMessage(item)
                Log.i(TAG, "subscription onResponse: ${it.toString()}")
            }
        }

        override fun onCompleted() {
            Log.i(TAG, "subscription onCompleted")
        }
    }
    val subscriptionWatcher: AppSyncSubscriptionCall<OnCreateMessageSubscription.Data> =
        awsAppSyncClient.subscribe(subscription)
    subscriptionWatcher.execute(callback)
}

新しいメッセージが追加されるごとに onResponse がコールバックされるので、それを RecyclerView が持つリストに追加して表示を更新するなどといった使い方が一般的でしょう。

f:id:fushiroyama:20191201055140j:plain
完成図

サインアウトを実装

最後にサインアウトを実装します。 サインアウト処理自体は AWSMobileClient#signOut メソッドを呼ぶだけです。

今回はオプションメニューに追加してみました。

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    when (item.itemId) {
        R.id.signOut -> {
            AWSMobileClient.getInstance().signOut(
                SignOutOptions.builder().invalidateTokens(true).build(),
                object : Callback<Void> {
                    override fun onResult(result: Void?) {
                        Log.i(TAG, "signOut(): onResult ok")
                    }

                    override fun onError(e: java.lang.Exception?) {
                        Log.e(TAG, "signOut(): onResult error")
                    }
                })
            return true
        }
        else -> {
            return super.onOptionsItemSelected(item)
        }
    }
}

f:id:fushiroyama:20191201055459j:plain
サインアウト

サインアウトすると自動的に認証状態の変更を検知するリスナが発火されてサインイン画面が再び表示されるはずです。

まとめ

ちょうど30分ぐらいでしょうか!GraphQLがこれほど簡単に使えることに驚かれたのではないでしょうか。

AmplifyはバックエンドはすべてAWSのサービスであり、設定や組み合わせは柔軟でスケールもします。この使い勝手の良さとカスタマイズ性のバランスがAmplifyの大きな魅力だと個人的には考えています。

今日の例はごくごく初歩的な内容に留めましたが、今後いくつかのエントリでAmplifyを使ったネイティブアプリ開発の実践的な例をご紹介できたらなと考えています。楽しみにお待ち下さい!