Ember.js ,Think different ! (Etape 6 - BO Liferay)

Par défaut Ember Data préfère les services JSON et tout particulièrement ceux au format json-api. Néanmoins, sa grande flexibilité et l’intelligence de sa conception lui permette d’exploiter des services disponibles dans des formats moins ‘standards’.

Nous allons ici le démontrer en exploitant cette fois ci un backoffice Liferay. Nous irons même plus loin, puisque l’application Ember sera in fine packagée sous la forme d’une portlet !

Cet article fait partie d’une série composée des posts suivants :

  • Introduction à Ember – Rappel rapide sur les mécanismes mis en place par ce framework,
  • Etape 1  – Application simple avec données ‘rack’ retournées directement par le modèle sous forme d’un tableau JSON. Cette étape sera complété par l’utilisation de addons Ember pour rendre l’application plus esthétique et ergonomique.
  • Etape 2 (ce post) –  Passage sur un modèle géré par Ember Data. Etant donné qu’aucun back-office ne sera déployé, nous introduirons le framework de mock Mirage pour intercepter les appels HTTP.
  • Etape 3 – Introduction d’une relation rack —> bottle, et enrichissement des données mockées.
  • Etape 4 –  Construction d’un composant graphique permettant une représentation d’un rack et simplifiant les templates d’affichage
  • Etape 5  – Mise en place d’un backoffice construit à l’aide de JHipster. Nous profiterons des capacités de cet outil pour obtenir rapidement un modèle persisté en base, les services Rest permettant de l’administrer ainsi qu’une interface d’administration. Nous verrons au passage quelques problématiques liées à la sécurité.
  • Etape 6 (cet ultime post !) – Mise en place d’un backoffice sur services Liferay et packaging de l’application Ember au sein d’une portlet. Nous avons choisi un backoffice Liferay pour sa facilité à construire un modèle de persistance mais surtout pour bien démontrer l’utilisation potentielle des Adapters et des Serializers, même si un backoffice plus conforme aux standards Rest n’en sera que plus aisé à utiliser.

Le code de cet article est disponible sur mon gitlab dans les branches step_5_x.

Step 5b.1 Création des services via le Service Builder Liferay

Les chapitres précédents ont mis en avant la possibilité de découpler les développements de services REST et ceux de la couche de présentation. On va maintenant aller encore un peu plus loin en imaginant un scénario un poil plus complexe où :

  • Les services JSON mis à disposition sont nettement plus éloignés des habitudes REST ;
  • Le packaging de l’application finale est réalisé sous forme d’une portlet agrégeant les travaux des équipes front et back.

Pour cela, nous allons implémenter la couche de services à l’aide du Service Builder du portail Liferay. En deux mots, le Service Builder permet de créer rapidement, à l’aide d’une description XML, les objets métier d’une application et d’en exposer les services sous forme de services Web de type SOAP ou JSON. Pour ceux qui veulent en savoir plus, la documentation officielle est disponible sur https://dev.liferay.com/develop/tutorials/-/knowledge_base/7-0/service-builder.

L’utilisation de cet environnement nécessite par ailleurs l’installation préalable de quelques outils comme gradle (https://gradle.org/) et blade (https://dev.liferay.com/develop/tutorials/-/knowledge_base/7-0/installing-blade-cli).

Dans un premier temps, on se construit un workspace Liferay tout neuf directement dans le répertoire principal de l’application Ember :

   blade init lf7-sdk

Petit conseil au passage : n’hésitez pas à enrichir le fichier .gitignore du répertoire principal avec le contenu de celui situé dans le répertoire lf7-sdk.

On installe également un bundle Liferay en procédant aux deux opérations suivantes dans le répertoire lf7-sdk :

  • Décommenter la première ligne de gradle.properties pour désigner le package à télécharger
  • Lancer l’installation par la commande :

 ./gradlew initBundle

Le démarrage du serveur peut être effectué par la commande suivante, mais la configuration de celui-ci se réduira à l’utilisation d’une base de données “mémoire” :

   blade server start

Je vous conseille donc au préalable de :

  • Créer une base de données dans votre technologie préférée (postgres ou mysql au hasard !). Attention de bien créer votre base en UTF-8 - Sous MySQL : CREATE DATABASE embercellar_liferay CHARACTER SET utf8 COLLATE utf8_general_ci;
  • De déposer le driver JDBC qui va bien dans le répertoire /lib/ext du Tomcat situé dans lf7-sdk/bundles

Le démarrage du serveur vous amènera alors à une page de configuration pour vous brancher sur votre base.

On peut ensuite passer à la création de notre module activant le service-builder de Liferay, toujours dans le répertoire lf7-sdk :

   blade create -t service-builder -p fr.ippon.services.embercellar embercellar

 On peut alors éditer le fichier services.xml situé dans modules/embercellar/embercellar-service pour y intégrer les mêmes objets que ceux déclarés dans le modèle Ember :

<?xml version="1.0"?>


<service-builder package-path="fr.ippon.services.embercellar">
	<namespace>embercellar</namespace>
	<!--<entity data-source="sampleDataSource" local-service="true" name="Foo" remote-service="false" session-factory="sampleSessionFactory" table="foo" tx-manager="sampleTransactionManager uuid="true"">-->
	<entity local-service="true" name="Bottle" remote-service="true" uuid="true">

		<!-- PK fields -->
		<column name="bottleId" primary="true" type="long" />

		<!-- Group instance -->
		<column name="groupId" type="long" />

		<!-- Audit fields -->
		<column name="companyId" type="long" />
		<column name="userId" type="long" />
		<column name="userName" type="String" />
		<column name="createDate" type="Date" />
		<column name="modifiedDate" type="Date" />

		<!-- Other fields -->
		<column name="name" type="String" />
		<column name="flipped" type="boolean" />
		<column name="xcolumn" type="int" />
		<column name="yrow" type="int" />

		<!-- Just for complying with Ember -->
		<column name="type" type="String" />

		<!-- Relationship -->
		<column name="rackId" type="long" />

		<!-- Order -->
		<order by="asc">
			<order-column name="name" />
		</order>

		<!-- Finder methods -->
		<finder name="Name" return-type="Collection">
			<finder-column name="name" />
		</finder>

		<finder name="RackId" return-type="Collection">
			<finder-column name="rackId" />
		</finder>
	</entity>

	<entity local-service="true" name="Rack" remote-service="true" uuid="true">

		<!-- PK fields -->
		<column name="rackId" primary="true" type="long" />

		<!-- Group instance -->
		<column name="groupId" type="long" />

		<!-- Audit fields -->
		<column name="companyId" type="long" />
		<column name="userId" type="long" />
		<column name="userName" type="String" />
		<column name="createDate" type="Date" />
		<column name="modifiedDate" type="Date" />

		<!-- Just for complying with Ember -->
		<column name="type" type="String" />

		<!-- Other fields -->
		<column name="name" type="String" />
		<column name="nbColumns" type="int" />
		<column name="nbRows" type="int" />
		<column name="image" type="String" />

		<!-- Relationship -->
		<column name="bottles" type="Collection" entity="Bottle" />
		<column name="bottlesId" type="String" />

		<!-- Order -->
		<order by="asc">
			<order-column name="name" />
		</order>

		<!-- Finder methods -->
		<finder name="Name" return-type="Collection">
			<finder-column name="name" />
		</finder>
	</entity>
</service-builder>

Sur cette base de modèle métier, on génère les classes propres à la persistance et aux services construits au dessus des entités présentes dans le service.xml. Pour cela, on exécute la commande suivante dans le répertoire lf7-sdk/modules/embercellar :

   blade gw buildService

Reste maintenant à implémenter les services tels qu’on les attend pour Ember, en suivant quelques contraintes de nommage.

On édite ainsi les fichiers présents dans le module embercellar-service au sein du package fr.ippon.services.embercellar.service.impl.

Pour permettre le travail de l’Adapter Ember, le nommage des méthodes exposées par Liferay doit suivre des conventions, comme par exemple :

  • findAll() pour récupérer tous les éléments,
  • findBy<nom objet modèle>() pour récupérer un élément par son id.

Ceux qui connaissent bien Liferay retrouveront les implémentations à produire pour mettre en place les services sur les Racks qui vont se trouver dans RackLocalServiceImpl

package fr.ippon.services.embercellar.service.impl;

import aQute.bnd.annotation.ProviderType;

import com.liferay.portal.kernel.exception.NoSuchUserException;
import com.liferay.portal.kernel.model.User;
import com.liferay.portal.kernel.service.ServiceContext;

import fr.ippon.services.embercellar.exception.NoSuchRackException;
import fr.ippon.services.embercellar.model.Rack;
import fr.ippon.services.embercellar.service.base.RackLocalServiceBaseImpl;

import java.util.Date;
import java.util.List;


@ProviderType
public class RackLocalServiceImpl extends RackLocalServiceBaseImpl {

public Rack addRack(long userId, long groupId,
String name, int nbColumns, int nbRows, String pathImg,
ServiceContext serviceContext) throws NoSuchUserException {

User user = userPersistence.findByPrimaryKey(userId);
Date now = new Date();

long rackId = counterLocalService.increment(Rack.class.getName());
Rack rack = rackPersistence.create(rackId);

rack.setUserId(userId);
rack.setCompanyId(user.getCompanyId());
rack.setGroupId(groupId);
rack.setCreateDate(serviceContext.getCreateDate(now));
rack.setModifiedDate(serviceContext.getModifiedDate(now));
rack.setName(name);
rack.setNbColumns(nbColumns);
rack.setNbRows(nbRows);
rack.setImage(pathImg);

super.addRack(rack);

return rack;
}


public List<Rack> findAll() {
return rackPersistence.findAll();
}

public Rack findByRackId(long rackId) {
try {
Rack rack = rackPersistence.findByPrimaryKey(rackId);
return rack;
} catch (NoSuchRackException e) {
e.printStackTrace();
return null;
}
}

}

Le code des trois autres classes Java (BottleLocalServiceImpl.java, BottleServiceImpl.java et RackServiceImpl.java) n’est pas repris dans cet article mais disponible sur le gitlab.

On facilitera également le travail d’Ember Data en assignant une valeur à l’attribut ‘type’ dans le construction des objets Liferay. En effet, cet attribut est implicitement utilisé par le mapping JSON API pour la détermination du type de l’objet manipulé dans le flux JSON. Ci-dessous le code pour la classe RackImpl (même principe à appliquer pour BottleImpl) :

package fr.ippon.services.embercellar.model.impl;

import aQute.bnd.annotation.ProviderType;

/**
* The extended model implementation for the Rack service. Represents a row in the "embercellar_Rack" database table, with each column mapped to a property of this class.
*
* <p>
* Helper methods and all application logic should be put in this class. Whenever methods are added, rerun ServiceBuilder to copy their definitions into the {@link fr.ippon.services.embercellar.model.Rack} interface.
* </p>
*
* @author Brian Wing Shun Chan
*/
@ProviderType
public class RackImpl extends RackBaseImpl {

public RackImpl() {
this.setType("rack"); // Add specifically for Ember Data
}

/**
* NOTE FOR DEVELOPERS:
*
* Never reference this class directly. All methods that expect a rack model instance should use the {@link fr.ippon.services.embercellar.model.Rack} interface instead.
*/
}

Sur Liferay 7, du fait de la mise en place d’une contrainte sur un token CSRF plus évoluée que sur les versions précédentes, on ouvre la consommation de service (sans jeton d’authentification) JSON sur localhost :

json.service.auth.token.hosts.allowed=SERVER_IP

Une fois ces opérations réalisées, les différents services doivent être disponibles au sein de l’annuaire Web des services offerts par Liferay, disponible sur l’URL http://localhost:8080/api/jsonws :(D’autres stratégies plus évoluées sont bien sûr possibles).

Maintenant que nos services backoffice sont prêts, nous allons mettre en place les stratégies pour permettre leur consommation en mode proxy sur Node selon le schéma suivant :

Step 5.2b Mise en place d’un Adapter / Serializer

Comme pour JHipster, on modifie tout d’abord le fichier environment.js du répertoire config pour y intégrer un mode serveur avec backoffice Liferay. Pour cela on ajoute tout simplement la fonction :

function usingLiferay() {
let isLiferay = !!process.argv.filter(function (arg) {
  return arg.indexOf('--liferay') === 0 || arg.indexOf('-lif') === 0
}).length;
if (typeof Liferay !== 'undefined') { // Defined by Liferay
   isLiferay = true;
}
return isLiferay;
}

Et en complétant la déclaration de l’application par :

APP: {
    // Here you can pass flags/options to your application instance
    // when it is created
    liferay: usingLiferay(),
    proxy: usingProxy(), 
    jhipster: usingJhipster()
  },

On va ensuite, comme pour JHipster, écrire un Adapter spécifique pour Liferay. Etant donné la forme des services Liferay, cet Adapter ne pourra cependant pas s’appuyer sur un Adapter quasi prêt à l’emploi, mais directement étendre la classe DS.Adapter d’Ember Data. Il devra donc implémenter a minima les méthodes suivantes :

  • findRecord()
  • createRecord()
  • updateRecord()
  • deleteRecord()
  • findAll()
  • query()

Par ailleurs, contrairement au mode JHipster, l’objectif étant à terme de déléguer l’authentification au portail, nous utiliserons une authentification automatique (avec un compte par défaut du type test@liferay.com / test) pour travailler en mode proxy Liferay. Pour cela, nous utiliserons un mécanisme JQuery (ajaxPrefilter) pour peupler notre header avec les informations d’authentification / autorisation attendues.

Contrairement au reste de cet article, le code de l’adapter spécifiquement créé pour utiliser les services Liferay ne sera pas repris ici mais reste consultable sur le gitlab du projet.

On y trouvera quelques astuces pour :

  • Gérer de façon transparente l’authentification au travers d’une méthode init de l’Adapter
  • Mettre en place une gestion des promesses, basée sur JQuery mais restant pleinement compatible avec les attentes d’Ember en la matière
  • Transformer le flux JSON retourné en quelque chose qui se rapproche d’un flux conforme à la JSON API
  • Gérer les relations entre objets du modèle au mieux vis-à-vis des attentes de l’interface

A noter que l’Adapter prend la liberté de stocker en Local Storage du navigateur quelques éléments pour optimiser les appels au back-office.

Le code de cet Adapter est loin d’être un modèle du genre, mais pourra inspirer des implémentations plus propres et plus complètes !

Step 5.3b Portletisation de l’application Ember

L’intérêt du mode de développement précédent est qu’il permet de réaliser une interface Web basée sur des services Liferay, sans rien connaître des spécificités Java et portail.

Maintenant, il serait encore bien plus appréciable d’aller un cran plus loin en transformant le développement précédent en une véritable portlet, immédiatement déployable sur Liferay.

C’est tout à fait possible et ceci sans grande difficulté !

Pour cela, on doit simplement signaler à Ember qu’il doit travailler dans un fragment de page et non dans la page complète. Pour cela, il faut lui indiquer un rootElt. Ce ‘Root Element’ correspondra à la balise

qui délimite la portlet.

Cette déclaration ne doit cependant se faire que quand le JavaScript est exécuté au sein du portail. Pour cela, on va utiliser un subterfuge : le test de l’existence d’un objet nommé Liferay (automatiquement mis à disposition par le portail). On modifie ainsi le fichier app/app.js avec le test suivant :

var rootElt;
if (typeof Liferay !== 'undefined') {
 var portletId = config.rootElement;
 console.log("Running in portlet mode with portletId="+portletId);
 rootElt = 'section#portlet'+portletId.substr(0, portletId.length - 1);
}

App = Ember.Application.extend({
 modulePrefix: config.modulePrefix,
 podModulePrefix: config.podModulePrefix,
 rootElement: rootElt,
 Resolver
});

De la même manière, il faut signaler autrement que par un drapeau de lancement sur la ligne de commande qu’on doit utiliser l’Adapter Liferay. On modifie donc la fin du fichier app/adapters/application.js :

if ((ENV.APP.liferay && ENV.APP.proxy)|| (typeof Liferay !== 'undefined')) {
  FinalAdapter = LiferayAdapter;
} else {
  FinalAdapter = MirageAdapter;
}

Notre code Front est maintenant prêt à être injecté dans une porltet standard Liferay, qu’on créera dans le workspace du portail par la commande :

blade create -t mvc-portlet -p fr.ippon.portlet.emberCellar embercellar-portlet

Il suffit maintenant d’aller modifier le fichier /src/main/resources/META-INF/resources/view.jsp pour :

  • Intégrer dans la balise meta les informations de démarrage de l’application Ember
  • Éventuellement, de mettre à disposition dans des variables JavaScript les informations déjà connues du portail et qui pourraient être utiles à l’application ember (ici le login de l’utilisateur et ses groupes)

Le code de cette page JSP prend alors la forme :

<%@ include file="/init.jsp" %>

<meta name="ember-cellar/config/environment" content="%7B%22modulePrefix%22%3A%22ember-cellar%22%2C%22rootElement%22%3A%22<portlet:namespace />%22%2C%22environment%22%3A%22production%22%2C%22baseURL%22%3A%22/%22%2C%22locationType%22%3A%22auto%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%7D%2C%22APP%22%3A%7B%22liferay%22%3Atrue%2C%22proxy%22%3Atrue%2C%22LOG_RESOLVER%22%3Afalse%2C%22LOG_ACTIVE_GENERATION%22%3Afalse%2C%22LOG_TRANSITIONS%22%3Afalse%2C%22LOG_TRANSITIONS_INTERNAL%22%3Afalse%2C%22LOG_VIEW_LOOKUPS%22%3Afalse%2C%22name%22%3A%22ember-cellar%22%2C%22version%22%3A%220.0.0+b72e2a6f%22%7D%2C%22intl%22%3A%7B%22baseLocale%22%3A%22fr-fr%22%7D%2C%22contentSecurityPolicy%22%3A%7B%22default-src%22%3A%22%27none%27%22%2C%22script-src%22%3A%22%27self%27%22%2C%22font-src%22%3A%22%27self%27%20https%3A//fonts.gstatic.com%22%2C%22connect-src%22%3A%22%27self%27%20http%3A//localhost%3A8888%22%2C%22img-src%22%3A%22%27self%27%20data%3A%22%2C%22style-src%22%3A%22%27self%27%20%27unsafe-inline%27%22%2C%22media-src%22%3A%22%27self%27%22%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Atrue%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Atrue%7D" />
<%
// Get User group in a List
List<com.liferay.portal.model.UserGroup> userGroups = user.getUserGroups();
List<String> userGroupNames = new ArrayList();
for (UserGroup userGroup : userGroups) {
 userGroupNames.add(userGroup.getName().toLowerCase());
}
String userLogin = user.getScreenName();
%>

<script type="text/javascript">
var userGroupNames = "<%=userGroupNames%>";
var userLogin = "<%=userLogin%>";
</script>

Pour les autres ressources, on procède simplement par un :

  • Ember build
  • Suivi par une recopie des ressources produites dans l’environnement de la portlet comme décrit dans le schéma suivant :

Reste ensuite à compiler la portlet et à la déployer dans le portail pour l’utiliser de la même façon que sous Node !

Cette manière de procéder permet de concilier les avantages du déploiement portail (gestion des profils utilisateur et de leur habilitation, gestion des pages et des applications mis à disposition, CMS et GED intégrés) avec la légèreté du développement des interfaces utilisateurs Javascript modernes sous Node.js. Elle permet également d’utiliser des compétences purement JavaScript pour la partie front qui peuvent ignorer tout des arcanes de Java et de Liferay. L’implémentation présentée ici est cependant encore très partielle et occulte un certain nombre de problèmes sous-jacents (gestion des URL, multi-instanciation des portlets, etc..). Rien de rédhibitoire, mais encore pas mal d’expérimentation à poursuivre !