コード生成を用いたiOSアプリマルチモジュール化のための依存解決

こんにちは、モバイル基盤部の@giginetです。

iOS版のクックパッドアプリでは、2019年頃より、大規模なアプリを複数のモジュールに分割するマルチモジュールの導入を進めてきました。

今回はクックパッドアプリのマルチモジュール化の戦略について、主に依存関係の解決という点に焦点を当てて紹介します。

クックパッドアプリとマルチモジュールプロジェクト

iOS版のクックパッドアプリはコード量が多く、膨大なビルド時間が問題となっていました。また、同時に関わる開発者も多く、それぞれの機能間を疎結合にしたいという需要が大きくありました。

この問題を解決するために、2019年の初頭からiOSアプリのマルチモジュール化プロジェクト*1を開始しました。 以来、ここ2年で、モジュール分離を前提とした開発が大きく進みました。

現在では、アプリ全体のコードのうち、半分以上がモジュール分離され、アプリ全体が約25個のモジュールに分割されています。

これまでのマルチモジュールの取り組みは、2019年に開催されたCookpad Tech Conf 2019の講演「〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」で紹介しています。

以下は上記の講演動画の書き起こし記事です。この記事を読む前にご覧いただけると、より理解しやすくなると思います。

モジュール分離には、分割の方法や粒度、移行プロセスなど、様々なトピックがありますが、この記事ではとりわけ、大きな障害となるモジュール間の相互の依存関係解決のための仕組みについてお伝えします。

クックパッドアプリのモジュール構成

まず始めに、前提となるアプリケーション全体の構成について説明します。

クックパッドアプリのモジュール構成は概ね以下のような図で表すことができます。

f:id:gigi-net:20210615192428j:plain
クックパッドアプリのモジュール構成

下の層はCoreモジュールと呼ばれています。図中でCookpadCoreやCookpadComponentと呼ばれているモジュールです。 Coreモジュールは抽象化のためのインターフェイスや、アプリ内で共通して使うUIコンポーネントを提供しています。

その上の層はFeature Moduleと呼ばれる層です。検索、レシピ投稿、つくれぽ、買い物機能など、機能単位をひとまとまりとするモジュールに分離されています。 図中にある黄色い四角は、シーンと呼ばれる単位です。アプリ内の1画面に相当します。 各シーンは、VIPERアーキテクチャにより実装されています。Feature Moduleは、ドメイン層を共有する複数の(数個の)シーンの集まりということができます。

f:id:gigi-net:20210615192457p:plain
Feature ModuleにおけるVIPERアプリケーションの構成

最上位のCookpadはアプリケーションです。 Xcodeプロジェクトにおける、アプリケーションターゲットに相当します。アプリケーションは全てのモジュールの実装を知ることができます。

単機能ビルドを重視したモジュール構成

モジュール分離の議論でしばしば話題に挙がるのが、アプリケーション全体をどのように分割し、再構成するかという点です。

我々のモジュール分割の大目的はビルド時間の削減にありました。

そのため、アプリ全体をビルドせずとも開発できるように、モジュール1つを単体起動できる構造を重視しています。

このような構成になっていることで、Feature Module単体のみを取り出し、個別にビルドすることが可能となりました。

この構成を生かした結果が、Sandboxアプリという動作確認用のミニアプリです。Feature Aの開発を行う場合は、CoreモジュールとFeature Aのビルドのみで動作確認ができるようになりました。

f:id:gigi-net:20210615192522p:plain
Sandboxアプリの構成

これにより、単機能のみのビルドと実行を行うことができるようになり、開発効率が大きく改善されています。

詳しくは以下の記事で紹介しています。併せてご覧ください。

マルチモジュール化における依存関係解決の必要性

巨大なアプリを複数のモジュールに分割する際に問題になるのは、依存関係の解決です。

f:id:gigi-net:20210615192553p:plain
Feature Module同士の参照は制限されている

我々のアーキテクチャでは、同じレイヤー上のモジュールが、お互いに参照を持つことを制限しています。もし両方向に依存してしまうと即座に循環参照が発生してしまうからです。その代わり、循環参照を避けるための依存抽象化の仕組みを導入しています。

複数のFeature Moduleが互いに連携する例を考えてみましょう。 例えばBモジュールで実装されているシーンを、Aモジュールで表示するというのが代表的な例です。

これを実現するために、どのような抽象化を実現しているのでしょう。

Environment 〜モジュール外への依存を簡単に注入できるためにするコンテナ〜

各シーンはEnvironmentという、依存関係を取り出すためのDIコンテナにアクセスすることができます。

Environmentは、外部とのI/Oや外部ライブラリ、モジュール間の連携といった、別のモジュールとの連携を持つ必要がある実装を抽象化する役目を果たしています。 各シーンは、他のモジュールの実装を用いたいときや、ネットワーク通信など、外部との入出力が発生するとき、Environmentを用いて依存にアクセスします。

アプリケーションターゲットは、Environmentに具体的な実装を注入する役目を持ちます。

f:id:gigi-net:20210615192615p:plain
Environmentを使った依存関係解決の仕組み

Environmentは動作環境によって差し替えることができます。例えばアプリ起動時やテスト開始時など、環境の起動時に生成して全てのシーンに渡されます。 これにより、フルビルドしたアプリケーション、Sandboxアプリ、ユニットテスト、UIテストなど、様々な環境で異なるEnvironmentを用意することで、依存の注入を簡略化しています。

ResolverとDescriptor 〜依存を抽象化して取り出すための仕組み〜

先に述べたとおり、それぞれのFeature Moduleはお互いを知ることができないため、他のモジュールの実装を取り出すためには、Environmentを経由する必要があります。 この仕組みをResolverと呼んでいます。

同時に、Resolverに特定の実装を示すためのマーカーをDescriptorと呼んでいます。このDescriptorはCoreモジュールに存在します。 そのため、あるモジュールは他のモジュールにある実装を知ることができませんが、その実装を示すDescriptorはどこからでも知ることができます。

他のモジュール上にある実装を取り出したい場合、Descriptorを用いて、Environmentに実装の取得を要求します。 アプリケーションターゲットは全てのモジュール上の実装を知っていることを利用し、アプリケーションターゲットは具体的な実装をEnvironmentに注入し、protocolで抽象化して返します。 その結果、実装を取り出したいモジュールからは、protocolを用いて抽象化された実装を取り出すことができます。

開発しやすいResolverのための課題

このResolverの仕組みを、シンプルに、ミス無く維持するためには何が必要でしょうか。大きく、以下のような点が議論に挙がりました。

型安全性

Resolverから取得した値を任意の型として型安全に使いたい

コンパイル時の網羅性の検証

あるDescriptorに対応したResolverの実装漏れを自動的に検出できるようにしたい

利用の簡便さ

開発時のグルーコードの記述量を減らしたい。また、実装に迷わないようにしたい

コード生成を用いた型安全なResolver

これらを満たすために、コード生成を用いてResolverをある程度自動生成する仕組みを実現しました。

ここからは、その仕組みを使った、モジュール間依存解決の例を見ていきましょう。

Coreモジュール

まず、開発者はDescriptor構造体をCoreモジュールに定義します。

これは、レシピIDを元に、該当のレシピ詳細シーン(RecipeDetails)のUIViewControllerを取り出すためのDescriptorです。

extension ViewDescriptor {
    public struct RecipeDetailsDescriptor: TypedDescriptor {
        public typealias Output = UIViewController
        public var recipeID: Int64

        public init(recipeID: Int64) {
            self.recipeID = recipeID
        }
    }
}

Descriptor構造体は、依存を取り出すために必要なパラメータと、取りだしたい依存の型(Output)を持ちます。

コードの自動生成

ビルドシステムは、ビルド時に、Coreモジュールに含まれるDescriptorを探索し、それらを使うResolverを実装するように促すprotocol、ConcreteViewResolverを自動生成します。 ConcreteViewResolverは、定義されている全てのDescriptorに関するresolveメソッドを持つprotocolです。

// 自動生成される
public protocol ConcreteViewResolver {
    func resolveConcrete(_ descriptor: ViewDescriptor.RecipeDetailsDescriptor) -> ViewDescriptor.RecipeDetailsDescriptor.Output
}

コード生成にはSourceryという、Swiftのコード生成を行うユーティリティを利用しています。

この仕組みにより、存在する全てのDescriptorに対してのResolverの実装が、コンパイル時に強制されるため、Resolverの実装漏れを防ぐことができます。

アプリケーションターゲット(Cookpad)

次に、開発者はアプリケーションターゲットにおいて、ConcreteViewResolverに適合したEnvironmentを実装します。

extension CookpadEnvironment: ConcreteViewResolver {
    func resolveConcrete(_ descriptor: ViewDescriptor.RecipeDetailsDescriptor) -> ViewDescriptor.RecipeDetailsDescriptor.Output {
        RecipeDetailsViewBuilder.build(with: descriptor, environment: self) // レシピ詳細シーンを生成する
    }
}
利用したいモジュール

最後に利用したいモジュールからResolver経由で実装を取り出します。

let destinationViewController: UIViewController = environment.resolve(ViewDescriptor.RecipeDetailsDescriptor(recipeID: 42))
currentViewController.present(destinationViewController, animated: true, completion: nil)

このとき、取り出した型はDescriptor.Outputに指定した型にダウンキャストされます。これにより、利用者は型安全に他のモジュールの実装を取り出すことができます。

型安全に実装を取り出すための仕組み

ところで、上記のようなダウンキャストはどのように動作するのでしょうか。これも自動生成されたextensionで実現されています。

ConcreteViewResolver protocolを生成するタイミングで、Environmentの方には、任意のDescriptorをダウンキャストするresolveメソッドが自動生成されます。

このメソッドは、先ほど実装したresolveConcreteをそれぞれのDescriptorごとに呼び出し、Descriptor.Outputにダウンキャストします。

アプリケーションターゲット(Cookpad)
// このコードも自動生成される
extension CookpadEnvironment {
    func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Output {
        switch descriptor {
        case let recipeDetailsDescriptor as ViewDescriptor.RecipeDetailsDescriptor:
            return resolveConcrete(recipeDetailsDescriptor) as! Descriptor.Output
        case let anotherDescriptor as ViewDescriptor.AnotherDescriptor:
            return resolveConcrete(anotherDescriptor) as! Descriptor.Output
        // 以下省略...
        default:
            // 全てのケースをコード生成で網羅しているので、ここには到達し得ないはず
            fatalError("Unknown descriptor!")
        }
    }
}

この巨大なswitch文をコード生成することで、存在する全てのDescriptorがケースとして網羅されます。 これにより、コンパイル時に、全てのDescriptorに対するResolverの実装が保証されるのです。

型安全なインターフェイスの取得

この際、実際にResolverから返されるViewControllerは RecipeDetailsViewController ですが、これを取得した他のモジュールからは単なるUIViewControllerとして見えます。

これは、RecipeDetailsViewControllerを含むモジュール内でしか、この具体的な型を知ることはできないためです。

各ViewControllerに機能を持たせたい場合、この仕組みにより、任意のインターフェイスを公開し、取得することもできます。

Coreモジュール(CookpadCore)

Coreモジュール内に、ViewControllerのうち、公開したいインターフェイスのみを含むprotocolを追加しましょう。

public protocol RecipeDetailsViewControllerProtocol {
    var recipeID: Int64 { get }
}

このとき、具体的な実装はこのViewControllerが実装されているFeature Module内に存在します。Coreモジュールは実装を持っていません。

その後、先ほどのようにDescriptorを実装します。このとき、Outputの型としてRecipeDetailsViewControllerProtocolを返すように指定しています。

extension ViewDescriptor {
    public struct RecipeDetailsDescriptor: TypedDescriptor {
        public typealias Output = UIViewController & RecipeDetailsViewControllerProtocol

        // ...
}
利用したいモジュール

同様に利用したいモジュールからはresolverを用いて実装にアクセスできます。このとき、返却される型は Descriptor.Output に指定した型として扱われます。

let destinationViewController: UIViewController & RecipeDetailsViewControllerProtocol = environment.resolve(ViewDescriptor.RecipeDetailsDescriptor(recipeID: 42))
destinationViewController.recipeID // 42

これにより、他のモジュールはResolver経由で実装を任意のprotocolに適合した形として取り出すことができました。


このように、Resolverの実装にコード生成を用いることで、型安全性を保ちながら、コンパイル時に網羅性を担保し、実装ミスが起こらないようにすることができました。

また、開発者はDescriptorと、それに対応するResolverを用意するだけでよいので、Resolverの実装コストを減らすこともできました。

ドメイン層へのDescriptorの拡張

今回の例では、簡単のため、画面遷移にResolverを使う例をご紹介しました。

我々のアプリでは、DataStoreやUseCaseといった、ドメイン層の依存解決のためにもResolverを解放しています。

例えばAモジュールで実装されているUseCaseをBモジュール内の実装で扱いたいという例です。 Viewの例と同様に、公開したいインターフェイスのみをprotocolとして抽象化し、DataStoreDescriptorとして同様の仕組みを実現しています。

この仕組みにより、モジュール間での任意の依存解決を簡単に実現できるようになりました。

さらにマルチモジュールについて知りたい方へ

今回の記事では一部しか紹介することができませんでしたが、いくつかの記事やイベントで、クックパッドアプリのマルチモジュール戦略についてご紹介しています。

冒頭に紹介した「Cookpad TechConf 2019 〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」では、今回紹介したマルチモジュール戦略の全体像を説明しています。

先日、5/26には、「Cookpad Lounge #3 クックパッド iOS アプリを爆速で開発できるようにする話」というイベントで、座談会形式でマルチモジュールの話をしました。 視聴者の方から多くの質問を頂き、ありがとうございました。アーカイブが以下で公開されています。

来る6/16 19:30より、メルペイさんが主催の「iOS Tech Talk 〜 Multi module 戦略座談会 〜」というイベントで、各社のマルチモジュール戦略について座談会を行います。 この中でもクックパッドアプリのマルチモジュール戦略についてご紹介するつもりです。ご興味のある方はぜひ遊びに来てください。

まとめ

今回は、クックパッドアプリのマルチモジュール化のうち、特に依存関係解決の仕組みにフォーカスしてご紹介しました。

マルチモジュールは、iOS界隈でも関心度の高いトピックなため、今後も発信の機会を作っていきます。

また、ご質問がある場合は@giginet までお気軽にお寄せください😄

クックパッドでは、大規模なアプリのリアーキテクチャを行いたいエンジニアを募集しています。

*1:霞が関プロジェクトと呼んでいます

データ分析 SQL とその実行結果を共有・検索できるアプリ Bdash Server を作りました

こんにちは。クックパッドでエンジニアをしている @morishin です。Bdash Server というデータ分析 SQL を共有するアプリケーションを作って社内で使い始めたのでその紹介をします。

クックパッドのサービス開発は「仮説を立てる」→「作ってリリース」→「効果検証」の繰り返しで進んでいます。ここで言う効果検証というのは作ったサービスが狙い通りの使われ方をし、ユーザーに価値を提供できているかどうかの確認のことです。その手段は複数あり、実際に使っていただいたユーザーさんにインタビューをさせていただく場合もあればアプリケーションから送信されたアクセスログ等を分析することで評価する場合もあります。この記事では後者の定量分析を効率化するためのツールを作った話をします。

続きを読む

全国のスーパーに置かれる storeTV 端末の情報の取得にかかる時間を15分から10秒にした話

こんにちは。
メディアプロダクト開発部の柴原です。
普段は CookpadTV のサービスである storeTV や storeLive の Android アプリを開発を担当しています。

storeTV では現在、サービスを高品質に継続開発・運用するための仕組みづくりをしており、この記事ではその中の1つである「端末が再生した動画をリアルタイムで把握する機能」について紹介します。
このリアルタイムログの導入でもともと実際の端末で動画再生がされているのかを確認するのに15分かかっていたものが10秒程度に短縮できました。

storeTV とは

storeTV は、スーパーで料理動画を流すサービスで、店頭に独自の Android 端末を設置し、その売り場に適したレシピ動画を再生するサービスです。 より詳しいサービス概要にについては、弊社メンバーの Cookpad TechConf 2018 における以下の発表スライドを御覧ください。

動画の再生順序の概要

storeTV では、「手順動画15秒 * 4回 + 広告動画30秒 * 1回」の90秒を1ロールとし動画をループ再生しています。

f:id:Nshiba:20210607140255j:plain
1ロールの動画再生順序

背景

storeTV では通常のアプリよりも端末の状態把握が重要です。 なぜなら「storeTV」は、買い物客の「今日何作ろう?」と、店舗の「この食材がおすすめ!」をマッチングさせるサービスなので、サービスとして動き続けることが重要だからです。 万が一停止していると問い合わせが発生した場合こちらで端末の状態を把握し回答をする必要があります。
そのため、いくつか端末の状態を把握するための仕組みがあり、主に端末から送信しているログと端末管理システムから取得できる情報があります。

端末から送信しているログについては、ログが送信されてから実際に見れるようになるまで約15分程度の時間がかかります。 また、端末管理システムから取得できる情報はアプリバージョンやOSバージョンといった基本的なものと、1台ずつですがその時のスクリーンショットを取得することが可能です。

よって、端末の状態を把握するにはログが来るまでの15分程度待つか、端末管理システムで1台ずつ能動的にスクリーンショットを確認していく必要がありました。

このような状況を改善するためにリアルタイムで各端末の動画再生ログを収集するリアルタイムログを導入しました。

構成


リアルタイムログを実現するために以下のような構成を採用しました。

f:id:Nshiba:20210607140435j:plain
構成図

IoT

まずログの受け取りは AWS IoT の Rule Action を用いています。
なぜ AWS IoT を用いているかというと、端末管理システムは内製したもので AWS IoT を用いて開発しており、今回のリアルタイムログも端末管理システムの一部機能として実装しました。
端末管理システムのより詳しい話は、以下のAWS導入事例の記事を御覧ください。

aws.amazon.com

Rule Action とは、特定の Topic に対してデータが送られてきた際に特定の動作を行わせることができる機能です。 例として、受け取ったデータを DynamoDB や S3 への書き込みや Lambda Function の起動といったことが可能になってます。

しかし今回のリアルタイムログでは、受け取ったデータを Kinesis に流す構成を採用しています。

Kinesis & Lambda

IoT Rule Action で受け取ったデータは一度 Kinesis に入れ Lambda から DynamoDB に書き込みます。 これは、大量のデータを DynamoDB に直接書き込んでしまうと大量の WCU が必要になってしまうため、書き込み量を制御するためです。
 構成図を見ると分かる通り、最終的に DynamoDB にログを書き込みます。
IoT の Rule Action ではそのまま DynamoDB に書き込むことも可能ですが、今回は一度 Kinesis を挟んでから DynamoDB に書き込むようにしています。
これは、そのまま DynamoDB に書き込んでしまうと秒間の書き込みの最大が端末数に依存してしまうためです。
現在でも約5000台程度の端末から約15秒に1件のログが送られてきます。さらに端末数は今後増えていく見込みであるためシステム側で DynamoDB への書き込み量を制御するために Kinesis を挟み、一度ハンドリングしてから DynamoDB に書き込むようにしています。

DynamoDB

データは storeTV 端末から以下のような JSON が送られてきます。
thing_name とは端末を識別するためにそれぞれ割り当てられている固有の値です。

{
    "thing_name": #{thingName}, 
    “creative_id”: 1, 
    “creative_type”: 1,
    "published_at": "2021-06-01 10:00:00"
    “project”: “store_tv”   
}

これを thing_name を Hash Key とし、受け取ったものから上書き保存という形で DynamoDB に書き込んで行きます。

name type schema example
thing_name String Hash Key thing_001
creative_type Int Sort Ket 1
creative_id Int 1
published_at String 2021-06-01 10:00:00
project String store_tv

大量のログをさばくためにしたこと

この機能を実現しようとしたときに、まずはじめに困ったことは大量のログが常に送信され続ける、という点です。
そのため、この機能を実装する際に大量のログをさばくためにいくつか対策をしました。

実際どの程度のログが送信されてくるかというと、現在 storeTV が稼働している端末で、端末管理システムが導入できている端末は当時5000台程度になります。
また storeTV は主に15秒の動画をループ再生しているため、1分間に送信されうるログの量は 5000 x 4 で20000件のログが送信されてくることになります。

IoT Rule Action では受け取ったデータをそのまま DynamoDB に書き込むことも可能ですが、この量のログをそのまま書き込んでしまうと大量の WCU が必要になってしまい運用にかかるコストが莫大になります。
そのため、まずは直接 DynamoDB に書き込むのではなく、 Kinesis でバッファし Lambda で DynamoDB に書き込むログの量を制御できるようにしています。

さらに DynamoDB のテーブル構成を見ると thing_name を Hash Key としています。
そのため、このテーブル仕様では1つの端末が sort key 毎に1行のログしか持てないことになります。
これは今回のリアルタイムログでは、端末が「最後に動画を再生したのはいつか」が把握できれば良いという方針で設計を行ったからです。
こうすることで Kinesis に溜まったデータを Lambda で DynamoDB に書き込む際に、同じ thing_name のログは最新のログだけ DynamoDB に書き込むようにすることで書き込む量を削減し大量のログをさばけるようにしています。

また、今回のログは端末側がオフラインのときなどにログが送信できなかった場合、再送などの処理は行っていません。
そのため、もともと正確性はそれほど高くならないような設計になっており、広告動画の再生回数の集計などには使えないものになっていますが、集計のためのログはすでに別の方法で取得し集計しているため今回は必要ありませんでした。

DynamoDB のコスト最適化

今回のリアルタイムログでは、 Scheduled Action を用いて 8:00~22:00 の間だけ DynamoDB の WCU を必要な分確保しています。 これは storeTV は 8:00~22:00 の営業時間というのが設定されており、営業時間外の 22:01~翌日7:59まではアプリは動作しておらず、ログが来るのは 8:00~22:00 の間のみに限定されるからです。

ちなみに、当初は Auto Scaling のみで DynamoDB の WCU を調整するようにしていましたが、これだと 8:00 の段階で急に書き込みが増えるため Auto Scaling が間に合わず数時間にわたりログの遅延が発生する状態に陥っており、これを解消するためにも Scheduled Action を用いるようにしました。
また、オンデマンドでも試しましたがサービスの稼働時間が固定されている状況もあり、 Auto Scaling のほうがコスト的にメリットが大きかったため Auto Scaling を採用しています。

まとめ

今回リアルタイムログを実装し端末ごとに今なんの動画が再生されているのかが迅速に把握できるようになりました。
ちなみにログの遅延具合は約5~10秒程度、遅くても1分以内に再生した動画は確認できるようになりました。
ただし、まだ端末ごとに再生した動画が確認できるだけなのでこのリアルタイムログを活用した新たな機能や、障害対応時を想定した端末内のより詳しいログを欲しいときだけ取得する機能など、端末の状態をより詳しく把握するための仕組みづくりを検討しています。

また、私はアプリエンジニアでしたが、サービスに何が必要なのかを自分で考えそれが自分の得意分野でなかったですが興味のある分野であったため、今回設計から実装までを任せてもらい、実際に Go でプログラム書いたり Lambda のデプロイ環境の準備や DynamoDB のテーブル設計を行いました。
そんなメディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、ぜひ採用情報を御覧ください。

info.cookpad.com

料理動画サービスに強く興味がある方は以下のリンクから「料理動画サービス」のついた項目を御覧ください。

info.cookpad.com