在不重新部署的情况下为 .NET 客户端应用程序添加新的数据库报表 - 动态 LINQ 和反射技巧的奥德赛






4.25/5 (5投票s)
本文解决了每次创建新的存储过程数据库报表时都需要重新部署所有内容的难题。
引言:我想实现什么目标?
挑战在于开发一种可扩展的报表架构,使用 WPF、Silverlight 或类似的客户端技术以及 Web 服务,以便能够在不重新部署客户端或 Web 服务的情况下添加新的数据库报表(例如 SQL Server 存储过程)到系统中。
免责声明:本文已通过 WPF 和 .NET 3.5 测试。我希望这个原理也能推广到其他框架。我很想听听您是否有任何关于替代方法的想法——如果有,请留下评论!本文及示例旨在展示一个概念验证,而不是提供一个完全可靠且可用于生产环境的代码库。自行承担风险。
目录
- 背景
- 为什么我们通常需要重新部署?
- 理想的 Web 服务 API
- 问题
- 解决方案
- 通用的返回类型
- 使用 LINQ 动态调用存储过程
- 包含报表元数据
- 我们的通用 Web 服务操作
- 客户端如何调用 Web 服务
- 从 Web 服务返回的内容
- 动态报表解释器
- 生成源代码
- 编译动态类并获取类型
- 什么时候列表不是列表?
- 实例化并填充我们动态类型的实例
- 将扁平的对象列表“解压”到数组中
- 事后思考
- 说明
- 如何尝试演示
- 历史
背景
为什么我们通常需要重新部署?
您的基本架构可能如下所示
数据库存储过程 -> LINQ DBML -> Web 服务 -> 客户端
当创建一个新的存储过程时,客户端需要执行以下步骤才能调用它
- 将过程添加到 DBML 文件
- 添加 Web 服务方法
- 重新构建并重新部署 Web 服务
- 更新客户端中的服务引用
- 向客户端添加代码以调用新过程
- 重新构建并重新部署客户端
我们不能为所有存储过程使用相同的 Web 服务方法,因为它们具有
- 不同的签名(参数类型和数量)
- 不同的返回类型(字段类型和数量)
理想的 Web 服务 API
理想情况下,我们有一个用于报表的单一 Web 服务方法,也许还有一个用于告知我们哪些报表可用。例如
GetListOfAvailableReports();
GetReport(int reportId, object[] parameters);
然后,如果稍后添加了新报表,我们将发现它们并仅通过 ID 号调用它们。
问题
当您考虑 `GetReport` 的返回类型应该是什么时,您就开始看到问题了
- 如何使返回类型相同且可序列化(过程类型和 Web 服务操作类型)
- 如何在不重新编译的情况下调用未在 DBML 文件中声明的过程
- SQL Server 和 C# 之间的类型不匹配怎么办?
- 使用分析和实例化返回的数据
我们将逐一解决它们……
解决方案
通用的返回类型
XML 是数据传输和定义之间的自然选择。与其使用普通的“MyProcedureResult
”风格类型(通常由 LINQ DBML 定义),我们通过一个简单的包装器来调整存储过程的 SQL,该包装器将结果转换为具有通用顶部两级的 XML。
而不是正常的查询
SELECT Name,
Email,
Age
FROM Person
我们这样编写并用一个通用的包装器包裹它
标题
DECLARE @XmlData XML
SET @XmlData = (
查询
SELECT Name,
Email,
Age
FROM Person
页脚
FOR XML
PATH ('Record'), ROOT('Records')
);
标签为 Header 和 Footer 的部分对于每个存储过程都相同,我们只需要更改中间的原始查询以满足我们的需求。简单!
这将为我们提供以下形式的 XML
<Records>
<Record>
<SomeSortOfColumn1>DataValue</SomeSortOfColumn1>
<SomeSortOfColumn2>DataValue</SomeSortOfColumn2>
<SomeSortOfColumn3>DataValue</SomeSortOfColumn3>
<!--...etc. (this inner bit is specific to the procedure)-->
</Record>
<Record>
<SomeSortOfColumn1>DataValue</SomeSortOfColumn1>
<SomeSortOfColumn2>DataValue</SomeSortOfColumn2>
<SomeSortOfColumn3>DataValue</SomeSortOfColumn3>
<!--...etc. (this inner bit is specific to the procedure)-->
</Record>
<!--...etc. (repeats for each row)-->
</Records>
这样,我们的报表数据就被封装到一个对象中,具有相同的返回类型(XML)。现在我们可以轻松地移动它,并从同一个方法返回几个不同的报表。
使用 LINQ 动态调用存储过程
LINQ 提供了一个名为 `ExecuteQuery` 的方法,该方法来自数据上下文,允许您将查询构造为字符串并执行它。它要求您知道返回类型,但由于这只会是某些 XML,我们可以创建一个简单的类来包含 XML 结果作为该类型,如下所示
[DataContract]
public class DynamicReport
{
[DataMember]
public string XmlData { get; set; }
}
请注意,字段名 `XmlData` 与前面的 SQL Header 匹配
DECLARE @XmlData XML
所以我们现在可以这样动态地调用这个过程
DynamicReport report = db.ExecuteQuery<DynamicReport>(queryString, providedParameters).FirstOrDefault();
我们使用 `FirstOrDefault()` 是因为在我们的场景中,我们只使用返回单个结果的过程。如果您正在做更复杂的事情,您需要考虑到这一点。
包含报表元数据
我们还可以选择向 `DynamicReport` 类添加有关参数、类型、UI 友好列名(包括空格等)甚至列格式化字符串的更多信息。在 UI 端,这将允许您使用适当呈现的标题和值动态配置数据网格等组件,所有这些都定义在数据库中,而不是硬编码在 UI 中。在这里,我向 `DynamicReport` 类添加了一些元数据,这些元数据在数据库表中进行了描述(请注意,我将 C# 类型指定为 TypeCode,这在相关表中进行了描述)
[DataContract]
public class DynamicReport
{
[DataMember]
public string XmlData { get; set; }
[DataMember]
public List<ReportColumn> Columns { get; set; }
}
CREATE TABLE [Reporting].[ReportColumn]
(
[ReportColumnId] [int] IDENTITY(1,1) NOT NULL,
[ReportId] [int] NOT NULL,
[ColumnOrder] [int] NOT NULL,
[TypeCodeId] [int] NOT NULL DEFAULT ((1)),
[ProcedureColumnName] [varchar](250) NOT NULL,
[ColumnFullName] [varchar](max) NULL,
[ColumnFormatString] [varchar](max) NULL ,
[IsChartDataPointX] [bit] NOT NULL DEFAULT ((0)),
[IsChartDataPointY] [bit] NOT NULL DEFAULT ((0)),
[IsChartDataPointZ] [bit] NOT NULL DEFAULT ((0)),
[Created] [datetime] NOT NULL DEFAULT (getdate()),
[Changed] [datetime] NOT NULL DEFAULT (getdate()),
[Creator] [varchar](250) NOT NULL DEFAULT ('SYS'),
[Changer] [varchar](250) NOT NULL DEFAULT ('SYS'),
PRIMARY KEY [ReportColumnId]
)
以下是演示中找到的相关表
我们的通用 Web 服务操作
由于每个报表的类型都相同,我们可以使用相同的 Web 服务方法来调用它们
[OperationContract]
public DynamicReport DynamicReport(int reportId, object[] parameters)
{
return DynamicReportLogic.DynamicReport(reportId, parameters);
}
客户端如何调用 Web 服务
在这里,我向 ID 为 2 的报表传递了一些任意参数。请注意它们不同的类型,字符串和整数,它们被协变(covaried)到一个对象列表。我们可以根据报表的需求混合搭配参数类型。为了清晰起见,我在下面将其硬编码了
void Button_Click(object sender, RoutedEventArgs e)
{
client.DynamicReportCompleted += new EventHandler<DynamicReportCompletedEventArgs>(client_DynamicReportCompleted);
client.DynamicReportAsync(2, new List<object> { "1", "x2", 3 });
}
从 Web 服务返回的内容
在这里,我们遇到了另一个问题,尽管这可能是一个您一直预料到的问题。假设我们已经成功接收了那个大的 XML 数据包。如果我们不知道它的真实结构,我们如何实际使用它?
我们将一些元数据打包到我们的 `DynamicReport` 对象中。特别是存储过程列(字段)名及其类型。我们将编写一个类来处理解释这些数据并将其转换为对象列表,然后我们可以将其“直接”插入到 DataGrid 或类似的 UI 控件中。这是我们希望客户端中的代码看起来的样子
void client_DynamicReportCompleted(object sender, DynamicReportCompletedEventArgs e)
{
if (e.Error != null)
{
MessageBox.Show(e.Error.Message.ToString());
return;
}
dataGrid.ItemsSource = DynamicReportInterpreter.InterpretRecords(e.Result);
}
当然,我们可以将数据放入属性中以实现 MVVM 的优点。
动态报表解释器
这个类将接收动态报表,“在此处执行魔术”,然后返回一个已实例化报表记录的数组。这些记录的类型仅在运行时才知道,因此“在此处执行魔术”意味着我们必须为此类生成一些源代码,实例化它并填充值。
生成源代码
尽管代码很丑陋,但这只是创建了一个巨大的类字符串,类似于我如果查看它包含的字段可能会写的那样。它有公共属性,对应私有实例变量。尽管我通常不喜欢这种约定,但我故意选择在私有实例变量前加上下划线,因为这比编码“this.
”或某种大小写转换函数更容易在字符串中区分它与参数。也许最重要的是,它有一个“Init
”方法,它接收每个字段作为值并对其进行赋值。这是因为我发现动态创建一个类然后设置数据比一次性完成更容易。
const string ClassName = "DynamicRecord";
const string InitMethodName = "Init";
//...
static string GenerateClassSourceCode(
DynamicReport dynamicReport,
List<ReportColumn> columns,
List<XElement> fields)
{
// Generate code for class wrapper
string classHeaderCode = "using System;" + newLine + newLine + "public class " + ClassName + newLine + "{" + newLine;
string classFooterCode = "}";
// Generate code for the variables, properties & Init method by looking at descendants of first record
string variableTemplate = "\tprivate {0} _{1};{2}" + newLine;
string propertyTemplate = "\tpublic {0} {1} {{ get {{return _{1};}} set {{ _{1} = value;}} }}" + newLine + newLine;
string initTemplate = "\tpublic void {0} ({1})" + newLine + "\t{{" + newLine + "{2}" + newLine + "\t}}" + newLine;
StringBuilder variableLines = new StringBuilder();
StringBuilder propertyLines = new StringBuilder();
StringBuilder initSetterLines = new StringBuilder();
for (int i = 0; i < columns.Count; i++)
{
bool isLastColumn = i == columns.Count - 1;
string initDelimiter = isLastColumn ? string.Empty : newLine;
string fieldDelimiter = isLastColumn ? newLine : string.Empty;
string fieldName = fields[i].Name.LocalName;
string typeName = TypeUtil.ToSourceCodeName((TypeCode)columns[i].TypeCodeId);
variableLines.Append(String.Format(variableTemplate, typeName, fieldName, fieldDelimiter));
propertyLines.Append(String.Format(propertyTemplate, typeName, fieldName));
initSetterLines.Append(String.Format("\t\tthis._{0} = ({1})args[{2}];{3}", fieldName, typeName, i, initDelimiter));
}
// Assemble complete source code
string variableCode = variableLines.ToString();
string propertyCode = propertyLines.ToString();
string initMethodCode = String.Format(initTemplate, InitMethodName, "object[] args", initSetterLines.ToString());
return classHeaderCode + variableCode + propertyCode + initMethodCode + classFooterCode;
}
编译动态类并获取类型
有了源代码生成后,下一步就是将其在运行时转换为一个类并读取其类型,如下所示
// Compile & read dynamic type
Assembly assembly = CompilationUtil.Compile(classSourceCode);
Type dynamicType = assembly.GetType(ClassName);
功劳归于 Matthew Watson 的评论,这是编译源代码的基础,这里是方法
public static class CompilationUtil
{
public static Assembly Compile(string sourceCode)
{
CompilerResults compilerResults = CompileScript(sourceCode);
if (compilerResults.Errors.HasErrors)
{
throw new InvalidOperationException("Expression has a syntax error.");
}
return compilerResults.CompiledAssembly;
}
public static CompilerResults CompileScript(string source)
{
CompilerParameters parameters = new CompilerParameters
{
GenerateExecutable = false,
GenerateInMemory = true,
IncludeDebugInformation = false
};
CodeDomProvider compiler = CSharpCodeProvider.CreateProvider("CSharp");
return compiler.CompileAssemblyFromSource(parameters, source);
}
}
什么时候列表不是列表?
当它是一个对象时。不,等等——
走到这一步,关于对象、类型、协变性和泛型的痛苦的微妙之处开始困扰我。如果我不知道 `T` 是什么,我怎么能创建一个 `List<T>`?我怎么能创建一个 `???[]` 数组?即使我创建了它,我该如何引用它?我该如何调用它的 Add 方法?
幸运的是,Anoop Madhusudanan 的帖子给出了答案。现在,仅采用创建动态类型泛型列表的想法,我将其创建为一个扁平对象,如下所示
// Create a list, stored/formed as a flat object so we can keep the element type dynamic
object listDynamicRecords = CovarianceUtil.CreateGenericList(dynamicType);
//...
public static class CovarianceUtil
{
public static object CreateGenericList(Type typeX)
{
Type listType = typeof(List<>);
Type[] typeArgs = { typeX };
Type genericType = listType.MakeGenericType(typeArgs);
return Activator.CreateInstance(genericType);
}
}
实例化并填充我们动态类型的实例
我们有一个列表可以存放东西,现在我们想扫描我们的 XML 数据并用它创建记录。注意 `MethodInfo` 的使用。
// Note: these match the values names in the SQL footer
const string RecordCollectionIdentifier = "Records";
const string RecordIdentifier = "Record";
//...
// Read records into list
foreach (var record in xDoc.Descendants(RecordCollectionIdentifier).First().Descendants(RecordIdentifier))
{
// Read this record
List<object> listRecordArguments = new List<object>();
var recordFields = record.Descendants();
for (int i = 0; i < columns.Count; i++)
{
// Get field
var recordField = recordFields.Where(q => q.Name == fields[i].Name.LocalName).Single();
// Change value from raw string to real type, then covary into an object
TypeCode typeCode = (TypeCode)columns[i].TypeCodeId;
object argument;
if (typeCode == TypeCode.Boolean)
{
argument = Convert.ChangeType(recordField.Value == "0" ? "False" : "True", typeCode);
}
else
{
argument = Convert.ChangeType(recordField.Value, typeCode);
}
listRecordArguments.Add(argument);
}
// Instantiate the dynamic class & populate its data with the Init Method
object instance = Activator.CreateInstance(dynamicType, null);
object[] args = listRecordArguments.ToArray();
MethodInfo initMethodInfo = dynamicType.GetMethod(InitMethodName);
initMethodInfo.Invoke(instance, new object[] { args });
// Add the instance to the list of objects (instead of listDynamicRecords.Add(instance))
MethodInfo addMethodInfo = listDynamicRecords.GetType().GetMethod("Add");
addMethodInfo.Invoke(listDynamicRecords, new object[] { instance });
}
将扁平对象列表“解压”到数组中
最后,我们使用相同的 `MethodInfo` 方法将纯对象列表记录转换为 `.ToArray`。这会创建一个我们可以返回并用于客户端的数组。
// Invoke the ToArray mothod of our generic List
MethodInfo toArrayMethodInfo = listDynamicRecords.GetType().GetMethod("ToArray");
Array itemSourceArray = (Array)toArrayMethodInfo.Invoke(listDynamicRecords, null);
事后思考
就这样。一个端到端的演示,将 Web 服务另一端的未知存储过程转换为一个可用的数组。
为了进一步发展,我们还可以解释 `ReportColumn` 对象列表,以构建具有自定义标题的数据网格,应用格式化字符串,甚至自动确定它是一个图表并映射相应的列,但这变得更具体到 UI 框架。希望您在自己的需求中使用它时不会遇到太多麻烦。
完整的源代码已包含,包括数据库脚本。
说明
如何尝试演示
- 使用 SQL 脚本 `01 - Create database.sql` 设置 SQL Server 2008 数据库
- 在 Visual Studio 中加载 Dynamic Reports 项目
- 根据需要修改 Web.config,以便 Web 服务可以访问新创建的数据库
- 执行 WPF 应用程序并运行几个报表
- 在应用程序运行时,执行第二个 SQL 脚本 `02 - Add new report.sql`
- 回到您正在运行的应用程序并点击“Fetch”—新报表现在应该会出现
- 选择它并填充参数
- 运行新报表
这是演示应用程序在运行时发现和执行新存储过程
历史
- 2012/05/31:初稿
- 2012/05/31:修复了文章描述中的拼写错误和错误的图像链接
- 2012/06/01:正文少量文字修改
- 2012/06/08:更新了源代码,修复了无参数报表的错误(参见评论)