Дашбординг: Dash или Shiny
Дисклеймер
В данной статье не будут расмотрены многие решения для дашбордина: Tableau, Power BI, Streamlit, Shiny For Python, Dash For R и другие.
Часть статьи будет отрожать мой персональный опыт, и к нему нужно относиться критично.
Введение
Статьи, прочитанные мной про эти фрейморки, делали далеко идущие выводы — boiler plate код у кого-то меньше, значит этот фреймворк лучше, или спорили о вкусах. Предлагаю обсудить вещи чуть более важные
Архитектура
Клиент-серверное взаимодействие
Дерево каталогов
Краткое описание реактивности
Деплой
Тестирование
Архитектура
Shiny
Shiny представляет собой stateful single page application (SPA). Stateful означает, что сервер будет хранить состояние изолированных сессий для каждого пользователя. Приложение может использовать эту информацию при обработке следующих взаимодействий, оптимизируя их выполнение. SPA (single page application) подразумевает, что все взаимодействия пользователя с приложением происходят в рамках единственной веб-страницы, без каких-либо перезагрузок, роутинга и т.п. Это обеспечивает пользователям плавное и непрерывное взаимодействие с приложением. Все это звучит очень хорошо, особенно для дашбординга, где достаточно много интерактивных элементов. Однако с ростом приложения неизбежны проблемы в навигации, а увеличение пользовательской базы приводит к росту числа сессий, которые серверу необходимо поддерживать.
Dash
Dash представляет собой немного иную концепцию- stateless SPA с поддержкой url. Stateless означает, что сервер не сохраняет состояние, специфичное для каждого пользователя. Каждый запрос от клиента к серверу обрабатывается отдельно и независимо от предыдущих запросов. При этом каждый запрос самодостаточен и содержит всю необходимую информацию для его обработки сервером. Dash позволяет строить «мультистраничные приложения» с помощью dcc.Link
и dcc.Loacation
, но рендеринг происходит как в SPA, без полной перезагрузки приложения. Каждая из страниц регистрируется через dash.registar_page
, позволяющий задать путь к странице внутри приложения, а сама страница будет отображаться в app.page_container
.
Клиент-серверное взаимодействие
Shiny
Что же обеспечивает архитектурную концепцию Shiny Web App? В качестве основных сетевых протоколов используется HTTP и WebSocket. HTTP необходим для загрузки той единственной веб-страницы в браузер пользователя. Как только загрузка завершается между клиентом и пользователем образуется соединение, использующие WebSocket протокол. WebSocket представляет из себя дуплексный канал через длительное TCP соединение, позволяя передавать данные в обоих направлениях без значительных дополнительных издержек. Тем самым мы и обеспечиваем плавность взаимодействия клиента с Shiny сервером и сессию как таковую. За создание этого добра отвечает пакет httpuv, утилизирующий libuv и http-parser из С. Несмотря на отличные качества для дашбординга, это решение не лишено своих проблем: как управлять жизненным циклом сессии, как маштабироваться при таком подходе и т.п.
Dash
В отличие от Shiny, дефолтный Dash использует стандартный HTTP/HTTPS для общения между клиентом и сервером. Когда пользователь заходит на ваше приложение, он отправляет инициализирующий запрос, запрашивающий контент, связанный с URL. По этому запросу сервер отправляет клиенту сгенерированный на основе нашего python кода HTML template, CSS, JS код, React, Plotly. Как только браузер клиента переваривает отправленный контент, он посылает запрос на получение основного layout приложения app.layout
. Получив его, Dash c помощью React наполняет DOM и рендерит элементы. Если в приложении используются @callbacks
без флага prevent_initial_call=True
, происходит серия POST запросов на сервер, для их исполнения, после чего приложение готово к взаимодействию с пользователем. Для back-end составляющей, Dash использует Flask сервер, который можно заменить любым Flask like решением, совместимым с Dash. Скажем Quart. И здесь мы уже можем cоздать endpoint, утилизирующий websocket протокол или асинхронность. Подробнее об этом можно почитать тут
Структурная архитектура кода и дерево каталогов
Shiny
Типичная файловая структура Shiny проекта представляла из себя точку входа app.R, директорию www
, содержащую .js скрипты, .css, картинки и датасеты, директорию tests
и виртуальное окружение renv. А также разные файлы конфигурирующие СI/CD. Пока все неплохо, но до 2016 года Shiny не поддерживал модульность, а ожидал от вас одну UI функцию и одну сервер функцию. Поэтому все сшивалось в единое полотно, либо приходилось возиться с source
. Чтобы хоть как-то облегчить себе жизнь, код способный работать вне реактивного контекста выносили в отдельный пакет. Огромное количество легаси остается написанным в таком стиле, а многие приложения продолжают писаться в одном файле. Примерно в 2016 году, Posit понял что так жить нельзя, а где-то к 2019 и выкатил shiny modules. Каждый модуль предстаялет из себя UI и server компонент, связанные через id. Модули можно и нужно переиспользовать, они в идеале должны содержать оптимальную порцию бизнес логики и если начать работать с ними, а рядовому R пользователю на них реально сложно переключится, можно и вправду создать что-то большое, читабельное и поддерживаемое. Современная shiny разработка довольно часто ведется с помощью фрэймворка golem. Golem предсоздает полезные скрипты для деплоя, конфигурации приложения, позволяет удобно добавлять модули, тесты, документацию. Советую новые проекты начинать на нем.
Dash
Тут дела обстоят гораздо проще. Dash приложение мало чем отличается от любого другого проекта на python
Так что мы можем организовывать наш код так как мы привыкли это делать. Конечно от нас будут ожидать предоставление точки входа и определенной файловой структуры. Как я уже говорил, Dash поддерживает url с помощью dash.registar_page
. По дефолту Dash будет искать эти страницы в директории pages
.
- app.py
- pages
|-- analytics.py
|-- home.py
|-- archive.py
-modules
-tests
...
В директории assets
приложение ожидает увидеть .css, .js файлы, а также картинки, видео и т.п. Файлы для бекенда, описывающие логику и layout страниц можно расположить в различных модулях, что позволяет удобно разделить и переиспользовать логику между компонентами приложения.
Краткое описание реактивности
Shiny
В Shiny выделяют 3 группы объектов или ролей реактивного программирования: reactive source
, reactive conductor
, reactive endpoint
. Reactive source
— всё, что попадает в аргумент input функции сервера, это read-only объекты. Именно с этими компонентами будет взаимодействовать пользователь в UI. Reactive conductor
— представляется из себя reactive expression
, способное работать с реактивным контекстом и возвращать результат в ответ на изменения reactive source
. Для создания reactive expression
используется ключевое слово обертка reactive({})
или eventReactive(event, {})
, если мы хотим привязать выполнение выражения к какому-либо событию, например, onClick
кнопки и т.п. В категории reactive expressions следуют упомянуть isolate()
— если мы хотим лишь получить доступ к текущему состоянию reactive source
, без триггера дальнейших обновлений. Reactive endpoint
— все что содержится output аргументе в нашей сервер функции. В отличие от reactive expressions
, reactive endpoint
всегда возвращает NULL, но в ходе выполнения создают side effect (вызов rended метода), который как раз таки воспринимается пользователем как реакция приложения на обновление. Как только запускается новая сессия, Shiny не знает ничего о взаимосвязи конкретного reactive source
виджета с каким либо reactive conductor/reactive output
. Reactive endpoint
и reactive expression
инвалидированы. Shiny выбирает рандомный reactive output
и если он зависит от какой-то реактивной переменной, которая может прийти из reactive source
напрямую или возвращена, как результат reactive expression
, то устанавливает между ними связь. Реактивная переменная поступающая их reactive source
передается мгновенно. Результат reactive expression
вычисляется единожды, и может быть использована неограниченным числом reactive endponts
. Как только все значения reactive expression
вычислены, а reactive endpoints
создали свои side-effects, наше приложение готово к работе. Пользователь начинает работать с приложением изменяя reactive source
, который подписан на DOM event (change, input, click), через метод subscribe
. Данный метод запускает callback, чья задача уведомить shiny server об изменении состояния элемента, а также передать это новое состояние. Упрощенно это можно представить так
el.addEventListener('input', function() {
// This function is the callback that Shiny uses
Shiny.onInputChange('input_id', el.value);
});
Как только Shiny server получает новое значение, он инвалидирует все взаимосвязи с reactive expression/reactive endpoint
. И мы приходим, по сути, к изначальному состоянию, когда нужно вновь вычислить все инвалидированые reactive endpoints
. Стоит отметить одну важную особенность Shiny-приложений, которая вытекает из описанного выше механизма работы — laziness
. Reactive expression, reactive endpoints
будут вычислены, если изменится reactive source
, от которого они зависят. Всё остальное их не волнует. Обобщая этот принцип, если разработчик случайно передаст несуществующий reactive source
или попытается обновить несуществующий reactive endpoint
, R даже не выдаст предупреждение. Также это относится и к порядку выполнения кода. В следующей Shiny-дашборде есть только одна маленькая проблема, из-за которой не отображается график, но при этом слайдер работает нормально, как и все приложение.
library(shiny)
library(dplyr)
df = faithful # Old Faithful Geyser data
# Define the UI
ui <- fluidPage(
titlePanel("Simple Histogram App"),
sidebarLayout(
sidebarPanel(
sliderInput(
inputId = "bins",
label = "Number of bins:",
min = 1,
max = 50,
value = 10
)
),
mainPanel(
plotOutput(outputId = "distPlot")
)
)
)
# Define the server logic
server <- function(input, output) {
output$dispPlot <- renderPlot({
hist(data, breaks = input$bins, col = "skyblue", border = "white",
main = paste("Histogram of Waiting Times", input$y),
xlab = "Waiting time to next eruption (in mins)")
})
data <- df%>%pull(waiting)
}
# Run the app
shinyApp(ui = ui, server = server)
Молодой человек нужна подсказка ? R с радоcтью ее даст.
Listening on http://127.0.0.1:4276
>
Думаю, если вы привыкли к Python ваша реакция на все это примерно такая
Dash
В ходе инициализации Dash
, пробегается через модули приложения, запускает их, и с помощью inspect.getmbembers
ищет все объекты, продекарированные @callback
. @callback
принимает в себя один или несколько Output
, Input
, State
объектов, которые выступают в роли reactive source
, reactive endpoint
Output
— reactive endpoint
отвечает за конкретный компонент, к которому мы обращаемся по id и обновляем его атрибуты. Input
— reactive source
, изменение в котором должны стриггерить callback функцию. State
— reactive source
, чьи атрибуты нам важны при исполнении callback функции, но они не триггерят ее выполнение, аналог isolate в Shiny. Каждый @callback
добавляет информацию о себе в глобальную переменную GLOBAL_CALLBACK_MAP
, предтавлющую из себя словарь. Ключями к этому словарю являются Output
объекты @callback
а, а значением становится объект функции. Чтобы не возникли коллизии, мы должны добавлять allow_duplicate=True
, если несколько @callback
ов обновляют атрибут одного и того же объекта. Затем происходит конфигурация сервера, при которой GLOBAL_CALLBACK_MAP
копируется в app.callback_map. Каждый flask request к приложению проходит через метод dispatch
, из его body получают информацию о Output, Input, State. На основе этих данных происходит поиск в app.callback_map, выставляется контекст и в ctx.run
запускается функция. На основе полученного ответа формируется flask response, который отправляется клиенту.
Мы обладаем полнотой контроля над исполнением callback’a:
prevent_initial_call
позволяет предотвратить первоначальное исполнение callback’a при запуске приложенияon_error
— ожидает получить callable объект, который будет вызван в случае raise ошибки внутри callback’аrunning
— ожиадет коллекцию объектов, значения которых будут выставлены, пока callback находится в работеcancel
— ожидает коллекциюInput
объектов, триггер которых прерывает запуск callback’a
Также @callback
может выполняется специальным менеджером — long callback manager или быть заданным через @long_callback, тем самым мы можем поместить его в отдельную очередь (background callback queue), управляемую callback manager и не оказаться в ситуации, при которой приложение заблокировано выполнением длительных операций, а также добавить возможность кэширования результатов. Часть логики можно исполнять исключительно на стороне клиента, через client side callbacks Кстати, Dash проверит наличие Outputs/Inputs перед запуском приложения и скажет вам об ошибке. Это конечно тормозит старт приложения, но обезопасит вас от проблем. Но как быть если мы имеем динамический layout, в котором объекты появляются и исчезают ? Мы может подписать callback на неограниченное количество Inputs/Output используя pattern matching callback, задавая id объектов через группу — type
, и добавляю уникальный идентификаторов index
. В дальнейшем, callback может реагировать как на адресное изменения объекта через уникальную пару (type
, index
), так и (type
, ALL/MATCH/ALLSMALLER). Еще одной киллер фичей, я считаю Patch. Если коротко, Patch — proxy write object, который будет содержать инструкции по частичному обновлению атрибутов, комплексного объекта. Скажем, у вас есть график, содержащий полмиллиона точек, и input, контролирующий title графика. Нам абсолютно не нужно пересылать весь график между клиентом и сервером и снова его отрисовывать, мы лишь должны изменить его title. Наивный, но частый пример. В целом таким образом можно очень эффективно обновлять графики, включая слои данных, таблицы и даже layout страниц.
Деплой
Shiny
Пойдем мы с вами от бесплатных решений к платным, все таки нельзя недооценивать силу халявы. Если у вас мало времени и денег, лучшим выбором станет PaaS https://www.shinyapps.io/. Бесплатный тир позволяет задеплоиться максимально быстро и просто и на этом все. А чего еще можно ожидать от пробника PaaS? За ним следует shiny server. Тут у вас максимум свободы, но и ответственности. Shiny server можно поставить на множество линукс дистрибутивов. Или можно воспользоваться docker image. Так как коммерческая версия (Shiny Server Pro) отжила свое, то никакой поддержки или жирного функционала идущего из коробки ждать не приходится, а приходится конфигрурировать все самому. А конфигурировать там есть что, скажем из за того что Shiny это statefull приложение, то ему нужен load balancer со sticky session. Доставать метрики и логи тоже будет не так просто. Ну и наконцец Posit Connect. На самом деле незаменимая вещь, если количество дашбордов и активных авторов переваливает за 100. Позволяет деплоится в 1 кнопку, поддерживает множество типов аутентификаций, сам автор приложения может легко настроить доступ к нему, балансировку, сбор логов и метрик. А самое приятное, пригоден не только для деплоя shiny, но и Dash, Steamlit, Flask, маркдаунов и много чего еще. Из минусов — за него придется платить, и по отзывам служба поддержки posit становится хуже год от года.
Dash
Выберем тот же подход, если у вас мало времени и денег, лучшим выбором станет free-tier Heroku. Следующим вариантом станет классический gunicorn+nginx, опять же полная свобода действий и конфигурации приложения.Следом идет уже знакомый Posit connect, лично я работаю уже 5 лет с ним, особых претензий как у пользователя у меня нет. Ну и последней платной альтернатиаой является dash enterprise. Тут сказать ничего не могу, так как работал лишь с Posit Connect. Если у вас есть мнение про него, с радостью прочитаю
Тестирование
Shiny
Начнем с unit-тестирования, как только мы написали несколько отдельных модулей для shiny приложения, мы можем их протестировать с помощью testthat, а также shiny: testServer. Интеграционное тестирование можно организовать используя shinytest. Это snapshot based фреймворк, который позволяет записывать сценарий взаимодействия пользователя, входные и выходные данные, а также создавать скриншоты UI. Затем мы можем прогонять новые версии нашего приложения и сравнивать с полученными «golden image».
Dash
Начнем с unit-тестов, их мы можем написать с помощью dash[testing]
. Общая логика заключается в ручном выславлении контекста с помощью context_value.set
и запуска @callback
ов в этом контексте. Это позволяет проверить, что наши @callback
триггерятся нужными компонентами, а также их значения верно обрабатываются. Если мы не возрващаем write-only Patch объект, то результат любого @callback
будет сериализуемый в JSON объект, который достаточно легко проверить. Интеграционное тестирование можно организовать через dash duo, это snapshot based фреймворк, который записывает сценарии пользовтельского взаимодействия, а затем также сравнивает с золотым стандартом.
Заключение
В тех статьях что читал я, принято было как в телевикторинах, за каждый раунд сравнения начислять очки, а потом говорить, они шли ноздря в ноздрю и бла бла бла. Я здесь этого делать не стал, но своим мнением капитана очевидности поделюсь: если вы знаете R — пишите на Shiny, если вы знаете Python — пишите на Dash. Если же вы не пишите код, а заставляете писать других, ну или знаете R/Python и вы быстро пролистали до конца статьи, вот вам правило большого пальца: если ваш дашборд планирует расширятся в сторону взаимодействия с внешними сервисами и большим количеством относительно простой логики — выбирайте Python и Dash. У R тут могут быть ощутимые риски в виде написания своего велосипеда или очень сложного кода. Если ваше приложение планирует погружение в глубокую, наукоемкую доменную область — выбирайте R. Иначе в какой-то момент вы обнаружите себя за изучением поправки для какой-нибудь термодинамики, а не формошлепстом, потому что в Python этой поправки нет, а вот в R добрый ученый ее опубликовал в виде пакета, так уж принято в академической среде.