From e8a5d9e4e0fd0e05da47216b10ee4e6a92555008 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Mon, 4 May 2015 11:21:13 +0100
Subject: [PATCH] Issue #2479363 by Wim Leers, dawehner: Cache
 MenuActiveTrail::getActiveIds() for *all* menus per route match: 1 cache get
 instead of N DB queries, saves 1 ms/response

---
 core/core.services.yml                        |  6 +-
 core/lib/Drupal/Core/Menu/MenuActiveTrail.php | 57 +++++++++++++-
 core/lib/Drupal/Core/Menu/MenuLinkTree.php    | 72 ++++--------------
 .../src/Tests/Block/SystemMenuBlockTest.php   |  6 ++
 .../Tests/Core/Menu/MenuActiveTrailTest.php   | 76 ++++++++++++++++++-
 5 files changed, 151 insertions(+), 66 deletions(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index 5959c4470693..891d0b777254 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -487,13 +487,15 @@ services:
     arguments: ['@menu.tree_storage', '@menu_link.static.overrides', '@module_handler']
   menu.link_tree:
     class: Drupal\Core\Menu\MenuLinkTree
-    arguments: ['@menu.tree_storage', '@plugin.manager.menu.link', '@router.route_provider', '@menu.active_trail', '@controller_resolver', '@cache.menu', '@current_route_match']
+    arguments: ['@menu.tree_storage', '@plugin.manager.menu.link', '@router.route_provider', '@menu.active_trail', '@controller_resolver']
   menu.default_tree_manipulators:
     class: Drupal\Core\Menu\DefaultMenuLinkTreeManipulators
     arguments: ['@access_manager', '@current_user', '@entity.query']
   menu.active_trail:
     class: Drupal\Core\Menu\MenuActiveTrail
-    arguments: ['@plugin.manager.menu.link', '@current_route_match']
+    arguments: ['@plugin.manager.menu.link', '@current_route_match', '@cache.menu', '@lock']
+    tags:
+      - { name: needs_destruction }
   menu.parent_form_selector:
     class: Drupal\Core\Menu\MenuParentFormSelector
     arguments: ['@menu.link_tree', '@entity.manager', '@string_translation']
diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php
index 6247b29ee13f..2acb9723f8ca 100644
--- a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php
+++ b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php
@@ -7,6 +7,9 @@
 
 namespace Drupal\Core\Menu;
 
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\CacheCollector;
+use Drupal\Core\Lock\LockBackendInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
 
 /**
@@ -15,7 +18,7 @@
  * It uses the current route name and route parameters to compare with the ones
  * of the menu links.
  */
-class MenuActiveTrail implements MenuActiveTrailInterface {
+class MenuActiveTrail extends CacheCollector implements MenuActiveTrailInterface {
 
   /**
    * The menu link plugin manager.
@@ -38,16 +41,66 @@ class MenuActiveTrail implements MenuActiveTrailInterface {
    *   The menu link plugin manager.
    * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
    *   A route match object for finding the active link.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
+   *   The cache backend.
+   * @param \Drupal\Core\Lock\LockBackendInterface $lock
+   *   The lock backend.
    */
-  public function __construct(MenuLinkManagerInterface $menu_link_manager, RouteMatchInterface $route_match) {
+  public function __construct(MenuLinkManagerInterface $menu_link_manager, RouteMatchInterface $route_match, CacheBackendInterface $cache, LockBackendInterface $lock) {
+    parent::__construct(NULL, $cache, $lock);
     $this->menuLinkManager = $menu_link_manager;
     $this->routeMatch = $route_match;
   }
 
   /**
    * {@inheritdoc}
+   *
+   * @see ::getActiveTrailIds()
+   */
+  protected function getCid() {
+    if (!isset($this->cid)) {
+      $route_parameters = $this->routeMatch->getRawParameters()->all();
+      ksort($route_parameters);
+      return 'active-trail:route:' . $this->routeMatch->getRouteName() . ':route_parameters:' . serialize($route_parameters);
+    }
+
+    return $this->cid;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see ::getActiveTrailIds()
+   */
+  protected function resolveCacheMiss($menu_name) {
+    $this->storage[$menu_name] = $this->doGetActiveTrailIds($menu_name);
+    $this->tags[] = 'config:system.menu.' . $menu_name;
+    $this->persist($menu_name);
+
+    return $this->storage[$menu_name];
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This implementation caches all active trail IDs per route match for *all*
+   * menus whose active trails are calculated on that page. This ensures 1 cache
+   * get for all active trails per page load, rather than N.
+   *
+   * It uses the cache collector pattern to do this.
+   *
+   * @see ::get()
+   * @see \Drupal\Core\Cache\CacheCollectorInterface
+   * @see \Drupal\Core\Cache\CacheCollector
    */
   public function getActiveTrailIds($menu_name) {
+    return $this->get($menu_name);
+  }
+
+  /**
+   * Helper method for ::getActiveTrailIds().
+   */
+  protected function doGetActiveTrailIds($menu_name) {
     // Parent ids; used both as key and value to ensure uniqueness.
     // We always want all the top-level links with parent == ''.
     $active_trail = array('' => '');
diff --git a/core/lib/Drupal/Core/Menu/MenuLinkTree.php b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
index 40dfbbe54c95..44eed04456c5 100644
--- a/core/lib/Drupal/Core/Menu/MenuLinkTree.php
+++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php
@@ -47,30 +47,6 @@ class MenuLinkTree implements MenuLinkTreeInterface {
    */
   protected $controllerResolver;
 
-  /**
-   * The cache backend.
-   *
-   * @var \Drupal\Core\Cache\CacheBackendInterface
-   */
-  protected $cache;
-
-  /**
-   * The current route match.
-   *
-   * @var \Drupal\Core\Routing\RouteMatchInterface
-   */
-  protected $routeMatch;
-
-  /**
-   * Stores the cached current route parameters by menu and current route match.
-   *
-   * @todo Remove this non-static caching in
-   *   https://www.drupal.org/node/1805054.
-   *
-   * @var \Drupal\Core\Menu\MenuTreeParameters[]
-   */
-  protected $cachedCurrentRouteParameters;
-
   /**
    * Constructs a \Drupal\Core\Menu\MenuLinkTree object.
    *
@@ -84,53 +60,31 @@ class MenuLinkTree implements MenuLinkTreeInterface {
    *   The active menu trail service.
    * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver
    *   The controller resolver.
-   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
-   *   The cache backend.
-   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
-   *   The current route match.
    */
-  public function __construct(MenuTreeStorageInterface $tree_storage, MenuLinkManagerInterface $menu_link_manager, RouteProviderInterface $route_provider, MenuActiveTrailInterface $menu_active_trail, ControllerResolverInterface $controller_resolver, CacheBackendInterface $cache, RouteMatchInterface $route_match) {
+  public function __construct(MenuTreeStorageInterface $tree_storage, MenuLinkManagerInterface $menu_link_manager, RouteProviderInterface $route_provider, MenuActiveTrailInterface $menu_active_trail, ControllerResolverInterface $controller_resolver) {
     $this->treeStorage = $tree_storage;
     $this->menuLinkManager = $menu_link_manager;
     $this->routeProvider = $route_provider;
     $this->menuActiveTrail = $menu_active_trail;
     $this->controllerResolver = $controller_resolver;
-    // @todo Remove these two in https://www.drupal.org/node/1805054.
-    $this->cache = $cache;
-    $this->routeMatch = $route_match;
   }
 
   /**
    * {@inheritdoc}
    */
   public function getCurrentRouteMenuTreeParameters($menu_name) {
-    $route_parameters = $this->routeMatch->getRawParameters()->all();
-    ksort($route_parameters);
-    $cid = 'current-route-parameters:' . $menu_name . ':route:' . $this->routeMatch->getRouteName() . ':route_parameters:' . serialize($route_parameters);
-
-    if (!isset($this->cachedCurrentRouteParameters[$cid])) {
-      $cache = $this->cache->get($cid);
-      if ($cache && $cache->data) {
-        $parameters = $cache->data;
-      }
-      else {
-        $active_trail = $this->menuActiveTrail->getActiveTrailIds($menu_name);
-
-        $parameters = new MenuTreeParameters();
-        $parameters->setActiveTrail($active_trail)
-          // We want links in the active trail to be expanded.
-          ->addExpandedParents($active_trail)
-          // We marked the links in the active trail to be expanded, but we also
-          // want their descendants that have the "expanded" flag enabled to be
-          // expanded.
-          ->addExpandedParents($this->treeStorage->getExpanded($menu_name, $active_trail));
-
-        $this->cache->set($cid, $parameters, CacheBackendInterface::CACHE_PERMANENT, array('config:system.menu.' . $menu_name));
-      }
-      $this->cachedCurrentRouteParameters[$menu_name] = $parameters;
-    }
-
-    return $this->cachedCurrentRouteParameters[$menu_name];
+    $active_trail = $this->menuActiveTrail->getActiveTrailIds($menu_name);
+
+    $parameters = new MenuTreeParameters();
+    $parameters->setActiveTrail($active_trail)
+      // We want links in the active trail to be expanded.
+      ->addExpandedParents($active_trail)
+      // We marked the links in the active trail to be expanded, but we also
+      // want their descendants that have the "expanded" flag enabled to be
+      // expanded.
+      ->addExpandedParents($this->treeStorage->getExpanded($menu_name, $active_trail));
+
+    return $parameters;
   }
 
   /**
diff --git a/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php b/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php
index 0361eb781903..c053295d3f6d 100644
--- a/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php
+++ b/core/modules/system/src/Tests/Block/SystemMenuBlockTest.php
@@ -239,6 +239,12 @@ public function testConfigLevelDepth() {
     $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'example3');
     $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route);
     $this->container->get('request_stack')->push($request);
+    // \Drupal\Core\Menu\MenuActiveTrail uses the cache collector pattern, which
+    // includes static caching. Since this second scenario simulates a second
+    // request, we must also simulate it for the MenuActiveTrail service, by
+    // clearing the cache collector's static cache.
+    \Drupal::service('menu.active_trail')->clear();
+
     $active_trail_expectations = [];
     $active_trail_expectations['all'] = [
       'test.example1' => [],
diff --git a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php
index 2ea72ff6ace3..495d29806e32 100644
--- a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php
+++ b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php
@@ -11,6 +11,7 @@
 use Drupal\Core\Routing\CurrentRouteMatch;
 use Drupal\Tests\UnitTestCase;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\DependencyInjection\Container;
 use Symfony\Component\HttpFoundation\ParameterBag;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\RequestStack;
@@ -53,6 +54,20 @@ class MenuActiveTrailTest extends UnitTestCase {
    */
   protected $menuLinkManager;
 
+  /**
+   * The mocked cache backend.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cache;
+
+  /**
+   * The mocked lock.
+   *
+   * @var \Drupal\Core\Lock\LockBackendInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $lock;
+
   /**
    * {@inheritdoc}
    */
@@ -62,8 +77,14 @@ protected function setUp() {
     $this->requestStack = new RequestStack();
     $this->currentRouteMatch = new CurrentRouteMatch($this->requestStack);
     $this->menuLinkManager = $this->getMock('Drupal\Core\Menu\MenuLinkManagerInterface');
+    $this->cache = $this->getMock('\Drupal\Core\Cache\CacheBackendInterface');
+    $this->lock = $this->getMock('\Drupal\Core\Lock\LockBackendInterface');
+
+    $this->menuActiveTrail = new MenuActiveTrail($this->menuLinkManager, $this->currentRouteMatch, $this->cache, $this->lock);
 
-    $this->menuActiveTrail = new MenuActiveTrail($this->menuLinkManager, $this->currentRouteMatch);
+    $container = new Container();
+    $container->set('cache_tags.invalidator', $this->getMock('\Drupal\Core\Cache\CacheTagsInvalidatorInterface'));
+    \Drupal::setContainer($container);
   }
 
   /**
@@ -148,12 +169,14 @@ public function testGetActiveTrailIds(Request $request, $links, $menu_name, $exp
 
     $this->requestStack->push($request);
     if ($links !== FALSE) {
-      $this->menuLinkManager->expects($this->once())
+      // We expect exactly two calls, one for the first call, and one after the
+      // cache clearing below.
+      $this->menuLinkManager->expects($this->exactly(2))
         ->method('loadLinksbyRoute')
         ->with('baby_llama')
         ->will($this->returnValue($links));
       if ($expected_link !== NULL) {
-        $this->menuLinkManager->expects($this->once())
+        $this->menuLinkManager->expects($this->exactly(2))
           ->method('getParentIds')
           ->will($this->returnValueMap(array(
             array($expected_link->getPluginId(), $expected_trail_ids),
@@ -161,7 +184,54 @@ public function testGetActiveTrailIds(Request $request, $links, $menu_name, $exp
       }
     }
 
+    // Call out the same twice in order to ensure that static caching works.
+    $this->assertSame($expected_trail_ids, $this->menuActiveTrail->getActiveTrailIds($menu_name));
     $this->assertSame($expected_trail_ids, $this->menuActiveTrail->getActiveTrailIds($menu_name));
+
+    $this->menuActiveTrail->clear();
+    $this->assertSame($expected_trail_ids, $this->menuActiveTrail->getActiveTrailIds($menu_name));
+  }
+
+  /**
+   * Tests getCid()
+   *
+   * @covers ::getCid
+   */
+  public function testGetCid() {
+    $data = $this->provider()[1];
+    /** @var \Symfony\Component\HttpFoundation\Request $request */
+    $request = $data[0];
+    /** @var \Symfony\Component\Routing\Route $route */
+    $route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT);
+    $route->setPath('/test/{b}/{a}');
+    $request->attributes->get('_raw_variables')->add(['b' => 1, 'a' => 0]);
+    $this->requestStack->push($request);
+
+    $this->menuLinkManager->expects($this->any())
+      ->method('loadLinksbyRoute')
+      ->with('baby_llama')
+      ->will($this->returnValue($data[1]));
+
+    $expected_link = $data[3];
+    $expected_trail = $data[4];
+    $expected_trail_ids = array_combine($expected_trail, $expected_trail);
+
+    $this->menuLinkManager->expects($this->any())
+      ->method('getParentIds')
+      ->will($this->returnValueMap(array(
+        array($expected_link->getPluginId(), $expected_trail_ids),
+      )));
+
+    $this->assertSame($expected_trail_ids, $this->menuActiveTrail->getActiveTrailIds($data[2]));
+
+    $this->cache->expects($this->once())
+      ->method('set')
+      // Ensure we normalize the serialized data by sorting them.
+      ->with('active-trail:route:baby_llama:route_parameters:' . serialize(['a' => 0, 'b' => 1]));
+    $this->lock->expects($this->any())
+      ->method('acquire')
+      ->willReturn(TRUE);
+    $this->menuActiveTrail->destruct();
   }
 
 }
-- 
GitLab