【劉文彬】RPC的基礎:調研EOS插件http_plugin

原文連接:www.cnblogs.com/Evsward/p/h…html

區塊鏈的應用是基於http服務,這種能力在EOS中是依靠http_plugin插件賦予的。 關鍵字:通信模式,add_api,http server,https server,unix server,io_service,socket,connection前端

通信模式

EOS中,一個插件的使用要先獲取其實例,例如http_plugin獲取實例的語句是:node

auto& _http_plugin = app().get_plugin<http_plugin>();
複製代碼

其餘插件的獲取方式與此相同。目前爲止,包括前文介紹到的method、channel、信號槽、信號量,跨模塊的交互方式能夠總結爲五種:git

  • method,插件之間的調用,一個插件A將其函數按key註冊到method池中,其餘任意數量的插件B、C、D都可經過key去method池中找到該函數並調用。這種通信模式是一個由調用者主動發起的過程。
  • channel,插件之間的調用,一個插件A按key找到頻道並向頻道publish一個動做,其餘任意數量的插件B、C、D,甚至在不一樣節點上的插件B、C、D,只要是按key訂閱了該channel並綁定了他們各自本地的一個notify function,就會被觸發執行。這種通信模式是基於發佈訂閱模式,或者說是更高級的觀察者模式,是由發佈者的行爲交由channel來觸發全部訂閱者綁定的本地通知函數的過程。
  • 信號槽,插件與controller的交互過程。controller下啓基於chainbase的狀態數據庫,上承信號的管理,經過信號來與外部進行交互,controller會根據鏈的行爲emit一個對應的信號出來,其餘插件若是有處理該信號的需求會鏈接connect該信號並綁定函數實現。有時候一個信號會被多個插件所鏈接,例如accepted_block_header信號,是認可區塊頭的信號,會被net_plugin捕捉並處理,同時該信號也會被chain_plugin所捕捉,觸發廣播。
  • 信號量,通常是應用程序與操做系統發生的交互,在EOS中,應用程序的實例是application,它與操做系統發生的交互都是經過信號量來完成,首先聲明一個信號,而後經過async_wait觸發信號完成與操做系統的交互。
  • 實例調用,對比以上四種鬆散的方式,這種模式是強關聯,正如咱們剛剛學習編程時喜歡使用new/create而不考慮對象的垃圾處理以及實例管理,後來會採用解耦的鬆散的統一實例管理框架,或者採用單例而不是每次都要new/create。但這種方式並非徹底不被推薦的,當實例的某個成員直接被須要時,能夠直接經過該方式獲取到,而不是經過以上四種方式來使用。

目前總結出來的五種跨模塊交互方式,前四種更注重通信,最後一種更注重其餘模塊的內容。更注重通信的前四種是基於同一底層通信機制(socket),但適用於不一樣場景的設計實現。github

add_api函數

從chain_api_plugin過來,http_plugin的使用方式是:web

_http_plugin.add_api({
      CHAIN_RO_CALL(get_info, 200l),
      ...
   });
複製代碼

那麼,就從add_api入手研究http_plugin。add_api函數聲明在http_plugin頭文件中,說明該函數的內容不多或很具有通用性。數據庫

void add_api(const api_description& api) {
   for (const auto& call : api)
      add_handler(call.first, call.second);
}
複製代碼

從前面的調用代碼能夠看出,add_api函數的參數是一個對象集合,它們整體是一個api_description類型的常量引用。編程

using api_description = std::map<string, url_handler>;
複製代碼

api_description根據源碼可知是一個map,key爲string類型的url路徑地址,值爲url_handler是具體實現API功能的處理函數。在add_api的調用部分,宏CHAIN_RO_CALL調用了另外一個宏CALL,CALL組裝了map的這兩個數:json

#define CALL(api_name, api_handle, api_namespace, call_name) \
{std::string("/v1/" #api_name "/" #call_name), \
   [api_handle](string, string body, url_response_callback cb) mutable { \
          try { \
             if (body.empty()) body = "{}"; \
             auto result = api_handle.call_name(fc::json::from_string(body).as<api_namespace::call_name ## _params>()); \
             cb(200, fc::json::to_string(result)); \
          } catch (...) { \
             http_plugin::handle_exception(#api_name, #call_name, body, cb); \
          } \
       }}
複製代碼

CALL宏體包含兩個數據,以逗號隔開,前面部分爲url路徑地址,後面部分爲api_handler,此處其實是一個匿名內部函數。回到add_api函數的聲明,遍歷整個api,逐一執行add_handler爲url和api處理函數添加相互綁定的關係。api

add_handler函數

直接進入函數實現的代碼:

void http_plugin::add_handler(const string& url, const url_handler& handler) {
  ilog( "add api url: ${c}", ("c",url) ); // 輸出日誌
  app().get_io_service().post([=](){
    my->url_handlers.insert(std::make_pair(url,handler));
  });
}
複製代碼

app()前文講到了,是用來獲取application實例的,其包含一個public權限的成員函數get_io_service:

boost::asio::io_service& get_io_service() { return *io_serv; }
複製代碼

返回的是基於boost::asio::io_service庫的共享指針類型,application的私有成員io_serv的指針。

io_service是asio框架中的調度器,用來調度異步事件,application實例要保存一個io_service對象,用於保存當前實例的全部待調度的異步事件。
複製代碼

io_service的兩個重要方法:

  • post,用於發佈一個異步事件,依賴asio庫進行自動調度,不須要顯式調用函數。
  • run,顯式調用,同步執行回調函數。

當appbase.exec()執行時,io_service會同步啓動,若是一個插件須要IO或其餘異步操做,能夠經過如下方式進行分發:

app().get_io_service().post( lambda )
複製代碼

那麼,這種分發方式,除了在http_plugin的add_handler函數中使用到,EOSIO/eos中在bnet_plugin插件中有大量使用到,緣於bnet_plugin對異步事件發佈的需求。回到add_handler函數,post後面跟隨的是lambda表達式,[=]表明捕獲全部以值訪問的局部名字。lambda體是將url和handler做爲二元組插入到http_plugin_impl對象的惟一指針my的共有成員url_handlers集合中,數據類型與上面的api_description一致。

url_handlers集合

url_handlers集合的數據源是其餘插件經過add_api函數傳入組裝好的url和handler的對象。該集合做爲api的異步處理器集合,在http_plugin中消費該集合數據的是handle_http_request函數。該函數處理外部請求,根據請求url在url_handlers集合中查找數據,找到handler之後,傳入外部參數數據並執行handler對應的處理函數。

handle_http_request函數

/**
 * 處理一個http請求(http_plugin)
 * @tparam T socket type
 * @param con 鏈接對象
 */
template<class T>
void handle_http_request(typename websocketpp::server<T>::connection_ptr con) {
    try {
       auto& req = con->get_request(); // 得到請求對象req。
       if(!allow_host<T>(req, con))// 檢查host地址是否有效
          return;
       // 根據config.ini中http_plugin相關的鏈接配置項進行設置。
       if( !access_control_allow_origin.empty()) {
          con->append_header( "Access-Control-Allow-Origin", access_control_allow_origin );
       }
       if( !access_control_allow_headers.empty()) {
          con->append_header( "Access-Control-Allow-Headers", access_control_allow_headers );
       }
       if( !access_control_max_age.empty()) {
          con->append_header( "Access-Control-Max-Age", access_control_max_age );
       }
       if( access_control_allow_credentials ) {
          con->append_header( "Access-Control-Allow-Credentials", "true" );
       }
       if(req.get_method() == "OPTIONS") { // HTTP method包含:`GET` `HEAD` `POST` `OPTIONS` `PUT` `DELETE` `TRACE` `CONNECT`
          con->set_status(websocketpp::http::status_code::ok);
          return;// OPTIONS不能緩存,未能獲取到請求的資源。
       }
    
       con->append_header( "Content-type", "application/json" );// 增長請求頭。
       auto body = con->get_request_body(); // 得到請求體(請求參數)
       auto resource = con->get_uri()->get_resource(); // 得到請求的路徑(url)
       auto handler_itr = url_handlers.find( resource ); // 在url_handlers集合中找到對應的handler
       if( handler_itr != url_handlers.end()) {
          con->defer_http_response();// 延時響應
          // 調用handler,傳入參數、url,回調函數是lambda表達式,用於將接收到的結果code和響應body賦值給鏈接。
          handler_itr->second( resource, body, [con]( auto code, auto&& body ) {
             con->set_body( std::move( body )); // 接收到的響應body賦值給鏈接。
             con->set_status( websocketpp::http::status_code::value( code )); // 接收到的code賦值給鏈接。
             con->send_http_response();// 發送http響應
          } );
       } else {
          dlog( "404 - not found: ${ep}", ("ep", resource)); // 未在url_handlers集合中找到
          // 針對失敗的狀況,設置http的響應對象數據。
          error_results results{websocketpp::http::status_code::not_found,
                                "Not Found", error_results::error_info(fc::exception( FC_LOG_MESSAGE( error, "Unknown Endpoint" )), verbose_http_errors )};
          con->set_body( fc::json::to_string( results ));
          con->set_status( websocketpp::http::status_code::not_found );
       }
    } catch( ... ) {
       handle_exception<T>( con );
    }
}
複製代碼

下面來看該函數handle_http_request的使用位置。有兩處,均在http_plugin內部:

  • create_server_for_endpoint函數,爲websocket對象ws設置http處理函數,是一個lambda表達式,lambda體爲handle_http_request函數的調用,傳入鏈接對象con,由hdl轉換而來。另外,create_server_for_endpoint函數在http_plugin::plugin_startup中也有兩處調用。
  • http_plugin::plugin_startup,插件的啓動階段,下面將分析該插件的生命週期。

http_plugin的生命週期

正如研究其餘的插件同樣,學習路線離不開插件的生命週期。

插件通常都是在程序入口(例如nodeos,keosd)進行生命週期的控制的,通常不作區分,因爲插件有共同基類,程序入口作統一控制。
複製代碼

下面依次介紹http_plugin的生命週期。

http_plugin::set_defaults

僅屬於http_plugin插件的生命週期。設置默認值,默認值僅包含三項:

struct http_plugin_defaults {
  // 若是不爲空,該項的值將在被監聽的地址生效。做爲不一樣配置項的前綴。
  string address_config_prefix;
  // 若是爲空,unix socket支持將被徹底禁用。若是不爲空,值爲data目錄的相對路徑,做爲默認路徑啓用unix socket支持。
  string default_unix_socket_path;
  // 若是不是0,HTTP將被啓用於默認給出的端口號。若是是0,HTTP將不被默認啓用。
  uint16_t default_http_port{0};
};
複製代碼

nodeos的set_defaults語句爲:

http_plugin::set_defaults({
    .address_config_prefix = "",
    .default_unix_socket_path = "",
    .default_http_port = 8888
});
複製代碼

keosd的set_defaults語句爲:

http_plugin::set_defaults({
    .address_config_prefix = "",
    // key_store_executable_name = "keosd";
    .default_unix_socket_path = keosd::config::key_store_executable_name + ".sock", // 默認unix socket路徑爲keosd.sock
    .default_http_port = 0
});
複製代碼

http_plugin::set_program_options

設置http_plugin插件的參數,構建屬於http_plugin的配置選項,將與其餘插件的配置共同組成配置文件config.ini,在此基礎上添加--help等參數構建程序(例如nodeos)的CLI命令行參數。同時設置參數被設置之後的處理方案。

/**
 * 生命週期 http_plugin::set_program_options
 * @param cfg 命令行和配置文件的手動配置項的並集,交集以命令行配置爲準的配置對象。
 */
void http_plugin::set_program_options(options_description&, options_description& cfg) {
   // 處理默認set_defaults配置項。
  my->mangle_option_names();
  if(current_http_plugin_defaults.default_unix_socket_path.length())// 默認unix socket 路徑
     cfg.add_options()
        (my->unix_socket_path_option_name.c_str(), bpo::value<string>()->default_value(current_http_plugin_defaults.default_unix_socket_path),
         "The filename (relative to data-dir) to create a unix socket for HTTP RPC; set blank to disable.");
  if(current_http_plugin_defaults.default_http_port)// 設置默認http端口
     cfg.add_options()
        (my->http_server_address_option_name.c_str(), bpo::value<string>()->default_value("127.0.0.1:" + std::to_string(current_http_plugin_defaults.default_http_port)),
         "The local IP and port to listen for incoming http connections; set blank to disable.");
  else
     cfg.add_options()
        (my->http_server_address_option_name.c_str(), bpo::value<string>(),
         "The local IP and port to listen for incoming http connections; leave blank to disable.");// 端口配置爲空的話禁用http
  // 根據手動配置項來設置
  cfg.add_options()
        (my->https_server_address_option_name.c_str(), bpo::value<string>(),
         "The local IP and port to listen for incoming https connections; leave blank to disable.")// 端口配置爲空的話禁用http
        ("https-certificate-chain-file", bpo::value<string>(),// https的配置,證書鏈文件
         "Filename with the certificate chain to present on https connections. PEM format. Required for https.")
        ("https-private-key-file", bpo::value<string>(),// https的配置,私鑰文件
         "Filename with https private key in PEM format. Required for https")
        ("access-control-allow-origin", bpo::value<string>()->notifier([this](const string& v) {// 跨域問題,控制訪問源
            my->access_control_allow_origin = v;
            ilog("configured http with Access-Control-Allow-Origin: ${o}", ("o", my->access_control_allow_origin));
         }),
         "Specify the Access-Control-Allow-Origin to be returned on each request.")
        ("access-control-allow-headers", bpo::value<string>()->notifier([this](const string& v) {// 控制容許訪問的http頭
            my->access_control_allow_headers = v;
            ilog("configured http with Access-Control-Allow-Headers : ${o}", ("o", my->access_control_allow_headers));
         }),
         "Specify the Access-Control-Allow-Headers to be returned on each request.")
        ("access-control-max-age", bpo::value<string>()->notifier([this](const string& v) {// 控制訪問的最大緩存age
            my->access_control_max_age = v;
            ilog("configured http with Access-Control-Max-Age : ${o}", ("o", my->access_control_max_age));
         }),
         "Specify the Access-Control-Max-Age to be returned on each request.")
        ("access-control-allow-credentials",
         bpo::bool_switch()->notifier([this](bool v) {
            my->access_control_allow_credentials = v;
            if (v) ilog("configured http with Access-Control-Allow-Credentials: true");
         })->default_value(false), // 控制訪問容許的證書
         "Specify if Access-Control-Allow-Credentials: true should be returned on each request.")
         // 最大請求體的大小,默認爲1MB。
        ("max-body-size", bpo::value<uint32_t>()->default_value(1024*1024), "The maximum body size in bytes allowed for incoming RPC requests")
        // 打印http詳細的錯誤信息到日誌,默認爲false,不打印。
        ("verbose-http-errors", bpo::bool_switch()->default_value(false), "Append the error log to HTTP responses")
        // 校驗host,若是設置爲false,任意host均爲有效。默認爲true,要校驗host。
        ("http-validate-host", boost::program_options::value<bool>()->default_value(true), "If set to false, then any incoming \"Host\" header is considered valid")
        // 別名。另外可接受的host頭
        ("http-alias", bpo::value<std::vector<string>>()->composing(), "Additionaly acceptable values for the \"Host\" header of incoming HTTP requests, can be specified multiple times. Includes http/s_server_address by default.");
}
複製代碼

http_plugin::plugin_initialize

插件初始化的操做。讀取配置並作出處理。

實際上,在set_option_program階段也作了對配置值的讀取及轉儲處理。緣由是一些默認參數,即用戶不常常配置的選項,就不須要讀取用戶配置的選項,能夠在set_option_program階段作出處理,而那些須要用戶來配置的選項則須要在初始化階段讀入並處理。
複製代碼

初始化階段讀入的配置項包含:

  • validate_host,是否校驗host,bool類型的值。

  • valid_hosts,添加alias別名做爲有效host。

  • listen_endpoint,根據在set_option_program階段賦值的my成員http_server_address_option_name,重組處理獲得監聽點,同時添加至valid_hosts。

  • unix_endpoint,一樣根據my成員unix_socket_path_option_name處理,獲得絕對路徑賦值給unix_endpoint。

  • 對set_option_program階段賦值的my成員https_server_address_option_name的值的處理,https的兩個配置的處理,最終重組處理,分別賦值給my成員https_listen_endpoint,https_cert_chain,https_key,以及valid_hosts。

  • max_body_size,直接賦值。

    固然在初始化階段仍舊能夠配置set_option_program階段已作出處理的配置項,以用戶配置爲準。

http_plugin::plugin_startup

在插件中,啓動階段都是很是重要的生命週期。它每每代碼很簡單甚至簡略,但功能性很強。下面來看http_plugin的啓動階段的內容,g共分爲三部分:

  • listen_endpoint,本地節點的http監聽路徑,例如127.0.0.1:8888。
  • unix_endpoint,若是爲空,unix socket支持將被徹底禁用。若是不爲空,值爲data目錄的相對路徑,做爲默認路徑啓用unix socket支持。
  • https_listen_endpoint,https版本的本地節點http監聽路徑,通常不設置,對應的是配置中的https_server_address選項。

對於以上三種狀況,啓動階段分別作了三種對應的處理,首先來看最標準最多見的狀況,就是基於http的本地監聽路徑listen_endpoint:

if(my->listen_endpoint) {
    try {
        my->create_server_for_endpoint(*my->listen_endpoint, my->server); // 建立http服務(上面介紹到的函數)。內部調用了http請求處理函數。
        ilog("start listening for http requests");
        my->server.listen(*my->listen_endpoint);// 手動監聽設置端點。使用設置綁定內部接收器。
        my->server.start_accept();// 啓動服務器的異步鏈接,開始監聽:無限循環接收器。啓動服務器鏈接無限循環接收器。監聽後必須調用。在底層io_service開始運行以前,此方法不會有任何效果。它能夠在io_service已經運行以後被調用。有關如何中止此驗收循環的說明,請參閱傳輸策略的文檔。
    } catch ( const fc::exception& e ){
        elog( "http service failed to start: ${e}", ("e",e.to_detail_string()));
        throw;
    } catch ( const std::exception& e ){
        elog( "http service failed to start: ${e}", ("e",e.what()));
        throw;
    } catch (...) {
        elog("error thrown from http io service");
        throw;
    }
}
複製代碼

主要是啓動http服務的流程,包括客戶端和服務端,endpoint和server_endpoint兩個角色的啓動。下面來看基於unix socket的狀況unix_endpoint:

if(my->unix_endpoint) {
    try {
        my->unix_server.clear_access_channels(websocketpp::log::alevel::all);// 清除全部登錄的頻道
        my->unix_server.init_asio(&app().get_io_service());// 初始化io_service對象,io_service就是上面分析過的application的io_service對象,傳入asio初始化函數初始化asio傳輸策略。在使用asio transport以前必需要init asio。
        my->unix_server.set_max_http_body_size(my->max_body_size); // 設置HTTP消息體大小的最大值,該值決定了若是超過這個值的消息體將致使鏈接斷開。
        my->unix_server.listen(*my->unix_endpoint); // 手動設置本地socket監聽路徑。
        my->unix_server.set_http_handler([&](connection_hdl hdl) {// 設置http請求處理函數(注意此處再也不經過create_server_for_endpoint函數來調用,由於再也不須要websocket的包裝)。
           my->handle_http_request<detail::asio_local_with_stub_log>( my->unix_server.get_con_from_hdl(hdl));
        });
        my->unix_server.start_accept();// 同上,啓動server端的無限循環接收器。
    } catch ( const fc::exception& e ){
        elog( "unix socket service failed to start: ${e}", ("e",e.to_detail_string()));
        throw;
    } catch ( const std::exception& e ){
        elog( "unix socket service failed to start: ${e}", ("e",e.what()));
        throw;
    } catch (...) {
        elog("error thrown from unix socket io service");
        throw;
    }
}
複製代碼

下面來看基於https的本地監聽路徑https_listen_endpointd的處理:

if(my->https_listen_endpoint) {
    try {
        my->create_server_for_endpoint(*my->https_listen_endpoint, my->https_server); // 同上http的原理,只是參數換爲https的值。
        // 設置TLS初始化處理器。當請求一個TLS上下文使用時,將調用該TLS初始化處理器。該處理器必須返回一個有效TLS上下文,以支持當前端點可以初始化TLS鏈接。
        // connection_hdl,一個鏈接的惟一標識。它是實現了一個弱引用智能指針weak_ptr指向鏈接對象。線程安全。經過函數endpoint::get_con_from_hdl()能夠轉化爲一個完整的共享指針。
        my->https_server.set_tls_init_handler([this](websocketpp::connection_hdl hdl) -> ssl_context_ptr{
           return my->on_tls_init(hdl); 
        });
        ilog("start listening for https requests");
        my->https_server.listen(*my->https_listen_endpoint);// 同上http的原理,監聽地址。
        my->https_server.start_accept();// 同上http的原理,啓動服務。
    } catch ( const fc::exception& e ){
        elog( "https service failed to start: ${e}", ("e",e.to_detail_string()));
        throw;
    } catch ( const std::exception& e ){
        elog( "https service failed to start: ${e}", ("e",e.what()));
        throw;
    } catch (...) {
        elog("error thrown from https io service");
        throw;
    }
}
複製代碼

unix server與server的底層實現是一致的,只是外部的包裹處理不一樣,https_server的類型再加上這個ssl上下文的類型指針ssl_context_ptr。他們的聲明分別是:

using websocket_server_type = websocketpp::server<detail::asio_with_stub_log<websocketpp::transport::asio::basic_socket::endpoint>>; // http server
using websocket_local_server_type = websocketpp::server<detail::asio_local_with_stub_log>; // unix server
using websocket_server_tls_type =  websocketpp::server<detail::asio_with_stub_log<websocketpp::transport::asio::tls_socket::endpoint>>; // https server
using ssl_context_ptr =  websocketpp::lib::shared_ptr<websocketpp::lib::asio::ssl::context>; // https ssl_context_ptr
複製代碼
HTTPS = HTTP over TLS。TLS的前身是SSL。
複製代碼

從上面的聲明能夠看出,http和https最大的不一樣是,前者是basic_socket,後者是tls_socket,socket類型不一樣,http是基礎socket,https是包裹了tls的socket。

http_plugin::plugin_shutdown

關閉是插件的最後一個生命週期,代碼不多,主要執行的是資源釋放工做。

void http_plugin::plugin_shutdown() {
  if(my->server.is_listening())
     my->server.stop_listening();
  if(my->https_server.is_listening())
     my->https_server.stop_listening();
}
複製代碼

此處沒有unix_server的處理[#6393]。http和https都是socket,須要手動中止監聽,啓動無限循環接收器。unix server是經過io_service來異步處理,底層實現邏輯相同,也啓動了無限循環接收器。

總結

本文首先之外部使用http_plugin的方式:add_api函數爲研究入口,逐層深刻分析。接着從總體上研究了http_plugin的生命週期,進一步加深了對http_plugin的http/https/unix三種server的認識。


相關文章和視頻推薦

【劉傑良】使用RPC接口新建EOS帳戶 - 實戰

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

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

相關文章
相關標籤/搜索