Egg开发项目实践纪实

博客版文档在线阅读地址: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

初始化完成后,我们可以发现两点

  1. 其实目录和配置文件并不多,但是结构清晰。
  2. 在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’)
INTEGER32位整型,可用属性: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()
UUIDUUID类型列,其默认值可以为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 就可以在 ControllerService 中通过 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

使用方法

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方法。

待续…

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值