Fabric1.4出典分析:チャンネルを作成するには、クライアント・プロセス

通常、我々はコマンド補完を実行し、チャネルを作成するために、ファブリックを使用している場合、この資料では、このコマンドのファブリックソースコードの実行後に解決するために実装プロセスを見ていきます。

peer channel create -o orderer.example.com:7050 -c mychannel -f ./channel-artifacts/channel.tx --tls true --cafile $ORDERER_CA

全体のプロセスのエントリポイントfabric/peer/main.goファイルmain()方法(本明細書で使用される、Fabric1.4バージョン、コンテンツの異なるバージョンが異なっていてもよいです)この方法はまた、バージョンについて、実行できるコマンドのピア・ノードに定義されている:version.Cmd()、ノードステータス:node.Cmd()チェーン・コードについては:chaincode.Cmd(nil)、クライアントがログオン:clilogging.Cmd(nil)最後のチャンネルが約あります:channel.Cmd(nil)だから我々は、全体のプロセスを見て、ここから始めるかのチャネルを作成することです。
ポイントの後にpeer/channel/channel.goチャネルピア・ノードを動作させるためのコマンドを定義するライン49上のファイル、実行することができます。

func Cmd(cf *ChannelCmdFactory) *cobra.Command {
    AddFlags(channelCmd)
    
    #创建通道
    channelCmd.AddCommand(createCmd(cf))  
    #从通道获取区块
    channelCmd.AddCommand(fetchCmd(cf))
    #加入通道
    channelCmd.AddCommand(joinCmd(cf))
    #列出当前节点所加入的通道
    channelCmd.AddCommand(listCmd(cf))
    #签名并更新通道配置信息
    channelCmd.AddCommand(updateCmd(cf))
    #只对通道配置信息进行签名
    channelCmd.AddCommand(signconfigtxCmd(cf))
    #获取通道信息
    channelCmd.AddCommand(getinfoCmd(cf))

    return channelCmd
}

ピア・ノードの特定のコマンドの使用法は、参照することができファブリック公式文書ではなく、ここでの説明。
我々は見てcreateCmd(cf)の方法は、へ進み、方法peer/channel/create.goを知っていると、関連するチャンネルを作成するファイル名を確認するために、ファイル、51行。

func createCmd(cf *ChannelCmdFactory) *cobra.Command {
    createCmd := &cobra.Command{
        Use:   "create",   #使用create关键词创建通道
        Short: "Create a channel",  
        Long:  "Create a channel and write the genesis block to a file.",   #创建通道并将创世区块写入文件
        RunE: func(cmd *cobra.Command, args []string) error {
            #这一行命令就是对通道进行创建了,点进行看一下
            return create(cmd, args, cf)
        },
    }
...
}

create(cmd, args, cf)この文書に記載されているメソッド227行:

func create(cmd *cobra.Command, args []string, cf *ChannelCmdFactory) error {
    // the global chainID filled by the "-c" command
    #官方注释用-c来表明通道ID
    if channelID == common.UndefinedParamValue {
        #UndefinedParamValue="",如果通道ID等于空
        return errors.New("must supply channel ID")
    }

    // Parsing of the command line is done so silence cmd usage
    cmd.SilenceUsage = true

    var err error
    if cf == nil {
        #如果ChannelCmdFactory为空,则初始化一个
        cf, err = InitCmdFactory(EndorserNotRequired, PeerDeliverNotRequired, OrdererRequired)
        if err != nil {
            return err
        }
    }
    #最后将ChannelCmdFactory传入该方法,进行通道的创建
    return executeCreate(cf)
}

InitCmdFactory初めて目()で行われていたものpeer/channel/channel.goファイル、行126:

func InitCmdFactory(isEndorserRequired, isPeerDeliverRequired, isOrdererRequired bool) (*ChannelCmdFactory, error) {
    #这里的意思就是只能有一个交付源,要么是Peer要么是Orderer
    if isPeerDeliverRequired && isOrdererRequired {
        return nil, errors.New("ERROR - only a single deliver source is currently supported")
    }

    var err error
    #初始化ChannelCmdFactory,看一下该结构体的内容
    cf := &ChannelCmdFactory{}

さて、ここで直接それを取ります:

 type ChannelCmdFactory struct {
    #用于背书的客户端
    EndorserClient   pb.EndorserClient
    #签名者
    Signer           msp.SigningIdentity
    #用于广播的客户端
    BroadcastClient  common.BroadcastClient
    #用于交付的客户端
    DeliverClient    deliverClientIntf
    #创建用于广播的客户端的工厂
    BroadcastFactory BroadcastClientFactory
}

そして、見下します:

    #获取默认的签名者,通常是Peer节点
    cf.Signer, err = common.GetDefaultSignerFnc()
    if err != nil {
        return nil, errors.WithMessage(err, "error getting default signer")
    }

    cf.BroadcastFactory = func() (common.BroadcastClient, error) {
        #根据ChannelCmdFactory结构体中的BroadcastFactory获取BroadcastClient
        return common.GetBroadcastClientFnc()
    }

    // for join and list, we need the endorser as well
    #我们这里是完成对通道的创建,所以只使用了最后一个isOrdererRequired
    if isEndorserRequired {
        #创建一个用于背书的客户端
        cf.EndorserClient, err = common.GetEndorserClientFnc(common.UndefinedParamValue, common.UndefinedParamValue)
        if err != nil {
            return nil, errors.WithMessage(err, "error getting endorser client for channel")
        }
    }

    // for fetching blocks from a peer
    if isPeerDeliverRequired {
        #从Peer节点创建一个用于交付的客户端
        cf.DeliverClient, err = common.NewDeliverClientForPeer(channelID, bestEffort)
        if err != nil {
            return nil, errors.WithMessage(err, "error getting deliver client for channel")
        }
    }

    // for create and fetch, we need the orderer as well
    if isOrdererRequired {
        if len(strings.Split(common.OrderingEndpoint, ":")) != 2 {
            return nil, errors.Errorf("ordering service endpoint %s is not valid or missing", common.OrderingEndpoint)
        }
        #从Order节点创建一个一个用于交付的客户端
        cf.DeliverClient, err = common.NewDeliverClientForOrderer(channelID, bestEffort)
        if err != nil {
            return nil, err
        }
    }
    logger.Infof("Endorser and orderer connections initialized")
    return cf, nil
}

戻るcreate()方法:

#到了最后一行代码,传入之前创建的ChannelCmdFactory,开始进行通道的创建
return executeCreate(cf)

方法peer/channel/create.goライン174ファイル:

#方法比较清晰,一共完成了以下几个步骤
func executeCreate(cf *ChannelCmdFactory) error {
    #发送创建通道的Transaction到Order节点
    err := sendCreateChainTransaction(cf)
    if err != nil {
        return err
    }
    #获取该通道内的创世区块(该过程在Order节点共识完成之后)
    block, err := getGenesisBlock(cf)
    if err != nil {
        return err
    }
    #序列化区块信息
    b, err := proto.Marshal(block)
    if err != nil {
        return err
    }
    file := channelID + ".block"
    if outputBlock != common.UndefinedParamValue {
        file = outputBlock
    }
    #将区块信息写入本地文件中
    err = ioutil.WriteFile(file, b, 0644)
    if err != nil {
        return err
    }
    return nil
}

1.Peerノードは、チャネルを作成するために使用される封筒のファイルを作成します

まず、見てsendCreateChainTransaction()バックへの道peer/channel/create.goライン144上のファイル:

func sendCreateChainTransaction(cf *ChannelCmdFactory) error {
    var err error
    #定义了一个Envelope结构体
    var chCrtEnv *cb.Envelope

Envelope構造:

type Envelope struct {
    #主要就是保存被序列化的有效载荷
    Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
    #由创建者进行的签名信息
    Signature            []byte   `protobuf:"bytes,2,opt,name=signature,proto3" json:"signature,omitempty"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

戻るまでsendCreateChainTransaction()、この方法で、ダウンし続けます:

    if channelTxFile != "" {
        #如果指定了channelTxFile,则使用指定的文件创建通道,这个方法很简单,从文件中读取数据,反序列化后返回chCrtEnv.对于我们启动Fabric网络之前曾创建过一个channel.tx文件,指的就是这个
        if chCrtEnv, err = createChannelFromConfigTx(channelTxFile); err != nil {
            return err
        }
    } else {
        #如果没有指定,则使用默认的配置创建通道,看一下这个方法,在71行
        if chCrtEnv, err = createChannelFromDefaults(cf); err != nil {
            return err
        }
    }
-------------------------------createChannelFromDefaults()-------
func createChannelFromDefaults(cf *ChannelCmdFactory) (*cb.Envelope, error) {
    #主要就这一个方法,点进去
    chCrtEnv, err := encoder.MakeChannelCreationTransaction(channelID, localsigner.NewSigner(), genesisconfig.Load(genesisconfig.SampleSingleMSPChannelProfile))
    if err != nil {
        return nil, err
    }
    return chCrtEnv, nil
}

MakeChannelCreationTransaction()ID方式は、チャネルに導入され、署名者、およびデフォルトのコンフィギュレーションファイル内のメソッド作成されたcommon/tools/configtxgen/encoder/encoder.goファイルの行502:

func MakeChannelCreationTransaction(channelID string, signer crypto.LocalSigner, conf *genesisconfig.Profile) (*cb.Envelope, error) {
    #从名字可以看到是使用了默认的配置模板,对各种策略进行配置,里面就不再细看了
    template, err := DefaultConfigTemplate(conf)
    if err != nil {
        return nil, errors.WithMessage(err, "could not generate default config template")
    }
    #看一下这个方法,从模板中创建一个用于创建通道的Transaction
    return MakeChannelCreationTransactionFromTemplate(channelID, signer, conf, template)
}

MakeChannelCreationTransactionFromTemplate()方法ライン530:

func MakeChannelCreationTransactionFromTemplate(channelID string, signer crypto.LocalSigner, conf *genesisconfig.Profile, template *cb.ConfigGroup) (*cb.Envelope, error) {
    newChannelConfigUpdate, err := NewChannelCreateConfigUpdate(channelID, conf, template)
    ...
    #创建一个用于配置更新的结构体
    newConfigUpdateEnv := &cb.ConfigUpdateEnvelope{
        ConfigUpdate: utils.MarshalOrPanic(newChannelConfigUpdate),
    }

    if signer != nil {
        #如果签名者不为空,创建签名Header
        sigHeader, err := signer.NewSignatureHeader()
        ...
        newConfigUpdateEnv.Signatures = []*cb.ConfigSignature{{
            SignatureHeader: utils.MarshalOrPanic(sigHeader),
        }}
        ...
        #进行签名
        newConfigUpdateEnv.Signatures[0].Signature, err = signer.Sign(util.ConcatenateBytes(newConfigUpdateEnv.Signatures[0].SignatureHeader, newConfigUpdateEnv.ConfigUpdate))
        ...
    }
    #创建被签名的Envelope,然后一直返回到最外面的方法
    return utils.CreateSignedEnvelope(cb.HeaderType_CONFIG_UPDATE, channelID, signer, newConfigUpdateEnv, msgVersion, epoch)
}

ここでは、封筒は、チャネルが作成された作成するために使用sendCreateChainTransaction()上の読み:

...
    #该方法主要是对刚刚创建的Envelope进行验证
    if chCrtEnv, err = sanityCheckAndSignConfigTx(chCrtEnv); err != nil {
        return err
    }
    var broadcastClient common.BroadcastClient
    #验证完成后,创建一个用于广播信息的客户端
    broadcastClient, err = cf.BroadcastFactory()
    if err != nil {
        return errors.WithMessage(err, "error getting broadcast client")
    }

    defer broadcastClient.Close()
    #将创建通道的Envelope信息广播出去
    err = broadcastClient.Send(chCrtEnv)
    return err
}

ここでは、sendCreateChainTransaction()この方法は、この方法で行った作業をまとめるために、オーバーです。

  1. これは、構造のエンベロープを定義します
  2. (channel.txネットワークを開始する前に作成した)判定channelTxFileファイルは、それが一般的に存在する、存在しています。
  3. ある場合、その後、存在するデフォルトのテンプレートから作成していないファイルから設定情報を読み取り、そして最終的に封筒を返します
  4. 検証するファイルの封筒
  5. 放送情報のためのクライアントを作成し、封筒のファイルは、放送の外に作成されます。

    2注文封筒文書処理ノード

    作成ブロックを取得してのは、ピア・ノードを見てみましょう、説明できないローカルにファイルを保存するためとして封筒の後に放送されたチャネルを作成し、注文のノードがありません。
    方法で/order/common/server/server.go行141:
func (s *server) Broadcast(srv ab.AtomicBroadcast_BroadcastServer) error {
    ...
    #主要在这一行代码,Handle方法对接收到的信息进行处理
    return s.bh.Handle(&broadcastMsgTracer{
        AtomicBroadcast_BroadcastServer: srv,
        msgTracer: msgTracer{
            debug:    s.debug,
            function: "Broadcast",
        },
    })
}

以下のためのHandler()方法では、/order/common/broadcast/broadcast.go66行のファイル:

func (bh *Handler) Handle(srv ab.AtomicBroadcast_BroadcastServer) error {
    #首先获取消息的源地址
    addr := util.ExtractRemoteAddress(srv.Context())
    ...
    for {
        #接收消息
        msg, err := srv.Recv()
        ...
        #处理接收到的消息,我们看一下这个方法
        resp := bh.ProcessMessage(msg, addr)
        #最后将响应信息广播出去
        err = srv.Send(resp)
        ...
    }
}

ProcessMessage(msg, addr)受信されたメッセージおよびメッセージの送信元アドレスにメソッドに渡されたパラメータは、この方法は、メッセージの順序マスタノード処理方法がより重要です。ライン136:

func (bh *Handler) ProcessMessage(msg *cb.Envelope, addr string) (resp *ab.BroadcastResponse) {
    #这个结构体应该理解为记录器,记录消息的相关信息
    tracker := &MetricsTracker{
        ChannelID: "unknown",
        TxType:    "unknown",
        Metrics:   bh.Metrics,
    }
    defer func() {
        // This looks a little unnecessary, but if done directly as
        // a defer, resp gets the (always nil) current state of resp
        // and not the return value
        tracker.Record(resp)
    }()
    #记录处理消息的开始时间
    tracker.BeginValidate()
    #该方法获取接收到的消息的Header,并判断是否为配置信息
    chdr, isConfig, processor, err := bh.SupportRegistrar.BroadcastChannelSupport(msg)
    ...
    #由于之前Peer节点发送的为创建通道的信息,所以消息类型为配置信息
    if !isConfig {
        ...
        #而对于普通的交易信息的处理方法这里就不再看了,主要关注于创建通道的消息的处理
    } else { // isConfig
        logger.Debugf("[channel: %s] Broadcast is processing config update message from %s", chdr.ChannelId, addr)
        #到了这里,对配置更新消息进行处理,主要方法,点进行看一下
        config, configSeq, err := processor.ProcessConfigUpdateMsg(msg)

ProcessConfigUpdateMsg(msg)メソッドorderer/common/msgprocessor/systemchannel.goの行84ファイル:

#这个地方有些不懂,为什么会调用systemchannel.ProcessConfigUpdateMsg()而不是standardchannel.ProcessConfigUpdateMsg()方法?是因为这个结构体的原因?
===========================SystemChannel=======================
type SystemChannel struct {
    *StandardChannel
    templator ChannelConfigTemplator
}
===========================SystemChannel=======================
func (s *SystemChannel) ProcessConfigUpdateMsg(envConfigUpdate *cb.Envelope) (config *cb.Envelope, configSeq uint64, err error) {
    #首先从消息体中获取通道ID
    channelID, err := utils.ChannelID(envConfigUpdate)
    ...
    #判断获取到的通道ID是否为已经存在的用户通道ID,如果是的话转到StandardChannel中的ProcessConfigUpdateMsg()方法进行处理
    if channelID == s.support.ChainID() {
        return s.StandardChannel.ProcessConfigUpdateMsg(envConfigUpdate)
    }
    ...
    #由于之前由Peer节点发送的为创建通道的Tx,所以对于通道ID是不存在的,因此到了这个方法,点进行看一下
    bundle, err := s.templator.NewChannelConfig(envConfigUpdate)

NewChannelConfig()ライン215の方法、チャンネルの設定を完了するための最も重要な方法:

func (dt *DefaultTemplator) NewChannelConfig(envConfigUpdate *cb.Envelope) (channelconfig.Resources, error) {
    #首先反序列化有效载荷
    configUpdatePayload, err := utils.UnmarshalPayload(envConfigUpdate.Payload)
    ...
    #反序列化配置更新信息Envelope
    configUpdateEnv, err := configtx.UnmarshalConfigUpdateEnvelope(configUpdatePayload.Data)s
    ...
    #获取通道头信息
    channelHeader, err := utils.UnmarshalChannelHeader(configUpdatePayload.Header.ChannelHeader)
    ...
    #反序列化配置更新信息
    configUpdate, err := configtx.UnmarshalConfigUpdate(configUpdateEnv.ConfigUpdate)
    ...
    #以下具体的不再说了,就是根据之前定义的各项策略对通道进行配置,具体的策略可以看configtx.yaml文件
    consortiumConfigValue, ok := configUpdate.WriteSet.Values[channelconfig.ConsortiumKey]
    ...
    consortium := &cb.Consortium{}
    err = proto.Unmarshal(consortiumConfigValue.Value, consortium)
    ...
    applicationGroup := cb.NewConfigGroup()
    consortiumsConfig, ok := dt.support.ConsortiumsConfig()
    ...
    consortiumConf, ok := consortiumsConfig.Consortiums()[consortium.Name]
    ...
    applicationGroup.Policies[channelconfig.ChannelCreationPolicyKey] = &cb.ConfigPolicy{
        Policy: consortiumConf.ChannelCreationPolicy(),
    }
    applicationGroup.ModPolicy = channelconfig.ChannelCreationPolicyKey
    #获取当前系统通道配置信息
    systemChannelGroup := dt.support.ConfigtxValidator().ConfigProto().ChannelGroup
    if len(systemChannelGroup.Groups[channelconfig.ConsortiumsGroupKey].Groups[consortium.Name].Groups) > 0 &&
        len(configUpdate.WriteSet.Groups[channelconfig.ApplicationGroupKey].Groups) == 0 {
        return nil, fmt.Errorf("Proposed configuration has no application group members, but consortium contains members")
    }
    if len(systemChannelGroup.Groups[channelconfig.ConsortiumsGroupKey].Groups[consortium.Name].Groups) > 0 {
        for orgName := range configUpdate.WriteSet.Groups[channelconfig.ApplicationGroupKey].Groups {
            consortiumGroup, ok := systemChannelGroup.Groups[channelconfig.ConsortiumsGroupKey].Groups[consortium.Name].Groups[orgName]
            if !ok {
                return nil, fmt.Errorf("Attempted to include a member which is not in the consortium")
            }
            applicationGroup.Groups[orgName] = proto.Clone(consortiumGroup).(*cb.ConfigGroup)
        }
    }
    channelGroup := cb.NewConfigGroup()
    #将系统通道配置信息复制
    for key, value := range systemChannelGroup.Values {
        channelGroup.Values[key] = proto.Clone(value).(*cb.ConfigValue)
        if key == channelconfig.ConsortiumKey {
            // Do not set the consortium name, we do this later
            continue
        }
    }

    for key, policy := range systemChannelGroup.Policies {
        channelGroup.Policies[key] = proto.Clone(policy).(*cb.ConfigPolicy)
    }
    #新的配置信息中Order组配置使用系统通道的配置,同时将定义的application组配置赋值到新的配置信息
    channelGroup.Groups[channelconfig.OrdererGroupKey] = proto.Clone(systemChannelGroup.Groups[channelconfig.OrdererGroupKey]).(*cb.ConfigGroup)
    channelGroup.Groups[channelconfig.ApplicationGroupKey] = applicationGroup
    channelGroup.Values[channelconfig.ConsortiumKey] = &cb.ConfigValue{
        Value:     utils.MarshalOrPanic(channelconfig.ConsortiumValue(consortium.Name).Value()),
        ModPolicy: channelconfig.AdminsPolicyKey,
    }
    if oc, ok := dt.support.OrdererConfig(); ok && oc.Capabilities().PredictableChannelTemplate() {
        channelGroup.ModPolicy = systemChannelGroup.ModPolicy
        zeroVersions(channelGroup)
    }
    #将创建的新的配置打包为Bundle
    bundle, err := channelconfig.NewBundle(channelHeader.ChannelId, &cb.Config{
        ChannelGroup: channelGroup,
    })
    ...
    return bundle, nil
}

その後、我々は戻ってProcessConfigUpdateMsg()メソッドを:

    ...
    #创建一个配置验证器对该方法的传入参数进行验证操作
    newChannelConfigEnv, err := bundle.ConfigtxValidator().ProposeConfigUpdate(envConfigUpdate)
    ...
    #创建一个签名的Envelope,此次为Header类型为HeaderType_CONFIG进行签名
    newChannelEnvConfig, err := utils.CreateSignedEnvelope(cb.HeaderType_CONFIG, channelID, s.support.Signer(), newChannelConfigEnv, msgVersion, epoch)
    #创建一个签名的Transaction,此次为Header类型为HeaderType_ORDERER_TRANSACTION进行签名
    wrappedOrdererTransaction, err := utils.CreateSignedEnvelope(cb.HeaderType_ORDERER_TRANSACTION, s.support.ChainID(), s.support.Signer(), newChannelEnvConfig, msgVersion, epoch)
    ...
    #过滤器进行过滤,主要检查是否创建的Transaction过大,以及签名检查,确保Order节点使用正确的证书进行签名
    err = s.StandardChannel.filters.Apply(wrappedOrdererTransaction)
    ...
    #将Transaction返回 
    return wrappedOrdererTransaction, s.support.Sequence(), nil
}

ここでは、メッセージが処理され、それに戻るProcessMessage()方法:

    config, configSeq, err := processor.ProcessConfigUpdateMsg(msg)
    ...
    #记录消息处理完毕时间
    tracker.EndValidate()
    #开始进行入队操作
    tracker.BeginEnqueue()
    #waitReady()是一个阻塞方法,等待入队完成或出现异常
    if err = processor.WaitReady(); err != nil {
    logger.Warningf("[channel: %s] Rejecting broadcast of message from %s with SERVICE_UNAVAILABLE: rejected by Consenter: %s", chdr.ChannelId, addr, err)
    return &ab.BroadcastResponse{Status: cb.Status_SERVICE_UNAVAILABLE, Info: err.Error()}
    }
    #共识方法,具体看定义的Fabric网络使用了哪种共识
    err = processor.Configure(config, configSeq)
    ...
    #最后返回操作成功的响应
    return &ab.BroadcastResponse{Status: cb.Status_SUCCESS}
}

ここでは、クライアントから送信されたチャネルを作成することはTransaction以上です。二つの部分に分け、合計が1であるPeerのチャネルを作成するノードEnvelopeを作成する処理を、1つのOrderノードが受信するEnvelope:全体的なプロセスを総括、プロセス構成を
Peerノード側:

  1. 作成するためのチャネルを作成します。Transaction
    1. あるかどうかを判断しchannel.tx、通常存在し、ファイルから直接情報を読み取るように構成されているいずれかの場合、ファイルは、
    2. デフォルトのテンプレートに基づいて構成されていない場合
    3. ちょうど構成の更新のために作成するためにEnvelopeチャネルがすでに存在して作成されているかどうか、データが空であるかどうかを含め、関連する検査、設定情報が正しいこと、およびパッケージのために署名されるHeaderTypeまでCONFIG_UPDATEEnvelope
    4. 作成されたEnvelope放送。
  2. (作成はブロック生成このステップ正常コンセンサス順序ノードの後
  3. 作成ブロックは、ローカルファイルを書き込みます

Orderノード側:

  1. 受信Envelope設定情報を確認するために、関連する情報を決定しているかどうか
  2. IDは、構成更新情報として存在する場合、チャネルが既に存在するかどうかを決定する、またはチャネルの情報を作成します
  3. 以下からのEnvelopeポリシー設定を読み込みます
  4. 検証のためのポリシー設定情報について
  5. オンHeaderタイプCONFIGサイン封筒の
  6. 上のHeaderタイプORDERER_TRANSACTIONEnvelope符号生成Transaction
  7. 生成するTransactionフィルタ、主に送信サイズ、Order証明書情報が正しいノードであります
  8. エンキュー操作して、コンセンサスを完了するためにチームを待ちます
  9. 正常な応答をブロードキャスト

チャンネルを作成するためのプロセス全体が比較的長く、限られた容量であるので、いくつかの場所ではなく、あまりにも明確に分析します。しかし、我々はまだ全体を把握することができます。
最後に、参考資料を添付:ポータル
とファブリックソースアドレス:ポータル

おすすめ

転載: www.cnblogs.com/cbkj-xd/p/11113195.html