一、回顾与本篇目标
前面六篇,我们从零开始,用 Node.js 原生 http 模块和 Express 框架,写出了能处理路由、解析参数、响应 JSON 的后端服务。
但有一个关键问题我们一直没解决:数据存在哪里?
目前我们的“数据”都是写死在代码里的——用户列表是一个硬编码的数组,创建用户只是在控制台打印一下,服务重启之后之前创建的数据就全没了。这显然不是真正的后端。真正的后端需要把数据持久化——存到硬盘上,服务重启之后数据还在。
持久化存储有很多种方案:文件、关系型数据库(MySQL)、文档数据库(MongoDB)、缓存(Redis)。这一篇我们从最简单的一种开始——用文件存储数据。
你可能会想:“现在谁还用文件存数据?不都是用数据库吗?”别急。用文件存储的好处是:零安装、零配置,打开就能用。你能直观地看到数据是如何被写入硬盘、如何被读出来的。理解了文件存储的流程,再学数据库只是换了一种存取方式,底层逻辑完全一样。
本篇的目标:
- 学会用
fs模块读写 JSON 文件 - 实现一个用文件存储数据的“用户管理系统”——增删改查全部齐全
- 把文件存储和 Express 路由整合起来,做一个能真正保存数据的后端服务
- 理解“数据持久化”的本质
二、为什么用 JSON 文件存储数据
JavaScript 对象和 JSON 之间的转换非常方便:
JSON.stringify(对象):把 JavaScript 对象变成 JSON 字符串,用于写入文件。JSON.parse(字符串):把 JSON 字符串变回 JavaScript 对象,用于从文件读取。
所以用文件存储数据的流程非常简单:
添加数据 → 把整个数组转成 JSON → 写入文件
读取数据 → 从文件读出 JSON 字符串 → 转成数组 → 返回
这种方案不适合生产环境(并发、性能、数据一致性都不行),但对于学习和小型个人项目来说,完全够用。而且它能让你直观感受到“数据到底是怎么被存起来的”。
三、基础文件读写操作回顾
第三篇学过 fs 模块的基本用法。这里快速回顾一下,直接进入实战。
同步写入文件
const fs = require('fs');
let data = { name: '张三', age: 28 };
// 把对象转成 JSON 字符串,写入文件
fs.writeFileSync('./data.json', JSON.stringify(data, null, 2), 'utf-8');
console.log('数据已写入 data.json');
JSON.stringify(data, null, 2):第三个参数 2 表示缩进 2 个空格,让写入的 JSON 文件格式化好读。不加的话所有内容挤在一行,人没法看。
同步读取文件
const fs = require('fs');
// 读取文件
let rawData = fs.readFileSync('./data.json', 'utf-8');
// 把 JSON 字符串转回对象
let data = JSON.parse(rawData);
console.log('姓名:', data.name);
console.log('年龄:', data.age);
异步版本
实际项目中用异步方法更好,不会阻塞其他请求:
// 异步读取
fs.readFile('./data.json', 'utf-8', function (error, rawData) {
if (error) {
console.error('读文件失败:', error.message);
return;
}
let data = JSON.parse(rawData);
console.log(data);
});
下面我们会用异步方法来实现完整的增删改查。
四、用文件实现“用户数据库”
我们的目标是做一个用户数据管理模块,提供以下功能:
- 获取所有用户
- 根据 ID 获取单个用户
- 添加新用户
- 更新用户信息
- 删除用户
所有数据存储在 users.json 这个文件里,格式是一个数组:
[
{
"id": 1,
"name": "张三",
"email": "zhangsan@example.com"
},
{
"id": 2,
"name": "李四",
"email": "lisi@example.com"
}
]
第 1 步:创建数据存储模块 userStore.js
// userStore.js —— 用户数据存储模块
const fs = require('fs');
const path = require('path');
// 数据文件的路径
const dataFile = path.join(__dirname, 'users.json');
// 读取所有用户(异步)
function getAllUsers(callback) {
fs.readFile(dataFile, 'utf-8', function (error, rawData) {
if (error) {
// 如果文件不存在,返回空数组
if (error.code === 'ENOENT') {
return callback(null, []);
}
// 其他错误,返回错误信息
return callback(error);
}
// 解析 JSON 并返回
let users = JSON.parse(rawData);
callback(null, users);
});
}
// 保存用户数组到文件(异步)
function saveUsers(users, callback) {
let jsonStr = JSON.stringify(users, null, 2);
fs.writeFile(dataFile, jsonStr, 'utf-8', callback);
}
// 根据 ID 查找用户
function getUserById(id, callback) {
getAllUsers(function (error, users) {
if (error) return callback(error);
// 用 find 方法查找匹配的用户
let user = users.find(function (u) { return u.id === id; });
if (!user) {
return callback(new Error('用户不存在'));
}
callback(null, user);
});
}
// 添加新用户
function addUser(newUser, callback) {
getAllUsers(function (error, users) {
if (error) return callback(error);
// 生成新 ID:找到当前最大 ID + 1,如果没有用户则从 1 开始
let maxId = 0;
users.forEach(function (u) {
if (u.id > maxId) maxId = u.id;
});
newUser.id = maxId + 1;
// 添加到数组
users.push(newUser);
// 保存到文件
saveUsers(users, function (error) {
if (error) return callback(error);
callback(null, newUser);
});
});
}
// 更新用户
function updateUser(id, updatedData, callback) {
getAllUsers(function (error, users) {
if (error) return callback(error);
// 查找用户索引
let index = -1;
for (let i = 0; i < users.length; i++) {
if (users[i].id === id) {
index = i;
break;
}
}
if (index === -1) {
return callback(new Error('用户不存在'));
}
// 更新用户数据(保留 id 不变)
users[index] = Object.assign({}, users[index], updatedData, { id: id });
saveUsers(users, function (error) {
if (error) return callback(error);
callback(null, users[index]);
});
});
}
// 删除用户
function deleteUser(id, callback) {
getAllUsers(function (error, users) {
if (error) return callback(error);
// 过滤掉要删除的用户
let newUsers = users.filter(function (u) { return u.id !== id; });
if (newUsers.length === users.length) {
// 没有过滤掉任何用户,说明 ID 不存在
return callback(new Error('用户不存在'));
}
saveUsers(newUsers, function (error) {
if (error) return callback(error);
callback(null, { message: '用户已删除' });
});
});
}
// 导出所有方法
module.exports = {
getAllUsers: getAllUsers,
getUserById: getUserById,
addUser: addUser,
updateUser: updateUser,
deleteUser: deleteUser
};
逐块解释:
dataFile:用path.join(__dirname, 'users.json')拼出数据文件的完整路径,确保无论在哪运行都能找到正确的文件。getAllUsers:读文件 → 解析 JSON → 返回数组。如果文件不存在(ENOENT错误),返回空数组而不是报错。saveUsers:把数组转成格式化的 JSON → 写入文件。getUserById:先获取全部用户,然后用find方法查找匹配的 ID。addUser:先获取全部用户 → 生成新 ID(最大 ID + 1) → 添加到数组 → 保存。updateUser:先获取全部用户 → 找到要更新的用户 → 用Object.assign合并新旧数据(保留 ID) → 保存。deleteUser:先获取全部用户 → 用filter过滤掉要删除的 → 保存。如果过滤前后的数组长度一样,说明没找到,返回错误。
关于回调函数模式:每个函数都接收一个 callback 参数。这是 Node.js 中传统的异步处理方式——callback(error, result)。第一个参数是错误(成功时为 null),第二个参数是结果。后续我们会用 Promise 和 async/await 改写这些代码,但现在先用回调来理解底层机制。
五、把数据存储模块和 Express 整合
有了 userStore.js,Express 路由里的处理函数就变得非常简洁——只需要调用存储模块的方法。
创建 app.js:
// app.js —— 用文件存储用户数据的 Express 服务
const express = require('express');
const userStore = require('./userStore.js');
const app = express();
// 中间件:解析 JSON 请求体
app.use(express.json());
// ========== 用户路由 ==========
// 获取所有用户
app.get('/api/users', function (request, response) {
userStore.getAllUsers(function (error, users) {
if (error) {
return response.status(500).json({ error: '读取数据失败' });
}
response.json(users);
});
});
// 根据 ID 获取单个用户
app.get('/api/users/:id', function (request, response) {
let id = Number(request.params.id);
userStore.getUserById(id, function (error, user) {
if (error) {
return response.status(404).json({ error: '用户不存在' });
}
response.json(user);
});
});
// 添加新用户
app.post('/api/users', function (request, response) {
let newUser = request.body;
// 简单验证
if (!newUser.name || !newUser.email) {
return response.status(400).json({ error: '缺少必填字段:name 和 email' });
}
userStore.addUser(newUser, function (error, createdUser) {
if (error) {
return response.status(500).json({ error: '添加用户失败' });
}
response.status(201).json(createdUser);
});
});
// 更新用户
app.put('/api/users/:id', function (request, response) {
let id = Number(request.params.id);
let updatedData = request.body;
userStore.updateUser(id, updatedData, function (error, updatedUser) {
if (error) {
return response.status(404).json({ error: error.message });
}
response.json(updatedUser);
});
});
// 删除用户
app.delete('/api/users/:id', function (request, response) {
let id = Number(request.params.id);
userStore.deleteUser(id, function (error, result) {
if (error) {
return response.status(404).json({ error: error.message });
}
response.json(result);
});
});
// ========== 启动服务 ==========
app.listen(3000, function () {
console.log('服务已启动,访问 http://localhost:3000');
console.log('');
console.log('用户管理接口:');
console.log(' GET /api/users 获取所有用户');
console.log(' GET /api/users/1 获取 ID 为 1 的用户');
console.log(' POST /api/users 添加新用户');
console.log(' PUT /api/users/1 更新 ID 为 1 的用户');
console.log(' DELETE /api/users/1 删除 ID 为 1 的用户');
});
新出现的知识点:
response.status(201).json(...):链式调用。先设置状态码 201(Created,创建成功),再返回 JSON。以前我们要分两行写,现在一行搞定。Number(request.params.id):路径参数是字符串,但 ID 应该是数字。用Number()转换。- 返回 400:当请求缺少必填字段时,返回 400 Bad Request,并说明缺了什么。
- 返回 201:POST 创建资源成功后,按 HTTP 惯例返回 201 Created,而不是 200。
六、完整测试流程
现在启动服务,用 Postman 完整测试一遍增删改查。
1. 获取所有用户(初始为空)
GET http://localhost:3000/api/users
返回:[](空数组,因为还没添加过用户)
2. 添加第一个用户
POST http://localhost:3000/api/users
Body → raw → JSON:
{
"name": "张三",
"email": "zhangsan@example.com"
}
返回:
{
"id": 1,
"name": "张三",
"email": "zhangsan@example.com"
}
状态码是 201。
3. 添加第二个用户
POST http://localhost:3000/api/users
{
"name": "李四",
"email": "lisi@example.com"
}
返回 id 为 2 的用户。
4. 获取所有用户
GET http://localhost:3000/api/users
返回包含两个用户的数组。
5. 获取单个用户
GET http://localhost:3000/api/users/1
返回张三的数据。
6. 更新用户
PUT http://localhost:3000/api/users/1
{
"name": "张三丰",
"email": "zhangsanfeng@example.com"
}
返回更新后的数据。
7. 删除用户
DELETE http://localhost:3000/api/users/2
返回 {"message":"用户已删除"}。
再次获取所有用户,只剩张三丰了。
8. 测试错误处理
- GET
/api/users/999→ 返回 404 和“用户不存在”。 - POST
/api/users不传 name → 返回 400 和“缺少必填字段”。
此时打开项目文件夹,你会看到一个 users.json 文件,内容就是当前的用户数组。所有数据都持久化在了硬盘上。关掉服务、重新启动,数据还在。
七、现有方案的问题
用文件存储数据虽然简单直观,但有几个明显的问题:
- 并发问题:如果两个请求同时写入文件,数据可能错乱。Node.js 是单线程的,这个问题暂时不太严重,但逻辑上不严谨。
- 性能问题:每次查询都要读取整个文件。用户量小的时候没问题,但如果有几万条数据,每次读整个文件就很慢了。
- 无法高效查询:如果想知道“所有姓张的用户”,必须把整个文件读出来,然后手动遍历筛选。数据库有索引,可以毫秒级完成这种查询。
- 数据一致性:如果在写入文件的过程中服务器断电,文件可能只写了一半,数据就损坏了。
这些问题,正是数据库要解决的。下一篇,我们正式引入 MySQL——真正的数据库系统。
八、本篇动手练习
练习 1:增加文章管理功能
仿照 userStore.js,新建 articleStore.js 和对应的路由,实现文章的增删改查。文章数据存储在 articles.json,字段包括 id、title、content、author。
练习 2:增加搜索功能
在文章路由中增加 GET /api/articles/search?keyword=xxx,返回标题中包含关键词的文章列表。提示:用字符串的 includes() 方法判断标题是否包含关键词。
练习 3:改写为 Promise 版本
把 userStore.js 中的回调函数改成 Promise 写法,然后用 async/await 重写路由处理函数。这会是下一篇讲到数据库之前的一次重要练习。
九、本篇小结
这一篇我们实现了后端开发中最核心的能力——数据持久化:
- JSON 文件作为数据存储:简单直观,适合学习和小型项目。
- 数据存储模块:把文件读写逻辑封装成独立模块,提供增删改查的接口。
- 与 Express 整合:路由处理函数调用存储模块的方法,把请求数据写入文件,从文件读取数据返回给客户端。
- 完整的 RESTful API:GET 获取、POST 创建、PUT 更新、DELETE 删除——标准的资源操作接口。
- 错误处理:文件不存在返回空数组、资源不存在返回 404、参数不完整返回 400。
你现在已经能做出一个“真正有数据”的后端服务了。添加一个用户,刷新页面,数据还在——这就是持久化的意义。下一篇,我们用真正的数据库 MySQL 来替换文件存储,解决并发、性能、查询效率的问题。
下一篇预告
下一篇——《数据库入门——MySQL 与增删改查》:安装 MySQL、创建数据库和表、用 SQL 语句实现增删改查、理解主键和索引、用 Node.js 连接数据库。从“文件当数据库”进化到真正的数据库系统。
后端零基础入门,每周更新。













暂无评论内容