ARTICLE DETAIL

资讯详情

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

Node.js后端项目模板搭建指南:从目录结构到生产可用

Node.js后端项目模板搭建指南:从目录结构到生产可用 做后端开发这些年我见过太多项目是从复制粘贴另一个项目开始的。尤其是Node.js后端项目模版这件事看起来只是搭一个空壳实际上一不小心就把上个项目里的历史包袱全带过来。我在团队里维护了一套后端项目模版前后折腾了几轮从最初的Express全家桶到现在这套结构清晰、开箱即用的骨架新服务启动时间从半天缩短到十几分钟。这篇文章就把我的完整思路、目录设计、关键代码和踩坑记录梳理出来给那些准备用Node.js起新项目或者正在纠结模板怎么搭的朋友做个参考。不管你是刚转后端、想做个前后端分离的小项目还是团队里要批量创建微服务这套模板的思路都可以直接拿去用。1. 为什么每次新项目都要从零搭一遍1.1 重复劳动背后藏着的真实成本我见过一个很典型的现象很多团队起新后端服务的时候流程是找一个老项目把目录复制一遍然后把业务代码删掉再改配置。听起来很省事但实际上每一次复制都会把老项目里的历史包袱带过来比如某个还没升级到新接口的依赖、某个只在上一个项目里成立的硬编码路径、甚至是一份早就没人看得懂的加密逻辑。单看一次复制的成本不高但如果你维护过五个以上服务会发现每个服务渐渐长得都不一样了。A服务用morgan打日志B服务用winstonC服务干脆什么日志都不打。错误处理也是五花八门有的用next()把错误丢给Express默认处理有的自己封装了一个非标准格式的失败对象。等到线上出问题要排障的时候排查成本高得离谱。这里有一个非常直观的对比我在团队内部讲过很多次环节没有模板有模板新建项目到跑通约半天到一天五到十分钟日志和错误处理规范每个项目各不相同完全统一新人理解项目结构靠人传人或考古旧代码看目录就知道依赖升级每个服务单独处理模板统一升级后同步落新项目线上排障不同格式来回切换一套格式通吃所以模板不是写给懒人的偷懒工具它是把踩过的坑约定好的规范基础设施代码沉淀成一份可执行的起点。1.2 模板帮你省下的是沟通和试错成本很多人觉得模板的价值是节省初始化时间这个说法太浅了。模板真正的作用是把你和团队在无数个项目里用教训换来的约定固化到文件里。举几个例子。目录该怎么组织错误码怎么返回配置项放哪日志里必须带哪些字段环境变量怎么区分开发和生产这些如果只靠口头约定一定有人漏。但如果模板里就是那么写的新人进来照着目录放代码出的活至少框架上是统一的。我这套模板里连日志请求ID都预设好了。每次请求进来自动生成一个requestId日志里打上traceId错误响应里也带上。所有服务只要基于模板创建排障的时候用同一个requestId就能把网关、服务、数据库日志串起来。这个东西如果等出了问题再想就太晚了。1.3 这套模板适合哪些使用场景适合的场景主要有三类个人开发者想要一个可以直接跑的后端骨架重点是快速验证想法目录和配置比较规范省得后面返工。小团队同时维护多个后端服务统一技术栈和代码风格以后人挪到哪个项目都不用重新适应。团队内部做微服务拆分每个服务不再需要重复搭建基础设施拉模板、改配置、写业务即可。不太适合的场景是项目已经非常庞大并且有自己独特的架构约束这时候模板只能提供参考还有一些极小的脚本型工具只有几十行代码用模板反而笨重。要分清什么场景用模板、什么场景不用这是经验。2. 技术选型先把地基打好再动手2.1 Node.js版本与包管理器怎么定Node.js版本这点必须放在第一位。我踩过大坑之前有同事图新鲜直接装最新版结果项目里某个依赖锁定的版本还不兼容运行的时候崩得一塌糊涂。模板里我建议采用当前LTS版本现在至少是Node.js 20以上。LTS的意思是长期维护版本稳定性和依赖兼容性都有保证。不要追current。包管理器我建议团队统一一种。npm、pnpm、yarn都能用但混着用会出问题。npm是Node自带的学习成本最低只是安装速度和人磁盘占用一般。pnpm胜在安装快、省磁盘而且有内容寻址存储机制多个项目共用一份依赖副本。如果你是个人用推荐pnpm如果团队里的人对命令行工具不敏感用npm最稳。模板仓库里只需要提交一种锁文件。我是用的pnpm所以提交pnpm-lock.yaml如果用npm就提交package-lock.json。这两种锁文件不要混着提交否则同一个项目不同人用不同命令装出来的依赖版本可能不一致。2.2 Web框架Express、Fastify还是NestJS后端框架的选择会直接影响目录设计和代码组织方式。我先给一个对比大家按需求选框架生态成熟度性能学习成本适合场景Express极高中低通用后端、快速交付Fastify较高高中高并发、对性能敏感的服务NestJS高中较高大团队、强规范、TypeScript重度用户模板本身我用Express来演示因为它的中间件生态最完整几乎任何问题都能找到现成人用的方案而且国内大量前后端分离项目就是Express或Koa风格理解起来没有门槛。如果你的团队更追求性能完全可以把这个目录结构平移到Fastify上路由和中间件的写法有一些差异但分层思路是一样的。NestJS则是一个更重的框架自带依赖注入、模块系统、守卫和拦截器适合大型项目和强规范团队。但如果你想保持轻量NestJS这些概念反而是负担。2.3 目录结构按业务逻辑分层而不是按文件类型堆我见过很多项目的目录是按文件类型堆的比如所有controller放一个文件夹所有service放一个文件夹。这种做法在项目小的时候还行一旦业务多起来要找一个订单相关的代码得在controller、service、model三个目录之间来回跳。我的模板里采用这种偏分层的目录结构my-backend-template/ ├── src/ │ ├── config/ # 配置读取与校验 │ ├── controllers/ # 请求参数解析、调用service、返回响应 │ ├── middlewares/ # 中间件鉴权、日志、错误处理等 │ ├── models/ # 数据模型定义与数据库访问 │ ├── routes/ # 路由注册与模块挂载 │ ├── services/ # 核心业务逻辑 │ ├── utils/ # 通用工具函数 │ ├── app.js # 搭建应用、加载中间件和路由 │ └── server.js # 启动HTTP服务 ├── tests/ ├── .env.example ├── .eslintrc.cjs ├── .prettierrc ├── .editorconfig ├── Dockerfile ├── docker-compose.yml └── package.jsonroutes和controllers分开是为了让人一眼看清接口路径和业务逻辑。controllers只做参数校验、调用service、把结果转换成HTTP响应不写业务。services负责真正的业务规则。models层处理数据库访问。如果你想做更彻底的模块化也可以按业务模块分比如src/modules/user/controller.js这类的结构但对小项目来说上述目录已经足够清晰。2.4 配置管理环境变量和配置文件别混在一起配置管理最容易出问题的地方就是开发、测试、生产环境分不清楚。我的原则是环境变量只放环境相关的配置比如端口、数据库地址、jwt密钥不要放业务规则比如某个阈值、某个开关这类配置应该放到应用配置文件里集中管理。模板里使用dotenv来加载.env文件但.env文件只提交示例不提交真是直内容。仓库里放一个.env.example把所有需要的变量名写清楚并附上默认值注释NODE_ENVdevelopment PORT3000 DATABASE_URLpostgresql://postgres:postgreslocalhost:5432/template CORS_ORIGINhttp://localhost:3001然后在src/config/index.js里做统一读取和校验const dotenv require(dotenv); const path require(path); dotenv.config({ path: path.resolve(process.cwd(), .env) }); const config { env: process.env.NODE_ENV || development, port: parseInt(process.env.PORT, 10) || 3000, databaseUrl: process.env.DATABASE_URL, corsOrigin: process.env.CORS_ORIGIN || *, }; if (!config.databaseUrl) { throw new Error(缺少环境变量 DATABASE_URL请检查 .env 文件); } module.exports config;这里有个小技巧启动时做环境变量必填校验宁可启动失败也不要带着缺少关键配置的半成品跑起来。现代应用应该fail fast配置缺了就让它在入口直接报错而不是运行到一半的时候再炸。3. 从零搭建到跑通核心步骤全记录3.1 初始化项目与依赖清单先创建项目目录并初始化npm和git这些都是基础操作但每一步都有目的mkdir my-backend-template cd my-backend-template npm init -y git init接下来安装依赖。我把它分成两类说明一下运行时依赖npm install express dotenv cors helmet morgan compressionexpressWeb框架dotenv加载.env文件cors开启跨域helmet设置安全HTTP头morganHTTP请求日志compressiongzip压缩响应体开发依赖npm install --save-dev nodemon eslint prettiernodemon开发时监听文件变动自动重启eslint代码规范检查prettier代码格式统一如果你要接入数据库后面再装prisma。先不要一次装太多东西模板要克制运行时依赖越少后续维护压力越小。3.2 入口文件与服务启动我强烈建议把app.js和server.js分开。app.js负责创建express应用server.js负责真正监听端口。这样写测试的时候可以直接用supertest对app发出请求不需要真的启动一个端口避免端口冲突也快很多。src/app.js的核心代码const express require(express); const helmet require(helmet); const cors require(cors); const morgan require(morgan); const compression require(compression); const config require(./config); const routes require(./routes); const errorHandler require(./middlewares/error-handler); const notFoundHandler require(./middlewares/not-found-handler); const app express(); app.use(helmet()); app.use(cors({ origin: config.corsOrigin })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(compression()); app.use(morgan(config.env development ? dev : combined)); app.use(/api, routes); app.use(notFoundHandler); app.use(errorHandler); module.exports app;中间件的顺序很重要。helmet和cors这类安全相关的中间件要放在最前面这样任何请求进来先经过安全检查。express.json()必须在路由之前否则拿不到请求体。404处理的中间件放在所有路由之后这样路由都不匹配时才会走到它。错误处理中间件必须放在最后而且它要有四个参数(req, res, next, err)Express才能识别它是错误处理中间件。src/server.jsconst app require(./app); const config require(./config); const server app.listen(config.port, () { console.log(Server is running at http://localhost:${config.port} in ${config.env} mode); }); process.on(SIGTERM, () { server.close(() { process.exit(0); }); });监听SIGTERM是Docker环境下的优雅停机一定要保留。Kubernetes或者Docker stop会发SIGTERM给进程如果你不做处理进程会立刻被强制杀掉当前正在处理的请求会被打断产生大量异常日志和客户端超时。3.3 路由模块化与中间件装配路由不要全部堆在index.js里按业务模块拆开放。示例里的健康检查路由就可以作为一个标准模板src/routes/index.jsconst express require(express); const healthRoutes require(./health.routes); const userRoutes require(./user.routes); const router express.Router(); router.use(/health, healthRoutes); router.use(/users, userRoutes); module.exports router;src/routes/health.routes.jsconst express require(express); const healthController require(../controllers/health.controller); const router express.Router(); router.get(/, healthController.check); module.exports router;src/controllers/health.controller.jsexports.check async (req, res) { res.status(200).json({ status: ok, timestamp: new Date().toISOString(), }); };这种写法虽然多了一层文件但每一层职责非常清楚。路由只做URL映射controller做参数整理和响应输出。以后加一个用户模块只需要新增user.routes.js、user.controller.js和user.service.js然后到routes/index.js里挂一下即可不需要动其他文件。3.4 统一异常处理与日志体系统一异常处理是模板里面价值最高的部分之一。没有它代码里到处都是try/catch或者错误直接抛到Express默认错误页接口返回的是HTML格式的异常信息前端根本没法处理。我先写一个工具函数用来包装异步路由src/utils/async-handler.jsmodule.exports function asyncHandler(fn) { return function (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; };在路由里就可以这么用所有async函数抛出的异常都会自动传到errorHandlerconst asyncHandler require(../utils/async-handler); router.get( /, asyncHandler(async (req, res) { const userList await userService.getUsers(); res.json(userList); }) );统一错误处理中间件src/middlewares/error-handler.jsconst logger require(../utils/logger); module.exports function errorHandler(err, req, res, next) { const status err.status || err.statusCode || 500; const message err.expose ? err.message : 服务器内部错误; if (status 500) { logger.error([${req.id}] ${err.stack || err.message}); } res.status(status).json({ code: status, message, requestId: req.id, }); };这里的关键点对外暴露的错误信息不能带服务器内部堆栈否则很容易泄露敏感信息。日志里记录完整堆栈但响应用户的是通用提示。404处理则单独一个中间件module.exports function notFoundHandler(req, res) { res.status(404).json({ code: 404, message: 找不到 ${req.method} ${req.originalUrl}, }); };日志体系方面morgan负责HTTP访问日志winston负责业务日志和错误日志两者分开使用。morgan是请求层面的winston是代码层面主动打的。模板里我封装了一个轻量loggerconst winston require(winston); const logger winston.createLogger({ level: process.env.LOG_LEVEL || info, format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [new winston.transports.Console()], }); module.exports logger;3.5 数据库接入与健康检查数据库这块我用Prisma做示例。它是现在Node.js生态里最顺手的ORM之一类型安全、迁移工具完善、和TypeScript配合非常好。即使是JavaScript项目也能享受到schema文件带来的清晰感。安装npm install prisma/client npm install --save-dev prisma npx prisma init --datasource-provider postgresqlprisma/schema.prisma里至少定义一个示例模型generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(uuid()) email String unique name String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }生成clientnpx prisma generatesrc/models/prisma.jsconst { PrismaClient } require(prisma/client); const prisma new PrismaClient(); module.exports prisma;健康检查路由里可以加数据库连通性检测const prisma require(../models/prisma); exports.check async (req, res) { let dbStatus up; try { await prisma.$queryRawSELECT 1; } catch (err) { dbStatus down; } res.status(200).json({ status: ok, db: dbStatus, timestamp: new Date().toISOString(), }); };这个健康检查接口是给部署用的不只是给人看。Docker/Kubernetes的探针会周期性地请求/health如果应用进程还在但数据库已经挂了这个接口能准确把状态暴露出来。4. 工程化配置让模板直接达到生产可用4.1 ESLint Prettier把代码风格锁死代码风格这种事靠Code Review讨论太累了。直接在模板里配好ESLint和Prettier创建新项目之后跑一次format全项目风格就统一了。我用的ESLint是8.x原因是可以继续使用.eslintrc.cjs这种传统配置格式很多现成配置和教程可以直接复用。ESLint 9改成了扁平配置初期迁移成本还挺高的对模板来说没有必要追这个新。安装npm install --save-dev eslint^8.57.0 prettier eslint-config-prettier eslint-plugin-prettier创建.eslintrc.cjsmodule.exports { root: true, env: { node: true, es2022: true, }, extends: [eslint:recommended, plugin:prettier/recommended], parserOptions: { ecmaVersion: 2022, sourceType: script, }, rules: { no-console: warn, prettier/prettier: error, }, };.prettierrc{ semi: true, singleQuote: true, trailingComma: all, printWidth: 100, tabWidth: 2 }然后在package.json里加入scripts: { lint: eslint . --ext .js, format: prettier --write \**/*.{js,json,md}\ }4.2 Git提交检查与团队协作基础模板里可以把husky和lint-staged加上让每次提交代码前自动跑lint防止不合格代码进仓库。这个属于团队成熟后必备的工具个人项目可以跳过。安装npm install --save-dev husky lint-staged npx husky installpackage.json里配置lint-stagedlint-staged: { *.js: eslint --fix, *.{js,json,md}: prettier --write }.husky/pre-commit文件#!/usr/bin/env sh npx lint-staged它的作用是本地提交代码的瞬间自动lint并把格式修正如果lint失败提交会直接拦截。这样代码进到远端分支前已经把大部分低级问题挡在门外。4.3 自动化测试骨架模板里至少预留一套最小可用的测试骨架。我推荐vitest比jest更现代、配置更少、跑得更快。安装npm install --save-dev vitest supertesttests/health.test.jsconst request require(supertest); const app require(../src/app); describe(GET /api/health, () { it(应返回 ok 状态, async () { const res await request(app).get(/api/health).expect(200); const body res.body; if (body.status ! ok) { throw new Error(期望 statusok实际是 ${body.status}); } }); });package.json里加scripts: { test: vitest run, test:watch: vitest }注意这里直接用app实例而不启动server这是当初把app和server拆开的直接收益。测试过程中完全不占端口跑完就结束不会出现测试之间端口冲突。4.4 Docker容器化与部署准备模板里一定要预置Dockerfile和docker-compose.yml否则新建服务后第一步到处查怎么部署又要浪费不少时间。DockerfileFROM node:20-alpine WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN npm install --frozen-lockfile COPY . . EXPOSE 3000 CMD [npm, start]docker-compose.ymlservices: api: build: . ports: - 3000:3000 environment: NODE_ENV: production PORT: 3000 DATABASE_URL: postgresql://postgres:postgresdb:5432/template CORS_ORIGIN: http://localhost:3001 depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: template ports: - 5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U postgres] interval: 5s timeout: 5s retries: 5使用npm install --frozen-lockfile而不是npm install是为了依赖完全按照锁文件安装避免本地和CI环境版本不一致。这是很多线上事故的根源必须养成习惯。5. 高频问题排查与避坑实录5.1 Node.js版本切换和安装失败的坑很多新人习惯从官网下载最新版但等来的不是稳定而是兼容性问题。我记得有个热词是error installing 24.21.0: node.js v24.21.0 is not yet released or is not ava我看到那台机器上node版本管理工具试图安装一个还不存在的预发布版本号。正确做法是使用nvm。安装nvm之后维护三套长期使用的版本就够了不要每个项目换一个版本。我的个人实践是模板锁定一个Node版本范围在package.json里用engines字段声明engines: { node: 20 21 }然后项目根目录放一个.nvmrc文件内容只写版本号20.18.0这样只要团队用nvm进入项目目录后一条命令nvm use就能自动切到项目需要的版本。省去互相喊你那边为什么跑不起来哦因为你的node版本不对这种无意义沟通。5.2 npm安装依赖失败锁文件与镜像源npm install失败的原因很多最直接的排查路线是先看错误提示最后几行。常见的几种EACCES权限问题不要用sudo装全局包应该修复npm目录权限或者用nvm管理Node。版本冲突node_modules里已经装过某个包的旧版本依赖解析失败。把node_modules整个删掉重新npm install。网络下载超时可以临时切换镜像源。国内常用的是npmmirror的registrynpm config set registry https://registry.npmmirror.com使用pnpm的话对应为pnpm config set registry https://registry.npmmirror.com注意不要随意升级大版本依赖。模板锁文件的作用就是让所有人在同一个依赖版本集下运行。如果必须升某个依赖单独起一个分支升级并跑完测试不要在主分支上顺手升一下。5.3 端口占用和进程清理开发时最常见的就是端口3000被占用一启动立刻报EADDRINUSE。Linux/macOS下lsof -i :3000 kill -9 PIDWindows下netstat -ano | findstr :3000 taskkill /PID PID /F不过在高频出现的场景里我更推荐让开发端口尽量固定3000同时代码里做一个小小的容错。你可以启动前先检测端口被占用就直接报出清晰的提示提示用户哪个进程占用了端口而不是给一个冷冰冰的底层错误。注意开发环境固定端口没问题生产环境的端口应该交给容器编配或平台配置不要写死。5.4 Windows / Linux 跨平台路径问题Node.js后端项目在很多团队里是跨平台开发的有人用Windows有人用macOS服务器是Linux。这些平台最大的差异之一就是路径分隔符Windows用反斜杠\Linux和macOS用/。如果你的代码里出现const filePath __dirname \\src\\temp;那项目到Linux上必挂。正确做法是用path模块const path require(path); const filePath path.join(__dirname, src, temp);另外在package.json的scripts里如果需要设置NODE_ENVWindows上用NODE_ENVproduction node server.js会失效因为Windows cmd不支持这种语法。统一使用cross-env来跨平台设置环境变量npm install --save-dev cross-envscripts: { start: cross-env NODE_ENVproduction node src/server.js }这个坑我第一次遇到的时候排查了快一个小时后来直接把cross-env写进模板的scripts里再也不讨论哪条命令在Windows不兼容了。5.5 异步接口异常导致进程崩溃Express 4里async函数中抛出的异常不会自动进入错误处理中间件。如果用户请求的接口内部有一步异步操作报错不会变成500响应的JSON而是会变成一个未处理的Promise rejection严重情况下直接把Node进程搞挂后续所有请求全部失败。这就是我在3.4里写asyncHandler的价值。所有异步路由处理器都包一层把异常统一转给next。如果你用Fastify它对async/await的异常捕获天然支持得更好这也是Fastify的一个优势。但Express的场景下请一定保持asyncHandler的习惯。排查这类问题还有一个技巧在入口文件加一个全局的unhandledRejection监听至少让进程在崩溃前把日志打出来process.on(unhandledRejection, (reason) { logger.error(未处理的Promise异常: ${reason}); });这不是救命稻草真正的解法还是每个异步路由都正确捕获错误但监听器能帮助你在开发阶段尽早发现漏网之鱼。6. 模板落地之后怎么维护和演进6.1 模板仓库的版本管理与变更记录模板不是一个一次性交付的交付物它本身就是需要持续维护的代码仓库。我会建议单独建一个模板仓库用git tag来标记版本。比如v1.0.0、v1.1.0每次有比较大的调整就在CHANGELOG.md里记录变更点。使用模板的方式不要直接复制老项目而是从模板仓库拉一个新的分支或者用git clone到一个新目录。有条件的话可以做一个小脚手架CLI一字不漏地复制模板仓库然后自动替换项目名、包名、端口等变量。没有CLI也没关系clone下来然后全局搜索替换backend-template即可。团队里其它服务用了某个早期版本的模板后续模板升级了不必追着老项目改因为老项目可能已经长出自己独有的业务和架构。模板的价值在于新项目从最新的基线开始老项目只需要在必要时做定向修复。6.2 团队内统一模板的推广经验我在团队里推广这套模板的过程中遇到过不少抵触。最常见的反馈是这个目录结构和我们之前不一样看起来不习惯或者加这么多工具学习成本太高了。后来我总结出一个特别有用的做法不要着急一步到位。第一版模板先只做目录结构、入口文件、配置、日志和错误处理这些最核心的部分。跑通之后再逐步加入Docker、测试、linter、husky。每加一项都要写清楚理由。等团队尝到甜头再继续扩展。还有一点比模板本身更重要的就是模板的代码质量要足够高。它会被所有人当作代码范式来参照。如果模板本身里面有些历史遗留写法大家就会一直照着写。定期评审模板代码把它当作一等公民来维护这是我一直坚持的事。6.3 后续还能扩展什么这套模板我已经用了相当长一段时间目前大部分新服务都能在几分钟内拉好并本地跑通。如果再往深走有几个方向值得做支持TypeScript现在很多团队已经整体切TypeScript了模板可以增加一个ts分支。目录结构不变换成tsconfig、类型化的Controller和Service。接入更多中间件比如接口限流、接口幂等、JWT鉴权、权限校验这些可以做成可选模块按需勾选。增加更多数据库适配Prisma的Schema可以同时管理多套数据库模板里预留一个migrations目录方便不同的服务使用不同数据库。增加CI/CD流水线GitHub Actions或者GitLab CI模板让新项目创建后自动跑lint、测试和镜像构建。我个人做模板这件事最大的体会是模板的价值不在于省那十几分钟而在于把团队的共识写进文件里。每个人看到同样的目录结构、同样的错误处理格式、同样的日志规范协作起来几乎不需要花时间解释我们的项目是怎么组织的。如果你也想搭一套自己的Node.js后端项目模版建议从目录结构、入口拆分、统一错误处理和健康检查这四个最小核心开始先把这四样做好整个模板的骨架就立住了。后面需要什么再往这个骨架上长就够了。
返回列表