Drupal: Cache aktualisieren bei neuen URL-Aliasen

Das Problem

In Drupal gibt es zwei Wege, einen URL-Alias anzulegen: Zumindest für Nodes kann man das beim Bearbeiten des Nodes tun. Speichert man den Node, wird auch der URL-Alias angelegt bzw. geändert. Man kann aber auch über Konfiguration > Suche und Metadaten > URL-Aliase gehen und den Alias dort direkt anlegen.

Bei letzterem Weg machten uns Kunden darauf aufmerksam, dass sie zwar einen Alias angelegt hatten, dass sich aber der Link auf der Seite nicht geändert hatte. Erst ein Leeren des Drupal-Caches führte dazu, dass der neue Alias auch verwendet wurde. Das kann man Kunden so natürlich nicht vermitteln und kann man eigentlich nur als Bug einstufen.

Die Erklärung

Wenn man als Gast eine Seite aufruft, werden sowohl die einzelnen Blöcke als auch die Seite als ganzes gecacht (je nachdem welche Cache-Module man aktiviert hat natürlich). Das beschleunigt den nächsten Seitenaufruf enorm. Drupal merkt sich dabei, von welchen Inhalten gecachte Schnipsel abhängen. Baut man also eine Seite, welche z.B. den Teaser für den Node mit der ID 42 enthält, dann ist an dem Cache-Eintrag der Cache-Tag „node:42″ vermerkt.

Speichert man diesen Node, wird alles was mit diesem Cache-Tag versehen ist, aus dem Cache gelöscht und beim nächsten Aufruf neu gebaut. Das wäre der erste Weg, einen Alias zu setzen: Weil gleichzeitig der Node gespeichert wird, wird dessen Cache-Tag invalidiert und die Seite mit dem Teaser wird beim nächsten Aufruf neu erzeugt.

Für den Fall, dass ein URL-Alias noch nicht existiert, hängt der gecachte Teaser von etwas ab, dass noch gar nicht da ist und deswegen keinen Cache-Tag hat. Man müsste eigentlich etwas wie „node:42:path_alias“ als Cache-Tag verwenden, immer sobald die URL des Nodes irgendwo verwendet wird. Und selbst wenn ein Alias existiert und nur geändert wird, fügt Drupal scheinbar einen Cache-Tag wie „path_alias:13″ nicht als Cache-Tag ein. Änderungen daran werden erst nach einem Leeren des Caches sichtbar. Auch das erscheint mir wie ein Bug.

Das Problem ist im Issue-Tracker von Drupal bekannt, das Issue #2480077 wurde vor fast 10 Jahren angelegt. Getan hat sich seitdem aber nichts nennenswertes.

Die Lösung

So schwer kann das ja nicht sein, dachte ich mir, und habe eine kleine Hook-Lösung gebaut. Diese Lösung ist nicht perfekt, aber sollte die meisten Fälle abdecken:

  • Die neue Methode leert den betroffenen Cache-Tag beim Anlegen, Ändern oder Löschen eines URL-Alias.
  • Das funktioniert für die kanonischen URLs wie „/node/42″. Es funktioniert nicht für andere URLs zu dem Node, wie „/node/42/edit“, muss es aber ja auch nicht für das ursprüngliche Problem. Die ganzen anderen, nicht-kanonischen URLs sind eigentlich nie Teil einer gecachten Seite.
  • Es funktioniert auch nicht, wenn an einen Alias auf einen anderen Alias verweisen lässt.
  • Es funktioniert zudem nicht, wenn man einen Alias bearbeitet und den Quellpfad anpasst, also aus „/node/42″ z.B. ein „/node/47″ macht. Der Hook kriegt nur den gespeicherten Zustand zu sehen und würde dann den Cache für Node 47 invalidieren, nicht aber für Node 42.

Man braucht dafür drei Hooks, welche jeweils auf die Fälle Anlegen, Bearbeiten und Löschen eines URL-Alias reagieren. Zu platzieren in einem eigenen Modul eurer Wahl:

/**
 * Implements hook_entity_insert().
 */
function my_helpers_entity_insert(EntityInterface $entity): void {
  if ($entity instanceof PathAliasInterface) {
    PathHelper::invalidateCacheForPath($entity->getPath());
  }
}

/**
 * Implements hook_entity_update().
 */
function my_helpers_entity_update(EntityInterface $entity): void {
  if ($entity instanceof PathAliasInterface) {
    PathHelper::invalidateCacheForPath($entity->getPath());
  }
}

/**
 * Implements hook_entity_delete().
 */
function my_helpers_entity_delete(EntityInterface $entity): void {
  if ($entity instanceof PathAliasInterface) {
    PathHelper::invalidateCacheForPath($entity->getPath());
  }
}

Dazu natürlich die Use-Statements für die Klassen „\Drupal\Core\Entity\EntityInterface“ und „\Drupal\path_alias\PathAliasInterface“ oben einfügen.

Die eigentliche Arbeit ist in allen drei Fällen gleich und deswegen in eine weitere Methode ausgelagert. In meinem Fall hatte ich schon einen PathHelper, an den die Methode gut gepasst hat:

  /**
   * Invalidates the cache for the entity the given path points to.
   *
   * This is called when a URL alias is inserted, updated, or deleted. Chances
   * are that this alias has already been used on a cached page. This cached
   * snippet will be removed from the cache when the right cache tag is
   * invalidated.
   *
   * The method tries to guess from the path which entity it points to and then
   * invalidates this entity's primary cache tag. For a path like '/node/42' it
   * will invalidate the cache tag 'node:42'. This won't work for more
   * complicated URLs like '/node/42/edit', but it doesn't have to. The URL
   * alias use case only concerns the canonical address of the entity.
   *
   * Caveats:
   *  - This approach does not work for the case when an existing alias is
   *    changed and a new path is now aliased.
   *
   * @param string $path
   *   The path for which a URL alias was changed, e.g. '/node/42'.
   *
   * @see https://www.drupal.org/project/drupal/issues/2480077
   */
  public static function invalidateCacheForPath(string $path): void {
    // Is a path given?
    $path = rtrim(strtolower(trim($path)), '/');
    if (empty($path)) {
      return;
    }

    // Try to guess the correct cache tag.
    $cacheTag = '';
    $matches = [];
    if (preg_match('@^/([a-z_-]+)/(\d+)$@', $path, $matches) && count($matches) === 3) {
      // Handle the default case of a URL like '/entity_type/id'.
      $cacheTag = "{$matches[1]}:{$matches[2]}";
    }
    elseif (preg_match('@^/group/\d+/content/(\d+)$@', $path, $matches) && count($matches) === 2) {
      // Handle the special case of group content.
      $cacheTag = "group_content:{$matches[1]}";
    }

    // Invalidate this cache tag.
    if ($cacheTag) {
      \Drupal::service('cache_tags.invalidator')->invalidateTags([$cacheTag]);
    }
  }

Neben dem Normalfall von „/node/42″ habe ich noch einen Spezialfall für das Group-Modul eingebaut. Falls ihr das nicht benutzt, braucht ihr den zweiten if-Zweig nicht. Hier werden URLs wie „/group/5/content/17″ mit einem Alias versehen, der Cache-Tag dazu ist „group_content:17″.

Es gibt bestimmt weitere Sonderfälle, vielleicht Entities bei denen die kanonische URL nicht mit dem Cache-Tag übereinstimmt. Man könnte natürlich aus dem Pfad auch versuchen, die Entity zu laden und sie direkt nach ihren Cache-Tags fragen. Das hatte ich erwogen, habe mich dann aber dagegen entschieden. Das erzeugt nur wieder Overhead mit noch mehr Datenbank-Queries, und ich war vor allem nicht sicher, ob das dann auch läuft, wenn man URL-Aliase per Muster massenhaft generiert (Pathauto-Modul). Da kam mir diese einfachere Lösung besser vor, und wenn man mal einen Cache-Tag, den es gar nicht gibt, zu invalidieren versucht, schadet das ja auch nicht.

Für meine Fälle reicht die oben skizzierte Lösung jedenfalls völlig aus und macht den Kunden hoffentlich das Leben etwas leichter. Dass in der Drupal-Community es zehn Jahre lang niemand schafft, wenigstens so einen einfachen Ansatz zu implementieren, ist schon traurig.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Bitte beachte die Kommentarregeln: 1) Kein Spam, und bitte höflich bleiben. 2) Ins Namensfeld gehört ein Name. Gerne ein Pseudonym, aber bitte keine Keywords. 3) Keine kommerziellen Links, außer es hat Bezug zum Beitrag. mehr Details...

So, noch mal kurz drüber schauen und dann nichts wie ab damit. Vielen Dank fürs Kommentieren! :-)