十七.读写区块索引(上)
1.CBlock未定义问题
在这之前先来解决一个问题,就是之前我不是将CBlock类代码移到了main.h中吗。
为了遵照源码的写法,然后将CBlockIndex和CDiskBlockIndex都写进了main.h中。
但报大量错误:使用了未定义的class CBlock。因为在CBlockIndex和CDiskBlockIndex大量使用了CBlock,就报这里的错误。我当时就奇怪了,我声明了啊。最上面有class CBlock。
然后我把这三个类放到main.cpp就不报错,奇怪。
最终跟什么main.h和main.cpp无关。始于我的一个误解。因为C++这门语言是我很久以前学的,有一些东西都忘记了。只是临时复习了一段时间。
没有去了解这个类声明,我以为类声明只要class CBlock这样就行了,这种声明只能定义一个指针是不报错的,如果你要构建对象或者调用类里的函数是会报错,你需要声明整个类的结构,变量和函数才行。
所以我在main.h中,是将CBlockIndex和CDiskBlockIndex定义在CBlock前面的。
而在main.cpp因为我没有那个错误的class CBlock的声明,我下意识的就将CBlock定义在他们的前面。所以导致这种假象,然后我又去看了源码中的定义,确实是CBlock定义在这两个类的前面。
问题出在我打乱顺序,所以将main.h中的这三个类的声明改好顺序就行,就能解决这个问题。
然后这里我们要将CTransaction及其下涉及所有交易结构的类都搬到main.h中(注意顺序)按main.cpp中的顺序来。否则会有声明问题,因为这些我们都没有声明。
然后将下面这一长串的声明复制到main.h最上面:
class COutPoint; class CInPoint; class CDiskTxPos; class CCoinBase; class CTxIn; class CTxOut; class CTransaction; class CBlock; class CBlockIndex; class CWalletTx; class CKeyItem;这种声明是没问题的,就算没有具体的定义,只要没有调用,也是可以的,所以我们将CkeyItem这些还没用到的声明一下也是可以的(为了省事所以都复制过来了)
2.CTxDB
好,现在AddToBlockIndex函数就只剩下的CTxDB问题了,我们转到db.cpp,db.h,恢复CTxDB类。
我们先将db.h中的CTxDB声明注释取消,恢复代码。
然后转到db.cpp中取消所有CTxDB相关的代码注释。
2.CTxIndex txindex
先来解决第一个报错,不允许使用不完整的类型txindex,是因为这个类型CTxIndex只有声明没有定义。
相关代码如下:
bool CTxDB::ReadTxIndex(uint256 hash, CTxIndex& txindex) { assert(!fClient); txindex.SetNull(); return Read(make_pair(string("tx"), hash), txindex); }这个ReadTxIndex是在读什么呢?他根据哈希值,可以读取这笔交易数据在文件中的编号,所属块的偏移,以及具体那笔交易数据的偏移。
注意这个哈希值不是CBlock的哈希值,而是CTransaction的哈希值,即一笔交易的哈希值,txid。
这就是从DB数据库里读的,也就是说,每笔交易数据的索引也被存到数据库里,不仅仅是之前的区块索引。那么这必然又有着另一套相似的但不同名索引系统。一个是block一个是tx.
所以这里的CTxIndex其实就对应着CBlockIndex,我们看它们的取名就知道,如果是tx相关的索引和数据读取,就带有Tx标志,如果是Block就带有Block.
那么们来看看CTxIndex的具体结构:
// CTxIndex 类用于存储一笔交易的磁盘位置索引信息 // 以及该交易输出(outputs)被花费的情况 // // 这是早期比特币交易数据库(TxDB)中非常重要的一个结构。 // 它的主要作用是:通过交易哈希(txid)快速定位该交易在磁盘上的位置, // 并记录它的每个输出是否已经被后续交易花费。 // // 注意:vSpent 虽然存储了花费位置,但早期主要用作“是否已花费”的标志, // 实际位置信息主要用于调试和开发。 // class CTxIndex { public: // 该交易在磁盘上的具体位置(哪个 blk 文件、哪个区块、交易在区块中的偏移量) CDiskTxPos pos; // 记录该交易每个输出(output)被花费的情况 // vSpent[i] 表示第 i 个输出被哪笔交易的哪个输入所花费 // 如果 vSpent[i] 是空(IsNull()),则说明该输出尚未被花费 // Satoshi 时代主要把 vSpent 当作“已花费标志”使用,位置信息更多用于调试 vector<CDiskTxPos> vSpent; // 默认构造函数:将对象初始化为空状态 CTxIndex() { SetNull(); } // 带参数的构造函数 // posIn:交易在磁盘上的位置 // nOutputs:该交易的输出数量(用于初始化 vSpent 的大小) CTxIndex(const CDiskTxPos& posIn, unsigned int nOutputs) { pos = posIn; vSpent.resize(nOutputs); // 根据输出数量分配空间 } // 序列化 / 反序列化支持(用于写入和读取 Berkeley DB 数据库) // IMPLEMENT_SERIALIZE 是早期比特币自定义的宏,用于简化序列化代码 IMPLEMENT_SERIALIZE ( if (!(nType & SER_GETHASH)) READWRITE(nVersion); // 版本号(早期版本中通常不使用) READWRITE(pos); // 写入/读取交易位置 READWRITE(vSpent); // 写入/读取每个输出的花费记录 ) // 将对象重置为空状态 void SetNull() { pos.SetNull(); // 清空位置信息 vSpent.clear(); // 清空花费记录 } // 判断当前 CTxIndex 是否为空(即数据库中没有找到该交易的索引) bool IsNull() { return pos.IsNull(); } // 重载 == 运算符,用于比较两个 CTxIndex 是否完全相同 friend bool operator==(const CTxIndex& a, const CTxIndex& b) { if (a.pos != b.pos || a.vSpent.size() != b.vSpent.size()) return false; // 逐个比较 vSpent 中的每个元素 for (int i = 0; i < a.vSpent.size(); i++) if (a.vSpent[i] != b.vSpent[i]) return false; return true; } // 重载 != 运算符(直接调用 == 的反结果) friend bool operator!=(const CTxIndex& a, const CTxIndex& b) { return !(a == b); } };3.CDiskTxPos
这个类下面还有一个关键的类型:CDiskTxPos,顾名思义,Disk和tx,pos就表明,磁盘上tx的位置。定义如下:
// CDiskTxPos 类表示一笔交易(或交易输出)在磁盘文件中的物理位置 // // 这是早期比特币存储系统中非常重要的一个定位结构。 // 比特币把所有区块数据顺序追加写入 blk00000.dat、blk00001.dat 等文件中, // CDiskTxPos 就是用来精确记录“某笔交易到底写在哪个文件、哪个位置”的。 // class CDiskTxPos { public: // 区块数据文件编号(从 0 开始) // 例如:0 表示 blk00000.dat,1 表示 blk00001.dat,以此类推 unsigned int nFile; // 在该 blk 文件中,区块头(CBlock)的起始位置(字节偏移量) // 即该交易所属的区块从文件的哪个字节开始 unsigned int nBlockPos; // 在该区块内部,交易数据(CTransaction)的起始位置(字节偏移量) // 即交易的具体位置 = 区块起始位置 + nTxPos unsigned int nTxPos; // 默认构造函数:初始化为空位置 CDiskTxPos() { SetNull(); } // 带参数构造函数:直接设置文件编号、区块位置和交易位置 CDiskTxPos(unsigned int nFileIn, unsigned int nBlockPosIn, unsigned int nTxPosIn) { nFile = nFileIn; nBlockPos = nBlockPosIn; nTxPos = nTxPosIn; } // 序列化支持(用于写入和读取 Berkeley DB 数据库) // FLATDATA(*this) 表示把整个结构体当作字节数组直接读写,效率较高 IMPLEMENT_SERIALIZE( READWRITE(FLATDATA(*this)); ) // 将位置信息重置为空状态(无效位置) // nFile = -1 是约定俗成的“空位置”标记 void SetNull() { nFile = -1; nBlockPos = 0; nTxPos = 0; } // 判断当前位置是否为空(无效) // 当 nFile == -1 时表示该位置未设置或不存在 bool IsNull() const { return (nFile == -1); } // 重载 == 运算符:判断两个位置是否完全相同 friend bool operator==(const CDiskTxPos& a, const CDiskTxPos& b) { return (a.nFile == b.nFile && a.nBlockPos == b.nBlockPos && a.nTxPos == b.nTxPos); } // 重载 != 运算符 friend bool operator!=(const CDiskTxPos& a, const CDiskTxPos& b) { return !(a == b); } // 将位置信息转换为字符串,便于打印和调试 string ToString() const { if (IsNull()) return strprintf("null"); else return strprintf("(nFile=%d, nBlockPos=%d, nTxPos=%d)", nFile, nBlockPos, nTxPos); } // 打印位置信息到控制台(调试用) void print() const { printf("%s\n", ToString().c_str()); } };好,我们把这两个类放进main.h中,注意顺序,然后让CTxDB能正常读取tx索引到数据库中。
让这些索引数据有一个承载的对象。
4.CTransaction
if (nItemHeight >= nMinHeight) { vtx.resize(vtx.size() + 1); if (!vtx.back().ReadFromDisk(pos)) return false; }4.1ReadFromDisk
这里,这里会报错,就是ReadFromDisk函数,在CTransaction里我们并没有定义。
因为我们现在用的是简写的CTransaction,所以很多东西没有。
这个ReadFromDisk就跟CBlock里的函数一样,根据位置从磁盘读取一个tx实例化自身。
我们将这个函数添加进CTransaction中
bool ReadFromDisk(CDiskTxPos pos, FILE** pfileRet = NULL) { CAutoFile filein = OpenBlockFile(pos.nFile, 0, pfileRet ? "rb+" : "rb"); if (!filein) return error("CTransaction::ReadFromDisk() : OpenBlockFile failed"); // Read transaction if (fseek(filein, pos.nTxPos, SEEK_SET) != 0) return error("CTransaction::ReadFromDisk() : fseek failed"); filein >> *this; // Return file pointer if (pfileRet) { if (fseek(filein, pos.nTxPos, SEEK_SET) != 0) return error("CTransaction::ReadFromDisk() : second fseek failed"); *pfileRet = filein.release(); } return true; }这里可以看到读取交易数据,只指定了起始位置,并没结束位置,他是怎么知道要读多少的呢。
关键在这句filein>>*this,是会依据对象来读取多少字节的。
也就是内部有指明大小,针对对象的特殊协议。tx,vin,vout都属于这个分类。读写这些对象时,不是直接读写,而是遵循一套协议,先读写大小,后根据大小,读写相应数量的数据。
4.2SetNull
然后这里报错:
bool CTxDB::ReadDiskTx(uint256 hash, CTransaction& tx, CTxIndex& txindex) { assert(!fClient); tx.SetNull(); if (!ReadTxIndex(hash, txindex)) return false; return (tx.ReadFromDisk(txindex.pos)); }tx.SetNull(),CTransaction没有这个SetNull函数。
将函数代码添加进去:
void SetNull() { nVersion = 1; vin.clear(); vout.clear(); nLockTime = 0; }然后给CTransaction类成员定义如下:
class CTransaction { public: int nVersion; vector<CTxIn> vin; vector<CTxOut> vout; int nLockTime;5.LoadBlockIndex
好,前面都没问题了,现在就剩下解决CTxDB的LoadBlockIndex函数的问题了。
这跟我们之前解决CWalletDB下的LoadWallet是同样的工作。
1.pindexGenesisBlock
CBlockIndex* pindexGenesisBlock = NULL;将上面这个在main.cpp和main.h定义和声明一下。
2.hashGenesisBlock
然后是:
const uint256 hashGenesisBlock("0x000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f");同样定义和声明,但是这个创世哈希值后续我们可能需要更改。
6.hashBestChain
然后是LoadBlockIndex里的这段代码:
if (!ReadHashBestChain(hashBestChain)) { if (pindexGenesisBlock == NULL) return true; return error("CTxDB::LoadBlockIndex() : hashBestChain not found\n"); } if (!mapBlockIndex.count(hashBestChain)) return error("CTxDB::LoadBlockIndex() : blockindex for hashBestChain not found\n"); pindexBest = mapBlockIndex[hashBestChain]; nBestHeight = pindexBest->nHeight; printf("LoadBlockIndex(): hashBestChain=%s height=%d\n", hashBestChain.ToString().substr(0,14).c_str(), nBestHeight);最优链相关操作,我们暂时不要,直接全部注释掉。
7.fClient
然后报db.obj中无法解析的外部符号fClient,我在db.h看到这句:
extern bool fClient;只有声明,但没有定义,奇怪可能我之前忘记了吧。
这个看源码定义在net.h中,我们直接加上:
bool fClient = false;但是加上后,又报fClient重定义,然后我又把net.h中的定义注释掉。
然后又报未定义,我仔细看一下是db.obj和main.obj报这个未定义,说明db.cpp和main.cpp这两个cpp使用了fClient这个变量。
然后我又去看了下头文件,我发现为什么把这种非static变量放在net.h中呢,如果多次包含,这样不是很容易引起重定义吗?
然后一看,嗨,原来是我看错了,这个是在net.cpp中定义的。
我们没有net.cpp,所以有些忽视了,这样我们新建一个net.cpp吧,添加如下代码解决即可:
#include"headers.h" // // Global state variables // bool fClient = false;好现在,程序编译能正常运行了。接下来我们准备测试读写索引功能了,以便调试,遇到什么问题再进行代码的添加,因为被我注释的代码我感觉有些还是必须要用上的。
我们一边测试一边查找。
8.准备测试
改好简写后的bool CBlock::AddToBlockIndex(unsigned int nFile, unsigned int nBlockPos)
函数只做两件事,将区块索引写进内存mapBlockIndex,以及磁盘CTxDB数据库中。
不涉及根据分叉判断来写,也没有写交易索引,只写区块索引。也没有验证以及网络相关部分。
现在就是纯粹的写一个区块索引。
这个函数在哪调用的呢?在CBlock中的AcceptBlock函数中,调用WriteToDisk写完区块数据后,就会接着调用AddToBlockIndex写索引。
所以我们现在把它们两个都放在AcceptBlock函数里,并删除之前的blk0001.dat文件,我们需要重新测试,这次增加了添加索引的功能。(这次不是单独调用WriteToDisk)。
我们先来看一下原本的AcceptBlock代码:
bool CBlock::AcceptBlock() { // Check for duplicate uint256 hash = GetHash(); if (mapBlockIndex.count(hash)) return error("AcceptBlock() : block already in mapBlockIndex"); // Get prev block index map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.find(hashPrevBlock); if (mi == mapBlockIndex.end()) return error("AcceptBlock() : prev block not found"); CBlockIndex* pindexPrev = (*mi).second; // Check timestamp against prev if (nTime <= pindexPrev->GetMedianTimePast()) return error("AcceptBlock() : block's timestamp is too early"); // Check proof of work if (nBits != GetNextWorkRequired(pindexPrev)) return error("AcceptBlock() : incorrect proof of work"); // Write block to history file unsigned int nFile; unsigned int nBlockPos; if (!WriteToDisk(!fClient, nFile, nBlockPos)) return error("AcceptBlock() : WriteToDisk failed"); if (!AddToBlockIndex(nFile, nBlockPos)) return error("AcceptBlock() : AddToBlockIndex failed"); if (hashBestChain == hash) RelayInventory(CInv(MSG_BLOCK, hash)); // // Add atoms to user reviews for coins created // vector<unsigned char> vchPubKey; // if (ExtractPubKey(vtx[0].vout[0].scriptPubKey, false, vchPubKey)) // { // unsigned short nAtom = GetRand(USHRT_MAX - 100) + 100; // vector<unsigned short> vAtoms(1, nAtom); // AddAtomsAndPropagate(Hash(vchPubKey.begin(), vchPubKey.end()), vAtoms, true); // } return true; }在接受块之前有很多判断。
9.判断重复块
mapBlockIndex.count(hash)
这个不用管,判断这个哈希值是否重复,就是已经存在添加过了。
10.创世块生成
mi == mapBlockIndex.end()
这个是查找它的父区块哈希,如果没有,则不通过。
很明显,我们无法通过这个判断,因为我们创建的区块是单独的,没有给他加上父区块。
那这里有个问题,如果是创世区块呢?
如果创世区块不在这里判断,也就是说在调用AcceptBlock之前,默认创世区块是存在的。
它是在程序启动时先调用LoadBlockIndex全局函数,判断创世块是否存在,不存在则创建,注意这个跟CTxDB里的LoadBlockIndex是不一样的,它会先调用CTxDB里的LoadBlockIndex函数加载数据库索引,如果发现为空,也就是没有创世块,则会创建创世块,是这么个逻辑。具体代码。等以后用到的时候再贴上来。
我们现在需要修改AcceptBlock函数,所以这个父区块判断,第二个逻辑的代码我们需要临时注释一下。以便测试我们的写索引功能。
11.中位时间
nTime <= pindexPrev->GetMedianTimePast()
第三个判断,是接受块的时候,判断这个块的nTime是否小于中位时间,如果是,则拒绝接受该块。
我们先来说一下什么是中位时间,就是GetMedianTimePast干了什么事,将前11个区块的nTime,按从小到大排序然后取中间那个nTime,就是第6个块的时间。
然后你的块的时间,不能早于这个时间。为什么这样做,为了保证区块的生成时间总是线性向前的,不能倒退。否则你把nTime设置为2009年,那不是也可以。
而因为是去中心化,没有一个权威去相信,你不可能去获取系统时间来对比,否则被造假怎么办。
所以取前11个区块的中位时间。
还有一是nLockTime,规定了我这个区块在多少时间后才能被打包,也就是延时交易吧。
比如现在3号,我要10号这个交易才可以被确认,它是怎么判断对比的呢?
在CTransaction里IsFinal判断,但我看了具体的代码,只有对比区块高度判断,并不是对比nTime判断。相关功能还没实现。
总而言之这个中位时间相当于一个权威的时间,目的是为了防止区块时间nTime被造假,不能太早也不能太晚,需要一个参考物。然后就可以实现其他相关功能。比如这里的这句判断,又比如后来的版本中nLockTime相关,你设置nTime得通过中位检查,然后才会对比nLockTime和nTime,这样即你的nTime造假,有一定的误差,那几乎也没有影响,相差不大。
所以这里的中位时间判断,我们也得临时注释,因为我们没有前11个区块,或者前几个区块,如果少于11个。它还是会取中间块的那个时间。但是我们是0个区块。
而且第二个逻辑已经被我们注释了,它不会获取到父区块pindexPrev,所以也无从调用。
所以这里的中位时间我们直接注释吧。
12.GetNextWorkRequired
if (nBits != GetNextWorkRequired(pindexPrev))
这里直接注释,我们同样没有pindexPrev,但是还是说明一下这个nBits难度判断,在前面我们判断了区块的nTime是否符合要求,这里又增加了难度判断是否符合要求,目的都是为防止矿工胡乱设置。换句话说,这些都是对矿工提交区块的合法性检查。
只大概讲一下这个是干什么的,GetNextWorkRequired函数逻辑就不说明了,是怎么计算难度的,留待以后吧。直接注释。
然后还有里也注释掉吧:
if (hashBestChain == hash) RelayInventory(CInv(MSG_BLOCK, hash));就是这个区块是被你添加到你最优链,不是分叉块也不是过时块,你就会高兴的通知其他节点,跟大家分享,调用RelayInventory函数广播出去,当然只广播哈希。是为了为止跟你有同样高兴的人。所以得验证一下哈希,如果其他节点发现没有这个哈希,才会请求这个块的具体数据,这样做的好处是节省带宽。
我们不需要广播,直接注释,这样注释下来,其实几乎就剩两句代码了,AcceptBlock只调用了WriteToDisk和AddToBlockIndex了。如下:
bool CBlock::AcceptBlock() { // Check for duplicate uint256 hash = GetHash(); if (mapBlockIndex.count(hash)) return error("AcceptBlock() : block already in mapBlockIndex"); //// Get prev block index 临时注释 //map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.find(hashPrevBlock); //if (mi == mapBlockIndex.end()) // return error("AcceptBlock() : prev block not found"); //CBlockIndex* pindexPrev = (*mi).second; //// Check timestamp against prev //if (nTime <= pindexPrev->GetMedianTimePast()) // return error("AcceptBlock() : block's timestamp is too early"); //// Check proof of work //if (nBits != GetNextWorkRequired(pindexPrev)) // return error("AcceptBlock() : incorrect proof of work"); // Write block to history file unsigned int nFile; unsigned int nBlockPos; if (!WriteToDisk(!fClient, nFile, nBlockPos)) return error("AcceptBlock() : WriteToDisk failed"); if (!AddToBlockIndex(nFile, nBlockPos)) return error("AcceptBlock() : AddToBlockIndex failed"); //if (hashBestChain == hash) // RelayInventory(CInv(MSG_BLOCK, hash)); // // Add atoms to user reviews for coins created // vector<unsigned char> vchPubKey; // if (ExtractPubKey(vtx[0].vout[0].scriptPubKey, false, vchPubKey)) // { // unsigned short nAtom = GetRand(USHRT_MAX - 100) + 100; // vector<unsigned short> vAtoms(1, nAtom); // AddAtomsAndPropagate(Hash(vchPubKey.begin(), vchPubKey.end()), vAtoms, true); // } return true; }11.测试代码,好现在的测试很简单,调用我们之前的GenerateTestBlock函数,构建一个区块。
然后调用块的AcceptBlock函数即可。
但是这里我们还需要看一下代码,创建索引文件blkindex.dat应该会碰到和wallet.dat同样的问题。
class CTxDB : public CDB { public: CTxDB(const char* pszMode = "r+", bool fTxn = false) : CDB(!fClient ? "blkindex.dat" : NULL, pszMode, fTxn) {}它传的同样是r+,也就是不支持创建文件。
而我们上面的测试,同样没有调用跟LoadWallet一样的LoadBlockIndex函数,这是分别创建wallet.dat和blkindex.dat文件函数,如果文件不存在的话。
那我们这里还是遵循之前wallet.dat的解决方法,在构造CTxDB对象,传参数"cr"。(减少测试工作量,不调用Load)
即AddToBlockIndex函数里,对应的部分改成如下代码:
CTxDB txdb("cr"); txdb.TxnBegin(); txdb.WriteBlockIndex(CDiskBlockIndex(pindexNew));13.测试代码
好,main中测试代码如下:
int main() { CBlock b = GenerateTestBlock(); bool res=b.AcceptBlock(); if (res) { cout << "写区块数据和写索引目录成功!" << endl; auto it = mapBlockIndex.begin(); // 迭代器指向第一个元素 uint256 firstHash = it->first; cout << "索引哈希值:" << firstHash.ToString() << endl; CBlockIndex* pIndex = it->second; cout << "索引区块中的时间:(对比是否一致)" << pIndex->nTime<<endl; } return 0; }这个GenerateTestBlock函数代码在前面的章节有给出,这里就不贴代码,就是填充一个块的数据。然后区块数据部分就不输出了,因为WriteToDisk函数之前已测试过。
这里只输出一下索引相关的数据,而且是读内存中的,不是数据库里的。
数据库将会在读的部分给出,这里仅测试写的部分,只是为了展示一下数据。
注:程序有报错,解决报错过程由于太长,写在了下一章。