HyperLeger Fabric开发(七)——HyperLeger Fabric链码开发

HyperLeger Fabric开发(七)——HyperLeger Fabric链码开发

一、链码开发模式

1、链码开发模式简介

Fabric的链码开发调试比较繁琐。在不使用链码开发模式的情况下,链码不能在本地测试,必须部署到docker,install和instantiate后,Peer节点会在新的容器中启动链码。但只能通过docker logs查看链码日志,通过打印日志的方式进行链码调试。如果对链码进行了修改,需要重新开始上述流程。
为了简化Fabric链码开发的调试过程,Fabric引入了链码开发模式。通常情况下,链码由Peer节点启动和维护,但在链码开发模式下,链码由用户构建和启动。链码开发模式用于链码开发阶段中链码的编码、构建、运行、调试等链码生命周期阶段的快速转换。
使用链码开发模式,启动Peer节点仍然需要安装、初始化链码,但只需要执行一次,并且链码可以运行在本地(比如直接在IDE启动),可以使用IDE的调试功能。如果对链码进行了膝盖,直接在IDE中编译运行就能在Peer节点看到修改后的链码。
要使用链码开发模式,首先修改运行Peer节点容器的启动命令,添加--peer-chaincodedev参数,例如在docker-compose.yaml中:
command: peer node start --peer-chaincodedev=true
指定宿主机端口与Peer节点容器端口的映射:

ports:
    - 7052:7052

宿主机端口是在本地启动链码连接Peer节点时使用的端口。Fabric 1.1版本使用7052端口,如果是Fabric 1.0版本,使用7051端口,可以通过修改core.yaml文件修改默认端口。
进入cli容器,安装、实例化链码:

peer chaincode install -n 链码名 -v 1 -p xxx.com/xxxapp
peer chaincode instantiate -o orderer.example.com:7050 -C mychannel -n 链码名 -v 版本号 -c ‘{"Args":[""]}‘ -P "OR (‘Org1MSP.member‘,‘Org2MSP.member‘)"

背书策略使用-P参数指定。
如果在IDE中直接运行链码,需要先配置两个环境变量:
CORE_PEER_ADDRESS=127.0.0.1:7052 CORE_CHAINCODE_ID_NAME=链码名:版本号

2、链码开发环境部署

fabric-sample项目提供了Fabric开发的多个实例,其中一个提供了链码开发Fabric网络环境,即chaincode-docker-devmode实例。
fabric-sample项目地址如下:
https://github.com/hyperledger/fabric-samples
进入chaincode-docker-devmode目录:
cd fabric-samples/chaincode-docker-devmode
启动Fabric链码开发网络环境:
docker-compose -f docker-compose-simple.yaml up -d
docker-compose-simple.yaml文件在chaincode容器中指定了链码代码注入目录为./../chaincode,用于指定开发者的开发目录。

3、编译链码

进入链码容器:
docker exec -it chaincode bash
此时进入链码容器的工作目录,工作目录中存放了开发者开发的链码。
编译链码:

cd [链码目录]
go build -o [可执行文件]

部署链码
CORE_PEER_ADDRESS=peer:[端口号] CORE_CHAINCODE_ID_NAME=[链码实例]:0 ./[可执行文件]
退出链码容器:
exit

4、测试链码

进入客户端cli容器:
docker exec -it cli bash
安装链码

cd ..
peer chaincode install -p [链码可执行文件的所在目录路径] -n [链码实例] -v [版本号]

实例化链码
peer chaincode instantiate -n [链码实例] -v [版本号] -c ‘{"Args":["函数","参数","参数"]}‘ -C [通道]
调用链码
peer chaincode invoke -n [链码实例] -c ‘{"Args":["函数", "参数", "参数"]}‘ -C [通道]

5、测试新链码

如果要测试新开发的链码,需要将新开发的链码目录添加到chaincode子目录下,并重新启动chaincode-docker-devmode网络。

二、链码的结构

1、链码API

每个链码程序都必须实现链码接口?,接口中的方法会在响应传来的交易时被调用。Init方法会在链码接收到instantiate(实例化)或者upgrade(升级)交易时被调用,执行必要的初始化操作,包括初始化应用的状态;Invoke方法会在响应调用交易时被调用以执行交易。
链码在开发过程中需要实现链码接口,交易的类型决定了哪个接口函数将会被调用,如instantiate和upgrade类型会调用链码的Init接口,而invoke类型的交易则调用链码的Invoke接口。链码的接口定义如下:

type Chaincode interface {
   Init(stub ChaincodeStubInterface) pb.Response
   Invoke(stub ChaincodeStubInterface) pb.Response
}

shim.ChaincodeStubInterface接口用于访问及修改账本,并实现链码之间的互相调用,为编写链码的业务逻辑提供了大量实用的方法。

2、链码的基本结构

链码的必要结构如下:

使用Go语言开发链码需要定义一个struct,然后在struct上定义Init和Invoke两个函数,定义main函数作为链码的启动入口。
Init和Invoke都有传入参数stub shim.ChaincodeStubInterface,为编写链码的业务逻辑提供大量实用方法。

三、shim.ChaincodeStubInterface接口

1、获得调用的参数

GetArgs() [][]byte
以byte数组的数组的形式获得传入的参数列表
GetStringArgs() []string
以字符串数组的形式获得传入的参数列表
GetFunctionAndParameters() (string, []string)
将字符串数组的参数分为两部分,数组第一个字是Function,剩下的都是Parameter
GetArgsSlice() ([]byte, error)
以byte切片的形式获得参数列表
function, args := stub.GetFunctionAndParameters()

2、增删改查State DB

链码开发的核心业务逻辑就是对State Database的增删改查。
PutState(key string, value []byte) error
State DB是一个Key Value数据库,增加和修改数据是统一的操作,如果指定的Key在数据库中已经存在,那么是修改操作,如果Key不存在,那么是插入操作。Key是一个字符串,Value是一个对象经过JSON序列化后的字符串。

type Student struct {
   Id int
   Name string
}
func (t *SimpleChaincode) testStateOp(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   student1:=Student{1,"Devin Zeng"}
   key:="Student:"+strconv.Itoa(student1.Id)//Key格式为 Student:{Id}
   studentJsonBytes, err := json.Marshal(student1)//Json序列号
   if err != nil {
      return shim.Error(err.Error())
   }
   err= stub.PutState(key,studentJsonBytes)
   if(err!=nil){
      return shim.Error(err.Error())
   }
   return shim.Success([]byte("Saved Student!"))
}

DelState(key string) error
根据Key删除State DB的数据。如果根据Key找不到对应的数据,删除失败。

err= stub.DelState(key)
if err != nil {
   return shim.Error("Failed to delete Student from DB, key is: "+key)
}

GetState(key string) ([]byte, error)
根据Key来对数据库进行查询,返回byte数组数据,需要转换为string,然后再Json反序列化,可以得到对象。
不能在一个链码的函数中PutState后马上GetState,因为还没有完成,还没有提交到StateDB里。

dbStudentBytes,err:= stub.GetState(key)
var dbStudent Student;
err=json.Unmarshal(dbStudentBytes,&dbStudent)//反序列化
if err != nil {
   return shim.Error("{\"Error\":\"Failed to decode JSON of: " + string(dbStudentBytes)+ "\" to Student}")
}
fmt.Println("Read Student from DB, name:"+dbStudent.Name)

3、复合键处理

CreateCompositeKey(objectType string, attributes []string) (string, error)
根据某个对象生成复合键,需要指定对象的类型,复合键涉及的属性。

type ChooseCourse struct {
   CourseNumber string //开课编号
   StudentId int //学生ID
   Confirm bool //是否确认
}
cc:=ChooseCourse{"CS101",123,true}
var key1,_= stub.CreateCompositeKey("ChooseCourse",[]string{cc.CourseNumber,strconv.Itoa(cc.StudentId)})
fmt.Println(key1)

SplitCompositeKey(compositeKey string) (string, []string, error)
根据复合键拆分得到对象类型,属性字符串数组

objType,attrArray,_:= stub.SplitCompositeKey(key1)
fmt.Println("Object:"+objType+" ,Attributes:"+strings.Join(attrArray,"|"))
GetStateByPartialCompositeKey(objectType string, keys []string) (StateQueryIteratorInterface, error)

对Key进行前缀匹配的查询,不允许使用后面部分的复合键进行匹配。

4、获取当前用户证书

GetCreator() ([]byte, error)?
获得调用本链码的客户端的用户证书。
通过获得当前用户的用户证书,可以将用户证书的字符串转换为Certificate对象,然后通过Subject获得当前用户的名字。

func (t *SimpleChaincode) testCertificate(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   creatorByte,_:= stub.GetCreator()
   certStart := bytes.IndexAny(creatorByte, "-----BEGIN")
   if certStart == -1 {
      fmt.Errorf("No certificate found")
   }
   certText := creatorByte[certStart:]
   bl, _ := pem.Decode(certText)
   if bl == nil {
      fmt.Errorf("Could not decode the PEM structure")
   }

   cert, err := x509.ParseCertificate(bl.Bytes)
   if err != nil {
      fmt.Errorf("ParseCertificate failed")
   }
   uname:=cert.Subject.CommonName
   fmt.Println("Name:"+uname)
   return shim.Success([]byte("Called testCertificate "+uname))
}

5、高级查询

GetStateByRange(startKey, endKey string)** (StateQueryIteratorInterface, error)
提供了对某个区间的Key进行查询的接口,适用于任何State DB,返回一个StateQueryIteratorInterface接口。需要通过返回接口再做一个for循环,读取返回的信息。

func getListResult(resultsIterator shim.StateQueryIteratorInterface) ([]byte,error){

   defer resultsIterator.Close()
   // buffer is a JSON array containing QueryRecords
   var buffer bytes.Buffer
   buffer.WriteString("[")

   bArrayMemberAlreadyWritten := false
   for resultsIterator.HasNext() {
      queryResponse, err := resultsIterator.Next()
      if err != nil {
         return nil, err
      }
      // Add a comma before array members, suppress it for the first array member
      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("queryResult:\n%s\n", buffer.String())
   return buffer.Bytes(), nil
}
func (t *SimpleChaincode) testRangeQuery(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   resultsIterator,err:= stub.GetStateByRange("Student:1","Student:3")
   if err!=nil{
      return shim.Error("Query by Range failed")
   }
   students,err:=getListResult(resultsIterator)
   if err!=nil{
      return shim.Error("getListResult failed")
   }
   return shim.Success(students)
}

GetQueryResult(query string) (StateQueryIteratorInterface, error)
富查询,CouchDB才能使用。

func (t *SimpleChaincode) testRichQuery(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   name:="Lee"
   queryString := fmt.Sprintf("{\"selector\":{\"Name\":\"%s\"}}", name)
   resultsIterator,err:= stub.GetQueryResult(queryString)//必须是CouchDB才行
   if err!=nil{
      return shim.Error("Rich query failed")
   }
   students,err:=getListResult(resultsIterator)
   if err!=nil{
      return shim.Error("Rich query failed")
   }
   return shim.Success(students)
}

GetHistoryForKey(key string) (HistoryQueryIteratorInterface, error)
对同一个数据(Key相同)的更改,会记录到区块链中,可以通过GetHistoryForKey方法获得对象在区块链中记录的更改历史,包括是在哪个TxId,修改的数据,修改的时间戳,以及是否是删除等。

func (t *SimpleChaincode) testHistoryQuery(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   student1:=Student{1,"Lee"}
   key:="Student:"+strconv.Itoa(student1.Id)
   it,err:= stub.GetHistoryForKey(key)
   if err!=nil{
      return shim.Error(err.Error())
   }
   var result,_= getHistoryListResult(it)
   return shim.Success(result)
}
func getHistoryListResult(resultsIterator shim.HistoryQueryIteratorInterface) ([]byte,error){

   defer resultsIterator.Close()
   // buffer is a JSON array containing QueryRecords
   var buffer bytes.Buffer
   buffer.WriteString("[")

   bArrayMemberAlreadyWritten := false
   for resultsIterator.HasNext() {
      queryResponse, err := resultsIterator.Next()
      if err != nil {
         return nil, err
      }
      // Add a comma before array members, suppress it for the first array member
      if bArrayMemberAlreadyWritten == true {
         buffer.WriteString(",")
      }
      item,_:= json.Marshal( queryResponse)
      buffer.Write(item)
      bArrayMemberAlreadyWritten = true
   }
   buffer.WriteString("]")
   fmt.Printf("queryResult:\n%s\n", buffer.String())
   return buffer.Bytes(), nil
}

6、调用链码

InvokeChaincode(chaincodeName string, args [][]byte, channel string) pb.Response
在本链码中调用其它通道上已经部署好的链码。
channel:通道名称
chaincodeName:链码实例名称
args:调用的方法、参数的数组组合

func (t *SimpleChaincode) testInvokeChainCode(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   trans:=[][]byte{[]byte("invoke"),[]byte("a"),[]byte("b"),[]byte("11")}
   response:= stub.InvokeChaincode("mycc",trans,"mychannel")
   fmt.Println(response.Message)
   return shim.Success([]byte( response.Message))
}

7、获取提案对象Proposal属性

(1)获得签名的提案
GetSignedProposal() (*pb.SignedProposal, error)
从客户端发现背书节点的Transaction或者Query都是一个提案,GetSignedProposal获得当前的提案对象包括客户端对提案的签名。提案的内容包括提案Header,Payload和Extension。
(2)获得Transient对象
GetTransient() (map[string][]byte, error)
返回提案对象的Payload的属性ChaincodeProposalPayload.TransientMap
(3)获得交易时间戳
GetTxTimestamp() (*timestamp.Timestamp, error)
返回提案对象的proposal.Header.ChannelHeader.Timestamp
(4)获得Binding对象
GetBinding() ([]byte, error)
返回提案对象的proposal.Header中SignatureHeader.Nonce,SignatureHeader.Creator和ChannelHeader.Epoch的组合。

8、事件设置

SetEvent(name string, payload []byte) error
当链码提交完毕,会通过事件的方式通知客户端,通知的内容可以通过SetEvent设置。事件设置完毕后,需要在客户端也做相应的修改。

func (t *SimpleChaincode) testEvent(stub shim.ChaincodeStubInterface, args []string) pb.Response{
   tosend := "Event send data is here!"
   err := stub.SetEvent("evtsender", []byte(tosend))
   if err != nil {
      return shim.Error(err.Error())
   }
   return shim.Success(nil)
}

四、链码开发示例

package main

import (
   "fmt"
   "strconv"

   "github.com/hyperledger/fabric/core/chaincode/lib/cid"
   "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 {

   fmt.Println("abac Init")
   err := cid.AssertAttributeValue(stub, "abac.init", "true")
   if err != nil {
      return shim.Error(err.Error())
   }

   _, args := stub.GetFunctionAndParameters()
   var A, B string    // Entities
   var Aval, Bval int // Asset holdings

   if len(args) != 4 {
      return shim.Error("Incorrect number of arguments. Expecting 4")
   }

   // 初始化链码
   A = args[0]
   Aval, err = strconv.Atoi(args[1])
   if err != nil {
      return shim.Error("Expecting integer value for asset holding")
   }
   B = args[2]
   Bval, err = strconv.Atoi(args[3])
   if err != nil {
      return shim.Error("Expecting integer value for asset holding")
   }
   fmt.Printf("Aval = %d, Bval = %d\n", Aval, Bval)

   // 写入状态到账本
   err = stub.PutState(A, []byte(strconv.Itoa(Aval)))
   if err != nil {
      return shim.Error(err.Error())
   }

   err = stub.PutState(B, []byte(strconv.Itoa(Bval)))
   if err != nil {
      return shim.Error(err.Error())
   }

   return shim.Success(nil)
}

func (t *SimpleChaincode) Invoke(stub shim.ChaincodeStubInterface) pb.Response {
   fmt.Println("abac Invoke")
   function, args := stub.GetFunctionAndParameters()
   if function == "invoke" {
      // 转账,将X金额从账户A转账到账户B
      return t.invoke(stub, args)
   } else if function == "delete" {
      // 删除账户
      return t.delete(stub, args)
   } else if function == "query" {
      return t.query(stub, args)
   }

   return shim.Error("Invalid invoke function name. Expecting \"invoke\" \"delete\" \"query\"")
}

// 转账交易,将X金额从账户A转账到账户B
func (t *SimpleChaincode) invoke(stub shim.ChaincodeStubInterface, args []string) pb.Response {
   var A, B string    // Entities
   var Aval, Bval int // Asset holdings
   var X int          // Transaction value
   var err error

   if len(args) != 3 {
      return shim.Error("Incorrect number of arguments. Expecting 3")
   }

   A = args[0]
   B = args[1]

   // 从账本读取状态
   // TODO: will be nice to have a GetAllState call to ledger
   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))

   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, 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)

   // 将状态写回账本
   err = stub.PutState(A, []byte(strconv.Itoa(Aval)))
   if err != nil {
      return shim.Error(err.Error())
   }

   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]

   // Delete the key from the state in ledger
   err := stub.DelState(A)
   if err != nil {
      return shim.Error("Failed to delete state")
   }

   return shim.Success(nil)
}

// query callback representing the query of a chaincode
func (t *SimpleChaincode) query(stub shim.ChaincodeStubInterface, args []string) pb.Response {
   var A string // Entities
   var err error

   if len(args) != 1 {
      return shim.Error("Incorrect number of arguments. Expecting name of the person to query")
   }

   A = args[0]

   // Get the state from the ledger
   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)
   }
}

原文地址:http://blog.51cto.com/9291927/2318328

时间: 2024-11-05 18:27:34

HyperLeger Fabric开发(七)——HyperLeger Fabric链码开发的相关文章

HyperLeger Fabric开发(八)——HyperLeger Fabric链码开发测试

HyperLeger Fabric开发(八)--HyperLeger Fabric链码开发测试 一.链码实例 SACC项目链码实例如下: package main import ( "fmt" "github.com/hyperledger/fabric/core/chaincode/shim" "github.com/hyperledger/fabric/protos/peer" ) // SimpleAsset implements a si

Fabric1.4:Go 链码开发与编写

1 链码结构 1.1 链码接口 链码启动必须通过调用 shim 包中的 Start 函数,传递一个类型为 Chaincode 的参数,该参数是一个接口类型,有两个重要的函数 Init 与 Invoke . type Chaincode interface{ Init(stub ChaincodeStubInterface) peer.Response Invoke(stub ChaincodeStubInterface) peer.Response } Init:在链码实例化或升级时被调用, 完

程序员之选:七款杰出移动开发工具(转)

移动优先的开发理念已经成为前瞻性应用开发机构的首要标志.有鉴于此,移动应用开发工具的阵营不断扩张.成员日益丰富自然不足为奇.据调查,移动开发原型.概念验证与跨平台三类工具受到移动开发人员的广泛欢迎. AD:WOT2015 互联网运维与开发者大会 热销抢票 移动开发原型.概念验证与跨平台三类工具受到移动开发人员的广泛欢迎. [2013年5月29日 51CTO外电头条]移动优先的开发理念已经成为前瞻性应用开发机构的首要标志.有鉴于此,移动应用开发工具的阵营不断扩张.成员日益丰富自然不足为奇. 包括英

Hyperledger Fabric 实战(十二): Fabric 源码本地调试

借助开发网络调试 fabric 源码本地调试 准备工作 IDE Goland Go 1.9.7 fabric-samples 模块 chaincode-docker-devmode fabric 源码 步骤 添加本地域名 127.0.0.1 peer 127.0.0.1 orderer 用 ide 打开 $GOPATH 下的fabric源码目录 在源码目录下添加 dev-network 把 sampleconfig 下的所有文件复制到 dev-network 修改 core.yaml 中 fil

iOS开发-博客导出工具开发教程(附带源码)

前言: 作为一名学生, 作为一名iOS开发学习者, 我个人浏览信息包括博客, 更多的选择移动终端.然而, csdn并没有现成的客户端(不过有个web版的). 之前曾经看到一款开源的导出工具, 但是它是基于Windows平台的.导出的也仅仅是PDF格式.而且, 对于文章的导出, 需要精确URL.无法做到边浏览别导出. 另外, 我想实现的是, 可以在没有网络的情况下, 浏览自己收藏的文章.并且, 对于自己收藏的文章, 可以分类管理. 最关键的是, 对于自己的文章, 可以做一个备份.我曾经遇到过这样一

Hololens开发笔记之使用Unity开发一个简单的应用

一.Hololens概述 Hololens有以下特性 1.空间映射借助微软特殊定制的全息处理单元(HPU),HoloLens 实现了对周边环境的快速扫描和空间匹配.这保证了 HoloLens能够准确地在真实世界表面放置或展现全息图形内容,确保了核心的AR体验. 2.场景匹配HoloLens 设备能存储并识别环境信息,恢复和保持不同场景中的全息图像对象.当你离开当前房间再回来时,会发现原有放置的全息图像均会在正确的位置出现. 3.自然交互HoloLens 主要交互方式为凝视(Gaze).语音(Vo

创新式开发(四) —— 反思自己的开发活动

 提纲:   1.   没有遵从自己的生物本性来进行工作和作息, 是第一个需要反思的事情: 2.   不重视方法, 只顾蛮干,  是第二个需要反思的事情: 3.   没有做好有效的记录.备份工作, 是第三个需要反思的事情: 4.   开发程序时不能知其所以然, 满足于 “It works”,  是第四个需要反思的事情: 5.   没有建立一套行之有效的机制,来应对工作中的各种中断和异常, 是第五个需要反思的事情: 6.   没有明确的职业/技术方向, 精力分散容易导致样样会一点却无所精通, 是第

以太坊代币开发虚拟币钱包交易平台开发

以太坊代币开发虚拟币钱包交易平台开发156-3841-3841 作为一种加密数字货币,比特币价格在过去几年里暴涨,到2017年底时曾达到近两万美元,令许多人感到不可思议. 然而自2018年以来,比特币价格开始下跌,特别是在近期上演"大跳水".11月20日,比特币重挫逾16%,跌破4100美元,为去年10月以来的最低水平.比特币的暴跌,也引发其他加密货币大幅下挫.CoinMarketCap数据显示,目前整个加密货币市场价值已跌至约1500亿美元左右,与今年初时的8500亿美元规模相比严重

微信小程序开发由0到1开发,快速开发上线

首先先注册微信小程序管理 一.登录微信公众平台https://mp.weixin.qq.com 二.点击立即注册. 注意:这里不要用微信公众号登录,小程序账号和微信公众号是不同的. 三.在注册页面点击小程序板块. 四.进入小程序注册页面.已经有小程序账号的可以直接登录. 五.注册成功后登录邮箱激活小程序账号. 六.激活后进入小程序身份信息登记,按要求填写好自己的个人/企业/组织等身份信息,通过后确认即可. 七.完成前期账号注册和认证后,即可进入小程序管理页面 点击查看腾讯官方3元购小程序购买 在