创建具有完整设计时支持的自定义 DataSourceControl






4.50/5 (10投票s)
2007 年 1 月 3 日
8分钟阅读

65168

1010
关于创建具有完整设计时支持的 DataSourceControl 的文章。
引言
本文介绍了如何创建自定义 DataSourceControl 并为其添加完整的设计时支持。
背景
本文假定您熟悉 DataSourceControls 并了解设计时基础设施的工作原理。如果不是这样,请查看以下文章。
对于 DataSourceControls
对于设计时基础设施
创建自定义 DataSourceControl
我们将编写的数据源将能够检索数据但不能修改数据。它仅支持 Select 操作。它将类似于 ObjectDataSource,但仅用于检索数据。它将有一个 TypeName 属性来保存类名,以及一个 SelectMethod 属性来保存要在该类中调用的方法。为了避免编写大量代码,我们将仅调用静态方法。我们还将有一个参数集合传递给 SelectMethod (SelectParameters)。我将解释创建 DataSourceControl 时要执行的主要任务,但我不会详细解释方法或属性的作用。代码在复杂区域应该有足够的注释,以便您能够跟上我。
实现 DataSourceControl 时要做的第一件事是选择我们将拥有多少个 DataSourceView 并编写 IDataSource 相关的代码。在此示例中,我们只有一个视图
public class CustomDataSource : DataSourceControl
{
protected static readonly string[] _views = { "DefaultView" };
protected CustomDataSourceView _view;
protected override DataSourceView GetView(string viewName)
{
if ((viewName == null) || ((viewName.Length != 0) &&
(String.Compare(viewName, "DefaultView",
StringComparison.OrdinalIgnoreCase) != 0)))
{
throw new ArgumentException("An invalid view was requested",
"viewName");
}
return View;
}
protected override ICollection GetViewNames()
{
return _views;
}
protected CustomDataSourceView View
{
get
{
if (_view == null) {
_view = new CustomDataSourceView(this, _views[0]);
if (base.IsTrackingViewState) {
((IStateManager)_view).TrackViewState();
}
}
return _view;
}
}
}
由于 CustomDataSourceView 是执行所有工作的类,因此最佳方法是将属性存储在该类中。但是,我们需要在 CustomDataSource 类中公开这些属性,以便用户可以在属性网格中修改它们。因此,我们需要将此添加到 CustomDataSource 类中
[Category("Data"), DefaultValue("")]
public string TypeName
{
get { return View.TypeName; }
set { View.TypeName = value; }
}
[Category("Data"), DefaultValue("")]
public string SelectMethod
{
get { return View.SelectMethod; }
set { View.SelectMethod = value; }
}
[PersistenceMode(PersistenceMode.InnerProperty), Category("Data"),
DefaultValue((string)null), MergableProperty(false),
Editor(typeof(ParameterCollectionEditor),
typeof(UITypeEditor))]
public ParameterCollection SelectParameters
{
get { return View.SelectParameters; }
}
并将此添加到 CustomDataSourceView 类中
public class CustomDataSourceView : DataSourceView, IStateManager
{
protected bool _tracking;
protected CustomDataSource _owner;
protected string _typeName;
protected string _selectMethod;
protected ParameterCollection _selectParameters;
public string TypeName
{
get
{
if (_typeName == null) {
return String.Empty;
}
return _typeName;
}
set
{
if (TypeName != value) {
_typeName = value;
OnDataSourceViewChanged(EventArgs.Empty);
}
}
}
public string SelectMethod
{
get
{
if (_selectMethod == null) {
return String.Empty;
}
return _selectMethod;
}
set
{
if (SelectMethod != value) {
_selectMethod = value;
OnDataSourceViewChanged(EventArgs.Empty);
}
}
}
public ParameterCollection SelectParameters
{
get
{
if (_selectParameters == null)
{
_selectParameters = new ParameterCollection();
_selectParameters.ParametersChanged +=
new EventHandler(ParametersChangedEventHandler);
if (_tracking)
{
((IStateManager)_selectParameters).TrackViewState();
}
}
return _selectParameters;
}
}
protected void ParametersChangedEventHandler(object o, EventArgs e)
{
OnDataSourceViewChanged(EventArgs.Empty);
}
public CustomDataSourceView(CustomDataSource owner, string name)
: base(owner, name)
{
_owner = owner;
}
}
请注意,当属性更改时,会调用 OnDataSourceViewChanged 方法强制重新绑定。另请注意,CustomDataSourceView 类实现了 IStateManager 以支持自定义视图状态管理。在这种情况下,我们使用它来保存 SelectParameters。CustomDataSource 类中的状态管理是
protected override void LoadViewState(object savedState)
{
Pair previousState = (Pair) savedState;
if (savedState == null)
{
base.LoadViewState(null);
}
else
{
base.LoadViewState(previousState.First);
if (previousState.Second != null)
{
((IStateManager) View).LoadViewState(previousState.Second);
}
}
}
protected override object SaveViewState()
{
Pair currentState = new Pair();
currentState.First = base.SaveViewState();
if (_view != null)
{
currentState.Second = ((IStateManager) View).SaveViewState();
}
if ((currentState.First == null) && (currentState.Second == null))
{
return null;
}
return currentState;
}
protected override void TrackViewState()
{
base.TrackViewState();
if (_view != null)
{
((IStateManager) View).TrackViewState();
}
}
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
// handle the LoadComplete event to update select parameters
if (Page != null)
{
Page.LoadComplete += new EventHandler(UpdateParameterValues);
}
}
我们使用一对来存储视图状态。第一个元素用于存储父视图的状态,第二个元素用于存储视图的状态。对于 CustomDataSourceView,状态管理是
bool IStateManager.IsTrackingViewState
{
get { return _tracking; }
}
void IStateManager.LoadViewState(object savedState)
{
LoadViewState(savedState);
}
object IStateManager.SaveViewState()
{
return SaveViewState();
}
void IStateManager.TrackViewState()
{
TrackViewState();
}
protected virtual void LoadViewState(object savedState)
{
if (savedState != null)
{
if (savedState != null)
{
((IStateManager)SelectParameters).LoadViewState(savedState);
}
}
}
protected virtual object SaveViewState()
{
if (_selectParameters != null)
{
return ((IStateManager)_selectParameters).SaveViewState();
}
else
{
return null;
}
}
protected virtual void TrackViewState()
{
_tracking = true;
if (_selectParameters != null)
{
((IStateManager)_selectParameters).TrackViewState();
}
}
我们需要在每次请求时评估 SelectParameters,因为如果参数已更改,我们就必须重新绑定
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
// handle the LoadComplete event to update select parameters
if (Page != null)
{
Page.LoadComplete += new EventHandler(UpdateParameterValues);
}
}
protected virtual void UpdateParameterValues(object sender, EventArgs e)
{
SelectParameters.UpdateValues(Context, this);
}
唯一剩下的工作就是从 CustomDataSourceView 中实际选择
protected override IEnumerable ExecuteSelect(
DataSourceSelectArguments arguments)
{
// if there isn't a select method, error
if (SelectMethod.Length == 0)
{
throw new InvalidOperationException(
_owner.ID + ": There isn't a SelectMethod defined");
}
// check if we support the capabilities the data bound control expects
arguments.RaiseUnsupportedCapabilitiesError(this);
// gets the select parameters and their values
IOrderedDictionary selParams =
SelectParameters.GetValues(System.Web.HttpContext.Current, _owner);
// gets the data mapper
Type type = BuildManager.GetType(_typeName, false, true);
if (type == null)
{
throw new NotSupportedException(_owner.ID + ": TypeName not found!");
}
// gets the method to call
MethodInfo method = type.GetMethod(SelectMethod,
BindingFlags.Public | BindingFlags.Static);
if (method == null)
{
throw new InvalidOperationException(
_owner.ID + ": SelectMethod not found!");
}
// creates a dictionary with the parameters to call the method
ParameterInfo[] parameters = method.GetParameters();
IOrderedDictionary paramsAndValues =
new OrderedDictionary(parameters.Length);
// check that all parameters that the method needs are
// in the SelectParameters
foreach (ParameterInfo currentParam in parameters)
{
string paramName = currentParam.Name;
if (!selParams.Contains(paramName))
{
throw new InvalidOperationException(_owner.ID +
": The SelectMethod doesn't have a parameter for " +
paramName);
}
}
// save the parameters and its values into a dictionary
foreach (ParameterInfo currentParam in parameters)
{
string paramName = currentParam.Name;
object paramValue = selParams[paramName];
if (paramValue != null)
{
// check if we have to convert the value
// if we have a string value that needs conversion
if (!currentParam.ParameterType.IsInstanceOfType(paramValue) &&
(paramValue is string))
{
// try to get a type converter
TypeConverter converter =
TypeDescriptor.GetConverter(currentParam.ParameterType);
if (converter != null)
{
try
{
// try to convert the string using the type converter
paramValue = converter.ConvertFromString(null,
System.Globalization.CultureInfo.CurrentCulture,
(string)paramValue);
}
catch (Exception)
{
throw new InvalidOperationException(
_owner.ID + ": Can't convert " +
paramName + " from string to " +
currentParam.ParameterType.Name);
}
}
}
}
paramsAndValues.Add(paramName, paramValue);
}
object[] paramValues = null;
// if the method has parameters, create an array to
// store parameters values
if (paramsAndValues.Count > 0)
{
paramValues = new object[paramsAndValues.Count];
for (int i = 0; i < paramsAndValues.Count; i++)
{
paramValues[i] = paramsAndValues[i];
}
}
object returnValue = null;
try
{
// call the method
returnValue = method.Invoke(null, paramValues);
}
catch (Exception e)
{
throw new InvalidOperationException(
_owner.ID + ": Error calling the SelectMethod", e);
}
return (IEnumerable)returnValue;
}
此代码距离生产代码还差得很远。例如,可能存在多个与 SelectMethod 同名但参数不同的方法。参数转换对引用类型和泛型类型处理不佳。不支持 DataSet 和 DataTable 类型,因为它们不实现 IEnumerable。您还需要提取底层的 DataView 来处理它们。但是,添加所有这些“额外功能”会使事情更难理解。
现在我们将为我们的 CustomDataSource 控件创建一个设计器。DataSourceDesigner 需要执行的主要任务是
- 配置数据源
- 公开架构信息
此外,我们必须公开至少一个 DesignerDataSourceView。DataSource 控件公开一个或多个 DataSourceView,而 DataSourceDesigner 公开一个或多个 DesignerDataSourceView
private static readonly string[] _views = { "DefaultView" };
public override DesignerDataSourceView GetView(string viewName)
{
if ((viewName == null) || ((viewName.Length != 0) &&
(String.Compare(viewName, "DefaultView",
StringComparison.OrdinalIgnoreCase) != 0)))
{
throw new ArgumentException("An invalid view was requested",
"viewName");
}
return View;
}
public override string[] GetViewNames()
{
return _views;
}
正如您所看到的,代码与自定义数据源中用于公开自定义数据源视图的代码非常相似。由于我们的数据源仅检索数据,因此 DesignerDataSourceView 的默认实现足以满足所有 CanXXX 属性。为了快速配置我们的自定义 DataSource,我们将提供一个 GUI,允许我们使用下拉列表选择 TypeName 和 SelectMethod

为了能够显示“配置数据源”对话框,我们需要重写 CanConfigure 属性并实现 Configure 方法
public override bool CanConfigure
{
get { return true; }
}
public override void Configure()
{
_inWizard = true;
// generate a transaction to undo changes
InvokeTransactedChange(Component,
new TransactedChangeCallback(ConfigureDataSourceCallback),
null, "ConfigureDataSource");
_inWizard = false;
}
protected virtual bool ConfigureDataSourceCallback(object context)
{
try
{
SuppressDataSourceEvents();
IServiceProvider provider = Component.Site;
if (provider == null)
{
return false;
}
// get the service needed to show a form
IUIService UIService =
(IUIService) provider.GetService(typeof(IUIService));
if (UIService == null)
{
return false;
}
// shows the form
ConfigureDataSource configureForm =
new ConfigureDataSource(provider, this);
if (UIService.ShowDialog(configureForm) == DialogResult.OK)
{
OnDataSourceChanged(EventArgs.Empty);
return true;
}
}
finally
{
ResumeDataSourceEvents();
}
return false;
}
由于 GUI 将一次更改多个属性,因此我们必须创建事务性更改以提供撤消功能。该窗体使用类型发现服务而不是反射来填充第一个下拉列表,其中包含所有可用类型。为什么?因为使用反射,我们只能获取已编译程序集的所有类型。但是,我们可以在未编译项目的情况下添加更多类型。我们还可以有不编译的类型,类型发现服务也会显示它们。因此,使用类型发现服务而不是反射要好得多。
在代码中,我们没有删除很可能不是 TypeName 属性候选类型的类型——即泛型类型、接口——以使代码尽可能简单
private void DiscoverTypes()
{
// try to get a reference to the type discovery service
ITypeDiscoveryService discovery = null;
if (_component.Site != null)
{
discovery =
(ITypeDiscoveryService)_component.Site.GetService(
typeof(ITypeDiscoveryService));
}
// if the type discovery service is available
if (discovery != null)
{
// saves the cursor and sets the wait cursor
Cursor previousCursor = Cursor.Current;
Cursor.Current = Cursors.WaitCursor;
try
{
// gets all types using the type discovery service
ICollection types = discovery.GetTypes(typeof(object), true);
ddlTypes.BeginUpdate();
ddlTypes.Items.Clear();
// adds the types to the list
foreach (Type type in types)
{
TypeItem typeItem = new TypeItem(type);
ddlTypes.Items.Add(typeItem);
}
}
finally
{
Cursor.Current = previousCursor;
ddlTypes.EndUpdate();
}
}
}
TypeItem 类是一个用于在下拉列表中存储类型的类。当从第一个下拉列表中选择一个类型时,第二个下拉列表将填充选定类型的相关方法
private void FillMethods()
{
// saves the cursor and sets the wait cursor
Cursor previousCursor = Cursor.Current;
Cursor.Current = Cursors.WaitCursor;
try
{
// gets all public methods (instance + static)
MethodInfo[] methods =
CustomDataSourceDesigner.GetType(_component.Site, TypeName).
GetMethods(BindingFlags.Public | BindingFlags.Static |
BindingFlags.Instance | BindingFlags.FlattenHierarchy);
ddlMethods.BeginUpdate();
ddlMethods.Items.Clear();
// adds the methods to the dropdownlist
foreach (MethodInfo method in methods)
{
MethodItem methodItem = new MethodItem(method);
ddlMethods.Items.Add(methodItem);
}
}
finally
{
Cursor.Current = previousCursor;
ddlMethods.EndUpdate();
}
}
为了从窗体快速获取和设置 TypeName 和 SelectMethod,我们在窗体中定义了以下属性
internal string TypeName
{
get
{
// gets the selected type
TypeItem selectedType = ddlTypes.SelectedItem as TypeItem;
// return the selected type
if (selectedType != null)
{
return selectedType.Name;
}
else
{
return String.Empty;
}
}
set
{
// iterate through all the types searching for the requested type
foreach (TypeItem item in ddlTypes.Items)
{
// if we have found it, select it
if (String.Compare(item.Name, value, true) == 0)
{
ddlTypes.SelectedItem = item;
break;
}
}
}
}
internal string SelectMethod
{
get
{
// gets the select method
string methodName = String.Empty;
if (MethodInfo != null)
{
methodName = MethodInfo.Name;
}
return methodName;
}
set
{
// iterate through all the types searching for the requested type
foreach (MethodItem item in ddlMethods.Items)
{
// if we have found it, select it
if (String.Compare(item.MethodInfo.Name, value, true) == 0)
{
ddlMethods.SelectedItem = item;
break;
}
}
}
}
internal MethodInfo MethodInfo
{
get
{
MethodItem item = ddlMethods.SelectedItem as MethodItem;
if (item == null)
{
return null;
}
return item.MethodInfo;
}
}
请注意,为了简化代码,当设置 SelectMethod 属性时,下拉列表中的选定方法将是第一个名称与 SelectMethod 相同的。为了简化代码,不对参数进行检查,但对于生产代码,您可能需要检查参数是否匹配。
在 FillMethods 方法中,使用 GetType 方法获取类型,该方法使用解析服务。这是因为我们之前指定使用类型发现服务的原因。为了简化代码,我们没有删除一些肯定不是正确方法的函数,例如属性的 getter 和 setter 或抽象方法。
internal static Type GetType(IServiceProvider serviceProvider,
string typeName)
{
// try to get a reference to the resolution service
ITypeResolutionService resolution =
(ITypeResolutionService)serviceProvider.
GetService(typeof(ITypeResolutionService));
if (resolution == null)
{
return null;
}
// try to get the type
return resolution.GetType(typeName, false, true);
}
当用户在“配置数据源”窗体中单击“确定”按钮时,执行的代码是
private void bOK_Click(object sender, EventArgs e) { // if the type has changed, save it if (String.Compare(TypeName, _component.TypeName, false) != 0) { TypeDescriptor.GetProperties( _component)["TypeName"].SetValue(_component, TypeName); } // if the select method has changed, save it if (String.Compare(SelectMethod, _component.SelectMethod, false) != 0) { TypeDescriptor.GetProperties( _component)["SelectMethod"].SetValue(_component, SelectMethod); } // if there is method selected, refresh the schema if (MethodInfo != null) { _designer.RefreshSchemaInternal(MethodInfo.ReflectedType, MethodInfo.Name, MethodInfo.ReturnType, true); } }
我们保存 Type 和 SelectMethod 并刷新架构。要提供架构信息,我们必须在 CanRefreshSchema 方法中返回 true,并实现 RefreshSchema 方法。当我们提供架构信息时,控件可以提供字段选择器——例如 GridView 的列——并基于架构信息生成模板,例如绑定到我们的数据源控件的 DataList。但是,我们不能为 CanRefreshSchema 返回 true,因为只有在用户配置了数据源后,我们才能返回架构信息
public override bool CanRefreshSchemablic override bool CanRefreshSchema
{
get
{
// if a type and the select method have been
// specified, the schema can be refreshed
if (!String.IsNullOrEmpty(TypeName) && !String.IsNullOrEmpty(
SelectMethod))
{
return true;
}
else
{
return false;
}
}
}
要实现 RefreshSchema 方法,我们需要提取架构信息并生成 SchemaRefreshed 事件。如果数据源控件可以提供架构信息,则架构信息将从底层 DesignerDataSourceView 的 Schema 属性中检索。但是,SchemaRefreshed 事件不一定每次都触发,只有在数据源返回不同架构时才触发。要了解其重要性,请考虑:如果数据源绑定到 GridView,每次触发 RefreshSchema 事件时,设计器都会询问是否需要重新生成列和数据键。因此,我们只对架构发生变化时触发 SchemaRefreshed 事件感兴趣。我们使用设计器状态来存储之前的架构。当调用 RefreshSchema 方法时,我们将检查架构是否发生变化,仅在此情况下触发 SchemaRefreshed 事件。与 RefreshSchema 方法相关的代码是
internal IDataSourceViewSchema DataSourceSchema
{
get
{
return DesignerState["DataSourceSchema"] as IDataSourceViewSchema;
}
set
{
DesignerState["DataSourceSchema"] = value;
}
}
public override void RefreshSchema(bool preferSilent)
{
// saves the old cursor
Cursor oldCursor = Cursor.Current;
try
{
// ignore data source events while refreshing the schema
SuppressDataSourceEvents();
try
{
Cursor.Current = Cursors.WaitCursor;
// gets the Type used in the DataSourceControl
Type type = GetType(Component.Site, TypeName);
// if we can't find the type, return
if (type == null)
{
return;
}
// get all the methods that can be used as the select method
MethodInfo[] methods =
type.GetMethods(BindingFlags.FlattenHierarchy |
BindingFlags.Static | BindingFlags.Instance |
BindingFlags.Public);
MethodInfo selectedMethod = null;
// iterates through the methods searching for the select method
foreach (MethodInfo method in methods)
{
// if the method is named as the selected method, select it
if (IsMatchingMethod(method, SelectMethod))
{
selectedMethod = method;
break;
}
}
// if the SelectMethod was found, save the type information
if (selectedMethod != null)
{
RefreshSchemaInternal(type, selectedMethod.Name,
selectedMethod.ReturnType, preferSilent);
}
}
finally
{
// restores the cursor
Cursor.Current = oldCursor;
}
}
finally
{
// resume data source events
ResumeDataSourceEvents();
}
}
internal void RefreshSchemaInternal(Type typeName,
string method, Type returnType, bool preferSilent)
{
// if all parameters are filled
if ((typeName != null) && (!String.IsNullOrEmpty(method)) &&
(returnType != null))
{
try
{
// gets the old schema
IDataSourceViewSchema oldSchema = DataSourceSchema;
// gets the schema of the return type
IDataSourceViewSchema[] typeSchemas =
new TypeSchema(returnType).GetViews();
// if we can't get schema information from the type, exit
if ((typeSchemas == null) || (typeSchemas.Length == 0))
{
DataSourceSchema = null;
return;
}
// get a view of the schema
IDataSourceViewSchema newSchema = typeSchemas[0];
// if the schema has changed, raise the schema refreshed event
if (!DataSourceDesigner.ViewSchemasEquivalent(
oldSchema, newSchema))
{
DataSourceSchema = newSchema;
OnSchemaRefreshed(EventArgs.Empty);
}
}
catch (Exception e)
{
if (!preferSilent)
{
ShowError(DataSourceComponent.Site,
"Cannot retrieve type schema for " +
returnType.FullName + ". " + e.Message);
}
}
}
}
如您所见,我们获取 SelectMethod 的 MethodInfo 并获取返回类型。所有暴露架构信息的繁重工作都由框架帮助类 TypeSchema 完成。有关 TypeSchema 类的更多信息,请参阅开头的文章。DesignerDataSource 视图公开保存的架构
public override IDataSourceViewSchema Schema
{
get
{
// if a type and the select method have been
// specified, the schema information is available
if (!String.IsNullOrEmpty(_owner.TypeName) && !String.IsNullOrEmpty(
_owner.SelectMethod))
{
return _owner.DataSourceSchema;
}
else
{
return null;
}
}
}
需要澄清的最后一件事是我们重写了 CustomDataSourceDesigner 类中的 PreFilterProperties 方法,以修改 TypeName 和 SelectMethod 属性的工作方式。这是因为当这些属性中的任何一个发生变化时,底层数据源和架构很可能会发生变化。因此,我们必须将其通知给关联的设计器
protected override void PreFilterProperties(IDictionary properties)
{
base.PreFilterProperties(properties);
// filters the TypeName property
PropertyDescriptor typeNameProp =
(PropertyDescriptor)properties["TypeName"];
properties["TypeName"] = TypeDescriptor.CreateProperty(base.GetType(),
typeNameProp, new Attribute[0]);
// filters the SelectMethod property
PropertyDescriptor selectMethodProp =
(PropertyDescriptor)properties["SelectMethod"];
properties["SelectMethod"] =
TypeDescriptor.CreateProperty(base.GetType(),
selectMethodProp, new Attribute[0]);
}
public string TypeName
{
get
{
return DataSourceComponent.TypeName;
}
set
{
// if the type has changed
if (String.Compare(DataSourceComponent.TypeName, value, false) != 0)
{
DataSourceComponent.TypeName = value;
// notify to the associated designers that this
// component has changed
if (CanRefreshSchema)
{
RefreshSchema(true);
}
else
{
OnDataSourceChanged(EventArgs.Empty);
}
UpdateDesignTimeHtml();
}
}
}
public string SelectMethod
{
get
{
return DataSourceComponent.SelectMethod;
}
set
{
// if the select method has changed
if (String.Compare(DataSourceComponent.SelectMethod,
value, false) != 0)
{
DataSourceComponent.SelectMethod = value;
// notify to the associated designers that this
// component has changed
if (CanRefreshSchema && !_inWizard)
{
RefreshSchema(true);
}
else
{
OnDataSourceChanged(EventArgs.Empty);
}
UpdateDesignTimeHtml();
}
}
}
该设计器和数据源控件的完整源代码可在本文的下载文件中找到。正如您所看到的,为数据源控件添加设计时支持并不十分复杂,但您需要编写相当多的代码——在此示例中为 1300 行——即使对于简单的数据源也是如此。您的数据源越复杂,您需要编写的代码就越多。
关注点
本文涵盖的设计时支持是最常见的场景:数据源控件在运行时不渲染任何 HTML,并且仅提供一个窗体来配置数据源。但是,数据源控件有时也可以渲染 HTML——请参阅 PagerDataSource——它不仅是数据提供者,还是数据使用者。如果您想使用数据源控件渲染 HTML,您还有很多工作要做,因为框架没有能够同时渲染 HTML 的数据源控件的基类。
历史
- 01/03/2007 - 初始版本
- 06/19/2007 - 文章已编辑并移至 CodeProject.com 主文章库