アプリからサーバまで全部Kotlinなお手軽サービス開発 #ktac2015

More than 1 year has passed since last update.

この記事は Kotlin Advent Calendar 2015 の5日目の記事です。

昨日は @daneko0123さんretrolambda - AndroidをKotlinとRetrolamda+Lombokとで作る場合の比較でした。

はじめに

KotlinでAndroidアプリを書いていると楽しくなりますね。
でも、ちょっと本格的なアプリを作ろうと思うとサーバーサイドも欲しくなってきます。

だけどサーバーサイドなんて実装したこと無いし...Kotlinで書きたいし...というあなた(私のことです)でもお気軽に始められる環境Google App Engine (Google Cloud Endpoints) でサーバーサイドを構築してみます。

この記事では、GoogleアカウントでOAuth認証したユーザが書き込む掲示板風なものを作るという想定で進めていきます。

環境

この記事は下記の環境で実行しています。

  • Android Studio 2.0 Preview 2
  • Kotlin 1.0.0-beta-3594

GAEモジュールの追加

Android StudioでGAEのモジュールを導入する方法は色々な方が記事を書いてくれているので参考にしてください。

Module Type は App Engine Java Endpoints Module です。

参考
AndroidStudio - Android Studio + Google App Engine でお手軽バックエンドサーバ構築 - Qiita

GAEモジュールにKotlinを設定

まずは、作成したbackendモジュールに自動で生成されている適当なJavaソースをShift+Command+KでKotlinに変換します。

その後、メニューからTools -> Kotlin -> Configure Kotlin in Projectと選択しbuild.gradleをKotlin用に設定します。

ただし、このままではAndroid向けの設定になっているため少し手を加えます。

kotlin-androidkotlinに変更

Androidではなく通常のJavaプロジェクトなのでkotlin-androidからkotlinに変更します。

build.gradle
//apply plugin: 'kotlin-android'
apply plugin: 'kotlin'

androidブロックを削除

androidブロックもAndroidではないので削除します。

build.gradle
//android {
//    sourceSets {
//        main.java.srcDirs += 'src/main/kotlin'
//    }
//}

これでGAEでKotlinを使う準備ができました。

Entityの作成

今回はデータストアへのアクセスにObjectifyを使用します。
Objectifyで使用するEntityをみんな大好きdataクラスで書きたいところですが、ObjectifyのEntityは引数なしのデフォルトコンストラクタを持っている必要があるため、今回は普通のクラスで作ることにします。

掲示板のメッセージ

@Idアノテーションを付けたLong型がnullのとき保存時に自動でIDを生成してくれるので、idはNullable & nullで初期化しておきます。

BoardMessage.kt
@Entity
class BoardMessage() {
    @Id var id: Long? = null
    lateinit var message: String
    lateinit @Index var date: Date
    lateinit @Index var user: Ref<BoardUser>
}

掲示板のメッセージ(レスポンス用)

ObjectifyのRefがエンティティに含まれているとAndroid Clientへのレスポンスに使えないのでレスポンス用の定義を作成。

data class MessageResponse(
        val id: Long,
        val message: String,
        val userName: String,
        val date: Date
)

ユーザ情報

ユーザ情報はメールアドレスをキーに作成します。

BoardUser.kt
@Entity
class BoardUser() {
    lateinit @Id var email: String
    lateinit @Index var userName: String
}

APIの作成

APIを定義するクラスには@Apiアノテーションを付与します。WEB_CLIENT_IDANDROID_CLIENT_IDは事前にDeveloper Consoleで作成しておきます。

今回はOAuth認証を使うのでscopeも忘れずに定義します。

MessageBoardEndpoint.kt
@Api(
        name = "boardApi",  // URLの一部 & Androidで使うクライアントクラスの名前になる
        version = "v1",
        scopes = arrayOf("https://www.googleapis.com/auth/userinfo.email"),
        clientIds = arrayOf(WEB_CLIENT_ID, ANDROID_CLIENT_ID),
        audiences = arrayOf(ANDROID_AUDIENCE) // ANDROID_AUDIENCE は WEB_CLIENT_ID と同じ値
        namespace = ApiNamespace(
                ownerDomain = "kotlingaesample.chibatfching.com",
                ownerName = "chibatching",
                packagePath = "")
)
class MessageBoardEndpoint {
    init {
        // Objectifyから定義したEntityを使えるように初期化
        ObjectifyService.register(BoardMessage::class.java)
        ObjectifyService.register(BoardUser::class.java)
    }
    ...
}

次からはこのクラスに実装していく各APIのエンドポイントです。

ユーザ登録

ユーザはメールアドレスとユーザ名を登録します。メールアドレスはOAuthで確認したGoogleアカウントです。

MessageBoardEndpoint.kt
@ApiMethod(
        path = "users/",
        name = "users.register",
        httpMethod = ApiMethod.HttpMethod.POST
)
fun registerUser(@Named("user_name") userName: String, auth: User?): BoardUser {
    // OAuth認証されているか確認
    auth ?: throw UnauthorizedException("Not authorized.")
    // ユーザ名の重複をチェック
    if (ofy().load().type(BoardUser::class.java).filter("userName", userName).count() > 0) {
        throw ConflictException("$userName is not available.")
    }
    // ユーザを生成
    val boardUser = BoardUser().apply {
        this.email = auth.email
        this.userName = userName
    }
    // 生成したユーザをデータストアに保存
    ofy().save().entity(boardUser).now()
    return boardUser
}

@ApiMethodアノテーションのパラメータと@Namedアノテーションは次の意味があります。

  • path: APIのパス
  • name: 自動生成されるAndroidクライアントでAPIを実行するときのメソッド名
    • BoardApi#user().register(userName: String)
  • @Namedアノテーション: リクエストのパラメータ名

User?型の引数があるとそのAPIはユーザ認証が必要になります。
認証されていないリクエストが来たときは、この引数にnullが渡されてくるので判定して必要な処理を行います。ここでは、401エラーを返しています。

メッセージ投稿

メッセージの投稿時にはPOSTされた文字列と日時、ユーザへの参照を付けて保存。

MessageBoardEndpoint.kt
@ApiMethod(
        path = "messages/",
        name = "message.put",
        httpMethod = ApiMethod.HttpMethod.POST
)
fun putMessage(@Named("message") message: String, auth: User?) {
    // OAuth認証されているか確認
    auth ?: throw UnauthorizedException("Not authorized.")
    // ユーザが登録済みならメッセージを作成して投稿
    getUserInfo(auth.email)?.let {
        val boardMessage = BoardMessage().apply { // applyでレシーバを編集して返す
            this.message = message
            this.date = Date()
            this.user = Ref.create(it)
        }
        ofy().save().entity(boardMessage)
        return
    }
    throw NotFoundException("Requested user is not found.")
}

/**
 * メールアドレスをキーにユーザ情報を取得、登録されていない時はnullを返す
 */
private fun getUserInfo(email: String): BoardUser? {
    return ofy().load().type(BoardUser::class.java).id(email).now()
}

メッセージ一覧取得

メッセージ一覧をObjectify経由で取得し、レスポンス用のオブジェクトにマッピングします。

MessageBoardEndpoint.kt
@ApiMethod(
        path = "messages/",
        name = "message.list",
        httpMethod = ApiMethod.HttpMethod.GET
)
fun getMessageList(auth: User?): List<MessageResponse> {
    // OAuth認証されているか確認
    auth ?: throw UnauthorizedException("Not authorized.")
    return ofy().load().type(BoardMessage::class.java)
            .order("-date")  // 日付降順でソート
            .limit(20)  // 最新20件を取得
            .list()  // リストに変換
            .map { MessageResponse(it.id!!, it.message, it.user.get().userName, it.date) }  // レスポンス用のオブジェクトにマッピング
}

ユーザ毎のメッセージ一覧取得

ユーザ毎のメッセージを取得したい場合は次のように書くことが出来ます。

MessageBoardEndpoint.kt
@ApiMethod(
        path = "users/{user}/messages",
        name = "message.user",
        httpMethod = ApiMethod.HttpMethod.GET
)
fun getMessageListByUser(@Named("user") userName: String, auth: User?): List<MessageResponse> {
    // OAuth認証されているか確認
    auth ?: throw UnauthorizedException("Not authorized.")
    // 指定されたユーザを取得、ユーザが存在しない時は404
    val boardUser =
            ofy().load().type(BoardUser::class.java).filter("userName =", userName).first().now()
                    ?: throw NotFoundException("$userName does not exist.")
    return ofy().load().type(BoardMessage::class.java)
            .filter("user =", Ref.create(boardUser)) // ユーザでフィルタリング
            .order("-date")
            .limit(20)
            .list()
            .map { MessageResponse(it.id!!, it.message, it.user.get().userName, it.date) }
}

Android Client側の実装

定義したAPIのAndroidクライアントが自動生成されるので、それを使ってAPIを実行します。 APIの実行はそのまま実行すると同期的に行われるので注意してください。

今回は簡易的にthreadブロックで実行しています。

クライアントの準備

APIクラスの@Apiアノテーションのnameで指定した名前のAPIクライアントを初期化します。
今回はOAuth認証を使うのでcredentialについても設定します。

private val boardApi: BoardApi by lazy {
    BoardApi.Builder(NetHttpTransport(), AndroidJsonFactory(), credential)
            .setApplicationName("Kotlin GAE Sample")
            .setRootUrl("https://hogehoge.appspot.com/_ah/api/")
//                .setRootUrl("http://192.168.xxx.xxx:8080/_ah/api/")  // ローカル環境でテスト
            .build()
}

private val credential: GoogleAccountCredential by lazy {
    GoogleAccountCredential
            .usingAudience(this, WEB_CLIENT_ID)
            .setSelectedAccountName("") // アカウント名はAccount Pickerを使用して後から設定する。
}

Googleアカウントの選択

Account Pickerを使用してアカウントを選択してcredentialに設定します。

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

    ...

    chooseAccount()
}

/**
 * Account Pickerでアカウントを選択
 */
private fun chooseAccount() {
    startActivityForResult(credential.newChooseAccountIntent(), REQUEST_ACCOUNT_PICKER)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        REQUEST_ACCOUNT_PICKER ->
            // ユーザが選択したGoogleアカウントの認証情報をcredentialにセット
            data?.extras?.getString(AccountManager.KEY_ACCOUNT_NAME)?.let {
                credential.setSelectedAccountName(it)
                thread {
                    try {
                        // ユーザ名 chibatching で登録。実際にはユーザが入力する。
                        registerUser("chibatching")
                    } catch (e: IOException) {
                        e.printStackTrace()
                    }
                }
        }
    }
}

ユーザ登録

Googleアカウントの選択後にユーザ登録。

private fun registerUser(userName: String) {
    thread {
        try {
            boardApi.user().register(userName).execute()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

メッセージ投稿

こちらはただ実行するだけです。

postMessage("message $it is sent!")

private fun postMessage(message: String) {
    thread {
        boardApi.message().put(message).execute()
        Log.d(TAG, "message \"$message\" is sent")
    }
}

一覧の取得

AndroidクライアントにはCallbackなどの設定は無いようなので、データ取得後の処理を指定できるようなメソッドを作成しておきます。

getMessages {
    it?.forEach {
        Log.d("List: ${this.javaClass.simpleName}", "${it.userName}: ${it.message} (${it.date.toString()})")
    }
}
getMessages("hoge") {
    it?.forEach {
        Log.d("Filtered: ${this.javaClass.simpleName}", "${it.userName}: ${it.message} (${it.date.toString()})")
    }
}

private fun getMessages(userName: String? = null, callback: (List<MessageResponse>?) -> Unit) {
    thread {
        val list = if (userName == null) {
            boardApi.message().list().execute().items
        } else {
            boardApi.message().user(userName).execute().items
        }

        runOnUiThread { callback(list) }
    }
}

まとめ

データの保存、取得ぐらいのサーバサイドであればKotin + GAEで簡単にすっきりと作成することができました。
もっと本格的なアプリのときどうなるかはなんとも言えませんが、Kotlinの素敵な文法を使えばかなり効率的にすっきりと書くことができるのではないかと思っています。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.