【问题标题】:Allow factory executed code to raise events when that code is a `Func<TIn, TOut>`?当代码是“Func<TIn, TOut>”时,允许工厂执行的代码引发事件吗?
【发布时间】:2021-02-23 17:44:10
【问题描述】:

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

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, "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}");
    }
}

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

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

发生错误。 {错误消息}

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

发生错误。 {错误消息}

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

发生错误。 {错误消息}

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

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

发生错误。 {错误消息}

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

已连接。


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

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

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

【问题讨论】:

  • 你在能够打电话给DelegateFactory.DelegateWork 之后失去了我。鉴于当前的设计(暂时忽略编译错误),PostMessage正确使用时如何调用?目前还不清楚您要防止什么,因为没有任何暴露迹象。
  • “这个想法是创建一个名为 OnMessageReceived 的事件” - 你有这个,很好。现在,既然您在同一句话中提到了它,那么 PostMessage 在代码方面与它有何关系?
  • @madreflection 我会在发布更新后再次通知你,但我相信这个问题的解决方案在技术上会产生代码异味,所以我正在走一条不同的道路,直到我可以向自己证明这一点版本不会引入气味。哦,是的,SqlConnection 只是一个例子。
  • @madreflection 我添加了一个示例用例。 PostMessageOnMessageReceived 相关,因为它在工厂为订阅者引发了该事件,这就是为什么我相信有气味。在这一点上,我认为该事件应该存在于调用者身上,并且订阅者也可以订阅调用者(在示例用例中,SomeObjectUsingTheFactory 将获得它自己的OnMessageReceived 并且订阅者也会监听它。

标签: c# events delegates constraints


【解决方案1】:

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

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

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

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

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

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

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


引入上下文对象

我将使用部分类,以便在每个 sn-p 中都清楚上下文。

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

引发事件的概念要经过一层语义翻译,因为注册事件处理程序的代码可能与调用该方法的代码不同,它们可能有不同的视角。此方法的调用者想要发布消息。事件处理程序想知道已收到一条消息。因此,发布消息 (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 派生类,以便您可以传递该事件的整个异常对象,而不是您从中拼凑的任何字符串数据。

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

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&lt;TIn, IRetryerContext, TOut&gt;

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,因此如果您需要更改事件的引发方式(可能是一些添加日志记录),只需在一个地方进行更改。

至此,防止垃圾邮件发布的问题解决了,因为:

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

Small nit-pick:是的,调用者可以捕获一个局部变量并将上下文分配给外部范围,但是有人也可以使用反射来找到事件委托的支持字段并在他们想要的时候调用它,也。你能做的只有这么多。

最后,消费者代码需要在 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>(Func<TIn, TOut> work, int attemptThreshold, TIn input, 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, 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);
}

【讨论】:

  • 谢谢你!这是一个很棒的解决方案,并且是一个非常非常详细的答案!我爱它彻头彻尾!非常感谢您花时间写下来!如果可以的话,我一定会奖励你的答案!通过您使用示例和详尽的解释,我了解了一切。实际上,为了清楚起见,我没有剩余的问题,这对我来说是一个很好的奖励!再次感谢您写出如此详细的答案!
  • 24 小时内收到赏金。再次感谢!
  • 这远远超出了。不会撒谎,我已经阅读了您的评论数十次,并且它继续提供多巴胺命中。 这就是动机。
  • @Tacoタコス:非常感谢您的慷慨。我真的很谦虚,我希望有一天我能再次为您服务。
猜你喜欢
  • 2019-09-04
  • 2019-11-26
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-08-07
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多