août
2013
Restangular : une architecture full-REST avec Restlet et AngularJS
Il y a un an, je vous proposais d'intégrer AngularJS et Spring MVC. L'excellent support du format JSON par ce dernier en faisait une technologie de choix pour exposer des ressources en REST/JSON, format favori d'AngularJS.
Un an plus tard, je découvre Restlet, un peu par hasard.
Je lis la documentation, mon intérêt s'éveille, je fais quelques tests... puis je repense à AngularJS.
A ma gauche, un framework spécifiquement conçu pour exposer des ressources en REST ; à ma droite, un framework qui consomme principalement des ressources REST... Aurais-je trouvé le couple parfait ? Vite, un prototype !
Dans cet article, je vous présente le résultat de ma petite expérience : une micro-application (la classique Todo-list) à l'architecture 100% RESTful, basée sur Restlet et AngularJS, et saupoudrée d'un peu de Bootstrap 3 pour le style graphique (voir capture d'écran ci-dessous).
Vous avez fait le plein de café et de tartines ? Alors c'est parti, suivez le guide !
Côté serveur
Configuration du backend
Pour planter le décor, voyons rapidement la partie backend, volontairement très simplifiée dans ce POC.
Le modèle métier se résume à une unique classe Todo
, dont la structure est la suivante :
public class Todo { private Long id; private String title; private String description; private boolean done; }
Les Todos sont gérés par un TodoRepository
simpliste, qui les sauvegarde dans une Map
en mémoire[1].
public final class TodoRepository { private static final TodoRepository INSTANCE = new TodoRepository(); public static TodoRepository getInstance() { return INSTANCE; } private static final Map<Long, Todo> REPOSITORY = new ConcurrentSkipListMap<>(); private static final AtomicLong IDS = new AtomicLong(0); private TodoRepository() { } public List<Todo> list() { return new ArrayList<>(REPOSITORY.values()); } public Todo get(Long id) { return REPOSITORY.get(id); } public void create(Todo todo) { long id = IDS.getAndIncrement(); todo.setId(id); REPOSITORY.put(id, todo); } public void update(Todo todo) { REPOSITORY.put(todo.getId(), todo); } public boolean delete(Long id) { return REPOSITORY.remove(id) != null; } }
Exposition en REST
Entrons maintenant dans le vif du sujet : la mise en oeuvre de Restlet pour exposer les Todos en REST.
Restlet, un framework très complet
En abordant ce framework, j'ai été très surpris. Je m'attendais à une petite librairie de mapping, et je me suis retrouvé face à un gros framework, un environnement à part entière, capable de consommer et d'exposer des ressources REST (et bien plus encore), et déployable aussi bien de façon autonome que sur des environnements plus classiques.
Certains concepts de Restlet peuvent être déroutants au début ; je vais essayer d'en expliquer les bases, juste de quoi comprendre cet article.
Vue de loin, une application basée sur Restlet ressemble à une application Java EE classique. Chaque requête entrante est :
- acceptée par un composant central (classe
Application
=~ serveur d'applications Java EE), - routée selon des règles de correpondance (classe
Router
=~ Front Controller), - éventuellement filtrée (classe
Filter
=~ filtre de servlet), - puis traitée par une unité d'exécution (classe
Restlet
=~Servlet
), chargée de produire une représentation particulière de la ressource (classeRepresentation
et ses spécialisations, commeJacksonRepresentation
).
Une fois l'Application configurée (routage, sécurité, logging...), elle peut être déployée à l'aide d'un Composant (classe Component
).
Un Composant fait principalement office d'adaptateur entre une Application développée de manière portable, et un environnement d'exécution particulier (serveur autonome, serveur d'applications Java EE, plateforme Google AppEngine, Android...) - on pourrait comparer son rôle à celui de la JVM, qui offre aux programmes Java un environnement d'exécution uniforme quel que soit le système d'exploitation sous-jacent. Un Composant propose également bien d'autres services (Virtual Hosts...), mais nous n'en aurons pas besoin pour notre application.
Implémentation
Mais revenons justement à l'application !
Voici la façon dont j'ai choisi d'exposer les Todos :
/rest/todos (GET)
: liste des Todos/rest/todos (POST)
: création d'un nouveau Todo/rest/todos/{id} (GET)
: récupération d'un Todo/rest/todos/{id} (PUT)
: modification d'un Todo/rest/todos/{id} (DELETE)
: suppression d'un Todo
Ce mapping fait apparaître deux ressources REST différentes : une ressource "liste de Todos" et une ressource "Todo". Leur traitement est donc confié à deux Restlets différentes : TodoListResource
et TodoResource
.
Maintenant que le plan d'action est fixé, le code de la classe RestangularApplication
, qui étend Application
, est trivial :
public class RestangularApplication extends Application { @Override public Restlet createInboundRoot() { Router router = new Router(getContext()); router.attach("/rest/todos", TodoListResource.class); router.attach("/rest/todos/{todoId}", TodoResource.class); return router; } }
Les Restlets traitant les requêtes sont également très simples. La classe JacksonRepresentation
, s'appuyant sur la librairie open-source Jackson, se charge de convertir nos objets métiers en leur représentation JSON, attendue par AngularJS ; elle permet également de parser le JSON envoyé par AngularJS lors de la création ou de la mise à jour d'une tâche.
public class TodoListResource extends ServerResource { private TodoRepository repository = TodoRepository.getInstance(); @Get public Representation list() { return new JacksonRepresentation<>(repository.list()); } @Post("json") public void create(Representation representation) throws IOException { JacksonRepresentation<Todo> jsonRepresentation = new JacksonRepresentation<Todo>(representation, Todo.class); Todo todo = jsonRepresentation.getObject(); repository.create(todo); } }
public class TodoResource extends ServerResource { private TodoRepository repository = TodoRepository.getInstance(); private Long todoId; @Override protected void doInit() throws ResourceException { this.todoId = Long.valueOf(getAttribute("todoId")); } @Get public Representation get() { Todo todo = repository.get(todoId); if (todo == null) { throw new ResourceException(Status.CLIENT_ERROR_NOT_FOUND); } return new JacksonRepresentation<>(todo); } @Put("json") public void update(Representation representation) throws IOException { JacksonRepresentation<Todo> jsonRepresentation = new JacksonRepresentation<Todo>(representation, Todo.class); Todo todo = jsonRepresentation.getObject(); repository.update(todo); } @Delete public void remove() { repository.delete(todoId); } }
Si vous êtes attentif, vous aurez remarqué que les méthodes create(Representation)
et update(Representation)
étaient respectivement annotées @Post("json")
et @Put("json")
. La précision sur l'annotation permet d'activer le mécanisme de négociation de contenu, afin de s'assurer que les données transmises par la requête sont bien au format JSON.
Pour finir, il ne reste plus qu'à utiliser un Composant pour déployer l'application. Pour vous permettre de faire fonctionner le code plus facilement, et pour prouver que les serveurs d'applications Java EE ne sont pas une fatalité, j'ai opté pour un Composant embarquant un serveur HTTP autonome.
public class RestangularComponent extends Component { public static void main(String[] args) throws Exception { new RestangularComponent().start(); } public RestangularComponent() { Server server = new Server(Protocol.HTTP, 8000); getServers().add(server); getDefaultHost().attachDefault(new RestangularApplication()); System.out.println("Server started on port 8000."); System.out.println("Application is now available on http://localhost:8000/web/index.html"); } }
Côté serveur : terminé !
A ce point de l'article, la partie serveur est fonctionnelle.
Pour lancer le serveur, vous pouvez :
- importer le code dans votre IDE et lancer la classe RestangularComponent ;
- ou utiliser Maven pour construire un jar auto-exécutable, puis lancer ce jar :
mvn package cd target java -jar restangular-1.0.jar
Vous pouvez alors utiliser votre client REST favori (curl, le plugin RESTClient pour Firefox, ou le plugin RESTClient pour IntelliJ...) pour interagir avec le serveur.
Côté client
Bien, passons maintenant à la partie cliente de notre application.
Je vous ai déjà parlé d'AngularJS dans un précédent article, je ne m'attarderai donc pas sur les principes de ce chouette framework tout-en-un made in Google.
Présentation de l'interface
Côté interface, l'application se présente sous la forme de 4 écrans :
- Liste des tâches; cet écran permet d'accéder aux autres, et offre la possibilité de supprimer et de compléter directement une tâche.
- Ajout d'une tâche
- Edition d'une tâche
- Vue détaillée d'une tâche
Les écrans d'ajout et d'édition possédant une interface similaire, le même template HTML est utilisé, mais avec des contrôleurs différents.
Implémentation
Routage
Commençons par définir à quelles URLS seront associés les écrans. Ou plutôt, quels fragments d'URL, puisqu'il s'agit d'une application de type "MVC côté client", contenue dans une unique page HTML dont l'apparence est ensuite modifiée dynamiquement. Les ancres HTML (la portion après le #
) permettent d'identifier certains états prédéfinis de l'application.
J'ai choisi d'associer les fragments d'URL suivants aux différentes états (ou "pages") de l'application :
/list
: liste des tâches/add
: création d'une nouvelle tâche/{id}
: affichage de la tâche d'ID {id}/{id}/edit
: modification de la tâche d'ID {id}- Toute autre URL doit renvoyer vers la liste des tâches.
Cela se traduit directement par la configuration de routage suivante :
var app = angular.module('restangular.app', ['ngResource']); app.config(function ($routeProvider, $locationProvider) { $routeProvider.when('/list', {templateUrl: 'view/list.html', controller: 'ListController'}); $routeProvider.when('/add', {templateUrl: 'view/add.html', controller: 'AddController'}); $routeProvider.when('/:id/edit', {templateUrl: 'view/add.html', controller: 'EditController'}); $routeProvider.when('/:id', {templateUrl: 'view/display.html', controller: 'DisplayController'}); $routeProvider.otherwise({redirectTo: '/list'}); $locationProvider.hashPrefix('!'); // Enable ajax crawling });
Vous remarquerez que la route /:id/edit
est déclarée avant la route /:id
. AngularJS teste les règles de routage dans l'ordre de leur déclaration ; il est donc nécessaire de placer les URLs les plus spécifiques en premier.
Interaction avec le serveur
Pour interagir avec la ressource REST exposée par notre serveur, il suffit de la déclarer auprès d'AngularJS, à l'aide du service $resource
:
app.factory('Todo', ['$resource', function ($resource) { return $resource( '/rest/todos/:id', { 'id': '@id'}, {'update': {method: 'PUT'} }) ; }]);
Le code ci-dessus déclare que l'objet Javascript Todo
est la représentation locale (proxy) d'une ressource REST exposée à l'URL /rest/todos/:id
, le marqueur ":id
" de l'URL correspondant à la valeur du champ id
de l'objet.
Une Ressource AngularJS ainsi créée fournit quelques méthodes (save()
, query()
, get()
...) couvrant les use-cases les plus courants, et effectuant des appels REST aux paramètres prédéfinis. Mais il est parfois nécessaire d'ajuster ces réglages afin de les faire correspondre parfaitement à la façon dont la ressource est exposée côté serveur.
Dans l'application, côté serveur, la création (POST) et la mise à jour (PUT) d'une tâche sont deux opérations distinctes, mais AngularJS ne fournit par défaut qu'une méthode save()
effectuant une requête POST. Une méthode personnalisée update()
est donc ajoutée pour combler ce manque.
Page de liste
Pour terminer, voyons comment la page listant les tâches est implémentée. C'est à mon avis la plus intéressante de l'application, car elle met en jeu plusieurs fonctionnalités standard d'AngularJS, ainsi que quelques astuces ergonomiques.
Détaillons les fonctionnalités de cette page :
- Affichage de la liste des tâches.
- Suppression directe d'une tâche.
- Modification directe de l'état d'une tâche (à faire / terminée) à l'aide d'une case à cocher ; les tâches terminées sont grisées.
- Affichage du nombre de tâches restantes.
- Accès à la page de création d'une nouvelle tâche.
- Accès à la page d'édition de chaque tâche.
Commençons par le contrôleur :
app.controller('ListController', ['$scope', 'Todo', '$location', function ($scope, Todo, $location) { // Modèle $scope.todos = Todo.query(); $scope.deleteTodo = function (todo) { todo.$delete(function () { $location.path("/list"); }); }; $scope.toggleTodo = function (todo) { todo.$update(function () { $location.path('/list'); }); }; $scope.todosLeft = function () { return $scope.todos.filter(function (t) { return ! t.done; }); }; }]);
Le contrôleur ListController
déclare qu'il manipule des ressources de type Todo
, et qu'il dépend du service $location
permettant de manipuler les URLs et de rediriger l'utilisateur vers d'autres pages.
Il indique ensuite que la variable "todos
" fait partie du modèle qu'il manipule, et que cette variable est initialisée par un appel à Todo.query()
, qui effectue une requête de type GET
sur l'URL racine (/rest/todos
) pour récupérer la liste des tâches.
Le contrôleur définit ensuite deux fonctions deleteTodo()
et toggleTodo()
, qui utilisent les méthodes delete()
et update()
pour émettre respectivement des requêtes de type DELETE et PUT. L'utilisateur est ensuite redirigé vers la liste des tâches.
Pour finir, la fonction todosLeft()
calcule le nombre de tâches encore en attente, afin de l'afficher à l'utilisateur.
Le code HTML associé est présenté ci-dessous (les classes CSS sont omises pour une meilleure lisibilité) :
[html] <h2> <ng-pluralize count="todosLeft().length" when="{ '0':'Nothing left to do !', '1':'Only 1 thing left to do !', 'other':'Still {} things to do !'}"> </ng-pluralize> </h2> <table> <tr ng-repeat="todo in todos" ng-class="{active:todo.done}"> <td> <input type="checkbox" ng-model="todo.done" ng-change="toggleTodo(todo);"/> <a ng-href="#!/{{todo.id}}">{{todo.title}}</a> <a ng-href="#!/{{todo.id}}/edit">Edit</a> <a href="#" ng-click="deleteTodo(todo);">Delete</a> </td> </tr> </table> <hr/> <a href="#!/add">Add a new item</a>
Le code des autres écrans est trivial et ne sera pas détaillé ici.
Déploiement
La partie client n'est composée que de quelques ressources statiques : pages HTML, scripts Javascript, et ressources graphiques. Il serait donc très facile de la déployer sur un serveur léger de type Apache Httpd ou node.js, ou sur un serveur d'applications au sein d'une archive war.
Mais je vous propose ici une troisième solution : la déployer directement aux côtés des ressources REST !
Restlet fournit une classe Directory
(un type particulier de Restlet
), permettant de servir des ressources statiques à partir d'un répertoire ou d'un package. Il suffi de choisir l'URL à laquelle les ressources seront exposées, de la déclarer au niveau du routeur de requêtes, et le tour est joué !
Voici la classe RestangularApplication
complétée :
public class RestangularApplication extends Application { @Override public Restlet createInboundRoot() { Directory directory = new Directory(getContext(), "clap://class/static/"); directory.setDeeplyAccessible(true); Router router = new Router(getContext()); router.attach("/web", directory); router.attach("/rest/todos", TodoListResource.class); router.attach("/rest/todos/{todoId}", TodoResource.class); return router; } }
Un Directory
peut servir des ressources à partir de plusieurs sources, définies par un protocole et un chemin de base. Ici, j'ai sélectionné le protocole CLAP (ClassLoader Access Protocol : "clap://
"), qui permet d'accéder à toutes les ressources appartenant au classpath (ici, au répertoire /static
contenant la partie cliente de l'application).
Quel que soit le protocole retenu, il ne faut pas oublier de l'activer au niveau du Composant :
public class RestangularComponent extends Component { public static void main(String[] args) throws Exception { new RestangularComponent().start(); } public RestangularComponent() { Server server = new Server(Protocol.HTTP, 8000); getServers().add(server); getClients().add(Protocol.CLAP); // Activation du protocole CLAP getDefaultHost().attachDefault(new RestangularApplication()); System.out.println("Server started on port 8000."); System.out.println("Application is now available on http://localhost:8000/web/index.html"); } }
Cette solution de déploiement est simple et élégante. Elle offre surtout l'immense l'avantage de pouvoir livrer une application auto-contenue, portable, et exécutable en quelques secondes. Pour une démonstration de produit, la première impression est souvent déterminante, et la facilité de lancement est un facteur important !
Côté client : terminé !
La partie client est maintenant terminée !
Vous pouvez recompiler et relancer le serveur, et accéder à l'URL http://localhost:8000/web/index/html pour jouer avec l'application.
Conclusion
La combinaison de Restlet, qui expose des ressources REST, et d'AngularJS, qui les consomme, semble très puissante. Ces deux frameworks sont naturellement complémentaires, et permettent de développer des applications riches et scalables avec un minimum de code.
Je débute avec Restlet, mais ses fonctionnalités riches (sécurité, clustering, logging...) et sa capacité à s'intégrer à n'importe quel environnement - compris une application Jva EE préexistante - en font à mon avis un framework majeur dans l'écosystème Java, et sur lequel il convient de garder un oeil attentif.
Mes projets professionnels actuels ne me permettent pas de le mettre en place, mais la combinaison Restlet + AngularJS prendra sûrement une place importante dans mes projets personnels !
Pour aller plus loin...
Vous pouvez retrouver tout le code de cet article en pièce jointe, ou sur GitHub :
https://github.com/OlivierCroisier/restangular.
N'hésitez pas à le forker et/ou y contribuer !
Un peu de lecture sur Restlet...
- Le site de Restlet : http://restlet.org
- Le livre Restlet in Action, de Jerome Louvel, Thierry Templier, et Thierry Boileau
Et un peu de lecture sur AngularJS :
- Le site d'AngularJS : http://www.angularjs.org/
- Développer une application REST avec Spring MVC & Angular.js
Et pour apprendre AngularJS en s'amusant : inscrivez-vous dès maintenant à ma formation AngularJS ! Entièrement développée par votre serviteur !
Note
[1] Petit détail d'implémentation : une ConcurrentSkipListMap
a pour particularité de trier ses entrées selon l'ordre naturel de leurs clés (ici, les identifiants des Todos), ce qui nous permet de présenter les tâches toujours dans le même ordre. C'est en quelque sorte l'enfant caché d'une ConcurrentHashMap
et d'une LinkedHashMap
:)
Commentaires
Très bon article comme d'habitude. Une seule chose me chagrine c'est l'identifiant de la todo à récupérer. Il se trouve dans une méthode annoté de @init. Est-ce que cela veut dire que si dans la même classe j'ai plusieurs méthodes permettant de récupérer des éléments alors tous les paramètres, si différents, sont récupérés dans cette méthode pour être ajouté en tant que champ de la classe ? Cela peut rendre la classe difficile à lire et/ou debugguer, non?
Merci en tout cas.
Intéressante cette présentation. Pour mes pocs je faisais souvent du Spring MVC embarqué dans un Jetty côté serveur. Du coup avec Restlet on n'a plus besoin de server web. Par contre à titre purement d'information, qu'en est il des perf ? Est ce que Restlet est assez light et ne tire pas la terre entière en terme de dépendance maven ?
@Adrien : La méthode
getAttribute()
est accessible depuis toutes les méthodes, tu n'es pas obligé de récupérer toutes les variables dans la méthodedoInit()
. C'était le pattern utilisé dans le livre, je l'ai simplement réutilisé dans mon code. Mais en pratique, ça ne devrait pas être très gênant, il est rare d'avoir plus d'une ou deux variables dans l'URL.@Doanduyhai : D'après Maven, le coeur de Restlet ne tire aucune dépendance. Si tu ajoutes le support de Jackson, de Velocity, l'intégration Java EE, etc., évidemment la liste s'allonge, mais on reste loin de la gourmandise d'un Spring par exemple. Si tu ne sers que des ressources statiques (en plus des ressources REST), Restlet semble donc une solution simple, légère et pratique.
Pour les perfs, je n'ai pas fait de tests.
Article bien détaillé.
Néanmoins j'ai un doute : entre JAX-RS est ses implémentations (à commencer par CXF et la RI) puis avec Spring-MVC et son @ResponseBody, le moins qu'on puisse dire c'est qu'on ne manque pas de solutions en Java pour exposer des services REST.
Je suppose que le footprint de Restlet est plus léger qu'une stack Spring dans le cas d'une application simple, mais ça me semble être une plus value un peu juste. Y'a-t-il d'autres avantages à l'emploi de Restlet, notamment par rapport à JAX-RS ?
Effectivement, on ne manque pas de solutions. Mais Restlet a quelques atouts dans sa manche.
L'application montrée ici est simplissime, et ne tire pas partie des fonctionnaliltés avancées de Restlet, comme :
Router
et chaqueRestlet
terminale (qui traite une requête), par exemple pour ajouter des filtres de sécurité, de logging, etc. à granularité très fine, et réutilisables (pattern Decorator).Pour finir, mais c'est totalement subjectif, mon pifomètre personnel a réagi positivement à cette technologie. Comme pour AngularJS bien avant sa présentation à la JavaOne, et comme pour Spring 1.1 il y a... longtemps :) Je peux me tromper, mais disons que Restlet me paraît sain et utile.
Je me suis timeboxé à 1h pour refactorer la partie serverside en restx, visible ici pour les curieux : https://github.com/fcamblor/restang...
Ce qui m'a le plus surpris durant ce refactoring, c'est l'utilisation systématique des Representation. J'ai même cru au début que c'était quelque chose d'obligatoire dans restlet, et puis il y a eu la discussion sur twitter avec fabien (https://twitter.com/fcamblor/status...) qui m'a rassuré sur ce point.
En terme de temps de démarrage, restx est perdant : 600ms vs 27ms pour restlet ... mais ça reste acceptable :-)
Il faudrait bencher les temps que prennent une requête en moyenne.
Par contre en terme de design, j'avoue avoir quelques préférences envers restx (bon ok, je ne suis pas forcément la personne la plus objective sur le sujet ;-)) :
- Très peu de réflexion (la seule réflexion utilisée est celle de Jackson pour les entrées/sorties) : aussi bien l'injection/résolution de dépendances que la déclaration des routes/paramètres se fait via de la génération de code basé sur de l'annotation processing (meilleures perfs, debugging plus aisé dans l'IDE à l'aide des "call hierarchy")
- Pas besoin d'hériter de quoique ce soit pour que ça marche (dans tes exemples, souvent, tu as besoin d'hériter d'une classe : que ce soit sur l'Application ou sur les Resources)
- Le fait de déclarer les routes sur chaque méthode et non de manière centralisée (comme on le fait avec spring mvc). Là, c'est plus du subjectif car il y a des pros & cons dans les 2 philosophies.
- L'utilisation des Optional Guava en entrée/sortie pour définir les beans optionnels
- La validation des beans via bean validation
- La compilation à la volée (à la Play!), fonctionnalité à venir dans la 0.2.9 :-)
J'aurais aimé mettre en place davantage de choses (tests basés sur des fichiers de specs, bean validation, cache et sécurité), mais en 1h, c'était compliqué :)
En parlant de RestAngular : https://github.com/mgonto/restangul...
C'est pas mal fait et ça utilise des promises :)