Аналитика в декларативном стиле с поддержкой многомодульности

от автора

С приходом Compose в голове всё чаще всплывают мысли, что же ещё можно написать в декларативном стиле. И как мне кажется, на эту роль хорошо подходит аналитика, точнее, описание её событий. В среде разработчиков тема аналитики нечасто всплывает в обсуждениях. Отправка аналитики не является целью фичи, а лишь обязательной дополнительной частью. Сама отправка — задача довольно тривиальная и поэтому воспринимается обычно как «обязаловка», а не как интересный процесс, в котором можно блеснуть своими знаниями архитектуры. Тем не менее, если сложить время всех задач по отправке аналитики, то получится довольно приличное количество часов. Поэтому хочется, чтобы работа с этой частью была максимально комфортной. В этом и поможет декларативный подход.

Вообще, декларативный подход к событиям аналитики у нас появился задолго до Compose, и рассказать о нём мы хотели достаточно давно. Но раз уж декларативный подход набирает популярность, то, кажется, время пришло.

Вводные

Представим, что в приложении подключены две аналитики. Такое часто бывает, так как сила конкретного аналитического сервиса в его админке. У всех разные графики, сводки, по-разному резолвятся данные, разные возможности работы с ними. Поэтому аналитики просят интегрировать несколько сервисов, чтобы иметь больше гибкости при работе с данными. 

В нашем случае одна — что-то из популярного, вроде Google Analytics или Yandex Metrika. Вторая — самописная аналитика внутри компании, которая предоставляет данные для отдела машинного обучения, например, чтобы более качественно и быстро выдать пользователю рекомендации. События одни и те же, но в разные аналитики уходит разный набор параметров.

Когда-то давно у нас всё было построено на константах и выглядело примерно так:

class OldAnalytics(    private val currentTimeProvider: CurrentTimeProvider,    private val analytics1: Analytics1,    private val analytics2: Analytics2 ) {     fun sendEvent(userId: Int, itemsIds: List<Int>) {        val analytics1Params = mutableMapOf<String, String>()        analytics1Params[FEATURE] = SOME_FEATURE        analytics1Params[SCREEN] = SOME_SCREEN        analytics1Params[BLOCK] = SOME_BLOCK        analytics1Params[ACTION] = CLICK        analytics1Params[REASON] = SOME_REASON        analytics1Params[USER_ID] = userId.toString()        analytics1Params[ITEM_IDS] = itemsIds.joinToString()        val time = currentTimeProvider.getCurrentTime().toString()        analytics1Params[CURRENT_TIME] = time        analytics1.send("SomeEventName", params = analytics1Params)         val analytics2Params = mutableMapOf<String, String>()        analytics1Params[SCREEN] = SOME_SCREEN        analytics2Params[SOURCE] = "SomeSource"        analytics2Params[ACTION] = CLICK        analytics2.send("SomeEventName", params = analytics2Params)    }     companion object {         const val FEATURE = "feature"        const val SCREEN = "screen"        const val BLOCK = "block"        const val ACTION = "action"        const val REASON = "reason"        const val SOURCE = "source"        const val SOME_FEATURE = "SomeFeature"        const val SOME_SCREEN = "SomeScreen"        const val SOME_BLOCK = "SomeBlock"        const val SOME_REASON = "SomeReason"        const val CLICK = "Click"        const val USER_ID = "userId"        const val ITEM_IDS = "itemIds"        const val CURRENT_TIME = "currentTime"    } }

Я специально привёл такой большой кусок кода, чтобы вы могли ощутить всю монструозность такого подхода. А ведь это только одно событие. Помимо количества кода, в таком подходе есть ещё целый ворох проблем:

  • Целое полотно из констант, в которых уже через 5-10 событий можно ориентироваться только через поиск. По всему приложению событий аналитики сотни (возможно и за тысячу перевалило, но это не точно). Всё это счастье в одном очень-очень большом файле. 

  • Для многих событий нужно «обогащение» данными, когда в событие нужно добавить информацию, которой сейчас нет на экране. Из-за этого приходилось в Presenter/ViewModel добавлять зависимости, которые нужны исключительно для отправки событий в аналитику. 

  • В этих константах легко запутаться. Часто получалось, что какая-то константа использовалась, например, в 15 событиях. В новых требованиях аналитики её содержимое нужно поменять, но только в 14 событиях. Хорошо, если разработчик это заметил. Но часто бывало, что разработчик не замечал этого, и в одном из событий аналитики отправлялось неверное значение или ключ. 

  • Константы типа String, в целом не очень удобное решение. Иногда константы, предназначенные для конкретного ключа, например, «Screen» использовались с другими ключами, например, «Block». При этом с точки зрения кода и Lint всё было хорошо. Опять же, при изменении значения константы иногда ломалось значение у параметра с совершенно другим ключом.

  • Сборка события происходит в том же потоке, в каком был вызван метод отправки. Это почти всегда был main. В итоге отправка аналитики била по скорости работы приложения.

  • У нас многомодульное приложение и все модули должны были подключать общий модуль, содержащий файл со всей аналитикой. К тому же модуль с аналитикой косвенно знает о всех фичёвых модулях, так как в константах находится информация о них. Это не очень хорошо с точки зрения полноценного деления на модули.

Можно было потихоньку избавиться от каждой из проблем, но мы решили поступить более радикально.

Требования

Мы решили попробовать сделать события аналитики с помощью декларативного подхода. Чтобы больше не возится с константами. Ну, почти… 

Мы выработали следующие требования к нашей будущей аналитике:

  • Простота. Хочется, чтобы писать аналитику было довольно просто, без лишних заморочек.

  • Читаемость. Хочется, чтобы когда ты смотришь аналитику, которую делал другой разработчик, например, на Pull Request’е, то всё было понятно.

  • Производительность. Хочется, чтобы отправка аналитики никак не влияла на общую производительность приложения, так как события аналитики отправляются в приложении постоянно, и часто приходится для создания события делать какие-либо операции.

  • Обогащение. Не всегда на экране, с которого должны уходить события аналитики, есть все данные, необходимые для конкретного события. Не хочется портить логику, связанную с экраном, то есть код Presenter/ViewModel, какой-либо логикой, связанной с аналитикой.

  • Многомодульность — хорошая работа с многомодульностью. Чтобы в общих модулях не было значений, относящихся к конкретным модулям.

Создание общей модели

Но… Стоит заметить, что переработка аналитики не очень-то и возможна без самих аналитиков. Поэтому перво-наперво стоит договориться об основных параметрах, которые отправляются в большинстве событий. 

В итоге родились два data-класса, описывающие события аналитики. По одному на каждую аналитику.

data class Analytics1Event(    val eventName: String,    val feature: String?,    val screen: String?,    val block: String?,    val action: String?,    val reason: String?,    val additionalParams: Map<String, Any?>? )  data class Analytics2Event(    val eventName: String,    val screen: String?,    val source: String?,    val action: String?, )

Собственно, в обоих моделях обязательным является только eventName. Остальные могут отсутствовать в некоторых событиях. Также в модели для первой аналитики присутствует property под названием additionalParams. Туда предполагается складывать параметры, которые ситуативны для события. 

Вместо констант в ключах у нас теперь property в модели. Теперь настало время что-то придумать и с константами в значениях.

Типы параметров

Напомню, что одна из проблем состояла в том, что они не были типизированы и попадали не в тот ключ. Такую проблему обычно решают с помощью enum, ведь значение одного enum уже не подсунуть вместо значения совершенно другого enum. Мы же решили эту проблему с помощью интерфейса… И ещё класса… И ещё множества enum.

Начнём с интерфейса. Вместо просто String у нас появляются следующие типы-интерфейсы: AnalyticsAction, AnalyticsScreen, AnalyticsFeature, AnalyticsBlock. Каждый из которых относится к своему параметру аналитики. Рассмотрим только AnalyticsAction. Ибо он первый под руку попался. Остальные устроены похожим образом.

interface AnalyticsAction : Parcelable {     val key: String }

По сути, он просто содержит в себе одно property типа String. Задача этого интерфейса — просто не давать типам параметров аналитики путаться между собой. Вместо AnalyticsAction уже не подсунуть, например, AnalyticsScreen. Проще говоря, не дать разработчику засунуть параметр не того типа.

Сами же значения хранятся в отдельных фичёвых enum.

@Parcelize enum class CommonAnalyticsAction(    override val key: String ): AnalyticsAction {     CLICK("Click"),    LONG_CLICK("LongClick"),    OPEN("Open"),    CLOSE("Close"), }  @Parcelize internal enum class Feature1AnalyticsAction(    override val key: String ): AnalyticsAction {     RECEIVE("Receive"), }

«Почему бы не сделать просто enum? Зачем их несколько, да ещё и interface?». Всё дело в многомодульности. В нашем проекте базовые классы аналитики находятся в базовом модуле base-analytics, который подключается к фичёвым модулям.

Так как это две абсолютно разные, не связанные друг с другом фичи, то они не должны знать лишнего друг о друге. Общий enum косвенно разглашал бы информацию о них (вспоминаем проблему конвергенции).

В подходе с «interface с несколькими enum» в модуле base-analytics содержатся только общий interface AnalyticsAction и его наследник — общий enum CommonAnalyticsAction, в котором содержатся реально общие значения, не относящиеся прямо ни к одной из фичей. Если же фича должна иметь собственные значения, то в фичёвом модуле содержится её собственный enum, в котором хранятся относящиеся только к ней значения. Кстати, не лишним будет пометить этот enum в Kotlin как internal.

Но что, если значение будет уникальным не только для фичи, но и для события. Если оно используется только в одном единственном событии и больше нигде, то для этого предусмотрен класс CustomAnalyticsAction:

@Parcelize data class CustomAnalyticsAction(    override val key: String ) : AnalyticsAction

В нужном месте будет достаточно прописать:

CustomAnalyticsAction("customAction")

И у вас появилось уникальное значение. Это также актуально, если значение создаётся на основе runtime данных приложения.

CustomAnalyticsAction("customAction$actionType")

Общая модель и типы у нас есть. Теперь надо как-то заполнить эту модель с помощью декларативного подхода.

Решение выглядит не очень эффективным с точки зрения потребления памяти. Либо используются значения enum, либо создаются объекты. В целом так и есть, с использованием строк было бы эффективнее. Но в этом конкретном месте было решено немного сжать рамки для разработчиков не позволяя добавлять значения одного типа в другой тип. Тем самым сделать код немного безопаснее за счёт того, что перепутать типы уже никак нельзя. Да и важность оптимизаций именно в этом месте не очень велика. Относительно современных объёмов оперативной памяти такое потребление ничего особо не поменяет.

Декларативность

В общем смысле декларативный подход в Kotlin основан на Type-safe builders, позволяющих нам делать собственные DSL. Правда, сами разработчики Kotlin называют это полудекларативным подходом («semi-declarative way»). Но нам не очень важно, кто, что и как называет. Главное, что общий подход выглядит следующим образом.

class SemiDeclarativeWay {     fun code() = html {    } }

Под капотом html представляет из себя обычную функцию, которая в качестве аргумента принимает лямбду c receiver. В роли receiver выступает какой-либо объект.

fun html(init: HTML.() -> Unit): HTML {     val html = HTML()     html.init()     return html }

Использование лямбды с receiver позволит нам внутри этой лямбды обратиться к receiver (HTML) как к this. За счёт этого у нас выстраивается вложенность объектов, и нам нет необходимости обращаться к экземпляру HTML через переменную. 

Дело за малым. Осталось переложить эти знания на нашу аналитику. Создаём интерфейс EventBuilder, наследники которого будут описывать конкретные события аналитики.

interface EventBuilder {     fun buildEvent(): AnalyticsEvent     fun event(init: AnalyticsEvent.() -> Unit): AnalyticsEvent {        val analyticsEvent = AnalyticsEvent()        analyticsEvent.init()        return analyticsEvent    } }

В нём всего два метода:

  • buildEvent создаёт наше событие аналитики (AnalyticsEvent);

  • event позволит заполнить содержимое AnalyticsEvent в декларативном стиле.

На данной стадии событие выглядит как-то так:

internal class DeclarativeEventBuilder: EventBuilder {     override fun buildEvent() = event {    } }

То же самое теперь надо провернуть с самим AnalyticsEvent. Чтобы была возможность заполнить и его поля в декларативном стиле.

class AnalyticsEvent {     private var analytics1Event: Analytics1EventDeclarative? = null    private var analytics2Event: Analytics2EventDeclarative? = null     fun analytics1(        init: Analytics1EventDeclarative.() -> Unit    ): Analytics1EventDeclarative {        val eventDeclarative = Analytics1EventDeclarative()        eventDeclarative.init()        analytics1Event = eventDeclarative        return eventDeclarative    }     fun analytics2(        init: Analytics2EventDeclarative.() -> Unit    ): Analytics2EventDeclarative {        val eventDeclarative = Analytics2EventDeclarative()        eventDeclarative.init()        analytics2Event = eventDeclarative        return eventDeclarative    } }

В нём у нас будет два метода: для первой аналитики — analytics1 и для второй — analytics2. Работает каждый из них со своим объектом, так как в разные аналитики у нас уходит свой набор данных.

Представлять аналитики будут два класса: Analytics1EventDeclarative и Analytics2EventDeclarative. В них, по сути, провернем тоже самое — сделаем заполнение полей через декларативный подход. 

Весь их код я приводить не буду. При желании можете посмотреть его под спойлером.

Analytics1EventDeclarative
class Analytics1EventDeclarative {     var eventName: String? = null    var action: AnalyticsAction? = null    var reason: String? = null    private var source: EventSourceDeclarative? = null    private var additional: EventAdditionalDeclarative? = null     fun source(        block: EventSourceDeclarative.() -> Unit    ): EventSourceDeclarative {        val eventDeclarative = EventSourceDeclarative()        block(eventDeclarative)        source = eventDeclarative        return eventDeclarative    }     fun additional(        block: EventAdditionalDeclarative.() -> Unit    ): EventAdditionalDeclarative {        val additionalDeclarative = EventAdditionalDeclarative()        additionalDeclarative.block()        additional = additionalDeclarative        return additionalDeclarative    }     fun customAction(customAction: String): AnalyticsAction {        return CustomAnalyticsAction(customAction)    }     fun build(): Analytics1Event {        return Analytics1Event(            eventName = this.eventName!!,            feature = this.source?.feature?.key,            screen = this.source?.screen?.key,            block = this.source?.block?.key,            action = this.action?.key,            reason = this.reason,            additionalParams = this.additional?.params        )    }     class EventSourceDeclarative {         var feature: AnalyticsFeature? = null        var screen: AnalyticsScreen? = null        var block: AnalyticsBlock? = null         fun customFeature(customFeature: String): AnalyticsFeature {            return CustomAnalyticsFeature(customFeature)        }         fun customScreen(customScreen: String): AnalyticsScreen {            return CustomAnalyticsScreen(customScreen)        }         fun customBlock(customBlock: String): AnalyticsBlock {            return CustomAnalyticsBlock(customBlock)        }    }     class EventAdditionalDeclarative {         var params: Map<String, Any?>? = null         fun <T> fromPair(vararg items: Pair<String, T?>): Map<String, T?> {            return mapOf(*items)        }    } }  

Analytics2EventDeclarative
class Analytics2EventDeclarative {     var eventName: String? = null    var screen: String? = null    var source: String? = null    var action: AnalyticsAction? = null     fun customAction(customAction: String): AnalyticsAction {        return CustomAnalyticsAction(customAction)    }     fun build(): Analytics2Event {        return Analytics2Event(            eventName = this.eventName!!,            screen = this.screen,            source = this.source,            action = this.action?.key,        )    } }

Взглянем же, наконец, на итоговый пример заполнения события аналитики в декларативном стиле. Для примера создадим класс SomeEventBuilder, который описывает отправку какого-то события на каком-то экране.

internal class SomeEventBuilder(    private val userId: Int,    private val itemsIds: List<Int>,    private val timestampProvider: () -> Long ) : EventBuilder {     override fun buildEvent() = event {        analytics1 {            eventName = "SomeEventName"            reason = "SomeReason"            action = CommonAnalyticsAction.CLICK            source {                feature = Feature1AnalyticsFeature.FEATURE_1                screen = Feature1AnalyticsScreen.FEATURE_1_SCREEN                block = customBlock("SomeBlock")            }        }        analytics2 {            eventName = "SomeName"            source = "SomeBlockOfUser$userId"            action = customAction("Click")            screen = Feature1AnalyticsScreen.FEATURE_1_SCREEN        }    } }

Как по мне, неплохо. Мне всё понятно, но это ощущение субъективно. Вы можете поиграться с вложенностью, пока не получите результат, который нравится лично вам. 

В этом моменте вспомним про additionalParams, в который кладутся специфичные для конкретного события параметры. Чтобы это выглядело максимально красиво, стоит принимать их в виде Pair, а чтобы можно было передать их несколько, воспользуемся vararg.

additional {    params = fromPair(        "userId" to userId,        "itemIds" to itemsIds.joinToString(),        "currentTime" to timestampProvider()    ) }

Сам класс SomeEventBuilder лежит в фичёвом модуле. Таким образом, вся аналитика распределяется по модулям.

Ещё один момент: вы можете обратить внимание, что параметр eventName не является обязательным в декларативном методе, но обязателен в модели для аналитики. Всё из-за того, что в Kotlin DSL пока нет возможности помечать поля как required. Я тут вижу три варианта: 

  • Подождать, пока создатели Kotlin допилят Kotlin Contracts. Они обещали.

  • Воспользоваться сторонним решением.  

  • Обязательные параметры передавать как параметры метода, как это происходит в Compose. Хотя мне это не очень нравится с эстетической точки зрения. Вот смотрите:

internal class DeclarativeEventBuilder : EventBuilder {     override fun buildEvent() = event {        analytics1(            eventName = "SomeEventName"        ) {            action = CommonAnalyticsAction.CLICK        }    } }

Можно заметить, что из объявленных в начале требований декларативный подход к событиям закрывает три из них: простота, понятность, многомодульность. Остались ещё два: обогащение и производительность. Начнём с первого.

Обогащение

Допустим, для нашего события нужно дополнительно послать какую-то информацию, например, о текущем времени. Я знаю, что System.currentTimeMillis() статический, и ситуация не очень реалистичная, но это просто для примера. На его месте может быть что угодно — от информации о пользователе до списка предыдущих экранов. При этом на самом экране и в Presenter/ViewModel, из которого и будет отправляться событие, текущее время не нужно.

Если пытаться отправлять события напрямую из Presenter, то придётся добавлять в его конструктор сущность, которая предоставит информацию для аналитики. Затем перед отправкой события доставать из него текущее время. Пусть этой сущностью будет CurrentTimeProvider:

class SomePresenter @Inject constructor(    private val featureAnalytics: FeatureAnalytics,    private val timeProvider: CurrentTimeProvider ) {     fun onSomeClick() {        val currentTime = timeProvider.getTime()        featureAnalytics.onSomeClick(currentTime)    } }

Весь этот код относится исключительно к аналитике, и не очень хочется, чтобы он захламлял Presenter. Ведь с одного экрана могут уходить десятки событий или для обогащения понадобится больше объектов. Также это будет сильно мешать, если такое событие отправляется с нескольких Presenter, и придётся писать подобный код в каждом из них. В итоге у нас усложняется класс логики экрана лишь из-за отправки аналитики. 

Чтобы этого избежать, мы создали класс-прослойку <Feature>Analytics. Который и служит цели обогащения данных для аналитики и инкапсуляции аналитики в целом.

В примере с текущим временем мы в конструктор новоявленного класса FeatureAnalytics добавляем CurrentTimeProvider и будем отдавать текущее время в обратном вызове SomeEventBuilder.

class FeatureAnalytics(    private val analyticsSender: AnalyticsSender,    private val timeProvider: CurrentTimeProvider ) {     fun sendEvent(userId: Int, itemsIds: List<Int>) {        val builder = SomeEventBuilder(            userId = userId,            itemsIds = itemsIds,            timestampProvider = timeProvider::getCurrentTime        )        analyticsSender.send(builder)    } }

А зачем здесь пробрасывается метод timeProvider::getCurrentTime? Почему бы не вызвать его перед созданием SomeEventBuilder и не передать значение сразу в конструктор? 

Во-первых, нужно, чтобы вызов происходил во время создания AnalyticsEvent, а не в тот момент, когда создаётся его Builder.

Во-вторых, это ведь может быть не простое получение константы. Возможна более сложная операция, что может ударить по производительности. Ведь вызов создания Builder происходит в потоке в котором вызвали sendEvent, а это скорей всего main.

Также лучше не позволять SomeEventBuilder обращаться к зависимостям напрямую. Например, когда SomeEventBuilder обращается CurrentTimeProvider за текущим временем.

internal class SomeEventBuilder(    private val userId: Int,    private val itemsIds: List<Int>,    private val timeProvider: CurrentTimeProvider ) : EventBuilder { ... }

Пусть он делает это через лямбду в конструкторе.

internal class SomeEventBuilder(    private val userId: Int,    private val itemsIds: List<Int>,    private val timestampProvider: () -> Long ) : EventBuilder { ... }

Это нужно, чтобы класс был более чистым от внешних зависимостей. Так его будет проще переиспользовать в случае чего.

Но вернёмся к прослойкам <Feature>Analytics. С помощью такой прослойки Presenter не засоряется логикой, связанной с аналитикой. В целом использование прослойки — дело дискуссионное, ведь не всегда нужно обогащать события. Тут всё зависит от вашей аналитики. Если события часто обогащаются дополнительными данными, то стоит задуматься. Если же нет, то и от такой прослойки смысла тоже нет.

<Feature>Analytics классы также хранятся в feature модулях. В итоге вся аналитика, связанная с фичёй, остаётся в фичёвом модуле.

Займёмся последним требованием: производительность.

Производительность

Подробно расписывать эту часть я не буду, так как у всех свой набор аналитик и фреймоворк для работы с многопоточкой. Поэтому опишу лишь в общих чертах.

По сути вся работа над производительностью сводится к тому, чтобы вызвать декларативный метод в отдельном потоке. Так как объект аналитики, описанный в декларативном методе, создаётся только при его вызове, то само создание <Some>Builder класса является быстрой задачей, а более долгий метод создания объекта аналитики вызывается в выделенном для аналитики потоке. С помощью таких простых манипуляций мы освободим наш главный поток от аналитики.

По этой же причине стоит превращать данные из моделей для Presentation или бизнес-логики в данные для аналитики прямо внутри метода создания объекта аналитики. Это сэкономит драгоценное время вашего главного потока.

Переход на другой поток, вызов декларативного метода и отправка данных в аналитику происходят внутри AnalyticsSender.

AnalyticsSender переходит на отдельный поток, вызывает buildEvent, тем самым собирая событие и передаёт его в классы, каждый из которых отвечает за свою аналитику Analytics1Sender и Analytics2Sender. 

Внутри них происходит конвертация из объектов, которыми мы оперируем, в формат, понимаемый библиотекой аналитики.

На этом всё. Сомневаюсь, что такой подход подойдёт или понравится всем. Но его можно настраивать под себя. У нас, например, он зашёл на ура. 

Возможно, как задача отправка аналитики — действительно тривиальная, но реализуют все очень по-разному. Всё сильно зависит от требований аналитиков. Поэтому мне кажется, что различные варианты реализации аналитики это достаточно интересная тема, потому что ни у кого нет «красивого» решения для всех. Это всегда какие-то костыли с кучей допущений, заточенные под конкретную задачу. 

Надеюсь, кому-то из вас такой подход тоже пришелся по душе и в мире станет больше аналитики с использованием декларативного подхода.


ссылка на оригинал статьи https://habr.com/ru/company/cian/blog/672862/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *