博客版文档在线阅读地址:egg开发实践文档-by山岚
Egg开发实践文档
目前项目中 Node.js 的需求是越来越多,简单的内部系统、Socket 通信服务、官网等,开发难度也越来越大,而 Egg.js 就是一款解决企业级开发问题的 Node.js 框架。
web服务端框架背景介绍
市面主要流行的有Express、Koa2、Egg、NextJs等
- Express.js 是 Node.JS 诞生之初,最早出现的一款框架,现在仍然很流行,作者是TJ。
- 随着ECMAScript的发展,推出了
generator yield
语法,JS向同步方式写异步代码迈出了一步,作为回应,TJ大神推出了Koa.js。 - Koa.js是一款微型Web框架,写一个
hello world
很简单,但 web 应用离不开session,视图模板,路由,文件上传,日志管理。这些 Koa 都不提供,需要自行去官方的Middleware
寻找。然而,100个人可能找出100种搭配。 - Egg.js是基于Koa.js,解决了上述问题,将社区最佳实践整合进了Koa.js,另取名叫Egg.js,并且将多进程启动,开发时的热更新等问题一并解决了。这对开发者很友好,开箱即用,开箱即是最(较)佳配置。
- Egg.js发展期间,ECMAScript又推出了
async await
,相比yield的语法async写起来更直观。当然,Koa.js同步进行了跟进,Egg.js低层依赖Koa.js,自然也进行了跟进。 - 现在TypeScript大热,可以在编码期间,提供类型检查,更智能的代码提示。Egg.js不支持TypeScript,此时淘宝团队在Egg.js基础上,引入了TypeScript支持,取名叫 MidwayJS 。
TypeScript是绕不开的话题。基于Express.js的全功能框架 Nest.js,他是在Express.js上封装的,充分利用了TypeScript的特性;Nest.js的优点是社区活跃,涨势喜人。缺点是,如果从来没有接触过TS,刚开始学习曲线有点陡峭。
简单来讲,egg.js对我们使用者来说,其实是封装了一套koa,可以理解成大礼包版的koa,集成度高,可以轻松创建一个项目而不用做很多繁琐的初期工作,解放生产力,更可贵的是有一套现成的规范提供给我们,不需要我们自己再去探索一套规范。
那我们,还在等什么呢?备好键盘,快来一起体验一下!
一、体验搭建过程(demo)
推荐直接使用脚手架,只需几条简单指令,即可快速生成项目(npm >=6.1.0):
使用egg脚手架初始化
* $ mkdir eggDemo && cd eggDemo
* $ cnpm init egg --type=simple
* $ cnpm i
ps: npm下载插件网速较慢,还是cnpm的淘宝镜像好撸些~
下面是命令的执行过程~
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WG2XaxzR-1603701814698)(/egg1.jpg)]
当与脚手架的连接建立成功时,首先需要手动补充项目的配置项:
- project name(项目名称)
- project description (项目描述)
- project anthor (作者)
- cookie security keys (cookie标识 此项默认生成)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vvn59Sfy-1603701814699)(/egg2.jpg)]
注意:
目录结构如图。其实简单来讲,脚手架就是在 npm 仓库中下载了个模板,然后给我们配置了个key。
谈论key值,它并不是个随机数,经过我的解密,
是当前拉取脚手架的时间戳_当天下载量拼凑而成 Yeah~ 好吧,和随机数效果没差
// use for cookie sign key, should change to your own and keep security
config.keys = appInfo.name + '_1603108381520_3138';
使用egg-init脚手架初始化
使用官方脚手架 egg-init 开始项目。
- 脚手架安装:npm i -g egg-init
- 初始化目录:egg-init egg-first --type=simple
- 安装项目依赖:cd egg-first && npm i
- 热部署启动:npm run dev
全局安装egg-init后,我们执行初始化流程,但是往下走的路往往不会那么顺利。
Connect Timeout for 5000ms!
由于网络的原因,和npm仓库无法直连,那有别的法子能解决此问题否?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KWf09PoA-1603701814700)(/egg3.jpg)]
- 遇到问题,就去打开help~
在配置项中,出现了配置registry的选项。(china/npm/custom,默认自动探测选择)
顾名思义,china应该就是国内的仓库镜像.虽然他提及了自动选择,但是我试了五次,没有一次在超时后实现自动更换配置。这就有趣了~
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3t2beavq-1603701814702)(/egg4.jpg)]
我们转换思路,在执行初始化指令时,把配置项写入指令中。
egg-init egg-first -r=china --type=simple
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NCkwzpQt-1603701814704)(/egg5.jpg)]
妥当!成功安装!
这里有个需要注意的点: 当准备项目的description时,不可以使用双引号。
因为在配置文件时,录入的信息都会以json的key-value的形式写入到package.json中,如果带入双引号,会破坏package.json文件的结构,无法成功安装后续的依赖包。形如:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OcHVtblq-1603701814706)(/egg6.jpg)]
解决了上面的注意事项,接下来就可以在编辑器中愉快的玩耍了。
安装依赖
cnpm i
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X6cEMThB-1603701814706)(/egg8.jpg)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g90ySIBn-1603701814707)(/egg9.jpg)]
运行初尝试
当准备工作完成后,我们可以在 浏览器 点开启动后的项目地址:http://127.0.0.1:7001
hello egg 经典再现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fyqo8tsj-1603701814708)(/egg10.jpg)]
二、架构概览
在成功搭建了简易的egg服务之后,我们回头看下在egg的 约定优于配置 的主旨下,具体是怎样实现的。
脚手架项目结构
我们的项目结构如下这般:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9ujNDO3U-1603701814709)(/egg7.jpg)]
注解:
egg-first //项目根目录
├─ .autod.conf.js
├─ .eslintignore
├─ .eslintrc // eslint规则
├─ app
│ ├─ controller //用于解析用户的输入,处理后返回相应的结果
│ │ └─ home.js
│ └─ router.js // 用于配置 URL 路由规则
├─ config //配置
│ ├─ config.default.js //用于编写配置文件
│ └─ plugin.js //用于配置需要加载的插件
├─ package.json
├─ README.md
└─ test // 用于单元测试
└─ app
└─ controller
└─ home.test.js
初始化完成后,我们可以发现两点
- 其实目录和配置文件并不多,但是结构清晰。
- 在egg.js约定优于配置的主旨下,更多的配置文件都是可选的状态,未被创建,对后期开发而言,具有很强的灵活性。
完整项目结构及释义
官网 补充的具体项目结构如下:
egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
| ├── router.js
│ ├── controller
│ | └── home.js
│ ├── service (可选)
│ | └── user.js
│ ├── middleware (可选)
│ | └── response_time.js
│ ├── schedule (可选)
│ | └── my_task.js
│ ├── public (可选)
│ | └── reset.css
│ ├── view (可选)
│ | └── home.tpl
│ └── extend (可选)
│ ├── helper.js (可选)
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选)
│ ├── application.js (可选)
│ └── agent.js (可选)
├── config
| ├── plugin.js
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js (可选)
| ├── config.local.js (可选)
| └── config.unittest.js (可选)
└── test
├── middleware
| └── response_time.test.js
└── controller
└── home.test.js
上面这些具体的、由框架约定的目录,一一释义:
- app/router.js 用于配置 URL 路由规则。
- app/controller/** 用于解析用户的输入,处理后返回相应的结果。
- app/service/** 用于编写业务逻辑层,可选,建议使用。
- app/middleware/** 用于编写中间件,可选。
- app/public/** 用于放置静态资源,可选。
- app/extend/** 用于框架的扩展,可选。
- config/config.{env}.js 用于编写配置文件。
- config/plugin.js 用于配置需要加载的插件。
- test/** 用于单元测试。
- app.js 和 agent.js 用于自定义启动时的初始化工作,可选。
由内置插件约定的目录:
- app/public/** 用于放置静态资源,可选。
- app/schedule/** 用于定时任务,可选。
浅析运行机制
回顾我们打开浏览器时,请求的地址为 http://127.0.0.1:7001/
如果 好奇 请求的接口如何做出响应的,可以先简单看下router.js的运行机制
// app/router.js
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
};
router.js,是请求的路由配置文件。
- Router 主要用来描述请求 URL 和具体承担执行动作的 Controller 的 对应关系。
- 框架约定了 app/router.js 文件用于统一所有路由规则。
- 通过统一的配置,我们可以避免路由规则逻辑散落在多个地方,从而出现未知的冲突,集中在一起我们可以更方便的来查看全局的路由规则。
仔细观察我们项目中的router.js,不难发现,在拿到 app的 router和controller类后,有个简单的 Router 对应关系:
router.get('/', controller.home.index);
我们通过 Router 将用户的请求基于 method 和 URL 分发到了对应的 Controller 上,那 Controller 负责做什么?
简单的说 Controller 负责解析用户的输入,处理后返回相应的结果。
简单的看一下我们的如今的controller的模样
//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;
controller的使用可分为以下三个场景:
- 在 RESTful 接口中,Controller 接受用户的参数,从数据库中查找内容返回给用户或者将用户的请求更新到数据库中。
- 在 HTML 页面请求中,Controller 根据用户访问不同的 URL,渲染不同的模板得到 HTML 返回给用户。
- 在代理服务器中,Controller 将用户的请求转发到其他服务器上,并将其他服务器的处理结果返回给用户。
在浏览器发起http://127.0.0.1:7001/请求后,路由以get请求 分发到了 controller的home的index入口方法
在拿到ctx上下文后,处理响应,成功返回了字符‘hi,egg’
至此,一个仅涉及router和controller内置对象的简单的请求就通了。
思考体会
在脚手架中,我们发现其只是提供了最基础的配置项。凡是标注【可选】状态的,都没有创建。
但是实际使用的过程中,有些配置文件或约定的文件夹是必不可少的。
接下来让我们以一个实例的形式,在和数据库交互的过程中,体验egg.js的工作原理。
三、山岚笔札项目搭建
效果预览
使用egg.js链接mySql数据库搭建笔记站点,具备CRUD的完整功能。详情可预览效果 山岚笔札
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5halTgmr-1603701814709)(/list.gif)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nF5Ifry9-1603701814710)(/add.gif)]
框架搭建
因为之前使用脚手架搭建的项目骨架,结构过于简单。所以需要适当增加几个常用的文件夹:
- app/service/** 用于编写业务逻辑层
- app/public/** 用于放置静态资源
- app/extend/helper.js 用于框架的扩展,提供一些实用的 utility 函数
引入数据库
我们使用的数据库是mysql,但是我们不是直接使它,而是安装封装过的mysql2和egg-sequelize。
在 Node.js 社区中,sequelize 是一个广泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源。它会辅助我们将定义好的 Model 对象加载到 app 和 ctx 上。
# 安装mysql
$ npm i mysql2 -S
# 安装使用egg的sequelize版本 egg-sequelize
$ npm add egg-sequelize -S
当然,我们需要一个数据库进行连接,那就得在本机或服务器安装一个能够供我们使用的数据库。
关于mysql安装包 待我准备好分享 或者 问一下度娘即可,这里不多解释。
数据库安装好后,我们管理数据库,可以通过控制台命令行进行控制,也可以通过图形化工具进行控制。我们推荐后者,我们下载了一个Navicat Premiun的工具。
Navicat Premiun 是一款数据库可视化管理工具。
连接数据库
配置数据库的基本信息,前提是我们已经创建好了这个数据库。假设我们创建了一个名为article的数据库,用户是root,密码是mine2020。那么,我们就可以像下面这样连接。
// config/config.default.js
...
// 数据库连接
config.sequelize = {
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
database: 'led_develop',
username: 'root',
password: 'mine2020',
};
...
当然,这是通过包egg-sequelize处理的,我们也要将其引入,告诉eggjs去使用这个插件。
// config/plugin.js
...
sequelize: {
enable: true,
package: 'egg-sequelize',
},
...
创建数据库表
1. 通过查询语句创建
你可以直接通过mysql控制台命令行或者或者可视化工具Navicat Premiun
执行mysql查询语句创建 ↓
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k3CBaMlH-1603701814711)(/egg11.jpg)]
参考查询语句如下:
CREATE TABLE `articlesss` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`logo` LONGTEXT DEFAULT NULL COMMENT '封面图',
`title` VARCHAR(80) DEFAULT NULL COMMENT '标题',
`desc` VARCHAR(300) DEFAULT NULL COMMENT '笔记摘要',
`content` LONGTEXT DEFAULT NULL COMMENT '笔记内容',
`change_date` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='user';
ALTER TABLE articles
MODIFY change_date datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间要自增';
2. 使用sequelize实现Migrations迁移操作
我们可以直接通过 mysql 命令将表直接建好,但是这并不是一个对多人协作非常友好的开发模式。
在项目的演进过程中,每一个迭代都有可能对数据库数据结构做变更,怎样跟踪每一个迭代的数据变更,并在不同的环境(开发、测试、CI)和迭代切换中,快速变更数据结构呢?这时候我们就需要 Migrations 来帮我们管理数据结构的变更了。
sequelize 提供了 sequelize-cli 工具来实现 Migrations,我们可以在 egg 项目中引入 sequelize-cli。
npm i sequelize-cli -D
在项目中,我们希望将所有的数据库Migrations相关的内容都放在database目录下面,所以在根目录下新建一个.sequelizerc配置文件:
如果不配置.sequelizerc 的话,sequelize init 初始化的文件夹会出现在项目目录下面,如果配置了.sequelizerc 就可以指定到相应的目录
// .sequelizerc
'use strict';
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'),
};
在终端初始化Migrations配置文件和目录。
npx sequelize init:config
npx sequelize init:migrations
执行完后会生成 database/config.json 文件和 database/migrations 目录 ↓
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d1TITkYA-1603701814712)(/egg12.jpg)]
我们修改一下 database/config.json 中的内容,将其改成我们项目中使用的数据库配置:
{
"development": {
"username": "root",
"password": "mine2020",
"database": "led_develop",
"host": "127.0.0.1",
"dialect": "mysql"
},
"test": {
"username": "root",
"password": "mine2020",
"database": "led_test",
"host": "127.0.0.1",
"dialect": "mysql"
},
"production": {
"username": "root",
"password": "mine2020",
"database": "led_production",
"host": "127.0.0.1",
"dialect": "mysql"
}
}
此时 sequelize-cli 和相关的配置初始化到这里就完成啦。
接下来,我们就可以开始编写项目的第一个 Migration 文件来创建我们数据库的“主菜” articles表。
npx sequelize migration:generate --name=init-articles
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-meBEX8EV-1603701814712)(/egg13.jpg)]
执行完后会在 database/migrations 目录下生成一个 migration 文件(${timestamp}-init-users.js),我们修改它来处理初始化 users 表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-usolQQlz-1603701814714)(/egg14.jpg)]
下面是init-articles.js的具体模样 ↓
// app/model/article.js
'use strict';
module.exports = {
// 在执行数据库升级时调用的函数,创建 users 表
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, TEXT, DATE } = Sequelize;
await queryInterface.createTable('articles', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
logo: TEXT('long'),
title: STRING(80),
desc: STRING(300),
content: TEXT('long'),
change_date: DATE,
});
},
// 在执行数据库降级时调用的函数,删除 users 表
down: async queryInterface => {
await queryInterface.dropTable('articles');
},
};
在涉及数据类型定义的问题时,需要和数据库的类型定义对应。
sequelize 的常用类型:
类型 | 说明 |
---|---|
STRING | 将字段指定为变长字符串类型,默认长度为 255。例:Sequelize.STRING(64) |
CHAR | 将字段指定为定长字符串类型,默认长度为 255。例:Sequelize.CHAR(64) |
TEXT | 将字段指定为(无)有限长度的文本列。可用长度:tiny, medium, long。例: Sequelize.TEXT(‘tiny’) |
INTEGER | 32位整型,可用属性:UNSIGNED,ZEROFILL。例:Sequelize.INTEGER(‘UNSIGNED’) |
DECIMAL | 小数,接受一个或两个参数表示精度。例:Sequelize.BOOLEAN() |
TIME | 指定为时间类型列,Sequelize.TIME() |
DATE | 指定为日期时间类型列, Sequelize.DATE() |
DATEONLY | 指定为日期类型列, Sequelize.DATEONLY() |
HSTORE | 指定为键/值类型列,仅Postgres适用, Sequelize.HSTORE() |
JSON | 指定为JSON字符串类型列,仅Postgres适用, Sequelize.JSON() |
JSONB | 指定为预处理的JSON数据列,仅Postgres适用, Sequelize.JSONB() |
NOW | 一个表示当前时间戳的默认值, Sequelize.NOW() |
UUID | UUID类型列,其默认值可以为UUIDV1或UUIDV4,Sequelize.UUID() |
ENUM | 枚举类型, Sequelize.ENUM() |
ARRAY | 数组类型,仅Postgres适用, Sequelize.ARRAY() |
而后在项目中执行脚本命令:
# 升级数据库
npx sequelize db:migrate
- 如果有问题需要回滚,可以通过
db:migrate:undo
回退一个变更 npx sequelize db:migrate:undo
- 可以通过
db:migrate:undo:all
回退到初始状态 npx sequelize db:migrate:undo:all
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-alMHBDOg-1603701814714)(/egg15.jpg)]
但是如果在执行过程中遇到了:
ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ‘NOW, PRIMARY KEY (id)) ENGINE=InnoDB’ at line 1
这时,停下来 看一下model/article.js中的创建表的语句是否有数据类型方面的错误,err内容虽然是报mysql版本问题,但是实际上,数据类型定义出错,也囊括其中。
只要我们的脚本没问题,在执行migrate的过程就不会节外生枝
。
更加详细内容,可以参考官网 sequelize 相关章节
四、准备CRUD接口
服务端的工作到这里就准备妥当了,我们可以在此基础上开始编写代码,实现属于自己的业务逻辑。
那么,下面的章节是结合数据库,来实现对文章表进行增删改查的操作。
首先我们来在 app/model/ 目录下编写 user 这个 Model:
'use strict';
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
const Article = app.model.define(
'articlesss',
{
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
logo: TEXT('long'),
title: STRING(80),
desc: STRING(300),
content: TEXT('long'),
change_date: DATE,
},
{
freezeTableName: true, // 不自动将表名添加复数
timestamps: false, // 不强制添加 created_at/updated_at 两个时间戳字段
}
);
return Article;
};
这个 Model 就可以在 Controller 和 Service 中通过 app.model.User 或者 ctx.model.User 访问到了。
之后,搭架路由,我们使用的是MVC的架构,那么我们的现有代码逻辑自然会这样流向:
app/router.js 获取文章路由
↓↓↓app/controller/article.js中对应的方法
↓↓↓app/service/article.js中的方法
-end
回顾下,这样的流向的原因
- app/router.js 用于配置 URL 路由规则
- app/controller/** 用于解析用户的输入,处理后返回相应的结果
- app/service/** 用于编写业务逻辑层
根据规则,我们可以先把 路由对应关系 在router.js中创建出来
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.get('/api/lists', controller.article.getList);
router.get('/api/getArticle/:id', controller.article.getArticle);
router.post('/api/create', controller.article.postItem);
router.post('/api/edit', controller.article.putItem);
router.get('/api/del', controller.article.deleteItem);
};
搭架完骨架,接下来,我们就主要展示在controller层和service层做的事情了。把接口丰富起来。
// app/controller/article.js
/*
* @description:
* @Author: ljc
* @Date: 2020-10-21 14:46:48
* @LastEditors: ljc
* @LastEditTime: 2020-10-23 16:17:11
*/
'use strict';
const Controller = require('egg').Controller;
class ArticleController extends Controller {
async index() {
const { ctx } = this;
ctx.body = 'hi, egg';
}
...
// 在这里 增加相应的异步方法
...
}
module.exports = ArticleController;
获取文章列表
[get] /api/lists
// app/controller/article.js
...
// 查询列表
async getList() {
const { ctx } = this;
const { page, pageSize } = ctx.request.query;
if (!page || !pageSize) {
await ctx.helper.returnBody(500, '', '', '缺少必要参数');
return;
}
const lists = await ctx.service.article.getList({
page,
pageSize,
});
ctx.helper.returnBody(
200,
'获取文章列表成功!',
{
count: (lists && lists.count) || 0,
results: (lists && lists.rows) || [],
},
);
}
...
// app/service/article.js
...
async getList(obj) {
const { ctx } = this;
console.log(obj);
return await ctx.model.Article.findAndCountAll({
order: [[ 'change_date', 'ASC' ]],
offset: (parseInt(obj.page) - 1) * parseInt(obj.pageSize),
limit: parseInt(obj.pageSize),
});
}
...
获取文章详情
[get] /api/getArticle/:id
// app/controller/article.js
...
// 查询单个文章
async getArticle() {
const { ctx } = this;
const id = ctx.params.id;
console.log(ctx.params);
const articleDetail = await ctx.service.article.getArticle(id);
if (!articleDetail) {
ctx.helper.returnBody(400, '不存在此条数据!', {}, '00001');
return;
}
ctx.helper.returnBody(200, '获取文章成功!', articleDetail, '00000');
}
...
// app/service/article.js
...
async getArticle(id) {
const { ctx } = this;
return await ctx.model.Article.findOne({
where: {
id,
},
});
}
...
添加文章
[post] /api/create
// app/controller/article.js
...
// 添加文章
async postItem() {
const { ctx } = this;
const { title, logo, desc, content } = ctx.request.body;
// 新文章
const newArticle = { title, logo, desc, content };
const article = await ctx.service.article.addArticle(newArticle);
console.log(article);
if (!article) {
await ctx.helper.returnBody(400, '网络错误,请稍后再试!', {}, '00001');
return;
}
console.log('ssss', ctx.body);
await ctx.helper.returnBody(200, '新建文章成功!', article, '00000');
}
...
// app/service/article.js
...
async addArticle(data) {
const { ctx } = this;
return await ctx.model.Article.create(data);
}
...
编辑文章
[post] /api/edit
// app/controller/article.js
...
// 编辑文章
async putItem() {
const { ctx } = this;
const { id, title, logo, desc, content } = ctx.request.body;
// 存在文章
const editArticle = { id, title, logo, desc, content };
const article = await ctx.service.article.editArticle(id, editArticle);
console.log(article);
if (!article) {
ctx.helper.returnBody(400, '网络错误,请稍后再试!', {}, '00001');
return;
}
if (article[0] === 0) {
ctx.helper.returnBody(400, '文章不存在,请检查重试!', {}, '00001');
return;
}
ctx.helper.returnBody(200, '编辑文章成功!', article, '00000');
}
...
// app/service/article.js
...
async editArticle(id, data) {
const { ctx } = this;
return await ctx.model.Article.update(data, {
where: {
id,
},
});
}
...
删除文章
[post] /api/del
// app/controller/article.js
...
// 删除文章
async deleteItem() {
const { ctx } = this;
const { id } = ctx.request.query;
const articleDetail = await ctx.service.article.deleteArticle(id);
if (!articleDetail) {
ctx.helper.returnBody(400, '不存在此条数据!', {}, '00001');
return;
}
ctx.helper.returnBody(200, '删除文章成功!', articleDetail, '00000');
}
...
// app/service/article.js
...
async deleteArticle(id) {
const { ctx } = this;
return await ctx.model.Article.destroy({
where: {
id,
},
});
}
...
使用postMan测试接口
当表为空,不做任何操作时的状态
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XFTkivBG-1603701814715)(/egg16.jpg)]
1.添加文章
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QsPNH3i3-1603701814716)(/egg17.jpg)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-re4sBlfh-1603701814716)(/egg18.jpg)]
2.修改文章
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EuZbgEci-1603701814717)(/egg19.jpg)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-psqvGXyJ-1603701814717)(/egg20.jpg)]
3.获取文章
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jB9JqK4H-1603701814718)(/egg21.jpg)]
4.删除文章
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Asyx2ZNn-1603701814719)(/egg22.jpg)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X6no7M3B-1603701814719)(/egg23.jpg)]
使用单元测试
在编写测试之前,推荐阅读官方文档单元测试章节 Egg单元测试 了解相关知识。
由于在前面的 egg 配置中,我们将单元测试环境和开发环境指向了不同的数据库,因此需要通过 Migrations 来初始化测试数据库的数据结构:
npx sequelize-cli db:migrate --env=test
有数据库访问的单元测试直接写起来会特别繁琐,特别是很多接口我们需要创建一系列的数据才能进行,造测试数据是一个非常繁琐的过程。为了简化单测,我们可以通过 factory-girl 模块来快速创建测试数据。
- 安装 factory-girl 依赖
npm install --save-dev factory-girl
- 定义 factory-girl 的数据模型到 test/factories.js 中
// test/factories.js
'use strict';
const { factory } = require('factory-girl');
module.exports = app => {
// 可以通过 app.factory 访问 factory 实例
app.factory = factory;
// 定义 user 和默认数据
factory.define('article', app.model.Article, {
title: factory.sequence('Article.', n => `title_${n}`),
logo: '2233',
});
};
- 初始化文件 test/.setup.js,引入 factory,并确保测试执行完后清理数据,避免被影响。
我们对测试单元执行顺序无特殊要求
const { app } = require("egg-mock/bootstrap");
const factories = require("./factories");
before(() => factories(app));
afterEach(async () => {
// clear database after each test case
await Promise.all([app.model.Article.destroy({ truncate: true, force: true })]);
});
接下来我们就可以开始编写真正的测试用例了:【 覆盖我们的增删改查的接口 】
// test/app/controller/article.test.js
/*
* @description:
* @Author: ljc
* @Date: 2020-10-26 10:41:48
* @LastEditors: ljc
* @LastEditTime: 2020-10-26 11:35:53
*/
'use strict';
const { assert, app } = require('egg-mock/bootstrap');
describe('test/app/controller/article.test.js', () => {
describe('GET /api/getLists', () => {
it('should work', async () => {
// 通过 factory-girl 快速创建 user 对象到数据库中
await app.factory.createMany('article', 3);
const res = await app.httpRequest().get('/api/getLists?page=1&pageSize=10');
assert(res.status === 200);
assert(res.body.data.results.length === 3);
assert(res.body.data.results[0].title);
assert(res.body.data.results[0].logo);
});
});
describe('GET /api/getArticle/:id', () => {
it('should work', async () => {
const article = await app.factory.create('article');
const res = await app.httpRequest().get(`/api/getArticle/${article.id}`);
assert(res.status === 200);
assert(res.body.data.logo === article.dataValues.logo);
});
});
describe('POST /api/create', () => {
it('should work', async () => {
app.mockCsrf();
let res = await app.httpRequest().post('/api/create').send({
logo: '233333',
title: '我是测试',
});
assert(res.status === 200);
assert(res.body.data.id);
res = await app.httpRequest().get(`/api/getArticle/${res.body.data.id}`);
assert(res.status === 200);
assert(res.body.data.title === '我是测试');
});
});
describe('POST /api/edit', () => {
it('should work', async () => {
app.mockCsrf();
let res = await app.httpRequest().post('/api/create').send({
logo: '233333',
title: '我是测试',
});
assert(res.body.data.id);
const resLast = await app.httpRequest().post('/api/edit').send({
id: res.body.data.id,
logo: '233333',
title: '我是修改过后的测试',
});
assert(resLast.status === 200);
res = await app.httpRequest().get(`/api/getArticle/${res.body.data.id}`);
assert(res.status === 200);
assert(res.body.data.title === '我是修改过后的测试');
});
});
describe('DELETE /api/del?id=', () => {
it('should work', async () => {
const article = await app.factory.create('article');
app.mockCsrf();
const res = await app.httpRequest().get(`/api/del?id=${article.id}`);
assert(res.status === 200);
});
});
});
最后,如果我们需要在 CI 中运行单元测试,需要确保在执行测试代码之前,执行一次 migrate 确保数据结构更新,例如我们在 package.json
中声明 scripts.ci
来在 CI 环境下执行单元测试:
{
"scripts": {
"ci": "eslint . && npx sequelize db:migrate --env=test && egg-bin cov"
}
}
- 通过命令
npm run cli
执行单元测试
展示结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VD6xwbA8-1603701814720)(/test.gif)]
测试结果 概况
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oo9ilLe2-1603701814721)(/egg24.jpg)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kofrqp6B-1603701814721)(/egg25.jpg)]
五、前端项目搭建
源码地址
前端项目源码地址:山岚笔札
可在线预览效果 http://news.epochy.cn
山岚笔札 V1.0
本项目demo为配合egg.js搭建的服务接口,链接mysql数据库测试CRUD使用,使用Vue开发,具备完整的增删改查功能。
效果预览
预览图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lvosVRLm-1603701814722)(https://cdn.jsdelivr.net/gh/ledtwo/eggNews@1.0/src/assets/preview1.jpg “首页”)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TP2Jr1da-1603701814722)(https://cdn.jsdelivr.net/gh/ledtwo/eggNews@1.0/src/assets/preview2.jpg “详情页”)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oynVRGxQ-1603701814723)(https://cdn.jsdelivr.net/gh/ledtwo/eggNews@1.0/src/assets/preview3.jpg “左滑操作文章队列”)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AF7vL0Di-1603701814724)(https://cdn.jsdelivr.net/gh/ledtwo/eggNews@1.0/src/assets/preview4.jpg “添加文章预览”)]
项目结构
前端项目文件结构
dif-client
├─ .gitignore
├─ babel.config.js
├─ package-lock.json
├─ package.json
├─ public
│ ├─ favicon.ico
│ └─ index.html
├─ README.md
├─ src
│ ├─ App.vue
│ ├─ assets
│ │ ├─ bg1.jpg
│ │ ├─ bg2.jpg
│ │ ├─ bg3.jpg
│ │ ├─ logo.png
│ │ └─ twoP.jpg
│ ├─ common
│ │ └─ initHtmlEditor.js
│ ├─ components
│ │ └─ HelloWorld.vue
│ ├─ main.js
│ ├─ router
│ │ └─ index.js
│ └─ views
│ ├─ add.vue
│ ├─ details.vue
│ └─ home.vue
└─ vue.config.js
使用方法
- 克隆本仓库https://github.com/ledtwo/eggNews.git,
npm i
安装所有依赖包 - 启动项目 npm run serve 就可以愉快的玩耍啦~
module.exports = {
lintOnSave: false,
devServer: {
proxy: {
"/": {
// 下面这行替换成你的服务地址
target: "http://localhost:7001/",
ws: true,
changeOrigin: true,
},
},
},
runtimeCompiler: true,
};
六、执行过程与运行环境
执行过程
当想继续深入探寻egg的机制时,我们可以先从最熟悉的request讲起
如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ba7Kb4SB-1603701814725)(/eggServe.jpg)]
绿色虚线框起来的所有组件组成了一个Worker,
这就是egg.js中实际执行代码逻辑的进程
【即一个node服务器。】
- request进来后,先穿过中间件,自己定义的中间件都放在
eggServerDir/app/middleware
下,并在config中启用。 - egg.js内置了egg-static中间件,将静态资源放在
eggServerDir/app/public
中,只会经过egg-static中间件之前的中间件,最后egg-static直接响应给客户端,不会到达其后的中间件以及Router。 - 如果不是public中的资源,将会穿越所有中间件,到达路由。一般所有的路由都放在router.js中,这个文件没有任何逻辑,而是直接指向一个处理请求的controller,只起到目录和索引的作用:
router.get('/users', controller.home.index);
- Controller都放在
eggServerDir/app/controller
中,不含有具体的业务逻辑,业务逻辑都在Service中,Controller只负责调用并组合Service,最后将响应提交给客户端。 - Service放在
eggServerDir/app/service
中,负责调用Model,进行具体业务。 - 除此之外,Worker中还有定时任务,写在
eggServerDir/app/schedule
文件夹中。 - 各个部件的所有可操控行为,都可以在
eggServerDir/confg/
中的配置文件中定义,配置文件可以同时有很多份,default会被具体环境的配置文件中的同名字段覆盖,具体使用哪份配置,是根据使用场景来决定
待续…
运行环境
EGG_SERVER_ENV这个环境变量的值。
框架有两种方式指定运行环境:
通过 config/env
文件指定,该文件的内容就是运行环境,如 prod。一般通过构建工具来生成这个文件。
// config/env
prod
通过 EGG_SERVER_ENV
环境变量指定运行环境更加方便,比如在生产环境启动应用:
npm start EGG_SERVER_ENV=prod
七、扩展内置对象
内置对象可以被方便地获取到,不过功能有限,我们可以通过egg.js的扩展(Extend)功能去进一步加强、定制框架的能力。
egg.js中有非常多新鲜的特性:“扩展”、“插件”、“多环境配置”,这些特性名称虽然不一样,但本质都是一样的:有则覆盖,无则增加。类似于lodash中的defaults函数,也类似于继承。
因此,如果我们想扩展Application对象,根据egg.js规范,应该在projectDir/app/extend/下增加application.js:
// app/extend/application.js
module.exports = {
specialName: "ljc's app"
}
以后就可以方便地调用app.specialName获取这个值。
待续…
九、中间件
待续…
八、与express框架的中间件比较
其实中间件执行逻辑没有什么特别的不同,都是依赖函数调用栈的执行顺序,抬杠一点讲都可以叫做洋葱模型。
-
Koa 依靠
async/await
让异步操作可以变成同步写法,更好理解。 -
最关键的不是这些中间的执行顺序,而是响应的时机。
-
Express 使用
res.end()
是立即返回,这样想要做出些响应前的操作变得比较麻烦; -
而 Koa 是在所有中间件中使用 ctx.body 设置响应数据,但是并不立即响应,而是在所有中间件执行结束后,再调用
res.end(ctx.body)
进行响应,这样就为响应前的操作预留了空间,所以是请求与响应都在最外层,中间件处理是一层层进行,所以被理解成 洋葱模型。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uDE2PYEW-1603701814725)(/atnsr-gzcrg.jpg)] -
这个流程可以从源码
compse(middlewares)
后形成的函数执行处看到,这个合并的函数执行后有个.then((ctx) => { res.end(ctx.body) })
的操作,我们也可以通过在不同中间件中都设置ctx.body
,会发现响应数据被一次次覆盖。
形成差异的核心就是请求的响应的时机不同
express是在调用res.send就结束响应了,而koa则是在中间件调用完成之后,在洋葱的最外层,由koa调用res.send方法。
待续…