Node.js Express框架开发基础
# Node.js Express框架开发基础
这篇博客以一个简单的博客系统入手,讲一下怎么用 Node.js 来实现一个项目。
项目在开发之前定下了技术栈:后端用 Nodejs 平台,基于 Express 框架,数据库使用 MySQL,前端使用 Pug 模板引擎(express 推荐)实现,接口采用 RESTful 风格。
项目主要是实现系统的登录注册,博客的增删改查。但为了避免整篇整篇的代码,文章内只展示了登录注册的部分代码,而且每一节里只展示了与这一节相关的零碎代码。若是想看较为完整的使用示例,而没有心情一步一步来,请直接前往最后一节。若是想看完整项目代码,请前往 Github 仓库。
此外,我也只是一个普通的学生党,写这篇博客是因为近来做毕设项目时用到了 Express 搭建一个临时服务器。我借此机会, 结合之前做课程实验项目的时候学到的知识,写下了一些经验,向自己证明我学过。
我不是专门的 JavaScirpt 程序员,加之自身并没有实际应用至商业程序中的经验,博客内容浅薄之处难免会贻笑大方,若是发现问题还请多多指点。
- **本篇博客所有示例代码,都是用的 promise 调用方式,并且使用 ES7 的 async/await 语法。**若是读者老爷还没有接触过,可能需要先看一看这个语法。
- 这篇博客,只是为了让项目跑起来,让读者们知道怎么用 express 快速搭建一个服务,却并不是为了让读者老爷深入了解一个个模块,一个个 api。如果有需要深入了解,我还是推荐先看看官方文档,有英语阅读能力的看原版,否则看译制版。
# 项目初始化
搭建一个 node.js 项目,需要先在一个空文件夹里初始化一个 package.json
。
npm init
初始化过程会有许多信息让你填,不过不用理会,一路默认就行。完成后,安装必要的依赖。
npm install express@lastest
# express 是今天的主角,用来搭建 http 服务器用的
# 上面这条命令中的 `@latest` 会指定安装指定包的最新版,由于是新项目,没有负担,所以我比较倾向于最新版。
2
3
依赖安装完成后,需要创建我们必须的文件夹和文件。通常我是喜欢用如下的结构:
+ public // 存放对外公开的资源文件,例如 js, css, 图片等
+ js
+ css
+ img
+ src // 我们做开发的主要目录
+ routes // 配置路由
+ views // 页面(模板引擎),如果不用模板引擎,而是前后端分离,这个目录就没有必要了
- app.js // 应用入口
+ test // 测试文件目录,写单元测试会用到
2
3
4
5
6
7
8
9
文件夹的分工简单明确,对于开发来说是有好处的。通常使用大家都知道的缩写,看起来也很明了。
接下来要开发一个简单的服务器入口了,编辑 src/app.js
:
const Express = require("express");
// 通过调用 Express 函数,生成了一个 express 应用。后面我们要做的一切都会用到 `app`
const app = Express();
// 简单的路由,只返回一个字符串 先记住这个写法
app.get("/", async (req, res) => {
res.end("Hello Express");
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
2
3
4
5
6
7
8
9
10
11
12
在项目根目录运行 node src/app.js
。在用浏览器访问 localhost:3000
就可以看到我们的应用搭建起来了。
其实要搭建一个 express.js
项目,还有另外一个选择:可以使用 express-generator
点击了解 express-generator (opens new window)
不过这玩意儿截至2020年11月,已经两年没有更新过了。依赖有些过期,而且生成的代码略微繁琐,新语法也没有应用上。所以我不喜欢用。
# 模板引擎
虽然现阶段很多项目都是前后端分离式的了,对于这些项目,模板引擎没有发挥空间。但是对于非前后端分离的项目,模板引擎依然是必不可少的一部分。
例如,我们这个项目就要使用 express 默认的 Pug 模板引擎。
首先我们需要安装模板引擎依赖。
npm install pug
然后在 src/app.js
中添加如下配置。
const Express = require("express");
const path = require("path");
// 通过调用 Express 函数,生成了一个 express 应用。后面我们要做的一切都会用到 `app`
const app = Express();
// 配置模板文件所在目录,__dirname 是 js 内置变量,表示当前文件的目录,path.join() 把前后目录按各自平台的规则,连接起来
app.set("views", path.join(__dirname, "views"));
// 配置模板引擎种类
app.set("view engine", "pug");
// 设置静态文件路径,让渲染出的 html 能够获取到你的css,js,images等文件
app.use(Express.static(path.join(__dirname, "../public/")));
app.get("/login", async (req, res) => {
res.render("login", {
title: "Login",
});
});
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
如上,path.join()
把目录连接起来,例如 app.js
在 <root-dir>/src
下, 第二个参数是 views
, 返回的结果就是 <root-dir>/src/views
;如果第二个参数是 ../views
, 返回的结果就是 <root-dir>/views
。而且,不用在乎斜线是 windows 风格还是 Linux/Mac os 风格,方法内部会自己处理。
你甚至不用显式导入 pug
,只需要用两行代码设置一下模板引擎的种类和路径就好。express 与 pug 结合就是这么简单。在之后的 route 中,就可以使用 res.render()
来用模板引擎生成你的html,返回到浏览器。res.render()
第一个参数是模板路径,相对于上面设置的 views
目录,例如模板是 path/to/views/login.pug
,第一个参数就是 login
, 第二个参数可以是模板中用到的变量,还有第三个是个回调函数,这个传了之后,res.render()
就不会自动把生成的 html 发送到浏览器了,而是等待你的进一步操作,具体请看 (opens new window)
如果你使用其他模板引擎的话,可能需要更多配置,不过呢,我在这里没办法写出来,因为我也没有用过 [😃]。
# 解析 Body,Cookie,Session
# 解析 body: body-parser
大家都知道类似 HTTP 中的 POST
请求的请求参数是不会放到 Url 中的,而是在 body 中。所以我们要完成对 POST
请求的处理,首先便是要让获取到请求体中带的数据,有一个方便的插件 body-parser
。
先安装这个插件
npm install body-parser@latest
安装好后在 src/app.js
里配置一下
const Express = require('express');
const bodyParser = require("body-parser");
const app = Express();
// 解析 content-type = application/x-www-form-urlencoded
app.use(
bodyParser.urlencoded({
extended: false,
})
);
// 解析 content-type: application/json
app.use(bodyParser.json());
// 省略其他内容
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
上述部分代码将调用 bodyParser.urlencoded()
和 bodyParser.json()
,并将其返回值作为参数传入到了 app.use()
中。其实调用 app.use()
方法的过程就是为 express 添加了一个中间件。最后一节有对中间件的简单描述,不过这里暂且不用理会什么是中间件。
之后,我们就可以在添加路由的回调函数中,获取到 body 中的内容。
app.post('/login', async (req, res, next) => {
console.log(req.body); // {}
})
2
3
# 解析 cookie: cookie-parser
解析 cookie
可以用到 cookie-parser
npm install cookie-parser@latest
# cookie-parser 是用来解析 cookie,把 cookie 加到 Request 请求对象中去的。
2
const Express = require('express');
const CookieParser = require("cookie-parser");
const app = Express();
// 解析 Cookie
app.use(CookieParser());
// 省略其他内容
2
3
4
5
6
7
8
9
之后,我们就可以在添加路由的回调函数中,获取到 cookie 中的内容。
app.post('/login', async (req, res, next) => {
// 获取请求头中的 cookie
console.log(req.cookies); // {}
// 设置 cookie 到 响应头
// "Remember Me" for 15 minutes
res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true });
// res.cookie(key: string, value:string, option?);
// 设置 cookie 到浏览器的本质其实就是添加一个响应头 `Set-Cookie`
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
// 删除 Cookie
res.clearCookie();
})
2
3
4
5
6
7
8
9
10
11
12
13
此外,其实浏览器发来的 cookie 数据就在 http 请求的请求头中,即便不用其他插件我们也可以轻松获取到。最后一节会提到。
# 解析 session: express-session
和 body 与 cookie 的解析一样,实现 session 存储也需要一个中间件 expres-session
。
先来安装:
npm install express-session
安装好后需要做一个简单的配置,稍稍比上面的 body 和 cookie 复杂一些。
const expressSession = require('express-session');
app.use(
expressSession({
key: 'sessionId', // 设置cookie中保存sessionId的字段名
secret: 'linfalfjkasjflka', // 通过secret值计算hash值,就像是一个密码
resave: true, // 强制更新session值
saveUninitialized: true, // 初始化cookie值
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天后过期
},
})
);
// 省略其他值
2
3
4
5
6
7
8
9
10
11
12
13
14
15
之后使用也很简单:
app.post('/login', async (req, res, next) => {
console.log(req.session);
// 设置session
req.session.key1 = value1;
req.session.key2 = value2;
// 删除 session
req.sesion.destory();
})
2
3
4
5
6
7
8
# 路由编写
做到上面几节完成后,我们可以发现系统是能够跑起来了。但展示出来的也不过是让用户可以看到一个 Hello World
,这怎么能行,我们可是要做大事的人。[^_^]
这一节我们一起来写个路由看一看。
先设计好需求,这一节要完成一个登录的功能,服务端接收放有表单数据的 POST 请求,处理返回登录、注册结果。
第一个实现登录的版本,设计请求头中的 content-type 为 application/x-www-form-urlencoded,登录完成之后,把登录状态保存到 session。
登录请求格式:
POST /login
{
"email": "",
"password": ""
}
2
3
4
5
6
具体代码实现:
const Express = require("express");
const CookieParser = require("cookie-parser");
const BodyParser = require("body-parser");
const path = require("path");
const pool = require("./config/database");
const expressSession = require('express-session');
// 通过调用 Express 函数,生成了一个 express 应用。后面我们要做的一切都会用到 `app`
const app = Express();
// body cookie session 配置
app.use(BodyParser.json());
app.use(CookieParser());
app.use(
expressSession({
key: 'sessionId', // 设置cookie中保存sessionId的字段名
secret: 'private-secret', // 通过secret值计算hash值
resave: true, // 强制更新session值
saveUninitialized: true, // 初始化cookie值
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000,
},
})
);
// 模板引擎与静态资源目录
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "pug");
app.use(Express.static(path.join(__dirname, "../public/")));
// 渲染登录页面
app.get("/login", async (req, res) => {
res.render("login", {
title: "Login",
});
});
app.get("/", async (req, res) => {
res.render("index", {
title: "Login",
// 模板引擎中必要的数据
})
})
/**
* 处理promise抛出的错误,返回一个数组
* @param {Promise} promise promise对象
*/
const hp = (promise) =>
promise.then((res) => [null, res]).catch((err) => [err, null]);
/**
* req 请求对象
* res 响应对象
* next 把请求发送到下一个 handler 的回调函数
*/
app.post("/login", async (req, res) => {
const { email, password } = req.body;
if (!isEmail(email) || !isPassword(password)) {
res.render("login", {
title: 'Login'
errTip: "邮箱或密码格式不正确",
});
return;
}
// 查询用户信息
const [err, results] = await hp(
pool.query("select * from account where email=?", [email])
);
// 是否碰到数据库查询的错误
if (err) {
res.render("login", {
title: 'Login'
errTip: "服务器内部错误,请稍后重试",
});
return;
}
// 对于 select 查询的结果,值应该是一个数组
// 由于用户的邮箱唯一绑定,所以,查到的结果数组要么包含1个元素,要么是包含0个元素
const result = results[0];
// 是否存在该用户:如果结果数组中没有元素,自然不存在该用户
if (result.length === 0) {
res.render("login", {
title: 'Login'
errTip: "账号不存在",
});
return;
}
// 密码是否正确
// 加密浏览器传来的明文密码后形成加密密码,与数据库中的加密密码进行比对
// 这里的加密方式是: 加密后的密码 = hash(明文密码 + 存放于数据库的一段随机字符串)
// 这个 encrypt 方法可以自己实现。
if (encrypt(password + result[0].pwd_salt) !== result[0].password) {
res.render("login", {
title: 'Login'
errTip: "账号和密码不匹配",
});
return;
}
// 登录成功了,把用户登录信息放至 session
req.session.account = {
email,
// ... 其他必要的信息
};
// 重定向到 主页面
res.redirect("/");
});
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
上述代码中用到的模板页面,就不贴出来了。模板接收 res.render()
中的参数,其中的 errTip
的作用就是让模板页面能够渲染出来错误提示。
个人觉得没有多重回调和 try {} catch() {}
的代码看起来确实要干净许多。
# Json Web Token 验证
关于 Json Web Token 是什么这个问题,网上已经有太多太多的博客有写到了。这里不想去赘述。
我在使用 jwt 的时候,用到的是一个叫做 jsonwebtoken
的库。
npm install jsonwebtoken
完成之后,简单地封装一下。
# TOKEN
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
/** 私钥,签发 toke 时使用私钥 */
const privateKey = fs.readFileSync(path.join(__dirname, '../../config/rsa_private_key.pem'));
/** 公钥,解密 token 时使用公钥 */
const publicKey = fs.readFileSync(path.join(__dirname, '../../config/rsa_public_key.pem'));
const maxAge = 1000 * 60 * 60 * 24 * 7; // 7 天
/** token配置,可考虑放到 config 中 */
const tokenConfig = {
algorithm: 'RS256',
expiresIn: '7d',
issuer: 'example@example.com',
subject: 'card-game'
};
/**
* 给用户发布 token,假定参入参数是正确有效的
* @param {any} data 参数
*/
const sign = data => jwt.sign({
data,
}, privateKey, tokenConfig);
/**
* 验证 token 的合法性,不合法时会抛出一个错误
* @param {string} token 用户传递过来的 token
* @returns {Promise}
*/
const verify = token => new Promise((resolve, reject) => {
jwt.verify(token, publicKey, tokenConfig, (err, decoded) => {
if (err) {
reject(err);
} else {
resolve(decoded);
}
});
});
/**
* 获取token中所包含的信息
* @param {string} token 用户传递的 token
*/
const decode = token => jwt.decode(token, publicKey);
module.exports = {
sign,
verify,
decode
};
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
看上述代码可以发现,有两个文件读取。这两个文件分别就是私钥和公钥。需要用工具生成。
TODO 需要提到怎么生成公钥和私钥,以及相关配置
在需要用到的地方这么调用:
const jwt = require('./config/token.js')
// 省略...
app.get(async (req, res,next) => {
// 检查一下浏览器访问的页面是否需要登录
// 在白名单内就直接调用 next() 传递给处理路由
if (whiteList.includes(req.url)) {
next();
return;
}
// 从 cookie 中获取到 Authorization
const authorization = req.cookies['Authorization'];
const [err, info] = hp(jwt.verify(authorization));
// 验证失败,说明 token 不合法,清除浏览器 cookie 中的 Authorization 参数
if (err) {
console.error(err);
res.clearCookie();
res.status(401);
return;
}
// 成功,则重新签发一下 token
res.cookie('Authorization', jwt.sign(info));
// 把 token 中包含的信息加入到 请求
req.authInfo = info;
next();
})
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
# TypeScript
# 零零散散的小 Tips
# 优雅的 promise 错误捕捉
既然 await 后接的都是一个 promise, 那么可以这么处理:
// 使用方式
async funtion main() {
const [results, fields] = pool.query("select * from account").catch(err => {
console.log(err);
});
// 接下来放心使用 res
// ...
}
2
3
4
5
6
7
8
9
但是如果每次都这么写是不是太丑了些?提取出重复的处理,封装成函数,就好多了:
/**
* handle error
* @param {Promise} promise promise对象
* @return {Array}
*/
const handleError = promise => promise.then(res => [null, res]).catch(err => [err, null]);
// 或者
// 这个版本主要是为了适应 typescript 的类型系统,更容易定义类型系统,我是这么定义的
async function hp(promise) {
try {
const res = await promise;
return [null, res];
} catch(err) {
return [err, null];
}
}
export async function hp<T, E extends Error>(promise: Promise<T>): Promise<[null, T] | [E, null]> {
try {
const res = await promise;
return [null, res];
} catch (err) {
return [err, null];
}
}
// 使用方式
async funtion main() {
const [err, res] = await handleError(pool.query("select * from account"));
if (err) {
// 处理错误
return;
}
// 接下来放心使用 res
const [results, fileds] = res;
}
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
参考:
# npm scripts 配置
可是有一个问题,每次都运行 app 都要输入 node src/app.js
是不是有点儿太繁琐了呢,而且别人执行你项目的时候,可能还需要摸索以下。既然觉得繁琐,不如简化一下。
在 package.json
中的 scripts
添加一个任务(Task):
{
// ...
"scripts": {
"start": "node src/app.js",
},
// ...
}
2
3
4
5
6
7
之后运行 npm start
项目就启动了。项目的基础也就搭建起来了。
# express 中间件(middleware)原理
# 自己写一个简易的 cookie-parser
# axios 封装
import * as axios from 'axios';
/* 创建axios实例 */
const service = axios.default.create({
baseURL: 'http://localhost:3010',
timeout: 1000 * 6, // 请求超时时间
maxContentLength: 10000,
headers: {
Authorization: '',
}
});
const getCookie = (key) => {
const cookie = document.cookie.split(';').map((row) => {
const [key, val] = row.split('=');
return {
key, val
}
});
for (const row of cookie) {
if (row.key === key) {
return row.val;
}
}
return null;
}
service.interceptors.request.use((config) => {
const token = getCookie('token')
if (token) {
config.headers.Authorization = token;
}
return config;
}, (error) => {
return Promise.reject(error);
});
service.interceptors.response.use((res) => {
if (res.status === 401) {
console.error('请求错误');
window.location.href = '/sign-in';
return;
}
return res;
}, (error) => {
return Promise.reject(error);
});
export default service;
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