ThinkPHP 8 多应用架构实战:从零构建企业级RESTful API的完整路径

2026-06-28 0 698

如果你正在用ThinkPHP 8做项目,大概率已经注意到官方对多应用模式的重视程度明显提高了。之前用6.0的时候多应用还是个可选方案,到了8.0往后几乎是默认推荐的做法。刚好前段时间把一个旧项目从单应用拆成了多应用架构,顺带把API层也重构了一遍,踩了不少坑,趁记忆还新鲜,整理出来给有需要的同学参考。

一、为什么要折腾多应用模式

说个很现实的情况:大部分项目跑着跑着就会膨胀。起初可能就一个前端入口加几个接口,后来需求堆上来——管理后台要独立、要给第三方开放API、可能还得接个小程序端。如果所有逻辑都塞在一个应用里,控制器目录会变成灾难现场,配置文件互相打架,路由文件翻半天找不到对应的方法。

ThinkPHP 8的多应用模式解决的就是这个问题。它允许你在一个项目里跑多个独立的应用,比如:

  • admin — 管理后台接口,内部使用
  • api — 对外开放的RESTful接口
  • web — 传统的页面渲染应用

每个应用有自己的控制器、模型、配置、路由、中间件,互不干扰。但公共的东西(比如基础模型类、工具函数、第三方扩展)可以放在项目根层的common目录下共享。这个架构对中大型项目来说确实舒服不少。

二、环境与初始化

先确保本机PHP版本不低于8.0,这是ThinkPHP 8的硬性要求。习惯用8.1或8.2的话更好,JIT和枚举类型在写业务的时候偶尔能派上用场。

创建项目:

composer create-project topthink/think tp8-api-demo
cd tp8-api-demo

项目建好之后,默认是单应用模式。要开启多应用,先把app目录下的controllermodelview等文件夹删掉(别犹豫,后面会按应用重新建),然后安装多应用扩展:

composer require topthink/think-multi-app

安装完成后,在app目录下新建几个子目录作为应用。我们按实际需求来:

app/
├── admin/          # 管理后台应用
│   ├── controller/
│   ├── model/
│   ├── middleware/
│   └── route/
├── api/            # 对外开放API应用(本文重点)
│   ├── controller/
│   ├── model/
│   ├── middleware/
│   ├── validate/
│   └── route/
├── common/         # 公共模块
│   ├── controller/
│   ├── model/
│   └── service/
└── AppService.php  # 应用服务(多应用会自动生成)

到这一步,访问http://你的域名/adminhttp://你的域名/api应该都能分别进到对应应用了。如果报了404,先检查一下config/app.php里的with_route是否设为true,以及域名是否配置到了public目录。

三、统一响应格式——先把”壳”搭好

写API最忌讳的就是响应格式五花八门。一会儿返回{"code":0,"data":...},一会儿又变成{"status":"success"},前端对接的人会想打人的。在动手写业务逻辑之前,先把响应格式定死。

app/common/controller下建一个基类ApiController.php,所有API控制器都继承它:

<?php
namespace appcommoncontroller;

use thinkexceptionHttpResponseException;
use thinkResponse;

class ApiController
{
    /**
     * 成功响应
     * @param mixed $data 返回数据
     * @param string $message 提示信息
     * @param int $code 业务状态码
     */
    protected function success($data = [], string $message = '操作成功', int $code = 200): Response
    {
        $result = [
            'code'    => $code,
            'message' => $message,
            'data'    => $data,
            'timestamp' => time(),
        ];
        throw new HttpResponseException(json($result, 200));
    }

    /**
     * 失败响应
     * @param string $message 错误信息
     * @param int $code 业务状态码
     * @param int $httpCode HTTP状态码
     */
    protected function error(string $message = '操作失败', int $code = 400, int $httpCode = 200): Response
    {
        $result = [
            'code'    => $code,
            'message' => $message,
            'data'    => null,
            'timestamp' => time(),
        ];
        throw new HttpResponseException(json($result, $httpCode));
    }

    /**
     * 分页数据响应
     */
    protected function page($list, int $total, int $page, int $pageSize): Response
    {
        return $this->success([
            'list'     => $list,
            'total'    => $total,
            'page'     => $page,
            'pageSize' => $pageSize,
            'hasMore'  => ($page * $pageSize) < $total,
        ]);
    }
}

这里有一个小细节值得说一下:我用的是throw new HttpResponseException来终止执行并返回响应,而不是直接return json()。这样做的好处是可以在中间件或者异常处理器里统一拦截,避免层层return导致的代码冗余。这个习惯是从Laravel那边借鉴过来的,实测在TP8里同样好使。

然后建一个异常处理类,把框架报错也统一成上面的格式。在app/api目录下创建exception/Handler.php

<?php
namespace appapiexception;

use thinkexceptionHandle;
use thinkResponse;
use Throwable;

class Handler extends Handle
{
    public function render($request, Throwable $e): Response
    {
        // 参数验证异常
        if ($e instanceof thinkexceptionValidateException) {
            return json([
                'code'      => 422,
                'message'   => $e->getMessage(),
                'data'      => null,
                'timestamp' => time(),
            ], 200);
        }

        // 调试模式下保留原始错误信息
        if (app()->isDebug()) {
            return parent::render($request, $e);
        }

        // 生产环境统一返回
        return json([
            'code'      => 500,
            'message'   => '服务器内部错误,请稍后重试',
            'data'      => null,
            'timestamp' => time(),
        ], 200);
    }
}

别忘了在app/api下的provider.php里注册这个异常处理器(没有就新建一个):

<?php
use appapiexceptionHandler;

return [
    'thinkexceptionHandle' => Handler::class,
];

四、JWT认证——别再存session了

RESTful API讲究无状态,session那套东西在前后端分离的场景下确实不太合适。JWT是目前最主流的方案,TP8也有现成的扩展可以用。

安装thans/tp-jwt-auth

composer require thans/tp-jwt-auth

发布配置文件:

php think jwt:create

这会在config目录下生成jwt.php。里面可以调整token有效期、密钥、签发者等参数。默认配置其实就够用,但建议把ttl(过期时间)根据业务场景调整一下,别用默认的3600秒。我的习惯是access_token设2小时,refresh_token设7天,具体看项目安全要求。

然后在app/api/middleware下建一个认证中间件Auth.php

<?php
namespace appapimiddleware;

use thansjwtfacadeJWTAuth;
use thinkexceptionHttpResponseException;

class Auth
{
    public function handle($request, Closure $next)
    {
        try {
            // 验证token,同时获取用户信息
            $payload = JWTAuth::auth();
            // 把用户ID注入到请求中,后续控制器可以直接拿
            $request->userId = $payload['uid'] ?? 0;
        } catch (Exception $e) {
            throw new HttpResponseException(json([
                'code'      => 401,
                'message'   => '登录已过期,请重新登录',
                'data'      => null,
                'timestamp' => time(),
            ], 200));
        }

        return $next($request);
    }
}

在路由里给需要认证的接口组套上这个中间件就行。后面路由设计的部分会具体演示。

登录逻辑这里简单贴一下核心代码。在app/api/controller下建AuthController.php

<?php
namespace appapicontroller;

use appcommoncontrollerApiController;
use thinkRequest;

class AuthController extends ApiController
{
    /**
     * 用户登录
     */
    public function login(Request $request)
    {
        $username = $request->param('username');
        $password = $request->param('password');

        if (empty($username) || empty($password)) {
            return $this->error('用户名和密码不能为空');
        }

        // 这里是模拟查询,实际项目请替换为数据库查询
        $user = appapimodelUser::where('username', $username)->find();

        if (!$user || !password_verify($password, $user->password)) {
            return $this->error('用户名或密码错误', 401);
        }

        // 签发token
        $token = thansjwtfacadeJWTAuth::builder(['uid' => $user->id]);

        return $this->success([
            'access_token'  => $token['access_token'],
            'refresh_token' => $token['refresh_token'],
            'expires_in'    => $token['expires_in'],
            'user' => [
                'id'       => $user->id,
                'username' => $user->username,
                'avatar'   => $user->avatar,
            ],
        ], '登录成功');
    }
}

这里有个容易被忽略的点:密码验证务必用password_verifypassword_hash这套PHP内置函数,别自己瞎拼接md5加盐。都2024年了,bcrypt是底线。

五、路由设计与版本控制

API版本管理是个老生常谈的话题。我的做法是在URL路径里带版本号,比如/api/v1/articles。这种方式最直观,前端对接的时候不会搞混,也方便后续做灰度升级。

app/api/route下新建route.php

<?php
use thinkfacadeRoute;

// 不需要认证的接口
Route::group('v1', function () {
    // 认证相关
    Route::post('auth/login', 'Auth/login');
    Route::post('auth/register', 'Auth/register');

    // 公开的文章列表(游客可看)
    Route::get('articles', 'Article/index');
    Route::get('articles/:id', 'Article/read');
})->prefix('v1.');

// 需要登录认证的接口
Route::group('v1', function () {
    // 文章管理
    Route::post('articles', 'Article/create');
    Route::put('articles/:id', 'Article/update');
    Route::delete('articles/:id', 'Article/delete');

    // 用户相关
    Route::get('user/profile', 'User/profile');
    Route::put('user/profile', 'User/updateProfile');
})->prefix('v1.')->middleware(appapimiddlewareAuth::class);

注意这里我用了一个小技巧:把同一个版本号的接口按是否需要认证拆成两个group。这样中间件只作用在需要的地方,不会影响公开接口。见过不少项目把所有接口都套上认证中间件,然后白名单排除,逻辑反过来写其实更容易出漏洞。

另外prefix('v1.')这个写法会让控制器也按版本号分目录存放。也就是说你的控制器实际路径是app/api/controller/v1/Article.php。这样做的好处是将来如果要升级v2版本,直接在controller下新建v2目录,路由里改个前缀就行,v1的代码完全不用动。

六、数据验证——别把校验逻辑写在控制器里

控制器应该尽量薄,这是我的一个执念。数据校验、业务逻辑、数据库操作各归各位,代码才不容易变成意大利面条。

ThinkPHP 8的验证器用起来很顺手。在app/api/validate下建ArticleValidate.php

<?php
namespace appapivalidate;

use thinkValidate;

class ArticleValidate extends Validate
{
    protected $rule = [
        'title'      => 'require|max:120',
        'content'    => 'require|min:20',
        'category_id'=> 'require|integer|gt:0',
        'tags'       => 'array',
        'status'     => 'in:draft,published',
    ];

    protected $message = [
        'title.require'     => '文章标题不能为空',
        'title.max'         => '文章标题最多120个字符',
        'content.require'   => '文章内容不能为空',
        'content.min'       => '文章内容至少20个字符',
        'category_id.require' => '请选择文章分类',
        'category_id.integer' => '分类参数格式错误',
        'category_id.gt'    => '分类参数必须大于0',
        'status.in'         => '文章状态仅支持draft或published',
    ];

    // 场景定义:创建和更新时的规则可能不一样
    protected $scene = [
        'create' => ['title', 'content', 'category_id', 'tags', 'status'],
        'update' => ['title', 'content', 'category_id', 'tags', 'status'],
    ];
}

在控制器里调用验证只需要一行:

// 在Article控制器的create方法中
$validate = new appapivalidateArticleValidate;
$validate->scene('create')->check($request->post());
// 如果验证不通过,框架会自动抛出ValidateException
// 而我们之前在异常处理器里已经统一处理了这类异常

验证器和异常处理器配合起来,控制器里完全不需要写if-else判断参数是否合法,代码干净很多。

七、实战案例——文章管理API完整实现

前面铺垫了那么多,现在来一个完整的案例把流程串起来。下面是一个文章管理功能的完整实现,包含列表查询、详情查看、创建、更新和删除。

首先是控制器app/api/controller/v1/Article.php

<?php
namespace appapicontrollerv1;

use appcommoncontrollerApiController;
use appapivalidateArticleValidate;
use appapimodelArticle as ArticleModel;
use thinkRequest;

class Article extends ApiController
{
    /**
     * 文章列表(公开接口,支持分页和筛选)
     */
    public function index(Request $request)
    {
        $page     = $request->param('page', 1);
        $pageSize = $request->param('pageSize', 15);
        $keyword  = $request->param('keyword', '');
        $status   = $request->param('status', 'published');

        $query = ArticleModel::where('status', $status);

        if (!empty($keyword)) {
            $query->whereLike('title', "%{$keyword}%");
        }

        $total = $query->count();
        $list  = $query->field('id,title,summary,cover,author,create_time,view_count')
            ->order('create_time', 'desc')
            ->page($page, $pageSize)
            ->select()
            ->toArray();

        return $this->page($list, $total, (int)$page, (int)$pageSize);
    }

    /**
     * 文章详情
     */
    public function read($id)
    {
        $article = ArticleModel::field('id,title,content,cover,author,category_id,tags,status,create_time,view_count')
            ->find($id);

        if (!$article) {
            return $this->error('文章不存在', 404);
        }

        // 阅读量自增
        $article->inc('view_count')->update();

        return $this->success($article->toArray());
    }

    /**
     * 创建文章(需认证)
     */
    public function create(Request $request)
    {
        // 数据验证
        $validate = new ArticleValidate;
        $validate->scene('create')->check($request->post());

        $data = [
            'title'       => $request->post('title'),
            'content'     => $request->post('content'),
            'summary'     => mb_substr(strip_tags($request->post('content')), 0, 200),
            'category_id' => $request->post('category_id'),
            'tags'        => json_encode($request->post('tags', []), JSON_UNESCAPED_UNICODE),
            'status'      => $request->post('status', 'draft'),
            'author'      => $request->userId, // 从认证中间件注入的
            'create_time' => date('Y-m-d H:i:s'),
        ];

        $article = ArticleModel::create($data);

        return $this->success(['id' => $article->id], '文章创建成功');
    }

    /**
     * 更新文章(需认证,且只能修改自己的文章)
     */
    public function update(Request $request, $id)
    {
        $article = ArticleModel::find($id);

        if (!$article) {
            return $this->error('文章不存在', 404);
        }

        if ($article->author != $request->userId) {
            return $this->error('无权编辑他人的文章', 403);
        }

        $validate = new ArticleValidate;
        $validate->scene('update')->check($request->post());

        $updateData = $request->only(['title', 'content', 'category_id', 'tags', 'status']);
        if (!empty($updateData['content'])) {
            $updateData['summary'] = mb_substr(strip_tags($updateData['content']), 0, 200);
        }
        if (!empty($updateData['tags'])) {
            $updateData['tags'] = json_encode($updateData['tags'], JSON_UNESCAPED_UNICODE);
        }

        $article->save($updateData);

        return $this->success([], '文章更新成功');
    }

    /**
     * 删除文章(需认证)
     */
    public function delete(Request $request, $id)
    {
        $article = ArticleModel::find($id);

        if (!$article) {
            return $this->error('文章不存在', 404);
        }

        if ($article->author != $request->userId) {
            return $this->error('无权删除他人的文章', 403);
        }

        // 软删除,数据表需要有delete_time字段
        $article->delete();

        return $this->success([], '文章已删除');
    }
}

模型层app/api/model/Article.php

<?php
namespace appapimodel;

use thinkModel;
use thinkmodelconcernSoftDelete;

class Article extends Model
{
    use SoftDelete;

    protected $name = 'article';
    protected $deleteTime = 'delete_time';
    protected $defaultSoftDelete = 0;

    // 自动时间戳
    protected $autoWriteTimestamp = true;
    protected $createTime = 'create_time';
    protected $updateTime = 'update_time';

    // JSON字段自动转换
    protected $json = ['tags'];
    protected $jsonAssoc = true;

    /**
     * 关联分类
     */
    public function category()
    {
        return $this->belongsTo(Category::class, 'category_id');
    }

    /**
     * 作用域:仅查询已发布
     */
    public function scopePublished($query)
    {
        $query->where('status', 'published');
    }
}

对应的数据表结构参考(MySQL):

CREATE TABLE `article` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `title` varchar(120) NOT NULL COMMENT '标题',
    `content` longtext NOT NULL COMMENT '正文',
    `summary` varchar(300) DEFAULT '' COMMENT '摘要',
    `cover` varchar(255) DEFAULT '' COMMENT '封面图',
    `author` int unsigned NOT NULL COMMENT '作者用户ID',
    `category_id` int unsigned NOT NULL COMMENT '分类ID',
    `tags` json DEFAULT NULL COMMENT '标签',
    `status` enum('draft','published') DEFAULT 'draft' COMMENT '状态',
    `view_count` int unsigned DEFAULT '0' COMMENT '阅读数',
    `create_time` datetime NOT NULL,
    `update_time` datetime DEFAULT NULL,
    `delete_time` int unsigned DEFAULT '0' COMMENT '软删除标记',
    PRIMARY KEY (`id`),
    KEY `idx_author` (`author`),
    KEY `idx_category` (`category_id`),
    KEY `idx_status_time` (`status`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';

整个流程走下来,从路由到控制器到验证到模型,链条很清晰。这里面有几个我踩过的坑值得单独说一下:

  • 软删除的delete_time字段类型:TP8默认用int存时间戳,不是datetime。如果你之前用的是Laravel迁移过来的表,注意字段类型要匹配,否则软删除不生效。
  • JSON字段的处理:模型里设了$json属性后,存入数据库会自动编码,取出来自动解码。但如果你的MySQL版本低于5.7不支持原生JSON类型,字段类型可以用text替代,不影响使用。
  • 权限校验放在控制器还是中间件:上面更新和删除接口里,我手动判断了$article->author != $request->userId。这种资源级别的权限控制放中间件里反而不灵活,直接在控制器里判断最直观。如果是角色级别的权限(比如只有管理员能访问某个模块),那放在中间件里更合适。

八、一些性能上的小建议

写完功能只是第一步,上线前还有几个地方值得优化:

  • 数据库索引:上面表结构里已经加了idx_status_time联合索引,列表查询的时候能走索引。实际项目中记得用EXPLAIN分析慢查询,别等线上报警了再补索引。
  • JWT黑名单:用户退出登录或者修改密码后,旧的token应该失效。thans/tp-jwt-auth支持缓存黑名单,把失效的token丢进Redis,中间件里多查一次就行。不过如果业务对实时性要求不高,等token自然过期也不是不行。
  • 响应数据裁剪:上面index方法里用field()指定了返回字段,列表接口不要查content这种大字段,能省不少传输时间和内存。
  • 接口限流:对外的公开API最好加一层限流。可以用TP8的中间件配合Redis计数器实现,一分钟内同一IP超过阈值就返回429。这个不复杂但很实用,能挡住大部分恶意爬虫。

九、写在最后

ThinkPHP 8的多应用模式比6.0时期成熟了不少,目录结构清晰,配合中间件和验证器能把代码组织得比较舒服。这篇文章侧重实战,很多细节没法一一展开——比如队列、事件、缓存策略这些,但上面这套架子搭好之后,加这些功能都是水到渠成的事。

如果你正在用TP8做项目,建议从一开始就上多应用模式。哪怕现在只有一个应用,把架子预留好,将来扩展的时候会感谢自己当初的决定。另外代码规范方面,控制器保持薄、模型保持纯、验证独立成层,这三条原则坚持下来,项目的可维护性能提升不少。

文中所有代码都在本地跑通过,如果照着来遇到问题,多半是版本差异或者配置细节没对上,欢迎对照排查。

ThinkPHP 8 多应用架构实战:从零构建企业级RESTful API的完整路径
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 多应用架构实战:从零构建企业级RESTful API的完整路径 https://www.taomawang.com/server/thinkphp/2289.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务