Explorar o código

feat: 新增第十九课命令行参数与交互输入例程

zhuts hai 1 día
pai
achega
106323a6a3
Modificáronse 4 ficheiros con 326 adicións e 4 borrados
  1. 9 4
      README.md
  2. 145 0
      lessons/19_cli/README.md
  3. 90 0
      lessons/19_cli/main.go
  4. 82 0
      lessons/19_cli/main_test.go

+ 9 - 4
README.md

@@ -70,10 +70,14 @@
     │   ├── README.md       # 路径、读写、追加、关闭与逐行扫描
     │   ├── main.go         # 在独立临时目录操作学习记录
     │   └── main_test.go    # 使用临时目录隔离的文件测试
-    └── 18_json/            # 第 18 课:JSON 编码与解码
-        ├── README.md       # 标签、编码、解码和文件操作
-        ├── main.go         # 学习记录的 JSON 往返示例
-        └── 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    # 模拟输入输出与边界测试
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -100,6 +104,7 @@
 | 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、交互输入、校验、退出状态 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 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())
+	}
+}