Conception d'une bibliothèque Java conviviale

1. Vue d'ensemble

Java est l'un des piliers du monde open source. Presque tous les projets Java utilisent d'autres projets open-source car personne ne veut réinventer la roue. Cependant, il arrive souvent que nous ayons besoin d'une bibliothèque pour ses fonctionnalités, mais nous n'avons aucune idée de comment l'utiliser. Nous rencontrons des choses comme:

  • Qu'est-ce que c'est avec toutes ces classes «* Service»?
  • Comment puis-je instancier cela, cela prend trop de dépendances. Qu'est-ce qu'un « loquet »?
  • Oh, je l'ai mis ensemble, mais maintenant il commence à lancer IllegalStateException . Qu'est-ce que je fais mal?

Le problème est que tous les concepteurs de bibliothèques ne pensent pas à leurs utilisateurs. La plupart ne pensent qu'à la fonctionnalité et aux fonctionnalités, mais peu considèrent comment l'API va être utilisée dans la pratique, et à quoi le code des utilisateurs ressemblera et sera testé.

Cet article contient quelques conseils sur la façon de sauver nos utilisateurs de certaines de ces difficultés - et non, ce n'est pas par la rédaction de documentation. Bien sûr, un livre entier pourrait être écrit sur ce sujet (et quelques-uns l'ont été); ce sont quelques-uns des points clés que j'ai appris en travaillant moi-même sur plusieurs bibliothèques.

Je vais illustrer les idées ici en utilisant deux bibliothèques: charles et jcabi-github

2. Limites

Cela devrait être évident, mais ce n'est souvent pas le cas. Avant de commencer à écrire une ligne de code, nous devons avoir une réponse claire à certaines questions: quelles entrées sont nécessaires? quelle est la première classe que mon utilisateur verra? avons-nous besoin d'implémentations de la part de l'utilisateur? quelle est la sortie? Une fois ces questions clairement répondues, tout devient plus facile puisque la bibliothèque a déjà une doublure, une forme.

2.1. Contribution

C'est peut-être le sujet le plus important. Nous devons nous assurer que ce que l'utilisateur doit fournir à la bibliothèque est clair pour qu'elle puisse faire son travail. Dans certains cas, c'est une question très triviale: il peut s'agir simplement d'une chaîne représentant le jeton d'authentification d'une API, mais aussi d'une implémentation d'une interface ou d'une classe abstraite.

Une très bonne pratique est de prendre toutes les dépendances via les constructeurs et de les garder courtes, avec quelques paramètres. Si nous avons besoin d'un constructeur avec plus de trois ou quatre paramètres, alors le code doit clairement être refactorisé. Et si des méthodes sont utilisées pour injecter des dépendances obligatoires, les utilisateurs se retrouveront probablement avec la troisième frustration décrite dans la vue d'ensemble.

De plus, nous devrions toujours proposer plus d'un constructeur, donner aux utilisateurs des alternatives. Laissez-les travailler à la fois avec String et Integer ou ne les limitez pas à un FileInputStream , travaillez avec un InputStream , afin qu'ils puissent soumettre peut-être ByteArrayInputStream lors des tests unitaires, etc.

Par exemple, voici quelques façons d'instancier un point d'entrée d'API Github à l'aide de jcabi-github:

Github noauth = new RtGithub(); Github basicauth = new RtGithub("username", "password"); Github oauth = new RtGithub("token"); 

Simple, pas de bousculade, pas d'objets de configuration louches à initialiser. Et il est logique d'avoir ces trois constructeurs, car vous pouvez utiliser le site Web Github en étant déconnecté, connecté ou une application peut s'authentifier en votre nom. Naturellement, certaines fonctionnalités ne fonctionneront pas si vous n'êtes pas authentifié, mais vous le savez depuis le début.

Comme deuxième exemple, voici comment nous travaillerions avec Charles, une bibliothèque d'exploration Web:

WebDriver driver = new FirefoxDriver(); Repository repo = new InMemoryRepository(); String indexPage = "//www.amihaiemil.com/index.html"; WebCrawl graph = new GraphCrawl( indexPage, driver, new IgnoredPatterns(), repo ); graph.crawl(); 

C'est aussi assez explicite, je crois. Cependant, en écrivant ceci, je me rends compte dans la version actuelle qu'il y a une erreur: tous les constructeurs demandent à l'utilisateur de fournir une instance de IgnoredPatterns . Par défaut, aucun modèle ne doit être ignoré, mais l'utilisateur ne doit pas avoir à le spécifier. J'ai décidé de le laisser comme ça ici, donc vous voyez un contre-exemple. Je suppose que vous essayez d'instancier un WebCrawl et que vous vous demandez "Qu'est-ce que c'est avec ces IgnoredPatterns ?!"

La variable indexPage est l'URL à partir de laquelle l'exploration doit démarrer, le pilote est le navigateur à utiliser (ne peut rien par défaut car nous ne savons pas quel navigateur est installé sur la machine en cours d'exécution). La variable repo sera expliquée ci-dessous dans la section suivante.

Donc, comme vous le voyez dans les exemples, essayez de rester simple, intuitif et explicite. Encapsulez la logique et les dépendances de manière à ce que l'utilisateur ne se gratte pas la tête en regardant vos constructeurs.

Si vous avez encore des doutes, essayez de faire des requêtes HTTP à AWS à l'aide de aws-sdk-java: vous devrez gérer un soi-disant AmazonHttpClient, qui utilise un ClientConfiguration quelque part, puis doit prendre un ExecutionContext quelque part entre les deux. Enfin, vous pouvez exécuter votre demande et obtenir une réponse, mais vous n'avez toujours aucune idée de ce qu'est un ExecutionContext, par exemple.

2.2. Production

C'est principalement pour les bibliothèques qui communiquent avec le monde extérieur. Ici, nous devons répondre à la question «comment la sortie sera-t-elle gérée?». Encore une fois, une question plutôt drôle, mais il est facile de se tromper.

Regardez à nouveau le code ci-dessus. Pourquoi devons-nous fournir une implémentation de référentiel? Pourquoi la méthode WebCrawl.crawl () ne renvoie-t-elle pas simplement une liste d'éléments WebPage? Ce n'est clairement pas le travail de la bibliothèque de gérer les pages explorées. Comment devrait-il même savoir ce que nous aimerions en faire? Quelque chose comme ça:

WebCrawl graph = new GraphCrawl(...); List pages = graph.crawl(); 

Rien de pire. Une exception OutOfMemory peut survenir de nulle part si le site analysé contient, disons, 1000 pages - la bibliothèque les charge toutes en mémoire. Il existe deux solutions à cela:

  • Continuez à renvoyer les pages, mais implémentez un mécanisme de pagination dans lequel l'utilisateur devrait fournir les numéros de début et de fin. Ou
  • Demandez à l'utilisateur d'implémenter une interface avec une méthode appelée export (List), que l'algorithme appellerait à chaque fois qu'un nombre maximum de pages serait atteint

La deuxième option est de loin la meilleure; il simplifie les choses des deux côtés et est plus testable. Pensez à la quantité de logique à implémenter du côté de l'utilisateur si nous options pour le premier. Comme ça, un référentiel pour les pages est spécifié (pour les envoyer dans une base de données ou les écrire sur le disque peut-être) et rien d'autre ne doit être fait après l'appel de la méthode crawl ().

À propos, le code de la section Entrée ci-dessus est tout ce que nous devons écrire pour récupérer le contenu du site Web (toujours en mémoire, comme le dit l'implémentation du repo, mais c'est notre choix - nous avons fourni cette implémentation donc nous prenons le risque).

Pour résumer cette section: nous ne devons jamais séparer complètement notre travail du travail du client. Nous devons toujours penser à ce qui se passe avec la sortie que nous créons. Tout comme un chauffeur de camion devrait aider à déballer les marchandises plutôt que de simplement les jeter à l'arrivée à destination.

3. Interfaces

Utilisez toujours des interfaces. L'utilisateur ne doit interagir avec notre code que via des contrats stricts.

Par exemple, dans la bibliothèque jcabi-github , la classe RtGithub est la seule que l'utilisateur voit réellement:

Repo repo = new RtGithub("oauth_token").repos().get( new Coordinates.Simple("eugenp/tutorials")); Issue issue = repo.issues() .create("Example issue", "Created with jcabi-github");

L'extrait de code ci-dessus crée un ticket dans le référentiel eugenp / tutorials. Les instances de Repo et Issue sont utilisées, mais les types réels ne sont jamais révélés. Nous ne pouvons pas faire quelque chose comme ça:

Repo repo = new RtRepo(...)

The above is not possible for a logical reason: we cannot directly create an issue in a Github repo, can we? First, we have to login, then search the repo and only then we can create an issue. Of course, the scenario above could be allowed, but then the user's code would become polluted with a lot of boilerplate code: that RtRepo would probably have to take some kind of authorization object through its constructor, authorize the client and get to the right repo etc.

Interfaces also provide ease of extensibility and backward-compatibility. On one hand, we as developers are bound to respect the already released contracts and on the other, the user can extend the interfaces we offer – he might decorate them or write alternative implementations.

In other words, abstract and encapsulate as much as possible. By using interfaces we can do this in an elegant and non-restrictive manner – we enforce architectural rules while giving the programmer freedom to enhance or change the behaviour we expose.

To end this section, just keep in mind: our library, our rules. We should know exactly how the client's code is going to look like and how he's going to unit test it. If we do not know that, no one will and our library will simply contribute in creating code that is hard to understand and maintain.

4. Third Parties

Keep in mind that a good library is a light-weight library. Your code might solve an issue and be functional, but if the jar adds 10 MB to my build, then it's clear that you lost the blueprints of your project a long time ago. If you need a lot of dependencies you are probably trying to cover too much functionality and should break the project into multiple smaller projects.

Be as transparent as possible, whenever possible do not bind to actual implementations. The best example that comes to mind is: use SLF4J, which is only an API for logging – do not use log4j directly, maybe the user would like to use other loggers.

Document libraries that come through your project transitively and make sure you don't include dangerous dependencies such as xalan or xml-apis (why they are dangerous is not for this article to elaborate).

Bottom line here is: keep your build light, transparent and always know what you are working with. It could save your users more hustle than you could imagine.

5. Conclusion

The article outlines a few simple ideas that can help a project stay on the line with regards to usability. A library, being a component that should find its place in a bigger context, should be powerful in functionality yet offer a smooth and well-crafted interface.

C'est un pas facile sur la ligne et fait un gâchis dans la conception. Les contributeurs sauront toujours comment l'utiliser, mais quelqu'un de nouveau qui l'aura d'abord jeté les yeux ne le sera peut-être pas. La productivité est le plus important de tous et suivant ce principe, les utilisateurs devraient pouvoir commencer à utiliser une bibliothèque en quelques minutes.