PHP 如何用上下文与效应重新思考依赖注入
几乎每位程序员最终都会遇到日志记录问题。乍看之下,这件事很简单:在程序的适当位置写入一行日志即可。
function loadUser(int $id): User
{
$user = findUser($id);
log("User {$id} loaded");
return $user;
}然而,这行代码很快就会显现出一些令人头疼的特性。
首先,函数现在需要一个日志记录器。它可以来自全局变量,可以通过单例访问,也可以作为参数传入。如果显式传入日志记录器,函数签名就会开始膨胀:
function loadUser(Logger $logger, Database $db, int $id): User
{
...
}这会显著增加重构难度,并使代码逐渐变得难以维护。依赖注入通常用于解决这一问题。
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;
}
}与全局状态相比,这种写法清晰得多。依赖关系变得明确,可以在测试中替换,对象本身也无需了解 Logger 和 Database 来自何处。然而,依赖注入并未消除问题,只是将它转移到了“另一个层级”。
依赖的存续范围开始超出其实际被需要的位置。如果一个类中只有一个方法使用日志记录器,那么日志记录器通常仍会成为整个对象的依赖。
final class UserService
{
public function __construct(
private Logger $logger,
private Database $db,
private Clock $clock,
private Metrics $metrics,
) {}
}仅从构造函数签名来看,已经很难判断每项依赖由哪个方法使用,以及为何需要它。
第二个问题是传递性。
如果函数 A 调用 B,B 又调用需要日志记录器的 C,那么这项依赖通常必须沿整条调用链逐层传递。
A -> B -> C -> Logger这里还存在一个更根本的问题。依赖注入回答的是:如何向函数或对象提供其所需的依赖?但对于代码究竟可以借助该依赖执行哪些操作,它几乎没有提供任何说明。
如果函数接收一个 Database,通常就能访问整个 Database,即使它实际只需要其中一个操作。
此外,还有一个鲜少被讨论的根本问题。依赖注入会使函数变得不纯,也就是允许函数对系统产生效应。此时,有必要重新审视纯函数的概念。
纯函数的行为完全由参数决定。给定相同的输入,它始终返回相同的结果,也不会改变自身计算之外的任何状态。
function add(int $a, int $b): int
{
return $a + $b;
}调用 add(2, 3) 始终会得到 5。这个函数不会读取文件、修改全局变量、写入数据库或向日志发送消息。因此,这类代码易于理解和测试,也可以脱离程序的其他部分独立执行。日志记录打破了这一模型。
function add(int $a, int $b): int
{
log("adding numbers");
return $a + $b;
}从数学角度看,结果仍然相同。然而,这项计算已不再是纯计算:函数除了生成值 5,还执行了另一个可观察操作。这类操作称为副作用,也可以简称为 #效应。
典型的函数签名不会体现这些信息:
function loadUser(int $id): User它描述了参数和结果,却没有说明函数会写入日志、访问数据库,或可能通过抛出异常终止执行。换言之,真实函数的接口还包含另一个部分,而大多数编程语言在历史上几乎没有提供描述它的方式。
将效应纳入语言
如果效应是函数的隐藏契约,为什么不将它显式表达出来?例如,可以这样表示 loadUser 的签名:
loadUser : int -> User
+ Log也可以采用效应系统语言中更常见的表示法:
loadUser : int -> User
effects { Log }现在,签名除了描述函数的输入和结果,还说明了函数在计算过程中可能执行哪些操作。在这一模型中,纯函数只是一个特例:
add : (int, int) -> int
effects {}它不包含任何效应。下面这个函数:
loadUser : int -> User
effects { Log }并非纯函数,但它的不纯性已经不再隐藏在实现内部,而是成为类型的一部分。这样一来,编译器便可检查过去完全依赖程序员自行保证的事项。
然而,这种表示法究竟意味着什么?
effects { Log }它有多种实现方式。在 PHP 中,Log 可以是一个接口。这样一来,核心思路就会变得十分简单:
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;
}此时,函数调用可以写成:
with {
LoggerInterface $logger = new FileLogger('/var/log/app.log');
DatabaseInterface $db = new PostgreSQLDatabase($connection);
} {
$user = loadUser(42);
}编译器会保证:
loadUser函数只能在上下文中调用。- 相关接口均已声明。
那么,#PHP 目前提供了什么?
今年,PHP 社区曾尝试引入 Context Managers。但 PHP 何时才能获得许多其他语言早已具备的完整功能?