mixiグループ Advent Calendar 2017の11日目になります。 @oogatta です。
TL;DR
モバイルアプリ開発経験の少ない少人数チームでも、ネイティブアプリ同時リリースできた。誰のおかげか? Firebase と Kotlin のおかげだ!(あと、苦労したけど Rx も)(あ、一応、 Swift も…)
はじめに
私の所属するチームには私を含めエンジニアが2名います。この2名で iOS / Android / サーバ(Cloud Functions)をすべて担当しながら、クライアント側はネイティブアプリを作ってリリースしました。
チームとしての初リリースであり、まだプロダクトとして立ち上がっていないフェーズですので、特にYAGNIと、オーバーアブストラクションにならないように気をつけています。
機能を小さく抑えつつ、できるかぎり早く結合・リリースをしてエンドユーザまで届け、生の反応をもらいながらリーンに制作を進めています。
また、以前私個人のブログに書いたようなペアプログラミングを基本にした開発と極端に短いスプリントのスクラムを採用し、1プロダクトバックログごとに iOS / Android / Cloud Functions の実装を揃え、結合を完了してから次に進むようにしました。
サンプルコード
特にモバイルプラットフォームにおいて、 Firebase と RxJava2 と RxSwift によってマルチプラットフォーム開発のストレスをほとんど感じることなく(正確には、 View にしか感じることなく)、サーバ側ロジックも含めてこのような開発が実現できました。
サンプルコードからそんな様子を感じていただけたら嬉しいです。
※ご紹介するコードは実際にアプリに使われているコードを元にしていますが、わかりやすくする意図からエラー処理などを大幅に編集しており、そのままでは実行できません。また、 Firebase / Rx 以外のライブラリの提供するAPIについては説明していません。擬似コードとしてご覧ください。
ユーザ認証(iOS/Android)
電話番号認証(SMS認証)の、電話番号を Firebase に送信して SMS の送信を依頼する部分のコード例になります。
このように Firebase が揃えてくれるサービスロジックと ReactiveX が揃えてくれる非同期フローによってビジネスロジックの実装をほぼ共通にでき、レビュー等での議論を効率的にしてくれました。
return Single.create<PhoneAuthCredential> {
PhoneAuthProvider.getInstance().verifyPhoneNumber(phoneNumber, 60, TimeUnit.SECONDS, parentActivity,
object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
override fun onVerificationCompleted(phoneAuthCredential: PhoneAuthCredential?) {
if (phoneAuthCredential == null) {
it.onError(Error())
return
}
it.onSuccess(phoneAuthCredential)
}
override fun onVerificationFailed(firebaseException: FirebaseException?) {
it.onError(firebaseException!!)
}
}
)
}
return Single<String>.create { event in
PhoneAuthProvider.provider().verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationId, error in
if let error = error {
event.on(.error(error))
return
}
event.on(.success(verificationId))
}
return Disposables.create()
}
Activity/Fragment と ViewController の名前も、(無理にはしませんが)おおよそ合わせることができるのも助かっています。
データの永続化と読み出し(iOS/Android)
永続化、あるいはサーバ側で処理すべきビジネスロジックの呼び出しについては、 Architecting for Data Contention in a Realtime World with Firebase (Google I/O '17) - YouTube にあるように "Command" にまとめて Realtime Database に保存し、それを Cloud Functions が読み取って処理する方法にしてみています。
その中で、プッシュ通知の送受信のために registration token を Realtime Database に保存する(ことを Cloud Functions 上の function に依頼する)処理がこちらです。やっていることは同じですが、コードもほとんど同じになります。同じ AST で記述できてしまいそうです。
class CommandClient() {
fun saveRegistrationToken(registrationToken: String) {
FirebaseDatabase.getInstance().reference.child("commands/save_registration_token/").push()
.setValue(mapOf("userId" to currentUserId, "registrationToken" to registrationToken)
}
// ...
}
class CommandClient {
func saveRegistrationToken(_ registrationToken: String) {
Database.database().reference().child("commands/save_registration_token/").childByAutoId()
.setValue(["userId": currentUserId, "registrationToken": registrationToken])
}
// ...
}
読み出しは、基本的には直接 Realtime Database を読み出しています。お決まりのフィード部分を今まさに Firestore に書き換えている真っ最中なのですが😅 Realtime Database でのクライアント側でのジョインも含めたサンプルになります。
込み入った処理になりがちな部分ですが、これも見た目がかなり揃いますので、抽象化しようと思えばそのやり方も揃えられます。モデルも同じファイル・モジュール構成を採れますので、自分たちのプロダクトに必要なロジックや、 View などのプラットフォームごとに必要な調整に集中することができます。
そもそもここまで見た目が揃ってしまえば、モデルを準備するロジックのどこかに意図せぬ差異ができてしまっても簡単に気がつきますので、その都度議論し調整することができました。
return Observable.create<List<Post>> { emitter ->
FirebaseDatabase.getInstance().reference.child("feed").child(postId)
.orderByKey()
.addValueEventListener(object: ValueEventListener {
override fun onCancelled(databaseError: DatabaseError?) {}
override fun onDataChange(dataSnapshot: DataSnapshot?) {
val posts = dataSnapshot?.getValue(object : GenericTypeIndicator<List<Post>>() {}) ?: return
emitter.onNext(posts)
}
})
}.flatMap { posts ->
Observable.zip(posts.map { post ->
Observable.zip(infoAObservable(post), infoBObservable(post),
BiFunction<InfoA, InfoB, Post> { infoA, infoB ->
post.infoA = infoA
post.infoB = infoB
post
})
}
) { it.map { it as Post } }
}.subscribe {
// render view
}
return Observable.create { observer in
Database.database().reference().child("feed").child(postId)
.queryOrderedByKey()
.observe(.value, with: { dataSnapshot in
if let posts = Mapper<Post>().mapArray(JSONObject: dataSnapshot.valuesOfChildren()) {
observer.on(.next(posts))
}
})
return Disposables.create()
}.flatMap { (posts: [Post]) in
return Observable.zip(posts.map { post in
return Observable.zip(infoAObservable(post), infoBObservable(post)) {
post.infoA = $0.0
post.infoB = $0.1
return post
}
}) { return $0 }
}.subscribe(onNext: { (posts: [Post]) in
// render view
}).disposed(by: disposeBag)
この処理がここまで揃うかってくらいほとんど同じじゃないですか?
プッシュ通知(Cloud Functions)
FCM は Android 公式というだけでなく、簡単に iOS / Android 両プラットフォームにプッシュ通知を送ることができます。私たちも Cloud Functions から利用しています。
クライアント端末側での表示・処理の要件によって細かく違いが生まれますが(ヘッドアップをどうしたいとか)、簡単に書くと
const sendNotification = async (toUserId: UserId, content: NotificationContent): Promise<void> => {
let registrationToken = await getRegistrationToken(toUserId);
let platform = await getPlatform(toUserId);
let payload = {
notification: {
body: "あいさつが届きました",
infoA: "infoA",
infoB: "infoB",
}
};
if (platform == Platform.iOS) {
const badgeNumber = await getBadgeNumber(toUserId);
payload.notification = _.extend({}, payload.notification, { badge: `${badgeNumber}` })
}
return admin.messaging().sendToDevice(registrationToken, payload);
};
これくらいのコードで、送信先プラットフォームにこだわらずプッシュ通知の送信ができてしまいます。むしろ iOS の certs や capabilities まわりの整備がずっとずっとずっと面倒でした。
Cloud Functions の JavaScript では現状 babel を通じて flow と async/await を使っており、 Rx の Single に相当するような Promise の処理はこのように async/await の方が断然書きやすく見やすいので、今後は Kotlin の coroutine も使っていこうと考えています。
管理画面(App Engine Java)
ユーザー向けに稼働している Firebase プロジェクトの Cloud Functions を管理機能のために変更したりデプロイしたりするのは、現状の Cloud Functions の可用性を考えるとあまりやりたくありません(例えば、障害発生中にデプロイしてしまうと function が応答しなくなり、回復するまで再デプロイもできなくなることがあります。ベータですからね)。
また、 "Command" を使う形ではユーザー向けの Cloud Functions はなんでもできる権限を持つことになりますので、やはり「読み込みだけ」などの権限の制限された環境を別に用意したいところです。
別の Firebase プロジェクトを用意したとしても、現状の Cloud Functions では、別プロジェクトのリソースにトリガーすることはできません。しかし Admin SDK を使えば、効率では劣りますが Cloud Functions の外にユーザー向けの本番環境とは切り離された管理環境を作ることができます。現在 Node.js / Java / Python / Go が用意されています(言語によって機能の制約があります)。
例えば Admin Java SDK / Kotlin / RxJava2 / Spring Boot 2 を使い、 Spring Boot 2 の Kotlin Extension も利用すれば、ブラウザでユーザーの情報を表示するだけのリードオンリーな画面は Realtime Database と Auth の両方からデータを取得してもこれだけで書けてしまいます。
実際に自分が書いたコードはこれと User モデル( Android からの流用)だけです。
@Controller
@SpringBootApplication
class DemoApplication {
@RequestMapping("/u/{userId}")
fun user(@PathVariable("userId") userId: String, model: Model): Single<String> {
return Single.create<User> {
FirebaseDatabase.getInstance().reference.child("users").addListenerForSingleValueEvent(object : ValueEventListener {
override fun onCancelled(error: DatabaseError?) {}
override fun onDataChange(dataSnapshot: DataSnapshot) {
it.onSuccess(dataSnapshot.child(userId).getValue(User::class.java))
}
})
}.flatMap {
val userRecord = FirebaseAuth.getInstance().getUserAsync(it.userId).get()
it.phoneNumber = userRecord.phoneNumber
model.addAttribute("user", it)
Single.just("user")
}
}
}
fun main(args: Array<String>) {
FirebaseApp.initializeApp(FirebaseOptions.Builder()
.setCredentials(GoogleCredentials.fromStream(FileInputStream("src/main/resources/readonly-service-account.json")))
.setDatabaseUrl("https://oogatta-XXXXXXX.firebaseio.com/")
.build()
)
runApplication<DemoApplication>(*args)
}
Spring Framework 5 / Spring Boot 2 は RxJava に対応していてコントローラメソッドから Single を返すことができるので、ロジック部分は Android とまったく同じ感覚で書けます。Realtime Database / Firestore のどこからどういう風にデータを取ってきてどういう風に使うのかという処理の記述に集中することができます。
支えてくれたもの
ユビキタス言語
チームメイトの取り仕切りにより、エンジニアだけでなく、5名いるチーム内全員の用いるサービス内の用語が会話からファイル名・コードまで統一されていて(たぶん…)、 Trello のボードにまとまっています。話していてあれっと思ったらいつでも Trello を参照したり、どんどん新規追加・変更するようにしています。
エンジニアは全プラットフォームを担当しますので、プラットフォーム・言語間で用語を統一することのメリットを十分に得られます。また、ドキュメントを作らず Trello のボードとコミュニケーションだけで済むようにすることで、形骸化しづらく継続しやすい環境になっていると思います。
コードの前に人と人との間がずれていて、それを直せない状況だと、コードだって合わせる気なくなりますよね…。
ペアプログラミング
あくまで環境と目標によって決まることですが、立ち上がったばかりのいまだゴールを探しているフェーズのプロダクトで、かつ少人数のチームである以上、出来る限り動的なコミュニケーションで問題を解決し、静的なガイド・ドキュメント類を極力作らないようにしています。
ペアワークへの継続的な取り組みの結果動的なコミュニケーションのコストは一般的なチームより遥かに低くなっていますが、それは Firebase や Rx といったプラットフォーム横断のソリューションのおかげでもあります。
逆に Cloud Functions については、当初 Kotlin JS を利用していたものの当時の周辺環境の貧弱さから諦めてしまいました。 RxJS も利用しておらず、それらのことから Cloud Functions に関してはドキュメントやコメントや「一人しかわかってない領域」が残りがちになっています。
おわりに〜Firebase の気に入っているところ
- サーバも含めたマルチプラットフォームに SDK が提供されている(ちょっとしたスクリプトでもSDKを利用できる)
- 永続化だけでなく、ユーザ認証、ストレージ、プッシュ通知、ログ解析といった一般的なアプリ開発の必須領域がカバーされている
これら両方の利点から、チームで開発するという、思考が複数に分かれるということによるコミュニケーションコストや議論のずれが自然と小さくなり、チーム開発の良い部分である相互チェックの品質向上、対象の深掘りや質の良い議論といった部分だけをおいしくいただくことができました。
作業が楽になるというだけでなく、プラットフォームやライブラリによる差異に目を奪われないので、自分たちこそが書くべきコード・実装するべき機能に集中でき、チームでの開発がやりやすくなると感じました。
以上、ここまで読んでいただいた方にはご想像の通り、おそらく私たちが次に検討することになるのは React Native だろうと思います。
でも俺、 Kotlin がいいなあ…。