设计模式之六大原则

Java
367
0
0
2022-11-12

一、开闭原则

一般认为最 早提出开闭原则(Open-Close Principle, OCP) 的是伯特兰迈耶。他在1988 年发表的《面向对象软件构造》中给出的。

在面向对象编程领域中,开闭原则规定软件中的对象、类、模块和函数对扩展应该是开放的,但对于修改是封闭的。

开闭原则的核心思想也可以理解为面向抽象编程。

错误示范

public interface UserDao {
  public void insert();
}
public class UserDaoImpl implements UserDao{
  public void insert() {
    // 基于 JDBC 实现插入
    ...
    pstmt.executeUpdate();
  }
}

新需求,将 JDBC 改为 JNDI方式,删除原有代码,在方法中重写逻辑

public class UserDaoImpl implements UserDao{
  public void insert() {
  // pstmt.executeUpdate(); 
  // 基于 JNDI 实现数据插入
  jndi.insert();
  }
}

正确做法

对修改关闭,对拓展开放

public class UserDaoJndiImpl implements UserDao{
  public void insert() {
    // 基于 JNDI 实现数据插入
    jndi.insert();
  }
}

不推荐的做法,Java 对继承不友好,除非父类明确 abstract,否则不推荐优先使用 extends

public class UserDaoJndiImpl extends UserDaoImpl{
  public void insert() {
    // 基于 JNDI 实现数据插入
    jndi.insert();
  }
}

二、单一职责原则

单一职责原则(Single Responsibility Principle, SRP)又称单一功能原则。

如果需要开发的一个功能需求不是一次性的,且随着业务发展的不断变化而变化,那么当一个 Class 类负责超过两个及以上的职责时,就在需求的不断迭代、实现类持续扩张的情况下,就会出现难以维护、不好扩展、测试难度大和上线风险高等问题。

错误做法

这里通过一个视频网站用户分类的例子,来帮助大家理解单一职责原则的构建方法。 当在各类视频网站看电影、电视剧时,网站针对不同的用户类型,会在用户观看时给出不同的服务反馈,如以下三种。

  • 访客用户,一般只可以观看 480P 视频, 并时刻提醒用户注册会员能观看高清视频。这表示视频业务发展需要拉客,以获取更多的新注册用户。
  • 普通会员,可以观看 720P 超清视频,但不能屏蔽视频中出现的广告。这表示视频业务发展需要盈利。
  • VIP 会员(属于付费用户),既可以观看 1080P 蓝光视频,又可以关闭或跳过广告。
public class VideoUserService{
  public void serverGrade(String userType) {
    if("VIP 用户".equals(userType)) {
      System.out.println("VIP 用户,视频 1080p 蓝光")
    }else if("普通用户".equals(userType)) {
      System.out.println("VIP 用户,视频 1080p 蓝光")
    }else if("访客用户".equals(userType)) {
      System.out.println("VIP 用户,视频 1080p 蓝光")
    }
  }
}

正确的做法

抽象接口

public interface IVideoUserService {
  // 视频清晰级别:480p、720p、1080p 
  void definition();

  // 广告播放形式:无广告、有广告 
  void advertisement();
}

访客逻辑

public class  GuestVideoUserService implement IVideoUserService {
  public void definition() {
    System.out.println("访客用户,视频 480p");
  }

  public void advertisment() {
    System.out.println("访客用户,视频有广告");
  }
}

普通用户逻辑

public class  OrdinaryVideoUserService implement IVideoUserService {
  public void definition() {
    System.out.println("普通用户,视频 720p");
  }

  public void advertisment() {
    System.out.println("普通用户,视频有广告");
  }
}

VIP 会员逻辑

public class  VipVideoUserService implement IVideoUserService {
  public void definition() {
    System.out.println("VIP 用户,视频 1080p");
  }

  public void advertisment() {
    System.out.println("VIP 用户,视频无广告");
  }
}

三、里氏代换原则

继承必须确保超类所拥有的性质在子类中仍然成立

简单来说,子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:当子类继承父类时,除添加新的方法且完成新增功能外,尽量不要重写父类的方法。这句话包括了四点含义:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  • 子类可以增加自己特有的方法。
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松。
  • 当子类的方法实现父类的方法(重写、重载或实现抽象方法)时,方法的后置条件(即方法的输出或返回值)要比父类的方法更严格或与父类的方法相等。

里氏替换原则的作用

  • 里氏替换原则是实现开闭原则的重要方式之一 。
  • 解决了继承中重写父类造成的可复用性变差的问题。
  • 是动作正确性的保证,即类的扩展不会给已有的系统弓|入新的错误,降低了代码出错的可能性。
  • 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。

错误的做法

储蓄卡和信用卡在使用功能上类似,都有支付、提现、还款、充值等功能,也有些许不同,例如支付,储蓄卡做的是账户扣款动作,信用卡做的是生成贷款单动作。下面这里模拟先有储蓄卡的类,之后继承这个类的基本功能,以实现信用卡的功能。

public class CashCard {

    private final Logger logger = LoggerFactory.getLogger(CashCard.class);

    public String withdrawal(String orderId, BigDecimal amount) {
        // 模拟支付成功
        logger.info("提现成功,单号: {} 金额: {}", orderId, amount);
        return "0000";
    }

    public String recharge(String orderId, BigDecimal amount) {
        // 模拟充值
        logger.info("储蓄成功,单号:{} 金额:{}", orderId, amount);
        return "0000";
    }

    public List<String> tradeFlow() {
        logger.info("交易流水查询成功");
        List<String> tradeList = new ArrayList<>();
        tradeList.add("100001,100.00");
        tradeList.add("100001,80.00");
        tradeList.add("100001,76.50");
        tradeList.add("100001,126.00");
        return tradeList;
    }
}

信用卡

public class CreditCard extends CashCard {
    public static final Logger logger = LoggerFactory.getLogger(CreditCard.class);

    @Override 
    public String withdrawal(String orderId, BigDecimal amount) {
        // 校验 
        if (amount.compareTo(new BigDecimal(1000)) >= 0) {
            logger.info("贷款金额校验(限额 1000 元),单号:{} 金额:{}", orderId, amount);
            return "00001";
        }
        // 模拟生成贷款单
        logger.info("生成贷款单,单号:{} 金额:{}", orderId, amount);
        logger.info("贷款成功,单号:{} 金额:{}", orderId, amount);
        return "0000";
    }

    @Override 
    public String recharge(String orderId, BigDecimal amount) {
        // 模拟生成还款单
        logger.info("生成还款单,单号:{} 金额:{}", orderId, amount);
        // 模拟还款
        logger.info("还款成功,单号:{} 金额:{}", orderId, amount);
        return "0000";
    }

    @Override 
    public List<String> tradeFlow() {
        return super.tradeFlow();
    }
}

用卡的功能实现是在继承了储蓄卡类后,进行方法重写:支付withdrawal() 、还款recharge()。其实交易流水可以复用,也可以怀用重写这个类。

这种继承父类方式的优点是复用了父类的核心功能逻辑,但是也破坏了原有的方法。此时继承父类实现的信用卡类并不满足里氏替换原则,也就是说,此时的子类不能承担原父类的功能,直接当储蓄卡使用。

正确做法

抽象银行卡父类

在抽象银行卡类中,提供了基本的卡属性,包括卡号、开卡时间及三个核心方法。正向入账,加钱;逆向入账,减钱。当然,实际的业务开发抽象出来的逻辑会比模拟场景多一些。接下来继承这个抽象类,实现储蓄卡的功能逻辑。

public abstract class BankCard {
    public static final Logger logger = LoggerFactory.getLogger(BankCard.class);

    private String carNo; // 卡号 
    private String cardData; // 开卡时间

    public BankCard(String carNo, String cardData) {
        this.carNo = carNo;
        this.cardData = cardData;
    }

    abstract boolean rule(BigDecimal amount);

    // 正向入账,+ 钱 
    public String positive(String orderId, BigDecimal amount) {
        // 入款成功,存款、还款
        logger.info("卡号{} 入款成功,单号:{} 金额:{}", carNo, orderId, amount);
        return "0000";
    }

    // 逆向入账,- 钱 
    public String negative(String orderId, BigDecimal amount) {
        logger.info("卡号{} 出款成功,单号:{} 金额:{}", carNo, orderId, amount);
        return "0000";
    }

    // 交易流水查账 
    public List<String> tradeFlow() {
        logger.info("交易流水查询成功");
        List<String> tradeList = new ArrayList<>();
        tradeList.add("100001,100.00");
        tradeList.add("100001,80.00");
        tradeList.add("100001,76.50");
        tradeList.add("100001,126.00");
        return tradeList;
    }

    public String getCarNo() {
        return carNo;
    }

    public String getCardData() {
        return cardData;
    }
}

储蓄卡子类

储蓄卡类中继承抽象银行卡父类 BankCard,实现的核心功能包括规则过滤rule、提现withdrawal、储蓄 recharge 和新增的扩展方法,即风控校验 checkRisk。

public class CashCard extends BankCard {

    public static final Logger logger = LoggerFactory.getLogger(CashCard.class);

    public CashCard(String carNo, String cardData) {
        super(carNo, cardData);
    }

    @Override 
    boolean rule(BigDecimal amount) {
        return true;
    }

    public String withdrawal(String orderId, BigDecimal amount) {
        // 模拟支付成功
        logger.info("提现成功,单号:{} 金额:{}", orderId, amount);
        return super.negative(orderId, amount);
    }

    public String recharge(String orderId, BigDecimal amount) {
        // 模拟充值成功
        logger.info("储蓄成功,单号:{} 金额:{}", orderId, amount);
        return super.positive(orderId, amount);
    }

    public boolean checkRisk(String cardNo, String orderId, BigDecimal amount) {
        // 模拟风控校验
        logger.info("风控校验,卡号:{} 单号:{} 金额:{}", cardNo, orderId, amount);
        return true;
    }
}

信用卡子类

信用卡类在继承父类后,使用了公用的属性,即卡号 cardNo、开卡时间 cardDate,同时新增了符合信用卡功能的新方法,即贷款 loan、还款 repayment, 并在两个方法中都使用了抽象类的核心功能。

另外,关于储蓄卡中的规则校验方法,新增了自己的规则方法 rule2,并没有破坏储蓄卡中的校验方法。

以上的实现方式都是在遵循里氏替换原则下完成的,子类随时可以替代储蓄卡类。

public class CreditCard extends CashCard {
    public static final Logger logger = LoggerFactory.getLogger(CreditCard.class);

    public CreditCard(String carNo, String cardData) {
        super(carNo, cardData);
    }

    boolean rule2(BigDecimal amount) {
        return amount.compareTo(new BigDecimal(1000)) <= 0;
    }

    public String loan(String orderId, BigDecimal amount) {
        boolean rule = rule2(amount);
        if (!rule) {
            logger.info("生成贷款单失败,金额超限。单号:{} 金额:{}", orderId, amount);
            return "0001";
        }
        // 模拟生成贷款单
        logger.info("生成贷款单,单号:{} 金额:{}", orderId, amount);
        logger.info("贷款成功,单号:{} 金额:{}", orderId, amount);
        return super.negative(orderId, amount);
    }

    public String repayment(String orderId, BigDecimal amount) {
        // 模拟生成还款单
        logger.info("生成还款单,单号:{} 金额:{}", orderId, amount);
        // 模拟还款
        logger.info("还款成功,单号:{} 金额:{}", orderId, amount);
        return super.positive(orderId, amount);
    }
}

通过以上的测试结果可以看到,储蓄卡功能正常,继承储蓄卡实现的信用卡功能也正常。同时,原有储蓄卡类的功能可以由信用卡类支持,即CashCard creditCard=new CreditCard (…)。

继承作为面向对象的重要特征,虽然给程序开发带来了非常大的便利,但也引入了一些弊端。 继承的开发方式会给代码带来侵入性,可移植能力降低,类之间的耦合度较高。当对父类修改时,就要考虑一整套子类的实现是否有风险,测试成本较高。

里氏替换原则的目的是使用约定的方式,让使用继承后的代码具备良好的扩展性和兼容性。

在日常开发中使用继承的地方并不多,在有些公司的代码规范中也不会允许多层继承,尤其是一些核心服务的扩 展。而继承多数用在系统架构初期定义好的逻辑上或抽象出的核心功能里。如果使用了继承,就一定要遵从里氏替换原则,否则会让代码出现问题的概率变得更大。

四、迪米特法则

迪米特法则(Law of Demeter, LoD)又称为最少知道原则(Least Knowledge Principle, LKP) ,是指一个对象类对于其他对象类来说,知道得越少越好。也就是说,两个类之间不要有过多的耦合关系,保持最少关联性。

错误的做法

老师需要负责具体某一个学生的学习情况,而校长会关心老师所在班级的总体成绩,不会过问具体某一个学生的学习情况。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Student {
    // 学生姓名 
    private String name;
    // 考试排名成绩 
    private int rank;
    // 考试分数(总分) 
    private double grade;
}
public class Teacher {
    // 老师姓名 
    private String name;
    // 班级 
    private String clazz;
    // 学生 
    private static List<Student> studentList;

    public Teacher(String name, String clazz) {
        this.name = name;
        this.clazz = clazz;
    }

    static {
        studentList = new ArrayList<>();
        studentList.add(new Student("test1",1,589));
        studentList.add(new Student("test2",2,584));
        studentList.add(new Student("test3",3,579));
        studentList.add(new Student("test4",4,574));
        studentList.add(new Student("test5",5,569));
    }
}
public class Principal {
    private final Teacher teacher = new Teacher("teacher1","test");

    // 查询班级信息,总分数、学生人数、平均值 
    public Map<String,Object> queryClazzInfo(String clazzId) {
        // 获取班级信息:学生总人数、总分、平均分 
        int stuCount = clazzStudentCount();
        double totalScore = clazzTotalScore();
        double averageScore = clazzAverageScore();

        // 组装对象,实际业务开发会有对应的类
        HashMap<String, Object> mapObj = new HashMap<>();
        mapObj.put("班级",teacher.getClazz());
        mapObj.put("老师",teacher.getName());
        mapObj.put("学生人数",stuCount);
        mapObj.put("班级总分数",totalScore);
        mapObj.put("班级平均分",averageScore);

        return mapObj;
    }

    // 总分 
    public double clazzTotalScore() {
        double totalScore = 0;
        for (Student stu : teacher.getStudentList()) {
            totalScore += stu.getGrade();
        }
        return totalScore;
    }

    // 平均分 
    public double clazzAverageScore() {
        double totalScore = 0;
        for (Student stu : teacher.getStudentList()) {
            totalScore += stu.getGrade();
        }
        return totalScore / teacher.getStudentList().size();
    }

    // 班级人数 
    public int clazzStudentCount() {
        return teacher.getStudentList().size();
    }
}

以上就是通过校长管理所有学生,老师只提供了非常简单的信息。虽然可以查询到结果,但是违背了迪米特法则,因为校长需要了解每个学生的情况。如果所有班级都让校长类统计,代码就会变得非常臃肿,也不易于维护和扩展。

正确的做法

由老师负责分数统计

public class Teacher {
    // 老师姓名 
    private String name;
    // 班级 
    private String clazz;
    // 学生 
    private static List<Student> studentList;

    public Teacher(String name, String clazz) {
        this.name = name;
        this.clazz = clazz;
    }

    static {
        studentList = new ArrayList<>();
        studentList.add(new Student("test1",1,589));
        studentList.add(new Student("test2",2,584));
        studentList.add(new Student("test3",3,579));
        studentList.add(new Student("test4",4,574));
        studentList.add(new Student("test5",5,569));
    }

    // 总分 
    public double clazzTotalScore() {
        double totalScore = 0;
        for (Student stu : studentList) {
            totalScore += stu.getGrade();
        }
        return totalScore;
    }

    // 平均分 
    public double clazzAverageScore() {
        double totalScore = 0;
        for (Student stu : studentList) {
            totalScore += stu.getGrade();
        }
        return totalScore / studentList.size();
    }
}
public class Principal {
    private final Teacher teacher = new Teacher("teacher1","test");

    // 查询班级信息,总分数、学生人数、平均值 
    public Map<String,Object> queryClazzInfo(String clazzId) {
        // 获取班级信息:学生总人数、总分、平均分 
        int stuCount = teacher.clazzStudentCount();
        double totalScore = teacher.clazzTotalScore();
        double averageScore = teacher.clazzAverageScore();

        // 组装对象,实际业务开发会有对应的类
        HashMap<String, Object> mapObj = new HashMap<>();
        mapObj.put("班级",teacher.getClazz());
        mapObj.put("老师",teacher.getName()); 
        mapObj.put("学生人数",stuCount);
        mapObj.put("班级总分数",totalScore);
        mapObj.put("班级平均分",averageScore);

        return mapObj;
    }
}

五、接口隔离原则

一个类对另一个类的依赖应该建立在最小的接口上

接口隔离原则(Interface Segregation Principle, ISP) 要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法

真确的做法

Servlet 事件监听器可以监听 ServletContext、HttpSession、 ServletRequest 等域对象的创建和销毁过程 ,以及监听这些域对象属性的修改。

ServerletContextListener 接口

public void contextInitialized(servletContextEvent sce);
public void contextDestroyed(servletContextEvent sce);

HttpSessionListener

public void sessionCreated(HttpSessionEvent se);
public void sessionDestroyed(HttpSessionEvent se);

ServletRequestListener 接口

public void requestInitialized(ServletRequestEvent sre);
public void requestDestroyed(ServletRequestEvent sre);

监听器应用代码

public class MyListener implements ServletRequestListener, HttpSessionListener, ServletContextListener {

    @Override 
    public void requestDestroyed(ServletRequestEvent sre) {
        System.out.println("ServletRequest 对象被创建了");
    }

    @Override 
    public void requestInitialized(ServletRequestEvent sre) {
        System.out.println("ServletRequest 对象被销毁了");
    }

    @Override 
    public void sessionCreated(HttpSessionEvent se) {
        System.out.println("HttpSession 对象被创建了");
    }

    @Override 
    public void sessionDestroyed(HttpSessionEvent se) {
        System.out.println("HttpSession 对象被销毁了");
    }

    @Override 
    public void contextInitialized(ServletContextEvent sce) {
        System.out.println("ServletContext 对象被创建了");
    }

    @Override 
    public void contextDestroyed(ServletContextEvent sce) {
        System.out.println("ServletContext 对象被销毁了");
    }
}

六、依赖倒置原则

依赖倒置原则(Dependence Inversion Principle, DIP) 是指在设计代码架构时,高层模块不应该依赖于底层模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

DIP就是我们常说的“面向接口编程”。

依赖倒置原则是实现开闭原则的重要途径之一,它降低了类之间的耦合,提高了系统的稳定性和可维护性,同时这样的代码一般更易读,且便于传承。

错误的做法

在互联网的营销活动中,经常为了拉新和促活,会做-些抽奖活动。 这些抽奖活动的规则会随着业务的不断发展而调整,如随机抽奖、权重抽奖等。其中,权重是指用户在当前系统中的一个综合排名,比如活跃度、贡献度等。

抽奖用户类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class BetUser {
    // 用户名
    private String username;
    // 用户权重
    private int userWeight;
}

抽奖逻辑类

public class DrawControl {

    // 随机抽取指定数量的用户,作为抽奖用户 
    public List<BetUser> doDrawRandom(List<BetUser> list, int count) {
        // 集合数量很小,直接返回 
        if (list.size() <= count) {
            return list;
        }
        // 乱序集合
        Collections.shuffle(list);
        // 取出指定数量的中奖用户
        List<BetUser> prizeList = new ArrayList<>();
        for (int i = 0; i < count; i++) {
            prizeList.add(list.get(i));
        }
        return prizeList;
    }

    // 权重排名获取指定数量的用户,作为中奖用户 
    public List<BetUser> doDrawWeight(List<BetUser> list, int count) {
        // 按照权重排序
        list.sort(((o1, o2) -> {
            int e = o2.getUserWeight() - o1.getUserWeight();
            if (0 == e) {
                return 0;
            }
            return e > 0 ? 1 : -1;
        }));
        // 取出指定数量的中奖用户
        ArrayList<BetUser> prizeList = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            prizeList.add(list.get(i));
        }
        return prizeList;
    }
}

正确的做法

抽奖接口

public interface IDraw {
    // 获取中奖用户接口 
    List<BetUser> prize(List<BetUser> list, int count);
}

随机抽奖实现

public class DrawRandom implements IDraw{
    @Override
    public List<BetUser> prize(List<BetUser> list, int count) {
        // 集合数量很小,直接返回 
        if (list.size() <= count) {
            return list;
        }
        // 乱序集合
        Collections.shuffle(list);
        // 取出指定数量的中奖用户
        List<BetUser> prizeList = new ArrayList<>();
        for (int i = 0; i < count; i++) {
            prizeList.add(list.get(i));
        }
        return prizeList;
    }
}

权重抽奖实现

public class DrawWeightRank implements IDraw{
    @Override
    public List<BetUser> prize(List<BetUser> list, int count) {
        // 按照权重排序
        list.sort(((o1, o2) -> {
            int e = o2.getUserWeight() - o1.getUserWeight();
            if (0 == e) {
                return 0;
            }
            return e > 0 ? 1 : -1;
        }));
        // 取出指定数量的中奖用户
        ArrayList<BetUser> prizeList = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            prizeList.add(list.get(i));
        }
        return prizeList;
    }
}

开奖

public class DrawControl {

    public List<BetUser> doDraw(IDraw draw, List<BetUser> betUserList, int count) {
        return draw.prize(betUserList, count);
    }

    public static void main(String[] args) {
        // 初始化 userList 
        List<BetUser> userList = new ArrayList<>();
        // 把实现逻辑的接口作为参数传递 
        new DrawControl().doDraw(new DrawWeightRank(), userList, 3);
    }
}

在这个类中体现了依赖倒置的重要性,可以把任何一种抽奖逻辑传递给这个类。这样实现的好处是可以不断地扩展,但是不需要在外部新增调用接口,降低了一套代码的维护成本,并提高了可扩展性及可维护性。另外,这里的重点是把实现逻辑的接口作为参数传递,在一些框架源码中经常会有这种做法。