文章目录
一、软件设计模式
1. 设计模式概念
软件设计模式(Software Design Pattern),又称设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。它描述了在软件设计过程中的一些不断重复发生的问题,以及该问题的解决方案。也就是说,它是解决特定问题的一系列套路,是前辈们的代码设计经验的总结,具有一定的普遍性,可以反复使用。
学习设计模式的必要性:
设计模式的本质是 面向对象设计原则 的实际运用,是对类的封装性、继承性和多态性以及类的关联关系和组合关系的充分理解。
正确使用设计模式具有以下优点。
- 可以提高程序员的思维能力、编程能力和设计能力。
- 使程序设计更加标准化、代码编制更加工程化,使软件开发效率大大提高,从而缩短软件的开发周期。
- 使设计的代码可重用性高、可读性强、可靠性高、灵活性好、可维护性强。
2. 设计模式分类
设计模式可以分为三个大类:创建型、结构型、行为型。如下:
-
创建型模式(5种)
用于描述“怎样创建对象”,它的主要特点是“将对象的创建与使用分离”。GoF(四人组)书中提供了单例、原型、工厂方法、抽象工厂、建造者等 5 种创建型模式。
-
结构型模式(7种)
用于描述如何将类或对象按某种布局组成更大的结构,GoF(四人组)书中提供了代理、适配器、桥接、装饰、外观、享元、组合等 7 种结构型模式。
-
行为型模式(11种)
用于描述类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,以及怎样分配职责和算法。GoF(四人组)书中提供了模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器等 11 种行为型模式。
二、软件设计原则
在软件开发中,为了提高软件系统的可维护性和可复用性,增加软件的可扩展性和灵活性,程序员要尽量根据7条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
1. 开闭原则
对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。
想要达到这样的效果,我们需要使用接口和抽象类。
因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象派生来的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。
例子:
比如程序员分为前端程序员、后端程序员,他们要做的都是去打代码,具体如何打代码根据不同语言的程序员决定。将程序员打代码的行为抽象成统一的接口或抽象类,就满足了开闭原则的第一个要求:对扩展开放。哪个程序员使用什么语言怎么编程,是自己在负责,不需要其他程序员干涉,就满足第二个要求:对修改关闭。
// 程序员
public abstract class Coder {
public abstract void coding();
// Java程序员
class JavaCoder extends Coder{
@Override
public void coding() {
}
}
// Python程序员
class PythonCoder extends Coder{
@Override
public void coding() {
}
}
// PHP程序员
class PHPCoder extends Coder{
@Override
public void coding() {
}
}
}
2. 里式替换原则
是对子类的特别定义。所有引用基类的地方必须能透明地使用其子类的对象。即,子类可以扩展父类的功能,但不能改变父类原有的功能。换句话说,子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
里氏替换也是实现开闭原则的重要方式。
经典例子:正方形不是长方形。
又或者可以看如下的示例:
// 程序员
public abstract class Coder {
// 写代码
public void coding() {
}
// Java程序员
class JavaCoder extends Coder{
// 打游戏
public void game(){
}
}
}
可以看到 JavaCoder 虽然继承自 Coder,但是并没有对父类方法进行重写,并是在父类的基础上进行额外扩展,符合里氏替换原则。
// 程序员
public abstract class Coder {
// 写代码
public void coding() {
}
// Java程序员
class JavaCoder extends Coder{
// 打游戏
public void game(){
}
// 写代码
@Override
public void coding() {
}
}
}
这里对父类的方法进行了重写,父类的行为就被子类覆盖了,这个子类已经不具备父类的原本的行为,违背了里氏替换原则。对于这种情况,我们不需要再继承自 Coder 了,可以提升一下,将此行为定义到 People 中:
// 人类
public abstract class People {
// 写代码。这个行为还是定义出来,但是不实现
public abstract void coding();
// 程序员
class Coder extends People{
// 写代码
@Override
public void coding() {
}
}
// Java程序员
class JavaCoder extends People{
// 打游戏
public void game(){
}
// 写代码
@Override
public void coding() {
}
}
}
3. 依赖倒转原则
高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。简单的说就是 要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
应用: Spring框架
回顾一下在使用Spring框架之前的情况
public class UserController {
UserService service = new UserService();
// 调用服务
static class UserService {
UserMapper mapper = new UserMapper();
// 业务代码......
}
static class UserMapper {
// CRUD......
}
}
突然有一天,公司业务需求变化,现在用户相关的业务操作需要使用新的实现:
public class UserController {
UserServiceNew service = new UserServiceNew();
// 调用服务
// 服务发生变化,新的方法在新的服务类中
static class UserServiceNew {
UserMapper mapper = new UserMapper();
// 业务代码......
}
static class UserMapper {
// CRUD......
}
}
各个模块之间是强关联的,一个模块是直接指定依赖于另一个模块。虽然这样结构清晰,但是底层模块的变动,会直接影响到其他依赖于它的高层模块。如果项目很庞大,这样的修改将是一场灾难。
而有了 Spring 框架之后,我们的开发模式就发生了变化:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
interface UserMapper {
// 接口中只做 CRUD 方法定义
}
static class UserMapperImpl implements UserMapper {
// 实现类完成 CRUD 具体实现
}
interface UserService {
// 业务接口定义......
}
static class UserServiceImpl implements UserService {
// 现在由Spring来为我们选择一个指定的实现类,然后注入,而不是由我们在类中硬编码进行指定
@Resource
UserMapper mapper;
// 业务代码实现......
}
static class UserController {
// 直接使用接口,就算你改实现,我也不需要再修改代码了
@Resource
UserService service;
// 业务代码......
}
}
通过使用接口,将原有的强关联给弱化,只需要知道接口中定义了什么方法然后去使用即可。而具体的操作由接口的实现类来完成,并由 Spring 来为我们注入,而不是我们通过硬编码的方式去指定。
4. 单一职责原则
单一职责原则(Simple Responsibility Pinciple,SRP)是最简单的面向对象设计原则,它用于 控制类的粒度大小。一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。
根据单一职责原则,我们需要进行更明确的划分,同种类型的操作才放在一起。比如有一个People类,他的方法里如果有编程coding()、拧螺丝work()、送外卖ride()等,就会显得太过臃肿,我们可以将类进一步划分,如程序员Coder类,只有一个方法编程coding()。
应用:
- 在设计 Mapper、Service、Controller 等都应该采用单一职责原则根据不同的业务划分,作为实现高内聚低耦合的指导方针。
- 实际上微服务也是参考了单一职责原则,每个微服务只应担负一个职责。
5. 接口隔离原则
接口隔离原则实际上是对接口的细化。客户端不应该依赖于那些它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
我们在定义接口的时候,一定要注意控制接口的粒度,比如下面的例子:
// 电子设备
interface Device {
// 获取 CPU 信息
String getCpu();
// 获取类型
String getType();
// 获取内存
String getMemory();
}
// 电脑是一种电子设备,那么就实现此接口
class Computer implements Device {
@Override
public String getCpu() {
return "i9-12900K";
}
@Override
public String getType() {
return "电脑";
}
@Override
public String getMemory() {
return "32G DDR5";
}
}
// 电风扇也算是一种电子设备
class Fan implements Device {
@Override
public String getCpu() {
// 风扇没有 CPU
return null;
}
@Override
public String getType() {
return "风扇";
}
@Override
public String getMemory() {
// 风扇没有内存
return null;
}
}
虽然定义了一个 Device 接口,但是由于此接口的粒度不够细,虽然比较契合电脑这种设备,但是不适合风扇这种设备。因为风扇压根就不需要 CPU 和内存,所以风扇完全不需要这些方法。
这时我们就必须要对其进行更细粒度的划分:
// 智能设备
interface SmartDevice {
// 获取 CPU 信息
String getCpu();
// 获取类型
String getType();
// 获取内存
String getMemory();
}
// 智能设备
interface NormalDevice {
// 获取类型
String getType();
}
// 电脑是一种智能设备,继承智能设备接口
class Computer implements SmartDevice {
@Override
public String getCpu() {
return "i9-12900K";
}
@Override
public String getType() {
return "电脑";
}
@Override
public String getMemory() {
return "32G DDR5";
}
}
// 电风扇是一种普通设备,继承普通设备接口
class Fan implements NormalDevice {
@Override
public String getType() {
return "风扇";
}
}
这样,就将接口进行了细粒度的划分,不同类型的电子设备根据划分去实现不同的接口。
当然,也不能划分得太小,还是要根据实际情况来进行决定。
5. 迪米特法则
迪米特法则(Law of Demeter)又称最少知识原则,是对程序内部数据交互的限制。
只和你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)。
其含义是: 如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
一个类/模块对其他的类/模块有越少的交互越好。当一个类发生改动,与其相关的类需要尽可能少的受影响。这样我们在维护项目的时候会更加轻松一些。其实本质还是降低耦合度。
public class Main {
public static void main(String[] args) throws IOException {
// 假设我们当前的程序需要进行网络通信
Socket socket = new Socket("localhost", 8080);
Test test = new Test();
// 现在需要执行 test 方法来做一些事情
test.test(socket);
}
static class Test {
// 比如 test 方法需要得到我们当前 Socket 连接的本地地址
public void test(Socket socket){
System.out.println("IP地址:" + socket.getLocalAddress());
}
}
}
虽然这种写法没有问题,直接提供一个 Socket 对象供使用,然后再由 test 方法来取出 IP 地址。但是这样显然违背了迪米特法则,实际上这里的 test
方法只需要一个 IP 地址即可。完全可以只传入一个字符串,而不是整个 Socket 对象,这样就保证了与其他类的交互尽可能的少。要是某一天,Socket 类中的这些方法发生修改了,那我们就得连带着去修改这些类,很麻烦。
就像在餐厅吃完了饭,应该是自己扫码付款,而不是直接把手机交给老板来帮你操作付款。
所以,来进行改进:
public class Main {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8080);
Test test = new Test();
// 在外面解析好再传入
test.test(socket.getLocalAddress().getHostAddress());
}
static class Test {
// 一个字符串就搞定了
public void test(String str){
System.out.println("IP地址:"+str);
}
}
}
这样,类与类之间的耦合度再次降低。
7. 合成复用原则
合成复用原则的核心就是委派。尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新的对象通过向这些对象的委派达到复用已有功能的目的。在考虑将某个类通过继承关系在子类得到父类已经实现的方法时,应该先考虑使用合成的方式来实现复用。
比如下面这个例子:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
// 直接通过继承的方式,得到 A 的数据库连接逻辑
class B extends A{
public void test(){
System.out.println("我是B的方法,我也需要连接数据库!");
// 直接调用父类方法
connectDatabase();
}
}
这样看起来没啥毛病,但还是存在之前说的问题,耦合度太高了。
通过继承的方式实现复用,是将类 B 直接指定继承自类 A 的。如果有一天,由于业务的更改,数据库连接操作不再由A来负责,而是由C去负责。就不得不将需要复用 A 中方法的子类全部进行修改,这样是费时费力的。并且还有一个问题,通过继承子类会得到一些父类中的实现细节,比如某些字段或是方法,这样直接暴露给子类,并不安全。
所以,当需要实现复用时,可以优先考虑以下操作:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
// 不进行继承,而是在用的时候给我一个 A,当然也可以抽象成一个接口,更加灵活
class B {
public void test(A a){
System.out.println("我是B的方法,我也需要连接数据库!");
// 通过对象 A 去执行
a.connectDatabase();
}
}
或者:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B {
A a;
// 在构造时就指定好
public B(A a){
this.a = a;
}
public void test(){
System.out.println("我是B的方法,我也需要连接数据库!");
// 通过对象 A 去执行
a.connectDatabase();
}
}
通过对象之间的组合,我们就大大降低了类之间的耦合度,并且 A 的实现细节也不会直接得到了。