通过bodyparser源码深入了解koa运行机制

Koa、Egg、Express作为Nodejs三大开发框架,相信对Node有实战经验的朋友都非常熟悉,相对Express,Koa和Egg在企业级开发中应用更为广泛,Egg也是基于Koa的封装,所以我们熟悉Koa之后,对Egg手到擒来,下面我们通过分析中间件koa-bodyparser来深入了解Koa框架中的http请求部分。

基于Koa服务框架

结合http、koa、koa middleware画的一个简单框架图

Koa搭建简单的Node服务

通过koa-router搭建一个简单的node服务,服务提供了一个get接口:/product/1

/* app.js */
const Koa = require('koa');
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

router.get('/product/1', async (ctx, next) => {
  ctx.response.body = {
    data: {},
    success: true
  }
})

app.use(router.routes())
app.listen(3000, () => {
    console.log('app is starting: http://127.0.0.1:3000')
})
复制代码

启动服务

node app.js
复制代码

通过postman请求接口/product/1

通过http响应体可以看出请求返回符合我们预期,但是我们的服务提供的api不全是get请求,了解http的同学可能知道,常用的http method

  • GET
  • POST
  • PUT
  • DELETE
  • HEAD

通过修改app.js添加一个method为post的路由

router.post('/product/list', async (ctx, next) => {
  ctx.response.body = {
    data: {
      list: [],
      page: {},
      success: true
    }
  }
})
复制代码

通过postman请求接口/product/list

请求成功,返回预期数据。但是post和get是有区别的,具体区别不细讲,通常的分页请求我们用post方式,调用一个分页接口会传pageNum、pageSize等查询参数,服务器端拿到这些查询参数拼接成SQL查数据库并返回,服务器怎么拿到这些数据呢?熟悉koa的都知道在ctx.request.body中可以拿到。

路由改造

router.post('/product/list', async (ctx, next) => {
  const pageNum = ctx.request.body.pageNum
  const pageSize = ctx.request.body.pageSize

  ctx.response.body = {
    data: {
      list: [],
      page: { pageNum, pageSize },
      success: true
    }
  }
})
复制代码

重启服务,通过postman再次请求接口/product/list

此时接口响应状态码是500,报服务器异常,我们看看Node服务

Node服务抛了个异常,意思是ctx.request.body为空,拿不到post过来的参数,为什么拿不到?其实ctx.request的body属性是在koa中间件koa-bodyparser中添加的,下面我们来看看koa-bodyparser的实现原理。

Koa-bodyparser的实现原理

上面的例子我们没有引入koa-bodyparser,所以ctx.request上的body属性没有拿到,我们在程序中引入koa-bodyparser看看。

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

app.use(bodyParser())

router.post('/product/list', async (ctx, next) => {
  const pageNum = ctx.request.body.pageNum
  const pageSize = ctx.request.body.pageSize

  ctx.response.body = {
    data: {
      list: [],
      page: { pageNum, pageSize },
      success: true
    }
  }
})

app.use(router.routes())
app.listen(3000)
复制代码

通过postman请求接口/product/list

可以看到接口请求成功,服务器内部成功获取到post过去的参数pageNum和pageSize

koa-bodyparser内部实现

我们去github上clone一份koa-bodyparser的源代码,我以3.1.0版本为例,代码比较简单,我们找到入口index.js

从代码结构可以看出,koa-bodyparser向外暴露一个方法,方法中会在app的request属性和respons,context属性上添加一系列方法:

  • json
  • urlencoded
  • body
  • text
  • buffer

中间件入参app是什么?其实就是koa的上下文context,我们看看koa源码中对context的定义

/* koa源码 */
createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
}
复制代码

koa的上下文是通过createContext方法创建的,创建的目的就是可以在任何地方通过this或者createContext返回的对象中拿到请求(request/req)和响应(response/res)上的各种属性,比如body属性,可以通过ctx.request.body,ctx.req.body,ctx.request.req.body拿到,了解这个之后,我们看看koa-bodyparser中的body方法实现原理。

body方法内部实现

看看koa-bodyparser中body方法源码

request.body = function (limit) {
  switch (this.is('urlencoded', 'json')) {
    case 'json': return this.json(limit)
    case 'urlencoded': return this.urlencoded(limit)
  }
}
复制代码

代码中有个switch分支判断,判断条件this.is('urlencoded', 'json')的返回值,is方法并没有在当前的中间件中实现,其实这个is方法是在koa中实现的,具体的作用就是判断当前的http请求头content-type值,熟悉http的同学对这个请求头不陌生,在封装http client的时候会经常用到,常用的content-type有:

  • text/html
  • text/plain
  • application/json
  • application/x-www-form-urlencoded
  • multipart/form-data

其实content-type有十几种,常用的就是上面几种,每种类型的区别,不熟悉的可以自己百度,看下is方法的示例。

/* When Content-Type is application/json */
this.is('json', 'urlencoded'); // => 'json'
复制代码

因为3.1.0版本的koa-bodyparser,只支持两种类型的content-type,即application/json和application/x-www-form-urlencoded,两者有什么区别?

  • json:post的body是json字符串,例如{pageNum: 1, pageSize:10}
  • x-www-form-urlencoded:post的body是url encoded,例如pageNum=1&pageSize=10

之后koa-bodyparser的高版本支持了更多类型form、text等。通过body方法源码,我们可看出,如果content-type为application/json,switch就会走json分支,调用this.json并返回,我们看看具体实现:

request.json = function (limit) {
  if (!this.length) return Promise.resolve()
  return this.text(limit).then((text) => this._parse_json(text))
}

request.text = function (limit) {
  this.response.writeContinue()
  return get(this.req, {
    limit: limit || '100kb',
    length: this.length,
    encoding: 'utf8'
  })
}
复制代码

this.json调用了this.text方法,thi.text又调用了get方法,并传入了this.req和options,get方法是第三方模块提供的:

const get = require('raw-body')
复制代码

接下来可以看看raw-body模块的实现

raw-body模块实现

raw-body其实就是监听this.req上的data事件,this.req是koa在创建服务时,调用了Node内置模块http的createServer方法传递的

/* koa源码 */
listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

callback() {
    const fn = compose(this.middleware);

    if (!this.listenerCount('error')) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
        const ctx = this.createContext(req, res);
        return this.handleRequest(ctx, fn);
    };

    return handleRequest;
}
复制代码

http模块的createServer调用方式

http.createServer((req, res) => {
    ...
})
复制代码

http模块的源码我们在这里不做分析。上面说到raw-body其实就是监听this.req上的data事件,data事件的入参是post请求body的二进制流,我们可以把二进制流转换为options.encoding中的编码格式,比如utf-8,buffer等,默认为buffer

/* raw-body核心源码 */
  var buffer = decoder
    ? ''
    : []

  // attach listeners
  stream.on('aborted', onAborted)
  stream.on('close', cleanup)
  stream.on('data', onData)
  stream.on('end', onEnd)
  stream.on('error', onEnd)
复制代码
  function onData (chunk) {
    if (complete) return

    received += chunk.length

    if (limit !== null && received > limit) {
      done(createError(413, 'request entity too large', {
        limit: limit,
        received: received,
        type: 'entity.too.large'
      }))
    } else if (decoder) {
      buffer += decoder.write(chunk)
    } else {
      buffer.push(chunk)
    }
  }
复制代码
  function onEnd (err) {
    if (complete) return
    if (err) return done(err)

    if (length !== null && received !== length) {
      done(createError(400, 'request size did not match content length', {
        expected: length,
        length: length,
        received: received,
        type: 'request.size.invalid'
      }))
    } else {
      var string = decoder
        ? buffer + (decoder.end() || '')
        : Buffer.concat(buffer)
      done(null, string)
    }
  }
复制代码

总结

通过koa创建node服务,发起一个post请求,内置模块http处理了很多上游的东西,下游的koa中间件把http body的二进制流根据content-type类型,调用对应的适配器转换成json对象,然后放到ctx.request属性上。

分类:
前端
标签: