Рефакторинг 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("<span style='color: red'>"+
$(this).attr("id")+"<br/></span>")}),
$("input[id]").each(function(t){$(this).before("<span style='color: red'>"
+$(this).attr("id")+"<br/></span>")});

Фактически, кладем в закладки маленький 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, можно досконально до указателей покопаться и посчитать совместное потребление памяти несколькими объектами.

По возможности, надо постараться достичь полной прозрачности вычислений от источника до приемника.

app.R

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». Очень часто основы надо изучать по стартовым документам и архивам. Позже на это время уже не остается, все бегут вперед и основы скрываются под ворохом «очевидно».

Сводкой ранее приведенные документы + книги.

Предыдущая публикация — «О бедном бите замолвите слово».

Читайте так же: