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

Allow factory executed code to raise events when that code is a `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 的定义,但它只是一个 classobject 作为参数(为了原型设计的简单性)。现在,我上面的工作很好,但我想添加一种方法来允许调用者使用工厂订阅者记录的事件来 post 消息(整个单一责任的事情,我还在学习,顺便说一句,所以要温柔)。这个想法是创建一个名为 OnMessageReceived 的事件和一个名为 PostMessage 的 public 方法,只能从工厂执行的代码中调用。如果调用是从任何其他地方进行的,那么它会抛出一个 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, "ABC123", 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}");
    }
}

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

Attempting to establish a connection to SQL server.

An error occurred. {errorMessage}

Attempting to establish a connection to SQL server.

An error occurred. {errorMessage}

Attempting to establish a connection to SQL server.

An error occurred. {errorMessage}

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

Attempting to establish a connection to SQL server.

An error occurred. {errorMessage}

Attempting to establish a connection to SQL server.

Connected.


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

注意: 如果设计引入了不良做法,我并不反对更改设计。我完全乐于接受新想法。

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

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

但首先,请注意几点。我不打算谈论这种设计的优点,因为那是主观的。但是,我将解决一些术语、命名和设计问题。

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

  2. “DelegateFactory”这个名字听起来像是创建委托的东西。这不是。它 接受 委托,并且您正在使用它在重试循环中执行操作。不过,我很难对这个进行文字塑造;我在下面的代码中调用了 class Retryer 和方法 Execute 。随心所欲。

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

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

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


引入上下文对象

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

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

引发事件的概念将经过一层语义翻译,因为注册事件处理程序的代码可能与调用此方法的代码不同,并且它们可能有不同的观点。此方法的调用者想要 post 一条消息。事件处理程序想知道已收到消息。因此,post发送消息 (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);
    }
}

旁注:您可能需要考虑为 ExceptionEncountered 定义一个不同的 EventArgs-derived class,这样您就可以为该事件传递整个异常对象而不是您从中拼凑的任何字符串数据。

现在,我们需要一个上下文 class。将暴露给消费者的是接口或抽象基础class。我已经有了一个界面。

从“post 一条消息”到“收到一条消息”的语义翻译得益于 FactoryEventArgs 对于 post 消息的 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 class 嵌套在 Retryer class(和私有)中有两个原因:

  1. 它需要访问至少一个 Retryer class.
  2. 私有的调用方法
  3. 鉴于第一点,它通过不向消费者公开嵌套 class 来简化事情。

一般来说,应该避免嵌套 classes,但这是它们专门设计用来做的事情之一。

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

内部使用时,它也会作为发件人传递

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

static partial class Retryer
{
    public static bool Execute<TIn, TOut>(Func<TIn, IRetryerContext, 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,所以如果你需要改变事件的引发方式(可能有些添加日志记录),它只需要改一处。

至此,阻止垃圾邮件post的问题就解决了,因为:

  1. 事件不能任意引发,因为任何调用它的东西都是 class 私有的。
  2. 一条消息只能post被赋予这样做能力的东西编辑。

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

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

这是您的示例用例,已修改为使用上面的实现。作为操作的结果,lambda return 是连接的当前数据库 string。这与 true/false return 不同且不同,后者指示在 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>(Func<TIn, TOut> work, int attemptThreshold, TIn input, TOut output)
{
    return Execute((arg, _ /*discard the context*/) => work(arg), attemptThreshold, input, out output);
}

不需要 return 输出参数中的值的 lambda 重载,因此使用 Action 委托而不是 Func 委托。

public static bool Execute<TIn>(Action<TIn, IRetryerContext> work, int attemptThreshold, 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, int attemptThreshold, TIn input)
{
    return Execute((arg, _ /*discard the context*/) => work(arg), attemptThreshold, input);
}