node_egg Controller Controller

Controller Controller

所有的 Controller 文件都必须放在 app/controller 目录下,
可以支持多级目录,访问的时候可以通过目录名级联访问

Controller defined

// app/controller/post.js
const Controller = require('egg').Controller;
class PostController extends Controller {
  async create() {
    const { ctx, service } = this;
    const createRule = {
      title: { type: 'string' },
      content: { type: 'string' },
    };
    // 校验参数
    ctx.validate(createRule);
    // 组装参数
    const author = ctx.session.userId;
    const req = Object.assign(ctx.request.body, { author });
    // 调用 Service 进行业务处理
    const res = await service.post.create(req);
    // 设置响应内容和响应状态码
    ctx.body = { id: res.id };
    ctx.status = 201;
  }
}
module.exports = PostController;
我们通过上面的代码定义了一个 PostController 的类,类里面的每一个方法都可以作为一个 Controller 在 Router 中引用到,
我们可以从 app.controller 根据文件名和方法名定位到它。
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.post('createPost', '/api/posts', controller.post.create);
  // Controller 支持多级目录,例如如果我们将上面的 Controller 代码放到 app/controller/sub/post.js 中
  router.post('createPost', '/api/posts', controller.sub.post.create); // 可以这样访问
}
定义Controller类,会在每一个请求访问到server时实例化一个全新的对象,
项目中Controller类继承于egg.Controller,会有几个属性挂载this上
  · this.ctx: 当前请求的上下文Context对象的实例,处理当前请求的各种属性和方法
  · this.app: 当前应用Application对象的实例,获取框架提供的全局对象和方法
  · this.service: 应用定义的Service,可以访问到抽象出的业务层,等价于this.ctx.service
  · this.config: 应用运行时的配置项
  · this.logger: logger对象,对象上有四个方法(debug, info, warn, error)分别代表打印不同级别的日志

HTTP basic

Controller 基本上是业务开发中唯一和 HTTP 协议打交道的地方
如果发起一个post请求访问Controller,
axios({
  url: '/home',
  method: 'post',
  data: {name: 'jack', age:18},
  headers: {'Content-Type':'application/json; charset=UTF-8'}
})
// 发出的HTTP请求的内容就是下面这样
POST /home HTTP/1.1      
Host: localhost:3000
Content-Type: application/json; charset=UTF-8

{name: 'jack', age:18}
1. 请求行,第一行包含三个信息,我们比较常用的是前面两个
  method: 请求方式为post
  path: 值为/home,如果用户请求包含query也会显示在这里
2. 请求头,第二行开始直到遇到第一个空行位置,都是请求headers的部分
  Host: 浏览器会将域名和端口号放在host头中一并发给服务端
  Content-Type: 当请求有body的时候,都会有content-type来标明我们的请求体时什么格式的
  还有Cookie,User-agent等等,都在这个请求头中
3. 空行,发送回车符和换行符,通知服务器以下不再有请求头,它的作用是通过一个空行,告诉服务器请求头部到此为止。
4. 最后一行,请求体/请求数据
  如果请求方式为post,请求参数和值就会放在这里,会把数据以key: value的形式发送请求
  如果请求方式为post,请求参数和值就会包含在资源路径(URL)上

// 服务端处理完这个请求后,会返送一个HTTP响应给客户端
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 8
Date: Mon, 09 Jan 2017 08:40:28 GMT
Connection: keep-alive

{"id": 1}
1. 状态行,第一行,响应状态码,这个例子中的值为201,含义是在服务端成功创建一条资源 
2. 响应头,第二行开始到第一个空行,
  Content-Type: 表示响应的格式为json格式
  Content-Length: 表示长度为8个字节
3. 空行,响应结束
4. 返回响应的内容

Gets HTTP request parameters

框架通过在Comtroller上绑定Context实例,提供许多方法和属性来获取用户通过HTTP发送过来的参数

1.query

在URL中?后面的部分是一个Query String,经常用域get类型的请求中传递参数
// 例如 http://localhost:7001/home?name=jack&age=18
name=jack&age=18就是用户传递过来的参数,我们通过ctx.query获取解析过后的参数体
class PostController extends Controller {
  async listPosts() {
    const query = this.ctx.query; // => {name: 'jack', age: 18}
  }
}
当Query String中的key值重复,ctx.query只获取key第一次出现的值,后面的忽略
有时候我们的系统会设计成让用户传递相同的 key,例如 GET /posts?category=egg&id=1&id=2&id=3。
针对此类情况,框架提供了 ctx.queries 对象, 但是会将每一个数据都放进一个数组中
// GET /posts?category=egg&id=1&id=2&id=3
class PostController extends Controller {
  async listPosts() {
    console.log(this.ctx.queries);
    // {category: ['egg'], id: ['1', '2', '3']} 就算没有重复的值,也会保存到数组中
  }
}

2. Router params

在router动态路由中,也可以申明参数,这些参数都可以通过ctx.params获取到
// router.get('/projects/:projectId/app/:appId', controller.home.listApp)
class AppController extends Controller {
  async listApp() {
    this.ctx.response.body = `projectid:${this.ctx.params.projectId}, appid:${this.ctx.params.appId}`;
  }
}
// http://localhost:7001/projects/zhangsan/app/18 => 'projectid:zhangsan, appid:18'

3. body

虽然可以通过url传递参数,但是有许多限制
浏览器中会对url的长度有所限制,如果参数过多就会无法传递
服务端经常会将访问的完整的url暴露在请求信息中,一些敏感数据通过url传递不安全

前面HTTP请求报文实例中,header之后还有一个body部分,通常会在这个部分传递post,put等方法的参数
一般请求中有body的时候,客户端(浏览器)会同时发送Content-Type告诉服务端这次请求的body什么格式的
web开发数据传递最常用的格式json和Form,框架内置了bodyParse中间件来对这两类格式的请求body解析成obj
将obj对象挂载到全局ctx.request.body上,GET,HEAD方法无法获取到内容
let options = {
  url: '/form',
  method: 'post',
  data: {name: 'jack', age: 18},
  headers: {'Content-Type': 'application/json'}
}
axios(options).then(data=> {console.log(data)})
class PostController extends Controller {
  async listPosts() {
    console.log(this.ctx.request.body.name) // => 'jack'
    console.log(this.ctx.request.body.age) // => 18
  }
}
// 框架对bodyParse设置了一些默认参数:
1. Content-Type为application/json,application/json-patch+json,
   application/vnd.api+json和application/csp-report时,
   会按照json格式对请求body进行解析,并限制最大长度为100kb
2. Content-Type为application/x-www-form-urlencoded时,
   会按照form格式对请求body进行解析,限制最大长度为100kb
3. 如果解析成功,body一定会是一个object(可能时一个数组)
// 配置解析允许的最大长度,可以在config/config-default.js中覆盖框架默认值
module.exports = {
  bodyParser: {
    jsonLimit: '1mb',
    formLimit: '1mb',
  },
};
如果超出最大长度,抛出状态码413的异常。请求body解析失败,抛出错误状态码400的异常
// 一个常见的错误是把ctx.request.body和ctx.body混淆,后者是ctx.response.body的简写

Get upload files

请求body除了可以带参数以外,还可以发送文件,一般浏览器上都是通过Multipart/form-data格式发送文件
框架通过内置Multipart插件来支持获取用户上传的文件
1. 在config文件中启用file模式
  // config/config.default.js
  exports.multipart = {
    mode: 'file',
  };
2. 上传单个文件/前端静态页面form标签
  <form method="POST" action="http://localhost:7001/upload" enctype="multipart/form-data">
    // mtehod: 请求方式
    // action: 请求地址
    // enctype: 请求类型
    title: <input name="title" />
    file: <input name="file" type="file" />
    <button type="submit">Upload</button>
  </form>
3. 对应的后端代码
  // app/controller/upload.js
  const Controller = require('egg').Controller;
  const fs = require('fs');
  module.exports = class extends Controller {
    async upload() {
      const { ctx } = this;
      const file = ctx.request.files[0]; // 返回一个数组,保存的文件对象
      let result;
      try {
        // 处理文件,将上传的文件从缓存移动到本地服务器upload文件夹下
        // app/public/upload/file.filename
        fs.rename(file.filepath, `${__dirname}/../public/upload/${file.filename}`)
        result = 'success'
      } catch(e){
        console.log(e)
      }
      ctx.response.body = result;
    }
  };
// 对于多个文件,我们借助 ctx.request.files 属性进行遍历,然后分别进行处理:
  <form method="POST" action="http:localhost:7001/upload" enctype="multipart/form-data">
    title: <input name="title" />
    file1: <input name="file1" type="file" />
    file2: <input name="file2" type="file" />
    <button type="submit">Upload</button>
  </form>
  // app/controller/upload.js
  const Controller = require('egg').Controller;
  const fs = require('fs');
  module.exports = class extends Controller {
    async upload() {
      const { ctx } = this;
      console.log(ctx.request.body) //form表单其他字段
      for (const file of ctx.request.files) {
        console.log('field: ' + file.fieldname);   //字段名等于file.field
        console.log('filename: ' + file.filename); //文件名
        console.log('encoding: ' + file.encoding); //编码
        console.log('mime: ' + file.mime);         // mime类型
        console.log('tmp filepath: ' + file.filepath); //文件临时缓存目录
        try {
          do something....
        } catch (e) {
          ....
        }
      }
    }
  }
// 为了保证文件上传的安全,框架限制了支持的的文件格式,框架默认支持白名单
jpg,png,gif,svg,js,json,zip,mp3,mp4.......
用户可以通过在 config/config.default.js 中配置来新增支持的文件扩展名,或者重写整个白名单
module.exports = {
  multipart: {
    // 增加对 apk 扩展名的文件支持
    fileExtensions: [ '.apk' ] 
    // 覆盖整个白名单,只允许上传 '.png' 格式,当whitelist重写时,fileExtensions不生效
    // whitelist: [ '.png' ], 
  },
};
HTTP 请求都是无状态的,但是我们的 Web 应用通常都需要知道发起请求的人是谁
为了解决这个问题,HTTP 协议设计了一个特殊的请求头:Cookie
通过 ctx.cookies,我们可以在 Controller 中便捷、安全的设置和读取 Cookie。
'use strict';
const Controller = require('egg').Controller;
class CookieController extends Controller {
  async add() {
    const ctx = this.ctx;
    let count = ctx.cookies.get('count');
    count = count ? Number(count) : 0;
    // 每请求一次count加1,在没有调用remove以前,页面刷新后,cookie记录着上一次的值
    ctx.cookies.set('count', ++count);
    ctx.body = count;
  }
  async remove() {
    const ctx = this.ctx;
    //将cookie的count属性清空
    ctx.cookies.set('count', null);
    ctx.status = 204;
  }
}
module.exports = CookieController;
// Cookie 在 Web 应用中经常承担了传递客户端身份信息的作用,因此有许多安全相关的配置
// 详情: https://eggjs.org/zh-cn/core/cookie-and-session.html#cookie
// config/config.default.js
module.exports = {
  cookies: {
    // httpOnly: true | false, // true: 不能被修改, false,可以修改
    // sameSite: 'none|lax|strict', //配置应用级别的 Cookie SameSite 属性等于 Lax
    // signed: false, //可以被访问
    // encrypt: true, // 加密传输,不能看到明文
  },
};

Session

通过 Cookie,我们可以给每一个用户设置一个 Session,用来存储用户身份相关的信息,
这份信息会加密后存储在 Cookie 中,实现跨请求的用户身份保持,
Cookie 在 Web 应用中经常承担标识请求方身份的功能,
所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别。

// 框架内置了 Session 插件,给我们提供了 ctx.session 来访问或者修改当前用户 Session 
class PostController extends Controller {
  async fetchPosts() {
    const ctx = this.ctx;
    // 获取 Session 上的内容
    const userId = ctx.session.userId;
    const posts = await ctx.service.post.fetch(userId);
    // 修改 Session 的值
    ctx.session.visited = ctx.session.visited ? ++ctx.session.visited : 1;
    // Session 的使用方法非常直观,直接读取它或者修改它就可以了,如果要删除它,直接将它赋值为 null
    // this.ctx.session = null;
    ctx.body = {
      success: true,
      posts,
    };
  }
}
// 设置session属性时,不是以_开头,不能时关键字,
  ctx.session._visited = 1  // ×
  ctx.session.get = 'haha'  // ×
  ctx.session.visited = 1   // √
// Session的实现时基于Cookie的,默认配置下,用户Session的内容加密后直接存储在Cookie的一个字段中,
// 用户每次请求网站的时候都会带上这个Cookie,我们在服务端解密后使用
// config/config.default.js
exports.session = {
  key: 'EGG_SESS',
  maxAge: 24 * 3600 * 1000, // session保存的时间,1 天
  httpOnly: true,
  encrypt: true,
  renew: true, // 它会在发现当用户 Session 的有效期仅剩下最大有效期一半的时候,重置 Session 的有效期
};
// 可以看到这些参数除了 key 都是 Cookie 的参数,key 代表了存储 Session 的 Cookie 键值对的 key 是什么。在默认的配置下,存放 Session 的 Cookie 将会加密存储、不可被前端 js 访问,这样可以保证用户的 Session 是安全的。

Parameter check

在获取到用户请求的参数后,不可避免的要对参数进行一些校验。
借助 Validate 插件提供便捷的参数校验机制,帮助我们完成各种复杂的参数校验。
下载安装插件 npm install egg-validata --save
// 配置插件 config/plugin.js
exports.validate = {
  enable: true, // 启用插件
  package: 'egg-validate'
}

// 通过 ctx.validate(rule, [body]) 直接对参数进行校验:
rule: 校验规则,
body: 可选,不传递该参数会自动校验 ctx.request.body
// app/controller/home.js
class PostController extends Controller {
  async create() {
    const ctx = this.ctx;
    const createRule = {
      title: { type: 'string' },
      content: { type: 'string' },
    };
    try {
      //当校验异常,抛出异常,状态码422,errors字段包含详细验证不通过的信息
      ctx.validate(createRule);
    } catch (err) {
      ctx.logger.warn(err.errors);
      ctx.body = { success: false };
      return;
    }
  }
};

Call Service

我们并不想在 Controller 中实现太多业务逻辑,所以提供了一个 Service 层进行业务逻辑的封装,
这不仅能提高代码的复用性,同时可以让我们的业务逻辑更好测试。
在 Controller 中可以调用任何一个 Service 上的任何方法,
同时 Service 是懒加载的,只有当访问到它的时候框架才会去实例化它。
class PostController extends Controller {
  async create() {
    const ctx = this.ctx;
    // 进行参数处理
    const author = ctx.session.userId;
    const req = Object.assign(ctx.request.body, { author });
    // 调用 service 进行业务处理
    const res = await ctx.service.post.create(req);
    ctx.body = { id: res.id };
    ctx.status = 201;
  }
}

Sending an HTTP response

当业务逻辑完成之后,Controller 的最后一个职责就是将业务逻辑的处理结果通过 HTTP 响应发送给用户。

1. Set status

HTTP 设计了非常多的状态码,每一个状态码都代表了一个特定的含义,通过设置正确的状态码,可以让响应更符合语义。
class PostController extends Controller {
  async create() {
    // 设置状态码为 201
    this.ctx.status = 201;
  }
};

2. Set up body

绝大多数的数据都是通过 body 发送给请求方的,和请求中的 body 一样,在响应中发送的 body,也需要有配套的 Content-Type 告知客户端如何对数据进行解析。
· 作为一个 RESTful 的 API 接口 controller,我们通常会返回 Content-Type 为 application/json 格式的 body,内容是一个 JSON 字符串
· 作为一个 html 页面的 controller,我们通常会返回 Content-Type 为 text/html 格式的 body,内容是 html 代码段。
// 注意:ctx.body 是 ctx.response.body 的简写,不要和 ctx.request.body 混淆了。
class ViewController extends Controller {
  async show() {
    this.ctx.body = {
      name: 'egg',
      category: 'framework',
      language: 'Node.js',
    };
  }

  async page() {
    this.ctx.body = '<html><h1>Hello</h1></html>';
  }
}

3. Set Header

我们通过状态码标识请求成功与否、状态如何,
在 body 中设置响应的内容。而通过响应的 Header,还可以设置一些扩展信息。 
通过 ctx.set(key, value) 方法可以设置一个响应头,ctx.set(headers) 设置多个 Header。
// app/controller/api.js
class ProxyController extends Controller {
  async show() {
    const ctx = this.ctx;
    const start = Date.now();
    ctx.body = await ctx.service.post.get();
    const used = Date.now() - start;
    // 设置一个响应头
    ctx.set('show-response-time', used.toString());
  }
};

JSONP

有时我们需要给非本域的页面提供接口服务,又由于一些历史原因无法通过 CORS(跨域) 实现,可以通过 JSONP 来进行响应。
1. 通过 app.jsonp() 提供的中间件来让一个 controller 支持响应 JSONP 格式的数据。在路由中,我们给需要支持 jsonp 的路由加上这个中间件:
  module.exports = app => {
    const jsonp = app.jsonp();
    app.router.get('/api/posts/:id', jsonp, app.controller.posts.show);
    app.router.get('/api/posts', jsonp, app.controller.posts.list);
  };
2. 在Controller中,正常编写
  // app/controller/posts.js
  class PostController extends Controller {
    async show() {
      this.ctx.body = {
        name: 'egg',
        category: 'framework',
        language: 'Node.js',
      };
    }
  }
3. JSONP配置
  // config/config.default.js
  exports.jsonp = {
    callback: 'callback', // 识别 query 中的 `callback` 参数
    limit: 100, // 函数名最长为 100 个字符
  };
通过上面的方式配置之后,如果用户请求 /api/posts/1?callback=fn,响应为 JSONP 格式,
如果用户请求 /api/posts/1,响应格式为 JSON。

// 我们同样可以在 app.jsonp() 创建中间件时覆盖默认的配置,以达到不同路由使用不同配置的目的
// app/router.js
module.exports = app => {
  const { router, controller, jsonp } = app;
  router.get('/api/posts/:id', jsonp({ callback: 'callback' }), controller.posts.show);
  router.get('/api/posts', jsonp({ callback: 'cb' }), controller.posts.list);
};

// 在 JSONP 配置中,我们只需要打开 csrf: true,即可对 JSONP 接口开启 CSRF 校验。
// config/config.default.js
module.exports = {
  jsonp: {
    csrf: true,
  },
};
// 如果在同一个主域之下,可以通过开启 CSRF 的方式来校验 JSONP 请求的来源

// 如果想对其他域名的网页提供 JSONP 服务,我们可以通过配置 referrer 白名单的方式来限制 JSONP 的请求方在可控范围之内。
//config/config.default.js
exports.jsonp = {
  whiteList: /^https?:\/\/test.com\//,  
  // whiteList: '.test.com',
  // whiteList: 'sub.test.com',
  // whiteList: [ 'sub.test.com', 'sub2.test.com' ],
};
// whiteList 可以配置为正则表达式、字符串或者数组:
  1. 正则表达式: 此时只有请求的 Referrer 匹配该正则时才允许访问 JSONP 接口
  2. 字符串:当字符串以 . 开头,例如 .test.com 时,代表 referrer 白名单为 test.com 的所有子域名,包括 test.com 自身
            当字符串不以 . 开头,例如 sub.test.com,代表 referrer 白名单为 sub.test.com 这一个域名。
    exports.jsonp = {
      whiteList: '.test.com',
    };
    // matches domain test.com: // 匹配的域名
    // https://test.com/hello
    // http://test.com/
    
    // matches subdomain //匹配子域名
    // https://sub.test.com/hello
    // http://sub.sub.test.com/
    
    exports.jsonp = {
      whiteList: 'sub.test.com',
    };
    // only matches domain sub.test.com: //仅仅匹配这一个域名
    // https://sub.test.com/hello
    // http://sub.test.com/
  3. 数组: 当设置的白名单为数组时,满足数组中任意一个
    exports.jsonp = {
      whiteList: [ 'sub.test.com', 'sub2.test.com' ],
    };
    // matches domain sub.test.com and sub2.test.com:
    // https://sub.test.com/hello
    // http://sub2.test.com/
    
// 当 CSRF 和 referrer 校验同时开启时,请求发起方只需要满足任意一个条件即可通过 JSONP 的安全校验

Redirect

框架通过 security 插件覆盖了 koa 原生的 ctx.redirect 实现,以提供更加安全的重定向。
  1. ctx.redirect(url) 如果不在配置的白名单域名内,则禁止跳转。
  2. ctx.unsafeRedirect(url) 不判断域名,直接跳转,一般不建议使用,明确了解可能带来的风险后使用。
//使用ctx.redirect(url),需要在应用配置文件配置:
// config/config.default.js
exports.security = {
  domainWhiteList:['.domain.com'],  // 安全白名单,以 . 开头
};
若用户没有配置 domainWhiteList 或者 domainWhiteList数组内为空,则默认会对所有跳转请求放行,即等同于ctx.unsafeRedirect(url)

Guess you like

Origin www.cnblogs.com/JunLan/p/12578666.html