微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

允许工厂执行的代码在该代码是 `Func<TIn, TOut>` 时引发事件吗?

如何解决允许工厂执行的代码在该代码是 `Func<TIn, TOut>` 时引发事件吗?

我正在构建一个重试系统,允许我在放弃之前多次尝试代码(对于通过网络建立连接之类的事情很有用)。有了这个,我通常在任何地方复制和粘贴作为基础的基本代码是:

for (int i = 0; i < attemptThreshold; i++) {
    try {
        ...
        break;
    } catch (Exception ex) { ... }
}

trycatch 块中有相当多的日志代码可以通过重构来委托以确保一致性。重构它并委派重试工作很简单:

public static class DelegateFactory {
    public static bool DelegateWork<TIn,TOut>(Func<TIn,TOut> work,int attemptThreshold,TIn input,out TOut output) {
        if (work == null)
            throw new ArgumentException(...);

        for (int i = 0; i < attemptThreshold; i++) {
            try {
                OnMessageReceived?.Invoke(work,new FactoryEventArgs("Some message..."));
                output = work(input);
                return true;
            } catch (Exception e) { OnExceptionEncountered?.Invoke(work,new FactoryEventArgs(e)); }
        }

        return false;
    }
    public static event EventHandler<FactoryEventArgs> OnMessageReceived;
    public static event EventHandler<FactoryEventArgs> OnExceptionEncountered;
}

调用它也很简单:

DelegateFactory.DelegateWork((connectionString) => {
    using (sqlConnection conn = new sqlConnection(connectionString))
        conn.open();
},10,"ABC123",out bool connectionMade);
Console.WriteLine($"Connection Made: {(connectionMade ? "Yes" : "No")}");

请记住,上面的代码排除了 FactoryEventArgs 的定义,但它只是一个class 作为参数的 object(为了简化原型设计)。现在,我上面的工作正常,但我想添加一种方法,允许调用者使用工厂订阅者记录的事件发布消息(顺便说一下,我仍在学习的整个单一责任的事情,所以要温柔)。这个想法是创建一个名为 OnMessageReceived 的事件和一个名为 PostMessage 的公共方法,该方法只能从工厂正在执行的代码调用。如果调用是从任何其他地方进行的,那么它会抛出一个 InvalidOperationException 来表示调用无效。我首先要做到这一点是利用调用堆栈来发挥我的优势:

using System.Diagnostics; // Needed for StackFrame
...
public static void PostMessage(string message) {
    bool invalidCaller = true;
    try {
        Type callingType = new StackFrame(1).GetType();
        if (callingType == typeof(DelegateFactory))
            invalidCaller = false;
    } catch { /* Gracefully ignore. */ }

    if (invalidCaller)
        throw new InvalidOperationException(...);

    OnMessageReceived?.Invoke(null,new FactoryEventArgs(message));
}

但是,我不确定这是否会被证明是可靠的。虽然这个想法是允许工作也向订阅者发送消息,但这可能是一个有争议的问题,因为包含工作的对象可能只是引发它自己的 OnMessageReceived 事件。我只是不喜欢以一种方式将异常发送给订阅者,而将消息以另一种方式发送给订阅者的想法。也许我只是挑剔?开始有味道了,我想得越多。

示例用例

public class SomeObjectUsingTheFactory {
    public bool TestConnection() {
        DelegateFactory.DelegateWork((connectionString) => {
            // Completely valid.
            DelegateFactory.PostMessage("Attempting to establish a connection to sql server.");
            using (sqlConnection conn = new sqlConnection(connectionString))
                conn.open();
        },3,out bool connectionMade);

        // This should always throw an exception.
        // DelegateFactory.PostMessage("This is a test.");
        return connectionMade;
    }
}
public class Program {
    public static void Main(string[] args) {
        DelegateFactory.OnMessageReceived += OnFactoryMessageReceived;
        var objNeedingFactory = new SomeObjectUsingTheFactory();
        if (objNeedingFactory.TestConnection())
            Console.WriteLine("Connected.");
    }
    public static void OnFactoryMessageReceived(object sender,FactoryEventArgs e) {
        Console.WriteLine(e.Data);
    }
    public static void OnFactoryExceptionOccurred(object sender,FactoryEventArgs e) {
        string errorMessage = (e.Data as Exception).Message;
        Console.WriteLine($"An error occurred. {errorMessage}");
    }
}

在上面的例子中,如果我们假设连接继续失败,输出应该是:

正在尝试与 sql 服务器建立连接。

发生错误。 {错误消息}

正在尝试与 sql 服务器建立连接。

发生错误。 {错误消息}

正在尝试与 sql 服务器建立连接。

发生错误。 {错误消息}

如果第二次尝试成功,它应该是:

正在尝试与 sql 服务器建立连接。

发生错误。 {错误消息}

正在尝试与 sql 服务器建立连接。

已连接。


如何确保方法 PostMessage 仅由工厂正在执行的代码调用

注意:如果设计引入了不良做法,我不反对更改设计。我对新想法完全开放。

编译器错误:此外,此处的任何编译错误都是严格的疏忽和错别字。我手动输入了这个问题,因为我尽力解决这个问题。如果您遇到任何问题,请告诉我,我会及时解决

解决方法

您可以通过引入一个提供事件访问权限的上下文对象来消除基于堆栈的安全性。

但首先,有一些注意事项。我不会谈论这种设计的优点,因为这是主观的。不过,我将介绍一些术语、命名和设计问题。

  1. .NET 的事件命名约定不包括“On”前缀。相反,引发事件的方法(标记为 privateprotected virtual,取决于您是否可以继承该类)具有“On”前缀。我在下面的代码中遵循了这个约定。

  2. “DelegateFactory”这个名字听起来像是创建委托的东西。这不。它接受一个委托,您正在使用它在重试循环中执行一个操作。不过,我很难用文字来塑造这个词。我在下面的代码中调用了类 Retryer 和方法 Execute。做你想做的。

  3. DelegateWork/Execute 返回一个 bool 但你从不检查它。目前尚不清楚这是否是示例消费者代码中的疏忽,还是这件事的设计缺陷。我会让你来决定,但因为它遵循 Try 模式来确定输出参数是否有效,我将它留在那里并使用它

  4. 因为您在谈论与网络相关的操作,请考虑编写一个或多个接受可等待委托的重载(即返回 Task<TOut>)。因为您不能在异步方法中使用 refout 参数,所以您需要将 bool 状态值和委托的返回值包装在某些东西中,例如自定义类或元组。我将把它作为练习留给读者。

  5. 如果参数是 null,请确保抛出 ArgumentNullException 并简单地将参数的名称传递给它(例如 nameof(work))。您的代码抛出 ArgumentException,这不太具体。此外,使用 is 关键字确保您正在对 null 进行引用相等性测试,并且不会意外调用重载的相等运算符。您也会在下面的代码中看到这一点。


引入上下文对象

我将使用分部类,以便每个片段中的上下文都清晰。

首先,您有活动。让我们在这里遵循 .NET 命名约定,因为我们要引入调用程序方法。它是一个静态类(abstractsealed),因此它们将是 private。使用调用者方法作为模式的原因是为了使引发事件保持一致。当一个类可以被继承并且一个调用者方法需要被覆盖时,它必须调用基本实现来引发事件,因为派生类无权访问事件的后备存储(这可能是一个字段,如下所示case,或者可能是 Events 派生类型中的 Component 属性,其中该集合上使用的密钥是私有的)。虽然这个类是不可继承的,但有一个你可以坚持的模式是很好的。

引发事件的概念要经过一层语义翻译,因为注册事件处理程序的代码可能与调用这个方法的代码不一样,他们可能有不同的观点。此方法的调用者想要发布一条消息。事件处理程序想知道已收到消息。因此,发布消息 (PostMessage) 被转换为通知已收到消息 (OnMessageReceived)。

public static partial class Retryer
{
    public static event EventHandler<FactoryEventArgs> MessageReceived;
    public static event EventHandler<FactoryEventArgs> ExceptionEncountered;

    private static void OnMessageReceived(object sender,FactoryEventArgs e)
    {
        MessageReceived?.Invoke(sender,e);
    }

    private static void OnExceptionEncountered(object sender,FactoryEventArgs e)
    {
        ExceptionEncountered?.Invoke(sender,e);
    }
}

旁注:您可能需要考虑为 EventArgs 定义一个不同的 ExceptionEncountered 派生类,以便您可以传递该事件的整个异常对象,而不是您拼凑在一起的任何字符串数据

现在,我们需要一个上下文类。将向消费者公开的是接口或抽象基类。我已经使用了一个界面。

从“发布消息”到“收到消息”的语义转换得益于 FactoryEventArgs 对发布消息的 lambda 是未知的这一事实。它所要做的就是将消息作为字符串传递。

public interface IRetryerContext
{
    void PostMessage(string message);
}

static partial class Retryer
{
    private sealed class RetryerContext : IRetryerContext
    {
        public void PostMessage(string message)
        {
            OnMessageReceived(this,new FactoryEventArgs(message));
        }
    }
}

RetryerContext 类嵌套在 Retryer 类(和私有)中的原因有两个:

  1. 它至少需要访问 Retryer 类私有的调用程序方法之一。
  2. 考虑到第一点,它通过不向使用者公开嵌套类来简化事情。

一般来说,应该避免嵌套类,但这是它们的设计初衷之一。

还要注意发送者是this,即上下文对象。原始实现将 work 作为发送方传递,这不是引发(发送)事件的原因。因为它是静态类中的静态方法,所以之前没有实例传递,传递 null 可能感觉很脏;严格来说,上下文仍然不是引发事件的原因,但它比委托实例更好。在 Execute 内部使用时,它也将作为发送方传递。

在调用 work 时需要稍微修改实现以包含上下文。 work 参数现在是 Func<TIn,IRetryerContext,TOut>

static partial class Retryer
{
    public static bool Execute<TIn,TOut>(Func<TIn,TOut> work,int attemptThreshold,TIn input,out TOut output)
    {
        if (work is null)
            throw new ArgumentNullException(nameof(work));

        DelegationContext context = new DelegationContext();

        for (int i = 0; i < attemptThreshold; i++)
        {
            try
            {
                OnMessageReceived(context,new FactoryEventArgs("Some message..."));
                output = work(input,context);
                return true;
            }
            catch (Exception e)
            {
                OnExceptionEncountered(context,new FactoryEventArgs(e.Message));
            }
        }

        output = default;
        return false;
    }
}

OnMessageReceived 从两个不同的地方调用:ExecutePostMessage,因此如果您需要更改事件的引发方式(可能需要添加日志记录),它只需要在一处更改。

此时,防止不需要的消息发布的问题就解决了,因为:

  1. 该事件不能任意引发,因为任何调用它的东西都是类私有的。
  2. 消息只能由被赋予这样做的能力的人发布。

小挑剔:是的,调用者可以捕获一个局部变量并将上下文分配给外部作用域,但是有人也可以使用反射来查找事件委托的支持字段并在需要时调用它,也。你能合理地做的只有这么多。

最后,消费者代码需要在 lambda 的参数中包含上下文。

这是您的示例用例,已修改为使用上述实现。 lambda 返回一个 string,即连接的当前数据库,作为操作的结果。这与返回的 true/false 不同,用于指示 attemptThreshold 次尝试后是否成功,现在分配给 connectionMade

public class SomeObjectUsingTheFactory
{
    public bool TestConnection(out string currentDatabase)
    {
        bool connectionMade = Retryer.Execute((connectionString,context) =>
        {
            // Completely valid.
            context.PostMessage("Attempting to establish a connection to SQL server.");
            using (SqlConnection conn = new SqlConnection(connectionString))
            {
                conn.Open();

                return conn.Database;
            }
        },3,"ABC123",out currentDatabase);

        // Can't call context.PostMessage here because 'context' doesn't exist.

        return connectionMade;
    }
}
public class Program
{
    public static void Main(string[] args)
    {
        Retryer.MessageReceived += OnFactoryMessageReceived;
        var objNeedingFactory = new SomeObjectUsingTheFactory();
        if (objNeedingFactory.TestConnection(out string currentDatabase))
            Console.WriteLine($"Connected to '{currentDatabase}'.");
    }
    public static void OnFactoryMessageReceived(object sender,FactoryEventArgs e)
    {
        Console.WriteLine(e.Data);
    }
    public static void OnFactoryExceptionOccurred(object sender,FactoryEventArgs e)
    {
        string errorMessage = (e.Data as Exception).Message;
        Console.WriteLine($"An error occurred. {errorMessage}");
    }
}

作为进一步的练习,您还可以实现其他重载。以下是一些示例:

不需要调用 PostMessage 的 lambda 重载,因此不需要上下文。这与您的原始实现具有相同的 dowork 参数类型。

public static bool Execute<TIn,TOut output)
{
    return Execute((arg,_ /*discard the context*/) => work(arg),attemptThreshold,input,out output);
}

不需要在输出参数中返回值的 lambda 的重载,因此使用 Action 委托代替 Func 委托。

public static bool Execute<TIn>(Action<TIn,IRetryerContext> work,TIn input)
{
    // A similar implementation to what's shown above,// but without having to assign an output parameter.
}

public static bool Execute<TIn>(Action<TIn> work,TIn input)
{
    return Execute((arg,input);
}

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。