|
使用 ASP.NET Community Starter Kit建造网站
翻译:刘海东
以下内容翻译自Building Websites with the ASP.NET Community Starter Kit by K. Scott Allen and Cristian Darie for Packt Publishing,以下内容是该书的第8章,详细介绍如何扩展CSK来增加FAQ功能. 如果你想了解 ASP.NET Community Starter Kit的详细信息,可以到www.asp.net浏览和下载,它是一个免费的开源项目.假如你想建造一个稳健而灵活的ASP.NET网站,CSK将是一个很好的出发点.
创建一个新模块
每一个社区网站都会有不同的需求要实现。虽然本身的CSK类库有很大的灵活性,但完全拥有源代码意味者你可以在这个高品质的网站基础上增加定制的额外功能。在这篇文章里,我们将详细介绍如何在已有的框架中增加一个全新的功能FAQ(Frequently Asked Questions),并如何做到与已有的模块无缝衔接。
在真正开始之前,先提醒大家一下,CSK是一个不断修改和升级的项目,所以在动手增加新模块之前,先到网上查找一下别人是否已经实现了这项功能,或者关注一下CSK是否增加了新的特性。
模块设计
在你实现一个用于CSK的模块之前,首先要明白你要增加的特性到底是什么,然后决定由CSK中已有的哪些模块来实现这个功能。
首先让我们对FAQ的需求列一个大致的清单:
l 一个FAQ由一个问题、一个答案,一个描述或介绍和一些相关参照的链接组成,
l 社区成员能够对某个问题加评注或评级,当有新的问题时通过邮件提醒,
l 如果版主同意,社区成员可以发布新的问题主题。
你当然可以只用一个HTML文件列出所以的问题和答案,但那样就限制了用户的交互性操作(评注、评级、邮件等)
在CSK所附带的数据库中有表Community_ContentPages,其中包含了社区页面的大部分信息如作者、浏览计数和介绍等。由于要存放与FAQ相关的答案、参考链接等属性,我们再增加一个表Community_Faqs:
然后我们可以创建保存FAQ信息的类了。在下面的图中,FaqInfo类继承自ContentInfo,它可以保存一般内容信息项的大部分属性。每一个模块都会有一个自己的Utility类来读取、添加和编辑内容。所以对于FAQ的模块,我们还要创建一个FaqUtility类。
我们还要创建给Code-Behind页面使用的类来显示和编辑FAQ。CSK中是通过SkinnedCommunityControl来使不同页面显示不同的界面。CSK中也包含了其它实现了常用功能的基类可用于增加(ContentAddPage),编辑(ContentEditPage)和显示(ContentItemPage),下面的图中显示了这些类的继承关系
另外,我们还需要创建从WebControl继承的类来显示FAQ的内容。通常,每一个属性显示时放在不同的控件中,并且该控件可以用最适合的风格来显示内容。下面的图中显示了这些最终在这个模块中会使用的控件,它们最终都是从WebControl继承而来。
模块创建过程
构建模块的过程我们将采用自底向上底方式,从数据库建立开始,到表现层的界面和主题设置结束。我们将沿用在CSK中已经约定的命名模式,保持与其它模块的风格一致。如,在书的模块中要从Community_Books表中读取信息,那么相应的类就是BookInfo。
这样我们就用一个叫做Community_Faqs的表,对应的类叫做FaqInfo。当然你可能想另外加上唯一标示防止将来的CSK中包含这个模块。例如你在ABC公司工作,那么这个表名可以叫做Community_ABCFaqs来减少将来可能出现的名称冲突。
我们将使用下面的步骤来构建FAQ模块。你可以参照这些步骤构建你自己的模块:
1. 创建一个新的表(Community_FAqs)来保存新模块的附加字段信息,
2. 创建用于添加、编辑、和选择一个FAQ的存储过程,另外还要一个从给定范围内读取所有FAQ的存储过程,
3. 创建一个维护的存储过程通过填充Community_PageTypes和Community_NamedPages表来初始化FAQ模块,
4. 创建一个FaqInfo的类保存一条FAQ的信息,
5. 创建一个FaqUtility的类通过访问数据库来调用前面创建的与FAQ相关的存储过程,
6. 创建从WebControl继承的控件显示不同的字段,这些控件叫做FaqQuestion,FaqInro,FaqAnswer,FaqReference和FaqEditContent
7. 创建从SkinnedCommunityControl继承的类来包含下一步创建的显示页面背后的逻辑。这些类叫做AddFaq,EditFaq,FaqSection,和Faq
8. 创建新模块使用的内容显示页面,包括有Faqs_AddFaq.ascx,Faqs_FaqSection.ascx和Faq_Faq.ascx。我们将使用Faqs_AddFaq增加和编辑FAQ。另外你还要在Communityes\Common\Themes\Defalult\Skins\ContentSkins目录下创建默认界面文件,例如Robotics和Professional的主题。
9. 创建定义该模块页面风格的CSS文件和主题相关的CSS文件,并放到Communities\Common\Themes\Defalult\Styles。
下面将对每一个步骤作详细的解释。
Community_Faq表
大部分模块共有的信息如标题、描述和浏览计数放在Community_ContentPages表中。针对该FAQ模块的附加信息则需要另加一个表存放。例如,我们可以把FAQ的问题放在Page_title字段中,FAQ的介绍放在Page_description字段.另外,我们还需要存放FAQ的答案和附加的参考索引等信息,所以还要创建下面的表:
CREATE TABLE [Community_Faqs] (
[Faq_ContentPageID] [int] NOT NULL ,
[Faq_Answer] [ntext] NOT NULL ,
[Faq_Reference] [ntext] NULL,
CONSTRAINT [PK_Community_Faqs] PRIMARY KEY CLUSTERED
(
[Faq_ContentPageID]
),
CONSTRAINT [FK_Community_Faqs_Community_ContentPages]
FOREIGN KEY
(
[Faq_ContentPageID]
) REFERENCES [Community_ContentPages] (
[contentPage_id]
) ON DELETE CASCADE
可以看到表的命名方式和数据类型与CSK中的其它模块都是一致的,这样的编程习惯在国内实在是难以做到。
我们存储FAQ的答案和参考索引的字段是ntext类型,这是为了能支持大数据量的文本(可高达1GB)。还要注意的是Faq_Answer是一个必要字段而Faq_Reference可以是空值。我们的主键(Faq_ContentpageID)关联到Community_Contentpages表中的附加内容。另外一个要注意的细节是外键的约束(级联删除)的使用,保证的数据关联的完整性,并且节省了程序代码。
CSK使用名为Community_ContentpagesDeleteContentPage(太长了吧)的存储过程删除Community_ContentPages表中的记录。当这个存储过程删除记录时,服务器根据外键自动删除FAQ表中的对应记录。
增加存储过程
下面我们要做的是建立增加、编辑、读取单个、读取多个记录的存储过程,在代码中不会出现SQL的代码。从封装性和安全性来看,这样做是一种很好的习惯。
Community_FaqsAddFaq
下面是增加一条新的FAQ记录的存储过程。我们不需要给两个表中的每个字段填充数值。例如,我们不需要填充Contentpage_ViewCount列(默认为0),也不需要在ContentPage_DateCommented中填写日期。 CREATE PROCEDURE Community_FaqsAddFaq ( @communityID int, @sectionID int, @username nvarchar(50), @topicID int, @question nvarchar(100), @introduction nvarchar(500), @metaDescription nvarchar(250), @metaKeys nvarchar(250), @moderationStatus int, @answer ntext, @reference ntext ) AS DECLARE @ContentPageID int DECLARE @pageType int SET @pageType = dbo.Community_GetPageTypeFromName('Faq')DECLARE @userID int SET @userID = dbo.Community_GetUserID(@communityID, @username); BEGIN TRAN EXEC @ContentPageID = Community_AddContentPage @communityID, @sectionID, @userID, @question, @introduction, @metaDescription, @metaKeys, @pageType, @moderationStatus, @topicID INSERT Community_Faqs ( Faq_ContentPageID, Faq_Answer, Faq_Reference ) VALUES ( @ContentPageID, @answer, @reference ) COMMIT TRAN RETURN @ContentPageID
注意这里使用了两个CSK提供的UDF(User Defined Function),第一个UDF取回页面类型,CSK中每个模块都有唯一的页面类型。第二个存储过程是根据Community和用户名取回UserId。
由于我们要在两个不同表中插入记录,所以在这里使用了事务来保证操作的原子性。其中往Community_ContentPages表中插入记录是通过调用Communit_AddContentPage这个存储过程来完成的,把FAQ的问题作为@Title参数、介绍作为@Description参数。AddContentPage执行完后返回新增记录的主键值,该数值被用于往Community_Faqs中新增记录。
在CSK中所有新增记录的存储过程必须返回主键值作为结果。
得到新的ContentPageID数值在系统的上层是很有用的,这一点我们将在后面写数据访问组件时看到。
Community_FaqsEditFaq
这个用于修改已有的FAQ记录的存储过程需要的参数比前面少了很多。因为有些字段在我们增加记录之后就不会再被修改,如区域编号等。其代码如下: CREATE PROCEDURE Community_FaqsEditFaq ( @communityID int, @contentPageID int, @username NVarchar(50), @topicID int, @question NVarchar(100), @introduction NVarchar(500), @metaDescription NVarchar(250), @metaKeys NVarchar(250), @answer Text, @reference Text ) AS DECLARE @UserID int SET @UserID = dbo.Community_GetUserID(@communityID, @username) EXEC Community_EditContentPage @contentPageID, @userID, @question, @introduction, @metaDescription, @metaKeys, @topicID UPDATE Community_Faqs SET Faq_Answer = @answer, Faq_Reference = @reference WHERE Faq_ContentPageID = @contentPageID
这里我们又一次使用了CSK中的存储过程Community_ContentPages,然后用Update的SQL更新Community_Faqs。与新增FAQ的存储过程相比,这里没有使用事务来保证更新操作的原子性。我们遵循了CSK中已经建立的模式,这里都不使用事务。也许是设计者认为更新操作失败的可能性比新增要小的多,减少不必要的资源锁定来提高系统数据吞吐能力吧。
Community_FaqsGetFaqs
接下来要写的存储过程是返回某个社区内给定范围的所有FAQ记录。这个名为Community_GetPagedSortedContent的存储过程,在原来的基础上增加了针对FAQ列的参数,并用IndexID排序。 CREATE PROCEDURE Community_FaqsGetFaqs ( @communityID int, @username NVarchar(50), @sectionID int, @pageSize int, @pageIndex int, @sortOrder NVarchar(50) ) AS DECLARE @currentDate DATETIME SET @currentDate = GetUtcDate() SELECT null Faq_Answer, null Faq_Reference, Content.* FROM dbo.Community_GetPagedSortedContent ( @communityID, @username, @sectionID, @currentDate, @sortOrder, @pageSize, @pageIndex, default ) Content ORDER BY IndexID
这个存储过程使用了两个减少代码和将来维护量的技巧。首先,我们使用了Content.*作为返回的结果,这里还使用了CSK中的存储过程。从效率上来说,取回所有的列比让数据库推算哪些列是需要的在返回要更有效率。而且,在这里设计师为了更好的兼顾了维护性。如果将来Community_Contentpages的结构有了修改(如增加列),并不需要修改或测试相关的存储过程。
第二个要指出的是结果集中的两个空列(Faq_Answer和Faq_Reference)。后面我们将写一个FaqInfo组件来保存来自这个存储过程的多条记录和后面一个存储过程的单条记录。由于我们想使用同一个组件来实现这两种操作,所以我们要填充所有列的信息。因为这两个列可能占用很大空间,而且不会在FAQ的统计列表中显示,所以这里我们就用NULL值来代替。
Community_FaqsGetFaq
这个存储过程我们要用来取得一条单独的记录和其它相关的功能。它还需要增加这个页面的访问计数和告诉用户开始读取页面的内容。这些任务是通过执行CSK的Community_ContnetpagesTrackStats过程来完成的,整个过程代码如下: CREATE PROCEDURE Community_FaqsGetFaq ( @communityID INT, @username NVarchar(50), @contentPageID int ) AS DECLARE @userID INT SET @userID = dbo.Community_GetUserID(@communityID, @username) -- Update ViewCount and HasRead Stats EXEC Community_ContentPagesTrackStats @userID, @contentPageID DECLARE @currentDate DATETIME SET @currentDate = GetUtcDate() SELECT Faq_Answer, Faq_Reference, Content.* FROM dbo.Community_GetContentItem( @communityID, @userID, @currentDate) Content JOIN Community_Faqs (nolock) ON ContentPage_ID = Faq_ContentPageID WHERE ContentPage_ID = @contentPageID
注意由于要显示明细,这里我们读取了Faq_Answer和Faq_Reference列的实际值。这里我们在join连接Community_Faqs表时使用了nolock的选项,这将允许我们执行脏读取而不会有任何警告提示。(和数据库锁定机制有关)
初始化FAQ模块
每一个社区模块都有一个维护用的存储过程用来填充数据库中与模块运行关的信息设置内容。譬如我们要通过在Community_PageTypes中增加记录来注册页面类型:一条信息是关于显示FAQ列表的页面,另一条是显示单个FAQ详细信息的页面。为了遵循CSK一致的命名规则我们把这个存储过程叫做Community_MaintenanceInitializeFaqs,其中部分代码摘录如下: IF NOT EXISTS (SELECT * FROM Community_PageTypes WHERE pageType_Name='Faq Section') BEGIN INSERT Community_PageTypes ( pageType_name, pageType_description, pageType_pageContent, pageType_IsSectionType, pageType_ServiceSelect ) VALUES ( 'FAQ Section', 'Contains FAQs in a question and answer style format', 'ASPNET.StarterKit.Communities.Faqs.FaqSection', 1, 'Community_FaqsServiceSelect' ) END ELSE PRINT 'WARNING: The FAQ Module has already been registered.'
由于CSK会缓存Community_NamePages的数据所以只会读取数据一次。如果你在这些表中作了修改,需要重新启动Web程序来使修改生效。
这个维护的存储过程还需要注册一些新模块重要使用的静态显示页面,这里包括新增和编辑的页面,并且你必须使用与你将要创建的ASPX文件完全相同的名称作为注册信息。
下面是代码中的相关部分: IF NOT EXISTS (SELECT * FROM Community_NamedPages WHERE namedPage_Path='/Faqs_AddFaq.aspx') BEGIN INSERT Community_NamedPages ( namedPage_name, namedPage_path, namedPage_pageContent, namedPage_title, namedPage_description, namedPage_sortOrder, namedPage_isVisible, namedPage_menuID ) VALUES ( 'AddFaq', '/Faqs_AddFaq.aspx', 'ASPNET.StarterKit.Communities.Faqs.AddFaq', 'Add FAQ', 'Enables users to add a new FAQ', 0, 1, 0 ) END ELSE PRINT 'WARNING: /Faqs_AddFaq.aspx has already been registered as a NamedPage.'
其中namedPage_pageContent参数是CSK调用该静态页面时要使用的code-behind类的完整路径:ASPNET.StarterKit.Communities.Faqs.AddFaq。
FAQ组件
FaqInfo
所有FAQ模块中的C#代码都将放在Engine\Modules\Faqs目录中。首先在一个Components的目录内增加helper类。每个CSK的模块都应该放在不同的名称空间,即在ASPNET.StarterKit.Communities后再加上模块名称作为限定。 using System; using System.Data.SqlClient; namespace ASPNET.StarterKit.Communities.Faqs { public class FaqInfo : ContentInfo { public FaqInfo(SqlDataReader dr) : base(dr) { if(dr["Faq_Answer"] != DBNull.Value) { _answerText = (string)dr["Faq_Answer"]; } if(dr["Faq_Reference"] != DBNull.Value) { _referenceText = (string)dr["Faq_Reference"]; } } public string AnswerText { get { return _answerText; } set { _answerText = value; } } public string ReferenceText { get { return _referenceText; } set { _referenceText = value; } } public string QuestionText { get { return base.Title; } set { base.Title = value; } } public string IntroText { get { return base.BriefDescription; } set { base.BriefDescription = value; } } private string _answerText; private string _referenceText; } }
FaqInfo类初始化时需要一个SqlDataReader类的实例。在我们下一个类中将会有数据访问的代码来创建一个SqlDataReader。
FaqUtility
按照CSK中的编码惯例,我们将把数据访问的过程都放在一个utility类的静态方法中。对应于每一个与FAQ相关的存储过程都会有一个静态方法来调用。(除了用于系统维护的存储过程之外,因为只有在站点初始化时才会用到它)。每个静态方法都需要与之相对应的参数用于传递到存储过程中去。
下面是AddFaq的方法: public static int AddFaq( string username, int sectionID, int topicID, string question, string introduction, string answer, string reference, int moderationStatus) { SqlConnection conPortal = new SqlConnection( CommunityGlobals.ConnectionString); SqlCommand cmdAdd = new SqlCommand( "Community_FaqsAddFaq", conPortal); cmdAdd.CommandType = CommandType.StoredProcedure; cmdAdd.Parameters.Add("@RETURN_VALUE", SqlDbType.Int).Direction = ParameterDirection.ReturnValue; cmdAdd.Parameters.Add("@communityID", CommunityGlobals.CommunityID); cmdAdd.Parameters.Add("@sectionID", sectionID); cmdAdd.Parameters.Add("@username", username); cmdAdd.Parameters.Add("@topicID", topicID); cmdAdd.Parameters.Add("@question", question); cmdAdd.Parameters.Add("@introduction", introduction); cmdAdd.Parameters.Add("@metaDescription", ContentPageUtility.CalculateMetaDescription(introduction)); cmdAdd.Parameters.Add("@metaKeys", ContentPageUtility.CalculateMetaKeys(introduction)); cmdAdd.Parameters.Add("@moderationStatus", moderationStatus ); cmdAdd.Parameters.Add("@answer", SqlDbType.NText); cmdAdd.Parameters.Add("@reference", SqlDbType.NText); cmdAdd.Parameters["@answer"].Value = answer; cmdAdd.Parameters["@reference"].Value = reference; conPortal.Open(); cmdAdd.ExecuteNonQuery(); int result = (int)cmdAdd.Parameters["@RETURN_VALUE"].Value; SearchUtility.AddSearchKeys(conPortal, sectionID, result, question, introduction); conPortal.Close(); return result; }
请注意AddFaq方法内部还调用了SearchUtility类的方法产生了有关内容的查询关键字,另外它还返回了新增对象的唯一标示符。这里CSK需要做的一个改进是在代码中增加 try catch finally来保证执行数据库连接的Close方法。虽然发生异常的可能性很小,但对于一个大容量的社区网站来说,你无法承受浪费浪费数据库连接可能带来的性能下降。
在FaqUtility中的另外两个方法是GetFaqs和GetFaqInfo。GetFaqs在SqlDataReader中循环读取记录并返回一个FaqInfo对象的ArrayList,而GetFaqInfo只返回由数据库中一条记录填充的FaqInfo对象。 public static ContentInfo GetFaqInfo(string username, int contentPageID) { FaqInfo faq = null; SqlConnection conPortal = new SqlConnection( CommunityGlobals.ConnectionString); SqlCommand cmdGet = new SqlCommand( "Community_FaqsGetFaq", conPortal); cmdGet.CommandType = CommandType.StoredProcedure; cmdGet.Parameters.Add( "@communityID", CommunityGlobals.CommunityID); cmdGet.Parameters.Add("@username", username); cmdGet.Parameters.Add("@contentPageID", contentPageID); conPortal.Open(); SqlDataReader dr = cmdGet.ExecuteReader(); if (dr.Read()) faq = new FaqInfo(dr); conPortal.Close(); return faq; } public static ArrayList GetFaqs(string username, int sectionID, int pageSize, int pageIndex, string sortOrder) { SqlConnection conPortal = new SqlConnection(CommunityGlobals.ConnectionString); SqlCommand cmdGet = new SqlCommand( "Community_FaqsGetFaqs", conPortal); cmdGet.CommandType = CommandType.StoredProcedure; cmdGet.Parameters.Add("@communityID", CommunityGlobals.CommunityID); cmdGet.Parameters.Add("@username", username); cmdGet.Parameters.Add("@sectionID", sectionID); cmdGet.Parameters.Add("@pageSize", pageSize); cmdGet.Parameters.Add("@pageIndex", pageIndex); cmdGet.Parameters.Add("@sortOrder", sortOrder); ArrayList faqs = new ArrayList(); conPortal.Open(); SqlDataReader dr = cmdGet.ExecuteReader(); while (dr.Read()) faqs.Add(new FaqInfo(dr)); conPortal.Close(); return faqs; } 这里有比较重要的一点是GetFaqInfo使用了以上特定的参数列表.在类框架中将通过其它的代理类来调用此方法所以参数必须一致.在后面我们写内容页面时你将会看到它是如何工作的。 我们的数据访问层现在已经完成了。如果你按照这个方式创建了模块,你已经可以开始编译和解决错误了。你可以考虑写一个驱动页面来试验以上FaqUtility 中的4个静态方法,并检查在表Community_Faqs 和 Community_ContentPages中的结果是否正确。
FAQ WebControls
在CSK中把显示内容的功能分成几个较小的控件。例如在Engine\Framework\ContentPages\Controls目录下,你会看到一个显示标题的控件(文件名Title.cs),还要内容摘要的控件(BriefDescription.cs)。我们需要为FAQ再增加两个特定的控件,一个显示答案和参考,另一个为授权用户显示编辑内容的链接。
FaqAnswer 和 FaqReference
在这一层的所有控件都是从.NET Framework的WebControl控件继承而来。我们只需简单地设置控件的CssClass属性,从当前HttpContext中获取要显示的文本,然后重载RenderContents方法写出该文本。
下面的控件将被创建在Engine\Module\Faqs\Controls中,用来显示FAQ的答案和参考: using System; using System.Web; using System.Web.UI; using System.Web.UI.WebControls; using ASPNET.StarterKit.Communities.Faqs; using System.ComponentModel; namespace ASPNET.StarterKit.Communities { [Designer(typeof(ASPNET.StarterKit.Communities.CommunityDesigner))] public class FaqAnswer : WebControl { public FaqAnswer() : base() { CssClass = "faqAnswerText"; if(Context != null) { Object faqInfo = Context.Items["ContentInfo"]; if(faqInfo != null) { _text = ((FaqInfo)faqInfo).AnswerText; } } } public string Text { get { return _text; } set { _text = value; } } override protected void RenderContents( HtmlTextWriter writer) { SectionInfo objSectionInfo = (SectionInfo)Context.Items["SectionInfo"]; writer.Write( CommunityGlobals.FormatText( objSectionInfo.AllowHtmlInput, objSectionInfo.ID, _text)); } private string _text; } }
请注意我们前面的控件是在ASPNET.StarterKit.Communities.Faqs命名空间中,但是被放在上一层命名空间中。这是因为CSK所使用的界面文件都指向ASPNET.StarterKit.Communities。下面这行标记就为以上控件指定了界面:
<community:FaqAnswer Runat="Server" ID="Answer1" NAME="Answer1"/>
FaqRefrence控件看上去与FaqAnswer非常相似。都重载了RnderContents |