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":"待办不存在"}

要点讲解:

实战 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 文本)

要点讲解:

实战 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

要点讲解:

小结

三个项目分别对应 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 教程目录

✈️💬