Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<id>[^/]+)$#
```

因此路由匹配、`{param}` 参数提取、参数类型自动转换等行为与实时扫描完全一致。

### 注意事项

1. **修改路由后必须重新生成**:新增控制器、修改 `#[Route]` / `#[Resource]` 注解、调整中间件后,
需重新执行 `php lee route:cache`,否则生效的仍是旧路由表。命令会先删除旧缓存再重建。
2. **插件路由会一并缓存**:命令在插件 `boot()` 完成后收集路由,插件注册的路由也会写入缓存;
缓存生效后插件对路由表的重复注册会被自动跳过。
3. **建议仅用于生产环境**:开发环境保留实时扫描,改完代码立即生效。
4. 缓存文件采用原子写入(临时文件 + 重命名),生成过程中不会影响正在处理的请求。

## 查看路由列表

使用 `route:list` 命令查看所有已注册的路由:
Expand Down
70 changes: 35 additions & 35 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
114 changes: 114 additions & 0 deletions src/console/command/RouteCacheCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace Lychee\console\command;

use Lychee\console\Input;
use Lychee\console\Output;
use Lychee\routing\Router;

/**
* 生成路由表缓存文件。
*
* 用于生产环境:跳过每请求的控制器目录扫描与注解反射,
* 直接加载已收集的路由数据。命令执行时输出路由收集耗时,
* 便于评估实时解析路由的开销。
*
* 动态路由参数(如 /users/{id})不受影响:缓存保存的是已编译的
* 正则表达式(命名捕获组),匹配与参数提取行为完全一致。
*/
class RouteCacheCommand extends \Lychee\console\Command
{
protected function configure(): void
{
$this->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('<error>No routes found, route cache was not generated.</error>');

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('<error>Failed to write route cache file.</error>');

return 1;
}

if (!@rename($tmpFile, $cacheFile)) {
@unlink($tmpFile);
$output->writeln('<error>Failed to move route cache file into place.</error>');

return 1;
}

$output->writeln('<info>Route cache generated successfully.</info>');
$output->writeln('');
$output->writeln(' Routes: ' . count($routes));
$output->writeln(sprintf(' Collect time: %.2f ms', $collectTime));
$output->writeln(' Cache file: ' . $cacheFile);
$output->writeln('');
$output->writeln('<comment>Re-run this command after any route change, delete the file to disable.</comment>');

return 0;
}

/**
* 解析路由缓存文件路径。
*/
public static function cacheFilePath(string $runtimePath): string
{
return rtrim($runtimePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'route_cache.php';
}

/**
* @param array<int, array<string, mixed>> $routes
* @param array<string, string> $namedRoutes
*/
private function renderPayload(array $routes, array $namedRoutes): string
{
$export = var_export([
'routes' => $routes,
'named_routes' => $namedRoutes,
], true);

return <<<PHP
<?php

declare(strict_types=1);

// 路由缓存文件,由 `php lee route:cache` 自动生成,请勿手动修改。
// 修改路由后需重新执行生成命令;删除本文件即恢复实时扫描。

return {$export};

PHP;
}
}
5 changes: 1 addition & 4 deletions src/console/stubs/config/view.stub
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
declare(strict_types=1);

return [
// 模板驱动:twig(默认)或 liquid(需 composer require liquid/liquid)
'driver' => 'twig',

// 模板根目录
'view_path' => app_path('view'),

Expand All @@ -18,6 +15,6 @@ return [
// 静态资源基础 URL,如配置 CDN 可填 'https://cdn.example.com'
'base_url' => '',

// 允许的模板后缀,按查找优先级排序(twig 驱动默认 ['.twig', '.html'],liquid 驱动默认 ['.liquid'])
// 允许的模板后缀,按查找优先级排序
'extensions' => ['.twig', '.html'],
];
4 changes: 2 additions & 2 deletions src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions src/routing/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,50 @@ class Router
/** @var array<string, string> */
private array $namedRoutes = [];

/**
* 路由表是否来自缓存文件。
*
* 为 true 时 registerController / registerDirectory 不再执行,
* 缓存表即为路由的唯一来源(其中已包含插件注册的路由)。
*/
private bool $loadedFromCache = false;

public function __construct(string $routePrefix = '')
{
$this->routePrefix = trim($routePrefix, '/');
}

/**
* 从缓存数据加载路由表,跳过目录扫描与注解反射。
*
* 缓存中的 pattern 为已编译的正则表达式,动态参数(如 {id})
* 以命名捕获组形式保留,dispatch 的匹配与参数提取行为与实时扫描完全一致。
*
* @param array<int, array{method:string, path:string, pattern:string, controller:class-string, action:string, middlewares:array<class-string>, cache:?int}> $routes
* @param array<string, string> $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);
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -203,6 +241,16 @@ public function getRoutes(): array
return $this->routes;
}

/**
* 获取命名路由映射表。
*
* @return array<string, string>
*/
public function getNamedRoutes(): array
{
return $this->namedRoutes;
}

/**
* 解析控制器类的路由前缀。
*
Expand Down
Loading
Loading