基于Vue2 + Koa2搭建前后端分离的博客系统
2017-07-28阅读量
先上博客地址:https://www.xiao555.com.cn/
Github: https://github.com/xiao555/blog
很多人都想有一个自己的博客,很多人也都有自己的博客,现在网上也有很多流行的静态博客生成器,比如 Hexo啦,Jekyll啦,我的第一个博客也是用Hexo生成的,部署在Github Pages,还有VPS上。 但是又不满足于此,想要打造一个包含后端和数据库的完整的博客系统,而且可以让我从前端接触到后端,接触到服务器运维,数据库操作等方方面面,于是这个项目就诞生啦。
这个是我准备找实习练手vue的项目,不过大部分都是在通过腾讯实习后完成的。断断续续开发了这么长时间,也希望写一篇文章记录一下心得和体会。
本文将按照开发顺序,从后端到前端,从部署到优化,讲述一下这个博客系统的出生到落地。
后端篇
既然是前后端分离,那么后端的基本功能我们也就大体清楚:提供API接口供前端进行数据的增删改查。
那为什么要选择Koa作为后端框架呢? 因为作为前端er的我首选就是Node.js的,express 之前用过了, 这次要体验一个不一样的,所有就选择Koa2,而且Koa 也是express 原班人马打造的第二代框架:
koa 是由 Express 原班人马打造的,致力于成为一个更小、更富有表现力、更健壮的 Web 框架。使用 koa 编写 web 应用,通过组合不同的 generator,可以免除重复繁琐的回调函数嵌套,并极大地提升错误处理的效率。koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用变得得心应手。
实现过程中也会发现,Koa 确实如官网所说的简洁优雅,写起来得心应手,而Koa最有意思的当属他的中间件机制吧,就像这个洋葱图, 一层层进去,又一层层出来:
如果你愿意一层一层一层的进入我的心,我就把你一层一层一层地请出去~~ 相关文档,这里就不多说了。
有了后端框架,我们还需要一个数据库保存我们的数据,这里我选择了MongoDB,简单方便,没有关系型数据库那么多道道,而且数据存储格式是BOSN(Binary JSON),我们可以当做JSON来操作。
目录结构
打算用ES6来开发,所以配置了Babel:
// entry.js
require("babel-register")({
presets: [
"es2015",
"stage-0"
],
plugins: ["transform-runtime"]
});
require("babel-polyfill")
require('./app')
由入口文件进入了app.js
// app.js
'use strict';
import Koa from 'koa'
import log from './utils/log'
import middleware from './middleware'
import { connectDatabase } from './db/mongodb'
import api from './api'
import config from './config'
const port = config.port
const app = new Koa()
app.use(async (ctx, next) => { // 洋葱的外皮,利用中间件机制巧妙的计算 每次请求的响应时间
const start = new Date();
await next();
const ms = new Date() - start;
log.info(`${ctx.method} ${decodeURIComponent(ctx.url)} ${ctx.status} - ${ms}ms`);
});
app.use(middleware()) // 加载中间件
app.use(api()) // Koa-router 配置的路由
app.use(ctx => ctx.status = 404) // 默认返回 404
;(async () => { // 还可以愉快的用async await实现异步
try {
const db = await connectDatabase(config.mongoDB) // 连接数据库
log.info(`Connected to ${db.host}:${db.port}/${db.name}`)
await app.listen(port, () => log.info(`Server started on port ${port}`)) // 开启服务,监听端口
} catch(e) {
log.error(e);
}
})()
export default app
感觉Koa写起来十分简洁优雅哈哈,不过是大部分逻辑都抽离出来放在其他文件里了,这里面主要是Log,Middleware, DB, Router, 我们一个一个来解刨。
Log
这里选择的是 log4js 作为日志管理以及debug工具, 实现起来十分方便:
// utils/log.js
import log4js from 'log4js'
import path from 'path'
const env = process.env.NODE_ENV
if(env == 'production') { // 生产环境下日志输出到文件中
log4js.configure({
appenders: [
{ type: 'file', filename: path.join(__dirname,'..', 'log/cheese.log')}
]
});
}
let log = log4js.getLogger('TYPE')
export default log4js.getLogger('Blog')
// 下面这一串是打印输出格式的例子
log.debug("LOG TYPE:")
log.debug('DEBUG')
log.info('INFO')
log.warn('WARN')
log.error('ERROR')
log.fatal('FATAL')
效果如下:
Middleware
Koa 只提供基本的框架,很多功能需要以中间件的形式加进来,这也是他比较轻量化的原因之一。
多个中间件可以用koa-compose组合一下,有的1.0的中间件需要用koa-convert转换成promise Middleware。
// middleware/index.js
import compose from 'koa-compose';
import convert from 'koa-convert';
import helmet from 'koa-helmet';
import cors from 'koa-cors';
import bodyParser from 'koa-bodyparser';
import session from 'koa-generic-session';
const RedisStore = require('koa-redis');
export default function middleware() {
return compose([
helmet(), // 提供安全的header
convert(cors()), // 跨域 配置 Access-Control-Allow-Origin CORS header.
convert(bodyParser()), // 解析 body,存储在 ctx.request.body 里
convert(session({ // session
store: new RedisStore()
})),
]);
}
DataBase
对于MongoDB作为数据库的后端一般用Mongoose开发,数据库的连接:
// db/mongodb.js
import mongoose from 'mongoose'
import log from '../utils/log'
mongoose.Promise = global.Promise; // 内置的promise已经弃用了,需要用自己的promise库,这里选择用原生的ES6 Promise
export function connectDatabase(uri) {
return new Promise((resolve, reject) => {
mongoose.connection
.on('error', error => reject(error))
.on('close', () => log.warn('Database connection closed.'))
.once('open', () => resolve(mongoose.connections[0]))
mongoose.connect(uri)
})
}
Models的创建(以Category为例):
// models/category.js
import mongoose from 'mongoose'
const categorySchema = new mongoose.Schema({
name: String,
number: {
type: Number,
default: 0
}
}, {
versionKey: false
})
export default mongoose.model('category', categorySchema)
Router
最初的models是有四个,article,category,tag,user。为了避免一个一个去配置路由逻辑,所以通过importDir获取Models的集合,文件名就是key,通过generateRouter给每个model加上增删改查的路由,并对应generateAction里的不同的action,对于个别的请求需要加上权限验证。除此之外增加了两个路由,登录后台的/admin/login
和统计文章浏览数的/view/blog
。
// api/index.js
import compose from 'koa-compose'
import Router from 'koa-router'
import importDir from 'import-dir'
import generateRouter from './router'
import generateAction from './actions'
import Admin from './admin'
import Article from '../models/article.js'
import log from '../utils/log'
const prefix = '/api'
const models = importDir('../models')
export default () => {
const router = new Router({ prefix }) // url前缀
Object.keys(models).forEach(key => generateRouter(key, router, Admin.permission, generateAction(models[key])));
router
.post('/admin/login', Admin.login)
.post('/view/blog', async ctx => {
try {
const blog = await Article.find(ctx.request.query)
const result = await Article.findByIdAndUpdate(blog[0].id, {visits: ++blog[0].visits}, {new: true})
if (result) return ctx.status = 200
} catch (e) {
log.error(e)
}
})
return compose([
router.routes(),
router.allowedMethods()
])
}
RESTful API:
// api/router.js (generateRouter)
export default (model, router, permission, actions) => {
router
.get('/'+ model + 's', actions.find)
.post('/'+ model + 's', permission, actions.create)
.get('/'+ model + 's/:id', actions.findById)
.put('/'+ model + 's/:id', permission, actions.updateById)
.delete('/'+ model + 's/:id', permission, actions.deleteById)
}
Config
之所以把user去掉了,是因为想了下这个blog就自己在用,不会有好几个用户的情况,所以直接把用户名密码保存在config里了。除了用户名密码,config里还有mongoDB的地址,redis的地址,服务器运行端口,鉴权秘钥,token过期时间这些配置。
export default {
admin: {
name: 'test',
passwd: 'test'
},
mongoDB: 'mongodb://localhost/blog',
redis: {
host: '127.0.0.1',
port: 6379
},
port: process.env.PORT || 3000,
authSecret: 'blogAuth',
expiresIn: 60 * 60, // token 1h
}
Auth
再来说说权限验证,这里我用的是redis + JWT 的方式。用户在登录成功的时候,后台会生成一个token,用redis保存1h。并且这个token会返回给浏览器端,浏览器端保存在localStorage里,并且当localStorage存在token时,每次请求都会作为header的一个字段带上它,供后端验证。
// api/admin.js (permission 权限验证)
const token = ctx.request.headers['authorization'] || null
if (!token) return ctx.body = {
status: 'fail',
message: 'Token not found'
}
const result = Token.verifyToken(token)
if (!result) return ctx.body = {
status: 'fail',
message: 'Token verify failed'
}
const reply = await redis.getAsync(token)
if (!reply) return ctx.body = {
status: 'fail',
message: 'Token invalid'
}
return next()
Test
在写完api路由的时候用Mocha + Chai做了一下单元测试,用模拟数据测试增删改查功能,下面例子是上传文章的api测试:
// test/api/routes/articles.js
it('should create article', async () => {
const res = await request.post('/api/articles')
.send(article)
.expect(200)
.expect('Content-Type', /json/)
Object.keys(res.body).should.have.length(11);
res.body.should.have.property('_id');
res.body.title.should.equal(article.title);
res.body.content.should.equal(article.content);
res.body.status.should.equal(true);
});
总结
作为一个前后端分离的blog的后端,做好增啥改查,权限验证就好,用Koa实现起来并不难,不过当时写的时候也是看了很多资料,毕竟从零开始,学习别人代码的过程也让我受益匪浅,代码规范性有很大提高,比如下面这个就用了try-catch进行错误处理。
整个后端比较复杂的地方就是对api每个请求的处理了,算是业务逻辑吧(api/actions.js):
// api/actions.js (create)
create: async ctx => {
try {
const body = ctx.request.body // 获取数据
if (model.modelName === 'article') { // 如果是article的话进行这些操作
markdownParse(body); // markdown 语法解析
!!body.tags && await saveTags(body.tags)
!!body.category && await saveCategory(body.category)
}
ctx.body = await model.create(body) // Mongoose create
} catch(e) {
// statements
log.error(e)
}
},
下一篇讲一下前端部分,用vue2写的服务端渲染(SSR)单页应用(SPA).
Code 429: Too many requests. [429 GET https://avoscloud.com/1.1/classes/Comment]
v1.4.14