EggJS学习笔记
开始
介绍
Egg.js 为企业级框架和应用而生,Express框架本身缺少约定,标准的 MVC 模型会有各种千奇百怪的写法。Egg 按照约定进行开发,奉行『约定优于配置』,团队协作成本低。
Egg特性
- 提供基于Egg定制上层框架的能力
- 高度可拓展的插件机制
- 内置多进程管理
- 基于Koa开发,性能优异
- 框架稳定,测试覆盖率高
- 渐进式开发
安装
$ npm init egg --type=ts
$ npm i
本篇主要使用ts,类型可以不填,会让你手动选择,
启动
$ npm run dev
$ open http://localhost:7001
目录结构
egg-project
├── package.json -- 框架配置,依赖
├── app --源码目录
| ├── router.ts -- 用于配置 URL 路由规则
| ├── model -- 用于放置领域模型
│ | └── user.ts
│ ├── controller -- 用于解析用户的输入,处理后返回相应的结果
│ | └── home.ts
│ ├── service (可选) -- 用于编写业务逻辑层
│ | └── user.ts
│ ├── middleware (可选) -- 用于编写中间件
│ ├── schedule (可选) -- 用于定时任务
│ ├── public (可选) -- 用于放置静态资源
│ ├── view (可选) -- 用于放置模板文件
│ └── extend (可选) -- 用于框架的扩展
├── config -- 配置文件
| ├── plugin.ts -- 用于配置需要加载的插件
| ├── config.default.ts -- 用于编写配置文件(下同)
│ ├── config.prod.ts
| ├── config.test.ts (可选)
| ├── config.local.ts (可选)
| └── config.unittest.js (可选)
├── logs -- 日志文件
└── test/app -- 用于单元测试
└── controller -- 用于controller层的单元测试
└── home.test.js
路由
路由文件代码如下
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router } = app;
router.get('/', controller.home.index);
router.get('/login', controller.admin.login);
};
代码简单明了,一眼就能看懂是如何配置的,为了防止看不懂,我们来说一下
配置方法明白了再来说一下路由文件整体的结构
首先从egg中解构出Application
,这是一个接口,对传入的参数做出约束(启动文件在egg包中,使用过程基本不用动);从传入的app中再解构出controller
和router
,router就是路由对象
可以看到源码中router对象中有很多HTTP方法
这个是最常用的get方法参数,路径和中间件,中间件这里就是要执行的操作,示例中用到controller中的方法
POST请求
直接用相同的方法配置POST请求会抛出错误
因为这样不符合Egg的安全机制,常见的安全问题
- XSS 攻击:对 Web 页面注入脚本,使用 JavaScript 窃取用户信息,诱导用户操作。
- CSRF 攻击:伪造用户请求向网站发起恶意请求。
- 钓鱼攻击:利用网站的跳转链接或者图片制造钓鱼陷阱。
- HTTP参数污染:利用对参数格式验证的不完善,对服务器进行参数注入攻击。
- 远程代码执行:用户通过浏览器提交执行命令,由于服务器端没有针对执行函数做过滤,导致在没有指定绝对路径的情况下就执行命令。
解决办法1
在访问页面时向页面发送签名数据csrf
public async index() {
const { ctx } = this;
// ctx.body = await ctx.service.test.sayHi('egg');
await ctx.render('login.ejs', {
csrf: ctx.csrf,
});
}
然后在页面发送请求的时候带上这个csrf
<form action="/login?_csrf=<%= csrf %> " method="post">
用户名:<input type="text" name="username" /><br/>
密码:<input type="password" name="password" /><br/>
<button type="submit">登录</button>
</form>
解决办法2
使用中间件发送csrf
import { Context } from 'egg';
// 自定义的中间件
export default function printDate(): any {
return async (ctx: Context, next: () => Promise<any>) => {
// 设置全局变量
ctx.state.csrf = ctx.csrf;
await next();
};
}
这样就不用每次在方法里面发送csrf了
如果觉得url带着一长串csrf不好看,可以在表单域设置csrf
<form action="/login" method="post">
<input type="hidden" name="_csrf" value="<%= csrf %>">
用户名:<input type="text" name="username" /><br/>
密码:<input type="password" name="password" /><br/>
<button type="submit">登录</button>
</form>
controller已经获取到了表单数据
resources
自动配置RESTful风格API,只需要一行代码router.resources('posts', '/api/posts', controller.posts);
,就可以自动生成下面一套API
Method | Path | Route Name | Controller.Action |
---|---|---|---|
GET | /api/posts | posts | app.controllers.posts.index |
GET | /api/posts/new | new_post | app.controllers.posts.new |
GET | /api/posts/:id | post | app.controllers.posts.show |
GET | /api/posts/:id/edit | edit_post | app.controllers.posts.edit |
POST | /api/posts | posts | app.controllers.posts.create |
PUT | /api/posts/:id | post | app.controllers.posts.update |
DELETE | /api/posts/:id | post | app.controllers.posts.destroy |
只需要在Controller中实现Action即可
控制器Controller
看完路由再来看一下controller,代码如下
import { Controller } from 'egg';
export default class AdminController extends Controller {
public async login() {
const { ctx } = this;
ctx.body = await ctx.service.login.welcome('egg');
}
}
新建一个类继承egg中的Controller,编写方法login
这里注意,egg基于Koa,Koa是
ctx.xxx
,在egg中ctx被封装在this里面,使用解构赋值解析出来,如果不使用解构就要使用this.ctx.xxx
获取传值
get类型
通过ctx.query
获取参数,比如
export default class AdminController extends Controller {
public async index() {
const { ctx } = this;
const userName: string = ctx.query.name;
ctx.body = await ctx.service.login.welcome(userName);
}
}
可以看到已经从url中获取了name参数
动态路由
从url中获取数据,但不是使用query的形式,而是使用http://127.0.0.1:7001/user/123
这种形式
在配置路由时添加参数,例如 router.get('/user/:id', controller.admin.user);
然后在Controller中使用ctx.params
获取参数,如
public async user() {
const { ctx } = this;
const id: string = ctx.params.id;
ctx.body = await ctx.service.login.userCenter(id);
}
服务Service
Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层,好处:
- 保持 Controller 中的逻辑更加简洁。
- 保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用。
- 将逻辑和展现分离,更容易编写测试用例
使用场景:
- 复杂数据的处理,比如要展现的信息需要从数据库获取,还要经过一定的规则计算,才能返回用户显示。或者计算完成后,更新到数据库。
- 第三方服务的调用
定义服务代码示例
import { Service } from 'egg';
/**
* Login Service
*/
export default class Login extends Service {
/**
* userCenter for you
* @param id - your id
*/
public async userCenter(id: string) {
return `ID:${id}`;
}
}
属性
配置项config
在config.default.ts
中添加配置项,可以直接在controller或者service中调用配置项。比如在config中定义一个字符串,然后在service中调用,代码如下
//config.default.ts
// 自定义配置项
config.str = 'this is a config string';
// Login.ts
public async userCenter(id: string) {
console.log(this.config.str);
return `ID:${id}`;
}
日志logger
logger有四种级别,下面给出示例
this.logger.debug('this is degug');
this.logger.info('this is info');
this.logger.warn('this is warn');
this.logger.error('this is error');
上下文ctx
this.ctx.curl
发起网络调用。this.ctx.service.otherService
调用其他 Service。this.ctx.db
发起数据库调用等, db 可能是其他插件提前挂载到 app 上的模块。
注意
模板引擎
安装模板引擎
使用ejs做案例,首先安装ejs包,npm文档
$ npm i egg-view-ejs --save
然后将下面这段代码放到对应的配置文件里面
// {app_root}/config/plugin.js
exports.ejs = {
enable: true,
package: 'egg-view-ejs',
};
// {app_root}/config/config.default.js
exports.view = {
mapping: {
'.ejs': 'ejs',
},
};
// ejs config
exports.ejs = {};
注意ts的配置方式不同,如下图
编写模板代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2 style="color: #409eff;"><%= data %></h2>
<ul>
<% for(let i = 0; i < list.length; i++) { %>
<li><%= list[i] %></li>
<% } %>
</ul>
<img src="/public/images/egg.jpg" alt="" width="100px" height="60px">
</body>
</html>
模板文件一定要放在app下的view目录下
ejs语法:
<%
‘脚本’ 标签,用于流程控制,无输出。<%_
删除其前面的空格符<%=
输出数据到模板(输出是转义 HTML 标签)<%-
输出非转义的数据到模板<%#
注释标签,不执行、不输出内容<%%
输出字符串 ‘<%’%>
一般结束标签-%>
删除紧随其后的换行符_%>
将结束标签后面的空格符删除
controller返回模板并传值
public async user() {
const { ctx } = this;
const id: string = ctx.params.id;
const udata: string = await ctx.service.login.userCenter(id);
const arr: number[] = [ 111, 222, 333 ];
await ctx.render('user.ejs', {
data: udata,
list: arr,
});
}
- 这里注意两个点
- ctx.render是异步方法,需要使用await修饰
- render的第一个参数是模板名称,这里需要加上后缀名,否则会报错
效果如图
实战:小爬虫
地址:http://www.phonegap100.com/appapi.php?a=getPortalList&catid=20&page=1
Service
import { Service } from 'egg';
/**
* News Service
*/
export default class Login extends Service {
/**
* getNewsList
*/
public async getNewsList() {
const url = this.config.api + 'appapi.php?a=getPortalList&catid=20&page=1';
const res = await this.ctx.curl(url);
const response = JSON.parse(res.data);
return response.result;
}
/**
* getContent
* @param aid 文章aid
*/
public async getContent(aid: string) {
const url = this.config.api + 'appapi.php?a=getPortalArticle&aid=' + aid;
const res = await this.ctx.curl(url);
const response = JSON.parse(res.data);
console.log(response.result);
return response.result;
}
}
Controller
import { Controller } from 'egg';
export default class AdminController extends Controller {
public async index() {
const { ctx } = this;
const data = await ctx.service.news.getNewsList();
await ctx.render('news.ejs', {
data,
});
}
public async newsContent() {
const { ctx } = this;
const aid = ctx.query.aid;
const data = await ctx.service.news.getContent(aid);
await ctx.render('newscontent.ejs', {
data: data[0],
});
}
}
ejs
news.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>News</title>
</head>
<body>
<h2>新闻列表</h2>
<ul>
<% for( let i = 0; i < data.length; i++ ) { %>
<li><a href="/newscontent?aid=<%= data[i].aid %>" ><%= data[i].title %></a> </li>
<% } %>
</ul>
</body>
</html>
newscontent.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>详情</title>
</head>
<body>
<%- data.content %>
<script>
document.title = '<%= data.title %>'
</script>
</body>
</html>
最终效果
![QQ录屏20200814204744 00_00_00-00_00_08](https://cdn.easyremember.cn/img/QQ录屏20200814204744 00_00_00-00_00_08.gif)
框架拓展extend
有多种拓展自身的功能
- Application
- Context
- Request
- Response
- Helper
Application
app
对象指的是 Koa 的全局应用对象,全局只有一个,在应用启动时被创建
拓展方式
框架会把 app/extend/application.ts
中定义的对象与 Koa Application 的 prototype 对象进行合并,直接创建文件进行编码即可,例如
import { Application } from 'egg';
export default {
func(this: Application) {
return 'app extend';
},
};
在app对象中直接调用方法即可
其他对象拓展方式跟Application同样
注意:request和response是在ctx里面使用的
中间件middleware
配置中间件
Egg 的中间件形式和 Koa 的中间件形式是一样的,都是基于洋葱圈模型。
一个中间件是一个放置在 app/middleware
目录下的单独文件,它需要 exports 一个普通的 function,接受两个参数:
- options: 中间件的配置项,框架会将
app.config[${middlewareName}]
传递进来。 - app: 当前应用 Application 的实例。
来看一下代码示例,这个中间件的作用是在每次请求的时候打印出时间
import { Context } from 'egg';
// 自定义的中间件
export default function printDate(): any {
return async (ctx: Context, next: () => Promise<any>) => {
ctx.logger.info(new Date());
await next();
};
}
中间件写完之后要在config中进行配置,在middlewware数组中添加文件名
然后每次请求时就会看到打印出了日志
中间件传值
中间件传值在配置文件中bizConfig中增加中间件同名对象,就可以在中间件中通过options来访问变量
const bizConfig = {
// 中间件传值
printdate: {
configStr: 'this is value from config',
},
};
这时中间件需要配置参数
import { Context, Application, EggAppConfig } from 'egg';
// 自定义的中间件
export default function printDate(options: EggAppConfig['printdate'], app: Application): any {
app.logger.warn(options.configStr);
return async (ctx: Context, next: () => Promise<any>) => {
ctx.logger.info(new Date());
await next();
};
}
服务启动时就会在控制台看到打印出来的日志
实用中间件
csrf中间件
import { Context } from 'egg';
// 自定义的中间件
export default function auth(): any {
return async (ctx: Context, next: () => Promise<any>) => {
// 设置全局变量
ctx.state.csrf = ctx.csrf;
await next();
};
}
然后在config中添加中间件config.middleware = [ 'auth' ];
,就可以在任意页面上获取csrf了
Cookie
Cookie
是存储在访问者计算机中的变量,可以让同一个浏览器访问同一个域名的时候共享数据
Egg中Cookie的设置和获取
设置
ctx.cookies.set(key, value, options)
public async add() {
const { ctx } = this;
ctx.cookies.set('username', ctx.request.body.username);
ctx.body = ctx.request.body;
}
在浏览器的开发者工具中查看Cookie
获取
ctx.cookies.get(key, options)
设置
{Number} maxAge
: 设置这个键值对在浏览器的最长保存时间。是一个从服务器当前时刻开始的毫秒数。{Date} expires
: 设置这个键值对的失效时间,如果设置了 maxAge,expires 将会被覆盖。如果 maxAge 和 expires 都没设置,Cookie 将会在浏览器的会话失效(一般是关闭浏览器时)的时候失效。{String} path
: 设置键值对生效的 URL 路径,默认设置在根路径上(/
),也就是当前域名下的所有 URL 都可以访问这个 Cookie。{String} domain
: 设置键值对生效的域名,默认没有配置,可以配置成只在指定域名才能访问。{Boolean} httpOnly
: 设置键值对是否可以被 js 访问,默认为 true,不允许被 js 访问。{Boolean} secure
: 设置键值对只在 HTTPS 连接上传输,框架会帮我们判断当前是否在 HTTPS 连接上自动设置 secure 的值。
除了这些属性之外,框架另外扩展了 3 个参数的支持:
{Boolean} overwrite
:设置 key 相同的键值对如何处理,如果设置为 true,则后设置的值会覆盖前面设置的,否则将会发送两个 set-cookie 响应头。{Boolean} signed
:设置是否对 Cookie 进行签名,如果设置为 true,则设置键值对的时候会同时对这个键值对的值进行签名,后面取的时候做校验,可以防止前端对这个值进行篡改。默认为 true。{Boolean} encrypt
:设置是否对 Cookie 进行加密,如果设置为 true,则在发送 Cookie 前会对这个键值对的值进行加密,客户端无法读取到 Cookie 的明文值。默认为 false。
Session
- Cookie 在 Web 应用中经常承担标识请求方身份的功能,所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别
- Session保存在服务器上
Egg中使用Session
可以在config中配置session信息
exports.session = {
key: 'EGG_SESS',
maxAge: 24 * 3600 * 1000, // 1 天
httpOnly: true,
encrypt: true,
};
代码
代码已上传码云,传送门