七:数据存在哪里——文件读写与简单存储

一、回顾与本篇目标

前面六篇,我们从零开始,用 Node.js 原生 http 模块和 Express 框架,写出了能处理路由、解析参数、响应 JSON 的后端服务。

但有一个关键问题我们一直没解决:数据存在哪里?

目前我们的“数据”都是写死在代码里的——用户列表是一个硬编码的数组,创建用户只是在控制台打印一下,服务重启之后之前创建的数据就全没了。这显然不是真正的后端。真正的后端需要把数据持久化——存到硬盘上,服务重启之后数据还在。

持久化存储有很多种方案:文件、关系型数据库(MySQL)、文档数据库(MongoDB)、缓存(Redis)。这一篇我们从最简单的一种开始——用文件存储数据

你可能会想:“现在谁还用文件存数据?不都是用数据库吗?”别急。用文件存储的好处是:零安装、零配置,打开就能用。你能直观地看到数据是如何被写入硬盘、如何被读出来的。理解了文件存储的流程,再学数据库只是换了一种存取方式,底层逻辑完全一样。

本篇的目标:

  1. 学会用 fs 模块读写 JSON 文件
  2. 实现一个用文件存储数据的“用户管理系统”——增删改查全部齐全
  3. 把文件存储和 Express 路由整合起来,做一个能真正保存数据的后端服务
  4. 理解“数据持久化”的本质

二、为什么用 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 文件,内容就是当前的用户数组。所有数据都持久化在了硬盘上。关掉服务、重新启动,数据还在。

七、现有方案的问题

用文件存储数据虽然简单直观,但有几个明显的问题:

  1. 并发问题:如果两个请求同时写入文件,数据可能错乱。Node.js 是单线程的,这个问题暂时不太严重,但逻辑上不严谨。
  2. 性能问题:每次查询都要读取整个文件。用户量小的时候没问题,但如果有几万条数据,每次读整个文件就很慢了。
  3. 无法高效查询:如果想知道“所有姓张的用户”,必须把整个文件读出来,然后手动遍历筛选。数据库有索引,可以毫秒级完成这种查询。
  4. 数据一致性:如果在写入文件的过程中服务器断电,文件可能只写了一半,数据就损坏了。

这些问题,正是数据库要解决的。下一篇,我们正式引入 MySQL——真正的数据库系统。

八、本篇动手练习

练习 1:增加文章管理功能

仿照 userStore.js,新建 articleStore.js 和对应的路由,实现文章的增删改查。文章数据存储在 articles.json,字段包括 idtitlecontentauthor

练习 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 连接数据库。从“文件当数据库”进化到真正的数据库系统。

后端零基础入门,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容