VB.NET三层架构落地与VB6迁移避坑:把教程PPT变成可编译代码
简介:这份VS.NET入门教程以PPT形式呈现,面向零基础或刚接触.NET平台的编程初学者,重点解决从传统VB6向VB.NET过渡时的概念断层与语法差异问题。内容围绕VB.NET的面向对象特性展开,讲解继承、重载、重写、接口、委托、异常处理、命名空间、程序集、多线程与共享成员等核心机制,并梳理UI、BL、DB三层开发架构各自的职责与调用关系,同时用对照表呈现Property Let/Set、Currency、Variant、Wend等旧语法的替换方式,以及Integer、Long、Short、Decimal等整数与数值类型在版本间的对应变化。资源包共1个文件,为单份PPT课件,压缩包约116KB,体积轻便,适合课堂讲解或自学时快速浏览复习。该教程已有284人学习,可作为入门阶段的知识框架索引,帮助读者建立VB.NET语法体系与分层开发思路,为后续在VS.NET环境中动手实践和深入学习打下基础。
1. 从一份几十页的 PPT 说起:它到底能帮你解决什么
翻到这份《VS.NET 入门教程》PPT 的人,多半不是真要学 VB.NET 语法本身,而是手上有个老项目、老教材或者一门课要用它。PPT 前几页还算常规,真正有价值的是中间那张三层架构图:UI 用户界面层、BL 业务逻辑层、DB 数据交互层,调用方向、返回路径、各自该干什么,一张图讲完了很多人写了两年代码才悟出来的分层意识。后面接着是 VB6 到 VB.NET 的语法对照:哪些关键字被框架类替掉了,数据类型位宽怎么变,属性过程怎么从三段合并成一段,访问修饰符多出来的 Friend 和 Protected Friend 到底谁能看见。
这份材料的定位很清楚:给初学者一份能对着敲、对着查的语法索引和分层模板,不是让你读完就能接项目,而是让你在 VS.NET 里新建项目时不至于连代码往哪一层放都拿不准。适合三类人:要交课程设计的学生、接手 VB6 老系统需要迁移的开发者、以及需要快速回忆 VB.NET 语法细节的从业者。下面不按 PPT 页序讲,按"能跑起来"的顺序拆。
2. 三层架构落地:UI、BL、DB 的边界与一次请求的完整走位
2.1 三层各自管什么,边界画在哪
PPT 里对三层的描述很克制:UI 接请求、拿输入、调 BL、显示结果;BL 校验输入、做业务处理、调 DB、把结果回传;DB 负责读写数据库。这三句话看着像常识,实际写代码时最容易塌方的恰恰是边界——UI 层直接拼 SQL、BL 层里塞了个 GridView、DB 层开始判断"如果用户是 VIP 就打九折",这些都是把职责揉成一团的典型症状。
我把三层的职责和不许做的事列成一张表,对着这张表 review 自己的代码,比看十遍架构图都管用:
| 层 | 核心职责 | 不该出现的东西 | 越界后的典型症状 |
|---|---|---|---|
| UI 用户界面层 | 收集输入、调用 BL、展示返回结果与错误信息 | SQL 语句、业务规则判断、直接 new 数据库连接 | 换个界面就要重写业务逻辑,同一规则在两处不一致 |
| BL 业务逻辑层 | 参数校验、业务规则、事务边界、组装返回消息 | 控件对象、窗体引用、具体的数据库 Provider | 单元测试跑不起来,必须开界面才能验逻辑 |
| DB 数据交互层 | 连接管理、参数化命令、结果集映射、关闭释放 | 业务规则、界面文案、"如果金额大于 1000 就走另一张表" | 换数据库要改业务代码,SQL 注入防不住 |
判断有没有真的分层,有个很土但有效的办法:把 BL 和 DB 两个项目单独编译成一个类库,脱离界面能不能跑通一次完整调用。能跑通,说明边界是干净的;跑不通,说明某层偷偷依赖了 UI。
2.2 按调用链搭一个最小可跑的骨架
PPT 只给了概念,没给工程结构。我的做法是建一个解决方案,里面放四个项目:UiDemo(Windows 窗体应用)、Business(类库)、DataAccess(类库)、Contracts(类库,只放实体和接口)。引用关系是单向的:UiDemo 引用 Business 和 Contracts,Business 引用 DataAccess 和 Contracts,DataAccess 引用 Contracts。DB 层实现接口、BL 层只认接口,这样以后换数据库只动 DataAccess 一个项目。
先写契约层,实体和仓储接口都放这里:
' 文件:Contracts/OrderInfo.vb(只放数据,不放行为) Public Class OrderInfo Public Property OrderNo As String Public Property Quantity As Integer Public Property CreatedAt As DateTime End Class ' 文件:Contracts/IOrderRepository.vb Public Interface IOrderRepository Function Insert(order As OrderInfo) As Boolean Function GetByNo(orderNo As String) As OrderInfo End InterfaceOrderInfo只承载字段,不写校验逻辑;校验归 BL 层。IOrderRepository放在 Contracts 而不是 DataAccess,是为了让 Business 编译时不需要引用 DataAccess,依赖方向永远指向抽象。
接着是 BL 层。注意这里用的是ByRef msg As String,因为 VB.NET 一个函数只能有一个返回值,业务方法又需要同时告诉调用方"成功与否"和"失败原因",用 ByRef 回传消息是 VB 系项目里最常见的做法:
' 文件:Business/OrderService.vb Public Class OrderService Private ReadOnly _repo As IOrderRepository ' 构造函数注入,避免在 BL 里 new 具体仓储 Public Sub New(repo As IOrderRepository) _repo = repo End Sub Public Function Submit(order As OrderInfo, ByRef msg As String) As Boolean If order Is Nothing Then msg = "订单对象为空" Return False End If If String.IsNullOrWhiteSpace(order.OrderNo) Then msg = "订单号不能为空" Return False End If If order.Quantity <= 0 Then msg = "数量必须大于 0" Return False End If If order.CreatedAt = DateTime.MinValue Then order.CreatedAt = DateTime.Now ' 兜底赋值,避免写库时变成 1900 年 End If Try Return _repo.Insert(order) Catch ex As Exception msg = "写入失败:" & ex.Message Return False End Try End Function End ClassByRef msg必须在每个失败分支都赋值,否则调用方拿到的是空字符串,日志里只剩一句"提交失败",排查时就是黑匣子。Try...Catch放在 BL 而不是 DB,是因为 DB 层只负责抛,业主要决定"写入失败"对用户意味着什么。
DB 层用参数化命令,注意 OleDb 的占位符是问号,且参数顺序必须和 SQL 里问号出现的顺序严格一致,这一点和 SqlClient 的命名参数完全不同:
' 文件:DataAccess/OrderRepository.vb Imports System.Data.OleDb Public Class OrderRepository Implements IOrderRepository Private ReadOnly _connString As String Public Sub New(connString As String) _connString = connString End Sub Public Function Insert(order As OrderInfo) As Boolean Implements IOrderRepository.Insert Const sql As String = "INSERT INTO Orders (OrderNo, Quantity, CreatedAt) VALUES (?, ?, ?)" Using conn As New OleDbConnection(_connString) Using cmd As New OleDbCommand(sql, conn) ' 顺序必须与 SQL 中问号一致,错一个位置就是脏数据 cmd.Parameters.AddWithValue("?", order.OrderNo) cmd.Parameters.AddWithValue("?", order.Quantity) cmd.Parameters.AddWithValue("?", order.CreatedAt) conn.Open() Return cmd.ExecuteNonQuery() > 0 End Using End Using End Function Public Function GetByNo(orderNo As String) As OrderInfo Implements IOrderRepository.GetByNo Const sql As String = "SELECT OrderNo, Quantity, CreatedAt FROM Orders WHERE OrderNo = ?" Using conn As New OleDbConnection(_connString) Using cmd As New OleDbCommand(sql, conn) cmd.Parameters.AddWithValue("?", orderNo) conn.Open() Using reader = cmd.ExecuteReader() If reader.Read() Then Return New OrderInfo With { .OrderNo = reader.GetString(0), .Quantity = reader.GetInt32(1), .CreatedAt = reader.GetDateTime(2) } End If End Using End Using End Using Return Nothing End Function End ClassUsing块保证连接和命令在异常时也会释放,这是比手工写Try...Finally...conn.Close()更稳的写法。_connString从配置文件读,不要硬编码在类里,否则换个环境就要重新编译。
UI 层只做三件事:读表单、调 BL、显示结果:
' 文件:UiDemo/OrderForm.vb Private Sub btnSubmit_Click(sender As Object, e As EventArgs) Handles btnSubmit.Click Dim order As New OrderInfo With { .OrderNo = txtOrderNo.Text.Trim(), .Quantity = Integer.Parse(numQuantity.Value.ToString()) } Dim msg As String = String.Empty Dim repo As IOrderRepository = New OrderRepository(My.Settings.ConnStr) Dim svc As New OrderService(repo) Dim ok As Boolean = svc.Submit(order, msg) lblMsg.Text = If(ok, "提交成功", msg) End Sub2.3 引用方向错一次,分层就白做
最常见的退化是 UI 项目同时引用了 Business 和 DataAccess,理由往往是"界面要绑定实体"。一旦这个引用建立,开发者迟早会在窗体里直接 new 一个 Repository 省事,三个月后业务规则散落在十几个按钮事件里。正确的做法是把实体抽到 Contracts,UI 只引用 Contracts 和 Business,DataAccess 在启动时通过工厂或配置注入。
提示:如果发现 BL 层需要引用
System.Windows.Forms才能编译,说明有控件类型泄进了业务层,先把这个类型换成普通实体再继续。
3. 从 VB6 迁到 VB.NET:把 PPT 对照表变成能编译的代码
3.1 被框架类替换掉的关键字,逐个找替代
PPT 里有一页表专门列"VB 关键词 → 框架类里的位置 → 新方法名",这一页信息密度最高,也最容易被跳过。VB6 时代的Circle、Line、Atn、Sgn、Sqr、Rnd、LSet、RSet、VarType、DoEvents这些看起来像语句的东西,在 VB.NET 里全部让位给基础类库的方法,原因是框架要保证同一套能力对 C#、VB.NET 等语言都可用。
下面这张表是我从 PPT 里整理出来、并在项目里实际替换过的对照:
| VB6 写法 | VB.NET 替代位置 | 替换后写法 |
|---|---|---|
Circle (x, y), r | System.Drawing.Graphics | g.DrawEllipse(pen, x - r, y - r, 2*r, 2*r) |
Line (x1,y1)-(x2,y2) | System.Drawing.Graphics | g.DrawLine(pen, x1, y1, x2, y2) |
Atn(n) | System.Math | Math.Atan(n) |
Sgn(n) | System.Math | Math.Sign(n) |
Sqr(n) | System.Math | Math.Sqrt(n) |
Rnd | System.Random或兼容层 | New Random().NextDouble() |
Round(n, d) | 兼容层 | 优先用Math.Round(n, d, MidpointRounding.AwayFromZero) |
LSet(s, n)/RSet(s, n) | System.String | s.PadRight(n)/s.PadLeft(n) |
VarType(v) | System.Object | v.GetType() |
DoEvents() | 应用层 | Application.DoEvents(),但更该用异步 |
图形相关的改写最容易踩坑:VB6 的Circle参数是圆心加半径,DrawEllipse的参数是外接矩形的左上角加宽高,两者差一个x - r的偏移,直接照抄坐标画出来的圆会整体偏右下。数学函数换名后行为基本一致,但Rnd换成Random之后种子机制完全不同,Randomize那句代码要删掉;Round在 .NET 里默认走银行家舍入,2.5会变成2而不是3,涉及金额计算时必须显式指定MidpointRounding。
' VB6: Circle (1000, 1000), 500 → 圆心(1000,1000) 半径500 Dim g As Graphics = Me.CreateGraphics() Using pen As New Pen(Color.Black, 2) ' DrawEllipse 接收的是外接矩形:左上角 x=500, y=500,宽高各 1000 g.DrawEllipse(pen, 500, 500, 1000, 1000) End Using ' VB6: x = Atn(1) * 4 (求 π 的老写法) Dim pi As Double = Math.Atan(1) * 4 ' VB6: n = Sgn(-3) : r = Sqr(16) : v = Int(Rnd * 100) + 1 Dim n As Integer = Math.Sign(-3) ' -1 Dim r As Double = Math.Sqrt(16) ' 4 Dim rand As New Random() Dim v As Integer = rand.Next(1, 101) ' 1..100,上界不含Random实例不要在循环里反复 new,同一毫秒内创建的多个实例可能拿到相同种子,产生重复序列,这是迁移后"随机数不随机"的经典玄学问题。
3.2 数据类型位宽和默认传参方式:两处静默出错的坑
PPT 里那张位宽表必须背下来,因为它决定了和老接口、老文件、老数据库字段对接时会不会截断:
| VB6 类型 | 位宽 | VB.NET 对应 | 位宽 |
|---|---|---|---|
Integer | 16 bit | Short | 16 bit |
Long | 32 bit | Integer | 32 bit |
| 无 | — | Long | 64 bit |
Currency | 64 bit 定点 | Decimal | 128 bit |
Variant | 变体 | Object | 引用类型 |
最容易翻车的是第二行:VB6 里写Dim n As Long,迁到 VB.NET 后如果原样保留As Long,类型悄悄从 32 位变成了 64 位;反过来,把 VB6 的Long机械替换成 VB.NET 的Long,再去调用一个只接受 32 位的接口,就会在运行期抛溢出。正确映射是 VB6Long→ VB.NETInteger,VB6Integer→ VB.NETShort。金额字段则从Currency换成Decimal,并且所有中间计算都用Decimal,不要先转Double再转回来。
第二处是参数默认传递方式。PPT 明确写了:默认方式从ByRef变成了ByVal。这意味着老代码里那些"指望子过程顺便把参数改掉"的写法,迁移后全部失效,而且不报错,只是结果不对:
Sub ChangeByVal(ByVal n As Integer) n = 100 ' 改的是副本 End Sub Sub ChangeByRef(ByRef n As Integer) n = 100 ' 改的是调用方的变量 End Sub Dim a As Integer = 1 ChangeByVal(a) Console.WriteLine(a) ' 输出 1,老代码在这里往往期望 100 ChangeByRef(a) Console.WriteLine(a) ' 输出 100迁移时的判断标准很简单:这个方法需要往外带值,就显式写ByRef;只是读入,就保持ByVal。不要为了"兼容老行为"给所有参数都加ByRef,那样等于把 VB6 的副作用风险整体搬过来。
3.3 属性过程从三段合并成一段
VB6 里Property Let、Property Set、Property Get是三个独立的声明,VB.NET 把它们合并进一个Property...End Property块,Get和Set变成块内的访问器。合并之后有个细节变化:Set访问器里接收值的隐式变量仍然叫value,但Get访问器推荐写Return,而不是像 VB6 那样给属性名赋值。
Private msName As String Public Property Name() As String Get Return msName ' 推荐写法,语义清晰 End Get Set(value As String) If value Is Nothing Then value = String.Empty msName = value.Trim() ' 在 Set 里统一做规整 End Set End Property把Trim之类的规整逻辑放在Set里,是为了保证不管从哪条路径赋值,落到字段里的值都是干净的。代价是Set里不要做耗时操作或抛业务异常,属性访问器在调试器里会被频繁求值,塞重逻辑进去会让断点调试变成灾难。
4. 访问修饰符与命名空间:类放在哪、谁能看见
4.1 五个修饰符的可访问范围
PPT 里这段给了完整的示例代码,直接照着敲一遍比读文字有用。Public无限制;Private仅本类或本模块内;Protected本类及派生类;Friend同一项目内(注意是项目/程序集,不是命名空间);Protected Friend是两者的并集,派生类或同项目都能访问。
Public Class TestA Public i As Int32 = 100 ' 无限制 Private s As String = "Hello" ' 仅 TestA 内 Protected b As Boolean = True ' TestA 及其派生类 Friend d As Double = 3.1415926 ' 同一项目内 Protected Friend l As Long = 100000 ' 派生类或同项目 End Class ' 同项目内的派生类:i、b、d、l 都能访问,s 不行 Public Class TestB Inherits TestA Public Sub New() MyBase.b = False MyBase.d = 3.14 MyBase.i = 10 MyBase.l = 10000 End Sub End Class ' 同项目内的非派生类:能访问 i、d、l,不能访问 b 和 s Public Class TestC Public Sub New() Dim a As New TestA a.d = 3.14 a.i = 10 a.l = 1000 ' a.b = True ' 编译错误:Protected 不可从非派生类访问 End Sub End ClassTestC里那句被注释掉的a.b = True值得亲手试一次,编译器报的错会让人真正记住Protected和Friend的区别。Friend的作用域是程序集,跨项目引用时即使命名空间一样也访问不到,这一点在拆解决方案时经常被忽略。
4.2 根命名空间导致的重复前缀
PPT 里讲命名空间时提到一个容易漏的点:VB.NET 工程默认有一个根命名空间,名字和工程名相同,Namespace...End Namespace块是往根命名空间上追加,不是替换。假设工程名叫MyProject,代码里写了:
Namespace MyNamespace Public Class MyClass End Class End Namespace那么完整类型名是MyProject.MyNamespace.MyClass,而不是MyNamespace.MyClass。很多人在别处引用时写MyNamespace.MyClass编译不过,就是因为漏了根前缀。两种处理方式:一是在工程属性里把根命名空间清空,二是引用时写全。我一般选第一种,让代码里的命名空间和实际类型名一一对应,减少心智负担。
同一命名空间可以分散在多个文件里,这一点 PPT 用两段代码演示过:一个文件写MyClass,另一个文件写MyOtherClass,都包在Namespace MyNamespace里,编译后就是同一个命名空间下的两个类。这个特性在多人协作时很有用,按功能分文件、按命名空间归类,互不干扰。
4.3 继承、MyBase 与构造函数执行顺序
Inherits只能跟一个基类,Implements可以跟多个接口。子类构造函数里用MyBase.New()显式调用基类构造,如果不写,编译器会自动插入对基类无参构造的调用,而基类一旦只定义了带参构造,编译立刻失败。
Public Class BaseService Protected ReadOnly ConnStr As String Public Sub New(connStr As String) ConnStr = connStr End Sub End Class Public Class OrderService Inherits BaseService Public Sub New(connStr As String) MyBase.New(connStr) ' 必须显式调用,且必须是第一句 End Sub End Class执行顺序是:基类字段初始化 → 基类构造函数 → 子类字段初始化 → 子类构造函数体。如果子类构造函数里访问了基类某个依赖注入的字段,而基类构造函数还没跑完,拿到的就是 Nothing。这类问题不会在编译期提示,只在运行时空引用。
5. 避坑:PPT 上不会标红的五处翻车点
5.1Imports System.WinForms直接编译不过
现象:照着 PPT 敲Imports System.WinForms,编辑器立刻画红波浪线,提示命名空间不存在。
原因:PPT 用的是早期框架的命名空间布局,窗体相关的类型后来统一收进了System.Windows.Forms,WinForms这个名字不再存在。
解决:把Imports System.WinForms改成Imports System.Windows.Forms。同理,PPT 里出现的System.WinForms.Form基类也要一起改,否则Inherits那行同样报错。批量替换后重新生成,如果还有残留报错,用"查找全部"搜WinForms逐个确认。
5.2 子过程里改了参数,回到调用方没变化
现象:一个校验方法内部给参数赋了默认值,调用方拿到的还是原来的 Nothing,后续空引用异常。
原因:VB.NET 默认按值传递,参数是副本,方法内的修改不影响调用方。从 VB6 迁过来的代码默认按引用,迁移后语义变了但没有任何编译警告。
解决:需要回传的参数显式加ByRef;更推荐的做法是用返回对象承载多个结果,比如定义一个ValidateResult类,包含IsValid和Message两个属性,比一串ByRef参数可读性好得多。
5.3 Integer 和 Long 位宽变了,老接口数值错乱
现象:从旧系统读出来的编号,迁移后变成负数或者被截断;写回旧表时数据库报溢出。
原因:VB6 的Long是 32 位,VB.NET 的Long是 64 位,而 VB.NET 的Integer才是 32 位。机械替换类型名会让位宽整体偏移一档。
解决:按 3.2 节的映射表逐个核对,尤其是和外部文件、旧数据库、串口协议打交道的字段。用Integer.TryParse替代CInt,溢出时能拿到False而不是异常,便于定位是哪条数据越界。
5.4Get访问器里给属性名赋值,递归到栈溢出
现象:运行到某个属性读取时直接崩溃,调用栈里同名的Get访问器重复出现几百层。
原因:VB6 的习惯是在Get里写属性名 = 字段名来返回,迁移到 VB.NET 后,如果属性名和内部字段名没有区分开,这句赋值会被解析成又一次属性写入或读取,形成自引用。
解决:Get里统一用Return 字段名,并且内部字段统一加前缀(如ms、_),从命名上杜绝同名。这个坑一旦踩到,堆栈信息会非常难看,属于典型的血泪经验。
5.5 数组下界默认值和Option Base的旧习惯
现象:老代码里Dim arr(10)然后从 1 循环到 10,迁移后发现少处理了一个元素,或者第 0 个元素永远是空。
原因:VB6 里可以通过Option Base 1改变默认下界,VB.NET 的数组下界固定从 0 开始,Dim arr(10)表示 11 个元素。
解决:所有For i = 1 To UBound(arr)的循环改成For i = 0 To arr.Length - 1,或者直接用For Each。如果业务语义确实需要 1 基,就用List(Of T)并在访问时做index - 1转换,别在数组上硬凑。
6. 把 PPT 拆成可编译的语法卡片:一份自测清单和验证方法
PPT 最大的问题是它只能看不能跑。我的习惯是拿到这种材料后,按知识点拆成一个个独立的小Module,每个文件只验证一个语法点,全部放在一个控制台项目里,按编号命名,跑一遍就知道哪一页自己其实没看懂。
' 文件:Cards/Card04_AccessModifier.vb Module Card04_AccessModifier Sub Run() Dim a As New TestA() Console.WriteLine("Public i = " & a.i) Console.WriteLine("Friend d = " & a.d) Console.WriteLine("ProtectedFriend l = " & a.l) ' Console.WriteLine(a.b) ' 取消注释应立即编译失败,说明 Protected 生效 Dim b As New TestB() Console.WriteLine("派生类可访问 Protected,b 已置为 " & b.ReadProtected()) End Sub End Module卡片的写法有三条规矩。第一,每个卡片必须包含一句"故意写错"的注释代码,比如上面那行被注释掉的a.b,取消注释后编译器报错,才算真正验证了修饰符的边界。第二,涉及数据类型位宽的卡片,必须打印Integer.MaxValue、Long.MaxValue这类边界值,而不是打印一个普通数字。第三,涉及传参方式的卡片,要在调用前后各打印一次实参,肉眼确认值有没有被改。
验证顺序建议按依赖排:先跑声明语句和数据类型卡片(不依赖任何东西),再跑访问修饰符和继承(依赖类定义),最后跑异常处理和命名空间。全部卡片跑通,相当于把 PPT 里能落地的部分过了一遍。
从那以后我每次拿到这类教程类材料,都强制先做一遍"卡片化",把每一页的语法点变成能编译、能故意报错的最小文件,再回头看 PPT 里的架构图和对照表,理解会完全不一样。希望帮到你。
本文还有配套的精品资源,点击获取