前言
《搭建 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
,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 = `${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,也就知道了为谁增加商品。这里,你应该可以清晰的感受到 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('发生了一个致命错误');