做后端开发的这些年,多多少少都接触过ThinkPHP。坦白讲,早期很多同行看不上这个框架,觉得它就是给新手写增删改查用的。但到了ThinkPHP 8这一代,框架本身的底子是越来越扎实了,PHP 8的强类型、注解、联合类型这些特性都融合了进来,用起来顺手不少。
这篇文章我打算把用ThinkPHP 8搭API服务时最核心的几个组件一次性讲透,全程贴可运行的代码。如果你正准备用TP8做前后端分离项目,或者从老版本往上迁,这篇文章可以帮你少踩不少坑。
1. 环境准备与项目初始化
环境要求没什么好说的:PHP 8.2,Composer,MySQL。直接看安装命令:
composer create-project topthink/think tp8-api
装完以后,打开根目录的.env文件,把数据库连接配置好,把app_debug设为false。有人问本地开发期debug要不要开?我的建议是开发环境开,部署后必须关,没有例外。
另外ThinkPHP 8对PHP版本有硬性要求,必须是8.0以上。如果你还在用7.4,建议先升级PHP再谈其他,硬撑没意义。
2. 目录结构设计
默认的项目结构很整洁,但做API服务,我习惯在app目录下多做一层拆分:
app ├── controller │ └── v1 ├── common ├── exception ├── middleware ├── model └── validate
common放基础类库,比如统一响应。exception放自定义异常。middleware放认证、CORS等中间件。validate集中管理参数校验规则。
这么拆的好处不用多说,代码量越大,清晰的目录结构带来的收益越明显。
3. 统一响应机制
API接口的返回格式不统一,前后端联调的时候永远在吵架。所以项目启动第一天,就要把响应格式定死。
{
"code": 200,
"message": "操作成功",
"data": [],
"time": 1698765432
}
code表示业务状态码,200成功,400业务错误,401未认证。message是人类可读的提示信息。data是具体业务数据。time带个时间戳,方便排查网络层的缓存问题。
封装一个响应类,控制器里用起来就是一行代码:
namespace appcommonresponse;
class ApiResponse
{
public static function success($data = null, string $message = '操作成功', int $code = 200)
{
return json([
'code' => $code,
'message' => $message,
'data' => $data,
'time' => time()
]);
}
public static function error(string $message = '操作失败', int $code = 400, $data = null)
{
return json([
'code' => $code,
'message' => $message,
'data' => $data,
'time' => time()
]);
}
}
这里有个细节:code字段我用的是业务状态码,而不是HTTP状态码。HTTP状态码定位为传输层的状态,业务状态码放入body。刚开始用这套方案的时候,有同事觉得两套状态码很绕,但用习惯了以后,前端处理起来特别舒服,前端只要先读body里的code,再决定怎么渲染。
4. 异常处理体系
后端再稳的程序也免不了出Bug,关键是出错时返回给前端的东西要一致。我一般的做法是:自定义一个BizException类代表业务异常,然后在全局异常处理里做统一拦截。
先定义一个业务异常类:
namespace appexception;
use Exception;
class BizException extends Exception
{
}
接着去app/ExceptionHandle.php,覆写render方法:
namespace app;
use appexceptionBizException;
use thinkexceptionHandle;
use thinkexceptionValidateException;
use thinkResponse;
use Throwable;
class ExceptionHandle extends Handle
{
public function render($request, Throwable $e): Response
{
if ($e instanceof BizException) {
return json([
'code' => $e->getCode(),
'message' => $e->getMessage(),
'data' => null,
'time' => time()
]);
}
if ($e instanceof ValidateException) {
return json([
'code' => 422,
'message' => $e->getMessage(),
'data' => null,
'time' => time()
], 422);
}
if (config('app.app_debug')) {
return json([
'code' => 500,
'message' => $e->getMessage(),
'data' => null,
'time' => time()
], 500);
}
return json([
'code' => 500,
'message' => '服务器内部错误',
'data' => null,
'time' => time()
], 500);
}
}
这套机制的重点在于:业务异常和系统异常彻底分离。你只需要在业务代码里throw new BizException(‘余额不足’, 400),前端就能在同样的位置拿到错误码和错误信息。
有个教训想分享一下。以前图省事,在异常处理里直接返回了异常消息,结果线上SQL语句泄露过一次。后来生产环境的异常消息一律加密记录到日志,前端只拿得到”服务器内部错误”这六个字。
5. 验证器
很多PHP项目把参数校验写在哪的都有:控制器里、模型里、甚至模板里(这个我真见过)。TP8的验证器是专门干这个的,不用白不用。
namespace appvalidate;
use thinkValidate;
class UserValidate extends Validate
{
protected $rule = [
'username|用户名' => 'require|max:20',
'password|密码' => 'require|min:6|max:20',
'email|邮箱' => 'email'
];
public function sceneLogin()
{
return $this->only(['username', 'password']);
}
public function sceneRegister()
{
return $this->only(['username', 'password', 'email']);
}
}
scene方法的作用是定义场景。登录时只需要检查username和password,注册时还要检查email。一套验证规则对应多个场景,省得写一堆类。
控制器里用起来就是这样:
$validate = new UserValidate();
if (!$validate->scene('login')->check($data)) {
throw new BizException($validate->getError(), 422);
}
6. JWT认证中间件
JWT还没流行的时候,做登录认证基本靠Session。但前后端分离之后,Session跨域始终是个麻烦事,Cookie的属性只要稍微设置不对,前端根本存不住。JWT用起来简单粗暴,服务端不需要存状态,Token里自带用户信息和过期时间。
使用firebase/php-jwt库,引入方式:
composer require firebase/php-jwt
然后写认证中间件:
namespace appmiddleware;
use FirebaseJWTJWT;
use FirebaseJWTKey;
use thinkRequest;
class AuthMiddleware
{
public function handle(Request $request, Closure $next)
{
$token = $request->header('Authorization');
if (!$token) {
return json(['code' => 401, 'message' => '未登录或登录已过期']);
}
try {
$token = str_replace('Bearer ', '', $token);
$payload = JWT::decode($token, new Key(config('app.jwt_key'), 'HS256'));
$request->userId = $payload->uid;
} catch (Exception $e) {
return json(['code' => 401, 'message' => 'Token无效或已过期']);
}
return $next($request);
}
}
在中间件里解出Token携带的uid后,我顺手把它塞到了Request对象上。这样后面的控制器里直接$request->userId就能拿到用户ID,省去重复解析Token的步骤。
7. API版本控制
接口上线容易,下线难。尤其是App端的接口,用户手里的旧版本App可能几个月都不更新。所以API从第一版开始就要有版本意识。
我用路由分组控制版本,route/app.php文件里看起来是这样:
use thinkfacadeRoute;
use appmiddlewareAuthMiddleware;
Route::group('api/v1', function () {
Route::post('login', 'v1.User/login');
Route::post('register', 'v1.User/register');
// 需要认证才能访问的接口
Route::group(function () {
Route::get('profile', 'v1.User/profile');
Route::put('profile', 'v1.User/updateProfile');
})->middleware(AuthMiddleware::class);
});
这样当v2版本出来时,只需要新增一个controller/v2目录,再在路由里加一组v2的路由,新旧版本共存,互不干扰。
8. 完整登录接口
前面的组件都就位了,现在串起来走一个完整的登录接口,从参数校验到生成Token返回。
User控制器login方法:
namespace appcontrollerv1;
use appcommonresponseApiResponse;
use appexceptionBizException;
use appvalidateUserValidate;
use FirebaseJWTJWT;
use thinkfacadeDb;
use thinkRequest;
class User
{
public function login(Request $request)
{
$data = $request->post();
$validate = new UserValidate();
if (!$validate->scene('login')->check($data)) {
throw new BizException($validate->getError(), 422);
}
$user = Db::name('user')
->where('username', $data['username'])
->find();
if (!$user || !password_verify($data['password'], $user['password'])) {
throw new BizException('用户名或密码错误', 400);
}
$payload = [
'uid' => $user['id'],
'iat' => time(),
'exp' => time() + 7200
];
$token = JWT::encode($payload, config('app.jwt_key'), 'HS256');
return ApiResponse::success([
'token' => $token,
'user' => [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email']
]
], '登录成功');
}
}
路由文件里已经定义了POST api/v1/login,前端请求这个地址,带着username和password参数,就能拿到一个JWT和一个基本信息。后续其他需要认证的接口,在请求头加上Authorization: Bearer 即可。
9. 踩坑记录
这一节纯粹是经验总结,遇到过的坑列出来,没遇到的当个预警。
1. 路由注解缓存问题。TP8支持注解路由,但改完注解后如果不执行php think optimize:route,新路由不会生效。这个坑很容易踩,而且很难排查。
2. JWT密钥。密钥放.env,别提交进Git仓库。曾经见过有人把JWT_SECRET和数据库密码写死在代码里,还在Github上开源了,结果几分钟内就被扫描机器人抓走,跑了一堆挖矿木马。
3. Nginx的PATH_INFO。Nginx默认的fastcgi配置不会解析PATH_INFO,导致ThinkPHP的路由全都404。这个官方文档里有现成方案,搜索”tp8 nginx伪静态”就能找到。
4. 生产环境关闭debug。后端报错直接输出SQL和堆栈,这等于把数据库结构免费送给黑客。就一句话:app_debug永远等于false,没商量。
10. 总结
写到这里,核心内容讲得差不多了。这套组合方案不是银弹,但它把接口开发中那些最琐碎、最容易出幺蛾子的部分都规范了起来。以后新项目起步时,把这一整套骨架拖过去直接用,效率和健壮性都有保障。
ThinkPHP 8这个版本,确实让人看到了国产框架的进步。如果你有更好用的封装方案,欢迎交流。

