Notre template Shiny pour concevoir une appli prod-ready

Dans cet article, je vais vous présenter l’approche que nous utilisons chez ThinkR pour les applications Shiny que nous sommes ammenés à concevoir. Il s’agit d’un cadre de travail relativement strict mais conçu pour nous simplifier la vie et garantir de livrer une application “prod ready” ( qu’elle soit lancée en local, sur shinyserver, Rconnect ou encore via ShinyProxy avec docker ). Ce cadre facilite la maintenance de l’application et le travail collaboratif.

Une appli Shiny dans un package R

Un modèle de librairie R pour une application Shiny

Ce que je vous présente ici est une approche que nous avons pris le temps de figer et documenter pour cet article. Dans les faits elle évolue encore, et nous serions ravis d’entendre vos remarques et de recevoir vos propositions d’amélioration du template.

Cette approche a été concue pas à pas, au fil des projets Shiny qui nous ont été confiés par nos clients, en apprenant de nos erreurs, en cherchant à optimiser notre temps et les phases de debogage du code.

Pour comprendre la suite de mon propos, il vaut mieux :

  • Connaître le langage R
  • Avoir des bases de Shiny
  • Savoir faire un package, si vous ne savez pas encore, ne fuyez pas, je vous explique clairement ici avec même une vidéo .
  • Savoir créer un shinyModule c’est super important! voir ici

Ma première “grosse” application shiny, à vocation professionelle, date de 2015. Concrètement, remettre les mains dedans aujourd’hui serait un calvaire, et je dois être le seul sur terre à pouvoir la maintenir (j’exagère mais vous comprennez l’idée). Grâce au template proposé ici, l’approche est beaucoup plus claire et confortable.

Tout est Package

Dès que vous faîtes votre premier “copier-coller”, il faut créer une fonction. Dès que vous faîtes une 1 fonction, il faut créer un package (en sachant qu’un package ça se versionne via git).

Concevoir une application Shiny c’est une chose, s’assurer de le faire de telle sorte qu’elle soit facilement deployable et maintenable c’est autre chose. Dans R, dès que l’on veut un minimum péréniser et cadrer son travail, il faut créer un package. Nous allons donc concevoir un package avec certaines régles, spécialement conçues pour le développement d’applications Shiny.

Cela reste un package, donc toutes les fonctions que vous allez coder doivent se placer dans le dossier R. Il faut de ‘belles’ fonctions, un devtools_history.R, des balisages, {roxygen2}, etc. cf. notre article “créer un package R en 5 étapes”.

Ne pas coder votre application Shiny trop tôt

Cet aspect n’est pas détaillé dans l’article mais il faut éviter de commencer bille-en-tête par coder une application Shiny, sans trop savoir où l’on va. Assurez-vous d’avoir un Rmd type, propre, qui fonctionne bien. Il doit utiliser les fonctions que vous avez déja intégrées dans votre packages, des fonctions avec une documentation et des tests unitaires. Une fois que vous avez cela, une fois que vous avez fabriqué un workflow qui tourne bien (validé par votre commanditaire :p ), vous pouvez commencer à carrosser le tout dans une application Shiny.

Une appli dans le package

L’arboresence d’un package est quelque chose d’assez figé, et il n’est pas prévu d’emplacement spécifique pour les applications Shiny. C’est pourquoi nous allons utiliser le dossier inst. Tout ce qui est placé dans ce dossier se retrouvera sur le poste de l’utilisateur, à la racine du package, dans son dossier d’installation. C’est le seul endroit où vous pouvez déposer des fichiers et documents exotiques (si ceux-ci doivent arriver jusqu’à l’utilisateur final du package), tels qu’une application Shiny.

Dans notre cas, l’application Shiny dans le dossier inst sera une coque vide. Le contenu de l’application sera créé sous forme de fonctions dans le dossier R. Dans un premier temps, nous allons donc concevoir des fonctions distinctes qui retourneront la partie UI de l’app d’une part et la partie serveur de l’app d’autre part. Ainsi, dans le dossier R, nous créons un fichier app_ui.R qui contient la définition de la fonction app_ui.

#' @import shiny
app_ui <- function() {
  fluidPage(
    titlePanel("My Awesome Shiny App"),
   ...
    )
}

et un fichier app_server.R qui contient la partie serveur.

#' @import shiny
#' @importFrom graphics hist
#' @importFrom stats rnorm
#'
app_server <- function(input, output,session) {
...
  output$tableau <- DT::renderDT({data()})
}

Ensuite, dans le dossier inst/app nous allons concevoir un fichier server.R et un autre ui.R, il s’agit du formalisme attendu, par exemple, par shinyApps.io et shiny server. Rstudio fera même apparaître le bouton ‘run app’, à l’ouverture de ces fichiers.

Ces fichiers sont plutot légers et contiennent ces instructions :

ui.R

shinytemplate::app_ui()

server.R

shinytemplate::app_server

Bien sûr il faut remplacer shinytemplate par le nom de votre package.

Reste ensuite à définir une fonction run_app() (mais vous pouvez l’appeler comme vous voulez), qui aura pour rôle de lancer l’application :

run_app <- function() {
  shinyApp(ui = app_ui(), server = app_server)
}

Cette fonction est celle qui pourra être appelée par l’utilisateur de votre package pour lancer l’application, ou encore elle sera lancée par un container Docker concue pour Shinyproxy.

Tester son application

Quand on développe une application Shiny, cela peut prendre un peu de temps pour visualiser l’impact d’une modification. L’application en elle-même peut être longue à se mettre en place. Souvent, il faut se balader dans l’application (téléverser un fichier, cliquer ici, là et là) pour faire apparaître la correction ou vérifier un bug… Bref c’est long, trop long. De plus, nous sommes en train de vous encourager à faire cela en package, il faut donc y ajouter le temps de compilation/installation à chaque modification !

Mais pas de soucis on a une solution à tout cela 🙂 Dans les faits, on ne va que rarement installer le package et lancer l’application run_app() que l’on a défini… cela prend trop de temps.

À la place, nous vous proposons de créer un script dans inst/dev/run_dev.R. Voici une proposition de script :

.rs.api.documentSaveAll() # ferme et sauvegarde tous les fichiers ouvert
suppressWarnings(lapply(paste('package:', names(sessionInfo()$otherPkgs), sep = ""), detach, character.only = TRUE, unload = TRUE))# detache tous les packages
rm(list = ls(all.names = TRUE))# vide l'environneent
devtools::document('.') # genere NAMESPACE et man
devtools::load_all('.') # charge le package
options(app.prod = FALSE) # TRUE = production mode, FALSE = development mode
shiny::runApp('inst/app') # lance l'application

Un clic sur le bouton source, ou un Ctrl + A puis Ctrl +R, et vous avez en quelques secondes votre application à jour sous les yeux. Pas mal, non?

Des Modules partout, tout le temps

Il n’est pas souhaitable de devoir maintenir des parties UI et serveur de plusieurs centaines/milliers de lignes, c’est ingérable :

  • On ne peut pas travailler à plusieurs dessus
  • On ne peut pas réutiliser facilement des concepts d’une application à l’autre
  • Il faut être extrèmement rigoureux sur l’agencement des reactive, observe, etc.
  • Il est très compliqué d’avoir une vue d’ensemble de l’app
  • etc

La solution est d’utiliser des shinymodules. Ça change la vie. Il y à un avant et un après l’usage de cette fonctionalité. L’idée est de définir des “modules”, c’est-à-dire des blocs de mini applications Shiny qui vont permettre de compartimenter les fonctionnalités de votre application. Et de le rappeler autant de fois que vous le voulez dans ce projet ou dans un autre. Il s’agit du concept de fonction appliqué aux applications Shiny.

Concevoir un ShinyModule revient à créer deux fonctions, une en charge des aspects UI, et l’autre en charge des aspects serveur. Ces deux fonctions vont être définies dans le même fichier .R. Pour le nom de ce fichier .R et le noms des deux fonctions à y inclure nous proposons cette convention de nomenclature :

  • le nom du fichier doit correspondre au nom de la fonction UI du module. Il commence par mod_, suivi de la fonctionnalité du module en minuscule, avec des underscore, et se termine par UI, Input ou Output
    • On utilisera Input en suffixe si le module est un Input
    • On utilisera Output en suffixe si le module est un Output
    • On utilisera UI en suffixe si le module est à la fois un Input et un Output

Le suffixe de la fonction serveur reprend le nom de la fonction UI. Par exemple, le fichier mod_import_excelUI.R contiendrait les deux fonctions suivantes :

  • ui : mod_import_excelUI
  • server side : mod_import_excel

Chaque fonction doit être documentée, et doit faire appel aux @import et @importFrom qui conviennent. On fera en sorte que les deux fonctions partagent la même page de documentation. #' @rdname permet cela.

Enfin, il est essentiel qu’un exemple le plus minimaliste possible de l’usage du module soit proposé.

Voici par exemple le contenu du fichier mod_csv_fileInput.R

#' @title   mod_csv_fileInput and mod_csv_file
#' @description  A shiny Module that imports csv file
#'
#' @param id shiny id
#' @param label fileInput label
#' @importFrom stringr str_detect
#' @export
#' library(shiny)
#' library(DT)
#' if (interactive()){
#' ui <- fluidPage(
#'   mod_csv_fileInput("fichier"),
#' DTOutput("tableau")
#' )
#' server <- function(input, output, session) {
#'   data <- callModule(mod_csv_file,"fichier")
#'   output$tableau <- renderDT({data()})
#' }
#' shinyApp(ui, server)
#' }
mod_csv_fileInput <- function(id, label = "CSV file") {
  ns <- NS(id)
 out <-  tagList(
  ...
 out
}
#' mod_csv_file server function
#'
#' @param input internal
#' @param output internal
#' @param session internal
#'
#' @importFrom utils read.csv
#' @importFrom glue glue
#' @export
#' @rdname mod_csv_fileInput
mod_csv_file <- function(input, output, session, stringsAsFactors=TRUE) {
  userFile <- reactive({
    validate(need(input$file, message = FALSE))
    input$file
  })
  observe({
    message( glue::glue("File {userFile()$name} uploaded" ) )
  })
  dataframe <- reactive({
    read.csv(userFile()$datapath,
             header = input$heading,
             quote = input$quote,
             sep = input$sep,
             stringsAsFactors = stringsAsFactors)
  })
  output$log <- renderPrint({input$file})
  dataframe
}

Tester ses modules

L’interêt de travailler en modules, c’est que l’on peut tester et debogger chacun des modules indépendament des autres. Ainsi, nous fabriquons pour chaque module un fichier proche de run_dev.R, mais qui ne lance que le module en question. Là aussi le nom du fichier répond à une nomenclature précise : run_dev + nom_du_module + .R Voici par exemple le contenu de inst/dev/run_dev_mod_csv_fileInput.R

.rs.api.documentSaveAll() # close and save all open file
suppressWarnings(lapply(paste('package:', names(sessionInfo()$otherPkgs), sep = ""), detach, character.only = TRUE, unload = TRUE))
rm(list = ls(all.names = TRUE))
devtools::document('.')
devtools::load_all('.')
options(app.prod = FALSE) # TRUE = production mode, FALSE = development mode
library(shiny)
library(DT)
if (interactive()){
  ui <- fluidPage(
    mod_csv_fileInput("fichier"),
    DTOutput("tableau")
  )
  server <- function(input, output, session) {
    data <- callModule(mod_csv_file,"fichier")
    output$tableau <- renderDT({data()})
  }
  shinyApp(ui, server)
}

Gérer votre application avec une version dev et une version prod

L’idée est d’utiliser une options que l’on passera à FALSE ou TRUE, pour définir si l’on souhaite voir l’appli en mode production (options(app.prod = TRUE)) ou en mode développement (options(app.prod = FALSE))

Ensuite, via une fonction spécifique, définie comme suit :

`%||%` <- function (x, y)
{
  if (is.null(x))
    y
  else x
}
#' return `TRUE` if in `production mode`
app_prod <- function(){
  getOption( "app.prod" ) %||% TRUE
}

il est possible d’afficher ou masquer des message() de débogage :

if (!app_prod()){message(" mode dev activé ")}

On utilisera aussi ce if ( app_prod() ){ pour afficher ou non des éléments d’un shinyModule

Pour cela, on prendra soin de nommer les éléments du tagList, avec un nom commencant par ‘dev’ pour ensuite les retirer si app_prod() est TRUE.

Exemple :

out <-  tagList(
    fileInput(ns("file"), label),
    checkboxInput(ns("heading"), "Has heading"),
    selectInput(ns("quote"), "Quote", c(
      "None" = "",
      "Double quote" = "\"",
      "Single quote" = "'"
    )),
    selectInput(ns("sep"), "Separator", c(
      "comma" = ",",
      "tabulation" = "\t",
      "semicolon" = ";"
    )),
    "dev1" = verbatimTextOutput("log")
  )
if ( app_prod()) {
   out <-   out[! (names(out) %>% str_detect("^dev"))]
}
 }
 out

Tester son code

Chaque élément de l’application est une fonction, qu’il est possible de tester, et qu’il faut tester. Pour rappel usethis::use_test('fonction_a_tester) permet de mettre en place des tests unitaires. Pour l’heure, dans notre package {shinytemplate}, un fichier tests/testthat/test-app.R contient un embryon de tests spécifiques aux applications Shiny, mais le test ‘app server’ est commenté puisqu’il ne passe pas avec les ShinyModules (work in progress)

Le package {shinytemplate}

Nous avons développé un package qui ajoute un template Rstudio, et vous permettra de concevoir un projet Rstudio contenant tout ce qu’il faut pour construire un package R spécifiquement pour une application Shiny. Ce template reprend les différents points présenté dans cet article.

Vous pouvez installer l’application avec l’instruction suivante :

devtools::install_github("ThinkR-open/shinytemplate")

Puis choisir File > New project > New Directory > R package for shiny app

Vous avez maintenant un template ‘vierge’ à partir duquel vous pouvez développer votre application.

N’hésitez pas à commenter cet article ci-dessous ou à faire vos propositions sur la page Github de notre package {createshinytemplate}


À propos de l'auteur

Vincent Guyader

Vincent Guyader

Codeur fou, formateur et expert logiciel R



Also read