搭建 nodeJS 服务器之(1)koa

2,007 阅读6分钟

前言

《搭建 nodeJS 服务器之(1)koa》是系列教程的第一部分。包含路由, 静态资源服务器, session, cookie, 安全, gizp, 缓存, 跨域, 文件上传, 用户验证等知识。同时,本系列教程将会带你从零架构一个五脏俱全的后端项目。

注意,本篇教程面向有一定 koa 使用经验的同学。如果,你还不了解 koa,请先看下面的文档

准备

首先,创建以下项目结构:

  • config - 配置
  • controllers - 控制器
  • middlewares - 中间件
  • models - 数据库模型(ORM)
  • public - 静态资源
  • service - 服务
  • test - 测试
  • view - 视图
  • router.js - 路由文件
  • app.js - 启动文件
  • server.js - koa 主文件

接着,初始化你的 package.json 文件,安装以下依赖。

npm i babel-core babel-preset-es2015 koa koa-body koa-cache-control koa-compress koa-cors koa-logger koa-onerror koa-router koa-session koa-static koa-helmet md5 mkdirp -D
  • babel-core/babel-preset-es2015 - 让 nodeJs 支持 es6 modules
  • koa - koa2
  • koa-body - request body 解析
  • koa-cache-control - 缓存控制
  • koa-compress - gzip
  • koa-cors - 跨域
  • koa-logger - 日志
  • koa-onerror - 错误处理
  • koa-router - 路由
  • koa-session - session
  • koa-static - 静态资源服务
  • koa-helmet - 安全
  • md5 - md5 加密
  • mkdirp - 递归创建目录

让 nodeJs 支持 es6 modules 语法

//  app.js

// 注意,启动文件必须用 require
require("babel-register")
({
    'presets': ["es2015"],
})
    
// 引入 koa 的主文件
require('./server.js')

中间件

// server.js

import Koa from 'koa'
import body from 'koa-body'
import koaStatic from 'koa-static'
import session from 'koa-session'
import cors from 'koa-cors'
import compress from 'koa-compress'
import cacheControl from 'koa-cache-control'
import onerror from 'koa-onerror'
import logger from 'koa-logger'
import helmet from 'koa-helmet'

// 导入 rouer.js 文件
import router from './app/router'

const app = new Koa()

// 在使用 koa-session 之前,必须需要指定一个私钥
// 用于加密存储在 session 中的数据
app.keys = ['some secret hurr']

// 将捕获的错误消息生成友好的错误页面(仅限开发环境)
onerror(app)


app
  // 在命令行打印日志
  .use(logger())
  // 缓存控制
  .use(cacheControl({ maxAge: 60000 }))
  // 开启 gzip 压缩
  .use(compress())
  // 跨域(允许在 http 请求头中携带 cookies)
  .use(cors({ credentials: true }))
  // 安全
  .use(helmet())
  // 静态资源服务器
  .use(koaStatic(__dirname + '/app/public'))
  // session
  .use(session(app))
  // 解析 sequest body
  // 开启了多文件上传,并设置了文件大小限制
  .use(body({
    multipart: true,
    formidable: {
      maxFileSize: 200 * 1024 * 1024
    }
  }))
  // 载入路由
  .use(router.routes(), router.allowedMethods())
  // 启动一个 http 服务器,并监听 3000 端口
  .listen(3000)

// 导出 koa 实例(用于测试)
export default app

路由(koa-router)

如果,你有足够的好奇心,我相信,这样的代码你一定写过:

// router.js

import Router from 'koa-router'
const router = new Router

router.prefix('/api')

router
    .get('/goods/find', async ctx => {/* ... */}))
    .post('/goods/add', async ctx => {/* ... */})
    .post('/goods/update', async ctx  => {/* ... */})
    .post('/goods/remove', async ctx => {/* ... */})

export default router

一个典型的商品的增删改查。这本身没任何问题,但当你的项目足够大,比如有二十,甚至上百个接口时,你会发现一个严重的问题,一个文件太挤。于是,你把路由模块化了。但是,同时你又会发现有些逻辑相互重叠,是否能更好的封装起来,形成良性的复用呢?

当然可以!

并且,我们进一步遵循 RESTful 规范来净化我们的接口。

这意味着,你需要把路由的逻辑分离到 controllers 中,并把可复用的函数或类抽象到 service 中,而我们的路由只是单纯的取需即可。

// controllers/goods.js

export default class Goods {
  static async find () {}
  static async add () {}
  static async remove () {}
  static async update () {}
}
// router.js

import Router from 'koa-router'
const router = new Router

router.prefix('/api/v1')

/** goods **/
import goods from './controllers/goods'
const GOODS_BASE_URL = '/goods'

router
  .get(GOODS_BASE_URL, goods.find)
  .post(GOODS_BASE_URL, goods.add)
  .put(GOODS_BASE_URL, goods.update)
  .delete(GOODS_BASE_URL, goods.remove)

export default router

很干净吧!我们将所有的路由放在 router.js 进行统一的管理, 把对应的逻辑放到 controllers 中。当你的 controllers 发生重叠或者有大量密集的计算时,你可以进一步把庞大的 controllers 拆分到 service 中,更好解耦,更具原子。

静态资源服务器(koa-static)

.use(koaStatic(__dirname + '/app/public'))

koa-static 会将你项目中的 public 目录当做静态资源服务器的根目录。

body 解析(koa-body)

use(body({
    multipart: true,
    formidable: {
        maxFileSize: 200 * 1024 * 1024
    }
}))

koa-body 有两个常见的场景,解析 post 请求提交的数据和获取上传的文件

// 获取前端提交的数据
const post = ctx.request.body
// 获取上传的文件
const files = ctx.request.body.files

缓存控制(koa-cache-control)、gzip(koa-compress) 、跨域(koa-cors)和安全(koa-helmet)

.use(cacheControl({ maxAge: 60000 }))
.use(compress())
.use(cors({ credentials: true }))
.use(helmet())

当开启缓存后,浏览器不仅缓存了文件,还缓存 GET 请求的数据。为了避免这种情况,可在 url 后面增加一个时间戳:

http.get('/api/v1/goods?timestamp=' + (new Date).getTime())

让 cors 携带 cookie,必须为其指定 credentials: true 。且前端发送跨域请求时开启 withCredentials

// 以 axios 为例

import http from 'axios'

http.defaults.baseURL = 'http://localhost:3000'
http.defaults.withCredentials = true

session(koa-session)

app.keys = ['some secret hurr']
// ...
.use(session(app))

注册 koa-session后,通过 ctx.session 向 session 存入数据。


router.get('/test/seesion', ctx => {
   ctx.session.msg = 'test session'
})

请求 GET /test/seesion 后,可在浏览器中看到它依赖了 cookie 并加密了我们存入的数据。

文件上传

由于,文件上传在业务中的高度可复用性,所以很适合封装成一个 service

接下来,让我们通用户头像上传的例子看看 router ,controllerservice 三者的关系:

// service/file.js

import fs from 'fs'
import path from 'path'
import mkdirp from 'mkdirp'

export const updateFile = (basePath, files) => {
   // 同步递归创建目录
    mkdirp.sync(basePath)
    const filePaths = []
    for (let key in files) {
      const file = files[key]
      const ext = file.name.split('.').pop()
      // 重命名文件
      const filename = `${Math.random().toString(32).substr(2)}.${ext}`
      const filePath = path.join(basePath, filename)
      const reader = fs.createReadStream(file.path)
      const writer = fs.createWriteStream(filePath)
      // 写入文件
      reader.pipe(writer)
      filePaths.push(filePath)
    }
    
    return filePaths
}
// contorllers/user.js

import {updateFile} from '../service/file'

export default class User {
  static async avatar (ctx) {
    let filePath
    // 获取上传的文件
    const {file} = ctx.request.body.files || {}
    try {
      // 保存到指定路径
      filePath = updateFile('app/public/images/user/avatar', file)[0]
      // 更新 user 表中的 avatar 字段
      await db.user.update({
        where: {
          avatar: filePath
        }
      })
    } catch (error) {
      return ctx.body = { success: false, error }
    }
    
    ctx.body = {
      success: true,
      filePath
    }
  }
}
// router.js

// ...
/** user **/
import user from './controllers/user'
const USER_BASE_URL = '/user'

router
  .post(USER_BASE_URL + '/avatar', user.avatar)
//...

从大体上看, updateFile (service)封装成了一个纯函数,并为 User.avatar (controller)提供了保存用户头像的功能,接着把 User.avatar 作为 POST \user\avater (router)接口的逻辑。

另一个例子 —— 用户验证

由于 http 协议的无状态性,我们依赖 cookie 存储 token(用户信息)。当用户想要为它的商品列表增加一行记录时,就需要对发起请求方的身份进行有效的验证,告诉服务器他是谁?应该为谁的商品增加一条记录?

现在,我们通过一个验证拦截器解决这个问题:

// controllers/user.js

export default class User {
    static async validator (ctx, next) {
      // 解析 token
      const {id, password} = jwt.verify(ctx.cookies.set('token'),  secret)
      // 向数据库匹配用户
      const result = await db.user.find({where: {
        id,
        password
      }})
        
      if (result) {
          // 存在,把当前用户的 id 通过 ctx.state 传递出去 
          ctx.state.id = id
          await next()
      } else {
        // 不存在,拦截掉后续的中间件并返回对应的数据
        return ctx.body = {
          success: false,
          error: '用户身份已过期'
        }
      }
    }
}

然后,扩展 goods 路由逻辑:

/** goods **/

import goods from './controllers/goods'
const GOODS_BASE_URL = '/goods'

router
  .get(GOODS_BASE_URL, user.validator, goods.find)
  .post(GOODS_BASE_URL, user.validator, goods.add)
  .put(GOODS_BASE_URL, user.validator, goods.update)
  .delete(GOODS_BASE_URL, user.validator, goods.remove)

goods.add 中,我们可以通过 ctx.state.id 获取通过验证的用户 id,也就知道了为谁增加商品。这里,你应该可以清晰的感受到 routercontroller 分离带来的巨大变化了吧!

错误处理

// middlewares/erros.js

export default async function(ctx, next) {
  try {
    await next();
  } catch (err) {
    const status = ctx.status = err.status || 500
    
    // 自定一些常见的错误逻辑
    if (status === 404) { /** **/}
    if (status === 500) { /** **/}

    // 同时,触发 app 的 error 事件
    // 它会捕获应用级别的错误消息
    ctx.app.emit('error', err, ctx);
  }
}
// 抛出一个 403 错误
ctx.throw(403, '用户身份已过期')

// 直接抛出, 默认为 500 错误
throw new Error('发生了一个致命错误');