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);
};

代码简单明了,一眼就能看懂是如何配置的,为了防止看不懂,我们来说一下

image-20200814113043622

配置方法明白了再来说一下路由文件整体的结构

首先从egg中解构出Application,这是一个接口,对传入的参数做出约束(启动文件在egg包中,使用过程基本不用动);从传入的app中再解构出controllerrouter,router就是路由对象

image-20200814114317902

可以看到源码中router对象中有很多HTTP方法

image-20200814114505745

这个是最常用的get方法参数,路径和中间件,中间件这里就是要执行的操作,示例中用到controller中的方法

POST请求

直接用相同的方法配置POST请求会抛出错误

error

因为这样不符合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已经获取到了表单数据

image-20200815220805570

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

image-20200814174024725

获取传值

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);
  }
}

image-20200814150310702

可以看到已经从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);
}

image-20200814152644496

服务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}`;
  }
}

属性

image-20200814174524884

配置项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}`;
}

image-20200814184147214

日志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');

image-20200814185530905

上下文ctx

  • this.ctx.curl 发起网络调用。
  • this.ctx.service.otherService 调用其他 Service。
  • this.ctx.db 发起数据库调用等, db 可能是其他插件提前挂载到 app 上的模块。

注意

image-20200814190621897

模板引擎

安装模板引擎

使用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的配置方式不同,如下图

image-20200814154013457

编写模板代码

<!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的第一个参数是模板名称,这里需要加上后缀名,否则会报错

效果如图

image-20200814161413074

实战:小爬虫

地址: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对象中直接调用方法即可

image-20200815100708535

其他对象拓展方式跟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数组中添加文件名

image-20200815151449656

然后每次请求时就会看到打印出了日志

image-20200815151546815

中间件传值

中间件传值在配置文件中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();
  };
}

服务启动时就会在控制台看到打印出来的日志

image-20200815153449330

实用中间件

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是存储在访问者计算机中的变量,可以让同一个浏览器访问同一个域名的时候共享数据

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

image-20200815224513166

获取

ctx.cookies.get(key, options)

image-20200815224842251

设置

  • {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。

image-20200815225602597Session

  • Cookie 在 Web 应用中经常承担标识请求方身份的功能,所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别
  • Session保存在服务器上

Egg中使用Session

image-20200815225941005

可以在config中配置session信息

exports.session = {
  key: 'EGG_SESS',
  maxAge: 24 * 3600 * 1000, // 1 天
  httpOnly: true,
  encrypt: true,
};

代码

代码已上传码云,传送门


前端小白