什么是koa?

koa是Express的下一代基于Node.js的web框架。使用 koa 编写 web 应用,通过组合不同的 generator,可以免除重复繁琐的回调函数嵌套,并极大地提升常用错误处理效率。Koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用和API变得得心应手。

Koa能干什么?

主要用途

  • 网站(比如cnode这样的论坛)
  • api(三端:pc、移动端、h5)
  • 与其他模块搭配,比如和socket.io搭配写弹幕、im(即时聊天)等

koa是微型web框架,但它也是个Node.js模块,也就是说我们也可以利用它做一些http相关的事儿。举例:实现类似于http-server这样的功能,在vue或react开发里,在爬虫里,利用路由触发爬虫任务等。比如在bin模块里,集成koa模块,启动个static-http-server这样的功能,都是非常容易的。

搭建项目启动服务

1
2
3
4
5
6
// 1. 创建项目文件夹后初始化npm
npm init
// 2. 安装koa环境
npm install koa
// 3. 根目录下创建app文件夹作为我们源代码的目录
// 4. app下新建index.js作为入口文件

生成目录结构如下:

编写app/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
const Koa = require('koa');
const app = new Koa();
const port = '3333';
const host = '0.0.0.0';

app.use(async ctx => {
ctx.body = 'Hello World';
});

app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});

根目录下运行node app/index.js,启动成功后控制台出现API server listening on 0.0.0.0:3333,打开浏览器访问本机ip:3333

image-20210627084008790

路由处理

koa中处理相应的路由返回对应的响应这一开发过程类似java中编写controllerrestful风格的路由可以非常语义化的根据业务场景编写对应的处理函数,前端利用axios访问服务端找到对应的函数(路由名字)来获取对应想要的结果。

编写app/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// app/index.js
const Koa = require('koa');
const app = new Koa();

const port = '3333';
const host = '0.0.0.0';

app.use(async ctx => {
const { path } = ctx;
console.log(path)
if (path === '/test1') {
ctx.body = 'response for test1';
} else if (path === '/test2') {
ctx.body = 'response for test2';
} else if (path === '/test3') {
ctx.body = 'response for test3';
} else {
ctx.body = 'Hello World';
}
});

app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});

注意:每次在koa中更新代码后想要生效必须重启koa服务

这时,我们再访问试试:

image-20210627085327472

结果按我们预期返回了,这时我们先解决上诉的问题,如何热更新代码来帮助我们提高开发效率

  1. 安装nodemon

    1
    2
    npm install nodemon
    npm i nodemon -g // 建议直接全局安装
  1. 修改package.js
1
2
3
"scripts": {
"start": "nodemon app/index.js"
}

运行npm run start再次启动服务,这时修改代码后只需要刷新浏览器即可,不用重启node服务了!

可以预见的是:上面对路由的处理在实战中是不可行的,api逐渐增加后需要考虑到系统的可维护性,koa-router应运而生。

koa-router: 集中处理URL的中间件,它负责处理URL映射,根据不同的URL调用不同的处理函数,这样,我们就能能专心为每个URL编写处理函数

app 目录下新建 router 目录,如下所示:

image-20210627090757106

安装koa-router

1
npm install koa-router

编写app/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const koaRouter = require('koa-router');
const router = new koaRouter();

router.get('/test1', ctx => {
ctx.body = 'response for test1';
});

router.get('/test2', ctx => {
ctx.body = 'response for test2';
});

router.get('/test3', ctx => {
ctx.body = 'response for test3';
});

module.exports = router;

浏览器中再次访问测试,http://192.168.0.197:3333/test3,返回response for test3,返回结果与之前一致。再次细想一下,实际公司的业务场景中,router/index.js中可能一个处理函数就会非常庞大,因此,路由文件我们只需要关心具体的路由,它对应的处理函数可以单独提出来统一管理。我们可以把业务逻辑处理函数放到controller中,如下:

image-20210627092430698

我们新增了三个文件:

  • app/router/routes.js 路由列表文件
  • app/contronllers/index.js 业务处理统一导出
  • app/contronllers/test.js 业务处理文件

所有的业务逻辑代码放到controller中管理,如app/contronllers/test.js所示:

1
2
3
4
5
6
7
const echo = async ctx => {
ctx.body = '这是一段文字...';
}

module.exports = {
echo
}

app/contronllers/index.js统一入口,管理导出

1
2
3
4
5
const test =  require('./test');

module.exports = {
test
}

app/router/routes.js路由文件专心管理所有路由,无需维护对应业务逻辑代码

1
2
3
4
5
6
7
8
9
10
11
12
const { test } = require('../controllers');

const routes = [
{
path: 'test1',
method: 'get',
controller: test.echo
}
];

module.exports = routes;

改造app/router/index.js

1
2
3
4
5
6
7
8
9
10
11
const koaRouter = require('koa-router');
const router = new koaRouter();
const routes = require('./routes');

routes.forEach(route => {
const { method, path, controller } = route;
// router 第一个参数是 path, 后面跟上路由级中间件 controller(上面编写的路由处理函数)
router[method](path, controller);
});

module.exports = router;

打开浏览器访问http://192.168.0.197:3333/test1

image-20210627093721213

结果如逾期正常返回,测试成功。

参数解析

测试完get请求后再请求一个post请求,path为/postTest,参数为name: wangcong,请求如下:

image-20210627102933947

打印出console.log('postTest', ctx)如下,好像并没有找到我们传入的参数’name’,那如何获取到post的请求体呢?

image-20210627102601854

koa-bodyparser: 对于POST请求的处理,koa-bodyparser中间件可以把koa2上下文的formData数据解析到ctx.request.body中

安装中间件之前,我们可以按照改造router的方式改造一下中间件的管理

新建app/midllewares目录,添加index.js文件统一管理所有中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
const router = require('../router');

// 路由处理,router.allowedMethods()用在了路由匹配router.routes()之后,所以在当所有路由中间件最后调用.此时根据ctx.status设置response响应头
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();

// 导出数组是为后面使用koa-compose做准备,koa-compose支持传入数组,数组里的中间件一次同步执行
// 洋葱模型, 务必注意中间件的执行顺序!!!
module.exports = [
mdRoute,
mdRouterAllowed
];

index.js文件里集中了所有用到的中间件,接下来改造下启动文件 app/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Koa = require('koa');
const app = new Koa();
const compose = require('koa-compose');
const MD = require('./midllewares'); // 引入所有的中间件


const port = '3333';
const host = '0.0.0.0';

app.use(compose(MD)); // compose接收一个中间件数组, 按顺序同步执行!!!

app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});

compose 是一个工具函数,Koa.js 的中间件通过这个工具函数组合后,app.use() 的顺序同步执行,也就是形成了 洋葱圈 式的调用。

引入 koa-bodyparser统一处理请求参数,注意:bodyParser 为了处理每个 Request 中的信息,要放到路由前面先让他处理再进路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// midllewares/index.js
const router = require('../router');
const koaBody = require('koa-bodyparser'); // bodyParser 就是为了处理每个 Request 中的信息,要放到路由前面先让他处理再进路由

// 路由处理,router.allowedMethods()用在了路由匹配router.routes()之后,所以在当所有路由中间件最后调用.此时根据ctx.status设置response响应头
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();

/**
* 参数解析
* https://github.com/koajs/bodyparser
*/
const mdKoaBody = koaBody({
enableTypes: ['json', 'form', 'text', 'xml'],
formLimit: '56kb',
jsonLimit: '1mb',
textLimit: '1mb',
xmlLimit: '1mb',
strict: true
})

// 洋葱模型, 务必注意中间件的执行顺序!!!
module.exports = [
mdKoaBody,
mdRoute,
mdRouterAllowed
];

postman请求测试

image-20210627102758998

获取ctx.request.body成功!

引用koa-bodyparser文档的一句话,可以看出来它并不支持二进制流来进行上传,并且希望我们用co-busboy来解析multipart format data

Notice: this module don’t support parsing multipart format data, please use co-busboy to parse multipart format data.

替换koa-bodyparserkoa-bodykoa-body 主要是下面两个依赖:

1
2
"co-body": "^5.1.1",
"formidable": "^1.1.1"

官方这样对它做了介绍

A full-featured koa body parser middleware. Supports multipart, urlencoded, and json request bodies. Provides the same functionality as Express’s bodyParser - multer.

修改app/midllewares/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const { tempFilePath } = require('../config');
const { checkDirExist } = require('../utils/file');
const router = require('../router');
const koaBody = require('koa-body'); // koa-body 就是为了处理每个 Request 中的信息,要放到路由前面先让他处理再进路由

// 路由处理,router.allowedMethods()用在了路由匹配router.routes()之后,所以在当所有路由中间件最后调用.此时根据ctx.status设置response响应头
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();

/**
* 参数解析
* https://github.com/koajs/bodyparser
*/
const mdKoaBody = koaBody({
multipart: true, // 支持文件上传
// encoding: 'gzip', // 启用这个会报错
formidable: {
uploadDir: tempFilePath, // 设置文件上传目录
keepExtensions: true, // 保持文件的后缀
maxFieldsSize: 200 * 1024 * 1024, // 设置上传文件大小最大限制,默认2M
onFileBegin: (name,file) => { // 文件上传前的设置
// 检查文件夹是否存在如果不存在则新建文件夹
checkDirExist(tempFilePath);
// 获取文件名称
const fileName = file.name;
// 重新覆盖 file.path 属性
file.path = `${tempFilePath}/${fileName}`;
},
onError:(err)=>{
console.log(err);
}
}
})

// 洋葱模型, 务必注意中间件的执行顺序!!!
module.exports = [
mdKoaBody,
mdRoute,
mdRouterAllowed
];

其中,创建了config和utils两个文件夹,各自目录分别为:

image-20210627163251606

config中文件目前只配置了上传文件的临时路径,后面还可以配置一些不同环境下的配置相关:

image-20210627163452123

utils文件夹下创建了一个file.js工具文件和`index.js统一导出文件,主要处理对文件相关(路径、文件名等)的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// utils/file.js
const fs = require('fs');
const path = require('path');

function getUploadDirName(){
const date = new Date();
let month = Number.parseInt(date.getMonth()) + 1;
month = month.toString().length > 1 ? month : `0${month}`;
const dir = `${date.getFullYear()}${month}${date.getDate()}`;
return dir;
}

// 创建目录必须一层一层创建
function mkdir(dirname) {
if(fs.existsSync(dirname)){
return true;
} else {
if (mkdir(path.dirname(dirname))) {
fs.mkdirSync(dirname);
return true;
}

}
}

function checkDirExist(p) {
if (!fs.existsSync(p)) {
mkdir(p)
}
}

function getUploadFileExt(name) {
let idx = name.lastIndexOf('.');
return name.substring(idx);
}

function getUploadFileName(name) {
let idx = name.lastIndexOf('.');
return name.substring(0, idx);
}

module.exports = {
getUploadDirName,
checkDirExist,
getUploadFileExt,
getUploadFileName
}
1
2
3
4
5
6
// utils/index.js
const file = require('./file')

module.exports = {
file
}

app/index.js引入全局公共部分,挂载到app.context上下文中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// app/index.js
const Koa = require('koa');
const app = new Koa();
const compose = require('koa-compose');
const MD = require('./midllewares');
const config = require('./config');
const utils = require('./utils');

const port = '3333';
const host = '0.0.0.0';

app.context.config = config;
app.context.utils = utils;

app.use(compose(MD));

app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});

测试上传功能:

image-20210627164254743

注意:KoaBody配置中,keepExtensions: true必须开启,否则上传不会成功!

查看app目录下,生成了我们刚刚上传的文件

image-20210627164506045

在koa-body @4中,控制台打印文件相关信息用ctx.request.files,低版本使用ctx.request.body.files

image-20210627164652759

统一响应体 & 错误处理

统一 格式处理返回响应,可以充分利用洋葱模型进行传递,我们可以编写两个中间件,一个统一返回格式middleware,一个错误处理middleware,分别如下:

文件app/midllewares/response.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const response = () => {
return async (ctx, next) => {
ctx.res.fail = ({ code, data, msg }) => {
ctx.body = {
code,
data,
msg,
};
};

ctx.res.success = msg => {
ctx.body = {
code: 0,
data: ctx.body,
msg: msg || 'success',
};
};

await next();
};
};

module.exports = response;

文件 app/middlewares/error.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const error = () => {
return async (ctx, next) => {
try {
await next();
if (ctx.status === 200) {
ctx.res.success();
}
} catch (err) {
if (err.code) {
// 自己主动抛出的错误
ctx.res.fail({ code: err.code, msg: err.message });
} else {
// 程序运行时的错误
ctx.app.emit('error', err, ctx);
}
}
};
};

module.exports = error;

app/middlewares/index.js引用它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const { tempFilePath } = require('../config');
const { checkDirExist } = require('../utils/file');
const router = require('../router');
const koaBody = require('koa-body'); // koa-body 就是为了处理每个 Request 中的信息,要放到路由前面先让他处理再进路由
const response = require('./response');
const error = require('./error');

// 路由处理,router.allowedMethods()用在了路由匹配router.routes()之后,所以在当所有路由中间件最后调用.此时根据ctx.status设置response响应头
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();

/**
* 参数解析
* https://github.com/koajs/bodyparser
*/
const mdKoaBody = koaBody({
multipart: true, // 支持文件上传, 必须设置为true!!!
// encoding: 'gzip', // 启用这个会报错
formidable: {
uploadDir: tempFilePath, // 设置文件上传目录
keepExtensions: true, // 保持文件的后缀
maxFieldsSize: 200 * 1024 * 1024, // 设置上传文件大小最大限制,默认2M
onFileBegin: (name,file) => { // 文件上传前的设置
// 检查文件夹是否存在如果不存在则新建文件夹
checkDirExist(tempFilePath);
// 获取文件名称
const fileName = file.name;
// 重新覆盖 file.path 属性
file.path = `${tempFilePath}/${fileName}`;
},
onError:(err)=>{
console.log(err);
}
}
})
// 统一返回格式
const mdResHandler = response();
// 错误处理
const mdErrorHandler = error();

// 洋葱模型, 务必注意中间件的执行顺序!!!
module.exports = [
mdKoaBody,
mdResHandler,
mdErrorHandler,
mdRoute,
mdRouterAllowed
];

再次强调一遍,app.use()中,中间件执行是按续同步执行,mdResHandler定义了两种处理通道(成功和失败),真正判断逻辑在error.js中间件中,一种是业务型错误码,需要返回给前端进行处理,另一种是服务端代码运行时报错,这种错误类型我们需要出发koa的错误处理事件去处理。error.js中判断处理后都是调用mdResHandler统一返回格式返回请求响应。针对服务端运行时代码错误,我们还需要做出修改,在app/index.js中修改代码如下:

1
2
3
4
5
6
7
8
app.on('error', (err, ctx) => {
if (ctx) {
ctx.body = {
code: 9999,
message: `程序运行时报错:${err.message}`
};
}
});

完成后,我们还是利用之前的controller/ap/test.js中echo的代码:

1
2
3
const echo = async ctx => {
ctx.body = '这是一段文字...';
}

再次请求看看跟之前有什么不一样

image-20210627171126461

结果如逾期返回,再进行模拟错误的返回,修改test.js下的echo函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// test.js
const { throwError } = require('../utils/handle');
const echo = async ctx => {
const data = '';
ctx.assert(data, throwError(50002, 'token失效!'));
// 不会往下执行了
ctx.body = '这是一段文字...';
}


// utils/handle.js
const assert = require('assert');

const throwError = (code, message) => {
const err = new Error(message);
err.code = code;
throw err;
};

module.exports = {
assert,
throwError
};

postman再次请求测试:

image-20210627173550538

结果如预期返回

修改test.js为koa运行时的代码错误:

1
2
3
4
5
const echo = async ctx => {
const data = '';
data = 'a'; // 模拟语法错误
ctx.body = '这是一段文字...';
}

再次请求,得到结果如下:

image-20210627173801715

至此,错误处理搞定了,统一返回格式也搞定。

参数校验

参数校验可以极大的避免上诉的程序运行时的错误,在这个例子里,我们也将参数校验放在controller里面去完成,test.js新增一个业务处理函数print用于返回前端姓名,打印在页面上:

1
2
3
4
5
6
7
const print = async ctx => {
const { name } = ctx.request.query;
if (!name) {
ctx.utils.handle.assert(false, ctx.utils.handle.throwError(10001, '参数错误'));
}
ctx.body = '打印姓名: ' + name;
}

请求测试,正常传参如下 :

image-20210628073806022

不传参数,返回错误状态码10001:

image-20210628073852996

可以预料的是,随着业务场景复杂度的上升,controller层后面对于参数校验的部分代码会变得越来越庞大,所以这部分一定是可以优化的,第三方插件 joi 就是应对这种场景,我们可以借助此中间件帮助我们完成参数校验。在 app/middlewares/ 下添加 validator.js 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
module.exports = paramSchema => {
return async function (ctx, next) {
let body = ctx.request.body;
try {
if (typeof body === 'string' && body.length) body = JSON.parse(body);
} catch (error) {}
const paramMap = {
router: ctx.request.params,
query: ctx.request.query,
body
};

if (!paramSchema) return next();

const schemaKeys = Object.getOwnPropertyNames(paramSchema);
if (!schemaKeys.length) return next();

schemaKeys.some(item => {
const validObj = paramMap[item];

const validResult = paramSchema[item].validate(validObj, {
allowUnknown: true
});

if (validResult.error) {
ctx.assert(false, ctx.utils.handle.throwError(9998, validResult.error.message));
}
});
await next();
};
};

修改app/router/index.js:

1
2
3
4
5
6
7
8
9
10
11
const koaRouter = require('koa-router');
const router = new koaRouter();
const routes = require('./routes');
const validator = require('../midllewares/validator');

routes.forEach(route => {
const { method, path, controller, valid } = route;
router[method](path, validator(valid), controller);
});

module.exports = router;

可以看到,route中多解构了一个valid来作为validator的参数,app/router/routes.jsprint路由新增一条校验规则,如下:

1
2
3
4
5
6
{
path: '/print',
method: 'get',
valid: schTest.print,
controller: test.print
}

koa-router 允许添加多个路由级中间件,我们将参数校验放在这里处理。随后在 app目录下新建目录 schema,用来存放参数校验部分的代码,添加两个文件:

  1. app/schema/index.js:

    1
    2
    3
    4
    5
    const schTest = require('./test');

    module.exports = {
    schTest
    };
  2. app/schema/test.js:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const Joi = require('@hapi/joi');

    const print = {
    query: Joi.object({
    name: Joi.string().required(),
    age: Joi.number().required()
    })
    };

    module.exports = {
    list
    };

    把之前app/controller/test.js手动校验部分删掉 ,测试joi中间件是否生效:

1
2
3
4
const print = async ctx => {
const { name } = ctx.request.query;
ctx.body = '打印姓名: ' + name;
}

请求接口测试

image-20210628082630673

到这里,参数校验就算整合完成,joi 更多的使用方法请查看文档

配置跨域

使用@koa/cors插件来进行跨域配置,app/middlewares/index.js添加配置,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...省略其他配置
const cors = require('@koa/cors'); // 跨域配置
// 跨域处理
const mdCors = cors({
origin: '*',
credentials: true,
allowMethods: [ 'GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH' ]
});
module.exports = [
mdKoaBody,
mdCors,
mdResHandler,
mdErrorHandler,
mdRoute,
mdRouterAllowed
];

日志

采用 log4js 来记录请求日志,添加文件 app/middlewares/log.js :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const log4js = require('log4js');
const { outDir, flag, level } = require('../config').logConfig;

log4js.configure({
appenders: { cheese: { type: 'file', filename: `${outDir}/receive.log` } },
categories: { default: { appenders: [ 'cheese' ], level: 'info' } },
pm2: true
});

const logger = log4js.getLogger();
logger.level = level;

module.exports = () => {
return async (ctx, next) => {
const { method, path, origin, query, body, headers, ip } = ctx.request;
const data = {
method,
path,
origin,
query,
body,
ip,
headers
};
await next();
if (flag) {
const { status, params } = ctx;
data.status = status;
data.params = params;
data.result = ctx.body || 'no content';
if (ctx.body.code !== 0) {
logger.error(JSON.stringify(data));
} else {
logger.info(JSON.stringify(data));
}
}
};
};

app/middlewares/index.js 中引入上面写的日志中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const log = require('./log'); // 添加日志
// ...省略其他代码

// 记录请求日志
const mdLogger = log();

module.exports = [
mdKoaBody,
mdCors,
mdLogger,
mdResHandler,
mdErrorHandler,
mdRoute,
mdRouterAllowed
];

利用postman请求接口测试效果:

image-20210627174730031

打开日志文件,查看日志 :

1
2
[2021-06-27T17:45:53.803] [INFO] default - {"method":"GET","path":"/test1","origin":"http://192.168.0.197:3333","query":{},"body":{},"ip":"192.168.0.197","headers":{"host":"192.168.0.197:3333","connection":"keep-alive","cache-control":"no-cache","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36","postman-token":"ae200806-a92b-00c4-5f2d-d5afdb7d717c","accept":"*/*","accept-encoding":"gzip, deflate","accept-language":"zh-CN,zh;q=0.9,en;q=0.8"},"status":200,"params":{},"result":{"code":0,"data":"这是一段文字...","msg":"success"}}

到这里,日志模块引用成功!

数据库操作

app下再新增一个 service 目录, 之后的数据库操作放在 service 目录下,controller专注业务处理,service专注数据库的增删改查等事务操作。还可以添加一个 model 目录,用来定义数据库表结构,具体的操作将在之后的koa应用实战中具体展示。

总结

基本的koa实战型项目到这里就结束了,企业级开发中,还会有更多的问题需要解决,期待更加贴近企业级的实战项目。

项目远程地址