言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

AndroidからGoogle Sign In経由、AWS Cognitoでuser管理してDynamo DBを操作する ~AWS Java SDKを添えて~

ふと、「そういえばNo SQLのDBって業務で関わったことないな」と思いついたのでAWS DynamoDBをさわってみることにした。
Androidから直接さわるためAWS Java SDKを利用したが、ググって引っかかる情報は結構古くてもう存在しないAPIを使っていたり、公式documentのsampleコードも使えなかったりと苦労したのでメモを残しておく。
とはいえSDKコードgithubで公開されているし、Java docも用意されているので、これからさわる人はこっちからコードを辿ったりした方が早いかもしれない。

1.やったこと概要

f:id:muumuumuumuu:20200416180342p:plain

  1. Android appからGoogle Sign In
  2. 1で取得したtokenを用いてAWS CognitoにOpen ID Connectでlog in
  3. 2で取得したcredentialを用いてAWS DynamoDBのtableをread/write

2. 手順詳細

Google Sign In を実装する

まずは画面ぽちぽちしてFirebase側の設定を終わらす。このあたりを参考にしたらできるので手順は省略。

firebase.google.cn

developers.google.cn

設定が終わったらコードを書いていく。まずapp moduleのbuild.gradleにDependenciesを追加する。

implementation 'com.google.firebase:firebase-auth:19.3.0'
implementation 'com.google.android.gms:play-services-auth:17.0.0'

認証画面のonCreate()GoogleSignInClientを初期化。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.default_web_client_id))
            .requestEmail()
           .build()
   googleSignInClient = GoogleSignIn.getClient(this, gso)
}

onStart()ですでにGoogle Log in 済み状態かどうか取得。前回のLog in情報が残っていればGoogle Sign Inはスキップ。

 override fun onStart() {
    super.onStart()

    val account = GoogleSignIn.getLastSignedInAccount(this)
    if (account == null) {
        authorizeWithGoogle()
    } else {
        logInToCognito(account)
    }
}

未Log in状態であればGoogleが用意しているLog inボタンを表示してclick listenerを設定する。

private fun authorizeWithGoogle() {
    setContentView(R.layout.activity_launch)

    findViewById<SignInButton>(R.id.sign_in_button).apply {
        setSize(SignInButton.SIZE_WIDE)
        setOnClickListener {
            // 第二引数のrequest code(RC_SIGN_IN)は適宜定義してください
            startActivityForResult(googleSignInClient.signInIntent, RC_SIGN_IN)
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LaunchActivity">

    <com.google.android.gms.common.SignInButton
        android:id="@+id/sign_in_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

上記のボタンが押下されるとGoogleSignIn側でいい感じにLog in処理が走ってくれるので結果を受け取るコードを書く。

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == RC_SIGN_IN) {
        handleSignInResult(GoogleSignIn.getSignedInAccountFromIntent(data))
    }
}

private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
    try {
        val account = completedTask.getResult(ApiException::class.java)
        if (account != null) {
            logInToCognito(account)
        } else {
            // handle error case
        }
    } catch (ex: ApiException) {
        // handle error case
    }
}

Open ID Connectを使用してGoogleのuser account情報を元にAWS Cognitoにsing in する

この辺りを参考にしてAWSのmanegement画面をぽちぽちしてIdentity Poolを作成する。

dev.classmethod.jp

Cognitoの設定が終わったらコードを書く。

まずはbuild.gradleにaws sdkのdependenciesを追加

def aws_android_sdk_version = '2.15.0'
implementation "com.amazonaws:aws-android-sdk-core:$aws_android_sdk_version"
implementation "com.amazonaws:aws-android-sdk-ddb:$aws_android_sdk_version"

このサンプルコードでは画面間でCognitoCachingCredentialsProviderを共有したいので、雑にApplicationクラスに持たせるようなコードを書いている。

private fun logInToCognito(account: GoogleSignInAccount) {
    val application = application as MyApplication
    application.initCredentialProvider(account.idToken)
}

Applicationクラスはこんな感じ。

class MyApplication : Application() {

    var credentialsProvider: CognitoCachingCredentialsProvider? = null

    fun initCredentialProvider(token: String?) {
        credentialsProvider = CognitoCachingCredentialsProvider(
            applicationContext,
            YOUR_IDENTITY_POOL_ID,
            YOUR_REGION
        ).apply {
            logins = mapOf("accounts.google.com" to token)
        }
    }
}

これでDynamo DBにアクセスするためのcredential providerの準備は完了。

Dynamo DBにアクセスする

再びAWSのmanagement画面側をぽちぽちしてDynamo DBにTableを定義する。Tableが定義されるとAmazon Resource Name (ARN)が発行されるので、CogniteでIdentity Poolを作った時に自動で作成されたIAM RoleにこのARNへのpermissionをつける。Resourceへのpermissionだけでなく、Read/WriteといったActionsへのpermissionも一緒につける。

ぽちぽちが終わったらAndroid側のコードに戻る。
Credential providerからAmazonDynamoDBClientを生成してDynamo DBにアクセスする。ここでsetRegion()を設定する必要があることに気がつかずしばらくはまってしまった…

val dynamoDBClient = AmazonDynamoDBClient(credentialsProvider).apply {
    setRegion(Region.getRegion(Regions.US_EAST_1)) // Regionは適宜置き換えてください
}

ここから先実際にtableにアクセスするにはnetwork通信が走るので別threadで処理を実行する必要がある。サンプルコードでは雑にKotlin coroutine(しかも一部)で書いているので適宜使いたい非同期処理に置き換えてほしい。

まずはread系。table全体のitemsを取得したい時はこんな感じ。

withContext(Dispatchers.IO) {
    try {
        val result = dynamoDBClient.scan(ScanRequest(YOUR_TABLE_NAME))
        // Do whatever you want
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

このresultScanResult型になっていて、getItems()経由で各itemのListを取得することができる。さらにこのListの型が<String, AttributeValue>のMapとなっている。このAttributeValueが若干曲者で、例えばNumber型が欲しい場合はgetN()を呼ぶ必要があるが、戻り値自体はStringなのでtoInt()を呼んだりしてキャストしてやる必要がある。詳細は下記のdoc参照。

docs.aws.amazon.com

また、これはscanに限らずtableにアクセスする場合全般に言える話だが、tokenが切れていたらNotAuthorizedExceptionがthrowされるので、この場合は手動でtokenをrefreshする必要がある。

条件を指定するquery()の使い方はこんな感じ。

withContext(Dispatchers.IO) {
    try {
        val result = dynamoDBClient.query(
            QueryRequest(YOUR_TABLE_NAME).apply {
                keyConditionExpression = "id = :id"
                expressionAttributeValues = mapOf(
                    ":id" to AttributeValue(credentialsProvider.identityId)
                )
            }
        )
        // Do whatever you want
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

上記の例ではidというpartition keyが存在するtableに対し、ログイン中のユーザのIDに一致するitemsを取得している。
key condition expressionの記述方法はJava doc を参照。また、このdocに書いてある通り、

Use the ExpressionAttributeValues parameter to replace tokens such as :partitionval and :sortval with actual values at runtime.

とのことなので、AttributeValueでwrapしたvaluesetExpressionAttributeValues()で設定する必要がある。

次にWrite系。

まずはtableにitemを追加するputItem()のメモ。

withContext(Dispatchers.IO) {
    try {
        dynamoDBClient.putItem(
            PutItemRequest(
                YOUR_TABLE_NAME,
                mapOf(
                    "id" to AttributeValue(credentialsProvider.identityId),
                    "name" to AttributeValue(account.displayName),
                    "image_url" to AttributeValue(account.photoUrl.toString())
                )
            )
        )
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

追加したいItemのattributeをmapで渡すだけ。ここでもvalueAttributeValueでwrapする必要がある。

最後にAtomic Counterを使ってItemを更新する場合の例。

withContext(Dispatchers.IO) {
    try {
        dynamoDBClient.updateItem(
            UpdateItemRequest()
                .withTableName(YOUR_TABLE_NAME)
                .withKey(
                    mapOf(
                        "partition_key" to AttributeValue(partitionKey),
                        "sort_key" to AttributeValue(sortKey)
                    )
                )
                .apply {
                    updateExpression = "add #count :i"
                    expressionAttributeNames = mapOf(
                        "#count" to "count"
                    )
                    expressionAttributeValues = mapOf(
                        ":i" to AttributeValue().apply {
                            n = "1"
                        }
                    )
                }
        )
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

上記の例では、withKey() でpartition keyとsort keyを渡して更新したいitemを指定し、更新の条件はapply{}節の中に記述している。サンプルではcountというNumber型のattributeを現在のものから1インクリメントしている。ちなみにAttributeValueのpublic constructorはStringを取れるのだが、ここにStringである"1"を渡してしまうとString型のvalueとして処理されてしまうので、この場合はparameterなしconstructorを使ってinstanceを生成してからsetN()でString "1"を渡す必要がある。

言いたいことは以上です。