今天我们完成了ZendFramework3的主要目标之一:将各种组件拆分到它们自己的存储库中。这被证明是一个巨大的挑战,因为我们存储库中的历史数量(git存储库的历史可以追溯到2009年,大约在ZF1.8发布的时候!),以及我们对组件存储库应该是什么样子的目标。这是我们如何实现它的故事。
为什么要拆分它们?
“但是您已经可以单独安装组件了!”
是的,但如果您知道这是怎么发生的,您就会畏缩。我们尝试了多种解决方案,但每一个都在某些时候让我们失望了,通常是在我们迁移到框架的新次要版本时,但有时甚至是在微不足道的错误修复版本上。我们尝试了filter-branch
和subdirectory-filter
,我们尝试了subtreesplit
,甚至subsplit。我们使用手动脚本同步每个提交的内容并创建一个参考提交。我们当前的版本是多种方法的组合,但是我们发现我们必须手动运行它并在推送之前验证结果,因为我们遇到过很多情况,最近在2.4.0版本中,内容不正确。
除了所有这些之外,还有另一个问题:为什么所有组件的版本都会发生变化,即使没有任何变化?例如,自2.0版本以来,许多组件的新特性为零;它们要么稳定,要么用户群较小。提升他们的版本没有意义,但无论何时我们发布新的框架,他们都会被提升。当我们开始考虑框架的新主要版本时,增加这些组件不一定有意义,因为实际上会有零破坏性更改,而且在许多情况下,没有新功能。
在其他情况下,例如EventManager
、ServiceManager
和一些其他组件,我们知道由于必要的架构更改,这些将需要主要版本。但是,只要我们仍在开发框架的次要版本分支,我们就无法对这些功能进行有意义的开发,因为在分支之间保持更改同步很复杂。
简而言之,我们希望能够在各自的周期内分别对各个组件进行版本控制。
最重要的是,当我们考虑维护时,拥有一个单一的存储库会带来挑战:我们必须限制拥有提交权限的开发人员的数量,以确保那些可以提交的人意识到影响整个框架可能会发生变化。这意味着许多有时间和精力花在改进单个组件或组件的一小部分子集上的开发人员会受到维护人员审查他们的更改的速度的阻碍。
拆分组件使我们有机会扩大具有提交权限的贡献者的数量。框架本身可以固定到组件的特定版本,拥有框架提交权限的维护人员可以根据集成和冒烟测试审查和更改这些版本。与此同时,更多的贡献者可以逐步改进各个组件,用户可以根据自己的审核周期有选择地将这些新版本采用到他们的应用程序中。
最后:
- 我们获得了正确遵循语义版本控制的组件。
- 我们加速了需要它的组件的开发。
- 我们增加了活跃的、有能力的维护者的数量。
- 我们使用户能够按照自己的节奏采用新功能。
- 我们保持框架的稳定性。
目标
自从我们分支ZF2开发以来,我们的存储库看起来像以下内容:
.coveralls.yml .gitattributes .gitignore .php_cs .travis.yml bin/ CHANGELOG.md composer.json CONTRIBUTING.md demos/ INSTALL.md library/ Zend/ {component directories} LICENSE.txt README-GIT.md README.md resources/ tests/ _autoload.php Bootstrap.php phpunit.xml.dist run-tests.php run-tests.sh TestConfiguration.php.dist TestConfiguration.php.travis ZendTest/ {component directories}
结构遵循PSR-0,每个组件都在library/Zend/
目录下。
目标是拥有单独的组件存储库,每个组件存储库具有以下结构:
.coveralls.yml .gitattributes .gitignore .php_cs .travis.yml composer.json CONTRIBUTING.md src/ LICENSE.txt phpunit.xml.dist phpunit.xml.travis README.md test/ bootstrap.php {component test cases}
在上面的结构中,注意以下区别:
- 源代码和单元测试文件现在遵循PSR-4,并且可以直接在新的
src/
和test/
目录下找到(它们取代了library/
和tests/
),没有任何基于命名空间的目录嵌套(除非存在任何子命名空间)。 README.md
文件将需要特定于组件。此外,它可以合并原来在INSTALL.md
文件中的内容。composer.json
文件将需要用于组件,而不是框架。此外,我们目前没有在我们的组件存储库中列出开发/测试依赖项,因此需要添加这些依赖项。TestConfiguration.php.*
文件定义了由单元测试;这些可以迁移到phpunit.xml.*
文件——我们可以将其移至项目根目录以简化测试。.travis.yml
可以简化文件,因为我们现在只测试一个组件。- 可以删除大多数测试基础结构,因为它围绕着简化在更大框架内对单个组件运行测试。
Bootstrap.php
重命名为bootstrap.php
以避免与单元测试文件混淆。 README-GIT.md
被更长的CONTRIBUTING.md
文件取代。
除此之外,我们还有以下要求:
- 组件必须具有从2.0.0rc7开始的完整历史记录。这是为了让那些在组件上工作的人可以看到提交背后的原因和谁。
- 提交消息必须引用原始问题并在ZF2存储库上拉取请求;同样,这是为了便于了解更改背后的原因。
- 理想情况下,历史应该仅包含给定组件的历史。
- 每个提交中的目录结构,包括(尤其是!)标签,必须遵循建议的结构。
我们如何到达那里
使用Git的巨大好处之一是能够重写历史。(这也是它最可怕的功能之一。)它提供了许多这样做的工具,从rebase
到grafts到subtree
到filter-branch
。在我们的组件拆分研究中,我们评估了几种解决方案。
嫁接
移植提供了一种将两条不同的历史线合并在一起的方法,但是,出于我们的目的,还允许我们修剪历史。我们为什么要这样做?因为此时我们真的不需要2.0.0开发之前的历史。在很大程度上,这是因为它无关紧要;在1.X树和2.0之间的分支之间,文件被移动和更改太多,以至于很难追踪历史。
我最终找到了一种修剪方法,如下所示:
$ echo bb50be26b24a9e0e62a8f4abecce53259d707b61 > .git/info/grafts $ git filter-branch --tag-name-filter cat -- --all $ git reflog expire --expire=now --all $ git gc --prune=now --aggressive $ rm .git/info/grafts
它应该基本上删除给定sha1之前的历史记录。我发现,就其本身而言,我注意到存储库几乎没有变化,除了大小;我仍然可以达到更早的提交。然而,当与我们使用的最终技术相结合时,这意味着在这一点之前我们实际上没有看到任何提交。
子树
gitsubtree
是一个“贡献的”git命令;它在git的默认发行版中不可用,但通常作为附加包提供;如果您从源代码安装git,它位于contrib
树中,您可以在其中编译和安装它。子树围绕处理存储库子树提供了一组丰富的功能,允许您将它们拆分、添加来自其他项目的子树,甚至在它们之间来回推送提交。
乍一看,这似乎是一个理想、简单的解决方案:
- 将每个
library/
和tests/
组件子树拆分到它们自己的分支中。 - 创建一个新的存储库,并添加以上每一个作为子树。
$ git clone zendframework/zf2 $ git init zend-http $ cd zf2 $ git subtree split --prefix=library/Zend/Http -b src $ git subtree split --prefix=tests/ZendTest/Http -b test $ cd ../zend-http $ # add in basic assets, and create initial commit $ git remote add zf2 ../zf2 $ git subtree add --prefix=src/ zf2 src $ git subtree add --prefix=test/ zf2 test
确实,如果您执行上述操作,完成后目录看起来完全像它应该的那样!然而,历史全错了;如果你签出任何标签,你会得到标签的完整ZF2树。因此,子树立即未能满足最重要的标准之一:每个提交和标记仅代表组件。
子目录过滤器
subdirectory-filter
是gitfilter-branch
策略之一。它的操作类似于subtree
,但也会重写历史。我们在第一个ZF2稳定版本之前从主存储库拆分各种“服务”(API包装器)组件时使用了这种方法。
基本思路和subtree
类似;不同之处在于,您必须首先对每个源代码和测试进行单独检查。
$ git clone zendframework/zf2 zend-http-src $ git clone zendframework/zf2 zend-http-test $ cd zend-http-src $ git filter-branch --subdirectory-filter library/Zend/Http --tag-name-filter cat -- -all $ cd ../zend-http-test $ git filter-branch --subdirectory-filter tests/ZendTest/Http --tag-name-filter cat -- -all $ cd .. $ git init zend-http $ cd zend-http # add in basic assets, and create initial commit $ git remote add -f src ../zend-http-src $ git remote add -f test ../zend-http-test $ git merge -s ours --no-commit src/master $ git read-tree -u --prefix=src/ src/master $ git commit -m 'Merging src tree' $ git merge -s ours --no-commit test/master $ git read-tree -u --prefix=test/ test/master $ git commit -m 'Merging test tree'
同样,乍一看这看起来很棒;给定组件的所有内容都被完美重写。但是当你开始查看以前的标签和提交时,你会看到一个有趣的画面:基于提交和你首先添加的远程,你会看到一个完全不同的目录结构。像subtree
,这不符合我们的标准回购在任何给定的提交中都处于可用状态。
树过滤器
和subdirectory-filter
一样,tree-filter
是一个filter-branch
策略。tree-filter
允许您可以以任何您想要的方式重写树的内容,同时保留提交消息和元数据。这正是我们要找的!
但是,我们还需要解决一些问题:
- 重写引用问题和拉取请求以链接到主ZF2存储库的提交消息。
- 修剪空提交。
- 确保标签包含预期的树。
幸运的是,filter-branch
有其他策略可用于这些目的:
msg-filter
允许您重写提交消息。commit-filter
提供用于检测和删除空提交的工具。tag-name-filter
确保在父提交更改或删除时重写标记引用。
所以,我们最终得到的结果如下:
git filter-branch -f \ --tree-filter "php /path/to/tree-filter.php" \ --msg-filter "sed -re 's/(^|[^a-zA-Z])(\#[1-9][0-9]*)/zendframework\/zf2/g'" \ --commit-filter 'git_commit_non_empty_tree "$@"' \ --tag-name-filter cat \ -- --all
/path/to/tree-filter.php
是一个脚本,包含重新安排目录结构的逻辑,以及根据需要重写文件内容(例如,重写composer.json
,或者在CONTRIBUTING.md
中填写组件名称)。msg-filter
查找问题和拉取请求标识符(一个#
字符后跟一个或多个数字),并重写它们以引用存储库。commit-filter
检查存储库内容是否在这次提交中发生了变化,如果没有,则指示git
忽略提交(并且,因为tree-filter
总是在commit-filter
之前执行,比较总是在重写的树之间)。tag-name-filter
必须存在,并且基本上只是确保标签被重写;不存在则不重写标签,以原内容为准!
绊脚石
我们在实现上述功能时遇到了一些障碍。首先是,出于测试目的,我们必须指定一个提交范围,而不是----all
。这是必要的,因为repo的大小;在大约27k次提交时,运行每一次提交可能需要5到12个小时,具体取决于git版本、HDD与ramdisk、I/O速度等。对于小的子集,我们可以获得一致的结果。当我们扩大范围时,我们开始看到奇怪的错误,例如一些标签没有被写入。
使情况更加复杂的是,我们还在最后一刻进行了更改,仅从2.0.0rc7标记开始执行历史记录,这就是事情完全崩溃的时候。大量标签不会被重写,组件之间的格式错误标签集各不相同,我们无法弄清楚原因。
在某一时刻,我想起了git
将提交存储为树,那时我才意识到发生了什么:当我们指定一个提交范围时,我们实际上是通过提交。如果在该路径之外的分支上创建了标记,则不会重写它。
这意味着获得符合我们标准的一致结果的唯一方法是对整个历史运行测试。幸运的是,在那个时候,社区成员Renato建议我尝试使用tmpfs文件系统运行——本质上是一个ramdisk。这将运行速度提高了2倍,我能够在一个晚上内验证我的假设。
另一个绊脚石是空提交。我们最初使用filter-branch
的--prune-empty
开关,但发现与tree-filter
一起使用时通常不可靠。这个问题的解决方案是上面列出的commit-filter
;它做得非常出色。
空合并提交
然而,有一个挥之不去的问题:在检查过滤存储库时,我们仍然有大量与组件无关的空合并提交。经过大量搜索,我找到了这个gem:
$ git filter-branch -f \ > --commit-filter ' > if [ z$1 = z`git rev-parse $3^{tree}` ];then > skip_commit "$@"; > else > git commit-tree "$@"; > fi' \ > --tag-name-filter cat -- --all $ git reflog expire --expire=now --all $ git gc --prune=now --aggressive
上面使用了一个commit-filter
,它在内部使用rev-parse
来确定提交是否是一个合并以及两个父项都存在于存储库中;如果不是,它跳过(删除)提交。reflogexpire
和gc
命令清除并删除存储库中现在不再可访问的所有对象。
最终解决方案
有了有效的graft
、tree-filter
和commit-filter
,我们终于可以继续了。我们创建了一个存储库,其中包含我们需要的所有脚本,以及重写组件存储库树所需的资产。然后我们有了一个可以简单地执行的工具:
$ ./bin/split.sh -c Authentication 2>&1 | tee authentication.log
这样,我们就可以坐下来观察组件被拆分,并在完成后推送结果。
您可以在我们的组件拆分存储库中查看工作。
但是速度呢?
“但是你不是说运行每个组件需要5到12个小时吗?难道不是有50个组件吗?那需要数周时间!”
你真聪明!为此,我们有一个秘密武器:社区贡献者GianlucaArbezzano为AWS合作伙伴Corley工作,Corley发起同时并行拆分所有组件,使我们能够在一天内完成整个工作。不过,我会让其他人讲述这个故事!
结果
我对结果非常满意。ZF2存储库有约27k次提交、67次发布和超过700名贡献者;干净的结账大约是150MB。作为对比,重写的zend-http
组件存储库最终有~1.7k次提交、50次发布、~160位贡献者和5.4MB的干净签出时钟!所以各个组件都非常精简!此外,它们包含开始开发所需的所有QA工具,供那些想要修补问题或创建功能的人使用,从而使开发过程更简单。
经验教训:
tree-filter
是您的朋友,如果您的重构涉及多个目录和/或添加或删除文件。tag-name-filter
必须在您使用filter-branch
时使用;否则你的标签可能最终无效!filter-branch
应该谨慎地在范围上使用,理想情况下只有当你不担心标签时。在大多数情况下,您希望遍历整个历史记录。commit-filter
是确保去除任何类型的空提交的最佳选择,尤其是在您使用树过滤器
;--prune-empty
标志不是非常可靠。- 始终进行完整的测试运行。使用提交范围来验证您的过滤器是否有效是很诱人的,但结果将不同于运行整个历史记录。这导致:
- 安排充足的时间,尤其是当您的存储库很大时。那些完整的测试运行需要时间,而且,如果您遵循科学流程并一次进行一个更改,您可能需要多次迭代才能使您的脚本正确。
总而言之,这是一项压力大、耗时且吃力不讨好的任务。但我对结果很满意;我们的组件看起来就像是并且始终被开发为一流的组件,并且具有丰富的历史,将它们的原始开发作为包含框架的一部分。
荣誉!
我对Gianluca和Corley的慷慨努力感激不尽!看起来需要几天和/或几周才能完成的任务实际上是在一夜之间完成的,这使我们能够完成ZendFramework3开发中的一项主要任务,并为大量新功能奠定了基础。谢谢!