Problem/Motivation

Over at #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees, we're working to make rendered menu trees render cacheable. That patch is green and leads to a very significant performance improvement on all Drupal pages: ~30 ms on every page load with stock Drupal 8 Standard profile, on a system where pages take 200 to 240 milliseconds to be generated. A 10–20% improvement on all pages.

Now, to achieve that, we have to apply advanced caching techniques. But unfortunately, it's impossible to do without refactoring D8 HEAD's MenuTree. The majority of the code in that file is "legacy" code, that is nigh impossible to understand, utterly impossible to provide full test coverage for, plus it interacts with/depends on procedural code that lives in menu.inc to top it off. It contains a mishmash of loosely related logic.

Proposed resolution

That necessary refactoring represents by far the majority of the green patch at #1805054-118: Cache localized, access filtered, URL resolved, and rendered menu trees. At #122 in that issue, we decided to split off that majority of the work, which does not add any improved cacheability, into a new issue: this issue.

Finally: this issue will also help ensure #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module doesn't introduce new regressions, because this adds full test coverage for everything related to transforming the menu links in a menu into a tree (including the things that currently still live in menu.inc for legacy reasons).

This also:

Remaining tasks

Get to RTBC & commit.

User interface changes

None.

API changes

  • MenuTree(Interface) is now much more focused: it's only purposes are to build a menu tree and render it.
  • For a large part, this is thanks to splitting the convoluted-and-impossible-to-grok logic in MenuTree::buildAllData(), MenuTree::buildPageData() and MenuTree::buildTreeData() into smaller pieces that are reusable, rather than the same logic being repeated in multiple places with subtle differences. The logic responsible for determining the query to retrieve a menu's menu links according to certain parameters now lives in a new MenuTreeParameters object
  • The access checking, translating, active trail-expanding etc. logic no longer lives partially in menu.inc and partially in MenuTree (partially in those); it now all lives in MenuDefaultTreeManipulators
  • The active trail handling is unchanged, but now lives in MenuActiveTrail(Interface)
  • A MenuTreeItem value object has been added, to make the resulting menu tree (of MenuTreeInterface::build()) more well-defined/formalized and hence more reliable/less brittle. A menu tree now consists of menu tree items, just like before, but instead of it being a particularly structured array, it's now a tree of MenuTreeItems. Menu tree manipulators receive and manipulate these.
  • Consistent tree building: All menu trees across Drupal core, including the tree of menu links you see when editing menu links, or the options in the <select> for selecting a parent item, are now built through one and the same mechanism; hence they all benefit from the test coverage. I.e. this patch removes all the special snowflake rendering in the "admin-esque" use cases, all of which duplicated parts of the logic elsewhere, but every time in subtly different ways. This allows us to feel more confident about the admin parts; they're not fragile anymore.
  • Consistent tree rendering: Related to the above, all of the #type = links ()-based rendering is gone as well (this was used for the primary and secondary menus): they're just rendered as single-level trees now. So all rendered menus now are rendered using #type = menu_tree, which removes another point of fragility. The markup structure remains identical.

I've also added a new section to the Menu API documentation in menu.inc, for the very high-level explanation:

 * @section Rendering menus
 * Once you have created menus (that contain menu links), you want to render
 * them. Drupal provides a block (Drupal\system\Plugin\Block\SystemMenuBlock) to
 * do so.
 *
 * However, perhaps you have more advanced needs and you're not satisfied with
 * what the menu blocks offer you. If that's the case, you'll want to:
 * - Instantiate \Drupal\menu_link\MenuTreeParameters, and set its values to
 *   match your needs.
 * - Potentially write a custom menu tree manipulator, see
 *   \Drupal\menu_link\DefaultMenuTreeManipulators for examples.
 * - Call \Drupal\menu_link\MenuTree::build() with your menu tree parameters,
 *   this will return a menu tree.
 * - Pass the menu tree to \Drupal\menu_link\MenuTree::render(), this will
 *   return a renderable array.

Then, still at a high level, but with considerable more detail, the updated docblock for MenuTreeInterface

/**
 * Defines an interface for building and rendering trees of menu links.
 *
 * The main goal of this service is to, given a menu name, build (::build()) the
 * corresponding tree of menu links.
 * Building the menu consists of two phases. First: loading the menu links in
 * the given menu. Second: transforming this list of menu links into a tree (by
 * looking at their tree metadata) and applying tree manipulators. Tree
 * manipulators can perform simple tasks like translating the title of all menu
 * links in a tree, or more complex tasks like access checking (which will
 * remove inaccessible menu links), or even very complex tasks like extracting a
 * subtree from the tree according depending on the active trail.
 * Which links are loaded and which tree manipulators are applied can be
 * specified in the MenuTreeParameters you pass to ::build().
 *
 * If desired, that tree of menu links can then be rendered (::render()).
 *
 *
 * @section rendered_menu_tree_cacheability Rendered menu tree cacheability
 *
 * Unfortunately, because Drupal only wants to show menu links to a user that
 * are accessible for that user, we have a performance problem. A menu consists
 * of menu links arranged in a tree. Each menu link points to a route (or an
 * external URL). Each route can have any number of access checks defined. And,
 * finally, those access checks can be of arbitrary complexity, and therefore of
 * arbitrary cacheability. Some may be globally cacheable, others per role, yet
 * others per user, per page, per language, and whatnot. Some may not even be
 * cacheable at all. Caching per user, for example, is usually not an option:
 * the cache hit ratio would be too low on a typical Drupal site.
 *
 * Access checking is performed by menu tree manipulators. The simplest possible
 * manipulator will just apply access checks for the given user, and end up with
 * the correct result. Unfortunately, the resulting tree will only be cacheable
 * per user. To allow for more cacheable access checking, ::render() allows each
 * link to have a 'needs_dynamic_access_check' property. Those menu links will
 * be rendered into render cache placeholders. See ::render() for details.
 *
 * @see \Drupal\menu_link\DefaultMenuTreeManipulators
 */
interface MenuTreeInterface {}
CommentFileSizeAuthor
#2 menutree_refactor-2289033-2.patch217.48 KBwim leers

Comments

wim leers’s picture

Issue summary: View changes
wim leers’s picture

Status: Active » Needs review
Issue tags: +Spark, +sprint
StatusFileSize
new217.48 KB
wim leers’s picture

msonnabaum reviewed this over at #1805054-121: Cache localized, access filtered, URL resolved, and rendered menu trees:

3. It took me a while to get the whole menu tree manipulator concept, but it still looks considerably simpler than what was there before. A few things threw me, but I think they are fixable.

$parameters->appendManipulator('menu_link.cacheable_access_check_tree_manipulator:checkNonDynamicAccess')
    ->appendManipulator('menu_link.default_tree_manipulators:translate')
    ->appendManipulator('menu_link.default_tree_manipulators:generateIndexAndSort')
    ->appendManipulator('menu_link.default_tree_manipulators:setHrefPropertyIfExternal')
    ->appendManipulator('menu_link.default_tree_manipulators:setTreeItemClass');

Using the servicename:method notation with two different services makes me think that this is an extensible system, where contrib could define new services to be added to menus in core. I don't think this is the case, which makes sense, but I think we can suggest it more in code.

This is indeed an extensible system; for example, the toolbar module has its own manipulator: toolbar_menu_navigation_links(). Similarly, contirb modules may want to apply their own tree manipulators.

You're right that existing rendered menus (e.g. the one in the menu administration area) cannot be manipulated by default, but that could be changed. And in the case of for example the menu block (SystemMenuBlock), it's possible for a contrib module to override that default implementation with a different one that applies different/additional menu tree manipulators.

I hope that clarified it for you.

wim leers’s picture

Issue summary: View changes
pwolanin’s picture

This seems like it's hugely overlapping or even conflicting with: #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module

Can we combine them rather than doing the work twice?

This patch seems to be retaining a lot of code we could omit like:

$this->moduleHandler->alter('translated_menu_link', $item);

And the whole "translated" terminology is rather misleading and should be dropped.

pwolanin’s picture

There is too much retention of old code and concepts that need to be removed, it doesn't make sense to do this in isolation.

effulgentsia’s picture

If pwolanin and Wim are able to jointly merge this into #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module in a way that satisfies both of them, and then close this issue as a duplicate, I'd be fine with that. Other than the work needed to do the merge, I think that issue is close to ready, so #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees wouldn't be held up for too long waiting for it. Since the merge needs to happen either way, I don't see much point in committing this to HEAD and then merging, vs. merging and then committing.

We need feedback from Wim though before proceeding.

wim leers’s picture

Status: Needs review » Postponed
Issue tags: -sprint

pwolanin and I discussed in detail (3.5 hour long chat detail) how to move this forward and combine with #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module.

Even though pwolanin set out to refactor just Menu Link entities/storage, and I set out to just add render caching of menu trees (in #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees), and those logically have close to zero overlap, the sad reality is that the menu system in HEAD is in a poor state. Due to the tight coupling in MenuTree, where lots of things live together that don't belong together, and worse yet, MenuTree calling out to procedural functions in menu.inc, the unfortunate consequence is that we have both cleaned up in different ways, with different goals in mind.

So, alas, even though we both had best intentions, we're now left to somehow reconcile both patches.

After careful deliberation, we've agreed to move forward by:

  1. Closing #2289033: Refactor MenuTree to be simpler and have full unit test coverage (this issue) as a duplicate.
  2. "Upgrading" #2256521: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module to incorporate the logic of that closed issue.
  3. That will then still allow the main "render tree caching" logic over at #1805054-123: Cache localized, access filtered, URL resolved, and rendered menu trees to work, and apply relatively cleanly. Conceptually, this patch is "just" merged with that in #2256521.

We both began the discussion sad and frustrated, and came out hopeful — particularly because we should be able to move faster by combining our efforts on the same patch :) Particularly, we both want this mammoth, long overdue work to be behind us :P

The plan

In short: the following will be moved into pwolanin's patch from mine: MenuTreeItem, MenuTreeParameters, the menu tree manipulators concept, DefaultMenuTreeManipulators, MenuTree::render() (which brings all rendered menus to a single template: menu-tree.html.twig), the use of MenuTreeParameters everywhere and all added unit test coverage.

The rendering process becomes:

  1. $tree = build($menu_name, $parameters) or buildSubtree($menu_name, $parent)
  2. $tree = transform($tree, $manipulators)
  3. $rendered = render($tree)

The plan: detail

pwolanin and I have also discussed how to do this exactly. Key to this all is that we agree on MenuTreeInterface (pwolanin renamed this to MenuLinkTree), so that's what we discussed in detail, and where we've agreed on how to combine everything.

In pwolanin's latest patch, over at #2256521-71: [META] New plan, Phase 2: Implement menu links as plugins, including static admin links and views, and custom links with menu_link_content entity, all managed via menu_ui module, it looks like this:

<?php

interface MenuLinkTreeInterface {

  public function maxDepth();
  public function buildRenderTree($tree);
  public function getActiveTrailIds($menu_name);
  public function getActiveMenuNames();
  public function setActiveMenuNames(array $menu_names);
  public function buildPageData($menu_name, $max_depth = NULL);
  public function buildAllData($menu_name, $id = NULL, $max_depth = NULL);
  public function buildTree($menu_name, array $parameters = array());
  public function buildSubtree($id, $max_relative_depth = NULL);
  public function getChildLinks($id, $max_relative_depth = NULL);
  public function menuLinkGetPreferred($route_name = NULL, array $route_parameters = array(), $selected_menu = NULL);
  public function getParentSelectOptions($id = '', array $menus = NULL);
  public function parentSelectElement($menu_parent, $id = '', array $menus = NULL);
  public function getMenuOptions(array $menu_names = NULL);
  public function getParentDepthLimit($id);
  public function resetStaticCache();

}

And we're going to refactor that to this, where "commented = planned to be deleted":

<?php

interface MenuLinkTreeInterface {

  public function maxDepth();
  //public function buildRenderTree($tree);
  public function render(MenuTreeItem[] $tree);
  //public function buildPageData($menu_name, $max_depth = NULL);
  //public function buildAllData($menu_name, $id = NULL, $max_depth = NULL);
  //public function buildTree($menu_name, array $parameters = array());
  public function build($menu_name, MenuTreeParameters $parameters);
  public function transform(MenuTreeItem[] $tree, array $manipulators);
  public function buildSubtree($id, $max_relative_depth = NULL);
  // @todo Reconsider later, after the above changes have been made.
  public function getChildLinks($id, $max_relative_depth = NULL);
  //public function resetStaticCache();

}

interface MenuActiveTrailInterface {
  
  public function getActiveTrailIds($menu_name);
  public function getActiveMenuNames();
  public function setActiveMenuNames(array $menu_names);
  public function menuLinkGetPreferred($route_name = NULL, array $route_parameters = array(), $selected_menu = NULL);
  
}

interface MenuParentFormSelectorInterface {

  //public function getParentSelectOptions($id = '', array $menus = NULL);
  public function parentSelectElement($menu_parent, $id = '', array $menus = NULL);
  //public function getMenuOptions(array $menu_names = NULL);
  //public function getParentDepthLimit($id);
  
}

Those changes amount to:

  1. Introducing a value object to represent nodes (in the CS sense, not the Drupal sense) in the menu tree; I called them MenuTreeItem, on par with existing terminology in HEAD. The name and exact properties may change, but the concept is needed.
  2. Making MenuLinkTree as focused as MenuTree in my patch. Notes:
    1. The MenuLinkTree::maxDepth() method is the successor for the MENU_MAX_DEPTH constant. We'd both like to get rid of it, but can't think of a better place for now. It's minor enough that it doesn't matter in the grand scheme of things, so barring a sudden insight, this is acceptable.
    2. Just like in my patch, buildPageData(), buildAllData() and buildTree() are removed in favor of a single build() method, which accepts parameters.
    3. We introduce the "tree manipulators" concept from my patch. DefaultMenuTreeManipulators can almost be copy/pasted verbatim from my patch, including test coverage.
    4. We introduce a MenuTreeParameters value object (just like in my patch), but pwolanin preferred to have the tree manipulators to not be defined as part of the MenuTreeParameters. This is a distinction that is also fine by me: MenuTreeParameters is then solely for determining which menu links to load, and the tree manipulators are applied as part of a new transform() step that accepts the tree manipulators.
    5. pwolanin strongly felt that buildSubtree() was necessary/valuable (to get the tree below a given parent item). This will just be an alias:buildSubtree($menu_name, $parent) === build($menu_name, $parameters = array('parent' => $parent)) (pseudocode).
    6. To neither of us, it was clear whether getChildLinks() belonged in the interface/was worth keeping around, but we figured that'd become clearer once all of the above had been done.
    7. The resetStaticCache() method was already planned to be removed, and will be.
  3. Splitting off the methods relating to the active trail into MenuActiveTrail(Interface). This is like in my patch. But unlike in my patch, pwolanin has already done the work to get rid of the procedural menu.inc/menu_link_get_preferred() (YAY!) — though we probably want a better name for the method now :)
  4. Moving the methods relating to presenting a menu tree in some shape in a form (to select a parent menu item, primarily) into MenuParentFormSelectorInterface. We both would like to remove this, but since we want modules to (finally) be able to override this form item cleanly (e.g. to use something that scales better, like Hierarchical Select), we need this to be a service.
    All other methods should go away though, but that may only be addressed in a follow-up issue.

@pwolanin: if I misrepresented anything, ping me, and I'll correct it.

giorgio79’s picture

The Commerce guys had an attempt here for a generic Tree function. Perhaps, parts of it can be reused here https://www.drupal.org/project/tree

pwolanin’s picture

Status: Postponed » Fixed
wim leers’s picture

Oh, yes, absolutely. I forgot to close this one. Thanks :)

wim leers’s picture

Status: Fixed » Closed (duplicate)