在不到200行Go代码中创建一个Live Reloader(一)

77 阅读6分钟

这是我参与2022首次更文挑战的第29天,活动详情查看:2022首次更文挑战

本文为译文,原文链接:www.calhoun.io/creating-a-…

我试图为开发设置一个docker容器,并遇到了一个问题-无论我尝试什么,文件更改事件没有被传播到容器,所以我不能得到热/实时重新加载工作。

你可以想象,这非常令人沮丧;在开发过程中必须手动停止、重建和重新启动应用程序,这已经够耗时的了,但是再加上一个docker容器和一些额外的步骤,突然之间,这个过程就会消耗掉你一整天的时间。这对我来说行不通。

我是一个相当务实的人,所以在这一点上,我的下一步通常是最小化浪费的时间,并恢复到一个我知道会有用的工具。在过去,一直在本地运行modd,而不是为我的本地Go应用程序使用一个容器。这已经证明对我来说工作得很好,因为modd可以处理我需要完成的大多数准备任务,也很适合在文件更改后运行测试。双赢!

不幸的是,在这种情况下,这是行不通的。很多人都知道,我创建了编程课程,我如此热衷于让这个本地docker设置工作的原因之一是,我想为我所有的新课程提供docker-compose配置。这有助于避免任何支持问题,在一个操作系统上一切正常,但在另一个操作系统上有一些微妙的bug(我在看你,Windows!),同时也使新用户更容易得到东西和快速运行-你只需要安装Docker。所有这一切都意味着我需要一个解决方案,不能回到我以前的方式。😭

我检查了我的选项,最终决定只编写一个使用轮询的自定义实时加载程序。我认为库是最好的,然后我可以写一个快速的(约50行)主程序main.go。在将来的每个项目中使用它。

我意识到还有其他的实时重新加载工具,以及许多支持轮询,但我仍然觉得编写自己的工具是这种情况下的最佳选择,因为它可以在相对较短的时间内获得我所需要的内容。

本文的其余部分将记录编写名为pitstop的实时重载库的过程,然后讨论我是如何让它与我的Go应用程序一起工作的。之后,我将讨论一些额外的想法和库的一些潜在问题。

Breaking the code into steps 将代码分解成步骤

我做的第一件事就是把我想要走的步骤分开。历史告诉我,这可能是错误的,但我喜欢在开始之前有一个大致的想法,我想在这里与大家分享。

  1. 检测文件变化的函数。

  2. 一个函数来构建和运行我的应用程序。

  3. 一个外部循环,调用这些函数,并使用用户提供的配置变量在必要时休眠。

最终版本采取了比这更多的步骤,但这些都是一个很好的起点。

步骤一-检测文件变化

我需要一个函数,它可以递归地查看目录中的所有文件,查找自给定时间戳以来发生过更改的所有文件。第一个版本并不需要任何特别的东西,比如可以忽略的扩展。我只需要一个概念证明,这样我就可以继续进行剩下的步骤,并让某些东西工作起来。

幸运的是,Go有一个函数可以帮我完成大部分工作:filepath.Walk

filepath.Walk,我们只需要提供一个filepath.WalkFunc,它将递归地遍历目录中的所有文件,同时为我们提供一个os.FileInfo,幸运的是,它有一个ModTime()方法。这意味着我们真正需要的是一种检测变化的方法,这可以通过一个闭包来完成,该闭包为我们的DidChange函数提供了以下代码。

func DidChange(dir string, since time.Time) bool {
	var changed bool

	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		if info.ModTime().After(since) {
			changed = true
		}
		return nil
	})

	return changed
}

源代码链接:github.com/joncalhoun/…

步骤二:构建和运行应用程序

接下来,我需要一种方法来构建和运行应用程序。在许多实时加载程序中,这只是一个bash命令,但我没有看到任何真正的理由,为什么我必须限制自己只有bash命令。毕竟,这是一个库,所以我可以接受任何函数作为构建或运行步骤。

我决定如下:

type BuildFunc func() error
type RunFunc func() (stop func(), err error)

正如我前面提到的,许多构建步骤往往是像安装这样的bash命令,所以我也想让它更容易实现。为了适应这一点,我添加了以下帮助程序。

// BuildCommand works similar to exec.Command, but rather than returning an
// exec.Cmd it returns a BuildFunc that can be reused.
func BuildCommand(command string, args ...string) BuildFunc {
	return func() error {
		cmd := exec.Command(command, args...)
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err := cmd.Run()
		if err != nil {
			return fmt.Errorf("error building: "%s %s": %w", command, strings.Join(args, " "), err)
		}
		return nil
	}
}
// RunCommand works similar to exec.Command, but rather than returning an
// exec.Cmd it returns a RunFunc that can be reused.
func RunCommand(command string, args ...string) RunFunc {
	return func() (func(), error) {
		cmd := exec.Command(command, args...)
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err := cmd.Start()
		if err != nil {
			return nil, fmt.Errorf("error running: "%s %s": %w", command, strings.Join(args, " "), err)
		}
		return func() {
			cmd.Process.Kill()
		}, nil
	}
}

现在在我的库中,任何地方我想要一个BuildFunc,我可以提供一个典型的bash命令行,就像这样:

pitstop.BuildCommand("go", "install")
pitstop.BuildCommand("go", "test", "./...")

相同的,我也可以用这种方式提供一个RunFunc

pitstop.RunCommand("server", "--prod")

为了简单起见,我在这里做的一个权衡是,任何由RunCommandBuildCommand创建的东西都将写入os.Stdoutos.Stderr,不能自定义。如果任何构建命令退出时带有任何非零状态码,我还选择返回一个错误,我们很快就会看到,这意味着任何go test ./...失败导致构建被停止。最后,我选择只使用cmd.Process.Kill()来终止命令。我的理由是,从技术上讲,任何使用这个库的人都可以编写他们自己的自定义BuildFunc,它不使用这个默认行为,但对我来说,这是我90%的时间都想要的,我不想总是配置这些助手。

最后,我用一个Run函数将所有这些放在一起。

// Run will run all pre BuildFuncs, then the RunFunc, and then finally the post
// BuildFuncs. Any errors encountered will be returned, and the build process
// halted. If RunFunc has been called, stop will also be called so that it is
// guaranteed to not be running anytime an error is returned.
func Run(pre []BuildFunc, run RunFunc, post []BuildFunc) (func(), error) {
	for _, fn := range pre {
		err := fn()
		if err != nil {
			return nil, err
		}
	}
	stop, err := run()
	if err != nil {
		return nil, err
	}
	for _, fn := range post {
		err := fn()
		if err != nil {
			stop()
			return nil, err
		}
	}
	return stop, nil
}

旁注:我考虑了一个可变参数的post参数,但决定不这样做,因为它会使我们如何传入pre和post不同,尽管事实是,这些基本上是相同的,除了当他们运行。

编写并测试了所有的代码后,我已经准备好将其整合在一起并开始使用它了!

本篇先到这里啦,下节继续。