在ASP.NET 2.0中操作数据之十七:研究插入、更新和删除的关联事件
导言
当使用GridView、DetailsView或FormView控件的内建插入、编辑或删除特征时,在用户添加一条新记录或更新/删除一条现在记录的过程中发生了多个步骤。正如我们之前一节里所讨论的,在GridView中编辑一行时,保存(Update)和取消(Cancel)按钮将取代编辑(Edit)按钮,并且绑定列转换成TextBox。在用户更新了数据并点击保存按钮之后,下述步骤在回传时执行:
1.该GridView控件根据当前编辑行的唯一标识字段(通过DataKeyNames属性)组装它的ObjectDataSource的UpdateParameters参数,连同用户输入的值
2.该GridView控件调用它的ObjectDataSource的Update()方法,它转而调用潜在对象的适当的方法(ProductsDAL.UpdateProduct,我们之前一节里)
3.现在,这些隐含的数据,包含保存后的更改,被重新绑定到GridView控件
在这一连串的步骤里,触发了许多事件,这让我们可以创建事件处理程序从而在需要的地方增加自定义逻辑。例如,在第1步之前,触发GridView的事件。在这里,如果有什么validation错误我们可以取消更新请求。当调用Update()方法时,触发ObjectDataSource的Updating事件,提供了增加或自定义UpdateParameters的值的机会。在ObjectDataSource的潜在对象的方法完全执行后,触发ObjectDataSource的Updated事件。针对Updated事件的事件处理程序可以检查更新操作的相关详细信息,例如影响了多少行数据,或者是否引发了一个异常。最后,在第2步之后,GridView的RowUpdated事件触发;针对此事件的事件处理程序可以检查关于刚刚完成的更新操作的相关额外信息。
图1描述了使用GridView更新时这一系列连续的事件和步骤。图1里的这个事件模式不仅是在GridView的更新操作。从GridView、DetailsView或者FormView里插入、更新或者删除数据时,数据Web服务器控件和ObjectDataSource都会发生这一连串的pre-level和post-level的事件。
图1: 当在GridView里更新数据时,触发一连串的Pre-和Post-事件
在这一节里,我们将探讨使用这些事件从而扩展ASP.NET数据Web服务器控件的内建插入、更新和删除功能。我们也会看看如何自定义编辑界面从而仅仅更新部分产品字段。
第一步: 更新产品的ProductName和UnitPrice字段
在之前一节的编辑界面里,包含了产品的所有字段并且它们都不是只读的。如果我们从GridView中剔除一列(QuantityPerUnit),那么当更新时数据Web服务器控件就不会设置ObjectDataSource的QuantityPerUnit UpdateParameters的值。ObjectDataSource则传入一个null值到UpdateProduct这个业务逻辑层的方法,它将当前编辑的数据库记录的QuantityPerUnit字段更改为值。同样地,如果是一个必需的字段,例如ProductName,如果从编辑界面中剔除了,那么此更新将会失败并抛出一个“Column 'ProductName' does not allow nulls”异常。导致这一现象的原因是ObjectDataSource被配置为调用ProductsBLL类的UpdateProduct方法,它预期每一个产品字段都对应一个输入参数。因此,ObjectDataSource的UpdateParameters集合包含了该方法的每一个输入参数。
如果我们希望提供一个允许最终用户仅仅可以更新部分字段的数据Web服务器控件,那么我们需要在ObjectDataSource的Updating事件处理程序中编程设置缺失的UpdateParameters值,或者创建并调用一个预期部分字段的BLL方法。让我们在接下来的步骤中探讨。
特别地,让我们创建一个在一个可编辑的GridView中仅显示ProductName和UnitPrice字段的页面。这个GridView的编辑界面将仅仅允许用户更新两个显示的字段,ProductName和UnitPrice 。因为这个编辑界面仅仅提供产品的部分字段,我们需要创建一个ObjectDataSource,它使用现有的BLL的UpdateProduct方法并在Updating事件处理程序中编程设置产品的缺少的字段的值,或者我们需要创建一个新的BLL方法,它仅接受那些在GridView中已经定义的部分字段。在这一节里,我们使用后者,创建一个UpdateProduct方法的重载,它提取3个输入参数:productName、unitPrice和productID:
[System.ComponentModel.DataObjectMethodAttribute (System.ComponentModel.DataObjectMethodType.Update, false)] public bool UpdateProduct(string productName, decimal? unitPrice, int productID) { Northwind.ProductsDataTable products = Adapter.GetProductByProductID(productID); if (products.Count == 0) // no matching record found, return false return false; Northwind.ProductsRow product = products[0]; product.ProductName = productName; if (unitPrice == null) product.SetUnitPriceNull(); else product.UnitPrice = unitPrice.Value; // Update the product record int rowsAffected = Adapter.Update(product); // Return true if precisely one row was updated, otherwise false return rowsAffected == 1; }
跟原有的UpdateProduct方法类似,这个重载方法首先检查是否在数据库中存在一个指定ProductID的产品。如果不存在,它返回false,指示更新产品信息的请求失败。否则,它因而更新现存的产品记录的ProductName和UnitPrice字段并通过调用TableAdapter的Update()方法提交此更新,传入ProductsRow实例。
通过这些对我们的ProductsBLL类的额外处理,我们现在可以创建一个简单GridView界面。打开EditInsertDelete文件夹中的DataModificationEvents.aspx ,添加一个GridView控件到页面。新建一个ObjectDataSource并配置它使用ProductsBLL类,它的Select()方法映射到GetProducts ,Update()方法映射到仅接受productName、unitPrice和productID输入参数的UpdateProduct方法重载。图2展示了映射ObjectDataSource的Update()方法到ProductsBLL类的新的UpdateProduct方法重载时的数据源配置向导。
图 2: 映射ObjectDataSource的Update()方法到新的UpdateProduct重载
因为我们的例子将仅仅需要编辑数据的能力,不需要插入或删除记录,那么花些时间明确地指明该ObjectDataSource的Insert()和Delete()方法不会映射到ProductsBLL类的任何方法--通过到INSERT和DELETE的tab页并从下拉列表中选择(无)。
图 3: 在INSERT和DELETE的Tab页,从下拉列表中选择(无)
完成了此向导后,从GridView的职能标记里勾选上启用编辑。
完成了此数据源配置向导并绑定到GridView后,Visual Staudio就已经添加好它们的声明语法。到源视图察看该ObjectDataSource的声明标记,它将如下所示:
[System.ComponentModel.DataObjectMethodAttribute (System.ComponentModel.DataObjectMethodType.Update, false)] public bool UpdateProduct(string productName, decimal? unitPrice, int productID) { Northwind.ProductsDataTable products = Adapter.GetProductByProductID(productID); if (products.Count == 0) // no matching record found, return false return false; Northwind.ProductsRow product = products[0]; product.ProductName = productName; if (unitPrice == null) product.SetUnitPriceNull(); else product.UnitPrice = unitPrice.Value; // Update the product record int rowsAffected = Adapter.Update(product); // Return true if precisely one row was updated, otherwise false return rowsAffected == 1; }
因为ObjectDataSource的Insert()和Delete()方法没有映射,也就没有InsertParameters或者DeleteParameters片段。此外,因为方法映射到UpdateProduct方法重载,它仅接受3个输入参数,UpdateParameters片段也就仅包含3个Parameter实例。
注意到ObjectDataSource的属性被设置为original_{0}。这个属性是使用数据源配置向导时Visual Studio自动设置的。因此,由于我们的BLL方法不需要传入原始的ProductID值,从ObjectDataSource的声明语法中删除所有这些属性设置。
注意: If you如果你只是简单地在设计视图里从属性窗口清除OldValuesParameterFormatString属性的值,这个属性会依旧存在于声明语法里,但会被设置为一个空字符串。要么从声明语法里把该属性通通删掉,要么从属性窗口,设置它的值为默认值:{0} 。
虽然ObjectDataSource仅仅包含对productName、unitPrice和productID的UpdateParameters,Visual Studio还是为产品的每一个字段添加一个绑定列或CheckBoxField。
图 4: 对应产品的每一个字段,GridView都包含一个BoundField或CheckBoxField
当最终用户编辑一个产品并点击它的保存按钮(Update),该GridView遍历那些可编辑的字段,然后把用户输入的值赋值到ObjectDataSource的UpdateParameters集合里对应的参数。如果没有对应的参数,GridView则添加一个到参数集合里。因此,如果我们的GridView包含对应产品的所有字段的绑定列或CheckBox列,那么ObjectDataSource则会调用能接受这些参数的方法重载,而不顾ObjectDataSource的声明标记指定只接受3个输入参数的事实(见图5)。类似地,如果存在某些由GridView非只读列构成的组合,而没有一个UpdateProduct重载能接受相应的参数,则会在试图保存时引发一个异常。
图 5: GridView将添加参数到ObjectDataSource的UpdateParameters集合
为了确保ObjectDataSource调用仅接受productName、unitPrice和productID参数的UpdateProduct重载,我们需要限定GridView仅包含ProductName和UnitPrice这两个可编辑的列。这可以通过删除其他绑定列和CheckBox列,或者把这些列的ReadOnly属性设置为true实现,又或者上述两种方法结合使用。在本节里让我们简单地删除除了ProductName和UnitPrice绑定列以外的GridView的所有列,然后,GridView的声明标记将如下所示:
<asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="False" DataKeyNames="ProductID" DataSourceID="ObjectDataSource1"> <Columns> <asp:CommandField ShowEditButton="True" /> <asp:BoundField DataField="ProductName" HeaderText="ProductName" SortExpression="ProductName" /> <asp:BoundField DataField="UnitPrice" HeaderText="UnitPrice" SortExpression="UnitPrice" /> </Columns> </asp:GridView>
即使重载预期3个输入参数,在我们的GridView里仅包含两个绑定列。这是因为productID输入参数是一个主键(primary key)的值,它通过当前编辑行的DataKeyNames属性传入值。
我们的GridView控件,连同这个UpdateProduct重载,允许一个用户仅仅编辑产品的名称和单价而不会丢失产品的其他字段。
图 6: 仅允许编辑 ProductName和UnitPrice的界面
完善UnitPrice格式化
虽然图6所示的GridView实例能够正常工作,UnitPrice字段还完全未被格式化,导致显示价格时缺少货币符号,还有4个小数位。为了在非编辑状态的行应用货币格式,只需要简单地设置UnitPrice绑定列的DataFormatString属性为{0:c},还有它的HtmlEncode属性为false 。
图 7: 设置UnitPrice绑定列的DataFormatString和HtmlEncode属性
通过此项更改,处于非编辑状态的行将价格格式化为货币;然而,当前编辑的行,仍然显示为没有货币符号并保留四位小数。
图 8: 现在,非编辑状态的行格式化为货币值
通过设置此绑定列的ApplyFormatInEditMode属性为true(默认为false),可以把DataFormatString属性里指定的格式化指令应用到编辑界面。
图 9: 设置此绑定列的ApplyFormatInEditMode属性为true
通过这个更改,当前编辑行里显示的UnitPrice值也被格式化为货币。
图 10: 现在,当前编辑行的值也被格式化为货币
然而,如果把产品价格更新为文本框里带货币符号的值– 例如$19.00 – 则会抛出一个FormatException异常。当GridView尝试把用户提供的值赋值到ObjectDataSource的UpdateParameters集合,它无法把UnitPrice字符串“$19.00”转换成参数要求的decimal类型(见图11)。为了补救这个问题我们可以为GridView的RowUpdating事件添加一个事件处理程序并让它把用户输入的UnitPrice格式化为货币格式的decimal 。
这个GridView的RowUpdating事件接受的第二个参数是一个GridViewUpdateEventArgs类型的对象,它包含一个NewValues字典,当中的每一个属性保存着用户输入的值,准备赋值到ObjectDataSource的UpdateParameters集合。我们可以重写现有的NewValues集合中的UnitPrice值为一个货币金额,通过下面的事件处理程序中的代码进行解析:
protected void GridView1_RowUpdating(object sender, GridViewUpdateEventArgs e) { if (e.NewValues["UnitPrice"] != null) e.NewValues["UnitPrice"] = decimal.Parse(e.NewValues["UnitPrice"].ToString(), System.Globalization.NumberStyles.Currency); }
如果用户提供了一个诸如“$19.00”的UnitPrice值,这个值会被通过Decimal.Parse计算并解析为货币金额的值重写。这将正确的解析货币值,无论是货币符号、逗号、小数点、等等,并使用System.Globalization命名空间的NumberStyles枚举。
图11展示因用户输入的UnitPrice值带有货币符号引起的问题,还展示了如果利用GridView的RowUpdating事件处理程序从而正确地解析这样的输入。
图 11: 现在,当前编辑行的UnitPrice值被格式化为货币金额
第二步: 阻止NULL UnitPrices
虽然数据库被配置为允许Products表里的UnitPrice字段为NULL值,但我们可能希望防止用户访问这个特别的页面并指定一个空的UnitPrice值。更确切地说,如果用户在编辑一个产品行记录时忘记输入一个UnitPrice值, 与其保存这个结果到数据库,我们还不如给用户显示一个提示信息,自始至终这个页面,任何的对产品的编辑必须指定一个价格。
传入到GridView的事件处理程序的GridViewUpdateEventArgs对象包含一个Cancel属性,如果把它设置为true,则中止这个更新过程。让我们扩展RowUpdating事件处理程序,设置e.Cancel为true并显示一个信息说明为什么在NewValues集合里的UnitPrice值为null 。
首先,添加一个Label服务器控件到页面并命名为MustProvideUnitPriceMessage 。这个Label控件将显示用户是否在更新一个产品时忘记指定一个UnitPrice值。设置这个Label的Text属性为“您必须为产品提供一个价格。”。我也已经在Styles.css文件中添加了一个名为Warning的新CSS类别,定义如下:
.Warning { color: Red; font-style: italic; font-weight: bold; font-size: x-large; }
最后,设置Label的CssClass属性为Warning 。这样设计器中将在GridView上方显示这个红色、斜体、加粗、并且较大字体的警告信息。
图 12: 一个Label控件已被添加到GridView上方
默认地,这个Label控件隐藏,那么在Page_Load事件处理程序中设置它的Visible属性为false 。
protected void Page_Load(object sender, EventArgs e) { MustProvideUnitPriceMessage.Visible = false; }
如果用户尝试更新一个产品并不指定UnitPrice值,我们希望取消这个更新并显示警告标签。在GridView的RowUpdating事件处理程序中增加如下的代码:
protected void GridView1_RowUpdating(object sender, GridViewUpdateEventArgs e) { if (e.NewValues["UnitPrice"] != null) { e.NewValues["UnitPrice"] = decimal.Parse(e.NewValues["UnitPrice"].ToString(), System.Globalization.NumberStyles.Currency); } else { // Show the Label MustProvideUnitPriceMessage.Visible = true; // Cancel the update e.Cancel = true; } }
如果一个用户试图保存一个产品并不指定价格,这个更新操作就被取消并显示一个有用的提示信息。虽然数据库(和业务逻辑)允许NULL值的UnitPrice,但这个特定的ASP.NET页面不允许。
图 13: 用户不能让UnitPrice为空
到目前为止我们看过了如何使用GridView的RowUpdating事件来编程改变赋值到ObjectDataSource的UpdateParameters集合的参数值,还有如何完全地取消这个更新过程。这些思想也可以延展至DetailsView和FormView控件并且应用到插入或删除。
这些任务也可以在ObjectDataSource这一层通过的Inserting、Updating和Deleting事件处理程序完成。这些事件在隐含对象的关联方法被调用前触发,并且提供一个更改输入参数集合或者完全取消此操作的最后机会。对应这3个事件的事件处理程序传入一个ObjectDataSourceMethodEventArgs 类型的对象,我们对它的这两个属性感兴趣:
·Cancel,它如果被设置为true,取消执行中的操作
·InputParameters,它是InsertParameters、UpdateParameters或DeleteParameters的集合,这取决于这是Inserting、Updating、还是Deleting事件的事件处理程序
为了举例说明在ObjectDataSource这一层如何处理参数的值,让我们在页面里包含一个DeailsView,它允许用户新增一个新的产品记录。这个DetailsView将用作提供一个快捷的方式添加一个新的产品记录到数据库。为了在新增产品时保持界面一致,我们仅允许用户输入ProductName和UnitPrice字段。作为默认值,这些在DetailsView的插入界面提供的值将被设置为一个数据库的NULL值。不过,我们可以使用ObjectDataSource的Inserting事件注入不同的默认值,正如我们马上要看的。
第三步: 提供一个添加新产品的界面
在GridView的上方,从工具箱里拖放一个DetailsView控件到设计器,清空它的Height和Width属性,并将它绑定到页面中已经存在的ObjectDataSource 。这将为产品的每一个字段添加一个绑定列或CheckBox列。因为我们希望用这个DetailsView控件来添加新产品,我们需要从它的职能标记里勾选启用插入这一项;然而并没有这一项,这是因为此ObjectDataSource的Insert()方法还没有映射到ProductsBLL类的方法(回想起我们在配置数据源时设置了这个映射为(无) – 见图3)。
为了再次配置这个ObjectDataSource,从它的职能标记中选择“配置数据源”的链接,载入向导。第一屏允许你更改ObjectDataSource绑定到的隐含对象;让它依旧是ProductsBLL 。下一屏列出从ObjectDataSource到隐含对象的方法的映射。尽管我们已经明确地指定Insert()和Delete()不映射到任何方法,然而如果你到INSERT和DELETE的tab页你还是会看到那里有一个映射。这是因为ProductsBLL的AddProduct和DeleteProduct方法使用了DataObjectMethodAttribute指出它们分别是为Insert()和Delete()服务的默认的方法。因此,ObjectDataSource向导在每次运行时都会自动地选择它们,除非那里被明确地指定了一个别的值。
让Insert()方法依旧指向AddProduct方法,不过再次从DELETE的tab页的下拉列表中选择“(无)”。
图 14: 从INSERT的Tab页的下拉列表中选择AddProduct方法
图 15: 从DELETE的Tab页的下拉列表中选择(无)
完成了这些更改后,该ObjectDataSource的声明语法中将包含一个InsertParameters集合,如下所示:
<asp:ObjectDataSource ID="ObjectDataSource1" runat="server" SelectMethod="GetProducts" TypeName="ProductsBLL" UpdateMethod="UpdateProduct" OnUpdating="ObjectDataSource1_Updating" InsertMethod="AddProduct" OldValuesParameterFormatString="original_{0}"> <UpdateParameters> <asp:Parameter Name="productName" Type="String" /> <asp:Parameter Name="unitPrice" Type="Decimal" /> <asp:Parameter Name="productID" Type="Int32" /> </UpdateParameters> <InsertParameters> <asp:Parameter Name="productName" Type="String" /> <asp:Parameter Name="supplierID" Type="Int32" /> <asp:Parameter Name="categoryID" Type="Int32" /> <asp:Parameter Name="quantityPerUnit" Type="String" /> <asp:Parameter Name="unitPrice" Type="Decimal" /> <asp:Parameter Name="unitsInStock" Type="Int16" /> <asp:Parameter Name="unitsOnOrder" Type="Int16" /> <asp:Parameter Name="reorderLevel" Type="Int16" /> <asp:Parameter Name="discontinued" Type="Boolean" /> </InsertParameters> </asp:ObjectDataSource>
再次运行向导导致重新添加OldValuesParameterFormatString属性。把这个属性设置为默认值({0})或者从声明语法中把它们完全删除。
随着ObjectDataSource提供插入数据的能力,现在DetailsView的职能标记里包含“启用插入”的checkbox;回到设计器并勾选这一项。然后,减少DetailsView的列直道它只包含两个绑定列- ProductName和UnitPrice ,还有一个CommandField。此时DetailsView的声明语法将如下所示:
<asp:DetailsView ID="DetailsView1" runat="server" AutoGenerateRows="False" DataKeyNames="ProductID" DataSourceID="ObjectDataSource1"> <Fields> <asp:BoundField DataField="ProductName" HeaderText="ProductName" SortExpression="ProductName" /> <asp:BoundField DataField="UnitPrice" HeaderText="UnitPrice" SortExpression="UnitPrice" /> <asp:CommandField ShowInsertButton="True" /> </Fields> </asp:DetailsView>
图16展现的是在此时通过浏览器查看的页面。正如你所看到的,DetailsView列出第一个产品(Chai)的名称和价格。不过,我们需要的是一个插入界面来提供一个用户快速增加一个新产品到数据库的手段。
图16: 该DetailsView当前呈现在只读模式
为了展示插入模式的DetailsView我们需要设置DefaultMode属性为Inserting。这让DetailsView在第一次访问和插入一条新纪录之后,它都呈现在插入模式。如图17所示,这样的一个DetailsView提供了一个快捷的界面用以添加新记录。
图 17: 这个DetailsView提供了一个快速添加新产品的界面
当用户输入一个产品名称和价格(例如“Acme Wate”和1.99,如图17)并点击插入按钮,发生回传并且开始插入的工作流程,直到最后添加一个新产品记录到数据库。DetailsView维持在它的插入界面,并且GridView自动地重现绑定到它的数据源,目的是为了包含新增加的产品,如图18所示。
图 18: 产品“Acme Water”已经被添加到数据库
虽然图18的GridView中没有显示出来,DetailsView界面里缺少的产品字段 – CategoryID、SupplierID、QuantityPerUnit、等等 – 被赋上一个数据库NULL值。你可以通过履行下面一个步骤来看到这一点:
1.到Visual Studio的服务器资源管理器
2.展开NORTHWND.MDF数据库节点
3.在Products数据表节电上右键点击
4.选择“显示表数据”
这将列出Products表中的所有记录。如图19所示,除了ProductID、ProductName和UnitPrice字段,我们的新产品的其它字段都为NULL值。
图 19: 未在DetailsView中提供的产品的其它字段被赋予NULL值
我们可能希望给予一个或几个这些字段的值一个默认值,而不是NULL,要么是因为NULL并非最佳的默认项,要么是因为数据库字段本身不允许NULL值。为了实现这一点,我们可以编程设置这些DetailsView的InputParameters集合里的参数值。这项工作可以在DetailsView的ItemInserting事件处理程序中完成,或者在ObjectDataSource的Inserting事件处理程序。因为我们已经看过了如何在数据Web服务器控件中如何使用pre-和post-level的事件,这次让我们探究一下ObjectDataSource的事件。
第四步: 给CategoryID和SupplierID参数赋值
在这里我们假设当我们的应用程序通过这个界面添加一个新产品时应该给CategoryID和SupplierID字段赋值为1。如之前提及的,ObjectDataSource控件有一对pre-和post-level的事件发生在数据更改过程中。当它的Insert()方法被调用时,ObjectDataSource首先触发它的Inserting事件,然后调用它的Insert()方法所映射到的业务方法,最后触发Inserted事件。这个Inserting事件处理程序提供给我们一个处理输入参数或者彻底取消此操作的最后机会。
注意: 在一个真实的应用程序中你很可能希望既让用户指定category和supplier的值,又希望基于一定的标准和业务逻辑的基础上选择这个值(而不是盲目地选择ID为1)。不管如何,这个例子阐明了如何在ObjectDataSource的pre-level的事件中编程设置一个输入参数的值。
花些时间为ObjectDataSource的Inserting事件创建一个事件处理程序。注意到该事件处理程序的第二个输入参数是一个ObjectDataSourceMethodEventArgs类型的对象,它有一个属性来存取参数集合(InputParameters),还有一个属性用来取消此操作(Cancel)。
protected void ObjectDataSource1_Inserting (object sender, ObjectDataSourceMethodEventArgs e) { }
此时,InputParameters属性包含了通过DetailsView赋值到ObjectDataSource的InsertParameters集合。为了修改这些参数里的一个值,简单地使用:e.InputParameters["paramName"] = value 。所以,为了设置CategoryID和SupplierID为1,把Inserting事件处理程序调整为如下:
protected void ObjectDataSource1_Inserting (object sender, ObjectDataSourceMethodEventArgs e) { e.InputParameters["CategoryID"] = 1; e.InputParameters["SupplierID"] = 1; }
这次当我们添加一个新产品(例如Acme Soda),这个新产品的CategoryID和SupplierID字段被赋值为1(见图20)。
图 20: 现在,新产品的CategoryID和SupplierID字段被设置为1
总结
在编辑、插入和删除的过程中,无论数据Web服务器控件还是ObjectDataSource都会发生许多pre-和post-level的事件。在这一节里,我们研究了pre-level的事件,并看到了如何在数据Web服务器控件和ObjectDataSource的事件里使用它们来自定义输入参数或取消当前的数据更改操作。下一节,我们将看看创建和使用post-level的事件的事件处理程序。
祝编程快乐!
作者简介
Scott Mitchell,著有六本ASP/ASP.NET方面的书,是4GuysFromRolla.com的创始人,自1998年以来一直应用微软Web技术。Scott是个独立的技 术咨询顾问,培训师,作家,最近完成了将由Sams出版社出版的新作,24小时内精通ASP.NET 2.0。他的联系电邮为mitchell@4guysfromrolla.com,也可以通过他的博客http://ScottOnWriting.NET与他联系。