面试官:聊聊你知道的设计原则?

写在前面

每位程序员写代码时,潜意识中都有着一套自己的思维方式和解决问题的思路 这些不同的思路都会转化成风格和范式出现在代码中

代码风格(programing style)

  • 声明式(declarative programming): 更加强调代码结果是什么
  • 命令式(imperative programming): 更加强调代码结果怎么来

代码范式(programming paradigm)

  • 面向过程编程(procedure-oriented programming)
    • 线性执行的编程思想
    • 以程序 = 算法 + 数据结构 为核心
  • 面向对象编程(object-oriented programming)
    • 为了解决面向过程编程,面对变化不易扩展的问题而诞生的编程思想
    • 以封装、继承、抽象、多态为核心
  • 面向函数编程(functional programming)
    • 为了解决面向过程编程,面对变化不易维护的问题而诞生的编程思想
    • 以纯函数、高阶函数、不可变数据为核心

最终,一个混合着不同比例编程风格和范式的代码被发布到线上,完成了产品的需求

在软件行业中,随着需求迭代,可扩展、可读、可维护的人类高质量代码 成了同事借鉴临摹的范例,而仅仅是"能用"的低质量代码则成为了同事口中的诟病

人类高质量代码

我们可以通过完成一个前端需求来看出低质量代码高质量代码 的区别:实现Tab组件逻辑,使tab1和tab2都能正常交互

面向过程实现:jQuery梭哈

Tab功能确实是实现了,但正因为是面向过程的实现,代码流程相对固定,扩展起来比较麻烦,比如我要改变初始Tab坐标和展示内容页数,给第一个Tab组件改文案,给第二个Tab组件改样式等等,50行以内的代码还好说,但如果这是个1000行面向过程编程实现的代码,那我得一行一行地读代码改细节,回归测试范围也很难评估,懂的都懂

对于工业系统来说,比如啤酒、可乐、老干妈生产流水线,讲究的是效率,扩展性不是那么重要,一旦一条生产线建成,至少需要运行几年甚至几十年;但对于软件系统来说,很难想象一个需求完成上线后几年都不变的,大部分情况是一年扩展多次,有时还没开发完新需求又来了,在软件系统这样的环境下,面向过程编程就显得比较吃力,每次需求变更可能都要对流程的每个步骤甚至是全局进行修改,不但工作量会大大增加,风险也在大大增加

面向对象编程OOP正是为了解决面向过程的这个缺点诞生的,其思想的核心就是可扩展性!

让我们来看一下如果使用面向对象的方式,这个需求会是怎么被实现和后续扩展的

面向对象实现:复用设计 + 参数设计

这里我们可以看到,当过程被抽象封装成对象和方法后,可以很方便地通过new的方式构造出一模一样的Tab实例出来,还可以通过传入不同的参数,改变Tab组件起始坐标和展示内容,相比面向过程编程,复用性和灵活性大大提高

扩展:tab1和tab2同时加载完成时打印日志、tab1切换Tab时打印日志

当要给Tab组件添加打印日志的扩展功能时,我们可以单独实现一个EventEmitter类专门来负责处理回调函数的逻辑,最后让Tab类继承EventEmitter类,在初始化Tab类的时候调用EventEmitter.on和EventEmitter.trigger来注册和调用自定义方法,完成需求的同时还为后续的自定义回调函数留下了扩展点

扩展:把tab2升级成可以自动播放的轮播图组件

由于轮播图组件和Tab组件有着相似的行为,所以只要在构造Tab类的基础上新增自动播放的功能,也就是在Slider类里继承并定时调用Tab类的active方法,就能完成一个轮播图Slider类,完全不需要重新从零实现一个

面向对象编程概述

如今,主流编程范式中最流行的是面向对象编程,大部分项目也是基于此范式进行迭代开发的,但很多人对面向对象的理解很粗浅,认为面向对象就是写写class,于是工作越久疑惑就越来越多:

  • 为什么要这样写class,不那样写class,反正都能完成任务啊?
  • 除了Request、Response、DAO、BO、DTO用class,其他地方都是面向过程编程?
  • Response用Object行不行啊?
  • 为什么我要设计这个class,你要设计那个interface,标准是什么?
  • 什么时候用继承,什么时候用组合,什么时候用接口,什么时候用抽象类?
  • 如何判断面向对象设计做得好还是不好?

好在业界总结出许多设计原则(design principle) 来指导我们怎么设计类的行为,也总结出了设计模式(design pattern) 来指导我们怎么设计类的交互,两者都是在OOP范式中找到稳定点封装扩展点为前提展开的,两者是相辅相成的,即:设计原则指导设计模式,设计模式实现设计原则

学会如何利用设计原则和设计模式正确地写出让人眼前一亮的代码,是一个成熟程序员该有的觉悟,因为面向需求翻译编程和面向代码质量编程是区分程序员(programmer)和工程师(engineer)的标杆

设计原则介绍

设计原则是软件开发高层面的设计纲领,并不涉及具体实现,也不依赖具体语言,怎么遵守设计原则去实现具体需求完全取决于开发

设计模式是软件开发低层面的落地实践,是针对某一类具体需求的特定实现套路,并且这些套路被证明是有利于扩展的,具有很好的参考意义

如何才能写出扩展性好的代码,首先要从设计原则讲起,设计原则从最开始Bob大叔(Robert C. Martin aka Uncle Bob)2000年论文(Design Principles and Design Patterns)提出的SOLID五原则演变至今,但如果现在我们百度上去搜OOP设计原则,会发现搜出来的要么是五大设计原则,要么是六大设计原则,七个就到头了,八大九大十大后面的就再也没有了,虽然我们可以自己发明设计原则,可二十年过去了,能被业界接受并上得了台面的设计原则真的就只有SOLID这五个吗?

接下来我们来逐个讲解目前耳熟能详的新生代设计原则

Open Closed Principle

Open Closed Principle 又叫开闭原则,讲的是在扩展新功能时要对模块的扩展开放修改关闭,目的是防止实现新需求的同时改坏了老代码

首先我们要弄清一个问题,如果给一个已有类新增了10个字段,影响到了老代码已有缓存值的结构和后续读写缓存的操作,算不算违背了开闭原则?

我们一定要搞清楚,设计原则是指导OOP编程范式的设计纲领,一个对象在声明时只有属性没有行为,那它只能算作一个key-value数据结构(贫血模型)不能算成OOP中的对象(充血模型),属于面向过程编程和函数式编程范式的数据载体,我们不能用OOP范式的规范去限制其他范式的行为,所以开闭原则在这种情况下并不是一个合适的评判标准,更改数据结构加新字段自然也不存在违背了开闭原则的说法,只有搞清了这一点我们才能继续往下看

现在有个需求:分别在3个接口打印出Person类的姓名和年龄,那我们会这么写

java 复制代码
// Person.java
class Person{
    public Person(String name, Integer age){
        this.name = name;
        this.age = age;
    }
    public sayNameAndAge(){
        System.out.println(this.name);
        System.out.println(this.age);
    }
}
 
// ControllerA.java
Person p = new Person("卢本伟", 28);
p.sayNameAndAge()
 
// ControllerB.java
Person p = new Person("white", 28);
p.sayNameAndAge()
 
// ControllerC.java
Person p = new Person("55开", 28);
p.sayNameAndAge()

第二天产品提了个新需求:新增一个接口,打印出Person类的姓名年龄,还要打印出性别

如果违背了开闭原则就会是这样写

java 复制代码
// Person.java
class Person{
    public Person(String name, Integer age, String sex){
        this.name = name;
        this.age = age;
        this.sex = sex;
    }
    public sayNameAndAge(){
        System.out.println(this.name);
        System.out.println(this.age);
    }
    public sayNameAndAgeAndSex(){
        System.out.println(this.name);
        System.out.println(this.age);
        System.out.println(this.sex);
    }
}
 
// ControllerA.java
Person p = new Person("卢本伟", 28, "male");
p.sayNameAndAge()
 
// ControllerB.java
Person p = new Person("white", 28, "male");
p.sayNameAndAge()
 
// ControllerC.java
Person p = new Person("55开", 28, "male");
p.sayNameAndAge()
 
// ControllerD.java
Person p = new Person("狒狒", 28, "male");
p.sayNameAndAgeAndSex()

在Person类中新增了一个字段一个方法,引入ControllerD的同时,却确迫使ControllerA、ControllerB、ControllerC的老代码跟着变动,违背了开闭原则,因为每当新增一个字段,我们都要去在调用构造函数的地方加上这个字段,没有对修改关闭

这个情况如果要遵守开闭原则我们可以利用重载,在扩展ControllerD的同时,不影响老代码不需要回归测试,符合开闭原则

java 复制代码
// Person.java
class Person{
    public Person(String name, Integer age){
        this.name = name;
        this.age = age;
    }
    public Person(String name, Integer age, String sex){
        this.name = name;
        this.age = age;
        this.sex = sex;
    }
    public sayNameAndAge(){
        System.out.println(this.name);
        System.out.println(this.age);
    }
    public sayNameAndAgeAndSex(){
        System.out.println(this.name);
        System.out.println(this.age);
        System.out.println(this.sex);
    }
}
 
// ControllerA.java
Person p = new Person("卢本伟",28);
p.sayNameAndAge()
 
// ControllerB.java
Person p = new Person("white",28);
p.sayNameAndAge()
 
// ControllerC.java
Person p = new Person("55开",28);
p.sayNameAndAge()
 
// ControllerD.java
Person p = new Person("狒狒",28,"male");
p.sayNameAndAgeAndSex()

我们现在思考一个问题,如果第二次产品提的新需求是:让所有老接口打印出Person类的姓名年龄,还要打印出性别,那我们能不修改老代码就让老接口打印出性别吗?那肯定是不行的,因为给老接口加字段属于对老功能的修改并不属于扩展新功能,相当于第一次需求的功能没开发完,老功能有改动老功能肯定要回归测试,我们就不得不去修改老代码

再举一个计算图形面积的经典例子

java 复制代码
//Rectangle.java
public class Rectangle {
    private double length;
    private double height;
}
 
//AreaManager.java
public class AreaManager {
    public double calculateArea(ArrayList<Rectangle> shapes) {
        double area = 0;
        for (Rectangle rect : shapes) {
            area += (rect.getLength() * rect.getHeight());
        }
        return area;
    }
}

这时如果要新增一种图形,计算其面积,在不重构的情况下我们得这么写:修改入参类型为Object并增加if-else来支持每一次扩展出来的图形,为了最大程度上的不修改老代码

java 复制代码
//Rectangle.java
public class Rectangle {
    private double length;
    private double height;
}
 
//Circle.java
public class Circle {
    private double radius;
}
 
//AreaManager.java
public class AreaManager {
    public double calculateArea(ArrayList<Object> shapes) {
        double area = 0;
        for (Object shape : shapes) {
            if (shape instanceof Rectangle) {
                Rectangle rect = (Rectangle)shape;
                area += (rect.getLength() * rect.getHeight());               
            } else if (shape instanceof Circle) {
                Circle circle = (Circle)shape;
                area += (circle.getRadius() * cirlce.getRadius() * Math.PI;
            } else {
                throw new RuntimeException("Shape not supported");
            }           
        }
        return area;
    }
}

说到这又有人会问了,新增一种图形,是算扩展新功能还是算完善老功能?算修改还是算扩展?能不能应用开闭原则?

其实我们看问题的不同角度对修改和扩展的定义都会不一样,从接口和需求层面看,额外支持了一种图形,算扩展;从方法层面上看,增加了if-else修改了老代码,算修改;从调用的地方来看,老代码不需要改变,不用回归测试,算扩展;方法的参数类型被修改或者新增了字段,算修改......

可以看出通常每个人对修改和扩展的定义不会一样,但只要我们不忘开闭原则的初衷,实现需求的同时能保证老功能不被影响,在底层新增代码在上层调用处无感知就能实现需求,我们就可以认定这是一次合格的实现,无需过度纠结算修改还是算扩展

其实开发的时候不遵守开闭原则往往会给同事带来不少麻烦,比如提测之后对相似度高的老代码不满意,顺手把老代码抽成一个新方法并替换所有老代码被调用的地方,这种实现需求之中夹杂着的技改影响了老逻辑扩大了测试范围,而这时测试不得不打乱排期重新安排人力来协助测试,如果测试同意接这私活还好,测试不同意就只能把技改部分的代码删掉再重新测试,害人害己......

在这个例子中如果要遵守开闭原则,我们得重构老代码再利用多态才可以做到下次扩展新图形在底层新增逻辑时上层调用无感知

java 复制代码
//Shape.java
public interface Shape {
    double getArea();
}
 
//Rectangle.java
public class Rectangle implements Shape {
    private double length;
    private double height;
 
    @Override
    public double getArea() {
        return (length * height);
    }
}
 
//Circle.java
public class Circle implements Shape {
    private double radius;
 
    @Override
    public double getArea() {
        return (radius * radius * Math.PI);
    }
}
 
//AreaManager.java
public class AreaManager {
    public double calculateArea(ArrayList<Shape> shapes) {
        double area = 0;
        for (Shape shape : shapes) {
            area += shape.getArea();
        }
        return area;
    }
}

Single Responsibility Principle

Single Responsibility Principle 又叫单一职责原则,说的是设计类的职责要单一,如果一个类有一个以上的职责,那么就会有多个不同的原因引起该类的变化,其实就是为了避免一个类耦合多个不相关的职责,降低这个类的内聚性

就拿活动页审核管理后台来说,通常会有新增、删除、编辑、查询、校验、审核的功能

这里如果我们不遵守单一职责原则,把所有功能都聚合在一个类里,那么就会耦合三种职责在一个类里:后台数据CRUD的职责、审核的职责、校验的职责

java 复制代码
//Management.java
public class Management{
    public boolean add(Activity activity){};
    public boolean delete(String activityId){};
    public boolean edit(String activityId, Activity activity){};
    public Page<List<Activity>> queryActivity(ActivityQuery query){};
    public ActivityValidateResponse validate(String bizId, String validateType){};
    public ActivityApprovalResponse approve(String activityId, String systemBizCode){};
}

低内聚的类不但会影响代码可读性,增加了理解的复杂度,还很容易发生改变,增加了修改的复杂度,每当其中任何一个职责在这个类里更新迭代,不相关的属性和方法就会变多,有时明明只需要一个小功能却要初始化整个Management类,最终其他类无法明确该如何重用这个类,因为这个类很庞大实现很复杂

当有别的地方只需要校验逻辑validate和审核逻辑approve的时候,大概率会自己实现一套有重复逻辑代码的类,如果别的地方单独实现了校验逻辑或者审核逻辑这两个职责,某一天更新了入参和响应而忘了更新这个Management类......懂的都懂

当我们把类按照职责划分后,其他模块就可以很方便地调用自己需要的功能而不去考虑不相关的功能,比如当我想调用校验方法,只需初始化Validation类调用validate方法就好,完全不需要为了能用上validate方法,而去初始化一个大且复杂的Management类

java 复制代码
//Management.java
public class Management{
    public boolean add(String activityId,String content){};
    public boolean edit(String activityId,String content){};
    public boolean delete(String activityId){};
}
 
//Validation.java
public class Validation{
    public boolean validate(String activityId,String validateType){};
}
 
//Approval.java
public class Approval{
    public boolean approve(String activityId,String systemBizCode){};
}

话说回来,如果我们能保证未来一年,校验的职责和审核的职责不会扩展,只为这个项目独家定制,只在这个项目的这个位置调用,那确实没有必要拆散它们,增加不必要的代码复杂度

Liskov Substitution Principle

Liskov Substitution Principle 又叫里氏替换原则,说的是子类要能替换父类实例出现的任何地方而且不改变父类的原有逻辑,不影响程序的正确性,不产生任何副作用,因为只有当子类能安全地替换掉父类时,这个子类才能被我们放心地用来重构父类,也是给父类扩展新功能的前提

java 复制代码
class Shape {
    // 一些通用的图形操作
}

class Rectangle extends Shape {
    private int width;
    private int height;
}

class Circle extends Shape {
    private int radius;
}

Shape rectangle = new Rectangle(); 
Shape circle = new Circle();

这时我们编写一个函数,该函数接受Shape对象并执行一些操作,那么它应该适用于任何Shape的子类对象,而不会引发错误。这就是Liskov Substitution Principle的核心概念:子类应该可以替代基类而不引发问题

java 复制代码
public double calculateArea(Shape shape) {
    if (shape instanceof Rectangle) {
        Rectangle rectangle = (Rectangle) shape;
        return rectangle.getWidth() * rectangle.getHeight();
    } else if (shape instanceof Circle) {
        Circle circle = (Circle) shape;
        return Math.PI * circle.getRadius() * circle.getRadius();
    }
    // 如果有其他图形子类,可以继续扩展这个函数
    return 0; // 默认返回值,可根据实际需求修改
}

在上述示例中,calculateArea 函数接受一个 Shape 对象,并根据传入的对象的实际类型(可以是 RectangleCircle 或其他子类)执行相应的面积计算操作。这就是Liskov Substitution Principle的应用,因为我们可以将基类 Shape 的对象替代为其子类的对象,而函数仍然可以正常工作。

Interface Segregation Principle

Interface Segregation Principle 接口隔离原则说的是接口要设计得高内聚,或者说实现类不应该去实现它不需要的接口,因为如果一个类实现了一个低内聚的包含多个职责的接口,其中必定有许多方法是这个实现类用不上的,如果不把这个大接口拆分成细粒度更小的接口再依次实现,那么其他类在实现这个低内聚接口时就会做不少的额外工作量,被强迫去实现自己不会用到的方法

比如当企鹅类Penguin和燕子类Sparrow都去实现Bird接口时,遇到自己不支持的方法需要抛出不支持的异常

java 复制代码
//Bird.java
public interface Bird{
    public boolean fly();
    public boolean huntFish();
    public boolean eatInsects();
    public boolean tweet();
    public boolean lay();
}
 
//Sparrow.java
public class Sparrow implements Bird{
    @Override
    public boolean fly(){
        return true
    }
    @Override
    public boolean huntFish(){
        throw new UnsupportedMethodException("燕子不会捕鱼");
    }
    @Override
    public boolean eatInsects(){
        return true
    }
    @Override
    public boolean tweet(){
        return true
    }
    @Override
    public boolean lay(){
        return true
    }
}
 
//Penguin.java
public class Penguin implements Bird{
    @Override
    public boolean fly(){
        throw new UnsupportedMethodException("企鹅不会飞");
    }
    @Override
    public boolean huntFish(){
        return true
    }
    @Override
    public boolean eatInsects(){
        throw new UnsupportedMethodException("企鹅不吃虫");
    }
    @Override
    public boolean tweet(){
        return true
    }
    @Override
    public boolean lay(){
        return true
    }
}

更合适的做法是把Bird大接口拆分成细粒度更小的接口,然后企鹅类Penguin和燕子类Sparrow根据情况,去实现自己需要的小接口

java 复制代码
//Flyable.java
public interface Flyable{
    public boolean fly();
}
 
//HuntFishable.java
public interface HuntFishable{
    public boolean huntFish();
}
 
//EatInsectable.java
public interface EatInsectable{
    public boolean eatInsects();
}
 
//Tweetable.java
public interface Tweetable{
    public boolean tweet();
}
 
//Layable.java
public interface Layable{
    public boolean lay();
}
 
//Sparrow.java
public class Sparrow implements Flyable,EatInsectable,Tweetable,Layable{
    @Override
    public boolean fly(){
        return true
    }
    @Override
    public boolean eatInsects(){
        return true
    }
    @Override
    public boolean tweet(){
        return true
    }
    @Override
    public boolean lay(){
        return true
    }
}
 
//Penguin.java
public class Penguin implements HuntFishable,Tweetable,Layable{
    @Override
    public boolean huntFish(){
        return true
    }
    @Override
    public boolean tweet(){
        return true
    }
    @Override
    public boolean lay(){
        return true
    }
}

这个原则强调一个类不应该强制性地依赖于它不需要的接口,也就是要将接口设计得精简而专注,以满足特定类的需求,而不强迫类实现不相关的方法

Dependency Inversion Principle

Dependency Inversion Principle 依赖倒置原则讲的是高层模块要通过抽象来和低层模块交互而不是通过具体实现来交互,因为方法入参是抽象的话,利用多态的特性,可以在调用方法时,替换不同实现类并委托实现类的方法来执行不同的逻辑,不会去修改老代码,而如果方法的入参是一个具体的类不是接口或者抽象类,那么这个方法就和这个类耦合在了一起,扩展时必定会改动老代码,因为新扩展而去改老代码是大忌,有时会产生意想不到的副作用

回到前面计算图形面积的例子,我们可以看到calculateArea方法的入参只接受Rectangle类的数组,如果新增Circle类,在不改动老代码的情况下很难复用已有接口逻辑

java 复制代码
//Rectangle.java
public class Rectangle {
    private double length;
    private double height;
}
 
//AreaManager.java
public class AreaManager {
    public double calculateArea(ArrayList<Rectangle>... shapes) {
        double area = 0;
        for (Rectangle rect : shapes) {
            area += (rect.getLength() * rect.getHeight());
        }
        return area;
    }
}

而如果我们把calculateArea方法的入参改成接受Rectangle类的抽象类Shape,并且把处理的逻辑单独封装在实现类的getArea方法里,在AreaManager调用时委托实现类的getArea方法,那么下次新增Circle类或者其他类时,只需实现Shape这个接口和getArea方法,调用的地方利用多态,就可以做到对修改关闭对扩展开放的效果

java 复制代码
//Shape.java
public interface Shape {
    double getArea();
}
 
//Rectangle.java
public class Rectangle implements Shape {
    private double length;
    private double height;
 
    @Override
    public double getArea() {
        return (length * height);
    }
}
 
//Circle.java
public class Circle implements Shape {
    private double radius;
 
    @Override
    public double getArea() {
        return (radius * radius * Math.PI);
    }
}
 
//AreaManager.java
public class AreaManager {
    public double calculateArea(ArrayList<Shape> shapes) {
        double area = 0;
        for (Shape shape : shapes) {
            area += shape.getArea();
        }
        return area;
    }
}

使用接口或抽象类来定义抽象,然后让具体的实现类实现这些抽象,这么做的目标是为了降低组件之间的耦合度,以便更容易实现替代和扩展。

高层模块除了通过抽象接口来与低层模块通信,还可以通过依赖注入(Dependency Injection)等技术来将具体实现的依赖注入到高层模块中,以实现松散的耦合。

java 复制代码
// 定义一个消息服务接口,作为高层模块
public interface MessageService {
  
  // 发送消息的方法
  public String getMessage(); 

}

// 邮件服务,实现消息服务接口,作为低层模块
@Service  
public class EmailService implements MessageService {

  @Override
  // 实现获取消息的方法
  public String getMessage() {
    return "email message";
  }

}

// 短信服务,实现消息服务接口,作为低层模块
@Service
public class SmsService implements MessageService {

  @Override
  // 实现获取消息的方法
  public String getMessage() {
    return "sms message";
  }

}

// 消息发送者,依赖消息服务接口,作为高层模块
@Component
public class MessageSender {

  // 通过@Autowired注解实现依赖注入,降低了高低层模块的耦合
  @Autowired
  // 这里通过@Qualifier直接指定注入EmailService的实例
  @Qualifier("emailService")
  private MessageService messageService;
  
  public void sendMessage() {
    // 调用消息服务接口的方法
    System.out.println(messageService.getMessage());
  }

}

在MessageSender类中,并没有依赖具体的EmailService或SmsService,而是依赖抽象接口MessageService,在运行时通过@Autowired注入具体实现类。这样就降低了高层模块与低层模块的耦合性,实现了依赖倒转原则

遵循DIP原则,可以实现更灵活、可测试和可维护的代码,因为可以更容易地替换或扩展系统的组件,而不会对其他部分造成不必要的影响,这有助于构建更稳定和可扩展的软件系统。

Law of Demeter or Least Knowledge Principle

Law of Demeter / Least Knowledge Principle 最少知道原则说的是一个类不应该与没有依赖关系的类进行交互,没有依赖关系说的是这两个类不为对方的类属性、类方法入参、类方法响应,这样做的目的是减少类之间不该有的耦合,让类更加独立,如果两个没有依赖关系的类是高耦合的,那么就会出现其中一个类迭代时更新了一处忘了更新另一处而产生副作用

比如从规范上讲,一个Java项目中,Controller类依赖Service类和DTO,Service类依赖Manager类和BO,Manager类依赖Repository类和DAO

这里Controller是不直接依赖Repository类的,不会直接操作DAO,符合最少知道原则

结果有一天,一个开发同事为了图方便省时间,直接在Controller层操作上了DAO而没有通过Manager实现新方法来操作DAO,这就违背了最少知道原则把Controller和DAO耦合在了一起,后期如果要扩展或者换实现类就很有可能牵一发而动全身,这样的代码很难维护

再举个例子:

java 复制代码
@Service
public class UserService {

  @Autowired
  private UserRepository userRepository;
  @Autowired
  private OrderRepository orderRepository;
  @Autowired
  private OrderDetailRepository orderDetailRepository;

  public BigDecimal findOrderPrice1(Long userId, Long orderId) {
    // 违反迪米特法则
    User user = userRepository.findById(userId);
    // 链式调用访问多层对象,增加耦合
    return user.getOrderById(orderId).getDetails().getPrice();
  }
  
   public BigDecimal findOrderPrice1(Long userId, Long orderId) {
    // 遵守迪米特法则
    User user = userRepository.findById(userId);
    // 通过中转层减少链式调用,降低耦合  
    Order order = orderRepository.findByUser(user);
    OrderDetail orderDetail = orderDetailRepository.getDetails(order)
    
    return orderDetail.getPrice();
  }

}
  • 第一个findOrderPrice方法中,UserService类直接通过User访问了Order、OrderDetails和Product多个对象,这违反了迪米特法则。
  • 第二个findOrderPrice方法中,在需要访问其他对象时,都通过Repository进行中转,避免了过度依赖链式调用,这样就遵守了迪米特法则,降低了类之间的耦合

简单来说,迪米特法则要求一个对象应该仅与它直接关联的对象通信,避免过分依赖链式调用导致的高耦合。

You Ain't Gonna Need It or No Overdesign Principle

You Ain't Gonna Need It / No Overdesign Principle 说的是不要过度设计,因为过度设计会给设计方案引入很多不必要的复杂度、代码量庞大、投入产出不成正比、项目无法按时完成等等很多问题,为什么会这样呢?我们做OOP设计不就是为了应对变化、拥抱变化吗?

在《论语》中,子贡问:"师与商也孰贤?"子曰:"师也过,商也不及。"曰:"然则师愈与?"子曰:"过犹不及。"意思是说不管什么事情,做过头了和没有做是一样的效果,我们接到需求做扩展点的预测也是有一个度的,不是说扩展点越多预测越远越好,很简单的道理:预测越远,正确性就越低

实际上做好OOD 或者 领域驱动设计(Domain Driven Design) 的关键是看我们对自己业务的熟悉程度,而不是对OOD或者DDD概念本身的熟悉程度,即使我们把相关设计概念和原则搞得再清楚,如果对业务理解不到位也就并不能做出合理的设计

比如我们如果要让已有接口多返回几个新的DB字段且不大幅度降低C端接口性能,通常是会复用已有的SQL,把需要的数据尽量用已有SQL查到并返回,但如果这时我们没这么做因为这么做违背了开闭原则和单一职责原则,而选择去增加一次额外的SQL调用降低了接口性能,或者在C端接口逻辑里调职责单一的B端接口,代码往往会被打回重写

有时候过分设计比设计不足危害更大,设计不足我们还可以选择重构,也不至于出现浪费大量人力物力的情况,所以做设计时要时刻铭记这一条原则:不要过度设计

Keep It Simple and Stupid Principle

Keep It Simple and Stupid 说的是不要滥用同事可能看不懂的技术来实现代码,过度的优化会降低代码的可读性提高维护成本,要基于团队自身情况而定

虽然这是一个非常主观的原则,即代码不能实现得太复杂也不能高效到看不懂,但基本上能通过code review上线的代码都不会违反这个原则,KISS 原则并不意味着要牺牲功能或质量,而是要寻找最简单和最有效的解决方案

Don't Repeat Yourself Principle

Don't Repeat Yourself 说的是不要重复造轮子,要善于使用已有的工具类库,同时有功能重复的代码要抽离出来复用,执行重复的代码要简化,因为越少的代码越好管理和维护

这个原则和KISS原则一样,也是属于非常主观的原则,DRY原则强调在软件开发中避免代码和信息的重复,以提高代码的质量和可维护性

Favor Composition Over Inheritance Principle

Favor Composition Over Inheritance 是说当我们想复用已有类的部分方法或者实现多态时子类应尽量用组合的方式而非继承的方式构建,继承应该在重构或者加强父类的时候使用,简单来说就是如果两个类是is-a关系时用继承,两个类是has-a关系时用组合

这么做的原因是因为继承存在一些很严重的缺陷:

  • 实现多继承的时候会形成很长的继承链路,影响代码的可读性和维护性
  • 继承破坏了类的封装特性,把父类所有protected属性和方法都通过子类暴露给了调用层,不经意的滥用有时会引起意想不到的副作用
  • 继承是一种反模式,依赖实现而不是抽象,类与类之间耦合度增高,一旦父类做了修改,子类也要跟着修改

可以看到如果使用继承来复用已有类的方法,子类会把不需要的父类方法unwantedMethod1、unwantedMethod2、unwantedMethod3、unwantedMethod4也继承到自身一起暴露出去

java 复制代码
//FlyableBird.java
public class FlyableBird extends EatInsectableBird{
    public boolean fly(){};
    public boolean unwantedMethod1(){};
}
 
//EatInsectableBird.java
public class EatInsectableBird extends TweetableBird{
    public boolean eatInsects(){};
    public boolean unwantedMethod2(){};
}
 
//TweetableBird.java
public class TweetableBird extends LayableBird{
    public boolean tweet(){};
    public boolean unwantedMethod3(){};
}
 
//LayableBird.java
public class LayableBird{
    public boolean lay(){};
    public boolean unwantedMethod4(){};
}
 
//Sparrow.java
public class Sparrow extends FlyableBird{
    public boolean fly(){
        return super.fly();
    };
    public boolean eatInsects(){
        return super.eatInsects();
    };
    public boolean tweet(){
        return super.tweet();
    };
    public boolean lay(){
        return super.lay();
    };
}
 
//Controller.java
Sparrow s = new Sparrow();
s.unwantedMethod1();
s.unwantedMethod2();
s.unwantedMethod3();
s.unwantedMethod4();

正确的做法是把已有的类作为私有属性,并通过委托的方式调用来达到老方法复用的目的

java 复制代码
//FlyableBird.java
public interface FlyableBird{
    public boolean fly();
    public boolean unwantedMethod1();
}
 
//EatInsectableBird.java
public interface EatInsectableBird{
    public boolean eatInsects();
    public boolean unwantedMethod2();
}
 
//TweetableBird.java
public interface TweetableBird{
    public boolean tweet();
    public boolean unwantedMethod3();
}
 
//LayableBird.java
public interface LayableBird{
    public boolean lay();
    public boolean unwantedMethod4();
}
 
//Sparrow.java
public class Sparrow{
 
    @Autowired
    private FlyableBird flyableBird;
    @Autowired
    private EatInsectableBird eatInsectableBird;
    @Autowired
    private TweetableBird tweetableBird;
    @Autowired
    private LayableBird layableBird;
 
    public boolean fly(){
        return flyableBird.fly();
    };
    public boolean eatInsects(){
        return eatInsectableBird.eatInsects();
    };
    public boolean tweet(){
        return tweetableBird.tweet();
    };
    public boolean lay(){
        return layableBird.lay();
    };
}

再举个例子,如果我们用数组(ArrayList)去模拟实现一个栈(Stack)时用的是继承,那么ArrayList类所有的public接口都会被Stack类继承并支持调用,比如get, set, add, remove, clear等等,都是一个Stack不应该有的行为

  • 从语义上来讲,一个Stack跟一个ArrayList没有is-a的关系
  • 从功能上来讲,一个Stack的底层实现对外应该是无感知的,而继承把这些底层方法都暴露了出来,破坏了OOP的封装特性
  • 从原则上来讲,这个Stack类不能替换父类ArrayList,由于方法的滥用会导致类行为不稳定,违背里氏替换原则

如果我们故意使坏,push、add、set方法混用,那么或多或少都会给Stack造成一些副作用,这个Stack类在使用上就会显得不够稳定可靠

java 复制代码
//Stack.class
public class Stack extends ArrayList{
 
    public String peek() {}
     
    public String pop() {}
 
    public void push(String element) {}
 
    public int size() {}
 
    public boolean isEmpty() {}
}
 
//Controller.class
Stack s = new Stack();
s.get();
s.set();
s.add();
s.remove();
s.clear();

而当我们用组合的方式去实现时,这个Stack类就会显得稳定很多,不会因为自己暴露出去的方法的滥用而产生不符合预期的结果

java 复制代码
//Stack.class
public class Stack {
 
    private List<String> elements = new ArrayList<>();
 
    public String peek() {
        if (elements.isEmpty()) {
            return null;
        }
        return elements.get(elements.size() - 1);
    }
     
    public String pop() {
        if (elements.isEmpty()) {
            return null;
        }
        String top = elements.get(elements.size() - 1);
        elements.remove(elements.size() - 1);
        return top;
    }
 
    public void push(String element) {
        elements.add(element);
    }
 
    public int size() {
        return elements.size();
    }
 
    public boolean isEmpty() {
        return elements.isEmpty();
    }
}

设计原则总结

设计原则 含义
Open Closed Principle 扩展类的新行为时不应该修改已有的老行为
Single Responsibility Principle 一个类应该只负责一个职责
Liskov Substitution Principle 子类应该能替换父类出现的任何位置而且不改变原有逻辑
Interface Segregation Principle 一个类不应该去实现低内聚的接口
Dependency Inversion Principle 应该基于抽象而非实现编程
Law of Demeter / Least Knowledge Principle 一个类应该只和直接朋友(成员变量、方法入参、方法响应)交互
You Ain't Gonna Need It / No Overdesign Principle 应该提前预判并封装隔离扩展点,不要过度设计和封装
Keep It Simple and Stupid Principle 不应该使用奇技淫巧降低代码可读性
Don't Repeat Yourself Principle 尽量开发可复用的代码,不重复造轮子
Favor Composition Over Inheritance Principle 用已有类组装一个新类的时候尽量用组合/委托而不是继承
相关推荐
Larcher27 分钟前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐39 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭1 小时前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu2 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花2 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋2 小时前
场景模拟:基础路由配置
前端
六月的可乐2 小时前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程