ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP实时聊天室源码实战:基于WebSocket与Workerman的通信实现

PHP实时聊天室源码实战:基于WebSocket与Workerman的通信实现 简介这是一份基于PHP与WebSocket实现的H5实时聊天室全开源源码包定位明确为具备一定PHP基础、需要快速为网站或应用接入网页即时通讯能力的开发者提供可直接运行和二次改造的完整工程。包内共19个文件涵盖9个PHP核心逻辑模块、4份SQL建表脚本、WebSocket服务端脚本、前端CSS/JS交互文件、部署文档及数据库文件压缩包仅1.5MB目录简洁、上手轻量。源码支持有数据库与无数据库两种运行模式既能处理用户与聊天记录存储也能在轻量环境快速演示开源特性便于自由扩展消息模型、在线用户管理、上传等模块并参考社区贡献排查WebSocket连接与部署问题。已有125人学习下载适合通过实际项目掌握WebSocket全双工通信、PHP聊天室架构设计最终搭建定制化的实时消息应用。1. 从“PHP也能做实时聊天室”说起这个源码到底解决什么问题很多人在第一次听说“PHP全开源聊天室源码”的时候第一反应是PHP不是同步请求的语言吗实时消息怎么做得动其实这个标题里的关键点并不是PHP本身而是它配套的常驻内存方案——Workerman或Swoole。真正跑起来的架构是“PHP常驻进程负责WebSocket长连接Nginx负责HTTP静态资源与代理握手”H5前端通过原生WebSocket或Socket.io客户端接入。这个方向最适合三类人第一类是接外包项目需要快速交付H5直播互动、客服咨询、在线教育讨论区的开发者第二类是手里有现成PHP业务系统、不想为了聊天功能再引一套Java/Go服务的团队第三类是学生或自由职业者想找一个全开源、能改能跑的聊天室源码当毕设或独立产品底座。这篇文章要讲的不是让你去下载某个神秘压缩包而是把这类源码背后最常见的实现路径拆开实时方案怎么选、服务端怎么常驻、H5端怎么连、消息怎么广播、出问题怎么排。看完之后你能自己复现一套可用的PHPH5实时聊天室也能判断某个号称全开源的源码包值不值得接手。2. 实时消息到底怎么做先搞清三种方案再谈源码2.1 轮询、长轮询、WebSocket为什么前两种会被聊天室淘汰常见做法是早期PHP聊天室用AJAX轮询前端每隔2到3秒发一个HTTP请求服务端查一次数据库把新消息带回。这套方案在用户量小的时候能跑但只要同时在线超过两百人数据库查询和HTTP握手就会把服务器拖垮。后来有人用长轮询优化客户端发请求后服务端不立刻返回而是把请求挂起等有新消息再响应响应后客户端立即发起下一个请求。长轮询确实把实时性从几秒提升到秒级但每个挂起的请求都要占一个PHP-FPM进程进程池很快就会被耗尽。这两条路的共同问题是HTTP协议是“一问一答”而聊天室本质上是一条持续的双向通道。WebSocket的出现就是为了解决这个不对称。WebSocket握手借HTTP的80/443端口握手完成后连接升级为TCP长连接服务端能主动把消息推到浏览器浏览器也能随时发消息双方只需维护一条连接。2.2 用Workerman搭一个最小WebSocket服务端能收发就成功了一半市面上标称“PHP全开源聊天室源码”的项目绝大多数底层用的是Workerman少部分用Swoole。Swoole是PHP扩展需要编译安装Workerman是纯PHP代码包用Composer就能拉下来门槛低很多适合先跑通逻辑。下面这段代码是我平时用来验证环境的最小服务端把它存成server.php命令行执行php server.php start就能跑起来。需要先安装依赖composer require workerman/workerman。?php require_once __DIR__ . /vendor/autoload.php; use Workerman\Worker; use Workerman\Connection\TcpConnection; // 创建一个 WebSocket 服务监听 2346 端口 $worker new Worker(websocket://0.0.0.0:2346); // 启动 4 个进程处理连接window 下只能填 1 $worker-count 4; // 当客户端连接建立时回调触发 $worker-onConnect function (TcpConnection $connection) { echo new connection from {$connection-getRemoteIp()}\n; }; // 收到客户端消息时原样回显给客户端 $worker-onMessage function (TcpConnection $connection, $data) { $connection-send(echo: {$data}); }; // 当客户端断开时打印日志 $worker-onClose function (TcpConnection $connection) { echo connection closed\n; }; // 让 Worker 常驻运行 Worker::runAll();这段代码的逻辑很直白onConnect在有新连接进来时触发onMessage在收到消息时把内容原样返回onClose负责断开清理。关键参数是$worker-count它决定进程数量单机调试填1正式环境按CPU核心数填2到4。0.0.0.0表示监听所有网卡如果你只想内网访问可以改成127.0.0.1。跑起来后用浏览器的开发者工具或者在线WebSocket测试工具连接ws://服务器IP:2346发一句“hello”能收到echo: hello就说明核心链路通了。这一步的意义在于把“PHP不能做实时”这个顾虑直接破掉后面所有聊天室业务逻辑都是在onMessage里扩展出来的。2.3 H5端如何连接WebSocket握手、心跳与断线重连服务端就绪后H5前端最朴素的做法就是用浏览器原生WebSocket对象不需要引任何第三方库。下面这段前端代码要解决的问题不只是“连接上”还包括“连接掉线后自动恢复”这是聊天室能不能长期挂机不翻车的关键。class ChatSocket { constructor(url, handlers) { this.url url; this.handlers handlers || {}; this.ws null; this.heartbeatTimer null; this.reconnectCount 0; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { console.log(connected); this.reconnectCount 0; this.startHeartbeat(); }; this.ws.onmessage (event) { const msg JSON.parse(event.data); if (this.handlers.onMessage) { this.handlers.onMessage(msg); } }; this.ws.onclose () { console.log(disconnected); this.stopHeartbeat(); this.reconnect(); }; this.ws.onerror (error) { console.error(ws error, error); }; } startHeartbeat() { // 每25秒发一次心跳包服务端收到后原样返回 this.heartbeatTimer setInterval(() { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({type: ping})); } }, 25000); } stopHeartbeat() { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } reconnect() { // 最多重连10次间隔按指数退避递增 if (this.reconnectCount 10) return; const delay Math.min(1000 * Math.pow(2, this.reconnectCount), 30000); this.reconnectCount; console.log(reconnect after ${delay}ms); setTimeout(() this.connect(), delay); } send(data) { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } } // 使用方式 const client new ChatSocket(ws://your-server.com:2346, { onMessage: (msg) { // 渲染一条新消息到页面 renderMessage(msg); } });这段代码的核心不是连接本身而是三个防御动作心跳包每25秒发一次防止移动端网络空闲时被运营商或Nginx切断空闲连接断线重连采用指数退避避免大量客户端同时重连造成服务端抖动reconnectCount上限10次杜绝无限重连。常见坑是很多新手只写了onmessage就去测聊天结果页面挂一会儿后消息就收不到了其实不是服务端停了而是连接被中间设备静默断开心跳就是用来提前发现这种假死连接的。3. 把完整源码跑起来环境准备、目录结构与数据库三张表3.1 PHP与扩展要求直接上PHP 8.3会遇到什么现在新起的项目直接装PHP 8.3问题不大。Workerman 4.x 和 5.x 都支持PHP 8Swoole 5.0 以上也对PHP 8做了适配。需要确认三个扩展pcntl用于进程控制posix用于进程信号event或pecl扩展用于提升事件轮询性能。在Linux上安装并检查的命令如下。# 安装PHP 8.3及常用扩展以Ubuntu 22.04为例 sudo apt update sudo apt install php8.3-cli php8.3-dev php8.3-mbstring php8.3-redis # 安装posix和pcntl扩展 sudo apt install php8.3-posix php8.3-pcntl # 安装event扩展可选但推荐能让事件驱动性能更好 sudo pecl install event # 查看已加载的扩展 php -m | grep -E posix|pcntl|event|pdo注意一个非常容易翻车的点Workerman是CLI模式运行的常驻进程它用的PHP和跑Nginx的PHP-FPM可以不是同一个版本。比如你服务器上PHP-FPM是7.4但命令行php -v显示8.3这完全没问题Workerman只需要命令行版本满足要求即可。不少人在部署时只看到phpinfo()里显示7.4以为环境不满足其实只要执行php -m看CLI扩展就行。另外pcntl扩展在Windows上不存在如果你是本地Windows开发环境$worker-count必须设为1否则会直接报Process count must be 1错误。3.2 目录结构与三张核心表聊天室源码的老三样典型全开源PHP聊天室源码的目录结构大致长这样chatroom/ ├── server/ │ ├── start.php # 启动入口加载Workerman │ ├── Chatroom.php # 聊天室核心业务类 │ └── config.php # 数据库与端口配置 ├── public/ │ ├── index.html # H5入口页面 │ ├── js/ │ │ └── chat.js # 前端连接与渲染逻辑 │ └── css/ │ └── style.css ├── vendor/ # Composer依赖 └── sql/ └── install.sql # 建表脚本数据库设计上大部分项目不会少于三张表用户表、房间表、消息表。有些还加了禁言表和系统公告表但最小可用集就是下面这三张。表结构用MySQL 8的写法如下。CREATE DATABASE IF NOT EXISTS chatroom DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE chatroom; CREATE TABLE user ( id int unsigned NOT NULL AUTO_INCREMENT, nickname varchar(32) NOT NULL COMMENT 昵称, avatar varchar(255) DEFAULT COMMENT 头像URL, token varchar(64) NOT NULL COMMENT 登录凭证, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_token (token) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE room ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL COMMENT 房间名, max_members int NOT NULL DEFAULT 500 COMMENT 最大在线数, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房间表; CREATE TABLE message ( id bigint unsigned NOT NULL AUTO_INCREMENT, room_id int unsigned NOT NULL COMMENT 所属房间, user_id int unsigned NOT NULL COMMENT 发送人, content text NOT NULL COMMENT 消息内容, type tinyint NOT NULL DEFAULT 1 COMMENT 1普通 2系统 3图片, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_room_time (room_id, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT消息表;这里有两个设计上的讲究。第一所有表都用utf8mb4而不是utf8否则用户输入emoji表情时MySQL会直接报Incorrect string value错误这是中文站做聊天室最常见的翻车点。第二消息表的主键用bigint且message的内容类型用text不要用id自增之外的方式做分页因为聊天消息只增不改按主键分页比LIMIT OFFSET高效得多。你拿到任何源码第一件事就是看这三张表的字符集和索引能避开至少一半部署问题。3.3 用命令行启动服务与Nginx反向代理配置源码里通常会给你一个start.php它的作用是实例化业务类并把路由挂到Workerman上。部署时先改配置文件里的数据库连接和监听端口然后进入server/目录执行命令后台启动。# 进入源码server目录 cd /data/www/chatroom/server # 前台启动调试用能看到实时日志 php start.php start # 后台启动正式环境用 php start.php start -d # 重启改了代码后常用 php start.php restart # 查看运行状态 php start.php status # 安全停止 php start.php stopstart后面加-d会让进程转入后台不加则保持前台输出第一次调试建议不加这样日志会直接打在终端里。status会打印每个进程的连接数和负载排查内存泄漏时很有用。接着在Nginx里加一个反向代理把WebSocket握手的路径转发到Workerman端口同时让静态页面直接由Nginx服务。server { listen 80; server_name chat.example.com; root /data/www/chatroom/public; index index.html; # H5普通HTTP请求直接走静态文件 location / { try_files $uri $uri/ /index.html; } # WebSocket握手与消息通信走这个路径 location /ws { proxy_pass http://127.0.0.1:2346; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 60s; } }注意proxy_read_timeout一定要设置默认60秒可能在心跳间隔25秒的场景下没问题但如果你把心跳改成40秒Nginx就会先断掉连接。更稳妥的做法是把心跳间隔设成小于Nginx超时时间的一半。前端连接地址写成ws://chat.example.com/ws不要直接暴露2346端口一是避免被扫描二是能省一层Nginx上的HTTPS卸载后面要是加SSL证书ws://换wss://也方便。4. 消息路由与广播一条消息从发送到全房间可见的完整链路4.1 消息格式约定用JSON统一前端与后端的边界聊天室最容易在消息格式上扯皮。前端发上来的是字符串后端给前端推的也是字符串但内容结构必须在一开始就约定好。我见过不少源码把消息直接写成张三:你好这种设计在文字聊天时够用一旦要支持图片、表情、系统通知、禁言提醒解析就会变成灾难。规范的做法是统一用JSON封装。前端发送的消息长这样{ type: message, room_id: 1, token: a1b2c3d4e5f6, content: 大家好我是新人 }服务端广播给所有人的消息长这样{ type: message, id: 1024, room_id: 1, user: { id: 8, nickname: 张三, avatar: https://cdn.example.com/avatar/8.jpg }, content: 大家好我是新人, timestamp: 1712204800 }前端发送时带上token而不是user_id是为了让服务端通过token查用户表拿到当前用户身份后再填充user字段。这么做的好处是防止用户伪造身份你永远不信任客户端传来的user_id。type字段预留出扩展空间1是普通消息2是系统通知3是图片消息将来加了礼物或点赞加一个类型值就行不用改字段结构。4.2 后台广播逻辑单房间推送、全局推送与在线列表服务端收到一条消息后的处理顺序是这样的先将消息落库然后推送给当前房间的所有在线连接最后更新房间的在线人数。落库和推送的顺序很重要先落库的好处是即使推送时某个连接失败消息也不会丢。下面是一个核心业务类的骨架实现。?php namespace Chat; use Workerman\Connection\TcpConnection; use Workerman\Timer; class Chatroom { // 维护连接与用户绑定关系connectionId [room_id, user_id] private static array $connections []; // 维护房间在线列表room_id [连接ID集合, 用户信息集合] private static array $rooms []; public function onMessage(TcpConnection $connection, string $data) { $msg json_decode($data, true); if (json_last_error() ! JSON_ERROR_NONE) { // 心跳等非JSON消息直接忽略或特殊处理 if ($data ping) { $connection-send(pong); return; } return; } $type $msg[type] ?? message; switch ($type) { case login: $this-handleLogin($connection, $msg); break; case message: $this-handleMessage($connection, $msg); break; case join_room: $this-handleJoinRoom($connection, $msg); break; default: $connection-send(json_encode([ type error, content unknown message type ])); } } private function handleLogin(TcpConnection $connection, array $msg) { $token $msg[token] ?? ; // 真实项目中这里应该查数据库验证token // 这里用HTTP请求PHP-FPM里的接口换取用户信息 $userInfo $this-getUserByToken($token); if (!$userInfo) { $connection-send(json_encode([type error, content invalid token])); $connection-close(); return; } self::$connections[$connection-id] [ user_id $userInfo[id], nickname $userInfo[nickname], conn $connection ]; $connection-send(json_encode([ type login_success, user $userInfo ])); } private function handleMessage(TcpConnection $connection, array $msg) { $connInfo self::$connections[$connection-id] ?? null; if (!$connInfo) { $connection-send(json_encode([type error, content please login first])); return; } $roomId $msg[room_id] ?? 1; $content trim($msg[content] ?? ); // 空消息直接丢弃 if ($content ) return; // 落库 $messageId $this-saveMessage($roomId, $connInfo[user_id], $content); // 广播给房间内所有连接 $payload json_encode([ type message, id $messageId, room_id $roomId, user [ id $connInfo[user_id], nickname $connInfo[nickname] ], content $content, timestamp time() ]); // 遍历房间在线列表逐连接推送 foreach (self::$rooms[$roomId] ?? [] as $cid $info) { $targetConn $info[conn] ?? null; if ($targetConn) { $targetConn-send($payload); } } } private function getUserByToken(string $token) { // 通过Redis或HTTP接口获取用户这里简化返回 return [id 1, nickname TestUser]; } private function saveMessage(int $roomId, int $userId, string $content): int { // 使用PDO插入消息表返回自增ID // 这里省略具体SQL执行代码返回模拟ID return time() % 10000 1; } }这段代码把聊天室的核心状态管理说清楚了self::$connections用连接ID做键记录每个连接对应用户self::$rooms用房间ID做键记录房间内所有连接广播的本质就是遍历所属房间的连接集合逐个调用send。这里有个性能要点连接多的时候用一个foreach遍历推送是O(n)操作如果房间内有1000人一条消息就要循环1000次。常见优化方案是把同一房间的连接批量放到一个进程内并且用Workerman的GatewayWorker模式做分布式让不同连接分散到多个进程由Gateway统一转发。4.3 历史消息的加载与分页不能只靠WebSocketWebSocket只管实时增量新用户进入房间后不可能把之前所有消息全推给他。历史消息必须走HTTP接口用RESTful方式从MySQL查询。常见做法是前端进入房间时先调用GET /api/room/1/messages?last_id0limit50拿到最近50条然后用last_id作为游标往前翻页。SQL对应的写法如下。-- 查询room_id1的最新50条消息 SELECT id, user_id, content, type, created_at FROM message WHERE room_id 1 ORDER BY id DESC LIMIT 50;注意这里用ORDER BY id DESC拿到的是最新50条但展示时要倒序排列才是正常的聊天顺序所以代码里要再array_reverse一次。如果要翻更早的消息前端把当前页最早一条消息的id作为max_id传回来SQL改成SELECT id, user_id, content, type, created_at FROM message WHERE room_id 1 AND id 5000 ORDER BY id DESC LIMIT 50;这种基于主键游标的分页方式比LIMIT 200, 50稳定得多因为消息一旦插入主键单调递增不存在某条记录被物理删除导致偏移量错位的问题。如果你的聊天室有撤回功能消息表里要加一个is_deleted字段撤回时不是删行而是把内容改成“此消息已撤回”这样游标分页仍然连续。5. 部署与运行常见问题排查从握手失败到中文乱码5.1 现象WebSocket握手失败浏览器报404或WebSocket connection failed这个问题在部署到Nginx后面时出现频率最高。原因是Nginx配置里没有识别WebSocket的Upgrade请求头导致请求被当成普通HTTP请求发给PHP-FPM而PHP-FPM根本不会处理升级协议直接返回404或500。解决方法是确认Nginx的location /ws块里写上了proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;并且proxy_pass指向的地址要与Workerman监听地址完全一致包括端口。还有一个容易被忽略的坑Workerman容器内监听的是127.0.0.1:2346Nginx在宿主机上proxy_pass http://127.0.0.1:2346反而连不上这时候要改成Docker容器的IP或使用host网络模式。排查时可以在服务器上手动执行php server.php start看是否有握手日志然后用curl -i http://127.0.0.1:2346测试端口是否可用。5.2 现象PHP 8.3下启动报错提示Call to undefined function pcntl_fork()原因基本是CLI环境没装pcntl扩展。Workerman靠pcntl_fork创建多进程没有这个扩展无法启动。需要检查的是命令行PHP版本用的扩展目录而不是Web版本。执行php -i | grep extension_dir看路径再用php -m确认模块是否加载。解决方法是安装php8.3-pcntl和php8.3-posixUbuntu下直接apt installCentOS下用yum install php-pcntl。如果你是用宝塔面板之类的一键环境记得在面板的PHP设置里勾选pcntl和posix扩展并重启。另一个相关报错是event扩展缺失出现的是性能警告不会阻止启动但高并发下事件轮询退化成select模式连接数超过1024就明显卡顿。5.3 现象消息时有时无或者某些用户收不到广播最经典的场景是前端A发消息服务端日志显示已经send出去了但用户B没收到。原因通常是用户B连接已经断开但服务端的onClose没有及时触发。在Workerman里TCP连接的断开检测依赖心跳和TCP keepalive默认行为是操作系统在连接异常断开时不会立刻通知应用层。解决方法是服务端设置心跳检测启动一个定时器每隔一段时间检查连接的最后一次通信时间超时后主动调用close清理连接。Workerman官方提供了TcpConnection::$maxSendBufferSize和心跳配置但更简单的是在onMessage里记录$connection-lastMessageTime然后注册一个Timer比如每10秒检查一次超过60秒没有收到消息就断开。use Workerman\Timer; // 在Worker::runAll()之前注册定时器 Timer::add(10, function () { foreach (self::$rooms as $roomId $connections) { foreach ($connections as $cid $info) { $conn $info[conn] ?? null; if ($conn time() - $conn-lastMessageTime 60) { $conn-close(); } } } });这个定时器配合前端的25秒心跳能保证正常情况下连接不会被误杀异常断开时最多60秒恢复资源。如果消息还是丢检查另一个点发送消息时服务端有没有判断$connection-getStatus()Workerman里连接状态是TcpConnection::STATUS_ESTABLISHED才能正常send正在关闭中的连接发消息会静默失败。5.4 现象中文昵称和消息显示成乱码或者写入数据库报错乱码问题集中在两个位置一个在MySQL连接一个在HTTP接口返回。MySQL方面如果你建库时用了utf8而不是utf8mb4表情符号直接无法存储中文倒是能存但排序和兼容性不如utf8mb4。另一个高频坑是PHP连接MySQL时没设置字符集PDO的连接字符串里必须加上charsetutf8mb4。HTTP接口方面如果你的历史消息接口是PHP-FPM提供的响应头必须带Content-Type: application/json; charsetutf-8否则前端JSON.parse对中文没问题但页面显示时如果HTML meta标签缺失也会乱。还有一个涉及源码修改的点PHP的json_encode默认把中文转义成\uXXXX这在显示上没影响但如果你想在日志里直接看到中文要给json_encode传JSON_UNESCAPED_UNICODE参数。比如上面广播代码里改成json_encode($payload, JSON_UNESCAPED_UNICODE)日志和调试会友好很多。6. 进阶验证与优化压测、消息合并与离线补发先聊压测。基于Workerman的聊天室单机8核16G内存保持1万条WebSocket连接问题不大但很多人测的时候用ab或wrk打HTTP接口发现数据很差原因是这些工具不擅长模拟长连接场景。我一般用websocket-bench或者写一段Swoole协程客户端脚本模拟1000个用户同时连接、发送、接收看服务端的CPU和内存曲线。压测时重点关注两个指标一是连接建立速率二是在线状态下的内存增长。如果内存持续上涨不回落多半是某个连接关闭时没有清理self::$connections里的旧记录典型的对象引用泄漏。血泪经验是服务端日志里连接数对不上先查onClose里有没有做unset(self::$connections[$connection-id])。再聊消息合并。当房间内消息量非常大比如每秒上百条逐条广播会让前端DOM渲染跟不上。常见做法是服务端每100毫秒做一次批量推送把这一百毫秒内的多条消息打包进一个JSON数组前端收到后一次性渲染。这个优化能让前端的渲染压力从“每秒多次”降到“每秒10次”体验反而更顺滑。代价是消息延迟多100毫秒对聊天场景完全无感。离线补发要做到位需要服务端记录每个用户在每个房间的“最后已读消息ID”。当用户重新连接并进入房间时服务端拉取大于该ID的消息推送给他。这块逻辑建议放在HTTP登录接口里登录成功后返回last_read_idWebSocket只负责实时流避免在长连接里做复杂查询。最后是我个人的一条习惯任何聊天室项目上线前我都会在服务端加上一条日志规则记录每条消息的字节数、房间号、发送耗时以及广播覆盖的连接数。这样出了问题能迅速判断是某个连接慢还是整个房间广播链路被堵住。聊天室的坑不在代码复杂度而在状态管理——连接什么时候注册、什么时候清理、什么时候超时把这些边界想清楚整个系统就稳了。希望帮到你。本文还有配套的精品资源点击获取
返回列表