v1: 图书录入器 --- 从零开始
一、版本概述
v1 是整个项目的起点,实现了一个最简单的命令行图书录入工具。用户通过交互式命令行输入图书信息(书名、作者、价格),程序将数据保存到文本文件中。
核心学习目标:
- Go 基础语法:变量、函数、控制流
- 文件 I/O 操作:os.OpenFile、bufio.Scanner/Writer
- 字符串处理:strings.Split、strconv.ParseFloat
- 表驱动测试(table-driven tests)
项目结构:
bash
v1/
├── main.go # 主程序(所有逻辑在一个文件中)
├── main_test.go # 单元测试
└── books.txt # 数据存储文件
二、完整代码
main.go
go
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
const (
dataFile = "books.txt"
exitCmd = "quit"
)
func main() {
printWelcome()
file := createOrOpenFile(dataFile)
defer file.Close()
scanner := bufio.NewScanner(os.Stdin)
writer := bufio.NewWriter(file)
defer writer.Flush()
for {
fmt.Print("\n请输入图书信息(书名,作者,价格)或输入quit退出: ")
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == exitCmd {
fmt.Println("再见!")
break
}
parts := parseInputByComma(input)
if len(parts) != 3 {
fmt.Println("❌ 格式错误,请使用: 书名,作者,价格")
continue
}
title := strings.TrimSpace(parts[0])
author := strings.TrimSpace(parts[1])
priceStr := strings.TrimSpace(parts[2])
if err := validateTitle(title); err != nil {
fmt.Printf("❌ %v\n", err)
continue
}
if err := validateAuthor(author); err != nil {
fmt.Printf("❌ %v\n", err)
continue
}
price, err := parseAndValidatePrice(priceStr)
if err != nil {
fmt.Printf("❌ %v\n", err)
continue
}
line := formatBookLine(title, author, price)
writeLine(writer, line)
fmt.Printf("✅ 已录入: %s\n", line)
}
}
func printWelcome() {
fmt.Println("========================================")
fmt.Println(" 📚 图书录入器 v1.0")
fmt.Println("========================================")
fmt.Println(" 输入格式: 书名,作者,价格")
fmt.Println(" 示例: Go语言编程,许式伟,79.5")
fmt.Println(" 输入 quit 退出")
fmt.Println("========================================")
}
func createOrOpenFile(filename string) *os.File {
file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("❌ 无法打开文件: %v\n", err)
os.Exit(1)
}
return file
}
func parseInputByComma(input string) []string {
return strings.Split(input, ",")
}
func validateTitle(title string) error {
if title == "" {
return fmt.Errorf("书名不能为空")
}
return nil
}
func validateAuthor(author string) error {
if author == "" {
return fmt.Errorf("作者不能为空")
}
return nil
}
func parseAndValidatePrice(priceStr string) (float64, error) {
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
return 0, fmt.Errorf("价格格式错误: %s", priceStr)
}
if price < 0 {
return 0, fmt.Errorf("价格不能为负数")
}
return price, nil
}
func formatBookLine(title, author string, price float64) string {
return fmt.Sprintf("%s|%s|%.2f", title, author, price)
}
func writeLine(writer *bufio.Writer, line string) {
fmt.Fprintln(writer, line)
}
main_test.go
go
package main
import (
"testing"
)
func TestValidateTitle(t *testing.T) {
tests := []struct {
name string
title string
wantErr bool
}{
{"有效标题", "Go语言编程", false},
{"空标题", "", true},
{"空格标题", " ", false},
{"长标题", "这是一本非常非常长的书名用于测试", false},
{"特殊字符", "C++ Primer(第5版)", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTitle(tt.title)
if (err != nil) != tt.wantErr {
t.Errorf("validateTitle(%q) error = %v, wantErr %v", tt.title, err, tt.wantErr)
}
})
}
}
func TestValidateAuthor(t *testing.T) {
tests := []struct {
name string
author string
wantErr bool
}{
{"有效作者", "许式伟", false},
{"空作者", "", true},
{"英文名", "Alan Donovan", false},
{"带标点", "Kernighan, B.W.", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAuthor(tt.author)
if (err != nil) != tt.wantErr {
t.Errorf("validateAuthor(%q) error = %v, wantErr %v", tt.author, err, tt.wantErr)
}
})
}
}
func TestParseAndValidatePrice(t *testing.T) {
tests := []struct {
name string
input string
want float64
wantErr bool
}{
{"正常价格", "79.5", 79.5, false},
{"整数价格", "100", 100.0, false},
{"零价格", "0", 0.0, false},
{"负价格", "-10", 0, true},
{"非数字", "abc", 0, true},
{"空字符串", "", 0, true},
{"科学计数法", "1e5", 100000, false},
{"超高精度", "79.555", 79.555, false},
{"极端大数", "9999999999", 9999999999, false},
{"带空格", " 79.5 ", 79.5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAndValidatePrice(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseAndValidatePrice(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseAndValidatePrice(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestParseInputByComma(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"标准输入", "Go,作者,79.5", 3},
{"缺少字段", "Go,作者", 2},
{"多余逗号", "Go,作者,79.5,额外", 4},
{"无逗号", "Go语言编程", 1},
{"空字符串", "", 1},
{"只有逗号", ",,", 3},
{"中文逗号混合", "Go,作者,79.5", 1},
{"带空格", "Go , 作者 , 79.5", 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parts := parseInputByComma(tt.input)
if len(parts) != tt.want {
t.Errorf("parseInputByComma(%q) = %d parts, want %d", tt.input, len(parts), tt.want)
}
})
}
}
func TestFormatBookLine(t *testing.T) {
tests := []struct {
name string
title string
author string
price float64
want string
}{
{"正常格式", "Go", "许式伟", 79.5, "Go|许式伟|79.50"},
{"整数价格", "Go", "许式伟", 100.0, "Go|许式伟|100.00"},
{"零价格", "Go", "许式伟", 0.0, "Go|许式伟|0.00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatBookLine(tt.title, tt.author, tt.price)
if got != tt.want {
t.Errorf("formatBookLine() = %q, want %q", got, tt.want)
}
})
}
}
func TestConstants(t *testing.T) {
if dataFile != "books.txt" {
t.Errorf("dataFile = %q, want books.txt", dataFile)
}
if exitCmd != "quit" {
t.Errorf("exitCmd = %q, want quit", exitCmd)
}
}
三、代码解读
3.1 程序流程
- 打印欢迎信息 →
printWelcome()输出使用说明 - 打开数据文件 →
createOrOpenFile()以追加模式打开 books.txt - 循环读取输入 →
bufio.Scanner逐行读取标准输入 - 解析与校验 → 依次校验书名、作者、价格
- 格式化与写入 → 格式化为
书名|作者|价格并写入文件 - 退出 → 输入
quit退出循环
3.2 关键设计点
- 文件 I/O :使用
os.O_APPEND|os.O_CREATE|os.O_WRONLY确保文件不存在时创建、存在时追加 - 缓冲写入 :
bufio.Writer提高写入性能,defer writer.Flush()确保退出时刷新 - 输入校验:分层校验(格式 → 书名 → 作者 → 价格),任何一步失败都给出明确提示
- 常量提取 :
dataFile和exitCmd定义为常量,便于维护
3.3 测试设计
- 表驱动测试 :每个校验函数使用
[]struct{name, input, want, wantErr}表格,覆盖正常/异常/边界场景 - 独立可测试 :
validateTitle、parseAndValidatePrice等纯函数无副作用,天然可测试
四、设计思想
| 思想 | 体现 |
|---|---|
| 单一职责 | 每个函数只做一件事:校验、解析、格式化、写入 |
| 防御式编程 | 每一步输入都做校验,错误信息具体明确 |
| 可测试性 | 业务逻辑拆分为纯函数,便于单元测试 |
| 常量提取 | 魔法值提取为命名常量,语义清晰 |
五、为什么需要 v2?
v1 存在明显的局限性:
- 数据无法查询和修改:只能追加写入,无法查看已录入的图书、无法修改或删除
- 数据结构缺失 :没有
Book结构体,数据以原始字符串形式存储 - 没有分层架构:所有逻辑堆积在 main.go 中,随着功能增加会变得无法维护
- 没有持久化检索:文本文件只能顺序读取,不支持按 ID 查询
v2 的改进方向:引入结构体和分层架构(Model-Repository-Handler),实现完整的 CRUD 操作和 JSON 持久化。