搭建 nodejs 服务器之(1)koa

前言
《搭建 nodeJS 服务器之(1)koa》是系列教程的第一部分。包含路由, 静态资源服务器, session, cookie, 安全, gizp, 缓存, 跨域, 文件上传, 用户验证等知识。同时,本系列教程将会带你从零架构一个五脏俱全的后端项目。
注意,本篇教程面向有一定 koa 使用经验的同学。如果,你还不了解 koa,请先看下面的文档
Koa2.0 中文文档
准备
首先,创建以下项目结构:

config – 配置
controllers – 控制器
middlewares – 中间件
models – 数据库模型(ROM)
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 –save

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 ,controller 和 service 三者的关系:
// 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 = len ? `${Math.random().toString(32).substr(2)}.${ext}` : file.name
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 useravater (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,也就知道了为谁增加商品。这里,你应该可以清晰的感受到 router 与 controller 分离带来的巨大变化了吧!
错误处理

// 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(‘发生了一个致命错误’);

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理