天天看點

Koa2開發入門

Koa2入門

建立Koa2

首先,我們建立一個名為koa2的工程目錄,然後使用VS Code打開。然後,我們建立app.js,輸入以下代碼:

// 導入koa,和koa 1.x不同,在koa2中,我們導入的是一個class,是以用大寫的Koa表示:
const Koa = require('koa');

// 建立一個Koa對象表示web app本身:
const app = new Koa();

// 對于任何請求,app将調用該異步函數處理請求:
app.use(async (ctx, next) => {
    await next();
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello, koa2!</h1>';
});

// 在端口3000監聽:
app.listen(3000);
console.log('app started at port 3000...');      

對于每一個http請求,koa将調用我們傳入的異步函數進行處理。例如:

async (ctx, next) => {
    await next();
    // 設定response的Content-Type:
    ctx.response.type = 'text/html';
    // 設定response的内容:
    ctx.response.body = '<h1>Hello, koa2!</h1>';
}      

其中,參數ctx是由koa傳入的封裝了request和response的變量,我們可以通過它通路request和response,next是koa傳入的将要處理的下一個異步函數。

那麼,怎麼啟動koa呢?首先,你需要安裝koa,可以直接使用npm進行安裝,可以參考​​Koa官網資料​​。

Koa2開發入門

然後在剛才的koa的項目目錄中建立一個package.json,這個檔案用于管理koa項目運作需要的依賴包,依賴時注意koa版本号。例如:

{
    "name": "hello-koa2",
    "version": "1.0.0",
    "description": "Hello Koa 2 example with async",
    "main": "app.js",
    "scripts": {
        "start": "node app.js"
    },
    "keywords": [
        "koa",
        "async"
    ],
    "author": "xzh",
    "license": "Apache-2.0",
    "repository": {
        "type": "git",
        "url": "https://github.com/michaelliao/learn-javascript.git"
    },
    "dependencies": {
        "koa": "2.7.0"
    }
}      

其中,dependencies是我們的工程依賴的包以及版本号,需要注意版本号的對應。其他字段均用來描述項目資訊,可任意填寫。然後,在koa目錄下執行npm install安裝項目所需依賴包。安裝完成後,項目的目錄結構如下:

hello-koa/
|
+- .vscode/
|  |
|  +- launch.json        //VSCode 配置檔案
|
+- app.js              //使用koa的js
|
+- package.json          //項目配置檔案
|
+- node_modules/     //npm安裝的所有依賴包      

然後,使用npm start啟動項目,即可看到效果。

Koa2開發入門

當然,還可以直接用指令node app.js在指令行啟動程式,該命名最終執行的是package.json檔案中的start對應指令:

"scripts": {
    "start": "node app.js"
}      

接下來,讓我們再仔細看看koa的執行邏輯,核心代碼如下:

app.use(async (ctx, next) => {
    await next();
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello, koa2!</h1>';
});      

每收到一個http請求,koa就會調用通過app.use()注冊的async函數,并傳入ctx和next參數。那為什麼需要調用await next()呢?

原因是koa把很多async函數組成一個處理鍊,每個async函數都可以做一些自己的事情,然後用await next()來調用下一個async函數,此處我們把每個async函數稱為中間件。

例如,可以用以下3個middleware組成處理鍊,依次列印日志,記錄處理時間,輸出HTML。

// 導入koa,和koa 1.x不同,在koa2中,我們導入的是一個class,是以用大寫的Koa表示:
const Koa = require('koa');

// 建立一個Koa對象表示web app本身:
const app = new Koa();

app.use(async (ctx, next) => {
    console.log(`${ctx.request.method} ${ctx.request.url}`); // 列印URL
    await next(); // 調用下一個middleware
});

app.use(async (ctx, next) => {
    const start = new Date().getTime(); // 目前時間
    await next(); // 調用下一個middleware
    const ms = new Date().getTime() - start; // 耗費時間
    console.log(`Time: ${ms}ms`); // 列印耗費時間
});

app.use(async (ctx, next) => {
    await next();
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello, koa2!</h1>';
});

// 在端口3000監聽:
app.listen(3000);
console.log('app started at port 3000...');      

koa-router

在上面的例子中,我們處理http請求一律傳回相同的HTML,這樣顯得并不是很友好,正常的情況是,我們應該對不同的URL調用不同的處理函數,這樣才能傳回不同的結果。

為了處理URL跳轉的問題,我們需要引入koa-router中間件,讓它負責處理URL映射。首先在package.json中添加koa-router依賴:

"koa-router": "7.4.0"      

然後用npm install安裝依賴。接下來,我們修改app.js,使用koa-router來處理URL映射。

const Koa = require('koa');

// 注意require('koa-router')傳回的是函數:
const router = require('koa-router')();

const app = new Koa();

app.use(async (ctx, next) => {
    console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
    await next();
});

router.get('/hello/:name', async (ctx, next) => {
    var name = ctx.params.name;
    ctx.response.body = `<h1>Hello, ${name}!</h1>`;
});

router.get('/', async (ctx, next) => {
    ctx.response.body = '<h1>Index</h1>';
});

app.use(router.routes());
app.listen(3000);
console.log('app started at port 3000...');      

需要說明的是,require(‘koa-router’) 傳回的是函數,其作用類似于:

const fn_router = require('koa-router');
const router = fn_router();      

然後,我們使用router.get(’/path’, async fn)來注冊一個GET請求。可以在請求路徑中使用帶變量的/hello/:name,變量可以通過ctx.params.name來完成通路。

當我們在輸入首頁:​​http://localhost:3000/​​

Koa2開發入門

當在浏覽器中輸入:​​http://localhost:3000/hello/koa​​

Koa2開發入門

post請求

用router.get(’/path’, async fn)處理的是get請求。如果要處理post請求,可以用router.post(’/path’, async fn)。

用post請求處理URL時,我們會遇到一個問題:post請求通常會發送一個表單、JSON作為request的body發送,但無論是Node.js提供的原始request對象,還是koa提供的request對象,都不提供解析request的body的功能!此時需要借助koa-bodyparser插件。

是以,使用koa-router進行post請求時,需要在package.json中添加koa-bodyparser依賴:

"koa-bodyparser": "4.2.1"      

現在,我們就可以使用koa-bodyparser進行post請求了,例如:

const Koa = require('koa');

// 注意require('koa-router')傳回的是函數:
const router = require('koa-router')();
const bodyParser = require('koa-bodyparser');

const app = new Koa();
app.use(async (ctx, next) => {
    console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
    await next();
});

router.get('/hello/:name', async (ctx, next) => {
    var name = ctx.params.name;
    ctx.response.body = `<h1>Hello, ${name}!</h1>`;
});

router.get('/', async (ctx, next) => {
    ctx.response.body = `<h1>Index</h1>
        <form action="/signin" method="post">
            <p>Name: <input name="name" value="koa"></p>
            <p>Password: <input name="password" type="password"></p>
            <p><input type="submit" value="Submit"></p>
        </form>`;
});

//POST請求
router.post('/signin', async (ctx, next) => {
    var
        name = ctx.request.body.name || '',
        password = ctx.request.body.password || '';
    console.log(`signin with name: ${name}, password: ${password}`);
    if (name === 'koa' && password === '12345') {
        ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
    } else {
        ctx.response.body = `<h1>Login failed!</h1>
        <p><a href="/">Try again</a></p>`;
    }
});

router.get('/', async (ctx, next) => {
    ctx.response.body = '<h1>Index</h1>';
});

app.use(bodyParser());
app.use(router.routes());

app.listen(3000);
console.log('app started at port 3000...');      

然後,當我們使用npm start啟動服務,輸入koa和12345時,就能通過測試。

優化

現在,雖然我們可以根據輸入處理不同的URL,但是代碼的可閱讀和擴充性極差。正确的寫法是頁面和邏輯分離,于是我們把url-koa複制一份,重命名為url2-koa,并重構項目。重構的項目目錄結構如下:

url2-koa/
|
+- .vscode/
|  |
|  +- launch.json 
|
+- controllers/
|  |
|  +- login.js        //處理login相關URL
|  |
|  +- users.js      //處理使用者管理相關URL
|
+- app.js           //使用koa的js
|
+- package.json 
|
+- node_modules/          //npm安裝的所有依賴包      

我們在controllers目錄下添加一個index.js檔案,并添加如下内容:

var fn_index = async (ctx, next) => {
    ctx.response.body = `<h1>Index</h1>
        <form action="/signin" method="post">
            <p>Name: <input name="name" value="koa"></p>
            <p>Password: <input name="password" type="password"></p>
            <p><input type="submit" value="Submit"></p>
        </form>`;
};

var fn_signin = async (ctx, next) => {
    var
        name = ctx.request.body.name || '',
        password = ctx.request.body.password || '';
    console.log(`signin with name: ${name}, password: ${password}`);
    if (name === 'koa' && password === '12345') {
        ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
    } else {
        ctx.response.body = `<h1>Login failed!</h1>
        <p><a href="/">Try again</a></p>`;
    }
};

module.exports = {
    'GET /': fn_index,
    'POST /signin': fn_signin
};      

上面示例中,index.js通過module.exports把兩個URL處理函數暴露出來。

然後,我們修改app.js,讓它自動掃描controllers目錄,找到所有的js檔案并注冊每個URL。

var files = fs.readdirSync(__dirname + '/controllers');

// 過濾出.js檔案:
var js_files = files.filter((f)=>{
    return f.endsWith('.js');
});

// 處理每個js檔案:
for (var f of js_files) {
    console.log(`process controller: ${f}...`);
    // 導入js檔案:
    let mapping = require(__dirname + '/controllers/' + f);
    for (var url in mapping) {
        if (url.startsWith('GET ')) {
            // 如果url類似"GET xxx":
            var path = url.substring(4);
            router.get(path, mapping[url]);
            console.log(`register URL mapping: GET ${path}`);
        } else if (url.startsWith('POST ')) {
            // 如果url類似"POST xxx":
            var path = url.substring(5);
            router.post(path, mapping[url]);
            console.log(`register URL mapping: POST ${path}`);
        } else {
            // 無效的URL:
            console.log(`invalid URL: ${url}`);
        }
    }
}      

如果上面的例子看起來有點費勁,可以對上面的功能進行拆分。

function addMapping(router, mapping) {
    for (var url in mapping) {
        if (url.startsWith('GET ')) {
            var path = url.substring(4);
            router.get(path, mapping[url]);
            console.log(`register URL mapping: GET ${path}`);
        } else if (url.startsWith('POST ')) {
            var path = url.substring(5);
            router.post(path, mapping[url]);
            console.log(`register URL mapping: POST ${path}`);
        } else {
            console.log(`invalid URL: ${url}`);
        }
    }
}

function addControllers(router) {
    var files = fs.readdirSync(__dirname + '/controllers');
    var js_files = files.filter((f) => {
        return f.endsWith('.js');
    });

    for (var f of js_files) {
        console.log(`process controller: ${f}...`);
        let mapping = require(__dirname + '/controllers/' + f);
        addMapping(router, mapping);
    }
}

addControllers(router);      

為了友善,我們把掃描controllers目錄和建立router的代碼從app.js中提取出來作為一個中間件,并将它命名為:controller.js。

const fs = require('fs');

function addMapping(router, mapping) {
    for (var url in mapping) {
        if (url.startsWith('GET ')) {
            var path = url.substring(4);
            router.get(path, mapping[url]);
            console.log(`register URL mapping: GET ${path}`);
        } else if (url.startsWith('POST ')) {
            var path = url.substring(5);
            router.post(path, mapping[url]);
            console.log(`register URL mapping: POST ${path}`);
        } else {
            console.log(`invalid URL: ${url}`);
        }
    }
}

function addControllers(router) {
    var files = fs.readdirSync(__dirname + '/controllers');
    var js_files = files.filter((f) => {
        return f.endsWith('.js');
    });

    for (var f of js_files) {
        console.log(`process controller: ${f}...`);
        let mapping = require(__dirname + '/controllers/' + f);
        addMapping(router, mapping);
    }
}

module.exports = function (dir) {
    let
        controllers_dir = dir || 'controllers', // 如果不傳參數,掃描目錄預設為'controllers'
        router = require('koa-router')();
    addControllers(router, controllers_dir);
    return router.routes();
};      

然後,我們在app.js檔案中可以直接使用controller.js。例如:

const Koa = require('koa');
const bodyParser = require('koa-bodyparser');

const app = new Koa();

// 導入controller 中間件
const controller = require('./controller');

app.use(bodyParser());
app.use(controller());

app.listen(3000);
console.log('app started at port 3000...');      

Koa2跨域

同源政策

所謂同源政策,即浏覽器的一個安全功能,不同源的用戶端腳本在沒有明确授權的情況下,不能讀寫對方資源,同源政策由 Netscape 公司引入浏覽器。目前,所有浏覽器都實行這個政策。最初,它的含義是指,A網頁設定的 Cookie,B網頁不能打開,除非這兩個網頁"同源"。。所謂"同源"指的是"三個相同",即協定相同、域名相同和端口相同。

例如,有下面一個網址:​​http://www.netease.com/a.html,​​​ 協定是http://,域名是 ​​www.netease.com​​,端口是80(預設端口可以省略),那麼同源情況如下:

Koa2開發入門

跨域

受浏覽器同源政策的影響,不是同源的腳本不能操作其他源下面的對象,想要解決同源政策的就需要進行跨域操作。針對浏覽器的Ajax請求跨域的主要有兩種解決方案JSONP和CORS。

Ajax

Ajax 是一種用于建立快速動态網頁的技術,無需重新加載整個網頁的情況下即将實作網頁的局部更新。下面通過Ajax進行跨域請求的情景,首先通過koa啟了兩個本地服務:一個port為3200,一個為3201。

app1.js

onst koa = require('koa');
const app = new koa();

const Router = require('koa-router');
const router = new Router();

const serve = require('koa-static');

const path = require('path');

const staticPath = path.resolve(__dirname, 'static');

// 設定靜态服務
const staticServe = serve(staticPath, {
  setHeaders: (res, path, stats) => {
    if (path.indexOf('jpg') > -1) {
      res.setHeader('Cache-Control', ['private', 'max-age=60']);
    }
  }
});
app.use(staticServe);

router.get('/ajax', async (ctx, next) => {
  console.log('get request', ctx.request.header.referer);
  ctx.body = 'received';
});

app.use(router.routes());

app.listen(3200);
console.log('koa server is listening port 3200');      

app2.js

const koa = require('koa');
const app = new koa();
const Router = require('koa-router');

const router = new Router();

router.get('/ajax', async (ctx, next) => {
  console.log('get request', ctx.request.header.referer);
  ctx.body = 'received';
});

app.use(router.routes());

app.listen(3200);
console.log('app2 server is listening port 3200');      

由于此示例需要使用koa-static插件,是以啟動服務前需要安裝koa-static插件。然後新增一個origin.html檔案,添加如下代碼:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>cross-origin test</title>
</head>
<body style="width: 600px; margin: 200px auto; text-align: center">
  <button onclick="getAjax()">AJAX</button>
  <button onclick="getJsonP()">JSONP</button>
</body>
<script type="text/javascript">

  var baseUrl = 'http://localhost:3201';

  function getAjax() {
    var xhr = new XMLHttpRequest();            
    xhr.open('GET',  baseUrl + '/ajax', true);
    xhr.onreadystatechange = function() {
      // readyState == 4說明請求已完成
      if (xhr.readyState == 4 && xhr.status == 200 || xhr.status == 304) { 
        // 從伺服器獲得資料  
        alert(xhr.responseText);
      } else {
        console.log(xhr.status);
      }
    };
    xhr.send();
  }
</script>
</html>      

當ajax發送跨域請求時,控制台報錯:

Failed to load http://localhost:3201/ajax: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:3200' is therefore not allowed access.      

雖然控制台有報錯,但AJAX請求收到了200,這是因為浏覽器的CORS機制,後面會具體解釋。

Koa2開發入門

JSONP

雖然浏覽器同源政策限制了XMLHttpRequest請求不同域資料的限制。但是,在頁面上引入不同域的js腳本是可以的,而且script元素請求的腳本會被浏覽器直接運作。

在origin.html的腳本檔案中添加如下腳本:

function getJsonP() {
    var script = document.createElement('script');
    script.src = baseUrl + '/jsonp?type=json&callback=onBack';
    document.head.appendChild(script);
}

function onBack(res) {
  alert('JSONP CALLBACK:  ', JSON.stringify(res)); 
}      

當點選JSONP按鈕時,getJsonP方法會在目前頁面添加一個script,src屬性指向跨域的GET請求:​​http://localhost:3201/jsonp?type=json&callback=onBack,​​ 通過query格式帶上請求的參數。callback是關鍵,用于定義跨域請求回調的函數名稱,這個值必須背景和腳本保持一緻。

然後在app2.js中添加jsonp請求的路由代碼:

router.get('/jsonp', async (ctx, next) => {
  const req = ctx.request.query;
  console.log(req);
  const data = {
    data: req.type
  }
  ctx.body = req.callback + '('+ JSON.stringify(data) +')';
})

app.use(router.routes());      

然後重新重新整理即可看的效果。需要說明的是,jquery、zepto這些js第三方庫,其提供的ajax 方法都有對jsonp請求進行封裝,如jquery發jsonp的ajax請求如下:

function getJsonPByJquery() {
    $.ajax({
      url: baseUrl + '/jsonp',
      type: 'get',
      dataType: 'jsonp',  
      jsonpCallback: "onBack",   
      data: {
        type: 'json'
      }
    });
  }      

CORS

跨域資源共享(CORS) 是一種機制,它使用額外的 HTTP 頭來告訴浏覽器 讓運作在一個 origin (domain) 上的Web應用被準許通路來自不同源伺服器上的指定的資源。當一個資源從與該資源本身所在的伺服器不同的域、協定或端口請求一個資源時,資源會發起一個跨域 HTTP 請求。

實作跨域ajax請求的方式有很多,其中一個是利用CORS,而這個方法關鍵是在伺服器端進行配置。

CORS将請求分為簡單請求和非簡單請求。其中,簡單請求就是沒有加上額外請求頭部的get和post請求,并且如果是post請求,請求格式不能是application/json。而其餘的,put、post請求,Content-Type為application/json的請求,以及帶有自定義的請求頭部的請求,就為非簡單請求。

非簡單請求的CORS請求,會在正式通信之前,增加一次HTTP查詢請求,稱為"預檢"請求(preflight)。浏覽器先詢問伺服器,目前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些HTTP動詞和頭資訊字段。隻有得到肯定答複,浏覽器才會發出正式的XMLHttpRequest請求,否則就報錯。

首先,在origin.html中添加一個post請求,并添加如下代碼:

function corsWithJson() {
    $.ajax({
      url: baseUrl + '/cors',
      type: 'post',
      contentType: 'application/json',
      data: {
        type: 'json',
      },
      success: function(data) {
        console.log(data);
      }
    })
  }
      

通過設定Content-Type為appliaction/json使其成為非簡單請求,"預檢"請求的方法為OPTIONS,伺服器判斷Origin為跨域,是以傳回404。除了Origin字段,"預檢"請求的頭資訊包括兩個特殊字段:

Access-Control-Request-Method

該字段是必須的,用來列出浏覽器的CORS請求會用到哪些HTTP方法,上例是PUT。

Access-Control-Request-Headers

該字段是一個逗号分隔的字元串,指定浏覽器CORS請求會額外發送的頭資訊字段,例如示例中的content-type。

同時,CORS允許服務端在響應頭中添加一些頭資訊來響應跨域請求。然後在app2.js引入koa2-cors,并添加如下代碼:

app.use(cors({
  origin: function (ctx) {
      if (ctx.url === '/cors') {
          return "*"; 
      }
      return 'http://localhost:3201';
  },
  exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
  maxAge: 5,
  credentials: true,
  allowMethods: ['GET', 'POST', 'DELETE'], 
  allowHeaders: ['Content-Type', 'Authorization', 'Accept'],
}));      

重新開機服務後,浏覽器重新發送POST請求。可以看到浏覽器發送了兩次請求。

Koa2開發入門

OPTIONS的響應頭表示服務端設定了Access-Control-Allow-Origin:*,于是發送POST請求,得到伺服器傳回值。

Koa2開發入門

除此之外,在OPTIONS的請求響應封包中,頭資訊裡有一些CORS提供的其他字段:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type,Authorization,Accept
Access-Control-Allow-Methods: GET,POST,DELETE
Access-Control-Max-Age: 5