Fermer

novembre 28, 2019

Mise en oeuvre (2e partie)48 minutes de lecture


À propos de l'auteur

Leonardo Losoviz est un développeur et écrivain indépendant, dont la mission est d'intégrer des paradigmes innovants (PHP sans serveur, composants côté serveur, GraphQL)…
Plus d'informations sur
Leonardo

Le fait de rendre notre code agnostique pour les CMS nous permet, autant que possible, de transférer facilement notre application sur un autre CMS en cas de besoin. Dans cet article, nous allons apprendre à résumer une application WordPress, en rendant son code facilement disponible pour d'autres frameworks ou CMS.

Dans la première partie de cette série nous avons appris les concepts clés pour créer une application. c'est aussi agnostique que possible vis-à-vis des CMS. Dans cette deuxième et dernière partie, nous allons résumer une application WordPress, rendant son code prêt à être utilisé avec les composants Symfony Laravel framework et October CMS (basé sur Laravel).

Accès aux services

Avant de commencer à extraire le code, nous devons fournir la couche d'injection de dépendance à l'application. Comme décrit dans la première partie de cette série, cette couche est satisfaite par le composant DependencyInjection de Symfony . Pour accéder aux services définis, nous créons une classe ContainerBuilderFactory qui stocke simplement une instance statique de l’objet ContainerBuilder du composant:

 utilise Symfony  Component  DependencyInjection  ContainerBuilder;

classe ContainerBuilderFactory {
  private statique $ instance;
  Fonction statique publique init ()
  {
    self :: $ instance = new ContainerBuilder ();
  }
  fonction statique publique getInstance ()
  {
    return self :: $ instance;
  }
} 

Ensuite, pour accéder à un service appelé "cache" l'application le demande comme suit:

 $ cacheService = ContainerBuilderFactory :: getInstance () -> get ('cache');
// faire quelque chose avec le service
// $ cacheService -> ... 

Résumé du code WordPress

Nous avons identifié les éléments de code et les concepts suivants d'une application WordPress qui doivent être extraits du point de vue de WordPress:

  • accès aux fonctions
  • noms
  • paramètres de fonction
  • états (et autres valeurs constantes)
  • fonctions d'assistance de CMS
  • autorisations utilisateur
  • options d'application
  • noms de colonne de la base de données
  • erreurs
  • crochets
  • routage
  • propriétés d'objet
  • état global
  • modèles d'entité (méta, types d'article, pages étant des articles et taxonomies — balises et catégories -)
  • traduction
  • média.

Procédons à leur résumé. , un par un.

Note: Pour faciliter la lecture, j’ai omis d’ajouter des espaces de noms à toutes les classes et interfaces de cet article. Cependant, l'ajout d'espaces de noms, comme spécifié dans la recommandation des normes PHP PSR-4 est indispensable! Entre autres avantages, l’application peut alors bénéficier du chargement automatique et l’injection de dépendance de Symfony peut s’appuyer sur le chargement de service automatique pour réduire sa configuration au strict minimum.

Fonctions d’accès [

] 19659031] Le mantra «code contre interfaces, pas mises en œuvre» signifie que toutes les fonctions fournies par le CMS ne sont plus accessibles directement. Au lieu de cela, nous devons accéder à la fonction à partir d'un contrat (une interface), sur lequel la fonction CMS sera simplement l'implémentation. À la fin de l'abstraction, puisqu'un code WordPress ne sera plus référencé directement, nous pourrons alors échanger WordPress avec un autre CMS.

Par exemple, si notre application accède à la fonction get_posts :

 $ posts = get_posts ($ args); 

Nous devons ensuite résumer cette fonction sous un contrat quelconque:

 interface PostAPIInterface
{
  fonction publique getPosts ($ args);
} 

Le contrat doit être implémenté pour WordPress: la classe

 WPPostAPI implémente PostAPIInterface
{
  fonction publique getPosts ($ args) {
    return get_posts ($ args);
  }
} 

Un service "posts_api" doit être ajouté au fichier de configuration injection de dépendance services.yaml indiquant la classe qui résout le service:

 services:
  posts_api:
    class:  WPPostAPI 

Enfin, l'application peut référencer la fonction via le service "posts_api" :

 $ postsAPIService = ContainerBuilderFactory :: getInstance () -> get ('posts_api');
$ posts = $ postsAPIService-> getPosts ($ args); 

Noms de fonction

Si vous avez remarqué que dans le code présenté ci-dessus, la fonction get_posts est abstraite sous la forme getPost . C'est une bonne idée pour deux raisons:

  • En appelant la fonction différemment, elle permet d'identifier quel code appartient à WordPress et quel code appartient à notre application abstraite.
  • Les noms de fonction doivent être camelCased pour se conformer à. PSR-2 qui tente de définir une norme pour l'écriture de code PHP.

Certaines fonctions peuvent être redéfinies, ce qui est plus logique dans un contexte abstrait. Par exemple, la fonction WordPress get_user_by (champ $, valeur) utilise le paramètre champ $ avec les valeurs "id" "ID" , "slug" "email" ou "login" pour savoir comment obtenir l'utilisateur. Au lieu de reproduire cette méthodologie, nous pouvons explicitement définir une fonction distincte pour chacune d’elles:

 interface UsersAPIInterface
{
  fonction publique getUserById ($ value);
  fonction publique getUserByEmail ($ value);
  fonction publique getUserBySlug ($ value);
  fonction publique getUserByLogin ($ value);
} 

Et ceux-ci sont résolus pour WordPress: la classe

 WPUsersAPI implémente UsersAPIInterface
{
  fonction publique getUserById ($ value)
  {
    return get_user_by ('id', $ valeur);
  }
  Fonction publique getUserByEmail ($ value)
  {
    return get_user_by ('email', $ valeur);
  }
  Fonction publique getUserBySlug ($ value)
  {
    return get_user_by ('slug', $ valeur);
  }
  Fonction publique getUserByLogin ($ value)
  {
    return get_user_by ('login', $ valeur);
  }
} 

Certaines autres fonctions devraient être renommées car leurs noms transmettent des informations sur leur implémentation, qui peuvent ne pas s'appliquer à un autre CMS. Par exemple, la fonction WordPress get_the_author_meta peut recevoir le paramètre "user_lastname" indiquant que le nom de famille de l'utilisateur est stocké sous forme de "méta" (définie comme une propriété supplémentaire pour un objet, non mappé à l'origine dans le modèle de base de données). Cependant, d’autres CMS peuvent avoir une colonne "nom de famille" dans la table utilisateur, elle ne s’applique donc pas en tant que méta-valeur. (La définition même de «méta» est incohérente dans WordPress: la fonction get_the_author_meta accepte également la valeur "user_email" même si le courrier électronique est stocké dans la table des utilisateurs. Par conséquent, I Je préférerais m'en tenir à ma définition de «méta» et supprimer toutes les incohérences du code abstrait.)

Ensuite, notre contrat implémentera les fonctions suivantes:

 interface UsersAPIInterface
{
  fonction publique getUserDisplayName ($ user_id);
  fonction publique getUserEmail ($ user_id);
  fonction publique getUserFirstname ($ user_id);
  fonction publique getUserLastname ($ user_id);
  ...
} 

Lesquels sont résolus pour WordPress:

 la classe WPUsersAPI implémente UsersAPIInterface
{
  fonction publique getUserDisplayName ($ user_id)
  {
    return get_the_author_meta ('display_name', $ user_id);
  }
  Fonction publique getUserEmail ($ user_id)
  {
    return get_the_author_meta ('user_email', $ user_id);
  }
  fonction publique getUserFirstname ($ user_id)
  {
    return get_the_author_meta ('user_firstname', $ user_id);
  }
  fonction publique getUserLastname ($ user_id)
  {
    return get_the_author_meta ('user_lastname', $ user_id);
  }
  ...
} 

Nos fonctions pourraient également être redéfinies afin de supprimer les limitations de WordPress. Par exemple, la fonction update_user_meta ($ user_id, $ meta_key, $ meta_value) peut recevoir un méta-attribut à la fois, ce qui est logique puisque chacun d'entre eux est mis à jour sur sa propre requête de base de données. October Octobre associe tous les méta-attributs sur une seule colonne de base de données. Il est donc plus logique de mettre à jour toutes les valeurs ensemble sur une seule opération de base de données. Ensuite, notre contrat peut inclure une opération updateUserMetaAttributes ($ user_id, $ meta) pouvant mettre à jour plusieurs méta-valeurs simultanément: interface

 UserMetaInterface
{
  fonction publique updateUserMetaAttributes ($ user_id, $ meta);
} 

Ce qui est résolu pour WordPress comme ceci:

 La ​​classe WPUsersAPI implémente UsersAPIInterface.
{
  fonction publique updateUserMetaAttributes ($ user_id, $ meta)
  {
    foreach ($ meta as $ meta_key => $ meta_value) {
      update_user_meta ($ user_id, $ meta_key, $ meta_value);
    }
  }
} 

Enfin, nous voudrons peut-être redéfinir une fonction pour supprimer ses ambiguïtés. Par exemple, la fonction WordPress add_query_arg peut recevoir des paramètres de deux manières différentes:

  1. À l'aide d'une clé et d'une valeur uniques: add_query_arg ('clé', 'valeur', 'http: // exemple. com ');
  2. Utilisation d'un tableau associatif: add_query_arg (['key1' => 'value1', 'key2' => 'value2'],' http://example.com ');

Cela devient difficile à maintenir cohérent sur tous les CMS. Par conséquent, notre contrat peut définir les fonctions addQueryArg (singulier) et addQueryArgs (afin de supprimer les ambiguïtés:

 fonction publique addQueryArg (chaîne $ key, chaîne $ value, chaîne $ url);
fonction publique addQueryArgs (tableau $ valeurs_clé, chaîne $ url); 

Paramètres de fonction

Nous devons également extraire les paramètres de la fonction, en s'assurant qu'ils ont un sens dans un contexte générique. Pour que chaque fonction soit abstraite, nous devons envisager:

  • de renommer et / ou de redéfinir les paramètres;
  • de renommer et / ou de redéfinir les attributs transmis aux paramètres de tableau.

Par exemple, la fonction WordPress get_posts reçoit un paramètre unique $ args qui est un tableau d'attributs. Un de ses attributs est les champs qui, quand on leur donne la valeur "ids" font que la fonction retourne un tableau d'identifiants au lieu d'un tableau d'objets. Cependant, j'estime que cette implémentation est trop spécifique pour WordPress, et pour un contexte générique, je préférerais une solution différente: transmettez cette information via un paramètre séparé appelé $ options sous attribut "return-type ".

Pour ce faire, nous ajoutons le paramètre $ options à la fonction de notre contrat:

 interface PostAPIInterface
{
  fonction publique getPosts ($ args, $ options = []);
} 

Au lieu de faire référence à la valeur constante de WordPress "ids" (nous ne pouvons pas garantir que ce sera celui utilisé dans tous les autres CMS), nous créons une valeur constante correspondante pour notre application abstraite: [19659007] Constantes de classe
{
  const RETURNTYPE_IDS = 'ids';
}

L'implémentation WordPress doit mapper et recréer les paramètres entre le contrat et l'implémentation:

 la classe WPPostAPI implémente PostAPIInterface
{
  fonction publique getPosts ($ args, $ options = []) {
    if ($ options ['return-type'] == Constants :: RETURNTYPE_IDS) {
      $ args ['fields'] = 'ids';
    }
    return get_posts ($ args);
  }
} 

Enfin, nous pouvons exécuter le code via notre contrat:

 $ options = [
  'return-type' => Constants::RETURNTYPE_IDS,
];
$ post_ids = $ postsAPIService-> getPosts ($ args, $ options); 

Lors de l’abrégé des paramètres, il convient d’éviter dans la mesure du possible de transférer la dette technique de WordPress dans notre code abrégé. Par exemple, le paramètre $ args de la fonction get_posts peut contenir l'attribut 'post_type' . Ce nom d'attribut est quelque peu trompeur, puisqu'il peut recevoir un élément ( post_type => "post" ) mais aussi une liste d'entre eux ( post_type => "post, événement" ), ce nom doit donc être au pluriel: post_types . Lors de l'abstraction de ce morceau de code, nous pouvons configurer notre interface de manière à ce qu'elle attribue l'attribut post_types à la place, qui sera mappé sur le post_type de WordPress. De même, différentes fonctions acceptent des arguments portant des noms différents Même si ces objectifs ont le même objectif, leurs noms peuvent être unifiés. Par exemple, avec le paramètre $ args la fonction WordPress get_posts accepte l'attribut posts_per_page et la fonction get_posers accepte l'attribut posts_per_page et la fonction get_posers accepte l'attribut . ]. Ces noms d'attributs peuvent parfaitement être remplacés par le nom d'attribut plus générique limite .

Il est également judicieux de renommer les paramètres afin de permettre de comprendre facilement ceux qui appartiennent à WordPress et ceux qui ont été abstraits. . Par exemple, nous pouvons décider de remplacer tous les "_" par "-" de sorte que notre argument récemment défini post_types devient post-types .

En appliquant ces considérations précédentes, notre code abstrait se présentera comme suit:

 La ​​classe WPPostAPI implémente PostAPIInterface.
{
  fonction publique getPosts ($ args, $ options = []) {
    ...
    if (isset ($ args ['post-types']))) {
      $ args ['post_type'] = $ args ['post-types'];
      unset ($ args ['post-types']);
    }
    if (isset ($ args ['limit']))) {
      $ args ['posts_per_page'] = $ args ['limit'];
      unset ($ args ['limit']);
    }
    return get_posts ($ args);
  }
} 

Nous pouvons également redéfinir les attributs pour modifier la forme de leurs valeurs. Par exemple, le paramètre WordPress $ args de la fonction get_posts peut recevoir un attribut date_query dont les propriétés ( "après" " inclus "etc) peuvent être considérés comme spécifiques à WordPress:

 $ date = current_time ('timestamp');
$ args ['date_query'] = array (
  tableau (
    'after' => date ('Y-m-d H: i: s', $ date),
    'inclusive' => true,
  )

Pour unifier la forme de cette valeur en quelque chose de plus générique, nous pouvons la réappliquer en utilisant d’autres arguments, tels que "date-à partir de" et "date-à-compter " (cette solution n'est toutefois pas convaincante à 100%, car elle est plus explicite que WordPress):

 La ​​classe WPPostAPI implémente PostAPIInterface
{
  fonction publique getPosts ($ args, $ options = []) {
    ...
    if (isset ($ args ['date-from']))) {
      $ args ['date_args'][]  = [
        'after' => $ args ['date-from'],
        'inclusive' => false,
      ];
      unset ($ args ['date-from']);
    }
    if (isset ($ args ['date-from-inclusive']))) {
      $ args ['date_args'][]  = [
        'after' => $ args ['date-from-inclusive'],
        'inclusive' => true,
      ];
      unset ($ args ['date-from-inclusive']);
    }
    return get_posts ($ args);
  }
} 

En outre, nous devons déterminer s'il convient d'abstraire ou non les paramètres trop spécifiques de WordPress. Par exemple, la fonction get_posts permet de classer les messages par attribut menu_order ce qui, à mon avis, ne fonctionne pas dans un contexte générique. Ensuite, je préférerais ne pas résumer ce code et le conserver dans le package spécifique à CMS pour WordPress.

Enfin, nous pouvons également ajouter des types d'argument (et, puisque nous en sommes, nous renvoyons également des types) à notre fonction contractuelle, le rendant plus compréhensible et permettant au code d’échouer pendant la compilation plutôt que pendant l’exécution:

 interface PostAPIInterface
{
  fonction publique getPosts (tableau $ args, tableau $ options = []): tableau;
} 

Etats (et autres valeurs constantes)

Nous devons nous assurer que tous les états ont la même signification dans tous les CMS. Par exemple, les publications dans WordPress peuvent avoir l'un des états suivants: "publier" "en attente" "projet" ou "corbeille" . Pour vous assurer que l'application référence la version abstraite des états et non la version spécifique du CMS, nous pouvons simplement définir une valeur constante pour chacun d'entre eux:

 class PostStates {
  const PUBLISHED = 'publié';
  const PENDING = 'en attente';
  const DRAFT = 'draft';
  const TRASH = 'corbeille';
} 

Comme on peut le constater, les valeurs constantes réelles peuvent ne pas être les mêmes que dans WordPress: alors que "publier" était renommé "publié" l'autre les uns restent les mêmes.

Pour l'implémentation de WordPress, nous convertissons la valeur agnostique en valeur spécifique à WordPress:

 la classe WPPostAPI implémente PostAPIInterface.
{
  fonction publique getPosts ($ args, $ options = []) {
    ...
    if (isset ($ args ['post-status']))) {
      $ conversion = [
        PostStates::PUBLISHED => 'publish',
        PostStates::PENDING => 'pending',
        PostStates::DRAFT => 'draft',
        PostStates::TRASH => 'trash',
      ];
      $ args ['post_status'] = $ conversion [$args['post-status']];
      unset ($ args ['post-status']);
    }
    return get_posts ($ args);
  }
} 

Enfin, nous pouvons faire référence à ces constantes dans toute notre application agnostique au CMS:

 $ args = [
  'post-status' => PostStates::PUBLISHED,
];
$ posts = $ postsAPIService-> getPosts ($ args); 

Cette stratégie fonctionne sous l'hypothèse que tous les systèmes de gestion prendront en charge ces états. Si un CMS ne prend pas en charge un état particulier (par exemple: "en attente" ), il devrait alors émettre une exception chaque fois qu'une fonctionnalité correspondante est appelée.

Fonctions d'assistance du CMS

WordPress implémente plusieurs fonctions d'assistance qui doit également être abstraite, telle que make_clickable . Comme ces fonctions sont très génériques, nous pouvons implémenter un comportement par défaut qui fonctionne bien dans un contexte abstrait et qui peut être remplacé si le CMS implémente une meilleure solution.

Nous définissons d'abord le contrat:

 interface HelperAPIInterface
{
  fonction publique makeClickable (string $ text);
} 

Et fournit un comportement par défaut aux fonctions d'assistance via une classe abstraite:

 la classe abstraite AbstractHelperAPI implémente HelperAPIInterface
{
  fonction publique makeClickable (string $ text) {
    return preg_replace ('! ((f | ht) tp (s)?: //) [-a-zA-Zа-яА-Я()0-9@:%_+.~#?&;//=] +)! i', ' 1 $ ', $ text);
  }
} 

Notre application peut désormais utiliser cette fonctionnalité ou, si elle s'exécute sur WordPress, utiliser l'implémentation spécifique à WordPress:

classe WPHelperAPI étend AbstractHelperAPI.
{
  fonction publique makeClickable (string $ text) {
    return make_clickable ($ text);
  }
} 

Autorisations utilisateur

Pour tous les CMS prenant en charge la gestion des utilisateurs, en plus de l’abstraction des fonctions correspondantes (telles que current_user_can et user_can dans WordPress), nous devons également effectuer les opérations suivantes: Assurez-vous que les autorisations utilisateur (ou capacités) ont le même effet sur tous les CMS. Pour ce faire, notre application abstraite doit énoncer explicitement ce que l’on attend de la capacité et l’implémentation de chaque CMS doit satisfaire ce besoin par l’une de ses propres capacités ou émettre une exception s’il ne le peut pas. Par exemple, si l'application doit valider si l'utilisateur peut éditer des publications, elle peut la représenter via une fonctionnalité appelée "fonctionnalité: editPosts" qui satisfait à WordPress grâce à sa fonctionnalité "edit_posts". .

C’est toujours un exemple du principe “code contre interfaces, pas implémentations”, mais nous nous heurtons ici à un problème: alors que PHP permet de définir des interfaces et des classes pour modéliser des contrats et des fournisseurs de services en compilation, pour que le code ne soit pas compilé si une classe implémentant une interface n'implémente pas toutes les fonctions définies dans l'interface), PHP ne propose aucune construction similaire pour valider une capacité contractuelle (qui est simplement une chaîne, telle que "capacité: editPosts" ) a été satisfaite par une capacité du CMS. Ce concept, que je qualifie de «contrat en vrac», devra être traité par notre application, au moment de l'exécution.

Pour traiter les «contrats en vrac», j'ai créé un service LooseContractService via lequel:

  • l'application peut définir quels «noms de contrat» doivent être implémentés, via la fonction requireNames .
  • les implémentations spécifiques à CMS peuvent satisfaire ces noms, via la fonction implementationNames . 19659013] l'application peut obtenir l'implémentation d'un nom via la fonction getImplementedName .
  • l'application peut également rechercher tous les noms requis non satisfaits via la fonction getNotImplementedRequiredNames comme pour lancer un exception ou consigner l'erreur si nécessaire.

Le service se présente comme suit:

 class LooseContractService
{
  protected $ requiredNames = [];
  protected $ nameImplementations = [];

  fonction publique requireNames (array $ names): void
  {
    $ this-> requiredNames = array_merge (
      $ this-> requiredNames,
      $ noms
    )
  }

  fonction publique implantNames (array $ nameImplementations): void
  {
    $ this-> nameImplementations = array_merge (
      $ this-> nameImplementations,
      $ nameImplementations
    )
  }

  fonction publique getImplementedName (string $ name):? string {
    retourne $ this-> nameImplementations [$name];
  }

  fonction publique getNotImplementedRequiredNames (): array {
    return array_diff (
      $ this-> requiredNames,
      array_keys ($ this-> nameImplementations)
    )
  }
} 

Une fois initialisée, l'application peut ensuite établir des contrats en vrac en demandant des noms:

 $ looseContractService = ContainerBuilderFactory :: getInstance () -> get ('loose_contracts');
$ looseContractService-> requireNames ([
  'capability:editPosts',
]); 

Et l'implémentation spécifique à CMS peut satisfaire à ces critères:

 $ looseContractService-> ImplementNames ([
  'capability:editPosts' => 'edit_posts',
]); 

L'application peut alors résoudre le nom requis la mise en œuvre du CMS. Si ce nom requis (dans ce cas, une capacité) n'est pas implémenté, l'application peut émettre une exception:

 $ cmsCapabilityName = $ looseContractService-> getImplementedName ('capable: editPosts');
si (! $ cmsCapabilityName) {
  jette new Exception (sprintf (
    "Le CMS ne prend pas en charge la capacité "% s  "",
    'capacité: editPosts'
  ));
}
// Peut maintenant utiliser la fonctionnalité pour vérifier les autorisations
$ userManagementAPIService = ContainerBuilderFactory :: getInstance () -> get ('user_management_api');
if ($ userManagementAPIService-> userCan ($ user_id, $ cmsCapabilityName)) {
  ...
} 

Sinon, l'application peut également échouer lors de la première initialisation si un nom requis n'est pas satisfait:

 if ($ notImplementedNames = $ looseContractService-> getNotImplementedRequiredNames ()) {
  jette new Exception (sprintf (
    "Le CMS n'a pas implémenté les noms de contrat en vrac% s",
    imploser (',', $ notImplementedNames)
  ));
} 

Options d'application

WordPress est livré avec plusieurs options d'application, telles que celles stockées dans le tableau wp_options sous les entrées "blogname" "blogdescription" . , "admin_email" "date_format" et beaucoup d'autres. La synthèse des options d’application implique:

  • l’abstraction de la fonction de getOption et
  • de la synthèse de chacune des options requises, dans le but de faire en sorte que le CMS satisfasse à la notion de cette option (par exemple, si un CMS n’a une option pour la description du site, il ne peut pas renvoyer le nom du site à la place).

Résolissons ces 2 actions à tour de rôle. En ce qui concerne la fonction getOption je pense que nous pouvons nous attendre à ce que tous les CMS prennent en charge les options de stockage et de récupération. Nous pouvons donc placer la fonction correspondante sous un contrat CMSCoreInterface :

 interface CMSCoreInterface.
{
  fonction publique getOption ($ option, $ default = false);
} 

Comme on peut le constater à partir de la signature de fonction ci-dessus, je suppose que chaque option aura également une valeur par défaut. Cependant, je ne sais pas si chaque CMS permet de définir des valeurs par défaut pour les options. Mais peu importe puisque l’implémentation peut simplement renvoyer NULL alors.

Cette fonction est résolue pour WordPress comme suit:

 La ​​classe WPCMSCore implémente CMSCoreInterface.
{
  fonction publique getOption ($ option, $ default = false)
  {
    return get_option ($ option, $ default);
  }
} 

Pour résoudre la deuxième action, qui consiste à extraire chaque option nécessaire, il est important de noter que même si nous pouvons toujours nous attendre à ce que le CMS prenne en charge getOption nous ne pouvons pas nous attendre à ce qu'il les implémente. option unique utilisée par WordPress, telle que "use_smiles" ou "default_ping_status" . Par conséquent, nous devons d’abord filtrer toutes les options et n’abréger que celles qui ont un sens dans un contexte générique, telles que "siteName" ou "dateFormat" .

liste d’options à résumer, nous pouvons utiliser un «contrat en vrac» (comme expliqué précédemment) et exiger un nom d’option correspondant pour chacune d’elles, tel que "option: nom du site" (résolu pour WordPress comme "blogname" ) ou "option: dateFormat" (résolu en "date_format" ).

Noms de colonne de la base de données

Dans WordPress, lorsque nous demandons des données à partir de la fonction get_posts vous pouvez définir l'attribut "orderby" dans $ args pour ordonner les résultats, qui peuvent être basés sur une colonne de la table posts (telle que valeurs "ID" "titre" "date" "commentaire_compte" etc.), une méta-valeur (par le biais de valeurs "meta_value" et "meta_value_num" ) ou d'autres valeurs (telles que "post__in" et "rand" ).

Chaque fois que la valeur correspond au nom de la colonne de la table, nous pouvons les résumer en utilisant un «contrat en vrac», comme expliqué précédemment. Ensuite, l’application peut faire référence à un nom de contrat non structuré:

 $ args = [
  'orderby' => $looseContractService->getImplementedName('dbcolumn:orderby:posts:date'),
];
$ posts = $ postsAPIService-> getPosts ($ args); 

Et ce nom est résolu pour WordPress:

 $ looseContractService-> ImplementNames ([
  'dbcolumn:orderby:posts:date' => 'date',
]); 

Maintenant, disons cela dans notre application WordPress nous avons créé une méta-valeur "likes_count" (qui stocke le nombre de mentions "J'aime" dans une publication) pour classer les publications par popularité, et nous souhaitons également faire abstraction de cette fonctionnalité. Pour commander les résultats selon une méta-propriété, WordPress attend un attribut supplémentaire "méta_key" ainsi:

 $ args = [
  'orderby' => 'meta_value',
  'meta_key' => 'likes_count',
]; 

À cause de cet attribut supplémentaire, j’estime que cette implémentation est WordPress -spécifique et très difficile à résumer pour le faire fonctionner partout. Ensuite, au lieu de généraliser cette fonctionnalité, je peux simplement m'attendre à ce que chaque CMS ajoute sa propre implémentation spécifique.

Faisons-le. Tout d'abord, je crée une classe d'assistance pour récupérer la requête indépendante du CMS:

 class QueryHelper
{
  Fonction publique getOrderByQuery ()
  {
    retourne un tableau (
      'orderby' => $ looseContractService-> getImplementedName ('dbcolumn: orderby: posts: likesCount'),
    )
  }
} 

Le package spécifique à OctoberCMS peut ajouter une colonne "likes_count" au tableau posts et résoudre le nom "dbcolumn: orderby: posts: likesCount" à " like_count " et cela fonctionnera. Le package spécifique à WordPress doit cependant résoudre "dbcolumn: orderby: posts: likesCount" en tant que "meta_value" puis annuler la fonction d'assistance pour ajouter la propriété supplémentaire " meta_key ":

 class WPQueryHelper étend QueryHelper
{
  Fonction publique getOrderByQuery ()
  {
    $ query = parent :: getOrderByQuery ();
    $ query ['meta_key'] = 'likes_count';
    return $ query;
  }
} 

Enfin, nous configurons la classe de requête d'assistance en tant que service dans ContainerBuilder nous la configurons pour qu'elle soit résolue en classe spécifique à WordPress et nous obtenons la requête pour obtenir les résultats de la commande: [19659009] $ queryHelperService = ContainerBuilderFactory :: getInstance () -> get ('query_helper');
$ args = $ queryHelperService-> getOrderByQuery ();
$ posts = $ postsAPIService-> getPosts ($ args);

Résumé des valeurs pour les résultats de classement qui ne correspondent pas aux noms de colonnes ou aux méta-propriétés (telles que "post__in" et "rand ") semble être plus difficile. Comme mon application ne les utilise pas, je n’ai pas cherché à savoir comment le faire, ni même si cela était possible. Ensuite, j'ai choisi la solution de facilité: je les ai considérées comme spécifiques à WordPress, c'est pourquoi l'application ne les rend disponibles que lorsqu'elles sont exécutées sur WordPress.

Erreurs

Pour traiter les erreurs, nous devons envisager de résumer les éléments suivants:

  • la définition d'une erreur;
  • les codes et messages d'erreur.

Voyons-les à tour de rôle.

Définition d'une erreur:

Une erreur est un objet spécial, différent de Exception utilisé pour indiquer qu'une opération avait échoué et pourquoi. WordPress représente des erreurs dans la classe WP_Error et permet de vérifier si une valeur renvoyée est une erreur via la fonction is_wp_error .

Nous pouvons vérifier l’abstrait:

 interface CMSCoreInterface
{
  fonction publique isError ($ object);
} 

Ce qui est résolu pour WordPress comme ceci:

 la classe WPCMSCore implémente CMSCoreInterface
{
  fonction publique isError ($ object)
  {
    return is_wp_error ($ object);
  }
} 

Cependant, pour traiter les erreurs dans notre code abstrait, nous ne pouvons pas nous attendre à ce que tous les CMS aient une classe d’erreur avec les mêmes propriétés et méthodes que la classe WP_Error de WordPress. Par conséquent, nous devons aussi abstraire cette classe et convertir l’erreur CMS en erreur abstraite après l’exécution d’une fonction du CMS.

La classe d’erreur abstraite Error est simplement une version légèrement modifiée de celle de WordPress WP_Error classe:

 erreur de classe {

  protected $ errors = array ();
  protected $ error_data = array ();

  fonction publique __construct ($ code = null, $ message = null, $ data = null)
  {
    if ($ code) {
      $ this-> errors [$code][]  = $ message;
      si ($ data) {
        $ this-> error_data [$code] = $ data;
      }
    }
  }

  fonction publique getErrorCodes ()
  {
    return array_keys ($ this-> errors);
  }

  fonction publique getErrorCode ()
  {
    if ($ codes = $ this-> getErrorCodes ()) {
      return $ codes [0];
    }

    return null;
  }

  fonction publique getErrorMessages ($ code = null)
  {
    if ($ code) {
      retourne $ ceci-> erreurs [$code] ?? [];
    }

    // Renvoie tous les messages si aucun code n'est spécifié.
    return array_reduce ($ this-> errors, 'array_merge', array ());
  }

  fonction publique getErrorMessage ($ code = null)
  {
    si (! $ code) {
      $ code = $ this-> getErrorCode ();
    }
    $ messages = $ this-> getErrorMessages ($ code);
    retourne $ messages [0] ?? '';
  }

  fonction publique getErrorData ($ code = null)
  {
    si (! $ code) {
      $ code = $ this-> getErrorCode ();
    }

    retourne $ this-> error_data [$code];
  }

  fonction publique add ($ code, $ message, $ data = null)
  {
    $ this-> errors [$code][]  = $ message;
    si ($ data) {
      $ this-> error_data [$code] = $ data;
    }
  }

  fonction publique addData ($ data, $ code = null)
  {
    si (! $ code) {
      $ code = $ this-> getErrorCode ();
    }

    $ this-> error_data [$code] = $ data;
  }

  public function remove($code)
  {
    unset($this->errors[$code]);
    unset($this->error_data[$code]);
  }
}

We implement a function to convert from the CMS to the abstract error through a helper class:

class WPHelpers
{
  public static function returnResultOrConvertError($result)
  {
    if (is_wp_error($result)) {
      // Create a new instance of the abstracted error class
      $error = new Error();
      foreach ($result->get_error_codes() as $code) {
        $error->add($code, $result->get_error_message($code), $result->get_error_data($code));
      }
      return $error;
    }
    return $result;
  }
}

And we finally invoke this method for all functions that may return an error:

class UserManagementService implements UserManagementInterface
{
  public function getPasswordResetKey($user_id)
  {
    $result = get_password_reset_key($user_id);
    return WPHelpers::returnResultOrConvertError($result);
  }
}
Error codes and messages:

Every CMS will have its own set of error codes and corresponding explanatory messages. For instance, WordPress function get_password_reset_key can fail due to the following reasons, as represented by their error codes and messages:

  1. "no_password_reset": Password reset is not allowed for this user.
  2. "no_password_key_update": Could not save password reset key to database.

In order to unify errors so that an error code and message is consistent across CMSs, we will need to inspect these and replace them with our custom ones (possibly in function returnResultOrConvertError explained above).

Hooks

Abstracting hooks involves:

  • the hook functionality;
  • the hooks themselves.

Let’s analyze these in turn.

Abstracting the hook functionality

WordPress offers the concept of “hooks”: a mechanism through which we can change a default behavior or value (through “filters”) and execute related functionality (through “actions”). Both Symfony and Laravel offer mechanisms somewhat related to hooks: Symfony provides an event dispatcher component, and Laravel’s mechanism is called events; these 2 mechanisms are similar, sending notifications of events that have already taken place, to be processed by the application through listeners.

When comparing these 3 mechanisms (hooks, event dispatcher and events) we find that WordPress’s solution is the simpler one to set-up and use: Whereas WordPress hooks enable to pass an unlimited number of parameters in the hook itself and to directly modify a value as a response from a filter, Symfony’s component requires to instantiate a new object to pass additional information, and Laravel’s solution suggests to run a command in Artisan (Laravel’s CLI) to generate the files containing the event and listener objects. If all we desire is to modify some value in the application, executing a hook such as $value = apply_filters("modifyValue", $value, $post_id); is as simple as it can get.

In the first part of this seriesI explained that the CMS-agnostic application already establishes a particular solution for dependency injection instead of relying on the solution by the CMS, because the application itself needs this functionality to glue its parts together. Something similar happens with hooks: they are such a powerful concept that the application can greatly benefit by making it available to the different CMS-agnostic packages (allowing them to interact with each other) and not leave this wiring-up to be implemented only at the CMS level. Hence, I have decided to already ship a solution for the “hook” concept in the CMS-agnostic application, and this solution is the one implemented by WordPress.

In order to decouple the CMS-agnostic hooks from those from WordPress, once again we must “code against interfaces, not implementations”: We define a contract with the corresponding hook functions:

interface HooksAPIInterface
{
  public function addFilter(string $tag, $function_to_add, int $priority = 10, int $accepted_args = 1): void;
  public function removeFilter(string $tag, $function_to_remove, int $priority = 10): bool;
  public function applyFilters(string $tag, $value, ...$args);
  public function addAction(string $tag, $function_to_add, int $priority = 10, int $accepted_args = 1): void;
  public function removeAction(string $tag, $function_to_remove, int $priority = 10): bool;
  public function doAction(string $tag, ...$args): void;
}

Please notice that functions applyFilters and doAction are variadic, i.e. they can receive a variable amount of arguments through parameter ...$args. By combining this feature (which was added to PHP in version 5.6, hence it was unavailable to WordPress until very recently) with argument unpacking, i.e. passing a variable amount of parameters ...$args to a function, we can easily provide the implementation for WordPress:

class WPHooksAPI implements HooksAPIInterface
{
  public function addFilter(string $tag, $function_to_add, int $priority = 10, int $accepted_args = 1): void
  {
    add_filter($tag, $function_to_add, $priority, $accepted_args);
  }

  public function removeFilter(string $tag, $function_to_remove, int $priority = 10): bool
  {
    return remove_filter($tag, $function_to_remove, $priority);
  }

  public function applyFilters(string $tag, $value, ...$args)
  {
    return apply_filters($tag, $value, ...$args);
  }

  public function addAction(string $tag, $function_to_add, int $priority = 10, int $accepted_args = 1): void
  {
    add_action($tag, $function_to_add, $priority, $accepted_args);
  }

  public function removeAction(string $tag, $function_to_remove, int $priority = 10): bool
  {
    return remove_action($tag, $function_to_remove, $priority);
  }

  public function doAction(string $tag, ...$args): void
  {
    do_action($tag, ...$args);
  }
}

As for an application running on Symfony or Laravel, this contract can be satisfied by installing a CMS-agnostic package implementing WordPress-like hooks, such as this onethis one or this one.

Finally, whenever we need to execute a hook, we do it through the corresponding service:

$hooksAPIService = ContainerBuilderFactory::getInstance()->get('hooks_api');
$title = $hooksAPIService->applyFilters("modifyTitle", $title, $post_id);
Abstracting the hooks themselves

We need to make sure that, whenever a hook is executed, a consistent action will be executed no matter which is the CMS. For hooks defined inside of our application that is no problem, since we can resolve them ourselves, most likely in our CMS-agnostic package. However, when the hook is provided by the CMS, such as action "init" (triggered when the system has been initialized) or filter "the_title" (triggered to modify a post’s title) in WordPress, and we invoke these hooks, we must make sure that all other CMSs will process them correctly and consistently. (Please notice that this concerns hooks that make sense in every CMS, such as "init"; certain other hooks can be considered too specific to WordPress, such as filter "rest_{$this->post_type}_query" from a REST controller, so we don’t need to abstract them.)

The solution I found is to hook into actions or filters defined exclusively in the application (i.e. not in the CMS), and to bridge from CMS hooks to application hooks whenever needed. For instance, instead of adding an action for hook "init" (as defined in WordPress), any code in our application must add an action on hook "cms:init"and then we implement the bridge in the WordPress-specific package from "init" to "cms:init":

$hooksAPIService->addAction('init', function() use($hooksAPIService) {
  $hooksAPIService->doAction('cms:init');
});

Finally, the application can add a “loose contract” name for "cms:init"and the CMS-specific package must implement it (as demonstrated earlier on).

Routing

Different frameworks will provide different solutions for routing (i.e. the mechanism of identifying how the requested URL will be handled by the application), which reflect the architecture of the framework:

  • In WordPress, URLs map to database queriesnot to routes.
  • Symfony provides a Routing component which is independent (any PHP application can install it and use it), and which enables to define custom routes and which controller will process them.
  • Laravel’s routing builds on top of Symfony’s routing component to adapt it to the Laravel framework.

As it can be seen, WordPress’s solution is the outlier here: the concept of mapping URLs to database queries is tightly coupled to WordPress’s architecture, and we would not want to restrict our abstracted application to this methodology (for instance, October CMS can be set-up as a flat-file CMS, in which case it doesn’t use a database). Instead, it makes more sense to use Symfony’s approach as its default behavior, and allow WordPress to override this behavior with its own routing mechanism.

(Indeed, while WordPress’s approach works well for retrieving content, it is rather inappropriate when we need to access some functionality, such as displaying a contact form. In this case, before the launch of Gutenberg, we were forced to create a page and add a shortcode "[contact_form]" to it as content, which is not as clean as simply mapping the route to its corresponding controller directly.)

Hence, the routing for our abstracted application will not be based around the modeled entities (post, page, category, tag, author) but purely on custom-defined routes. This should already work perfectly for Symfony and Laravel, using their own solutions, and there is not much for us to do other than injecting the routes with the corresponding controllers into the application’s configuration.

To make it work in WordPress, though, we need to take some extra steps: We must introduce an external library to handle routing, such as Cortex. Making use of Cortex, the application running on WordPress can have it both ways:

  • if there is a custom-defined route matching the requested URL, use its corresponding controller.
  • if not, let WordPress handle the request in its own way (i.e. retrieving the matched database entity or returning a 404 if no match is successful).

To implement this functionality, I have designed the contract CMSRoutingInterface to, given the requested URL, calculate two pieces of information:

  • the actual route, such as contactposts or posts/my-first-post.
  • the nature of the route: core nature values "standard""home" and "404"and additional nature values added through packages such as "post" through a “Posts” package or "user" through a “Users” package.

The nature of the route is an artificial construction that enables the CMS-agnostic application to identify if the route has extra qualities attached to it. For instance, when requesting the URL for a single post in WordPress, the corresponding database object post is loaded into the global state, under global $post. It also helps identify which case we want to handle, to avoid inconsistencies. For instance, we could have defined a custom route contact handled by a controller, which will have nature "standard"and also a page in WordPress with slug "contact"which will have nature "page" (added through a package called “Pages”). Then, our application can prioritize which way to handle the request, either through the controller or through a database query.

Let’s implement it. We first define the service’s contract:

interface CMSRoutingInterface
{
  public function getNature();
  public function getRoute();
}

We can then define an abstract class which provides a base implementation of these functions:

abstract class AbstractCMSRouting implements CMSRoutingInterface
{
  const NATURE_STANDARD = 'standard';
  const NATURE_HOME = 'home';
  const NATURE_404 = '404';

  public function getNature()
  {
    return self::NATURE_STANDARD;
  }

  public function getRoute()
  {
    // By default, the URI path is already the route (minus parameters and trailing slashes)
    $route = $_SERVER['REQUEST_URI'];
    $params_pos = strpos($route, '?');
    if ($params_pos !== false) {
       $route = substr($route, 0, $params_pos);
    }
    return trim($route, '/');
  }
}

And the implementation is overriden for WordPress:

class WPCMSRouting extends AbstractCMSRouting
{
  const ROUTE_QUERY = [
    'custom_route_key' => 'custom_route_value',
  ];
  private $query;
  private function init()
  {
    if (is_null($this->query)) {
      global $wp_query;
      $this->query = $wp_query;
    }
  }

  private function isStandardRoute() {
    return !empty(array_intersect($this->query->query_vars, self::ROUTE_QUERY));
  }

  public function getNature()
  {
    $this->init();
    if ($this->isStandardRoute()) {
      return self::NATURE_STANDARD;
    } elseif ($this->query->is_home() || $this->query->is_front_page()) {
      return self::NATURE_HOME;
    } elseif ($this->query->is_404()) {
      return self::NATURE_404;
    }

    // Allow components to implement their own natures
    $hooksAPIService = ContainerBuilderFactory::getInstance()->get('hooks_api');
    return $hooksAPIService->applyFilters(
      "nature",
      parent::getNature(),
      $this->query
    );
  }
}

In the code above, please notice how constant ROUTE_QUERY is used by the service to know if the route is a custom-defined one, as configured through Cortex:

$hooksAPIService->addAction(
  'cortex.routes', 
  function(RouteCollectionInterface $routes) {  
    // Hook into filter "routes" to provide custom-defined routes
    $appRoutes = $hooksAPIService->applyFilters("routes", []);
    foreach ($appRoutes as $route) {
      $routes->addRoute(new QueryRoute(
        $route,
        function (array $matches) {
          return WPCMSRouting::ROUTE_QUERY;
        }
      ));
    }
  }
);

Finally, we add our routes through hook "routes":

$hooksAPIService->addFilter(
  'routes',
  function($routes) {
    return array_merge(
      $routes,
      [
        'contact',
        'posts',
      ]
    );
  }
);

Now, the application can find out the route and its nature, and proceed accordingly (for instance, for a "standard" nature invoke its controller, or for a "post" nature invoke WordPress’s templating system):

$cmsRoutingService = ContainerBuilderFactory::getInstance()->get('routing');
$nature = $cmsRoutingService->getNature();
$route = $cmsRoutingService->getRoute();
// Process the requested route, as appropriate
// ...

Object properties

A rather inconvenient consequence of abstracting our code is that we can’t reference the properties from an object directly, and we must do it through a function instead. This is because different CMSs will represent the same object as containing different properties, and it is easier to abstract a function to access the object properties than to abstract the object itself (in which case, among other disadvantages, we may have to reproduce the object caching mechanism from the CMS). For instance, a post object $post contains its ID under $post->ID in WordPress and under $post->id in October CMS. To resolve this property, our contract PostObjectPropertyResolverInterface will contain function getId:

interface PostObjectPropertyResolverInterface {
  public function getId($post);
}

Which is resolved for WordPress like this:

class WPPostObjectPropertyResolver implements PostObjectPropertyResolverInterface {
  public function getId($post)
  {
    return $post->ID;
  }
}

Similarly, the post content property is $post->post_content in WordPress and $post->content in October CMS. Our contract will then allow to access this property through function getContent:

interface PostObjectPropertyResolverInterface {
  public function getContent($post);
}

Which is resolved for WordPress like this:

class WPPostObjectPropertyResolver implements PostObjectPropertyResolverInterface {
  public function getContent($post)
  {
    return $post->post_content;
  }
}

Please notice that function getContent receives the object itself through parameter $post. This is because we are assuming the content will be a property of the post object in all CMSs. However, we should be cautious on making this assumption, and decide on a property by property basis. If we don’t want to make the previous assumption, then it makes more sense for function getContent to receive the post’s ID instead:

interface PostObjectPropertyResolverInterface {
  public function getContent($post_id);
}

Being more conservative, the latter function signature makes the code potentially more reusable, however it is also less efficient, because the implementation will still need to retrieve the post object:

class WPPostObjectPropertyResolver implements PostObjectPropertyResolverInterface {
  public function getContent($post_id)
  {
    $post = get_post($post_id);
    return $post->post_content;
  }
}

In addition, some properties may be needed in their original value and also after applying some processing; for these cases, we will need to implement a corresponding extra function in our contract. For instance, the post content needs be accessed also as HTML, which is done through executing apply_filters('the_content', $post->post_content) in WordPress, or directly through property $post->content_html in October CMS. Hence, our contract may have 2 functions to resolve the content property:

interface PostObjectPropertyResolverInterface {
  public function getContent($post_id); // = raw content
  public function getHTMLContent($post_id);
}

We must also be concerned with abstracting the value that the property can have. For instance, a comment is approved in WordPress if its property comment_approved has the value "1". However, other CMSs may have a similar property with value true. Hence, the contract should remove any potential inconsistency or ambiguity:

interface CommentObjectPropertyResolverInterface {
  public function isApproved($comment);
}

Which is implemented for WordPress like this:

class WPCommentObjectPropertyResolver implements CommentObjectPropertyResolverInterface {
  public function isApproved($comment)
  {
    return $comment->comment_approved == "1";
  }
}

Global state

WordPress sets several variables in the global context, such as global $post when querying a single post. Keeping variables in the global context is considered an anti-pattern, since the developer could unintentionally override their values, producing bugs that are difficult to track down. Hence, abstracting our code gives us the chance to implement a better solution.

An approach we can take is to create a corresponding class AppState which simply contains a property to store all variables that our application will need. In addition to initializing all core variables, we enable components to initialize their own ones through hooks:

class AppState
{
  public static $vars = [];

  public static function getVars()
  {
    return self::$vars;
  }

  public static function initialize()
  {
    // Initialize core variables
    self::$vars['nature'] = $cmsRoutingService->getNature();
    self::$vars['route'] = $cmsRoutingService->getRoute();

    // Initialize $vars through hooks
    self::$vars = $hooksAPIService->applyFilters("AppState:init", self::$vars);

    return self::$vars;
  }
}

To replace global $posta hook from WordPress can then set this data through a hook. A first step would be to set the data under "post-id":

$hooksAPIService->addFilter(
  "AppState:init", 
  function($vars) {
    if (is_single()) {
      global $post;
      $vars['post-id'] => $post->ID;
    }
    return $vars;
  }
);

However, we can also abstract the global variables: instead of dealing with fixed entities (such as posts, users, comments, etc), we can deal with the entity in a generic way through "object-id"and we obtain its properties by inquiring the nature of the requested route:

$hooksAPIService->addFilter(
  "AppState:init", 
  function($vars) {
    if ($vars['nature'] == 'post') {
      global $post;
      $vars['object-id'] => $post->ID;
    }
    return $vars;
  }
);

From now own, if we need to display a property of the current post, we access it from the newly defined class instead of the global context:

$vars = AppState::getVars();
$object_id = $vars['object-id'];
// Do something with it
// ...

Entity models (meta, post types, pages being posts, and taxonomies —tags and categories—)

We must abstract those decisions made for WordPress concerning how its entities are modeled. Whenever we consider that WordPress’s opinionatedness makes sense in a generic context too, we can then replicate such a decision for our CMS-agnostic code.

Meta:

As mentioned earlier, the concept of “meta” must be decoupled from the model entity (such as “post meta” from “posts”), so if a CMS doesn’t provide support for meta, it can then discard only this functionality.

Then, package “Post Meta” (decoupled from, but dependent on, package “Posts”) defines the following contract:

interface PostMetaAPIInterface
{
  public function getMetaKey($meta_key);
  public function getPostMeta($post_id, $key, $single = false);
  public function deletePostMeta($post_id, $meta_key, $meta_value = '');
  public function addPostMeta($post_id, $meta_key, $meta_value, $unique = false);
  public function updatePostMeta($post_id, $meta_key, $meta_value);
}

Which is resolved for WordPress like this:

class WPPostMetaAPI implements PostMetaAPIInterface
{
  public function getMetaKey($meta_key)
  {
    return '_'.$meta_key;
  }
  public function getPostMeta($post_id, $key, $single = false)
  {
    return get_post_meta($post_id, $key, $single);
  }
  public function deletePostMeta($post_id, $meta_key, $meta_value = '')
  {
    return delete_post_meta($post_id, $meta_key, $meta_value);
  }
  public function addPostMeta($post_id, $meta_key, $meta_value, $unique = false)
  {
    return add_post_meta($post_id, $meta_key, $meta_value, $unique);
  }
  public function updatePostMeta($post_id, $meta_key, $meta_value)
  {
    return update_post_meta($post_id, $meta_key, $meta_value);
  }
}
Post types:

I have decided that WordPress’s concept of a custom post type, which allows to model entities (such as an event or a portfolio) as extensions of posts, can apply in a generic context, and as such, I have replicated this functionality in the CMS-agnostic code. This decision is controversial, however, I justify it because the application may need to display a feed of entries of different types (such as posts, events, etc) and custom post types make such implementation feasible. Without custom post types, I would expect the application to need to execute several queries to bring the data for every entity type, and the logic would get all muddled up (for instance, if fetching 12 entries, should we fetch 6 posts and 6 events? but what if the events were posted much earlier than the last 12 posts? and so on).

What happens when the CMS doesn’t support this concept? Well, nothing serious happens: a post will still indicate its custom post type to be a “post”, and no other entities will inherit from the post. The application will still work properly, just with some slight overhead from the unneeded code. This is a trade-off that, I believe, is more than worth it.

To support custom post types, we simply add a function getPostType in our contract:

interface PostAPIInterface
{
  public function getPostType($post_id);
}

Which is resolved for WordPress like this:

class WPPostAPI implements PostAPIInterface
{
  public function getPostType($post_id) {
    return get_post_type($post_id);
  }
}
Pages being posts:

While I justify keeping custom post types in order to extend posts, I don’t justify a page being a post, as it happens in WordPress, because in other CMSs these entities are completely decoupled and, more importantly, a page may have higher rank than a post, so making a page extend from a post would make no sense. For instance, October CMS ships pages in its core functionality, but posts must be installed through plugins.

Hence we must create separate contracts for posts and pages, even though they may contain the same functions:

interface PostAPIInterface
{
  public function getTitle($post_id);
}

interface PageAPIInterface
{
  public function getTitle($page_id);
}

To resolve these contracts for WordPress and avoid duplicating code, we can implement the common functionality through a trait:

trait WPCommonPostFunctions
{
  public function getTitle($post_id)
  {
    return get_the_title($post_id);
  }
}

class WPPostAPI implements PostAPIInterface
{
  use WPCommonPostFunctions;
}

class WPPageAPI implements PageAPIInterface
{
  use WPCommonPostFunctions;
}
Taxonomies (tags and categories):

Once again, we can’t expect all CMSs to support what is called taxonomies in WordPress: tags and categories. Hence, we must implement this functionality through a package “Taxonomies”, and, assuming that tags and categories are added to posts, make this package dependent on package “Posts”.

interface TaxonomyAPIInterface
{
  public function getPostCategories($post_id, $options = []);
  public function getPostTags($post_id, $options = []);
  public function getCategories($query, $options = []);
  public function getTags($query, $options = []);
  public function getCategory($cat_id);
  public function getTag($tag_id);
  ...
}

We could have decided to create two separate packages “Categories” and “Tags” instead of “Taxonomies”, however, as the implementation in WordPress makes evident, a tag and a category are basically the same concept of entity with only a tiny difference: categories are hierarchical (i.e. a category can have a parent category), but tags are not. Then, I consider that it makes sense to keep this concept for a generic context, and shipped under a single package “Taxonomies”.

We must pay attention that certain functionalities involve both posts and taxonomies, and these must be appropriately decoupled. For instance, in WordPress we can retrieve posts that were tagged "politics" by executing get_posts(['tag' => "politics"]). In this case, while function getPosts must be implemented in package “Posts”, filtering by tags must be implemented in package “Taxonomies”. To accomplish this separation, we can simply execute a hook in the implementation of function getPosts for WordPress, allowing any component to modify the arguments before executing get_posts:

class WPPostAPI implements PostAPIInterface
{
  public function getPosts($args) {
    $args = $hooksAPIService->applyFilters("modifyArgs", $args);
    return get_posts($args);
  }
}

And finally we implement the hook in package “Taxonomies for WordPress”:

$hooksAPIService->addFilter(
  'modifyArgs',
  function($args) {
    if (isset($args['tags'])) {
      $args['tag'] = implode(',', $args['tags']);
      unset($args['tags']);
    }
    if (isset($args['categories'])) {
      $args['cat'] = implode(',', $args['categories']);
      unset($args['categories']);
    }
    return $args;
  }
);

Please notice that in the abstracted code the attributes were re-defined (following the recommendations for abstracting function parameters, explained earlier on): "tag" must be provided as "tags" and "cat" must be provided as "categories" (shifting the connotation from singular to plural), and these values must be passed as arrays (i.e. removed accepting comma-separated strings as in WordPress, to add consistency).

Translation

Because calls to translate strings are spread all over the application code, translation is not a functionality that we can opt out from, and we should make sure that the other frameworks are compatible with our chosen translation mechanism.

In WordPress, which implements internationalization through gettextwe are required to set-up translation files for each locale code (such as ‘fr_FR’, which is the code for french language from FRance), and these can be set under a text domain (which allows themes or plugins to define their own translations without fear of collision with the translations from other pieces of code). We don’t need to check for support for placeholders in the string to translate (such as when doing sprintf(__("Welcome %s"), $user_name)), because function sprintf belongs to PHP and not to the CMS, so it will always work.

Let’s check if the other frameworks support the required two properties, i.e. getting the translation data for a specific locale composed of language and country, and under a specific text domain:

  • Symfony’s translation component supports these two properties.
  • The locale used in Laravel’s localization involves the language but not the country, and text domains are not supported (they could be replicated through overriding package language files, but the domain is not explicitly set, so the contract and the implementation would be inconsistent with each other).

However, luckily there is library Laravel Gettext which can replace Laravel’s native implementation with Symfony’s translation component. Hence, we got support for all frameworks, and we can rely on a WordPress-like solution.

We can then define our contract mirroring the WordPress function signatures:

interface TranslationAPIInterface
{
  public function __($text, $domain = 'default');
  public function _e($text, $domain = 'default');
}

The implementation of the contract for WordPress is like this:

class WPTranslationAPI implements TranslationAPIInterface
{
  public function __($text, $domain = 'default')
  {
    return __($text, $domain);
  }
  public function _e($text, $domain = 'default')
  {
    _e($text, $domain);
  }
}

And to use it in our application, we do:

$translationAPI = ContainerBuilderFactory::getInstance()->get('translation_api');
$text = $translationAPI->__("translate this", "my-domain");

Media

WordPress has media management as part of its core functionality, which represents a media element as an entity all by itself, and allows to manipulate the media element (such as cropping or resizing images), but we can’t expect all CMSs to have similar functionality. Hence, media management must be decoupled from the CMS core functionality.

For the corresponding contract, we can mirror the WordPress media functions, but removing WordPress’s opinionatedness. For instance, in WordPress, a media element is a post (with post type "attachment"), but for the CMS-agnostic code it is not, hence the parameter must be $media_id (or $image_id) instead of $post_id. Similarly, WordPress treats media as attachments to posts, but this doesn’t need to be the case everywhere, hence we can remove the word “attachment” from the function signatures. Finally, we can decide to keep the $size of the image in the contract; if the CMS doesn’t support creating multiple image sizes for an image, then it can just fall back on its default value NULL and nothing grave happens:

interface MediaAPIInterface
{
  public function getImageSrcAndDimensions($image_id, $size = null): array;
  public function getImageURL($image_id, $size = null): string;
}

The response by function getImageSrcAndDimensions can be asbtracted too, returning an array of our own design instead of simply re-using the one from the WordPress function wp_get_attachment_image_src:

class WPMediaAPI implements MediaAPIInterface
{
  public function getImageSrcAndDimensions($image_id, $size = null): array
  {
    $img_data = wp_get_attachment_image_src($image_id, $size);
    return [
      'src' => $img_data[0],
      'width' => $img_data[1],
      'height' => $img_data[2],
    ];
  }
  public function getImageURL($image_id, $size = null): string
  {
    return wp_get_attachment_image_url($image_id, $size);
  }
}

Conclusion

Setting-up a CMS-agnostic architecture for our application can be a painful endeavor. As it was demonstrated in this article, abstracting all the code was a lengthy process, taking plenty of time and energy to achieve, and it is not even finished yet. I wouldn’t be surprised if the reader is intimidated by the idea of going through this process in order to convert a WordPress application into a CMS-agnostic one. If I hadn’t done the abstraction myself, I would certainly be intimidated too.

My suggestion is for the reader is to analyze if going through this process makes sense based on a project-by-project basis. If there is no need whatsoever to port an application to a different CMS, then you will be right to stay away from this process and stick to the WordPress way. However, if you do need to migrate an application away from WordPress and want to reduce the effort required, or if you already need to maintain several codebases which would benefit from code reusability, or even if you may migrate the application sometime in the future and you have just started a new project, then this process is for you. It may be painful to implement, but well worth it. I know because I’ve been there. But I’ve survived, and I’d certainly do it again. Thanks for reading.

Smashing Editorial(dm, yk, il)



Source link

Revenir vers le haut