Данное руководство является переводом статьи.
Хотелось бы поделиться своим опытом миграции форума с Drupal 7 на Drupal 8, а также рассказать о проблемах, с которым пришлось столкнуться во время этого процесса, а также об инструментах, которые я использовал. Кроме этого я расскажу о подводных камнях, которые встретились при миграции форума и терминов к нему.
Инструменты
Весь процесс миграции мы будем осуществлять с использованием Drush. Теперь давайте определимся с тем, что именно нам понадобится для проведения миграции (я буду указывать версии, которые будут использовать на момент написания статьи)
Drupal: 8.4.5
Модули для миграции:
- migrate
- migrate_drupal
- migrate_plus: 8.x-4.0-beta2
- migrate_tools: 8.x-4.0-beta2
Drush: 9.1.0
Весь процесс миграции происходил с использованием
PHP: 7.1.14
MySQL: MariaDB 10.1.31
Примечание:
- Все пути будут указаны относительно корня модуля (директории custom_migration_forum, или того названия, которое вы дадите своему модулю).
- Перед началом миграции отключите модуль rdf в Drupal 8, поскольку с ним могут возникнуть проблемы во время исполнения Rolling back (когда отменяем изменения миграции).
Тестовый контент
Для актуальности информации я решил по ходу написания статьи дописывать модуль, который проводит миграцию форума. Тестовый контент я создал, используя для генерации контента Devel. Общее количество сгенерированных тем форума составляет 300 шт., а также комментарии к ним. Получилось вот что:
Подготовка к миграции
Сначала мы развернем чистый сайт на Drupal 8. На чистом сайте мы создаем свой модуль, либо используем GitHub.
Создаем файл custom_migration_forum.info.yml, в который заносим основную информацию про наш модуль и зависимости:
name: Custom Migration Forum
description: Custom module for migrating forum from a Drupal 7 site.
package: Migrations
type: module
core: 8.x
dependencies:
- drupal:migrate
- drupal:migrate_drupal
- drupal:forum
- migrate_plus:migrate_plus (>=4.0-beta2)
- migrate_tools:migrate_tools (>=4.0-beta2)
Для того, чтобы удалить старые конфиги миграций, при деинсталяции модуля это нужно описать в custom_migration_forum.install. Бывало, я сталкивался с ситуациями, когда возникал конфликт конфигов из-за того, что старые конфиги не были удалены при деинсталяции модуля. Поэтому, дабы обезопасить себя, лучше удалять их при деинсталяции модуля.
custom_migration_forum.install
<?php
/**
* @file
* Contains migrate_forum_drupal8.install.
*/
/**
* Implements hook_uninstall().
*
* Removes stale migration configs during uninstall.
*/
function custom_migration_forum_uninstall() {
$query = \Drupal::database()->select('config', 'c');
$query->fields('c', ['name']);
$query->condition('name', $query->escapeLike('migrate_plus.') . '%', 'LIKE');
$config_names = $query->execute()->fetchAll();
// Delete each config using configFactory.
foreach ($config_names as $config_name) {
\Drupal::configFactory()->getEditable($config_name->name)->delete();
}
}
Или же Вам просто нужно будет прописать зависимость у каждой миграции:
dependencies:
enforced:
module:
- custom_migration_forum
Миграция терминов форума
Поскольку форум в Drupal 7 является, по сути, нодами с терминами, то для начала, мы должны мигрировать термины. Начнем с создания плагинов для миграции терминов и словаря.
src/Plugin/migrate/source/Vocabulary.php:
<?php
/**
* @file
* Contains \Drupal\migrate_therasomnia\Plugin\migrate\source\Vocabulary.
*/
namespace Drupal\custom_migration_forum\Plugin\migrate\source;
use Drupal\migrate\Row;
use Drupal\migrate\Plugin\migrate\source\SqlBase;
/**
* Drupal 7 vocabularies source from database.
*
* @MigrateSource(
* id = "custom_migration_forum_vocabulary",
* source_provider = "taxonomy"
* )
*/
class Vocabulary extends SqlBase {
/**
* {@inheritdoc}
*/
public function query() {
$query = $this->select('taxonomy_vocabulary', 'v')
->fields('v', array(
'vid',
'name',
'description',
'hierarchy',
'module',
'weight',
'machine_name'
));
// Filtered out unnecessary dictionaries.
$query->condition('machine_name', 'forums');
return $query;
}
/**
* {@inheritdoc}
*/
public function fields() {
return array(
'vid' => $this->t('The vocabulary ID.'),
'name' => $this->t('The name of the vocabulary.'),
'description' => $this->t('The description of the vocabulary.'),
'help' => $this->t('Help text to display for the vocabulary.'),
'relations' => $this->t('Whether or not related terms are enabled within the vocabulary. (0 = disabled, 1 = enabled)'),
'hierarchy' => $this->t('The type of hierarchy allowed within the vocabulary. (0 = disabled, 1 = single, 2 = multiple)'),
'weight' => $this->t('The weight of the vocabulary in relation to other vocabularies.'),
'parents' => $this->t("The Drupal term IDs of the term's parents."),
'node_types' => $this->t('The names of the node types the vocabulary may be used with.'),
);
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['vid']['type'] = 'integer';
return $ids;
}
}
src/Plugin/migrate/source/Terms.php:
<?php
/**
* @file
* Contains \Drupal\migrate_therasomnia\Plugin\migrate\source\Terms.
*/
namespace Drupal\custom_migration_forum\Plugin\migrate\source;
use Drupal\migrate\Row;
use Drupal\migrate\Plugin\migrate\source\SqlBase;
/**
* Drupal 7 taxonomy terms source from database.
*
* @MigrateSource(
* id = "custom_migration_forum_term",
* source_provider = "taxonomy"
* )
*/
class Terms extends SqlBase {
/**
* {@inheritdoc}
*/
public function query() {
$query = $this->select('taxonomy_term_data', 'td')
->fields('td', ['tid', 'vid', 'name', 'description', 'weight', 'format'])
->fields('tv', ['vid', 'machine_name'])
->distinct();
// Add table for condition on query.
$query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid');
// Filtered out unnecessary dictionaries.
$query->condition('tv.machine_name', 'forums');
return $query;
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'tid' => $this->t('The term ID.'),
'vid' => $this->t('Existing term VID'),
'name' => $this->t('The name of the term.'),
'description' => $this->t('The term description.'),
'weight' => $this->t('Weight'),
'parent' => $this->t("The Drupal term IDs of the term's parents."),
];
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
// Find parents for this row.
$parents = $this->select('taxonomy_term_hierarchy', 'th')
->fields('th', ['parent', 'tid']);
$parents->condition('tid', $row->getSourceProperty('tid'));
$parents = $parents->execute()->fetchCol();
$row->setSourceProperty('parent', reset($parents));
return parent::prepareRow($row);
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['tid']['type'] = 'integer';
return $ids;
}
}
После создания плагинов для миграции терминов и словарей, необходимо сделать конфигурацию для нашей миграции. Для этого создаем migrate_plus.migration.term.yml и migrate_plus.migration.vocablary.yml.
config/install/migrate_plus.migration.vocablary.yml:
id: custom_migration_forum_vocabulary
label: Taxonomy vocabulary forum
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
source:
plugin: custom_migration_forum_vocabulary
target: migrate
process:
vid:
-
plugin: machine_name
source: machine_name
-
plugin: make_unique_entity_field
entity_type: taxonomy_vocabulary
field: vid
length: 32
migrated: true
label: name
name: name
description: description
hierarchy: hierarchy
module: module
weight: weight
destination:
plugin: entity:taxonomy_vocabulary
В 'source' мы указываем наш плагин custom_migration_forum_vocabulary, который был описан в классе Vocabulary.
config/install/migrate_plus.migration.term.yml:
id: custom_migration_forum_term
label: Taxonomy terms forum
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
source:
plugin: custom_migration_forum_term
target: migrate
process:
tid: tid
vid:
plugin: migration
migration: custom_migration_forum_vocabulary
source: vid
name: name
description: description
weight: weight
parent: parent
changed: timestamp
destination:
plugin: entity:taxonomy_term
migration_dependencies:
required:
- custom_migration_forum_vocabulary
При миграции терминов в vid мы указываем машинное имя (custom_migration_forum_vocabulary), из которого мы будем брать ID словаря форума.
Миграция форума
Хотелось бы обратить внимание, что в данной статье мы мигрируем форум без дополнительных полей. Перед тем, как начать миграцию форумов, нам нужно мигрировать пользователей, ведь без них у форумов не будет авторов. Такую миграцию мы проведем с использованием плагинов из Ядра, но добавим их в свою группу миграции “Custom Migration Forum” для того чтобы можно было запустить всю миграцию одной командой. Нам нужно три миграции:
- миграция ролей (custom_migration_forum_user_role)
- миграция пользователей (custom_migration_forum_user)
- Миграция форматов текста (custom_migration_forum_filter_format).
Вот наши три yml-файла.
config/install/migrate_plus.migration.user_role.yml:
id: custom_migration_forum_user_role
label: User roles
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
source:
plugin: d7_user_role
process:
id:
-
plugin: machine_name
source: name
-
plugin: user_update_8002
label: name
permissions:
-
plugin: static_map
source: permissions
bypass: true
map:
'use PHP for block visibility': 'use PHP for settings'
'administer site-wide contact form': 'administer contact forms'
'post comments without approval': 'skip comment approval'
'edit own blog entries': 'edit own blog content'
'edit any blog entry': 'edit any blog content'
'delete own blog entries': 'delete own blog content'
'delete any blog entry': 'delete any blog content'
'create forum topics': 'create forum content'
'delete any forum topic': 'delete any forum content'
'delete own forum topics': 'delete own forum content'
'edit any forum topic': 'edit any forum content'
'edit own forum topics': 'edit own forum content'
- plugin: flatten
weight: weight
destination:
plugin: entity:user_role
migration_dependencies:
optional:
- custom_migration_forum_filter_format
config/install/migrate_plus.migration.user.yml:
id: custom_migration_forum_user
label: User accounts
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
class: Drupal\user\Plugin\migrate\User
source:
plugin: d7_user
process:
uid: uid
name: name
pass: pass
mail: mail
created: created
access: access
login: login
status: status
timezone: timezone
langcode:
plugin: user_langcode
source: language
fallback_to_site_default: false
preferred_langcode:
plugin: user_langcode
source: language
fallback_to_site_default: true
preferred_admin_langcode:
plugin: user_langcode
source: language
fallback_to_site_default: true
init: init
roles:
plugin: migration_lookup
migration: custom_migration_forum_user_role
source: roles
user_picture:
-
plugin: default_value
source: picture
default_value: null
-
plugin: migration_lookup
migration: d7_file
destination:
plugin: entity:user
migration_dependencies:
required:
- custom_migration_forum_user_role
optional:
- d7_field_instance
- d7_file
- language
- default_language
- user_picture_field_instance
- user_picture_entity_display
- user_picture_entity_form_display
config/install/migrate_plus.migration.filter_format.yml:
id: custom_migration_forum_filter_format
label: Filter format configuration
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
source:
plugin: d7_filter_format
process:
format: format
name: name
cache: cache
weight: weight
filters:
plugin: sub_process
source: filters
key: '@id'
process:
id:
plugin: filter_id
bypass: true
source: name
map: { }
settings:
plugin: filter_settings
source: settings
status:
plugin: default_value
default_value: true
weight: weight
destination:
plugin: entity:filter_format
Теперь можно начинать подготовку плагина для миграции материалов форума.
src/Plugin/migrate/source/Forum.php:
<?php
namespace Drupal\custom_migration_forum\Plugin\migrate\source;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Plugin\migrate\source\d7\FieldableEntity;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Extension\ModuleHandler;
use Drupal\Core\State\StateInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Extract forum from Drupal 7 database.
*
* @MigrateSource(
* id = "custom_migration_forum_forum",
* )
*/
class Forum extends FieldableEntity {
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, StateInterface $state, EntityManagerInterface $entity_manager, ModuleHandlerInterface $module_handler) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $state, $entity_manager);
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('state'),
$container->get('entity.manager'),
$container->get('module_handler')
);
}
/**
* The join options between the node and the node_revisions table.
*/
const JOIN = 'n.vid = nr.vid';
/**
* {@inheritdoc}
*/
public function query() {
// Select node in its last revision.
$query = $this->select('node_revision', 'nr')
->fields('n', [
'nid',
'type',
'language',
'status',
'created',
'changed',
'comment',
'promote',
'sticky',
'tnid',
'translate',
])
->fields('nr', [
'vid',
'title',
'log',
'timestamp',
])
->fields('fb', [
'body_value',
'body_format',
]);
$query->addField('n', 'uid', 'node_uid');
$query->addField('n', 'type', 'node_type');
$query->addField('nr', 'uid', 'revision_uid');
$query->innerJoin('node', 'n', static::JOIN);
$query->innerJoin('field_data_body', 'fb', 'n.nid = fb.entity_id');
// If the content_translation module is enabled, get the source langcode
// to fill the content_translation_source field.
if ($this->moduleHandler->moduleExists('content_translation')) {
$query->leftJoin('node', 'nt', 'n.tnid = nt.nid');
$query->addField('nt', 'language', 'source_langcode');
}
$this->handleTranslations($query);
// Filtered node type forum.
$query->condition('n.type', 'forum');
return $query;
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
// Get Field API field values.
foreach (array_keys($this->getFields('node', 'forum')) as $field) {
$nid = $row->getSourceProperty('nid');
$vid = $row->getSourceProperty('vid');
$row->setSourceProperty($field, $this->getFieldValues('node', $field, $nid, $vid));
}
// Make sure we always have a translation set.
if ($row->getSourceProperty('tnid') == 0) {
$row->setSourceProperty('tnid', $row->getSourceProperty('nid'));
}
return parent::prepareRow($row);
}
/**
* {@inheritdoc}
*/
public function fields() {
$fields = [
'nid' => $this->t('Node ID'),
'type' => $this->t('Type'),
'title' => $this->t('Title'),
'body_value' => $this->t('Full text of body'),
'body_format' => $this->t('Format of body'),
'node_uid' => $this->t('Node authored by (uid)'),
'revision_uid' => $this->t('Revision authored by (uid)'),
'created' => $this->t('Created timestamp'),
'changed' => $this->t('Modified timestamp'),
'status' => $this->t('Published'),
'promote' => $this->t('Promoted to front page'),
'sticky' => $this->t('Sticky at top of lists'),
'revision' => $this->t('Create new revision'),
'language' => $this->t('Language (fr, en, ...)'),
'tnid' => $this->t('The translation set id for this node'),
'timestamp' => $this->t('The timestamp the latest revision of this node was created.'),
];
return $fields;
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['nid']['type'] = 'integer';
$ids['nid']['alias'] = 'n';
return $ids;
}
/**
* Adapt our query for translations.
*
* @param \Drupal\Core\Database\Query\SelectInterface $query
* The generated query.
*/
protected function handleTranslations(SelectInterface $query) {
// Check whether or not we want translations.
if (empty($this->configuration['translations'])) {
// No translations: Yield untranslated nodes, or default translations.
$query->where('n.tnid = 0 OR n.tnid = n.nid');
}
else {
// Translations: Yield only non-default translations.
$query->where('n.tnid <> 0 AND n.tnid <> n.nid');
}
}
}
И yml-файл к плагину.
config/install/migrate_plus.migration.forum.yml:
id: custom_migration_forum_forum
label: Custom forum migration
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
dependencies:
enforced:
module:
- custom_migration_forum
source:
plugin: custom_migration_forum_forum
node_type: forum
target: migrate
migration_dependencies:
required:
- custom_migration_forum_term
- custom_migration_forum_user
- custom_migration_forum_filter_format
process:
nid: tnid
vid: vid
langcode:
plugin: default_value
source: language
default_value: 'en'
title: title
type:
plugin: default_value
default_value: forum
'body/value': body_value
'body/format': body_format
uid:
plugin: migration
migration: custom_migration_forum_user
source: node_uid
status: status
created: created
changed: changed
promote: promote
sticky: sticky
revision_uid: revision_uid
revision_log: log
revision_timestamp: timestamp
taxonomy_forums:
plugin: migration
migration: custom_migration_forum_term
source: taxonomy_forums
destination:
plugin: entity:node
Последнее, что нам осталось — это комментарии к темам (как же без них?).
Создаем плагин и описываем его в конфигурации.
src/Plugin/migrate/source/ForumComment.php:
<?php
/**
* @file
* Contains \Drupal\custom_migration_forum\Plugin\migrate\source\ForumComment.
*/
namespace Drupal\custom_migration_forum\Plugin\migrate\source;
use Drupal\migrate\Row;
use Drupal\migrate_drupal\Plugin\migrate\source\d7\FieldableEntity;
/**
* Drupal 7 comment forum source from database.
*
* @MigrateSource(
* id = "custom_migration_forum_forum_comment",
* source_provider = "comment",
* )
*/
class ForumComment extends FieldableEntity {
/**
* {@inheritdoc}
*/
public function query() {
$query = $this->select('comment', 'c')->fields('c');
$query->innerJoin('node', 'n', 'c.nid = n.nid');
$query->addField('n', 'type', 'node_type');
$query->addField('n', 'nid');
$query->condition('n.type', 'forum');
$query->orderBy('c.created');
return $query;
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
$cid = $row->getSourceProperty('cid');
$node_type = $row->getSourceProperty('node_type');
$comment_type = 'comment_node_' . $node_type;
$row->setSourceProperty('comment_type', 'comment_forum');
foreach (array_keys($this->getFields('comment', $comment_type)) as $field) {
$row->setSourceProperty($field, $this->getFieldValues('comment', $field, $cid));
}
return parent::prepareRow($row);
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'cid' => $this->t('Comment ID.'),
'pid' => $this->t('Parent comment ID. If set to 0, this comment is not a reply to an existing comment.'),
'nid' => $this->t('The {node}.nid to which this comment is a reply.'),
'uid' => $this->t('The {users}.uid who authored the comment. If set to 0, this comment was created by an anonymous user.'),
'subject' => $this->t('The comment title.'),
'comment' => $this->t('The comment body.'),
'hostname' => $this->t("The author's host name."),
'created' => $this->t('The time that the comment was created, as a Unix timestamp.'),
'changed' => $this->t('The time that the comment was edited by its author, as a Unix timestamp.'),
'status' => $this->t('The published status of a comment. (0 = Published, 1 = Not Published)'),
'format' => $this->t('The {filter_formats}.format of the comment body.'),
'thread' => $this->t("The vancode representation of the comment's place in a thread."),
'name' => $this->t("The comment author's name. Uses {users}.name if the user is logged in, otherwise uses the value typed into the comment form."),
'mail' => $this->t("The comment author's email address from the comment form, if user is anonymous, and the 'Anonymous users may/must leave their contact information' setting is turned on."),
'homepage' => $this->t("The comment author's home page address from the comment form, if user is anonymous, and the 'Anonymous users may/must leave their contact information' setting is turned on."),
'type' => $this->t("The {node}.type to which this comment is a reply."),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['cid']['type'] = 'integer';
return $ids;
}
}
config/install/migrate_plus.migration.forum_comment.yml:
id: custom_migration_forum_forum_comment
label: Comments forum
migration_group: Custom Migration Forum
dependencies:
enforced:
module:
- custom_migration_forum
source:
plugin: custom_migration_forum_forum_comment
target: migrate
constants:
entity_type: node
process:
cid: cid
pid:
plugin: migration_lookup
migration: custom_migration_forum_forum_comment
source: pid
entity_id: nid
entity_type: 'constants/entity_type'
comment_type: comment_type
field_name: comment_type
subject: subject
uid: uid
name: name
mail: mail
homepage: homepage
hostname: hostname
created: created
changed: changed
status: status
thread: thread
comment_body: comment_body
destination:
plugin: entity:comment
migration_dependencies:
required:
- custom_migration_forum_forum
Начало миграции
Теперь мы готовы к проведению миграции. Добавляем базу данных Drupal 7 в файл конфигурации нашего сайта на Drupal 8. Делаем это в файле settings.php (либо в другом файле, который подключает ваши настройки. У меня это файл settings.local.php ):
<?php
$databases['migrate']['default'] = array (
'database' => 'Drupal_7',
'username' => 'root',
'password' => 'root',
'prefix' => '',
'host' => 'localhost',
'port' => '3306',
'namespace' => 'Drupal\Core\Database\Driver\mysql',
'driver' => 'mysql',
);
Включаем следующие модули:
drush en migrate migrate_drupal migrate_plus migrate_tools taxonomy forum -у
Если вы загрузили модуль с GitHub, можете просто включить его:
drush en custom_migration_forum -y
Миграция через Drush
Проверяем список доступных миграций:
drush ms
Запускаем все наши миграции одной командой:
drush mim --group="Custom Migration Forum"
Для того, чтобы отменить изменения миграции, существует команда drush mr:
drush mr --group="Custom Migration Forum"
Также при миграции иногда возникают ошибки, и миграция может зависнуть в статусе Importing или Rolling back. Для того чтобы сбросить статус миграции, нужно запустить:
drush php-eval 'var_dump(Drupal::keyValue("migrate_status")->set('custom_migration_forum_forum', 0))'
Где custom_migration_forum_forum — это ID миграции.
Наша миграция форума завершена. В результате мы получили полностью мигрированный форум с пользователями и комментариями к темам.
afi13
Добрый день, необязательно использовать hook_uninstall() для удаления конфигов при удалении модуля, достаточно указать enforced зависимость во всемх конфиг-файлах:
helender Автор
Здравствуйте, спасибо за конструктивное замечание. Я немного подкоректировал статью.