Что случилось с hook_menu в Drupal 8?

Главные вкладки

Аватар пользователя gor gor 11 декабря 2015 в 15:50

Перепост с habrahabr.ru. Спасибо за вклад ZapevalovAnton


habt export
В связи с недавним выходом стабильной версии Drupal 8, решил внести свой небольшой вклад, и перевести небольшую статью. Это очень вольный перевод статьи What Happened to Hook_Menu in Drupal 8? от Lullabot'ов. Надеюсь, что кому-нибудь пригодится.

В Drupal 7 и более ранних версиях, hook_menu был как швейцарский нож. Он отвечал практически за все: пути страниц, обработчики меню, вкладки и локальные задачи, контекстные ссылки, управление доступом, аргументы и параметры, обработчики форм, и даже устанавливал пункты меню. В моей книге, это самый часто используемый hook из всех. Я не знаю, ни одного модуля в котором, я не реализовывал бы hook_menu.

Но, в Drupal 8 все изменилось. Этого очень важного hook'a больше нет, и теперь все эти задачи решаются отдельно, используя систему YAML файлов, в которых нужно описать метаданные о каждом элементе и соответствующие ему PHP классы, которые обеспечивают логику.

В новой системе есть смысл, но она может показаться запутанной, тем более что API менялся несколько раз, в течении длительной разработки Drupal 8, и документация в настоящее время, не соответствует действительности. В этой статье будет рассказано как работает новая система.

Так же я хочу рассказать о ситуациях с которыми я столкнулся, во время переноса своего модуля с Drupal 7 на Drupal 8 и приведу примеры кода, до и после переноса.

Пользовательские страницы (Custom pages)

В самом простом случае hook_menu использовался, для создания пользовательских страниц по заданному пути. В Drupal 8, пути управляются с помощью файла MODULE.routing.yml, в котором описывается соответствие путей (маршрутов) и классов контроллеров, содержащих логику обработки данных по этому пути. Каждый класс наследуется от базового контроллера. В Drupal 7 такие логические контроллеры, могли находиться в MODULE.pages.inc.

Пример кода в Drupal 7:

function example_menu() {
  $items = array();
  $items['main'] = array(<strong></strong>
    'title' => 'Main Page',
    'page callback' => example_main_page,
    'access arguments' => array('access content'),
    'type' => MENU_NORMAL_ITEM,
    'file' => 'MODULE.pages.inc'
  );
  return $items;
}

function example_main_page() {
  return t(‘Something goes here’);
}

В Drupal 8, информацию о маршруте (пути) мы описываем в файле MODULE.routing.yml. У каждого маршрута есть название, которое ни за что не отвечает, а просто является уникальным идентификатором маршрута, и должно быть с префиксом из имени Вашего модуля, чтобы избежать конфликтов имен. В документации можно найти, что когда-то были обсуждения об использовании _content или _form суффиксов вместо _controller в YAML файлах, но позже от этого отказались и теперь всегда нужно использовать суффикс _controller, чтобы определить соответствующий контроллер.

example.main_page_controller:
  path: '/main'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
  requirements:
    _permission: 'access content'

Обратите внимание на использование слеша в начале. В Drupal 7 путь был бы «main», а в Drupal 8 стал "/main". Я постоянно забываю про слеш в начале, это одна из проблем перехода на новую версию. Слеш в начале, это первое, что нужно проверить, если Ваш код не работает.

В приведенном выше примере класс контроллера назван MainPageController.php, и располагается он в файле MODULE/src/Controller/MainPageController.php. Имя файла должно соответствовать имени класса контроллера, и все контроллеры Вашего модуля должны лежать в папке /src/Controller. Это место описано в стандарте PSR-4, который принят в Drupal 8. В принципе все что лежит в ожидаемом для Drupal’a месте /src, будет автоматически загружено при необходимости, без использования module_load_include(), или перечисления в .info файле, как мы это делали в Drupal 7.

Метод в контроллере, который будет управлять этим маршрутом, может иметь любое имя. В своем примере я использовал произвольное название mainPage. Самое главное, что метод который мы будем использовать в нашем контроллере должен соответствовать тому, что мы описали в YAML файле, в директиве _controller как class_name::method.

Один контроллер может управлять одним и более маршрутами, так как у каждого есть свой обработчик (callback) и своя запись в YAML файле. Например, в ядре контроллер nodeController управляет четырьмя маршрутами, перечисленными в node.routing.yml.
Контроллер всегда должен возвращать массив для (render array), а не текст или HTML, как это было в Drupal 7.

Перевод в контроллере доступен, через метод $this->t() вместо функции t(). Так сделано потому что в BaseController добавлен StringTranslationTrait. Хорошая статья о том, как PHP трейты, такие как переводы работают в Drupal 8 на DrupalizeMe.

/**
 * file
 * Contains \Drupal\example\Controller\MainPageController.
 */

namespace Drupal\example\Controller;

use Drupal\Core\Controller\ControllerBase;

class MainPageController extends ControllerBase {
  public function mainPage() {
    return [
        '#markup' => $this->t('Something goes here!'),
    ];
  }
}

Пути с аргументами (Path with arguments)

Для некоторых маршрутов, нужны аргументы (параметры). Если бы у моей страницы была бы пара аргументов, то в Drupal 7 это выглядело бы так:

function example_menu() {
  $items = array();
  $items[‘main/%/%] = array(
    'title' => 'Main Page',
    'page callback' => 'example_main_page',
    'page arguments' => array(1, 2),
    'access arguments' => array('access content'),
    'type' => MENU_NORMAL_ITEM,
  );
  return $items;
}

function example_main_page($first, $second) {
  return t(‘Something goes here’);
}

Давайте подправим наш YAML файл для Drupal 8, и посмотрим как передача аргументов выглядит там:

example.main_page_controller:
  path: '/main/{first}/{second}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
  requirements:
    _permission: 'access content'

А наш контроллер будет выглядеть так (параметры переданы аргументами в метод):

/**
 * file
 * Contains \Drupal\example\Controller\MainPageController.
 */

namespace Drupal\example\Controller;

use Drupal\Core\Controller\ControllerBase;

class MainPageController extends ControllerBase {
  public function mainPage($first, $second) {
    // Do something with $first and $second.
    return [
        '#markup' => $this->t('Something goes here!'),
    ];
  }
}

Маршруты с необязательными аргументами (Paths With Optional Arguments)

Приведенный выше пример, будет работать корректно, только тогда, когда переданы оба аргумента. То есть ни "/main", ни "/main/first" работать не будет, только "/main/first/second". Если Вы хотите, чтобы все три маршрута, были работоспособными, Вам необходимо внести несколько изменений в YAML файл, а именно в разделе defaults, добавить значения по умолчанию, для передаваемых аргументов:

example.main_page_controller:
  path: '/main/{first}/{second}'
  defaults:<strong></strong>
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
    first: ''
    second: ''
  requirements:
    _permission: 'access content'

Ограничения в параметрах (Restricting Parameters)

После того как мы передали параметры, нужно описать в YAML файле модуля, что в этих параметрах разрешено передавать. В примере приведенном ниже показано, что параметр с именем $first может содержать только значения 'Y' или 'N', а параметр с именем $second, обязательно должен быть числом. Любые переданные параметры, которые не соответствуют этим правилам вернут страницу с кодом 404 Not found.

Чтобы узнать больше о настройке маршрутов Вы можете обратиться к документации Symfony.

example.main_page_controller:
  path: '/main/{first}/{second}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
    first: ''
    second: ''
  requirements:
    _permission: 'access content'
    first: Y|N
    second: \d+

Передача сущностей в параметрах (Entity Parameters)

Так же, как и в Drupal 7, при создании маршрута, в него можно передать объект сущности, а не просто ее идентификатор. Это называется «upcasting» (приведение к базовому типу). В седьмой версии для этого нужно было бы вместо простого знака "%", указать ключевое слово "%node". В Drupal 8, нужно в качестве имени параметра просто использовать имя объекта, например, {node} или {user}.

example.main_page_controller:
  path: '/node/{node}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Node Page'
  requirements:
    _permission: 'access content'

Такой «upcasting», будет работать только тогда, когда в Вашем контроллере в качестве параметра присутствует объект передаваемого типа. В противном случае, там будет просто значение переданного параметра.

JSON обработчики (JSON Callbacks)

Все то что мы рассмотрели выше, в итоге возвращает уже готовый HTML код. То есть массив который Вы возвращаете в методе обработчике, автоматически будет сконвертирован системой в HTML код. Но что если Вам нужно вернуть не HTML, а JSON? У меня возникла проблема с поиском информации на эту тему. В старой документации было написано, что нужно добавить _format:json в секцию requirements, Вашего YAML файла, но это совсем не обязательно, если Вы хотите предоставить другой формат, по этому же маршруту.

Создайте массив состоящий из значений, которые Вы хотите вернуть и верните его как JsonResponse объект. Не забудьте добавить «use Symfony\Component\HttpFoundation\JsonResponse» в верхнею часть Вашего класса контроллера, чтобы этот объект был доступен.

/**
 * file
 * Contains \Drupal\example\Controller\MainPageController.
 */

namespace Drupal\example\Controller;

use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\JsonResponse;

class MainPageController extends ControllerBase {
  public function mainPage() {
    $return = array();
    // Create key/value array.
    return new JsonResponse($return);
  }
}

Управление доступом (Access Control)

В Drupal 7, hook_menu так же позволял управлять доступом. Сейчас контроль доступа осуществляется в MODULE.routing.yml файле. Есть несколько способов управления доступом:

Разрешить доступ абсолютно для всех по этому маршруту:

example.main_page_controller:
  path: '/main'
  requirements:
    _access: 'TRUE'

Ограничение по праву доступа, например для тех у кого есть доступ к содержимому, «access content» (доступ к содержимому):

example.main_page_controller:
  path: '/main'
  requirements:
    _permission: 'access content'

Ограничение по роли, например только для тех пользователей у которых есть роль «admin»:

example.main_page_controller:
  path: '/main'
  requirements:
    _role: 'admin'

Ограничение по взаимодействию с сущностью, например только когда пользователю разрешено редактировать материал (сущность должна быть передана аргументом в пути):

example.main_page_controller:
  path: '/node/{node}'
  requirements:
    _entity_access: 'node.edit'

Управление доступом более подробно описано в документации.

hook_menu_alter

А что если мы хотим изменить уже существующий маршрут (который был создан ядром или другим модулем)? В Drupal 7 для этого был hook_menu_alter, но в Drupal 8 его тоже нет. На данный момент это сложнее, чем было раньше. Самый простой пример который я смог найти, находился в модуле Node, он изменял маршрут, созданный модулем System.

Файл с классом MODULE/src/Routing/CLASSNAME.php наследуется от RouteSubscriberBase и работает следующим образом. Он находит маршрут и изменяет его используя метод alterRoutes().

/**
 * file
 * Contains \Drupal\node\Routing\RouteSubscriber.
 */

namespace Drupal\node\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

/**
 * Listens to the dynamic route events.
 */

class RouteSubscriber extends RouteSubscriberBase {

  /**
   * {inheritdoc}
   */

  protected function alterRoutes(RouteCollection $collection) {
    // As nodes are the primary type of content, the node listing should be
    // easily available. In order to do that, override admin/content to show
    // a node listing instead of the path's child links.
    $route = $collection->get('system.admin_content');
    if ($route) {
      $route->setDefaults(array(
        '_title' => 'Content',
        '_entity_list' => 'node',
      ));
      $route->setRequirements(array(
        '_permission' => 'access content overview',
      ));
    }
  }
}

services:
  node.route_subscriber:
    class: Drupal\node\Routing\RouteSubscriber
    tags:
      - { name: event_subscriber }

Большинство основных модулей описывают класс наследующийся от RouteSubscriber в папке MODULE/src/EventSubscriber/CLASSNAME.php вместо MODULE/src/Routing/CLASSNAME.php. Я не смог выяснить почему они использовали, другую папку.

На самом деле изменение существующих маршрутов и создание новых динамических маршрутов, достаточно сложные темы, и явно выходят за рамки этой статьи.

Более подробная информация по теме:

https://api.drupal.org/api/drupal/8
https://www.drupal.org/developing/api/8/routing
http://symfony.com/doc/current/book/routing.html
https://drupalize.me/videos/symfony-routing?p=1856
https://drupalize.me/videos/introduction-yaml
https://www.drupal.org/node/2092643


Еще раз благодарим ZapevalovAnton за хорошую статью по Drupal 8.