前置知识

TIP

项目开始前,请确保你已做好以下准备工作

  • 安装Nodejs 建议使用长期支持版本(LTS)
  • 推荐使用 pnpm 作为包管理工具,可以提供更快的安装速度和更小的安装体积

将 npm/pnpm 的源更换为国内镜像,可以提供更快、更稳定的下载体验。如果你有科学上网的能力,还是建议使用官方源,避免有些包在国内无法下载的问题。

bash
# 查看当前源
npm config get registry
pnpm config get registry

# 更换为国内源
npm config set registry https://registry.npmmirror.com
pnpm config set registry https://registry.npmmirror.com

项目初始化

bash
# 创建项目目录
mkdir nodejs-express-api
cd nodejs-express-api

# 初始化项目
pnpm init

# 安装依赖
pnpm install express # 核心包
pnpm install @dotenvx/dotenvx nodemon -D # 使用环境变量和热更新

修改 package.json 文件,添加以下内容

json
{
  // 启用 ESM 模块化
  "type": "module",
  "scripts": {
    "dev": "dotenvx run -- nodemon ./src/server.js"
  }
}

根据 Best Practices for Structuring an Express.js Project 创建项目结构

📁 nodejs-express-api
 ├── 📁 src
 │   ├── 📁 config          # 配置文件 (例如: 数据库, 环境变量)
 │   ├── 📁 controllers     # 业务逻辑 (处理请求/响应)
 │   ├── 📁 models          # 数据库模型 & 模式
 │   ├── 📁 routes          # API 路由定义
 │   ├── 📁 middlewares     # 自定义中间件 (身份验证, 日志记录, 错误处理)
 │   ├── 📁 services        # 业务逻辑或外部API交互
 │   ├── 📁 utils           # 辅助函数和工具
 │   ├── app.js             # 应用入口
 │   └── server.js          # 服务器初始化
 ├── .env                   # 环境变量
 ├── .gitignore             # 忽略版本控制的文件
 ├── package.json           # 项目依赖和脚本
 └── README.md              # 项目说明

简单使用 Expresss

先了解下 Express 的用法,实现简单的 CRUD 接口。

  1. src 目录下创建一个 mock-database.json 文件,模拟数据库
  2. server.js 文件下编写 API 接口

注意

如果需要 body 传参,需要使用 body-parser 中间件。 见 server.js 文件中第 8 行代码

js
import { readFile, writeFile } from "node:fs/promises";
import express from "express";
import bodyParser from "body-parser";

const PORT = 3000;
const app = express();
// parse application/json
app.use(bodyParser.json());

// 构建相对于当前模块的文件路径,而不是依赖运行时的工作目录
const mockDatabasePath = new URL("./mock-database.json", import.meta.url);

async function getTodosData() {
  try {
    const todosData = await readFile(mockDatabasePath, "utf-8");
    const todos = JSON.parse(todosData);
    return todos;
  } catch (error) {
    return [];
  }
}

// 查询全部数据
app.get("/todos", async (_req, res) => {
  try {
    const todos = await getTodosData();
    return res.status(200).json(todos);
  } catch (error) {
    return res.status(500).json({ error: "Failed to fetch todos" });
  }
});

// 根据 id 查询数据
app.get("/todos/:todoId", async (req, res) => {
  const todos = await getTodosData();
  try {
    const todo = todos.find((todo) => todo.id === parseInt(req.params.todoId));
    if (!todo) {
      throw new Error("todo id is not valid");
    }
    return res.status(200).json(todo);
  } catch (error) {
    return res.status(404).json({ error: error.message });
  }
});

// 根据 id 删除数据
app.delete("/todos/:todoId", async (req, res) => {
  debugger;
  const todos = await getTodosData();
  try {
    const updatedTodos = todos.filter((todo) => todo.id !== parseInt(req.params.todoId));
    if (updatedTodos.listen === todos.length) {
      throw new Error("delete todo failed, todo id is not valid");
    }
    await writeFile(mockDatabasePath, JSON.stringify(updatedTodos, null, 2));
    return res.status(200).json({ message: "delete todo success" });
  } catch (error) {
    return res.status(404).json({ error: error.message });
  }
});

// 添加
app.post("/addTodo", async (req, res) => {
  const todos = await getTodosData();
  try {
    const newTodo = {
      id: todos.length + 1,
      ...req.body,
    };
    const newTodos = [...todos, newTodo];
    await writeFile(mockDatabasePath, JSON.stringify(newTodos, null, 2));
    return res.status(200).json(newTodo);
  } catch (error) {
    return res.status(500).json({ error: "add todo failed" });
  }
});

// 修改
app.put("/updateTodo", async (req, res) => {
  const todos = await getTodosData();
  try {
    const { id, ...updates } = req.body;
    const todo = todos.find((todo) => todo.id === parseInt(id));
    if (!todo) {
      throw new Error("update todo failed, todo id is not valid");
    }
    const updatedTodo = { ...todo, ...updates };
    const newTodos = todos.map((todo) => (todo.id === parseInt(id) ? updatedTodo : todo));
    await writeFile(mockDatabasePath, JSON.stringify(newTodos, null, 2));
    return res.status(200).json(updatedTodo);
  } catch (error) {
    return res.status(500).json({ error: error.message });
  }
});

app.listen(PORT, () => {
  console.log(`Server is running at http://localhost:${PORT}`);
});
json
[
  {
    "id": 1,
    "title": "完成项目需求文档",
    "description": "与产品经理确认后,撰写并提交 V2.0 版本的需求文档。",
    "priority": "high",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-19"
  },
  {
    "id": 2,
    "title": "预约牙医",
    "description": "打电话预约下周的牙齿检查和洗牙。",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 3,
    "title": "购买生日礼物",
    "description": "为朋友挑选并购买下周生日聚会的礼物。",
    "priority": "low",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 4,
    "title": "整理电脑文件",
    "description": "清理桌面和下载文件夹,按项目分类归档。",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 5,
    "title": "提交月度报销单",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 6,
    "title": "阅读《原子习惯》第3章",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 7,
    "title": "健身30分钟",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 8,
    "title": "回复客户邮件",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 9,
    "title": "准备周会材料",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 10,
    "title": "缴纳水电费",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 11,
    "title": "备份重要数据",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  },
  {
    "id": 12,
    "title": "规划周末短途旅行",
    "priority": "medium",
    "completed": false,
    "createdAt": "2025-12-12",
    "dueDate": "2025-12-12"
  }
]

src 目录下执行 node server.js 启动服务器,访问 http://localhost:3000/todos 查看效果。

重构路由

为了提高清晰度、可扩展性和可维护性,需要按照上面文件夹拆分不同的代码模块。

txt
PORT = 3000;
js
import { readFile, writeFile } from "node:fs/promises";

// 构建相对于当前模块的文件路径,而不是依赖运行时的工作目录
const mockDatabasePath = new URL("../mock-database.json", import.meta.url);

/**
 * 获取 todos 数据
 * @returns {Promise<Array>} 返回 todos 数据
 */
async function getTodosData() {
  try {
    const todosData = await readFile(mockDatabasePath, "utf-8");
    const todos = JSON.parse(todosData);
    return todos;
  } catch (error) {
    return [];
  }
}

/**
 * 获取 todos 数据
 * @param {Request} req - 请求对象
 * @param {Response} res - 响应对象
 * @returns {Promise<Response>} 响应对象
 */
export async function getTodos(_req, res) {
  debugger;
  try {
    const todos = await getTodosData();
    return res.status(200).json(todos);
  } catch (error) {
    return res.status(500).json({ error: "Failed to fetch todos" });
  }
}

/**
 * 根据 id 获取 todo 数据
 * @param {Request} req - 请求对象
 * @param {Response} res - 响应对象
 * @returns {Promise<Response>} 响应对象
 */
export async function getTodoById(req, res) {
  const todos = await getTodosData();
  try {
    const todo = todos.find((todo) => todo.id === parseInt(req.params.todoId));
    if (!todo) {
      throw new Error("todo id is not valid");
    }
    return res.status(200).json(todo);
  } catch (error) {
    return res.status(404).json({ error: error.message });
  }
}

/**
 * 根据 id 删除 todo 数据
 * @param {Request} req - 请求对象
 * @param {Response} res - 响应对象
 * @returns {Promise<Response>} 响应对象
 */
export async function deleteTodoById(req, res) {
  const todos = await getTodosData();
  try {
    const updatedTodos = todos.filter((todo) => todo.id !== parseInt(req.params.todoId));
    if (updatedTodos.listen === todos.length) {
      throw new Error("delete todo failed, todo id is not valid");
    }
    await writeFile(mockDatabasePath, JSON.stringify(updatedTodos, null, 2));
    return res.status(200).json({ message: "delete todo success" });
  } catch (error) {
    return res.status(404).json({ error: error.message });
  }
}

/**
 * 添加 todo 数据
 * @param {Request} req - 请求对象
 * @param {Response} res - 响应对象
 * @returns {Promise<Response>} 响应对象
 */
export async function addTodo(req, res) {
  const todos = await getTodosData();
  try {
    const newTodo = {
      id: todos.length + 1,
      ...req.body,
    };
    const newTodos = [...todos, newTodo];
    await writeFile(mockDatabasePath, JSON.stringify(newTodos, null, 2));
    return res.status(200).json(newTodo);
  } catch (error) {
    return res.status(500).json({ error: "add todo failed" });
  }
}

/**
 * 更新 todo 数据
 * @param {Request} req - 请求对象
 * @param {Response} res - 响应对象
 * @returns {Promise<Response>} 响应对象
 */
export async function updateTodo(req, res) {
  const todos = await getTodosData();
  try {
    const { id, ...updates } = req.body;
    const todo = todos.find((todo) => todo.id === parseInt(id));
    if (!todo) {
      throw new Error("update todo failed, todo id is not valid");
    }
    const updatedTodo = { ...todo, ...updates };
    const newTodos = todos.map((todo) => (todo.id === parseInt(id) ? updatedTodo : todo));
    await writeFile(mockDatabasePath, JSON.stringify(newTodos, null, 2));
    return res.status(200).json(updatedTodo);
  } catch (error) {
    return res.status(500).json({ error: error.message });
  }
}
js
import express from "express";
import {
  getTodos,
  getTodoById,
  deleteTodoById,
  addTodo,
  updateTodo,
} from "../controllers/todoController.js";

const router = express.Router();

router.get("/todos", getTodos);
router.get("/todos/:todoId", getTodoById);
router.delete("/todos/:todoId", deleteTodoById);
router.post("/todos", addTodo);
router.put("/todos/:todoId", updateTodo);

export default router;
js
import express from "express";
import bodyParser from "body-parser";
import todoRouters from "./routes/todoRouters.js";

const app = express();
// parse application/json
app.use(bodyParser.json());
app.use("/api", todoRouters);

export default app;
js
import { getLocalIP } from "./utils/getLocalIP.js";
import app from "./app.js";

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server is running at:`);
  console.log(`- Local:   \x1b[38;2;130;182;200mhttp://localhost:${PORT}\x1b[0m`);
  console.log(`- Network: \x1b[38;2;130;182;200mhttp://${getLocalIP()}:${PORT}\x1b[0m`);
});
js
import os from "os";

export function getLocalIP() {
  const interfaces = os.networkInterfaces();
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name]) {
      if (iface.family === "IPv4" && !iface.internal) {
        return iface.address;
      }
    }
  }
  return "127.0.0.1";
}

测试: 执行命令 pnpm run dev, 在浏览器打开 http://localhost:3000/api/todos 获取 TODO 列表

Supabase 数据库使用

上面我们都是在使用 mock-database.json 文件来模拟数据库,这种只适合演示demo,生产使用将有诸多问题:

  1. 不支持并发读写。多个用户/进程同时修改文件时,会导致数据损坏或覆盖
  2. 每次读写都需要加载整个文件到内存(尤其是写入时通常需重写整个文件)。随着数据量增大,读写速度急剧下降。
  3. 无法字段限制,无法复杂查询,不方便分析数据等等...

Supabase 是一个基于 PostgreSQL 的开源后端即服务(BaaS),它提供了易于使用的 API 来处理常见的后端任务,如用户认证、数据库操作、文件存储和服务器端函数等

Sequelize ORM 使用

接口限流

接口日志

接口鉴权

接口部署

评论 隐私政策