关键词:[后端] [编程习惯] [代码风格]
原文链接:
这里涉及到的主要是 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) {}
写个对象出来并给它赋予相应的意义,使用对象传参清晰明了,也方便后期扩展和维护;
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) {}
为什么要这么写?比如你这方法是用来查询的,万一以后加个查询条件是不是要修改方法?每次加每次都要改方法参数列表。封装个对象,以后无论加多少查询条件都只需要在对象里面加字段就行。而且关键是看起来代码也很舒服啊!
如果你看过“屎山”你就会有深刻的感触,这特么一个方法能写几千行代码,还无任何规则可言…往往负责的人会说,这个业务太复杂,没有办法改善,实际上这都是懒的借口。不管业务再复杂,我们都能够用合理的设计、封装去提升代码可读性。下面贴两段高级开发(作者)写的代码
@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;
}
@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);
}
这两段代码里面其实业务很复杂,内部估计保守干了五万件事情,但是不同水平的人写出来就完全不同,不得不赞一下这个注释,这个业务的拆分和方法的封装。一个大业务里面有多个小业务,不同的业务调用不同的 Service
方法即可,后续接手的人即使没有流程图等相关文档也能快速理解这里的业务,而很多初级开发写出来的业务方法就是上一行代码是 A 业务的,下一行代码是 B 业务的,在下面一行代码又是 A 业务的,业务调用之间还嵌套这一堆单元逻辑,显得非常混乱,代码还多;
ps: 把独立功能的代码抽象成方法,避免一个方法内镶嵌若干个功能/业务逻辑,很好的习惯
很多人喜欢写这样的代码去判断集合
if (list == null || list.size() == 0) {
return null;
}
当然你硬要这么写也没什么问题…但是不觉得难受么,现在框架中随便一个 jar 包都有集合工具类,比如 org.springframework.util.CollectionUtils
等等;以后请这么写:
if (CollectionUtils.isEmpty(list) || CollectionUtils.isNotEmpty(list)) {
return null;
}
ps: 两者功能应该一样,我自己一般用的前者,但看公司代码和一些项目都是倾向于后者
当你的业务方法返回值是集合类型时,请不要返回 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;
}
这段代码的可读性很差,这 if
里面谁知道干啥的?我们用面向对象的思想去给 loanApp
这个对象里面封装个方法不就行了么?
public void method(LoanAppEntity loan, long operatorId) {
if (!loan.finished()) {
//...
return;
}
LoanApp
这个类中封装一个方法,简单来说就是这个逻辑判断细节不该出现在业务方法中;
/**
* 贷款单是否完成
*/
public boolean finished() {
return LoanAppEntity.LoanAppStatus.OVERDUE != this.getStatus()
&& LoanAppEntity.LoanAppStatus.CURRENT != this.getStatus()
&& LoanAppEntity.LoanAppStatus.GRACE_PERIOD != this.getStatus();
}
推荐一款 IDEA 插件 CodeMetrics
,它能显示出方法的复杂度,它是对方法中的表达式进行计算,布尔表达式,if/else
分支,循环等。
点击可以查看哪些代码增加了方法的复杂度,可以适当进行参考,毕竟我们通常写的是业务代码,在保证正常工作的前提下最重要的是要让别人能够快速看懂。当你的方法复杂度超过 10 就要考虑是否可以优化了;
列举一下 @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
来接配置属性;
当然这是一个有争议的问题,我的习惯是使用它省去 getter、setter、toString
等等;
我们一定要遵循从 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 包直接拿来用的可以省略,当然这也是个积累的过程;我在视频上边学边敲项目时就发现很多工具类写的很巧妙
搞不懂为什么那么多人喜欢把接口的返回值用 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);
}
这样就相当于
coupon-api
抛出异常coupon-api
拦截异常,修改 Response.code
response.code
如果是 FAIELD
再把异常抛出去…你直接在服务提供方抛异常不就行了么。。。而且这样一包装 HTTP
请求永远都是 200
,没法做重试和监控。当然这个问题涉及到接口响应体该如何设计,目前网上大多是三种流派
200
HTTP
真实状态不接受反驳,我推荐使用 HTTP
标准状态。特定场景包括参数校验失败等一律使用 400 给前端弹 toast
;
ps:作者说的有道理啊,对接口返回值进行封装虽然一定程度上提高了通用性,简洁性,emm 但确实存在上述问题,看怎么取舍吧,以后写出了可能有自己的见解 // 借评论区一个大佬:个人建议各自服务自行抛异常,微服务通过网关进行分发请求,在网关这一层拦截响应信息,给它包一层code\data\message再返回到前端,而服务间调用,直接报异常得啦,两方都兼容
当然,有些观点是”网关要轻量“这类的,当我没说
要记住 RESTful
和 jsonrpc
是两回事;就是因为以下原因固定 200 并且使用 code、message、data
来返回的
200
的错误一定不是来自程序,而是来自可能是 nginx 等,说明压根没进入到 Controller 里,方便排查;code
来决定是否要捕捉、抛出、忽略这个错误,举个例子比如我要注册并登陆一个用户,下层服务返回 USER_EXISTED_ALREADY
,那我就继续走登陆接口了。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){
}
要么就别写,要么就在后面加上描述…写这样的注释被 IDEA 报一堆警告看着不难受?
什么 VO、BO、DTO、PO
我倒真是觉得没有那么大必要分那么详细,至少我们在和前端交互的时候类名要起的合适,不要直接用映射数据库的类返回给前端,这会返回很多不必要的信息,如果有敏感信息还要特殊处理;
推荐的做法是接受前端请求的类定义为 XxxRequest
,响应的定义为 XxxResponse
。以订单为例:接受保存更新订单信息的实体类可以定义为 OrderRequest
,订单查询响应定义为 OrderResponse
,订单的查询条件请求定义为 OrderQueryRequest
。
跨服务查询时,如果有批量数据查询的场景,直接写一个批量的 Feign
查询接口,不要像下面这样
list.foreach(id -> {
UserResponse user = userClient.findById(id);
});
因为每一次 OpenFeign
的请求都是一个 Http
请求、一个数据库 IO
操作,还要经过各种框架内的拦截器、解码器等等,全都是损耗。
直接定义一个批量查询接口
@PostMapping("/user/batch-info")
List<UserResponse> batchInfo(@RequestBody List<Long> userIds);
这就结束了吗?并没有,如果你遇到这种 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;
}
我是很反感看到 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;
}
如果到了元宵节的时候,运营小姐姐突然又有想法,红包皮肤换成灯笼相关的,这时候,是不是要去修改代码了,重新发布了?
从一开始接口设计时,可以实现一张红包皮肤的配置表,将红包皮肤做成配置化呢?更换红包皮肤,只需修改一下表数据就好了。当然,还有一些场景适合一些配置化的参数:一个分页多少数量控制、某个抢红包多久时间过期这些,都可以搞到参数配置化表里面。这也是扩展性思想的一种体现。
我觉得这是一个程序员应该具备的素养…反正我是喜欢用新的技术组件,因为新的技术组件出现必定是解决旧技术组件的不足,而且作为一个技术人员我们应该要与时俱进~~ 当然前提是要做好准备工作,不能无脑升级。举个最简单的例子,Java 17 都出来了,新项目现在还有人用 Date
来处理日期时间…
节的时候,运营小姐姐突然又有想法,红包皮肤换成灯笼相关的,这时候,是不是要去修改代码了,重新发布了?
从一开始接口设计时,可以实现一张红包皮肤的配置表,将红包皮肤做成配置化呢?更换红包皮肤,只需修改一下表数据就好了。当然,还有一些场景适合一些配置化的参数:一个分页多少数量控制、某个抢红包多久时间过期这些,都可以搞到参数配置化表里面。这也是扩展性思想的一种体现。
我觉得这是一个程序员应该具备的素养…反正我是喜欢用新的技术组件,因为新的技术组件出现必定是解决旧技术组件的不足,而且作为一个技术人员我们应该要与时俱进~~ 当然前提是要做好准备工作,不能无脑升级。举个最简单的例子,Java 17 都出来了,新项目现在还有人用 Date
来处理日期时间…
养成一些好的开发习惯肯定是需要通过不断的实践来沉淀下来的,但若事先有这个意识,在实践的过程中就会多留心,多注重代码的规范性、可读性、可扩展性等等;实习的时候便是对两个 Demo 进行重构,对代码的质量问题深有感触;