浅谈“三层结构”的原理与用意
对于有经验的Web应用程序开发人员来说,“三层结构”一词应该不会感到陌生。其实“三层结构”的开发模式不仅仅可以应用于Web应用程序,在其他应用领域也是可以发挥其巨大作用的。而本文主旨是阐明三层结构的原理与用意,并说明Bincess的三层结构的特点。
“三层结构”指的是什么?
“三层结构”一词中的“三层”是指:“外观层”、“中间层”、“数据库层”。其中:
¨ 外观层:位于最外层,直接呈现在用户面前。用于显示数据,并为用户提供一种交互式的界面。
¨ 中间层:负责处理用户输入的信息,或者是将这些信息发送给数据库层进行保存,或者是调用数据库层中的函数再次读出这些数据。
¨ 数据库层:仅实现对数据的保存和读取操作。
为什么需要 “三层结构”
在一个软件系统中,如果不分以层次,那么在将来的升级维护中会遇到很大的麻烦。就像一个ASP.NET网页访问数据库一样。例如在ASP.NET后台程序文件aspx.cs中,使用OleDbConnection和OleDbCommand来处理Access后台数据库。而当数据库服务器从Access2000升迁到SQLServer2000的时候,我们就必须修改原来的OleDbConnection为新的SqlConnection,OleDbCommand为新的SqlCommand来适应新的数据库服务器。但问题是对于一个大型的商业网站,要进行数据库操作的并不只有一两个页面。访问数据库的代码会散落各个页面中,就像夜空中的星星一样。这样的维护,难度可想而知。有一个比较好的解决办法,那就是将访问数据库的代码全部都放在一个cs文件里,这样数据库服务器一旦变换,那么只需要集中修改一个cs文件就可以了。
namespace Bincess // ListBoard.aspx.cs 文件
{
public class ListBoard
{
private void BoardDataBind()
{
OleDbConnection dbConn=new OleDbConnection();
OleDbCommand dbCmd=new OleDbCommand();
...
}
}
注意,这两个文件都进行了数据库操作。那么在数据库服务器改换时,两个文件就都必须修改并重新编译… |
namespace Bincess // ListTopic.aspx.cs 文件
{
public class ListTopic
{
private void TopicDataBind()
{
OleDbConnection dbConn=new OleDbConnection();
OleDbCommand dbCmd=new OleDbCommand();
...
}
}
}
将原来的访问数据库的代码全部都放在DBTask.cs程序文件中,这样只要修改这一个文件就可以适应新的数据库
namespace Bincess // DBTask.cs
{
public class DBTask
{
public void BoardDataBind()
{
OleDbConnection dbConn=new OleDbConnection();
OleDbCommand dbCmd=new OleDbCommand();
定义一个DBTask类,让它来完成所有的数据库操作。那么当数据库服务器改换时,只要集中修改这一个文件并重新编译即可… |
}
public void TopicDataBind()
{
OleDbConnection dbConn=new OleDbConnection();
OleDbCommand dbCmd=new OleDbCommand();
...
}
}
}
namespace Bincess // ListBoard.aspx.cs 文件
{
public class ListBoard
{
private void BoardDataBind()
{
(new DBTask()).BoardDataBind();
}
}
}
namespace Bincess // ListTopic.aspx.cs 文件
{
public class ListTopic
{
private void TopicDataBind()
{
(new DBTask()).TopicDataBind();
}
}
}
当然这是一个简单的“门面模式”的应用,恐怕也是“三层结构”的最原始模型…
如果数据库访问代码太多,令DBTask.cs文件过大的话,可以将函数功能分组,存储到其它文件里。
怎样才算是一个符合“三层结构”的Web应用程序?
在一个ASP.NET Web应用程序解决方案中,并不是说有aspx文件、有dll文件、还有数据库,就是“三层结构”的Web应用程序,这样的说法是不对的。也并不是说没有对数据库进行操作,即没有“数据库层”,就不是“三层结构”的。其实三层结构是功能实现上的三层:
¨ 外观层,用于显示,并为用户提供交互式操作的可能…
¨ 中间层,服务于外观层并调用数据库层的函数。
¨ 数据库层,实现数据库的存储和读出。存储目标不一定是数据库服务器,也可以是文本文档或XML文档。
在微软的ASP.NET示范实例Duwamish7中,外观层被放置在Web项目中,中间层是放置在BusinessFacade项目中,而数据库层则是放置在DataAccess项目中。而微软的另一个ASP.NET示范实例PetShop中,外观层被放置在Web项目中,中间层是放置在BLL项目中,而数据库层则是放置在SQLServerDAL和OracleDAL两个项目中。在我的彬月论坛中,外观层是被放置在Web项目中,中间层是被放置在InterService项目中,而数据库层是被放置在AccessTask项目中。
显然PetShop要比Duwamish7复杂的多!如果先不讨论这些,那么现在的问题就是:既然三层结构已经被分派到各自的项目中,那么剩下来的项目是做什么的呢?例如PetShop中的Model、IDAL、DALFactory这三个项目,再例如Duwamish7中的Common项目,还有就是在我的论坛中的Classes、DBTask、DALFactory三个项目。它们是做什么用的呢?我想下面的文字会慢慢让你明白的。
从Nokia的手机生产线说起
一个“三层结构”的Web应用程序,就象是Nokia公司的手机生产线。
¨ Web层就像是公司的经理,他负责洞察市场趋势,决策产品的生产。并根据市场筹策下一步计划。
¨ InterService就像是公司的管理员,他主要负责管理下层员工,传达上级布置的生产任务给员工,并将生产结果反馈给上级Web。
¨ AccessTask就是公司里的工人,他们主要是负责手机产品的生产装配工作,并将生产结果反馈给上级InterService。他们并不需要知道产品将销往何处,也不用关心产品销量。只要能完成任务,就可以拿到报酬。
命令方向是自上而下的,而结果反馈方向则是自下而上的。
根据这个图例来简要的描述彬月论坛中的留言板显示功能,那么代码应该是:
<!--// 首先是ListLeaveWord.aspx这个文件 //-->
<Asp:Repeater id=″leaveWordRepeater″ Runat=″SERVER″>
<ItemTemplate>
<%# DataItem.Eval(Container.DataItem, ″Content″) %>
</ItemTemplate>
</Asp:Repeater>
// 其后台文件是由位于Web层的ListLeaveWord.aspx.cs 文件发出读取留言板信息的任务
// 任务命令交由LeaveWordService类对象的List方法去完成。
using System;
using System.Data;
using Bincess.InterService;
namespace Web
{
public class ListLeaveWord : System.Web.UI.Page
{
// 将留言视图绑定到该重复器上
protected System.Web.UI.WebControls.Repeater leaveWordRepeater;
// 其他代码...
//--------------------------------------------------------------------
// 绑定留言视图
//--------------------------------------------------------------------
private void LeaveWordDataBind()
{
DataSet ds=new DataSet();
(new LeaveWordService()).List(ds);
leaveWordRepeater.DataSource=ds.Tables[″LeaveWord″].DefaultView;
leaveWordRepeater.DataBind();
}
}
// 位于中间层的 LeaveWordService.cs 调用下一层数据库层的方法来填充数据集
//
using System;
using System.Data;
using Bincess.AccessTask;
namespace InterService
{
public class LeaveWordService
{
public void List(DataSet ds)
{
(new LeaveWordDBTask()).List(ds)
}
}
}
// 位于数据库层的 LeaveWordDBTask.cs, 他真正完成将数据读出并将其填充到数据集中
//
using System;
using System.Data;
using System.Data.OleDb;
namespace AccessTask
{
public class LeaveWordDBTask
{
public void List(DataSet ds)
{
string cmdText=″SELECT * FROM [LeaveWord]″;
OleDbConnection dbConn=new OleDbConnection(″...″);
OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);
dbAdp.Fill(ds, ″LeaveWord″);
}
}
}
这样便完成了对留言板的访问和显示,箭头所指的方向就是命令的方向。
虽然这符合“三层结构”开发模式的思想,但是这却存在着重大的漏洞,或者说是重大缺陷!为什么会这么说呢?因为从中间层返回的结果是不安全的!而造成中间层返回结果不安全的原因是从数据库层返回的结果并不确切!这会造成外观层过于脆弱,这并不是一个“强”三层结构。
还是用代码来说明。假如,LeaveWordDBTask.cs文件中的List方法实现是这样的:
// 位于数据库层的 LeaveWordDBTask.cs 文件
namespace AccessTask
{
public class LeaveWordDBTask
{
public void List(DataSet ds)
{
string cmdText=″SELECT * FROM [RegUser]″; // 注意这里,访问的不是LeaveWord数据表
OleDbConnection dbConn=new OleDbConnection(″...″);
OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);
dbAdp.Fill(ds, ″LeaveWord″); // 但是也填充了DataSet
}
}
}
那么回逆到文件LeaveWordService.cs的List函数,再回逆到ListLeaveWord.aspx.cs文件的LeaveWordDataBind函数。把数据绑定到重复器上,而在显示的时候,会提示:找不到Content字段!
<Asp:Repeater id=″leaveWordRepeater″ Runat=″SERVER″>
<ItemTemplate>
<%# DataItem.Eval(Container.DataItem, ″Content″) %> <-- 在这里会出现错误提示
</ItemTemplate>
</Asp:Repeater>
出现这样的结果并不奇怪,因为数据库层访问的是RegUser数据表,而RegUser数据表中并没有定义Content字段。外观层因此变得很脆弱,这也使得页面设计师和数据库编程人员产生了不应有的交涉。仅仅为了达到程序可运行目的,数据库编程人员就必须小心翼翼的写每句代码。这就像是NoKia公司的经理发布生产命令后,得到的返回结果却是生产线上的员工生产装配了好几台电视?!这当然不是经理们想要的结果。但为什么会有这样结果呢?因为经理们在发布生产命令时,忘记说明产品的规格和特征了。
经理一声令下:“生产!”—— (new LeaveWordService()).List(DataSet ds)
但是却没有对产品的规格特征作详细说明?例如手机的型号、外观等等。
这里的ds就相当于所要生产的产品集合,但却没有作细部说明…
那么怎样才能避免这样荒唐的结果出现呢?经理在发布生产命令之前,应该规定产品的规格特征!
经理一声令下:“生产3310型号的手机产品!”—— (new LeaveWordService()).List(LeaveWordDataSet ds)
这里的ds就相当于所要生产的产品集合,而且它有详细的规格说明!
// LeaveWordDataSet.cs 文件
//
using System;
using System.Data;
namespace Common
{
public class LeaveWordDataSet : DataSet
{
public LeaveWordDataSet() : base()
{
DataTable table=new DataTable(″LeaveWord″);
table.Columns.Add(″Content″, typeof(System.String));
this.Tables.Add(table);
}
}
}
那么原来的示意图应该也发生一些变化:
相应的代码也要变化:
<!--// 首先是ListLeaveWord.aspx这个文件 //-->
<Asp:Repeater id=″leaveWordRepeater″ Runat=″SERVER″>
<ItemTemplate>
<%# DataItem.Eval(Container.DataItem, ″Content″) %>
</ItemTemplate>
</Asp:Repeater>
// 其后台文件是由位于Web层的ListLeaveWord.aspx.cs 文件发出读取留言板信息的任务
// 任务命令交由LeaveWordService类对象的List方法去完成。
using System;
using System.Data;
using Bincess.InterService;
using Bincess.Common; // 在彬月论坛中所使用的是 Bincess.Classes 名称空间
namespace Web
{
public class ListLeaveWord : System.Web.UI.Page
{
// 将留言视图绑定到该重复器上
protected System.Web.UI.WebControls.Repeater leaveWordRepeater;
//--------------------------------------------------------------------
// 绑定留言视图,为了简明扼要省略了其他代码 ...
//--------------------------------------------------------------------
private void LeaveWordDataBind()
{
LeaveWordDataSet ds=new LeaveWordDataSet();
(new LeaveWordService()).List(ds);
leaveWordRepeater.DataSource=ds.Tables[″LeaveWord″].DefaultView;
leaveWordRepeater.DataBind();
}
}
// 位于中间层的 LeaveWordService.cs 调用下一层数据库层的方法来填充数据集
//
using System;
using System.Data;
using Bincess.AccessTask;
using Bincess.Common; // 在彬月论坛中所使用的是 Bincess.Classes 名称空间
namespace InterService
{
public class LeaveWordService
{
public void List(LeaveWordDataSet ds)
{
(new LeaveWordDBTask()).List(ds)
}
}
}
// 位于数据库层的 LeaveWordDBTask.cs, 他真正完成将数据读出并将其填充到数据集中
//
using System;
using System.Data;
using System.Data.OleDb;
using Bincess.Common; // 在彬月论坛中所使用的是 Bincess.Classes 名称空间
namespace AccessTask
{
public class LeaveWordDBTask
{
public void List(LeaveWordDataSet ds)
{
string cmdText=″SELECT * FROM [LeaveWord]″;
OleDbConnection dbConn=new OleDbConnection(″...″);
OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);
dbAdp.Fill(ds, ″LeaveWord″);
}
}
}
这样,即便是将LeaveWordTask.List方法修改成访问RegUser数据表的代码,也依然不会影响到外观层。再执行期间系统会抛出异常,而这个异常信息肯定是再数据库层。再有,因为位于外观层的重复器控件绑定的是LeaveWordDataSet类对象,而LeaveWordDataSet类中就一定有Content字段的定义。当然,这同时也达到了规范数据库层返回结果的目的。这便是为什么在Duwamish7中会出现Common项目的原因。不知道你现在看明白了么?而Bincess的做法是和PetShop一样,是通过类定义来达到同样的目的!PetShop是通过Modal项目,而Bincess是通过Classes项目。为了举例和易于理解,我在上面的例子中使用了Bincess.Common名称空间,但实际的Bincess论坛,却不是这样实现的。你可以到Classes目录中去查看代码。
// LeaveWordTask.cs 文件
//
using ...
namespace Bincess.AccessTask
{
/// <summary>
/// LeaveWordTask 留言板数据库任务实现类
/// </summary>
public class LeaveWordTask : ILeaveWordTask
{
类 LeaveWordTask 默认构造器
/// <summary>
/// 列表留言板内容
/// </summary>
/// <returns>留言板对象数组</returns>
public LeaveWord[] List()
{
OleDbConnection dbConn=new OleDbConnection(″...″);
OleDbCommand dbCmd=new OleDbCommand(cmdText, dbConn);
// 其他代码 ...
}
}
}
// LeaveWord.cs 留言板类定义
//
using System;
namespace Bincess.Classes.Message
{
/// <summary>
/// LeaveWord 浏览板类
/// </summary>
public class LeaveWord : BaseMsg...
}
而用于显示留言板的控件,你可以参见 PageCtrl/MessageView/LeaveWordView.ascx文件
再谈手机的装配方法
在上一步中,经理说明了产品型号和规格。InterService层的LeaveWordService调用了AccessTask层中的LeaveWordTask类List函数来获取留言数据。在Bincess论坛中使用Classes层LeaveWord类来规范化Web层与AccessTask层的返回结果。那么在InterService和AccessTask之间有没有需要规范的呢?先别那么多的想法,先假设一个很现实的例子。假如,Bincess论坛的数据库要从Access2000升迁到MS SqlServer 2000,那么只要集中修改AccessTask项目中的所有文件以时应新的数据库服务器即可。不过,可不可以让论坛同时支持Access又支持MS SqlServer 2000?通过一个开关,当开关指向Access一端时,Bincess就可以运行在Access数据库平台上,而如果开关指向MS SqlServer 2000时,Bincess就运行在MS SqlServer数据库服务器上?这个办法很好!但是怎么实现呢?
先在解决方案中新建一个项目就叫作:SqlServerTask。然后还有建立一个项目DALFactory充当开关。这个开关项目中仅有一个DBTaskDriver.cs文件,就用它来控制Bincess到底运行载那个数据库平台上。
// 新建的SqlServerTask项目,并添加一个LeaveWordTask.cs文件
//
using System;
using System.Collections;
using System.Data.SqlClient; // 注意这里使用的是SqlClient,因为要对MS SqlServer数据库进行操作
using Bincess.Classes.Message;
using Bincess.Classes.User;
namespace Bincess.SqlServerTask
{
/// <summary>
/// LeaveWordTask 留言板数据库任务实现类
/// </summary>
public class LeaveWordTask
{
/// <summary>
/// 列表留言板内容
/// </summary>
/// <returns>留言板对象数组</returns>
public LeaveWord[] List()
{
SqlConnection dbConn=new SqlConnection("...");
SqlCommand dbCmd=new OleDbCommand("SELECT * FROM [LeaveWord]", dbConn);
// 其他代码 ...
}
}
}
// DALFactory.cs 数据库层工厂类
//
using System;
using System.Configuration;
using System.Reflection;
namespace Bincess.DALFactory
{
/// <summary>
/// DBTaskDriver 数据库任务驱动程序
/// </summary>
public class DBTaskDriver
{
//------------------------------------------------------------
// 获取程序集名称
//------------------------------------------------------------
private Assembly GetAssembly()
{
// 如果将字符串修改为“Bincess.SqlServerTask”,
// 那么指向的就是SqlServerTask项目,也就是要工作在MS SqlServer数据库服务器上了
return Assembly.Load("Bincess.AccessTask");
// 当前指向的是AccessTask项目
// 也就是说当前是工作在Access数据库平台上
}
/// <summary>
/// 获取留言板数据库任务对象
/// </summary>
/// <returns></returns>
public ILeaveWordTask DriveLeaveWordTask()
{
// 如果将字符串修改为“Bincess.SqlServerTask.LeaveWordTask”,
// 那么返回的就是SqlServerTask项目中的LeaveWordTask类对象
return
(LeaveWordTask)this.GetAssembly().CreateInstance("Bincess.AccessTask.LeaveWordTask");
// 当前返回的是AccessTask项目中的LeaveWordTask类对象
}
}
}
值得一提的是,SqlServerTask项目是位于数据库层的,那么就必须和AccessTask项目一样,与Web曾保持同样的协议Classes!
在InterService项目中的LeaveWordService也要改些代码:
// LeaveWordService.cs留言板任务服务类
//
using System;
using Bincess.Classes.Message;
using Bincess.DALFactory;
namespace Bincess.InterService
{
/// <summary>
/// LeaveWordService 留言板服务类
/// </summary>
public class LeaveWordService
{
/// <summary>
/// 列表显示留言板信息
/// </summary>
/// <returns>留言板对象数组</returns>
public LeaveWord[] List()
{
return (new DBTaskDriver()).DriveLeaveWordTask().List();
}
}
}
外观层,也就是Web项目,并不需要作任何修改…
到现在为止,我想你应该看到分层结构的巨大益处了吧?不过,就是在上面的程序中,也存在一个漏洞或者说是缺陷。因为:(new DBTaskDriver()).DriveLeaveWordTask() 对象中不一定有会有List成员函数。为什么呢?假如SqlServerTask项目中的LeaveWordTask.cs文件,是这样的程序代码:
// 新建的SqlServerTask项目,并添加一个LeaveWordTask.cs文件
//
using System;
using System.Collections;
using System.Data.SqlClient; // 注意这里使用的是SqlClient,因为要对MS SqlServer数据库进行操作
using ...
namespace Bincess.SqlServerTask
{
/* ... */
public class LeaveWordTask
{
/* ... */
public LeaveWord[] GetAll() // 注意先前使用的是 List 函数名
{
SqlConnection dbConn=new SqlConnection("...");
SqlCommand dbCmd=new OleDbCommand("SELECT * FROM [LeaveWord]", dbConn);
// 其他代码 ...
}
}
}
那么通过DALFactory切换到SqlServer数据库时,会出现执行期错误:LeaveWordTask没有List方法。
中间层因此变得很脆弱,这又使得中间层设计师和数据库编程人员产生了不应有的交涉。仅仅为了达到程序可运行目的,数据库编程人员就又必须小心翼翼的写对每个函数名。这就像是NoKia公司的管理员在传达上级的生产命令后,看到员工在装配线上,都是按照逆顺序来装配手机的?!即先将手机的面板和底壳扣紧,之后再装线路板和按键。这当然是不可能装进去的!因为正确的步骤是:先将线路板装在底壳上,然后再装上按键,最后扣好面板…但为什么会有这样结果呢?因为管理人原在传达生产命令后,忘记对手机的装配步骤作出说明!所以员工喜欢怎样装都成…
要使员工能够训练有素地,按照要求来装配产品,那么就必须对他们进行培训。那么用程序语言来描述这种现实活动是什么样的呢?或者说怎么描述呢?答案是使用接口!
// 新建一个DBTask项目,并添加一个ILeaveWordTask.cs 接口类文件
//
using System;
using Bincess.Classes.Message;
namespace Bincess.DBTask
{
/// <summary>
/// ILeaveWordTask 留言板数据库任务接口类
/// </summary>
public interface ILeaveWordTask
{
LeaveWord[] List();
}
}
新建一个项目DBTask,然后添加ILeaveWordTask.cs接口类文件。请注意接下来的发生的细微变化!
// SqlServerTask项目中的LeaveWordTask.cs文件
//
using ...
using Bincess.Classes.Message;
using Bincess.Classes.User;
using Bincess.DBTask;
namespace Bincess.SqlServerTask
{
/* ... */
public class LeaveWordTask : ILeaveWordTask // 注意,该类实现了ILeaveWordTask接口
{
/* ... */
public LeaveWord[] List()...
}
}
// AccessTask项目中的LeaveWordTask.cs文件
//
using ...
using Bincess.Classes.Message;
using Bincess.Classes.User;
using Bincess.DBTask;
namespace Bincess.AccessTask
{
/* ... */
public class LeaveWordTask : ILeaveWordTask // 注意,该类实现了ILeaveWordTask接口
{
/* ... */
public LeaveWord[] List()...
}
}
// DALFactory.cs 数据库层工厂类
//
using ...
namespace Bincess.DALFactory
{
/* ... */
public class DBTaskDriver
{
//------------------------------------------------------------
// 获取程序集名称
//------------------------------------------------------------
private Assembly GetAssembly()...
/* ... */
public ILeaveWordTask DriveLeaveWordTask()
{
// 如果将字符串修改为“Bincess.SqlServerTask.LeaveWordTask”,
// 那么返回的就是SqlServerTask项目中的LeaveWordTask类对象
return
(ILeaveWordTask)this.GetAssembly().CreateInstance("Bincess.AccessTask.LeaveWordTask");
// 当前返回的是AccessTask项目中的LeaveWordTask类对象
// 之前返回的是LeaveWordTask类型
}
}
}
只要想对数据库中的LeaveWord数据表进行操作,就必须实现DBTask中的ILeaveWordTask接口。而ILeaveWordTask接口中,必有List方法。所以在InterService项目中的LeaveWordService总能找到List方法!因为打DALFactory的DBTaskDriver类就限定了返回类型,必须是ILeaveWordTask的子类。这样可以净化InterService对AccessTask或SqlServerTask调用,也就是规范AccessTask和SqlServerTask提供给InterService的“接口”
在微软的ASP.NET示范实例PetShop中是通过DALFactory项目来控制应用程序运行的数据库平台,而IDAL项目的作用就是“培训”OracleDAL和SQLServerDAL,这个和Bincess的DBTask一个道理。
最后一张示意图,右图: