概覽
區塊鏈的基礎概念非常簡單, 說白了就是一個維護著一個持續增長的有序資料記錄列表的這麼一個分散式資料庫。在此章節中我們將實現一個簡單的玩具版的區塊鏈。此章節結束時,我們的區塊鏈將實現以下功能:
- 實現區塊和區塊鏈結構定義
- 實現可以將包含任意資料的新區塊寫入到區塊鏈的方法
- 實現可以與其他節點進行點到點溝通和同步區塊鏈資料的執行節點
- 操作單個執行節點的簡單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上的專案給個星,將不勝感激。