概覽
區塊鏈的基礎概念非常簡單, 說白了就是一個維護着一個持續增長的有序數據記錄列表的這么一個分布式數據庫。在此章節中我們將實現一個簡單的玩具版的區塊鏈。此章節結束時,我們的區塊鏈將實現以下功能:
- 實現區塊和區塊鏈結構定義
- 實現可以將包含任意數據的新區塊寫入到區塊鏈的方法
- 實現可以與其他節點進行點到點溝通和同步區塊鏈數據的運行節點
- 操作單個運行節點的簡單HTTP(Restful) API
區塊數據結構
我們首先會從區塊數據結構的定義開始。在當前階段,簡單起見,我們只會給每個區塊定義最關鍵的屬性。
- index: 區塊在區塊鏈中的高度(即序號),因為每加一個區塊,該index就會加1,所以幣圈將其稱之為高度。
- data: 任何需要包括在此區塊中的數據。本章節中可以是任何數據,到后面章節我們會用來記賬用。
- timestamp: 時間戳。本章節中也是可以是任何數據,往后我們需要保證這個字段是正確的時間戳數據,用來防止攻擊等用。
- hash: 根據區塊內容計算的哈希值(SHA256)。
- previousHash: 前一個區塊的哈希值。通過這個屬性,我們能很方便回溯前面的區塊。
相應代碼大致如下:
class Block {
public index: number;
public hash: string;
public previousHash: string;
public timestamp: number;
public data: string;
constructor(index: number, hash: string, previousHash: string, timestamp: number, data: string) {
this.index = index;
this.previousHash = previousHash;
this.timestamp = timestamp;
this.data = data;
this.hash = hash;
}
}
區塊哈希
區塊哈希值是區塊中最重要的屬性之一。哈希值根據區塊中的所有數據計算而得,這意味着如果區塊中任何數據發生變化,原有的哈希值就不再有效。區塊哈希值也能被看成區塊的唯一性標識。比如說,兩個人同時挖礦成功,那就有可能出現兩個高度一致的區塊,但是因為要通過其他屬性值一起算哈希(往后我們會看到data屬性會存放交易數據,交易數據,特別是id,肯定不能重復),所以絕對不會出現一樣的哈希值。
根據以下的代碼來計算哈希值:
const calculateHash = (index: number, previousHash: string, timestamp: number, data: string): string =>
CryptoJS.SHA256(index + previousHash + timestamp + data).toString();
需要注意的是,在這個階段,區塊的哈希值與挖礦沒有任何關系,因為還未有 POW(工作量證明) 問題需要解決。我們使用區塊哈希值來保證區塊的完整性,同時也使用它來回溯前一個區塊。
由以上對 hash 和 previousHash 屬性的處理機制,很容易得出區塊鏈的一個重要特性:區塊的內容不能被修改,除非同時修改它后續的所有區塊內容。
以下的例子描述了這個特性。如果將第44區塊的數據從“DESERT”修改成“STREET”,所有后續區塊的哈希值也必須被修改。這是由於區塊的哈希值是通過對區塊的內容計算哈希得到的,而內容中包含了 previousHash 這個代表了前一個區塊的哈希的值。
這個特性在我們后面章節中引入的工作量證明機制來說尤其重要。一個區塊在區塊鏈中的位置越深(即越靠前),要修改它的難度就越大,因為需要同時修改它本身以及它后續的所有區塊。
創世塊
創世塊是區塊鏈中的第一個區塊。它是唯一一個沒有 previousHash 的區塊,因為這個區塊比較特別,我們在代碼里會將創世區塊進行硬編碼處理:
const genesisBlock: Block = new Block(
0, '816534932c2b7154836da6afc367695e6337db8a921823784c14378abed4f7d7', null, 1465154705, 'my genesis block!!'
);
創建區塊
創建一個新的區塊,需要獲得上一個區塊的哈希值,並創建其他必須的內容( index, hash, data 和 timestamp)。區塊的數據(data字段)由用戶提供,其他的參數使用以下代碼生成:
const generateNextBlock = (blockData: string) => {
const previousBlock: Block = getLatestBlock();
const nextIndex: number = previousBlock.index + 1;
const nextTimestamp: number = new Date().getTime() / 1000;
const nextHash: string = calculateHash(nextIndex, previousBlock.hash, nextTimestamp, blockData);
const newBlock: Block = new Block(nextIndex, nextHash, previousBlock.hash, nextTimestamp, blockData);
return newBlock;
};
保存區塊鏈
目前我們使用 JavaScript 的數組,將區塊鏈保存在程序的運行內存中。這意味着當一個運行節點停止時,該節點上的區塊鏈數據不會被持久化。
const blockchain: Block[] = [genesisBlock];
驗證區塊完整性
為確保數據完整性,我們應想辦法做到可隨時對一個區塊,或者一條區塊鏈上的區塊進行有效性驗證。特別是當我們的節點從其他運行節點中接收到廣播過來的新區塊時,我們就需要驗證區塊的有效性,以便決定是否接受這些區塊。
驗證區塊的有效性,需要滿足以下所有條件:
- 區塊的 index 需要比上一個區塊大1;
- 區塊的 previousHash 屬性需要與上一個區塊的 hash 屬性一致;
- 區塊自身的 hash 值需要有效。
以下代碼描述了整個驗證過程:
const isValidNewBlock = (newBlock: Block, previousBlock: Block) => {
if (previousBlock.index + 1 !== newBlock.index) {
console.log('invalid index');
return false;
} else if (previousBlock.hash !== newBlock.previousHash) {
console.log('invalid previoushash');
return false;
} else if (calculateHashForBlock(newBlock) !== newBlock.hash) {
console.log(typeof (newBlock.hash) + ' '
+ typeof calculateHashForBlock(newBlock));
console.log('invalid hash: '
+ calculateHashForBlock(newBlock) + ' '
+ newBlock.hash);
return false;
}
return true;
};
同時我們還必須驗證該區塊的結構是否正確,以避免其他節點廣播過來的帶有不正確格式的數據導致程序崩潰。
const isValidBlockStructure = (block: Block): boolean => {
return typeof block.index === 'number'
&& typeof block.hash === 'string'
&& typeof block.previousHash === 'string'
&& typeof block.timestamp === 'number'
&& typeof block.data === 'string';
};
既然我們現在能夠驗證單個區塊的有效性,我們就可以進一步的對整個區塊鏈進行有效性驗證了。首先驗證鏈中的第一個區塊為創世區塊。然后,我們使用以上的方式來依次校驗鏈中的下一個區塊,以下為實現代碼:
const isValidChain = (blockchainToValidate: Block[]): boolean => {
const isValidGenesis = (block: Block): boolean => {
return JSON.stringify(block) === JSON.stringify(genesisBlock);
};
if (!isValidGenesis(blockchainToValidate[0])) {
return false;
}
for (let i = 1; i < blockchainToValidate.length; i++) {
if (!isValidNewBlock(
blockchainToValidate[i], blockchainToValidate[i - 1])) {
return false;
}
}
return true;
};
選擇最長鏈
在任何時候,在區塊鏈系統中都應該只存在一條正確的鏈,但沖突還是在所難免的,我們需要有一個大家都認同的共識機制來確保沖突得以解決。在沖突發生的情況下(比如:主鏈在71這個塊的時候發生分叉,然后我緊鄰的節點在某一條鏈的基礎上挖出了第73個塊),則從中選擇包含更長區塊的鏈(比如我的節點啟動時會和其他節點請求區塊鏈狀態,發現有最后塊為72和73的兩條鏈,那么我們的節點將會在73這個鏈的基礎上繼續貢獻資源進行挖礦)。在以下的例子中,由於被更長的區塊鏈復寫,第72區塊: a350235b00 中的數據將不會被包括在區塊鏈中。
代碼實現如下:
const replaceChain = (newBlocks: Block[]) => {
if (isValidChain(newBlocks)
&& newBlocks.length > getBlockchain().length) {
console.log('Received blockchain is valid. Replacing current blockchain with received blockchain');
blockchain = newBlocks;
broadcastLatest();
} else {
console.log('Received blockchain invalid');
}
};
節點間通信
每個運行節點都必須能和其他節點廣播和同步區塊鏈數據。我們通過以下規則保證節點間能正確有效的同步:
- 當一個節點生成新區塊時,該節點會將此區塊廣播至區塊鏈網絡中
- 當一個節點和另外一個節點建立點對點連接時,該節點將會向另一個節點請求最新的區塊鏈信息
- 當一個節點發現從其他節點過來的一個區塊的 index 比該節點中保留的區塊鏈的最后一個區塊的 index 大,根據兩個index之間相差的大小,該節點會有兩個選擇:如果只相差1,則將此區塊加到自身的區塊鏈中; 如果超過1,則需要向其他節點請求整條區塊鏈。
我們將會使用 WebSocket 技術來實現各個節點的點對點通信。各個節點的 socket 列表將保存在 const sockets: WebSocket[] 變量中。我們並沒有實現節點發現機制,所以新增加一個節點后,需要手動添加需要建立點對點連接的目標節點的地址。
操作節點
用戶需能夠以某種方式來操作節點。我們將通過實現相應的http服務端接口來提供相應功能。
const initHttpServer = ( myHttpPort: number ) => {
const app = express();
app.use(bodyParser.json());
app.get('/blocks', (req, res) => {
res.send(getBlockchain());
});
app.post('/mineBlock', (req, res) => {
const newBlock: Block = generateNextBlock(req.body.data);
res.send(newBlock);
});
app.get('/peers', (req, res) => {
res.send(getSockets().map(( s: any ) =>
s._socket.remoteAddress + ':' + s._socket.remotePort));
});
app.post('/addPeer', (req, res) => {
connectToPeers(req.body.peer);
res.send();
});
app.listen(myHttpPort, () => {
console.log('Listening http on port: ' + myHttpPort);
});
};
根據以上代碼暴露出來的HTTP接口,用戶可以發送請求到節點進行以下操作:
- 列出所有區塊
- 由用戶指定相應內容來創建一個新區塊
- 列出連接過來的節點的地址
- 通過websocket url連接到指定節點
您可以通過Curl工具來對節點進行操作,當然您也可以通過postman等工具來操作:
#get all blocks from the node
> curl http://localhost:3001/blocks
架構
每個節點都對外暴露兩個web 服務: 一個是用戶來給用戶對節點進行操作(HTTP Server),一個是用來實現節點間的點對點通信(Websocket HTTP server)。
運行測試
安裝
npm install
運行
打開一個終端運行節點1. 節點1的http服務端端口為3001, p2p端口為6001。
npm run node1
建議打開另外一個終端運行節點2,以便能通過輸出查看兩個區塊鏈節點是怎么通信的。 節點1的http服務端端口為3002, p2p端口為6002。
npm run node2
ps: 節點2運行后,即可以通過addPeer這個api和節點1進行websocket連接。
生成一個區塊
curl -H "Content-type:application/json" --data '{"data" : "Some data to the first block"}' http://localhost:3001/mineBlock
返回結果示例:
{
"index": 1,
"previousHash": "816534932c2b7154836da6afc367695e6337db8a921823784c14378abed4f7d7",
"timestamp": 1561025398.834,
"data": "Some data to the first block",
"hash": "979335f8383fa058c0abf5d342a232d345de51ea644756d3522eca5637e97a17"
}
獲取區塊鏈
curl http://localhost:3001/blocks
返回示例:
[
{
"index": 0,
"previousHash": "",
"timestamp": 1465154705,
"data": "my genesis block!!",
"hash": "816534932c2b7154836da6afc367695e6337db8a921823784c14378abed4f7d7"
},
{
"index": 1,
"previousHash": "816534932c2b7154836da6afc367695e6337db8a921823784c14378abed4f7d7",
"timestamp": 1561025398.834,
"data": "Some data to the first block",
"hash": "979335f8383fa058c0abf5d342a232d345de51ea644756d3522eca5637e97a17"
}
]
連接到一個節點
curl -H "Content-type:application/json" --data '{"peer" : "ws://localhost:6001"}' http://localhost:3002/addPeer
查詢連接的節點列表
curl http://localhost:3001/peers
返回示例:
["::ffff:127.0.0.1:54261"]
小結
到現在為止,我們實現了一個簡單的玩具版的區塊鏈。此外,本章節還為我們展示了如何用簡單扼要的方法來實現區塊鏈的一些基本原理。下一章節中我們將為naivecoin 加入工作量證明機制。
本章節的代碼請查看這里
本文由天地會珠海分舵編譯,轉載需授權,喜歡點個贊,吐槽請評論,如能給Github上的項目給個星,將不勝感激。