不久前我們推送的《讓數據庫運行在瀏覽器裏?TiDB + WebAssembly 告訴你答案》,向大家展示了TiDB-Wasm的魅力:TiDB-Wasm 項目實現了將 TiDB 編譯成 Wasm 運行在瀏覽器裏,讓用戶無需安裝就可以使用TiDB。
本文將爲大家詳細介紹 TiDB-Wasm 設計與實現細節。
WebAssembly 簡介
這裏插入一些 WebAssembly 的背景知識,讓大家對這個技術有個大致的瞭解。
WebAssembly 的官方介紹是這樣的:WebAssembly(縮寫爲 Wasm)是一種爲基於堆棧的虛擬機設計的指令格式。它被設計爲 C/C++/Rust 等高級編程語言的可移植目標,可在 web 上部署客戶端和服務端應用程序。
從上面一段話我們可以得出幾個信息:
- Wasm 是一種可執行的指令格式。
- C/C++/Rust 等高級語言寫的程序可以編譯成 Wasm。
- Wasm 可以在 web(瀏覽器)環境中運行。
可執行指令格式
看到上面的三個信息我們可能又有疑問:什麼是指令格式?
我們常見的ELF 文件就是 Unix 系統上最常用的二進制指令格式,它被 loader 解析識別,加載進內存執行。同理,Wasm 也是被某種實現了 Wasm 的 runtime 識別,加載進內存執行,目前常見的實現了 Wasm runtime 的工具有各種主流瀏覽器,nodejs,以及一個專門爲 Wasm 設計的通用實現:Wasmer,甚至還有人給 Linux 內核提 feature 將 Wasm runtime 集成在內核中,這樣用戶寫的程序可以很方便的跑在內核態。
各種主流瀏覽器對 WebAssembly 的支持程度:
從高級語言到 Wasm
有了上面的背景就不難理解高級語言是如何編譯成 Wasm 的,看一下高級語言的編譯流程:
我們知道高級編程語言的特性之一就是可移植性,例如 C/C++ 既可以編譯成 x86 機器可運行的格式,也可以編譯到 ARM 上面跑,而我們的 Wasm 運行時和 ARM,x86_32 其實是同類東西,可以認爲它是一臺虛擬的機器,支持執行某種字節碼,這一點其實和 Java 非常像,實際上 C/C++ 也可以編譯到 JVM 上運行(參考:compiling-c-for-the-jvm)。
各種 runtime 以及 WASI
再囉嗦一下各種環境中運行 Wasm 的事,上面說了 Wasm 是設計爲可以在 web 中運行的程序,其實 Wasm 最初設計是爲了彌補 js 執行效率的問題,但是發展到後面發現,這玩意兒當虛擬機來移植各種程序也是很讚的,於是有了 nodejs 環境,Wasmer 環境,甚至還有內核環境。
這麼多環境就有一個問題了:各個環境支持的接口不一致。比如 nodejs 支持讀寫文件,但瀏覽器不支持,這挑戰了 Wasm 的可移植性,於是 WASI (WebAssembly System Interface) 應運而生,它定義了一套底層接口規範,只要編譯器和 Wasm 運行環境都支持這套規範,那麼編譯器生成的 Wasm 就可以在各種環境中無縫移植。如果用現有的概念來類比,Wasm runtime 相當於一臺虛擬的機器,Wasm 就是這臺機器的可執行程序,而 WASI 是運行在這臺機器上的系統,它爲 Wasm 提供底層接口(如文件操作,socket 等)。
Example or Hello World?
程序員對 Hello World 有天生的好感,爲了更好的說明 Wasm 和 WASI 是啥,我們這裏用一個 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 world\n")
;; _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 的關係類似於彙編和 ELF 的關係。
然後我們把 wat 編譯爲 Wasm 並且使用 Wasmer(一個通用的 Wasm 運行時實現)運行:
改造工作
恐懼來自未知,有了背景知識動起手來才無所畏懼,現在可以開啓 TiDB 的瀏覽器之旅。
瀏覽器安全限制
我們知道,瀏覽器本質是一個沙盒,是不會讓內部的程序做一些危險的事情的,比如監聽端口,讀寫文件。而 TiDB 的使用場景實際是用戶啓動一個客戶端通過 MySQL 協議連接到 TiDB,這要求 TiDB 必須監聽某個端口。
考慮片刻之後,我們認爲即便克服了瀏覽器沙盒這個障礙,真讓用戶用 MySQL 客戶端去連瀏覽器也並不是一個優雅的事情,我們希望的是用戶在頁面上可以有一個開箱即用的 MySQL 終端,它已經連接好了 TiDB。
於是我們第一件事是給 TiDB 集成一個終端,讓它啓動後直接彈出這個終端接受用戶輸入 SQL。所以我們需要在 TiDB 的代碼中找到一個工具,它的輸入是一串 SQL,輸出是 SQL 的執行結果,寫一個這樣的東西對於我們幾個沒接觸過 TiDB 代碼的人來說還是有些難度,於是我們想到了一個捷徑:TiDB 的測試代碼中肯定會有輸入 SQL 然後檢查輸出的測試。那麼把這種測試搬過來改一改不就是我們想要的東西嘛?然後我們翻了翻 TiDB 的測試代碼,發現了大量的這樣的用法:
result = tk.MustQuery("select count(*) from t group by d order by c")
result.Check(testkit.Rows("3", "2", "2"))
所以我們只需要看看這個 tk
是個什麼東西,借來用一下就行了。這是 tk
的主要函數:
// Exec executes a sql statement.
func (tk *TestKit) Exec(sql string, args ...interface{}) (sqlexec.RecordSet, error) {
var err error
if 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.RecordSet
rss, 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
}
剩下的事情就非常簡單了,寫一個 Read-Eval-Print-Loop (REPL) 讀取用戶輸入,將輸入交給上面的 Exec,再將 Exec 的輸出格式化到標準輸出,然後循環繼續讀取用戶輸入。
編譯問題
集成一個終端只是邁出了第一步,我們現在需要驗證一個非常關鍵的問題:TiDB 能不能編譯到 Wasm,雖然 TiDB 是 Golang 寫的,但是中間引用的第三方庫沒準哪個寫了平臺相關的代碼就沒法直接編譯了。
我們先按照Golang 官方文檔編譯:
果然出師不利,查看 goleveldb 的代碼發現,storage 包下面的代碼針對不同平臺有各自的實現,唯獨沒有 Wasm/js 的:
所以在 Wasm/js 環境下編譯找不到一些函數。所以這裏的方案就是添加一個 file_storage_js.go
,然後給這些函數一個 unimplemented 的實現:
package storage
import (
"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
}
然後再次編譯:
emm… 編譯的時候沒有函數可以說這個函數沒有 Wasm/js 對應的版本,沒有 body 是個什麼情況?好在我們有代碼可以看,到 arith_decl.go
所在的目錄看一下就知道怎麼回事了:
然後 arith_decl.go
的內容是一些列的函數聲明,但是具體的實現放到了上面的各個平臺相關的彙編文件中了。
看起來還是和剛剛一樣的情況,我們只需要爲 Wasm 實現一套這些函數就可以了。但這裏有個問題是,這是一個代碼不受我們控制的第三方庫,並且 TiDB 不直接依賴這個庫,而是依賴了一個叫 mathutil
的庫,然後 mathutil
依賴這個 bigfft
。悲催的是,這個 mathutil
的代碼也不受我們控制,因此很直觀的想到了兩種方案:
- 給這兩個庫的作者提 PR,讓他們支持 Wasm。
- 我們將這兩個庫 clone 過來改掉,然後把 TiDB 依賴改到我們 clone 過來的庫上。
方案一的問題很明顯,整個週期較長,等作者接受 PR 了我們的 Hackathon 都涼涼了(而且還不一定會接受);方案二的問題也不小,這會導致我們和上游脫鉤。那麼有沒有第三種方案呢,即在編譯 Wasm 的時候不依賴這兩個庫,在編譯正常的二進制文件的時候又用這兩個庫?經過搜索發現,我們很多代碼都用到了 mathutil
,但是基本上只用了幾個函數:MinUint64
,MaxUint64
,MinInt32
,MaxInt32
等等,我們想到的方案是:
- 新建一個
mathutil
目錄,在這個目錄裏建立mathutil_linux.go
和mathutil_js.go
。 - 在
mathutil_linux.go
中 reexport 第三方包的幾個函數。 - 在
mathutil_js.go
中自己實現這幾個函數,不依賴第三方包。 - 將所有對第三方的依賴改到
mathutil
目錄上。
這樣,mathutil
目錄對外提供了原來 mathutil
包的函數,同時整個項目只有 mathutil
目錄引入了這個不兼容 Wasm 的第三方包,並且只在 mathutil_linux.go
中引入(mathutil_js.go
是自己實現的),因此編譯 Wasm 的時候就不會再用到 mathutil
這個包。
再次編譯,成功了!
兼容性問題
編譯出 main.Wasm 按照 Golang 的 Wasm 文檔跑一下,由於目前是直接通過 os.Stdin 讀用戶輸入的 SQL,通過 os.Stdout 輸出結果,所以理論上頁面上會是空白的(我們還沒有操作 dom),但是由於 TiDB 的日誌會打向 os.Stdout,所以在瀏覽器的控制檯上應該能看到 TiDB 正常啓動的日誌纔對。然而很遺憾看到的是異常棧:
可以看到這個錯是運行時沒實現 os.stat 操作,這是因爲目前的 Golang 沒有很好的支持 WASI,它僅在 wasm_exec.js
中 mock 了一個 fs
:
global.fs = {
writeSync(fd, buf) {
...
},
write(fd, buf, offset, length, position, callback) {
...
},
open(path, flags, mode, callback) {
...
},
...
}
而且這個 mock 的 fs
並沒有實現 stat
, lstat
, unlink
, mkdir
之類的調用,那麼解決方案就是我們在啓動之前在全局的 fs
對象上 mock 一下這幾個函數:
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);
然後再刷新頁面,在控制檯上出現了久違的日誌:
到目前爲止就已經解決了 TiDB 編譯到 Wasm 的所有技術問題,剩下的工作就是找一個合適的能運行在瀏覽器裏的 SQL 終端替換掉前面寫的終端,和 TiDB 對接上就能讓用戶在頁面上輸入 SQL 並運行起來了。
用戶接口
通過上面的工作,我們現在有了一個 Exec 函數,它接受 SQL 字符串,輸出 SQL 執行結果,並且它可以在瀏覽器裏運行,我們還需要一個瀏覽器版本 SQL 終端和這個函數交互,兩種方案:
- 使用 Golang 直接操作 dom 來實現這個終端。
- 在 Golang 中把 Exec 暴露到全局,然後找一個現成的 js 版本的終端和這個全局的 Exec 對接。
對於前端小白的我們來說,第二種方式成本最低,我們很快找到了 jquery.console.js 這個庫,它只需要傳入一個 SQL 處理的 callback 即可運行,而我們的 Exec 簡直就是爲這個 callback 量身打造的。
因此我們第一步工作就是把 Exec 掛到瀏覽器的 window 上(暴露到全局給 js 調用):
js.Global().Set("executeSQL", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
go func() {
// Simplified code
sql := args[0].String()
args[1].Invoke(k.Exec(sql))
}()
return nil
}))
這樣就能在瀏覽器的控制檯運行 SQL 了:
然後將用 jquery.console.js 搭建一個 SQL 終端,再將 executeSQL 作爲 callback 傳入,大功告成:
現在算是有一個能運行的版本了。
本地文件訪問
還有一點點小麻煩要解決,那就是 TiDB 的 load stats 和 load data 功能。load data 語法和功能詳解可以參考TiDB 官方文檔,其功能簡單的說就是用戶指定一個文件路徑,然後客戶端將這個文件內容傳給 TiDB,TiDB 將其加載到指定的表裏。我們的問題在於,瀏覽器中是不能讀取用戶電腦上的文件的,於是我們只好在用戶執行這個語句的時候打開瀏覽器的文件上傳窗口,讓用戶主動選擇一個這樣的文件傳給 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 的實現也是同理。
此外,我們還使用同樣的原理 “自作主張” 加入了一個新的指令:source,用戶執行這個命令可以上傳一個 SQL 文件,然後我們會執行這個文件裏的語句。我們認爲這個功能的主要使用場景是:用戶初次接觸 TiDB 時,想驗證其對 MySQL 的兼容性,但是一條一條輸入 SQL 效率太低了,於是可以將所有用戶業務中用到的 SQL 組織到一個 SQL 文件中(使用腳本或其他自動化工具),然後在頁面上執行 source 導入這個文件,驗證結果。
以一個 test.sql 文件爲例,展示下 source 命令的效果,test.sql 文件內容如下:
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 命令執行之後彈出文件選擇框:
選中 SQL 文件上傳後自動執行,可以對數據庫進行相應的修改:
總結與展望
總的來說,這次 Hackathon 爲了移植 TiDB 我們主要解決了幾個問題:
- 瀏覽器中無法監聽端口,我們給 TiDB 嵌入了一個 SQL 終端。
- goleveldb 對 Wasm 的兼容問題。
- bigfft 的 Wasm 兼容問題。
- Golang 自身對 WASI 支持不完善導致的 fs 相關函數缺失。
- TiDB 對本地文件加載轉換爲瀏覽器上傳文件方式加載。
- 支持 source 命令批量執行 SQL。
目前而言我們已經將這個項目作爲 TiDB Playground (https://play.pingcap.com/) 和 TiDB Tour (https://tour.pingcap.com/) 開放給用戶使用。由於它不需要用戶安裝配置就能讓用戶在閱讀文檔的同時進行嘗試,很大程度上降低了用戶學習使用 TiDB 的成本,社區有小夥伴已經基於這些自己做數據庫教程了,譬如:imiskolee/tidb-wasm-markdown(相關介紹文章)。