3. 构建第一个 Node.js 微服务

微服务的第一步不是“拆得多细”,而是先让一个服务能够独立启动、独立监听端口、独立暴露接口,并且能被外部稳定探测到存活状态。本节我们用一个“商品查询服务”作为示例,使用 Express 或 Fastify 创建 HTTP 服务,定义健康检查与业务路由,通过环境变量管理端口与运行模式,最后在本地运行并用调试工具验证行为。示例中的端口、路径和返回数据均为教学假设,不代表任何真实线上配置。

一、选择框架:Express 与 Fastify 的取舍

Express 生态成熟、中间件丰富、资料多,适合快速搭建和团队已有 Express 经验的情况;Fastify 性能更高、内置 JSON Schema 校验和序列化优化,适合对吞吐和请求校验有要求的服务。两者都能满足“第一个微服务”的需求。下面以 Fastify 为主示例,因为它对路由定义、请求校验和日志输出更结构化;同时给出 Express 的等价写法,便于对照。

初始化项目时,先创建目录并生成 package.json,再安装依赖。Fastify 示例安装 fastify 与 fastify-plugin(后者用于插件封装,可选);Express 示例安装 express。开发期建议安装 nodemon 或使用 Node.js 内置的 --watch,避免每次修改都手动重启。

mkdir product-service && cd product-service
npm init -y
npm install fastify
npm install --save-dev nodemon

二、用环境变量配置端口与运行模式

微服务不应把端口、主机、日志级别写死在代码里。约定使用 PORT、HOST、NODE_ENV、LOG_LEVEL 四个环境变量:PORT 默认 3000,HOST 默认 0.0.0.0(便于容器或局域网访问),NODE_ENV 默认 development,LOG_LEVEL 默认 info。本地开发可以用 .env 文件配合 dotenv 加载,但生产环境应通过部署平台注入,不要把密钥提交到仓库。

# .env(仅本地开发使用,不要提交到 Git)
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
LOG_LEVEL=info

读取时统一做一次解析和默认值兜底,避免每个模块各自读 process.env 导致行为不一致。例如:

const config = {
  port: Number(process.env.PORT) || 3000,
  host: process.env.HOST || '0.0.0.0',
  env: process.env.NODE_ENV || 'development',
  logLevel: process.env.LOG_LEVEL || 'info'
};

三、定义健康检查与业务路由

健康检查通常分两类:存活检查(liveness)只回答“进程还在不在”,不依赖下游;就绪检查(readiness)回答“能不能接流量”,可能依赖数据库或缓存。第一个微服务先实现存活检查 GET /health,返回 200 与固定结构;再实现业务路由 GET /api/products/:id,根据路径参数返回商品数据,找不到时返回 404。

Fastify 写法如下,注意路由注册、参数校验和错误处理:

const fastify = require('fastify')({ logger: { level: config.logLevel } });

fastify.get('/health', async () => {
  return { status: 'ok', service: 'product-service', uptime: process.uptime() };
});

fastify.get('/api/products/:id', {
  schema: {
    params: {
      type: 'object',
      required: ['id'],
      properties: { id: { type: 'string', pattern: '^[0-9]+$' } }
    }
  }
}, async (request, reply) => {
  const { id } = request.params;
  const product = { id, name: '示例商品', price: 99.9, stock: 10 };
  if (id === '999') {
    return reply.code(404).send({ code: 'NOT_FOUND', message: '商品不存在' });
  }
  return product;
});

fastify.listen({ port: config.port, host: config.host })
  .then(() => fastify.log.info(`服务已启动:http://${config.host}:${config.port}`))
  .catch(err => { fastify.log.error(err); process.exit(1); });

Express 等价写法更直观,但需要自己处理 JSON 解析和错误中间件:

const express = require('express');
const app = express();
app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok', service: 'product-service', uptime: process.uptime() });
});

app.get('/api/products/:id', (req, res) => {
  const { id } = req.params;
  if (id === '999') {
    return res.status(404).json({ code: 'NOT_FOUND', message: '商品不存在' });
  }
  res.json({ id, name: '示例商品', price: 99.9, stock: 10 });
});

app.listen(config.port, config.host, () => {
  console.log(`服务已启动:http://${config.host}:${config.port}`);
});

四、本地运行、调试与结果判断

启动命令建议在 package.json 中固定为 npm run dev,使用 nodemon src/server.js 或 node --watch src/server.js。启动后应观察日志中是否出现监听地址与端口;若端口被占用,会抛出 EADDRINUSE,此时应更换 PORT 或结束占用进程。调试时可用 node --inspect 配合 Chrome DevTools,在路由处理函数中打断点,观察 request.params、request.query 与返回值。

验证分三步:第一,访问 GET /health,期望 HTTP 200 且 status 为 ok;第二,访问 GET /api/products/1,期望返回商品 JSON;第三,访问 GET /api/products/999,期望 HTTP 404 且 code 为 NOT_FOUND。若返回 500,优先查看服务端日志中的堆栈;若返回 404 但路径正确,检查路由前缀与大小写;若连接被拒绝,检查 HOST 是否为 0.0.0.0 以及防火墙设置。

curl -i http://localhost:3000/health
curl -i http://localhost:3000/api/products/1
curl -i http://localhost:3000/api/products/999

下面这张图把本节的核心结构串起来:环境变量进入配置对象,配置对象驱动服务启动;服务内部同时挂载健康检查与业务路由;外部请求按路径分流,最终得到 200、404 或 500 三类可判断结果。图中同时标出本地调试时最值得观察的日志与断点位置。

第一个 Node.js 微服务:配置 → 启动 → 路由 → 请求判断 环境变量(.env / 平台注入) PORT=3000 HOST=0.0.0.0 NODE_ENV=development LOG_LEVEL=info config 配置对象 port / host env / logLevel 默认值兜底 Fastify / Express 实例 listen(port, host) logger.level 路由注册 错误处理 路由分流 GET /health 存活检查:不依赖下游 GET /api/products/:id 业务路由:参数校验 + 数据返回 200 OK status=ok / 商品 JSON 404 Not Found code=NOT_FOUND 500 Internal Error 查看服务端堆栈日志 调试 断点 日志

把上述结构落地后,你就得到了一个可独立运行、可被探测、可被调试的最小微服务。后续接入数据库、消息队列或服务注册发现时,健康检查与配置读取这两条主线仍然保持不变,只是就绪检查会加入对下游依赖的判断。