Русский
Русский
English
Статистика
Реклама

Блог компании wheely

Android Redux amplt3

25.03.2021 18:04:40 | Автор: admin

Привет! Меня зовут Виталий Сулимов, я Android-разработчик в компании Wheely, и сегодня я бы хотел поговорить с вами об архитектуре мобильных приложений. А именно о том, как мы в компании применили Redux-архитектуру к двум нашим приложениям и что из этого вышло.

Дисклеймер #1

Я разрабатываю коммерческие Android-приложения с 2016-го года, начинал с классического в то время MVC, потом был MVP, библиотека Moxy от ребят из Arello Mobile, Clean Architecture и вот теперь Redux. Мое мнение идеальной и единственно правильной архитектуры не существует. Любая из них будет набором компромиссов, начиная от особенностей интеграции с самой платформой и заканчивая простотой, расширяемостью и возможностью написания тестов. То, что отлично подходит под наши приложения, может оказаться абсолютно непригодным для вашего проекта, и наоборот. Цель данной статьи показать еще один способ написания Android-приложений.

К чему мы стремимся?

Перед тем как начать разбирать Redux, давайте тезисно обозначим, что мы хотим получить в итоге:

1. Пассивные View

Задача View отображать интерфейс пользователю, в ней не должно быть бизнес-логики, она не должна принимать решения, всё, что она должна делать сообщать о происходящих с ней событиях и взаимодействиях (создание, уничтожение, изменился размер, нажали кнопку, потянули Pull To Refresh, и т.д.)

2. Отдельно живущая модель, бизнес-логика / механизмы принятия решений

Здесь всё тоже должно быть очень просто, но в то же время четко разграничено.

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

3. Возможность покрывать логику тестами

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

4. Консистентность подхода в масштабе всего приложения

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

Redux, я выбираю тебя!

Перед тем как говорить о Redux в контексте Android-приложения, давайте для начала разберемся, чем он является сам по себе, отдельно от Android.

В контексте данной статьи Redux рассматривается как библиотека для управления состоянием приложения. Основная идея заключается в том, что глобальное иммутабельное состояние содержится в глобальном компоненте Store, который среди прочего позволяет подписываться на это состояние, получать уведомления каждый раз, когда что-то меняется, а также отправлять ему события, когда что-то происходит. Это базовое описание, давайте теперь копнем чуть глубже и посмотрим, из каких компонентов состоит Redux.

State

Я не просто так вынес состояние на первое место, потому что считаю это основной и ключевой фишкой Redux. Состояние является иммутабельным (важно) объектом и единственным источником истины для всего приложения.

Store

Store содержит глобальное глобальное состояние (State) вашего приложения, а также все подключенные к нему Middleware и Reducer.

Типичное API позволяет вам получать текущее состояние, отправлять события (Action), подписываться и отписываться от изменений состояния.

Action

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

Reducer

Чистая функция, которая меняет текущее состояние в ответ на пришедшее событие (Action). Для тех, кто не знает, чистая функция всегда возвращает одинаковое значение при одинаковых входных данных (детерминированность) и не имеет побочных эффектов (никаким образом не изменяет локальные переменные, не осуществляет ввод, вывод, и т.д.).

Поскольку состояние у нас является иммутабельным, Reducer использует Copy-on-write подход, копируя состояние целиком с изменением только необходимой части.

Middleware

Middleware является своего рода промежуточным звеном, позволяя перехватывать события (Action) и заменять их в случае необходимости, до того, как они попадут в наш Reducer.

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

А как натянуть сову на глобус подружить Redux с Android?

Всё очень просто. Достаточно представить Android как источник событий (Action), не важно, что это, создание Activity, View, нажатие кнопки или BroadcastReceiver - просто отправьте Action об этом и обработайте его, как обычно (рассмотрим детальнее дальше).

Talk is cheap. Show me the code.

Теперь, когда мы имеем общее понимание того, что есть Redux и из чего он состоит, давайте попробуем написать приложение. Пусть это будет Counter, обычный счетчик, который можно увеличивать нажатием кнопки. Приложение должно переживать поворот экрана и обнулять значение счетчика при выходе из него. А выглядеть оно будет примерно так.

Что нам потребуется?

Чистый проект в Android Studio и реализация Redux для языка Kotlin.

Код библиотеки доступен в репозитории на GitLab.

Помимо реализации базовых компонентов Redux, я также добавил полезные утилиты для простой интеграции с Android. Итак, проект есть, библиотека подключена, давайте создавать наше приложение!

Дисклеймер #2

В процессе разбора вам может показаться, что в этой библиотеке очень нужен Rx, или Coroutines, или что-то ещё, о чем я даже не догадываюсь, но здесь этого нет. Библиотека создавалась по принципу KISS, она полностью открыта и вы можете без проблем изменить ее для соответствия вашим требованиям, главное не менять фундаментальную идею.

Описываем состояние

Помните, что я говорил о роли состояния в Redux? Простой иммутабельный объект, который содержит всю необходимую нам информацию и позволяет легко производить над ним операции по принципу Copy-on-write. Лучшее, что вы можете выбрать для этого подхода в Kotlin - data class.

ApplicationState.kt

data class ApplicationState( val counter: Int = 0)

Выглядит так же просто, как и звучит, едем дальше.

События

Все события в нашей реализации должны так или иначе реализовывать интерфейс Action из библиотеки Redux. Интерфейс Action является маркерным и не содержит никаких методов для реализации. Я стараюсь логически декомпозировать события для простоты работы с ними и обработки их в Middleware и Reducer, а также использовать sealed classы, последние ограничивают всех возможных наследников до узкого круга того, что нас непосредственно интересует. В итоге наши события будут выглядеть вот так.

CounterAction.kt

sealed class CounterAction : Action {   object Increment : CounterAction()     object Reset : CounterAction()}

Reducer

В нашем случае это объект, который реализует интерфейс Reducer<S>, где S - глобальное состояние нашего приложения, т.е. В нашем случае ApplicationState. Интерфейс описывает одну-единственную функцию - reduce. Не забываем про то, что функция должна быть чистой.

CounterReducer.kt

object CounterReducer : Reducer<ApplicationState> {   override fun reduce(action: Action, state: ApplicationState): ApplicationState = when (action) { is CounterAction.Increment -> state.copy(counter = state.counter.inc())                 is CounterAction.Reset -> state.copy(counter = 0)                 else -> state }}

Store

А теперь соберем все эти компоненты в единый механизм, и поможет нам в этом Store.

Библиотека уже содержит в себе абстрактный Store с реализацией всех необходимых методов. Всё что нам нужно сделать - создать наследника класса AbstractStore<S> и явно указать тип нашего глобального состояния. Это же состояние будет передаваться в наши Middleware и Reducer.

ApplicationStore.kt

class ApplicationStore( initialState: ApplicationState, middlewares: List<Middleware<ApplicationState>>, reducers: List<Reducer<ApplicationState>>) : AbstractStore<ApplicationState>(initialState, middlewares, reducers)

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

AppComponent.kt

object AppComponent {val store = ApplicationStore(initialState = ApplicationState(),middlewares = emptyList(),reducers = listOf(CounterReducer))}

Для чистоты кода я также советую создать подобные функции в любом удобном для вас файле, поскольку отправка событий, подписка и отписка будут часто использоваться во всем приложении.

ReduxFunctions.kt

fun dispatch(action: Action) = AppComponent.store.dispatch(action)    fun subscribe(subscription: Subscription<ApplicationState>) = AppComponent.store.subscribe(subscription)    fun unsubscribe(subscription: Subscription<ApplicationState>) = AppComponent.store.unsubscribe(subscription)

Подведем промежуточный итог

Сейчас у нас есть иммутабельное состояние, которое отражает значение нашего счетчика, есть события, которые позволяют взаимодействовать с этим значением, и reducer, который обрабатывает эти события, меняя состояние соответствующим образом. Несмотря на общую простоту конструкции, можно заметить весьма жесткие ограничения. Мы можем быть уверены в том, что состояние может измениться только в результате работы reducera, а тот в свою очередь сработает, только если в store будет отправлено соответствующее событие. Дело осталось за малым, UI!

Прикручиваем отображение

В данном случае в ход идет непосредственно часть Android-фреймворка. Тут может быть несколько подходов (Single Activity / Multiple Activities / Fragments?), я покажу один из них, который нравится мне больше всего - это связка одной Activity и чистых View. Activity является своего рода контроллером и может заменять текущую View, а те в свою очередь подписываются на состояние и непосредственно отображают интерфейс пользователю.

CounterView.kt

class CounterView( context: Context) : FrameLayout(context) {   private val counterSubscription = SubStateSubscription<ApplicationState, Int>( transform = { it.counter }, onStateChange = { state: Int, _: Boolean -> handleCounterStateChange(state) } )     private lateinit var counterTextView: TextView private lateinit var floatingActionButton: FloatingActionButton   init { inflate(context, R.layout.view_counter, this) findViewsById() setOnClickListeners() }     private fun findViewsById() { counterTextView = findViewById(R.id.counterTextView) floatingActionButton = findViewById(R.id.floatingActionButton) }     private fun setOnClickListeners() { floatingActionButton.setOnClickListener { dispatch(CounterAction.Increment) } }     override fun onAttachedToWindow() { super.onAttachedToWindow() subscribeToStateChanges() }     private fun subscribeToStateChanges() { subscribe(counterSubscription) }     override fun onDetachedFromWindow() { unsubscribeFromStateChanges() super.onDetachedFromWindow() }     private fun unsubscribeFromStateChanges() { unsubscribe(counterSubscription) }     private fun handleCounterStateChange(state: Int) { counterTextView.text = state.toString() }}

Давайте внимательно рассмотрим этот класс и разберем, что происходит. Для начала создадим SubStateSubscription, это вспомогательный класс, который позволяет подписываться не на состояние целиком, а на какую-то из его частей, опять же, если вы захотите Rx, то эту заботу на себя возьмет map(), в нашем случае - руками.

Дальше ничего необычного, объявляем lateinit var для всех виджетов внутри данной View.

Конструктор. Надуваем XML-разметку, находим в ней наши виджеты и вешаем обработчик нажатия на Floating Action Button. Внутри обработчика делаем dispatch события CounterAction.Increment, которое мы создали ранее.

OnViewAttached / Detached from window.

Здесь подписываемся и отписываемся соответственно на наше значение и пишем обработчик, который выполнится, когда произойдет изменение этой части состояния. В нашем случае всё очень тривиально, просто устанавливаем пришедшее значение в TextView.

CounterView.kt

...counterTextView.text = state.toString()...

И мы на финишной прямой! Почти.

Если запустить приложение сейчас, то оно будет работать, View будет отображать значение счетчика, которое соответствует переменной counter внутри ApplicationState, поворот экрана никак не ломает наше приложение, это решается by design, ведь состояние нашего счётчика живёт на уровне Application и жизненный цикл View на него никак не влияет, но Помните, я говорил вам, что счетчик надо сбрасывать, когда мы выходим из приложения (нажимаем клавишу Назад). Как это сделать? Давайте разбираться.

Еще раз вспоминаем о том, как подружить Redux и Android

Выше я писал о том, что нам нужно представить Android как источник событий в архитектуре Redux. Давайте сделаем это. В библиотеке также есть уже готовый класс AppCompatActivity, который является самой обычной AppCompatActivity с одним маленьким бонусом: эта Activity отправляет события ActivityLifecycleAction (тоже являются частью библиотеки) на каждое событие жизненного цикла. Всё что вам нужно сделать для интеграции - это создать наследника данной AppCompatActivity и предоставить ему Store, в который она и будет отправлять события. Итоговый код выглядит так.

MainActivity.kt

class MainActivity : AppCompatActivity<ApplicationState>() {   private lateinit var contentViewGroup: ViewGroup   override fun getStore(): Store<ApplicationState> = AppComponent.store   override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewsById() addCounterView() }     private fun findViewsById() { contentViewGroup = findViewById(R.id.contentViewGroup) }     private fun addCounterView() { contentViewGroup.addView(CounterView(context = this)) }}

Наша первая Middleware и принятие решения

У нас есть всё необходимое для написания финального элемента нашего приложения - MIddleware. Для начала давайте сформулируем, что мы хотим: Ловить событие уничтожения Activity (onDestroy) и если флаг isFInishing == true - обнулять наш счетчик.

isFinishing в данном случае определяет причину уничтожения, если он true, значит, пользователь выходит из нашего приложения, если false - активити уничтожается по другой причине, будь то поворот экрана или изменение конфигурации.

Опять же, выглядит так же просто, как и звучит. Создаем объект, который реализует интерфейс Middleware<S>, где S - тип нашего глобального состояния и реализуем метод handleAction().

ActivityLifecycleMiddleware.kt

object ActivityLifecycleMiddleware : Middleware<ApplicationState> {   override fun handleAction( action: Action, state: ApplicationState, next: Next<ApplicationState> ): Action { val newAction = when (action) { is ActivityLifecycleAction.OnDestroy -> handleActivityOnDestroy(action)                 else -> action } return next(newAction, state) }     private fun handleActivityOnDestroy(action: ActivityLifecycleAction.OnDestroy): Action = if (action.isFinishing) CounterAction.Reset else action}

Давайте посмотрим, что здесь происходит. До того как событие ActivityLifecycleAction.OnDestroy попадет в Reducer, оно пройдет через все наши Middleware, и именно здесь мы можем заменить это изначальное событие на то, что нас интересует. Это и происходит, если флаг isFinishing == true, то в Reducer попадёт событие CounterAction.Reset, которое обнулит наш счетчик, если же флаг false - событие уйдёт как есть, но поскольку никто его не обрабатывает, оно никак не поменяет состояние нашего приложения, и подписчики на состояние ничего об этом не узнают. Не забудьте добавить middleware в наш AppComponent-класс.

AppComponent.kt

store = ApplicationStore( initialState = ApplicationState(), middlewares = listOf(ActivityLifecycleMiddleware), reducers = listOf(CounterReducer))

Вот и всё!

Выводы

Лично мне очень нравится Redux. Простая на вид идея, но в тоже время при этой простоте получается создавать сложные вещи, главное научиться его правильно готовить. И он действительно предсказуемый, как и говорится в оригинальном описании библиотеки на JavaScript. Он также позволяет вам четко разграничить места, где у вас есть логика, и места, где этой логики нет. Создание унифицированного кода становится очень простым. Что-то происходит - Action. Нужно принять решение - Middleware, нужно отреагировать на событие - Reducer. А View является простым представлением, которое умеет рисовать себя и сообщать о взаимодействиях с ней.

Бонус

Все исходники данной статьи лежат в открытом доступе, там вы можете найти полный код библиотеки, проект Counter, в котором Middleware и Reducer покрыты тестами, но это еще не все. Я также сделал куда более сложное приложение на Redux-архитектуре это приложение Погода, которое поддерживает систему разрешений Android (доступ к локации), определяет местоположение пользователя и выполняет асинхронную загрузку данных с API OpenWeatherMap. Всё это покрыто тестами и также лежит в открытом доступе.

https://gitlab.com/v.sulimov/android-redux-kotlin

https://gitlab.com/v.sulimov/android-redux-demo

https://gitlab.com/v.sulimov/android-openweather-kotlin

Прощаемся

Данная статья подходит к концу, основная моя цель была рассказать вам про Redux в реалиях Android, и я сделал это. Я считаю, что введение должно быть максимально простым и понятным, потому в качестве примера используется обычный счетчик, но это совсем не значит, что на Redux нельзя делать более сложные вещи, именно поэтому я написал второе приложение и вы сможете без проблем разобрать его, используя данные из этой статьи. Но на случай, если у Вас возникнут вопросы - вы всегда можете задать их в комментариях.

Спасибо за уделенное время, надеюсь, вам было интересно.

С уважением, Виталий. Команда разработки Wheely.

Подробнее..

Мультитул для управления Хранилищем Данных кейс Wheely dbt

30.03.2021 00:12:15 | Автор: admin

Уже более двух лет data build tool активно используется в компании Wheely для управления Хранилищем Данных. За это время накоплен немалый опыт, мы на тернистом пути проб и ошибок к совершенству в Analytics Engineering.

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

Поэтому сегодня я предлагаю вам экскурсию по Хранилищу Данных Wheely. В формат публикации я попытался уложить самые яркие моменты и впечатления от использования dbt, снабдив реальными примерами, практиками и опытом. Добро пожаловать под кат.

Структура превыше всего

Измерять сложность Хранилища Данных в количестве гигабайт сегодня - дурной тон

Налить кучу тяжело интерпретируемых данных без метаинформации (читай мусора) не составит большого труда. Гораздо сложнее из этих данных получить что-то осмысленное. То, на что с уверенностью могут опираться business stakeholders, принимая решения. То, что регулярно измеряется на предмет качества и актуальности. Наконец, то, что соответствует принципам Keep it simple (KISS) и Dont repeat yourself (DRY).

Первостепенным элементом я считаю прозрачность структуры Хранилища Данных. Чаще всего DWH выстраивается согласно многослойной логике, где каждому этапу соответствует набор преобразований, детали реализации которого скрыты для последующих слоев (элемент абстракции).

Схема слоев Хранилища ДанныхСхема слоев Хранилища Данных

Зеленым цветом слой источников данных sources. Это реплики структур и таблиц из исходных систем, которые поддерживаются ELT-сервисом. Данные синхронизируются 1:1 с источником, без каких-либо преобразований. Опциональный слой flatten позволяет вложенные иерархические структуры (JSON) превратить в плоские таблицы.

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

Intermediate или промежуточный слой отвечает за формирование предварительных таблиц и агрегатов, где происходит обогащение данных. Для ряда бизнес-областей мы не используем этот слой, для других логика может насчитывать до 5-10 взаимосвязанных моделей.

Кульминацией являются data marts или Витрины Данных, которые используются Data Scientists / Business Users / BI tools. Слой, в свою очередь, делится на:

  • dimensions: пользователи, компании, машины, водители, календарь

  • facts: поездки, транзакции, сеансы, продвижения, коммуникации

  • looker: материализованные представления и витрины, оптимизированные под чтение из BI-системы

Число 120 из заголовка публикации относится только к витринам данных:

Running with dbt=0.19.0Found 273 models, 493 tests, 6 snapshots, 4 analyses, 532 macros, 7 operations, 8 seed files, 81 sources, 0 exposures

На текущий момент в проекте:

  • 273 модели во всех перечисленных слоях

  • 493 теста на эти модели, включая not null, unique, foreign key, accepted values

  • 6 снапшотов для ведения истории SCD (slowly changing dimensions)

  • 532 макроса (большая часть из которых импортирована из сторонних модулей)

  • 7 operations включая vacuum + analyze

  • 81 источник данных

Помимо разбиения на логические слои, Хранилище можно нарезать по бизнес-областям. В случае необходимости есть возможность пересчитать или протестировать витрины, относящиеся к вертикалям Marketing / Supply / Growth / B2B. Например, в случае late arriving data или ручных корректировках маппингов/справочников.

Осуществляется это за счет присвоения моделям и витринам тегов, а также за счет богатых возможностей синтаксиса выбора моделей. Запустить расчет всех витрин вертикали Marketing и их вышестоящие зависимости:

dbt run -m +tag:marketing

Этот же принцип лежит в основе организации кодой базы. Все скрипты объединены в директории с общей логикой и понятными наименованиями. Сложно потеряться даже при огромном количестве моделей и витрин:

Иерархия проекта dbt
.|____staging| |____webhook| |____receipt_prod| |____core| |____wheely_prod| |____flights_prod| |____online_hours_prod| |____external| |____financial_service|____marts| |____looker| |____dim| |____snapshots| |____facts|____flatten| |____webhook| |____receipt_prod| |____wheely_prod| |____communication_prod|____audit|____sources|____aux| |____dq| | |____marts| | |____external|____intermediate

Оптимизация физической модели

Логическое разделение на слои и области - это замечательно. Но не менее важно и то, как эта логика ложится на конкретную СУБД. В случае Wheely это Amazon Redshift.

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

Цепочка зависимостей витрины поездок (journeys)Цепочка зависимостей витрины поездок (journeys)

На этапе обогащения данных важна скорость склейки таблиц (join performance), поэтому данные сегментированы и отсортированы в одинаковом ключе, начиная с sources. Это позволит использовать самый быстрый вид соединения - sort merge join:

Конфигурация для оптимального соединения sort merge join
{{config(materialized='table',unique_key='request_id',dist="request_id",sort="request_id")}}

Витрина же хранится отсортированной по самым популярным колонкам доступа: city, country, completed timestamp, service group. В случае правильного подбора колонок Interleaved key позволяет значительно оптимизировать I/O и ускорить отрисовку графиков в BI-системах.

Конфигурация для быстрого чтения витрины interleaved sortkey
{{config(materialized='table',unique_key='request_id',dist="request_id",sort_type='interleaved',sort=["completed_ts_loc", "city", "country", "service_group", "is_airport", "is_wheely_journey"])}}

При этом часть моделей есть смысл материализовать в виде views (виртуальных таблиц), не занимающих дисковое пространство в СУБД. Так, слой staging, не содержащий сложных преобразований, конфигурируется на создание в виде представлений на уровне проекта:

staging:+materialized: view+schema: staging+tags: ["staging"]

Другой интересный пример результаты проверки качества данных. Выбранный тип материализации ephemeral, т.е. на уровне СУБД не будет создано ни таблицы, ни представления. При каждом обращении к такой модели будет выполнен лишь запрос. Результат такого запроса является слагаемым в суммарной таблице, содержащей метрики всех проверяемых объектов.

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

Пример инкрементального наполнения витрины
{{config(materialized='incremental',sort='metadata_timestamp',dist='fine_id',unique_key='id')}}with fines as (selectfine_id, city_id, amount, details, metadata_timestamp, created_ts_utc, updated_ts_utc, created_dt_utcfrom {{ ref('stg_fines') }}where true-- filter fines arrived since last processed time{% if is_incremental() -%}and metadata_timestamp > (select max(metadata_timestamp) from {{ this }}){%- endif %}),...

Кстати, о принципах MPP и о том, как выжать максимум из аналитических СУБД я рассказываю на курсах Data Engineer и Data Warehouse Analyst (скоро первый запуск!).

SQL + Jinja = Flexibility

Высокоуровневый декларативный язык SQL прекрасен сам по себе, но вкупе с движком шаблонизации Jinja он способен творить чудеса.

Любой код, который вы используете с dbt проходит этапы compile & run. На этапе компиляции интерпретируются все шаблонизированные выражения и переменные. На этапе запуска код оборачивается в конструкцию CREATE в зависимости от выбранного типа материализации и фишек используемой СУБД: clustered by / distributed by / sorted by. Рассмотрим пример:

Model code:
{{config(materialized='table',dist="fine_id",sort="created_ts_utc")}}with details as (  select{{dbt_utils.star(from=ref('fine_details_flatten'),except=["fine_amount", "metadata_timestamp", "generated_number"])}}from {{ ref('fine_details_flatten') }}where fine_amount > 0)select * from details
Compiled code:
with details as (select  "id","fine_id","city_id","amount","description","created_ts_utc","updated_ts_utc","created_dt_utc"from "wheely"."dbt_test_akozyr"."fine_details_flatten"where fine_amount > 0)select * from details
Run code:
create table"wheely"."dbt_test_akozyr"."f_chauffeurs_fines"diststyle key distkey (fine_id)compound sortkey(created_ts_utc)as (with details as (select"id","fine_id","city_id","amount","description","created_ts_utc","updated_ts_utc","created_dt_utc"from "wheely"."dbt_test_akozyr"."fine_details_flatten"where fine_amount > 0)select * from details);

Ключевым моментом является тот факт, что пишете вы только лаконичный шаблонизированный код, а остальным занимается движок dbt. Написание boilerplate code сведено к минимуму. Фокус инженера или аналитика остается преимущественно на реализуемой логике.

Во-вторых, как происходит выстраивание цепочки связей и очередности создания витрин, продемонстрированные на картинках выше? Внимательный читатель уже заметил, что в рамках написания кода при ссылках на другие модели нет хардкода, но есть конструкция {{ ref('fine_details_flatten') }} ссылка на наименование другой модели. Она и позволяет распарсить весь проект и построить граф связей и зависимостей. Так что это тоже делается абсолютно прозрачным и органичным способом.

С помощью шаблонизации Jinja в проекте Wheely мы гибко управляем схемами данных и разделением сред dev / test / prod. В зависимости от метаданных подключения к СУБД будет выбрана схема и период исторических данных. Продакшн модели создаются в целевых схемах под технической учетной записью. Аналитики же ведут разработку каждый в своей личной песочнице, ограниченной объемом данных в 3-е последних суток. Это реализуется с помощью макроса:

Макрос управления схемами для подключений:
{% macro generate_schema_name_for_env(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' and custom_schema_name is not none -%}{{ custom_schema_name | trim }}{%- else -%}{{ default_schema }}{%- endif -%}{%- endmacro %}

Еще одним важным преимуществом является самодокументируемый код. Иными словами, из репозитория проекта автоматически можно собрать статический сайт с документацией: перечень слоев, моделей, атрибутный состав, метаинформацию о таблицах в СУБД и даже визуализировать граф зависимостей (да-да, картинки выше именно оттуда).

Не повторяйся лучше подготовь макрос

Однотипный код, повторяющиеся обращения и действия, зачастую реализуемые по принципу copy-paste нередко являются причиной ошибок и багов. В Wheely мы придерживаемся принципа Do not repeat yourself и любой сколько-нибудь похожий код шаблонизируем в макрос с параметрами. Писать и поддерживать такой код становится сплошным удовольствием.

Простой пример с конвертацией валют:
-- currency conversion macro{% macro convert_currency(convert_column, currency_code_column) -%}( {{ convert_column }} * aed )::decimal(18,4) as {{ convert_column }}_aed, ( {{ convert_column }} * eur )::decimal(18,4) as {{ convert_column }}_eur, ( {{ convert_column }} * gbp )::decimal(18,4) as {{ convert_column }}_gbp, ( {{ convert_column }} * rub )::decimal(18,4) as {{ convert_column }}_rub, ( {{ convert_column }} * usd )::decimal(18,4) as {{ convert_column }}_usd{%- endmacro %}
Вызов макроса из модели:
select...-- price_details, r.currency, {{ convert_currency('price', 'currency') }}, {{ convert_currency('transfer_min_price', 'currency') }}, {{ convert_currency('discount', 'currency') }}, {{ convert_currency('insurance', 'currency') }}, {{ convert_currency('tips', 'currency') }}, {{ convert_currency('parking', 'currency') }}, {{ convert_currency('toll_road', 'currency') }}, {{ convert_currency('pickup_charge', 'currency') }}, {{ convert_currency('cancel_fee', 'currency') }}, {{ convert_currency('net_bookings', 'currency') }}, {{ convert_currency('gross_revenue', 'currency') }}, {{ convert_currency('service_charge', 'currency') }}...from {{ ref('requests_joined') }} r

По большому счету, макрос это просто вызов функции с передачей аргументов, на уже знакомом вам диалекте Jinja. Результатом работы макроса является готовый к исполнению SQL-скрипт. Макрос для кросс-сверки значений в колонках:

Сравнить значения двух колонок
-- compare two columns{% macro dq_compare_columns(src_column, trg_column, is_numeric=false) -%}{%- if is_numeric == true -%}{%- set src_column = 'round(' + src_column + ', 2)' -%}{%- set trg_column = 'round(' + trg_column + ', 2)' -%}{%- endif -%}CASEWHEN {{ src_column }} = {{ trg_column }} THEN 'match'WHEN {{ src_column }} IS NULL AND {{ trg_column }} IS NULL THEN 'both null'WHEN {{ src_column }} IS NULL THEN 'missing in source'WHEN {{ trg_column }} IS NULL THEN 'missing in target'WHEN {{ src_column }} <> {{ trg_column }} THEN 'mismatch'ELSE 'unknown'END{%- endmacro %}

В макрос можно запросто записать даже создание UDF-функций:

Создать UDF
-- cast epoch as human-readable timestamp{% macro create_udf() -%}{% set sql %}CREATE OR REPLACE FUNCTION {{ target.schema }}.f_bitwise_to_delimited(bitwise_column BIGINT, bits_in_column INT)RETURNS VARCHAR(512)STABLEAS $$# Convert column to binary, strip "0b" prefix, pad out with zeroesif bitwise_column is not None:b = bin(bitwise_column)[2:].zfill(bits_in_column)[:bits_in_column+1]return belse:None$$ LANGUAGE plpythonu;CREATE OR REPLACE FUNCTION {{ target.schema }}.f_decode_access_flags(access_flags INT, deleted_at TIMESTAMP)RETURNS VARCHAR(128)STABLEAS $$SELECT nvl(DECODE($2, null, null, 'deleted'), DECODE(LEN(analytics.f_bitwise_to_delimited($1, 7))::INT, 7, null, 'unknown'), DECODE(analytics.f_bitwise_to_delimited($1, 7)::INT, 0, 'active', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 1, 1), 1, 'end_of_life', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 7, 1), 1, 'pending', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 6, 1), 1, 'rejected', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 5, 1), 1, 'blocked', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 4, 1), 1, 'expired_docs', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 3, 1), 1, 'partner_blocked', null), DECODE(SUBSTRING(analytics.f_bitwise_to_delimited($1, 7), 2, 1), 1, 'new_partner', null))$$ LANGUAGE SQL;{% endset %}{% set table = run_query(sql) %}{%- endmacro %}

Параметризовать можно и довольно сложные вещи, такие как работа с nested structures (иерархическими структурами) и выгрузка во внешние таблицы (external tables) в S3 в формате parquet. Эти примеры вполне достойны отдельных публикаций.

Не изобретай велосипед импортируй модули

Модуль или package - это набор макросов, моделей, тестов, который можно импортировать в свой проект в виде готовой к использованию библиотеки. На портале dbt hub есть неплохая подборка модулей на любой вкус, и, что самое главное, их список постоянно пополняется.

С помощью модуля логирования и добавления 2 простых hooks на каждый запуск dbt у меня как на ладони появляется статистическая информация о времени, продолжительности, флагах и параметрах развертывания. Я наглядно вижу модели анти-лидеры по потребляемым ресурсам (первые кандидаты на рефакторинг):

models:+pre-hook: "{{ logging.log_model_start_event() }}"+post-hook: "{{ logging.log_model_end_event() }}"
Мониторинг развертывания dbt моделей на кластере RedshiftМониторинг развертывания dbt моделей на кластере Redshift

Измерение календаря собирается в одну строку, при этом набор колонок поражает:

{{ dbt_date.get_date_dimension('2012-01-01', '2025-12-31') }}
Измерение календарь, сгенерированное макросомИзмерение календарь, сгенерированное макросом

С помощью модуля dbt_external_tables я уже выстраиваю полноценный Lakehouse, обращаясь из Хранилища к данным, расположенным в файловом хранилище S3. К примеру, самые свежие курсы валют, получаемые через API Open Exchange Rates в формате JSON:

External data stored in S3 accessed vith Redshift Spectrum
- name: externalschema: spectrumtags: ["spectrum"]description: "External data stored in S3 accessed vith Redshift Spectrum"tables:- name: currencies_oxrdescription: "Currency Exchange Rates fetched from OXR API https://openexchangerates.org"freshness:error_after: {count: 15, period: hour}loaded_at_field: timestamp 'epoch' + "timestamp" * interval '1 second'external:location: "s3://data-analytics.wheely.com/dwh/currencies/"row_format: "serde 'org.openx.data.jsonserde.JsonSerDe'"columns:- name: timestampdata_type: bigint- name: basedata_type: varchar(3)- name: ratesdata_type: struct<aed:float8, eur:float8, gbp:float8, rub:float8, usd:float8>

Ну и, конечно, ночью по расписанию работает VACUUM + ANALYZE, ведь Redshift это форк PostgreSQL. Дефрагментация, сортировка данных в таблицах, сбор статистик. Иначе говоря поддержание кластера в тонусе, пока dba спит.

dbt run-operation redshift_maintenance --args '{include_schemas: ["staging", "flatten", "intermediate", "analytics", "meta", "snapshots", "ad_hoc"]}'
VACUUM + ANALYZEVACUUM + ANALYZE

Running in production: используем dbt Cloud в Wheely

dbt Cloud это платный сервис для управления проектами, основанными на движке dbt. За небольшие деньги команда получает возможность создавать окружения, конфигурировать джобы и таски, устанавливать расписание запусков, и даже полноценную IDE (среду разработки!) в браузере.

Прежде всего речь идет об удобстве использования: приятный и понятный визуальный интерфейс, простота поиска и ориентирования, акцентирование ключевой информации при разборе ошибок и чтении логов:

Во-вторых, это гибкие настройки условий запуска джобов. Начиная от простых условий с выбором дня недели и времени, продолжая кастомными cron-выражениями, и заканчивая триггером запуска через webhook. Например, именно через вебхук мы связываем в цепочку завершение выгрузок для кросс-сверки и начало расчета соответствующих витрин в Хранилище (kicked off from Airflow):

В третьих, это консолидация всех важных уведомлений одном месте. Для нашей команды это канал в Slack и любые проблемы связанные с Production-запусками. В режиме реального времени мы получаем все уведомления об инцидентах с деталями и ссылками на подробный лог.

Сам dbt является проектом с открытым исходным кодом, и использование продукта dbt Cloud представляется очень удобным, но не обязательным. В качестве альтернативных способов можно выбрать любой другой оркестратор: Airflow, Prefect, Dagster, и даже просто cron. В своем проекте Сквозная Аналитика я организую оркестрацию при помощи Github Actions. Выходит очень занятно.

Вместо заключения

В команде аналитики Wheely мы стремимся к тому, чтобы работа была наполнена смыслом и приносила удовлетворение и пользу, но не раздражение и негодование. Все перечисленные в публикации достоинства не могут не вызвать симпатию со стороны новых членов команды и значительно ускоряют процессы адаптации и onboarding.

Сегодня бизнес и команда активно растут. Доступен ряд интересных позиций:

У тебя есть возможность узнать детали из первых уст и получить прямую рекомендацию.

Также время от времени я провожу вебинары и выступления, на которых подробнее рассказываю о своей работе и проектах. Следить за моими публикациями можно в телеграм-канале Technology Enthusiast https://t.me/enthusiastech

Пишите, задавайте вопросы и, конечно, пробуйте dbt в своих проектах!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru