从 SQL-Server 到 Web 服务






4.75/5 (21投票s)
另一种将数据从 SQL-Server 导出以调用 Web 服务的方法
引言
我日常工作之一是将 SAP Business One ERP 集成到客户的 IT 系统中。集成是这项工作的重要组成部分,因为我们实施 ERP 的公司通常有其他系统来执行补充任务,而所有这些系统都必须相互通信。
在我最近的一个项目中,需要将 ERP “深度”集成到客户的 IT 环境中。出于该客户的原因,这种双向集成(发送 ERP 中的修改,集成其他系统中完成的修改)是通过 Web 服务调用完成的……这意味着使用 XML 数据传输的 SOAP 调用。我在这里不是为了讨论这种选择的优缺点,而是要提出一个具体问题(以及给出的解决方案):ERP 中的一些传出调用必须与用户所做的修改同步,直到 Web 服务调用返回。这意味着用户修改的有效性取决于远程程序的结果,从而创建了一个大型分布式事务。
说起来容易做起来难(至少第一次是这样 )。与 ERP 同步意味着数据必须在 SQL Server 事务中提取(因此,从用户进程内部提取,否则修改将不可见),对这些数据进行一些转换,调用远程 Web 服务,并将调用的结果集成到用户进程中。
对于本文,我们的兴趣将集中在全局过程的一个小片段
- 从 SQL Server 提取数据,
- 将其转换为“可理解”的形式以调用 Web 服务。
这看起来很简单,但对于一篇文章来说也足够了!
背景
这里提出的问题(以及实现的解决方案)应被视为一个普遍问题,但描述的解决方案特定于某个客户:因此无法提供完整的源代码,并且演示项目并没有真正意义。除此之外,这里有趣的是整体过程以及问题是如何解决的。
我们假设读者对 SQL(特别是 SQL Server 的 T-SQL)有足够的了解,能够阅读一些 C# 源代码,最重要的是,对全局系统有很好的理解,以便将提供的材料全部或部分地适应到自己的实现中。
全局架构
在做任何事情之前,有必要了解解决方案是如何组织的,所以我们首先要看看全貌
- 用户在 ERP 中修改特定数据(客户、商品、特定数据、发票等)。
- 对于这些修改中的每一个,ERP 会启动一个 SQL Server 事务,并在其中调用一个用户存储过程来控制数据;这是认识到需要做一些工作的地方。
- 如果需要做一些工作,则必须通过某种方式调用 Web 服务,并使用修改后的(且经过技术转换的)数据。
从这里,我们有几个问题
- 并非所有调用都是同步的:有些可以是异步的。遵守“只做一次”规则;我们选择以相同的方式实现两者,并仅区分如何调用此单一实现。
- 必须存在某种处理错误的机制:因此,需要记录请求已启动(或计划启动)及其结果。坏消息是,即使全局过程失败(对于同步调用,事务将被回滚),日志记录也必须保留,
- 必须运行快速:过程可以从事务中触发,用户通常没有耐心,因此没有时间花在结构转换等方面。
- 必须是可扩展的:因此,在添加新对象时,我们需要最大程度地减少工作量。
- 必须至少是可调试的,并且是可复现的。整体系统应提供机会,以任何方式重新处理失败的消息。
除了我们不详细讨论每个点的事实外,给出的答案如下:
引入了一个特定的数据库,该数据库包含配置表。这些表给出我们可以处理的对象、传输的类型(谁是发起者、谁是接收者、同步与否、移动类型(添加-更新)等)。除了这个配置之外,还存在“实时表”:它们存档传输请求、已发生的事情、日志错误消息等。
因此,根据我们之前的五个要点:
- 所有传输请求都被记录下来,以便由特定的存储过程处理。对于需要同步的请求,直接调用该过程;否则,计时器将定期调用它来处理所有待处理的请求。这是一个简单的答案。
- 日志记录是架构的组成部分。对于本文,我们关心的唯一部分是“良好的”SQL 编写,我们稍后将看到这一点。
- 这里开始变得棘手,这也是本文的主要内容。最终,我们需要调用一个 Web 服务(由 WSDL 定义),并传输非常复杂的对象,而不仅仅是几个字符串。从这里,有条好消息和一个坏消息:好消息是在 SQL Server 中,我们可以添加用 .NET 编写的存储过程;坏消息是,由于 Microsoft 引入的所有限制(主要是由于安全性),即使我们能够从扩展存储过程中调用 Web 服务,我们也不允许引用 WSDL。
因此,最简单的解决方案是引入一个中间 Web 服务,它将以非常简单的形式(单个字符串)接收来自 SQL Server 的数据,然后重新格式化和转换这些数据,再调用真正的 Web 服务。 - 这是最棘手的部分之一:由于上述原因,如果添加新对象,则必须修改中间 Web 服务,因此必须限制对此的修改。所做的选择是修改一个配置表,以包含在提取数据时要执行的 SQL 查询。
- 通过全局架构实现了这一点:由于系统记录了所有接口的请求历史,因此要重现调用,我们只需更改历史数据库中的状态即可。
那么,我们现在在哪里?
- 用户对数据进行了修改,该修改由系统存档。
- 如果此修改对应于我们的某个接口,则记录该请求。如果接口是同步的,则立即执行;否则,它“仅”被存储,并将等待定期计时器检测到待处理请求并执行接口。
- 然后,该过程使用动态查询将数据提取到一个字符串中,其中包含结果,并调用中间 Web 服务。
- 中间 Web 服务将接收到的数据转换为可理解的形式,进行转换,然后调用最终的 Web 服务。
- 调用的结果最终存储在存档表中,如果调用是同步的,则通知用户。
这里的图景几乎完整了,只剩下一个点:中间 Web 服务中的数据转换。最终的 Web 服务将使用 SOAP 协议调用,因此它只使用 XML 数据进行传输。.NET 框架在这里会做一些有趣的事情:它将通过序列化本机将 .NET 对象转换为 XML。因此,我们转换的结果需要是一个 C# 对象(该对象在集成 WSDL 时生成)。
我们进行控制-转换和处理的最简单方法是只处理 C# 对象,并且像瘟疫一样避免字符串/ XML 操作。这样做还有另一个优点:我们将确保数据类型,并且不必进行任何转换,只需要映射和转换。所以:我们从 SQL Server 获得数据为一个字符串,我们需要一个对象?这看起来像一个反序列化过程 。从哪种字符串反序列化?从 XML。所以 SQL Server 发送的字符串必须格式化为 XML:非常简单
因此,在描述的结尾:
- 我们有包含 SQL 查询的配置表,
- 这些查询作为动态 SQL 执行,并返回 XML 格式的字符串,
- XML 格式的字符串作为参数发送到中间 Web 服务,该服务将其反序列化,
- 从这里开始,这个 Web 服务就完成了它的工作,而这对于本文来说并不重要。
很简单。
提取数据
这个过程必须全局地思考,而不仅仅是从 SQL Server 端:提取的数据必须由 Web 服务处理;Web 服务需要强类型的数据(它需要知道列名、数据类型以及接收数据的组织结构)才能对其进行转换。
在这里最简单的方法是,通过 xsd.exe 工具,从 XSD 定义中定义 XML,并通过该工具生成 C# 对象。这个过程将给我们带来以下好处:
- 使用生成的对象,我们将只有 C# 对象操作,而没有字符串-XML 转换,
- XSD 将定义从 SQL Server 提取的 XML 字符串的格式。
由于提取数据的 SQL 查询可能很复杂,最简单的方法是为它们创建存储过程,这些存储过程将被动态调用。例如,这是一个存储过程:
IF EXISTS (select * from sysobjects where type = 'P'
and name = 'ESB_Frontend_Create_ProductInformations')
DROP PROCEDURE ESB_Frontend_Create_ProductInformations
GO
CREATE PROCEDURE ESB_Frontend_Create_ProductInformations
@parm varchar(20),
@xml nvarchar(MAX) out
AS
SET @xml = (SELECT DISTINCT
I0.ItemCode
, I0.CodeBars
, G0.ItmsGrpNam ItemGroupName
, I0.ItemName
, I0.U_Color Color
, I0.U_Brand Brand
, ESB_CS.U_ESB ContributionStatus
, ESB_LS.U_ESB LogisticStatus
, I1.Price DistributorPurchasePrice
, I0.AvgPrice AvgStdPrice
, ESB_NF.U_ESB NetworkFlag
FROM DB_PRODUCTION..OITM I0 WITH (NOLOCK)
INNER JOIN DB_PRODUCTION..OITB G0 WITH (NOLOCK) ON I0.ItmsGrpCod = G0.ItmsGrpCod
INNER JOIN DB_PRODUCTION..ITM1 I1 WITH (NOLOCK)
INNER JOIN DB_PRODUCTION..OPLN PL0 WITH (NOLOCK) ON I1.PriceList = PL0.ListNum AND PL0.U_Usage = 'PCD'
ON I0.ItemCode = I1.ItemCode
INNER JOIN DB_PRODUCTION..[@ESB_LOGISTICSTATUS] ESB_LS WITH (NOLOCK) ON I0.U_LogStatus = ESB_LS.Code
LEFT OUTER JOIN DB_PRODUCTION..[@ESB_NETWORKFLAG] ESB_NF WITH (NOLOCK) ON I0.U_NetFlag = ESB_NF.Code
INNER JOIN DB_PRODUCTION..[@ESB_CONTSTAT] ESB_CS WITH (NOLOCK) ON I0.U_ContStat = ESB_CS.Code
WHERE I0.ItemCode = @parm
FOR XML PATH)
这里有两个有趣的地方:
- 首先,在我们的每个表名之后使用 WITH (NOLOCK) 子句。这是强制性的,因为过程可能在事务内部调用,如果未添加此项,我们将导致存储过程中发生死锁。
- 其次,全局调用:SET @xml = ( ... FOR XML PATH)。其结果是,该过程将返回一个包含所有数据的单个字符串。这个字符串将是基于 XML 格式的,并且标签名将是列名。
还记得我们需要将其反序列化为 C# 对象吗?这是该数据的 XSD 定义:
<xs:complexType name="SboEsbProductItem">
<xs:all>
<xs:element name="ItemCode" type="tns:ItemCode" />
<xs:element name="CodeBars" type="tns:BarCode" />
<xs:element name="ItemGroupName" type="tns:GroupName" />
<xs:element name="ItemName" type="tns:ItemName" />
<xs:element name="Color" type="tns:Color" />
<xs:element name="Brand" type="tns:Brand" />
<xs:element name="ContributionStatus" type="tns:ContributionStatus" />
<xs:element name="LogisticStatus" type="tns:LogisticStatus" />
<xs:element name="DistributorPurchasePrice" type="tns:Price" />
<xs:element name="AvgStdPrice" type="tns:Price" />
<xs:element name="NetworkFlag" type="tns:NetworkFlag" />
</xs:all>
</xs:complexType>
因此,SQL 查询中的列名必须是 XSD 中的标签名:正是这个简单的东西将使整个过程正常工作。
这个 XSD 定义包含在一个更全局的模式(节选)中:
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:tns="http://www.elconsulting.fr/"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://www.elconsulting.fr/"
elementFormDefault="qualified" version="1.0">
<xs:include schemaLocation="InterfacesTypes.xsd" />
<xs:include schemaLocation="GpObjects.xsd" />
<xs:include schemaLocation="EsbObjects.xsd" />
<!-- Basic Types -->
<!-- Structures -->
<xs:complexType name="AdminInfo">
<xs:all>
<xs:element name="archiveId" type="tns:archiveId" minOccurs="1" default="0" />
<xs:element name="interfaceId" type="tns:interfaceId" minOccurs="1" default="0" />
<xs:element name="RetryInstanceId" type="tns:RetryInstanceId" minOccurs="1" default="0" />
<xs:element name="Source" type="tns:SubSystem" minOccurs="1" />
<xs:element name="Destination" type="tns:SubSystem" minOccurs="1" />
<xs:element name="Entity" type="tns:Entity" minOccurs="1" />
<xs:element name="Method" type="tns:Method" minOccurs="1" />
<xs:element name="Database" type="tns:Database" minOccurs="1" />
<xs:element name="ObjectType" type="tns:ObjectType" />
<xs:element name="ObjectTransactionType" type="tns:ObjectTransactionType" />
<xs:element name="ObjectKey" type="tns:ObjectKey" />
<xs:element name="ObjectKeyValue" type="tns:ObjectKeyValue" />
<xs:element name="Created" type="tns:Timestamp" minOccurs="1" />
</xs:all>
</xs:complexType>
<xs:complexType name="BusinessObject">
<xs:choice>
<xs:element name="SboEsbProductItem" type="tns:SboEsbProductItem" maxOccurs="1" />
</xs:choice>
</xs:complexType>
<!-- Xml format definition -->
<xs:element name="WS_SBO">
<xs:complexType>
<xs:sequence>
<xs:element name="AdminInfo" type="tns:AdminInfo" minOccurs="1" />
<xs:element name="BusinessObject" type="tns:BusinessObject" minOccurs="1" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
我们的存储过程是从另一个存储过程中调用的,这里是最后一个的两个节选:
首先,如何提取数据并获取 XML 字符串:
IF @query <> ''
BEGIN
SELECT @parameters = '@parm varchar(20), @xml nvarchar(MAX) output'
EXECUTE sp_executesql @query, @parameters, @list_of_cols_val_tab_del, @xml = @xml out
END
没什么特别的,只是纯粹的动态 SQL。接收到的变量 @query 包含(对于我们的示例):
EXEC ArchiveDB..ESB_Frontend_Update_ProductInformations @parm, @xml out
其次,调用中间 Web 服务(稍后详述)。为此,会创建一个具有当前过程技术信息的控制对象(来自模式的 AdminInfo 对象),然后执行外部存储过程:
SELECT
@AdminInfo =
'<![CDATA[<AdminInfo>' +
CASE ISNULL (@interface, 0) WHEN 0 THEN '' ELSE '<interfaceId>' +
CAST (@interface AS VARCHAR) + '</interfaceId>' END +
'<archiveId>' + CAST (@archive AS varchar) + '</archiveId>' +
'<RetryInstanceId>' + CAST (@RetryInstance AS varchar) + '</RetryInstanceId>' +
'<Source>SBO</Source>' +
'<Destination>' + ISNULL (@destination, '') + '</Destination>' +
'<Database>' + ISNULL (@syndb, '') + '</Database>' +
'<Entity>' +
CASE ISNULL (@destination, '')
WHEN 'ESB' THEN
CASE @object_type
WHEN '2' THEN 'BusinessPartner' -- Profile
WHEN '4' THEN 'ProductItem'
WHEN 'SOFI_DEVICE' THEN 'Device'
WHEN 'SOFI_INSIM' THEN 'Sim'
WHEN '17' THEN 'SalesOrder' -- PurchaseOrder pour distributeur
WHEN '22' THEN 'PurchaseOrder' -- SimPurchaseOrder
ELSE ''
END
WHEN 'GP' THEN
CASE @object_type
WHEN '4' THEN 'ProductItem'
WHEN '13' THEN 'CustomerInvoice'
WHEN '14' THEN 'CustomerCreditNote'
WHEN '15' THEN 'InventoryTransactionDelivery'
WHEN '20' THEN 'InventoryTransactionReception'
WHEN '67' THEN 'InventoryTransfert'
ELSE ''
END
ELSE ''
END +
'</Entity>' +
'<Method>' + CASE ISNULL (@transaction_type, '')
WHEN 'A' THEN 'Create'
WHEN 'U' THEN 'Update'
ELSE '' END +
'</Method>' +
'<ObjectType>' + ISNULL (@object_type, '') + '</ObjectType>' +
'<TransactionType>' + ISNULL (@transaction_type, '') + '</TransactionType>' +
'<ObjectKey>' + ISNULL (@list_of_key_cols_tab_del, '') + '</ObjectKey>' +
'<ObjectKeyValue>' + ISNUll (@list_of_cols_val_tab_del, '') + '</ObjectKeyValue>' +
'<Created>' + CONVERT (varchar(64), getdate(), 126) + '</Created>' +
'</AdminInfo>]]>'
, @BusinessObject = '<![CDATA[<BusinessObject>' + @xml + '</BusinessObject>]]>'
SELECT @WebService = prmValue
FROM dbo.Parameters
WHERE prmName = 'SBO-' + @destination
BEGIN TRY
exec @error = dbo.SboWebService @WebService, @AdminInfo, @BusinessObject,
@errorClass OUT, @errorCode OUT, @errorDetails OUT
IF @error <> 0
BEGIN
SELECT @error = @errorCode, @error_message = @errorDetails
END
END TRY
BEGIN CATCH
SELECT @Error = 1, @error_message = ERROR_MESSAGE()
END CATCH
这里唯一棘手的部分是,我们将 XML 数据发送到一个 Web 服务,该服务将(不进行任何转换)将其重新发送到另一个 Web 服务。但是,外部存储过程(SboWebService)为了能够发送这些数据,不能对其进行解释;在 XML 中实现这一点的唯一方法是将字符串包含在 [CDATA[...]] 块中。
这里还有一件有趣的事情是影响 @WebService 变量的 T-SQL 块:在最终解决方案中,实际上有六个 Web 服务,并且可以从 SQL Server 调用四个。因此,这里我们从配置表中获取要调用的 Web 服务的实际地址。(一个好的)副作用是,这样在不同的环境(开发、测试和生产)之间没有区别。
外部存储过程
要从 SQL Server 调用中间 Web 服务,有几种方法,这里不予讨论。在实施过程中,为了性能、演进和开发的一致性,我们选择执行类似 http://blogs.msdn.com/b/spike/archive/2010/11/25/how-to-consume-a-web-service-from-within-sql-server-using-sql-clr.aspx 的操作,这是其中一种可用资源,因此无需强调。
这里唯一棘手的事情是,由于 SQL Server (2008R2) 只能使用 CLR 2.0,所以项目必须针对 Framework 2.0。
外部存储过程的主体非常简单,只是参数转换(双向)和对我们中间 Web 服务的调用:
public class StoredProcedures
{
[SqlProcedure]
public static SqlInt32 SboWebService(SqlString url, SqlString xmlAdminInfo,
SqlString xmlBusinessObject, out SqlString errorClass,
out SqlInt32 errorCode, out SqlString errorDetails)
{
string error;
int code;
string details;
errorClass = (SqlString)string.Empty;
errorCode = 0;
errorDetails = (SqlString)string.Empty;
var ws = new CallWebService.CallWebService.Frontend
{
Url = url.ToString()
};
int returnValue = ws.SboWebService(xmlAdminInfo.ToString(),
xmlBusinessObject.ToString(), out error, out code, out details);
if (0 != returnValue)
{
errorClass = error;
errorDetails = details;
}
return (SqlInt32)returnValue;
}
};
在 var ws = new CallWebService.CallWebService.Frontend 这一行,第一个 CallWebService 是导入 Web 引用时给定的命名空间。我们正在动态更改目标 URL,使用前面讨论的参数。
中间 Web 服务
最后,这就是需要完成工作的地方。唯一让我们感兴趣的是,Web 服务接收一个 XML 字符串,反序列化后提供要处理的对象。遵守“只做一次”规则,相应的代码驻留在祖先抽象类中。
public int SboWebService(string xmlAdminInfo, string xmlBusinessObject,
out string errorClass, out int errorCode, out string errorDetails)
{
var returnValue = 0;
using (_sLogger.VerboseCall())
{
var watch = new StopwatchExt();
string xmlWsObject;
_sLogger.Debug("SboWebService Parameters:");
_sLogger.Debug(xmlAdminInfo);
_sLogger.Debug(xmlBusinessObject);
if (true == ReformatData(xmlAdminInfo, xmlBusinessObject,
"WS_SBO", out errorClass, out errorCode, out errorDetails, out xmlWsObject))
{
Data.Ws.AdminInfo adminInfo = null;
Data.Ws.BusinessObject businessObject = null;
try
{
var sboBase = XmlHelpers.Deserialize<Data.Ws.WS_SBO>(xmlWsObject);
if (null != sboBase)
{
adminInfo = sboBase.AdminInfo;
businessObject = sboBase.BusinessObject;
}
}
catch (Exception e)
{
_sLogger.Debug(e);
errorDetails = "Error deserializing WsSboBase object";
returnValue = 1;
_sLogger.Error(errorDetails + ": " + xmlWsObject);
adminInfo = null;
businessObject = null;
}
if (null != adminInfo)
{
if (null != businessObject)
{
returnValue = SboWebServiceEx(adminInfo, businessObject,
out errorClass, out errorCode, out errorDetails);
}
else
{
var contextManager = ContextManager.CreateNew(
WebConfigurationManager.ConnectionStrings["Interfaces"]);
var errorMessage = "Nothing to process. Execution stopped [ArchiveId: " +
adminInfo.archiveId + "][Entity: " + adminInfo.Entity +
"][Method: " + adminInfo.Method + "][ObjectKey: " +
adminInfo.ObjectKey + "][ObjectKeyValue: " +
adminInfo.ObjectKeyValue + "]";
_sLogger.Error(errorMessage);
returnValue = 1;
contextManager.Initialize(adminInfo.Source.ToString(),
adminInfo.Destination.ToString(), adminInfo.Entity.ToString(),
adminInfo.Method.ToString());
contextManager.Archive = adminInfo.archiveId;
contextManager.RetryInstance = adminInfo.RetryInstanceId;
InterfacesHelpers.AddRetryInstanceError(contextManager, "Error",
"SAPBusinessOne", "-2052", errorMessage, null, null);
InterfacesHelpers.SetArchiveStatus(contextManager, "E");
}
}
}
else
{
_sLogger.Error("Error Reformating Data");
returnValue = 1;
}
watch.Stop();
_sLogger.Info("Time Elapsed: {0} - Return: {1}",
watch.ElapsedTime, returnValue);
}
return returnValue;
}
[WebMethod]
public abstract int SboWebServiceEx(Data.Ws.AdminInfo adminInfo,
Data.Ws.BusinessObject businessObject, out string errorClass,
out int errorCode, out string errorDetails);
以及 ReformatData 函数,没有它什么也做不了:
protected bool ReformatData(string xmlAdminInfo, string xmlBusinessObject,
string rootNode, out string errorClass, out int errorCode,
out string errorDetails, out string xmlWsObject)
{
bool returnValue = false;
using (_logger.VerboseCall())
{
errorCode = 0;
errorClass = string.Empty;
errorDetails = string.Empty;
xmlWsObject = string.Empty;
if ((false == string.IsNullOrEmpty(xmlAdminInfo)) &&
("Null" != xmlAdminInfo))
{
if ((false == string.IsNullOrEmpty(xmlBusinessObject)) &&
("Null" != xmlBusinessObject))
{
try
{
if (true == xmlAdminInfo.StartsWith("<![CDATA["))
{
xmlAdminInfo = xmlAdminInfo.Substring(9, xmlAdminInfo.Length - 12);
}
if (true == xmlBusinessObject.StartsWith("<![CDATA["))
{
xmlBusinessObject =
xmlBusinessObject.Substring(9, xmlBusinessObject.Length - 12);
}
xmlWsObject =
"<?xml version=\"1.0\"?>\r\n<" + rootNode +
" xmlns:xsi=\"http://www.w3.org/2001/xmlschema-instance\" xmlns" +
":tns=\"http://elconsulting.fr/\" xmlns=\"http:" +
"//www.elconsulting.fr/\">\r\n";
xmlWsObject += xmlAdminInfo;
xmlWsObject += xmlBusinessObject;
xmlWsObject += "</" + rootNode + ">";
returnValue = true;
}
catch (Exception e)
{
_logger.Debug(e);
errorDetails = "Error reformating xml data: wrong format";
_logger.Error(errorDetails);
}
}
else
{
errorDetails = "BusinessObject not provided: nothing to process";
_logger.Error(errorDetails);
}
}
else
{
errorDetails = "AdminInfo not provided: don't know what and how to process";
_logger.Error(errorDetails);
}
}
return returnValue;
}
主要是删除 <![CDATA[ ]]> 声明并添加命名空间以能够反序列化对象。
接下来是过程的功能部分,对于本文来说并不真正有趣。
结论
从一个简单的请求(在用户数据输入和远程 Web 服务的验证之间存在同步过程)出发,由于所有现有限制,我们得到一个复杂的解决方案来实现。但是,通过将其分解成小部分,我们能够对初始请求给出合理的答案。
我们在整个过程中看到了什么?
- 首先,我们可以从事务中调用 Web 服务,并使用当前修改的数据进行调用。这消除了对数据验证/转换可能性的所有限制。
- 其次,可以从 SQL Server 中提取数据(我假设这也可以用于其他 RDBMS,但我没有这方面的需求,所以没有尝试),以便我们可以将其反序列化并获得可处理的原生 DotNet 对象。
- 第三,最后,通过一点组织并全局地看待系统,我们能够最大程度地减少工作量:SQL 查询的编写方式遵循 XSD 定义,以及一点反序列化。
我个人认为,这会留在我的工具箱里。
历史
- 2013-05-20 - 初始版本。