介绍
失物招领网页现在需要有意义的搜索功能。寻找蓝色包袋的访客,不应得到所有蓝色物品或所有包袋。不存在的物品、无效类别和没有匹配结果的有效搜索,也需要不同的响应。
这个独立工作区已提供上一个引导实验中的列表和详情处理函数。你需要掌握该实验介绍的 HTTP、JSON 和 httptest 概念,或具备同等经验。你将组合筛选条件、添加明确的错误响应,并使用实际网页。包括阅读和处理普通错误,预计需要约 55 分钟;这是作者估计。
组合名称和类别筛选
本步骤让两个搜索条件同时作用于同一条记录。从 Workspace 开始。恢复工作目录,并把准备好的 Go 安装目录加入当前终端的搜索路径。export 只影响当前 Shell;$PATH 保留已有条目。
cd /home/labex/project/lost-found
export PATH=/usr/local/go/bin:$PATH
先使用能够正常工作的列表,并阅读数据:
curl -i 'http://127.0.0.1:8080/api/items'
cat items.json
预期看到全部六条记录。切换到 Lost and Found,输入 blue,选择 bags,再选择 Search collection。当前处理函数忽略这些输入,仍显示全部六件物品。返回 Workspace,补上缺失的判断逻辑。
URL 的查询字符串位于 ? 之后。category=bags&q=blue 提供两个具名参数,以 & 分隔。API 列表搜索只接受 category 和 q,每个参数最多出现一次。本步骤先实现有效搜索;下一步处理无效输入。
契约有明确规定:未提供类别或类别为空表示全部类别。否则,其拼写和大小写必须匹配 items.json 中存在的类别。关键词 q 不区分大小写地匹配名称子字符串,忽略首尾空白。未提供关键词、关键词为空或仅包含空白时,匹配全部名称。如果同时提供两个筛选条件,两个都必须匹配(AND)。保留每个匹配 ID 的完整字段,不能重复;结果顺序不限。有效但没有匹配项的搜索返回 HTTP 200 和 []。
r.URL.Query().Get 读取解码后的参数。strings.TrimSpace 移除首尾空白,strings.ToLower 执行 Unicode 小写转换,strings.Contains 检查子字符串。这是简单的小写比较,不会移除重音,也不是按特定语言规则工作的搜索引擎。make([]Item, 0) 创建空的非 nil 切片,让 JSON 在没有匹配项时编码为 []。&& 组合两个必需条件;在这里使用 || 会错误地包含只满足其中一个条件的记录。
编辑之前,根据 items.json 预测三个结果:只有关键词 blue、只有类别 bags,以及两者同时存在。两者的交集只有 LF-106。两把雨伞说明了为什么返回满足任一条件的记录是错误的。
保留已有详情代码。使用第一个实验的 nano 流程:Ctrl+W 定位文本,Ctrl+O 再按 Enter 保存到已有文件名,Ctrl+X 退出。备份必须使用非 .go 扩展名,避免 Go 编译第二个处理函数。
cp handler.go handler.go.backup
LC_ALL=C.UTF-8 nano handler.go
找到 /api/items 集合分支,只替换其中的集合选择逻辑。保留响应头、详情循环和缺失 ID 响应。使用 items 参数实现以下判断:
- 用
r.URL.Query().Get读取解码后的category和q参数。类别保持传入时的原样。在循环之前对关键词先应用strings.TrimSpace,再应用strings.ToLower。 - 用
make([]Item, 0)初始化结果切片。不要将原来的完整切片复用为结果,否则会保留不匹配的记录。 - 用
for _, item := range items遍历记录。判断类别是否缺失/为空或等于item.Category,再独立判断转换为小写的名称是否包含规范化后的关键词。用&&组合这两个判断。用append追加匹配的完整item,不要只追加名称,也不要取另一条记录的 ID。 - 在集合分支中编码结果切片并返回。保留按 ID 选择详情的逻辑;不要对详情请求应用集合筛选条件。
作为对照,类别条件可以表示为 category == "" || item.Category == category:这里的 || 表示没有限制,或者属于所请求的类别。它不能替代类别与名称条件之间的 AND。选择能让这些独立判断容易阅读的局部变量名。
保存、退出,并检查实际保存的文件:
cat handler.go
格式化并重新构建。-w 让 gofmt 更新文件。serve.py 构建你的代码,只在构建成功后替换工作区记录的服务器。编译错误会保留旧版本运行;修正报告的行,再重复两个命令。
gofmt -w handler.go
python3 serve.py
现在发送组合搜索。引号将 & 保留在 URL 内,避免被当作 Shell 操作符:
curl -i 'http://127.0.0.1:8080/api/items?category=bags&q=blue'
预期只有 LF-106,即位于 Sports hall 的 Blue sports bag。如果也返回雨伞,说明使用了 OR。有效但无法匹配的关键词必须产生空数组:
curl -i 'http://127.0.0.1:8080/api/items?q=not-in-the-collection'
对于包含空格、+ 或非 ASCII 字符的文本,让 curl 编码。-G 将提供的数据放入 GET 查询字符串,--data-urlencode 对该参数进行百分号编码。如果不编码,查询数据中的字面加号可能被解码为空格。
curl -G -i --data-urlencode 'q= CAFÉ + ' 'http://127.0.0.1:8080/api/items'
预期返回 LF-104,即位于 Welcome booth 的 Café + Map。大写关键词和首尾空格经过规范化处理,而字面加号仍属于名称。如果路径输入有误,用 cd /home/labex/project/lost-found 恢复;如果服务不可用,用 cat 阅读 .work/server.log,修正错误后重新构建。
区分无效请求和空搜索结果
本步骤通过状态和 JSON 同时呈现错误,再使用完成的页面。从 Workspace 开始,保持相同工作目录。
集合查询包含未知类别、未知参数或重复参数时,使用 400。类别拼写必须精确匹配;Bags 与 bags 不同。空的 category 和 q 仍然有效。未知物品 ID 或未知 API 路径使用 404。对 /api/items 和 /api/items/<id> 使用非 GET 方法时,返回 405,并带上 Allow: GET。错误正文是包含非空 error 字符串的对象;具体措辞不限。有效但没有匹配项的搜索仍返回 200 和 []。详情请求根据 ID 选择物品;集合查询筛选条件不适用于详情。准备好的首页 / 保持可用。
url.ParseQuery(r.URL.RawQuery) 解析查询,同时返回参数值和错误,因此可以拒绝错误的查询编码。每个键对应一个值切片:其长度能揭示重复参数。本示例根据当前源记录检查类别,不使用固定的类别列表,因此另一个服务台也可以复用处理函数。
写正文前先设置响应头。提前返回可防止错误响应继续执行到正常成功结果。 编辑之前,将上一步有效但为空的搜索与下面被拒绝的请求比较。预测其状态为何不同。物品 ID 不存在与集合查询格式错误,是不同的失败。保留上一步的搜索判断;本次编辑在其周围增加检查。
LC_ALL=C.UTF-8 nano handler.go
用 Ctrl+W 找到返回的处理函数。在导入中添加 net/url,以使用 url.ParseQuery。不要替换整个文件,实现以下检查:
首先检查路径。集合路径恰好是 /api/items;详情以 /api/items/ 开头。其他 API 路径必须返回 404,不能落入普通页面或列表。然后在识别出的路由上检查方法:如果不是 GET,将 Allow 设置为 GET,返回 405 JSON 错误。
接着,如果有帮助,可以复用一个局部错误写入函数。它接受整数状态和字符串消息,调用 w.WriteHeader(status),再编码 map[string]string{"error": message}。content-type 响应头仍须最先设置。选择非空消息;措辞不参与评分。
然后,将详情选择保留在集合查询解析之前,使查询筛选条件不改变 ID 查找。缺失详情仍返回 404 和 JSON 错误。
之后,在集合分支中,用 url.ParseQuery 解析 r.URL.RawQuery。使用参数值前检查其返回的错误;错误编码必须返回 400。百分号转义需要两个十六进制数字,因此 /api/items?q=%ZZ 是可以在测试中尝试的具体错误输入。遍历每个键及其值切片。拒绝除 category 或 q 之外的键,也拒绝长度不为一的切片。每次拒绝后立即返回。
接下来,从解析结果读取通过检查的参数。类别非空时,遍历传入的记录,判断该精确类别是否存在;不存在则用 400 拒绝。不要将 items.json 中的固定类别列表复制进程序。另一个服务台可能有全新的类别。
最后,在这些检查成功后,运行已有的 AND 搜索,编码结果数组。空类别和空关键词仍然有效。无匹配结果保持为 [],不要改成错误。
按 Ctrl+O、Enter 保存,再按 Ctrl+X 退出。如果编辑器提示的文件名不符合预期,用 Ctrl+C 取消提示并检查路径。修复编译错误时,保留已经正常工作的搜索和详情代码。
gofmt -w handler.go
python3 serve.py
以下请求会故意被拒绝。依次预期得到 400、400、404 和 405。-X POST 选择 POST 方法;这是只读服务,不得创建任何内容。
curl -i 'http://127.0.0.1:8080/api/items?category=unknown'
curl -i 'http://127.0.0.1:8080/api/items?q=blue&q=bag'
curl -i 'http://127.0.0.1:8080/api/not-a-route'
curl -X POST -i 'http://127.0.0.1:8080/api/items'
每个响应都有 JSON error;POST 响应还包含 Allow: GET。curl 收到这些响应,与没有收到 HTTP 状态或 JSON 响应的连接失败不同。不要因为预期内的 HTTP 错误就重置工作区。
使用测试用例表,将相关预期放在一起。这里重复使用上个实验中的记录器/请求方法。url.Values.Encode() 在 Go 中编码包含 Unicode 和加号的查询。将列表响应解码到 []Item,检查 json.Unmarshal 返回的错误后再查看内容。对于空结果,长度为零的非 nil 切片表示 [];nil 切片表示 null。你已经执行的实际搜索也检查了真实物品内容。
自行创建表驱动测试,使用独立记录,不使用在线集合。选择一个小 fixture:两个类别中各有一个相同名称的物品,再有第三个不匹配的物品。先预测组合搜索的精确 ID 集合;这样能发现只断言状态无法发现的 OR 实现错误。
LC_ALL=C.UTF-8 nano handler_test.go
与第一个实验相同,以 package main 开头,导入所用包,将测试放在接受 *testing.T 的 Test 函数中。用 net/http/httptest 构造请求/记录器,用 encoding/json 解码,用 testing 断言;如需编码查询则使用 net/url。在测试内部,从 fixture 构建一次处理函数。
用匿名结构体切片表示相关请求。例如,字段 name string、path string 和 status int 为每个用例提供标签、请求路径和预期状态。自行提供各行:包括成功的组合搜索、空搜索、无效类别、重复键、错误编码和缺失 ID。for _, tc := range cases 循环将每个用例的请求应用到一个新记录器。将 tc.path 用于 httptest.NewRequest("GET", tc.path, nil);比较 recorder.Code 与 tc.status,在失败输出中包括用例名。复用记录器会混合响应正文,因此每个用例都要创建新记录器。
Go 的 t.Run(tc.name, func(t *testing.T) { ... }) 可选择为每行建立具名子测试。将省略号替换为记录器、请求和断言语句。失败的 t.Fatalf 应同时报告预期状态和实际观察到的状态。
仅有状态不能证明搜索正确。对于成功的组合用例,解码到 []Item,检查解码错误,再将返回的 ID 和字段与匹配的 fixture 记录比较。对于空结果用例,声明 var got []Item,检查解码错误,然后要求 got != nil 且 len(got) == 0。这无需类型转换即可区分空数组与 null。对于被拒绝的用例,解码到 map[string]string,要求非空 error。再为一个识别出的路由添加独立 POST 请求,同时检查 405 和 recorder.Header().Get("Allow") == "GET"。这些都使用相同记录器方法,只是预期结果不同。
fixture、用例名和具体断言由你决定。至少编写一个在结果额外包含同名物品时会失败的断言。按 Ctrl+O、Enter 保存,再按 Ctrl+X 退出。
gofmt -w handler_test.go
go test -v .
预期看到 PASS。测试失败时,将显示的路径和状态与上面的契约比较。修正处理函数、重新运行测试,再用 python3 serve.py 重新构建,让浏览器使用当前代码。保持支持文件 main.go、go.mod、items.json、serve.py 和 web/ 不变。可以编辑 handler.go,添加或编辑自己的 Go 源代码和测试,但不要替换源集合或网页。
切换到 Lost and Found 并重新加载。在类别 bags 中搜索 blue;预期只有一张卡片,编号为 LF-106。打开 View item,阅读 Sports hall,再用 Back to collection 关闭详情。搜索 not-in-the-collection;预期看到 0 items found 和 No matching items。清空关键词,选择 All categories,再搜索;全部六条记录必须重新出现。用键盘 Tab 访问字段和按钮;Enter 提交表单,Escape 关闭打开的详情对话框。

观察选中的类别、只有一个结果的数量,以及匹配的编号。页面的每次搜索和详情查看都使用你的 API。不可用提示代表实际请求问题;页面绝不会用内置集合替代结果。
总结
你组合了规范化的名称筛选和精确的类别筛选,保留了物品身份,为有效但无结果的搜索返回 [],并区分了 400、404 和 405 错误。表驱动测试和实际网页都调用了你的处理函数。挑战将把相同契约迁移到另一个服务台的数据。



