笔者前公司用的是think.js作为后端框架,初次使用,深感业务场景的傻瓜式。它就是一个基于koa二次开发。一个显著的特点就是可以在对应文件夹下直接书写接口。比如api /aaa/
对应 aaa
文件夹下的index。/bbb/aaa/user
对应bbb文件夹下的 aaa.js
下等 user
方法等。
本文重点阐述的是一个企业级框架的实现过程。基于koa中间件这一强大的机制。可以给自己的node开发搞出很多好玩的东西。
认识egg.js(阿里系框架)
Egg.js 为企业级框架和应用而生,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本。
Express 是 Node.js 社区广泛使用的框架,简单且扩展性强,非常适合做个人项目。但框架本身缺少约定,标准的 MVC 模型会有各种千奇百怪的写法。Egg 按照约定进行开发,奉行『约定优于配置』,团队协作成本低。
官方文档 https://eggjs.org/zh-cn/intro/quickstart.html
安装
代码语言:javascript复制npm install egg-init -g
egg-init --type=simple
// showcase && cd showcase
npm install
npm run dev
// open http://localhost:7001
约定优于配置:接口
一般接口
接口逻辑在controller下的文件夹下。
代码语言:javascript复制// app/controller/home.js
'use strict';
const Controller = require('egg').Controller;
class HomeController extends Controller {
async index() {
const { ctx } = this;
ctx.body = 'hi, egg';
}
}
module.exports = HomeController;
路由逻辑:
代码语言:javascript复制// app/router.js
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
};
怎么写接口:
代码语言:javascript复制// router.js
router.get('/user',controller.user.index);
// 新建控制器 user.js
'use strict';
const Controller = require('egg').Controller;
class UserController extends Controller {
async index() {
const { ctx } = this;
ctx.body = [{
name:'dangjingtao'
},{
name:'djtao'
}];
}
}
module.exports = UserController;
复杂接口:分离业务逻辑的服务集成
当controller特别复杂时,需要对controller继续分层,在controller同级别目录下新建service目录。新建一个user.js
代码语言:javascript复制// app/service/user.js
const {Service}=require('egg');
class UserService extends Service{
async getAll(){
return [{name:'djtao'},{name:'dangjingtao'}]
}
}
module.exports=UserService;
在使用时不需要做任何引入,直接可通过 ctx.service.user
调用 getAll
:
// controller/user.js
'use strict';
const Controller = require('egg').Controller;
class UserController extends Controller {
async index() {
const { ctx } = this;
// ctx.body = [{
// name:'dangjingtao'
// },{
// name:'djtao'
// }];
console.log(111111,ctx.service.user)
ctx.body= await ctx.service.user.getAll();
}
}
module.exports = UserController;
接入数据库
配置
以接入mysql为例:使用sequelize作为工具:
代码语言:javascript复制npm i egg-sequelize mysql2 -S
在 config/config.default.js
中便携serquelize配置
const userConfig = {
// myAppName: 'egg',
sequelize:{
dialect:'mysql',
host:'127.0.0.1',
port:'3306',
username:'root',
password:'12345678',
database:'eggjs'
}
};
在plugin.js中注册插件:
代码语言:javascript复制'use strict';
/** @type Egg.EggPlugin */
module.exports = {
// had enabled by egg
// static: {
// enable: true,
// }
serquelize:{
enable:true,
package:'egg-sequelize'
}
};
建立数据模型
在app目录下新建model文件夹,新建user.js
代码语言:javascript复制module.exports=app=>{
const {STRING}=app,Sequelize;
const User=app.model.define(
'user',
{name:STRING(30)},
{timestampt:false}
);
User.async({force:true});
return User;
}
使用时也是直接调用 this.ctx.model.User.
async getAll(){
// return [{name:'djtao'},{name:'dangjingtao'}];
return await this.ctx.model.User.findAll()
}
调用如下:
手撸一个MVC(degg.js)
自己手撸mvc,期望实现以下特点:
- 基于koa,模仿egg(koa-egg.js)
- 目标是创建约定大于配置、开发效率高、可维护性强的项目架构
路由(router)
代码语言:javascript复制规范:
- 所有路由放到一个routes文件夹中
- 若导出路由对象,使用 动词 空格 路径 作为key,值是操作方法
- 到处函数,则函数返回第二条作为约定格式的对象。
路由定义
先看路由需要实现什么:
- 新建routes/index.js,默认Index.js没有前缀
module.exports = {
'get /': async ctx => {
ctx.body = '首页'
},
'get /detail': ctx => {
ctx.body = '详情'
}
}
- 新建routes/user.js
module.exports = {
"get /": async ctx => {
ctx.body = "用户首页";
},
"get /info": ctx => {
ctx.body = "用户详情页面";
}
};
路由加载器
然后就是实现路由加载器。能够读文件夹,"智能地"解析路由。
在根目录下新建 degg-loader.js
// 路由加载器
const fs = require("fs");
const path = require("path");
const Router = require("koa-router");
/**
* 读文件夹
* @param {文件夹} dir
* @param {回调,参数是文件名和文件路径} cb
*/
function load(dir,cb){
//获取绝对路径
const url=path.resolve(__dirname,dir);
const files=fs.readdirSync(url);
files.forEach(filename=>{
filename=filename.replace('.js','');
const file=require(url '/' filename);
cb(filename,file);
})
}
/**
* 初始化路由
*
*/
function initRouter(){
const router=new Router();
load('router',(filename,routes)=>{
// 对于index,需要特殊处理:请求后缀为index。指向跟路径
const prefix =filename==='index'?'':`/${filename}`;
Object.keys(routes).forEach(key=>{
// 根据空格解析
const [method,path]=key.split(' ');
console.log(`正在映射地址:${method.toLocaleUpperCase()} /${prefix}/${path}`);
router[method](prefix path,routes[key])
})
})
return router;
}
module.exports={initRouter}
测试:
代码语言:javascript复制// 根目录index.js
const app = new (require('koa'))()
const {initRouter} = require('./kkb-loader')
app.use(initRouter().routes())
app.listen(3000)
封装
现在跑通了,但是明显看出了对koa的依赖,可以根据面向对象的思想稍微封装一下:
代码语言:javascript复制// degg.js
const koa = require("koa");
const { initRouter } = require("./degg-loader");
class degg {
constructor(conf) {
this.$app = new koa(conf);
this.$router = initRouter();
this.$app.use(this.$router.routes());
}
start(port) {
this.$app.listen(port, () => {
console.log("服务器启动成功,端口" port);
});
}
}
module.exports = degg;
index.js就可以很友好的这么写:
代码语言:javascript复制const degg = require("./degg");
const app = new degg();
app.start(3000);
控制器(controller)
以上的实现还是没有体现关注点分离的思想,我希望路由处理方法放在controller,而router导出的对象是这样的:
代码语言:javascript复制// router/index.js
module.exports = app =>({
'get /':app.$ctrl.home.index,
'get /detail':app.$ctrl.home.detail,
})
通过 请求方法/路由
跳转对应的处理逻辑。
所以得做两件事:
- 抽取route中业务逻辑至controller
/**
* 初始化路由
* 兼容上一代
*/
function initRouter(app){
const router=new Router();
load('router',(filename,routes)=>{
// 对于index,需要特殊处理:请求后缀为index。指向跟路径
const prefix =filename==='index'?'':`/${filename}`;
//兼容上一代写法:
routes=typeof routes==='function'?routes(app):routes;
Object.keys(routes).forEach(key=>{
const [method,path]=key.split(' ');
console.log(`正在映射地址:${method.toLocaleUpperCase()} /${prefix}/${path}`);
// 解析路由
router[method](prefix path,routes[key])
})
})
return router;
}
- 根据路径,对controller进行分配
function initController(){
const controllers={}
load('controller',(filename,controller)=>{
// 添加路由控制器
controllers[filename]=controller;
})
return controllers
}
module.exports={initRouter,initController}
打印出的逻辑如下:
服务集成(service)
什么叫"服务集成"?想想之前service文件夹,放的是通过不同方式从数据层获取数据的方法。
比如说,我需要后端提供一个人的名字(getName)和年龄(getAge)就包含了两个方法。
代码语言:javascript复制// service/user.jsss
const delay=(data, tick)=> new Promise(resolve=>{
setTimeout(()=>{
resolve(data)
},tick)
});
// 可复用的服务,一个同步,一个异步
module.exports={
getName(){
return delay('djtao',1000);
},
getAge(){
return 30
}
}
给loader添加service逻辑,和controller高度相似:
代码语言:javascript复制/**
* 服务集成
*/
function initService() {
const services = {};
// 读取控制器目录
load("service", (filename, service) => {
// 添加路由,和controller一样的逻辑
services[filename] = service;
});
return services;
}
module.exports={initRouter,initController,initService}
在degg,js中:
代码语言:javascript复制this.$service=initService();
这时候路由怎么执行呢?需要添加异步逻辑。同时,把ctx挂载带app实例中。
代码语言:javascript复制/**
* 初始化路由
*/
function initRouter(app) {
const router = new Router();
load('router', (filename, routes) => {
// 对于index,需要特殊处理:请求后缀为index。指向跟路径
const prefix = filename === 'index' ? '' : `/${filename}`;
//兼容上一代写法:
routes = typeof routes === 'function' ? routes(app) : routes;
Object.keys(routes).forEach(key => {
const [method, path] = key.split(' ');
console.log(`正在映射地址:${method.toLocaleUpperCase()} /${prefix}/${path}`);
// 解析路由,处理异步
router[method](prefix path, async ctx => {
// 挂载ctx至app
app.ctx = ctx;
// 路由处理器现在接收到的是app
await routes[key](app);
});
})
})
return router;
}
这时候,router对应的不再是ctx。而是app
代码语言:javascript复制// router/user.js
module.exports = {
"get /": async app => {
const name=await app.$service.user.getName()
app.ctx.body = `hello, ${name}`;
},
"get /info":async app => {
const age=await app.$service.user.getAge()
app.ctx.body = `i am ${age} years old`;
}
};
对于使用controller的home.js:
代码语言:javascript复制module.exports={
index:async app=>{
app.ctx.body='首页ctrl'
},
detail:async app=>{
app.ctx.body='详情ctrl'
}
}
更新之后的controller层就可以通过app拿到service的方法。
这样,逻辑就出来了。
数据层(model)
数据库还是使用sequelize和msql2。
代码语言:javascript复制约定
- config/config.js存放配置项
- key表示对应配置目标
- model存放数据库模型
配置及其加载
配置sequelize连接配置项,config.js
代码语言:javascript复制// 这里就是数据库的配置
module.exports = {
db: {
dialect:'mysql',
host:'127.0.0.1',
port:'3306',
username:'root',
password:'12345678',
database:'eggjs'
}
}
然后在loader中添加内容:
代码语言:javascript复制const Sequelize = require("sequelize");
function loadConfig(app) {
load("config", (filename, conf) => {
if (conf.db) {
app.$db = new Sequelize(conf.db);
// 加载模型
app.$model = {};
// 期望:从model下拿到对应数据表比如user
load("model", (filename, { schema, options }) => {
app.$model[filename] = app.$db.define(filename, schema, options);
});
app.$db.sync();
}
});
}
module.exports = { loadConfig };
// degg.js
//先加载配置项
loadConfig(this);
数据模型
在model文件夹下新建user.js,存放配置:
代码语言:javascript复制const { STRING } = require("sequelize");
module.exports = {
schema: {
name: STRING(30)
}, options: {
timestamps: false
}
};
测试
在controller层添加逻辑
js detail:asyncapp=>{// app.ctx.body='详情ctrl'app.ctx.body=awaitapp.$model.user.findAll()}
中间件(middleware)
中间件,其实就是一个插槽。
代码语言:javascript复制规定koa中间件放入middleware文件夹
编写一个请求记录中间件,./middleware/logger.js
新建一个自己写的logger中间件, middleware/logger.js
module.exports = async (ctx, next) => {
console.log(ctx.method " " ctx.path);
const start = new Date();
await next();
const duration = new Date() - start;
console.log(
ctx.method " " ctx.path " " ctx.status " " duration "ms"
);
};
配置中间件(插槽):
代码语言:javascript复制// config.js
module.exports = {
db:{...},
middleware: ['logger'] // 以数组形式,保证执行顺序
}
代码语言:javascript复制// 如果有middleware选项,则按其规定循序应用中间件 if (conf.middleware) {
conf.middleware.forEach(mid => {
const midPath = path.resolve(__dirname, "middleware", mid);
app.$app.use(require(midPath));
}); }
在loader中:
代码语言:javascript复制function loadConfig(app) {
load("config", (filename, conf) => {
// 。。。
//中间件
if (conf.middleware) {
conf.middleware.forEach(mid => {
const midPath = path.resolve(__dirname, "middleware", mid);
app.$app.use(require(midPath));
});
}
});
}
定时任务
有时候需要打日志。
我们使用Node-schedule来管理定时任务。
代码语言:javascript复制约定:schedule目录,存放定时任务,使用crontab格式来启动定时
所以,新建 module
目录,写以下两个配置和处理逻辑:
//log.js
module.exports = {
interval: '*/3 * * * * *',
handler() {
console.log('定时任务:嘿嘿嘿 三秒执行一次' new Date())
}
}
// user.js
module.exports = {
interval: '30 * * * * *',
handler() {
console.log('定时任务 嘿嘿 每分钟第30秒执行一次' new Date())
}
}
然后在loader添加相应处理逻辑(注意导入和导出):
代码语言:javascript复制function initSchedule() {
// 读取控制器目录
load("schedule", (filename, scheduleConfig) => {
schedule.scheduleJob(scheduleConfig.interval, scheduleConfig.handler);
});
}
运行结果如下
视图层(views)与错误捕捉
这实际上是一个健壮功能,也是一个内置功能。
我希望通过如下方法,很轻易地渲染一个html:
代码语言:javascript复制ctx.render('/html',配置)
那我们就用ejs作为degg.js的模板引擎。
修改loader:
代码语言:javascript复制function initViews(app){
return views(__dirname '/views', {
extension: 'ejs'
})
}
// degg.js使用这个配置
this.$views = initViews(this);
this.$app.use(this.$views);
新建view文件夹。写一个404的页面(404.html)
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>404</title>
</head>
<body>
你来到了没有知识的荒原...
</body>
</html>
写一个错误内容的中间件,
代码语言:javascript复制// middleware/err.js
// 错误捕捉
module.exports = async (ctx, next) => {
try {
await next();
if(ctx.status!==200){
ctx.throw(ctx.status);
}
} catch (err) {
const status = err.status || 500;
ctx.status = status;
if (status === 404) {
await ctx.render("./404.html");
} else if (status === 500) {
await ctx.render("./500.ejs",{message:err,stack:err.stack});
}
}
};
注册中间件
代码语言:javascript复制middleware: ['logger','err']
测试结果如下