マルチテナントアプリで行うイベント計測のしくみ

こんにちは、Androidエンジニアのid:r4wxiiです。『Inside GigaViewer for Apps』連載5回目は、メディア共通機能の1つであるイベント送信機能を、GigaViewer for Apps(以下、GigaApps)の標準機能として実現するための設計について紹介します。

イベント送信機能とは

イベント計測は主にユーザー・行動の分析や広告効果測定などのために行われているもので、代表的なイベント計測を行うサービスとしてFirebaseが挙げられます。 イベント計測を行うことで効果的な機能追加や施策を打てるようになり、これはユーザーである読者の元に新たな出会いを提供するだけでなく、素敵な作品を連載している作者の方を広く知らしめ、ひいてはマンガ業界をよりよいものにする、GigaAppsをそのようなアプリへ進化させるための重要な取り組みといえます。そこで、GigaAppsにはイベント計測を行うためのイベント送信機能が備わっています。

さて、そんなイベント送信機能ですが、一言でイベント送信といっても複数のメディアを展開しているGigaAppsではそれぞれの事情が絡み合い複雑なものとなっています。 複雑になってしまう理由として大きく以下の2つが挙げられます。

  • メディアによって利用している計測プラットフォームが異なる
  • メディアによって送信するイベントが異なる

次のパートではその2つ理由について詳しく説明していきます。

GigaAppsのイベント送信機能が複雑となってしまう理由

メディアによって異なる計測プラットフォーム

ここではイベントが送信される先のサービスについて計測プラットフォームと呼びます。
世の中には様々な計測プラットフォームが存在していますが、それぞれ得意・可能な分野が違うので目的に応じて適したものを選択する必要があります。また、目的というのもメディア・出版社によって異なるはずなので、その分送信が必要な計測プラットフォームが増える/変わることになります。
ここでより複雑になってしまう要因として、複数のメディアで利用している計測プラットフォームがあれば、あるメディアでのみ利用している計測プラットフォームがあるというものが挙げられます。

メディアによって異なるイベント

前項でメディアによって目的が異なるため、利用する計測プラットフォームが変わると書きました。ということはつまり、計測に必要なイベントもその目的によって変化することになります。 ここで重要なのが同一タイミングで複数の計測プラットフォームへ送信するイベントが存在するという点です。計測プラットフォームごとに送りたいパラメータが異なることがよくあり、それがより複雑にさせる一因になっています。
また、全ての計測プラットフォームへ同一イベントを送信しているわけではなく、ある計測プラットフォームへ送信する一方で、別の計測プラットフォームには送信しないイベントがもちろん存在しています。

GigaAppsにおけるイベント送信機能を噛み砕いた設計

以上を踏まえ、イベント送信機能は「メディアによって計測プラットフォームとパラメータが異なるイベントを適切に振り分けて送信する」機能といえます。 この「メディアによって計測プラットフォームとパラメータが異なるイベントを適切に振り分けて送信する」機能をGigaAppsでどのように実現していくか考えます。

まず「メディアによって計測プラットフォームとパラメータが異なるイベントを適切に振り分けて送信する」機能を

  • メディアによって異なる計測プラットフォーム
  • メディアによって異なるパラメータ
  • イベントを適切に振り分けて送信する

の3つに分解して考えます。

メディアによって異なる計測プラットフォーム

前のパートで書いた通り計測プラットフォームについては複数のメディアで同一のものを利用していることがあります。したがって、GigaAppsでは計測プラットフォームごとに送信する機能を用意し、メディアごとにそれを切り替えるような仕組みで対応することにします。このような仕組みにする理由として以下の3つがあります

  • 計測プラットフォームが増えても切り替え先を増やすだけなので拡張性が高い
  • 他のメディアから利用するときも既にある機能を使うだけでなので再利用性が高い
  • 計測プラットフォームごとに機能を実装することで単体性が保たれ、GigaAppsとして計測プラットフォームの機能を複数もっていても個別のメディアで必要のない計測プラットフォームの混入を防ぐことができる

メディアによって異なるパラメータ

メディアによってイベントのパラメータが異なると分かるには、メディアが要求する計測プラットフォームやイベントのパラメータを事前に知っていないといけません。つまりこれを実現した機能にはメディア固有の事情が含まれてしまうことになります。この事情はメディアによって完全に異なるものであり、他メディアの事情を知る必要はないので、それぞれのメディア実装にイベントを定義するようにします。これによってメディアによって異なるパラメータも表現できるようになります。

イベントを適切に振り分けて送信する

この処理は、どのメディアにおいても自身が送信したいイベントを振り分ける(必要なら送信する)という共通の処理であることが分かります。つまりこの部分についてはメディアによらないGigaApps共通の機能とするとよいでしょう。しかし、実際にイベントを振り分けるには各メディアの事情を知っておかなければいけないため、メディア固有の機能として実装したいという問題があります。
この問題を解決するためにイベントを送信するという機能として抽象化されたインターフェースを定義し、その実態は各メディアに任せるという方法を取ることにします。

抽象化されたイベント送信機能

こうすることでメディアによって切り替わる計測プラットフォームやメディア実装であるイベント定義を元にイベントの振り分けを行えるようになります。 また、呼び出し側がメディアを意識しないでイベントを送信できるという利点が生まれます。

ということで、「メディアによって計測プラットフォームとパラメータが異なるイベントを適切に振り分けて送信する」機能の実現方法を考えることができました。最後に目指す具体的な実装を図に起こしたものが以下です。

インターフェースのメディア実装

次パートではこの設計を元にどのように実装したか、実際の実装を例に紹介していきます。

実装

基本的な設計はiOS・Androidで共通となっていますが、今回はAndroid(Kotlin)の実装を例に紹介します。Inside GigaViewer for Apps第2回・第3回で説明した内容を含むので、あわせて見ていただけるとより理解が深まります。

イベントを送信する

まず全体像を掴みやすくするために前パートの「イベントを適切に振り分けて送信する」で話した、イベントを送信する機能のインターフェースについて話していきます。
インターフェースは以下のように、EventIdentifierを受け取ってイベントを送信するEventTrackerとして定義されています。

// in common-analytics module     
interface EventTracker {
    fun track(identifier: EventIdentifier)
}

図にするとこんな感じ

EventIdentifierは送信するイベントの識別子として使うもので、例えばビューワが開いたときにイベントを送りたいメディアがある場合はEventIdentifierを実装したViewerOpenを定義します。

// in common-analytics module
interface EventIdentifier {
    object ViewerOpen : EventIdentifier
}

イベントは同一タイミングで複数の計測プラットフォームへ送信されることがあり、さらにメディアの数だけその組み合わせが増加します。これら全てを呼び出し元で取り扱うのはとても厳しいので、イベントが送信されるタイミングとして抽象化されたものがEventIdentifierです。
EventIdentifierの参照元はイベントが送信されることを期待しています。しかし、EventIdentifierは送信タイミングを表す識別子のため、どのメディアでどの計測プラットフォームへイベントが送信されるかを参照元から知ることができません。

イベントの送信は送信したいタイミングでEventTracker.track()を呼び出すだけです。

// in feature module
class ViewerViewModel @Inject constructor(
    // メディアAアプリの場合、MediaAモジュールからEventTrackerの実装がDIされる
    private val eventTracker: EventTracker
) {
    fun load() {
        // 呼び出し元はどの計測プラットフォームへイベントが送信されるか知らない
        eventTracker.track(EventIdentifier.ViewerOpen)
    }
}

このように呼び出し元はインターフェースしか知らないので、各メディアの事情を意識することなくイベントを送信することができます。また、ViewreOpenのタイミングで送信したい計測プラットフォームが複数ある場合も1度の呼び出しで全ての計測プラットフォームへ送信されます。

メディアによって異なる計測プラットフォームを扱う

メディアによって異なる計測プラットフォームを扱うには始めに、計測プラットフォームへイベントを送信する機能として抽象化します。送信されるイベントの形式は計測プラットフォームによって異なりますが、イベント名とその他パラメータのよくある形でインターフェースに定義します

// in common-module
interface Event {
    val name: String
    val params: Map<String, Any?>
}

interface AnalyticsPlatform {
    fun track(event: Event)
    fun isValidEvent(event: Event): Boolean
}

この抽象化された機能には特定の計測プラットフォームの情報が含まれていないため、イベント送信のための共通機能として定義しています。
そしてこのインターフェースの実装に具体的な計測プラットフォームへイベントを送信する処理を書いていきます。

// in feature-x-platform module
interface XPlatformEvent: Event

class PlatformX(val platformX: PlatformX) : AnalyticsPlatform {
    override fun track(event: Event) {
        platformX.send(event.name) // 計測プラットフォーム固有の送信処理
    }
    
    override fun isValidEvent(event: Event): Boolean = event is XPlatformEvent
}

これで計測プラットフォームへイベントを送信する機能を用意することができました。GigaAppsはメディアが欲しい機能に依存することでアプリを構成する仕組みになっているので、この実装へ依存することでそれぞれの計測プラットフォームへイベントを送信できるようになります。

図でいうと赤丸部分

メディアによって異なるパラメータを扱う

「メディアによって異なるパラメータを扱う」ではEventIdentifierを参照するだけで各メディアが持つ複数のイベントを取り扱えるようにするのを目指します。

まずはEventIdentifierから参照されて送信されるイベントの実態を定義していきます。iOS/AndroidのOS間でイベント追加の足並みを揃えたい、そしてOS間でOS固有の機能を除いた差異が発生するのを防ぎたいという2つの理由からyamlでイベントを定義しています。
yamlはメディア×計測プラットフォームごとに分けて定義していて、同一タイミングでパラメータが異なるイベントの例が以下になります。

# media: A
# メディアAでは、2つの計測プラットフォームへビューワ起動時のイベントを送信する
# 同じイベントでも計測プラットフォームによってパラメータが異なる
platform: x_platform
events: 
  - name: viewer_open
    params:
      - name: id
        type: String 

platform: y_platform
events:
  - name: viewer_start
    params:
      - name: title
        type: String

# media: B
# メディアAと同じ計測プラットフォームに異なるパラメータを持つイベントを送信する
platform: x_platform
events: 
  - name: viewer_open
    params:
      - name: id
        type: String 
      - name: point
        type: Int

そしてこのyamlに定義されたイベントは各OSの実装に合わせたコードがそれぞれ自動生成されるようになっています。

// in MediaA module
object XPlatform {
    data class ViewerOpen(
        private val id: String,
    ) : XPlatformEvent {
        override val name: String = "viewer_open"
        override val params: Map<String, Any?> = mapOf(
            "id" to id,
        )
    }
}

object YPlatform {
    data class ViewerStart(
        private val title: String,
    ): YPlatformEvent {
        override val name: String = "viewer_start"
        override val params: Map<String, Any?> = mapOf(
            "title" to title,
        )
    }
}

// in MediaB module
object XPlatform {
    data class ViewerOpen(
        private val id: String,
        private val point: Int
    ) : XPlatformEvent {
        override val name: String = "viewer_open"
        override val params: Map<String, Any?> = mapOf(
            "id" to id,
            "point" to point,
        )
    }
}

最後に生成されたイベントをメディアごとに同一タイミングでまとめたEventProxyEventIdentifierと一対一で紐づけるようにします。こうすることでイベント送信箇所ではEventIdentifierのみを参照して同一タイミングでパラメータが異なるイベントを取り扱えるようになります。

// in common-analytics module
interface EventIdentifier {
    data class ViewerOpen(
        val params: Parameters,
    ) : EventIdentifier {
        data class Parameters(
            val id: String,
            val title: String,
            val point: Int,
        )
    }
}

interface EventProxy {
    val events: List<Event>
}

// in MediaA module
object MediaAEventProxy {
    data class ViewerOpen(
        private val params: EventIdentifier.ViewerOpen.Parameters,
    ) : EventProxy {
        override val events: List<Event> = listOf(
            XPlatform.ViewerOpen(id = params.id),
            YPlatform.ViewerStart(title = params.title),
        )
    }
}

// in MediaB module
object MediaBEventProxy {
    data class ViewerOpen(
        private val params: EventIdentifier.ViewerOpen.Parameters,
    ) : EventProxy {
        override val events: List<Event> = listOf(
            XPlatform.ViewerOpen(id = params.id, point = params.point),
        )
    }
}

EventIdentifierからそれに紐づいたEventProxyへと変換したいので、Eventを作るのに必要な全てのパラメータをEventProxyEventIdentifierの両方で持つように定義しています。

図でいうと赤丸部分

イベントを適切に振り分けて送信する

まずEventIdentifierを受け取って実際に送信するイベントへ変換しなければなりません。実際に送信されるイベントの変換は「メディアによって異なるパラメータを扱う」でメディアごとに実装しているため、それに合わせて変換する処理もメディアモジュールで実装するようにします。
EventIdentifierからイベントへの変換はどのメディアでも共通の処理となるので、インターフェースのデフォルト実装から呼び出すことができます。

// in common-analytics module
abstract class EventTracker {
    fun track(identifier: EventIdentifier) {
        val eventProxy = buildEventProxy(identifier)
    }
     
    protected abstract fun buildEventProxy(identifier: EventIdentifier): EventProxy
}

// in MediaA module
class MediaAEventTracker : EventTracker() {
    override fun buildEventProxy(identifier: EventIdentifier): EventProxy {
        return when (identifier) {
            is EventIdentifier.ViewerOpen -> {
                // XPlatform.ViewerOpenとYPlatform.ViewerStartを持つEventProxy
                MediaAEventProxy.ViewerOpen(identifier.params)
            }
        }
    }
}

// in MediaB module
class MediaBEventTracker : EventTracker() {
    override fun buildEventProxy(identifier: EventIdentifier): EventProxy {
        return when (identifier) {
            is EventIdentifier.ViewerOpen -> {
                // XPlatform.ViewerOpenを持つEventProxy
                MediaBEventProxy.ViewerOpen(identifier.params)
            }
        }
    }
}

図でいうと赤丸部分

次にEventTrackerがやることとしてはアプリから計測プラットフォームへのイベント送信があります。しかし既に「メディアによって異なる計測プラットフォームを扱う」で実装しているので、イベントを「メディアによって異なる計測プラットフォームを扱う」へ振り分けて送信処理を移譲する処理しかありません。
そして「メディアによって異なる計測プラットフォームを扱う」は送信先となる計測プラットフォームを意識せずに送信できるように抽象化しているため、どのメディアでも行う共通の機能としてデフォルト実装から呼び出すことができます。

// in common-analytics module
abstract class EventTracker {
    protected abstract val platforms: Set<AnalyticsPlatform>
    
    fun track(identifier: EventIdentifier) {
        val eventProxy = buildEventProxy(identifier)
        eventProxy.events.forEach { event ->
            platforms.firstOrNull { platforms -> platform.isValidEvent(event) }?.track(event)
        }
    }
    
    protected abstract fun buildEventProxy(identifier: EventIdentifier): EventProxy
}

// in MediaA module
class MediaAEventTracker(
    // MediaAが依存しているfeaturemモジュールからDIされる
    override val platforms: Set<AnalyticsPlatform>
): EventTracker() {
    override fun buildEventProxy(identifier: EventIdentifier): EventProxy {
        return when (identifier) {
            is EventIdentifier.ViewerOpen -> {
                MediaAEventProxy.ViewerOpen(identifier.params)
            }
        }
    }
}

図でいうと赤丸部分

終わりに

いかがだったでしょうか。ここまでGigaAppsにおけるイベント送信機能をどのように設計・実装したかを紹介していきました。
マルチテナント開発では従来のアプリ開発では起き得ない課題に直面します。たとえば、あるテナントの機能開発が、別のテナントの機能に影響を与えないための工夫が必要です。難易度は高いですが、技術的チャレンジも多いことがGigaViewer開発の魅力です。連載企画『Inside GigaViewer for Apps』では、アーキテクチャやしくみ、多様なテーマで過去取り組んできた技術的チャレンジを紹介していきます。