news 2026/10/27 8:48:36

单文件PHP无数据库聊天室:长轮询与文件存储实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
单文件PHP无数据库聊天室:长轮询与文件存储实战

简介:这份PHP在线聊天室源码面向希望快速搭建轻量聊天功能、或学习Web实时通信与会话状态维护的开发者,采用单文件和无数据库设计,适合临时测试、小型站点以及PHP入门实践。压缩包仅包含1个PHP文件,体积约8KB,全部逻辑集中在入口脚本内,部署时无需创建数据表和配置数据库连接,因此环境依赖少、上手门槛低。源码通过服务端内存或文件暂存聊天内容,页面可展示在线用户列表,并允许调整字体样式,兼容PHP5与PHP7环境,便于读者理解请求处理、消息轮询、在线状态更新、会话维护和版本适配等基础思路。已有1879人学习,可对照代码梳理单文件应用的模块组织、消息刷新流程、兼容性处理和简单排错思路,也能作为轻量聊天室原型或课堂演示的参考实现。

1. 单文件 PHP 聊天室能干什么:不装数据库、丢上去就能说话的即时通讯小工具

前阵子帮一个做运维的朋友救急。他们值班室想要个能挂在内部网络的小聊天室,需求朴素得不像需求:几个人发消息、能看到历史记录、刷新别丢,最好别开数据库、别装框架、别配 composer。翻了半天,最后落地的是一份 PHP 在线聊天室源码(单文件无数据库版)——整个后端就是一个.php,丢进任何支持 PHP 的目录、给写权限、打开网页就能说话。

它天生适合三类场景:内网值班室、教学演示、给客户临时搭一个反馈通道。不适合拿去做公开高并发社区,因为它没有连接池、没有索引、没有用户体系,存储就是文本文件。但正因为它只有单文件、无数据库,你可以在十分钟内读懂全部逻辑,再花半小时改成自己想要的样子——这个"改得动"的价值,比功能本身重要得多。

2. 架构与目录:一个 .php 文件怎么同时扮演后端接口、前端页面和存储层

单文件不等于没架构,它只是把三层压进了一个入口。理解这三层怎么切分,后面改代码才不会乱。

2.1 三段式结构:入口分发、HTML 模板、消息存储

这类单文件聊天室常见的组织方式是"三段式":文件顶部是 PHP 处理区,负责判断action参数、读写消息、输出 JSON;中间是 HTML 骨架;底部是内联 JavaScript,负责轮询和渲染。整个文件从上到下执行,遇到exit就结束,不进入模板部分。

之所以这样切,是因为 PHP 是天生的"请求即生命周期"模型:每次请求都是全新的进程上下文,没有常驻内存可以复用。所以单文件项目不能像 Node 那样维护一个全局的clients数组,只能把所有状态外置到文件里。想通这一点,后面所有设计都会顺理成章。

下面是我一般会先摆出来的骨架,改别人的单文件项目时也照这个顺序读:

<?php // ========== 第一段:后端处理区 ========== define('BASE_DIR', __DIR__); define('DATA_DIR', BASE_DIR . '/data'); define('MSG_FILE', DATA_DIR . '/messages.jsonl'); define('ONLINE_FILE', DATA_DIR . '/online.json'); define('MAX_KEEP', 300); // 最多保留多少条消息 define('POLL_WAIT', 20); // 长轮询最长挂起秒数 $action = $_GET['action'] ?? 'page'; // 所有接口统一走 JSON 返回,页面渲染走最后的分支 if ($action !== 'page') { header('Content-Type: application/json; charset=utf-8'); switch ($action) { case 'send': handle_send(); break; case 'poll': handle_poll(); break; case 'online':handle_online();break; default: echo json_encode(['ok' => false, 'err' => 'bad_action']); } exit; // 关键:接口处理完必须退出,别往下走模板 } // ========== 第二段:HTML 模板(下面就是普通 HTML)========== ?> <!doctype html> <html lang="zh-CN"> <head><meta charset="utf-8"><title>值班室</title></head> <body> <div id="list"></div> <form id="f"><input id="u" placeholder="昵称"><input id="t" placeholder="说点什么"><button>发送</button></form> <script> // ========== 第三段:前端逻辑 ========== </script> </body> </html>

这段骨架的价值在于那个exit。单文件项目最常见的翻车方式,就是接口分支忘了exit,JSON 后面又跟了一整段 HTML,前端JSON.parse直接报错,而浏览器 Network 面板里状态码还是 200,看半天看不出问题。

参数说明几个点:MAX_KEEP是消息保留上限,决定文件增长的天花板;POLL_WAIT是长轮询挂起时长,必须小于反向代理和 PHP 的超时时间,否则会被网关提前切断,前端表现为"每隔几十秒卡一下"。DATA_DIR建议放在 Web 根目录之外,如果只能放里面,后面第 4 章会讲怎么用 Nginx 挡住直接访问。

2.2 消息没有数据库,落在哪儿:文件存储的三种常见做法

"无数据库"不等于"无存储",它只是把存储换成了文件。这类项目里能见到的做法基本三种:

存储方式典型实现优点明显短板
JSON Lines(每行一条)messages.jsonl,一行一个json_encode追加方便、可读、能按行跳过坏数据读全量要遍历,文件大了变慢
单个 JSON 数组整个数组json_encode后覆盖写结构直观、好裁剪每次读写都要全量序列化,并发下容易写坏
序列化 / 自定义分隔符serialize()或昵称|时间|内容实现最短字段扩展麻烦,转义容易出错

我更倾向 JSON Lines。原因很实际:它是"追加 + 定期重写"的模型,写入时不需要把整个历史消息反序列化再序列化一遍;某一行写坏了,json_decode返回null,循环里continue跳过就行,不会让整个聊天室打不开。单 JSON 数组的写法一旦在写入过程中被中断(进程被杀、磁盘满),整个文件就是残缺的 JSON,所有历史消息一起废掉——这个后悔药可不好买。

消息的数据结构不用复杂,四个字段够用:

{"id":12,"u":"值班A","t":"机房空调报警了","ts":1718000000}

id是自增整数,前端靠它做增量拉取(只取比本地id大的),这是整个项目最关键的设计;ts是服务端时间戳,别用客户端时间,否则不同人机器的时钟一偏,消息顺序就乱。

2.3 请求分发:靠 action 参数区分四种动作

单文件项目的路由就是switch,没有中间件、没有控制器。四个动作对应四条链路:

  • action=page(默认):输出 HTML 模板,浏览器首次打开走这条
  • action=send:POST 提交昵称和内容,写入文件,返回新消息的id
  • action=poll&since=N:拉取id > N的消息,长轮询挂起
  • action=online:返回当前在线昵称列表

在线状态同样没有数据库,常见做法是写一个online.json,结构是昵称 => 最后活动时间戳。每次请求都更新自己那条记录,读取时把超过 60 秒没活动的过滤掉。这是典型的"近似在线"——没有心跳连接,只能靠最近活跃时间推断。人数不多(几十人以内)完全够用,别指望它能撑起几千人同时在线的状态同步。

3. 核心代码拆解:一次发消息到别人看见的完整链路

这一章按消息流向走一遍:写入 → 拉取 → 渲染 → 在线状态。四段代码拼起来就是一个能跑的聊天室。

3.1 发消息:独占锁 + 自增 ID + 尾部裁剪

写入是整个项目最容易出问题的地方。多个用户同时点发送,两个 PHP 进程同时读写同一个文件,轻则消息丢失,重则文件被写成半截。所以必须加锁,而且要加在"读—改—写"整个区间上。

function handle_send(): void { $user = trim((string)($_POST['u'] ?? '')); $text = trim((string)($_POST['t'] ?? '')); // 基础校验:空内容、超长内容一律拒掉 if ($user === '' || $text === '') { echo json_encode(['ok' => false, 'err' => 'empty'], JSON_UNESCAPED_UNICODE); return; } $user = mb_substr($user, 0, 20); $text = mb_substr($text, 0, 500); if (!is_dir(DATA_DIR)) { mkdir(DATA_DIR, 0755, true); // 首次运行自动建目录 } // 'c+' = 读写模式,不截断,指针在文件头 $fp = fopen(MSG_FILE, 'c+'); if (!$fp) { echo json_encode(['ok' => false, 'err' => 'open_failed'], JSON_UNESCAPED_UNICODE); return; } // 独占锁:从这一刻起,其他进程的 flock 会阻塞等待 flock($fp, LOCK_EX); $raw = stream_get_contents($fp); $lines = $raw === '' ? [] : explode("\n", trim($raw)); // 从最后一行拿最大 id,自增 +1 $lastId = 0; if ($lines) { $last = json_decode((string)end($lines), true); if (is_array($last) && isset($last['id'])) { $lastId = (int)$last['id']; } } $row = [ 'id' => $lastId + 1, 'u' => $user, 't' => $text, 'ts' => time(), ]; $lines[] = json_encode($row, JSON_UNESCAPED_UNICODE); // 裁剪:超出上限就只留尾部,防止文件无限膨胀 if (count($lines) > MAX_KEEP) { $lines = array_slice($lines, -MAX_KEEP); } ftruncate($fp, 0); // 清空 rewind($fp); // 指针归位到 0,顺序不能反 fwrite($fp, implode("\n", $lines) . "\n"); fflush($fp); // 刷盘,别依赖进程退出时自动 flush flock($fp, LOCK_UN); fclose($fp); echo json_encode(['ok' => true, 'data' => $row], JSON_UNESCAPED_UNICODE); }

几个参数值得说明。mb_substr而不是substr——中文昵称用substr截断会切断多字节字符,存进去就是乱码,这个坑很经典,血泪经验。MAX_KEEP决定了每次写入的开销:文件最多几百行,全量重写一次也就几十 KB,完全撑得住;如果把上限设成十万条,每次发消息都要重写几 MB,那就不是聊天室了,是磁盘压力测试。LOCK_EX是排他锁,读接口如果也想严格一致,应该用LOCK_SH,但考虑到读频率远高于写、且容忍轻微延迟,多数实现选择读时不加锁,靠"写完才可见"这个特性兜底。

ftruncate和rewind的先后顺序不能颠倒。ftruncate($fp, 0)只是把文件长度设为 0,文件指针位置不变(还在末尾),必须rewind才能从头写,否则写进去的是一堆\0空洞。这种问题排查起来极其折磨,因为文件大小看着是对的,cat出来却是乱码。

3.2 拉消息:长轮询怎么挂住 20 秒还不吃满 CPU

短轮询(setInterval每 2 秒请求一次)实现最简单,但消息延迟最多 2 秒,且空请求占满日志。稍有追求的实现都会用长轮询:请求进来后不立刻返回,服务端循环检查有没有新消息,有就返回,超过 20 秒还没新消息就返回空数组让前端重连。

function handle_poll(): void { $since = (int)($_GET['since'] ?? 0); $wait = min((int)($_GET['wait'] ?? POLL_WAIT), 25); // 硬上限 25 秒 $deadline = time() + $wait; // 先立刻查一次,有新消息就别挂起了 while (true) { $rows = read_messages($since); if ($rows) { echo json_encode(['ok' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE); return; } if (time() >= $deadline) { echo json_encode(['ok' => true, 'data' => []], JSON_UNESCAPED_UNICODE); return; // 空数组 = 超时,前端应立刻发起下一次 } usleep(500000); // 500ms 查一次,绝不能空转 } } function read_messages(int $since = 0): array { if (!is_file(MSG_FILE)) { return []; } // FILE_SKIP_EMPTY_LINES 自动跳过尾部换行产生的空行 $lines = file(MSG_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if (!$lines) { return []; } $out = []; foreach ($lines as $line) { $row = json_decode($line, true); if (!is_array($row) || !isset($row['id'])) { continue; // 坏行直接跳过,不影响其他消息 } if ((int)$row['id'] > $since) { $out[] = $row; } } return $out; }

usleep(500000)是这段代码的命门。去掉它,while(true)会以每秒几万次的频率读文件,CPU 直接打满,还连带着把磁盘 IO 顶上去——这就是玄学式"聊天室一开,服务器就飘红"。500ms 的检查间隔意味着一半情况下新消息会在半秒内被发现,对内部沟通完全够用。想再快就降到 200ms,但别低于 100ms,收益已经趋近于零。

wait参数加了min(..., 25)的硬上限,是为了防止前端传个wait=600把 PHP-FPM 的进程全占死。这类单文件项目最容易忽视的就是资源上限——本来只是十来个人的工具,一个恶意参数就能让它变成拒绝服务入口。

3.3 前端渲染:只追加不重绘,转义交给 textContent

前端这块我不建议用字符串拼 HTML。拼接意味着你要自己处理转义,而转义一旦漏掉一处,昵称里塞个<script>就是一个存储型 XSS。

let lastId = 0; let timer = null; // 只追加,永远不重建整个列表 function appendBubble(m) { const box = document.getElementById('list'); const div = document.createElement('div'); div.className = 'msg'; const name = document.createElement('b'); name.textContent = m.u; // textContent 天然转义,不解析标签 const body = document.createElement('span'); body.textContent = m.t; div.append(name, document.createTextNode(':'), body); box.appendChild(div); box.scrollTop = box.scrollHeight; // 自动滚到底部 } async function poll() { try { const r = await fetch(`chat.php?action=poll&since=${lastId}&wait=20`, { cache: 'no-store' }); const j = await r.json(); if (j && Array.isArray(j.data) && j.data.length) { for (const m of j.data) { appendBubble(m); lastId = m.id; // 游标前移,下次只取更新的 } } } catch (e) { // 断线了,退避 2 秒再重连,别把错误日志刷爆 await new Promise(res => setTimeout(res, 2000)); } timer = setTimeout(poll, 0); // 立刻进入下一轮长轮询 } document.getElementById('f').addEventListener('submit', async (ev) => { ev.preventDefault(); const u = document.getElementById('u').value.trim(); const t = document.getElementById('t').value.trim(); if (!u || !t) return; const fd = new FormData(); fd.append('u', u); fd.append('t', t); await fetch('chat.php?action=send', { method: 'POST', body: fd }); document.getElementById('t').value = ''; // 只清内容,昵称留着 // 注意:这里不主动 poll,等服务端长轮询自己带回这条消息 });

lastId是整个前端的游标,所有增量逻辑都围绕它转。这里有个容易写错的地方:发送成功后不要立刻手动调poll(),否则会和服务端正在挂起的那个长轮询请求抢,同一批消息可能被渲染两次。等挂起的那个请求自己带着新消息返回,id单调递增的特性会天然去重——因为你只接受id > lastId的消息。

textContent和cache: 'no-store'是两个小但重要的细节。前者让 XSS 从根上不成立,后者避免某些代理把长轮询的响应缓存住,出现"消息发了但别人看到的是几分钟前的内容"这种鬼故事。

3.4 在线人数:用最后活动时间做近似,别追求精确

function touch_online(string $user): void { $fp = fopen(ONLINE_FILE, 'c+'); if (!$fp) return; flock($fp, LOCK_EX); $raw = stream_get_contents($fp); $map = $raw === '' ? [] : (json_decode($raw, true) ?: []); $map[$user] = time(); // 每次请求刷新自己的时间戳 $map = array_filter($map, fn($ts) => time() - $ts < 60); // 60 秒没动静就算离线 ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($map, JSON_UNESCAPED_UNICODE)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); }

这段逻辑在每个接口入口调一次即可。60 秒的阈值对应"用户打开页面但一直不发言"的情况——如果只靠发消息来刷新活跃时间,一个安静的旁观者会被误判成离线。所以更稳的做法是长轮询每返回一次就顺手刷新一下时间戳,20 秒一轮,正好卡在 60 秒阈值的 1/3,容忍两次超时。

4. 部署与跑通:从 php -S 本地验证到线上目录配置

代码看懂只是第一步,能不能在你自己机器上跑起来,往往取决于几个配置细节。

4.1 本地三分钟验证:内置服务器 + 一个必须注意的单进程坑

最省事的验证方式是 PHP 自带的开发服务器,不用装 Nginx、不用配虚拟主机:

# 进到项目目录,确认 PHP 版本(7.4 以上基本都行,8.x 也可以) php -v # 起一个开发服务器,8080 端口 php -S 0.0.0.0:8080 # 浏览器打开 http://127.0.0.1:8080/chat.php

然后立刻会发现一个问题:页面一直转圈,第二个标签页打不开。原因是 PHP 内置服务器默认是单进程的,一个长轮询请求把唯一的进程占住了,其他请求全部排队。这不是代码的错,是运行方式的问题。解决办法是给内置服务器开多进程,环境变量在启动时指定:

# 非 Windows 环境:开 4 个 worker 进程 PHP_CLI_SERVER_WORKERS=4 php -S 0.0.0.0:8080

4 个进程意味着最多能同时挂住 3 个长轮询(留一个给页面和发送请求)。自己测试够用,真要上线还是得走 PHP-FPM。这个坑我见过太多次,很多人第一次跑长轮询项目都是在本地被内置服务器劝退的。

验证顺序建议这样走:先开两个浏览器窗口(一个正常模式一个隐身模式,避免 session 互相影响),各自发一条消息,确认另一边能在一秒内看到;然后关掉一个窗口,等 60 秒,刷新另一个窗口看在线列表是否减少;最后把data/messages.jsonl删掉,刷新页面,确认会自动重建而不是报错——这一步验证的是is_dir和is_file那些兜底判断有没有写全。

4.2 线上落地:写权限、数据文件保护、超时对齐

线上环境的核心是三件事:PHP 进程能写data/目录、外部访问不到数据文件、网关超时大于长轮询时长。

# 目录属主给 PHP-FPM 的运行用户(Debian/Ubuntu 系一般是 www-data) sudo mkdir -p /var/www/chat/data sudo chown -R www-data:www-data /var/www/chat/data sudo chmod 755 /var/www/chat/data # 数据文件本身只给读写,别给执行 sudo chmod 644 /var/www/chat/data/messages.jsonl 2>/dev/null || true

注意chmod 755给的是目录,目录需要x权限才能进入;文件给644就够。给成777虽然能跑,但在共享主机上等于把消息记录公开给同服务器上的其他用户,得不偿失。

Web 服务器这边,最关键的是挡住.jsonl和.json的直接下载。消息文件放在 Web 根目录内时,任何人猜到路径就能把整个聊天记录拖走:

server { listen 80; server_name chat.internal.example; root /var/www/chat; index chat.php; # 数据目录一律拒绝外部访问 location ^~ /data/ { deny all; return 403; } location = /chat.php { include fastcgi_params; fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root/chat.php; # 必须大于长轮询的 wait(20s),留 10 秒余量 fastcgi_read_timeout 30s; # 关掉缓冲,否则响应会被攒着一起发 fastcgi_buffering off; } }

fastcgi_read_timeout 30s和代码里的POLL_WAIT = 20是一对必须对齐的参数。如果网关超时是 10 秒而代码挂 20 秒,用户会看到每 10 秒一次的 504,然后前端重连,表现为"消息延迟忽大忽小、控制台一片红"。fastcgi_buffering off同样重要,缓冲开着的时候,Nginx 会等 FastCGI 输出攒够一定大小才转发,长轮询的"一有新消息立刻返回"就退化成了"攒够了才返回"。

如果用 Apache,思路一样,Timeout要大于长轮询时长,data/目录用.htaccess里的Require all denied挡掉。另外记得在 PHP 侧把max_execution_time调到比wait大,否则长轮询会被 PHP 自己的执行时限砍断:

// 放在文件顶部,长轮询需要比默认 30 秒更长一点 set_time_limit(40); ignore_user_abort(false); // 用户关了页面就让脚本退出,别空跑

ignore_user_abort(false)是个容易被忽略的开关。默认值本来就是false,但有些环境或框架会把它改成true,那样即使用户关了浏览器,长轮询循环还会跑满 20 秒才退出,白白占着进程。

4.3 参数调优:三个数字决定体感

跑起来之后,能调的基本就三个数:

参数位置建议值调大的后果调小的后果
POLL_WAITPHP 常量20 秒网关超时风险、进程占用久空请求变多、延迟感明显
usleep间隔handle_poll内500ms消息延迟增加CPU 占用上升
MAX_KEEPPHP 常量200~500 条每次写入重写变大、变慢历史消息很快就翻不到

这三个数是有联动关系的。MAX_KEEP越大,read_messages每次遍历的行数越多,长轮询里 500ms 一次的检查成本就越高。如果非要保留大量历史,正确做法不是把MAX_KEEP调到几万,而是改成"按月分文件"或者干脆接一个 SQLite——那已经是二次开发的范畴,第 6 章会讲怎么平滑过渡。

5. 避坑与排查:单文件聊天室最容易翻车的五条现场记录

单文件项目的故障现象往往很迷惑:HTTP 状态码全是 200,前端就是不显示。下面五条是我和身边人真实踩过的,按"现象 → 原因 → 解决"记下来。

5.1 发送返回成功,别人就是看不见

现象:点发送后 Network 面板显示 200,响应体里也有{"ok":true,"data":{...}},但另一个窗口刷新多少次都没有这条消息。

原因:最常见的是文件属主不对。命令行里用root创建的data/messages.jsonl,PHP-FPM 以www-data运行,fopen(..., 'c+')直接失败返回false,但代码里只判断了!$fp就返回错误——如果错误分支写得不严,或者被@抑制了,前端就只看到"成功"。第二种原因是写入到了另一个路径:__DIR__在符号链接目录下解析的是真实路径,你ls的那个文件根本不是程序在写的那个。

解决:先看响应体里到底是不是ok:true;再用ls -l data/确认属主和权限;最后在handle_send开头临时打一行error_log(MSG_FILE),确认路径和你想的一致。养成习惯:所有fopen失败分支都要把error_log打出来,别静默返回。

5.2 偶发消息丢失,或者整个聊天室突然打不开

现象:大部分时候正常,忙的时候偶尔少一两条;严重的时候页面直接白屏,messages.jsonl打开是半截 JSON。

原因:没加锁,或者锁加错了地方。两个进程同时file_get_contents读到同样的内容,各自算出同一个id,然后各自覆盖写,后写的把先写的吃掉。更糟的是写到一半进程被杀,文件就断了。还有一种隐蔽情况:flock加在了fopen之后但ftruncate之前,看着没问题,实际上某些文件系统(尤其网络挂载)对flock支持不完整。

解决:锁必须覆盖"读—算—写"整个区间,且fopen用c+而不是w(w会先截断,加锁前文件就已经空了)。ftruncate之后一定要rewind,写完一定要fflush再flock(LOCK_UN)。如果条件允许,把数据目录放在本地磁盘而不是网络挂载上,能省掉一大类玄学问题。

5.3 用了几天,页面越来越卡

现象:刚部署时丝滑,一周后每次拉消息要等一两秒,messages.jsonl涨到了几十 MB。

原因:没做裁剪,或者裁剪逻辑写在了写入分支里但被某个return提前跳过了。另一种情况是文件里混进了大量空行——fwrite每次都在末尾加\n,如果某次写入内容为空,就会累积出一堆空行,file()读出来全是空字符串,白白占用遍历时间。

解决:MAX_KEEP必须在每次写入时强制执行,不能只在启动时裁一次。读取时用FILE_SKIP_EMPTY_LINES过滤空行。已经膨胀的文件手动处理:tail -n 300 messages.jsonl > tmp && mv tmp messages.jsonl,注意要在低峰期做,因为mv的瞬间可能有写入落到旧 inode 上。

5.4 线上一直转圈,日志里全是 504

现象:本地跑得好好的,一上线就每隔十几秒报一次 504,前端不停地重连。

原因:网关超时小于长轮询时长。Nginx 默认fastcgi_read_timeout是 60 秒,看着够;但如果前面还有一层负载均衡或者 CDN,它们自己的空闲超时可能只有 15 秒甚至 10 秒,请求在那一层就被切了。另一种原因是 PHP 的max_execution_time生效了,脚本被强制终止,返回的是空白响应。

解决:把链路上每一层的超时都排查一遍,取最小值,然后把POLL_WAIT设成那个最小值的 2/3 左右。同时在 PHP 顶部显式set_time_limit(40)。排查时看响应头里的Server和X-Cache,能判断是哪一层返回的错误页。

5.5 昵称里带尖括号,弹出个对话框

现象:某个用户把昵称改成一段脚本,之后所有打开聊天室的人都弹窗。

原因:前端用字符串拼 HTML,innerHTML = '<b>' + name + '</b>',服务端也没做过滤。这类单文件项目通常只在发送时trim一下,压根没考虑转义。

解决:前端一律用textContent或createTextNode,这是最彻底的做法,因为它在 DOM 层面就不解析标签。服务端可以再加一层htmlspecialchars($text, ENT_QUOTES, 'UTF-8')做纵深防御,但注意别对已经是 JSON 的字段重复转义,否则用户会看到&lt;这种字面量。两边都做的时候,前端负责渲染安全,后端负责存储规范,职责别混。

6. 二次开发:从「能用」到「顺手」的四个改造方向

单文件版最大的价值是可改。按投入产出比排序,我一般会先动这四个地方。

第一,把存储层抽成三个函数。现在读写逻辑散在handle_send和read_messages里,想换存储就得改一片。抽出msg_append()、msg_since()、msg_trim()三个接口,文件实现放一个storage_file.php,以后接 SQLite 只要再写一个storage_sqlite.php,业务代码一行不改。

第二,加发言频率限制。无数据库版本可以用一个rate.json记录昵称 => [上次发言时间, 计数],超过 10 秒内 3 条就返回429。这段逻辑和数据文件共用同一套flock模式,不引入新依赖。

第三,房间/频道。最小改动是把文件名变成messages_{room}.jsonl,room从$_GET取并做白名单校验(preg_match('/^[a-z0-9_-]{1,16}$/', $room))。千万别直接把用户输入拼进路径,否则../../etc/passwd这种输入能让你当场下班。

第四,用 SSE 替掉长轮询。如果部署环境允许长连接,SSE 比长轮询省资源得多,一个连接一直挂着,服务端推一条前端收一条:

// action=stream:SSE 推送,注意关闭输出缓冲 header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // 告诉 Nginx 别缓冲 set_time_limit(0); $since = (int)($_GET['since'] ?? 0); while (true) { $rows = read_messages($since); foreach ($rows as $row) { $since = max($since, (int)$row['id']); echo "id: {$row['id']}\n"; echo 'data: ' . json_encode($row, JSON_UNESCAPED_UNICODE) . "\n\n"; } if (connection_aborted()) break; // 用户断开就退出 echo ": ping\n\n"; // 注释行当心跳,防代理断连 @ob_flush(); @flush(); sleep(2); // SSE 靠短间隔轮询 + 长连接,比长轮询简单 }

这段代码里X-Accel-Buffering: no和: ping心跳是两个必须项,少一个就会出现"前端几十秒收不到东西然后自动重连"。前端的对接也非常轻:new EventSource('chat.php?action=stream&since=0'),监听onmessage就行,断线浏览器会自动重连,比手写长轮询的退避逻辑省心。

四种方案怎么选,我给个粗略的判断:

方案消息延迟服务端连接占用实现复杂度适用规模
短轮询1~3 秒无(请求即断)最低十几人以内
长轮询< 1 秒每人在线占 1 个进程中几十人
SSE< 1 秒每人在线占 1 个连接中几十到几百人
WebSocket实时常驻进程高,要额外服务几百人以上

从长轮询迁到 SSE 的时候,我踩过一个坑:read_messages($since)在 SSE 里是每 2 秒查一次文件,如果同时有几十个连接,就是每 2 秒几十次文件读,磁盘 IO 会明显上升。正确做法是把消息缓存在一个共享内存(apcu_fetch)里,或者干脆换成 SQLite 让查询走索引——这也是为什么我把"抽存储层"排在改造清单第一位。

从那以后我每次动这类单文件项目,都强制先做一件事:把数据文件删掉,从零跑一遍完整流程,确认所有"文件不存在"的分支都能自动兜底。这个动作只要两分钟,但能提前暴露掉八成以上的线上事故。单文件无数据库的写法确实简陋,可它把"状态在哪里"这件事摊在了明面上,改起来心里有数——这一点比很多层层封装的项目强。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 8:48:26

工业AI实战:如何将报警降99.8%并实现自控率98%

开头 “报警降99.8%”和“自控率98%”这两个数字摆在一起&#xff0c;做工业控制的人一眼就能看出含金量。报警从原来每天上百条压到只剩零星几条&#xff0c;生产系统从“靠人盯着”变成“系统自己开着”&#xff0c;这不是PPT上的噱头&#xff0c;而是一个典型的工业AI落地项…

作者头像 李华
网站建设 2026/10/27 8:48:10

现代测试工程师核心能力框架与实战策略

1. 测试工程师的核心能力框架在软件质量保障领域&#xff0c;测试工程师的角色早已从单纯的"找bug"演变为质量守门人。根据2023年Stack Overflow开发者调查报告&#xff0c;超过67%的测试岗位要求候选人具备跨职能协作能力。这意味着现代测试工程师需要构建复合型技能…

作者头像 李华
网站建设 2026/10/27 8:48:10

Repo工具在多仓库管理中的核心价值与实践指南

1. Repo工具在多仓库管理中的核心价值 第一次接触Android源码开发时&#xff0c;我就被那个神奇的.repo目录吸引了——它居然能同时管理数百个Git仓库&#xff01;这种多仓库协同管理模式&#xff0c;后来成为我处理复杂项目的标配工具。Repo本质上是用Python封装的Git管理脚本…

作者头像 李华
网站建设 2026/10/27 8:46:01

11款大模型AI量化实测:排名、评分与选型避坑指南

最近花了两周时间&#xff0c;把市面上能调用的11个主流大模型全拉出来&#xff0c;针对AI量化这个场景做了一轮横向实测。结果比我预想的要刺激——头部模型和后面几名之间&#xff0c;总分差距拉开到接近一倍&#xff0c;而且有几个平时口碑很好的模型&#xff0c;放到量化任…

作者头像 李华
网站建设 2026/10/27 8:44:43

MySQL到达梦数据库迁移实战指南

1. 为什么需要从MySQL迁移到达梦数据库 国内数据库市场近年来呈现出明显的国产化替代趋势&#xff0c;达梦数据库作为国产数据库的代表产品之一&#xff0c;正在被越来越多的政企单位采用。在实际工作中&#xff0c;我遇到过三种典型的迁移场景&#xff1a; 第一种是政策合规要…

作者头像 李华
网站建设 2026/10/27 8:44:28

Java Timeline抽象库:统一朋友圈、IM与推送的时间线模型

简介&#xff1a;这是一份基于Java实现的社交场景抽象库&#xff0c;面向需要快速搭建朋友圈、微博、信息流与IM通讯功能的后端开发者。它围绕Timeline时间线模式&#xff0c;封装了数据流间的分发、消息推送、Feed流聚合及基础IM通信能力&#xff0c;可显著降低复杂社交系统的…

作者头像 李华