巨大なWEBアプリケーションに巨大な変更を取り入れるためにやったこと

会員事業部ユーザー基盤チームエンジニアの井口(@iguchi1124)です。

ユーザー基盤チームでは、クックパッドのサービス開発者のあらゆる要望に答え続けられるような『柔軟でいい感じのユーザー基盤』を目指し、サービス開発者およびユーザーさんの課題と向き合いながら日々開発を進めています。

第一弾として、普段の開発の様子や一部のユーザーさんに向けてユーザー登録機能をリリースするまでの話も公開されていますので是非そちらもご覧いただければと思います。

今回は、上述の記事にも触れられているようにクックパッドでユーザーさんのアカウント登録や認証情報として電話番号を利用できるようになりましたので、そのためにやってきたことの一部をご紹介したいと思います。

一口に電話番号を利用出来るようになったと言うと簡単そうに聞こえますが実際にはそうでもありません。

クックパッドではこれまで連絡先情報あるいはアカウントの認証に必要な情報としてメールアドレスを使うという前提で長い期間に渡る開発が積み重なってきました。

その状況から、電話番号をメールアドレスと同等に連絡先情報やアカウントの認証に必要な情報として利用するには多くの技術的負債の返却や機能追加が必要になります。

また、ユーザーさんに与える影響を考えると、ユーザーさんに迷惑をかけないようにリリースする順序を考慮したり、関連するサービスでデプロイするタイミングを合わせることも必要になったりします。

このような巨大な変更を取り入れようとしている最中も、並行してクックパッドのサービス開発は継続的に行われています。様々な施策を止めるわけにはいきません。

サービスを「ユーザーさんが一通り触れる」単位で分割する

ユーザー基盤チームでは、ユーザー登録およびログインに関わるサービス内の一連の動きを垂直に分割した、社内ではShishamo(ししゃも)と呼ばれるマイクロサービスに分離することで開発を加速させています。

サービスを分割することで実際に得られた利点は次のようなものです。

  • チームの外の開発者達にコードの変更による影響を与えたり、受けたりしない
  • 自動テストを高速に実行できる
  • 新しいアーキテクチャを素早く取り入れることができる
    • Dockerを利用したナウいデプロイフローを取り入れる
    • Webpackerを利用してナウいjavascript開発環境を取り入れる
      • React.js を導入して再利用性の高いプレゼンテーショナルコンポーネントを設計してみる
    • 電話番号パーサーを導入してみる

また、サービスを水平に分割し地層を積み上げるのではなく垂直に分割することで、新しい技術要素を取り入れる場面では早い段階で技術スタックを試し、技術的に実現可能であることの裏取りができます。

巨大な開発ブランチを作らない

提供したい機能の内容によっては、それぞれの機能の依存関係から変更を同時にリリースしなければならないことがあります。

そういった場合、開発ブランチを作り水面下で作業をすすめ、変更内容が揃った段階でマージし、リリースするということになるかと思います。

しかし、それではリリースするためには負担が開発者だけに留まらず広がってしまうことが想像できます。

  • 開発者の負担
    • 他の開発者との変更の衝突
    • 他の開発者や自分の変更の影響による予想外のバグの発生
  • コードレビューにかかる負担
    • 「よさそうだけど自信がない」、「自信がないけどLGTM」の発生
  • リリース前の動作確認、リリース後の監視にかかる負担
    • テストする必要があるパターン数の肥大化
    • 動作確認の結果おかしそうな動きを直したら別の挙動がおかしくなったので再修正、再確認、再修正、再確認

ユーザー基盤チームでは、Cookieベースのfeature flagを導入することで、この問題の解消に取り組みました。

これによって、ユーザーさんには機能を提供しないサイレントリリースとスタッフによる動作確認を可能にし、最小単位での変更のマージ、デプロイとテストを繰り返すことができました。

実際に、最後のユーザーさんに届けるステップに入る頃には十分にテストされたサービスのうちの、if分岐を取り除く程度のものにできます。

非常に簡単な仕組みではありますがOSSとして公開しています。

https://github.com/iguchi1124/cookie_flag

以下に電話番号でユーザー登録する機能をリリースするために実際に運用した例を紹介します。

ログイン機能の実装の中で、電話番号によるユーザー登録機能がリリースされているときの動きを実装する場合

class SessionsController < ApplicationController
  def create
    if feature_available?(:phone_number_registration)
      # 電話番号またはメールアドレスとパスワードを利用したユーザー認証処理
    else
      # メールアドレスとパスワードを利用したユーザー認証処理
    end
  end
end

電話番号によるユーザー登録機能がリリースされると表示されるリンク

<% if feature_available?(:phone_number_registration) %>
  <%= link_to "電話番号でユーザー登録する", new_phone_number_registrations_path %>
<% end %>

「電話番号によるユーザー登録をしようとしたこと」リソースにfeature flagを適用したい場合

class PhoneNumberRegistrationsController < ApplicationController
  feature :phone_number_registration

  def new
  end

  def create
  end
end

動作確認をするときは feature_available?(:phone_number_registration) が真になるように手動でブラウザのクッキーを設定することで一般のユーザーさんが利用する前に社内のスタッフが機能を試せるようになります。

まとめ

この記事では継続的にサービス開発が行われているクックパッドで巨大な変更を入れるためにやったことのうち、以下の2つのことを紹介しました。

  • 垂直分割によるサービス開発の効率化
  • フィーチャーフラグ導入によるリリースにかかる負担の改善

ユーザー基盤チームでは大きなサービスの基盤を再構築するにあたり、イテレティブかつインクリメンタルに価値を届けることを心がけながら周囲の開発者と協力してサービスの改善に取り組んでいます。

まだまだ失敗も多い道半ばですが、今後もユーザーさんや、となりで働く開発者、そして一緒にサービスを運営している全員にとってよいものである基盤づくりをしていけたらと思います。

Google Play アプリ内定期購入を実装する

技術部モバイル基盤グループの宇津(@)です。

今年3月、クックパッドのAndroidアプリはこれまでサポートしていたクレジットカード、キャリア決済に加えて、Google Playアプリ内定期購入によるプレミアムサービス登録機能を追加しました。

f:id:himeatball:20180313181401p:plain

Google Playのアプリ内定期購入機能は個人的に気に入っているので早速乗り換えました。

良い機会なので、この記事ではIn-app Billing version 3(以下IABv3)以降のアプリ内課金の実装を振り返りつつ、Google Play Billing Libraryの紹介も交えながら開発TIPSを紹介します。

TIPS1. Google Play Billing Libraryの採用

IABv3以降のアプリ内課金を実施するためには(ざっくりですが)以下のフローを実装する必要がありました。

  1. ServiceConnection を生成し、 IInAppBillingService にバインドする
  2. 購入可能な商品情報を IInAppBillingService から取得する
  3. 購入する商品のパラメータを含む Bundle (以下 BuyIntentBundle )を IInAppBillingService 経由で生成する
  4. BuyIntentBundle に含まれる PendingIntent を取得する
  5. PendingIntent を用いて Activity#startIntentSenderForResult を呼び出し、Google Play決済画面を立ち上げる
  6. Google Play決済画面上での精算結果は Activity#onActivityResult にて返却される
  7. 返却された精算結果を元に(レシート検証等も実施しつつ)よしなに処理する

より詳細なフローは公式ドキュメントを参照ください。これに加えて、IabHelper等のサンプル実装クラスが提供されており これらを元に各位アプリ内課金を実装していたと思いますが、その内容もそこそこにAndroid開発経験がないと難しい内容となっており、ただでさえ難易度の高い決済周りの実装にさらに多くのドメイン知識を求められるものとなっていました。

さらに、素直にこれを実装した場合、処理フロー5,6 がある以上、既存の画面表示処理にアプリ内課金処理実装が混ざってしまう事になってしまいます。 決済が絡んでくる箇所においてはメンテナンス容易性の側面から避けたい所です。 その対策として、購入用のActivityを1つ用意し、そちらに処理フロー5, 6を移譲する、といった工夫がされてきたと思います。

2017年9月、GoogleからGoogle Play Billing Libraryがリリースされました。

このライブラリは前述の購入処理の分離がなされており、比較的アプリ内課金処理の分離がしやすい状況になりました。 私自身、 IInAppBillingService を直接取り扱う事から卒業したい思いがあり、こちらのライブラリを利用する事にしました。

ライブラリ自体にこれといった問題もなく使えているのですが、いくつか懸念点がありました。そちらについてはTIPS2, 3にて紹介します。

TIPS2. Developer Payload非推奨に対する対処

Developer PayloadとはIABv3時代からある、「購入レシートの中に含める事のできる文字列フィールド」を指します。

多くの開発者はこのフィールドに、サーバ上で発行した購入トランザクションに対して一意な識別子を入れる事で、Google Playアプリ内課金に対する問い合わせ対応等に役立てていたかと思います。

しかし、このフィールドは現在非推奨となっており、Google Play Billing LibraryにおいてもDeveloper Payloadフィールドを指定する事が出来ません。

クックパッドのAndroidアプリでは、購入/復元(ユーザ様のアプリ内定期購入の購入情報を元にプレミアムサービス登録を再開する)時に都度識別子をサーバ上で発行し、レシート検証が必要なタイミングで復元するレシートとセットで送信するようにして代替しています。

こうする事で、Developer Payloadが存在していた時代では購入時に一度だけサーバ上で発行すれば良かった識別子が、無駄に発行されてしまう事が懸念されますが、これは許容する方向に倒しています。

TIPS3. ラッパーライブラリによるドメイン知識のさらなる吸収

アプリ内定期購入に限らずですが、決済処理というのは想定以上に実装が複雑になりがちです。 クックパッドのようにプレミアムサービスという商品を1つだけ取り扱うにしても、自サービス内のアカウント種別に加え、決済手段も複数あり、それらの整合性を取りながら決済処理を実装しなければならないとなると サーバサイドはもちろんの事、アプリ単体でも中々に複雑な実装になる事が予想できます。

その為、Google Play Billing Libraryでも吸収できていない、アプリ内課金処理固有のドメイン知識をもっと吸収して、利用者側のソースコードのメンテナンス容易性を上げたい気持ちがありました。 そこで tsuruhashi という名のラッパーライブラリを(社内向けに)開発し、クックパッドのAndroidアプリではこれを使用しています。

tsuruhashiでは以下の3点をうまく吸収し、関心事を分離しています。

TIPS3-1. BillingClientをいい感じにpoolingして ServiceConnection を意識させない

BillingClient はGoogle Play Billing Libraryに含まれる、 IInAppBillingService とのやり取りや決済Activityの起動を行うクラスです。

BillingClient はいつ ServiceConnection が切断されるか分からないので、利用者側でそのライフサイクルをしっかり管理する前提の実装にするのが無難です。 でも、そもそもこういった事はそもそも意識したくないですね。

tsuruhashiでは BillingClient インスタンスの生成を常に1つに抑えるようpoolingを行い、 ServiceConnection が切断されたら自動的にpoolから破棄するような機構を設けています。

ついでではありますが、 BillingClient は 内部的に Handler インスタンスを生成する為にメインスレッドでの生成が必須ですが、これも意識したくないのでライブラリ側で吸収しています。

TIPS3-2. BillingClient の全 interface を Rx friendly にして記述しやすく

クックパッドのAndroidアプリではRxJavaが導入されているので、アプリ内定期購入においてもRxなinterfaceで実装したい需要がありました。 BillingClient そのままの状態でもRx化は可能ですが、1つのメソッドだけ実現方法に悩むかもしれません。

それは BillingClient#launchBillingFlow です。

このメソッドは返却値としては決済Activityの起動等に成功したか否かが返却され、実際の購入結果は BillingClient の生成時に引数として渡した PurchasesUpdatedListener に渡されます。 なので、メソッドの呼び出し元と購入結果の受け取り口が離れてしまいます。

tsuruhashiでは以下のように対応しました。まず、 PurchasesUpdatedListener を実装します。

class CompositePurchasesUpdatedListener : PurchasesUpdatedListener {
    private val listeners: MutableList<PurchasesUpdatedListener> = mutableListOf()
    private val lock:ReentrantReadWriteLock = ReentrantReadWriteLock()

    fun add(listener: PurchasesUpdatedListener): () -> Unit {
        lock.write {
            if (!listeners.contains(listener)) {
                listeners.add(listener)
            } }
        return { lock.write { listeners.remove(listener) } }
    }

    fun remove(listener: PurchasesUpdatedListener): Boolean = lock.write { listeners.remove(listener) }

    fun clear() = lock.write { listeners.clear() }

    override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) {
        val list = lock.read { listeners.toList() }
        list.forEach { it.onPurchasesUpdated(responseCode, purchases) }
    }
}

これを BillingClient 生成時に listener として登録します。

val listener = CompositePurchasesUpdatedListener()
val client = BillingClient
        .newBuilder(context)
        .setListener(listener)
        .build()
val wrapper = BillingClientWrapperImpl(client, listener)

wrapper内での BillingClient#launchBillingflow の呼び出しは以下のように行います。(説明の為、一部省略しています)

var removeListenerFunc:(() -> Unit)? = null
removeListenerFunc = compositeListener.add(PurchasesUpdatedListener { responseCode, purchases ->

    if (/* 長いので省略 `PurchaseUpdatedListener#onPurchasesUpdated` がこのブロック内で捌ける時にlistenerを破棄 */) {
        removeListenerFunc?.invoke()
    }

    when (responseCode) {
        BillingResponse.OK -> {
            if (purchases.any { it.sku == params.sku }) {
                /* 省略 */
            }
        }
        /* 省略 */
    }
})

val launchResponseCode = client.launchBillingFlow(activity, params)
if (launchResponseCode != BillingResponse.OK) {

    // BillingClient#launchBillingFlowに失敗したらその時点でlistenerを破棄
    removeListenerFunc?.invoke()
    when (launchResponseCode) {
       /* 省略 */
    }
}

Rxな表現でいえばSubject(hot-observable)を内包する形ですね。 このような呼び出し方にする事で、非Rxの世界ではcallback interfaceで、Rxの世界においてもRx friendlyなinterfaceで購入結果を取り扱えるようになっています。

fun launchBillingFlow(activity: Activity, params: BillingFlowParams, listener:LaunchBillingFlowListener) // 非Rx
fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Single<BillingResult>              // Rx

TIPS3-3. 汎用的に必要となるinterfaceを追加して利用者側のコードの理解可読性の向上を図る

アプリ内定期購入の実装においては多くの場合、以下の2つのFeatureTypeに対応しているかを確認します。

  • FeatureType.SUBSCRIPTIONS
  • FeatureType.SUBSCRIPTIONS_UPDATE

そうなった際に FeatureType をまとめて確認できないと流石に利用者側のコードが冗長になってしまうので、複数の FeatureType をまとめて確認できるようなinterfaceを追加しています。

fun verifyFeaturesSupported(features: List<String>, listener: VerifyFeaturesSupportedResponseListener) // 非Rx 
fun verifyFeaturesSupported(features: List<String>): Completable                                       // Rx

上記のような、汎用的に必要となる機能についてはライブラリ機能として提供し、利用者側のソースコードの冗長化を抑止しています。

TIPS4. レシート情報は多少過剰にでもサーバサイドで保存する

決済系の格言として「まずは保存」というのがあります。

サーバサイド寄りの話になりますが、アプリからレシートを送信するようなエンドポイントを用意する場合、まず第一にアプリから送信されたレシートをDBに保存し、その後レシート検証や定期購入の開始処理といったエンドポイントの本来求められている処理を実施するようにしています。

これは、レシート検証や定期購入の開始処理に万が一不具合がありレシートがDB上に保存されず、その状態でユーザ様から問い合わせがあった場合、DB上にはレシートが存在しないため、どうしても煩わしいやり取りが発生してしまい、ユーザ様に悪い印象を与えてしまう恐れがある為です。

レシートの保存さえ出来てしまえば、Google Play Developer APIを利用する事で、ユーザ様の問い合わせに対して購入のキャンセルや払い戻し対応も可能になるので、この格言に習う事に越した事はありません。

TIPS5. 購入トランザクション上のログを細かく取る

決済系に限らず「まずはログ」という所で、TIPS4に関連して、ユーザ様が商品の購入に失敗した際、どの処理中に購入に失敗してしまったのかを追跡可能な状態にする必要があります。

クックパッドのAndroidアプリでは、よりユーザ様の購入状況が追跡できるよう、購入/復元時に実施する処理のログをサーバログとは別にアプリから送信・集計し、 問い合わせがあったユーザ様に対し適切にサポート対応が実施できるよう環境を整えています。

まとめ

クックパッドのAndroidアプリでは、Google Playアプリ内課金実装に対するドメイン知識をラッパーライブラリ上でいい感じに吸収した甲斐もあって、アプリケーション上の実装はクックパッドのプレミアムサービスのドメイン知識のみで満たされた実装に仕上がっています。[TIPS1, 3]

Developer Payloadは非推奨になってしまいましたが、それでもなんとかうまくやっています。[TIPS2]

ユーザ様に対し適切にサポート対応が実施できるよう、レシートの取扱い方とログ集計を中心に環境を整備しています。[TIPS4, 5]

今後の展望としては、まずはTIPS3で紹介した開発した社内ライブラリをオープンソース化したいという所と、まだまだ運用面を見据えて改善したい箇所が沢山あります。例えば、

  • テスト購入の為のAndroid端末上の設定の自動化
  • 購入/復元処理のE2Eな自動テスト
  • 決済処理実装に対する敷居を下げる為のあれこれ

といった事があげられます。 今後も大きな進歩があればtechlifeで報告していきます。

最後に、クックパッドではより良いサービスを提供し続ける為にエンジニアを募集しています。 もしこの記事を読んで興味を持たれたAndroidエンジニアの方、あるいは決済処理に熱い思いのあるエンジニアの方いらっしゃいましたら、是非遊びに来てください。 ご連絡をお待ちしています!

クックパッド株式会社 採用情報

firebase.yebisu #2 の開催報告

こんにちは。事業開発部で新規事業に取り組んでいる高田です。

Cookpad の新規事業と Firebase でもご紹介したとおりクックパッドでは Firebase を活用しはじめています。そのような流れもあり2018年2月20日に Firebase.yebisu #2 を開催しましたのでご報告いたします。

クックパッドからは3名が発表し、LT枠として3名社外のかたに発表していただきました。

発表

Firebase Cloud Messaging 入門編 by 三浦

Komerco 事業部の三浦から Firebase Cloud Messaging (以下 FCM) についての発表です。

通知対象を柔軟に指定できる Topic 機能などについてデモを通しての説明がありました。また FCM を使用する際に毎回実装する処理をまとめたライブラリ Tsuchi の紹介がありました。

speakerdeck.com

料理ショートライブアプリ Cookin' の開発 by 森川

投稿開発部の森川からは新規事業で Firebase を検討し採用するまでの経緯や、どのように Firebase を利用してサービス開発をしたかの話などがありました。

新しく Firebase を検討している人には参考になる話だったのではないかと思います。

speakerdeck.com

実践 Cloud Functions for Firebase by 星川

Komerco 事業部の星川からは在庫管理や決済処理で Cloud Functions を利用して得た知見を元に実践的な話がありました。トリガーイベントの多重起動対策やデプロイの話など参考になる話があったのではないかと思います。

speakerdeck.com

発表のなかで紹介のあったライブラリは次の通りです。

Firestore rules tips by 岸本

Komerco 事業部の岸本は当日インフルエンザにより発表できなかったため、発表予定内容を以下で共有させていただきました。 qiita.com

LT枠

LT枠では3人のかたに発表していただきました。

Firebase関連をCIでデプロイするときのTips by yamacraft さん

speakerdeck.com

スマートなcronを考案した by Yatima さん

speakerdeck.com

Firebase Auth with GAE & Cloud Endpoints by take_e10 さん

www.slideshare.net

まとめ

いずれの発表も Firebase 利用者の現場の知見があり参考になったという声を多くお聞きしました。

クックパッドでは引き続きエンジニアを募集していますので、Firebase を利用した開発や新規事業開発に興味あるかたは採用ページを是非ご覧ください。ご連絡をお待ちしております。