PHP 匿名类与依赖注入:不用框架也能写出可测代码
不用框架也能写出可测代码,配方只有三样:接口约定边界、匿名类当测试替身、依赖从构造函数传进来。容器是可选项,注入才是必需品——把 `new` 从方法体里挪到构造参数里,代码当天就变得可测。
可测性的本质是"依赖可替换",不是"用了框架"
结论:一段代码难测,90% 的原因不是缺少 PHPUnit,而是它在方法内部自己 `new` 了外部依赖。
// 难测:SendCode 里写死 new Mailer(),测试必然真发邮件
public function send(string $to): void {
(new Mailer())->send($to, '验证码');
}
// 可测:依赖从外面进来,测试塞什么就用什么
public function __construct(private MailerInterface $mailer) {}
判断标准很简单:如果一个依赖的行为你在测试里必须控制(网络、数据库、时间、随机数、文件系统),它就必须被注入;反过来,纯值对象、纯函数工具类,直接 `new` 完全没问题,别为了"架构纯洁"把 `new DateTime()` 也包一层。
匿名类是 PHP 里最轻的测试替身,一个文件搞定
结论:`new class implements X {}` 能在测试文件里就地造出替身,不需要 mock 库、不需要 Composer、不产生额外文件。
interface MailerInterface {
public function send(string $to, string $subject): void;
}
$fake = new class implements MailerInterface {
public array $sent = [];
public function send(string $to, string $subject): void {
$this->sent[] = [$to, $subject]; // 记录调用,供断言
}
};
$service = new PostService(new FakeRepo(), $fake);
$service->register('a@b.com');
assert($fake->sent[0][0] === 'a@b.com');
注意点是匿名类只能通过构造参数捕获外部变量,它没有闭包那种 `use` 语法:想传进去的变量必须写成 `new class($var) { public function __construct(public array $log) {} }`(属性提升需要 PHP 8.0+;PHP 7.4 环境要显式声明属性再赋值)。
另外三条边界要记牢:匿名类实例不能序列化(会直接抛异常),`get_class()` 返回的是 `class@anonymous...` 这种带路径行号的字符串,所以别把它写进缓存键、日志字段或数据库;它也不适合做业务领域模型,只适合测试替身和一次性小对象。
注入三种姿势:构造注入为主,方法注入补位,闭包工厂兜底
结论:优先构造注入;框架回调/钩子这类"调用点才拿到参数"的场景用方法注入;需要按参数反复创建对象时用闭包工厂。
// 1) 构造注入:对象生命周期内依赖不变
final class PostService {
public function __construct(
private PostRepo $repo,
private MailerInterface $mailer,
) {}
}
// 2) 方法注入:钩子、回调、命令处理器常用
function on_post_created(array $post, Container $c): void {
$c->get(MailerInterface::class)->send($post['email'], '发布成功');
}
// 3) 闭包工厂:同一个类需要"每次都要新的"
$container->bind(Editor::class, fn() => new Editor($config));
这段写法在 PHP 7.4 上要降级:不能写属性提升,得老老实实 `private MailerInterface $mailer;` 加构造函数赋值。像 Clara BBS 这类无框架 PHP 系统(环境要求 PHP 7.4-8.5、MySQL 5.7+,不需要 Composer 和命令行),插件是运行时钩子加载的,注入点基本都落在第 2、3 种姿势上——写插件时把依赖从钩子参数或全局注册表里取,而不是在插件内部 `new`,你的插件就能脱离整站单独跑测试。
二十行手写容器,够用到项目结束
结论:一个数组 + 闭包就是容器,不需要反射自动装配那一套。
final class Container {
private array $bindings = []; // id => factory
private array $shared = []; // id => 已实例化的单例
public function bind(string $id, callable $factory): void {
$this->bindings[$id] = $factory;
}
public function singleton(string $id, callable $factory): void {
$this->bindings[$id] = $factory;
$this->shared[$id] = null; // 标记为共享,懒加载
}
public function get(string $id): mixed {
if (!array_key_exists($id, $this->bindings)) {
throw new RuntimeException("未注册: {$id}");
}
if (array_key_exists($id, $this->shared)) {
return $this->shared[$id] ??= ($this->bindings[$id])();
}
return ($this->bindings[$id])();
}
}
坑在这里:很多人写 `singleton()` 时直接 `$this->instances[$id] = $factory();`,这会让所有单例在注册瞬间全部实例化,等于放弃懒加载——注册表只存工厂,取的时候再 `??=` 才是正确姿势。测试时也很方便:`$c->bind(MailerInterface::class, fn() => $fake)` 一行就完成替换。
什么时候该停手
结论:如果某个依赖你从没在测试里替换过,也没打算替换,就不要为它写接口、加容器注册。
接口和注入是有成本的:多一层文件、多一次跳转、新人多花十分钟找实现类。经验阈值是——**一个类超过 3 个构造依赖,或者出现"为了注入而注入"的透传参数时,先考虑拆类,而不是继续加注册项**。匿名类、闭包工厂、手写容器这三样加起来不到一百行,能覆盖绝大多数无框架 PHP 项目的可测性需求。
回到主线:把 `new` 换成构造参数,把测试替身写成匿名类,把装配逻辑收进一个小容器——不用框架,你的代码照样能一行命令跑完全部单元测试。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





