Développer une app Shiny dans un package

Une Shiny App dans un Package

Dans un article précédent, j’ai présenté{golem}, qui est un framework pour la création d’applications Shiny prêtes à partir en production. Ce cadre commence par la création d’un squelette de package en attente d’être rempli.

Mais, dans un monde où les applications Shiny sont le plus souvent créées sous la forme d’une série de fichiers, pourquoi se soucier de créer un package ? C’est la question sur laquelle je vais me concentrer dans ce billet.

Qu’y a-t-il dans une application Shiny ?

OK, posons la question dans l’autre sens. Pensez à votre dernière Shiny qui a été créé sous la forme d’un fichier unique (app.R) ou de deux fichiers app (ui.R et server.R). Nous avons donc ces deux-là, mis dans un dossier.

Passons donc en revue ce dont vous aurez besoin pour une application robuste.

Tout d’abord, les métadonnées. En d’autres termes, le nom de l’application, le numéro de version (qui est crucial pour tout projet sérieux de production), ce que fait l’application, à qui s’adresser si quelque chose tourne mal.

Ensuite, vous devez trouver un moyen de gérer les dépendances. Parce que vous savez, quand vous voulez pousser votre application en production, vous ne pouvez pas avoir cette conversation avec l’IT :

IT : Hey, j’ai essayé de ‘source(« app.R »)’ mais j’ai une erreur.

R-dev : Quelle est l’erreur ?

IT : Il est écrit « could not find package ‘shiny' ».

R-dev : Ah oui, vous devez installer {shiny}. Essayez d’exécuter ‘install.packages(« shiny »)’.

IT : D’accord, c’est bien. Quoi d’autre ?

R-dev : Laissez-moi réfléchir, essayez aussi ‘install.packages(« DT »)’ ? Essayez maintenant ‘install.packages(« ggplot2 »)’, et ….

[…]

IT : Ok, maintenant je source l’application ‘app.R’, c’est bien ça ?

R-dev : Bien sûr !

IT : Ok donc il est écrit ‘Impossible de trouver la fonction runApp()’.

R-dev : Ah, vous devez faire library(shiny) au début du script. Et library(purrr), et library(jsonlite)*.

* Ce qui conduira à un conflit de namespace sur la fonction flatten() qui peut vous causer des maux de tête de débogage. Donc, hé, ce serait cool si nous pouvions avoir une application Shiny qui n’importe que des fonctions spécifiques, non ?

Alors oui, les dépendances comptent. Vous devez les gérer, et les gérer correctement.

Maintenant, disons que vous construisez une grosse application. Quelque chose avec des milliers de lignes de code. Manipuler une application Shiny à un ou deux fichiers avec autant de lignes est un cauchemar. Alors, que faire ? Ah oui, eh bien divisons le tout en fichiers plus petits que nous pouvons sourcer !

Enfin, nous voulons que notre application vive longue et prospère, ce qui signifie que nous devons la documenter. Chaque petit morceau de code devrait avoir un commentaire pour expliquer ce que font ses lignes. L’autre chose dont nous avons besoin pour que notre application soit couronnée de succès à long terme, ce sont des tests, de sorte que nous sommes sûrs de ne pas introduire de régression.

Oh, et ce serait bien si les gens pouvaient obtenir un tar.gz et l’installer sur leur ordinateur et avoir accès à une copie locale de l’application !

OK, résumons donc : nous voulons construire une application. Cette application doit avoir metadata et gérer dépendances correctement, ce qui est ce que vous obtenez avec les fichiers DESCRIPTION et NAMESPACE du package. Encore plus pratique est le fait que vous pouvez faire une « extraction sélective des namespaces » à l’intérieur d’un paquet, c’est-à-dire que vous pouvez dire « Je veux cette fonction de ce package ». De plus, cette application doit être divisée en fichiers .R plus petits, ce qui est la façon dont un package est organisé. Et je n’ai pas besoin d’insister sur le fait que la documentation est une partie vitale de tout package, nous avons donc résolu cette question aussi. Il en va de même pour la boîte à outils de test. Et bien sûr, le souhait d' »installer partout » prend vie lorsqu’une application Shiny est dans un paquet.

Les autres points positifs d’une « Shiny App as a Package »

Les tests

Rien ne devrait être mis en production sans avoir été testé. Tester des applications de production est une vaste question, et je vais m’en tenir aux tests à l’intérieur d’un package ici.

Les frameworks pour le test des packages sont robustes et largement documentés. Vous n’avez donc pas besoin de faire un effort supplémentaire ici : utilisez simplement un cadre canonique comme{testthat}. L’apprentissage de son utilisation n’est pas l’objet de ce billet de blog, n’hésitez donc pas à vous référer à la documentation. Voir aussi le chapitre 5 de « Building a Package that lasts ».

Que devriez-vous tester ?

  • Tout d’abord, comme nous l’avons déjà dit, l’application doit être partagée entre la partie interface utilisateur et la partie backend (ou ‘logique métier’). Ces fonctions d’arrière-plan sont censées fonctionner sans contexte interactif, tout comme les fonctions classiques. Donc pour ceux-là, vous pouvez faire des tests classiques. Comme il s’agit de fonctions back-end (si spécifiques à un projet), {golem} ne peut fournir aucune aide pour cela.
  • Pour la partie UI, n’oubliez pas que toute fonction UI est conçue pour retourner un élément HTML. Vous pouvez donc enregistrer un fichier au format HTML, puis le comparer à un objet de l’interface utilisateur avec la fonction golem::expect_html_equal().
library(shiny)
ui <- tagList(h1("Hello world!"))
htmltools::save_html(ui, "ui.html")
golem::expect_html_equal(ui, "ui.html")
# Changements
ui <- tagList(h2("Hello world!"))
golem::expect_html_equal(ui, "ui.html")

Ceci peut par exemple être utile si vous avez besoin de tester un module. Une fonction UI d’un module renvoie une liste de balises HTML, de sorte qu’une fois vos modules définis, vous pouvez les enregistrer et les utiliser dans les tests.

my_mod_ui <- function(id){
  ns <- NS("id")
  tagList(
    selectInput(ns("this"), "that", choices = LETTERS[1:4])
  )
}
my_mod_ui_test <- tempfile(fileext = "html")
htmltools::save_html(my_mod_ui("test"), my_mod_ui_test)
# Un peu plus tard, et bien sûr sauvegardé dans le dossier de test, 
# pas comme un fichier temporaire
golem::expect_html_equal(my_mod_ui("test"), my_mod_ui_test)

{golem} fournit également deux fonctions, expect_shinytag() et expect_shinytaglist(), qui testent si un objet est de classe "shiny.tag" ou "shiny.tag.list".

  • Lancement du package : lors du lancement de golem::use_recommended_tests(), vous trouverez un test construit sur {processx} qui permet de vérifier si l’application est lancable. Voici une brève description de ce qui se passe :
# Standard testthat things
context("launch")

library(processx)

testthat::test_that(
  "app launches",{
    # We're creating a new process that runs the app
    x <- process$new(
      "R", 
      c(
        "-e", 
        # As we are in the tests/testthat dir, we're moving 
        # two steps back before launching the whole package
        # and we try to launch the app
        "setwd('../../'); pkgload::load_all();run_app()"
      )
    )
    # We leave some time for the app to launch
    # Configure this according to your need
    Sys.sleep(5)
    # We check that the app is alive
    expect_true(x$is_alive())
    # We kill it
    x$kill()
  }
)

Note : cette configuration spécifique échouera peut-être sur une plate-forme d’intégration continue comme Gitlab CI ou Travis. Une solution de contournement consiste à, dans votre yaml de CI, installer d’abord le paquet avec remotes::install_local(), puis remplacer la commande setwd (...) run_app() par myuberapp::run_app().

Par exemple :

  • dans .gitlab-ci.yml:
test:
  stage: test
  script: 
  - echo "Running tests"
  - R -e 'remotes::install_local()'
  - R -e 'devtools::check()'
  • dans test-golem.R:
testthat::test_that(
  "app launches",{
    x <- process$new( 
      "R", 
      c(
        "-e", 
        "datuberapp::run_app()"
      )
    )
    Sys.sleep(5)
    expect_true(x$is_alive())
    x$kill()
  }
)

Déploiement

Déploiement local

Comme votre application Shiny est un paquet standard, elle peut être construite sous la forme d’un fichier tar.gz, envoyé à vos collègues, amis et famille, et même au CRAN. Il peut également être installé dans n’importe quel gestionnaire de paquets R. Ensuite, si vous avez construit votre application avec {golem}, vous n’aurez plus qu’à faire :

library(myuberapp)
run_app()

pour lancer votre application.

RStudio Connect & Shiny Server

Ces deux plates-formes attendent une configuration d’application en fichier, c’est-à-dire un fichier app.R ou deux fichiers ui.R / server.R. Alors comment intégrer cette « Shiny App as Package » dans Connect ou Shiny Server ?

  • En utilisant un gestionnaire de packages interne commeRStudio Package Manager, où le package de l’application est installé, et ensuite vous n’avez qu’à créer un app.R avec le petit morceau de code de la section .
  • Envoyer le dossier du package sur le serveur. Dans ce scénario, vous utilisez le dossier package comme dossier de l’application, et vous téléchargez le tout sur le serveur. Ensuite, un app.R qui fait :
pkgload::load_all()
shiny::shinyApp(ui = app_ui(), server = app_server)

Et bien sûr, n’oubliez pas d’ajouter ce fichier dans le fichier .Rbuildignore !

C’est le fichier que vous obtiendrez si vous exécutez golem::add_rconnect_file().

Dockers

Pour dockériser votre application, installez simplement le package comme n’importe quel autre package, et utilisez comme CMD R -e'options("shiny.port"=80,shiny.host="0.0.0.0.0");myuberapp::run_app()'. Bien sûr, changer le port pour celui dont vous avez besoin.

Vous obtiendrez la Dockerfile dont vous avez besoin avec golem::add_dockerfile().

Ressources pour développer un package