使用Func代替IoC接口


15

上下文:我正在使用C#

我设计了一个类,为了隔离它并使单元测试更容易,我传入了它的所有依赖关系。它在内部没有对象实例化。但是,不是引用接口来获取所需的数据,而是让它引用通用的Funcs返回所需的数据/行为。当注入其依赖项时,我可以使用lambda表达式来实现。

对我来说,这似乎是一种更好的方法,因为在单元测试期间我不必做任何繁琐的模拟。另外,如果周围的实现有根本变化,则只需要更改工厂类即可;无需更改包含逻辑的类。

但是,我之前从未见过IoC这样做,这使我认为我可能会缺少一些潜在的陷阱。我唯一想到的是与未定义Func的C#早期版本的轻微不兼容,在我看来,这不是问题。

使用泛型委托/高阶函数(例如Func for IoC),而不是使用更具体的接口时,是否存在任何问题?


2
您要描述的是高阶函数,这是函数编程
罗伯特·哈维

2
我将使用委托而不是a,Func因为您可以命名参数,并在其中声明其意图。
Stefan Hanke

1
相关:stackoverflow:接口接口与代理的IOC工厂优缺点相反。@TheCatWhisperer问题更为笼统,而stackoverflow问题则缩小为特殊情况“ factory”
k3b

Answers:


11

如果一个接口仅包含一个功能,而不包含更多功能,并且没有令人信服的理由引入两个名称(接口名称接口内部的功能名称),则使用a Func可以避免不必要的样板代码,并且在大多数情况下更可取-就像您开始设计DTO并认识到它只需要一个成员属性一样。

我猜很多人都习惯使用interfaces,因为在依赖关系注入和IoC流行时,还没有真正等同Func于Java或C ++中的类的人(我什至不确定Func当时C#是否可用)。 )。因此interface,尽管使用起来Func会更优雅,但许多教程,示例或教科书仍然更喜欢这种形式。

您可能会看看我以前关于接口隔离原则和Ralf Westphal的Flow Design方法的答案。由于您自己(和其他一些人)已经提到过的完全相同的原因,此范式使用Func参数实现DI 。因此,正如您所看到的,您的想法并不是一个真正的新想法,相反。

是的,我自己将这种方法用于生产代码,用于需要以流水线形式处理数据的程序,其中包括几个中间步骤,包括每个步骤的单元测试。因此,我可以给您第一手的经验,它可以很好地工作。


该程序甚至在设计时都没有考虑接口隔离原理,它们基本上只是用作抽象类。这就是为什么我继续使用这种方法的原因,因为这些接口没有经过深思熟虑,并且几乎没有用,因为无论如何只有一个类可以实现它们。接口隔离原则并不一定要极端,尤其是现在有了Func,但在我看来,它仍然非常重要。
TheCatWhisperer

1
该程序甚至在设计时都没有考虑接口隔离原理 -根据您的描述,很可能您只是不知道该术语可用于您的设计类型。
布朗

Doc,我指的是我的前辈编写的代码,我正在重构这些代码,而我的新类在某种程度上依赖于此。我采用这种方法的部分原因是因为我计划将来对程序的其他部分进行重大更改,包括此类的依赖项
TheCatWhisperer

1
“ ...在依赖注入和IoC变得流行的时候,没有真正等同于Func类的文档。” Doc正是我几乎回答的内容,但我真的没有足够的信心!感谢您证实我对此的怀疑。
格雷厄姆

9

我发现IoC的主要优点之一是,它将允许我通过命名它们的接口来命名我的所有依赖项,并且容器将通过匹配类型名称来知道向构造函数提供哪个依赖项。这很方便,并且比允许使用更多描述性的依赖项名称Func<string, string>

我还经常发现,即使只有一个简单的依赖关系,有时也需要具有多个功能-接口允许您以自记录的方式将这些功能组合在一起,而不是拥有多个都像Func<string, string>Func<string, int>

肯定有很多时候,简单地将一个委托作为依赖来传递很有用。这是关于何时使用委托与拥有很少成员的接口的判断。除非真的很清楚该参数的目的是什么,否则我通常会在生成自文档代码方面犯错。即。编写界面。


当原始的实现者不再在那里时,我不得不调试某人的代码,并且我可以从最初的经验告诉你,找出哪个lambda运行在堆栈的下方是一项繁重的工作,并且确实令人沮丧。如果原始的实现者使用了接口,那么找到我想要的错误将很容易。
cwap

w,我感到你很痛苦。但是,在我的特定实现中,lambda都是指向单个函数的所有内衬。它们也都在一个地方生成。
TheCatWhisperer '04

以我的经验,这只是工具问题。ReSharpers检查->传入呼叫在跟踪funcs / lambda路径方面做得很好。
克里斯蒂安

3

使用泛型委托/高阶函数(例如Func for IoC),而不是使用更具体的接口时,是否存在任何问题?

并不是的。Func是它自己的一种接口(英文含义,不是C#含义)。“此参数是在询问时提供X的东西。” Func甚至具有仅根据需要延迟提供信息的好处。我对此做了一些建议,建议适度进行。

至于缺点:

  • IoC容器通常会做一些魔术来以级联的方式连接依赖项,并且在某些事物存在T并且某些事物存在时可能不会发挥很好的作用Func<T>
  • Funcs具有某种间接性,因此进行推理和调试可能会更加困难。
  • Funcs延迟实例化,这意味着运行时错误可能在奇怪的时间出现,或者在测试期间根本不出现。它还可能增加操作顺序问题的机会,并且根据您的使用,初始化顺序中会出现死锁。
  • 您传递给Func的内容可能是封闭的,带有一些开销和复杂性。
  • 调用Func比直接访问对象要慢一些。(不足以使您在任何非平凡的程序中都注意到它,但是它在那里)

1
我使用IoC容器以传统方式创建工厂,然后工厂将接口方法打包为lambda,以解决您在第一点中提到的问题。好点。
TheCatWhisperer

1

让我们举一个简单的例子-也许您正在注入一种记录方式。

注入课程

class Worker: IWorker
{
    ILogger _logger;

    Worker(ILogger logger)
    {
        _logger = logger;
    }
    void SomeMethod()
    {
        _logger.Debug("This is a debug log statement.");
    }
}        

我认为这很清楚。而且,如果您使用的是IoC容器,则无需显式注入任何东西,只需将其添加到合成根目录中:

container.RegisterType<ILogger, ConcreteLogger>();
container.RegisterType<IWorker, Worker>();
....
var worker = container.Resolve<IWorker>();

调试时Worker,开发人员只需要查阅组合根目录即可确定正在使用的具体类。

如果开发人员需要更复杂的逻辑,则可以使用整个界面:

    void SomeMethod()
    { 
       if (_logger.IsDebugEnabled) {
           _logger.Debug("This is a debug log statement.");
       }
    }

注入方法

class Worker
{
    Action<string> _methodThatLogs;

    Worker(Action<string> methodThatLogs)
    {
        _methodThatLogs = methodThatLogs;
    }
    void SomeMethod()
    {
        _methodThatLogs("This is a logging statement");
    }
}        

首先,请注意,构造函数参数的名称现在更长methodThatLogs。这是必要的,因为您无法确定an Action<string>应该做什么。使用该接口,它是完全清楚的,但是在这里,我们必须依靠参数命名。这似乎天生就不太可靠,并且在构建期间很难执行。

现在,我们如何注入这种方法?好吧,IoC容器不会为您做这件事。因此,您在实例化时将其明确注入Worker。这带来了两个问题:

  1. 实例化一个更多的工作 Worker
  2. 尝试进行调试的开发人员Worker会发现,很难弄清楚具体实例被调用了。他们不能仅仅参考合成词根。他们将不得不跟踪代码。

如果我们需要更复杂的逻辑怎么办?您的技术仅公开一种方法。现在,我想您可以将复杂的内容烘焙到lambda中:

var worker = new Worker((s) => { if (log.IsDebugEnabled) log.Debug(s) } );

但是在编写单元测试时,如何测试该lambda表达式?它是匿名的,因此您的单元测试框架无法直接实例化它。也许您可以找出一些聪明的方法来做到这一点,但是它可能比使用接口更大的PITA。

差异摘要:

  1. 仅注入方法会使推断目标变得更加困难,而接口则清楚地传达了目标。
  2. 仅注入方法会使接收注入的类的功能较少。即使您今天不需要,明天也可能需要。
  3. 您不能仅使用IoC容器自动注入方法。
  4. 您无法从组合根目录中判断在特定实例中哪个具体的类在起作用。
  5. 对lambda表达式本身进行单元测试是一个问题。

如果您对上述所有方法都满意,则可以只注入该方法。否则,我建议您坚持传统并注入一个界面。


我应该指定,我正在制作的类是过程逻辑类。它依靠外部类来获取做出明智决策所需的数据,而没有使用导致此类记录器的副作用。您的示例存在一个问题,即OO不良,尤其是在使用IoC容器的情况下。不应在类中使用if语句(这会增加额外的复杂性),而应仅将它传递给惰性记录器。另外,在lambda中引入逻辑确实会使测试更加困难……破坏了其使用目的,这就是为什么它没有完成的原因。
TheCatWhisperer '17

Lambda指向应用程序中的接口方法,并在单元测试中构造任意数据结构。
TheCatWhisperer

为什么人们总是专注于明显是任意例子的细节?好吧,如果您坚持谈论适当的日志记录,那么在某些情况下,例如,当要记录的字符串计算起来很昂贵时,惰性记录器将是一个糟糕的主意。
约翰·吴

我不确定“ lambdas指向接口方法”是什么意思。我认为您必须注入与委托签名匹配的方法的实现。如果该方法恰好属于接口,那是偶然的;没有编译时或运行时检查来确保能够进行检查。我误会了吗 也许您可以在帖子中包含一个代码示例?
吴敬Wu

我不能说我同意你的结论。一方面,您应该将类​​与最小数量的依赖项耦合在一起,因此使用lambda可以确保每个注入的项都是一个依赖项,而不是接口中多个事物的合并。您还可以支持一种模式,一次建立一个可行的Worker依赖关系,从而允许每个lambda独立注入,直到满足所有依赖关系为止。这类似于FP中的部分应用程序。此外,使用lambda有助于消除依赖之间的隐式状态和隐式耦合。
艾伦·埃希巴赫

0

考虑一下我很久以前写的以下代码:

public interface IPhysicalPathMapper
{
    /// <summary>
    /// Gets the physical path represented by the relative URL.
    /// </summary>
    /// <param name="relativeURL"></param>
    /// <returns></returns>
    String GetPhysicalPath(String relativeURL);
}

public class EmailBuilder : IEmailBuilder
{
    public IPhysicalPathMapper PhysicalPathMapper { get; set; }
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templateRelativeURL, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(this.PhysicalPathMapper.GetPhysicalPath(templateRelativeURL));

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

它需要相对于模板文件的相对位置,将其加载到内存中,呈现消息正文,并组装电子邮件对象。

您可能会IPhysicalPathMapper想到,“只有一个功能。可能是一个Func。” 但是实际上,这里的问题是IPhysicalPathMapper甚至不应该存在。更好的解决方案是仅将路径参数化

public class EmailBuilder : IEmailBuilder
{
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templatePath, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(templatePath);

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

这就提出了许多其他问题来改进此代码。例如,也许它应该只接受一个EmailTemplate,然后它应该接受一个预渲染的模板,然后也许应该对其进行内联。

这就是为什么我不喜欢将控制反转作为一种普遍的模式。通常,它是构成所有代码的类神解决方案。但是实际上,如果您普遍使用它(而不是很少使用),那么它会鼓励您引入很多完全向后使用的不必要的接口,从而使您的代码变得更加糟糕。(从某种意义上来说,调用者应该真正负责评估那些依赖关系并传递结果,而不是由类本身调用调用。)

应该谨慎使用接口,并且还应该谨慎使用控制反转和依赖注入。如果您有大量的代码,您的代码将变得更加难以解密。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.