我在本机跑了一个很小的 HTTP 服务,给自己做的监控面板用。面板上有一排操作按钮:停手、恢复、记一笔、刷新。某天这些按钮点了全都没反应——不报错、不转圈、页面也不刷新,就像没点一样。
诡异的是,后端日志干干净净。我用命令行直接请求那个接口,返回的是 200 和一串正确的 JSON。
下面记录我最后是怎么定位并修掉它的。结论先给:问题不在后端,而在前端用了相对路径;而相对路径打到哪台服务器,取决于这个页面是被谁打开的。
第一反应是"服务挂了"。查进程,活着;查端口,listen 着。
第二步,绕开浏览器,直接请求接口:
$ curl -s -X POST "http://127.0.0.1:8015/halt?line=L2&action=stop"
{"ok": true, "halted": true}接口完全正常。
于是排除掉三项:接口没问题、进程没问题、参数也没问题。那"没反应"就只剩一种解释——请求根本没发出去,或者发去了别的地方。
我的面板有两条打开路径:
http://127.0.0.1:8015/;两种方式的 HTML 内容一模一样,但在浏览器眼里,它们不是同一个源(origin)。前端当时的写法是:
fetch('/halt?line=' + line + '&action=' + action, {method:'POST'})/halt 是相对路径。它补全后的完整地址 = 当前页面的源 + /halt。
http://127.0.0.1:8015,请求打到 8015,成功。/halt。那台服务器上根本没有这个接口,返回 404;就算它碰巧有,跨源也会被浏览器的同源策略拦掉。表现就是"点了没反应":请求确实发出去了,但发错了地方,而且错误被静默吞掉了。
这一点值得单独强调:"没反应"比"报错"难查得多。 报错至少会告诉你哪里错了;没反应意味着错误发生在你没在看的那一层——Network 面板和 console。当时的失败回调里只有一句话,没人会专门点开控制台看它。
既然"当前页面源"不可控,就不要依赖它。改成先试同源,失败了再试绝对地址:
function wbPost(path, ok, fail, opts){
opts = opts || {};
var init = {method: opts.method || 'POST', cache: 'no-store'};
if (opts.headers) init.headers = opts.headers;
if (opts.body) init.body = opts.body;
var urls = [path, 'http://127.0.0.1:8015' + path], i = 0;
(function next(){
if (i >= urls.length){
fail('够不到本机服务(确认 8015 在跑,或双击「工作台.bat」)');
return;
}
fetch(urls[i++], init)
.then(function(r){ return r.json(); })
.then(function(d){
if (d && d.ok) ok(d);
else fail((d && d.msg) || '未知原因');
})
.catch(function(){ next(); }); // 这一路失败,就换下一个源重试
})();
}有三个细节值得说:
fail,而且要给人一句话。前端把异常吞掉,用户只会说"点了没反应",等于把排查成本转嫁给了未来的自己。127.0.0.1,不写 localhost。这两个名字在有些环境里解析结果不同(IPv4 / IPv6),而我的服务只 listen 在 127.0.0.1。只改前端还不够。当页面源不是 8015 时,浏览器会先做一次跨源判定,所以服务端得明确表态:
def _send(self, code, body, ctype="text/html; charset=utf-8"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
"""预检兜底:现在都是简单请求不走预检,留着防以后加自定义头。"""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Content-Length", "0")
self.end_headers()有人会问:Allow-Origin: * 是不是放得太宽?
在这里成立,原因是服务本身只 listen 127.0.0.1,外网根本连不上这个端口;跨源放行的实际含义只是"本机的其它页面可以访问本机的这个服务"。如果这个服务是对外提供的话,就不能这么写。
do_OPTIONS 是预检兜底。当前的请求都是"简单请求",不会触发预检;但以后一旦加了自定义请求头,浏览器就会先发一个 OPTIONS,缺了这个分支就会卡在预检阶段——那又会是一次"点了没反应"。
把上面两层修完,我发现还有一个按钮照样没反应。翻代码才看清:在网页版里,那个按钮只是渲染出来的一行文案,它根本没有绑定点击事件。 它是"看起来像按钮的文字",不是按钮。
这类问题用眼睛是看不出来的,得让脚本去核:在体检脚本里加一条规则——凡是带按钮样式的元素,都要求在事件绑定表里能找到对应项;查不到就报出来。渲染出来的样子,不等于它真的能用。
这次之后,我给自己定了条规矩:任何"点了没反应",按三段排查,每段留一个可观测点。
三段里必然有一段是断的。这次断在第 2 段;而第 2 段的断点由"页面是从哪打开的"决定——那是我一开始完全没有考虑到的变量。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。