rsb-app
v1.0.1
Published
RSB-APP ou (**R**eact **S**ervice **B**ased **App**lication), est une librairie Typescript experimentale d'architecture par Service côté Front-End pour des projets React et NextJS. <br><br> Il permet la création de services interconnectés au sein de l'ap
Downloads
144
Readme
Qu'est-ce que RSB-APP ?
RSB-APP ou (React Service Based Application), est une librairie Typescript experimentale d'architecture par Service côté Front-End pour des projets React et NextJS. Il permet la création de services interconnectés au sein de l'application sous forme de classe modulable et surchargeable avec un système d'event-bus pré-intégré, tout en exploitant la capacité réactive de React. Il permet de passer outre la relation hierarchique unidirectionnel des composants React en les rendant (les services) interconnectés sans avoir à passé par une passation de props entre parents <-> enfants. Il intègre également un système de plugin optionnel pensé pour l'intégration en CI/CD pour des projets multi-clients (un repository pour plusieurs clients).
Disclaimer : le projet n'est qu'à un stade experimental, il vaut mieux le voir pour l'instant comme un outil d'architecture plutot qu'un framework complet avec une architecture propre et définie.
Qu'est-ce qu'un Service RSB ?
On peut définir un Service RSB comme un Service générique sous forme de classe Typescript ayant une instance unique (*Singleton créer et géré automatiquement par RSB). Il peut à la fois servir comme un componsant globale, un service d'API, un store de model global, un task scheduler et une infinité d'autre cas.. Voici un exemple de Service :
import Service, { createServiceExport, ServiceDescriptor } from "rsb-app";
@ServiceDescriptor({
name: 'MathService',
package: 'utils/math'
})
export class MathService extends Service {
constructor() {
super();
}
public onInit() {
// Appelé à l'initialisation du service (une seule fois par instance de l'application).
}
public addition(a: number, b: number) {
return a + b;
}
}
export default createServiceExport(MathService);
Voici un exemple de son implémentation dans un composant React :
import { useState } from "react";
import mathService from "@services/utils/math/math.service";
function ExampleAdditionComponent() {
const math = mathService.use();
const [firstValue, setFirstValue] = useState(0);
const [secondValue, setSecondValue] = useState(0);
const [resultValue, setResultValue] = useState(0);
const onResultClick = () => {
// Appel de la méthode du service
const additionResult = math.addition(firstValue, secondValue);
setResultValue(additionResult);
};
return (
<div>
<input type="number" min="0" max="99" value={firstValue} onChange={e => setFirstValue(e.target.current)}/>
<input type="number" min="0" max="99" value={secondValue} onChange={e => setSecondValue(e.target.current)}/>
<button
type="button"
onClick={onResultClick}
>
Calculate
</button>
<input type="number" disabled value={resultValue}/>
</div>
);
};
export default ExampleAdditionComponent;
Système de Plugin RSB
Les plugins RSB prennent la même forme que des services au sein de l'application mais sous une autre appelation, ils doivent être optionnel à l'éxécution et au bon fonctionnement de l'application et doivent être destiné à être des modules changeable / supprimable vis à vis de l'application.
Le principal cas d'utilisation des plugins RSB est dans les projets multi-clients, par ici on entend un repository pour plusieurs environnement et/ou clients avec différentes fonctionnalités. Cela permet de conserver la même codebase pour x clients tout en supportant l'ajout de fonctionnalité unique par client sans une gestion massive de branche, repository, pull requests, merge conflicts etc..
Il suffit d'ajouter dynamiquement les fichiers sources des plugins en fonction du cas de déploiement lors de la parti CI/CD. Cela peut être effectué via un système fait maison ou bien par un provider de plugin RSB (à voir rsb-manager
et rsb-manager-cli
).
Initilialisation de RSB dans l'app
Afin d'initialiser et démarrer automatiquement tous les services enregistré de l'application il suffit d'appeler la fonction InitRSB
dans le fichier root de l'application.
Cette fonction chargera et initialisera automatiquement tous les services et plugins enregistrés dans les répértoires cibles du projet (configurable depuis le fichier de configuration).
Exemple pour une application NextJS (fichier "src/pages/_app.tsx") :
import type { AppProps } from "next/app";
import { InitRSB } from "rsb-app";
// Initialisation des services et plugins avant le premier rendu de l'application.
InitRSB();
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
</>
);
};
Configuration
Le fichier de configuration "rsb.config.js", présent à la racine du projet, vous permet de paramètrer votre projet RSB.
Il est automatiquement créer lors de l'appel à la commande rsb-app init
.
Voici la configuration par défaut :
/*
* React-Service-Based APP CONFIG (RSB-APP)
*/
const config = {
build: {
services: {
path: 'src/services/', // Répertoire des services.
extension: '.service.ts' // Extension des services.
},
plugins: {
enabled: false, // Status d'activation des plugins.
path: 'src/plugins/', // Répertoire des plugins.
extension: '.plugin.ts' // Extension des plugins.
}
},
create: {
pluginClassPath: "rsb-app", // Chemin d'import de la classe Plugin pour la commande create.
serviceClassPath: "rsb-app", // Chemin d'import de la classe Service pour la commande create.
askForEventMap: true // Demander la création de la map des évennements lors de la création d'un Service/Plugin.
},
init: {
createDirectories: true // Créer automatiquement les répertoires des services et plugins lors de l'initialisation.
}
};
module.exports = config;
Commandes
Liste des commandes RSB disponibles :
rsb-app init : Initialisation du projet, création du fichier de configuration "rsb.config.js". Exemple :
npm run rsb-app init
rsb-app build : Importe les services et plugins présents dans le projet au sein de RSB. (requis avant le build de React/NextJS)
Options :
- --clear-cache : Supprimer le cache de webpack et react automatiquement.
- --clear-next-cache : Supprime le cache de NextJS.
Exemple d'utilisation :
npm run rsb-app build
rsb-app create : Créer un service/plugin automatiquement.
rsb-app create service (package) (opt : nom) : Créer un service basique.
rsb-app create plugin (package) (opt : nom) : Créer un plugin basique.
rsb-app create service-component (package) (opt : nom) : Créer un service composant avec un composant par défaut sous le format .tsx.
rsb-app create plugin-component (package) (opt : nom) : Créer un plugin composant avec un composant par défaut sous le format .tsx.
Exemple d'utilisation :
npm run rsb-app create service api/auth AuthApiService
Réactivité avec les Services RSB
Afin de ne pas perdre le principale interêt de React et malgré l'architecture POO (Programmation Orienté Objet) imposé par la librairie concernant les services, les composants React ont la possibilité d'intéragir avec les propriétés d'un service et d'écouter la mise à jour des valeurs automatiquement (semblable au useState de React) via une simple implémentation de méthode dans le Service désiré.
Par défaut chaque mise à jour de chaque propriété d'un service RSB sera écouté et intercepté par la librairie puis dispatché au sein de l'event-bus natif du service.
Il existe en plus de l'event-bus une manière simple de rendre une propriété d'un service écoutable par les composants React, il suffit de généré un getter publique de la propriété annoté du décorateur UseStateProperty
.
Voici un exemple d'implémentation de ce décorateur au sein d'un Service :
import Service, { createServiceExport, ServiceDescriptor, UseStateProperty } from "rsb-app";
@ServiceDescriptor({
name: 'LangService',
package: 'lang'
})
export class LangService extends Service {
protected locale: string;
protected allowedLocales: string[];
constructor() {
super();
this.allowedLocales = [
"en",
"fr",
"it",
"es"
];
this.locale = this.allowedLocales[0];
}
public onInit() {
// Appelé à l'initialisation du service (une seule fois par instance de l'application).
}
public getAllowedLocales() {
return [...this.allowedLocales];
}
// Setter publique de "locale"
public setLocale(locale: string) {
if(this.allowedLocales.includes(locale)) {
this.locale = locale;
}
}
// Getter publique de "locale"
public getLocale() { return this.locale; }
// UseState publique de "locale" (à utiliser uniquement depuis un composant React)
@UseStateProperty()
public useLocale() { return this.locale; }
}
export default createServiceExport(LangService);
Voici un exemple de son implémentation dans un composant React :
import { useState } from "react";
import langService from "@services/lang/lang.service";
function ExampleLanguageComponent() {
const lang = langService.use();
// Re-rend le composant lorsque la valeur de la propriété "locale" se met à jour.
const locale = lang.useLocale();
// Récupère simplement les "allowedLocales" sans réactivité.
const allowedLocales = lang.getAllowedLocales();
// Handler du choix du select.
const onSelectChoice = (value: string) => {
// Appel du setter publique du service.
lang.setLocale(value);
};
return (
<div>
<h2>Choix de langue</h2>
<select value={locale} onChange={e => onSelectChoice(e.target.current)}>
{
allowedLocales.map(value => (
<option value={value}>{value}</option>
))
}
</select>
</div>
);
};
export default ExampleLanguageComponent;
Event-Bus dans les Services RSB
Tous les services (et plugins) RSB possèdent un système d'event-bus natif avec un système de clé (channel), valeur, à destination principale des autres services de l'application, avec un support tier aux composants React.
Un support du typage est disponible en déclarant les clés et valeurs des évennements au sein d'une interface.
Voici un exemple d'implémentation au sein d'un service, avec un total de 3 évennements :
- authenticate
- success
- failed
Fichiers :
src/services/auth/auth.events.ts
:
export default interface AuthEvents {
authenticate: {
email: string;
password: string;
};
success: {
email: string;
token: string;
};
failed: {
email: string;
};
};
src/services/auth/auth.service.ts
:
import Service, { createServiceExport, ServiceDescriptor } from "rsb-app";
import AuthEvents from "./auth.events";
interface IAuthResponse {
success: boolean;
token: string;
message?: string;
};
@ServiceDescriptor({
name: 'AuthService',
package: 'auth'
})
export class AuthService extends Service<AuthEvents> {
protected token: string | null;
constructor() {
super();
this.token = null;
}
public onInit() {
// Appelé à l'initialisation du service (une seule fois par instance de l'application).
}
public authenticate(email: string, password: string) {
return new Promise<boolean>(async resolve => {
if(!this.emitEvent(
'authenticate',
{
email,
password
}
)) {
resolve(false);
return;
}
try {
const res = await fetch(...);
if(res.status != 200) {
throw new Error("Status code is not 200");
}
const data: IAuthResponse = await res.json();
if(!data) {
throw new Error("Invalid response body (not JSON format)");
}
if(!data.success) {
throw new Error("Authentication has failed");
}
if(!this.emitEvent(
"success",
{
email,
token: data.token
}
)) {
throw new Error("Authentication has been canceled");
}
this.token = data.token;
resolve(true);
return;
}
catch(err) {
console.log(`An error has occured while authenticating, reason : ${err}`);
this.emitEvent('failed', { email });
}
resolve(false);
});
}
public isAuthenticated() { return this.token != null; }
// Getter publique de "token"
public getToken() { return this.token; }
}
export default createServiceExport(AuthService);
Implémentation dans un Service consommateur de l'event-bus du Service :
import Service, { createServiceExport, ServiceDescriptor, UseStateProperty } from "rsb-app";
import authService from "@services/auth/auth.service";
interface IUserProfile {
firstName: string;
lastName: string;
email: string;
};
@ServiceDescriptor({
name: 'UserProfileService',
package: 'user-profile'
})
export class AuthService extends Service {
protected userProfile: IUserProfile | null;
constructor() {
super();
this.userProfile = null;
}
// Appelé à l'initialisation du service (une seule fois par instance de l'application).
public onInit() {
const auth = authService.use();
// Lors de l'évennement "success" on appel la méthode "onAuthenticated" qui viendra lancer un fetch du user-profile lié au token.
auth.addEventListener("success", (e) => this.onAuthenticated(e.data.token));
}
protected onAuthenticated(token: string) {
this.userProfile = null;
this.fetchUserProfile(token);
}
protected fetchUserProfile(token: string) {
return new Promise<boolean>(async resolve => {
try {
const res = await fetch(...);
if(res.status != 200) {
throw new Error("Status code is not 200");
}
const data: IUserProfile = await res.json();
if(!data) {
throw new Error("Invalid response body (not JSON format)");
}
if(!data.success) {
throw new Error("Authentication has failed");
}
this.userProfile = { ...data };
resolve(true);
return;
}
catch(err) {
console.log(`An error has occured while fetching user profile, reason : ${err}`);
}
resolve(false);
});
}
// Getter publique de "userProfile"
public getUserProfile() { return this.userProfile; }
@UseStateProperty()
public useUserProfile() { return this.userProfile; }
}
export default createServiceExport(UserProfileService);
Implémentation dans un composant consommateur :
import { useState } from "react";
import authService from "@services/auth/auth.service";
enum AuthStatus {
AUTHENTICATING = 0,
AUTHENTICATED = 1,
UNAUTHENTICATED = 2
};
function AuthStatusComponent() {
const auth = authService.use();
const [authStatus, authStatus] = useState<AuthStatus>(AuthStatus.UNAUTHENTICATED);
// Ecoute l'évennement "authenticate" du Service
auth.useListenEvent("authenticate", e => {
setStatus(AuthStatus.AUTHENTICATING);
});
// Ecoute l'évennement "success" du Service
auth.useListenEvent("success", e => {
setStatus(AuthStatus.AUTHENTICATED);
});
// Ecoute l'évennement "failed" du Service
auth.useListenEvent("failed", e => {
setStatus(AuthStatus.UNAUTHENTICATED);
});
return (
<div>
{ authStatus == AuthStatus.AUTHENTICATING && (<label>Authenticating...</label>) }
{ authStatus == AuthStatus.AUTHENTICATED && (<label>Authenticated</label>) }
{ authStatus == AuthStatus.UNAUTHENTICATED && (<label>Unauthenticated</label>) }
</div>
);
};
export default AuthStatusComponent;