Nodejs學習記錄: koa2

圖片描述

koa list in github : https://github.com/topics/koajavascript

異常處理

圖片描述
圖片描述

併發

圖片描述

async / await

特色

  • 讓異步邏輯用同步寫法實現
  • 最底層的await返回須要是Promise對象
  • 能夠經過多層 async function 的同步寫法代替傳統的callback嵌套
function getSyncTime() {
  return new Promise((resolve, reject) => {
    try {
      let startTime = new Date().getTime()
      setTimeout(() => {
        let endTime = new Date().getTime()
        let data = endTime - startTime
        resolve( data )
      }, 500)
    } catch ( err ) {
      reject( err )
    }
  })
}

async function getSyncData() {
  let time = await getSyncTime()
  let data = `endTime - startTime = ${time}`
  return data
}

async function getData() {
  let data = await getSyncData()
  console.log( data )
}

getData()

圖片描述
瞭解更多異步編程,能夠戳鯨魚以前的筆記Nodejs學習記錄:異步編程html

如今咱們實現異步編程是用 async/await 加上 Promise, 那麼咱們使用Promise如何兼容之前的回調呢?--> async awarit前端

const fs = require("fs");

const readFilePromise = filename => {
   new Promise((resolve, reject) => {
      fs.readFile(filename, (err, data) => {
         if(err) {
            reject(err)
            return
         }
         resolve(data)
     })
  })
}


async function main() {
  const txt = await readFilePromise("mock.txt")
  console.log(txt.toString())
}

main()

cookie

HTTP 請求都是無狀態的,可是咱們的 Web 應用一般都須要知道發起請求的人是誰。爲了解決這個問題,HTTP 協議設計了一個特殊的請求頭:Cookie。服務端能夠經過響應頭(set-cookie)將少許數據響應給客戶端,瀏覽器會遵循協議將數據保存,並在下次請求同一個服務的時候帶上(瀏覽器也會遵循協議,只在訪問符合 Cookie 指定規則的網站時帶上對應的 Cookie 來保證安全性)。vue

cookie常常用於作登陸信息的儲存,固然咱們在後端常常喜歡用它,在前端的單頁應用通常喜歡用localstoragejava

經過 ctx.cookies,咱們能夠在 controller 中便捷、安全的設置和讀取 Cookie。node

來個簡單的案例,看看如何寫入cookiemysql

koa提供了從上下文直接讀取、寫入cookie的方法git

  • ctx.cookies.get(name, [options]) 讀取上下文請求中的cookie
  • ctx.cookies.set(name, value, [options]) 在上下文中寫入cookie

設置Cookie實際上是經過在HTTP響應中設置set-cookie頭完成,每一個set-cookie都會讓瀏覽器在Cookie中存一個鍵值對。在設置Cookie值同時,協議還支持許多參數來配置這個Cookie的傳輸、儲存和權限github

  • {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 的值。
  • {Boolean} overwrite

圖片描述
感興趣的能夠看看 cookie的實現源碼ajax

koa2 中操做的cookies是使用了npm的cookies模塊,因此在讀寫cookie的使用參數與該模塊的使用一致。
源碼在:https://github.com/pillarjs/c...

const Koa = require('koa')
const app = new Koa()
app.use(async(ctx) => {
    if(ctx.url === '/index'){
        ctx.cookies.set(
            'cid',
            'hello world',
            {
                domain: 'localhost', //寫cookie所在的域名
                path: '/index',// 寫cookie所在的路徑
                maxAge:10 * 60 * 1000, // cookie有效時長
                expires: new Date('2017-02-15'), // cookie失效時間
                httpOnly: false, // 是否只用於http請求中獲取
                overwrite:false // 是否容許重寫
            }
        )
        ctx.body = 'cookie is ok'
    } else {
        ctx.body = 'hello world'
    }
})

app.listen(3000, () => {
    console.log('[demo] cookie is starting at port 3000')
})

在設置 Cookie 時咱們須要思考清楚這個 Cookie 的做用,它須要被瀏覽器保存多久?是否能夠被 js 獲取到?是否能夠被前端修改?

訪問http://localhost:3000/index

  • 能夠在控制檯的cookie列表中中看到寫在頁面上的cookie
  • 在控制檯的console中使用document.cookie能夠打印出在頁面的全部cookie(須要是httpOnly設置false才能顯示)

圖片描述
圖片描述

更改下代碼

const Koa = require('koa')
const app = new Koa()
app.use(async(ctx) => {
    if(ctx.url === '/index'){
        ctx.cookies.set(
            'cid',
            'hello world',
            {
                domain: 'localhost', //寫cookie所在的域名
                path: '/index',// 寫cookie所在的路徑
                maxAge:10 * 60 * 1000, // cookie有效時長
                expires: new Date('2017-02-15'), // cookie失效時間
                httpOnly: false, // 是否只用於http請求中獲取
                overwrite:false // 是否容許重寫
            }
        );
        ctx.body = 'cookie is ok';
    } else {
        if(ctx.cookies.get('cid')){
            ctx.body= ctx.cookies.get('cid');
        }else {
            ctx.body = 'cookie is none';
        }
    }
})

app.listen(3000, () => {
    console.log('[demo] cookie is starting at port 3000')
})

重啓服務器node cookie.js,瀏覽器分別輸入
http://localhost:3000/index/ http://localhost:3000
http://localhost:3000/index/aa
圖片描述
圖片描述
由於咱們配置了

{path:'/index'}

圖片描述

session

Cookie 在 Web 應用中常常承擔標識請求方身份的功能,
可是cookie信息會被儲存在瀏覽器本地或硬盤中,這樣會有安全問題,若是有人可以訪問你的電腦就能分析出你的敏感信息,用戶名、密碼等等。爲了解決這個隱患,因此 Web 應用在 Cookie 的基礎上封裝了 Session 的概念,專門用作用戶身份識別。

既然服務器渲染又須要用戶登陸功能,那麼用session去記錄用戶登陸態是必要的

koa2原生功能只提供了cookie的操做,可是沒有提供session操做。session就只能本身實現或者經過第三方中間件實現。
可是基於koa的egg.js框架內置了 Session 插件,給咱們提供了 ctx.session 來訪問或者修改當前用戶 Session 。----> cookie & session
在koa2中實現session的方案有:

  • 若是session數據量很小,能夠直接存在內存中
  • 若是session數據量很大,則須要存儲介質存放session數據

 數據庫存儲方案

 儲存在MySQL

須要用到中間件:

  • koa-session

圖片描述

  • koa-session-minimal 適用於koa2 的session中間件,提供存儲介質的讀寫接口 。
  • koa-mysql-session 爲koa-session-minimal中間件提供MySQL數據庫的session數據讀寫操做。

而後,咱們將將sessionId和對應的數據存到數據庫

將數據庫的存儲的sessionId存到頁面的cookie中

根據cookie的sessionId去獲取對於的session信息

圖片描述
在mysql數據庫建立 Koa_session_demo數據庫
圖片描述

const Koa = require('koa')
const session = require('koa-session-minimal');
const MysqlSession = require('koa-mysql-session')

const app = new Koa()

//配置存儲session信息的mysql

let store = new MysqlSession({
    user: 'root',
    password: 'wyc2016',
    database: 'koa_session_demo',
    host: '127.0.0.1',
})

//存放sessionId的cookie配置

let cookie = {
    maxAge: '',// cookie有效時長
    expires: '',// cookie失效時間
    path: '', // 寫cookie所在的路徑
    domain: '', // 寫cookie所在的域名
    httpOnly: '', // 是否只用於http請求中獲取
    overwrite: '',  // 是否容許重寫
    secure: '',
    sameSite: '',
    signed: '',
}


// 使用session中間件

app.use(session({
    key: 'SESSION_ID',
    store: store,
    cookie: cookie
}))

app.use(async (ctx) => {
    // 設置session

    if(ctx.url === '/set') {
        ctx.session = {
            user_id: Math.random().toString(36).substr(2),
            count:0
        }
        ctx.body = ctx.session
    }else if (ctx.url === '/'){
        // 讀取session信息
        ctx.session.count = ctx.session.count + 1
        ctx.body = ctx.session
    }
})

app.listen(3000, ()=> {
    console.log('[demo] session is starting at port 3000');
})

圖片描述
圖片描述
圖片描述

 儲存在redis

在express中咱們用的是express-session,那麼在koa2中用的是哪些模塊:

注意:一旦選擇了將 Session 存入到外部存儲中,就意味着系統將強依賴於這個外部存儲,當它掛了的時候,咱們就徹底沒法使用 Session 相關的功能了。所以咱們更推薦你們只將必要的信息存儲在 Session 中,保持 Session 的精簡併使用默認的 Cookie 存儲,用戶級別的緩存不要存儲在 Session 中。

模板引擎

文件上傳(upload)

busboy模塊

npm install --save busboy

busboy 模塊是用來解析POST請求,node原生req中的文件流。
更多詳細API能夠訪問npm官方文檔 https://www.npmjs.com/package...

const inspect = require('util').inspect 
const path = require('path')
const fs = require('fs')
const Busboy = require('busboy')

// req 爲node原生請求
const busboy = new Busboy({ headers: req.headers })

// ...

// 監聽文件解析事件
busboy.on('file', function(fieldname, file, filename, encoding, mimetype) {
  console.log(`File [${fieldname}]: filename: ${filename}`)


  // 文件保存到特定路徑
  file.pipe(fs.createWriteStream('./upload'))

  // 開始解析文件流
  file.on('data', function(data) {
    console.log(`File [${fieldname}] got ${data.length} bytes`)
  })

  // 解析文件結束
  file.on('end', function() {
    console.log(`File [${fieldname}] Finished`)
  })
})

// 監聽請求中的字段
busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated) {
  console.log(`Field [${fieldname}]: value: ${inspect(val)}`)
})

// 監聽結束事件
busboy.on('finish', function() {
  console.log('Done parsing form!')
  res.writeHead(303, { Connection: 'close', Location: '/' })
  res.end()
})
req.pipe(busboy)

上傳文件簡單實現

#index.js
const Koa = require('koa');
const path = require('path');
const app = new Koa();

const {uploadFile} = require('./util/upload')

app.use( async (ctx) => {
    if(ctx.url === '/' && ctx.method === 'GET') {
        //當GET請求時候返回表單頁面

        let html = `
          <h1>koa2 upload demo</h1>
            <form method="POST" action="/upload.json" enctype="multipart/form-data">
            <p>file upload</p>
            <span>picName:</span><input name="picName" type="text" /><br/>
            <input name="file" type="file" /><br/><br/>
            <button type="submit">submit</button>
            </form>
        ` 
        ctx.body = html
    }else if (ctx.url === '/upload.json' && ctx.method ==='POST'){
        // 上傳文件請求處理
        let result = {success: false}
        let serverFilePath = path.join(__dirname, 'upload-files')

        //上傳文件事件
        result = await uploadFile(ctx, {
            fileType: 'album',
            path: serverFilePath
        })
        ctx.body = result
    }else {
        // 其餘請求顯示404
        ctx.body = '<h1>404!!! o(╯□╰)o</h1>'
    }

  })

app.listen(3000, () => {
    console.log('[demo] upload-simple is starting at port 3000');
})
# util/upload.js
const inspect = require('util').inspect
const path = require('path')
const fs = require('fs')
const Busboy = require('busboy')

/**
 * 同步建立文件目錄
 * @param  {string} dirname 目錄絕對地址
 * @return {boolean}        建立目錄結果
 */
function mkdirsSync( dirname ) {
  if (fs.existsSync( dirname )) {
    return true
  } else {
    if (mkdirsSync( path.dirname(dirname)) ) {
      fs.mkdirSync( dirname )
      return true
    }
  }
}

/**
 * 獲取上傳文件的後綴名
 * @param  {string} fileName 獲取上傳文件的後綴名
 * @return {string}          文件後綴名
 */
function getSuffixName( fileName ) {
  let nameList = fileName.split('.')
  return nameList[nameList.length - 1]
}

/**
 * 上傳文件
 * @param  {object} ctx     koa上下文
 * @param  {object} options 文件上傳參數 fileType文件類型, path文件存放路徑
 * @return {promise}
 */
function uploadFile( ctx, options) {
  let req = ctx.req
  let res = ctx.res
  let busboy = new Busboy({headers: req.headers})

  // 獲取類型
  let fileType = options.fileType || 'common'
  let filePath = path.join( options.path,  fileType)
  let mkdirResult = mkdirsSync( filePath )

  return new Promise((resolve, reject) => {
    console.log('文件上傳中...')
  let result = {
    success: false,
    formData: {},
  }

  // 解析請求文件事件
  busboy.on('file', function(fieldname, file, filename, encoding, mimetype) {
    let fileName = Math.random().toString(16).substr(2) + '.' + getSuffixName(filename)
    let _uploadFilePath = path.join( filePath, fileName )
    let saveTo = path.join(_uploadFilePath)

    // 文件保存到制定路徑
    file.pipe(fs.createWriteStream(saveTo))

    // 文件寫入事件結束
    file.on('end', function() {
      result.success = true
      result.message = '文件上傳成功'
      console.log('文件上傳成功!')
    })
  })

  // 解析表單中其餘字段信息
  busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
    console.log('表單字段數據 [' + fieldname + ']: value: ' + inspect(val));
    result.formData[fieldname] = inspect(val);
  });

  // 解析結束事件
  busboy.on('finish', function( ) {
    console.log('文件上結束')
    resolve(result)
  })

  // 解析錯誤事件
  busboy.on('error', function(err) {
    console.log('文件上出錯')
    reject(result)
  })

  req.pipe(busboy)
})

}


module.exports =  {
  uploadFile
}

圖片描述
圖片描述
圖片描述

異步上傳圖片實現

源碼:https://github.com/JXtreehous...
圖片描述

經過stream的方式上傳文件

參考中間件-> aliyun-oss-upload-stream

爲何使用``stream?
  • 使用stream的方式上傳文件能夠很大程度上下降服務器內存開銷。Aliyun官方SDK並無對stream進行一個完美的封裝,因此一般上傳文件(Put Object)的流程是客戶端上傳文件到服務器,服務器把文件數據緩存到內存,等文件所有上傳完畢後,一次性上傳到Aliyun Oss服務。這樣作一旦瞬間上傳文件的請求過多,服務器的內存開銷會直線上升。而使用stream的方式上傳文件的流程是客戶端在上傳文件數據到服務器的過程當中,服務器同時也在把文件數據往Aliyun Oss服務傳送,而不須要在服務器上緩存文件數據。
  • 能夠上傳大文件,根據上傳數據方式不一樣而不一樣, Put Object 方式 文件最大不能超過 5GB,而使用stream的方式,文件大小不能超過 48.8TB
  • 更快的速度,因爲傳統方式(Put Object方式)是客戶端上傳完畢文件後,統一上傳到Aliyun Oss,而stream的方式基本上客戶端上傳完畢後,服務器已經把一大半的文件數據上傳到Aliyun了,因此速度要快不少
  • 使用更簡單,通過封裝後,stream的方式使用起來很是的方便,1分鐘就能夠學會如何使用

數據庫

sequelize 鏈接池

ORM(Object Relational Mapping)框架,提供了了PostgreSQL, MySQL, SQLite and MSSQL 數據庫鏈接池
import Sequelize from "sequelize";
const sequelize = new Sequelize('mock_server', 'root', '123456', {
    host: 'localhost',
    dialect: 'mysql',
    dialectOptions: {
    charset: "utf8mb4",
    collate: "utf8mb4_unicode_ci",
    supportBigNumbers: true,
    bigNumberStrings: true
},
pool: {
    max: 5,
    min: 0,
    idle: 10000
}
});
const sequelize = ne

Sequelize ORM

this.dao = sequelize.define('Api', {
    project_id: Sequelize.INTEGER,
    url: Sequelize.STRING,
    request_body: Sequelize.TEXT,
    response_body: Sequelize.TEXT,
    user_id: Sequelize.INTEGER,
    description: Sequelize.TEXT,
    host: Sequelize.STRING,
});
this.dao.sync();
let api = this.dao.findOne({
    where: {
        user_id: user_id,
        id: api_id,
        project_id: pid,
    }
});

將查詢結果封裝成Promise

Sequelize 事務

Database Transaction

  • 託管事務:根據 Promise 鏈的結果⾃自動提交或回滾事

務,若是遇到⼀一⾏行行,⾃自動回滾全部操做

  • 非託管事務:若是出現異常,須要⼿手動處理理commit或

者執⾏行行rollback操做

mysql

npm install --save mysql

mysql模塊是node操做MySQL的引擎,能夠在node.js環境下對MySQL數據庫進行建表,增、刪、改、查等操做。

建立數據庫會話

onst mysql      = require('mysql')
const connection = mysql.createConnection({
  host     : '127.0.0.1',   // 數據庫地址
  user     : 'root',    // 數據庫用戶
  password : '123456'   // 數據庫密碼
  database : 'my_database'  // 選中數據庫
})
 
// 執行sql腳本對數據庫進行讀寫 
connection.query('SELECT * FROM my_table',  (error, results, fields) => {
  if (error) throw error
  // connected! 
  
  // 結束會話
  connection.release() 
});
注意:一個事件就有一個從開始到結束的過程,數據庫會話操做執行完後,就須要關閉掉,以避免佔用鏈接資源。

建立數據鏈接池

通常狀況下操做數據庫是很複雜的讀寫過程,不僅是一個會話,若是直接用會話操做,就須要每次會話都要配置鏈接參數。因此這時候就須要鏈接池管理會話。

const mysql = require('mysql')

// 建立數據池
const pool  = mysql.createPool({
  host     : '127.0.0.1',   // 數據庫地址
  user     : 'root',    // 數據庫用戶
  password : '123456'   // 數據庫密碼
  database : 'my_database'  // 選中數據庫
})
 
// 在數據池中進行會話操做
pool.getConnection(function(err, connection) {
   
  connection.query('SELECT * FROM my_table',  (error, results, fields) => {
    
    // 結束會話
    connection.release();
 
    // 若是有錯誤就拋出
    if (error) throw error;
  })
})

更多詳細API能夠訪問npm官方文檔 https://www.npmjs.com/package...

async/await封裝使用mysql

因爲mysql模塊的操做都是異步操做,每次操做的結果都是在回調函數中執行,如今有了async/await,就能夠用同步的寫法去操做數據庫
Promise封裝mysql模塊
Promise封裝 ./async-db

const mysql = require('mysql')
const pool = mysql.createPool({
  host     :  '127.0.0.1',
  user     :  'root',
  password :  '123456',
  database :  'my_database'
})

let query = function( sql, values ) {
  return new Promise(( resolve, reject ) => {
    pool.getConnection(function(err, connection) {
      if (err) {
        reject( err )
      } else {
        connection.query(sql, values, ( err, rows) => {

          if ( err ) {
            reject( err )
          } else {
            resolve( rows )
          }
          connection.release()
        })
      }
    })
  })
}

module.exports = { query }

async/await使用

const { query } = require('./async-db')
async function selectAllData( ) {
  let sql = 'SELECT * FROM my_table'
  let dataList = await query( sql )
  return dataList
}

async function getData() {
  let dataList = await selectAllData()
  console.log( dataList )
}

getData()

建表初始化

一般初始化數據庫要創建不少表,特別在項目開發的時候表的格式可能會有些變更,這時候就須要封裝對數據庫建表初始化的方法,保留項目的sql腳本文件,而後每次須要從新建表,則執行建表初始化程序就行

├── index.js # 程序入口文件
├── node_modules/
├── package.json
├── sql   # sql腳本文件目錄
│   ├── data.sql
│   └── user.sql
└── util    # 工具代碼
    ├── db.js # 封裝的mysql模塊方法
    ├── get-sql-content-map.js # 獲取sql腳本文件內容
    ├── get-sql-map.js # 獲取全部sql腳本文件
    └── walk-file.js # 遍歷sql腳本文件

https://chenshenhai.github.io...
https://github.com/ChenShenha...

圖片描述
圖片描述
圖片描述
圖片描述

Redis

nodejs調用redis服務

import redis from "redis";
const client = redis.createClient(({
    port: "19002",
    host: "localhost"
}));

使用場景

  • 首⻚頁數據列列表,top N列列表
  • 日誌
  • 計數信息(如接⼝口訪問次數等)
  • mock數據

難點

  • 數據同步
  • 內存飆升

JSONP

圖片描述
圖片描述

原生koa2實現jsonp

在項目複雜的業務場景,有時候須要在前端跨域獲取數據,這時候提供數據的服務就須要提供跨域請求的接口,一般是使用JSONP的方式提供跨域接口。
https://chenshenhai.github.io...
https://github.com/ChenShenha...

const Koa = require('koa')
const app = new Koa()

app.use(async (ctx) => {
    // 若是jsonp 的請求爲GET
    if(ctx.method === 'GET' && ctx.url.split('?')[0] ==='/getData.jsonp'){

      // 獲取jsonp的callback
      let callbackName = ctx.query.callback || 'callback'
      let returnData = {
          success: true,
          data: {
              text: 'this is a jsonp api',
              time: new Date().getTime(),
          }
      }

      // jsonp的script字符串
      let jsonpStr = `;${callbackName}(${JSON.stringify(returnData)})`

      // 用text/javascript,讓請求支持跨域獲取
      ctx.type = 'text/javascript'

      //輸出jsonp字符串
      ctx.body = jsonpStr
    }else{
      ctx.body = 'hello jsonp'
    }
})

app.listen(3000, () => {
    console.log('[demo] jsonp is starting at port 3000')
})

前端部分

$.ajax({
  url: 'http://localhost:3000/getData.jsonp',
  type: 'GET',
  dataType: 'JSONP',
  success: function(res) {
    console.log(res)
  }
})

koa-jsonp中間件

npm install --save koa-jsonp

https://www.npmjs.com/package...
https://github.com/chenshenha...

OAuth 2.0

OAuth 2.0深刻了解:以微信開放平臺統一登陸爲例
微信開放平臺開發——網頁微信掃碼登陸(OAuth2.0)

經常使用中間件

圖片描述
Koa Static Cache: https://www.npmjs.com/package...

更多中間件
koa-onerror
koa-safe-jsonp

經常使用腳手架

hello-koa2-mongodb

koa-generator
yi-ge/Koa2-API-Scaffold

- Express-style
- Support koa 1.x(supported)
- Support koa 2.x(koa middleware supported,need Node.js 7.6+ ,babel optional)

restful api

uri:

http://localhost:7001/game/62231163?channel=1323

router:

clipboard.png

controller.game.detail

const { gameId, channel } = ctx.query;
const _gameId = ctx.params.id || gameId; // 一級分類

 console.log('-------------------1', ctx.query)
  console.log('-------------------2', gameId)
  console.log('-------------------3', ctx.url)
  console.log('---------------------4', ctx.params)
  console.log('---------------------5', ctx.querystring)
  console.log('--------------------6', ctx.origin)

clipboard.png

https://www.jianshu.com/p/d0b...

ssrf

看我如何利用NodeJS SSRF漏洞得到AWS徹底控制權限

Web安全漏洞之SSRF
Web 安全漏洞 SSRF 簡介及解決方案
welefen/ssrf-agent

sms(短信服務、郵箱服務 )

https://github.com/yolopunk/e...

參考項目

egg-commerce
koajs/examples
johndatserakis/koa-vue-notes-api
使用koa2+wechaty打造我的微信小祕書

常見問題

koaexpress區別

關於區別詳解 戳我這篇文章express中間件 文末
圖片描述
圖片描述

koa原理
  • 中間件(Middleware)
  • 上下文(Context)

圖片描述

參考

Koa.js 設計模式-學習筆記
koa2進階學習筆記
koa2 源碼分析
npm koa-session-minimal
koa2中的session及redis
egg.js Cookie and Session
《HTTP權威指南》
七天學會NodeJS

Koa2 之文件上傳下載
node消息隊列
快速搭建可用於實戰的koa2+mongodb框架
https://chenshenhai.github.io...
KOA2框架原理解析和實現
koa 介紹 ppt

相關文章
相關標籤/搜索