面向 Go 开发人员的链代码深入研究,第 1 部分: 使用 Go 编写智能合约

www.ibm.com · · 3207 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

面向 Go 开发人员的链代码深入研究,第 1 部分

使用 Go 编写智能合约

如何使用 Golang 为 Hyperledger Fabric v0.6 编写链代码

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 面向 Go 开发人员的链代码深入研究,第 1 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:面向 Go 开发人员的链代码深入研究,第 1 部分

敬请期待该系列的后续内容。

在本教程中,将学习如何使用 Golang 为基于 Hyperledger Fabric v0.6 的区块链网络开发链代码。我不但会介绍一些基础知识,比如链代码的角色和与底层 Fabric 交互的 API,还会介绍各种高级主题,比如数据建模、访问控制和事件。大量的代码示例演示了区块链上的一个住房贷款和购买合同流程。(参见本教程末尾部分的 “可下载资源”,下载完整的示例链代码。)

本教程是本系列的第一篇;后续教程将介绍如何对链代码执行单元测试,以及如何开发可调用您部署的链代码的客户端应用程序。

链代码是什么?

链代码也称为智能合约,实质上是控制区块链网络中的不同实体或相关方如何相互交互或交易的业务逻辑。简言之,链代码将业务网络交易封装在代码中。可以调用链代码来设置和获取账本或 world state。

在发表本教程时,Hyperledger 支持使用 Golang 或 Java™ 语言编写链代码,链代码最终在一个 Docker 容器内运行。因为 Java 链代码支持还处于公测阶段,所以本教程将重点介绍 Go。

设置开发环境

按照 IBM Bluemix 文档中的步骤开始 “设置开发环境”。到达题为 “设置开发管道” 的小节时,您已经为开始使用 Go 开发链代码做好了准备。

链代码结构

让我们仔细看看链代码的结构。前面已经提到过,清单 1 及整篇教程中的示例链代码,以及所讨论的架构都严格遵守 Hyperledger Fabric v0.6 预览版的规定。

清单 1 的第 4 行将 shim 包导入您的链代码包中。shim 包 提供了一些 API,以便您的链代码与底层区块链网络交互来访问状态变量、交易上下文、调用方证书和属性,并调用其他链代码和执行其他操作。

清单 1. 示例链代码
package main

import "fmt"
import "github.com/hyperledger/fabric/core/chaincode/shim"

type SampleChaincode struct {
}

func (t *SampleChaincode) Init(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	return nil, nil
}

func (t *SampleChaincode) Query(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	return nil, nil
}

func (t *SampleChaincode) Invoke(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	return nil, nil
}

func main() {
	err := shim.Start(new(SampleChaincode))
	if err != nil {
		fmt.Println("Could not start SampleChaincode")
	} else {
		fmt.Println("SampleChaincode successfully started")
	}

}

Main 函数

任何 Go 程序的起点都是 main 函数,因此该函数被用于引导/启动链代码。当对等节点部署其链代码实例时,就会执行 main 函数。

如清单 2 中第 2 行所示,shim.Start(new(SampleChaincode)) 行启动了链代码并向对等节点注册它。要在本地验证这一点,可在您的开发环境中运行该代码,这将生成以下错误:[shim] CRIT : peer.address not configured, can't connect to peer.

清单 2. Main()
func main() {
	err := shim.Start(new(SampleChaincode))
	if err != nil {
		fmt.Println("Could not start SampleChaincode")
	} else {
		fmt.Println("SampleChaincode successfully started")
	}

}

SampleChaincode 是实现 shim.Chaincode 接口所需的结构,它必须包含 3 种方法 — Init、Query 和 Invoke — 才能被 shim 包视为有效的链代码类型。让我们逐一看看这 3 种方法。

Init 方法

Init 方法 在链代码首次部署到区块链网络时调用,将由部署自己的链代码实例的每个对等节点执行。此方法可用于任何与初始化、引导或设置相关的任务。

清单 3. Init()
func (t *SampleChaincode) Init(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	return nil, nil
}

Query 方法

只要在区块链状态上执行任何读取/获取/查询操作,就会调用 Query 方法。根据链代码的复杂性,此方法含有您的读取/获取/查询逻辑,或者它可以外包给可从 Query 方法内调用的不同方法。

Query 方法不会更改底层链代码的状态,因此它不会在交易上下文内运行。如果尝试在 Query 方法内修改区块链的状态,将出现一个错误显示缺少交易上下文。另外,因为此方法仅用于读取区块链的状态,所以对它的调用不会记录在区块链上。

清单 4. Query()
func (t *SampleChaincode) Query(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	return nil, nil
}

Invoke 方法

只要修改区块链的状态,就会调用 Invoke 方法。简言之,所有创建、更新和删除操作都应封装在 Invoke 方法内。因为此方法将修改区块链的状态,所以区块链 Fabric 代码会自动创建一个交易上下文,以便此方法在其中执行。对此方法的所有调用都会在区块链上记录为交易,这些交易最终被写入区块中。

清单 5. Invoke()
func (t *SampleChaincode) Invoke(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	return nil, nil
}

链代码中的数据模型

Hyperledger 账本包含两部分:

  1. World State,存储在一个键值存储中。这个键值存储由 RocksDB 提供支持。这个键值存储获取一个字节数组作为值,该值可用于存储序列化 JSON 结构。基本上讲,这个键值存储可用于存储您的智能合约正常运行所需的所有自定义数据模型/模式。
  2. 区块链,由一系列区块组成,每个区块包含许多交易。每个区块包含 World State 的哈希值,并被链接到前一个区块。区块链采用仅附加模式 (append-only)。

清单 6 展示了如何创建自定义数据模型/模式。它定义了住房贷款申请所需的数据模型。主要模型称为 LoanApplication,它拥有原始数据类型和复杂数据类型,也就是 Personal 和 Financial Info。

因为我们的键值存储将数据存储为 JSON,所以这些数据模型最终需要转换为 JSON 字符串。每个字段(如 json:"id")的注释的作用类似于编组 (marshal) /解组 (unmarshal) API 的元数据,该 API 将使用这些注释将每个字段映射到相应的 JSON 字符串等效表示。

清单 6. 创建自定义数据模型/模式的代码
//custom data models
type PersonalInfo struct {
	Firstname string `json:"firstname"`
	Lastname  string `json:"lastname"`
	DOB       string `json:"DOB"`
	Email     string `json:"email"`
	Mobile    string `json:"mobile"`
}

type FinancialInfo struct {
	MonthlySalary      int `json:"monthlySalary"`
	MonthlyRent        int `json:"monthlyRent"`
	OtherExpenditure   int `json:"otherExpenditure"`
	MonthlyLoanPayment int `json:"monthlyLoanPayment"`
}

type LoanApplication struct {
	ID                     string        `json:"id"`
	PropertyId             string        `json:"propertyId"`
	LandId                 string        `json:"landId"`
	PermitId               string        `json:"permitId"`
	BuyerId                string        `json:"buyerId"`
	SalesContractId        string        `json:"salesContractId"`
	PersonalInfo           PersonalInfo  `json:"personalInfo"`
	FinancialInfo          FinancialInfo `json:"financialInfo"`
	Status                 string        `json:"status"`
	RequestedAmount        int           `json:"requestedAmount"`
	FairMarketValue        int           `json:"fairMarketValue"`
	ApprovedAmount         int           `json:"approvedAmount"`
	ReviewerId             string        `json:"reviewerId"`
	LastModifiedDate       string        `json:"lastModifiedDate"`
}

存储和检索数据

清单 7 和清单 8 中的代码展示了如何将数据存储到账本中以及如何从账本中获取数据。

将数据存储到账本中

清单 7 中第 1 行 上的 CreateLoanApplication 接受两个参数。第一个参数是 ChaincodeStubInterface,它拥有实用的 API 来与区块链账本、交易上下文、调用方证书等交互。第二个参数是一个字符串数组,该方法的调用方可以使用该数组传入所需的参数。

第 2-8 行记录和验证了输入参数。

第 9 行上,检索了贷款申请的 Id 值,该值将用作存储实际贷款申请对象的键。

第 10 行上,以 JSON 字符串格式检索了实际贷款申请内容。例如,‘{"propertyId":"prop1","landId":"land1","permitId":"permit1","buyerId":"vojha24","personalInfo":{"firstname":"Varun","lastname":"Ojha","dob":"dob","email":"varun@gmail.com","mobile":"99999999"},"financialInfo":{"monthlySalary":10000,"otherExpenditure":0,"monthlyRent":1000,"monthlyLoanPayment":1000},"status":"Submitted","requestedAmount":40000,"fairMarketValue":58000,"approvedAmount":40000,"reviewedBy":"abc1","lastModifiedDate":"21/09/2016 2:30pm"}’

第 12 行上,调用了 stub.PutState 方法,以便将贷款申请 id 和实际的贷款申请 JSON 内容作为一个键值对存储到区块链账本中。请注意,存储在键值存储中的值必须始终是字节数组。因此,在存储到账本之前,贷款申请 JSON 字符串首先被转换为字节数组。

清单 7. 将数据存储到账本中的代码
func CreateLoanApplication(stub shim.ChaincodeStubInterface, args []string) ([]byte, error) {
	fmt.Println("Entering CreateLoanApplication")

	if len(args) < 2 {
		fmt.Println("Invalid number of args")
		return nil, errors.New("Expected at least two arguments for loan application creation")
	}

	var loanApplicationId = args[0]
	var loanApplicationInput = args[1]

	err := stub.PutState(loanApplicationId, []byte(loanApplicationInput))
	if err != nil {
		fmt.Println("Could not save loan application to ledger", err)
		return nil, err
	}

	fmt.Println("Successfully saved loan application")
	return nil, nil
}

从账本获取数据

在清单 8 中,第 1 行上的 GetLoanApplication 方法接受两个参数。第一个参数是 ChaincodeStubInterface,它拥有实用的 API 来与区块链账本、交易上下文、调用方证书等交互。第二个参数是一个字符串数组,该方法的调用方可使用该数组传入需要的参数。

第 2-7 行记录和验证了输入参数。

第 9 行上,检索了贷款申请的 Id 值,该值将用作从账本中检索实际贷款申请对象的键。

第 10 行上,通过传入 loanApplicationId 键,调用 stub.GetState 方法以字节数组格式检索贷款申请 JSON 内容。请注意,存储在键值存储中的值必须始终是字节数组。因此,在存储到账本之前,贷款申请 JSON 字符串首先被转换为字节数组。

清单 8. 从账本中获取数据的代码
func GetLoanApplication(stub shim.ChaincodeStubInterface, args []string) ([]byte, error) {
	fmt.Println("Entering GetLoanApplication")

	if len(args) < 1 {
		fmt.Println("Invalid number of arguments")
		return nil, errors.New("Missing loan application ID")
	}

	var loanApplicationId = args[0]
	bytes, err := stub.GetState(loanApplicationId)
	if err != nil {
		fmt.Println("Could not fetch loan application with id "+loanApplicationId+" from ledger", err)
		return nil, err
	}
	return bytes, nil
}

备注:也可以使用传统关系数据模型处理数据。例如,ChaincodeStubInterface 能够创建表格并处理行和列。但这只是一种逻辑抽象,而且数据将作为键值对存储在 RocksDB 中。在撰写本文时,Hyperledger Fabric 1.0 版正在开发之中,而且表数据结构已被弃用。因此,本教程不会讨论它,以便最大限度地减少链代码开发人员从 v0.6 过渡到 v1.0 所需执行的更改。

在 golang 结构与 JSON 字符串之间编组和解组数据

如清单 7 和清单 8 中所示,stub.PutState 和 stub.GetState 方法只处理字节数组。所以,能够将链代码中使用的常规结构对象与 JSON 字符串进行相互转换很重要。

清单 9 展示了如何将一种结构编组为可存储在账本中的 JSON 字符串字节数组。第 2 行创建了 PersonalInfo 对象的一个实例。第 3 行使用 json 包将对象编组为 JSON 字符串,并返回同一个字符串的字节数组。

可以通过将 "encoding/json" 包含在顶部的 import 代码块中来导入 json 包。然后此字节数组可以使用清单 7 中给出的 stub.PutState 方法来存储到账本中。

清单 9. 将一种结构编组为 JSON 字符串字节数组的代码
var personalInfo PersonalInfo
 personalInfo = PersonalInfo{"Varun", "Ojha", "dob", "varun@gmail.com", "9999999999"}
 bytes, err ;= json.Marshal (&personalInfo)
 if err != nil {
        fmt.Println("Could not marshal personal info object", err)
		return nil, err
 }
 err = stub.PutState("key", bytes)

清单 10 展示如何将一种结构从字节数组解组为已填充的结构。第 1 行使用关联的键从账本中获取 PersonalInfo JSON 字符串字节。第 3 行将第 1 行中检索的字节解组为变量 personalInfo 所引用的 PersonalInfo 对象。

现在可以使用第 4 行所示的点记法来访问和修改 personalInfo 对象。

清单 10. 将一种结构从字节数组解组为已填充的结构的代码
piBytes, err := stub.GetState(la1)	
 var personalInfo PersonalInfo
 err = json.Unmarshal(piBytes, &personalInfo)
 fmt.Println(personalInfo.Firstname)

实现访问控制和权限

Hyperledger 与其他区块链结构的主要区别之一是,它有一个安全的、带许可的账本,该账本适合用于实现企业级解决方案。

成员服务

Hyperledger 中的 “成员服务” 组件对在区块链网络中实现安全性和许可发挥着关键作用。它负责向用户发放登记和交易证书来响应注册和登记。

  • 登记证书:成员服务中的证书颁发机构将向希望在区块链上交易的用户发放一个登记证书,作为一种身份证明。
  • 交易证书:交易证书应用作一次性令牌,由调用方/调用应用程序连同对链代码的每个调用请求一起传递。交易证书是通过数学方式从父登记证书那里获得的,因此从理论上讲,可以利用父登记证书生成无限多个交易证书。在将交易数据存储在区块链中时,可以使用交易证书对该数据进行签名和加密。这可以确保只有拥有交易证书的用户或拥有父证书的监管者/审计者等人员,能够实际查看写入的交易内容。
  • 属性:每个交易证书可以包含一些由用户定义的属性。在从客户端应用程序发往证书颁发机构的注册请求中,可以提及这些属性。本系列的后续教程将从开发客户端应用程序的角度进行详细介绍。

清单 11 展示了如何从调用方的交易证书检索属性。前面已经提到过,用户或客户端应用程序需要在每个链代码调用请求中传入用户的证书,以便对区块链网络上的目标对等节点执行身份验证。HFC SDK 负责自动在请求中传递证书。

清单 11 的第 11 行上的 Invoke 函数已经过修改,以便检查输入函数名称并将调用工作委托给合适的处理函数。此外,Invoke 函数还会验证发送 CreateLoanApplication 调用请求的调用方的访问权限和角色。Invoke 函数调用自定义的 GetCertAttribute 函数从调用方的交易证书中检索特定属性。

GetCertAttribute 函数通过在第 3 行传入属性名称来获取属性值。

第 15 行检查调用方是否拥有 Bank Admin 角色并能调用 CreateLoanApplication 函数。如果调用方没有所需的角色,则会返回相应的错误。这样就可以在链代码中实现基于属性的访问控制。

ChaincodeStubInterface 有一些实用程序函数可以处理属性,比如 ReadCertAttributeVerifyAttributeVerifyAttributes。所有这些方法都依靠 github.com/hyperledger/fabric/core/chaincode/shim/crypto/attr 包来创建和使用负责处理属性的 AttributeHandlerImpl。

在目前正在开发的 Hyperledger Fabric 版本 (v1.0) 中,这些实用程序函数已从 ChaincodeStubInterface 删除。因此在 v1.0 中,链代码开发人员需要直接使用 AttributeHandlerImpl 来处理属性。

清单 11. 从调用方的交易证书中检索属性
func GetCertAttribute(stub shim.ChaincodeStubInterface, attributeName string) (string, error) {
	fmt.Println("Entering GetCertAttribute")
	attr, err := stub.ReadCertAttribute(attributeName)
	if err != nil {
		return "", errors.New("Couldn't get attribute " + attributeName + ". Error: " + err.Error())
	}
	attrString := string(attr)
	return attrString, nil
}

func (t *SampleChaincode) Invoke(stub shim.ChaincodeStubInterface, function string, args []string) ([]byte, error) {
	if function == "CreateLoanApplication" {
		username, _ := GetCertAttribute(stub, "username")
		role, _ := GetCertAttribute(stub, "role")
		if role == "Bank_Home_Loan_Admin" {
			return CreateLoanApplication(stub, args)
		} else {
			return nil, errors.New(username + " with role " + role + " does not have access to create a loan application")
		}

	}
	return nil, nil
}

创建和发出自定义事件

Hyperledger 包含一个事件框架,可以使用该框架发布/订阅预定义的或自定义的事件。您可以自由地在链代码中创建和发出自定义事件。例如,只要区块链的状态发生更改,就会生成一个事件。通过向区块链上的事件中心注册一个事件适配器,客户端应用程序可以订阅和使用这些事件。本系列的后续教程将详细介绍客户端应用程序如何使用 HFC SDK 订阅和使用通过链代码生成的事件。

除了自定义事件之外,Hyperledger 中包含的一些预定义的内部事件还包括:

  • 区块事件
  • 链代码事件
  • 拒绝事件
  • 注册事件

清单 12 中的代码展示了如何创建和发布自定义事件。

第 1-4 行定义了一个包含类型和描述字段的自定义事件对象。

CreateLoanApplication 函数从第 6 行开始,已被修改为包括在成功创建贷款申请时创建事件。

第 23 行创建了 customEvent 对象的实例并填入适当的事件细节。

第 24 行按照前面解释的方法将事件对象编组为 JSON 字符串字节数组。

第 28 行设置了自定义事件。stub.SetEvent 方法接受两个参数:事件 namepayload。客户端应用程序可订阅同一个事件名称/主题,以便在链代码生成事件时接收它们。

清单 12. 创建和发布自定义事件
type customEvent struct {
	Type        string `json:"type"`
	Description string `json:"description"`
}

func CreateLoanApplication(stub shim.ChaincodeStubInterface, args []string) ([]byte, error) {
	fmt.Println("Entering CreateLoanApplication")

	if len(args) < 2 {
		fmt.Println("Invalid number of args")
		return nil, errors.New("Expected at least two arguments for loan application creation")
	}

	var loanApplicationId = args[0]
	var loanApplicationInput = args[1]

	err := stub.PutState(loanApplicationId, []byte(loanApplicationInput))
	if err != nil {
		fmt.Println("Could not save loan application to ledger", err)
		return nil, err
	}

	var event = customEvent = {"createLoanApplication", "Successfully created loan application with ID " + loanApplicationId}
    eventBytes, err ;= json.Marshal(&event)
    if err != nil {
            return nil, err
    }
	err = stub.SetEvent("evtSender", eventBytes)
	if err != nil {
		fmt.Println("Could not set event for loan application creation", err)
	}

	fmt.Println("Successfully saved loan application")
	return nil, nil

}

处理日志

要在链代码中处理日志,既可以使用标准 fmt 包和 print 语句,也可以使用 shim 包中的 ChaincodeLogger 类型。

ChaincodeLogger 支持以下日志级别:

  • CRITICAL
  • ERROR
  • WARNING
  • NOTICE
  • INFO
  • DEBUG

可以通过 3 种方式设置日志级别:

  • shim.SetChaincodeLoggingLevel():此方法将采用 CORE_LOGGING_CHAINCODE 集中的 core.yaml 文件内指定的日志级别。core.yaml 文件包含设置和部署区块链网络所需的所有配置信息。
  • shim.SetLoggingLevel(level LoggingLevel):此方法将在 shim 级别上设置日志级别。
  • ChaincodeLogger.SetLevel(level LoggingLevel):此方法将在单个记录器实例级别上设置日志级别。

清单 13 展示了如何创建、配置和使用 ChaincodeLogger。

清单 13. 创建、配置和使用 ChaincodeLogger
func SampleLogging() {
		//Different Logging Levels
		criticalLevel, _ := shim.LogLevel("CRITICAL")
		errorLevel, _ := shim.LogLevel("ERROR")
		warningLevel, _ := shim.LogLevel("WARNING")
		noticeLevel, _ := shim.LogLevel("NOTICE")
		infoLevel, _ := shim.LogLevel("INFO")
		debugLevel, _ := shim.LogLevel("DEBUG")

		//Logging level at the shim level
		shim.SetLoggingLevel(infoLevel)

		//Create a logger instance
		myLogger := shim.NewLogger("SampleChaincodeLogger")

		//Set logging level on logger instance
		myLogger.SetLevel(infoLevel)

		//Check logging level
		fmt.Println(myLogger.IsEnabledFor(infoLevel))

		//Log statements
		myLogger.Info("Info Message")
		myLogger.Critical("Critical Message")
		myLogger.Warning("Warning Message")
		myLogger.Error("Error Message")
		myLogger.Notice("Notice Message")
		myLogger.Debug("Debug Message")

	}

常见问题和最佳实践

在与客户一起开发区块链应用程序时,我常常被问及以下问题。

如何将文件(图像、音频、视频、PDF 等)存储在区块链中?

以下两种方法在最新的 Hyperledger Fabric (v0.6) 中都有效:

  • 将所有文件/对象存储为 base64 编码字符串。客户端应用程序将文件/对象转换为 base64 编码字符串,并将它作为输入参数发送给链代码函数。然后链代码将它作为字节数组存储在键/值存储中。
  • 实际的文件/对象内容存储在区块链以外的地方;例如,存储在 IBM Bluemix Object Storage 服务 中。仅将文件/对象的链接/引用/ID 连同文件/对象的哈希值一起存储在区块链上。存储哈希值可确保在区块链外对文件/对象的任何篡改都能被相关方/实体检测出来。

如何避免将私有业务逻辑/合同细节泄漏给网络中的所有对等节点?

此问题是在一个供应链场景中提出的,区块链解决方案的一个最终用户不满意在对所有对等节点可见的智能合约中共享私有业务逻辑/合同信息(比如与不同供应商谈判的不同价格)。在 v0.6 中,可以使用外部系统集成来处理这种情况。

解决方案:对等节点希望保持为私有的业务逻辑/规则/合同,可以作为一组业务规则在外部应用程序(比如服务)中运行。链代码本身能够执行出站调用。所以举例而言,链代码可对业务规则/逻辑服务执行 REST API 调用并获取结果,以便隐藏逻辑,让实际链代码看不见它。

可以从链代码内与区块链外的系统集成。例如,可以使用链代码与外部数据库、API 等通信。但重要的是确保与这些系统的交互不会让链代码变得不确定。

局限性和问题

  • 修改业务规则服务时无需了解其他对等节点,因为它在区块链外运行。根据区块链网络中不同参与方之间的业务交互类型,这可能导致信任问题。
  • 业务规则服务必须可供区块链网络中运行智能合约/链代码的所有对等节点使用。
  • 该解决方案可能导致链代码变得不确定。链代码必须是确定性的。简言之,如果多方使用相同的参数调用链代码中的同一个函数,那么结果应该是相同的。例如,如果在与响应关联的链代码函数中使用一个时间戳或计数器,那么多方对链代码的调用会导致不同的结果。这将在区块链网络中的不同对等节点之间产生不一致的账本状态。

    请记住,每次调用链代码都会导致一致性网络中所有对等节点在自己的本地账本副本上调用链代码。

备注:在目前正在开发的 Hyperledger Fabric v1.0 中,此问题已通过更改架构本身得到系统解决。

结束语

本教程首先介绍了链代码的基础知识,然后深入介绍了链代码的构建块,以及可用于在链代码中执行重要任务(比如访问控制、数据建模和事件管理)的不同 API。请下载 SampleChaincode.go 文件中的统一代码段。

本系列的后续教程将介绍测试驱动的链代码开发,开发可与区块链网络通信的 Node.js 客户端应用程序,从而将开发流程补充完整。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册


有疑问加站长微信联系(非本文作者)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

3207 次点击  
加入收藏 微博
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传