还重构?就你那代码只能铲了重写!


作者:小傅哥
博客://bugstack.cn

一、前言

我们不一样,就你没对象! 对,你是面向过程编程的!

我说的,绝大多数码农没日没夜被需求憋着肝出来的代码,无论有多么的吭哧瘪肚,都不可能有重构,只有重新写。为什么?因为重新写所花的时间成本,远比重构一份已经烂成团的代码,要节省时间。但谁又不敢保证重写完的代码,就比之前能好多少,况且还要承担着重写后的代码事故风险和几乎体现不出来的业务价值

虽然代码是给机器运行的,但同样也是给人看的,并且随着每次需求的迭代、变更、升级,都需要研发人员对同一份代码进行多次开发和上线,那么这里就会涉及到可维护易扩展好交接的特点。

而那些不合理分层实现代码逻辑、不写代码注释、不按规范提交、不做格式化、命名随意甚至把 queryBatch 写成 queryBitch 的,都会造成后续代码没法重构的问题。那么接下来我们就分别介绍下,开发好能重构的代码,都要怎么干!

二、代码优化

1. 约定规范

# 提交:主要 type
feat:     增加新功能
fix:      修复bug

# 提交:特殊 type
docs:     只改动了文档相关的内容
style:    不影响代码含义的改动,例如去掉空格、改变缩进、增删分号
build:    构造工具的或者外部依赖的改动,例如webpack,npm
refactor: 代码重构时使用
revert:   执行git revert打印的message

# 提交:暂不使用type
test:     添加测试或者修改现有测试
perf:     提高性能的改动
ci:       与CI(持续集成服务)有关的改动
chore:    不修改src或者test的其余修改,例如构建过程或辅助工具的变动

# 注释:类注释配置
/**
* @description: 
* @author: ${USER}
* @date: ${DATE}
*/
  • 分支:开发前提前约定好拉分支的规范,比如日期_用户_用途,210905_xfg_updateRuleLogic
  • 提交作者,type: desc 如:小傅哥,fix:更新规则逻辑问题 参考Commit message 规范
  • 注释:包括类注释、方法注释、属性注释,在 IDEA 中可以设置类注释的头信息 Editor -> File and Code Templates -> File Header 推荐下载安装 IDEA P3C 插件 Alibaba Java Coding Guidelines,统一标准化编码方式。

2. 接口标准

在编写 RPC 接口的时候,返回的结果中一定要包含明确的Code码Info描述,否则使用方很难知道这个接口是否调用成功还是异常,以及是什么情况的异常。

定义 Result

public class Result implements java.io.Serializable {

    private static final long serialVersionUID = 752386055478765987L;

    /** 返回结果码 */
    private String code;

    /** 返回结果信息 */
    private String info;

    public Result() {
    }

    public Result(String code, String info) {
        this.code = code;
        this.info = info;
    }

    public static Result buildSuccessResult() {
        Result result = new Result();
        result.setCode(Constants.ResponseCode.SUCCESS.getCode());
        result.setInfo(Constants.ResponseCode.SUCCESS.getInfo());
        return result;
    }
    
    // ...get/set
}

返回结果包装:继承

public class RuleResult extends Result {

    private String ruleId;
    private String ruleDesc;

    public RuleResult(String code, String info) {
        super(code, info);
    }
	
	// ...get/set
}

// 使用
public RuleResult execRule(DecisionMatter request) {
    return new RuleResult(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo());
}

返回结果包装:泛型

public class ResultData<T> implements Serializable {

    private Result result;
    private T data;

    public ResultData(Result result, T data) {
        this.result = result;
        this.data = data;
    }   
	
	// ...get/set
}  

// 使用
public ResultData<Rule> execRule(DecisionMatter request) {
    return new ResultData<Rule>(Result.buildSuccessResult(), new Rule());
}
  • 两种接口返回结果的包装定义,都可以规范返回结果。在这样的方式包装后,使用方就可以用统一的方式来判断Code码并做出相应的处理。

3. 库表设计

三范式:是数据库的规范化的内容,所谓的数据库三范式通俗的讲就是设计数据库表所应该遵守的一套规范,如果不遵守就会造成设计的数据库不规范,出现数据库字段冗余,数据的查询,插入等操作等问题。

数据库不仅仅只有三范式(1NF/2NF/3NF),还有BCNF、4NF、5NF…,不过在实际的数据库设计时,遵守前三个范式就足够了。再向下就会造成设计的数据库产生过多不必要的约束。

0NF

  • 第零范式是指没有使用任何范式,数据存放冗余大量表字段,而且这样的表结构非常难以维护。

1NF

  • 第一范式是在第零范式冗余字段上的改进,把重复字段抽离出来,设计成一个冗余数据较少便于存储和读取的表结构。
  • 同时在第一范式中也指出,表中的所有字段都应该是原子的、不可再分割的,例如:你不能把公司雇员表的部门名称和职责存放到一个字段。需要确保每列保持原子性

2NF

  • 满足1NF后,要求表中的列,都必须依赖主键,确保每个列都和主键列之间联系,而不能间接联系,也就是一个表只能描述一件事情。需要确保表中的每列都和主键相关。

3NF

  • 不能存在依赖关系,学号、姓名,到院系,院系到宿舍,需要确保每列都和主键列直接相关,而不是间接相关。

反三范式

三大范式是设计数据库表结构的规则约束,但是在实际开发中允许局部变通:

  1. 有时候为了便于查询,会在如订单表冗余上当时用户的快照信息,比如用户下单时候的一些设置信息。
  2. 单列列表数据汇总到总表中一个数量值,便于查询的时候可以避免列表汇总操作。
  3. 可以在设计表的时候冗余一些字段,避免因业务发展情况多变,考虑不周导致该表繁琐的问题。

4. 算法逻辑

通常在我们实际的业务功能逻辑开发中,为了能满足一些高并发的场景,是不可能对数据库表上锁扣减库存、也不能直接for循环大量轮训操作的,通常需要考虑🤔在这样场景怎么去中心化以及降低时间复杂度。

秒杀:去中心化

  • 背景:这个一个商品活动秒杀的实现方案,最开始的设计是基于一个活动号ID进行锁定,秒杀时锁定这个ID,用户购买完后就进行释放。但在大量用户抢购时,出现了秒杀分布式独占锁后的业务逻辑处理中发生异常,释放锁失败。导致所有的用户都不能再拿到锁,也就造成了有商品但不能下单的问题。
  • 优化:优化独占竞态为分段静态,将活动ID+库存编号作为动态锁标识。当前秒杀的用户如果发生锁失败那么后面的用户可以继续秒杀不受影响。而失败的锁会有worker进行补偿恢复,那么最终会避免超卖以及不能售卖。

算法:反面教材

@Test
public void test_idx_hashMap() {
    Map<String, String> map = new HashMap<>(64);
    map.put("alderney", "未实现服务");
    map.put("luminance", "未实现服务");
    map.put("chorology", "未实现服务");
    map.put("carline", "未实现服务");
    map.put("fluorosis", "未实现服务");
    map.put("angora", "未实现服务");
    map.put("insititious", "未实现服务");
    map.put("insincere", "已实现服务");
    
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        map.get("insincere");
    }
    System.out.println("耗时(initialCapacity):" + (System.currentTimeMillis() - startTime));
}
  • 背景:HashMap 数据获取时间复杂度在 O(1) -> O(logn) -> O(n),但经过特殊操作,可以把这个时间复杂度,拉到O(n)
  • 操作:这是一个定义HashMap存放业务实现key,通过key调用服务的功能。但这里的key,只有insincere有用,其他的都是未实现服务。那你看到有啥问题了吗?
    • 这点代码乍一看没什么问题,看明白了就是代码里下砒霜!它的目的就一个,要让所有的key成一个链表放到HashMap中,而且把有用的key放到链表的最后,增加get时的耗时!
    • 首先,new HashMap<>(64);为啥默认初始化64个长度?因为默认长度是8,插入元素时,当链表长度为8时候会进行扩容和链表树化判断,此时就会把原有的key散列了,不能让所有key构成一个时间复杂度较高的链表。
    • 其次,所有的 key 都是刻意选出来的,因为他们在 HashMap 计算下标时,下标值都为0,idx = (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16)),这样就能让所有 key 都散列到同一个位置进行碰撞。而且单词 insincere 的意思是;不诚恳的、不真诚的
    • 最后,前7个key其实都是废 key,不起任何作用,只有最后一个 key 有服务。那么这样就可以在HashMap中建出来很多这样耗时的碰撞链表,当然要满足0.75的负载因子,不要让HashMap扩容。

其实很多算法包括:散列、倒排、负载等,都是可以用到很多实际的业务场景中的,包括:人群过滤、抽奖逻辑、数据路由等等方面,这些功能的使用可以降低时间复杂度,提升系统的性能,降低接口响应时常。

5. 职责分离

为了可以让程序的逻辑实现更具有扩展性,通常我们都需要使用设计模式来处理各个场景的代码实现结构。而设计模式的使用在代码开发中的体现也主要为接口的定义、抽象类的包装和继承类的实现。通过这样的方式来隔离各个功能领域的开发,以此保障每次需求扩展时可以更加灵活的添加,而不至于让代码因需求迭代而变得更加混乱。

案例

public interface IRuleExec {

    void doRuleExec(String req);

}

public class RuleConfig {

    protected Map<String, String> configGroup = new ConcurrentHashMap<>();

    static {
        // ...
    }

}

public class RuleDataSupport extends RuleConfig{

    protected String queryRuleConfig(String ruleId){
        return "xxx";
    }

}

public abstract class AbstractRuleBase extends RuleDataSupport implements IRuleExec{

    @Override
    public void doRuleExec(String req) {
        // 1. 查询配置
        String ruleConfig = super.queryRuleConfig("10001");

        // 2. 校验信息
        checkRuleConfig(ruleConfig);

        // 3. 执行规则{含业务逻辑,交给业务自己处理}
        this.doLogic(configGroup.get(ruleConfig));
    }

    /**
     * 执行规则{含业务逻辑,交给业务自己处理}
     */
    protected abstract void doLogic(String req);

    private void checkRuleConfig(String ruleConfig) {
        // ... 校验配置
    }

}

public class RuleExec extends AbstractRuleBase {

    @Override
    protected void doLogic(String req) {
        // 封装自身业务逻辑
    }

}

类图

  • 这是一种模版模式结构的定义,使用到了接口实现、抽象类继承,同时可以看到在 AbstractRuleBase 抽象类中,是负责完成整个逻辑调用的定义,并且这个抽象类把一些通用的配置和数据使用单独隔离出去,而公用的简单方法放到自身实现,最后是关于抽象方法的定义和调用,而业务类 RuleExec 就可以按需实现自己的逻辑功能了。

6. 逻辑缜密

你的代码出过线上事故吗?为什么出的事故,是树上有十只鸟开一枪还剩几只的问题吗?比如:枪是无声的吗、鸟聋吗、有怀孕的吗、有绑在树上的鸟吗、边上的树还有鸟吗、鸟害怕枪声吗、有残疾的鸟吗、打鸟的人眼睛花不花,… …

实际上你的线上事故基本回围绕在:数据库连接和慢查询、服务器负载和宕机、异常逻辑兜底、接口幂等性、数据防重性、MQ消费速度、RPC响应时常、工具类使用错误等等。

下面举个例子:用户积分多支付,造成批量客诉。

  • 背景:这个产品功能的背景可能很大一部分研发都参与开发过,简单说就是满足用户使用积分抽奖的一个需求。上图左侧就是研发最开始设计的流程,通过RPC接口扣减用户积分,扣减成功后进行抽奖。但由于当天RPC服务不稳定,造成RPC实际调用成功,但返回超时失败。而调用RPC接口的uuid是每次自动生成的,不具备调用幂等性。所以造成了用户积分多支付现象。
  • 处理:事故后修改抽奖流程,先生成待抽奖的抽奖单,由抽奖单ID调用RPC接口,保证接口幂等性。在RPC接口失败时由定时任务补偿的方式执行抽奖。流程整改后发现,补偿任务每周发生1~3次,那么也就是证明了RPC接口确实有可用率问题,同时也说明很久之前就有流程问题,但由于用户客诉较少,所以没有反馈。

7. 领域聚合

不够抽象、不能写死、不好扩展,是不是总是你的代码,每次都像一锤子买卖,完全是写死的、绑定的,根本没有一点缝隙让新的需求扩展进去。

为什么呢,因为很多研发写出来的代码都不具有领域聚合的特点,当然这并不一定非得是在DDD的结构下,哪怕是在MVC的分层里,也一样可以写出很多好的聚合逻辑,把功能实现和业务的调用分离开。

  • 依靠领域驱动设计的设计思想,通过事件风暴建立领域模型,合理划分领域逻辑和物理边界,建立领域对象及服务矩阵和服务架构图,定义符合DDD分层架构思想的代码结构模型,保证业务模型与代码模型的一致性。通过上述设计思想、方法和过程,指导团队按照DDD设计思想完成微服务设计和开发。
    • 拒绝泥球小单体、拒绝污染功能与服务、拒绝一加功能排期一个月
    • 架构出高可用极易符合互联网高速迭代的应用服务
    • 物料化、组装化、可编排的服务,提高人效

8. 服务分层

如果你想让你的系统工程代码可以支撑绝对多数的业务需求,并且能沉淀下来可以服用的功能,那么基本你就需要在做代码开发实现的时候,抽离出技术组件、功能领域和业务逻辑这样几个分层,不要把频繁变化的业务逻辑写入到各个功能领域中,应该让功能领域更具有独立性,可以被业务层串联、编排、组合实现不同业务需求。这样你的功能领域才能被逐步沉淀下来,也更易于每次需求都 扩展。

  • 这是一个简化的分层逻辑结构,有聚合的领域、SDK组件、中间件和代码编排,并提供一些通用共性凝练出的服务治理功能。通过这样的分层和各个层级的实现方式,就可以更加灵活的承接需求了。

9. 并发优化

在分布式场景开发系统,要尽可能运用上分布式的能力,从程序设计上尽可能的去避免一些集中的、分布式事物的、数据库加锁的,因为这些方式的使用都可能在某些极端情况下,造成系统的负载的超标,从而引发事故。

  • 所以通常情况下更需要做去集中化处理,使用MQ消除峰,降低耦合,让数据可以最终一致性,也更要考虑在 Redis 下的使用,减少对数据库的大量锁处理。
  • 合理的运用MQ、RPC、分布式任务、Redis、分库分表以及分布式事务只有这样的操作你才可能让自己的程序代码可以支撑起更大的业务体量。

10. 源码能力

你有了解过 HashMap 的拉链寻址数据结构吗、知道哈希散列和扰动函数吗、懂得怎么结合Spring动态切换数据源吗、AOP 是怎么实现以及使用的、MyBatis 是怎么和 Spring 结合交管Bean对象的,等等。看似都是些面试的八股文,但在实际的开发中其实是可以解决很多问题的。

@Around("aopPoint() && @annotation(dbRouter)")
public Object doRouter(ProceedingJoinPoint jp, DBRouter dbRouter) throws Throwable {
    String dbKey = dbRouter.key();
    if (StringUtils.isBlank(dbKey)) throw new RuntimeException("annotation DBRouter key is null!");

    // 计算路由
    String dbKeyAttr = getAttrValue(dbKey, jp.getArgs());
    int size = dbRouterConfig.getDbCount() * dbRouterConfig.getTbCount();

    // 扰动函数
    int idx = (size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16));

    // 库表索引
    int dbIdx = idx / dbRouterConfig.getTbCount() + 1;
    int tbIdx = idx - dbRouterConfig.getTbCount() * (dbIdx - 1);   

    // 设置到 ThreadLocal
    DBContextHolder.setDBKey(String.format("%02d", dbIdx));
    DBContextHolder.setTBKey(String.format("%02d", tbIdx));
    logger.info("数据库路由 method:{} dbIdx:{} tbIdx:{}", getMethod(jp).getName(), dbIdx, tbIdx);
   
    // 返回结果
    try {
        return jp.proceed();
    } finally {
        DBContextHolder.clearDBKey();
        DBContextHolder.clearTBKey();
    }
}
  • 这是 HashMap 哈希桶数组 + 链表 + 红黑树的数据结构,通过扰动函数 (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16)); 解决数据碰撞严重的问题。
  • 但其实这样的散列算法、寻址方式都可以运用到数据库路由的设计实现中,还有整个数组+链表的方式其实库+表的方式也有类似之处。
  • 数据库路由简化的核心逻辑实现代码如上,首先我们提取了库表乘积的数量,把它当成 HashMap 一样的长度进行使用。
  • 当 idx 计算完总长度上的一个索引位置后,还需要把这个位置折算到库表中,看看总体长度的索引因为落到哪个库哪个表。
  • 最后是把这个计算的索引信息存放到 ThreadLocal 中,用于传递在方法调用过程中可以提取到索引信息。

三、总结

  • 讲道理,你几乎不太可能把一堆已经烂的不行的代码,通过重构的方式把他处理干净。细了说,你要改变代码结构分层、属性对象整合、调用逻辑封装,但任何一步的操作都可能会对原有的接口定义和调用造成风险影响,而且外部现有调用你的接口还需要随着你的改动而升级,可能你会想着在包装一层,但这一层包装仍需要较大的时间成本和几乎没有价值的适配。
  • 所以我们在实际开发中,如果能让这些代码具有重构的可能,几乎就是要实时重构,每当你在添加新的功能、新的逻辑、修复异常时,就要考虑是否可以通过代码结构、实现方式、设计模式等手段的使用,改变不合理的功能实现。每一次,一点的优化和改变,也不会有那么难。
  • 当你在接需求的时候,认真思考承接这样的业务诉求,都需要建设怎样的数据结构、算法逻辑、设计模式、领域聚合、服务编排、系统架构等,才能更合理的搭建出良好的具有易维护、可扩展的系统服务。如果你对这些还没有什么感觉,可以阅读设计模式手写Spring,这些内容可以帮助你提升不少的编程逻辑设计。

四、系列推荐