委托的常见模式

以前

委托提供了一种机制,可实现涉及组件间最小耦合度的软件设计。

这种设计的一个很好的例子是 LINQ。 LINQ 查询表达式模式依赖于委托来实现其所有功能。 请考虑以下简单示例:

var smallNumbers = numbers.Where(n => n < 10);

这会筛选数字序列,仅筛选小于值 10 的数字。 该方法 Where 使用委托来确定序列的哪些元素传递筛选器。 创建 LINQ 查询时,为此特定目的提供委托的实现。

Where 方法的原型为:

public static IEnumerable<TSource> Where<TSource> (this IEnumerable<TSource> source, Func<TSource, bool> predicate);

此示例重复使用属于 LINQ 的所有方法。 它们都依赖于管理特定查询的代码委托。 此 API 设计模式是一种强大的学习和理解模式。

此简单示例说明了委托在组件之间仅需要极少耦合度的原因。 无需创建派生自特定基类的类。 无需实现特定接口。 唯一的要求是提供一个对当前任务至关重要的方法的实现。

通过委托生成自己的组件

让我们基于该示例,通过一种依赖委托的设计来创建组件。

让我们定义可用于大型系统中日志消息的组件。 库组件可在多个不同平台上的许多不同的环境中使用。 组件中有很多用于管理日志的常见功能。 它需要接受系统中任何组件的消息。 这些消息将具有不同的优先级,核心组件可以管理这些优先级。 消息应具有其最终存档形式的时间戳。 对于更高级的方案,可以按源组件筛选消息。

功能有一个方面会经常更改:写入消息的位置。 在某些环境中,可能会将其写入错误控制台。 在其他环境中,可能会写入一个文件。 其他可能性包括数据库存储、OS 事件日志或其他文档存储。

还可以将输出组合在不同的方案中使用。 你可能想要将消息写入控制台和文件。

基于委托的设计可提供极大的灵活性,并便于支持将来可能添加的存储机制。

在此设计下,主日志组件可以是非虚拟的,甚至是封闭类。 可以插入任意一组委托程序,将消息写入不同的存储介质。 对多播委托的内置支持有助于支持必须将消息写入多个位置(文件和控制台)的情况。

第一次实施

我们从小处着手:初始实现会接受新消息并使用任意附加委托编写它们。 你可以从一个将消息写入控制台的委托开始。

public static class Logger
{
    public static Action<string>? WriteMessage;

    public static void LogMessage(string msg)
    {
        if (WriteMessage is not null)
            WriteMessage(msg);
    }
}

上面的静态类是能够起作用的最简化方案。 我们需要为将消息写入控制台的方法编写单个实现:

public static class LoggingMethods
{
    public static void LogToConsole(string message)
    {
        Console.Error.WriteLine(message);
    }
}

最后,你需要通过将委托附加到记录器中声明的 WriteMessage 委托来进行挂钩:

Logger.WriteMessage += LoggingMethods.LogToConsole;

实践

到目前为止,我们的示例相当简单,但它仍然演示了涉及代表的设计的一些重要准则。

借助在核心框架中定义的委托类型,用户可更轻松地使用委托。 无需定义新类型,使用库的开发人员无需学习新的专用委托类型。

使用的接口尽可能少且灵活:若要创建新的输出记录器,必须创建一种方法。 该方法可以是静态方法,也可以是实例方法。 它可能具有任何访问权限。

设置输出格式

让我们使第一个版本更可靠一点,然后开始创建其他日志记录机制。

接下来,让我们向 LogMessage() 方法添加几个参数,以便日志类创建更多结构化消息:

public enum Severity
{
    Verbose,
    Trace,
    Information,
    Warning,
    Error,
    Critical
}
public static class Logger
{
    public static Action<string>? WriteMessage;

    public static void LogMessage(Severity s, string component, string msg)
    {
        var outputMsg = $"{DateTime.Now}\t{s}\t{component}\t{msg}";
        if (WriteMessage is not null)
            WriteMessage(outputMsg);
    }
}

接下来,让我们利用该 Severity 参数筛选发送到日志输出的消息。

public static class Logger
{
    public static Action<string>? WriteMessage;

    public static Severity LogLevel { get; set; } = Severity.Warning;

    public static void LogMessage(Severity s, string component, string msg)
    {
        if (s < LogLevel)
            return;

        var outputMsg = $"{DateTime.Now}\t{s}\t{component}\t{msg}";
        if (WriteMessage is not null)
            WriteMessage(outputMsg);
    }
}

实践

你已向日志记录基础结构添加了新功能。 由于记录器组件与任何输出机制非常松散耦合,因此可以添加这些新功能,而不会影响任何实现记录器委托的代码。

在不断构建这一点时,你将看到更多示例,这些示例说明了这种松散耦合如何能够更好地更新站点的各个部分,而无需对其他位置进行任何更改。 事实上,在较大的应用程序中,记录器输出类可能位于不同的程序集中,甚至不需要重新生成。

生成第二个输出引擎

还将附带日志组件。 让我们再添加一个输出引擎,用于将消息记录到文件中。 这将是一个略微更复杂的输出引擎。 这将是一个封装文件操作的类,并确保在每次写入后始终关闭文件。 这可确保生成每个消息后所有数据都刷新到磁盘。

下面是基于文件的记录器:

public class FileLogger
{
    private readonly string logPath;
    public FileLogger(string path)
    {
        logPath = path;
        Logger.WriteMessage += LogMessage;
    }

    public void DetachLog() => Logger.WriteMessage -= LogMessage;
    // make sure this can't throw.
    private void LogMessage(string msg)
    {
        try
        {
            using (var log = File.AppendText(logPath))
            {
                log.WriteLine(msg);
                log.Flush();
            }
        }
        catch (Exception)
        {
            // Hmm. We caught an exception while
            // logging. We can't really log the
            // problem (since it's the log that's failing).
            // So, while normally, catching an exception
            // and doing nothing isn't wise, it's really the
            // only reasonable option here.
        }
    }
}

创建此类后,可将它进行实例化,然后它会将 LogMessage 方法附加到记录器组件中:

var file = new FileLogger("log.txt");

这两个不是相互排斥的。 可以将日志方法附加到控制台和文件并生成消息:

var fileOutput = new FileLogger("log.txt");
Logger.WriteMessage += LoggingMethods.LogToConsole; // LoggingMethods is the static class we utilized earlier

以后,即使在同一个应用程序中,也可在不对系统产生任何其他问题的情况下删除其中一个委托:

Logger.WriteMessage -= LoggingMethods.LogToConsole;

实践

现在,你已为日志记录子系统添加了第二个输出处理程序。 这一个需要更多基础结构才能正确支持文件系统。 此委托为实例方法。 它也是一种私有方法。 无需提高更高的可访问性,因为代表基础架构可以连接代表。

其次,基于委托的设计可实现多个输出方法,而无需任何额外的代码。 无需生成任何其他基础结构即可支持多个输出方法。 它们只是成为调用列表中的另一种方法。

请特别注意文件日志记录输出方法中的代码。 它进行编码以确保它不会引发任何异常。 虽然这不是总是必要的,但它通常是一个很好的做法。 如果任意一种委托方法引发异常,将不会调用该调用中剩余的其他委托。

最后一个注释是,文件记录器必须通过打开和关闭每个日志消息上的文件来管理其资源。 可以选择使文件保持打开状态,并在完成后实现 IDisposable 关闭该文件。 任一方法都有其优点和缺点。 两者都在类之间创建了更高的耦合度。

为了支持任一方案,无需更新类中的 Logger 代码。

处理 NULL 委托

最后,让我们更新 LogMessage 方法,以确保在未选择任何输出机制时方法的可靠性。 NullReferenceException 委托没有附加调用列表时,当前实现将引发 WriteMessage。 你可能更喜欢一种当没有附加方法时能默默继续运行的设计。 使用 null 条件运算符结合 Delegate.Invoke() 方法,这很容易做到。

public static void LogMessage(string msg)
{
    WriteMessage?.Invoke(msg);
}

当左操作数(在本例中为 ?.)为 null 时,null 条件运算符 WriteMessage 将短路,这意味着不会尝试记录日志消息。

不会在 Invoke()System.Delegate 的文档中列出 System.MulticastDelegate 方法。 编译器为声明的任何委托类型生成类型安全 Invoke 方法。 在此示例中,这意味着 Invoke 采用单个 string 参数,并且具有 void 返回类型。

实践摘要

你已经看到了一个日志组件的初始阶段,该组件可以与其他写作者和其他功能一起扩展。 通过在设计中使用委托,这些不同的组件松散地耦合在一起。 这提供了几个优点。 可以轻松创建新的输出机制并将其附加到系统。 这些其他机制只需要一种方法:写入日志消息的方法。 这是一种在添加新功能时可复原的设计。 所有编写器所需的协定都是为了实现一种方法。 该方法可以是静态方法或实例方法。 它可以是公用、专用或任何其他合法访问。

Logger 类可以进行任意数量的增强或更改,而无需引入中断性变更。 像其他任何类一样,您不能在不面临导致破坏性变更风险的情况下修改公共 API。 但是,由于记录器与任何输出引擎之间的耦合仅通过委托,因此没有涉及其他类型(如接口或基类)。 耦合尽可能小。

下一步