书签检索服务实战
书签检索服务实战 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课把 G05 到 G14 的零件组装成完整书签检索服务:管理、标签、关键词查询、分页一次到位,接上前端示例,交付任何人 clone 后三条命令能跑起来的项目。
问题场景
零件齐了:路由(G05)、校验(G06)、中间件(G07)、SQLite(G08)、并发(G09/G10)、context(G12)、测试(G13)、退出(G14)。现在是项目 p7-go-bookmark 的正餐:把它们拼成一个前端真的能用的服务,并且让「跑起来」这件事可复现。
核心解释
路由收进 newMux,一眼看清服务全貌:
func newMux(s Store, logger *slog.Logger) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthHandler(s))
mux.HandleFunc("GET /bookmarks", s.handleList) // ?q= &tag= &page= &page_size=
mux.HandleFunc("POST /bookmarks", s.handleCreate)
mux.HandleFunc("GET /bookmarks/{id}", s.handleGet)
mux.HandleFunc("PUT /bookmarks/{id}", s.handleUpdate)
mux.HandleFunc("DELETE /bookmarks/{id}", s.handleDelete)
return withRecovery(withLogging(withRequestID(mux)))
}
列表接口升级成检索:关键词、标签、分页三种条件组合,翻译成 SQL:
type SearchQuery struct {
Keyword string
Tag string
Page int // 从 1 开始
PageSize int // 服务端钳制在 1-50
}
type Page struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []Bookmark `json:"items"`
}
func (s *SQLiteStore) Search(ctx context.Context, q SearchQuery) (Page, error) {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 50 {
q.PageSize = 20
}
where := " WHERE 1=1"
var args []any
if q.Keyword != "" {
where += " AND (title LIKE ? OR url LIKE ?)"
like := "%" + q.Keyword + "%"
args = append(args, like, like)
}
if q.Tag != "" {
where += " AND id IN (SELECT bookmark_id FROM bookmark_tags WHERE tag = ?)"
args = append(args, q.Tag)
}
var total int
if err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM bookmarks"+where, args...).Scan(&total); err != nil {
return Page{}, fmt.Errorf("count bookmarks: %w", err)
}
args = append(args, q.PageSize, (q.Page-1)*q.PageSize)
rows, err := s.db.QueryContext(ctx,
"SELECT id, title, url, created_at FROM bookmarks"+where+
" ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", args...)
// rows 循环 Scan 收集 items,defer rows.Close(),结束后检查 rows.Err()(G08 已练过)
_ = rows
return Page{Total: total, Page: q.Page, PageSize: q.PageSize, Items: items}, nil
}
分页契约为前端提供完整信息:total 用来算总页数,page/page_size 回显当前状态——只返回 items 数组,前端就得再发一次 COUNT 或瞎猜有没有下一页。分页参数做服务端钳制(PageSize 上限 50),防止 ?page_size=1000000 变相拖库。条件全拼进同一份 where/args,COUNT 和取数天然一致,不会出现「总数对不上列表」的尴尬。
前端接入只差跨域:开发期让 Next.js 示例通过 rewrites 把 /api/* 代理到 8080,或给服务加一个放行本机来源的 CORS 中间件。rewrites 只需在 next.config 里把 /api/:path* 指到 http://127.0.0.1:8080/:path*,前端统一请求 /api 开头;选 CORS 方案则要放行来源白名单并正确响应 OPTIONS 预检。两条路二选一,不要同时上。项目以 rewrites 方案为准,避免生产环境 CORS 配置裸奔。
示例拆解
go run .
curl "http://127.0.0.1:8080/bookmarks?q=go&tag=docs&page=1&page_size=5"
# {"total":3,"page":1,"page_size":5,"items":[...]}
# 再翻一页,确认 OFFSET 生效:items 变化且 total 稳定
curl "http://127.0.0.1:8080/bookmarks?page=2&page_size=2"
curl -X POST http://127.0.0.1:8080/bookmarks \
-d '{"title":"检索课","url":"https://go.dev/ref/spec","tags":["go","spec"]}'
# 201 Created
go vet ./... && go test ./... -race -count=1
# 全绿后提交
检索行为用三个维度交叉验证:只带关键词、只带标签、两个全带,total 和 items 都对得上,才说明 where 条件拼接正确;再翻一页确认 OFFSET 生效,分页这关才算过。
项目目录与可复现命令写进 README,这是交付的一部分:
p7-go-bookmark/
├── main.go // 装配与退出编排(G14)
├── store.go // Store 接口(G03)+ MemoryStore
├── store_sqlite.go // SQLiteStore + Search(G08/G12)
├── middleware.go // requestID/logging/recovery(G07)
├── *_test.go // 表驱动 + httptest(G13)
└── README.md // go run . 启动 / go test ./... -race 检查
验收标准:新环境 clone 后 go mod tidy、go run .、go test ./... 三条命令全部按预期工作,前端示例能列出、搜索、新建书签。
动手任务
- 给 handleList 补 q/tag/page/page_size 的解析与钳制,写表驱动测试覆盖非法值回退默认的分支。
- 加 GET /tags 返回全部标签及使用计数(GROUP BY tag ORDER BY count DESC),前端侧栏用。
- 按验收标准在新目录完整走一遍 clone → tidy → run → test,把卡住的点修进 README。
常见错误
- 分页不钳制 page_size:一次查询拖回全表,接口被人当导出工具用。
- COUNT 和取数条件不一致:total 与 items 对不上,前端分页逻辑跟着错乱。
- LIKE 关键词不转义 % 和 _:用户输入 % 会全表匹配,检索语义失真。
- README 只写「见代码」:可复现命令是项目交付物,跑不起来等于没交付。
检查与复盘
完成下方检查题。自查清单:
- 列表接口支持关键词、标签、分页三种条件且可组合
- 分页契约含 total/page/page_size,参数在服务端钳制
- README 的启动与检查命令在新目录验证过
参考资料
- 标准库 net/http —— 路由与请求处理总览
- 标准库 database/sql —— 查询与聚合
- 标准库 log/slog —— 服务日志
- Go Spec(语言参考) —— 语言细节仲裁
读完这一课了?
标记完成后,工作台会安排接下来的学习。