Phorge Files模块无侵入拆包实录:Composer自动加载与类映射排雷
2026/9/19 9:50:41 网站建设 项目流程

这个系列写到第四篇,前面的路基本是给 Phorge 换骨架、改门面,这次的活儿说实话更绕——不是加功能,而是把一个模块从 Phorge 母体里“摘”出去,放进我们内部叫 Gorge 的代码库里。标题里那句“无侵入改造不等于不碰文件”,就是我做完以后最想纠正的一个认知偏差。

这次拆的是 Files 文件存储模块。Gorge 是我们维护公共 PHP 库用的一个仓库组织名,里面专门放那些“多个系统都要用、但不值得重复造轮子”的代码。整个改造过程从评估、迁移、适配到验证花了大概两个周末,中间踩了不少坑,尤其是 autoload 顺序和类映射缓存这两个问题,差点让我怀疑人生。这篇文章把完整思路、实操步骤和排错记录都写出来,给正在折腾 Phorge 二次开发、或者想把单体里某个横切能力抽成独立包的朋友做参考。

1. 这次改造要解决什么问题

1.1 为什么要把 Files 模块拆出去

Phorge 本身是个典型单体,应用之间靠类名互相引用,模块边界说清晰也清晰,说模糊也模糊。Files 应用看起来和别的应用挨得很近,比如 Differential 要贴补丁、Phame 要传封面图、Maniphest 要挂附件,几乎所有业务都会碰它,但它本身做的事情其实非常“工具化”:存字节、取字节、判断有没有权限、生成访问 URI。这种横切能力放在单体里没问题,可一旦有第二个系统也想用同一套逻辑,问题就来了。

我们团队有一个内部告警通知平台,也需要上传截图和导出报表文件。以前的做法是从 Phorge 里复制一份代码过去,再改配置、改命名空间,当时觉得省事,后面维护才知道痛:Phorge 升级修了存储层的 bug,告警平台还是老代码,同一个存储路径两套逻辑,排查问题的时候经常对不上号。与其这样,不如把 Files 抽成一个独立包放进 Gorge,让两个系统通过 Composer 依赖同一个版本,谁升级大家都升。

选 Files 而不是别的模块,有三个理由。第一,它是横切能力,和具体业务界面绑定弱,核心是一套“字节存取”接口,天然适合独立成包。第二,它要对接外部存储层(本地磁盘、S3 之类),这部分代码放哪都一样,拆出去不会影响 Phorge 的业务流程。第三,它的对外 API 相对稳定,创建、读取、删除、生成 URI 就那几个方法,兼容层可控。

1.2 “无侵入改造”到底是什么

拆之前被问得最多的一个问题是:能不能做到完全不碰 Phorge 的文件?我的回答很直接:做不到,也不该追求这个。无侵入改造的本质是外部契约和运行行为不变,不是 git diff 为零。

我给自己定了两个清单。绝对不动的:数据库表结构、HTTP 接口路径、对外行为表现、配置项名称、权限规则。这些是用户和下游系统能感知到的东西,改任何一项都属于破坏性变更,必须零改动。允许动的:源码目录里的实现类、自动加载配置、一个轻量应用入口类。这些属于内部装配层,改了外部无感。

这个道理有点像家里换配电箱:墙要开洞、线要重接,但外面看灯泡还是那个灯泡,插座还是那个插座。判断无侵入的唯一标准,就是改造前后把所有操作在浏览器和 API 层完整走一遍,行为完全一致,数据库不需要任何迁移脚本;至于内部文件动了多少,根本不重要。明确这一点之后,后面动起文件来反而踏实。

2. 动手之前的准备,比迁移本身更花时间

2.1 用脚本把依赖关系摸清楚

第一步不是建目录,而是搞清楚 Files 到底被谁引用。单体项目里类名满天飞,凭感觉圈边界必漏。我用一个简单脚本扫描了一遍:

cd /path/to/phorge grep -rn "PhabricatorFiles" src/ --include="*.php" -l | sort | uniq > /tmp/files_refs.txt wc -l /tmp/files_refs.txt grep -rhn "PhabricatorFiles[A-Za-z_]*" src/ --include="*.php" -o | sort | uniq -c | sort -rn | head -30

第一行拿到所有直接引用了 Files 命名空间的文件列表,第二行统计每个类被引用的次数。这个结果非常关键,它能告诉你哪些类属于“高频外部依赖”,哪些类只是内部自嗨。我这次盘点出来的结论大概是:外部业务真正高频依赖的类不超过十五个,而且集中在PhabricatorFilePhabricatorFileStorageConfigurationPhabricatorFileInfoFileDeleteController这些固定入口上。这个数字决定了兼容层可以做得很薄,也让我有信心继续往下拆。

2.2 边界怎么切才不破坏兼容

依赖清单一出来,边界就比较好画了。我分了三个层面:

内聚边界:包内只放存储抽象、存储引擎实现、字节流操作、URI 生成这些底层能力。任何和用户、权限策略、项目、事务评论相关的概念,一律不进包。比如PhabricatorFile::attachToObject这种把文件和业务对象绑定的方法,本质上是业务行为,不能跟着字节存取一起下沉,要留在原应用层做适配。

兼容边界:外部引用频率高的类和方法,列成一张白名单。迁移之后,白名单上的类名必须还在原位置能被找到,方法签名不能变。这也就是后来我在原目录里留“壳”的原因。

数据边界:这次拆分不迁表、不建表。Files 的数据表继续归 Phorge 的 storage 管理,新包通过 Phorge 提供的连接读取,包自身不持有任何库表结构。这样从根上避免了“拆完包还得处理数据库迁移”这种风险。

2.3 包结构方案:为什么用 Composer 包而不是 Git Submodule

很多人一听说拆模块,第一反应是建一个 Git Submodule。我劝你慎重。Submodule 的问题是部署时要手动 init、update,版本约束能力基本为零,几个项目很难统一锁同一个版本。Composer 包就顺滑得多:语义化版本、自动加载、和 Phorge 现有的 vendor 体系天然一致。

在项目根目录的composer.json里加一段:

{ "repositories": [ { "type": "vcs", "url": "ssh://git@your-git-host:gorge/files.git" } ], "require": { "gorge/files": "^1.0" } }

这里有个细节要提醒:内部包名如果和 Packagist 上的公开包撞了,Composer 默认会优先从 Packagist 解析,所以要在 repositories 里显式声明 VCS 地址,并且包名不要对外发布。本地联调阶段也可以用"type": "path"指向本地目录,省去每次 push 再 update 的循环。

3. 实操:文件迁移、自动加载和兼容层

3.1 迁移源码并调整命名空间

src/applications/files/src/里的实现类整体搬到gorge/files/src/,命名空间从PhabricatorFiles改成Gorge\Files。这一步看起来机械,实际上有两个难点。

一是类之间的内部引用要批量处理。PHP 工程里大家习惯用use PhabricatorFiles\File;这种写法,迁移后所有use都要跟着改。我靠 IDE 的全局重构做了一遍,然后用 PHPStan 扫了一遍残留引用,确认没有遗漏。

二是这些实现类原本大量依赖 Phorge 基础类,比如PhabricatorUser。包不能反向依赖 Phorge,否则等于没拆。我的处理方式是适度抽象,定义一个UserContext接口,把当前用户信息包装后传入包内,而不是让包直接引用PhabricatorUser。这个接口只暴露 id、username、email 这些基本字段,够用就行,不做过度设计:

<?php namespace Gorge\Files; interface UserContext { public function getUserId(): int; public function getUsername(): string; }

在 Phorge 侧做个适配器,实现这个接口,内部再转调PhabricatorUser。这样包和 Phorge 的唯一联系就剩下“客户端提供一个用户上下文”,依赖方向彻底反转了。

3.2 通过 Composer 接入自动加载

代码搬完,接下来要保证 Phorge 能加载到新包。新版 Phorge 项目本身用 Composer 管理依赖,在根目录执行:

composer update gorge/files

正常来说,vendor/autoload.php里就会有Gorge\\Files命名空间的 PSR-4 映射。但我在这里撞上了一个很刁钻的问题:Phorge 在启动时会注册自己的类加载器,而这个加载器找不到类时会直接抛异常,导致后面的 Composer loader 根本没有执行机会。这也是后面 4.1 节要展开讲的坑。

解决方式是在 Phorge 正式 boot 之前,先主动加载 Composer 的 autoload:

<?php // webroot/index.php 顶部,注意顺序 require __DIR__.'/../vendor/autoload.php'; require __DIR__.'/../src/__phorge_initial__.php';

这一步顺序非常重要。让 Composer 先注册,Phorge 的加载器再注册,两个 loader 形成协作而不是竞争。凡是Gorge\开头的类,Composer 处理;凡是Phabricator\开头的类,Phorge 处理,各管各的一段。

3.3 原应用里保留一个轻量转发层

按“不碰文件”的标准,这一步是最容易被误解的。我在src/applications/files/目录里保留了一个FilesApplication.php,类名、命名空间都不变,但内部实现几乎全部转发到 Gorge 包:

<?php final class PhabricatorFilesApplication extends PhabricatorApplication { public function getName() { return 'Files'; } public function getRoutes() { return (new \Gorge\Files\Routing\FileRoutes())->buildRoutes(); } public function getFileURI($phid) { return \Gorge\Files\UriResolver::resolve($phid); } }

为什么不直接在原目录删干净?因为 Phorge 的应用发现机制依赖类名和文件路径映射。它扫描src/applications/*/下的应用类,如果PhabricatorFilesApplication彻底消失,侧边栏的 Files 入口、路由表、权限配置全部会变成幽灵配置,表现就是应用菜单里 Files 直接消失。保留这个“壳”不是怂,也不是没拆干净,而是为了让外界感知不到迁移发生了。真正的实现已经搬走,这个类只是一个门面。

关键点在于壳里不要堆业务。如果某个功能实现没法通过一行转发解决,说明边界的抽象还没做好,要继续把逻辑收到包内。

3.4 数据库、配置、权限一点没动

这次改造结束后,我特意做了一次差分检查,确认以下这些项在迁移前后完全一致:

检查项改造前改造后是否有变更
数据库表结构files、file_transaction 等表由 Phorge storage 管理表结构一模一样,无迁移脚本
配置项storage.local-disk.path等键名完全一致,新包读取同一个配置
HTTP 接口/file/download/{phid}等路由路由行为一致,转发到新包实现
用户界面上传、列表、预览、删除操作路径一致,前端无感
权限规则基于 Policy 的对象权限权限仍由 Phorge 业务层判断

这里有一个很重要的设计选择:文件权限校验逻辑没有下沉到包里。因为权限属于业务语义,不同系统对“谁能看这个文件”的定义不一样,把它留在 Phorge 层,包只提供“给你一个 token,你把字节取出来”这种底层接口。这样包更容易被其他系统复用,也不会绑架别人的权限模型。

3.5 验证:从单元测试到手工回归

代码迁移完,验证不能省。我做了三层验证。

第一层是 Phorge 自带的单元测试,用arc unit跑 Files 相关用例,重点看上传、元数据读取、URI 生成有没有回归。第二层是我写的一个迁移校验脚本,遍历数据库里所有存量文件记录,逐个从新包拉取字节,对比文件哈希和大小,确保存量数据没有随着代码搬家损坏。第三层是手工回归,在 UI 上完整走一遍上传、列表、预览、删除、下载的流程,顺便把定时任务bin/garbage collect跑一次,确认清理逻辑也正常。

三层都通过之后,才敢在测试环境里正式切换依赖,并用生产数据做只读验证。这里要强调:迁移校验脚本别一跑完就删,后续再动存储层代码的时候,它还能当回归用例使。

4. 常见问题与排查技巧实录

4.1 Class not found:反直觉的 autoload 顺序问题

拆包后第一次访问 Files 页面,白屏,日志里只有一行:

PHP Fatal error: Uncaught Error: Class "Gorge\Files\FileStore" not found

我第一反应是 Composer 没生成对应的 PSR-4 映射,于是立刻检查vendor/composer/autoload_psr4.php,发现Gorge\\Files明明在里面。又手动vendor/bin/composer dump-autoload重新生成一次,还是报错。最后才定位到:Phorge 自己的 autoloader 在spl_autoload_register里排得比 Composer 靠前,遇到Gorge\Files\FileStore这个类,它按照 Phorge 的类名映射表找不到,直接抛异常退出,PHP 的 autoload 机制也就不会继续调用后面的 Composer loader。

解决办法就是 3.2 节说的,在 Phorge 启动之前先 requirevendor/autoload.php,让 Composer 的 loader 先注册。这是整个改造里最容易踩、也最害人的一个坑,因为问题表现在“类找不到”,根子却在一个毫不起眼的加载顺序上。

4.2 拆包后应用在菜单里消失了

类不报错了,但侧边栏的 Files 入口不见了。这个问题同样很隐蔽。Phorge 的应用发现机制除了扫目录,还会根据一份类映射文件定位应用类。迁移前PhabricatorFilesApplication的映射指向src/applications/files/,迁移后虽然壳文件还在,但如果你重新生成过类映射,或者部署环境清过一次 opcache 缓存,映射信息发生错位,应用就找不到入口了。

处理方式是重新生成 Phorge 的类映射缓存,对应旧版 Phabricator 里跑arc liberate的动作。如果你部署用的发布包里有预生成的映射文件,记得在发布脚本里加上这步,不然每次发版都会丢一次菜单入口。

4.3 清缓存是拆包后的第一件事

升级完依赖之后,很多人会遇到“方法不存在”“属性不存在”这类问题。比如Call to undefined method Gorge\Files\File::getByteSize(),代码里明明有这个方法。原因通常是 Phorge 的静态缓存和 APCu 缓存里还留着旧版本类文件的路径映射,甚至缓存了旧类对象。解决方式很简单:

./bin/cache purge

再配合在 PHP-FPM 里清一次 opcache。我自己的教训是:拆完包之后先清缓存,再开始排错,否则你看到的错误可能根本不是当前代码的真实状态,白折腾一晚上。

4.4 序列化数据里的遗留类名

还有一个藏在暗处的雷:数据库里有少量历史数据,以序列化形式存了PhabricatorFilesFile类对象。代码迁移后,反序列化时找不到旧的类名,PHP 会生成__PHP_Incomplete_Class,读取这些老数据就会静默丢字段,甚至报错。

我在兼容层加了一段按需的类名别名,只在反序列化场景生效:

<?php if (!class_exists('PhabricatorFilesFile', false)) { class_alias(\Gorge\Files\File::class, 'PhabricatorFilesFile'); }

注意,这种 alias 不能放在 Composer 的files自动加载里全局注册,否则新旧类名同时存在,运行期可能出现双份对象,逻辑判断会乱。我的做法是在一个专门的compat.php里按需 require,只在遇到遗留序列化数据时才加载,避免污染正常的类映射关系。

4.5 避坑清单速查表

问题现象根因处理动作
Class "Gorge\..." not found,但 PSR-4 映射存在Phorge autoloader 先抛异常,阻挡了 Composer loader在 Phorge boot 前 require vendor/autoload.php
应用菜单里 Files 入口消失类映射缓存或 opcache 过期,应用发现失败重新生成类映射并清缓存
Call to undefined method,代码里确有该方法静态/APCu 缓存了旧类路径./bin/cache purge,清 opcache
反序列化老数据生成__PHP_Incomplete_Class历史数据存了旧类名class_alias按需兼容,不要全局注册
包内代码反向依赖PhabricatorUser抽象没做好,依赖方向没反转定义UserContext接口,用适配器包装

我个人在实际操作中的体会是:拆分模块这件事,真正的难点从来不是把文件挪个地方,而是把依赖边界切得干净。像UserContext这种接口设计,看起来只多了一层,实际上决定了这个包以后能不能被其他系统用起来。如果你也在做类似拆包,别急着写代码,先把“谁依赖谁”的清单拉出来,边界画清楚了,后面就是一马平川。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询