编程

Zend 框架转移到 Laminas: 回顾

520 2024-04-06 01:55:00

前言:对 Zend 框架知之甚少,只闻其名从未真正去关注了解过。近日,逛他人博客时,忽闻 Zend 框架寿终正寝,进一步了解后发现其实是转移到 Laminas 中,而且早在 2019 年底就完成了转移。于是把旧闻当新闻翻译一番,遂成此篇。如果你对 Zend/Laminas 相关信息感兴趣,欢迎在文后留言评论。

早在 2018 年 10 月,Rogue Wave Software 就宣布重组其 Zend 投资组合,包括 Zend Framework。Zend Framework 社区一时震惊,这是可以理解的,许多人对该框架的未来感到担忧。六个月后,即 2019 年 4 月,Roue Wave 宣布他们将把该项目作为 Laminas 项目移交给 Linux 基金会。

在宣布这一消息的几个月前,即 2019 年 2 月,我们开始开发一种工具,用于将项目存储库转移到他们的新家中。一开始,每个人都认为该工具应该相对简单,因为我们“只”需要更改名称空间。但我们的目标要大得多:我们希望提供与遗留 Zend Framework 组件完全兼容的包。我们希望我们的新组件能够取代旧组件(是的,我们希望在 composer.json 中使用 replace)。

我们需要一个不仅仅能够“重写名称空间”的工具。

终年决定

重写整个历史还是只是重写 tag?

我们有 150 多个组件要移动,其中许多是有 8 年历史和数千次提交的大型组件。有些要小得多,尤其是 Expressive 组件,只有几年的历史和几百次提交。

第一个计划是使用 git-filter-branch 命令重写整个历史,每次提交。此命令允许对存储库历史的每个修订应用任意操作。不幸的是,随着转移工具的功能和范围的扩大,这一操作变得慢得令人望而却步;即使是一个组件的重写也需要几个小时。

因此,我们决定采用不同的方法:我们决定只重写 tag。由于用户无论如何都无法固定到现有的 commit(因为 filter-branch 会创建新的签名,从而创建新的 commit 标识符),我们实际上只需要担心特定的标签(tag),然后这些标签会被转换为可安装的版本。这也确保了更容易识别任何给定提交的完整历史记录,这对维护人员很有用。

因此,我们改变了流程:我们签出了每个标记,执行了重写操作,删除了原始标记,并用重写的代码重新发布了标记。

拆分项目

Apigility 一直是独立组织下的独立项目。但 Expressive 与 MVC 和通用组件捆绑在同一组织下。我们决定它应该有自己的组织,就像 Apigility 已经做的那样。

此外,我们认为这些子项目的组织名称应该与它们的新名称相匹配:Laminas API Tools 和 Mezzio。

因此,我们现在有以下 GitHub 组织代表 Laminas 项目:

  • laminas 表示组件和 MVC。
  • laminas-api-tools,代表 laminas api 工具组件(前身为 Apigility)。
  • mezzio,代表 mezzio 中间件运行时和组件(以前的 Expressive)。

这使得查找专门与每个子项目相关的代码变得更加简单。

一致性

对于新项目,我们希望为所有组件保持一致的名称空间。

例如,以下是 Zend Framework 包中出现的一些以前的名称空间:

  • Zend
  • Zend\Expressive
  • ZendXml
  • ZendOAuth
  • ZendService\{service component namespace}
  • ZF
  • ZF\Apigility

这种情况经常让维护人员感到困惑!因此,我们对两个顶级名称空间进行了标准化:

  • Laminas
  • Mezzio

我们考虑使用 Laminas API Tools 子命名空间,因为他是基于 Laminas MVC 构建的,因此所有该子项目下的命名空间为  Laminas\ApiTools。 服务组件比如  ZendOAuthZendService\Twitter 现在成了 Laminas 组件,因此它们的命名空间如  Laminas\OAuthLaminas\Twitter,现在与其他组件保持一致。

弃用和丢弃包

也是艰难的决定,我们也决定放弃一些包,包括:

我们没有资源将它们相应的 API 更新到最新版本。如果找到愿意维护的人,我们很乐意将其引回 Laminas 项(尽管这样的努力需要提供超出官方 SDK 库的特性)。

此外,我们决定弃用其他一些小型包,这些包没有被我们提供的其他组件所使用的。包括:

让一切都运转其他

所有组件的桥梁

最终结果是 laminas-zendframework-bridge 组件是所有迁移过的组件所需要的

首先,我们需要提供一个兼容层,允许第三方组件同时与 Zend Framework 和 Laminas 组件一起工作。我们的目标是,切换到 Laminas 不需要在第三方库中引入破坏性变更。这有几个挑战:

1.如果请求的 Zend Framework 类不存在,则加载适当的 Laminas 类。这很容易:我们通过创建一个自动加载器来实现这一点,该自动加载器可以动态更改命名空间,同时使用 class_alias 为遗留类创建一个别名。这种方法确保了对同一类的后续请求使用 Laminas 替换。

不过,类型提示所引用的类不会触发自动加载,这就产生了第二个挑战。

2.确保历史遗留类的类型提示与 Laminas 替代类一起使用。例如,可以考虑以下代码:

namespace ThirdParty\Component;

use Zend\ServiceManager\ServiceLocatorInterface;

interface MyInterface
{
    public function run(ServiceLocatorInterface $sm);
}

我们希望确保在安装替代 ZF 组件的 Laminas 组件时,此类代码能够正常工作。为了实现这一点,我们创建了一个额外的自动加载器,每当自动加载 Laminas 类时,它都会创建一个与 Laminas 等价的遗留类的 class_alias

还有额外的困难:我们有一些名称中带有 “Zend” 的集成类(例如,LaminasRouter)。我们能够通过包含类映射来解决这些问题,以便在使用上述自动加载器解析类时使用。

自定义功能

在我们发布的一些库中,我们定义了命名空间函数。这些带来了一个问题,因为函数没有等价的 class_alias。我们需要将以前的函数保留在其旧名称空间中,并将其委托给新名称空间中的等效函数。

为了实现这一点,该工具使用后缀 .legacy.php 复制每个函数文(例如,normalize_server.php 将被复制到normalize_server.legacy.php),我们使用该文件中的旧名称空间,并将函数修改为代理到新名称空间中的函数。然后,我们将这些遗留功能文件添加到自动加载器中,作为对现有功能文件的添加。这种方法允许将遗留功能与新版本并行使用。

更复杂的例子,比如:

查看转换工具代码: FunctionAliasFixture.

自定义常量

与命名空间函数类似,我们遇到了命名空间常量的问题。幸运的是,这些问题的解决方案与函数的解决方案相同。

例如,请参阅 mezzio/mezzio 包中的 constants.phpconstants.legacy.php

查看转换工具代码: NamespacedConstantFixture.

容器 / Service Manager 键

许多组件为 DI 容器(即服务管理器 Service Manager 或者 PSR-11 容器)提供了配置。这便允许使用类似于这样的代码检索服务:

$serviceManager->get(ClassName::class);

或者:

$container->get(ClassName::class);

有趣的是,::class 不会触发自动加载;甚至更糟,::class 之前的类名甚至可以不存在。PHP 根据当前命名空间扩展该字符串,并将其导入,而并不验证其是否存在。

我们想要完成的是:

$container->get(\Zend\ClassName::class);

和:

$container->get(\Laminas\ClassName::class);

产生通用的结果;完全相同的实例,而不只是一个新实例。

为什么呢?因为用户可能将它们的项目迁移到 Laminas,有些第三方库可能仍然使用历史名称。

如果稍微了解过我们的服务管理器配置,该方案相对简单:你提供别名(aliases)匹配它们的 Laminas 等效历史类。

不过,它在实践中并不那么简单。容器配置可以来自于来自各种来源,甚至是给定的包:

我们需要确保这些依赖配置位置中的每一个都会被重写。

委托人工厂

不幸的是,我们无法对委托人工厂配置进行同样的操作。删除程序必须在原始类上定义,而不是在别名上定义。

如果库为 \Zend\ClassName 提供了一个委托程序,但你使用的是 \Laminas\ClassName,则不会触发旧的委托程序。你需要更新自己的配置才能添加它。

查看转换工具代码: DIAliasFixture.

插件管理器

与前一点相关,许多组件都提供插件管理器,你的代码或第三方库也可以为其提供配置。配置是相同的,但在不同的位置执行。更困难的是,提供插件管理器的组件通常直接在插件管理器定义中定义插件管理器配置。

我们的解决方案是在重写过程中更改插件管理器类,将遗留的 ZF 类别名为其 Laminas 等效类。幸运的是,这样做可以让它们在没有任何进一步更改的情况下工作!

例如,你可以在 FilterPluginManager 中检查其他别名。

给这个场景增加困难的是,插件管理器定义从第一次引入到最新版本都发生了变化,特别是当它们被更新为针对我们的服务管理器版本 3 时。我们的工具必须适应这些变化!

查看转换工具代码: PluginManagerFixture.

工厂

下一个挑战是工厂类。许多组件为与服务管理器一起使用的服务工厂。这些工厂通常使用 DI 容器中配置的其他服务,以及配置服务本身。参考以下示例:

class ExampleFactory
{
    public function __invoke(ContainerInterface $container)
    {
        $otherService = $container->get(\Zend\OtherService::class);

        return new Example($otherService);
    }
}

虽然我们提供别名的工作意味着这些代码应该继续工作,但有一个问题:解析别名是服务管理器执行的最慢的操作。因此,我们的迁移工具重写了这些引用:

class ExampleFactory
{
    public function __invoke(ContainerInterface $container)
    {
        $otherService = $container->get(\Laminas\OtherService::class);

        return new Example($otherService);
    }
}

不过,更复杂的场景呢?像这样:

class ExampleFactory
{
    public function __invoke(ContainerInterface $container)
    {
        if (! $container->has(\Zend\OtherService::class)) {
            throw new MissingDependencyException();
        }

        return new Example($container->get(\Zend\OtherService::class));
    }
}

在这里,我们还希望能够使用等效的 Laminas 服务(如果已定义),如果未定义,则返回到遗留服务。因此,重写工具产生:

class ExampleFactory
{
    public function __invoke(ContainerInterface $container)
    {
        if (! $container->has(\Laminas\OtherService::class)
            && ! $container->has(\Zend\OtherService::class)
        ) {
            throw new MissingDependencyException();
        }

        return new Example(
            $container->has(\Laminas\OtherService::class)
                ? $container->get(\Laminas\OtherService::class)
                : $container->get(\Zend\OtherService::class)
        );
    }
}

我们有了更为复杂的示例;请查阅 SwooleRequestHandlerRunnerFactoryHalResponseFactoryFactory 查看其复杂度。

虽然我们不喜欢嵌套式三元表达式,但在许多情况下,这是实现我们目标的最一致的方式。

查看转换工具代码: LegacyFactoriesFixture.

配置键 - 配置 Post 处理器及配置合并监听器

许多工厂也使用和引用特定的配置。通常组件提供默认配置,用户必须根据具体情况进行调整。默认配置通常在以组件本身命名的键名下提供。例如:

// Default module configuration:
return [
    'zend-expressive-hal' => [
        'metadata-factories' => [
            ResourceMetadata::class => ResourceMetadataFactory::class,
        ],
    ],
];
// Custom user configuration:
return [
    'zend-expressive-hal' => [
        'metadata-factories' => [
            CustomCollectionMetadata::class => CustomCollectionMetadataFactory::class,
        ],
    ],
];

使用以上配置的工厂大致如下:

class MetadataMapFactory
{
    public function __invoke(ContainerInterface $container)
    {
        $config = $container->has('config') ? $container->get('config') : [];

        $metadataMapConfig = $config[\Zend\Expressive\Hal\MetadataMap::class] ?? [];
        $metadataFactories = $config['zend-expressive-hal']['metadata-factories'] ?? [];

        return new \Zend\Expressive\Hal\MetadataMap($metadataMapConfig, $metadataFactories);
    }
}

如你所见,我们希望秀给配置中的两个字符串:\Zend\Expressive\Hal\MetadataMap::classzend-expressive-hal

问题是:我们可以在工厂中重命名它们,但第三方或使用遗留密钥的应用程序提供的所有配置都将被忽略。

为了解决这个问题,我们为 Mezzio 应用引入了配置 Post 处理器(Config Post Processor ),并为 MVC 应用引入配置合并监听器(Configuration merge listener)。

在底层,它们都做了同样的事:它们解析遗留的配置键并将该值与新键提供的默认值进行合并。

结果是组件配置可以引用新键:

return [
    'mezzio-hal' => [
        'metadata-factories' => [
            ResourceMetadata::class => ResourceMetadataFactory::class,
        ],
    ],
];

而工厂只能引用新键:

class MetadataMapFactory
{
    public function __invoke(ContainerInterface $container)
    {
        $config = $container->has('config') ? $container->get('config') : [];

        $metadataMapConfig = $config[\Mezzio\Hal\MetadataMap::class] ?? [];
        $metadataFactories = $config['mezzio-hal']['metadata-factories'] ?? [];

        return new \Mezzio\Hal\MetadataMap($metadataMapConfig, $metadataFactories);
    }
}

当配置 Post 处理器正在运行时,引用旧密钥的第三方配置将它自己的配置合并到新密钥下,从而保持向后兼容性。

虽然配置后处理是有效的,但它高度依赖于应用特定配置最后合并的想法。因此,我们建议第三方库提供商更新其库。然而,在此之前,配置后处理器提供了一种不会导致破坏性更改的解决方案。

在中间件中自定义请求属性

使用 PSR-15 中间件(比如 Mezzio:前身为 Expressive)时,我们使用请求属性在中间件之间传递参数。我们已经对这些属性使用类名进行标准化,这引发了另一个迁移问题。比如,假设有以下路由中间件:

class RouteMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface {
        $result = $this->router->match($request);

        $request = $request->withAttribute(
            \Zend\Expressive\Router\RouteResult::class,
            $result
        );

        return $handler->handle($request);
    }
}

该中间件诸如了一个包含路由结果的属性,这样用户可以在稍后访问它们。这也意味着用户使用旧的类名Zend\Expressive\Router\RouteResult,以检索这些值。

为提供向后兼容性,我们决定注入两个属性,一个在当前类名之下,一个在旧类名之下:

class RouteMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface {
        $result = $this->router->match($request);

        $request = $request
            ->withAttribute(\Mezzio\Router\RouteResult::class, $result)
            ->withAttribute(\Zend\Expressive\Router\RouteResult::class, $result);

        return $handler->handle($request);
    }
}

这允许你使用任何一个名称,确保你的代码或者第三方代码可以无需更改继续生效。

查看转换工具代码: MiddlewareAttributesFixture.

使用 "Zend" 作为名称的方法

在一些地方,我们定义了包含单词 “Zend” 的方法名称。我们在这里提供了一个模拟命名空间函数的解决方案:我们使用名称中的 “Laminas” 重命名现有函数,然后使用代理原始函数的旧名称添加一个新函数。比如,你可以查看 Psr7Response 类。

查看转换工具代码: SourceFixture.

仍未囊括一切

以上部分详细介绍了我们在多个存储库中遇到的各种常见问题。不幸的是,我们遇到了许多边缘情况,最终得到了 30 个组件的自定义规则。
一个值得注意的例子是:我们需要保留对 Zend Server 产品的所有引用,但更改对 zend-server 组件的引用(这与 Zend Server 的产品无关)。有关示例,请参阅 ZendServerDisk 类和 ZendMonitor 类。

另一个例子是,“Zend” 或 “Expressive” 在几个第三方库中被用作下级命名空间(请参阅 container-auryn),我们需要确保这些名称保持不变。

如何测试

在上线之前,我们需要测试一切是否正常。我们在多个库上尝试了重写工具,它们看起来很好,但我们需要比查看代码更有用的东西。

重写 vendor 中的包

我们采用的第一种方法是重写基于 ZF 的项目的 vendor/ 目录下的所有包。我们在我们的迁移工具中添加了一个命令来做到这一点,并立即取得了一些成功。这种方法让我们相信,我们所写的很可能会奏效。

然而,问题是,它不允许我们测试每一个可能的组件或组合。我们需要更强有力的东西。

查看转换工具代码: VendorCommand.

本地测试

第二个想法是本地重写所有主键,为它们创建本地 Composer 仓库,并将仓库添加到每个组件,然后安装依赖并为每个组件运行测试。

这很有效,并给我们提供了很多有用信息。它帮我们找到一些失败的情况,但我们仍然感觉不够充分:我们通常只针对一个 PHP 版本进行测试;我们只是针对最新版本的依赖项进行测试;存在多种配置问题,由于缺少依赖项、扩展和/或服务(例如 mongodb、数据库、swoole 等),我们无法运行所有测试。

查看转换工具代码: LocalTestCommand.

Composer 仓库

我们所做的下一件事是重写我们计划迁移的所有组件的所有标签,并使用这些标签通过 Satis 创建一个合适的 Composer 存储库。这将允许我们通过将存储库添加到包配置中,针对任何可用版本测试任何项目或库。

在这一点上,我们向公众开放了测试,并请求社区提供帮助。我们发布了 Laminas 迁移工具的第一个版本,并开始测试基于 ZF 的项目。
在这一阶段,我们发现并解决了一些边缘案例,否则我们永远不会发现。
但这仍然不够。

在每个组件上运行单元测试

作为最后的努力,我们决定对每个组件进行完全持续地整合。

(你可能会认为我们应该从这个开始,但你错了!在我们有了公共 Composer 存储库之前,我们无法采取这一步骤。)

为此,我们为每个项目创建了测试组织,将所有组件推送给这些组织,并在每个项目上启用 Travis CI

我们修改了配置,也在源代码上运行 php linter,因为我们的重写工具大量使用正则表达式。这有助于识别一些重写的边缘案例,但也报告了许多误报。作为误报的一个例子,我们有许多类仅在特定的 PHP 版本下启用。我们也有以 PHP 关键字命名的类,这些关键字后来被保留;在这些情况下,我们有替换,但 linter 会将遗留类标记为无效。

我们预计这一阶段将需要相当长的时间,因为开源 Travis CI 限制了任何给定帐户可以运行的并行操作的数量。考虑到我们在 PHP 5.6、7.0、7.1、7.2 和 7.3 中的每一个上测试了 150 多个存储库(还有 7.4 上的一些!),每个存储库都针对最低和最新的依赖项,因此作业数量巨大!事实上,这比预期的要快得多,但尽管如此,我们经常在白天解决问题,让测试在夜间运行。

这种方法使我们能够识别大量问题,在这个过程中,我们多次重建存储库和 Composer 存储库,直到我们对结果感到满意。最后,我们剩下的问题是由于重命名的类和/或配置而需要更改的测试期望值,以及除了 Travis CI 平台本身之外,我们无法在其他地方复现的一些小问题。

并非一切都是完美的

当我们不断改进工具时,我们知道它永远不可能是完美的,而且在迁移结束后,我们会有问题需要解决。

例如,我们在测试中放弃了代码样式检查,因为其中许多检查由于行的长度而失败。

我们无法重写图像。此时,重新生成所有图像以保留对新库的引用是不可能的,所以我们决定将其作为“迁移后”手动操作。

尽管如此,我们还是利用迁移工具来解决一些长期存在的问题。要列出一些:

  1. 按字母顺序排列导入语句(由于名称空间发生了变化,我们希望保持字母顺序)。我们能够通过重写的 PHP 文件上的 PHP 代码嗅探器来做到这一点。
  2. 在一些旧版本的包中,我们使用分组导入语句,因此我们运行另一个 CS 修复程序来拆分它们。
  3. 版权标题。我们已经完全更改了所有文件的版权标题。以前,我们将版权年份作为标题的一部分,但它更新后不一致(我们有一些应该更新年份的规则,但经常忘记更新)。现在,我们有了一个更简单的版权头,其中引用了存储库中的其他文件(LICENSE.mdCOPYRIGHT.md)。
  4. 更新间距以遵循 PSR-12。由于 PSR-12 编码标准已经获得批准,我们决定在开始的 <?php 标记给添加一个空白行,这样我们以后就不需要再这样做了。
  5. 多种 QA 统一和改进:Travis CI 配置、.gitattributes.gitignore、PHPUnit 和 PHPSpec 配置中的条目等都保持一致。
  6. 文档及其配置统一。一些文档位于遗留的 doc/ 子目录下,而其他文档则使用 docs/(这也是 GitHub 社区支持文档的推荐路径)。用于控制文档呈现方式的  mkdocs.yml 文件也随着时间的推移而更新。我们利用这个机会使这些内容在所有存储库中保持一致。
  7. 针对拉取请求和问题的一致 Github 模板。事实上,我们最终将它们转移到组织级的 .github 存储库中,这样它们就可以一次更新,而不必在所有存储库中进行更新。
  8. 已更新所有支持文件(例如,CONTRIBUTING.mdCODE_OF_CONDUCT.mdSUPPORT.md)。

最后:过渡

最后,在第一次发布一年多后,经过 10 个月的转移工具开发,我们决定准备推出 Laminas,并选择在 2019 年的最后一天推出。

之前的几天和几周,通常是我们大多数人的假期,都在打磨和准备转移工具。我们甚至有一些“最后一分钟的变化”,这让我们感到惊讶,使我们无法尽早开始,但我们设法在 UTC 时间中午左右迁移了所有内容。

我们知道并非每件事都是完美的,还有很多事情要做,但我们成功地实现了我们的承诺:我们否决并归档了所有 Zend Framework 存储库,并在三个全新的组织下创建了所有新组件:Laminas、Mezzio 和 Laminas API Tools。
是的:并非每件事都是完美的

在完成迁移后不久,我们收到了有关问题的用户报告。

第一个也是最严重的问题是命名空间函数。在调用新变体时,我们不知何故遗落了在遗留函数中包含 “return” 语句。

因此,我们不得不为以下内容发布补丁版本:

因此,我们不得不为此发布补丁版本:

我们同时注意到一些版本的 laminas-view 没有正确转换(2.24 到 2.53 之间的所有标签),所以我们也为此发布了补丁版本。

Laminas API Tool 框架应用注册了一个错误的模块(由于将 ZendDeveloperTools 重命名为 Laminas\DeveloperTools),而它很难打补丁。

补丁版本

上述仓库我们使用  p1 后缀发布了新标签,以表示其是”补丁“版本。在执行 composer update 更新操作时,Composer 会更喜欢这些,而不是它们修补的标签。

谢天谢地,这就是一切。自 1 月下旬以来,我们没有收到任何阻塞问题的报告,同时我们继续收到成功迁移的报告。

在过去的两个月里,我们还看到许多第三方存储库迁移到 Laminas。

如果你仍然没有更新你的应用,或者你公司仍在使用 Zend Framework 组件,我们建议你进行迁移,以便继续获得安全更新。请参阅我们的迁移指南

下一步?

我们召开了第一次技术指导委员会会议,并开始计划如何维护和扩大项目。

原文地址:https://getlaminas.org/blog/2020-03-09-transferring-zf-to-laminas.html

 

PHP