Router.php 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. <?php
  2. declare(strict_types=1);
  3. namespace Glacier\Core;
  4. /**
  5. * URL 模式引擎(自研,零依赖)。
  6. *
  7. * 核心原则:URL 与物理路径完全解耦 —— 本类只消费 URL 模式与参数,
  8. * 从不把 URL 段拼进文件系统路径。
  9. *
  10. * 模式语法:
  11. * - 静态段: /about
  12. * - 参数段: /user/:id (:name 为命名参数)
  13. * - 可选段: /news[/:page]
  14. * - 方法: GET / POST / '*'(任意)
  15. */
  16. final class Router
  17. {
  18. /** @var list<array{method:string, pattern:string, regex:string, handler:callable, middleware:list<string>}> */
  19. private array $routes = [];
  20. public function add(string $method, string $pattern, array|callable|string $handler, array $middleware = []): void
  21. {
  22. $this->routes[] = [
  23. 'method' => strtoupper($method),
  24. 'pattern' => $pattern,
  25. 'regex' => $this->compile($pattern),
  26. 'handler' => $this->normalizeHandler($handler, $pattern),
  27. 'middleware' => $middleware,
  28. ];
  29. }
  30. public function get(string $pattern, array|callable|string $handler, array $middleware = []): void
  31. {
  32. $this->add('GET', $pattern, $handler, $middleware);
  33. }
  34. public function post(string $pattern, array|callable|string $handler, array $middleware = []): void
  35. {
  36. $this->add('POST', $pattern, $handler, $middleware);
  37. }
  38. public function any(string $pattern, array|callable|string $handler, array $middleware = []): void
  39. {
  40. $this->add('*', $pattern, $handler, $middleware);
  41. }
  42. /**
  43. * 把声明式处理器规范化为闭包。
  44. * PHP 8 起 ['类名', '非静态方法'] 不能经类名字符串直接调用(不再自动实例化),
  45. * 这里统一包装为实例调用,路由表保持简洁声明。
  46. */
  47. private function normalizeHandler(array|callable|string $handler, string $pattern): callable
  48. {
  49. if (is_array($handler) && isset($handler[0], $handler[1]) && is_string($handler[0]) && is_string($handler[1])) {
  50. [$class, $method] = $handler;
  51. return static fn(array $params = []) => (new $class())->{$method}($params);
  52. }
  53. if (!is_callable($handler)) {
  54. throw new \InvalidArgumentException("路由 {$pattern} 的处理器不是可调用项");
  55. }
  56. return $handler;
  57. }
  58. /**
  59. * 按注册顺序匹配 URL 路径。
  60. * @return array{handler:callable, params:array<string,string>, middleware:list<string>}|null
  61. */
  62. public function match(string $path, string $method): ?array
  63. {
  64. $method = strtoupper($method);
  65. foreach ($this->routes as $route) {
  66. if ($route['method'] !== '*' && $route['method'] !== $method) {
  67. continue;
  68. }
  69. if (preg_match($route['regex'], $path, $matches)) {
  70. $params = [];
  71. foreach ($matches as $key => $value) {
  72. if (is_string($key)) {
  73. // 路径参数做百分号解码(rawurldecode 不把 + 当空格);
  74. // 输出安全由模板默认转义兜底。
  75. $params[$key] = rawurldecode($value);
  76. }
  77. }
  78. return [
  79. 'handler' => $route['handler'],
  80. 'params' => $params,
  81. 'middleware' => $route['middleware'],
  82. ];
  83. }
  84. }
  85. return null;
  86. }
  87. /** 编译 URL 模式为正则(支持静态段、:param、[可选段],可选段不可嵌套)。 */
  88. private function compile(string $pattern): string
  89. {
  90. $out = '';
  91. $rest = $pattern;
  92. while ($rest !== '') {
  93. $pos = strpos($rest, '[');
  94. if ($pos === false) {
  95. $out .= $this->compileSegment($rest);
  96. break;
  97. }
  98. $out .= $this->compileSegment(substr($rest, 0, $pos));
  99. $end = strpos($rest, ']', $pos);
  100. if ($end === false) {
  101. throw new \InvalidArgumentException("路由模式存在未闭合的 '[': {$pattern}");
  102. }
  103. $inner = substr($rest, $pos + 1, $end - $pos - 1);
  104. $out .= '(?:' . $this->compileSegment($inner) . ')?';
  105. $rest = substr($rest, $end + 1);
  106. }
  107. return '~^' . $out . '$~';
  108. }
  109. /** 编译不含可选段的一段模式:按 / 分段,:name → 命名捕获组;前导/尾部空段忽略。 */
  110. private function compileSegment(string $segment): string
  111. {
  112. if ($segment === '') {
  113. return '';
  114. }
  115. $parts = [];
  116. foreach (explode('/', $segment) as $piece) {
  117. if ($piece === '') {
  118. continue; // 忽略前导/重复/尾部斜杠
  119. }
  120. if ($piece[0] === ':') {
  121. $name = substr($piece, 1);
  122. if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) {
  123. throw new \InvalidArgumentException("非法路由参数名: {$name}");
  124. }
  125. $parts[] = '/(?P<' . $name . '>[^/]+)';
  126. } else {
  127. $parts[] = '/' . preg_quote($piece, '~');
  128. }
  129. }
  130. return $parts === [] ? '/' : implode('', $parts);
  131. }
  132. }