做者:freewind前端
比原項目倉庫:react
Github地址:https://github.com/Bytom/bytomgit
Gitee地址:https://gitee.com/BytomBlockchain/bytomgithub
在前幾篇裏,咱們研究了比原是如何經過web api接口來建立密鑰、賬戶和地址的,今天咱們繼續看一下,比原是如何顯示賬戶餘額的。web
在Dashboard中,左側有一欄名爲"Balances"(餘額),點擊後,咱們能夠看到每一個賬戶當前有多少餘額,以下圖:chrome
這又是怎麼實現的呢?咱們仍是和之前同樣,把它分紅兩個部分:數據庫
對應這個功能的前端代碼遠比想像中複雜,我花了不少功夫才把邏輯理清楚,主要緣由是它是一種通用的展現方式:以表格的形式來展現一個數組中多個元素的內容。不過在上圖所展現的例子中,這個數組只有一個元素而已。json
首先須要提醒的是,這裏涉及到Redux和Redux-router的不少知識,若是不熟悉的話,最好能先去找點文檔和例子看看,把裏面的一些基本概念弄清楚。好比,redux
store
的數據結構,像一個巨大的JSON對象,持有整個應用全部須要的數據;reduxConnect
函數,幫咱們把store跟react的組件鏈接起來,使得咱們在React組件中,能夠方便的去dispatch另外,在Chrome中,有兩個插件能夠方便咱們去調試React+Redux:後端
下面將結合前端源代碼來分析一下過程,由於在邏輯上能夠看做存在幾條線,因此咱們將分開追蹤。
首先咱們發如今啓動的地方,初始化了store
:
// Start app export const store = configureStore()
而且在裏面建立store的時候,還建立了reducer:
export default function() { const store = createStore( makeRootReducer(), ... return store }
進入makeRootReducer
:
// ... 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就只有這些了,看起來仍是比較簡單的。
在前面,咱們看到在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塊內容:
baseListActions('balance')
,傳進來的第一個參數是用來表示這是什麼類型的數據,其它地方能夠根據這個類型發送不一樣的請求或進行不一樣的操做balance
就是/balances
,它會被redux-router處理,而且轉到相應的組件clientApi
,封裝了後臺提供的web api接口其實我以爲這些函數的細節在這裏都不用怎麼展現,由於在代碼分析的時候,難度不在一個具體的函數是怎麼實現的,而是在於骨架和流程是怎麼樣的。這裏列出了多個函數的名字,我還不清楚哪些會用到,因此先不講解,等後面遇到了再把代碼貼出來說解。
再看前面剩下的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塊:
loadPage
的操做,它實際上要是調用該type對應的action的fetchAll
方法(還記得前面action骨架中定義了fetchAll
函數嗎)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塊:
if
分支處理的是第2頁的狀況。拿到數據後,會經過receive
這個函數定義了一個action傳給dispatch
進行操做。這個receive
在前面被我省略了,其實就是定義了一個type
爲RECEIVED_${type.toUpperCase()}_ITEMS
的action,也就是說,拿到數據後,還須要有另外一個地方對它進行處理。咱們晚點再來討論它。else
處理的是查詢狀況,拿到其中的過濾條件等,傳給fetchItems
函數promise
就是前面兩處中的一個,也就是拿到數據後再進行APPEND_${type.toUpperCase()}_PAGE
的操做咱們從這裏並無看到它到底會向比原後臺的哪一個接口發送請求,它可能被隱藏在了某個函數中,好比nextPage
或者fetchItems
等。咱們先看看nextPage
:
nextPage(cb) { let queryOwner = this.client this.memberPath.split('.').forEach((member) => { queryOwner = queryOwner[member] }) return queryOwner.query(this.next, cb) }
能夠看到它最後調用的是client
的query
方法。其中的client對應的是balanceAPI
:
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接口的地方:
func (a *API) buildHandler() { // ... if a.wallet != nil { // ... m.Handle("/list-balances", jsonHandler(a.listBalances)) // ... // ... }
能夠看到,/list-balances
對應的handler是a.listBalances
(外面的jsonHandler
是用於處理http方面的東西,以及在Go對象與JSON之間作轉換的)
// 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
並傳入空字符串(表示所有賬戶)拿到結果,而且返回給前端便可:
// 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
:
// 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
:
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塊:
accBalance
是一個兩級的map(AccountID -> AssetID -> AssetAmount),經過對參數accountUTXOs
進行遍歷,把相同account和相同asset的數量累加在一塊兒。balances
是用來保存結果的,是一個AccountBalance
的切片accBalance
中w.AccountMgr.GetAliasByID
拿到缺乏的alias
信息,最後生成一個切片返回。其中GetAliasByID
就是從wallet
數據庫中查詢,比較簡單,就不貼代碼了。看完這一段代碼以後,個人心情是比較鬱悶的,由於這裏的代碼看着多,但實際上都是一些比較低層的邏輯(構建、排序、遍歷),在其它的語言中(尤爲是支持函數式的),可能只須要十來行代碼就能搞定,可是這麼要寫這麼多。並且,我還發現,GO語言經過它獨特的語法、錯誤處理和類型系統,讓一些看起來應該很簡單的事情(好比抽出來一些可複用的處理數據結構的函數)都變得很麻煩,我試着重構,竟然發現無從下手。
今天的問題就算是解決了,下次再見。