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 02, 2016  |  Design Pattern

这篇文章是我开始学习设计模式的第一篇, 虽然设计模式(Design Pattern)这个概念在别的语言里远不如在Java里那么举足轻重, 但是稍微看了一点之后, 却让我感觉这些概念其实并不局限于Java或者C#, 而是一些在计算机行业里通行的设计理念, 于是我决定简单的通读一下23种设计模式, 这篇文章便是一个开始.

基本的学习都是围绕着<大话设计模式>这本书来进行的, 不过语言换成了我比较熟悉JavaScript, 并且使用ES6中最新的基于类的面向对象语法.

开始学习之前, 我们先看一下这次需要解决的问题: 编写一个简单的计算器, 能够从终端中读取数字和操作符, 并且输出计算结果.

相信大部分程序员都能很快的写出一个简单的实现:

const readline = require('readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

rl.question('Input number A: ', (A) => {
  rl.question('Input operator(+, -, *, /): ', (operator) => {
    rl.question('Input number B:', (B) => {
      var result;

      if (operator === '+') result = parseInt(A) + parseInt(B);
      if (operator === '-') result = parseInt(A) - parseInt(B);
      if (operator === '*') result = parseInt(A) * parseInt(B);
      if (operator === '/') result = parseInt(A) / parseInt(B);

      console.log('The result is: ', result);
      rl.close();
    });
  });
});

当然, 这份代码基本上可以满足基本的四则运算了, 但是代码本身可以看到, 还是有很多地方需要改进的:

  1. 变量A, B命名很不规范
  2. 判断分支, 每次都有三次无用判断
  3. 没有处理被除数是0的意外情况

那么我们现在着手来把上面的问题解决一下, 代码如下:

const readline = require('readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

rl.question('Input number A: ', (strNumA) => {
  rl.question('Input operator(+, -, *, /): ', (operator) => {
    rl.question('Input number B: ', (strNumB) => {
      var result;

      try {
        switch (operator) {
          case '+':
            result = parseInt(strNumA) + parseInt(strNumB)
            break;
          case '-':
            result = parseInt(strNumA) - parseInt(strNumB)
            break;
          case '*':
            result = parseInt(strNumA) * parseInt(strNumB)
            break;
          case '/':
            if (strNumB === '0')
              throw new Error('divided by 0.');
            else
              result = parseInt(strNumA) / parseInt(strNumB);
            break;
          default:
            break;
        }
      } catch (e) {
        console.log('Error happens:', e.message);
      }

      console.log('The result is:', result);
      rl.close();
    });
  });
});

这样看来, 代码的逻辑是比第一段清晰了不少, 但是这段代码有一个问题, 就是逻辑内聚严重, 比如计算的过程本来和输入输出是独立的, 在这里的输入输出是终端, 如果需要用web端或者其他途径来作为输入输出呢, 这段代码就无法复用了, 所以仍然有改进的空间.

这里我们选择的改进方式就是使用面向对象的方式对代码进行重构.

首先我们复习一下面向对象的三个重要特征, 也就是封装, 继承多态, 我们需要做的就是就是基于这三个概念把程序的耦合度降低, 并且达到如下的目标:

  1. 可维护
  2. 可复用
  3. 可扩展
  4. 灵活性好

那么首先我们可以做的一个改进就是, 把运算过程从输入输出中独立出来形成一个类:

// Operatiron 运算类
class Operation {
  static getResult (numA, numB, operator) {
    var result;
    switch (operator) {
      case '+':
        result = numA + numB;
        break;
      case '-':
        result = numA - numB;
        break;
      case '*':
        result = numA * numB;
        break;
      case '/':
        if (strNumB === '0')
          throw new Error('divided by 0.');
        else
          result = parseInt(strNumA) / parseInt(strNumB);
        break;
    }
    return result;
  }
}

// 客户端代码
const readline = require('readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

rl.question('Input number A: ', (strNumA) => {
  rl.question('Input operator(+, -, *, /): ', (operator) => {
    rl.question('Input number B: ', (strNumB) => {
      var result;

      try {
        console.log('The result is:', Operation.getResult(parseInt(strNumA), parseInt(strNumB), operator));
      } catch (e) {
        console.log('Error happens:', e.message);
      }

      rl.close();
    });
  });
});

这样一来, 我们就把运算过程独立出来了, 这样已经是使用了面向对象中的封装特性, 看上去已经好不少了, 但是问题仍然是有的, 比如如果我们需要增加一个运算符呢, 我们就无可避免的要去修改getResult这个方法, 这样显然不满足易扩展的原则, 所以仍然不算是一个好的解决方案.


这时我们就可以使用继承多态来实现可扩展的代码, 一下就是改进之后的运算类:

class Operation {
  constructor(operator) {
    this.operator = operator;
    this.numA = this.numB = 0;
  }

  getResult () {
    return 0;
  }
}

class OperationAdd extends Operation {
  getResult () {
    return this.numA + this.numB;
  }
}

class OperationSub extends Operation {
  getResult () {
    return this.numA - this.numB;
  }
}

class OperationMul extends Operation {
  getResult () {
    return this.numA * this.numB;
  }
}

class OperationDiv extends Operation {
  getResult () {
    if (this.numB === 0)
      throw new Error('divided by 0.');
    else
      return parseInt(this.numA) / parseInt(this.numB);
  }
}

这样一来, 当我们需要添加新的运算方式的时候, 只需要添加一个新的类来继承Operation类就可以了, 使用的时候, 将相应的类实例化, 设定好数据调用getResult方法即可.

但是即使定义好了类, 我们还有一个任务需要完成, 那么就是需要一个东西来决定什么时候去实例化相应的类, 那么这就说到今天要学习的设计模式了, 我们需要给代码添加一个工厂, 而工厂的作用, 简而言之就是:

根据输入条件, 决定需要实例化的类.

那么现在就来撸这个工厂吧:

class OperationFactory {
  static createOperation (operator) {
    var operation;
    switch (operator) {
      case '+':
        operation = new OperationAdd();
        break;
      case '-':
        operation = new OperationSub();
        break;
      case '*':
        operation = new OperationMul();
        break;
      case '/':
        operation = new OperationDiv();
        break;
    }
    return operation;
  }
}

调用的客户端部分也需要做相应改进:

const readline = require('readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

rl.question('Input number A: ', (strNumA) => {
  rl.question('Input operator(+, -, *, /): ', (operator) => {
    rl.question('Input number B: ', (strNumB) => {
      var result;

      try {
        var operation = OperationFactory.createOperation(operator);
        operation.numA = parseInt(strNumA);
        operation.numB = parseInt(strNumB);
        console.log('The result is:', operation.getResult());
      } catch (e) {
        console.log('Error happens:', e.message);
      }

      rl.close();
    });
  });
});

到这里我们就完成了对这个简单计算器的改进.


最后我们再总结一下这次的学习, 简单工厂模式其实是设计模式中非常基础的一个, 而且使用的也都是各位程序员非常熟悉的概念, 但是通过对代码的规范编写, 我们完美实现了之前所设定的任务:

  1. 可维护, 各种运算方式在独立的类内部, 修改任意一个对别的没有影响
  2. 可复用, 运算逻辑和输入输出分离, 运算逻辑暴露统一接口很容易被外部使用
  3. 可扩展, 添加运算符只需要建立新的类继承Operation并且给工厂添加条件即可
  4. 灵活性好, 各种运算符可以在独立的类里面进行自定义的操作, 互不影响

那么到这里这次的学习就完成了, 这里我想再次把书上的一句话重复一边, 与大家共勉:

编程是一门技术, 更加是一门艺术.

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