65.9K
CodeProject 正在变化。 阅读更多。
Home

数据回复的手工方法

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2017年4月16日

CPOL

10分钟阅读

viewsIcon

14392

downloadIcon

138

一种通过 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 状态码用于通知接收者传输状态。浏览器使用该状态做出反应。将传输状态和数据响应状态混合在一起,迟早会导致浏览器意外反应或无法找到适合你需求的代码。

我认为更好的主意是创建一个通用的数据响应格式,类似于一个包装对象,其中 DataStatus 是属性。

七个步骤

异常详细信息通过数据响应管道进行传递的任务可以分为几个步骤

  1. 在数据库中,找到异常情况并输出必要的数据。
  2. 在存储库中,识别异常情况并读取有关它的数据。
  3. 在存储库中,抛出异常,以便数据服务可以以 C# 的最佳实践方式处理它。
  4. 在数据服务中,获取正常数据或捕获异常,并创建一个通用的数据响应。
  5. 在 ASP.NET Web API 控制器中,将数据响应序列化为 JSON 格式。
  6. 在 Web 客户端数据服务中,获取 JSON 数据,定义数据响应的状态,并采取适当的操作。
  7. 在 SPA 控制器中,获取数据响应,定义数据响应的状态,并采取适当的操作。

通用的 DataReply 格式

JSON 格式的 DataReply

期望的数据包装器或 DataReply 对象,在 ASP.NET Web API 控制器将其序列化为 JSON 后,应具有以下形式

dataReply: {
    status: "ok",
    data: {...}, 
    messages: [...]
}

因此,用于序列化的 C# 对象必须具有相同的 public 属性。经过一系列实验,我发现了 DataReply 类在 C# 中的最佳结构,至少对我来说。

C# 格式的 DataReply

基类 DataReply 只有两个属性:StatusMessages
派生类 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 命令能够输出 ErrorNumberErrorMessageServerState。这很好,但还不够。客户端经常想了解错误的更多详细信息。

为了以 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

在存储库中,识别异常情况并读取有关它的数据

在存储库方法中,我们遵循上述输出模式,并从存储过程中抛出具有 StatusMessagesDataReplyException

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 是一个自定义异常,它有两个额外的属性:StatusMessages

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.OkData = User
  • 预期的异常情况下,返回具有来自 DataReplyStatus Enum 列表的状态和来自存储过程的消息的 DataReply
  • 非预期的异常情况下,返回一个 DataReply 对象,其中 Status = DataReplyStatus.ErrorMessages 包含原始异常的 MessageStackTrace

当然,将 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);
    } 
}

由于 DataReplyDataMessages 属性带有 [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.ErrorMessages 包含原始异常的 MessageStackTrace,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

DataReplyDataReplyStatus EnumDataReplyMessagesDataReplyException 现在是 Artisan.Orm 的一部分。

关于 Artisan.Orm 的最初文章在这里 here

如果你对该项目感兴趣,请访问 Artisan.OrmGitHub 页面及其 文档维基

GitHub + Wiki

Artisan.Orm 也可用作 NuGet 包

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 的源代码
© . All rights reserved.