PHP 如何用上下文与效应重新思考依赖注入

几乎每位程序员最终都会遇到日志记录问题。乍看之下,这件事很简单:在程序的适当位置写入一行日志即可。

php
function loadUser(int $id): User
{
    $user = findUser($id);

    log("User {$id} loaded");

    return $user;
}

然而,这行代码很快就会显现出一些令人头疼的特性。

首先,函数现在需要一个日志记录器。它可以来自全局变量,可以通过单例访问,也可以作为参数传入。如果显式传入日志记录器,函数签名就会开始膨胀:

php
function loadUser(Logger $logger, Database $db, int $id): User
{
    ...
}

这会显著增加重构难度,并使代码逐渐变得难以维护。依赖注入通常用于解决这一问题。

php
final class UserService
{
    public function __construct(
        private Logger $logger,
        private Database $db,
    ) {}

    public function loadUser(int $id): User
    {
        $user = $this->db->findUser($id);

        $this->logger->info("User loaded");

        return $user;
    }
}

与全局状态相比,这种写法清晰得多。依赖关系变得明确,可以在测试中替换,对象本身也无需了解 LoggerDatabase 来自何处。然而,依赖注入并未消除问题,只是将它转移到了“另一个层级”。

依赖的存续范围开始超出其实际被需要的位置。如果一个类中只有一个方法使用日志记录器,那么日志记录器通常仍会成为整个对象的依赖。

php
final class UserService
{
    public function __construct(
        private Logger $logger,
        private Database $db,
        private Clock $clock,
        private Metrics $metrics,
    ) {}
}

仅从构造函数签名来看,已经很难判断每项依赖由哪个方法使用,以及为何需要它。

第二个问题是传递性。

如果函数 A 调用 B,B 又调用需要日志记录器的 C,那么这项依赖通常必须沿整条调用链逐层传递。

text
A -> B -> C -> Logger

这里还存在一个更根本的问题。依赖注入回答的是:如何向函数或对象提供其所需的依赖?但对于代码究竟可以借助该依赖执行哪些操作,它几乎没有提供任何说明。

如果函数接收一个 Database,通常就能访问整个 Database,即使它实际只需要其中一个操作。

此外,还有一个鲜少被讨论的根本问题。依赖注入会使函数变得不纯,也就是允许函数对系统产生效应。此时,有必要重新审视纯函数的概念。

纯函数的行为完全由参数决定。给定相同的输入,它始终返回相同的结果,也不会改变自身计算之外的任何状态。

php
function add(int $a, int $b): int
{
    return $a + $b;
}

调用 add(2, 3) 始终会得到 5。这个函数不会读取文件、修改全局变量、写入数据库或向日志发送消息。因此,这类代码易于理解和测试,也可以脱离程序的其他部分独立执行。日志记录打破了这一模型。

php
function add(int $a, int $b): int
{
    log("adding numbers");

    return $a + $b;
}

从数学角度看,结果仍然相同。然而,这项计算已不再是纯计算:函数除了生成值 5,还执行了另一个可观察操作。这类操作称为副作用,也可以简称为 #效应。

典型的函数签名不会体现这些信息:

php
function loadUser(int $id): User

它描述了参数和结果,却没有说明函数会写入日志、访问数据库,或可能通过抛出异常终止执行。换言之,真实函数的接口还包含另一个部分,而大多数编程语言在历史上几乎没有提供描述它的方式。

将效应纳入语言

如果效应是函数的隐藏契约,为什么不将它显式表达出来?例如,可以这样表示 loadUser 的签名:

php
loadUser : int -> User
           + Log

也可以采用效应系统语言中更常见的表示法:

php
loadUser : int -> User
    effects { Log }

现在,签名除了描述函数的输入和结果,还说明了函数在计算过程中可能执行哪些操作。在这一模型中,纯函数只是一个特例:

text
add : (int, int) -> int
    effects {}

它不包含任何效应。下面这个函数:

rust
loadUser : int -> User
    effects { Log }

并非纯函数,但它的不纯性已经不再隐藏在实现内部,而是成为类型的一部分。这样一来,编译器便可检查过去完全依赖程序员自行保证的事项。

然而,这种表示法究竟意味着什么?

text
effects { Log }

它有多种实现方式。在 PHP 中,Log 可以是一个接口。这样一来,核心思路就会变得十分简单:

php
function loadUser(int $id) with {
    LoggerInterface $logger,
    DatabaseInterface $db
}: User
{
    $logger->info("Loading user", ['id' => $id]);
    $user = $db->findUser($id);
    $logger->info("User loaded", ['id' => $id]);

    return $user;
}

此时,函数调用可以写成:

php
with {
    LoggerInterface $logger = new FileLogger('/var/log/app.log');
    DatabaseInterface $db = new PostgreSQLDatabase($connection);
} {
    $user = loadUser(42);
}

编译器会保证:

  • loadUser 函数只能在上下文中调用。
  • 相关接口均已声明。

那么,#PHP 目前提供了什么?

今年,PHP 社区曾尝试引入 Context Managers。但 PHP 何时才能获得许多其他语言早已具备的完整功能?

本作品采用《CC 协议》,转载必须注明作者和本文链接