zoukankan      html  css  js  c++  java
  • [ethereum源码分析](3) ethereum初始化指令

    前言

      在上一章介绍了关于区块链的一些基础知识,这一章会分析指令 geth --datadir dev/data/02 init private-geth/genesis.json 的源码,若你的ethereum的debug环境还没有搭建,那么需要先搭建ethereum的dabug环境

    准备工作

    • 创建文件 genesis.json ,内容如下:
    {
      "config": {
        "chainId": 666,  //可用于网络标识,在eip155里有用到,目前来看是做重放保护的,目前eth的公网的网络id为1
        "homesteadBlock": 0,  //以太坊版本
        "eip155Block": 0,  //(Ethereum Improvement Proposals)简单重访攻击保护,由于是私有链,无硬分叉,此处我们设置为0
        "eip158Block": 0  //同上
      },
      "coinbase" : "0x0000000000000000000000000000000000000000",  //矿工账号
      "difficulty" : "0x40000",  //设置当前区块的难度,值越大挖矿难度越大
      "extraData" : "",  //附加信息,可以填写任意信息
      "gasLimit" : "0xffffffff",  //该值设置对GAS的消耗总量限制,用来限制区块能包含的交易信息总和
      "nonce" : "0x0000000000000042",  //是一个64位的随机数,用于挖矿,注意他和mixhash的设置需要满足以太坊的Yellow paper, 4.3.4. Block Header Validity, (44)章节所描述的条件。
      "mixhash" : "0x0000000000000000000000000000000000000000000000000000000000000000",  //与nonce配合用于挖矿
      "parentHash" : "0x0000000000000000000000000000000000000000000000000000000000000000",  //上一个区块的hash值,创世区块没有上一个区块,因此设置为0
      "timestamp" : "0x00",  //设置创世块的时间戳
      "alloc": { 
      "1fd4027fe390abaa49e5afde7896ff1e5ecacabf": { "balance": "20000000000000000000" }
     }  //用来预置账号以及账号的以太币数量
    }

    指令分析

    指令: geth --datadir dev/data/02 init private-geth/genesis.json 

    介绍:上面的指令主要的工作为:

    • 生成创世区块
    • 生成账号的一些信息

    分析:

    •  dev/data/02 :eth数据保存的地址,主要保存了区块信息和账号信息,日志信息
    •  private-geth/genesis.json :eth初始化的一些配置参数

    代码分析

    接下来就让我们跟以下debug,来一探ethereum的真面目。

    • 进入入口程序

    由于我们使用的是 geth 指令,所以我们代开下面的代码:

    • 找到以下函数

    main.go:

    func main() {
    if err := app.Run(os.Args); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
    }
    }

    上面的函数是geth命令的入口函数,这段代码首先调用了 app.Run(os.Args) 这个函数, os.Args 为系统参数(例: --datadir dev/data/02 init private-geth/genesis.jso )。那么让我们来看看 app 是什么。

    App:

    // App is the main structure of a cli application. It is recommended that
    // an app be created with the cli.NewApp() function
    type App struct {
        // The name of the program. Defaults to path.Base(os.Args[0])
        Name string
        // Full name of command for help, defaults to Name
        HelpName string
        // Description of the program.
        Usage string
        // Text to override the USAGE section of help
        UsageText string
        // Description of the program argument format.
        ArgsUsage string
        // Version of the program
        Version string
        // Description of the program
        Description string
        // List of commands to execute
        Commands []Command
        // List of flags to parse
        Flags []Flag
        // Boolean to enable bash completion commands
        EnableBashCompletion bool
        // Boolean to hide built-in help command
        HideHelp bool
        // Boolean to hide built-in version flag and the VERSION section of help
        HideVersion bool
        // Populate on app startup, only gettable through method Categories()
        categories CommandCategories
        // An action to execute when the bash-completion flag is set
        BashComplete BashCompleteFunc
        // An action to execute before any subcommands are run, but after the context is ready
        // If a non-nil error is returned, no subcommands are run
        Before BeforeFunc
        // An action to execute after any subcommands are run, but after the subcommand has finished
        // It is run even if Action() panics
        After AfterFunc
    
        // The action to execute when no subcommands are specified
        // Expects a `cli.ActionFunc` but will accept the *deprecated* signature of `func(*cli.Context) {}`
        // *Note*: support for the deprecated `Action` signature will be removed in a future version
        Action interface{}
    
        // Execute this function if the proper command cannot be found
        CommandNotFound CommandNotFoundFunc
        // Execute this function if an usage error occurs
        OnUsageError OnUsageErrorFunc
        // Compilation date
        Compiled time.Time
        // List of all authors who contributed
        Authors []Author
        // Copyright of the binary if any
        Copyright string
        // Name of Author (Note: Use App.Authors, this is deprecated)
        Author string
        // Email of Author (Note: Use App.Authors, this is deprecated)
        Email string
        // Writer writer to write output to
        Writer io.Writer
        // ErrWriter writes error output
        ErrWriter io.Writer
        // Other custom info
        Metadata map[string]interface{}
        // Carries a function which returns app specific info.
        ExtraInfo func() map[string]string
        // CustomAppHelpTemplate the text template for app help topic.
        // cli.go uses text/template to render templates. You can
        // render custom help text by setting this variable.
        CustomAppHelpTemplate string
    
        didSetup bool
    }

    那么 app 是在 main.go 的 init 函数中初始化的,下面让我们来看看 init 函数。

    func init() {
        // Initialize the CLI app and start Geth
        app.Action = geth
        app.HideVersion = true // we have a command to print the version
        app.Copyright = "Copyright 2013-2018 The go-ethereum Authors"
        app.Commands = []cli.Command{
            // See chaincmd.go:
            initCommand,
            importCommand,
            exportCommand,
            importPreimagesCommand,
            exportPreimagesCommand,
            copydbCommand,
            removedbCommand,
            dumpCommand,
            // See monitorcmd.go:
            monitorCommand,
            // See accountcmd.go:
            accountCommand,
            walletCommand,
            // See consolecmd.go:
            consoleCommand,
            attachCommand,
            javascriptCommand,
            // See misccmd.go:
            makecacheCommand,
            makedagCommand,
            versionCommand,
            bugCommand,
            licenseCommand,
            // See config.go
            dumpConfigCommand,
        }
        sort.Sort(cli.CommandsByName(app.Commands))
    
        app.Flags = append(app.Flags, nodeFlags...)
        app.Flags = append(app.Flags, rpcFlags...)
        app.Flags = append(app.Flags, consoleFlags...)
        app.Flags = append(app.Flags, debug.Flags...)
        app.Flags = append(app.Flags, whisperFlags...)
    
        app.Before = func(ctx *cli.Context) error {
            runtime.GOMAXPROCS(runtime.NumCPU())
            if err := debug.Setup(ctx); err != nil {
                return err
            }
            // Cap the cache allowance and tune the garbage colelctor
            var mem gosigar.Mem
            if err := mem.Get(); err == nil {
                allowance := int(mem.Total / 1024 / 1024 / 3)
                if cache := ctx.GlobalInt(utils.CacheFlag.Name); cache > allowance {
                    log.Warn("Sanitizing cache to Go's GC limits", "provided", cache, "updated", allowance)
                    ctx.GlobalSet(utils.CacheFlag.Name, strconv.Itoa(allowance))
                }
            }
            // Ensure Go's GC ignores the database cache for trigger percentage
            cache := ctx.GlobalInt(utils.CacheFlag.Name)
            gogc := math.Max(20, math.Min(100, 100/(float64(cache)/1024)))
    
            log.Debug("Sanitizing Go's GC trigger", "percent", int(gogc))
            godebug.SetGCPercent(int(gogc))
    
            // Start system runtime metrics collection
            go metrics.CollectProcessMetrics(3 * time.Second)
    
            utils.SetupNetwork(ctx)
            return nil
        }
    
        app.After = func(ctx *cli.Context) error {
            debug.Exit()
            console.Stdin.Close() // Resets terminal mode.
            return nil
        }
    }

    从上面的代码,可以看到,它将所有的指令放到了 app 中缓存了起来。通过这个缓存的指令集,我们可以找到需要执行的代码。下面就让我们来看一下 app.Run(os.Args) 里面的代码。

    func (a *App) Run(arguments []string) (err error) {
        a.Setup()//在这个里面主要做了三件事:1.初始化App中的commands(主要初始化Command.HelpName)2.增加helpCommand指令到App.Commands,一共21个指令 3.初始化App.categories,主要是给指令分类
    
        // handle the completion flag separately from the flagset since
        // completion could be attempted after a flag, but before its value was put
        // on the command line. this causes the flagset to interpret the completion
        // flag name as the value of the flag before it which is undesirable
        // note that we can only do this because the shell autocomplete function
        // always appends the completion flag at the end of the command
        shellComplete, arguments := checkShellCompleteFlag(a, arguments)
    
        // parse flags
        set, err := flagSet(a.Name, a.Flags)//这里初始化了一些FlagSet,FlagSet里面存储了一些eth的默认配置,比如networkId=1
        if err != nil {
            return err
        }
    
        set.SetOutput(ioutil.Discard)
        err = set.Parse(arguments[1:])//将命令行参数设置到set中,可以通过看里面的代码知道,命令行输入的参数有两种:1.环境配置参数以--为开头 2.命令参数,需要执行代码
        nerr := normalizeFlags(a.Flags, set)
        context := NewContext(a, set, nil)
        if nerr != nil {
            fmt.Fprintln(a.Writer, nerr)
            ShowAppHelp(context)
            return nerr
        }
        context.shellComplete = shellComplete
    
        if checkCompletions(context) {
            return nil
        }
    
        if err != nil {
            if a.OnUsageError != nil {
                err := a.OnUsageError(context, err, false)
                HandleExitCoder(err)
                return err
            }
            fmt.Fprintf(a.Writer, "%s %s
    
    ", "Incorrect Usage.", err.Error())
            ShowAppHelp(context)
            return err
        }
    
        if !a.HideHelp && checkHelp(context) {
            ShowAppHelp(context)
            return nil
        }
    
        if !a.HideVersion && checkVersion(context) {
            ShowVersion(context)
            return nil
        }
    
        if a.After != nil {
            defer func() {
                if afterErr := a.After(context); afterErr != nil {
                    if err != nil {
                        err = NewMultiError(err, afterErr)
                    } else {
                        err = afterErr
                    }
                }
            }()
        }
    
        if a.Before != nil {
            beforeErr := a.Before(context)
            if beforeErr != nil {
                ShowAppHelp(context)
                HandleExitCoder(beforeErr)
                err = beforeErr
                return err
            }
        }
    
        args := context.Args()//获取需要执行的命令,当前为init
        if args.Present() {
            name := args.First()
            c := a.Command(name)//查找是否有init命令
            if c != nil {
                return c.Run(context)//执行init命令
            }
        }
    
        if a.Action == nil {
            a.Action = helpCommand.Action
        }
    
        // Run default Action
        err = HandleAction(a.Action, context)
    
        HandleExitCoder(err)
        return err
    }

     从上面的注释我们可以知道,命令行输入的参数 init 在 c.Run(context) 这行代码被执行。那么下面就让我们来 c.Run(context) 的代码。

    func (c Command) Run(ctx *Context) (err error) {
        if len(c.Subcommands) > 0 {
            return c.startApp(ctx)
        }
    
        if !c.HideHelp && (HelpFlag != BoolFlag{}) {
            // append help to flags
            c.Flags = append(
                c.Flags,
                HelpFlag,
            )
        }
    
        set, err := flagSet(c.Name, c.Flags)
        if err != nil {
            return err
        }
        set.SetOutput(ioutil.Discard)
    
        if c.SkipFlagParsing {
            err = set.Parse(append([]string{"--"}, ctx.Args().Tail()...))
        } else if !c.SkipArgReorder {
            firstFlagIndex := -1
            terminatorIndex := -1
            for index, arg := range ctx.Args() {
                if arg == "--" {
                    terminatorIndex = index
                    break
                } else if arg == "-" {
                    // Do nothing. A dash alone is not really a flag.
                    continue
                } else if strings.HasPrefix(arg, "-") && firstFlagIndex == -1 {
                    firstFlagIndex = index
                }
            }
    
            if firstFlagIndex > -1 {
                args := ctx.Args()
                regularArgs := make([]string, len(args[1:firstFlagIndex]))
                copy(regularArgs, args[1:firstFlagIndex])
    
                var flagArgs []string
                if terminatorIndex > -1 {
                    flagArgs = args[firstFlagIndex:terminatorIndex]
                    regularArgs = append(regularArgs, args[terminatorIndex:]...)
                } else {
                    flagArgs = args[firstFlagIndex:]
                }
    
                err = set.Parse(append(flagArgs, regularArgs...))
            } else {
                err = set.Parse(ctx.Args().Tail())//初始化init命令的参数,该处为private-geth/genesis.json
            }
        } else {
            err = set.Parse(ctx.Args().Tail())
        }
    
        nerr := normalizeFlags(c.Flags, set)
        if nerr != nil {
            fmt.Fprintln(ctx.App.Writer, nerr)
            fmt.Fprintln(ctx.App.Writer)
            ShowCommandHelp(ctx, c.Name)
            return nerr
        }
    
        context := NewContext(ctx.App, set, ctx)
        context.Command = c
        if checkCommandCompletions(context, c.Name) {
            return nil
        }
    
        if err != nil {
            if c.OnUsageError != nil {
                err := c.OnUsageError(context, err, false)
                HandleExitCoder(err)
                return err
            }
            fmt.Fprintln(context.App.Writer, "Incorrect Usage:", err.Error())
            fmt.Fprintln(context.App.Writer)
            ShowCommandHelp(context, c.Name)
            return err
        }
    
        if checkCommandHelp(context, c.Name) {
            return nil
        }
    
        if c.After != nil {
            defer func() {
                afterErr := c.After(context)
                if afterErr != nil {
                    HandleExitCoder(err)
                    if err != nil {
                        err = NewMultiError(err, afterErr)
                    } else {
                        err = afterErr
                    }
                }
            }()
        }
    
        if c.Before != nil {
            err = c.Before(context)
            if err != nil {
                ShowCommandHelp(context, c.Name)
                HandleExitCoder(err)
                return err
            }
        }
    
        if c.Action == nil {
            c.Action = helpSubcommand.Action
        }
    
        err = HandleAction(c.Action, context)//这一行是用来执行init指令的,指令需要执行的代码链接到了c.Action
    
        if err != nil {
            HandleExitCoder(err)
        }
        return err
    }

    那么最终 init 指令需要执行的代码是 MigrateFlags ,可以在 main.go  initCommand 中看到需要执行的代码。

        initCommand = cli.Command{
            Action:    utils.MigrateFlags(initGenesis),
            Name:      "init",
            Usage:     "Bootstrap and initialize a new genesis block",
            ArgsUsage: "<genesisPath>",
            Flags: []cli.Flag{
                utils.DataDirFlag,
                utils.LightModeFlag,
            },
            Category: "BLOCKCHAIN COMMANDS",
            Description: `
    The init command initializes a new genesis block and definition for the network.
    This is a destructive action and changes the network in which you will be
    participating.
    
    It expects the genesis file as argument.`,
        }

    从上面可以看到,执行 MigrateFlags 会先执行 initGenesis ,下面就来看看 initGenesis 的代码。

    func initGenesis(ctx *cli.Context) error {
        // Make sure we have a valid genesis JSON
        genesisPath := ctx.Args().First()//获取命令行参数,此处为private-geth/genesis.json
        if len(genesisPath) == 0 {
            utils.Fatalf("Must supply path to genesis JSON file")
        }
        file, err := os.Open(genesisPath)//打开private-geth/genesis.json文件
        if err != nil {
            utils.Fatalf("Failed to read genesis file: %v", err)
        }
        defer file.Close()
    
        genesis := new(core.Genesis)//构造一个Genesis
        if err := json.NewDecoder(file).Decode(genesis); err != nil {//读取配置文件genesis.json,构造genesis结构体
            utils.Fatalf("invalid genesis file: %v", err)
        }
        // Open an initialise both full and light databases
        stack := makeFullNode(ctx)//这里面初始化了一些配置信息,网络的一些设置等
        for _, name := range []string{"chaindata", "lightchaindata"} {
            chaindb, err := stack.OpenDatabase(name, 0, 0)
            if err != nil {
                utils.Fatalf("Failed to open database: %v", err)
            }
            _, hash, err := core.SetupGenesisBlock(chaindb, genesis)//这里将创世区块写入leveldb
            if err != nil {
                utils.Fatalf("Failed to write genesis block: %v", err)
            }
            log.Info("Successfully wrote genesis state", "database", name, "hash", hash)
        }
        return nil
    }

     下面就让我们看看这里是如何构建创世区块的,构建创世区块的过程在 core.SetupGenesisBlock(chaindb, genesis) 里面。

    func SetupGenesisBlock(db ethdb.Database, genesis *Genesis) (*params.ChainConfig, common.Hash, error) {
        if genesis != nil && genesis.Config == nil {
            return params.AllEthashProtocolChanges, common.Hash{}, errGenesisNoConfig
        }
    
        // Just commit the new block if there is no stored genesis block.
        stored := rawdb.ReadCanonicalHash(db, 0)
        if (stored == common.Hash{}) {
            if genesis == nil {
                log.Info("Writing default main-net genesis block")
                genesis = DefaultGenesisBlock()
            } else {
                log.Info("Writing custom genesis block")
            }
            block, err := genesis.Commit(db)//最终我们的代码会走到这里,这里将genesis写入数据库
            return genesis.Config, block.Hash(), err
        }
    
        // Check whether the genesis block is already written.
        if genesis != nil {
            hash := genesis.ToBlock(nil).Hash()
            if hash != stored {
                return genesis.Config, hash, &GenesisMismatchError{stored, hash}
            }
        }
    
        // Get the existing chain configuration.
        newcfg := genesis.configOrDefault(stored)
        storedcfg := rawdb.ReadChainConfig(db, stored)
        if storedcfg == nil {
            log.Warn("Found genesis block without chain config")
            rawdb.WriteChainConfig(db, stored, newcfg)
            return newcfg, stored, nil
        }
        // Special case: don't change the existing config of a non-mainnet chain if no new
        // config is supplied. These chains would get AllProtocolChanges (and a compat error)
        // if we just continued here.
        if genesis == nil && stored != params.MainnetGenesisHash {
            return storedcfg, stored, nil
        }
    
        // Check config compatibility and write the config. Compatibility errors
        // are returned to the caller unless we're already at block zero.
        height := rawdb.ReadHeaderNumber(db, rawdb.ReadHeadHeaderHash(db))
        if height == nil {
            return newcfg, stored, fmt.Errorf("missing block number for head header hash")
        }
        compatErr := storedcfg.CheckCompatible(newcfg, *height)
        if compatErr != nil && *height != 0 && compatErr.RewindTo != 0 {
            return newcfg, stored, compatErr
        }
        rawdb.WriteChainConfig(db, stored, newcfg)
        return newcfg, stored, nil
    }

     接下来就让我们跟一下 genesis.Commit(db) 的代码。

    // Commit writes the block and state of a genesis specification to the database.
    // The block is committed as the canonical head block.
    func (g *Genesis) Commit(db ethdb.Database) (*types.Block, error) {
        block := g.ToBlock(db)//这里面做了两件事:1.写入状态树 2.构造创世区块
        if block.Number().Sign() != 0 {
            return nil, fmt.Errorf("can't commit genesis block with number > 0")
        }
        rawdb.WriteTd(db, block.Hash(), block.NumberU64(), g.Difficulty)
        rawdb.WriteBlock(db, block)
        rawdb.WriteReceipts(db, block.Hash(), block.NumberU64(), nil)
        rawdb.WriteCanonicalHash(db, block.Hash(), block.NumberU64())
        rawdb.WriteHeadBlockHash(db, block.Hash())
        rawdb.WriteHeadHeaderHash(db, block.Hash())
    
        config := g.Config
        if config == nil {
            config = params.AllEthashProtocolChanges
        }
        rawdb.WriteChainConfig(db, block.Hash(), config)//写入链的配置信息
        return block, nil
    }

    那么让我们来看看 g.ToBlock(db) 的代码。

    // ToBlock creates the genesis block and writes state of a genesis specification
    // to the given database (or discards it if nil).
    func (g *Genesis) ToBlock(db ethdb.Database) *types.Block {
        if db == nil {
            db = ethdb.NewMemDatabase()
        }
        statedb, _ := state.New(common.Hash{}, state.NewDatabase(db))
        for addr, account := range g.Alloc {
            statedb.AddBalance(addr, account.Balance)
            statedb.SetCode(addr, account.Code)
            statedb.SetNonce(addr, account.Nonce)
            for key, value := range account.Storage {
                statedb.SetState(addr, key, value)
            }
        }
        root := statedb.IntermediateRoot(false)//这里面构造了一颗状态树,状态树是由一些列的钱包组成
        head := &types.Header{//这里构造创世区块头部信息
            Number:     new(big.Int).SetUint64(g.Number),
            Nonce:      types.EncodeNonce(g.Nonce),
            Time:       new(big.Int).SetUint64(g.Timestamp),
            ParentHash: g.ParentHash,
            Extra:      g.ExtraData,
            GasLimit:   g.GasLimit,
            GasUsed:    g.GasUsed,
            Difficulty: g.Difficulty,
            MixDigest:  g.Mixhash,
            Coinbase:   g.Coinbase,
            Root:       root,
        }
        if g.GasLimit == 0 {
            head.GasLimit = params.GenesisGasLimit
        }
        if g.Difficulty == nil {
            head.Difficulty = params.GenesisDifficulty
        }
        statedb.Commit(false)
        statedb.Database().TrieDB().Commit(root, true)
    
        return types.NewBlock(head, nil, nil, nil)//这里构造了一个创世区块
    }

    下面让我们看下 types.Header 的结构体。

    type Header struct {
        ParentHash  common.Hash    `json:"parentHash"       gencodec:"required"`//父区块头的Hash值
        UncleHash   common.Hash    `json:"sha3Uncles"       gencodec:"required"`//当前区块ommers列表的Hash值
        Coinbase    common.Address `json:"miner"            gencodec:"required"`//接收挖此区块费用的矿工钱包地址
        Root        common.Hash    `json:"stateRoot"        gencodec:"required"`//状态树根节点的Hash值
        TxHash      common.Hash    `json:"transactionsRoot" gencodec:"required"`//包含此区块所列的所有交易的树的根节点Hash值
        ReceiptHash common.Hash    `json:"receiptsRoot"     gencodec:"required"`//包含此区块所列的所有交易收据的树的根节点Hash值
        Bloom       Bloom          `json:"logsBloom"        gencodec:"required"`//由日志信息组成的一个Bloom过滤器 (数据结构)
        Difficulty  *big.Int       `json:"difficulty"       gencodec:"required"`//挖此区块的难度
        Number      *big.Int       `json:"number"           gencodec:"required"`//区块编号,也就是区块高度
        GasLimit    uint64         `json:"gasLimit"         gencodec:"required"`//每个区块当前的gasLimit
        GasUsed     uint64         `json:"gasUsed"          gencodec:"required"`//此区块中交易所用的总gas量
        Time        *big.Int       `json:"timestamp"        gencodec:"required"`//此区块创建时间戳
        Extra       []byte         `json:"extraData"        gencodec:"required"`//与此区块相关的附加数据
        MixDigest   common.Hash    `json:"mixHash"          gencodec:"required"`//一个Hash值,当与nonce组合时,证明此区块已经执行了足够的计算
        Nonce       BlockNonce     `json:"nonce"            gencodec:"required"`//一个Hash值,当与mixHash组合时,证明此区块已经执行了足够的计算
    }

    最后我们看下 types.Block 的结构体。

    type Block struct {
        header       *Header//区块头信息
        uncles       []*Header
        transactions Transactions//区块交易信息
    
        // caches
        hash atomic.Value
        size atomic.Value
    
        // Td is used by package core to store the total difficulty
        // of the chain up to and including the block.
        td *big.Int
    
        // These fields are used by package eth to track
        // inter-peer block relay.
        ReceivedAt   time.Time
        ReceivedFrom interface{}
    }

    到这里这一章就介绍结束了。有什么理解错误的地方还望指正。

  • 相关阅读:
    转:调试Release发布版程序的Crash错误
    [原创]桓泽学音频编解码(9):MP3 多相滤波器组算法分析
    [转] 一些你不知道但是超美的地方,一定要去
    [原创]桓泽学音频编解码(11):AC3 exponent(指数部分)模块解码算法分析
    [原创]桓泽学音频编解码(14):AC3 时频转换模块算法分析
    [原创]桓泽学音频编解码(15):AC3 最终章 多声道处理模块算法分析
    Android Supported Media Formats
    VoiceChatter 编译记录
    [原创]桓泽学音频编解码(4):MP3 和 AAC 中反量化原理,优化设计与参考代码中实现
    OPUS 视频PPT介绍
  • 原文地址:https://www.cnblogs.com/cafebabe-yun/p/9306753.html
Copyright © 2011-2022 走看看