[埋点搜集服务器] - 总结: Koa 服务端框架用到了哪些能力

  1. 本节目标: 「实现一个简单的埋点服务器」 站在巨人的肩膀上,不仅登高望远,可以低头俯瞰,Node 有很多优秀框架,赋予我们极大的工程效率,那么前面章节学习到的 Node 基础能力在 Koa 这个框架里到底用到了哪些呢?我们拨开云雾看一看。

对于 Node 的框架部分,我们本册只针对 Koa 简单学习一下,因为它的源码更精简,结构更清晰,学习的难度相对较小,Koa 的历史就不多说了,也是 TJ 开创,从 GeneratorFunction 时代到现在的 Async/Await 异步时代,经历了一个较大的版本变化,大家可以翻开 Koa 5 年前的代码 看一看,早期的 Koa 就分离了 Application 和 Context,代码风格和流程的设计就比较精简,对于进化到今天的 Koa ,在它里面像 cookies koa-compose delegates 都尽量抽象出去了,所以剩下的部分,特别的纯粹,只有:

  • application 整个 Koa 应用服务类
  • context Koa 的应用服务上下文
  • request Koa 的请求对象
  • response Koa 的响应对象

这四个也分别对应到 Koa 的四个模块文件: application.jscontext.jsrequest.jsresponse.js,整个 HTTP 的门面就是靠它们扛起来的,代码量加一起也就一两千行,简约而强大,整个 Koa 的设计哲学就是小而美的,比如 Koa 启动一个 HTTP Server 也非常简单:

  1. const Koa = require('koa')
  2. const app = new Koa()
  3. app.use(ctx => {
  4. ctx.body = 'Hello Koa'
  5. }).listen(3000)

在 new 这个 Koa 实例的时候,实际上是基于这个 Application 类创建的实例,而这个类集成了 Emitter 也就是我们前文学习到的事件类,一旦继承了事件,就可以非常方便基于各种条件来监听和触发事件了。

  1. module.exports = class Application extends Emitter {
  2. constructor() {
  3. super()
  4. this.proxy = false
  5. this.middleware = []
  6. this.subdomainOffset = 2
  7. this.env = process.env.NODE_ENV || 'development'
  8. this.context = Object.create(context)
  9. this.request = Object.create(request)
  10. this.response = Object.create(response)

在创建这个实例的时候,对实例上面也挂载了通过 Object.create 所创建出来的上下文对象 context、请求对象 request 和 响应对象 response,所以一开始,这几个核心的对象都有了,后面无非就是基于这些对象做更多的扩展和封装罢了。

为帮助大家读源码,我把 application.js 删减到了 100 行代码,单独拎出来给大家看一下:

  1. const onFinished = require('on-finished')
  2. const response = require('./response')
  3. const compose = require('koa-compose')
  4. const context = require('./context')
  5. const request = require('./request')
  6. const Emitter = require('events')
  7. const util = require('util')
  8. const Stream = require('stream')
  9. const http = require('http')
  10. const only = require('only')
  11. // 继承 Emitter,暴露一个 Application 类
  12. module.exports = class Application extends Emitter {
  13. constructor() {
  14. super()
  15. this.proxy = false
  16. this.middleware = []
  17. this.subdomainOffset = 2
  18. this.env = process.env.NODE_ENV || 'development'
  19. this.context = Object.create(context)
  20. this.request = Object.create(request)
  21. this.response = Object.create(response)
  22. if (util.inspect.custom) {
  23. this[util.inspect.custom] = this.inspect
  24. }
  25. }
  26. // 等同于 http.createServer(app.callback()).listen(...)
  27. listen(...args) {
  28. const server = http.createServer(this.callback())
  29. return server.listen(...args)
  30. }
  31. // 返回 JSON 格式数据
  32. toJSON() {
  33. return only(this, ['subdomainOffset', 'proxy', 'env'])
  34. }
  35. // 把当前实例 JSON 格式化返回
  36. inspect() { return this.toJSON() }
  37. // 把中间件压入数组
  38. use(fn) {
  39. this.middleware.push(fn)
  40. return this
  41. }
  42. // 返回 Node 原生的 Server request 回调
  43. callback() {
  44. const fn = compose(this.middleware)
  45. const handleRequest = (req, res) => {
  46. const ctx = this.createContext(req, res)
  47. return this.handleRequest(ctx, fn)
  48. }
  49. return handleRequest
  50. }
  51. // 在回调中处理 request 请求对象
  52. handleRequest(ctx, fnMiddleware) {
  53. const res = ctx.res
  54. // 先设置一个 404 的响应码,等后面来覆盖它
  55. res.statusCode = 404
  56. const onerror = err => ctx.onerror(err)
  57. const handleResponse = () => respond(ctx)
  58. onFinished(res, onerror)
  59. return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  60. }
  61. // 初始化一个上下文,为 req/res 建立各种引用关系,方便使用
  62. createContext(req, res) {
  63. const context = Object.create(this.context)
  64. const request = context.request = Object.create(this.request)
  65. const response = context.response = Object.create(this.response)
  66. context.app = request.app = response.app = this
  67. context.req = request.req = response.req = req
  68. context.res = request.res = response.res = res
  69. request.ctx = response.ctx = context
  70. request.response = response
  71. response.request = request
  72. context.originalUrl = request.originalUrl = req.url
  73. context.state = {}
  74. return context
  75. }
  76. }
  77. // 响应处理的辅助函数
  78. function respond(ctx) {
  79. const res = ctx.res
  80. let body = ctx.body
  81. // 此处删减了代码,如 head/空 body 等问题的处理策略等
  82. // 基于 Buffer/string 和 流,分别给予响应
  83. if (Buffer.isBuffer(body)) return res.end(body)
  84. if ('string' == typeof body) return res.end(body)
  85. if (body instanceof Stream) return body.pipe(res)
  86. // 最后则是以 JSON 的格式返回
  87. body = JSON.stringify(body)
  88. res.end(body)
  89. }

这个里面,我们关注到这几个点就可以了:

  • new Koa() 的 app 只有在 listen 的时候才创建 HTTP Server
  • use fn 的时候,传入的一个函数会被压入到中间件队列,像洋葱的一层层皮一样逐级进入逐级穿出
  • Koa 支持对 Buffer/String/JSON/Stream 数据类型的响应
  • 上下文 context 是在 Node 原生的 request 进入也就是异步回调执行的时候才创建,不是一开始创建好的,所以每个请求都有独立的上下文,自然不会互相污染
  • 创建好的上下文,Koa 会把它们跟原生,以及请求和响应之间,建立各种引用关系,方便在业务代码和中间件中使用,也就是 createContext 里面所干的事情

看到这里,Koa 这个服务框架对我们就没那么神秘了,索性再把 context.js 代码删减到 50 行,大家再浏览下:

  1. const util = require('util')
  2. const delegate = require('delegates')
  3. const Cookies = require('cookies')
  4. // 上下文 prototype 的原型
  5. const proto = module.exports = {
  6. // 挑选上下文的内容,JSON 格式化处理后返回
  7. toJSON() {
  8. return {
  9. request: this.request.toJSON(),
  10. response: this.response.toJSON(),
  11. app: this.app.toJSON(),
  12. originalUrl: this.originalUrl,
  13. req: '<original node req>',
  14. res: '<original node res>',
  15. socket: '<original node socket>'
  16. }
  17. },
  18. // 错误捕获处理
  19. onerror(err) {},
  20. // 拿到 cookies
  21. get cookies() {},
  22. // 设置 cookies
  23. set cookies(_cookies) { }
  24. }
  25. // 对新版 Node 增加自定义 inspect 的支持
  26. if (util.inspect.custom) {
  27. module.exports[util.inspect.custom] = module.exports.inspect
  28. }
  29. // 为响应对象绑定原型方法
  30. delegate(proto, 'response')
  31. .method('attachment').method('redirect').method('remove').method('vary')
  32. .method('set').method('append').method('flushHeaders')
  33. .access('status').access('message').access('body').access('length').access('type')
  34. .access('lastModified').access('etag')
  35. .getter('headerSent').getter('writable')
  36. // 为请求对象绑定原型方法
  37. delegate(proto, 'request')
  38. .method('acceptsLanguages').method('acceptsEncodings').method('acceptsCharsets')
  39. .method('accepts').method('get').method('is')
  40. .access('querystring').access('idempotent').access('socket').access('search')
  41. .access('method').access('query').access('path').access('url').access('accept')
  42. .getter('origin').getter('href').getter('subdomains').getter('protocol').getter('host')
  43. .getter('hostname').getter('URL').getter('header').getter('headers').getter('secure')
  44. .getter('stale').getter('fresh').getter('ips').getter('ip')

可以发现,之所以 Koa 的请求/响应上下文上有那么多方法和属性可以用,或者可以设置,其实就是这里的 delegate 搞的鬼,它对 context 施加了许多能力,剩下的 request.js 和 response.js 就留给大家自行消化了,都是一些属性方法的特定封装,没有太多的门槛,或者大家可以参考这张图:

12案例十一:[埋点搜集服务器] - 总结: Koa 服务端框架用到了哪些能力 - 图1

12案例十一:[埋点搜集服务器] - 总结: Koa 服务端框架用到了哪些能力 - 图2

12案例十一:[埋点搜集服务器] - 总结: Koa 服务端框架用到了哪些能力 - 图3

其中本册子所讲的几个知识点,在 Koa 中也有大量的使用,比如 path/util/stream/fs/http 等等,不过建议大家学习 Koa 时候重点关注它的网络进出模型,不需要过多关注底层细节。

编程练习 - 开发一个埋点服务器

我们可以用 Koa 开发一个简易的埋点收集服务器,客户端每请求一次,就把埋点的数据往数据库里更新一下,为了演示,我们使用 JSON 结构存储的 lowdb 来模拟数据库,大家可以在本地自行替换为 MongoDB 或者 MySQL,同时我们会用到 Koa 的一个路由中间件,首先我们把依赖的模块安装一下:

  1. npm i koa koa-router lowdb -S

然后创建一个 server.js,代码如下:

  1. const Koa = require('koa')
  2. const path = require('path')
  3. const Router = require('koa-router')
  4. const low = require('lowdb')
  5. const FileSync = require('lowdb/adapters/FileSync')
  6. // 创建一个 Koa 服务实例
  7. const app = new Koa()
  8. // 创建一个路由的实例
  9. const router = new Router()
  10. // 创建一个数据库实例,这里用 lowdb 的 JSON 存储来模拟数据库而已
  11. const adapter = new FileSync(path.resolve(__dirname, './db.json'))
  12. const db = low(adapter)
  13. // 初始化数据库,可以看做是数据库的字段定义
  14. db.defaults({visits: [], count: 0}).write()
  15. // 当有请求进来,路由中间件的异步回调会被执行
  16. router.get('/', async (ctx, next) => {
  17. const ip = ctx.header['x-real-ip'] || ''
  18. const { user, page, action } = ctx.query
  19. // 更新数据库
  20. db.get('visits').push({ip, user, page, action}).write()
  21. db.update('count', n => n + 1).write()
  22. // 返回更新后的数据库字段
  23. ctx.body = {success: 1, visits: db.get('count')}
  24. })
  25. // 把中间件压入队列,等待执行
  26. app
  27. .use(router.routes())
  28. .use(router.allowedMethods())
  29. .listen(7000)

在命令行 node server.js 把服务开起来后,可以从浏览器通过: [http://localhost:7000/?user=a&page=1&action=click](http://localhost:7000/?user=a&page=1&action=click) 来访问,或者从命令里面 curl [http://localhost:7000/?user=a&page=1&action=click](http://localhost:7000/?user=a&page=1&action=click),多请求几次,就会发现数据都存进去了,在 server.js 的同目录,有一个 db.json,里面的数据大概如下:

  1. {
  2. "visits": [
  3. {
  4. "ip": "",
  5. "user": "a",
  6. "page": "1",
  7. "action": "click"
  8. },
  9. {
  10. "ip": "",
  11. "user": "a",
  12. "page": "1",
  13. "action": "click"
  14. }
  15. ],
  16. "count": 2
  17. }

那么本节给大家布置一个小作业,如果把上一节的 cluster 跟这一节的埋点服务器做结合,来提升服务器的负载能力,代码可以怎么写呢?