作者:freewind
比原項目倉庫:
Github位址:
https://github.com/Bytom/bytom Gitee位址: https://gitee.com/BytomBlockchain/bytom在前幾篇裡,我們研究了比原是如何通過web api接口來建立密鑰、帳戶和位址的,今天我們繼續看一下,比原是如何顯示帳戶餘額的。
在Dashboard中,左側有一欄名為"Balances"(餘額),點選後,我們可以看到每個帳戶目前有多少餘額,如下圖:

- 前端是如何向後端發送請求的
- 後端接收到請求資料後,是如何去查詢出帳戶餘額的
對應這個功能的前端代碼遠比想像中複雜,我花了很多功夫才把邏輯理清楚,主要原因是它是一種通用的展示方式:以表格的形式來展示一個數組中多個元素的内容。不過在上圖所展示的例子中,這個數組隻有一個元素而已。
首先需要提醒的是,這裡涉及到
Redux和
Redux-router的很多知識,如果不熟悉的話,最好能先去找點文檔和例子看看,把裡面的一些基本概念弄清楚。比如,
- 在Redux中,通常會有一個叫
的資料結構,像一個巨大的JSON對象,持有整個應用所有需要的資料;store
- 我們需要寫很多reducer,它們就是store的轉換器,根據目前傳入的store傳回一個新的内容不同的store,store在不同時刻的内容可以看作不同的state
- action是用來向reducer傳遞資料的,reducer将根據action的類型和參數來做不同的轉換
- dispatch是Redux提供的,我們一般不能直接調用reducer,而是調用dispatch,把action傳給它,它會幫我們拿到目前的store,并且把它(或者一部分)和action一起傳給reducer去做轉換
- redux-router會提供一個
函數,幫我們把store跟react的元件連接配接起來,使得我們在React元件中,可以友善的去dispatchreduxConnect
另外,在Chrome中,有兩個插件可以友善我們去調試React+Redux:
- React DevTools: https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi
- 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-L37export 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插件中就可以看到:
經過這個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處需要關注的是,這是一個函數,可以被外界調用,是以前面才可以
,傳進來的第一個參數是用來表示這是什麼類型的資料,其它地方可以根據這個類型發送不同的請求或進行不同的操作baseListActions('balance')
- 第2處是定義前台列出資料(就是常用的list頁面)的router路徑,預設就type的複數,比如
就是balance
,它會被redux-router處理,并且轉到相應的元件/balances
- 第3處是找到相應的用于向背景傳送資料的對象,名為
,封裝了背景提供的web api接口clientApi
- 第4處是與顯示資料相關的通用函數定義,比如取資料,按頁取,删除等
- 第5處是把前面定義的各種操作函數組合成一個對象,傳回給調用者
其實我覺得這些函數的細節在這裡都不用怎麼展示,因為在代碼分析的時候,難度不在一個具體的函數是怎麼實作的,而是在于骨架和流程是怎麼樣的。這裡列出了多個函數的名字,我還不清楚哪些會用到,是以先不講解,等後面遇到了再把代碼貼出來講解。
routes
再看前面剩下的routes是怎麼實作的:
// ...
import routes from './routes'
export {
actions,
reducers,
routes
}
這個
routes
routes.js
檔案:
src/features/balances/routes.js#L1-L4import { 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處定義了
的操作,它實際上要是調用該type對應的action的loadPage
方法(還記得前面action骨架中定義了fetchAll
函數嗎)fetchAll
- 第2處根據傳入的參數來确定這個router裡到底有哪些routes,比如是否需要“建立”,“顯示”等等
- 第3處就是傳回值,傳回了一個對象,它是可以被redux-router了解的。可以看到它裡面有
, 對應的元件path
,甚至首頁中某些特别時刻如進入或者改變時,要進行什麼操作。component
由于這裡調用了
fetchAll
,那我們便把前面action裡的
fetchAll
貼出來:
src/features/shared/actions/list.js#L58-L60const 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處的
分支處理的是第2頁的情況。拿到資料後,會通過if
這個函數定義了一個action傳給receive
進行操作。這個dispatch
在前面被我省略了,其實就是定義了一個receive
為type
的action,也就是說,拿到資料後,還需要有另一個地方對它進行處理。我們晚點再來讨論它。RECEIVED_${type.toUpperCase()}_ITEMS
- 第2處的
處理的是查詢情況,拿到其中的過濾條件等,傳給else
函數fetchItems
- 第3處的
就是前面兩處中的一個,也就是拿到資料後再進行promise
的操作APPEND_${type.toUpperCase()}_PAGE
我們從這裡并沒有看到它到底會向比原背景的哪個接口發送請求,它可能被隐藏在了某個函數中,比如
nextPage
或者
fetchItems
等。我們先看看
nextPage
:
src/sdk/page.js#L17-L24nextPage(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-L35const 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-L28export 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-L54export 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
接口查詢出帳戶餘額的
/list-balances
跟之前一樣,我們可以很快的找到定義web api接口的地方:
api/api.go#L164-L244func (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塊分别定義了後面要用到的一些資料結構,其中
是一個兩級的map(AccountID -> AssetID -> AssetAmount),通過對參數accBalance
進行周遊,把相同account和相同asset的數量累加在一起。accountUTXOs
是用來儲存結果的,是一個balances
的切片AccountBalance
- 第2塊就是累加assetAmount,放到
中accBalance
- 對accountId進行排序,
- 對assetId也進行排序,這兩處的排序是想讓最後的傳回結果穩定(有利于檢視及分頁)
- 經過雙層周遊,拿到了每一個account的每一種asset的assetAmount,然後再通過
拿到缺少的w.AccountMgr.GetAliasByID
資訊,最後生成一個切片傳回。其中alias
就是從GetAliasByID
資料庫中查詢,比較簡單,就不貼代碼了。wallet
看完這一段代碼之後,我的心情是比較郁悶的,因為這裡的代碼看着多,但實際上都是一些比較低層的邏輯(建構、排序、周遊),在其它的語言中(尤其是支援函數式的),可能隻需要十來行代碼就能搞定,但是這麼要寫這麼多。而且,我還發現,GO語言通過它獨特的文法、錯誤處理和類型系統,讓一些看起來應該很簡單的事情(比如抽出來一些可複用的處理資料結構的函數)都變得很麻煩,我試着重構,居然發現無從下手。
今天的問題就算是解決了,下次再見。