Cet article couvre le refactoring du code lié au comportement de l'arbre CakePHP et aux slugs dans Willow CMS. À l'origine, les articles et les balises avaient du code dupliqué pour les opérations d'arborescence. Cela a été refactorisé dans OrderableBehavior, rendant le code réutilisable et testable. De même, la gestion des slugs, auparavant dispersée sur plusieurs emplacements, a été consolidée dans le nouveau SlugBehavior. Ce comportement gère la génération, la validation et l'historique des slugs, simplifiant les classes ArticlesTable et TagsTable tout en ajoutant des fonctionnalités telles que la génération automatique de slugs à partir des titres, les slugs personnalisés et le suivi de l'historique des slugs pour un meilleur référencement. Ce refactoring a considérablement réduit la duplication de code et amélioré la maintenabilité et la testabilité.
Refactorisation de modèles à l'aide du comportement d'arbre CakePHP
Les articles et les balises utilisent tous deux le comportement de l'arbre CakePHP . La zone d'administration de Willow permet de réorganiser les pages et les balises par glisser-déposer (nous verrons comment refactoriser le code du contrôleur et de la couche d'affichage pour cela à l'avenir), ce qui signifie que les deuxArticlesTable
etTagsTable
les classes avaient des méthodes similaires pour obtenir et modifier des données hiérarchiques.
Jetez un œil à l'ancienne source de l'ancien ArticlesTable (740 lignes, y compris les commentaires) et de l'ancien TagsTable (430 lignes) .
Les deux avaient un très similairegetPageTree
méthode pour obtenir un tableau threadé de données utilisé pour une vue hiérarchique des enregistrements.
public function getPageTree(array $additionalConditions = []): array
{
$conditions = [
'Articles.kind' => 'page',
];
// Merge the default conditions with any additional conditions provided
$conditions = array_merge($conditions, $additionalConditions);
$cacheKey = hash('xxh3', json_encode($conditions));
$query = $this->find()
->select([
'id',
'parent_id',
'title',
'slug',
'created',
'modified',
'is_published',
'pageview_count' => $this->PageViews->find()
->where(['PageViews.article_id = Articles.id'])
->select([
'count' => $this->PageViews->find()->func()->count('PageViews.id'),
]),
])
->where($conditions)
->orderBy(['lft' => 'ASC'])
->cache($cacheKey . 'article_page_tree', 'articles');
return $query->find('threaded')->toArray();
}
Les deux avaient un très similairereorder
méthode pour modifier la hiérarchie par glisser-déposer.
public function reorder(array $data): bool
{
if (!is_array($data)) {
throw new InvalidArgumentException('Data must be an array');
}
$article = $this->get($data['id']);
if ($data['newParentId'] === 'root') {
// Moving to root level
$article->parent_id = null;
$this->save($article);
} else {
// Moving to a new parent
$newParent = $this->get($data['newParentId']);
$article->parent_id = $newParent->id;
$this->save($article);
}
// Adjust the position within siblings
if ($article->parent_id === null) {
// For root level items
$siblings = $this->find()
->where(['parent_id IS' => null])
->orderBy(['lft' => 'ASC'])
->toArray();
} else {
// For non-root items
$siblings = $this->find('children', for: $article->parent_id, direct: true)
->orderBy(['lft' => 'ASC'])
->toArray();
}
$currentPosition = array_search($article->id, array_column($siblings, 'id'));
$newPosition = $data['newIndex'];
if ($currentPosition !== false && $currentPosition !== $newPosition) {
if ($newPosition > $currentPosition) {
$this->moveDown($article, $newPosition - $currentPosition);
} else {
$this->moveUp($article, $currentPosition - $newPosition);
}
}
return true;
}
Les deux avaient du code pour ajouter le comportement de l'arbre, sans lequel le code ci-dessus ne peut pas fonctionner.
$this->addBehavior('Tree');
Cela représente environ 80 lignes de code dupliquées entre ArticlesTable et TagsTable. La première étape pour refactoriser ce code doit être de le déplacer dans un comportement CakePHP et de le faire fonctionner avec n'importe quel modèle auquel il est attaché. J'ai donc créé OrderableBehavior .
Aucun des deux modèles ne contient le code ci-dessus et il existe plusieurs manières intéressantes d'utiliser ce comportement. Voici quelques exemples issus d'un contexte de contrôleur .
// Basic usage
$tree = $this->Articles->getTree();
// With additional conditions
$tree = $this->Articles->getTree(['is_published' => true]);
// With additional fields
$tree = $this->Articles->getTree([], ['created', 'modified', 'slug']);
// With both conditions and fields
$tree = $this->Articles->getTree(
['is_published' => true],
['created', 'modified', 'slug']
);
Le comportement fait certaines choses intéressantes, comme l'ajout automatique du comportement d'arbre CakePHP sur le modèle dans leinitialize
méthode.
public function initialize(array $config): void
{
parent::initialize($config);
// Add the Tree behavior if it's not already added
if (!$this->_table->hasBehavior('Tree')) {
$this->_table->addBehavior('Tree', $treeConfig);
}
}
Cette refactorisation du code en un comportement a rendu beaucoup plus facile le test de la fonctionnalité de réorganisation. Jetez un œil au cas de test spécifique écrit pour le comportement ici .
Refactorisation des slugs à utiliser avec les articles et les balises
J'ai fait quelques travaux de base pour garder la logique Slug à un seul endroit avec un comportement. Jetez un œil à la source de l'ancien SluggableBehavior . Il utilisait un beforeSave
rappel pour déclencher une logique de génération de slug de base, mais mélangeait également certains paramètres de message d'erreur de validation.
C'est un peu brouillon, mélanger la génération d'un slug avec la validation de son caractère unique, le tout dans le rappel :
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): ?bool
{
$config = $this->getConfig();
$field = $config['field'];
$slugField = $config['slug'];
if ($entity->isNew() && !$entity->get($slugField)) {
$sluggedTitle = strtolower(Text::slug($entity->get($field)));
// trim slug to maximum length defined in schema
$entity->set($slugField, substr($sluggedTitle, 0, $config['maxLength']));
//check generated slug is unique
$existing = $this->table()->find()->where([$slugField => $entity->get($slugField)])->first();
if ($existing) {
// If not unique, set the slug back to the entity for user modification
$entity->setError($slugField, 'The generated slug is not unique. Please modify it.');
return false; // Prevent save
}
}
return true;
}
Une bonne idée était d'utiliser un buildValidator
rappel pour ajouter des règles de validation pour leslug
champ au modèle. Le moins bien, c'est que j'avais aussi un code de validation de slug dans leArticlesTable
, et il y avait tout un journal de règles à valider :
- La longueur de la limace
- Que le slug était unique dans la table Articles (ce qui devrait être répété pour les balises et tout autre modèle utilisant l'ancien comportement)
- Que le slug était unique dans la table Slugs (utilisé comme historique de slug pour maintenir de belles redirections 301 à des fins de référencement)
$validator
->scalar('slug')
->maxLength('slug', 255) // Checking the length of the slug
->regex(
'slug',
'/^[a-z0-9-]+$/',
__('The slug must be URL-safe (only lowercase letters, numbers, and hyphens)')
)
->requirePresence('slug', 'create')
->notEmptyString('slug')
->add('slug', 'uniqueInArticles', [ // adding rules to make sure slug is unique in the Articles table (would need to be repeated in TagsTable model too)
'rule' => function ($value, $context) {
$exists = $this->exists(['slug' => $value]);
if ($exists && isset($context['data']['id'])) {
$exists = $this->exists(['slug' => $value, 'id !=' => $context['data']['id']]);
}
return !$exists;
},
'message' => __('This slug is already in use in articles. Please enter a unique slug.'),
])
->add('slug', 'uniqueInSlugs', [ // adding rules to check slug is unique in slugs table (repeated for other models too)
'rule' => function ($value, $context) {
$slugsTable = TableRegistry::getTableLocator()->get('Slugs');
$exists = $slugsTable->exists(['slug' => $value]);
if ($exists && isset($context['data']['id'])) {
$exists = $slugsTable->exists(['slug' => $value, 'article_id !=' => $context['data']['id']]);
}
return !$exists;
},
'message' => __('Slug conflicts with an existing SEO redirect. Please choose a different slug.'),
]);
Il y avait également du code lié aux limaces répandu ailleurs dans leArticlesTable
classe ici et plus dans le beforeSave
et le afterSave
rappels.
Plus de code slug dans leArticlesTable
beforeSave
rappel… euh… ça devenait incontrôlable.
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): ?bool
{
if ($entity->isNew() && !$entity->slug) {
$sluggedTitle = strtolower(Text::slug($entity->title));
// trim slug to maximum length defined in schema
$entity->slug = substr($sluggedTitle, 0, 255);
//check generated slug is unique
$existing = $this->find('all', conditions: ['slug' => $entity->slug])->first();
if ($existing) {
// If not unique, set the slug back to the entity for user modification
$entity->setError('slug', 'The generated slug is not unique. Please modify it.');
return false; // Prevent save
}
}
En plus de cela, les deux avaient quelques lignes de code pour ajouter le comportement Sluggable et définir la relation avec leslugs
tableau:
$this->addBehavior('Sluggable', [
'field' => 'title',
'slug' => 'slug',
'maxLength' => 255,
]);
$this->hasMany('Slugs', [
'dependent' => true,
'cascadeCallbacks' => true,
]);
Cela rendait la maintenance et le test du code assez difficiles, il fallait donc le refactoriser dans le nouveau SlugBehavior . Ce comportement gère tout ce qui concerne les slugs (génération, validation et suivi de l'historique) en un seul endroit et peut être facilement attaché à n'importe quel modèle avec une configuration personnalisée pour les champs source et destination. Il existe de très bonnes façons de l'utiliser :
- Utilisation de base :
// In your Table class (e.g., ArticlesTable.php)
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Slug');
}
- Configuration personnalisée :
// Customize source field, target field, and maximum length
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Slug', [
'sourceField' => 'name', // Generate slug from 'name' instead of 'title'
'targetField' => 'url_path', // Store slug in 'url_path' instead of 'slug'
'maxLength' => 100, // Limit slug length to 100 characters
]);
}
- Génération automatique de slugs :
// The behavior automatically generates slugs
$article = $articlesTable->newEntity([
'title' => 'My First Blog Post!',
]);
$articlesTable->save($article);
echo $article->slug; // Outputs: "my-first-blog-post"
// Works with special characters
$article = $articlesTable->newEntity([
'title' => 'Café & Résumé 2024',
]);
$articlesTable->save($article);
echo $article->slug; // Outputs: "cafe-resume-2024"
- Remplacement manuel des slugs :
// You can provide your own slug
$article = $articlesTable->newEntity([
'title' => 'My Article',
'slug' => 'custom-url-path',
]);
$articlesTable->save($article);
echo $article->slug; // Outputs: "custom-url-path"
- Suivi de l'historique des slugs :
// Original article
$article = $articlesTable->newEntity([
'title' => 'Original Title',
]);
$articlesTable->save($article);
// slug: "original-title"
// Update the title
$article->title = 'New Updated Title';
$article->slug = ''; // Clear the old slug
$articlesTable->save($article);
// slug: "new-updated-title"
// Both slugs are now in the slugs table
// You can find the article using either slug in
// the front end of Willow CMS with old slugs
// giving a 301 redirect to the latest slug
- Accéder à l’historique des slugs :
// Get all historical slugs for an article
$article = $articlesTable->get($id, [
'contain' => ['Slugs'],
]);
foreach ($article->slugs as $slug) {
echo $slug->slug . ' (created: ' . $slug->created->format('Y-m-d') . ')\n';
}
- Recherche de contenu par slug :
// Find an article by its current or historical slug
public function findBySlug($slug)
{
return $this->find()
->where(['slug' => $slug])
->orMatchingInBatches('Slugs', function ($q) use ($slug) {
return $q->where(['Slugs.slug' => $slug]);
})
->first();
}
Cette refactorisation du code en un comportement a rendu beaucoup plus facile le test de la fonctionnalité slug. Jetez un œil au cas de test spécifique écrit pour le comportement ici .
Conclure
Cette série de refactorisations (qui consiste principalement à déplacer le code vers quelques comportements et à le faire fonctionner de manière générique pour n'importe quel modèle) a :
- Réduit la taille de la
ArticlesTable
classe de 34%, de 740 à 488 lignes de code (commentaires compris). - Réduit la taille de la
TagsTable
classe de 31%, de 430 à 295 lignes de code (commentaires compris). - Ajout d'une meilleure fonctionnalité de slugs pour les balises dans Willow CMS.
- La logique des deux slugs et la modification de la hiérarchie de l'arborescence sont désormais plus faciles à maintenir et à tester.
Vous savez maintenant comment :
- Les comportements CakePHP améliorent la réutilisabilité du code : les comportements encapsulent la logique réutilisable, réduisant la redondance et favorisant les principes DRY.
- La centralisation de la logique améliore la maintenabilité : en consolidant le code dispersé en comportements, nous avons créé un point de maintenance unique pour l'ordre des arborescences et la gestion des slugs.
- Les comportements facilitent les tests : la nature modulaire des comportements rend le test des composants individuels de la logique de votre application plus simple.
- L'utilisation cohérente des conventions de nommage et des rappels améliore la lisibilité du code : le respect des conventions établies de CakePHP garantit une base de code cohérente et compréhensible.