• 【好文鉴赏】优秀的后端应该有哪些开发习惯


    关键词:[后端] [编程习惯] [代码风格]
    原文链接:

    在这里插入图片描述

    这里涉及到的主要是 Java 语言,当然也有通用的一些习惯;

    拆分合理的目录结构

    受传统的 MVC 模式影响,传统做法大多是几个固定的文件夹 controller、service、mapper、entity,然后无限制添加,到最后你就会发现一个 service 文件夹下面有几十上百个 Service 类,根本没法分清业务模块。正确的做法是在写 Service 上层新建一个 modules 文件夹,在 moudles 文件夹下根据不同业务建立不同的包,在这些包下面写具体的 service、controller、entity、enums 包或者继续拆分;

    在这里插入图片描述
    等以后开发版本迭代,如果某个包可以继续拆领域就继续往下拆,可以很清楚的一览项目业务模块。后续拆微服务也简单;
    ps:个人理解为把 功能模块化 转换为 业务模块化,但是又保证了单个业务下的功能模块化;随着整个系统不断扩张时,后者无论是在查询效率还是在扩展效率上都有显著提高;但应该要留意不同业务之间的交互;

    封装方法形参

    当你的方法形参过多时请封装一个对象出来… 下面是一个反面教材,谁特么教你这样写代码的!

    public void updateCustomerDeviceAndInstallInfo(long customerId, String channelKey,
                       String androidId, String imei, String gaId,
                       String gcmPushToken, String instanceId) {}
    
    • 1
    • 2
    • 3

    写个对象出来并给它赋予相应的意义,使用对象传参清晰明了,也方便后期扩展和维护;

    public class CustomerDeviceRequest {
        private Long customerId;
        private String channelKey;
        private String androidId;
        private String imei;
        private String gaId;
        private String gcmPushToken;
        private String instanceId;
    }
    
    public void updateCustomerDeviceAndInstallInfo(CustomerDeviceRequest cdr) {}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    为什么要这么写?比如你这方法是用来查询的,万一以后加个查询条件是不是要修改方法?每次加每次都要改方法参数列表。封装个对象,以后无论加多少查询条件都只需要在对象里面加字段就行。而且关键是看起来代码也很舒服啊!

    封装业务逻辑

    如果你看过“屎山”你就会有深刻的感触,这特么一个方法能写几千行代码,还无任何规则可言…往往负责的人会说,这个业务太复杂,没有办法改善,实际上这都是懒的借口。不管业务再复杂,我们都能够用合理的设计、封装去提升代码可读性。下面贴两段高级开发(作者)写的代码

    @Transactional
    public ChildOrder submit(Long orderId, OrderSubmitRequest.Shop shop) {
        ChildOrder childOrder = this.generateOrder(shop);
        childOrder.setOrderId(orderId);
        //订单来源 APP/微信小程序
        childOrder.setSource(userService.getOrderSource());
        // 校验优惠券
        orderAdjustmentService.validate(shop.getOrderAdjustments());
        // 订单商品
        orderProductService.add(childOrder, shop);
        // 订单附件
        orderAnnexService.add(childOrder.getId(), shop.getOrderAnnexes());
        // 处理订单地址信息
        processAddress(childOrder, shop);
        // 最后插入订单
        childOrderMapper.insert(childOrder);
        this.updateSkuInventory(shop, childOrder);
        // 发送订单创建事件
        applicationEventPublisher.publishEvent(new ChildOrderCreatedEvent(this, shop, childOrder));
        return childOrder;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    @Transactional
    public void clearBills(Long customerId) {
        // 获取清算需要的账单、deposit等信息
        ClearContext context = getClearContext(customerId);
        // 校验金额合法
        checkAmount(context);
        // 判断是否可用优惠券,返回可抵扣金额
        CouponDeductibleResponse deductibleResponse = couponDeducted(context);
        // 清算所有账单
        DepositClearResponse response = clearBills(context);
        // 更新 l_pay_deposit
        lPayDepositService.clear(context.getDeposit(), response);
        // 发送还款对账消息
        repaymentService.sendVerifyBillMessage(customerId, context.getDeposit(), EventName.DEPOSIT_SUCCEED_FLOW_REMINDER);
        // 更新账户余额
        accountService.clear(context, response);
        // 处理清算的优惠券,被用掉或者解绑
        couponService.clear(deductibleResponse);
        // 保存券抵扣记录
        clearCouponDeductService.add(context, deductibleResponse);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    这两段代码里面其实业务很复杂,内部估计保守干了五万件事情,但是不同水平的人写出来就完全不同,不得不赞一下这个注释,这个业务的拆分和方法的封装。一个大业务里面有多个小业务,不同的业务调用不同的 Service 方法即可,后续接手的人即使没有流程图等相关文档也能快速理解这里的业务,而很多初级开发写出来的业务方法就是上一行代码是 A 业务的,下一行代码是 B 业务的,在下面一行代码又是 A 业务的,业务调用之间还嵌套这一堆单元逻辑,显得非常混乱,代码还多;

    ps: 把独立功能的代码抽象成方法,避免一个方法内镶嵌若干个功能/业务逻辑,很好的习惯

    判断集合类型不为空的正确方式

    很多人喜欢写这样的代码去判断集合

    if (list == null || list.size() == 0) {
      return null;
    }
    
    • 1
    • 2
    • 3

    当然你硬要这么写也没什么问题…但是不觉得难受么,现在框架中随便一个 jar 包都有集合工具类,比如 org.springframework.util.CollectionUtils等等;以后请这么写:

    if (CollectionUtils.isEmpty(list) || CollectionUtils.isNotEmpty(list)) {
      return null;
    }
    
    • 1
    • 2
    • 3

    ps: 两者功能应该一样,我自己一般用的前者,但看公司代码和一些项目都是倾向于后者

    集合类型返回值不要 return null

    当你的业务方法返回值是集合类型时,请不要返回 null,正确的操作是返回一个空集合。你看 Bybatis 的列表查询,如果没查询到元素返回的就是一个空集合,而不是 null。否则调用方得去做 NULL 判断,多数场景下对于对象也是如此;

    映射数据库的属性尽量不要用基本类型

    我们都知道 int/long 等基本数据类型作为成员变量默认值是 0。现在流行使用 mybatisplus 、mybatis 等 ORM 框架,在进行插入或者更新的时候很容易会带着默认值插入更新到数据库。我特么真想砍了之前的开发,重构的项目里面实体类里面全都是基本数据类型。当场裂开…

    对多个判断条件进行封装

    public void method(LoanAppEntity loanAppEntity, long operatorId) {
      if (LoanAppEntity.LoanAppStatus.OVERDUE != loanAppEntity.getStatus()
              && LoanAppEntity.LoanAppStatus.CURRENT != loanAppEntity.getStatus()
              && LoanAppEntity.LoanAppStatus.GRACE_PERIOD != loanAppEntity.getStatus()) {
        //...
        return;
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这段代码的可读性很差,这 if 里面谁知道干啥的?我们用面向对象的思想去给 loanApp 这个对象里面封装个方法不就行了么?

    public void method(LoanAppEntity loan, long operatorId) {
      if (!loan.finished()) {
        //...
        return;
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    LoanApp 这个类中封装一个方法,简单来说就是这个逻辑判断细节不该出现在业务方法中;

    /**
     * 贷款单是否完成
     */
    public boolean finished() {
      return LoanAppEntity.LoanAppStatus.OVERDUE != this.getStatus()
              && LoanAppEntity.LoanAppStatus.CURRENT != this.getStatus()
              && LoanAppEntity.LoanAppStatus.GRACE_PERIOD != this.getStatus();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    控制方法复杂度

    推荐一款 IDEA 插件 CodeMetrics ,它能显示出方法的复杂度,它是对方法中的表达式进行计算,布尔表达式if/else 分支,循环等。

    点击可以查看哪些代码增加了方法的复杂度,可以适当进行参考,毕竟我们通常写的是业务代码,在保证正常工作的前提下最重要的是要让别人能够快速看懂。当你的方法复杂度超过 10 就要考虑是否可以优化了;

    使用 @ConfigurationProperties 代替 @Value

    列举一下 @ConfigurationProperties 的好处

    • 在项目 application.yml 配置文件中按住 ctrl + 鼠标左键 点击配置属性可以快速导航到配置类。写配置时也能自动补全、联想到注释。需要额外引入一个依赖 org.springframework.boot:spring-boot-configuration-processor

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rOFl6HQW-1668648167363)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5621193de8ac40479d5b402da1801a0b~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?)]

    • @ConfigurationProperties 支持 NACOS 配置自动刷新,使用 @Value 需要在 BEAN 上面使用 @RefreshScope 注解才能实现自动刷新;

    • @ConfigurationProperties 可以结合 Validation 校验,@NotNull@Length 等注解,如果配置校验没通过程序将启动不起来,及早的发现生产丢失配置等问题

    • @ConfigurationProperties 可以注入多个属性@Value 只能一个一个写

    • @ConfigurationProperties 可以支持复杂类型,无论嵌套多少层,都可以正确映射成对象

    相比之下我不明白为什么那么多人不愿意接受新的东西,裂开…你可以看下所有的 springboot-starter 里面用的都是 @ConfigurationProperties 来接配置属性;

    推荐使用 lombok

    当然这是一个有争议的问题,我的习惯是使用它省去 getter、setter、toString 等等;

    不要在 AService 调用 BMapper (有异议)

    我们一定要遵循从 AService -> BService -> BMapper,如果每个 Service 都能直接调用其他的 Mapper,那特么还要其他 Service 干嘛?老项目还有从 Controller 调用 Mapper 的,把控制器当 Service 来处理了。。

    ps: 理论上一个 Service 职责最好单一,尽量别耦合,不然你会发现当一个调用的 Service里的业务逻辑发生变化后不一定跟你现在要的是一个东西。这么干,那势必会出多层、递归调用;具体地说:" AService -> BService " ,也可能需要" BService -> AService ",这样就会造成循环依赖,虽然目前的版本 Spring 是默认解决循环依赖,对于低版本的 Spring 是有问题的,报循环依赖异常;// 一个评论区佬的看法是:Service 层不应该循环调用,不止 Service 层,同层间都不应该互相调用,上面说的情况可以通过加中间层 manager 解决;相当于提供一个仓储,里面包含所有的dao,对外还可以隐藏内部细节;// 不过说到底感觉都过度追求规范了,都能举出反例,看是规范和简单怎么抉择,出现了反例势必就要提供解决方法

    会使用基础工具类,会写自定义工具类

    少写基础工具类,因为你写的大部分基础点的工具类,在你无形中引入的 jar 包里面就有,String 的,Assert 断言的,IO 上传文件,拷贝流的,Bigdecimal 的等等。自己写容易错还要加载多余的类;

    ps:自定义的一些必要的工具类还是要写,自己知道的有 jar 包直接拿来用的可以省略,当然这也是个积累的过程;我在视频上边学边敲项目时就发现很多工具类写的很巧妙

    要不要包裹 OpenFeign 接口返回值

    不使用 code、message 封装

    搞不懂为什么那么多人喜欢把接口的返回值用 Response 包装起来…加个 code、message、success 字段,然后每次调用方就变成这样

    CouponCommonResult bindResult = couponApi.useCoupon(request.getCustomerId(), order.getLoanId(), coupon.getCode());
    if (Objects.isNull(bindResult) || !bindResult.getResult()) {
      throw new AppException(CouponErrorCode.ERR_REC_COUPON_USED_FAILED);
    }
    
    • 1
    • 2
    • 3
    • 4

    这样就相当于

    1. coupon-api 抛出异常
    2. coupon-api 拦截异常,修改 Response.code
    3. 在调用方判断 response.code 如果是 FAIELD 再把异常抛出去…

    你直接在服务提供方抛异常不就行了么。。。而且这样一包装 HTTP 请求永远都是 200,没法做重试和监控。当然这个问题涉及到接口响应体该如何设计,目前网上大多是三种流派

    • 接口响应状态一律 200
    • 接口响应状态遵从 HTTP 真实状态
    • 佛系开发,领导怎么说就怎么做

    不接受反驳,我推荐使用 HTTP 标准状态。特定场景包括参数校验失败等一律使用 400 给前端弹 toast

    ps:作者说的有道理啊,对接口返回值进行封装虽然一定程度上提高了通用性,简洁性,emm 但确实存在上述问题,看怎么取舍吧,以后写出了可能有自己的见解 // 借评论区一个大佬:个人建议各自服务自行抛异常,微服务通过网关进行分发请求,在网关这一层拦截响应信息,给它包一层code\data\message再返回到前端,而服务间调用,直接报异常得啦,两方都兼容
    当然,有些观点是”网关要轻量“这类的,当我没说

    使用 code message data 封装

    要记住 RESTfuljsonrpc 是两回事;就是因为以下原因固定 200 并且使用 code、message、data 来返回的

    1. 能够知道非 200 的错误一定不是来自程序,而是来自可能是 nginx 等,说明压根没进入到 Controller 里,方便排查;
    2. 服务间调用可以通过 code 来决定是否要捕捉、抛出、忽略这个错误,举个例子比如我要注册并登陆一个用户,下层服务返回 USER_EXISTED_ALREADY,那我就继续走登陆接口了。
    3. OpenFeign 是可以自定义Decoder来自动拆掉范型的,比如你服务上的 Controller 是 Response getById(),在写 FeignClient 时可以直接写 User getById()

    写有意义的方法注释

    这种注释你写出来是怕后面接手的人瞎么…你在后面写字段参数的意义啊…

    /**
    * 请求电话验证
    *
    * @param credentialNum
    * @param callback
    * @param param
    * @return phoneVerifyResult
    */
    
    public void method(String credentialNum,String callback,String param,String phoneVerifyResult){
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    要么就别写,要么就在后面加上描述…写这样的注释被 IDEA 报一堆警告看着不难受?

    和前端交互的 DTO 对象命名

    什么 VO、BO、DTO、PO 我倒真是觉得没有那么大必要分那么详细,至少我们在和前端交互的时候类名要起的合适,不要直接用映射数据库的类返回给前端,这会返回很多不必要的信息,如果有敏感信息还要特殊处理

    推荐的做法是接受前端请求的类定义为 XxxRequest,响应的定义为 XxxResponse。以订单为例:接受保存更新订单信息的实体类可以定义为 OrderRequest,订单查询响应定义为 OrderResponse,订单的查询条件请求定义为 OrderQueryRequest

    不要跨服务循环访问数据库

    跨服务查询时,如果有批量数据查询的场景,直接写一个批量的 Feign 查询接口,不要像下面这样

    list.foreach(id -> {
        UserResponse user = userClient.findById(id);
    });
    
    • 1
    • 2
    • 3

    因为每一次 OpenFeign 的请求都是一个 Http 请求、一个数据库 IO 操作,还要经过各种框架内的拦截器、解码器等等,全都是损耗。

    直接定义一个批量查询接口

    @PostMapping("/user/batch-info")
    List<UserResponse> batchInfo(@RequestBody List<Long> userIds);
    
    • 1
    • 2

    这就结束了吗?并没有,如果你遇到这种 userIds 的数量非常大,在 2000 以上,那么你在实现方不能在数据库中直接用 in() 去查询。在实现方要拆分这个 useIds 。有索引的情况下 in() 1000 个元素以下通常问题不大

    public List<XxxResponse> list(List<Long> userIds) {
      List<List<Long>> partition = Lists.partition(userIds, 500); //拆分 List
    
      List<XxxResponse> list = new ArrayList<>();
      partition.forEach(item -> list.addAll(xxxMapper.list(item)));
      return list;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    尽量别让 IDEA 报警

    我是很反感看到 IDEA 代码窗口一串警告的,非常难受。因为有警告就代表代码还可以优化,或者说存在问题。 前几天捕捉了一个团队内部的小 bug,其实本来和我没有关系,但是同事都在一头雾水的看外面的业务判断为什么走的分支不对,我一眼就扫到了问题。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qocS6hvj-1668648167364)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5d59b8e61f5841eaa8036be98f4174e1~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?)]

    因为 java 中整数字面量都是 int 类型,到集合中就变成了 Integer,然后 stepId 点上去一看是 long 类型,在集合中就是 Long,那这个 contains 妥妥的返回 false,都不是一个类型。

    你看如果注重到警告,鼠标移过去看一眼提示就清楚了,少了一个生产 bug。

    ps: 确实,强迫症是一方面,时常提供有价值的信息也是一方面;以前在实习的时候找一个bug,忽视了 IDEA 的警告,硬是没找到地方,结果吃个饭回来一个警告信息就是出错的地方

    保持优化性能的嗅觉

    比如避免创建比必要的对象、异步处理、使用缓冲流,减少IO操作等等。

    比如,我们设计一个APP首页的接口,它需要查用户信息、需要查banner信息、需要查弹窗信息等等。假设耗时如下:

    图片

    查用户信息200ms,查banner信息100ms、查弹窗信息50ms,那一共就耗时350ms了。如果还查其他信息,那耗时就更大了。如何优化它呢?可以并行发起,耗时可以降为200ms。如下:

    图片

    可变参数的配置化处理

    日常开发中,我们经常会遇到一些可变参数,比如用户多少天没登录注销、运营活动,不同节日红包皮肤切换、订单多久没付款就删除等等。对于这些可变的参数,不用该直接写死在代码。优秀的后端,要做配置化处理,你可以把这些可变参数,放到数据库一个配置表里面,也可以放到项目的配置文件或者 apollo 上。

    比如产品经理提了个红包需求,圣诞节的时候,红包皮肤为圣诞节相关的,春节的时候,为春节红包皮肤等。如果在代码写死控制,可有类似以下代码:

    if(duringChristmas){
       img = redPacketChristmasSkin;
    }else if(duringSpringFestival){
       img =  redSpringFestivalSkin;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    如果到了元宵节的时候,运营小姐姐突然又有想法,红包皮肤换成灯笼相关的,这时候,是不是要去修改代码了,重新发布了?

    从一开始接口设计时,可以实现一张红包皮肤的配置表,将红包皮肤做成配置化呢?更换红包皮肤,只需修改一下表数据就好了。当然,还有一些场景适合一些配置化的参数:一个分页多少数量控制、某个抢红包多久时间过期这些,都可以搞到参数配置化表里面。这也是扩展性思想的一种体现。

    尽可能使用新技术组件

    我觉得这是一个程序员应该具备的素养…反正我是喜欢用新的技术组件,因为新的技术组件出现必定是解决旧技术组件的不足,而且作为一个技术人员我们应该要与时俱进~~ 当然前提是要做好准备工作,不能无脑升级。举个最简单的例子,Java 17 都出来了,新项目现在还有人用 Date 来处理日期时间…

    总结

    节的时候,运营小姐姐突然又有想法,红包皮肤换成灯笼相关的,这时候,是不是要去修改代码了,重新发布了?

    从一开始接口设计时,可以实现一张红包皮肤的配置表,将红包皮肤做成配置化呢?更换红包皮肤,只需修改一下表数据就好了。当然,还有一些场景适合一些配置化的参数:一个分页多少数量控制、某个抢红包多久时间过期这些,都可以搞到参数配置化表里面。这也是扩展性思想的一种体现。

    尽可能使用新技术组件

    我觉得这是一个程序员应该具备的素养…反正我是喜欢用新的技术组件,因为新的技术组件出现必定是解决旧技术组件的不足,而且作为一个技术人员我们应该要与时俱进~~ 当然前提是要做好准备工作,不能无脑升级。举个最简单的例子,Java 17 都出来了,新项目现在还有人用 Date 来处理日期时间…

    总结

    养成一些好的开发习惯肯定是需要通过不断的实践来沉淀下来的,但若事先有这个意识,在实践的过程中就会多留心,多注重代码的规范性、可读性、可扩展性等等;实习的时候便是对两个 Demo 进行重构,对代码的质量问题深有感触;

  • 相关阅读:
    主题教育问题清单及整改措施2023年-主题教育对照六个方面个人剖析材料
    ShardingSphere简介(一)
    不同层设置不同学习率
    nn.Linear & Tensor.masked_fill
    Minikube本地安装(mac M1版)
    React 消息文本循环展示
    使用Vue + vue-i18n搭建国际化网站
    leetcode刷题[22天小总结] 面试题 01.09. 字符串轮转
    对象序列化浅析
    10 小时 RTX 4090 兑换券免费送!用户调研招募中
  • 原文地址:https://blog.csdn.net/wyll19980812/article/details/127897546