Node.js + Express 从零搭建 API 服务实战:AI 辅助开发与三层架构设计 1. 为什么我选 Node.js Express 来搭这个 API 服务1.1 从零搭 API 服务先想清楚要解决什么问题很多人一听到“用 AI 搭 API 服务”第一反应是打开某个代码生成工具输入一句“帮我写一个 API”然后复制粘贴跑起来就完事。我一开始也这么干过结果就是代码能跑但一旦要改一个字段、加一个接口、处理一个报错整个人就懵了。所以这个实战项目我想做的不是“让 AI 替我写代码”而是“我主导设计AI 帮我加速实现”。这个 API 服务的目标很明确对外提供一组 HTTP 接口能够接收请求、处理业务逻辑、返回 JSON 数据。它可能是一个查询接口也可能是一个数据写入接口甚至是一个调用大模型能力的中间层。不管具体业务是什么底层骨架是一样的一个 Web 框架、一组路由、一层业务处理、一套错误响应机制。那为什么选 Node.js Express原因很实际。第一JavaScript 是我日常最顺手的语言前端后端可以共用一套思维模型不用在语言之间来回切换。第二Node.js 的非阻塞 I/O 模型特别适合 API 这种“请求进来、查一下、返回出去”的场景并发处理能力在中小规模下完全够用。第三Express 的生态成熟到几乎不需要思考中间件、路由、错误处理都有现成方案遇到问题搜索一下就有大量实践参考。提示如果你的项目对性能有极高要求或者需要强类型约束可以考虑 Fastify 或 NestJS。但对于“从零搭一个能用的 API 服务”这个目标Express 的学习曲线最平缓AI 辅助生成的代码也最容易理解和修改。1.2 整体架构设计三层结构各司其职我在设计这个项目时刻意把代码分成三层这样后面不管是自己维护还是让 AI 帮忙改都不会乱。第一层是入口层也就是server.js或app.js。它只做三件事创建 Express 应用、挂载中间件、启动监听端口。这一层不写任何业务逻辑保持极简。第二层是路由层放在routes/目录下。每个路由文件负责一组相关的接口比如userRoutes.js处理用户相关请求dataRoutes.js处理数据相关请求。路由层只负责解析请求参数、调用业务函数、返回响应不直接操作数据。第三层是业务层放在services/或controllers/目录下。这里才是真正干活的地方校验参数、查询数据、调用外部 API、组装返回结果。业务层不关心 HTTP 细节只关心“给我输入我给你输出”。这样分层的好处是当 AI 帮你生成代码时你可以明确告诉它“只改业务层”不会把路由和入口搅在一起。而且测试的时候业务层可以单独测不用启动整个服务。1.3 项目初始化从 package.json 开始我习惯先建目录再初始化项目。打开终端执行mkdir ai-api-demo cd ai-api-demo npm init -ynpm init -y会生成一个默认的package.json。然后安装核心依赖npm install express cors dotenv npm install -D nodemon这里解释一下每个包的作用。express是 Web 框架本体。cors用来处理跨域请求前端调接口时必备。dotenv用来读取.env文件里的环境变量比如端口号、API Key 这类敏感信息。nodemon是开发依赖它会在你修改代码后自动重启服务省去手动 CtrlC 再 npm start 的麻烦。安装完成后在package.json里加上启动脚本{ scripts: { start: node server.js, dev: nodemon server.js } }注意不要把 API Key、数据库密码这类信息直接写在代码里。用.env文件管理并且在.gitignore里加上.env避免提交到代码仓库。2. 核心代码实现让 AI 帮你写但你要知道为什么2.1 入口文件 server.js 的骨架入口文件是整个服务的起点。我让 AI 生成初版后自己又调整了几处。最终的结构是这样的const express require(express); const cors require(cors); require(dotenv).config(); const app express(); const PORT process.env.PORT || 3000; // 中间件 app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // 路由挂载 const dataRoutes require(./routes/dataRoutes); app.use(/api/data, dataRoutes); // 健康检查 app.get(/health, (req, res) { res.json({ status: ok, timestamp: new Date().toISOString() }); }); // 全局错误处理 app.use((err, req, res, next) { console.error(err.stack); res.status(500).json({ error: 服务器内部错误 }); }); app.listen(PORT, () { console.log(服务已启动监听端口 ${PORT}); });这里有几个关键点值得展开。express.json()是必须的它让 Express 能解析请求体里的 JSON 数据。如果没有这行req.body会是 undefined这是新手最常踩的坑之一。cors()放在最前面确保所有路由都能处理跨域。健康检查接口/health看起来不起眼但在部署后排查问题时非常有用负载均衡器也常用它来判断服务是否存活。全局错误处理中间件必须放在所有路由之后而且要有四个参数(err, req, res, next)Express 才能识别它是错误处理中间件。我见过有人只写三个参数结果错误根本不会被捕获。2.2 路由层把请求分发给正确的处理函数路由层的职责很单一定义 URL 路径和 HTTP 方法的对应关系然后把请求交给业务函数。以routes/dataRoutes.js为例const express require(express); const router express.Router(); const dataService require(../services/dataService); // 获取数据列表 router.get(/, async (req, res, next) { try { const result await dataService.getDataList(req.query); res.json({ success: true, data: result }); } catch (err) { next(err); } }); // 获取单条数据 router.get(/:id, async (req, res, next) { try { const result await dataService.getDataById(req.params.id); if (!result) { return res.status(404).json({ success: false, error: 数据不存在 }); } res.json({ success: true, data: result }); } catch (err) { next(err); } }); // 创建数据 router.post(/, async (req, res, next) { try { const result await dataService.createData(req.body); res.status(201).json({ success: true, data: result }); } catch (err) { next(err); } }); module.exports router;这里我用了async/await配合try/catch每个路由都把错误通过next(err)传递给全局错误处理。这样做的好处是业务层抛出的任何异常都会被统一处理不会导致进程崩溃。另外返回格式统一为{ success, data, error }前端处理起来很省心。实操心得路由路径不要写死业务含义太强的名字比如/getUserList。用 RESTful 风格GET /api/users表示获取列表GET /api/users/:id表示获取单条POST /api/users表示创建。这样接口语义清晰后面扩展也方便。2.3 业务层真正处理逻辑的地方业务层是核心。以services/dataService.js为例我模拟了一个简单的数据操作// 模拟数据存储 let dataStore [ { id: 1, name: 示例数据一, createdAt: new Date().toISOString() }, { id: 2, name: 示例数据二, createdAt: new Date().toISOString() } ]; async function getDataList(query) { let result [...dataStore]; if (query.keyword) { result result.filter(item item.name.includes(query.keyword)); } return result; } async function getDataById(id) { return dataStore.find(item item.id id) || null; } async function createData(payload) { if (!payload.name) { throw new Error(name 字段不能为空); } const newItem { id: String(Date.now()), name: payload.name, createdAt: new Date().toISOString() }; dataStore.push(newItem); return newItem; } module.exports { getDataList, getDataById, createData };这段代码虽然简单但体现了几个原则。第一业务函数是纯异步的返回 Promise这样后面换成真实数据库查询时路由层不需要改。第二参数校验放在业务层比如createData里检查name是否存在校验失败就抛错由全局错误处理统一返回。第三数据操作和 HTTP 解耦dataService完全不知道 Express 的存在这样单元测试可以直接调用这些函数。2.4 用 AI 辅助生成代码的正确姿势我实际用 AI 辅助时不会直接说“帮我写一个 API”。我会把上下文拆得很细。比如我会这样描述“我有一个 Express 项目路由文件在routes/dataRoutes.js业务文件在services/dataService.js。现在需要增加一个更新数据的接口PUT/api/data/:id接收name字段返回更新后的对象。请只给出路由层和业务层的代码改动。”这样 AI 生成的代码基本可以直接用因为约束足够明确。如果只说“帮我加一个更新接口”它可能会把路由、业务、甚至数据库操作全混在一起改起来反而更费劲。另外AI 生成的代码一定要自己过一遍。我遇到过 AI 生成的代码里用了req.body.id但实际应该用req.params.id也遇到过忘记加await导致返回 Promise 对象的情况。这些错误不跑一遍很难发现所以每加一个接口我都会用 curl 或 Postman 测一下。3. 完整实操流程从零到服务跑起来3.1 环境准备与依赖安装在开始之前确认本机已经安装了 Node.js。打开终端输入node -v npm -v如果能看到版本号说明环境没问题。建议使用 LTS 版本稳定性更好。如果还没安装去 Node.js 官网下载对应系统的安装包一路下一步即可。然后按照前面的步骤创建项目目录、初始化package.json、安装依赖。这里再强调一下.env文件的配置PORT3000 NODE_ENVdevelopment.env文件放在项目根目录dotenv会自动读取。PORT用来指定服务监听端口NODE_ENV用来区分开发和生产环境后面可以做不同的错误处理策略。3.2 目录结构规划我最终的项目结构是这样的ai-api-demo/ ├── routes/ │ └── dataRoutes.js ├── services/ │ └── dataService.js ├── middleware/ │ └── errorHandler.js ├── .env ├── .gitignore ├── package.json └── server.jsmiddleware/errorHandler.js是我后来抽出来的把全局错误处理单独放一个文件入口文件更干净。.gitignore里至少要有node_modules和.env。3.3 启动服务与接口测试开发模式下运行npm run dev看到终端输出“服务已启动监听端口 3000”就说明成功了。然后开另一个终端用 curl 测试# 健康检查 curl http://localhost:3000/health # 获取列表 curl http://localhost:3000/api/data # 获取单条 curl http://localhost:3000/api/data/1 # 创建数据 curl -X POST http://localhost:3000/api/data \ -H Content-Type: application/json \ -d {name: 新数据}如果每个请求都返回了预期的 JSON说明服务基本跑通了。这时候可以打开 Postman 或 Apifox把接口保存成集合后面调试更方便。3.4 参数校验与错误码设计一个能用的 API 服务必须有一套清晰的错误码和错误信息。我在middleware/errorHandler.js里做了统一处理function errorHandler(err, req, res, next) { const statusCode err.statusCode || 500; const message err.message || 服务器内部错误; console.error([${new Date().toISOString()}] ${statusCode} - ${message}); res.status(statusCode).json({ success: false, error: message, ...(process.env.NODE_ENV development { stack: err.stack }) }); } module.exports errorHandler;然后在业务层抛错时可以带上statusCodeconst error new Error(name 字段不能为空); error.statusCode 400; throw error;这样前端收到的就是 400 状态码和明确的错误信息。生产环境下不返回stack避免暴露内部细节。注意不要把所有错误都返回 500。参数错误用 400未找到用 404未授权用 401禁止访问用 403。状态码用对了前端和调用方才能正确处理。4. 常见问题与排查技巧实录4.1 请求体解析失败req.body 为 undefined这是最常见的问题。原因通常是忘记加app.use(express.json())或者中间件顺序不对。express.json()必须放在路由挂载之前。另外如果请求头里没有Content-Type: application/jsonExpress 也不会解析 JSON 体。用 curl 测试时记得加-H Content-Type: application/json。还有一种情况是请求体格式不对比如多了一层嵌套或者 JSON 语法错误。这时候 Express 会抛出一个SyntaxError全局错误处理会捕获并返回 400。我一般会在错误处理里判断err.type entity.parse.failed返回更友好的提示。4.2 跨域问题前端调接口报 CORS 错误本地开发时前端跑在localhost:5173后端跑在localhost:3000浏览器会拦截跨域请求。解决办法就是加cors中间件。如果只需要允许特定域名可以这样配置app.use(cors({ origin: [http://localhost:5173], methods: [GET, POST, PUT, DELETE], credentials: true }));生产环境要把origin换成实际域名。不要图省事用origin: *同时开credentials: true浏览器会拒绝这种组合。4.3 端口被占用EADDRINUSE 错误启动服务时报EADDRINUSE: address already in use说明端口被其他进程占了。解决办法有两个换一个端口或者找到占用端口的进程杀掉。在 macOS/Linux 上lsof -i :3000 kill -9 PID在 Windows 上netstat -ano | findstr :3000 taskkill /PID PID /F我一般会在.env里把端口设成不常用的比如 3100 或 8080减少冲突概率。4.4 异步错误没有被捕获Express 默认不会捕获异步函数里抛出的错误。如果你写的是async (req, res) { throw new Error(xxx) }没有try/catch也没有next(err)这个错误会变成未处理的 Promise rejection进程可能直接退出。解决办法就是每个异步路由都包try/catch或者用一个asyncHandler包装函数const asyncHandler fn (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); };然后路由写成router.get(/, asyncHandler(async (req, res) { ... }))。这样代码更干净也不用每个路由都写try/catch。4.5 常见问题速查表问题现象可能原因排查方向解决方案req.body 为 undefined缺少 express.json() 或 Content-Type 不对检查中间件顺序和请求头加 app.use(express.json())请求头加 Content-Type跨域报错未配置 CORS浏览器控制台看具体错误加 cors 中间件配置 origin端口被占用其他进程占用端口终端报 EADDRINUSE换端口或杀掉占用进程异步错误导致进程退出未捕获 Promise rejection查看终端错误堆栈用 try/catch 或 asyncHandler 包装返回 404 但路由存在路由挂载路径不对检查 app.use 的前缀和 router 路径确认完整路径拼接正确返回 500 但无详细信息错误处理未返回 message检查 errorHandler确保 err.message 被返回4.6 几个我踩过的坑第一个坑是路由顺序。我把router.get(/:id)写在了router.get(/search)前面结果访问/search时被:id匹配走了id变成了字符串 search。解决办法是把具体路径放在参数路径前面。第二个坑是express.json()的 limit。默认请求体大小限制是 100kb如果上传大 JSON 会报 413。可以在配置里改express.json({ limit: 10mb })。但要注意API 服务不应该用来传大文件大文件应该走对象存储。第三个坑是环境变量没生效。.env文件必须放在项目根目录而且require(dotenv).config()要放在使用process.env之前。我有一次把它放在了路由引入之后结果端口号一直是 undefined服务跑在了默认端口上。5. 后续扩展方向与个人体会这个 API 服务骨架跑通之后后面可以做的事情很多。比如接入真实数据库把dataService里的内存数组换成 MySQL 或 MongoDB 查询。比如加一层 JWT 鉴权在路由前挂一个authMiddleware校验请求头里的 token。比如接入大模型能力在业务层调用外部 API把 AI 返回的结果包装成标准 JSON 响应。我自己在实际操作中的体会是AI 辅助写代码的效率确实高但前提是你自己得清楚要什么。如果你对 Express 的中间件机制、路由匹配顺序、异步错误处理这些基础概念不清楚AI 生成的代码一旦出问题你连从哪里排查都不知道。所以我的建议是先用这个项目把基础骨架跑通理解每一层在干什么然后再让 AI 帮你加速。这样你得到的不只是一个能跑的 API而是一套你能掌控、能修改、能扩展的服务。最后分享一个小技巧每次让 AI 生成代码后不要直接粘贴覆盖原文件。先在一个新文件里跑一遍确认没问题再合并。这样即使 AI 生成的代码有问题也不会破坏你原本能跑的服务。