【基于 Swoole+Hyperf 的微服务实战】第一周·周六:综合实战与知识串联
·
为了巩固前五天所学的协程、Channel、上下文与 Hyperf 基础,我为你设计了一个可选的周六综合实战日。今天不引入全新理论,而是通过一个完整的项目——“协程 TCP 聊天室 + HTTP 监控面板”,把本周所有知识点串联起来。这种“做中学”的方式,能让你对协程的威力和框架的便利性产生肌肉记忆。

第一周 周六:综合实战与知识串联(可选)
今日目标
- 使用 Swoole 原生构建一个多人在线 TCP 聊天室,支持用户上线、广播消息和私聊。
- 利用
Channel实现用户间消息广播,利用WaitGroup安全关闭服务器。 - 利用协程上下文
Context传递用户昵称和房间信息。 - 额外添加一个 HTTP 监控接口,可查看在线人数和最近消息记录。
- 将所有代码组织清晰,并能在 Docker 环境中运行、测试。
注:虽然我们才刚在周四接触 Hyperf,今天为了聚焦协程本身,仍先用纯 Swoole 实现核心聊天逻辑。你会惊讶地发现,用下周即将学习的依赖注入和注解,可以把这个项目进一步改造成一个更优雅的微服务。
一、环境准备(约 15 分钟)
在之前使用的 swoole-course 目录下新建一个周六项目文件夹:
cd swoole-course
mkdir day6-chat && cd day6-chat
进入容器:
docker-compose exec swoole bash
cd /var/www/day6-chat
二、核心知识点回顾(约 30 分钟)
开始前,我们快速在脑海中过一遍今天会用到的工具:
- TCP 服务器:
$server = new Swoole\Server('0.0.0.0', 9501),回调onConnect,onReceive,onClose。 - Channel:用作广播通道,所有连接都可以向同一个 Channel 推送消息,专门的广播协程负责消费。
- 协程上下文:
Context::set('nickname', $name),在接收消息时可以拿到发送者的信息。 - WaitGroup:用于在服务器关闭时,等待所有广播协程处理完剩余消息后再退出。
- HTTP 服务器:我们将在同一个进程中开启一个 HTTP 服务监听 9502,提供监控接口(注意协程化)。
由于 Swoole Server 是单进程多协程的,我们可以直接在 onWorkerStart 中启动 HTTP 服务和广播协程。
三、实战:构建聊天室与监控面板(约 3 小时)
我们将分步编写 chat_server.php,最终效果如下:
- 用户通过
telnet连接后,输入/nick 昵称设置昵称,然后发言。 - 消息会被广播给所有在线用户(包括自己)。
- 支持
/msg 昵称 内容发送私聊。 - 通过浏览器访问
http://localhost:9502/stats查看在线人数和最近 20 条消息。
步骤 1:创建基本 TCP 服务器,初始化广播通道
<?php
// chat_server.php
use Swoole\Server;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\Context;
use Swoole\Coroutine\WaitGroup;
use Swoole\Http\Response;
use Swoole\Http\Request;
$server = new Server('0.0.0.0', 9501, SWOOLE_PROCESS);
$server->set([
'worker_num' => 1, // 单 Worker 方便演示
'enable_coroutine' => true,
]);
// 全局广播通道(容量 256)
$broadcastChan = new Channel(256);
// 在线用户表:[ fd => ['nick' => 'xxx', 'ctx' => Context] ]
$onlineUsers = [];
// 历史消息队列(保留最近 20 条)
$historyMessages = [];
// WaitGroup 用于关闭时的优雅退出
$shutdownWg = new WaitGroup();
$server->on('WorkerStart', function (Server $server, int $workerId) use ($broadcastChan, &$onlineUsers, &$historyMessages, &$shutdownWg) {
// 启动广播协程:不断从 Channel 消费消息并分发给所有连接
go(function () use ($server, $broadcastChan, &$onlineUsers, &$historyMessages, $shutdownWg) {
while ($msg = $broadcastChan->pop()) {
if ($msg === 'SHUTDOWN') break; // 关闭信号
// $msg 结构: ['type' => 'broadcast'|'private', 'from' => fd, 'content' => '', 'to' => fd|0]
switch ($msg['type']) {
case 'broadcast':
$payload = json_encode([
'type' => 'broadcast',
'from' => $msg['from'],
'nick' => $onlineUsers[$msg['from']]['nick'] ?? 'Unknown',
'content' => $msg['content'],
'time' => date('H:i:s'),
]);
// 存储历史
$historyMessages[] = $payload;
if (count($historyMessages) > 20) {
array_shift($historyMessages);
}
// 广播给所有人
foreach ($onlineUsers as $fd => $info) {
$server->send($fd, $payload . "\n");
}
break;
case 'private':
$toFd = $msg['to'];
if (isset($onlineUsers[$toFd])) {
$payload = json_encode([
'type' => 'private',
'from' => $msg['from'],
'nick' => $onlineUsers[$msg['from']]['nick'] ?? 'Unknown',
'content' => $msg['content'],
'time' => date('H:i:s'),
]);
$server->send($toFd, $payload . "\n");
// 也发给发送者一份回显
$server->send($msg['from'], $payload . "\n");
} else {
$server->send($msg['from'], "系统:用户不在线\n");
}
break;
}
}
echo "广播协程退出\n";
$shutdownWg->done();
});
$shutdownWg->add(); // 广播协程计入等待组
// 启动一个协程 HTTP 服务器监听 9502
go(function () use (&$onlineUsers, &$historyMessages) {
$http = new Swoole\Http\Server('0.0.0.0', 9502);
$http->on('Request', function (Request $req, Response $res) use (&$onlineUsers, &$historyMessages) {
$path = $req->server['request_uri'];
if ($path === '/stats') {
$res->header('Content-Type', 'application/json');
$res->end(json_encode([
'online_count' => count($onlineUsers),
'users' => array_map(fn($u) => $u['nick'], $onlineUsers),
'recent_messages' => $historyMessages,
], JSON_UNESCAPED_UNICODE));
} else {
$res->status(404);
$res->end('Not Found');
}
});
$http->start();
});
});
$server->on('Connect', function (Server $server, int $fd) use (&$onlineUsers) {
$onlineUsers[$fd] = ['nick' => "User{$fd}", 'ctx' => Context::get()];
echo "客户端连接: fd={$fd}\n";
});
$server->on('Receive', function (Server $server, int $fd, int $reactorId, string $data) use ($broadcastChan, &$onlineUsers) {
$data = trim($data);
if (empty($data)) return;
// 命令解析
if (str_starts_with($data, '/nick ')) {
$nick = trim(substr($data, 6));
if ($nick) {
$onlineUsers[$fd]['nick'] = $nick;
Context::set('nick', $nick); // 存入当前协程上下文(这里上下文作用域有限,仅演示)
$server->send($fd, "系统:昵称已设置为 {$nick}\n");
}
} elseif (str_starts_with($data, '/msg ')) {
// 格式: /msg 昵称 内容
$parts = explode(' ', $data, 3);
if (count($parts) < 3) {
$server->send($fd, "系统:格式错误,应为 /msg 昵称 内容\n");
return;
}
$targetNick = $parts[1];
$content = $parts[2];
$targetFd = null;
foreach ($onlineUsers as $fdId => $info) {
if ($info['nick'] === $targetNick) {
$targetFd = $fdId;
break;
}
}
if ($targetFd) {
$broadcastChan->push([
'type' => 'private',
'from' => $fd,
'to' => $targetFd,
'content' => $content,
]);
} else {
$server->send($fd, "系统:用户 {$targetNick} 不在线\n");
}
} else {
// 普通消息,广播
$broadcastChan->push([
'type' => 'broadcast',
'from' => $fd,
'content' => $data,
]);
}
});
$server->on('Close', function (Server $server, int $fd) use (&$onlineUsers, $broadcastChan) {
// 通知其他用户该人下线
$nick = $onlineUsers[$fd]['nick'] ?? "User{$fd}";
$broadcastChan->push([
'type' => 'broadcast',
'from' => 0, // 系统
'content' => "{$nick} 离开了聊天室",
]);
unset($onlineUsers[$fd]);
echo "客户端断开: fd={$fd}\n";
});
// 关闭服务器时的清理
$server->on('WorkerStop', function () use ($broadcastChan, $shutdownWg) {
$broadcastChan->push('SHUTDOWN');
$shutdownWg->wait(); // 等待广播协程结束
echo "Worker 安全退出\n";
});
$server->start();
步骤 2:启动并测试聊天功能
在容器内运行:
php chat_server.php
然后打开三个终端窗口,均使用 telnet localhost 9501 连接:
- 终端 1 输入:
/nick Alice然后输入Hello everyone! - 终端 2 输入:
/nick Bob应该能收到 Alice 的消息。 - 终端 3 输入:
/nick Charlie,然后 Bob 发一条/msg Alice 你好,私聊,只有 Alice 和 Bob 能看到。
观察服务器终端日志,并同时浏览器访问 http://localhost:9502/stats,查看在线用户列表和历史消息。
四、成果测试与观察(约 1 小时)
1. 功能测试清单
- 昵称设置:
/nick 新昵称,服务器返回确认。 - 广播消息:任意一个客户端发言,所有在线客户端都能收到,并且历史消息被记录。
- 私聊消息:
/msg 目标昵称 内容,只有目标和自己收到,若目标不存在则提示。 - 上线/下线通知:新用户连接时(虽然没有主动通知,但可改进),下线时系统广播离开消息。
- 监控面板:浏览器访问
http://localhost:9502/stats,在线人数随连接数变化,历史消息实时更新。
2. 协程安全性验证
- 并发连接:快速打开 10 个
telnet连接并同时发送消息,所有消息都能正常广播,没有协程冲突导致的 PHP 警告。 - 通道背压:由于
broadcastChan容量为 256,如果广播协程处理速度跟不上,生产者push会自动挂起等待,不会丢失消息。可以试着用脚本快速发送数百条消息,看看服务器是否卡顿或报错。
3. 进阶思考
- 如何将聊天服务集成到 Hyperf 中? 下周学完依赖注入后,你可以把广播通道定义为一个单例 Bean,把用户管理抽象成一个
UserManager服务,通过注解注入到 WebSocket 或 TCP 控制器中,立刻变得高内聚低耦合。 - 如何用 WebSocket 替代 Telnet? Swoole 支持 WebSocket 服务器,可以在 HTTP 基础上升级协议,Hyperf 也提供了
hyperf/websocket-server组件,届时聊天室将变成网页版,体验更好。
4. 自我挑战任务
- 增加房间功能:实现
/join 房间名,消息只发给同房间的人。提示:可以用二维数组或 Channel 分组。 - 敏感词过滤:在广播消息入库前,用协程调用一个外部过滤 API(可用
file_get_contents协程化),将敏感词替换为***。 - 持久化:将历史消息异步写入文件或 MySQL,使用
go加defer保证文件句柄关闭。
五、今日作业与产出
- 提交代码:将
chat_server.php和相关注释提交到 Git。 - 学习笔记:画出聊天室的消息流向图,标出 Channel、Context、WaitGroup 各自扮演的角色。
- 总结思考:写一段话总结本周从协程基础到完整聊天室的收获,重点反思“为什么单进程能支撑成百上千的并发连接”。
- 预习准备:周末简单浏览 Hyperf 官方文档的“依赖注入”章节,为周一正式学习 DI 和 AOP 做准备。
这个周六的综合项目,让你真正感受到了 Swoole 协程在生产环境下的巨大潜力。下周开始,我们将用 Hyperf 框架的优雅封装来重新审视这些底层能力,写出更健壮、更可维护的微服务代码。
更多推荐
所有评论(0)