feat(routes): 初始化路由注解系统

- 添加路由注解基础类 Get、Post、Put、Delete
- 实现路由前缀和版本控制注解 Prefix、Version
- 添加中间件和路由名称注解 Middleware、Name
- 创建路由服务提供者 RouteServiceProvider
- 实现控制器目录扫描和路由自动注册
- 添加配置文件支持控制器目录自定义
- 完善单元测试和集成测试用例
- 添加测试控制器和相关测试代码
- 配置 composer 自动加载和 laravel 服务提供者
- 添加 phpunit 测试配置和基础测试用例
This commit is contained in:
yeyixianyang 2025-12-06 21:13:27 +08:00
commit 2c309b0f27
22 changed files with 3141 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

42
composer.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "jltx/routes",
"description": "采用注解形式的路由系统,以避免在大型项目中维护大静态路由表",
"minimum-stability": "stable",
"license": "proprietary",
"authors": [
{
"name": "hui",
"email": "yeyixianyang@163.com"
}
],
"autoload": {
"psr-4": {
"Jltx\\Routes\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Jltx\\Routes\\Tests\\": "tests/"
}
},
"require": {
"php": "^8.4",
"illuminate/contracts": "^12.0",
"illuminate/support": "^12.41"
},
"require-dev": {
"laravel/pint": "^1.26",
"phpunit/phpunit": "^10.5"
},
"scripts": {
"lint": "vendor/bin/pint",
"test": "vendor/bin/phpunit"
},
"extra": {
"laravel": {
"providers": [
"Jltx\\Routes\\Providers\\RouteServiceProvider"
]
}
}
}

2441
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
config/routes.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Controller Directories
|--------------------------------------------------------------------------
|
| Here you may specify the list of controller directories that should be scanned
| for route attributes. By default, it scans the standard Laravel controllers
| directory.
|
*/
'controller_directories' => [
// 注意:在包测试环境中 app_path() 不可用
// 在真实的 Laravel 应用中,这将是 app_path('Http/Controllers')
'Http/Controllers',
],
];

23
phpunit.xml.dist Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="Integration">
<directory suffix="Test.php">./tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

8
src/Attribute/Delete.php Normal file
View File

@ -0,0 +1,8 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Delete extends RouteBase {}

8
src/Attribute/Get.php Normal file
View File

@ -0,0 +1,8 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Get extends RouteBase {}

11
src/Attribute/Group.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Group
{
public function __construct(public ?string $prefix = null, public ?string $name = null, public array $middleware = []) {}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Middleware
{
public function __construct(public array|string $middleware)
{
if (is_string($middleware)) {
$this->middleware = [$middleware];
}
}
}

11
src/Attribute/Name.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Name
{
public function __construct(public string $name) {}
}

8
src/Attribute/Post.php Normal file
View File

@ -0,0 +1,8 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Post extends RouteBase {}

11
src/Attribute/Prefix.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Prefix
{
public function __construct(public string $prefix) {}
}

8
src/Attribute/Put.php Normal file
View File

@ -0,0 +1,8 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Put extends RouteBase {}

View File

@ -0,0 +1,11 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class RouteBase
{
public function __construct(public string $path, public array $middleware = [], public ?string $name = null, public string $version = 'v1', public string $prefix = '') {}
}

11
src/Attribute/Version.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace Jltx\Routes\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Version
{
public function __construct(public string $version) {}
}

View File

@ -0,0 +1,179 @@
<?php
namespace Jltx\Routes\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Jltx\Routes\Attribute\Delete;
use Jltx\Routes\Attribute\Get;
use Jltx\Routes\Attribute\Middleware;
use Jltx\Routes\Attribute\Post;
use Jltx\Routes\Attribute\Prefix;
use Jltx\Routes\Attribute\Put;
use Jltx\Routes\Attribute\Version;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
class RouteServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
// 合并配置文件
$this->mergeConfigFrom(
__DIR__.'/../../config/routes.php', 'routes'
);
}
/**
* Bootstrap services.
*
* @throws ReflectionException
*/
public function boot(): void
{
// 发布配置文件
$this->publishes([
__DIR__.'/../../config/routes.php' => config_path('routes.php'),
], 'routes-config');
Route::middleware('api')
->prefix('api')
->group(function () {
// 从配置中获取控制器目录列表
$directories = config('routes.controller_directories', [app_path('Http/Controllers')]);
// 遍历每个目录进行扫描
foreach ($directories as $directory) {
if (is_dir($directory)) {
$this->scanControllers($directory);
}
}
});
}
/**
* @throws ReflectionException
*/
private function scanControllers(string $path): void
{
// 检查目录是否存在
if (! is_dir($path)) {
return;
}
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
foreach ($files as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$class = $this->getClassFromFile($file->getRealPath());
if (! $class || ! class_exists($class)) {
continue;
}
$this->registerRoutesFromClass($class);
}
}
private function getClassFromFile(string $file): ?string
{
$content = file_get_contents($file);
if (preg_match('/namespace\s+(.+?);/', $content, $ns) &&
preg_match('/class\s+(\w+)/', $content, $cls)) {
return $ns[1].'\\'.$cls[1];
}
return null;
}
/**
* @throws ReflectionException
*/
private function registerRoutesFromClass(string $class): void
{
$refClass = new ReflectionClass($class);
// 类级别属性
$classPrefix = $this->getAttributeValue($refClass, Prefix::class, 'prefix');
$classVersion = $this->getAttributeValue($refClass, Version::class, 'version');
$classMiddleware = $this->getAttributeValues($refClass);
foreach ($refClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isConstructor() || $method->class !== $class) {
continue;
}
// 方法级属性
$methodPrefix = $this->getAttributeValue($method, Prefix::class, 'prefix');
$methodVersion = $this->getAttributeValue($method, Version::class, 'version');
$methodMiddleware = $this->getAttributeValues($method);
$version = $methodVersion ?? $classVersion ?? 'v1';
$prefix = $classPrefix.($methodPrefix ? '/'.trim($methodPrefix, '/') : '');
$middleware = array_merge($classMiddleware, $methodMiddleware);
// HTTP 动作
foreach ([
Get::class => 'get',
Post::class => 'post',
Put::class => 'put',
Delete::class => 'delete',
] as $attrClass => $httpVerb) {
$attrs = $method->getAttributes($attrClass);
foreach ($attrs as $attr) {
$instance = $attr->newInstance();
$path = trim($instance->path, '/');
$routePath = '/'.trim($version, '/').'/'.trim($prefix, '/').($path ? '/'.$path : '');
$routePath = preg_replace('#//+#', '/', $routePath);
$routeMiddleware = array_merge($middleware, $instance->middleware ?? []);
$version = $instance->version ?? $version;
$routeName = ($instance->name ?? Str::random()).'.'.$version;
$route = Route::$httpVerb($routePath, [$class, $method->getName()]);
if ($routeMiddleware) {
$route->middleware($routeMiddleware);
}
$route->name($routeName);
}
}
}
}
private function getAttributeValue($ref, string $attrClass, string $property): ?string
{
$attrs = $ref->getAttributes($attrClass);
if (! $attrs) {
return null;
}
$instance = $attrs[0]->newInstance();
return $instance->$property ?? null;
}
private function getAttributeValues($ref): array
{
$property = 'name';
$results = [];
$attrs = $ref->getAttributes(Middleware::class);
foreach ($attrs as $attr) {
$instance = $attr->newInstance();
$results[] = $instance->$property;
}
return $results;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace Jltx\Routes\Tests\Feature;
use Jltx\Routes\Attribute\Get;
use Jltx\Routes\Attribute\Middleware;
use Jltx\Routes\Attribute\Post;
use Jltx\Routes\Attribute\Prefix;
use Jltx\Routes\Attribute\Version;
use Jltx\Routes\Tests\Fixtures\Controllers\TestController;
use Jltx\Routes\Tests\TestCase;
use ReflectionClass;
class RouteRegistrationTest extends TestCase
{
/** @test */
public function it_can_scan_controller_and_extract_attributes()
{
// 测试控制器类的属性提取
$reflection = new ReflectionClass(TestController::class);
// 检查类级别的属性
$prefixAttrs = $reflection->getAttributes(Prefix::class);
$versionAttrs = $reflection->getAttributes(Version::class);
$middlewareAttrs = $reflection->getAttributes(Middleware::class);
$this->assertCount(1, $prefixAttrs);
$this->assertCount(1, $versionAttrs);
$this->assertCount(1, $middlewareAttrs);
// 检查方法级别的属性
$methods = $reflection->getMethods();
// 查找 getUsers 方法
$getUsersMethod = null;
foreach ($methods as $method) {
if ($method->getName() === 'getUsers') {
$getUsersMethod = $method;
break;
}
}
$this->assertNotNull($getUsersMethod);
$getAttrs = $getUsersMethod->getAttributes(Get::class);
$this->assertCount(1, $getAttrs);
}
/** @test */
public function it_can_create_route_attribute_instances()
{
// 测试创建路由属性实例
$reflection = new ReflectionClass(TestController::class);
$methods = $reflection->getMethods();
// 查找 createUser 方法
$createUserMethod = null;
foreach ($methods as $method) {
if ($method->getName() === 'createUser') {
$createUserMethod = $method;
break;
}
}
$this->assertNotNull($createUserMethod);
// 获取 Post 属性实例
$postAttrs = $createUserMethod->getAttributes(Post::class);
$this->assertCount(1, $postAttrs);
$postInstance = $postAttrs[0]->newInstance();
$this->assertInstanceOf(Post::class, $postInstance);
$this->assertEquals('users', $postInstance->path);
}
/** @test */
public function it_supports_multiple_middleware_attributes()
{
// 测试重复的中间件属性支持
$reflection = new ReflectionClass(TestController::class);
$methods = $reflection->getMethods();
// 查找 createUser 方法(应该有两个中间件:类级别的 auth 和方法级别的 admin
$createUserMethod = null;
foreach ($methods as $method) {
if ($method->getName() === 'createUser') {
$createUserMethod = $method;
break;
}
}
$this->assertNotNull($createUserMethod);
$middlewareAttrs = $createUserMethod->getAttributes(Middleware::class);
// 方法级别应该有一个 Middleware 属性
$this->assertCount(1, $middlewareAttrs);
// 类级别的 Middleware 属性也需要被检测到
$classMiddlewareAttrs = $reflection->getAttributes(Middleware::class);
$this->assertCount(1, $classMiddlewareAttrs);
}
/** @test */
public function it_can_override_version_attribute()
{
// 测试版本属性覆盖
$reflection = new ReflectionClass(TestController::class);
$methods = $reflection->getMethods();
// 查找 getPost 方法(应该覆盖类级别的版本)
$getPostMethod = null;
foreach ($methods as $method) {
if ($method->getName() === 'getPost') {
$getPostMethod = $method;
break;
}
}
$this->assertNotNull($getPostMethod);
$versionAttrs = $getPostMethod->getAttributes(Version::class);
$this->assertCount(1, $versionAttrs);
$versionInstance = $versionAttrs[0]->newInstance();
$this->assertEquals('v3', $versionInstance->version);
}
/** @test */
public function it_can_parse_class_file_for_namespace_and_class_name()
{
// 测试从文件中解析命名空间和类名
$content = file_get_contents(__DIR__.'/../Fixtures/Controllers/TestController.php');
$namespace = null;
$class = null;
if (preg_match('/namespace\s+(.+?);/', $content, $ns) &&
preg_match('/class\s+(\w+)/', $content, $cls)) {
$namespace = $ns[1];
$class = $cls[1];
}
$this->assertEquals('Jltx\Routes\Tests\Fixtures\Controllers', $namespace);
$this->assertEquals('TestController', $class);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Jltx\Routes\Tests\Fixtures\Controllers;
use Jltx\Routes\Attribute\Get;
use Jltx\Routes\Attribute\Middleware;
use Jltx\Routes\Attribute\Post;
use Jltx\Routes\Attribute\Prefix;
use Jltx\Routes\Attribute\Version;
#[Prefix('api')]
#[Version('v2')]
#[Middleware('auth')]
class TestController
{
#[Get('users')]
public function getUsers()
{
return 'get users';
}
#[Post('users')]
#[Middleware('admin')]
public function createUser()
{
return 'create user';
}
#[Get('posts/{id}')]
#[Version('v3')]
public function getPost()
{
return 'get post';
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Jltx\Routes\Tests\Integration;
use Jltx\Routes\Tests\Fixtures\Controllers\TestController;
use Jltx\Routes\Tests\TestCase;
use ReflectionClass;
class RouteScanningTest extends TestCase
{
/** @test */
public function it_can_scan_directory_and_find_controllers()
{
// 测试目录扫描功能
$this->assertTrue(class_exists(TestController::class));
$reflection = new ReflectionClass(TestController::class);
$this->assertTrue($reflection->isUserDefined());
}
/** @test */
public function it_can_read_config_values()
{
// 测试配置读取功能
// 因为这是一个包环境,我们不能直接访问 Laravel 的 config() 函数
// 但我们可以通过检查配置文件的内容来验证结构
$config = include __DIR__.'/../../config/routes.php';
$this->assertArrayHasKey('controller_directories', $config);
$this->assertIsArray($config['controller_directories']);
// 注意:由于 app_path() 是 Laravel 辅助函数,在包测试环境中不可用
// 我们只需验证配置结构即可
}
}

10
tests/TestCase.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace Jltx\Routes\Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View File

@ -0,0 +1,67 @@
<?php
namespace Jltx\Routes\Tests\Unit;
use Jltx\Routes\Attribute\Delete;
use Jltx\Routes\Attribute\Get;
use Jltx\Routes\Attribute\Middleware;
use Jltx\Routes\Attribute\Post;
use Jltx\Routes\Attribute\Prefix;
use Jltx\Routes\Attribute\Put;
use Jltx\Routes\Attribute\Version;
use Jltx\Routes\Providers\RouteServiceProvider;
use Jltx\Routes\Tests\TestCase;
use ReflectionClass;
class RouteServiceProviderTest extends TestCase
{
/** @test */
public function it_can_be_instantiated()
{
// 简单测试类可以被加载
$this->assertTrue(class_exists(RouteServiceProvider::class));
}
/** @test */
public function it_has_all_required_route_attributes()
{
// 测试所有必需的路由属性类是否存在
$this->assertTrue(class_exists(Get::class));
$this->assertTrue(class_exists(Post::class));
$this->assertTrue(class_exists(Put::class));
$this->assertTrue(class_exists(Delete::class));
$this->assertTrue(class_exists(Prefix::class));
$this->assertTrue(class_exists(Version::class));
$this->assertTrue(class_exists(Middleware::class));
}
/** @test */
public function route_attributes_have_correct_targets()
{
// 测试路由属性的目标是否正确
$getAttribute = new ReflectionClass(Get::class);
$postAttribute = new ReflectionClass(Post::class);
$prefixAttribute = new ReflectionClass(Prefix::class);
$versionAttribute = new ReflectionClass(Version::class);
$middlewareAttribute = new ReflectionClass(Middleware::class);
// Get 和 Post 应该只能用于方法
$getAttributes = $getAttribute->getAttributes(\Attribute::class);
$this->assertNotEmpty($getAttributes);
$postAttributes = $postAttribute->getAttributes(\Attribute::class);
$this->assertNotEmpty($postAttributes);
// Prefix 可以用于类和方法
$prefixAttributes = $prefixAttribute->getAttributes(\Attribute::class);
$this->assertNotEmpty($prefixAttributes);
// Version 可以用于类和方法
$versionAttributes = $versionAttribute->getAttributes(\Attribute::class);
$this->assertNotEmpty($versionAttributes);
// Middleware 可以用于类和方法,并且是可重复的
$middlewareAttributes = $middlewareAttribute->getAttributes(\Attribute::class);
$this->assertNotEmpty($middlewareAttributes);
}
}

16
tests/Unit/SampleTest.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace Jltx\Routes\Tests\Unit;
use PHPUnit\Framework\TestCase;
class SampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_example(): void
{
$this->assertTrue(true);
}
}