Рефакторинг Shiny приложений
Кадр из фильма «Формула любви», 1984
В жизненном цикле любого эксплуатируемого ПО наступает фаза, когда накопившийся набор изменений (CR) ложится неподъемным грузом на первичную архитектуру и вот тут наступает пора рефакторинга. Много книг понаписано на эту тему, есть специфика для различных языков. Ниже затронем только отдельные аспекты, которые могут оказаться полезным применительно к RStudio Shiny приложениям. Это ряд практических методов, трюков и нюансов, накопившихся при рефакторинге, как правило, чужого Shiny кода.
«Aliena nobis, nostra aliis» — Ежели один человек построил, другой завсегда разобрать сможет.
Это было в фильме, в первоисточнике несколько по-другому. Фраза Публилия Сира «Aliena nobis, nostra plus aliis placent» переводится как «Чужое нам, наше же в основном другим нравится». Но кузнец Степан все равно дело говорит.
Является продолжением серии предыдущих публикаций.
Сделать красивое и эффективное shiny приложение, как и любое приложение, достаточно непросто. Тут неплохо бы обладать квалификацией аналитик-разработчик, но таковых можно встретить так же часто, как летчиков-испытателей. Когда берешь в руки приложение, обычно наблюдается нечто из палок и связующей массы. И надо сделать из этого башню-гиперболоид («Шуховская» башня). Те же палки, но добавить болты и выстроить легкую надежную конструкцию, которая и ураганов не боится и видна всем издалека.
Дальше пройдемся по возможным шагам наведения порядка.
Shiny приложение обладает определенной спецификой, обусловленной его направленностью. Аналитика и интерактивное отображение результатов.
Функционально типовое Shiny приложение выглядит следующим образом:
- Инициализация, подкачка справочников и данных для расчетов.
- Управление интерактивным взаимодействием с пользователем, включая генерацию управляющих html элементов в зависимости от состояния управляющих элементов.
- Интерактивные запросы во внешние источники.
- Различной степени сложности расчеты.
- Экспорт вовне.
Логирование
В принципе, для любой продуктивной системы наличие логов является Альфой и Омегой. Особенно в data-driven проектах. Лог является единственным способом заглянуть в ситуацию именно так, как она проходила. Для сбойного случая крайне тяжело будет воспроизвести все, начиная от последовательности потока данных, до всего внешнего окружения с которым явно или неявно взаимодействует приложение. У Shiny приложения это является, фактически, единственным интерфейсом для технологического контроля.
Начинаем обвешивать логами двигаясь по функциональным блокам от инициализации до экспорта. Наша основная задача — сформировать трейс, наложенный на шкалу времени, чтобы определить сценарии использования и отранжировать тормозные места. Вторичная задача — наладить контроль памяти. Во всех блоках, связанных с появлением новых переменных (особенно реактивных), необходимо взвешивать их размер в RAM и время их получения. Память в приложениях течет на ура, ниже приведу один из типичных случаев.
Заглядываем внутрь
Для того, чтобы погрузиться в код запущенного приложения, классические точки останова срабатывают не всегда. Поэтому, чтобы не гадать и не мучаться, используем 100% работающий способ — ставим browser()
в интересующей нас точке. Просто и незатейливо. Остановились и получили доступ ко всему, включая реактивные выражения.
Также есть хороший трюк по встраиванию универсальной точки останова, найденный здесь: «A little trick for debugging Shiny». Поставили кнопку и скрыли при старте — просто, элегантно и доступно на проде без изменения кода.
Устанавливаем связи между элементами интерфейса и серверными функциями
Конечно, внимательное чтение кода всегда поможет проследить все взаимосвязи, но есть и очень полезные вспомогательные средства, которые сильно сэкономят время в сложном приложении.
Идентифицируем элементы на экране
Для быстрого ориентирования может оказаться очень полезным маркировка объектов на html странице их идентификаторами. Идея простая и элегантная, позаимствована здесь: «Display element ids for debugging Shiny apps».
javascript:$("div[id]").each(function(t){$(this).prepend(""+
$(this).attr("id")+"
")}),
$("input[id]").each(function(t){$(this).before(""
+$(this).attr("id")+"
")});
Фактически, кладем в закладки маленький javascript и получаем что-то подобное.
Трассируем связь между визуальными элементами и кодом
В Shiny есть встроенный механизм визуализации активируемого кода при совершении тех или иных действий в интерфейсе (либо обновлении по таймеру). Включается одной командой — shiny::runApp(display.mode="showcase")
. Детальнее можно прочитать здесь: «Display modes»
Реконструируем реактивные связи между элементами
Если у вас приложение с одним выходным элементов и одним входным — тут достаточно просто пристального взгляда. В сложном, накрученном приложении может оказаться множество связей, причем они могут достигать любой глубины иерархичности — зависит от выдумки автора. Заняться исследованиями и устранением избыточных связей позволяет механизм reactlog
. Включается он просто. Ставим одноименный пакет, добавляем в код options(shiny.reactlog=TRUE)
и жмем при запущенном приложении Ctrl+F3
. Вся картина подкапотного взаимодействия как на ладони. Детально можно почитать здесь: https://rstudio.github.io/reactlog/.
Уничтожаем ненужную реактивность
Типичная ситуация, когда аналитик долго разбирался, что же такое реактивные вычисления и с чем их едят. И вот, понимание наступило! И давай эту реактивность применять прямо-таки на каждый чих.
Приложение превращается в полную лапшу. Глядя на код не имеешь представления, что где вызывается и из-за какого угла выскочит заяц. Reactlog
помогает реконструировать динамику реальных связей, но не забываем что есть еще lazy evaluation
. Реактивное выражение не будет считаться до тех пор пока оно реально не потребуется. Поэтому, если вы не прокликаете абсолютно все закоулки, есть вероятность, что что-то упущено.
И есть еще второй фактор, который превращает приложение в монстра, сжирающего все ресурсы. Дело в том, что реактивные выражения хранят у себя кэш. Ладно, если это единичное значение, получаемое в результате долго расчета. Но разработчики начинают нанизывать на шашлык датафреймы, которые могут измеряться гигабайтами. Чик-чик и RAM кончилась.
Вот пример маленького приложения, демонстрирующего такие фокусы. При старте сожрали всю память, а еще даже не начали ничего считать. Причем утечка памяти видна в ОС, а R радостно рапортует о 300 Мб. Реактивность сказывается.
Для изучения структуры объектов в этом случае лучше всего использовать пакет lobstr
, можно досконально до указателей покопаться и посчитать совместное потребление памяти несколькими объектами.
По возможности, надо постараться достичь полной прозрачности вычислений от источника до приемника.
library(shiny)
# Define UI for application that draws a histogram
ui <- fluidPage(
# A BROWSER ANYWHERE, ANYTIME
# Add to your UI:
actionButton("browser", "browser"),
tags$script("$('#browser').hide();"),
tags$script("$('#browser').show();"),
# And to show the button in your app, go
# to your web browser, open the JS console,
# And type: $('#browser').show();
# Application title
titlePanel("Old Faithful Geyser Data"),
# Sidebar with a slider input for number of bins
sidebarLayout(
sidebarPanel(
sliderInput("bins",
"Number of bins:",
min = 1,
max = 50,
value = 30),
br(),
actionButton("add50", "+ 500Mb")
),
# Show a plot of the generated distribution
mainPanel(
plotOutput("distPlot"),
textOutput("info"),
tags$style(type="text/css", "#info {white-space: pre-wrap;}")
)
)
)
df <- data.frame(
a = stringi::stri_rand_strings(10000, 10, '[a-z]'),
b = stringi::stri_rand_strings(10000, 12, '[A-Z]')
)
# Define server logic required to draw a histogram
server <- function(input, output) {
output$distPlot <- renderPlot({
# generate bins based on input$bins from ui.R
x <- faithful[, 2]
bins <- seq(min(x), max(x), length.out = input$bins + 1)
# draw the histogram with the specified number of bins
hist(x, breaks = bins, col = 'darkgray', border = 'white')
})
output$info <- renderText({
glue::glue("Counter = {rval$cnt}",
"mem_used = {fs::fs_bytes(lobstr::mem_used())}",
"react3_df = {fs::fs_bytes(lobstr::obj_size(react3_df()))}",
"react4_df = {fs::fs_bytes(lobstr::obj_size(react4_df()))}",
.sep = ", ")
})
# A BROWSER ANYWHERE, ANYTIME
# Add to your server
observeEvent(input$browser,{
browser()
})
react1_df <- reactive({
dplyr::mutate(df, c = input$bins)
})
react2_df <- reactive({
dplyr::mutate(react1_df(), d = input$bins * 2)
})
react3_df <- reactive({
# runif(6.5555e8 * rval$cnt)
runif(6.5555e6 * rval$cnt)
})
react4_df <- reactive({
# заберем все, кроме последнего элемента. Как бы "фильтрация"
react3_df()[-1]
})
rval <- reactiveValues(cnt = 1) # Defining & initializing the reactiveValues object
observeEvent(input$add50, {
rval$cnt <- rval$cnt + 1
})
}
# Run the application
shinyApp(ui = ui, server = server)
Сворачиваем в функции
По-хорошему, значимые куски кода стоит свернуть в функции и вынести наружу. Их необходимо инкапсулировать, исключив все обращения к переменным за пределами тела функции. Датафреймы, даже гигабайтные, легко передавать внутрь в параметрах, поскольку уезжает список ссылок на колонки, а он копеечный.
Функции хорошо отлаживать, документировать, профилировать, отчуждать. Для них можно снапшоты параметров делать для автотестов.
Определяемся с областью видимости переменных
Исходя из логики приложения, числа пользователей и схемы деплоя определяем, что и где мы храним, куда и когда подгружаем. Глобальные переменные, сессионные переменные, кросс-кэш в виде in-memory db. Хорошая подсказка написана здесь: «Scoping rules for Shiny apps».
Определяемся с источниками данных и способами подгрузки
Классическое Shiny приложение предназначено для расчетов с предзагруженными данными. После того, как мы поняли, что используется, сколько грузится и какие фильтры накладываются в приложении, можно изменить стратегии работы с источниками. Важно учитывать периодичность обновления информации во внешних источниках. В большинстве случаев работа идет по закрытым периодам.
Ищем компромисс, в качестве целевого показателя — минимальное время отклика приложения на действия пользователей. Удобство использования во главе угла. Данные, которые подгружаются долго, либо внешний источник ненадежен, лучше всего закэшировать локально. В своей базе, в файлах, еще как — дело вкуса. Отличным претендентом на серебряную пулю выступает Apache Arrow. Можно быстро фильтровать на нижнем уровне и не тащить в память мусор, можно внизу вести первичную агрегацию, можно не загружать R String Pool мусором, оставаясь на уровне Arrow Dataframe. Замечательно!
Одним из возможных вариантов — вынос всех черновых задач по фоновой загрузке и препроцессингу в отдельный «ETL» слой. R скрипты и cron. Приложение должно работать с уже полностью подготовленными и оптимизированными данными. Не надо воровать у пользователей время на решение своих частных технических задач.
Упрощаем фильтрацию
В большей части Shiny приложений будут те или иные фильтры. И хорошо, если они только по значениям. Но ведь нет. Очень часто появляется значение «Все», которым может выступать пустое поле в фильтре. Если таких фильтров несколько (3–5 и более) и они применяются на большом количестве страниц/закладок, то это может оказаться проблемой.
Желание трассировки к источникам и снижение реактивных объектов вступает в противоречие с необходимостью гибкой фильтрации. Это еще может усугубляться тем, что на экране в фильтре один справочник, а в данных ему соответствуют другие значения. Например, выражение dt[group == input$grp]
если grp == NULL
, а подразумевается Всё, даст совсем не то, что хотелось бы.
На этот случай есть хороший трюк. Идея предельно проста — сначала сочиним индекс строк (булев вектор), который является пересечением различных фильтров по разным колонкам, а потом просто одним махом в data.table
выберем по нему строки в I
и тут же применим функцию в J
. Противоречие разрешено. Легко и просто.
# функция для фильтрации колонок в реактивном data.table
smartFilter <- function(dt, val, col_name){
if(is.null(val)) val <- "Все"
if(val != "Все") dt[val, on = col_name, which = TRUE] else dt[, .I]
}
# функция для расчета пересечений всех фильтров data.table
intersectFilters2 <- function(lst){
Reduce(intersect, Filter(Negate(is.null), lst))
}
# код фильтрации ниже
idx <- dt %>%
{list(
.[!is.na(`Тип`), which = TRUE],
.[`Год` == as.integer(input$year_input), which = TRUE],
.[`Неделя` == input$week, which = TRUE],
smartFilter(., input$domain, "Домен"),
smartFilter(., input$service, "Сервис"),
smartFilter(., input$business_operation, "Бизнес-операция")
)} %>%
intersectFilters2()
used_cols <- c(
'Год', 'Неделя', 'Домен', 'Сервис',
'Бизнес-операция', 'Номер', 'Тип', 'Статус')
dt[idx, .SD, .SDcols = used_cols] %>%
.[, ':='(a = sum(b), c = mean(d))]
Дополнительно ускоряем
Для сокращения времени отклика приложения придется делать дополнительные технологические шаги. Добавление параллелизации в процессы загрузки и расчетов.
Кэширование результатов расчетов, в т.ч. графических.
В качестве стартовой точки можно начать читать отсюда:
Очень хорошим подспорьем является чтение кода от мэтров. Отличный набор минимальных примеров подготовлен RStudio. Читать и смотреть приложения. «Collection of Shiny examples». И отличные материалы конференции «Shiny Developer Conference 2016 talks». Очень часто основы надо изучать по стартовым документам и архивам. Позже на это время уже не остается, все бегут вперед и основы скрываются под ворохом «очевидно».
Сводкой ранее приведенные документы + книги.
Предыдущая публикация — «О бедном бите замолвите слово».