十分钟搭建网络诊断原型:代码生成与联调实践
十分钟搭出一个能跑起来的测试环境我头一回听到这个说法时嘴角是往下撇的。凭经验光是等依赖装完就够喝半杯咖啡更别提页面点下去、接口返回一堆 502 的尴尬时刻。但真把需求拆细、把提示词写准让生成的前端页面调通后端探测接口确实能压在十分钟出头——前提是你清楚这十分钟到底花在哪以及哪几件事千万不能塞进这十分钟里。这篇就聊我最近做的一个网络工具原型一个本地跑的网络连通性诊断面板输入域名依次查 DNS 解析、TCP 端口连通性、HTTP 响应状态和耗时用来快速判断某个地址到底卡在哪一层。整套东西从零到能点靠的是代码生成平台先把骨架搭出来人只负责收口。适合两类人看一是想快速验证一个工具类想法、不想在环境上耗一整天的人二是已经会用平台生成代码、但每次生成完都卡在联调那一步的人。1. 把网络工具原型翻译成一份能交给平台的需求清单1.1 敲一句帮我做个网络工具生成结果注定是废的我见过太多人生成失败问题不在平台在输入。你丢过去一句帮我做一个网络工具平台只能靠猜是测速的是抓包的是批量检测的猜错的概率接近百分之百于是它给你一个能跑的壳子里面塞满你想删的按钮和看不懂的注释你还得花二十分钟把它拆掉。这就是典型的生成十分钟、清理半小时。代码生成平台本质上是把需求描述当作上下文上下文越稀薄它越倾向于走最保守的通用模板路线。所以要做的第一件事不是打开平台而是拿张纸把原型说清楚输入是什么、点了之后依次发生什么、每层输出哪些字段、失败的时候界面显示什么。这四件事写明白提示词的信息密度就够了。我给自己这个原型的定义是这样的单页应用顶部一个输入框接收域名或 IP下面三张结果卡片分别对应 DNS、TCP、HTTP每张卡片显示是否成功、耗时毫秒、失败原因码右侧一条时间线把三段耗时按比例画出来。不需要登录、不需要历史记录、不需要数据库。把不需要的部分也明说出来比只列功能更有效因为平台最爱自作主张加的恰恰是数据库和登录。1.2 我用的四段式提示词边界、栈、契约、样例提示词我没搞什么花活就是四个固定段落顺序不能乱。第一段写功能边界第二段钉死技术栈第三段把接口契约用 JSON 写死第四段给一个期望输出的样例片段。之所以强推契约先行是因为生成类工具在跨前后端的地方最容易胡编字段名——后端返回resolveMs前端读dnsTime跑起来就是一屏undefined你还得肉眼比对半天。我实际用的输入大概长这样【功能边界】 做一个本地运行的单页网络诊断工具原型只做三件事 1. DNS 解析输入域名返回解析到的 IP 列表和耗时 2. TCP 探测指定端口返回是否可连接和握手耗时 3. HTTP 探测返回状态码、响应头中的 server 字段、首字节时间 不包含用户系统、历史记录、数据库、任何第三方云服务依赖。 所有探测在后端完成前端只负责展示。 【技术栈】 前端 Vite 原生 JavaScript不要 React不要 TypeScript 后端 Node.js 20 Express仅使用内置模块 node:dns、node:net 不要在原型阶段引入 axios、lodash 这类额外依赖 【接口契约】 GET /api/diagnose?host域名port端口 成功响应 200 { host: example.com, dns: { ok: true, costMs: 23, addrs: [93.184.216.34] }, tcp: { ok: false, costMs: 3000, reason: TIMEOUT }, http: { ok: true, costMs: 118, status: 200, server: nginx } } 参数非法时返回 400body 为 { error: INVALID_HOST } 【输出要求】 先输出目录结构再逐个文件输出完整内容 每个文件第一行注释写明该文件的作用。这套写法的关键在第三段。契约一旦固定平台生成的代码前后端就对得上了省下的返工时间远远超过写这段 JSON 的时间。我自己的体会是写提示词的那三分钟是在替后面调试的那二十分钟买单。1.3 生成结果回来第一轮只看三个地方生成的代码我不会立刻npm run dev先通读三个点。第一看接口路由有没有写死在文件里方便后面挪位置第二看错误分支是不是只有try/catch却没有任何兜底字段很多生成结果在超时时直接抛异常前端拿不到结构化错误只会显示请求失败这在诊断类工具里是致命的因为失败原因本身就是产品功能第三看它有没有偷偷引入你没提过的依赖。这里补一个反直觉的经验生成的代码越完整越值得警惕。有一次平台给我加了缓存层、加了重试、还加了两个我没要的健康检查接口看起来专业实际上每个额外模块都多一个出错点而原型阶段你最不需要的就是出错点。宁可要一个丑但只有八十行、你能完整读完的后端文件。另外提一句域名和 IP 的输入校验一定要自己过一遍。生成结果里经常是if (!host)就算校验完了实际得限制字符集把空格、斜杠、中文冒号这类输入挡在入口否则后面dns.lookup会抛一个你没法映射成用户能看懂原因的异常。2. 十分钟里真正省不掉的几个环节2.1 先做时间账十分钟到底被谁吃掉十分钟这个数字容易让人误以为全程无摩擦实际拆开来看各段耗时差别很大。下面这张表是我连续做了几次之后记录的区间能比较真实地反映预期环节乐观估计实测区间主要变量写提示词 生成骨架40 秒1 至 2 分钟契约写得细不细安装依赖3 分钟40 秒至 3 分钟本机缓存、镜像配置起后端 前端20 秒30 秒至 5 分钟端口是否被占前端调通第一条链路2 分钟2 至 5 分钟跨域、字段是否对齐补错误分支展示1 分钟1 至 3 分钟生成代码的兜底质量结论很清楚真正不可压缩的是装依赖和联调两段其余都是可控的。所以我的做法是在生成代码的同时并行开一个终端装依赖不要串行等着。这一步听起来小但能省下一分钟以上而一分钟在十分钟的预算里占了一成。还有一个隐性成本容易被忽略首次运行时本机没有缓存Node 的包管理会去拉元数据这一下比后续安装慢得多慢到让人怀疑是不是卡死了。如果你当天要试好几个原型先把缓存暖起来后面每个原型的启动都能快一大截。2.2 版本对齐比装包更费神我踩过最冤枉的一次坑是本地默认运行时版本太低代码用到了较新的内置模块npm run dev直接语法报错报错信息还指向一个跟真实原因毫无关系的行号。这类问题排查起来特别费时间所以现在我的习惯是在项目根目录放两个小文件把版本钉死。第一个是.nvmrc写一行版本号20.11.0第二个是在package.json里补engines字段加一句提示{ name: netkit-probe, engines: { node: 20.0.0 }, scripts: { dev: concurrently -k -n api,web -c blue,green \node server/index.js\ \vite\ } }engines本身不会强制拦截但它能让你在装包时收到一条警告比事后对着莫名其妙的报错猜要强得多。而dev脚本里用concurrently把前后端一起拉起来也是有讲究的-k参数的意思是任意一个进程退出就杀掉其他进程没有这个参数你CtrlC之后后端经常还挂在后台占着端口下一次启动就报端口已被使用而这个错误信息在终端滚屏里特别容易被忽略很多人会误以为是代码问题白白排查十分钟。顺带说一句-n和-c的作用给两个进程打上不同颜色和名字的前缀日志混在一起时能一眼分清是哪边输出的。原型阶段日志就是唯一的观测手段这点排版上的投入回报率非常高。2.3 一条命令起来的开发服务器后端我固定监听一个不常用的端口比如 8787避开 3000、8080 这些被各种工具默认占用的位置。启动文件保持极简// server/index.js - 后端入口只负责挂载路由和启动监听 import express from express import diagnose from ./routes/diagnose.js const app express() app.use(diagnose) const PORT process.env.PORT || 8787 app.listen(PORT, 127.0.0.1, () { console.log([api] listening on http://127.0.0.1:${PORT}) })注意监听地址我写的是127.0.0.1而不是省略。省略时默认会监听所有网卡在某些系统上会额外触发一层权限提示或者被安全软件拦一下原型阶段完全没必要暴露给局域网锁死在本地回环更省心也更安全。这个细节生成代码通常不会替你做但改起来只要两个词。前端那边用 Vite 默认配置就行端口冲突时它会自动往上递增这点比手写构建工具友好很多。唯一需要自己补的是代理配置这个放到第 4 章讲因为它是联调阶段第一个会撞上的墙。3. 让页面真的调到接口三条探测链路怎么落地3.1 DNS 解析探测把打不开拆出第一层诊断类工具的价值不在于告诉你能通而在于告诉你是哪一层不通。DNS 是第一层也是最容易出问题的一层。实现上我用内置的dns.lookup而不是dns.resolve原因是lookup走的是系统解析器行为跟你日常访问时一致resolve走的是直接查询会绕过本机配置结论可能和你实际体验对不上。原型阶段要的是贴近真实体感不是标准答案。超时必须自己兜底。内置 DNS 查询没有提供超时参数所以得用Promise.race包一层// server/probe/dns.js - DNS 探测带 3 秒硬超时 import dns from node:dns/promises const withTimeout (promise, ms, label) Promise.race([ promise, new Promise((_, reject) setTimeout(() reject(Object.assign(new Error(label), { code: label })), ms) ) ]) export async function probeDns(host, timeoutMs 3000) { const t0 performance.now() try { const addrs await withTimeout( dns.lookup(host, { all: true }), timeoutMs, DNS_TIMEOUT ) return { ok: true, costMs: Math.round(performance.now() - t0), addrs: addrs.map(a ${a.address}/${a.family}) } } catch (err) { return { ok: false, costMs: Math.round(performance.now() - t0), reason: err.code || DNS_FAIL } } }这里有两个经验点。第一把family一起带上很值钱因为很多时候 IPv6 解析成功了但 IPv4 没有或者反过来用户看到的时好时坏往往就是这个原因界面上直接标出来能省一大段解释。第二超时时间不要设太短三秒是个比较稳的平衡点设成五百毫秒会因为公共解析服务的抖动产生大量假阴性误导判断比不判断更糟。另外注意performance.now()返回的是浮点数输出给前端之前记得取整。这种小地方不处理界面上就会出现23.000000000000004这种数字看着不专业虽然不影响功能但这种细节在原型演示时格外扎眼。3.2 TCP 端口探测超时一定要自己兜住TCP 层是最容易写出挂着不返回的一段。用node:net建连必须自己处理三种结局连上了、超时了、报错了。三者的语义完全不同而生成代码经常只处理第一种。// server/probe/tcp.js - TCP 端口探测三态收敛 import net from node:net export function probeTcp(host, port, timeoutMs 3000) { return new Promise(resolve { const t0 performance.now() const socket new net.Socket() let settled false const finish (ok, reason) { if (settled) return settled true socket.destroy() resolve({ ok, port, costMs: Math.round(performance.now() - t0), reason }) } socket.setTimeout(timeoutMs) socket.once(connect, () finish(true, CONNECTED)) socket.once(timeout, () finish(false, TIMEOUT)) socket.once(error, err finish(false, err.code || CONN_ERROR)) socket.connect(port, host) }) }settled这个标志位不是多余的。timeout事件触发之后底层连接可能还会补一个error事件上来没有这个标志位resolve会被调用两次第二次静默失败但日志里会看到重复记录排查时很容易被带偏。这种细节属于跑一次看不出来、跑一百次偶尔出错的类型很难靠肉眼测试发现所以我习惯在写这类异步探测时先把状态机想清楚。还有一个实践建议端口探测别用localhost做测试目标它在某些系统上会先尝试 IPv6 再回退 IPv4导致每次耗时差异很大看起来像是不稳定。测试时统一用127.0.0.1结论才干净。3.3 HTTP 层探测HEAD 请求、TTFB 和重定向HTTP 层我用 HEAD 请求而不是 GET因为原型阶段只关心状态码和响应头不需要下载正文HEAD 能显著缩短耗时也能避免拉到几兆文件把界面卡住。但 HEAD 有个坑部分服务端不支持 HEAD会返回 405 或者干脆异常断开这时候需要降级成 GET 并立刻中断读取。// server/probe/http.js - HTTP 探测HEAD 优先失败降级 GET export async function probeHttp(host, port 443) { const url https://${host}/ const attempts [ { method: HEAD, redirect: manual }, { method: GET, redirect: manual } ] for (const opt of attempts) { const t0 performance.now() try { const res await fetch(url, { ...opt, signal: AbortSignal.timeout(5000), headers: { user-agent: netkit-probe/0.1 } }) return { ok: res.status 400, costMs: Math.round(performance.now() - t0), status: res.status, server: res.headers.get(server) || unknown } } catch (err) { if (opt.method GET) { return { ok: false, costMs: Math.round(performance.now() - t0), reason: err.name TimeoutError ? HTTP_TIMEOUT : (err.cause?.code || HTTP_FAIL) } } } } }redirect: manual这个设置很关键。默认会自动跟随重定向你拿到的状态码是最终页面的状态码反而看不出原始响应是不是 301而诊断场景下到底跳没跳恰恰是有价值的信息。同理err.cause?.code这层取值也别省fetch抛出的异常外面那层信息很模糊真正的原因码藏在cause里探测类工具如果只报一个笼统的失败等于白做。首字节时间这个指标我没单独抽原型阶段用总耗时近似就够了真要精确测量需要用底层 HTTP 模块手动监听response事件投入产出比在验证阶段不划算。先跑通再精修这是我做原型的固定原则。4. 本地跑通之后必然撞上的几个坑4.1 跨域与代理Vite 的 proxy 到底改了谁前端在 5173 端口后端在 8787直接fetch(/api/diagnose)请求会打到前端自己的开发服务器上返回一个 404 或者首页 HTML控制台里报的是 JSON 解析错误看起来完全不像跨域问题这是最容易走偏的一次排查。正确做法是在 Vite 配置里把/api前缀转发出去// vite.config.js - 开发期把 /api 转发到本地后端 import { defineConfig } from vite export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8787, changeOrigin: true } } } })changeOrigin的作用是改写请求头里的 Host不改的话某些后端框架会因为 Host 不匹配做额外校验。这里要理解一件事代理解决的是开发期的路径对齐问题不是安全边界。它只在你本地开发服务器进程里生效构建之后就没这层了。原型阶段用它完全没问题但别把它当成我的接口就是这么设计的来理解。排查这类问题的顺序我固定为三步先在浏览器网络面板看请求 URL 落在哪个端口再看响应内容是不是一段 HTML最后才怀疑跨域。顺序颠倒会很浪费时间因为跨域报错信息明确、容易识别而代理没配对时报错信息往往非常含糊。4.2 端口占用和僵尸进程端口占用是原型阶段最高频的阻塞点而且它的报错信息在不同系统上差异极大。我一般先查再杀# macOS / Linux 查占用 lsof -i :8787 # 杀掉指定进程 kill -9 PID# Windows 查占用 netstat -ano | findstr :8787 # 按 PID 结束 taskkill /PID PID /F但我更推荐从根上避免把上一节提到的concurrently -k用上CtrlC时会把子进程一起带走。我吃过一次亏是强行关掉终端窗口导致后端进程变成孤儿之后两天里每次启动都报端口占用而我一直以为是别的项目在跑直到用lsof才看到那个进程已经挂了两天。顺带说个细节lsof -i :8787输出的 PID 有时候是父进程杀掉之后子进程还在得再查一次。所以杀完立刻重新查一遍确认端口空了这个动作只要两秒能避免我明明杀了怎么还占着的困惑。4.3 依赖漂移与锁文件生成代码里通常只有一个package.json没有锁文件。这在单人单机时不明显一旦你换机器或者隔几天再装装到的可能是次版本更新过的包行为出现细微差异。原型阶段最省事的做法是把package-lock.json提交进去之后统一用npm ci安装。npm ci和npm install的区别值得记牢我做了个对照对比项npm installnpm ci依据package.json可能更新锁文件严格依据锁文件锁文件不存在时正常创建直接报错退出node_modules处理增量更新先删干净再装适合场景首次搭建、加新包复现环境、CI、稳定启动原型验证阶段我一般第一次用npm install之后只要代码没加新依赖就一律npm ci。哪怕同一个项目里只差一个次版本号探测类工具的结论也可能变因为底层网络实现的行为会跟着运行时和库一起变。4.4 生成代码里看着对却跑不通的三类写法通读生成结果时有三类写法我见到就必改。第一种是res.json里字段名和契约不一致通常出现在后端改了实现但忘了对齐文档的地方肉眼比对一眼就能抓出来忽略了就要在浏览器里对着undefined发呆。第二种是把同步异常和异步异常混着处理try/catch包住了await却漏了回调里的error事件表现为偶尔整个接口不返回。第三种是路径大小写尤其在跨系统时某个import里写的是./probe/Dns.js而磁盘上是dns.js本地恰好能跑换台机器立刻炸。这三类问题的共同点是本地首次运行不一定暴露所以我现在的做法是生成之后先不点界面先把所有import路径扫一遍再把契约 JSON 和实现里的返回对象逐字段对一遍。这两步各花一两分钟但能把后面最烦人的排查掐死在源头。还有一个更隐蔽的生成代码有时会在模块顶层直接执行副作用比如启动时就发一次探测请求。放在原型里问题不大但如果你后面想把它挂到测试里顶层副作用会让导入这个模块的瞬间就发起网络请求测试变得不确定。看到这种写法随手挪进函数里成本极低。5. 从一次性 Demo 到还能继续长的工程5.1 目录分层先砍掉那个扁平结构平台生成的结构通常是所有文件堆在一个目录里前后端混着。原型能跑但你加第二个探测项的时候就得开始纠结文件往哪放了。我的做法是十分钟内先不动它跑通之后再花五分钟整理成下面这个样子netkit-probe/ ├─ server/ │ ├─ index.js # 入口挂路由 │ ├─ routes/ │ │ └─ diagnose.js # 聚合三类探测 │ └─ probe/ │ ├─ dns.js │ ├─ tcp.js │ └─ http.js ├─ web/ │ ├─ index.html │ └─ main.js ├─ vite.config.js └─ package.json核心思路是把探测能力和接口编排分开。probe/下面是纯粹的函数输入参数、输出结构化结果不碰 HTTP 上下文routes/负责参数校验、并发调度、错误映射。分开之后好处立刻显现想加第四个探测项只需要在probe/加文件在routes/里加一行并发调用前端加一张卡片改动范围完全可预期。混在一起的话每次改动都要在一坨代码里找插入点。并发调度也值得说一句。三类探测之间没有依赖串行跑的话总耗时是三者之和网络不通时每层都要等满超时用户可能要盯着屏幕九秒。用Promise.allSettled并发跑总耗时收敛到最慢的那一层体验差别很大// server/routes/diagnose.js - 并发调度单层失败不影响整体返回 const [dns, tcp, http] await Promise.allSettled([ probeDns(host), probeTcp(host, port), probeHttp(host, port) ])用allSettled而不是all是因为诊断工具的核心诉求恰恰是哪怕失败了也要看到失败原因。用all的话任何一层抛异常整体就 reject前面查到的信息全丢等于把工具最有价值的部分扔掉了。5.2 加一层薄日志让排查从猜变成看原型阶段没有监控日志就是唯一眼睛。我加的东西非常薄一个中间件把请求参数、耗时、结果状态打一行出来// server/middleware/log.js - 一行式请求日志 export default function requestLog(req, res, next) { const t0 performance.now() res.on(finish, () { console.log( [api] ${req.method} ${req.originalUrl} ${res.statusCode} ${Math.round(performance.now() - t0)}ms ) }) next() }就这几行价值比想象中大。因为探测类工具的失败往往发生在网络层前端只能显示一个原因码具体是超时还是连接被拒得靠后端日志确认。没有这行日志你只能在代码里到处插console.log再重跑一遍一轮下来三五分钟就没了。另一个小技巧把每次探测的原始结果整条打印一次别只打结论。因为很多问题是看起来成功但数据不对比如解析到的 IP 是内网地址、HTTP 状态是 200 但 server 字段为空这类情况只有看原始对象才能发现。日志量在原型阶段完全不构成负担一天几百行而已别在这上面省。5.3 什么时候该亲手重写什么时候继续用生成的这个问题我纠结过很久后来总结出一条比较硬的判断线如果一段生成代码你要改三个以上的地方才能理解它就直接重写。因为理解成本一旦超过重写成本后面的每次修改都在还这笔债。具体到这个原型我最后亲手重写了 TCP 探测那一段。生成版本用了一个第三方库写法上把超时和错误处理绕在一起我读了两遍才明白它到底什么时候 resolve这种代码留着就是隐患。而 DNS 和 HTTP 两段我基本保留了生成结果只改了字段名和超时值因为它们的逻辑本身够直白。还有一条经验把错误码表单独抽出来。原型里错误码散落在各个文件里前端没法统一映射成用户能看懂的话。抽成一个常量对象之后界面文案和后端原因码就解耦了加一个新原因码只改一处// shared/reasons.js - 原因码到可读文案的映射 export const REASONS { DNS_TIMEOUT: 域名解析超时检查本地解析配置, DNS_FAIL: 域名解析失败确认域名拼写, TIMEOUT: 端口连接超时检查地址与端口是否可达, ECONNREFUSED: 连接被拒绝端口可能未开放, HTTP_TIMEOUT: 服务响应超时可能负载过高, HTTP_FAIL: 请求未完成检查协议与证书 }这个小文件是我认为原型里性价比最高的一块。它让一个能跑的技术验证变成别人能看懂的界面而诊断工具最终交付的其实不是技术结论是让人看懂的解释。最后分享我个人的节奏这十分钟只用来验证一件事就是这套探测思路到底能不能给我有效结论。它不需要好看不需要测试甚至不需要完整的错误码。跑通之后我会立刻用它去测几个已知状态的地址看看结论跟预期是否一致——这一步才是真正验证原型价值的地方。生成得快不是目的能尽快拿去验证一个判断才是这十分钟最值钱的部分。