简介围绕VB学籍管理系统展开的毕业设计文档面向计算机相关专业学生及初学VB、数据库开发者可用于课程设计、毕业设计参考与学籍管理业务逻辑梳理。压缩包仅含1个doc文件约523KB是一份结构完整的论文式设计说明。内容从系统需求分析、可行性分析入手依次展开系统功能分析与流程图、数据库需求与概念结构设计含E-R图、表结构实现再到主窗体、用户管理、学籍管理、班级管理、课程设置、成绩管理等模块的详细设计并附总结、参考文献与致谢。读者可据此理解VB窗体与控件布局、ADO连接Access或SQL Server、增删改查与权限分配的实现思路也能参照章节组织与E-R设计方法整理自己的项目文档。已有436人学习适合需要完整设计流程与数据库建模范例的读者按模块查阅。1. 老兵不死一份 VB6 学籍管理系统源代码里最值钱的二十行一份看起来过时的 VB6 学籍管理系统源代码最值得读的地方往往不是学籍管理这四个字而是 Module.bas 里那个只有二十行的 ExecuteSQL——它把界面输入 → 拼接 SQL → 建立连接 → 拿回记录集 → 塞进表格控件这整条链路压在了同一个函数体里没有任何抽象层遮挡。今天的 ORM、连接池、参数化查询、事务边界各自在挡什么风险只要拿这套代码当对照物就能一眼看清。它不是教材但它不撒谎一条语句从 TextBox 到 SQL Server 2000 到底经过了几次字符串拼接、几次引用计数增减、几个游标开关全摆在那儿。搞懂它你也就有了判断框架。2. 公共出口ExecuteSQL 怎么把一条拼接字符串变成记录集这套系统只有一条数据通道。所有窗体、所有按钮、所有增删改查最终都收敛到公用模块里的一个函数理解了这个函数整个工程的读写行为就有了统一的解释模型。2.1 .bas 模块那个年代最朴素的共享层VB6 的工程结构很直白.vbp 记录工程构成.frm 存窗体.frx 存窗体的二进制属性.bas 存标准模块.cls 存类模块.ocx 是外部控件。标准模块里用 Public 声明的函数和变量对整个工程可见VB6 没有命名空间、没有程序集隔离一个 .bas 就相当于今天的一层 Service——只不过它共享的不是业务能力而是数据库访问。这套系统把连接字符串和 ExecuteSQL 一起放进 Module.bas好处是任何窗体事件里一行调用就能取数据不用构造对象、不用传依赖代价也很清楚连接串硬编码、函数无法替换、无法单独测试、异常处理只能靠全局 Err 对象。桌面单机 MIS 规模下这些代价可以接受一旦要支持多用户并发或者换数据库这层薄壳就会变成最难拆的部分。一个典型的调用形态是这样注意 SQL 是拼出来的Private Sub cmdQuery_Click() Dim rst As ADODB.Recordset Dim msg As String Dim sSql As String Dim i As Integer 注意字符串开头这里有个前导空格后面会讲它带来的后果 sSql SELECT student_ID, student_Name, class_NO _ FROM student_Info _ WHERE student_Name LIKE % Trim$(txtName.Text) % Set rst ExecuteSQL(sSql, msg) msg 是 ByRef 输出参数带回摘要 If rst Is Nothing Then Exit Sub DML 分支返回 Nothing必须判空 MSFlexGrid1.Rows 1 Do Until rst.EOF MSFlexGrid1.Rows MSFlexGrid1.Rows 1 For i 0 To rst.Fields.Count - 1 MSFlexGrid1.TextMatrix(MSFlexGrid1.Rows - 1, i) rst.Fields(i).Value Next i rst.MoveNext Loop rst.Close Set rst Nothing lblMsg.Caption msg End Sub这段调用说明了三件事其一VB6 的参数默认是 ByRef所以 MsgString 不需要返回值就能把执行结果带回调用方等于用副作用传结果其二Recordset 是手工遍历的Fields.Count 决定列数、TextMatrix 决定单元格、EOF 与 MoveNext 决定循环边界没有任何数据绑定魔法其三rst.Close 之前必须保证循环没把游标停在 EOF 的非法位置否则关闭时可能抛错。这些细节在今天都被 DataReader、DataGrid 自动绑定、using 语句包掉了但底层是同一套东西。2.2 Split 加 InStr一行判断背后的猜谜游戏函数最精巧也最脆弱的地方是用字符串的第一个词来猜这条 SQL 是查询还是写入Public Function ExecuteSQL(ByVal SQL As String, MsgString As String) As ADODB.Recordset Dim CNn As ADODB.Connection Dim rst As ADODB.Recordset Dim sTokens() As String On Error GoTo ExecuteSQL_Error sTokens Split(SQL) 按空格切分取第一个词当动词 Set CNn New ADODB.Connection CNn.Open ConnectString ConnectString 是模块级全局常量 If InStr(INSERT,DELETE,UPDATE, UCase$(sTokens(0))) Then CNn.Execute SQL 写入类无记录集返回函数最终返回 Nothing MsgString sTokens(0) query successful Else Set rst New ADODB.Recordset rst.Open Trim$(SQL), CNn, adOpenKeyset, adLockOptimistic Set ExecuteSQL rst 赋给返回值引用计数 1 MsgString 查询到 rst.RecordCount 条记录 End If ExecuteSQL_Exit: Set rst Nothing 只释放局部变量那一份引用 Set CNn Nothing Exit Function ExecuteSQL_Error: MsgString 查询错误: Err.Description Resume ExecuteSQL_Exit End Function参数语义先说清楚SQL 是 ByVal 传入的完整语句串MsgString 是 ByRef 的输出参数用来带回查询成功或查询错误:xxx这类执行摘要返回值是 ADODB.Recordset写入类语句返回 Nothing。执行路径分两支首词命中 INSERT/DELETE/UPDATE 就走 Connection.Execute 直接执行不产生记录集否则用 Recordset.Open 打开一个可滚动游标。问题在于 InStr 是子串匹配不是数组比较。它有两个致命边界第一InStr(INSERT,DELETE,UPDATE, )返回 1因为空串在任何字符串的位置 1 处匹配所以只要 SQL 开头有空格Split 出的第一个元素就是空串判断为真一条 SELECT 会被当成写入类执行第二Split(SQL)只按空格切SQL 里开头是换行、Tab、注释块或者 WITH 子句首词都不是真正的动词。这条链路在正常手工输入时基本能糊过去一旦格式化代码或者自动生成语句就会稳定踩坑。Set rst Nothing那行也值得停一下。函数返回值已经持有一份对象引用局部变量再释放只是把引用计数减一Recordset 并不会销毁——这正是 VB6 里明明 Set Nothing 了对象还活着的答案第 3 章会展开。2.3 游标与锁adOpenKeyset 加 adLockOptimistic 选的是什么rst.Open的后两个参数才是这套系统真正的性能开关。ADO 的游标类型有四种adOpenForwardOnly 只能向前滚、开销最小adOpenKeyset 维护键集能看到他人的修改和删除看不到新增adOpenDynamic 全动态、服务端每行一次往返adOpenStatic 拿一份静态快照。锁类型里 adLockReadOnly 最快adLockPessimistic 编辑时立刻加锁adLockOptimistic 提交更新时才检查冲突。这里选 keyset 加 optimistic动机很实在表格控件需要 RecordCount、需要前后翻页、需要就地编辑后回写这三点 forwardOnly 都给不了而 pessimistic 会在用户点开一行的瞬间锁住服务端记录对多人使用的系统来说等于自找麻烦。代价是服务端要为每个结果集维护键集数据量大时内存和网络往返都会涨且 Keyset 下看不到别人新插入的行刷新界面时会有明明加了学生却查不到的困惑。源码里还有一行被注释掉的rst.MoveLast get RecordCount是个历史层。作者当初大概是为了让 RecordCount 变成准确值才加 MoveLast后来换了 Keyset 发现不必就注释掉了。这一行注释是理解第 3 章 RecordCount 问题的最好入口。3. 生命周期ADO 的 Connection 与 Recordset 什么时候才算真的关掉很多接手老 VB 项目的人会卡在同一个问题上代码里明明写了 Set Nothing为什么数据库连接没释放、句柄越用越多。答案藏在 COM 的引用计数模型里。3.1 New、Open、Set Nothing 的时序VB6 没有垃圾回收对象是 COM 对象靠 AddRef 和 Release 维护引用计数计数归零的瞬间析构。Set CNn New ADODB.Connection创建对象并持有一份引用CNn.Open ConnectString建立底层连接Set CNn Nothing把这份引用释放。但 Recordset 打开时会持有对 Connection 的引用通过 ActiveConnection 指过去。所以当函数返回 rst 给调用方时即使 CNn 已经被 Set Nothing只要 rst 还活着底层连接就不会真正关闭。连接什么时候关取决于调用方什么时候执行 rst.Close 并释放它。这就是为什么会有程序跑了半天数据库连接数涨到几十上百的现象——每个子窗体各绑一个结果集结果集不关连接就一直占着。把这个模型再推一层Set ExecuteSQL rst让函数返回值持有一份引用Set rst Nothing减掉局部变量那份两相抵消对象计数没有归零所以返回值有效。这不是语法糖是引用计数的直接结果也是 VB6 里最常见的看着像 bug 其实是对的的地方。3.2 RecordCount 为什么有时是 -1MsgString 查询到 rst.RecordCount 条记录这行代码在服务端游标下并不可靠。ADO 的 RecordCount 属性需要游标知道结果集总行数而 forwardOnly 游标永远返回 -1Keyset 和 Static 游标在服务端模式下游标未完全填充前也可能返回 -1。要拿到准确值要么先 MoveLast 再 MoveFirst 强制走到底要么干脆用一条 COUNT 语句。两种做法的取舍很明确。MoveLast 的代价是把整个结果集在网络上走一遍大表上是灾难单独的 COUNT 是一次额外往返但结果稳定、不受游标类型影响。老代码里经常两种混着写结果就是同一个界面有时显示 5 条、有时显示 0 条 做法一强制填充后取行数代价是全量遍历 rst.MoveLast rst.MoveFirst n rst.RecordCount 做法二单独 COUNT代价是一次额外往返但不受游标影响 Dim rsCnt As ADODB.Recordset Set rsCnt ExecuteSQL(SELECT COUNT(*) AS c FROM student_Info, msg) n rsCnt.Fields(c).Value做法二还有个好处COUNT 语句首词是 SELECT不会误触发 2.2 里那个空串判断比在结果集上做小动作安全得多。注意 Fields(c) 用的是列别名别名必须和 SELECT 里写的完全一致中文列名在某些客户端配置下还会出乱码所以我一般强制用英文别名。3.3 每次调用都开新连接真的慢吗直觉上每次调用 ExecuteSQL 都 New 一个 Connection、Open、再释放应该很慢。实际未必因为 OLE DB 有会话池Session Pooling同一个进程内连接字符串完全一致、线程套间一致的情况下关闭的连接会进池下次 Open 直接复用省掉登录握手和会话建立。默认池大小 100、超时 60 秒对单机桌面 MIS 来说绰绰有余。真正会让池失效的往往是一些不起眼的差异连接串里多一个空格、Provider 写法不同、一个用 SQL 身份验证一个用集成验证都会被认为是不同的池。这些属于玄学类问题的典型来源——代码看起来一样性能却差一倍。要定位的话在连接串里显式写OLE DB Services -4可以把池化关掉做对比如果关掉后反而更慢说明池本来是在生效的。3.4 错误处理On Error GoTo 加 Resume 的真实语义函数末尾的On Error GoTo ExecuteSQL_Error到Resume ExecuteSQL_Exit是 VB6 的结构化错误处理前身。Resume 到指定标签会清空 Err 对象并跳转Resume Next 则从出错行的下一行继续错误处理块里如果再触发未捕获错误会直接冒泡到调用者导致整个窗体崩溃且 Err 内容被覆盖。还有一个实践层面的坑Err.Description 是本地化字符串中文系统上返回中文句子英文系统上返回英文句子。老代码里经常写If InStr(Err.Description, 重复) 0 Then这类判断换一台机器就静默失效。稳妥做法是判 Err.NumberSQL Server 的唯一约束冲突是 2627、外键冲突是 547这些数字跨语言稳定。4. 物理落地六张表从 E-R 图变成 char(10) 与 int 之后发生了什么概念模型画得再漂亮最终决定系统寿命的是建表语句里那几个类型和约束。这套系统的六张表问题几乎全在细节上。4.1 实体关系落到列三个外键一个都没建E-R 图上画了学生、班级、课程、年级四个实体关系有一对多也有多对多。物理落地后student_Info.class_NO 是 intclass_Info.class_No 也是 int两者对应但没有任何 FOREIGN KEY 声明result_Info 作为学生与课程的关联表主键设成了 exam_No考试编号而不是 (student_ID, course_Name)意味着同一学生对同一门课可以有多条成绩记录——这勉强可以解释成多次考试但系统里并没有考试场次这个概念来支撑它。gradecourse_Info 把年级开哪些课表达成 grade 加 course_Name 两列同样没有主键。没有外键的后果不是立刻可见的删掉一个班级班里学生的 class_NO 变成孤儿值界面查班级名时拿到空串改一次学号历史成绩全部断链而且数据库不会报任何错。这类问题往往要等数据攒了两年才被发现那时候修复成本已经很高了。4.2 char(10) 存姓名字节语义与代码页建表语句里大量出现[student_Name] [char] (10) COLLATE Chinese_PRC_CI_AS。这里有两个概念必须分清n 在 char(n)/varchar(n) 里是字节数在 nvarchar(n) 里是字符数而 Chinese_PRC_CI_AS 是中文简体排序规则CI 表示大小写不敏感AS 表示重音敏感对应的代码页是 936一个汉字占 2 字节。于是 char(10) 实际只能存 5 个汉字。姓名勉强够用但复姓加双名、少数民族姓名会直接截断或报错。更要命的是 tele_Number 也是 char(10)——11 位手机号根本装不下这条字段从设计之初就注定要出问题。course_Name char(10) 只能放 5 个汉字数据结构与算法这种课程名会插入失败或悄悄截断。course_Des char(50) 约 25 个汉字地址 varchar(50) 同样。改造思路是把所有承载自然语言的字段换成 nvarchar长度按字符计同时放宽数字串字段ALTER TABLE student_Info ALTER COLUMN student_Name NVARCHAR(20) NOT NULL; ALTER TABLE student_Info ALTER COLUMN tele_Number VARCHAR(20) NULL; ALTER TABLE course_Info ALTER COLUMN course_Name NVARCHAR(50) NOT NULL; ALTER TABLE course_Info ALTER COLUMN course_Des NVARCHAR(200) NULL;还要注意 char 是定长不足部分补空格。查询时WHERE student_Name 张三因为 SQL 标准的补齐规则通常能匹配上但 LEN() 会忽略尾随空格、DATALENGTH() 不会这两个函数在报表统计里混用会导致明明有数据却算成 0。定长列只在长度高度一致比如身份证号、学号时才值得用姓名这种天然变长的字段用它纯属自找麻烦。4.3 gradecourse_Info把课程名当外键的代价这张表只有 grade 和 course_Name 两列没有 course_No。这样设计在当年是很自然的选择查某年级开哪些课只要一条SELECT course_Name FROM gradecourse_Info WHERE grade 2005就出结果不用 joinVB6 里拼 SQL 也简单而课程改名的频率看起来很低。但它埋了一个连锁反应课程改名时course_Info 改一次gradecourse_Info 要跟着改result_Info 里的历史成绩也要跟着改三张表没有任何约束保证它们同步。实际运维里最典型的场景是教务把计算机基础改成大学计算机基础改完课表对了两年前的成绩单全变成空白课程名。这是自然键做外键的经典教训。用课程编号做关联改名就只是一条 UPDATE用课程名做关联改名就变成一次全库数据迁移。今天讨论业务 ID 还是自增 ID 做主键本质上是同一个问题。4.4 主键与密码缺位的约束六张表的建表语句里字段标了 NOT NULL但没有一张声明 PRIMARY KEY。SQL Server 会把它们建成堆表没有聚集索引学号可以重复插入更新和删除语句可能一次误伤多行。user_Info 表更彻底user_ID char(10)、user_PWD char(10)、user_Des char(10)没有主键密码明文长度上限 10 个字符。补主键和外键的成本很低收益是立刻的ALTER TABLE student_Info ADD CONSTRAINT PK_student_Info PRIMARY KEY (student_ID); ALTER TABLE class_Info ADD CONSTRAINT PK_class_Info PRIMARY KEY (class_No); ALTER TABLE result_Info ADD CONSTRAINT FK_result_student FOREIGN KEY (student_ID) REFERENCES student_Info(student_ID); ALTER TABLE student_Info ADD CONSTRAINT FK_student_class FOREIGN KEY (class_NO) REFERENCES class_Info(class_No);外键加上之后删班级这个操作会直接失败而不是留下孤儿数据这比在界面层写十条判断都可靠。密码字段则应该改成存哈希值长度给够比如 VARCHAR(64) 存 SHA-256 的十六进制串明文存密码在今天是不可接受的在那套系统里却是默认做法。5. 避坑排查这套代码接手后最容易翻车的五个地方老代码的问题有个共同特征不报错。它会安静地返回空结果、安静地截断字段、安静地占用连接让你以为是数据问题。下面五条是实际接手这类系统时反复出现的每条按现象、原因、解决写。5.1 查询没结果也不报错界面一片空白现象输入姓名点查询表格没数据状态栏也没显示查询错误看起来像数据库里真没有这条记录。原因SQL 字符串开头带了空格或者拼接时 WHERE ...前多了空格Split(SQL)出来的第一个元素是空串InStr(INSERT,DELETE,UPDATE, )返回 1判断为真于是走CNn.Execute分支。Connection.Execute 执行一条 SELECT 是合法的结果集被直接丢弃函数返回 Nothing调用方If rst Is Nothing Then Exit Sub静默退出。解决函数入口加SQL LTrim$(SQL)只能挡住前导空格挡不住换行、Tab 和注释。更稳的做法是取首词后用白名单精确比对而不是子串匹配SQL LTrim$(SQL) If SQL Then Exit Function sTokens Split(SQL) Select Case UCase$(sTokens(0)) Case INSERT, UPDATE, DELETE 精确匹配不做子串判断 CNn.Execute SQL Case Else Set rst New ADODB.Recordset rst.Open SQL, CNn, adOpenKeyset, adLockOptimistic Set ExecuteSQL rst End Select5.2 更新完数据界面崩在对象变量未设置现象点保存修改数据库里确实改成功了紧接着弹出运行时错误 91提示对象变量或 With 块变量未设置。原因更新走的是 DML 分支函数返回 Nothing但调用方仍然写了Set rst ExecuteSQL(...)然后rst.MoveNext或者rst.Fields(0).Value对 Nothing 取成员直接崩。解决调用方必须判空或者更彻底一点把读写拆成两个函数——ExecuteSQL只返回记录集ExecuteCmd只返回受影响行数让类型系统帮你记住哪条路没有返回值。老代码里混用的根源就是一个函数干两件事。5.3 明明有五条数据界面显示查询到 0 条记录现象表格里数据出来了状态栏却写查询到 0 条记录或者第一次查是 0切个页面再查就正常了。原因RecordCount 依赖游标类型和填充状态。服务端 Keyset 游标在未完全填充前返回 -1 或 0ForwardOnly 永远返回 -1。如果某次改动把打开参数换成了 adOpenForwardOnly比如为提升性能这一行就彻底失效。解决不要信任 RecordCount 的即时值改用 COUNT 语句或者显式 MoveLast 再 MoveFirst。同时检查 Recordset 的 CursorLocation默认是 adUseServer改成 adUseClient 会让本地一次拉全量、RecordCount 立刻准确但数据量大时内存会翻车。5.4 姓名、手机号、课程名被截断或插入报错现象录入 11 位手机号提示字符串或二进制数据将被截断或者是四字姓名存进去只剩两个字没有任何报错。原因char(10) 按字节计代码页 936 下一个汉字两字节。截断是 SQL Server 在 ANSI_PADDING 和静默截断设置下的默认行为只在部分客户端配置下才报错。解决承载自然语言的列一律换 nvarchar长度按字符数给足数字串字段手机号、证件号用 varchar 而不是 char避免定长补空格带来的比较问题。改完类型后要回头检查前端输入框的 MaxLength否则界面还是只让输 5 个字。5.5 姓名里带单引号直接弹出查询错误现象查一个叫 ONeil 的学生或者地址里写了单引号SQL 直接语法错误界面只显示一句本地化的错误描述。原因字符串拼接没有任何转义单引号提前闭合了字面量。更严重的是这同时是一条注入通道输入 OR 11就能绕过所有条件。解决Replace(sSql, , )只是止血对数值型字段和 LIKE 通配符都没用。治本是换成 ADO Command 加 Parameters参数化之后单引号和注入一起消失Dim cmd As New ADODB.Command cmd.ActiveConnection CNn cmd.CommandText SELECT student_ID, student_Name FROM student_Info WHERE student_Name ? cmd.Parameters.Append cmd.CreateParameter(name, adVarChar, adParamInput, 50, txtName.Text) Set rst cmd.Execute参数化还有个副作用是执行计划可以复用同一语句反复执行时数据库不必重新编译长期看比拼串更快。6. 设计取舍如果今天重写这套学籍系统哪几个决定我会反过来做这套代码有几个决定放在今天依然成立。薄数据访问层没有错错的是它承担了它不该承担的职责MDI 加菜单启停的权限模型在单机场景下简单有效用服务端游标换表格控件的滚动和定位能力在小数据量下是划算的交易把公用函数和连接串集中在一个 .bas 里也比散落在几十个窗体事件里要好维护得多。这些是设计判断不是技术债。真正要反过来做的是四件事每一件都有明确的代价对比当年的做法现在的做法换来的收益付出的代价字符串拼接 SQLCommand Parameters注入消失、计划复用代码量增加约三成char(10) 存姓名和手机号nvarchar / varchar 按语义给长度不再静默截断迁移需要重建索引无主键无外键的裸表主键加外键加唯一约束孤儿数据不可能产生删除操作要先处理依赖明文密码 char(10)哈希加盐存 VARCHAR(64)拖库不再等于泄露老账号需要强制重置有一个决定我会保留但加限制把 DML 和查询塞进同一个函数。它省下了调用方的判断成本但把有没有返回值变成了运行时才知道的事。折中方案是保留统一入口但让函数在 DML 分支返回一个空记录集而不是 Nothing调用方的判空逻辑就永远成立。验证改造是否真的生效我一般不看代码看行为在姓名里输一个单引号看是否还能查把手机号填满 11 位看是否截断删一个有学生的班级看数据库是否拒绝连续开关二十个子窗体看连接数是否回落。这四条过了说明改造不是纸面功夫。从那以后我每次接手这类老系统都强制先把建表语句和那个公共数据函数完整读一遍再动界面——因为血泪经验告诉我界面上看到的每一个诡异现象根子几乎都在这两处。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?