- date
- entry
- 030
- topic
- infrastructure
- rev
- —
CloudFront Function 自己回 301,query string 會在兩個地方掉
CloudFront 不會替函式產生的轉址補上 query string。忘了讀 multiValue,或補斜線時只接路徑,參數就安靜地不見。
第一篇講目錄 key 陷阱時,提過另一條路:用 CloudFront Functions 在 viewer request 階段把 /blog/ 改寫成 /blog/index.html。我後來在幾個站改走這條路,同一支函式順便做 www 與主網域之間的 301,因為同一個 cache behavior 的 viewer request 只能掛一支函式。
轉址是函式自己回的 response,Location 由程式碼組出來,query string 也得自己帶上。我用 curl 打其中一個站(下面稱「只讀 value 的那支」):?tag=a&tag=b 轉完只剩 ?tag=a,/blog?utm_source=line 轉完變成 /blog/,utm 整段不見。這篇要談的是這兩個掉參數的地方。依序回答四件事:event 裡的 query string 長什麼樣、兩處各怎麼掉、怎麼在本機測、修完的函式。
event 裡的 query string
CloudFront Functions 的文件(Event structure 那頁)寫明,request.querystring 是一個物件,每個參數名一個欄位,值放在 value。同名參數出現不只一次時,會收成同一個欄位,多一個 multiValue 陣列放全部的值,第一個值同時出現在 value 和 multiValue 裡。節錄文件範例的部分欄位:
"querystring": {
"ID": {"value": "42"},
"NoValue": {"value": ""},
"querymv": {
"value": "val1",
"multiValue": [{"value": "val1"}, {"value": "val2,val3"}]
}
}
Event structure 那頁沒有直接說值有沒有做過百分比解碼。我用 curl 打線上的函式,?q=a%20b&r=%26x%3D1&s=%E5%8C%85 轉完之後三個值都還是原本的百分比編碼。所以重組 Location 時直接用這些字串,不要再編一次,否則 %20 會變成 %2520。
第一處:只讀 value,同名參數只剩一個
其中一支函式組 query string 的寫法:
var qs = Object.keys(request.querystring).map(function (k) {
var v = request.querystring[k];
return v.value ? k + '=' + v.value : k;
}).join('&');
它只讀 value。?tag=a&tag=b 進來,value 是 a,b 只在 multiValue 裡,於是轉完只剩 tag=a。順帶一個小差異:value 是空字串時它輸出 empty 而不是 empty=(另一支對單一空值也一樣)。例如瀏覽器的 URLSearchParams 把兩種寫法都解析成空字串,但字面上不是同一個網址,後端會不會區分要另外確認。
另一個站的函式有處理 multiValue,同一個網址轉完是 ?tag=a&tag=b。兩支在換主機時都要重組 query,一支只讀 value,一支也讀 multiValue;目錄的處理策略也不同,下一節會談。
第二處:補斜線的轉址沒帶 query
同一支函式對最後一段不含 .、也沒有結尾斜線的路徑(例如 /blog)回 301 到 /blog/。這是用「有沒有點號」猜是不是目錄:
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: uri + '/' } } };
Location 只有路徑,query 沒接上。www 的那一段有帶 query,所以兩個轉址串起來時,前一跳保留、後一跳丟掉:
$ curl -sSIL 'https://www.<站>/blog?utm_source=line' | grep -iE '^(HTTP|location)'
HTTP/2 301
location: https://<站>/blog?utm_source=line
HTTP/2 301
location: /blog/
HTTP/2 200
兩跳之後 utm 不見。分析工具如果靠這組參數辨識來源,活動歸因就可能受影響,沒有其他來源資訊時甚至可能算成直接流量;實際報表我沒有去對。實際影響取決於對外分享的連結有多少是不帶斜線的版本,這我沒有統計。
另一支函式的選擇不同:/blog 不轉址,直接把 request.uri 改寫成 /blog/index.html 交給後面處理,線上實測回 200。網址列不變,query 不會掉;代價是同一份內容至少有 /blog 和 /blog/ 兩個網址,頁面上的 canonical 只能表達偏好,最後選哪個由搜尋引擎決定。
在本機跑 handler
兩個問題都是邏輯錯誤,不需要上 AWS 就能測。函式檔裡只有一個 handler,沒有 export,用 new Function 包起來就能在 Node 裡呼叫,event 照文件的格式自己組。下面存成 .mjs 用 node 執行,函式檔放在同一個目錄,host 要換成那支函式認得的網域:
import { readFileSync } from "node:fs";
const load = (f) => new Function(readFileSync(f, "utf8") + "\nreturn handler;")();
const ev = (host, uri, qs = {}) => ({ version: "1.0",
context: { eventType: "viewer-request" }, viewer: { ip: "203.0.113.1" },
request: { method: "GET", uri, querystring: qs,
headers: { host: { value: host } }, cookies: {} } });
const h = load("www-redirect.js");
console.log(h(ev("www.example.com", "/blog/",
{ tag: { value: "a", multiValue: [{ value: "a" }, { value: "b" }] } })));
四個案例在兩支函式上的結果(「轉 www」那支的主網域是 www,所以 host 對調):
| 案例 | 只讀 value 的那支 | 讀 multiValue 的那支 |
|---|---|---|
換主機,?tag=a&tag=b | ?tag=a | ?tag=a&tag=b |
主網域 /blog?utm_source=line | 301 → /blog/ | 改寫成 /blog/index.html |
換主機 /blog?utm_source=line | 301,保留 utm(下一跳才掉) | 301,保留 utm |
空值 ?empty= | ?empty | ?empty |
本機的結果和線上 curl 一致。這個做法有兩個限制要講清楚。第一,Node 不是 CloudFront Functions 的執行環境(這兩支分別設定 cloudfront-js-1.0 和 2.0),語法支援不同,本機過了不代表上傳後能跑;發布前仍要用 aws cloudfront test-function 在真的環境跑一次(這篇只做本機測試與線上 HTTP 觀察,沒有呼叫 AWS 的管理 API)。第二,參數順序:本機這組參數名照我建立的順序列出,線上實測 ?q=…&r=…&s=… 轉完變成 ?s=…&r=…&q=…,順序反過來。文件沒有保證順序,依賴參數順序的後端要另外處理。
修完的函式
兩個改動:把組 query 的邏輯抽成一個函式,讀 multiValue,空值一律輸出成 key=;換主機和補斜線合併成一跳,直接轉到最終網址。
function qs(request) {
var parts = [];
Object.keys(request.querystring).forEach(function (k) {
var e = request.querystring[k];
(e.multiValue ? e.multiValue : [e]).forEach(function (v) {
parts.push(v.value === '' ? k + '=' : k + '=' + v.value);
});
});
return parts.length ? '?' + parts.join('&') : '';
}
function handler(event) {
var request = event.request;
var host = request.headers.host && request.headers.host.value;
var uri = request.uri;
var needsSlash = !uri.endsWith('/') &&
uri.lastIndexOf('.') < uri.lastIndexOf('/') + 1;
if (host === 'www.example.com' || needsSlash) {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value:
'https://example.com' + uri + (needsSlash ? '/' : '') + qs(request) } } };
}
if (uri.endsWith('/')) request.uri = uri + 'index.html';
return request;
}
用同一個本機腳本跑:?tag=a&tag=b 兩個都在,/blog?utm_source=line 不論從哪個主機進來,都一跳到 https://example.com/blog/?utm_source=line。注意它的行為比原本寬:任何 host 進來、只要需要補斜線,都轉到主網域,包括直接打 CloudFront 預設網域的請求。我認為這是對的,但要先確認那個站沒有別的網域掛在同一個 distribution 上,也沒有不能被當成目錄的路徑:用點號猜目錄,/.well-known/acme-challenge/ 底下的無副檔名檔案、API 路徑都會被補上斜線,要另外排除。
這支掛在 viewer request 上,改錯的影響是走這個 cache behavior 的所有請求;distribution 只有預設 behavior 時,就是全站。本機腳本適合當發布前的第一關,因為它不需要憑證、幾毫秒跑完;第二關是 test-function,兩關都過才 publish-function,發布後再用 curl 走一次轉址鏈。這個修正我還沒有發布到線上。
如果只記得一件事
CloudFront Function 回 301 時,整個網址是你自己組的,CloudFront 不會替你補 query string。開頭那兩個掉參數的網址,在本機十幾行的腳本裡就能重現,前提是 event 要照文件的格式組,包含同名參數的 multiValue。
修訂紀錄
- 首次發布