如何在C#中使用依赖注入和继承

24

介绍

大家好,我正在使用C#编写一个持久化库。在这个库里,我实现了仓储模式,但是我遇到了SOLID原则的问题。下面是一个简化版的当前实现,以便集中讨论关键问题:

在持久化库中包含的抽象仓储:

public abstract class Repository<T> 
{
    protected Repository(
        IServiceA serviceA,
        IServiceB serviceB) 
    {
        /* ... */
    }
}

由库用户创建的具体代码仓库:

public class FooRepository : Repository<Foo> 
{
    protected FooRepository(
        IServiceA serviceA,
        IServiceB serviceB) :
        base(serviceA, serviceB)
    {
        /* ... */
    }
}

问题

目前的代码,派生类必须知道基类的每一个依赖关系,这可能没问题,但是如果我添加了基类的依赖项呢?每个派生类都会出现问题,因为它们需要将新的依赖项传递给基类...所以目前,我不能改变基类构造函数,这是一个问题,因为我希望我的基类具有进化的可能性。这种实现明显违反了开闭原则,但是我不知道如何解决这个问题而不破坏SOLID...

要求

  • 库对用户使用应该简单易懂
  • 可以通过DI来构建具体的仓库
  • 应该能够在抽象仓库中添加一个或多个依赖项,而不影响派生仓库
  • 应该可以使用命名约定在DI容器中注册每个仓库,就像ASP.NET MVC框架中的控制器一样
  • 用户应该能够在其派生仓库中添加更多依赖项(如果他愿意)

已经考虑的解决方案

1. 服务聚合器模式

根据这篇文章,服务聚合器模式可以应用于这种情况,因此代码将如下所示:

包含在持久性库中的抽象仓库:

public abstract class Repository<T> 
{

    public interface IRepositoryDependencies
    {
        IServiceA { get; }
        IServiceB { get; }
    }

    protected Repository(IRepositoryDependencies dependencies) 
    {
        /* ... */
    }
}

由库用户创建的具体存储库:

public class FooRepository : Repository<Foo> 
{
    protected Repository(IRepositoryDependencies dependencies) :
        base(dependencies)
    {
        /* ... */
    }
}

优点

  • 如果基类添加一个依赖项,派生类不会被破坏。

缺点

  • 如果我们添加一个依赖关系,则必须修改“IRepositoryDependencies”接口的实现。
  • 文章没有解释如何使用“Castle DynamicProxy2”(这对我来说是一种未知技术)来动态生成服务聚合器。

2. 建造者模式

也许可以删除基本存储库构造函数并引入建造者模板来创建存储库,但要使此解决方案工作,建造者必须是可继承的,以允许用户输入自己的存储库依赖项。

优点

  • 如果基类添加一个依赖项,派生类不会被破坏。
  • 存储库构建由另一个类管理。

缺点

  • 用户必须为他想要创建的每个存储库创建一个构建器。
  • 使用命名约定通过DI注册每个存储库变得更加困难。

3. 属性注入

也许可以取消基本存储库构造函数并配置DI使用属性注入。

优点

  • 如果基类添加一个依赖项,派生类不会被破坏。

缺点

  • 我认为属性设置器必须是公共的吗?

结论

在SOLID世界中,是否有任何提到的解决方案是可接受的?如果没有,你们有什么解决方法?非常感谢您的帮助!


3
基类的新依赖关系是干什么用的?如果基类需要一个新的依赖,那就意味着它将有额外的责任。换言之,它会做些什么,以前没有额外的依赖会做不到吗?这个新行为是否完全不属于该类? - Scott Hannen
如果我想添加一个简单的记录器怎么办?此外,我目前需要通过工作单元注册存储库的依赖关系,但如果将来不再需要呢?让派生类知道基类的每个依赖项真的可以吗? - Maxime Gélinas
如果你的基类有多个构造函数,那么作为用户,我会决定使用哪一个。如果我需要日志记录器,那么我会调用带有日志记录器的构造函数。你只需要确保实现正常工作,并且每个构造函数版本对于类都是有用的。将来,如果你需要添加额外的职责,那么你不应该这样做,因为这样会违反单一职责原则。 - CodingYoshi
@AnkitVijay 好的,但是如果构建基类的方式改变了怎么办?有可能改变一个类的构建方式而不添加或删除其职责,如果这种情况发生,每个派生类都必须更改吗?如果我添加一个依赖项,我可以添加一个构造函数,我理解你的观点是完全正确的,但是如果所需的依赖关系发生变化怎么办?我永远不应该更改所需的依赖关系吗? - Maxime Gélinas
@MaximeGélinas 关于记录器,有几种其他方式可以实现简单的日志记录。您可以使用装饰器模式或使用面向切面编程。我过去都很成功地使用了这两种方法。这里还有一些其他想法 https://softwareengineering.stackexchange.com/questions/298714/design-pattern-for-wrapping-logging-around-execution - Kerri Brown
显示剩余7条评论
4个回答

10

经过几年的经验积累,我发现装饰器模式非常适合这种情况。

实现:

// Abstract type
public interface IRepository<T>
{
    Add(T obj);
}

// Concete type
public class UserRepository : IRepository<User>
{
    public UserRepository(/* Specific dependencies */) {}

    Add(User obj) { /* [...] */ }
}

// Decorator
public class LoggingRepository<T> : IRepository<T>
{
    private readonly IRepository<T> _inner;

    public LoggingRepository<T>(IRepository<T> inner) => _inner = inner;

    Add(T obj) 
    {
        Console.Log($"Adding {obj}...");
        _inner.Add(obj);
        Console.Log($"{obj} addded.");
    }
}

用法:

// Done using the DI.
IRepository<User> repository = 
    // Add as many decorators as you want.
    new LoggingRepository<User>(
        new UserRepository(/* [...] */));

// And here is your add method wrapped with some logging :)
repository.Add(new User());

这种模式非常棒,因为您可以将行为封装在单独的类中,而不会产生破坏性变化,并且只在您真正需要它们时使用它们。


这不允许您在具体类中重用方法实现...也就是说,您必须显式地重新定义IRepository中的所有方法。 如果我继承了UserRepository,那么我可以直接调用Add(无需重新实现)。 - Vishesh Agarwal
@VisheshAgarwal 如果你想避免重新定义IRepository中的所有方法,可以添加一个名为RepositoryDecorator的基类,其中包含所有虚成员。但请记住,如果重新定义所有方法让你感到困扰,那可能是因为你的接口具有太多的方法/职责。 - Maxime Gélinas

6

根据您的要求,这里提供一个通过组合而非继承解决问题的基本粗略示例。

public class RepositoryService : IRepositoryService
{

    public RepositoryService (IServiceA serviceA, IServiceB serviceB) 
    {
        /* ... */
    }

    public void SomeMethod()
    {
    }     
}

public abstract class Repository
{
    protected IRepositoryService repositoryService;

    public (IRepositoryService repositoryService)   
    {
      this.repositoryService= repositoryService;
    }

    public virtual void SomeMethod()
    {
          this.repositoryService.SomeMethod()

          .
          .
    }
}

public class ChildRepository1 : Repository
{

    public (IRepositoryService repositoryService)  : base (repositoryService)
    {
    }

    public override void SomeMethod()
    {
          .
          .
    }
}

public class ChildRepository2 : Repository
{

    public (IRepositoryService repositoryService, ISomeOtherService someotherService)   : base (repositoryService)
    {
          .
          .
    }

    public override void SomeMethod()
    {
          .
          .
    }
}

现在,抽象基类和每个子存储库类只依赖于或任何其他所需的依赖项(参见中的)。
这样,您的子存储库仅向基类提供依赖项,而无需在任何地方提供的依赖项。

这个实现违反了我的一个要求(“库对用户来说应该易于使用”)。让用户为RepositoryService的每个方法都这样做很烦人。我正在寻找更像EF Core中的DbContext类的东西(一个无参数构造函数和改变默认选项的方法)。 - Maxime Gélinas
1
但是,您将拥有RepositoryServiceRepository类,而不是您的用户。因此,我不确定您的用户使用起来有多困难。 - Ankit Vijay
哦,我没有正确阅读代码……原来还有这种做法!谢谢! - Maxime Gélinas
如果这篇文章帮助您解决了问题,我想请您把它标记为答案。 - Ankit Vijay

1

可能有点晚了,但您可以在构造函数中使用IServiceProvider:

包含在持久性库中的抽象存储库:

public abstract class Repository<T> 
{
    protected Repository(IServiceProvider serviceProvider) 
    {
        serviceA = serviceProvider.GetRequiredService<IServiceA>();
        serviceB = serviceProvider.GetRequiredService<IServiceB>();
        /* ... */
    }
}

由库用户创建的具体存储库:

public class FooRepository : Repository<Foo> 
{
    protected FooRepository(IServiceProvider serviceProvider)
        :base(serviceProvider)
    {
        serviceC = serviceProvider.GetRequiredService<IServiceC>();
        serviceD = serviceProvider.GetRequiredService<IServiceD>();
        /* ... */
    }
}

这样每个类都可以使用自己的服务而不产生任何交叉依赖。
这种方法也有一些缺点。首先,这是一种具有抽象定位器的服务定位(反)模式,虽然这并不意味着它不能在任何地方使用,但需要明智地使用,并确保没有更好的解决方案。 其次,对于任何存储库类来说,没有编译时强制要求提供必要的服务实现。这意味着,即使您没有将IServiceA的具体实现放入服务提供程序中,它仍然可以编译。然后,在运行时会失败。然而,在这种情况下,那是您的要求之一。

这很像服务定位器模式,它有点像反模式。 - Maxime Gélinas

-3

结论“I'm limited to never change the base class constructor”只是展示了依赖注入容器“模式”的繁重程度。

想象一下,你有一个asp应用程序,并想要为特定用户启用日志记录功能,使得每个会话都能存储到新的文件中。使用IoC容器/服务定位器/ASP控制器构造函数是不可能实现这一点的。你能做的是:在每次会话开始时创建这样的记录器,并准确地通过所有构造函数(服务实现等越来越深)传递它。没有其他方法。IoC容器中不存在“每个会话”生命周期这样的概念 - 但这是ASP中实例应该自然存在的唯一方式(多用户/多任务应用程序)。

如果你没有通过构造函数进行依赖注入,那肯定是做错了什么(对于ASP.CORE和EF.CORE来说是如此 - 不可能看到它们如何折磨每个人以及自己的抽象泄露:你能想象添加自定义记录器会破坏 DbContext https://github.com/aspnet/EntityFrameworkCore/issues/10420 而这是正常的吗?

只从 DI 获取配置或动态插件(但如果您没有动态插件,请不要考虑任何依赖关系“因为它可能是一个动态插件”),然后通过构造函数以 DI 标准经典方式进行所有操作。


抱歉,我不太明白你在这里的建议是什么(也许是我的英语不好)?你告诉我不要考虑基类可能发生的演变?你还谈到了EF Core。EF Core做得很好,对吧?我想知道当没有调用基础构造函数时,EF Core DbContexts是如何构建的?例如,连接字符串或EF提供程序是如何收集的? - Maxime Gélinas
1
EF Core 显示了错误的方法。我所说的就是通过构造函数传递所有 DI。如果你做不到这一点,那么你肯定做错了什么。 - Roman Pokrovskij
当然我可以,但是这个示例会破坏您想象中的规则:“库应该对用户易于使用” - 您决定什么是容易什么不容易(而且它不是中立的)。我试着用不同的想法来回答你:想象一下,所有的对象(记录器和依赖于记录器的所有对象)都需要“每个会话”存活。你会怎么做? - Roman Pokrovskij
我希望用户可以从基本存储库继承并进行一些简单的配置,就这样。用户不应该编写太多代码来创建存储库。如果我参考EF,可以在选项中更改记录器工厂和内部服务提供程序。因此,如果我们想要,可以将一些“每个会话”服务传递给DbContext吗?为什么像这样做是不好的?我的意思是选项是完全可配置的? - Maxime Gélinas
在EF6中,记录“每个会话”是很正常的(您可以创建每个会话的记录器并订阅它到消息事件),但在EF Core中可能会非常复杂:https://dev59.com/XlcP5IYBdhLWcg3w5d0l#47490737(请阅读我的答案)。将一行事件订阅与使用超级IoC容器的EF Core中的百行池记录器进行比较。 - Roman Pokrovskij

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接