天天看點

剝開比原看代碼13:比原是如何通過/list-balances顯示帳戶餘額的?

作者:freewind

比原項目倉庫:

Github位址:

https://github.com/Bytom/bytom Gitee位址: https://gitee.com/BytomBlockchain/bytom

在前幾篇裡,我們研究了比原是如何通過web api接口來建立密鑰、帳戶和位址的,今天我們繼續看一下,比原是如何顯示帳戶餘額的。

在Dashboard中,左側有一欄名為"Balances"(餘額),點選後,我們可以看到每個帳戶目前有多少餘額,如下圖:

剝開比原看代碼13:比原是如何通過/list-balances顯示帳戶餘額的?
這又是怎麼實作的呢?我們還是和以前一樣,把它分成兩個部分:

  1. 前端是如何向後端發送請求的
  2. 後端接收到請求資料後,是如何去查詢出帳戶餘額的

對應這個功能的前端代碼遠比想像中複雜,我花了很多功夫才把邏輯理清楚,主要原因是它是一種通用的展示方式:以表格的形式來展示一個數組中多個元素的内容。不過在上圖所展示的例子中,這個數組隻有一個元素而已。

首先需要提醒的是,這裡涉及到

Redux

Redux-router

的很多知識,如果不熟悉的話,最好能先去找點文檔和例子看看,把裡面的一些基本概念弄清楚。比如,

  1. 在Redux中,通常會有一個叫

    store

    的資料結構,像一個巨大的JSON對象,持有整個應用所有需要的資料;
  2. 我們需要寫很多reducer,它們就是store的轉換器,根據目前傳入的store傳回一個新的内容不同的store,store在不同時刻的内容可以看作不同的state
  3. action是用來向reducer傳遞資料的,reducer将根據action的類型和參數來做不同的轉換
  4. dispatch是Redux提供的,我們一般不能直接調用reducer,而是調用dispatch,把action傳給它,它會幫我們拿到目前的store,并且把它(或者一部分)和action一起傳給reducer去做轉換
  5. redux-router會提供一個

    reduxConnect

    函數,幫我們把store跟react的元件連接配接起來,使得我們在React元件中,可以友善的去dispatch

另外,在Chrome中,有兩個插件可以友善我們去調試React+Redux:

  1. React DevTools: https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi
  2. Redux DevTools: https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd

下面将結合前端源代碼來分析一下過程,因為在邏輯上可以看作存在幾條線,是以我們将分開追蹤。

reducers

首先我們發現在啟動的地方,初始化了

store

src/app.js#L17-L18
// Start app
export const store = configureStore()           

并且在裡面建立store的時候,還建立了reducer:

src/configureStore.js#L13-L37
export default function() {
  const store = createStore(
    makeRootReducer(),
    ...

  return store
}           

進入

makeRootReducer

src/reducers.js#L18-L62
// ...
import { reducers as balance } from 'features/balances'
// ...
const makeRootReducer = () => (state, action) => {
  // ...
  return combineReducers({
    // ...
    balance,
    // ...
  })(state, action)
}           

這個函數的最後實際上會把多個元件需要的reducer合并在一起,但是我把其它的都省略了,隻留下了今天要研究的

balance

而這個balance是來自于

'features/balances'

暴露出來的

reducers

src/features/balances/index.js#L5-L9
import reducers from './reducers'

export {
  actions,
  reducers,
  routes
}           

可以看到除了

reducers

,它還暴露了别的,那些我們一會兒再研究。先看

reducers

,它對應于

reducers.js

src/features/balances/reducers.js#L30-L33
export default combineReducers({
  items: itemsReducer,
  queries: queriesReducer
})           

可以看到,它是把兩種作用的reducer合并起來了,一個是跟操作元素相關的,另一個是用來記錄查詢狀态的(是否查詢過)。

我們先看元素相關的

itemsReducer

src/features/balances/reducers.js#L3-L17
const itemsReducer = (state = {}, action) => {
  if (action.type == 'APPEND_BALANCE_PAGE') {
    const newState = {}
    action.param.data.forEach((item, index) => {
      const id = `balance-${index}`
      newState[id] = {
        id: `balance-${index}`,
        ...item
      }
    })

    return newState
  }
  return state
}           

可以看到,當傳過來的參數

action

type

APPEND_BALANCE_PAGE

時,就會把

action.param.data

中包含的元素放到一個新建立的state中,并且以索引順序給它們起了id,且在id前面加了

balance-

友善追蹤。比如我們在Chrome的Redux DevTools插件中就可以看到:

剝開比原看代碼13:比原是如何通過/list-balances顯示帳戶餘額的?

經過這個reducer處理後産生的新store中就包含了與balance相關的資料,它們可以用于在别處拿出來顯示在React元件中。這點我們在後面會看到。

再看另一個與查詢相關的

queriesReducer

src/features/balances/reducers.js#L19-L27
const queriesReducer = (state = {}, action) => {
  if (action.type == 'APPEND_BALANCE_PAGE') {
    return {
      loadedOnce: true
    }
  }
  return state
}           

這個比較簡單,它關心的

action.type

跟前面一樣,也是

APPEND_BALANCE_PAGE

。傳回的

loadedOnce

的作用是告訴前端有沒有向背景查詢過,這樣可以用于控制比如提示資訊的顯示等。

與balance相關的reducer就隻有這些了,看起來還是比較簡單的。

actions

在前面,我們看到在balance中除了reducer,還定義了actions:

import actions from './actions'
// ...

export {
  actions,
  reducers,
  routes
}           

其中的

actions

對應的是

actions.js

src/features/balances/actions.js#L1-L2
import { baseListActions } from 'features/shared/actions'
export default baseListActions('balance')           

可以看到,它實際上是利用了一個項目内共享的action來産生自己的action,讓我們找到

baseListActions

src/features/shared/actions/index.js#L1-L9
// ...
import baseListActions from './list'

export {
  // ...
  baseListActions,
}           

繼續,先讓我們省略掉一些代碼,看看骨架:

src/features/shared/actions/list.js#L4-L147
// 1. 
export default function(type, options = {}) {

  // 2. 
  const listPath  = options.listPath || `/${type}s`

  // 3. 
  const clientApi = () => options.clientApi ? options.clientApi() : chainClient()[`${type}s`]

  // 4. 
  const fetchItems = (params) => {
    // ...
  }

  const fetchPage = (query, pageNumber = 1, options = {}) => {
    // ...
  }

  const fetchAll = () => {
    // ...
  }

  const _load = function(query = {}, list = {}, requestOptions) {
    // ...
  }

  const deleteItem = (id, confirmMessage, deleteMessage) => {
    // ...
  }

  const pushList = (query = {}, pageNumber, options = {}) => {
    // ...
  }

  // 5.
  return {
    fetchItems,
    fetchPage,
    fetchAll,
    deleteItem,
    pushList,
    didLoadAutocomplete: {
      type: `DID_LOAD_${type.toUpperCase()}_AUTOCOMPLETE`
    },
  }
}           

這個函數比較大,它是一個通用的用來分頁分元素來展示資料的。為了友善了解,我們先把一些細節代碼注釋掉了,隻留下了骨架,并且标注了6塊内容:

  1. 第1處需要關注的是,這是一個函數,可以被外界調用,是以前面才可以

    baseListActions('balance')

    ,傳進來的第一個參數是用來表示這是什麼類型的資料,其它地方可以根據這個類型發送不同的請求或進行不同的操作
  2. 第2處是定義前台列出資料(就是常用的list頁面)的router路徑,預設就type的複數,比如

    balance

    就是

    /balances

    ,它會被redux-router處理,并且轉到相應的元件
  3. 第3處是找到相應的用于向背景傳送資料的對象,名為

    clientApi

    ,封裝了背景提供的web api接口
  4. 第4處是與顯示資料相關的通用函數定義,比如取資料,按頁取,删除等
  5. 第5處是把前面定義的各種操作函數組合成一個對象,傳回給調用者

其實我覺得這些函數的細節在這裡都不用怎麼展示,因為在代碼分析的時候,難度不在一個具體的函數是怎麼實作的,而是在于骨架和流程是怎麼樣的。這裡列出了多個函數的名字,我還不清楚哪些會用到,是以先不講解,等後面遇到了再把代碼貼出來講解。

routes

再看前面剩下的routes是怎麼實作的:

// ...
import routes from './routes'

export {
  actions,
  reducers,
  routes
}           

這個

routes

routes.js

檔案:

src/features/balances/routes.js#L1-L4
import { List } from './components'
import { makeRoutes } from 'features/shared'

export default (store) => makeRoutes(store, 'balance', List)           

跟前面的action類似,它也是通過調用一個通用的函數再傳入一些具體的參數過去實作的,那麼在那邊的

makeRoutes

肯定做了大量的工作。讓我們進入

features/shared/index.js

src/features/shared/index.js#L1-L9
// ...
import makeRoutes from './routes'
// ...

export {
  actions,
  reducers,
  makeRoutes
}           

隻聚焦于

makeRoutes

src/features/shared/routes.js#L5-L44
const makeRoutes = (store, type, List, New, Show, options = {}) => {
  // 1. 
  const loadPage = () => {
    store.dispatch(actions[type].fetchAll())
  }

  // 2.
  const childRoutes = []

  if (New) {
    childRoutes.push({
      path: 'create',
      component: New
    })
  }

  if (options.childRoutes) {
    childRoutes.push(...options.childRoutes)
  }

  if (Show) {
    childRoutes.push({
      path: ':id',
      component: Show
    })
  }

  // 3.
  return {
    path: options.path || type + 's',
    component: RoutingContainer,
    name: options.name || humanize(type + 's'),
    name_zh: options.name_zh,
    indexRoute: {
      component: List,
      onEnter: (nextState, replace) => {
        loadPage(nextState, replace)
      },
      onChange: (_, nextState, replace) => { loadPage(nextState, replace) }
    },
    childRoutes: childRoutes
  }
}           

分成了4塊:

  1. 第1處定義了

    loadPage

    的操作,它實際上要是調用該type對應的action的

    fetchAll

    方法(還記得前面action骨架中定義了

    fetchAll

    函數嗎)
  2. 第2處根據傳入的參數來确定這個router裡到底有哪些routes,比如是否需要“建立”,“顯示”等等
  3. 第3處就是傳回值,傳回了一個對象,它是可以被redux-router了解的。可以看到它裡面有

    path

    , 對應的元件

    component

    ,甚至首頁中某些特别時刻如進入或者改變時,要進行什麼操作。

由于這裡調用了

fetchAll

,那我們便把前面action裡的

fetchAll

貼出來:

src/features/shared/actions/list.js#L58-L60
const fetchAll = () => {
    return fetchPage('', -1)
  }           

又調用到了

fetchPage

src/features/shared/actions/list.js#L39-L55
const fetchPage = (query, pageNumber = 1, options = {}) => {
    const listId =  query.filter || ''
    pageNumber = parseInt(pageNumber || 1)

    return (dispatch, getState) => {
      const getFilterStore = () => getState()[type].queries[listId] || {}

      const fetchNextPage = () =>
        dispatch(_load(query, getFilterStore(), options)).then((resp) => {
          if (!resp || resp.type == 'ERROR') return

          return Promise.resolve(resp)
        })

      return dispatch(fetchNextPage)
    }
  }           

在中間又調用了

_load

src/features/shared/actions/list.js#L62-L101
const _load = function(query = {}, list = {}, requestOptions) {
    return function(dispatch) {
      // ...
      // 1.
      if (!refresh && latestResponse) {
        let responsePage
        promise = latestResponse.nextPage()
          .then(resp => {
            responsePage = resp
            return dispatch(receive(responsePage))
          })
          // ...
      } else {
        // 2. 
        const params = {}
        if (query.filter) params.filter = filter
        if (query.sumBy) params.sumBy = query.sumBy.split(',')
        promise = dispatch(fetchItems(params))
      }

      // 3. 
      return promise.then((response) => {
        return dispatch({
          type: `APPEND_${type.toUpperCase()}_PAGE`,
          param: response,
          refresh: refresh,
        })
      })
      // ...
    }
  }           

這個函數還比較複雜,我進行了适當簡化,并且分成了3塊:

  1. 第1處的

    if

    分支處理的是第2頁的情況。拿到資料後,會通過

    receive

    這個函數定義了一個action傳給

    dispatch

    進行操作。這個

    receive

    在前面被我省略了,其實就是定義了一個

    type

    RECEIVED_${type.toUpperCase()}_ITEMS

    的action,也就是說,拿到資料後,還需要有另一個地方對它進行處理。我們晚點再來讨論它。
  2. 第2處的

    else

    處理的是查詢情況,拿到其中的過濾條件等,傳給

    fetchItems

    函數
  3. 第3處的

    promise

    就是前面兩處中的一個,也就是拿到資料後再進行

    APPEND_${type.toUpperCase()}_PAGE

    的操作

我們從這裡并沒有看到它到底會向比原背景的哪個接口發送請求,它可能被隐藏在了某個函數中,比如

nextPage

或者

fetchItems

等。我們先看看

nextPage

:

src/sdk/page.js#L17-L24
nextPage(cb) {
    let queryOwner = this.client
    this.memberPath.split('.').forEach((member) => {
      queryOwner = queryOwner[member]
    })

    return queryOwner.query(this.next, cb)
  }           

可以看到它最後調用的是

client

query

方法。其中的client對應的是

balanceAPI

src/sdk/api/balances.js#L3-L9
const balancesAPI = (client) => {
  return {
    query: (params, cb) => shared.query(client, 'balances', '/list-balances', params, {cb}),

    queryAll: (params, processor, cb) => shared.queryAll(client, 'balances', params, processor, cb),
  }
}           

可以看到,

query

最後将調用背景的

/list-balances

接口。

fetchItems

最終也調用的是同樣的方法:

src/features/shared/actions/list.js#L15-L35
const fetchItems = (params) => {
    // ...
    return (dispatch) => {
      const promise = clientApi().query(params)

      promise.then(
        // ...
      )

      return promise
    }
  }           

是以我們一會兒在分析背景的時候,隻需要關注

/list-balances

就可以了。

這裡還剩下一點,就是從背景拿到資料後,前端怎麼處理,也就是前面第1塊和第3塊中拿到資料後的操作。

我們先看一下第1處中的

RECEIVED_${type.toUpperCase()}_ITEMS

的action是如何被處理的。通過搜尋,發現了:

src/features/shared/reducers.js#L6-L28
export const itemsReducer = (type, idFunc = defaultIdFunc) => (state = {}, action) => {
  if (action.type == `RECEIVED_${type.toUpperCase()}_ITEMS`) {
    const newObjects = {}

    const data = type.toUpperCase() !== 'TRANSACTION' ? action.param.data : action.param.data.map(data => ({
      ...data,
      id: data.txId,
      timestamp: data.blockTime,
      blockId: data.blockHash,
      position: data.blockIndex
    }));

    (data || []).forEach(item => {
      if (!item.id) { item.id = idFunc(item) }
      newObjects[idFunc(item)] = item
    })
    return newObjects
  } else // ...
  return state
}           

可以看到,當拿到資料後,如果是“轉帳”則進行一些特殊的操作,否則就直接用。後面的操作,也主要是給每個元素增加了一個id,然後放到store裡。

那麼第3步中的

APPEND_${type.toUpperCase()}_PAGE

呢?我們找到一些通用的處理代碼:

src/features/shared/reducers.js#L34-L54
export const queryCursorReducer = (type) => (state = {}, action) => {
  if (action.type == `APPEND_${type.toUpperCase()}_PAGE`) {
    return action.param
  }
  return state
}

export const queryTimeReducer = (type) => (state = '', action) => {
  if (action.type == `APPEND_${type.toUpperCase()}_PAGE`) {
    return moment().format('h:mm:ss a')
  }
  return state
}

export const autocompleteIsLoadedReducer = (type) => (state = false, action) => {
  if (action.type == `DID_LOAD_${type.toUpperCase()}_AUTOCOMPLETE`) {
    return true
  }

  return state
}           

這裡沒有什麼複雜的操作,主要是把前面送過來的參數當作store新的state傳出去,或者在

queryTimeReducer

是傳出目前時間,可以把它們了解為一些占位符(預設值)。如果針對某一個具體類型,還可以定義具體的操作。比如我們這裡是

balance

,是以它還會被前面最開始講解的這個函數處理:

const itemsReducer = (state = {}, action) => {
  if (action.type == 'APPEND_BALANCE_PAGE') {
    const newState = {}
    action.param.data.forEach((item, index) => {
      const id = `balance-${index}`
      newState[id] = {
        id: `balance-${index}`,
        ...item
      }
    })

    return newState
  }
  return state
}           

這個前面已經講了,這裡列出來僅供回憶。

那麼到這裡,我們基本上就已經把比原前端中,如何通過分頁清單形式展示資料的流程弄清楚了。至于拿到資料後,最終如何在頁面上以table的形式展示出來,可以參看

https://github.com/freewind/bytom-dashboard-v1.0.0/blob/master/src/features/balances/components/ListItem.jsx

,我覺得這裡已經不需要再講解了。

那麼我們準備進入後端。

後端是如何通過

/list-balances

接口查詢出帳戶餘額的

跟之前一樣,我們可以很快的找到定義web api接口的地方:

api/api.go#L164-L244
func (a *API) buildHandler() {
    // ...
    if a.wallet != nil {
        // ...
        m.Handle("/list-balances", jsonHandler(a.listBalances))
        // ...

    // ...
}           

/list-balances

對應的handler是

a.listBalances

(外面的

jsonHandler

是用于處理http方面的東西,以及在Go對象與JSON之間做轉換的)

api/query.go#L60-L67
// POST /list-balances
func (a *API) listBalances(ctx context.Context) Response {
    balances, err := a.wallet.GetAccountBalances("")
    if err != nil {
        return NewErrorResponse(err)
    }
    return NewSuccessResponse(balances)
}           

這個方法看起來很簡單,因為它不需要前端傳入任何參數,然後再調用

wallet.GetAccountBalances

并傳入空字元串(表示全部帳戶)拿到結果,并且傳回給前端即可:

wallet/indexer.go#L544-L547
// GetAccountBalances return all account balances
func (w *Wallet) GetAccountBalances(id string) ([]AccountBalance, error) {
    return w.indexBalances(w.GetAccountUTXOs(""))
}           

這裡分成了兩步,首先是調用

w.GetAccountUTXOs

得到帳戶對應的

UTXO

,然後再根據它計算出來餘額balances。

UTXO

Unspent Transaction Output

,是比特币采用的一個概念(在比原鍊中對它進行了擴充,支援多種資産)。其中

Transaction

可看作是一種資料結構,記錄了一個交易的過程,包括若幹個資金輸入和輸出。在比特币中沒有我們通常熟悉的銀行帳戶那樣有專門的地方記錄餘額,而是通過計算屬于自己的所有未花費掉的輸出來算出餘額。關于UTXO網上有很多文章講解,可以自行搜尋。

我們繼續看

w.GetAccountUTXOs

wallet/indexer.go#L525-L542
// GetAccountUTXOs return all account unspent outputs
func (w *Wallet) GetAccountUTXOs(id string) []account.UTXO {
    var accountUTXOs []account.UTXO

    accountUTXOIter := w.DB.IteratorPrefix([]byte(account.UTXOPreFix + id))
    defer accountUTXOIter.Release()
    for accountUTXOIter.Next() {
        accountUTXO := account.UTXO{}
        if err := json.Unmarshal(accountUTXOIter.Value(), &accountUTXO); err != nil {
            hashKey := accountUTXOIter.Key()[len(account.UTXOPreFix):]
            log.WithField("UTXO hash", string(hashKey)).Warn("get account UTXO")
        } else {
            accountUTXOs = append(accountUTXOs, accountUTXO)
        }
    }

    return accountUTXOs
}           

這個方法看起來不是很複雜,它主要是從資料庫中搜尋

UTXO

,然後傳回給調用者繼續處理。這裡的

w.DB

是指名為

wallet

的leveldb,我們這段時間一直在用它。初始化的過程今天就不看了,之前做過多次,大家有需要的話應該能自己找到。

然後就是以

UTXOPreFix

(常量

ACU:

,表示

StandardUTXOKey prefix

)作為字首對資料庫進行周遊,把取得的JSON格式的資料轉換為

account.UTXO

對象,最後把它們放到數組裡傳回給調用者。

我們再看前面

GetAccountBalances

方法中的

w.indexBalances

wallet/indexer.go#L559-L609
func (w *Wallet) indexBalances(accountUTXOs []account.UTXO) ([]AccountBalance, error) {
    // 1. 
    accBalance := make(map[string]map[string]uint64)
    balances := make([]AccountBalance, 0)

    // 2.
    for _, accountUTXO := range accountUTXOs {
        assetID := accountUTXO.AssetID.String()
        if _, ok := accBalance[accountUTXO.AccountID]; ok {
            if _, ok := accBalance[accountUTXO.AccountID][assetID]; ok {
                accBalance[accountUTXO.AccountID][assetID] += accountUTXO.Amount
            } else {
                accBalance[accountUTXO.AccountID][assetID] = accountUTXO.Amount
            }
        } else {
            accBalance[accountUTXO.AccountID] = map[string]uint64{assetID: accountUTXO.Amount}
        }
    }

    // 3. 
    var sortedAccount []string
    for k := range accBalance {
        sortedAccount = append(sortedAccount, k)
    }
    sort.Strings(sortedAccount)

    for _, id := range sortedAccount {
        // 4.
        var sortedAsset []string
        for k := range accBalance[id] {
            sortedAsset = append(sortedAsset, k)
        }
        sort.Strings(sortedAsset)

        // 5.
        for _, assetID := range sortedAsset {
            alias := w.AccountMgr.GetAliasByID(id)
            targetAsset, err := w.AssetReg.GetAsset(assetID)
            if err != nil {
                return nil, err
            }

            assetAlias := *targetAsset.Alias
            balances = append(balances, AccountBalance{
                Alias: alias,
                AccountID: id,
                AssetID: assetID,
                AssetAlias: assetAlias,
                Amount: accBalance[id][assetID],
                AssetDefinition: targetAsset.DefinitionMap,
            })
        }
    }

    return balances, nil
}           

這個方法看起來很長,但是實際上做的事情沒那麼多,隻不過是因為Go低效的文法讓它看起來非常龐大。我把它分成了5塊:

  1. 第1塊分别定義了後面要用到的一些資料結構,其中

    accBalance

    是一個兩級的map(AccountID -> AssetID -> AssetAmount),通過對參數

    accountUTXOs

    進行周遊,把相同account和相同asset的數量累加在一起。

    balances

    是用來儲存結果的,是一個

    AccountBalance

    的切片
  2. 第2塊就是累加assetAmount,放到

    accBalance

  3. 對accountId進行排序,
  4. 對assetId也進行排序,這兩處的排序是想讓最後的傳回結果穩定(有利于檢視及分頁)
  5. 經過雙層周遊,拿到了每一個account的每一種asset的assetAmount,然後再通過

    w.AccountMgr.GetAliasByID

    拿到缺少的

    alias

    資訊,最後生成一個切片傳回。其中

    GetAliasByID

    就是從

    wallet

    資料庫中查詢,比較簡單,就不貼代碼了。

看完這一段代碼之後,我的心情是比較郁悶的,因為這裡的代碼看着多,但實際上都是一些比較低層的邏輯(建構、排序、周遊),在其它的語言中(尤其是支援函數式的),可能隻需要十來行代碼就能搞定,但是這麼要寫這麼多。而且,我還發現,GO語言通過它獨特的文法、錯誤處理和類型系統,讓一些看起來應該很簡單的事情(比如抽出來一些可複用的處理資料結構的函數)都變得很麻煩,我試着重構,居然發現無從下手。

今天的問題就算是解決了,下次再見。

繼續閱讀