剝開比原看代碼13:比原是如何通過/list-balances顯示帳戶余額的?
作者:freewind
比原項目倉庫:
Github地址:https://github.com/Bytom/bytom
Gitee地址:https://gitee.com/BytomBlockchain/bytom
在前幾篇裏,我們研究了比原是如何通過web api接口來創建密鑰、帳戶和地址的,今天我們繼續看一下,比原是如何顯示帳戶余額的。
在Dashboard中,左側有一欄名為"Balances"(余額),點擊後,我們可以看到每個帳戶當前有多少余額,如下圖:
這又是怎麽實現的呢?我們還是和以前一樣,把它分成兩個部分:
- 前端是如何向後端發送請求的
- 後端接收到請求數據後,是如何去查詢出帳戶余額的
前端是如何向後端發送請求的
對應這個功能的前端代碼遠比想像中復雜,我花了很多功夫才把邏輯理清楚,主要原因是它是一種通用的展示方式:以表格的形式來展示一個數組中多個元素的內容。不過在上圖所展示的例子中,這個數組只有一個元素而已。
首先需要提醒的是,這裏涉及到Redux和Redux-router的很多知識,如果不熟悉的話,最好能先去找點文檔和例子看看,把裏面的一些基本概念弄清楚。比如,
- 在Redux中,通常會有一個叫
store
的數據結構,像一個巨大的JSON對象,持有整個應用所有需要的數據; - 我們需要寫很多reducer,它們就是store的轉換器,根據當前傳入的store返回一個新的內容不同的store,store在不同時刻的內容可以看作不同的state
- action是用來向reducer傳遞數據的,reducer將根據action的類型和參數來做不同的轉換
- dispatch是Redux提供的,我們一般不能直接調用reducer,而是調用dispatch,把action傳給它,它會幫我們拿到當前的store,並且把它(或者一部分)和action一起傳給reducer去做轉換
- redux-router會提供一個
reduxConnect
函數,幫我們把store跟react的組件連接起來,使得我們在React組件中,可以方便的去dispatch
另外,在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-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插件中就可以看到:
經過這個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:
src/features/balances/index.js#L5-L9
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
就是/balances
,它會被redux-router處理,並且轉到相應的組件 - 第3處是找到相應的用於向後臺傳送數據的對象,名為
clientApi
,封裝了後臺提供的web api接口 - 第4處是與顯示數據相關的通用函數定義,比如取數據,按頁取,刪除等
- 第5處是把前面定義的各種操作函數組合成一個對象,返回給調用者
其實我覺得這些函數的細節在這裏都不用怎麽展示,因為在代碼分析的時候,難度不在一個具體的函數是怎麽實現的,而是在於骨架和流程是怎麽樣的。這裏列出了多個函數的名字,我還不清楚哪些會用到,所以先不講解,等後面遇到了再把代碼貼出來講解。
routes
再看前面剩下的routes是怎麽實現的:
src/features/balances/index.js#L5-L9
// ...
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處定義了
loadPage
的操作,它實際上要是調用該type對應的action的fetchAll
方法(還記得前面action骨架中定義了fetchAll
函數嗎) - 第2處根據傳入的參數來確定這個router裏到底有哪些routes,比如是否需要“新建”,“顯示”等等
- 第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處的
if
分支處理的是第2頁的情況。拿到數據後,會通過receive
這個函數定義了一個action傳給dispatch
進行操作。這個receive
在前面被我省略了,其實就是定義了一個type
為RECEIVED_${type.toUpperCase()}_ITEMS
的action,也就是說,拿到數據後,還需要有另一個地方對它進行處理。我們晚點再來討論它。 - 第2處的
else
處理的是查詢情況,拿到其中的過濾條件等,傳給fetchItems
函數 - 第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
,所以它還會被前面最開始講解的這個函數處理:
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
}
這個前面已經講了,這裏列出來僅供回憶。
那麽到這裏,我們基本上就已經把比原前端中,如何通過分頁列表形式展示數據的流程弄清楚了。至於拿到數據後,最終如何在頁面上以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塊分別定義了後面要用到的一些數據結構,其中
accBalance
是一個兩級的map(AccountID -> AssetID -> AssetAmount),通過對參數accountUTXOs
進行遍歷,把相同account和相同asset的數量累加在一起。balances
是用來保存結果的,是一個AccountBalance
的切片 - 第2塊就是累加assetAmount,放到
accBalance
中 - 對accountId進行排序,
- 對assetId也進行排序,這兩處的排序是想讓最後的返回結果穩定(有利於查看及分頁)
- 經過雙層遍歷,拿到了每一個account的每一種asset的assetAmount,然後再通過
w.AccountMgr.GetAliasByID
拿到缺少的alias
信息,最後生成一個切片返回。其中GetAliasByID
就是從wallet
數據庫中查詢,比較簡單,就不貼代碼了。
看完這一段代碼之後,我的心情是比較郁悶的,因為這裏的代碼看著多,但實際上都是一些比較低層的邏輯(構建、排序、遍歷),在其它的語言中(尤其是支持函數式的),可能只需要十來行代碼就能搞定,但是這麽要寫這麽多。而且,我還發現,GO語言通過它獨特的語法、錯誤處理和類型系統,讓一些看起來應該很簡單的事情(比如抽出來一些可復用的處理數據結構的函數)都變得很麻煩,我試著重構,居然發現無從下手。
今天的問題就算是解決了,下次再見。
剝開比原看代碼13:比原是如何通過/list-balances顯示帳戶余額的?