设计模式-面向抽象编程(IOC+里氏代换原则)

依赖倒转原则详解

定义:
依赖倒转原则(Dependency Inversion Principle,简称 DIP)是面向对象设计(OOP)中的一项重要原则,其核心思想是:
高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
抽象不应该依赖于细节,细节应该依赖于抽象。

理解:

  • 高层模块: 通常是指业务逻辑层或应用层,负责处理业务逻辑。(Service层)
  • 低层模块: 通常是指数据访问层或基础设施层,负责提供数据访问或基础设施服务。(DAO层)
  • 抽象: 指的是接口或抽象类,定义了模块之间的依赖关系。
  • 细节: 指的是具体的实现类,实现了抽象所定义的功能。

依赖倒转的实现:
依赖倒转原则可以通过使用 接口抽象类 来实现。

  • 高层模块和低层模块都依赖于抽象,而不是具体的实现类。
  • 抽象定义了模块之间的依赖关系,具体实现类实现了抽象所定义的功能。

依赖倒转的优点:

  • 提高代码的灵活性和可维护性: 依赖倒转使得代码更加模块化,易于修改和扩展。
  • 降低耦合度: 依赖倒转使得高层模块和低层模块之间耦合度降低,提高了代码的可重用性。
  • 提高代码的测试性: 依赖倒转使得代码更容易测试,因为可以很容易地模拟抽象。

依赖倒转的应用:
依赖倒转原则可以应用于各种软件设计场景,例如:

  • 框架设计: 许多框架都采用了依赖倒转原则,例如 Spring 框架。
  • 业务逻辑设计: 可以使用依赖倒转原则来解耦业务逻辑和数据访问层。
  • 测试驱动开发: 依赖倒转原则可以使测试驱动开发更加容易。

示例:
假设有一个应用程序,需要访问数据库。我们可以使用依赖倒转原则来设计该应用程序:

  • 定义一个 数据库访问接口,定义了数据库访问操作。
  • 创建一个 数据库访问实现类,实现了 数据库访问接口。
  • 业务逻辑层依赖于 数据库访问接口,而不是具体的 数据库访问实现类。

这样,我们可以很容易地将业务逻辑层与数据库访问层解耦,提高代码的灵活性和可维护性。
总结:
依赖倒转原则是一项重要的面向对象设计原则,可以提高代码的灵活性和可维护性,降低耦合度,提高代码的测试性。

只是这样还不太能看出来依赖倒转究竟有什么好处,我们用代码来展示。

依赖倒转原则示例

场景:
假设我们有一个简单的应用程序,该应用程序需要将数据存储在数据库中。
传统方法:
传统的做法是直接在业务逻辑层中依赖数据库访问层。例如:

1
2
3
4
5
6
7
8
9
public class BusinessLogic {

private DatabaseAccess databaseAccess = new DatabaseAccess();

public void saveData(String data) {
databaseAccess.saveData(data);
}

}

这种方法耦合度很高,如果需要更改数据库访问层,则需要同时修改业务逻辑层。
依赖倒转原则:
我们可以使用依赖倒转原则来解耦业务逻辑层和数据库访问层。

  • 定义一个数据库访问接口,定义了数据库访问操作:

    1
    2
    3
    4
    5
    public interface DatabaseAccess {

    void saveData(String data);

    }
  • 创建一个数据库访问实现类,实现了数据库访问接口:

    1
    2
    3
    4
    5
    6
    7
    8
    public class DatabaseAccessImpl implements DatabaseAccess {

    @Override
    public void saveData(String data) {
    // 具体的数据库操作
    }

    }
  • 业务逻辑层依赖于数据库访问接口,而不是具体的 数据库访问实现类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class BusinessLogic {

    private DatabaseAccess databaseAccess;

    public BusinessLogic(DatabaseAccess databaseAccess) {
    this.databaseAccess = databaseAccess;
    }

    public void saveData(String data) {
    databaseAccess.saveData(data);
    }

    public String getData() {
    return databaseAccess.getData();
    }

    }
    1
    2
    3
    4
    5
    6
    7
    DatabaseAccess application = new DatabaseAccess(new MySQLAccess());
    String data = databaseAccess.getData();
    System.out.println(data); // 输出 "data from MySQL"

    DatabaseAccess databaseAccess = new Application(new OracleAccess());
    String data = databaseAccess.getData();
    System.out.println(data); // 输出 "data from Oracle"

    这样,业务逻辑层与数据库访问层就解耦了,我们可以很容易地将 数据库访问实现类 替换成其他实现,例如使用不同的数据库,如果您需要将数据存储方式从 MySQL 改为 Oracle,只需要将 BusinessLogic 类的构造函数参数改为 OracleDataAccessImpl 即可,而不需要修改业务逻辑层代码,这就是高层模块不应该依赖于低层模块,二者都应该依赖于抽象(二者都依赖于DatabaseAccess接口)
    依赖注入:
    我们可以使用依赖注入框架来将 数据库访问接口 注入到业务逻辑层中。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class BusinessLogic {

    @Autowired
    private DatabaseAccess databaseAccess;

    public void saveData(String data) {
    databaseAccess.saveData(data);
    }

    }

    这样,我们就不用在业务逻辑层中显式创建 数据库访问接口 的实例了。
    总结:
    依赖倒转原则可以提高代码的灵活性和可维护性,降低耦合度,提高代码的测试性。
    其他示例:
    依赖倒转原则可以应用于各种软件设计场景,例如:

  • 框架设计: 许多框架都采用了依赖倒转原则,例如 Spring 框架。

  • 测试驱动开发: 依赖倒转原则可以使测试驱动开发更加容易。

里氏代换原则

里氏代换原则:子类型必须能够替换掉它们的夫类型。
假设我们有一个 Animal 类,它定义了一个 eat() 方法:

1
2
3
4
5
public class Animal {
public void eat() {
System.out.println("Animal is eating");
}
}

我们还有一个 Dog 类,它继承自 Animal 类,并重写了 eat() 方法:

1
2
3
4
5
6
public class Dog extends Animal {
@Override
public void eat() {
System.out.println("Dog is eating");
}
}

现在,我们有一个程序,它使用 Animal 类型的对象:

1
2
Animal animal = new Animal();
animal.eat();

这个程序会输出 “Animal is eating”。
根据里氏代换原则,我们可以将 Animal 类型的对象替换为 Dog 类型的对象,而不会影响程序的执行结果:

1
2
Dog dog = new Dog();
dog.eat();

这个程序也会输出 “Animal is eating”。
重要性
里氏代换原则对于面向对象编程具有重要意义。它可以帮助我们确保程序的健壮性和可扩展性。
优势:当需求有变化,使得需要把“狗”换成“猫”,“猪”等其它动物,程序除了更改实例化的地方其他地方不需要改变,这些动物都具有吃,移动等行为
违反里氏代换原则的示例
以下示例违反了里氏代换原则:

1
2
3
4
5
6
7
8
9
10
11
12
public class Animal {
public void eat() {
System.out.println("Animal is eating");
}
}

public class Dog extends Animal {
@Override
public void eat(String food) {
System.out.println("Dog is eating " + food);
}
}

在这个例子中,Dog 类的 eat() 方法与 Animal 类的 eat() 方法的签名不同。Dog 类的 eat() 方法需要一个参数,而 Animal 类的 eat() 方法不需要参数。
如果我们将 Animal 类型的对象替换为 Dog 类型的对象,程序就会抛出异常:

1
2
Animal animal = new Dog();
animal.eat();

这个程序会输出 "java.lang.NoSuchMethodException: Animal.eat(java.lang.String)"
遵循里氏代换原则的建议
以下是一些遵循里氏代换原则的建议:

  • 子类应该继承父类的所有非抽象方法。
  • 子类可以重写父类的非抽象方法,但必须保证重写后的方法的行为与父类的方法的行为一致。
  • 子类可以添加新的方法。

总结
里氏代换原则是一条重要的面向对象设计原则。它可以帮助我们确保程序的健壮性和可扩展性。

再来一遍深入理解针对抽象编程!

根据上文你理解了什么是针对抽象编程了吗?
如果还没有理解,那就从上面的代码去思考吧,我们的Service层和Mapper层(或者叫Dao层)都是依赖于Mapper接口去编写这样,应用程序代码(Service层)就无需关心具体的数据库实现细节,只需要关心数据库访问接口。
这里我们举个反例来加强理解
如果直接在Service层中调用Mapper接口的实现类,放弃接口的话,当数据库发生变化时,例如升级到新的版本或更换数据库类型,应用程序代码也需要进行相应的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 数据库访问类
public class DatabaseAccess {
private String connectionString;

public DatabaseAccess(String connectionString) {
this.connectionString = connectionString;
}

public String getData() {
// 从数据库中获取数据
return "data from database";
}
}

// 应用程序
public class Application {
private DatabaseAccess databaseAccess;

public Application() {
// 使用 MySQL 数据库
this.databaseAccess = new DatabaseAccess("jdbc:mysql://localhost:3306/test");
}

public String getData() {
return databaseAccess.getData();
}
}

// 使用应用程序
public class Main {
public static void main(String[] args) {
Application application = new Application();
String data = application.getData();
System.out.println(data); // 输出 "data from database"
}
}

最后通过这两个原则可以凝练出一种面向对象设计的话:
编写时考虑的都是如何针对抽象编程而不是针对细节编程,即程序中所有的依赖关系都是终止于抽象类或者接口