1 鏈碼結構
1.1 鏈碼接口
鏈碼啟動必須通過調用 shim 包中的 Start 函數,傳遞一個類型為 Chaincode 的參數,該參數是一個接口類型,有兩個重要的函數 Init 與 Invoke 。
type Chaincode interface{
Init(stub ChaincodeStubInterface) peer.Response
Invoke(stub ChaincodeStubInterface) peer.Response
}
- Init:在鏈碼實例化或升級時被調用, 完成初始化數據的工作
- Invoke:更新或查詢帳本數據狀態時被調用, 需要在此方法中實現響應調用或查詢的業務邏輯
實際開發中, 開發人員可以自行定義一個結構體,重寫 Chaincode 接口的兩個方法,並將兩個方法指定為自定義結構體的成員方法。
1.2 鏈碼結構
package main
// 引入必要的包
import(
"fmt"
"github.com/hyperledger/fabric/core/chaincode/shim"
pb "github.com/hyperledger/fabric/protos/peer"
)
// 聲明一個結構體
type SimpleChaincode struct {
}
// 為結構體添加Init方法
func (t *SimpleChaincode) Init(stub shim.ChaincodeStubInterface) pb.Response{
// 在該方法中實現鏈碼初始化或升級時的處理邏輯
// 編寫時可靈活使用stub中的API
}
// 為結構體添加Invoke方法
func (t *SimpleChaincode) Invoke(stub shim.ChaincodeStubInterface) pb.Response{
// 在該方法中實現鏈碼運行中被調用或查詢時的處理邏輯
// 編寫時可靈活使用stub中的API
}
// 主函數,需要調用shim.Start()方法
func main() {
err := shim.Start(new(SimpleChaincode))
if err != nil {
fmt.Printf("Error starting Simple chaincode: %s", err)
}
}
- shim: 用來訪問/操作數據狀態、事務上下文和調用其他鏈代碼的 API, 鏈碼通過 shim.ChaincodeStub 提供的方法來讀取和修改賬本的狀態
- peer: 提供了鏈碼執行后的響應信息的 API,peer.Response 封裝了響應信息
2 鏈碼相關的 API
shim 包提供了如下幾種類型的接口:
- 參數解析 API:調用鏈碼時需要給被調用的目標函數/方法傳遞參數,該 API 提供解析這些參數的方法
- 賬本狀態數據操作 API:該 API 提供了對賬本數據狀態進行操作的方法,包括對狀態數據的查詢及事務處理等
- 交易信息獲取 API:獲取提交的交易信息的相關 API
- 對 PrivateData 操作的 API: Hyperledger Fabric 在 1.2.0 版本中新增的對私有數據操作的相關 API
- 其他 API:其他的 API,包括事件設置、調用其他鏈碼操作
2.1 參數解析 API
// 返回調用鏈碼時指定提供的參數列表(以字符串數組形式返回)
GetStringArgs() []string
// 返回調用鏈碼時在交易提案中指定提供的被調用的函數名稱及函數的參數列表(以字符串數組形式返回)
GetFunctionAndParameters() (function string, params []string)
// 返回提交交易提案時提供的參數列表(以字節串數組形式返回)
GetArgsSlice() ([]byte, error)
// 返回調用鏈碼時在交易提案中指定提供的被調用的函數名稱及函數的參數列表(以字符串數組形式返回)
GetArgs() [][]byte
一般使用 GetFunctionAndParameters() 及 GetStringArgs() 。
2.2 賬本數據狀態操作 API
// 查詢賬本,返回指定鍵對應的值
GetState(key string) ([]byte, error)
// 嘗試添加/更新賬本中的一對鍵值
// 這一對鍵值會被添加到寫集合中,等待 Committer 進一步確認,驗證通過后才會真正寫入到賬本
PutState(key string, value []byte) error
// 嘗試刪除賬本中的一對鍵值
// 同樣,對該對鍵值刪除會添加到寫集合中,等待 Committer 進一步確認,驗證通過后才會真正寫入到賬本
DelState(key string) error
// 查詢指定范圍的鍵值,startKey 和 endkey 分別指定開始(包括)和終止(不包括),當為空時默認是最大范圍
// 返回結果是一個迭代器結構,可以按照字典序迭代每個鍵值對,最后需要調用 Close() 方法關閉
GetStateByRange(startKey, endKey string) (StateQueryIteratorInterface, error)
// 返回指定鍵的所有歷史值。該方法的使用需要節點配置中打開歷史數據庫特性(ledger.history.enableHistoryDatabase=true)
GetHistoryForKey(key string) (HistoryQueryIteratorInterface, error)
// 給定一組屬性(attributes),將這些屬性組合起來構造返回一個復合鍵
// 例如:CreateComositeKey("name-age",[]string{"Alice", "12"});
CreateCompositeKey(objectType string, attributes []string) (string, error)
// 將指定的復合鍵進行分割,拆分成構造復合鍵時所用的屬性
SplitCompositeKey(compositeKey string) (string, []string, error)
// 根據局部的復合鍵(前綴)返回所有匹配的鍵值,即與賬本中的鍵進行前綴匹配
// 返回結果是一個迭代器結構,可以按照字典序迭代每個鍵值對,最后需要調用 Close() 方法關閉
GetStateByPartialCompositeKey(objectType string, keys []string) (StateQueryIteratorInterface, error)
// 對(支持富查詢功能的)狀態數據庫進行富查詢,返回結果是一個迭代器結構,目前只支持 CouchDB
// 注意該方法不會被 Committer 重新執行進行驗證,所以不能用於更新賬本狀態的交易中
GetQueryResult(query string) (StateQueryIteratorInterface, error)
注意: 通過 put 寫入的數據狀態不能立刻 get 到,因為 put 只是鏈碼執行的模擬交易(防止重復提交攻擊),並不會真正將狀態保存到賬本中,必須經過 Orderer 達成共識之后,將數據狀態保存在區塊中,然后保存在各 peer 節點的賬本中。
2.3 交易信息相關 API
// 返回交易提案中指定的交易 ID。
// 一般情況下,交易 ID 是客戶端提交提案時由 Nonce 隨機串和簽名者身份信息哈希產生的數字摘要
GetTxID() string
// 返回交易提案中指定的 Channel ID
GetChannelID() string
// 返回交易被創建時的客戶端打上的的時間戳
// 這個時間戳是直接從交易 ChannnelHeader 中提取的,所以在所以背書節點處看到的值都相同
GetTxTimestamp() (*timestamp.Timestamp, error)
// 返回交易的 binding 信息
// 交易的 binding 信息是將交提案的 nonse、Creator、epoch 等信息組合起來哈希得到數字摘要
GetBinding() ([]byte, error)
// 返回該 stub 的 SignedProposal 結構,包括了跟交易提案相關的所有數據
GetSignedProposal() (*pb.SignedProposal, error)
// 返回該交易提交者的身份信息(用戶證書)
// 從 SignedProposal 中的 SignatureHeader.Creator 提取
GetCreator() ([]byte, error)
// 返回交易中帶有的一些臨時信息
// 從 ChaincodeProposalPayload.transient 提取,可以存放與應用相關的保密信息,該信息不會被寫入到賬本
GetTransient() (map[string][]byte, error)
2.4 對 PrivateData 操作的 API
// 根據指定的 key,從指定的私有數據集中查詢對應的私有數據
GetPrivateData(collection, key string) ([]byte, error)
// 將指定的 key 與 value 保存到私有數據集中
PutPrivateData(collection string, key string, value []byte) error
// 根據指定的 key 從私有數據集中刪除相應的數據
DelPrivateData(collection, key string) error
// 根據指定的開始與結束 key 查詢范圍(不包含結束key)內的私有數據
GetPrivateDataByRange(collection, startKey, endKey string) (StateQueryIteratorInterface, error)
// 根據給定的部分組合鍵的集合,查詢給定的私有狀態
GetPrivateDataByPartialCompositeKey(collection, objectType string, keys []string) (StateQueryIteratorInterface, error)
// 根據指定的查詢字符串執行富查詢 (只支持支持富查詢的 CouchDB)
GetPrivateDataQueryResult(collection, query string) (StateQueryIteratorInterface, error)
2.5 其他 API
// 設定當這個交易在 Committer 處被認證通過,寫入到區塊時發送的事件(event),一般由 Client 監聽
SetEvent(name string, payload []byte) error
// 調用另外一個鏈碼的 Invoke 方法
// 如果被調用鏈碼在同一個通道內,則添加其讀寫集合信息到調用交易;否則執行調用但不影響讀寫集合信息
// 如果 channel 為空,則默認為當前通道。目前僅限讀操作,同時不會生成新的交易
InvokeChaincode(chaincodeName string, args [][]byte, channel string) pb.Response
3 鏈碼開發
3.1 賬戶轉賬
package main
import (
"fmt"
"strconv"
"github.com/hyperledger/fabric/core/chaincode/shim"
pb "github.com/hyperledger/fabric/protos/peer"
)
type SimpleChaincode struct {
}
// 初始化數據狀態,實例化/升級鏈碼時被自動調用
func (t *SimpleChaincode) Init(stub shim.ChaincodeStubInterface) pb.Response {
// println 函數的輸出信息會出現在鏈碼容器的日志中
fmt.Println("ex02 Init")
// 獲取用戶傳遞給調用鏈碼的所需參數
_, args := stub.GetFunctionAndParameters()
var A, B string // 兩個賬戶
var Aval, Bval int // 兩個賬戶的余額
var err error
// 檢查合法性, 檢查參數數量是否為 4 個, 如果不是, 則返回錯誤信息
if len(args) != 4 {
return shim.Error("Incorrect number of arguments. Expecting 4")
}
A = args[0] // 賬戶 A 用戶名
Aval, err = strconv.Atoi(args[1]) // 賬戶 A 余額
if err != nil {
return shim.Error("Expecting integer value for asset holding")
}
B = args[2] // 賬戶 B 用戶名
Bval, err = strconv.Atoi(args[3]) // 賬戶 B 余額
if err != nil {
return shim.Error("Expecting integer value for asset holding")
}
fmt.Printf("Aval = %d, Bval = %d\n", Aval, Bval)
// 將賬戶 A 的狀態寫入賬本中
err = stub.PutState(A, []byte(strconv.Itoa(Aval)))
if err != nil {
return shim.Error(err.Error())
}
// 將賬戶 B 的狀態寫入賬本中
err = stub.PutState(B, []byte(strconv.Itoa(Bval)))
if err != nil {
return shim.Error(err.Error())
}
// 一切成功,返回 nil(shim.Success)
return shim.Success(nil)
}
// 對賬本數據進行操作時(query, invoke)被自動調用
func (t *SimpleChaincode) Invoke(stub shim.ChaincodeStubInterface) pb.Response {
fmt.Println("ex02 Invoke")
// 獲取用戶傳遞給調用鏈碼的函數名稱及參數
function, args := stub.GetFunctionAndParameters()
// 對獲取到的函數名稱進行判斷
if function == "invoke" {
// 調用 invoke 函數實現轉賬操作
return t.invoke(stub, args)
} else if function == "delete" {
// 調用 delete 函數實現賬戶注銷
return t.delete(stub, args)
} else if function == "query" {
// 調用 query 實現賬戶查詢操作
return t.query(stub, args)
}
// 傳遞的函數名出錯,返回 shim.Error()
return shim.Error("Invalid invoke function name. Expecting \"invoke\" \"delete\" \"query\"")
}
// 賬戶間轉錢
func (t *SimpleChaincode) invoke(stub shim.ChaincodeStubInterface, args []string) pb.Response {
var A, B string // 賬戶 A 和 B
var Aval, Bval int // 賬戶余額
var X int // 轉賬金額
var err error
if len(args) != 3 {
return shim.Error("Incorrect number of arguments. Expecting 3")
}
A = args[0] // 賬戶 A 用戶名
B = args[1] // 賬戶 B 用戶名
// 從賬本中獲取 A 的余額
Avalbytes, err := stub.GetState(A)
if err != nil {
return shim.Error("Failed to get state")
}
if Avalbytes == nil {
return shim.Error("Entity not found")
}
Aval, _ = strconv.Atoi(string(Avalbytes))
// 從賬本中獲取 B 的余額
Bvalbytes, err := stub.GetState(B)
if err != nil {
return shim.Error("Failed to get state")
}
if Bvalbytes == nil {
return shim.Error("Entity not found")
}
Bval, _ = strconv.Atoi(string(Bvalbytes))
// X 為 轉賬金額
X, err = strconv.Atoi(args[2])
if err != nil {
return shim.Error("Invalid transaction amount, expecting a integer value")
}
// 轉賬
Aval = Aval - X
Bval = Bval + X
fmt.Printf("Aval = %d, Bval = %d\n", Aval, Bval)
// 更新轉賬后賬本中 A 余額
err = stub.PutState(A, []byte(strconv.Itoa(Aval)))
if err != nil {
return shim.Error(err.Error())
}
// 更新轉賬后賬本中 B 余額
err = stub.PutState(B, []byte(strconv.Itoa(Bval)))
if err != nil {
return shim.Error(err.Error())
}
return shim.Success(nil)
}
// 賬戶注銷
func (t *SimpleChaincode) delete(stub shim.ChaincodeStubInterface, args []string) pb.Response {
if len(args) != 1 {
return shim.Error("Incorrect number of arguments. Expecting 1")
}
A := args[0] // 賬戶用戶名
// 從賬本中刪除該賬戶狀態
err := stub.DelState(A)
if err != nil {
return shim.Error("Failed to delete state")
}
return shim.Success(nil)
}
// 賬戶查詢
func (t *SimpleChaincode) query(stub shim.ChaincodeStubInterface, args []string) pb.Response {
var A string
var err error
if len(args) != 1 {
return shim.Error("Incorrect number of arguments. Expecting name of the person to query")
}
A = args[0] // 賬戶用戶名
// 從賬本中獲取該賬戶余額
Avalbytes, err := stub.GetState(A)
if err != nil {
jsonResp := "{\"Error\":\"Failed to get state for " + A + "\"}"
return shim.Error(jsonResp)
}
if Avalbytes == nil {
jsonResp := "{\"Error\":\"Nil amount for " + A + "\"}"
return shim.Error(jsonResp)
}
jsonResp := "{\"Name\":\"" + A + "\",\"Amount\":\"" + string(Avalbytes) + "\"}"
fmt.Printf("Query Response:%s\n", jsonResp)
// 返回轉賬金額
return shim.Success(Avalbytes)
}
func main() {
err := shim.Start(new(SimpleChaincode))
if err != nil {
fmt.Printf("Error starting Simple chaincode: %s", err)
}
}
該鏈碼位於 ./fabric-samples/chaincode/chaincode_example02
,我們啟動 dev 網絡對其進行測試:
$ cd ./fabric-samples/chaincode-docker-devmode/
$ docker-compose -f docker-compose-simple.yaml up -d
進入鏈碼容器,對鏈碼進行編譯:
$ docker exec -it chaincode bash
# cd chaincode_example02/go/
# go build
# CORE_PEER_ADDRESS=peer:7052 CORE_CHAINCODE_ID_NAME=test:0 ./go
打開一個新的終端,進入 cli 容器,安裝並示例化鏈碼:
$ docker exec -it cli bash
# peer chaincode install -p chaincodedev/chaincode/chaincode_example02/go -n test -v 0
# peer chaincode instantiate -n test -v 0 -c '{"Args":["init","a", "100", "b","200"]}' -C myc
查詢賬戶 a 的余額,返回結果為 100:
# peer chaincode query -n test -c '{"Args":["query","a"]}' -C myc
從賬戶 a 轉賬 10 給 b:
# peer chaincode invoke -n test -c '{"Args":["invoke","a","b","10"]}' -C myc
再次查詢賬戶 b 的余額,返回結果為 90:
# peer chaincode query -n test -c '{"Args":["query","a"]}' -C myc
可以在 chaincode 容器中查看到運行的日志:
ex02 Init
Aval = 100, Bval = 200
ex02 Invoke
Query Response:{"Name":"a","Amount":"100"}
ex02 Invoke
Aval = 90, Bval = 210
ex02 Invoke
Query Response:{"Name":"a","Amount":"90"}
關閉網絡:
$ docker-compose -f docker-compose-simple.yaml down
3.2 汽車信息記錄
package main
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"github.com/hyperledger/fabric/core/chaincode/shim"
sc "github.com/hyperledger/fabric/protos/peer"
)
type SmartContract struct {
}
type Car struct {
Make string `json:"make"` // 產商
Model string `json:"model"` // 型號
Colour string `json:"colour"` // 顏色
Owner string `json:"owner"` // 擁有者
}
// 在鏈碼初始化過程中調用 Init 來數據,此處不做任何操作
func (s *SmartContract) Init(APIstub shim.ChaincodeStubInterface) sc.Response {
return shim.Success(nil)
}
// query 和 invoke 時被自動調用
func (s *SmartContract) Invoke(APIstub shim.ChaincodeStubInterface) sc.Response {
// 解析用戶調用鏈碼傳遞的函數名及參數
function, args := APIstub.GetFunctionAndParameters()
// 調用不同的函數
if function == "queryCar" {
return s.queryCar(APIstub, args)
} else if function == "initLedger" {
return s.initLedger(APIstub)
} else if function == "createCar" {
return s.createCar(APIstub, args)
} else if function == "queryAllCars" {
return s.queryAllCars(APIstub)
} else if function == "changeCarOwner" {
return s.changeCarOwner(APIstub, args)
}
return shim.Error("Invalid Smart Contract function name.")
}
// 初始化賬本數據
func (s *SmartContract) initLedger(APIstub shim.ChaincodeStubInterface) sc.Response {
cars := []Car{
Car{Make: "Toyota", Model: "Prius", Colour: "blue", Owner: "Tomoko"},
Car{Make: "Ford", Model: "Mustang", Colour: "red", Owner: "Brad"},
Car{Make: "Hyundai", Model: "Tucson", Colour: "green", Owner: "Jin Soo"},
Car{Make: "Volkswagen", Model: "Passat", Colour: "yellow", Owner: "Max"},
Car{Make: "Tesla", Model: "S", Colour: "black", Owner: "Adriana"},
Car{Make: "Peugeot", Model: "205", Colour: "purple", Owner: "Michel"},
Car{Make: "Chery", Model: "S22L", Colour: "white", Owner: "Aarav"},
Car{Make: "Fiat", Model: "Punto", Colour: "violet", Owner: "Pari"},
Car{Make: "Tata", Model: "Nano", Colour: "indigo", Owner: "Valeria"},
Car{Make: "Holden", Model: "Barina", Colour: "brown", Owner: "Shotaro"},
}
i := 0
for i < len(cars) {
fmt.Println("i is ", i)
carAsBytes, _ := json.Marshal(cars[i])
// key 為編號 CARi,value 為 Car 結構體的 json 串
APIstub.PutState("CAR"+strconv.Itoa(i), carAsBytes)
fmt.Println("Added", cars[i])
i = i + 1
}
return shim.Success(nil)
}
// 根據編號查詢汽車
func (s *SmartContract) queryCar(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {
if len(args) != 1 {
return shim.Error("Incorrect number of arguments. Expecting 1")
}
carAsBytes, _ := APIstub.GetState(args[0])
return shim.Success(carAsBytes)
}
// 創建一輛新的汽車數據
func (s *SmartContract) createCar(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {
if len(args) != 5 {
return shim.Error("Incorrect number of arguments. Expecting 5")
}
var car = Car{Make: args[1], Model: args[2], Colour: args[3], Owner: args[4]}
carAsBytes, _ := json.Marshal(car)
APIstub.PutState(args[0], carAsBytes)
return shim.Success(nil)
}
// 查詢全部的汽車
func (s *SmartContract) queryAllCars(APIstub shim.ChaincodeStubInterface) sc.Response {
// 查詢 startKey(包括)到 endKey(不包括)間的值
startKey := "CAR0"
endKey := "CAR999"
resultsIterator, err := APIstub.GetStateByRange(startKey, endKey)
if err != nil {
return shim.Error(err.Error())
}
defer resultsIterator.Close() // 延遲關閉迭代器
// 將查詢結果以 json 字符串的形式寫入 buffer
var buffer bytes.Buffer
buffer.WriteString("[")
bArrayMemberAlreadyWritten := false
for resultsIterator.HasNext() {
queryResponse, err := resultsIterator.Next()
if err != nil {
return shim.Error(err.Error())
}
if bArrayMemberAlreadyWritten == true {
buffer.WriteString(",")
}
buffer.WriteString("{\"Key\":")
buffer.WriteString("\"")
buffer.WriteString(queryResponse.Key)
buffer.WriteString("\"")
buffer.WriteString(", \"Record\":")
// Record is a JSON object, so we write as-is
buffer.WriteString(string(queryResponse.Value))
buffer.WriteString("}")
bArrayMemberAlreadyWritten = true
}
buffer.WriteString("]")
fmt.Printf("- queryAllCars:\n%s\n", buffer.String())
return shim.Success(buffer.Bytes())
}
// 根據汽車編號改變車的擁有者
func (s *SmartContract) changeCarOwner(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {
if len(args) != 2 {
return shim.Error("Incorrect number of arguments. Expecting 2")
}
carAsBytes, _ := APIstub.GetState(args[0])
car := Car{}
json.Unmarshal(carAsBytes, &car)
car.Owner = args[1] // 更改汽車擁有者
carAsBytes, _ = json.Marshal(car)
APIstub.PutState(args[0], carAsBytes) // 更新賬本
return shim.Success(nil)
}
func main() {
err := shim.Start(new(SmartContract))
if err != nil {
fmt.Printf("Error creating new Smart Contract: %s", err)
}
}
該鏈碼位於 ./fabric-samples/chaincode/fabcar
,我們啟動 dev 網絡對其進行測試:
$ cd ./fabric-samples/chaincode-docker-devmode/
$ docker-compose -f docker-compose-simple.yaml up -d
進入鏈碼容器,對鏈碼進行編譯:
$ docker exec -it chaincode bash
# cd fabcar/go/
# go build
# CORE_PEER_ADDRESS=peer:7052 CORE_CHAINCODE_ID_NAME=test:0 ./go
打開一個新的終端,進入 cli 容器,安裝並示例化鏈碼:
$ docker exec -it cli bash
# peer chaincode install -p chaincodedev/chaincode/fabcar/go -n test -v 0
# peer chaincode instantiate -n test -v 0 -c '{"Args":[]}' -C myc
初始化賬本數據:
# peer chaincode invoke -n test -c '{"Args":["initLedger"]}' -C myc
查詢賬本全部汽車的信息:
# peer chaincode query -n test -c '{"Args":["queryAllCars"]}' -C myc
[{"Key":"CAR0", "Record":{"make":"Toyota","model":"Prius","colour":"blue","owner":"Tomoko"}},{"Key":"CAR1", "Record":{"make":"Ford","model":"Mustang","colour":"red","owner":"Brad"}},{"Key":"CAR2", "Record":{"make":"Hyundai","model":"Tucson","colour":"green","owner":"Jin Soo"}},{"Key":"CAR3", "Record":{"make":"Volkswagen","model":"Passat","colour":"yellow","owner":"Max"}},{"Key":"CAR4", "Record":{"make":"Tesla","model":"S","colour":"black","owner":"Adriana"}},{"Key":"CAR5", "Record":{"make":"Peugeot","model":"205","colour":"purple","owner":"Michel"}},{"Key":"CAR6", "Record":{"make":"Chery","model":"S22L","colour":"white","owner":"Aarav"}},{"Key":"CAR7", "Record":{"make":"Fiat","model":"Punto","colour":"violet","owner":"Pari"}},{"Key":"CAR8", "Record":{"make":"Tata","model":"Nano","colour":"indigo","owner":"Valeria"}},{"Key":"CAR9", "Record":{"make":"Holden","model":"Barina","colour":"brown","owner":"Shotaro"}}]
創建一個新的汽車信息寫入賬本:
# peer chaincode invoke -n test -c '{"Args":["createCar","CAR10","Toyota","Prius","blue","233"]}' -C myc
查詢編號為 CAR10 的汽車信息:
# peer chaincode query -n test -c '{"Args":["queryCar","CAR10"]}' -C myc
{"make":"Toyota","model":"Prius","colour":"blue","owner":"233"}
改變編號為 CAR10 的汽車的擁有者:
# peer chaincode invoke -n test -c '{"Args":["changeCarOwner","CAR10","hehe"]}' -C myc
再次查詢編號為 CAR10 的汽車信息:
# peer chaincode query -n test -c '{"Args":["queryCar","CAR10"]}' -C myc
{"make":"Toyota","model":"Prius","colour":"blue","owner":"hehe"}
關閉網絡:
$ docker-compose -f docker-compose-simple.yaml down
參考
- 《Hyperledger Fabric 菜鳥進階攻略》
- https://www.cnblogs.com/preminem/p/7754811.html