一、项目背景详细介绍
学生管理系统(Student Management System)是计算机基础课程、数据结构与软件工程入门课程中常见的综合实训题目。该系统通过对学生信息的增删改查(CRUD)实现对实体数据的管理,是练习结构体、文件 I/O、数组/链表、函数组织、菜单交互等 C 语言基础技能的优秀案例。
在现实中,教育机构会管理大量学生数据(学号、姓名、性别、年龄、成绩等),这些数据需要持久化存储、检索、排序和统计。尽管大型系统会使用数据库与 web 前端,但作为教学练习,用 C 语言实现一个简洁、可扩展、可移植的学生管理系统,仍然非常有价值:
帮助学生理解数据建模(struct);
掌握文件读写(保存/加载);
熟悉内存管理与边界检查;
练习用户交互与错误处理;
为后续的数据库/GUI/网络扩展打基础。
本项目的目标是实现一个简单、清晰、易读的控制台学生管理系统,适合初学者练习与课堂演示。系统应包含常见功能:添加学生、删除学生、修改信息、查询、显示所有学生、按成绩排序、保存/读取文件、退出等。
二、项目需求详细介绍
功能需求(必须实现)
添加学生(Add):录入学生信息,包含至少字段:学号(唯一)、姓名、年龄、性别、成绩(浮点)。
删除学生(Delete):根据学号删除学生记录。
修改学生(Modify):根据学号修改某个学生的信息。
查询学生(Search):按学号或姓名查询学生,并显示信息。
显示所有学生(List):以表格形式列出当前所有学生。
按成绩排序(Sort):按成绩从高到低或从低到高排序并显示。
保存到文件(Save):把当前学生列表保存到文件(文本或二进制)。
从文件加载(Load):从文件读取学生列表,恢复上一次数据。
退出(Exit):退出前提示是否保存。
非功能需求(工程/教学要求)
用户界面为命令行菜单,容易交互。
所有代码写在单个代码块中(不同“文件”用注释分隔),并有详细注释,便于教学。
程序具备基本错误处理(如重复学号、无效输入、文件读写失败)。
使用静态数组或动态数组(这里采用可扩展的动态数组实现,便于理解 realloc)。
编写风格清晰、易读,便于学生理解与扩展。
三、相关技术详细介绍
为实现上述需求,需要掌握以下 C 语言相关技术点:
1. 结构体(struct)
用来表示学生实体,例如:
typedef struct { char id[16]; char name[50]; int age; char gender; // 'M' 或 'F' float score; } Student;
结构体使得数据有良好的组织,便于操作。
2. 动态数组(malloc / realloc / free)
使用动态数组存放学生记录,使系统能随添加扩容,而非固定容量的数组。
核心函数:
malloc(size):分配初始内存;realloc(ptr, newsize):扩展或缩小已分配内存;free(ptr):释放内存。
在添加学生时,若达到容量上限则调用realloc扩容(常用策略是加倍扩容)。
3. 文件 I/O(fopen / fprintf / fscanf / fclose / fread / fwrite)
实现保存与读取功能,常见两种方式:
文本格式(可读):利用
fprintf/fscanf,便于调试。二进制格式(节省空间、速度快):利用
fwrite/fread。
为教学与可读性,本示例采用文本格式存储(每行一条记录,字段用空格或特殊分隔符分隔),同时演示如何安全读取。
4. 字符串处理(strcpy / strcmp / fgets / sscanf / snprintf)
用于处理姓名、学号等字符串字段。注意输入时需要防止缓冲区溢出,使用fgets并去掉换行。
5. 菜单与输入校验
程序通过printf输出菜单,通过scanf或fgets读取用户输入。注意:scanf在读取数字后留有换行符,使用fgets读取字符串并去尾\n更安全。
6. 排序(qsort 或手写排序)
实现按成绩排序。可使用库函数qsort,并传入比较函数;也可手写简单的冒泡或选择排序。教学中两种均可,我在代码中使用qsort,并给出比较函数示例。
7. 内存与边界检查
每次内存分配或文件操作都必须检测返回值是否为 NULL 或错误码,避免崩溃和未定义行为。
四、实现思路详细介绍
总体设计分为模块化函数,主流程(main)负责菜单与调用模块函数。模块划分如下:
模块 A:数据结构与内存管理
Student结构体定义。StudentList管理动态数组:包含Student *data; int size; int capacity;。init_list,free_list,ensure_capacity,用于初始化、释放和扩容。
模块 B:基本操作函数
add_student(StudentList *list, Student s):添加学生(校验学号唯一)。delete_student(StudentList *list, const char *id):删除学生(按学号)。modify_student(StudentList *list, const char *id):查找并修改学生信息。find_student_by_id,find_students_by_name:查找辅助函数。list_students:格式化打印所有学生。
模块 C:排序与统计
compare_score_desc/asc:用于qsort的比较函数。sort_by_score(list, ascending):调用qsort排序。
模块 D:文件存取
save_to_file(list, filename):以文本方式保存。load_from_file(list, filename):加载并替换当前列表(或追加,根据设计)。
文件格式设计为:每行一个记录,字段用|分隔,避免姓名中空格问题,例如:
学号|姓名|年龄|性别|成绩\n 2021001|张三|20|M|88.5
读取时使用fgets逐行,然后用sscanf或strtok解析,注意错误处理。
模块 E:用户交互(菜单)
show_menu()打印菜单。get_choice()读取并返回用户选择。主循环根据选择调用对应函数。
数据校验与健壮性
学号唯一性检查;
年龄、成绩范围检查(例如年龄 0~150,成绩 0~100);
文件打开失败提示;
删除/修改/查询找不到记录时提示。
五、完整实现代码
/*************************************************************** * file: student.h * 说明:学生管理系统头文件(数据结构与函数声明) ***************************************************************/ #ifndef STUDENT_H #define STUDENT_H #include <stdio.h> #define INITIAL_CAPACITY 8 #define ID_LEN 32 #define NAME_LEN 64 #define LINE_BUF 256 typedef struct { char id[ID_LEN]; /* 学号(字符串) */ char name[NAME_LEN]; /* 姓名 */ int age; /* 年龄 */ char gender; /* 性别:'M' 或 'F' */ float score; /* 成绩 */ } Student; typedef struct { Student *data; /* 动态数组指针 */ int size; /* 当前学生数 */ int capacity; /* 当前容量 */ } StudentList; /* 初始化/释放 */ void init_list(StudentList *list); void free_list(StudentList *list); /* 增删改查操作 */ int add_student(StudentList *list, const Student *s); /* 返回 1 成功,0 失败(重复id) */ int delete_student(StudentList *list, const char *id); /* 返回 1 成功,0 未找到 */ Student *find_student_by_id(StudentList *list, const char *id); int find_students_by_name(StudentList *list, const char *name, Student *outBuf, int outBufSize); /* 返回匹配数 */ /* 修改 */ int modify_student(StudentList *list, const char *id); /* 列表显示 */ void list_students(StudentList *list); /* 排序 */ void sort_by_score(StudentList *list, int ascending); /* ascending 1 升序,0 降序 */ /* 文件保存/加载(文本格式,每行为一条记录) */ int save_to_file(StudentList *list, const char *filename); /* 返回 1 成功,0 失败 */ int load_from_file(StudentList *list, const char *filename); /* 返回 1 成功,0 失败(会清空现有数据) */ #endif /* STUDENT_H */ /*************************************************************** * file: student.c * 说明:学生管理系统函数实现 ***************************************************************/ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include "student.h" /* 内部工具函数:扩容 */ static int ensure_capacity(StudentList *list, int minCapacity) { if (list->capacity >= minCapacity) return 1; int newCap = list->capacity ? list->capacity * 2 : INITIAL_CAPACITY; while (newCap < minCapacity) newCap *= 2; Student *tmp = (Student*)realloc(list->data, sizeof(Student) * newCap); if (!tmp) return 0; list->data = tmp; list->capacity = newCap; return 1; } /* 初始化列表 */ void init_list(StudentList *list) { list->data = NULL; list->size = 0; list->capacity = 0; ensure_capacity(list, INITIAL_CAPACITY); } /* 释放列表内存 */ void free_list(StudentList *list) { if (list->data) free(list->data); list->data = NULL; list->size = 0; list->capacity = 0; } /* 查找:按学号查找索引(返回索引或 -1) */ static int find_index_by_id(StudentList *list, const char *id) { for (int i = 0; i < list->size; ++i) { if (strcmp(list->data[i].id, id) == 0) return i; } return -1; } /* 添加学生(学号唯一) */ int add_student(StudentList *list, const Student *s) { if (find_index_by_id(list, s->id) != -1) return 0; /* 重复学号 */ if (!ensure_capacity(list, list->size + 1)) return 0; list->data[list->size] = *s; /* 结构体复制 */ list->size++; return 1; } /* 删除学生(按学号) */ int delete_student(StudentList *list, const char *id) { int idx = find_index_by_id(list, id); if (idx == -1) return 0; /* 用最后一个元素覆盖被删除位置,保持数组紧凑 */ list->data[idx] = list->data[list->size - 1]; list->size--; return 1; } /* 按学号查找学生,返回指针或 NULL */ Student *find_student_by_id(StudentList *list, const char *id) { int idx = find_index_by_id(list, id); if (idx == -1) return NULL; return &list->data[idx]; } /* 按姓名查找学生(部分匹配或完全匹配可依据需求修改,这里使用 strstr 部分匹配) */ int find_students_by_name(StudentList *list, const char *name, Student *outBuf, int outBufSize) { int cnt = 0; for (int i = 0; i < list->size && cnt < outBufSize; ++i) { if (strstr(list->data[i].name, name) != NULL) { outBuf[cnt++] = list->data[i]; } } return cnt; } /* 修改学生信息(按学号)——交互式修改 */ int modify_student(StudentList *list, const char *id) { Student *p = find_student_by_id(list, id); if (!p) return 0; char buf[LINE_BUF]; printf("找到学生:%s %s 年龄:%d 性别:%c 成绩:%.2f\n", p->id, p->name, p->age, p->gender, p->score); printf("按回车跳过某项修改。\n"); printf("新姓名(当前:%s):", p->name); if (fgets(buf, sizeof(buf), stdin)) { buf[strcspn(buf, "\n")] = 0; if (strlen(buf) > 0) strncpy(p->name, buf, NAME_LEN-1); } printf("新年龄(当前:%d):", p->age); if (fgets(buf, sizeof(buf), stdin)) { int v = atoi(buf); if (v > 0) p->age = v; } printf("新性别(M/F)(当前:%c):", p->gender); if (fgets(buf, sizeof(buf), stdin)) { if (buf[0] == 'M' || buf[0] == 'F' || buf[0] == 'm' || buf[0] == 'f') { p->gender = toupper((unsigned char)buf[0]); } } printf("新成绩(当前:%.2f):", p->score); if (fgets(buf, sizeof(buf), stdin)) { float sc = atof(buf); if (sc >= 0.0f && sc <= 100.0f) p->score = sc; } printf("修改已保存。\n"); return 1; } /* 打印列表 */ void list_students(StudentList *list) { printf("当前共有 %d 位学生:\n", list->size); printf("-------------------------------------------------------------\n"); printf("| %-10s | %-12s | %-4s | %-6s | %-6s |\n", "学号", "姓名", "年龄", "性别", "成绩"); printf("-------------------------------------------------------------\n"); for (int i = 0; i < list->size; ++i) { Student *p = &list->data[i]; printf("| %-10s | %-12s | %-4d | %-6c | %-6.2f |\n", p->id, p->name, p->age, p->gender, p->score); } printf("-------------------------------------------------------------\n"); } /* 比较函数:按成绩降序 */ static int compare_score_desc(const void *a, const void *b) { const Student *pa = (const Student*)a; const Student *pb = (const Student*)b; if (pa->score < pb->score) return 1; if (pa->score > pb->score) return -1; return 0; } /* 比较函数:按成绩升序 */ static int compare_score_asc(const void *a, const void *b) { const Student *pa = (const Student*)a; const Student *pb = (const Student*)b; if (pa->score < pb->score) return -1; if (pa->score > pb->score) return 1; return 0; } /* 排序接口 */ void sort_by_score(StudentList *list, int ascending) { if (list->size <= 1) return; if (ascending) qsort(list->data, list->size, sizeof(Student), compare_score_asc); else qsort(list->data, list->size, sizeof(Student), compare_score_desc); } /* 保存到文件(文本格式) */ int save_to_file(StudentList *list, const char *filename) { FILE *fp = fopen(filename, "w"); if (!fp) return 0; /* 每行格式:id|name|age|gender|score\n */ for (int i = 0; i < list->size; ++i) { Student *p = &list->data[i]; /* 使用 fprintf,注意字段中不允许包含 '|' */ fprintf(fp, "%s|%s|%d|%c|%.2f\n", p->id, p->name, p->age, p->gender, p->score); } fclose(fp); return 1; } /* 加载文件(文本格式),加载时清空原有数据并替换 */ int load_from_file(StudentList *list, const char *filename) { FILE *fp = fopen(filename, "r"); if (!fp) return 0; /* 清空当前数据 */ list->size = 0; char line[LINE_BUF]; while (fgets(line, sizeof(line), fp)) { /* 去掉尾部换行 */ line[strcspn(line, "\n")] = 0; /* 解析字段 */ /* 安全解析:用 strtok 分割 '|' */ char *tok; char *saveptr; tok = strtok_r(line, "|", &saveptr); if (!tok) continue; Student s; strncpy(s.id, tok, ID_LEN-1); s.id[ID_LEN-1] = 0; tok = strtok_r(NULL, "|", &saveptr); if (!tok) continue; strncpy(s.name, tok, NAME_LEN-1); s.name[NAME_LEN-1] = 0; tok = strtok_r(NULL, "|", &saveptr); if (!tok) continue; s.age = atoi(tok); tok = strtok_r(NULL, "|", &saveptr); if (!tok) continue; s.gender = toupper((unsigned char)tok[0]); tok = strtok_r(NULL, "|", &saveptr); if (!tok) continue; s.score = (float)atof(tok); add_student(list, &s); /* 忽略重复学号返回值 */ } fclose(fp); return 1; } /*************************************************************** * file: main.c * 说明:程序入口与用户交互(菜单驱动) ***************************************************************/ #include <stdio.h> #include <string.h> #include "student.h" /* 显示菜单 */ static void show_menu() { printf("\n====== 学生管理系统(简单版) ======\n"); printf("1. 添加学生\n"); printf("2. 删除学生(按学号)\n"); printf("3. 修改学生(按学号)\n"); printf("4. 查询学生(学号/姓名)\n"); printf("5. 显示所有学生\n"); printf("6. 按成绩排序并显示\n"); printf("7. 保存到文件\n"); printf("8. 从文件加载\n"); printf("0. 退出\n"); printf("===================================\n"); printf("请选择(0-8):"); } /* 读取一行(替代 gets,安全) */ static void read_line(char *buf, int size) { if (fgets(buf, size, stdin)) { buf[strcspn(buf, "\n")] = 0; } else { buf[0] = 0; } } /* 添加学生的交互 */ static void ui_add_student(StudentList *list) { Student s; char buf[LINE_BUF]; printf("输入学号:"); read_line(buf, sizeof(buf)); strncpy(s.id, buf, ID_LEN-1); s.id[ID_LEN-1] = 0; if (find_student_by_id(list, s.id) != NULL) { printf("学号已存在,添加失败。\n"); return; } printf("输入姓名:"); read_line(buf, sizeof(buf)); strncpy(s.name, buf, NAME_LEN-1); s.name[NAME_LEN-1] = 0; printf("输入年龄:"); read_line(buf, sizeof(buf)); s.age = atoi(buf); printf("输入性别(M/F):"); read_line(buf, sizeof(buf)); s.gender = toupper((unsigned char)buf[0]); printf("输入成绩(0-100):"); read_line(buf, sizeof(buf)); s.score = (float)atof(buf); if (add_student(list, &s)) { printf("添加成功。\n"); } else { printf("添加失败(可能内存不足或学号重复)。\n"); } } /* 删除学生交互 */ static void ui_delete_student(StudentList *list) { char id[ID_LEN]; printf("输入要删除的学号:"); read_line(id, sizeof(id)); if (delete_student(list, id)) { printf("删除成功。\n"); } else { printf("未找到学号 %s。\n", id); } } /* 修改学生交互 */ static void ui_modify_student(StudentList *list) { char id[ID_LEN]; printf("输入要修改的学号:"); read_line(id, sizeof(id)); /* 注意:modify_student 内部会用 fgets 读取,需保证输入缓冲正确 */ if (!modify_student(list, id)) { printf("未找到学号 %s。\n", id); } } /* 查询学生交互 */ static void ui_query_student(StudentList *list) { char buf[LINE_BUF]; printf("按学号查询请输入 1,按姓名查询请输入 2:"); read_line(buf, sizeof(buf)); if (buf[0] == '1') { char id[ID_LEN]; printf("输入学号:"); read_line(id, sizeof(id)); Student *p = find_student_by_id(list, id); if (p) { printf("找到:%s %s 年龄:%d 性别:%c 成绩:%.2f\n", p->id, p->name, p->age, p->gender, p->score); } else { printf("未找到学号 %s。\n", id); } } else if (buf[0] == '2') { char name[NAME_LEN]; printf("输入(部分)姓名关键字:"); read_line(name, sizeof(name)); Student results[100]; int cnt = find_students_by_name(list, name, results, 100); if (cnt == 0) { printf("未找到匹配:%s\n", name); } else { printf("找到 %d 条记录:\n", cnt); for (int i = 0; i < cnt; ++i) { Student *p = &results[i]; printf("%s %s 年龄:%d 性别:%c 成绩:%.2f\n", p->id, p->name, p->age, p->gender, p->score); } } } else { printf("无效选择。\n"); } } /* 保存到文件交互 */ static void ui_save_to_file(StudentList *list) { char fname[LINE_BUF]; printf("输入保存文件名(例如 students.txt):"); read_line(fname, sizeof(fname)); if (save_to_file(list, fname)) { printf("保存成功。\n"); } else { printf("保存失败。\n"); } } /* 从文件加载交互 */ static void ui_load_from_file(StudentList *list) { char fname[LINE_BUF]; printf("输入加载文件名:"); read_line(fname, sizeof(fname)); if (load_from_file(list, fname)) { printf("加载成功。\n"); } else { printf("加载失败(检查文件是否存在或格式是否正确)。\n"); } } int main() { StudentList list; init_list(&list); /* 尝试加载默认文件(可选) */ // load_from_file(&list, "students.txt"); char choice_buf[8]; int running = 1; while (running) { show_menu(); read_line(choice_buf, sizeof(choice_buf)); switch (choice_buf[0]) { case '1': ui_add_student(&list); break; case '2': ui_delete_student(&list); break; case '3': ui_modify_student(&list); break; case '4': ui_query_student(&list); break; case '5': list_students(&list); break; case '6': { char ord[8]; printf("输入 1 升序,0 降序:"); read_line(ord, sizeof(ord)); int asc = (ord[0] == '1') ? 1 : 0; sort_by_score(&list, asc); list_students(&list); } break; case '7': ui_save_to_file(&list); break; case '8': ui_load_from_file(&list); break; case '0': { char yn[8]; printf("是否保存当前数据到 students.txt?(y/n):"); read_line(yn, sizeof(yn)); if (yn[0]=='y' || yn[0]=='Y') { if (save_to_file(&list, "students.txt")) printf("已保存到 students.txt\n"); else printf("保存失败。\n"); } running = 0; } break; default: printf("无效选择,请重新输入。\n"); break; } } free_list(&list); printf("程序已退出。\n"); return 0; }六、代码详细解读
init_list(StudentList *list)
初始化学生列表结构体,分配初始容量(调用ensure_capacity),并将size设为 0。任何使用前都应调用该函数。
free_list(StudentList *list)
释放学生列表中动态分配的内存,避免内存泄露。程序终止或不再使用列表时调用。
ensure_capacity(StudentList *list, int minCapacity)
内部工具函数,用于保证数组容量至少为minCapacity。如果当前容量不足,按倍增策略扩容(常见且高效),并处理realloc失败情况。
add_student(StudentList *list, const Student *s)
向列表添加新学生。函数首先检查学号是否重复(调用find_index_by_id),若重复则返回失败。若容量不足,则扩容,最后复制结构体到数组并更新size。
delete_student(StudentList *list, const char *id)
按学号删除学生。找到索引后用最后一个元素覆盖该位置(O(1) 时间删除,注意这会改变数组顺序),再将size--。若要保持原顺序,应采用移动元素的方法(O(n))。
find_student_by_id/find_index_by_id
按学号查找学生并返回指针或索引。用于删除、修改、查询等操作。
find_students_by_name
按姓名关键字(部分匹配)查找多条匹配记录,返回匹配数并把结果写入调用者提供的输出缓冲区,便于批量显示。
modify_student
交互式修改学生信息,支持跳过某项(按回车跳过)。函数演示了如何用fgets安全读取用户输入并做基本校验(年龄、成绩、性别)。
list_students
以表格样式显示当前学生列表,打印字段名和每条记录。便于课堂演示输出格式化。
sort_by_score
调用qsort对数组排序,使用两个比较函数实现升序或降序,并演示如何通过比较函数进行排序定制。
save_to_file
把当前学生列表写入文本文件,每条记录一行,使用|分隔字段(避免姓名中包含空格导致解析问题)。函数负责打开/写入/关闭并返回操作是否成功。
load_from_file
从文本文件加载学生记录。先清空当前列表(list->size = 0),然后逐行读取并用strtok_r按|分割字段,解析为Student结构并add_student(忽略学号重复导致的失败)。函数注意解析鲁棒性:若某行字段不足则跳过。
main与 UI 函数
main初始化列表后进入主循环,显示菜单并根据用户输入调用相应的 UI 函数(ui_add_student、ui_delete_student等)。界面交互使用fgets+read_line以安全读取,避免scanf的常见输入陷阱。退出前提示是否保存默认文件students.txt。
七、项目详细总结
本项目实现了一个简单但功能完整的学生管理系统,涵盖了:
数据建模(
Student结构体);动态内存管理(数组扩容策略);
常见 CRUD 操作(增删改查);
文件持久化(文本存储/加载);
数据排序(
qsort);用户交互(菜单驱动);
错误处理(重复学号、文件错误、输入校验);
该实现适合用于 C 语言教学、课堂演示、课程作业与小型练手项目。代码清晰、模块化,便于学生理解与后续扩展(如改成链表、加入数据库支持、添加 GUI 或网络功能)。
八、项目常见问题及解答
Q1:为什么使用动态数组而非固定大小数组?
A:动态数组更灵活,能根据实际数据量增长而不浪费内存。课堂上可以先用固定数组实现,理解后再替换为动态数组以支持更大规模。
Q2:为何文件采用文本格式而不是二进制?
A:文本格式可读性好,便于调试和教学。若追求性能或隐私可换为二进制fwrite/fread,但需注意跨平台兼容性(结构体对齐、字节序)。
Q3:删除操作为什么用最后一个元素覆盖?会改变顺序吗?
A:这种做法删除效率 O(1)。如果需要保持输入顺序,应将后续元素向前移动(O(n)),视需求选择实现方式。
Q4:如何避免姓名或学号包含分隔符导致解析错误?
A:本示例用|作为字段分隔符,前提是字段中不包含|。生产系统可以采用转义、JSON、CSV(并处理引号与转义)、或使用数据库存储以避免此类问题。
Q5:如何处理并发读写或多用户访问?
A:本示例为单进程单用户命令行程序。并发场景需引入锁、数据库或文件锁机制(例如flock)以及事务设计。
九、扩展方向与性能优化
本系统是简单版,以下为若干实用扩展方向与优化建议,适合课程作业或进阶练习:
1. 改用链表或平衡二叉树存储
链表便于插入/删除,适合频繁变动场景;但随机访问效率差。
平衡树(如 AVL、红黑树)可加速基于学号的查找。
2. 使用哈希表提升查找性能
用哈希表(学号为 key)能把查找、删除、插入降为平均 O(1),适合大规模数据。
3. 使用数据库持久化
如 SQLite 嵌入式数据库,能提供事务、并发和 SQL 查询能力,适合生产级应用。
4. 支持二进制保存与加密
二进制保存更高效;若数据敏感,可在保存前对内容加密(对称加密 AES 或简单的 XOR 练习)。
5. 添加导入/导出 CSV、JSON 功能
便于与 Excel或其他系统交互;可使用现成的 CSV/JSON 库或自实现解析。
6. 图形界面(GUI)或网页接口
将命令行程序改造为 GUI(如使用 GTK、Qt)或提供 REST API(使用 C 的 web 框架或改用其他后端语言)。
7. 多线程或异步 I/O 优化
在非常大的数据量或高并发场景中,分块保存或并行操作可提高吞吐。
8. 单元测试与持续集成
为每个函数编写单元测试(使用 C 的测试框架),并在 CI 中自动运行,提升代码质量。