🔖 代码注解
SunAdmin 通过 PHP 8 属性(attribute)声明路由、依赖注入等元数据。本页集中列出项目中实际使用的所有注解:签名、用途、工作机制、典型示例、注意事项。
🎯 本页目标
读完本章节后,应能:
- 区分项目自定义注解(
#[Inject]、#[ApiDoc]、#[OpenGet]、#[OpenPost])与 Spatie 提供的路由注解。 - 知道每个注解应该写在类还是方法、可以传哪些参数。
- 写公开接口、登录接口、尝试登录接口时正确选择注解组合。
- 在需要时扩展自定义路由注解或属性级注解。
🧾 注解一览
| 注解 | 来源 | 是否项目自定义 | 适用范围 |
|---|---|---|---|
#[Inject] | App\Core\Attributes\Inject | ✅ 项目自定义 | 类属性 |
#[ApiDoc] | App\Core\Attributes\ApiDoc | ✅ 项目自定义 | 控制器类 / 控制器方法 |
#[Group] | Spatie\RouteAttributes\Attributes\Group | ❌ Spatie | 控制器类 |
#[Get] | Spatie\RouteAttributes\Attributes\Get | ❌ Spatie | 控制器方法 |
#[Post] | Spatie\RouteAttributes\Attributes\Post | ❌ Spatie | 控制器方法 |
#[Put] | Spatie\RouteAttributes\Attributes\Put | ❌ Spatie | 控制器方法 |
#[Patch] | Spatie\RouteAttributes\Attributes\Patch | ❌ Spatie | 控制器方法 |
#[Delete] | Spatie\RouteAttributes\Attributes\Delete | ❌ Spatie | 控制器方法 |
#[Options] | Spatie\RouteAttributes\Attributes\Options | ❌ Spatie | 控制器方法 |
#[Resource] | Spatie\RouteAttributes\Attributes\Resource | ❌ Spatie | 控制器类 |
#[OpenGet] | App\Core\Attributes\OpenGet | ✅ 项目自定义 | 控制器方法 |
#[OpenPost] | App\Core\Attributes\OpenPost | ✅ 项目自定义 | 控制器方法 |
#[Put]、#[Patch]、#[Delete]、#[Options] 与 #[Get]/#[Post] 行为一致,仅 HTTP 方法不同;#[Resource] 用于在控制器类上一次性声明 RESTful 资源路由。Spatie 包还提供更多扩展注解,按 Spatie 文档使用即可。
🪛 依赖注入:#[Inject]
用途
把容器解析得到的对象,自动注入到类属性,省去构造函数手写一长串依赖。Controller、Dao、Service、Action、Middleware、Job 都可以用。
签名
namespace App\Core\Attributes;
#[Attribute(Attribute::TARGET_PROPERTY)]
class Inject
{
public function __construct(
public readonly ?string $abstract = null,
) {}
}| 参数 | 类型 | 说明 |
|---|---|---|
abstract | ?string | 容器绑定 key。null 时使用属性声明类型,绝大多数场景留空即可 |
工作机制
app/Core/Container/PropertyInjector.php 反射扫描类属性:
- 找出所有带
#[Inject]的属性。 - 读属性声明类型;要求是非内置(非
int/string/array/...)的 class/interface。 - 调用
app()->make($abstract ?? $type)解析依赖,赋值给属性。 - 反射结果按类缓存,后续创建同类对象不再重复扫描。
被注入对象会随项目容器解析流程一起处理,业务层无需手动触发。
典型示例
控制器注入 Dao:
use App\Core\Attributes\Inject;
use App\Core\Http\Controllers\ApiController;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Group;
#[Group(prefix: 'article')]
class ArticleController extends ApiController
{
#[Inject]
protected ArticleDao $dao;
#[Get('index')]
public function index()
{
return success($this->dao->getList([]));
}
}Dao 注入 Model(用于复用同一个模型实例):
use App\Core\Attributes\Inject;
use App\Core\Dao\BaseDao;
class ArticleDao extends BaseDao
{
#[Inject]
protected Article $articleModel;
protected function model(): Article
{
return $this->articleModel;
}
}显式指定接口绑定:
#[Inject(CouponServiceInterface::class)]
protected CouponServiceInterface $coupon;与构造函数注入的对比
#[Inject] 不替代 Laravel 的构造函数注入,两种写法容器都能正常解析。差异在于"样板代码量"和"依赖位置":
不使用 #[Inject](传统构造函数注入):
use App\Core\Http\Controllers\ApiController;
use Modules\System\Application\Services\Auth\LoginService;
use Modules\System\Dao\Article\ArticleDao;
use Modules\System\Dao\Article\CategoryDao;
class ArticleController extends ApiController
{
public function __construct(
protected ArticleDao $articleDao,
protected CategoryDao $categoryDao,
protected LoginService $loginService,
) {
parent::__construct();
}
public function index()
{
return success($this->articleDao->getList([]));
}
}使用 #[Inject](属性注入):
use App\Core\Attributes\Inject;
use App\Core\Http\Controllers\ApiController;
use Modules\System\Application\Services\Auth\LoginService;
use Modules\System\Dao\Article\ArticleDao;
use Modules\System\Dao\Article\CategoryDao;
class ArticleController extends ApiController
{
#[Inject]
protected ArticleDao $articleDao;
#[Inject]
protected CategoryDao $categoryDao;
#[Inject]
protected LoginService $loginService;
public function index()
{
return success($this->articleDao->getList([]));
}
}两种写法的取舍:
| 维度 | 构造函数注入 | #[Inject] 属性注入 |
|---|---|---|
| 样板代码 | 需要写 __construct 和 parent::__construct() | 不需要 |
| 依赖位置 | 集中在构造函数参数列表 | 散落在每个属性声明上 |
| 增删依赖 | 改构造函数签名 | 加/删一行属性 |
子类有自己的 __construct | 必须显式调用 parent::__construct() 把父类依赖往上传 | 子类无需关心父类依赖 |
直接 new 创建对象 | 直接传依赖即可 | 必须走容器 app()->make(),否则属性为 null |
| 依赖是否可变 | readonly 友好,构造后不可变 | 反射赋值,初始化后仍可被外部覆盖 |
项目约定:Controller、Dao、Service、Action、Job 这类由容器解析、不会被手动 new 的类优先使用 #[Inject],减少构造函数样板;如果某个类需要在脚本、测试中频繁手动 new,或希望依赖 readonly 不可变,仍可走构造函数注入。
注意事项
- 属性必须声明非内置类型,不能写
mixed、int、string等。 private与protected属性都可以注入,由PropertyInjector通过反射赋值。- 已经在构造函数里初始化的属性会被跳过,不会被注入覆盖。
📘 接口文档:#[ApiDoc]
用途
给 OpenAPI 导出补充接口中文名称、tag、描述和参数。控制器类上声明 tag,控制接口归属目录;控制器方法上声明 summary,控制具体接口名称。所有对外接口都应通过 #[ApiDoc] 声明文档名称,避免到导出器里反查集中兜底字典。
签名
namespace App\Core\Attributes;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class ApiDoc
{
public function __construct(
public string $summary = '',
public string $tag = '',
public string $description = '',
public array $parameters = [],
) {}
}| 参数 | 类型 | 说明 |
|---|---|---|
summary | string | 接口名称,例如“装修数据”“同步知识库” |
tag | string | 控制器分组名称,通常写在控制器类上 |
description | string | 接口说明,会追加到 OpenAPI operation description |
parameters | array | 手动补充 OpenAPI 参数,适合复杂或动态参数 |
示例
use App\Core\Attributes\ApiDoc;
use App\Core\Attributes\OpenGet;
#[ApiDoc(tag: '装修')]
class DecorateController extends ApiController
{
#[OpenGet('page')]
#[ApiDoc(summary: '按 Key 获取装修页')]
public function page()
{
$params = $this->request()->pick([
['key', 'home'],
]);
return success($this->decorateAction->page((string) $params['key']));
}
}OpenapiExporter 会读取类上的 tag 生成分组,读取方法上的 summary 作为接口中文名,并从 $this->request()->pick([...]) 推导 key 这类简单参数。复杂场景可以手动补充:
#[ApiDoc(
summary: '按 Key 获取装修页',
parameters: [
['name' => 'key', 'type' => 'string', 'description' => '装修页面 Key', 'example' => 'home'],
],
)]🧭 路由分组:#[Group]
用途
给控制器内所有路由方法统一加前缀和中间件,避免每个方法重复写。
签名
#[Group(
prefix: ?string,
middleware: array|string = [],
where: array = [],
domain: ?string = null,
name: ?string = null,
)]业务里最常用的是 prefix 与 middleware。
路径拼接
最终接口路径 = 路由扫描根前缀 + Group::prefix + 方法注解 uri。
| 控制器目录 | 扫描根前缀 |
|---|---|
modules/{Module}/Http/Controllers/Admin | /adminapi/{slug} |
modules/{Module}/Http/Controllers/Api | /api/{slug} |
{slug} 由模块 module.json 的 slug 字段决定(如 System → system,UserCenter → user-center)。
示例
#[Group(prefix: 'article/list')]
class ArticleController extends ApiController
{
#[Get('index')] // GET /adminapi/system/article/list/index
public function index() {}
#[Post('save')] // POST /adminapi/system/article/list/save
public function save() {}
}控制器级中间件:
#[Group(prefix: 'upload', middleware: ['throttle:admin-upload'])]
class UploadController extends ApiController { /* ... */ }📡 HTTP 方法:#[Get] / #[Post] / #[Delete]
签名
#[Get(
uri: string,
name: ?string = null,
middleware: array|string = [],
withoutMiddleware: array|string = [],
where: array = [],
domain: ?string = null,
)]#[Post]、#[Delete]、其他 Spatie 提供的 HTTP 注解签名一致。
| 参数 | 用途 |
|---|---|
uri | 相对 #[Group::prefix] 的路径,支持 {id} 这类参数占位 |
name | 命名路由,方便后端 route('name') 反查 |
middleware | 当前方法叠加的中间件 |
withoutMiddleware | 显式排除继承到的中间件 |
与 #[Group] 组合
方法注解的 uri 拼接在 #[Group::prefix] 之后;中间件按"目录默认 + Group + 方法"叠加。
#[Group(prefix: 'order', middleware: ['admin.audit-log'])]
class OrderController extends ApiController
{
// 自动叠加 admin.auth(目录默认)+ admin.audit-log(Group)
#[Get('index')]
public function index() {}
// 在前两个基础上再追加 throttle:admin-export
#[Get('export', middleware: ['throttle:admin-export'])]
public function export() {}
}路径参数
#[Get('read/{id}')]
public function read(int $id) {}id 会作为方法参数注入,Laravel 自动按类型转换。
命名路由
#[Post('save', name: 'admin.article.save')]
public function save() {}后端代码可用 route('admin.article.save') 反查 URL。
🔓 公开接口:#[OpenGet] / #[OpenPost]
用途
声明"不需要登录的对外接口"。Open* 注解构造时会自动把项目三大认证中间件追加到 withoutMiddleware:
['admin.auth', 'api.auth', 'auth:sanctum']这样无论控制器位于 Admin 还是 Api 目录、默认中间件是 admin.auth 还是 api.auth,都会被本注解放行。
签名
#[OpenGet(
uri: string,
name: ?string = null,
middleware: array|string = [],
withoutMiddleware: array|string = [], // 会自动并入三大认证中间件
)]#[OpenPost] 签名一致。
与普通 #[Get]/#[Post] 的差异
| 场景 | 普通 #[Get] | #[OpenGet] |
|---|---|---|
| 控制器位于 Admin 目录 | 必须管理员登录 | 公开访问 |
| 控制器位于 Api 目录 | 必须用户登录 | 公开访问 |
显式追加 middleware | 叠加生效 | 同样叠加生效 |
示例
后台登录接口(公开 + 限流):
#[OpenPost('account', middleware: ['throttle:admin-login'])]
public function account(LoginRequest $form)
{
return success();
}用户端公开文章详情:
#[OpenGet('article/detail')]
public function detail()
{
return success();
}与 api.try-auth 配合:公开但识别登录态
如果接口允许游客访问,但登录后想给登录用户更多个性化数据:
#[OpenGet('article/detail', middleware: ['api.try-auth'])]
public function detail()
{
$user = $this->user(); // 游客时为 null,登录态时返回用户
return success([
'is_login' => filled($user),
]);
}api.try-auth 不会拦截游客,但会在请求头携带有效 token 时自动注入用户身份。
🧮 常见组合速查
| 场景 | 控制器目录 | 用法 |
|---|---|---|
| 后台必须登录接口 | Admin/ | #[Get] 或 #[Post](默认 admin.auth) |
| 后台公开接口(登录、验证码) | Admin/ | #[OpenGet] / #[OpenPost] |
| 用户端必须登录接口 | Api/ | #[Get] 或 #[Post](默认 api.auth) |
| 用户端公开接口(首页、文章) | Api/ | #[OpenGet] / #[OpenPost] |
| 用户端公开但识别登录态 | Api/ | #[OpenGet] + middleware: ['api.try-auth'] |
| 公开接口 + 限流 | 任意 | #[OpenPost('account', middleware: ['throttle:admin-login'])] |
| 删除资源 | 任意 | #[Delete('items/{id}')] |
🔗 参考
- 接口注册流程视角的注解使用:路由与认证
- 全局 helper 与中间件:公共函数与工具
- 上游包文档:Spatie Laravel Route Attributes
