はじめに
業務でサーバーサイドとのやり取りをGraphQLを使ってやってみようということになり、AndroidアプリにAWS Amplify CLIとAndroid用AWS SDKを導入しました。
今回AWSの各サービスを統一して利用するため、Cognito認証を使います。
開発環境
- MacBook Pro 10.14.1
- Android Studio 3.2.1
公式ドキュメント
Amplify Android SDK Getting Start
https://aws-amplify.github.io/docs/android/start
事前準備
- Node.jsとnpmがマシンにインストールされていない場合はインストールします。
- 導入したAndroidアプリのディレクトリへ移動しAmplify Command Line Interface (Amplify CLI)をインストールします。
$ cd ./YOUR_PROJECT_FOLDER
$ npm install -g @aws-amplify/cli
導入手順
事前準備に引き続き同ディレクトリにてAmplifyの初期化を行います。
$ amplify init
上記のコマンドを実行すると、使用エディタ、AWSアカウント情報などを聞かれるので入力。エディタはIDEA 14 CE
を選択。
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project (<YOUR_PROJECT_NAME>)
? Choose your default editor: IDEA 14 CE
? Choose the type of app that you're building android
Describe your project:
? Where is your Res directory: app/src/main/res
Using default provider awscloudformation
AWS access credentials can not be detected.
? Setup new user No
For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html
? accessKeyId: (<YOUR_ACCESS_KEY_ID>)
? secretAccessKey: (<YOUR_SECRET_ACCESS_KEY>)
? region: ap-northeast-1
⠧ Initializing project in the cloud... // 以下略
デプロイ用の初期AWSクラウドリソース作成しました。プロジェクトは初期化されクラウドに接続されました!
$ amplify push
上記のコマンドを実行すると./app/src/main/res/raw
にawsconfiguration.json
が追加されました。
この時点ではまだawsconfiguration.json
にはデフォルトの情報しかありません。
{
"UserAgent": "aws-amplify-cli/0.1.0",
"Version": "1.0",
"IdentityManager": {
"Default": {}
}
}
以下はawsconfiguration.json
にAPIキーを使用する場合の記述を追加しています。
{
"UserAgent": "aws-amplify-cli/0.1.0",
"Version": "1.0",
"IdentityManager": {
"Default": {}
},
"AppSync": {
"Default": {
"ApiUrl": "https://<xxxxxxxxxx>.appsync-api.ap-northeast-1.amazonaws.com/graphql",
"Region": "ap-northeast-1",
"AuthMode": "API_KEY",
"ApiKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
次に、GraphQLスキーマやレスポンスのデータクラスなどのコード生成を実行します。
$ amplify add codegen --apiId XXXXXXXXXXXXXXXXXXXXX
実行すると、./app/src/main/graphql/../queries.graphql
や
./app/build/generated
配下にGetPostQueryなどクラスが生成されました。
次からはAndroidStudioを起動し、コードの追加していきます。
プロジェクト配下の build.gradle の dependencies に以下を追加します。
dependencies {
// other classpath...
classpath "com.amazonaws:aws-android-sdk-appsync-gradle-plugin:$versions.appsync"
}
appのbuild.gradle の 一番上に以下を追加。
apply plugin: 'com.amazonaws.appsync'
ここでなんでEclipseなんだろうと思ってしまった。
AWS Mobile SDK、AppSync SDK、MQTT(Message Queuing Telemetry Transport) アプリを実装する上で必要なライブラリも依存関係に追加します。
//Base SDK
implementation 'com.amazonaws:aws-android-sdk-core:2.8.2'
//AppSync SDK
implementation 'com.amazonaws:aws-android-sdk-appsync:2.6.28'
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.0'
implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'
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 ... // 略
<service android:name="org.eclipse.paho.android.service.MqttService" />
以下はパーミッションとその使用目的を一覧にしています。各パーミッションの使用目的が何か随時わかれば更新していきます。
permission | 使用目的 |
---|---|
INTERNET, ACCESS_NETWORK_STATE |
SDKの初期化に必要 |
WAKE_LOCK |
MqttService.NetworkConnectionIntentReceiver にて必要.サーバーへの接続が失われた後、再び使用可能なデータ接続が確立するまで待機するために必要 |
READ_PHONE_STATE | 不明 |
WRITE_EXTERNAL_STORAGE | 不明 |
READ_EXTERNAL_STORAGE | 不明 |
MainActivity#onCreate()に以下を追加します。
AWSAppSyncClientの初期化処理にてawsconfiguration.jsonからserverUrl、regionおよびAuth Modeを読み込みます
// apikey で認証
awsAppSyncClient = AWSAppSyncClient.builder()
.context(applicationContext)
.awsConfiguration(AWSConfiguration(applicationContext))
.build()
上記までで、一度実行してみます。
AWS AppSync SDKの初期化が成功しました。
GraphQL実行
GraphQLでは以下三つの操作があります。
- Query 読み取り専用フェッチ
- Mutation 書き込み、その後のフェッチ
- Subscription サーバと長時間接続し、データを継続して受信する
query
Callback インスタンスの作成と AwsAppSyncClient#enqueue()
をコールするだけでフェッチができました。
ネイティブアプリっぽいコードでかけているでしょうか。
responseFetcher()では値をキャッシュから得るのか、ネットワークから得るのかなど指定でき、
用途によって使い分けることができるようですが、ドキュメントではAppSyncResponseFetchers.CACHE_AND_NETWORK
を推奨しています。
ネットワークを介してデータを取得する前に、まずローカルキャッシュから結果を取得するためとのことです。
オフラインサポートが可能とのことですが、ここは別途検証が必要そうです。
fun query() {
val builder = GetPostQuery.builder()
.id("XXXXXXXXXXXXXXXXXXXXX")
awsAppSyncClient.query(builder.build())
.responseFetcher(AppSyncResponseFetchers.CACHE_AND_NETWORK)
.enqueue(getPostCallback)
}
private val getPostCallback = object: GraphQLCall.Callback<GetPostQuery.Data>() {
override fun onResponse(response: Response<GetPostQuery.Data>) {
Timber.d("GetPostQuery %s", response.data().toString())
}
override fun onFailure(e: ApolloException) {
Timber.e(e.message)
}
}
responceをログ出力した結果
GetPostQuery Data{getPost=GetPost{__typename=Post, id=XXXXXXXXXXXXXXXXXXXXX, author=iridge, content=appsync, views=1, comments=[]}}
mutation
fun runMutation() {
val createCommentMutation =
CreateCommentMutation.builder()
.author("iridge")
.postId("XXXXXXXXXXXXXXXXXXXXX")
.content("Use AppSync! content")
.build()
awsAppSyncClient.mutate(createCommentMutation)
.enqueue(mutationCallback)
}
private val mutationCallback = object: GraphQLCall.Callback<CreateCommentMutation.Data>() {
override fun onResponse(response: Response<CreateCommentMutation.Data>) {
Timber.d("CreateCommentMutation", response.data().toString())
}
override fun onFailure(e: ApolloException) {
Timber.e("Error", e.toString())
}
}
responceをログ出力した結果
demo.app D/AppSync: Adding object: CreateCommentMutation
{"query":"mutation CreateComment($postId: String!, $author: String!, $content: String!) { createComment(postId: $postId, author: $author, content: $content) { __typename id postId author content upvotes downvotes }}","variables":{"postId":"XXXXXXXXXXXXXXXXXXXXX","author":"iridge","content":"Use AppSync! content"}}
subscription
fun subscription() {
val subscription = AddedCommentByAuthorSubscription.builder().build()
subscriptionWatcher = awsAppSyncClient.subscribe(subscription)
subscriptionWatcher.execute(subCallback)
}
private val subCallback =
object : AppSyncSubscriptionCall.Callback<AddedCommentByAuthorSubscription.Data> {
override fun onResponse(response: Response<AddedCommentByAuthorSubscription.Data>) {
Timber.d("Response", response.data()!!.toString())
}
override fun onFailure(e: ApolloException) {
Timber.e("Error", e.toString())
}
override fun onCompleted() {
Timber.d("Completed", "Subscription completed")
}
}
subscriptionの結果ログ
demo.app D/RealSubscriptionManager: Adding
demo.app.MainActivity$subCallback$1@4410fd4 listener to subObject: com.amazonaws.amplify.generated.graphql.AddedCommentByAuthorSubscription@c3336ec got: com.amazonaws.amplify.generated.graphql.AddedCommentByAuthorSubscription@c3336ec
demo.app D/SubscriptionObject: Adding listener to com.amazonaws.mobileconnectors.appsync.subscription.SubscriptionObject@3127ab5
demo.app D/RetryInterceptor: Retry Interceptor called
demo.app D/AppSyncSigV4SignerInterceptor: Signer Interceptor called
demo.app D/AppSyncSigV4SignerInterceptor: Subscriber ID is 111AAA2222-BB33-CC44-DD55-EEEEEE666666
demo.app I/RetryInterceptor: Returning network response: success
demo.app D/RealSubscriptionManager: subscribe called [/addedCommentByAuthor/]
demo.app D/RealSubscriptionManager: Adding subscription watcher com.amazonaws.mobileconnectors.appsync.subscription.SubscriptionObject@3127ab5 to topic /addedCommentByAuthor/ total topics: 1
demo.app D/RealSubscriptionManager: Attempting to make [1] MQTT clients]
demo.app D/MqttSubscriptionClient: Set transmit false com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997
demo.app D/MqttSubscriptionClient: Set transmit false com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997
demo.app D/MqttSubscriptionClient: Calling MQTT Connect with actual endpoint
demo.app D/AlarmPingSender: Register alarmreceiver to MqttServiceMqttService.pingSender.6g57fvwpo5ewjo7yw6ftys5nky
demo.app D/AlarmPingSender: Schedule next alarm at 1542680090686
demo.app D/AlarmPingSender: Alarm scheule using setExactAndAllowWhileIdle, next: 30000
demo.app D/RealSubscriptionManager: Connection successful. Will subscribe up to 1 topics
demo.app D/RealSubscriptionManager: Connecting to topic:[/addedCommentByAuthor/]
demo.app D/MqttSubscriptionClient: com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997 Attempt to subscribe to topic /addedCommentByAuthor/
demo.app D/RealSubscriptionManager: Made [1] MQTT clients
demo.app D/RealSubscriptionManager: Muting the old clients [ 1] in total
demo.app D/MqttSubscriptionClient: Set transmit false com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@20d2ce7
demo.app D/RealSubscriptionManager: Unmuting the new clients [1] in total
demo.app D/MqttSubscriptionClient: Set transmit true com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@957f997
demo.app D/RealSubscriptionManager: Closing the old clients [1] in total
demo.app D/RealSubscriptionManager: Closing client: com.amazonaws.mobileconnectors.appsync.subscription.mqtt.MqttSubscriptionClient@20d2ce7
demo.app D/AlarmPingSender: Unregister alarmreceiver to MqttServicekcyggvo75zaonbq33hgkcuttle
demo.app D/MqttSubscriptionClient: Successfully closed the connection.
demo.app D/MqttSubscriptionClient: connection lost isTransmitting: false
demo.app D/AlarmPingSender: Sending Ping at:1542680090691
demo.app D/AlarmPingSender: Schedule next alarm at 1542680120695
demo.app D/AlarmPingSender: Alarm scheule using setExactAndAllowWhileIdle, next: 30000
demo.app D/AlarmPingSender: Success. Release
demo.app D/AlarmPingSender: Success. Release lock(MqttService.client...
Amazon Cognito User Poolsでの認証
Cognito認証はユーザーを認証し、AWS のサービスに対するアクセスをユーザーに許可するための認証方式です。
詳しい説明はこちらをみていただくと良いかと思います。
https://docs.aws.amazon.com/cognito/index.html#lang/ja_jp
appのbuild.gradle の dependencies に以下を追加します。
// AWS Cognito
implementation 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.8.2'
// For the drop-in UI also:
implementation 'com.amazonaws:aws-android-sdk-auth-userpools:2.8.2'
awsconfiguration.json
をCognito認証方式に書き換えます。
AppClientSecret
の要素はサーバ側の仕様として不要ということで指定していません。
{
"CredentialsProvider": {
"CognitoIdentity": {
"Default": {
"PoolId": "XX-XXXX-X:XXXXXXXX-XXXX-1234-abcd-1234567890ab",
"Region": "XX-XXXX-X"
}
}
},
"CognitoUserPool": {
"Default": {
"PoolId": "XX-XXXX-X_abcd1234",
"AppClientId": "XXXXXXXX",
"Region": "XX-XXXX-X"
}
},
"AppSync": {
"Default": {
"ApiUrl": "https://XXXXXX.appsync-api.XX-XXXX-X.amazonaws.com/graphql",
"Region": "XX-XXXX-X",
"AuthMode": "AMAZON_COGNITO_USER_POOLS"
}
}
}
以下のようにCognitoUserPoolを引数にAWS AppSync SDKの初期化処理をします。
val cognitoUserPool =
CognitoUserPool(
context,
BuildConfig.COGNITO_USER_POOL_ID,
BuildConfig.COGNITO_CLIENT_ID,
null,
Regions.fromName(BuildConfig.COGNITO_REGION)
)
val cognitoUserPoolsAuthProvider = BasicCognitoUserPoolsAuthProvider(cognitoUserPool)
// AWS AppSync SDKの初期化
val client = AWSAppSyncClient.builder()
.context(context)
.awsConfiguration(AWSConfiguration(context))
.cognitoUserPoolsAuthProvider(cognitoUserPoolsAuthProvider) // Cognito で認証
.build()
// AWS Mobile SDKのイニシャライズ
AWSMobileClient.getInstance()
.initialize(
applicationContext,
object : com.amazonaws.mobile.client.Callback<UserStateDetails> {
override fun onResult(result: UserStateDetails?) {
if (result != null) {
Timber.d("initialize %s", result.userState)
when (result.userState) {
UserState.GUEST -> Timber.i("userState %s", "user is in guest mode")
UserState.SIGNED_OUT -> {
Timber.i("userState %s", "user is signed out")
// サインアップおよびログイン処理を行う
signUpOrSignIn()
}
UserState.SIGNED_IN -> Timber.i("userState %s", "user is signed in")
UserState.SIGNED_OUT_USER_POOLS_TOKENS_INVALID -> Timber.i(
"userState %s",
"need to login again"
)
UserState.SIGNED_OUT_FEDERATED_TOKENS_INVALID -> Timber.i(
"userState %s",
"user logged in via federation, but currently needs new tokens"
)
else -> Timber.i("userState %s", "unsupported")
}
}
}
override fun onError(e: java.lang.Exception?) {
Timber.e("initialize %s", e!!)
}
}
)
fun signUpOrSignIn() {
if (!preference.isSignUpCompleted()) {
cognitoAuthenticator.signUpAsAws()
.subscribeOn(schedulerProvider.io())
.observeOn(schedulerProvider.io())
.subscribeBy(
onSuccess = {
Timber.d("login success. $it")
signIn()
}
,
onError = { Timber.e(it) })
} else {
cognitoAuthenticator.login()
.subscribeOn(schedulerProvider.io())
.observeOn(schedulerProvider.io())
.subscribeBy(
onSuccess = {
Timber.d("login success. $it")
}
,
onError = { Timber.e(it) })
}
}
private fun signUp() {
cognitoAuthenticator.signUpAsAws()
.subscribeOn(schedulerProvider.io())
.observeOn(schedulerProvider.io())
.subscribeBy(
onSuccess = {
// Timber.d("login success. ${it.first?.username}")
Timber.d("login success. $it")
signIn()
}
,
onError = { Timber.e(it) })
}
private fun signIn() {
cognitoAuthenticator.loginAsAws()
.subscribeOn(schedulerProvider.io())
.observeOn(schedulerProvider.io())
.subscribeBy(
onSuccess = {
// Timber.d("login success. ${it.first?.username}")
Timber.d("login success. $it")
}
,
onError = { Timber.e(it) })
}
fun signUp(): Single<Optional<SignUpResult>> {
val attribute = HashMap<String, String>()
.also { it["phone_number"] = "+8109000000000" }
return Single.create { emitter ->
awsMobileClient.signUp(
username,
password,
attribute,
null,
object : com.amazonaws.mobile.client.Callback<SignUpResult> {
override fun onResult(result: SignUpResult?) {
// サインアップ成功時の処理
}
override fun onError(e: java.lang.Exception?) {
// 例外処理
}
})
}
}
fun login(): Single<Optional<SignInResult>> {
return Single.create { emitter ->
awsMobileClient.signIn(
username,
password,
null,
object: com.amazonaws.mobile.client.Callback<SignInResult> {
override fun onResult(result: SignInResult?) {
// サインアップ成功時の処理
}
override fun onError(e: java.lang.Exception?) {
// 例外処理
}
}
)
}
}
signUp()
での処理を実行したところ、アプリは落ちないものの以下のエラーが発生しました。
awsMobileClient.signIn()
のなかでCognitoSecretHash.getSecretHash(userId, clientId, clientSecret)
がコールされるのですが、SecretKeySpec
インスタンスを生成する際にsecretKeyが空でエラーが返ってしまいます。
demo.app E/MainActivity$signUp: java.lang.IllegalArgumentException: Empty key
at javax.crypto.spec.SecretKeySpec.<init>(SecretKeySpec.java:96)
at com.amazonaws.mobileconnectors.cognitoidentityprovider.util.CognitoSecretHash.getSecretHash(CognitoSecretHash.java:57)
at com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserPool.signUpInternal(CognitoUserPool.java:416)
at com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserPool.signUp(CognitoUserPool.java:378)
at com.amazonaws.mobile.client.AWSMobileClient$7.run(AWSMobileClient.java:1097)
at com.amazonaws.mobile.client.internal.InternalCallback$1.run(InternalCallback.java:101)
at java.lang.Thread.run(Thread.java:764)
SDKのgithubを確認していたところ下記にてバグ報告されており、2018年12月4日時点でこちらのissueはまだOpenのままです。
そこでサーバ側にクライアントIDとシークレットキーを発行しなおしてもらいawsconfiguration.json
の
AppClientSecret
の要素を追加することで、エラーが解消されました。
{
// Other configurations
"CognitoUserPool": {
"Default": {
"PoolId": "XX-XXXX-X_abcd1234",
"AppClientId": "XXXXXXXX",
"AppClientSecret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"Region": "XX-XXXX-X"
}
}
// Other configurations
}
まとめ
今回GraphQLのスキーマはサーバより取得しましたが、正式にはクライアントが欲しい情報をスキーマにしサーバ側にプッシュしていきます。
AWS Mobile SDKを用いたCognito認証部分で、現状も修正されていないバグを発見したりと、ドキュメントを見るだけではすんなりとはいきませんでした。
リリースこそしているものの、issueも頻繁に更新されており、watchしていく必要があると感じました。
queryにてAppSyncResponseFetchers.CACHE_AND_NETWORK
について触れましたが、これから実装していくにあたってネットワーク接続のオプションなど、ここでは触れていないApolloの仕様も知る必要があると考えています。