3 커밋 881d292752 ... 3aef897fa7

작성자 SHA1 메시지 날짜
  zhuts 3aef897fa7 feat: 新增第二十课HTTP客户端与本地服务例程 1 일 전
  zhuts 106323a6a3 feat: 新增第十九课命令行参数与交互输入例程 1 일 전
  zhuts 6a81ce683a feat: 新增第十八课JSON编码解码例程与测试 1 일 전
10개의 변경된 파일과 966개의 추가작업 그리고 4개의 파일을 삭제
  1. 19 4
      README.md
  2. 163 0
      lessons/18_json/README.md
  3. 90 0
      lessons/18_json/main.go
  4. 69 0
      lessons/18_json/main_test.go
  5. 145 0
      lessons/19_cli/README.md
  6. 90 0
      lessons/19_cli/main.go
  7. 82 0
      lessons/19_cli/main_test.go
  8. 133 0
      lessons/20_http/README.md
  9. 98 0
      lessons/20_http/main.go
  10. 77 0
      lessons/20_http/main_test.go

+ 19 - 4
README.md

@@ -66,10 +66,22 @@
     │   ├── README.md       # 隐式实现、方法集、nil 与测试替身
     │   ├── main.go         # 不同类型通过统一接口自我介绍
     │   └── main_test.go    # 接口实现及测试替身验证
-    └── 17_files/           # 第 17 课:文件读写
-        ├── README.md       # 路径、读写、追加、关闭与逐行扫描
-        ├── main.go         # 在独立临时目录操作学习记录
-        └── main_test.go    # 使用临时目录隔离的文件测试
+    ├── 17_files/           # 第 17 课:文件读写
+    │   ├── README.md       # 路径、读写、追加、关闭与逐行扫描
+    │   ├── main.go         # 在独立临时目录操作学习记录
+    │   └── main_test.go    # 使用临时目录隔离的文件测试
+    ├── 18_json/            # 第 18 课:JSON 编码与解码
+    │   ├── README.md       # 标签、编码、解码和文件操作
+    │   ├── main.go         # 学习记录的 JSON 往返示例
+    │   └── main_test.go    # 往返、字段省略和非法输入测试
+    ├── 19_cli/             # 第 19 课:命令行输入与参数
+    │   ├── README.md       # 参数、交互输入、帮助和退出状态
+    │   ├── main.go         # 学习记录输入示例
+    │   └── main_test.go    # 模拟输入输出与边界测试
+    └── 20_http/            # 第 20 课:HTTP 客户端与服务端
+        ├── README.md       # 请求、响应、状态码与本地测试
+        ├── main.go         # 临时服务和客户端请求
+        └── main_test.go    # 服务处理和客户端测试
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -95,6 +107,9 @@
 | 15 | [包与模块](./lessons/15_packages/README.md) | package、import、module、导出、跨包测试 | 已建立 |
 | 16 | [接口](./lessons/16_interfaces/README.md) | 隐式实现、接口参数、方法集、nil、测试替身 | 已建立 |
 | 17 | [文件读写](./lessons/17_files/README.md) | filepath、整体读写、追加、Scanner、Close | 已建立 |
+| 18 | [JSON 编码与解码](./lessons/18_json/README.md) | 结构体标签、Marshal、Unmarshal、文件往返 | 已建立 |
+| 19 | [命令行输入与参数](./lessons/19_cli/README.md) | os.Args、flag、交互输入、校验、退出状态 | 已建立 |
+| 20 | [HTTP 客户端与服务端](./lessons/20_http/README.md) | 请求、响应、状态码、JSON、本地测试 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 163 - 0
lessons/18_json/README.md

@@ -0,0 +1,163 @@
+# 第 18 课:JSON 编码与解码
+
+## 学习目标
+
+将 Go 结构体编码为 JSON,把 JSON 解码回结构体,并结合上一课保存和读取文件。先阅读 [main.go](./main.go),运行成功后再看 [main_test.go](./main_test.go)。本课仅使用 Go 标准库,无需安装第三方依赖。
+
+例程每次创建独立的系统临时目录,将 JSON 写到其中的 `record.json`,**运行后保留文件**,终端会打印路径。它不会覆盖仓库中的资料。多次运行会产生多个目录,学习完可在核对完整路径后自行删除。
+
+## 1. JSON 是什么
+
+JSON 是一种常用的文本数据格式,适合存储和交换数据。例如:
+
+```json
+{
+  "title": "学习 Go JSON",
+  "minutes": 30,
+  "finished": true,
+  "tags": ["Go", "JSON"]
+}
+```
+
+对象使用 `{}`,键用双引号;数组使用 `[]`。字符串也要用双引号,不能把 Go 的反引号当成 JSON 的字符串标记。JSON 本身不允许注释或尾随逗号。
+
+## 2. Go 结构体与标签
+
+```go
+type StudyRecord struct {
+    Title    string   `json:"title"`
+    Minutes  int      `json:"minutes"`
+    Finished bool     `json:"finished"`
+    Tags     []string `json:"tags"`
+    Note     string   `json:"note,omitempty"`
+}
+```
+
+- 大写开头的字段可以导出,encoding/json 才能访问;小写字段不会作为普通 JSON 字段编码或解码。
+- 反引号内的是**结构体标签**,`json:"title"` 指定 JSON 中使用小写的 `title` 键,Go 代码中仍写 `record.Title`。
+- `omitempty` 表示**编码时**该字段为空值就省略。这里 Note 为 `""` 时省略;它不代表解码时这个字段必填或做校验。
+- 本例的 omitempty 是 encoding/json 对指定类型的空值规则,不能笼统理解为“所有类型的零值一定都会省略”。
+- 编码时空的非 nil 切片是 `[]`,nil 切片是 `null`;本课 Tags 有明确元素。
+
+## 3. 编码:Go 值 → JSON
+
+```go
+data, err := json.Marshal(record)
+```
+
+`Marshal` 返回 `[]byte` 和 `error`;数据类型不支持或出现不支持的值时可能失败,应检查错误。
+
+本课使用:
+
+```go
+data, err := json.MarshalIndent(record, "", "  ")
+```
+
+它输出带缩进的 JSON:第二个参数是每行前缀,第三个参数是每层缩进。想在终端查看,可用 `string(data)`;写文件则直接传 data。
+
+编码不是加密,也不会自动隐藏敏感信息。不要把密码或令牌写进需要共享的 JSON。
+
+## 4. 解码:JSON → Go 值
+
+```go
+var record StudyRecord
+err := json.Unmarshal(data, &record)
+```
+
+第二个参数传 `&record`,让 Unmarshal 修改这份结构体。传值而不是可用指针会返回错误。
+
+- JSON 语法错误、字段类型不匹配等会返回 error;**出现错误时不要当作完整成功结果使用**。
+- `decodeRecord` 在出错时返回零值和 error,避免调用方误用部分解码结果。
+- 默认情况下未知字段会被忽略;缺失字段在新建的零值结构体中保持零值。这不等于验证了必填项或业务规则。
+- 如果复用已有的非零结构体,JSON 中缺失的字段可能保留旧值;本例每次创建新变量再解码。
+- JSON `null` 解码到非指针整数、字符串等字段时通常不改变该字段且不报错;如需区分缺失、null 和零值,要另行设计。
+
+需要严格拒绝未知字段时,可用 `json.Decoder` 的 `DisallowUnknownFields`;若还要求只接受一个 JSON 值,需要进一步检查输入末尾。本课先掌握基本操作。
+
+## 5. 错误包装 `%w`
+
+```go
+return fmt.Errorf("读取记录:%w", err)
+```
+
+`%w` 在添加上下文的同时保留原始错误,供调用方用 `errors.Is` / `errors.As` 检查。本课先知道它与只格式化文本的 `%v` 不同;错误链的详细用法以后再学。
+
+`decodeRecord` 只检查语法和字段类型,不检查 Minutes 是否为负数、Title 是否为空等业务约束;真实项目需要另外做输入验证,也要根据场景限制文件和请求大小。
+
+## 6. 文件安全
+
+示例顺序:创建新临时目录 → MarshalIndent → WriteFile → ReadFile → Unmarshal。`WriteFile` 遇到同路径已有文件会覆盖,因此不要把示例路径改成重要资料。
+
+本例用 `0600` 指定新文件权限;实际权限受系统与已有文件状态影响,Windows 上需结合 ACL 考虑。写入到文件不自动保证断电安全,重要数据需要进一步考虑临时文件、同步和原子替换。
+
+## 编译并运行
+
+在**仓库根目录**执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/18_json | Out-Null
+go build -o ./bin/18_json/json_demo.exe ./lessons/18_json
+.\bin\18_json\json_demo.exe
+```
+
+修改源码后需要重新编译。快速编译并运行:
+
+```powershell
+go run ./lessons/18_json
+```
+
+也可以从根目录进入本课,再运行:
+
+```powershell
+Set-Location lessons/18_json
+go run .
+```
+
+## 预期输出
+
+临时路径每次不同,其余主要内容:
+
+```text
+=== 编码为 JSON ===
+{
+  "title": "学习 Go JSON",
+  "minutes": 30,
+  "finished": true,
+  "tags": [
+    "Go",
+    "JSON"
+  ]
+}
+记录文件(运行后保留): <每次生成的临时路径>
+
+=== 读取并解码 ===
+标题:学习 Go JSON,时长:30 分钟,完成:true,标签:[Go JSON]
+
+=== 错误示例 ===
+字段类型不匹配,解码失败
+```
+
+## 测试与练习
+
+在仓库根目录执行:
+
+```powershell
+go test -count=1 -v ./lessons/18_json
+```
+
+测试覆盖结构体→JSON→临时文件→结构体的往返、空 Note 的省略、语法错误和类型错误。`t.TempDir()` 的目录会由测试框架清理,与普通程序保留的演示目录不同。
+
+测试使用 `reflect.DeepEqual` 比较含切片的结构体:切片不能直接用 `==`,所以该结构体也不能直接用 `==`。本课暂把它当作比较整体内容的工具,反射原理留待后续;DeepEqual 会区分 nil 切片与非 nil 空切片。
+
+- [ ] 给 Note 填入文字,观察 JSON 中出现 `note`。
+- [ ] 修改 Tags 内容,再解码检查。
+- [ ] 在 JSON 里加一个未知字段,观察默认是否报错。
+- [ ] 缺少 minutes 时观察新结构体的值;再思考这是否代表输入合法。
+- [ ] 增加一个 `Minutes < 0` 的业务校验,并为其添加测试。
+- [ ] 把格式化编码改成 `json.Marshal`,比较输出,不依赖 JSON 对象键顺序判断语义。
+
+若另行手敲,放到本课的 practice 子目录,用 `go run ./lessons/18_json/practice` 运行,不要在同一个包中重复声明 main 或同名类型。
+
+下一课学习命令行输入与参数,让程序接收用户提供的数据。
+
+[上一课:文件读写](../17_files/README.md) · [学习总览](../../README.md)

+ 90 - 0
lessons/18_json/main.go

@@ -0,0 +1,90 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// 字段需要导出(大写开头)才能由 encoding/json 读写。
+// 反引号中的内容是结构体标签,不是字段的值。
+type StudyRecord struct {
+	Title    string   `json:"title"`
+	Minutes  int      `json:"minutes"`
+	Finished bool     `json:"finished"`
+	Tags     []string `json:"tags"`
+	Note     string   `json:"note,omitempty"`
+}
+
+// encodeRecord 将结构体转换为缩进后的 JSON 字节。
+func encodeRecord(record StudyRecord) ([]byte, error) {
+	return json.MarshalIndent(record, "", "  ")
+}
+
+// decodeRecord 将 JSON 字节解析到新结构体。
+// 本例只检查 JSON 语法和字段类型,不验证学习时长等业务规则。
+func decodeRecord(data []byte) (StudyRecord, error) {
+	var record StudyRecord
+	if err := json.Unmarshal(data, &record); err != nil {
+		return StudyRecord{}, err
+	}
+	return record, nil
+}
+
+func run() error {
+	record := StudyRecord{
+		Title:    "学习 Go JSON",
+		Minutes:  30,
+		Finished: true,
+		Tags:     []string{"Go", "JSON"},
+		// Note 为空字符串,会因 omitempty 在编码时省略。
+	}
+
+	// 1. 编码:Go 值 -> JSON 字节。
+	data, err := encodeRecord(record)
+	if err != nil {
+		return fmt.Errorf("编码记录:%w", err)
+	}
+	fmt.Println("=== 编码为 JSON ===")
+	fmt.Println(string(data))
+
+	// 2. 每次创建独立临时目录,不覆盖已有文件;运行结束保留供查看。
+	directory, err := os.MkdirTemp("", "go-lesson18-")
+	if err != nil {
+		return fmt.Errorf("创建临时目录:%w", err)
+	}
+	path := filepath.Join(directory, "record.json")
+	// WriteFile 会覆盖已有文件;此处使用新建目录中的文件。
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		return fmt.Errorf("保存记录:%w", err)
+	}
+	fmt.Println("记录文件(运行后保留):", path)
+
+	// 3. 读取文件,解码:JSON 字节 -> Go 值。
+	saved, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("读取记录:%w", err)
+	}
+	decoded, err := decodeRecord(saved)
+	if err != nil {
+		return fmt.Errorf("解码记录:%w", err)
+	}
+	fmt.Println("\n=== 读取并解码 ===")
+	fmt.Printf("标题:%s,时长:%d 分钟,完成:%t,标签:%v\n", decoded.Title, decoded.Minutes, decoded.Finished, decoded.Tags)
+
+	// 4. 非法 JSON 会返回错误;这里只演示,不让它中断整个程序。
+	_, err = decodeRecord([]byte(`{"minutes": "不是数字"}`))
+	if err != nil {
+		fmt.Println("\n=== 错误示例 ===")
+		fmt.Println("字段类型不匹配,解码失败")
+	}
+	return nil
+}
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, "程序失败:", err)
+		os.Exit(1)
+	}
+}

+ 69 - 0
lessons/18_json/main_test.go

@@ -0,0 +1,69 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+)
+
+func TestRecordRoundTrip(t *testing.T) {
+	want := StudyRecord{Title: "学习 Go", Minutes: 30, Finished: true, Tags: []string{"Go", "JSON"}, Note: "复习"}
+	data, err := encodeRecord(want)
+	if err != nil {
+		t.Fatalf("编码失败:%v", err)
+	}
+
+	// 测试使用隔离目录,结束后由测试框架清理。
+	path := filepath.Join(t.TempDir(), "record.json")
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		t.Fatalf("写文件失败:%v", err)
+	}
+	saved, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("读文件失败:%v", err)
+	}
+	got, err := decodeRecord(saved)
+	if err != nil {
+		t.Fatalf("解码失败:%v", err)
+	}
+	// 结构体含切片,不能直接用 == 比较;这里用 DeepEqual 检查内容。
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("往返结果=%+v,期望=%+v", got, want)
+	}
+}
+
+func TestOmitEmptyNote(t *testing.T) {
+	data, err := encodeRecord(StudyRecord{Title: "练习"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	var fields map[string]json.RawMessage
+	if err := json.Unmarshal(data, &fields); err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := fields["note"]; ok {
+		t.Error("空 note 应当在编码时被省略")
+	}
+	if _, ok := fields["title"]; !ok {
+		t.Error("应包含 title 字段")
+	}
+}
+
+func TestDecodeInvalid(t *testing.T) {
+	tests := []struct {
+		name string
+		data string
+	}{
+		{name: "malformed", data: `{"title":`},
+		{name: "wrong_type", data: `{"minutes":"三十"}`},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if _, err := decodeRecord([]byte(tt.data)); err == nil {
+				t.Error("非法输入应返回错误")
+			}
+		})
+	}
+}

+ 145 - 0
lessons/19_cli/README.md

@@ -0,0 +1,145 @@
+# 第 19 课:命令行输入与参数
+
+## 学习目标
+
+让程序从命令行参数或键盘取得姓名、学习分钟数,理解 `os.Args`、`flag`、标准输入输出与基本校验。先看 [main.go](./main.go),运行两种模式,再看 [main_test.go](./main_test.go)。
+
+本课只使用标准库,共享根目录的 go.mod,无需安装依赖;不会写入或修改学习记录文件。当前程序每次运行只处理一条记录,后续综合项目再结合 JSON 存储。
+
+## 1. 编译程序
+
+在**仓库根目录**的 PowerShell 执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/19_cli | Out-Null
+go build -o ./bin/19_cli/study.exe ./lessons/19_cli
+```
+
+修改源码后需要重新编译。后面的 `study.exe` 命令均假设终端仍在仓库根目录。
+
+## 2. 参数模式
+
+```powershell
+.\bin\19_cli\study.exe -name 小朱 -minutes 30
+```
+
+输出:
+
+```text
+学习者:小朱,学习时长:30 分钟
+```
+
+姓名包含空格时加引号:
+
+```powershell
+.\bin\19_cli\study.exe -name "小 朱" -minutes 45
+```
+
+不提前编译也能使用 `go run`,它仍会先编译再运行:
+
+```powershell
+go run ./lessons/19_cli -name 小朱 -minutes 30
+```
+
+参数写在包路径之后。也可以写 `-name=小朱 -minutes=30`。不提供姓名或分钟数、分钟数不在 1~1440 之间、整数格式错误,都会报告错误并以非零状态退出。
+
+查看帮助:
+
+```powershell
+.\bin\19_cli\study.exe -h
+```
+
+`-h` / `-help` 在本程序中显示帮助并正常结束。参数错误由 flag 输出提示,本程序再输出错误摘要;因此有时会看到两部分信息。
+
+## 3. 交互模式
+
+```powershell
+.\bin\19_cli\study.exe -interactive
+```
+
+按提示输入两行:
+
+```text
+请输入姓名:小朱
+请输入学习分钟数:45
+学习者:小朱,学习时长:45 分钟
+```
+
+输入的姓名和分钟数会去掉两端空白。空姓名、非整数或超范围分钟数会报错,不会重复要求输入;结束后重新运行即可。
+
+也可以把输入传给程序,例如 PowerShell:
+
+```powershell
+"小朱", "45" | .\bin\19_cli\study.exe -interactive
+```
+
+管道中不会像交互终端一样回显你输入的每一行,因此提示和结果可能显示在同一行。实际终端编码依赖 Windows / PowerShell 配置,出现乱码时先检查终端编码。
+
+两种输入来源不可混用:`-interactive` 不能同时显式指定 `-name` 或 `-minutes`,即使它们的值是空字符串或 0 也不行。帮助中布尔选项单独写 `-interactive` 等价于 `-interactive=true`;`-interactive=false` 表示参数模式。
+
+## 4. os.Args 与 flag
+
+```go
+os.Args[0]  // 通常是程序名或可执行文件路径。
+os.Args[1:] // 用户输入的参数,不含程序名。
+```
+
+参数原始上是字符串切片。标准库 flag 根据声明解析带名称的参数,避免自己逐个处理 `-name` 之类的文本。
+
+```go
+name := flags.String("name", "", "学习者姓名")
+minutes := flags.Int("minutes", 0, "学习时长")
+```
+
+String 返回 `*string`、Int 返回 `*int`,所以使用时写 `*name`、`*minutes`。解析成功后它们指向保存解析结果的变量。第九课学过的 `*` 是解引用,不是乘法。
+
+`flag.NewFlagSet` 创建独立解析器,`flag.ContinueOnError` 让 Parse 返回错误,便于调用者和测试处理。flags.SetOutput 只控制 flag 产生的帮助/解析提示;本课 main 额外将业务错误输出到标准错误。
+
+`flags.Visit` 遍历**显式设置过**的选项。本例借它区分“没有设置”和“明确设置为默认值”,用于禁止混合输入。它的回调是第十三课学过的函数参数,不需要在这里记住完整接口。
+
+flag 遇到第一个非 flag 位置参数后停止解析;本例用 `NArg` 检查并拒绝所有位置参数,因此请把选项写在前面,避免将输入误当作合法参数。单独的 `--` 会结束选项解析,后续内容也是位置参数,在本例会被拒绝。
+
+## 5. 标准输入、输出与测试
+
+- `os.Stdin`:标准输入,交互时通常连接键盘,也可以来自管道。
+- `os.Stdout`:标准输出,正常结果写到这里。
+- `os.Stderr`:标准错误,main 将最终错误写到这里。
+- 传入 run 的 `io.Reader` 和 `io.Writer` 是第十六课的接口,让函数可以接收终端,也可以接收测试中准备的输入输出。
+
+交互模式用 `bufio.Scanner` 逐行读取,Scan 后检查 Err,正常输入提前结束也会报错。Scanner 有默认单行大小限制;不适合无上限的输入。
+
+测试中 `strings.NewReader` 模拟输入,`bytes.Buffer` 保存输出,不需要测试时真实敲键盘。这个程序的 flag 解析帮助信息写到传入的 output;若要更细致地区分标准输出和标准错误,可以进一步分别注入两个 writer。
+
+## 6. 退出状态
+
+main 遇到非帮助错误时输出错误并调用 `os.Exit(1)`。PowerShell 运行 exe 后,可检查 `$LASTEXITCODE`:0 表示正常退出,非 0 表示失败。
+
+```powershell
+.\bin\19_cli\study.exe -name 小朱 -minutes 0
+$LASTEXITCODE
+```
+
+`os.Exit` 不执行当前进程已登记的 defer。本例 run 已经返回,且没有需要释放的外部资源;以后涉及文件或网络时,应先关闭资源,再决定是否退出。
+
+## 测试与练习
+
+在仓库根目录执行:
+
+```powershell
+go test -count=1 -v ./lessons/19_cli
+```
+
+测试覆盖参数模式、交互模式、帮助、空输入、混用模式、参数错误和时长边界,不需要人工输入。
+
+- [ ] 用你的姓名和不同分钟数运行两种模式。
+- [ ] 测试边界 1、1440、0、1441,观察退出状态。
+- [ ] 用带空格的姓名,比较是否加引号。
+- [ ] 输入非数字、只输入姓名就结束输入,观察错误。
+- [ ] 增加一个 `-topic` 参数,并为它补测试。
+- [ ] 修改范围规则,记得同步更改帮助文字和边界测试。
+
+若另行手敲,放到本课的 practice 子目录,用 `go run ./lessons/19_cli/practice -name 小朱 -minutes 30` 运行,不要在同一个包里重复声明 main 或 run。
+
+下一课学习 HTTP 客户端与简单服务,让程序通过网络交换数据。
+
+[上一课:JSON](../18_json/README.md) · [学习总览](../../README.md)

+ 90 - 0
lessons/19_cli/main.go

@@ -0,0 +1,90 @@
+package main
+
+import (
+	"bufio"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"strconv"
+	"strings"
+)
+
+// run 接收参数、输入和输出,方便测试时不依赖真实终端。
+// 参数不包含程序名,通常由 main 传入 os.Args[1:]。
+func run(args []string, input io.Reader, output io.Writer) error {
+	// ContinueOnError 让解析失败返回错误,而不是直接结束整个进程。
+	flags := flag.NewFlagSet("study", flag.ContinueOnError)
+	flags.SetOutput(output) // 帮助信息和参数解析错误写到这里。
+	name := flags.String("name", "", "学习者姓名;交互模式可留空")
+	minutes := flags.Int("minutes", 0, "学习时长(分钟,1~1440)")
+	interactive := flags.Bool("interactive", false, "从标准输入读取姓名和时长")
+
+	if err := flags.Parse(args); err != nil {
+		return err
+	}
+	if flags.NArg() != 0 {
+		return fmt.Errorf("不支持的位置参数:%s", strings.Join(flags.Args(), " "))
+	}
+
+	if *interactive {
+		// 明确禁止混用两种输入来源,避免覆盖用户提供的参数。
+		mixed := false
+		flags.Visit(func(item *flag.Flag) {
+			if item.Name == "name" || item.Name == "minutes" {
+				mixed = true
+			}
+		})
+		if mixed {
+			return errors.New("交互模式不能同时指定 name 或 minutes")
+		}
+		scanner := bufio.NewScanner(input)
+		if _, err := fmt.Fprint(output, "请输入姓名:"); err != nil {
+			return err
+		}
+		if !scanner.Scan() {
+			if err := scanner.Err(); err != nil {
+				return err
+			}
+			return errors.New("没有读取到姓名")
+		}
+		*name = scanner.Text()
+
+		if _, err := fmt.Fprint(output, "请输入学习分钟数:"); err != nil {
+			return err
+		}
+		if !scanner.Scan() {
+			if err := scanner.Err(); err != nil {
+				return err
+			}
+			return errors.New("没有读取到分钟数")
+		}
+		value, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
+		if err != nil {
+			return fmt.Errorf("分钟数必须是整数:%w", err)
+		}
+		*minutes = value
+	}
+
+	cleanName := strings.TrimSpace(*name)
+	if cleanName == "" {
+		return errors.New("姓名不能为空,请指定 -name 或使用 -interactive")
+	}
+	if *minutes < 1 || *minutes > 1440 {
+		return errors.New("学习分钟数必须在 1~1440 之间")
+	}
+	_, err := fmt.Fprintf(output, "学习者:%s,学习时长:%d 分钟\n", cleanName, *minutes)
+	return err
+}
+
+func main() {
+	// Args[0] 通常是程序名,Args[1:] 是用户提供的参数。
+	if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
+		if errors.Is(err, flag.ErrHelp) {
+			return // -h 是查看帮助,不属于失败。
+		}
+		fmt.Fprintln(os.Stderr, "错误:", err)
+		os.Exit(1)
+	}
+}

+ 82 - 0
lessons/19_cli/main_test.go

@@ -0,0 +1,82 @@
+package main
+
+import (
+	"bytes"
+	"errors"
+	"flag"
+	"io"
+	"strings"
+	"testing"
+)
+
+func TestRunArguments(t *testing.T) {
+	var output bytes.Buffer
+	err := run([]string{"-name", "小朱", "-minutes", "30"}, strings.NewReader(""), &output)
+	if err != nil {
+		t.Fatalf("运行失败:%v", err)
+	}
+	want := "学习者:小朱,学习时长:30 分钟\n"
+	if output.String() != want {
+		t.Errorf("输出=%q,期望=%q", output.String(), want)
+	}
+}
+
+func TestRunInteractive(t *testing.T) {
+	var output bytes.Buffer
+	err := run([]string{"-interactive"}, strings.NewReader("小朱\n45\n"), &output)
+	if err != nil {
+		t.Fatalf("交互失败:%v", err)
+	}
+	want := "请输入姓名:请输入学习分钟数:学习者:小朱,学习时长:45 分钟\n"
+	if output.String() != want {
+		t.Errorf("输出=%q,期望=%q", output.String(), want)
+	}
+}
+
+func TestRunErrors(t *testing.T) {
+	tests := []struct {
+		name  string
+		args  []string
+		input string
+	}{
+		{name: "missing_name", args: []string{"-minutes", "30"}},
+		{name: "empty_name", args: []string{"-name", "  ", "-minutes", "30"}},
+		{name: "zero", args: []string{"-name", "小朱", "-minutes", "0"}},
+		{name: "too_large", args: []string{"-name", "小朱", "-minutes", "1441"}},
+		{name: "invalid_flag", args: []string{"-unknown"}},
+		{name: "invalid_number", args: []string{"-name", "小朱", "-minutes", "abc"}},
+		{name: "positional", args: []string{"extra"}},
+		{name: "mixed", args: []string{"-interactive", "-minutes", "0"}},
+		{name: "missing_input", args: []string{"-interactive"}, input: "小朱\n"},
+		{name: "invalid_input", args: []string{"-interactive"}, input: "小朱\nabc\n"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := run(tt.args, strings.NewReader(tt.input), io.Discard); err == nil {
+				t.Fatal("预期返回错误,实际没有错误")
+			}
+		})
+	}
+}
+
+func TestRunBounds(t *testing.T) {
+	for _, minutes := range []string{"1", "1440"} {
+		t.Run(minutes, func(t *testing.T) {
+			err := run([]string{"-name", "小朱", "-minutes", minutes}, strings.NewReader(""), io.Discard)
+			if err != nil {
+				t.Fatalf("边界值应当有效:%v", err)
+			}
+		})
+	}
+}
+
+func TestRunHelp(t *testing.T) {
+	var output bytes.Buffer
+	err := run([]string{"-h"}, strings.NewReader(""), &output)
+	if !errors.Is(err, flag.ErrHelp) {
+		t.Fatalf("期望帮助标记,实际错误:%v", err)
+	}
+	if !strings.Contains(output.String(), "-interactive") {
+		t.Errorf("帮助信息未包含参数说明:%q", output.String())
+	}
+}

+ 133 - 0
lessons/20_http/README.md

@@ -0,0 +1,133 @@
+# 第 20 课:HTTP 客户端与简单服务
+
+## 学习目标
+
+学会区分客户端、服务端、请求、响应与状态码,将上一课的 JSON 用在网络通信中。先读 [main.go](./main.go),运行成功后再看 [main_test.go](./main_test.go)。本课只使用标准库,不安装第三方依赖。
+
+本例**不访问外部网站**:程序自己在本机回环地址启动临时 HTTP 服务,自动选择可用端口,客户端发起一次请求,函数返回时关闭服务。地址每次可能不同;如果系统禁止本地监听,运行和部分测试可能失败。
+
+## 1. 请求与响应
+
+```text
+客户端  -- HTTP 请求 -->  服务端
+客户端  <-- HTTP 响应 --  服务端
+```
+
+请求包含方法、路径、查询参数等;响应包含状态码、响应头和响应体。示例请求相当于:
+
+```text
+GET /hello?name=小朱
+```
+
+实际传输时,中文、空格和特殊字符会进行 URL 编码。
+
+| 状态码 | 本课含义 |
+| --- | --- |
+| 200 | 请求成功,响应体是 JSON |
+| 400 | 姓名缺失或全是空白 |
+| 405 | 请求方法不是 GET |
+
+HTTP 错误状态与网络 error 是两回事:服务端返回 400 时,客户端可能已经成功收到 HTTP 响应,`client.Get` 的 err 仍可能是 nil,因此还需要检查 StatusCode。
+
+## 2. 服务端处理函数
+
+```go
+func greetingHandler(w http.ResponseWriter, r *http.Request) {
+    name := r.URL.Query().Get("name")
+    // 检查方法和参数后写回 JSON。
+}
+```
+
+- w:用于写响应状态、响应头和响应体的接口。
+- r:请求指针,可读取 Method 和 URL 等信息。
+- `Query().Get("name")`:获取第一个同名查询参数;找不到时得到空字符串。
+- `http.Error`:写入指定状态码和纯文本错误响应。
+- `json.NewEncoder(w).Encode(...)`:直接将 Go 值编码到响应体,通常会追加换行。
+
+应先设置响应头,再写状态和正文。本例写正文时默认状态是 200。响应一旦开始写入,后续编码出错时不一定还能更改状态码,因此示例只记录写入错误。
+
+`http.NewServeMux` 是路由器;`HandleFunc("/hello", greetingHandler)` 将路径交给处理函数。这里使用普通路径注册,方法检查在处理函数里完成。
+
+## 3. 客户端请求
+
+```go
+params := url.Values{}
+params.Set("name", name)
+response, err := client.Get(baseURL + "/hello?" + params.Encode())
+```
+
+url.Values 负责正确编码查询参数,不要手工拼接未经编码的用户输入。
+
+请求成功取得 response 后,应关闭 `response.Body`;本例使用 defer。请求失败时 response 可能不存在,不能在检查错误之前就 defer 关闭。
+
+示例的 `http.Client` 设置三秒 Timeout,避免无限等待。服务端返回非 200 时,函数返回一个包含状态码的错误;本课为简洁没有读取错误响应体。
+
+成功响应通过 `io.LimitReader` 限制读取,再检查是否超过 64 KiB,避免无限读取。之后用 `json.Unmarshal` 解析。只解析成功不代表业务内容一定有效,真实项目还要验证字段、内容类型等。
+
+非成功状态下直接关闭未完全读取的响应体,可能影响连接复用;它不会让这个示例错误地继续解析失败响应,真实客户端需结合连接复用、错误体大小上限和协议设计处理。
+
+## 4. 为什么这里使用 httptest
+
+```go
+server := httptest.NewServer(mux)
+defer server.Close()
+```
+
+httptest 是标准库中的测试辅助工具。本例借它在本机快速启动临时服务,简化启动、选端口和关闭流程,方便一次运行看完整请求链路。
+
+它适合教学和测试,**不是生产服务器的部署方式**。真实服务通常使用 `http.Server` 配置地址、超时等并调用 ListenAndServe,还要考虑优雅关闭、日志、身份认证和 HTTPS。本课不会在公网暴露服务。
+
+本例 HTTP 明文且只在本机回环地址演示,不要用于传输敏感信息或当作生产安全配置。
+
+## 5. 自动测试
+
+- `httptest.NewRequest` + `NewRecorder`:不经过真实网络,直接调用处理函数,检查状态和内容。
+- `httptest.NewServer`:启动临时本地服务,测试完整的客户端与服务端交互。
+- 测试覆盖有效请求、空姓名、错误方法、中文和特殊字符、非法 JSON、响应过大。
+
+这种测试不依赖外部网站,但 NewServer 仍需要本机网络监听权限。测试服务通过 defer 关闭。
+
+## 编译并运行
+
+在**仓库根目录**执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/20_http | Out-Null
+go build -o ./bin/20_http/http_demo.exe ./lessons/20_http
+.\bin\20_http\http_demo.exe
+```
+
+修改源码后需要重新编译。也可快速编译并运行:
+
+```powershell
+go run ./lessons/20_http
+```
+
+预期输出(地址和端口每次可能不同):
+
+```text
+本地临时服务: http://127.0.0.1:随机端口
+客户端收到: 你好,小朱!
+演示结束,临时服务即将关闭
+```
+
+运行本课测试:
+
+```powershell
+go test -count=1 -v ./lessons/20_http
+```
+
+## 动手练习
+
+- [ ] 把客户端姓名改成自己的名字,观察返回内容。
+- [ ] 姓名中加入空格、中文和 `&`,理解 URL 编码的作用。
+- [ ] 在测试中用 POST 请求,确认返回 405。
+- [ ] 为返回数据增加一个字段,同步修改结构体、服务端和测试。
+- [ ] 增加一个请求失败时的测试;不要依赖公网网站。
+- [ ] 将客户端响应大小上限调小,验证超限错误,再恢复。
+
+如果要另行手敲,放到本课的 practice 子目录,用 `go run ./lessons/20_http/practice` 运行,不要在同一个包里重复声明 main 或同名函数。
+
+下一课学习 goroutine 并发基础,先理解如何启动任务和等待任务结束。
+
+[上一课:命令行输入](../19_cli/README.md) · [学习总览](../../README.md)

+ 98 - 0
lessons/20_http/main.go

@@ -0,0 +1,98 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+)
+
+// Greeting 是服务端返回的 JSON 结构。
+type Greeting struct {
+	Message string `json:"message"`
+}
+
+// greetingHandler 是 HTTP 服务端的处理函数。
+func greetingHandler(w http.ResponseWriter, r *http.Request) {
+	// 本课只接受 GET。状态码不是 Go 函数的 error 返回值。
+	if r.Method != http.MethodGet {
+		w.Header().Set("Allow", http.MethodGet)
+		http.Error(w, "只支持 GET", http.StatusMethodNotAllowed)
+		return
+	}
+
+	name := strings.TrimSpace(r.URL.Query().Get("name"))
+	if name == "" {
+		http.Error(w, "name 不能为空", http.StatusBadRequest)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	// 首次写入响应体时会默认发送 200 状态码。
+	if err := json.NewEncoder(w).Encode(Greeting{Message: "你好," + name + "!"}); err != nil {
+		// 响应可能已开始,不能保证此时还能改状态码。
+		fmt.Fprintln(os.Stderr, "写入响应失败:", err)
+	}
+}
+
+// fetchGreeting 是客户端:发送请求、检查状态、读取并解析 JSON。
+func fetchGreeting(client *http.Client, baseURL, name string) (Greeting, error) {
+	params := url.Values{}
+	params.Set("name", name) // 对中文、空格和特殊字符做 URL 编码。
+	response, err := client.Get(baseURL + "/hello?" + params.Encode())
+	if err != nil {
+		return Greeting{}, fmt.Errorf("发送请求:%w", err)
+	}
+	defer response.Body.Close() // 只在请求成功后关闭响应体。
+
+	if response.StatusCode != http.StatusOK {
+		return Greeting{}, fmt.Errorf("服务端返回状态码 %d", response.StatusCode)
+	}
+	// 为示例设置大小上限,避免无限读取响应体。
+	const maxBody = 64 * 1024
+	data, err := io.ReadAll(io.LimitReader(response.Body, maxBody+1))
+	if err != nil {
+		return Greeting{}, fmt.Errorf("读取响应:%w", err)
+	}
+	if len(data) > maxBody {
+		return Greeting{}, fmt.Errorf("响应内容超过 %d 字节", maxBody)
+	}
+	var result Greeting
+	if err := json.Unmarshal(data, &result); err != nil {
+		return Greeting{}, fmt.Errorf("解析 JSON:%w", err)
+	}
+	return result, nil
+}
+
+func run() error {
+	// 新建路由器:只将 /hello 交给我们的处理函数。
+	mux := http.NewServeMux()
+	mux.HandleFunc("/hello", greetingHandler)
+
+	// 本课使用本机临时测试服务器,自动选择可用端口,不访问外网。
+	server := httptest.NewServer(mux)
+	defer server.Close()
+	fmt.Println("本地临时服务:", server.URL)
+
+	// 设置超时,避免请求无限等待。
+	client := &http.Client{Timeout: 3 * time.Second}
+	result, err := fetchGreeting(client, server.URL, "小朱")
+	if err != nil {
+		return err
+	}
+	fmt.Println("客户端收到:", result.Message)
+	fmt.Println("演示结束,临时服务即将关闭")
+	return nil
+}
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, "请求失败:", err)
+		os.Exit(1)
+	}
+}

+ 77 - 0
lessons/20_http/main_test.go

@@ -0,0 +1,77 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestGreetingHandler(t *testing.T) {
+	tests := []struct {
+		name       string
+		method     string
+		url        string
+		wantStatus int
+	}{
+		{name: "valid", method: http.MethodGet, url: "/hello?name=Go", wantStatus: http.StatusOK},
+		{name: "missing_name", method: http.MethodGet, url: "/hello", wantStatus: http.StatusBadRequest},
+		{name: "wrong_method", method: http.MethodPost, url: "/hello?name=Go", wantStatus: http.StatusMethodNotAllowed},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			request := httptest.NewRequest(tt.method, tt.url, nil)
+			response := httptest.NewRecorder()
+			greetingHandler(response, request)
+			if response.Code != tt.wantStatus {
+				t.Errorf("状态码=%d,期望=%d", response.Code, tt.wantStatus)
+			}
+			if tt.wantStatus == http.StatusOK && !strings.Contains(response.Body.String(), "你好,Go!") {
+				t.Errorf("响应内容不符合预期:%s", response.Body.String())
+			}
+		})
+	}
+}
+
+func TestFetchGreeting(t *testing.T) {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/hello", greetingHandler)
+	server := httptest.NewServer(mux)
+	defer server.Close()
+	client := &http.Client{Timeout: 2 * time.Second}
+
+	got, err := fetchGreeting(client, server.URL, "小 朱&Go")
+	if err != nil {
+		t.Fatalf("请求失败:%v", err)
+	}
+	if got.Message != "你好,小 朱&Go!" {
+		t.Errorf("消息=%q", got.Message)
+	}
+	if _, err := fetchGreeting(client, server.URL, ""); err == nil {
+		t.Error("空姓名应导致客户端返回错误")
+	}
+}
+
+func TestFetchRejectsInvalidResponse(t *testing.T) {
+	tests := []struct {
+		name string
+		body string
+	}{
+		{name: "invalid_json", body: "not json"},
+		{name: "oversized", body: strings.Repeat("a", 64*1024+1)},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				fmt.Fprint(w, tt.body)
+			}))
+			defer server.Close()
+			client := &http.Client{Timeout: 2 * time.Second}
+			if _, err := fetchGreeting(client, server.URL, "Go"); err == nil {
+				t.Error("无效响应应返回错误")
+			}
+		})
+	}
+}