この記事は 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-android
をkotlin
に変更
Androidではなく通常のJavaプロジェクトなのでkotlin-androidからkotlinに変更します。
//apply plugin: 'kotlin-android'
apply plugin: 'kotlin'
android
ブロックを削除
android
ブロックもAndroidではないので削除します。
//android {
// sourceSets {
// main.java.srcDirs += 'src/main/kotlin'
// }
//}
これでGAEでKotlinを使う準備ができました。
Entityの作成
今回はデータストアへのアクセスにObjectifyを使用します。
Objectifyで使用するEntityをみんな大好きdataクラスで書きたいところですが、ObjectifyのEntityは引数なしのデフォルトコンストラクタを持っている必要があるため、今回は普通のクラスで作ることにします。
掲示板のメッセージ
@Id
アノテーションを付けたLong型がnull
のとき保存時に自動でIDを生成してくれるので、id
はNullable & nullで初期化しておきます。
@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
)
ユーザ情報
ユーザ情報はメールアドレスをキーに作成します。
@Entity
class BoardUser() {
lateinit @Id var email: String
lateinit @Index var userName: String
}
APIの作成
APIを定義するクラスには@Api
アノテーションを付与します。WEB_CLIENT_ID
やANDROID_CLIENT_ID
は事前にDeveloper Consoleで作成しておきます。
今回はOAuth認証を使うのでscopeも忘れずに定義します。
@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アカウントです。
@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された文字列と日時、ユーザへの参照を付けて保存。
@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経由で取得し、レスポンス用のオブジェクトにマッピングします。
@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) } // レスポンス用のオブジェクトにマッピング
}
ユーザ毎のメッセージ一覧取得
ユーザ毎のメッセージを取得したい場合は次のように書くことが出来ます。
@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の素敵な文法を使えばかなり効率的にすっきりと書くことができるのではないかと思っています。