Docker 源码分析 (二):Docker Client 创建与命令执行

阅读数:13130 2014 年 10 月 10 日

【编者按】在《深入浅出 Docker》系列文章的基础上,InfoQ 推出了《Docker 源码分析》系列文章。《深入浅出 Docker》系列文章更多的是从使用角度出发,帮助读者了解 Docker 的来龙去脉,而《Docker 源码分析》系列文章通过分析解读 Docker 源码,来让读者了解 Docker 的内部实现,以更好的使用 Docker。总之,我们的目标是促进 Docker 在国内的发展以及传播。另外,欢迎加入 InfoQ Docker 技术交流群,QQ 群号:272489193。

1. 前言

如今,Docker 作为业界领先的轻量级虚拟化容器管理引擎,给全球开发者提供了一种新颖、便捷的软件集成测试与部署之道。在团队开发软件时,Docker 可以提供可复用的运行环境、灵活的资源配置、便捷的集成测试方法以及一键式的部署方式。可以说,Docker 的优势在简化持续集成、运维部署方面体现得淋漓尽致,它完全让开发者从持续集成、运维部署方面中解放出来,把精力真正地倾注在开发上。

然而,把 Docker 的功能发挥到极致,并非一件易事。在深刻理解 Docker 架构的情况下,熟练掌握 Docker Client 的使用也非常有必要。前者可以参阅《Docker 源码分析》系列之 Docker 架构篇,而本文主要针对后者,从源码的角度分析 Docker Client,力求帮助开发者更深刻的理解 Docker Client 的具体实现,最终更好的掌握 Docker Client 的使用方法。即本文为《Docker 源码分析》系列的第二篇——Docker Client 篇。

2. Docker Client 源码分析章节安排

本文从源码的角度,主要分析 Docker Client 的两个方面:创建与命令执行。整个分析过程可以分为两个部分:

第一部分分析 Docker Client 的创建。这部分的分析可分为以下三个步骤:

  • 分析如何通过 docker 命令,解析出命令行 flag 参数,以及 docker 命令中的请求参数;
  • 分析如何处理具体的 flag 参数信息,并收集 Docker Client 所需的配置信息;
  • 分析如何创建一个 Docker Client。

第二部分在已有 Docker Client 的基础上,分析如何执行 docker 命令。这部分的分析又可分为以下两个步骤:

  • 分析如何解析 docker 命令中的请求参数,获取相应请求的类型;
  • 分析 Docker Client 如何执行具体的请求命令,最终将请求发送至 Docker Server。

3. Docker Client 的创建

Docker Client 的创建,实质上是 Docker 用户通过可执行文件 docker,与 Docker Server 建立联系的客户端。以下分三个小节分别阐述 Docker Client 的创建流程。

以下为整个 docker 源代码运行的流程图:

上图通过流程图的方式,使得读者更为清晰的了解 Docker Client 创建及执行请求的过程。其中涉及了诸多源代码中的特有名词,在下文中会一一解释与分析。

3.1. Docker 命令的 flag 参数解析

众所周知,在 Docker 的具体实现中,Docker Server 与 Docker Client 均由可执行文件 docker 来完成创建并启动。那么,了解 docker 可执行文件通过何种方式区分两者,就显得尤为重要。

对于两者,首先举例说明其中的区别。Docker Server 的启动,命令为 docker -d 或 docker --daemon=true;而 Docker Client 的启动则体现为 docker --daemon=false ps、docker pull NAME 等。

可以把以上 Docker 请求中的参数分为两类:第一类为命令行参数,即 docker 程序运行时所需提供的参数,如: -D、--daemon=true、--daemon=false 等;第二类为 docker 发送给 Docker Server 的实际请求参数,如:ps、pull NAME 等。

对于第一类,我们习惯将其称为 flag 参数,在 go 语言的标准库中,同时还提供了一个flag 包,方便进行命令行参数的解析。

交待以上背景之后,随即进入实现 Docker Client 创建的源码,位于./docker/docker/docker.go,该 go 文件包含了整个 Docker 的 main 函数,也就是整个 Docker(不论 Docker Daemon 还是 Docker Client)的运行入口。部分 main 函数代码如下:

func main() {
    if reexec.Init() {
      return
    }
    flag.Parse()
    // FIXME: validate daemon flags here
    ……
}

在以上代码中,首先判断 reexec.Init() 方法的返回值,若为真,则直接退出运行,否则的话继续执行。查看位于./docker/reexec/reexec.go 中reexec.Init()的定义,可以发现由于在 docker 运行之前没有任何的 Initializer 注册,故该代码段执行的返回值为假。

紧接着,main 函数通过调用 flag.Parse() 解析命令行中的 flag 参数。查看源码可以发现 Docker 在./docker/docker/flag.go中定义了多个 flag 参数,并通过 init 函数进行初始化。代码如下:

var (
  flVersion     = flag.Bool([]string{"v", "-version"}, false, "Print version information and quit")
  flDaemon      = flag.Bool([]string{"d", "-daemon"}, false, "Enable daemon mode")
  flDebug       = flag.Bool([]string{"D", "-debug"}, false, "Enable debug mode")
  flSocketGroup = flag.String([]string{"G", "-group"}, "docker", "Group to assign the unix socket specified by -H when running in daemon mode use '' (the empty string) to disable setting of a group")
  flEnableCors  = flag.Bool([]string{"#api-enable-cors", "-api-enable-cors"}, false, "Enable CORS headers in the remote API")
  flTls         = flag.Bool([]string{"-tls"}, false, "Use TLS; implied by tls-verify flags")
  flTlsVerify   = flag.Bool([]string{"-tlsverify"}, false, "Use TLS and verify the remote (daemon: verify client, client: verify daemon)")

  // these are initialized in init() below since their default values depend on dockerCertPath which isn't fully initialized until init() runs
  flCa    *string
  flCert  *string
  flKey   *string
  flHosts []string
)

func init() {
  flCa = flag.String([]string{"-tlscacert"}, filepath.Join(dockerCertPath, defaultCaFile), "Trust only remotes providing a certificate signed by the CA given here")
  flCert = flag.String([]string{"-tlscert"}, filepath.Join(dockerCertPath, defaultCertFile), "Path to TLS certificate file")
  flKey = flag.String([]string{"-tlskey"}, filepath.Join(dockerCertPath, defaultKeyFile), "Path to TLS key file")
  opts.HostListVar(&flHosts, []string{"H", "-host"}, "The socket(s) to bind to in daemon mode\nspecified using one or more tcp://host:port, unix:///path/to/socket, fd://* or fd://socketfd.")
}

这里涉及到了 Golang 的一个特性,即 init 函数的执行。在 Golang 中 init 函数的特性如下:

  • init 函数用于程序执行前包的初始化工作,比如初始化变量等;
  • 每个包可以有多个 init 函数;
  • 包的每一个源文件也可以有多个 init 函数;
  • 同一个包内的 init 函数的执行顺序没有明确的定义;
  • 不同包的 init 函数按照包导入的依赖关系决定初始化的顺序;
  • init 函数不能被调用,而是在 main 函数调用前自动被调用。

因此,在 main 函数执行之前,Docker 已经定义了诸多 flag 参数,并对很多 flag 参数进行初始化。定义的命令行 flag 参数有:flVersion、flDaemon、flDebug、flSocketGroup、flEnableCors、flTls、flTlsVerify、flCa、flCert、flKey 等。

以下具体分析 flDaemon:

  • 定义:flDaemon = flag.Bool([]string{"d", "-daemon"}, false, "Enable daemon mode")
  • flDaemon 的类型为 Bool 类型
  • flDaemon 名称为”d”或者”-daemon”,该名称会出现在 docker 命令中
  • flDaemon 的默认值为 false
  • flDaemon 的帮助信息为”Enable daemon mode”
  • 访问 flDaemon 的值时,使用指针 * flDaemon 解引用访问

在解析命令行 flag 参数时,以下的语言为合法的:

  • -d, --daemon
  • -d=true, --daemon=true
  • -d=”true”, --daemon=”true”
  • -d=’true’, --daemon=’true’

当解析到第一个非定义的 flag 参数时,命令行 flag 参数解析工作结束。举例说明,当执行 docker 命令 docker --daemon=false --version=false ps 时,flag 参数解析主要完成两个工作:

  • 完成命令行 flag 参数的解析,名为 -daemon 和 -version 的 flag 参数 flDaemon 和 flVersion 分别获得相应的值,均为 false;
  • 遇到第一个非 flag 参数的参数 ps 时,将 ps 及其之后所有的参数存入 flag.Args(),以便之后执行 Docker Client 具体的请求时使用。

如需深入学习 flag 的解析,可以参见源码命令行参数 flag 的解析

3.2. 处理 flag 信息并收集 Docker Client 的配置信息

有了以上 flag 参数解析的相关知识,分析 Docker 的 main 函数就变得简单易懂很多。通过总结,首先列出源代码中处理的 flag 信息以及收集 Docker Client 的配置信息,然后再一一对此分析:

  • 处理的 flag 参数有:flVersion、flDebug、flDaemon、flTlsVerify 以及 flTls;
  • 为 Docker Client 收集的配置信息有:protoAddrParts(通过 flHosts 参数获得,作用为提供 Docker Client 与 Server 的通信协议以及通信地址)、tlsConfig(通过一系列 flag 参数获得,如 *flTls、*flTlsVerify,作用为提供安全传输层协议的保障)。

随即分析处理这些 flag 参数信息,以及配置信息。

在 flag.Parse() 之后的代码如下:

  if *flVersion {
    showVersion()
    return
  }

不难理解的是,当经过解析 flag 参数后,若 flVersion 参数为真时,调用 showVersion() 显示版本信息,并从 main 函数退出;否则的话,继续往下执行。

  if *flDebug {
    os.Setenv("DEBUG", "1")
  }

若 flDebug 参数为真的话,通过 os 包的中 Setenv 函数创建一个名为 DEBUG 的系统环境变量,并将其值设为”1”。继续往下执行。

  if len(flHosts) == 0 {
    defaultHost := os.Getenv("DOCKER_HOST")
    if defaultHost == "" || *flDaemon {
      // If we do not have a host, default to unix socket
      defaultHost = fmt.Sprintf("unix://%s", api.DEFAULTUNIXSOCKET)
    }
    if _, err := api.ValidateHost(defaultHost); err != nil {
      log.Fatal(err)
    }
    flHosts = append(flHosts, defaultHost)
  }

以上的源码主要分析内部变量 flHosts。flHosts 的作用是为 Docker Client 提供所要连接的 host 对象,也为 Docker Server 提供所要监听的对象。

分析过程中,首先判断 flHosts 变量是否长度为 0,若是的话,通过 os 包获取名为 DOCKER_HOST 环境变量的值,将其赋值于 defaultHost。若 defaultHost 为空或者 flDaemon 为真的话,说明目前还没有一个定义的 host 对象,则将其默认设置为 unix socket,值为 api.DEFAULTUNIXSOCKET,该常量位于./docker/api/common.go,值为"/var/run/docker.sock",故 defaultHost 为”unix:///var/run/docker.sock”。验证该 defaultHost 的合法性之后,将 defaultHost 的值追加至 flHost 的末尾。继续往下执行。

  if *flDaemon {
    mainDaemon()
    return
  }

若 flDaemon 参数为真的话,则执行 mainDaemon 函数,实现 Docker Daemon 的启动,若 mainDaemon 函数执行完毕,则退出 main 函数,一般 mainDaemon 函数不会主动终结。由于本章节介绍 Docker Client 的启动,故假设 flDaemon 参数为假,不执行以上代码块。继续往下执行。

  if len(flHosts) > 1 {
    log.Fatal("Please specify only one -H")
  }
  protoAddrParts := strings.SplitN(flHosts[0], "://", 2)

以上,若 flHosts 的长度大于 1 的话,则抛出错误日志。接着将 flHosts 这个 string 数组中的第一个元素,进行分割,通过”://”来分割,分割出的两个部分放入变量 protoAddrParts 数组中。protoAddrParts 的作用为解析出与 Docker Server 建立通信的协议与地址,为 Docker Client 创建过程中不可或缺的配置信息之一。

  var (
    cli       *client.DockerCli
    tlsConfig tls.Config
  )
tlsConfig.InsecureSkipVerify = true

由于之前已经假设过 flDaemon 为假,则可以认定 main 函数的运行是为了 Docker Client 的创建与执行。在这里创建两个变量:一个为类型是 client.DockerCli 指针的对象 cli,另一个为类型是 tls.Config 的对象 tlsConfig。并将 tlsConfig 的 InsecureSkipVerify 属性设置为真。TlsConfig 对象的创建是为了保障 cli 在传输数据的时候,遵循安全传输层协议 (TLS)。安全传输层协议 (TLS) 用于两个通信应用程序之间保密性与数据完整性。tlsConfig 是 Docker Client 创建过程中可选的配置信息。

  // If we should verify the server, we need to load a trusted ca
  if *flTlsVerify {
    *flTls = true
    certPool := x509.NewCertPool()
    file, err := ioutil.ReadFile(*flCa)
    if err != nil {
      log.Fatalf("Couldn't read ca cert %s: %s", *flCa, err)
    }
    certPool.AppendCertsFromPEM(file)
    tlsConfig.RootCAs = certPool
    tlsConfig.InsecureSkipVerify = false
  }

若 flTlsVerify 这个 flag 参数为真的话,则说明需要验证 server 端的安全性,tlsConfig 对象需要加载一个受信的 ca 文件。该 ca 文件的路径为 *flCA 参数的值,最终完成 tlsConfig 对象中 RootCAs 属性的赋值,并将 InsecureSkipVerify 属性置为假。

// If tls is enabled, try to load and send client certificates
  if *flTls || *flTlsVerify {
    _, errCert := os.Stat(*flCert)
    _, errKey := os.Stat(*flKey)
    if errCert == nil && errKey == nil {
      *flTls = true
      cert, err := tls.LoadX509KeyPair(*flCert, *flKey)
      if err != nil {
        log.Fatalf("Couldn't load X509 key pair: %s. Key encrypted?", err)
      }
      tlsConfig.Certificates = []tls.Certificate{cert}
    }
  }

如果 flTls 和 flTlsVerify 两个 flag 参数中有一个为真,则说明需要加载以及发送 client 端的证书。最终将证书内容交给 tlsConfig 的 Certificates 属性。

至此,flag 参数已经全部处理,并已经收集完毕 Docker Client 所需的配置信息。之后的内容为 Docker Client 如何实现创建并执行。

3.3. Docker Client 的创建

Docker Client 的创建其实就是在已有配置参数信息的情况,通过 Client 包中的 NewDockerCli 方法创建一个实例 cli,源码实现如下:

  if *flTls || *flTlsVerify {
    cli = client.NewDockerCli(os.Stdin, os.Stdout, os.Stderr, protoAddrParts[0], protoAddrParts[1], &tlsConfig)
  } else {
    cli = client.NewDockerCli(os.Stdin, os.Stdout, os.Stderr, protoAddrParts[0], protoAddrParts[1], nil)
  }

如果 flag 参数 flTls 为真或者 flTlsVerify 为真的话,则说明需要使用 TLS 协议来保障传输的安全性,故创建 Docker Client 的时候,将 TlsConfig 参数传入;否则的话,同样创建 Docker Client,只不过 TlsConfig 为 nil。

关于 Client 包中的 NewDockerCli 函数的实现,可以具体参见./docker/api/client/cli.go

func NewDockerCli(in io.ReadCloser, out, err io.Writer, proto, addr string, tlsConfig *tls.Config) *DockerCli {
  var (
    isTerminal = false
    terminalFd uintptr
    scheme     = "http"
  )

  if tlsConfig != nil {
    scheme = "https"
  }

  if in != nil {
    if file, ok := out.(*os.File); ok {
      terminalFd = file.Fd()
      isTerminal = term.IsTerminal(terminalFd)
    }
  }

  if err == nil {
    err = out
  }
  return &DockerCli{
    proto:      proto,
    addr:       addr,
    in:         in,
    out:        out,
    err:        err,
    isTerminal: isTerminal,
    terminalFd: terminalFd,
    tlsConfig:  tlsConfig,
    scheme:     scheme,
  }
}

总体而言,创建 DockerCli 对象较为简单,较为重要的 DockerCli 的属性有 proto:传输协议;addr:host 的目标地址,tlsConfig:安全传输层协议的配置。若 tlsConfig 为不为空,则说明需要使用安全传输层协议,DockerCli 对象的 scheme 设置为“https”,另外还有关于输入,输出以及错误显示的配置,最终返回该对象。

通过调用 NewDockerCli 函数,程序最终完成了创建 Docker Client,并返回 main 函数继续执行。

4. Docker 命令执行

main 函数执行到目前为止,有以下内容需要为 Docker 命令的执行服务:创建完毕的 Docker Client,docker 命令中的请求参数(经 flag 解析后存放于 flag.Arg())。也就是说,需要使用 Docker Client 来分析 docker 命令中的请求参数,并最终发送相应请求给 Docker Server。

4.1. Docker Client 解析请求命令

Docker Client 解析请求命令的工作,在 Docker 命令执行部分第一个完成,直接进入 main 函数之后的源码部分

if err := cli.Cmd(flag.Args()...); err != nil {
    if sterr, ok := err.(*utils.StatusError); ok {
      if sterr.Status != "" {
        log.Println(sterr.Status)
      }
      os.Exit(sterr.StatusCode)
    }
    log.Fatal(err)
  }

查阅以上源码,可以发现,正如之前所说,首先解析存放于 flag.Args() 中的具体请求参数,执行的函数为 cli 对象的 Cmd 函数。进入./docker/api/client/cli.go 的 Cmd 函数

// Cmd executes the specified command
func (cli *DockerCli) Cmd(args ...string) error {
  if len(args) > 0 {
    method, exists := cli.getMethod(args[0])
    if !exists {
      fmt.Println("Error: Command not found:", args[0])
      return cli.CmdHelp(args[1:]...)
    }
    return method(args[1:]...)
  }
  return cli.CmdHelp(args...)
}

由代码注释可知,Cmd 函数执行具体的指令。源码实现中,首先判断请求参数列表的长度是否大于 0,若不是的话,说明没有请求信息,返回 docker 命令的 Help 信息;若长度大于 0 的话,说明有请求信息,则首先通过请求参数列表中的第一个元素 args[0] 来获取具体的 method 的方法。如果上述 method 方法不存在,则返回 docker 命令的 Help 信息,若存在的话,调用具体的 method 方法,参数为 args[1] 及其之后所有的请求参数。

还是以一个具体的 docker 命令为例,docker –daemon=false –version=false pull Name。通过以上的分析,可以总结出以下操作流程:

(1) 解析 flag 参数之后,将 docker 请求参数”pull”和“Name”存放于 flag.Args();

(2) 创建好的 Docker Client 为 cli,cli 执行 cli.Cmd(flag.Args()…);

在 Cmd 函数中,通过 args[0] 也就是”pull”, 执行 cli.getMethod(args[0]),获取 method 的名称;

(3) 在 getMothod 方法中,通过处理最终返回 method 的值为”CmdPull”;

(4) 最终执行 method(args[1:]…) 也就是 CmdPull(args[1:]…)。

4.2. Docker Client 执行请求命令

上一节通过一系列的命令解析,最终找到了具体的命令的执行方法,本节内容主要介绍 Docker Client 如何通过该执行方法处理并发送请求。

由于不同的请求内容不同,执行流程大致相同,本节依旧以一个例子来阐述其中的流程,例子为:docker pull NAME。

Docker Client 在执行以上请求命令的时候,会执行 CmdPull 函数,传入参数为 args[1:]...。源码具体为./docker/api/client/command.go 中的 CmdPull 函数

以下逐一分析 CmdPull 的源码实现。

(1) 通过 cli 包中的 Subcmd 方法定义一个类型为 Flagset 的对象 cmd。

cmd := cli.Subcmd("pull", "NAME[:TAG]", "Pull an image or a repository from the registry")

(2) 给 cmd 对象定义一个类型为 String 的 flag,名为”#t”或”#-tag”,初始值为空。

tag := cmd.String([]string{"#t", "#-tag"}, "", "Download tagged image in a repository")

(3) 将 args 参数进行解析,解析过程中,先提取出是否有符合 tag 这个 flag 的参数,若有,将其给赋值给 tag 参数,其余的参数存入 cmd.NArg(); 若无的话,所有的参数存入 cmd.NArg() 中。

if err := cmd.Parse(args); err != nil {
return nil }

(4) 判断经过 flag 解析后的参数列表,若参数列表中参数的个数不为 1,则说明需要 pull 多个 image,pull 命令不支持,则调用错误处理方法 cmd.Usage(),并返回 nil。

if cmd.NArg() != 1 {
cmd.Usage()
return nil
    }

(5) 创建一个 map 类型的变量 v,该变量用于存放 pull 镜像时所需的 url 参数;随后将参数列表的第一个值赋给 remote 变量,并将 remote 作为键为 fromImage 的值添加至 v;最后若有 tag 信息的话,将 tag 信息作为键为”tag”的值添加至 v。

var (
  v      = url.Values{}
  remote = cmd.Arg(0)
)
v.Set("fromImage", remote)
if *tag == "" {
  v.Set("tag", *tag)
}

(6) 通过 remote 变量解析出镜像所在的 host 地址,以及镜像的名称。

  remote, _ = parsers.ParseRepositoryTag(remote)
    // Resolve the Repository name from fqn to hostname + name
    hostname, _, err := registry.ResolveRepositoryName(remote)
    if err != nil {
      return err
    }

(7) 通过 cli 对象获取与 Docker Server 通信所需要的认证配置信息。

cli.LoadConfigFile()
    // Resolve the Auth config relevant for this server
    authConfig := cli.configFile.ResolveAuthConfig(hostname)

(8) 定义一个名为 pull 的函数,传入的参数类型为 registry.AuthConfig,返回类型为 error。函数执行块中最主要的内容为:cli.stream(……) 部分。该部分具体发起了一个给 Docker Server 的 POST 请求,请求的 url 为"/images/create?"+v.Encode(),请求的认证信息为:map[string][]string{"X-Registry-Auth": registryAuthHeader,}。

   pull := func(authConfig registry.AuthConfig) error {
      buf, err := json.Marshal(authConfig)
      if err != nil {
        return err
      }
      registryAuthHeader := []string{
        base64.URLEncoding.EncodeToString(buf),
      }
      return cli.stream("POST", "/images/create?"+v.Encode(), nil, cli.out, map[string][]string{
      "  X-Registry-Auth": registryAuthHeader,
      })
    }

(9) 由于上一个步骤只是定义 pull 函数,这一步骤具体调用执行 pull 函数,若成功则最终返回,若返回错误,则做相应的错误处理。若返回错误为 401,则需要先登录,转至登录环节,完成之后,继续执行 pull 函数,若完成则最终返回。

 if err := pull(authConfig); err != nil {
  if strings.Contains(err.Error(), "Status 401") {
    fmt.Fprintln(cli.out, "\nPlease login prior to pull:")
    if err := cli.CmdLogin(hostname); err != nil {
      return err
    }
        authConfig := cli.configFile.ResolveAuthConfig(hostname)
        return pull(authConfig)
  }
  return err
}

以上便是 pull 请求的全部执行过程,其他请求的执行在流程上也是大同小异。总之,请求执行过程中,大多都是将命令行中关于请求的参数进行初步处理,并添加相应的辅助信息,最终通过指定的协议给 Docker Server 发送 Docker Client 和 Docker Server 约定好的 API 请求。

5. 总结

本文从源码的角度分析了从 docker 可执行文件开始,到创建 Docker Client,最终发送给 Docker Server 请求的完整过程。

笔者认为,学习与理解 Docker Client 相关的源码实现,不仅可以让用户熟练掌握 Docker 命令的使用,还可以使得用户在特殊情况下有能力修改 Docker Client 的源码,使其满足自身系统的某些特殊需求,以达到定制 Docker Client 的目的,最大发挥 Docker 开放思想的价值。

6. 作者简介

孙宏亮,DaoCloud初创团队成员,软件工程师,浙江大学 VLIS 实验室应届研究生。读研期间活跃在 PaaS 和 Docker 开源社区,对 Cloud Foundry 有深入研究和丰富实践,擅长底层平台代码分析,对分布式平台的架构有一定经验,撰写了大量有深度的技术博客。2014 年末以合伙人身份加入 DaoCloud 团队,致力于传播以 Docker 为主的容器的技术,推动互联网应用的容器化步伐。邮箱:allen.sun@daocloud.io

7. 参考文献

  1. http://www.infoq.com/cn/articles/docker-command-line-quest
  2. http://docs.studygolang.com/pkg/
  3. http://blog.studygolang.com/2013/02/%E6%A0%87%E5%87%86%E5%BA%93-%E5%91%BD%E4%BB%A4%E8%A1%8C%E5%8F%82%E6%95%B0%E8%A7%A3%E6%9E%90flag/
  4. https://docs.docker.com/reference/commandline/cli/

感谢郭蕾对本文的策划和审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论