前言

在上个实验 Hyperledger Fabric 多组织多排序节点部署在多个主机上 中,我们已经实现了多组织多排序节点部署在多个主机上,但到目前为止,我们所有的实验都只是研究了联盟链的网络配置方法(尽管这确实是重难点),而没有考虑具体的应用开发。本文将在前面实验的基础上,首先尝试使用 Go 语言开发了一个工作室联盟链的项目信息智能合约,并成功将其部署至联盟链上;然后依据官方示例,使用 fabric-gateway 模块实现了一个能够管理项目信息智能合约的客户端;之后对比了 fabric-gateway 模块和 fabric-sdk-* 模块各自的优缺点,分析官方示例源码实现了通过 fabric-sdk-* 模块管理整个联盟链网络。一般语境下,本文默认智能合约等于链码。

工作准备

本文工作

以三组织三排序节点的方式启动 Hyperledger Fabric 网络,实验共包含四个组织—— council 、 soft 、 web 、 hard , 其中 council 组织为网络提供 TLS-CA 服务,并且运行维护着三个 orderer 服务;其余每个组织都运行维护着一个 peer 节点、一个 admin 用户和一个 user 用户。网络结构为(实验代码已上传至:https://github.com/wefantasy/FabricLearn6_ContractGatewayAndSDK 下):

实验准备

本文网络结构直接将 Hyperledger Fabric无排序组织以Raft协议启动多个Orderer服务、TLS组织运行维护Orderer服务 中创建的 4-2_RunOrdererByCouncil 复制为 6_ContractGatewayAndSDK 并修改(建议直接将本案例仓库 FabricLearn 下的 6_ContractGatewayAndSDK 目录拷贝到本地运行),文中大部分命令在 Hyperledger Fabric定制联盟链网络工程实践 中已有介绍因此不会详细说明。默认情况下,所有命令皆在 6_ContractGatewayAndSDK 根目录下执行,在开始后面的实验前按照以下命令启动基础实验网络:

  1. 设置DNS(如果未设置): ./setDNS.sh
  2. 设置环境变量: source envpeer1soft
  3. 启动CA网络: ./0_Restart.sh

本实验初始 docker 网络为:
Hyperledger Fabric 智能合约开发及 fabric-sdk-go/fabric-gateway 使用示例-LMLPHP

基础环境

注册用户

直接运行根目录下的 1_RegisterUser.sh 即可完成本实验所需用户的注册。以往我们每个组织只有一个 peer 节点和一个 admin 节点,但这些节点都不适合为客户端所用,因此基础环境的改变主要包含了为每个组织新增一个 client 类型的用户。以 soft 组织为例,其注册用户命令为:

echo "Working on soft"
export FABRIC_CA_CLIENT_TLS_CERTFILES=$LOCAL_CA_PATH/soft.ifantasy.net/ca/crypto/ca-cert.pem
export FABRIC_CA_CLIENT_HOME=$LOCAL_CA_PATH/soft.ifantasy.net/ca/admin
fabric-ca-client enroll -d -u https://ca-admin:[email protected]:7250
# client 类型用户注册
fabric-ca-client register -d --id.name user1 --id.secret user1 --id.type client -u https://soft.ifantasy.net:7250
fabric-ca-client register -d --id.name peer1 --id.secret peer1 --id.type peer -u https://soft.ifantasy.net:7250
fabric-ca-client register -d --id.name admin1 --id.secret admin1 --id.type admin -u https://soft.ifantasy.net:7250

组织证书构建

直接运行根目录下的 2_EnrollUser.sh 即可完成本实验所需证书的构建,每个组织主要增加了 client 类型用户的证书构建每个注册用户单元配置文件 config.yaml ,以 soft 组织为例,其生成组织证书的命令为:

echo "Start Soft============================="
# 新增
echo "Enroll User1"
export FABRIC_CA_CLIENT_HOME=$LOCAL_CA_PATH/soft.ifantasy.net/registers/user1
export FABRIC_CA_CLIENT_TLS_CERTFILES=$LOCAL_CA_PATH/soft.ifantasy.net/assets/ca-cert.pem
export FABRIC_CA_CLIENT_MSPDIR=msp
fabric-ca-client enroll -d -u https://user1:[email protected]:7250

echo "Enroll Admin1"
export FABRIC_CA_CLIENT_HOME=$LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1
export FABRIC_CA_CLIENT_TLS_CERTFILES=$LOCAL_CA_PATH/soft.ifantasy.net/assets/ca-cert.pem
export FABRIC_CA_CLIENT_MSPDIR=msp
fabric-ca-client enroll -d -u https://admin1:[email protected]:7250
mkdir -p $LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1/msp/admincerts
cp $LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1/msp/signcerts/cert.pem $LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1/msp/admincerts/cert.pem

echo "Enroll Peer1"
export FABRIC_CA_CLIENT_HOME=$LOCAL_CA_PATH/soft.ifantasy.net/registers/peer1
export FABRIC_CA_CLIENT_TLS_CERTFILES=$LOCAL_CA_PATH/soft.ifantasy.net/assets/ca-cert.pem
export FABRIC_CA_CLIENT_MSPDIR=msp
fabric-ca-client enroll -d -u https://peer1:[email protected]:7250
# for TLS
export FABRIC_CA_CLIENT_MSPDIR=tls-msp
export FABRIC_CA_CLIENT_TLS_CERTFILES=$LOCAL_CA_PATH/soft.ifantasy.net/assets/tls-ca-cert.pem
fabric-ca-client enroll -d -u https://peer1soft:[email protected]:7050 --enrollment.profile tls --csr.hosts peer1.soft.ifantasy.net
cp $LOCAL_CA_PATH/soft.ifantasy.net/registers/peer1/tls-msp/keystore/*_sk $LOCAL_CA_PATH/soft.ifantasy.net/registers/peer1/tls-msp/keystore/key.pem
mkdir -p $LOCAL_CA_PATH/soft.ifantasy.net/registers/peer1/msp/admincerts
cp $LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1/msp/signcerts/cert.pem $LOCAL_CA_PATH/soft.ifantasy.net/registers/peer1/msp/admincerts/cert.pem

mkdir -p $LOCAL_CA_PATH/soft.ifantasy.net/msp/admincerts
mkdir -p $LOCAL_CA_PATH/soft.ifantasy.net/msp/cacerts
mkdir -p $LOCAL_CA_PATH/soft.ifantasy.net/msp/tlscacerts
mkdir -p $LOCAL_CA_PATH/soft.ifantasy.net/msp/users
cp $LOCAL_CA_PATH/soft.ifantasy.net/assets/ca-cert.pem $LOCAL_CA_PATH/soft.ifantasy.net/msp/cacerts/
cp $LOCAL_CA_PATH/soft.ifantasy.net/assets/tls-ca-cert.pem $LOCAL_CA_PATH/soft.ifantasy.net/msp/tlscacerts/
cp $LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1/msp/signcerts/cert.pem $LOCAL_CA_PATH/soft.ifantasy.net/msp/admincerts/cert.pem

cp $LOCAL_ROOT_PATH/config/config-msp.yaml $LOCAL_CA_PATH/soft.ifantasy.net/msp/config.yaml
# 新增
cp $LOCAL_ROOT_PATH/config/config-msp.yaml $LOCAL_CA_PATH/soft.ifantasy.net/registers/user1/msp/config.yaml
cp $LOCAL_ROOT_PATH/config/config-msp.yaml $LOCAL_CA_PATH/soft.ifantasy.net/registers/admin1/msp/config.yaml
cp $LOCAL_ROOT_PATH/config/config-msp.yaml $LOCAL_CA_PATH/soft.ifantasy.net/registers/peer1/msp/config.yaml
echo "End Soft============================="

为了配合使用每个用户的单元配置文件,需要将所有用户 msp 目录下的 cacerts/council-ifantasy-net-7050.pem 文件名修改为 cacerts/ca-cert.pem ,因此在 2_EnrollUser.sh 的末尾追加一行批量修改文件名的命令来实现此目的:

# 按正则匹配并批量修改符合要求的文件
find orgs/ -regex ".+cacerts.+.pem" -not -regex ".+tlscacerts.+" | rename 's/cacerts\/.+\.pem/cacerts\/ca-cert\.pem/'

配置通道

直接运行根目录下的 3_Configtxgen.sh 即可完成本实验所需通道配置,需要注意的是,为了使通道组织架构更加清晰,将通道配置文件 configtx.yaml 中各组织名称从 orgnameMSP 改为了 orgname ,以 soft 组织为例,其组织通道配置如下:

- &soft
    Name: softMSP
    ID: softMSP
    MSPDir: ../orgs/soft.ifantasy.net/msp
    Policies:
        Readers:
            Type: Signature
            Rule: "OR('softMSP.admin', 'softMSP.peer', 'softMSP.client')"
        Writers:
            Type: Signature
            Rule: "OR('softMSP.admin', 'softMSP.client')"
        Admins:
            Type: Signature
            Rule: "OR('softMSP.admin')"
        Endorsement:
            Type: Signature
            Rule: "OR('softMSP.peer')"
    AnchorPeers:
        - Host: peer1.soft.ifantasy.net
            Port: 7251

智能合约开发

本节将参考官方示例智能合约 asset-transfer-basic 开发工作室联盟链的 项目资源管理智能合约 ,其在官方示例的基础上进行了依赖和结构上的简化。本示例是基于 Go 语言的智能合约,因此建议先学习 Go 语言基础概念和规范,不然自行定制可能会有一些 Bug 。

合约代码

  1. 初始化目录/文件
    在实验根目录 6_ContractGatewayAndSDK 下创建目录 contract 作为智能合约根目录,并在其下创建智能合约文件 project_contract.go ,后续代码皆在 project_contract.go 中。
  2. 智能合约结构体
    type ProjectContract struct {
        contractapi.Contract
    }
    
    智能合约结构体一般是固定写法,创建任意一个结构体然后继承 contractapi.Contract 即可,当部署至链上后利用其继承的 contractapi.Contract 的接口实现对合约操作。
  3. 项目信息结构体
    type Project struct {
        ID           string `json:"ID"`             // 项目唯一ID
        Name         string `json:"Name"`           // 项目名称
        Developer    string `json:"Developer"`      // 项目主要负责人
        Organization string `json:"Organization"`   // 项目所属组织
        Category     string `json:"Category"`       // 项目所属类别
        Url          string `json:"Url"`            // 项目介绍地址
        Describes    string `json:"Describes"`      // 项目描述
    }
    
    项目信息结构体主要定义了单个项目的基本信息,类似于 Java 的 Entity 类、数据库的单个表。
  4. 初始化智能合约数据
    func (s *ProjectContract) InitLedger(ctx contractapi.TransactionContextInterface) error {
        projects := []Project{
            {ID: "FA8B31A55CD59DB352BCBF4D2AE791AD", Name: "工作室联盟链管理系统", Developer: "Fantasy", Organization: "Web", Category: "blockchain", Url: "https://github.com/wefantasy/FabricLearn", Describes: "本项目虚拟了一个工作室联盟链需求并将逐步实现,致力于提供一个易理解、可复现的Fabric学习项目,其中项目部署步骤的各个环节都清晰可见,并且将所有实验打包为脚本使之能够被快速复现在任何一台主机上"},
        }
        for _, project := range projects {
            projectJSON, err := json.Marshal(project)
            if err != nil {
                return err
            }
            err = ctx.GetStub().PutState(project.ID, projectJSON)
            if err != nil {
                return fmt.Errorf("failed to put to world state. %v", err)
            }
        }
        return nil
    }
    
    在 Fabric 某个旧版本之前必须提供智能合约初始化函数,但在本实验所用的 Fabric 2.4 则是可选项,在此仅仅是为了写入预设实验数据。Fabric 底层使用默认键值对(key-value)状态数据库 LevelDB 储存数据,在操作体验上十分像 redis 数据库。
  5. 判断项目信息是否已存在
    func (s *ProjectContract) ProjectExists(ctx contractapi.TransactionContextInterface, id string) (bool, error) {
        projectJSON, err := ctx.GetStub().GetState(id)
        if err != nil {
            return false, fmt.Errorf("failed to read from world state: %v", err)
        }
    
        return projectJSON != nil, nil
    }
    
  6. 写入新项目信息
    func (s *ProjectContract) CreateProject(ctx contractapi.TransactionContextInterface, id string, name string, developer string, organization string, category string, url string, describes string) error {
        exists, err := s.ProjectExists(ctx, id)
        if err != nil {
            return err
        }
        if exists {
            return fmt.Errorf("the project %s already exists", id)
        }
        project := Project{
            ID:           id,
            Name:         name,
            Developer:    developer,
            Organization: organization,
            Category:     category,
            Url:          url,
            Describes:    describes,
        }
        projectJSON, err := json.Marshal(project)
        if err != nil {
            return err
        }
        return ctx.GetStub().PutState(id, projectJSON)
    }
    
  7. 删除指定项目信息
    func (s *ProjectContract) DeleteProject(ctx contractapi.TransactionContextInterface, id string) error {
        exists, err := s.ProjectExists(ctx, id)
        if err != nil {
            return err
        }
        if !exists {
            return fmt.Errorf("the project %s does not exist", id)
        }
    
        return ctx.GetStub().DelState(id)
    }
    
    Fabric 联盟链作为区块链的一种特殊形式,同样具有可追溯特性,因此任何对数据的增删改操作都是软操作——留下操作记录。
  8. 修改项目信息
    func (s *ProjectContract) UpdateProject(ctx contractapi.TransactionContextInterface, id string, name string, developer string, organization string, category string, url string, describes string) error {
        exists, err := s.ProjectExists(ctx, id)
        if err != nil {
            return err
        }
        if !exists {
            return fmt.Errorf("the project %s does not exist", id)
        }
        project := Project{
            ID:           id,
            Name:         name,
            Developer:    developer,
            Organization: organization,
            Category:     category,
            Url:          url,
            Describes:    describes,
        }
        projectJSON, err := json.Marshal(project)
        if err != nil {
            return err
        }
        return ctx.GetStub().PutState(id, projectJSON)
    }
    
  9. 查询项目信息
    func (s *ProjectContract) ReadProject(ctx contractapi.TransactionContextInterface, id string) (*Project, error) {
        projectJSON, err := ctx.GetStub().GetState(id)
        if err != nil {
            return nil, fmt.Errorf("failed to read from world state: %v", err)
        }
        if projectJSON == nil {
            return nil, fmt.Errorf("the project %s does not exist", id)
        }
    
        var project Project
        err = json.Unmarshal(projectJSON, &project)
        if err != nil {
            return nil, err
        }
    
        return &project, nil
    }
    
  10. 查询链上所有项目信息
    func (s *ProjectContract) GetAllProjects(ctx contractapi.TransactionContextInterface) ([]*Project, error) {
        // GetStateByRange 查询参数为两个空字符串时即查询所有数据
        resultsIterator, err := ctx.GetStub().GetStateByRange("", "")
        if err != nil {
            return nil, err
        }
        defer resultsIterator.Close()
    
        var projects []*Project
        for resultsIterator.HasNext() {
            queryResponse, err := resultsIterator.Next()
            if err != nil {
                return nil, err
            }
    
            var project Project
            err = json.Unmarshal(queryResponse.Value, &project)
            if err != nil {
                return nil, err
            }
            projects = append(projects, &project)
        }
    
        return projects, nil
    }
    
  11. 智能合约入口函数/主函数
    func main() {
        chaincode, err := contractapi.NewChaincode(&ProjectContract{})
        if err != nil {
            log.Panicf("Error creating project-manage chaincode: %v", err)
        }
    
        if err := chaincode.Start(); err != nil {
            log.Panicf("Error starting project-manage chaincode: %v", err)
        }
    }
    

至此,项目信息管理智能合约核心代码以编写完毕,完整 project_contract.go 文件内容如下(需要注意的是合约入口必须属于 main 包):

package main

import (
	"encoding/json"
	"fmt"
	"github.com/hyperledger/fabric-contract-api-go/contractapi"
	"log"
)

type ProjectContract struct {
	contractapi.Contract
}

type Project struct {
	ID           string `json:"ID"`             // 项目唯一ID
	Name         string `json:"Name"`           // 项目名称
	Developer    string `json:"Developer"`      // 项目主要负责人
	Organization string `json:"Organization"`   // 项目所属组织
	Category     string `json:"Category"`       // 项目所属类别
	Url          string `json:"Url"`            // 项目介绍地址
	Describes    string `json:"Describes"`      // 项目描述
}

// 初始化智能合约数据
func (s *ProjectContract) InitLedger(ctx contractapi.TransactionContextInterface) error {
	projects := []Project{
		{ID: "FA8B31A55CD59DB352BCBF4D2AE791AD", Name: "工作室联盟链管理系统", Developer: "Fantasy", Organization: "Web", Category: "blockchain", Url: "https://github.com/wefantasy/FabricLearn", Describes: "本项目虚拟了一个工作室联盟链需求并将逐步实现,致力于提供一个易理解、可复现的Fabric学习项目,其中项目部署步骤的各个环节都清晰可见,并且将所有实验打包为脚本使之能够被快速复现在任何一台主机上"},
	}
	for _, project := range projects {
		projectJSON, err := json.Marshal(project)
		if err != nil {
			return err
		}
		err = ctx.GetStub().PutState(project.ID, projectJSON)
		if err != nil {
			return fmt.Errorf("failed to put to world state. %v", err)
		}
	}
	return nil
}

// 写入新项目
func (s *ProjectContract) CreateProject(ctx contractapi.TransactionContextInterface, id string, name string, developer string, organization string, category string, url string, describes string) error {
	exists, err := s.ProjectExists(ctx, id)
	if err != nil {
		return err
	}
	if exists {
		return fmt.Errorf("the project %s already exists", id)
	}

	project := Project{
		ID:           id,
		Name:         name,
		Developer:    developer,
		Organization: organization,
		Category:     category,
		Url:          url,
		Describes:    describes,
	}
	projectJSON, err := json.Marshal(project)
	if err != nil {
		return err
	}
	return ctx.GetStub().PutState(id, projectJSON)
}

// 读取指定ID的项目信息
func (s *ProjectContract) ReadProject(ctx contractapi.TransactionContextInterface, id string) (*Project, error) {
	projectJSON, err := ctx.GetStub().GetState(id)
	if err != nil {
		return nil, fmt.Errorf("failed to read from world state: %v", err)
	}
	if projectJSON == nil {
		return nil, fmt.Errorf("the project %s does not exist", id)
	}

	var project Project
	err = json.Unmarshal(projectJSON, &project)
	if err != nil {
		return nil, err
	}

	return &project, nil
}

// 更新项目信息.
func (s *ProjectContract) UpdateProject(ctx contractapi.TransactionContextInterface, id string, name string, developer string, organization string, category string, url string, describes string) error {
	exists, err := s.ProjectExists(ctx, id)
	if err != nil {
		return err
	}
	if !exists {
		return fmt.Errorf("the project %s does not exist", id)
	}

	project := Project{
		ID:           id,
		Name:         name,
		Developer:    developer,
		Organization: organization,
		Category:     category,
		Url:          url,
		Describes:    describes,
	}
	projectJSON, err := json.Marshal(project)
	if err != nil {
		return err
	}

	return ctx.GetStub().PutState(id, projectJSON)
}

// 删除指定ID的项目信息
func (s *ProjectContract) DeleteProject(ctx contractapi.TransactionContextInterface, id string) error {
	exists, err := s.ProjectExists(ctx, id)
	if err != nil {
		return err
	}
	if !exists {
		return fmt.Errorf("the project %s does not exist", id)
	}

	return ctx.GetStub().DelState(id)
}

// 判断某项目是否存在
func (s *ProjectContract) ProjectExists(ctx contractapi.TransactionContextInterface, id string) (bool, error) {
	projectJSON, err := ctx.GetStub().GetState(id)
	if err != nil {
		return false, fmt.Errorf("failed to read from world state: %v", err)
	}

	return projectJSON != nil, nil
}

// 读取所有项目信息
func (s *ProjectContract) GetAllProjects(ctx contractapi.TransactionContextInterface) ([]*Project, error) {
	// GetStateByRange 查询参数为两个空字符串时即查询所有数据
	resultsIterator, err := ctx.GetStub().GetStateByRange("", "")
	if err != nil {
		return nil, err
	}
	defer resultsIterator.Close()

	var projects []*Project
	for resultsIterator.HasNext() {
		queryResponse, err := resultsIterator.Next()
		if err != nil {
			return nil, err
		}

		var project Project
		err = json.Unmarshal(queryResponse.Value, &project)
		if err != nil {
			return nil, err
		}
		projects = append(projects, &project)
	}

	return projects, nil
}

func main() {
	chaincode, err := contractapi.NewChaincode(&ProjectContract{})
	if err != nil {
		log.Panicf("Error creating project-manage chaincode: %v", err)
	}

	if err := chaincode.Start(); err != nil {
		log.Panicf("Error starting project-manage chaincode: %v", err)
	}
}

依赖下载

合约代码编写完成后并不能直接部署到联盟链上,需要将合约中 import 导入的包下载到本地以供后面一起打包,本小节所有命令默认运行于 6_ContractGatewayAndSDK/contract 下。

  1. 初始化模块
    go mod init github.com/wefantasy/FabricLearn/6_ContractGatewayAndSDK/contract
    
  2. 将所有依赖下载到本地
    go mod vendor
    

以上命令运行成功后,智能合约开发工作基本结束,此时 contract 目录结构如下:

6_ContractGatewayAndSDK/contract
├── go.mod
├── go.sum
├── project_contract.go
└── vendor
    ├── github.com
    ├── golang.org
    ├── google.golang.org
    ├── gopkg.in
    └── modules.tx

合约部署测试

如无特殊说明,以下命令默认运行于实验根目录 6_ContractGatewayAndSDK 下:

  1. 合约打包
     source envpeer1soft
     peer lifecycle chaincode package basic.tar.gz --path contract --lang golang --label basic_1
    
  2. 三组织安装
     source envpeer1soft
     peer lifecycle chaincode install basic.tar.gz
     peer lifecycle chaincode queryinstalled
     source envpeer1web
     peer lifecycle chaincode install basic.tar.gz
     peer lifecycle chaincode queryinstalled
     source envpeer1hard
     peer lifecycle chaincode install basic.tar.gz
     peer lifecycle chaincode queryinstalled
    
  3. 三组织批准
     export CHAINCODE_ID=basic_1:0f1f1ffc8e3865a9179e70a3c56237482b3eb4dcecd30ab51ab01a6f5d3daeff
     source envpeer1soft
     peer lifecycle chaincode approveformyorg -o orderer1.council.ifantasy.net:7051 --tls --cafile $ORDERER_CA  --channelID testchannel --name basic --version 1.0 --sequence 1 --waitForEvent --init-required --package-id $CHAINCODE_ID
     peer lifecycle chaincode queryapproved -C testchannel -n basic --sequence 1
     source envpeer1web
     peer lifecycle chaincode approveformyorg -o orderer3.council.ifantasy.net:7057 --tls --cafile $ORDERER_CA  --channelID testchannel --name basic --version 1.0 --sequence 1 --waitForEvent --init-required --package-id $CHAINCODE_ID
     peer lifecycle chaincode queryapproved -C testchannel -n basic --sequence 1
     source envpeer1hard
     peer lifecycle chaincode approveformyorg -o orderer2.council.ifantasy.net:7054 --tls --cafile $ORDERER_CA  --channelID testchannel --name basic --version 1.0 --sequence 1 --waitForEvent --init-required --package-id $CHAINCODE_ID
     peer lifecycle chaincode queryapproved -C testchannel -n basic --sequence 1
    
    注意要将 CHAINCODE_ID 的值改为三组织安装时输出的连码包 ID
  4. 提交并测试
     source envpeer1soft
     peer lifecycle chaincode commit -o orderer2.council.ifantasy.net:7054 --tls --cafile $ORDERER_CA --channelID testchannel --name basic --init-required --version 1.0 --sequence 1 --peerAddresses peer1.soft.ifantasy.net:7251 --tlsRootCertFiles $CORE_PEER_TLS_ROOTCERT_FILE --peerAddresses peer1.web.ifantasy.net:7351 --tlsRootCertFiles $CORE_PEER_TLS_ROOTCERT_FILE
     peer chaincode invoke --isInit -o orderer1.council.ifantasy.net:7051 --tls --cafile $ORDERER_CA --channelID testchannel --name basic --peerAddresses peer1.soft.ifantasy.net:7251 --tlsRootCertFiles $CORE_PEER_TLS_ROOTCERT_FILE --peerAddresses peer1.web.ifantasy.net:7351 --tlsRootCertFiles $CORE_PEER_TLS_ROOTCERT_FILE -c '{"Args":["InitLedger"]}'
     sleep 5
     peer chaincode invoke -o orderer1.council.ifantasy.net:7051 --tls --cafile $ORDERER_CA --channelID testchannel --name basic --peerAddresses peer1.soft.ifantasy.net:7251 --tlsRootCertFiles $CORE_PEER_TLS_ROOTCERT_FILE --peerAddresses peer1.web.ifantasy.net:7351 --tlsRootCertFiles $CORE_PEER_TLS_ROOTCERT_FILE -c '{"Args":["GetAllProjects"]}'
    
    Hyperledger Fabric 智能合约开发及 fabric-sdk-go/fabric-gateway 使用示例-LMLPHP

fabric-gateway 客户端示例

客户端代码

  1. 初始化目录/文件
    在实验根目录 6_ContractGatewayAndSDK 下创建目录 contract-gateway 作为 fabric-gateway 客户端的根目录,并在其下创建联盟链网络连接文件 connect.go 和 客户端主程序 app.go 。实验最终目录结构为:
    contract-gateway
    ├── app.go
    ├── connect.go
    ├── go.mod
    └── go.sum
    
  2. connect.go 写入以下内容
    package main
    
    import (
        "crypto/x509"
        "fmt"
        "io/ioutil"
        "path"
        "github.com/hyperledger/fabric-gateway/pkg/identity"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials"
    )
    
    const (
        mspID         = "softMSP"				// 所属组织的MSPID
        cryptoPath    = "/root/FabricLearn/6_ContractGatewayAndSDK/orgs/soft.ifantasy.net"	// 中间变量
        certPath      = cryptoPath + "/registers/user1/msp/signcerts/cert.pem"		// client 用户的签名证书
        keyPath       = cryptoPath + "/registers/user1/msp/keystore/"		// client 用户的私钥路径
        tlsCertPath   = cryptoPath + "/assets/tls-ca-cert.pem"			// client 用户的 tls 通信证书
        peerEndpoint  = "peer1.soft.ifantasy.net:7251"			// 所连 peer 节点的地址
        gatewayPeer   = "peer1.soft.ifantasy.net"		// 网关 peer 节点名称
    )
    
    // 创建指向联盟链网络的 gRPC 连接.
    func newGrpcConnection() *grpc.ClientConn {
        certificate, err := loadCertificate(tlsCertPath)
        if err != nil {
            panic(err)
        }
    
        certPool := x509.NewCertPool()
        certPool.AddCert(certificate)
        transportCredentials := credentials.NewClientTLSFromCert(certPool, gatewayPeer)
    
        connection, err := grpc.Dial(peerEndpoint, grpc.WithTransportCredentials(transportCredentials))
        if err != nil {
            panic(fmt.Errorf("failed to create gRPC connection: %w", err))
        }
    
        return connection
    }
    
    // 根据用户指定的X.509证书为这个网关连接创建一个客户端标识。
    func newIdentity() *identity.X509Identity {
        certificate, err := loadCertificate(certPath)
        if err != nil {
            panic(err)
        }
    
        id, err := identity.NewX509Identity(mspID, certificate)
        if err != nil {
            panic(err)
        }
        return id
    }
    
    // 加载证书文件
    func loadCertificate(filename string) (*x509.Certificate, error) {
        certificatePEM, err := ioutil.ReadFile(filename)
        if err != nil {
            return nil, fmt.Errorf("failed to read certificate file: %w", err)
        }
        return identity.CertificateFromPEM(certificatePEM)
    }
    
    // 使用私钥从消息摘要生成数字签名
    func newSign() identity.Sign {
        files, err := ioutil.ReadDir(keyPath)
        if err != nil {
            panic(fmt.Errorf("failed to read private key directory: %w", err))
        }
        privateKeyPEM, err := ioutil.ReadFile(path.Join(keyPath, files[0].Name()))
    
        if err != nil {
            panic(fmt.Errorf("failed to read private key file: %w", err))
        }
    
        privateKey, err := identity.PrivateKeyFromPEM(privateKeyPEM)
        if err != nil {
            panic(err)
        }
    
        sign, err := identity.NewPrivateKeySign(privateKey)
        if err != nil {
            panic(err)
        }
    
        return sign
    }
    
  3. app.go 写入以下内容
    package main
    
    import (
        "bytes"
        "encoding/json"
        "fmt"
        "time"
        "github.com/hyperledger/fabric-gateway/pkg/client"
    )
    
    const (
        channelName   = "testchannel"	// 连接的通道
        chaincodeName = "basic"			// 连接的链码
    )
    
    func main() {
        clientConnection := newGrpcConnection()
        defer clientConnection.Close()
    
        id := newIdentity()
        sign := newSign()
    
        gateway, err := client.Connect(
            id,
            client.WithSign(sign),
            client.WithClientConnection(clientConnection),
            client.WithEvaluateTimeout(5*time.Second),
            client.WithEndorseTimeout(15*time.Second),
            client.WithSubmitTimeout(5*time.Second),
            client.WithCommitStatusTimeout(1*time.Minute),
        )
        if err != nil {
            panic(err)
        }
        defer gateway.Close()
    
        network := gateway.GetNetwork(channelName)
        contract := network.GetContract(chaincodeName)
    
        fmt.Println("getAllAssets:")
        getAllAssets(contract)
    }
    func getAllAssets(contract *client.Contract) {
        fmt.Println("Evaluate Transaction: GetAllAssets, function returns all the current assets on the ledger")
    
        evaluateResult, err := contract.EvaluateTransaction("GetAllProjects")
        if err != nil {
            panic(fmt.Errorf("failed to evaluate transaction: %w", err))
        }
        result := formatJSON(evaluateResult)
    
        fmt.Printf("*** Result:%s\n", result)
    }
    
    func formatJSON(data []byte) string {
        var prettyJSON bytes.Buffer
        if err := json.Indent(&prettyJSON, data, " ", ""); err != nil {
            panic(fmt.Errorf("failed to parse JSON: %w", err))
        }
        return prettyJSON.String()
    }
    

客户端演示

如无特殊说明,以下命令默认运行于实验根目录 contract-gateway 下:

  1. 初始化模块
    go mod init github.com/wefantasy/FabricLearn/6_ContractGatewayAndSDK/contract-gateway
    
  2. 下载依赖
    go get
    
    此时实验目录结构为
  3. 运行客户端
    go run .
    
    因为本目录下同时有两个 packagemain 的 go 文件,所以要用 . 的方式运行,运行结果如下:
    Hyperledger Fabric 智能合约开发及 fabric-sdk-go/fabric-gateway 使用示例-LMLPHP

fabric-sdk-go 客户端示例

刚接触 Fabric 你可能会很疑惑,有些案例使用 fabric-gateway 连接联盟链、另一些案例通过 fabric-sdk-* 连接联盟链,并且似乎都可以操纵网络,那么有什么区别呢? fabric-sdk-* 被定义为 Fabric 的低级 SDK ,主要为开发者提供账本管理、通道管理、用户管理等联盟链管理的 API ,它的开发成本更高但功能丰富;而 fabric-gateway 被定义为 Fabric 的高级 SDK ,这里的高级主要体现在其抽象程度更高,主要为开发者提供账本管理的 API ,它的开发成本更低但功能较少。因此建议优先学习 fabric-sdk-* 的使用。

连接配置文件

就像刚才说的, fabric-sdk-* 开发成本比较高,我觉得高出来的开发成本有一半都在连接配置文件的配置上,它让我花费了至少半天的时间来排错,而网上几乎没有能把连接配置文件讲清楚的文章(也许是我没有找到),只能通过官方示例代码慢慢推导出正确的配置方法。
从 fabric-sdk-* 官方示例 assetTransfer.go 中引用的 connection-org1.yaml 连接配置文件出发,可以定位到生成它的相关文件为 ccp-generate.shccp-template.yaml ,后者为连接配置文件的基准模板,前者使用 bash 命令将基准模板替换为具体连接配置文件。连接配置文件有 json 和 yaml 两种格式,我觉得 yaml 语法更为简洁,后续实验以此为例。将 ccp-generate.sh 文件中的函数展开后,可以很容易的得生成连接配置文件的过程,本节所有命令默认运行于 6_ContractGatewayAndSDK 目录下,通过如下命令生成 soft 组织的连接配置文件:

  1. 创建模板文件
    将官方模板 ccp-template.yaml 复制一份至我们项目的 6_ContractGatewayAndSDK/config/ccp-template.yaml 中,由于我们的命名规范与官方不同,且该模板通用性不高,因此将其内容改为如下:
    ---
    name: test-network-${ORG}
    version: 1.0.0
    client:
    organization: ${ORG}
    connection:
        timeout:
        peer:
            endorser: '300'
    organizations:
    ${ORG}:
        mspid: ${ORG}MSP
        peers:
        - peer1.${ORG}.ifantasy.net
        certificateAuthorities:
        - ${ORG}.ifantasy.net
    peers:
    peer1.${ORG}.ifantasy.net:
        url: grpcs://peer1.${ORG}.ifantasy.net:${P0PORT}
        tlsCACerts:
        pem: |
            ${PEERPEM}
        grpcOptions:
        ssl-target-name-override: peer1.${ORG}.ifantasy.net
        hostnameOverride: peer1.${ORG}.ifantasy.net
    certificateAuthorities:
    ${ORG}.ifantasy.net:
        url: https://${ORG}.ifantasy.net:${CAPORT}
        caName: ${ORG}.ifantasy.net
        tlsCACerts:
        pem:
            - |
            ${CAPEM}
        httpOptions:
        verify: false
    
  2. 设置模板参数
    ORG=soft
    P0PORT=7251
    CAPORT=7250
    cryptoPath=$LOCAL_CA_PATH/soft.ifantasy.net
    PEERPEM=$cryptoPath/assets/tls-ca-cert.pem
    CAPEM=$cryptoPath/assets/ca-cert.pem
    
  3. 获取 tls 证书和 ca 证书
    PP="`awk 'NF {sub(/\\n/, ""); printf "%s\\\\\\\n",$0;}' $PEERPEM`"
    CP="`awk 'NF {sub(/\\n/, ""); printf "%s\\\\\\\n",$0;}' $CAPEM`"
    
  4. 生成模板文件
    sed -e "s/\${ORG}/$ORG/" \
            -e "s/\${P0PORT}/$P0PORT/" \
            -e "s/\${CAPORT}/$CAPORT/" \
            -e "s#\${PEERPEM}#$PP#" \
            -e "s#\${CAPEM}#$CP#" \
            config/ccp-template.yaml | sed -e $'s/\\\\n/\\\n          /g'  > connection-soft.yaml
    

依次执行上述命令,最后会将连接配置文件 connection-soft.yaml 输出到实验根目录中,本例中其内容如下:

---
name: test-network-soft
version: 1.0.0
client:
  organization: soft
  connection:
    timeout:
      peer:
        endorser: '300'
organizations:
  soft:
    mspid: softMSP
    peers:
    - peer1.soft.ifantasy.net
    certificateAuthorities:
    - soft.ifantasy.net
peers:
  peer1.soft.ifantasy.net:
    url: grpcs://peer1.soft.ifantasy.net:7251
    tlsCACerts:
      pem: |
          -----BEGIN CERTIFICATE-----
          MIICHzCCAcWgAwIBAgIUbO4XSCy2KbQQN/E63zvkhUJfMzwwCgYIKoZIzj0EAwIw
          bDELMAkGA1UEBhMCVVMxFzAVBgNVBAgTDk5vcnRoIENhcm9saW5hMRQwEgYDVQQK
          EwtIeXBlcmxlZGdlcjEPMA0GA1UECxMGRmFicmljMR0wGwYDVQQDExRjb3VuY2ls
          LmlmYW50YXN5Lm5ldDAeFw0yMjA2MTEwNTU3MDBaFw0zNzA2MDcwNTU3MDBaMGwx
          CzAJBgNVBAYTAlVTMRcwFQYDVQQIEw5Ob3J0aCBDYXJvbGluYTEUMBIGA1UEChML
          SHlwZXJsZWRnZXIxDzANBgNVBAsTBkZhYnJpYzEdMBsGA1UEAxMUY291bmNpbC5p
          ZmFudGFzeS5uZXQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQecDRTwml7bcaD
          nZdPiEYiTxFwHa+g2nw+mq+6KeMPW98WT3BPNErb1gw9BQa6GRcTypJ7Ga1lSqLS
          IFD+aypYo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAd
          BgNVHQ4EFgQUq3Q80AlYM9lGKHWVupCEjpyBb1kwCgYIKoZIzj0EAwIDSAAwRQIh
          AJashZ+Sob7DoOpYII22wDOPSV8updo1W9LNEAaxzMyTAiAokfgCVjtlX3EJnV+m
          qc5EBQCjA0AaX1HPNBTUII7T+Q==
          -----END CERTIFICATE-----

    grpcOptions:
      ssl-target-name-override: peer1.soft.ifantasy.net
      hostnameOverride: peer1.soft.ifantasy.net
certificateAuthorities:
  soft.ifantasy.net:
    url: https://soft.ifantasy.net:7250
    caName: soft.ifantasy.net
    tlsCACerts:
      pem:
        - |
          -----BEGIN CERTIFICATE-----
          MIICGDCCAb+gAwIBAgIUXF3f1cgHiAMO03c/61iyFWAD/0AwCgYIKoZIzj0EAwIw
          aTELMAkGA1UEBhMCVVMxFzAVBgNVBAgTDk5vcnRoIENhcm9saW5hMRQwEgYDVQQK
          EwtIeXBlcmxlZGdlcjEPMA0GA1UECxMGRmFicmljMRowGAYDVQQDExFzb2Z0Lmlm
          YW50YXN5Lm5ldDAeFw0yMjA2MTEwNTU3MDBaFw0zNzA2MDcwNTU3MDBaMGkxCzAJ
          BgNVBAYTAlVTMRcwFQYDVQQIEw5Ob3J0aCBDYXJvbGluYTEUMBIGA1UEChMLSHlw
          ZXJsZWRnZXIxDzANBgNVBAsTBkZhYnJpYzEaMBgGA1UEAxMRc29mdC5pZmFudGFz
          eS5uZXQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASP0Vs5wUaRzIyiXx2ygH6A
          IQyCLe6VhTxnNPmJhMUVOmO+iyLJqMUuQRRHIcCgiNGPR9cqd4ygcRJBvsG+sooY
          o0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4E
          FgQUkPhZPSjyHVdL5NkQED1Rdif7GdowCgYIKoZIzj0EAwIDRwAwRAIgfOt69wD8
          HEqroGm/zVFf/NiqivluaK5Yf3Ryn0C7p5ECID/KNGjbt5b53ivuL5slK5B+8eA2
          KGUN7ysBzX8hTzPj
          -----END CERTIFICATE-----

    httpOptions:
      verify: false

上述操作已打包至 5_GenConnectYaml.sh 中,也可以直接在根目录下运行 5_GenConnectYaml.sh 来了生成连接配置文件。

客户端代码

  1. 初始化目录/文件
    在实验根目录 6_ContractGatewayAndSDK 下创建目录 contract-sdk 作为 fabric-sdk 客户端的根目录,并在其下创建主程序 app.go 。将上节生成的 connection-soft.yaml 复制到该目录下,最终目录结构为:
     contract-sdk
     ├── app.go
     ├── connection-soft.yaml
     ├── go.mod
     ├── go.sum
     ├── keystore
     └── wallet
         └── appUser.id
    
  2. 向 app.go 写入以下内容
     package main
    
     import (
         "fmt"
         "io/ioutil"
         "log"
         "os"
         "path/filepath"
    
         "github.com/hyperledger/fabric-sdk-go/pkg/core/config"
         "github.com/hyperledger/fabric-sdk-go/pkg/gateway"
     )
    
     func main() {
         log.Println("============ application-golang starts ============")
    
         err := os.Setenv("DISCOVERY_AS_LOCALHOST", "true")
         if err != nil {
             log.Fatalf("Error setting DISCOVERY_AS_LOCALHOST environemnt variable: %v", err)
         }
    
         wallet, err := gateway.NewFileSystemWallet("wallet")
         if err != nil {
             log.Fatalf("Failed to create wallet: %v", err)
         }
    
         err = populateWallet(wallet)
         // 调试建议注释这里
         // if !wallet.Exists("appUser") {
         // 	err = populateWallet(wallet)
         // 	if err != nil {
         // 		log.Fatalf("Failed to populate wallet contents: %v", err)
         // 	}
         // }
    
         ccpPath := filepath.Join(
             "connection-soft.yaml",
         )
    
         gw, err := gateway.Connect(
             gateway.WithConfig(config.FromFile(filepath.Clean(ccpPath))),
             gateway.WithIdentity(wallet, "appUser"),
         )
         if err != nil {
             log.Fatalf("Failed to connect to gateway: %v", err)
         }
         defer gw.Close()
    
         network, err := gw.GetNetwork("testchannel")
         if err != nil {
             log.Fatalf("Failed to get network: %v", err)
         }
    
         contract := network.GetContract("basic")
    
         log.Println("--> Evaluate Transaction: GetAllAssets, function returns all the current assets on the ledger")
         result, err := contract.EvaluateTransaction("GetAllProjects")
         if err != nil {
             log.Fatalf("Failed to evaluate transaction: %v", err)
         }
         log.Println(string(result))
    
         log.Println("--> Submit Transaction: DeleteProject, delete new project info with ID arguments")
         result, err = contract.SubmitTransaction("DeleteProject", "FA8B31A55CD59DB352BCBF4D2AE791AD")
         if err != nil {
             log.Fatalf("Failed to Submit transaction: %v", err)
         }
         log.Println(string(result))
     }
    
     func populateWallet(wallet *gateway.Wallet) error {
         log.Println("============ Populating wallet ============")
         credPath := filepath.Join(
             "..",
             "orgs",
             "soft.ifantasy.net",
             "registers",
             "user1",
             "msp",
         )
    
         certPath := filepath.Join(credPath, "signcerts", "cert.pem")
         // read the certificate pem
         cert, err := ioutil.ReadFile(filepath.Clean(certPath))
         if err != nil {
             return err
         }
    
         keyDir := filepath.Join(credPath, "keystore")
         // there's a single file in this dir containing the private key
         files, err := ioutil.ReadDir(keyDir)
         if err != nil {
             return err
         }
         if len(files) != 1 {
             return fmt.Errorf("keystore folder should have contain one file")
         }
         keyPath := filepath.Join(keyDir, files[0].Name())
         key, err := ioutil.ReadFile(filepath.Clean(keyPath))
         if err != nil {
             return err
         }
    
         identity := gateway.NewX509Identity("softMSP", string(cert), string(key))
    
         return wallet.Put("appUser", identity)
     }
    

客户端演示

如无特殊说明,以下命令默认运行于实验根目录 contract-sdk 下:

  1. 初始化模块
    go mod init github.com/wefantasy/FabricLearn/6_ContractGatewayAndSDK/contract-gateway
    
  2. 下载依赖
    go get
    
  3. 运行客户端
    go run .
    
    Hyperledger Fabric 智能合约开发及 fabric-sdk-go/fabric-gateway 使用示例-LMLPHP

Q&A

遇到错误:

QueryBlockConfig failed: no channel peers configured for channel [testchannel]

解决方法: 大概率是连接配置文件组织名称啥的写错了,再次检查组织配置文件与configtx.yaml中声明的是否匹配。

遇到错误:

2022/06/10 15:55:44 Failed to get network: Failed to create new channel client: event service creation failed: could not get chConfig cache reference: QueryBlockConfig failed: QueryBlockConfig failed: target(s) required

解决方法: 可能是因为 wallet 目录下的身份与所申明的身份不匹配,建议每次启动前删除 wallet 目录让它重新生成。

遇到错误:

2022/06/10 16:08:13 Failed to Submit transaction: Failed to submit: error getting channel response for channel [testchannel]: no successful response received from any peer: access denied

解决方法: 此时检查对应的 peer 节点容器日志若有 implicit policy evaluation failed 错误,则说明当前使用的身份权限不足。在实验中使用 peer 类型的用户身份则会导致此问题,建议使用 client 身份的用户(admin 身份也行)。

遇到错误:

2022/06/10 16:08:13 Failed to Submit transaction: Failed to submit: error getting channel response for channel [testchannel]: no successful response received from any peer: access denied

解决方法: 此时检查对应的 peer 节点容器日志若有 implicit policy evaluation failed 错误,则说明当前使用的身份权限不足。在实验中使用 peer 类型的用户身份则会导致此问题,建议使用 client 身份的用户(admin 身份也行)。

参考

[1]: hyperledger-fabric. Fabric Contract APIs and Application APIs. readthedocs.io. [-]
[2]: barney2k7. What is the difference between fabric-chaincode-go and fabric-contract-api-go?. stackoverflow.com. [2020-05-08]
[3]: Nikos Karamolegkos. fabric-sdk-go vs fabric-gateway. When to use each one?. hyperledger.org. [2021-12-07]
[4]: kid1999 Karamolegkos. Fabric智能合约Go开发包简单理解. github.io. [2021-06-26]

06-12 09:27