Licence CC BY

Calvin and Bot : un bot Discord sur Calvin et Hobbes, avec des coroutines Kotlin

Un journal de développement décousu

Il y a de ça plusieurs années, j’ai voulu tester les coroutines de Kotlin, mais c’était encore en bêta, pas sec, pas documenté et sans rien qui ne les supportait. Et donc je n’étais pas allé plus loin.

Or donc, voici que l’autre jour je tombe par hasard sur les pages Calvin et Hobbes de GoComics, qui non seulement proposent toute la collection (en anglais) bien rangée par date de publication d’origine (en plus des re-publications), mais en plus donne les transcriptions. Ce qui en fait un très chouette jeu de données pour tester des choses, parce que Calvin et Hobbes, c’est quand même génial.

Une première version obèse au dernier degré

Comme la mode c’est de faire des bots Discord, je me dis que je ne vais pas mourir idiot et en faire un. Je commence par une v1 avec Discord4J, Spring Boot et ElasticSearch. Bon, ça fonctionne, c’est facile, j’ai même une version réactive mais… en terme de consommation, c’est une horreur. Rien que le packaging standard d’ElasticSearch semble considérer que tout le serveur est à lui. En fait, ça ne tourne même pas correctement sur mon serveur, sur lequel il reste presque 1 Go de RAM de libre. Pour une poignée de Mo de données à indexer, c’est quand même un peu raide.

Et c’est à ce moment que je tombe sur cette section laconique de la présentation de Discord4J, qui me rappelle que les coroutines Kotlin existent, qu’il y a un projet qui permet l’intégration avec le projet Reactor utilisé dans Spring Boot (entre autres), et que Discord4J le supporte.

Donc, je change mon idée de projet : je veux une version en Kotlin avec coroutines, dans un seul Jar exécutable, sans dépendance à des services tiers.

Les technologies qui vont permettre ça

Je pars donc sur du Kotlin (avec la JVM pour cible), ses coroutines, et quitte à rester dans Kotlin je vais essayer leur DSL Gradle.

Discord4J reste ma bibliothèque d’accès à Discord.

Pour indexation je pars sur Apache Lucene, c’est austère mais je sais comment ça fonctionne, et surtout c’est intégrable dans le même Jar que le reste de l’application. Comme dans la version précédente, j’utilise jsoup pour lire les pages HTML de GoComics et en extraire les données (merci aux développeurs du site qui les ont proprement rangées dans des balises spéciales, d’ailleurs).

Il me reste à remplacer aussi tout ce que faisait Spring Boot. Or, il se trouve que depuis la dernière fois que j’y au touché, l’écosystème propre de Kotlin s’est bien développé, et pas uniquement dans des outils spécifiques Android (le langage fête ses dix ans après tout). Se contenter des bibliothèques Java classiques n’est plus nécessaire. Je trouve donc :

  • Un client/serveur HTTP presque officiel avec Ktor, qui fonctionne très bien.
  • J’ai testé Dagger 21 mais c’est une usine à gaz incompréhensible et qui nécessite des contorsions peu idiomatiques pour fonctionner avec Kotlin. Je découvre un peu par hasard Koin, qui est écrit en Kotlin et qui fonctionne très bien – peut-être qu’il est moins performant mais pour ce que j’en fait…
  • Toujours en Kotlin, je découvre qu’on ne peut pas tester les coroutines simplement sans framework adapté. En particulier, les mocks ne fonctionnent pas comme prévu s’ils ne sont pas adaptés… problème qui se pose avec Mockito, que j’avais l’habitude d’utiliser. Là aussi, je découvre MockK qui réponds plutôt bien à mes problèmes, à commencer par un support natif des coroutines.

Retours sur le développement

Des difficultés, des bonnes idées

Se remettre à la programmation réactive après presque un an sans y toucher a été plutôt douloureux ; par contre la transition Reactor (le framework utilisé par Spring Boot donc) vers les coroutines est très simple.

Notamment parce que les concepteurs on eu une idée que je qualifierais de géniale : ils ont repris presque toutes les méthodes de Reactor, et toutes celles dont le nom est différent (souvent parce que le fonctionnement l’est aussi) sont marquées comme dépréciées-erreur2 avec la bonne méthode à utiliser en commentaire. Donc, en cas de doute, on tape la méthode dont on a l’habitude, et l’outil de compatibilité nous indique le moyen canonique de résoudre le problème.

Par contre, les coroutines ont le même défaut que les frameworks réactifs habituels : quant ça fonctionne tout va bien, par contre au moindre problème, c’est une purge à débugger. Par contre, c’est plus simple à développer et tester que Reactor, en particulier parce que tout ce qui renvoie des éléments simples (et pas des flux) se traite comme des fonctions habituelles qui renvoient des Objets, et pas des Mono<Objet>.

Utiliser les coroutines avec des outils qui ne sont pas du tout prévus pour ce genre de programmation (coucou Lucene), ça implique de jongler avec les API de retour au monde synchrone. En particulier, de bien penser à isoler les passages gourmands en I/O sur l’ensemble de threads dédiés. Heureusement, c’est très facile à faire… et semble encore mal détecté par l’IDE, donc je ne suis pas certain d’avoir bien fait.

// suspend indique que la fonction est destinée à être utilisée dans une coroutine,
// et plantera à la compilation si ça n’est pas le cas.
// query ne peut pas être null, sort peut l’être, d’où le ? à la fin du nom du type
// Le type de retour ne peut pas être null non plus (pas de ? à la fin de "Strip")
internal suspend fun search(query: Query, sort: Sort?): Strip {
    // Tout ce qui est à l’intérieur du bloc withContext() sera lancé dans le contexte donné.
    // Ici, le dispatcher pour tâches gourmandes en I/O
    val hits = withContext(Dispatchers.IO) {
        when (sort) {
            null -> searcher.search(query, 1)
            else -> searcher.search(query, 1, sort)
        }.scoreDocs
    }
    // Comme on ne peut pas renvoyer null, on doit gérer explicitement le cas où on ne trouve rien
    // Ici Strip.emptyStrip est un Strip vide qui ne va rien affiche à l’utilisateur
    return if (hits.isEmpty()) Strip.emptyStrip else toDoc(hits[0])
}

En fait, le truc le plus casse-pieds avec Lucene a été… le fait qu’il ne permet pas la mise à jour à chaud d’un index : c’est en lecture, ou en écriture, mais pas les deux en même temps !

Et si on parlait de documentation ?

En terme de documentations… en général c’est léger et ça ressemble plus à de l’aide-mémoire pour les développeurs du produit. La documentation complète du support des coroutines par Discord4J par exemple est contenu dans le paragraphe que je vous ai montré, et toute la documentation de la couche de compatibilité entre Reactor et les coroutines est en fait dans les dépréciations dont je vous ai parlé.

Par contre, les outils 100% Kotlin (Ktor, Koin, MockK) ont des API et des documentations efficaces, dans le sens où je suis arrivé assez facilement à réaliser ce que je voulais avec, sans trop me poser de questions existentielles.

Je ne peux hélas pas en dire autant de la documentation des coroutines de Kotlin, qui est loin d’être claire et facile d’accès – surtout que le concept de base est très loin d’être simple ! – et que, la technologie étant récente et peu utilisée, on trouve assez peu de ressource pertinentes sur Internet.

Sans parler de cette manie de mettre des exemples tronqués ou qui correspondent à un cas minimal complètement hors sol. Par exemple, le readme de Discord4J indique qu’on peut faire ainsi (dans un main) :

val token = args[0]
val client = DiscordClient.create(token)

client.withGateway {
  mono {
    it.on(MessageCreateEvent::class.java)
      .asFlow()
      .collect {
        val message = it.message
        if (message.content == "!ping") {
          val channel = message.channel.awaitSingle()
          channel.createMessage("Pong!").awaitSingle()
        }
      }
  }
}
.block()

C’est… exact. À condition d’avoir un bot qui ne fait à peu près rien et qui n’utilise aucun système tiers. Parce que dans la vraie vie, on va avoir besoin de plusieurs listeners sur plusieurs types d’évènements, et d’éviter de bloquer tout le reste du programme une fois le client Discord initialisé ; et donc le code va plutôt ressembler à ceci (dans une méthode d’initialisation)

DiscordClient.create(token).withGateway { client ->
    mono {
        listeners.forEach { listener ->
            client.on(listener.getEventType()).asFlow()
                .let { messageFlow: Flow<Event> -> listener.listen(messageFlow as Flow<Nothing>) }
                .catch { log.error(it.message, it) }
                .onEach { log.info(it.toString()) }
                .launchIn(this)
        }
    }
}.awaitSingle()

C’est d’autant plus vrai et problématique que les API de Discord peuvent être peu intuitives  !

Je tiens d’ailleurs à remercier tous ces gens qui ont mis leur code en accès libre sur Internet (et souvent sous licence libre), ça permet d’avoir des exemples qui fonctionnent (et parfois même commentés). Et c’est pour ça que je publie le code de mon bot à mon tour.

Utiliser Kotlin/JVM en 2021

Java a fait d’énormes progrès depuis la sortie de Kotlin, et Kotlin a lui-même beaucoup évolué. De fait, la présentation que j’en avait fait en 2017 (« L’une des prétentions de Kotlin, c’est grosso merdo d’être une version moderne et efficace (= sans boilerplate code) de Java. ») est devenue assez fausse, puisque Java est devenu une version moderne et efficace de lui-même (surtout si on lui colle des outils comme Lombok). Mais Kotlin a continué dans la voie de l’expressivité et des fonctionnalités natives que n’a pas Java, comme les coroutines dont je vous parle depuis le début.

On peut bien sûr toujours faire des choses claires et dépourvues de boilerplate code :

class ScraperService : KoinComponent {
    companion object {
        val log: Logger by LoggerDelegate()
        val firstPublish: LocalDate = LocalDate.of(1985, 11, 18)
        val lastPublish: LocalDate = LocalDate.of(1995, 12, 31)
    }
    
    private val stripsRepository by inject<StripsRepository>()
    private val delayMs = Properties.value(Properties.SCRAPER_DELAY_MS, 1000)
    private val timeoutMs = Properties.value(Properties.SCRAPER_TIMEOUT_MS, 30_000)
    private val httpClient: HttpClient = HttpClient(CIO) {
        if (log.isDebugEnabled) {
            install(Logging) { level = LogLevel.HEADERS }
        }
        BrowserUserAgent()
        engine {
            endpoint {
                connectTimeout = timeoutMs
            }
        }
    }
    
    suspend fun scrape() {
        val lastKnowStrip = stripsRepository.lastUpdate(firstPublish)
        log.info("Last strip found is strip of $lastKnowStrip")

        dateFlow(lastKnowStrip)
            .onEach { delay(delayMs) }
            .map { it.format(dateToUrlPartFormatter) }
            .map {
                val url = "https://www.gocomics.com/calvinandhobbes/$it"
                log.info("Downloading strip from $url")
                withContext(Dispatchers.IO) {
                    httpClient
                        .get<HttpStatement>(url)
                        .receive<String>()
                }
            }
            .map { StripBodyToModelMapper.stripBodyToModel(it) }
            .map { stripsRepository.add(it) }
            .collect { withContext(Dispatchers.IO) { stripsRepository.save() } }
        log.warn("Scraping finished! Restart server to index content.")
    }

    private fun dateFlow(lastKnowStrip: LocalDate): Flow<LocalDate> = flow {
        var date = lastKnowStrip
        while (date.isBefore(lastPublish) || date.isEqual(lastPublish)) {
            emit(date)
            date = date.plusDays(1)
        }
    }
}

Par contre, le côté expressif est parfois allé un peu loin, et on peut se retrouver face à du code abscons :

class LoggerDelegate<in R : Any> : ReadOnlyProperty<R, Logger> {
    override fun getValue(thisRef: R, property: KProperty<*>) = getLogger(getClassForLogging(thisRef.javaClass))

    inline fun <T : Any> getClassForLogging(javaClass: Class<T>): Class<*> {
        return javaClass.enclosingClass?.takeIf {
            it.kotlin.companionObject?.java == javaClass
        } ?: javaClass
    }
}

Sachant que beaucoup de bibliothèques utilisent ce genre de fonctionnalité (au moins pour comprendre les API, dès qu’il y a des lambdas – et Kotlin en promeut l’usage). Pour moi, ça n’est pas (ou plus) un langage pour débutant, mais plutôt pour développeurs qui savent ce qu’ils font.

Mais dans l’ensemble c’est un langage agréable à travailler, quoiqu’on y trouve des manques assez surprenants (comme une façon standard de déclare une constante vraie ou un logger) !

Et ça marche

Et donc, avec tout ça, j’ai un bot Discord fonctionnel, avec moins de 2 Mo d’index sur le disque. Le Jar exécutable avec toutes les dépendances embarquées fait 29 Mo, dont 10 de Kotlin – il y a des moyens de le réduire, mais c’est beaucoup de boulot pour rien. Le serveur tourne largement avec un heap (-Xmx dans les paramètres Java) de 64 Mo, et tournerait sans doute avec moins, mais je ne suis pas en rade de mémoire à ce point.

C’est l’un des gros avantages de la programmation réactive (framework Reactor, coroutines…) : en évitant de créer des tonnes de threads pour gérer la charge, on économise aussi beaucoup de ressources.

Et ce bot ?!

Des images

C’est un bot qui réponds quand on lui parle, soit en MP :

Soit quand on l’interpelle dans un chan public (par mot-clé ou par mention) :

Je voulais le faire répondre à une commande Discord (ce qui va bientôt être obligatoire pour les gros bots), mais c’est une horreur d’un point de vue API, pas encore documenté dans Discord4J, et ça nécessite un listener spécifique : les listeners de messages – création et mise à jour – sont globaux, mais le listener de commande doit être paramétré pour chaque « guilde » (le nom interne des « serveurs ») à laquelle est connectée le bot… et donc on doit avoir un listener de connexion de guilde capable de déclarer la commande.

Et je trouve ça où ?


  1. J’avais déjà utilisé le 1 sous Android, la version 2 remplace Google Guice et Dagger 1, tous deux mentionnés dans cet ancien tuto.
  2.  Le système de dépréciation de Kotlin contient un système de niveau de dépréciation, qui permet d’afficher un avertissement, une erreur de compilation voire d’interdire l’accès au code déprécié depuis le nouveau code.


Merci à vous d’avoir lu ce trop long monologue décousu sur cette aventure.


Logo © Bill Watterson, tous droits réservés.

5 commentaires

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte