基于接口而非实现编程,你真的理解了吗

什么是面向接口编程

“基于接口而非实现编程”这条原则的英文描述是:“Program to an interface, not an implementation”。我们理解这条原则的时候,千万不要一开始就与具体的编程语言挂钩,局限在编程语言的“接口”语法中(比如Java中的 interface)。这条原则是一条比较抽象、泛化的设计思想。

实际上,理解这条原则的关键,就是理解其中的“接口”两个字,这里的“接口”是泛指,可以理解为“抽象”。“基于接口而非实现编程”这条原则的另一个表述方式是“基于抽象而非实现编程”,后者的表述方式其实更能体现这条原则的设计初衷。落实到具体的编码,“接口”可以理解为编程语言中的接口(interface)或者抽象类(abstract class)。

面向接口编程的好处

在我看来,面向接口编程可以带来两大好处:解耦和提供扩展性。面向接口编程可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。

解耦

软件设计与开发最重要的工作之一就是应对复杂性,而控制代码复杂性最关键的就是解耦。对依赖关系复杂的软件作解耦的常用手段就是抽象和增加中间层,无论哪种手段,都体现了基于抽象而非实现编程的思想

一个真实的典型耦合案例,就是java的logger,早些年,大家都用commons-logging、log4j并没有什么问题。然而,此处埋了一个雷一——那就是对logger实现的强耦合。当logback与log4j2出现后,情况变得复杂,程序员发现想要切换logger实现的代价非常高,为了减少应用层代码的改动,尽可能最小代价地完成logger实现切换,不得不上各种Bridge(适配器模式),到最后日志体系代码搞得极其复杂,没什么人看得懂。我本人在【从一个Logger异常开始梳理Java日志体系】以及【适配器模式及其在Java日志体系中的应用】这两篇博客中费了九牛二虎之力去搞清楚日志体系,各种adapter满天飞,让人眼花缭乱,下图来自SL4J官网文档,展示了将其它日志框架的API转调到slf4j的API上的过程,看着就头大。

试想如果能够时光倒流,我们一开始就在应用与日志实现之间加入一层抽象,让两者解耦,应用层基于日志的抽象编程而非某一个具体的日志实现,如下图所示。MyLogger接口这个关键抽象将接口和实现相分离,封装了不稳定的实现,暴露稳定的接口。应用层面向稳定的MyLogger接口而非实现编程,不依赖不稳定的实现细节,这样当底层的Logger实现发生变化的时候,应用层的代码基本上不需要做改动,显著降低耦合性

提供扩展性

可扩展设计,主要是利用了面向对象的多态特性,面向接口编程的思想将接口和实现分离,实现可以有N多种,上层可以按需加载最合适的那一个实现,这恰恰是良好扩展性的体现。

扩展性诉求在软件开发中无处不在,例如我要将系统产生的数据存储起来放置到如Oracle的数据库中,后来出现了IOE运动,我又要把数据放置到免费开源的MySQL数据库中。如果软件是直接基于底层存储为Oracle这个“现实”去编写的,当需要切换新的存储源时,就必须要修改应用代码了。更恰当的做法应该是定义一组接口,让不同的数据库厂商实现去实现这些接口,从而在切换配置实现的时候,应用代码不再需要更改了。

JDBC(Java Database Connection)就为Java开发者使用数据库提供了统一的编程接口,它由一组Java类和接口组成,是Java程序与数据库通信的标准API。只要数据库想要和Java连接的,数据库厂商必须自己实现JDBC这套接口,数据库厂商的JDBC实现,我们就叫它此数据库的驱动。

  • 装载MySQL驱动:Class.forName(“com.mysql.jdbc.Driver”);
  • 装载Oracle驱动:Class.forName(“com.jdbc.driver.OracleDriver”);
// 1.加载数据访问驱动
Class.forName("com.mysql.jdbc.Driver");
 //2.连接到数据库上去
Connection conn= DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb?characterEncoding=GBK", "root", "admin");

面向接口编程实现依赖倒置

依赖倒置原则的英文翻译是Dependency Inversion Principle,缩写为DIP,依赖倒置原则的含义有两层:

  1. 高层模块不要依赖低层模块,高层模块和低层模块应该通过抽象(abstractions)来互相依赖。
  2. 抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)要依赖抽象(abstractions)

这里“抽象”这个词是不是很熟悉,前面我们说了“基于接口而非实现编程”的真实含义就是“基于抽象编程”,而实现DIP的关键就是定义抽象,去依赖抽象而非实现(实现就是detail,是细节),所以实现DIP的手段无它,就是面向接口编程

DIP规则中提到了高层模块和低层模块,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的,例如MVC结构的工程,从高到低,Controller层调用Service层,Service层调用Repository层,每一层各司其职。DIP原则主要用来指导框架型软件的设计

我们拿Tomcat这个Servlet容器作为例子来解释一下,Tomcat是运行Java Web应用程序的容器。我们编写的Web应用程序代码只需要部署在Tomcat容器中,便可以被Tomcat容器调用执行。按照上面提到的划分原则,Tomcat就是高层模块,我们编写的Web应用程序代码就是低层模块。Tomcat和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是Servlet规范。Servlet规范只是定义并输出标准,不提供细节。而Tomcat容器和Web应用程序都去依赖Servlet规范,Web应用程序与容器之间的关系如下图所示,两者解耦,都去依赖抽象,各自独立发展,只要遵守规范即可。因此容器可以有很多种,例如Jetty与JBoss也是应用广泛的Servlet容器,而Spring可以算得上是最知名的实现了Servlet规范的Web应用程序开发框架了,它帮助程序员把很多可复用的底层工作都做好了。

设计符合依赖倒置原则的单元测试框架

上面提到DIP原则主要用来指导框架型软件的设计,框架的特征就是定义很多开放接口,让应用代码去实现接口,让框架去调用应用层,应用层不要调用框架。例如Tomca调用Web应用程序,但是Web应用程序不会反过来调用Tomcat。

这里从调用链来看,明明是框架(高层次模块)要调用应用层(低层次模块),对应用程序(低层次模块)有依赖性,但是应用程序却需要根据框架层(高层次模块)来设计,出现了「倒置」的现象,这里DIP的体现就是:高层不依赖低层,二者都依赖抽象,面向接口编程

先写一个普通的单元测试例子,在下面这个例子中,所有的流程都由程序员来控制,测试用例的执行与测试用例的编写混合在一起,理论上程序员应该将所有的精力都集中在编写测试用例上,用例的执行逻辑千篇一律,可以统一封装到框架层去,避免一段代码重复写N次。

public class CouplingUserServiceTest {

    public static boolean doTest() {
        return Math.random() - 0.5 > 0;
    }

    /**
     * 测试框架与被测试代码耦合在一起
     * 下面这段执行测试用例的代码,单独分离到测试框架中更好
     * 应用层专注于写测试用例即可,测试用例执行交给框架来做
     */
    public static void main(String[] args) {
        if (doTest()) {
            System.out.println("Test succeed.");
        } else {
            System.out.println("Test failed.");
        }
    }
}

下面,我们实现一个简单的单元测试框架,框架抽象出一些接口作为扩展点,将执行测试用例的流程统一封装到一处。把这个简化版本的测试框架引入到工程中之后,我们只需要在框架预留的扩展点,也就是TestCase类中的doTest()接口方法,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的main()函数了。

public interface TestCase {
    /**
     * 抽象方法,交给实现类去实现细节
     * @return
     */
    boolean doTest();

    /**
     * java 8 接口默认方法
     */
    default void run() {
        if (doTest()) {
            System.out.println(this.getClass().getName() + " Test succeed.");
        } else {
            System.out.println(this.getClass().getName() +  " Test failed.");
        }
    }
}

定义单元测试执行类,这个类负责加载所有的TestCase实例,并执行这些用例。

public class JunitApplication {

    private static final List<TestCase> testCases = new ArrayList<>();

    // 测试用例的注册操作还可以通过配置的方式来实现,不需要程序员显示调用register()
    // 以下仅为演示
    static {
        JunitApplication.register(new UserServiceTest());
    }

    public static void register(TestCase testCase) {
        testCases.add(testCase);
    }

    public static void main(String[] args) {
        for (TestCase testCase : testCases) {
            testCase.run();
        }
    }
}

程序员专注于写各种测试用例即可。

public class UserServiceTest implements TestCase {
    @Override
    public boolean doTest() {
        return Math.random() - 0.5 > 0;
    }
}

public class OrderServiceTest implements TestCase {
    @Override
    public boolean doTest() {
        // ...
    }
}

public class AccountServiceTest implements TestCase {
    @Override
    public boolean doTest() {
        // ...
    }
}

修改后的代码,单元测试框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。

其实TestCase类不一定要定义成接口,也可以定义成下面这样的抽象类。用这个抽象类去代替上面的接口,其他代码无需作任务修改,可无缝衔接。这恰好验证了本文开头处所说的,基于接口而非实现编程这句话中的“接口”是泛指,不能局限地理解为编程语言的“接口”语法,而应该理解为“抽象”,具体的编码实践,就是编程语言中的接口(interface)或者抽象类(abstract class)。

public abstract class TestCase {
    
    public void run() {
        if (doTest()) {
            System.out.println(this.getClass().getName() + " Test succeed.");
        } else {
            System.out.println(this.getClass().getName() +  " Test failed.");
        }
    }
    /**
     * 抽象方法,交给子类去实现
     * @return
     */
    public abstract boolean doTest();
}

要为所有类都定义接口吗

理解了基于接口而非实现编程这条原则后,程序员在开发的时候,是不是任何代码都要只依赖接口,完全不依赖实现编程呢?

如果这样想就走入了另一个极端——接口满天飞导致不必要的开发负担。什么时候要为类定义接口,什么时候直接使用实现类,做决策的根本依据还是要回归到这条设计原则诞生的初衷。

这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高扩展性。

从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式(例如Util类,负责DO与DTO转换的Transfer类),未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值