如何在不破坏封装性的情况下使用依赖注入?

35

如何在不破坏封装的情况下执行依赖注入?

使用维基百科上的依赖注入示例

public Car {
    public float getSpeed();
}

注意: 其他方法和属性(例如PushBrake(),PushGas(),SetWheelPosition()等)由于清晰起见而省略。

这很有效; 您不知道我的对象如何实现getSpeed - 它是"封装的"。

实际上,我的对象实现getSpeed 如下:

public Car {
    private m_speed;
    public float getSpeed( return m_speed; );
}

现在一切都很好。有人构建了我的Car对象,踩下了踏板,按下了喇叭,转动了方向盘,汽车做出了响应。

现在假设我改变了汽车的一个内部实现细节:

public Car {
    private Engine m_engine;
    private float m_currentGearRatio;
    public float getSpeed( return m_engine.getRpm*m_currentGearRatio; );
}

一切都很好。这个Car遵守了适当的OO原则,隐藏了如何完成某些操作的细节。这使得调用者可以解决他的问题,而不是试图理解汽车如何工作。它还赋予了我自由,根据需要更改我的实现。
但是依赖注入会强制我将我的类暴露给一个我没有创建或初始化的Engine对象。更糟糕的是,我现在已经暴露了我的Car甚至一个引擎。
public Car {
   public constructor(Engine engine);
   public float getSpeed();
}

现在外部世界知道我使用了一个引擎。我并非总是使用引擎,将来可能不会使用引擎,但我不能再改变我的内部实现:

public Car {
    private Gps m_gps;
    public float getSpeed( return m_gps.CurrentVelocity.Speed; )
}

不破坏调用者:

public Car {
   public constructor(Gps gps);
   public float getSpeed();
}

但是依赖注入会引发一系列问题:因为打开了整个“潘多拉魔盒”,所以依赖注入需要将我所有对象的私有实现细节暴露出来。我的Car类的使用者现在必须理解并处理我类的所有先前隐藏的内部复杂性:

public Car {
   public constructor(
       Gps gps, 
       Engine engine, 
       Transmission transmission,
       Tire frontLeftTire, Tire frontRightTire, Tire rearLeftTire, Tire rearRightTire, 
       Seat driversSeat, Seat passengersSeat, Seat rearBenchSeat,
       SeatbeltPretensioner seatBeltPretensioner,
       Alternator alternator, 
       Distributor distributor,
       Chime chime,
       ECM computer,
       TireMonitoringSystem tireMonitor
       );
   public float getSpeed();
}

我如何使用依赖注入的优点来帮助单元测试,同时不破坏封装的优点以帮助可用性?

另请参阅


为了好玩,我可以简化getSpeed示例,只保留必要的部分:

public Car {
   public constructor(
       Engine engine, 
       Transmission transmission,
       Tire frontLeftTire, Tire frontRightTire
       TireMonitoringSystem tireMonitor,
       UnitConverter unitsConverter
       );
   public float getSpeed()
   {
      float tireRpm = m_engine.CurrentRpm * 
              m_transmission.GetGearRatio( m_transmission.CurrentGear);

      float effectiveTireRadius = 
         (
            (m_frontLeftTire.RimSize + m_frontLeftTire.TireHeight / 25.4)
            +
            (m_frontRightTire.RimSize + m_frontRightTire.TireHeight / 25.4)
         ) / 2.0;

      //account for over/under inflated tires
      effectiveTireRadius = effectiveTireRadius * 
            ((m_tireMonitor.FrontLeftInflation + m_tireMontitor.FrontRightInflation) / 2.0);

      //speed in inches/minute
      float speed = tireRpm * effetiveTireRadius * 2 * Math.pi;

      //convert to mph
      return m_UnitConverter.InchesPerMinuteToMilesPerHour(speed);
   }
}

更新:也许有些回答可以跟随问题的线索,提供示例代码吗?

public Car {
    public float getSpeed();
}

另一个例子是当我的类依赖于另一个对象时:

public Car {
    private float m_speed;
}

在这种情况下,float 是一个用于表示浮点值的类。从我所读的内容来看,每个依赖的类都应该被注入 - 以防我想要模拟 float 类。这引发了注入每个私有成员的问题,因为每个东西在本质上都是一个对象。
public Car {
    public Constructor(
        float speed,
        float weight,
        float wheelBase,
        float width,
        float length,
        float height,
        float headRoom,
        float legRoom,
        DateTime manufactureDate,
        DateTime designDate,
        DateTime carStarted,
        DateTime runningTime,
        Gps gps, 
        Engine engine, 
        Transmission transmission,
        Tire frontLeftTire, Tire frontRightTire, Tire rearLeftTire, Tire rearRightTire, 
        Seat driversSeat, Seat passengersSeat, Seat rearBenchSeat,
        SeatbeltPretensioner seatBeltPretensioner,
        Alternator alternator, 
        Distributor distributor,
        Chime chime,
        ECM computer,
        TireMonitoringSystem tireMonitor,
        ...
     }

这些确实是实现细节,我不希望客户必须去查看。


1
关于最新的编辑:“这些确实是我不希望客户看到的实现细节。”那么,有什么替代方案吗?你打算硬编码它们吗? - Jeff Sternal
2
但是你是否建议硬编码 数值 呢?如果不在构造函数中公开 manufactureDate,你打算如何创建正确的汽车实例呢? - Jeff Sternal
@IanBoyd 我知道你已经接受了一个答案,但是你最终做了什么呢?工厂模式是最好的选择吗?那么我们在应用程序中每个真实类都应该有一个工厂吗? - Rodrigo Ruiz
@RodrigoRuiz 我最终采用了外部看起来车是一个单块的想法。Car类不会暴露或让调用者知道它的依赖关系。依赖关系是内部实现细节。这意味着Car构造函数没有参数。在内部,构造函数转而调用受保护的构造函数 - 那个需要所有依赖项的构造函数。换句话说:公共构造函数本质上是一个工厂。它创建所有依赖项,然后将它们传递给另一个内部构造函数。这仍然允许测试,但隐藏了依赖关系。 - Ian Boyd
@IanBoyd 如果您有时间,我想听听您对这个问题的看法 https://dev59.com/3onda4cB1Zd3GeqPDbwC - Rodrigo Ruiz
显示剩余4条评论
9个回答

13
许多其他答案都暗示了这一点,但是我将更明确地说,是的,依赖注入的天真实现可能会破坏封装性。 避免这种情况的关键在于调用代码不应直接实例化依赖项(如果它们不关心依赖项)。这可以通过许多方式实现。
最简单的方法只是使用一个带有默认值的默认构造函数进行注入。只要调用代码仅使用默认构造函数,您就可以在不影响调用代码的情况下在后台更改依赖项。
如果您的依赖关系本身具有依赖关系等,则此过程可能开始失控。此时,工厂模式可以起作用(或者您可以从一开始就使用它,以便调用代码已经使用该工厂)。如果引入工厂并且不想破坏现有用户的代码,则始终可以从默认构造函数调用工厂。
此外,还有使用控制反转的方法。我没有使用足够的IoC来谈论它,但这里有很多关于它的问题,以及在线文章比我更好地解释了它。
如果应该真正封装到调用代码无法知道依赖关系,则有以下选项:如果语言支持,则使注入(具有依赖参数的构造函数或设置器)internal ,否则将它们设置为私有,并使用Reflection之类的东西进行单元测试。如果您的语言都不支持,则我想可能性是,调用代码正在实例化只封装执行实际工作的类的虚拟类(我认为这是Facade模式,但我从来不记得名称)。
public Car {
   private RealCar _car;
   public constructor(){ _car = new RealCar(new Engine) };
   public float getSpeed() { return _car.getSpeed(); }
}

这似乎是最有意义的。困扰我的缺点是每个方法调用都会导致一个内部方法的thunk。如果做得足够多,我们最终会得到与Visual Studio或Eclipse运行一样快的东西。:( 不,我的语言没有内部类,也不能在运行时获取除“published”成员以外的任何内容的能力。 - Ian Boyd
关于额外的方法调用,如果它很简单,编译器可能能够将其优化掉,但我不确定(这显然取决于使用哪个编译器)。 - Davy8
最终,我的公共类变成了一个门面,一个包装器。真正的功能移动到一个内部的CoreCar类中,所有Car以前使用的对象现在都是“同级”类,传递到CoreCar中。 - Ian Boyd
你通过将单元测试向上移动一层成功避免了问题,那么在这种情况下,你将如何测试你的汽车或工厂呢? - Rodrigo Ruiz

7
如果我正确理解您的问题,您正在尝试防止需要实例化新Car对象的任何类手动注入所有这些依赖项。
我用了几种模式来做到这一点。在具有构造函数链接的语言中,我指定了一个默认构造函数,该构造函数将具体类型注入另一个依赖注入的构造函数。我认为这是一种相当标准的手动DI技术。
我使用的另一种方法,允许某些松耦合,就是创建一个工厂对象,该对象将使用适当的依赖项配置DI'ed对象。然后,我将此工厂注入到需要在运行时“new”一些车辆的任何对象中;这还允许您在测试期间注入完全虚假的汽车实现。
还有一种setter-injection方法。该对象将为其属性设置合理的默认值,可以根据需要替换为测试替身。不过,我更喜欢构造函数注入。
编辑以显示代码示例:
interface ICar { float getSpeed(); }
interface ICarFactory { ICar CreateCar(); }

class Car : ICar { 
  private Engine _engine;
  private float _currentGearRatio;

  public constructor(Engine engine, float gearRatio){
    _engine = engine;
    _currentGearRatio = gearRatio;
  }
  public float getSpeed() { return return _engine.getRpm*_currentGearRatio; }
}

class CarFactory : ICarFactory {
  public ICar CreateCar() { ...inject real dependencies... }    
}

然后消费者类只通过接口与其交互,完全隐藏任何构造函数。

class CarUser {
  private ICarFactory _factory;

  public constructor(ICarFactory factory) { ... }

  void do_something_with_speed(){
   ICar car = _factory.CreateCar();

   float speed = car.getSpeed();

   //...do something else...
  }
}

1
防止任何需要实例化新 Car 对象的类手动注入所有这些依赖项,同时我也试图防止我的类外部的任何人了解我的依赖关系。它们是一个私有实现细节,随时可能更改或消失。 - Ian Boyd
4
接口允许隐藏详细信息,但某些人会了解依赖关系——无论是手动构建的工厂还是IoC容器实际上实例化对象。只要消费者只从接口中使用,您就可以隐藏所有实现细节。因此,任何需要使用Car的类都将注入一个ICar,或者如果需要创建它们,则使用ICar工厂。 - Paul Phillips

3

我认为你在Car构造函数中违反了封装性。具体来说,你要求必须将一个Engine注入到Car中,而不是使用某种接口来确定你的速度(例如下面的IVelocity)。

使用接口,Car能够独立于决定速度的内容获取其当前速度。例如:

public Interface IVelocity {
   public float getSpeed();
}

public class Car {
   private m_velocityObject;
   public constructor(IVelocity velocityObject) { 
       m_velocityObject = velocityObject; 
   }
   public float getSpeed() { return m_velocityObject.getSpeed(); }
}

public class Engine : IVelocity {
   private float m_rpm;
   private float m_currentGearRatio;
   public float getSpeed( return m_rpm * m_currentGearRatio; );
}

public class GPS : IVelocity {
    private float m_foo;
    private float m_bar;
    public float getSpeed( return m_foo * m_bar; ); 
}

引擎或GPS可以根据其工作类型拥有多个接口。接口是依赖注入的关键,没有它,依赖注入就会破坏封装性。


我为了简洁起见省略了 Car 类的其他方法和属性。无论您将其称为 ICar 接口还是抽象的 Car 类,用户仍将创建汽车。而且,IVelocity 示例对于我的最终示例是不起作用的,因为它依赖于汽车中的所有内容(发动机、变速器、车轮、轮胎压力传感器)。 - Ian Boyd
2
@Ian - 这就是工厂模式派上用场的地方,通过它可以轻松处理创建复杂对象图的繁琐工作,或者使用 DI 框架自动完成这些任务。 - Gavin Miller

2
这是我认为您必须使用依赖注入容器的地方,它可以让您封装创建汽车的过程,而不需要让客户端调用者知道如何创建它。以下是Symfony解决此问题的方式(即使不是相同的语言,原则仍然相同):

http://components.symfony-project.org/dependency-injection/documentation

这段文字的意思是:有一个关于依赖注入容器的部分。简单来说,摘自文档页面的总结如下:当使用容器时,我们只需要请求一个邮件发送对象(在你的例子中就是汽车),不需要知道如何创建它,所有创建邮件发送对象(汽车)的知识现在都嵌入到容器中了。希望这能帮到你。

2

工厂和接口。

这里有几个问题。

  1. 如何使用多个实现相同操作?
  2. 如何从对象的消费者中隐藏对象的构建细节?

因此,您需要将真实代码隐藏在ICar接口后面,如果需要,创建单独的EnginelessCar,并使用ICarFactory接口和CarFactory类将构建细节从汽车的消费者中隐藏起来。

这很可能看起来很像依赖注入框架,但是您不必使用它。

根据我在另一个问题中的回答,是否会破坏封装取决于您如何定义封装。 我见过两种常见的封装定义:

  1. 逻辑实体的所有操作都公开为类成员,类的使用者不需要使用任何其他操作。
  2. 类具有单一职责,并且管理该职责的代码包含在类中。 也就是说,在编写类时,您可以有效地将其与其环境隔离并减少要处理的代码范围。

(像第一种定义的代码可以存在于与第二种条件配合使用的代码库中 - 它通常仅限于门面,并且这些门面往往具有最少或没有逻辑)。


依赖注入,在我看来,打破了您对封装的两个定义。 - Ian Boyd
我不同意。如果你把接口看作是可以发送的消息的定义,那么持有接口的引用并调用它看起来很像发送消息。在这种情况下,对象的行为可以定义为“给定这些输入消息,期望这些输出消息”。接收消息的人做什么不是发送者的责任。 - kyoryu
1
当类的使用者需要使用其他东西时,“...而且类的使用者不需要使用其他任何东西”就会出现问题。当责任不再包含在类中时,“...管理该责任的代码包含在类中”也会出现问题。 - Ian Boyd

1

我已经很久没有使用Delphi了。在Spring中,DI的工作方式是您的setter和构造函数不是接口的一部分。因此,您可以有多个接口实现,一个可能使用基于构造函数的注入,另一个可能使用基于setter的注入,使用接口的代码并不关心。被注入的内容在应用程序上下文xml中,这是您的依赖项唯一暴露的地方。

编辑: 无论您是否使用框架,您都在做同样的事情,您有一个工厂来连接您的对象。因此,您的对象在构造函数或setter中公开这些细节,但是您的应用程序代码(工厂之外,不包括测试)从不使用它们。无论哪种方式,您选择从工厂获取对象图,而不是即时实例化东西,并且选择不执行诸如在代码中使用旨在注入的setter之类的操作。这是从某些人的代码中看到的“钉住一切”的哲学的思维转变。


我删除了“delphi-5”标签。我只包含它是为了强制人们只考虑与语言无关的解决方案(即没有框架)。 - Ian Boyd

1

我认为汽车并不是依赖注入在现实世界中有用性的特别好的例子。

我认为在你最后的代码示例中,Car类的目的并不清楚。它是一个保存数据/状态的类吗?它是一个计算速度等事物的服务吗?还是一个混合体,允许您构建其状态,然后调用其上的服务来基于该状态进行计算?

在我看来,Car类本身很可能是一个有状态的对象,其目的是保存其组成细节,而计算速度的服务(如果需要,可以进行注入)将是一个单独的类(具有像“getSpeed(ICar car)”这样的方法)。使用DI的许多开发人员倾向于分离有状态和服务对象--尽管有些情况下对象既有状态又有服务,但大多数情况下它们会被分开。此外,DI的绝大多数用法 tend to be on the service side。

下一个问题是:汽车类应该如何组成?是每个具体的汽车只是 Car 类的一个实例,还是有一个单独的类为每个品牌和型号继承自 CarBase 或 ICar?如果是前者,那么必须有一些方法将这些值设置/注入到汽车中 - 即使您从未听说过依赖反转,也无法避免这种情况。如果是后者,那么这些值就是汽车的一部分,我不会想要使它们可设置/可注入。这归结于 Engine 和 Tires 这样的东西是否特定于实现(硬依赖项),或者它们是否可组合(松耦合依赖项)。
我理解汽车只是一个例子,但在现实世界中,您将是唯一知道反转类依赖关系是否违反了封装的人。如果是这样,你应该问的问题是“为什么?”而不是“如何?”(当然,这就是你正在做的事情)。

一个 Car 对象是一个对象的表示。对象具有内部状态,用于操作该状态的方法,有时还具有属性,允许选择性地读取或修改该状态。 - Ian Boyd
没错。我的意思是这种类型的对象通常不适合使用IOC/DI。虽然这并不构成“证明”,但请参见Windor文档,特别是在“What makes a good component”一节下面:http://www.castleproject.org/container/documentation/v21/usersguide/usingmicrokernel.html。 - Phil Sandler

1

你应该将你的代码分成两个阶段:

  1. 通过工厂或依赖注入解决方案构建特定生命周期的对象图
  2. 运行这些对象(涉及输入和输出)

在汽车工厂,他们需要知道如何制造汽车。他们知道它有什么样的发动机,喇叭是如何接线的等等。这就是上面所说的第一阶段。汽车工厂可以制造不同的汽车。

当你驾驶汽车时,你可以驾驶任何符合你期望的汽车接口的东西,如踏板,方向盘,喇叭等等。当你开车时,你不知道你踩刹车时的内部细节。然而,你可以看到结果(速度的变化)。

封装性得以保持,因为没有人驾驶汽车需要知道它是如何制造的。因此,你可以使用相同的驱动程序驾驶许多不同的汽车。当驱动程序需要一辆汽车时,应该给他一辆。如果他们自己制造汽车,那么封装性将被破坏。


0

现在,来点完全不同的东西...

你想要依赖注入的优点,但又不想破坏封装性。依赖注入框架可以为你做到这一点,但是通过一些创造性地使用虚构造函数、元类注册和选择性地包含项目中的单元,你也可以使用“穷人版依赖注入”。

然而,它确实有一个严重的限制:每个项目中只能有一个特定的Engine类。不能挑选和选择引擎,虽然想想看,你可能可以通过改变元类变量的值来实现这一点。但我有点跑题了。

另一个限制是单一继承线:只有一个主干,没有分支。至少对于单个项目中包含的单元而言。

你似乎正在使用Delphi,因此下面的方法将按原样工作,因为自D5以来,我们一直在需要TBaseX类的单个实例的项目中使用它,但是不同的项目需要该基类的不同子类,并且我们希望能够通过简单地删除一个单元并添加另一个单元来交换类。这个解决方案不仅限于Delphi。它适用于任何支持虚构造函数和元类的语言。

那么你需要什么呢?

好的,每个想要根据项目中包含的单位进行交换的类都需要在某个地方拥有一个变量,以存储要实例化的类类型:

var
  _EngineClass: TClass;

每个实现引擎的类都应该使用一种方法将自己注册到_EngineClass变量中,以防止祖先取代后代的位置(这样可以避免对单元初始化顺序的依赖):
procedure RegisterMetaClass(var aMetaClassVar: TClass; const aMetaClassToRegister: TClass);
begin
  if Assigned(aMetaClassVar) and aMetaClassVar.InheritsFrom(aMetaClassToRegister) then
    Exit;

  aMetaClassVar := aMetaClassToRegister;
end;

可以在一个公共的基类中注册类:

  TBaseEngine
  protected
    class procedure RegisterClass; 

class procedure TBaseEngine.RegisterClass;
begin
  RegisterMetaClass(_EngineClass, Self);
end;

每个子级通过在其单元的初始化部分调用注册方法来注册自己:

type
  TConcreteEngine = class(TBaseEngine)
  ...
  end;

initialization
  TConcreteEngine.RegisterClass;

现在你所需要的就是实例化“后代最多”的已注册类,而不是硬编码特定的类。
  TBaseEngine
  public
    class function CreateRegisteredClass: TBaseEngine; 

class function TBaseEngine.CreateRegisteredClass: TBaseEngine;
begin
  Result := _EngineClass.Create;
end;

当然,现在你应该总是使用这个类函数来实例化引擎,而不是普通的构造函数。

如果你这样做,你的代码将总是实例化项目中存在的“最后代”的引擎类。你可以通过包含或不包含特定的单元来在类之间切换。例如,你可以通过使模拟类成为实际类的祖先并在测试项目中不包含实际类来确保你的测试项目使用模拟类;或者通过使模拟类成为实际类的后代并在正常代码中不包含模拟类来实现;或者更简单的方法是在你的项目中包含模拟类实际类之一。


在这个实现示例中,Mock和Actual类都有一个无参数构造函数。这不是必须的,但由于var参数,在调用RegisterMetaClass过程时需要使用特定的元类(而不是TClass)和一些强制类型转换。
type
  TBaseEngine = class; // forward
  TEngineClass = class of TBaseEngine;
var
  _EngineClass: TEngineClass

type
  TBaseEngine = class
  protected
    class procedure RegisterClass;
  public
    class function CreateRegisteredClass(...): TBaseEngine;
    constructor Create(...); virtual;

  TConcreteEngine = class(TBaseEngine)
    ...
  end;

  TMockEngine = class(TBaseEngine)
    ...
  end;

class procedure TBaseEngine.RegisterClass;
begin
  RegisterMetaClass({var}TClass(_EngineClass), Self);
end;

class function TBaseEngine.CreateRegisteredClass(...): TBaseEngine;
begin
  Result := _EngineClass.Create(...);
end;

constructor TBaseEngine.Create(...);
begin
  // use parameters in creating an instance.
end;

玩得愉快!


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