JavaScript 中的位運算和權限設計
- 2019 年 11 月 20 日
- 筆記
1. 內容概要
本文主要討論以下兩個問題:
- JavaScript 的位運算:先簡單回顧下位運算,平時用的少,相信不少人和我一樣忘的差不多了
- 權限設計:根據位運算的特點,設計一個權限系統(添加、刪除、判斷等)
2. JavaScript 位運算
2.1. Number
在講位運算之前,首先簡單看下 JavaScript 中的 Number,下文需要用到。
在 JavaScript 里,數字均為基於 IEEE 754 標準的雙精度 64 位的浮點數,引用維基百科的圖片,它的結構長這樣:

- sign bit(符號): 用來表示正負號
- exponent(指數): 用來表示次方數
- mantissa(尾數): 用來表示精確度
也就是說一個數字的範圍只能在 -(2^53 -1) 至 2^53 -1 之間。
既然講到這裡,就多說一句:0.1 + 0.2 算不準的原因也在於此。浮點數用二進制表達時是無窮的,且最多 53 位,必須截斷,進而產生誤差。最簡單的解決辦法就是放大一定倍數變成整數,計算完成後再縮小。不過更穩妥的辦法是使用下文將會提到的 math.js 等工具庫。
此外還有四種數字進制:
// 十進制 123456789 0 // 二進制:前綴 0b,0B 0b10000000000000000000000000000000 // 2147483648 0b01111111100000000000000000000000 // 2139095040 0B00000000011111111111111111111111 // 8388607 // 八進制:前綴 0o,0O(以前支持前綴 0) 0o755 // 493 0o644 // 420 // 十六進制:前綴 0x,0X 0xFFFFFFFFFFFFFFFFF // 295147905179352830000 0x123456789ABCDEF // 81985529216486900 0XA // 10
好了,Number 就說這麼多,接下來看 JavaScript 中的位運算。
2.2. 位運算
按位操作符將其操作數當作 32 位的比特序列(由 0 和 1 組成)操作,返回值依然是標準的 JavaScript 數值。JavaScript 中的按位操作符有:
運算符 |
用法 |
描述 |
---|---|---|
按位與(AND) |
a & b |
對於每一個比特位,只有兩個操作數相應的比特位都是 1 時,結果才為 1,否則為 0。 |
按位或(OR) |
a | b |
對於每一個比特位,當兩個操作數相應的比特位至少有一個 1 時,結果為 1,否則為 0。 |
按位異或(XOR) |
a ^ b |
對於每一個比特位,當兩個操作數相應的比特位有且只有一個 1 時,結果為 1,否則為 0。 |
按位非(NOT) |
~a |
反轉操作數的比特位,即 0 變成 1,1 變成 0。 |
左移(Left shift) |
a << b |
將 a 的二進制形式向左移 b (< 32) 比特位,右邊用 0 填充。 |
有符號右移 |
a >> b |
將 a 的二進制表示向右移 b (< 32) 位,丟棄被移出的位。 |
無符號右移 |
a >>> b |
將 a 的二進制表示向右移 b (< 32) 位,丟棄被移出的位,並使用 0 在左側填充。 |
下面舉幾個例子,主要看下 AND
和 OR
:
# 例子1 A = 10001001 B = 10010000 A | B = 10011001 # 例子2 A = 10001001 C = 10001000 A | C = 10001001
# 例子1 A = 10001001 B = 10010000 A & B = 10000000 # 例子2 A = 10001001 C = 10001000 A & C = 10001000
3. 位運算在權限系統中的使用
傳統的權限系統里,存在很多關聯關係,如用戶和權限的關聯,用戶和角色的關聯。系統越大,關聯關係越多,越難以維護。而引入位運算,可以巧妙的解決該問題。
在講「位運算在權限系統中的使用」之前,我們先假定兩個前提,下文所有的討論都是基於這兩個前提的:
- 每種權限碼都是唯一的(這是顯然的)
- 所有權限碼的二進制數形式,有且只有一位值為 1,其餘全部為 0(
2^n
)
如果用戶權限和權限碼,全部使用二級制數字表示,再結合上面 AND
和 OR
的例子,分析位運算的特點,不難發現:
|
可以用來賦予權限&
可以用來校驗權限
為了講的更明白,這裡用 Linux 中的實例分析下,Linux 的文件權限分為讀、寫和執行,有字母和數字等多種表現形式:
權限 |
字母表示 |
數字表示 |
二進制 |
---|---|---|---|
讀 |
r |
4 |
0b100 |
寫 |
w |
2 |
0b010 |
執行 |
x |
1 |
0b001 |
可以看到,權限用 1、2、4(也就是 2^n
)表示,轉換為二進制後,都是只有一位是 1,其餘為 0。我們通過幾個例子看下,如何利用二進制的特點執行權限的添加,校驗和刪除。
3.1. 添加權限
let r = 0b100 let w = 0b010 let x = 0b001 // 給用戶賦全部權限(使用前面講的 | 操作) let user = r | w | x console.log(user) // 7 console.log(user.toString(2)) // 111 // r = 0b100 // w = 0b010 // r = 0b001 // r|w|x = 0b111
可以看到,執行 r | w | x
後,user
的三位都是 1,表明擁有了全部三個權限。
Linux 下出現權限問題時,最粗暴的解決方案就是
chmod 777 xxx
,這裡的7
就代表了:可讀,可寫,可執行。而三個7
分別代表:文件所有者,文件所有者所在組,所有其他用戶。
3.2. 校驗權限
剛才演示了權限的添加,下面演示權限校驗:
let r = 0b100 let w = 0b010 let x = 0b001 // 給用戶賦 r w 兩個權限 let user = r | w // user = 6 // user = 0b110 (二進制) console.log((user & r) === r) // true 有 r 權限 console.log((user & w) === w) // true 有 w 權限 console.log((user & x) === x) // false 沒有 x 權限
如前所料,通過 用戶權限 & 權限 code === 權限 code
就可以判斷出用戶是否擁有該權限。
3.3. 刪除權限
我們講了用 |
賦予權限,使用 &
判斷權限,那麼刪除權限呢?刪除權限的本質其實是將指定位置上的 1 重置為 0。上個例子里用戶權限是 0b110
,擁有讀和寫兩個權限,現在想刪除讀的權限,本質上就是將第三位的 1 重置為 0,變為 0b010
:
let r = 0b100 let w = 0b010 let x = 0b001 let user = 0b010; console.log((user & r) === r) // false 沒有 r 權限 console.log((user & w) === w) // true 有 w 權限 console.log((user & x) === x) // false 沒有 x 權限
那麼具體怎麼操作呢?其實有兩種方案,最簡單的就是異或 ^
,按照上文的介紹「當兩個操作數相應的比特位有且只有一個 1 時,結果為 1,否則為 0」,所以異或其實是 toggle 操作,無則增,有則減:
let r = 0b100 let w = 0b010 let x = 0b001 let user = 0b110 // 有 r w 兩個權限 // 執行異或操作,刪除 r 權限 user = user ^ r console.log((user & r) === r) // false 沒有 r 權限 console.log((user & w) === w) // true 有 w 權限 console.log((user & x) === x) // false 沒有 x 權限 console.log(user.toString(2)) // 現在 user 是 0b010 // 再執行一次異或操作 user = user ^ r console.log((user & r) === r) // true 有 r 權限 console.log((user & w) === w) // true 有 w 權限 console.log((user & x) === x) // false 沒有 x 權限 console.log(user.toString(2)) // 現在 user 又變回 0b110
那麼如果單純的想刪除權限(而不是無則增,有則減)怎麼辦呢?答案是執行 &(~code)
,先取反,再執行與操作:
let r = 0b100 let w = 0b010 let x = 0b001 let user = 0b110 // 有 r w 兩個權限 // 刪除 r 權限 user = user & (~r) console.log((user & r) === r) // false 沒有 r 權限 console.log((user & w) === w) // true 有 w 權限 console.log((user & x) === x) // false 沒有 x 權限 console.log(user.toString(2)) // 現在 user 是 0b010 // 再執行一次 user = user & (~r) console.log((user & r) === r) // false 沒有 r 權限 console.log((user & w) === w) // true 有 w 權限 console.log((user & x) === x) // false 沒有 x 權限 console.log(user.toString(2)) // 現在 user 還是 0b010,並不會新增
4. 局限性和解決辦法
前面我們回顧了 JavaScript 中的 Number 和位運算,並且了解了基於位運算的權限系統原理和 Linux 文件系統權限的實例。
上述的所有都有前提條件:1、每種權限碼都是唯一的;2、每個權限碼的二進制數形式,有且只有一位值為 1(2^n
)。也就是說,權限碼只能是 1, 2, 4, 8,…,1024,…而上文提到,一個數字的範圍只能在 -(2^53 -1) 和 2^53 -1 之間,JavaScript 的按位操作符又是將其操作數當作 32 位比特序列的。那麼同一個應用下可用的權限數就非常有限了。這也是該方案的局限性。
為了突破這個限制,這裡提出一個叫「權限空間」的概念,既然權限數有限,那麼不妨就多開闢幾個空間來存放。
基於權限空間,我們定義兩個格式:
- 權限 code,字符串,形如
index,pos
。其中pos
表示 32 位二進制數中 1 的位置(其餘全是 0);index
表示權限空間,用於突破 JavaScript 數字位數的限制,是從 0 開始的正整數,每個權限code都要歸屬於一個權限空間。index
和pos
使用英文逗號隔開。 - 用戶權限,字符串,形如
1,16,16
。英文逗號分隔每一個權限空間的權限值。例如1,16,16
的意思就是,權限空間 0 的權限值是 1,權限空間 1 的權限值是 16,權限空間 2 的權限是 16。
干說可能不好懂,直接上代碼:
// 用戶的權限 code let userCode = "" // 假設系統里有這些權限 // 純模擬,正常情況下是按順序的,如 0,0 0,1 0,2 ...,儘可能佔滿一個權限空間,再使用下一個 const permissions = { SYS_SETTING: { value: "0,0", // index = 0, pos = 0 info: "系統權限" }, DATA_ADMIN: { value: "0,8", info: "數據庫權限" }, USER_ADD: { value: "0,22", info: "用戶新增權限" }, USER_EDIT: { value: "0,30", info: "用戶編輯權限" }, USER_VIEW: { value: "1,2", // index = 1, pos = 2 info: "用戶查看權限" }, USER_DELETE: { value: "1,17", info: "用戶刪除權限" }, POST_ADD: { value: "1,28", info: "文章新增權限" }, POST_EDIT: { value: "2,4", info: "文章編輯權限" }, POST_VIEW: { value: "2,19", info: "文章查看權限" }, POST_DELETE: { value: "2,26", info: "文章刪除權限" } } // 添加權限 const addPermission = (userCode, permission) => { const userPermission = userCode ? userCode.split(",") : [] const [index, pos] = permission.value.split(",") userPermission[index] = (userPermission[index] || 0) | Math.pow(2, pos) return userPermission.join(",") } // 刪除權限 const delPermission = (userCode, permission) => { const userPermission = userCode ? userCode.split(",") : [] const [index, pos] = permission.value.split(",") userPermission[index] = (userPermission[index] || 0) & (~Math.pow(2, pos)) return userPermission.join(",") } // 判斷是否有權限 const hasPermission = (userCode, permission) => { const userPermission = userCode ? userCode.split(",") : [] const [index, pos] = permission.value.split(",") const permissionValue = Math.pow(2, pos) return (userPermission[index] & permissionValue) === permissionValue } // 列出用戶擁有的全部權限 const listPermission = userCode => { const results = [] if (!userCode) { return results } Object.values(permissions).forEach(permission => { if (hasPermission(userCode, permission)) { results.push(permission.info) } }) return results } const log = () => { console.log(`userCode: ${JSON.stringify(userCode, null, " ")}`) console.log(`權限列表: ${listPermission(userCode).join("; ")}`) console.log("") } userCode = addPermission(userCode, permissions.SYS_SETTING) log() // userCode: "1" // 權限列表: 系統權限 userCode = addPermission(userCode, permissions.POST_EDIT) log() // userCode: "1,,16" // 權限列表: 系統權限; 文章編輯權限 userCode = addPermission(userCode, permissions.USER_EDIT) log() // userCode: "1073741825,,16" // 權限列表: 系統權限; 用戶編輯權限; 文章編輯權限 userCode = addPermission(userCode, permissions.USER_DELETE) log() // userCode: "1073741825,131072,16" // 權限列表: 系統權限; 用戶編輯權限; 用戶刪除權限; 文章編輯權限 userCode = delPermission(userCode, permissions.USER_EDIT) log() // userCode: "1,131072,16" // 權限列表: 系統權限; 用戶刪除權限; 文章編輯權限 userCode = delPermission(userCode, permissions.USER_EDIT) log() // userCode: "1,131072,16" // 權限列表: 系統權限; 用戶刪除權限; 文章編輯權限 userCode = delPermission(userCode, permissions.USER_DELETE) userCode = delPermission(userCode, permissions.SYS_SETTING) userCode = delPermission(userCode, permissions.POST_EDIT) log() // userCode: "0,0,0" // 權限列表: userCode = addPermission(userCode, permissions.SYS_SETTING) log() // userCode: "1,0,0" // 權限列表: 系統權限
除了通過引入權限空間的概念突破二進制運算的位數限制,還可以使用 math.js 的 bignumber
,直接運算超過 32 位的二進制數,具體可以看它的文檔,這裡就不細說了。
5. 適用場景和問題
如果按照當前使用最廣泛的 RBAC 模型設計權限系統,那麼一般會有這麼幾個實體:應用,權限,角色,用戶。用戶權限可以直接來自權限,也可以來自角色:
- 一個應用下有多個權限
- 權限和角色是多對多的關係
- 用戶和角色是多對多的關係
- 用戶和權限是多對多的關係
在此種模型下,一般會有用戶與權限,用戶與角色,角色與權限的對應關係表。想像一個商城後台權限管理系統,可能會有上萬,甚至十幾萬店鋪(應用),每個店鋪可能會有數十個用戶,角色,權限。隨着業務的不斷發展,剛才提到的那三張對應關係表會越來越大,越來越難以維護。
而進制轉換的方法則可以省略對應關係表,減少查詢,節省空間。當然,省略掉對應關係不是沒有壞處的,例如下面幾個問題:
- 如何高效的查找我的權限?
- 如何高效的查找擁有某權限的所有用戶?
- 如何控制權限的有效期?
所以進制轉換的方案比較適合剛才提到的應用極其多,而每個應用中用戶,權限,角色數量較少的場景。
6. 其他方案
除了二進制方案,當然還有其他方案可以達到類似的效果,例如直接使用一個1和0組成的字符串,權限點對應index,1表示擁有權限,0表示沒有權限。舉個例子:添加 0、刪除 1、編輯 2,用戶A擁有添加和編輯的權限,則 userCode 為 101;用戶B擁有全部權限,userCode 為 111。這種方案比二進制轉換簡單,但是浪費空間。
還有利用質數的方案,權限點全部為質數,用戶權限為他所擁有的全部權限點的乘積。如:權限點是 2、3、5、7、11,用戶權限是 5 * 7 * 11 = 385。這種方案麻煩的地方在於獲取質數(新增權限點)和質因數分解(判斷權限),權限點特別多的時候就快成 RSA 了,如果只有增刪改查個別幾個權限,倒是可以考慮。
7. 參考
- MDN:JavaScript 數字和日期
- 雙精度浮點類型
- MDN:按位操作符
- 【小知識大道理】被忽視的位運算
- 為什麼不要在 JavaScript 中使用位操作符?
- 角色權限設計的100種解法
- 權限系統與RBAC模型概述
- 權限設計及算法
- 基於角色的訪問控制