La communication entre modules et ses caprices

Tags : shiny

Dans le cadre du développement d’une application Shiny pour la production à l’aide du package {golem}, nous recommandons, entre autres, de travailler avec les Shiny-modules. La communication des données entre les différents modules peut s’avérer complexe. Chez ThinkR nous utilisons une stratégie : la stratégie du petit r. Nous vous expliquons tout dans cet article.

Qu’est-ce qu’un module ?

Un module est la combinaison de deux fonctions. Ces fonctions contiennent des paramètres d’entrée. Comme pour une fonction que l’on dira “pure”, ce qui se passe à l’intérieur du module est fait pour y rester et n’est pas censé se retrouver dans l’environnement global, sauf le dernier objet créé s’il est exporté.
Comme pour une fonction classique, un paramètre non déclaré dans la fonction, mais qui existe dans l’environnement global, peut être utilisé dans celle-ci.

a <- 5
my_sum <- function(b) {
  b + a
}
my_sum(b = 3)
## [1] 8

Mais ce n’est pas une manière reproductible de travailler. La fonction n’est pas indépendante du code dans lequel elle existe.

Si vous avez bien suivi l’actualité ThinkR, vous savez que pour développer nos applications shiny nous utilisons le package {golem}. {golem} permet un développement sous forme de modules. Cela permet de plus facilement maintenir l’application dans le temps et de favoriser le travail à plusieurs.

Comme nous travaillons avec {golem}, nous créons un package R. Par analogie avec les fonctions, les modules doivent pouvoir être considérés comme indépendants, documentables et testables avec des tests unitaires. Ceci assure la maintenabilité du package. Ainsi, on pourrait considérer qu’un module puisse être ré-utilisé en l’état dans une autre application Shiny. Ce n’est pas toujours le cas, mais c’est ce que nous recommandons de garder en tête lorsque vous développez en modules.

Comment mettre en place la “stratégie du petit r”

“La stratégie du petit r” permet de faire communiquer les modules à l’aide d’une reactiveValues globale qui passe dans chaque module pour récupérer les différents résultats que nous souhaitons partager entre modules.

Un petit r se balade entre les modules

Un exemple d’application Shiny

Prenons un exemple reproductible d’une application shiny indépendante qui permet de sélectionner une colonne dans un jeu de données choisi. Les colonnes sélectionnées sont visualisées dans une table.

library(shiny)
# UI
ui <- fluidPage(
  selectInput("dataset", "Choose a dataset:",
              choices = c("rock", "pressure", "cars")),
  selectInput("colonnes",label = "Choose some columns", choices = NULL, multiple = TRUE),
  tableOutput("table")
)
# Server
server <- function(input, output, session) {
  datasetInput <- reactive({
    switch(input$dataset,
           "rock" = rock,
           "pressure" = pressure,
           "cars" = cars)
  })

  observe({
   colonnes <- names(datasetInput())
   updateSelectInput( session, "colonnes", choices = colonnes)
  })

  data <- reactive({
    req(input$colonnes)
    datasetInput()[, input$colonnes]
  })
  output$table <- renderTable({
    head(data())
  })
}
shinyApp(ui, server)

Transformation en module

Pour l’exemple, nous allons passer en module uniquement la partie “sélection d’une colonne et visualisation de son contenu”. La sélection du jeu de données se fait en dehors du module. La transformation en module se fait à l’aide de {golem}.
Nous n’allons pas détailler toutes les étapes pour utiliser {golem}, vous trouverez plus d’informations ici et . Nous nous contenterons de transformer l’application précédente en module.

Première étape, créer un golem pour notre application :

golem::create_golem("~/thinkr/appdemo")

On se retrouve alors avec :

Pour créer un module, utilisons la fonction suivante:

golem::add_module("select_view")

Nous avons alors un fichier R/mod_select_view.R de créé.

# Module UI
#' @title   mod_select_view_ui and mod_select_view_server
#' @description  A shiny Module.
#'
#' @param id shiny id
#' @param input internal
#' @param output internal
#' @param session internal
#'
#' @rdname mod_select_view
#'
#' @keywords internal
#' @export 
#' @importFrom shiny NS tagList 
mod_select_view_ui <- function(id){
  ns <- NS(id)
  tagList(
  )
}
# Module Server
#' @rdname mod_select_view
#' @export
#' @keywords internal
mod_select_view_server <- function(input, output, session){
  ns <- session$ns
}
## To be copied in the UI
# mod_select_view_ui("select_view_ui_1")
## To be copied in the server
# callModule(mod_select_view_server, "select_view_ui_1")

La “stratégie du petit r” dès le fichier “server” principal

Avant de coder, il faut s’assurer de la disponibilité des données pour notre futur module. Dans notre exemple, pour bien comprendre la “stratégie du petit r”, nous considérons la sélection du jeu de données principal en dehors du module de sélection de colonne.

Pour cela, nous allons créer une “reactiveValues” qui va récupérer le choix de l’utilisateur.

Coté “app_ui” pour l’instant (équivalent au ui.R):

app_ui <- function() {
  tagList(
    # Leave this function for adding external resources
    golem_add_external_resources(),
    # List the first level UI elements here 
    fluidPage(
      h1("appdemo"),
      selectInput("dataset", "Choose a dataset:",
              choices = c("rock", "pressure", "cars"))
    )
  )
}

Coté app_server (équivalent à server.R):

app_server <- function(input, output,session) {
  # List the first level callModules here
  r <- reactiveValues()
  observe({
    r$dataset <- switch(input$dataset,
           "rock" = rock,
           "pressure" = pressure,
           "cars" = cars)
  })
}

Donc notre jeu de données est stocké dans r$dataset. Voilà la raison du nom : “stratégie du petit r”.

Le côté UI de notre module

Le coté UI d’un module n’est vraiment pas différent des UI “classiques”. Il n’est pas affecté par la “stratégie du petit r”.

mod_select_view_ui <- function(id){
  ns <- NS(id)
  tagList(
    selectInput(ns("colonnes"),label = "Choose some columns", choices = NULL, multiple = TRUE),
    tableOutput(ns("table"))
  )
}

Il faut utiliser la fonction ns autour des “inputId” et “outputId” pour les rendre uniques.

Le côté server de notre module

Le côté “server” doit se préoccuper des données disponibles dans l’environnement global et être réactif à toutes modifications de ces données.
Pour cela, la reactiveValues petit r est partagée avec notre module. Pour la documentation de la fonction de “server”, nous ajoutons un paramètre r à notre module.

mod_select_view_server <- function(input, output, session, r){
  ns <- session$ns
  
  observe({
   colonnes <- names(r$dataset)
   updateSelectInput( session, "colonnes", choices = colonnes)
  })

  data <- reactive({
    req(input$colonnes)
    r$dataset[, input$colonnes]
  })
  output$table <- renderTable({
    head(data())
  })
}

Notre fonction mod_select_view_server prend maintenant un paramètre r qui sera la “reactiveValues” inter-modules. À l’intérieur de l’application Shiny, on peut utiliser le contenu de r$dataset, c’est-à-dire le jeu de données choisis par l’utilisateur.

Appel du module dans l’app générale

Au niveau de l’UI général (“app_ui”), il est nécessaire d’ajouter la fonction mod_select_view_ui.

app_ui <- function() {
  tagList(
    # Leave this function for adding external resources
    golem_add_external_resources(),
    # List the first level UI elements here 
    fluidPage(
      h1("appdemo"),
      selectInput("dataset", "Choose a dataset:",
              choices = c("rock", "pressure", "cars")),
      mod_select_view_ui("select")
    )
  )
}

Et du coté de l’“app_server”, le callModule associé :

app_server <- function(input, output,session) {
  # List the first level callModules here
      r <- reactiveValues()
      observe({
        r$dataset <- switch(input$dataset,
               "rock" = rock,
               "pressure" = pressure,
               "cars" = cars)
      })
      callModule(mod_select_view_server,id = "select",session = session, r = r)
}

Tous ça, pour ca ? C’est tout ? On connaît déjà ça. Il sert à rien ton article Cervan. Il faut du croquant !

Reste avec nous lecteur, la partie croustillante arrive !!!

Nous avons maintenant une app ou les modules peuvent communiquer entre eux. Je vais introduire maintenant un problème lié aux reactiveValues, l’actualisation des listes dans les “reactiveValues”.

Les caprices des reactiveValues

Dans l’exemple précédent, nous détaillons comment utiliser la “stratégie du petit r”. C’est à ce moment que les choses commencent à devenir interéssantes.

Voici un morceau de code qui a été la cause de certains problèmes dans des applications :

library(shiny)
ui <- fluidPage(
  actionButton("pierre",label = "pierre"),
  actionButton("henri",label = "henri")
)
server <- function(input, output, session) {
  r <- reactiveValues()
  observe({
    r$test$pierre <- input$pierre
  })
  observe({
    r$test$henri <- input$henri
  })
  observeEvent(r$test$pierre,{
    message("Normalement, je clique sur pierre!")
  })
}
shinyApp(ui, server)

Si vous cliquez une première fois sur “pierre” et ensuite seulement sur “henri”, vous verez que le message “Normalement, je clique sur pierre!” est présent pour chaque clic sur “henri” dans votre console.

Autrement dit, la réactivité d’une liste dans une reactiveValues est propagée si l’un des éléments de cette liste change.

Vous imaginez donc l’impact que cela peut avoir sur la communication entre module et la possibilité d’avoir du code relancé plusieurs fois.

Avec ce morceaux de code, c’est encore plus flagrant :

library(shiny)
ui <- fluidPage(
  actionButton("pierre",label = "pierre"),
  actionButton("henri",label = "henri"),
  textOutput("text")
)
server <- function(input, output, session) {
  r <- reactiveValues()
  observe({
    r$test$pierre <- input$pierre
  })
  observe({
    r$test$henri <- input$henri
  })
  observeEvent(r$test$pierre,{
    message("Pourtant j'ai cliqué sur henri....")
  })
  output$text <- renderText({
    time <- Sys.time()
    Sys.sleep(5)
    paste0(r$test$pierre," ",time)
  })
}
shinyApp(ui, server)

Ce problème se pose pour chaque élement à un même niveau de sous liste sauf pour le premier. Autrement dit, ici on n’a pas de problème avec ce code :

library(shiny)
ui <- fluidPage(
  actionButton("pierre",label = "pierre"),
  actionButton("henri",label = "henri"),
  textOutput("text")
)
server <- function(input, output, session) {
  r <- reactiveValues()
  observe({
    r$pierre <- input$pierre
  })
  observe({
    r$henri <- input$henri
  })
  observeEvent(r$pierre,{
    message("Pourtant j'ai cliqué sur henri....")
  })
  output$text <- renderText({
    time <- Sys.time()
    Sys.sleep(5)
    paste0(r$pierre," ",time)
  })
}
shinyApp(ui, server)

Dans le premier exemple, les résultats de nos boutons étaient stockés dans r$test donc on avait une liste dans une reactiveValues et c’est bien le problème. Dans le deuxième exemple, nous avons stocké directement les boutons à la racine de la reactiveValues, r$pierre et r$henri. On pourrait alors ne pas faire de sous liste dans une reactiveValues. Cependant, ce n’est pas pratique. Si nous avons plusieurs modules qui stockent des résultats dans cette reactiveValues, nous allons être perdus sur l’origine des valeurs. Pas facile à déboguer…

Comment corriger ce problème qui pourrait avoir des impacts important dans la communication entre modules ?

La stratégie du petit r, oui mais avec des reactiveValues nested

Il va falloir utiliser les ‘nested’ reactiveValues ! Euh, ça sort de votre chapeau ça ? Oui. Un peu. Mais c’est pas vraiment compliqué.

Etant donné que pour chaque premier élement d’une reactiveValues le problème ne se pose pas, on va donc avoir :

r <- reactiveValues(
    test = reactiveValues()
)

Dans ce cas, le problème est résolu et cliquer sur “henri” ne déclenche pas “pierre”!

Exemple :

library(shiny)
ui <- fluidPage(
  actionButton("pierre",label = "pierre"),
  actionButton("henri",label = "henri"),
  textOutput("text")
)
server <- function(input, output, session) {
  r <- reactiveValues(
    test = reactiveValues()
  )
  observe({
    r$test$pierre <- input$pierre
  })
  observe({
    r$test$henri <- input$henri
  })
  observeEvent(r$test$pierre,{
    message("Pourtant j'ai cliqué sur henri....")
  })
  output$text <- renderText({
    time <- Sys.time()
    Sys.sleep(5)
    paste0(r$test$pierre," ",time)
  })
}
shinyApp(ui, server)

Voilà, c’est tout. Un grand mot pour une petite modification qui change tout !

Les avantages et inconvénients de la stratégie

Nous avons exploré d’autres possibilités pour la communication entre modules, mais elle se fait souvent au détriment de la lisibilité du code.

  • (+) Un avantage de cette stratégie est que les données ne sont pas copiées plusieurs fois dans la RAM.
  • (+) L’arboresence entre module est facile à identifier et on peut retrouver facilement d’où vient telle ou telle variable.
  • (-) Un inconvénient est que notre module dépend maintenant de ce r$dataset. Si on souhaite le réutiliser dans une autre application, il faudra un r$dataset. Même si, chez ThinkR, nous développons les modules Shiny comme s’ils devaient être indépendants, la réalité est que les modules réutilisables ne représentent qu’une petite part des modules développés. Cette contrainte n’est donc pas très importante. Par ailleurs, si le module devait être réutilisé dans une autre application, la documentation qui lui est associée doit indiquer cette contrainte, de la même manière qu’une fonction classique contraint l’utilisateur à un format de données ou de paramètre, ou de choix de paramètre en entrée.

Aller plus loin

Envie d ’en savoir plus sur {golem} et nos recommandations autour du développement d’application shiny :


À propos de l'auteur

Cervan Girard

Cervan Girard

Data scientist spécialiste du jonglage avec les serveurs et docker



Also read