这篇博客以一个简单的博客的登录系统入手,讲一下怎么用 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` 会指定安装指定包的最新版,由于是新项目,没有负担,所以我比较倾向于最新版。
依赖安装完成后,需要创建我们必须的文件夹和文件。通常我是喜欢用如下的结构:
+ public // 存放对外公开的资源文件,例如 js, css, 图片等
+ js
+ css
+ img
+ src // 我们做开发的主要目录
+ routes // 配置路由
+ views // 页面(模板引擎),如果不用模板引擎,而是前后端分离,这个目录就没有必要了
- app.js // 应用入口
+ test // 测试文件目录,写单元测试会用到
文件夹的分工简单明确,对于开发来说是有好处的。通常使用大家都知道的缩写,看起来也很明了。
接下来要开发一个简单的服务器入口了,编辑 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}`);
});
在项目根目录运行 node src/app.js
。在用浏览器访问 localhost:3000
就可以看到我们的应用搭建起来了。
其实要搭建一个 express.js
项目,还有另外一个选择:可以使用 express-generator
点击了解 express-generator
不过这玩意儿截至2020年11月,已经两年没有更新过了。依赖有些过期,而且生成的代码略微繁琐,新语法也没有应用上。所以我不喜欢用。
数据库连接
安装依赖 mysql2
对于 MySQL, npmjs.com 上最火的模块有两个,一个是 mysql
, 另一个是 mysql2
。我更倾向于 mysql2
,因为这个库提供了 Promise API。虽然,封装一个 Promise API 其实也很方便。
npm install mysql2@latest
编写 mysql 连接配置
安装完成后,编写一下基础的 mysql 配置。
先在 src
目录下创建 /config
目录,在此目录中创建 database.js
// 我导入的是 mysql2/promise,这是 Mysql2 库中自带的一个 Promise 兼容。
// 这篇博客里用的是最新版本的 js 语法,用到了 async/await。
// 如果大家不使用 promise, 也可以使用官方文档的错误优先式回调函数的写法,但这篇博客就不赘述了。
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: "localhost",
port: 3306,
user: "root",
password: "root",
database: "att_game",
connectionLimit: 10,
queueLimit: 0,
});
module.exports = pool;
创建一个 mysql 连接池就这么简单。配置项的含义也是一看便懂。
使用连接池查询数据
然后在其他 js 文件里怎么使用呢?
const pool = require("./config/database");
async function main(email) {
if (!email) return;
try {
const [
results, // 结果集,根据你执行的sql语句不同,可能是数组或对象等类型
fileds, // 查询表的字段集,是个数组。多数情况下你可能用不到这个变量
] = await pool.query("select * from account where email=?", [email]);
// 使用 results
for (const row of results)
console.log(row);
} catch (err) {
console.log(err);
}
}
若是简单的单一查询就像上面这么写就很OK了。调用 pool.query(sql: string, args: array)
就可以得到结果集和字段集合。注意一点,如果用的是这个 async/await 的写法,千万千万要包裹try {} catch () {}
处理错误信息,否则,查询一旦报错,会直接崩掉整个应用的。(try...catch... 太不优雅?最后一节会介绍一个方式来包裹错误)
大家可能也看到过有些博客写查询语句是自由拼接的,类似与下面这样:
const [
results, // 结果集,根据你执行的sql语句不同,可能是数组或对象等类型
fileds, // 查询表的字段集,是个数组。多数情况下你可能用不到这个变量
] = await pool.query(`select * from account where email=${email}`);
但是这个既不优雅,也不安全。查看文档。大家千万不要使用字符串拼接的方式了。
接下来说一下上面所说的查询函数返回的结果集的类型。
如果同一时间有好几个查询,如果使用 pool.query()
的话可能会运行在不同的连接上,甚至并行运行。所以,可以用另外一个查询方式。
const main = async () => {
let connection;
try {
connection = await pool.getConnection(); // 从连接池获取一个连接
const [results1] = await connection.query('select xxx');
const [results2] = await connection.query('update xxx');
const [results3] = await connection.query('delete xxx');
const [results4] = await connection.query('insert xxx');
// ...
} catch (err) {
console.log(err);
}
if (connection)
connection.release(); // 不要忘记释放连接 connection
};
这一种方式,要注意一点的是,查询之后,要使用 connection.release()
把连接还给连接池。另外就是错误处理一定要做。
这些也只描述了一部分 使用方法,具体的请看 mysqljs/mysql - Github。这个库的 api 和 mysql2 的几乎是一样的,所以可以阅读这个库的文档。只是上面的示例把写法换成了 es7 的 async/await。
query()
方法的返回的结果集的类型说明
当 sql 语句是 select 查询,返回格式有点儿像下面这样,是包含了所有结果的数组。
[
TextRow {
uid: 10000010,
name: 'jon',
email: '[email protected]',
password: '87ea9be07d37a475f33c383530bc4db46a3a30ce03ebb276b21780221d3a2a43',
coin: 14000,
portrait: null,
signature: null,
state: 0,
pwd_salt: 'gp76354eho6posu3cauoonq3ftecacsg'
}
]
当 sql 语句是 update 更新,返回的是一个对象。我们最有可能用到的是 affectedRows 或 changedRows。具体返回示例如下:
ResultSetHeader {
fieldCount: 0,
affectedRows: 1,
insertId: 0,
info: 'Rows matched: 1 Changed: 1 Warnings: 0',
serverStatus: 34,
warningStatus: 0,
changedRows: 1
}
其中
当 sql 语句是 delete 删除,最有可能用到的依然是 affectedRows,是删除的行数。具体返回示例如下:
ResultSetHeader {
fieldCount: 0,
affectedRows: 1,
insertId: 0,
info: '',
serverStatus: 34,
warningStatus: 0
}
当 sql 语句时 insert 插入,最有可能用到 insertId,这个值是如果有自增 id 的话,插入的自增 id 值,需要注意的是如果其主键值超出了 js 的数字限制,我们需要在创建连接池的时候,添加配置 supportBigNumbers
让库把 id 解析成字符串,否则是会抛出错误的。另外,如果从数据库里取出超出 js 最大限制的数字时也要配置 supportBigNumbers
,否则数字会被四舍五入成几百或者几千,这显然不是我们所期望的,这一点在文档里有。具体返回示例如下:
ResultSetHeader {
fieldCount: 0,
affectedRows: 1,
insertId: 10000015,
info: '',
serverStatus: 2,
warningStatus: 0
}
模板引擎
虽然现阶段很多项目都是前后端分离式的了,对于这些项目,模板引擎没有发挥空间。但是对于非前后端分离的项目,模板引擎依然是必不可少的一部分。
例如,我们这个项目就要使用 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}`);
});
如上,path.join()
把目录连接起来,例如 app.js
在
下, 第二个参数是 views
, 返回的结果就是
;如果第二个参数是 ../views
, 返回的结果就是
。而且,不用在乎斜线是 windows 风格还是 Linux/Mac os 风格,方法内部会自己处理。
你甚至不用显式导入 pug
,只需要用两行代码设置一下模板引擎的种类和路径就好。express 与 pug 结合就是这么简单。在之后的 route 中,就可以使用 res.render()
来用模板引擎生成你的html,返回到浏览器。res.render()
第一个参数是模板路径,相对于上面设置的 views
目录,例如模板是 path/to/views/login.pug
,第一个参数就是 login
, 第二个参数可以是模板中用到的变量,还有第三个是个回调函数,这个传了之后,res.render()
就不会自动把生成的 html 发送到浏览器了,而是等待你的进一步操作,具体请看
如果你使用其他模板引擎的话,可能需要更多配置,不过呢,我在这里没办法写出来,因为我也没有用过 [:)]。
解析 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());
// 省略其他内容
上述部分代码将调用 bodyParser.urlencoded()
和 bodyParser.json()
,并将其返回值作为参数传入到了 app.use()
中。其实调用 app.use()
方法的过程就是为 express 添加了一个中间件。最后一节有对中间件的简单描述,不过这里暂且不用理会什么是中间件。
之后,我们就可以在添加路由的回调函数中,获取到 body 中的内容。
app.post('/login', async (req, res, next) => {
console.log(req.body); // {}
})
解析 cookie: cookie-parser
解析 cookie
可以用到 cookie-parser
npm install cookie-parser@latest
# cookie-parser 是用来解析 cookie,把 cookie 加到 Request 请求对象中去的。
const Express = require('express');
const CookieParser = require("cookie-parser");
const app = Express();
// 解析 Cookie
app.use(CookieParser());
// 省略其他内容
之后,我们就可以在添加路由的回调函数中,获取到 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();
})
此外,其实浏览器发来的 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 天后过期
},
})
);
// 省略其他值
之后使用也很简单:
app.post('/login', async (req, res, next) => {
console.log(req.session);
// 设置session
req.session.key1 = value1;
req.session.key2 = value2;
// 删除 session
req.sesion.destory();
})
路由编写
做到上面几节完成后,我们可以发现系统是能够跑起来了。但展示出来的也不过是让用户可以看到一个 Hello World
,这怎么能行,我们可是要做大事的人。[_]
这一节我们一起来写个路由看一看。
先设计好需求,这一节要完成一个登录的功能,服务端接收放有表单数据的 POST 请求,处理返回登录、注册结果。
第一个实现登录的版本,设计请求头中的 content-type 为 application/x-www-form-urlencoded,登录完成之后,把登录状态保存到 session。
登录请求格式:
POST /login
{
"email": "",
"password": ""
}
具体代码实现:
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}`);
});
上述代码中用到的模板页面,就不贴出来了。模板接收 res.render()
中的参数,其中的 errTip
的作用就是让模板页面能够渲染出来错误提示。
个人觉得没有多重回调和 try {} catch() {}
的代码看起来确实要干净许多。
续
之后还会更新来说一说 jsonwebtoken 登录验证,typescript 实现,以及文内提到的一些要在文末写出来的内容