Java中的控制(耦合)反转
什么是控制反转?什么是依赖注入?这些类型的问题通常会遇到代码示例,模糊解释以及StackOverflow上标识为“ 低质量答案 ”的问题。
我们使用控制反转和依赖注入,并经常将其作为构建应用程序的正确方法。然而,我们无法清晰地阐明原因!
原因是我们还没有清楚地确定控制是什么。一旦我们理解了我们正在反转的内容,控制反转与依赖注入的概念实际上并不是要问的问题。它实际上变成了以下内容:
控制反转 = 依赖(状态)注入 + 线程注入 + 连续(函数)注入
为了解释这一点,我们来写一些代码。是的,使用代码来解释控制反转的明显问题正在重复,但请耐心等待,答案一直在你眼前。
一个明确使用控制反转/依赖注入的模式是存储库模式,来避免绕过连接。而不是以下:
public class NoDependencyInjectionRepository implements Repository<Entity> {
public void save(Entity entity, Connection connection) throws SQLException {
// Use connection to save entity to database
}
}
依赖注入允许将存储库重新实现为:
public class DependencyInjectionRepository implements Repository<Entity> {
@Inject Connection connection;
public void save(Entity entity) throws SQLException {
// Use injected connection to save entity to database
}
}
现在,你看到我们刚刚解决的问题了吗?
如果您正在考虑“我现在可以更改 connection 来使用REST调用” ,这一切都可以灵活改变,那么您就会很接近这个问题。
要查看问题是否已解决,请不要查看实现类。相反,看看接口。客户端调用代码已经从:
repository.save(entity, connection);
变为以下内容:
repository.save(entity);
我们已经移除了客户端代码的耦合,以提供一个 connection 在调用方法上。通过删除耦合,我们可以替换存储库的不同实现(再次,无聊的代码,但请忍受我):
public class WebServiceRepository implements Repository<Entity> {
@Inject WebClient client;
public void save(Entity entity) {
// Use injected web client to save entity
}
}
客户端能够继续调用方法:
repository.save(entity);
客户端不知道存储库现在调用微服务来保存实体而不是直接与数据库通信。(实际上,客户已经知道,但我们很快就会谈到这一点。)
因此,将此问题提升到关于该方法的抽象级别:
R method(P1 p1, P2 p2) throws E1, E2
// with dependency injection becomes
@Inject P1 p1;
@Inject P2 p2;
R method() throws E1, E2
通过依赖注入消除了客户端为该方法提供参数的耦合。
现在,你看到耦合的其他四个问题了吗?
在这一点上,我警告你,一旦我向你展示耦合问题,你将永远不会再看同样的代码了。 这是矩阵中我要问你是否想要红色或蓝色的要点。一旦我向你展示这个问题真正的兔子洞有多远,就没有回头了 - 实际上没有必要进行重构,而且在建模逻辑和计算机科学的基础知识方面存在问题(好的,大的声明,但请继续阅读 - 我不会把它放在任何其他方式)。
所以,你选择了红点。
让我们为你做好准备。
为了识别四个额外的耦合问题,让我们再看一下抽象方法:
@Inject P1 p1;
@Inject P2 p2;
R method() throws E1, E2
// and invoking it
try {
R result = object.method();
} catch (E1 | E2 ex) {
// handle exception
}
什么是客户端代码耦合?
返回类型
方法名称
处理异常
提供给该方法的线程
依赖注入允许我更改方法所需的对象,而无需更改调用方法的客户端代码。但是,如果我想通过以下方式更改我的实现方法:
更改其返回类型
修改它的名称
抛出一个新的异常(在上面的交换到微服务存储库的情况下,抛出HTTP异常而不是SQL异常)
使用不同的线程(池)执行方法而不是客户端调用提供的线程
这涉及“ 重构 ”我的方法的所有客户端代码。当实现具有实际执行功能的艰巨任务时,为什么调用者要求耦合?我们实际上应该反转耦合,以便实现可以指示方法签名(而不是调用者)。
你可能就像Neo在黑客帝国中所做的那样“哼”一下吗?让实现定义他们的方法签名?但是,不是覆盖和实现抽象方法签名定义的整个OO原则吗?这样只会导致更混乱,因为如果它的返回类型,名称,异常,参数随着实现的发展而不断变化,我如何调用该方法?
简单。你已经知道了模式。你只是没有看到他们一起使用,他们的总和比他们的部分更强大。
因此,让我们遍历方法的五个耦合点(返回类型,方法名称,参数,异常,调用线程)并将它们分离。
我们已经看到依赖注入删除了客户端的参数耦合,所以一个个向下。
接下来,让我们处理方法名称。
方法名称解耦
许多语言(包括Java lambdas)允许或具有该语言的一等公民的功能。通过创建对方法的函数引用,我们不再需要知道方法名称来调用该方法:
Runnable f1 = () -> object.method();
// Client call now decoupled from method name
f1.run()
我们现在甚至可以通过依赖注入传递方法的不同实现:
@Inject Runnable f1;
void clientCode() {
f1.run(); // to invoke the injected method
}
好的,这是一些额外的代码,没有太大的额外价值。但是,再次,忍受我。我们已将方法的名称与调用者分离。
接下来,让我们解决方法中的异常。
方法异常解耦
通过使用上面的注入函数技术,我们注入函数来处理异常:
Runnable f1 = () -> {
@Inject Consumer<E1> h1;
@Inject Consumer<E2> h2;
try {
object.method();
} catch (E1 e1) {
h1.accept(e1);
} catch (E2 e2) {
h2.accept(e2);
}
}
// 注意:上面是用于标识概念的抽象伪代码(我们将很快编译代码)
现在,异常不再是客户端调用者的问题。注入的方法现在处理将调用者与必须处理异常分离的异常。
接下来,让我们处理调用线程。
方法的调用线程解耦
通过使用异步函数签名并注入Executor,我们可以将调用实现方法的线程与调用者提供的线程分离:
Runnable f1 = () -> {
@Inject Executor executor;
executor.execute(() -> {
object.method();
});
}
通过注入适当的 Executor,我们可以使用我们需要的任何线程池调用的实现方法。要重用客户端的调用线程,我们只需要同步Exectutor:
Executor synchronous = (runnable) -> runnable.run();
所以现在,我们可以解耦一个线程,从调用代码的线程执行实现方法。
但是没有返回值,我们如何在方法之间传递状态(对象)?让我们将它们与依赖注入结合在一起。
控制(耦合)反转
让我们将上述模式与依赖注入相结合,得到ManagedFunction:
public interface ManagedFunction {
void run();
}
public class ManagedFunctionImpl implements ManagedFunction {
@Inject P1 p1;
@Inject P2 p2;
@Inject ManagedFunction f1; // other method implementations to invoke
@Inject ManagedFunction f2;
@Inject Consumer<E1> h1;
@Inject Consumer<E2> h2;
@Inject Executor executor;
@Override
public void run() {
executor.execute(() -> {
try {
implementation(p1, p2, f1, f2);
} catch (E1 e1) {
h1.accept(e1);
} catch (E2 e2) {
h2.accept(e2);
});
}
private void implementation(
P1 p1, P2 p2,
ManagedFunction f1, ManagedFunction f2
) throws E1, E2 {
// use dependency inject objects p1, p2
// invoke other methods via f1, f2
// allow throwing exceptions E1, E2
}
}
好的,这里有很多东西,但它只是上面的模式结合在一起。客户端代码现在完全与方法实现分离,因为它只运行:
@Inject ManagedFunction function;
public void clientCode() {
function.run();
}
现在可以自由更改实现方法,而不会影响客户端调用代码:
方法没有返回类型(一般的限制可以使用void,但是异步代码是必需的)
实现方法名称可能会更改,因为它包含在 ManagedFunction.run()
不再需要参数ManagedFunction。这些是依赖注入的,允许实现方法选择它需要哪些参数(对象)
异常由注入的Consumers处理。实现方法现在可以规定它抛出的异常,只需要Consumers 注入不同的异常 。客户端调用代码不需要知道实现方法,现在可以自定义抛出 HTTPException 而不是 SQLException 。此外, Consumers 实际上可以通过ManagedFunctions 注入异常来实现 。
注入Executor 允许实现方法通过指定注入的Executor来指示其执行的线程 。这可能导致重用客户端的调用线程或让实现由单独的线程或线程池运行
现在,通过其调用者的方法的所有五个耦合点都是分离的。
我们实际上已经“对耦合进行了反向控制”。换句话说,客户端调用者不再指定实现方法可以命名的内容,用作参数,抛出异常,使用哪个线程等。耦合的控制被反转,以便实现方法可以决定它耦合到什么指定它是必需的注射。
此外,由于调用者没有耦合,因此不需要重构代码。实现发生变化,然后将其耦合(注入)配置到系统的其余部分。客户端调用代码不再需要重构。
因此,实际上,依赖注入只解决了方法耦合问题的1/5。对于仅解决20%问题非常成功的事情,它确实显示了该方法的耦合问题究竟有多少。
实现上述模式将创建比您的系统中更多的代码。这就是为什么开源框架OfficeFloor是控制框架的“真正”反转,并且已经整合在一起以减轻此代码的负担。这是上述概念中的一个实验,以查看真实系统是否更容易构建和维护,具有“真正的”控制反转。
摘要
因此,下次你遇到Refactor Button / Command时,意识到这是通过每次编写代码时一直盯着我们的方法的耦合引起的。
真的,为什么我们有方法签名?这是因为线程堆栈。我们需要将内存加载到线程堆栈中,并且方法签名遵循计算机的行为。但是,在现实世界中,对象之间行为的建模不提供线程堆栈。对象都是通过很小的接触点松耦合 - 而不是由该方法施加的五个耦合方面。
此外,在计算中,我们努力实现低耦合和高内聚。有人可能会提出一个案例,来对比ManagedFunctions,方法是:
高耦合:方法有五个方面耦合到客户端调用代码
低内聚:随着方法处理异常和返回类型开始模糊方法的责任随着时间的推移,持续变化和快捷方式会迅速降低方法实施的凝聚力,开始处理超出其责任的逻辑
由于我们力求低耦合和高内聚,我们最基本的构建块( method 和 function)可能实际上违背了我们最核心的编程原则。
原文链接:https://dzone.com/articles/inversion-of-coupling-control
作者:Daniel Sagenschneider
译者:xuli
关注公众号
点击原文阅读更多