浅谈“三层结构”原理与用意(转帖)

浅谈“三层结构”原理与用意 

在刚刚步入“多层结构”Web应用程序开发的时候,我阅读过几篇关于“asp.net三层结构开发”的文章。但其多半都是对PetShop3.0Duwamish7的局部剖析或者是学习笔记。对“三层结构”通体分析的学术文章几乎没有。

2005211日,Bincess BBS彬月论坛开始试运行。不久之后,我写了一篇题目为《浅谈“三层结构”原理与用意》的文章。旧版文章以彬月论坛程序中的部分代码举例,通过全局视角阐述了什么是“三层结构”的开发模式?为什么要这样做?怎样做?……而在这篇文章的新作中,配合这篇文章我写了7个程序实例(TraceLWord1~TraceLWord7留言板)以帮助读者理解“三层结构”应用程序。这些程序示例可以在随带的CodePackage目录中找到——

  对于那些有丰富经验的Web应用程序开发人员,他们认为文章写的通俗易懂,很值得一读。可是对于asp.net初学者,特别是没有任何开发经验的人,文章阅读起来就感到非常困难,不知文章所云。甚至有些读者对“三层结构”的认识更模糊了……

  关于“多层结构”开发模式,存在这样一种争议:一部分学者认为“多层结构”与“面向对象的程序设计思想”有着非常紧密的联系。而另外一部分学者却认为二者之间并无直接联系。写作这篇文章并不是要终结这种争议,其行文目的是希望读者能够明白:在使用asp.net进行Web应用程序开发时,实现“多层结构”开发模式的方法、原理及用意。要顺利的阅读这篇文章,希望读者能对“面向对象的程序设计思想”有一定深度的认识,最好能懂一些“设计模式”的知识。如果你并不了解前面这些,那么这篇文章可能并不适合你现在阅读。不过,无论这篇文章面对的读者是谁,我都会尽量将文章写好。我希望这篇文章能成为学习“三层结构”设计思想的经典文章!

 

“三层结构”是什么?

  “三层结构”一词中的“三层”是指:“表现层”、“中间业务层”、“数据访问层”。其中:

n   表 现 层:位于最外层(最上层),离用户最近。用于显示数据和接收用户输入的数据,为用户提供一种交互式操作的界面。

n   中间业务层:负责处理用户输入的信息,或者是将这些信息发送给数据访问层进行保存,或者是调用数据访问层中的函数再次读出这些数据。中间业务层也可以包括一些对“商业逻辑”描述代码在里面。

n   数据访问层:仅实现对数据的保存和读取操作。数据访问,可以访问数据库系统、二进制文件、文本文档或是XML文档。

 

  对依赖方向的研究将是本文的重点,数值返回方向基本上是没有变化的。

为什么需要 “三层结构”?——通常的设计方式

  在一个大型的Web应用程序中,如果不分以层次,那么在将来的升级维护中会遇到很大的麻烦。但在这篇文章里我只想以一个简单的留言板程序为示例,说明通常设计方式的不足——

功能说明:

    ListLWord.aspx(后台程序文件 ListLWord.aspx.cs)列表显示数据库中的每条留言。

    PostLWord.aspx(后台程序文件 PostLWord.aspx.cs)发送留言到数据库。

更完整的示例代码,可以到CodePackage/TraceLWord1目录中找到。数据库中,仅含有一张数据表,其结构如下:

 

字段名称

数据类型

默认值

备注说明

[LWordID]

INT

NOT NULL IDENTITY(1, 1)

留言记录编号

[TextContent]

NText

N’’

留言内容

[PostTime]

DateTime

GetDate()

留言发送时间,默认值为当前时间

 

ListLWord.aspx 页面文件(列表显示留言)

 

#001 <%@ Page language="c#" Codebehind="ListLWord.aspx.cs" AutoEventWireup="false"

Inherits="TraceLWord1.ListLWord" %>

#002 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">

#003

#004 <html>

#005 <head>

#006 <title>ListLWord</title>

#007 <meta name="GENERATOR" Content="Microsoft Visual Studio .NET 7.1">

#008 <meta name="CODE_LANGUAGE" Content="C#">

#009 <meta name=vs_defaultClientScript content="JavaScript">

#010 <meta name=vs_targetSchema content="http://schemas.microsoft.com/intellisense/ie5">

#011 </head>

#012 <body MS_POSITIONING="GridLayout">

#013

#014 <form id="__aspNetForm" method="post" runat="server">

#015

#016 <a href="PostLWord.aspx">发送新留言</a>

#017

#018 <asp:DataList ID="m_lwordListCtrl" Runat="Server">

#019 <ItemTemplate>

#020    <div>

#021        <%# DataBinder.Eval(Container.DataItem, "PostTime") %>

#022        <%# DataBinder.Eval(Container.DataItem, "TextContent") %>

#023    </div>

#024 </ItemTemplate>

#025 </asp:DataList>

#026

#027 </form>

#028

#029 </body>

#030 </html>

 

以最普通的设计方式制作留言板,效率很高。

这些代码可以在Visual Studio.NET 2003开发环境的设计视图中快速建立。
ListLWord.aspx
后台程序文件 ListLWord.aspx.cs

 

#001 using System;

#002 using System.Collections;

#003 using System.ComponentModel;

#004 using System.Data;

#005 using System.Data.OleDb;   // 需要操作 Access 数据库

#006 using System.Drawing;

#007 using System.Web;

#008 using System.Web.SessionState;

#009 using System.Web.UI;

#010 using System.Web.UI.WebControls;

#011 using System.Web.UI.HtmlControls;

#012

#013 namespace TraceLWord1

#014 {

#015    /// <summary>

#016    /// ListLWord 列表留言板信息

#017    /// </summary>

#018    public class ListLWord : System.Web.UI.Page

#019    {

#020        // 留言列表控件

#021        protected System.Web.UI.WebControls.DataList m_lwordListCtrl;

#022

#023        /// <summary>

#024        /// ListLWord.aspx 页面加载函数

#025        /// </summary>

#026        private void Page_Load(object sender, System.EventArgs e)

#027        {

#028            LWord_DataBind();

#029        }

#030

#031        #region Web 窗体设计器生成的代码

#032        override protected void OnInit(EventArgs e)

#033        {

#034            InitializeComponent();

#035            base.OnInit(e);

#036        }

#037

#038        private void InitializeComponent()

#039        {   

#040            this.Load+=new System.EventHandler(this.Page_Load);

#041        }

#042        #endregion

#043

 

#044        /// <summary>

#045        /// 绑定留言信息列表

#046        /// </summary>

#047        private void LWord_DataBind()

#048        {

#049            string mdbConn=@"PROVIDER=Microsoft.Jet.OLEDB.4.0;

DATA Source=C:"DbFs"TraceLWordDb.mdb";

#050            string cmdText=@"SELECT * FROM [LWord] ORDER BY [LWordID] DESC";

#051

#052            OleDbConnection dbConn=new OleDbConnection(mdbConn);

#053            OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);

#054

#055            DataSet ds=new DataSet();

#056            dbAdp.Fill(ds, @"LWordTable");

#057

#058            m_lwordListCtrl.DataSource=ds.Tables[@"LWordTable"].DefaultView;

#059            m_lwordListCtrl.DataBind();

#060        }

#061    }

#062 }

 

PostLWord.aspx页面文件(发送留言到数据库)

 

#001 <%@ Page language="c#" Codebehind="PostLWord.aspx.cs" AutoEventWireup="false"

Inherits="TraceLWord1.PostLWord" %>

#002 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">

#003

#004 <html>

#005 <head>

#006 <title>PostLWord</title>

#007 <meta name="GENERATOR" Content="Microsoft Visual Studio .NET 7.1">

#008 <meta name="CODE_LANGUAGE" Content="C#">

#009 <meta name=vs_defaultClientScript content="JavaScript">

#010 <meta name=vs_targetSchema content="http://schemas.microsoft.com/intellisense/ie5">

#011 </head>

#012 <body MS_POSITIONING="GridLayout">

#013

#014 <form id="__aspNetForm" method="post" runat="server">

#015

#016 <textarea id="m_txtContent" runat="Server" rows=8 cols=48></textarea>

#017 <input type="Button" id="m_btnPost" runat="Server" value="发送留言" />

#018

#019 </form>

#020

#021 </body>

#022 </html>


PostLWord.aspx后台程序文件PostLWord.aspx.cs

 

#001 using System;

#002 using System.Collections;

#003 using System.ComponentModel;

#004 using System.Data;

#005 using System.Data.OleDb;   // 需要操作 Access 数据库

#006 using System.Drawing;

#007 using System.Web;

#008 using System.Web.SessionState;

#009 using System.Web.UI;

#010 using System.Web.UI.WebControls;

#011 using System.Web.UI.HtmlControls;

#012

#013 namespace TraceLWord1

#014 {

#015    /// <summary>

#016    /// PostLWord 发送留言到数据库

#017    /// </summary>

#018    public class PostLWord : System.Web.UI.Page

#019    {

#020        // 留言内容编辑框

#021        protected System.Web.UI.HtmlControls.HtmlTextArea m_txtContent;

#022        // 提交按钮

#023        protected System.Web.UI.HtmlControls.HtmlInputButton m_btnPost;

#024

#025        /// <summary>

#026        /// PostLWord.aspx 页面加载函数

#027        /// </summary>

#028        private void Page_Load(object sender, System.EventArgs e)

#029        {

#030        }

#031

#032        #region Web 窗体设计器生成的代码

#033        override protected void OnInit(EventArgs e)

#034        {

#035            InitializeComponent();

#036            base.OnInit(e);

#037        }

#038

#039        private void InitializeComponent()

#040        {   

#041            this.Load+=new System.EventHandler(this.Page_Load);

#042            this.m_btnPost.ServerClick+=new EventHandler(Post_ServerClick);

#043        }

#044        #endregion

#046        /// <summary>

#047        /// 发送留言信息到数据库

#048        /// </summary>

#049        private void Post_ServerClick(object sender, EventArgs e)

#050        {

#051            // 获取留言内容

#052            string textContent=this.m_txtContent.Value;

#053

#054            // 留言内容不能为空

#055            if(textContent=="")

#056                throw new Exception("留言内容为空");

#057

#058            string mdbConn=@"PROVIDER=Microsoft.Jet.OLEDB.4.0; DATA Source=C:"DbFs"TraceLWordDb.mdb";

#059            string cmdText="INSERT INTO [LWord]([TextContent]) VALUES(@TextContent)";

#060

#061            OleDbConnection dbConn=new OleDbConnection(mdbConn);

#062            OleDbCommand dbCmd=new OleDbCommand(cmdText, dbConn);

#063

#064            // 设置留言内容

#065            dbCmd.Parameters.Add(new OleDbParameter("@TextContent",

OleDbType.LongVarWChar));

#066            dbCmd.Parameters["@TextContent"].Value=textContent;

#067

#068            try

#069            {

#070                dbConn.Open();

#071                dbCmd.ExecuteNonQuery();

#072            }

#073            catch

#074            {

#075                throw;

#076            }

#077            finally

#078            {

#079                dbConn.Close();

#080            }

#081

#082            // 跳转到留言显示页面

#083            Response.Redirect("ListLWord.aspx", true);

#084        }

#085    }

#086 }

 

仅仅通过两个页面,就完成了一个基于Access数据库的留言功能。

程序并不算复杂,非常简单清楚。但是随后你会意识到其存在着不灵活性!


为什么需要“三层结构”?——数据库升迁、应用程序变化所带来的问题

留言板正式投入使用!但没过多久,我准备把这个留言板程序的数据库升迁到Microsoft SQL Server 2000服务器上去!除了要把数据导入到SQL Server 2000中,还得修改相应的.aspx.cs程序文件。也就是说需要把调用OleDbConnection的地方修改成SqlConnection,还要把调用OleDbAdapter的地方,修改成SqlAdapter。虽然这并不是一件很困难的事情,因为整个站点非常小,仅仅只有两个程序文件,所以修改起来并不费劲。但是,如果对于一个大型的商业网站,访问数据库的页面有很多很多,如果以此方法一个页面一个页面地进行修改,那么费时又费力!只是修改了一下数据库,却可能要修改上千张网页。一动百动,这也许就是程序的一种不灵活性……

再假如,我想给留言板加一个限制:

n   每天上午09时之后到11时之前可以留言,下午则是13时之后到17时之前可以留言

n   如果当天留言个数小于 40,则可以继续留言

那么就需要把相应的代码,添加到PostLWord.aspx.cs程序文件中。但是过了一段时间,我又希望去除这个限制,那么还要修改PostLWord.aspx.cs文件。但是,对于一个大型的商业网站,类似于这样的限制,或者称为“商业规则”,复杂又繁琐。而且这些规则很容易随着商家的意志为转移。如果这些规则限制被分散到各个页面中,那么规则一旦变化,就要修改很多的页面!只是修改了一下规则限制,却又可能要修改上千张网页。一动百动,这也许又是程序的一种不灵活性……

  最后,留言板使用过一段时间之后,出于某种目的,我希望把它修改成可以在本地运行的Windows程序,而放弃原来的Web型式。那么对于这个留言板,可以说是“灭顶之灾”。所有代码都要重新写……当然这个例子比较极端,在现实中,这样的情况还是很少会发生的——

 

为什么需要“三层结构”?——初探,就从数据库的升迁开始

一个站点中,访问数据库的程序代码散落在各个页面中,就像夜空中的星星一样繁多。这样一动百动的维护,难度可想而知。最难以忍受的是,对这种维护工作的投入,是没有任何价值的……

有一个比较好的解决办法,那就是将访问数据库的代码全部都放在一个程序文件里。这样,数据库平台一旦发生变化,那么只需要集中修改这一个文件就可以了。我想有点开发经验的人,都会想到这一步的。这种“以不变应万变”的做法其实是简单的“门面模式”的应用。如果把一个网站比喻成一家大饭店,那么“门面模式”中的“门面”,就像是饭店的服务生,而一个网站的浏览者,就像是一个来宾。来宾只需要发送命令给服务生,然后服务生就会按照命令办事。至于服务生经历了多少辛苦才把事情办成?那个并不是来宾感兴趣的事情,来宾们只要求服务生尽快把自己交待事情办完。我们就把ListLWord.aspx.cs程序就看成是一个来宾发出的命令,而把新加入的LWordTask.cs程序看成是一个饭店服务生,那么来宾发出的命令就是:

“给我读出留言板数据库中的数据,填充到DataSet数据集中并显示出来!”

而服务生接到命令后,就会依照执行。而PostLWord.aspx.cs程序,让服务生做的是:

“把我的留言内容写入到数据库中!”

而服务生接到命令后,就会依照执行。这就是TraceLWord2!可以在CodePackage/TraceLWord2目录中找到——

 

把所有的有关数据访问的代码都放到LWordTask.cs文件里,LWordTask.cs程序文件如下:

 

#001 using System;

#002 using System.Data;

#003 using System.Data.OleDb;   // 需要操作 Access 数据库

#004 using System.Web;

#005

#006 namespace TraceLWord2

#007 {

#008    /// <summary>

#009    /// LWordTask 数据库任务类

#010    /// </summary>

#011    public class LWordTask

#012    {

#013        // 数据库连接字符串

#014        private const string DB_CONN=@"PROVIDER=Microsoft.Jet.OLEDB.4.0;

DATA Source=C:"DbFs"TraceLWordDb.mdb";

#015

#016        /// <summary>

#017        /// 读取数据库表 LWord,并填充 DataSet 数据集

#018        /// </summary>

#019        /// <param name="ds">填充目标数据集</param>

#020        /// <param name="tableName">表名称</param>

#021        /// <returns>记录行数</returns>

#022        public int ListLWord(DataSet ds, string tableName)

#023        {

#024            string cmdText="SELECT * FROM [LWord] ORDER BY [LWordID] DESC";

#025

#026            OleDbConnection dbConn=new OleDbConnection(DB_CONN);

#027            OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);

#028

#029            int count=dbAdp.Fill(ds, tableName);

#030

#031            return count;

#032        }

#033

#034        /// <summary>

#035        /// 发送留言信息到数据库

#036        /// </summary>

#037        /// <param name="textContent">留言内容</param>

#038        public void PostLWord(string textContent)

#039        {

#040            // 留言内容不能为空

#041            if(textContent==null || textContent=="")

#042                throw new Exception("留言内容为空");

#043

#044            string cmdText="INSERT INTO [LWord]([TextContent]) VALUES(@TextContent)";

#045

#046            OleDbConnection dbConn=new OleDbConnection(DB_CONN);

#047            OleDbCommand dbCmd=new OleDbCommand(cmdText, dbConn);

#048

#049            // 设置留言内容

#050            dbCmd.Parameters.Add(new OleDbParameter("@TextContent", OleDbType.LongVarWChar));

#051            dbCmd.Parameters["@TextContent"].Value=textContent;

#052

#053            try

#054            {

#055                dbConn.Open();

#056                dbCmd.ExecuteNonQuery();

#057            }

#058            catch

#059            {

#060                throw;

#061            }

#062            finally

#063            {

#064                dbConn.Close();

#065            }

#066        }

#067    }

#068 }

 

如果将数据库从Access 2000修改为SQL Server 2000,那么只需要修改LWordTask.cs这一个文件。如果LWordTask.cs文件太大,也可以把它切割成几个文件或“类”。如果被切割成的“类”还是很多,也可以把这些访问数据库的类放到一个新建的“项目”里。当然,原来的ListLWord.aspx.cs文件应该作以修改,LWord_DataBind函数被修改成:

 

...

#046        private void LWord_DataBind()

#047        {

#048            DataSet ds=new DataSet();

#049            (new LWordTask()).ListLWord(ds, @"LWordTable");

#050

#051            m_lwordListCtrl.DataSource=ds.Tables[@"LWordTable"].DefaultView;

#052            m_lwordListCtrl.DataBind();

#053        }

...

 

原来的PostLWord.aspx.cs文件也应作以修改,Post_ServerClick函数被修改成:

 

...

#048        private void Post_ServerClick(object sender, EventArgs e)

#049        {

#050            // 获取留言内容

#051            string textContent=this.m_txtContent.Value;

#052

#053            (new LWordTask()).PostLWord(textContent);

#054

#055            // 跳转到留言显示页面

#056            Response.Redirect("ListLWord.aspx", true);

#057        }

...

 

  从前面的程序段中可以看出,ListLWord.aspx.csPostLWord.aspx.cs这两个文件已经找不到和数据库相关的代码了。只看到一些和LWordTask类有关系的代码,这就符合了“设计模式”中的一种重要原则:“迪米特法则”。“迪米特法则”主要是说:让一个“类”与尽量少的其它的类发生关系。TraceLWord1中,ListLWord.aspx.cs这个类和OleDbConnectionOleDbDataAdapter都发生了关系,所以它破坏了“迪米特法则”。利用一个“中间人”是“迪米特法则”解决问题的办法,这也是“门面模式”必须遵循的原则。下面就引出这个LWordTask门面类的示意图:

 

 

ListLWord.aspx.csPostLWord.aspx.cs两个文件对数据库的访问,全部委托LWordTask类这个“中间人”来办理。利用“门面模式”,将页面类和数据库类进行隔离。这样就作到了页面类不依赖于数据库的效果。以一段比较简单的代码来描述这三个程序的关系:

 

public class ListLWord

{

private void LWord_DataBind()

{

    (new LWordTask()).ListLWord( ... );

    }

}

 

public class PostLWord

{

    private void Post_ServerClick(object sender, EventArgs e)

    {

        (new LWordTask()).PostLWord( ... );

    }

}

 

public class LWordTask

{

    public DataSet ListLWord(DataSet ds)...

 

    public void PostLWord(string textContent)...

}

 


应用中间业务层,实现“三层结构”

前面这种分离数据访问代码的形式,可以说是一种“三层结构”的简化形式。因为它没有“中间业务层”也可以称呼它为“二层结构”。一个真正的“三层”程序,是要有“中间业务层”的,而它的作用是连接“外观层”和“数据访问层”。换句话说:“外观层”的任务先委托给“中间业务层”来办理,然后“中间业务层”再去委托“数据访问层”来办理……

那么为什么要应用“中间业务层”呢?“中间业务层”的用途有很多,例如:验证用户输入数据、缓存从数据库中读取的数据等等……但是,“中间业务层”的实际目的是将“数据访问层”的最基础的存储逻辑组合起来,形成一种业务规则。例如:“在一个购物网站中有这样的一个规则:在该网站第一次购物的用户,系统为其自动注册”。这样的业务逻辑放在中间层最合适:

 

 

在“数据访问层”中,最好不要出现任何“业务逻辑”!也就是说,要保证“数据访问层”的中的函数功能的原子性!即最小性和不可再分。“数据访问层”只管负责存储或读取数据就可以了。

  在新TraceLWord3中,应用了“企业级模板项目”。把原来的LWordTask.cs,并放置到一个单一的项目里,项目名称为:AccessTask。解决方案中又新建了一个名称为:InterService的项目,该项目中包含一个LWordService.cs程序文件,它便是“中间业务层”程序。为了不重复命名,TraceLWord3的网站被放置到了WebUI项目中。更完整的代码,可以在CodePackage/TraceLWord3目录中找到——

 


 

 

这些类的关系,也可以表示为如下的示意图:

 


LWordService.cs程序源码:

 

#001 using System;

#002 using System.Data;

#003

#004 using TraceLWord3.AccessTask;     // 引用数据访问层

#005

#006 namespace TraceLWord3.InterService

#007 {

#008    /// <summary>

#009    /// LWordService 留言板服务类

#010    /// </summary>

#011    public class LWordService

#012    {

#013        /// <summary>

#014        /// 读取数据库表 LWord,并填充 DataSet 数据集

#015        /// </summary>

#016        /// <param name="ds">填充目标数据集</param>

#017        /// <param name="tableName">表名称</param>

#018        /// <returns>记录行数</returns>

#019        public int ListLWord(DataSet ds, string tableName)

#020        {

#021            return (new LWordTask()).ListLWord(ds, tableName);

#022        }

#023

#024        /// <summary>

#025        /// 发送留言信息到数据库

#026        /// </summary>

#027        /// <param name="textContent">留言内容</param>

#028        public void PostLWord(string content)

#029        {

#030            (new LWordTask()).PostLWord(content);

#031        }

#032    }

#033 }

 

LWordService.cs程序文件的行#021和行#030可以看出,“中间业务层”并没有实现什么业务逻辑,只是简单的调用了“数据访问层”的类方法……这样做是为了让读者更直观的看明白“三层结构”应用程序的调用顺序,看清楚它的全貌。加入了“中间业务层”,那么原来的ListLWord.aspx.cs文件应该作以修改:


 

...

#012 using TraceLWord3.InterService;        // 引用中间服务层

...

#045        /// <summary>

#046        /// 绑定留言信息列表

#047        /// </summary>

#048        private void LWord_DataBind()

#049        {

#050            DataSet ds=new DataSet();

#051            (new LWordService()).ListLWord(ds, @"LWordTable");

#052

#053            m_lwordListCtrl.DataSource=ds.Tables[@"LWordTable"].DefaultView;

#054            m_lwordListCtrl.DataBind();

#055        }

...

 

原来的PostLWord.aspx.cs文件也应作以修改:

 

...

#012 using TraceLWord3.InterService;        // 引用中间服务层

...

#047        /// <summary>

#048        /// 发送留言到数据库

#049        /// </summary>

#050        private void Post_ServerClick(object sender, EventArgs e)

#051        {

#052            // 获取留言内容

#053            string textContent=this.m_txtContent.Value;

#054

#055            (new LWordService()).PostLWord(textContent);

#056

#057            // 跳转到留言显示页面

#058            Response.Redirect("ListLWord.aspx", true);

#059        }

...

 

 


到目前为止,TraceLWord3程序已经是一个简单的“三层结构”的应用程序,以一段比较简单的代码来描述四个程序的关系:

 

namespace TraceLWord3.WebLWord

{

public class ListLWord

{

        private void LWord_DataBind()

        {

            (new LWordService()).ListLWord( ... );

        }

}

 

    public class PostLWord

    {

        private void Post_ServerClick(object sender, EventArgs e)

        {

            (new LWordService()).PostLWord( ... );

        }

    }

}

 

namespace TraceLWord3.InterService

{

    public class LWordTask

    {

        public DataSet ListLWord(DataSet ds, string tableName)

        {

            return (new LWordTask()).ListLWord(ds, tableName);

        }

 

        public void PostLWord(string content)

        {

            (new LWordTask()).PostLWord(content);

        }

    }

}

 

namespace TraceLWord3.AccessTask

{

public class LWordTask

{

        public DataSet ListLWord(DataSet ds)...

 

        public void PostLWord(string content)...

}

}


用户在访问TraceLWord3ListLWord.aspx页面时序图:

 

 

当一个用户访问TraceLWord5ListLWord.aspx页面的时候,会触发该页面后台程序中的Page_Load函数。而在该函数中调用了LWord_DataBind函数来获取留言板信息。由图中可以看到出,LWord_DataBind在被调用的期间,会建立一个新的LWordService类对象,并调用这个对象的ListLWord函数。在LWordService.ListLWord函数被调用的期间,会建立一个新的LWordTask类对象,并调用这个对象的ListLWord来获取留言板信息的。PostLWord.aspx页面时序图,和上面这个差不多。就是这样,经过一层又一层的调用,来获取返回结果或是保存数据。

 

注意:从时序图中可以看出,当子程序模块未执行结束时,主程序模块只能处于等待状态。这说明将应用程序划分层次,会带来其执行速度上的一些损失……


对“三层结构”的深入理解——怎样才算是一个符合“三层结构”的Web应用程序?

在一个ASP.NET Web应用程序解决方案中,并不是说有aspx文件、有dll文件、还有数据库,就是“三层结构”的Web应用程序,这样的说法是不对的。也并不是说没有对数据库进行操作,就不是“三层结构”的。其实“三层结构”是功能实现上的三层。例如,在微软的ASP.NET示范实例“Duwamish7中,“表现层”被放置在“Web”项目中,“中间业务层”是放置在“BusinessFacade”项目中,“数据访问层”则是放置在“DataAccess”项目中……而在微软的另一个ASP.NET示范实例“PetShop3.0中,“表现层”被放置在“Web”项目中,“中间业务层”是放置在“BLL”项目中,而“数据访问层”则是放置在“SQLServerDAL”和“OracleDAL”两个项目中。Bincess.CN彬月论坛中,“表现层”是被放置在“WebForum”项目中,“中间业务(服务)层”是被放置在“InterService”项目中,而“数据访问层”是被放置在“SqlServerTask”项目中。

  如果只以分层的设计角度看,Duwamish7要比PetShop3.0复杂一些!而如果较为全面的比较二者,PetShop3.0则显得比较复杂。但我们先不讨论这些,对PetShop3.0Duwamish7的研究,并不是本文的重点。现在的问题就是:既然“三层结构”已经被分派到各自的项目中,那么剩下来的项目是做什么的呢?例如PetShop3.0中的“Model”、“IDAL”、“DALFactory”这三个项目,再例如Duwamish7中的“Common”项目,还有就是在Bincess.CN彬月论坛中的“Classes”、“DbTask”、这两个项目。它们究竟是做什么用的呢?

 

对“三层结构”的深入理解——从一家小餐馆说起

  一个“三层结构”的Web应用程序,就好象是一家小餐馆。

n   表 现 层,所有的.aspx页面就好像是这家餐馆的菜谱。

n   中间业务层,就像是餐馆的服务生。

n   数据访问层,就像是餐馆的大厨师傅。

n   而我们这些网站浏览者,就是去餐馆吃饭的吃客了……

 

 

我们去一家餐馆吃饭,首先得看他们的菜谱,然后唤来服务生,告诉他我们想要吃的菜肴。服务生记下来以后,便会马上去通知大厨师傅要烹制这些菜。大厨师傅收到通知后,马上起火烧菜。过了不久,服务生便把一道一道香喷喷的、热气腾腾的美味端到我们的桌位上——

而我们访问一个基于asp.net技术的网站的时候,首先打开的是一个aspx页面。这个aspx页面的后台程序会去调用中间业务层的相应函数来获取结果。中间业务层又会去调用数据访问层的相应函数来获取结果。在一个用户访问TraceLWord3打开ListLWord.aspx页面查看留言的时候,其后台程序ListLWord.aspx.cs会去调用位于中间业务层LWordServiceListLWord(DataSet ds)函数。然后这个函数又会去调用位于数据访问层AccessTaskListLWord(DataSet ds)函数。最后把结果显示出来……


对比一下示意图:

 

 

从示意图看,这两个过程是否非常相似呢?

 

不同的地方只是在于,去餐馆吃饭,需要吃客自己唤来服务生。而访问一个asp.net网站,菜单可以代替吃客唤来服务生。在最后的返回结果上,把结果返回给aspx页面,也就是等于把结果返回给浏览者了。

 

高度的“面向对象思想”的体现——封装

在我们去餐馆吃饭的这个过程中,像我这样在餐馆中的吃客,最关心的是什么呢?当然是:餐馆的饭菜是不是好吃,是不是很卫生?价格是不是公道?……而餐馆中的服务生会关心什么呢?应该是:要随时注意响应每位顾客的吩咐,要记住顾客在哪个桌位上?还要把顾客点的菜记在本子上……餐馆的大厨师傅会关心什么呢?应该是:一道菜肴的做法是什么?怎么提高烧菜的效率?研究新菜式……大厨师傅,烧好菜肴之后,只管把菜交给服务生就完事了。至于服务生把菜送到哪个桌位上去了?是哪个顾客吃了他做的菜,大厨师傅才不管咧——服务生只要记得把我点的菜肴端来,就成了。至于这菜是怎么烹饪的?顾客干麻要点这道菜?他才不管咧——而我,只要知道这菜味道不错,价格公道,干净卫生,其他的我才不管咧——

这里面不正是高度的体现了“面向对象思想”的“封装”原则吗?

无论大厨师傅在什么时候研究出新的菜式,都不会耽误我现在吃饭。就算服务生忘记我的桌位号是多少了,也不可能因此让大厨师傅忘记菜肴的做法?在我去餐馆吃饭的这个过程中,我、餐馆服务生、大厨师傅,是封装程度极高的三个个体。当其中的一个个体内部发生变化的时候,并不会波及到其他个体。这便是面向对象封装特性的一个益处!

 

土豆炖牛肉盖饭与实体规范

  在我工作过的第一家公司楼下,有一家成都风味的小餐馆,每天中午我都和几个同事一起去那家小餐馆吃饭。公司附近只有这么一家餐馆,不过那里的饭菜还算不错。我最喜欢那里的“土豆炖牛肉盖饭”,也很喜欢那里的“鸡蛋汤”,那种美味至今难忘……所谓“盖饭”,又称是“盖浇饭”,就是把烹饪好的菜肴直接遮盖在铺在盘子里的米饭上。例如“土豆炖牛肉盖饭”,就是把一锅热气腾腾的“土豆炖牛肉”遮盖在米饭上——


当我和同事再次来到这家餐馆吃饭,让我们想象以下这样的情形:

 

情形一:

我对服务生道:给我一份好吃的!

服务生道:什么好吃的?

我答道:一份好吃的——

三番几次……

我对服务生大怒道:好吃的,好吃的,你难道不明白吗?!——

 

这样的情况是没有可能发生的!因为我没有明确地说出来我到底要吃什么?所以服务生也没办法为我服务……

问题后果:我可能被送往附近医院的精神科……

 

情形二:

我对服务生道:给我一份土豆炖牛肉盖饭!

服务生对大厨师傅道:做一份宫爆鸡丁——

 

这样的情况是没有可能发生的!因为我非常明确地说出来我要吃土豆炖牛肉盖饭!但是服务生却给我端上了一盘宫爆鸡丁?!

问题后果:我会投诉这个服务生的……

 

情形三:

我对服务生道:给我一份土豆炖牛肉盖饭!

服务生对大厨师傅道:做一份土豆炖牛肉盖饭——

大厨师傅道:宫爆鸡丁做好了……

 

这样的情况是没有可能发生的!因为我非常明确地说出来我要吃土豆炖牛肉盖饭!服务生也很明确地要求大厨师傅做一份土豆炖牛肉盖饭。但是厨师却烹制了一盘宫爆鸡丁?!

问题后果:我会投诉这家餐馆的……

 

情形四:

我对服务生道:给一份土豆炖牛肉盖饭!

服务生对大厨师傅道:做一份土豆炖牛肉盖饭——

大厨师傅道:土豆炖牛肉盖饭做好了……

服务生把盖饭端上来,放到我所在的桌位。我看着香喷喷的土豆炖牛肉盖饭,举勺下口正要吃的时候,却突然发现这盘土豆炖牛肉盖饭变成了石头?!

 

这样的情况更是没有可能发生的!必定,现实生活不是《西游记》。必定,这篇文章是学术文章而不是《哈里波特》……

问题后果:……

 

如果上面这些荒唐的事情都成了现实,那么我肯定永远都不敢再来这家餐馆吃饭了。这些让我感到极大的不安。而在TraceLWord3这个项目中呢?似乎上面这些荒唐的事情都成真了。(我想,不仅仅是在TraceLWord3这样的项目中,作为这篇文章的读者,你是否也经历过像这一样荒唐的项目而全然未知呢?)

 

首先在ListLWord.aspx.cs文件


 

...

#048        private void LWord_DataBind()

#049        {

#050            DataSet ds=new DataSet();

#051            (new LWordService()).ListLWord(ds, @"LWordTable");

#052

#053            m_lwordListCtrl.DataSource=ds.Tables[@"LWordTable"].DefaultView;

#054            m_lwordListCtrl.DataBind();

#055        }

...

 

  在ListLWord.aspx.cs文件中,使用的是DataSet对象来取得留言板信息的。但是DataSet是不明确的!为什么这么说呢?行#051LWordService填充的DataSet中可以集合任意的数据表DataTable,而在这些被收集的DataTable中,不一定会有一个是我们期望得到的。假设,LWordService类中的ListLWord函数其函数内容是:

 

...

#006 namespace TraceLWord3.InterService

#007 {

...

#011    public class LWordService

#012    {

...

#019        public int ListLWord(DataSet ds, string tableName)

#020        {

#021            ds.Tables.Clear();

#022            ds.Tables.Add(new DataTable(tableName));

#023

#024            return 1;

#025        }

...

 

函数中清除了数据集中所有的表之后,加入了一个新的数据表后就匆匆返回了。这样作的后果,会直接影响ListLWord.aspx

 

...

#018 <asp:DataList ID="m_lwordListCtrl" Runat="Server">

#019 <ItemTemplate>

#020    <div>   <!--// 会提示找不到下面这两个字段 //-->

#021        <%# DataBinder.Eval(Container.DataItem, "PostTime") %>

#022        <%# DataBinder.Eval(Container.DataItem, "TextContent") %>

#023    </div>

#024 </ItemTemplate>

#025 </asp:DataList>

...

 

这和前面提到的“情形一”,一模一样!我没有明确地提出自己想要的饭菜,但是餐馆服务生却揣摩我的意思,擅自作主。

其次,再看LWordService.cs文件

 

...

#019        public int ListLWord(DataSet ds, string tableName)

#020        {

#021            return (new LWordTask()).ListLWord(ds, tableName);

#022        }

...

 

  在LWordService.cs文件中,也是使用DataSet对象来取得留言板信息的。这个DataSet同样的不明确,含糊不清的指令还在执行……行#021LWordTask填充的DataSet不一定会含有我们希望得到的表。即便是行#019中的DataSet参数已经明确的定义了每个表的结构,那么在带入行#021之后,可能也会变得混淆。例如,LWordTask类中的ListLWord函数其函数内容是:

 

...

#006 namespace TraceLWord2

#007 {

...

#011    public class LWordTask

#012    {

...

#022        public int ListLWord(DataSet ds, string tableName)

#023        {

#024            ds.Tables.Clear();

#025

#026            // SQL语句里选取了 [RegUser] 表而非 [LWord]

#027            string cmdText="SELECT * FROM [RegUser] ORDER BY [RegUserID] DESC";

#028

#029            OleDbConnection dbConn=new OleDbConnection(DB_CONN);

#030            OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);

#031

#032            int count=dbAdp.Fill(ds, tableName);

#033

#034            return count;

#035        }

...

 

函数中清除了数据集中所有的表之后,选取了注册用户数据表[RegUser]DataSet进行填充并返回。也就是说,即便是LWordService.cs文件中#019中的DataSet参数已经明确的定义了每个表的结构,也可能会出现和前面提到的和“情形三”一样结果。

 

最后,再看看LWordTask.cs文件


 

...

#022        public int ListLWord(DataSet ds, string tableName)

#023        {

#024            string cmdText="SELECT * FROM [LWord] ORDER BY [LWordID] DESC";

#025

#026            OleDbConnection dbConn=new OleDbConnection(DB_CONN);

#027            OleDbDataAdapter dbAdp=new OleDbDataAdapter(cmdText, dbConn);

#028

#029            int count=dbAdp.Fill(ds, tableName);

#030

#031            return count;

#032        }

...

 

看到这里,我感到很是欣慰!我只能说我们的大厨师傅是一个厚道的人,而且还是很知我心的人。

 

  我们不能只坐在那里期盼着我们的程序会往好的方向发展,这样很被动。写出上面的这些程序段,必须小心翼翼。就连数据库表中的字段命名都要一审再审。一旦变化,就直接影响到位于“表现层”的ListLWord.aspx文件。仅仅是为了顺利的完成TraceLWord3这个“大型项目”,页面设计师要和程序员还有数据库管理员要进行额外的沟通。我们需要一个“土豆炖牛肉盖饭”式的强制标准!——

 

引入实体规范

 

为了达到一种“土豆炖牛肉盖饭”式的强制标准,所以在TraceLWord4中,引入了Classes项目。在这个项目里,只有一个LWord.cs程序文件。这是一个非常重要的文件,它属于“实体规范层”,如果是在一个Java项目中,Classes可以看作是:“实体Bean”。更完整的代码,可以在CodePackage/TraceLWord4目录中找到——

 

 

 

LWord.cs文件内容如下:

 

#001 using System;

#002

#003 namespace TraceLWord4.Classes

#004 {

#005    /// <summary>

#006    /// LWord 留言板类定义

#007    /// </summary>

#008    public class LWord

#009    {

#010        // 编号

#011        private int m_uniqueID;

#012        // 文本内容

#013        private string m_textContent;

#014        // 发送时间

#015        private DateTime m_postTime;

#016

#017        #region LWord 构造器

#018        /// <summary>

#019        /// LWord 默认构造器

#020        /// </summary>

#021        public LWord()

#022        {

#023        }

#024


#025        /// <summary>

#026        /// LWord 参数构造器

#027        /// </summary>

#028        /// <param name="uniqueID">留言编号</param>

#029        public LWord(int uniqueID)

#030        {

#031            this.UniqueID=uniqueID;

#032        }

#033        #endregion

#034

#035        /// <summary>

#036        /// 设置或获取留言编号

#037        /// </summary>

#038        public int UniqueID

#039        {

#040            set

#041            {

#042                this.m_uniqueID=(value<=0 ? 0 : value);

#043            }

#044

#045            get

#046            {

#047                return this.m_uniqueID;

#048            }

#049        }

#050

#051        /// <summary>

#052        /// 设置或获取留言内容

#053        /// </summary>

#054        public string TextContent

#055        {

#056            set

#057            {

#058                this.m_textContent=value;

#059            }

#060

#061            get

#062            {

#063                return this.m_textContent;

#064            }

#065        }

#066


#067        /// <summary>

#068        /// 设置或获取发送时间

#069        /// </summary>

#070        public DateTime PostTime

#071        {

#072            set

#073            {

#074                this.m_postTime=value;

#075            }

#076

#077            get

#078            {

#079                return this.m_postTime;

#080            }

#081        }

#082    }

#083 }

 

这个强制标准,LWordServiceLWordTask都必须遵守!所以LWordService相应的要做出变化:

 

#001 using System;

#002 using System.Data;

#003

#004 using TraceLWord4.AccessTask; // 引用数据访问层

#005 using TraceLWord4.Classes;     // 引用实体规范层

#006

#007 namespace TraceLWord4.InterService

#008 {

#009    /// <summary>

#010    /// LWordService 留言板服务类

#011    /// </summary>

#012    public class LWordService

#013    {

#014        /// <summary>

#015        /// 读取 LWord 数据表,返回留言对象数组

#016        /// </summary>

#017        /// <returns></returns>

#018        public LWord[] ListLWord()

#019        {

#020            return (new LWordTask()).ListLWord();

#021        }

#022


#023        /// <summary>

#024        /// 发送留言信息到数据库

#025        /// </summary>

#026        /// <param name="newLWord">留言对象</param>

#027        public void PostLWord(LWord newLWord)

#028        {

#029            (new LWordTask()).PostLWord(newLWord);

#030        }

#031    }

#032 }

 

从行#018中可以看出,无论如何,ListLWord函数都要返回一个LWord数组!这个数组可能为空值,但是一旦数组的长度不为零,那么其中的元素必定是一个LWord类对象!而一个LWord类对象,就一定有TextContentPostTime这两个属性!这个要比DataSet类对象作为参数的形式明确得多……同样的,LWordTask也要做出反应:

 

#001 using System;

#002 using System.Collections;

#003 using System.Data;

#004 using System.Data.OleDb;

#005 using System.Web;

#006

#007 using TraceLWord4.Classes;     // 引用实体规范层

#008

#009 namespace TraceLWord4.AccessTask

#010 {

#011    /// <summary>

#012    /// LWordTask 留言板任务类

#013    /// </summary>

#014    public class LWordTask

#015    {

#016        // 数据库连接字符串

#017        private const string DB_CONN=@"PROVIDER=Microsoft.Jet.OLEDB.4.0;

DATA Source=C:"DbFs"TraceLWordDb.mdb";

#018

#019        /// <summary>

#020        /// 读取 LWord 数据表,返回留言对象数组

#021        /// </summary>

#022        /// <returns></returns>

#023        public LWord[] ListLWord()

#024        {

#025            // 留言对象集合

#026            ArrayList lwordList=new ArrayList();

#027

#028            string cmdText="SELECT * FROM [LWord] ORDER BY [LWordID] DESC";

#029


#030            OleDbConnection dbConn=new OleDbConnection(DB_CONN);

#031            OleDbCommand dbCmd=new OleDbCommand(cmdText, dbConn);

#032

#033            try

#034            {

#035                dbConn.Open();

#036                OleDbDataReader dr=dbCmd.ExecuteReader();

#037

#038                while(dr.Read())

#039                {

#040                    LWord lword=new LWord();

#041

#042                    // 设置留言编号

#043                    lword.UniqueID=(int)dr["LWordID"];

#044                    // 留言内容

#045                    lword.TextContent=(string)dr["TextContent"];

#046                    // 发送时间

#047                    lword.PostTime=(DateTime)dr["PostTime"];

#048

#049                    // 加入留言对象到集合

#050                    lwordList.Add(lword);

#051                }

#052            }

#053            catch

#054            {

注意这里,为了保证语义明确,使用了一步强制转型。

而不是直接返回ArrayList对象

#055                throw;

#056            }

#057            finally

#058            {

#059                dbConn.Close();

#060            }

#061

#062            // 将集合转型为数组并返回给调用者

#063            return (LWord[])lwordList.ToArray(typeof(TraceLWord4.Classes.LWord));

#064        }

#065

#066        /// <summary>

#067        /// 发送留言信息到数据库

#068        /// </summary>

#069        /// <param name="newLWord">留言对象</param>

#070        public void PostLWord(LWord newLWord)

#071        {

#072            // 留言内容不能为空

#073            if(newLWord==null || newLWord.TextContent==null || newLWord.TextContent=="")

#074                throw new Exception("留言内容为空");

#075

#076            string cmdText="INSERT INTO [LWord]([TextContent]) VALUES(@TextContent)";

#077

#078            OleDbConnection dbConn=new OleDbConnection(DB_CONN);

#079            OleDbCommand dbCmd=new OleDbCommand(cmdText, dbConn);

#080

#081            // 设置留言内容

#082            dbCmd.Parameters.Add(new OleDbParameter("@TextContent",

OleDbType.LongVarWChar));

#083            dbCmd.Parameters["@TextContent"].Value=newLWord.TextContent;

#084

#085            try

#086            {

#087                dbConn.Open();

#088                dbCmd.ExecuteNonQuery();

#089            }

#090            catch

#091            {

#092                throw;

#093            }

#094            finally

#095            {

#096                dbConn.Close();

#097            }

#098        }

#099    }

#100 }

 

  这样,即便是将LWordTask.cs文件中的ListLWords方法修改成访问[RegUser]数据表的代码,也依然不会影响到外观层。因为函数只返回一个LWord类型的数组。再有,因为位于外观层的重复器控件绑定的是LWord类对象,而LWord类中就必有对TextContent字段的定义。这样也就达到了规范数据访问层返回结果的目的。这便是为什么在Duwamish7中会出现Common项目的原因。不知道你现在看明白了么?而Bincess.CN的做法和PetShop3.0一样,是通过自定义类来达到实体规范层的目的!PetShop3.0是通过Modal项目,而Bincess.CN则是通过Classes项目。

 

餐馆又来了一位新大厨师傅——谈谈跨越数据库平台的问题

餐馆面积不大,但生意很火。每天吃饭的人都特别多。为了加快上菜的速度,所以餐馆又找来了一位新的大厨师傅。假如,TraceLWord4为了满足一部分用户对性能的较高需要,要其数据库能使用MS SQL Server 2000。那么我们该怎么办呢?数据库要从Access 2000升迁到MS SqlServer 2000,那么只要集中修改AccessTask项目中的程序文件就可以了。但是,我又不想让这样经典的留言板失去对Access 2000数据库的支持。所以,正确的做法就是把原来所有的程序完整的拷贝一份放到另外的一个目录里。然后集中修改AccessTask项目,使之可以支持MS SQL Server 2000。这样这个留言板就有了两个版本,一个是Access 2000版本,另外一个就是MS SQL Server 2000版本……新的大厨师傅过来帮忙了,我们有必要让原来表现极佳的大厨师傅下课吗?可这样,新大厨师傅不是等于没来一样?新的大厨师傅过来帮忙了,我们有必要为新来的大厨师傅重新配备一套餐馆服务生系统、菜单系统吗?当然也没必要!那么,可不可以让TraceLWord4同时支持Access 2000又支持MS SQL Server 2000呢?也就是说,不用完整拷贝原来的程序,而是在解决方案里加入一个新的项目,这个项目存放的是可以访问MS SQL Server 2000数据库的代码。然后,我们再通过一个“开关”来进行控制,当开关指向Access 2000一端时,TraceLWord4就可以运行在Access 2000数据库平台上,而如果开关指向MS SQL Server 2000那一端时,TraceLWord4就运行在MS SQL Server 2000数据库平台上……

 

 

TraceLWord5中,加入了一个新项目SqlServerTask,这个项目的代码是访问的MS SQL Server 2000数据库。还有一个新建的项目DALFactory,这个项目就是一个“开关”。这个“开关”项目中仅有一个DbTaskDriver.cs程序文件,就是用它来控制TraceLWord5到底运行载那个数据库平台上?

关于TraceLWord5,更完整的代码,可以在CodePackage/TraceLWord5目录中找到——

DALFactory项目,其实就是“数据访问层工厂”,而DbTaskDriver类就是一个工厂类。也就是说DALFactory项目是“工厂模式”的一种应用。关于“工厂模式”,顾名思义,工厂是制造产品的地方,而“工厂模式”,就是通过“工厂类”来制造对象实例。“工厂类”可以通过给定的条件,动态地制造不同的对象实例。就好像下面这个样子:

 

// 水果基类

public class Fruit;

 

// 苹果是一种水果

public class Apple : Fruit;

 

// 句子是一种水果

public class Orange : Fruit;

// 水果工厂类

public class FruitFactory

{

    // 根据水果名称制造一个水果对象

    public static Fruit CreateInstance(string fruitName)

    {

        if(fruitName=="APPLE")

            return new Apple();

        else if(fruiteName=="ORANGE")

            return new Orange();

        else

            return null;

    }

}

// 制造一个Apple对象,即:new Apple();

Apple anApple=(Apple)FruitFactory.CreateInstance("APPLE");

// 制造一个Orange对象,即:new Orange();

Orange anOrange=(Orange)FruitFactory.CreateInstance("ORANGE");

 

  工厂类制造对象实例,实际通常是要通过语言所提供的RTTIRunTime Type Identification运行时类型识别)机制来实现。在Visual C#.NET中,是通过“反射”来实现的。它被封装在“System.Reflection”名称空间下,通过C#反射,我们可以在程序运行期间动态地建立对象。关于C#.NET反射,你可以到其它网站上搜索一下相关资料,这里就不详述了。左边是工厂模式的UML示意图。

 

 

 

新建的DbTaskDriver.cs文件,位于DALFactory项目中

 

#001 using System;

#002 using System.Configuration;

#003 using System.Reflection;   // 需要使用 .NET 反射

#004

#005 namespace TraceLWord5.DALFactory

#006 {

#007    /// <summary>

#008    /// DbTaskDriver 数据库访问层工厂

#009    /// </summary>

#010    public class DbTaskDriver

#011    {

#012        DbTaskDriver 构造器

#020

#021        /// <summary>

#022        /// 驱动数据库任务对象实例

#023        /// </summary>

#024        public object DriveLWordTask()

#025        {

#026            // 获取程序集名称

#027            string assemblyName=ConfigurationSettings.AppSettings["AssemblyName"];

#028            // 获取默认构造器名称

#029            string constructor=ConfigurationSettings.AppSettings["Constructor"];

#030

#031            // 建立 AccessTask 或者 SqlServerTask 对象实例

#032            return Assembly.Load(assemblyName).CreateInstance(constructor, false);

#033        }

#034    }

#035 }

 


那么相应的,LWordService.cs程序文件也要做相应的修改。

 

#001 using System;

#002 using System.Data;

#003

#004 using TraceLWord5.AccessTask;

#005 using TraceLWord5.Classes;     // 引用实体规范层

#006 using TraceLWord5.DALFactory; // 引用数据访问层工厂

#007 using TraceLWord5.SqlServerTask;

#008

#009 namespace TraceLWord5.InterService

#010 {

...

#014    public class LWordService

#015    {

...

#020        public LWord[] ListLWord()

#021        {

#022            object dbTask=(new DbTaskDriver()).DriveLWordTask();

#023

#024            // 留言板运行在 Access 数据库平台上

#025            if(dbTask is AccessTask.LWordTask)

#026                return ((AccessTask.LWordTask)dbTask).ListLWord();

#027

#028            // 留言板运行在 MS SQL Server 数据库平台上

#029            if(dbTask is SqlServerTask.LWordTask)

#030                return ((SqlServerTask.LWordTask)dbTask).GetLWords();

#031

#032            return null;

#033        }

...

#039        public void PostLWord(LWord newLWord)

#040        {

#041            object dbTask=(new DbTaskDriver()).DriveLWordTask();

#042

#043            // 留言板运行在 Access 数据库平台上

#044            if(dbTask is AccessTask.LWordTask)

#045                ((AccessTask.LWordTask)dbTask).PostLWord(newLWord);

#046

#047            // 留言板运行在 MS SQL Server 数据库平台上

#048            if(dbTask is SqlServerTask.LWordTask)

#049                ((SqlServerTask.LWordTask)dbTask).AddNewLWord(newLWord);

#050        }

#051    }

#052 }


原来的AccessTask项目及程序文件不需要变化,只是多加了一个SqlServerTask项目。新项目中,也有一个LWordTask.cs程序文件,其内容是:

 

#001 using System;

#002 using System.Collections;

#003 using System.Data;

#004 using System.Data.SqlClient;   // 需要访问 MS SQL Server 数据库

#005 using System.Web;

#006

#007 using TraceLWord5.Classes;     // 引用实体规范层

#008

#009 namespace TraceLWord5.SqlServerTask

#010 {

#011    /// <summary>

#012    /// LWordTask 留言板任务类

#013    /// </summary>

#014    public class LWordTask

#015    {

#016        // 数据库连接字符串

#017        private const string DB_CONN=@"Server=127.0.0.1; uid=sa; pwd=;

DataBase=TraceLWordDb";

#018

#019        /// <summary>

#020        /// 读取 LWord 数据表,返回留言对象数组

#021        /// </summary>

#022        /// <returns></returns>

#023        public LWord[] GetLWords()

#024        {

#025            // 留言对象集合

#026            ArrayList lwordList=new ArrayList();

#027

#028            string cmdText="SELECT * FROM [LWord] ORDER BY [LWordID] DESC";

#029

#030            SqlConnection dbConn=new SqlConnection(DB_CONN);

#031            SqlCommand dbCmd=new SqlCommand(cmdText, dbConn);

#032

#033            try

#034            {

#035                dbConn.Open();

#036                SqlDataReader dr=dbCmd.ExecuteReader();

#037

#038                while(dr.Read())

#039                {

#040                    LWord lword=new LWord();

#041


#042                    // 设置留言编号

#043                    lword.UniqueID=(int)dr["LWordID"];

#044                    // 留言内容

#045                    lword.TextContent=(string)dr["TextContent"];

#046                    // 发送时间

#047                    lword.PostTime=(DateTime)dr["PostTime"];

#048

#049                    // 加入留言对象到集合

#050                    lwordList.Add(lword);

#051                }

#052            }

#053            catch

#054            {

#055                throw;

#056            }

#057            finally

#058            {

#059                dbConn.Close();

#060            }

#061

#062            // 将集合转型为数组并返回给调用者

#063            return (LWord[])lwordList.ToArray(typeof(TraceLWord5.Classes.LWord));

#064        }

#065

#066        /// <summary>

#067        /// 发送留言信息到数据库

#068        /// </summary>

#069        /// <param name="newLWord">留言对象</param>

#070        public void AddNewLWord(LWord newLWord)

#071        {

#072            // 留言内容不能为空

#073            if(newLWord==null || newLWord.TextContent==null || newLWord.TextContent=="")

#074                throw new Exception("留言内容为空");

#075

#076            string cmdText="INSERT INTO [LWord]([TextContent]) VALUES(@TextContent)";

#077

#078            SqlConnection dbConn=new SqlConnection(DB_CONN);

#079            SqlCommand dbCmd=new SqlCommand(cmdText, dbConn);

#080

#081            // 设置留言内容

#082            dbCmd.Parameters.Add(new SqlParameter("@TextContent", SqlDbType.NText));

#083            dbCmd.Parameters["@TextContent"].Value=newLWord.TextContent;

#084


#085            try

#086            {

#087                dbConn.Open();

#088                dbCmd.ExecuteNonQuery();

#089            }

#090            catch

#091            {

#092                throw;

#093            }

#094            finally

#095            {

#096                dbConn.Close();

#097            }

#098        }

#099    }

#100 }

 

特别指出的是,这个SqlServerTask中的LWordTask程序文件,也遵循“土豆炖牛肉盖饭”式的强制标准!

TraceLWord5中,也需要配置Web.Config文件,需要加入自定义的键值:

 

#001 <?xml version="1.0" encoding="utf-8" ?>

#002 <configuration>

#003

#004 <system.web>

#005    <identity impersonate="true" />

#006    <compilation defaultLanguage="c#" debug="true" />

#007     <customErrors mode="RemoteOnly" />

#008 </system.web>

#009

#010 <appSettings>

...

#026    <!--// SQLServer 2000 数据库任务程序集及驱动类名称 //-->

#027    <add key="AssemblyName"

#028        value="TraceLWord5.SqlServerTask" />

#029    <add key="Constructor"

#030        value="TraceLWord5.SqlServerTask.LWordTask" />

#031

#032 </appSettings>

#033

#034 </configuration>

 

通过修改配置文件中的关键信息,就可以修改留言板的数据库运行平台。这样便做到了跨数据库平台的目的。


用户在访问TraceLWord5ListLWord.aspx页面时序图:

 

 

当一个用户访问TraceLWord5ListLWord.aspx页面的时候,会触发该页面后台程序中的Page_Load函数。而在该函数中调用了LWord_DataBind函数来获取留言板信息。由图中可以看到出,LWord_DataBind在被调用的期间,会建立一个新的LWordService类对象,并调用这个对象的ListLWord函数。在LWordService.ListLWord函数被调用的期间,会建立一个新的DALFactory.DbTaskDriver类对象,并调用这个对象的DriveLWordTask函数来建立一个真正的数据访问层对象。在代码中,DriveLWordTask函数需要读取应用程序配置文件。当一个真正的数据访问层类对象被建立之后,会返给调用者LWordService.ListLWord,调用者会继续调用这个真正的数据访问层类对象的GetLWords函数,最终取到留言板数据。PostLWord.aspx页面时序图,和上面这个差不多。就是这样,经过一层又一层的调用,来获取返回结果或是保存数据。

 

注意:从时序图中可以看出,当子程序模块未执行结束时,主程序模块只能处于等待状态。这说明将应用程序划分层次,会带来其执行速度上的一些损失……


烹制土豆烧牛肉盖饭的方法论

TraceLWord5已经实现了跨数据库平台的目的。但是稍微细心一点就不难发现,TraceLWord5有一个很致命的缺点。那就是如果要加入对新的数据库平台的支持,除去必要的新建数据访问层项目以外,还要在中间业务层InsetService项目中添加相应的依赖关系和代码。例如,新加入了对Oracle9i的数据库支持,那么除去要新建一个OracleTask项目以外,还要在LWordService中添加对OracleTask项目的依赖关系,并增加代码如下:

 

...

#020        public LWord[] ListLWord()

#021        {

#022            object dbTask=(new DbTaskDriver()).DriveLWordTask();

#023

#024            // 留言板运行在 Access 数据库平台上

#025            if(dbTask is AccessTask.LWordTask)

#026                return ((AccessTask.LWordTask)dbTask).ListLWord();

#027

#028            // 留言板运行在 MS SQL Server 数据库平台上

#029            if(dbTask is SqlServerTask.LWordTask)

#030                return ((SqlServerTask.LWordTask)dbTask).GetLWords();

#031

#032            // 留言板运行在 Oracle 数据库平台上

#033            if(dbTask is OracleTask.LWordTask)

#034                return ((OracleTask.LWordTask)dbTask).FetchLWords();

#035

#036            return null;

#037        }

#038

...

 

每加入对新数据库的支持,就要修改中间业务层,这是件很麻烦的事情。再有就是,这三个数据访问层,获取留言板信息的方法似乎是各自为政,没有统一的标准。在AccessTask项目中使用的是ListLWord函数来获取留言信息;而在SqlServerTask项目中则是使用GetLWords函数来获取;再到了OracleTask又是换成了FetchLWords……

餐馆服务生也许会对新来的大厨师傅很感兴趣,或许也会对新来的大厨师傅的手艺很感兴趣。但是这些餐馆服务生,绝对不会去背诵哪位大厨师傅会做什么样的菜,哪位大厨师傅不会做什么样的菜?也不会去在意同样的一道菜肴,两位大厨师傅不同的烹制步骤是什么?对于我所点的“土豆炖牛肉盖饭”,餐馆服务生只管对着厨房大声叫道:“土豆炖牛盖饭一份!”,饭菜马上就会做好。至于是哪个厨师做出来的,服务生并不会关心。其实服务生的意思是说:“外面有个顾客要吃‘土豆炖牛肉盖饭’,你们两个大厨师傅,哪位会做这个,马上给做一份……”。如果新来的大厨师傅不会做,那么原来的大厨师傅会担起此重任。如果新来的大厨师傅会做,那么两个大厨师傅之间谁现在更悠闲一些就由谁来做。

TraceLWord5中,两个数据访问层,都可以获取和保存留言信息,只是他们各自的函数名称不一样。但是对于中间业务层,却必须详细的记录这些,这似乎显得有些多余。仅仅是为了顺利的完成TraceLWord5这个“大型项目”,负责中间业务层的程序员要和负责数据访问层的程序员进行额外的沟通。TraceLWord5中,一个真正的数据访问层对象实例,是由DALFactory名称空间中的DbTaskDriver类制造的。如果中间业务层只需要知道“这个真正的数据访问层对象实例”有能力获取留言板和存储留言板,而不用关心其内部实现,那么就不会随着数据访问层项目的增加,而修改中间业务层了。换句直白的话来说就是:如果所有的数据访问层对象实例,都提供统一的函数名称“ListLWord函数”和“PostLWord函数”,那么中间业务层就不需要判断再调用了。我们需要“烹制土豆烧牛肉盖饭的方法论”的统一!——


烹制土豆炖牛肉盖饭方法论的统一——接口实现

怎么实现“烹制土豆烧牛肉盖饭方法论”的统一呢?答案是应用接口。在TraceLWord6中,新建了一个DbTask项目,里面只有一个ILWordTask.cs程序文件,在这里定义了一个接口。DbTask项目应该属于“抽象的数据访问层”。更完整的代码,可以在CodePackage/TraceLWord6目录中找到——

 

 

DbTask项目中的ILWordTask.cs内容如下:

 

#001 using System;

#002

#003 using TraceLWord6.Classes;     // 引用实体规范层

#004

#005 namespace TraceLWord6.DbTask

#006 {

...

#010    public interface ILWordTask

#011    {

#012        // 获取留言信息

#013        LWord[] ListLWord();

#014

#015        // 发送新留言信息到数据库

#016        void PostLWord(LWord newLWord);

#017    }

#018 }


AccessTask项目中的LWordTask.cs需要做出修改:

 

...

#007 using TraceLWord6.Classes;     // 引用实体规范层

#008 using TraceLWord6.DbTask;      // 引用抽象的数据访问层

#009

#010 namespace TraceLWord6.AccessTask

#011 {

...

#015    public class LWordTask : ILWordTask // 实现了ILWordTask接口

#016    {

...

#024        public LWord[] ListLWord()...

...

#071        public void PostLWord(LWord newLWord)...

...

#099    }

#100 }

 

SqlServerTask项目中的LWordTask.cs需要做出修改:

 

...

#007 using TraceLWord6.Classes;     // 引用实体规范层

#008 using TraceLWord6.DbTask;      // 引用抽象的数据访问层

#009

#010 namespace TraceLWord6.SqlServerTask

#011 {

...

#015    public class LWordTask : ILWordTask // 实现了ILWordTask接口

#016    {

...

#024        public LWord[] ListLWord()...

...

#071        public void PostLWord(LWord newLWord)...

...

#100    }

#101 }

 

  AccessTask项目中的LWordTask类实现了ILWordTask接口,那么就必须覆写ListLWordPostLWord这两个函数。SqlServerTask项目中的LWordTask类也实现了ILWordTask接口,那么就也必须覆写ListLWordPostLWord这两个函数。这两个类对共同的接口ILWordTask的实现,使这两个类得到空前的统一。这对于求根溯源,向上转型也是很有帮助的。
DALFactory
项目中的DbTaskDriver.cs文件也要作以修改:

 

...

#026        public ILWordTask DriveLWordTask()

#027        {

#028            // 获取程序集名称

#029            string assemblyName=ConfigurationSettings.AppSettings["AssemblyName"];

#030            // 获取默认构造器名称

#031            string constructor=ConfigurationSettings.AppSettings["Constructor"];

#032

#033            // 建立 ILWordTask 对象实例

#034            return (ILWordTask)Assembly.Load(assemblyName).CreateInstance(constructor,

false);

...

 

  因为AccessTask项目中的LWordTask类和SqlServerTask项目中的LWordTask类,都实现了ILWordTask接口。那么,像行#034这样的转型是绝对成立的。而且转型后的对象,一定含有ListLWordPostLWord这两个函数。InterService项目中的LWordService.cs程序文件应该作以修改,中间业务层只依赖于一个抽象的数据访问层。这样,修改具体的数据访问层就不会影响到它了:

 

...

#008 namespace TraceLWord6.InterService

#009 {

...

#013    public class LWordService

#014    {

#015        /// <summary>

#016        /// 读取 LWord 数据表,返回留言对象数组

#017        /// </summary>

#018        /// <returns></returns>

#019        public LWord[] ListLWord()

#020        {

#021            return (new DbTaskDriver()).DriveLWordTask().ListLWord();

#022        }

#023

#024        /// <summary>

#025        /// 发送留言信息到数据库

#026        /// </summary>

#027        /// <param name="newLWord">留言对象</param>

#028        public void PostLWord(LWord newLWord)

#029        {

#030            (new DbTaskDriver()).DriveLWordTask().PostLWord(newLWord);

#031        }

#032    }

#033 }


一次完整愉快的旅行

就让我们以ListLWord.aspx页面开始,进行一次完整愉快的旅行,看清TraceLWord6的运行全过程。当用浏览ListLWord.aspx页面时,服务器首先会调用ListLWord.aspx.cs文件:

 

...

#021        // 留言列表控件

#022        protected System.Web.UI.WebControls.DataList m_lwordListCtrl;

#023

#024        /// <summary>

#025        /// ListLWord.aspx 页面加载函数

1

2

3

7

8

#026        /// </summary>

#027        private void Page_Load(object sender, System.EventArgs e)

#028        {

#029            LWord_DataBind();

#030        }

...

#045        /// <summary>

#046        /// 绑定留言信息列表

#047        /// </summary>

#048        private void LWord_DataBind()

#049        {

#050            m_lwordListCtrl.DataSource=(new LWordService()).ListLWord();

#051            m_lwordListCtrl.DataBind();

#052        }

...

 

调用InterService名称空间中的LWordService

 

...

#008 namespace TraceLWord6.InterService

#009 {

...

#013    public class LWordService

#016        /// 读取 LWord 数据表,返回留言对象数组

#017        /// </summary>

#018        /// <returns></returns>

#019        public LWord[] ListLWord()

#020        {

#021            return (new DbTaskDriver()).DriveLWordTask().ListLWord();

#022        }

...

#032    }

#033 }


通过数据访问层工厂来制造对象实例,而工厂类

#001 <?xml version="1.0" encoding="utf-8" ?>

#002 <configuration>

...

#010 <appSettings>

...

#026    <!--// SQLServer 2000 数据库任务程序集及驱动类名称 //-->

#027    <add key="AssemblyName"

#028        value="TraceLWord6.SqlServerTask" />

#029    <add key="Constructor"

#030        value="TraceLWord6.SqlServerTask.LWordTask" />

#031

#032 </appSettings>

#033

#034 </configuration>

4

5

6

7

4

5

DbTaskDriver 需要读取网站应用程序中的:

Web.Config文件。这里应用了.NET反射机制。

 

...

#007 namespace TraceLWord6.DALFactory

#008 {

...

#012    public class DbTaskDriver

#013    {

...

#023        /// <summary>

#024        /// 驱动数据库任务对象实例

#025        /// </summary>

#026        public ILWordTask DriveLWordTask()

#027        {

#028            // 获取程序集名称

#029            string assemblyName=ConfigurationSettings.AppSettings["AssemblyName"];

#030            // 获取默认构造器名称

#031            string constructor=ConfigurationSettings.AppSettings["Constructor"];

#032

#033            // 建立 ILWordTask 对象实例

#034            return (ILWordTask)Assembly.Load(assemblyName).CreateInstance(constructor,

false);

#035        }

#036    }

#037 }

 

根据配置文件,制造TraceLWord6.SqlServerTask.LWordTask对象

 

...

#010 namespace TraceLWord6.SqlServerTask

#011 {

...

#015    public class LWordTask : ILWordTask

#016    {

...

#020        /// <summary>

#021        /// 读取 LWord 数据表,返回留言对象数组

#022        /// </summary>

#023        /// <returns></returns>

#024        public LWord[] ListLWord()...

...

#100    }

#101 }


最后按照页面上的代码样式绑定数据:

 

...

#018 <asp:DataList ID="m_lwordListCtrl" Runat="Server">

#019 <ItemTemplate>

#020    <div>

#021        <%# DataBinder.Eval(Container.DataItem, "PostTime") %>

#022        <%# DataBinder.Eval(Container.DataItem, "TextContent") %>

#023    </div>

#024 </ItemTemplate>

#025 </asp:DataList>

...

 

至此为止,一个简单的“三层结构”Web应用程序的执行全过程已经尽显在你眼前。执行顺序其实并不复杂。

 

加入商业规则

“商业规则”,是商业活动中的特殊规则。例如:我们去一家超市买东西,这家超市规定:凡是一次消费金额在2000元以上的顾客,可以获得一张会员卡。凭借这张会员卡,下次消费可以获得积分和享受9折优惠。“商业规则”主旨思想是在表达事与事之间,或者是物与物之间,再或者是事与物之间的关系,而不是事情本身或物质本身的完整性。再例如:一个用户在一个论坛进行新用户注册,该论坛系统规定,新注册的用户必须在4个小时之后才可以发送主题和回复主题。4个小时之内只能浏览主题。这也可以视为一种商业规则。但是,例如:电子邮件地址必须含有“@”字符;用户昵称必须是由中文汉字、英文字母、数字或下划线组成,这些都并不属于商业规则,这些应该被划作“实体规则”。它所描述的是物质本身的完整性。

TraceLWord7中,商业规则是由Rules项目来实现的。其具体的商业规则是:

n   每天上午09时之后到11时之前可以留言,下午则是13时之后到17时之前可以留言

n   如果当天留言个数小于 40,则可以继续留言

这两个条件必须同时满足。更完整的代码,可以在CodePackage/TraceLWord7目录中找到——

那么,商业规则层和中间业务层有什么区别吗?其实本质上没有太大的区别,只是所描述的功能不一样。一个是功能逻辑实现,另外一个则是商业逻辑实现。另外,中间业务层所描述的功能逻辑通常是不会改变的。但是商业逻辑却会因为季节、消费者心理、资金费用等诸多因素而一变再变。把易变的部分提取出来是很有必要的。

 

LWordRules.cs文件内容:

 

#001 using System;

#002

#003 using TraceLWord7.Classes;

#004 using TraceLWord7.DALFactory;

#005 using TraceLWord7.DbTask;

#006

#007 namespace TraceLWord7.Rules

#008 {

#009    /// <summary>

#010    /// LWordRules 留言规则

#011    /// </summary>

#012    public class LWordRules

#013    {


#014        /// <summary>

#015        /// 验证是否可以发送新留言

#016        /// </summary>

#017        /// <returns></returns>

#018        public static bool CanPostLWord()

#019        {

...

#027            DateTime currTime=DateTime.Now;

#028

#029            // 每天上午 09 时之后到 11 时之前可以留言,

#030            // 下午则是 13 时之后到 17 时之前可以留言

#031            if(currTime.Hour<=8 || (currTime.Hour>=11 && currTime.Hour<=12) || currTime.Hour>=17)

#032                return false;

#033

#034            // 获取当天的留言个数

#035            LWord[] lwords=(new DbTaskDriver()).DriveLWordTask().ListLWord(

#036                currTime.Date, currTime.Date.AddDays(1));

#037

#038            // 如果当天留言个数小于 40,则可以继续留言

#039            if(lwords==null || lwords.Length<40)

#040                return true;

#041

#042            return false;

#043        }

#044    }

#045 }

 

LWordService.cs文件中,要加入这样的规则:

 

#025        /// <summary>

#026        /// 发送留言信息到数据库

#027        /// </summary>

#028        /// <param name="newLWord">留言对象</param>

#029        public void PostLWord(LWord newLWord)

#030        {

#031            if(!LWordRules.CanPostLWord())

#032                throw new Exception("无法发送新留言,您违反了留言规则");

#033

#034            (new DbTaskDriver()).DriveLWordTask().PostLWord(newLWord);

#035        }

 

在发送留言之前,调用“商业规则层”来验证当前行为是否有效?如果无效则会抛出一个异常。
“三层结构”的缺点

有些网友在读完这篇文章前作之后,对我提出了一些质疑,这提醒我文章至此还没有提及“三层结构”的缺点。“三层结构”这个词眼似乎一直都很热门,究其原因,或许是这种开发模式应用的比较普遍。但是“三层结构”却并不是百试百灵的“万灵药”,它也存在着缺点。下面就来说说它的缺点……

“三层结构”开发模式的一个非常明显的缺点就是其执行速度不够快。当然这个“执行速度”是相对于非分层的应用程序来说的。从文中所给出的时序图来看,也明显的暴露了这一缺点。TraceLWord1TraceLWord2没有分层,直接调用的ADO.NET所提供的类来获取数据。但是,TraceLWord6确要经过多次调用才能获取到数据。在子程序模块程序没有返回时,主程序模块只能处于等待状态。所以在执行速度上,留言板的版本越高,排名却越靠后。“三层结构”开发模式,不适用于对执行速度要求过于苛刻的系统,例如:在线订票,在线炒股等等……它比较擅长于商业规则容易变化的系统。

“三层结构”开发模式,入门难度够高,难于理解和学习。这是对于初学程序设计的人来说的。以这种模式开发出来的软件,代码量通常要稍稍多一些。这往往会令初学者淹没在茫茫的代码之中。望之生畏,对其产生反感,也是可以理解的……

其实,无论哪一种开发模式或方法,都是有利有弊的。不会存在一种“万用法”可以解决任何问题。所以“三层结构”这个词眼也不会是个例外!是否采用这个模式进行系统开发,要作出比较、权衡之后才可以。切忌滥用——

 

结束语

谈到这里,文章对“三层结构”的原理和用意已经作了完整的阐述。作为这篇文章的作者,在心喜之余也感到写作技术文章并不是件很轻松的事情,特别是第一次写作像这样长达40多页的文章。为了能使读者轻松阅读,每字每句都要斟酌再三,唯恐会引起歧义。在这里要特别感谢一直关注和支持彬月论坛的网友,他们对彬月论坛的喜爱以及对我的支持,是我写作的巨大动力。当然,在这里还要感谢自己的父母,在我辞去原来的工作在家中完成彬月论坛的日子里,他们给了我极大的支持和理解……


  希望这篇文章能将你带到梦想的地方——

  AfritXia01.18/2005

转载于:https://www.cnblogs.com/min10/archive/2008/10/22/1316546.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值