前一部分我们完成了”发表日记”的前端功能, 本部分我们对接服务器端, 实现完整的发表日记功能.

26. 封装小程序端网络请求方法

目前为止, 小程序端与服务器端仅有一个网络请求的对接, 即: 请求主界面数据. 可以想见, 当发布日记, 获得日志详情信息等功能加入后, 网络请求会越来越多, 事情也会变得越来越复杂…

为了让我们在远行的道路上走得更轻松, 更有条理, 在开始后续内容之前, 我们先对现有的小程序端代码进行一些调整, 对网络请求进行封装, 以提高代码的复用性.

在小程序端的utils文件夹下新建文件:api.js, 我们将把小程序端与服务器端交互的相对通用的代码放到里面.

Code 26-1: /utils/api.js

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
// 服务器地址
const SERVER_API = 'http://localhost:8000';

// 向服务器发送http/https请求
const request = (url, data) => {
return new Promise((resolve, reject) => {
wx.showLoading({ title: '加载数据中...'});
wx.request({
url: `${SERVER_API}${url}`,
method: 'POST',
data: data || {},
success: (resp) => {
wx.hideLoading();
if (resp.statusCode === 200) {
resolve(resp.data);
} else {
wx.showToast({ title: '网络请求失败: ' + resp.statusCode, icon: 'none' });
}
},
fail: (err) => {
wx.showToast({ title: '网络请求失败', icon: 'none' });
reject(err);
}
});
});
}

module.exports = {
request
}

上面这段代码是不是感觉似曾相识? 呵呵, 我只是把原先学过的知识组装了一下而已…

总体来看, api.js 是一个模块(module), 在其中定义了request方法并向外暴露.

  • 第 2 行, 定义SERVER_API常量, 其值是服务器地址

    因为即便是不同的网络请求, 服务器地址这个前缀应该是不会变化的, 所以这里定义成一个常量. 如果未来服务器地址变化了, 那么修改 SERVER_API 的值即可.

  • 第 5 ~ 26 行, 使用 Promise 封装微信小程序原生的 wx.request() 方法. 若忘记什么是 Promise 的话, 回去看下21.1节

    • 第 9 行, 使用前面定义的 SERVER_API 和传入参数 url 拼接成真正的网络请求地址
    • 第 10 行, 注意这里封装的 request 方法统一使用 HTTP POST 请求(后文中将对服务器端代码作相应修改)
    • 第 11 行, data || {} 的意思是: 若 data 中有值, 则取 data 的值, 否则取 {} , 即: 给参数 data 一个默认值 {}. 这种写法在项目中经常用到, 这只是 JavaScript 的基础语法, 顺带提一下.
    • 特别注意: 微信小程序框架中的 wx.request() 方法仅在调用失败时走 fail回调(第 20~ 23 行 ), 如果网络请求完成, 即使服务器端出错, 返回错误码, 比如: 404, 500, 仍然会走success回调 ( 与其它技术不同, 差评! ). 所以, 第 14 ~ 18 行我们还需要对 resp.statusCode 进一步判断, 若为 200 才是真正的”成功”. ( 参见: wx.request() )

关于 HTTP 状态码参见维基百科 - HTTP状态码

一般而言, 我们只需判定状态码(resp.statusCode)为 200 则认为一切正常

接着对pages/index/index.js进行相应修改…

Code 26-2: pages/index/index.js (局部)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const api = require('../../utils/api');

// 获得BackgroundAudioManager
const audioPlayer = wx.getBackgroundAudioManager();

Page({
data: {
curAudioDiaryIndex : -1
},
onLoad: async function () {
// 加载并填充初始界面数据
const firstPageData = await api.request('/getFirstPageData');
this.setData({...firstPageData});

// 监听BackgroundAudioManager的各种事件
// 开始播放
audioPlayer.onPlay(() => {
this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: true});
});
...
}
.....
})

注意, 上面的代码仅是 index.js 代码的一部分, 在原来的基础上修改了 2 个地方: 第 1 行 和 第 10 ~ 13 行, 其余代码仅为了便于你找到修改位置.

  • 第 1 行, 引入刚才新增的 api.js 模块, 注意括号中的那个路径别写错

  • 第 10, 12 行, 关键字 asyncawait 别搞漏了. 忘记这是什么东西的话再看一下第21.1节.

  • 第 12 行, 这里把获取小程序首页界面数据的 URL 换成了 /getFirstPageData ( 原本是 “/“ ) .

    原因: 未来我们会有更多发送到服务器的网络请求, 最好每一个请求的 URL 都稍微有点内涵(看名字就能猜到是干什么的). 另外, 也最好不要随意去污染 “/“ 这个宝贵的根路径, 这和不要把所有文件都堆在项目根目录是一样的道理.

    当然, 既然这里变更了请求的地址(URL), 那么服务器端程序也要进行相应修改(见后文)

  • 第 13 行, 这里用到了 ES6 中的扩展运算符 (spread, 3个点), 意为将后面的 firstPageData 对象”拆开”.

    举例: const src = { a: 1, b: 2 }; const des = {…src, c: 3}; 则 des 为 {a: 1, b: 2, c: 3}

    想一想, firstPageData 里装的什么, 你大概就能明白第 13 行代码的意思

    再想一想, 为什么第 13 行不写成 this.setData({ firstPageData });

其它还有不明白的话, 参照 Code 26-1, 相信你一定能看懂…

现在, 如果你保存/刷新了小程序端程序, 会发现小程序会报 网络请求失败: 404, 这其实是 Code 26-1 第 17 行输出的.

状态码 404 是什么鬼?

呵呵, 前面已经提到了, 小程序端的网络请求统一换成了 POST 方式(Code 26-1, 第 10 行), 服务器端得作对应修改.

同时, 请求的URL也换了, 也一并修改了吧, 刚好在同一个地方…

**Code 26-3: 服务器端代码 index.js (局部) **

1
2
3
4
5
6
.....
// 获得首页数据
app.post('/getFirstPageData', async function(req, resp) { // 原为get, 现应改为post
....
});
....

重新启动一下服务器端, 刷新小程序端, 是不是一切 OK 啦 ~

记住这个坑, 你以后会经常掉进去的…

27. 实现”发表日记”

目前为止, 我们小程序的发表日记功能完成了一半: 填写日记标题/内容,设置封面图片, 录音…

但轻触发表日记按钮之后 …. 什么都没发生…

下面是小程序端 /pages/diary/diary.js最后面几行的代码…

1
2
3
4
5
// 发表日记
publishDiary: function() {
const diary = this.data.diary;
console.log(diary);
}

嘿嘿, 看到了吧, 我们确实还没实现真正的发表日记功能, 本节我们就来搞定它 ~

说起这”发表日记”啊, 其实还真不简单 ~

在一般的项目中, 大多数表单页面的数据提交通常只涉及简单数据, 比如: 在界面上放一些框框让用户填完后提交. 并不会像我们的小程序的这个”发表日记”功能这么复杂, 又有填写的框框(日记标题/内容), 又有图片, 还有音频… 因此, 相对而言, 还是有点点小难度的 ~

其实, 无论是做网站, 还是做小程序或是别的什么东东, 只要涉及文件上传功能的开发, 都要比一般的表单数据提交稍困难, 这也是很多同学经常遇到的一个坎, 这此我还专门写了一篇博文, 有空可以看看: 文件上传怎么做

当然, 现在还是继续跟着往下走吧 ~ ( 记得你有个课外阅读材料需要读哦~ )

再罗嗦两句, 呵呵 ~

在一般的网站开发中, 文件数据和普通表单字段数据是可以一起传到服务器端的, 即一个网络请求即可搞定.

当然, 在微信小程序中也是可以这样做的… 嘿嘿, 那还有什么好说的, BB 半天 …

问题在于微信小程序所提供的文件上传 API 不支持多文件同时上传啊 ~

这里我们的发表日记时将有 2 个文件需要上传: 封面图片, 音频文件. 真是醉了 ~

所以, 我们还得专门为了补微信小程序框架的窟窿, 想想天法…

大体的思路是这样滴…

(1) “发表日记”时, 先把封面图片上传到服务器端的临时文件夹, 返回服务器端文件名, 小程序端记录

(2) 上传音频文件, 同理, 服务器端将音频文件存储于临时文件夹, 返回音频文件名, 小程序端记录

(3) 发起第三次网络请求, 将日记完整信息(标题, 内容, 封面图片文件名, 音频文件名) 一起通过普通 request 请求方式传送到服务器端.

(4) 服务器端将收到的完整日记信息写入数据库, 搞定 ~

有同学可能有疑问, 为何不将(2)(3)两步合并? 确实可以这样做, 因为微信小程序上传文件的 API 确实支持文件和普通字段数据一起上传.

但是, 但本教程为了把服务器端接收上传文件的接口做得相对通用, 或者说是相对简单一些, 因而未把(2)(3)两步合并.

呵呵 ~ 不明白的话, 就当我没说, 继续就是了…

网上可以找到支持微信小程序多文件上传的第3方库, 但试用了一下, 不好用, 放弃…

如果你有更好的解决思路, 欢迎讨论 ~

好了, 一不小心又罗嗦出一大段来… 动手吧 ~

27.1 封装上传文件 API

与第26节类似, 为了后续开发更舒爽, 我们照例把微信小程序框架中上传文件的 API 封装到api.js模块中去…

Code 27-1: /utils/api.js

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
58
59
60
61
62
63
// 服务器地址
const SERVER_API = 'http://localhost:8000';

// 向服务器发送http/https请求
const request = (url, data) => {
return new Promise((resolve, reject) => {
wx.showLoading({ title: '加载数据中...'});
wx.request({
url: `${SERVER_API}${url}`,
method: 'POST',
data: data || {},
success: (resp) => {
wx.hideLoading();
if (resp.statusCode === 200) {
resolve(resp.data);
} else {
wx.showToast({ title: '网络请求失败: ' + resp.statusCode, icon: 'none' });
}
},
fail: (err) => {
wx.showToast({ title: '网络请求失败', icon: 'none' });
reject(err);
}
});
});
}

// 上传文件
const uploadFile = (url, data, localFilePath, processCallback) => {
return new Promise((resolve, reject) => {
wx.showLoading({ title: '文件上传中...'});
const uploadTask = wx.uploadFile({
url: `${SERVER_API}${url}`,
method: 'POST',
filePath: localFilePath,
name: 'file',
formData: data || {},
header: {
"Content-Type": "multipart/form-data",
},
success: (resp) => {
wx.hideLoading();
if (resp.statusCode === 200) {
resolve(resp.data);
} else {
wx.showToast({ title: '上传文件失败: ' + resp.statusCode, icon: 'none' });
}
},
fail: (err) => {
wx.showToast({ title: '上传文件失败', icon: 'none' });
reject(err);
}
});
uploadTask.onProgressUpdate((resp) => {
if (processCallback) processCallback(resp, uploadTask);
})
});
}

module.exports = {
request,
uploadFile
}

上述代码在 Code 26-1的基础上增加了第 28 ~ 58 行, 以及第 62 行.

文件上传部分的内容与此前对 wx.request() 的封装(第 4 ~ 26 行)差不多, 只提几点:

  • 文件上传使用的 API 是: wx.uploadFile(), 文档传送门

  • 第 39 行, 上传文件时需要设置 request 的”头”, 具体原因参看: 文件上传怎么做

  • 第 54 ~ 56 行, 上传过程中的进度更新回调, 用于给用户反馈一个上传进度条之类的东东. 使用了 wx.uploadFile() API 提供的机制.

    不过… 本教程只是留了一个口在这里, 提醒你有这么个玩意.

    实际上, 我们没搞这么高档, 没做上传进度提示, 自己脑补一下. 或者… 自己试着写写, 其实, 真心不难!

27.2 发表日记

27.2.1 小程序端准备

废话不多说… 小程序端, 修改 diary.js 中的代码…

Code 27-2: /pages/diary/diary.js (局部)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const api = require('../../utils/api');

// ......

// 发表日记
publishDiary: async function() {
const diary = this.data.diary;
try {
diary.coverServerFileName = await api.uploadFile('/upload', null, diary.cover);
diary.audioServerFileName = await api.uploadFile('/upload', null, diary.audioPath);
await api.request('/publishDiary', diary);
wx.showToast({ title: '日记已发表~'});
} catch(e) {
wx.showToast({ title: '发表日记失败', icon: 'none'});
}
}

注意, 上述代码省略了N行 ~ 只列出有变化的部分而已…

  • 第 1 行, 别忘记哦, 需要引入 api.js 模块, 我们和服务器端打交道的东西都被封装在里面了.

  • 第 9 ~ 12 行, 很简单, 猜都能猜到, 依次上传封面图片, 上传音频文件, 上传完整的日记数据, 显示一个”成功提示”.

    只是注意几个小细节…

    • 代码中的那些 await 别搞漏了, 还有第 6 行的 async 哦, 眼睛别大 ~
    • 按照前面提到的”解题思路”, 第 9, 10 行, 将封面图片和音频文件上传后, 分别把服务器端返回的服务器端文件名 ( diary.coverServerFileName, diary.audioServerFileName ) 记录下来, 在第 11 行提交完整的日记数据时一并带到服务器端.
  • 上面的代码装到一个 try … catch… 中, 以对付那些意想不到的幺蛾子 ~

是不是没想到会这么简单, 呵呵 ~ 这就是代码封装与复用的力量 ~

当然, 我们还需要跑到服务器端去实现相应的 2 个接口:

(1) /upload: 对接 Code 27-2 第 9, 10 行 ( 下文第 27.2.2 节 )

(2) /publishDiary: 对象 Code 27-2 第 11 行 ( 下文第 27.2.3 节 )

27.2.2 对接文件上传接口

对接小程序端文件上传接口, 这个事 Express 自己搞不定, 需要借助第 3 方库. 在 Express 的文档中推荐了几个, 这里我们选用 multer

打开控制台, 在服务器端项目的根目录下执行:

1
2
npm install multer --save
npm install uuid --save

第 1 行, 安装 multer

第 2 行, 安装另一个名叫 uuid 的第 3 方库. 这是什么? 呵呵, 后面你就会知道… 这里只是好不容易打开控制台, 就顺手把它也安装了.

编辑服务器端的 index.js 文件

**Code 27-3: index.js (局部) **

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
const express = require('express');       // 引入Express
const multer = require('multer');
const uuid = require('uuid');

const app = express(); // 初始化Express
const port = 8000; // 使用8000端口

const tempDir = 'temp'; // 临时文件夹

// 上传文件
const storage = multer.diskStorage({
destination: tempDir,
filename: function (req, file, cb) {
cb(null, uuid.v1() + file.originalname.substring(file.originalname.lastIndexOf('.')));
}
});
const upload = multer({ storage });
app.post('/upload', upload.single('file'), function (req, resp) {
resp.send(req.file.filename);
});

// 启动服务器端程序
app.listen(port, function() {
console.log('启动成功! 快试试吧~ http://localhost:' + port + '/');
});

注意, 上面的代码只是与上传文件相关的部分, 你应该在你原先的代码中找到合适的位置, 将它们插入.

  • 第 2 ~ 3 行, 引入 multer 和 uuid 库

  • 第 16 ~ 18 行, 看样子有点面熟… 呵呵, 确实长得很像此前写过的获取首页数据的代码. 只是其中多了一个东东: upload.single('file'), 这是 multer 对 express 框架进行改造后的产物, 呵呵 ~

    简单来说, multer 对 express 的 post() 方法进行了改造, 为它注入的接收上传文件的能力.

    • upload.single(‘file’) 表示可接收一个前端上传的名为 file 的个文件. 注意, 不是指的上传的文件名必须是 file, 而是文件字段域的名称须是 file.

      此时, 你应该回到 Code 27-1, 看一看第 36 行

    • 第 17 行, 向前端返回服务器端保存的文件名. 具体是怎么来的… 继续看下去就知道了…

  • 第 15 行, 使用 9 ~ 14 行的配置初始化 multer 对象, 取名叫upload, 而 upload 又在第 16 行用到, 呵呵~

  • 第 9 ~ 14 行, 使用 multer.diskStorage() 配置 multer, 详细的使用方法强烈建议参看multer文档, 10 分钟绝对可以看完.

    • 第 10 行, 指定上传文件保存的位置, 这里指定保存到项目根目录下的 temp 文件夹中. ( mutler 会自动创建 )

    • 第 11 ~ 13 行, 为上传的文件取个名字…

      首先, 要明白一点, 你不应该把上传的文件直接按原始文件名保存到服务器端, 万一多次上传的文件名相同, 这不就覆盖了吗? 呵呵 ~ ( 当然还有更多, 更充分理由让你对用户上传的文件进行重命名, 暂不扯远了, 照做就行 )

      默认情况下, multer 会自己生成一个文件名保存上传的文件, 但不带扩展名

      所以, 这里我们自己取名字…

      uuid.v1()将取得一个 UUID 值, 用作文件名. 关于 UUID 是什么, 简单说就是一个(基本)不可能重复的字符串, 至于详细解释, 自行百度吧…

      所以, 回头说一句, 我们使用了另一个第 3 方库: uuid, 前面顺道安装了.

      file.originalname.substring(file.originalname.lastIndexOf('.'))则是从原始文件名中取得”扩展名”, 如 .mp3

好了, 现在如果你想测试一下文件上传功能的话, 可以把 Code 27-2 中的第 11 ~ 12 行暂时换成 console.log(diary);

然后, 打开小程序端的发表日记页面, 写点东西, 设置封面, 录个音, 轻触”发表日记”按钮…

如果一切正常, 应该可以看到, 服务器端 temp 目录中会多出两个上传的文件(封面图片和录音). 此外, 小程序端控制台会输出包含了服务器端文件信息的完整日记信息…

当然, 如果不正常, 呵呵, 也别急, 对照上面的代码, 慢慢调试…

( 把小程序端代码还原为 Code 27-2 的样子(第 11 ~ 12 行), 再继续… )

27.2.3 对接上传日记数据接口

接下来, 我们对象小程序端的第 3 个网络请求(Code 27-2, 第 11 行) , 关键是取得小程序端传来的数据.

编辑服务器端 index.js 文件, 继续添加如下代码…

**Code 27-4: index.js (局部) **

1
2
3
4
5
6
7
app.use(express.json());                             // for parsing application/json

// 发表日记
app.post('/publishDiary', function (req, resp) {
console.log(req.body);
resp.send('OK');
});

呵呵, 好简单 ~ 只是别忘记第 1 行. 这是什么东东? 嘿嘿 ~ 传送门: Node.js + Express 获取前端传来的数据 (现在就跳过去看一眼!)

第 5 行将前端传来的信息输出到控制台…

重启一下服务端程序, 然后, 小程序端”发表日记”, 快试试吧, 很简单的 ~

写上面这小段程序有 2 个目的:

(1) 观察小程序端传来了一些什么数据 ( 试运行一下 )

(2) 举例说明如何获取前端传来的数据

27.2.4 将日记数据写入数据库

上面一小节已经可以看到, 我们获得了小程序端传来找日记数据. 接下来, 即是把这些数据写入数据库…

让我们再修改一下前面 Code 27-4 的代码:

Code 27-5: index.js (局部)

1
2
3
4
5
6
7
8
9
10
11
app.use(express.json());                             // for parsing application/json

// 发表日记
app.post('/publishDiary', async function (req, resp) {
try {
await diaryService.addDiary(req.body);
resp.send('OK');
} catch (err) {
resp.status(500).send(err.message);
}
});

第 6 行, 将获得的日记数据继续传递给后端的 service 处理, 即写入数据库. ( diaryService.addDiary() 函数的定义见正文: Code 27-6 )

service 是什么? 呵呵, 自己写过的代码都忘记了… 在教程的第五部分, 我们曾经做过一些封装, 把与数据库交互相关的代码都放到所谓的 service 里, 真忘记了的话, 回去看看吧 ~

编辑 /service/diary.js

Code 27-6: /service/diary.js ( 局部, addDiary )

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
const dbUtil = require('../utils/dbUtil.js');   // 引入dbUtil模块  
const uuid = require('uuid');

// 新增日记
async function addDiary(params) {
// 插入数据库到日记表的SQL语句
const sqlNewDiary = 'insert into diary(id, userId, title, publishTime, cover, content, audioPath, audioDuration) values (?,?,?,?,?,?,?,?)';
// 插入到diary表中的数据(对应上面一条中的那些问号)
const valuesNewDiary = [
uuid.v1(), // 使用uuid作为记录主键
'u1', // 假设日记都是u1用户发表的(以后处理)
params.title,
new Date(),
params.cover,
params.content,
params.audioPath,
params.audioDuration
];
// 执行添加日记的SQL语句
await dbUtil.query(sqlNewDiary, valuesNewDiary);

// 更新作者日记数的SQL语句
const sqlUpdateDiaryCnt = 'update user_info set diaryCnt = diaryCnt + 1 where id = ?';
// 对应的参数, 假设日记是u1用户发表的(以后处理)
const valuesUpdateDiaryCnt = ['u1'];
// 更新作者的已发表的日记数
await dbUtil.query(sqlUpdateDiaryCnt, valuesUpdateDiaryCnt);
}

module.exports = {
getAllDiaries,
addDiary
}

上面这段代码, 我想不用注释你也能看懂…

只是要注意, 传入的日记数据参数 params 是一个 JavaScript 对象, 而最终执行 SQL 命令时需要的参数是一个数组.

另外, 别忘记更新日记作者的”已发表日记数”哦 ~ (小程序主界面头部的”日记数”)

现在你可以保存, 重启服务端, 小程序端”发表日记”, 如果人品好的话, 应该可以看到, 文件也上传了, 数据也保存到数据库里了…

Code 27-6 中第 20, 27 行分别执行了两次数据库更新操作, 严格来说, 需要在一个**事务(Transaction)**中执行.

此处主要是为了让小白们更容易理解些, 代码写得并不严谨. 如果你对如何引入事务, 让上述代码变得更好, 欢迎学习本部分第 27.2.6 节 (选修内容)

27.2.5 最后的工作

现在, 日记数据已经可以写入数据库, 封面图片和音频文件也已经上传到服务器端的 temp 文件夹中, 最后再做 2 件事就可以完美收工了:

(1) 服务器端: 把文件从临时文件夹(temp)搬到正式的位置, 并允许前端访问. 同时, 确保数据库中存储了正确的文件路径.

(2) 小程序端: 跳转回主界面.

27.2.5.1 服务器端

修改服务器端 index.js 文件内容:

Code 27-7: index.js (局部: /publishDiary)

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
const express = require('express');       // 引入Express
const path = require('path');
const fs = require('fs');

const tempDir = 'temp'; // 临时文件夹
const staticDir = 'public'; // 静态资源文件夹
const uploadDir = 'upload'; // 上传文件正式存储的文件夹: /public/uploads

app.use(express.static(staticDir)); // 指定staticDir中的内容为静态资源
app.use(express.json());

// 发表日记
app.post('/publishDiary', async function (req, resp) {
try {
// 获得前端提交的数据(日记数据)
const params = req.body;

const srcFolder = path.join(__dirname, tempDir); // 源文件所在的文件夹路径(临时文件夹)
const desFolder = path.join(__dirname, staticDir, uploadDir); // 目的文件夹路径(public/uploads)

// 若上传文件夹不存在则创建之
if (!fs.existsSync(desFolder)) {
fs.mkdirSync(desFolder, { recursive: true });
}
// 将封面图片和音频文件从临时文件夹移动到正式的上传文件存储位置
fs.renameSync(path.join(srcFolder, params.coverServerFileName), path.join(desFolder, params.coverServerFileName));
if (params.audioServerFileName) {
fs.renameSync(path.join(srcFolder, params.audioServerFileName), path.join(desFolder, params.audioServerFileName));
}

// 替换参数中的封面图片, 音频文件路径为正确的相对路径
Object.assign(params, {
cover: `${uploadDir}/${params.coverServerFileName}`, // 封面图片的相对路径,
audioPath: params.audioServerFileName ? `${uploadDir}/${params.audioServerFileName}` : null, // 音频文件的相对路径
});

// 保存日记信息到数据库
await diaryService.addDiary(params);

// 向前端返回成功标志
resp.send('OK');
} catch (err) {
resp.status(500).send(err.message);
}
});

以上仅给出了”发表日记”接口及其相关的部分代码, 并非 index.js 的完整代码. 如果乱不清楚该放什么位置的话, 在本文最后有项目完整代码, 可参考.

除了上面代码中的注释, 再导读一下…

  • 第 18 ~ 35 行, 将封面图片及音频文件从临时文件夹移动到正式文件夹(参看第 5 ~ 7 行)
    • 第 18, 19 行, 生成源文件夹和目的文件夹的物理路径, 这里使用了 Node.js 的 path 模块 (第 2 行引入), __dirname为项目根目录的物理路径.
    • 第 22 ~ 24 行, 确保目的文件夹存在 (若不存在则自动创建)
    • 第 26 ~ 29 行, 移动文件. 使用了 Node.js 的 fs 模块 ()第 3 行引入).
  • 第 32 ~ 35 行, 替换参数中的封面图片, 音频文件路径为相对路径
    • 所谓相对路径即是小程序端访问时使用的相对路径, 呵呵, 你懂的. 数据库中最终保存的是文件的相对路径
    • Object.assign( .. ) 是 ES6 本来就有的方法, 怕你迷惑, 解释一句: 相当于 params.cover = relPathCover; params.audioPath = relPathAudio;
  • 第 38 行, 保存数据到数据库, 对接第 27.2.4 节
  • 第 9 行, 将 public 文件夹指定为静态资源. 所谓静态资源, 可以简单理解为前端可直接访问的资源. 这里, 我们把 public 文件夹指定为静态资源, 那就顺带着 public/uploads以及里面的所有文件都可以被前端直接访问到了. 不然的话, 你让小程序怎么读取最终上传的图片和音频??

做到这里, 你可以测试一下, 服务器端已经可以正常工作了 ~ 注意去看看数据库里的数据…

27.2.5.2 小程序端

现在, 让我们回到小程序端. 有 2 件事情需要做:

(1) 发表日记成功后, 让界面自动退回到主界面

Code 27-8: /pages/diary/diary.js (publishDiary函数)

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
// 发表日记
publishDiary: async function() {
const diary = this.data.diary;

// 表单校验
const fieldCheck = function(field, errMsg) {
if (!diary[field]) {
wx.showToast({ title: errMsg, icon: 'none'});
return false;
}
return true;
}
if (!fieldCheck('title', '标题还是要有滴~')) return;
if (!fieldCheck('content', '说点什么吧~')) return;
if (!fieldCheck('cover', '美照来一张~')) return;

try {
diary.coverServerFileName = await api.uploadFile('/upload', null, diary.cover); // 上传封面图片
if (diary.audioPath) { // 音频可选
diary.audioServerFileName = await api.uploadFile('/upload', null, diary.audioPath); // 上传音频
}
await api.request('/publishDiary', diary); // 提交日记数据
wx.showToast({ title: '日记已发表~'});
wx.navigateBack(); // 返回上一页
} catch(e) {
wx.showToast({ title: '发表日记失败', icon: 'none'});
}
}
  • 第 23 行, 退回到上一页, 即主界面.
  • 第 5 ~ 15 行, 顺带加了一段表单校验, 自己研究一下呗 ~

(2) 主界面的中封面图片和音频访问路径需要处理一下下…

Code 27-9: /utils/api.js (局部)

1
2
3
4
5
6
//.....
module.exports = {
SERVER_PATH: SERVER_API + '/',
request,
uploadFile
}

在先 api.js 的基础上加了第 3 行, 将服务器地址”暴露”出去(如: http://localhost:8000/), 因为页面中要用它来拼接完整的图片/音频文件路径(见Code 27-10)

Code 27-10: /pages/index/index.wxml (封面图片)

1
<image class="book-cover" src="{{SERVER_PATH}}{{item.cover}}" mode="aspectFill"></image>

这是主界面的 index.wxml 文件中展示封面图片的代码, 自己找一找, src 属性这里在封面图片相对路径前加了SERVER_PATH

此时你可能会发现原先数据库中的那几条日记的封面图片显示不出来了, 自己想想原因, 试着处理一下呗…

Code 27-11: /pages/index/index.js (局部)

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
const api = require('../../utils/api');

Page({
data: {
SERVER_PATH: api.SERVER_PATH
},
// 页面显示时加载数据
onShow: async function () {
const firstPageData = await api.request('/getFirstPageData');
// 将日记音频的相对路径转换为完整的url
firstPageData.diaries.forEach(diary => {
if (!diary.audioPath) return;
diary.audioPath = api.SERVER_PATH + diary.audioPath;
});
this.setData({...firstPageData});

// 监听BackgroundAudioManager的各种事件
// 开始播放
audioPlayer.onPlay(() => {
this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: true});
});
// 播放暂停
audioPlayer.onPause(() => {
this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false});
});
// 播放停止
audioPlayer.onStop(() => {
this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false, [`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: 0});
});
// 播放结束
audioPlayer.onEnded(() => {
this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false, [`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: 0});
});
// 播放进度变化
audioPlayer.onTimeUpdate(() => {
// 若当前用户没有正在拖动播放滑动, 则播放器的时候随播放进度变化
if (!this.data.isChangingAudioPos) {
this.setData({[`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: audioPlayer.currentTime});
}
});
}
})

主界面的 index.js 文件, 这里只是有变更的部分哦 ~

  • 第 5 行, 把 SERVER_PATH 从 api.js 模块引入过来, 暴露给当前页面使用 (参见 Code 27-10 )
  • 第 8 行, 把原先 onLoad 改了 onShow, 即页面显示时自动加载首页数据, 而不只是页面首次加载时刷新首页数据, 这样当用户发表完日记返回主界面时, 其中的日记列表将自动刷新. 此外, 还加了第 11 ~ 14 行, 在音频文件相对路径的前面拼接上服务器地址(http://localhost:8000/), 构成完整的url
  • 测试时发现一个Bug, 当离开主页面时, 会导致以上第 17 ~ 40 行代码进行的音频播放器回调事件注册失效, 继而各种功能不正常. 因此, 这里将就把此段代码也一并搬到 onShow 事件回调函数中, 即: 重新显示主界面时再注册一次各种事件…

小程序中 Page 的生命周期, 了解一下 ~

27.2.6 选修课

本节内容有一定难度, 但值得学习, 呵呵~

27.2.6.1 使用事务(Transaction)

在第 27.2.4 节, 将日记数据写入数据库时, 我们需要同时更新 2 个表: (1) 向日记表(diary)插入一行新数据; (2) 更新日记作者的已发表日记数(user_info.diaryCnt)

在 Code 27-6 中第 20, 27 行分执行了两次数据库更新操作, 以完成上述操作. 严格来说, 这种做法是非常不妥当的.

试想, 如果 diary 表插入数据成功, 但 user_info.diaryCnt 未更新成功, 那么数据库中的数据就处于一种不一致的状态. 在数据库理论中, 为解决这一问题, 引入了事务的概念.

简单说, 一个数据库更新任务若需要多个步骤完成, 应该保证要么每一个步骤均正确执行完成, 要么任何一个步骤都不执行. 而这里所说的包含了多个步骤的任务即可称作一个事务. 如果不清楚这部分内容, 强烈建议复习《数据库原理》

如果你的代码不是写得太烂, 基本的输入校验都没做好, 那么, 按前文的做法也并不是说就总会发生上述导致数据”不一致”的状况, 应该说发生的机率不大, 但不代表不会发生.

因此, 就有一些不太负责任的程序员经常”偷工减料”… 在这里让我们一起鄙视他们!

在本教程中, 我们使用了 mysql 模块, 其中就包含了事务机制, 使用起来也并不复杂. ( 没见过哪个和数据库打交道的模块/框架不支持事务的 )

下面, 我们就来处理一下前文留下的隐患, 重写 dbUtils.js 中的 query 函数:

Code 27-12: /utils/dbUtil.js (升级版 query 函数)

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
58
59
60
61
62
63
64
65
66
67
68
69
70
// 内部使用的数据库操作函数
// 使用connection执行单条sql命令, params为sql中参数占位符对应参数值(数组)
function _query(connection, sql, params) {
return new Promise((resolve, reject) => {
connection.query(sql, params || [], (err, result) => {
if (err) reject(err);
resolve(result);
})
})
}

/**
* 执行SQL命令
* 可用于执行一条需要SQL命令(sql参数为string)
* 也可用于执行多条SQL命令(sql参数为Array<String>), 此时多条命令将使用一个事务执行.
* @param {String|Array<String>} sql 待执行的单条或多条SQL命令
* @param {Array<any>} params 与sql命令中参数点位符对应的参数值表.
*
* 例:
* query( 'select * from t where id=?', ['a'] );
* query([ 'update t set age=10 where id=?', 'delete from t where age>? and weight<?' ], [['a'], [18, 50]]);
*/
function query(sql, params) {
return new Promise((resolve, reject) => {
// 若sql为单条命令, 直接执行
if (typeof sql === 'string') {
pool.query(sql, params || [], (err, result) => {
if (err) reject(err);
resolve(result);
});
}
// sql为命令数组, 启用事务执行
else if (Array.isArray(sql) && sql.length && sql.length === params.length) {
pool.getConnection(function (err, connection) { // 获得数据库连接对象
if (err) reject(err);
connection.beginTransaction(async err => { // 启动事务
// 若启动事件出错, 则释放连接, 返回
if (err) {
connection.release();
reject(err);
}

const results = []; // 用于存储每一条SQL命令的执行结果
try {
// 依次执行sql数组中的各条SQL指令, 并将执行结果追加到results数组中
for (let i = 0; i < sql.length; i++) {
results.push(await _query(connection, sql[i], params[i])); // _query()函数定义在第3~10行
}
// 提交事务
connection.commit(err => {
connection.release(); // 释放数据库连接
if (err) reject(err);
resolve(results); // 返回结果
});
} catch (e) {
// 异常时回滚事务
connection.rollback(() => {
connection.release();
});
reject(e);
}
});
});
}
// 调用本函数时传入的参数不合法
else {
reject('Illegal Argument');
}
});
}

以上代码重写了原先 dbUtil.js 中的 query 函数, 在原来的基础上进一步兼容执行多条 SQL 的情况. 结合代码中大量的注释, 自己研究一下吧~

不同技术中的, 带事务的数据库操作语法可能有些许差异, 但原理差不多, 这里给一段伪代码, 帮助你理解…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const connection = null;
const transaction = null;
try {
connection = getConnection(); // 获得数据库连接
transaction = connection.beginTransaction(); // 开始事务
// 执行SQL-1
// 执行SQL-2
// ...
transaction.commit(); // 提交事务
} catch(e) {
transaction.rollback(); // 出现异常时回滚事务
} finally {
if (connection) {
connection.release(); // 无论如何要断开数据库连接. 当然, 如果代码在第4行处就已经出现异常,自然也就不必多此一举了.
}
}

以上代码主要为了辅助理解数据库操作流程, 本文 Code 27-12 的代码看上去和上面这段差异较大, 但原理相同. ( 大量的异步回调导致 Code 27-12 显得有些诡异 )

来看看怎么使用上面修改过的 query 函数吧~

将 Code 27-6 稍作调整即可… ( 项目中其它使用到 dbUtil.query() 函数的地方无须调整 )

Code 27-13: /service/diary.js ( addDiary )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 新增日记
async function addDiary(params) {
// 插入数据库到日记表的SQL语句
const sqlNewDiary = 'insert into diary(id, userId, title, publishTime, cover, content, audioPath, audioDuration) values (?,?,?,?,?,?,?,?)';
// 插入到diary表中的数据(对应上面一条中的那些问号)
const valuesNewDiary = [
uuid.v1(), // 使用uuid作为记录主键
'u1', // 假设日记都是u1用户发表的(以后处理)
params.title,
new Date(),
params.cover,
params.content,
params.audioPath,
params.audioDuration
];

// 更新作者日记数的SQL语句
const sqlUpdateDiaryCnt = 'update user_info set diaryCnt = diaryCnt + 1 where id = ?';
// 对应的参数, 假设日记是u1用户发表的(以后处理)
const valuesUpdateDiaryCnt = ['u1'];

// 执行两条语句
await dbUtil.query([sqlNewDiary, sqlUpdateDiaryCnt], [valuesNewDiary, valuesUpdateDiaryCnt]);
}

自己对比原来的代码看吧~

27.2.6.2 进一步封装

对于 Code 27-13 中第 4 ~ 15 行的那段, 不知道你眼花了没有, 反正我这种老人家为了凑上面的代码放大镜都拿出来了…

如果你是手工来写上面的代码的话, 主要精力基本上就花在 2 件事: (1) 准备要执行的 sql 语句, 掰着手指头数, 到底是有几个问号; (2) 准备参数, 小心翼翼地把参数值放在它该在的位置.

像这种又累, 又废眼神的事情, 老人家是绝对不愿意干的… 让我们来写个小工具函数处理一下吧 ~

不知你有没有发现这样的规律: sql 语句中有多少的字段 (id, userId, title, publishTime, cover, content, audioPath, audioDuration), 后面就会有多少个问号

这不废话吗, 呵呵 ~

还有… sql 语句中有多少个字段, values 那个数组就会有多少的元素

嘿嘿, 这也是废话 ~

如果你代码中那些变量的命名不是凭心情乱来, 那么 sql 语句中的字段名应该和 params 里的属性名是一致的, 就像本文中的代码一样.

好了, 基于上面 3 句废话, 我们就可以来写”小工具”了…

utils/dbUtil.js 中添加如下代码:

Code 27-14: /utils/dbUtil.js ( buildInsert )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 构造insert语句(sql)和参数表(values)
* @param {String} table 表名, 如: diary
* @param {Array<String>} fields 要插入的字段列表, 如:['id', 'userId', 'title', ... ]
* @param {Object} params 参数对象, 如: { title: '', content: '', ... }
* @param {Function} getValue 获取参数值的回调函数: getValue(字段名, 根据字段名取得的默认值). 若此参数为空, 则直接使用默认值
* @return {Object} insert语句及参数表: {sql, values}
*/
function buildInsert(table, fields, params, getValue) {
const sql = `INSERT INTO ${table} ( ${ fields.join(', ') }) values ( ${new Array(fields.length).fill('?').join(', ')} )`;
const values = [];
fields.forEach(field => {
if (!getValue) return values.push(params[field]);
values.push(getValue(field, params[field]));
});
return { sql, values };
}

module.exports = {
query,
buildInsert
}

写了一个函数: buildInsert, 并 export …

此函数的输出结果是组装好的 insert 语句(sql), 以及数组形式的参数表(values), 供 dbUtil.query(sql, values) 使用.

自己先看一下第 1 ~ 8 行的注释, 大致了解一下输入参数是些什么…

重点解释一下参数表中的那个回调函数(getValue), 此函数需要调用方传入, 用于向调用方”询问”指定字段的值. 回调函数 getValue() 形参表中第 1 个参数为”字段名”, 第 2 个参数为默认值, 即 buildInsert 函数根据字段名尝试从 params 中取得值.

第 10 行, 组装 insert 语句, 其中 fields.join(‘,’) 意为把 fields 数组中的元素串成一个字符串, 并使用逗号分隔. new Array(fields.length).fill(‘?’) 的意思是: 先看看 fields 数组中有多少个元素(fields.length), 构建一个数组并使用对应个数的问号填充.

第 12 ~ 15 行: getValue 是向调用方索要参数值的回调函数. 这里, 依次遍历每一个字段, 若 getValue 为空(即未传入), 则直接使用 params 对象中对应属性值追加到值数组(values), 返回. 若 getValue 不为空, 则通过 getValue() 从调用方那里获得指定字段的值, 并追加到 values 数组中.

其中, 第 14 行的调用 getValue() 函数时, 同时给调用方传递了字段名 (field) 和 按字段名从 params 对象中取得的值( 也可能为 undefind )

最后, 第 16 行返回由 sql 语句和字段值集合(数组)组成的对象.

呵呵 ~ 是不是比较难懂? 说了是选修内容啦~ 看不懂就暂时放过它吧…

OK , 来看看怎么使用吧… 现在, Code 27-13 中的那个 addDiary() 函数进一步改写成下面的样子:

Code 27-15: /service/diary.js ( addDiary )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function addDiary(params) {
const fields = ['id', 'userId', 'title', 'publishTime', 'cover', 'content', 'audioPath', 'audioDuration'];
const sqlAndValuesOfNewDiary = dbUtil.buildInsert('diary', fields, params, function(field, defaultValue) {
switch (field) {
case 'id': return uuid.v1(); // 使用uuid作为记录主键
case 'userId': return 'u1'; // 假设日记都是u1用户发表的(以后处理)
case 'publishTime': return new Date();
default: return defaultValue;
}
});

// 更新作者日记数的SQL语句
const sqlUpdateDiaryCnt = 'update user_info set diaryCnt = diaryCnt + 1 where id = ?';
// 对应的参数, 假设日记是u1用户发表的(以后处理)
const valuesUpdateDiaryCnt = ['u1'];

// 执行两条语句
await dbUtil.query([sqlAndValuesOfNewDiary.sql, sqlUpdateDiaryCnt], [sqlAndValuesOfNewDiary.values, valuesUpdateDiaryCnt]);
}

注意第 3 行调用 dbUtils.buildInsert () 时传入的最后 一个参数是一个回调函数: function(field, defaultValue){ ... } . 它一直延伸到了第 10 行.

自己用鼠标把这个回调函数的起止范围选出来试试…

相比 Code 27-13 中的 addDiary() 函数少了几行而已, 不过没有了那些让人眼睛花的东西.

如果要插入的字段更多的话, 你就更加可以感觉出它的优势…

好了, 上面说过, 这本小节选修, 不强求…

之所以代码不那么容易看懂, 你可能会放弃, 但仍然坚持写下本节内容, 有几个目的:

  • 学会使用事务(Transaction), 做一个负责任的码农.

  • 了解一下前面多次使用的各式各样的”回调函数”是怎么在主调方和被调方之间协同工作的. 事实上, 这也是一种重要的逻辑抽离(分层)的方法, 如果你接触过其它面向对象程序设计语言, 比如 Java 之类的, 那么你会发现, 回调函数和接口(Interface)有异曲同工之处, 只是 JavaScript 里的回调函数没有 Java 的接口那么严格

  • 进一步体会封装与代码复用思想, 激发你进行更多封装, 或说是激发你优化代码的冲动…

  • 再进一步, 向你安利持久层框架: 我们的 dbUtil.js 模块其实一直在试图把与数据库打交道的通用代码封装起来,以便于我们在写程序时把主要精力放在业务逻辑的实现上, 而不是成天”搬砖”.

    悄悄告诉你一个好消息 ~ 各种技术中基本上都可以找到别人已经实现好的工具包, 通常称作持久层框架或ORM(对象关系模型), 比如: Node.js 的世界里可以用一下 Sequlize, 你将感到极度舒适. ( 自学 )

好了, 好了, 终于完了….

这是目前为止的完整代码, 需要的话就自取吧: daily-reading_part7.zip.

其中 /daily-reading-server/service/diary.js 和 /daily-reading-server/utils/dbUtil.js 两个文件中代码使用了第 27.2.6 节(选修)的版本.

若有不适, diary.js 可使用 Code 27-6 中的版本, dbUtil.js 可使用 Code 22-2 中的版本.