最近需要给内部几个小团队分别管理各自的后台任务,比如定时报表、数据清洗、消息推送。不用搞太复杂的微服务,但又必须让每个团队只能看到和操作自己的任务,数据之间完全隔离。打开ThinkPHP 8新建了个项目,用它的多应用模式、中间件自动识别租户、再加上服务层和命令调度,前后不到两天搭了一套挺顺手的多租户任务系统。把整个过程记录下来,很多思路在常规后台开发里也能复用。
为什么直接用多应用模式
多租户系统的头号难题就是隔离。不少人习惯在数据库里加一个team_id字段,然后用模型全局作用域过滤。这招在简单场景可以用,可一旦业务逻辑复杂起来,稍有不慎某个查询忘了加条件,数据就串了。我这次采取的策略更彻底一点:每个团队一个独立的子应用,应用之间路由、控制器、业务逻辑完全分开,数据库也可以独立配置,物理上把边界划清楚。
ThinkPHP 8对多应用的支持比前几版顺手不少,开箱即用。在项目目录下创建app/team-a、app/team-b这样的目录,每个目录里都是完整的MVC结构。入口文件还是同一个,通过URL自动分发,比如/team-a/task/index就进到team-a应用的Task控制器。
安装和初始配置这里不展开,直接说重点:在app目录下执行:
php think make:controller team-a@Task
一行命令就把控制器建到对应子应用里了,不用手建一大堆文件夹。
全局中间件自动解析租户
多应用结构准备好了之后,接下来要解决的问题是:怎么让每个子应用知道当前访问的租户是谁,并且自动切换对应的数据库连接。这需要一个在路由分发之前就执行的中间件。
在app/common/middleware下新建TenantResolver.php:
namespace appcommonmiddleware;
use thinkfacadeConfig;
use thinkfacadeRequest;
class TenantResolver
{
public function handle($request, Closure $next)
{
// 从域名或路径中提取租户标识,这里用路径第一段作为租户名
$path = $request->pathinfo();
$segments = explode('/', trim($path, '/'));
$tenant = $segments[0] ?? 'default';
// 检查租户是否合法
$validTenants = ['team-a', 'team-b', 'team-c']; // 实际可从配置或数据库读取
if (!in_array($tenant, $validTenants)) {
abort(404, '租户不存在');
}
// 把租户信息注入请求对象,方便后续使用
$request->tenant = $tenant;
// 动态切换数据库连接,每个租户可以用独立库
Config::set([
'connections' => [
'tenant' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'task_' . $tenant,
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
]
]
], 'database');
return $next($request);
}
}
这个中间件做了两件事:从URL第一段切出租户标识,然后动态设置一个叫tenant的数据库连接。之后在模型里只要指定连接到tenant,就自动落到对应租户的库上,完全不用在每个查询里手动加租户条件。而且因为子应用本身就是按租户目录放置的,路由天然隔离,即使开发者写控制器时完全忘了租户这事,也无法跨应用访问数据。
注册中间件到全局,在app/middleware.php里加上:
return [
appcommonmiddlewareTenantResolver::class,
];
抽象任务服务层,让控制器薄如纸
ThinkPHP社区里有种习惯是把逻辑全写在控制器里,一个方法上百行,这在多应用场景下就是灾难,每个子应用都要复制粘贴一遍。我把任务相关的核心操作抽成一个服务层,放在app/common/service/TaskService.php,所有子应用共用这一套,但操作的数据源由中间件切换好的租户连接决定。
namespace appcommonservice;
use thinkfacadeDb;
class TaskService
{
// 创建任务
public function create(array $data)
{
$data['created_at'] = date('Y-m-d H:i:s');
$id = Db::connect('tenant')->table('tasks')->insertGetId($data);
return $id;
}
// 获取任务列表,关键词搜索、分页
public function list(array $params)
{
$query = Db::connect('tenant')->table('tasks');
if (!empty($params['keyword'])) {
$query->where('title', 'like', "%{$params['keyword']}%");
}
return $query->order('id desc')->paginate(15);
}
// 执行任务(可由命令行触发)
public function execute(int $id)
{
$task = Db::connect('tenant')->table('tasks')->find($id);
if (!$task) {
throw new Exception('任务不存在');
}
// 模拟任务执行逻辑
Db::connect('tenant')->table('tasks')
->where('id', $id)
->update(['status' => 2, 'executed_at' => date('Y-m-d H:i:s')]);
}
}
控制器里只需要注入服务类,变成薄薄一层:
namespace appteam-acontroller;
use appcommonserviceTaskService;
use thinkRequest;
class Task
{
protected $service;
public function __construct(TaskService $service)
{
$this->service = $service;
}
public function index(Request $request)
{
$list = $this->service->list($request->get());
return json($list);
}
public function save(Request $request)
{
$id = $this->service->create($request->post());
return json(['id' => $id]);
}
}
因为不同子应用共用同一个服务类,业务逻辑只维护一份。但底层数据库连接却因为中间件切换了tenant连接指向不同的物理库,所以team-a和team-b看到的完全是自己的数据。这种组合既保证了代码复用,又没牺牲隔离性。
租户事件监听,新团队自动初始化
SaaS系统里少不了这样的需求:当一个新租户注册时,要自动给它创建数据库表、写入默认配置。ThinkPHP 8的事件系统很适合干这个活。在公共事件目录下新建TenantRegistered.php事件类:
namespace appcommonevent;
class TenantRegistered
{
public $tenant;
public function __construct($tenant)
{
$this->tenant = $tenant;
}
}
然后在app/common/listener里建监听器AutoSetupTenant.php:
namespace appcommonlistener;
use thinkfacadeDb;
use thinkfacadeEvent;
class AutoSetupTenant
{
public function handle($event)
{
$tenant = $event->tenant;
// 切换到该租户的临时连接
$db = Db::connect('tenant');
// 创建任务表
$db->execute("CREATE TABLE IF NOT EXISTS `tasks` (
`id` int unsigned auto_increment primary key,
`title` varchar(200) not null,
`status` tinyint default 0,
`created_at` datetime,
`executed_at` datetime
)");
// 写入默认配置等
}
}
别忘了在app/event.php里绑定:
return [
'appcommoneventTenantRegistered' => [
'appcommonlistenerAutoSetupTenant',
],
];
以后在任何地方触发Event::trigger(new TenantRegistered('team-d')),监听器就会自动为新租户建好基础结构。结合前面的中间件,一旦建好,新域名或路径对应的子应用也立刻能用。
利用命令行与队列自动调度任务
任务系统最终还得让任务自己跑起来。ThinkPHP 8内置了队列和命令行指令,不需要额外装什么东西。我给每个定时任务定义了一个命令,比如php think cron:execute --tenant team-a。
在app/common/command下创建CronExecute.php:
namespace appcommoncommand;
use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleinputOption;
use thinkconsoleOutput;
use appcommonserviceTaskService;
class CronExecute extends Command
{
protected function configure()
{
$this->setName('cron:execute')
->addOption('tenant', null, Option::VALUE_REQUIRED, '租户标识')
->setDescription('执行租户下的待处理任务');
}
protected function execute(Input $input, Output $output)
{
$tenant = $input->getOption('tenant');
// 此处需要手动切换连接,因为命令行不走中间件
thinkfacadeConfig::set([
'connections' => [
'tenant' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'task_' . $tenant,
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
]
]
], 'database');
$service = new TaskService();
$pendingTasks = thinkfacadeDb::connect('tenant')->table('tasks')
->where('status', 0)->select();
foreach ($pendingTasks as $task) {
try {
$service->execute($task['id']);
$output->info("任务 {$task['id']} 执行成功");
} catch (Exception $e) {
$output->error("任务 {$task['id']} 失败: " . $e->getMessage());
}
}
}
}
在app/console.php里注册这个命令,然后就可以用系统自带的定时任务每分钟执行一次:
* * * * * php /path/to/project/think cron:execute --tenant team-a >> /tmp/cron_team-a.log 2>&1
虽然用系统cron简单直接,但如果你希望任务失败后自动重试,可以配合ThinkPHP的队列功能。把具体任务投递到队列,让队列消费者去执行,后续扩展更多租户时只要加一条命令行即可,不需要改代码。
几个踩到的细节
- 路由缓存问题。多应用模式下如果开启了路由缓存,增加新租户时需要重新生成缓存,否则新子应用路由不生效。我在部署脚本里加了
php think optimize:route。 - 数据库连接配置切换的作用域。中间件里用
Config::set动态改配置,对当前请求有效。但注意,如果控制器里手动Db::connect('mysql')会越过租户配置,这需要团队规范约束。 - 服务层需要无状态。服务类不要存跟租户相关的属性,所有数据操作都从当前连接获取,避免同一进程处理多租户时串数据。
这套方案的适用边界
这里用物理隔离数据库的方式适合对数据隔离要求高的场景,比如不同企业客户的数据绝对不能混在一起。如果只是小团队内部用,可以用一张表加tenant_id加全局作用域的做法,资源利用率更高。ThinkPHP 8完全兼容这两种方案,可以根据实际情况选择。
多应用模式的好处是,未来某个租户有特殊需求,可以直接修改它子应用下的控制器而不影响他人。坏处是如果对所有租户都是同一套逻辑,新增一个租户需要拷贝目录,稍显繁琐。我后来写了一个脚手架命令php think tenant:create,自动复制标准文件,把这个过程自动化了。
回顾一下全过程
从零到跑起来整套SaaS任务系统,核心步骤无非这几步:先利用ThinkPHP 8多应用搭出租户目录骨架,用一个全局中间件解析租户标识并切换数据库连接,把业务逻辑抽成共享服务层避免代码重复,通过事件监听自动化新租户初始化,最后用命令和队列完成任务的定时调度。整个过程没有用任何微服务框架或第三方SaaS包,全凭框架自身的能力组合。
如果你也在做类似的多团队或多客户后台,这套思路也许能让你少写不少样板代码。框架工具用对路,很多看起来需要复杂架构的活,其实几层薄薄的抽象就能解决。

