Golang程序配置方案小结

No Comments

在Twitter上看到一篇关于Golang程序配置方案总结的系列文章(一个mini series,共6篇),原文链接:在这里。我觉得不错,这里粗略整理(非全文翻译)一下,供大家参考。

一、背景

无论使用任何编程语言开发应用,都离不开配置数据。配置数据提供的形式有多样,不外乎命令行选项(options)、参数(parameters),环境 变量(env vars)以及配置文件等。Golang也不例外。Golang内置flag标准库,可以用来支持部分命令行选项和参数的解析;Golang通过os包提 供的方法可以获取当前环境变量;但Golang没有规定标准配置文件格式(虽说内置支持xml、json),多通过第三方 包来解决配置文件读取的问题。Golang配置相关的第三方包邮很多,作者在本文中给出的配置方案中就包含了主流的第三方配置数据操作包。

文章作者认为一个良好的应用配置层次应该是这样的:
1、程序内内置配置项的初始默认值
2、配置文件中的配置项值可以覆盖(override)程序内配置项的默认值。
3、命令行选项和参数值具有最高优先级,可以override前两层的配置项值。

下面就按作者的思路循序渐进探讨golang程序配置方案。

二、解析命令行选项和参数

这一节关注golang程序如何访问命令行选项和参数。

golang对访问到命令行参数提供了内建的支持:

//cmdlineargs.go
package main

import (
    //      "fmt"
    "os"
    "path/filepath"
)

func main() {
    println("I am ", os.Args[0])

    baseName := filepath.Base(os.Args[0])
    println("The base name is ", baseName)

    // The length of array a can be discovered using the built-in function len
    println("Argument # is ", len(os.Args))

    // the first command line arguments
    if len(os.Args) > 1 {
        println("The first command line argument: ", os.Args[1])
    }
}

执行结果如下:
$go build cmdlineargs.go
$cmdlineargs test one
I am  cmdlineargs
The base name is  cmdlineargs
Argument # is  3
The first command line argument:  test

对于命令行结构复杂一些的程序,我们最起码要用到golang标准库内置的flag包:

//cmdlineflag.go
package main

import (
    "flag"
    "fmt"
    "os"
    "strconv"
)

var (
    // main operation modes
    write = flag.Bool("w", false, "write result back instead of stdout\n\t\tDefault: No write back")

    // layout control
    tabWidth = flag.Int("tabwidth", 8, "tab width\n\t\tDefault: Standard")

    // debugging
    cpuprofile = flag.String("cpuprofile", "", "write cpu profile to this file\n\t\tDefault: no default")
)

func usage() {
    // Fprintf allows us to print to a specifed file handle or stream
    fmt.Fprintf(os.Stderr, "\nUsage: %s [flags] file [path ...]\n\n",
        "CommandLineFlag") // os.Args[0]
    flag.PrintDefaults()
    os.Exit(0)
}

func main() {
    fmt.Printf("Before parsing the flags\n")
    fmt.Printf("T: %d\nW: %s\nC: '%s'\n",
        *tabWidth, strconv.FormatBool(*write), *cpuprofile)

    flag.Usage = usage
    flag.Parse()

    // There is also a mandatory non-flag arguments
    if len(flag.Args()) < 1 {
        usage()
    }
   
    fmt.Printf("Testing the flag package\n")
    fmt.Printf("T: %d\nW: %s\nC: '%s'\n",
        *tabWidth, strconv.FormatBool(*write), *cpuprofile)

    for index, element := range flag.Args() {
        fmt.Printf("I: %d C: '%s'\n", index, element)
    }
}

这个例子中:
- 说明了三种类型标志的用法:Int、String和Bool。
- 说明了每个标志的定义都由类型、命令行选项文本、默认值以及含义解释组成。
- 最后说明了如何处理标志选项(flag option)以及非option参数。

不带参数运行:

$cmdlineflag
Before parsing the flags
T: 8
W: false
C: ''

Usage: CommandLineFlag [flags] file [path ...]

  -cpuprofile="": write cpu profile to this file
        Default: no default
  -tabwidth=8: tab width
        Default: Standard
  -w=false: write result back instead of stdout
        Default: No write back

带命令行标志以及参数运行(一个没有flag,一个有两个flag):

$cmdlineflag aa bb
Before parsing the flags
T: 8
W: false
C: ''
Testing the flag package
T: 8
W: false
C: ''
I: 0 C: 'aa'
I: 1 C: 'bb'

$cmdlineflag -tabwidth=2 -w aa
Before parsing the flags
T: 8
W: false
C: ''
Testing the flag package
T: 2
W: true
C: ''
I: 0 C: 'aa'

从例子可以看出,简单情形下,你无需编写自己的命令行parser或使用第三方包,使用go内建的flag包即可以很好的完成工作。但是golang的 flag包与命令行Parser的事实标准:Posix getopt(C/C++/Perl/Shell脚本都可用)相比,还有较大差距,主要体现在:

1、无法支持区分long option和short option,比如:-h和–help。
2、不支持short options合并,比如:ls -l -h <=> ls -hl
3、命令行标志的位置不能任意放置,比如无法放在non-flag parameter的后面。

不过毕竟flag是golang内置标准库包,你无须付出任何cost,就能使用它的功能。另外支持bool型的flag也是其一大亮点。

三、TOML,Go配置文件的事实标准(这个可能不能得到认同)

命令行虽然是一种可选的配置方案,但更多的时候,我们使用配置文件来存储静态的配置数据。就像Java配xml,ruby配yaml,windows配 ini,Go也有自己的搭配组合,那就是TOML(Tom's Obvious, Minimal Language)。

初看toml语法有些类似windows ini,但细致研究你会发现它远比ini强大的多,下面是一个toml配置文件例子:

# This is a TOML document. Boom.

title = "TOML Example"

[owner]
name = "Lance Uppercut"
dob = 1979-05-27T07:32:00-08:00 # First class dates? Why not?

[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true

[servers]

  # You can indent as you please. Tabs or spaces. TOML don't care.
  [servers.alpha]
  ip = "10.0.0.1"
  dc = "eqdc10"

  [servers.beta]
  ip = "10.0.0.2"
  dc = "eqdc10"

[clients]
data = [ ["gamma", "delta"], [1, 2] ]

# Line breaks are OK when inside arrays
hosts = [
  "alpha",
  "omega"
]

看起来很强大,也很复杂,但解析起来却很简单。以下面这个toml 文件为例:

Age = 25
Cats = [ "Cauchy", "Plato" ]
Pi = 3.14
Perfection = [ 6, 28, 496, 8128 ]
DOB = 1987-07-05T05:45:00Z

和所有其他配置文件parser类似,这个配置文件中的数据可以被直接解析成一个golang struct:

type Config struct {
  Age int
  Cats []string
  Pi float64
  Perfection []int
  DOB time.Time // requires `import time`
}

其解析的步骤也很简单:

var conf Config
if _, err := toml.Decode(tomlData, &conf); err != nil {
  // handle error
}

是不是简单的不能简单了!

不过toml也有其不足之处。想想如果你需要使用命令行选项的参数值来覆盖这些配置文件中的选项,你应该怎么做?事实上,我们常常会碰到类似下面这种三层配置结构的情况:

1、程序内内置配置项的初始默认值
2、配置文件中的配置项值可以覆盖(override)程序内配置项的默认值。
3、命令行选项和参数值具有最高优先级,可以override前两层的配置项值。

在go中,toml映射的结果体字段没有初始值。而且go内建flag包也没有将命令行参数值解析为一个go结构体,而是零散的变量。这些可以通过第三方工具来解决,但如果你不想用第三方工具,你也可以像下面这样自己解决,虽然难看一些。

func ConfigGet() *Config {
    var err error
    var cf *Config = NewConfig()

    // set default values defined in the program
    cf.ConfigFromFlag()
    //log.Printf("P: %d, B: '%s', F: '%s'\n", cf.MaxProcs, cf.Webapp.Path)

    // Load config file, from flag or env (if specified)
    _, err = cf.ConfigFromFile(*configFile, os.Getenv("APPCONFIG"))
    if err != nil {
        log.Fatal(err)
    }
    //log.Printf("P: %d, B: '%s', F: '%s'\n", cf.MaxProcs, cf.Webapp.Path)

    // Override values from command line flags
    cf.ConfigToFlag()
    flag.Usage = usage
    flag.Parse()
    cf.ConfigFromFlag()
    //log.Printf("P: %d, B: '%s', F: '%s'\n", cf.MaxProcs, cf.Webapp.Path)

    cf.ConfigApply()

    return cf
}

就像上面代码中那样,你需要:
1、用命令行标志默认值设置配置(cf)默认值。
2、接下来加载配置文件
3、用配置值(cf)覆盖命令行标志变量值
4、解析命令行参数
5、用命令行标志变量值覆盖配置(cf)值。

少一步你都无法实现三层配置能力。

四、超越TOML

本节将关注如何克服TOML的各种局限。

为了达成这个目标,很多人会说:使用viper,不过在介绍viper这一重量级选手 之前,我要为大家介绍另外一位不那么知名的选手:multiconfig

有些人总是认为大的就是好的,但我相信适合的还是更好的。因为:

1、viper太重量级,使用viper时你需要pull另外20个viper依赖的第三方包
2、事实上,viper单独使用还不足以满足需求,要想得到viper全部功能,你还需要另外一个包配合,而后者又依赖13个外部包
3、与viper相比,multiconfig使用起来更简单。

好了,我们再来回顾一下我们现在面临的问题:

1、在程序里定义默认配置,这样我们就无需再在toml中定义它们了。
2、用toml配置文件中的数据override默认配置
3、用命令行或环境变量的值override从toml中读取的配置。

下面是一个说明如何使用multiconfig的例子:

func main() {
    m := multiconfig.NewWithPath("config.toml") // supports TOML and JSON

    // Get an empty struct for your configuration
    serverConf := new(Server)

    // Populated the serverConf struct
    m.MustLoad(serverConf) // Check for error

    fmt.Println("After Loading: ")
    fmt.Printf("%+v\n", serverConf)

    if serverConf.Enabled {
        fmt.Println("Enabled field is set to true")
    } else {
        fmt.Println("Enabled field is set to false")
    }
}

这个例子中的toml文件如下:

Name              = "koding"
Enabled           = false
Port              = 6066
Users             = ["ankara", "istanbul"]

[Postgres]
Enabled           = true
Port              = 5432
Hosts             = ["192.168.2.1", "192.168.2.2", "192.168.2.3"]
AvailabilityRatio = 8.23

toml映射后的go结构如下:

type (
    // Server holds supported types by the multiconfig package
    Server struct {
        Name     string
        Port     int `default:"6060"`
        Enabled  bool
        Users    []string
        Postgres Postgres
    }

    // Postgres is here for embedded struct feature
    Postgres struct {
        Enabled           bool
        Port              int
        Hosts             []string
        DBName            string
        AvailabilityRatio float64
    }
)

multiconfig的使用是不是很简单,后续与viper对比后,你会同意我的观点的。

multiconfig支持默认值,也支持显式的字段赋值需求。
支持toml、json、结构体标签(struct tags)以及环境变量。
你可以自定义配置源(例如一个远程服务器),如果你想这么做的话。
可高度扩展(通过loader接口),你可以创建你自己的loader。

下面是例子的运行结果,首先是usage help:

$cmdlinemulticonfig -help
Usage of cmdlinemulticonfig:
  -enabled=false: Change value of Enabled.
  -name=koding: Change value of Name.
  -port=6066: Change value of Port.
  -postgres-availabilityratio=8.23: Change value of Postgres-AvailabilityRatio.
  -postgres-dbname=: Change value of Postgres-DBName.
  -postgres-enabled=true: Change value of Postgres-Enabled.
  -postgres-hosts=[192.168.2.1 192.168.2.2 192.168.2.3]: Change value of Postgres-Hosts.
  -postgres-port=5432: Change value of Postgres-Port.
  -users=[ankara istanbul]: Change value of Users.

Generated environment variables:
   SERVER_NAME
   SERVER_PORT
   SERVER_ENABLED
   SERVER_USERS
   SERVER_POSTGRES_ENABLED
   SERVER_POSTGRES_PORT
   SERVER_POSTGRES_HOSTS
   SERVER_POSTGRES_DBNAME
   SERVER_POSTGRES_AVAILABILITYRATIO

$cmdlinemulticonfig
After Loading:
&{Name:koding Port:6066 Enabled:false Users:[ankara istanbul] Postgres:{Enabled:true Port:5432 Hosts:[192.168.2.1 192.168.2.2 192.168.2.3] DBName: AvailabilityRatio:8.23}}
Enabled field is set to false

检查一下输出结果吧,是不是每项都符合我们之前的预期呢!

五、Viper

我们的重量级选手viper(https://github.com/spf13/viper)该出场了!

毫无疑问,viper非常强大。但如果你想用命令行参数覆盖预定义的配置项值,viper自己还不足以。要想让viper爆发,你需要另外一个包配合,它就是cobra(https://github.com/spf13/cobra)。

不同于注重简化配置处理的multiconfig,viper让你拥有全面控制力。不幸的是,在得到这种控制力之前,你需要做一些体力活。

我们再来回顾一下使用multiconfig处理配置的代码:

func main() {
    m := multiconfig.NewWithPath("config.toml") // supports TOML and JSON

    // Get an empty struct for your configuration
    serverConf := new(Server)

    // Populated the serverConf struct
    m.MustLoad(serverConf) // Check for error

    fmt.Println("After Loading: ")
    fmt.Printf("%+v\n", serverConf)

    if serverConf.Enabled {
        fmt.Println("Enabled field is set to true")
    } else {
        fmt.Println("Enabled field is set to false")
    }
}

这就是使用multiconfig时你要做的所有事情。现在我们来看看使用viper和cobra如何来完成同样的事情:

func init() {
    mainCmd.AddCommand(versionCmd)

    viper.SetEnvPrefix("DISPATCH")
    viper.AutomaticEnv()

    /*
      When AutomaticEnv called, Viper will check for an environment variable any
      time a viper.Get request is made. It will apply the following rules. It
      will check for a environment variable with a name matching the key
      uppercased and prefixed with the EnvPrefix if set.
    */

    flags := mainCmd.Flags()

    flags.Bool("debug", false, "Turn on debugging.")
    flags.String("addr", "localhost:5002", "Address of the service")
    flags.String("smtp-addr", "localhost:25", "Address of the SMTP server")
    flags.String("smtp-user", "", "User to authenticate with the SMTP server")
    flags.String("smtp-password", "", "Password to authenticate with the SMTP server")
    flags.String("email-from", "noreply@example.com", "The from email address.")

    viper.BindPFlag("debug", flags.Lookup("debug"))
    viper.BindPFlag("addr", flags.Lookup("addr"))
    viper.BindPFlag("smtp_addr", flags.Lookup("smtp-addr"))
    viper.BindPFlag("smtp_user", flags.Lookup("smtp-user"))
    viper.BindPFlag("smtp_password", flags.Lookup("smtp-password"))
    viper.BindPFlag("email_from", flags.Lookup("email-from"))

  // Viper supports reading from yaml, toml and/or json files. Viper can
  // search multiple paths. Paths will be searched in the order they are
  // provided. Searches stopped once Config File found.

    viper.SetConfigName("CommandLineCV") // name of config file (without extension)
    viper.AddConfigPath("/tmp")          // path to look for the config file in
    viper.AddConfigPath(".")             // more path to look for the config files

    err := viper.ReadInConfig()
    if err != nil {
        println("No config file found. Using built-in defaults.")
    }
}

可以看出,你需要使用BindPFlag来让viper和cobra结合一起工作。但这还不算太糟。

cobra的真正威力在于提供了subcommand能力。同时cobra还提供了与posix 全面兼容的命令行标志解析能力,包括长短标志、内嵌命令、为command定义你自己的help或usage等。

下面是定义子命令的例子代码:

// The main command describes the service and defaults to printing the
// help message.
var mainCmd = &cobra.Command{
    Use:   "dispatch",
    Short: "Event dispatch service.",
    Long:  `HTTP service that consumes events and dispatches them to subscribers.`,
    Run: func(cmd *cobra.Command, args []string) {
        serve()
    },
}

// The version command prints this service.
var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print the version.",
    Long:  "The version of the dispatch service.",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println(version)
    },
}

有了上面subcommand的定义,我们就可以得到如下的help信息了:

Usage:
  dispatch [flags]
  dispatch [command]

Available Commands:
  version     Print the version.
  help        Help about any command

Flags:
      –addr="localhost:5002": Address of the service
      –debug=false: Turn on debugging.
      –email-from="noreply@example.com": The from email address.
  -h, –help=false: help for dispatch
      –smtp-addr="localhost:25": Address of the SMTP server
      –smtp-password="": Password to authenticate with the SMTP server
      –smtp-user="": User to authenticate with the SMTP server

Use "dispatch help [command]" for more information about a command.

六、小结

以上例子的完整源码在作者的github repository里可以找到。

关于golang配置文件,我个人用到了toml这一层次,因为不需要太复杂的配置,不需要环境变量或命令行override默认值或配置文件数据。不过 从作者的例子中可以看到multiconfig、viper的确强大,后续在实现复杂的golang应用时会考虑真正应用。

也谈并发与并行

No Comments

在一般人的眼中,“并行”就是并行,即你干你的,我干我的,两个“并行”的执行过程可能是两条毫无瓜葛的平行线,也可能是有交叉,但瞬即分开的两条线。不 过在程序员的世界里,有关“并行”的概念却有两个单词:Concurrency和Parallelism,对应的比较主流的中文翻译为并发 (Concurrency)和并行(Parallelism)。

之前一直使用C、Python进行Coding,对Concrrency和Parallelism的异同并不十分关心,也未求甚解。但switch to golang后,尤其是学习2012年Rob Pike的一个talk slide:“Concurrency is not Parallelism(译作:并发不是并行)"后,感觉之前对于“并行”的理解还未到火候。

golang的Author们对文档还是非常看重的。按照目前golang的age来说,其文档的充分性相对于其他语言已经是相对较好的了。golang 的 author们还时不时放出一些blog、talk和slide,以帮助大家编写出more idiomatic的golang程序。Rob Pike的“并发不是并行”就是golang官方站点上的一个talk slide(中文版在这里 )。

Rob Pike是Golang大神,这里先列出他在talk中对于并发与并行的学术阐释和理解:

【Concurrency并发】
Programming as the composition of independently executing processes. (Processes in the general sense, not Linux processes. Famously hard to define.)
将相互独立的执行过程综合到一起的编程技术。(这里是指通常意义上的执行过程,而不是Linux进程。很难定义。)

Concurrency is about dealing with lots of things at once.
并发是指同时处理很多事情。

Concurrency is about structure.
并发关乎结构。

Concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.
并发提供了一种方式让我们能够设计一种方案将问题(非必须的)并行的解决。

Concurrency is a way to structure a program by breaking it into pieces that can be executed independently.
并发是一种将一个程序分解成小片段独立执行的程序设计方法。

【Parallelism并行】
Programming as the simultaneous execution of (possibly related) computations.
同时执行(通常是相关的)计算任务的编程技术。

Parallelism is about doing lots of things at once.
并行是指同时能完成很多事情。

Parallelism is about execution.
并行关乎执行。

【小结】
They are Not the same, but related.
它们不相同,但相关。

怎么样?看上上面的论述是不是一头雾水啊。Rob Pike也觉得这些概念以及描述过于抽象,于是给了一个具体的“地鼠推车运书”的例子,不过当你看完这个例子后,可能会变得更加糊涂,至少我有这种感觉-地鼠凌乱综合症^_^。这是因为这个例子隐含的结合了Go语言goroutine调度的三个概念:P(虚拟processor上下文)、M(内核线程)和G(Goroutine对象)。如果仅仅从理解并行和并发的差异来说,我们可以抛开go语言,用生活中的例子感觉更适合些。

下面我们就来一个例子来说说明一下并发与并行,从一个程序的设计演进角度来阐述。

问题:说的是一个Gopher早起后的生活,Gopher早起后,有三个任务(或者称为三件事情)要完成:洗漱、早餐、着装。我们来设计一个程序,帮助Gopher高效正确的完成这三件事。

如果你是程序员,要完成这个场景,你可能会这么设计你的程序:

program1:

最简单的思路:这个gopher一件一件事情去完成:

main:
    call 洗漱
    call 早餐
    call 着装

这里我们把Gopher看做是一颗cpu,它按程序逻辑,顺序执行洗漱、早餐和着装三件事。即如下图那样:

现在我们玩个克隆游戏,我们clone出一个与这个Gopher一模一样的Gopher,且两个gopher之间存在着某种超宇宙联系,一个Gopher行为的结果都能反应到另外一个gopher上。我们让这两个Gopher一起来做这三件事情,看看是否能够提速。

遗憾的是,两个Gopher都要从洗漱做起。一个Gopher占用了卫生间开始洗漱,另外一个Gopher只能等着,而没法去做早餐或是着装。当那个 Gopher完成洗漱,后面的这个Gopher由于超联系也同步完成了洗漱,进入下一个环节:早餐。过程还是一样的,只能一个Gopher在餐厅准备早 餐。也就是说这两个Gopher没有一起做事,而是一个做,一个赋闲。因此我们看到两个Gopher并没有加快事情完成的步伐,从过程上来看,即便有更多 的Gopher,也依旧无法提速。我们需要对程序做些改造。

注:首尾相连的红线的总长度 = 完成时间。

program2:

main:
    pthread_create(洗漱)
    pthread_create(早餐)
    pthread_create(着装)

    waitAll

Gopher来执行一遍新程序。由于建立了三个逻辑执行体,因此Gopher在三个执行体间切换,从Gopher的角度去看,Gopher的执行路径如下图:


Program2-1

Gopher不再像上面Program1那样顺序执行了,而是在三个活动间切换,但总时长依旧没有下降。

为了验证该程序在多Gopher下是否有效率提升,我们再玩一次克隆游戏,这次clone出另外两个Gopher,三个Gopher一起来执行该程序,一个可能的执行路径见下图:


Program2-2

每个Gopher绑定一个逻辑执行体,整体完成的总时长下降为原来的三分之一。这次三个Gopher都没有赋闲,真正做到你干你的,我干我的,一起做。

program3:

虽然在program2中,多个Gopher一起工作提升了效率,但那是极限么,还能提高么?我们试想一下三个活动:洗漱、早餐和着装的难易不同,耗时不 同。一个可能的结果是Gopher1完成了洗漱,但Gopher2才准备了一半早餐,Gopher3刚选完上衣。这时Gopher1便开始空闲,无法帮助 Gopher2和Gopher3继续提高效率。我们再试试重新组合一下要完成的任务,让每个Gopher都能执行不同的活动环节。

main:
        c chan job
        for i = 0; i < 3; i++  {
            go gopherworker(c)
        }

        for j := range jobs {
            c <- j
        }
        … …

gopherworker(c chan job):
      for {
         select {
         case <-c:
         … …
      }

以下是一个可能的执行路径图:

到了这里,不知道你是否通过上面程序演进的过程悟道些什么,例子里我通篇没有提到并发或并行。

但从例子可以看出,并发和并行是两个阶段的事情。并发在程序的设计和实现阶段,并行在程序的执行阶段。

在Program1之前,我们只有问题,并无方案。

Program1方案让我们可以解决问题,但从Program1的执行结果来看,Program1并不能并行执行。原因是在设计和实现阶段程序就是按照顺序思路进行的,这就好比底子没打好,在平房的地基上永远不能盖50层的大楼。

Program2-1方案的执行结果与Program1相同,但Program2在设计和实现阶段采用的理念却与Program1完全不同,如果说 Program1打的是平房的地基,那么Program2打的就是大厦的地基,虽然Program2-1上依旧盖的是平房(单Gopher执行)。但 Program2-2显然就是在这样的地基上盖的摩天大楼了(多Gopher执行)。Program2的结构使得Program2在多Gopher下提升 了效率,实现了运行时并行。

Program3更进一步,在设计和实现阶段就本着充分高效的利用多个Gopher的理念,并最终实现了执行阶段的并行。

因此我们在编程语言层面更多谈并发,Golang对外宣传时永远用的是支持并发,而不是支持并行。设计实现阶段好比打地基,不同水准的地基决定了你在这个地基上面是只能盖平房,还是盖高层,还是能盖摩天大楼。

我们再回过头来重温Rob Pike大神关于两者的阐述:“并发关乎结构,并行关乎执行”,是不是感觉意味深长啊,大神就是大神,一句话就能抓住本质。

go 1.5之前默认情况下,Go程序都是不能并行的,因为Go将GOMAXPROCS默认设置为1,这样你仅仅能利用一个内核线程。Go 1.5及以后GOMAXPROCS被默认设置为所运行机器的CPU核数,如果你的机器是多核的,你的Go程序就有可能在运行期是并行的,前提是你在设计程 序时就充分运用了并发的设计理念,否则就会像Program1那样,即便有1w颗CPU,你也只能利用上一颗。

Appdash,用Go实现的分布式系统跟踪神器

1 Comment

在“云”盛行的今天,分布式系统已不是什么新鲜的玩意儿。用脚也能想得出来:Google、baidu、淘宝、亚马逊、twitter等IT巨头 背后的巨型计算平台都是分布式系统了,甚至就连一个简单的微信公众号应用的后端也都分布式了,即便仅有几台机器而已。分布式让系统富有弹性,面 对纷繁变化的需求,可以伸缩自如。但分布式系统也给开发以及运维人员带来了难题:如何监控和优化分布式系统的行为。

以google为例,想象一下,用户通过浏览器发起一个搜索请求,Google后端可能会有成百上千台机器、多种编程语言实现的几十个、上百个应 用服务开始忙碌起来,一起计算请求的返回结果。一旦这个过程中某一个环节出现问题/bug,那么查找和定位起来是相当困难的,于是乎分布式系统跟 踪系统出炉了。Google在2010年发表了著名论文《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》(中文版在这里)。Dapper是google内部使用的一个分布式系统跟踪基础设施,与之前的一些跟踪系统相比,Dapper以低消耗、对应用透明以及良好的扩展性著称。并且 Google Dapper更倾向于性能数据方面的收集和调查,可以辅助开发人员和运维人员发现分布式系统的性能瓶颈并着手优化。Dapper出现后,各大巨头开始跟 风,比如twitter的Zipkin(开源)、淘宝的“鹰眼”、eBay的Centralized Activity Logging (CAL)等,它们基本上都是参考google的dapper论文设计和实现的。

而本文将要介绍的Appdash则是sourcegraph开源的一款用Go实现的分布式系统跟踪工具套件,它同样是以google的 dapper为原型设计和实现的,目前用于sourcegraph平台的性能跟踪和监控。

一、原理

Appdash实现了Google dapper中的四个主要概念:

【Span】

Span指的是一个服务调用的跨度,在实现中用SpanId标识。根服务调用者的Span为根span(root span),在根级别进行的下一级服务调用Span的Parent Span为root span。以此类推,服务调用链构成了一棵tree,整个tree构成了一个Trace。

Appdash中SpanId由三部分组成:TraceID/SpanID/parentSpanID,例如: 34c31a18026f61df/aab2a63e86ac0166/592043d0a5871aaf。TraceID用于唯一标识一次Trace。traceid在申请RootSpanID时自动分配。

在上面原理图中,我们也可以看到一次Trace过程中SpanID的情况。图中调用链大致是:

frontservice:
        call  serviceA
        call  serviceB
                  call serviceB1
        … …
        call  serviceN

对应服务调用的Span的树形结构如下:

frontservice: SpanId = xxxxx/nnnn1,该span为root span:traceid=xxxxx, spanid=nnnn1,parent span id为空。
serviceA: SpanId = xxxxx/nnnn2/nnnn1,该span为child span:traceid=xxxxx, spanid=nnnn2,parent span id为root span id:nnnn1。
serviceB: SpanId = xxxxx/nnnn3/nnnn1,该span为child span:traceid=xxxxx, spanid=nnnn3,parent span id为root span id:nnnn1。
… …
serviceN: SpanId = xxxxx/nnnnm/nnnn1,该span为child span:traceid=xxxxx, spanid=nnnnm,parent span id为root span id:nnnn1。
serviceB1: SpanId = xxxxx/nnnn3-1/nnnn3,该span为serviceB的child span,traceid=xxxxx, spanid=nnnn3-1,parent span id为serviceB的spanid:nnnn3

【Event】

个人理解在Appdash中Event是服务调用跟踪信息的wrapper。最终我们在Appdash UI上看到的信息,都是由event承载的并且发给Appdash Server的信息。在Appdash中,你可以显式使用event埋点,吐出跟踪信息,也可以使用Appdash封装好的包接口,比如 httptrace.Transport等发送调用跟踪信息,这些包的底层实现也是基于event的。event在传输前会被encoding为 Annotation的形式。

【Recorder】

在Appdash中,Recorder是用来发送event给Appdash的Collector的,每个Recorder会与一个特定的span相关联。

【Collector】

从Recorder那接收Annotation(即encoded event)。通常一个appdash server会运行一个Collector,监听某个跟踪信息收集端口,将收到的信息存储在Store中。

二、安装

appdash是开源的,通过go get即可得到源码并安装example:

go get -u sourcegraph.com/sourcegraph/appdash/cmd/…

appdash自带一个example,在examples/cmd/webapp下面。执行webapp,你会看到如下结果:

$webapp
2015/06/17 13:14:55 Appdash web UI running on HTTP :8700
[negroni] listening on :8699

这是一个集appdash server, frontservice, fakebackendservice于一身的example,其大致结构如下图:

通过浏览器打开:localhost:8700页面,你会看到appdash server的UI,通过该UI你可以看到所有Trace的全貌。

访问http://localhost:8699/,你就触发了一次Trace。在appdash server ui下可以看到如下画面:

从页面上展示的信息可以看出,该webapp在处理用户request时共进行了三次服务调用,三次调用的耗时分别为:201ms,202ms, 218ms,共耗时632ms。

一个更复杂的例子在cmd/appdash下面,后面的应用实例也是根据这个改造出来的,这里就不细说了。

三、应用实例

这里根据cmd/appdash改造出一个应用appdash的例子,例子的结构如下图:

例子大致分为三部分:
appdash — 实现了一个appdash server, 该server带有一个collector,用于收集跟踪信息,收集后的信息存储在一个memstore中;appdash server提供ui,ui从memstore提取信息并展示在ui上供operator查看。
backendservices — 实现两个模拟的后端服务,供frontservice调用。
frontservice — 服务调用的起始端,当用户访问系统时触发一次跟踪。

先从backendservice这个简单的demo service说起,backendservice下有两个service: ServiceA和ServiceB,两个service几乎一模一样,我们看一个就ok了:

//appdash_examples/backendservices/serviceA.go
package main

import (
    "fmt"
    "net/http"
    "time"
)

func handleRequest(w http.ResponseWriter, r *http.Request) {
    var err error
    if err = r.ParseForm(); err != nil {
        fmt.Println("Http parse form err:", err)
        return
    }
    fmt.Println("SpanId =", r.Header.Get("Span-Id"))

    time.Sleep(time.Millisecond * 101)
    w.Write([]byte("service1 ok"))
}

func main() {
    http.HandleFunc("/", handleRequest)
    http.ListenAndServe(":6601", nil)
}

这是一个"hello world"级别的web server。值得注意的只有两点:
1、在handleRequest中我们故意Sleep 101ms,用来模拟服务的耗时。
2、打印出request头中的"Span-Id"选项值,用于跟踪Span-Id的分配情况。

接下来我们来看appdash server。appdash server = collector +store +ui。

//appdash.go
var c Server

func init() {
    c = Server{
        CollectorAddr: ":3001",
        HTTPAddr:      ":3000",
    }
}

type Server struct {
    CollectorAddr string
    HTTPAddr      string
}

func main() {
    var (
        memStore = appdash.NewMemoryStore()
        Store    = appdash.Store(memStore)
        Queryer  = memStore
    )

    app := traceapp.New(nil)
    app.Store = Store
    app.Queryer = Queryer

    var h http.Handler = app
    var l net.Listener
    var proto string
    var err error
    l, err = net.Listen("tcp", c.CollectorAddr)
    if err != nil {
        log.Fatal(err)
    }
    proto = "plaintext TCP (no security)"
    log.Printf("appdash collector listening on %s (%s)",
                c.CollectorAddr, proto)
    cs := appdash.NewServer(l, appdash.NewLocalCollector(Store))
    go cs.Start()

    log.Printf("appdash HTTP server listening on %s", c.HTTPAddr)
    err = http.ListenAndServe(c.HTTPAddr, h)
    if err != nil {
        fmt.Println("listenandserver listen err:", err)
    }
}

appdash中的Store是用来存储收集到的跟踪结果的,Store是Collector接口的超集,这个例子中,直接利用memstore(实现了 Collector接口)作为local collector,利用store的Collect方法收集trace数据。UI侧则从store中读取结果展示给用户。

最后我们说说:frontservice。frontservice是Trace的触发起点。当用户访问8080端口时,frontservice调用两个backend service:

//frontservice.go
func handleRequest(w http.ResponseWriter, r *http.Request) {
    var result string
    span := appdash.NewRootSpanID()
    fmt.Println("span is ", span)
    collector := appdash.NewRemoteCollector(":3001")

    httpClient := &http.Client{
        Transport: &httptrace.Transport{
            Recorder: appdash.NewRecorder(span, collector),
            SetName:  true,
        },
    }

    //Service A
    resp, err := httpClient.Get("http://localhost:6601")
    if err != nil {
        log.Println("access serviceA err:", err)
    } else {
        log.Println("access serviceA ok")
        resp.Body.Close()
        result += "access serviceA ok\n"
    }

    //Service B
    resp, err = httpClient.Get("http://localhost:6602")
    if err != nil {
        log.Println("access serviceB err:", err)
        return
    } else {
        log.Println("access serviceB ok")
        resp.Body.Close()
        result += "access serviceB ok\n"
    }
    w.Write([]byte(result))
}

func main() {
    http.HandleFunc("/", handleRequest)
    http.ListenAndServe(":8080", nil)
}

从代码看,处理每个请求时都会分配一个root span,同时traceid也随之分配出来。例子中没有直接使用Recorder埋点发送event,而是利用了appdash封装好的 httptrace.Transport,在初始化httpClient时,将transport实例与span和一个remoteCollector想 关联。后续每次调用httpClient进行Get/Post操作时,底层代码会自动调用httptrace.Transport的RoundTrip方 法,后者在Request header上添加"Span-Id"参数,并调用Recorder的Event方法将跟踪信息发给RemoteCollector:

//appdash/httptrace/client.go
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
    var transport http.RoundTripper
    if t.Transport != nil {
        transport = t.Transport
    } else {
        transport = http.DefaultTransport
    }

    … …
    req = cloneRequest(req)

    child := t.Recorder.Child()
    if t.SetName {
        child.Name(req.URL.Host)
    }
    SetSpanIDHeader(req.Header, child.SpanID)

    e := NewClientEvent(req)
    e.ClientSend = time.Now()

    // Make the HTTP request.
    resp, err := transport.RoundTrip(req)

    e.ClientRecv = time.Now()
    if err == nil {
        e.Response = responseInfo(resp)
    } else {
        e.Response.StatusCode = -1
    }
    child.Event(e)

    return resp, err
}

这种方法在一定程度上实现了trace对应用的透明性。

你也可以显式的在代码中调用Recorder的Event的方法将trace信息发送给Collector,下面是一个fake SQLEvent的跟踪发送:

 // SQL event
    traceRec := appdash.NewRecorder(span, collector)
    traceRec.Name("sqlevent example")

    // A random length for the trace.
    length := time.Duration(rand.Intn(1000)) * time.Millisecond
    startTime := time.Now().Add(-time.Duration(rand.Intn(100)) * time.Minute)
    traceRec.Event(&sqltrace.SQLEvent{
        ClientSend: startTime,
        ClientRecv: startTime.Add(length),
        SQL:        "SELECT * FROM table_name;",
        Tag:        fmt.Sprintf("fakeTag%d", rand.Intn(10)),
    })

不过这种显式埋点需要程序配合做一些改造。

四、小结

目前Appdash的资料甚少,似乎只是其东家sourcegraph在production环境有应用。在github.com上受到的关注度也不算高。

appdash是参考google dapper实现的,但目前来看appdash只是实现了“形”,也许称为神器有些言过其实^_^。

首先,dapper强调对应用透明,并使用了Thread LocalStorage。appdash实现了底层的recorder+event机制,上层通过httptrace、sqltrace做了封装,以降 低对应用代码的侵入性。但从上面的应用来看,透明性还有很大提高空间。

其次,appdash的性能数据、扩展方案sourcegraph并没有给出明确说明。

不过作为用go实现的第一个分布式系统跟踪工具,appdash还是值得肯定的。在小规模分布式系统中应用对于系统行为的优化还是会有很大帮助的。   

BTW,上述例子的完整源码在这里可以下载到。

巴萨“三冠王”梅开二度,梅球王预定第五座金球奖杯

No Comments

好久不在博客上写有关足球的文章了。上一次聊足球,还是在去年世界杯决赛后,就是那个让全世界阿根廷球迷、梅西球迷伤心的日子。梅西登上领奖台瞥视大力神 金杯而不能举起的场景曾让无数梅西球迷心碎。不过梅西的足球世界大部分时间是快乐的,才用不到一年,梅西就用职业生涯的第二个“三冠王”告诉大家:王者梅西回来了

今天早上8:30,用了2个多小时看完了CCTV5 尤文vs巴萨的2015年欧冠决赛的录像。没错,的确看的是录像。虽然是巴萨球迷,梅西死忠,但周日固定的家庭活动计划已经让我无法在2:45起床收看比 赛了。实际上整个这个赛季收看巴萨直播的次数也屈指可数,不过这一切都不影响我对巴萨和梅西的热爱。

经历了上个赛季的触底,实际上我对巴萨这个赛季并没有过高的期望,能收获一个冠军,止住下滑,重新企稳,我就满意了。

不过巴萨在西甲却出人意料的高开了。赛季初,巴萨连续取得胜利,并保持多场比赛不失球,这瞬间吊起了萨迷们的胃口,渐渐的大家都调高了对这 只巴萨期望。但就在这时,巴萨却连续在强强对抗中打平或失利,并在第一次国家德比输球后拱手让出榜首位置,成为追赶者。球迷对巴萨的不满气氛在巴萨冬歇期 后客场输给皇家社会后到达顶点。记得那场比赛后,我还在微博中发泄了一下,痛斥恩里克的情商不配做巴萨主帅。也正是这场比赛成为了巴萨整个赛季的拐点,值 得庆幸的是,这次是向上拐。

赛季末经媒体报道得知,这个拐点是球员与主教练的合力促成的:
1、以梅西、哈维等大佬为首的球员们内部达成了一致,要团结,不能内乱,不能再给隔壁任何机会;
2、恩里克教练团队也认识到了梅西在团队中无可替代的核心地位。

至此以后,梅西就再也没有出现在替补席,巴萨基本没有再犯低级错误,MSN三叉戟磨合期过,大放异彩,三线皆喜报频传。

于是乎就有了以下三个场景
1、西甲第37轮,梅西一球定江山,巴萨登顶西甲冠军。
2、诺坎普国王杯决赛,梅西千里走单骑,打入史诗进球,3:1立克毕巴,摘取国王杯桂冠。
3、今天凌晨,内马尔压哨进球,巴萨3:1击溃老妇人尤文图斯,站上欧洲之巅。

巴萨触底反弹,并以绝对超出预期的表现,直接拿到俱乐部历史第二个三冠王,这是巴萨团队合力的结果。

我们来谈谈这个赛季的巴萨团队

【管理层】

不得不说,巴萨管理团队于上个赛季中后期的内乱真是让球迷们烦透了。巴萨将士士气低落,战绩不佳,与管理层的“乱”有着直接关系。历史上,巴萨的阶段性没 落也基本上都源于管理团队的内乱。从罗塞尔辞职,到苏比萨雷塔因巴萨收到FIFA禁止转会处罚而被炒鱿鱼,巴萨内乱终于渐渐平息了一些。也就是在这段“和 平”时期,巴萨将关注放回赛场,战力逐渐恢复。巴萨管理层这个赛季的表现仅仅算得上及格罢了,这个分数还是看在表现异常优异的苏牙(苏亚雷斯)和辣鸡(拉 基蒂奇)才给出的。巴萨下个赛季还要进行主席大选,巴萨球迷心中又得忐忑一阵了。

【恩里克】

来巴萨之前,恩里克的执教“名声”似乎不那么好,在罗马以失意告终,上个赛季也仅仅实现塞尔塔保级罢了。恩里克自封“球队老大”的行事风格总是会触发更衣 室矛盾,这也是他在罗马这样的意甲豪门吃不开的原因(在罗马不尊重狼王托蒂,结果好不到哪去)。最初恩里克的行为特征充满我行我素,缺少一些妥协和平衡, 这也是其情商被球迷和媒体诟病的原因。与皇家社会一役让恩里克似乎顿悟。我们局外人很难了解到细节,恩里克是如何让球队走上正轨的。但与梅球王的关系缓和 绝对是恩里克本赛季取得成功的重要原因之一。恩里克学会了妥协,也就是说不再那么自我了。

不过恩里克也的确给巴萨带来了变化,我个人觉得其最大的贡献是在巴萨目前的阵容下找到了最适合现在巴萨的首发11人(还记得这个赛季中前期恩里克用过多少 种首发阵容吗?)以及适合的风格和踢法。恩里克很清楚不能模仿瓜帅的巴萨,现在的巴萨已经不再具备再踢那种tiki taka绝对控球风格足球的能力了。双核已老,巴萨传球的精确性下降,控制力下降,很容易丢球被打反击。

获得三冠王的恩里克,总是无法避免被和当年的瓜帅对比。所有人都看得出来,现在的巴萨风格与瓜帅鼎盛时期巴萨的风格有大不同。个人拙见:如果真正比起来, 还是瓜帅那支巴萨更强,那种强强在气势上,强在任何要与巴萨为对手的欧洲球队面对巴萨都会采集一种战术:大巴。而现在这只巴萨,任何人都想也都能和他拜拜 手腕。

关于恩里克的轮换让巴萨将士保持健康和状态的观点,我觉得见仁见智。梅西没有轮换,依旧健康,也依旧好状态。

总而言之,恩里克成功了,成功的度过了第一个赛季的信任危机。之后如何表现,如何变化(被对手研究透后)才是体现恩里克真实能力的体现,前提是下个赛季恩 里克继续执教。我还是希望他能继续执教的,毕竟能保持冠军球队的连续性和稳定性。毕竟萨米们还期待着六冠王的梅开二度呢!

【梅西】

竭尽全力,将阿根廷送入决赛,但却没能帮助阿根廷最终捧杯,要说伤心,谁也比不过梅西。多伤心只有他自己知道。不过还是那句话,梅西天生为足球而生, 天生为快乐足球而生。沉溺于快乐的足球中,梅西才能发挥出外星人般的威力。经历了两个不算太成功的赛季后,梅西也终于大爆发了。一方面这得益于梅西将重心 重新放回到俱乐部,梅西承认14年为世界杯留力了。另一方面则是对荣誉的新的渴望。这些都正面的表现在赛场上、日常训练上以及梅西减肥的态度上了。我们要 庆幸,庆幸梅西没有走肥罗的老路。严格遵守营养师的建议让梅西重归轻盈,再次获得了凌波微步的能力,也避免了再受伤病侵袭,这是本赛季梅西重回巅峰的基 础。

另外梅西有意识的自我进化,让我们再次看到梅西的足球境界是多么的高深。这个赛季,在MSN组合中梅球王更多的是扮演搭台的角色,内马尔和 苏牙唱戏。梅西长传日益精准和飘逸,45度角长传找内马尔或阿尔巴的进攻路线屡屡敲碎对方防线。直塞、任意球、撞墙配合、突破传中无所不能,勺子点球也带 给球迷一丝惊艳。词穷是梅吹们的共同心声。这里再套用一次俗语格式:梅西是“中场里进球最多的,前锋中组织、助攻、突破、传威胁球最多、后撤最深的”。

凭借本赛季的三冠王,以及下个赛季的可能的“六冠王”,梅西基本上预定了下一个“金球奖”,梅西的纪录只有梅西自己去打破了!

【布拉沃】

巴萨联赛最后一道闸门,联赛上半段连续不失球,绝对是能力的体现。布拉沃的成就让我想起了巴萨历史最佳门神:巴尔德斯。如果巴尔德斯没有走,他的荣誉簿中 就又多了一次三冠王。不过布拉沃也应该清楚,小狮王特尔施特根才是未来巴萨重点培养的对象,中流砥柱。不知道下个赛季巴萨如何在两位顶级门将中抉择。

【内马尔】

公认巴萨王储。这个赛季在进球数上是仅次于梅西的第二功臣,屡屡有关键比赛的关键进球。个人觉得内少最大的优点就是清楚的知道现在梅西是球队的核心,还没 到他立腕的时候,安心辅佐梅西才能带来个人能力和成绩上的最大收益。内马尔的能力毋庸置疑,但要学习的还有很多。年轻就是内马尔最大的优势。内马尔后续的 职业生涯如何,能否像梅西那样,连续N年持续保持最高状态,还是要看他自己的自律了。一般来说,巴西球员,尤其是巨星,到目前为止还少有能持续保持巅峰状 态的,比如大罗和小罗,希望内马尔能为巴西球员做出表率!

【小白】

欧冠决赛MVP,这个赛季低开高走,状态在最后的欧冠决赛彻底释放,让我们依稀看到带球飘逸的小白。在哈维离开巴萨后,小白义不容辞成为巴萨的绝对大佬,带领新一期巴萨梦之队走向一个有一个巅峰。

【皮克】

皮总这个赛季终于也随着“大盘”进入牛市了。在经历了两个赛季低迷后,皮总和梅西同步的回到了巅峰,再次成为后防线上那个让人放心的带刀后卫了。

【苏牙】

头顶欧洲金靴和世界杯“亮牙”的光环,苏牙从英超来到了西甲,并出人意料的与梅西、内马尔组成了史上最强三叉戟。苏牙个人能力太强,跑位、前插、卸球、射 门一气呵成,估计连皇马球迷都不得不承认:“太销魂”了!巴萨自埃托奥之后的9号魔咒似乎对苏牙也不起什么作用。下个赛季相信苏牙能表现更好,巴萨历史最 佳中锋名号在等待着苏牙。

【辣鸡】

巴萨本赛季最佳引援之一。欧冠决赛的第一个进球是大家对他最深的印象,实际上整个赛季,拉基蒂奇都有着优异的表现。在巴萨中场承前启后,与梅西不断配合、 换位、保护。前插得分能力是辣鸡一大特色,跑不死是辣鸡的招牌!相信88年出生的辣鸡必将成为巴萨新一代王朝的中流砥柱。

【哈维】

“新陈代谢”,自然规律无法抗拒。西班牙足球史上最佳中场哈维本赛季以三冠王的荣誉完美谢幕。本赛季哈维更多的是坐在替补席发挥着自己的光和热。在输球于 皇家社会后,是哈维带领大家自我反省,达成一致,从而使得巴萨走出低谷的。哈维在他在巴萨的最后一个赛季,发挥了居功至伟的作用。“三冠王”是送给哈维最 好的离别礼物。相信未来,哈维还会回到巴萨,并以主教练身份带领巴萨走向欧洲巅峰。

【布斯克茨】

兢兢业业,勤勤恳恳,作为巴萨的单后腰,布教授是巴萨中轴线上重要的一枚棋子,这个赛季也有着上佳的发挥。尤文教练甚至认为,只要封住梅西和小布,就能封住巴萨,可见小布在巴萨阵容和战术中的重要性。

【阿尔维斯】

这几个赛季阿尔维斯随着年龄的增大,状态却有下滑,梅西回归中路后,与梅西的那种配合也少见了。但这个赛季似乎是梅西回归右路后,又激发了阿尔维斯的状 态。这个赛季的阿尔维斯似乎又重新回到了巅峰时刻:助攻犀利,防守到位。无奈合同即将到底,未来还不确定。从阿尔维斯目前的身体情况和状态而言,再为巴萨 打两个赛季不成问题,真心不希望阿尔维斯出走,尤其是在巴萨特别需要他的时候。

欧冠落幕,欧洲联赛告一段落。不过阿根廷球迷、梅西粉丝却不担心,因为还有即将开打的美洲杯赛,我们仍旧会看到梅西、小马哥。阿根廷目前的实力在南美还是数一数二的。前锋线自不必提,牛B前锋太多;中场稍弱,但与其他南美球队中场比起来,巴内加、帕斯托雷等也不逞多让。后防线有当红的瓦伦中卫奥塔门迪领衔,也是让人可以相对放心的。

目前唯一担心的就是梅西赛季全勤后的体能状况。一般来说这种洲际大赛表现突出的都是那些在欧洲联赛中没有消耗多少体力的,像阿根廷对中的阿圭罗、迪玛利 亚,我觉得在本次美洲杯中会有上佳发挥。因此梅西可以选择在小组赛面对弱队时,适当做做替补席,虽然这明显不符合梅西的性格。但要走的更远,在关键比赛中 有上佳发挥,体力是基本保证啊。

期待本届美洲杯,阿根廷能载誉而归,也该轮到梅西拿拿国家队层面的冠军了!

Caddy,一个用Go实现的Web Server

1 Comment

这是一个Web Server的时代,apache2nginx共舞,在追求极致性能的路上,没有最高,只有更高。但这又是一个追求个性化的时代,有些Web Server并没有去挤“Performance提升”这一独木桥,而是有着自己的定位,Caddy就是这样一个开源Web Server。

Caddy的作者Matt Holt在caddy官网以及FAQ中对caddy的目标阐释如下: 其他Web Server为Web而设计,Caddy为human设计。功能定位上,与经常充当最前端反向代理的nginx不同,caddy致力于成为一个易用的静态 文件Web Server。可以看出Caddy主打易用性,使用配置简单。并且得益于Go的跨平台特性,caddy很容易的支持了三大主流平台:Windows、 Linux、Mac。在Caddy开发者文档中,我们可以看到caddy还可以在Android(linux arm)上运行。caddy目前版本为0.7.1,还不稳定,且后续版本可能变化较大,甚至与前期版本不兼容,因此作者目前不推荐caddy在生产环境被 重度使用。

关注caddy,是因为caddy填补了go在通用web server这块的空白(也许有其他,但我还不知道),同时Web server in go也“响应”了近期Golang去C化的趋势(Go 1.5中C is gone!),即便caddy作者提到caddy的目标并非如nginx那样。但未来谁知道呢?一旦Go性能足够高时,一旦caddy足够稳定时,自然而 然的就会有人将其用在某些应用的生产环境中替代nginx或apache2了。一套全Go的系统,在部署、运维方面也是有优势的。

一、安装和运行caddy

和诸多go应用一样,我们可以直接从caddy的github.com releases页中找到最新发布版(目前是0.7.1)的二进制包。这里使用的是caddy_darwin_amd64.zip。

下载解压后,进入目录,直接执行./caddy即可将caddy运行起来。

$caddy
0.0.0.0:2015

在浏览器里访问localhost:2015,页面上没有预期显示的类似"caddy works!”之类的默认Welcome页面,而是“404 Not Found"。虽然这说明caddy已经work了,但没有一个default welcome page毕竟对于caddy beginer来说并不友好。这里已经向作者提了一个sugguestion issue

二、caddy原理

Go的net/http标准库已经提供了http server的实现,大多数场合这个http server都能满足你的需要,无论是功能还是性能。Caddy实质上也是一个Go web app,它也import net/http,嵌入*http.Server,并通过handler的ServeHTTP方法为每个请求提供服务。caddy使用 http.FileServer作为处理 静态文件的基础。caddy的诱人之处在于其middleware,将诸多middleware串成一个middleware chain以提供了灵活的web服务。另外caddy中的middleware还可以独立于caddy之外使用。

caddy从当前目录的Caddyfile(默认)文件中读取配置,当然你也可以通过-conf指定配置文件路径。Caddyfile的配置格式 的确非常easy,这也符合caddy的目标。

Caddyfile总是以站点的Addr开始的。

单一站点的Caddyfile样例如下:

//Caddyfile
localhost:2015
gzip
log ./2015.log

Caddy也支持配置多个站点,类似virtualhost的 配置(80端口多路复用):

//Caddyfile
foo.com:80 {
    log ./foo.log
    gzip
}

bar.com:80 {
    log ./bar.log
    gzip
}

为了实现风格上的统一,单一站点也最好配置为如下这种格式(代码内部称之为    Server Block):

localhost:2015 {
    gzip
    log ./2015.log

}

这样Caddyfile的配置文件模板样式类似于下面这样:

host1:port {
    middleware1
    middleware2 {
        … …
    }
    … …
}

host2:port {
    middleware1
    middleware2 {
        … …
    }
    … …
}
… …

关于middleware,在caddy文档中有较为详细的说明和例子。对于caddy这样一个年轻的开源项目而言,其文档还算是相对较全的,虽 然现在还不能和nginx、 apache比。

caddy中的middleware就是一个实现了middleware.Handler接口的struct,例如gzip这个 middleware:

// middleware.go
type Middleware func(Handler) Handler
type Handler interface {
        ServeHTTP(http.ResponseWriter, *http.Request) (int, error)
}

// gzip/gzip.go
type Gzip struct {
    Next middleware.Handler
}

func (g Gzip) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
    if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
        return g.Next.ServeHTTP(w, r)
    }
    …. …
    gz := gzipResponseWriter{Writer: gzipWriter, ResponseWriter: w}

    // Any response in forward middleware will now be compressed
    status, err := g.Next.ServeHTTP(gz, r)
    … …
}

middleware.Handler的函数原型与http.Handler的不同,不能直接作为http.Server的Handler使用。caddy使用了下面这个idiomatic go pattern:

type appHandler func(http.ResponseWriter, *http.Request) (int, error)

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if status, err := fn(w, r); err != nil {
        http.Error(w, err.Error(), status)
    }
}
当然这个pattern有很多变种,但思路大致类似。一个middleware chain大致就是handler1(handler2(handler3))的调用传递。

前面说过caddy是基于http.FileServer的静态文件Web Server,FileServer总会作为middleware chain的最后一环,如果没有配置任何middleware,那你的server就是一个静态文件server。

三、caddy典型应用

【静态文件Server】

caddy的最基础应用实际就是一个静态文件Server,底层由http.FileServer承载,当然caddy封装了http.FileServer,做了一些拦截处理,最后将w, r传递给http.ServeContent去处理文件数据。

第一次执行./caddy,实际上就启动了一个静态文件Server。但这个server不默认支持你navigate directory。如果你知道website root目录(如果没有指定root,则caddy执行的当前路径会作为website的root路径)下的文件名,比如foo.txt,你可以在浏览器 中输入:localhost:2015/foo.txt,caddy会执行正确的服务,浏览器也会显示foo.txt的全文。

对于静态文件Server,caddy支持在website的root路径下首先查找是否有如下四个文件:

//caddy/middleware/browse/browse.go
var IndexPages = []string{
    "index.html",
    "index.htm",
    "default.html",
    "default.htm",
}

如果查到有其中一个,则优先返回这个文件内容,这就是静态站点的首页。

如果要支持目录文件列表浏览,则需要为website配置browse middleware,这样对于无index file的目录,我们可以看到目录文件列表。

localhost:2015 {
    browse
}   

【反向代理】

caddy支持基本的反向代理功能。反向代理配置通过proxy middleware实现。

localhost:2015 {
    log ./2015.log

    proxy /foo localhost:9001
    proxy /bar localhost:9002
}

当你访问localhost:2015/foo时,实际上访问的是9001端口的服务程序;
当你访问localhost:2015/bar时,实际上访问的是9002端口的服务程序。

【负载均衡】

Caddy支持负载均衡配置,并支持三种负载均衡算法:random(随机)、least_conn(最少连接)以及round_robin(轮询调度)。

负载均衡同样是通过proxy middleware实现的。

localhost:2015 {
    log ./2015.log

    proxy / localhost:9001 localhost:9003 {
        policy round_robin
    }
    proxy /bar localhost:9002 localhost:9004 {
        policy least_conn
    }
}

【支持fastcgi代理】

caddy同样支持fastcgi代理,可以将请求通过fastcgi接口发送给后端的实现fastcgi的server。我们以一个"hello world"的php server为例。

mac os上自带了php-fpm,一个实现了fastcgi的php cgi进程管理器。caddy将请求转发给php-fpm监听的端口,后者会启动php-cgi解释器,解释index.php,并将结果返回给caddy。

mac os上的php-fpm默认没有随机启动。我们需要简单配置一下:

$mkdir phptest
$mkdir -p phptest/etc
$mkdir -p phptest/log
$cd phptest
$sudo cp /private/etc/php-fpm.conf.default ./etc
$cd ./etc

$sudo chown tony php-fpm.conf.default
$mv php-fpm.conf.default php-fpm.conf

编辑php-fpm.conf,保证下面两项是非注释状态的:

error_log = log/php-fpm.log
listen = 127.0.0.1:9000
 

我们通过network socket进行fastcgi通信。

回到phptest目录下,执行:

php-fpm -p ~/test/go/caddy/phptest

执行后,php-fpm就会转入后台执行了。

接下来我们来配置Caddyfile:

localhost:2015 {
    fastcgi / 127.0.0.1:9000 php
    log ./2015.log
}

这里配置的含义是:将全部请求转发到9000端口,这里的php是一个preset(预配置集合),相当于:

ext   .php
split .php
index index.php

我们在phptest目录下创建一个index.php文件,内容如下:

<?php echo "Hello World\n"; ?>

好了,现在启动caddy,并使用浏览器访问localhost:2015试试。你会看到"Hello World"呈现在浏览器中。

【git push发布】

对于一些静态站点,caddy支持git directive,实现在server启动以及运行时定期git pull你的项目库,将最新更新pull到server上。

caddy文档中给出两个例子:

第一个是一个php站点,定期pull项目库,实现server更新:

git git@github.com:user/myphpsite {
    key /home/user/.ssh/id_rsa
}
fastcgi / 127.0.0.1:9000 php

第二个是一个hugo支撑的静态站点,每次pull后,执行hugo命令生成新的静态页面:

git github.com/user/site {
    path  ../
    then  hugo –destination=/home/user/hugosite/public
}

注意:git directive并非middleware,而是一个单独的goroutine实现的。

四、小结

caddy的功能不局限于上面的几个例子,上面只是几个最为常见的场景而已。caddy目前还很年轻,应用不多,但知名golang网站 gopheracademy.com(GopherCon组织方)是由Caddy support的。caddy还在积极进化,有兴趣的Gopher可持续关注。

ngrok原理浅析

3 Comments

之前在进行微信Demo开发时曾用到过ngrok这个强大的tunnel(隧道)工具,ngrok在其github官方页面上的自我诠释是 “introspected tunnels to localhost",这个诠释有两层含义:
1、可以用来建立public到localhost的tunnel,让居于内网主机上的服务可以暴露给public,俗称内网穿透。
2、支持对隧道中数据的introspection(内省),支持可视化的观察隧道内数据,并replay(重放)相关请求(诸如http请 求)。

因此ngrok可以很便捷的协助进行服务端程序调试,尤其在进行一些Web server开发中。ngrok更强大的一点是它支持tcp层之上的所有应用协议或者说与应用层协议无关。比如:你可以通过ngrok实现ssh登录到内 网主 机,也可以通过ngrok实现远程桌面(VNC)方式访问内网主机。

今天我们就来简单分析一下这款强大工具的实现原理。ngrok本身是用go语言实现的,需要go 1.1以上版本编译。ngrok官方代码最新版为1.7,作者似乎已经完成了ngrok 2.0版本,但不知为何迟迟不放出最新代码。因此这里我们就以ngrok 1.7版本源码作为原理分析的基础。

一、ngrok tunnel与ngrok部署

网络tunnel(隧道)对多数人都是很”神秘“的概念,tunnel种类很多,没有标准定义,我了解的也不多(日常工作较少涉及),这里也就不 深入了。在《HTTP权威指南》中有关于HTTP tunnel(http上承载非web流量)和SSL tunnel的说明,但ngrok中的tunnel又与这些有所不同。

ngrok实现了一个tcp之上的端到端的tunnel,两端的程序在ngrok实现的Tunnel内透明的进行数据交互。

ngrok分为client端(ngrok)和服务端(ngrokd),实际使用中的部署如下:

内网服务程序可以与ngrok client部署在同一主机,也可以部署在内网可达的其他主机上。ngrok和ngrokd会为建立与public client间的专用通道(tunnel)。

二、ngrok开发调试环境搭建

在学习ngrok代码或试验ngrok功能的时候,我们可能需要搭建一个ngrok的开发调试环境。ngrok作者在ngrok developer guide中给出了步骤:

$> git clone https://github.com/inconshreveable/ngrok
$> cd ngrok
$> make client
$> make server

make client和make server执行后,会建构出ngrok和ngrokd的debug版本。如果要得到release版本,请使用make release-clientmake release-server。debug版本与release版本的区别在于debug版本不打包 assets下的资源文件,执行时通过文件系统访问。

修改/etc/hosts文件,添加两行:

127.0.0.1 ngrok.me
127.0.0.1 test.ngrok.me

创建客户端配置文件debug.yml:

server_addr: ngrok.me:4443
trust_host_root_certs: false
tunnels:
      test:
        proto:
           http: 8080

不过要想让ngrok与ngrokd顺利建立通信,我们还得制作数字证书(自签发),源码中自带的证书是无法使用的,证书制作方法可参见《搭建自 己的ngrok服务》一文,相关原理可参考《Go和HTTPS》一文,这里就不赘述了。

我直接使用的是release版本(放在bin/release下),这样在执行命令时可以少传入几个参数:

启动服务端:
$> sudo ./bin/release/ngrokd -domain ngrok.me
[05/13/15 17:15:37] [INFO] Listening for public http connections on [::]:80
[05/13/15 17:15:37] [INFO] Listening for public https connections on [::]:443
[05/13/15 17:15:37] [INFO] Listening for control and proxy connections on [::]:4443

启动客户端:
$> ./bin/release/ngrok -config=debug.yml -log=ngrok.log -subdomain=test 8080

有了调试环境,我们就可以通过debug日志验证我们的分析了。

ngrok的源码结构如下:

drwxr-xr-x   3 tony  staff  102  3 31 16:09 cache/
drwxr-xr-x  16 tony  staff  544  5 13 17:21 client/
drwxr-xr-x   4 tony  staff  136  5 13 15:02 conn/
drwxr-xr-x   3 tony  staff  102  3 31 16:09 log/
drwxr-xr-x   4 tony  staff  136  3 31 16:09 main/
drwxr-xr-x   5 tony  staff  170  5 12 16:17 msg/
drwxr-xr-x   5 tony  staff  170  3 31 16:09 proto/
drwxr-xr-x  11 tony  staff  374  5 13 17:21 server/
drwxr-xr-x   7 tony  staff  238  3 31 16:09 util/
drwxr-xr-x   3 tony  staff  102  3 31 16:09 version/

main目录下的ngrok/和ngrokd/分别是ngrok和ngrokd main包,main函数存放的位置,但这里仅仅是一个stub。以ngrok为例:

// ngrok/src/ngrok/main/ngrok/ngrok.go
package main

import (
    "ngrok/client"
)

func main() {
    client.Main()
}

真正的“main”被client包的Main函数实现。

client/和server/目录分别对应ngrok和ngrokd的主要逻辑,其他目录(或包)都是一些工具类的实现。

三、第一阶段:Control Connection建立

在ngrokd的启动日志中我们可以看到这样一行:

[INFO] Listening for control and proxy connections on [::]:4443

ngrokd在4443端口(默认)监听control和proxy connection。Control Connection,顾名思义“控制连接”,有些类似于FTP协议的控制连接(不知道ngrok作者在设计协议时是否参考了FTP协议^_^)。该连接 只用于收发控制类消息。作为客户端的ngrok启动后的第一件事就是与ngrokd建立Control Connection,建立过程序列图如下:

前面提到过,ngrok客户端的实际entrypoint在ngrok/src/ngrok/client目录下,包名client,实际入口是 client.Main函数。

//ngrok/src/ngrok/client/main.go
func Main() {
    // parse options
    // set up logging
    // read configuration file
    …. …
    NewController().Run(config)
}

ngrok采用了MVC模式构架代码,这既包括ngrok与ngrokd之间的逻辑处理,也包括ngrok本地web页面(用于隧道数据的 introspection)的处理。

//ngrok/src/ngrok/client/controller.go
func (ctl *Controller) Run(config *Configuration) {

    var model *ClientModel

    if ctl.model == nil {
        model = ctl.SetupModel(config)
    } else {
        model = ctl.model.(*ClientModel)
    }
    // init the model
    // init web ui
    // init term ui
   … …
   ctl.Go(ctl.model.Run)
   … …
  
}

我们来继续看看model.Run都做了些什么。

//ngrok/src/ngrok/client/model.go
func (c *ClientModel) Run() {
    … …

    for {
        // run the control channel
        c.control()
        … …
        if c.connStatus == mvc.ConnOnline {
            wait = 1 * time.Second
        }

        … …
        c.connStatus = mvc.ConnReconnecting
        c.update()
    }
}

Run函数调用c.control来运行Control Connection的主逻辑,并在control connection断开后,尝试重连。

c.control是ClientModel的一个method,用来真正建立ngrok到ngrokd的control connection,并完成基于ngrok的鉴权(用户名、密码配置在配置文件中)。

//ngrok/src/ngrok/client/model.go
func (c *ClientModel) control() {
    … …
    var (
        ctlConn conn.Conn
        err     error
    )
    if c.proxyUrl == "" {
        // simple non-proxied case, just connect to the server
        ctlConn, err = conn.Dial(c.serverAddr, "ctl", c.tlsConfig)
    } else {……}
    … …

    // authenticate with the server
    auth := &msg.Auth{
        ClientId:  c.id,
        OS:        runtime.GOOS,
        Arch:      runtime.GOARCH,
        Version:   version.Proto,
        MmVersion: version.MajorMinor(),
        User:      c.authToken,
    }

    if err = msg.WriteMsg(ctlConn, auth); err != nil {
        panic(err)
    }

    // wait for the server to authenticate us
    var authResp msg.AuthResp
    if err = msg.ReadMsgInto(ctlConn, &authResp); err != nil {
        panic(err)
    }

    … …

    c.id = authResp.ClientId
    … ..
}

ngrok封装了connection相关操作,代码在ngrok/src/ngrok/conn下面,包名conn。

//ngrok/src/ngrok/conn/conn.go
func Dial(addr, typ string, tlsCfg *tls.Config) (conn *loggedConn, err error) {
    var rawConn net.Conn
    if rawConn, err = net.Dial("tcp", addr); err != nil {
        return
    }

    conn = wrapConn(rawConn, typ)
    conn.Debug("New connection to: %v", rawConn.RemoteAddr())

    if tlsCfg != nil {
        conn.StartTLS(tlsCfg)
    }

    return
}

ngrok首先创建一条TCP连接,并基于该连接创建了TLS client:

func (c *loggedConn) StartTLS(tlsCfg *tls.Config) {
    c.Conn = tls.Client(c.Conn, tlsCfg)
}

不过此时并未进行TLS的初始化,即handshake。handshake发生在ngrok首次向ngrokd发送auth消息(msg.WriteMsg, ngrok/src/ngrok/msg/msg.go)时,go标准库的TLS相关函数默默的完成这一handshake过程。我们经常遇到的ngrok证书验证失败等问题,就发生在该过程中。

在AuthResp中,ngrokd为该Control Connection分配一个ClientID,该ClientID在后续Proxy Connection建立时使用,用于关联和校验之用。

前面的逻辑和代码都是ngrok客户端的,现在我们再从ngrokd server端代码review一遍Control Connection的建立过程。

ngrokd的代码放在ngrok/src/ngrok/server下面,entrypoint如下:

//ngrok/src/ngrok/server/main.go
func Main() {
    // parse options
    opts = parseArgs()
    // init logging
    // init tunnel/control registry
    … …
    // start listeners
    listeners = make(map[string]*conn.Listener)

    // load tls configuration
    tlsConfig, err := LoadTLSConfig(opts.tlsCrt, opts.tlsKey)
    if err != nil {
        panic(err)
    }
    // listen for http
    // listen for https
    … …

    // ngrok clients
    tunnelListener(opts.tunnelAddr, tlsConfig)
}

ngrokd启动了三个监听,其中最后一个tunnelListenner用于监听ngrok发起的Control Connection或者后续的proxy connection,作者意图通过一个端口,监听两种类型连接,旨在于方便部署。

//ngrok/src/ngrok/server/main.go
func tunnelListener(addr string, tlsConfig *tls.Config) {
    // listen for incoming connections
    listener, err := conn.Listen(addr, "tun", tlsConfig)
    … …

    for c := range listener.Conns {
        go func(tunnelConn conn.Conn) {
            … …
            var rawMsg msg.Message
            if rawMsg, err = msg.ReadMsg(tunnelConn); err != nil {
                tunnelConn.Warn("Failed to read message: %v", err)
                tunnelConn.Close()
                return
            }
            … …
            switch m := rawMsg.(type) {
            case *msg.Auth:
                NewControl(tunnelConn, m)
            … …
            }
        }(c)
    }
}

从tunnelListener可以看到,当ngrokd在新建立的Control Connection上收到Auth消息后,ngrokd执行NewControl来处理该Control Connection上的后续事情。

//ngrok/src/ngrok/server/control.go
func NewControl(ctlConn conn.Conn, authMsg *msg.Auth) {
    var err error

    // create the object
    c := &Control{
        … …
    }

    // register the clientid
    … …
    // register the control
    … …

    // start the writer first so that
    // the following messages get sent
    go c.writer()

    // Respond to authentication
    c.out <- &msg.AuthResp{
        Version:   version.Proto,
        MmVersion: version.MajorMinor(),
        ClientId:  c.id,
    }

    // As a performance optimization,
    // ask for a proxy connection up front
    c.out <- &msg.ReqProxy{}

    // manage the connection
    go c.manager()
    go c.reader()
    go c.stopper()
}

在NewControl中,ngrokd返回了AuthResp。到这里,一条新的Control Connection建立完毕。

我们最后再来看一下Control Connection建立过程时ngrok和ngrokd的输出日志,增强一下感性认知:

ngrok Server:

[INFO] [tun:d866234] New connection from 127.0.0.1:59949
[DEBG] [tun:d866234] Waiting to read message
[DEBG] [tun:d866234] Reading message with length: 126
[DEBG] [tun:d866234] Read message {"Type":"Auth",
"Payload":{"Version":"2","MmVersion":"1.7","User":"","Password":"","OS":"darwin","Arch":"amd64","ClientId":""}}
[INFO] [ctl:d866234] Renamed connection tun:d866234
[INFO] [registry] [ctl] Registered control with id ac1d14e0634f243f8a0cc2306bb466af
[DEBG] [ctl:d866234] [ac1d14e0634f243f8a0cc2306bb466af] Writing message: {"Type":"AuthResp","Payload":{"Version":"2","MmVersion":"1.7","ClientId":"ac1d14e0634f243f8a0cc2306bb466af","Error":""}}

Client:

[INFO] (ngrok/log.Info:112) Reading configuration file debug.yml
[INFO] (ngrok/log.(*PrefixLogger).Info:83) [client] Trusting root CAs: [assets/client/tls/ngrokroot.crt]
[INFO] (ngrok/log.(*PrefixLogger).Info:83) [view] [web] Serving web interface on 127.0.0.1:4040
[INFO] (ngrok/log.Info:112) Checking for update
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [view] [term] Waiting for update
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] New connection to: 127.0.0.1:4443
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Writing message: {"Type":"Auth","Payload":{"Version":"2","MmVersion":"1.7","User":"","Password":"","OS":"darwin","Arch":"amd64","ClientId":""}}
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Waiting to read message
(ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Reading message with length: 120
(ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Read message {"Type":"AuthResp","Payload":{"Version":"2","MmVersion":"1.7","ClientId":"ac1d14e0634f243f8a0cc2306bb466af","Error":""}}
[INFO] (ngrok/log.(*PrefixLogger).Info:83) [client] Authenticated with server, client id: ac1d14e0634f243f8a0cc2306bb466af

四、Tunnel Creation

Tunnel Creation是ngrok将配置文件中的tunnel信息通过刚刚建立的Control Connection传输给 ngrokd,ngrokd登记、启动相应端口监听(如果配置了remote_port或多路复用ngrokd默认监听的http和https端口)并返回相应应答。ngrok和ngrokd之间并未真正建立新连接。

我们回到ngrok的model.go,继续看ClientModel的control方法。在收到AuthResp后,ngrok还做了如下事情:

//ngrok/src/ngrok/client/model.go
 
   // request tunnels
    reqIdToTunnelConfig := make(map[string]*TunnelConfiguration)
    for _, config := range c.tunnelConfig {
        // create the protocol list to ask for
        var protocols []string
        for proto, _ := range config.Protocols {
            protocols = append(protocols, proto)
        }

        reqTunnel := &msg.ReqTunnel{
            … …
        }

        // send the tunnel request
        if err = msg.WriteMsg(ctlConn, reqTunnel); err != nil {
            panic(err)
        }

        // save request id association so we know which local address
        // to proxy to later
        reqIdToTunnelConfig[reqTunnel.ReqId] = config
    }

    // main control loop
    for {
        var rawMsg msg.Message
       
        switch m := rawMsg.(type) {
        … …
        case *msg.NewTunnel:
            … …

            tunnel := mvc.Tunnel{
                … …
            }

            c.tunnels[tunnel.PublicUrl] = tunnel
            c.connStatus = mvc.ConnOnline
           
            c.update()
        … …
        }
    }

ngrok将配置的Tunnel信息逐一以ReqTunnel消息发送给ngrokd以注册登记Tunnel,并在随后的main control loop中处理ngrokd回送的NewTunnel消息,完成一些登记索引工作。

ngrokd Server端对tunnel creation的处理是在NewControl的结尾处:

//ngrok/src/ngrok/server/control.go
func NewControl(ctlConn conn.Conn, authMsg *msg.Auth) {
    … …
    // manage the connection
    go c.manager()
    … …
}

func (c *Control) manager() {
    //… …

    for {
        select {
        case <-reap.C:
            … …

        case mRaw, ok := <-c.in:
            // c.in closes to indicate shutdown
            if !ok {
                return
            }

            switch m := mRaw.(type) {
            case *msg.ReqTunnel:
                c.registerTunnel(m)

            .. …
            }
        }
    }
}

Control的manager在收到ngrok发来的ReqTunnel消息后,调用registerTunnel进行处理。

// ngrok/src/ngrok/server/control.go
// Register a new tunnel on this control connection
func (c *Control) registerTunnel(rawTunnelReq *msg.ReqTunnel) {
    for _, proto := range strings.Split(rawTunnelReq.Protocol, "+") {
        tunnelReq := *rawTunnelReq
        tunnelReq.Protocol = proto

        c.conn.Debug("Registering new tunnel")
        t, err := NewTunnel(&tunnelReq, c)
        if err != nil {
            c.out <- &msg.NewTunnel{Error: err.Error()}
            if len(c.tunnels) == 0 {
                c.shutdown.Begin()
            }

            // we're done
            return
        }

        // add it to the list of tunnels
        c.tunnels = append(c.tunnels, t)

        // acknowledge success
        c.out <- &msg.NewTunnel{
            Url:      t.url,
            Protocol: proto,
            ReqId:    rawTunnelReq.ReqId,
        }

        rawTunnelReq.Hostname = strings.Replace(t.url, proto+"://", "", 1)
    }
}

Server端创建tunnel的实际工作由NewTunnel完成:

// ngrok/src/ngrok/server/tunnel.go
func NewTunnel(m *msg.ReqTunnel, ctl *Control) (t *Tunnel, err error) {
    t = &Tunnel{
      … …
    }

    proto := t.req.Protocol
    switch proto {
    case "tcp":
        bindTcp := func(port int) error {
            if t.listener, err = net.ListenTCP("tcp",
               &net.TCPAddr{IP: net.ParseIP("0.0.0.0"),
               Port: port}); err != nil {
                … …
                return err
            }

            // create the url
            addr := t.listener.Addr().(*net.TCPAddr)
            t.url = fmt.Sprintf("tcp://%s:%d", opts.domain, addr.Port)

            // register it
            if err = tunnelRegistry.RegisterAndCache(t.url, t);
               err != nil {
                … …
                return err
            }

            go t.listenTcp(t.listener)
            return nil
        }

        // use the custom remote port you asked for
        if t.req.RemotePort != 0 {
            bindTcp(int(t.req.RemotePort))
            return
        }
        // try to return to you the same port you had before
        cachedUrl := tunnelRegistry.GetCachedRegistration(t)
        if cachedUrl != "" {
            … …
        }

        // Bind for TCP connections
        bindTcp(0)
        return

    case "http", "https":
        l, ok := listeners[proto]
        if !ok {
            … …
            return
        }

        if err = registerVhost(t, proto, l.Addr.(*net.TCPAddr).Port);
           err != nil {
            return
        }

    default:
        err = fmt.Errorf("Protocol %s is not supported", proto)
        return
    }

    … …

    metrics.OpenTunnel(t)
    return
}

可以看出,NewTunnel区别对待tcp和http/https隧道:

- 对于Tcp隧道,NewTunnel先要看是否配置了remote_port,如果remote_port不为空,则启动监听这个 remote_port。否则尝试从cache里找出你之前创建tunnel时使用的端口号,如果可用,则监听这个端口号,否则bindTcp(0),即 随机选择一个端口作为该tcp tunnel的remote_port。

- 对于http/https隧道,ngrokd启动时就默认监听了80和443,如果ngrok请求建立http/https隧道(目前不支持设置remote_port),则ngrokd通过一种自实现的vhost的机制实现所有http/https请求多路复用到80和443端口上。ngrokd不会新增监听端口。

从下面例子,我们也可以看出一些端倪。我们将debug.yml改为:

server_addr: ngrok.me:4443
trust_host_root_certs: false
tunnels:
      test:
        proto:
           http: 8080
      test1:
        proto:
           http: 8081
      ssh1:
        remote_port: 50000
        proto:
            tcp: 22
      ssh2:
        proto:
            tcp: 22

启动ngrok:

$./bin/release/ngrok -config=debug.yml -log=ngrok.log start test test1  ssh1 ssh2

Tunnel Status                 online
Version                       1.7/1.7
Forwarding                    tcp://ngrok.me:50000 -> 127.0.0.1:22
Forwarding                    tcp://ngrok.me:56297 -> 127.0.0.1:22
Forwarding                    http://test.ngrok.me -> 127.0.0.1:8080
Forwarding                    http://test1.ngrok.me -> 127.0.0.1:8081
Web Interface                 127.0.0.1:4040

可以看出ngrokd为ssh2随机挑选了一个端口56297进行了监听,而两个http隧道,则都默认使用了80端口。

如果像下面这样配置会发生什么呢?

      ssh1:
        remote_port: 50000
        proto:
            tcp: 22
      ssh2:
        remote_port: 50000
        proto:
            tcp: 22

ngrok启动会得到错误信息:
Server failed to allocate tunnel: [ctl:5332a293] [a87bd111bcc804508c835714c18a5664] Error binding TCP listener: listen tcp 0.0.0.0:50000: bind: address already in use

客户端ngrok在ClientModel control方法的main control loop中收到NewTunnel并处理该消息:

    case *msg.NewTunnel:
            if m.Error != "" {
                … …
            }

            tunnel := mvc.Tunnel{
                PublicUrl: m.Url,
                LocalAddr: reqIdToTunnelConfig[m.ReqId].Protocols[m.Protocol],
                Protocol:  c.protoMap[m.Protocol],
            }

            c.tunnels[tunnel.PublicUrl] = tunnel
            c.connStatus = mvc.ConnOnline
            c.Info("Tunnel established at %v", tunnel.PublicUrl)
            c.update()

五、Proxy Connection和Private Connection

到目前为止,我们知道了Control Connection:用于ngrok和ngrokd之间传输命令;Public Connection:外部发起的,尝试向内网服务建立的链接。

这节当中,我们要接触到Proxy Connection和Private Connection。

Proxy Connection以及Private Connection的建立过程如下:

前面ngrok和ngrokd的交互进行到了NewTunnel,这些数据都是通过之前已经建立的Control Connection上传输的。

ngrokd侧,NewControl方法的结尾有这样一行代码:

    // As a performance optimization, ask for a proxy connection up front
    c.out <- &msg.ReqProxy{}

服务端ngrokd在Control Connection上向ngrok发送了"ReqProxy"的消息,意为请求ngrok向ngrokd建立一条Proxy Connection,该链接将作为隧道数据流的承载者。

客户端ngrok在ClientModel control方法的main control loop中收到ReqProxy并处理该消息:

case *msg.ReqProxy:
            c.ctl.Go(c.proxy)

// Establishes and manages a tunnel proxy connection with the server
func (c *ClientModel) proxy() {
    if c.proxyUrl == "" {
        remoteConn, err = conn.Dial(c.serverAddr, "pxy", c.tlsConfig)
    }……

    err = msg.WriteMsg(remoteConn, &msg.RegProxy{ClientId: c.id})
    if err != nil {
        remoteConn.Error("Failed to write RegProxy: %v", err)
        return
    }
    … …
}

ngrok客户端收到ReqProxy后,创建一条新连接到ngrokd,该连接即为Proxy Connection。并且ngrok将RegProxy消息通过该新建立的Proxy Connection发到ngrokd,以便ngrokd将该Proxy Connection与对应的Control Connection以及tunnel关联在一起。

// ngrok服务端
func tunnelListener(addr string, tlsConfig *tls.Config) {
    …. …
    case *msg.RegProxy:
                NewProxy(tunnelConn, m)
    … …
}

到目前为止, tunnel、Proxy Connection都已经建立了,万事俱备,就等待Public发起Public connection到ngrokd了。

下面我们以Public发起一个http连接到ngrokd为例,比如我们通过curl 命令,向test.ngrok.me发起一次http请求。

前面说过,ngrokd在启动时默认启动了80和443端口的监听,并且与其他http/https隧道共同多路复用该端口(通过vhost机制)。ngrokd server对80端口的处理代码如下:

// ngrok/src/ngrok/server/main.go
func Main() {
    … …
 // listen for http
    if opts.httpAddr != "" {
        listeners["http"] =
          startHttpListener(opts.httpAddr, nil)
    }

    … …
}

startHttpListener针对每个连接,启动一个goroutine专门处理:

//ngrok/src/ngrok/server/http.go
func startHttpListener(addr string,
    tlsCfg *tls.Config) (listener *conn.Listener) {
    // bind/listen for incoming connections
    var err error
    if listener, err = conn.Listen(addr, "pub", tlsCfg);
        err != nil {
        panic(err)
    }

    proto := "http"
    if tlsCfg != nil {
        proto = "https"
    }

   … …
    go func() {
        for conn := range listener.Conns {
            go httpHandler(conn, proto)
        }
    }()

    return
}

// Handles a new http connection from the public internet
func httpHandler(c conn.Conn, proto string) {
    … …
    // let the tunnel handle the connection now
    tunnel.HandlePublicConnection(c)
}

我们终于看到server端处理public connection的真正方法了:

//ngrok/src/ngrok/server/tunnel.go
func (t *Tunnel) HandlePublicConnection(publicConn conn.Conn) {
    … …
    var proxyConn conn.Conn
    var err error
    for i := 0; i < (2 * proxyMaxPoolSize); i++ {
        // get a proxy connection
        if proxyConn, err = t.ctl.GetProxy();
           err != nil {
            … …
        }
        defer proxyConn.Close()
       … …

        // tell the client we're going to
        // start using this proxy connection
        startPxyMsg := &msg.StartProxy{
            Url:        t.url,
            ClientAddr: publicConn.RemoteAddr().String(),
        }

        if err = msg.WriteMsg(proxyConn, startPxyMsg);
            err != nil {
           … …
        }
    }

    … …
    // join the public and proxy connections
    bytesIn, bytesOut := conn.Join(publicConn, proxyConn)
    …. …
}

HandlePublicConnection通过选出的Proxy connection向ngrok client发送StartProxy信息,告知ngrok proxy启动。然后通过conn.Join方法将publicConn和proxyConn关联到一起。

// ngrok/src/ngrok/conn/conn.go
func Join(c Conn, c2 Conn) (int64, int64) {
    var wait sync.WaitGroup

    pipe := func(to Conn, from Conn, bytesCopied *int64) {
        defer to.Close()
        defer from.Close()
        defer wait.Done()

        var err error
        *bytesCopied, err = io.Copy(to, from)
        if err != nil {
            from.Warn("Copied %d bytes to %s before failing with error %v", *bytesCopied, to.Id(), err)
        } else {
            from.Debug("Copied %d bytes to %s", *bytesCopied, to.Id())
        }
    }

    wait.Add(2)
    var fromBytes, toBytes int64
    go pipe(c, c2, &fromBytes)
    go pipe(c2, c, &toBytes)
    c.Info("Joined with connection %s", c2.Id())
    wait.Wait()
    return fromBytes, toBytes
}

Join通过io.Copy实现public conn和proxy conn数据流的转发,单向被称作一个pipe,Join建立了两个Pipe,实现了双向转发,每个Pipe直到一方返回EOF或异常失败才会退出。后续在ngrok端,proxy conn和private conn也是通过conn.Join关联到一起的。

我们现在就来看看ngrok在收到StartProxy消息后是如何处理的。我们回到ClientModel的proxy方法中。在向ngrokd成功建立proxy connection后,ngrok等待ngrokd的StartProxy指令。

    // wait for the server to ack our register
    var startPxy msg.StartProxy
    if err = msg.ReadMsgInto(remoteConn, &startPxy);
             err != nil {
        remoteConn.Error("Server failed to write StartProxy: %v",
                   err)
        return
    }

一旦收到StartProxy,ngrok将建立一条private connection:
    // start up the private connection
    start := time.Now()
    localConn, err := conn.Dial(tunnel.LocalAddr, "prv", nil)
    if err != nil {
       … …
        return
    }
并将private connection和proxy connection通过conn.Join关联在一起,实现数据透明转发。

    m.connTimer.Time(func() {
        localConn := tunnel.Protocol.WrapConn(localConn,
             mvc.ConnectionContext{Tunnel: tunnel,
              ClientAddr: startPxy.ClientAddr})
        bytesIn, bytesOut := conn.Join(localConn, remoteConn)
        m.bytesIn.Update(bytesIn)
        m.bytesOut.Update(bytesOut)
        m.bytesInCount.Inc(bytesIn)
        m.bytesOutCount.Inc(bytesOut)
    })

这样一来,public connection上的数据通过proxy connection到达ngrok,ngrok再通过private connection将数据转发给本地启动的服务程序,从而实现所谓的内网穿透。从public视角来看,就像是与内网中的那个服务直接交互一样。

Go和HTTPS

8 Comments

近期在构思一个产品,考虑到安全性的原因,可能需要使用到HTTPS协议以及双向数字证书校验。之前只是粗浅接触过HTTP(使用Golang开 发微信系列)。对HTTPS的了解则始于那次自行搭建ngrok服务,在那个过程中照猫画虎地为服务端生成了一些私钥和证书,虽然结果是好 的:ngrok服务成功搭建起来了,但对HTTPS、数字证书等的基本原理并未求甚解。于是想趁这次的机会,对HTTPS做一些深度挖掘。主要途 径:翻阅网上资料、书籍,并利用golang编写一些实验examples。

一、HTTPS简介

日常生活中,我们上网用的最多的应用层协议就是HTTP协议了,直至目前全世界的网站中大多数依然只支持HTTP访问。

使用Go创建一个HTTP Server十分Easy,十几行代码就能搞定:

//gohttps/1-http/server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w,
     "Hi, This is an example of http service in golang!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

执行这段代码:
$ go run server.go

打开浏览器,在地址栏输入"http://localhost:8080", 你会看到“ Hi, This is an example of http service in golang!"输出到浏览器窗口。

不过HTTP毕竟是明文的,在这样一个不安全的世界里,随时存在着窃听(sniffer工具可以简单办到)、篡改甚至是冒充等风险,因此对于一些 对安全比较care的站点或服务,它们需要一种安全的HTTP协议,于是就有了HTTPS。

HTTPS只是我们在浏览器地址栏中看到协议标识,实际上它可以被理解为运行在SSL(Secure Sockets Layer)或TLS(Transport Layer Security)协议所构建的安全层之上的HTTP协议,协议的传输安全性以及内容完整性实际上是由SSL或TLS保证的。

关于HTTPS协议原理的详细说明,没有个百八十页是搞不定的,后续我会在各个实验之前将相关的原理先作一些说明,整体原理这里就不赘述了。有兴 趣的朋友可以参考以下资料:
1、《HTTP权威指南》第十四章
2、《图解HTTP》第七章
3、阮一峰老师的两篇博文“SSL/TLS协议运行机制的概述"和"图解SSL/TLS协议"。

二、实现一个最简单的HTTPS Web Server

Golang的标准库net/http提供了https server的基本实现,我们修改两行代码就能将上面的HTTP Server改为一个HTTPS Web Server:

// gohttps/2-https/server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w,
        "Hi, This is an example of https service in golang!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServeTLS(":8081", "server.crt",
                           "server.key", nil)
}

我们用http.ListenAndServeTLS替换掉了http.ListenAndServe,就将一个HTTP Server转换为HTTPS Web Server了。不过ListenAndServeTLS 新增了两个参数certFile和keyFile,需要我们传入两个文件路径。到这里,我们不得不再学习一点HTTPS协议的原理了。不过为 了让这个例子能先Run起来,我们先执行下面命令,利用openssl生成server.crt和server.key文件,供程序使用,原 理后续详述:

$openssl genrsa -out server.key 2048

Generating RSA private key, 2048 bit long modulus
…………….+++
……………+++
e is 65537 (0×10001)

$openssl req -new -x509 -key server.key -out server.crt -days 365

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
—–
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:localhost
Email Address []:

执行程序:go run server.go
通过浏览器访问:https://localhost:8081,chrome浏览器会显示如下画面:

忽略继续后,才能看到"Hi, This is an example of https service in golang!"这个结果输出在窗口上。

也可以使用curl工具验证这个HTTPS server:

curl -k https://localhost:8081
Hi, This is an example of http service in golang!

注意如果不加-k,curl会报如下错误:

$curl https://localhost:8081
curl: (60) SSL certificate problem: Invalid certificate chain
More details here: http://curl.haxx.se/docs/sslcerts.html

curl performs SSL certificate verification by default, using a "bundle"
 of Certificate Authority (CA) public keys (CA certs). If the default
 bundle file isn't adequate, you can specify an alternate file
 using the –cacert option.
If this HTTPS server uses a certificate signed by a CA represented in
 the bundle, the certificate verification probably failed due to a
 problem with the certificate (it might be expired, or the name might
 not match the domain name in the URL).
If you'd like to turn off curl's verification of the certificate, use
 the -k (or –insecure) option.

三、非对称加密和数字证书

前面说过,HTTPS的数据传输是加密的。实际使用中,HTTPS利用的是对称与非对称加密算法结合的方式。

对称加密,就是通信双方使用一个密钥,该密钥既用于数据加密(发送方),也用于数据解密(接收方)。
非对称加密,使用两个密钥。发送方使用公钥(公开密钥)对数据进行加密,数据接收方使用私钥对数据进行解密。

实际操作中,单纯使用对称加密或单纯使用非对称加密都会存在一些问题,比如对称加密的密钥管理复杂;非对称加密的处理性能低、资源占用高等,因 此HTTPS结合了这两种方式。

HTTPS服务端在连接建立过程(ssl shaking握手协议)中,会将自身的公钥发送给客户端。客户端拿到公钥后,与服务端协商数据传输通道的对称加密密钥-对话密钥,随后的这个协商过程则 是基于非对称加密的(因为这时客户端已经拿到了公钥,而服务端有私钥)。一旦双方协商出对话密钥,则后续的数据通讯就会一直使用基于该对话密 钥的对称加密算法了。

上述过程有一个问题,那就是双方握手过程中,如何保障HTTPS服务端发送给客户端的公钥信息没有被篡改呢?实际应用中,HTTPS并非直接 传输公钥信息,而是使用携带公钥信息的数字证书来保证公钥的安全性和完整性。

数字证书,又称互联网上的"身份证",用于唯一标识一个组织或一个服务器的,这就好比我们日常生活中使用的"居民身份证",用于唯一标识一个 人。服务端将数字证书传输给客户端,客户端如何校验这个证书的真伪呢?我们知道居民身份证是由国家统一制作和颁发的,个人向户 口所在地公安机关申请,国家颁发的身份证才具有法律 效力,任何地方这个身份证都是有效和可被接纳的。大悦城的会员卡也是一种身份标识,但你若用大悦城的会员卡去买机票,对不起, 不卖。航空公司可不认大悦城的会员卡,只认居民身份证。网站的证书也是同样的道理。一般来说数字证书从受信的权威证书授权机构 (Certification Authority,证书授权机构)买来的(免费的很少)。一般浏览器在出厂时就内置了诸多知名CA(如Verisign、GoDaddy、美国国防部、 CNNIC等)的数字证书校验方法,只要是这些CA机构颁发的证书,浏览器都能校验。对于CA未知的证书,浏览器则会报错(就像上面那个截图一 样)。主流浏览器都有证书管理功能,但鉴于这些功能比较高级,一般用户是不用去关心的。

初步原理先讲到这,我们再回到上面的例子。

四、服务端私钥与证书

接上面的例子,我们来说说服务端私钥与证书的生成。

go的http.ListenAndServeTLS需要两个特别参数,一个是服务端的私钥 文件路径,另外一个是服务端的数字证书文件路径。在测试环境,我们没有必要花钱去购买什么证书,利用openssl工具,我们可以自己生成相 关私钥和自签发的数字证书。

openssl genrsa -out server.key 2048 用于生成服务端私钥文件server.key,后面的参数2048单位是bit,是私钥的长度。
openssl生成的私钥中包含了公钥的信息,我们可以根据私钥生成公钥:

$openssl rsa -in server.key -out server.key.public

我们也可以根据私钥直接生成自签发的数字证书:

$openssl req -new -x509 -key server.key -out server.crt -days 365

server.key和server.crt将作为ListenAndServeTLS的两个输入参数。

我们编写一个Go程序来尝试与这个HTTPS server建立连接并通信。

//gohttps/4-https/client1.go
package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("https://localhost:8081")
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

运行这个client,我们得到如下错误:

$go run client1.go
error: Get https://localhost:8081: x509: certificate signed by unknown authority

此时服务端也给出了错误日志提示:
2015/04/30 16:03:31 http: TLS handshake error from 127.0.0.1:62004: remote error: bad certificate

显然从客户端日志来看,go实现的Client端默认也是要对服务端传过来的数字证书进行校验的,但客户端提示:这个证书是由不知名CA签发 的!

我们可以修改一下client1.go的代码,让client端略过对证书的校验:

//gohttps/4-https/client2.go
package main

import (
    "crypto/tls"
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    tr := &http.Transport{
        TLSClientConfig:    &tls.Config{InsecureSkipVerify: true},
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://localhost:8081")

    if err != nil {
        fmt.Println("error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

通过设置tls.Config的InsecureSkipVerify为true,client将不再对服务端的证书进行校验。执行后的结果 也证实了这一点:
$go run client2.go
Hi, This is an example of http service in golang!

五、对服务端的证书进行校验

多数时候,我们需要对服务端的证书进行校验,而不是像上面client2.go那样忽略这个校验。我大脑中的这个产品需要服务端和客户端双向 校验,我们先来看看如何能让client端实现对Server端证书的校验呢?

client端校验证书的原理是什么呢?回想前面我们提到的浏览器内置了知名CA的相关信息,用来校验服务端发送过来的数字证书。那么浏览器 存储的到底是CA的什么信息呢?其实是CA自身的数字证书(包含CA自己的公钥)。而且为了保证CA证书的真实性,浏览器是在出厂时就内置了 这些CA证书的,而不是后期通过通信的方式获取的。CA证书就是用来校验由该CA颁发的数字证书的。

那么如何使用CA证书校验Server证书的呢?这就涉及到数字证书到底是什么了!

我们可以通过浏览器中的"https/ssl证书管理"来查看证书的内容,一般服务器证书都会包含诸如站点的名称和主机名、公钥、签发机构 (CA)名称和来自签发机构的签名等。我们重点关注这个来自签发机构的签名,因为对于证书的校验,就是使用客户端CA证书来验证服务端证书的签名是否这 个CA签的。

通过签名验证我们可以来确认两件事:
1、服务端传来的数字证书是由某个特定CA签发的(如果是self-signed,也无妨),数字证书中的签名类似于日常生活中的签名,首先 验证这个签名签的是Tony Bai,而不是Tom Bai, Tony Blair等。
2、服务端传来的数字证书没有被中途篡改过。这类似于"Tony Bai"有无数种写法,这里验证必须是我自己的那种写法,而不是张三、李四写的"Tony Bai"。

一旦签名验证通过,我们因为信任这个CA,从而信任这个服务端证书。由此也可以看出,CA机构的最大资本就是其信用度。

CA在为客户签发数字证书时是这样在证书上签名的:

数字证书由两部分组成:
1、C:证书相关信息(对象名称+过期时间+证书发布者+证书签名算法….)
2、S:证书的数字签名

其中的数字签名是通过公式S = F(Digest(C))得到的。

Digest为摘要函数,也就是 md5、sha-1或sha256等单向散列算法,用于将无限输入值转换为一个有限长度的“浓缩”输出值。比如我们常用md5值来验证下载的大文件是否完 整。大文件的内容就是一个无限输入。大文件被放在网站上用于下载时,网站会对大文件做一次md5计算,得出一个128bit的值作为大文件的 摘要一同放在网站上。用户在下载文件后,对下载后的文件再进行一次本地的md5计算,用得出的值与网站上的md5值进行比较,如果一致,则大 文件下载完好,否则下载过程大文件内容有损坏或源文件被篡改。

F为签名函数。CA自己的私钥是唯一标识CA签名的,因此CA用于生成数字证书的签名函数一定要以自己的私钥作为一个输入参数。在RSA加密 系统中,发送端的解密函数就是一个以私钥作 为参数的函数,因此常常被用作签名函数使用。签名算法是与证书一并发送给接收 端的,比如apple的一个服务的证书中关于签名算法的描述是“带 RSA 加密的 SHA-256 ( 1.2.840.113549.1.1.11 )”。因此CA用私钥解密函数作为F,对C的摘要进行运算得到了客户数字证书的签名,好比大学毕业证上的校长签名,所有毕业证都是校长签发的。

接收端接收服务端数字证书后,如何验证数字证书上携带的签名是这个CA的签名呢?接收端会运用下面算法对数字证书的签名进行校验:
F'(S) ?= Digest(C)

接收端进行两个计算,并将计算结果进行比对:
1、首先通过Digest(C),接收端计算出证书内容(除签名之外)的摘要。
2、数字证书携带的签名是CA通过CA密钥加密摘要后的结果,因此接收端通过一个解密函数F'对S进行“解密”。RSA系统中,接收端使用 CA公钥对S进行“解密”,这恰是CA用私钥对S进行“加密”的逆过程。

将上述两个运算的结果进行比较,如果一致,说明签名的确属于该CA,该证书有效,否则要么证书不是该CA的,要么就是中途被人篡改了。

但对于self-signed(自签发)证书来说,接收端并没有你这个self-CA的数字证书,也就是没有CA公钥,也就没有办法对数字证 书的签名进行验证。因此如果要编写一个可以对self-signed证书进行校验的接收端程序的话,首先我们要做的就是建立一个属于自己的 CA,用该CA签发我们的server端证书,并将该CA自身的数字证书随客户端一并发布。

这让我想起了在《搭建自己的ngrok服务》一文中为ngrok服务端、客户端生成证书的那几个步骤,我们来重温并分析一下每一步都在做什么。

(1)openssl genrsa -out rootCA.key 2048
(2)openssl req -x509 -new -nodes -key rootCA.key -subj "/CN=*.tunnel.tonybai.com" -days 5000 -out rootCA.pem

(3)openssl genrsa -out device.key 2048
(4)openssl req -new -key device.key -subj "/CN=*.tunnel.tonybai.com" -out device.csr
(5)openssl x509 -req -in device.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out device.crt -days 5000

(6)cp rootCA.pem assets/client/tls/ngrokroot.crt
(7)cp device.crt assets/server/tls/snakeoil.crt
(8)cp device.key assets/server/tls/snakeoil.key

自己搭建ngrok服务,客户端要验证服务端证书,我们需要自己做CA,因此步骤(1)和步骤(2)就是生成CA自己的相关信息。
步骤(1) ,生成CA自己的私钥 rootCA.key
步骤(2),根据CA自己的私钥生成自签发的数字证书,该证书里包含CA自己的公钥。

步骤(3)~(5)是用来生成ngrok服务端的私钥和数字证书(由自CA签发)。
步骤(3),生成ngrok服务端私钥。
步骤(4),生成Certificate Sign Request,CSR,证书签名请求。
步骤(5),自CA用自己的CA私钥对服务端提交的csr进行签名处理,得到服务端的数字证书device.crt。

步骤(6),将自CA的数字证书同客户端一并发布,用于客户端对服务端的数字证书进行校验。
步骤(7)和步骤(8),将服务端的数字证书和私钥同服务端一并发布。

接下来我们来验证一下客户端对服务端数字证书进行验证(gohttps/5-verify-server-cert)!

首先我们来建立我们自己的CA,需要生成一个CA私钥和一个CA的数字证书:

$openssl genrsa -out ca.key 2048
Generating RSA private key, 2048 bit long modulus
……….+++
………………………….+++
e is 65537 (0×10001)

$openssl req -x509 -new -nodes -key ca.key -subj "/CN=tonybai.com" -days 5000 -out ca.crt

接下来,生成server端的私钥,生成数字证书请求,并用我们的ca私钥签发server的数字证书:

openssl genrsa -out server.key 2048
Generating RSA private key, 2048 bit long modulus
….+++
…………………….+++
e is 65537 (0×10001)

$openssl req -new -key server.key -subj "/CN=localhost" -out server.csr

$openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 5000
Signature ok
subject=/CN=localhost
Getting CA Private Key

现在我们的工作目录下有如下一些私钥和证书文件:
CA:
    私钥文件 ca.key
    数字证书 ca.crt

Server:
    私钥文件 server.key
    数字证书 server.crt

接下来,我们就来完成我们的程序。

Server端的程序几乎没有变化:

// gohttps/5-verify-server-cert/server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w,
        "Hi, This is an example of http service in golang!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServeTLS(":8081",
        "server.crt", "server.key", nil)
}

client端程序变化较大,由于client端需要验证server端的数字证书,因此client端需要预先加载ca.crt,以用于服务端数字证书的校验:

// gohttps/5-verify-server-cert/client.go
package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    pool := x509.NewCertPool()
    caCertPath := "ca.crt"

    caCrt, err := ioutil.ReadFile(caCertPath)
    if err != nil {
        fmt.Println("ReadFile err:", err)
        return
    }
    pool.AppendCertsFromPEM(caCrt)

    tr := &http.Transport{
        TLSClientConfig: &tls.Config{RootCAs: pool},
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://localhost:8081")
    if err != nil {
        fmt.Println("Get error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

运行server和client:

$go run server.go

go run client.go
Hi, This is an example of http service in golang!

六、对客户端的证书进行校验(双向证书校验)

服务端可以要求对客户端的证书进行校验,以更严格识别客户端的身份,限制客户端的访问。

要对客户端数字证书进行校验,首先客户端需要先有自己的证书。我们以上面的例子为基础,生成客户端的私钥与证书。

$openssl genrsa -out client.key 2048
Generating RSA private key, 2048 bit long modulus
………………..+++
………………..+++
e is 65537 (0×10001)
$openssl req -new -key client.key -subj "/CN=tonybai_cn" -out client.csr
$openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 5000
Signature ok
subject=/CN=tonybai_cn
Getting CA Private Key

接下来我们来改造我们的程序,首先是server端。

首先server端需要要求校验client端的数字证书,并且加载用于校验数字证书的ca.crt,因此我们需要对server进行更加灵活的控制:

// gohttps/6-dual-verify-certs/server.go
package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io/ioutil"
    "net/http"
)

type myhandler struct {
}

func (h *myhandler) ServeHTTP(w http.ResponseWriter,
                   r *http.Request) {
    fmt.Fprintf(w,
        "Hi, This is an example of http service in golang!\n")
}

func main() {
    pool := x509.NewCertPool()
    caCertPath := "ca.crt"

    caCrt, err := ioutil.ReadFile(caCertPath)
    if err != nil {
        fmt.Println("ReadFile err:", err)
        return
    }
    pool.AppendCertsFromPEM(caCrt)

    s := &http.Server{
        Addr:    ":8081",
        Handler: &myhandler{},
        TLSConfig: &tls.Config{
            ClientCAs:  pool,
            ClientAuth: tls.RequireAndVerifyClientCert,
        },
    }

    err = s.ListenAndServeTLS("server.crt", "server.key")
    if err != nil {
        fmt.Println("ListenAndServeTLS err:", err)
    }
}

可以看出代码通过将tls.Config.ClientAuth赋值为tls.RequireAndVerifyClientCert来实现Server强制校验client端证书。ClientCAs是用来校验客户端证书的ca certificate。

Client端变化也很大,需要加载client.key和client.crt用于server端连接时的证书校验:

// gohttps/6-dual-verify-certs/client.go

package main
import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    pool := x509.NewCertPool()
    caCertPath := "ca.crt"

    caCrt, err := ioutil.ReadFile(caCertPath)
    if err != nil {
        fmt.Println("ReadFile err:", err)
        return
    }
    pool.AppendCertsFromPEM(caCrt)

    cliCrt, err := tls.LoadX509KeyPair("client.crt", "client.key")
    if err != nil {
        fmt.Println("Loadx509keypair err:", err)
        return
    }

    tr := &http.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs:      pool,
            Certificates: []tls.Certificate{cliCrt},
        },
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://localhost:8081")
    if err != nil {
        fmt.Println("Get error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

好了,让我们来试着运行一下这两个程序,结果如下:

$go run server.go
2015/04/30 22:13:33 http: TLS handshake error from 127.0.0.1:53542:
tls: client's certificate's extended key usage doesn't permit it to be
used for client authentication

$go run client.go
Get error: Get https://localhost:8081: remote error: handshake failure

失败了!从server端的错误日志来看,似乎是client端的client.crt文件不满足某些条件。

根据server端的错误日志,搜索了Golang的源码,发现错误出自crypto/tls/handshake_server.go。

k := false
for _, ku := range certs[0].ExtKeyUsage {
    if ku == x509.ExtKeyUsageClientAuth {
        ok = true
        break
    }
}
if !ok {
    c.sendAlert(alertHandshakeFailure)
    return nil, errors.New("tls: client's certificate's extended key usage doesn't permit it to be used for client authentication")
}

大致判断是证书中的ExtKeyUsage信息应该包含clientAuth。翻看openssl的相关资料,了解到自CA签名的数字证书中包含的都是一些basic的信息,根本没有ExtKeyUsage的信息。我们可以用命令来查看一下当前client.crt的内容:

$ openssl x509 -text -in client.crt -noout
Certificate:
    Data:
        Version: 1 (0×0)
        Serial Number:
            d6:e3:f6:fa:ae:65:ed:df
        Signature Algorithm: sha1WithRSAEncryption
        Issuer: CN=tonybai.com
        Validity
            Not Before: Apr 30 14:11:34 2015 GMT
            Not After : Jan  6 14:11:34 2029 GMT
        Subject: CN=tonybai_cn
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
            RSA Public Key: (2048 bit)
                Modulus (2048 bit):
                    00:e4:12:22:50:75:ae:b2:8a:9e:56:d5:f3:7d:31:
                    7b:aa:75:5d:3f:90:05:4e:ff:ed:9a:0a:2a:75:15:
                    … …
                Exponent: 65537 (0×10001)
    Signature Algorithm: sha1WithRSAEncryption
        76:3b:31:3e:9d:b0:66:ad:c0:03:d4:19:c6:f2:1a:52:91:d6:
        13:31:3a:c5:d5:58:ea:42:1d:b7:33:b8:43:a8:a8:28:91:ac:
         … …

而偏偏golang的tls又要校验ExtKeyUsage,如此我们需要重新生成client.crt,并在生成时指定extKeyUsage。经过摸索,可以用如下方法重新生成client.crt:

1、创建文件client.ext
内容:
extendedKeyUsage=clientAuth

2、重建client.crt

$openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -extfile client.ext -out client.crt -days 5000
Signature ok
subject=/CN=tonybai_cn
Getting CA Private Key

再通过命令查看一下新client.crt:

看到输出的文本中多了这么几行:
        X509v3 extensions:
            X509v3 Extended Key Usage:
                TLS Web Client Authentication

这说明client.crt的extended key usage已经添加成功了。我们再来执行一下server和client:

$ go run client.go
Hi, This is an example of http service in golang!

client端证书验证成功,也就是说双向证书验证均ok了。

七、小结

通过上面的例子可以看出,使用golang开发https相关程序十分便利,Golang标准库已经实现了TLS 1.2版本协议。上述所有example代码均放在我的github上的experiments/gohttps中。

Older Entries