代码实现sql编译器_TiDB-Wasm 原理与实现 | Hackathon 优秀项目介绍
作者:Ti-Cool
上周我們推送了《讓數(shù)據(jù)庫(kù)運(yùn)行在瀏覽器里?TiDB + WebAssembly 告訴你答案》,向大家展示了 TiDB-Wasm 的魅力:TiDB-Wasm 項(xiàng)目是 TiDB Hackathon 2019 中誕生的二等獎(jiǎng)項(xiàng)目,實(shí)現(xiàn)了將 TiDB 編譯成 Wasm 運(yùn)行在瀏覽器里,讓用戶無需安裝就可以使用 TiDB。本文由 Ti-Cool 隊(duì)成員主筆,為大家詳細(xì)介紹 TiDB-Wasm 設(shè)計(jì)與實(shí)現(xiàn)細(xì)節(jié)。
10 月 27 日,為期兩天的 Hackathon 落下帷幕,我們用一枚二等獎(jiǎng)為此次上海之行畫上了圓滿的句號(hào),不枉我們風(fēng)塵仆仆跑去異地參賽(強(qiáng)烈期待明年杭州能作為賽場(chǎng),主辦方也該鼓勵(lì)鼓勵(lì)杭州當(dāng)?shù)氐男』锇檠?:D )。
我們幾個(gè) PingCAP 的小伙伴找到了 Tony 同學(xué)一起組隊(duì),組隊(duì)之后找了一個(gè)周末進(jìn)行了“秘密會(huì)晤”——Hackathon kick off。想了 N 個(gè) idea,包括使用 unikernel 技術(shù)將 TiDB 直接跑在裸機(jī)上,或者將網(wǎng)絡(luò)協(xié)議棧做到用戶態(tài)以提升 TiDB 集群性能,亦或是使用異步 io 技術(shù)提升 TiKV 的讀寫能力,這些都被一一否決,原因是這些 idea 不是和 Tony 的工作內(nèi)容相關(guān),就是和我們 PingCAP 小伙伴的日常工作相關(guān),做這些相當(dāng)于我們?cè)?Hackathon 加了兩天班,這一點(diǎn)都不酷。本著「與工作無關(guān)」的標(biāo)準(zhǔn),我們想了一個(gè) idea:把 TiDB 編譯成 Wasm 運(yùn)行在瀏覽器里,讓用戶無需安裝就可以使用 TiDB。我們一致認(rèn)為這很酷,于是給隊(duì)伍命名為 Ti-Cool(太酷了)。
WebAssembly 簡(jiǎn)介
這里插入一些 WebAssembly 的背景知識(shí),讓大家對(duì)這個(gè)技術(shù)有個(gè)大致的了解。
WebAssembly 的 官方介紹 是這樣的:WebAssembly(縮寫為 Wasm)是一種為基于堆棧的虛擬機(jī)設(shè)計(jì)的指令格式。它被設(shè)計(jì)為 C/C++/Rust 等高級(jí)編程語言的可移植目標(biāo),可在 web 上部署客戶端和服務(wù)端應(yīng)用程序。
從上面一段話我們可以得出幾個(gè)信息:
可執(zhí)行指令格式
看到上面的三個(gè)信息我們可能又有疑問:什么是指令格式?
我們常見的 ELF 文件 就是 Unix 系統(tǒng)上最常用的二進(jìn)制指令格式,它被 loader 解析識(shí)別,加載進(jìn)內(nèi)存執(zhí)行。同理,Wasm 也是被某種實(shí)現(xiàn)了 Wasm 的 runtime 識(shí)別,加載進(jìn)內(nèi)存執(zhí)行,目前常見的實(shí)現(xiàn)了 Wasm runtime 的工具有各種主流瀏覽器,nodejs,以及一個(gè)專門為 Wasm 設(shè)計(jì)的通用實(shí)現(xiàn):Wasmer,甚至還有人給 Linux 內(nèi)核提 feature 將 Wasm runtime 集成在內(nèi)核中,這樣用戶寫的程序可以很方便的跑在內(nèi)核態(tài)。
各種主流瀏覽器對(duì) WebAssembly 的支持程度:
圖 1 主流瀏覽器對(duì) WebAssembly 的支持程度從高級(jí)語言到 Wasm
有了上面的背景就不難理解高級(jí)語言是如何編譯成 Wasm 的,看一下高級(jí)語言的編譯流程:
圖 2 高級(jí)語言編譯流程我們知道高級(jí)編程語言的特性之一就是可移植性,例如 C/C++ 既可以編譯成 x86 機(jī)器可運(yùn)行的格式,也可以編譯到 ARM 上面跑,而我們的 Wasm 運(yùn)行時(shí)和 ARM,x86_32 其實(shí)是同類東西,可以認(rèn)為它是一臺(tái)虛擬的機(jī)器,支持執(zhí)行某種字節(jié)碼,這一點(diǎn)其實(shí)和 Java 非常像,實(shí)際上 C/C++ 也可以編譯到 JVM 上運(yùn)行(參考:compiling-c-for-the-jvm)。
各種 runtime 以及 WASI
再啰嗦一下各種環(huán)境中運(yùn)行 Wasm 的事,上面說了 Wasm 是設(shè)計(jì)為可以在 web 中運(yùn)行的程序,其實(shí) Wasm 最初設(shè)計(jì)是為了彌補(bǔ) js 執(zhí)行效率的問題,但是發(fā)展到后面發(fā)現(xiàn),這玩意兒當(dāng)虛擬機(jī)來移植各種程序也是很贊的,于是有了 nodejs 環(huán)境,Wasmer 環(huán)境,甚至還有內(nèi)核環(huán)境。
這么多環(huán)境就有一個(gè)問題了:各個(gè)環(huán)境支持的接口不一致。比如 nodejs 支持讀寫文件,但瀏覽器不支持,這挑戰(zhàn)了 Wasm 的可移植性,于是 WASI (WebAssembly System Interface) 應(yīng)運(yùn)而生,它定義了一套底層接口規(guī)范,只要編譯器和 Wasm 運(yùn)行環(huán)境都支持這套規(guī)范,那么編譯器生成的 Wasm 就可以在各種環(huán)境中無縫移植。如果用現(xiàn)有的概念來類比,Wasm runtime 相當(dāng)于一臺(tái)虛擬的機(jī)器,Wasm 就是這臺(tái)機(jī)器的可執(zhí)行程序,而 WASI 是運(yùn)行在這臺(tái)機(jī)器上的系統(tǒng),它為 Wasm 提供底層接口(如文件操作,socket 等)。
Example or Hello World?
程序員對(duì) Hello World 有天生的好感,為了更好的說明 Wasm 和 WASI 是啥,我們這里用一個(gè) Wasm 的 Hello World 來介紹(例程來源:chai2010-golang-wasm.slide#27):
(module;; type iov struct { iov_base, iov_len int32 };; func fd_write(id *iov, iovs_len int32, nwritten *int32) (written int32)(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))(memory 1)(export "memory" (memory 0));; The first 8 bytes are reserved for the iov array, starting with address 8(data (i32.const 8) "hello worldn");; _start is similar to main function, will be executed automatically(func $main (export "_start")(i32.store (i32.const 0) (i32.const 8)) ;; iov.iov_base - The string address is 8(i32.store (i32.const 4) (i32.const 12)) ;; iov.iov_len - String length(call $fd_write(i32.const 1) ;; 1 is stdout(i32.const 0) ;; *iovs - The first 8 bytes are reserved for the iov array(i32.const 1) ;; len(iovs) - Only 1 string(i32.const 20) ;; nwritten - Pointer, inside is the length of the data to be written)drop ;; Ignore return value) )具體指令的解釋可以參考 這里。
這里的 test.wat 是 Wasm 的文本表示,wat 之于 Wasm 的關(guān)系類似于匯編和 ELF 的關(guān)系。
然后我們把 wat 編譯為 Wasm 并且使用 Wasmer(一個(gè)通用的 Wasm 運(yùn)行時(shí)實(shí)現(xiàn))運(yùn)行:
圖 3 Hello World改造工作
恐懼來自未知,有了背景知識(shí)動(dòng)起手來才無所畏懼,現(xiàn)在可以開啟 TiDB 的瀏覽器之旅。
瀏覽器安全限制
我們知道,瀏覽器本質(zhì)是一個(gè)沙盒,是不會(huì)讓內(nèi)部的程序做一些危險(xiǎn)的事情的,比如監(jiān)聽端口,讀寫文件。而 TiDB 的使用場(chǎng)景實(shí)際是用戶啟動(dòng)一個(gè)客戶端通過 MySQL 協(xié)議連接到 TiDB,這要求 TiDB 必須監(jiān)聽某個(gè)端口。
考慮片刻之后,我們認(rèn)為即便克服了瀏覽器沙盒這個(gè)障礙,真讓用戶用 MySQL 客戶端去連瀏覽器也并不是一個(gè)優(yōu)雅的事情,我們希望的是用戶在頁(yè)面上可以有一個(gè)開箱即用的 MySQL 終端,它已經(jīng)連接好了 TiDB。
于是我們第一件事是給 TiDB 集成一個(gè)終端,讓它啟動(dòng)后直接彈出這個(gè)終端接受用戶輸入 SQL。所以我們需要在 TiDB 的代碼中找到一個(gè)工具,它的輸入是一串 SQL,輸出是 SQL 的執(zhí)行結(jié)果,寫一個(gè)這樣的東西對(duì)于我們幾個(gè)沒接觸過 TiDB 代碼的人來說還是有些難度,于是我們想到了一個(gè)捷徑:TiDB 的測(cè)試代碼中肯定會(huì)有輸入 SQL 然后檢查輸出的測(cè)試。那么把這種測(cè)試搬過來改一改不就是我們想要的東西嘛?然后我們翻了翻 TiDB 的測(cè)試代碼,發(fā)現(xiàn)了大量的這樣的用法:
result = tk.MustQuery("select count(*) from t group by d order by c") result.Check(testkit.Rows("3", "2", "2"))所以我們只需要看看這個(gè) tk 是個(gè)什么東西,借來用一下就行了。這是 tk 的主要函數(shù):
// Exec executes a sql statement. func (tk *TestKit) Exec(sql string, args ...interface{}) (sqlexec.RecordSet, error) {var err errorif tk.Se == nil {tk.Se, err = session.CreateSession4Test(tk.store)tk.c.Assert(err, check.IsNil)id := atomic.AddUint64(&connectionID, 1)tk.Se.SetConnectionID(id)}ctx := context.Background()if len(args) == 0 {var rss []sqlexec.RecordSetrss, err = tk.Se.Execute(ctx, sql)if err == nil && len(rss) > 0 {return rss[0], nil}return nil, errors.Trace(err)}stmtID, _, _, err := tk.Se.PrepareStmt(sql)if err != nil {return nil, errors.Trace(err)}params := make([]types.Datum, len(args))for i := 0; i < len(params); i++ {params[i] = types.NewDatum(args[i])}rs, err := tk.Se.ExecutePreparedStmt(ctx, stmtID, params)if err != nil {return nil, errors.Trace(err)}err = tk.Se.DropPreparedStmt(stmtID)if err != nil {return nil, errors.Trace(err)}return rs, nil }剩下的事情就非常簡(jiǎn)單了,寫一個(gè) Read-Eval-Print-Loop (REPL) 讀取用戶輸入,將輸入交給上面的 Exec,再將 Exec 的輸出格式化到標(biāo)準(zhǔn)輸出,然后循環(huán)繼續(xù)讀取用戶輸入。
編譯問題
集成一個(gè)終端只是邁出了第一步,我們現(xiàn)在需要驗(yàn)證一個(gè)非常關(guān)鍵的問題:TiDB 能不能編譯到 Wasm,雖然 TiDB 是 Golang 寫的,但是中間引用的第三方庫(kù)沒準(zhǔn)哪個(gè)寫了平臺(tái)相關(guān)的代碼就沒法直接編譯了。
我們先按照 Golang 官方文檔 編譯:
圖 4 按照 Golang 官方文檔編譯(1/2)果然出師不利,查看 goleveldb 的代碼發(fā)現(xiàn),storage 包下面的代碼針對(duì)不同平臺(tái)有各自的實(shí)現(xiàn),唯獨(dú)沒有 Wasm/js 的:
圖 5 按照 Golang 官方文檔編譯(2/2)所以在 Wasm/js 環(huán)境下編譯找不到一些函數(shù)。所以這里的方案就是添加一個(gè) file_storage_js.go,然后給這些函數(shù)一個(gè) unimplemented 的實(shí)現(xiàn):
package storageimport ("os""syscall" )func newFileLock(path string, readOnly bool) (fl fileLock, err error) {return nil, syscall.ENOTSUP }func setFileLock(f *os.File, readOnly, lock bool) error {return syscall.ENOTSUP }func rename(oldpath, newpath string) error {return syscall.ENOTSUP }func isErrInvalid(err error) bool {return false }func syncDir(name string) error {return syscall.ENOTSUP }然后再次編譯:
圖 6 再次編譯的結(jié)果emm… 編譯的時(shí)候沒有函數(shù)可以說這個(gè)函數(shù)沒有 Wasm/js 對(duì)應(yīng)的版本,沒有 body 是個(gè)什么情況?好在我們有代碼可以看,到 arith_decl.go 所在的目錄看一下就知道怎么回事了:
圖 7 查看目錄然后 arith_decl.go 的內(nèi)容是一些列的函數(shù)聲明,但是具體的實(shí)現(xiàn)放到了上面的各個(gè)平臺(tái)相關(guān)的匯編文件中了。
看起來還是和剛剛一樣的情況,我們只需要為 Wasm 實(shí)現(xiàn)一套這些函數(shù)就可以了。但這里有個(gè)問題是,這是一個(gè)代碼不受我們控制的第三方庫(kù),并且 TiDB 不直接依賴這個(gè)庫(kù),而是依賴了一個(gè)叫 mathutil 的庫(kù),然后 mathutil 依賴這個(gè) bigfft。悲催的是,這個(gè) mathutil 的代碼也不受我們控制,因此很直觀的想到了兩種方案:
方案一的問題很明顯,整個(gè)周期較長(zhǎng),等作者接受 PR 了我們的 Hackathon 都涼涼了(而且還不一定會(huì)接受);方案二的問題也不小,這會(huì)導(dǎo)致我們和上游脫鉤。那么有沒有第三種方案呢,即在編譯 Wasm 的時(shí)候不依賴這兩個(gè)庫(kù),在編譯正常的二進(jìn)制文件的時(shí)候又用這兩個(gè)庫(kù)?經(jīng)過搜索發(fā)現(xiàn),我們很多代碼都用到了 mathutil,但是基本上只用了幾個(gè)函數(shù):MinUint64,MaxUint64,MinInt32,MaxInt32 等等,我們想到的方案是:
這樣,mathutil 目錄對(duì)外提供了原來 mathutil 包的函數(shù),同時(shí)整個(gè)項(xiàng)目只有 mathutil 目錄引入了這個(gè)不兼容 Wasm 的第三方包,并且只在 mathutil_linux.go 中引入(mathutil_js.go 是自己實(shí)現(xiàn)的),因此編譯 Wasm 的時(shí)候就不會(huì)再用到 mathutil 這個(gè)包。
再次編譯,成功了!
圖 8 編譯成功兼容性問題
編譯出 main.Wasm 按照 Golang 的 Wasm 文檔跑一下,由于目前是直接通過 os.Stdin 讀用戶輸入的 SQL,通過 os.Stdout 輸出結(jié)果,所以理論上頁(yè)面上會(huì)是空白的(我們還沒有操作 dom),但是由于 TiDB 的日志會(huì)打向 os.Stdout,所以在瀏覽器的控制臺(tái)上應(yīng)該能看到 TiDB 正常啟動(dòng)的日志才對(duì)。然而很遺憾看到的是異常棧:
圖 9 異常棧可以看到這個(gè)錯(cuò)是運(yùn)行時(shí)沒實(shí)現(xiàn) os.stat 操作,這是因?yàn)槟壳暗?Golang 沒有很好的支持 WASI,它僅在 wasm_exec.js 中 mock 了一個(gè) fs:
global.fs = {writeSync(fd, buf) {...},write(fd, buf, offset, length, position, callback) {...},open(path, flags, mode, callback) {...},... }而且這個(gè) mock 的 fs 并沒有實(shí)現(xiàn) stat, lstat, unlink, mkdir 之類的調(diào)用,那么解決方案就是我們?cè)趩?dòng)之前在全局的 fs 對(duì)象上 mock 一下這幾個(gè)函數(shù):
function unimplemented(callback) {const err = new Error("not implemented");err.code = "ENOSYS";callback(err); } function unimplemented1(_1, callback) { unimplemented(callback); } function unimplemented2(_1, _2, callback) { unimplemented(callback); }fs.stat = unimplemented1; fs.lstat = unimplemented1; fs.unlink = unimplemented1; fs.rmdir = unimplemented1; fs.mkdir = unimplemented2; go.run(result.instance);然后再刷新頁(yè)面,在控制臺(tái)上出現(xiàn)了久違的日志:
圖 10 日志信息到目前為止就已經(jīng)解決了 TiDB 編譯到 Wasm 的所有技術(shù)問題,剩下的工作就是找一個(gè)合適的能運(yùn)行在瀏覽器里的 SQL 終端替換掉前面寫的終端,和 TiDB 對(duì)接上就能讓用戶在頁(yè)面上輸入 SQL 并運(yùn)行起來了。
用戶接口
通過上面的工作,我們現(xiàn)在有了一個(gè) Exec 函數(shù),它接受 SQL 字符串,輸出 SQL 執(zhí)行結(jié)果,并且它可以在瀏覽器里運(yùn)行,我們還需要一個(gè)瀏覽器版本 SQL 終端和這個(gè)函數(shù)交互,兩種方案:
對(duì)于前端小白的我們來說,第二種方式成本最低,我們很快找到了 jquery.console.js 這個(gè)庫(kù),它只需要傳入一個(gè) SQL 處理的 callback 即可運(yùn)行,而我們的 Exec 簡(jiǎn)直就是為這個(gè) callback 量身打造的。
因此我們第一步工作就是把 Exec 掛到瀏覽器的 window 上(暴露到全局給 js 調(diào)用):
js.Global().Set("executeSQL", js.FuncOf(func(this js.Value, args []js.Value) interface{} {go func() {// Simplified codesql := args[0].String()args[1].Invoke(k.Exec(sql))}()return nil }))這樣就能在瀏覽器的控制臺(tái)運(yùn)行 SQL 了:
圖 11 在瀏覽器控制臺(tái)運(yùn)行 SQL然后將用 jquery.console.js 搭建一個(gè) SQL 終端,再將 executeSQL 作為 callback 傳入,大功告成:
圖 12 搭建 SQL 終端現(xiàn)在算是有一個(gè)能運(yùn)行的版本了。
本地文件訪問
還有一點(diǎn)點(diǎn)小麻煩要解決,那就是 TiDB 的 load stats 和 load data 功能。load data 語法和功能詳解可以參考 TiDB 官方文檔,其功能簡(jiǎn)單的說就是用戶指定一個(gè)文件路徑,然后客戶端將這個(gè)文件內(nèi)容傳給 TiDB,TiDB 將其加載到指定的表里。我們的問題在于,瀏覽器中是不能讀取用戶電腦上的文件的,于是我們只好在用戶執(zhí)行這個(gè)語句的時(shí)候打開瀏覽器的文件上傳窗口,讓用戶主動(dòng)選擇一個(gè)這樣的文件傳給 TiDB:
js.Global().Get("upload").Invoke(js.FuncOf(func(this js.Value, args []js.Value) interface{} {go func() {fileContent := args[0].String()_, e := doSomething(fileContent)c <- e}()return nil }), js.FuncOf(func(this js.Value, args []js.Value) interface{} {go func() {c <- errors.New(args[0].String())}()return nil }))load stats 的實(shí)現(xiàn)也是同理。
此外,我們還使用同樣的原理 “自作主張” 加入了一個(gè)新的指令:source,用戶執(zhí)行這個(gè)命令可以上傳一個(gè) SQL 文件,然后我們會(huì)執(zhí)行這個(gè)文件里的語句。我們認(rèn)為這個(gè)功能的主要使用場(chǎng)景是:用戶初次接觸 TiDB 時(shí),想驗(yàn)證其對(duì) MySQL 的兼容性,但是一條一條輸入 SQL 效率太低了,于是可以將所有用戶業(yè)務(wù)中用到的 SQL 組織到一個(gè) SQL 文件中(使用腳本或其他自動(dòng)化工具),然后在頁(yè)面上執(zhí)行 source 導(dǎo)入這個(gè)文件,驗(yàn)證結(jié)果。
以一個(gè) test.sql 文件為例,展示下 source 命令的效果,test.sql 文件內(nèi)容如下:
CREATE DATABASE IF NOT EXISTS samp_db;USE samp_db;CREATE TABLE IF NOT EXISTS person (number INT(11),name VARCHAR(255),birthday DATE );CREATE INDEX person_num ON person (number);INSERT INTO person VALUES("1","tom","20170912");UPDATE person SET birthday='20171010' WHERE name='tom';source 命令執(zhí)行之后彈出文件選擇框:
圖 13 source 命令執(zhí)行(1/2)選中 SQL 文件上傳后自動(dòng)執(zhí)行,可以對(duì)數(shù)據(jù)庫(kù)進(jìn)行相應(yīng)的修改:
圖 14 source 命令執(zhí)行(2/2)總結(jié)與展望
總的來說,這次 Hackathon 為了移植 TiDB 我們主要解決了幾個(gè)問題:
目前而言我們已經(jīng)將這個(gè)項(xiàng)目作為 TiDB Playground (https://play.pingcap.com/) 和 TiDB Tour (https://tour.pingcap.com/) 開放給用戶使用。由于它不需要用戶安裝配置就能讓用戶在閱讀文檔的同時(shí)進(jìn)行嘗試,很大程度上降低了用戶學(xué)習(xí)使用 TiDB 的成本,社區(qū)有小伙伴已經(jīng)基于這些自己做數(shù)據(jù)庫(kù)教程了,譬如:imiskolee/tidb-wasm-markdown(相關(guān)介紹文章)。
圖 15 TiDB Playground由于 Hackathon 時(shí)間比較緊張,其實(shí)很多想做的東西還沒實(shí)現(xiàn),比如:
歡迎更多感興趣的社區(qū)小伙伴們加入進(jìn)來,一起在這個(gè)項(xiàng)目上愉快的玩耍(github.com/pingcap/tidb/projects/27),也可以通過 info@pingcap.com 聯(lián)系我們。
閱讀原文:
TiDB-Wasm 原理與實(shí)現(xiàn) | Hackathon 優(yōu)秀項(xiàng)目介紹 | PingCAP?pingcap.com總結(jié)
以上是生活随笔為你收集整理的代码实现sql编译器_TiDB-Wasm 原理与实现 | Hackathon 优秀项目介绍的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python将csv文件导入mysql-
- 下一篇: 安卓ps2模拟器_安卓PSP模拟器评测: