剝開比原看代碼08:比原的Dashboard是怎麼作出來的?

做者:freewind前端

比原項目倉庫:node

Github地址:https://github.com/Bytom/bytomwebpack

Gitee地址:https://gitee.com/BytomBlockc...git

在前面的幾篇文章中,咱們一直在研究如何與一個比原節點創建鏈接,而且從它那裏請求區塊數據。然而我很快就遇到了瓶頸。github

由於當我處理拿到的區塊數據時,發現我已經觸及到了比原鏈的核心,即區塊鏈的數據結構以及分叉的處理。若是不能徹底理解這一塊,就沒有辦法正確的處理區塊數據。然而它涉及的內容太多了,在短期以內把它理解透徹是一件很是困難的事情。golang

以前個人作法就好像我想了解一個城市,因而沿着一條路從外圍向市中心進發。前面一直很順利,但等到了市中心時,發現這裏人多路雜,有點迷失了。在這種狀況下,我以爲我應該暫停研究核心,而是從另一條路開始,由外向內再來一遍。由於在行進的過程當中,我能夠慢慢的積累更多的知識,讓本身處於學習區而非恐慌區。這條路的終點也將是觸及到核心,可是不深刻進去。這樣的話,等我多走了幾條路以後,積累的知識夠了,再研究核心就不會以爲迷茫了。web

因此本文原本是想去研究一下,當別的節點把區塊數據發給咱們以後,咱們應該怎麼處理,如今換成研究比原的Dashboard是怎麼作出來的。爲何選擇這個呢?由於它很是以一種很是直觀的方式,展現了比原向咱們提供的各類信息和功能。在本文中,咱們並不過多的講解它上面的功能,而是把關注點放在比原究竟是如何在代碼層面上實現了這樣的一個Dashboard。它上面的功能,將會在之後慢慢研究。npm

咱們今天的問題是「比原的Dashboard是怎麼作出來的」,可是這個問題有點大,而且不夠具體,因此咱們仍是跟之前同樣,先來把它細分一下:json

  1. 咱們怎樣在比原中啓用Dashboard功能?
  2. Dashboard中提供了哪些信息和功能?
  3. 比原是如何實現了http服務器?
  4. Dashboard使用了什麼樣的前端框架?
  5. Dashboard上面的數據,是以什麼樣的方式從後臺拿到的?

咱們下面開始一一探討。bootstrap

咱們怎樣在比原中啓用Dashboard功能?

當咱們使用bytomd node啓動比原節點的時候,不須要任何配置,它就會自動啓用Dashboard功能,而且會在瀏覽器中打開頁面,很是方便。

若是是第一次運行,尚未建立過賬戶,它會提示咱們建立一個賬戶及相關的私鑰:

咱們能夠經過填寫賬戶別名、密鑰別名和相應的密碼來建立,或者點擊下面的"Restore wallet"來恢復以前的賬號(若是以前備份過的話):

點擊"Register"後,就會建立成功,並進入管理頁面:

注意它的地址是:http://127.0.0.1:9888/dashboard

若是咱們查看配置文件config.toml,能夠在其中看到它的身影:

fast_sync = true
db_backend = "leveldb"
api_addr = "0.0.0.0:9888"
chain_id = "solonet"
[p2p]
laddr = "tcp://0.0.0.0:46658"
seeds = ""

注意其中的api_addr,就是dashboard以及web-api的地址。比原在啓動以後,其BaseConfig.ApiAddress會從配置文件中取到相應的值:

config/config.go#L41-L85

type BaseConfig struct {
    // ...
    ApiAddress string `mapstructure:"api_addr"`
    // ...
}

而後在啓動時,比原的web api以及dashboard會使用該地址,而且在瀏覽器中打開dashboard。

然而此處有一個奇怪的問題,就是不論這裏的值是什麼,瀏覽器老是打開http://localhost:9888這個地址。爲何呢?由於它寫死在了代碼中。

在代碼中,http://localhost:9888一共出如今了三個地方,一個是用來表示dashboard的訪問地址,位於node/node.go中:

node/node.go#L33-L37

const (
    webAddress               = "http://127.0.0.1:9888"
    expireReservationsPeriod = time.Second
    maxNewBlockChSize        = 1024
)

這裏的webAddress,只在從代碼中打開瀏覽器顯示dashboard時使用:

node/node.go#L153-L159

func lanchWebBroser() {
    log.Info("Launching System Browser with :", webAddress)
    if err := browser.Open(webAddress); err != nil {
        log.Error(err.Error())
        return
    }
}

比原經過"github.com/toqueteos/webbrowser"這個第三方的庫,能夠在節點啓動的時候,調用系統默認的瀏覽器,並打開指定的網址,方便了用戶。(注意這段代碼中有很多錯別字,好比lanchbroser,已在後續版本中修正了)

另外一個地方,是用於bytomcli這個命令行工具的,只是奇怪的是它放在了util/util.go下面:

util/util.go#L26-L28

var (
    coreURL = env.String("BYTOM_URL", "http://localhost:9888")
)

爲何說它是屬於bytomcli的呢?由於這個coreURL最終被用在util包下的一個ClientCall(...)函數中,用於從代碼中向指定的web api發送請求,並使用其回覆信息。可是這個方法在bytomcli所在的包使用。若是是這樣的話,coreURL及相關的函數,應該移到bytomcli包裏纔對。

第三個地方,跟第二個很是像,可是位於tools/sendbulktx/core/util.go中,它是用於另外一個命令行工具sendbulktx的:

tools/sendbulktx/core/util.go#L26-L28

var (
    coreURL = env.String("BYTOM_URL", "http://localhost:9888")
)

如出一轍,對吧。其實不光是這裏,還有一堆相關的方法和函數,也是如出一轍的,一看就是跟第二處互相複製過來的。

關於這裏的問題,我提了兩個issue:

  • dashboard和web api的地址寫在配置文件config.toml中,可是同時寫死在代碼中:這裏在實現上的確是有必定難度的,緣由是在配置文件中,寫的是0.0.0.0:9998,可是從瀏覽器或者命令行工具中去訪問時,須要使用一個具體的ip(而不是0.0.0.0),不然某些功能會不正常。另外,在後面的代碼分析處會看到,除了配置文件中的這個地址,比原還會優先從環境變量中取得LISTEN所對應的地址web api的地址。因此這裏須要更多的研究才能正確修復。
  • 與讀取webapi相關的代碼出現大量重複:官方解釋說sendbulktx這個工具在將來將從bytom項目中獨立出去,因此代碼是重複的,若是是這樣的話,能夠接受。

Dashboard中提供了哪些信息和功能?

下面咱們快速過一遍比原的Dashboard提供了哪些信息和功能。因爲在本文中,咱們關注的重點不是這些具體的功能,因此會不會細究。另外,前面剛建立好的賬號裏,不少數據都是沒有的,爲了展現方便,我事先作了一些數據。

首先是密鑰:

這裏顯示了當前有幾個密鑰,其別名是什麼,而且顯示出來了主公鑰。咱們能夠點擊右上角的「新建」按鈕建立多個密鑰,可是這裏再也不展現。

賬戶:

資產:

默認只定義了BTM這一種資產,能夠經過「新建」按鈕增長多種資產。

餘額:

看起來我仍是至關有錢的(惋惜不能用)。

交易:

展現了多筆交易,其實是在本機挖礦挖出來的。因爲挖礦出來的BTM是由系統直接轉到咱們的賬戶上的,因此也能夠看做是一種交易。

建立交易:

咱們也能夠像這樣本身建立交易,把咱們持有的某種資產(好比BTM)轉到另外一個地址。

未花費輸出:

簡單的理解就是與我相關的每一筆交易都被記錄下來,有輸入和輸出部分,其中的輸出可能又是另外一個交易的輸入。這裏顯示的是尚未花費掉的輸出(能夠根據它來計算我當前到底還剩下多少餘額)

查看核心狀態:

定義訪問控制:

備份和還原操做:

另外每一個頁面左側欄的下面,還有關於鏈接的鏈的類型(此處爲solonet),以及同步狀況和與當前節點鏈接的其它節點數。

這裏展現的信息和功能咱們還不須要細究,可是這裏出現的名詞倒是要留意的,由於它們都是比原的核心概念。等咱們之後研究比原內部區塊鏈核心功能的時候,實際上都是圍繞着它們來的。這裏的每個概念,可能都須要一到多篇文章專門討論。

咱們在今天關注的是技術實現層面,下面咱們要開始進入代碼時間了。

比原是如何實現了http服務器?

首先讓咱們從比原節點啓動開始,一直找到啓動http服務的地方:

cmd/bytomd/main.go#L54-L57

func main() {
    cmd := cli.PrepareBaseCmd(commands.RootCmd, "TM", os.ExpandEnv(config.DefaultDataDir()))
    cmd.Execute()
}

cmd/bytomd/commands/run_node.go#L41-L54

func runNode(cmd *cobra.Command, args []string) error {
    // Create & start node
    n := node.NewNode(config)
    if _, err := n.Start(); err != nil {
    // ..
}

node/node.go#L169-L180

func (n *Node) OnStart() error {
    // ...
    n.initAndstartApiServer()
    // ...
}

很快找到了,initAndstartApiServer

node/node.go#L161-L167

func (n *Node) initAndstartApiServer() {
    // 1.
    n.api = api.NewAPI(n.syncManager, n.wallet, n.txfeed, n.cpuMiner, n.miningPool, n.chain, n.config, n.accessTokens)

    // 2. 
    listenAddr := env.String("LISTEN", n.config.ApiAddress)
    env.Parse()

    // 3.
    n.api.StartServer(*listenAddr)
}

能夠看到,該方法分紅了三部分:

  1. 經過傳入大量的參數,來構造一個API對象。進去後會看到大量的與url相關的配置。
  2. 先從環境中取得LISTEN對應的值,若是沒有的話,再使用config.toml中指定的api_addr值,做爲api服務的入口地址
  3. 真正啓動服務

因爲2比較簡單,因此咱們下面將仔細分析1和3.

先找到1處所對應的api.NewAPI方法:

api/api.go#L143-L157

func NewAPI(sync *netsync.SyncManager, wallet *wallet.Wallet, txfeeds *txfeed.Tracker, cpuMiner *cpuminer.CPUMiner, miningPool *miningpool.MiningPool, chain *protocol.Chain, config *cfg.Config, token *accesstoken.CredentialStore) *API {
    api := &API{
        sync:          sync,
        wallet:        wallet,
        chain:         chain,
        accessTokens:  token,
        txFeedTracker: txfeeds,
        cpuMiner:      cpuMiner,
        miningPool:    miningPool,
    }
    api.buildHandler()
    api.initServer(config)

    return api
}

它主要就是把傳進來的各參數拿住,供後面使用。而後就是api.buildHandler來配置各個功能點的路徑和處理函數,以及用api.initServer來初始化服務。

進入api.buildHandler()。這個方法有點長,把它分紅幾部分來說解:

api/api.go#L164-L244

func (a *API) buildHandler() {
    walletEnable := false
    m := http.NewServeMux()

看來http服務使用的是Go自帶的http包。

向下是,當用戶的錢包功能沒有禁用的話,就會配置與錢包相關的各功能點(好比賬號、交易、密鑰等):

if a.wallet != nil {
        walletEnable = true

        m.Handle("/create-account", jsonHandler(a.createAccount))
        m.Handle("/list-accounts", jsonHandler(a.listAccounts))
        m.Handle("/delete-account", jsonHandler(a.deleteAccount))

        m.Handle("/create-account-receiver", jsonHandler(a.createAccountReceiver))
        m.Handle("/list-addresses", jsonHandler(a.listAddresses))
        m.Handle("/validate-address", jsonHandler(a.validateAddress))

        m.Handle("/create-asset", jsonHandler(a.createAsset))
        m.Handle("/update-asset-alias", jsonHandler(a.updateAssetAlias))
        m.Handle("/get-asset", jsonHandler(a.getAsset))
        m.Handle("/list-assets", jsonHandler(a.listAssets))

        m.Handle("/create-key", jsonHandler(a.pseudohsmCreateKey))
        m.Handle("/list-keys", jsonHandler(a.pseudohsmListKeys))
        m.Handle("/delete-key", jsonHandler(a.pseudohsmDeleteKey))
        m.Handle("/reset-key-password", jsonHandler(a.pseudohsmResetPassword))

        m.Handle("/build-transaction", jsonHandler(a.build))
        m.Handle("/sign-transaction", jsonHandler(a.pseudohsmSignTemplates))
        m.Handle("/submit-transaction", jsonHandler(a.submit))
        m.Handle("/estimate-transaction-gas", jsonHandler(a.estimateTxGas))

        m.Handle("/get-transaction", jsonHandler(a.getTransaction))
        m.Handle("/list-transactions", jsonHandler(a.listTransactions))

        m.Handle("/list-balances", jsonHandler(a.listBalances))
        m.Handle("/list-unspent-outputs", jsonHandler(a.listUnspentOutputs))

        m.Handle("/backup-wallet", jsonHandler(a.backupWalletImage))
        m.Handle("/restore-wallet", jsonHandler(a.restoreWalletImage))
    } else {
        log.Warn("Please enable wallet")
    }

錢包功能默認是啓用的,用戶如何才能禁用它呢?方法是在配置文件config.toml中,加上這一節代碼:

[wallet]
disable = true

在前面的代碼中,在配置功能點時,使用了大量的m.Handle("/create-account", jsonHandler(a.createAccount))這樣的代碼,它是什麼意思呢?

  1. /create-account:該功能的路徑,好比對於這個,用戶須要在瀏覽器或者命令行中,使用地址http://localhost:9888/create-account來訪問
  2. a.createAccount:用於處理用戶的訪問,好比拿到用戶提供的數據,處理完後再返回某個數據給用戶,會在下面詳解
  3. jsonHandler:是一箇中間層,把用戶發送的JSON數據轉成第2步handler須要的Go類型參數,或者把2返回的Go數據轉成JSON給用戶
  4. m.Handle(path, handler):用來把功能點路徑和相應的處理函數對應起來

這裏先看第3步中的jsonHandler的代碼:

api/api.go#L259-L265

func jsonHandler(f interface{}) http.Handler {
    h, err := httpjson.Handler(f, errorFormatter.Write)
    if err != nil {
        panic(err)
    }
    return h
}

它裏面用到了httpjson,它是比原代碼中提供的一個包,位於net/http/httpjson 。它的功能主要是爲了在http訪問與Go的函數之間增長了一層轉換。一般用戶經過http與api交互的時候,發送和接收的都是JSON數據,而咱們在第2步的handler中定義的是Go函數,經過httpjson,能夠在二者之間自動轉換,使得咱們在寫Go代碼的時候,不須要考慮JSON以及http協議相關的問題。相應的,爲了與jsonhttp配合使用,第2步中的handler在格式上也會有一些要求,詳情可參見這裏的詳細註釋:net/http/httpjson/doc.go#L3-L40 。因爲httpjson所涉及的代碼還比較多,這裏就不詳述,之後有機會專開一篇。

而後咱們再看第2步的a.createAccount的代碼:

api/accounts.go#L16-L30

func (a *API) createAccount(ctx context.Context, ins struct {
    RootXPubs []chainkd.XPub `json:"root_xpubs"`
    Quorum    int            `json:"quorum"`
    Alias     string         `json:"alias"`
}) Response {
    acc, err := a.wallet.AccountMgr.Create(ctx, ins.RootXPubs, ins.Quorum, ins.Alias)
    if err != nil {
        return NewErrorResponse(err)
    }

    annotatedAccount := account.Annotated(acc)
    log.WithField("account ID", annotatedAccount.ID).Info("Created account")

    return NewSuccessResponse(annotatedAccount)
}

這個函數的內容咱們在這裏不細究,須要注意的反而是它的格式,由於前面說了,它須要跟jsonHandler配合使用。格式的要求大概就是,第一個參數是Context,第二個參數是能夠從JSON數據轉換過來的參數,返回值是一個Response以及一個Error,可是這四個又所有是可選的。

讓咱們回到api.buildHandler(),繼續往下:

m.Handle("/", alwaysError(errors.New("not Found")))
    m.Handle("/error", jsonHandler(a.walletError))

    m.Handle("/create-access-token", jsonHandler(a.createAccessToken))
    m.Handle("/list-access-tokens", jsonHandler(a.listAccessTokens))
    m.Handle("/delete-access-token", jsonHandler(a.deleteAccessToken))
    m.Handle("/check-access-token", jsonHandler(a.checkAccessToken))

    m.Handle("/create-transaction-feed", jsonHandler(a.createTxFeed))
    m.Handle("/get-transaction-feed", jsonHandler(a.getTxFeed))
    m.Handle("/update-transaction-feed", jsonHandler(a.updateTxFeed))
    m.Handle("/delete-transaction-feed", jsonHandler(a.deleteTxFeed))
    m.Handle("/list-transaction-feeds", jsonHandler(a.listTxFeeds))

    m.Handle("/get-unconfirmed-transaction", jsonHandler(a.getUnconfirmedTx))
    m.Handle("/list-unconfirmed-transactions", jsonHandler(a.listUnconfirmedTxs))

    m.Handle("/get-block-hash", jsonHandler(a.getBestBlockHash))
    m.Handle("/get-block-header", jsonHandler(a.getBlockHeader))
    m.Handle("/get-block", jsonHandler(a.getBlock))
    m.Handle("/get-block-count", jsonHandler(a.getBlockCount))
    m.Handle("/get-difficulty", jsonHandler(a.getDifficulty))
    m.Handle("/get-hash-rate", jsonHandler(a.getHashRate))

    m.Handle("/is-mining", jsonHandler(a.isMining))
    m.Handle("/set-mining", jsonHandler(a.setMining))

    m.Handle("/get-work", jsonHandler(a.getWork))
    m.Handle("/submit-work", jsonHandler(a.submitWork))

    m.Handle("/gas-rate", jsonHandler(a.gasRate))
    m.Handle("/net-info", jsonHandler(a.getNetInfo))

能夠看到仍是各類功能的定義,主要是跟區塊數據、挖礦、訪問控制等相關的功能,這裏就不詳述了。

再繼續:

handler := latencyHandler(m, walletEnable)
    handler = maxBytesHandler(handler)
    handler = webAssetsHandler(handler)
    handler = gzip.Handler{Handler: handler}

    a.handler = handler
}

這裏是把前面定義的功能點配置包成了一個handler,而後在它外面包了一層又一層,添加上了更多的功能:

  1. latencyHandler:我目前還不能準確說出它的做用,留待之後補充
  2. maxBytesHandler:防止用戶提交的數據過大,目前值約爲10MB。對於除signer/sign-block之外的url有效
  3. webAssetsHandler:向用戶提供dashboard相關的前端頁面資源(好比網頁、圖片等等)。多是爲了性能和方便性方面的考慮,前端文件都通過混淆後,以字符串形式嵌入在dashboard/dashboard.go中,真正的代碼在另外一個項目中 https://github.com/Bytom/dashboard,咱們在後面會看一下
  4. gzip.Handler:對http客戶端進行是否支持gzip的檢測,而且在支持的狀況下,傳輸數據時使用gzip壓縮

而後讓咱們回到主線,看看前面的NewAPI中最後調用的api.initServer(config)

api/api.go#L89-L122

func (a *API) initServer(config *cfg.Config) {
    // The waitHandler accepts incoming requests, but blocks until its underlying
    // handler is set, when the second phase is complete.
    var coreHandler waitHandler
    var handler http.Handler

    coreHandler.wg.Add(1)
    mux := http.NewServeMux()
    mux.Handle("/", &coreHandler)

    handler = mux
    if config.Auth.Disable == false {
        handler = AuthHandler(handler, a.accessTokens)
    }
    handler = RedirectHandler(handler)

    secureheader.DefaultConfig.PermitClearLoopback = true
    secureheader.DefaultConfig.HTTPSRedirect = false
    secureheader.DefaultConfig.Next = handler

    a.server = &http.Server{
        // Note: we should not set TLSConfig here;
        // we took care of TLS with the listener in maybeUseTLS.
        Handler:      secureheader.DefaultConfig,
        ReadTimeout:  httpReadTimeout,
        WriteTimeout: httpWriteTimeout,
        // Disable HTTP/2 for now until the Go implementation is more stable.
        // https://github.com/golang/go/issues/16450
        // https://github.com/golang/go/issues/17071
        TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){},
    }

    coreHandler.Set(a)
}

這個方法在本文不適合細講,由於它更多的是涉及到http層面的一些東西,不是本文的重點。值得關注的地方是,方法建立了一個Go提供的http.Server,把前面咱們辛苦配置好的handler塞進去,萬事俱備,只欠啓動。

下面就是啓動啦。咱們終於能夠回到最新的initAndstartApiServer方法了,還記得它的第3塊內容嗎?主要就是調用了n.api.StartServer(*listenAddr)

api/api.go#L125-L140

func (a *API) StartServer(address string) {
    // ...
    listener, err := net.Listen("tcp", address)
    // ...
    go func() {
        if err := a.server.Serve(listener); err != nil {
            log.WithField("error", errors.Wrap(err, "Serve")).Error("Rpc server")
        }
    }()
}

這塊比較簡單,就是使用Go的net.Listen來監聽傳入的web api地址,獲得相應的listener以後,把它傳給咱們在前面建立的http.ServerServe方法,就大功告成了。

這一塊代碼分析寫得十分痛苦,主要緣由是它的web api這裏幾乎涉及到了全部比原提供的功能,很龐雜。還有很多跟http協議相關的東西。同時,由於暴露出了接口,這裏就容易出現安全風險,因此代碼裏面還有很多涉及到用戶輸入、安全檢查等。這些東西固然是很是重要的,可是從代碼閱讀的角度上來說又不免枯燥,除非咱們就是爲了研究安全性。

本文的任務主要是研究比原是如何提供http服務的,關於比原在安全性方面作了哪些事情,之後會有專門的分析。

Dashboard使用了什麼樣的前端框架?

比原的前端代碼是在另外一個獨立的項目中:https://github.com/Bytom/dash...

本文咱們並不去探討代碼細節,而僅僅去看一下它使用了哪些前端框架,有個大概印象便可。

經過https://github.com/Bytom/dashboard/blob/master/package.json咱們就能夠大概瞭解到,比原前端使用了:

  1. 構建工具:直接利用npmScripts
  2. 前端框架:React + Redux
  3. CSS方面:bootstrap
  4. JavaScript:ES6
  5. http請求:fetch-ponyfill
  6. 資源打包:webpack
  7. 測試:mocha

Dashboard上面的數據,是以什麼樣的方式從後臺拿到的?

以Account相關的代碼爲例:

src/sdk/api/accounts.js#L16

const accountsAPI = (client) => {
  return {
    create: (params, cb) => shared.create(client, '/create-account', params, {cb, skipArray: true}),

    createBatch: (params, cb) => shared.createBatch(client, '/create-account', params, {cb}),

    // ...

    listAddresses: (accountId) => shared.query(client, 'accounts', '/list-addresses', {account_id: accountId}),
  }
}

這些函數主要是經過fetch-ponyfill庫中提供的方法,向向前面使用go建立的web api接口發送http請求,而且拿到相應的回覆數據。而它們又將在React組件中被調用,拿回來的數據用於填充頁面。

一樣,更細節的內容在本文就不講啦。

終於,通過這一大篇的分析,我以爲我對於比原的Dashboard是怎麼作出來的,有了一些基本的印象。剩下的,就是在之後,針對其中的功能進行細緻的研究。

相關文章
相關標籤/搜索