前回は、ノードの起動〜停止までをテストコードから追いました。今回は同じことを、実際のソースコードから読んでゆきます。
記事内のコード引用は、前回と同じくCodablecashのソースコード967b7eb(2026年9月8日)のコミットからお借りしています。
ノードの動き
これから見てゆくノードの動きは、大まかに以下のようになっています。
- 自分の名札を用意する
- 待ち受けスタート
- 最初に連絡する相手(シード)を知る
- 3の相手に一時的に接続し、他のノードの連絡先が載った「電話帳」をもらう
- 電話帳から何台か選んで、ノード仲間としてつなぐ
- つないだらご挨拶。双方の処理を行う。
- 仲間の情報に追いついて(同期して)、日常の動きに入る
- 退出
関連するクラス
ノードに関連するクラスは、src_blockchain_p2p/配下と、src_blockchain/bc_network_instance/配下にいました。
NodeIdentifier: ノードの名札P2pNodeRecord: 連絡先1件分(ゾーン・名札・ホスト・ポート・プロトコルなどを持つ)INetworkSeeder/ArrayNetworkSheeder: 最初の連絡先を渡す「シーダー」P2pDnsManager/P2pNodeDatabase: 電話帳の窓口担当と、ノードが保有する「MY電話帳」BlochchainP2pManager: 接続まわりの管理担当(接続中のノード仲間・一般客、一斉送信など)DownloadDnsInfoCommand: 相手ノードの電話帳の一部をコピーさせてもらうためのコマンドConnectNodeSelector: 電話帳から接続先を選ぶLoginPubSubCommand/NodeNetworkInfo: ノードにつながるためのコマンドと、そのとき添える自分の名刺DnsCheckCommandMessage: 名刺を受け取った側のノードが相手に折り返して確認NodeShutdownCommand: 退出を通知するコマンド
1. ノードの名札は公開鍵
まず、ノードは自身の名札であるNodeIdentifierを用意します。
// src_blockchain/bc_network/NodeIdentifier.h
class NodeIdentifier : public IBlockObject {
public:
// ・・・・・
const BigInteger* getPublicKey() const noexcept {
return this->nodeIdentifier;
}
// ・・・・・
private:
BigInteger* nodeIdentifier; // pubkey
};
メンバ変数の名前はnodeIdentifierですが、コメントにpubkeyとある通り、中身は公開鍵のようです。対になる秘密鍵はNodeIdentifierSourceが持っていて、ノードはこの鍵でログインや各種の通知に署名します。
// src_blockchain/bc_network/NodeIdentifierSource.h
class NodeIdentifierSource : public IBlockObject {
public:
// ・・・・・
static NodeIdentifierSource* create(); // 鍵のペアを新しく作る
NodeIdentifier toNodeIdentifier() const noexcept; // 公開鍵から名札を作る
const BigInteger* getSecretKey() const noexcept; // 署名に使う秘密鍵
// ・・・・・
private:
IKeyPair* pair; // 秘密鍵と公開鍵のペア
};
「名札」といっても「好きに付けられる名前」ではなく、「署名ができる名札」のようです。これはステーキングの記事で出てきた、投票プールを登録するときのNodeIdentifierと同じものです。
2〜3. 待ち受け・最初につながる相手
ネットワークの開始となる、CodablecashNetworkNode::startNetwork()の中身が以下です。
// src_blockchain/bc_network_instance/CodablecashNetworkNode.cpp
void CodablecashNetworkNode::startNetwork(INetworkSeeder* seeder, bool pending) {
// ・・・・・
// startNetwork
const UnicodeString* host = this->nwconfig->getHost();
int port = this->nwconfig->getPort();
this->inst->startNetwork(host, port); // 待ち受けを始める
// start processors
{
const NodeIdentifierSource* networkKey = this->nwconfig->getNetworkKey();
this->inst->startProcessors(networkKey, pending); // 届いた命令を処理する担当たちを起こす(pendingは7で)
}
// init database
if(seeder != nullptr){
ArrayList<P2pNodeRecord>* seedList = seeder->getSeedNodes(); __STP(seedList);
// ・・・・・
if(seedList != nullptr && !seedList->isEmpty()){
initDatabase(seedList); // シーダーの連絡先から電話帳を作る
}
}
// init connections
maintainNetwork(); // 電話帳から選んで接続する
}
引数で渡されているINetworkSeederが「最初につながる相手」のリストです。中身は以下のようになっていて、連絡先(P2pNodeRecord)のリストが配列で入っています。
// src_blockchain/bc_network_instance/INetworkSeeder.h
class INetworkSeeder {
public:
// ・・・・・
virtual ArrayList<P2pNodeRecord>* getSeedNodes() const noexcept = 0;
virtual void addRecord(const P2pNodeRecord* record) noexcept = 0;
// ・・・・・
};
さらにそのリストの中の1件分のP2pNodeRecordを見てみると、以下のような情報が入っています。
| 項目 | 中身 |
|---|---|
zone |
ゾーン番号 |
nodeId |
名札(公開鍵) |
canonicalName |
人間向けの名前 |
protocol |
TCP_IP_V4かTCP_IP_V6か |
host / port |
接続先のアドレスとポート |
createdTime / lastUpdatedTime |
登録・更新の時刻 |
list |
ゾーンごとの、つながっている相手 |
1台目となるノードは最初に連絡する相手がいないので、シーダーは空で起動します。そのためこういった連絡先リストを処理する流れは2台目以降になります。
4. 最初の相手から「電話帳」をもらう
ノードは、まだ見ぬ多数のノードとつながる必要があります。その相手をどのように知るのでしょう。
まず、シードとして「最初につながる相手」の連絡先をもらったノードはinitDatabase()→P2pDnsManager::importSeeds()でその相手に接続します。そして、その相手が持っている電話帳をもらいに行きます。
自分自身は1件(もしくは少し)しか他のノードを知らなくても、そのノードの知り合いを紹介してもらうイメージです。
// src_blockchain_p2p/bc_p2p_info/P2pDnsManager.cpp
void P2pDnsManager::importSeeds(int numZone, const P2pNodeRecord *nodeRecord, ISystemLogger *logger) {
// ・・・・・
const UnicodeString* host = nodeRecord->getHost();
int port = nodeRecord->getPort();
try{
for(int i = 0; i != numZone; ++i){
doImportSeeds(i, host, port, logger); // ゾーンごとに電話帳をもらう
}
// add seed
this->database->importUnconnectedNode(nodeRecord); // シード自身も電話帳に入れる
}
// ・・・・・
}
void P2pDnsManager::doImportSeeds(uint16_t zone, const UnicodeString *host, int port, ISystemLogger *logger) {
P2pClient client(zone, logger);
// ・・・・・(接続)
DownloadDnsInfoCommand command(zone);
client.sign(&command);
AbstractCommandResponse* response = client.sendCommnad(&command); __STP(response);
// client close
client.close();
DownloadDnsInfoCommandResponse* dnsInfoResponse = dynamic_cast<DownloadDnsInfoCommandResponse*>(response);
if(dnsInfoResponse != nullptr){
const ArrayList<P2pNodeRecord>* list = dnsInfoResponse->getRecords();
// ・・・・・
for(int i = 0; i != maxLoop; ++i){
P2pNodeRecord* rec = list->get(i);
this->database->importUnconnectedNode(rec); // もらった連絡先を自分の電話帳へ
}
}
}
P2pDnsManagerなるものが、個々のノードが所有する「MY電話帳」の窓口担当です。
そのP2pDnsManagerが呼んでいるP2pClientは、ウォレットがノードから電話帳をもらうときにも使われている「一般客」としての接続です。接続にはLoginClientCommandを使っていて、電話帳をもらったらすぐに切ります。
「接続」の種類
P2pClientは「一般客」としての接続だと書きましたが、このあと6章で出てくる『ノード仲間』としての接続とは、以下のように使うコマンドが違います。
LoginClientCommand(P2pClientが使う):「こんにちは〜、電話帳ください」と一般客としてつながるLoginPubSubCommand(6章):「私もノードです、こんにちは〜」と名刺を添えて仲間としてつながる
つまり、まだ誰ともつながっていないノードは、いきなり「私も仲間です」とつながるのではなく、まずは一般客として接続し、「すみませんが、お知り合いのノードのリストをくださいな」と伺って、そのノードの持っている電話帳をコピーさせてもらって帰る、という手順になっているようです。
そして、ここでもらえる電話帳のリストには上限があり、1回のDownloadDnsInfoCommandで最大256件となっていました。
// src_blockchain_p2p/bc_p2p_cmd_client/DownloadDnsInfoCommand.cpp
DownloadDnsInfoCommand::DownloadDnsInfoCommand(uint16_t zone) : AbstractClientRequestCommand(TYPE_CLIENT_DOWNLOAD_DNS) {
this->zone = zone;
this->maxNum = 256;
}
「知り合いノードのリストをください」と言われた側のノードは、自分のP2pDnsManagerから該当ゾーンの連絡先を取り出して返しています。
// src_blockchain_p2p/bc_p2p_cmd_client/DownloadDnsInfoCommand.cpp
AbstractCommandResponse* DownloadDnsInfoCommand::executeAsClient(ClientNodeHandshake *clientHandshake, CodablecashNodeInstance *inst) const {
P2pDnsManager* p2pManager = inst->getP2pDnsManager();
DownloadDnsInfoCommandResponse* respose = new DownloadDnsInfoCommandResponse(); __STP(respose);
ArrayList<P2pNodeRecord>* list = p2pManager->getZoneRecords(this->zone, this->maxNum); __STP(list);
// ・・・・・
respose->importRecords(list);
return __STP_MV(respose);
}
こうしてゲットした電話帳はP2pNodeDatabaseによってノードのデータフォルダの中にファイルとして保存されています。一度手に入れた電話帳のリストは自身でもちゃんと保有して、ノードを終了・再起動しても手元に残る、ということのようです。
5. 電話帳から選んでつなぐ
ここまでで、やっと「MY電話帳」が用意できました。この時点ではまだノードとしては誰とも繋がっていない状態です(先ほどリストを受け取ったときは来客窓口から声をかけただけで、ノードとしての接続ではない)。なので今からやっと仲間と接続します。
startNetwork()の最後で呼ばれていたmaintainNetwork()が、ゾーンごとに「あと何本足りないか」を数えて、足りないぶんだけ電話帳から選んでつなぐ形になります。この接続には目標の本数があって、以下で設定されていました。
// src_blockchain/bc/CodablecashSystemParam.cpp
CodablecashSystemParam::CodablecashSystemParam() {
// ・・・・・
this->numConnectionOwnShard = 8;
this->numConnectionExtShard = 4;
this->numInitialSyncNodes = 3;
// ・・・・・
}
自分のゾーン(numConnectionOwnShard)に8本、ほかの各ゾーン(numConnectionExtShard)に4本になるまで、自分からつなぎに行くようです。
ではいよいよ接続作業です!まずは接続相手を選びます。
// src_blockchain/bc/CodablecashNodeInstance.cpp
void CodablecashNodeInstance::__maintainNetwork(uint16_t zone) {
int numNeedConnection = this->param->getNumConnectionExtShard(); // ほかのゾーン: 4
if(zone == getZoneSelf()){
numNeedConnection = this->param->getNumConnectionOwnShard(); // 自分のゾーン: 8
}
int currentSize = this->p2pManager->getNumNodes(zone); // 今つながっている数
int diff = numNeedConnection - currentSize; // 足りない数
if(diff > 0){
ArrayList<P2pNodeRecord>* list = this->p2pDnsManager->getZoneRecords(zone, 1024); __STP(list);
// ・・・・・
ConnectNodeSelector selector;
selector.importCandidates(list); // 電話帳に載っている連絡先を候補にする
while(diff > 0 && selector.hasNext()){
const P2pNodeRecord* rec = selector.fetchNext(); // 1件選ぶ
if(__connectWithP2pNodeRecord(rec)){ // つながったら
diff--; // 残りを減らす
}
}
}
}
ゾーンごとの目標本数と今つながっているノードの数を出して、目標に足りていない分だけ電話帳から取ってきて接続を繰り返します。
最後のif(__connectWithP2pNodeRecord(rec))は接続が成功したかどうかをチェックしていて、成功したら「あと何本」の残りを減らしています。接続失敗ならカウントせず次の相手に接続、という形です。
電話帳からどうやって接続相手をピックアップするかというと、以下のConnectNodeSelector::fetchNext()がそれを担っていました。候補の中からくじ引きのようにランダムに1台ずつ選んで、一度選んだ相手は候補から外しています。
// src_blockchain_p2p/bc_p2p_selector/ConnectNodeSelector.cpp
const P2pNodeRecord* ConnectNodeSelector::fetchNext() const {
int index = toIndex(this->candidates->size());
P2pNodeRecord* record = this->candidates->remove(index); // 取り出した候補は候補から外す
this->usedCandidates->addElement(record);
return record;
}
6. ノード接続時、双方のうごき
ついにノード同士でつながります。このときの、つなぐ側と、それを受け取る側両方の動きをそれぞれ見てゆきます。
つなぐ側: 名刺を添えてご挨拶
つなぐ側の視点、loginNode()から。
まず、相手ノードに回線をつないだら、LoginPubSubCommandでご挨拶を送ります。LoginPubSubCommandという名前ですが、ログインといっても一般的なIDやパスワードを使うものではなく、「私はこの名札(公開鍵)のノードです」とご挨拶するような形式のものです。このときに自分の連絡先が書かれた名刺(NodeNetworkInfo)を添えています。
// src_blockchain/bc/CodablecashNodeInstance.cpp
void CodablecashNodeInstance::loginNode(uint16_t zone, P2pHandshake *handshake, const UnicodeString* canonicalName) {
// login
NodeIdentifierSource* source = this->p2pRequestProcessor->getNetworkKey(); // 自分の鍵
uint16_t zoneSelf = this->statusCache->getZoneSelf();
LoginPubSubCommand cmd(zoneSelf, canonicalName);
{
NodeIdentifier nodeId = source->toNodeIdentifier(); // 鍵から名札を作る
SystemTimestamp tm;
int protocol = this->p2pManager->getProtocol();
const UnicodeString* host = this->p2pManager->getHost();
uint32_t port = this->p2pManager->getPort();
NodeNetworkInfo info(zoneSelf, &nodeId, &tm, protocol, host, port); // 名刺を準備
info.setCanonicalName(this->nodeName);
cmd.setNodeNetworkInfo(&info);
}
cmd.sign(source); // 自分の鍵で署名
AbstractCommandResponse* response = handshake->publishCommand(&cmd); __STP(response); // 「こんにちは、私もノードです(名刺スッ)」
// ・・・・・
uint32_t type = response->getType();
if(type != AbstractCommandResponse::TYPE_RES_OK){
throw new P2pHandshakeAuthenticationException(L"Already connected.", __FILE__, __LINE__);
}
}
接続のための準備を色々と行って、後半のhandshake->publishCommand(&cmd)が相手ノードに送信している箇所です。その後のresponse->getType()で相手ノードからの返事がOKでなければ、「もうつながっている」として例外を投げています。
受ける側: 相手確認と登録
次は、接続を受ける側の視点です。挨拶を受けたノード側では、LoginPubSubCommand::execute()が実行されます。
// src_blockchain_p2p/bc_p2p_cmd/LoginPubSubCommand.cpp
AbstractCommandResponse* LoginPubSubCommand::execute(const PubSubId* pubsubId, IPubsubCommandListner *listner) const {
try{
checkSignature(); // 1. 署名が名札(公開鍵)と合うか
}
catch(Exception* e){
delete e;
return new SignatureErrorResponse();
}
{
// ・・・・・
BlockchainNodeHandshake* node = p2pMgr->getNodeHandshakeByNodeId(this->nodeId);
StackHandshakeReleaser __releaser(node);
if(node != nullptr){ // 2. 同じ名札がもうつながっていたら断る
ErrorPubsubResponse* res = new ErrorPubsubResponse();
return res;
}
}
listner->fireExecuteCommand(pubsubId, this); // 3. 接続として登録
// check & register dns
if(this->nodeNetInfo){ // 4. 名刺を担当に回す
// ・・・・・
netDnsProcessor->requestDnsCheck(this->nodeNetInfo);
}
OkPubsubResponse* res = new OkPubsubResponse();
return res;
}
やっていることを順番書くと、
- 署名の確認:
checkSignature()で名札(公開鍵)から署名を確かめます。なりすましはここで弾かれます - 二重接続チェック:
getNodeHandshakeByNodeId()ですでに繋がっているノードIDをチェックして、もし同じ鍵のノードがすでに接続済みならエラーとします - 接続中リストに登録:
fireExecuteCommand()で、接続中の相手を管理するBlochchainP2pManagerに相手を登録してもらう - MY電話帳に登録:もらった名刺を別の担当に回して、相手ノードの確認ができたらMY電話帳(
P2pDnsManager)にも登録
4の「確認」とはなにかというと、「このノードに接続できるかな?」の確認です。DnsCheckCommandMessage::checkAndRegister()で以下のようにしています。
// src_blockchain_p2p/bc_p2p_processor/DnsCheckCommandMessage.cpp
void DnsCheckCommandMessage::checkAndRegister(P2pRequestProcessor* processor, ISystemLogger* logger) {
{
IClientSocket* con = this->nodeNetInfo->getConnection(); __STP(con); // もらった名刺の連絡先に自分から接続してみる
StackConnectionCloser _st_closer(con);
ExceptionThrower<PubsubNetworkException>::throwExceptionIfCondition(!con->isConnected(), L"Connection does not reach.", __FILE__, __LINE__);
}
// dns check
P2pDnsManager* dnsManager = processor->getP2pDnsManager();
dnsManager->updateOrAddRecord(this->nodeNetInfo, logger); // 届いたときだけ電話帳に登録
}
名刺に書かれた連絡先に自分からつなぎ直してみて、ちゃんと接続できたときだけ、MY電話帳に登録します。届かなければ例外になって、電話帳には登録されません。
ここで、「今つながっている相手に自分からつなぎ直したらつながらない、なんてことあるのかな?」と思ったのですが、外から届かないノードだった場合はこのケースに当てはまるようです。ノード自身はプログラムの中で待ち受けを開始しますが、ルーターやファイアウォールなどの設定で外部からのアクセスを拒否している、という場合があります。
- 外からは届かないノードでも → 他のノードに「もしもし」と声をかけることは可能
- 外からは届かないノードなら → 他のノードから「もしもし」はできない(相手の電話帳には載らない)
7. つながったあと
無事に仲間とつながったあとは、仲間のブロックに追いつくための同期が始まります。syncNetwork()ですね。
// src_blockchain/bc_network_instance/CodablecashNetworkNode.cpp
void CodablecashNetworkNode::syncNetwork() {
NetworkNodeSync sync(this);
sync.sync(); // 仲間に追いつく
this->inst->resumeRequestProcessor(); // 保留箱を片付けて、保留モードを解除する
}
sync.sync()の部分は、ゾーンごとに「つながっているノードから3台(numInitialSyncNodesで定義)をランダムに選び、それぞれからブロックヘッダをもらう。自分のゾーンだけは、ブロックの中身も取りに行く」という動きになっているようです。
// src_blockchain/bc_network_instance_sync/AbstractZoneSync.cpp
ArrayList<NodeIdentifier>* nodelist = p2pManager->getNodeIds(this->zone); __STP(nodelist);
// ・・・・・
int num = param->getNumInitialSyncNodes(); // 3
RamdomNodesSelector sel(nodelist, num); // つながっている中から3台
while(sel.hasNext()){
const NodeIdentifier* nodeId = sel.next();
// ・・・・・
HeaderSyncWorker* worker = new HeaderSyncWorker(this->zone, nodeId, this, this->logger, &str);
this->workers->addElement(worker);
worker->start();
}
そしてsyncNetwork()の最後で呼ばれているresumeRequestProcessor()についてです。これは処理を再開させる関数ですが、「再開」ってなにかというと、「止めておいた処理の再開」を指します。
止めておいた処理とは何かというと、改めてネットワーク開始時の関数CodablecashNetworkNode::startNetwork(INetworkSeeder* seeder, bool pending)を見てください。第二引数がbool pendingとなっています。
2台目以降のノードは、このpending(保留中)をtrueにしてネットワークを開始していました。これは「まだ仲間に追いついていないので、届いた新着のお知らせ(新しいブロックや取引)は、ひとまず保留箱に溜めておく」という指定です。なんでかというと、ノード起動は、次の順番で進む必要があるからです。
- 保留モードでネットワークにつながる(
startNetwork(&seeder, true)) - 仲間からブロックをもらって追いつく(
sync()) - 追いついたら、保留箱に溜まったお知らせを片付けて、保留モードを解除する(
resumeRequestProcessor())
古いデータのまま新着を処理し始めないように、まずは追いつくことを優先しているのですね。逆に1台目は追いつく相手がいないのでfalseで、最初から保留なしで動いています。
8. ノードを停止するとき
最後は、ノードの停止時です。停止する側のノードはトラブルでなければ自分の意思で停止します。その停止と、「停止します」を受け取った仲間ノードは何をするのかを見てゆきます。
まずは停止する側です。停止を仲間に広めるのはbroadCastShutdownCommand()という関数です。「私は止まります」というお知らせに自分の鍵で署名して、全ゾーンの仲間に流しています。
// src_blockchain/bc/CodablecashNodeInstance.cpp
void CodablecashNodeInstance::broadCastShutdownCommand(const NodeIdentifierSource *source) {
NodeShutdownCommand command;
NodeIdentifier nodeId = source->toNodeIdentifier();
command.signTimestamp(source); // 時刻に署名
command.sign(source); // 本体に署名
this->p2pManager->broadCastAllZones(nullptr, &command, this->p2pRequestProcessor); // 全ゾーンへ
}
そして、そのお知らせを受け取ったノード側の処理がこちらのNodeShutdownCommand::executeAsNode()です。
// src_blockchain_p2p/bc_p2p_cmd_network/NodeShutdownCommand.cpp
AbstractCommandResponse* NodeShutdownCommand::executeAsNode(BlockchainNodeHandshake *nodeHandShake, CodablecashNodeInstance *inst, bool suspend) const {
{
bool vl = verifyTimestamp(); // 本人の署名か
// ・・・・・
}
bool updated = updateShutdownNode(inst); // 電話帳から消して、接続を切る
if(!suspend && updated && this->hops < 5){ // まだ5回転送されていなければ
// ・・・・・
command.incHpos();
command.sign(source);
// ・・・・・
p2pManager->broadCastAllZones(this->nodeId, &command, processor); // 自分の仲間にも転送
}
return new OkPubsubResponse();
}
「停止します」を受け取ったら、まずは相手を電話帳から消して、接続を切る。そして自分の仲間にも転送する、という流れです。この転送はhopsでカウントしていて、5回までとなっていますね。
ここでも署名を確かめているので、他人が勝手に「あのノードはやめました」という知らせを作ることはできない設計です。
おわりに
もう少し要点を絞って挙げられるといいのですが、関連コードを並べすぎて読みにくくなっていたらすみません(コードが壮大すぎて追いかけるだけで精一杯気味です)。
でも、こうして1つずつ追ってみたことでノードが何をしているのかがうっすら理解できました。最初の1つのノードからスタートして、それぞれのノードがだんだんと手をつないで・・・と想像するととても面白く、シナプスを想起させるような、不思議な感じがしますね。