Golang 的字符编码与 regexp
前言
最近在使用 Golang 的 regexp 對(duì)網(wǎng)絡(luò)流量做正則匹配時(shí),發(fā)現(xiàn)有些情況無(wú)法正確進(jìn)行匹配,找到資料發(fā)現(xiàn) regexp 內(nèi)部以 UTF-8 編碼的方式來(lái)處理正則表達(dá)式,而網(wǎng)絡(luò)流量是字節(jié)序列,由其中的非 UTF-8 字符造成的問(wèn)題。
我們這里從 Golang 的字符編碼和 regexp 處理機(jī)制開(kāi)始學(xué)習(xí)和分析問(wèn)題,并尋找一個(gè)有效且比較通用的解決方法,本文對(duì)此進(jìn)行記錄。
本文代碼測(cè)試環(huán)境 go version go1.14.2 darwin/amd64
regexp匹配字節(jié)序列
我們將匹配網(wǎng)絡(luò)流量所遇到的問(wèn)題,進(jìn)行抽象和最小化復(fù)現(xiàn),如下:
我們可以看到 \xff 沒(méi)有按照預(yù)期被匹配到,那么問(wèn)題出在哪里呢?
UTF-8編碼
翻閱 Golang 的資料,我們知道 Golang 的源碼采用 UTF-8 編碼, regexp 庫(kù)的正則表達(dá)式也是采用 UTF-8 進(jìn)行解析編譯(而且 Golang 的作者也是 UTF-8 的作者),那我們先來(lái)看看 UTF-8 編碼規(guī)范。
1.ASCII
在計(jì)算機(jī)的世界,字符最終都由二進(jìn)制來(lái)存儲(chǔ),標(biāo)準(zhǔn) ASCII 編碼使用一個(gè)字節(jié)(低7位),所以只能表示 127 個(gè)字符,而不同國(guó)家有不同的字符,所以建立了自己的編碼規(guī)范,當(dāng)不同國(guó)家相互通信的時(shí)候,由于編碼規(guī)范不同,就會(huì)造成亂碼問(wèn)題。
2.Unicode
為了解決亂碼問(wèn)題,提出了 Unicode 字符集,為所有字符分配一個(gè)獨(dú)一無(wú)二的編碼,隨著 Unicode 的發(fā)展,不斷添加新的字符,目前最新的 Unicode 采用 UCS-4(Unicode-32) 標(biāo)準(zhǔn),也就是使用 4 字節(jié)(32位) 來(lái)進(jìn)行編碼,理論上可以涵蓋所有字符。
但是 Unicode 只是字符集,沒(méi)有考慮計(jì)算機(jī)中的使用和存儲(chǔ)問(wèn)題,比如:
字符的存儲(chǔ)都將額外占用字節(jié)(存儲(chǔ)0x00)
3.UTF-8
后來(lái)提出了 UTF-8 編碼方案,UTF-8 是在互聯(lián)網(wǎng)上使用最廣的一種 Unicode 的實(shí)現(xiàn)方式;UTF-8 是一種變長(zhǎng)的編碼方式,編碼規(guī)則如下:
10,剩下的的二進(jìn)制位則用于存儲(chǔ)這個(gè)符號(hào)的 Unicode 碼點(diǎn)(從低位開(kāi)始)。
編碼規(guī)則如下:
Unicode符號(hào)范圍(十六進(jìn)制) | UTF-8編碼方式(二進(jìn)制) 00000000 - 0000007F | 0xxxxxxx 00000080 - 000007FF | 110xxxxx 10xxxxxx 00000800 - 0000FFFF | 1110xxxx 10xxxxxx 10xxxxxx 00010000 - 0010FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx編碼中文 你 如下:
Unicode: \u4f60 (0b 01001111 01100000) UTF-8: \xe4\xbd\xa0 (0b 1110/0100 10/111101 10/100000) (這里用斜線分割了下 UTF-8 編碼的前綴)1.根據(jù) UTF-8 編碼規(guī)則,當(dāng)需要編碼的符號(hào)超過(guò) 1 個(gè)字節(jié)時(shí),其第一個(gè)字節(jié)前面的 1 的個(gè)數(shù)表示該字符占用了幾個(gè)字節(jié)。
2.UTF-8 是自同步碼(Self-synchronizing_code),在 UTF-8 編碼規(guī)則中,任意字符的第一個(gè)字節(jié)必然以 0 / 110 / 1110 / 11110 開(kāi)頭,UTF-8 選擇 10
作為后續(xù)字節(jié)的前綴碼,以此進(jìn)行區(qū)分。自同步碼可以便于程序?qū)ふ易址吔?#xff0c;快速跳過(guò)字符,當(dāng)遇到錯(cuò)誤字符時(shí),可以跳過(guò)該字符完成后續(xù)字符的解析,這樣不會(huì)造成亂碼擴(kuò)散的問(wèn)題(GB2312存在該問(wèn)題)
byte/rune/string
在 Golang 中源碼使用 UTF-8 編碼,我們編寫(xiě)的代碼/字符會(huì)按照 UTF-8 進(jìn)行編碼,而和字符相關(guān)的有三種類型 byte/rune/string。
byte 是最簡(jiǎn)單的字節(jié)類型(uint8),string 是固定長(zhǎng)度的字節(jié)序列,其定義和初始化在 https://github.com/golang/go/blob/master/src/runtime/string.go,可以看到 string 底層就是使用 []byte 實(shí)現(xiàn)的:
rune 類型則是 Golang 中用來(lái)處理 UTF-8 編碼的類型,實(shí)際類型為 int32,存儲(chǔ)的值是字符的 Unicode 碼點(diǎn),所以 rune 類型可以便于我們更直觀的遍歷字符(對(duì)比遍歷字節(jié))如下:
類型轉(zhuǎn)換
byte(uint8) 和 rune(int32) 可以直接通過(guò)位擴(kuò)展或者舍棄高位來(lái)進(jìn)行轉(zhuǎn)換。
string 轉(zhuǎn)換比較復(fù)雜,我們一步一步來(lái)看:
string 和 byte 類型相互轉(zhuǎn)換時(shí),底層都是 byte 可以直接相互轉(zhuǎn)換,但是當(dāng)單字節(jié) byte 轉(zhuǎn) string 類型時(shí),會(huì)調(diào)用底層函數(shù) intstring() (https://github.com/golang/go/blob/master/src/runtime/string.go#L244),然后調(diào)用 encoderune() 函數(shù),對(duì)該字節(jié)進(jìn)行 UTF-8 編碼,測(cè)試如下:
string 和 rune 類型相互轉(zhuǎn)換時(shí),對(duì)于 UTF-8 字符的相互轉(zhuǎn)換,底層數(shù)據(jù)發(fā)生變化 UTF-8編碼 <=> Unicode編碼;而對(duì)于非 UTF-8 字符,將以底層單字節(jié)進(jìn)行處理:
(https://github.com/golang/go/blob/master/src/runtime/string.go#L178),最終跟進(jìn)到
Golang 編譯器的 for-range
實(shí)現(xiàn)(https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/range.go#L220),轉(zhuǎn)換時(shí)調(diào)用
decoderune() 對(duì)字符進(jìn)行 UTF-8 解碼,解碼失敗時(shí)(非 UTF-8 字符)將返回 RuneError = \uFFFD;
測(cè)試如下:
regexp處理表達(dá)式
在 regexp 中所有的字符都必須為 UTF-8 編碼,在正則表達(dá)式編譯前會(huì)對(duì)字符進(jìn)行檢查,非 UTF-8 字符將直接提示錯(cuò)誤;當(dāng)然他也支持轉(zhuǎn)義字符,比如:\t \a 或者 16進(jìn)制,在代碼中我們一般需要使用反引號(hào)包裹正則表達(dá)式(原始字符串),轉(zhuǎn)義字符由 regexp 在內(nèi)部進(jìn)行解析處理,如下:
當(dāng)然為了讓 regexp 編譯包含非 UTF-8 編碼字符的表達(dá)式,必須用反引號(hào)包裹才行
我們?cè)谑褂?regexp 時(shí),其內(nèi)部首先會(huì)對(duì)正則表達(dá)式進(jìn)行編譯,然后再進(jìn)行匹配。
1.編譯
編譯主要是構(gòu)建自動(dòng)機(jī)表達(dá)式,其底層最終使用 rune 類型存儲(chǔ)字符(https://github.com/golang/go/blob/master/src/regexp/syntax/prog.go#L112),所以 \xff 通過(guò)轉(zhuǎn)義后最終存儲(chǔ)為 0x00ff (rune)
除此之外,在編譯階段 regexp 還會(huì)提前生成正則表達(dá)式中的前綴字符串,在執(zhí)行自動(dòng)機(jī)匹配前,先用匹配前綴字符串,以提高匹配效率。需要注意的是,生成前綴字符串時(shí)其底層將調(diào)用 strings.Builder 的 WriteRune() 函數(shù)(https://github.com/golang/go/blob/master/src/regexp/syntax/prog.go#L147),內(nèi)部將調(diào)用 utf8.EncodeRune() 強(qiáng)制轉(zhuǎn)換表達(dá)式的字符為 UTF-8 編碼(如:\xff => \xc3\xbf)。
2.匹配
當(dāng)匹配時(shí),首先使用前綴字符串匹配,這里使用常規(guī)的字符串匹配。UTF-8 可以正常進(jìn)行匹配,但當(dāng)我們的字符串中包含非 UTF-8 字符就會(huì)出現(xiàn)問(wèn)題,原因正則表達(dá)式中的前綴字符串已經(jīng)被強(qiáng)制 UTF-8 編碼了,示例如下:
當(dāng)執(zhí)行自動(dòng)機(jī)匹配時(shí),將最終調(diào)用 tryBacktrace() 函數(shù)進(jìn)行逐字節(jié)回溯匹配(https://github.com/golang/go/blob/master/src/regexp/backtrack.go#L140),使用 step() 函數(shù)遍歷字符串(https://github.com/golang/go/blob/master/src/regexp/regexp.go#L383),該函數(shù)有 string/byte/rune 三種實(shí)現(xiàn),其中 string/byte 將調(diào)用 utf8.DecodeRune*() 強(qiáng)制為 rune 類型,所以三種實(shí)現(xiàn)最終都返回 rune 類型,然后和自動(dòng)機(jī)表達(dá)式存儲(chǔ)的 rune 值進(jìn)行比較,完成匹配。而這里當(dāng)非 UTF-8 字符通過(guò) utf8.DecodeRune*() 函數(shù)時(shí),將返回 RuneError=0xfffd,示例如下:
(PS: 不應(yīng)該用簡(jiǎn)單字符表達(dá)式,簡(jiǎn)單字符表達(dá)式將會(huì)直接使用前綴字符串完成匹配) regexp: `\xcf-\xff` real regexp inst: {Op:InstRune Out:4 Arg:0 Rune:[207 255]}string: "\xff" string by step(): 0xfffd[NOT MATCHED]比較復(fù)雜,不過(guò)簡(jiǎn)而言之就是 regexp 內(nèi)部會(huì)對(duì)表達(dá)式進(jìn)行 UTF-8 編碼,會(huì)對(duì)字符串進(jìn)行 UTF-8 解碼。
了解 regexp 底層匹配運(yùn)行原理過(guò)后,我們甚至可以構(gòu)造出更奇怪的匹配:
解決方法
在了解以上知識(shí)點(diǎn)過(guò)后,就很容易解決問(wèn)題了:表達(dá)式可以使用任意字符,待匹配字符串在匹配前手動(dòng)轉(zhuǎn)換為合法的 UTF-8 字符串。
因?yàn)楫?dāng) regexp 使用前綴字符串匹配時(shí),會(huì)自動(dòng)轉(zhuǎn)換表達(dá)式字符為 UTF-8 編碼,和我們的字符串一致;當(dāng) regexp 使用自動(dòng)機(jī)匹配時(shí),底層使用 rune 進(jìn)行比較,我們傳入的 UTF-8 字符串將被正確通過(guò) UTF-8 解碼,可以正確進(jìn)行匹配。
實(shí)現(xiàn)測(cè)試如下:
總結(jié)
關(guān)于開(kāi)頭提出的 regexp 匹配的問(wèn)題到這里就解決了,在不斷深入語(yǔ)言實(shí)現(xiàn)細(xì)節(jié)的過(guò)程中發(fā)現(xiàn):Golang 本身在盡可能的保持 UTF-8 編碼的一致性,但在編程中字節(jié)序列是不可避免的,Golang 中使用 string/byte 類型來(lái)進(jìn)行處理,在 regexp 底層實(shí)現(xiàn)同樣使用了 UTF-8 編碼,所以問(wèn)題就出現(xiàn)了,字節(jié)序列數(shù)據(jù)和編碼后的數(shù)據(jù)不一致。
個(gè)人感覺(jué) regexp 用于匹配字節(jié)流并不是一個(gè)預(yù)期的使用場(chǎng)景,像是 Golang 官方在 UTF-8 方面的一個(gè)取舍。
當(dāng)然這個(gè)過(guò)程中,我們翻閱了很多 Golang 底層的知識(shí),如字符集、源碼等,讓我們了解了一些 Golang 的實(shí)現(xiàn)細(xì)節(jié);在實(shí)際常見(jiàn)下我們不是一定要使用標(biāo)準(zhǔn)庫(kù) regexp,還可以使用其他的正則表達(dá)式庫(kù)來(lái)繞過(guò)這個(gè)問(wèn)題。
總結(jié)
以上是生活随笔為你收集整理的Golang 的字符编码与 regexp的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: PlugX变体已经悄悄更改源代码且正式更
- 下一篇: 简单免杀绕过和利用上线的 GoCS