讓數據庫運行在瀏覽器裏?TiDB-Wasm 原理與實現 | Hackathon 優秀項目介紹

做者:Ti-Coolhtml

上週咱們推送了《 讓數據庫運行在瀏覽器裏?TiDB + WebAssembly 告訴你答案》,向你們展現了 TiDB-Wasm 的魅力:TiDB-Wasm 項目是 TiDB Hackathon 2019 中誕生的二等獎項目,實現了將 TiDB 編譯成 Wasm 運行在瀏覽器裏,讓用戶無需安裝就可使用 TiDB。

本文由 Ti-Cool 隊成員主筆,爲你們詳細介紹 TiDB-Wasm 設計與實現細節。前端

10 月 27 日,爲期兩天的 Hackathon 落下帷幕,咱們用一枚二等獎爲這次上海之行畫上了圓滿的句號,不枉咱們風塵僕僕跑去異地參賽(強烈期待明年杭州能做爲賽場,主辦方也該鼓勵鼓勵杭州當地的小夥伴呀 :D )。node

咱們幾個 PingCAP 的小夥伴找到了 Tony 同窗一塊兒組隊,組隊以後找了一個週末進行了「祕密會晤」——Hackathon kick off。想了 N 個 idea,包括使用 unikernel 技術將 TiDB 直接跑在裸機上,或者將網絡協議棧作到用戶態以提高 TiDB 集羣性能,亦或是使用異步 io 技術提高 TiKV 的讀寫能力,這些都被一一否決,緣由是這些 idea 不是和 Tony 的工做內容相關,就是和咱們 PingCAP 小夥伴的平常工做相關,作這些至關於咱們在 Hackathon 加了兩天班,這一點都不酷。本着「與工做無關」的標準,咱們想了一個 idea:把 TiDB 編譯成 Wasm 運行在瀏覽器裏,讓用戶無需安裝就可使用 TiDB。咱們一致認爲這很酷,因而給隊伍命名爲 Ti-Cool(太酷了)。jquery

WebAssembly 簡介

這裏插入一些 WebAssembly 的背景知識,讓你們對這個技術有個大體的瞭解。linux

WebAssembly 的 官方介紹 是這樣的:WebAssembly(縮寫爲 Wasm)是一種爲基於堆棧的虛擬機設計的指令格式。它被設計爲 C/C++/Rust 等高級編程語言的可移植目標,可在 web 上部署客戶端和服務端應用程序。git

從上面一段話咱們能夠得出幾個信息:程序員

  1. Wasm 是一種可執行的指令格式。
  2. C/C++/Rust 等高級語言寫的程序能夠編譯成 Wasm。
  3. Wasm 能夠在 web(瀏覽器)環境中運行。

可執行指令格式

看到上面的三個信息咱們可能又有疑問:什麼是指令格式?github

咱們常見的 ELF 文件 就是 Unix 系統上最經常使用的二進制指令格式,它被 loader 解析識別,加載進內存執行。同理,Wasm 也是被某種實現了 Wasm 的 runtime 識別,加載進內存執行,目前常見的實現了 Wasm runtime 的工具備各類主流瀏覽器,nodejs,以及一個專門爲 Wasm 設計的通用實現:Wasmer,甚至還有人給 Linux 內核提 feature 將 Wasm runtime 集成在內核中,這樣用戶寫的程序能夠很方便的跑在內核態。golang

各類主流瀏覽器對 WebAssembly 的支持程度:web

<center>圖 1 主流瀏覽器對 WebAssembly 的支持程度</center>

從高級語言到 Wasm

有了上面的背景就不難理解高級語言是如何編譯成 Wasm 的,看一下高級語言的編譯流程:

<center>圖 2 高級語言編譯流程</center>

咱們知道高級編程語言的特性之一就是可移植性,例如 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 運行時實現)運行:

<center>圖 3 Hello World</center>

改造工做

恐懼來自未知,有了背景知識動起手來才無所畏懼,如今能夠開啓 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 官方文檔 編譯:

<center>圖 4 按照 Golang 官方文檔編譯(1/2)</center>

果真出師不利,查看 goleveldb 的代碼發現,storage 包下面的代碼針對不一樣平臺有各自的實現,惟獨沒有 Wasm/js 的:

<center>圖 5 按照 Golang 官方文檔編譯(2/2)</center>

因此在 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
}

而後再次編譯:

<center>圖 6 再次編譯的結果</center>

emm… 編譯的時候沒有函數能夠說這個函數沒有 Wasm/js 對應的版本,沒有 body 是個什麼狀況?好在咱們有代碼能夠看,到 arith_decl.go 所在的目錄看一下就知道怎麼回事了:

<center>圖 7 查看目錄</center>

而後 arith_decl.go 的內容是一些列的函數聲明,可是具體的實現放到了上面的各個平臺相關的彙編文件中了。

看起來仍是和剛剛同樣的狀況,咱們只須要爲 Wasm 實現一套這些函數就能夠了。但這裏有個問題是,這是一個代碼不受咱們控制的第三方庫,而且 TiDB 不直接依賴這個庫,而是依賴了一個叫 mathutil 的庫,而後 mathutil 依賴這個 bigfft。悲催的是,這個 mathutil 的代碼也不受咱們控制,所以很直觀的想到了兩種方案:

  1. 給這兩個庫的做者提 PR,讓他們支持 Wasm。
  2. 咱們將這兩個庫 clone 過來改掉,而後把 TiDB 依賴改到咱們 clone 過來的庫上。

方案一的問題很明顯,整個週期較長,等做者接受 PR 了咱們的 Hackathon 都涼涼了(並且還不必定會接受);方案二的問題也不小,這會致使咱們和上游脫鉤。那麼有沒有第三種方案呢,即在編譯 Wasm 的時候不依賴這兩個庫,在編譯正常的二進制文件的時候又用這兩個庫?通過搜索發現,咱們不少代碼都用到了 mathutil,可是基本上只用了幾個函數:MinUint64MaxUint64MinInt32MaxInt32 等等,咱們想到的方案是:

  1. 新建一個 mathutil 目錄,在這個目錄裏創建 mathutil_linux.gomathutil_js.go
  2. mathutil_linux.go 中 reexport 第三方包的幾個函數。
  3. mathutil_js.go 中本身實現這幾個函數,不依賴第三方包。
  4. 將全部對第三方的依賴改到 mathutil 目錄上。

這樣,mathutil 目錄對外提供了原來 mathutil 包的函數,同時整個項目只有 mathutil 目錄引入了這個不兼容 Wasm 的第三方包,而且只在 mathutil_linux.go 中引入(mathutil_js.go 是本身實現的),所以編譯 Wasm 的時候就不會再用到 mathutil 這個包。

再次編譯,成功了!

<center>圖 8 編譯成功</center>

兼容性問題

編譯出 main.Wasm 按照 Golang 的 Wasm 文檔跑一下,因爲目前是直接經過 os.Stdin 讀用戶輸入的 SQL,經過 os.Stdout 輸出結果,因此理論上頁面上會是空白的(咱們尚未操做 dom),可是因爲 TiDB 的日誌會打向 os.Stdout,因此在瀏覽器的控制檯上應該能看到 TiDB 正常啓動的日誌纔對。然而很遺憾看到的是異常棧:

<center>圖 9 異常棧</center>

能夠看到這個錯是運行時沒實現 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);

而後再刷新頁面,在控制檯上出現了久違的日誌:

<center>圖 10 日誌信息</center>

到目前爲止就已經解決了 TiDB 編譯到 Wasm 的全部技術問題,剩下的工做就是找一個合適的能運行在瀏覽器裏的 SQL 終端替換掉前面寫的終端,和 TiDB 對接上就能讓用戶在頁面上輸入 SQL 並運行起來了。

用戶接口

經過上面的工做,咱們如今有了一個 Exec 函數,它接受 SQL 字符串,輸出 SQL 執行結果,而且它能夠在瀏覽器裏運行,咱們還須要一個瀏覽器版本 SQL 終端和這個函數交互,兩種方案:

  1. 使用 Golang 直接操做 dom 來實現這個終端。
  2. 在 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 了:

<center>圖 11 在瀏覽器控制檯運行 SQL</center>

而後將用 jquery.console.js 搭建一個 SQL 終端,再將 executeSQL 做爲 callback 傳入,大功告成:

<center>圖 12 搭建 SQL 終端</center>

如今算是有一個能運行的版本了。

本地文件訪問

還有一點點小麻煩要解決,那就是 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 命令執行以後彈出文件選擇框:

<center>圖 13 source 命令執行(1/2)</center>

選中 SQL 文件上傳後自動執行,能夠對數據庫進行相應的修改:

<center>圖 14 source 命令執行(2/2)</center>

總結與展望

總的來講,此次 Hackathon 爲了移植 TiDB 咱們主要解決了幾個問題:

  1. 瀏覽器中沒法監聽端口,咱們給 TiDB 嵌入了一個 SQL 終端。
  2. goleveldb 對 Wasm 的兼容問題。
  3. bigfft 的 Wasm 兼容問題。
  4. Golang 自身對 WASI 支持不完善致使的 fs 相關函數缺失。
  5. TiDB 對本地文件加載轉換爲瀏覽器上傳文件方式加載。
  6. 支持 source 命令批量執行 SQL。

目前而言咱們已經將這個項目做爲 TiDB Playground (https://play.pingcap.com/) 和 TiDB Tour (https://tour.pingcap.com/) 開放給用戶使用。因爲它不須要用戶安裝配置就能讓用戶在閱讀文檔的同時進行嘗試,很大程度上下降了用戶學習使用 TiDB 的成本,社區有小夥伴已經基於這些本身作數據庫教程了,譬如:imiskolee/tidb-wasm-markdown相關介紹文章)。

<center>圖 15 TiDB Playground</center>

因爲 Hackathon 時間比較緊張,其實不少想作的東西還沒實現,好比:

  1. 使用 indexedDB 讓數據持久化:須要針對 indexedDB 實現一套 Storage 的 interface。
  2. 使用 P2P 技術(如 webrtc)對其餘瀏覽器提供服務:將來一定會有愈來愈多的應用遷移到 Wasm,而不少應用是須要數據庫的,TiDB-Wasm 剛好能夠扮演這樣的角色。
  3. 給 TiDB 的 Wasm 二進制文件瘦身:目前編譯出來的二進制文件有將近 80M,對瀏覽器不太友好,同時運行時佔用內存也比較多。

歡迎更多感興趣的社區小夥伴們加入進來,一塊兒在這個項目上愉快的玩耍(github.com/pingcap/tidb/projects/27),也能夠經過 info@pingcap.com 聯繫咱們。

原文閱讀https://pingcap.com/blog-cn/tidb-wasm-introduction/

相關文章
相關標籤/搜索