Quelle surface d’impact pour une architecture multi-tenant, en pratique

Quelle surface d’impact pour une architecture multi-tenant, en pratique ?

Avec l’hégémonie annoncée du cloud computing, on voit de plus en plus d’architectures logicielles migrer vers ces infrastructures dont les défis ne se limitent finalement pas aux principes du manifeste 12-Factor Apps. Bien sûr, une application dite “cloud-native” se doit d’être modulaire, adaptable, résiliente, déployée en continu et supervisée… j’en oublie sans doute. Pourtant une dimension supplémentaire, presque cachée mais tributaire des caractéristiques déjà énoncées, s’impose bien au marché moderne des applications.

Cette dimension du Something “as-a-Service” ne date pas d’hier, mais elle reste difficile à aborder concrètement pour des développements basés sur les concepts datant de l’âge de l’évolutivité verticale, où l’augmentation de la puissance matérielle suffisait à répondre à une demande croissante. La mise à l’échelle horizontale, elle, implique toujours une refonte profonde de la gestion des ressources, avec la distribution des états et la gestion de leurs transitions au sein de services tiers : databasedata-gridmessage broker. Toutes ces solutions requérant une grande rapidité d’exécution et de solides mécanismes de synchronisation.

L’étape suivante consistera à fournir un produit suffisamment robuste pour proposer à la demande une expérience utilisateur complètement personnalisée, et dans une infrastructure entièrement mutualisée, afin de bénéficier d’économies d’échelle en exploitation. Les architectures multi-tenant, ou multi-entités, ou multi-locataires, sont censées réaliser cette promesse. Et nous allons tenter de voir quels sont les défis spécifiques à cette attente :
Multi-Tenancy-1

  • L’assignation des utilisateurs aux “tenants”, évidemment
  • La gestion du cycle de vie des services et l’activation dynamique des fonctionnalités
  • Le cloisonnement des données et des ressources informatiques

Tenant assignment

Dans notre réflexion présente, le “tenant” correspond à un contrat de service qu’il convient d’honorer en fournissant les fonctionnalités attendues ; c’est un groupe d’utilisateurs qui bénéficient de cette prestation. Même si on peut anticiper un certain niveau d’activité dans le système, en fonction de la taille du groupe et de la nature de leur activité, il est toujours complexe de prévoir quelles seront les charges de travail réelles, et surtout leurs fluctuations à petite (journée, semaine) ou grande (mois, année) echelle.

Une première tentation est de déployer l’application dans un environnement complètement autonome (dedicatedisolated) – comme un _private cloud _ou un virtual private network, voire un _data center _à part – accessible uniquement par le client ciblé. Cette solution a l’avantage (non négligeable) de nous débarrasser purement et simplement de la problématique de multi-tenancy ! Cependant cela ouvre la voie à toute une série de tracasseries, telles que le refus possible d’évolutions par le client qui pourrait se montrer rétif à former les équipes à des fonctionnalités dont il n’aurait pas ressenti le besoin ; dans ce cas on prend le risque de devoir maintenir plusieurs versions parfois très éloignées les unes des autres. Ou encore le fait de devoir supporter le coût d’une infrastructure sous-utilisée ; ne serait-ce qu‘en dehors des heures de bureau, si les utilisateurs sont localisés dans le même fuseau horaire.

Alors, bien sûr, si l’on souhaite éviter au moins les deux écueils mentionnés plus haut, nous voudrions déployer une seule application pour tout le monde, avec une publication systématique des correctifs et des évolutions. Pour plus de sécurité, et une meilleure efficacité des équipes de développement, cette solution a bien des attraits. Et l’on doit choisir une stratégie de détermination du “tenant” correspondant à chaque utilisateur qui sera autorisé à accéder au système. Voici une liste des courses possible :

  • Un mécanisme de gestion de plusieurs fournisseurs d’identité (IdP)
    • Organisation de la correspondance des IdP avec les “tenants” gérés
  • Un mécanisme de filtrage des ressources au sein de chaque requête
    • Contextes de communication systématiquement contrôlés
    • Pools de connexion disponibles pour toutes les couches (data accessview, etc.) afin d’éviter la latence dans l’instanciation des accès aux ressources
  • Un mécanisme de facturation par rapport à la charge consommée (audit) ?

Quoi qu’il en soit, on ne doit jamais oublier que l’assignation doit se faire prioritairement au niveau requête (c’est-à-dire du _thread _en Java qui fournit la classe ThreadLocal, tandis que Spring MVC gère des portées de niveau “session” et “request”). Cette assignation se basera sur le mécanisme d’identification existant, qui constitue un premier filtrage.

Product life-cycle and feature toggles

Il n’est pas toujours possible de “vendre” toutes les fonctionnalités d’une application à tous les utilisateurs. Il arrive très fréquemment que ceux-ci n’emploient qu’une partie de ces fonctions pour leur activité quotidienne, et c’est même souvent souhaitable afin de distinguer entre les responsabilités de chacun. Et bien que dans la plupart des cas on soit capable de déterminer à la conception les différents profils d’utilisation, sous la forme de combinaisons de modules par exemple. On peut ainsi vouloir répartir la puissance du logiciel dans des offres distinctes, avec des facturations précises.

Des problèmes peuvent toutefois survenir, lorsque les fonctions optionnelles sont en réalité des développements personnalisés, pour répondre aux besoins spécifiques de tel ou tel client. Il n’est pas rare d’observer l’apparition d’équipes dédiées à ces problématiques, et à leur maintenance. Le pire étant le cas où une fonctionnalité d’origine est détournée de son but, pour élargir son champ d’action ou carrément en dévier sans espoir de réconciliation.

  • Mise en place d’un cycle de déploiement unique
  • Adaptation de la gestion des clients pour tenir compte de spécificités (c’est une logique de groupe, qui peut vite dériver vers une logique de profil, voire individuelle)
    • Gestion des configurations (paramétrage des interfaces, par exemple)
  • Gestion de la souscription aux fonctionnalités en option, avec éventuellement des offres dégressives en fonction des bouquets souscrits
    • Mise en place de rôles, ou prise en compte des groupes d’utilisateurs
    • Facturation à la souscription (date à date) ou à l’utilisation (temps, volume)

Comme il est relevé plus haut, on ne doit pas négliger la question du paramétrage propre à chaque “tenant”. Il est difficile d’imaginer ne pas fournir aujourd’hui (même dans les produits destinés au grand public) un degré minimum de personnalisation (organisation des données et des modules applicatifs, thème de présentation, catégories libellées, etc.) même s’il est facile d’externaliser au moins la gestion des accès.

Data security

Il est temps à présent de se pencher en profondeur sur la question cruciale du multi-tenancy, à savoir le cloisonnement des données. Ce sujet est sous-jacent à tous les autres aspects concernés. On a déjà dit qu’il reste possible, en marge d’une véritable stratégie de colocation, de déployer le système dans son propre environnement. Mais si cela n’est pas le cas, plusieurs options générales sont à envisager :

  1. Réservation d’une instance de base de données à part…
    • À multiplier par le nombre de systèmes différents nécessaires.
  2. Réservation d’un schéma dans une base de données commune
  3. Partage des tables elles-mêmes, avec discrimination des lignes par un champ dédié
    • Les techniques de partitionnement horizontal peuvent également servir à une séparation des données de bas niveau, pour des gains de performance.

Il est bien évident que la première option est la plus facile à mettre en oeuvre, mais elle constitue aussi un retour en arrière qui correspond à la stratégie d’évitement : on devra superviser chaque instance de base séparément et les surveiller comme le lait sur le feu. Dans le second cas, on a un compromis entre la souplesse d’administration et un cloisonnement clair ; par ailleurs, comme il n’y a pas vraiment de différence (en Java) entre un _datasource _sur une base et un schéma, c’est tout bénéfice pour nous dans ce cas.

La troisième option, quant à elle est… désastreuse, n’ayons pas peur des mots. En effet, le risque de ne pas complètement maîtriser les extractions des données peut provenir autant des erreurs de programmation que de jointures malheureuses… sans parler des menaces d’injection SQL ! Vous aurez suffisamment de sueurs froides comme ça, rien que dans le routage des messages, inutile de rajouter de la faiblesse à la complexité.

  • Cloisonnement du stockage des données
    • Séparation des schémas SGBDR, des partitions NoSQL
    • Utilisation de dépôts séparés pour le stockage de documents
  • Filtrage fort des messages émis dans le cadre des communications asynchrones
  • Identification claire du contexte des journaux d’exécution émis
  • Cloisonnement du traitement des données par lots, de la sauvegarde ?

J’attire votre attention sur le fait que le cloisonnement des données occulte parfois l’autre cloisonnement, celui des infrastructures, que nous allons traiter dans la section suivante.

Shared resources

Les bénéfices que nous avons évoqués jusqu’ici sont sans compter le fait qu’avec l’architecture _multi-tenancy _il devient possible de limiter le nombre de ressources actives dans l’infrastructure, et donc de réduire les coûts d’exploitation. En effet, avec une infrastructure partagée, on bénéficie du lissage de la charge, du fait d’horaires de travail potentiellement décalés entre les utilisateurs ; mais également des rythmes d’activité, nécessairement dépendants de tâches de natures diverses réparties dans le temps.

Attention cependant, à ne pas sacrifier la qualité du service dans le même temps. On doit trouver une façon d’empêcher que l’activité d’un groupe d’utilisateurs ne compromette celle des autres, en s’accaparant les ressources disponibles sans aucune contrainte. Il n’est pas simple du tout de parvenir à cet équilibre. On peut limiter les bandes passantes dans l’absolu, par exemple avec un nombre limité de connexions ou grâce au _pattern _“watchdog”, ou instaurer le streaming des flux de réponse (médias et listes), voire d’entrée (upload).

  • Limitation des _pools _de connexion aux ressources informatiques
  • Limitation du nombre de sessions actives, du nombre de requêtes simultanées…
  • Modulation des débits par le _streaming _des flux de données
  • Limitation des volumes de stockage
    • Système de surfacturation en cas de dépassement

Mais on préférera sans doute tenir compte de la charge totale du système. Après tout, pourquoi refuser une charge de travail, si elle peut être supportée ? Surtout si elle peut être facturée… On aura donc recours à la modulation des seuils, via une supervision globale.

Pour conclure

Sur toutes les logiques que nous avons vues, doit s’appliquer une politique stricte de supervision, qui doit permettre de contrôler (au moins a posteriori) le cloisonnement des données et la gestion optimale des flux de travail. On ne doit donc pas se cacher que des efforts particuliers de visibilité sont nécessaires à la réussite d’un tel projet.

Si une première étape, dans la migration d’applications “classiques” consiste à permettre une évolutivité à la fois fonctionnelle (mises à jour ciblée sur des services découplés) et en termes de performances (capacité à s’adapter aux fluctuations de charge), c’est une excellente chose, et déjà pas si simple à mettre en oeuvre. Mais une application SaaS doit également adresser une problématique de cloisonnement qui est cruciale. En effet, une offre “à la demande” doit être disponible immédiatement, en permanence (notamment sans arrêts de maintenance), tout en garantissant la sécurité des données impliquées. Elle doit aussi remplir un contrat de prestation de services, afin d’assurer aux utilisateurs des performances perçues fluides.

Tous ces objectifs ne sont atteignables qu’après avoir soigneusement envisagé les différentes options disponibles pour le(s) système(s) d’information et les infrastructures de déploiement (voir le schéma synthétique en introduction). Il s’agit donc d’une approche d’architecture globale qui doit être prise en compte dès la conception et le développement du logiciel.

Machine Learning Serverless : Détection d'objet dans une image

Machine Learning Serverless : Détection d’objet dans une image

Les modèles de machine learning sont de plus en plus utilisés dans nos applications car ils permettent de réaliser des tâches qui, jusque-là, étaient complexes voire impossibles à résoudre à l’aide d’algorithmes classiques. Ils sont au coeur de l’innovation dans les entreprises car ils permettent d’apporter une valeur nouvelle aux données. Pourtant, le passage d’un PoC (Proof of Concept) à une intégration dans une application en production ne se réalise pas facilement. Ainsi, une bonne partie des projets de machine learning n’arrivent jamais en production.

Comme reporté par le Gartner, jusqu’en 2019, 85% des projets IA resteront en état d’alchimie, et un des rôles du data engineer est justement d’aider les data scientists dans cette mise en production.

Les applications utilisant du machine learning se font en deux phases. Dans cet article je m’intéresserai particulièrement à la deuxième phase :

Quels choix pour l’infrastructure permettent l’application du modèle sur de nouvelles données ? Pourquoi ne pas utiliser des function as a service (FaaS) pour effectuer l’application du modèle ? Quelles en sont les limites ?

Dans un premier temps, je justifierai l’utilisation d’une telle infrastructure pour certains types d’applications. Dans un second temps, nous verrons comment mettre en place une telle infrastructure serverless avec une petite expérimentation : construire une fonction serverless dans AWS permettant la détection d’objets dans une photo (comme dans l’exemple ci-dessous par exemple), pour en exposer les limitations.

ptestTransfo-11.34.27

Du FaaS pour l’application de modèle ?

Un Data scientist est plus proche de la recherche, par conséquent son code n’est pas forcément adapté aux infrastructures de production.

Image empruntée à cet article.

Pour répondre à cette problématique, on utilise le plus souvent des orchestrateurs de conteneurs pour faire tourner le code dans des serveurs on-premise, ou bien même dans le cloud pour profiter de plus d’élasticité en fonction du besoin.

Ce choix est pertinent lors de l’entraînement car ce dernier peut se révéler long (plusieurs heures voire plusieurs jours). Cependant il l’est beaucoup moins pour la phase d’application du modèle. En effet, cette dernière est beaucoup plus rapide et peut être exercée de manière irrégulière pour certaines applications.

Les FaaS ou fonction serverless sont des fonctions qui permettent d’exécuter du code sans se préoccuper du provisionnement des serveurs et de ne payer que pour la durée d’exécution du code. Leur utilisation se révèle donc pertinente dans le cas de la phase d’application du modèle.

Passons maintenant à la pratique afin de découvrir les limites d’une telle infrastructure. Pour cette expérimentation je fais le choix d’utiliser le service de fonction serverless d’AWS : Lambda.

Expérimentation : choix de l’algorithme

Pour la détection d’objets, les modèles les plus efficaces sont les modèles de deep learning. Si vous n’avez aucune connaissance dans le domaine, je vous conseille de lire cette suite d’articles.

Pour un modèle de deep learning, le modèle sera constitué de l’ensemble des poids qui ont été ajustés sur le réseau de neurones. Il faut donc connaître la configuration du réseau (quelles sont les couches ou “layer” successives qui ont été appliquées) et les poids du modèle. Tous ces fichiers devront être accessibles pour mon code. Deux solutions s’offrent alors : ajouter les fichiers du modèle et de la configuration à l’archive du code de la Lambda ou alors aller le chercher systématiquement lorsqu’on en a besoin.

C’est la deuxième solution qu’il faut privilégier pour plusieurs raisons. Premièrement, la taille du code de la Lambda est limitée à 250 Mo (d’après la documentation). Deuxièmement, il serait intéressant de pouvoir changer de modèle à loisir et de le réentraîner sans avoir à redéployer le code.
Dans certains cas il sera plus intéressant de garder le modèle dans l’archive pour réduire les coûts et le temps de traitement. Mais il faudra alors utiliser un modèle assez léger pour respecter les limitations de taille de code de la Lambda.

Le but ici n’est pas de choisir absolument le tout dernier modèle en date mais un modèle qui a fait ses preuves dans la détection d’objets.

Après avoir parcouru plusieurs articles, je me frotte à une première limitation de la Lambda : le code de la Lambda (layer compris) ne doit pas dépasser la limite précédemment énoncée. Or dans la plupart des articles que je trouve, un nombre important de librairies python est utilisé pour les algorithmes de détections d’objets. Ces librairies sont lourdes car elles ne contiennent pas que le code d’application du modèle mais aussi celui d’entrainement du modèle et parfois même des modules de préparations des données.

Astuce : pour avoir une idée du poids des librairies dans votre environnement virtuel, utilisez la commande suivante (dans un terminal linux ou mac) :

pip show pip | grep -o 'Location.*' | cut -f2 -d: | { read LP; du -shc $LP; }

Finalement, je tombe sur l’article suivant qui n’utilise à priori qu’une seule librairie Python lourde pour appliquer le modèle : https://www.arunponnusamy.com/yolo-object-detection-opencv-python.html. Cette librairie est opencv, une bibliothèque graphique libre permettant le traitement d’images. La taille du module n’étant pas un problème, je décide, de suivre ce dernier. Le modèle utilisé s’appelle “YOLO” dont on peut récupérer la configuration et les poids du modèle ici.

Expérimentation : Implémentation de la Lambda

Tout ce qui a été réalisé en terme de code est disponible de manière libre sur le repository github suivant : https://github.com/tcastel-ippon/object-detection-serverless

Infrastructure

Voici l’infrastructure que nous allons mettre en place pour notre Lambda.

Le dépot d’une image sur notre compartiment de donnée (bucket) permet le déclenchement de notre fonction object_detection_lambda de manière evenementielle. Cette dernière récupère alors les fichiers de poids et de configuration du modèle afin de les appliquer grâce à l’algorithme. La fonction retourne alors l’image enrichie avec les labels des objets détectés, dans un autre dossier du compartiment de S3.

Image originale complétée par les objets détectés :

imagetransform

Mise en place du projet

Pour créer et déployer une infrastructure telle que présentée, avec une Lambda et des buckets S3, il est possible d’utiliser différents outils d’infrastructure as code (IaC) : TerraformCloudformation, ou encore Serverless Framework. Ce dernier est un framework qui permet de déployer rapidement des infrastructures serverless. Contrairement à Terraform, Serverless propose uniquement du code pour les services qui gravitent autour des fonctions serverless. Il est particulièrement adapté au déploiement d’architectures micro-services. Dans notre cas, on peut imaginer l’intégration de ce service dans une telle architecture.

Pour commencer un projet avec le provider cloud AWS et en Python :

serverless create --template aws-python3

Pour déployer la Lambda sur votre compte AWS il faudra au préalable configurer vos accès par programmation pour le framework serverless.

Dans le fichier serverless.yml, on peut voir la configuration de la Lambda. C’est ici que nous pouvons définir toute l’infrastructure qui y sera associée. Il est notamment possible d’y spécifier les triggers de la Lambda (le déclencheur ici S3), le runtime (langage et version utilisés), ainsi que le handler qui est la fonction invoquée lorsqu’un événement déclenche la Lambda. Il est donc important de garder une cohérence entre ce fichier de configuration et l’organisation du code. Dans mon cas, je décide d’appeler le module de la fonction lambda_function et le handler lambda_handler.

J’organise ensuite mon arborescence de la manière suivante :

    object-detection-serverless/
                    	├─ modules/
                         	├─ object-detection
                            └─ Tests
                        ├─ lambda_function.py
                    	├─ serverless.yml/                  
                    	└─ gitlab-ci.yml/


Le dossier module contient le module de détection de la Lambda qui est une refactorisation du code proposé dans l’article précédent afin de s’abstraire du traitement des données. Il n’a besoin pour fonctionner que des fichiers de poids et de configuration placés dans le bucket.

Le code de la fonction Lambda ne fait qu’un simple appel au module d’object-detection, fait la gestion d’événements et renvoie les données traitées.

Déploiement

Lorsque vous déployez votre code, il faut rendre disponibles les bibliothèques dont dépend votre code. Dans notre cas il n’y a que la librairie opencv qui est nécessaire. Les Lambdas bénéficient d’un système de “layer” ou couche en français. Ces couches peuvent être utilisées pour importer les librairies nécessaires au bon fonctionnement du code. Elles sont ensuite utilisables pour d’autres Lambdas, d’autres comptes et peuvent même être rendues publiques. Ceci permet aussi de restreindre la taille du code de base de votre lambda et par conséquent d’afficher ce code dans votre console AWS, ce qui permet de faciliter un débogage lors de l’intégration.

Une méthode possible est d’utiliser une layer partagée par un membre de la communauté open source. Ce dernier peut avoir partagé la référence de ces layers dans un github. Dans le cadre d’un MVP, il est tout à fait possible d’utiliser ces arn pour gagner du temps. Pour cette expérimentation j’ai par exemple utilisé les layers publiés par Keith Rozario dans son projet open source KLayers.

Il est cependant préférable dans l’industrialisation de la solution de mettre en place une construction des layers from scratch. Il faut alors prendre en compte que certaines librairies utilisent des fonctions natives de l’OS, par conséquent le build de la layer doit se faire dans l’environnement d’exécution du code, ici pour une Lambda en python c’est une AMI Linux ou AMI Linux 2 selon le runtime (voir la documentation). Plusieurs solutions sont alors possibles : l’utilisation d’une VM ou d’un docker lors de la création du package.

Attention : Si vous déployez vos Lambdas ou vos layer(s) à la main il se peut que vous soyez bloqués par une des limites de la Lambda. L’archive (zip) de la Lambda que vous déployez ne doit pas dépasser 50 Mo, comme spécifié dans la documentation.

Sachez que ceci est une “soft limit”, la seule “hard limit” concerne la taille du code dézippé qui est de 250 Mo. Voici un article vous indiquant une des façons de contourner cette “soft limit” avec un code compressé supérieur à 50 Mo.

Pour rappel, le code de cette expérimentation est disponible sur le github suivant : https://github.com/tcastel-ippon/object-detection-serverless

Conclusion

Au travers de mes missions, j’ai eu l’occasion de travailler sur la mise en place du service Fargate (service de container AWS serverless) pour déclencher l’application de modèles de machine learning. L’utilisation de containers rend l’opération plus lente que l’utilisation d’une lambda puisqu’il faut charger l’image docker et lancer le container. L’utilisation d’une lambda par rapport au service Fargate, lorsque c’est possible, constitue donc un gain de temps et d’argent lors de la phase d’application du modèle. Pour l’entraînement des modèles, les orchestrateurs de conteneurs restent une meilleure solution que les fonctions serverless (qui ne sont ici pas du tout adaptées du fait d’une exécution dépassant généralement les 15 minutes).

La viabilité d’une telle solution est grandement dépendante du travail des data scientists. S’ils ont multiplié l’utilisation de librairies lourdes, la mise en place dans une Lambda sera plus compliquée.

Les deux autres principaux providers de cloud, Microsoft Azure et Google Cloud Platform ont aussi des services de fonctions serverless, respectivement, Azure functions et Cloud functions. Pour les Cloud functions, les limites sont proches de celles des fonctions Lambda d’Amazon. À noter que la limite de taille de code pour les Cloud functions est deux fois supérieure à celle proposée par AWS ce qui permet sûrement d’appliquer cette méthode dans un plus grand nombre de cas.

Valider sa certification Google Cloud Associate en 8 semaines

Valider sa certification Google Cloud Associate en 8 semaines

Aujourd’hui je vais vous expliquer comment j’ai obtenu la certification Google Cloud Associate en 8 semaines. Pour cela, nous allons avoir besoin de différentes ressources pour préparer l’examen et d’une stratégie pour étudier. Mais revenons d’abord sur les attentes de l’examen afin d’orienter au mieux notre apprentissage.

Comment se passe l’examen ?

Durant la certification Google Cloud Associate, vous allez avoir un questionnaire numérique de 50 questions à répondre en 2h maximum. Vous avez donc environ 2 min par question. Par ailleurs, il y a un système pour revenir sur les réponses incertaines une fois le questionnaire terminé. Il faut prévoir au moins une dizaine de minutes pour avoir le temps de revenir dessus.

Si vous souhaitez connaître le détail des items évalués, vous avez toutes les informations sur la page de l’exam guide.

Les ressources que j’ai utilisées

Pour commencer :

Pour avoir une première approche du sujet, je trouve que le cours en ligne de Coursera “Preparing for the Google Cloud Associate Cloud Engineer Exam” est un bon début. Les vidéos manquent un peu de dynamisme mais permettent de comprendre facilement les bases de Google Cloud Platform (GCP). De plus, le programme inclut des exercices pratiques à réaliser sur Qwiklabs. C’est, selon moi, l’élément le plus intéressant de la formation sur Coursera.

Pour ceux qui connaissent Amazon Web Service (AWS), Google met à disposition une documentation très détaillée, service par service, des différences entre AWS et GCP.

Le temps à passer dessus est de 4 semaines si on y consacre environ 4h par semaine. C’est un bon rythme pour assimiler les informations et ne pas s’éterniser sur Coursera qui n’apporte que les bases.

Pour approfondir :

Par contre, se limiter à Coursera n’est pas suffisant pour valider la certification de mon point de vue. Comme je le disais juste au-dessus, c’est bien pour avoir des bases. Pour aller plus loin dans votre apprentissage, je vous conseille l’excellent livre “Official Google Cloud Certified Associate Cloud Engineer Study Guide” de Dan Sullivan. Il vous permettra vraiment d’avoir une meilleure compréhension des différents services de GCP à connaître pour l’examen. C’est ce livre qui m’a le plus aidé pour avoir la certification.

Le temps à passer dessus est de 3 semaines et demie si on lit 5 chapitres par semaine. Il faut compter environ 1h pour lire un chapitre.

Pour s’entraîner :

Cependant, étudier et tester les services de GCP, ce n’est toujours pas suffisant pour la certification… Il faut aussi s’entraîner aux questions qui sont posées à l’examen. Pour cela, il existe des examens blancs. Voici les 3 avec lesquels je me suis préparé :

  • Celui de Google : c’est le moins pertinent, les questions sont beaucoup trop faciles, il n’a que peu d’intérêts pour se préparer à l’examen (après si vous n’avez pas presque tout bon, c’est qu’il y a encore du boulot ^^)
  • Celui de Whizlabs : il est mieux que celui de Google mais ça n’est pas encore le top. Faire une fois la version gratuite de l’examen blanc, c’est suffisant.
  • Ceux à la fin de chaque chapitre du livre “Official Google Cloud Certified Associate Cloud Engineer Study Guide” : je n’ai pas réussi à trouver mieux ! Les questions sont très ressemblantes à celles que nous pouvons avoir à l’examen.

Le temps à passer dessus est d’environ 20h (18h pour les questions du livre et 2h pour les 2 autres examens blancs)

Ma stratégie d’apprentissage

Mon premier objectif a été de comprendre ce qu’est GCP et de connaître les services qu’il offre. Pour cela, Coursera est parfaitement adapté, ses vidéos vont nous aider à aborder le sujet et ses Qwiklabs à utiliser les services de GCP. En complément, vous pouvez lire les pages “Concepts” de la documentation GCP et particulièrement la partie “Overview” s’il y a, pour chacun des principaux services (ComputeVPCIAM, etc…).

Une fois le cours “Preparing for the Google Cloud Associate Cloud Engineer Exam” de Coursera terminé, vous pouvez faire les examens blancs de Google et Whizlabs pour voir ce que vous avez retenu.

Maintenant, il est temps d’approfondir ses connaissances sur GCP. Vous allez commencer la lecture du livre de Dan Sullivan. Il est découpé en 18 chapitres avec 25 questions à la fin de chaque chapitre. Le but ne sera pas de faire le questionnaire directement après avoir lu le chapitre mais quelques jours plus tard. Ce que je faisais c’est que tous les 3 chapitres lus, le lendemain, je répondais aux questionnaires des 2 premiers chapitres en une seule fois. Bien sûr en essayant de faire les 2 questionnaires en 1h45/1h50 maximum. L’objectif étant d’être au plus proche des conditions de l’examen et d’avoir du temps pour revenir sur des questions.

Durant toute la lecture du livre, j’ai mis des marques-pages sur les passages où je souhaitais revenir une fois le livre complètement terminé. Cela correspondait aux pages où j’ai surligné les commandes GCP. L’objectif était de pouvoir faire mon propre GCP cheat sheet quelques jours avant la date de passage à l’examen. Cela m’a permis d’abord de faire une révision des commandes mais aussi des services GCP et de me remémorer ce qu’il est possible de faire avec. Ensuite, j’ai relu mon cheat sheet les derniers soirs.

L’inscription à l’examen Google Cloud Associate

Créer son compte :

Pour passer les examens GCP, vous devez tout d’abord vous inscrire sur la plateforme Webassessor. Vous pouvez suivre ce lien pour vous créer un compte dessus car ce n’est pas évident à trouver.

S’inscrire à l’examen :

Une fois le compte créé, vous allez pouvoir choisir une certification (ici Google Cloud Associate), un centre d’examen, une date et une heure de passage puis payer les 150$ de frais d’inscription. Vous recevrez ensuite un mail de confirmation et toutes les informations nécessaires pour pouvoir passer l’examen. Celle à ne pas manquer, c’est que vous devez avoir 2 pièces d’identité sur vous pour avoir le droit d’entrer dans la salle.

Mon retour d’expérience de l’examen :

Comme je vous l’ai dit au début de cet article, vous allez avoir 50 questions à répondre en 2h. Il va falloir être très concentré et ce n’est pas forcément évident dans les salles d’examen. En effet, il est fortement probable que le centre d’examen ne fasse pas passer que des certifications GCP. Pour ma part, j’étais dans une salle à Paris avec une dizaine de personnes qui passaient le code de la route et d’autres examens. Je peux vous dire que c’était assez bruyant. Je pouvais entendre les questions du code sortant des casques et le bruit insupportable de touches de clavier que peuvent faire les personnes stressées. Je vous conseille donc vivement de prendre des boules Quies lors du grand jour. Cela m’a bien aidé, surtout si vous êtes comme moi assez sensible au bruit dans ce genre de circonstance.

Le mot de la fin

Vous avez maintenant toutes les cartes en main pour valider votre certification Google Cloud Associate dans les meilleures conditions. En tout cas, les informations que je vous ai partagées m’ont permis de valider la certification rapidement. Le plus dur sera de mettre en place une routine afin de se prendre du temps pour réviser. La rigueur sera la clef du succès et mon dernier conseil.

Qu'est-ce qu'un MVP

Qu’est-ce qu’un MVP ?

Désolé pour ceux qui pensaient que cet article parlerait de LeBron James, je vais aborder un sujet qui me tient à cœur professionnellement et, malheureusement pour mon compte en banque, je ne suis pas joueur de basket, mais Product Owner.

Je travaille pour une société de conseil (Ippon Technologies) et nous développons des produits numériques pour différentes entreprises. Beaucoup d’entre elles viennent nous consulter avec des idées de produit et en demandant le développement d’un MVP parce qu’elles s’attendent à un produit rapidement en production. Hélas, le périmètre du MVP n’est pas toujours à la hauteur de leurs attentes initiales (la plupart ne sachant pas exactement ce qui se cache derrière le terme MVP) et cela amène à des situations de frustration.

À travers cet article, je souhaite partager avec vous, en quelques minutes, ma vision de ce qu’est un MVP pour clarifier ce concept et éviter les futures incompréhensions.

Le MVP “by the book”

Pour commencer, d’où vient le terme “MVP” ? “MVP” signifie “Minimum Viable Product”. Il a été popularisé par la méthodologie Lean Startup qui le définit comme quelque chose qui “permet à une équipe de récolter un maximum d’apprentissages validés à propos de ses clients en un minimum d’effort.”

Derrière le MVP se cache l’idée de mettre en place une solution, la plus minimale possible (donc pas toujours codée) et mesurer son impact sur les utilisateurs dans le but d’apprendre à propos de sa proposition de valeur, soit en validant l’impact (et donc en continuant à investir), soit en ajustant, soit en pivotant (en revoyant la proposition de valeur). Cette décision, basée sur des données, aide à réduire le risque (et donc le coût) de développer un produit qui ne sera pas utilisé.

Un MVP = une fonctionnalité

“M” comme “Minimum”. La tâche la plus difficile concernant la conception d’un MVP est de définir jusqu’où il est possible d’aller dans la réduction du produit à son minimum. À chaque réduction, si le produit continue à résoudre la problématique pour un type d’utilisateur, alors on peut continuer à essayer de réduire.

Le choix de la fonctionnalité à développer en priorité doit être basé sur des critères d’importance (par rapport à la proposition de valeur) et d’incertitude.

Potentiellement, un MVP peut être abandonné, il faut donc le développer le plus rapidement possible et bien adapter le périmètre et la qualité associée au niveau minimal requis.

Pour comprendre jusqu’où il est possible d’aller, prenons le fameux exemple de Dropbox.

Avant de commencer le développement de la plateforme, Dropbox a publié une vidéo sur Youtube expliquant leur produit, tout en ajoutant un lien en dessous de la vidéo pour ceux intéressés par le service. Pas une ligne de code, juste une vidéo : le moyen le plus simple pour eux de vérifier que des personnes étaient intéressées par ce service, en étant prêt à payer pour cela.
C’est seulement quand ils ont pu confirmer qu’il y avait un attrait du public, qu’ils commencèrent à coder leur service. Quel bénéfice tirer de cette approche ? S’ils avaient commencé à coder et qu’ils s’étaient rendus compte que personne n’était intéressé, alors ils auraient perdu beaucoup plus d’argent et de temps qu’en créant une courte vidéo expliquant leur concept.

L’exemple de Dropbox divise car certains n’y voient pas la notion de version initiale du produit (le “P” de “MVP”). On parle de MVP dans le cas de Dropbox puisqu’on met à disposition de potentiels prospects une présentation (vidéo) du produit et un moyen de valider qu’il y a une attente forte de ces prospects (landing page).

Contrairement à Dropbox, la plupart des MVPs sont bien plus qu’une simple landing page d’un site et une vidéo : ils sont la première pièce du produit final, mais cette première version doit être développée facilement et rapidement. Une technique pour accélérer le développement est celle du “Magicien d’Oz”, consistant à effectuer manuellement des actions qui seront à terme automatisées. Je vous renvoie à l’exemple de Zappos pour avoir plus d’informations sur ce sujet.

Quelques fonctionnalités obligatoires pour une version finale peuvent être absentes (ou réduites) pour un MVP. Avez-vous réellement besoin d’une fonctionnalité de connexion pour valider votre proposition de valeur ? Avez-vous réellement besoin d’automatiser des tâches qui peuvent être faites manuellement ? Avez-vous besoin d’être compatible avec tous les types d’appareils/écrans ? Ce qui est nécessaire pour un MVP (conçu pour adresser des “early adopters”) n’est pas le même que celui pour une première version complète adressant une audience plus large. Souvenez-vous, le premier iPhone n’avait pas de fonction copier-coller. Est-ce que cela a empêché l’iPhone de devenir un succès ?

Procéder à cette réduction de périmètre n’est pas évidente pour le client et s’avère compliquée à comprendre pour ceux ne maîtrisant pas le concept de “Test and Learn” et de développement itératif et incrémental.

Valider l’impact

“V” comme “Viable”. Un MVP est utile pour valider la pertinence d’un produit, donc il est important de savoir quel est le retour sur investissement attendu (ROI). Cela peut être de générer des revenus, aider dans la promotion d’un produit ou d’un service, réduire des coûts ou améliorer l’image de marque.

Les bons produits sont conçus pour avoir un impact positif sur leurs utilisateurs. Le “MVP” est la première étape pour analyser l’impact du produit sur son audience cible. La définition des fonctionnalités d’un MVP doit être liée aux KPIs associés à la vérification de cet impact. Si on revient à l’exemple de Dropbox, le ROI est basé sur la génération de revenus. L’idée du MVP n’était pas seulement de savoir si les gens étaient intéressés par l’utilisation du service, mais surtout s’ils étaient prêts à payer pour cela. Avoir un KPI indiquant que les gens étaient intéressés et un autre indiquant qu’ils n’étaient pas prêts à payer aurait potentiellement pousser Dropbox, non pas à abandonner son projet, mais à repenser son business model.

Transformer un “Minimum Viable Product” en “Most Valuable Product” est facilité par l’adoption d’une approche scientifique basée sur l’observation des comportements des utilisateurs et la validation systématique des impacts au travers de la récolte de données d’usage.

C’est un Produit

“P” comme “Product”. Un MVP doit être une version minimale du produit permettant de valider qu’il répond à un besoin méritant d’être résolu. Cela peut être juste une “landing page” expliquant la proposition de valeur ou un peu plus. Le MVP peut aussi être une version manuelle du futur produit offrant le service (cf la technique du magicien d’Oz vue précédemment) ayant comme unique but de valider l’attraction du service pour une population donnée.

On pourrait dire qu’une maquette ou une démonstration peuvent faire le même travail de validation, mais un MVP est livré en production et visible d’une population plus importante (mais pas trop), dans le but de valider s’il commence à résoudre les problèmes de cette population. Doit-il être accessible par le plus grand nombre ? Cela dépend du contexte, mais il est préférable de se limiter, dans un premier temps, à une audience de “early adopters” qui pourraient être intéressés par la proposition de valeur.
Le produit est alors une “beta version”, il faut donc faire attention à l’image du produit (et de la marque).

Conclusion

Alors que le but d’un POC (Proof of Concept) est de dérisquer la faisabilité technique d’un produit, le but d’un MVP est de dérisquer la problématique business la plus critique de ce produit, et à moindre frais.

Souvent, les incompréhensions reposent sur la définition de “Minimum”. Notre responsabilité en tant que Product Owner/Manager est de réduire autant que possible le périmètre du produit pour récupérer du feedback le plus tôt possible, et en tirer les enseignements conséquents. Penser simple et accepter que tous les cas ne seront pas traités n’est pas toujours facile à faire entendre au client, mais c’est primordial dans la démarche.

Avant de parler de MVP, il faut que tout le monde comprenne en quoi consiste un développement itératif et incrémental. Un MVP est juste une première étape menant à un produit à succès, et il faut être clair avec les parties prenantes sur ce qui va être développé et pourquoi, dans un premier temps, “seulement” ça.

css grid

Découvrir CSS Grid avec le tableau périodique des éléments

CSS Grid Layout est un système de mise en forme de pages web bidimensionnel puissant avec des lignes et des colonnes, qui vous aide à faire des mises en page très complexes sans utiliser de flottants ou de positionnement.

Avant de plonger dans le tutoriel, je vous recommande de lire cet article.

Un grand merci à Anthony Rey pour avoir rendu le code CSS plus structuré pour ce tutoriel.

Support du navigateur

Bureau:

  • Chrome 57
  • Opera 44
  • Firefox 52
  • IE 11 *
  • Edge 16
  • Safari 10.1

Mobile / Tablette:

  • IOS Safari 10.3
  • Opera Mobile 46
  • Opera Mini (non pris en charge)
  • Android 67
  • Android Chrom 74
  • Android Firefox 66

Principes de base

Pour créer une grille, la première chose à faire est de créer un conteneur de grille et définir la propriété display sur grid, puis définir le nombre de colonnes et leur taille à l’aide de la propriété grid-template-columns, enfin définir le nombre de lignes et leur taille à l’aide de la propriété grid-template-rows.

1

Dans cet exemple, nous avons 3 colonnes et 2 lignes. La largeur de chaque colonne est 1fr, ce qui signifie une fraction de la largeur du conteneur (33,33%). La hauteur pour chaque rangée est 200px.

.container { 
  display: grid; 
  grid-template-columns: repeat(3, 1fr [col-start]); 
  grid-template-rows: repeat(2, 200px [col-start]); 
}
2

La colonne de l’élément de grille .box-1 commence entre les lignes de colonne 1 et 2 (lignes vertes) ; la rangée commence entre les lignes de rangée 1 et 2 (lignes orange).

.box-1{
  grid-column-start: 1;
  grid-column-end: 2;
  grid-row-start: 1;
  grid-row-end: 2;
}
/* OU */
.box-1{
  grid-column: 1/2;
  grid-row: 1/2;
}
3

Dans cet exemple, la colonne de la grille commence à la ligne de colonne 2 et se termine à la ligne de colonne 4 (lignes vertes), la rangée débute entre les lignes de rangée 2 et 3 (lignes orange). Avec CSS, vous pouvez placer vos éléments de grille n’importe où dans la grille, et comme pour la Flexbox, l’ordre des éléments de la grille n’a pas d’importance, vous pouvez les réorganiser comme vous le souhaitez.

.box-1{
  grid-column-start: 2;
  grid-column-end: 4;
  grid-row-start: 2;
  grid-row-end: 3;
}
/* OU */
.box-1{
  grid-column: 2/4;
  grid-row: 2/3;
}

Commençons!

Dans ce tutoriel, nous allons construire le tableau périodique des éléments en utilisant CSS Grid. La grille semble complexe, mais avec l’utilisation de CSS Grid, tout sera facile à prendre en main.

Grid

Partie HTML:

Tout d’abord, nous allons créer une div avec une classe .periodic-table, puis nous ajouterons tous les éléments de la grille sur le HTML. La grille contient 118 éléments de grille. Chaque élément possède 3 classes .element.-n (numéro d’élément), une classe du type de l’éléments ainsi qu’une couleur propre pour chaque groupe.

metals
<div class="periodic-table">
  <div class="element -n1 -reactive-nonmetal">1 <br/> H</div>
  <div class="element -n2 -noble-gas">2 <br/> He</div>
  <div class="element -n3 -alkali-metal">3 <br/> Li</div>
  ...
  <div class="element -n118 -unknown-chemical-properties">118 <br/> Og</div>
</div>

Partie CSS:

Passons maintenant à la partie CSS. La première chose à faire est de définir la propriété display de la classe .periodic-table en tant que grid, puis de la diviser en 18 colonnes et 10 lignes. Normalement, le tableau périodique ne contient que 9 lignes, mais il y a un espace vide que nous allons considérer comme une ligne vide.


.periodic-table {
  display: grid;
  grid-template-columns: repeat(18, 1fr [col-start]);/* 18 columns */
  grid-template-rows: repeat(10, 90px [col-start]);/* 10 rows */
}

Maintenant, appelons les éléments.

.periodic-table .element {
  border: 2px solid #fff;
  text-align: center;
  padding-top: 30px;
  font-family: Arial;
  font-weight: bold;
}

Maintenant, nous allons ajouter le style de chaque élément de la grille, le style comprendra deux propriétés .grid-column et .grid-row . Nous montrerons simplement le style de certains éléments (1, 2, 58, 103, 108, 118) et pas tous les 118 éléments.

Grid-square
/* 1er élément  */ 
.periodic-table .element.-n1 {
  grid-column: 1/2;
  grid-row: 1/2;
}

/* 2ème élément  */ 
.periodic-table .element.-n2 {
  grid-column: 18/19;
  grid-row: 1/2;
}

/* 58ème élément  */ 
.periodic-table .element.-n58 {
  grid-column: 4/5;
  grid-row: 9/10;
}

/* 108ème élément  */ 
.periodic-table .element.-n108 {
  grid-column: 8/9;
  grid-row: 7/8;
}

/* 103ème élément  */ 
.periodic-table .element.-n103 {
  grid-column: 17/18;
  grid-row: 10/11;
}

/* 118ème élément  */ 
.periodic-table .element.-n118 {
  grid-column: 18/19;
  grid-row: 7/8;
}

CSS Grid est un puissant outil de création de disposition qui vous fera gagner beaucoup de temps, mais je pense que cela sera plus utile pour les dispositions complexes telles que les portefeuilles de mosaïque et les tableaux de bord. Sinon, je pense que Flexbox conviendra mieux pour des projets classiques basés sur une grille comme celle de bootstrap.

Une gestion des Authorizations sur mesure avec Spring

Une gestion des Authorizations sur mesure avec Spring

Quand on parle de gestion des authorizations on pense souvent RBAC (Role-Based Access Control). Cette stratégie est tout à fait viable pour différencier les utilisateurs des membres du staff (avec des droits plus élevés). Cependant, on en atteint rapidement les limites lorsque l’on souhaite restreindre les actions des utilisateurs à certaines ressources (pour éviter que tout le monde puisse modifier les données de tout le monde par exemple).

L’idée de cet article est de vous présenter un moyen de gérer ce type d’authorizations dans une application Spring Boot tout en gardant quelque chose qui soit simplement maintenable et utilisable.

Bien qu’il contienne des exemples de code, le but de cet article n’est pas de fournir une solution toute faite puisque cette gestion doit être adaptée à vos besoins. Pour cette raison l’implémentation fournie n’est pas utilisable directement (puisque je ne donne pas toutes les implémentations, les dépendances ou les tests).

Stratégie globale

Autant que faire se peut la vérification des authorizations doit se faire en dehors des traitements métier ; dans du code dédié. Spring Security propose depuis très longtemps une décoration des méthodes avec l’annotation @PreAuthorize : c’est ce que nous allons utiliser ici.

Vous pouvez aussi utiliser très simplement @Secured si votre besoin est de simplement vérifier les rôles et groupes de l’utilisateur ou les annotations de la JSR250 mais ce ne sera pas le sujet de cet article.

A plusieurs reprises je vais utiliser le terme application service : ce sont des services qui ne sont chargés que de l’orchestration des opérations. Il ne font pas de traitements métier.

Pour cet article nous allons donc utiliser @PreAuthorize dans une application

  • où les utilisateurs peuvent faire certaines actions (en fonction de leur rôles) sur des groupes d’objets. Les règles métier à appliquer pour savoir qui a accès à quels objets changent pour chaque type d’objets,
  • où on ne manipule pas de types primitifs (ou de String) en entrée des méthodes des applications services mais uniquement des types dédiés (par exemple on n’aura pas un String avec le login de la personne mais un objet Username),
  • où la sécurisation est faite en entrée des applications services.

Si votre projet n’a pas ces pré-requis l’article est quand même tout à fait applicable, je devais simplement faire des choix :).

Comment gérer qui peut faire quoi ?

Il est très courant de vérifier la possibilité de faire une action en se basant directement sur les rôles de l’utilisateur connecté. Je n’aime pas cette stratégie !

Dans ma compréhension un rôle doit donner accès à une liste d’actions possibles, je vais donc préférer associer les rôles à des actions et vérifier la possibilité de faire une action. De cette manière il sera bien plus simple de comprendre le contenu des @PreAuthorize mais il sera aussi plus simple de créer de nouveaux rôles pouvant faire des sous-ensembles d’actions.

En combinant la possibilité de faire une action avec les accès à une ressource nous allons pouvoir couvrir une part importante des besoins de vérification d’accès de notre application.

Choix d’une implémentation par type d’objet

Nous devons maintenant trouver un moyen de fournir une implémentation par type d’objet sur lequel l’accès doit être validé. Commençons par définir une interface :

import org.springframework.security.core.Authentication;

@FunctionalInterface
public interface CanChecker<T> {
  /**
  * Checks if the authenticated user can access the item.
  *
  * @param authentication
  *          authenticated user information
  * @param action
  *          action to check
  * @param item
  *          element to check action possibility on to
  * @return true is the user can do the action on the resource, false otherwise
  */
  boolean can(Authentication authentication, String action, T item);
}

Nous retrouvons bien nos notions : est-ce que l’utilisateur connecté peut faire une action sur un objet.

Nous pouvons maintenant écrire une class pour sélectionner l’implémentation correspondant à un objet donné et faire la vérification d’accès :

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Service
class CanEvaluator {
  private static final Logger logger = LoggerFactory.getLogger(CanEvaluator.class);
  private final ObjectChecker defaultEvaluator;
  private final Map<Class<?>, CanChecker<?>> evaluators;

  public CanEvaluator(List<CanChecker<?>> checkers) {
    evaluators = checkers.stream().collect(Collectors.toMap(this::getCheckerResourceClass, checker -> checker));
    logger.info("Authorized types: {}",
        evaluators.keySet().stream().map(Class::getName).collect(Collectors.joining(", ")));
    defaultEvaluator = (ObjectChecker) evaluators.get(Object.class);
    // Here you should ensure that you have a default evaluator :)
  }

  private Class<?> getCheckerResourceClass(CanChecker<?> checker) {
    Class<?> checkerClass = findCheckerClass(checker);
    return (Class<?>) ((ParameterizedType) streamParameterizedTypes(checkerClass)
        .filter(type -> ((ParameterizedType) type).getRawType().equals(CanChecker.class))
        .findFirst()
        .get()).getActualTypeArguments()[0];
  }

  private Class<?> findCheckerClass(CanChecker<?> checker) {
    Class<?> checkerClass = checker.getClass();
    while (Arrays.stream(checkerClass.getInterfaces()).noneMatch(interf -> CanChecker.class.equals(interf))) {
      checkerClass = checkerClass.getSuperclass();
    }
    return checkerClass;
  }

  private Stream<Type> streamParameterizedTypes(Class<?> checkerClass) {
    return Arrays.stream(checkerClass.getGenericInterfaces()).filter(type -> type instanceof ParameterizedType);
  }

  public boolean can(Authentication authentication, String action, Object item) {
    return evaluators.getOrDefault(item.getClass(), defaultEvaluator).canOnObject(authentication, action, item);
  }
}

Pour pouvoir faire cette implémentation j’ai dû ajouter une méthode dans l’interface CanChecker :

@SuppressWarnings("unchecked")
default boolean canOnObject(Authentication authentication, String action, Object item) {
  return can(authentication, action, (T) item);
}

Ce n’est pas très élégant mais, de cette manière, vos implémentations seront directement appelées avec des objets du type qu’elles gèrent.

Il nous manque aussi l’implémentation par défaut, j’ai choisi de bloquer les actions par défaut :

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
 
@Service
class ObjectChecker implements CanChecker<Object> {
  private static final Logger logger = LoggerFactory.getLogger(ObjectChecker.class);
 
  @Override
  public boolean can(Authentication authentication, String action, Object item) {
    logger.error("Error checking rights, falled back to default handler for action {} on class {}", action, getItemClass(item));
 
    return false;
  }
 
  private String getItemClass(Object item) {
    if (item == null) {
      return "unknown";
    }
 
    return item.getClass().getName();
  }
}

Maintenant que nous avons ce mécanisme de résolution d’un CanChecker pour un type d’objet nous pouvons utiliser cela depuis les @PreAuthorize.

Branchement à Spring

La première chose dont nous allons avoir besoin est de redéfinir notre SecurityExpressionRoot :

import org.springframework.security.access.expression.SecurityExpressionRoot;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;
 
class MyMethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
  private final CanEvaluator evaluator;
 
  private Object filterObject;
  private Object returnObject;
  private Object target;
 
  public MyMethodSecurityExpressionRoot(Authentication authentication, CanEvaluator evaluator) {
    super(authentication);
    this.evaluator = evaluator;
  }
 
  @Override
  public void setFilterObject(Object filterObject) {
    this.filterObject = filterObject;
  }
 
  @Override
  public Object getFilterObject() {
    return filterObject;
  }
 
  @Override
  public void setReturnObject(Object returnObject) {
    this.returnObject = returnObject;
  }
 
  @Override
  public Object getReturnObject() {
    return returnObject;
  }
 
  void setThis(Object target) {
    this.target = target;
  }
 
  @Override
  public Object getThis() {
    return target;
  }
 
  public boolean can(String action, Object item) {
    return evaluator.can(getAuthentication(), action, item);
  }
}

Le but ici est d’invoquer la méthode can lorsque l’on utilisera @PreAuthorize(« can(‘action’, #item) »). Vous pouvez donc changer la signature comme vous le souhaitez pour correspondre à vos besoins. Vous pouvez par exemple ajouter une clé pour le type de ressource et sélectionner votre CanChecker (ou équivalent) non pas en fonction du type mais en fonction de la clé.

Nous devons maintenant utiliser ce nouveau SecurityExpressionRoot, pour cela il nous faut un MethodSecurityExpressionHandler :

import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations;
import org.springframework.security.core.Authentication;
 
class MyMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
  private final CanEvaluator evaluator;
 
  MyMethodSecurityExpressionHandler(CanEvaluator evaluator) {
    this.evaluator = evaluator;
  }
 
  @Override
  protected MethodSecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, MethodInvocation invocation) {
    MyMethodSecurityExpressionRoot root = new MyMethodSecurityExpressionRoot(authentication, evaluator);
 
    root.setThis(invocation.getThis());
    root.setPermissionEvaluator(getPermissionEvaluator());
    root.setTrustResolver(getTrustResolver());
    root.setRoleHierarchy(getRoleHierarchy());
    root.setDefaultRolePrefix(getDefaultRolePrefix());
 
    return root;
  }
}

Et enfin nous devons configurer l’utilisation de ce handler :

import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
 
@EnableGlobalMethodSecurity(prePostEnabled = true)
class MySecurityConfiguration extends GlobalMethodSecurityConfiguration {
  private final CanEvaluator evaluator;
 
  // All beans here needs to be Lazy otherwise it'll break AOP (cache, transactions, etc...)
  public MySecurityConfiguration(@Lazy CanEvaluator evaluator) {
    this.evaluator = evaluator;
  }
 
  @Override
  protected MethodSecurityExpressionHandler createExpressionHandler() {
    return new MyMethodSecurityExpressionHandler(evaluator);
  }
}

Même si c’est dit en commentaire je préfère le redire ici : injectez impérativement tous les beans de cette classe de configuration en @Lazy sinon vous allez perdre vos transactions, caches, etc !

Vous avez maintenant la possibilité d’utiliser votre nouvelle expression dans les @PreAuthorize :

@PreAuthorize("can('read', #stuff)")
public Optional<Stuff> get(StuffId stuff) {
  //...
}

Et c’est votre implémentation de CanChecker<StuffId> qui sera appelée pour savoir si votre utilisateur peut faire l’action read.

Et le filtrage des données en retour ?

La stratégie que nous avons vue permet de vérifier qu’un utilisateur peut accéder à une ressource mais elle ne permet pas un filtrage des données en retour. Même si on peut faire des choses avec @PostAuthorize je préfère traiter ce second besoin directement dans le code métier en filtrant les données lors de leur récupération.

S’adapter à votre besoin

L’exemple donné ici ne correspond très certainement pas directement à votre besoin : pas le bon verbe (can), pas les bons paramètres, … Je pense cependant que la stratégie de choisir une implémentation en fonction du besoin métier s’applique dans la majorité des cas.

C’est maintenant à vous de trouver comment :

  • Associer des actions à des rôles (est-ce qu’une Map en dur suffit ?) ;
  • Faire simplement les implémentations des CanChecker des différents types (pensez aux délégations) ;
  • Améliorer CanEvaluator pour gérer l’héritage, les collections et les tableaux d’objets traités.

Un dernier point : selon votre besoin il est possible de brancher cette mécanique sur l’expression native Spring hasAuthority en définissant votre propre permissionEvaluator dans votre MethodSecurityExpressionHandler :

@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
  MyMethodSecurityExpressionHandler handler = new MyMethodSecurityExpressionHandler(evaluator);
  handler.setPermissionEvaluator(new MyPermissionEvaluator(evaluator));
  return handler;
}

De cette manière vous pourrez utiliser @PreAuthorize(« hasPermission(#item, ‘read’) ») et @PreAuthorize(« hasPermission(#item, ‘resource’, ‘read’) »), c’est à vous de voir !

Gérer ses secrets avec Mozilla SOPS

Gérer ses secrets avec Mozilla SOPS

En travaillant sur de l’Infrastructure as Code (IaC) ou encore K8S il vous est certainement arrivé de manipuler des tokens, des mot de passes, des certificats et plein d’autres données sensibles qu’il vaudrait mieux ne pas partager publiquement. Nous allons voir dans cet article qu’il est possible de gérer les secrets d’une application avec un VCS comme git au même titre que du code source sans s’exposer à des risques de sécurité. Cette solution est SOPS de Mozilla. Elle a l’avantage d’être une solution transparente et universelle car elle s’intègre bien avec les différents clouds publics.

Présentation de l’outil

SOPS est un éditeur de fichier chiffré supportant les formats YAML, JSON, ENV et INI ainsi que les fichiers binaires. Le chiffrement se fait aussi bien avec AWS KMS, GCP KMS, Azure Key Vault ou une clé PGP. Ces méthodes de chiffrement peuvent être combinées sur un seul et même fichier. Sa particularité est qu’il chiffre uniquement les valeurs et non les clés du fichier, ce qui signifie que dans tous les cas nous avons accès à l’architecture du fichier.

Démonstration en utilisant une clé PGP

Pour commencer voici un exemple simple de l’utilisation de SOPS avec une clé PGP fraîchement générée. Notre but ici va être de créer un fichier yaml que nous allons chiffrer avec SOPS à l’aide de PGP. Le but est de comprendre son utilisation mais aussi d’avoir une idée plus précise de comment ce chiffrement fonctionne.

Génération de la clé PGP :

> gpg --generate-key
gpg (GnuPG) 2.2.19; Copyright (C) 2019 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Remarque : Utilisez « gpg --full-generate-key » pour une fenêtre de dialogue de génération de clef complète.

GnuPG doit construire une identité pour identifier la clef.

Nom réel : sops_key
Adresse électronique : afougerouse@ippon.fr
Vous avez sélectionné cette identité :
    « sops_key <afougerouse@ippon.fr> »

 [ … ]

pub   rsa2048 2020-05-04 [SC] [expire : 2022-05-04]
      59C8C737858631D5832043E83F59D0BA0AA089E7
uid                      sops_key <afougerouse@ippon.fr>
sub   rsa2048 2020-05-04 [E] [expire : 2022-05-04]

‌‌‌‌‌‌‌À l’aide de l’empreinte de la clé « 59C8C737858631D5832043E83F59D0BA0AA089E7 » nous allons pouvoir créer notre premier fichier avec SOPS. Pour information, une empreinte de clé PGP n’est pas la clé elle-même, c’est un identifiant unique qui permet d’authentifier que la clé est bien détenue par la personne se réclamant détenteur de ladite clé.

> sops -p 59C8C737858631D5832043E83F59D0BA0AA089E7 demo_pgp.yaml

À noter que SOPS utilise Vim pour l’édition des fichiers par défaut si la variable d’environnement $EDITOR n’est affectée à aucune valeur.

Une fois que SOPS ouvre notre éditeur nous allons pouvoir remplir notre fichier. Voici l’exemple que j’ai utilisé :

key: value
service:
    db:
        password: my-secret-password

À la fermeture du fichier SOPS se chargera de le chiffrer. Si nous l’ouvrons simplement sans utiliser SOPS nous obtenons ceci :

> cat demo_pgp.yaml
key: ENC[AES256_GCM,data:OyFGrsA=,iv:Pe61hnynI1KCgNLEZB2+BeUQDxkEjPzMdt80P96lrKM=,tag:8Z4idajd/ciH4nQgqCUAkA==,type:str]
service:
    db:
        password: ENC[AES256_GCM,data:L2PJssrCqWJUVQhYMgLXJH7u,iv:5ILlqLaKg5KFjT9Ods1Kvtss9LqlFHXw11bCADAnHZw=,tag:UMQdRLZJ4KeIG92IKfJa8g==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    lastmodified: '2020-05-04T12:55:23Z'
    mac: ENC[AES256_GCM,data:kDnzC7v8OFcIIRneBZh1ou0NehoGdAS5ddJRDyww9bAdcKwgnEG5TC8cmUaDrz4kt68TJ0C3EvGhrvzfXbpl/eLdfzTjEWev+wdeOBFDJNMHMsWL3ayGZJAcb4z2uo0Rg1qJOVPDwAzkw1bRfMr20/ldm/5kI1nFfZoS8T9Hz1I=,iv:Nkj5qf+ghUT8EqIOyEFINT7JdfLfVOSJdFSf8BbzIk8=,tag:LfecshluJHqw/uGoSSj2ZQ==,type:str]
    pgp:
    -   created_at: '2020-05-04T12:49:01Z'
        enc: |
                -----BEGIN PGP MESSAGE-----
            hQEMA1+85P4Q6IY2AQgAuhwZUpvWxKsryuY1MdcAz3Ig/U5QEM7eTOnmNH/0qAgb
            JydQT4NOzZm3GNppJ7vT10OKSpHNiRattCm4muBWR8U7xhuDhh6R179D7bWRiuRL
            s2asB4cmjsYFCNvqd64nCbbepiw57e7AgtstySGcsqjZAZt9OIzLr26VoGBeENJt
            154AGq+Fbjm8iGWOjLQIP0wlH8kCMnn5VIMLTZaraJcBdZEqEQDfOhmMnd86rPkt
            w6KEt1+I0Rc9eJ7rBCmm/lMHz6LkB9j5vZ34/jpAXvv7646yNMkUSNBEFjyD35gP
            HUENhruKiP4W15fcyHGiPOnKCsfLleey2IOdW0hrgdJcASGK2ksU+w3BgqxK9Rha
            Ct5hPF7SOcIxk7niKLq4ELs5V/GHKoQQVDaMmdmANDBLmToXNk3qcvmMHdUk1kIC
            nm+my5XT/KdEomydiiZwZiyaAx5PX7rQt2jdYTQ=
            =hjjv
                -----END PGP MESSAGE-----
        fp: 59C8C737858631D5832043E83F59D0BA0AA089E7
    unencrypted_suffix: _unencrypted
    version: 3.5.0

‌‌‌Tout d’abord nous constatons bien, comme mentionné plus haut, qu’uniquement les valeurs sont chiffrées et non les clés. L’avantage de ce fonctionnement est que dans une utilisation avec git l’ensemble des contributeurs peuvent voir les changements affectés aux fichiers sans avoir à les déchiffrer avec SOPS à chaque fois.

Pour ce qui est de nos valeurs, nous pouvons remarquer qu’à la place nous avons une enveloppe qui contient :

  • le nom de l’algorithme utilisé, AES256_GCM
  • un champs data qui est la valeur précédemment entrée, mais chiffrée
  • les champs iv (initial vector) et tag utiles au chiffrement/déchiffrement
  • le type de la donnée, string dans notre cas

Ensuite, nous trouvons sous la clé SOPS un ensemble de métadonnées. Les valeurs kms, gcp_kms et azure_kv sont vides car nous n’utilisons ici aucune clé provenant d’un cloud public. Le mac sert à garantir l’intégrité du fichier, il sera donc mis à jour à chaque modification apportée à notre fichier. Enfin, la clé publique à la fin du fichier sert à chiffrer les valeurs de notre fichier yaml. Pour les déchiffrer SOPS utilisera la clé privé associée à celle-ci.

Maintenant que vous avez bien saisi son fonctionnement de base nous allons pouvoir utiliser SOPS dans un cas plus concret en utilisant AWS.

Démonstration de son utilisation avec AWS

Afin de pouvoir faire une présentation de l’utilisation de SOPS avec AWS, j’ai au préalable :

  • généré une application monolithique JHispter utilisant une base de données MySQL
  • configuré des identifiants AWS sur mon poste
  • créé une instance EC2 sur AWS avec docker, docker compose et SOPS
  • créé une clé AWS KMS

Si vous voulez plus de détails vous pouvez visiter ce dépôt, vous trouverez notamment l’application JHispter, le script Terraform qui m’a permis de générer tout le nécessaire sur AWS et un Dockerfile créant une image Docker de l’application.

Notre but ici va être de chiffrer le fichier docker-compose définissant deux services. Un pour l’application, un pour la base de données. Les deux injectent des variables d’environnement contenant des données sensibles que nous voulons chiffrer.

Voici le docker-compose en claire, il se trouve dans le répertoire /src/main/docker/.

version: "3"
services:
    sops_demo-app:
        image: sops-demo
        environment:
        - _JAVA_OPTIONS=-Xmx512m -Xms256m
        - SPRING_PROFILES_ACTIVE=prod,swagger
        - MANAGEMENT_METRICS_EXPORT_PROMETHEUS_ENABLED=true
        - SPRING_DATASOURCE_URL=jdbc:mysql://sops_demo-mysql:3306/sops_demo?allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=utf8&createDatabaseIfNotExist=true
        - SPRING_DATASOURCE_PASSWORD=a-very-secure-password
        ports:
        - 8080:8080
        depends_on:
        - sops_demo-mysql
    sops_demo-mysql:
        image: mysql:8.0.19
        environment:
        - MYSQL_ROOT_PASSWORD=a-very-secure-password
        - MYSQL_DATABASE=sops_demo
        ports:
        - 3306:3306
        command: mysqld --lower_case_table_names=1 --skip-ssl --character_set_server=utf8mb4
            --explicit_defaults_for_timestamp
~  

‌Pour le chiffrer nous n’allons pas passer une clé PGP en argument à SOPS, comme précédemment. Nous allons plutôt créer un fichier .sops.yamlà la racine du projet, celui-ci contiendra l’empreinte de la clé PGP que nous avons utilisée plus haut dans l’article et un identifiant ARN d’une clé AWS KMS.

Ce qui donne :

creation_rules:
        - kms: 'arn:aws:kms:eu-west-3:236323065396:key/8a467ae6-bd8c-46b2-9702-c48d648e82db'
          pgp: '59C8C737858631D5832043E83F59D0BA0AA089E7'

‌‌Pour appliquer le chiffrement à un fichier déjà existant il suffit d’utiliser l’option -ede SOPS. Elle permet de générer sur la sortie standarde le fichier chiffré, sortie que nous allons rediriger dans un fichier app.yml.

> sops -e app_not_encrypt.yml > app.yml
> cat app.yml

version: ENC[AES256_GCM,data:/Q==,iv:khsUUonbwtXEPEsoGpQxdL6KODCgOcY23bZsvC2aiIA=,tag:/eXOf/rYCqTB8pAd3Tj6pA==,type:str]
services:
    sops_demo-app:
        image: ENC[AES256_GCM,data:+EWMTFtRlpYY,iv:7dMqCzV9bUIDpA+shmkH3vqk6umnJZPTo0xmougHaqM=,tag:4f78r12klL+qXO/tHJoF4w==,type:str]
        environment:
        - ENC[AES256_GCM,data:3MQCTvl3soJ6pdKPGMDGsJcCWsUGkUnEZC5VuQk5wg==,iv:YIEWXK3NEZtT+35DNeqCQkCCTxT8BPvl+hDwh0k8qbk=,tag:0DZQY/pr0eglbWBUHLelNQ==,type:str]
        - ENC[AES256_GCM,data:Ncfq/qaNvaHgnMe3yBCYQ80DbHBGj8lCv+rbhkAf2hHPN/c=,iv:ap6NUHzmACwDYqHs4Vkn+xBv0AIjBG6NucHKKFZHa50=,tag:2y8KH4mkunMFO0Pcv6d7/w==,type:str]

[ … ]

sops:
    kms:
    -   arn: arn:aws:kms:eu-west-3:236323065396:key/8a467ae6-bd8c-46b2-9702-c48d648e82db
        created_at: '2020-05-06T09:35:14Z'
        enc: AQICAHijBlzZqCPpmXHUcK+zANSXooCkWvXha3R2gF+wJjfnHAGfk09toWpQS+fsO8gPjKfMAAAAfjB8BgkqhkiG9w0BBwagbzBtAgEAMGgGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMu+IfaLetmCxQbzi8AgEQgDsiNdNsMWoHa4zQiu3IC3kLgV8SfjX9hFp77R6hDFITaRbnj9dlB/v+/6uciWLY73ax73L98aEuvNzhmQ==
        aws_profile: ""
    gcp_kms: []
    azure_kv: []
    lastmodified: '2020-05-06T09:35:14Z'
    mac: ENC[AES256_GCM,data:yvSVucCVktAii3JJM2EGjGO+uLUpSeorcdVZyQT/Hu8O9nqkPqy4PtOPcxqV2IpVgnHsNcLJ2ajGyPnA3ihwKrunhTkDct0RZIJVlzsJkbUviJxHFGLm8RXf4HrP+My1bkUnHmF+J1WNt+x/W/tTV4JaxCqOYchh9TOreIQhdjQ=,iv:zjCm9A/Sk9j3OPoI9DBAO8I4V4ap5bYZlXmRfAt2Aqw=,tag:xMu1NYbuQpDCAceGcdmFJA==,type:str]
    pgp:
    -   created_at: '2020-05-06T09:35:14Z'
        enc: |
            -----BEGIN PGP MESSAGE-----            hQEMA1+85P4Q6IY2AQf/U28jJdkNwn+CgLgLDshKBqVMXgJaeVuQVWf0QX7cjQRx
/PRUQ+puq/52qURaeFXtjWGcqq5kx0kcdVR54Be9HyQaiN8pSGriDQx7oBltbZNj
yRyd4wX2dQz/TnLjyuO+kGCFd97zdAWEGGwPPoMZ15Bs2kPx2vzkQ9htK26umoEy
kDaKUaza9AHgYe/tkG6QZa7JPVIjZJMmYgenXlBTj5BEL2Zp8VFECoWCAbWAYGde
mdg/wWp945j2654lBnCfS4GCy4ZuhRJCqx8G8WZo9Y1Pp7vQrNiGLWXQE2bM/edo
YhYLMUC8bVuCapqcn5gnqmUHYQ0Dji9sD1tdTmlEidJcAWLqJaRjejkMGxSYpJOp
mEeFBE9NT1Q20yGLiYfpk6Z31mWfeBOL2T5GbJpXU9qNonI9ydADDG6vW2Lg1Ixq
oMe8+1bWG6IHZOVDAj9PEIN2rNrZdV5FdjdnHB8=
=IgGi
            -----END PGP MESSAGE-----
        fp: 59C8C737858631D5832043E83F59D0BA0AA089E7
    unencrypted_suffix: _unencrypted
    version: 3.5.0

Comme pour tout à l’heure nous pouvons voir qu’uniquement les valeurs sont chiffrées. Tout va bien ! Concernant les métadonnées cette fois-ci nous avons, en plus d’avoir des données sur le chiffrement PGP, des informations sur le chiffrement via AWS KMS. Ce fichier pourra donc aussi bien être déchiffré par notre clé PGP que par la clé KMS d’Amazon.

Afin de transférer tout le projet sur mon instance EC2 j’ai créé une archive tar pour me faciliter la vie. Une fois décompressée et l’image Docker créée c’est le moment de passer à l’instant de vérité.

> scp  -i "private_key" demo_sops.tar.gz ec2-user@ec2-15-188-207-142.eu-west-3.compute.amazonaws.com:/home/ec2-user/

# Sur l’EC2
> docker build -t sops-demo .

‌Pour faire exécuter le fichier déchiffré, SOPS possède plusieurs fonctionnalités. Ici nous allons utiliser la fonction exec-file. Cette fonction va créer une liste FIFO en mémoire sur laquelle notre fichier déchiffré se trouvera. Le but étant que notre fichier soit utilisable une seule et unique fois et ne soit jamais écrit sur le disque en clair.

Couplé à la commande docker-compose nous obtenons ceci :

> sops exec-file app.yml 'docker-compose -f {} up'

‌Après un long moment d’attente, si nous nous connectons à l’adresse publique de notre instance EC2 l’application JHipster tourne parfaitement !‌

Quelques fonctionnalités utiles

Je ne vais pas détailler toutes les fonctionnalités de SOPS mais je vais vous faire part de certaines qui ont retenu mon attention.

Pour plus de sécurité SOPS propose un sytème de rotation de clés qui va permettre de renouveler la clé de chiffrement. Pour l’utiliser rien de plus simple, il suffit de faire comme ceci : > sops -r file.yml

Néanmoins la rotation n’est effectuée qu’une seule fois, c’est à vous de mettre en place une solution de rotation récurrente.

Il est également possible de définir dans le fichier .sops.yaml quels fichiers seront chiffrés avec quelles clés et lesquelles seront chiffrées avec d’autres clés. Ceci permet d’avoir une séparation des privilèges au niveau de l’équipe en permettant à certains membres de déchiffrer des fichiers que d’autres ne pourront pas déchiffrer.

Voici un exemple :

creation_rules:
        - path_regex: \.dev\.yaml$
          kms: 'arn:aws:kms:eu-west-3:XX:key/XX'
          pgp: '59C8C7378XX'

        - path_regex: \.prod\.yaml$
          kms: 'arn:aws:kms:eu-west-2:XXY:key/XX'
          gcp_kms: projects/mygcproject/locations/global/keyRings/mykeyring/cryptoKeys/key
          pgp: 'E83F59DXXXXXBA'

Dans le premier cas les fichiers *.dev.yaml seront affectés, dans le deuxième cas ce seront les fichiers *.prod.yaml.

D’autres fonctionnalités permettent d’établir des règles d’envoi de fichiers sur des buckets S3 en les chiffrant, ou encore de stocker facilement sur HashiCorp Vault les données clés-valeurs.

Conclusion

Comme nous pouvons le constater une fois configuré, SOPS est un outil assez transparent à l’utilisation. Le chiffrement/déchiffrement s’effectue rapidement. Il peut à mon avis très bien s’intégrer dans une chaîne DevOps afin d’augmenter drastiquement la sécurité sans pour autant être vu comme une contrainte.

Source : https://github.com/mozilla/sops

CameraX, la nouvelle librairie photo pour Android

CameraX, la nouvelle librairie photo pour Android

Annoncée à la Google I/O 2019, CameraX fut présentée comme étant l’API permettant d’améliorer et de simplifier considérablement le développement photo dans le monde Android.
Cette librairie est, à l’heure où j’écris cet article, en alpha, mais elle présente déjà un certain nombre de nouveautés assez puissantes sur lesquelles nous allons nous intéresser ici.
Suite au Meetup Android (organisé par le GDG Android Nantes) que j’ai co-animé avec Thomas BOUTIN le mardi 10 septembre 2019 dans nos nouveaux locaux nantais, et dans lequel je présente CameraX, j’ai décidé d’en faire un article afin de vous en parler de façon plus approfondie.

Petit état des lieux de l’avant CameraX

Si vous vous êtes déjà frottés au développement photo dans une application Android, que ce soit avec Camera2 ou même Camera1, vous avez déjà dû constater que ce n’est pas une mince affaire.

Camera2 est une API relativement puissante mais qui nécessite une connaissance assez pointue et donc une prise en main assez longue et fastidieuse. Le code au final est lourd et difficilement maintenable. Jugez par vous-même avec ce petit tutoriel disponible sur Internet.
Vous remarquerez que nous devons utiliser différents objets tels que CameraManagerCameraDeviceCaptureRequestCameraCaptureSession, etc., et j’en passe.

D’autre part, vous devrez gérer vous-même le cycle de vie de Camera2 : ce sera à vous de définir quand éteindre la caméra, quand la démarrer et quand notamment gérer le cas de la rotation d’écran (un cas qui peut facilement faire planter votre application si vous n’y faites pas attention). Pour cela, vous devrez surcharger les fonctions de cycle de vie de l’Activity ou du Fragment que vous utiliserez, par exemple onStart()onResume()onDestroy(), etc.

Ensuite, vous devrez faire en sorte que votre code fonctionne avec la multitude de terminaux Android présents sur le marché, ce qui représente 80% des devices sur le marché, sans compter les différentes versions Android présentes (au contraire d’Apple qui maîtrise ses devices de A à Z). Autant de choses que vous devrez prendre en compte pour avoir une application photo la plus fiable et la plus robuste possible, il n’y a plus qu’à vous souhaiter bon courage ! 😀

Enfin, on notera également que les téléphones de façon générale ont connu une évolution importante dans le domaine de la photo depuis plusieurs années : on est passé de 1 à 2, 3 voire même 5 objectifs à l’arrière (cf. le Nokia 9 PureView), et d’autre part, de nouveaux modes “logiciels” plus orientés professionnels sont venus s’ajouter au monde Android, notamment le mode HDR, le mode nuit ou encore le mode portrait. Tout cela améliorant considérablement la qualité de nos prises de vues.

Tout ça pour simplement récupérer un flux photo et faire une capture, alors pour des fonctionnalités plus poussées telles que de la reconnaissance d’image par exemple, je vous laisse imaginer.
Vous l’aurez convenu, difficile donc de réaliser une application orientée photo et avec des fonctionnalités un minimum poussées avec du code propre, simple et facilement compréhensible.

Et Google présenta CameraX…

Rapide introduction

Si vous avez suivi de près la Google I/O de mai 2019, vous aurez repéré qu’une présentation de 30 minutes est consacrée à CameraX.
Cette librairie est présentée comme ajoutant une couche d’abstraction à Camera2. En effet, CameraX apporte une nouvelle façon de développer une application photo en reprenant l’ensemble des fonctionnalités de Camera2, mais l’énorme avantage est que toute la partie hardware est complètement transparente pour le développeur. Fini les problèmes de compatibilité ou les gestions selon les constructeurs :

if (Build.MANUFACTURER.equalsIgnoreCase("samsung")) {
    ... // This code will make you die a little inside.
}

Pour l’instant, l’API est en version alpha, cela signifie que Google est en pleine phase de prise de feedbacks de la part des développeurs qui auront pu jouer avec la librairie.

Enfin, CameraX fait partie du programme Android Jetpack dont nous allons faire un petit rappel dans la partie suivante.

Android Jetpack

Android Jetpack est un programme lancé lors de la Google I/O 2018. Avant ce programme, chaque développeur Android était plus ou moins livré à lui-même car il n’y avait pas de bonnes pratiques de définies par Google. Chacun appliquait donc ses propres recommandations quant à l’architecture à utiliser notamment.
Google a voulu répondre à cette problématique en sortant Android Jetpack. Il s’agit d’un programme qui comprend un ensemble de librairies et d’outils visant à aider les développeurs à écrire des apps Android de meilleure qualité, avec une architecture cohérente, maintenue et performante, et avec du code plus fiable et permettant de simplifier les tâches complexes.
Jetpack comprend notamment les Android extension libraries (autrement appelées AndroidX), des librairies visant à gérer les problèmes de rétrocompatibilité longuement présents dans le monde Android.
CameraX vient s’inscrire dans ce programme, plus précisément dans la partie Behavior, une partie composée de librairies permettant d’intégrer les différents services Android (notifications, permissions, médias, etc.).

Le schéma de Jetpack est articulé de la façon suivante (remis à jour avec les derniers composants) :

Schema-Android-Jetpack2-1

La catégorie qui va nous intéresser ici est Architecture. Cette catégorie regroupe des librairies dont le but est de développer des applications robustes, facilement testables et facilement maintenables. Ces librairies permettent notamment de gérer le cycle de vie des composants ainsi que la persistance des données. Entre autre, les composants tels que les Activity ou les Fragments seront “conscients” du cycle de vie qu’ils contiennent (ils sont LifecycleOwner) et pourront ainsi l’exposer. Cela va permettre à d’autres composants qui observent (LifecycleObserver), et qui sont donc lifecycle aware, de pouvoir gérer leurs actions selon les cycles de vie des LifecycleOwner.

Si les Android Architecture Components sont quelque chose de nouveau pour vous, vous pouvez lire cet article de Thomas BOUTIN, qui présente de façon claire les différents avantages des librairies. Si vous souhaitez approfondir vos connaissances, vous pourrez ensuite suivre une mise en pratique composée de 7 articles (en commençant ici) afin de maîtriser les AAC jusqu’au bout des doigts. 😉

Revenons à CameraX. La raison pour laquelle j’ai pointé la partie Architecture du doigt est relativement simple : dans l’état des lieux de l’avant CameraX, j’énonçais le fait qu’il fallait gérer soi-même l’ouverture et la fermeture de la caméra en se calquant sur le cycle de vie du composant qui l’intègre. Et bien avec CameraX, vous n’aurez plus ce problème ! Et ce grâce à une seule et unique fonction qui gèrera tout à votre place (et qui fera même le café), elle s’appelle bindToLifecycle(). Littéralement, on peut traduire ça par “brancher au cycle de vie”. Cela signifie qu’une fois votre composant CameraX configuré, vous n’aurez plus qu’à le brancher au cycle de vie exposé par votre LifecycleOwner.
Ainsi, vous n’aurez plus à surcharger les méthodes onStart()onResume(), etc., puisque la fonction gèrera tout cela et ce sera totalement transparent pour vous.
Imaginez donc le nombre de lignes de code en moins rien qu’avec cette fonction. 😉
Nous verrons comment cela s’implémente concrètement dans la partie technique.

Mais dis-moi Jamy, comment ça marche CameraX ?

Nous venons de voir comment CameraX gérait les cycles de vie, nouveauté somme toute, plutôt puissante.
Nous allons voir ici comment cette librairie s’implémente concrètement.

CameraX est une API basée sur des use cases. Le but est de pouvoir nous concentrer sur ce que nous souhaitons que l’application fasse, et d’arrêter de passer du temps à gérer les différences des téléphones niveau hardware.
Voici les use cases basiques, sur ce schéma repris directement de la Google I/O :

Captureandroid2-1

En premier, on aura le use case Preview, il s’agira simplement d’afficher le flux vidéo sur notre écran.
Ensuite, on aura Image analysis. Comme son nom l’indique, ce use case va permettre de faire de l’analyse d’image en extrayant des informations sur la luminosité par exemple, ou en les envoyant notamment à des algorithmes. Un cas intéressant est l’utilisation de MLKit qui va nous permettre de faire de la reconnaissance d’image par exemple.
Enfin, Capture va nous permettre de sauvegarder simplement la photo (ou la vidéo).

Dans ces 3 use cases, on va pouvoir définir une configuration plus ou moins commune. On va pouvoir par exemple définir la résolution, le ratio, l’utilisation du flash, l’objectif avant ou arrière, la qualité de l’image, etc. Nous verrons tout cela tout à l’heure dans la démo.
Ainsi, on se détache totalement des problèmes rencontrés dans les versions précédentes de l’API Camera, et vous verrez qu’en quelques dizaines de minutes, vous pourrez développer une application photo un minimum fonctionnelle.

Il est aussi important d’ajouter que Google a prévu les fonctionnalités avancées telles que le mode HDR, le mode nuit, le mode beauté ou encore le mode portrait. Cela est accessible grâce à la librairie appelée Vendor Extensions.

Maintenant, passons à la pratique !

Présentation du projet

Avant toute chose, sachez que je vais pas m’attarder sur ce qui ne concerne pas CameraX. Il existe une multitude de tutoriels vous permettant de débuter une application Android.
Sachez en tout cas que je vais m’appuyer sur un sample que j’ai développé et qui est présent sur mon GitHub à cette adresse : https://github.com/yannickj10/CameraX-Sample. Ce sample reprend les 3 use cases que nous avons vu tout à l’heure.

Pour expliquer rapidement l’architecture du projet, nous avons :

  • un MainActivity.kt qui lance le premier fragment (PermissionFragment.kt) selon le navigation graph (nav_graph.xml présent dans les resources). Pour en savoir plus sur les Navigation components (composants aussi issus de Jetpack), vous pouvez retrouver plus d’informations sur la documentation Android ;
  • un PermissionFragment.kt qui s’occupe de demander la permission à l’utilisateur d’utiliser la caméra (nous n’allons pas nous attarder dessus) ;
  • un ImagePreviewFragment.kt qui s’occupe d’afficher un aperçu de l’image lorsqu’une capture a été réalisée (nous n’allons pas non plus passer de temps dessus) ;
  • un CameraConfiguration.kt qui regroupe l’ensemble de la configuration utilisée dans nos 3 use cases ;
  • un CameraFragment.kt qui va nous intéresser car il implémente les 3 use cases. Ce sont essentiellement des morceaux de code de ce fragment que je présenterai et expliquerai ici ;
  • enfin, le layout fragment_camera.xml (présent dans les resources) qui contient entre autre la TextureView, le composant permettant d’afficher le flux vidéo.

Explication du code et démonstration

Si vous ouvrez le fragmentcamera.xml, vous aurez une ContraintLayout qui englobe la TextureView et un bouton. Le bouton sert simplement à prendre la photo (avec le use case Capture). La TextureView est définie de façon relativement simple :

<TextureView
            android:id="@+id/view_finder"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

On va simplement lui définir un ID et faire en sorte qu’il utilise toute la place de son parent, c’est-à-dire le layout, qui lui-même utilise l’écran en entier. On aura donc un flux vidéo en plein écran.

Maintenant, ouvrons le CameraFragment.kt. Comme je l’ai précisé plus haut, les 3 use cases sont regroupés ici, dans les fonctions buildPreviewUseCase()buildImageCaptureUseCase() et buildImageAnalysisUseCase().
Dans un premier temps, regardons de plus près la fonction setupCamera(). Celle-ci va récupérer les dimensions de l’écran de notre téléphone et les utiliser pour instancier l’objet CameraConfiguration en lui donnant un ratio, une rotation et une résolution. Cet objet sera ensuite utilisé pour les 3 use cases :

val metrics = DisplayMetrics().also { view_finder.display.getRealMetrics(it) }
        config = CameraConfiguration(
            aspectRatio = Rational(metrics.widthPixels, metrics.heightPixels),
            rotation = view_finder.display.rotation,
            resolution = Size(metrics.widthPixels, metrics.heightPixels)
        )

Si vous allez dans le CameraConfiguration.kt, ce ne sont rien d’autre que des variables. Cela nous évite simplement d’avoir à redéfinir la configuration à chaque instanciation des use cases.

Ensuite, vous remarquerez l’appel de la fameuse fonction magique bindToLifecycle() qui reçoit en paramètre le this, c’est-à-dire le fragment et les 3 use cases. Tout simplement, on branche le cycle de vie des use cases au cycle de vie du fragment. C’est le seul morceau de code utile à gérer les problèmes de cycle de vie présents dans Camera2 et Camera.
On peut maintenant se concentrer entièrement sur la configuration et l’utilisation des use cases.

Image Preview

La fonction concernant le premier use case est définie ici :

    private fun buildPreviewUseCase(): Preview {
        val previewConfig = PreviewConfig.Builder()
            .setTargetAspectRatio(config.aspectRatio)
            .setTargetRotation(config.rotation)
            .setTargetResolution(config.resolution)
            .setLensFacing(config.lensFacing)
            .build()
        val preview = Preview(previewConfig)

        preview.setOnPreviewOutputUpdateListener { previewOutput ->
            val parent = view_finder.parent as ViewGroup
            parent.removeView(view_finder)
            parent.addView(view_finder, 0)

            view_finder.surfaceTexture = previewOutput.surfaceTexture
        }

        return preview
    }

Dans un premier temps, on définit une configuration avec PreviewConfig dans laquelle on définit différentes valeurs récupérées dans notre CameraConfiguration. On pourra aussi définir quel objectif utiliser (avant ou arrière).

Il est important de préciser que si aucune configuration n’est définie, CameraX sera capable de trouver une configuration par défaut selon la taille et le nombre de pixels de votre écran, le nombre de pixels de vos objectifs photo, et de faire un choix optimal.

On instancie ensuite notre Preview.
Ensuite, Preview met à disposition une méthode pour définir un listener. A chaque fois que la preview sera active, elle va fournir un previewOuput. On doit alors mettre à jour notre viewFinder puis attacher la surfaceTexture de la previewOutput à la surfaceView de notre viewFinder. (J’espère que vous suivez toujours 😀 )
Voilà, notre preview est prête et est branchée au cycle de vie du composant père. Si vous lancez l’appli en commentant les 2 autres use cases, vous verrez ainsi le flux vidéo.

Image Analysis

Ce second use case est utilisé, comme son nom l’indique, pour de l’analyse d’image. Voici la fonction :

    private fun buildImageAnalysisUseCase(): ImageAnalysis {

        val analysisConfig = ImageAnalysisConfig.Builder()
            .setImageReaderMode(config.readerMode)
            .setImageQueueDepth(config.queueDepth)
            .build()
        val analysis = ImageAnalysis(analysisConfig)

        analysis.setAnalyzer { image, rotationDegrees ->
            val buffer = image.planes[0].buffer
            // Extract image data from callback object
            val data = buffer.toByteArray()
            // Convert the data into an array of pixel values
            val pixels = data.map { it.toInt() and 0xFF }
            // Compute average luminance for the image
            val luma = pixels.average()
            Log.d("CameraFragment", "Luminance: $luma")
        }

        return analysis
    }

La configuration est définie sur le même principe que Preview : on a un objet Config auquel on ajoute des attributs. Ici, on va spécifier que l’on souhaite récupérer la dernière image de la file d’attente (composée de 5 images), en éliminant les plus anciennes.
Si on augmente le nombre d’images dans la file d’attente, l’application paraîtra plus fluide mais l’utilisation de la mémoire sera plus importante.

On va ensuite définir un analyzer qui va nous fournir un ImageProxy. Il s’agit d’un objet regroupant l’ensemble des informations de l’image que l’on souhaite analyser. Il contient par exemple la luminance, le contraste, etc. Dans notre exemple, on extrait la luminance que l’on affiche dans les logs de notre IDE. Plus l’image à l’écran sera éclairée, plus la valeur sera élevée.
Un exemple très intéressant que j’ai d’ailleurs présenté lors du Meetup est la reconnaissance d’image : l’idée est tout simplement d’extraire les infos nécessaires et de les envoyer à l’outil ML Kit. Il est ainsi capable, avec une certaine probabilité, de reconnaître les objets de la Preview. J’avais repris l’application développée dans cet article.

Image Capture

Le dernier use case est donc Capture. Il s’agira de créer une capture de notre flux et de l’enregistrer :

    private fun buildImageCaptureUseCase(): ImageCapture {
        val captureConfig = ImageCaptureConfig.Builder()
            .setTargetAspectRatio(config.aspectRatio)
            .setTargetRotation(config.rotation)
            .setTargetResolution(config.resolution)
            .setFlashMode(config.flashMode)
            .setCaptureMode(config.captureMode)
            .build()
        val capture = ImageCapture(captureConfig)

        camera_capture_button.setOnClickListener {
            val fileName = "myPhoto"
            val fileFormat = ".jpg"
            val imageFile = createTempFile(fileName, fileFormat)

            capture.takePicture(imageFile, object : ImageCapture.OnImageSavedListener {
                override fun onImageSaved(file: File) {

                    val arguments = ImagePreviewFragment.arguments(file.absolutePath)
                    Navigation.findNavController(requireActivity(), R.id.mainContent)
                        .navigate(R.id.imagePreviewFragment, arguments)

                    Toast.makeText(requireContext(), "Image saved", Toast.LENGTH_LONG).show()
                }

                override fun onError(useCaseError: ImageCapture.UseCaseError, message: String, cause: Throwable?) {

                    Toast.makeText(requireContext(), "Error: $message", Toast.LENGTH_LONG).show()
                    Log.e("CameraFragment", "Capture error $useCaseError: $message", cause)
                }
            })
        }

        return capture
    }

Je ne vais pas repasser sur la configuration puisqu’elle est similaire à celle de la Preview. Une fois notre ImageCapture instancié, on va pouvoir définir un listener sur notre bouton qui s’exécutera à chaque clic. On crée un fichier temporaire, puis on appelle la fonction takePicture() mise à disposition par l’ImageCapture. Elle prend en paramètre notre fichier nouvellement créé. On va pouvoir ensuite surcharger onImageSaved() et onError(). Dans onImageSaved(), on passe le chemin du fichier créé au ImagePreviewFragment puis on affiche un petit Toast. En cas d’erreur, on affiche simplement son origine.
Vous n’avez rien de plus à faire.

Conclusion

Nous venons de voir au travers de cet article et de la démonstration qu’il est totalement possible d’avoir une application un minimum fonctionnelle en quelques lignes de code. Bien évidemment, elle est basique, et il vous faudra donc lire la documentation afin d’en développer une plus étoffée selon vos besoins, notamment si vous voulez ajouter les fonctionnalités avancées avec les Vendor Extensions.

La simplification du code a permis, selon les premiers retours présentés à la Google I/O, de réduire de 70% le code par rapport à Camera2, ce qui est plutôt considérable.

D’autre part, Google est en ce moment dans une phase d’amélioration de cette API, notamment grâce à un laboratoire appelé Automated CameraX test lab dans lequel ils regroupent plusieurs téléphones, de différents constructeurs et sous différentes versions d’Android et sur lesquels ils réalisent différents tests (tests fonctionnels, tests de performance, etc.). Ils ont ainsi pu déjà corriger des problèmes de crashs d’application ou d’orientation du téléphone par exemple.
A cela s’ajoute le fait qu’ils sont en pleine phase de recueillement des feedbacks de la part des développeurs. Ils ont donc une réelle volonté d’amélioration, et c’est plutôt prometteur.

Enfin, à l’heure où j’écris cet article, le support vidéo n’est pas encore disponible, il n’y a pas de documentation à ce sujet, mais on peut déjà trouver avec quelques recherches un objet appelé VideoCapture. La configuration est similaire à l’ImageCapture, et la prise en main ne devrait donc pas poser problème.

Grâce à cette application, Google souhaite donc “redorer” l’image du développement photo sous Android, et effacer les points pénibles que l’on pouvait rencontrer avec les précédentes APIs, à l’image de ce qui était présenté dans Jetpack, et c’est une très bonne chose !

Sources