什么是 base64
我們知道一個字節可以表示的范圍是 0 ~ 255,並且在 ASCII 碼表中會對應一個字符,比如:字符 97 對應字符 'a'、90 對應字符 'Z' 等等。而在 ASCII 碼表中有很多字符都是不可見字符,那么當數據在網絡上傳輸時,由於不同的設備對字符的處理會有一些不同,那些不可見字符就有可能被錯誤處理。所以這是不利於數據傳輸的,而解決辦法就是先將數據進行一個 base64 編碼,將其統統變成可見字符,這樣出錯的概率就大大降低了。
base64 應用在很多領域當中,比如:根證書、電子郵件、圖片傳輸等等,那么下面就來分析一下 base64 到底是如何對數據進行編碼的。
這里需要提一嘴,很多人會把編碼(encode)和加密(encrypt)搞混,盡管它們都是將數據變成另一種格式,但是兩者還是不一樣的。首先編碼是為了方便數據傳輸,不是為了保證數據的機密性,比如這里的 base64,它的編碼規則是公開的,只要你的數據是 base64 編碼之后的,那么任何人都可以進行解碼;而加密才是為了數據不被別人知道,所采取的安全策略。
base64 原理解密
我們說由於存在不可見的字符的問題,所以需要將每一個 ASCII 碼都映射成可見字符。那么同 ASCII 碼表一樣,base64 肯定也有一張相應的表,記錄了數字和字符之間的映射關系。
+----+---+----+---+----+---+----+---+
| 0 | A | 16 | Q | 32 | g | 48 | w |
| 1 | B | 17 | R | 33 | h | 49 | x |
| 2 | C | 18 | S | 34 | i | 50 | y |
| 3 | D | 19 | T | 35 | j | 51 | z |
| 4 | E | 20 | U | 36 | k | 52 | 0 |
| 5 | F | 21 | V | 37 | i | 53 | 1 |
| 6 | G | 22 | W | 38 | m | 54 | 2 |
| 7 | H | 23 | X | 39 | n | 55 | 3 |
| 8 | I | 24 | Y | 40 | o | 56 | 4 |
| 9 | J | 25 | Z | 41 | p | 57 | 5 |
| 10 | K | 26 | a | 42 | q | 58 | 6 |
| 11 | L | 27 | b | 43 | r | 59 | 7 |
| 12 | M | 28 | c | 44 | s | 60 | 8 |
| 13 | N | 29 | d | 45 | t | 61 | 9 |
| 14 | O | 30 | e | 46 | u | 62 | + |
| 15 | P | 31 | f | 47 | v | 63 | / |
+----+---+----+---+----+---+----+---+
我們看到總共由 26 個大寫英文字符、26 個小寫英文字符、10 個阿拉伯數字、1 個加號、1 個斜杠,總共 64 個字符組成,所以是 base64。這些字符很明顯都是我們熟知的可見的字符,任何設備對它們的處理顯然都是沒有歧義的。
我們以字節串 b"satori" 為例,看看它 base64 編碼之后的值是多少?
import base64
s = b"satori"
print(base64.b64encode(s)) # b'c2F0b3Jp'
但是問題來了,到底要怎么映射呢?下面我們來解釋一下。
第一步
將待轉換的字節串每三個字節分為一組,由於每個字節有 8 位,那么總共是 24 個位。
s = b"satori"
print([c for c in s])
"""
[115, 97, 116, 111, 114, 105]
"""
以上是每個字節對應的 ASCII 碼,我們轉成二進制格式。
第二步
然后第一步中的 24 個位再每 6 個分為一組,因此可以分為四組。
因此總共我們得到了 8 組數據:011100、110110、000101、110100、011011、110111、001001、101001。
第三步
每組數據有 6 個位,然后在高位補兩個 0,於是可以得到:00011100、00110110、00000101、00110100、00011011、00110111、00001001、00101001。
將其轉成十進制,得到對應的值:
lst = ['00011100', '00110110', '00000101', '00110100', '00011011', '00110111', '00001001', '00101001']
print([int(_, 2) for _ in lst]) # [28, 54, 5, 52, 27, 55, 9, 41]
8 組數據對應的十進制數為 28、54、5、52、27、55、9、41。
第四步
根據每組得到的碼值在 base64 編碼對照表中映射出對應的字符,28 對應 c、54 對應 2、5 對應 F、52 對應 0、27 對應 b、55 對應 3、9 對應 J、41 對應 p,因此最終得到的結果就是 c2F0b3Jp,可以看到我們推算的結果和 Python 計算的結果是一樣的。
為什么要 3 個字節分為一組,原因是 6 和 8 的最小公倍數是 24,而 3 個字節正好是 24 個位。並且 24 個字節,6 個一組可以分為 4 組,所以 base64 編碼之后的數據大小會比原來多大約三分之一。
整體來說還是比較簡單好理解的,就是將原來的一組 3 個字節變成 4 個字節。並且此時每個字節只用了 6 個位(前兩位補 0),取值范圍正好是 0 ~ 63,和 base64 編碼對照表匹配。
但是這里還存在一個問題,就是我們上面的字節串正好是 6 個字節,是 3 的倍數。那如果不是 3 的倍數怎么辦?假設字節串只有一個字節,比如:b"A",對應的 ASCII 碼為 65,二進制格式為 01000001。
即使只有 1 個字節,那么還是當成 3 個字節來處理,而多余的兩個字節暫時先不管,然后依舊分為 4 組。第一組是 010000,前面補 0 之后得到 00010000、對應的結果為 16,根據 base64 對照表我們得到對應的字符為 Q;第二組只有 01,后面沒東西了,那么此時后面全部按 0 處理,即 010000,然后前面補 0 得到 00010000,因此對應的字符也是 Q;而第三組和第四種組則是完全沒有內容,這種情況直接對應 =。因此 b"A" 在 base64 編碼之后得到的結果就是 QQ==,同理 b"satoriA" 在 base64 編碼之后得到的結果就是 c2F0b3JpQQ==
import base64
print(base64.b64encode(b"satori")) # b'c2F0b3Jp'
print(base64.b64encode(b"A")) # b'QQ=='
print(base64.b64encode(b"satoriA")) # b'c2F0b3JpQQ=='
除了一個字節之外,還有一種特殊情況,就是每 3 個字節一組之后還剩下兩個字節,顯然兩者是類似的。我們還是來分析一下,以 b"satoriAV" 為例,顯然我們只需要分析 AV 即可。
第一組:補 0 之后 00010000 對應十進制為 16,根據對照表得到字符 Q;第二組:補 0 之后 00010101 對應十進制為 21,根據對照表得到字符 V;第三組:補 0 之后 00011000 對應十進制為 24,根據對照表得到字符 Y;第四組全部為空,因此直接得到 =。所以 b"AV" 在 base64 編碼之后對應的字符為 QVY=
import base64
print(base64.b64encode(b"satori")) # b'c2F0b3Jp'
print(base64.b64encode(b"AV")) # b'QVY='
print(base64.b64encode(b"satoriAV")) # b'c2F0b3JpQVY='
小結
以上就是 base64 的原理,當然我們除了可以進行編碼、也可以進行解碼,不過是把過程逆過來了而已。
base64 編碼數據主要是為了傳輸和存儲,正如我們最開始所說,base64 只能說是一種編碼、不能算是加密。
實現 base64
絕大部分語言都內置了 base64 相關的庫,可以直接對數據進行編碼和解碼,那么我們可不可以自己實現呢?顯然是可以的,我們來試一下。
import base64
def b64encode(data: bytes):
if not isinstance(data, bytes):
raise TypeError("data 需要一個 bytes 對象")
# 構造碼值和字符之間的映射
base64_table = dict(zip(range(64), "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"))
# 轉成二進制直接拼接起來
data = "".join([f"{_:0>8b}" for _ in data])
# 24 個位(3 個字符)一組,計算可以分多少組
count = len(data) // 24
# 分為兩部分計算
idx = count * 24
part1 = [base64_table[int(f"00{data[i: i + 6]}", 2)] for i in range(0, idx, 6)]
part2 = [base64_table[int(f"00{data[idx + i: idx + i + 6]:0<6}", 2)] for i in range(0, len(data[idx:]), 6)]
part2.extend(["="] * (4 - len(part2)))
part1.extend(part2)
return "".join(part1).encode("utf-8")
print(b64encode(b"satoriAV"))
print(base64.b64decode(b"satoriAV")) # b'c2F0b3JpQVY='
結果顯然是正確的,當然解碼也很簡單,按照編碼的流程反推回去即可。