【劉文彬】 Controller:EOS區塊鏈核心控制器

圓方圓區塊鏈發表於2018-12-07

原文連結:醒者呆的部落格園,www.cnblogs.com/Evsward/p/c…

Controller是EOS區塊鏈的核心控制器,其功能豐富、責任重大。 關鍵字:EOS,區塊鏈,controller,chainbase,db,namespace,using,訊號槽,fork_database,snapshot

名稱空間namespace

名稱空間namespace定義了一個範圍,這個範圍本身可作為額外的資訊,類似於地址,或者位置。如果有兩個名字相同的變數或者函式,例如foshan::linshuhao和nba::linshuhao,名稱空間可以提供:

  • 區分性或者歸類性。不同名稱空間下的內容互相孤立,即使內部函式名稱相同,也不會產生混淆。

  • 可讀性,本例中foshan和nba提供了一層語義。

      C++程式架構中,不同的檔案可以通過引入相同的名稱空間使用或者擴充套件功能。進一步理解,不同的檔名可以提供一層語義,這些檔案可以共同維護一個跨檔案的名稱空間。
    複製程式碼

using語法

C++程式設計中,經常會遇到帶有using關鍵字的語句。using正如字面含義,代表了本作用域後續會使用到的內容,這個內容可以是:

  • 其他名稱空間,用using宣告以後,該名稱空間下的公有屬性都可被使用。
  • 直接指定其他名稱空間下的某個函式,相當於匯入功能,可以使用該函式,不過使用時仍舊要帶上包含函式名稱空間的完整路徑。
  • 為某個複雜名字變數起的別名以便於使用。例如using apply_handler = std::function<void(apply_context&)>;

controller依賴功能

通過controller的宣告檔案,可以看到其整個結構。它宣告瞭兩個名稱空間:

  • chainbase,這項宣告為controller提供了基於chainbase的狀態資料庫能力。該名稱空間是chainbase元件定義的,宣告瞭database類,在chainbase原始碼中可以找到database類,這個類在前文chainbase的章節已經介紹過。
  • eosio::chain,該命名函式是EOSIO專案中內容最豐富的,在很多其他元件都有定義與使用。Controller引用了其他元件在相同名稱空間下定義的功能,包括:
    • authorization_manager,提供許可權管理的功能,許可權內容有認證資訊、依賴金鑰、關聯許可權、許可。管理操作包括增刪改查。
    • resource_limits::resource_limits_manager,完全的名稱空間為eosio::chain::resource_limits,為controller提供了資源限制管理的功能。此處的資源指的是基於chainbase的資料庫的儲存資源。例如,增加索引、資料庫初始化、快照增加和讀取、賬戶初始化、設定區塊引數、更新賬戶使用等。
    • dynamic_global_property_object,動態維護全域性狀態資訊,繼承自chainbase::object。它的值是在正常的鏈操作期間計算的,以及反映全域性區塊鏈屬性的當前值。
    • global_property_object,維護全域性狀態資訊,同樣繼承自chainbase::object。它的的值由委員會成員設定,以調優區塊鏈引數。與上面的區別是一個是動態計算,一個是靜態指定。
    • permission_object,同樣繼承自chainbase::object。增加了屬於許可權範疇的屬性,包括id主鍵、parent父許可權id、許可權使用id,賬戶名、許可權名、最後更新時間、許可權認證。另外提供了檢查傳入許可權是否等效或大於其他許可權。許可權是按層次結構組織的,因此父許可權嚴格地比子許可權以及孫子許可權更強大。
    • account_object,同樣繼承自chainbase::object。增加了屬於賬戶範疇的屬性,包括id主鍵、賬戶名、是否擁有超級許可權能力、最後code更新時間、code版本、建立時間、code、abi。另外提供了abi設定函式set_abi()和abi查詢函式get_abi()。
    • fork_database,分叉資料庫。下面會詳細介紹。

controller擴充套件

在controller.hpp中,最重要的部分就是類controller的內容,它是對名稱空間eosio::chain內容的擴充套件。在展開介紹controller類之前,先要說明在eosio::chain名稱空間下,有兩個列舉類的定義,這也是對名稱空間功能的擴充套件,因為下面介紹controller類的時候會使用:

db_read_mode,db讀取模式是一個列舉類,包括:

  • SPECULATIVE,推測模式。內容為兩個主體的資料:已完成的頭區塊,以及還未上鍊的事務。
  • HEAD,頭塊模式。內容為當前頭區塊資料。
  • READ_ONLY,只讀模式。內容為同步進來的區塊資料,不包括推測狀態的事務處理資料。
  • IRREVERSIBLE,不可逆模式。內容為當前不可逆區塊的資料。

validation_mode,校驗模式也同樣是一個列舉類,包括:

  • FULL,完全模式。所有同步進來的區塊都將被完整地校驗。
  • LIGHT,輕量模式。所有同步進來的區塊頭都將被完整的校驗,通過校驗的區塊頭所在區塊的全部事務被認為可信。

下面進入controller類,內容很多,首先包含了一個公有的成員config,它是一個結構體,包含了大量鏈配置項,可在配置檔案或者鏈啟動命令中配置。controller中的config結構體是動態執行時的引數配置,而EOSIO提供了另外一個eosio::chain::config名稱空間,這裡定義了系統初始化預設的一些配置項的值,controller中的config結構體的某些配置項的初始化會使用到這些預設值。

config的配置項中大量使用到了一個容器:flat_set。這是一個使用鍵儲存物件,且經過排序的容器,同時它是一個去重容器,也就是說容器中不會包含兩個相同的元素。
複製程式碼

其中被序列化公開的屬性有:

FC_REFLECT( eosio::chain::controller::config,
    (actor_whitelist) // 賬戶集合,作為actor白名單
    (actor_blacklist) // 賬戶集合,作為actor黑名單
    (contract_whitelist) // 賬戶集合,作為合約白名單
    (contract_blacklist) // 賬戶集合,作為合約黑名單
    (blocks_dir) // 儲存區塊資料的目錄名字,有預設值為"blocks"
    (state_dir) // 儲存狀態資料的目錄名字,有預設值為"state"
    (state_size) // 狀態資料的大小,有預設值為1GB
    (reversible_cache_size) // 可逆去快資料的快取大小,有預設值為340MB
    (read_only) // 是否只讀,預設為false。
    (force_all_checks) // 是否強制執行所有檢查,預設為false。
    (disable_replay_opts) // 是否禁止重播引數,預設為false。
    (contracts_console) // 是否允許合約輸出到控制檯,一般為了除錯合約使用,預設為false。
    (genesis) // eosio::chain::genesis_state結構體的例項,包含了創世塊的初始化配置內容。
    (wasm_runtime) // 執行時webassembly虛擬機器的型別,預設值為eosio::chain::wasm_interface::vm_type::wabt
    (resource_greylist) // 賬戶集合,是資源灰名單。
    (trusted_producers) // 賬戶集合,為可信生產者。
)
複製程式碼

未包含在內的屬性有:

flat_set< pair<account_name, action_name> > action_blacklist; // 賬戶和action組成一個二元組作為元素的集合,儲存了action的黑名單
flat_set<public_key_type> key_blacklist; // 公鑰集合,公鑰黑名單
uint64_t                 state_guard_size       =  chain::config::default_state_guard_size; // 狀態守衛大小,預設為128MB
uint64_t                 reversible_guard_size  =  chain::config::default_reversible_guard_size; // 可逆區塊守衛大小,預設為2MB
bool                     allow_ram_billing_in_notify = false; // 是否允許記憶體賬單通知,預設為false。
db_read_mode             read_mode              = db_read_mode::SPECULATIVE; // db只讀模式,預設為SPECULATIVE
validation_mode          block_validation_mode  = validation_mode::FULL; // 區塊校驗模式,預設為FULL
複製程式碼

controller::block_status,區塊狀態列舉類,包括:

  • irreversible = 0,該區塊已經被當前節點應用,並且被認為是不可逆的。
  • validated = 1,這是由一個有效生產者簽名的完整區塊,並且之前已經被當前節點應用,因此該區塊已被驗證但未成為不可逆。
  • complete = 2,這是一個由有效生產者簽名的完整區塊,但是還沒有成為不可逆,也沒有被當前節點應用。
  • incomplete = 3,這是一個未完成的區塊,未被生產者簽名也沒有被某個節點生產。

接下來,檢視controller的私有成員:

  • apply_context類物件,處理節點應用區塊的上下文環境。其中包含了迭代器快取、二級索引管理、通用索引管理、構造器等內容。
  • transaction_context類物件,事務上下文環境。包含了構造器,轉型,事務的生命週期(包括初始化、執行、完成、刷入磁碟、撤銷操作),事務資源管理、分發action、定時事務、資源賬單等內容。
  • mutable_db(),返回一個可變db,型別與正常db相同,都是chainbase::database,但這個函式返回的是一個常量引用。
  • controller_impl結構體的例項的唯一指標my。這是整個controller的環境物件,controller_impl結構體包含了眾多controller功能的實現。通過my都可以快取在同一個環境下使用。

controller的訊號

controller類的共有成員屬性以及私有成員介紹完了,還剩下公有成員函式,這部分內容非常多,幾乎包含了整個鏈執行所涉及到的出塊流程相關的一切內容,從區塊本地組裝、校驗簽名,到本地節點應用入狀態庫,經過多節點共識成為不可逆區塊等函式。其中每個階段都有對應的訊號,訊號功能使用了boost::signals2::signal庫。controller維護了這些訊號內容,共8個:

  • signal<void(const signed_block_ptr&)> pre_accepted_block; // 預承認區塊(承認其他節點廣播過來的區塊是正確的)
  • signal<void(const block_state_ptr&)> accepted_block_header; // 承認區塊頭(對區塊頭做過校驗)
  • signal<void(const block_state_ptr&)> accepted_block; // 承認區塊
  • signal<void(const block_state_ptr&)> irreversible_block; // 不可逆區塊
  • signal<void(const transaction_metadata_ptr&)> accepted_transaction; // 承認事務
  • signal<void(const transaction_trace_ptr&)> applied_transaction; // 應用事務(承認其他節點資料要先校驗,通過以後可以應用在本地節點)
  • signal<void(const header_confirmation&)> accepted_confirmation; // 承認確認
  • signal<void(const int&)> bad_alloc; // 記憶體分配錯誤訊號

所有訊號的發射時機都是在controller中。

1. pre_accepted_block

發射時機: push_block函式,會對已籤區塊校驗,包括不能有pending塊,不能push空塊,區塊狀態不能是incomplete。通過校驗後,會發射該訊號,攜帶該區塊。 外掛捕捉處理: chain_plugin連線該訊號,由訊號槽轉播到channel,pre_accepted_block_channel釋出該區塊。但是該channel沒有訂閱者。

2. accepted_block_header

發射時機①: commit_block函式,如果該函式的引數add_to_fork_db為true,需要新增至fork_db,首先將pending狀態區塊的狀態置為已校驗,在fork_db中新增pending狀態區塊,然後發射該訊號並攜帶pending狀態區塊。 發射時機②: push_block函式,pre_accepted_block發射完以後,獲取區塊的可信狀態並新增至fork_db,然後發射該訊號,攜帶fork_db新增成功後返回的狀態區塊。 外掛捕捉處理①: net_plugin連線該訊號,繫結處理函式,函式體實現了日誌列印。 外掛捕捉處理②: chain_plugin連線該訊號,由訊號槽轉播到channel,accepted_block_header_channel釋出該區塊。bnet_plugin訂閱該channel,繫結bnet_plugin_impl的on_accepted_block_header函式,該函式涉及到執行緒池等概念,將會在bnet_plugin外掛的部分詳細分析。遍歷執行緒池,轉到session會話下的on_accepted_block_header函式執行。如果傳入區塊與本地時間相差6秒以內則接收,之外不處理。接收處理時先從本地多索引庫表block_status中查詢是否已存在,不存在則插入block_status結構物件,如果不是遠端不可逆請求以及不存在該區塊,或者該區塊不是來自其他節點的情況,要在區塊頭通知集合中插入該區塊id。

3. accepted_block

發射時機: commit_block函式,fork_db以及重播的處理結束後,發射承認區塊的訊號,攜帶pending狀態區塊資料。 外掛捕捉處理①: net_plugin連線該訊號,繫結處理函式,列印日誌的同時呼叫dispatch_manager::bcast_block,傳入區塊資料。send_all向所有連線傳送廣播,這部分內容會在net_plugin部分詳細研究。 外掛捕捉處理②: chain_plugin連線該訊號,由訊號槽轉播到channel,accepted_block_channel釋出該區塊。bnet_plugin訂閱該channel,依然有執行緒池的處理,會話遍歷,執行單個會話的on_accepted_block函式,刪除快取中的所有事務,遍歷接收到的區塊的事務receipt,獲得事務的打包物件,事務id,在多索引表_transaction_status中查詢該id,如果找到了則刪除。接下來如果在空閒狀態下,嘗試傳送下一條pingpong心跳連線資訊。 外掛捕捉處理③: mongo_db_plugin連線該訊號,繫結其mongo_db_plugin_impl::accepted_block函式,傳入區塊內容。該函式首先校驗是否達到了mongo配置中的開始處理的區塊號,這項配置是通過引數start_block_num設定的。如果傳入區塊號大於該引數設定的值(預設是0),則將標誌位start_block_reached置為true。接著根據另一個配置項mongodb-store-blocks(是否儲存區塊資料)以及mongodb-store-block-states(是否儲存狀態區塊資料)來判斷是否要儲存區塊資料。儲存區塊的方式是呼叫佇列block_state_\queue,傳入區塊資料等待被消費,等待的過程又涉及到一個速度平衡的機制,關於mongo外掛的內容請查閱相關篇章。 外掛捕捉處理④: producer_plugin連線該訊號,執行其on_block函式,傳入區塊資料。函式首先做了校驗,包括時間是否大於最後簽名區塊的時間以及大於當前時間,還有區塊號是否大於最後簽名區塊號。校驗通過以後,活躍生產者賬戶集合active_producers開闢新空間,插入計劃出塊生產者。 接下來利用set_intersection取本地生產者與集合active_producers的交集(如果結果為空,說明本地生產者沒有出塊權利不屬於活躍生產者的一份子)。將結果存入一個迭代器,迭代執行內部函式,如果交集生產者不等於接收區塊的生產者,說明是校驗別人生產的區塊,如果是相等的不必做特殊處理。校驗別人生產的區塊,首先要在活躍生產者的key中找到匹配的key(本地生產者賬戶公鑰),否則說明該區塊不是合法生產者簽名拋棄不處理。接下來,獲取本地生產者私鑰,組裝生產確認資料欄位,包括區塊id,區塊摘要,生產者,簽名。更新producer外掛本地標誌位_last_signed_block_time和_last_signed_block_num。最後發射訊號confirmed_block,攜帶以上組裝好的資料。但經過搜尋,專案中目前沒有對該訊號設定槽connection。 在區塊建立之前要為該區塊的生產者設定水印用來標示該區塊的生產者是誰。

4. irreversible_block

發射時機①: push_block函式,當推送的區塊狀態為irreversible不可逆時,發射該訊號,攜帶狀態區塊資料。 發射時機②: on_irreversible函式,更改區塊狀態為irreversible的函式,操作成功最後發射該訊號。 外掛捕捉處理①: net_plugin連線該訊號,繫結函式irreversible_block,列印日誌。 外掛捕捉處理②: chain_plugin連線該訊號,由訊號槽轉播到channel,irreversible_block_channel釋出該區塊。 bnet_plugin訂閱該channel,依然執行緒池遍歷會話,執行on_new_lib函式,當本地庫領先時可以清除歷史直到滿足當前庫,或者直到最後一個被遠端節點所知道的區塊。最後如果空閒,嘗試傳送下一條pingpong心跳連線資訊。 外掛捕捉處理③: mongo_db_plugin連線該訊號,執行applied_irreversible_block函式,仍舊參照mongo配置項的值決定是否儲存區塊、狀態區塊以及事務資料,然後將區塊資料塞入佇列等待消費。同上不贅述。 外掛捕捉處理④: producer_plugin連線該訊號,繫結執行函式on_irreversible_block,設定producer成員_irreversible_block_time的值為區塊的時間。

5. accepted_transaction

發射時機①: push_scheduled_transaction函式,推送計劃事務時,將事務體經過一系列轉型以及校驗,當事務超時時間小於pending區塊時間時的處理,接著發射該訊號,承認事務。當事務超時時間大於等於pending區塊時間時的處理,最後發射該訊號,承認事務。當事務的sender傳送者不是空且沒有主觀失敗的處理,最後發射該訊號,承認事務。基於生產和校驗的主觀修改,主觀時的處理之後發射該訊號,承認事務。當不是主觀問題而是硬邏輯錯誤時的處理,接著發射該訊號,承認事務。 發射時機②: push_transaction函式,新事務到大狀態區塊,要經過身份認證以及決定是否現在執行還是延期執行,最後要插入到pending區塊的receipt接收事務中去。當檢查事務未被承認時,發射一次該訊號。最後全部函式處理完畢,再次發射該訊號。 外掛捕捉處理①: net_plugin連線該訊號,繫結函式accepted_transaction,列印日誌。 外掛捕捉處理②: chain_plugin連線該訊號,由訊號槽轉播到channel,accepted_transaction_channel釋出該事務。bnet_plugin訂閱該channel,執行緒池遍歷會話,執行函式on_accepted_transaction。在可能是多個的投機塊中一個事務被承認,當一個區塊包含該承認事務或者切換分叉時,該事務狀態變為“receive now”,被新增至資料庫表中,作為傳送給其他節點的證據。當該事務被髮送給其他節點時,根據這個狀態可以保證之後不會重複傳送。每一次事務被“accepted”,都會延時5秒鐘。每次一個區塊被應用,所有超過5秒未被應用的但被承認的事務都將被清除。 外掛捕捉處理③: mongo_db_plugin連線該訊號,執行函式accepted_transaction,校驗加入佇列待消費。

6. applied_transaction

發射時機①: push_scheduled_transaction函式,事務過期時間小於pending區塊時間處理後發射該訊號。反之大於等於處理後發射該訊號。當事務的sender傳送者不為空且沒有主觀失敗的處理後發射該訊號。基於生產和校驗的主觀修改,主觀處理後發射該訊號,非主觀處理髮射該訊號。 發射時機② :push_transaction函式,發射兩次該訊號,邏輯較多,這段包括以上那個函式的可讀性很差,註釋幾乎沒有。 外掛捕捉處理①: net_plugin連線該訊號,繫結函式applied_transaction,列印日誌。 外掛捕捉處理② : chain_plugin連線該訊號,由訊號槽轉播到channel,原理基本同上,不再重複。 外掛捕捉處理③ : mongo_db_plugin同上。

7. accepted_confirmation

發射時機 : push_confirmation函式,推送確認資訊,在此階段不允許有pending區塊存在,接著fork_db新增確認資訊,發射該訊號。 外掛捕捉處理① : net_plugin連線該訊號,繫結函式accepted_confirmation,列印日誌。 外掛捕捉處理② : chain_plugin連線該訊號,由訊號槽轉播到channel,基本同上。

8. bad_alloc

發射時機 : 與前面七種不同,該訊號沒有發射,是屬於boost::interprocess::bad_alloc,用於捕捉記憶體分配錯誤的異常。 外掛捕捉處理 : 無connect。

controller的具體實現

controller函式的具體實現內容,一般是對引數的校驗,然後通過my來呼叫controller_impl結構體的具體函式來處理。所以controller的核心功能實現是在controller_impl結構體中,下面檢視其成員屬性:

  • self,controller例項的引用。
  • db, chainbase::database的一個例項,用於儲存區塊全資料,是區塊進入不可修改的block_log之前的緩衝地帶,包括本地的,同步過來的,未承認的,已承認的等等。
  • reversible_blocks,同樣也是chainbase::database的一個例項,但它是用來儲存那些已經成功被應用但仍舊是可逆的特殊區塊。
  • blog,block_log類例項,是區塊鏈不可逆資料的儲存物件。這部分內容在資料儲存結構部分已有詳細解釋,此處不再贅述。
  • pending,處於pending狀態的一個區塊的包裝。
  • head,block_state_ptr結構體是所有區塊的統一資料結構,head代表頭區塊物件。
  • fork_db,fork_database類例項,分叉庫。
  • wasmif,wasm_interface類例項,是webassembly虛擬機器介面的例項。
  • resource_limits,resource_limits_manager資源限制管理器例項。
  • authorization,authorization_manager認證許可權管理器例項。
  • conf,controller::config前文介紹的配置config的例項。
  • chain_id,chain_id_type型別,代表區塊鏈當前id。
  • replaying,是否允許重播,預設初始化為false。
  • replay_head_time,重播的頭區塊時間。
  • read_mode,資料庫讀取模式,預設初始話為SPECULATIVE
  • in_trx_requiring_checks,事務中是否需要檢查,預設為false。如果為true的話,通常會被跳過的檢查不會被跳過。例如身份驗證。
  • subjective_cpu_leeway,剩餘的cpu資源,以微妙計算。
  • trusted_producer_light_validation,可信的生產者執行輕量級校驗,預設為false。
  • snapshot_head_block,快照的頭區塊號。
  • handler_key,處理者的鍵,元素為scope和action組成的二元組。
  • apply_handlers,應用操作的處理者,元素為以handler_key為鍵,std::function<void(apply_context&)>為值的map作為值,賬戶名作為鍵的複雜map。
  • unapplied_transactions,未應用的事務map,以sha256加密串作為鍵,transaction_metadata_ptr為值。pop_block函式或者abort_block函式為執行完畢的事務,如果再次被其他區塊應用會從這個列表中移除,生產者在排程新事務打包到區塊裡時可以查詢這個列表。

剩下的內容為controller_impl的眾多功能函式的實現了,這些內容都是需要與其他程式組合使用,例如外掛程式,或者智慧合約,因此在接下來的篇章中,將會重新按照一個功能入口研究完整的使用脈絡。而在這些功能中有兩個內容需要在此處研究清楚,一個是fork_database,另一個是snapshot。下面逐一展開分析。

fork_database

在fork_database.hpp檔案中宣告。管理了輕量級狀態資料,是由未確認的潛在區塊產生的。當本地節點接收receive到新的區塊時,它們將被推入fork資料庫。fork資料庫跟蹤最長的鏈,以及最新不可逆塊號。所有大於最新不可逆塊號的區塊將會在發出“irreversible”不可逆訊號以後被釋放掉,區塊已經成功上鍊變為不可逆,因此fork庫沒必要再儲存。分叉庫提供了很多函式,例如通過區塊id獲取區塊、通過區塊號獲取區塊、插入區塊包括set和add各種過載函式、刪除區塊、獲取頭區塊、通過id獲取兩個分支、設定區塊標誌位等。

1. fork_database構造器

在controller_impl的建構函式體中會被呼叫。

controller_impl( const controller::config& cfg, controller& s  )
   :self(s),
    db( cfg.state_dir,
        cfg.read_only ? database::read_only : database::read_write,
        cfg.state_size ),
    reversible_blocks( cfg.blocks_dir/config::reversible_blocks_dir_name,
        cfg.read_only ? database::read_only : database::read_write,
        cfg.reversible_cache_size ),
    blog( cfg.blocks_dir ),
    fork_db( cfg.state_dir ), // 呼叫fork_db構造器,傳入一個檔案路徑。
    wasmif( cfg.wasm_runtime ),
    resource_limits( db ),
    authorization( s, db ),
    conf( cfg ),
    chain_id( cfg.genesis.compute_chain_id() ),
    read_mode( cfg.read_mode )
複製程式碼

進入構造器。

fork_database::fork_database( const fc::path& data_dir ):my( new fork_database_impl() ) {
  my->datadir = data_dir;

  if (!fc::is_directory(my->datadir))
     fc::create_directories(my->datadir);

  auto fork_db_dat = my->datadir / config::forkdb_filename; // 在該目錄下建立一個檔案forkdb.dat
  if( fc::exists( fork_db_dat ) ) { // 如果該檔案已存在
     string content;
     fc::read_file_contents( fork_db_dat, content ); // 將其讀到記憶體中

     fc::datastream<const char*> ds( content.data(), content.size() );
     unsigned_int size; fc::raw::unpack( ds, size ); // 按照區塊結構解析
     for( uint32_t i = 0, n = size.value; i < n; ++i ) { // 遍歷所有區塊
        block_state s;
        fc::raw::unpack( ds, s );
        set( std::make_shared<block_state>( move( s ) ) ); // 逐一插入到資料庫fork_database中
     }
     block_id_type head_id;
     fc::raw::unpack( ds, head_id );

     my->head = get_block( head_id ); // 處理fork_database的頭區塊資料

     fc::remove( fork_db_dat ); // 刪除持久化檔案forkdb.dat。
  }
}
複製程式碼

檔案forkdb.dat也位於節點資料目錄中,是前文介紹唯一沒有說到的檔案,這裡補齊。

2. irreversible訊號

上面講到了,fork_database擁有一個公有成員irreversible訊號。這個訊號在controller_impl結構體的巨集SET_APP_HANDLER中被使用:

fork_db.irreversible.connect( [&]( auto b ) {
                                 on_irreversible(b);
                                 });
複製程式碼

這段程式碼其實是boost的訊號槽機制,訊號有一個connect操作,其引數是一個slot插槽,可將插槽連線到訊號上,最終返回一個connection物件代表這段連線關係,可以靈活控制連線開關。插槽的型別可以是任意物件,這段程式碼中是一個lambda表示式,呼叫了on_irreversible函式。 接下來,去fork_database查詢該訊號的觸發位置,出現在prune函式中的一段程式碼,

auto itr = my->index.find( h->id ); // h是prune入參,const block_state_ptr& h
if( itr != my->index.end() ) {
    irreversible(*itr);
    my->index.erase(itr);
}
複製程式碼

在table中查詢入參區塊,查詢到以後,會觸發訊號irreversible並攜帶區塊源資料發射。然後執行fork_database的刪除操作將目標區塊從分叉庫中刪除。 irreversible訊號攜帶區塊被髮射後,由於上面巨集的作用,會呼叫controller_impl的on_irreversible函式,並按照lambda表示式的規則將區塊傳入。該函式會將入參區塊變為不可逆,處理成功以後,下面擷取了這部分相關程式碼:

...
    fork_db.mark_in_current_chain(head, true);
    fork_db.set_validity(head, true);
}
emit(self.irreversible_block, s);
複製程式碼

這兩行是該函式對fork_db的全部操作,將fork_db的屬性in_current_chain和validated置為true。在on_irreversible函式的最後,它也發射了一個自己的訊號,注意發射方式採用了關鍵字emit,也攜帶了操作的區塊資料。

訊號觸發可以有兩種方式,使用關鍵字emit(signal,param)和直接呼叫signal(param)。
複製程式碼

這個訊號本來是與這一小節的內容不相干,但既然分析到這了,還是希望能有個閉環,那麼來看一下該訊號的連線槽位置,如圖所示。

【劉文彬】 Controller:EOS區塊鏈核心控制器

可以看到,區塊不可逆的訊號在net_plugin,chain_plugin,mongo_db_plugin,producer_plugin四個外掛程式碼中得到了運用,也說明這四個外掛是非常關心區塊不可逆的狀態變化的。至於他們具體是如何運用的,在相關部分會有詳細介紹。

3. initialize_fork_db

初始化fork_db,主要工作是從創世塊狀態設定fork_db的頭塊。頭塊的資料結構是區塊狀態物件,構造頭塊時,要先構造區塊頭狀態物件,包括:

  • active_schedule,活動的出塊安排,預設為初始出塊安排。
  • pending_schedule,等待中的出塊安排,預設為初始出塊安排。
  • pending_schedule_hash,等待中的出塊安排的單向雜湊值。
  • header.timestamp,等於創世塊配置檔案genesis中的timestamp值。
  • header.action_mroot,action的Merkel樹根,創世塊的值為鏈id值,該值是通過加密演算法計算出的。
  • id,塊id。
  • block_num,塊號。

構建好區塊頭以後,接著構建區塊體,構建完成以後,將完整頭塊插入到空的fork_db中。

4. commit_block -> add_to_fork_db

提交區塊函式,無論提交是否成功,都不再保留活動的pending塊。該函式有一個引數add_to_fork_db,是否加入fork_db。在producer_plugin生產者生產區塊的邏輯中,提交區塊呼叫controller物件的commit_block函式:

void controller::commit_block() {
   validate_db_available_size(); // 校驗db資料庫的大小
   validate_reversible_available_size(); // 校驗reversible資料庫的大小
   my->commit_block(true); // 呼叫controller_impl結構體中的的commit_block函式,並且傳入true
}
複製程式碼

從這條邏輯過來的提交區塊,會執行add_to_fork_db,而commit_block函式的另一處呼叫是在應用區塊部分,沒有觸發add_to_fork_db。至於commit_block函式的內容不在此處展開,只看fork_db相關的內容:

if (add_to_fork_db) {
    pending->_pending_block_state->validated = true; // 將pending區塊物件的狀態屬性validated置為true,標記已校驗。
    auto new_bsp = fork_db.add(pending->_pending_block_state); // 將pending區塊新增至fork_db。
    emit(self.accepted_block_header, pending->_pending_block_state); // 發射controller的accepted_block_header訊號,攜帶pending區塊狀態物件。
    head = fork_db.head(); // 將當前節點的頭塊設定為fork_db的頭塊。
    // 校驗pending區塊是否最終成功同時變為fork_db以及主節點的頭塊。
    EOS_ASSERT(new_bsp == head, fork_database_exception, "committed block did not become the new head in fork database");
 }
複製程式碼

以上程式碼中又發射一個訊號accepted_block_header,仍舊檢視一下該訊號的連線槽在哪裡,經過查詢,發現是在net_plugin和chain_plugin兩個外掛中,說明這兩個外掛是要對這個訊號感興趣並捕捉該訊號。

5. maybe_switch_forks

或許要切換分叉庫到主庫。該函式會在controller_impl結構體中的push_block和push_confirmation兩個函式中被呼叫。

if ( read_mode != db_read_mode::IRREVERSIBLE ) { // 在db讀取模式不等於IRREVERSIBLE時,要呼叫maybe_switch_forks函式。
    maybe_switch_forks( s );
}
複製程式碼

db讀取模式為IRREVERSIBLE時,只關心當前不可逆區塊的資料,而fork_db中不存在不可逆區塊的資料。而其他三種讀取模式都涉及到可逆區塊以及未被確認的資料,因此要去maybe_switch_forks函式檢查處理一番。

  • 當fork_db頭塊的上一個塊等於當前節點的頭塊時,說明有新塊被接收,先到達fork_db中,執行:
apply_block( new_head->block, s ); // 將新塊應用到主庫中去。
fork_db.mark_in_current_chain( new_head, true ); // 在fork_db中將新塊的屬性in_current_chain標記為true。
fork_db.set_validity( new_head, true ); // 在fork_db中將新塊的屬性validity標記為true。
head = new_head; // 更新節點主庫的頭塊為當前塊。
複製程式碼
  • 當fork_db頭塊的前一個塊不等於主庫頭塊且fork_db頭塊id也不等於當前節點的頭塊id時,說明fork_db最新的兩個塊都不等於主庫頭塊。這時候fork_db是更長的一條鏈,因此要切換主庫為fork_db鏈。切換的過程很複雜,此處不展開。

6. controller析構對fork_db的處理

my->fork_db.close();
複製程式碼

在controller析構時將fork_db關掉,因為它會生成irreversible訊號到這個controller。如果db讀取模式為IRREVERSIBLE,將應用最後一個不可逆區塊,my需要成為指向有效controller_impl的指標。

void fork_database::close() {
  if( my->index.size() == 0 ) return;
  auto fork_db_dat = my->datadir / config::forkdb_filename;
  // 獲取檔案輸出流。
  std::ofstream out( fork_db_dat.generic_string().c_str(), std::ios::out | std::ios::binary | std::ofstream::trunc );
  uint32_t num_blocks_in_fork_db = my->index.size();
  // 將當前fork_db的區塊資料打包到輸出流,持久化到fork_db.dat檔案中。
  fc::raw::pack( out, unsigned_int{num_blocks_in_fork_db} );
  for( const auto& s : my->index ) {
     fc::raw::pack( out, *s );
  }
  if( my->head )
     fc::raw::pack( out, my->head->id );
  else
     fc::raw::pack( out, block_id_type() );

  // 通常頭塊不是不可逆的。如果fork_db中只剩一個塊就是頭塊,一般不會將它刪除因為下一個區塊需要從頭塊建立。不過可以在退出之前將這個區塊作為不可逆區塊從fork_db中刪除。
  auto lib    = my->head->dpos_irreversible_blocknum;
  auto oldest = *my->index.get<by_block_num>().begin();
  if( oldest->block_num <= lib ) {
     prune( oldest );
  }

  my->index.clear();
}
複製程式碼

7. controller::startup對fork_db的處理

my->head = my->fork_db.head();
複製程式碼

controller的startup週期時,會將fork_db的頭塊設定為主庫頭塊(頭塊一般不是不可逆的)。

snapshot

快照,顧名思義,可以為區塊鏈提供臨時快速備份的功能。

1. abstract_snapshot_row_writer

該結構體位於名稱空間eosio::chain::detail。提供了寫入snapshot快照的能力,是所有關於快照寫入的結構的基類。該結構體是一個抽象型別,包含四個成員函式:

  • write,引數為ostream_wrapper例項(同樣在detail名稱空間下定義)的引用。
  • write,過載引數為sha256的加密器。
  • to_variant,轉型變體。
  • row_type_name,行型別名,字串型別。

snapshot_row_writer繼承了abstract_snapshot_row_writer,在構造該結構體例項時,要傳入data資料被快取在函式體。接著,實際上,write向兩種資料型別的輸出流中寫入的時候,物件就是data,寫入方法都是fc::raw::pack(out, data);,最終將記憶體中的data資料寫入到輸出流。to_variant函式也被實現了,轉型的目標是data,返回轉型後的variant物件。data型別是模板型別,row_type_name實現了通過boost::core::demangle庫獲得data的具體型別名。最後,對外提供了make_row_writer函式,接收任何型別的資料,初始化以上快照行寫入的功能。 snapshot_writer進一步封裝了寫入功能,對外提供了write_row寫入介面以及其他輔助功能介面。該類使用到了detail的內容,包括make_row_writer函式的類。 接著,定義了snapshot_writer_ptr是snapshot_writer例項的共享指標。 variant_snapshot_writer和ostream_snapshot_writer都是snapshot_writer的子類,根據不同的資料型別實現了不同的處理邏輯。

2. abstract_snapshot_row_reader

與上面相對的,是讀取的部分,所有關於快照讀取結構的基類。其包含三個成員虛擬函式:

  • provide,引數是std::istream的例項引用,說明是對標準庫輸入流的讀取。
  • provide,過載引數是fc::variant的引用,對變體的讀取。
  • row_type_name,行型別名,同上,字串型別。

snapshot_row_reader繼承了abstract_snapshot_row_reader,在構造該結構體例項時,要傳入data資料被快取在函式體。接著,分別對應不同輸入流的處理不同,最終會將不同輸入流的資料讀取到記憶體的data例項中。row_type_name的實現同上。make_row_reader的意義同上。 snapshot_reader進一步封裝了讀取功能,對外提供了read_row讀取介面以及其他輔助功能介面。該類使用到了detail的內容,包括make_row_reader函式的類。 接著,定義了snapshot_reader_ptr是snapshot_reader例項的共享指標。 variant_snapshot_readerostream_snapshot_reader,還有integrity_hash_snapshot_writer(處理的是hash演算法sha256的加密串)都是snapshot_writer的子類,根據不同的資料型別實現了不同的處理邏輯。

3. controller::startup對snapshot的處理

void controller::startup( const snapshot_reader_ptr& snapshot ) {
   my->head = my->fork_db.head(); // 將fork_db的頭塊設定為狀態主庫頭塊
   if( !my->head ) { // 如果狀態主庫頭塊為空,則說明fork_db沒有資料,可能需要重播block_log生成這些資料。
      elog( "No head block in fork db, perhaps we need to replay" );
   }
   my->init(snapshot); // 根據startup的入參snapshot呼叫controller_impl的初始化函式init。
}
複製程式碼

進入controller_impl的初始化函式init。

void init(const snapshot_reader_ptr& snapshot) {
  if (snapshot) { // 如果入參snapshot不為空
     EOS_ASSERT(!head, fork_database_exception, "");//快照存在而狀態主庫頭塊不存在是個異常狀態。
     snapshot->validate();// 校驗快照
     read_from_snapshot(snapshot);// 執行read_from_snapshot函式
     auto end = blog.read_head();// 從日誌檔案中獲取不可逆區塊頭塊。
     if( !end ) {// 如果不可逆區塊頭塊為空,重置日誌檔案,清除所有資料,重新初始化block_log狀態。
        blog.reset(conf.genesis, signed_block_ptr(), head->block_num + 1);
     } else if ( end->block_num() > head->block_num) {// 如果不可逆區塊頭塊號大於狀態主庫頭塊號。
        replay();// 狀態庫的資料與真實資料不同步,版本過舊,需要重播修復狀態主庫資料。
     } else {
        // 校驗提示報錯:區塊日誌提供了快照,但不包含主庫頭塊號
        EOS_ASSERT(end->block_num() == head->block_num, fork_database_exception,
                   "Block log is provided with snapshot but does not contain the head block from the snapshot");
     }
  } else if( !head ) {如果入參snapshot為空且狀態主庫的頭塊也不存在,說明狀態庫完全是空的。
     initialize_fork_db(); // 重新初始化fork_db
     auto end = blog.read_head();// 讀取區塊日誌中的不可逆區塊頭塊。
     if( end && end->block_num() > 1 ) {// 如果頭塊存在且頭塊號大於1
        replay();// 重播生成狀態庫。
     } else if( !end ) {// 如果頭塊不存在
        blog.reset( conf.genesis, head->block );// 重置日誌檔案,清除所有資料,重新初始化block_log狀態。
     }
  }
  ...
  if( snapshot ) {//快照存在,計算完整hash值。通過sha256演算法計算,將結果寫入快照,同時將結果列印到控制檯。
     const auto hash = calculate_integrity_hash();
     ilog( "database initialized with hash: ${hash}", ("hash", hash) );
  }
}
複製程式碼
EOS為snapshot定義了一個chain_snapshot_header結構體,用來儲存快照版本資訊。
複製程式碼

執行read_from_snapshot函式:

void read_from_snapshot( const snapshot_reader_ptr& snapshot ) {
  snapshot->read_section<chain_snapshot_header>([this]( auto &section ){
     chain_snapshot_header header;
     section.read_row(header, db);
     header.validate();
  });// 先讀取快照頭資料。
  snapshot->read_section<block_state>([this]( auto &section ){
     block_header_state head_header_state;
     section.read_row(head_header_state, db);// 讀取區塊頭狀態資料
     auto head_state = std::make_shared<block_state>(head_header_state);
     // 對fork_db的設定。
     fork_db.set(head_state);
     fork_db.set_validity(head_state, true);
     fork_db.mark_in_current_chain(head_state, true);
     head = head_state;
     snapshot_head_block = head->block_num;// 設定快照的頭塊號為主庫頭塊號
  });
  controller_index_set::walk_indices([this, &snapshot]( auto utils ){
     using value_t = typename decltype(utils)::index_t::value_type;
     // 跳過table_id_object(內聯的合同表格部分)
     if (std::is_same<value_t, table_id_object>::value) {
        return;
     }
     snapshot->read_section<value_t>([this]( auto& section ) {//按照value_t型別讀取快照到section
        bool more = !section.empty();
        while(more) {// 迴圈讀取section內容,知道全部讀取完畢。
           decltype(utils)::create(db, [this, &section, &more]( auto &row ) {
              more = section.read_row(row, db);// 按行讀取資料,回撥逐行寫入主庫。
           });
        }
     });
  });
  read_contract_tables_from_snapshot(snapshot);//從快照中同步合約資料
  authorization.read_from_snapshot(snapshot);//從快照中同步認證資料
  resource_limits.read_from_snapshot(snapshot);//從快照中同步資源限制資料

  db.set_revision( head->block_num );// 更新頭塊
}
複製程式碼

同步快照資料的操作是在controller的startup週期中執行的,根據傳入的snapshot,會調整區塊鏈的基於block_log的不可逆日誌資料,基於chainbase的狀態主庫資料。在controller的startup完畢後,可以保證三者資料的健康同步。

在chain_plugin的外掛配置項中有一個“snapshot”的引數,該配置項可以指定讀取的快照檔案。幾個關鍵校驗:

  • 注意不能同時配置“genesis-json”和“genesis-timestamp”兩項,因為快照中已經存在這兩項的值,會發生衝突。
  • 不能存在已有狀態檔案data/state/shared_memory.bin,因為快照只能被用來初始化一個空的狀態資料庫。
  • 校驗block_log日誌中不可逆區塊的創世塊是否與快照中的保持一致。

引數設定完畢,在chain_plugin的startup階段,會檢查快照地址,如果存在,則會帶上該快照檔案啟動鏈。

if (my->snapshot_path) {
 auto infile = std::ifstream(my->snapshot_path->generic_string(), (std::ios::in | std::ios::binary));
 auto reader = std::make_shared<istream_snapshot_reader>(infile);
 my->chain->startup(reader);// 帶上該快照檔案啟動鏈。
 infile.close();
} 
複製程式碼

my->chain的型別是fc::optional,所以會執行controller的startup函式,這樣就與上面的流程掛鉤了,形成了一個完整的邏輯閉環。

4. controller::write_snapshot

void controller::write_snapshot( const snapshot_writer_ptr& snapshot ) const {
   // 寫入快照時,不允許存在pending區塊。
   EOS_ASSERT( !my->pending, block_validate_exception, "cannot take a consistent snapshot with a pending block" );
   return my->add_to_snapshot(snapshot);
}
複製程式碼

呼叫add_to_snapshot函式。

void add_to_snapshot( const snapshot_writer_ptr& snapshot ) const {
  snapshot->write_section<chain_snapshot_header>([this]( auto &section ){
     section.add_row(chain_snapshot_header(), db);// 向快照中寫入快照頭資料
  });
  snapshot->write_section<genesis_state>([this]( auto &section ){
     section.add_row(conf.genesis, db);// 向快照中寫入創世塊資料
  });
  snapshot->write_section<block_state>([this]( auto &section ){
     section.template add_row<block_header_state>(*fork_db.head(), db);// 向快照中寫入頭塊區塊頭資料。
  });
  controller_index_set::walk_indices([this, &snapshot]( auto utils ){
     using value_t = typename decltype(utils)::index_t::value_type;
     if (std::is_same<value_t, table_id_object>::value) {// 跳過table_id_object(內聯的合同表格部分)
        return;
     }
     snapshot->write_section<value_t>([this]( auto& section ){ // 遍歷主庫db區塊。
        decltype(utils)::walk(db, [this, &section]( const auto &row ) {
           section.add_row(row, db); // 向快照中逐行寫入快照
        });
     });
  });

  add_contract_tables_to_snapshot(snapshot);// 向快照中寫入合約資料
  authorization.add_to_snapshot(snapshot);// 向快照中寫入認證資料
  resource_limits.add_to_snapshot(snapshot);// 向快照中寫入資源限制資料
}
複製程式碼

5. producer_plugin的create_snapshot()功能

controller::write_snapshot函式在外部由producer_plugin所呼叫。producer_plugin通過rpc api介面create_snapshot對外提供了建立快照的功能。這個功能無疑是非常實用的,可以為生產者提供快速資料備份的能力,為整個EOS區塊鏈的運維工作增加了健壯性。producer_plugin的具體的實現程式碼:

producer_plugin::snapshot_information producer_plugin::create_snapshot() const {
   chain::controller& chain = app().get_plugin<chain_plugin>().chain();// 獲取chain_plugin的外掛例項
   auto reschedule = fc::make_scoped_exit([this](){// 獲取生產者出塊計劃
      my->schedule_production_loop();
   });
   if (chain.pending_block_state()) {// 快照大忌:如果有pending塊,不可生成快照。
      // abort the pending block
      chain.abort_block();// 將pending塊幹掉
   } else {
      reschedule.cancel();// 無pending塊,則取消出塊計劃。
   }
   // 開始寫快照。
   auto head_id = chain.head_block_id();
   // 快照目錄:可通過配置producer_plugin的snapshots-dir項來指定快照目錄,會在節點資料目錄下生成該快照目錄,如果未特殊指定,預設目錄名字為“snapshots”
   // 在快照目錄下生成格式為“snapshot-${id}.bin”的快照檔案。id是當前鏈的頭塊id
   std::string snapshot_path = (my->_snapshots_dir / fc::format_string("snapshot-${id}.bin", fc::mutable_variant_object()("id", head_id))).generic_string();

   EOS_ASSERT( !fc::is_regular_file(snapshot_path), snapshot_exists_exception,
               "snapshot named ${name} already exists", ("name", snapshot_path));

   auto snap_out = std::ofstream(snapshot_path, (std::ios::out | std::ios::binary));// 構造快照檔案輸出流
   auto writer = std::make_shared<ostream_snapshot_writer>(snap_out);// 構造快照寫入器
   chain.write_snapshot(writer);// 備份當前鏈寫入快照
   // 資源釋放。
   writer->finalize();
   snap_out.flush();
   snap_out.close();

   return {head_id, snapshot_path};// 返回快照檔案路徑
}
複製程式碼

快照的部分就介紹完畢了,區塊生產者可以根據需要呼叫producer_plugin的rpc介面create_snapshot為當前鏈建立快照。經過以上研究可以得出,EOS的快照是對狀態資料庫的備份,而不是block_log日誌檔案的備份,不可逆區塊在全網有很多節點作為備份,不必本地備份,而狀態資料庫很可能是本地唯一的,與其他節點都不同,如果有損壞會造成很多未上到不可逆區塊日誌的事務丟失。 當需要使用快照恢復時,可以重新啟動鏈,同時設定chain_plugin的引數“snapshot”,傳入快照檔案路徑,通過快照恢狀態資料庫。

總結

本節重點介紹了EOS中的核心控制器controller的功能和使用。controller的功能是非常多的,貫穿整個鏈生命週期的大部分行為,深入研究會發現controller實際上是對資料的控制,正如java中的mvc模式,控制器的功能就是對持久化資料的操作。本節首先介紹了兩個c++的語法使用,一個是名稱空間另一個是using關鍵字,另外文中也提到了boost的訊號槽機制。接著瀏覽了controller的宣告和實現的程式碼結構,最後,在眾多功能中挑選了fork_database分叉庫和snapshot快照進行了詳細的研究與分析。其他的眾多功能由於他們與外掛的緊密互動性,將會在相關外掛的部分詳細分析。

參考資料

  • EOSIO/eos
  • boost

相關文章和視訊推薦

圓方圓學院彙集大批區塊鏈名師,打造精品的區塊鏈技術課程。 在各大平臺都長期有優質免費公開課,歡迎報名收看。

公開課地址:ke.qq.com/course/3451…

相關文章