九一八事变纪念日|1931年9月18日,日本侵略者制造九一八事变,开启了长达14年的侵华战争。警钟长鸣,吾辈自强!

PHP 匿名类与依赖注入:不用框架也能写出可测代码

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-18 14:31 ·2 浏览 ·0 回复

不用框架也能写出可测代码,配方只有三样:接口约定边界、匿名类当测试替身、依赖从构造函数传进来。容器是可选项,注入才是必需品——把 `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` 换成构造参数,把测试替身写成匿名类,把装配逻辑收进一个小容器——不用框架,你的代码照样能一行命令跑完全部单元测试。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-482.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~