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

ThinkPHP 8 的 PSR 标准落地:容器、日志、缓存与分层实践

ThinkPHP 8 的 PSR 标准落地:容器、日志、缓存与分层实践 ★ FEATURED ARTICLE
如果把 ThinkPHP 8 当成ThinkPHP 6 加几个新语法来用大概率会在第一次代码评审时被打回。真正让 TP8 和上一代拉开距离的不是某个新方法而是它把PSR 标准摆到了台面上容器认 PSR-11日志认 PSR-3缓存认 PSR-16自动加载走 PSR-4编码风格对齐 PSR-12。这些接口看起来只是几个use语句的差别实际决定了你的代码能不能被替换、被测试、被复用。这篇内容面向已经会用 TP 写增删改查、但写出来的代码总感觉黏在框架上的开发者也适合刚接手一个 TP8 项目、想先把地基摸清楚的人。我会把 PSR 在 TP8 里到底落地在哪几个位置、哪些地方只是长得像其实并不是标准实现、以及分层、注入、事务、类型声明这些最佳实践该怎么落到具体文件上一条条拆开讲。1. 从一次代码评审被打回说起TP8 的底层规矩变在哪几年前帮一个团队看代码某个接口类里塞了Db::name(order)-where(...)、Cache::get()、Log::info()三种静态调用方法体一百二十行中间还夹着两次halt()。这段代码在 TP5 时代是正常水平放到 TP8 的项目里就成了典型反例。不是写法变难了而是 TP8 所依托的生态把可替换性当成了默认要求静态调用把依赖固化在类内部替换和测试都无从下手。1.1 PHP 8 作为硬门槛带来的连锁反应TP8 的最低运行要求是 PHP 8.0这条约束带来的变化远不止能跑就行。PHP 8.0 引入的构造器属性提升、命名参数、联合类型、match表达式、空安全运算符直接改变了框架内部类的写法也改变了你写业务类的习惯。举个最直观的例子过去写一个服务类构造函数里一堆$this-x $x的赋值样板现在可以直接在构造参数上加可见性修饰符属性声明和赋值一步完成。?php declare(strict_types1); namespace app\common\service; use app\common\repository\OrderRepository; use Psr\Log\LoggerInterface; final class OrderService { public function __construct( private OrderRepository $repository, private LoggerInterface $logger, ) { } public function detail(int $orderId): array { $order $this-repository-findById($orderId); if ($order null) { $this-logger-warning(order not found, [id $orderId]); throw new \RuntimeException(订单不存在); } return $order; } }这段代码里有两个关键点值得注意。第一是declare(strict_types1)它让类型声明从尽量转换变成严格比对传123给int $orderId会直接抛TypeError而不是悄悄转成 123。第二是构造器属性提升属性声明、参数接收、赋值三件事合并成一行减少了样板也减少了漏赋值的可能。这里有个容易踩的细节readonly修饰符是 PHP 8.1 才有的enum也是 8.1。如果你的部署环境还停在 8.0写private readonly会直接语法错误。我在一个项目里就遇到过本地 8.2 跑得好好的测试环境 8.0 一部署全线崩的情况报错信息还很隐晦。判断方式很简单跑一句php -v或者在composer.json里把php: 8.0写死让 Composer 在安装阶段就拦住不匹配的环境。1.2 容器、日志、缓存这三处的 PSR 落地位置TP8 里真正原生对接PSR 接口的地方主要集中在三处这三处也是最值得你主动去用接口类型而不是具体类的地方。能力对应 PSRTP8 中的实现类类型提示该写什么依赖注入容器PSR-11think\ContainerPsr\Container\ContainerInterface日志PSR-3think\log\LogPsr\Log\LoggerInterface缓存PSR-16think\CachePsr\SimpleCache\CacheInterface自动加载PSR-4Composer 生成无配置层面编码风格PSR-12无实现类无工具层面表格里类型提示该写什么这一列是重点。你在构造函数里写LoggerInterface $logger容器会自动解析并注入框架的日志实例写Log $logger也能跑但从此你的类就和 TP 的日志实现绑死了单元测试时想塞一个把日志写进内存的假对象都做不到。我在实际项目里做过一次对比一个二十多个类的模块日志全部用LoggerInterface注入的版本写单元测试时用匿名类实现接口、把日志收集到数组里断言一个测试文件三十行搞定而另一批用Log::info()静态调用的只能靠Log::close()加临时文件重定向来验证跑一次测试留下十几个临时文件。这个差距在项目后期会指数级放大。1.3 长得像但并不是标准实现的三处别混淆这一节是我认为整篇里最容易被忽略、也最容易出事故的部分。PSR 有一整套编号但不是每个编号 TP8 都完整实现了。把设计思路相似当成接口兼容会在需要接入第三方包时吃大亏。第一处是中间件。TP8 的中间件接口是think\MiddlewareInterface方法签名是handle($request, \Closure $next)。而 PSR-15 的MiddlewareInterface要求的是process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface。两者思路一致都是洋葱模型但类型完全不同一个 PSR-15 的中间件包不能直接扔进 TP8 的middleware.php里用必须写一层适配。第二处是 HTTP 消息对象。think\Request和think\Response不是 PSR-7 的ServerRequestInterface和ResponseInterface实现。这意味着那些依赖 PSR-7 的第三方组件比如某些 HTTP 客户端中间件、某些 OpenAPI 生成器没法直接接收 TP 的请求对象。常见的做法是在边界处做一次转换把需要的信息抽成数组或自定义 DTO 传进去。第三处是事件。TP8 有think\Event注册和监听都挺顺手但它没有实现 PSR-14 的EventDispatcherInterface。如果你打算引入一个基于 PSR-14 的第三方事件库两边的事件总线是需要桥接的。注意判断一个包能不能直接用看它的composer.json里的require段最准。如果它要求psr/http-message那基本可以确定它期望的是 PSR-7 对象别指望把think\Request直接传进去。把这些边界搞清楚后面的分层、注入、测试才有意义。很多人觉得 PSR 是教条其实它解决的就是我这段代码能不能换个环境继续用这个非常实际的问题。2. 骨架与自动加载命名空间映射错一步后面全是白费项目初始化这一步看着最没技术含量恰恰是后面所有问题的源头。我见过不止一个项目app目录下的命名空间和 Composer 的autoload配置对不上本地因为缓存文件还在跑得好好的换台机器composer install之后满屏Class not found。这类问题排查起来极其费时因为报错位置和真实原因隔了好几层。2.1 composer.json 里 autoload 段的真实含义TP8 的项目骨架里composer.json的autoload段通常是这个形状{ autoload: { psr-4: { app\\: app }, files: [], classmap: [] } }psr-4这一行的意思要拆开理解键app\\是命名空间前缀值app是相对项目根目录的路径。合起来就是——任何以app\开头的类名去掉这个前缀后剩余部分按反斜杠转成目录分隔符再拼上.php就是文件的物理路径。所以app\common\service\OrderService对应的文件是app/common/service/OrderService.php。这里有两个高频错误。一是大小写。PSR-4 是大小写敏感的规范虽然 PHP 的类名本身不区分大小写但文件系统在 Linux 上是区分的。类名写OrderService、文件名写成orderservice.php在本地 Windows 或 macOS 的默认文件系统上照样能加载一上 Linux 服务器就找不到文件。这个问题在团队协作里特别隐蔽因为出问题的人往往不是写错的人。二是目录层级和命名空间不能多也不能少。有人在app下新建了app/service/order/OrderService.php命名空间写成app\service\OrderService少了一层order目录。Composer 按命名空间去找app/service/OrderService.php文件不在那儿直接失败。修改autoload段之后必须执行一次composer dump-autoload重新生成映射表。开发阶段建议加-o参数生成优化过的类映射加载更快但如果频繁增删文件优化后的映射不会自动更新反而会造成新加的类找不到这时候去掉-o或者重新 dump 一次即可。这个问题我在调试时踩过明明文件在那儿、命名空间也对就是不认折腾了快半小时才想起来映射表是旧的。2.2 多应用模式下的命名空间映射该怎么配TP8 默认是单应用结构app目录下直接放controller、model这些。要用多应用模式得先装topthink/think-multi-app装完之后目录变成app/admin、app/api这样的结构每个应用下再有自己的controller、model。这里的关键是命名空间要跟着变。app/admin/controller/User.php的命名空间是app\admin\controller而不是app\controller。如果从单应用迁移到多应用最容易漏改的就是各类use语句和容器绑定里的类名报错信息通常只说类不存在不会告诉你是因为命名空间多了一层。多应用模式下还有一个配置项值得留意就是应用映射和默认应用。如果只有一个应用需要被外部直接访问可以在配置里指定默认应用避免 URL 里必须带应用名。但这个配置改完之后路由文件的位置也跟着变从route/app.php变成各应用目录下的route子目录。我建议的做法是先在单应用模式下把业务跑通确认路由、中间件、容器绑定都没问题再整体迁移到多应用一次性改完命名空间和路由文件位置别边写业务边迁移。2.3 extend 目录和自建包之间的取舍TP 骨架里有个extend目录历史上是用来放第三方类库的。现在既然有 Composer这个目录的地位就有点尴尬。我的建议很简单只放项目私有、不打算发布、也不打算被多个项目复用的类而且要在composer.json的autoload里显式注册别依赖框架的自动扫描。{ autoload: { psr-4: { app\\: app, extend\\: extend } } }真正需要跨项目复用的东西老老实实做成私有包通过 Composer 的仓库配置引入。好处是版本清晰、依赖明确、升级不会互相影响。把一堆工具类堆在extend里短期省事时间一长就变成没人敢动的黑盒——你不知道哪些方法还有人在调删一行都可能引发线上问题。还有一个现实问题extend目录下的类如果没走 PSR-4 注册而是靠框架的Loader加载那它在composer dump-autoload -o之后的行为可能和预期不一致。这属于能跑但脆的写法能不用就不用。3. 容器与依赖注入从 Facade 依赖走向可测试的构造器注入门面Facade大概是 ThinkPHP 里争议最大的设计。它让Db::、Cache::、Log::这类调用写起来极其顺手一个use think\facade\Db;就能在任何地方操作数据库。问题是顺手的地方往往也是失控的地方——你很难从类的签名上看出它依赖了什么也很难在测试里替换掉它。3.1 静态调用为什么会在项目后期变成负担先看一段很常见的写法public function create(array $data) { Db::startTrans(); try { $id Db::name(order)-insertGetId($data); Cache::delete(order_list); Log::info(order created, [id $id]); Db::commit(); return $id; } catch (\Throwable $e) { Db::rollback(); throw $e; } }这段代码能跑逻辑也不算错但它把三样东西焊死在方法体里数据库连接、缓存驱动、日志实现。带来的直接后果是你想验证订单创建失败时缓存是否被误删这个分支就必须准备一套真实的数据库和缓存环境没法用假对象模拟。还有一个更隐蔽的问题静态调用让依赖关系隐形。新人接手时看这个方法的签名create(array $data)完全看不出它会写数据库、会清缓存、会记日志。等到某天要把订单数据同步到别的地方得把整个方法重新读一遍才知道从哪儿下手。容器注入的思路是把这些依赖提到台面上类的构造函数明确声明我需要一个订单仓储、一个缓存、一个日志器运行的时候由容器负责把实例递进来。final class OrderService { public function __construct( private OrderRepository $repository, private CacheInterface $cache, private LoggerInterface $logger, ) { } }签名即文档看构造函数就知道这个类会碰什么。测试时构造三个假对象传进去就行不需要数据库。这就是从能跑到可维护的分界线。3.2 绑定、解析与单例注册的正确姿势TP8 的容器绑定配置集中在app/provider.php这个文件返回一个数组键是抽象接口或类名值是具体实现。?php // app/provider.php use app\common\contract\PaymentGateway; use app\common\service\AlipayGateway; use app\common\service\WechatGateway; return [ PaymentGateway::class AlipayGateway::class, payment.wechat WechatGateway::class, ];绑定之后任何类的构造函数只要声明了PaymentGateway这个类型容器就会自动实例化AlipayGateway递进去。这里要注意几点。第一接口绑定是必须显式写的。容器没法凭空知道一个接口该实例化成哪个类不写绑定就会抛无法解析的异常。而具体类非抽象、非接口不需要绑定容器能自己反射出来。第二默认是每次解析都新建实例。如果你的服务有状态、或者初始化成本高需要在绑定时注册成单例。TP8 里可以用bind方法的第三个参数或者在应用启动阶段通过容器实例调instance()注册。我在一个项目里遇到过连接池被反复重建的问题根因就是把一个内部持有连接的类当普通类解析每次注入都 new 一份。第三绑定的键名别用字符串魔法值。上面例子里的payment.wechat这种字符串键用的时候也得写同样的字符串拼错了不报错、只是解析出一个新实例。能用类名或接口名做键就用类名IDE 还能帮你跳转和重命名。容器解析的入口是app()辅助函数或者注入ContainerInterface。前者在框架内部随处可用后者适合写在业务类里让依赖更明确。use Psr\Container\ContainerInterface; final class ReportBuilder { public function __construct(private ContainerInterface $container) { } public function build(): array { $exporter $this-container-get(ExporterInterface::class); return $exporter-export(); } }这种服务定位器模式不算优雅但比重度静态调用好得多至少在测试时能塞一个自定义容器进去。不过我还是建议能用构造器注入解决的就别用容器去get依赖声明显式化永远是第一选择。3.3 属性注入和服务提供者的使用边界TP8 支持基于 PHP 属性的注解注入比如在属性上加标记让容器自动填值。这类写法在控制器里特别省事use think\annotation\Inject; class OrderController { #[Inject] protected OrderService $orderService; }写起来很爽但我对它的态度是谨慎使用理由有三个。一是属性注入让依赖不在构造函数里从签名上看不出这个类依赖了什么可读性反而下降。二是注解解析依赖反射在高频调用的类上有额外开销。三是注解包本身需要单独安装和配置不同版本之间行为有差异升级时容易出幺蛾子。我个人的取舍标准是控制器可以用属性注入业务服务层一律构造器注入。控制器本身是薄薄一层、生命周期短、几乎不会被单元测试直接实例化用注解省事划算服务层是要被反复测试和复用的依赖必须显式。服务提供者Service Provider这个概念的边界也要说清楚。TP8 的app/provider.php只负责绑定不负责启动逻辑。如果你有一些需要在应用启动时执行的操作比如注册自定义验证规则、注册命令、加载配置应该放在应用初始化阶段的事件或者app/common.php里别硬塞进 provider。把绑定和启动混在一起会导致容器初始化阶段就产生副作用调试起来非常难受。4. 分层落地Controller 薄到什么程度Service 厚到什么程度三层架构这四个字被讲烂了但真正落地时薄和厚的标准往往靠感觉。我的判断标准很具体Controller 只做四件事——接收参数、调用校验、调一次服务、返回响应。超过这四件事的任何一行代码都应该往外挪。4.1 一个增删改查接口的三层拆分实录拿一个创建订单的接口举例。先看常见的写法public function store() { $data $this-request-only([user_id, items, remark]); if (empty($data[items])) { return json([code 400, msg 商品不能为空]); } Db::startTrans(); try { $orderNo date(YmdHis) . mt_rand(1000, 9999); $id Db::name(order)-insertGetId([ order_no $orderNo, user_id $data[user_id], remark $data[remark] ?? , status 0, created_at date(Y-m-d H:i:s), ]); foreach ($data[items] as $item) { Db::name(order_item)-insert([ order_id $id, sku_id $item[sku_id], qty $item[qty], ]); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); return json([code 500, msg $e-getMessage()]); } return json([code 0, data [id $id]]); }这段代码信息密度很高但每一层职责都混在一起参数提取、校验、订单号生成、数据落库、事务控制、错误响应全在一个方法里。想复用创建订单这个能力给命令行任务用得把整个方法复制一遍。拆开之后Controller 会瘦成这样final class OrderController { public function __construct(private OrderService $service) { } public function store(CreateOrderRequest $request): Json { $dto CreateOrderDto::fromRequest($request); $result $this-service-create($dto); return json([code 0, data $result]); } }真正的业务逻辑在OrderService::create()它对外只认一个 DTO 对象不关心 HTTP 层怎么传进来的。这样一来命令行、队列消费者、后台任务都能复用同一个入口只要拼出一个 DTO 就行。这里有个值得强调的点服务的入参用 DTO 而不是裸数组。裸数组的问题是键名随意、类型不明确、IDE 无法提示运行到一半才发现少传了一个字段。DTO 把这些约束前移了。final class CreateOrderDto { public function __construct( public int $userId, public array $items, public string $remark , ) { } public static function fromRequest(Request $request): self { return new self( userId: (int) $request-param(user_id, 0), items: (array) $request-param(items, []), remark: (string) $request-param(remark, ), ); } }fromRequest这个静态方法是刻意写的边界DTO 知道怎么从请求里取数据Service 完全不知道请求长什么样。两边解耦之后Service 换成从消息队列取数据也不用改一行代码。4.2 参数校验该放在哪一层关于校验位置社区里争论过很久我的实践结论是分两层各管各的。第一层在入口负责格式校验字段在不在、类型对不对、长度有没有超、必填项是否为空。这层用框架自带的验证器或者 DTO 的构造期检查就够了目的是快速失败不让脏数据往下走。第二层在服务内部负责业务校验订单金额是否超限、库存是否充足、用户是否有权限下单。这些规则需要查数据库、依赖其他上下文天然属于服务层。把两层混在一起的后果是业务规则被写在验证器里验证器变成第二个服务层越来越臃肿或者业务规则完全不校验服务层拿到数据直接写库出了事才发现脏数据早就进去了。final class OrderService { public function create(CreateOrderDto $dto): array { if ($dto-items []) { throw new BusinessException(订单至少包含一件商品, 40001); } $stock $this-stockReader-available($dto-items); if (! $stock-sufficient()) { throw new BusinessException(库存不足, 40002); } // 后续落库 } }这里抛的是业务异常不是Exception也不是RuntimeException。自定义异常类的好处是能被全局异常处理器统一识别转成固定的错误结构返回给前端。用RuntimeException搅在一起最后只能靠字符串匹配来判断错误类型非常脆弱。4.3 事务边界为什么必须下沉到 Service 层把startTrans/commit/rollback写在 Controller 里是我见过最多的错误之一。原因很直接Controller 只知道我要调一次服务它不知道这次服务调用背后有几个写操作、哪几个必须原子。一旦业务扩展比如在创建订单之后还要扣减库存、写积分流水事务边界就得跟着改 Controller而 Controller 本不该知道这些。正确的做法是把事务开在 Service 方法里用框架提供的事务封装public function create(CreateOrderDto $dto): array { return Db::transaction(function () use ($dto) { $orderId $this-repository-insert($dto); $this-stockService-deduct($dto-items, $orderId); $this-pointService-grant($dto-userId, $orderId); return [id $orderId]; }); }Db::transaction()的好处是异常自动回滚、正常返回自动提交不用手写 try-catch。但仍然有两个坑要注意。坑一事务里别做耗时操作。发短信、调外部接口、写文件这些动作会让事务持续时间从毫秒级拉到秒级数据库锁被长时间占用。我在一个项目里见过在事务里调外部支付接口的写法高峰期直接把连接池打满。正确做法是用事务提交后执行的机制或者把外部调用挪到事务外面先落库再异步处理。坑二嵌套事务的行为要搞清楚。TP 的事务支持嵌套计数内层transaction并不会真的开一个新的数据库事务而是加一层计数最外层提交才真正提交。这意味着内层即使抛异常、外层捕获了继续往下走数据也不会回滚——因为它根本没有独立的事务边界。涉及多数据源或者必须独立回滚的场景得显式指定连接别指望默认行为。5. 编码风格与类型系统PSR-12 加严格类型的组合拳编码风格这件事团队里总有人说能跑就行。这话在小项目里成立在多人协作、生命周期超过一年的项目里就站不住。风格不统一的直接成本是代码评审时间翻倍间接成本是没人愿意重构最后整个项目变成能跑就别碰的状态。5.1 PSR-12 到底规定了什么为什么值得遵守PSR-12 是 PSR-2 的升级版核心内容其实不多缩进用四个空格、每行软限制一百二十字符、类的大括号另起一行、方法的大括号另起一行、控制结构的关键字后面必须有一个空格、use语句按字母排序分组、文件末尾留一个空行、只写?php不写结束标签。这些条款看着琐碎价值在于消除无意义的争论。没有规范时一个文件里可能同时存在两种缩进、三种括号风格diff 里一半的行是格式调整真正的逻辑改动淹没在里面。有了规范配合代码格式化工具格式问题在提交前就被自动修掉评审时只看逻辑。工具层面PHP 生态里最常用的是 PHP-CS-Fixer 和 PHP_CodeSniffer。前者自动修复后者只检查。我推荐的组合是本地用 PHP-CS-Fixer 在保存时自动修复CI 里用 PHP_CodeSniffer 做检查不通过就拦下。配置文件里把规则集直接指向PSR12别自己去拼规则。// .php-cs-fixer.php 片段 $config (new PhpCsFixer\Config()) -setRules([ PSR12 true, array_syntax [syntax short], ordered_imports [sort_algorithm alpha], no_unused_imports true, ]) -setFinder( PhpCsFixer\Finder::create()-in([app, config, route]) );no_unused_imports和ordered_imports这两条特别值得加。前者自动清理没用到的use后者保证导入顺序一致多人同时改一个文件时冲突能少一大半。5.2 严格类型声明带来的真实差异declare(strict_types1)这一行加在文件最顶部影响的是这个文件里所有函数调用的参数类型检查模式。不加的时候PHP 会做隐式转换传字符串5给int参数自动转成 5加了之后直接抛TypeError。这个差异在实际项目里的价值我用一个例子说明。某个计算金额的方法签名是function calc(int $cents): int调用方从数据库读出来的字段是字符串类型很多驱动默认返回字符串不加严格模式时PHP 悄悄转换看起来一切正常但这个字段如果是12.5这种带小数的字符串转换会截断成 12金额就这样少算了。加了严格模式之后这类情况在第一行调用就炸出来问题暴露得早得多。需要澄清一个常见误解严格模式是调用方文件的声明决定行为不是被调用方。也就是说A 文件声明了严格模式A 调用 B 的方法时按严格模式检查B 自己有没有声明不影响 A 的调用。这个规则很多人搞反了以为在被调用方加就行。我的建议是把declare(strict_types1)加在所有新写的文件上尤其是app目录下的业务代码。从老项目迁移过来的部分可以暂缓逐个文件改避免一次性大面积改动引发连锁问题。同样的思路适用于返回类型。给每个方法都写返回类型包括void和array。没有返回类型时一个方法可能返回null、返回false、返回空数组调用方要写一堆防御性判断有了明确的返回类型约定就清楚了——要么返回数组要么抛异常不会是第三种。5.3 枚举、联合类型在业务里的落点PHP 8.1 的枚举enum在业务代码里非常有用最典型的场景是状态字段。过去用常量class OrderStatus { const PENDING 0; const PAID 1; const SHIPPED 2; }问题在于这只是一堆整数方法签名写int $status谁都可以传 99 进去编译器不管。换成 backed enumenum OrderStatus: int { case Pending 0; case Paid 1; case Shipped 2; public function label(): string { return match ($this) { self::Pending 待支付, self::Paid 已支付, self::Shipped 已发货, }; } }方法签名可以写OrderStatus $status传错类型直接报错。用match做分支穷举性也是强制的——将来加了一个新状态match里没覆盖到运行时会抛UnhandledMatchError比switch漏一个分支导致返回null好得多。联合类型int|string要克制使用。它确实能表达这两种都行但代价是每个使用点都要判断当前是哪种。业务代码里我更倾向于用两个方法或者一个 DTO把类型不确定性挡在入口处别让它扩散到核心逻辑。真需要联合类型的场景通常是底层工具类比如一个 ID 生成器既能接受 int 也能接受字符串形式的 ID。值得一提的是readonly属性配合构造器属性提升非常适合写 DTO 和值对象。一旦初始化就不能再改避免了对象传出去被某处偷偷改了一个字段这种极难排查的问题。前提还是那句话运行环境得是 PHP 8.1 以上。6. 参照 PSR 视角回看的几个高频事故下面这几个问题都是我在实际项目里真实遇到或者帮别人定位过的共同特点是代码看着没毛病报错信息指向的位置和真实原因隔着好几层。6.1 日志注入写成具体类测试替换失败现象是单元测试跑起来之后某个服务类的所有断言都失败日志文件里多了一堆真实环境的记录测试库里也被写入了数据。排查了半天最后定位到服务类的构造函数写的是Log $logger而不是LoggerInterface $logger。原因在于容器解析Log这个具体类时如果没有预先绑定会自己反射构造一个新的实例这个实例的配置和全局的日志配置不是同一份行为也难以预测。更麻烦的是这个新实例在测试里无法被替换成假对象因为类型提示锁死了必须是think\log\Log的实例。改成接口类型之后容器会自动注入全局配置好的日志实例测试时塞一个自己实现的假日志器就行final class ArrayLogger implements LoggerInterface { public array $records []; public function log($level, $message, array $context []): void { $this-records[] compact(level, message, context); } // 其余方法按接口实现转调 log }这个坑的教训是凡是接口存在的依赖类型提示就写接口不写实现类。除了日志缓存、容器、数据库连接、HTTP 客户端都是同理。6.2 缓存接口选错PSR-6 和 PSR-16 不是一回事有一回同事引入了一个第三方包它要求Psr\Cache\CacheItemPoolInterface也就是 PSR-6。他直接把 TP 的think\Cache传进去运行时报类型不匹配。原因在于 TP 实现的是 PSR-16也就是Psr\SimpleCache\CacheInterface两者的方法签名完全不同。两者的区别可以用一句话概括PSR-6 是面向对象的缓存池你要先getItem()拿到一个 item 对象判断isHit()再set()回池子最后save()PSR-16 是一堆简单方法直接get、set、delete一行一个动作。对比项PSR-6PSR-16接口名CacheItemPoolInterfaceCacheInterface操作方式先取 item再操作最后 save直接 get/set/delete批量操作支持saveDeferred/commit支持getMultiple/setMultiple适合场景需要复杂缓存策略日常简单缓存TP 的think\Cache走的是 PSR-16 路线够用且简单。如果确实需要接 PSR-6 的包得自己写一个适配器把 PSR-6 的调用翻译成 PSR-16 的动作。写适配器的时候要注意过期时间的语义差异PSR-6 用绝对时间点或 nullPSR-16 用秒数或 DateInterval直接透传会导致缓存永不过期或者立刻过期。6.3 中间件顺序和异常处理的相互影响这个问题的表现是接口在正常情况下返回正确的 JSON出错时却返回一大段 HTML 错误页前端解析失败。查日志发现异常确实抛出来了但全局异常处理器没生效。根因是中间件的注册顺序。TP8 的中间件是洋葱模型注册在前面的在外层异常会先被外层捕获。如果路由中间件在异常处理中间件的外层那么路由阶段抛出的异常就绕过了异常处理器。而全局中间件配置在app/middleware.php路由中间件写在路由定义或控制器注解上两者的相对顺序需要看框架的合并逻辑不是简单地全局的就在外层。排查这类问题的思路是先确认异常是在哪个阶段抛的路由匹配、中间件、控制器、模型再确认该阶段在洋葱模型的哪一层最后检查异常处理中间件有没有包住它。实在理不清可以在异常处理器里加一行日志看看到底有没有进来。我的经验做法是把自己写的全局中间件分两类一类是必须在最外层的跨域、请求 ID 注入、统一异常处理一类是靠内层的鉴权、限流。前一类通过配置里的优先级保证位置别依赖默认顺序。异常处理器本身要写得足够健壮能处理各种阶段抛出的异常并且根据请求的Accept头决定返回 JSON 还是 HTML别一刀切。注意调试中间件问题时临时把中间件全部注释掉再一个个打开比盯着配置文件猜快得多。这是最笨但最有效的办法。这几个坑的共同点是它们都不在写代码的层面而在理解框架和标准的边界这个层面。PSR 存在的一部分意义就是把这些边界用接口的形式写清楚让你在拼接组件的时候能提前知道会不会有冲突。
阅读完成 · 觉得有帮助?
咨询建站