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>
最终效果

框架拓展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
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,
};
代码
代码已上传码云,传送门
 
         
                     
                     
                     
                     
                   
                  

 
                          