Golang 的字符編碼與 regexp

前言

最近在使用 Golang 的?regexp?對網(wǎng)絡(luò)流量做正則匹配時,發(fā)現(xiàn)有些情況無法正確進行匹配,找到資料發(fā)現(xiàn) regexp 內(nèi)部以?UTF-8?編碼的方式來處理正則表達式,而網(wǎng)絡(luò)流量是字節(jié)序列,由其中的非?UTF-8?字符造成的問題。

我們這里從 Golang 的字符編碼和?regexp?處理機制開始學(xué)習(xí)和分析問題,并尋找一個有效且比較通用的解決方法,本文對此進行記錄。

本文代碼測試環(huán)境?go version go1.14.2 darwin/amd64


regexp匹配字節(jié)序列

我們將匹配網(wǎng)絡(luò)流量所遇到的問題,進行抽象和最小化復(fù)現(xiàn),如下:

我們可以看到?\xff?沒有按照預(yù)期被匹配到,那么問題出在哪里呢?

UTF-8編碼

翻閱 Golang 的資料,我們知道 Golang 的源碼采用 UTF-8 編碼,?regexp?庫的正則表達式也是采用 UTF-8 進行解析編譯(而且 Golang 的作者也是 UTF-8 的作者),那我們先來看看 UTF-8 編碼規(guī)范。

1.ASCII

在計算機的世界,字符最終都由二進制來存儲,標(biāo)準(zhǔn) ASCII 編碼使用一個字節(jié)(低7位),所以只能表示 127 個字符,而不同國家有不同的字符,所以建立了自己的編碼規(guī)范,當(dāng)不同國家相互通信的時候,由于編碼規(guī)范不同,就會造成亂碼問題。


2.Unicode

為了解決亂碼問題,提出了 Unicode 字符集,為所有字符分配一個獨一無二的編碼,隨著 Unicode 的發(fā)展,不斷添加新的字符,目前最新的 Unicode 采用 UCS-4(Unicode-32) 標(biāo)準(zhǔn),也就是使用 4 字節(jié)(32位) 來進行編碼,理論上可以涵蓋所有字符。

但是 Unicode 只是字符集,沒有考慮計算機中的使用和存儲問題,比如:

與已存在的 ASCII 編碼不兼容,ASCII(A)=65 / UCS-2(A)=0065

由于 Unicode 編碼高字節(jié)可能為 0,C 語言字符串串函數(shù)將出現(xiàn) 00 截斷問題

從全世界來看原來 ASCII 的字符串使用得最多,而換成 Unicode 過后,這些 ASCII 字符的存儲都將額外占用字節(jié)(存儲0x00)

3.UTF-8

后來提出了 UTF-8 編碼方案,UTF-8 是在互聯(lián)網(wǎng)上使用最廣的一種 Unicode 的實現(xiàn)方式;UTF-8 是一種變長的編碼方式,編碼規(guī)則如下:

對于單字節(jié)的符號,字節(jié)的第一位設(shè)為 0,后面 7 位為這個符號的 Unicode 的碼點,兼容 ASCII

對于需要 n 字節(jié)來表示的符號(n > 1),第一個字節(jié)的前 n 位都設(shè)為 1,第 n+1 位設(shè)置為 0;后面字節(jié)的前兩位一律設(shè)為 10,剩下的的二進制位則用于存儲這個符號的 Unicode 碼點(從低位開始)。

編碼規(guī)則如下:


編碼中文?你?如下:


1.根據(jù) UTF-8 編碼規(guī)則,當(dāng)需要編碼的符號超過 1 個字節(jié)時,其第一個字節(jié)前面的 1 的個數(shù)表示該字符占用了幾個字節(jié)。

2.UTF-8 是自同步碼(Self-synchronizing_code),在 UTF-8 編碼規(guī)則中,任意字符的第一個字節(jié)必然以 0 / 110 / 1110 / 11110 開頭,UTF-8 選擇 10 作為后續(xù)字節(jié)的前綴碼,以此進行區(qū)分。自同步碼可以便于程序?qū)ふ易址吔?,快速跳過字符,當(dāng)遇到錯誤字符時,可以跳過該字符完成后續(xù)字符的解析,這樣不會造成亂碼擴散的問題(GB2312存在該問題)


byte/rune/string

在 Golang 中源碼使用 UTF-8 編碼,我們編寫的代碼/字符會按照 UTF-8 進行編碼,而和字符相關(guān)的有三種類型?byte/rune/string。

byte?是最簡單的字節(jié)類型(uint8),string?是固定長度的字節(jié)序列,其定義和初始化在?https://github.com/golang/go/blob/master/src/runtime/string.go,可以看到?string?底層就是使用?[]byte?實現(xiàn)的:


rune?類型則是 Golang 中用來處理 UTF-8 編碼的類型,實際類型為?int32,存儲的值是字符的 Unicode 碼點,所以?rune?類型可以便于我們更直觀的遍歷字符(對比遍歷字節(jié))如下:

類型轉(zhuǎn)換

byte(uint8)?和?rune(int32)?可以直接通過位擴展或者舍棄高位來進行轉(zhuǎn)換。

string?轉(zhuǎn)換比較復(fù)雜,我們一步一步來看:

string?和?byte?類型相互轉(zhuǎn)換時,底層都是?byte?可以直接相互轉(zhuǎn)換,但是當(dāng)單字節(jié)?byte?轉(zhuǎn)?string?類型時,會調(diào)用底層函數(shù)?intstring()?(https://github.com/golang/go/blob/master/src/runtime/string.go#L244),然后調(diào)用?encoderune()?函數(shù),對該字節(jié)進行 UTF-8 編碼,測試如下:


string?和?rune?類型相互轉(zhuǎn)換時,對于 UTF-8 字符的相互轉(zhuǎn)換,底層數(shù)據(jù)發(fā)生變化?UTF-8編碼 <=> Unicode編碼;而對于非 UTF-8 字符,將以底層單字節(jié)進行處理:

string => rune?時,會調(diào)用?stringtoslicerune()?(https://github.com/golang/go/blob/master/src/runtime/string.go#L178),最終跟進到 Golang 編譯器的?for-range?實現(xiàn)(https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/range.go#L220),轉(zhuǎn)換時調(diào)用?decoderune()?對字符進行 UTF-8 解碼,解碼失敗時(非 UTF-8 字符)將返回?RuneError = \uFFFD;

rune => string?時,和?byte?單字節(jié)轉(zhuǎn)換一樣,會調(diào)用?intstring()?函數(shù),對值進行 UTF-8 編碼。

測試如下:


regexp處理表達式

在?regexp?中所有的字符都必須為 UTF-8 編碼,在正則表達式編譯前會對字符進行檢查,非 UTF-8 字符將直接提示錯誤;當(dāng)然他也支持轉(zhuǎn)義字符,比如:\t \a 或者 16進制,在代碼中我們一般需要使用反引號包裹正則表達式(原始字符串),轉(zhuǎn)義字符由?regexp?在內(nèi)部進行解析處理,如下:


當(dāng)然為了讓?regexp?編譯包含非 UTF-8 編碼字符的表達式,必須用反引號包裹才行

我們在使用?regexp?時,其內(nèi)部首先會對正則表達式進行編譯,然后再進行匹配。

1.編譯

編譯主要是構(gòu)建自動機表達式,其底層最終使用?rune?類型存儲字符(https://github.com/golang/go/blob/master/src/regexp/syntax/prog.go#L112),所以?\xff?通過轉(zhuǎn)義后最終存儲為?0x00ff (rune)


除此之外,在編譯階段?regexp?還會提前生成正則表達式中的前綴字符串,在執(zhí)行自動機匹配前,先用匹配前綴字符串,以提高匹配效率。需要注意的是,生成前綴字符串時其底層將調(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()?強制轉(zhuǎn)換表達式的字符為 UTF-8 編碼(如:\xff => \xc3\xbf)。

2.匹配

當(dāng)匹配時,首先使用前綴字符串匹配,這里使用常規(guī)的字符串匹配。UTF-8 可以正常進行匹配,但當(dāng)我們的字符串中包含非 UTF-8 字符就會出現(xiàn)問題,原因正則表達式中的前綴字符串已經(jīng)被強制 UTF-8 編碼了,示例如下:


當(dāng)執(zhí)行自動機匹配時,將最終調(diào)用?tryBacktrace()?函數(shù)進行逐字節(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?三種實現(xiàn),其中?string/byte?將調(diào)用?utf8.DecodeRune*()?強制為?rune?類型,所以三種實現(xiàn)最終都返回?rune?類型,然后和自動機表達式存儲的?rune?值進行比較,完成匹配。而這里當(dāng)非 UTF-8 字符通過?utf8.DecodeRune*()?函數(shù)時,將返回?RuneError=0xfffd,示例如下:


比較復(fù)雜,不過簡而言之就是?regexp?內(nèi)部會對表達式進行 UTF-8 編碼,會對字符串進行 UTF-8 解碼。

了解?regexp?底層匹配運行原理過后,我們甚至可以構(gòu)造出更奇怪的匹配:


?解決方法

在了解以上知識點過后,就很容易解決問題了:表達式可以使用任意字符,待匹配字符串在匹配前手動轉(zhuǎn)換為合法的 UTF-8 字符串。

因為當(dāng)?regexp?使用前綴字符串匹配時,會自動轉(zhuǎn)換表達式字符為 UTF-8 編碼,和我們的字符串一致;當(dāng)?regexp?使用自動機匹配時,底層使用?rune?進行比較,我們傳入的 UTF-8 字符串將被正確通過 UTF-8 解碼,可以正確進行匹配。

實現(xiàn)測試如下:


?總結(jié)

關(guān)于開頭提出的?regexp?匹配的問題到這里就解決了,在不斷深入語言實現(xiàn)細(xì)節(jié)的過程中發(fā)現(xiàn):Golang 本身在盡可能的保持 UTF-8 編碼的一致性,但在編程中字節(jié)序列是不可避免的,Golang 中使用?string/byte?類型來進行處理,在?regexp?底層實現(xiàn)同樣使用了 UTF-8 編碼,所以問題就出現(xiàn)了,字節(jié)序列數(shù)據(jù)和編碼后的數(shù)據(jù)不一致。

個人感覺?regexp?用于匹配字節(jié)流并不是一個預(yù)期的使用場景,像是 Golang 官方在 UTF-8 方面的一個取舍。

當(dāng)然這個過程中,我們翻閱了很多 Golang 底層的知識,如字符集、源碼等,讓我們了解了一些 Golang 的實現(xiàn)細(xì)節(jié);在實際常見下我們不是一定要使用標(biāo)準(zhǔn)庫?regexp,還可以使用其他的正則表達式庫來繞過這個問題。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容