原文,需要科学上网。省略了一些无关的内容。

世界上很多开发者听说过区块链却不知道它是怎么工作的,他们或许仅仅听过比特币或者智能合约一类的名词。这篇文章尝试用简明的语言说明区块链并且用不到200行的代码来实现你自己的区块链!文章的最后,你应该可以运行并且添加区块到区块链中并从浏览器中看到结果。

有什么学习区块链的方法比你亲自实现一个更好呢?

文章讲了什么

  • 如何创建自己的区块链
  • 如何使用Hash来维护区块链的完整性
  • 如何添加新区块
  • 如何解决多个节点同时生成区块导致冲突问题
  • 如何在浏览器中查看区块链
  • 如何生成新区块
  • 了解区块链的基础知识后,你可以决定你的未来发展方向

文章没讲什么

为了保持简单,我们并没介绍更高级的概念比如pow和pos的对比,我们模拟了网络交互所以你可以看到区块链并且添加区块,但网络广播部分将以后再讲。

准备工作

因为我们使用GO,所以假设你已经是一个有经验的GO开发者了。安装并设置GO开发环境后,需要安装下面的三方包:

1
go get github.com/davecgh/go-spew/spew

spew可以更好的输出structslices,你值得拥有。

1
go get github.com/gorilla/mux

mux是一个流行的web服务框架,我们需要这个。

1
go get github.com/joho/godotenv

godotenv让我们从根目录的.env文件读取配置信息,这样http端口一类的配置就不需要硬编码在代码中了。

让我们在根目录创建一个.env文件,里面定义我们http服务的端口,内容就一行:

1
ADDR=8080

再创建一个main.go文件,所有的代码都将写在这里并且不会超过200行,让我们开始吧!

imports

首先引入我们需要的库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"

"github.com/davecgh/go-spew/spew"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
)

数据模型

接下来定义一个struct作为区块,别担心,我们下面将解释每一个字段的作用:

1
2
3
4
5
6
7
type Block struct {
Index int
Timestamp string
BPM int
Hash string
PrevHash string
}

每一个Block中的数据都会写入到区块链中:

  • Index表示区块在区块链中的位置
  • Timestamp是自动生成的并记录了数据写入时间
  • BPM表示每分钟心跳次数
  • Hash是sha256加密后的数据
  • PrevHash表示前一个区块的Hash值

接下来,定义一个由Block组成的slices:

1
var Blockchain []Block

那么是如何把区块组成区块链的呢?我们使用Hash值来识别和保证区块的顺序正确。确保每个块中的PrevHash和前一个区块中的Hash一样,这样我们就知道了区块链的顺序。

pic1

Hashing和创建新区块

为什么需要进行Hash?主要有2个原因:

  • 节省空间。Hash结果是由区块中全部数据计算产生的,在我们这个例子中仅仅有少量数据,但想象一下如果一个区块中有成百上千的数据,记录数据hash后的结果显然比一次又一次的拷贝全部数据更加高效。
  • 保持区块链完整性。如上图,通过记录上一个区块的Hash值,我们能够确保区块链的顺序是正确的。如果有人恶意写入数据(比如想修改心跳来影响保险价格),hash值将被改变而且区块链将被”打破”,并且每个人都能知道并且不信任那个恶意的链条。

现在创建一个函数来计算区块的Hash值:

1
2
3
4
5
6
7
func calculateHash(block Block) string {
record := string(block.Index) + block.Timestamp + string(block.BPM) + block.PrevHash
h := sha256.New()
h.Write([]byte(record))
hashed := h.Sum(nil)
return hex.EncodeToString(hashed)
}

calculateHash函数将IndexTimestampBPMPrevHash链接成一个字符串并返回其SHA256后的结果。现在我们可以使用这些参数通过generateBlock函数创建一个新区块了。我们需要传入前一个区块和BPM值,不用担心这个BPM我们晚一些解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func generateBlock(oldBlock Block, BPM int) (Block, error) {

var newBlock Block

t := time.Now()

newBlock.Index = oldBlock.Index + 1
newBlock.Timestamp = t.String()
newBlock.BPM = BPM
newBlock.PrevHash = oldBlock.Hash
newBlock.Hash = calculateHash(newBlock)

return newBlock, nil
}

注意时间是使用time.now()自动创建的并且我们调用了之前写的calculateHash函数,PrevHash直接从前一个区块中拷贝出来,Index则是根据前一个区块的值自增1。

验证区块

现在我们需要写一些函数来确保区块链的真实性,通过检测Index来确保正确的自增,还需要检测PrevHash和前一个区块的Hash是否相同。最后我们再次使用calculateHash函数检查当前区块的Hash值。现在写一个isBlockValid函数并且返回bool类型,如果通过了全部的检查则返回true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func isBlockValid(newBlock, oldBlock Block) bool {
if oldBlock.Index+1 != newBlock.Index {
return false
}

if oldBlock.Hash != newBlock.PrevHash {
return false
}

if calculateHash(newBlock) != newBlock.Hash {
return false
}

return true
}

如果有2个节点同时计算出正确的Hash值并且添加到各自的区块链中,那么我们该相信哪一条呢?我们选择最长的那条。这在区块链中是一个很典型的问题,并且坏人也无计可施。

节点很容易产生不同长度的链,自然而言的最长的链条有最新的数据和最后一个区块。所以我们需要确保拥有最长的链条,这样做我们可以使用最新的区块链来覆盖原来的。

img2

通过简单的长度比较来实现这个功能:

1
2
3
4
5
func replaceChain(newBlocks []Block) {
if len(newBlocks) > len(Blockchain) {
Blockchain = newBlocks
}
}

恭喜!我们已经完成了所需要的基本函数,接下来我们想要一种简单的方法来查看和添加新区块。

### web服务
我们假设你已经很熟悉web服务是如何工作的并且会使用GO来实现,接下来将使用mux包来创建web服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func run() error {
mux := makeMuxRouter()
httpAddr := os.Getenv("ADDR")
log.Println("Listening on ", os.Getenv("ADDR"))
s := &http.Server{
Addr: ":" + httpAddr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}

if err := s.ListenAndServe(); err != nil {
return err
}

return nil
}

这里使用了之前在.env文件中定义的端口,并且通过log.Println输出一条消息来表明服务已经运行。

现在我们需要实现makeMuxRouter函数,为了实现在浏览器中读和写我们的区块链,我们需要2个简单的路由规则。GET请求来查看POST请求来创建新区块。

1
2
3
4
5
6
func makeMuxRouter() http.Handler {
muxRouter := mux.NewRouter()
muxRouter.HandleFunc("/", handleGetBlockchain).Methods("GET")
muxRouter.HandleFunc("/", handleWriteBlock).Methods("POST")
return muxRouter
}

处理GET请求的函数如下:

1
2
3
4
5
6
7
8
func handleGetBlockchain(w http.ResponseWriter, r *http.Request) {
bytes, err := json.MarshalIndent(Blockchain, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, string(bytes))
}

我们简单的使用json格式返回全部的区块链,当访问浏览器的localhost:8080端口时候我们可以看到结果。 如果你修改了.env文件中的ADDR,确保你访问的端口正确。

POST请求相对复杂一点,我们需要一个新结构体叫做Message,晚点解释为什么需要它:

1
2
3
type Message struct {
BPM int
}

处理这种请求的函数如下,我们晚点解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

func handleWriteBlock(w http.ResponseWriter, r *http.Request) {
var m Message

decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&m); err != nil {
respondWithJSON(w, r, http.StatusBadRequest, r.Body)
return
}
defer r.Body.Close()

newBlock, err := generateBlock(Blockchain[len(Blockchain)-1], m.BPM)
if err != nil {
respondWithJSON(w, r, http.StatusInternalServerError, m)
return
}
if isBlockValid(newBlock, Blockchain[len(Blockchain)-1]) {
newBlockchain := append(Blockchain, newBlock)
replaceChain(newBlockchain)
spew.Dump(Blockchain)
}

respondWithJSON(w, r, http.StatusCreated, newBlock)

}

因为我们需要使用新结构体来接受来自POST请求的JSON数据,这样我们就可以简单的通过发送POST请求并附带下面这种数据来生成新区块了:

1
{"BPM":50}

50是一个心跳次数的例子,你可以随意替换。

当我们从请求的body中解码出数据到var m Message中,我们通过调用generateBlock函数并传递前一个区块和心跳次数作为参数来创建一个新区块。然后通过isBlockValid函数来进行验证。

2点说明:

  1. spwe.Dump 是一个转换函数可以在命令行中输出好看的结构体,这对于调试十分有用。
  2. 为了测试POST请求,我们喜欢使用postman,如果你喜欢使用终端curl也是不错的选择。

不管请求是否成功,我们都想收到通知。同样写一个函数来让我们知道发生了什么。记住,在GO语言中,永远不要忽略了error,要 优雅的处理它们

1
2
3
4
5
6
7
8
9
10
func respondWithJSON(w http.ResponseWriter, r *http.Request, code int, payload interface{}) {
response, err := json.MarshalIndent(payload, "", " ")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("HTTP 500: Internal Server Error"))
return
}
w.WriteHeader(code)
w.Write(response)
}

接下来,让我们完成main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

func main() {
err := godotenv.Load()
if err != nil {
log.Fatal(err)
}

go func() {
t := time.Now()
genesisBlock := Block{0, t.String(), 0, "", ""}
spew.Dump(genesisBlock)
Blockchain = append(Blockchain, genesisBlock)
}()
log.Fatal(run())

}

这个函数都做什么了呢?

  • godotenv.Load()可以让我们从根目录.env文件中读取类似端口这种变量,这样就不用硬编码了。
  • genesisBlockmain函数中最重要的部分,我们需要一个创世块,否则新的区块链就没有前一个区块的Hash了。
  • 我们分离了生成区块的代码到goroutine中,也就是分离区块链逻辑和web服务逻辑。

哈!完成了!

完整代码在 这里

娱乐时间到,让我们试试。

打开终端并运行go run main.go,我们可以看到web服务启动并生成了一个区块:

b3

在浏览器中访问本机端口,我们使用8080,同样的我们看到相同的区块:
b4

接下来发送post请求生成区块:
b5

刷新浏览器,我们将看到区块链中有新的区块并且PrevHash值等于老区块的Hash值,一切都在预料中!
b6

下一步

恭喜,你已经完成了有Hash和验证功能你的区块链!你现在可以去探索更高级的主题比如:工作量证明、权益证明、智能合约、分布式app、边链等等。这篇文章并没有处理结合工作量证明挖矿的问题。这应该写一个单独的文章,并且很多区块链并没有使用工作量证明。此外网络广播部分现在是通过web服务模拟的,本文中没涉及到P2P相关知识。