前言
在各大网站中,最基础,也最广泛的项目就是内容管理系统(CMS)。本文希望通过开发一个简易的博客系统,来熟悉每个模块的使用,并将这些模块组合起来完成一个完整系统的搭建,学以致用。
项目将用到以下开源框架:
- eggjs
- typescript
- sequlize
- react
- react-dom
- redux
- antd
文章包括以下知识点:
- 使用
sequelize-cli
完成数据库迁移(migration) - 使用
egg-view-assets
实现前后端HMR - 使用
egg-passport
完成鉴权 - 日志操作
- 图片上传
- 中间件使用:自定义错误处理
- 数据库事务
- 路由切割
项目线上地址:kapeter.com
项目截图:
建立项目
项目使用typescript开发,因此在用脚手架搭建项目时,选择ts模板。
$ mkdir egg-blog && cd egg-blog
$ npm init egg --type=ts
$ npm i
$ npm run dev
这个时候项目其实已经跑起来了。可以根据自己的需求新增几个文件夹,最终形成的目录结构如下:
egg-project
├── package.json
├── app.ts (可选)
├── agent.ts (可选)
├── app
| ├── router.ts
│ ├── controller
│ | └── home.ts
│ ├── service (可选)
│ | └── user.ts
│ ├── middleware (可选)
│ | └── response_time.ts
│ ├── schedule (可选)
│ | └── my_task.ts
│ ├── public (可选)
│ | └── reset.css
│ ├── view (可选)
│ | └── home.html
│ └── extend (可选)
│ ├── helper.ts (可选)
│ ├── request.ts (可选)
│ ├── response.ts (可选)
│ ├── context.ts (可选)
│ ├── application.ts (可选)
│ └── agent.ts (可选)
├── config
| ├── plugin.ts
| ├── config.default.ts
│ ├── config.prod.ts
| ├── config.test.ts (可选)
| ├── config.local.ts (可选)
| └── config.unittest.ts (可选)
├── build
| ├── webpack.dev.config.ts
| ├── webpack.prod.config.ts
├── client
| ├── index.tsx
| ├── index.scss
├── database
| ├── migrations
| ├── seeders
| ├── config.json
└── test
├── middleware
| └── response_time.test.ts
└── controller
└── home.test.ts
大部分目录结构是eggjs自有的,可以通过文档了解:eggjs目录结构。这里介绍一下,自定义的部分:
build
:存放webpack配置client
:存放前端代码database
:存放数据库相关配置,用于数据库迁移
设置数据库(MySQL)
以前使用SQL语句建表,这种方式既麻烦也会出现很多问题,比如多人协作下,表结构不一致。在其他语言框架中,都会提供一个命令行工具来实现数据库迁移,比如PHP框架Laravel的migrate模块。
在nodejs中,也有不少命令行工具来实现这个功能。本项目选择的ORM框架是sequelize.js
,因此使用它对应的命令行工具sequelize-cli
。
安装该工具:
$ npm install --save sequelize-cli
设置相关路径,在根目录上新建一个.sequelizerc
文件:
const path = require("path")
module.exports = {
'config': path.join(__dirname, 'database/config.json'),
'migrations-path': path.join(__dirname, 'database/migrations'),
'seeders-path': path.join(__dirname, 'database/seeders'),
'models-path': path.join(__dirname, 'app/model'),
};
在database/config.json
中,配置数据库。
{
"development": {
"username": "kapeter",
"password": "123456",
"database": "egg_blog_local",
"host": "127.0.0.1",
"dialect": "mysql",
"port": 3306,
"charset": "UTF8_GENERAL_CI"
},
"test": {
"username": "kapeter",
"password": "123456",
"database": "egg_blog_test",
"host": "127.0.0.1",
"dialect": "mysql",
"port": 3306,
"charset": "UTF8_GENERAL_CI"
},
"production": {
"username": "kapeter",
"password": "123456",
"database": "egg_blog_production",
"host": "127.0.0.1",
"dialect": "mysql",
"port": 3306,
"charset": "UTF8_GENERAL_CI"
}
}
使用命令行生成一个迁移文件。
$ npx sequelize-cli model:generate --name User --attributes email:string
个人不太习惯写很长的命令行代码,所以attributes
参数是去文件里补全的。
这个命令会生成两个文件:
- 在
models
文件夹中创建了一个user
模型文件 - 在
migrations
文件夹中创建了一个名字像XXXXXXXXXXXXXX-create-user.js
的迁移文件。
首先来看,迁移文件。
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
username: {
type: Sequelize.STRING(50),
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING(50),
allowNull: false,
unique: true
},
password: {
type: Sequelize.STRING,
allowNull: false,
},
created_at: {
allowNull: false,
type: Sequelize.DATE
},
updated_at: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('users');
}
};
里面有两个方法,up
用于创建表,down
用于删除表,是一组互逆的操作。在up
方法里面可以看到字段的描述,上文也提过了,我们可以自由修改这些字段属性,不需要在命令行里全部写齐。
然后执行,迁移命令。
$ npx sequelize-cli db:migrate
可以看到数据库已经生成了这张表。
如果发现迁移文件有问题,比如字段属性写错了等等,我们还可以执行回滚任务,撤销这次任务。
$ npx sequelize-cli db:migrate:undo
如果问题出现在已上线运行的数据库中,此时使用撤销任务会导致数据丢失。因此,我们需要通过另一个迁移文件来修改表,而不是删除重建。
这里是一个实际场景:博客系统上线后,我想在目录表(category
)中新增一个字段。
在database/migrations
中新增一个迁移文件20200715032239-add-type-in-category.js
。
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn('categories', 'type', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'article'
})
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn('categories', 'type')
}
};
类似的方法,但函数名从createTable
变成了addColumn
,也就是说,这次的命令是新增字段。然后执行这个文件,我们就可以在不删除表的情况下,对线上数据库表进行升级。
再来看models
文件。官方生成的是独立使用的文件,我们用的是eggjs
框架,所以需要做点改变。
import { Application } from 'egg';
export default (app: Application): any => {
const { STRING, INTEGER } = app.Sequelize;
const User = app.model.define('user', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
username: STRING(50),
email: STRING(50),
password: STRING
});
return class extends User {}
}
改造点就是把model
绑定到eggjs
的app
对象上。这里需要结合egg-sequelize
使用,下文会提到。
有了表之后,我们可以造一点测试数据进去。
$ npx sequelize-cli seed:generate --name article
这里举的例子是,在文章表(article
)中批量添加50篇文章。通过faker
插件,我们创建一些测试文章,然后把数据批量添加入数据库表。
const faker = require('faker');
module.exports = {
up: (queryInterface, Sequelize) => {
let articles = [];
for (let i = 0; i < 50; i++) {
articles[i] = {
title: faker.lorem.sentence(),
category_id: faker.random.number({min: 1, max: 5}),
desc: faker.lorem.paragraph(),
content: faker.lorem.paragraphs(),
created_at: new Date(),
updated_at: new Date()
}
}
return queryInterface.bulkInsert("articles", articles, {});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('articles', null, {});
}
};
路由配置
数据库生成之后,我们来加些路由。
分析项目,大致可以把路径分为两类。
- 前端路由,如
/admin
,用来导航到对应的前端页面。 - 接口路由,如
/api/article
,以/api
为前缀,用于前端异步请求。
这里我们使用egg-router-plus
来切割这两类路由。
app/router.ts
:
import { Application } from 'egg';
import API from './router/api'
import WEB from './router/web'
export default (app: Application) => {
API(app);
WEB(app);
};
app/router/api.ts
:
import { Application } from 'egg';
export default (app: Application) => {
const { router, controller } = app;
const apiController = controller.api;
const apiRouter = router.namespace('/api');
apiRouter.resources('category', '/category', apiController.category);
apiRouter.resources('article', '/article', apiController.article);
apiRouter.resources('gallery', '/gallery', apiController.gallery);
apiRouter.post('/upload', apiController.tool.upload);
};
通过egg-router-plus
,我们可以给统一的路由前缀一个命名空间,便于管理。
resources
方法是eggjs
提供的方法,用于生成一套RESTful风格的路由,可以减少代码量,有兴趣的可以看他的官方文档。
sequelize连接数据库
上文是创建数据库,这里是要让应用连接到数据库,让业务层可以修改数据。上文也提到过,我们使用egg-sequelize
作为ORM框架,可以理解为sequelize
的eggjs
版本。
eggjs
插件如何引入,官方已经讲的很详细了,这里不在赘述。
在config/config.local.ts
里面设置数据库配置。
config.sequelize = {
dialect: 'mysql',
// host
host: '127.0.0.1',
// 端口号
port: 3306,
// 用户名
username: 'root',
// 密码
password: '123456',
// 数据库名
database: 'egg_blog_local',
// 时区
timezone: '+08:00',
};
在app/controller/api/article.ts
加个方法, 根据id来获取文章内容。
async show() {
const { ctx, service } = this;
const { id } = ctx.params;
let article = await service.article.findById(id);
ctx.body = {
code: 0,
message: 'success',
data: article
};
}
然后在app/service/article.ts
里添加findById
方法。
async findById(id: number): Promise<any> {
const { Article } = this.ctx.model;
return Article.findByPk(id);
}
这里this.ctx.model
就是上文提到的sequelize
实例,通过实例,我们就可以使用sequelize
的方法来查询数据库。
此时,使用浏览器或者postman
访问网址/api/article/1
,就能看到对应数据了(该测试数据就是刚才通过faker
生成的测试数据)。
前端代码集成方案
首先,我们先把eggjs
推荐的模版引擎egg-view-nunjucks
和静态资源管理插件egg-view-assets
集成进来。
指定一下html模板。
config.view = {
root: path.join(appInfo.baseDir, 'app/view'),
mapping: {
'.html': 'nunjucks',
},
};
在app/controller/admin.ts
里面,渲染相应的html页面。
await ctx.render("admin", {}, {
templatePath: path.join(app.config.baseDir, 'app/view/admin.html'),
templateViewEngine: 'nunjucks',
});
<!doctype html>
<html>
<head>
<title>Admin / KaPeter</title>
<link href="https://fonts.font.im/css?family=Roboto:300,400" rel="stylesheet">
{{ helper.assets.getStyle('admin.css') | safe }}
</head>
<body>
<div id="app"></div>
{{ helper.assets.getScript('admin.js') | safe }}
</body>
</html>
helper.assets
可以获取对应的文件映射关系,加载css和js文件。
接着,来配置webpack,这里需要区分开发和线上环境。
const config: webpack.Configuration = {
entry: {
index: './client/index/index.tsx',
login: './client/login/index.tsx',
admin: './client/admin/index.tsx'
},
output: {
path: path.resolve(__dirname, 'public'),
publicPath: 'http://127.0.0.1:8888/',
filename: '[name].js'
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json']
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'babel-loader'
},
{
test: /\.(less|css)/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [{
loader: 'css-loader', // translates CSS into CommonJS
}, {
loader: 'less-loader',
options: {
modifyVars: {
'primary-color': '#15c0b0',
'link-color': '#15c0b0',
},
javascriptEnabled: true
}
}
],
publicPath: '../'
})
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: 'img/[name].[ext]'
}
},
{
test: /\.(ttf|eot|woff|woff2)$/,
loader: 'url-loader',
options: {
limit: 10000,
name: 'fonts/[name].[ext]',
},
},
]
},
plugins: [
new ExtractTextPlugin('[name].css'),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('development')
}
})
],
devServer: {
port: 8888,
contentBase: path.join(__dirname, '../public'),
historyApiFallback: true,
host: '127.0.0.1',
disableHostCheck: true,
headers: {
"Access-Control-Allow-Origin": "*"
}
},
devtool: 'source-map'
}
重点关注output
和devServer
配置,主项目运行在7001端口,我们另起一个8888端口来运行webpack-dev-server
。要注意的是,因为js是在7001端口运行,所以在执行热更新的时候,会出现跨域问题,所以要设置devServer
的header
,使之支持跨域。
然后去config/config.local.ts
里面设置启动devServer的语句, 同时完成文件映射。
config.assets = {
devServer: {
command: 'webpack-dev-server --config ./build/webpack.dev.config.ts --inline --hot',
port: 8888,
devServer: {
env: {
ESLINT: 'none',
PUBLIC_PATH: 'http://127.0.0.1:8888',
},
debug: true
}
}
}
上文提到helper.assets.getScript('admin.js')
就会转化成http://127.0.0.1:8888/admin.js
。此时,运行npm run dev
,就会同时启动node服务(7001端口)和webpack(8888端口),同时支持前后端HMR,就可以愉快地进行开发了。
然后来看正式环境webpack配置。其他配置都一样,就是要加一个manifest组件。
new ManifestPlugin({
fileName: path.resolve(__dirname, '../config/manifest.json'),
publicPath: ""
})
此时,执行打包命令,最终文件会输出到public
文件夹下,同时会在config
文件夹下生成一份manifest.json
。比如:
{
"admin.js": "js/admin.fb2ffa.js",
"admin.css": "css/admin.fb2ffa.css",
"index.js": "js/index.fb2ffa.js",
"index.css": "css/index.fb2ffa.css",
"login.js": "js/login.fb2ffa.js",
"login.css": "css/login.fb2ffa.css"
}
此时,helper.assets.getScript('admin.js')
就会转化成/assets/js/admin.fb2ffa.js
。这个时候,只有一个node服务,devServer是不需要的。
这样我们就把前端代码集成到项目中了。
如果你是用
babel-loader
打包ts文件,需要关注一下这个坑:Typescript+eggjs发布注意事项
鉴权方案
这里使用官方文档中提到的egg-passport
+ passport-local
方案来完成鉴权。
引入插件就不说了。
在app/router/web.ts
中新增鉴权路由。
// 展示登录页面
router.get('/login', controller.auth.login);
// 执行登录
router.post('/login', passport.authenticate('local', { successRedirect: '/admin', failureRedirect: '/login?err=1' }));
// 退出登录
router.get('/logout', controller.auth.logout);
在app/controller/auth.ts
里面写具体实现。
class AuthController extends Controller {
async login() {
const { ctx,app } = this;
if (!ctx.isAuthenticated()) {
await ctx.render("login", {}, {
templatePath: path.join(app.config.baseDir, 'app/view/login.html'),
templateViewEngine: 'nunjucks',
});
} else {
ctx.redirect('/admin');
}
}
async logout() {
const { ctx } = this;
ctx.logout();
await ctx.redirect("/login");
}
}
判断当前用户是否登录(ctx.isAuthenticated()
),未登录显示登录页面,已登录用户直接跳转后台。
在app.ts
中添加鉴权方法, 同时将登录信息记入日志。
app.passport.verify(async (ctx, user) => {
const existUser = await ctx.model.User.findOne({
where: {
username: user.username,
password: crypto.createHmac('sha256', app.config.authSalt).update(user.password).digest('hex')
}
});
if (existUser) {
ctx.getLogger("passportLogger").info(`${user.username} logined ip: ${ctx.ip}`);
}
return existUser;
});
自定义中间件
每一个错误单独处理很麻烦,并且代码量会很多,因此,我们需要创建一个中间件来捕获这些错误,进行统一处理。
在config/config.default.ts
里面注册这个中间件。
config.middleware = [ 'errorHandler' ];
然后,来实现这个中间件。
export default function errorHandlerMiddleWare(): any {
return async (ctx: Context, next: () => Promise<any>) => {
try {
await next();
} catch (err) {
// 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
ctx.app.emit('error', err, ctx);
const status = err.status || 500;
switch (status) {
case 1:
ctx.body = {
code: status,
message: "存在重复key"
}
break;
case 401:
ctx.body = {
code: status,
message: "无操作权限"
}
break;
case 422:
ctx.body = {
code: status,
message: "参数错误"
}
break;
default:
// 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息
const message = ctx.app.config.env === 'prod' ? 'Internal Server Error' : err.message;
ctx.body = {
code: -1,
message: message
};
break;
}
ctx.status = status === 500 ? 500 : 200;
}
};
}
将整个过程用try
和catch
包裹起来,我们在业务代码里只需要抛出对于的错误码,都会被这个中间件捕获,然后我们可以根据不同的错误码返回不同的错误信息。
自定义日志设置
系统默认是按照天来切割日志文件,对于博客这种小流量网站来说,每日访问量可能不足100,生成的日志文件多是空白的。因此我们需要修改默认设置,改成按文件大小来拆分。
// 日志按文件大小分割
config.logrotator = {
filesRotateBySize: [
path.join(appInfo.root, 'logs', appInfo.name, 'egg-web.log'),
path.join(appInfo.root, 'logs', appInfo.name, 'common-error.log'),
path.join(appInfo.root, 'logs', appInfo.name, 'egg-agent.log'),
path.join(appInfo.root, 'logs', appInfo.name, 'egg-schedule.log'),
path.join(appInfo.root, 'logs', appInfo.name, 'miku-web.log')
],
maxFileSize: 16 * 1024 * 1024
};
还有个场景是,我想设置一个只有登录记录的日志。可以通过官方提供的扩展设置来完成。
// 自定义日志
config.customLogger = {
passportLogger: {
file: path.join(appInfo.root, 'logs', appInfo.name, 'miku-passport.log')
}
}
使用起来也方便,在上文的鉴权方案里已经出现过了。
ctx.getLogger("passportLogger").info(`${user.username} logined ip: ${ctx.ip}`);
图片上传方案
在写文章的时候,添加图片是必不可少的。这里结合simplemde
编辑器做一个图片上传方案。
在编辑器初始化时,对image
模块进行自定义。这里的操作是创建一个上传对话框。
simplemde = new SimpleMDE({
element: mdeElement,
toolbar: [
"bold", "italic", "heading", "|", "quote", "ordered-list", "unordered-list", "|", "code",
{
name: "image",
action: () => {
_this.setState({
visible: true
})
},
className: "fa fa-image",
title: "upload",
},
"link", "table", "|", "preview", "side-by-side", "fullscreen", "|", "guide"
]
})
异步上传这个图片文件,上传成功后,关闭对话框,拼接内容,并将内容输出到当前光标处。
handleUpload() {
let formData = new FormData();
const fileList: any = this.file ? this.file.files : [];
formData.append('file', fileList[0])
http.post('/api/upload', formData, {
headers: {
"Content-Type": "multipart/form-data"
}
}).then((res: any) => {
this.setState({
visible: false
})
let str = `![](${res.url})`;
simplemde.codemirror.setCursor(this.cursorPos ? this.cursorPos : {ch: 0, line: 0, sticky: null});
simplemde.codemirror.replaceSelection(str);
}).catch((err: Error) => {
console.log(err);
})
}
再来看上传的后台代码。
async upload() {
const { ctx, app } = this;
if (!ctx.isAuthenticated()) throw { status: 401 };
try {
const file = ctx.request.files[0];
if (!file) throw { status: 404, message: 'file not found' };
const targetPath = path.join(app.baseDir, app.config.uploadPath, moment().format("YYYYMM"));
// 如果不存在该文件夹就创建
await fs.ensureDirSync(targetPath);
const filename = new Date().getTime() + "." + file.filename.split(".").slice(-1);
await fs.copyFileSync(file.filepath, path.join(targetPath, filename));
ctx.body = {
code: 0,
message: "success",
url: "/assets/upload/" + moment().format("YYYYMM") + "/" + filename
};
} catch (err) {
console.log(err);
throw { status: 500, message: err }
} finally {
await ctx.cleanupRequestFiles();
}
}
采用eggjs
内置的Multipart
插件来完成文件上传,然后通过fs-extra
将图片复制到指定文件夹下。这里按上传日期进行文件夹拆分,方便管理。需要注意的是,不敢请求有没有成功,都需要将临时文件清除。
数据库事务处理
在项目中,有这样的场景:删除目录时,需要同步移除该目录下的内容,这时需要同时对两张以上表进行操作。为保证数据一致性,我们需要用到数据库事务。sequelize
提供数据库事务能力,我们需要做的就是和eggjs
结合起来。
async destroy(id: number): Promise<any> {
const { ctx } = this;
const { Category, Article, Gallery } = ctx.model;
const category = await Category.findByPk(id);
if (!category) throw { status: 404 };
const t = await ctx.model.transaction();
try {
// 根据目录类型删除对应内容
switch (category.type) {
case CATEGORY_TYPE.ARTICLE:
await Article.destroy({
where: {
category_id: category.id
}
}, { transaction: t });
break;
case CATEGORY_TYPE.GALLERY:
await Gallery.destroy({
where: {
category_id: category.id
}
}, { transaction: t });
break;
}
// 删除目录
await category.destroy();
return await t.commit();
} catch (err) {
// 事务回滚
await t.rollback();
throw { status: 500, message: err };
}
}
上文提到过,this.ctx.model
是sequelize
的实例,因此通过该实例我们来得到数据库事务的对象t
。先根据目录类型删除对应内容,然后删除目录。使用t.commit()
执行事务,使用t.rollback()
来回滚事务。
部署上线
项目是ts开发的,但egg-scirpt
无法直接识别ts代码,因此,需要通过npm run tsc
,来将ts文件转化成js文件。然后,执行egg-scirpt
运行项目。
剩下的就是通过nginx
将7001端口映射到80端口,设置一下ssl协议。
upstream miku {
server 127.0.0.1:7001;
}
server {
listen 80;
server_name www.your_name.com your_name.com;
if ( $host != 'your_name.com' ) {
rewrite ^/(.*) https://your_name.com/$1 redirect;
}
rewrite ^(.*)$ https://$host$1 redirect;
}
server {
listen 443;
ssl on;
ssl_certificate /web/nginx/cert/your_pem;
ssl_certificate_key /web/nginx/cert/your_key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
server_name your_name.com www.your_name.com;
root /www/your_folder;
client_max_body_size 20M;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_max_temp_file_size 0;
proxy_pass http://miku/;
proxy_redirect off;
proxy_read_timeout 240s;
}
}
到此,就可以通过域名访问你的博客网站啦。