node_egg控制器Controller


控制器Controller

所有的 Controller 文件都必須放在 app/controller 目錄下,
可以支持多級目錄,訪問的時候可以通過目錄名級聯訪問

Controller定義

// 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 基礎

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. 返回響應的內容

獲取HTTP請求參數

框架通過在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的簡寫

獲取上傳文件

請求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, // 加密傳輸,不能看到明文
    // maxAge: 1000 * 60 * 60 * 24, // cookie保存一天的時間
  },
};

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 是安全的。

參數校驗

在獲取到用戶請求的參數后,不可避免的要對參數進行一些校驗。
借助 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;
    }
  }
};

調用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;
  }
}

發送HTTP響應

當業務邏輯完成之后,Controller 的最后一個職責就是將業務邏輯的處理結果通過 HTTP 響應發送給用戶。

1. 設置status

HTTP 設計了非常多的狀態碼,每一個狀態碼都代表了一個特定的含義,通過設置正確的狀態碼,可以讓響應更符合語義。
class PostController extends Controller {
  async create() {
    // 設置狀態碼為 201
    this.ctx.status = 201;
  }
};

2. 設置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.設置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 的安全校驗

重定向

框架通過 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)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM