Tempest 框架的核心是一个名为“发现(discovery)”的概念。这是 Tempest 区别于其他所有框架的标志性特性。虽然 Symfony、Laravel 等框架也具备有限的“发现”能力以提升便利性,但 Tempest 从“发现”机制起步,并将其打造为驱动所有功能的核心动力。本文将解释“发现”机制的工作原理、为何它如此强大,以及如何轻松构建自定义“发现”逻辑。
“发现”机制的工作原理
“发现”的理念很简单:让框架理解你的代码,这样你就无需关注配置或启动流程。当我们说 Tempest 是“不挡路的框架”时,这主要归功于“发现”机制。
我们从一个示例开始:控制器方法,写法如下:
use TempestRouterGet;use TempestViewView;final class BookController{ #[Get('/books')]
public function index(): View
{ /* … */ }
}你可以把这个文件放在项目的任何位置,Tempest 会自动识别它是一个控制器方法,并将路由注册到路由器中。单看这一点,可能不算特别惊艳——比如 Symfony 也有类似功能。但我们再看几个更多示例。
事件处理器通过 #[EventHandler] 注解标记,它处理的具体事件由方法参数的类型决定:
use TempestEventBusEventHandler;final class BooksEventHandlers{ #[EventHandler]
public function onBookCreated(BookCreated $event): void
{ // …
}
}控制台命令通过 #[ConsoleCommand] 注解被“发现”,控制台的命令定义会根据方法定义自动生成:
use TempestConsoleConsoleCommand;final readonly class BooksCommand{ #[ConsoleCommand]
public function list(): void
{ // ./tempest books:list
} #[ConsoleCommand]
public function info(string $name): void
{ // ./tempest books:info "Timeline Taxi"
}
}视图组件则通过文件名被“发现”:
类似的示例还有很多。那么,Tempest 的“发现”机制与 Symfony 或 Laravel 的“自动查找文件”有何不同?主要有两点:
Tempest 的“发现”机制无处不在,字面意义上的“任何地方”。无需配置特定的扫描目录,Tempest 会扫描整个项目,包括
vendor目录下的文件——这一点我们稍后再详细说明。“发现”机制具备可扩展性。如果你的项目或包需要“发现”新的内容?只需编写一个类就能实现。
这两个特性让 Tempest 的“发现”机制极具灵活性和强大性。它允许你按照自己喜欢的方式组织项目结构,无需遵循框架强制规定的结构——很多人都表示这是他们喜欢 Tempest 的原因之一。
那么,“发现”机制具体如何工作?本质上分为三个步骤:
首先,Tempest 会检查已安装的 Composer 依赖:项目的所有命名空间都会纳入“发现”范围,此外,所有依赖 Tempest 的包也会被纳入。
确定所有“发现”范围后,Tempest 会先扫描实现了
Discovery接口的类。没错:“发现类”本身也会被“发现”。最后,找到所有“发现类”后,Tempest 会遍历它们,并将所有待扫描的位置传递给每个“发现类”。每个“发现类”都能访问容器,并在容器中注册所需的内容。
我们以“路由发现”为例,看一个具体实现。以下是 RouteDiscovery 的完整代码,并添加了注释说明逻辑:
use TempestDiscoveryDiscovery;use TempestDiscoveryDiscoveryLocation;use TempestDiscoveryIsDiscovery;use TempestReflectionClassReflector;final class RouteDiscovery implements Discovery{ use IsDiscovery; // 路由发现需要两个依赖,
// 均通过自动注入(autowiring)获取
public function __construct(
private readonly RouteConfigurator $configurator, private readonly RouteConfig $routeConfig, ) {
} // 对每个可能被“发现”的类,都会调用 `discover` 方法
public function discover(DiscoveryLocation $location, ClassReflector $class): void
{ // 路由注册场景中,
// 我们需要查找带有 `Route` 注解的方法
foreach ($class->getPublicMethods() as $method) { $routeAttributes = $method->getAttributes(Route::class); foreach ($routeAttributes as $routeAttribute) { // 每个带有 `Route` 注解的方法
// 会先存储在内部,后续统一处理
$this->discoveryItems->add($location, [$method, $routeAttribute]);
}
}
} // `apply` 方法用于在 `RouteConfig` 中注册路由
// `discover` 和 `apply` 方法分离是为了缓存,
// 本文后续会详细说明这一点
public function apply(): void
{ foreach ($this->discoveryItems as [$method, $routeAttribute]) { $route = DiscoveredRoute::fromRoute($routeAttribute, $method); $this->configurator->addRoute($route);
} if ($this->configurator->isDirty()) { $this->routeConfig->apply($this->configurator->toRouteConfig());
}
}
}如你所见,逻辑并不复杂。实际上,由于需要做一些路由优化,“路由发现”已经算是相对复杂的“发现”实现了。再看一个非常简单的“发现”实现示例——这个是专门为本文档网站编写的自定义“发现”类,用于查找所有实现了 Projector 接口的类:
use TempestDiscoveryDiscovery;use TempestDiscoveryDiscoveryLocation;use TempestDiscoveryIsDiscovery;use TempestReflectionClassReflector;final class ProjectionDiscovery implements Discovery{ use IsDiscovery; public function __construct(
private readonly StoredEventConfig $config, ) {} public function discover(DiscoveryLocation $location, ClassReflector $class): void
{ if ($class->implements(Projector::class)) { $this->discoveryItems->add($location, $class->getName());
}
} public function apply(): void
{ foreach ($this->discoveryItems as $className) { $this->config->projectors[] = $className;
}
}
}相当简单,对吧?尽管简单,但“发现”机制的能力极强,也是 Tempest 区别于其他框架的关键。
缓存与性能
“等等,这样做性能肯定不行吧?”——这是我听到 Aidan 提议“Tempest 的‘发现’机制应扫描所有项目和 vendor 文件”时的第一反应。顺便提一句,Aidan 是 Tempest 的另外两位核心贡献者之一。
Aidan 说:“别担心,肯定没问题。”事实也确实如此,不过有几个注意事项需要说明。
首先,在生产环境中,不会执行这些“代码扫描”操作。这就是为什么 discover() 和 apply() 方法是分离的:discover() 方法负责判断“是否需要发现某个内容”并做好准备,apply() 方法则会获取准备好的数据并存储到正确的位置。换句话说:discover() 方法中执行的所有操作都会被缓存。
但本地开发环境是个例外——由于你会不断修改代码,无法对文件进行缓存。试想一下:每次添加新的控制器方法都要清除“发现”缓存,这得多麻烦?不过确实有解决方案:项目文件无法缓存,但所有 vendor 文件可以缓存——它们只会在执行 composer up 时更新。这就是所谓的“部分发现缓存(partial discovery cache)”:只缓存 vendor 目录的“发现”结果,不缓存项目文件的“发现”结果。可通过环境变量切换缓存模式:
# .envDISCOVERY_CACHE=falseDISCOVERY_CACHE=trueDISCOVERY_CACHE=partial
如果启用了“完整缓存”或“部分缓存”,还需要额外一步操作:部署后或更新 Composer 依赖后,必须重新生成“发现”缓存:
~ ./tempest discovery:generate │ Clearing discovery cache │ ✔ Done in 132ms. │ Generating discovery cache using the all strategy │ ✔ Done in 411ms.
对于本地开发,tempest/app 脚手架项目已预先配置好 Composer 钩子;如果你的项目不是基于 tempest/app 创建的,也可以手动添加:
{
"scripts": {
"post-package-update": [
"@php ./tempest discovery:generate"
]
}}另外提一句:我们曾通过“生成数千个文件模拟真实项目”的方式,对“无缓存发现”的性能进行了基准测试,测试代码可在此处查看。结果显示,“发现”机制对本地开发的性能影响微乎其微。
话虽如此,我们仍有优化空间让“发现”机制更快。例如,可基于项目的 Git 状态,只对“实际修改过的文件”执行实时“发现”。这些优化可能在未来需要时实施,但在充分测试当前实现之前,我们不会进行“过早优化”。因此,如果你在使用 Tempest 时遇到与“发现”机制相关的性能问题,一定要提交 Issue——我们将非常感谢你的反馈!
以上就是对“发现”机制的深入解析。我愿意将它比作 Tempest 的“心跳”。得益于“发现”机制,我们可以省去大部分配置——因为“发现”会直接分析代码,并根据代码内容做出决策。它还允许你按任意方式组织项目结构;Tempest 不会强制要求你“控制器放这里,模型放那里”。
你可以随心所欲地开发,Tempest 会自动适配。为什么?因为它是真正“不挡路”的框架。