工作两年的回头看和向前走

前言

不知不觉也是工作满2年的老程序员了。2020年年初的疫情并没有使我产生犹豫,毅然的选择跳槽。虽然没有选择的待遇最好的offer,而是选择离家最近的,但对这一年在技术方面的进步和沉淀还是挺满意的。总结一下第二年的工作经验里,比第一年多了哪些思考。

良好的代码规范能少踩很多坑

时间格式问题

yyyy-MM-dd HH:mm:ss这是大家熟知的时间格式了,但大小写分别代表了什么含义,可能很多人都不记得了。最近就遇到了一个有趣的生产问题。2020年12月28号,2020年的最后一星期的星期一。一个查询流水的接口,银行端在不断的返回失败。对于生产的问题的排查,所以先看相关代码是否有修改过,最近有没有发版记录。再确认代码已经一年没有更新后,我便认真研究了下祖传的代码,发现了一个令人哭笑不得的BUG。

        String stDate = DateUtils.formatDate(loginAndGetStreamDTO.getStartDate(), "YYYY-MM-dd");
        String enDate = DateUtils.formatDate(loginAndGetStreamDTO.getEndDate(), "YYYY-MM-dd");
        LocalDateTime localDate = LocalDateTime.now();
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("YYYY-MM-dd HH:MM:SS");

大写的年份和小写的年份是不是有什么不同呢?于是我百度了下
YYYY是以周来计算年的,意思是当天所在周属于的年份,一周从周日开始算计算,周六结束,只要本周跨年,那么这一周就算下一年的。
所以2020年12月28号,经过日期格式的转换后,就变成了2021-12-28了。如果不是刚好这个礼拜财务进行了转账的操作,这个BUG可能就要再等一年才有发现的机会了。

请求头的key和value一定要用常量

这次又是一个祖传接口报错的问题。请求报文出现了乱码的情况,在和银行端确认编码格式没有变更后,我查看了历史日志,确认是最近才出现的乱码。代码如下

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("Expect", "100-continue");
        httpHeaders.set("Content", "text/xml; charset=GBK");
        httpHeaders.set("ONE_STEP", "YES");

这请求头设置的都是些啥啊。正确的报文格式和字符集应该是

 httpHeaders.set("Content-Type", "application/x-fox;charset=GBK");

之前请求头字符集的设置根本没有生效,只是之前报文里没有出现中文,一旦出现了中文,就发生了乱码的情况。

数据库规范

建表的时候请把索引一起考虑进去

先说普通索引,生产出现过由于某张业务信息表的数据不停增加,导致查询越来越慢,最后发生了超时情况,这时候才发现常用的查询字段没有添加索引。这时候想给上百万的数据添加索引,SQL的执行已经不是毫秒级别了,这些都会存在很大的风险。
再说唯一索引。元旦放假的前一天,生产环境下单发生报错,经排查竟然是新创建的SKU出现了重复的现象。生成规则是通过redis自增来实现,但是是在商品编码的基础上,新增两位自增数。由于该商品的SKU种类已经超过了100个,所以导致了新增的SKU存在重复的情况。这个BUG的修复很简单,只需要把SKU的生成规则改成自增4位数就解决了,但这个问题完全是可以预防的。在新增SKU信息的时候,SKU表添加SKU字段的唯一索引,在新增SKU信息的时候就可以拦住这个问题了,而不会流转到下单才发现了。

表字段的规范问题

第一,相同含义的字段,在每张表的字段应该也是相同的,包括表字段的说明。举个例子,开票类型在有的表中是发票类型,对于新人来说是容易引起歧义的。第二,如果表字段关联数据字典,请在字段注释中描述清楚,包括代码的实体类中。如果后续字典有变更,记得及时更新数据库和代码的注释。

代码优化相关

Java8新特性

由于需求变更,需要修改一个同事写的代码。这位同事的代码全是Java8的流操作、异步编程等等,代码十分的优雅,但对于只懂皮毛的我来说,确认有点看不懂。在虚心请教下,同事推荐了我一本书,《Java8实战》。确实让我能熟练的使用上了Java8的新特性来编程。

数据的状态检验

        List<CustomerRechargeOrderAccount> dataList = this.getBaseMapper().selectBatchIds(idList);
        // 只能对未结算的记录进行操作
        for (CustomerRechargeOrderAccount data : dataList) {
    
    
            if (!CommonConstans.SETTLEMENT_STATUS_WAIT.equals(data.getBillStatus()))) {
    
    
                return new JsonResult().error("请选择未结算记录");
            }
        }
        List<CustomerRechargeOrderAccount> dataList = this.getBaseMapper().selectBatchIds(idList);
        // 只能对未结算的记录进行操作
        if (!dataList.stream().allMatch(data -> CommonConstans.SETTLEMENT_STATUS_WAIT.equals(data.getBillStatus()))) {
    
    
            return new JsonResult().error("请选择未结算记录!");
        }

上面两段代码都是对数据是否为未结算状态做的校验,使用流操作的函数式编程阅读起来更加的简单明了。

数据的计算

        // 订单结算记录对应的结算金额(销售价*数量)
        BigDecimal result = BigDecimal.ZERO;
        for (CountSettlementPriceVO data : dataList) {
    
    
            result = result.add(data.getSettlementPrice().multiply(new BigDecimal(data.getGoodsNum())));
        }
        // 订单结算记录对应的结算金额(销售价*数量)
        Function<List<CountSettlementPriceVO>, BigDecimal> function = (list) -> list.parallelStream()
                .map(data -> data.getSettlementPrice().multiply(new BigDecimal(data.getGoodsNum())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal result = function.apply(dataList);

当然Java8新特性远不止这些,《Java8实战》值得反复的细品。

对象的复用解决OOM问题

        List<BatchUpdateOptions> updateList = new ArrayList<>();
        for (int i = 1; i <= time; i++) {
    
    
            IPage<OutStorageDetails> page = outStorageDetailsDao.selectPage(new Page<>(i, exportCount), queryWrapper);
            for (OutStorageDetails record : page.getRecords()) {
    
    
                query = Query.query(Criteria.where("couponNo").is(record.getCouponNo()));
                BaseDataEntity baseDataEntity = this.queryOne(query, this.getTableName());
                if (baseDataEntity != null && StringUtils.isNotEmpty(baseDataEntity.getCardNo())) {
    
    
                    continue;
                }
                update  = new Update();
                update.set("cardNo", record.getCardNo());
                batchUpdateOptions = new BatchUpdateOptions();
                batchUpdateOptions.setUpdate(update);
                batchUpdateOptions.setQuery(query);
                batchUpdateOptions.setUpsert(true);
                batchUpdateOptions.setMulti(true);
                updateList.add(batchUpdateOptions);
                query = null;
            }
            page = null;
            if (CollectionUtils.isEmpty(updateList)) {
    
    
                continue;
            }
            this.batchUpdate(this.getTableName(), updateList, false);
            updateList.clear();
            System.gc();
        }

以上代码在生产环境发生了OOM。排查后发现,由于生产的数据量超过百万,内存循环中执行new BatchUpdateOptions()创建了上百万个对象。在第一版的优化中,可以看到通过把不用的大对象置为null,手动gc,以及集合的复用,最后发现效果甚微。于是有第二版的优化

 List<BatchUpdateOptions> updateList = new ArrayList<>();
        BatchUpdateOptions[] batchUpdateOptionsArray = new BatchUpdateOptions[exportCount];
        for (int i = 0; i < exportCount; i++) {
    
    
            batchUpdateOptionsArray[i] = new BatchUpdateOptions();
        }
        for (int i = 1; i <= time; i++) {
    
    
            IPage<OutStorageDetails> page = outStorageDetailsDao.selectPage(new Page<>(i, exportCount), queryWrapper);
            List<OutStorageDetails> records = page.getRecords();
            int total = records.size();
            for (int j = 0; j < total; j++) {
    
    
                query = Query.query(Criteria.where("couponNo").is(records.get(j).getCouponNo()));
                update  = new Update();
                update.set("cardNo", records.get(j).getCardNo());
                batchUpdateOptionsArray[j].setUpdate(update);
                batchUpdateOptionsArray[j].setQuery(query);
                batchUpdateOptionsArray[j].setUpsert(true);
                batchUpdateOptionsArray[j].setMulti(true);
                updateList.add(batchUpdateOptionsArray[j]);
                query = null;
            }
            page = null;
            this.batchUpdate(this.getTableName(), updateList, false);
            updateList.clear();
        }

说一下思路,初始化一个数组,先将数组内的对象完成初始化,然后在内层循环中直接复用数组中的对象,大大减小了对象创建的数量,解决了OOM的问题。

对常用工具类的二次封装

Assert

        if (null == dto) {
    
    
            throw new BizException(Const.CODE_FAILED, "入参不能为空");
        }
        Assert.notNull(dto, "入参不能为空");

Assert能让代码对于参数的校验看起来优雅很多。使用的时候要注意一点,Assert内部抛出的是IllegalArgumentException,要确定是否有对该异常进行处理。并且Assert是个抽象类,我们可以自己实现一个AssertUtil去继续Assert,然后在里面自定义一些方法,去做特有的校验,比如

public abstract class AssertUtil extends Assert {
    
    

    /**
     * @return void
     * @Author linlx
     * @Description 参数长度不等于length抛异常
     * @Date 2020/12/17 0017 下午 3:46
     * @Param [arg, length, message]
     **/
    public static void lengthEqual(Object arg, int length, String message) {
    
    
        notNull(arg, "AssertUtil.checkLength arg must not be null");
        String value = String.valueOf(arg);
        if (value.length() != length) {
    
    
            throw new IllegalArgumentException(message);
        }
    }

StringRedisTemplate

用来做接口幂等的常用方法redisTemplate.opsForValue().setIfAbsent在源码中是有@Nullable注解的,并且说明在when used in pipeline / transaction.的情况下可能出现。如果直接使用

if (!redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit)) {
    
    
            ....
}

idea是会给出空指针提示的,所以这里建议对该方法进行2次封装。

@Component
public class StringRedisTemplateUtil {
    
    

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public static StringRedisTemplate redisTemplate;

    /**
     * @Author linlx
     * @Description stringRedisTemplate的setIfAbsent可能返回null,该方法处理了返回为null的情况
     * @Date 2020/10/19 0019 上午 11:10
     * @Param [key, value, time, timeUnit]
     * @return boolean
     **/
    public static boolean setIfAbsent(String key, String value, Long time, TimeUnit timeUnit) {
    
    
        Boolean boo = redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit);
        return boo == null ? false : boo;
    }

    @PostConstruct
    public void init() {
    
    
        redisTemplate = this.stringRedisTemplate;
    }
}

工具类里的方法一般都是静态方法,可以在不初始化实例的情况下直接调用。但对于@Autowired来说,是无法作用于静态变量的。因为静态变量是属于本身类的信息,当类加载器加载静态变量时,Spring 的上下文环境还没有被加载,所以不可能为静态变量绑定值。所以这里有个小技巧,可以使用@PostConstruct在类初始化的时候完成赋值。

自定义注解

工作中经常需要调用第三方的接口,对于不同url的配置管理十分的繁琐


private ResponseEntity<String> post(String service, String url, Object data) {
    
    
        ChinaUmsDTO macDTO = new ChinaUmsDTO();
        macDTO.setApiKey(chinaUmsConfig.getApiKey());
        macDTO.setService(service);
        macDTO.setTimestamp(DateTimeFormatter.ofPattern(TimeType.TIME_STR_FORMAT.getTimeType()).format(LocalDateTime.now()));
        macDTO.setBizContent(JSON.toJSONString(data));
        macDTO.setSign(ChinaUmsUtil.createSign(macDTO, chinaUmsConfig.getPrivateKey()));
        return RestTemplateFactory.postForEntity(chinaUmsConfig.getUrl() + url, null, macDTO.toForm(), String.class);
    }

这里可以使用自定义注解的方式,将与请求相关的内容绑定在请求体中

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ChinaUmsRequest {
    
    

    String service();

    String url();
}
@ChinaUmsRequest(service = "createAuthCode", url = "discountRight.do")
public class CreateAuthCodeDTO extends CommonDTO{
    
    
private ResponseEntity<String> post(CommonDTO commonDTO) {
    
    
        ChinaUmsDTO macDTO = new ChinaUmsDTO();
        macDTO.setApiKey(chinaUmsConfig.getApiKey());
        macDTO.setService(commonDTO.getClass().getAnnotation(ChinaUmsRequest.class).service());
        macDTO.setTimestamp(DateTimeFormatter.ofPattern(TimeType.TIME_STR_FORMAT.getTimeType()).format(LocalDateTime.now()));
        macDTO.setBizContent(JSON.toJSONString(commonDTO));
        macDTO.setSign(ChinaUmsUtil.createSign(macDTO, chinaUmsConfig.getPrivateKey()));
        return RestTemplateFactory.postForEntity(chinaUmsConfig.getUrl() + commonDTO.getClass().getAnnotation(ChinaUmsRequest.class).url(), null, macDTO.toForm(), String.class);
}

大型项目的分享

刚接到需求的时候,对业务量的估算是每年的交易额在亿元,并且是定时的抢购活动,所以我首先考虑的是并发的解决方案已经以及数据库的分库分表设计。
先分享高并发的应对方案。请求的第一道入口是在nginx,每次活动的抢购数量是有限的,所以我的想法是根据活动数量在nginx完成第一步的限流。因为抢购的活动特性,所以在这里我选择的是漏桶算法,对于溢出的请求,直接返回失败。比如抢购的数量为3000份,那么nginx在活动开始时对抢购请求的限流在4000,在设定的时间段内超过4000,直接拦截,不让请求到达服务器,从而减少服务器的压力。接下来考虑的是活动开启前的预处理。对于需要用到的热点数据在缓存中的过期时间延长,保证活动开始后热点的key不会有失效的情况,避免缓存雪崩。最后是对于库存的后置处理,活动开始后的数量操作都是基于缓存,活动结束后再去同步数据库的数量。接下来考虑的是流量的问题,解决方式比较简单粗暴,运维在活动开始前在阿里云上购买了流量包。
对于合作项目,数据量的估算是较为准确的,所以在分库分表的设计上会比较的简单。我的做法是根据用户编码做哈希的分表,哈希的缺点不能动态扩展,但在能准确预估合作期间数据总量的情况下,是可以在一开始就解决的。后来在跟架构师的交流中得知,一年三百万的的数据量,完全没有做分表的必要。最后我认真考虑了一下业务场景,活动抢购的券码有效期都在一个月,对账的结算是T+30季度结算,所以对于超过1年的历史数据基本上是没有用处的。所以我最后的方案是利用定时器来迁移下单时间超过1年的数据到历史表中,减少主表的数据量。

关于对外的接口文档

对外的接口文档可以说是公司技术水平的体现,包括全局说明、命名规范、字段说明等等。举个例子,字段的说明不仅仅是字段的含义,还要有字段的类型以及长度。如果涉及时间,时间格式麻烦仔细确认,别出现YYYY这种奇怪的格式了。加解密一定要提供DEMO,如果有能力提供SDK那是最好的。

题外话

程序员有代码洁癖是真,都不愿意去改别人的代码,但在工作中是很难避免的,特别是开始新工作时。看到历史的代码写的不好,我也会吐槽,但最后我都会去改造去优化去重构。在写这篇文章的时候,我看了我刚入职时候的代码,这一年自己代码水平的进步还是十分明显的,也对刚入职时候的代码嗤之以鼻,写的是什么玩意啊。所以说呢,人都是会进步的啊。也不要因为某段代码写的不好,就对作者产生负面的情绪,因为实际工作中确实存在很多客观原因会影响到代码的质量,比如开发工期赶、需求的变更等等。代码水平不仅体现技术能力,更是职业素养的体现,简单的几行注释能为之后接手的人减少很多工作量,变量、方法的命名能做到见名知意那是再好不过了。有人说当兴趣变为工作就失去了乐趣,但在工作中我一直觉得能静静的敲代码是一件很快乐的事。

猜你喜欢

转载自blog.csdn.net/weixin_43776741/article/details/112093294