walkor / walkor/webman-framework

`Container::make` 的参数顺序依赖可在 404 场景下引发严重性能问题乃至服务瘫痪

Open
#131 4 comments 0 reactions 0 assignees View on GitHub
Dominant language
PHP
Stars
148
Forks
68
Avg merge
1d 8h
Merged PRs (30d)
3

Description

#### 环境

* **webman:** 2.1.2
* **PHP:** 8.4.8

#### 摘要

`Webman\Container` 类中的 `make` 方法当前使用了 `array_values` 函数,这导致其构造函数参数注入机制严重依赖于用户提供数组的**物理顺序**,而非键名。这种设计不仅不直观、容易出错,而且在特定场景下会因为一个简单的配置错误,引发灾难性的、难以排查的性能问题,甚至导致整个服务瘫痪。

本 issue 将通过一个真实的生产事故案例来详细阐述其严重性,并提出一个在 webman 当前 PHP 8.1+ 环境下,能够从根本上解决问题的、健壮且现代化的解决方案。

#### 问题根源

位于 `webman/src/Container.php` 的 `Webman\Container` 类的 `make` 方法的实现:

```php
public function make(string $name, array $constructor = [])
{
// ...
return new $name(... array_values($constructor));
}
```

`array_values($constructor)` 会丢弃传入数组的所有**键名 (keys)**,返回一个仅包含**值 (values)** 的索引数组。这使得开发者在 `config/process.php` 等文件中定义 `constructor` 参数时,必须严格按照目标类构造函数定义的参数顺序来编写(这一点目前没有在文档中也没有看到次序强调,如有遗漏请指出)。任何无意的顺序调换,都会导致参数注入错位。

#### 真实世界的影响:一个由参数错位引发的服务雪崩案例

我将通过一个我遇到的真实生产事故,来阐述这个问题的严重性。

**1. 背景:**
在 `config/process.php` 文件中,我定义了一个自定义的 HTTP 进程,其 `constructor` 参数如下。由于没有意识到次序问题,我不慎将 `appPath` 和 `publicPath` 的顺序写反了。同时,我的 `public_path` 指向一个文件数量庞大、往返延迟较高的类似 NAS 的网络存储目录,初衷是供低频的、在 Redis 队列中执行的文件存储功能用。

```php
// config/process.php
'my-http-server' => [
'handler' => app\process\Http::class, // 一个继承 Webman\App 的类
// ... 其他配置 ...
'constructor' => [
'requestClass' => support\Request::class,
'logger' => support\Log::channel(),
'publicPath' => public_path(), // 本应在后面
'appPath' => app_path(), // 本应在前面
]
]
```

**2. 触发条件:**
客户端访问了一个**不存在的路由**(一个本应返回 404 的常规请求)。

**3. 故障链条:**

* **参数注入错位**: `Http` 进程(Handler)在启动时被实例化,其内部的 `$appPath` 属性被错误地赋予了 NAS 的路径。这个错误因为 `appPath` 和 `publicPath` 都只是路径字符串,在 webman 启动时是静默的也不会引发任何异常。
* **进入 404 处理逻辑**: 请求因路由未匹配,进入 `Webman\App::onMessage` 的默认路由解析逻辑,最终调用到 `Webman\App::getController` 方法。
* **错误的基础路径**: 在 `getController` 方法内部,`$basePath` 变量被赋值为 `static::$appPath`,即错误的 NAS 路径。
* **触发大规模 I/O**: 框架开始在 `$basePath`(NAS 目录)下**递归扫描**,试图寻找匹配 URL 的控制器文件。
* **服务瘫痪**: 对一个庞大的 NAS 目录进行递归扫描,`scan_dir` 产生的 `openat->getdents64->close` 工作流会产生巨大的 I/O 和网络延迟,导致处理该请求的 Worker 进程被长时间阻塞,状态变为 `busy`。随着足够多 404 请求的到来,所有 Worker 进程相继被阻塞,最终导致整个 webman 服务完全无法响应任何新请求,形成**拒绝服务**。

这个案例表明,此问题已不仅是“不优雅”,而是一个潜藏的、可被轻易触发的**严重稳定性和安全隐患**。

#### 关于临时解决方案及其局限性

为了紧急规避这个问题,我曾尝试使用一个“捕获所有”的路由来代替 `Route::fallback`:

```php
Route::any('/{all:.+}', function (Request $request) {
// ... 返回自定义的 404 响应 ...
});
```

这个方法确实能阻止请求进入后续危险的默认控制器解析流程,从而避免服务瘫痪。然而这并非一个根本性的解决方案,原因如下:

1. **治标不治本**: 它仅仅是绕过了触发灾难的路径,而 `Container` 注入参数错位的根本问题依然存在于内存中的进程实例中,可能在其他地方引发难以预料的错误。
2. **将框架的健壮性责任转移给用户**: 框架不应依赖于用户必须定义某条特定路由来防止其核心逻辑崩溃。
3. **牺牲了框架特性**: 为了避免一个 Bug,我们被迫完全禁用了框架的“默认控制器路由”这一便利功能。

#### 解决方案

**修改前:**

```php
return new $name(... array_values($constructor));
```

**修改后:**

```php
return new $name(...$constructor);
```

**理由:**
移除 `array_values` 后,在 PHP 8.1+ 环境中,`...$constructor` 会将关联数组的键名自动匹配到构造函数的参数名上,从而实现真正的“顺序无关”实例化,使配置变得健壮、直观且安全。

#### 对修改方案的审慎分析

1. 此项改动技术上属于一个**破坏性更新**,因为它将不再支持纯索引数组作为 `constructor` 的参数(例如,`make(Foo::class, [10, 20])` 将会失败,因为它会尝试寻找名为 `$0` 和 `$1` 的参数)。

2. 命名参数的底层实现需要进行运行时的方法签名分析和哈希查找,相比于纯粹的位置性参数传递,存在微观层面上的性能开销。但对于 `config/process.php` 这类仅在服务启动时执行一次的初始化场景,这点**纳秒级的性能差异是完全可以忽略不计的**。为了消除一个可导致服务瘫痪的严重设计缺陷,这点微乎其微的性能代价是完全值得的。

#### 结论

综上所述,我认为移除 `array_values` 并全面转向基于命名参数的实例化,是提升框架健壮性、安全性和开发者体验的关键一步。希望得到项目维护者的关注和反馈。如果您认同这个方向,我准备提交一份 PR 来推进这个改动。

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.