Koa2框架利用CORS完成跨域ajax請求

james發表於2019-02-16

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

本文僅對能夠完成正常跨域ajax響應的,最基本的配置進行說明(深層次的配置我也不會)。

CORS將請求分為簡單請求和非簡單請求,可以簡單的認為,簡單請求就是沒有加上額外請求頭部的get和post請求,並且如果是post請求,請求格式不能是application/json(因為我對這一塊理解不深如果錯誤希望能有人指出錯誤並提出修改意見)。而其餘的,put、post請求,Content-Type為application/json的請求,以及帶有自定義的請求頭部的請求,就為非簡單請求。

簡單請求的配置十分簡單,如果只是完成響應就達到目的的話,僅需配置響應頭部的Access-Control-Allow-Origin即可。

如果我們在http://localhost:3000 域名下想要訪問 http://127.0.0.1:3001 域名。可以做如下配置:

app.use(async (ctx, next) => {
  ctx.set(`Access-Control-Allow-Origin`, `http://localhost:3000`);
  await next();
});

然後用ajax發起一個簡單請求,例如post請求,就可以輕鬆的得到伺服器正確響應了。
實驗程式碼如下:

$.ajax({
      type: `post`,
      url: `http://127.0.0.1:3001/async-post`
    }).done(data => {
      console.log(data);
})

伺服器端程式碼:

router.post(`/async-post`,async ctx => {
  ctx.body = {
    code: "1",
    msg: "succ"
  }
});

然後就能得到正確的響應資訊了。
這時候如果看一下請求和響應的頭部資訊,會發現請求頭部多了個origin(還有一個referer為發出請求的url地址),而響應頭部多了個Access-Control-Allow-Origin。

現在可以傳送簡單請求了,但是要想傳送非簡單請求還是需要其他的配置。

當第一次發出非簡單請求的時候,實際上會發出兩個請求,第一次發出的是preflight request,這個請求的請求方法是OPTIONS,這個請求是否通過決定了這一個種類的非簡單請求是否能成功得到響應。

為了能在伺服器匹配到這個OPTIONS型別的請求,因此需要自己做一箇中介軟體來進行匹配,並給出響應使得這個預檢能夠通過。

app.use(async (ctx, next) => {
  if (ctx.method === `OPTIONS`) {
    ctx.body = ``;
  }
  await next();
});

這樣OPTIONS請求就能夠通過了。

如果檢查一下preflight request的請求頭部,會發現多了兩個請求頭。

Access-Control-Request-Method: PUT
Origin: http://localhost:3000

要通過這兩個頭部資訊與伺服器進行協商,看是否符合伺服器應答條件。
很容易理解,既然請求頭多了兩個資訊,響應頭自然也應該有兩個資訊相對應,這兩個資訊如下:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: PUT,DELETE,POST,GET

第一條資訊和origin相同因此通過。第二條資訊對應Access-Controll-Request-Method,如果在請求的方式包含在伺服器允許的響應方式之中,因此這條也通過。兩個約束條件都滿足了,所以可以成功的發起請求。

至此為止,相當於僅僅完成了預檢,還沒傳送真正的請求呢。
真正的請求當然也成功獲得了響應,並且響應頭如下(省略不重要部分)

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: PUT,DELETE,POST,GET

請求頭如下:

Origin: http://localhost:3000

這就很顯而易見了,響應頭部資訊是我們在伺服器設定的,因此是這樣。
而客戶端因為剛才已經預檢過了,所以不需要再發Access-Control-Request-Method這個請求頭了。

這個例子的程式碼如下:

$.ajax({
      type: `put`,
      url: `http://127.0.0.1:3001/put`
    }).done(data => {
      console.log(data);
});

伺服器程式碼:

app.use(async (ctx, next) => {
   ctx.set(`Access-Control-Allow-Origin`, `http://localhost:3000`);
   ctx.set(`Access-Control-Allow-Methods`, `PUT,DELETE,POST,GET`);
   await next();
});

至此我們完成了能夠正確進行跨域ajax響應的基本配置,還有一些可以進一步配置的東西。

比如,到目前為止,每一次非簡單請求都會實際上發出兩次請求,一次預檢一次真正請求,這就比較損失效能了。為了能不發預檢請求,可以對如下響應頭進行配置。

Access-Control-Max-Age: 86400

這個響應頭的意義在於,設定一個相對時間,在該非簡單請求在伺服器端通過檢驗的那一刻起,當流逝的時間的毫秒數不足Access-Control-Max-Age時,就不需要再進行預檢,可以直接傳送一次請求。

當然,簡單請求時沒有預檢的,因此這條程式碼對簡單請求沒有意義。
目前程式碼如下:

app.use(async (ctx, next) => {
  ctx.set(`Access-Control-Allow-Origin`, `http://localhost:3000`);
  ctx.set(`Access-Control-Allow-Methods`, `PUT,DELETE,POST,GET`);
  ctx.set(`Access-Control-Max-Age`, 3600 * 24);
  await next();
});

到現在為止,可以對跨域ajax請求進行響應了,但是該域下的cookie不會被攜帶在請求頭中。如果想要帶著cookie到伺服器,並且允許伺服器對cookie進一步設定,還需要進行進一步的配置。

為了便於後續的檢測,我們預先在http://127.0.0.1:3001這個域名下設定兩個cookie。注意不要錯誤把cookie設定成中文(剛才我就設定成了中文,結果報錯,半天沒找到出錯原因)

然後我們要做兩步,第一步設定響應頭Access-Control-Allow-Credentials為true,然後在客戶端設定xhr物件的withCredentials屬性為true。

客戶端程式碼如下:

$.ajax({
      type: `put`,
      url: `http://127.0.0.1:3001/put`,
      data: {
        name: `黃天浩`,
        age: 20
      },
      xhrFields: {
        withCredentials: true
      }
    }).done(data => {
      console.log(data);
    });

服務端如下:

app.use(async (ctx, next) => {
   ctx.set(`Access-Control-Allow-Origin`, `http://localhost:3000`);
   ctx.set(`Access-Control-Allow-Methods`, `PUT,DELETE,POST,GET`);
   ctx.set(`Access-Control-Allow-Credentials`, true);
   await next();
});

這時就可以帶著cookie到伺服器了,並且伺服器也可以對cookie進行改動。但是cookie仍是http://127.0.0.1:3001域名下的cookie,無論怎麼操作都在該域名下,無法訪問其他域名下的cookie。

現在為止CORS的基本功能已經都提到過了。
一開始我不知道怎麼給Access-Control-Allow-Origin,後來經人提醒,發現可以寫一個白名單陣列,然後每次接到請求時判斷origin是否在白名單陣列中,然後動態的設定Access-Control-Allow-Origin,程式碼如下:

app.use(async (ctx, next) => {
  if (ctx.request.header.origin !== ctx.origin && whiteList.includes(ctx.request.header.origin)) {
    ctx.set(`Access-Control-Allow-Origin`, ctx.request.header.origin);
    ctx.set(`Access-Control-Allow-Methods`, `PUT,DELETE,POST,GET`);
    ctx.set(`Access-Control-Allow-Credentials`, true);
    ctx.set(`Access-Control-Max-Age`, 3600 * 24);
  }
  await next();
});

這樣就可以不用*萬用字元也可匹配多個origin了。
注意:ctx.origin與ctx.request.header.origin不同,ctx.origin是本伺服器的域名,ctx.request.header.origin是傳送請求的請求頭部的origin,二者不要混淆。

最後,我們再稍微調整一下自定義的中介軟體的結構,防止每次請求都返回Access-Control-Allow-Methods以及Access-Control-Max-Age,這兩個響應頭其實是沒有必要每次都返回的,只是第一次有預檢的時候返回就可以了。

調整後順序如下:

app.use(async (ctx, next) => {
  if (ctx.request.header.origin !== ctx.origin && whiteList.includes(ctx.request.header.origin)) {
    ctx.set(`Access-Control-Allow-Origin`, ctx.request.header.origin);
    ctx.set(`Access-Control-Allow-Credentials`, true);
  }
  await next();
});

app.use(async (ctx, next) => {
  if (ctx.method === `OPTIONS`) {
    ctx.set(`Access-Control-Allow-Methods`, `PUT,DELETE,POST,GET`);
    ctx.set(`Access-Control-Max-Age`, 3600 * 24);
    ctx.body = ``;
  }
  await next();
});

這樣就減少了多餘的響應頭。

相關文章