From c3ce9baf0dba09bf15226a745c207d1bd1964d5a Mon Sep 17 00:00:00 2001 From: watsonhaw5566 <348748267@qq.com> Date: Tue, 22 Sep 2026 15:18:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(routing):=20=E6=96=B0=E5=A2=9E=20route?= =?UTF-8?q?:cache=20=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/routing.md | 47 +++++++++ phpstan.neon | 8 -- src/Application.php | 70 ++++++------- src/console/command/RouteCacheCommand.php | 114 +++++++++++++++++++++ src/console/stubs/config/view.stub | 5 +- src/helpers.php | 4 +- src/routing/Router.php | 48 +++++++++ tests/feature/RouteCacheCommandTest.php | 117 ++++++++++++++++++++++ 8 files changed, 364 insertions(+), 49 deletions(-) create mode 100644 src/console/command/RouteCacheCommand.php create mode 100644 tests/feature/RouteCacheCommandTest.php diff --git a/docs/routing.md b/docs/routing.md index 405f0cb..2fa5ef3 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -408,6 +408,53 @@ cache()->delete('route:App\controller\ArticleController@index:GET:/articles:' . > 会话、限流等动态处理的接口。建议仅用于公开、内容相对稳定的接口(如文章列表、 > 商品详情、配置项等)。 +## 路由表缓存(生产环境) + +> 注意与上文的[路由缓存](#路由缓存)区分:**路由缓存**缓存的是响应内容(跳过控制器执行); +> 本节的**路由表缓存**缓存的是路由定义本身(跳过控制器目录扫描与注解反射),用于降低生产环境的路由解析开销。 + +默认情况下,框架每个请求都会扫描 `app/controller` 目录、反射所有控制器并解析注解。 +在生产环境可通过 `route:cache` 命令将收集好的路由表生成为缓存文件,之后直接加载,不再进行扫描与反射。 +命令执行时会输出路由收集耗时,便于评估实时解析路由的开销: + +```bash +php lee route:cache +``` + +输出示例: + +``` +Route cache generated successfully. + + Routes: 14 + Collect time: 1.01 ms + Cache file: /var/www/runtime/route_cache.php + +Re-run this command after any route change, delete the file to disable. +``` + +缓存文件位于 `runtime/route_cache.php`。文件存在时框架自动使用;删除该文件即恢复实时扫描,无需修改任何配置。 + +### 动态路由参数不受影响 + +`{id}` 等动态路由完全不受缓存影响。缓存保存的是注册阶段已编译好的正则表达式, +`/users/{id}` 在缓存文件中仍为命名捕获组: + +``` +#^/users/(?P[^/]+)$# +``` + +因此路由匹配、`{param}` 参数提取、参数类型自动转换等行为与实时扫描完全一致。 + +### 注意事项 + +1. **修改路由后必须重新生成**:新增控制器、修改 `#[Route]` / `#[Resource]` 注解、调整中间件后, + 需重新执行 `php lee route:cache`,否则生效的仍是旧路由表。命令会先删除旧缓存再重建。 +2. **插件路由会一并缓存**:命令在插件 `boot()` 完成后收集路由,插件注册的路由也会写入缓存; + 缓存生效后插件对路由表的重复注册会被自动跳过。 +3. **建议仅用于生产环境**:开发环境保留实时扫描,改完代码立即生效。 +4. 缓存文件采用原子写入(临时文件 + 重命名),生成过程中不会影响正在处理的请求。 + ## 查看路由列表 使用 `route:list` 命令查看所有已注册的路由: diff --git a/phpstan.neon b/phpstan.neon index 1562184..269126f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,11 +6,3 @@ parameters: checkExplicitMixed: false bootstrapFiles: - tests/stub/bootstrap.php - ignoreErrors: - # Liquid 驱动依赖可选的 liquid/liquid 包,未安装时静态分析无法解析其符号 - - - identifier: class.notFound - path: src/view/driver/Liquid.php - - - identifier: constructor.unusedParameter - path: src/view/driver/Liquid.php \ No newline at end of file diff --git a/src/Application.php b/src/Application.php index 2361a85..2e066b2 100644 --- a/src/Application.php +++ b/src/Application.php @@ -35,7 +35,6 @@ use Lychee\session\Session; use Lychee\view\ExceptionRenderer; use Lychee\view\View; -use Lychee\view\ViewInterface; use Lychee\websocket\command\ServerCommand; use Lychee\websocket\WebSocketServer; use Psr\Log\LoggerInterface; @@ -176,6 +175,21 @@ private function registerBindings(): void $this->container->singleton(Router::class, function (): Router { $routePrefix = (string) config('app.route_prefix', ''); $router = new Router($routePrefix); + + // 生产环境存在路由缓存文件时直接加载,跳过目录扫描与注解反射 + $cacheFile = \Lychee\console\command\RouteCacheCommand::cacheFilePath($this->container->runtimePath); + if (is_file($cacheFile)) { + $data = require $cacheFile; + if (is_array($data) && isset($data['routes']) && is_array($data['routes'])) { + $namedRoutes = isset($data['named_routes']) && is_array($data['named_routes']) + ? $data['named_routes'] + : []; + $router->loadFromCache($data['routes'], $namedRoutes); + + return $router; + } + } + $router->registerDirectory( $this->basePath . '/app/controller', $this->controllerNamespace @@ -192,6 +206,7 @@ private function registerBindings(): void $console = new ConsoleApplication($this->container); $console->addCommand(\Lychee\console\command\RunCommand::class); $console->addCommand(\Lychee\console\command\RouteListCommand::class); + $console->addCommand(\Lychee\console\command\RouteCacheCommand::class); $console->addCommand(\Lychee\console\command\MakeRestCommand::class); $console->addCommand(\Lychee\console\command\MakeBaseCommand::class); $console->addCommand(\Lychee\console\command\ConfigPublishCommand::class); @@ -556,43 +571,28 @@ private function resolveMigrationPdo(): ?PDO private function bootView(): void { - // 若应用侧已通过容器绑定自定义模板驱动,则优先使用 - if ($this->container->has(ViewInterface::class)) { - $view = $this->container->get(ViewInterface::class); - } else { - /** @var Config $config */ - $config = $this->container->get('config'); - $viewConfig = $config->get('view', []); - - $driver = (string) ($viewConfig['driver'] ?? 'twig'); - $viewPath = (string) ($viewConfig['view_path'] ?? ($this->basePath . '/app/view')); - $cachePath = (string) ($viewConfig['cache_path'] ?? ($this->container->runtimePath . 'twig')); - $debug = (bool) ($viewConfig['debug'] ?? false); - $baseUrl = (string) ($viewConfig['base_url'] ?? ''); - - if (!is_dir($viewPath)) { - @mkdir($viewPath, 0777, true); - } + /** @var Config $config */ + $config = $this->container->get('config'); + $viewConfig = $config->get('view', []); + + $viewPath = (string) ($viewConfig['view_path'] ?? ($this->basePath . '/app/view')); + $cachePath = (string) ($viewConfig['cache_path'] ?? ($this->container->runtimePath . 'twig')); + $debug = (bool) ($viewConfig['debug'] ?? false); + $baseUrl = (string) ($viewConfig['base_url'] ?? ''); + $extensions = (array) ($viewConfig['extensions'] ?? ['.twig', '.html']); - $view = match ($driver) { - 'liquid' => new \Lychee\view\driver\Liquid( - viewPath: $viewPath, - cachePath: $cachePath, - debug: $debug, - baseUrl: $baseUrl, - extensions: (array) ($viewConfig['extensions'] ?? ['.liquid']), - ), - default => new View( - viewPath: $viewPath, - cachePath: $cachePath, - debug: $debug, - baseUrl: $baseUrl, - extensions: (array) ($viewConfig['extensions'] ?? ['.twig', '.html']), - ), - }; + if (!is_dir($viewPath)) { + @mkdir($viewPath, 0777, true); } - $this->container->instance(ViewInterface::class, $view); + $view = new View( + viewPath: $viewPath, + cachePath: $cachePath, + debug: $debug, + baseUrl: $baseUrl, + extensions: $extensions, + ); + $this->container->instance(View::class, $view); $this->container->instance('view', $view); } diff --git a/src/console/command/RouteCacheCommand.php b/src/console/command/RouteCacheCommand.php new file mode 100644 index 0000000..b487f47 --- /dev/null +++ b/src/console/command/RouteCacheCommand.php @@ -0,0 +1,114 @@ +setName('route:cache'); + $this->setDescription('Cache the route table for production'); + } + + protected function execute(Input $input, Output $output): int + { + $cacheFile = self::cacheFilePath($this->app->runtimePath); + + // 先删除旧缓存:否则解析 Router 时会从旧缓存加载,无法反映最新路由 + if (is_file($cacheFile)) { + @unlink($cacheFile); + } + + // 计时:路由收集(目录扫描 + 注解反射 + 插件注册) + $start = microtime(true); + $router = $this->app->get(Router::class); + $collectTime = (microtime(true) - $start) * 1000; + + $routes = $router->getRoutes(); + + if (empty($routes)) { + $output->writeln('No routes found, route cache was not generated.'); + + return 1; + } + + if (!is_dir(dirname($cacheFile))) { + @mkdir(dirname($cacheFile), 0777, true); + } + + $payload = $this->renderPayload($routes, $router->getNamedRoutes()); + + // 原子写入:临时文件 + rename,避免并发请求读到半写入文件 + $tmpFile = $cacheFile . '.tmp'; + if (file_put_contents($tmpFile, $payload) === false) { + $output->writeln('Failed to write route cache file.'); + + return 1; + } + + if (!@rename($tmpFile, $cacheFile)) { + @unlink($tmpFile); + $output->writeln('Failed to move route cache file into place.'); + + return 1; + } + + $output->writeln('Route cache generated successfully.'); + $output->writeln(''); + $output->writeln(' Routes: ' . count($routes)); + $output->writeln(sprintf(' Collect time: %.2f ms', $collectTime)); + $output->writeln(' Cache file: ' . $cacheFile); + $output->writeln(''); + $output->writeln('Re-run this command after any route change, delete the file to disable.'); + + return 0; + } + + /** + * 解析路由缓存文件路径。 + */ + public static function cacheFilePath(string $runtimePath): string + { + return rtrim($runtimePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'route_cache.php'; + } + + /** + * @param array> $routes + * @param array $namedRoutes + */ + private function renderPayload(array $routes, array $namedRoutes): string + { + $export = var_export([ + 'routes' => $routes, + 'named_routes' => $namedRoutes, + ], true); + + return << 'twig', - // 模板根目录 'view_path' => app_path('view'), @@ -18,6 +15,6 @@ return [ // 静态资源基础 URL,如配置 CDN 可填 'https://cdn.example.com' 'base_url' => '', - // 允许的模板后缀,按查找优先级排序(twig 驱动默认 ['.twig', '.html'],liquid 驱动默认 ['.liquid']) + // 允许的模板后缀,按查找优先级排序 'extensions' => ['.twig', '.html'], ]; diff --git a/src/helpers.php b/src/helpers.php index 4092bf9..e468aab 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -381,7 +381,7 @@ function error_message(): string */ function view(string $template, array $data = []): string { - /** @var \Lychee\view\ViewInterface $view */ + /** @var \Lychee\view\View $view */ $view = app('view'); return $view->render($template, $data); @@ -396,7 +396,7 @@ function view(string $template, array $data = []): string */ function asset(string $path): string { - /** @var \Lychee\view\ViewInterface $view */ + /** @var \Lychee\view\View $view */ $view = app('view'); return $view->asset($path); diff --git a/src/routing/Router.php b/src/routing/Router.php index a6c413f..dfa17a8 100644 --- a/src/routing/Router.php +++ b/src/routing/Router.php @@ -39,16 +39,50 @@ class Router /** @var array */ private array $namedRoutes = []; + /** + * 路由表是否来自缓存文件。 + * + * 为 true 时 registerController / registerDirectory 不再执行, + * 缓存表即为路由的唯一来源(其中已包含插件注册的路由)。 + */ + private bool $loadedFromCache = false; + public function __construct(string $routePrefix = '') { $this->routePrefix = trim($routePrefix, '/'); } + /** + * 从缓存数据加载路由表,跳过目录扫描与注解反射。 + * + * 缓存中的 pattern 为已编译的正则表达式,动态参数(如 {id}) + * 以命名捕获组形式保留,dispatch 的匹配与参数提取行为与实时扫描完全一致。 + * + * @param array, cache:?int}> $routes + * @param array $namedRoutes + */ + public function loadFromCache(array $routes, array $namedRoutes = []): void + { + $this->routes = $routes; + $this->namedRoutes = $namedRoutes; + $this->loadedFromCache = true; + } + + public function isLoadedFromCache(): bool + { + return $this->loadedFromCache; + } + /** * @param class-string $controllerClass */ public function registerController(string $controllerClass): void { + // 缓存表为唯一来源时,插件 boot 中的动态注册无需重复执行 + if ($this->loadedFromCache) { + return; + } + $ref = new ReflectionClass($controllerClass); $prefix = $this->resolvePrefix($ref); @@ -143,6 +177,10 @@ private function registerResourceRoutes( public function registerDirectory(string $directory, string $namespace): void { + if ($this->loadedFromCache) { + return; + } + $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS) ); @@ -203,6 +241,16 @@ public function getRoutes(): array return $this->routes; } + /** + * 获取命名路由映射表。 + * + * @return array + */ + public function getNamedRoutes(): array + { + return $this->namedRoutes; + } + /** * 解析控制器类的路由前缀。 * diff --git a/tests/feature/RouteCacheCommandTest.php b/tests/feature/RouteCacheCommandTest.php new file mode 100644 index 0000000..f4dfbc8 --- /dev/null +++ b/tests/feature/RouteCacheCommandTest.php @@ -0,0 +1,117 @@ +cacheFile = RouteCacheCommand::cacheFilePath(STUB_DIR . '/runtime'); + + // 避免缓存文件串扰其他测试 + @unlink($this->cacheFile); + @unlink($this->cacheFile . '.tmp'); + } + + protected function tearDown(): void + { + @unlink($this->cacheFile); + @unlink($this->cacheFile . '.tmp'); + } + + private function createApp(): Application + { + return new Application( + basePath: STUB_DIR, + controllerNamespace: 'Tests\\stub\\app\\controller', + ); + } + + public function test_command_is_registered(): void + { + $console = $this->createApp()->container->get(ConsoleApplication::class); + + $this->assertTrue($console->has('route:cache')); + } + + public function test_command_generates_cache_file(): void + { + $app = $this->createApp(); + $console = $app->container->get(ConsoleApplication::class); + + $code = $console->run(new Input(['route:cache']), new Output()); + + $this->assertSame(0, $code); + $this->assertFileExists($this->cacheFile); + + $content = file_get_contents($this->cacheFile); + + // 动态路由的编译结果(命名捕获组)应完整保留 + $this->assertStringContainsString("(?P[^/]+)", $content); + $this->assertStringContainsString('route:cache', $content); + } + + public function test_cached_routes_are_identical_to_scanned_routes(): void + { + // 实时扫描的路由表 + $scannedRoutes = $this->createApp()->container->get(Router::class)->getRoutes(); + + // 生成缓存 + $app = $this->createApp(); + $console = $app->container->get(ConsoleApplication::class); + $this->assertSame(0, $console->run(new Input(['route:cache']), new Output())); + + // 从缓存加载的路由表:顺序与内容必须完全一致 + $cachedRouter = $this->createApp()->container->get(Router::class); + + $this->assertTrue($cachedRouter->isLoadedFromCache()); + $this->assertSame($scannedRoutes, $cachedRouter->getRoutes()); + } + + public function test_dynamic_route_dispatches_and_extracts_params_from_cache(): void + { + $app = $this->createApp(); + $console = $app->container->get(ConsoleApplication::class); + $this->assertSame(0, $console->run(new Input(['route:cache']), new Output())); + + $router = $this->createApp()->container->get(Router::class); + + // {id} 动态路由:匹配成功且参数正确提取 + $match = $router->dispatch('GET', '/users/123'); + + $this->assertSame('read', $match->action); + $this->assertSame(['id' => '123'], $match->params); + + // 未注册的路径仍然正常 404 + $this->expectException(\Lychee\routing\RouteNotFoundException::class); + $router->dispatch('DELETE', '/unknown/1'); + } + + public function test_command_overwrites_stale_cache(): void + { + // 先制造一份失效缓存 + file_put_contents($this->cacheFile, ' []];'); + + $app = $this->createApp(); + $console = $app->container->get(ConsoleApplication::class); + + $this->assertSame(0, $console->run(new Input(['route:cache']), new Output())); + + $router = $this->createApp()->container->get(Router::class); + + $this->assertTrue($router->isLoadedFromCache()); + $this->assertNotEmpty($router->getRoutes()); + } +} From f44d63fb794ca8ef9909834691a8592076ce5160 Mon Sep 17 00:00:00 2001 From: watsonhaw5566 <348748267@qq.com> Date: Tue, 22 Sep 2026 15:19:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?lint:=20=E4=BF=AE=E5=A4=8D=20phpstan=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpstan.neon | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 269126f..1562184 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,3 +6,11 @@ parameters: checkExplicitMixed: false bootstrapFiles: - tests/stub/bootstrap.php + ignoreErrors: + # Liquid 驱动依赖可选的 liquid/liquid 包,未安装时静态分析无法解析其符号 + - + identifier: class.notFound + path: src/view/driver/Liquid.php + - + identifier: constructor.unusedParameter + path: src/view/driver/Liquid.php \ No newline at end of file