Răsfoiți Sursa

feat: 新增第二十课HTTP客户端与本地服务例程

zhuts 1 zi în urmă
părinte
comite
3aef897fa7
4 a modificat fișierele cu 317 adăugiri și 4 ștergeri
  1. 9 4
      README.md
  2. 133 0
      lessons/20_http/README.md
  3. 98 0
      lessons/20_http/main.go
  4. 77 0
      lessons/20_http/main_test.go

+ 9 - 4
README.md

@@ -74,10 +74,14 @@
     │   ├── README.md       # 标签、编码、解码和文件操作
     │   ├── main.go         # 学习记录的 JSON 往返示例
     │   └── main_test.go    # 往返、字段省略和非法输入测试
-    └── 19_cli/             # 第 19 课:命令行输入与参数
-        ├── README.md       # 参数、交互输入、帮助和退出状态
-        ├── main.go         # 学习记录输入示例
-        └── 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 地址相同。
@@ -105,6 +109,7 @@
 | 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、本地测试 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 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("无效响应应返回错误")
+			}
+		})
+	}
+}