数据回复的手工方法





5.00/5 (2投票s)
一种通过 ASP.NET Web 应用程序将 SQL Server 数据库中的错误或异常的更多详细信息传递给 Web 客户端的方法。
本文是 Artisan.Orm
项目的延续和发展
此处描述的方法也可以完全独立使用。
引言
假设我们有一个三层结构的 Web 应用程序
- Web SPA 客户端,通过 Ajax 请求 Web 服务器的数据
- Web 服务器和 ASP.NET 应用程序
- SQL Server 数据库
一旦 Web 客户端请求获取或保存数据,数据响应就会通过一个可能看起来像这样的方式从数据库传输到 UI
在此过程中,可能会发生各种意外情况。SQL Server 数据库中可能会出现错误,或者 Web 服务器上的 ASP.NET 应用程序中可能会发生异常。最终接收者,Web 客户端,应该以某种方式被告知发生了什么:请求是否正确执行,或者出了问题。
本文旨在创建一种方便通用的数据响应格式,该格式能够将异常情况的详细信息通过上述管道从数据库传递到 Web 客户端。
背景
开始之前的一些想法和建议。
两类异常
错误和异常可以分为两类:预期和非预期。
- 非预期 异常是代码错误或设备故障的结果,是我们希望在完美世界中永远不会发生的事情。
对这类异常的通常反应:通知用户和管理员应用程序发生致命错误。 - 预期 异常是不一致的数据保存、不及时请求或其他用户可以自行解决问题的活动的结果。
示例- Web 服务器或数据库中的数据验证,例如用户登录名或电子邮件的唯一性。
- 数据并发,当两个用户同时编辑数据库中的同一条记录时。
- 数据丢失,当第一个用户在第二个用户保存之前删除一条记录时。
- 数据访问被拒绝,当数据响应取决于在数据库中计算的用户访问级别时。
通过 Http 传输状态
Web 客户端如何区分发生哪种类型的异常?有必要将其状态传递给它。
如何将此状态从 Web 服务器传递到 Web 客户端?首先想到的方法是使用 http 状态码...
而这个想法被证明是毫无价值的……这就像让飞机机长通过用于起降的官方无线电频率告知你妈妈你遇到了麻烦,而你只是把午餐盒忘在了厨房的桌子上。
Http 状态码用于通知接收者传输状态。浏览器使用该状态做出反应。将传输状态和数据响应状态混合在一起,迟早会导致浏览器意外反应或无法找到适合你需求的代码。
我认为更好的主意是创建一个通用的数据响应格式,类似于一个包装对象,其中 Data
和 Status
是属性。
七个步骤
异常详细信息通过数据响应管道进行传递的任务可以分为几个步骤
- 在数据库中,找到异常情况并输出必要的数据。
- 在存储库中,识别异常情况并读取有关它的数据。
- 在存储库中,抛出异常,以便数据服务可以以 C# 的最佳实践方式处理它。
- 在数据服务中,获取正常数据或捕获异常,并创建一个通用的数据响应。
- 在 ASP.NET Web API 控制器中,将数据响应序列化为 JSON 格式。
- 在 Web 客户端数据服务中,获取 JSON 数据,定义数据响应的状态,并采取适当的操作。
- 在 SPA 控制器中,获取数据响应,定义数据响应的状态,并采取适当的操作。
通用的 DataReply 格式
JSON 格式的 DataReply
期望的数据包装器或 DataReply
对象,在 ASP.NET Web API 控制器将其序列化为 JSON 后,应具有以下形式
dataReply: {
status: "ok",
data: {...},
messages: [...]
}
因此,用于序列化的 C# 对象必须具有相同的 public
属性。经过一系列实验,我发现了 DataReply
类在 C# 中的最佳结构,至少对我来说。
C# 格式的 DataReply
基类 DataReply
只有两个属性:Status
和 Messages
。
派生类 DataReply<TData>
添加了 Data
属性。
这是 DataReply
类及其属性的图表
这是 C# 代码
DataReplyStatus
DataReplyMessage
DataReply
DataReply<TData>
(点击选项卡。)
public enum DataReplyStatus
{
Ok ,
Fail ,
Missing ,
Validation ,
Concurrency ,
Denial ,
Error
}
[DataContract]
public class DataReplyMessage
{
[DataMember]
public String Code;
[DataMember(EmitDefaultValue = false)]
public String Text;
[DataMember(EmitDefaultValue = false)]
public Int64? Id;
[DataMember(EmitDefaultValue = false)]
public Object Value;
}
[DataContract]
public class DataReply {
[DataMember]
public DataReplyStatus Status { get; set; }
[DataMember(EmitDefaultValue = false)]
public DataReplyMessage[] Messages { get; set; }
public DataReply()
{
Status = DataReplyStatus.Ok;
Messages = null;
}
public DataReply(DataReplyStatus status)
{
Status = status;
Messages = null;
}
public DataReply(DataReplyStatus status, string code, string text)
{
Status = status;
Messages = new [] { new DataReplyMessage { Code = code, Text = text } };
}
public DataReply(DataReplyStatus status, DataReplyMessage message)
{
Status = status;
if (message != null)
Messages = new [] { message };
}
public DataReply(DataReplyStatus status, DataReplyMessage[] messages)
{
Status = status;
if (messages?.Length > 0)
Messages = messages;
}
public DataReply(string message)
{
Status = DataReplyStatus.Ok;
Messages = new [] { new DataReplyMessage { Text = message } };
}
public static DataReplyStatus? ParseStatus (string statusCode)
{
if (IsNullOrWhiteSpace(statusCode))
return null;
DataReplyStatus status;
if (Enum.TryParse(statusCode, true, out status))
return status;
throw new InvalidCastException(
$"Cannot cast string '{statusCode}' to DataReplyStatus Enum. " +
$"Available values: {Join(", ", Enum.GetNames(typeof(DataReplyStatus)))}");
}
}
[DataContract]
public class DataReply<TData>: DataReply {
[DataMember(EmitDefaultValue = false)]
public TData Data { get; set; }
public DataReply(TData data)
{
Data = data;
}
public DataReply()
{
Data = default(TData);
}
public DataReply(DataReplyStatus status, string code, string text, TData data)
:base(status, code, text)
{
Data = data;
}
public DataReply(DataReplyStatus status, TData data) :base(status)
{
Data = data;
}
public DataReply(DataReplyStatus status) :base(status)
{
Data = default(TData);
}
public DataReply(DataReplyStatus status, string code, string text)
:base(status, code, text)
{
Data = default(TData);
}
public DataReply(DataReplyStatus status, DataReplyMessage replyMessage)
:base(status, replyMessage)
{
Data = default(TData);
}
public DataReply(DataReplyStatus status, DataReplyMessage[] replyMessages)
:base(status, replyMessages)
{
Data = default(TData);
}
}
DataReplyStatus
这个 Enum
包含了我在项目中发现实用的状态,并且没有限制可以减少或扩展它。
状态的含义
代码 | 用法 |
好的 | 一切都如预期执行时的默认状态 |
失败 | 当查询目标未实现时 |
丢失 | 当查询未找到具有 Id 参数的记录时 |
验证 | 当查询发现对数据完整性的威胁时 |
并发 | 当两个或多个用户同时更新同一条记录时 |
拒绝 | 当数据访问在数据库中计算时,用户未获授权查看所请求的数据,并且你想告知用户原因时 |
Error(错误) | 所有非预期的错误和异常 |
DataReplyMessage
消息用于传递关于异常情况的附加信息。
DataReplyMessage
具有以下属性
代码 | 用法 |
代码 | 消息的 string 标识符 |
ID | 导致异常情况的数据库问题记录的整数标识符 |
文本 | 用于日志或其他需求的任何人可读的信息 |
值 | 导致异常情况的值 |
DataReply
对象包含一个 DataReplyMessages
数组,足以描述任何类型的异常情况的详细信息。
如何使用 DataReplyMessages?
想象一下,用户提交了一个包含许多字段的表单。客户端验证未发现错误,但服务器验证发现了。
例如,服务器验证发现登录名和电子邮件不是唯一的。那么 DataReply
将具有 Status
= DataReplyStatus.Validation
,并且 DataReplyMessages
数组将包含两项
代码 | ID | 文本 | 值 |
NON_UNIQUE_LOGIN | 15 | 登录名已存在 | Admin |
NON_UNIQUE_EMAIL | 15 | 电子邮件已存在 | admin@mail.com |
数据服务能够记录此异常,UI 能够处理它并使用此信息进行错误高亮显示和适当的操作。
DataReplyMessage
类有四个属性,但只有 Code
是必需的。所以如果用 [DataMember(EmitDefaultValue = false)]
装饰其他属性,它们将不会被序列化为 JSON。
逐步进行
步骤 # 1
在数据库中,找到异常情况并输出必要的数据。
数据库操作可能会引发错误并在 C# 代码中抛出 SqlException
。SQL Server 的 raiserror
命令能够输出 ErrorNumber
、ErrorMessage
、ServerState
。这很好,但还不够。客户端经常想了解错误的更多详细信息。
为了以 DataReply
格式输出错误详细信息,需要为 DataMessages
创建一个特殊的用户定义表类型。
create type dbo.DataReplyMessageTableType as table
(
Code varchar(50) not null ,
[Text] nvarchar(4000) null ,
Id bigint null ,
[Value] sql_variant null ,
unique (Code, Id)
);
然后,在存储过程中,我们可以输出异常情况的状态及其详细信息。例如,这里是 SaveUser
存储过程的一部分,用于检查数据并发和有效性
declare
@UserId int ,
@Login varchar(20) ,
@Name nvarchar(50) ,
@Email varchar(50) ,
@Concurrency varchar(20) = 'Concurrency',
@Validation varchar(20) = 'Validation',
@DataReplyStatus varchar(20) ,
@DataReplyMessages dbo.DataReplyMessageTableType;
begin transaction;
if exists -- concurrency
(
select * from dbo.Users u with (tablockx, holdlock)
inner join @User t on t.Id = u.Id and t.[RowVersion] <> u.[RowVersion]
)
begin
select DataReplyStatus = @Concurrency;
rollback transaction;
return;
end
begin -- validation
begin -- check User.Login uniqueness
select top 1
@UserId = u.Id,
@Login = u.[Login]
from
dbo.Users u
inner join @User t on t.[Login] = u.[Login] and t.Id <> u.Id;
if @Login is not null
begin
set @DataReplyStatus = @Validation;
insert into @DataReplyMessages
select Code ='NON_UNIQUE_LOGIN', 'Login is not unique', @UserId, @Login;
end;
end;
begin -- check User.Email uniqueness
select top 1
@UserId = u.Id,
@Email = u.Email
from
dbo.Users u
inner join @User t on t.Email = u.Email and t.Id <> u.Id
if @Email is not null
begin
set @DataReplyStatus = @Validation;
insert into @DataReplyMessages
select Code ='NON_UNIQUE_EMAIL', 'User email is not unique', @UserId, @Email;
end;
end;
select DataReplyStatus = @DataReplyStatus;
if @DataReplyStatus is not null
begin
select * from @DataReplyMessages;
rollback transaction;
return;
end
end;
-- save the user
-- output the saved user
注意异常情况的输出模式
select DataReplyStatus = @DataReplyStatus;
if @DataReplyStatus is not null
begin
select * from @DataReplyMessages;
rollback transaction;
return;
end
第一个是状态输出。如果状态不为空,则输出消息,回滚并返回。
步骤 # 2
在存储库中,识别异常情况并读取有关它的数据
在存储库方法中,我们遵循上述输出模式,并从存储过程中抛出具有 Status
和 Messages
的 DataReplyException
public User SaveUser(User user)
{
return GetByCommand(cmd =>
{
cmd.UseProcedure("dbo.SaveUser");
cmd.AddTableRowParam("@User", user);
return cmd.GetByReader(dr =>
{
var statusCode = dr.ReadTo<string>(getNextResult: false);
var dataReplyStatus = DataReply.ParseStatus(statusCode);
if (dataReplyStatus != null )
{
if (dr.NextResult())
throw new DataReplyException(dataReplyStatus.Value,
dr.ReadToArray<DataReplyMessage>());
throw new DataReplyException(dataReplyStatus.Value);
}
dr.NextResult();
var savedUser = reader.ReadTo<User>()
return savedUser;
});
});
}
上面的存储库方法是用 Artisan.Orm ADO.NET 扩展方法 编写的。但代码也可以重写为使用常规的 ADO.NET 方法。
步骤 # 3
在存储库中,抛出异常,以便数据服务可以以 C# 的最佳实践方式处理它。
上面的代码示例中的 DataReplyException
是一个自定义异常,它有两个额外的属性:Status
和 Messages
public class DataReplyException: Exception
{
public DataReplyStatus Status { get; } = DataReplyStatus.Error;
public DataReplyMessage[] Messages { get; set; }
public DataReplyException(DataReplyStatus status, DataReplyMessage[] messages)
{
Status = status;
Messages = messages;
}
}
因此,SaveUser
存储库方法
- 在正常情况下返回一个
User
对象 - 在预期的异常情况下返回带有状态和消息的
DataReplyException
- 在非预期的异常情况下返回常规的
SqlException
步骤 # 4
在数据服务中,获取正常数据或捕获异常,并创建通用的数据响应。
数据服务是一个层,其中拦截、记录所有来自存储库的异常,并将它们转换为适合 Web API 控制器的格式。
数据服务是数据从存储库方法包装到 DataReply
中的地方。
public DataReply<User> SaveUser(User user)
{
try
{
var user = repository.SaveUser(user);
return new DataReply<User>(user);
}
catch (DataReplyException ex)
{
// log exception here, if necessary
return new DataReply<User>(ex.Status, ex.Messages);
}
catch (Exception ex)
{
var dataReplyMessages = new []
{
new DataReplyMessage { Code = "ERROR_MESSAGE" , Text = ex.Message },
new DataReplyMessage { Code = "STACK_TRACE" ,
Text = ex.StackTrace.Substring(0, 500) }
};
// log exception here, if necessary
return new DataReply<User>(DataReplyStatus.Error, dataReplyMessages);
}
}
上面的 SaveUser
方法
- 在正常情况下,返回一个
DataReply
对象,其中Status
=DataReplyStatus.Ok
且Data
=User
; - 在预期的异常情况下,返回具有来自
DataReplyStatus Enum
列表的状态和来自存储过程的消息的DataReply
; - 在非预期的异常情况下,返回一个
DataReply
对象,其中Status
=DataReplyStatus.Error
且Messages
包含原始异常的Message
和StackTrace
。
当然,将 StackTrace
发送到 Web 客户端是一个坏主意,在生产环境中永远不应该这样做,但在开发环境中——它是一个很有用的东西。
步骤 # 5
在 ASP.NET Web API 控制器中,将数据响应序列化为 JSON 格式。
ASP.NET Web API 控制器中的 SaveUser
方法如下所示
[HttpPost]
public DataReply<User> SaveUser(User user)
{
using (var service = new DataService())
{
return service.SaveUser(user);
}
}
由于 DataReply
的 Data
和 Messages
属性带有 [DataMember(EmitDefaultValue = false)]
注释属性,如果它们是 Null
,则会被省略。所以
-
在正常情况下,JSON 字符串是
{ "status" : "ok", "data" : {"id":1,"name":"John Smith"} }
-
在预期的异常情况下,
Status
=DataReplyStatus.Validation
,JSON 字符串是{ "status" : "validation", "messages" : [ {"code":"NON_UNIQUE_LOGIN", "text":"Login is not unique", "id":"1","value":"admin"}, {"code":"NON_UNIQUE_EMAIL", "text":"User email is not unique", "id":"1","value":"admin@mail.com"} ] }
-
在非预期的异常情况下,
Status
=DataReplyStatus.Error
且Messages
包含原始异常的Message
和StackTrace
,JSON 字符串是{ "status": "error", "messages" : [ {"code":"ERROR_MESSAGE", "text":"Division by zero"}, {"code":"STACK_TRACE", "text":"Tests.DAL.Users.Repository. <>c__DisplayClass8_0.<SaveUser>b__0(SqlCommand cmd) ..."} ] }
步骤 # 6
在 Web 客户端数据服务中,获取 JSON 数据,定义数据响应的状态,并采取适当的操作。
下面是 Web 客户端 dataService
的 JavaScript 和 AngularJs 代码示例。因为所有数据请求和响应都通过单一的 dataService
及其方法进行,所以可以很容易地使用统一的方法来处理具有特定状态的数据响应。
(function () {
"use strict";
angular.module("app")
.service('dataService', function ( $q , $http ) {
var error = {status : "error"};
var allowedStatuses =
["ok", "fail", "validation", "missing", "concurrency", "denial"];
this.save = function (url, savingObject) {
var deferred = $q.defer();
$http({
method: 'Post',
url: url,
data: savingObject
})
.success(function (reply, status, headers, config) {
if ($.inArray((reply || {}).status, allowedStatuses) > -1) {
deferred.resolve(angular.fromJson(reply) );
} else {
showError(reply, status, headers, config);
deferred.resolve(error);
}
})
.error(function (data, status, headers, config) {
showError(data, status, headers, config);
deferred.resolve(error);
});
return deferred.promise;
};
function showError(data, status, headers, config) {
// inform a user about unexpected exceptional case
};
});
})();
步骤 # 7
在 SPA 控制器中,获取数据响应,定义数据响应的状态,并采取适当的操作。
最后,这里是 Web 客户端控制器中如何处理 DataReply
的示例
function save() {
userService.save('api/users' $scope.user)
.then(function (dataReply) {
if (dataReply.status === 'ok') {
var savedUser = dataReply.data;
$scope.user = savedUser;
}
else if (dataReply.status === 'validation' ){
for (var i = 0; i < dataReply.messages.length; i++) {
if (dataReply.messages[i].code === 'NON_UNIQUE_LOGIN') {
// highlight the login UI control
}
else if (dataReply.messages[i].code === 'NON_UNIQUE_EMAIL') {
// highlight the email UI control
}
}
}
else if (dataReply.status === 'concurrency' ){
// message about concurrency
}
});
}
结论
DataReply
的想法源于在复杂 对象图保存过程中向 Web 客户端传输异常情况更多细节的迫切需求。
在保存复杂对象时,任何部分都可能发生不一致或其他问题。收集并一次性传递数据的所有可能问题的任务需要一个合适的传输机制。DataReply
成为了这样的解决方案。
经过在几个实际项目中的尝试,可以说它证明了其通用性、有效性和存在的合理性。
☺
Artisan.Orm 中的 DataReply
DataReply
、DataReplyStatus Enum
、DataReplyMessages
和 DataReplyException
现在是 Artisan.Orm
的一部分。
关于 Artisan.Orm
的最初文章在这里 here。
如果你对该项目感兴趣,请访问 Artisan.Orm
的 GitHub 页面及其 文档维基。
Artisan.Orm
也可用作 NuGet 包。
关于源代码
附加的存档包含 GitHub 上的 Artisan.Orm
解决方案的副本,该解决方案使用 Visual Studio 2015 创建,包含三个项目
Artisan.Orm
- 包含Artisan.Orm
类的 DLL 项目Database
- SSDT 项目,用于创建 SQL Server 2016 数据库以提供测试数据Tests
- 包含代码使用示例的测试项目
要安装数据库并运行测试,请将 Artisan.publish.xml 文件和 App.config 中的连接字符串更改为你的。
历史
- 2017年4月16日
- 初次发表
Artisan.Orm
版本 1.1.0 的源代码