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` 会指定安装指定包的最新版,由于是新项目,没有负担,所以我比较倾向于最新版。 

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

+ 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 就可以看到我们的应用搭建起来了。

start.png

其实要搭建一个 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/src 下, 第二个参数是 views, 返回的结果就是 /src/views;如果第二个参数是 ../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 实现,以及文内提到的一些要在文末写出来的内容

你可能感兴趣的:(Node.js Express框架开发基础(一))