一.前言
Hello,everyone.日常工作中相信大家都多多少少接触过“祖传代码”。
- 一个类几千上万行,一个方法几百上千行,贫血模型严重
- 方法内部业务逻辑混乱,随处可见的if/else
- 关键业务逻辑没有注释,魔法值随处可见
- 重复代码随处可见
- ...
年初的时候,博主加入到新的产品线去研发一款新产品,人员都是从其他部门拉过来或者新招聘进来的。在我介入开发之前,需求大约迭代了半年的时间。由于是新产品,大家又是新组成的团队,每个人的代码风格差异很大。每次要在对应的模块上进行需求迭代时,一看代码就想吐。明明只是注释了一行代码,莫名其妙的引出了好几个bug,想改都改不动。在今年过完年之后,我跟领导沟通了技术架构的统一与制定了一些相关的研发准则。今天跟大家来分享一下如何解耦系统与修改代码中的坏味道。如有不对之处,欢迎指出,共同进步~
二.实体类
实体类作为数据的载体,大家日常工作中绝对会接触到,但是你真的正确使用了吗?
说一下我之前项目中看到的代码。数据查询得到的数据载体,service层交互的数据载体,rpc层交互的数据载体,web层交互的数据载体都集中在一个实体中。这个做法在业务场景特别简单的时候不会出现什么大问题,但是如果是一个比较庞大的业务应用体系。这样就会就会有问题了。
举个例子
用户表中一个四个字段,用户id,用户名,手机号,用户密码。现在需要在用户管理菜单页展示用户数据。如果只有一个实体的情况下,我从数据库里查询出来的数据拥有4个字段,把密码传递到前端肯定是不合适的。做一下脱敏,将password置为空。但是你在前端的报文中还是能看到
代码语言:javascript复制{
"userId":1
"userName":"admin"
"mobile":"13888888888"
"password":null
}
显然这个是不合理的,返回给前端的数据应该是
代码语言:javascript复制{
"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此概念感性去的同学可以阅读:https://zhuanlan.zhihu.com/p/147879821
举个例子
上图是博主做的登录接口的一些核心逻辑。如果把上述逻辑写在一个login方法中,那么杂七杂八的校验加起来方法我觉得至少上百行。如果不添加必要的注释,你很难串联起来对应的逻辑。
现在仅针对上述流程说一下博主的瘦身策略。LoginCommand指令发送过来之后,LoginBO【类似于DDD中的聚合根,但不完全一致】映射数据,抽离最小节点方法逻辑,例如校验登录参数就可以定义一个方法。service层中一个个调用bo层方法即可。也可以将4个小方法在聚合一个方法,逻辑清晰。看一下伪代码。
贫血模型
代码语言:javascript复制//校验1
//校验2
....
一坨代码
//设置token
充血模型
代码语言:javascript复制bo层
校验方法1{
}
校验方法2{
}
校验方法3{
}
校验方法4{
}
总校验{
校验方法1
校验方法2
校验方法3
校验方法4
}
....其余最小处理逻辑
service层
login{
bo.总校验
bo.生成token
返回登录结果
}
四.if/else遍地
相信大家肯定遇到过一个方法里面出现一大堆的if/else逻辑判断,最气的是他还没有注释。我总结了大家高频用到的if/else的场景
- 入参校验
- 业务逻辑内部判断
4.1.入参校验
方法的入参校验这个是不可避免的。校验错误参数结果无非就是两种:
1.直接报错
对于直接报错的形式,建议大家阅读博主的全局异常处理文章:https://cloud.tencent.com/developer/article/2079836
入参校验失败,直接映射业务错误码,使用ValidationUtil.isTrue()语义化形式强的表达方式,代码易读。
2.返回空值
扁平化if判断,层级表达清晰。
不建议
代码语言:javascript复制if(true){
//doSomething
}
建议
代码语言:javascript复制if(false){
return;
}
//doSomething
4.2.业务逻辑内部判断
消除if/else的手段我之前阅读到一篇很好的文章,推荐给大家:https://juejin.cn/post/6913877637802754061?utm_source=gold_browser_extension
这里贴上一个博主对于一些简单逻辑的if/else判断常用的一个函数式工具类,简化代码
代码语言:javascript复制/**
* 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的名称,他们不会重复。
常规写法,必定报出代码重复
代码语言:javascript复制//用户文件生成
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());
}
});
}
函数式与泛型结合
代码语言:javascript复制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)
六.魔法值/常量/枚举
相信大家经常会碰到这里的代码
代码语言:javascript复制if(state==1){
//doSomething
}else if(state==2){
//doSomething
}else{
//doSomething
}
一脸懵逼1,2,3这种魔法值到底是个什么意思?
稍微好点的给你备注一下,再好点的给你定义常量。但是正确的做法应该是定义一个枚举,里面定义清楚1,2,3到底的作用是什么。
例如定义一个性别枚举
代码语言:javascript复制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);
}
}
进行性别判断时
代码语言:javascript复制不推荐
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的聚合。
随着版本的演进,数据的来源不断的修改,而每次修改,都是要修改到主业务逻辑。这就会导致随着业务的演进,代码中出来很多数据的聚合动作,但是本质意义上来说。比如我要查询一个用户信息,我不关心你这个用户信息到底是从多少个数据源聚合出来了,我只关心我这个用户数据是什么。这里就有了DDD里面的仓储概念,把DAO层想象成一个强大的仓库,你只负责告诉他你想要什么的数据,至于数据的来源与聚合交给仓储层完成。有点类似于把dao层与service层数据操作一块的逻辑进行了合并变成了小型service。
这样在主逻辑就很清晰了。
九.过多串联化逻辑
在逻辑中定义了过多与本业务不是强相关联的逻辑。
例如我们定义权限体系为: 用户1:n角色1:n权限
现在我们开发一个接口,删除用户。那么相应的,用户与角色的权限关联关系也需要被删除。
通常我们的编码方式
代码语言:javascript复制public void delete(Long userId){
删除用户
删除用户与角色关联关系
删除。。。
}
发现问题了没有,明明我是在删除用户,但是却在做突破我删除用户这个业务边界的事情。并且后续如果再多一点跟用户关联的功能,我难道要一个个调用吗?这显然是不合理的,代码难读还不好维护。这里一定要解耦,微服务场景下,解耦能想到的一个重要知识就是MQ。业务应用那不就是事件监听机制吗?
修改一下代码
代码语言:javascript复制public void delete(Long userId){
删除用户
发布用户删除事件
}
//角色关联函数
public void listener(Event event){
删除角色与用户关联关系
}
//通知其他微服务
public void listener(Event event){
发送mq消息,告知其他微服务用户删除
}