zoukankan      html  css  js  c++  java
  • 《如何做好软件设计》:设计原则

    作者:yangwq
    博客:https://yangwq.cn

    前言

    软件设计是一门关注长期变化的学问,日常开发中需求不断变化,那我们该怎么编写出可以支撑长期变化的代码呢?大多数人都认同的解决方案是利用设计模式,这里就有一个问题:怎么融汇贯通的将设计模式应用到实际项目中呢?这就是我们本篇文章的主题:设计原则。

    个人认为设计原则是软件设计的基石之一,所有语言都可以利用设计原则开发出可扩展性、可维护性、可读性高的项目,学好设计原则,就等于我们拥有了指南针,不会迷失在各个设计模式的场景中。

    郑晔老师的《软件设计之美》指出:设计模式是在特定问题上应用设计原则的解决方案。我们可以类比设计原则是心法,设计模式是招式,两者相辅相成,虽然脱离对方都能使用,但是不能融会贯通。

    本章主要涉及的设计原则有:

    1. SOLID原则
    2. KISS原则、YAGNI原则、DRY原则

    接下来对各个原则进行详细说明,有错误或语义不明确的地方欢迎大家指正。

    一、SOLID原则

    1. S(Single Responsibility Principle,SRP):单一职责原则;
    2. O(Open–closed principle,OCP):开放-关闭原则;
    3. L(Liskov Substitution Principle,LSP):里氏替换原则;
    4. I(Interface segregation principle,LSP):接口隔离原则;
    5. D(Dependency inversion principle, DIP):依赖倒置原则。

    1、单一职责原则(Single Responsibility Principle,SRP)

    本原则的定义经历过一些变化。以前的定义是:一个模块(模块、类、接口)仅有一个引起变化的原因,后面升级为: 一个模块(模块、类、接口)对一类且仅对一类行为者负责

    怎么理解一个模块(模块、类、接口)仅有一个引起变化的原因?

    我们重点关注的是“变化”一词。下面我们用代码来进行示例:

    背景:设计一个订单接口,能做到创建、编辑订单和会员的赠送及过期。

    public interface OrderService {
        int createOrder();
        int updateOrder();
        
        // 下单完成后分配vip给用户
    	int distributionVIP();
    	// vip过期
    	int expireVIP();
    }
    

    OrderService包含对订单、VIP的操作,不管是订单业务或VIP业务的改变,我们都需要改变这个类。这样有什么问题?有多个引起OrderService变化的原因导致这个类不能稳定下来,对VIP代码的改动有可能导致原本运行正常的订单功能发生故障,没有做到高内聚、低耦合。

    一个模块最理想的状态是不改变,其次是少改变。我们可以将对VIP的处理单独放到一个类:

    public interface OrderService {
        int createOrder();
        int updateOrder();
        
    }
    
    public interface VIPService{
        // 下单完成后分配vip给用户
    	int distributionVIP();
    	// vip过期
    	int expireVIP();
    }
    

    这样我们对订单或VIP的改动都不会影响到对方正常的功能,极大程度上减少了问题发生的概率。

    该怎么理解一个模块(模块、类、接口)对一类且仅对一类行为者负责?

    这个定义比上面的定义多加了一个内容:变化的来源。

    上面的例子可能区分不出来变化的来源,像vip这类功能一般都是订单系统体系内的。从下面这个例子说明:

    背景:在上面例子的背景下,增加对地址信息的维护。

    public interface OrderService {
        int createOrder();
        int updateOrder();
        
    	// 订单地址的修改
    	int updateOrderAddress();
    }
    

    OrderService中对订单地址的修改,可能是订单负责人提出的需求,也可能是物流部门提出来:需要共用订单地址。

    这里就需要区分两种业务场景。

    如果是订单负责人提出的,那上面这个设计就是合理的,因为我们维护的是订单附属内容,而且变化的来源只有订单系统。

    但如果是物流部门提出共用订单地址,那就需要将更改地址的接口抽离出来,因为这个需求变化的来源有两拨人:可能是订单,也可能是物流部门。改动如下:

    public interface OrderService {
        int createOrder();
        int editOrder();
    }
    
    public interface AddressService {
        // 订单修改地址
    	int updateAddressByOrder();
    	
    	// 物流修改地址
    	int updateAddressByLogistics();
    }
    

    为了职责明确我们有对接口的命名进行重构,这样更容易被使用者接受,通过将地址的变化隔离在AddressService,后续维护地址只用修改这个类,提升了代码的可读性和可维护性。

    2、开放-关闭原则(Open–closed principle,OCP)

    定义:对扩展开放,对修改关闭。简而言之: 不修改已有代码(尽可能不更改已有代码的情况下),新需求用新代码实现。

    如何做到?分离关注点,找出共性构建模型/抽象,设计扩展点。

    代码示例:

    背景:设计一套通用的文件上传下载功能,需要支持本地盘和阿里云OSS。一开始的设计可能是这样的:

    public void FileUtil {
    	
    	void upload(UploadParam uploadParam) {
    		if(type == 1){
    			// 上传文件到本地盘
    		}else if (type == 2){
    			// 上传文件到阿里云OSS
    		}
    	}
    	
    	void download(DownloadParam downloadParam){
    		if(type == 1){
    			// 从本地盘下载文件
    		}else if (type == 2){
    			// 从阿里云OSS下载文件
    		}
    	}
    }
    

    上面的设计有什么问题?首先第一点UploadParam 和 DownloadParam 参数职责过重,不同方式的上传、下载参数混合在一个类,可读性不高,而且加入其他存储方式的时候可能只加了上传,漏掉了下载的改动,容易产生问题。

    那我们先通过分离关注点:不同存储方式都需要提供对应的上传、下载操作。于是我们可以将动作拆分成上传、下载,参数需要按不同场景选用不同的对象。改动后如下:

    // 所有参数的父类接口
    public interface BaseFileParam{
    	
    }
    
    // 统一的上传下载接口类
    public  interface FileService<U,D>{
        
        /**
         * 上传
         */
        void upload();
    
        /**
         * 下载
         */
        void  download();
    
    }
    
    // 抽象实现,将参数作为属性放到类中,子类可以使用
    public abstract class AbstractFileService<U,D> implements FileService<U,D>{
        protected U uploadParam;
        protected D downloadParam;
    
        public AbstractFileService() {
        }
    
        protected FileService<U, D> buildUploadParam(U uploadParam){
            this.uploadParam = uploadParam;
            return this;
        }
        
        protected FileService<U, D> buildDownloadParam(D downloadParam){
            this.downloadParam = downloadParam;
            return this;
        }
        
        protected U getUploadParam() {
            return uploadParam;
        }
    
        protected D getDownloadParam() {
            return downloadParam;
        }
    }
    
    
    
    // OSS实现
    public class OssFileServe extends AbstractFileService<OssFileServe.OssUpload, OssFileServe.OssDownload> {
    
        /**
         * 上传到阿里云
         */
        @Override
        public void upload() {
    
        }
    
        /**
         * 从阿里云下载文件
         */
        @Override
        public void download() {
    
        }
    
        public class OssUpload implements BaseFileParam{
    
        }
    
        public class OssDownload implements BaseFileParam{
    
        }
    
    }
    
    // 本地盘实现
    public class LocalFileService extends AbstractFileService<LocalFileService.LocalFileUploadParams, LocalFileService.LocalFileDownloadParams> {
    
        /**
         * 上传到本地磁盘
         */
        @Override
        public void upload() {
    
        }
    
        @Override
        public void download() {
    
        }
    
        public static class LocalFileUploadParams implements BaseFileParam {
    
        }
    
        public static class LocalFileDownloadParams implements BaseFileParam {
    
        }
    }
    
    
    
    // 使用入口
    public class FileServiceDelegate {
    
        public FileService<? extends BaseFileParam,? extends BaseFileParam> getFileService(String type, BaseFileParam upload, BaseFileParam download){
            if("local".equals(type)){
               return new LocalFileService().buildUploadParam(upload != null ? (LocalFileService.LocalFileUploadParams) upload : null)
                       .buildDownloadParam(download != null ? (LocalFileService.LocalFileDownloadParams) download : null);
            }else if ("oss".equals(type)) {
                return new OssFileServe().buildUploadParam(upload != null ? (OssFileServe.OssUpload) upload : null)
                        .buildDownloadParam(download != null ? (OssFileServe.OssDownload) download : null);
            }else {
                throw new RuntimeException("未知的上传类型");
            }
        }
    
        public void upload(String type, BaseFileParam baseFileParam){
            getFileService(type,baseFileParam, null).upload();
        }
    
        public void download(String type, BaseFileParam baseFileParam){
            getFileService(type,null, baseFileParam).download();
        }
    }
    
    
    
    
    

    以上是比较粗糙的方案,只做案例演示。后续如果需要加入亚马逊S3存储,我们需要改动的点:

    // 加入S3实现
    public class S3FileService extends AbstractFileService<S3FileService.S3UploadParams, S3FileService.S3DownloadParams> {
    
        /**
         * 上传到S3
         */
        @Override
        public void upload() {
    
        }
    
        /**
         * 从S3下载文件
         */
        @Override
        public void download() {
    
        }
    
        public class S3UploadParams implements BaseFileParam {
    
        }
    
        public class S3DownloadParams implements BaseFileParam {
    
        }
    }
    
    // 修改入口类
    public class FileServiceDelegate {
    
        public FileService<? extends BaseFileParam,? extends BaseFileParam> getFileService(String type, BaseFileParam upload, BaseFileParam download){
            if("local".equals(type)){
               return new LocalFileService().buildUploadParam(upload != null ? (LocalFileService.LocalFileUploadParams) upload : null)
                       .buildDownloadParam(download != null ? (LocalFileService.LocalFileDownloadParams) download : null);
            }else if ("oss".equals(type)) {
                return new OssFileServe().buildUploadParam(upload != null ? (OssFileServe.OssUpload) upload : null)
                        .buildDownloadParam(download != null ? (OssFileServe.OssDownload) download : null);
            }
            // 加入S3处理
            else if("s3".equals(type)){
                return new S3FileService().buildDownloadParam(upload != null ? (S3FileService.S3DownloadParams) upload : null)
                        .buildDownloadParam(download != null ? (S3FileService.S3DownloadParams) download : null);
    
            }else {
                throw new RuntimeException("未知的上传类型");
            }
        }
    
        public void upload(String type, BaseFileParam baseFileParam){
            getFileService(type,baseFileParam, null).upload();
        }
    
        public void download(String type, BaseFileParam baseFileParam){
            getFileService(type,null, baseFileParam).download();
        }
    }
    
    
    
    

    上面我们修改了两个地方,一个是加入了S3的实现类,另一个是更改入口类加入了S3的处理,这就符合新功能用新代码实现,但可能有人说改动了入口类,其实只要改动的代码没有影响到原有的功能,小幅度的修改是可以接受的。

    3、里氏替换原则(Liskov Substitution Principle,LSP)

    定义:子类必须能够替换其父类,并保证原来程序的逻辑行为不变及正确性不被破坏。

    如何实现?站在父类的角度设计接口,子类需要满足基于行为的IS-A关系,更具体的来讲:子类遵守父类的行为约定,约定包含:功能主旨,异常,输入,输出,注释等。

    违背功能主旨:

    public interface OrderService {
        Order updateById(Order order);
    }
    
    public class OrderServiceImpl {
        public Order findById(Order order) {
            // 实际上是通过订单编号进行更新的
            return orderMapper.updateBySn(order);
        }
    }
    

    父类的定义原本是按订单ID更新,在子类实现中却变成了按订单编号更新,这个方法就违背了功能主旨。会出现什么问题?使用者会发现执行结果与自己期望的不一致,而且有隐藏BUG:一开始传了订单编号,后面订单编号没了,这个方法就报错了,更严重一点,如果是使用mybatis的xml判断了编号不为空进行条件拼接,此时由于编号为空就没有了条件过滤然后更改了整个表的数据。

    异常:父类规定接口不能抛出异常,而子类抛出了异常。

    输入:父类输入整数类型就行,子类要求正整数才能执行。

    输出:父类执行方法要求有异常时返回null,子类重写后直接将异常抛出来了。

    关于里氏替换原则,我们就只要记住一点:从父类角度设计行为一致的子类

    4、接口隔离原则(Interface segregation principle,LSP)

    定义:不应强迫使用者依赖于它们不用的方法。 通俗的理解:对接口设计应用单一职责,根据调用者设计不同的接口。

    示例:

    public class UserController{
        
        int addUser(User user);
        int updateUser(User user);
        int deleteUser(int id);
        // 锁定用户
        int lockUser(User user);
    }
    

    上面是一个对订单crud的接口,现在有其他项目组的同事需要锁定用户的功能,然后你可能一拍脑袋直接把上面整个接口UserController扔给他(或者直接扔一个swagger文档),这样同事会很懵逼:我只要锁定用户就行,为什么还要这么多接口?

    这样做暴露的问题:

    1. 调用者关注了不需要的接口;
    2. 多余的接口暴露出来容易问题,每次更改接口你也不知道会不会影响其他模块的功能。

    所以我们尽量要最小化暴露接口,根据不同的调用者仅提供他们当前需要的接口,提供的公共接口越多越难以维护。

    接口隔离原则与单一职责的区别:

    1、单一职责要求的的是模块、类、接口的职责单一,

    2、接口隔离原则要求的是暴露给使用者的接口尽可能少。

    可以这么理解:一个类某个职责有10个接口都暴露给其他模块使用,按单一原则来讲是合理的,但是按接口隔离来讲是不允许的。

    5、依赖倒置原则(Dependency inversion principle, DIP)

    定义:高层模块不直接依赖底层模块,依赖于抽象,底层模块不依赖于细节,细节依赖于抽象。

    这一点如果我们是使用spring开发的项目就已经用到了。spring的依赖注入就是依赖倒置原则的体现。

    // 以前没有使用spring的时候,我们是这样初始化service的
    // 存在的问题:1、如果需要替换成一个新的实现类,改动点太多,简单点说就是高耦合;
    // 2、使用者不需要关注具体的实现类,只关注有哪些接口能用就行;
    // 3、对象实例不能共享,每个使用的地方都是新建的实例,实际上用同一个实例就行了。
    UserService userService = new UserServiceImpl();
    
    

    通过spring的IOC容器,我们只要定义好依赖关系,IOC容器就可以帮我们管理对应的实例,起到了松耦合的作用。

    还有其他的使用场景吗?

    有,举例:

    public class UserServiceImpl {
        private KafkaProducer producer;
        
        public int addUser(User user){
            // 创建用户
            
            // 发送消息到消息队列,由感兴趣的系统订阅并消费。
            producer.send(msg);
        }
    }
    

    这里初看没有什么问题,但如果后续我们更换了kafka为rabbitmq,那上面使用到kafka的类都需要重新调整。

    我们利用"高层模块不直接依赖底层模块,依赖于抽象"对上面代码进行调整,让我们的实现类UserServiceImpl不直接依赖KafkaProducer,而是依赖接口类MessageSender。

    public class UserServiceImpl {
        private MessageSender sender;
        
        public int addUser(User user){
            // 创建用户
            
            // 发送消息到消息队列,由感兴趣的系统订阅并消费。
            sender.send(msg);
        }
    }
    
    public interface MessageSender {
        void send(Map<String,String> params);
    }
    
    // kafka 实现
    public class KafkaProducer implements MessageSender{
        public void send(Map<String,String> params) {
            
        }
    }
    
    

    这样一来,就算我们切换成RabbitMq,改动的点无非是对MessageSender实现的更改,而有了spring的IOC容器,我们很容易就可以更改实例实现。

    // rabbitmq 实现
    public class RabbitmqProducer implements MessageSender{
        public void send(Map<String,String> params) {
            
        }
    }
    

    控制反转:控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里的控制指的是程序执行流程的控制,反转是从程序员变为框架控制。

    依赖注入:一种具体的编码技巧,不直接使用new创建对象,而是在外部将对象创建好后通过构造函数、方法、方法参数传递给类使用。

    二、KISS原则、YAGNI原则、DRY原则

    这三个原则是偏理论性的概念,主要目的是指导我们学习设计原则后不要过度设计。

    KISS(Keep it simple, stupid)原则

    定义: 尽量保持简单。保持简单可以让我们的代码可读性更高,维护起来也更容易。但这是一个比较抽象的概念:对于“简单”的定义没有统一规范,每个人的理解都不一致,这个时候就需要code review,同事有很多疑问的代码就要考虑是不是代码不够“简单”。

    实践过程中怎么编写满足KISS原则的代码?以下几点供大家参考:

    1. 不要重复造轮子,复用已有的工具;
    2. 方法写得越小越好;
    3. 不要使用同事可能不懂的技术来实现代码。

    YAGNI(You aren’t gonna need it)原则

    定义: 你不会需要它。我们可以这样理解:如非必要,勿增功能。

    这一个原则我们可以用在两个方面:需求和代码实现。

    对于产品人员提出的需求,按照二八原则,80%的功能是用不上的,所以我们可以不做对用户没有价值的需求。

    对于开发人员的代码实现,除非编写的模块以后会频繁变化,这种情况我们可以提前构建扩展点,但如果模块变化很少,我们就不需要做过多的扩展点,保持功能正常运行就行。

    KISS原则和YAGNI原则区别:

    KISS原则关注的怎么做,YAGNI原则关注的是需不需要做。

    DRY(Don’t repeat yourself)原则:

    定义:不要重复自己。广泛的认知是不写重复代码,更深入一点的理解是不要对你的知识和意图进行复制

    在我看来:解决重复代码是每个程序员都会做的事情,但是重复的代码一定要解决吗?首先要明白解决重复代码的重点是建立抽象,那这个抽象有没有存在的意义?我们应该根据实际的业务场景,如果发现引起该抽象改变的原因超过一个,这说明该抽象没有存在的意义。

    例如,我们开发crud接口中常见的VO和Entity:

    public class UserEntity {
        private String username;
        private String name;
        private Integer age;
        private String password;
    }
    
    public class UserVO {
        private String username;
        private String name;
        private Integer age;
        // 用户拥有的菜单
        private List menuList;
    }
    

    我们如果按DRY原则将重复的代码合并到一个类:

    public class BaseUser{
        private String username;
        private String name;
        private Integer age;
        private String phone;
    }
    public class UserEntity extends BaseUser{
        private String password;
    }
    
    public class UserVO {
        // 用户拥有的菜单
        private List menuList;
    }
    

    改成这样会有什么问题?如果后续UserVO不允许暴露age属性或者需要对手机号加密,这个时候就需要改动BaseUser和UserEntity,对UserVO的维护就会改动到BaseUser和UserEntity,一方面违反了单一职责,另一方面需要对发现所有使用BaseUser、UserEntity、UserVO的地方进行测试,增加了维护成本。

    基于以上考虑,我们需要将对UserVO的改动隔离起来:还原成刚开始重复代码的场景。

    实行DRY原则的方式:

    三次法则(Rule of Three)

    1. 第一次先写了一段代码,不考虑复用性;

    2. 第二次在另一个地方写了一段相同的代码,可以标记为需清除重复代码,但是暂不处理;

    3. 再次在另一个地方写了同样的代码,现在可以考虑解决重复代码了。

    总结

    本篇的宗旨是给大家树立一个观点:设计原则是设计模式的基础,而不是设计模式的附属物。设计模式是在特定问题应用设计原则的解决方案。但是只用设计原则开发软件离目标是有偏差的,所以我们也要借鉴设计模式:熟悉不同场景下设计原则的使用方式,这样才能开发出可扩展性、可维护性、可读性高的软件。

    本篇文章如有错误或语义不明确的地方欢迎大家指正。

  • 相关阅读:
    c# 深拷贝与浅拷贝
    SQLServer性能优化 .net开发菜鸟总结
    Ajax自定义无刷新控件实现
    APScheduler库的详细用法
    catkin在centos中的安装
    第五次任务实现与项目总结第六组
    Javascript教程:获取当前地址栏url
    窗口处理问题
    HTML中area标签
    Asp.net中Frameset的使用小结
  • 原文地址:https://www.cnblogs.com/winkin/p/14381247.html
Copyright © 2011-2022 走看看