为每个目标返回一个结果

Go 语言Beginner
立即练习

介绍

Garden Night 活动有几个本地网站入口,分别用于签到、地图、工作人员页面和票务信息。开场前,组织者需要得到每项检查任务的真实结果。入口拒绝访问或响应缓慢,都不能让它从报告中消失。

你将补全一个 Go 检查函数,并保存真实的 JSON 报告。你应当已经了解 Go 函数、结构体、切片、模块和基本通道语法,并完成过 Building a Lost and Found API,或具有同等 HTTP 经验。HTTP 客户端错误、有限超时和响应资源的管理责任都会在使用前讲解。所有目标都是课程控制的本地服务;这是一次性的活动检查,不是互联网扫描器或监控服务。本单元独立初始化,不需要之前 VM 中的文件。包括阅读和常见错误处理在内,预计需要约 55 分钟;这是作者估计。

读取一份简单的成功检查结果

本步骤先运行一个可用的小样例,了解一条结果标识的是什么。从 Workspace 开始。Go 源码和本地活动服务已经准备好。以下所有命令都在 Workspace 中运行。

cd 选择工作目录。Go 位于 /usr/local/go/bin;export 将这个目录添加到当前 shell 原有的 $PATH 前面。这个设置只对当前终端会话生效。打开新 shell 后,重新执行这两行即可,不需要修改全局 shell 文件。

cd /home/labex/project/site-checker
export PATH=/usr/local/go/bin:$PATH

cat 只读取文件,不编辑它们。查看两项正常任务以及 Go 数据结构:

cat normal.json types.go

每个目标都有标识本次任务的 id、需要请求的 url,以及预期出现在响应正文中的文字 want。结果保留 id 和 url;kind 表示业务结果,status 表示收到的 HTTP 状态码。如果尚未收到响应头,它就是零。任务与 URL 并不是同一个概念:两个人可以分别要求检查同一个入口。

go run . 构建并运行当前目录中的 Go 包。准备好的命令使用 -input 指定 JSON 列表,使用 -workers 指定同时执行检查的最大数量,使用 -timeout 指定每个目标的 HTTP 时间预算。200ms 表示 200 毫秒。这里由一个工作者依次检查两个快速响应的入口。shell 的 > 将标准输出写入文件,如果文件已经存在就替换它;错误信息仍会显示在终端中。

go run . -input normal.json -workers 1 -timeout 200ms > normal-report.json

读取实际保存的结果:

cat normal-report.json

两项任务都应显示 kind: "ok" 和 status: 200,并保留原来的 ID 和 URL。200 表示服务器接受了这次 GET;仅凭它还不能证明页面包含预期内容。最初的草稿只理解简单的成功情况。接下来,你会让其他入口的结果也具有实际意义。

如果看到 go: command not found,重新执行当前会话的 export。如果找不到文件,重新执行 cd,并检查文件名拼写。之后如果 Go 报告编译错误,根据文件名和行号定位并修复那一小块代码。下一步将讲解如何编辑、保存和检查修改,无需替换整个文件。保留 main.go、types.go、所有输入 JSON 文件以及准备好的本地服务,不要修改它们。

区分服务器响应与请求失败

本步骤将在 Workspace 中编辑小型 Check 函数,一次完善一个判断。保持 run.go 串行执行,专注于单个目标的结果含义。先阅读草稿:

cat check.go

结果最初包含本次任务的 ID 和 URL。client.Get 返回响应和错误。现有错误分支处理连接失败;调用成功后,这个函数就负责关闭响应正文。保留紧接该错误分支之后的 defer response.Body.Close():defer 在函数返回时执行,也包括提前返回。也要保留收到的状态码赋值。你将扩展这个草稿,而不是用提供的成品程序替换它。

局部编辑与恢复

编辑前先备份源码。cp 复制文件;.bak 后缀很重要,因为另一个 .go 文件会作为本包的一部分参与编译,可能导致 Check 被定义两次。

cp check.go check.go.bak

用终端编辑器打开已有文件:

nano check.go

在 nano 中,用方向键移动。输入文字即可插入;Backspace 和 Delete 删除字符。Ctrl+K 剪切当前行,因此仅在整行都需要移除时使用。Ctrl 表示按住 Control,同时按另一个键。按 Ctrl+W,输入 func Check,再按 Enter 搜索;Ctrl+C 取消搜索。在光标处编辑。保存时按 Ctrl+O,确认文件名仍是 check.go,再按 Enter。按 Ctrl+X 退出。需要再次编辑时,重新运行 nano check.go。如果打开了错误文件名或发生了意外修改,按 Ctrl+X,在询问是否保存时按 N。遇到不想使用的文件名提示,按 Ctrl+C 取消。运行 pwd 查看目录,使用第一步的 cd,打开正确文件后再试。

每次编辑后,gofmt -w check.go 会原地格式化文件。缺少花括号或其他语法错误会附带文件名和行号:重新打开该文件,搜索附近语句,修复那一小块。保存后用 cat check.go 检查磁盘上的实际内容。保留已经写好的正确部分;普通源码错误无需重置 VM 或覆盖整个函数。

判断服务器是否接受了这个入口请求

403 响应表示 HTTP 交换成功发生,因此 Get 不会自动为它返回 Go 错误。本活动要求状态码恰好为 200;收到其他状态码时,结果是 http_error。这是活动的业务规则,不表示其他所有 2xx 状态在任何情况下都属于 HTTP 失败。

在 Check 中,保存 response.StatusCode 后,编写条件,将它与标准库中表示 200 的常量 http.StatusOK 比较。如果状态不同,将结果类型设为 "http_error" 并立即返回该结果。暂时保留现有的最终 "ok" 结果。在这里返回时,已有 defer 会关闭正文,无需等待读取不必要的错误正文。

保存并退出 nano,然后格式化:

gofmt -w check.go

运行前,预测正常任务和工作人员任务应报告什么。可以查看全部任务,不要编辑它们:

cat targets.json
go run . -input targets.json -workers 1 -timeout 200ms

此阶段,工作人员任务应为状态码 403 的 http_error;已经收到的 200 页面即使内容错误,也仍为 ok。缓慢的票务检查此时仍被称为 connection_error;下面会区分超时。这些有意保留的错误表明后续编辑需要改变什么。每个输入 ID,包括两个签到 ID,都必须仍有结果。

检查收到的页面内容

对于 200 响应,io.ReadAll(response.Body) 返回正文的字节及错误。在 net/http 旁添加 io 和 strings,使用导入分组:以 import ( 开始,每行一个带引号的包名,最后写 )。导入声明文件所使用的包;未使用的导入会导致编译错误,因此在实现相关调用时再添加。

对成功收到的正文分类之前,将它读入 body 和 err。若读取失败,暂时返回类型为 "connection_error" 的结果。保留结果中已收到的状态码。成功读取后,先设置或保留 result.Kind = "ok"。然后,在该赋值之后、最终返回之前,检查预期标记。string(body) 将字节转换为文本,strings.Contains(string(body), target.Want) 判断文本是否包含该标记。如果为 false,将类型改为 "content_mismatch"。不要在此条件之后再次赋值为 "ok",否则会覆盖不匹配结果。在 Go 中,! 对布尔值取反。为成功读取的正文保留一个最终返回。

保存并退出,然后格式化并重复同一组真实任务:

gofmt -w check.go
go run . -input targets.json -workers 1 -timeout 200ms

现在,错误地图应为状态码 200 的 content_mismatch,两个签到任务仍为状态码 200 的 ok,工作人员结果仍为状态码 403 的 http_error。收到状态码不证明标记已经匹配。票务超时仍需自己的类别。

区分有限等待与其他失败

HTTP 客户端的预算包括读取响应正文。响应头之前超时,状态码为零;200 响应头之后超时,必须保留状态码 200。在导入中添加 net。net.Error 具有 Timeout() 方法。这个简短的 Go 模式先检查错误是否支持该接口,再调用方法:

if networkError, ok := err.(net.Error); ok && networkError.Timeout() {
    // The operation exceeded its time budget.
}

类型断言返回转换后的值和一个布尔值。只有左侧为 true,&& 才求值右侧。在请求错误分支和正文读取错误分支两处,都保留默认的 "connection_error",再在这个受保护测试成功时改为 "timeout"。保留各分支的返回及原始任务身份。保存、退出并格式化:

gofmt -w check.go

已有服务还提供两个有用的受控案例:/garden/bodylate/check 先发送 200 响应头,再将正文延迟有限的 600ms;/garden/deniedbody/check 先发送 403 响应头,再对无需读取的正文执行相同延迟。创建补充输入来观察这些情况,保留全部预备 JSON 列表不变:

nano body-probes.json

输入此 JSON 数组。JSON 的键和字符串使用双引号,字段和对象之间用逗号分隔,列表用方括号包围。最后一个字段或对象之后没有逗号。

[
  {"id": "body-200", "url": "http://127.0.0.1:8080/garden/bodylate/check", "want": "GARDEN OPEN"},
  {"id": "body-403", "url": "http://127.0.0.1:8080/garden/deniedbody/check", "want": "GARDEN OPEN"}
]

按 Ctrl+O 保存,确认 body-probes.json 并按 Enter,再按 Ctrl+X 退出。使用前先读取实际文件:

cat body-probes.json

预测两个结果,再以 200ms 预算发起请求:

go run . -input body-probes.json -workers 1 -timeout 200ms

预期 body-200 为状态码 200 的 timeout,body-403 为状态码 403 的 http_error。读取正文前就已知道拒绝结果,因此正文延迟不能将它变为超时。客户端超时会停止等待;服务端可能稍后才完成有限工作。不要通过总耗时推断正确性。

最后,重新运行原始完整任务列表:

go run . -input targets.json -workers 1 -timeout 200ms

按 ID 匹配,而非按行位置。预期有两个 ok 结果、一个状态码 403 的 http_error、一个状态码 200 的 content_mismatch、一个状态码零的 timeout,以及一个状态码零的 connection_error。业务失败是普通报告值,因此命令仍应成功结束。编译错误或 JSON 语法错误表示命令未能完成工作:根据文件名和位置修复本地编辑,再次运行该命令。课程检查会在独立输入上测试你的原样源码及正文关闭行为;不要求特定编辑器或补充文件名。

保存并读取完整报告

本步骤在保留每项任务的同时,保存一份有用的报告。读取准备好的顺序循环:

cat run.go

http.Client{Timeout: budget} 为每个请求设置有限的时间预算。循环为每个目标调用一次 Check,并追加每个结果,包括错误结果。它不能在第一个失败入口处提前返回,也不能用以 URL 为键的 map 收集结果。初始化后的空切片还会在没有任务时生成空 JSON 数组。

现在保存完整报告。这次有意替换的只有报告文件;输入任务保持不变。

go run . -input targets.json -workers 1 -timeout 200ms > report.json

读取你实际生成的文件:

cat report.json

使用 ID,将两项签到任务对应到同一个 URL。每项都必须恰好出现一次。403 结果告诉组织者,服务器已经响应并拒绝了请求,而不是发生连接中断。地图标记不符则提醒他们,即便状态码是 200,也仍需调查内容。超时表示本次检查超过了时间预算,并不表示服务永远不会响应。

最后,试用准备好的空任务列表。标准输出直接显示在终端,因此不会替换你有用的报告:

go run . -input empty.json -workers 1 -timeout 200ms

预期显示 [] 并正常完成。保留 report.json、更新后的 check.go 和所有原始输入。课程检查除了读取这份报告,还会在独立的本地受控服务上运行你的原样源码,因此复制结果行或生成静态结果不能代替真实请求。

总结

你为每个分配的本地入口生成了真实结果,区分了 HTTP 拒绝与传输失败,识别了内容不符,为 HTTP 请求设置了有限时间预算,并关闭了每个收到的响应正文。你保存并读取了完整 JSON 报告,将重复 URL 保留为不同的任务 ID。下一个单元会保留这些判断,改变少量工作者分担任务的方式。