Récemment, j'entends souvent que Java est devenu un langage obsolète dans lequel il est difficile de créer de grandes applications prises en charge. En général, je ne partage pas ce point de vue. À mon avis, la langue est toujours appropriée pour écrire des applications rapides et bien organisées. Cependant, j'avoue, il arrive aussi que lorsque vous écrivez du code tous les jours, vous pensez parfois: "comment cela serait résolu avec cette chose d'un autre langage". Dans cet article, je voulais partager ma douleur et mon expérience. Nous examinerons certains problèmes Java et comment ils pourraient être résolus dans Kotlin / Scala. Si vous avez un sentiment similaire ou si vous vous demandez simplement ce que d'autres langues peuvent offrir, je vous le demande sous cat.

Extension des classes existantes
Il arrive parfois qu'il soit nécessaire d'étendre une classe existante sans modifier son contenu interne. Autrement dit, après avoir créé la classe, nous la complétons avec d'autres classes. Prenons un petit exemple. Supposons que nous ayons une classe qui est un point dans un espace à deux dimensions. À différents endroits de notre code, nous devons le sérialiser à la fois en Json et en XML.
Voyons à quoi cela peut ressembler en Java en utilisant le modèle Visitorpublic class DotDemo { public static class Dot { private final int x; private final int y; public Dot(int x, int y) { this.x = x; this.y = y; } public String accept(Visitor visitor) { return visitor.visit(this); } public int getX() { return x; } public int getY() { return y; } } public interface Visitor { String visit(Dot dot); } public static class JsonVisitor implements Visitor { @Override public String visit(Dot dot) { return String .format("" + "{" + "\"x\"=%d, " + "\"y\"=%d " + "}", dot.getX(), dot.getY()); } } public static class XMLVisitor implements Visitor { @Override public String visit(Dot dot) { return "<dot>" + "\n" + " <x>" + dot.getX() + "</x>" + "\n" + " <y>" + dot.getY() + "</y>" + "\n" + "</dot>"; } } public static void main(String[] args) { Dot dot = new Dot(1, 2); System.out.println("-------- JSON -----------"); System.out.println(dot.accept(new JsonVisitor())); System.out.println("-------- XML ------------"); System.out.println(dot.accept(new XMLVisitor())); } }
En savoir plus sur le motif et son utilisation Il semble assez volumineux, non? Est-il possible de résoudre ce problème de manière plus élégante à l'aide d'outils linguistiques? Scala et Kotlin acquiescent positivement. Ceci est réalisé en utilisant le mécanisme d'extension de méthode. Voyons à quoi ça ressemble.
Extensions à Kotlin data class Dot (val x: Int, val y: Int)
Extensions à Scala object DotDemo extends App {
Ça a l'air beaucoup mieux. Parfois, cela ne suffit vraiment pas avec une cartographie abondante et d'autres transformations.
Chaîne informatique multi-thread
Maintenant, tout le monde parle d'informatique asynchrone et des interdictions de verrouillage dans les threads d'exécution. Imaginons le problème suivant: nous avons plusieurs sources de nombres, où le premier renvoie simplement le nombre, le second - renvoie la réponse après avoir calculé le premier. Par conséquent, nous devons renvoyer une chaîne avec deux nombres.
Schématiquement, cela peut être représenté comme suit Essayons d'abord de résoudre le problème en Java
Exemple Java private static CompletableFuture<Optional<String>> calcResultOfTwoServices ( Supplier<Optional<Integer>> getResultFromFirstService, Function<Integer, Optional<Integer>> getResultFromSecondService ) { return CompletableFuture .supplyAsync(getResultFromFirstService) .thenApplyAsync(firstResultOptional -> firstResultOptional.flatMap(first -> getResultFromSecondService.apply(first).map(second -> first + " " + second ) ) ); }
Dans cet exemple, notre numéro est encapsulé dans Facultatif pour contrôler le résultat. De plus, toutes les actions sont effectuées dans CompletableFuture pour un travail pratique avec les threads. L'action principale se déroule dans la méthode thenApplyAsync. Dans cette méthode, nous obtenons facultatif comme argument. Ensuite, flatMap est appelé pour contrôler le contexte. Si l'option facultative reçue est renvoyée comme optionnel.vide, nous n'irons pas au deuxième service.
Le total que nous avons reçu? En utilisant CompletableFuture et des fonctionnalités optionnelles avec flatMap et map, nous avons pu résoudre le problème. Bien que, à mon avis, la solution ne semble pas la plus élégante: avant de comprendre quel est le problème, vous devez lire le code. Et que se passerait-il avec deux sources de données ou plus?
La langue pourrait-elle nous aider d'une manière ou d'une autre à résoudre le problème. Et encore une fois, tournez-vous vers Scala. Voici comment vous pouvez le résoudre avec les outils Scala.
Exemple Scala def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]) = Future { getResultFromFirstService() }.flatMap { firsResultOption => Future { firsResultOption.flatMap(first => getResultFromSecondService(first).map(second => s"$first $second" ) )} }
Cela semble familier. Et ce n'est pas un hasard. Il utilise la bibliothèque scala.concurrent, qui est principalement un wrapper sur java.concurrent. Eh bien, quoi d'autre Scala peut-il nous aider? Le fait est que les chaînes de la forme flatMap, ..., map peuvent être représentées comme une séquence dans for.
Exemple de deuxième version sur Scala def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]) = Future { getResultFromFirstService() }.flatMap { firstResultOption => Future { for { first <- firstResultOption second <- getResultFromSecondService(first) } yield s"$first $second" } }
C'est mieux, mais essayons à nouveau de changer notre code. Connectez la bibliothèque de chats.
Troisième version de l'exemple Scala import cats.instances.future._ def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]): Future[Option[String]] = (for { first <- OptionT(Future { getResultFromFirstService() }) second <- OptionT(Future { getResultFromSecondService(first) }) } yield s"$first $second").value
Maintenant ce n'est pas si important ce que signifie OptionT. Je veux juste montrer à quel point cette opération peut être simple et courte.
Mais qu'en est-il de Kotlin? Essayons de faire quelque chose de similaire sur les coroutines.
Exemple de Kotlin val result = async { withContext(Dispatchers.Default) { getResultFromFirstService() }?.let { first -> withContext(Dispatchers.Default) { getResultFromSecondService(first) }?.let { second -> "$first $second" } } }
Ce code a ses propres particularités. Tout d'abord, il utilise le mécanisme Kotlin de la corutine. Les tâches dans async sont effectuées dans un pool de threads spécial (pas ForkJoin) avec un mécanisme de vol de travail. Deuxièmement, ce code nécessite un contexte spécial, à partir duquel des mots clés comme async et withContext sont extraits.
Si vous avez aimé Scala Future, mais que vous écrivez sur Kotlin, vous pouvez faire attention aux emballages Scala similaires.
Tapez tel.Travailler avec des flux
Pour montrer le problème plus en détail ci-dessus, essayons d'étendre l'exemple précédent: nous nous tournons vers les outils de programmation Java les plus populaires -
Reactor , sur Scala -
fs2 .
Envisagez la lecture ligne par ligne de 3 fichiers dans un flux et essayez d'y trouver des correspondances.
Voici la façon la plus simple de le faire avec Reactor en Java.
Exemple de réacteur en Java private static Flux<String> glueFiles(String filename1, String filename2, String filename3) { return getLinesOfFile(filename1).flatMap(lineFromFirstFile -> getLinesOfFile(filename2) .filter(line -> line.equals(lineFromFirstFile)) .flatMap(lineFromSecondFile -> getLinesOfFile(filename3) .filter(line -> line.equals(lineFromSecondFile)) .map(lineFromThirdFile -> lineFromThirdFile ) ) ); }
Pas le moyen le plus optimal, mais indicatif. Il n'est pas difficile de deviner qu'avec plus de logique et d'accès à des ressources tierces, la complexité du code augmentera. Voyons l'alternative sucrée de la syntaxe for-comprehension.
Exemple de FS2 sur Scala def findUniqueLines(filename1: String, filename2: String, filename3: String): Stream[IO, String] = for { lineFromFirstFile <- readFile(filename1) lineFromSecondFile <- readFile(filename2).filter(_.equals(lineFromFirstFile)) result <- readFile(filename3).filter(_.equals(lineFromSecondFile)) } yield result
Il semble qu'il n'y ait pas beaucoup de changements, mais cela semble beaucoup mieux.
Séparation de la logique métier avec HigherKind et implicite
Allons de l'avant et voyons comment nous pouvons améliorer notre code autrement. Je tiens à vous avertir que la prochaine partie peut ne pas être immédiatement compréhensible. Je veux montrer les possibilités et laisser la méthode d'implémentation hors de la boucle pour l'instant. Une explication détaillée nécessite au moins un article séparé. S'il y a un désir / commentaires - je suivrai dans les commentaires pour répondre aux questions et rédiger la deuxième partie avec une description plus détaillée :)
Imaginez donc un monde dans lequel nous pouvons définir la logique métier quels que soient les effets techniques pouvant survenir au cours du développement. Par exemple, nous pouvons effectuer chaque demande ultérieure auprès d'un SGBD ou d'un service tiers dans un thread distinct. Dans les tests unitaires, nous devons faire un stupide mok dans lequel rien ne se passe. Et ainsi de suite.
Peut-être que certaines personnes ont pensé au moteur BPM, mais aujourd'hui ce n'est pas à propos de lui. Il s'avère que ce problème peut être résolu à l'aide de certains modèles de programmation fonctionnelle et de prise en charge du langage. À un endroit, nous pouvons décrire la logique comme celle-ci.
En un seul endroit, nous pouvons décrire la logique comme celle-ci def makeCatHappy[F[_]: Monad: CatClinicClient](): F[Unit] = for { catId <- CatClinicClient[F].getHungryCat memberId <- CatClinicClient[F].getFreeMember _ <- CatClinicClient[F].feedCatByFreeMember(catId, memberId) } yield ()
Ici F [_] (lu comme "ef avec un trou") signifie un type sur un type (parfois on l'appelle une espèce dans la littérature russe). Il peut s'agir d'une liste, d'un ensemble, d'une option, d'un avenir, etc. Tout cela est un conteneur d'un type différent.
Ensuite, nous changeons simplement le contexte de l'exécution du code. Par exemple, pour l'environnement prod, nous pouvons faire quelque chose comme ça.
À quoi pourrait ressembler le code de combat? class RealCatClinicClient extends CatClinicClient[Future] { override def getHungryCat: Future[Int] = Future { Thread.sleep(1000)
À quoi pourrait ressembler le code de test class MockCatClinicClient extends CatClinicClient[Id] { override def getHungryCat: Id[Int] = 40 override def getFreeMember: Id[Int] = 2 override def feedCatByFreeMember(catId: Int, memberId: Int): Id[Unit] = { println("so testy!")
Notre logique métier ne dépend désormais plus des frameworks, des clients http et des serveurs que nous avons utilisés. À tout moment, nous pouvons changer le contexte et l'outil changera.
Ceci est réalisé par des fonctionnalités telles que HigherKind et implicite. Prenons le premier, et pour cela nous reviendrons à Java.
Regardons le code public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { } }
Combien de façons de renvoyer le résultat? Beaucoup. Nous pouvons soustraire, ajouter, échanger et bien plus encore. Imaginez maintenant que l'on nous ait donné des exigences claires. Nous devons ajouter le premier nombre au second. De combien de façons pouvons-nous faire cela?
si vous essayez dur et vous affinez beaucoup ... en général, un seul.
Le voici public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { return CompletableFuture.supplyAsync(() -> x + y); } }
Mais que se passe-t-il si l'appel à cette méthode est masqué et que nous voulons tester dans un environnement à thread unique? Ou si nous voulons changer l'implémentation de la classe en supprimant / remplaçant CompletableFuture. Malheureusement, en Java, nous sommes impuissants et devons changer l'API de la méthode. Jetez un œil à l'alternative à Scala.
Tenez compte du trait trait Calcer[F[_]] { def getCulc(x: Int, y: Int): F[Int] }
Nous créons des traits (l'analogue le plus proche est l'interface en Java) sans spécifier le type de conteneur de notre valeur entière.
De plus, nous pouvons simplement créer diverses implémentations si nécessaire.
Comme ça val futureCalcer: Calcer[Future] = (x, y) => Future {x + y} val optionCalcer: Calcer[Option] = (x, y) => Option(x + y)
De plus, il y a une chose aussi intéressante qu'Implicite. Il vous permet de créer le contexte de notre environnement et de sélectionner implicitement l'implémentation du trait en fonction de celui-ci.
Comme ça def userCalcer[F[_]](implicit calcer: Calcer[F]): F[Int] = calcer.getCulc(1, 2) def doItInFutureContext(): Unit = { implicit val futureCalcer: Calcer[Future] = (x, y) => Future {x + y} println(userCalcer) } doItInFutureContext() def doItInOptionContext(): Unit = { implicit val optionCalcer: Calcer[Option] = (x, y) => Option(x + y) println(userCalcer) } doItInOptionContext()
Implicite simplifié avant val - ajouter une variable à l'environnement actuel, et implicite comme argument à une fonction signifie prendre la variable de l'environnement. Cela rappelle quelque peu une fermeture implicite.
Dans l'ensemble, il s'avère que nous pouvons créer un environnement de combat et de test de manière assez concise sans utiliser de bibliothèques tierces.
Mais qu'en est-kotlinEn fait, d'une manière similaire, nous pouvons faire en kotlin:
interface Calculator<T> { fun eval(x: Int, y: Int): T } object FutureCalculator : Calculator<CompletableFuture<Int>> { override fun eval(x: Int, y: Int) = CompletableFuture.supplyAsync { x + y } } object OptionalCalculator : Calculator<Optional<Int>> { override fun eval(x: Int, y: Int) = Optional.of(x + y) } fun <T> Calculator<T>.useCalculator(y: Int) = eval(1, y) fun main() { with (FutureCalculator) { println(useCalculator(2)) } with (OptionalCalculator) { println(useCalculator(2)) } }
Ici, nous définissons également le contexte d'exécution de notre code, mais contrairement à Scala, nous le signalons explicitement.
Merci à
Beholder pour l'exemple.
Conclusion
En général, ce ne sont pas toutes mes douleurs. Il y en a plus. Je pense que chaque développeur a le sien. Pour ma part, j'ai réalisé que l'essentiel est de comprendre ce qui est vraiment nécessaire au bénéfice du projet. Par exemple, à mon avis, si nous avons un service de repos qui agit comme une sorte d'adaptateur avec un tas de mappage et une logique simple, alors toutes les fonctionnalités ci-dessus ne sont pas très utiles. Spring Boot + Java / Kotlin est parfait pour de telles tâches. Il existe d'autres cas avec un grand nombre d'intégrations et d'agrégation de certaines informations. Pour de telles tâches, à mon avis, la dernière option semble très bonne. En général, c'est cool si vous pouvez choisir un outil basé sur une tâche.
Ressources utiles:
- Lien vers tous les exemples complets ci-dessus
- Plus sur Corotin à Kotlin
- Un bon livre d'introduction sur la programmation fonctionnelle à Scala