Life of xhu

gin 源码阅读笔记

Dec 21, 2017  |  Go

今天来看一个 Go 项目的源码: gin: Live reload utility for Go web servers.

这个项目的简介是实现 Go web server 的实时重载, 现在这个博客的 dev 模式就是使用这个项目启动的, 启动脚本如下:

gin --excludeDir archives --excludeDir node_modules --excludeDir app/assets --all --port 8283 --appPort 13109

忽略命令中的一串参数, 这行脚本的作用是, 整个项目对外暴露 8283, 请求会被重定向到 13109 端口上, 然后 main.go 是 go server 入口并且实现热重载, 这样分析之后我们我们可以把这个问题分成两个部分:

  1. 怎么在内部启动 go server 并做 http 数据包的转发
  2. 怎么一个检测文件改动并重启内部服务器

带着这两个问题, 我们直接开始看源码吧, 以下代码都省略了无关代码:

// main.go
func MainAction(c *cli.Context) {
    os.Setenv("PORT", appPort)

  wd, err := os.Getwd()

  buildArgs, err := shellwords.Parse(c.GlobalString("buildArgs"))

  buildPath := c.GlobalString("build")
  builder := gin.NewBuilder(buildPath, c.GlobalString("bin"), c.GlobalBool("godep"), wd, buildArgs)
  runner := gin.NewRunner(filepath.Join(wd, builder.Binary()), c.Args()...)
  runner.SetWriter(os.Stdout)
  proxy := gin.NewProxy(builder, runner)

  config := &gin.Config{
    Laddr:    laddr,
    Port:     port,
    ProxyTo:  "http://localhost:" + appPort,
    KeyFile:  keyFile,
    CertFile: certFile,
  }

  err = proxy.Run(config)

  shutdown(runner)

  build(builder, runner, logger)

  // scan for changes
  scanChanges(c.GlobalString("path"), c.GlobalStringSlice("excludeDir"), all, func(path string) {
    runner.Kill()
    build(builder, runner, logger)
  })
}

在这段入口里, 首先把需要转发的端口放到了环境变量里, 然后取了三个在编译 go server 时需要用到的常量:

  1. wd: 当前的工作目录;
  2. buildArgs: 构建参数;
  3. buildPath: 构建 go server 的路径.

接下来我们可以看到, 整个 gin 项目把代码分成了三个模块, 分别是:

  1. builder: 使用上面的三个常量来构建内部服务器;

     // lib/builder.go
     type builder struct {
       dir       string                  // 构建的目录
       binary    string                  // 构建得到的二进制文件
       wd        string                  // 当前工作目录
       buildArgs []string                // 构建参数
     }
  2. runner: 负责运行和停止内部服务器;

     // lib/runner.go
     type runner struct {
       bin       string                  // builder 构建的二进制文件路径
       command   *exec.Cmd               // 使用二进制文件得到的 Command 实例
       starttime time.Time               // 当前内部服务器 进程开始的时间
     }
  3. proxy: 将外部的 http/https 请求转发到内部的 go server 上.

     // lib/proxy.go
     type Proxy struct {
       listener net.Listener             // 监听网络请求
       proxy    *httputil.ReverseProxy   // ReverseProxy 实例, 实现反响代码数据转发
       builder  Builder                  // Builder 接口实例
       runner   Runner                   // Runner 接口实例
       to       *url.URL                 // 反响代理的地址
     }

下面就是针对这三个模块的 new 函数:

  1. NewBuilder: 编译内部 server, 获得二进制文件信息, 返回实现了 Builder 接口的 builder 实例;
  2. NewRunner: 使用 builder 信息生成 exec.Command 实例, 返回实现了 Runner 接口的 runner 实例;
  3. NewProxy: 使用 builderrunner 生成 Proxy 实例, 其他字段暂时置为空.

接下俩就是调用 Run 方法来启动 proxy, 实现网络请求的转发:

config := &gin.Config{
  Laddr:    laddr,
  Port:     port,
  ProxyTo:  "http://localhost:" + appPort,
  KeyFile:  keyFile,
  CertFile: certFile,
}

err = proxy.Run(config)

下面我们来看一下 Run 方法的具体实现:

// proxy.go
func (p *Proxy) Run(config *Config) error {
  url, err := url.Parse(config.ProxyTo)
  p.proxy = httputil.NewSingleHostReverseProxy(url)
  p.to = url

  server := http.Server{Handler: http.HandlerFunc(p.defaultHandler)}

  // 省略 https 的处理代码
  p.listener, err = net.Listen("tcp", fmt.Sprintf("%s:%d", config.Laddr, config.Port))

  go server.Serve(p.listener)

  return nil
}

func (p *Proxy) defaultHandler(res http.ResponseWriter, req *http.Request) {
  errors := p.builder.Errors()
  if len(errors) > 0 {
    res.Write([]byte(errors))
  } else {
    p.runner.Run()
    p.proxy.ServeHTTP(res, req)
  }
}

也就是说, proxy 实例本质上是一个简单的 http 服务器, 这个服务器的请求都会打到 defaultHanlder 上, 而这个 handler 的作用有两个, 那就是在有请求到达的时候:

  1. 通过 runner.Run 方法, 确保内部服务器 已经在运行;
  2. 通过 *httputil.ReverseProxy#ServeHTTP 方法, 将请求转发到内部服务器 上.

我们在来看一下运行内部服务器 的 runner.Run 方法:

func (r *runner) Run() (*exec.Cmd, error) {
  if r.command == nil || r.Exited() {
    err := r.runBin()
    time.Sleep(250 * time.Millisecond)
    return r.command, err
  } else {
    return r.command, nil
  }

}

func (r *runner) runBin() error {
  r.command = exec.Command(r.bin, r.args...)
  err = r.command.Start()
  r.starttime = time.Now()

  go r.command.Wait()

  return nil
}

我们可以看到 runner.Run 方法其实是调用了内部的 runBin 方法, 在 runBin 方法里通过 os/exec 包生成了 *exec.CMD 对象, 通过 Start 方法执行之后, 会在一个新的协程里执行 Wait 方法, 使后台的服务器进程不会阻塞主进程.

回到 Run 方法中, 在启动内部服务器 之后还有一个 250ms 的停顿, 应该是等待服务器启动的时间.

到这里我们就算是弄明白了上文中的第一个问题, 简单的说, 就是通过 os/exec 来进行内部服务器启动, 通过 net/http/httputil 进行 http 请求转发, 当然通过阅读源码我们也可以发现一些实现上的小瑕疵:

  1. 在 build 完二进制文件, 只有当有 http 请求进来的时候, 才会执行这个二进制文件启动内部服务器, 而如果内部服务器启动时间大于 250ms, 那么修改文件之后的第一次请求总是会失败, 这也符合实际使用时的表现;
  2. runner 中的 runBin 只适用于内部服务器不带参数执行的情况, 因为 r.args 使用的是 gin 本身的参数列表, 并不一定能被内部服务器识别, 如果要实现这个, 只能给 gin 加一个新的参数了比如 executeArgs.

那么我们看第二个问题, 再次回到 main.go 文件:

// main.go
scanChanges(c.GlobalString("path"), c.GlobalStringSlice("excludeDir"), all, func(path string) {
  runner.Kill()
  build(builder, runner, logger)
}}

func scanChanges(watchPath string, excludeDirs []string, allFiles bool, cb scanCallback) {
  for {
    filepath.Walk(watchPath, func(path string, info os.FileInfo, err error) error {
      if path == ".git" && info.IsDir() {
        return filepath.SkipDir
      }
      for _, x := range excludeDirs {
        if x == path {
          return filepath.SkipDir
        }
      }

      // ignore hidden files
      if filepath.Base(path)[0] == '.' {
        return nil
      }

      if (allFiles || filepath.Ext(path) == ".go") && info.ModTime().After(startTime) {
        cb(path)
        startTime = time.Now()
        return errors.New("done")
      }

      return nil
    })
    time.Sleep(500 * time.Millisecond)
  }
}

这里其实就比较简单了, scanChanges 的内部实现其实是用一个间隔为半秒的死循环在不停的通过 filepath.Walk 方法来遍历参数 path 设定的目录, 如果一个文件满足下列条件:

  1. 不是 .git 目录;
  2. 不在 execludeDir 参数中;
  3. 不是隐藏文件;
  4. 扩展名是 .go 或者运行时带了 --all 参数;
  5. 文件在内部服务器启动后被修改过.

那么我们就执行回调函数 cb 并重置 startTime. 而回调函数中的内容就是终止当前内部服务器进程和重新 build. 而终止进程的 Kill 方法实现如下:

func (r *runner) Kill() error {
  if r.command != nil && r.command.Process != nil {
    done := make(chan error)
    go func() {
      r.command.Wait()
      close(done)
    }()

    select {
    case <-time.After(3 * time.Second):
      if err := r.command.Process.Kill(); err != nil {
        log.Println("failed to kill: ", err)
      }
    case <-done:
    }
    r.command = nil
  }

  return nil
}

这里做了一个超时处理, 如果进程在调用 Wait 方法 3 秒之后仍然没有响应, 就会被 Kill 方法来终止, 并且打印出命令执行的错误. 而回调中的下一步 build 就会重新生成内部服务器的二进制文件, 接下来有 http 请求的话, 就会进入上面 proxy 中的 defaultHandler, 进而执行 runner.Run 方法重新启动内部进服务器.

具体的流程图可以用下图来表示:

而这次阅读我们也学到了一些非常有用的内部库的用法:

package struct func description
net/http/httputil ReverseProxy ServeHTTP 反向代理 http 请求
path/filepath - Walk 遍历一个目录
os/exec CMD Start/Wait 执行一个命令并且等待输出, 可以用来执行耗时或者被挂起的命令

refs:

Copyright © 2018 - xhu - Powered by Gin,jQuery,Animate.css,Semantic UI

fammhF iBSbYp hKTLac bcBwio jMfvbc gFwJbt ikkyVr bPjPUf cofShm ekvxDO a"> /* sc-component-id: sc-bdVaJa */ .sc-bdVaJa {} .jTcPxY{height:100%;overflow:auto;} /* sc-component-id: sc-bwzfXH */ .sc-bwzfXH {} .iYgZUj{color:#BBB;margin:10px 0 20px 0;} /* sc-component-id: sc-htpNat */ .sc-htpNat {} .knYzzU{position:relative;padding:10px 0 10px 0;width:calc(100% - 10px);height:50px;-webkit-transition:-webkit-transform 0.6s;-webkit-transition:transform 0.6s;transition:transform 0.6s;} .knYzzU:hover{background-color:#EEE;-webkit-transform:translate(10px,0);-ms-transform:translate(10px,0);transform:translate(10px,0);} /* sc-component-id: sc-bxivhb */ .sc-bxivhb {} .fpBhvp{position:absolute;color:#888;} /* sc-component-id: sc-ifAKCX */ .sc-ifAKCX {} .eMFtQj{position:absolute;top:7px;left:60px;color:#666;font-size:18px;line-height:24px;-webkit-text-decoration:underline;text-decoration:underline;} .eMFtQj:hover{color:#666;-webkit-text-decoration:underline;text-decoration:underline;}.kDHEfQ{position:absolute;top:7px;left:60px;color:#666;font-size:18px;line-height:24px;-webkit-text-decoration:underline;text-decoration:underline;} .kDHEfQ:hover{color:#666;-webkit-text-decoration:underline;text-decoration:underline;} /* sc-component-id: sc-EHOje */ .sc-EHOje {} .dwejLW{display:inline-block;text-align:center;width:110px;font-size:14px;-webkit-letter-spacing:2px;-moz-letter-spacing:2px;-ms-letter-spacing:2px;letter-spacing:2px;color:#666;border:1px solid #DADADA;background:#FFF;padding:7px 8px 7px 10px;margin:30px 20px 50px 0;} .dwejLW:hover{background:#EEE;} /* sc-component-id: sc-bZQynM */ .sc-bZQynM {} .jMfvbc{height:100%;overflow:auto;} /* sc-component-id: sc-gzVnrw */ .sc-gzVnrw {} .gFwJbt{margin:40px 0;text-align:center;font-weight:500;color:#646464;font-family:"Lato",sans-serif;} /* sc-component-id: sc-htoDjs */ .sc-htoDjs {} .ikkyVr{margin:20px 0 40px 0 !important;} /* sc-component-id: sc-dnqmqq */ .sc-dnqmqq {} .bPjPUf a{color:#A3717F;} /* sc-component-id: sc-iwsKbI */ .sc-iwsKbI {} .cofShm{-webkit-letter-spacing:.2px;-moz-letter-spacing:.2px;-ms-letter-spacing:.2px;letter-spacing:.2px;font-size:15px;color:#555;} .cofShm h1,.cofShm h2,.cofShm h3,.cofShm h4,.cofShm h5,.cofShm h6{margin:20px 0 15px;font-weight:500;color:#646464;} .cofShm p,.cofShm li{line-height:1.9;} .cofShm blockquote{padding:15px 0 15px 15px;margin:0 0 18px;border-left:5px solid #D1D0CE;line-height:28px;font-weight:normal;font-size:15px;font-style:italic;color:#696969;} .cofShm img{max-width:100%;} .cofShm a{color:#4183c4;-webkit-text-decoration:none;text-decoration:none;} .cofShm hr{border:0;color:#ddd;background-color:#ddd;height:2px;margin:5px 0 19px 0;} .cofShm code{display:inline;word-wrap:break-word;font-size:14px;color:rgb(85,85,85);background:rgb(255,255,255);border-width:1px;border-style:solid;border-color:rgb(221,221,221);border-image:initial;border-radius:4px;padding:1px 3px;margin:-1px 1px 0px;} .cofShm pre code{display:block;font-size:11.8px;line-height:18px;font-weight:12px;-webkit-letter-spacing:.5px;-moz-letter-spacing:.5px;-ms-letter-spacing:.5px;letter-spacing:.5px;margin:0 0 20px 0;padding:15px !important;background-color:#f7f7f7 !important;border-width:0;} /* sc-component-id: sc-gZMcBi */ .sc-gZMcBi {} .ekvxDO{padding:18px 0 54px 0;}.a{padding:18px 0 54px 0;} /* sc-component-id: sc-VigVT */ .sc-VigVT {} .hbtmav{width:100%;height:100%;} /* sc-component-id: sc-jTzLTM */ .sc-jTzLTM {} .bchbfv{padding:60px 0 0 0;min-height:calc(100% - 60px);width:720px;margin-left:calc((100% - 720px) / 2);margin-right:calc((100% - 720px) / 2);} @media screen and (max-width:720px){.bchbfv{width:100%;margin-left:0;margin-right:0;padding:60px 15px 0 15px;}}.iZSXmW{padding:60px 0 0 0;min-height:calc(100% - 60px);width:720px;margin-left:calc((100% - 720px) / 2);margin-right:calc((100% - 720px) / 2);} @media screen and (max-width:720px){.iZSXmW{width:100%;margin-left:0;margin-right:0;padding:60px 15px 0 15px;}} /* sc-component-id: sc-fjdhpX */ .sc-fjdhpX {} .hzPhWj{margin:0 0 45px 0;font-size:18px;} /* sc-component-id: sc-keyframes-blDzHL */ @-webkit-keyframes blDzHL{0%{opacity:1;}50%{opacity:0;}100%{opacity:1;}} @keyframes blDzHL{0%{opacity:1;}50%{opacity:0;}100%{opacity:1;}} /* sc-component-id: sc-jzJRlG */ .sc-jzJRlG {} .kkvUer{margin:0 0 0 6px;-webkit-animation:blDzHL 1.2s infinite linear;animation:blDzHL 1.2s infinite linear;} /* sc-component-id: sc-cSHVUG */ .sc-cSHVUG {} .iBSbYp{background-color:#FFFEEC;height:60px;padding:18px 0 0 0;text-align:center;color:#444444;opacity:.8;-webkit-letter-spacing:.8px;-moz-letter-spacing:.8px;-ms-letter-spacing:.8px;letter-spacing:.8px;font-family:Lato,sans-serif;} @media screen and (max-width:720px){.iBSbYp{height:60px;padding:10px 0 18px 0;}}.hKTLac{background-color:#FFFEEC;height:60px;padding:18px 0 0 0;text-align:center;color:#444444;opacity:.8;-webkit-letter-spacing:.8px;-moz-letter-spacing:.8px;-ms-letter-spacing:.8px;letter-spacing:.8px;font-family:Lato,sans-serif;} @media screen and (max-width:720px){.hKTLac{height:60px;padding:10px 0 18px 0;}}.bcBwio{background-color:#FFFEEC;height:60px;padding:18px 0 0 0;text-align:center;color:#444444;opacity:.8;-webkit-letter-spacing:.8px;-moz-letter-spacing:.8px;-ms-letter-spacing:.8px;letter-spacing:.8px;font-family:Lato,sans-serif;} @media screen and (max-width:720px){.bcBwio{height:60px;padding:10px 0 18px 0;}} /* sc-component-id: sc-kAzzGY */ .sc-kAzzGY {} .bpqJVJ{position:fixed;right:-10px;bottom:70px;} /* sc-component-id: sc-chPdSV */ .sc-chPdSV {} .iGXldS{display:none;cursor:pointer;width:60px;height:60px;text-indent:100%;margin:0 0 0 -3px;box-shadow:0 0 10px rgba(0,0,0,0.05);background:rgba(87,218,178,0.8) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;} .iGXldS:hover{background:rgba(87,218,178,0.6) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;}.
Life of xhu

gin 源码阅读笔记

Dec 21, 2017  |  Go

今天来看一个 Go 项目的源码: gin: Live reload utility for Go web servers.

这个项目的简介是实现 Go web server 的实时重载, 现在这个博客的 dev 模式就是使用这个项目启动的, 启动脚本如下:

gin --excludeDir archives --excludeDir node_modules --excludeDir app/assets --all --port 8283 --appPort 13109

忽略命令中的一串参数, 这行脚本的作用是, 整个项目对外暴露 8283, 请求会被重定向到 13109 端口上, 然后 main.go 是 go server 入口并且实现热重载, 这样分析之后我们我们可以把这个问题分成两个部分:

  1. 怎么在内部启动 go server 并做 http 数据包的转发
  2. 怎么一个检测文件改动并重启内部服务器

带着这两个问题, 我们直接开始看源码吧, 以下代码都省略了无关代码:

// main.go
func MainAction(c *cli.Context) {
    os.Setenv("PORT", appPort)

  wd, err := os.Getwd()

  buildArgs, err := shellwords.Parse(c.GlobalString("buildArgs"))

  buildPath := c.GlobalString("build")
  builder := gin.NewBuilder(buildPath, c.GlobalString("bin"), c.GlobalBool("godep"), wd, buildArgs)
  runner := gin.NewRunner(filepath.Join(wd, builder.Binary()), c.Args()...)
  runner.SetWriter(os.Stdout)
  proxy := gin.NewProxy(builder, runner)

  config := &gin.Config{
    Laddr:    laddr,
    Port:     port,
    ProxyTo:  "http://localhost:" + appPort,
    KeyFile:  keyFile,
    CertFile: certFile,
  }

  err = proxy.Run(config)

  shutdown(runner)

  build(builder, runner, logger)

  // scan for changes
  scanChanges(c.GlobalString("path"), c.GlobalStringSlice("excludeDir"), all, func(path string) {
    runner.Kill()
    build(builder, runner, logger)
  })
}

在这段入口里, 首先把需要转发的端口放到了环境变量里, 然后取了三个在编译 go server 时需要用到的常量:

  1. wd: 当前的工作目录;
  2. buildArgs: 构建参数;
  3. buildPath: 构建 go server 的路径.

接下来我们可以看到, 整个 gin 项目把代码分成了三个模块, 分别是:

  1. builder: 使用上面的三个常量来构建内部服务器;

     // lib/builder.go
     type builder struct {
       dir       string                  // 构建的目录
       binary    string                  // 构建得到的二进制文件
       wd        string                  // 当前工作目录
       buildArgs []string                // 构建参数
     }
  2. runner: 负责运行和停止内部服务器;

     // lib/runner.go
     type runner struct {
       bin       string                  // builder 构建的二进制文件路径
       command   *exec.Cmd               // 使用二进制文件得到的 Command 实例
       starttime time.Time               // 当前内部服务器 进程开始的时间
     }
  3. proxy: 将外部的 http/https 请求转发到内部的 go server 上.

     // lib/proxy.go
     type Proxy struct {
       listener net.Listener             // 监听网络请求
       proxy    *httputil.ReverseProxy   // ReverseProxy 实例, 实现反响代码数据转发
       builder  Builder                  // Builder 接口实例
       runner   Runner                   // Runner 接口实例
       to       *url.URL                 // 反响代理的地址
     }

下面就是针对这三个模块的 new 函数:

  1. NewBuilder: 编译内部 server, 获得二进制文件信息, 返回实现了 Builder 接口的 builder 实例;
  2. NewRunner: 使用 builder 信息生成 exec.Command 实例, 返回实现了 Runner 接口的 runner 实例;
  3. NewProxy: 使用 builderrunner 生成 Proxy 实例, 其他字段暂时置为空.

接下俩就是调用 Run 方法来启动 proxy, 实现网络请求的转发:

config := &gin.Config{
  Laddr:    laddr,
  Port:     port,
  ProxyTo:  "http://localhost:" + appPort,
  KeyFile:  keyFile,
  CertFile: certFile,
}

err = proxy.Run(config)

下面我们来看一下 Run 方法的具体实现:

// proxy.go
func (p *Proxy) Run(config *Config) error {
  url, err := url.Parse(config.ProxyTo)
  p.proxy = httputil.NewSingleHostReverseProxy(url)
  p.to = url

  server := http.Server{Handler: http.HandlerFunc(p.defaultHandler)}

  // 省略 https 的处理代码
  p.listener, err = net.Listen("tcp", fmt.Sprintf("%s:%d", config.Laddr, config.Port))

  go server.Serve(p.listener)

  return nil
}

func (p *Proxy) defaultHandler(res http.ResponseWriter, req *http.Request) {
  errors := p.builder.Errors()
  if len(errors) > 0 {
    res.Write([]byte(errors))
  } else {
    p.runner.Run()
    p.proxy.ServeHTTP(res, req)
  }
}

也就是说, proxy 实例本质上是一个简单的 http 服务器, 这个服务器的请求都会打到 defaultHanlder 上, 而这个 handler 的作用有两个, 那就是在有请求到达的时候:

  1. 通过 runner.Run 方法, 确保内部服务器 已经在运行;
  2. 通过 *httputil.ReverseProxy#ServeHTTP 方法, 将请求转发到内部服务器 上.

我们在来看一下运行内部服务器 的 runner.Run 方法:

func (r *runner) Run() (*exec.Cmd, error) {
  if r.command == nil || r.Exited() {
    err := r.runBin()
    time.Sleep(250 * time.Millisecond)
    return r.command, err
  } else {
    return r.command, nil
  }

}

func (r *runner) runBin() error {
  r.command = exec.Command(r.bin, r.args...)
  err = r.command.Start()
  r.starttime = time.Now()

  go r.command.Wait()

  return nil
}

我们可以看到 runner.Run 方法其实是调用了内部的 runBin 方法, 在 runBin 方法里通过 os/exec 包生成了 *exec.CMD 对象, 通过 Start 方法执行之后, 会在一个新的协程里执行 Wait 方法, 使后台的服务器进程不会阻塞主进程.

回到 Run 方法中, 在启动内部服务器 之后还有一个 250ms 的停顿, 应该是等待服务器启动的时间.

到这里我们就算是弄明白了上文中的第一个问题, 简单的说, 就是通过 os/exec 来进行内部服务器启动, 通过 net/http/httputil 进行 http 请求转发, 当然通过阅读源码我们也可以发现一些实现上的小瑕疵:

  1. 在 build 完二进制文件, 只有当有 http 请求进来的时候, 才会执行这个二进制文件启动内部服务器, 而如果内部服务器启动时间大于 250ms, 那么修改文件之后的第一次请求总是会失败, 这也符合实际使用时的表现;
  2. runner 中的 runBin 只适用于内部服务器不带参数执行的情况, 因为 r.args 使用的是 gin 本身的参数列表, 并不一定能被内部服务器识别, 如果要实现这个, 只能给 gin 加一个新的参数了比如 executeArgs.

那么我们看第二个问题, 再次回到 main.go 文件:

// main.go
scanChanges(c.GlobalString("path"), c.GlobalStringSlice("excludeDir"), all, func(path string) {
  runner.Kill()
  build(builder, runner, logger)
}}

func scanChanges(watchPath string, excludeDirs []string, allFiles bool, cb scanCallback) {
  for {
    filepath.Walk(watchPath, func(path string, info os.FileInfo, err error) error {
      if path == ".git" && info.IsDir() {
        return filepath.SkipDir
      }
      for _, x := range excludeDirs {
        if x == path {
          return filepath.SkipDir
        }
      }

      // ignore hidden files
      if filepath.Base(path)[0] == '.' {
        return nil
      }

      if (allFiles || filepath.Ext(path) == ".go") && info.ModTime().After(startTime) {
        cb(path)
        startTime = time.Now()
        return errors.New("done")
      }

      return nil
    })
    time.Sleep(500 * time.Millisecond)
  }
}

这里其实就比较简单了, scanChanges 的内部实现其实是用一个间隔为半秒的死循环在不停的通过 filepath.Walk 方法来遍历参数 path 设定的目录, 如果一个文件满足下列条件:

  1. 不是 .git 目录;
  2. 不在 execludeDir 参数中;
  3. 不是隐藏文件;
  4. 扩展名是 .go 或者运行时带了 --all 参数;
  5. 文件在内部服务器启动后被修改过.

那么我们就执行回调函数 cb 并重置 startTime. 而回调函数中的内容就是终止当前内部服务器进程和重新 build. 而终止进程的 Kill 方法实现如下:

func (r *runner) Kill() error {
  if r.command != nil && r.command.Process != nil {
    done := make(chan error)
    go func() {
      r.command.Wait()
      close(done)
    }()

    select {
    case <-time.After(3 * time.Second):
      if err := r.command.Process.Kill(); err != nil {
        log.Println("failed to kill: ", err)
      }
    case <-done:
    }
    r.command = nil
  }

  return nil
}

这里做了一个超时处理, 如果进程在调用 Wait 方法 3 秒之后仍然没有响应, 就会被 Kill 方法来终止, 并且打印出命令执行的错误. 而回调中的下一步 build 就会重新生成内部服务器的二进制文件, 接下来有 http 请求的话, 就会进入上面 proxy 中的 defaultHandler, 进而执行 runner.Run 方法重新启动内部进服务器.

具体的流程图可以用下图来表示:

而这次阅读我们也学到了一些非常有用的内部库的用法:

package struct func description
net/http/httputil ReverseProxy ServeHTTP 反向代理 http 请求
path/filepath - Walk 遍历一个目录
os/exec CMD Start/Wait 执行一个命令并且等待输出, 可以用来执行耗时或者被挂起的命令

refs:

Copyright © 2018 - xhu - Powered by Gin,jQuery,Animate.css,Semantic UI

{display:none;cursor:pointer;width:60px;height:60px;text-indent:100%;margin:0 0 0 -3px;box-shadow:0 0 10px rgba(0,0,0,0.05);background:rgba(87,218,178,0.8) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;} .
Life of xhu

gin 源码阅读笔记

Dec 21, 2017  |  Go

今天来看一个 Go 项目的源码: gin: Live reload utility for Go web servers.

这个项目的简介是实现 Go web server 的实时重载, 现在这个博客的 dev 模式就是使用这个项目启动的, 启动脚本如下:

gin --excludeDir archives --excludeDir node_modules --excludeDir app/assets --all --port 8283 --appPort 13109

忽略命令中的一串参数, 这行脚本的作用是, 整个项目对外暴露 8283, 请求会被重定向到 13109 端口上, 然后 main.go 是 go server 入口并且实现热重载, 这样分析之后我们我们可以把这个问题分成两个部分:

  1. 怎么在内部启动 go server 并做 http 数据包的转发
  2. 怎么一个检测文件改动并重启内部服务器

带着这两个问题, 我们直接开始看源码吧, 以下代码都省略了无关代码:

// main.go
func MainAction(c *cli.Context) {
    os.Setenv("PORT", appPort)

  wd, err := os.Getwd()

  buildArgs, err := shellwords.Parse(c.GlobalString("buildArgs"))

  buildPath := c.GlobalString("build")
  builder := gin.NewBuilder(buildPath, c.GlobalString("bin"), c.GlobalBool("godep"), wd, buildArgs)
  runner := gin.NewRunner(filepath.Join(wd, builder.Binary()), c.Args()...)
  runner.SetWriter(os.Stdout)
  proxy := gin.NewProxy(builder, runner)

  config := &gin.Config{
    Laddr:    laddr,
    Port:     port,
    ProxyTo:  "http://localhost:" + appPort,
    KeyFile:  keyFile,
    CertFile: certFile,
  }

  err = proxy.Run(config)

  shutdown(runner)

  build(builder, runner, logger)

  // scan for changes
  scanChanges(c.GlobalString("path"), c.GlobalStringSlice("excludeDir"), all, func(path string) {
    runner.Kill()
    build(builder, runner, logger)
  })
}

在这段入口里, 首先把需要转发的端口放到了环境变量里, 然后取了三个在编译 go server 时需要用到的常量:

  1. wd: 当前的工作目录;
  2. buildArgs: 构建参数;
  3. buildPath: 构建 go server 的路径.

接下来我们可以看到, 整个 gin 项目把代码分成了三个模块, 分别是:

  1. builder: 使用上面的三个常量来构建内部服务器;

     // lib/builder.go
     type builder struct {
       dir       string                  // 构建的目录
       binary    string                  // 构建得到的二进制文件
       wd        string                  // 当前工作目录
       buildArgs []string                // 构建参数
     }
  2. runner: 负责运行和停止内部服务器;

     // lib/runner.go
     type runner struct {
       bin       string                  // builder 构建的二进制文件路径
       command   *exec.Cmd               // 使用二进制文件得到的 Command 实例
       starttime time.Time               // 当前内部服务器 进程开始的时间
     }
  3. proxy: 将外部的 http/https 请求转发到内部的 go server 上.

     // lib/proxy.go
     type Proxy struct {
       listener net.Listener             // 监听网络请求
       proxy    *httputil.ReverseProxy   // ReverseProxy 实例, 实现反响代码数据转发
       builder  Builder                  // Builder 接口实例
       runner   Runner                   // Runner 接口实例
       to       *url.URL                 // 反响代理的地址
     }

下面就是针对这三个模块的 new 函数:

  1. NewBuilder: 编译内部 server, 获得二进制文件信息, 返回实现了 Builder 接口的 builder 实例;
  2. NewRunner: 使用 builder 信息生成 exec.Command 实例, 返回实现了 Runner 接口的 runner 实例;
  3. NewProxy: 使用 builderrunner 生成 Proxy 实例, 其他字段暂时置为空.

接下俩就是调用 Run 方法来启动 proxy, 实现网络请求的转发:

config := &gin.Config{
  Laddr:    laddr,
  Port:     port,
  ProxyTo:  "http://localhost:" + appPort,
  KeyFile:  keyFile,
  CertFile: certFile,
}

err = proxy.Run(config)

下面我们来看一下 Run 方法的具体实现:

// proxy.go
func (p *Proxy) Run(config *Config) error {
  url, err := url.Parse(config.ProxyTo)
  p.proxy = httputil.NewSingleHostReverseProxy(url)
  p.to = url

  server := http.Server{Handler: http.HandlerFunc(p.defaultHandler)}

  // 省略 https 的处理代码
  p.listener, err = net.Listen("tcp", fmt.Sprintf("%s:%d", config.Laddr, config.Port))

  go server.Serve(p.listener)

  return nil
}

func (p *Proxy) defaultHandler(res http.ResponseWriter, req *http.Request) {
  errors := p.builder.Errors()
  if len(errors) > 0 {
    res.Write([]byte(errors))
  } else {
    p.runner.Run()
    p.proxy.ServeHTTP(res, req)
  }
}

也就是说, proxy 实例本质上是一个简单的 http 服务器, 这个服务器的请求都会打到 defaultHanlder 上, 而这个 handler 的作用有两个, 那就是在有请求到达的时候:

  1. 通过 runner.Run 方法, 确保内部服务器 已经在运行;
  2. 通过 *httputil.ReverseProxy#ServeHTTP 方法, 将请求转发到内部服务器 上.

我们在来看一下运行内部服务器 的 runner.Run 方法:

func (r *runner) Run() (*exec.Cmd, error) {
  if r.command == nil || r.Exited() {
    err := r.runBin()
    time.Sleep(250 * time.Millisecond)
    return r.command, err
  } else {
    return r.command, nil
  }

}

func (r *runner) runBin() error {
  r.command = exec.Command(r.bin, r.args...)
  err = r.command.Start()
  r.starttime = time.Now()

  go r.command.Wait()

  return nil
}

我们可以看到 runner.Run 方法其实是调用了内部的 runBin 方法, 在 runBin 方法里通过 os/exec 包生成了 *exec.CMD 对象, 通过 Start 方法执行之后, 会在一个新的协程里执行 Wait 方法, 使后台的服务器进程不会阻塞主进程.

回到 Run 方法中, 在启动内部服务器 之后还有一个 250ms 的停顿, 应该是等待服务器启动的时间.

到这里我们就算是弄明白了上文中的第一个问题, 简单的说, 就是通过 os/exec 来进行内部服务器启动, 通过 net/http/httputil 进行 http 请求转发, 当然通过阅读源码我们也可以发现一些实现上的小瑕疵:

  1. 在 build 完二进制文件, 只有当有 http 请求进来的时候, 才会执行这个二进制文件启动内部服务器, 而如果内部服务器启动时间大于 250ms, 那么修改文件之后的第一次请求总是会失败, 这也符合实际使用时的表现;
  2. runner 中的 runBin 只适用于内部服务器不带参数执行的情况, 因为 r.args 使用的是 gin 本身的参数列表, 并不一定能被内部服务器识别, 如果要实现这个, 只能给 gin 加一个新的参数了比如 executeArgs.

那么我们看第二个问题, 再次回到 main.go 文件:

// main.go
scanChanges(c.GlobalString("path"), c.GlobalStringSlice("excludeDir"), all, func(path string) {
  runner.Kill()
  build(builder, runner, logger)
}}

func scanChanges(watchPath string, excludeDirs []string, allFiles bool, cb scanCallback) {
  for {
    filepath.Walk(watchPath, func(path string, info os.FileInfo, err error) error {
      if path == ".git" && info.IsDir() {
        return filepath.SkipDir
      }
      for _, x := range excludeDirs {
        if x == path {
          return filepath.SkipDir
        }
      }

      // ignore hidden files
      if filepath.Base(path)[0] == '.' {
        return nil
      }

      if (allFiles || filepath.Ext(path) == ".go") && info.ModTime().After(startTime) {
        cb(path)
        startTime = time.Now()
        return errors.New("done")
      }

      return nil
    })
    time.Sleep(500 * time.Millisecond)
  }
}

这里其实就比较简单了, scanChanges 的内部实现其实是用一个间隔为半秒的死循环在不停的通过 filepath.Walk 方法来遍历参数 path 设定的目录, 如果一个文件满足下列条件:

  1. 不是 .git 目录;
  2. 不在 execludeDir 参数中;
  3. 不是隐藏文件;
  4. 扩展名是 .go 或者运行时带了 --all 参数;
  5. 文件在内部服务器启动后被修改过.

那么我们就执行回调函数 cb 并重置 startTime. 而回调函数中的内容就是终止当前内部服务器进程和重新 build. 而终止进程的 Kill 方法实现如下:

func (r *runner) Kill() error {
  if r.command != nil && r.command.Process != nil {
    done := make(chan error)
    go func() {
      r.command.Wait()
      close(done)
    }()

    select {
    case <-time.After(3 * time.Second):
      if err := r.command.Process.Kill(); err != nil {
        log.Println("failed to kill: ", err)
      }
    case <-done:
    }
    r.command = nil
  }

  return nil
}

这里做了一个超时处理, 如果进程在调用 Wait 方法 3 秒之后仍然没有响应, 就会被 Kill 方法来终止, 并且打印出命令执行的错误. 而回调中的下一步 build 就会重新生成内部服务器的二进制文件, 接下来有 http 请求的话, 就会进入上面 proxy 中的 defaultHandler, 进而执行 runner.Run 方法重新启动内部进服务器.

具体的流程图可以用下图来表示:

而这次阅读我们也学到了一些非常有用的内部库的用法:

package struct func description
net/http/httputil ReverseProxy ServeHTTP 反向代理 http 请求
path/filepath - Walk 遍历一个目录
os/exec CMD Start/Wait 执行一个命令并且等待输出, 可以用来执行耗时或者被挂起的命令

refs:

Copyright © 2018 - xhu - Powered by Gin,jQuery,Animate.css,Semantic UI

:hover{background:rgba(87,218,178,0.6) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;}.null{display:none;cursor:pointer;width:60px;height:60px;text-indent:100%;margin:0 0 0 -3px;box-shadow:0 0 10px rgba(0,0,0,0.05);background:rgba(87,218,178,0.8) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;} .null:hover{background:rgba(87,218,178,0.6) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;}.fammhF{display:none;cursor:pointer;width:60px;height:60px;text-indent:100%;margin:0 0 0 -3px;box-shadow:0 0 10px rgba(0,0,0,0.05);background:rgba(87,218,178,0.8) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;} .fammhF:hover{background:rgba(87,218,178,0.6) url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBB%0D%0AZG9iZSBJbGx1c3RyYXRvciAxNy4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9u%0D%0AOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBT%0D%0AVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzEx%0D%0ALmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3%0D%0ALnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxp%0D%0AbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94%0D%0APSIwIDAgMTYgMTYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE2IDE2IiB4bWw6c3BhY2U9%0D%0AInByZXNlcnZlIj4NCjxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iOCwyLjggMTYsMTAu%0D%0ANyAxMy42LDEzLjEgOC4xLDcuNiAyLjUsMTMuMiAwLDEwLjcgIi8+DQo8L3N2Zz4NCg==) no-repeat center 50%;}
Life of xhu

设计模式笔记-装饰模式

Nov 18, 2016  |  Design Pattern

这次我们来学习一个新的设计模式, 话说在刚开始阅读我们公司代码的时候, 发现有不少文件是以deco结尾的, 当时还以为是很高深的用法, 后来问过之后才知道, 这个是单词decorator的缩写, 也就是所谓的装饰器, 而且这种文件里的代码, 一般是给一个已经存在的类做一些扩展用的, 倒也是名副其实, 而今天要学习的, 就是这些代码使用的, 一个很常见的设计模式, 装饰模式.

照样从一个需求开始:

假设我们在玩一个类似于QQ里的角色装扮的功能, 我需要创建一个人物, 并且给这个角色在客户端中打印出不同搭配的装扮.

首先我们自然的想到要创建一个类来表示人物, 并且根据服装的多样, 在类里定义多个方法, 每个方法都会打印出相应的服装.

Person类的定义如下:

class Person {
  constructor(name) {
    this.name = name;
  }

  wearTShirts() {
    console.log('大T恤 ');
  }

  wearBigTrouser() {
    console.log('垮裤 ');
  }

  wearSneakers() {
    console.log('破球鞋 ');
  }

  wearSuit() {
    console.log('西装 ');
  }

  wearTie() {
    console.log('领带 ');
  }

  wearLeatherShoes() {
    console.log('皮鞋 ');
  }

  show() {
    console.log(`装扮的${this.name}`);
  }
}

同时客户端代码为:

var person = new Person('xhu');

console.log('第一种装扮:');
person.wearTShirts();
person.wearBigTrouser();
person.wearSneakers();
person.show();

console.log();

console.log('第二种装扮:');
person.wearSuit();
person.wearTie();
person.wearLeatherShoes();
person.show();

这样会打印出两套装扮结果:

第一种装扮: 大T恤 垮裤 破球鞋 装扮的xhu 第二种装扮: 西装 领带 皮鞋 装扮的xhu

这个实现非常简单, 但是缺点也是显而易见的, 如果要增加一种服装, 就要给Person类增加一个方法来实现, 这显然让代码的可维护性变的很差, 所以仍然有改进的空间.


那么首先想到的改进是, 我们可以给服装同意创建一个类, 然后各种服装都是继承这个类, 并且在子类中实现具体内容.

注: 这里最好使用接口或者抽象类来做, 因为这里的服装其实本身是不需要实现具体功能的, 但是因为这两者都不是ES6的内容, 所以我在这里仍然使用类的继承来实现.

class Person {
  constructor (name) {
    this.name = name;
  }

  show () {
    console.log(`装扮得${this.name}`);
  }
}

class Finery {
  show () {}
}

class TShirts extends Finery {
  show () {
    console.log('大T恤');
  }
}

class BigTrouser extends Finery {
  show () {
    console.log('垮裤');
  }
}

class Sneakers extends Finery {
  show () {
    console.log('破球鞋');
  }
}

class Suit extends Finery {
  show () {
    console.log('西装');
  }
}

class Tie extends Finery {
  show () {
    console.log('领带');
  }
}

class LeatherShoes extends Finery {
  show () {
    console.log('皮鞋');
  }
}

这时客户端代码就可以分别的去实例化相应的服装然后显示了:

var person = new Person('xhu');

console.log('第一种装扮:');
var tShirts = new TShirts();
var bigTrouser = new BigTrouser();
var sneakers = new Sneakers();
tShirts.show();
bigTrouser.show();
sneakers.show();
person.show()

console.log();

console.log('第二种装扮:');
var suit = new Suit();
var tie = new Tie();
var leatherShoes = new LeatherShoes();
suit.show();
tie.show();
leatherShoes.show();
person.show();

但是这个代码还是有一个问题, 就是整个穿衣服的过程都是暴露在外部的, 重复的调用show方法显的并不美观, 而且穿衣服的搭配和顺序都有有很多种, 也很难在类定义的时候就把穿什么衣服的规则写好, 所以我们一个迫切的需求就是把需要的功能按正确的顺序从外部串联起来进行控制.


这就说到我们今天要学习的装饰模式了:

装饰模式(Decorator), 动态地给一个对象添加一些额外的职责, 就增加功能来说, 装饰模式比生成子类更为灵活.

接下来我们通过一个例子来讲解装饰模式的具体实现, 这里我们一般会声明一个组件Component类来作为父类:

class Component {
  operation () {}
}

然后定义一个装饰器类, 在这个类里, 我们通过setComponent方法来设定需要装饰的组件, 同时也复写了父类中具体的操作代码, 但是不提供实际功能.

class Decorator extends Component {
  setComponent (component) {
    this.component = component;
  }

  operation () {
    if (this.component) {
      this.component.operation();
    }
  }
}

最后当具体要实现各个装饰器的时候, 我们只需要复写operation方法, 一般在这个方法里, 我们会调用被装饰对象的operation方法, 并且加入每个装饰器独有的特定逻辑, 来实现对一个对象进行装饰的目的.

class ConcreteDecoratorA extends Decorator {
  operation () {
    super.operation();
    this.addedState = 'New State';   // custom variable, different with decorator B
    console.log('具体装饰对象A的操作');
  }
}

class ConcreteDecoratorB extends Decorator {
  operation() {
    super.operation();
    this.AddBehavior();
    console.log('具体装饰对象B的操作');
  }

  AddBehavior () {
    // custom method, different with decorator A
  }
}

客户端的代码如下:

var c = new Decorator();
var dA = new ConcreteDecoratorA();
var dB = new ConcreteDecoratorB();

dA.setComponent(c);
dB.setComponent(dA);
dB.operation();

输出为:

具体装饰对象A的操作 具体装饰对象B的操作


那么从这个思路出发, 我们也可以对之前的代码进行改写了, 首先是Person类和Finery类, 我们在接下来的代码中, 把Finery作为装饰器的父类, 而Person就是我们需要装饰的组件:

class Person {
  constructor (name) {
    this.name = name;
  }

  show () {
    console.log(`装扮的${this.name}`);
  }
}

class Finery extends Person {
  decorate (component) {
    this.component = component;
  }

  show () {
    if (this.component) {
      this.component.show();
    }
  }
}

然后给不同的服装创建具体的装饰器类:

class TShirts extends Finery {
  show () {
    console.log('大T恤');
    super.show();
  }
}

class BigTrouser extends Finery {
  show () {
    console.log('垮裤');
    super.show();
  }
}

class Sneakers extends Finery {
  show () {
    console.log('破球鞋');
    super.show();
  }
}

class Suit extends Finery {
  show () {
    console.log('西装');
    super.show();
  }
}

class Tie extends Finery {
  show () {
    console.log('领带');
    super.show();
  }
}

class LeatherShoes extends Finery {
  show () {
    console.log('皮鞋');
    super.show();
  }
}

而在最后使用的时候, 只需要实例化装饰器来意思装饰person对象即可.

var person = new Person('xhu');

console.log('第一种装扮:');
var sn = new Sneakers();
var bt = new BigTrouser();
var ts = new TShirts();
sn.decorate(person);
bt.decorate(sn);
ts.decorate(bt);
ts.show();

console.log('第二种装扮:');
var ls = new LeatherShoes();
var ti = new Tie();
var su = new Suit();
ls.decorate(person);
ti.decorate(ls);
su.decorate(ti);
su.show();

这时如果我们想换一种搭配, 只要更换装饰器或者改变顺序就可以了.

console.log('第三种装扮:');
bt.decorate(person);
su.decorate(bt);
su.show();

这样就输出西装垮裤的混搭风格了:

第三种装扮: 西装 垮裤 装扮的xhu


那么现在我们可以来总结一下装饰模式的目的:

把类的装饰功能和核心职责区分开, 为已有功能动态地添加更多功能

而且对于具体的装饰过程, 好的实现需要支持这两种特性:

  1. 有选择地
  2. 顺序地

这里我也给我们公司的*_deco.rb文件做一个反思, 虽然这些文件是用deco结尾, 但是却都是用class.exec或者include这样mixin的方式来实现对现有模型的扩展, 而且在RoR里文件加载的顺序不甚明晰的情况下, 这样做的结果是导致扩展的装饰功能其实都一定程度上污染了被装饰对象本身, 并没有很好的满足这两个特性, 所以才经常有deco文件里方法和原有方法冲突的情况出现.

但是真正如上文中的装饰模式在实际使用中也是有问题的, 就是需要创建的对象太多, 特别是对象上属性太多的时候, 每个属性都要实例化一个装饰器并调用装饰方法, 函数调用栈太深太复杂, 这些都可能会成为实际运行中的性能瓶颈, 所以我们公司对于装饰器的实现, 算是一个比较折中的方案了吧.

Copyright © 2018 - xhu - Powered by Gin,jQuery,Animate.css,Semantic UI