这是我参与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类型是多么的粗糙,而且我们的DidChange和Run函数具有良好的测试覆盖率,我选择在此时跳过编写测试,而是手动测试这段代码。我知道,我知道,我打破了一些基本的编码规则,但这是一个只有我使用的开发工具,所以我可以这样冒险。😜
我可能最终会编写更好的测试,但目前工具还在工作,我让它继续工作。
使用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中,它看起来像下面的截图。
虽然这很简洁,但并不是很有用。为什么构建失败?当我们运行go test ./…时,所有的输出都发生了什么?
持久化从exec.Cmd的输出
我回到了pitstop包,并开始破解BuildCommand和RunCommand函数。为了持久化命令的输出,我想使用字strings.Builder,并将其分配给正在创建的每个命令的Stdout和Stderr。这将使我能够从我的命令中获取任何输出,并在有错误时将其附加到错误后面。不幸的是,这也意味着我们之前写入到终端的任何内容都将停止写入,如果有人期望在那里出现构建错误,这可能是一个糟糕的主意。
我选择的解决方案是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/…
BuildCommand和RunCommand的代码几乎是相同的,所以我只在这里显示了重要的部分。点击上面的源代码链接查看整个仓库。
现在我们得到了一个稍微有用的错误:
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的话,这就非常容易了。如果没有,那也很酷。
在这一点上,我决定放弃。我不知道自己是否会继续开发这个项目,但目前它解决了我的一个小问题,同时也让我能够稍微改进自己的开发过程。
我知道这个项目还很不完美。我没意见。我写这篇文章和分享这段代码的部分动机是为了传达这样一个观点:我们的目标通常不应该是创建一个功能完整的软件,而只是完成一些满足我们需求的事情。