今天来看一个 Go 项目的源码: gin: Live reload utility for Go web servers.
这个项目的简介是实现 Go web server 的实时重载, 现在这个博客的 dev 模式就是使用这个项目启动的, 启动脚本如下:
gin --excludeDir posts --excludeDir node_modules --excludeDir app/assets --all --port 8283 --appPort 13109
忽略命令中的一串参数, 这行脚本的作用是, 整个项目对外暴露 8283
, 请求会被重定向到 13109
端口上, 然后 main.go
是 go server 入口并且实现热重载, 这样分析之后我们我们可以把这个问题分成两个部分:
- 怎么在内部启动 go server 并做 http 数据包的转发
- 怎么一个检测文件改动并重启内部服务器
带着这两个问题, 我们直接开始看源码吧, 以下代码都省略了无关代码:
// 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 时需要用到的常量:
wd
: 当前的工作目录;buildArgs
: 构建参数;buildPath
: 构建 go server 的路径.
接下来我们可以看到, 整个 gin 项目把代码分成了三个模块, 分别是:
-
builder
: 使用上面的三个常量来构建内部服务器;// lib/builder.go type builder struct { dir string // 构建的目录 binary string // 构建得到的二进制文件 wd string // 当前工作目录 buildArgs []string // 构建参数 }
-
runner
: 负责运行和停止内部服务器;// lib/runner.go type runner struct { bin string // builder 构建的二进制文件路径 command *exec.Cmd // 使用二进制文件得到的 Command 实例 starttime time.Time // 当前内部服务器 进程开始的时间 }
-
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 函数:
NewBuilder
: 编译内部 server, 获得二进制文件信息, 返回实现了Builder
接口的builder
实例;NewRunner
: 使用 builder 信息生成exec.Command
实例, 返回实现了Runner
接口的runner
实例;NewProxy
: 使用builder
和runner
生成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 的作用有两个, 那就是在有请求到达的时候:
- 通过
runner.Run
方法, 确保内部服务器 已经在运行; - 通过
*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 请求转发, 当然通过阅读源码我们也可以发现一些实现上的小瑕疵:
- 在 build 完二进制文件, 只有当有 http 请求进来的时候, 才会执行这个二进制文件启动内部服务器, 而如果内部服务器启动时间大于 250ms, 那么修改文件之后的第一次请求总是会失败, 这也符合实际使用时的表现;
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
设定的目录, 如果一个文件满足下列条件:
- 不是
.git
目录; - 不在
execludeDir
参数中; - 不是隐藏文件;
- 扩展名是
.go
或者运行时带了--all
参数; - 文件在内部服务器启动后被修改过.
那么我们就执行回调函数 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 | 执行一个命令并且等待输出, 可以用来执行耗时或者被挂起的命令 |