Dieser Beitrag behandelt die Code-Refaktorierung im Zusammenhang mit dem CakePHP-Baumverhalten und den Slugs in Willow CMS. Ursprünglich hatten sowohl Artikel als auch Tags doppelten Code für Baumoperationen. Dieser wurde in OrderableBehavior umgestaltet, wodurch der Code wiederverwendbar und testbar wurde. Ebenso wurde die Slug-Verwaltung, die zuvor über mehrere Standorte verstreut war, in das neue SlugBehavior konsolidiert. Dieses Verhalten behandelt die Slug-Generierung, Validierung und den Verlauf, vereinfacht die Klassen ArticlesTable und TagsTable und fügt Funktionen wie die automatische Slug-Generierung aus Titeln, benutzerdefinierte Slugs und Slug-Verlaufsverfolgung für eine bessere SEO hinzu. Diese Umgestaltung reduzierte die Code-Duplikation erheblich und verbesserte die Wartbarkeit und Testbarkeit.
Refactoring von Modellen mithilfe des CakePHP-Baumverhaltens
Sowohl Artikel als auch Tags verwenden das CakePHP Tree Behaviour . Der Willow-Adminbereich ermöglicht die Neuanordnung von Seiten und Tags per Drag & Drop (wir werden uns später ansehen, wie der Controller- und Ansichtsebenencode dafür umgestaltet werden kann), was bedeutete, dass sowohlArticlesTable
UndTagsTable
Klassen hatten ähnliche Methoden zum Abrufen und Ändern hierarchischer Daten.
Sehen Sie sich die alte Quelle für die alte ArticlesTable (740 Zeilen inkl. Kommentare) und die alte TagsTable (430 Zeilen) an.
Beide hatten eine sehr ähnlichegetPageTree
Methode zum Abrufen eines Thread-Arrays von Daten, das für eine hierarchische Ansicht von Datensätzen verwendet wird.
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();
}
Beide hatten eine sehr ähnlichereorder
Methode zum Ändern der Hierarchie per Drag & Drop.
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;
}
Beide hatten Code zum Hinzufügen des Baumverhaltens, ohne das der obige Code nicht funktionieren kann.
$this->addBehavior('Tree');
Das sind etwa 80 Zeilen Code, die zwischen der ArticlesTable und der TagsTable dupliziert wurden. Der erste Schritt zur Umgestaltung dieses Codes sollte sein, ihn in ein CakePHP-Behavor zu verschieben und ihn mit jedem Modell funktionieren zu lassen, an das er angehängt ist. Also habe ich das OrderableBehavior erstellt.
Nun enthält keines der Modelle den oben genannten Code und es gibt einige nette Möglichkeiten, das Verhalten zu nutzen. Hier sind einige Beispiele aus einem Controller- Kontext.
// 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']
);
Das Verhalten bewirkt einige interessante Dinge, wie beispielsweise das automatische Hinzufügen des CakePHP-Baumverhaltens zum Modell iminitialize
Verfahren.
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);
}
}
Diese Umgestaltung des Codes in ein Verhalten hat das Testen der Neuordnungsfunktion wesentlich vereinfacht. Sehen Sie sich hier den spezifischen Testfall an, der für das Verhalten geschrieben wurde .
Refactoring von Slug-Einträgen zur Verwendung mit Artikeln und Tags
Ich habe einige grundlegende Arbeiten durchgeführt, um die Slug-Logik mit einem Verhalten an einem Ort zu halten. Sehen Sie sich die Quelle für das alte SluggableBehavior an. Es verwendete ein beforeSave
Rückruf, um eine grundlegende Slug-Generierungslogik auszulösen, mischte aber auch einige Einstellungen für Validierungsfehlermeldungen ein.
Das ist ein bisschen chaotisch, da im Rückruf das Generieren eines Slugs mit der Überprüfung vermischt wird, ob er eindeutig ist:
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;
}
Eine gute Idee war die Verwendung eines buildValidator
Rückruf zum Hinzufügen von Validierungsregeln für dieslug
Feld zum Modell. Das nicht so Gute war, dass ich auch Slug-Validierungscode imArticlesTable
, und es gab ein ziemlich großes Protokoll davon , das durch die Regeln validiert werden musste:
- Die Länge der Schnecke
- Dass der Slug in der Artikeltabelle eindeutig war (was für Tags und jedes andere Modell mit dem alten Verhalten wiederholt werden müsste)
- Dass der Slug in der Slug-Tabelle eindeutig war (wird als Slug-Verlauf verwendet, um gute 301-Weiterleitungen für SEO-Zwecke beizubehalten)
$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.'),
]);
Es gab auch Slug-bezogenen Code, der an anderer Stelle imArticlesTable
Klasse hier und mehr in der beforeSave
und die afterSave
Rückrufe.
Mehr Slug-Code imArticlesTable
beforeSave
Rückruf … uff … es geriet außer Kontrolle.
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
}
}
Darüber hinaus hatten beide ein paar Zeilen Code, um das Sluggable-Verhalten hinzuzufügen und die Beziehung zumslugs
Tisch:
$this->addBehavior('Sluggable', [
'field' => 'title',
'slug' => 'slug',
'maxLength' => 255,
]);
$this->hasMany('Slugs', [
'dependent' => true,
'cascadeCallbacks' => true,
]);
Dies erschwerte die Wartung und das Testen des Codes und musste daher in das neue SlugBehavior umgestaltet werden. Dieses Verhalten behandelt alles, was mit Slug zu tun hat (Generierung, Validierung und Verlaufsverfolgung), an einem Ort und kann problemlos an jedes Modell mit benutzerdefinierter Konfiguration für die Quell- und Zielfelder angehängt werden. Es gibt einige wirklich nette Verwendungsmöglichkeiten:
- Grundlegende Verwendung:
// In your Table class (e.g., ArticlesTable.php)
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Slug');
}
- Benutzerdefinierte Konfiguration:
// 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
]);
}
- Automatische Slug-Generierung:
// 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"
- Manuelle Slug-Überschreibung:
// 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"
- Verfolgung des Slug-Verlaufs:
// 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
- Zugriff auf den Slug-Verlauf:
// 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';
}
- Suchen von Inhalten anhand des Slugs:
// 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();
}
Diese Umgestaltung des Codes in ein Verhalten hat das Testen der Slug-Funktionalität erheblich vereinfacht. Sehen Sie sich hier den spezifischen Testfall an, der für das Verhalten geschrieben wurde .
Einpacken
Diese Refactoring-Runde (bei der Code größtenteils in einige Verhaltensweisen verschoben wird und dafür gesorgt wird, dass er für alle Modelle generisch funktioniert) hat Folgendes bewirkt:
- Reduzierte die Größe der
ArticlesTable
Klasse um 34 %, von 740 auf 488 Codezeilen (einschließlich Kommentare). - Reduzierte die Größe der
TagsTable
Klasse um 31 %, von 430 auf 295 Codezeilen (einschließlich Kommentare). - Bessere Slug-Funktionalität für Tags in Willow CMS hinzugefügt.
- Die Logik für beide Slug-Elemente und die Änderung der Baumhierarchie wurde wartungs- und testfreundlicher gestaltet.
Sie wissen, wie:
- CakePHP-Verhaltensweisen verbessern die Wiederverwendbarkeit von Code: Verhaltensweisen kapseln wiederverwendbare Logik, reduzieren Redundanz und fördern DRY-Prinzipien.
- Die Zentralisierung der Logik verbessert die Wartbarkeit: Durch die Konsolidierung verstreuten Codes in Verhaltensweisen haben wir einen einzigen Wartungspunkt für die Baumanordnung und Slug-Verwaltung geschaffen.
- Verhaltensweisen erleichtern das Testen: Der modulare Charakter von Verhaltensweisen vereinfacht das Testen einzelner Komponenten der Logik Ihrer Anwendung.
- Die konsistente Verwendung von Namenskonventionen und Rückrufen verbessert die Lesbarkeit des Codes: Die Befolgung der etablierten Konventionen von CakePHP gewährleistet eine konsistente und verständliche Codebasis.