在之前我们已经了解过 Node.js
和 Koa.js
的一些相关知识,今天我们来看看如何使用 Egg.js
来实现一个接口服务,Egg.js
是一个基于 Koa.js
框架而实现的框架,所以它应当属于框架之上的框架,它继承了 Koa.js
的高性能优点,同时又加入了一些『约束与开发规范』,来规避 Koa.js
框架本身的开发自由度太高的问题
Koa.js
是一个比较基层的框架,它本身没有太多约束与规范,自由度非常高,每一个开发者实现自己的服务的时候,代码风格都可以能不太一样,而 Egg.js
为了适应企业开发,加了一些开发时的规范与约束,从而解决 Koa.js
这种自由度过高而导致不适合企业内使用的缺点,Egg.js
便在这种背景下诞生,关于 Egg.js
更多的特性,这里我们只做简单介绍,更多的可以参考官网 egg.js
需求 需求比较简单,只需要实现一个接口服务即可,简单来说就是实现一个连接数据库,查询数据库里的数据并且提供一个 HTTP
接口服务,下面我们来看看如何实现
实现 首先安装 Egg.js
,根据官方文档提供的方法即可
1 2 3 4 5 $ npm init egg --type=simple $ npm i $ npm run dev
但是这里有一个需要注意的地方,如果想要使用 npm init egg
命令,npm
的版本需要 >= 6.1.0
启动成功以后,我们先来建立一张表,用于我们的后续操作,使用的 SQL
如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 CREATE TABLE `Tab_User_Info` ( id INT(100 ) AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50 ) NOT NULL COMMENT '姓名' , uid VARCHAR(50 ) NOT NULL, sex tinyint(2 ) DEFAULT 1 COMMENT '1男2女' , age tinyint(2 ) DEFAULT 1 , description VARCHAR(50 ) DEFAULT NULL, `createdAt` datetime DEFAULT CURRENT_TIMESTAMP, `updatedAt` datetime DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT = 'test user' ; INSERT INTO Tab_User_Info (`name` , uid, sex, age, description) VALUES ('zhangsan' , 'uid123' , 1 , 24 , 'this is boy' ), ('lisi' , 'uid124' , 2 , 24 , 'this is girl' ), ('wangwu' , 'uid125' , 1 , 26 , 'this is test user' ), ('zhaoliu' , 'uid126' , 2 , 44 , 'this is test user5' ), ('test01' , 'uid127' , 2 , 64 , 'this is test user4' ), ('test02' , 'uid128' , 1 , 14 , 'this is test user2' ), ('test03' , 'uid129' , 2 , 4 , 'this is test user9' );
完成后结果如下
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 mysql> show databases; +--------------------+ | Database | +--------------------+ | information_schema | | test | +--------------------+ 2 rows in set (0.00 sec)mysql> use test; Database changed mysql> show tables; +----------------+ | Tables_in_test | +----------------+ | tab_user_info | +----------------+ 1 row in set (0.00 sec) mysql> select * from tab_user_info; +----+----------+--------+------+------+--------------------+---------------------+---------------------+ | id | name | uid | sex | age | description | createdAt | updatedAt | +----+----------+--------+------+------+--------------------+---------------------+---------------------+ | 1 | zhangsan | uid123 | 1 | 24 | this is boy | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | | 2 | lisi | uid124 | 2 | 24 | this is girl | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | | 3 | wangwu | uid125 | 1 | 26 | this is test user | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | | 4 | zhaoliu | uid126 | 2 | 44 | this is test user5 | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | | 5 | test01 | uid127 | 2 | 64 | this is test user4 | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | | 6 | test02 | uid128 | 1 | 14 | this is test user2 | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | | 7 | test03 | uid129 | 2 | 4 | this is test user9 | 2019-11-12 17:51:38 | 2019-11-12 17:51:38 | +----+----------+--------+------+------+--------------------+---------------------+---------------------+ 7 rows in set (0.00 sec)
连接数据库 首先安装 mysql
插件 egg-mysql
1 $ npm install egg-mysql --save
接下来修改目录下的配置文件,开启 mysql
插件,更多的配置参数可以参考官方文档 egg-mysql
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 module .exports = { mysql: { enable: true , package: 'egg-mysql' , }, }; const mysql = { client: { host: '127.0.0.1' , port: '3306' , user: 'root' , password: '' , database: 'test' , }, app: true , agent: false , }; return { mysql };
路由 然后再来实现路由
1 2 3 4 5 6 7 module .exports = app => { const { router, controller } = app router.get('/' , controller.home.index) router.get('/user/list' , controller.user.list) router.get('/user/find' , controller.user.find) }
服务 然后我们来添加两个服务,一个 searchAll()
方法和一个 find(id)
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 'use strict' const Service = require ('egg' ).Serviceclass UserService extends Service { async searchAll() { const users = await this .app.mysql.select('tab_user_info' ) return { users } } async find(id) { const user = await this .app.mysql.get('tab_user_info' , { id }) return { user } } } module .exports = UserService
控制器 我们之前设定了两个服务,现在就建立一个对应的控制器来进行使用
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 'use strict' const Controller = require ('egg' ).Controllerclass UserController extends Controller { async list() { const { ctx } = this try { const userList = await ctx.service.user.searchAll() ctx.body = { success: true , data: userList, } } catch (error) { ctx.body = { success: false , error, } } } async find() { const { ctx } = this try { if (!ctx.query.id) throw new Error ('缺少参数' ) const userList = await ctx.service.user.find(ctx.query.id) ctx.body = { success: true , data: userList, } } catch (error) { ctx.body = { success: false , error, } } } } module .exports = UserController
验证 下面我们就可以在浏览器当中访问 http://127.0.0.1:7001/user/list
来访问我们的接口,可以发现已经将数据库当中所有的列表信息展示了出来,如果想针对单独的 id
进行查询,只需要访问 find
接口,然后传递参数即可,例如 http://127.0.0.1:7001/user/find?id=7
逻辑很简单,当路由匹配到我们对应访问的地址的时候(/user/list
)就回去调用我们对应的控制器(controller.user.list
),然后在控制器当中又回去访问我们之前定义的服务来进行数据库的数据查询
相关问题汇总 本节主要记录在学习 Egg.js
相关知识的时候遇到的一些坑或者知识点
query && queries 在 url
中的 ?
后面的部分是一个 Query String
,这一部分经常用于 GET
类型的请求中传递参数,例如 GET /search?name=zhangsan&age=18
中 name=zhangsan&age=18
就是用户传递过来的参数,Egg.js
已经帮我们封装好了获取方式,所以我们可以直接通过 this.ctx.query
来拿到解析过后的这个参数体
1 2 3 4 5 6 7 8 9 10 11 12 13 'use strict' const Controller = require ('egg' ).Controllerclass NewsController extends Controller { async index() { const query = this .ctx.query console .log(query.age) console .log(query) } } module .exports = NewsController
不过这样的使用方式上有一点需要注意的地方
当 Query String
中的 key
重复时,this.ctx.query
只会取 key
第一次出现时的值,后面再出现的都会被忽略,比如 GET /search?name=zhangsan&name=lisi
通过 this.ctx.query
拿到的值是 { name: 'zhangsan' }
但是有时候用户会传递相同的 key
,例如 GET /search?name=zhangsan&id=123&id=456
,针对此类情况,框架提供了 this.ctx.queries
对象,这个对象也解析了 Query String
,但是它不会丢弃任何一个重复的数据,而是将他们都放到一个数组中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 'use strict' const Controller = require ('egg' ).Controllerclass NewsController extends Controller { async index() { console .log(this .ctx.queries) } } module .exports = NewsController
this.ctx.queries
上所有的 key
如果有值,也一定会是数组类型
helper helper
函数用来提供一些实用的工具函数,它的作用在于我们可以将一些常用的动作抽离在 helper.js
里面成为一个独立的函数,下面来看一个比较常见的实例,序列化模版引擎当中的日期格式,我们先在 helper.js
当中定义我们格式化的方法
这里有个需要注意的地方,定义的文件名字需要是一致的,因为框架会把 app/extend/helper.js
中定义的对象与内置 helper
的 prototype
对象进行合并,在处理请求时会基于扩展后的 prototype
生成 helper
对象,这里使用的是 silly-datetime
这个日期库,使用其他的也是可行的
1 2 3 4 5 6 7 8 9 10 'use strict' const sd = require ('silly-datetime' )module .exports = { formatTime(time) { return sd.format(new Date (time * 1000 ), 'YYYY-MM-DD HH:mm' ) }, }
然后在模版当中不需要引入之类的操作,直接使用即可(helper.formatTime()
)
1 2 3 4 5 6 7 8 <ul > <% for (var i = 0; i < list.length; i++) {%> <li > <a href ="/newscontent?aid=<%= list[i].aid %>" > <%= list [i ].title %> </a > --- <span > <%= helper.formatTime (list [i ].datetime ) %> </span > </li > <% } %> </ul >
跨域请求设置 可以使用 egg-cors
这个库,先配置 plugin.js
1 2 3 4 5 exports.cors = { enable: true , package: 'egg-cors' , }
然后在配置 config.default.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 config.security = { csrf: { enable: false , ignoreJSON: true , }, domainWhiteList: ['http://www.baidu.com' ], } config.cors = { allowMethods: 'GET, HEAD, PUT, POST, DELETE, PATCH' , }
不要使用全部允许跨域,可能会引起安全方面的问题,建议配置访问白名单(注释掉 origin
即可)
使用 koa 的中间件 在框架里面可以非常容易的引入 Koa
中间件生态,以 koa-compress
为例,在 Koa
中使用时
1 2 3 4 5 6 7 const koa = require ('koa' )const compress = require ('koa-compress' )const app = koa()const options = { threshold : 2048 }app.use(compress(options))
在 Egg.js
当中使用如下
1 2 module .exports = require ('koa-compress' )
一个需要注意的地方,koa-compress
暴露的接口((options) => middleware
)和框架对中间件要求一致,配置中间件
1 2 3 4 5 6 7 module .exports = { middleware: ['compress' ], compress: { threshold: 2048 , }, }
表单内容的获取 先来看如下代码
1 2 3 4 5 6 7 8 9 module .exports = app => { app.router.post('/form' , app.controller.form.post) } exports.post = async ctx => { ctx.body = `body: ${JSON .stringify(ctx.request.body)} ` }
这里如果直接发起 POST
请求是会报错的,错误提示为 missing csrf token
,简单来说,因为框架中内置了安全插件 egg-security
,提供了一些默认的安全实践,并且框架的安全插件是默认开启的,如果需要关闭其中一些安全防范,直接设置该项的 enable
属性为 false
即可
1 2 3 exports.security = { csrf: false }
路由重定向 内部重定向
1 2 3 4 5 6 7 8 9 10 11 module .exports = app => { app.router.get('index' , '/home/index' , app.controller.home.index) app.router.redirect('/' , '/home/index' , 303 ) } exports.index = async ctx => { ctx.body = 'hello controller' }
外部重定向
1 2 3 4 5 6 7 8 9 10 exports.index = async ctx => { const type = ctx.query.type const q = ctx.query.q || 'nodejs' if (type === 'bing' ) { ctx.redirect(`http://cn.bing.com/search?q=${q} ` ) } else { ctx.redirect(`https://www.google.co.kr/search?q=${q} ` ) } }
自定义控制器基类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const { Controller } = require ('egg' )class BaseController extends Controller { get user() { return this .ctx.session.user } success(data) { this .ctx.body = { success: true , data, } } notFound(msg) { msg = msg || 'not found' this .ctx.throw(404 , msg) } } module .exports = BaseController
此时在编写应用的 Controller
时,可以继承 BaseController
,直接使用基类上的方法
1 2 3 4 5 6 7 8 9 10 11 const Controller = require ('../core/base_controller' )class PostController extends Controller { async list() { const posts = await this .service.listByUser(this .user) this .success(posts) } }
文件上传 一般来说,浏览器上都是通过 Multipart/form-data
格式发送文件的,框架通过内置 Multipart
插件来支持获取用户上传的文件,首先需要在 config
文件中启用 file
模式
1 2 3 4 exports.multipart = { mode: 'file' , }
然后就可以进行使用了,这里主要分为两种情况,上传单个文件和上传多个文件,我们先来看单文件情况
1 2 3 4 5 <form method ="POST" action ="/upload?_csrf={{ ctx.csrf | safe }}" enctype ="multipart/form-data" > title: <input name ="title" /> file: <input name ="file" type ="file" /> <button type ="submit" > Upload</button > </form >
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 const Controller = require ('egg' ).Controllerconst fs = require ('mz/fs' )module .exports = class extends Controller { async upload() { const { ctx } = this const file = ctx.request.files[0 ] const name = 'egg-multipart-test/' + path.basename(file.filename) let result try { result = await ctx.oss.put(name, file.filepath) } finally { await fs.unlink(file.filepath) } ctx.body = { url: result.url, requestBody: ctx.request.body, } } }
对于多个文件,我们借助 ctx.request.files
属性进行遍历,然后分别进行处理,HTML
还是一样的,不过添加了可接受多个值的文件上传字段 multiple
,这里主要来看后端是如何处理的
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 const Controller = require ('egg' ).Controllerconst fs = require ('mz/fs' )module .exports = class extends Controller { async upload() { const { ctx } = this console .log(ctx.request.body) console .log('got %d files' , ctx.request.files.length) for (const file of ctx.request.files) { console .log('field: ' + file.fieldname) console .log('filename: ' + file.filename) console .log('encoding: ' + file.encoding) console .log('mime: ' + file.mime) console .log('tmp filepath: ' + file.filepath) let result try { result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath) } finally { await fs.unlink(file.filepath) } console .log(result) } } }
但是这里有个需要注意的地方,为了保证文件上传的安全,框架限制了支持的的文件格式,框架默认支持白名单如下
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 const whitelist = [ '.jpg' , '.jpeg' , '.png' , '.gif' , '.bmp' , '.wbmp' , '.webp' , '.tif' , '.psd' , '.svg' , '.js' , '.jsx' , '.json' , '.css' , '.less' , '.html' , '.htm' , '.xml' , '.zip' , '.gz' , '.tgz' , '.gzip' , '.mp3' , '.mp4' , '.avi' , ]
但是我们可以通过在 config/config.default.js
中配置来新增支持的文件扩展名
1 2 3 4 5 6 7 module .exports = { multipart: { fileExtensions: ['.apk' ] }, }
或者重写整个白名单
1 2 3 4 5 6 module .exports = { multipart: { whitelist: ['.png' ], }, }
更多详细可以参考文档 egg-multipart
服务(service) 注意事项,service
文件必须放在 app/service
目录,可以支持多级目录,访问的时候可以通过目录名级联访问
1 2 3 app/service/biz/user.js ==> ctx.service.biz.user app/service/sync_user.js ==> ctx.service.syncUser app/service/HackerNews.js ==> ctx.service.hackerNews
一个 service
文件只能包含一个类,这个类需要通过 module.exports
的方式返回
service
需要通过 class
的方式定义,父类必须是 egg.service
service
不是单例,是『请求级别』的对象,框架在每次请求中首次访问 ctx.service.xx
时延迟实例化,所以 service
中可以通过 this.ctx
获取到当前请求的上下文
下面是一个实际的使用例子
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 const Controller = require ('egg' ).Controllerclass UserController extends Controller { async info() { const userId = this .ctx.params.id const userInfo = await this .ctx.service.user.find(userId) this .ctx.body = userInfo } } module .exports = UserControllerconst Service = require ('egg' ).Serviceclass UserService extends Service { async find(uid) { const user = await this .ctx.db.query('select * from user where uid = ?' , uid) const picture = await this .getPicture(uid) return { name: user.user_name, age: user.age, picture, } } async getPicture(uid) { const result = await this .ctx.curl(`http://photoserver/uid=${uid} ` , { dataType : 'json' }) return result.data } } module .exports = UserService
属性扩展 一般来说属性的计算只需要进行一次,那么一定要实现缓存,否则在多次访问属性时会计算多次,这样会降低应用性能,推荐的方式是使用 Symbol + Getter
的模式
1 2 3 4 5 6 7 8 9 10 11 12 13 const BAR = Symbol ('Application#bar' )module .exports = { get bar() { if (!this [BAR]) { this [BAR] = this .config.xx + this .config.yy } return this [BAR] }, }
session 存储 框架内置了 egg-session 插件,给我们提供了 ctx.session
来访问或者修改当前用户 Session
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class HomeController extends Controller { async fetchPosts() { const ctx = this .ctx const userId = ctx.session.userId const posts = await ctx.service.post.fetch(userId) ctx.session.visited = ctx.session.visited ? (ctx.session.visited + 1 ) : 1 ctx.body = { success: true , posts, } } }
但是有一个特别需要注意的地方,在设置 Session
属性时需要避免以下几种情况,因为会造成字段丢失(koa-session )
1 2 3 4 5 6 ctx.session._visited = 1 ctx.session.isNew = 'lisi' ctx.session.visited = 1
Session
默认存放在 Cookie
中,但是如果我们的 Session
对象过于庞大,就会带来一些额外的问题
浏览器通常都有限制最大的 Cookie
长度,当设置的 Session
过大时,浏览器可能拒绝保存
Cookie
在每次请求时都会带上,当 Session
过大时,每次请求都要额外带上庞大的 Cookie
信息
我们只需要设置 app.sessionStore
即可将 Session
存储到指定的存储中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 module .exports = app => { app.sessionStore = { async get (key) { }, async set (key, value, maxAge) { }, async destroy(key) { }, } }
sessionStore
的实现我们也可以封装到插件中,例如 egg-session-redis
就提供了将 Session
存储到 redis
中的能力,在应用层我们只需要引入 egg-redis 和 egg-session-redis 插件即可
1 2 3 4 5 6 7 8 9 10 exports.redis = { enable: true , package: 'egg-redis' , } exports.sessionRedis = { enable: true , package: 'egg-session-redis' , }
但是需要注意的是,一旦选择了将 Session
存入到外部存储中,就意味着系统将强依赖于这个外部存储,当它挂了的时候,就完全无法使用 Session
相关的功能了,因此更推荐只将必要的信息存储在 Session
中,保持 Session
的精简并使用默认的 Cookie
存储,用户级别的缓存不要存储在 Session
中
egg-jwt 本章节主要介绍使用 egg
验证 Token
的过程,首先初始化一个项目,然后安装两个用于我们之后操作所使用的包
1 2 3 4 5 6 npm init egg --type=ts npm install npm install egg-cors egg-jwt --save
安装完成后首先来配置 config/plugin.ts
当中的两个验证包
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { EggPlugin } from 'egg' const plugin: EggPlugin = { jwt: { enable: true , package: "egg-jwt" }, cors: { enable: true , package: 'egg-cors' , } } export default plugin
接下来是 config/config.default.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 config.jwt = { secret: 'abc' } config.security = { csrf: { enable: false , ignoreJSON: true }, domainWhiteList: ['http://localhost:8080' ], } config.cors = { origin: '*' , allowMethods: 'GET, HEAD, PUT, POST, DELETE, PATCH' }
最后一步操作,也是 TypeScript
独有的坑,需要在根目录下的 typings/index.d.ts
文件里声明一个 any
类型,否则会类型错误
1 2 3 4 5 6 7 import 'egg' declare module 'egg' { interface Application { jwt: any } }
下面就是具体操作流程,首先来定义路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { Application } from 'egg' export default (app: Application) => { const { controller, router, jwt } = app router.post('/admin/login' , controller.admin.login)
接下来就是设定控制器
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 import { Controller } from 'egg' export default class AdminController extends Controller { public async login() { const { ctx, app } = this const data = ctx.request.body const token = app.jwt.sign({ username: data.username, }, app.config.jwt.secret) ctx.body = token } public async index() { const { ctx, app } = this console .log(ctx.state.user)
最后前端在请求的时候需要在 headers
里面上添加上默认的验证字断 Authorization
就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 axios({ method: 'post' , url: 'http://127.0.0.1:7001/admin' , data: { username: 'admin' , lastName: '123456' }, headers: { 'Authorization' : `Bearer ${token} ` } }).then(res => { console .log(res.data) })