Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

CloudNative Days Kansai 2019参加レポート

どーも、SREやってます。菅原です。

先日は11月27,28日にグランフロント大阪のコングレコンベンションセンターで行われたCloudNative Days Kansai 2019に参加して来ました。

f:id:ksugahara08:20191201140016j:plain

せっかくなのでイベントの参加レポートを載せたいと思います。

CloudNative Daysとは

公式サイトから引用させて頂くと、

クラウドネイティブの現状をひとまとめにした開発者のためのイベントです。2018年はJapanContainerDaysとして2度開催し、今年からCloudNativeDaysに名称を変え4月に福岡、7月に東京、11月に大阪で開催します。

ということらしい。

クラウドネイティブ関連の技術を中心としたセッションが行われ、国内インフラ系のイベントでは一番大きいものではないかと思ってます。(クラウドベンダーが主催するものを除き)

CloudNative Days Kansai注目セッション

ここからは私が注目したセッションを紹介していこうと思います。

スライドが上がると思うので私の感想を中心に載せていきます。

コンテナの作り方~Dockerは裏方で何をしているのか~(前佛雅人氏)

コンテナやDockerって何?という疑問に図解で解説するという内容でした。

初心者向けと思いきや、図解でナレッジがまとめられていて、Dockerを使ったことがある人でも頭の中が整理されて本当に良いセッションでした。

また、前佛氏のトークはものすごくわかりやすく「コンテナはデフォルトでisolate」というキャッチフレーズが頭に残ったのは私だけではないでしょう。

メルペイのマイクロサービスとCloud Native(Junichiro Takagi氏)

speakerdeck.com

メルペイのマイクロサービスがどのようにCloud Nativeを取り入れているのか。また、運用してみてどのような課題があるのかを聞くことができた素晴らしいセッションでした。

最後にTakagi氏は「Cloud Nativeで重要なのは技術セットではない、組織体制や開発・運用のスタイルも含めたCultureが重要だ」とまとめていました。

弊社も半年間SREの文化や考え方を社内エンジニアに理解してもらうため共有会や勉強会をしてきたので、組織が大きくなってもこれを継続していくことが必要だなと思いました。

分散システム内のプロセス間の関係性に着目したObservabilityツールの設計と実装(Yuuki Tsubouchi氏)

speakerdeck.com

ゆうきさんのセッションは、Transtracerについての内容でした。

分散アプリケーションにおけるObservabilityの問題を、分散トレーシングで主流のリクエストベースアプローチとは違ったアプローチで問題解決しようというものでした。

正直この分野について詳しくなかったので目から鱗でした。 今後のTranstracerの発展が気になる素晴らしいセッションでした。

Kubernetesの運用を支えるGitOps(藤原峻輝氏)

www.slideshare.net

freeeではWeaveworksのGitOpsを参考にOpsのGit化をしたというセッション内容でした。

バージョン管理やロールバックが容易になるという恩恵を狙ってGitOps導入したところ、集中した権限管理、kubectlでdeployする必要がない安心感、実際に今動いているmanifestがコード上でバージョン管理されるといったメリットが得られたとのことでした。

freeeのGitOpsの導入手法はCloud Nativeを実践していて理想的ないい例だと思いました。弊社でもWeaveworksのGitOpsの考え方の布教活動から始めていきたいと思いました。

Production Ready Kubernetesに必要な15のこと(磯賢大氏)

speakerdeck.com

Kubernetesプラットフォームを構築するにあたって考慮点を15つにまとめており、コロプラでの設定例も紹介している神スライドでした。

Kubernetesを本番環境で運用していくには何を考えておく必要があるのかベストプラクティスがまとまっていたと言っても過言ではないでしょう。 Kubernetes初心者にも、実際に運用している人にも参考になったのではないでしょうか?

弊社もこの15項目についてSREチームで議論して決めていきたいと思います。

最後に

東京から大阪のカンファレンスに参加したのですが、大阪の街並みや関西のエンジニアの熱量を感じることができました。仕事に活かせる話を聞けただけでなく、自分のモチベーション向上につながったと思います。

少ししか大阪に滞在できませんでしたが、最高でした。また機会があれば遠征したいです。

AAC Navigation で Bundleの受け渡しを SafeArgs で行なってハマった話

こんにちは、Studyplus Androidチームの中島です。

以前の話に引き続き、 AACのNavigationについて話したいと思います。

今回は 遷移元・遷移先のFragment間でのBundle受け渡しでちょっとハマったことについてです。

なお、執筆時に利用している Navigation のバージョンは2.2.0-rc02です。

(前回) tech.studyplus.co.jp

初めに、Fragment間受け渡し方法について

まず、Navigation でのFragment間のデータ受け渡し方法について軽く触れておきます。 もう知ってるよという方は、本節を読み飛ばして頂いても構いません。 (遷移方法などの説明は割愛します、公式ドキュメントなどを参照してください)

FactoryメソッドによるsetArgumentsを利用した方法

従来通りの、プリミティブ型やSerializable、ParcelableをBundleに詰めて渡す手法です。

companion object {
    private const val ARG_KEY_NAME = "arg_key_name"
    fun newInstance(arg: Int) = YyyFragment().apply {
        arguments = Bundle(1).apply {
            putInt(ARG_KEY_NAME, arg)
        }
    }
}

ただし、 Navigation ではライブラリ内部で Fragment の生成が行われるため、この従来の生成方法は使えません。

Navigation では navigate メソッドで遷移アクションを指定しますが、その際にBundle型をパラメータに入れることができます。 なので、Fragmentでは Instanceの生成 ではなく Bundleの生成 のメソッドを生やしておくといいと思います。

// 遷移先Fragment
companion object {
    private const val ARG_KEY_NAME = "arg_key_name"
    // Navigation遷移のためInstanceではなくBundleを作成して返している
    fun createBundle(myArg: Int) = Bundle(1).apply {
        putInt(ARG_KEY_NAME, myArg)
    }
}
// 遷移元Fragmentの遷移処理箇所
val args = YyyFragment.createBundle(myArg = 123)
findNavController().navigate(R.id.action_Xxx_to_Yyy, args)

受け取ったBundleの中身は従来通りに取り出せます。

// 遷移先Fragment
val myArg = requireArguments().getInt(ARG_KEY_NAME) ?: 0

SafeArgs

Navigation の便利機能として、SafeArgsがあります。 これはBundleの put~~ get~~ を書かずにArgumentを直接渡せる機能です。 最大の利点としてその名の通りSerializableやParcelableを型安全なかたちで キャスト処理を書かず受け渡し可能になります。 利用するには build.gradle に追加でプラグインを指定する必要があります。 以下公式ドキュメントから抜粋します。

To add Safe Args to your project, include the following classpath in your top level build.gradle file:

buildscript {
    repositories {
        google()
    }
    dependencies {
        def nav_version = "2.1.0"
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}

To generate Java language code suitable for Java or mixed Java and Kotlin modules, add this line to your app or module's build.gradle file:

apply plugin: "androidx.navigation.safeargs.kotlin"

これらを追記した後にビルドすることで、NavGraph内で設定した各Fragmentごとに~~Directionsというクラスが自動生成されます。 その中に、同じくNavGraphで指定した各actionと対応したメソッドが自動生成されます。 この action のメソッドに引数としてデータを入れるわけですが、その型はnavGraphの遷移先Fragment定義内に<argument />アトリビュートを追加することで指定できます。 以下公式ドキュメントから一部改変して抜粋します。

<fragment android:id="@+id/YyyFragment" >
    <argument
        android:name="myArg"
        app:argType="integer"
        android:defaultValue="0" />
</fragment>

ここまで指定して改めてビルドすることで、以下のようにBundleにput/getせずデータのやり取りをすることができます。

// 遷移元Fragmentの遷移処理箇所
findNavController().navigate(XxxFragmentDirections.actionToYyy(myArg = 123))
// 遷移先Fragmentではフィールドとしてby navArgs()で取得
private val args: YyyFragmentArgs by navArgs()

val myArg = args.myArg // Int型として型安全で受け取れる

例ではintegerで書きましたが、これを用いることでSerializableやParcelableを継承したdata classのやりとりがとても楽になります。

なおYyyFragmentArgsの内部コードを覗いてみますと、変数名をArgumentのkey名としてBundleにput/getしている処理を見ることができます。 put/getのラッパークラスを自動生成してくれる機能、と思っていいかと思います。

ActivityレベルのViewModel

ActivityのLifecycleに合わせたViewModelでデータを保持することで、そのViewModelを通して共有する手法です。 強力な保持期間があり楽にデータを受け渡しできますが、逆にデータがずっと保持されてしまうとも言えます。 そのため、あるFragmentからあるFragmentへ遷移する際に明示的なリセット処理が必要となることがあります。 本記事では主題の外なのでこれ以上触れません。

NavGraphViewModelはNavigation2.1.0-alpha02以上で利用できる機能です。 同一のNavGraphの中にあるFragment間でのみ生存するViewModelでデータを共有する手法です。 Activityレベルより細かくデータ保持期間を指定できますが、NavGraphの分け方に依存する面もあります。 詳しくは公式ドキュメントをご参照ください。 本記事では主題の外なのでこれ以上触れません。

データの受け渡しについてまとめ

Navigation におけるFragment間でのデータ受け渡しは、公式ドキュメントにもある通りデータの量を考慮して選別する必要がありそうです。 また、そのデータを利用する上で生き残るべき期間も考えて、これらの手法を的確に使い分けるべきなのかなという認識です。

今回ハマったこと

手法選択

コードのリファクタリングを行なっていたところ、検索条件指定画面から検索結果表示画面への遷移を行なうコードにおいて Navigation を導入することとなりました。 このケースではFragment間での検索条件データの受け渡しが必要になります。 また、検索条件データは遷移時に生成して検索結果表示画面のFragmentだけで保持すればよく、検索条件指定画面のFragmentに戻る際は破棄したいデータでした。 そのため、ViewModelに依る共有ではなくBundleで渡す方法を選びました。

また、Serializable の data class であったため、型安全のため SafeArgs を利用したいと思いました。

ダメだったこと

以下の様に<argument />を指定したところ、debugビルドではうまくいっていました。

<argument
    android:name="data"
    app:argType="{data classのフルclasspass}" />

しかし、R8による難読化がなされたステージ環境向けのapkでテストしたところ、クラッシュが起きました。

Didn't find class {classpass}

つまり、難読化によって指定したクラス名が見つからないよと言われてしまったのです。 この解決法について調べている時、STAR-ZEROさんのブログ: Navigation SafeArgsで使える型から以下の記述を見つけました。

XMLで直接設定もできます。このとき . から開始することで、アプリのパッケージ名を省略することが可能です。

medium.com

この一文から、「もしかして相対パスで書けばいけるのでは?」と思い、以下の様にxmlを書き直しました。

<argument
    android:name="data"
    app:argType=".{data classの相対classpass}" />

結論から言うとこれでも同じく Didn't find class エラーでクラッシュし、しかも難読化のないdebugビルドですら同様のエラーが起きる様になってしまいました。

この時のエラーメッセージにあったクラスパスですが以下の様になっていました。

Didn't find class {アプリパッケージ名}.debug.{dataクラスの相対classpass}

Studyplus Androidアプリでは開発者環境、ステージング環境、本番環境で同端末内に共存できるよう、ビルドタイプに応じてsuffixをパッケージ名につけています。

アプリのパッケージ名を省略することが可能です。

そう、そのsuffixも当然パッケージ名の一部ですからここに挟まれてしまうわけです。 そのため、debugビルドでもクラスパスがズレてしまっていたわけでした。

加えて、Studyplus Androidアプリでは絶賛マルチモジュール化の促進中であることも思わぬ障壁になっていました。 マルチモジュールではアプリのパッケージ名各モジュールのパッケージ名が異なる形になります。 相対パスで書くと、SafeArgsによる自動生成クラス(~~FragmentArgsクラス)内ではモジュールのパッケージ名に相対パスを繋げてdata classをimportします。 しかし実行時だと、Navigationアプリのパッケージ名の直後に相対パスを繋げて指定したクラスを探しに行きます。 その結果、

  • 自動作成クラスに合わせて相対パスを指定すると実行時クラスが見つからずクラッシュする
  • 実行時の設定に合わせて相対パスを指定するとSafeArgsの自動生成クラスがdata classをimportできずビルドが通らない

となってしまっていました。 suffixがなかったとしても、マルチモジュールとこの相対クラスパスによる記法は相性が非常に悪いわけです。

解決法

まぁなんのことはなく、NavigationSafeArgs を利用せず、Bundle を生成して navigate メソッドに入れる手法で無事解決しました。

公式ドキュメントを改めて見たら…

SafeArgs と難読化の併用問題についての話には続きがあります。

コーディング当時(2019/11/11)は前節にまとめたように、FactoryメソッドによるsetArgumentsを利用した方法で解決しました。

その後(2019/11/22)、公式ドキュメントを改めて確認したところ、 ProGuard に関する考慮事項という記述があることに気付きました。 本節ではこれについて追記します。

keepnames ルールを使用する

公式ドキュメントから引用します。

keepnames ルールを proguard-rules.pro ファイルに追加することもできます。

これについては気づいていました。 確かに難読化によってフルパスが指定できなくなるのであれば、該当data classを難読化対象から外すことでももちろん解決できるはずです。 ですが、Studyplus Androidアプリは R8+Moshi Codegen によって、外部ライブラリ以外のファイルに関してproguard-rulesの表記が不要な状態となっています。 この手法を採用しなかったのは、今更 SafeArgs のためにこのdata classだけをproguard-rulesに追加したくないという判断でした。

@Keep アノテーション

公式ドキュメントから引用します。

    @Keep class ParcelableArg : Parcelable { ... }

    @Keep class SerializableArg : Serializable { ... }

    @Keep enum class EnumArg { ... }

androidx.annotation.Keep アノテーションを利用することで、難読化対象から外すというものです。 これについては恥ずかしながら知らなかったものでした。 試してみたところ、R8で難読化していても問題なく動作しました。 proguard-rulesファイルに影響を及ぼすことなく難読化対象から外せるのであれば、このアノテーションを付けて SafeArgs を利用していくのがベターかと思いました。

まとめ

今回は、NavigationSafeArgs を使おうとしてハマった話を紹介させていただきました。

SafeArgs は非常に便利ですが、現状では難読化と併用する場合もう一工夫必要になります。 まだ調査しきれていませんが、このArgumentの受け渡し周りについてはDeepLinkでの利用もある為、クラス名の取り扱いが内部で色々大変なのかもしれません。 今後のアップデートでこの辺りの利便性がさらに向上していくと嬉しいですね。

最後に反省点として、公式ドキュメントは今後も細かく確認していこうと思います…。

FirebaseとStripe Billingを組み合わせるとき、stripe.customerのdescriptionとmetadataが便利です

こんにちは、スタディプラスの須藤(id:kurotyann)です。

9/17にFirebaseとStripe Billingを使って新しいサービスをリリースしました。 サービスについては先日投稿したブログを参照してください。

tech.studyplus.co.jp

今日は、タイトルどおり「FirebaseとStripe Billingを組み合わせるとき、stripe.customerのdescriptionとmetadataが便利です」について説明します。

FirebaseとStripe Billingを使ってサービスを開発する予定がある人は、知っておけば作業効率が確実に上がる内容です 💪

Stripe Billingとstripe.customerとは?

Stripe Billingとは、定期支払いのビジネスモデルを構築できるStripeの機能のことです。 詳細は、Stripeの公式サイトを参照してください。

stripe.com

そして、stripe.customerとは、Stripe上で管理される顧客オブジェクトのことです。 stripe.customerには、顧客の連絡先や、クレジットカード情報、定期支払いのプランなど、様々な支払い情報が紐付けられています。

stripe.customerはIDをもち、このIDをFirestoreに保存してFirebase Authenticationのユーザーと紐付けることで、サブスクリプションサービスの構築が可能です。

stripe.customerのdescriptionとmetadataとは?

stripe.customerのdescriptionとは、Stripeのダッシュボードで顧客情報の隣に表示される文字列のことです。

customer_object-description

An arbitrary string attached to the object. Often useful for displaying to users. https://stripe.com/docs/api/customers/object#customer_object-description

そして、stripe.customerのmetadataとは、開発者が顧客情報に追加できるhash型の情報のことです。

customer_object-metadata

Set of key-value pairs that you can attach to an object. This can be useful for storing additional information about the object in a structured format https://stripe.com/docs/api/customers/object#customer_object-metadata

APIドキュメントを見た限りでは、よくあるオブジェクトのプロパティですが、これらを活用すると、FirebaseとStripeの開発効率が上がります。

私が開発をしていて、開発効率が上がった(便利だ)と感じた具体的な事例を3つあげて説明します。

descriptionとmetadataが便利な理由

1. descriptionは、Stripeのテストデータを環境別に見分けやすくする

Stripeには本番とテスト(テストデータ)の2つの環境しか用意されていません。 2つしかないので、本番(production)/ ステージング(staging)/ 開発(development)といった3環境構成のときに困ります。

一方、Firebaseで環境別に3つのプロジェクトを作成するのは簡単です。 そして、アプリ側でリクエスト先のプロジェクトを変えることも、今やデファクトスタンダードになっています。

Stripeのテスト環境にステージング環境と開発環境のユーザーを登録する仕様にした場合、テスト環境に2つの環境のユーザーが混じり合うことになります。 これだと、Stripeのダッシュボードでどの環境の顧客データなのか識別しづらくなります。

そこで、descriptionに環境の識別子をいれて見分けやすくしましょう。 具体的には、Cloud Functions for Firebase で process.env.GCLOUD_PROJECT を stripe.customerの新規作成時に保存するのが一番簡単だと思います。

customer = await stripe.customers.create({
  description: process.env.GCLOUD_PROJECT,
  email: email,
  source: token,
});

これでStripeのダッシュボードでテストデータを見たとき、どの環境で作成された顧客データなのか、ひと目でわかります。

Stripeのダッシュボードでテストデータを見たとき
f:id:kurotyann:20191105012821p:plain

さらに、他にも嬉しいことがあります。

  • ダッシュボードの検索バーに process.env.GCLOUD_PROJECT の値を入力すれば、環境ごとの顧客データを絞り込める
  • 顧客データをcsv形式でエクスポートしたとき、顧客データにdescriptionも同時に付与されて出力される

2. metadataにuidを保存して、uidで顧客情報を検索できるようにする

descriptionには環境の識別子を入れたので、metadataにはFirebase Authenticationのuidを保存します。

customer = await stripe.customers.create({
  description: process.env.GCLOUD_PROJECT,
  email: email,
  source: token,
  metadata: {
    uid: uid,
  },
});

これでdescriptionと同様に、Stripeのダッシュボードでどの環境のユーザーでもuidさえわかれば、顧客情報をすぐに絞り込めます。 また、metadataもcsv形式でエクスポートしたとき、顧客データに付与されて出力されるので、csvで分析したいときも便利です。

3. metadataにも環境識別子を保存して、どの環境のwebhookなのか判別できるようにする

最後に、 metadataにも環境識別子を保存しましょう。

customer = await stripe.customers.create({
  description: process.env.GCLOUD_PROJECT,
  email: email,
  source: token,
  metadata: {
    uid: uid,
    env: process.env.GCLOUD_PROJECT,
  },
});

Stripeのwebhookの環境も、本番とテストの2環境しかありません。

これだと、例えば定期支払いのイベント invoice.payment_failed がテスト環境で発生したとき、ステージングと開発の両方のwebhookが対象になってしまいます。

つまり、特に何も準備しなければ、Stripeのテスト環境で起きたイベントが、ステージングと開発のどちらの環境で発生したイベントなのか識別できません。

そこで、metadataの環境識別子を使います。 私の場合、webhookのStripe-Signatureの処理直後に、下記の環境識別子チェックを走らせるようにしました。

const functions = require('firebase-functions');
const stripe = require('stripe')(functions.config().stripe.token);

/*
 * Stripeのテスト環境に開発とステージングの webhookUrl を定義しているため
 * customerのメタデータでどちらの環境の webhook なのか判定している
 * doc: https://stripe.com/docs/api/customers/retrieve?lang=node
 */
module.exports = async function(data) {
  try {
    const customerId = data['customer'];
    if (customerId === 'cus_00000000000000') {
      // Stripe dash boardからイベントをテスト送信した場合
      return {
        isValid: true,
      };
    }

    const customer = await stripe.customers.retrieve(customerId);
    const metadataEnv = customer.metadata['env'];
    return {
      isValid: metadataEnv === process.env.GCLOUD_PROJECT,
      code: 202,
      message: `different project processEnv: ${process.env.GCLOUD_PROJECT}, metadataEnv: ${metadataEnv}`,
    };
  } catch (e) {
    console.error(`🧨 validateProjectEnv: ${JSON.stringify(e)}`);
    throw e;
  }
};
const validateProjectEnv = require('./util/validateProjectEnv');

/* Stripe-Signature の直後 */

const validate = await validateProjectEnv(event.data.object);
if (!validate.isValid) {
  return res.status(validate.code).send(validate.message);
}

/* 任意の処理が走る(例: Firestoreの更新や、slackへの通知など) */

Stripeの現在の仕様だと、 ダッシュボードからテストでwebhookを起動させるとき顧客IDは cus_00000000000000 です。これは無視するようにしています。

ちなみに、descriptionもwebhookのレスポンスに含まれるのでmetadataに保存するのは二度手間のように思いますが、StripeのAPIドキュメントだとdescriptionはダッシュボードで利用されるプロパティだと明記されています。

今後のStripeの仕様変更でAPIのレスポンスにdescriptionが含まれなくなっても大丈夫なように念の為、metadataに保存しておいた方が無難だと私は思います。

おわりに

stripe.customerのdescriptionとmetadataが便利な理由を具体的な3つの事例を交えて紹介しました。

まだまたFirebaseとStripe Billingを使った内容で紹介したいことがあるのですが、それは今年のアドベントカレンダーのどこかで投稿する予定なのでしばしお待ちください 😄

Studyplus - ML Study Jamsとして機械学習に取り組みました

こんにちは、Androidチームの若宮(id:D_R_1009)です。 2019年8月から9月にかけて、社内にて "Studyplus - ML Study Jams" を開催しました。

"Studyplus - ML Study Jams" はGoogleの "ML Study Jams vol.3" に取り組むため、社内で行ったイベントとなります。

developers-jp.googleblog.com

developers-jp.googleblog.com

ML Study Jamsについて

ML Study Jams vol.3は「Machine Learning (機械学習) の専門的な知識を持たない方向けにトレーニングを無料で提供するプログラム」となります。 vol.1, 2とは異なり"オーガーナイザー"がイベントを開き、グループで取り組むのが特徴です。

Cousera上に用意された4つのコースから1つを選び、そのコースの完遂を目指します。 今回は初心者向けの How Google does Machine Learning を選び、社内から10名強の有志を募っての開催となりました。

スタディプラスと機械学習

これまでの機械学習への取り込みといえば、AndroidアプリへQRコード読み取り機能を追加した程度でした。 一方で社内にある様々なデータや多くのエンジニアを考えると、機械学習について学ぶことで新たな利用シーンを見つけられる期待感もありました。 またユーザーにレコメンドする機能の改善などの話が持ち上がり「機械学習を利用することでよりユーザーに価値が提案できるのでは?」と言った話が持ち上がりつつもありました。

そのようなタイミングでGoogleのブログを見つけたため、「まずやってみよう!」と今回のイベント企画に至っています。

取り組み方

初回はイベントの趣旨説明、並びに進め方を連絡する会とし、基本的には各自のペースに合わせた実施としました。 質問や情報交換の場として、社内Slackに専用チャンネルを設けることで、各種連絡がスムーズに行えていたように思います。

f:id:D_R_1009:20191025190255p:plain
ある日の様子

またコースの進捗をサポートするため月水金の昼休みに社内のフリースペースを押さえ、集まって勉強する機会を作りました。 この取り組みは業務を進めながら学習にも意欲を出すために有効であり、集まりによく参加してくれる人はコースを完遂しやすかったようです。

機械学習コースについて

今回有志で取り組んだコースは "How Google does Machine Learning" となります。 こちらのコースは「どのように機械学習をビジネスチームが受け入れ、活用を考えていくか」をサポートする内容でした。

手を動かす箇所はエンジニア向き(エンジニアの方が興味を持ちやすい)かなと感じましたが、GoogleのAutoML APIを活用している事例など企画職の方にみてもらっても良さそうな内容です。 様々な職種の人グループでコースに取り組み、機械学習について学ぶことと運用することをイメージできるよい教材だなと感じました。

cloud.google.com

なお、機械学習の数学的な知見は Launching into Machine Learning を参考にしたほうが良さそうです。 基本的な統計の知見を持っていれば苦労せず理解できる内容になっており、自分たちで機械学習モデルを組みたい場合の第一歩と言えそうです。

取り組み結果

結果として、オーガナイザーを含め5名が1コースを完遂することができました。 準備のためにオーガナイザーは3コースを完了したため、当初の目的以上に取り組めたように思います。

参加賞のオックスフォード防水バッグ、並びにリュックサックは存在感もあり、記念品として非常に満足感がありました!

f:id:D_R_1009:20191112122037j:plain

終わりに

10月に入り、イベントに参加したメンバーで取り組みの振り返りを行いました。 すぐに機械学習の成果を取り入れることはなさそうですが、システムの様々な点で利点を取り込めそうなため、少しずつ取り組んでいれければと考えています。

オーガナイザーとしてイベントを企画することは楽しく、また良いプレッシャーになりました。 先に進めねば、という意識が働くためコースを先行して達成する良い機会となります。(実際、そのモチベーションで3コースを達成することができました)

ぜひ、機会がありましたらオーガナイザーとして立候補していただければと思います。 そして学習の際にはスタディプラスアプリをご活用ください!

CircleCIのPerformance Planでテスト時間を半減させた

こんにちはスタディプラスCTOの島田です。

今回はスタディプラスでCircleCIのPerformance Planを導入し、テストの実行時間を半分以下にした内容を書きます。

Performance Planについて

CircleCIのPerformance Planとは、処理能力を最適化しパォーマンスを最大化するためのプランです。Performance Planを用いるとビルド時間の短縮やビルドのキュー待ち時間を減らす事が出来ます。

料金体系は以下のようになります。
1アクティブユーザー1名あたり$15/月 + $0.06/100クレジット

導入時点でのスタディプラスの過去30日間の利用状況から費用を見積もったところ、

  • アクティブユーザー数が19名=$285
  • ビルドに要した計算リソースに対する従量課金分 約$250

という内訳で、概算が$535/月となりました。

それまでの料金が、
$349/月(Linux:コンテナ x 3 + Mac OS:STARTUP プラン)
だったので、若干高くなる見積もりとなりました。 ただPerformance Planの場合にはデフォルトで40コンテナを割り当てられ、キューの待ち時間(それまで多い月には69時間/月の待ち時間が発生していました)の減少が想定出来たので、そのコストに換算すれば十分ペイ出来るのではと思い2週間のトライアルから始めてみました。

スタディプラスでのCircleCI利用状況

今回は学習SNSアプリ「Studyplus」のAPIアプリケーション(コードネーム「steak」)を対象に説明をしていきたいと思います。

steakのワークフロー概要

steakはRuby on Railsのアプリケーションで、CIにて以下を実行していました。

  • RSpecによるテストの実行
  • RuboCopによる静的コード解析 + PullRequestにコメント
  • simplecovによるカバレッジ計測結果をSlackへ通知

課題

  • 1回のワークフローの時間が長い(8~10分ほどかかる)
  • CIを同時に実行するとコンテナが足りなくなり、usageキューがしばしば発生する

Performance Planの適用

ここからPerformance Planの適用ために変更した内容を説明します。

  1. CircleCIの管理画面からプランを変更
  2. 以下を実行できるよう、.circleci/config.ymlを変更
    • RSpecの並列実行化
    • Workflowを分割してRuboCopとsimplecov同時実行できるように修正

circleci/config.yml

実行環境の定義

まずはexecutorsに実行環境(docker)を定義します。 resource_classはlargeも試してみましたが、steakのテストの場合にはmediumでも実行時間に差はありませんでした。

version: 2.1
executors:
  default:
    working_directory: ~/steak
    docker:
      - image: circleci/ruby:2.6.3
        environment:
          RAILS_ENV: test
          CONFIG_EAGER_LOAD: true
          BUNDLE_APP_CONFIG: .bundle
      - image: circleci/mysql:5.7-ram
        environment:
          MYSQL_ALLOW_EMPTY_PASSWORD: 1
          MYSQL_DATABASE: stappy_api_test
          MYSQL_HOST: 127.0.0.1
          MYSQL_USER: root
        command:
          mysqld --sql-mode=NO_ZERO_IN_DATE
      - image: redis:3.2.9
    resource_class: medium

コマンド

bundle install等の複数回利用するコマンドを定義する

commands:
  bundle_install:
    steps:
      - run:
          name: bundle install
          command: |
            bundle install -j4 --path vendor/bundle --clean
  restore_bundle_cache:
    steps:
      - restore_cache:
          keys:
            - v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
            - v2-bundler-{{ arch }}-
  restore_repo_cache:
    steps:
      - restore_cache:
          key: v2-1-repo-{{ .Environment.CIRCLE_SHA1 }}
  setup_repo_bundle:
    steps:
      - restore_repo_cache
      - restore_bundle_cache
      - bundle_install

ジョブ

buildジョブについて

並列で実行するようにparallelismを設定します。 ここでは4並列で設定します。

jobs:
  build:
    executor: default
    parallelism: 4
    steps:
...

キャッシュ、bundle installやDatabaseのセットアップ

    steps:
      - checkout
      - save_cache:
          key: v2-1-repo-{{ .Environment.CIRCLE_SHA1 }}
          paths:
            - ~/steak
      - restore_bundle_cache
      - bundle_install
      - save_cache:
          key: v2-bundler-{{ arch }}-{{ checksum "Gemfile.lock" }}
          paths:
            - ~/steak/vendor/bundle
      - run:
          name: Prepare Database
          command: |
            bundle exec rake db:create
            bin/ridgepole -c config/ridgepole.yml -f db/Schemafile --apply
      ...

RSpecの並列実行とカバレッジの計測をします。
RSpecの並列実行についてはCircleCI CLIを利用してファイル名で分割して実行しています。
カバレッジの結果についてはCIRCLE_NODE_INDEXに実行コンテナの番号が渡ってくるので、coverage/.resultset.json をそれぞれのコンテナで別なファイルになるようにリネームしてコピーします。(ファイル名が同じだと内容が上書きされ、並列実行の最後の結果しか残らないため)
また、後続のWorkflowで共有できるようにpersist_to_workspaceでパスを設定します。

    steps:
    ...
      - run:
          name: RSpec
          command: |
            circleci tests glob 'spec/**/*_spec.*' \
              | circleci tests split --split-by=timings --timings-type=filename \
              | tee -a /dev/stderr \
              | xargs bundle exec rspec \
              --format progress --format RspecJunitFormatter -o tmp/rspec/result.xml
      - store_test_results:
          path: tmp/rspec
      - run:
          name: Stash Coverage Results
          command: |
            mkdir coverage_results
            cp -R coverage/.resultset.json coverage_results/.resultset-${CIRCLE_NODE_INDEX}.json
      - persist_to_workspace:
          root: .
          paths:
            - coverage_results
...

rubocopジョブについて

RuboCopで静的解析し、結果をSaddlerでPull Requestにコメントします。

jobs:
  build:
  ...
  rubocop:
    executor: default
    steps:
      - setup_repo_bundle
      - run:
          name: RuboCop
          command: |
            script/run-rubocop.sh
          when: always

script/run-rubocop.sh

#!/bin/bash -x
rubocop_config_changed=$(git diff --name-only origin/master | grep -i rubocop)

if [ "${CIRCLE_BRANCH}" == "master" ] || [ -n "$rubocop_config_changed" ] ; then
    warn=$(bundle exec rubocop)
    detected=$(echo "$warn" | grep "Offenses:")
    if [ -n "$detected" ]; then
        exit 1
    fi
else
    warn=$(git diff -z --name-only origin/master --diff-filter=AMRC \
           | xargs -0 --no-run-if-empty bundle exec rubocop --force-exclusion)

    detected=$(echo "$warn" | grep "Offenses:")
    if [ -n "$detected" ]; then
        ruby script/check_pull_request.rb \
        && echo "$warn" \
        | bundle exec rubocop \
            --require rubocop/formatter/checkstyle_formatter \
            --format RuboCop::Formatter::CheckstyleFormatter \
        | bundle exec checkstyle_filter-git diff origin/master \
        | bundle exec saddler report \
            --require saddler/reporter/github \
            --reporter Saddler::Reporter::Github::PullRequestReviewComment
        exit 1
    fi
fi

exit 0

こんな感じでstudyplus-botがコメントをしてくれます

f:id:yo-shimada:20191010164247p:plain

coverageジョブについて

並列実行したRSpecで出力されたカバレッジの結果(coverage_results/.resultset-${CIRCLE_NODE_INDEX}.json)をマージして、カバレッジの割合をSlackで通知します。 カバレッジの結果を閲覧できるようにstore_artifactsで設定しておきます。

jobs:
  build:
  ...
  rubocop:
  ...
  coverage:
    executor: default
    steps:
      - attach_workspace:
          at: .
      - setup_repo_bundle
      - run:
          name: Merge and check coverage
          command: |
            bundle exec rake simplecov:report_coverage
      - store_artifacts:
          path: ~/steak/coverage
          destination: coverage
      - run:
          name: notify result
          command: ruby script/post_coverage_to_slack.rb

カバレッジ結果をマージするRakeタスク(ヘルパーを呼び出しているだけ)
lib/tasks/simplecov_parallel.rake

# frozen_string_literal: true

if Rails.env.test?
  require_relative "../../spec/simplecov_helper"
  namespace :simplecov do
    desc "merge_results"
    task report_coverage: :environment do
      SimpleCovHelper.report_coverage
    end
  end
end

カバレッジ結果マージヘルパー
spec/simplecov_helper.rb

# frozen_string_literal: true

# spec/simplecov_helper.rb
require 'active_support/inflector'
require "simplecov"

class SimpleCovHelper
  def self.report_coverage(base_dir: "./coverage_results")
    SimpleCov.start 'rails' do
      add_filter '/vendor/'

      merge_timeout(3600)
    end
    new(base_dir: base_dir).merge_results
  end

  attr_reader :base_dir

  def initialize(base_dir:)
    @base_dir = base_dir
  end

  def all_results
    Dir["#{base_dir}/.resultset*.json"]
  end

  def merge_results
    results = all_results.map { |file| SimpleCov::Result.from_hash(JSON.parse(File.read(file))) }
    SimpleCov::ResultMerger.merge_results(*results).tap do |result|
      SimpleCov::ResultMerger.store_result(result)
    end
  end
end

テストカバレッジの結果をSlack通知するスクリプトです。 steakではカバレッジ80%を維持するために閾値を下回った場合にメッセージを変えています。 また、通知のメッセージからカバレッジの結果を確認できるようにartifactsへのパスを入れています。
script/post_coverage_to_slack.rb

require 'net/http'
require 'uri'
require 'json'

CIRCLE_NODE_INDEX = ENV['CIRCLE_NODE_INDEX']
CIRCLE_BUILD_NUM = ENV['CIRCLE_BUILD_NUM']
CIRCLE_ARTIFACT_ID = xxxxxxxx

threshold = 80.0
coverage = `cat ~/steak/coverage/.last_run.json | jq -r '.result.covered_percent'`.chomp

text = if coverage.to_f > threshold
         ":white_check_mark: Test Coverage: #{coverage} %.
         :circleci: coverage report: <https://#{CIRCLE_BUILD_NUM}-#{CIRCLE_ARTIFACT_ID}-gh.circle-artifacts.com/#{CIRCLE_NODE_INDEX}/coverage/index.html|open report :earth_asia: >(build #{CIRCLE_BUILD_NUM})"
       else
         ":x: Test Coverage: #{coverage} %. ( :cop: 基準値は #{threshold} % です。テストコードが足りていません)
         :circleci: coverage report: <https://#{CIRCLE_BUILD_NUM}-#{CIRCLE_ARTIFACT_ID}-gh.circle-artifacts.com/#{CIRCLE_NODE_INDEX}/coverage/index.html|open report :earth_asia: >(build #{CIRCLE_BUILD_NUM})"
       end

uri = URI.parse(ENV['SLACK_TEST_HOOK'])
params = { text: text }
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.start do
  request = Net::HTTP::Post.new(uri.path)
  request.set_form_data(payload: params.to_json)
  http.request(request)
end

通常のSlack通知 f:id:yo-shimada:20191010171900p:plain

閾値を下回った場合のSlack通知 f:id:yo-shimada:20191010172006p:plain

カバレッジの結果画面 f:id:yo-shimada:20191010172434p:plain

CircleCIのcoverageのジョブ画面のArtifactsからも遷移できます f:id:yo-shimada:20191010172516p:plain

ワークフロー

buildジョブを実行後に、rubocopcoverageのジョブを実行するように設定します。

...
workflows:
  build_and_test:
    jobs:
      - build:
          context: studyplus-bot
      - rubocop:
          context: studyplus-bot
          requires:
            - build
      - coverage:
          context: studyplus-bot
          requires:
            - build

まとめ

トライアル期間中に試行錯誤した結果、ビルド時間が半分以下(3~5分)になり無駄なストレスが軽減されたので、そのまま導入する事を決めました。 以下が導入前後のWorkflowsのキャプチャになります。

Performance Plan導入前(トータル時間 08:25) f:id:yo-shimada:20191010172710p:plain

rubocopとcoverageのジョブを分割する前(トータル時間 04:30) f:id:yo-shimada:20191010173603p:plain

ジョブを分割して並列実行(トータル時間 04:13) f:id:yo-shimada:20191010172943p:plain

カバレッジ結果をまとめるところで苦戦しましたが、並列実行自体することはすぐ試すことができ簡単に動作を確認する事が出来るので、CIの実行時間やビルド待ちで悩んでいる方は是非一度トライアルをされる事をお薦めします。

散らかったStorybookを整理する

こんにちは。ForSchool事業部の石上(id:shgam)です。今回はStorybookの話です。

7/17にリニューアルしたStudyplus for Schoolでしたが、このとき導入したStorybookを活用できていませんでした。そもそも整備が足りてなかったので、1日もらってStorybookを整理しました。

社内esaとの重複もありますが改めてチームメンバーへの共有も兼ねて、このブログ記事を書いています。

f:id:shgam:20191015144104p:plain
Studyplus for School の Storybook

背景

コンポーネントカタログとして導入してあるStorybookでしたが、リリースに向けて忙しくなるうちに整理が後回しになり、ちゃんと活用できない状態になってしまっていました。

作業の話を始める前に、なぜコンポーネントカタログが必要か、そしてどんな問題があったかを整理しておきます。

そもそもなぜStorybookが必要なのか

  • Storybookのようなものがないと、画面を実装する際使えるパーツを探すのが難しく暗黙知に頼るしかなくなります。整理されたStorybookは、画面実装の助けになると思います。
  • Wikiみたいなところにテキストドキュメントで整理しようとすると、実装との乖離が発生しやすく整理するのは難しいです。Storybookなら実装したものをそのまま置く形になるので、現実のコンポーネントが確認できます。

問題と原因

以下のような問題がありました。

  • Storybookに追加されているけど使い回せないコンポーネントがある
    • 原因:開発初期のコンポーネント分割のスキル不足(Atomic Designの理解が浅いままやってしまっていた)
  • Storybookに追加されてないコンポーネントがある問題
    • 原因:いちいち追加するのが面倒
  • Storybookの認識が曖昧(何に使うものなのか、今どうなってるのか)
    • 原因:ちゃんと整理されておらず、活用方法も特に共有していない

これらを解決するのをゴールに、修正をはじめました。

やったこと

Storybookに追加されているけど使い回せないコンポーネントがある

使い回せないコンポーネントがいくつかStorybookに存在してしまっていました。

本来なら適切にまとめる(たとえば無駄な分割がされたコンポーネントを親のコンポーネントに含める)ことが必要ですが、今回はStorybookの整理なので一旦Storybookから削除することにしました。

Storybookに追加されてないコンポーネントがある問題

いちいち追加するのが面倒だというのが明らかでした。そこで、コンポーネントをつくるときに必ずstoryが追加されるよう、コンポーネント生成コマンドを用意することにしました。

ただそれ以前に、各ストーリーがstories/index.stories.tsxにべた書きされていてファイルが分割できていませんでした。

// .storybook/config.ts
function loadStories() { require('../stories/index.stories.tsx'); }
configure(loadStories, module);

// stories/index.stories.tsx
storiesOf('atoms', module)
  .add('Hoge', () => <Hoge />)
  .add('Fuga', () => <Fuga />)
// これが延々と続く

これでは、コンポーネント生成のスクリプトからストーリーを追加するときに面倒です。

以下のように修正しました。

// .storybook/config.ts
import { configure } from '@storybook/react';
import "../src/styles/global.scss";

const loaderFn = () => {
const req = require.context('../stories', true, /\.tsx$/);
  req.keys().forEach(fname => req(fname));
};

configure(loaderFn, module);

// atoms/Tag.ts
 storiesOf('atoms', module)
  .add('Tag', () => {
    return (
      <Tag tag={{ id: "hogehoge", name: "タグ" }} />
    )
  })

stories以下はこうなりました。

$ tree stories/
stories/
├── atoms
│   ├── Card.tsx
│   ├── DoughnutChart.tsx
│   │_____ ...
└── molecules
    ├── EllipsisDropdown.tsx
    ├── SortLabel.tsx
    ├── ...

これなら、コンポーネントを作るときにストーリーファイルを生成するのも簡単です。atoms/Hogeを作るなら、stories下にも同じ名前で雛形ファイルを作ってあげればいいだけです。以下のスクリプトを用意しました。

const fs = require('fs');
const path = require('path')

const generateFile = (pathname, filename) => {
  const absolutePath = path.resolve(__dirname, pathname);
  const filePath = `${absolutePath}/${filename}`;

  if (!fs.existsSync(absolutePath)){
    fs.mkdirSync(absolutePath);
  }

  if (fs.existsSync(filePath)){
    console.log(`Error: ${filePath} already exists.`);
    return;
  }

  fs.appendFile(filePath, "// created by generator.", function(err) {
    if (err) { return console.log(err); }

    console.log(`${absolutePath}/${filename} generated.`);
  });
};

const generateComponent = (componentLevel, componentName) => {
  const filenames = ['index.tsx', 'styles.scss', 'styles.scss.d.ts'];

  filenames.forEach(filename => {
    generateFile(`../src/components/${componentLevel}/${componentName}`, filename);
  });
};
const generateStory = (componentLevel, componentName) => {
  generateFile(`../stories/${componentLevel}`, `${componentName}.tsx`);
}; 

const run = () => {
  const [processName, scriptName, ...options] = process.argv;
  const [componentLevel, componentName, ...undefinedOpts] = options;
  const validComponentLevels = ['atoms', 'molecules'];
  if (validComponentLevels.includes(componentLevel)) {
    generateComponent(componentLevel, componentName);
    generateStory(componentLevel, componentName);
  } else {
    console.log(`Error: コンポーネントレベルは${validComponentLevels.join(', ')}のいずれかにしてください`);
  }
}

run();

実行すると、必要なファイルが生成されるようになりました。

~/boron-web node scripts/componentGenerator.js atoms Hoge
/Users/gaaamii/boron-web/src/components/atoms/Hoge/styles.scss.d.ts generated.
/Users/gaaamii/boron-web/src/components/atoms/Hoge/index.tsx generated.
/Users/gaaamii/boron-web/src/components/atoms/Hoge/styles.scss generated.
/Users/gaaamii/boron-web/stories/atoms/Hoge.tsx generated.

Storybookの認識が曖昧(何に使うものなのか、今どうなってるのか)

今回のこのブログを読んでもらって、ちゃんと整理できたので活用していきましょうという感じにしていきたいです。社内のesaにも、補足があればどんどん書き足していきたいです。

ついでに:latest(5.2.3)に対応

ついでに、Storybookのバージョンも最新に上げました。

まとめ

以上、今回は4つの作業を行いました。

f:id:shgam:20191017100348p:plain
今回行った作業

当たり前にやるべきことをできてなかったという感じなので、ここで整理できてよかったです。

せっかくコンポーネントを分けているので、他の人が画面を実装するときには「Storybook見ながらコンポーネント組み合わせたら実装できた!」みたいな体験になればいいなと思っています。

スタディプラス AndroidアプリKotlin化の歩み

こんにちは、Androidチームの若宮(id:D_R_1009)です。 今回はAndroidアプリの大きな更新、JavaからKotlinへの移行について書きたいと思います。

Androidアプリの歴史

Kotlinの導入

スタディプラスのAndroidアプリは2016年1月ごろにフルリニューアルを行い、そのまま開発を続けています。 Kotlinは2017年12月ごろの導入となるため、コードの大半はJavaで記述されています。

その後、2018年3月ごろから本格的にKotlinへの移行(コードのKotlin化)を進め、8月ごろには20%を占める程度になりました。

tech.studyplus.co.jp

Kotlin化の本格化

2018年9月よりフルタイムの開発者が2名に、2019年5月より3名になりました。 また副業でkobakeiさん(id:keisukekobayashi)に入ってもらったことにより、Kotlin化が本格化します。

以下、大きな変更や方針が決まった時期を振り返ってみました。 もちろん、合間合間にリファクタリングやマルチモジュール化に伴うコードの整理が行われています。

  • タイムラインデザインリニューアル(2019年10月)
  • Kotlin Coroutines導入、RxJavaから移行開始(2018年11月)
  • デザインリニューアル(2018年11月)
  • マルチモジュール構成へ移行開始(2018年12月)
  • 友達からフォロー/フォロワーへの更新に伴うアプリの一新(2019年3月)
  • 大学情報関連画面のリファクタリング(2019年3月)
  • 内部DBにRoomのDatabaseViewを導入(2019年5月)
  • AndroidXへの移行(2019年6月)
  • NavigationによるFragment遷移実装開始(2019年7月)
  • アカウント作成方法更新(2019年8月)
  • ネットワークレスポンス用DataクラスのKotlin化 (2019年9月)

結果として、2019年9月末を持ってKotlinが全体の86%を占める状況となりました!

f:id:D_R_1009:20190930181835p:plain

ここに至るまでに大きな影響を与えた出来事について、いくつか抜き出してみたいと思います。

Kotlin Coroutines導入、RxJavaから移行開始(2018年11月)

Kotlin Coroutinesのstable版が2018年10月にリリースされ、AndroidチームではまずSDKに導入しました。 下記ブログを導入直後に書いたことを覚えています。

tech.studyplus.co.jp

SDKへの導入に続いて、スタディプラスへKotlin Coroutinesを導入しています。 RxJavaを利用していた箇所が多かっため、RxJavaをKotlin Coroutinesに置き換える処理が大半となりました。

RxJavaは下記のような目的で利用されていました。

  1. Single / Complete 型による通信
  2. RxBusによるクラス間連携
  3. Obserbable によるリスト操作

通信処理をOkHttp + Retrofitで行なっていたため、Kotlin化を簡単に進めることができました。 suspend を返り値とする対応はRetrofitのアップデートを待ってからとなりましたが、デフォルト引数の利用などだいぶコードの削減ができるようになりました。

2019年7月頃からRoom 2.1でKotlin Coroutinesがサポートされたため、Kotlinをより活用しやすくなっています。 またKotlin Coroutines 1.3.30からは Flow も導入されたため、 RxStream の処理も移行しやすくなりました。

Kotlin Coroutinesが登場したことにより、既存コードに +α を加えながらKotlin化しやすくなったと言えます。

マルチモジュール構成へ移行開始(2018年12月)

kobakeiさんには月1回勉強会を開いてもらっています。 その2018年11月のテーマが「マルチモジュール」でした。

当時のスタディプラスアプリはJavaとKotlinを合わせて10万行程度(Java 7.5万、Kotlin 2.5万)のシングルモジュールアプリでした。 設計はJavaのコードがActivityを中心としたMVC、KotlinのコードがAndroid Architecture Moduleを利用したMVVMが採用されていました。

当時開発していた時に上がっていた問題は、下記3点です。

  1. ビルド時間が長い
  2. 画面ごとに利用するメソッドがまとまっているため、処理がまとめられていない
  3. リファクタリング時に思わぬクラスへの影響が発生する

マルチモジュールに移行する際、一番期待していたのは「ビルド時間」の問題でした。 確かにマルチモジュール化により並列ビルドの恩恵を得られたのですが、同時にDaggerを導入したことにより相殺されてしまったのか、ビルド時間の短縮は感じられませんでした。

一方で、設計上は大きなメリットが得られました。 モジュール化を進める上で、まず entity (データクラス)モジュールから分離する必要があります。

スタディプラスアプリの場合、この entity モジュールの作成が難航しました。 というのも、データクラス内でネットワークインスタンスを呼び出すなどの処理をしている箇所が散見されたためです。 Kotlin化の早いタイミングで設計上の問題が見つかったため、結果として効率的にKotlin化を進めることができました。

一方で、クックパッドさんが行なっていた Legacy モジュールの対応は行えませんでした。 こちらは色々とモジュール移動に苦心することとなったため、行なっておけばよかったと強く後悔しています。

speakerdeck.com

マルチモジュール化と同時にアプリ全体を巻き込む機能開発(フォロー制への移行)が被ってしまったため、タイミングを逃してしまったことが大きかったように思います。 マルチモジュール対応を行う場合には、新規機能開発のタイミングと被らせずにスタートするのが良いのではないでしょうか。

1年間を通して見ると、kobakeiさんにマルチモジュール化の導入から実行までを強く推進してもらいました!

f:id:D_R_1009:20190930182212p:plain

マルチモジュール化によりKotlin化しやすくなる(クラス間の依存関係が一方方向になるため、影響範囲が限定される)ことを実感しています。 ビジネス的な成果は少ないのが少々難しいところですが、開発チームのタスクとして取り組むことを強くお勧めします。

ネットワークレスポンス用DataクラスのKotlin化 (2019年9月)

Kotlin Coroutines 1.3.30やOkHttp 4系を導入しようとしたところ、Proguardを起因とするビルドクラッシュが発生しました。 このため、ProguardからR8へ移行した方が良い状況となりました。

github.com

github.com

しかし、R8でGsonを利用すると問題が生じやすくなります。 この問題に対応するため、1週間ほどかけて全ての通信用データクラスをKotlin Dataクラスに変換しGsonからmoshiへ移行する対応を行いました。

r8.googlesource.com

また逆説的ではありますが、OkHttpやRetrofitなどのライブラリ側でKotlinが利用されるようになってしまったため、Kotlinの対応を見越した開発体制にする必要が生じています。 例えばKotlin CoroutinesのMainDispatchersの初期化遅延問題は、最新のR8(記事執筆時点でalpha版であるAGP 3.6以上)でなければ解決しません。

github.com

こういった大規模な問題が発生するまでデータクラスの整理を後回しにしていたので、少々タスクが重い状況になってしまいました。 Kotlin化を進める中で、少しずつサーバーチームと連携しながら進めていくのが良いように思います。

また知見としては、通信用のデータクラスのKotlin化をすることで下記のような事象に出くわしました。

  1. 古くからあるAPIのため特に理由もなくnullableとして扱っているプロパティが見つかった
  2. デフォルト引数により、non-nullな値として扱える箇所が複数見つかった
  3. Gsonではリフレクションにより継承関係を簡単に扱えたが、moshiでは継承関係をデータクラスの引数として表現する必要があった

2つ目は特にリストをプロパティとして持つJSONに有効でした。 これまでは orEmpty() を噛ませることで対応していた箇所が、デフォルト引数で emptyList() を指定するだけで対応が終わるようになります。 結果論ではありますが、型安全なコードを記述するためにも、Kotlin化は非常に有効な手段だと言えるのではないでしょうか。

終わりに

簡単ではありますが、スタディプラスアプリが1年ほどかけて60%ほど(削除しているコードを考えるとそれ以上! )をKotlin化した経験を振り返ってみました。 Kotlin化により、コードレビューの時に名前付き引数があるとレビューしやすいなど、様々なDXの向上を感じています。 機能開発の傍らであってもKotlin化を進めることを快諾してくれた企画部やCTO、並びにチームメンバー(中島さん、隅山さん)と副業エンジニア(kobakeiさん)にはいくら感謝しても感謝しきれません!

残り10%強のKotlin化、ならびによりユーザーにとってメリットのある設計を目指して、引き続き頑張っていきたいと思います。