Go 实战项目
学了 19 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可直接运行的 Go 项目——REST API 服务、命令行工具、并发爬虫,每个都从需求出发,给出完整代码、运行结果和要点讲解。它们分别对应 Go 在生产环境最常见的三类应用:写后端接口、做运维/数据处理 CLI、并发抓取与汇总。建议你把每段代码都复制到本地亲手跑一遍,再试着改一改需求,这才是真正"上手"的感觉。
实战 1:REST API 服务(net/http 实现 CRUD)
需求:用 Go 标准库 net/http 写一个"待办事项" REST 接口,支持 GET /todos 列出、POST /todos 新增、GET /todos/123 查看单条、PUT /todos/123 改、DELETE /todos/123 删。数据用内存 map 存,无需任何第三方框架。
涉及知识:net/http 路由、JSON 编解码、HTTP 方法分发、状态码、sync.RWMutex 保护并发 map、错误响应标准化。无第三方依赖,保存为 main.go 直接 go run main.go 即可。
// main.go — REST API:用纯标准库 net/http 实现"待办事项"CRUD 服务
// 启动: go run main.go
// 然后另开一个终端用 curl 测试(示例见文件末尾)
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"sync"
)
// Todo 一条待办
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var (
mu sync.RWMutex // 读写锁,保护下面的 map
todos = map[int]Todo{} // 内存存储(重启即丢,演示用)
nextID = 1 // 自增主键
)
// writeJSON 统一把任意值用 JSON 写回客户端
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// errResp 返回标准化的错误信息
func errResp(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/todos", todosHandler) // /todos
mux.HandleFunc("/todos/", todoHandler) // /todos/{id}
addr := ":8080"
log.Printf("服务启动: http://localhost%s/todos", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
// todosHandler 处理集合:/todos → GET 列出全部、POST 新增
func todosHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
mu.RLock()
list := make([]Todo, 0, len(todos))
for _, t := range todos {
list = append(list, t)
}
mu.RUnlock()
writeJSON(w, http.StatusOK, list)
case http.MethodPost:
var in struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || strings.TrimSpace(in.Title) == "" {
errResp(w, http.StatusBadRequest, "title 不能为空")
return
}
mu.Lock()
t := Todo{ID: nextID, Title: in.Title}
todos[nextID] = t
nextID++
mu.Unlock()
writeJSON(w, http.StatusCreated, t)
default:
errResp(w, http.StatusMethodNotAllowed, "仅支持 GET / POST")
}
}
// todoHandler 处理单个:/todos/{id} → GET 查看、PUT 更新、DELETE 删除
func todoHandler(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/todos/")
id, err := strconv.Atoi(idStr)
if err != nil {
errResp(w, http.StatusBadRequest, "id 必须是整数")
return
}
switch r.Method {
case http.MethodGet:
mu.RLock()
t, ok := todos[id]
mu.RUnlock()
if !ok {
errResp(w, http.StatusNotFound, "待办不存在")
return
}
writeJSON(w, http.StatusOK, t)
case http.MethodPut:
var in struct {
Title string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
errResp(w, http.StatusBadRequest, "请求体格式错误")
return
}
mu.Lock()
defer mu.Unlock()
t, ok := todos[id]
if !ok {
errResp(w, http.StatusNotFound, "待办不存在")
return
}
if strings.TrimSpace(in.Title) != "" {
t.Title = in.Title
}
t.Done = in.Done
todos[id] = t
writeJSON(w, http.StatusOK, t)
case http.MethodDelete:
mu.Lock()
defer mu.Unlock()
if _, ok := todos[id]; !ok {
errResp(w, http.StatusNotFound, "待办不存在")
return
}
delete(todos, id)
w.WriteHeader(http.StatusNoContent)
default:
errResp(w, http.StatusMethodNotAllowed, "仅支持 GET / PUT / DELETE")
}
}
// === curl 测试示例(服务启动后,在另一个终端运行)===
// # 1. 新增一条
// curl -s -X POST http://localhost:8080/todos \
// -H "Content-Type: application/json" \
// -d '{"title":"学 Go 标准库"}'
// 输出: {"id":1,"title":"学 Go 标准库","done":false}
//
// # 2. 再加一条
// curl -s -X POST http://localhost:8080/todos \
// -H "Content-Type: application/json" \
// -d '{"title":"写一个 CLI"}'
// 输出: {"id":2,"title":"写一个 CLI","done":false}
//
// # 3. 列出全部
// curl -s http://localhost:8080/todos
// 输出: [{"id":1,"title":"学 Go 标准库","done":false},{"id":2,"title":"写一个 CLI","done":false}]
//
// # 4. 改 id=1 为已完成
// curl -s -X PUT http://localhost:8080/todos/1 \
// -H "Content-Type: application/json" \
// -d '{"done":true}'
// 输出: {"id":1,"title":"学 Go 标准库","done":true}
//
// # 5. 查看单条
// curl -s http://localhost:8080/todos/1
// 输出: {"id":1,"title":"学 Go 标准库","done":true}
//
// # 6. 删除
// curl -i -X DELETE http://localhost:8080/todos/2
// 输出: HTTP/1.1 204 No Content
//
// # 7. 故意查不存在的 → 404
// curl -s http://localhost:8080/todos/99
// 输出: {"error":"待办不存在"}要点讲解:
- 标准库就够用:Go 1.22 之后
ServeMux已支持方法+路径模式(如"GET /todos/{id}"),本例为兼容老版本用手动判断r.Method,逻辑同样清晰。 - 两条路由:
/todos走集合(列出全部 / 新增),/todos/末尾带斜杠匹配/todos/123这种带 id 的单个资源,通过TrimPrefix取出 id。 - JSON 序列化:结构体字段后面那个反引号标签
json:"title"告诉encoding/json序列化时用什么字段名;json.NewEncoder(w).Encode(v)一步把对象写成 JSON 流回客户端。 - 并发安全:map 在 Go 里不是并发安全的,多个请求(每个都在自己的 goroutine 里)同时写会 panic,所以必须用
sync.RWMutex加锁;读多写少用RLock提高并发度。 - 状态码:新增用
201 Created,删除成功用204 No Content,找不到用404,方法不支持用405——遵循 HTTP 语义,客户端好处理。 - 可扩展方向:换用 SQLite/PostgreSQL 持久化(配合
database/sql)、加 JWT 鉴权中间件、用 Gin/Echo 框架简化路由、把todos抽成Store接口方便换实现。
实战 2:命令行工具 logview(flag 子命令)
需求:写一个日志处理 CLI logview,支持三个子命令——count 统计日志总行数和 ERROR 行数、top 找出出现次数最多的 IP、rename 批量把某目录下 .txt 改成别的扩展名。像 git add / git commit 那样,每个子命令有独立的参数和帮助。
涉及知识:flag 包(子命令、自定义 FlagSet)、文件读取 + bufio.Scanner 逐行扫描、map 统计 + 切片排序、filepath.Walk 遍历目录、-dry-run 安全开关。无第三方依赖。
// logview.go — 日志处理 CLI(三个子命令:count / top / rename)
// 编译: go build -o logview logview.go
// 用法: ./logview count app.log
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
// 把第一个参数当子命令分发,剩下的参数交给对应子命令解析
switch os.Args[1] {
case "count":
cmdCount(os.Args[2:])
case "top":
cmdTop(os.Args[2:])
case "rename":
cmdRename(os.Args[2:])
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "未知子命令: %s
", os.Args[1])
usage()
os.Exit(1)
}
}
func usage() {
fmt.Print(`logview — 日志处理 CLI
用法:
logview count <file> 统计总行数与 ERROR 行数
logview top <file> [-n N] 取出现次数最多的 IP Top N
logview rename <dir> [-ext ext] 把目录下 .txt 改名(默认改成 .log)
logview -h 显示本帮助
示例:
logview count app.log
logview top app.log -n 10
logview rename ./logs -ext .log
`)
}
// cmdCount: 统计总行数、ERROR 行数
func cmdCount(args []string) {
fs := flag.NewFlagSet("count", flag.ExitOnError)
fs.Parse(args)
if fs.NArg() < 1 {
log.Fatal("用法: logview count <file>")
}
f, err := os.Open(fs.Arg(0))
if err != nil {
log.Fatal(err)
}
defer f.Close()
var total, errors int
sc := bufio.NewScanner(f)
// bufio.Scanner 默认每行上限 64KB,大日志可调大 buffer
for sc.Scan() {
total++
if strings.Contains(sc.Text(), "ERROR") {
errors++
}
}
if err := sc.Err(); err != nil {
log.Fatal(err)
}
fmt.Printf("总行数: %d
ERROR 行数: %d
", total, errors)
}
// cmdTop: 出现最多的 IP Top N(假设每行格式: IP - - [时间] ...)
func cmdTop(args []string) {
fs := flag.NewFlagSet("top", flag.ExitOnError)
n := fs.Int("n", 5, "取前 N 名")
fs.Parse(args)
if fs.NArg() < 1 {
log.Fatal("用法: logview top <file> [-n N]")
}
f, err := os.Open(fs.Arg(0))
if err != nil {
log.Fatal(err)
}
defer f.Close()
// 用 map 统计每个 IP 出现次数
counts := map[string]int{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
// 简化处理:取第一个空格前的字段当作 IP
if i := strings.Index(line, " "); i > 0 {
counts[line[:i]]++
}
}
// map 没法直接排序,转成切片再排
type kv struct {
Key string
Val int
}
list := make([]kv, 0, len(counts))
for k, v := range counts {
list = append(list, kv{k, v})
}
sort.Slice(list, func(i, j int) bool { return list[i].Val > list[j].Val })
if *n > len(list) {
*n = len(list)
}
fmt.Printf("Top %d IP:
", *n)
for i := 0; i < *n; i++ {
fmt.Printf(" %4d %s
", list[i].Val, list[i].Key)
}
}
// cmdRename: 把目录下所有 .txt 文件批量改扩展名
func cmdRename(args []string) {
fs := flag.NewFlagSet("rename", flag.ExitOnError)
ext := fs.String("ext", ".log", "目标扩展名")
dry := fs.Bool("dry-run", false, "只打印将做什么,不真正改名")
fs.Parse(args)
if fs.NArg() < 1 {
log.Fatal("用法: logview rename <dir> [-ext ext]")
}
dir := fs.Arg(0)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || filepath.Ext(path) != ".txt" {
return nil
}
newPath := strings.TrimSuffix(path, ".txt") + *ext
if *dry {
fmt.Printf("[DRY] %s -> %s
", path, newPath)
return nil
}
if err := os.Rename(path, newPath); err != nil {
log.Printf("[跳过] %s: %v", path, err)
return nil
}
fmt.Printf("[OK] %s -> %s
", path, newPath)
return nil
})
if err != nil {
log.Fatal(err)
}
}
// 假设 app.log 的内容:
// 192.168.1.10 - - [01/Jan/2024:06:05:30] "GET /index"
// 192.168.1.10 - - [01/Jan/2024:06:05:31] "GET /api"
// 10.0.0.5 - - [01/Jan/2024:06:05:32] "POST /login"
// 10.0.0.5 - - [01/Jan/2024:06:05:33] ERROR invalid token
// 172.16.0.3 - - [01/Jan/2024:06:05:34] "GET /health"
//
// $ go run logview.go count app.log
// 输出:
// 总行数: 5
// ERROR 行数: 1
//
// $ go run logview.go top app.log -n 2
// 输出:
// Top 2 IP:
// 2 192.168.1.10
// 2 10.0.0.5
//
// $ go run logview.go rename ./logs -dry-run
// 输出:
// [DRY] logs/a.txt -> logs/a.log
// [DRY] logs/b.txt -> logs/b.log
//
// $ go run logview.go -h
// 输出:
// logview — 日志处理 CLI
// ...(同上 usage 文本)要点讲解:
- 子命令分发:Go 标准库做子命令的套路——取
os.Args[1]当子命令switch分发,剩下的os.Args[2:]交给每个子命令自己的flag.NewFlagSet解析,互不干扰。 flag.NewFlagSet:每个子命令一个独立的 flag 集合,这样-n只在top子命令里有意义、-ext只在rename里有意义,符合 UNIX 工具惯例。bufio.Scanner:按行读大文件不会一次性把整份日志读进内存,内存占用恒定;注意默认每行上限 64KB,单行特别长时要调大 buffer。- map 没法直接排序:把 map 转成结构体切片、再用
sort.Slice加自定义 less 函数,这是 Go 里"按值排序 map"的标准做法。 -dry-run:任何会改文件/删文件的 CLI 都该提供"先看后做"的 dry-run,这是运维工具的基本素养,能有效避免误操作。- 可扩展方向:加
grep子命令按正则过滤、加--since/--until按时间过滤、把统计结果输出成 JSON/CSV、用compress/gzip直接读.gz日志。
实战 3:并发爬虫(goroutine + channel + WaitGroup)
需求:给定一组 URL,并发抓取每个页面的 <title> 并统计响应耗时。要求:N 个请求并发发出(而不是串行);总耗时不能超过一个上限(超时未返回的请求自动取消);主 goroutine 能收集到全部结果后再打印汇总表。
涉及知识:goroutine 启动、sync.WaitGroup 等待全部完成、带缓冲 channel 收集结果、context.WithTimeout 总超时控制、http.NewRequestWithContext 让请求可被取消、循环变量绑定陷阱。
// crawler.go — 并发抓取多个 URL 的标题并统计响应耗时
// 技术点:goroutine + sync.WaitGroup + channel + context 超时
package main
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// Result 一次抓取的结果
type Result struct {
URL string
Title string
Duration time.Duration
Err error
}
// titleRe 提取 <title>...</title>(简单正则,生产环境建议用 golang.org/x/net/html)
var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
// fetch 抓单个 URL;ctx 用来在超时/取消时及时退出
func fetch(ctx context.Context, url string) Result {
start := time.Now()
// 把 ctx 塞进 request,client 在 ctx.Done 时会自动中断连接
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Result{URL: url, Err: err}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Result{URL: url, Err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return Result{URL: url, Err: err}
}
title := "(无 title)"
if m := titleRe.FindSubmatch(body); len(m) >= 2 {
title = strings.TrimSpace(string(m[1]))
}
return Result{URL: url, Title: title, Duration: time.Since(start)}
}
func main() {
urls := []string{
"https://golang.org",
"https://github.com",
"https://www.example.com",
"https://httpbin.org/delay/2", // 故意慢 2 秒
}
// 总超时:所有请求必须在 5 秒内完成,到点 ctx 自动取消
// 把它调到 1 秒,就能看到 delay/2 那个请求被 cancel 的效果
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 用带缓冲的 channel 收集每个 goroutine 的结果
results := make(chan Result, len(urls))
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
// 关键:循环变量要重新绑定,不然 goroutine 拿到的可能都是最后一个
url := u
go func() {
defer wg.Done()
results <- fetch(ctx, url)
}()
}
// 另起一个 goroutine 等 everyone 完成后再关 channel,这样下面才能 range 出全部
go func() {
wg.Wait()
close(results)
}()
fmt.Printf("%-30s %-10s %s
", "URL", "耗时", "标题")
fmt.Println(strings.Repeat("-", 72))
ok, fail := 0, 0
for r := range results {
if r.Err != nil {
fail++
fmt.Printf("%-30s %-10s 错误: %v
", r.URL, "-", r.Err)
continue
}
ok++
fmt.Printf("%-30s %-10s %s
", r.URL, r.Duration.Round(time.Millisecond), r.Title)
}
fmt.Println(strings.Repeat("-", 72))
fmt.Printf("成功 %d,失败 %d,共 %d
", ok, fail, len(urls))
}
// 输出(每次顺序可能不同,因为是并发):
// URL 耗时 标题
// ------------------------------------------------------------------------
// https://www.example.com 120ms Example Domain
// https://golang.org 340ms The Go Programming Language
// https://github.com 420ms GitHub: Let's build from here · GitHub
// https://httpbin.org/delay/2 2.01s (无 title)
// ------------------------------------------------------------------------
// 成功 4,失败 0,共 4
//
// 把上面 ctx 的超时改成 1*time.Second,慢请求会被取消:
// https://httpbin.org/delay/2 - 错误: Get "...": context deadline exceeded要点讲解:
- 并发 vs 串行:4 个 URL 串行抓可能要 5 秒,并发抓总耗时约等于最慢那一个(本例 ~2 秒)。这是 Go 并发模型最直观的收益。
go func():一句go关键字就起一个 goroutine,代价仅几 KB 栈,远比 OS 线程轻量。这是 Go 写并发的杀手锏。- 循环变量陷阱:Go 1.22 之前
for _, u := range里的u每轮是同一个变量,goroutine 闭包捕获后可能都拿到最后一个;本例显式url := u重新绑定一份,兼容所有版本,稳。 WaitGroup+ channel 配合:wg.Wait()等所有 goroutine 写完结果,再用一个匿名 goroutine 在等完后close(results),主 goroutine 就能放心for r := range results收尾——这是"fan-out 并发 + 主流程收尾"的经典模式。context.WithTimeout:context 是 Go 控制并发生命周期的标准答案。把它塞进 request,超时一到ctx.Done()触发,HTTP client 自动断开,goroutine 立刻收到context deadline exceeded错误返回,不会泄漏。- 带缓冲 channel:
make(chan Result, len(urls))缓冲设成任务数,这样每个 goroutine 写结果时不会阻塞,关闭后range出全部——典型"并发收集"骨架。 - 可扩展方向:加并发度上限(用带缓冲的 semaphore channel)、设置每个请求的
http.Client{Timeout:}、抓title换成golang.org/x/net/html真 HTML 解析、把结果写 CSV/Prometheus metrics。
小结
三个项目分别对应 Go 在生产环境最常见的三类场景:写后端 HTTP 服务(net/http + JSON + 锁)、做运维 / 数据处理 CLI(flag + 文件扫描)、并发抓取与汇总(goroutine + channel + context)。它们都用到了前面学的基础——结构体、接口、map、错误处理、并发原语——只是组合起来解决真实问题。建议你挑其中一个改造成自己的版本:给 REST API 换 SQLite 存储、给 logview 加 grep 子命令、给爬虫加并发上限和重试,做过一遍比读十遍更扎实。
恭喜!到这里 Go 系列就真正完结了。接下来可以挑一个方向深入:读 Kubernetes / etcd 源码学架构、用 Gin / Echo 写一个完整 Web 后端、用 Cobra 做工业级 CLI、用 gRPC 写微服务、用 Go 操作 Kubernetes 写 Operator——Go 的生态非常适合"边做边学"。
← 上一篇 Go 包管理
返回 Go 教程目录