ZIO & Cats Effect: une alliance réussie

Cats Effect est devenu une sorte de «flux réactifs» pour le monde Scala fonctionnel, vous permettant de combiner l'ensemble de l'écosystème diversifié des bibliothèques.

Beaucoup d'excellentes bibliothèques: http4s, fs2, doobie - sont implémentées uniquement sur la base des classes de type de Cats Effect. Et des bibliothèques comme ZIO et Monix, à leur tour, fournissent des instances de ces classes de types pour leurs types d'effets. Malgré certains problèmes qui seront corrigés dans la version 3.0, Cats Effect aide de nombreux contributeurs open source à prendre en charge de manière organique l'intégralité de l'écosystème fonctionnel du langage Scala. Les développeurs qui utilisent Cats Effect sont confrontés à un choix difficile: la mise en œuvre des effets à utiliser pour leurs applications.

Aujourd'hui, il existe trois alternatives:

  • Cats IO, implémentation de référence;
  • Monix, le type de données Task et sa réactivité en code;
  • ZIO, le type de données ZIO et sa portée de cross-threading.

Dans cet article, je vais essayer de vous prouver que pour créer votre application à l'aide de Cats Effect, ZIO est un bon choix avec des solutions et des capacités de conception assez différentes de l'implémentation de référence dans Cats IO.

1. Meilleure architecture MTL / Tagless-Final


MTL (Monad Transformers Library) est un style de programmation dans lequel les fonctions sont polymorphes par leur type d'effet et expriment leurs exigences à travers une «contrainte de classe de type». Dans Scala, cela est souvent appelé le style final sans étiquette (bien que ce ne soit pas la même chose), surtout lorsque la classe de type n'a pas de lois.

Il est bien connu qu'il est impossible de définir une instance globale pour des classes de type MTL classiques telles que Writer et State, ainsi que pour des types d'effet tels que Cats IO. Le problème est que les instances de ces classes de type pour ces types d'effets nécessitent l'accès à un état mutable, qui ne peut pas être créé globalement, car la création d'un état mutable est également un effet.

Pour de meilleures performances, cependant, il est important d'éviter les "transformateurs monades" et de fournir directement l'implémentation Write et State, en plus du type d'effet principal.

Pour y parvenir, les programmeurs Scala utilisent une astuce: ils créent (mais nettoient) des instances au niveau supérieur de leurs programmes avec des effets, puis les fournissent plus loin dans le programme sous forme d'implications locales:

Ref.make[AppState](initialAppState).flatMap(ref => implicit val monadState = new MonadState[Task, AppState] { def get: Task[AppState] = ref.get def set(s: AppState): Task[Unit] = ref.set(s).unit } myProgram ) 

Malgré le fait qu'une telle astuce soit utile, c'est toujours une «béquille». Dans un monde idéal, toutes les instances de classes de types pourraient être cohérentes (une instance par type) et ne pas être créées localement, générant des effets, puis s'enveloppant comme par magie dans des valeurs implicites à utiliser par les méthodes suivantes.

Une grande fonctionnalité de MTL / tagless-final est que vous pouvez définir directement la plupart des instances au-dessus du type de données ZIO à l'aide de l'environnement ZIO.

Voici une façon de créer une définition globale MonadState pour un type de données ZIO:

 trait State[S] { def state: Ref[S] } implicit def ZIOMonadState[S, R <: State[S], E]: MonadState[ZIO[R, E, ?], S] = new MonadState[ZIO[R, E, ?], S] { def get: ZIO[R, E, S] = ZIO.accessM(_.state.get) def set(s: S): ZIO[R, E, Unit] = ZIO.accessM(_.state.set(s).unit) } 

Une instance est désormais définie globalement pour tout environnement prenant en charge au moins State[S] .

De même pour FunctorListen , autrement connu sous le nom de MonadWriter :

 trait Writer[W] { def writer: Ref[W] } implicit def ZIOFunctorListen[W: Semigroup, R <: Writer[W], E]: FunctorListen[ZIO[R, E, ?], W] = new FunctorListen[ZIO[R, E, ?], W] { def listen[A](fa: ZIO[R, E, A]): ZIO[R, E, (A, W)] = ZIO.accessM(_.state.get.flatMap(w => fa.map(a => a -> w))) def tell(w: W): ZIO[R, E, W] = ZIO.accessM(_.state.update(_ |+| w).unit) } 

Et bien sûr, nous pouvons faire de même pour MonadError :

 implicit def ZIOMonadError[R, E]: MonadError[ZIO[R, E, ?], E] = new MonadError[ZIO[R, E, ?], E]{ def handleErrorWith[A](fa: ZIO[R, E, A])(f: E => ZIO[R, E, A]): ZIO[R, E, A] = fa catchAll f def raiseError[A](e: E): ZIO[R, E, A] = ZIO.fail(e) } 

Cette technique est facilement applicable à d'autres classes de type, y compris les classes de type tagless-final, dont les instances peuvent nécessiter la génération d'effets (modifications, configurations), les fonctions de test qui génèrent des effets (combinaison des effets d'environnement avec tagless-final), ou toute autre chose facilement accessible depuis l'environnement .

Plus de transformations monadiques lentes! Disons «non» à la création d'effets lors de l'initialisation des instances de la classe, aux implications locales. Plus besoin de béquilles. Immersion directe dans une programmation fonctionnelle pure.

2. Économiser des ressources pour les simples mortels


L'une des premières fonctionnalités de ZIO a été l'interruption - la capacité du runtime ZIO d'interrompre instantanément tout effet exécutable et garanti de libérer toutes les ressources. Une implémentation brute de cette fonctionnalité a atteint Cats IO.

Haskell a appelé une telle fonctionnalité une exception asynchrone, qui vous permet de créer et d'utiliser efficacement la latence, des opérations parallèles et compétitives efficaces et des calculs optimaux à l'échelle mondiale. De telles interruptions apportent non seulement de grands avantages, mais posent également des tâches complexes dans le domaine de la prise en charge d'un accès sécurisé aux ressources.

Les programmeurs sont habitués à suivre les erreurs dans les programmes grâce à une analyse simple. Cela peut également être fait avec ZIO, qui utilise un système de type pour aider à détecter les erreurs. Mais l'interruption est autre chose. Un effet créé à partir de nombreux autres effets peut être interrompu à n'importe quelle frontière.

Considérez l'effet suivant:

 for { handle <- openFile(file) data <- readFile(handle) _ <- closeFile(handle) } yield data 

La plupart des développeurs ne seront pas surpris de ce scénario: closeFile ne sera pas exécuté si readFile plante. Heureusement, le système d'effets a une ensuring ( guarantee dans Cats Effect) qui vous permet d'ajouter un gestionnaire final à l'effet finalizer, comme pour finalement.

Ainsi, le problème principal du code ci-dessus peut être facilement résolu:

 for { handle <- openFile(file) data <- readFile(handle).ensuring(closeFile(handle)) } yield () 

Maintenant, l'effet est devenu «résistant aux chutes», en ce sens que si le readFile casse, le fichier sera toujours fermé. Et si readFile réussit, le fichier sera également fermé. Dans tous les cas, le dossier sera fermé.

Mais pas tout à fait du tout. L'interruption signifie que l'effet peut être interrompu partout, même entre openFile et readFile . Si cela se produit, le fichier ouvert ne sera pas fermé et une fuite de ressources se produira.

Le modèle d'obtention et de libération d'une ressource est si répandu que ZIO a introduit un opérateur de support, qui est également apparu dans Cats Effect 1.0. L'instruction Bracket protège contre les interruptions: si la ressource est reçue avec succès, la libération se produit même si l'effet utilisant la ressource est interrompu. De plus, ni la réception ni la libération de la ressource ne peuvent être interrompues, offrant ainsi une garantie de sécurité des ressources.

En utilisant un support, l'exemple ci-dessus ressemblerait à ceci:

 openFile(file).bracket(closeFile(_))(readFile(_)) 

Malheureusement, la parenthèse n'encapsule qu'un seul modèle de consommation de ressources (assez général). Il en existe bien d'autres, notamment avec des structures de données compétitives, dont l'accès doit être accessible pour les interruptions, sinon des fuites sont possibles.

En général, tout travail d'interruption se résume à deux choses principales:

  • prévenir les interruptions dans certaines zones qui pourraient être interrompues;
  • permettre une interruption dans les zones susceptibles de geler.

ZIO a la capacité de mettre en œuvre les deux. Par exemple, nous pouvons développer notre propre version de bracket en utilisant des abstractions ZIO de bas niveau:

 ZIO.uninterruptible { for { a <- acquire exit <- ZIO.interruptible(use(a)) .run.flatMap(exit => release(a, exit) .const(exit)) b <- ZIO.done(exit) } yield b } 

Dans ce code, l' use(a) est la seule partie qui peut être interrompue. Le code environnant garantit l'exécution de la release dans tous les cas.

À tout moment, vous pouvez vérifier s'il existe une possibilité d'interruption. Pour cela, seules deux opérations primitives sont nécessaires (toutes les autres en sont dérivées).

Ce modèle d'interruption de composition complète vous permet d'implémenter non seulement une implémentation de support simple, mais également d'implémenter d'autres scénarios dans la gestion des ressources, dans lesquels un équilibre est trouvé entre les avantages et les inconvénients des interruptions.

Cats IO ne fournit qu'une seule opération pour contrôler les interruptions: le combinateur non annulable. Cela rend le bloc de code entier ininterrompu. Bien que cette opération soit rarement utilisée, elle peut entraîner une fuite de ressources ou des verrous.

En même temps, il s'avère que vous pouvez définir une primitive à l'intérieur de Cats IO, ce qui vous permet d'obtenir plus de contrôle sur les interruptions. L'implémentation très compliquée de Fabio Labella s'est avérée extrêmement lente.

ZIO vous permet d'écrire du code avec des interruptions, fonctionnant à un niveau élevé avec des instructions composées déclaratives, et ne vous oblige pas à choisir entre une complexité sévère combinée à de faibles performances et des fuites bloquantes.

De plus, la mémoire transactionnelle logicielle récemment ajoutée dans ZIO permet à l'utilisateur d'écrire de manière déclarative des structures de données et du code qui sont automatiquement asynchrones, compétitifs et autorisent les interruptions.

3. Finaliseurs garantis


Le bloc try / finally dans de nombreux langages de programmation offre les garanties nécessaires pour écrire du code synchrone sans fuite de ressources.

En particulier, ce bloc garantit ce qui suit: si un bloc try démarre l'exécution, alors le bloc finally s'exécutera lorsque try s'arrêtera.

Cette garantie s'applique à:

  • il y a des blocs "try / finally" imbriqués;
  • il y a des erreurs dans le "bloc try";
  • il y a des erreurs dans le bloc niché finalement.

L'opération «assurer» ZIO peut être utilisée comme try / enfin:

 val effect2 = effect.ensuring(cleanup) 

ZIO fournit les garanties suivantes pour "effect.ensuring (finalizer)": si "effect" a commencé à être exécuté, alors "finalizer" commencera son exécution lorsque "effect" s'arrêtera.

Comme try / enfin, ces garanties restent dans les cas suivants:

  • Il existe des compositions «assurant» imbriquées;
  • il y a des erreurs dans "l'effet";
  • il y a des erreurs dans le "finaliseur" imbriqué.

De plus, la garantie est maintenue même si l'effet est interrompu (les garanties sur la «tranche» sont similaires, en fait, la «tranche» est mise en œuvre sur la «garantie»).

Le type de données Cats IO fournit une autre garantie, plus faible. Pour «effect.guarantee (finalizer)», il est affaibli comme suit: si «effect» a commencé à être exécuté, «finalizer» commencera l'exécution lorsque «effect» s'arrêtera, si l'effet problématique n'est pas inséré dans «effect».

Une garantie plus faible se retrouve également dans la mise en œuvre du «bracket» dans Cats IO.

Pour obtenir une fuite de ressources, utilisez simplement l'effet utilisé à l'intérieur de l'effet "Guarantee" ou "bracket.use", composez-le avec quelque chose comme ceci:

 //   `interruptedFiber` -    val bigTrouble = interruptedFiber.join 

Lorsque bigTrouble est inséré de cette manière dans un autre effet, l'effet devient ininterrompu - aucun "finaliseur" défini via la "garantie", ou le nettoyage des ressources via le "support" ne sera pas exécuté. Tout cela conduit à un épuisement des ressources, même lorsqu'il y a un «finaliseur» dans le bloc.

Par exemple, le «finalizer» dans le code suivant ne démarrera jamais l'exécution:

 (IO.unit >> bigTrouble).guarantee(IO(println("Won't be executed!!!«))) 

Lors de l'évaluation du code sans prendre en compte le contexte global, il est impossible de déterminer si un effet, tel que "bigTrouble", sera inséré n'importe où dans l'effet "use" de l'opération "bracket" ou à l'intérieur du bloc "finalizer".

Par conséquent, vous ne pourrez pas savoir si le programme Cats IO fonctionnera avec des fuites de ressources ou des blocs de «finaliseur» manquants sans évaluer l’ensemble du programme. L'ensemble du programme ne peut être évalué que manuellement, et ce processus est toujours accompagné d'erreurs qui ne peuvent pas être vérifiées par le compilateur. En outre, ce processus doit être répété chaque fois que des modifications importantes du code se produisent.

ZIO a une implémentation personnalisée de «garantie» de Cats Effect, «GuaranteeCase» et «bracket». Les implémentations utilisent la sémantique ZIO native (pas la sémantique Cats IO), ce qui nous permet d'évaluer les problèmes possibles avec les fuites de ressources ici et maintenant, sachant que dans toutes les situations, les finaliseurs seront lancés et les ressources seront libérées.

4. Commutation stable


Cats Effect a la méthode «evalOn» de «ContextShift», qui vous permet de déplacer l'exécution de certains codes vers un autre contexte d'exécution.

Ceci est utile pour plusieurs raisons:

  • de nombreuses bibliothèques clientes vous obligent à travailler dans leur pool de threads;
  • Les bibliothèques d'interface utilisateur nécessitent certaines mises à jour pour se produire dans le thread d'interface utilisateur;
  • certains effets nécessitent une isolation sur des pools de threads adaptés à leurs spécificités.

L'opération «EvalOn» exécute l'effet là où il doit être exécuté, puis revient au contexte d'exécution d'origine. Par exemple:

 cs.evalOn(kafkaContext)(kafkaEffect) 

Remarque: Cats IO a une construction "shift" similaire, qui vous permet de basculer vers un contexte différent sans revenir en arrière, mais en pratique, ce comportement est rarement requis, donc "evalOn" est préféré.

L'implémentation ZIO de «evalOn» (réalisée sur le «verrou» primitif ZIO) fournit les garanties nécessaires pour comprendre de manière unique où fonctionne l'effet - l'effet sera toujours exécuté dans un contexte spécifique.

Cats IO a une garantie différente et plus faible - l'effet sera exécuté dans un certain contexte jusqu'au premier fonctionnement asynchrone ou commutation interne.

En considérant un petit morceau de code, il est impossible de savoir avec certitude si un effet asynchrone (ou une commutation imbriquée) sera intégré à l'effet qui basculera, car l'asynchronie n'est pas affichée dans les types.

Par conséquent, comme dans le cas de la sécurité des ressources, pour comprendre où l'effet Cats IO sera lancé, il est nécessaire d'étudier l'ensemble du programme. Dans la pratique, et d'après mon expérience, les utilisateurs de Cats IO sont surpris quand, en utilisant «evalOn» dans un contexte, on découvre par la suite que la plupart de l'effet a été accidentellement exécuté dans un autre.

ZIO vous permet de déterminer où les effets doivent être déclenchés et de croire que cela se produira dans tous les cas, quelle que soit la façon dont les effets sont intégrés dans d'autres effets.

5. Sécurité des messages d'erreur


Tout effet qui prend en charge la simultanéité, la simultanéité ou l'accès sécurisé aux ressources s'exécutera dans un modèle d'erreur linéaire: en général, toutes les erreurs ne peuvent pas être enregistrées.

Cela est vrai à la fois pour Throwable, un type d'erreur fixe intégré à Cats IO et pour le type d'erreur polymorphe pris en charge par ZIO.

Exemples de situations avec plusieurs erreurs ponctuelles:

  • Finalizer lève une exception;
  • deux effets (en baisse) sont combinés en exécution parallèle;
  • deux effets (en baisse) en état de course;
  • l'effet interrompu diminue avant de quitter la section à l'abri des interruptions.

Étant donné que toutes les erreurs ne sont pas enregistrées, ZIO fournit une structure de données «Cause [E]» basée sur un semirage libre (une abstraction de l'algèbre abstraite, sa connaissance n'est pas supposée ici), qui permet de connecter des erreurs série et parallèles pour tout type d'erreur. Pendant toutes les opérations (y compris le nettoyage pour un effet tombé ou interrompu), ZIO regroupe les erreurs dans la structure de données «Cause [E]». Cette structure de données est disponible à tout moment. Par conséquent, ZIO stocke toujours toutes les erreurs: elles sont toujours disponibles, elles peuvent être enregistrées, étudiées et transformées selon les besoins de l'entreprise.

Cats IO a choisi un modèle avec perte d'informations d'erreur. Alors que ZIO connectera les deux erreurs via Cause [E], Cats IO «perdra» l'un des messages d'erreur, par exemple, en appelant «e.printStackTrace ()» sur l'erreur qui se produit.

Par exemple, une erreur dans le «finaliseur» de ce code sera perdue.

 IO.raiseError(new Error("Error 1")).guarantee(IO.raiseError(new Error("Error 2«))) 

Cette approche du suivi des erreurs signifie que vous ne pouvez pas localiser et traiter localement tout le spectre des erreurs qui se produisent en raison de la combinaison d'effets. ZIO vous permet d'utiliser tout type d'erreur, y compris «Throwable» (ou des sous-types plus spécifiques comme «IOExceptio» ou une autre hiérarchie d'exceptions personnalisée), garantissant qu'aucune erreur n'est perdue pendant l'exécution du programme.

6. Asynchronie sans blocages


ZIO et Cats IO fournissent un constructeur qui vous permet de prendre du code avec un rappel et de l'envelopper en effet

Cette fonctionnalité est fournie via la classe de canal Async dans Cats Effect:

 val effect: Task[Data] = Async[Task].async(k => getDataWithCallbacks( onSuccess = v => k(Right(v)), onFailure = e => k(Left(e)) )) 

Cela crée un effet asynchrone qui, une fois exécuté, attendra que la valeur apparaisse, puis continue, et tout cela sera évident pour l'utilisateur de l'effet. Par conséquent, la programmation fonctionnelle est si intéressante pour développer du code asynchrone.

Notez que dès que le code de rappel se transforme en effet, la fonction de rappel (ici on l'appelle `k`) est appelée. Cette fonction de rappel se termine avec une valeur de succès / erreur. Lorsque cette fonction de rappel est appelée, l'exécution de l'effet (précédemment interrompu) reprend.

ZIO garantit que l'effet reprendra son exécution sur le pool de threads d'exécution si l'effet n'a pas été affecté à un contexte spécial particulier ou à un autre contexte auquel l'effet a été attaché.

Cats IO reprend l'effet sur le thread de rappel. La différence entre ces options est assez profonde: le thread provoquant le rappel ne s'attend pas à ce que le code de rappel soit exécuté pour toujours, mais n'autorise qu'un léger délai avant le retour du contrôle. D'un autre côté, Cats IO n'offre aucune garantie: le thread appelant, le rappel de lancement, peut se bloquer, en attendant un temps indéfini lorsque le contrôle d'exécution revient.

Les versions antérieures des structures de données concurrentielles dans Cats Effect («différé», «sémaphore») ont repris des effets qui ne renvoyaient pas le contrôle de l'exécution au thread appelant. En conséquence, des problèmes liés aux blocages et à un programmateur d'exécution cassé ont été découverts dans ces derniers. Bien que tous ces problèmes aient été trouvés, ils ne sont résolus que pour les structures de données compétitives dans Cats Effect.

Le code utilisateur qui utilise une approche similaire à celle de Cats IO se heurtera à de tels problèmes, car ces tâches ne sont pas déterministes, les erreurs ne peuvent se produire que très rarement pendant l'exécution, ce qui rend le débogage et la détection des problèmes un processus difficile.

ZIO fournit une protection contre les interblocages et un planificateur de tâches normal, et oblige également l'utilisateur à choisir explicitement le comportement de Cats IO (par exemple, en utilisant "unsafeRun" sur "Promise", ce qui s'est terminé par un effet asynchrone repris).

Bien qu'aucune des solutions ne convienne à tous les cas et que ZIO et Cats IO offrent suffisamment de flexibilité pour résoudre toutes les situations (de différentes manières), choisir ZIO signifie utiliser "Async" sans aucun souci et vous oblige à mettre le code du problème dans "unsafeRun", qui est connu pour provoquer une impasse

7. Compatible avec Future


L'utilisation de «Future» de la bibliothèque standard de Scala est une réalité pour un grand nombre de bases de code. ZIO est livré avec une méthode «fromFuture», qui fournit un contexte d'exécution prêt à l'emploi:

 ZIO.fromFuture(implicit ec => // Create some Future using `ec`: ??? ) 

Lorsque cette méthode est utilisée pour envelopper Future dans un effet, ZIO peut définir où Future sera exécuté et d'autres méthodes, telles que evalOn, transfèreront correctement Future dans le contexte d'exécution souhaité. Cats IO accepte "Future", qui a été créé avec un "ExecutionContext" externe. Cela signifie que Cats IO ne peut pas déplacer l'exécution de Future selon les exigences des méthodes evalOn ou shift. De plus, cela oblige l'utilisateur à déterminer le contexte d'exécution pour Future, ce qui signifie une sélection étroite et un environnement séparé.

Étant donné que le ExecutionContext fourni peut être ignoré, ZIO peut être représenté comme la somme des capacités Cats IO, garantissant une interaction plus fluide et plus précise avec Future dans le cas général, mais il existe toujours des exceptions.

8. Blocage des E / S


Comme indiqué dans l'article « Thread Pool. Meilleures pratiques avec ZIO ”, pour les applications serveur, au moins deux pools distincts sont requis pour une efficacité maximale:

  • pool fixe pour CPU / effets asynchrones;
  • dynamique, avec la possibilité d'augmenter le nombre de threads de blocage.

La décision d'exécuter tous les effets sur un pool de threads fixe entraînera un jour un blocage, tandis que le déclenchement de tous les effets sur un pool dynamique peut entraîner une perte de performances.

Sur la JVM, ZIO propose deux opérations qui prennent en charge les effets de blocage:

  • Opérateur "Blocking (effect"), qui commute l'exécution d'un certain effet dans un pool de threads de blocage qui ont de bons préréglages qui peuvent être modifiés si vous le souhaitez);
  • «effectBlocking(effect)» , , .

, , , «blocking». , - , , «effectBlocking» , ZIO ( ).

Cats IO , . , «blocking», «evalOn», , , .

( ZIO) (, ), .

9.


, Scala, :

  • «ReaderT»/ «Kleisli», ;
  • «EitherT», ( «OptionT», «EitherT» «Unit» ).

, (, http4s «Kleisli» «OptionT»). («effect totation»), ZIO «reader» «typed error» ZIO. «reader» «typed error» , ZIO , . , «Task[A]», «reader» «typed errors».

ZIO () - . , ZIO , .

Cats IO . , , «reader» «typed errors» «state», «writer» , .

ZIO 8 Cats IO . , Scala .

10.


ZIO , , . , Scala, .

ZIO 2000 , «typed errors» , — 375 . Scala , . , , .

:

  • ;
  • ;
  • , ;
  • .

. , - , .

- . , . ZIO . Cats IO , , ZIO ( , ).

11.


ZIO , , - .

  • , : «ZIO. succeed» «Applicative[F].pure», «zip» «Apply[F].product», «ZIO.foreach» «Traverse[F].traverse».
  • (Cats, Cats Effect, Scalaz ).
  • , ( «Runtime», Cats Effect - Cats Effect). — Cats IO.
  • .
  • . : "zip"/"zipPar", "ZIO.foreach"/"ZIO.foreachPar", "ZIO.succeed"/"ZIO.succeedLazy«.
  • , «». ZIO IDE.
  • Scala ZIO : «ZIO.fromFuture», «ZIO.fromOption», «ZIO.fromEither», «ZIO.fromTry».
  • «».

, Scala, , ZIO , , , ZIO, . Cats IO , Cats.

, , , ( , , ).

12.


ZIO — - , .

:

  • , «Ref», «Promise», «Queue», «Semaphore» «Stream» //;
  • STM, , , ;
  • «Schedule», ;
  • «Clock», «Random», «Console» «System» , ;
  • , .

- Cats IO . Cats IO , ( ) .

Conclusion


Cats Effect Scala-, , .

, Cats Effect, , Cats Effect : Cats IO, Monix, Zio.

, . , , , : ZIO Cats Effect .

Scala — . , Scala. ScalaConf , 18 , John A De Goes .

Source: https://habr.com/ru/post/fr472386/


All Articles