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


在这里插入图片描述

第一周 周六:综合实战与知识串联(可选)

今日目标
  1. 使用 Swoole 原生构建一个多人在线 TCP 聊天室,支持用户上线、广播消息和私聊。
  2. 利用 Channel 实现用户间消息广播,利用 WaitGroup 安全关闭服务器。
  3. 利用协程上下文 Context 传递用户昵称和房间信息。
  4. 额外添加一个 HTTP 监控接口,可查看在线人数和最近消息记录。
  5. 将所有代码组织清晰,并能在 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,使用 godefer 保证文件句柄关闭。

五、今日作业与产出

  1. 提交代码:将 chat_server.php 和相关注释提交到 Git。
  2. 学习笔记:画出聊天室的消息流向图,标出 Channel、Context、WaitGroup 各自扮演的角色。
  3. 总结思考:写一段话总结本周从协程基础到完整聊天室的收获,重点反思“为什么单进程能支撑成百上千的并发连接”。
  4. 预习准备:周末简单浏览 Hyperf 官方文档的“依赖注入”章节,为周一正式学习 DI 和 AOP 做准备。

这个周六的综合项目,让你真正感受到了 Swoole 协程在生产环境下的巨大潜力。下周开始,我们将用 Hyperf 框架的优雅封装来重新审视这些底层能力,写出更健壮、更可维护的微服务代码。

更多推荐