为了巩固前五天所学的协程、Channel、上下文与 Hyperf 基础,我为你设计了一个可选的周六综合实战日。今天不引入全新理论,而是通过一个完整的项目——“协程 TCP 聊天室 + HTTP 监控面板”,把本周所有知识点串联起来。这种“做中学”的方式,能让你对协程的威力和框架的便利性产生肌肉记忆。
第一周 周六:综合实战与知识串联(可选)
今日目标
- 使用 Swoole 原生构建一个多人在线 TCP 聊天室,支持用户上线、广播消息和私聊。
- 利用
Channel实现用户间消息广播,利用WaitGroup安全关闭服务器。 - 利用协程上下文
Context传递用户昵称和房间信息。 - 额外添加一个 HTTP 监控接口,可查看在线人数和最近消息记录。
- 将所有代码组织清晰,并能在 Docker 环境中运行、测试。
注:虽然我们才刚在周四接触 Hyperf,今天为了聚焦协程本身,仍先用纯 Swoole 实现核心聊天逻辑。你会惊讶地发现,用下周即将学习的依赖注入和注解,可以把这个项目进一步改造成一个更优雅的微服务。
一、环境准备(约 15 分钟)
在之前使用的swoole-course目录下新建一个周六项目文件夹:
cdswoole-coursemkdirday6-chat&&cdday6-chat进入容器:
docker-composeexecswoolebashcd/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.phpuseSwoole\Server;useSwoole\Coroutine\Channel;useSwoole\Coroutine\Context;useSwoole\Coroutine\WaitGroup;useSwoole\Http\Response;useSwoole\Http\Request;$server=newServer('0.0.0.0',9501,SWOOLE_PROCESS);$server->set(['worker_num'=>1,// 单 Worker 方便演示'enable_coroutine'=>true,]);// 全局广播通道(容量 256)$broadcastChan=newChannel(256);// 在线用户表:[ fd => ['nick' => 'xxx', 'ctx' => Context] ]$onlineUsers=[];// 历史消息队列(保留最近 20 条)$historyMessages=[];// WaitGroup 用于关闭时的优雅退出$shutdownWg=newWaitGroup();$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($onlineUsersas$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 服务器监听 9502go(function()use(&$onlineUsers,&$historyMessages){$http=newSwoole\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($onlineUsersas$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 框架的优雅封装来重新审视这些底层能力,写出更健壮、更可维护的微服务代码。