如何处理依赖冲突?演示 composer why-not 用法

解读

国内一线/二线互联网公司的 PHP 岗位面试里,Composer 依赖冲突排查是“必问”环节。
面试官真正想考察的是:

  1. 你是否具备“可复现、可量化”定位冲突的能力,而不是靠“删 lock 文件重装”这种黑盒做法;
  2. 你是否理解 SemVer 约束、Composer 版本解析算法(SAT)与稳定性标志(dev-stable)如何共同决定最终依赖图;
  3. 你是否能在生产环境“低停机”前提下给出灰度修复方案(如 inline-alias、临时 fork、版本兜底策略)。
    why-not 是官方提供的逆向推理命令,能把 SAT 求解器的拒绝路径翻译成“人话”,是体现专业度的“杀手锏”。

知识点

  1. Composer 版本约束表达式:^ ~ >= < || 运算符、@stable/@dev、#commit
  2. 冲突根因分类:
    a. 直接依赖与直接依赖冲突(A 要求 guzzle^6.0,B 要求 guzzle^7.0)
    b. 直接依赖与间接依赖冲突(laravel/framework 间接拉高 symfony/console 5.4,而公司私有包硬锁 4.4)
    c. PHP 扩展或平台设置冲突(ext-mongodb、php-64bit、hhvm)
  3. 诊断工具链:
    composer why-not <package> <version>
    composer prohibits <package> <version>(老版本别名)
    composer show -t 查看完整依赖树
    composer update --dry-run --with-all-dependencies 预演
  4. 解决策略:
    a. 升级/降级顶层包,让约束区间重叠
    b. 使用 inline-alias 临时“伪造”版本号
    c. 引入 replace/abandon 包,做“桥接”兼容层
    d. 私有源打补丁(fork 后加 tag),通过 vcs 仓库覆盖
    e. 最后手段:lock 文件局部回滚 + 二分法定位

答案

现场演示环节(建议直接带笔记本投屏,语速放慢,边敲边讲):

场景:项目已锁 laravel/framework v8.83.0,现在想装 league/flysystem-aws-s3-v3 ^3.0,Composer 报错:
“laravel/framework v8.83.0 requires league/flysystem ^1.1 -> found league/flysystem[1.1.0, …] but the package is fixed to 3.0.0”

步骤 1:复现冲突
composer require league/flysystem-aws-s3-v3:^3.0 --no-update
composer update --dry-run
=> 屏幕直接打印冲突信息,但信息碎片化,候选人此时说:“先别急着改 composer.json,我们用 why-not 做逆向推理。”

步骤 2:使用 why-not 精确定位
composer why-not league/flysystem 3.0.0
终端输出:
laravel/framework v8.83.0 requires league/flysystem (^1.1)
league/flysystem-aws-s3-v3 3.0.0 requires league/flysystem (^3.0)
=> 冲突路径一目了然:laravel 8 还在 flysystem 1.x 时代。

步骤 3:给出可选方案并量化影响
方案 A:升级 Laravel 到 ^9.0(官方 2022-02 发布,PHP≥8.0,公司当前 7.4,需评估 Swoole 扩展兼容性)
方案 B:临时降级 flysystem-aws-s3-v3 到 ^1.0(会失去 AWS StreamWrapper 新特性,但 0 业务改造)
方案 C:inline-alias 欺骗 Composer(composer.json 加 "league/flysystem":"3.0.0 as 1.99.0"),仅灰度 5% 机器,24 h 无异常再全量
步骤 4:现场演示方案 C 的最小闭环
composer config platform.php 8.0.2 # 先对齐平台
composer require "league/flysystem:^3.0 as 1.99.0" --no-update
composer update league/flysystem --with-all-dependencies
php artisan tinker >>> Storage::disk('s3')->put('test','ok');
=> 返回 true,验证功能无损。
最后口头总结:“如果灰度无异常,我会把 alias 方案写进 README 的 Known Issues,并建 Jira 工单跟踪 Laravel 9 升级排期,确保技术债可收敛。”

拓展思考

  1. 如果冲突包是公司内部私有包,且维护团队已离职,如何在 0 源码权限的前提下完成“黑盒兼容”?
    答:可用 “replace” 机制新建空包,把原包名声明为 replace,再在新包中按需 require 正确版本,实现“无痛换心”。
  2. 微服务场景下,不同子系统(PHP 版本、Composer 版本)不一致,如何统一 CI 镜像的依赖解析?
    答:在 CI 中强制 COMPOSER_CACHE_DIR=/dev/shm/composer 与 composer.lock 哈希双校验,拒绝“本地锁远程不锁”的漂移。
  3. 当冲突涉及 PHP 扩展(如 ext-imagick)版本时,why-not 无法给出扩展详情,如何继续深挖?
    答:结合 composer show --platform 与 php -m | grep imagick,再写一条自定义 Composer Plugin,在 pre-pool-create 事件里打印 ext 版本,补齐 why-not 的盲区。