# 4.11 扩展性设计 :::tip 概述 系统通过六种机制保证良好的扩展性:属性配置、生命周期钩子、FormRequest 参数校验、中间件链、注解系统、服务层复用。新增模块无需修改框架代码,只需继承基类 + 配置属性 + 按需重写钩子。 ::: ## 扩展机制总览 | 机制 | 位置 | 作用 | 扩展方式 | |------|------|------|----------| | 属性配置 | Service 子类 | 声明式行为定义 | 设置属性值 | | 生命周期钩子 | Service 子类 | 注入定制逻辑 | 重写钩子方法 | | 参数格式校验 | FormRequest | HTTP 入口拦截非法输入 | 创建 Request + 类型注入 | | 中间件链 | `Http/Middleware/` | 请求拦截处理 | 创建中间件 + 注册别名 | | 注解系统 | `Attributes/` | 声明式元数据 | 创建注解 + 中间件反射读取 | | 服务层复用 | `Services/` | 跨模块通用能力 | 创建 Service 类 | ## 1. 属性配置模式 Service 层通过属性配置声明行为,新增模块只需设置属性,无需编写 CRUD 代码。 ### 完整属性清单(摘自 `app/Services/ArticleService.php` 实际代码) ```php class ArticleService extends BaseTenantService { use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps; // 数据权限能力按需 use HasDataScope /** * 绑定的模型类 */ protected string $modelClass = Article::class; /** * 单文件字段:保存时迁移临时文件,读取时补全域名 * * @var array */ protected array $fileFields = ['cover']; /** * 多文件字段:保存时逐个迁移,读取时逐个补全域名 * * @var array */ protected array $multiFileFields = ['images']; /** * 文件保存子目录 */ protected string $fileSaveDir = 'article'; /** * 分页时模糊匹配的字段 * * @var array */ protected array $pageLikeFields = ['title', 'author']; /** * 分页时精确匹配的字段 * * @var array */ protected array $pageEqFields = ['category_id', 'status']; /** * 默认排序规则(多字段) * * @var array> */ protected array $pageOrderBy = [ ['field' => 'tenant_id', 'type' => 'asc'], ['field' => 'sort', 'type' => 'asc'], ]; /** * 字典翻译配置:[字段 => 字典编码] * * @var array */ protected array $serializeMaps = [ 'status' => 'article_status', ]; /** * 数据权限的用户字段(data_scope=3 仅本人时按此字段过滤) */ protected string $dataScopeUserField = 'create_user'; } ``` 其它可声明属性(`uniqueFields`、`contentFields`、`dataScopeDeptField`、`tenantScopeField`、`treeParentField` / `treeLikeField` 等)均在 `BaseService` 中声明,完整清单见 [4.6 核心基类体系](/zh/architecture/base-classes.html)。 ### 属性驱动的自动行为 | 属性 | 触发的自动行为 | |------|---------------| | `fileFields` | add/update 时迁移临时文件,查询时补全域名 | | `multiFileFields` | 逗号分隔多文件逐个迁移/补全 | | `contentFields` | add/update 时处理富文本媒体文件,查询时替换占位符 | | `pageLikeFields` | pageList 时自动构建 LIKE 查询 | | `pageEqFields` | pageList 时自动构建精确查询 | | `pageOrderBy` / `orderFieldAllow` | 自动应用默认排序 / 动态排序白名单 | | `uniqueFields` | add/update 时自动校验唯一性(BizException 拦截) | | `serializeMaps` | 查询时自动补全 `{字段}Text` 字典显示名 | | `dataScopeUserField` / `dataScopeDeptField` | 查询时自动按创建人/部门过滤 | | `tenantScopeField` | 查询时自动叠加 `tenant_id` 条件,新增时自动填充 | | `treeParentField` / `treeLikeField` | 树形列表自动组装 / 子树模糊搜索 | :::tip 未启用的属性是空操作 `BaseService` 为 6 个 Trait 的方法提供占位实现,子类 `use` 对应 Trait 后真实实现才生效(占位覆盖机制)。属性声明了但未 `use` 对应 Trait 时,对应自动行为不会执行。 ::: ## 2. 生命周期钩子 通过重写钩子注入定制逻辑,无需修改基类。钩子方法在 `HasCrudOperations` 中定义为空实现或默认返回,子类按需覆盖。 ### 钩子清单 | 钩子 | 时机 | 参数 | 返回值 | 可拦截 | |------|------|------|--------|--------| | `beforeAdd` | 新增前 | `$data` | 处理后的 `$data` | ✅ 抛异常 | | `afterAdd` | 新增后 | `$id, $data` | void(事务内) | ✅ 抛异常回滚 | | `beforeUpdate` | 修改前 | `$id, $data` | 处理后的 `$data` | ✅ 抛异常 | | `afterUpdate` | 修改后 | `$id, $data` | void(事务内) | ✅ 抛异常回滚 | | `beforeDelete` | 删除前 | `$id` | void | ✅ 抛异常 | | `afterDelete` | 删除后 | `$id` | void(事务内) | ✅ 抛异常回滚 | | `beforeBatchDelete` | 批量删除前 | `$ids` | void | ✅ 抛异常 | | `afterBatchDelete` | 批量删除后 | `$ids` | void(事务内) | ✅ 抛异常回滚 | | `afterDetail` | 详情查询后 | `$id, &$data` | void(引用传递) | ❌ | | `afterPageList` | 列表查询后 | `&$records, $params` | void(引用传递,分页与全量列表均触发) | ❌ | ### 典型用法(摘自项目实际代码) ```php // app/Services/DeptService.php —— 删除前校验(抛 BizException 拦截删除并回滚) protected function beforeDelete(int $id): void { $hasChildren = Dept::where('parent_id', $id)->exists(); if ($hasChildren) { throw new BizException('存在子部门,不可删除'); } $hasUsers = User::where('dept_id', $id)->exists(); if ($hasUsers) { throw new BizException('该部门下存在用户,不可删除'); } } // app/Services/ArticleService.php —— 详情后处理(引用传递,补充关联数据) protected function afterDetail(int $id, array &$data): void { $images = $data['images'] ?? ''; $data['imagesList'] = ! empty($images) ? array_map('trim', explode(',', $images)) : []; } // app/Services/UserService.php —— 列表后处理(批量补充,避免 N+1) protected function afterPageList(array &$records, array $params): void { $userIds = array_column($records, 'id'); if (empty($userIds)) { return; } // 批量获取用户角色(仅查所需字段) $userRoles = []; $roleData = UserRole::whereIn('user_id', $userIds)->select('user_id', 'role_id')->get(); foreach ($roleData as $rd) { $userRoles[$rd->user_id][] = $rd->role_id; } $roleMap = Role::getNameMap(); $deptMap = Dept::getNameMap(); foreach ($records as &$item) { // 补充角色名称,组装为 [{id, name}] $roleIds = $userRoles[$item['id']] ?? []; $item['roles'] = array_map( fn ($roleId) => ['id' => $roleId, 'name' => $roleMap[$roleId] ?? ''], $roleIds ); $item['deptName'] = $deptMap[$item['dept_id'] ?? 0] ?? ''; unset($item['password'], $item['salt']); } } ``` ### 执行顺序 **add 流程:** `beforeAdd` → `checkUnique` → `DB::transaction { processTenantId → processFile → processMultiFile → processContent → Model::create(审计填充)→ afterAdd }` **update 流程:** `beforeUpdate` → `checkUnique(excludeId)` → `DB::transaction { 同上处理 → update(审计刷新)→ afterUpdate }` **delete 流程:** `beforeDelete` → `DB::transaction { is_delete = 1 → afterDelete }` **pageList/allList 流程:** `buildQuery` → `排序` → `分页/全量` → `processAfterRead(文件/字典翻译/字段过滤)` → `afterPageList` (参数格式校验在入口 FormRequest 完成,不在此流程内。) ## 3. 参数格式校验(FormRequest) 通过控制器方法类型注入 FormRequest,进入方法前自动校验,无需手动调用。 ### 扩展方式(摘自 `app/Http/Requests/ExampleRequest.php` 实际代码) ```php // 1. 创建请求类 // app/Http/Requests/ExampleRequest.php class ExampleRequest extends BaseRequest { /** * 校验规则 * * @return array 字段规则 */ public function rules(): array { return [ 'name' => 'required|string|max:255', // 案例名称:必填,最长 255 'avatar' => 'nullable|string|max:255', // 头像/图片地址:可空,最长 255 'type' => 'nullable|integer|in:1,2,3,4', // 类型:1=类型1,2=类型2,3=类型3,4=类型4 'status' => 'nullable|integer|in:1,2', // 状态:1=在用,2=停用 'sort' => 'nullable|integer', // 排序值:可空,整数 ]; } /** * 自定义错误消息 * * @return array 字段规则 => 错误消息 */ public function messages(): array { return [ 'name.required' => '案例名称不能为空', 'name.max' => '案例名称不能超过 255 个字符', 'avatar.max' => '图片地址不能超过 255 个字符', 'type.in' => '案例类型不合法,允许:1-类型1 2-类型2 3-类型3 4-类型4', 'status.in' => '状态值不合法,允许:1-在用 2-停用', 'sort.integer' => '排序值格式不正确', ]; } } // 2. 控制器类型注入(校验通过才进入方法) public function add(ExampleRequest $request): JsonResponse { return $this->_add($this->exampleService, $request->validated()); } ``` 详见 [3.4 FormRequest 验证器](/zh/guide/validator.html)。 ## 4. 中间件机制 通过中间件链式处理请求,可灵活添加新的拦截逻辑。 ### 现有中间件 ```text 请求进入 │ ▼ ProxyMiddleware(API 组前置) ← 受信代理真实 IP 还原 │ ▼ Cors(API 组前置) ← OPTIONS 预检 + 跨域响应头 │ ▼ ForceJsonResponse(API 组前置) ← Accept 强制 application/json │ ▼ Authenticate(路由级,别名 auth.jwt) ← JWT 认证 + #[Permission] 权限校验 → 401/403 │ 注入 attributes['userInfo'] ▼ ResolveTenant(路由级,别名 tenant) ← 租户上下文注入 → 403 │ 注入 attributes['tenantId'] ▼ LogOperation(路由级,别名 log.operation) ← 操作日志采集(terminate 落库) │ ▼ 控制器方法 (另有 DemoMiddleware,已注册 demo 别名,可挂载到路由组) ``` ### 创建自定义中间件 中间件结构参考(摘自 `app/Http/Middleware/DemoMiddleware.php` 实际代码): ```php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class DemoMiddleware { public function handle(Request $request, Closure $next): Response { // 非演示模式直接放行 if (! config('app.demo_mode', false)) { return $next($request); } // GET 请求放行(读操作不受限) if ($request->isMethod('GET')) { return $next($request); } // 检查是否有 #[DemoAllow] 注解(反射读取,存在即放行) $route = $request->route(); if ($route) { $reflection = new \ReflectionMethod( $route->getControllerClass(), $route->getActionMethod() ); if (! empty($reflection->getAttributes(DemoAllow::class))) { return $next($request); } } // 演示模式下拦截写操作 return response()->json([ 'code' => 403, 'ok' => false, 'msg' => '演示模式下禁止操作', 'data' => null, ], 403); } } ``` 注册方式(`bootstrap/app.php` 中的三种挂载点): ```php // 方式一:注册别名(现有 7 个别名即在此处) $middleware->alias([ 'proxy' => ProxyMiddleware::class, 'cors' => Cors::class, 'force.json' => ForceJsonResponse::class, 'auth.jwt' => Authenticate::class, 'tenant' => ResolveTenant::class, 'demo' => DemoMiddleware::class, 'log.operation' => LogOperation::class, ]); // 方式二:API 组前置(对全部 /api 请求生效) $middleware->api(prepend: [ProxyMiddleware::class, Cors::class, ForceJsonResponse::class]); // 方式三:路由组注册(routes/api.php) Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])->group(function () { /* ... */ }); ``` ## 5. 注解系统 通过 PHP 8 注解声明元数据,中间件通过反射读取,业务代码零侵入。 ### 现有注解 | 注解 | 目标 | 说明 | 读取方 | |------|------|------|--------| | `#[OperationLog]` | 方法 | 操作日志(标题、类型、描述模板) | `LogOperation` 中间件(ReflectionMethod) | | `#[WithoutOperationLog]` | 方法 | 跳过操作日志标记(标注后写操作也不记录) | `LogOperation` 中间件(ReflectionMethod) | | `#[Permission]` | 方法 | 权限校验(权限编码、名称) | `Authenticate` 中间件(ReflectionMethod) | | `#[DemoAllow]` | 方法 | 演示环境放行标记 | `DemoMiddleware`(ReflectionMethod) | ### 使用示例 ```php // 操作日志(TYPE 常量 30 种,描述支持 {param} 占位符) #[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')] // 权限校验 #[Permission('sys:user:add', '添加用户')] // 演示环境放行(允许在演示环境执行写操作) #[DemoAllow] // 跳过操作日志(已有专门日志载体的写接口,如登出写入 login_log) #[WithoutOperationLog] // 组合使用 #[OperationLog('文章管理-删除', OperationLog::TYPE_DELETE, '删除文章ID:{id}')] #[Permission('sys:article:delete', '删除文章')] public function delete(int $id): JsonResponse { return $this->_delete($this->service, $id); } ``` ### 注解结构与读取方式(摘自项目实际代码) ```php // 1. 定义注解类(app/Attributes/Permission.php 实际代码) #[Attribute(Attribute::TARGET_METHOD)] class Permission { /** * @param string $code 权限编码(鉴权唯一依据,如 sys:user:add) * @param string $name 权限名称(用于展示,如"添加用户") * @param string $description 权限描述(可选,补充说明) */ public function __construct( public string $code, public string $name = '', public string $description = '', ) {} } // 2. 在控制器方法上使用 #[Permission('sys:user:add', '添加用户')] public function add(UserRequest $request): JsonResponse { ... } // 3. 在中间件中读取(DemoMiddleware 实际代码同款反射方式) $reflection = new \ReflectionMethod($controllerClass, $method); $attrs = $reflection->getAttributes(DemoAllow::class); if (! empty($attrs)) { return $next($request); // 命中注解即放行 } ``` 新增自定义注解时,按同样结构在 `app/Attributes/` 下定义类,再在中间件中用 `ReflectionMethod::getAttributes()` 读取即可。 :::tip 注解读取性能 反射读取开销为微秒级,`Authenticate` / `LogOperation` 均直接使用 `ReflectionMethod` 读取(无缓存),避免开发阶段改注解后需等缓存过期。 ::: ## 6. 服务层复用 通用能力封装为 Service,跨模块复用,无状态、无副作用。 ### 现有服务清单 | 服务 | 文件(`app/Services/`) | 能力 | |------|------|------| | `JwtService` | `JwtService.php` | 登录、刷新、解析 Token(Redis 有状态) | | `PasswordService` | `PasswordService.php` | bcrypt 双重加盐加密 | | `DictCacheService` | `DictCacheService.php` | 字典查询(两级缓存) | | `CaptchaService` | `CaptchaService.php` | 图片验证码生成与校验(IP 限流) | | `RequestInfoService` | `RequestInfoService.php` | 客户端 IP、UA、归属地解析 | | `GeneratorService` | `GeneratorService.php` | 代码生成 | | `ExcelService` | `ExcelService.php` | Excel 导入导出 | | `DbMigrateService` | `DbMigrateService.php` | 跨数据库迁移引擎 | | `DbSchemaBuilder` | `DbSchemaBuilder.php` | DDL 生成器(跨库映射、Oracle 序列) | | `ConfigService` | `ConfigService.php` | 系统配置读写与缓存 | | `DictService` | `DictService.php` | 字典/字典项管理 | ### 使用示例 ```php // 字典服务(静态调用,内部两级缓存) DictCacheService::getText('gender', 1); // → '男' DictCacheService::getValue('user_status', '启用'); // → 1 // 密码服务 $encrypted = PasswordService::encrypt('123456'); // → ['password'=>'...', 'salt'=>'...'] // Excel 服务 ExcelService::importWithMap($filePath, $headerMap); ExcelService::export($list, $headers, '导出.xlsx'); // 请求信息(IP 经 ProxyMiddleware 还原真实值) $requestInfo = RequestInfoService::create(request()); $location = $requestInfo->getIpLocation(); // 基于 zoujingli/ip2region ``` ### 创建自定义服务 服务结构参考(摘自 `app/Services/ParamService.php` 实际代码——无状态静态方法 + 请求级缓存的典型写法): ```php namespace App\Services; use App\Models\Param; /** * 系统参数服务 * * 从 param 表获取系统参数配置。 * 数据量小,不使用持久缓存,仅保留请求级缓存避免同请求内重复查库。 */ class ParamService { /** * 请求级缓存:[参数编码 => 参数值] * * @var array */ protected static array $cache = []; /** * 获取参数值 * * 读取顺序:请求级缓存 → 查库。 */ public static function get(string $code): mixed { if (array_key_exists($code, static::$cache)) { return static::$cache[$code]; } $value = /* 查库逻辑 */ null; static::$cache[$code] = $value; return $value; } /** * 清除缓存(参数变更后调用) */ public static function clearCache(string $code = ''): void { if ($code !== '') { unset(static::$cache[$code]); return; } static::$cache = []; } } ``` 新增服务时按此结构放在 `app/Services/` 下:无状态、单一职责、静态方法或构造注入均可;跨模块复用的能力才提升为 Service,业务专属逻辑留在业务 Service 中。 ## 扩展性设计原则 ### 开闭原则(OCP) | 扩展场景 | 做法 | 不应该做 | |----------|------|----------| | 新增 CRUD 模块 | 创建 Request/Service/Model/Controller | 修改基类 | | 新增查询条件 | 设置 `pageLikeFields` / `pageEqFields` | 重写 `buildQuery` | | 新增文件处理 | `use HasFileProcessing` + 设置 `fileFields` | 手动搬运临时文件 | | 新增校验规则 | 创建 FormRequest + 类型注入 | 在 Controller 手动校验 | | 新增请求拦截 | 创建中间件 + 注册 | 修改现有中间件 | | 新增操作日志 | 添加 `#[OperationLog]` 注解 | 在方法内手动记录日志 | | 新增权限节点 | 添加 `#[Permission]` 注解 | 在方法内手动校验权限 | | 新增通用能力 | 创建 Service | 在业务 Service 里写静态方法 | ### 最小改动原则 新增一个完整的业务模块,只需要: 1. 建表(含公共字段) 2. 创建 Model(组合双 Trait,声明表名与 fillable) 3. 创建 FormRequest(定义规则与中文消息) 4. 创建 Service(继承 BaseService / BaseTenantService,配置属性 + 按需钩子) 5. 创建 Controller(构造注入 Service,CRUD 一行调用 + 双注解) 6. 注册路由(`routes/api.php` 认证组内) 7. 配置菜单权限(可选) 8. 前端 API + 页面(或交给代码生成器一次完成) **不需要修改的文件:** 基类、中间件、配置文件、横切服务。 (或直接使用 [第 10 章 代码生成器](/zh/generator/overview.html) 一键生成以上全部。)