《新时期的Node.js入门》学习日记-使用Koa2构建Web站点

0xGeekCat · 2020-9-1 · 次阅读


Node Web框架的发展历程

首先梳理一下Node Web框架的发展历程,从2009年到现在,最为出名的Web框架有三个

Connect

Connect诞生于2010年,这个时间相当早,因为Node项目始于2009年,可以将Connect理解成一个Node中间件的脚手架,只提供了基本的调用逻辑,没有具体的处理逻辑,Connect的源码结构十分简单,只有一个文件,去掉注释后的代码不超过两百行

之所以首先提到Connect是因为它首先在Node服务器编程中引入了中间件middleware概念

中间件的概念并不新鲜,但对于当时还是一片荒芜的Node来说,中间件概念的引入有很重要的意义,因为之后产生的大多数框架都开始采用这一思路,为后面Express的诞生与繁荣打下了基础

中间件的引入将Web开发变成不同模块间的层级调用,有助于开发者将业务逻辑拆分;此外Connect的实现已经成了某种事实的规范

Express

Express框架开发的时间也很早,它继承了Connect的大部分思想,也继承了源码,其发展分为两个阶段,Express3.x与Express4.x

  • 在3.x及之前版本中,Express直接依赖Connect的源码,并内置不少中间件,这种做法的缺点是如果内置的中间件更新,那么开发者就不得不更新整个Express

  • 在4.x中,Express摆脱了对Connect的依赖,并且摒弃除了静态文件模块之外的所有中间件,只保留了核心路由处理逻辑以及一些其他的代码

在过去的几年中,Express取得了巨大的成功,无论是开发者的数量还是社区的活跃程度都是现象级的

🔔MEAN架构MongoDB + Express + Angular + Node成为了不少初创网站的开发首选,至今依旧非常流行

Koa

在某些需要同步调用的场景下处理异步让人窝火,开发者往往会在这上面耗费大量的时间,而不是把主要精力放在业务逻辑上

因此在2013年底,Express的原班开发人马使用ES2015中的新特性,主要是Generator重新打造新的Web框架Koa

Koa的初衷就是彻底解决在Node Web开发中的异步问题,在ES2015还没有被Node完全支持的时候,运行Koa项目需要在启动Node时加上--harmony参数

🔔Koa的理念与Connect更加相似,内部没有提供任何中间件,Express中保留的静态文件和路由也被剔除,仅作为中间件调用的脚手架

Koa的发展同样存在Koa1.x和Koa2两个阶段,两者之间有一定的区别

  • Koa2使用ES2017中async方法来处理中间件的调用
  • Koa1.x使用的是generator

Connect、 Express、 Koa这三个框架可谓一脉相承,Connect目前已经少有人问津,Express和Koa占据了绝大部分的市场

内容规划

需求分析

上传文章

实现Web的富文本编译器是一项吃力不讨好的工作,如果独立开发的话,逻辑的复杂性往往会让开发者陷入绝境。因此通常情况下要实现在线文章的编辑往往借用第三方模块

文章实现的博客系统里,采用本地编写文章,然后上传到网站上的方式实现,这能让我们更关注路由和数据库存储方面的内容

路由设计

👇初步设计的路由

screenshot

技术选型

为了实现目标网站,需要解决👇问题

  • 静态文件服务
  • 路由设计
  • 数据存储
  • 页面渲染

本次使用的技术栈为Node + Koa + MongoDB + Redis + Ejs

  • Node:开发语言
  • Koa:Web开发框架
  • MongoDB:基础的数据存储服务
  • Redis:主要用来存储Session
  • Ejs:页面模板引擎

Koa入门

Koa1.x与Koa2

Koa1.x和Koa2的主要区别在于前者使用Generator,后者使用async方法来进行中间件的管理

Web开发中尽管Node本身是异步的,但还是希望能够顺序执行某些操作,而且代码实现要尽可能简洁

在实际开发中这些操作会抽象为一个个中间件,通常都是异步进行调用的,那么问题就回到如何控制中间件的调用顺序

在Koa1.x的版本中,使用了ES2015提案中的Generator函数来作为异步处理的主要方式。为了实现Generator的自动执行,还使用了co模块作为底层的执行器

安装Koa 1.x& Koa 2.x

screenshot 1

screenshot 4

Koa 1.x示例

var Koa = require('koa')
var app = Koa()

app.use(function *(next) {
    var start = new Date
    // 调用下一个中间件,即向前端响应 'hello, world'
    yield next
    var ms = new Date - start
    // 打印从请求到响应的耗时
    console.log('%s %s - %s', this.method, this.url, ms)
})

app.use(function *() {
    this.body = 'hello, world'
})

app.listen(3000)

screenshot 2

screenshot 3

Koa1.x对中间件的处理基于co模块,这仍然是一种比较hack的方法。ES2017的草案里增加async函数,Koa为此发布2.0版本,这个版本舍弃Genrator函数和co模块,完全使用async函数来实现

Koa和Express最大的不同之处在于Koa剥离了各种中间件,这种做法的优点是可以让框架变得更加轻量,缺点就是Koa发展时间还较短,各种中间件质量参差不齐,1.x和2.x的中间件也存在一些兼容性问题,但对于多数常用的中间件来说,都已经实现了对Koa2.0的支持

❗️文章👇提到的Koa均代表Koa2.0

context对象

使用Koa2.0创建http服务器

const Koa = require('koa')
const app = new Koa()

app.use(ctx => {
    ctx.body = '0xGeekCat'
})

app.listen(3000)

Node提供requestresponse两个对象,Koa把两者封装到context对象中,缩写为ctx

context中封装许多方法和属性,大部分是从request和response对象中使用委托方式得来的

ctx也提供直接访问原生对象的手段,ctx.reqctx.res即代表原生request和response对象

ctx对象还自行封装了一些对象,例如ctx.requestctx.response,它们和原生对象之间的区别在于里面只有一部分常用的属性

const Koa = require('koa')
const app = new Koa()

app.use((ctx, next) => {
    console.log(ctx.request)
    console.log(ctx.response)
})

app.listen(3000)

screenshot 5

ctx.response只有最基本的几个属性,没有注册任何事件或方法,这表示👇的使用方法是错误的

fs.createReadStream('foo.txt').pipe(ctx.response)

screenshot 6

ctx.response只是一个简单的对象,没有定义任何事件,要使用pipe方法,代码要改成👇

fs.createReadStream('foo.txt').pipe(ctx.res)

ctx.state

state属性是官方推荐的命名空间,如开发者想把后端的消息传递到前端,可以将属性挂在ctx.state下面

例如从数据库中查找一个用户id

ctx.state.user = await User.find(id)

其他的一些属性和方法

ctx.app // ctx 对 app 对象的引用
ctx.cookie.get(name, [option]) // 获得 cookie
ctx.cookie.set(name, value, [option]) // 设置 cookie
ctx.throw([msg], [status], [properties]) // 用来抛出异常

处理http请求

Koa在ctx对象中封装了request以及response对象,那么在处理http请求的时候,使用ctx就可以完成所有的处理

ctx.body = '0xGeekCat'
👇 等效
res.statusCode = 200
res.end('0xGeekCat')

ctx相当于ctx.request或者ctx.response的别名,http请求类型通过ctx.method判断,get请求的参数可以通过ctx.query获取

设置路由获取get请求参数

const Router = require('koa-router')
const Koa = require('koa')

const app = new Koa()
const router = new Router()

router.get('/', async (ctx, next) => {
    console.log(ctx.method)
    console.log(ctx.query)
})

app.use(router.routes())
app.listen(3000)

访问http://127.0.0.1:3000/?name=0xGeekCat时console回显

screenshot 7

Koa处理get请求比较简单,直接通过ctx.query.﹤param﹥就能拿到get参数的值,post请求的处理稍微麻烦一些,通常使用bodyParser中间件进行处理,但也仅限于普通表单,获取格式为ctx.request.body.﹤param﹥

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>
<form action="http://localhost:3000/login" method="post">
    <input type="text" name="name">
    <input type="password" name="password">
    <input type="submit" value="submit">
</form>
</body>
</html>

服务端相应路由的代码

var Koa=require('koa')
var router = require('koa-router')()
var bodyParser = require('koa-bodyparser')

var app=new Koa();

router.post('/login', (ctx, next) => {
    console.log(ctx.request.body.name)
    console.log(ctx.request.body.password)
})


app.use(bodyParser());
app.use(router.routes());
app.listen(3000);

screenshot 8

screenshot 9

middleware

中间件的概念

在介绍Koa中间件之前,暂时先把目光投向Express,因为Koa中间件的设计思想大部分来自Connect,而Express又是基于Connect扩展而来

🔔Express本身是由路由和中间件构成,从本质上来说Express的运行就是在不断调用各种中间件

中间件本质上是接收请求并且做出相应动作的函数,该函数通常接收reqres作为参数,以便对request和response对象进行操作,在Web应用中,客户端发起的每一个请求,首先要经过中间件的处理才能继续向下

中间件的第三个参数一般写作next,代表下一个中间件。如果在中间件的方法体中调用next方法,即表示请求会由下一个中间件处理

👇这个函数就可以做为中间件

function md(req, res, next) {
    console.log('I am a Middleware')
    next()
}

中间件的功能

中间件本质仍然是函数,那么它就可以做到Node代码能做到的任何事情,除此之外还包括了修改request和response对象、终结请求-响应循环,以及调用下一个中间件等功能,这通常是通过内部调用next方法实现。如果在某个中间件中没有调用next方法,则表示对请求的处理到此为止,下一个中间件不会被执行

中间件的加载

中间件的加载使用use方法实现,该方法定义在Express或者Koa对象的实例上

例如加载👆定义的中间件md

var app = new Koa()
app.use(md)

Express中的中间件

Express应用可使用👇几种中间件

  • 应用级中间件
  • 路由级中间件
  • 错误处理中间件
  • 第三方中间件

这是官网的分类,实际上这几个概念有一些重合之处

应用级中间件

使用app.use方法或者app.METHOD(),调用绑定在app对象上的中间件,这里Method表示http方法,即get / post等

const express = require('express')
var app = express()

// 没有挂在路径的中间件,前端每个请求都会经过该中间件
app.use(function (req, rees, next) {
    console.log('Time:', Date.now())
    next()
})

app.use('/user/:id', function (req, res, next) {
    console.log('Request Type:', req.method)
})

app.listen(3000)

访问http://127.0.0.1:3000/user/1

screenshot 10

第一个中间件中调用了next方法,因此会转到第二个中间件,第二个由于没有调用next方法,其后的中间件都不会执行

路由级中间件

和Koa不同,路由处理是Express的一部分,通常通过router.use方法绑定到router对象上

const express = require('express')
var app = express()
var router = express.Router()

// 将中间件挂载道 /login 路径下,所有访问 /login 的请求都会经过该中间件
router.use('/login', function (req, res, next) {
    console.log('Time:', Date.now()) 
    next()
})

app.use(router) 👈
app.listen(3000)

访问http://127.0.0.1:3000/login

screenshot 11

错误处理中间件

❗️错误处理中间件有4个参数,即使不需要通过next方法来调用下一个中间件,也必须在参数列表中声明它,否则中间件会被识别为一个常规中间件,不能处理错误

const express = require('express')
var app = express()

app.get('/err',(req,res)=>{
    throw new Error("0xGeekCat");
})

app.use((err,req,res,next)=>{
    console.log(err)
    res.status(500).send('Something broke');
})

app.listen(3000)

screenshot 12

screenshot 14

screenshot 13

内置中间件

从4.x版本开始,Express不再依赖Connect。除负责管理静态资源的static模块的中间件外,Express以前内置的中间件已经全部作为单独模块安装使用

第三方中间件

第三方中间件可以为Express应用增加更多功能,通常通过npm来安装

Koa没有任何内置中间件,连路由处理都没有包括在内,所有中间件都要通过第三方模块来实现,比起Express来,Koa更像是Connect

next方法

无论是Express还是Koa,中间件的调用都是通过next方法来执行的,该方法最早在Connect中提出,并被Express和Koa沿用

  1. 调用app.use方法时,在内部形成了一个中间件数组,在框架内部会将执行下一个中间件的操作放在next方法内部
  2. 当执行next方法时,就会执行下一个中间件
  3. 如果在一个中间件中没有调用next方法,那么中间件的调用会中断,后续的中间件都不会被执行

对于整个应用来说,next方法实现的无非就是嵌套调用,可以理解成一个递归操作,执行完next对应的中间件后,还会返回原来的方法内部,继续向下执行后面的方法

👇洋葱图很形象地解释了Koa中间件的工作原理,对于request对象,首先从最外围的中间件开始一层层向下,到达最底层的中间件后,再由内到外一层层返回给客户端。每个中间件都可能对request或者response对象进行修改

screenshot 15

中间件的串行调用

👇是Koa设计的核心部分,在Web开发中,通常希望一些操作能够串行执行,例如等待写入日志完成后再进行数据库操作,最后再进行路由处理,在技术层面,上面的业务场景表现为串行调用某些异步中间件

Express的异步中间件

var app = require('express')()

app.use(function (req, res, next) {
    next()
    console.log('I am middleware1')
})

app.use(function (req, res, next) {
    process.nextTick(function () { 👈
        console.log('I am middleware2')
        next()
    })
})

app.listen(3000)

screenshot 16

根据👆提到next方法可以理解为递归操作,那么应该先执行输出middleware2,然后再输出middleware1;但由于第二个中间件内的process.nextTick是异步调用,因此马上返回到第一个中间件,然后第二个中间件的回调函数才执行

在有些情况下,可能需要等待middleware2执行结束之后再输出结果。在Koa中借助async / await方法即可轻松实现

Koa中使用async组织的异步中间件

var Koa = require('koa')
var app = new Koa()

app.use(async (ctx, next) => {
    await next()
    console.log('I am middleware1')
})

app.use(async (ctx, next) => {
    process.nextTick(function () {
        console.log('I am middleware2')
        next()
    })
})

app.listen(3000)

screenshot 17

使用await关键字后,直到next内部的异步方法完成之前,midddlware1都不会向下执行

如何实现超时响应

Express中的超时响应

在Web开发中,开发者希望能给长时间得不到响应的请求返回特定的错误信息

在Express中,可以使用connect-timeout第三方中间件来处理响应超时

var express = require('express')
var timeout = require('connect-timeout')

var app = express()

app.use(timeout('5s'))
app.use(some middleware)
app.use(haltOnTimedout)
app.use(some middleware)
app.use(haltOnTimedout)

function haltOnTimedout(req, res, next) {
    if (!req.timedout)
        next()
}

app.listen(3000)

该中间件的实现很简单,timeout内部定义了定时器方法,如果超过定时器规定的时间限制,就会触发错误事件并返回503状态码,并且haltOnTimedout后面的中间件不再执行;如果在定时器触发前完成响应,就会取消定时器

这种做法虽然看起来能解决超时问题,但缺点也很明显,timeout方法中只定义了简单的定时器,如果中间件中包含异步操作那么容易在调用回调方法时出现问题

假设timeout加载后又引入了一个名为queryDB的中间件,该中间件封装了一个异步的数据库操作,并且将查询的结果作为响应消息返回。queryDB在大多数状态下执行很快,1秒内就能完成,但有时会因为某些原因,例如被其他操作阻塞导致执行时间变成10秒,这时timeout中间件已经将超时信息返回给客户端,如果queryDB内部包含res.send方法,就会出现Can't set headers after they are sent的错误

要解决这个问题,比较妥当的方式是通过事件监听的方式,如果超时之后触发该事件,那么取消之后的全部操作,或者直接修改res.end方法,在其中设置flag用来判断是否已经调用过

问题的根本原因是connect-time或者是Express没办法对异步中间件的执行进行很好的控制

Koa中的超时响应

借助async方法中间件会按照顺序来执行,这时进行timeout管理就比较方便,社区也有koa-timeout等中间件

var Koa = require('koa')
var app = new Koa()

app.use(async (ctx, next) => {
    var tmr = null
    const timeout = 5000

    await Promise.race([new Promise(function (resolve, reject) {
        tmr = setTimeout(function () {
            var e = new Error('Request timeout')
            e.status = 408
            reject(e)
        }, timeout)
    }),
        new Promise(function (resolve, reject) {
            // 执行后面加载的中间件
            (async function () {
                await next()
                clearTimeout(tmr)
                resolve()
            })()
        })
    ])
})

❌此知识点没有太理解

常用服务的实现

静态文件服务

之前学习使用原生http和fs模块结合的方法实现静态文件服务,在Web开发中通常不会使用自己封装的方法,这里选择koa-static作为处理静态文件的中间件

const Koa = require('koa');
const app = new Koa();
const server = require('koa-static')

app.use(server(__dirname + '/static/html', {extensions: ['html']}))
app.listen(3000)

static模块规划好静态文件存放的路径,使用app.use挂载在应用上即可

👆__dirname + "/static/html"表示静态文件存放的路径,当接收到请求后,会在该路径下进行查找

serve方法可以接收一个对象作为参数,表示将查找文件的范围限定在指定后缀名范围内。例如👆代码设置{extensions: ['html']},那么在访问文件时就可以省略文件后缀名

👇通过http://127.0.0.1:3000/login访问login.html

screenshot 18

login.html所在文件位置

screenshot 19

路由服务

Express的路由中间件集成在框架内部

const express = require('express')
const app = new express

app.get('/', function (req, res) {
    // todo
})

Koa中的路由处理要借助第三方模块来实现,👇使用koa-router

const Koa = require('koa');
const bodyParser = require('koa-bodyparser')
const router = require('koa-router')()
const app = new Koa()

app.use(bodyParser())
app.use(router.routes())

router.get('/', async (ctx, next) => {
    ctx.response.body =
        '<h1>Index</h1> <form action="/login" method="post">' +
        '<p>Name: <input name="name"></p>' +
        '<p>Password: <input type="password" name="password"></p>' +
        '<p><input type="submit" value="submit"></p>' +
        '</form>'
})

router.post('/login', async (ctx, next) => {
    let name = ctx.request.body.name || '',
        password = ctx.request.body.password || ''

    console.log(ctx.request)

    if (name === '0xGeekCat' && password === '12345') {
        ctx.body = 'Success'
    } else {
        ctx.body = 'Error'
    }
})

app.listen(3000)

👆定义两个路由,接收到get请求后向前端渲染一个form表单用于登录,当用户单击submit提交后,router接收到post请求后使用ctx.request.body对象解析表单中的字段,该对象是router中间件提供的访问接口

因为router也是中间件,因此要使用app.use挂在app对象中,‼️bodyPaser要在router之前加载才能生效

koa-router同样支持定义多种形式的路由

router.get('/:category/:title', function (ctx, next) {
    console.log(ctx.param)
})

:category:title实际上起到get参数的作用,要获取这种形式的参数,可以使用ctx.params对象

router.get('/delete/blog/:blogId', async (ctx, next) => {
    await dbAPI.deleteBlogId(ctx.params.blogId)
    await next()
})

数据存储

在网站的规划中,使用id这一唯一属性来定位一篇博客,而博客是以HTML文件形式存储在static文件夹下的,文件名是博客的标题

为了管理id和文件名以及文件分类之间的映射关系,引入MongoDB来作为数据存储的介质

使用Mongoose访问MongoDB

在SSHstruts2 + spring + hibernate框架开发的J2EE应用中,Hibernate是一种ORM对象关系映射Object Relational Mapper,它提供了Java对象与关系型数据库表的映射关系,使得开发者能编写更高效率的代码而不是直接使用JDBC来连接数据库

在这一点上,Mongoose和Hibernate相似,它同样为Node提供访问MongoDB的接口,它将MongoDB中的collection映射到Node的代码中

Mongoose和Hibernate的不同之处在于Mongoose是一种ODM对象文件映射Object Document Mapper,提供的是对象和文档数据库Document Database之间的映射关系

Mongoose的使用

安装mongoose

screenshot 20

MongoDB的本地实例运行

screenshot 21

连接MongoDB

var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test', { useUnifiedTopology: true, useNewUrlParser: true }); 👈 没有这个options会报错

检测连接状态

var db = mongoose.connection

db.on('error', console.error.bind(console, 'connection error'))
db.once('open', function (callback) {
    // connected
})

Mongoose自身定义了一些数据结构来实现Node代码与MongoDB的映射

要使用Monggose,首先要明确schema、model的概念

  • schema:一种以文件形式存储的数据库模型骨架,不具备数据库的操作能力
  • model:由schema发布生成的模型,具有抽象属性和行为的数据库操作对

如果使用关系型数据库来类比的话,schema大致相当于关系型数据库中的一张表,每个schema中定义若干字段;而model则可以看作是SQL语句的抽象,只能定义在一个schema上,MongoDB的增删改查操作都是通过model来进行的

在数据库中定义一个名为login的collection,它包含两个字段:username、password

var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test', { useUnifiedTopology: true, useNewUrlParser: true });

var db = mongoose.connection

var loginSchema = new mongoose.Schema({
    username: String,
    password: String
})

var login = db.model('login', loginSchema, 'login') 👈
var user1 = new login({username: '0xGeekCat', password: '12345'})
user1.save(function (err) {
    if (err)
        return handleError(err)
})

👆首先声明一个schema,schema内有username和password两个字段,schema相当于collection的骨架

screenshot 22

该方法的第三个参数才是MongoDB中对应collection的名字

var login = db.model('login', loginSchema)

👆如果漏掉第三个参数,❗️Mongoose会自动创建一个名为logins的collection,相当于model名称的复数形式,那么之后在使用collection的时候就会发现一个预期之外的collection

定义好model之后,调用model的save方法将数据存储在对应的collection中

使用Mongoose查询

var login = db.model('login', loginSchema, 'login')
var query = login.find({username: '0xGeekCat'})
query.then(function (doc) {
    console.log(doc)
})

screenshot 23

doc对象是一个包含所有结果集的数组

此外Mongoose是默认支持Promise规范的,这就代表我们可以用ES201X的一些新语法来编写数据库代码

博客系统的数据库准备

在编写代码之前,首先要明确有哪些数据需要存储。除了存储登录信息外,还需要维护关于博客信息的collection

  • title:文章标题
  • kind:文章分类
  • id:文章id

本节定义的collection中,只维护一张信息表,至于博客文章的内容本身,暂且将它们视为静态文件放在static/blogs文件夹下

Schema的定义

在目前的实现中一共定义了两个collection,分别是login和blogList

  • login负责登录
  • blogList用作博客相关的操作
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test', { useUnifiedTopology: true, useNewUrlParser: true });

var db = mongoose.connection
var blogListSchema = new mongoose.Schema({
    title: String,
    kind: String,
    id: String
})

var blogList = db.model('blogList', blogListSchema, 'blogList')

数据查询的实现

数据查询分为两个阶段

  1. 数据库查询并返回结果
  2. 前端页面根据返回的json字符串渲染对应的页面元素

当访问博客网站时,首先会被默认导航至首页,即博客列表。这里以文章的分类作为条件进行查询,如果用户没有选择任何分类,则返回全部文章

查询某个分类下的全部文章

var blogList = db.model('blogList', blogListSchema, 'blogList')

async function getBlogList(kind) {
    let query = {} // 空对象作为查询条件,表示查询所有结果
    let results = []

    if (kind !== '/') {
        query = {kind: kind}
    }

    results = await blogList.find(query)
    return results
}

该方法返回一个包含着若干对象的数组,可以直接用来被前端解析

文件上传

处理文件的上传,大致分为两步

  • 路由收到前端的post请求,将文件存储在static目录下
  • 将form中的文件名、类别信息写入数据库,并赋给这篇博客一个用于访问的id

文件的上传使用formidable实现,formidable是一个著名的处理文件上传的第三方模块,被广泛地用在Node Web应用中

// uplaod.js
const formidable = require('formidable')
const fs = require('fs')

function dealUpload(ctx) {
    var form = new formidable.IncomingForm() 
    form.keepExtensions = true // 保持原来的扩展名
    form.uploadDir = __dirname + '/static/html'
    form.parse(ctx.req, function (err, fields, files) {
        if (err)
            throw err
        fs.renameSync(files.file.path, form.uploadDir + files.file.name)

        // todo save to db
    })
}

module.exports = dealUpload

下一步是将博客信息写入数据库,我们计划给每一篇博客增加id,这一属性是从1开始自增的,因此在插入新的数据前,要获取数据库中最大的id

查找ID的最大值

var blogList = db.model('blogList', blogListSchema, 'blogList')

async function queryMaxID() {
    let temp = 0
    await blogList.find({}).sort({'id': -1}).limit(1).then(function (doc) {
        if (doc.length > 0) {
            temp = doc[0].id
        } else {
            console.log('collection is empty')
        }
    })
    return temp
}

async function insertBlogList(title, kind) {
    let value = await queryMaxID()
    var record = new blogList({title: title, kind: kind, id: ++value})
    record.save(function (err) {
        if (err) {
            console.log(err)
            return 
        }
        console.log('Insert done')
    })
}

👆使用了两个async方法,queryMaxID方法使用了一条链式查询

blogList.find({}).sort({'id': -1}).limit(1)

❗️mongodb中没有其他数据库里的max或者min方法来取最大值和最小值,惯用的做法是先按照id进行排序,然后取第一条

sort()方法可以通过参数指定排序的字段,并使用 1 和 -1 来指定排序的方式

  • 1 为升序排列
  • -1是为降序排列

对文章的修改

由于没有实现一个线上的文本编辑器,因此操作只能局限在删除一篇文章或者修改文章的分类

配置路由

router.post('/delete/blog/:blogId', async (ctx, next) => {
    // todo delete someone blog
    await next()
})

router.post('/modify/blog/:blogId/:kindName', async (ctx, next) => {
    // todo modify blog kind
    await next()
})

删除和修改blog的分类

// delete未定义async方法
function deleteBlogId(id) {
    let query = {id: id}
    console.log(query)
    blogList.remove(query).then(function (doc) {
        console.log('done')
    })
}

function modifyBlogKind(id, kind) {
    let query = {id: id}
    blogList.findOneAndUpdate(query, {kind: kind}).then(function (doc) {
        console.log('done')
    })
}

使用MongoDB存储文件内容

在目前的系统中,将文章以静态文件的形式存放在目录下,在实践中通常是不安全的,通常需要将其存在数据库中,博客文章存储在数据库中通常还要经过加密,这里省略这一步

本章的网站采取用户本地上传的做法,那么在用户上传成功后,就要将文件内容写入数据库中

async function saveBlog(path, id) {
    let content = require('fs').readFileSync(path, {encoding: "UTF-8"})
    let query = new blog({content: content, id: id})
    query.save(function (err) {
        if (err)
            return
        console.log('save done')
    })
}

修改upload.js

form.parse(ctx.req, async function (err, fields, files) {
    if (err)
        throw err
    fs.renameSync(files.file.path, form.uploadDir + files.file.name)
    let value = await dbAPI.insertBlogList(files.file.name, fields.kind)
    console.log('Id is', value)

    await dbAPI.saveBlog(form.uploadDir, fields.kind)
})

文章内容的读取

当用户单击页面元素试图打开文章时,我们需要用id作为参数在数据库中进行查询

async function readBlog(id) {
    let content
    await blog.find({id: id}).then(function (doc) {
        content = doc[0]
    })

    return content
}

在MongoDB中,整片文章都是使用字符串的形式来存储的,对于Koa而言,直接使用👇方法就能在前端返回文章内容

ctx.body = content

打开博客内容的路由设计

router.get('/blog/:blogId', async (ctx, next) => {
    let blogId = ctx.params.blogId
    ctx.body = await dbAPI.readBlog(blogId)
    await next()
})

页面渲染

目前市面上流行的前端解决方案大致有👇几种:

  • 不使用任何框架,直接使用Ajax请求后端,再根据返回的结果对DOM进行操作,这种做法很少见
  • 使用页面模板方式来渲染页面,比较流行的是ejs、jade等几种模板引擎,其原理大都是通过使用正则替换来生成HTML
  • 使用完整的前端框架,近几年前端流行MVVM框架,比较出名的有React、 Angular、Vue等

使用前端渲染,通常需要一个页面引擎,它的本质是一个正则表达式,将引擎定义的标签和后端返回的数据转换成HTML标签本节选择ejs来作为模板引擎,它通过将JavaScript代码嵌入到HTML文件中来实现,其文件扩展名为.ejs

在Koa中使用ejs

首先安装koa-views模块,这是一个比较完整的包含多种页面模板的第三方模块

在root.js中增加👇代码

const views = require('koa-views')

app.use(views(__dirname + '/static/html'), {extensions: ['html']})

在route.js中调用render方法进行渲染

router.get('/blogList', async (ctx, next) => {
    const result = await dbAPI.getBlogList('/')
    return ctx.render('blogList', {results: results})
})

ctx.render方法之前需要加上return关键字,render方法接收两个参数

  • 第一个参数是ejs文件的名字,其路径已经定义在root.js中
  • 第二个参数是一个对象,属性名表示ejs文件中变量的名字,属性名必须和ejs中的定义的变量名相同,否则会出现解析错误

blogList.ejs代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>blogList</title>
</head>
<body>
<ul>
    <% for(var i=0;i<results.length;i++){%>
        
  • <%= results[i].title %>
  • <%}%> </ul> </body> </html>

    👆将文章的id作为dom元素的id,文章的类别作为元素的class属性,当用户单击了某一篇文章后,可以直接使用对应的id来作为http请求的参数进行查询

    根据id打开文件

    现在页面上显示了博客列表,接下来要做的就是打开某一篇具体的文章,现在在页面上有每一篇文章的id,只需要在单击时将id信息打包发出即可,可以使用﹤a/﹥标签或者jQuery来实现,这里选择jQuery

    <script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
    <script type="text/javascript">
        $("li").click(function(){
            $.get("/blog/"+$(this).attr('id'), function(result){
                console.log("done");
            });
        });
    </script>

    前端渲染和后端渲染

    • 前端渲染是指后端提供restful API ,前端只负责调用API并拿到json数据,然后根据拿到的数据更新页面视图或者其他的一些操作,如果开发者在项目中使用了ejs或者jade这样的页面模板,那么通常属于前端渲染
      • 优点是可以实现前后端分离和前端的模块化,事实上近些年涌现的React或者Vue都是以前端渲染为前提的
      • 缺点是SEO不友好
    • 服务器一次性返回全部的HTML字符串,这种方式被称为后端渲染;使用字符串拼接的HTML往往会耗费开发者全部的耐心
      • 这种方式的优点是首屏加载快对SEO有利
      • 缺点是前后端耦合,代码难以维护而且不美观

    screenshot 24

    构建健壮的Web应用

    上传文件验证

    允许用户上传文件其实是很危险的操作,因为你无法期望所有用户都能上传有效合法的文件,因此有必要对上传文件进行验证

    限制文件类型

    对于博客网站文件类型通常只有js/html/css三种类型的后缀名,再加上一些图片后缀或者pdf,系统应当对上传文件的后缀名进行检查,如果不是上述类型的文件名后缀,应该拒绝服务并返回错误码。对文件类型的验证通常在客户端完成

    限制文件大小

    对于网站来说,通常在任何情况下都应该避免大文件的上传,如果服务器对上传的文件没有进行正确的处理,很容易就会出现内存不足的情况,过大的文件也会浪费服务器磁盘空间

    验证文件的大小可以通过两个方面来进行

    • 在客户端上传之前就对文件大小进行判断
    • 在服务器端进行处理时进行验证

    前端验证文件类型和大小

    <form id="form1" action="/upload" method="post" enctype="multipart/form-data">
        <input type="file" name="file" id="uploadFile"/><br/>
        <input type="text" name="kind">
        <input type="button" value="submit" id="sbtn" onclick = "submitForm();"/>
    </form>
    <script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
    <script type="text/javascript">
        function submitForm(){
            var uploadFile = document.getElementById("uploadFile");
            var file = uploadFile.files[0];
            var type = file.type;//例如 text/html
            var fileSize = file.size;
            //文件大小超过100k
               if(fileSize > 100 * 1024 || type!== "text/html"){
                  alert("file type/size error,please check.")
                   return;
               }
            $("#form1").submit();
        }
    </script>

    服务器端验证上传文件大小

    function dealUpload(ctx) {
        if (ctx.method === 'POST' && ctx.path === '/upload') {
            let form = new formidable.IncomingForm()
            form.maxFileSize = 100 * 1024
            form.keepExtensions = true
            form.uploadDir = __dirname + '/static/blogs'
    
            ...
    
            form.on('error', function (e) {
                console.log(e)
                res.writeHead(400, {
                    'Connection': 'close'
                })
    
                res.end('file is too big')
            })
        }
    }

    如果上传的字节超过一定大小就拒绝接收并返回错误码,由于恶意访问者有可能篡改前端代码,因此后端的验证也是必需的

    formidable模块可以做到这一点,该模块使用流来处理上传的文件。在处理文件流的过程中可以获得已上传的文件大小,如果超过预定义的maxFileSize值就会触发error事件并停止接收文件,此时返回给客户端错误消息

    ‼️formidable在npm上的1.1.1版本,在这个版本中设置maxFileSize不会生效,即使检测到了文件大小超出限制也不能取消上传

    👇个人使用的formidable版本

    screenshot 25

    使用Cookie进行身份验证

    现在开发出来的站点是无状态的。在大型项目中的权限系统总是问题最多并且最难管理

    关于Cookie

    Cookie是为了辨别用户信息而存储在客户端的数据

    对于Cookie的使用,最重要的就是要控制Cookie的大小,不要放入无用的信息或者过多信息;无论使用何种服务端技术,只要发送的HTTP响应中包含👇形式的字段,则视为服务器要求客户端设置Cookie

    screenshot 26

    支持Cookie的浏览器都会对此做出反应,即创建Cookie文件并保存,也可能是放在内存中,用户以后在每次发出请求时,浏览器都要判断当前所有的Cookie中有没有处于有效期,根据expires属性判断;并且匹配path属性的Cookie信息,如果有的话,会以下面的形式加入到请求头中发回服务端

    screenshot 27

    Node中的Cookie

    Node设置Cookie很简单,response对象提供了原生的Cookie方法

    screenshot 28

    Koa中的Cookie

    Koa中对Cookie的操作本质上还是对Node原生方法的封装

    screenshot 29

    在博客系统中,主要针对用户登录进行Cookie设置

    router.post('/login', async (ctx, next) => {
        console.log('get')
        let name = ctx.request.body.name || '',
            password = ctx.request.body.password || ''
        const result = await dbAPI.validate(name, password)
    
        if (result) {
            ctx.cookie.set('loginStatus', true)
            ctx.redirect('/blogList')
        } else {
            ctx.body = 'body error'
        }
    })

    👆设置名为LoginStatus的Cookie,之后要做的就通过Cookie来验证登录状态

    function validateStatus(ctx) {
        if (!ctx.cookies.get('loginStatus')) {
            console.log('not login')
            ctx.redirect('/status/html/login.html')
        }
    }

    按照通常的思路,validateStatus方法应该放在route.js中,在收到路由请求后调用

    router.get('/', async (ctx, next) => {
        validateStatus()
        ctx.redirect('/blogList')
    })

    但这样做的缺点很明显,那就是在每一个路由方法中都要调用该函数,这样会带来很多重复代码。更好的做法是将其用中间件的方式来加载,这样每个路由请求都会经过该中间件

    将登录验证作为中间件来实现

    function validateCookie(ctx, next) {
        if (!ctx.cookies.get('loginStatus') && ctx.url !== '/login') {
            console.log('not login')
            ctx.redirect('/login')
        } else
            return next()
    }
    
    module.exports = validateCookie

    👆中间件首先尝试获取名为loginStatus的Cookie,如果没有设置,就将请求重定向到/login路径

    ❗️在判断时需要加上ctx.url !== "/login"的判断,否则当用户第一次访问/login时,由于没有还设置cookie,就会造成循环重定向,最后在浏览器中显示一个重定向次数过多的错误

    然后在root.js中挂载该中间件,❗️应该在路由中间件之前加载,每个路由请求在处理前都要进行登录验证

    screenshot 30

    当访问localhost:3000时,由于没有cookie浏览器页面会跳转到localhost:3000/login

    screenshot 31

    使用Session记录会话状态

    关于Session,不应混淆的是Session规范Session实现

    🔔Session与其说是一种规范,不如说是一种概念,表示用户从进入到离开网络应用这段时间内产生的动作以及上下文

    Session并不是HTTP的独创,而是广泛地体现在各种网络应用和数据库操作中

    • 例如使用FTP协议传输文件,那么从登录到下载文件完成然后离开的这段时间就可以称为一个Session
    • 从拿起电话到拨号然后打完电话离开也是一个Session,而且更接近其语义上的概念(会话)。

    Session主要用来管理用户状态,例如用户对某个网站页面的设置,或者在上个页面中做的一些操作,这些数据也可以放在Cookie中,但是一来会增加传输的数据量,二是有些数据存在Cookie中并不安全,例如电商网站的交易信息等

    HTTP中的Session

    以打电话为例,HTTP服务器就像和多数的用户同时打电话,然而每次说完一句话,服务器就会忘记电话那端是谁,这样的话和多个用户的通话就会带来混乱

    早期的HTTP应用是不可交互的,用户只能浏览静态页面,此时用户状态的问题还没有暴露出来;随着互联网的发展,出现了更复杂的交互式应用,但这时HTTP协议已经获得广泛的应用,想推翻重来是不现实的

    因此对于HTTP协议来说,折中的方法就是利用Cookie来实现Session

    既然Cookie每次都要随着HTTP请求发给服务器,那么只要给每个Cookie一个唯一id,就能知道请求来自哪一用户了,就像👆打电话的例子,只要每个用户在最后说一下自己的名字,服务器就能知道电话那端是谁

    创建Session

    一般来说,创建一个Session可以分为👇几步

    1. 生成一个Session id,这个标识符是唯一的
    2. Session id存储在内存里;实际上调用代码生成Session id后,其自然是位于内存中的,❗️不过如果服务器一旦断电或重启,Session信息就会丢失,因此通常使用一些其他技术来进行持久化,例如Redis
    3. 将带有Session id的Cookie发送给客户端

    在Koa中使用Session

    在Koa中使用Session可以考虑使用koa-session中间件

    screenshot 32

    其中app.keys代表加密用的密钥

    👇测试koa-seesion

    const session = require('koa-session')
    const Koa = require('koa')
    const app = new Koa()
    const router = require('koa-router')()
    // app.keys = ['Key'] 👈 如果CONFIG的signed字段为false就不需要设置keys
    
    const CONFIG = {
        key: 'login',
        maxAge: 86400000,
        overwrite: true,
        httpOnly: true,
        signed: false
    }
    app.use(session(CONFIG, app))
    
    router.get('/', (ctx, next) => {
        ctx.session.login = true 👈 没有这行代码无法正常生成Cookie
        ctx.body = '0xGeekCat'
    })
    
    app.use(router.routes())
    
    app.listen(3000, function () {
        console.log('listening on 3000')
    })

    ❗️由于Cookie的设置是跟在HTTP响应之后,也就是说要设置一个用作Session的Cookie

    screenshot 33

    👆这行代码生成Cookie,Cookie之后会随着HTTP response发送到客户端

    screenshot 34

    可以看到value字段的值是一个看似随机的字符串,这就是之后要使用的Session id

    👇可以设置一个其他路由,检测一下服务器端的Session是否在正常工作

    router.get('/verify', (ctx, next) => {
        ctx.body = ctx.session.login
    })

    screenshot 35

    👆证明设置的Session已经正常工作

    使用Redis进行持久化

    Redis是一个知名的key-value数据库,它由C语言实现,和MongoDB以及其他数据库不同的是Redis是一个内存数据库

    Node和Redis的交互

    npm上有很多用于连接到Redis的第三方模块,👇使用最为流行的node-redis模块

    screenshot 36

    安装完成之后连接Redis

    const redis = require('redis')
    const client = redis.createClient('6379', '127.0.0.1')
    
    client.on('error', function (err) {
        console.log(err)
    })
    
    client.on('ready', function () {
        console.log('ready')
    })
    
    client.set('name', '0xGeekCat', redis.print) 👈 ❗️是print不是print()

    screenshot 37

    👆连接Redis成功之后,设置了一个key为name,value为0xGeekCat的键值对,可在命令行中查询

    screenshot 38

    CURD操作

    get

    node-redis模块提供的API都是对应Redis命令的映射,除了最后的回调函数;模块方法的参数就是对应命令的参数

    此外所有的API操作都是异步的,redis.print就是一个回调函数,用于打印命令的执行结果

    使用get方法获取设置的值

    client.get('name', function (err, reply) {
        // reply is null when the key is missing
        console.log(reply) → 0xGeekCat
    })

    如果想要更新数据值,只需再做一次set操作,原有的值就会被覆盖

    SET

    screenshot 39

    👇只会对一个已经存在的key进行设置,并且设置了10s的过期时间,如果Redis中还没有对应的key,回调函数会返回null。如果该key存在,那么会首先修改value的值,在10s后,该key就会被删除

    client.set('name', '0xGeekCat', 'EX', 10, 'XX', redis.print)

    验证set方法的过期时间

    client.set('name', '0xGeekDog')
    
    client.set('name', '0xGeekCat', 'EX', 10, 'XX', function (err, reply) {
        client.get('name', redis.print)
        setTimeout(function () {
            client.get('name', redis.print)
        }, 10000)
    })

    screenshot 40

    redis.print的结尾没有(),如果错误的使用()会回显Reply: undefined

    从输出可以判断设置的key已经过期,被Redis从列表中删除

    DEL

    client.del('pass', redis.print)

    如果试图删除一条不存在的数据,会返回一个0值

    使用Promise实现同步调用

    和MongoDB相同,Node对Redis的操作也都是异步进行的,这在某些情境下会变得不方便

    对于node-redis模块来说,官方推荐的是使用bluebird来进行方法的Promise化

    安装bluebird后使用bluebird.promisifyAll来将全部的方法转换为Promise,这个过程十分方便

    const redis = require('redis')
    const {Promise} = require('bluebird')
    
    Promise.promisifyAll(redis.RedisClient.prototype)
    Promise.promisifyAll(redis.Multi.prototype)

    set方法的Promise版本

    const client = redis.createClient() 👈 查看源码得知参数默认值 host '127.0.0.1', port 6379
    client.setAsync('name', '0xGeekCat').then(function (res) {
        console.log(res) → OK
    })

    将所有方法转换成Promise之后,使用async方法就成了自然而然的选择

    async function redisTest() {
        await client.setAsync('name', '0xGeekCat')
        let result = await client.getAsync('name')
        console.log(result)
    }
    redisTest() → 0xGeekCat

    使用Redis持久化session

    现在开始尝试将Redis用在👆项目中,这个过程中由于相关的文档的缺乏,有时不得不通过阅读源码的方式来得到正确的用法

    要使用Redis来存储Session,仍然可以使用koa-session模块来完成

    但需要做一些额外的配置,给config增加一个store属性,这是一种类似于Java中接口的设计,只要CONFIG对象声明了该属性,就必须实现set、get和destory方法

    👇修改后的config对象

    const CONFIG = {
        key: 'Koa:sess',
        maxAge: 86400000,
        overwrite: true,
        httpOnly: true,
        signed: false,
        store: {}
    }
    
    CONFIG.store.get = async (key) => {}
    CONFIG.store.set = async (key, sess, maxAge) => {}
    CONFIG.store.destroy = async (key) => {}
    
    router.use(session(CONFIG, app))

    get

    每次当服务器收到请求时都会触发该方法。作为参数的key值就是客户端的Session id,get方法的作用和👇这句作用相同

    let key = ctx.cookies.get("Koa:sess")

    set

    set方法则会在设置session时触发。该方法有三个参数

    • key
    • sess
    • maxAge

    当执行ctx.session.views = ++n时会触发set方法,koa-session模块会自动生成一个key值

    • sess是一个完整的session对象
    • maxAge是config设置的过期时间

    destroy

    destroy方法则是在主动调用时才会触发,用于删除一条Session记录。

    👇观察koa-session在Redis配置下如何工作

    const session = require('koa-session')
    const Koa = require('koa')
    const app = new Koa()
    const redis = require('redis')
    const {Promise} = require('bluebird')
    
    Promise.promisifyAll(redis.RedisClient.prototype)
    Promise.promisifyAll(redis.Multi.prototype)
    
    const client = redis.createClient()
    client.on('error', function (err) {
        console.log(err)
    })
    
    const CONFIG = {
        key: 'Koa:sess',
        maxAge: 86400000,
        overwrite: true,
        httpOnly: true,
        signed: false,
        store: {}
    }
    
    CONFIG.store.get = async (key) => {
        console.log('get key:', key)
        let result = await client.getAsync(key)
        console.log('get result:', result)
    }
    CONFIG.store.set = async (key, sess, maxAge) => {
        await client.setAsync(key, JSON.stringify(sess))
        console.log('set key:', key)
    }
    CONFIG.store.destroy = async (key) => {
        console.log('destroy key:', key)
    }
    
    app.use(session(CONFIG, app))
    
    app.use(async ctx => {
        if (ctx.path === '/favicon.ico')
            return
        ctx.session.agent = ctx.header['user-agent']
    })
    
    app.listen(3000, function () {
        console.log('listening on port 3000')
    })

    使用浏览器访问localhost:3000,可以观察到程序首先调用set方法设置一个Cookie,id的值的来源是http headeruser agent属性(JSON格式),这个key-value键值对随后被写入到Redis

    screenshot 41

    继续刷新页面控制器回显👇

    screenshot 42

    get方法被调用,打印出key值和value的值

    此时注意到服务器又设置了一个新的Session id值,经过试验发现每一次的请求都会分配一个新的Session id

    那么会产生一个问题,set方法不断将新的Session id写入Redis,那么每次请求都会产生新的Session id,显然会浪费Redis的空间

    此时Redis中存储的key值情况

    screenshot 43

    这里的key会越来越多,但永远只有一个是有效的,所以针对一个客户连接只需要存储一个有用的Session id就行

    此时destroy方法的作用就显示出来了,在get方法后面调用destroy方法删除没用的Session id即可

    CONFIG.store.get = async (key) => {
        console.log('get key:', key)
        let result = await client.getAsync(key)
        console.log('get result:', result)
        await CONFIG.store.destroy(key) 👈
    }
    CONFIG.store.destroy = async (key) => {
        await client.delAsync(key)
        console.log('destroy key:', key)
    }

    此时Redis中便只有一个有效Session id存在

    screenshot 44

    在Redis中对于一个连接始终保持一个Session id,为了验证这一点可以使用别的浏览器来访问服务器地址,例如firefox,再次查看Redis中存储的Session id会发现多了一个

    screenshot 45

    Redis在Node中的应用

    消息队列

    一般来说消息队列有两种场景,利用Redis这两种场景都能够实现

    • 生产/消费者模式:生产者生产消息放到队列里,消费者同时监听队列,如果队列里有了新的消息就将其取走,对于单条消息,只能由一个消费者消费
    • 发布者/订阅者模式:发布者向某个频道发布一条消息后,多个订阅者都会收到同一份消息,这和发微博或者朋友圈的效果类似,每个订阅者收到的消息应该都是一样的

    发布者/订阅者模式

    // publisher
    const redis = require('redis')
    const client = redis.createClient()
    
    client.on('ready', function () {
        console.log('ready')
    
        // 向test频道发布一条信息
        client.publish('test', '0xGeekCat')
    })
    // subscriber
    const redis = require('redis')
    const client = redis.createClient()
    
    client.subscribe('test')
    client.on('message', function (channel, message) {
        console.log('channel:' + channel, 'message:' + message)
    })

    Koa源码剖析

    本节主要从源码的角度来讲述Koa,尤其是其中间件系统是如何实现的

    跟Express相比,Koa的源码异常简洁,Express因为把路由相关的代码嵌入到了主要逻辑中,因此读Express的源码可能长时间不得要领,而直接读Koa的源码几乎没有什么障碍

    Koa的主要代码位于koa根目录下的lib文件夹,只有4个文件去掉注释后的源码不到1000行

    • Request.js:对http request对象的封装
    • Response.js:对http response对象的封装
    • Context.js:将上面两个文件的封装整合到context对象中
    • Application.js:项目的启动及中间件的加载

    Koa的启动过程

    👇Koa应用的大致结构

    const Koa = require('koa')
    const app = new Koa()
    
    // load middleware
    app.use(...)
    app.use(...)
    app.use(...)
    
    app.listen(3000)

    Koa的启动过程大致分为三个步骤

    1. 引入Koa模块,调用构造方法新建一个app对象
    2. 加载中间件
    3. 调用listen方法监听端口

    逐步来看这三个步骤在源码中的实现

    首先是类和构造函数的定义,这部分代码位于Application.js中

    Application.js类定义

    screenshot 46

    Application类继承于Events模块,当调用Koa的构造函数时,会初始化一些属性和方法,例如以context/response/request为原型创建的新的对象,还有管理中间件的middleware数组等

    中间件的加载

    中间件的本质是一个函数,在Koa中该函数通常具有ctx和next两个参数,分别表示封装好的res/req对象以及下一个要执行的中间件,当有多个中间件的时候,本质上是一种嵌套调用,形如之前的洋葱图

    Koa和Express在调用上都是通过调用app.use()的方式来加载一个中间件,但内部的实现却大不相同

    Application.js中use方法的定义

    👇use方法的定义

    screenshot 47

    Koa在application.js中维持了一个middleware的数组,如果有新的中间件被加载,就push到这个数组中,除此之外没有任何多余的操作,相比之下,Express的use方法就麻烦得多

    此外该方法中还增加了isGeneratorFunction判断,这是为了兼容Koa1.x的中间件而加上去的

    在Koa1.x中中间件都是Generator函数,Koa2使用的async函数是无法兼容之前的代码的,因此Koa2提供了convert函数来进行转换

    Application.js对中间件的调用

    screenshot 48

    可以看出关于中间件的核心逻辑应该位于compose方法中,该方法是一个名为Koa-compose的第三方模块

    该模块只有一个方法compose,调用方式为compose([a, b, c, ...]),该方法接受一个中间件的数组作为参数,返回的仍然是一个中间件(函数),可以将这个函数看作是之前加载的全部中间件的功能集合

    compose核心方法

    screenshot 49

    方法的核心是一个递归调用的dispatch函数,compose的本质仍是嵌套的中间件

    listen()方法

    这是app启动过程中的最后一步,事实上👆都是为了app的启动做准备,整个Koa应用的启动是通过listen方法来完成的

    👇application.js中listen方法的定义

    screenshot 50

    👆调用了http.createServer方法建立了http服务器,参数为callback方法返回的handleRequest方法

    handleRequest方法做了两件事

    • 封装request和response对象

      const ctx = this.createContext(req, res);
    • 调用中间件对ctx对象进行处理

      screenshot 51

    next()与return next()

    在之前自定义的中间件validateCookie中,最后调用next方法来调用下一个中间件(router),如果将return去掉,再访问localhost:3000/login就会显示not found

    screenshot 52

    Koa对中间件调用的实现本质上是嵌套的promise.resolve方法

    一个简单的中间件模拟示例

    let ctx = 1
    
    let md1 = function (ctx, next) {
        next()
    }
    
    let md2 = function (ctx, next) {
        return ++ctx
    }
    
    let p = Promise.resolve(
        md1(ctx, function next() {
            return Promise.resolve( 
                md2(ctx, function next() {
                // more middleware
            }))
        })
    )
    
    p.then(function (ctx) {
        console.log(ctx)
    })

    👆第一行定义的变量ctx,可以将其看作Koa中的ctx对象,经过中间件的处理后,ctx的值会发生相应的变化

    定义md1和md2两个中间件,md1没有做任何操作,只调用了next方法,md2则是对ctx执行加一的操作,那么在最后的then方法中,我们期望ctx的值为2

    运行代码最后的结果却是undefined,在md1的next方法前加上return关键字后,就能得到正常的结果

    在Koa的源码application.js中,handleRequest方法的最后一行

    return fnMiddleware(ctx).then(handleResponse).catch(onerror);

    中的fnMiddleware(ctx)相当👆声明的Promise对象p,被中间件方法修改后的ctx对象被then方法传给handleResponse方法返回给客户端

    每个中间件方法都会返回一个Promise对象,里面包含的是对ctx的修改,通过调用next方法来调用下一个中间件;再通过return关键字将修改后的ctx对象作为resolve的参数返回

    如果多个中间件同时操作ctx对象,那么就有必要使用return关键字将操作的结果返回到上一级调用的中间件

    这就是为什么需要使用return next()而不是next()。事实上Koa-router或者Koa-static的源码都是使用return next方法

    关于Can’t set headers after they are sent.

    这是使用Express或者Koa常见的错误之一

    其原因如字面意思,对于同一个HTTP请求重复发送了HTTP HEADER。服务器在处理HTTP请求时会先发送一个响应头(使用writeHead或setHeader方法),然后发送主体内容(通过send或者end方法),如果对一个HTTP请求调用了两次writeHead方法,就会出现Can’t set headers after they are sent的错误提示

    const http = require('http')
    
    http.createServer(function (req, res) {
        res.setHeader('Content-Type', 'text/html')
        res.end('0xGeekCat')
        res.setHeader('Content-Type', 'text/html')
    }).listen(3000)

    screenshot 53

    访问localhost:3000就会得到错误信息,这个例子太过直白

    👇是一个Express的例子,由于中间件可能包含异步操作,因此有时错误的原因比较隐蔽

    const express = require('express')
    const app = express()
    
    app.use(function (req, res, next) {
        setTimeout(function () {
            res.redirect('/bar')
        }, 1000)
        next()
    })
    
    app.get('/foo', function (req, res) {
        res.end('foo')
    })
    
    app.get('/bar', function (req, res) {
        res.end('bar')
    })
    
    app.listen(3000)

    访问http://localhost:3000/foo会产生同样的错误,原因也很简单,在请求返回之后,setTimeout内部的redirect会对一个已经发送出去的response进行修改,就会出现错误

    在实际项目中不会像setTimeout这么明显,可能是一个数据库操作或者其他的异步操作,需要特别注意

    Context对象的实现

    ctx对象通过委托获得原生方法和属性

    screenshot 54

    delegate是一个Node第三方模块,作用是把一个对象中的属性和方法委托到另一个对象上

    这个模块的代码同样非常简单,源代码只有100多行;👆代码中,使用了三个方法

    • method:用于委托方法到目标对象上
    • access:综合getter和setter,可以对目标进行读写
    • getter:为目标属性生成一个访问器,可以理解成复制了一个只读属性到目标对象上

    getter和setter这两个方法是用来控制对象的读写属性的

    暂不做过多了解

    关于动态加载中间件

    在某些应用场景中,开发者可能希望能够动态加载中间件,例如当路由接收到某个请求后再去加载对应的中间件,但在Koa中这是无法做到的。Koa应用唯一一次加载所有中间件是在调用listen方法的时候,即使后面再调用app.use方法,也不会生效

    Koa的优缺点

    和Express相比,Koa的优势在于精简,它剥离了所有的中间件,并且对中间件的执行做了很大的优化

    一个经验丰富的Express开发者想要转到Koa上并不需要很大的成本,唯一需要注意的就是中间件执行的策略会有差异,这可能会带来一段时间的不适应

    现在来说说Koa的缺点,❗️剥离中间件虽然是个优点,但也让不同中间件的组合变得麻烦起来,Express经过数年的沉淀,各种用途的中间件已经很成熟;而Koa不同,Koa2.0推出的时间还很短,适配的中间件也不完善,有时单独使用各种中间件还好,但一旦组合起来,可能出现不能正常工作的情况

    举个例子,如果想同时使用router和views两个中间件,就要在render方法前加上return关键字,和return next()一个道理,对于刚接触Koa的开发者可能要花很长时间才能定位问题所在。再例如koa-sessionKoa-router。虽然中间件概念的引入让Node开发变得像搭积木一样,但积木之间如果不能很顺利地拼接在一块的话,也会增加开发成本

    本地部署

    将网站发布到公网上通常要走一些复杂的流程,使用国内的云服务商和域名提供商,还要提供身份信息和备案信息等,对于个人开发者来说,如果嫌这些步骤麻烦,那么建议选择本地部署的方式

    使用Localtunnel实现本地部署

    localtunnel是一个有名的npm第三方模块,它可以很容易地将你的本地服务器映射到公网上,而且不用修改DNS或者防火墙设置

    安装

    localtunnel需要全局安装

    npm install -g localtunnel  

    使用

    先把博客系统在本地运行起来,假设本地端口为3000,然后执行lt命令

    screenshot 55

    该命令会生成一个随机的域名,开发者可以通过该域名来访问自己的网站

    现在就可以使用该域名来访问网站了,不需要任何额外的操作,唯一的缺点可能就是访问比较慢

    通过这种方式部署的网站基本上无法进行SEO优化,也没办法支撑高并发,这不仅由开发者的本地机器决定,更是由localtunnel这种模式本身的特点决定的

    localtunnel原理

    localhost只用一行命令就能实现外网到内网的访问很不可思议。但实际上所有外网的访问,都要先经过localtunnel.me中转之后,才能到达本地主机上,也就是说localtunnel.me起到了转发作用,可以将localtunnel.me看作是一个反向代理服务器

    localtunnel.me会在内部维护一张映射表,记录着每个开发者本地主机的信息,当收到某个子域名下的请求时,会先在映射表中进行查找,然后将对应的请求或者响应信息转发出去

    从本质上说,所有的内网到外网的穿透,都是借助已经部署在公网上的服务器进行中转的,例如一些VPN服务提供商,往往也是通过某台服务器的中转再到达目标网站的

    如果localtunnel.me这个网站本身停止了服务,那么开发者本地的localtunnel模块也会变得不可用

    这也是为什么这种部署方式很难优化的原因,因为流量不是直接来自用户,而是经过了localtunnel服务器的中转,最直观的感受就是网页打开速度非常慢,这让所有的本地优化都失去了意义。但如果是访问量比较小的个人网站,这是比较推荐的方式

    reference

    《新时期的Node.js入门》