在 Drupal 8 中创建语言级联
从“你可以用Drupal8做的疯狂事情”的页面中,我最近不得不在一个网站上实现这个想法。这是在一个已经实现翻译的网站上,但他们需要一些额外的东西。中心思想是,如果您以特定语言访问网站上的页面,而该语言不存在,则尝试为用户提供类似的语言。
如果您还不知道,Drupal的默认行为是在请求的语言不存在时返回页面的默认语言版本。如果安装了路径自动模块(大多数Drupal站点通常都是这种情况),则Drupal将无法正确翻译路径别名,并且如果请求的翻译不存在,则会发出404。Drupal无法采用法语页面/fr/about-us的路径并找到原始节点,因为就其而言/fr/about-us不存在。
因此,这一要求需要改变Drupal8查找和检索翻译的方式。这样做让我了解了一些有关Drupal8内部工作原理的知识。
语言交换服务
在开始之前,我需要一种方法来决定选择哪种语言。我之前暗示过要选择一种类似的语言,但需要一个中央机制来促进这一点。所以我创建了一个名为LanguageSwapper的Drupal服务。此服务用于将一种语言更改为另一种语言。此服务的中心思想是采用像“en-gb”这样的语言字符串并从中提取语言字符串。
首先,我们在自定义模块services.yml文件中定义语言交换服务。
services: language_cascade.language_swapper: class: Drupal\language_cascade\LanguageSwapper arguments: ['@language_manager']
这个类的中心方法是swapLanguage()方法。这需要一个语言代码并使用“-”字符将其拆分。然后做出以下决定。
如果字符串包含超过1个部分,则剪掉语言的最后一部分。
如果字符串不包含“-”字符,则只返回传递给它的语言字符串。
在创建新语言的所有实例中,我们首先进行测试以确保我们提取的语言确实存在于站点上。这可以防止我们通过尝试获取不存在的语言的翻译而导致上游错误。
这里是swapLanguage()完整的方法。
/** * Convert the language code to something else. * * @param string $langcode * (optional) The langcode to swap. * * @return string * The new langcode if found, or the existing langcode if not. */ public function swapLanguage($langcode = NULL) { $langcode = $langcode ?: $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(); //将语言分成几部分。 $languageParts = explode('-', $langcode); $numberOfLanguageParts = count($languageParts); if ($numberOfLanguageParts > 1) { //如果我们找到了语言字符串的三个部分,那么剪掉 //结束元素并创建一个新的语言字符串。这将转换 //将zh-hant-tw之类的字符串转换为zh-hant。 $newLangcode = implode('-', array_slice($languageParts, 0, $numberOfLanguageParts - 1)); if ($this->languageManager->getLanguage($newLangcode)) { //我们找到了一种可行的语言,所以返回它。 return $newLangcode; } //剪掉数组的末尾。 unset($languageParts[$numberOfLanguageParts - 1]); } return $langcode; }
下面是一些示例来演示语言交换器将做什么。
这是一种选择机制,用于在用户请求不存在的页面翻译时选择要向用户显示的语言。例如,如果用户使用fr-ca访问该站点,那么他们将看到该站点的法语版本。这样做的结果是,只需进行一次翻译,就可以覆盖所有使用一种语言的地区,而无需到处复制和粘贴内容。这也意味着网站管理员在需要以特定语言创建页面时不需要翻译所有内容。
覆盖Drupal核心
LanguageSwapper只是这个过程的开始。为了让一切正常工作,我们还需要覆盖Drupal加载和提供内容页面的方式。
解释一下,Drupal中的通常流程(启用Path模块)是三个类协同工作以在正确的时间为正确的页面提供服务。这意味着为了改变Drupal服务页面的方式,我们需要改变这三个核心类。这是以尽可能尊重现有流程的方式完成的,同时在顶部添加我们自己的功能。下面描述了这三个类及其操作。
Drupal\Core\Path\AliasManager-Drupal用于管理别名,用于从数据库中查找和检索别名。
Drupal\Core\PathProcessor\PathProcessorAlias-Drupal使用它来将别名路径转换为可用路由。所以这需要一个像'node/123'这样的路径,并允许它具有'my-age'的别名。
Drupal\Core\ParamConverter\EntityConverter-这个类是在系统中加载实体(即节点)的地方,还允许加载特定语言的实体。
覆盖PathProcessorAlias
PathProcessorAlias的默认Drupal类用于允许Drupal站点中的路径与通常情况不同。例如,Drupal站点上的节点通常称为/node/x,其中x是节点的ID。使用PathProcessorAlias,我们可以将其更改为我们想要的任何其他值,Drupal会将其映射回原始节点。
语言被添加到此路径的开头,因此如果我们查看英语节点,路径将是/en/node/123。尽管我们要使用的语言存在,但翻译本身可能不存在。因此,如果我们尝试加载/ca-fr/node/123,那么我们需要通知Drupal该节点确实存在。因此,我们需要覆盖这个类,以便Drupal可以成功地将翻译定位到正确的节点。
可以使用定义几个标签的自定义服务定义来覆盖PathProcessorAlias。通过在此处规定path_processor_outbound标记,我们告诉Drupal该类是该服务的一部分。我们赋予它比默认值300稍高的优先级,以便它比默认值更能被使用。
path_processor_alias: class: Drupal\language_cascade\PathProcessor\LanguageCascadePathProcessorAlias tags: - { name: path_processor_outbound, priority: 301 } arguments: ['@path.alias_manager', '@language_cascade.language_swapper']
在我们的LanguagecascadePathProcessorAlias类中,我们覆盖了processOutbound方法。如果我们 从父processOutbound方法收到一个看起来像/node/123的路径,那么这会给我们一个信号,表明我们需要进一步加载路径。这是通过使用LanguageSwapper服务选择正确的语言,然后尝试从Drupal中加载该翻译来完成的。
这是我用来覆盖PathProcessorAlias的类。
aliasManager = $alias_manager; $this->languageSwapper = $languageSwapper; } public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) { $path = parent::processOutbound($path,$options, $request, $bubbleable_metadata ); $langcode = isset($options['language']) ? $options['language']->getId() : NULL; if (preg_match('/node\/\d+$/', $path, $output_array) && !$langcode) { //如果返回“node/x”路径,则尝试查找真正的别名。这 //将是根路径与下一级路径的组合。 $newLangcode = $this->languageSwapper->swapLanguage($langcode); $newPath = $this->aliasManager->getAliasByPath($path, $newLangcode); if ($newPath != $path) { $path = $newPath; } } return $path; } }
覆盖AliasManager
Drupal使用AliasManager类从数据库加载别名。这实际上处理与PathProcessorAlias非常相似的任务,但这个类最终是页面未找到响应的来源。因此,我们需要确保如果服务没有找到正确的页面,则使用LanguageSwapper服务加载正确的翻译并重置页面未找到响应。
由于AliasManager没有定义任何标签,这意味着我们需要使用另一种方法来覆盖这个类。这是我们需要使用ServiceProvider来拦截和更改加载到系统中的服务的地方。我在这里做的是加载path.alias_manager服务并用我们自己的自定义类替换该类。除此之外,我还以LanguageSwapper服务的形式提供了一个额外的参数,以便我们可以在自定义类中访问该服务。
getDefinition('path.alias_manager'); $definition->setClass('Drupal\language_cascade\Path\LanguageCascadeAliasManager'); //添加语言交换器作为此新服务的依赖项。 $definition->addArgument(new Reference('language_cascade.language_swapper')); } }
有了这个,我们现在可以添加我们自己的别名管理器覆盖类。此类在Drupal的AliasManager对象之后运行,并将尝试纠正页面未找到响应。如果未找到路径,则'noPath'数组将包含有关未找到的语言和路径的信息。我们需要做的是使用LanguageSwapper服务来查看是否存在不同语言的路径,并返回该语言的路径。
storage = $storage; $this->languageManager = $language_manager; $this->whitelist = $whitelist; $this->cache = $cache; $this->languageSwapper = $languageSwapper; } /** * {@inheritdoc} */ public function getPathByAlias($alias, $langcode = NULL) { $newAlias = parent::getPathByAlias($alias, $langcode); if (is_null($langcode)) { //确保已经设置了语言,以便我们可以查看noPath //已为当前语言填充数组。 $langcode = $langcode ?: $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(); } if (isset($this->noPath[$langcode][$alias])) { //未找到路径。 //如果找不到此语言的路径,则剪掉结尾并 //尝试找到该语言。 $newLangcode = $this->languageSwapper->swapLanguage($langcode); if ($langcode != $newLangcode && $path = $this->storage->lookupPathSource($alias, $newLangcode)) { //将找到的别名添加到lookupMap数组。 $this->lookupMap[$newLangcode][$path] = $alias; //现在我们已经找到了别名,将它从noPath数组中删除。 unset($this->noPath[$langcode][$alias]); return $path; } } return $newAlias; } }
覆盖EntityConverter
最后,在整理出路径别名后,下一步是确保将正确的页面内容加载到位。EntityConverter类负责根据语言和实体的ID加载正确的实体。由于此服务是使用标签定义的,我们可以使用这些标签来覆盖服务并赋予它更高的优先级,就像我们使用PathProcessorAlias所做的那样。
language_cascade.language_entity_converter: class: Drupal\language_cascade\ParamConverter\LanguageCascadeEntityConverter tags: - { name: paramconverter, priority: 9 } arguments: ['@entity.manager', '@language_manager', '@language_cascade.language_swapper']
当EntityConverter运行时,我们会收到一个对象。为了确保我们正在加载正确的对象,我们执行了一些检查。首先,我们确保实体存在并且加载了正确的语言变体。如果不是,那么我们需要使用LanguageSwapper对象来交换语言,然后加载该语言的正确翻译。
这是我们的自定义LanguageCascadeEntityConverter类。
entityManager = $entity_manager; $this->languageManager = $language_manager; $this->languageSwapper = $languageSwapper; } /** * {@inheritdoc} */ public function convert($value, $definition, $name, array $defaults) { $entity = parent::convert($value, $definition, $name, $defaults); //如果实体类型是可翻译的,请确保我们返回正确的 //当前上下文的翻译对象。 if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) { $langcode = $this->languageManager() ->getCurrentLanguage(LanguageInterface::TYPE_URL) ->getId(); //如果翻译项目的语言: //-不等于当前语言。 //-是站点默认语言。 //然后尝试找到下一个可用的最佳翻译。 if ($entity->language()->getId() != $langcode && $entity->language()->getId() == $entity->getUntranslated()->language()->getId()) { $newLangcode = $this->languageSwapper->swapLanguage($langcode); if ($langcode != $newLangcode) { //如果我们找到的新langcode不同,则尝试找到 //对应于新语言代码的翻译。 try { $translation = $entity->getTranslation($newLangcode); if ($translation) { //如果找到翻译,则将其设为当前实体。 $entity = $translation; } } catch (\Exception $e) { //什么都不做,这是一个实际的404页面。 } } } } return $entity; } }
有了所有这些,语言级联就可以完全正常工作了。如果我们用法语创建一个内容页面,并为加拿大法语设置了语言,那么加载页面的加拿大法语版本将改为加载法语版本。
下一步?
上面的代码并不完整。我可以做一些改进或更改来使事情变得更好。
这实际上接近“不要破解核心”规则。使用现代依赖注入系统,这意味着可以在不实际更改核心类的情况下更改它们。上面代码的原型对Drupal不是那么友好,只是覆盖了类方法,而不是先调用它们然后查看结果。我花了很多时间和精力确保这不会对Drupal核心的未来更新造成任何问题,并且不会干扰Drupal的现有功能。
在编写此语言级联时,我最初确实查看了诸如内容语言协商系统之类的插件。这比覆盖核心类更可取。我很确定最终结果不会是正确的。语言协商系统更多的是将用户重定向到页面的正确版本,而不是向他们展示正确的内容。
在开发过程中发现的一个问题是将paramconverter标记(如EntityConverter类中使用的)设置为高值会导致视图UI崩溃。这是因为它试图使用我的EntityConverter而不是自定义视图UI版本来加载视图。当前值允许Drupal正常运行,同时还提供我需要的功能。
我可以做的进一步改进是允许语言级联落入不止一种语言。就目前而言,上述功能将仅适用于下一种可用语言。多做一点工作,应该可以允许级联使用一种以上的语言,或者如果在级联期间没有找到其他语言,则可能会加载默认站点语言。
LanguageSwapper服务还可以与一些配置集成,以提供更好的机制来级联语言。这意味着添加配置页面,甚至集成到Drupal使用的语言权重系统中。
该服务至少无需任何用户交互即可完成工作,这涵盖了原始要求。如果您正在阅读本文并注意到我遗漏了一些明显的内容或者可以使用简单的插件完成此操作,请告诉我。我很想听听你的意见。