首页 / 资讯中心 / 文章详情

ThinkPHP 8 对齐 PSR 规范:自动加载、容器与中间件实践

ThinkPHP 8 对齐 PSR 规范:自动加载、容器与中间件实践 ★ FEATURED ARTICLE
做后端这十来年我接过不少从别人手里交接下来的 ThinkPHP 项目最深的感受是能跑起来的代码和能交接的代码完全是两回事。前者靠的是作者脑子里的记忆后者靠的是规范和约定。ThinkPHP 8 把 PHP 基线拉到 8.0 以上之后框架本身在类型声明、容器、服务注册上做了一轮收敛而 PSR 标准恰好是把这套收敛落到工程实践里的抓手。这篇内容想聊的就是这件事——ThinkPHP 8 到底对齐了哪些 PSR 规范哪些地方只是看起来像其实不是以及在真实业务里怎么把这些规范拆成可执行的编码习惯。不管你刚上手 ThinkPHP 8还是带着 ThinkPHP 5.x 的老底子往过迁或者是团队里负责定规范的那个人下面这些内容都能直接拿去用。1. 先把 PSR 这张地图摊开ThinkPHP 8 到底对齐了哪些规范1.1 一张表看清 PSR 与 ThinkPHP 8 的对应关系很多同行一听说 PSR 就觉得是代码风格检查这个理解太窄。PSR 是一整套从自动加载、编码风格、日志、缓存、容器、HTTP 消息到事件总线的接口约定编号从 PSR-1 排到 PSR-20。ThinkPHP 8 并没有全量实现它挑了对自己生态最重要的几个来做剩下的靠社区包补位。这里我按自己的实际使用情况整理了一张表标注清楚原生实现部分兼容需要适配三档避免大家被一些过期文章误导。规范编号规范名称ThinkPHP 8 的支持情况实际使用建议PSR-1基础编码规范原生遵循类名大驼峰、文件 UTF-8 无 BOM、常量全大写PSR-4自动加载规范完全实现走 Composercomposer.json 里做命名空间映射PSR-11容器接口容器实现 ContainerInterface用得少但很关键做接口绑定PSR-12扩展编码风格框架代码遵循业务代码靠工具上 PHP-CS-Fixer 做流水线PSR-3日志接口方法风格接近不直接实现 LoggerInterface想跨框架替换就自己写适配器PSR-16简易缓存接口不直接实现门面风格写一层薄适配器即可PSR-7HTTP 消息接口不实现用自有 Request/Response别硬套中间件签名会打架PSR-15中间件接口类 PSR-15签名不同理解差异不要盲目照抄别人代码PSR-18HTTP 客户端不内置走第三方库依赖注入时面向 PSR-18 接口这张表我建议直接贴到团队 wiki 里。原因很简单新手最容易犯的错就是拿着一份PSR 全实现的说法往 ThinkPHP 上硬套结果在中间件那块卡两天。后面我会专门用一节讲 PSR-15 的差异那是坑最深的地方。1.2 为什么对齐规范比能跑起来更值钱我先讲个真实碰到的情况。某内部管理系统最早是三个人一起写的一个人管订单模块一个人管库存一个人管用户。三个人的命名习惯完全不同有人把业务逻辑全塞进控制器Index控制器写着八百行有人习惯在模型里做数据库查询又顺手做参数校验还有人在模型里直接exit输出 JSON。功能都上线了但只要换人维护改一个字段名就要全项目搜索。后来做重构第一件事不是改架构而是先立编码规范、先统一自动加载、先统一异常出口——这三件事做完代码量没少但新人上手时间从两周压到三天。这就是规范的价值。PSR-4 让类在哪这件事有唯一答案你不用猜也不用问PSR-12 让 diff 里不再出现大面积的空白字符变更代码评审能真正看逻辑PSR-11 让依赖可以通过接口注入单元测试能塞假对象进去。它们解决的都不是能不能跑而是三个月后还认不认得。还有一个更现实的理由生态兼容。项目做大了总会引入第三方库日志要用 Sentry、缓存要上 Redis 集群、HTTP 客户端要换、消息队列要接。如果业务代码直接绑定在框架门面上每换一次就是一次全局搜索替换如果代码面向 PSR 接口换个适配器实现就完事。ThinkPHP 8 在这一点上比 5.x 时代友好太多容器支持接口绑定服务类可以做集中注册这是做规范化的基础条件。2. 环境与骨架从 composer create-project 到目录职责2.1 版本基线不能含糊差一个小版本都可能白折腾ThinkPHP 8 官方基线是 PHP 8.0 及以上Composer 建议 2.x。这里的坑在于很多人的本地环境还是 7.4 或者 8.0 的早期补丁版本装是能装上但一跑就报奇怪的语法错误。我自己的习惯是先检查三件事PHP 版本、必需扩展、Composer 版本确认无误再动骨架。下面是创建项目和自检的完整命令直接抄。# 1. 确认环境 php -v # 必须 8.0建议 8.2 或 8.3 php -m | grep -E mbstring|pdo|openssl|json|curl|fileinfo composer -V # 建议 2.5 以上 # 2. 创建项目骨架 composer create-project topthink/think tp8-demo # 3. 进入目录并验证 cd tp8-demo php think versionphp think version能正常输出说明骨架没问题。如果这一步报错先看vendor目录是不是完整再看 PHP 扩展八成是缺了 mbstring 或者 fileinfo。扩展这块我多说一句。fileinfo经常被漏装本地 Mac 上默认可能没开结果一上传文件就抛异常报错信息还很隐晦pdo_mysql漏装的话连不上数据库的样子像是密码错了实际是驱动没加载。这两个坑我都踩过排查一下午就为一行扩展配置。所以我现在做新项目的第一件事一定是php -m完整过一遍而不是等报错了再回头找。2.2 目录职责划分与多应用模式的取舍ThinkPHP 8 的标准骨架目录结构是固定的app放业务代码、config放配置、route放路由定义、public是 Web 根目录、runtime放运行时缓存和日志、vendor放依赖。这个结构的核心思想是Web 入口和代码分离public/index.php是唯一入口其他目录统统不该被直接访问。这一点很多人配 Nginx 的时候会配错把根目录指到项目根而不是public结果config目录被公网读到了这是典型的安全事故。多应用模式要不要开我的判断标准是如果项目里存在两套以上明显不同的端比如面向用户的前台、面向内部的管理后台、对外提供的接口就开如果只有一个后台管理系统就别开。开多应用要额外装topthink/think-multi-app目录会变成app/admin、app/api这种结构好处是路由和配置天然隔离坏处是公共代码的放置位置要多想一层——我一般会在app/common下放共享的模型、服务、异常类然后在各应用里引用。不开多应用的情况下我的目录约定是这样的app/controller只处理请求响应app/service放业务逻辑app/model只做数据映射app/validate放校验规则app/middleware放中间件app/exception放自定义异常app/support放工具类和适配器。这套分法不是官方的是我在几个中等规模项目里磨出来的好处是每个目录的职责一眼清楚坏处是比官方骨架多了几个目录需要手动建并配好 PSR-4 映射其实映射是app\打头所以子目录自动就能加载不用额外配。3. PSR-4 自动加载命名空间和文件路径那点事3.1 composer.json 里 autoload 的正确写法PSR-4 的规则说白了就一句话命名空间前缀映射到目录剩下的部分按层级对应文件名。比如app\service\order\OrderService这个类只要app\映射到app目录那文件就必然在app/service/order/OrderService.php文件名大小写必须和类名完全一致。ThinkPHP 8 骨架的composer.json里默认就有这条映射。{ name: topthink/think, autoload: { psr-4: { app\\: app }, files: [] }, autoload-dev: { psr-4: { tests\\: tests } } }这里有两个细节值得说。第一psr-4的键必须以\\结尾这是规范要求的写成app: app在部分 Composer 版本下会被当成 PSR-0 处理虽然现在有兼容处理但别赌。第二autoload-dev是给测试代码用的生产环境执行composer install --no-dev时这部分不参与加载所以测试类千万别往app里放否则线上会多出一些没用的类文件。改完composer.json必须执行一次composer dump-autoload否则新的映射不生效。这条我放在项目 README 的第一行因为团队里总有人加完目录不执行这一步然后对着Class not found发愣。composer dump-autoload # 开发环境带测试映射 composer dump-autoload --no-dev # 生产环境 composer dump-autoload -o # 生成 classmap启动更快但新增类要重新生成3.2 加载失败的三类典型原因在 PSR-4 这块卡住的人报错信息永远是Class xxx not found但原因只有三类按概率从高到低排第一类是大小写不一致。Linux 服务器文件系统区分大小写本地 Mac 默认不区分所以本地跑得好好的一上线就炸。目录名Service写成service、类名OrderService文件名叫Orderservice.php全是这个原因。我的做法是本地开发环境直接开区分大小写的磁盘分区或者干脆全部小写目录、大驼峰类名严格按规范来不留侥幸。第二类是命名空间和目录层级对不上。比如文件放在app/service/order/OrderService.php但类里写的是namespace app\service;少了order这一段。这类问题 IDE 一般能提示但手写的时候很容易漏。第三类是Composer 缓存没刷新。执行了composer dump-autoload但用了-o参数后新增类没重新生成。这种情况的表现是部分类能加载、部分不能很有迷惑性。排查办法是打开vendor/composer/autoload_psr4.php看一眼映射对不对再看autoload_classmap.php里有没有那个类。提示生产环境用composer dump-autoload -o能提升加载性能但每次发布新增类都要重新生成。我的折中方案是发布脚本里固定带上这条命令把它做成流程的一部分而不是靠人记。4. PSR-1 与 PSR-12把代码风格做成流水线4.1 PSR-1 的硬约束只有几条但每一条都别踩PSR-1 是最基础的一层内容少到很多人觉得这也要写成规范但恰恰是它规定了最容易出问题的几条硬约束。文件必须使用?php或?标签且不得有 BOM。BOM 这个东西最阴险本地编辑器保存时加上了接口返回的 JSON 前面多了三个不可见字节前端解析直接失败你还看不出哪里错。文件编码必须是 UTF-8且不带 BOM。一个文件只放一个类或者接口、trait除非是纯函数集合。类名必须用大驼峰常量必须全大写加下划线方法名用小驼峰。命名空间和类名必须遵循 PSR-4。BOM 这个坑我吃过一次特别惨的。接口在浏览器里看返回完全正常但用 curl 拿到响应头前面有\xEF\xBB\xBF前端 JSON.parse 报错。查了两个小时最后用file命令才看出来。从那以后我在代码提交前一定跑一次全局搜索把所有带 BOM 的文件找出来处理掉。# 找出所有带 BOM 的 PHP 文件 grep -rlI $\xEF\xBB\xBF --include*.php .4.2 用 PHP-CS-Fixer 把 PSR-12 变成提交前动作PSR-12 是在 PSR-2 基础上扩展的编码风格规范管的是缩进、括号位置、行长度、use语句排序这堆细节。人肉遵守基本不可能靠代码评审提醒也很低效正确做法是上工具让它变成一个自动动作。composer require --dev friendsofphp/php-cs-fixer然后在项目根目录放一个.php-cs-fixer.php?php $finder PhpCsFixer\Finder::create() -in(__DIR__ . /app) -in(__DIR__ . /config) -in(__DIR__ . /route) -exclude(runtime) -name(*.php) -notName(*.blade.php); return (new PhpCsFixer\Config()) -setRiskyAllowed(true) -setRules([ PSR12 true, array_syntax [syntax short], ordered_imports [sort_algorithm alpha], no_unused_imports true, declare_strict_types false, ]) -setFinder($finder);跑一下vendor/bin/php-cs-fixer fix --dry-run --diff就能看到所有不合规的地方去掉--dry-run直接改。我的习惯是把这条命令挂到 Git 的 pre-commit 钩子里改不干净就不让提交。有人会嫌它慢实际在中等规模项目上也就两三秒比事后返工划算太多。注意declare_strict_types我特意关掉了。开启严格类型对企业老项目迁移不友好很多方法的参数类型没写全一开就满地报错。新项目可以单独开老项目逐步迁。还有一个容易被忽略的点PSR-12 规定每行不超过 120 字符软限制但很多人写链式调用能写两百多字符。这个我建议用编辑器标尺线解决别指望工具强改因为工具强行折行后反而更难读。规范是给人看的不是给工具交差的。5. PSR-11 容器依赖注入别只停在构造函数5.1 绑定、单例与延迟解析PSR-11 定义的是一个极简的容器接口只有get()和has()两个方法。ThinkPHP 8 的容器实现就是按这个接口来的所以你可以把任何遵循 PSR-11 的第三方容器接进来替换掉默认的虽然实际这么干的人不多但接口统一带来的好处是——业务代码可以面向接口编程而不必关心容器是哪家的。容器在真实项目里最有价值的用法是接口绑定实现。举个典型场景支付通道。你的业务代码里不该出现new AlipayGateway()这种东西否则换支付渠道就要改业务代码。正确做法是定义一个接口在服务类里做绑定。// app/contract/PaymentGateway.php namespace app\contract; interface PaymentGateway { public function pay(string $orderNo, int $amount): array; public function refund(string $orderNo, int $amount): bool; }// app/support/AppService.php namespace app\support; use think\Service; use app\contract\PaymentGateway; use app\service\payment\AlipayGateway; class AppService extends Service { public function register(): void { $this-app-bind(PaymentGateway::class, AlipayGateway::class); } public function boot(): void { // 启动后的逻辑比如注册事件监听 } }服务类要在config/service.php里注册返回一个类名数组即可?php return [ \app\support\AppService::class, ];绑定好之后任何通过容器解析的类控制器、中间件、命令、事件监听器只要在构造函数里声明PaymentGateway $gateway容器就会自动注入AlipayGateway实例。这里有个关键点框架不会自动注入到new出来的对象里只有走容器解析的类才有这个待遇。所以控制器里new SomeService()这种写法就享受不到依赖注入得改成构造注入。bind()和instance()的区别也值得说一句。bind()是每次解析都创建新实例除非加了$this-app-bind(...)-make()或者用singleton风格instance()是直接把一个现成对象注册进去后续解析拿到的都是同一个。像数据库连接、配置这种明显应该共享的用单例像请求上下文相关的对象用bind每次新建避免状态串。5.2 服务类里做绑定别在控制器里写绑定代码我见过不少项目在控制器的构造函数里做绑定或者干脆在common.php里写得满屏都是app()-bind(...)。这两种做法都能跑但都有问题。控制器里绑定会导致每次请求都执行一次浪费且难追踪common.php是全局函数文件每次请求都会加载把绑定逻辑塞那儿会让启动流程变得不透明。正确位置就是服务类的register()方法。服务类的执行时机是框架启动早期只跑一次且语义清晰——这个模块需要这些绑定。如果绑定关系多可以拆成多个服务类按业务域划分比如PaymentService、StorageService、MessageService各自管自己的绑定。还有一个细节register()里不要做任何依赖其他服务已初始化的操作因为那时候可能还没启动完需要依赖其他服务的逻辑放boot()。这个先后顺序搞反了会报奇怪的服务未注册错误而且报错位置通常在很远的地方排查起来很费劲。6. 中间件与 PSR-15别把类 PSR-15当成 PSR-156.1 签名差异与执行顺序这是我认为最容易踩坑的一节。PSR-15 定义的中间件接口有两个方法process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface。而 ThinkPHP 8 的中间件是handle($request, \Closure $next)。两者思想一致但签名完全不同而且请求对象也不是 PSR-7 实现。这意味着什么呢意味着你从网上抄一个标准 PSR-15 中间件进来改个命名空间是跑不起来的必须重写整个handle方法。同时也意味着你没法直接用其他框架的中间件生态。对比项PSR-15 标准ThinkPHP 8 中间件方法名processhandle请求对象ServerRequestInterfacethink\Request下一环调用$handler-handle($request)$next($request)返回值ResponseInterface通常为think\Response额外参数透传不支持支持路由里可传参签名之外还有执行顺序的问题。ThinkPHP 8 的中间件分三种全局中间件app/middleware.php、应用中间件、路由中间件。执行顺序是全局 → 应用 → 路由同一个层级里按注册顺序正序进入、倒序退出。这个洋葱模型理解清楚很重要因为鉴权中间件必须放在最外层跨域中间件必须在鉴权之前否则预检请求会被拦。// app/middleware.php return [ \think\middleware\AllowCrossDomain::class, // 最先执行处理 OPTIONS \app\middleware\TraceId::class, // 注入请求追踪 ID \app\middleware\AuthCheck::class, // 鉴权 \app\middleware\RateLimit::class, // 限流 ];顺序错了的表现是跨域请求一直报 CORS 错误或者未登录用户收到的是跨域错误而不是 401。这类问题前端会先炸然后你才反应过来是顺序问题。6.2 写一个可复用的鉴权中间件中间件支持从路由传参这是 ThinkPHP 相对 PSR-15 的一个实用扩展。下面这个鉴权中间件支持指定守卫类型不同路由用不同的鉴权策略。namespace app\middleware; use Closure; use think\Request; use think\Response; use app\exception\AuthException; use app\service\auth\TokenResolver; class AuthCheck { public function __construct(private readonly TokenResolver $resolver) { } public function handle(Request $request, Closure $next, string $guard user): Response { $token $request-header(Authorization, ); if (empty($token)) { throw new AuthException(缺少凭证, 401); } $identity $this-resolver-resolve($token, $guard); if ($identity null) { throw new AuthException(凭证无效或已过期, 401); } // 挂到请求上下文后续控制器可直接取 $request-identity $identity; return $next($request); } }路由里这样用use think\facade\Route; use app\middleware\AuthCheck; use app\middleware\RateLimit; Route::group(api/v1, function () { Route::post(orders, Order/create); Route::get(orders/:id, Order/read); })-middleware(AuthCheck::class, user); Route::group(api/v1/admin, function () { Route::get(stats, Admin/stats); })-middleware([[AuthCheck::class, admin], RateLimit::class]);这里有个实践心得中间件构造函数里只注入无状态的依赖需要请求相关的信息一律从handle的参数里取。原因是中间件实例在某些场景下会被复用构造函数里存请求对象会导致上一个请求的数据串到下一个请求里。这个坑我在并发压测时踩过一次表现是用户 A 偶尔能看到用户 B 的数据排查了很久才发现是中间件里存了请求上下文。7. 日志、缓存、HTTP 客户端的规范适配7.1 PSR-3给日志加结构化上下文ThinkPHP 8 的日志门面用起来很顺手Log::info()、Log::error()都有方法风格接近 PSR-3但它并不直接实现Psr\Log\LoggerInterface。这意味着你没法直接把框架日志对象塞给一个只接受 PSR-3 的第三方包。解决办法是写一个薄适配器。代码不多但能解决实际兼容问题。namespace app\support\log; use Psr\Log\AbstractLogger; use think\facade\Log; class PsrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context []): void { $text (string) $message; if (!empty($context)) { $text . . json_encode($context, JSON_UNESCAPED_UNICODE); } Log::record($text, (string) $level); } }需要先composer require psr/log。写完之后在服务类里绑定LoggerInterface::class到PsrLogger::class第三方包就能用了。比适配更重要的是日志内容的组织方式。我见过太多项目日志里只有一句操作失败什么上下文都没有出问题根本查不了。正确做法是带上结构化的上下文订单号、用户 ID、入参摘要、耗时。Log::info(创建订单, [ order_no $orderNo, user_id $userId, amount $amount, cost_ms $cost, ]);ThinkPHP 8 的日志支持数组上下文会以 JSON 形式落到文件里。但要注意别把敏感字段密码、完整手机号、身份证打进去这是合规底线不是可选建议。我在几个项目里都加了统一的脱敏方法日志里统一过一遍再落盘。7.2 PSR-16给缓存写一层适配器缓存这块同理。ThinkPHP 8 的Cache门面很好用但不实现 PSR-16 的CacheInterface。如果你引入的第三方包依赖 PSR-16就得自己包一层。namespace app\support\cache; use Psr\SimpleCache\CacheInterface; use think\facade\Cache; class ThinkCacheAdapter implements CacheInterface { public function __construct(private readonly string $store ) { } public function get(string $key, mixed $default null): mixed { $value $this-driver()-get($key); return $value null ? $default : $value; } public function set(string $key, mixed $value, null|int|\DateInterval $ttl null): bool { $expire $ttl instanceof \DateInterval ? $ttl-s : ($ttl ?? 0); return $this-driver()-set($key, $value, $expire); } public function delete(string $key): bool { return $this-driver()-delete($key); } public function clear(): bool { return $this-driver()-clear(); } public function getMultiple(iterable $keys, mixed $default null): iterable { $result []; foreach ($keys as $key) { $result[$key] $this-get((string) $key, $default); } return $result; } public function setMultiple(iterable $values, null|int|\DateInterval $ttl null): bool { foreach ($values as $key $value) { $this-set((string) $key, $value, $ttl); } return true; } public function deleteMultiple(iterable $keys): bool { foreach ($keys as $key) { $this-delete((string) $key); } return true; } public function has(string $key): bool { return $this-get($key) ! null; } private function driver() { return $this-store ? Cache::store() : Cache::store($this-store); } }缓存键的设计比适配器本身重要得多。我的约定是模块:实体:标识:版本四段式比如order:detail:20240115abc:1。加版本号是为了后续数据结构变更时能整体失效而不影响其他键。另外缓存值一定要考虑序列化问题——存对象容易但升级后反序列化失败会很麻烦我倾向于只存数组和标量。7.3 PSR-18HTTP 客户端别写死在业务里外部接口调用是另一个重灾区。很多项目直接在 Service 里写curl_setopt一大串或者直接new GuzzleHttp\Client()。这两种做法的共同问题是没法测试、换库成本高。PSR-18 定义的是 HTTP 客户端接口思路是业务代码依赖ClientInterface具体实现Guzzle、Symfony HttpClient在容器里绑定。这样测试时塞一个 Mock 客户端进去不用真的发请求。namespace app\contract; use Psr\Http\Client\ClientInterface; interface HttpClientFactory { public function create(array $config []): ClientInterface; }具体实现里返回 Guzzle 的实例服务类里绑定接口。业务代码只用$client-sendRequest($request)不关心底层是谁。这一层抽象在中大型项目里非常值尤其是当你有十来个外部对接方的时候。8. PHP 8 新特性在 ThinkPHP 8 业务代码里的落地8.1 构造器属性提升 只读属性PHP 8.0 的构造器属性提升Constructor Property Promotion和 8.1 的readonly组合起来能把依赖注入类的样板代码砍掉一半。以前写一个服务类要这样class OrderService { private OrderRepository $repo; private PaymentGateway $gateway; public function __construct(OrderRepository $repo, PaymentGateway $gateway) { $this-repo $repo; $this-gateway $gateway; } }现在直接final class OrderService { public function __construct( private readonly OrderRepository $repo, private readonly PaymentGateway $gateway, ) { } }代码少了三分之二而且readonly保证了依赖不会被意外修改。这里我强烈建议把业务服务类都加上final——服务类本来就不该被继承继承会带来难以追踪的行为覆写。加上final之后静态分析工具还能做更多优化提示。readonly有一个使用限制要注意它的属性只能在构造函数里赋值一次之后不能改。所以不要把需要中途改变的状态比如计数器、缓存字段声明成readonly否则会报错。这个错误信息挺明确的一般不会卡太久。8.2 枚举替代常量字典订单状态、审核状态、支付渠道这类东西以前都是定义一堆常量然后在各处用if ($status 1)判断。PHP 8.1 的枚举出来后这套做法可以整体替换掉。namespace app\enum; enum OrderStatus: int { case Pending 0; case Paid 1; case Shipped 2; case Closed 3; public function label(): string { return match ($this) { self::Pending 待支付, self::Paid 已支付, self::Shipped 已发货, self::Closed 已关闭, }; } public function canShip(): bool { return $this self::Paid; } }好处有三个。第一类型安全方法签名写OrderStatus $status传个整数进去直接报错不用等到运行时才发现状态不对。第二状态相关的行为可以挂在枚举上canShip()这种判断不用散落在业务代码里。第三IDE 补全友好输入OrderStatus::就能看到全量状态比记常量名靠谱。和数据库交互的时候要注意枚举存进数据库需要用-value从数据库取出来要用OrderStatus::from($row[status])。from()遇到不存在的值会抛ValueError如果历史数据里有脏值用tryFrom()返回 null 更安全。这个细节很多人第一次用枚举时会忽略然后线上跑出个ValueError才发现。8.3 match 与命名参数match表达式比switch严格它是全等比较且必须有返回值忘记覆盖某个分支会抛UnhandledMatchError这反而是好事——逼着你想清楚所有情况。$fee match (true) { $amount 100 0, $amount 1000 5, default 10, };命名参数在调用那种参数特别多的方法时很好用。比如创建一个查询$query-where(status, OrderStatus::Paid-value) -whereBetweenTime(create_time, $start, $end) -order(id, desc);如果有默认参数需要跳过命名参数就派上用场了。不过我要提醒一句命名参数会把调用方和形参名绑定起来改形参名就算破坏性变更。所以公共 API 的方法名、参数名要慎重内部方法随便用。9. 分层与最佳实践控制器瘦身的可执行方案9.1 控制器只做三件事我说控制器只做三件事这三件是接收请求、调用服务、返回响应。听起来简单但实际项目里控制器动辄三五百行是常态。原因往往不是开发者不想分层而是没想清楚边界在哪。我的判断标准是这样的控制器里不应该出现这些——数据库查询Db::table()、模型find()、业务规则判断状态能不能流转、事务控制、外部接口调用。出现这四类代码就该往外挪。namespace app\controller; use app\service\OrderService; use app\validate\OrderValidate; use think\Response; class Order extends BaseController { public function __construct(private readonly OrderService $service) { } public function create(): Response { $data $this-request-only([goods_id, quantity, remark]); $this-validate($data, OrderValidate::class . .create); $result $this-service-create($data, $this-request-identity-id); return $this-success($result); } public function read(int $id): Response { return $this-success($this-service-detail($id)); } }这个控制器只有十来行读起来一目了然。业务逻辑全在OrderService里校验规则在OrderValidate里返回格式由基类统一处理。有人会问这样是不是过度设计我的回答是当你的项目只有五个接口时确实没必要但超过二十个接口之后分层带来的收益是指数级的。9.2 服务层与仓储层的边界服务层和仓储层Repository的边界我的划分是服务层管业务流程和规则仓储层管数据存取。服务层调用仓储层仓储层不调用服务层。这个方向不能反反了就会出现循环依赖。举个具体例子。下单流程大概是校验商品是否可售 → 检查库存 → 创建订单 → 扣减库存 → 发起支付。这个流程编排属于服务层每一步里的查商品表更新库存字段属于仓储层。仓储层不该出现如果库存为 0 就抛异常这种业务规则它只负责返回数据或者执行更新判断逻辑在服务层。namespace app\service; use app\repository\GoodsRepository; use app\repository\OrderRepository; use app\enum\OrderStatus; use app\exception\BusinessException; final class OrderService { public function __construct( private readonly OrderRepository $orders, private readonly GoodsRepository $goods, ) { } public function create(array $data, int $userId): array { $goods $this-goods-find($data[goods_id]); if ($goods null || $goods[status] ! 1) { throw new BusinessException(商品不可售); } if ($goods[stock] $data[quantity]) { throw new BusinessException(库存不足); } $orderNo $this-orders-create([ order_no date(YmdHis) . mt_rand(1000, 9999), user_id $userId, goods_id $goods[id], quantity $data[quantity], amount $goods[price] * $data[quantity], status OrderStatus::Pending-value, ]); $this-goods-decreaseStock($goods[id], $data[quantity]); return [order_no $orderNo]; } }这段代码里最需要注意的是事务边界。真实项目里创建订单和扣库存必须在同一个事务里否则扣库存失败会导致订单凭空出现。事务该放在服务层用Db::transaction()包住整个流程不要放在仓储层因为仓储层不知道业务上哪些操作需要原子。9.3 统一异常与响应异常处理不统一是接口对接方最讨厌的事。同一个项目里有的接口返回{code:0,msg:ok}有的返回{errcode:200,errmsg:成功}有的直接 500 加一段 HTML。这活儿必须靠全局异常处理来做。ThinkPHP 8 的异常处理在app/ExceptionHandle.php重写render方法就能统一所有未捕获异常的出口。namespace app; use think\exception\Handle; use think\Response; use Throwable; use app\exception\BusinessException; class ExceptionHandle extends Handle { public function render($request, Throwable $e): Response { if ($e instanceof BusinessException) { return json([ code $e-getCode(), msg $e-getMessage(), data null, ]); } // 记录完整堆栈但不返回给客户端 $this-report($e); return json([ code 500, msg 服务器开小差了, data null, ])-code(500); } }这里有两个要点。第一业务异常和系统异常必须分开业务异常是用户操作不合法消息可以给前端看系统异常是代码有 bug只记日志对外统一话术。第二异常返回体里的trace绝对不能暴露在生产环境这会泄露目录结构和文件路径。我见过不少项目在app_debugtrue的时候把堆栈直接返回上线忘了关属于典型的信息泄露。10. 踩坑实录与速查表10.1 五类高频报错第一个高频问题是Class not found前面 PSR-4 那节已经讲透三类原因大小写、命名空间层级、autoload 缓存。补充一个容易忽略的点composer require新装包之后如果开了 opcache 且没重启 PHP-FPM可能会加载到旧的文件。线上环境遇到莫名其妙的类找不到先systemctl reload php-fpm试试。第二个是Cannot use object of type think\Request as array多见于老项目迁移以前$this-request[id]能取到现在必须用$this-request-param(id)或$this-request-get(id)。TP8 收紧了这块的隐式转换直接改了写法就行。第三个是容器解析失败报Target class ... does not exist或者Unresolvable dependency。前者是命名空间写错后者通常是因为构造函数参数有默认值之外的标量类型比如int $id容器没法自动注入标量。解决办法是在绑定时传参或者在方法参数上取别在构造函数里要标量。第四个是路由 404检查三件事路由有没有定义、请求方法对不对Route::post但用 GET 请求、有没有开多应用但路由文件没放对位置。多应用模式下路由文件在app/应用名/route/下放到根route/目录里是不生效的。第五个是缓存配置改了不起作用原因通常是runtime目录下有缓存文件。TP8 的配置缓存需要手动清执行php think clear或者直接删runtime下的对应目录。10.2 排查速查表现象大概率原因处理动作Class not found命名空间/文件路径不符核对 PSR-4 映射执行 dump-autoload500 但日志无内容日志目录无写权限检查 runtime 权限设为可写接口返回前有乱码字节文件带 BOM用 grep 找出 BOM 文件并清除跨域预检失败中间件顺序不对AllowCrossDomain 放到最外层构造函数注入报错参数是标量类型改用方法参数或显式绑定传参事务没生效事务边界放错层事务放服务层包住完整流程修改配置不生效runtime 有缓存执行 php think clear中间件数据串号构造函数存了请求状态状态放 handle 方法里别放属性提示这张表建议直接抄进项目的 README 或者团队文档。新同事遇到问题先查表比在群里问为什么报错效率高得多。最后分享一个我自己的小习惯每接一个新项目我会先花二十分钟做三件事——跑一遍composer dump-autoload -o确认加载正常、跑一次php-cs-fixer看看代码风格有多乱、翻一遍app/ExceptionHandle.php看异常出口是否统一。这三件事做完基本就能判断出这个项目的可维护性到什么程度也就知道后面重构要投入多少精力了。
阅读完成 · 觉得有帮助?
咨询建站