作者:張志豪
一、前言
早期為了解決“會話保持”的需求,社群中出現了「cookie方案」並最終成為W3C標準:當某個網站登入成功後,客戶端(瀏覽器)收到一個cookie標識(文字)並儲存下來,在後續請求中會自動帶上這個欄位,由此Web後臺可以判斷是否同一個使用者,從而使“會話”得以延續。
微信小程式沒有像瀏覽器一樣內建實現了cookie方案,需要開發者自行模擬,而原先京東購物小程式及京喜小程式(現微信一級購物入口)是從微信及手Q購物H5中遷移迭代出來的,也就是說我們不僅要在小程式中模擬一套cookie方案,並且要保持和原業務對cookie處理邏輯的一致,為此我們將實現方向確定為“基於小程式開放能力,和瀏覽器保持一致”。
微信小程式開放了 資料快取 Storage 和 網路 Network 這兩種能力,通過這兩套API,我們可以自行DIY一個cookie方案。
PS:本文所有程式碼及使用示例都可以 在這裡 找到,閱讀本文時配合實踐,效果更佳。
二、瀏覽器中的cookie
為了保持後端對cookie的處理邏輯和原來的H5一致,小程式的實現需要往瀏覽器看齊。
所以模擬小程式的cookie前,先看看瀏覽器的cookie機制,主要有以下幾個部分:
- 本地儲存:瀏覽器會在本地分配一塊空間,儲存cookie
- 請求攜帶:每次發起請求,都會從本地取出cookie並追加在請求頭上
- 響應設定:當響應頭有Set-Cookie欄位時,需要解析並更新
- 過期時間:每個cookie欄位有單獨的過期時間,並且到期會自動清除
- 讀寫操作:暴露API給前端JS呼叫,可進行增刪改查操作
- 作用域:路徑path、域名domin
- 編碼:cookie值,在網路傳輸需要encode,建議儲存也一樣
- 其它:HttpOnly、Secure、SameSite
在瀏覽器的DevTools
中,可以看到當前站點下的Cookie明細:
三、小程式中的cookie實現
方案設計
在小程式中模擬Cookie,主要涉及五個部分:
其中我們會重點關注 「Cookie基礎庫」 的實現,另外也會給出「Request基礎庫」的封裝示例。本地儲存
小程式提供了 「資料快取 Storage API」(可以理解為Web規範中的LocalStorage
),支援儲存“原生型別、Date、及能夠通過JSON.stringify序列化的物件”。
我們可以利用這些API,在Storage中新開一個cookies
欄位進行儲存:
// 存:
wx.setStorageSync('cookies', cookies)
// 取:
wx.getStorageSync('cookies')
複製程式碼
其中cookies
的「儲存結構」如下:
// cookies =
{
cookie1: { // “最小cookie單元” ==> cookieItem
name: 'cookie1', // cookie名
value: 'xxx', // cookie值
expires: 'Fri, 17 Jan 2020 08:49:41 GMT' // 過期時間,使用GMT(格林威治標準時間)格式
}
},
複製程式碼
上面的cookie1
便是一個“最小cookie單元cookieItem
”,包含了3個欄位(name、value、expires),是本文中定義的「標準cookie格式」,也是cookie操作的基本單元。
開啟【微信開發工具】的Storage
選項卡,可以檢視本地儲存的情況:
讀寫操作
這部分主要作為“公共基礎庫“的角色,為外部業務提供增刪改查cookie的API。
1. 獲取cookie————getCookie()
步驟:從Storage中取出完整cookies ==> 取出指定name的cookie項 ==> 校驗有效期 ==> 返回值value
實現如下:
function getCookie(name = '') {
let cookies = wx.getStorageSync('cookies') // try/catch 略過
let { value, expires } = cookies[name] || {}
return (name && expires && !isExpired(expires)) ? decodeURIComponent(cookieItem.value) : ''
}
複製程式碼
2. 設定cookie————setCookie()
步驟:從Storage中取出完整cookies ==> 解析入參 ==> 覆蓋更新 ==> 同步到本地Storage
首先看下本API設計需求:
- 設定單個/多個cookie
- 直接傳值/傳cookieItem(Object)
- 時間格式maxAge/expires
呼叫示例如下:
setCookie({
cookie1: 12345,
cookie2: '12345'
})
setCookie({
cookie1: {
value: 12345,
maxAge: 3600 * 24 // 自定義有效期(這裡示例是24小時)
},
cookie2: {
value: '12345',
expires: 'Wed, 21 Oct 2015 07:28:00 GMT' // 標準GMT格式
}
})
複製程式碼
這裡可對入參遍歷,而cookie子項無論直接傳值value還是傳了詳細object,都儘量的獲取name/value/expires/maxAge
,傳給格式化函式轉為標準的cookieItem
:
function setCookie(cookiesParam) {
let oldCookies = wx.getStorageSync('cookies') // try/catch 略過
let newCookies = {} // 由 cookiesParam 轉化為標準格式後的cookies
for (let name in cookiesParam) {
if (isObject(cookiesParam[name])) { // 傳入是Object格式
let { value, expires, maxAge } = cookiesParam[name]
// 轉換為標準cookie格式(cookieItem)
newCookies[name] = getStandardCookieItem({ name, value, expires, maxAge })
} else {
newCookies[name] = getStandardCookieItem({ name, value: cookiesParam[name] })
}
}
// 同步到本地Storage
saveCookiesToStorage(Object.assign({}, oldCookies, newCookies))
}
複製程式碼
3. 刪除cookie————removeCookie()
步驟:從Storage中取出完整cookies ==> 刪除指定的cookie項 ==> 同步到本地Storage
function removeCookie(cookieName) {
let cookies = wx.getStorageSync('cookies') // try/catch 略過
delete cookies[cookieName]
saveCookiesToStorage(Object.assign({}, cookies))
}
複製程式碼
四、Cookie 在網路中的傳遞
本節主要簡單實現設計圖中的【Request基礎庫】部分
如上圖所示,Cookie在網路中的傳輸主要有四個過程:
- 客戶端發起HTTP請求
- 服務端響應,並在響應頭加上
Set-Cookie
,客戶端接受並解析儲存 - 下一次客戶端發起HTTP請求,在請求頭加上
Cookie
- 服務端識別出請求頭的
Cookie
,作出相應處理
以下是對一個請求的抓包示例:
在小程式中,請求發起有兩種方式:HTTP
和WebSocket
,這裡以HTTP為例,先對請求api進行「封裝」:
function requestPro({ url, data, header, method = 'GET' }) {
return new Promise((resolve, reject) => {
wx.request({
url,
data,
header: Object.assign({}, { 'Cookie': CookieLib.getCookiesStr() }, header), // 請求頭————帶上Cookie
success (res) {
let { data : resData, header, statusCode } = res
let setCookieStr = header['Set-Cookie'] || header['set-cookie'] || ''
CookieLib.setCookieFromHeader(setCookieStr) // 響應頭————解析Set-Cookie
resolve(resData)
},
fail (err) {
reject(err)
}
})
})
}
複製程式碼
如上程式碼所示,Cookie在前端側請求模組中的處理主要有3點:
1. 請求攜帶
步驟:(每次發請求前)從Storage中取出完整cookies ==> 轉化為HTTP規範的請求頭Cookie格式 ==> 設定到Request Header
中
上面程式碼中的getCookiesStr()
直接取cookies拼接即可,返回示例:cookie1=xxx;cookie2=yyy
。
2. 響應設定
步驟:(每次收到響應後)解析Response Header
的Set-Cookie
欄位 ==> 轉為標準Cookie格式 ==> setCookie()
這裡處理Set-Cookie
內容時,有幾個點需要留意:
- 最基本的格式:Set-Cookie: <cookie-name>=<cookie-value>
- 可能同時包含多個cookie欄位,以,分割(但需要排除時間值裡的,)
- 時間格式:Max-Age/Expires (不區分大小寫)
具體實現可在文末Demo中找到。
3. 編碼問題
「Cookie值編碼方式」是容易產生困惑的地方,目前看到的廣泛做法都是使用「URL編碼」。
但筆者翻閱 RFC6265 發現,原始規範中並沒有對編碼進行指定,比如在第四章 Server Requirements (服務端)中是這樣描述:
To maximize compatibility with user agents, servers that wish to store arbitrary data in a cookie-value SHOULD encode that data, for example, using Base64 [RFC4648].
“為了最好的相容效果,服務端應該對cookie值進行編碼,例如使用Base64。”
而在第五章 User Agent Requirements (客戶端,也就是瀏覽器),則是“建議以第四章服務端的實現為準”。
總之規範並沒有指定使用「URL編碼」,但基於該編碼方案已經深入人心,也就順其自然成了“預設選擇”。那這裡也不做例外,瀏覽器怎麼做,咋們小程式也保持一致。
在瀏覽器中,推薦cookie值經過encode
編碼後儲存下來,所以直接取到的也是encode
後的值,所以追加在請求頭Cookie
欄位,就不需要decode
解碼了,直接拼接即可(但基礎庫API的get操作最終需要進行decode
解碼)。
而對於響應頭Set-Cookie
的值,我們認為後端已經做了encode
編碼,所以前端不需要處理,直接存進 Storage 即可。
五、效能優化(高頻讀寫)
前面實現中每次讀寫cookie都會呼叫小程式Storage API(而且是同步的),小程式框架會讀寫到本地Storage。 對於高頻場景,可以將cookie在記憶體中維護一份,讀寫都直接走「記憶體層」,有更新才同步到「Storage層」。
1. 初始化
首先需要在記憶體中宣告一個_COOKIES
(命名自行diy),建議在cookie基礎庫中宣告,便於統一維護。
2. 讀
前面初始化時已經從Storage讀取一次cookies,後續getCookie就直接讀記憶體的_COOKIES
即可。
3. 寫
寫操作直接更新記憶體,間接更新Storage。 如果有高頻寫場景,可以考慮做個任務佇列進行節流。
六、單元測試
微信官方在2019年5月推出了「小程式自動化 SDK」 miniprogram-automator
,經過半年多的迭代,目前已基本穩定下來。
在購物小程式場景試用了一下,cookie相關的用例很快就完成了,簡直是開發者的福音:真香!!!
實際專案中,對cookie的單元測試可以分為兩類:
- 小程式全域性範圍的cookie驗證(比如初始化小程式後,有沒有種下版本號、訪問行為等關鍵cookie)
- cookie基礎庫API驗證(比如get/set/remove等各個API是否正常工作)
以驗證setCookie()
API為例:
it('API驗證:setCookie()', async () => {
await miniProgram.evaluate(() => {
wx.CookieLib.setCookie({ // 呼叫API
cookie1: 12345,
})
})
let { cookies } = await miniProgram.callWxMethod('getStorageSync', 'cookies')
expect(cookies['cookie1'].value).toBe(12345) // 期望成功設定cookie1為12345
})
複製程式碼
這裡為了方便測試用例呼叫基礎庫API,在小程式啟動前,把Cookie基礎庫(CookieLib)掛到了wx
物件上,實現方式是使用node讀寫檔案的API去【植入程式碼】:
fs.appendFileSync('./your_project/app.js', ''\n wx.CookieUtil = require(\'./lib/cookie.js\');\n'')
複製程式碼
七、Cookie安全
Cookie安全是一個比較大的話題,這裡只簡單列出和小程式相關的幾個點。
path、domin、HttpOnly、Secure、SameSite
小程式中已經做了一些安全措施,比如只能走HTTPS、合法域名需要管理員到微信後臺進行配置、Storage只能由寫入它的小程式中訪問,等等。
因此path、domin、HttpOnly、Secure、SameSite
這些欄位在小程式環境下的價值沒有瀏覽器環境大,本例中沒有使用(懶..),而實際業務場景可以按自身情況決定是否要使用。
白名單機制
-
前端維護(大小/數量) 通常瀏覽器保持的Cookie資料不超過4k,部分瀏覽器限制同一站點最多cookie數為20個。 如果業務龐大的話,建議在Cookie基礎庫做一套「白名單」機制,在白名單內才可以寫入,以此防止“非法寫入”或“內容超大導致資訊丟失”的問題。
-
後臺維護(閘道器白名單) 同樣的,建議從閘道器層面,建立一個“可信cookie”白名單,自動過濾請求中的“非法cookie”欄位。
前端防篡改
小程式前端更多是防“誤改”————即在操作Cookie過程中,發生了意料之外的修改。通常發生在JS“引用拷貝”特性上,比如前面提到的記憶體維護一個_Cookies
,如果有一個APIgetAllCookies()
直接將這份記憶體版cookies暴露出去,物件引用容易被連帶修改。所以cookie基礎庫需要控制暴露API的能力範圍,並對取值進行“深拷貝”。
Session
Session機制將使用者狀態放在了服務端維護,具備更好的安全性,而且目前各種後端對於session的儲存和同步都有很成熟的技術方案,有條件的業務應以Session為主做會話保持。
指紋上報
使用者訪問時生成裝置指紋並上報(通常是登入/結算等環節),業務後臺配合風控系統,遇到異常請求時下發驗證環節。
八、完整小程式實現Demo
程式碼片段:developers.weixin.qq.com/s/x4sFASmh7…
九、小結
本文先解析了瀏覽器的 Cookie機制 運作原理,然後使用「資料快取」和「網路」能力,以 公共基礎庫 的形式,在小程式中實現了一套 Cookie方案。希望對大家有所幫助。
十、相關連結
- RFC6265(HTTP狀態管理機制規範)
- HTTP Cookies Explained
- 小程式資料快取 Storage API
- 小程式網路 Network API
- 小程式自動化SDK
- 小程式實現Demo
如果你覺得這篇內容對你有價值,請點贊,並關注我們的官網和我們的微信公眾號(WecTeam),每週都有優質文章推送: