求求你,别写祖传代码了

3,112 阅读13分钟

一.前言

Hello,everyone.日常工作中相信大家都多多少少接触过“祖传代码”。

  1. 一个类几千上万行,一个方法几百上千行,贫血模型严重
  2. 方法内部业务逻辑混乱,随处可见的if/else
  3. 关键业务逻辑没有注释,魔法值随处可见
  4. 重复代码随处可见
  5. ...

1.jpeg

年初的时候,博主加入到新的产品线去研发一款新产品,人员都是从其他部门拉过来或者新招聘进来的。在我介入开发之前,需求大约迭代了半年的时间。由于是新产品,大家又是新组成的团队,每个人的代码风格差异很大。每次要在对应的模块上进行需求迭代时,一看代码就想吐。明明只是注释了一行代码,莫名其妙的引出了好几个bug,想改都改不动。在今年过完年之后,我跟领导沟通了技术架构的统一与制定了一些相关的研发准则。今天跟大家来分享一下如何解耦系统与修改代码中的坏味道。如有不对之处,欢迎指出,共同进步~

二.实体类

实体类作为数据的载体,大家日常工作中绝对会接触到,但是你真的正确使用了吗?

说一下我之前项目中看到的代码。数据查询得到的数据载体,service层交互的数据载体,rpc层交互的数据载体,web层交互的数据载体都集中在一个实体中。这个做法在业务场景特别简单的时候不会出现什么大问题,但是如果是一个比较庞大的业务应用体系。这样就会就会有问题了。

举个例子

image-20210530145628068.png

用户表中一个四个字段,用户id,用户名,手机号,用户密码。现在需要在用户管理菜单页展示用户数据。如果只有一个实体的情况下,我从数据库里查询出来的数据拥有4个字段,把密码传递到前端肯定是不合适的。做一下脱敏,将password置为空。但是你在前端的报文中还是能看到

{
	"userId":1
  "userName":"admin"
  "mobile":"13888888888"
  "password":null
}

显然这个是不合理的,返回给前端的数据应该是

{
	"userId":1
  "userName":"admin"
  "mobile":"13888888888"
}

显然对于java中的数据载体来说,每一层的分层是尤为重要的。我通常会对数据载体做如下分层

PO:持久化对象,实体属性与表字段一一对应,DAO层产生,在Service层被使用。

BO:业务对象,聚合PO层数据,也可以多表关联数据查询聚合,内部会有属性的业务逻辑处理方法。DAO/Service层产生,Service层使用。

DTO:数据传输对象,常用语service层,rpc层,controller层,用于数组传输的载体,内部无逻辑

VO:数据展示层,用于controller层,这里我习惯与方法的出参,用于切合DTO与VO层的结构差异。

Query:查询参数,controller层方法入参,接收前端的查询类型参数。

Command:指令性型参数,例如用户新增,用户修改的数据载体。


说明:

1.DTO与VO我常常会混用,如果数据传输载体只会在controller展示层中被组装使用,那直接返回给前端也可以,如果与前端要求不一致的情况,需要编写对应的Converter类进行处理,不可以将转换逻辑编写在DTO与VO中,他们只是数据载体。

2.Command与DTO/VO,网上一些博主会将VO或者DTO作为web层入参进行数据的增删改。从结构化与定义上没有问题,但是这个跟数据载体带有指令就有点关联不上了。我对DTO与VO的理解是他们是结果型数据,是业务逻辑处理后的产物。而Command是指令性数据,通过Command类型参数,经由BO层业务逻辑,将数据映射到PO层与数据库交互。

3.Query参数,与Command参数类似,常常有人会使用DTO或者VO来传递数据,一样的道理,业务语义不够强。

三.贫血模型

不说90%,但我觉得至少有70%以上的研发喜欢把大量的逻辑聚合在service层实现。这样就导致整个service层的业务逻辑不够凸显,语义化薄弱,对于二次开发者来说上手较为困难。而对应service方法中的BO层逻辑很空。

关于贫血模型与充血模型是DDD领域建模中常见的概念,本文仅针对MVC模型的充血模型转化,对DDD此概念感性去的同学可以阅读:zhuanlan.zhihu.com/p/147879821

举个例子

image-20210530161819891.png

上图是博主做的登录接口的一些核心逻辑。如果把上述逻辑写在一个login方法中,那么杂七杂八的校验加起来方法我觉得至少上百行。如果不添加必要的注释,你很难串联起来对应的逻辑。

现在仅针对上述流程说一下博主的瘦身策略。LoginCommand指令发送过来之后,LoginBO【类似于DDD中的聚合根,但不完全一致】映射数据,抽离最小节点方法逻辑,例如校验登录参数就可以定义一个方法。service层中一个个调用bo层方法即可。也可以将4个小方法在聚合一个方法,逻辑清晰。看一下伪代码。

贫血模型

//校验1
//校验2
....

一坨代码

//设置token

充血模型

bo层
校验方法1{

}
校验方法2{

}
校验方法3{

}
校验方法4{

}

总校验{
  校验方法1
  校验方法2
  校验方法3
  校验方法4
}

....其余最小处理逻辑



service层

login{
	bo.总校验
	bo.生成token
	返回登录结果
}

四.if/else遍地

相信大家肯定遇到过一个方法里面出现一大堆的if/else逻辑判断,最气的是他还没有注释。我总结了大家高频用到的if/else的场景

  1. 入参校验
  2. 业务逻辑内部判断

4.1.入参校验

方法的入参校验这个是不可避免的。校验错误参数结果无非就是两种:

1.直接报错

对于直接报错的形式,建议大家阅读博主的全局异常处理文章:juejin.cn/post/696540…

入参校验失败,直接映射业务错误码,使用ValidationUtil.isTrue()语义化形式强的表达方式,代码易读。

2.返回空值

扁平化if判断,层级表达清晰。

不建议

if(true){
	//doSomething
}

建议

if(false){
	return;
}
//doSomething

4.2.业务逻辑内部判断

消除if/else的手段我之前阅读到一篇很好的文章,推荐给大家:juejin.cn/post/691387…

这里贴上一个博主对于一些简单逻辑的if/else判断常用的一个函数式工具类,简化代码

/**
 * java8函数式工具
 *
 * if/else等简单逻辑疯狂缩短代码
 *
 * @author baiyan
 * @date 2021/05/30
 */
public final class JavaUtil {

    /**
     * 单例
     */
    private static JavaUtil javaUtil;

    /**
     * 单例工具类获取
     * @return
     */
    public static JavaUtil getJavaUtil() {
        if (javaUtil == null) {
            synchronized (JavaUtil.class) {
                if (javaUtil == null) {
                    javaUtil = new JavaUtil();
                }
            }
        }
        return javaUtil;
    }

    private JavaUtil() {
        super();
    }

    /**
     * 条件成立进行消费
     * @param condition 条件
     * @param value 需要消费的参数
     * @param consumer 消费函数
     * @param <T>
     * @return
     */
    public <T> JavaUtil acceptIfCondition(boolean condition, T value, Consumer<T> consumer) {
        if (condition) {
            consumer.accept(value);
        }
        return this;
    }

    /**
     * 根据条件的成立与否进行参数消费
     * @param condition 条件
     * @param trueValue 条件成立时消费参数
     * @param falseValue 条件不成立时消费参数
     * @param consumer 消费函数
     * @param <T>
     * @return
     */
    public <T> JavaUtil acceptDependCondition(boolean condition, T trueValue, T falseValue, Consumer<T> consumer) {

        consumer.accept(condition ? trueValue : falseValue);

        return this;
    }

    /**
     * 根据条件的成立决定消费函数
     * @param condition 条件
     * @param value 消费的参数
     * @param consumerTrue 条件成立时消费参数
     * @param consumerFalse 条件不成立时消费函数
     * @param <T>
     * @return
     */
    public <T> JavaUtil consumeDependCondition(boolean condition, T value, Consumer<T> consumerTrue, Consumer<T> consumerFalse) {

        if(condition){
            consumerTrue.accept(value);
        }else {
            consumerFalse.accept(value);
        }

        return this;
    }

    /**
     * 条件成立进行消费
     * @param condition 条件
     * @param supplier 提供者函数返回值作为参数
     * @param consumer 消费函数
     * @param <T>
     * @return
     */
    public <T> JavaUtil acceptSupplierIfCondition(boolean condition, Supplier<T> supplier, Consumer<T> consumer) {
        if (condition) {
            consumer.accept(supplier.get());
        }
        return this;
    }

    /**
     * 消费参数如果提供者函数为空
     * @param supplier 提供者函数作为判断入参
     * @param consumer 消费函数
     * @param defValue 消费入参
     * @param <T>
     * @return
     */
    public <T> JavaUtil acceptValueIfNull(Supplier<T> supplier, Consumer<T> consumer, T defValue) {
        return acceptIfCondition(supplier.get() == null, defValue, consumer);
    }

    /**
     * 参数值不为空则进行消费
     *
     * @param value 入参
     * @param consumer 消费函数
     * @param <T>
     * @return
     */
    public <T> JavaUtil acceptIfNotNull(T value, Consumer<T> consumer) {
        if (value != null) {
            consumer.accept(value);
        }
        return this;
    }

    /**
     * 字符串入参不为空则进行消费
     *
     * @param value 入参
     * @param consumer 消费函数
     * @return
     */
    public JavaUtil acceptIfNotEmpty(String value, Consumer<String> consumer) {
        if (StringUtils.hasText(value)) {
            consumer.accept(value);
        }
        return this;
    }

    /**
     * source不为null,则就行映射,并赋值
     * @param source 入参值进行非空判断
     * @param mapFunction 对非空入参进行消费并返回消费参数
     * @param consumer 消费参数
     * @param <T>
     * @param <R>
     * @return
     */
    public <T, R> JavaUtil mapAndAcceptIfNonnull(T source, Function<T, R> mapFunction, Consumer<R> consumer) {
        if (source != null) {
            R apply = mapFunction.apply(source);
            consumer.accept(apply);
        }
        return this;
    }

    /**
     * List<对象>转换成 List<对象的某一个属性>,并赋值给别的类
     * @param list        入参
     * @param consumer    消费
     * @param mapFunction 映射
     * @param <T>         原始对象
     * @param <R>         对象里面的某个属性
     * @return
     */
    public <T, R> JavaUtil mapAndAcceptIfNotEmpty(List<T> list, Function<T, R> mapFunction, Consumer<List<R>> consumer) {
        if (CollectionUtils.isNotEmpty(list)) {
            List<R> mapList = list.stream().map(mapFunction).collect(toList());
            consumer.accept(mapList);
        }
        return this;
    }


}

五.重复逻辑

使用idea开发的朋友应该都会使用阿里开发守则这个插件。当你在项目中编写了重复行数较多的逻辑代码之后,重复部分代码的开头就会被标注波浪下划线。都是程序员,谁还没点强迫症了。想着去消除提示,但是后面发现,他们只是结构相似,但是里面设值的逻辑不一样,无法剥离为公共方法。

例如方法1内为user.getName(),方法二内为employee.getName()。

有什么好的方法呢?

jdk8的一个重大特性就是函数是接口。使用函数式接口与泛型来解决。


举个例子

现在有一个方法需要将user与employee的数据写入文件,文件名称是user与employee的名称,他们不会重复。

常规写法,必定报出代码重复

//用户文件生成
public static void createExportFiles(String tempExportPath, List<User> datas)  {
    if (CollectionUtil.isEmpty(datas)){
        return;
    }
    datas.forEach(data -> {
        String fileName = user.getName() + ".json";
        FileWriter writer = new FileWriter(tempExportPath + File.separator + fileName);
        try {
            writer.write(GsonUtil.gsonToString(data));
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    });
}

//用户文件生成
public static void createExportFiles(String tempExportPath, List<Employee> datas)  {
    if (CollectionUtil.isEmpty(datas)){
        return;
    }
    datas.forEach(data -> {
        String fileName = data.getName() + ".json";
        FileWriter writer = new FileWriter(tempExportPath + File.separator + fileName);
        try {
            writer.write(GsonUtil.gsonToString(data));
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    });
}

函数式与泛型结合

public static <T,R> void createExportFiles(String tempExportPath, List<T> datas, Function<T, R> function)  {
    if (CollectionUtil.isEmpty(datas)){
        return;
    }
    datas.forEach(data -> {
        String fileName = function.apply(data) + ".json";
        FileWriter writer = new FileWriter(tempExportPath + File.separator + fileName);
        try {
            writer.write(GsonUtil.gsonToString(data));
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    });
}

调用是传参
user  :    createExportFiles("/root",List<User> users,User::getName)       
employee : createExportFiles("/root",List<Employee> employees,Employee::getName)       

六.魔法值/常量/枚举

相信大家经常会碰到这里的代码

if(state==1){
	//doSomething
}else if(state==2){
	//doSomething
}else{
	//doSomething
}

一脸懵逼1,2,3这种魔法值到底是个什么意思?

稍微好点的给你备注一下,再好点的给你定义常量。但是正确的做法应该是定义一个枚举,里面定义清楚1,2,3到底的作用是什么。

例如定义一个性别枚举

public enum SexEnum {

    MAN(1,"男","man"),
    WOMAN(0,"女","woman"),
    ;

    @Getter
    private Integer key;
    @Getter
    private String value;
    @Getter
    private String name;

    SexEnum(Integer key, String value,String name) {
        this.key = key;
        this.value = value;
        this.name = name;
    }

    public static SexEnum getByKey(String key) {
        for (SexEnum e : values()) {
            if (Objects.equals(key, e.key)) {
                return e;
            }
        }
        throw new RuntimeException("未找到对应的性别枚举:" + key);
    }
}

进行性别判断时

不推荐
if(Objects.equals(sex,1)){

}

推荐,语义清晰
if(Objects.equals(sex,SexEnum.MAN.getKey())){

}

七.工具类混乱

一个老工程你随便一搜StringUtil,绝对出现一大堆自定义的StringUtil。里面的方法大多相同,这对于一个项目而言,完全是没有必要的重复代码。

建议单独拉取一个base工程,放入与业务无关的通用工具类,公有方法在此维护。业务内部定义一个base模块,如果有对base工程内的工具类需要必要业务扩展的,则通过继承base工程内工具类来实现业务本身的工具类。通过类型的判断均由base工程的工具类提供。

八.数据逻辑管理混乱

8.1.dao层数据混乱

如果把单体应用想象成一个庞大的分布式应用。那么内部一个个service层就是对应的微服务。每个微服务对应的DAO层就是微服务对应的数据库。不同的微服务不可能跨库调用。因此绝不允许在本service层对应Dao层中增删改操作非本Service层数据。

8.2.主业务流无关数据修改与查询混乱

日常操作数据的过程中,我们很有可能一个对象的数据在版本1是从mysql来,版本二变成了mysql与redis聚合,版本三变成了ES,mysql与redis的聚合。

image-20210530174140778.png 随着版本的演进,数据的来源不断的修改,而每次修改,都是要修改到主业务逻辑。这就会导致随着业务的演进,代码中出来很多数据的聚合动作,但是本质意义上来说。比如我要查询一个用户信息,我不关心你这个用户信息到底是从多少个数据源聚合出来了,我只关心我这个用户数据是什么。这里就有了DDD里面的仓储概念,把DAO层想象成一个强大的仓库,你只负责告诉他你想要什么的数据,至于数据的来源与聚合交给仓储层完成。有点类似于把dao层与service层数据操作一块的逻辑进行了合并变成了小型service。

image-20210530174245135.png

这样在主逻辑就很清晰了。

九.过多串联化逻辑

在逻辑中定义了过多与本业务不是强相关联的逻辑。

例如我们定义权限体系为: 用户1:n角色1:n权限

现在我们开发一个接口,删除用户。那么相应的,用户与角色的权限关联关系也需要被删除。

通常我们的编码方式

public void delete(Long userId){
	删除用户
	删除用户与角色关联关系
	删除。。。
}

发现问题了没有,明明我是在删除用户,但是却在做突破我删除用户这个业务边界的事情。并且后续如果再多一点跟用户关联的功能,我难道要一个个调用吗?这显然是不合理的,代码难读还不好维护。这里一定要解耦,微服务场景下,解耦能想到的一个重要知识就是MQ。业务应用那不就是事件监听机制吗?

修改一下代码

public void delete(Long userId){
	删除用户
	发布用户删除事件
}

//角色关联函数
public void listener(Event event){
	删除角色与用户关联关系
}

//通知其他微服务
public void listener(Event event){
	发送mq消息,告知其他微服务用户删除
}

十.联系我

如果你觉得文章写得不错,能够点赞评论+关注三连,么么哒~

钉钉:louyanfeng25

微信:baiyan_lou