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

109 阅读7分钟

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

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

接上文,继续第三步

把代码整合到一起

对于第一个版本,我希望限制定制选项的数量,因此我将其限制为我们正在监视的目录、扫描文件更改之间的等待间隔,以及我们在步骤2中描述的构建/运行函数。这就留给我如下所示的Poller类型。

type Poller struct {
	// ScanInterval is the duration of time the poller will wait before scanning for new file changes. This defaults to 500ms.
	ScanInterval time.Duration

	// Dir is the directory to scan for file changes. This defaults to "." if it isn't provided.
	Dir string

	// Pre, Run, and Post represent the functions used to build and run our app.
	// Pre functions are called first, then run, then finally the post functions.
	Pre  []BuildFunc
	Run  RunFunc
	Post []BuildFunc
}

一旦我有一个类型,我写的Poll方法,将继续检查文件更改并试图重建我们的应用程序,我想保持尽可能简单的代码,所以我决定不担心一个构建失败由于一个问题可能解决本身(例如一个端口正在使用)。相反,我只是假设,如果构建失败,它将不会得到修复,直到另一个文件更改。就我个人而言,这已经足够好了。

func (p *Poller) Poll() {
	scanInt := p.ScanInterval
	if scanInt == 0 {
		scanInt = 500 * time.Millisecond
	}
	dir := p.Dir
	if dir == "" {
		dir = "."
	}

	var stop func()
	var err error
	var lastBuild time.Time

	for {
		if !DidChange(p.Dir, lastBuild) {
			time.Sleep(scanInt)
			continue
		}
		if stop != nil {
			fmt.Println("Stopping running app...")
			stop()
		}
		fmt.Println("Building & Running app...")
		stop, err = Run(p.Pre, p.Run, p.Post)
		if err != nil {
			fmt.Printf("Error running: %v\n", err)
		}
		lastBuild = time.Now()
		time.Sleep(scanInt)
	}
}

考虑到这种Poller类型是多么的粗糙,而且我们的DidChangeRun函数具有良好的测试覆盖率,我选择在此时跳过编写测试,而是手动测试这段代码。我知道,我知道,我打破了一些基本的编码规则,但这是一个只有我使用的开发工具,所以我可以这样冒险。😜

我可能最终会编写更好的测试,但目前工具还在工作,我让它继续工作。

使用poller

为了使用Poller类型,我需要创建一个main包,导入pitstop包,设置一个Poller,最后调用Poll方法。在大多数情况下,这是很正常的。

package main

import (
	"fmt"
	"os"
	"time"

	"github.com/joncalhoun/pitstop"
)

func main() {
	poller := pitstop.Poller{
		ScanInterval: 500 * time.Millisecond,
		Dir:          "./",
		Pre: []pitstop.BuildFunc{
			pitstop.BuildCommand("go", "test", "./..."),
			pitstop.BuildCommand("go", "install", "./cmd/server"),
		},
		Run: pitstop.RunCommand("server"),
		Post: []pitstop.BuildFunc{
			func() error {
				f, err := os.Create("../ui/src/last_built.js")
				if err != nil {
					return err
				}
				defer f.Close()
				loc, err := time.LoadLocation("America/New_York")
				if err != nil {
					return err
				}
				now := time.Now().In(loc)
				fmt.Fprintf(f, "const date ="%s";\n", now.Format(time.RFC1123))
				fmt.Fprintln(f, "export default date;")
				return err
			},
		},
	}
	poller.Poll()
}

这其中的大部分应该是非常明显的。我的Pre函数正在运行go test ./…然后在cmd/server中安装main包装器。我的Run函数然后执行服务器命令,这只是调用刚刚安装的二进制文件的一种方式。

这里唯一的奇怪之处是我提供的Post函数。我没有运行bash命令,而是选择提供一些Go代码,将创建一个文件在../ui/src/last_build.js,并写入以下JavaScript:

const date ="Tue, 05 May 2020 19:43:51 EDT";
export default date;

这只是我在使用React时添加到一些项目中的一个小技巧,以确保每当我更改API时,UI都会重新加载,而且我可以通过React组件看到API最后一次更新的时间,比如:

function LastBuilt(props) {
  return (
    <div className="w-full py-4 px-2 text-center bg-yellow-100 text-gray-600">
      The Go API was last built: {props.date}
    </div>
  );
}

现在你有了它——一个200行左右的Go代码的实时加载器。其中大约有150行用于编写pitstop库,另外50行来自创建可运行二进制文件所需的main包。

有趣的是,看到这一点最终激励我对pitstop包进行修改。最值得注意的是,它启发我允许用户提供一个OnError回调函数,然后可以触发整洁的可视化,我们将在下一节中看到。

创建一个OnError回调

为了保持简单,我决定我的OnError回调将是一个接受错误而不返回任何东西的函数。毕竟,这是用来处理错误的,让它返回自己的错误会有点奇怪。我不打算添加一个OnErrorError回调😂

type Poller struct {
	// ...

	// OnError is similar to Pre and Post, but is only called when Pre, Run, or
	// Post encounter an error.
	OnError func(error)
}

我没有将错误case构建函数传递到Run函数中,而是选择将其放在Poll方法的for循环中。为了简化这一点,我在Poll方法的早期做了一些nil检查,如果提供了一个错误函数,就给它赋值为空。最终结果如下所示。

func (p *Poller) Poll() {
	// ...
	onError := p.OnError
	if onError == nil {
		onError = func(error) {}
	}
	// ...
	for {
		// ...
		stop, err = Run(p.Pre, p.Run, p.Post)
		if err != nil {
			fmt.Printf("Error running: %v\n", err)
			onError(err)
		}
		lastBuild = time.Now()
		time.Sleep(scanInt)
	}
}

对于测试来说,这可能不是一个理想的方法,但在这个时候,我正在破解这个项目,更关心的是看到最终的结果。此外,在Go中重构这类代码往往非常容易,所以我认为没有必要让第一个版本变得完美,直到我决定它是否值得保留。

接下来是使用OnError回调的Go代码。为此,我需要回到使用pitstop包创建的main包。在这个文件中,我添加了两个函数——一个在成功构建时向api_error.js文件中写入一个空错误,另一个在失败构建时写出一个错误。

最后是一些JavaScript和React来渲染这个:

// import the error
import apiError from "./api_error";

// Define the React component
function Error({ error }) {
  return (
    <div className="w-full px-8 py-4">
      <h3 className="text-2xl font-mono text-red-600">API Error:</h3>
      <pre className="w-full py-4 px-2 bg-gray-200 text-red-600 border-2 border-gray-300">
        {error}
      </pre>
    </div>
  );
}

// Then where I want to use it:
{apiError ? <Error error={apiError} /> : ""}

然后我兴奋地把事情搞得一团糟,故意引入了一个错误……

🥁鼓声…🥁

我的javascript输出如下所示。

const error = `error building: "go test ./...": exit status 2`;
export default error;

在UI中,它看起来像下面的截图。 image.png

虽然这很简洁,但并不是很有用。为什么构建失败?当我们运行go test ./…时,所有的输出都发生了什么?

持久化从exec.Cmd的输出

我回到了pitstop包,并开始破解BuildCommandRunCommand函数。为了持久化命令的输出,我想使用字strings.Builder,并将其分配给正在创建的每个命令的StdoutStderr。这将使我能够从我的命令中获取任何输出,并在有错误时将其附加到错误后面。不幸的是,这也意味着我们之前写入到终端的任何内容都将停止写入,如果有人期望在那里出现构建错误,这可能是一个糟糕的主意。

我选择的解决方案是io.MultiWriter,这个函数接受多个写入器,并返回一个写入器,该写入器将写入所提供的所有写入器。简而言之,这使我能够同时拥有两个os。标准输出和我的字符串。生成器被我正在运行的命令写入。

var sb strings.Builder
cmd.Stdout = io.MultiWriter(os.Stdout, &sb)
cmd.Stderr = io.MultiWriter(os.Stderr, &sb)
err := cmd.Run()
if err != nil {
	return fmt.Errorf("error building: "%s %s": %w\n%v", command, strings.Join(args, " "), err, sb.String())
}

完整代码链接:github.com/joncalhoun/…

BuildCommandRunCommand的代码几乎是相同的,所以我只在这里显示了重要的部分。点击上面的源代码链接查看整个仓库。

现在我们得到了一个稍微有用的错误:

const error = `error building: "go test ./...": exit status 2
# github.com/calhounio/api/admin
admin/handler.go:11:1: syntax error: non-declaration statement outside function body
`;
export default error;

同样,一个失败的测试现在也会更容易阅读:

const error = `error building: "go test ./...": exit status 1
?   	github.com/calhounio/api/admin	[no test files]
?   	github.com/calhounio/api/cmd/server	[no test files]
?   	github.com/calhounio/api/cmd/watcher	[no test files]
--- FAIL: TestThing (0.00s)
    watcher_test.go:6: Thing() = nil; want "hello"
FAIL
FAIL	github.com/calhounio/api/poller	0.004s
FAIL
`;
export default error;

在我们的React UI中,当测试失败时,我们可以立即看到,如果你喜欢遵循TDD的话,这就非常容易了。如果没有,那也很酷。

image.png

在这一点上,我决定放弃。我不知道自己是否会继续开发这个项目,但目前它解决了我的一个小问题,同时也让我能够稍微改进自己的开发过程。

我知道这个项目还很不完美。我没意见。我写这篇文章和分享这段代码的部分动机是为了传达这样一个观点:我们的目标通常不应该是创建一个功能完整的软件,而只是完成一些满足我们需求的事情。