实战:学生管理系统
实战:学生管理系统
想象你是一位班主任,手边有一个笔记本:每一页记录着一位学生的姓名、学号,以及各科成绩。当新同学转来,你翻到空白页登记;考试结束后,你找到对应页面填写成绩;期末时,你按总分排名打印成绩单。整个过程就是在做"增删改查"。
今天我们把这个笔记本搬进电脑——用 Go 语言从零搭建一个命令行学生管理系统,把前面学到的结构体、方法、接口和错误处理全部串起来。
项目需求
| 功能 | 说明 |
|---|---|
| 添加学生 | 输入姓名和学号,创建新记录 |
| 添加课程成绩 | 为指定学生录入某门课的成绩 |
| 查询学生 | 按学号查找,显示学生信息和全部成绩 |
| 列出所有学生 | 展示所有已录入学生及其平均分 |
| 删除学生 | 按学号移除一位学生 |
| 退出程序 | 保存数据并退出 |
系统设计
┌─────────────────────────────────────────────┐
│ CLI 菜单 │
├─────────────────────────────────────────────┤
│ StudentManager │
│ (持有 Storer 接口,执行业务逻辑) │
├─────────────────────────────────────────────┤
│ Storer 接口 │
│ Save / Load —— 可替换存储后端 │
├──────────────────┬──────────────────────────┤
│ MemoryStore │ FileStore (可选扩展) │
│ (内存 map) │ (JSON 文件) │
└──────────────────┴──────────────────────────┘
核心类型:
- Student — 学生信息 + 课程成绩
- Course — 单条课程成绩
- Storer — 存储抽象接口
- StudentManager — 业务逻辑层
完整代码
创建文件 main.go,将以下代码完整复制:
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
)
// ============================================================
// 数据结构定义
// ============================================================
// Course 表示一门课程的成绩
type Course struct {
Name string `json:"name"`
Score float64 `json:"score"`
}
// Student 表示一名学生
type Student struct {
ID string `json:"id"`
Name string `json:"name"`
Courses []Course `json:"courses"`
}
// Average 计算学生的平均分
func (s Student) Average() float64 {
if len(s.Courses) == 0 {
return 0
}
total := 0.0
for _, c := range s.Courses {
total += c.Score
}
return total / float64(len(s.Courses))
}
// AddCourse 为学生添加一门课程成绩
func (s *Student) AddCourse(name string, score float64) {
// 如果课程已存在,则更新成绩
for i, c := range s.Courses {
if c.Name == name {
s.Courses[i].Score = score
return
}
}
s.Courses = append(s.Courses, Course{Name: name, Score: score})
}
// String 实现 fmt.Stringer 接口,方便打印
func (s Student) String() string {
return fmt.Sprintf("[%s] %s (平均分: %.1f)", s.ID, s.Name, s.Average())
}
// ============================================================
// 存储接口
// ============================================================
// Storer 定义存储操作的抽象接口
type Storer interface {
Save(students map[string]*Student) error
Load() (map[string]*Student, error)
}
// ------------------------------------------------------------
// 内存存储实现
// ------------------------------------------------------------
// MemoryStore 将数据保存在内存中(程序退出后丢失)
type MemoryStore struct{}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (m *MemoryStore) Save(students map[string]*Student) error {
// 内存存储不需要持久化,直接返回 nil
return nil
}
func (m *MemoryStore) Load() (map[string]*Student, error) {
return make(map[string]*Student), nil
}
// ------------------------------------------------------------
// 文件存储实现
// ------------------------------------------------------------
// FileStore 将数据以 JSON 格式保存到文件
type FileStore struct {
Path string
}
func NewFileStore(path string) *FileStore {
return &FileStore{Path: path}
}
func (f *FileStore) Save(students map[string]*Student) error {
data, err := json.MarshalIndent(students, "", " ")
if err != nil {
return fmt.Errorf("序列化失败: %w", err)
}
err = os.WriteFile(f.Path, data, 0644)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
func (f *FileStore) Load() (map[string]*Student, error) {
students := make(map[string]*Student)
data, err := os.ReadFile(f.Path)
if err != nil {
if os.IsNotExist(err) {
// 文件不存在,返回空 map
return students, nil
}
return nil, fmt.Errorf("读取文件失败: %w", err)
}
err = json.Unmarshal(data, &students)
if err != nil {
return nil, fmt.Errorf("解析数据失败: %w", err)
}
return students, nil
}
// ============================================================
// 业务逻辑层
// ============================================================
// StudentManager 管理所有学生数据
type StudentManager struct {
students map[string]*Student
store Storer
reader *bufio.Reader
}
// NewStudentManager 创建一个新的管理器
func NewStudentManager(store Storer) (*StudentManager, error) {
students, err := store.Load()
if err != nil {
return nil, fmt.Errorf("加载数据失败: %w", err)
}
return &StudentManager{
students: students,
store: store,
reader: bufio.NewReader(os.Stdin),
}, nil
}
// AddStudent 添加一名新学生
func (m *StudentManager) AddStudent(id, name string) error {
if id == "" || name == "" {
return fmt.Errorf("学号和姓名不能为空")
}
if _, exists := m.students[id]; exists {
return fmt.Errorf("学号 %s 已存在", id)
}
m.students[id] = &Student{ID: id, Name: name}
return m.store.Save(m.students)
}
// AddCourse 为学生添加课程成绩
func (m *StudentManager) AddCourse(id, courseName string, score float64) error {
student, err := m.findStudent(id)
if err != nil {
return err
}
if score < 0 || score > 100 {
return fmt.Errorf("成绩必须在 0-100 之间")
}
student.AddCourse(courseName, score)
return m.store.Save(m.students)
}
// GetStudent 查询学生信息
func (m *StudentManager) GetStudent(id string) (*Student, error) {
return m.findStudent(id)
}
// DeleteStudent 删除一名学生
func (m *StudentManager) DeleteStudent(id string) error {
if _, exists := m.students[id]; !exists {
return fmt.Errorf("学号 %s 不存在", id)
}
delete(m.students, id)
return m.store.Save(m.students)
}
// ListStudents 返回按学号排序的所有学生切片
func (m *StudentManager) ListStudents() []*Student {
list := make([]*Student, 0, len(m.students))
for _, s := range m.students {
list = append(list, s)
}
sort.Slice(list, func(i, j int) bool {
return list[i].ID < list[j].ID
})
return list
}
// findStudent 根据学号查找学生(内部辅助方法)
func (m *StudentManager) findStudent(id string) (*Student, error) {
s, exists := m.students[id]
if !exists {
return nil, fmt.Errorf("学号 %s 不存在", id)
}
return s, nil
}
// ============================================================
// CLI 交互层
// ============================================================
// Run 启动命令行交互循环
func (m *StudentManager) Run() {
for {
m.printMenu()
choice := m.readLine("请输入选项: ")
switch choice {
case "1":
m.handleAddStudent()
case "2":
m.handleAddCourse()
case "3":
m.handleGetStudent()
case "4":
m.handleListStudents()
case "5":
m.handleDeleteStudent()
case "6":
fmt.Println("\n数据已保存,再见!")
return
default:
fmt.Println("\n⚠ 无效选项,请重新输入")
}
fmt.Println()
}
}
func (m *StudentManager) printMenu() {
fmt.Println("╔══════════════════════════════╗")
fmt.Println("║ 学生管理系统 v1.0 ║")
fmt.Println("╠══════════════════════════════╣")
fmt.Println("║ 1. 添加学生 ║")
fmt.Println("║ 2. 添加课程成绩 ║")
fmt.Println("║ 3. 查询学生 ║")
fmt.Println("║ 4. 列出所有学生 ║")
fmt.Println("║ 5. 删除学生 ║")
fmt.Println("║ 6. 退出 ║")
fmt.Println("╚══════════════════════════════╝")
}
func (m *StudentManager) handleAddStudent() {
id := m.readLine("请输入学号: ")
name := m.readLine("请输入姓名: ")
err := m.AddStudent(id, name)
if err != nil {
fmt.Printf("\n❌ 添加失败: %v\n", err)
return
}
fmt.Printf("\n✅ 学生 %s 添加成功!\n", name)
}
func (m *StudentManager) handleAddCourse() {
id := m.readLine("请输入学号: ")
courseName := m.readLine("请输入课程名称: ")
scoreStr := m.readLine("请输入成绩 (0-100): ")
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil {
fmt.Printf("\n❌ 成绩格式错误: %v\n", err)
return
}
err = m.AddCourse(id, courseName, score)
if err != nil {
fmt.Printf("\n❌ 添加失败: %v\n", err)
return
}
fmt.Printf("\n✅ 成绩添加成功!\n")
}
func (m *StudentManager) handleGetStudent() {
id := m.readLine("请输入学号: ")
s, err := m.GetStudent(id)
if err != nil {
fmt.Printf("\n❌ 查询失败: %v\n", err)
return
}
fmt.Println("\n─── 学生信息 ───")
fmt.Printf("学号: %s\n", s.ID)
fmt.Printf("姓名: %s\n", s.Name)
if len(s.Courses) == 0 {
fmt.Println("暂无课程成绩")
} else {
fmt.Println("课程成绩:")
for _, c := range s.Courses {
fmt.Printf(" %s: %.1f\n", c.Name, c.Score)
}
fmt.Printf("平均分: %.1f\n", s.Average())
}
}
func (m *StudentManager) handleListStudents() {
list := m.ListStudents()
if len(list) == 0 {
fmt.Println("\n暂无学生记录")
return
}
fmt.Println("\n─── 所有学生 ───")
for _, s := range list {
courseCount := len(s.Courses)
fmt.Printf(" %s 课程数: %d 平均分: %.1f\n", s, courseCount, s.Average())
}
fmt.Printf("\n共 %d 名学生\n", len(list))
}
func (m *StudentManager) handleDeleteStudent() {
id := m.readLine("请输入要删除的学号: ")
err := m.DeleteStudent(id)
if err != nil {
fmt.Printf("\n❌ 删除失败: %v\n", err)
return
}
fmt.Printf("\n✅ 学号 %s 已删除\n", id)
}
// readLine 读取一行用户输入并去除首尾空格
func (m *StudentManager) readLine(prompt string) string {
fmt.Print(prompt)
line, _ := m.reader.ReadString('\n')
return strings.TrimSpace(line)
}
// ============================================================
// 程序入口
// ============================================================
func main() {
// 使用文件存储(数据保存到 students.json)
store := NewFileStore("students.json")
// 如果想用纯内存存储(退出后数据丢失),改为:
// store := NewMemoryStore()
mgr, err := NewStudentManager(store)
if err != nil {
fmt.Fprintf(os.Stderr, "初始化失败: %v\n", err)
os.Exit(1)
}
fmt.Println("欢迎使用学生管理系统!")
mgr.Run()
}
代码解析
1. 数据结构设计
type Student struct {
ID string `json:"id"`
Name string `json:"name"`
Courses []Course `json:"courses"`
}
Student持有一个Course切片,每个Course包含课程名和成绩。json标签使得结构体可以直接序列化为 JSON,方便文件存储。
2. 方法与指针接收者
func (s *Student) AddCourse(name string, score float64) { ... }
func (s Student) Average() float64 { ... }
AddCourse需要修改切片,使用指针接收者*Student。Average只读取数据,使用值接收者Student即可。AddCourse内部实现了"同名课程则更新"的逻辑,避免重复添加。
3. 接口抽象 — Storer
type Storer interface {
Save(students map[string]*Student) error
Load() (map[string]*Student, error)
}
StudentManager只依赖Storer接口,不关心具体存储方式。- 提供了两个实现:
MemoryStore(内存)和FileStore(JSON 文件)。 - 想换成数据库?只需新增一个实现
Storer接口的类型即可,业务代码零改动。
4. 错误处理策略
本项目在每一层都进行错误处理:
| 层级 | 策略 |
|---|---|
| 数据验证 | 检查学号/姓名非空、成绩范围 0-100 |
| 业务逻辑 | 检查学号是否存在,存在则报错 |
| 存储层 | 用 fmt.Errorf("...: %w", err) 包装底层错误 |
| CLI 层 | 捕获错误,向用户显示友好的中文提示 |
return fmt.Errorf("序列化失败: %w", err)
%w 包装错误保留了原始信息,调用者可以用 errors.Is 或 errors.As 判断错误类型。
5. CLI 菜单循环
for {
m.printMenu()
choice := m.readLine("请输入选项: ")
switch choice { ... }
}
主循环不断打印菜单、读取输入、分发到对应处理函数。这种"读取-分发"模式是命令行程序的经典结构。
6. 可扩展的存储层
切换存储方式只需修改 main() 中的一行:
// 内存模式
store := NewMemoryStore()
// 文件模式
store := NewFileStore("students.json")
StudentManager 完全不感知底层变化——这正是接口的价值。
运行与测试
# 编译并运行
go run main.go
欢迎使用学生管理系统!
╔══════════════════════════════╗
║ 学生管理系统 v1.0 ║
╠══════════════════════════════╣
║ 1. 添加学生 ║
║ 2. 添加课程成绩 ║
║ 3. 查询学生 ║
║ 4. 列出所有学生 ║
║ 5. 删除学生 ║
║ 6. 退出 ║
╚══════════════════════════════╝
请输入选项: 1
请输入学号: 1001
请输入姓名: 张三
✅ 学生 张三 添加成功!
操作结束后,程序会在当前目录生成 students.json 文件,下次启动时自动加载。
❓ 常见问题
Q1: 为什么 Average() 用值接收者,AddCourse() 用指针接收者?
Average() 只读取数据计算总和,不修改 Student,用值接收者更安全。AddCourse() 需要向 Courses 切片追加元素,必须用指针接收者才能修改调用者的原始数据。简单原则:要修改就用指针,只读取就用值。
Q2: map[string]*Student 中的 * 是什么意思?
这是一个值为指针的 map。m.students[id] 返回的是 *Student(指向 Student 的指针),而不是 Student 的副本。这样我们通过 AddCourse 修改学生数据时,改动会直接反映在 map 中,无需额外处理。
Q3: 为什么 Load() 中要单独处理文件不存在的情况?
首次运行程序时 students.json 还不存在,os.ReadFile 会返回错误。如果直接报错退出,用户就永远无法启动程序。因此用 os.IsNotExist(err) 判断——文件不存在是正常情况,返回空 map 即可。
Q4: 如何扩展为数据库存储?
只需实现 Storer 接口:
type MySQLStore struct {
db *sql.DB
}
func (m *MySQLStore) Save(students map[string]*Student) error {
// INSERT/UPDATE 到数据库
}
func (m *MySQLStore) Load() (map[string]*Student, error) {
// 从数据库 SELECT
}
然后在 main() 中传入 &MySQLStore{db: db},业务代码无需任何修改。
📖 小节
本课通过一个完整项目,将 Phase 2 的核心知识串联起来:
- 结构体 —
Student和Course定义了数据模型,json标签支持序列化。 - 方法 — 值接收者用于只读操作,指针接收者用于修改数据。
- 接口 —
Storer接口解耦了业务逻辑与存储实现,体现了 Go 的面向接口编程。 - 错误处理 — 每一层都进行校验和错误包装,CLI 层向用户展示友好提示。
- 包组织 — 所有代码在一个包中,但通过类型和方法做到了职责分离。
这就是 Go 工程实践的基本模式:用结构体建模,用方法封装行为,用接口解耦依赖,用 error 传递异常。
📝 作业
作业 1:添加按平均分排序功能
新增菜单选项"按平均分排名",将所有学生按平均分从高到低排序后输出。提示:使用 sort.Slice。
作业 2:添加导入/导出功能
实现两个新方法:
ExportCSV(filename string) error— 将所有学生导出为 CSV 文件(学号,姓名,课程,成绩)。ImportCSV(filename string) error— 从 CSV 文件批量导入学生和成绩。
作业 3:添加数据验证中间层
创建一个 ValidatingStore 结构体,它包装任意 Storer 实现,在 Save 之前验证所有学生数据(学号非空、姓名非空、成绩在 0-100 之间)。这就是装饰器模式在 Go 中的实践。



