Skip to content

13-交互:可以执行命令行的框架才是好框架 ​

你好,我是轩脉刃。

上一节课,我们开始把框架向工业级迭代,重新规划了目录,这一节课将对框架做更大的改动,让框架支持命令行工具。

一个拥有命令行工具的框架会非常受欢迎。比如 Beego 框架提供了一个命令行工具 Bee、Vue 框架提供了 Vue-CLI,这些工具无疑给框架的使用者提供了不少便利。在使用框架过程中,命令行工具能将很多编码行为自动化。

而且退一步说,在实际工作中你会发现,即使用的框架没有提供任何命令行工具,在业务运营的过程中,我们也需要开发各种大大小小的命令行运营工具,作为业务运营的必要辅助。所以一个自带命令行工具,且很方便让用户自行开发命令行的框架,是非常有必要的。

这节课我们就研究一下如何将hade框架改造成为支持命令行交互的框架。

第三方命令行工具库 cobra ​

要让一个程序支持命令行,那么它的核心功能就是要能解析参数,比如 ./hade app start --address=:8888 其中的 ./hade 是我们要运行的程序,而后面的 app 和 start 两个字段以及–address=:8888 就是这个程序对应的参数了。

那么如何解析参数呢?

Golang 标准库提供了 flag 包能对参数进行解析。但是 flag 包只是一个命令行解析的类库,不支持组织,所以如果想基于 flag 包实现父子命令行工具,显然就不够了。出于不重复造轮子,站在巨人肩膀上的想法,我们将视线移向开源社区一个最出名的命令行工具库 cobra。

cobra 是由大名鼎鼎的谷歌工程师 Steve Francia(spf13)开发并开源的一个项目。Steve Francia 是 Golang 开源界比较出名的一名程序员,是 Golang、Doctor、MongoDB 的开源贡献者,同时开源的 hugo、viper 等项目应用也非常广泛。而由他开发开源的 cobra 目前在 GitHub 上已经有了 23k 的 star。

cobra 不仅仅能让我们快速构建一个命令行,它更大的优势是能更快地组织起有许多命令行工具,因为从根命令行工具开始,cobra 把所有的命令按照树形结构组织起来了。

要使用 cobra 就要从源码上了解这个库。按照第一节课说的,按照 库函数 > 结构定义 > 结构函数的顺序读,你会发现,cobra 这个库最核心的内容是一个数据结构 Command 。

一个 Command 代表一个执行命令。这个 Command 包含很多可设置的字段,如何使用这个 Command,就取决于我们如何设置这些属性。下面是源码片段,我在注释中列出了这些属性的意义。

go
// Command代表执行命令的结构
type Command struct {
        // 代表当前命令的,如何执行,root 最好和生成的命令工具名称一致
        Use string
        
        // 代表这个工具的别名,在 subCommand 中有用,比如 root cmd1 和 root cmd_1 想要都执行一个 subCommand 就需要这样
        Aliases []string

        // 由于不强制设置,用于输入错误的时候建议字段
        SuggestFor []string

        // 这个就是在 help 的时候一句话描述这个命令的功能
        Short string

        // 详细描述这个命令的功能
        Long string

        // 例子
        Example string

        // 需要验证的参数
        ValidArgs []string

        // 有多少个参数,这里放了一个验证函数,可以是 ExactArgs,MaximumNArgs 等,验证有多少个参数
        Args PositionalArgs

        // 参数别名
        ArgAliases []string

        // 自动生成的命令设置
        BashCompletionFunction string

        // 如果这个命令已经废弃了,那么就这里写上废弃信息
        Deprecated string

        // 如果这个命令要被隐藏,设置这个字段
        Hidden bool

        // Annotations are key/value pairs that can be used by applications to identify or
        // group commands.
        Annotations map[string]string

        // 这个命令的版本
        Version string

        // 是否要打印错误信息
        SilenceErrors bool

        // 是否要打印如何使用
        SilenceUsage bool

        // 是否有 flag,如果这个命令没有 flag,设置为 true,那么所有的命令后面的参数都会是 arguments
        DisableFlagParsing bool

        // 是否打印自动生成字样: ("Auto generated by spf13/cobra...")
        DisableAutoGenTag bool

        // 是否显示[flags]字样
        DisableFlagsInUseLine bool

        // 是否打印建议
        DisableSuggestions bool

        // 两个字符串的差距多少会进入 suggest
        SuggestionsMinimumDistance int

        // 是否使用 Traverse 的方式来解析参数
        TraverseChildren bool

        // 解析错误白名单, 比如像未知参数
        FParseErrWhitelist FParseErrWhitelist
        
        // The *Run 函数运行顺序:
        //   * PersistentPreRun()
        //   * PreRun()
        //   * Run()
        //   * PostRun()
        //   * PersistentPostRun()
        // 会被继承的前置 Run
        PersistentPreRun func(cmd *Command, args []string)

        // 会被继承的前置 Run, 带 error
        PersistentPreRunE func(cmd *Command, args []string) error

        // 当前这个命令的前置 Run
        PreRun func(cmd *Command, args []string)
        // 当前这个命令的前置 Run,带 Error
        PreRunE func(cmd *Command, args []string) error
        // zh: 实际跑的时候运行的函数
        Run func(cmd *Command, args []string)
        // zh: Run 执行错误了之后
        RunE func(cmd *Command, args []string) error
        // 后置运行
        PostRun func(cmd *Command, args []string)
        // 后置运行,带 error
        PostRunE func(cmd *Command, args []string) error
        // 会被继承的后置运行
        PersistentPostRun func(cmd *Command, args []string)
        // 会被继承的后置运行,带 error
        PersistentPostRunE func(cmd *Command, args []string) error

        
}

这里属性非常多,你也不需要都记住是啥。来看一些常用属性,我们用一个设置好的输出结果图就能很好理解。

它对应的代码如下,后面会解释每一行都是怎么实现的:

go
// InitFoo 初始化 Foo 命令
func InitFoo() *cobra.Command {
   FooCommand.AddCommand(Foo1Command)
   return FooCommand
}
// FooCommand 代表 Foo 命令
var FooCommand = &cobra.Command{
   Use:     "foo",
   Short:   "foo 的简要说明",
   Long:    "foo 的长说明",
   Aliases: []string{"fo", "f"},
   Example: "foo 命令的例子",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      log.Println(container)
      return nil
   },
}
// Foo1Command 代表 Foo 命令的子命令 Foo1
var Foo1Command = &cobra.Command{
   Use:     "foo1",
   Short:   "foo1 的简要说明",
   Long:    "foo1 的长说明",
   Aliases: []string{"fo1", "f1"},
   Example: "foo1 命令的例子",
   RunE: func(c *cobra.Command, args []string) error {
      container := c.GetContainer()
      log.Println(container)
      return nil
   },
}

对照代码和输出结果图,能看出 Command 中最常用的一些字段设置。

  • Use 代表这个命令的调用关键字,比如要调用 Foo1 命令,我们就要用 ./hade foo foo1 。Short 代表这个命令的简短说明,它会出现在上级命令的使用文档中。
  • Long 代表这个命令的长说明,它会出现在当前命令的使用文档中。
  • Aliases 是当前命令的别名,等同于 Use 字段;
  • Example 是当前命令的例子,也是显示在当前命令的使用文档中。

而 RunE 代表当前命令的真正执行函数:

go
RunE: func(c *cobra.Command, args []string) error

这个执行函数的参数有两个:一个是 cobra.Command,表示当前的这个命令;而第二个参数是 args,表示当前这个命令的参数,返回值是一个 error,代表命令的执行成功或者失败。

如何使用命令行 cobra ​

现在大致了解 cobra 这个库的使用方法和最核心的 Command 结构,就要想想接下来我们要用它来做些什么事情了。

首先,要把 cobra 库引入到框架中。由于希望后续能修改 Command 的数据,并且在后面的章节中会在 Command 结构中,继续加入一些字段来支持定时的命令行,所以和 Gin 框架的引入一样,我们采用源码引入的方式。

引入后要对 Command 结构进行修改。我们希望把服务容器嵌入到 Command 结构中,让 Command 在调用执行函数 RunE 时,能从参数中获取到服务容器,这样就能从服务容器中使用之前定义的 Make 系列方法获取出具体的服务实例了。

那服务容器嵌到哪里合适呢?因为刚才说,在 cobra 中 Command 结构是一个树形结构,所有的命令都是由一个根 Command 衍生来的。所以我们可以在根 Command 中设置服务容器,让所有的子 Command 都可以根据 Root 方法来找到树的根 Command,最终找到服务容器。

不要忘记了,最终目的是完善 Web 框架,所以之前存放在 main 函数中的启动 Web 服务的一些方法我们也要做修改,让它们能通过一个命令启动。main 函数不再是启动一个 Web 服务了,而是启动一个 cobra 的命令。

也就是说,我们将Web服务的启动逻辑封装为一个Command命令,将这个Command挂载到根Command中,然后根据参数获取到这个 Command 节点,执行这个节点中的 RunE 方法,就能启动Web服务了。

但是在调用Web服务所在节点的RunE方法的时候,存在一个Engine结构的传递问题。

在main函数中,我们使用gin.New创建了一个Engine结构,在业务中对这个Engine结构进行路由设置,这些都应该在业务代码中。而后,我们就进入了框架代码中,调用Web服务所在Command节点的RunE方法,在这个方法里进行初始化http.Server,并且启动Goroutine进行监听:

go
func main() {
   // 创建engine结构
   core := gin.New()
   ...

   hadeHttp.Routes(core)

   server := &http.Server{
      Handler: core,
      Addr:    ":8888",
   }

   // 这个goroutine是启动服务的goroutine
   go func() {
      server.ListenAndServe()
   }()
   ...
}

也就是说,我们只能根据 Command 拿到服务容器,那怎么拿到 Gin 函数创建的 Engine 结构呢?这个问题我提供一个解决思路,是否可以将“提供服务引擎”作为一个接口,通过服务提供者注入进服务容器?这样就能在命令行中就能获取服务容器了。

使用 cobra 增加框架的交互性 ​

现在思路有了,可能发生的问题也想到了,下面进入实操。

首先是源码引入 cobra 库。引入的方式基本上和 Gin 框架引入的方式一样,先看下 cobra 源码的许可证,是 Apache License。这种许可证允许修改、商用、私有化等,只要求保留著作声明。所以我们直接拷贝最新的 cobra 源码,用 cobra v1.2.1 版本,将它放在 framework/cobra 目录下。

然后,对 Command 结构进行修改。要在 Command 结构中加入服务容器,由于刚才是源码引入的,很容易为 Command 增加一个container字段,在framework/cobra/command.go中修改Command结构:

go
type Command struct {
   // 服务容器
   container framework.Container
   ...
}

再为 Command 提供两个方法:设置服务容器、获取服务容器。设置服务容器的方法是为了在创建根 Command 之后,能将服务容器设置到里面去;而获取服务容器的方法,是为了在执行命令的 RunE 函数的时候,能从参数 Command 中获取到服务容器。

将定义的方法放在单独的一个文件framework/cobra/hade_command.go中。

go
// SetContainer 设置服务容器
func (c *Command) SetContainer(container framework.Container) {
   c.container = container
}
// GetContainer 获取容器
func (c *Command) GetContainer() framework.Container {
   return c.Root().container
}

做到这里,前面两步cobra的引入和Command结构的修改就都完成了。

将 Web 启动改成一个命令 ​

第三步,如何改造 Web 启动服务是最繁琐的,先简单梳理一下。

  • 把创建 Web 服务引擎的方法作为一个服务封装在服务容器中,完成准备工作。
  • 开始 main 函数的改造。首先要做的必然是初始化一个服务容器,然后将各个服务绑定到这个服务容器中,有一个就是刚才定义的提供 Web 引擎的服务。
  • 在业务代码中将业务需要的路由绑定到 Web 引擎中去。
  • 完成服务的绑定之后,最后要创建一个根Command,并且创建一个Web启动的Command,这两个Command会形成一个树形结构。

我们先要将创建 Web 服务引擎的方法作为一个服务封装在服务容器中,按照第十节课封装服务的三个步骤:封装接口协议、定义一个服务提供者、初始化服务实例。

在framework/contract/kernel.go中,把创建 Engine 的过程封装为一个服务接口协议:

go
// KernelKey 提供 kenel 服务凭证
const KernelKey = "hade:kernel"

// Kernel 接口提供框架最核心的结构
type Kernel interface {
   // HttpEngine http.Handler结构,作为net/http框架使用, 实际上是gin.Engine
   HttpEngine() http.Handler
}

在定义的 Kernel 接口,提供了 HttpEngine 的方法,返回了net/http 启动的时候需要的 http.Handler接口,并且设置它在服务容器中的字符串凭证为"hade:kernel"。
然后为这个服务定义一个服务提供者。这个服务提供者可以在初始化服务的时候传递 Web 引擎,如果初始化的时候没有传递,则需要在启动的时候默认初始化。

在对应的Kernel的服务提供者代码framework/provider/kernel/provider.go中,我们实现了服务提供者需要实现的五个函数Register、Boot、isDefer、Params、Name。

go
package kernel
import (
   "github.com/gohade/hade/framework"
   "github.com/gohade/hade/framework/contract"
   "github.com/gohade/hade/framework/gin"
)

// HadeKernelProvider 提供web引擎
type HadeKernelProvider struct {
   HttpEngine *gin.Engine
}

// Register 注册服务提供者
func (provider *HadeKernelProvider) Register(c framework.Container) framework.NewInstance {
   return NewHadeKernelService
}

// Boot 启动的时候判断是否由外界注入了Engine,如果注入的化,用注入的,如果没有,重新实例化
func (provider *HadeKernelProvider) Boot(c framework.Container) error {
   if provider.HttpEngine == nil {
      provider.HttpEngine = gin.Default()
   }
   provider.HttpEngine.SetContainer(c)
   return nil
}

// IsDefer 引擎的初始化我们希望开始就进行初始化
func (provider *HadeKernelProvider) IsDefer() bool {
   return false
}

// Params 参数就是一个HttpEngine
func (provider *HadeKernelProvider) Params(c framework.Container) []interface{} {
   return []interface{}{provider.HttpEngine}
}

// Name 提供凭证
func (provider *HadeKernelProvider) Name() string {
   return contract.KernelKey
}

创建服务的第三步就是初始化实例了。这个服务实例比较简单,就是一个包含着 Web 引擎的服务结构。在刚才实现的 HttpEngine()接口中,把服务结构中包含的 Web 引擎返回即可。

go
// 引擎服务
type HadeKernelService struct {
   engine *gin.Engine
}

// 初始化 web 引擎服务实例
func NewHadeKernelService(params ...interface{}) (interface{}, error) {
   httpEngine := params[0].(*gin.Engine)
   return &HadeKernelService{engine: httpEngine}, nil
}

// 返回 web 引擎
func (s *HadeKernelService) HttpEngine() http.Handler {
   return s.engine
}

现在我们完成了Web服务Kernel的设计,转而我们改造一下入口函数。 main 函数是我们的入口,但是现在,入口函数就不再是启动一个 HTTP 服务了,而是执行一个命令。那么这个 main 函数要做些什么呢?

整个框架目前都是围绕服务容器进行设计的了。所以在业务目录的main.go的 main 函数中,我们第一步要做的,必然是初始化一个服务容器。

go
// 初始化服务容器
container := framework.NewHadeContainer()

接着,要将各个服务绑定到这个服务容器中。目前要绑定的服务容器有两个,一个是上一节课我们定义的目录结构服务HadeAppProvider,第二个是这节课定义的提供 Web 引擎的服务。

go
// 绑定 App 服务提供者
container.Bind(&app.HadeAppProvider{})

// 后续初始化需要绑定的服务提供者...
// 将 HTTP 引擎初始化,并且作为服务提供者绑定到服务容器中
if engine, err := http.NewHttpEngine(); err == nil {
   container.Bind(&kernel.HadeKernelProvider{HttpEngine: engine})
}

http.NewHttpEngine 这个创建 Web 引擎的方法必须放在业务层,因为这个 Web 引擎不仅仅是调用了 Gin 创建 Web 引擎的方法,更重要的是调用了业务需要的绑定路由的功能。

将业务需要的路由绑定到 Web 引擎中去。因为这个是业务逻辑,我们放在业务目录的app/kernel.go 文件中:

go
// NewHttpEngine 创建了一个绑定了路由的 Web 引擎
func NewHttpEngine() (*gin.Engine, error) {
   // 设置为 Release,为的是默认在启动中不输出调试信息
   gin.SetMode(gin.ReleaseMode)
   // 默认启动一个 Web 引擎
   r := gin.Default()
   // 业务绑定路由操作
   Routes(r)
   // 返回绑定路由后的 Web 引擎
   return r, nil
}

而对应的业务绑定路由操作,还是放在业务代码的app/http/route.go中:

plain

// Routes 绑定业务层路由
func Routes(r *gin.Engine) {

   r.Static("/dist/", "./dist/")

   demo.Register(r)
}

完成服务提供者的绑定和路由设置之后,最后要创建一个根 Command,并且将业务的 Command 和框架定义的 Command 都加载到根 Command 中,形成一个树形结构。

在 main 中,我们用 console.RunCommand 来创建和运行根 Command。

go
// 运行 root 命令
console.RunCommand(container)

而这里RunCommand 的方法简要来说做了三个事情:

  1. 创建根 Command,并且将容器设置进根 Command 中。
  2. 绑定框架和业务的 Command 命令。
  3. 调用 Execute 启动命令结构。

具体的代码实现放在业务目录的app/console/kernel.go文件中,如下:

go
// RunCommand  初始化根 Command 并运行
func RunCommand(container framework.Container) error {
   // 根 Command
   var rootCmd = &cobra.Command{
      // 定义根命令的关键字
      Use: "hade",
      // 简短介绍
      Short: "hade 命令",
      // 根命令的详细介绍
      Long: "hade 框架提供的命令行工具,使用这个命令行工具能很方便执行框架自带命令,也能很方便编写业务命令",
      // 根命令的执行函数
      RunE: func(cmd *cobra.Command, args []string) error {
         cmd.InitDefaultHelpFlag()
         return cmd.Help()
      },
      // 不需要出现 cobra 默认的 completion 子命令
      CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
   }
   // 为根 Command 设置服务容器
   rootCmd.SetContainer(container)
   // 绑定框架的命令
   command.AddKernelCommands(rootCmd)
   // 绑定业务的命令
   AddAppCommand(rootCmd)
   // 执行 RootCommand
   return rootCmd.Execute()

仔细看这段代码,我们这一节课前面说的内容都在这里得到了体现。

首先,根 Command 的各个属性设置是基于我们对 cobra 的 Command 结构比较熟悉才能进行的;而为根 Command 设置服务容器,我们用之前为服务容器扩展的 SetContainer 方法设置的;最后运行 cobra 的命令是调用 Execute 方法来实现的。

这里额外注意下, 这里有两个函数 AddKernelCommands 和 AddAppCommand,分别是将框架定义的命令和业务定义的命令挂载到根Command下。

框架定义的命令我们使用framework/command/kernel.go 中的 AddKernelCommands 进行挂载。而业务定义的命令我们使用 app/console/kernel.go 中的 AddAppCommand进行挂载。比如下面要定义的启动服务的命令 appCommand 是所有业务通用的一个框架命令,最终会在 framework/command/kernel.go 的 AddKernelCommands 中进行挂载。

启动服务 ​

现在已经将 main 函数改造成根据命令行参数定位 Command 树并执行,且在执行函数的参数 Command 中已经放入了服务容器,在服务容器中我们也已经注入了 Web 引擎。那么下面就来创建一个命令 ./hade app start 启动 Web 服务。

这个命令和业务无关,是框架自带的,所以它的实现应该放在 frame/command 下,而启动 Web 服务的命令是一个二级命令,其一级命令关键字为 app,二级命令关键字为 start。

那么我们先创建一级命令,这个一级命令 app 没有具体的功能,只是打印帮助信息。在framework/command/app.go中定义appCommand:

go
// AppCommand 是命令行参数第一级为 app 的命令,它没有实际功能,只是打印帮助文档
var appCommand = &cobra.Command{
   Use:   "app",
   Short: "业务应用控制命令",
   RunE: func(c *cobra.Command, args []string) error {
      // 打印帮助文档
      c.Help()
      return nil
   },
}

而二级命令关键字为 start,它是真正启动 Web 服务的命令。这个命令的启动执行函数有哪些逻辑呢?

首先,它需要获取 Web 引擎。具体方法根据前面讲的,要从参数 Command 中获取服务容器,从服务容器中获取引擎服务实例,从引擎服务实例中获取 Web 引擎:

go
// 从 Command 中获取服务容器
container := c.GetContainer()
// 从服务容器中获取 kernel 的服务实例
kernelService := container.MustMake(contract.KernelKey).(contract.Kernel)
// 从 kernel 服务实例中获取引擎
core := kernelService.HttpEngine()

获取到了 Web 引擎之后如何启动 Web 服务,就和第一节课描述的一样,通过创建 http.Server,并且调用其 ListenAndServe 方法。这里贴一下具体的appStartCommand命令的实现,供你参考思路,在framework/command/app.go中:

go
// appStartCommand 启动一个Web服务
var appStartCommand = &cobra.Command{
   Use:   "start",
   Short: "启动一个Web服务",
   RunE: func(c *cobra.Command, args []string) error {
      // 从Command中获取服务容器
      container := c.GetContainer()
      // 从服务容器中获取kernel的服务实例
      kernelService := container.MustMake(contract.KernelKey).(contract.Kernel)
      // 从kernel服务实例中获取引擎
      core := kernelService.HttpEngine()

      // 创建一个Server服务
      server := &http.Server{
         Handler: core,
         Addr:    ":8888",
      }

      // 这个goroutine是启动服务的goroutine
      go func() {
         server.ListenAndServe()
      }()

      // 当前的goroutine等待信号量
      quit := make(chan os.Signal)
      // 监控信号:SIGINT, SIGTERM, SIGQUIT
      signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
      // 这里会阻塞当前goroutine等待信号
      <-quit

      // 调用Server.Shutdown graceful结束
      timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
      defer cancel()

      if err := server.Shutdown(timeoutCtx); err != nil {
         log.Fatal("Server Shutdown:", err)
      }

      return nil
   },
}

最后将RootCommand和AppCommand进行关联。在framework/command/app.go中定义initAppCommand()方法,将appStartCommand作为appCommand的子命令:

go
// initAppCommand 初始化app命令和其子命令
func initAppCommand() *cobra.Command {
   appCommand.AddCommand(appStartCommand)
   return appCommand
}

在framework/command/kernel.go中,挂载对应的appCommand的命令:

go
// AddKernelCommands will add all command/* to root command
func AddKernelCommands(root *cobra.Command) {
   // 挂载AppCommand命令
   root.AddCommand(initAppCommand())
}

我们就完成了Web启动的改造工作了。

验证 ​

好了到这里,整个命令行工具就引入成功,并且Web 框架也改造完成了。下面做一下验证。编译后调用./hade ,我们获取到根 Command 命令行工具的帮助信息:

提示可以通过一级关键字 app 获取下一级命令:

而./hade app 提醒我们可以通过二级关键字 start 来启动一个 Web 服务,调用 ./hade app start 。

Web 服务启动成功,通过浏览器可以访问到业务定义的/demo/demo 路径。

今天所有代码都存放在GitHub 的 geekbang/13 分支了,文中未展示的代码直接参考这个分支。本节课结束对应的目录结构如下:

总结 ​

今天我们把之前的 Web 框架改造成了一个命令行工具,引入了 cobra 库,并且将原本的进程启动,也就是启动 Web 服务的方式,改成了调用一个命令来启动 Web 服务。

不知道你有没有感觉,将框架的入口改造成命令行,这个设计不仅仅是简单换了一种 Web 服务的启动方式,而且是扩展了框架的另外一种可能性——设计命令行工具。改造后,这个框架可以用来开发业务需要的各种命令行工具,同时也允许我们后续为框架增加多种多样易用性高的工具。

思考题 ​

其实在之前的版本,我在framework/contract/kernel.go是这么设计kernel服务接口的:

go
package contract

const KernelKey = "hade:kernel"

// Kernel 接口提供框架最核心的结构
type Kernel interface {
   // HttpEngine 提供gin的Engine结构
   HttpEngine() *gin.Engine
}

在provider/kernel/service.go中是这么实现接口的:

go
// 返回web引擎
func (s *HadeKernelService) HttpEngine() *gin.Engine {
   return s.engine
}

和现在实现最大的不同是返回值。之前的返回值是返回了 *gin.Engine。而现在的返回值是返回了http.Handler,其他的实现没有任何变化。你能看出这样的改动相较之前有什么好处么?为什么这么改?

欢迎在留言区分享你的思考。感谢你的阅读,如果觉得有收获,也欢迎你把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见。

最近更新