Node.js Express框架开发基础

2/1/2021 vueNode.js

# 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
1

初始化过程会有许多信息让你填,不过不用理会,一路默认就行。完成后,安装必要的依赖。

npm install express@lastest
# express 是今天的主角,用来搭建 http 服务器用的
# 上面这条命令中的 `@latest` 会指定安装指定包的最新版,由于是新项目,没有负担,所以我比较倾向于最新版。 
1
2
3

依赖安装完成后,需要创建我们必须的文件夹和文件。通常我是喜欢用如下的结构:

+ public      // 存放对外公开的资源文件,例如 js, css, 图片等
  + js    
  + css
  + img
+ src         // 我们做开发的主要目录
  + routes    // 配置路由
  + views     // 页面(模板引擎),如果不用模板引擎,而是前后端分离,这个目录就没有必要了
  - app.js    // 应用入口
+ test        // 测试文件目录,写单元测试会用到
1
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}`);
});
1
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
1

然后在 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}`);
});
1
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: body-parser

大家都知道类似 HTTP 中的 POST 请求的请求参数是不会放到 Url 中的,而是在 body 中。所以我们要完成对 POST 请求的处理,首先便是要让获取到请求体中带的数据,有一个方便的插件 body-parser

先安装这个插件

npm install body-parser@latest
1

安装好后在 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());

// 省略其他内容
1
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); // {}
})
1
2
3

解析 cookie 可以用到 cookie-parser

npm install cookie-parser@latest
# cookie-parser 是用来解析 cookie,把 cookie 加到 Request 请求对象中去的。
1
2
const Express = require('express');
const CookieParser = require("cookie-parser");

const app = Express();

// 解析 Cookie
app.use(CookieParser());

// 省略其他内容
1
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();
})
1
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
1

安装好后需要做一个简单的配置,稍稍比上面的 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 天后过期
    },
  })
);

// 省略其他值
1
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();
})
1
2
3
4
5
6
7
8

# 路由编写

做到上面几节完成后,我们可以发现系统是能够跑起来了。但展示出来的也不过是让用户可以看到一个 Hello World,这怎么能行,我们可是要做大事的人。[^_^]

这一节我们一起来写个路由看一看。

先设计好需求,这一节要完成一个登录的功能,服务端接收放有表单数据的 POST 请求,处理返回登录、注册结果。

第一个实现登录的版本,设计请求头中的 content-type 为 application/x-www-form-urlencoded,登录完成之后,把登录状态保存到 session。

登录请求格式:

POST /login

{
  "email": "",
  "password": ""
}
1
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}`);
});
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
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
1

完成之后,简单地封装一下。

# 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
};
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

看上述代码可以发现,有两个文件读取。这两个文件分别就是私钥和公钥。需要用工具生成。

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();
})
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

# TypeScript

# 零零散散的小 Tips

# 优雅的 promise 错误捕捉

既然 await 后接的都是一个 promise, 那么可以这么处理:

// 使用方式
async funtion main() {
  const [results, fields] = pool.query("select * from account").catch(err => {
    console.log(err);
  });

  // 接下来放心使用 res
  // ...
}
1
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;
}
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

参考:

# npm scripts 配置

可是有一个问题,每次都运行 app 都要输入 node src/app.js 是不是有点儿太繁琐了呢,而且别人执行你项目的时候,可能还需要摸索以下。既然觉得繁琐,不如简化一下。

package.json 中的 scripts 添加一个任务(Task):

{
  // ...
  "scripts": {
    "start": "node src/app.js",
  },
  // ... 
}
1
2
3
4
5
6
7

之后运行 npm start 项目就启动了。项目的基础也就搭建起来了。

# express 中间件(middleware)原理

# 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;
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
Last Updated: 10/23/2021, 4:31:30 PM