Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

Évaluer Kotlin dans des projets réels

Read this post in other languages:

Article de notre invité Urs Peter, Ingénieur logiciel senior et Formateur Kotlin certifié JetBrains. Pour les lecteurs qui souhaitent une approche plus structurée pour développer leurs compétences en Kotlin, Urs anime également le Kotlin Upskill Program à la Xebia Academy.

Ceci est le deuxième article du Guide complet pour réussir l’adoption de Kotlin dans un environnement Java, qui explique concrètement comment l’adoption de Kotlin peut être progressivement déployée au sein d’une équipe, en partant de la curiosité d’un développeur pour aboutir à une transformation à l’échelle de l’entreprise.

Lire la première partie : Premiers pas avec Kotlin pour les développeurs Java


L’étape d’évaluation : au-delà de l’expérimentation avec Kotlin

Une fois que vous êtes à l’aise avec Kotlin dans les tests, il est temps de procéder à une évaluation plus substantielle. Pour cela, il y deux principales approches :

  1. Créer un microservice / une application en Kotlin
  2. Étendre / convertir une application Java existante

1. Créer un microservice / une application en Kotlin

Créer une application ou un microservice à partir de zéro vous apporte l’expérience Kotlin complète, sans les contraintes d’un code hérité. Cette approche offre souvent la meilleure expérience d’apprentissage et c’est celle qui démontre le plus clairement les points forts de Kotlin.

Astuce de pro : demandez l’aide d’experts à ce stade. Bien que les développeurs soient naturellement confiants dans leurs capacités, éviter les erreurs de débutant comme écrire du code Kotlin dans le style Java ou ne pas utiliser les bibliothèques Kotlin natives peut vous épargner des mois de dette technique.

Si vous êtes un développeur Java, voici comment éviter les pièges courants lorsque vous utilisez Kotlin :

Piège : choisir un autre framework que celui que vous utilisez en Java.

Astuce : gardez votre framework actuel

Vous utilisiez probablement Spring Boot avec Java, alors utilisez-le aussi avec Kotlin. La prise en charge de Spring Boot pour Kotlin est de premier ordre, il n’y a donc pas d’intérêt particulier à utiliser autre chose. De plus, cela vous obligerait à apprendre un nouveau framework en plus d’un nouveau langage, ce qui ne ferait qu’ajouter de la complexité sans offrir aucun avantage.

Important : Spring interfère avec le principe de « l’héritage par conception » de Kotlin, qui requiert de marquer explicitement les classes comme ouvertes pour pouvoir les étendre.

Afin de ne pas avoir à ajouter le mot-clé « open » à toutes les classes liées à Spring (comme @Configuration, etc.), utilisez le plugin de build suivant : https://kotlinlang.org/docs/all-open-plugin.html#spring-support. Si vous créez un projet Spring avec l’outil en ligne bien connu Spring initializr, ce plugin de build est déjà configuré pour vous.

Piège : écrire du Kotlin à la manière de Java, en s’appuyant sur les API Java courantes plutôt que sur la bibliothèque standard de Kotlin.

Cette liste peut être très longue, concentrons-nous donc sur les pièges les plus courants :

Piège 1 : utiliser Java Streams plutôt que Kotlin Collections

Astuce : utilisez toujours les collections Kotlin.

Les collections Kotlin sont entièrement interopérables avec les collections Java et fournissent des fonctions d’ordre supérieur concises et riches en fonctionnalités, éliminant ainsi le besoin de stream Java. 

Voici un exemple qui vise à sélectionner les 3 produits qui génèrent le plus de chiffre d’affaires (prix * quantité vendue) regroupés par catégorie de produits :

Java

record Product(String name, String category, double price, int sold){}

List products = List.of(
           new Product("Lollipop", "sweets", 1.2, 321),
           new Product("Broccoli", "vegetable", 1.8, 5);

Map<String, List> top3RevenueByCategory =
       products.stream()
          .collect(Collectors.groupingBy(
                Product::category,
                Collectors.collectingAndThen(
                    Collectors.toList(),
                    list -> list.stream()
                              .sorted(Comparator.comparingDouble(
                                  (Product p) -> p.price() * p.sold())
                                   .reversed())
                                   .limit(3)
                                   .toList()
                       		)
          )
);

Kotlin

val top3RevenueByCategory: Map<String, List> =
   products.groupBy { it.category }
       .mapValues { (_, list) ->
           list.sortedByDescending { it.price * it.sold }.take(3)
       }

L’interopérabilité de Kotlin avec Java vous permet de travailler avec des classes et des records Java comme s’ils étaient natifs de Kotlin, bien que vous puissiez également utiliser une classe (data) Kotlin à la place.

Piège 2 : continuer à utiliser les types optional de Java.

Astuce : adoptez les types nullables

L’une des principales raisons pour lesquelles les développeurs Java passent à Kotlin est la prise en charge de la nullabilité intégrée à Kotlin. Adieu les NullPointerExceptions ! Essayez donc d’utiliser uniquement des types nullables, et plus de ne plus utiliser types optionnels. Avez-vous encore des types optionnels dans vos interfaces ? Voici comment vous en débarrasser facilement en les convertissant en types nullables :

Kotlin

//Let’s assume this repository is hard to change, because it’s a library you depend on
class OrderRepository {
      //it returns Optional, but we want nullable types
      fun getOrderBy(id: Long): Optional = …
}

//Simply add an extension method and apply the orElse(null) trick
fun OrderRepository.getOrderByOrNull(id: Long): Order? = 
                                    getOrderBy(id).orElse(null)

//Now enjoy the safety and ease of use of nullable types:

//Past:
 val g = repository.getOrderBy(12).flatMap { product ->
     product.goody.map { it.name }
}.orElse("No goody found")

//Future:
 val g = repository.getOrderByOrNull(12)?.goody?.name ?: "No goody found"

Piège 3 : continuer à utiliser des wrappers statiques.

Astuce : adoptez les méthodes d’extension

Les méthodes d’extension vous apportent de nombreux avantages :

  • Elles rendent votre code beaucoup plus fluide et lisible que les wrappers.
  • Vous pouvez les trouver à l’aide de la saisie semi-automatique du code, ce qui n’est pas le cas pour les wrappers.
  • Comme les extensions doivent être importées, elles vous permettent d’utiliser de façon sélective des fonctionnalités étendues dans une section spécifique de votre application.

Java

//Very common approach in Java to add additional helper methods
public class DateUtils {
      public static final DateTimeFormatter DEFAULT_DATE_TIME_FORMATTER = 
           DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

      public String formatted(LocalDateTime dateTime, 
		              DateTimeFormatter formatter) {
         return dateTime.format(formatter);
      }

      public String formatted(LocalDateTime dateTime) {
         return formatted(dateTime, DEFAULT_DATE_TIME_FORMATTER);
      }
}

//Usage
 formatted(LocalDateTime.now());

Kotlin

val DEFAULT_DATE_TIME_FORMATTER: DateTimeFormatter = 
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

//Use an extension method, including a default argument, which omits the need for an overloaded method. 
fun LocalDateTime.asString(
   formatter: DateTimeFormatter = DEFAULT_DATE_TIME_FORMATTER): String = 
      this.format(formatter)

//Usage
LocalDateTime.now().formatted()

Notez que Kotlin propose des méthodes et variables de niveau supérieur. Cela implique que nous pouvons simplement déclarer le DEFAULT_DATE_TIME_FORMATTER au niveau supérieur sans avoir besoin de le lier à un objet comme c’est le cas en Java, par exemple.

Piège 4 : Utiliser les API Java (de manière maladroite)

Astuce : utilisez l’alternative élégante de Kotlin. 

La bibliothèque standard Kotlin utilise des méthodes d’extension pour améliorer la convivialité des bibliothèques Java, même si l’implémentation sous-jacente reste en Java. Presque toutes les bibliothèques et tous les frameworks majeurs tiers, comme Spring, ont fait de même.

Exemple de la bibliothèque standard :

Java

String text;
try (
       var reader = new BufferedReader(
                  new InputStreamReader(new FileInputStream("out.txt"), 
            StandardCharsets.UTF_8))) {
   text = reader
            .lines()
            .collect(Collectors.joining(System.lineSeparator()));
}
System.out.println("Downloaded text: " +  text + "n");

Kotlin

//Kotlin has enhanced the Java standard library with many powerful extension methods, like on java.io.*, which makes input stream processing a snap due to its fluent nature, fully supported by code completion

val text = FileInputStream("path").use {
             it.bufferedReader().readText()
           }
println("Downloaded text: $textn");

Exemple de Spring :
Java

final var books =  RestClient.create()
       .get()
       .uri("http://.../api/books")
       .retrieve()
       .body( new ParameterizedTypeReference<List>(){}); // ⇦ inconvenient ParameterizedTypeReference

Kotlin

import org.springframework.web.client.body
val books = RestClient.create()
   .get()
   .uri("http://.../api/books")
   .retrieve()
   .body<List>() //⇦ Kotlin offers an extension that only requires the type without the need for a ParameterizedTypeReference

Piège 5 : utiliser un fichier séparé pour chaque classe publique

Astuce : combinez des classes publiques connexes dans un même fichier. 

Cela vous permet de bien comprendre comment un (sous-)domaine est structuré sans avoir à naviguer dans des dizaines de fichiers.

Java

Kotlin

//For domain classes consider data classes - see why below
data class User(val email: String,
            //Use nullable types for safety and expressiveness
           val avatarUrl: URL? = null, 
           var isEmailVerified: Boolean)

data class Account(val user:User,
              val address: Address,
              val mfaEnabled:Boolean,
              val createdAt: Instant)

data class Address(val street: String,
              val city: String,
              val postalCode: String)

Piège 6 : s’appuyer sur le paradigme de programmation mutable

Astuce : adoptez l’immutabilité, le comportement par défaut en Kotlin

La tendance dans de nombreux langages de programmation (y compris en Java) est claire : l’immutabilité l’emporte sur la mutabilité. 

La raison est simple : l’immutabilité empêche les effets secondaires imprévus, ce qui rend le code plus sûr, plus prévisible et plus facile à comprendre. Elle simplifie également la concurrence, car les données immuables peuvent être partagées librement entre les threads sans risque des conditions de concurrence.

C’est pourquoi la plupart des langages modernes, et notamment Kotlin, mettent l’accent sur l’immutabilité par défaut ou l’encouragent fortement. En Kotlin, l’immutabilité est le comportement par défaut, bien que la mutabilité reste possible si elle est réellement nécessaire.

Voici un guide rapide des puissant pack d’immutabilité de Kotlin :

1. Utilisez val plutôt que var

Préférez val à var. IntelliJ IDEA vous notifiera si vous avez utilisé une var pour laquelle une val pourrait être utilisée. 

2. Utilisez des classes de données (immuables) avec copy(…)

Pour les classes liées au domaine, utilisez des classes data avec val. Les classes data de Kotlin sont souvent comparées aux records de Java. Bien qu’il y ait un certain chevauchement, les classes data offrent la fonctionnalité copy(…), dont l’absence rend la transformation de record, souvent requise dans la logique métier, si fastidieuse :

Java

//only immutable state
public record Person(String name, int age) {
   //Lack of default parameters requires overloaded constructor
   public Person(String name) { 
       this(name, 0);
   }
   //+ due to lack of String interpolation
  public String sayHi() {
       return "Hello, my name is " + name + " and I am " + age + " years old.";
   }
}

//Usage
final var jack = new Person("Jack", 42);
jack: Person[name=Jack, age=5]

//The issue is here: transforming a record requires manually copying the identical state to the new instance ☹️
final var fred = new Person("Fred", jack.name);

Kotlin

//also supports mutable state (var)
data class Person(val name: String,
                  val age: Int = 0) {
  //string interpolation
  fun sayHi() = "Hi, my name is $name and I am $age years old."
}
val jack = Person("Jack", 42)
jack: Person(name=Jack, age=42)

//Kotlin offers the copy method, which, due to the ‘named argument’ feature, allows you to only adjust the state you want to change 😃
val fred = jack.copy(name = "Fred")
fred: Person(name=Fred, age=42)

De plus, vous devriez utiliser des classes de données pour les classes liées au domaine chaque fois que possible. Leur nature immuable garantit une expérience sûre, concise et fluide lorsque vous travaillez avec le cœur de votre application.     

Astuce : préférez les collections immuables aux collections mutables

Les collections immuables présentent des avantages clairs en matière de sécurité des threads, peuvent être transmises en toute sécurité et sont plus faciles à appréhender. Bien que Java offre certaines fonctionnalités d’immutabilité pour les collections, leur utilisation est dangereuse, car elle provoque facilement des exceptions lors de l’exécution :

Java

List.of(1,2,3).add(4); ❌unsafe 😬! .add(...) compiles, but throws UnsupportedOperationException

Kotlin

//The default collections in Kotlin are immutable (read-only)
listOf(1,2,3).add(4);  //✅safe: does not compile

val l0 = listOf(1,2,3) 
val l1 = l0 + 4 //✅safe: it will return a new list containing the added element
l1 shouldBe listOf(1,2,3,4) //✅

Il en va de même pour l’utilisation de Collections.unmodifiableList(…), qui est non seulement peu sûre, mais requiert également une allocation supplémentaire :

Java

class PersonRepo {
   private final List cache = new ArrayList();
   // Java – must clone or wrap every call
   public List getItems() {
       return Collections.unmodifiableList(cache);   //⚠️extra alloc
   }
}

//Usage
personRepo.getItems().add(joe) ❌unsafe 😬! .add(...) can be called but throws UnsupportedOperationException

Kotlin

class PersonRepo {

//The need to type ‘mutable’ for mutable collections is intentional: Kotlin wants you to use immutable ones by default. But sometimes you need them:

   private val cache: MutableList = mutableListOf()

   fun items(): List = cache //✅safe: though the underlying collection is mutable, by returning it as its superclass List, it only exposes the read-only interface

}

//Usage
personRepo.items().add(joe) //✅safe:😬! Does not compile

En matière de concurrence, il est préférable d’utiliser des structures de données immuables, notamment les collections. En Java, des efforts supplémentaires sont nécessaires avec les collections spéciales, qui offrent une API différente ou limitée, telles que CopyOnWriteArrayList. Avec Kotlin, en revanche, la List en lecture seule fonctionne pour presque tous les cas d’utilisation. 

Si vous avez besoin de collections modifiables et sûres pour les threads, Kotlin propose des collections persistantes (persistentListOf(...), persistentMapOf(...)), qui partagent toutes la même interface puissante.

Java

ConcurrentHashMap persons = new ConcurrentHashMap();
persons.put("Alice", 23);
persons.put("Bob",   21);

//not fluent and data copying going on
Map incPersons = new HashMap(persons.size());
persons.forEach((k, v) -> incPersons.put(k, v + 1));

//wordy and data copying going on
persons
   .entrySet()
   .stream()
   .forEach(entry -> 
      entry.setValue(entry.getValue() + 1));

Kotlin

persistentMapOf("Alice" to 23, "Bob" to 21)
         .mapValues { (key, value) -> value + 1 } //✅same rich API like any other Kotlin Map type and not data copying going on

Piège 7 : continuer à utiliser des constructeurs (ou pire encore : essayer d’utiliser Lombok) 

Astuce : utilisez des arguments nommés.

Les constructeurs sont très courants en Java. Bien qu’ils soient pratiques, ils rajoutent du code, sont peu sûrs et augmentent la complexité. En Kotlin, ils sont inutiles, car une simple fonctionnalité du langage les remplace : les arguments nommés. 

Java

public record Person(String name, int age) {

   // Builder for Person
   public static class Builder {
       private String name;
       private int age;

       public Builder() {}

       public Builder name(String name) {
           this.name = name;
           return this;
       }

       public Builder age(int age) {
           this.age = age;
           return this;
       }

       public Person build() {
           return new Person(name, age);
       }
   }
}

//Usage
new JPerson.Builder().name("Jack").age(36).build(); //compiles and succeeds at runtime

new JPerson.Builder().age(36).build(); //❌unsafe 😬: compiles but fails at runtime.

Kotlin

data class Person(val name: String, val age: Int = 0)

//Usage - no builder, only named arguments.
Person(name = "Jack") //✅safe: if it compiles, it always succeeds at runtime
Person(name = "Jack", age = 36) //✅

2. Étendre / convertir une application Java existante

Si vous n’avez pas la possibilité d’utiliser Kotlin dans un projet entièrement nouveau, l’ajout de nouvelles fonctionnalités Kotlin ou de modules Kotlin entiers à une base de code Java existante est la solution. Grâce à l’interopérabilité parfaite entre Kotlin et Java, vous pouvez écrire du code Kotlin qui ressemble à du code Java et qui peut être appelé depuis du code Java. Cette approche permet :

  • Une migration progressive sans réécriture majeure
  • Des tests en conditions réelles de Kotlin dans votre contexte spécifique
  • Le renforcement de la confiance de l’équipe avec du code Kotlin en production

Plutôt que de commencer au hasard, vous pouvez envisager ces approches :

De l’extérieur vers l’intérieur :

Démarrez dans la section « feuille » de votre application, par exemple le contrôleur, la tâche par lot, etc., puis remontez vers le domaine principal. Cela présente les avantages suivants : 

  • Isolation lors de la compilation : les classes du niveau feuille ont rarement quelque chose qui dépend d’elles, vous pouvez donc les convertir en Kotlin et générer le reste du système sans modification.
  • Moins de modifications en cascade. Grâce à une interopérabilité parfaite, une interface utilisateur ou un contrôleur converti ne nécessite que peu ou pas de modifications lors de l’appel de code de domaine Java existant.
  • De plus petites requêtes d’extraction, des révisions plus faciles. Vous pouvez migrer fichier par fichier ou fonctionnalité par fonctionnalité.

De l’intérieur vers l’extérieur :

Commencer par le noyau et progresser ensuite vers les couches externes est généralement une approche plus risquée, car elle élimine les avantages de l’approche de l’extérieur vers l’intérieur mentionnée ci-dessus. C’est toutefois une option viable dans les cas suivants :

  • Noyau très petit ou autonome. Si votre couche de domaine n’est composée que d’une poignée de POJO et de services, la basculer tôt peut être économique et débloquer immédiatement des constructions idiomatiques (classes de données, classes de valeurs, hiérarchies scellées).
  • Réarchitecture déjà prévue. Si vous prévoyez de refactoriser des invariants ou d’introduire des schémas de DDD (objets de valeur, agrégats) lors de votre migration, il peut parfois être plus propre de repenser d’abord le domaine en Kotlin.
  • Contrats de sécurité stricts pour les valeurs null. Mettre Kotlin au centre transforme le domaine en une « forteresse null-safe » ; les couches Java externes peuvent toujours envoyer des valeurs null, mais les limites deviennent explicites et plus faciles à contrôler.

Module par module

  • Si votre architecture est organisée par fonctionnalités plutôt que par couches, et que les modules ont une taille gérable, les convertir un par un est une bonne stratégie.

Fonctionnalités du langage pour convertir du Java en Kotlin

Kotlin propose plusieurs fonctionnalités, principalement des annotations, qui permettent à votre code Kotlin de se comporter comme du Java natif. Ceci est particulièrement utile pour les environnements hybrides dans lesquels Kotlin et Java coexistent au sein de la même base de code.
Kotlin

class Person @JvmOverloads constructor(val name: String,
                          var age: Int = 0) {
  companion object {

  @JvmStatic
  @Throws(InvalidNameException::class)
  fun newBorn(name: String): Person = if (name.isEmpty()) 
       throw InvalidNameException("name not set")
     else Person(name, 0)

   @JvmField
   val LOG = LoggerFactory.getLogger(KPerson.javaClass)
  }
}

Java

//thanks to @JvmOverloads an additional constructor is created, propagating Kotlin’s default arguments to Java
var john =  new Person("John");

//Kotlin automatically generates getters (val) and setters (var) for Java
john.setAge(23);
var name = ken.getName();

//@JvmStatic and @JvmField all accessing (companion) object fields and methods as statics in Java

//Without @JvmStatic it would be: Person.Companion.newBorn(...)
var ken =  Person.newBorn("Ken"); 

//Without @JvmField it would be: Person.Companion.LOG
Person.LOG.info("Hello World, Ken ;-)");

//@Throws(...) will put the checked Exception in the method signature 
try {
  Person ken =  Person.newBorn("Ken");
} catch (InvalidNameException e) {
  //…
}

Kotlin

@file:JvmName("Persons")
package org.abc

@JvmName("prettyPrint")

fun Person.pretty() =
      Person.LOG.info("$name is $age old")

Java

//@JvmName for files and methods makes accessing static fields look like Java: without it would be: PersonKt.pretty(...)
Persons.prettyPrint(ken)

Convertisseur Java vers Kotlin d’IntelliJ IDEA

IntelliJ IDEA propose un convertisseur Java vers Kotlin, donc théoriquement, l’outil peut le faire pour vous. Le code résultant est toutefois loin d’être parfait, utilisez-le donc uniquement comme un point de départ. À partir de là, vous pouvez le convertir en une représentation plus Kotlinesque. Nous aborderons ce sujet plus en détail dans la dernière partie de cette série d’articles : Facteurs de réussite pour l’adoption de Kotlin à grande échelle.

Utiliser Java comme point de départ vous fera très probablement écrire du Kotlin à la sauce Java, ce qui vous apportera certains avantages, mais n’exploitera pas pleinement le potentiel de Kotlin. Écrire une nouvelle application est donc l’approche que je préfère. 

Suite de la série

Ce volet de notre série du Guide complet pour réussir l’adoption de Kotlin dans un environnement Java vous a montré comment des expérimentations avec Kotlin peuvent évoluer vers du code de production. Notre prochain article se concentrera sur l’aspect humain de l’adoption : convaincre vos collègues. Il expliquera comment présenter des arguments clairs axés sur le code, guider les nouveaux développeurs et créer une communauté Kotlin, petite mais durable, au sein de votre équipe.

Auteurs de l’article original en anglais :

Urs Peter

Urs est un ingénieur logiciel expérimenté, architecte de solutions, conférencier et formateur avec plus de 20 ans d’expérience dans la création de systèmes résilients, évolutifs et critiques, dont la plupart impliquent Kotlin et Scala.

Outre son activité de consultant, il est également un formateur passionné et l’auteur d’une grande variété de cours sur des sujets allant des langages Kotlin et Scala à l’architecture comme les microservices et les architectures pilotées par les événements.

Sociable de nature, il aime partager ses connaissances, inspirer et être inspiré par ses pairs lors de rencontres et de conférences. Urs est un formateur Kotlin certifié par JetBrains.

Alyona Chernyaeva

Alyona Chernyaeva

image description

Discover more