Node+React实战-记账本—后端
本博客基于掘金小册子《Node + React 实战:从 0 到 1 实现记账本》的内容进行整合与编写,旨在构建一个系统的知识网络。
本项目使用的Node框架是Egg
本项目github链接xxMudCloudxx/TallyBook: React+Eggjs+MySql
Eggjs项目结构
1 | egg-project |
如上,由框架约定的目录:
app/router.js
用于配置 URL 路由规则,具体参见 Router。app/controller/**
用于解析用户的输入,处理后返回相应的结果,具体参见 Controller。app/service/**
用于编写业务逻辑层,建议使用,具体参见 Service。app/middleware/**
用于编写中间件,具体参见 Middleware。app/public/**
用于放置静态资源,具体参见内置插件 egg-static。app/extend/**
用于框架的扩展,具体参见 框架扩展。config/config.{env}.js
用于编写配置文件,具体参见 配置。config/plugin.js
用于配置需要加载的插件,具体参见 插件。test/**
用于单元测试,具体参见 单元测试。app.js
和agent.js
用于自定义启动时的初始化工作,具体参见 启动自定义。关于agent.js
的作用,参见 Agent 机制。
由内置插件约定的目录:
app/public/**
用于放置静态资源,具体参见内置插件 egg-static。app/schedule/**
用于定时任务,具体参见 定时任务。
若需自定义自己的目录规范,参见 Loader API
app/view/**
用于放置模板文件,具体参见 模板渲染。app/model/**
用于放置领域模型,如egg-sequelize
等领域类相关插件。
略讲
本项目的后端接口编写的过程中会频繁地和controller,middleware,service和config文件夹打交道
app/router.js
用于配置 URL 路由规则,比如上述初始化代码中的 get
请求,npm run dev
启动项目之后,你可以直接在浏览器中访问启动的端口 + 路径
,默认是 http://localhost:7001/
,你将会拿到 app/controller
文件夹下,home.js
脚本中 index
方法返回的内容。
app/controller/xx
用于解析用户的输入,处理后返回相应的结果。上述我们也提到了,通过请求路径将用户的请求基于 method
和 URL
分发到对应的 Controller
上,而 Controller
要做的事情就是响应用户的诉求。举个例子,我想拿到 A 用户的个人信息,于是我们要在控制器(Controller)里,通过请求携带的 A 用户的 id 参数,从数据库里获取指定用户的个人信息。我画了一个简易流程图如下:
app/service/xx
简单来说,Service
就是在复杂业务场景下用于做业务逻辑封装的一个抽象层。上述初始化项目中未声明 service
文件夹,它是可选项,但是官方建议我们操作业务逻辑最好做一层封装。我们换一种理解方式,Service
层就是用于数据库的查询,我们尽量将粒度细化,这样以便多个 Controller
共同调用同一个 Service
。后续我们链接数据库操作的时候,再进行详细分析。更加详细的描述请移步至 Service 文档。
app/middleware/xx
用于编写中间件,中间件的概念就是在路由配置里设置了中间件的路由,每次请求命中后,都要过一层中间件。在我们后续的开发中,也会利用到这个中间件的原理做用户鉴权。当用户未登录的情况下,是不能调用某些接口的。
当然,你可以每次都在 Controller
判断,当前请求是否携带有效的用户认证信息。接口一多,到处都是这样的判断,逻辑重复。所以,中间件在某种程度上,也算是优化代码结构的一种方式。更加详细的描述请移步至 Middleware 文档。
简而言之
我们每次编写好接口后都需要在router.js
文件中添加对应的get
和post
路由:
1 | module.exports = (app) => { |
而Controller
文件里编写的是我们接口所调用的函数,它会解析用户输入,然后处理后返回对应的结果:
1 | ; |
get
方法一般用ctx.query
来获取输入post
方法一般用ctx.request.body
或ctx.request.header
来获取输入ctx.body
则是我们服务器的响应
而service
文件则是用来编写Controller
获取数据库数据的方法
1 | // app/service/home.js |
我们可以在 Controller
内拿到上述方法,如下所示:
1 | // app/controller/home.js |
该项目的知识点
- token鉴权
- egg 中间件编写
- 数据库的资源获取
Egg
文件资源处理- 一套增删改查
egg-mysql
的多种使用
该项目实现的功能
- 用户的登录注册
- 用户的信息获取及其更改
- 用户资源的上传及获取
- 账单的增删查改
- 账单详细及其数据的获取
登录注册
用户鉴权,一种用于在通信网络中对试图访问来自服务提供商的服务的用户进行鉴权的方法。用于用户登陆到DSMP或使用数据业务时,业务网关或Portal发送此消息到DSMP,对该用户使用数据业务的合法性和有效性(状态是否为激活)进行检查。
鉴权的机制,分为四种:
- HTTP Basic Authentication
- session-cookie
- Token 令牌
- OAuth(开放授权)
本项目用的鉴权模式是 token
令牌模式,出于多端考虑,token
可以运用在如网页、客户端、小程序、浏览器插件等等领域。如果选用 cookie
的形式鉴权,在客户端和小程序就无法使用这套接口,因为它们没有域的概念,而 cookie
是需要存在某个域下。
登录注册的流程如下
注册
UserController
通过ctx.request.body
获取传入的名字和密码- 将获得的信息传入
UserService
,UserService
使用get
方法获取数据库中的用户信息- 若存在信息,则响应返回账户名已被注册
- 将用户的名字,密码及其默认头像默认签名传入
UserService
进行注册,UserService
使用insert
方法将信息插入数据库中 - 编写
response
的消息体- 返回状态码
- 注册成功与否
登录
UserController
通过ctx.request.body
获取传入的名字和密码将获得的信息传入
UserService
,UserService
使用get
方法获取数据库中的用户信息- 若不存在信息,则返回账户不存在
- 若账号密码不对,则返回账号密码错误
使用
app.jwt.sign
方法配置token
编写
response
的消息体返回状态码
登录成功
token
登录的中间件
在app\middleware\jwtErr.js
中编写如下代码
1 | ; |
在router.js
路由中使用
1 | module.exports = app => { |
_jwt
虽然 只传了一个参数(app.config.jwt.secret
),但它实际上 返回了一个函数,而这个返回的函数才是真正的中间件。
get
展开实际上是:
1 router.get("/api/user/test", async function jwtErr(ctx, next) { ... }, controller.user.test);Egg.js 在执行
GET /api/user/test
这个请求时:
- 先执行
jwtErr(ctx, next)
- 解析
token
,如果token
不存在或无效,返回401
- 如果
token
有效,继续执行next()
- 执行
controller.user.test(ctx)
next()
允许请求进入controller.user.test
- 这样,只有通过 JWT 验证的请求,才能进入
test
方法
后续所有的接口都需要_jwt
这个中间件来验证登录
信息获取及其更改
信息获取
- 先获取消息头中的
token
- 将
token
传入app.jwt
中进行解密 - 将解密后的用户名字传入
UserService
中,UserService
通过get
方法获取数据库中的信息 - 编写响应的消息体
- 状态码
- 请求成功消息
- 用户数据
信息更改
因为是
post
方法,通过ctx.request.body
获取需要更改的信息获取消息头中的
token
将
token
传入app.jwt
中进行解密将解密后的用户名字传入
UserService
中,UserService
通过get
方法获取数据库中的信息使用
{...userInfo, signature, avatar}
将原本的签名和头像覆盖掉,并传入UserService
,UserService
使用update
方法更新数据库中的信息app.mysql.update("bill", { ...params }, { id: params.id, user_id: params.user_id })
- 功能:更新表中的记录。
- API:
app.mysql.update(table, values, conditions)
其中:table
是表名。values
是一个对象,包含要更新的字段和值。conditions
是一个对象,包含更新条件。
使用
let result = await app.mysql.update( "user", { ...params, }, { id: params.id, } );
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
- 返回响应的消息体
- 状态码
- 请求成功与否
- 更改后的信息
# 资源的上传及获取
图片地址的结构是 `host + IP + 图片名称 + 后缀`
```js
// Controller/upload.js
async upload() {
const { ctx } = this;
// ✅ 确保 request.files 不为空
if (!ctx.request.files || ctx.request.files.length === 0) {
ctx.body = { code: 400, msg: "没有上传文件" };
return;
}
// 需要前往 config/config.default.js 设置 config.multipart 的 mode 属性为 file
let file = ctx.request.files[0];
// 声明存放资源的路径
let uploadDir = "";
try {
// ctx.request.files[0] 表示获取第一个文件,若前端上传多个文件则可以遍历这个数组对象
let f = fs.readFileSync(file.filepath);
// <-----创建保存文件夹------>
// 1.获取当前日期
let day = moment(new Date()).format("YYYYMMDD");
// 2.创建图片保存的路径
let dir = path.join(this.config.uploadDir, day);
// this.config.uploadDir: 'app/public/upload'
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓
mkdirp.sync(dir); // 新版本的mkdirp要这样子写
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// 旧写法 await mkdirp(dir);
// <-----创建保存文件夹------>
let date = Date.now(); // 毫秒数
// 返回图片保存的路径
uploadDir = path.join(dir, date + path.extname(file.filename));
// 写入文件夹
fs.writeFileSync(uploadDir, f);
} finally {
// 清除临时文件
ctx.cleanupRequestFiles();
}
ctx.body = {
code: 200,
msg: "上传成功",
data: uploadDir.replace(/app/g, ""),
};
}
这里要注意的是,需要将
app
去除,因为我们在前端访问路径的时候,是不需要app
这个路径的,比如我们项目启动的是 7001 端口,最后我们访问的文件路径是这样的http://localhost:7001/public/upload/20250315/1741973858632.jpg
。
账单的增删查改
增
- 根据数据库的
bill
表的属性可知我们要从ctx.request.body
获取6个属性 - 先获取消息头中的
token
- 将
token
传入app.jwt
中进行解密 - 将解密的id同获取的六个属性一同传入
BillService
中,BillService
使用insert
将数据插入数据库中 - 返回响应
删
获取
ctx.request.body
的id
即我们要删除表的id
鉴权操作获取
user_id
将
id
和user_id
传入BillService
中,BillService
使用delete
方法删除对应数据返回响应
查
获取
ctx.query
的id
即我们要查表的id
get
方法用query
鉴权操作获取
user_id
将
id
和user_id
传入BillService
中,BillService
使用get
方法删除对应数据返回响应
改
通过
ctx.request.body
获取账单的所有属性鉴权操作获取
user_id
将所有属性及其
user_id
传入BillService
中,BillService
通过update
方法更新数据返回响应
账单详细及其数据获取
账单列表获取
我们需要的数据类型是
1 | [ |
并且我们前端还需要做滚动加载更多,所以服务端是需要给分页的。于是就需要在获取 bill
表里的数据之后,进行一系列的操作,将数据整合成上述格式。
当然,获取的时间维度以月为单位,并且可以根据账单类型进行筛选。上图左上角有当月的总支出和总收入情况,我们也在列表数据中给出,因为它和账单数据是强相关的。
于是,我们打开 /controller/bill.js
添加一个 list
方法,来处理账单数据列表:
1 | async list() { |
需要注意的是我们在数据库中的date是时间戳,其单位是毫秒,所以对于
2025-03-15 23:30:03
,我们在数据库中存储的是1742052603000
多了3个0
账单数据
我们最终要返回的数据机构如下:
1 | { |
1 | async data() { |
语法
reduce()
方法详解
reduce()
是 JavaScript 数组的高阶方法,用于对数组中的元素进行 累加、归纳、合并,最终返回一个 累积计算的结果。
📌 语法
1 | array.reduce((accumulator, currentValue, currentIndex, array) => { |
accumulator
(累加器):保存上一次reduce
迭代的返回值。currentValue
(当前值):当前正在处理的数组元素。currentIndex
(当前索引):当前元素的索引(可选)。array
(原数组):正在被遍历的数组(可选)。initialValue(初始值)
:accumulator
的初始值(可选)。- 如果提供了
initialValue
,则accumulator
的初始值就是initialValue
,并从array[0]
开始迭代。 - 如果 **未提供
initialValue
**,则accumulator
默认等于array[0]
,并从array[1]
开始迭代。
- 如果提供了
🔍 reduce()
的基本用法
1️⃣ 计算数组总和
1 | const numbers = [1, 2, 3, 4, 5]; |
acc
初始值是 0
,每次累加当前 cur
。
计算过程:
1
2
3
4
5(0 + 1) -> 1
(1 + 2) -> 3
(3 + 3) -> 6
(6 + 4) -> 10
(10 + 5) -> 15
2️⃣ 找最大值
1 | const numbers = [10, 25, 88, 3, 50]; |
计算过程:
1
2
3
4Math.max(10, 25) -> 25
Math.max(25, 88) -> 88
Math.max(88, 3) -> 88
Math.max(88, 50) -> 88
3️⃣ 统计元素出现次数
1 | const fruits = ['apple', 'banana', 'apple', 'orange', 'banana', 'apple']; |
acc[cur]
记录当前水果出现的次数,如果不存在则默认为0
。
4️⃣ 数组对象按属性归类
1 | const people = [ |
- 作用: 把相同
age
的人归类到同一个数组。
🚀 在你的 Egg.js 代码中的 reduce()
作用
你的代码里用 reduce()
按照 YYYY-MM-DD
归类账单:
1 | let listMap = _list.reduce((curr, item) => { |
👀 它在干什么?
reduce()
遍历账单列表_list
:
curr
:存储已归类的账单数据。item
:当前账单项。
按
YYYY-MM-DD
归类账单:
- 如果
curr
里已经存在该日期,就把item
加入已有的bills
。 - 如果
curr
里没有该日期,新建{ date, bills: [item] }
。
- 如果
最后按时间倒序排序。
🎯 reduce()
vs map()
vs forEach()
方法 | 作用 | 是否返回新数组 | 主要用途 |
---|---|---|---|
reduce() |
归纳 & 计算 | ❌ 需要手动返回 | 统计、分组、累加 |
map() |
映射转换 | ✅ 返回新数组 | 对数组元素进行转换 |
forEach() |
仅遍历 | ❌ 无返回值 | 仅执行操作,无返回值 |
📝 结论:如果你要 累加、归类、统计,
reduce()
是最合适的选择!