shinyApp(), runApp(), shinyAppDir(), et une quatrième option

Author : Colin Fay
Categories : shiny
Tags : golem
Date :
Commençons par un peu de contexte : tout a commencé avec cette issue  sur le package {golem}, reflétant une discussion que nous avons eue précédemment au sein de l’équipe. Aussi, il y a deux semaines, j’ai reçu un tweet sur le même sujet. Sujet qui peut être résumé comme tel : « Devrions-nous utiliser shinyApp() ou runApp() lors du déploiement en production ? »  
{golem} possède une fonction appelée run_app(), qui, dans son implémentation originale par Vincent, repose sur l’appel de runApp() sur les deux fichiers contenus dans le dossier inst/. Mais voici le problème — runApp() ne peut pas être utilisé avec les produits RStudio (Shiny Server, Connect & ; Shinyapps.io), car il renvoie une erreur Can't call runApp() from within runApp(). Alors, pourquoi utilier runApp() ? Vous verrez dans une minute pourquoi on avait choisit cette fonction 🙂 Mais, TL;DR, ces trois fonctions n’ont pas le même comportement, selon l’endroit où elles sont utilisées :
  • runApp() ne fonctionne pas sur les produits RStudio, mais c’est la seule façon pour Docker et les appels locaux d’accéder aux options passées à la fonction run_app().
  • Aucun produit RStudio ne gère pas les options « locales » utilisées dans le run_app(), selon notre implémentation originale.
  • {golem} possède aujourd’hui sa propre implémentation de lancement d’application, pour pouvoir marcher partout !

Il y a plusieurs façons de lancer une application Shiny App

  1. runApp() (implémentation de départ de {golem}), qui est un wrapper autourshiny::runApp(system.file("app", package = "aaaaaaa")).
  2. shinyApp(), qui est shiny::shinyApp(ui = app_ui(), server = app_server), la solution créée par l’ancienne version degolem::add_rstudioconnect_file() et friends.
  3. shinyAppDir(), qui est shinyAppDir( system.file("app", package = "aaaaaaa") ) – une solution nécessaire pour Shiny server si vous voulez appeler le dossier app/ via {golem}.
  4. La version actuelle avec {golem}.
Notes : il existe aussi shinyAppFile(), mais son comportement interne est le même que shinyAppDir(), donc il n’est pas comparé ici.

Une implémentation naïve

Donc, ce que nous pourrions faire, c’est de simplement laisser ouvert pour l’utilisateur final, de sorte que c’est à lui de choisir la meilleure implémentation pour son cas d’utilisation de déploiement. En faisant quelque chose comme :
run_app <- fonction(
  with = c("shinyApp", "runApp", "shinyAppDir")
) {
  avec <- match.arg(with)
  if (with == "shinyApp"){
    shiny::shinyApp(ui = app_ui(), server = app_server)
  } else if (with == "runApp") {
    shiny::runApp(system.file("app", package = "aaaaa"))
  } else if (with == "shinyAppDir") {
    shiny::shinyAppDir(system.file("app", package = "aaaaa"))
  }
)
C’est peut-être la meilleure réponse car elle laisse le choix à l’utilisateur, mais la question reste ouverte : quelle fonction dois-je utiliser pour mon déploiement ? Nous allons conserver cette fonction et l’utiliser pour notre benchmark.

Side node

Si je me réfère à ?shinyApp :
You generally shouldn’t need to use these functions to create/run applications; they are intended for interoperability purposes. https://shiny.rstudio.com/reference/shiny/1.3.2/shinyApp.html
Donc, d’après la doc, nous ne devrions jamais (ou presque) appeler shinyApp() directement, et utiliser seulement runApp() à la place. Mais utiliser runApp() est impossible avec les plates-formes RStudio, car elles impriment une erreur qui ressemble à ceci :
Loading aaaa
Error in shiny::runApp(system.file("app", package = "aaaa")) :
  Can't call `runApp()` from within `runApp()`. If your application code contains `runApp()`, please remove it.
Calls: runApp ... eval -> eval -> ..stacktraceon.. -> run_app -> <Anonymous>

Side note 2

J’ai essayé de rendre ce benchmark aussi reproductible que possible, donc n’hésitez pas à l’exécuter et voir si vous obtenez les mêmes résultats 🙂 Le paquet est nommé « aaaaa » (donc il n’entrera en conflit avec aucun autre paquet (j’espère), et peut être trouvé ici. Il contient le squelette du golem avec les fonctions listées ci-dessous.

Recherche de la meilleure implémentation

Essayons de trouver la meilleure implémentation. L’idée est que notre implémentation de run_app() devrait :
  • Marcher sur le nombre maximum de services (local + Docker + RStudio), et ce avec un minimum d’ajustements (une implémentation pour les régler tous serait préférable).
  • Pour pouvoir lire les options de l’environnement global, par exemple on peut utiliser la variable globale golem.app.prod depuis le serveur ou l’interface utilisateur.
  • Pouvoir lire des options locales, passées à run_app().

Conditions de benchmark

  • Nouveau golem avec la version précédent (0.0.1.700)

Contenu de la fonction app_ui:

app_ui <- fonction() {)
  tagList(
    fluidPage(
      h1 ("aaaaa"), 
      h3("options globales :"), 
      verbatimTextOutput("global"),
      h3("options de fonction :"), 
      verbatimTextOutput("shinycall")
    )
  )

Contenu de la fonction app_server :

 
app_server <- fonction(entrée, sortie,session) {
  output$global <- renderPrint({ {
    # Options globales
    getOption('golem.pkg.name')
  })
  output$shinycall <- renderPrint({ { {
    # Options locales
    getOption('shinycall')
  })

Diverses options de run_app

Nous utiliserons cette fonction pour comparer les trois fonctions (shinyApp(), runApp() et shinyAppDir()) :
run_app <- fonction(
  avec = c("shinyApp", "runApp", "shinyAppDir")
) {
  with <- match.arg(with)
  # Ici, on définit les options locales pour pouvoir passer 
  # des arguments à la fonction run_app()
  options("shinycall" = avec)
  on.exit(
    options("shinycall" = NULL)
  )
  if (with == "shinyApp"){
    shiny::shinyApp(ui = app_ui(), server = app_server)
  else if (with == "runApp") {
    shiny::runApp(system.file("app", package = "aaaaa"))
  else {
    shiny::shinyAppDir(system.file("app", package = "aaaaa"))
  }

Dans nos résultats, runApp est:

run_app("runApp" )

shinyApp est :

run_app("shinyApp" )

shinyAppDir est

run_app("shinyAppDir" )

Contextes de lancement

Lancement local

Vous pouvez l’exécuter dans votre console, dans RStudio.
# Définir les options ici
options("golem.pkg.name" = "aaa")

# Détachez tous les paquets chargés et nettoyez votre environnement
golem::detach_all_attached()
# rm(list=ls(all.names = TRUE)))

# Documentez et rechargez votre colis
golem::document_et_reload()

# Exécutez l'application
aaaaa::run_app(avec = "runApp")

La Dockerfile locale est

FROM rocker/tidyverse:3.6.0
RUN R -e 'install.packages("remotes")'
RUN R -e 'remotes::install_github("r-lib/remotes", ref = "97bbf81")'
RUN R -e 'remotes::install_cran("shiny")'
COPY aaaa_*.tar.gz /app.tar.gz
RUN R -e 'remotes::install_local("/app.tar.gz")'
EXPOSE 80
CMD R -e "options('shiny.port'=1234,shiny.host='0.0.0.0', 'golem.pkg.name' = 'aaa');aaaa::run_app( 'runApp' )" # also with shinyApp & shinyAppDir
Vous pouvez trouver ce Dockerfile dans le inst/dockerfilelocal dossier du repo golem4bench repo. Avant de le lancer, vous devez documenter, et compiler votre paquet avec devtools::build(chemin = "inst/dockerfilelocal/"). Pour que la chose entière puisse être lancée avec:
R -e "devtools::build(path = 'inst/dockerfilelocal/')" \
    && cd inst/dockerfilelocal/ \
    && docker build -t aaa . \
    && docker run --name aaaa -p 1234:1234 -d aaa \
    && sleep 2 \
    && open http://0.0.0.0:1234 
Ensuite, restez dans le dossier, changez le "runApp"arg dans le Dockerfile en "shinyApp", rebuild et relancez depuis la ligne docker build. Puis avec "shinyAppDir". Bien sûr, n’oubliez pas de docker kill aaa & docker rm aaaa entre chaque itération.

Produits RStudio 1/2 : le script app.R

pkgload::load_all()
options("golem.pkg.name" = "aaaa"))
run_app( "runApp" ) # aussi avec shinyApp & ; shinyAppDir
Chaque trois versions (c’est-à-dire les trois versions de run_app()) de ce fichier seront déployées sur :
  • un Shiny Server local (via Docker)
  • Le RStudio Connect de ThinkR (envoyé avec rsconnect::deployApp())
  • Le compte shinyapps de ThinkR (envoyé avec rsconnect::deployApp())
Ce fichier doit être placé à la racine de votre paquet.

Produits RStudio 2/2 : Configurer un serveur Shiny pour tester

Ce Dockerfile se trouve dans le dossier inst/dockerfileshinyserver du paquet.
FROM rocker/shiny:3.6.0
RUN R -e 'install.packages("remotes")'
RUN R -e 'remotes::install_github("r-lib/remotes", ref = "97bbf81")'
RUN R -e 'remotes::install_cran("shiny")'
RUN apt-get update && apt-get install libssl-dev libxml2-dev -y
RUN R -e 'remotes::install_cran("attachment")'
RUN R -e 'remotes::install_github("thinkr-open/golem")'
COPY . /srv/shiny-server/aaaa
RUN cd /srv/shiny-server/aaaa && R -e "attachment::install_from_description()"
Depuis la racine du package:
mv inst/dockerfileshinyserver/Dockerfile Dockerfile \
    && docker build -t plop . \
    && docker run --name plop -p 3838:3838 -d plop \
    && sleep 2 \
    && open http://0.0.0.0:3838/aaaa
Ensuite, changez pour "shinyApp" et "shinyAppDir" dans le fichier app.R, puis relancez le docker build. N’oubliez pas de kill & rm entre chaque itération, et de mvback la Dockerfile à sa place

Résultats

Les options globales sont celles définies en dehors de run_app(), les options locales sont celles définies dans le run_app(). 🚀 : l’application lance 💥 : l’application ne lance pas 📗 : les options globales sont lues ❌ : les options globales ne sont pas lues 📒 : les options fonction sont lues ⛔️ : les options fonction ne sont pas lues
Ou runApp shinyApp shinyAppDir
Local 🚀📗📒 🚀📗⛔️ 🚀📗⛔️
Docker 🚀📗📒 🚀📗⛔️ 🚀📗⛔️
Connect 💥❌ ⛔️ 🚀📗⛔️ 🚀📗⛔️
shinyApps.io 💥❌ ⛔️ 🚀📗⛔️ 🚀📗⛔️
ShinyServer 💥❌ ⛔️ 🚀📗⛔️ 🚀📗⛔️
Pour résumer :
  • Les conteneurs Docker ne lisent pas d’options locales des fonctions à moins d’être appelés avec runApp(). Ce que vous pouvez vérifier en utilisant n’importe quel terminal : R -e "options('shiny.port'=1234,shiny.host='0.0.0.0','golem.pkg.name' ='aaaa');aaaa::run_app('runApp')"
  • runApp() échoue sur les produits RStudio.
  • Les services RStudio n’ont pas d’options locales avec aucune solution (nous verrons dans une minute que c’est parce que nous ne pouvons pas utiliser runApp()).

Où allons-nous à partir de là ?

Alors, pourquoi ces différents comportements ? En fait, c’est à cause de ce que shinyApp() et shinyAppDir()retournent, comparés à runApp(). Si nous regardons le code source de shinyApp(), notre dernière ligne de code ressemble à ceci:
> shiny::shinyApp
function (ui = NULL, server = NULL, onStart = NULL, options = list(), 
    uiPattern = "/", enableBookmarking = NULL) 
{
 
 [...]

    structure(list(httpHandler = httpHandler, serverFuncSource = serverFuncSource, 
        onStart = onStart, options = options, appOptions = appOptions), 
        class = "shiny.appobj")
}
<bytecode: 0x7fb1714fa6d0>
<environment: namespace:shiny>
Nous pouvons voir que la dernière chose retournée par la fonction est une structure de classe shiny.appobj, alors que le runApp() renvoie un processus lancé. Ainsi le « lancement » de l’application avec shinyApp() n’est pas le même que celui de runApp()– le premier renvoie un objet, le second renvoie un processus. En fait, le lancement de l’application avec shinyApp() se fait avec print.shiny.appobj. C’est pourquoi si vous faites a <- shinyAppDir(appDir = "inst/app/"), vous ne pourrez pas lancer l’application tant que vous n’aurez pas essayé d’imprimer a. Ce qui explique aussi pourquoi les options locales (définies dans la fonction) ne sont pas lues : avec shinyApp(), la fonction renvoie un objet, donc la fonction est terminée, et les options définies ne sont plus accessibles. Pourquoi est-ce une bonne nouvelle ? Regardons shiny:::::print.shiny.appobj:
> shiny:::print.shiny.appobj
function (x, ...) 
{
    opts <- x$options %OR% list()
    opts <- opts[names(opts) %in% c("port", "launch.browser", 
        "host", "quiet", "display.mode", "test.mode")]
    args <- c(list(quote(x)), opts)
    do.call("runApp", args)
}
<bytecode: 0x7fb16e2809f8>
<environment: namespace:shiny>
Donc ici la chose cool est que l’on peut hacker le x passé à la méthode print pour ajouter golem.options à l’intérieur, c’est-à-dire dans le appOptions de l’objet app. Et donc :
with_golem_options <- function(app, golem_opts){
   app$appOptions$golem_options <- golem_opts
   app
}
et
run_app <- function(...) {
    with_golem_options(
        app = shinyApp(ui = app_ui(), server = app_server), 
        golem_opts = list(...)
    )
}
Soit, dans une app entière :
library(shiny)

options("golem.app.name" = "aaa")

get_golem_options <- function(which = NULL){
    if (is.null(which)){
        getShinyOption("golem_options")
    } else {
        getShinyOption("golem_options")[[which]]
    }
}

with_golem_options <- function(app, golem_opts){
   app$appOptions$golem_options <- golem_opts
   app
}

app_ui <- function() {
    tagList(
        fluidPage(
            verbatimTextOutput("all"),
            verbatimTextOutput("opt"), 
            verbatimTextOutput("glob")
        )
    )
}


app_server <- function(input, output,session) {
    
    output$all <- renderPrint({ get_golem_options() })
    output$opt <- renderPrint({ get_golem_options("a") })
    output$glob <- renderPrint({ getOption("golem.app.name") })
}

run_app <- function(...) {
    with_golem_options(
        app = shinyApp(ui = app_ui, server = app_server), 
        golem_opts = list(...)
    )
}

run_app(a = "pouet", b = "bing")
Et ça marche partout 🤘 Donc grâce à ce petit hack, vous pouvez maintenant utiliser la fonction run_app() de {golem} partout. Et maintenant, quand vous construisez votre package, vous pouvez utiliser des arguments avec la fonction run_app(), et les utiliser avec get_golem_options(). Les options globales sont, comme d’habitude, disponibles avec getOptions(). L’autre hack un peu cool? Lorsque vous générez un fichier app.R pour les produits RStudio avec golem::add_rstudioconnect_file, golem::add_shinyappsio_file,  ou golem::add_shinyserver_file, on a laissé trainer un petit ShinyApp en commentaire, ce qui fait que vous pouvez utiliser le fameux bouton bleu 😉 Session info de tous les tests sur GitHub.

Comments


Also read