前言
就在今天前同事进入了一家新的企业,然后在微信上跟我吐槽说公司的代码写的太不优雅了,什么中文命名,铺天盖地的IF-ELSE,Service不写接口等…,然后就想要去优化并给他们的员工做做培训,但是不知如何下手。这个问题是普遍存在的,程序员的水平和代码风格各不一样,如果一开始负责人没有做好项目规范和代码审核,n年后代码就变成了屎山,然后又进行重构,增加了很多成本。
这里我总结几条比较重要的点,以我自己的经验把一些好的写代码的方式拿出一起学习,当然我自己也不是什么厉害的人,不喜勿喷吧,毕竟也没收你钱,如果觉得有用还是可以给个三连哦!!!
参数判断
下面是从真实项目中截取出来的代码,代码中做了很多的参数判断和业务逻辑的处理,这段代码的问题有2个
- 一是逻辑代码是不能够写在Controller中的,Controller中最多只能写参数校验,原因是代码凌乱外还无法保证事务,因为事务通常在Service层
- 二是太多的if去判断参数导致代码结构特别凌乱,看着强迫症都犯了
上面代码的优化可以从这2方面入手,一个是代码抽到Service去,二个是参数处理。我们来处理IF判断的问题,对于不满足条件的参数可以通过异常的方式阻止程序往下执行if(...){ throw new RuntimeException(...) ; }
,我个人喜欢使用断言工具来处理参数校验,Spring内置了Assert工具,或者自定义断言工具比如:AssertUtil
/**
* 断言工具
*/
@Slf4j
public class AssertUtil {
public static void isNotEmpty(String obj, String message) {
if(org.apache.commons.lang3.StringUtils.isEmpty(obj)){
throw new RuntimeException(message);
}
}
...
这样使用的时候只需要调用 AssertUtil.isNotEmpty(user.getUserName(),"用户名不可空")
,然后通过异常去阻止代码往下执行。工具可以继续优化,错误信息可以通过枚举封装错误码,异常可以正对场景进行自定义封装:断言工具+错误码+自定义异常,如下
/**
* 断言工具
*/
@Slf4j
public class AssertUtil {
/**
* 不为空
* @param obj : 校验的数据
* @param resultCode :错误码
*/
public static void isNotEmpty(String obj, ResultCode resultCode) {
//Assert.isTrue:hutool工具包的断言工具,ResultCode 是错误码枚举,ServiceException是自定义异常
Assert.isTrue(StringUtils.isNotEmpty(obj),()->new ServiceException(resultCode));
}
...
这样使用的时候只需要AssertUtil.isNotEmpty(user.getUserName(),ResultCode.USER_NAME_ERROR)
即可。改造后的代码写法如下:是不是很优雅
Bean校验
另外一个种方式就是使用Bean Validation 直接对参数对象进行校验,它为JavaBean和方法的验证定义了一组元数据模型和API规范,主要用于后端数据的声明式校验。通过使用Bean Validation,可以确保数据模型(JavaBean)的正确性,避免在应用程序的每一层中重复实现相同的验证逻辑。
使用方法是先导入依赖:spring-boot-starter-validation
,然后在需要校验的Bean的字段上贴注解,比如:@NotNull、@Min、@Max、@Size、@Pattern等
@NotEmpty(message = "姓名不可为空")
private String name;
然后再Bean签名贴注解:@Validated
即可,那么在请求进入Controller前Spring会自动帮我们做Bean的校验,我们就可以不用再代码中单独判断了
@PostMapping("save")
@ApiOperation("保存用户")
public boolean save(@RequestBody @ApiParam("用户") @Validated User user) {
....
return userService.save(user);
}
Bean Validation 还有其他更多的用法需要你自己去探究。
异常处理
异常的统一处理也是很重要的一环,我们来看一下下面这段代码
它的问题是在Controller进行了多次catch操作,这种大量重复的工作让代码显得很冗余。SpringBoot提供了异常统一处理机制。改造如下
/**
* 全局异常处理器
* @author system
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler
{
/**
* 权限码异常
*/
@ExceptionHandler(NotPermissionException.class)
public R<?> handleNotPermissionException(NotPermissionException e, HttpServletRequest request){
String requestURI = request.getRequestURI();
log.error("请求地址'{}',权限码校验失败'{}'", requestURI, e.getMessage());
return R.error(HttpStatus.FORBIDDEN.value(), "没有访问权限,请联系管理员授权");
}
/**
* 角色权限异常
*/
@ExceptionHandler(NotRoleException.class)
public R<?> handleNotRoleException(NotRoleException e, HttpServletRequest request)
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',角色权限校验失败'{}'", requestURI, e.getMessage());
return R.error(HttpStatus.FORBIDDEN.value(), "没有访问权限,请联系管理员授权");
}
/**
* 业务异常
*/
@ExceptionHandler(ServiceException.class)
public R<?> handleServiceException(ServiceException e, HttpServletRequest request){
log.error("请求URL {} 出现ServiceException异常,ErrorCode = {},ErrorMessage={},ResultCode={}", request.getRequestURI(), e.getCode(),e.getMessage(),e.getResultCode());
e.printStackTrace();
ResultCode resultCode = e.getResultCode();
return R.error(resultCode.code(), resultCode.message());
}
/**
* 系统异常
*/
@ExceptionHandler(Exception.class)
public R<?> handleException(Exception e, HttpServletRequest request)
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',发生系统异常.", requestURI, e);
return R.error(ResultCode.ERROR);
}
有了全局异常捕获之后,我们的Controller中就只需要这样写代码了,简直优雅
@ApiOperation("xxxxx")
@GetMapping("/xxxxx/{webType}")
public R<CompanyGameHomeVo> getHomeGames(@RequestHeader("lang") String lang, @PathVariable("webType") Integer webType) {
return R.success(companyGameService.getHomeGames(PlatformUtils.getChannel(), lang, webType));
}
包括在Service层我们也只需要通过Asset断言工具去判断,然后抛出异常即可。所有的异常都会通过统一异常拦截器进行拦截处理。
封装思想
一定不要流水线式(过程式)的写代码,重复的代码或者可能会重用的代码一定要抽取封装,不要重复代码重复写,否则后期很难维护,耗时耗力,这里举例:比如有下面代码
上面代码的问题是
- 出现了魔术值 “1” , “2”: 这种是绝不允许的,谁能看得出1,2代表什么意思?时间久了就连自己都会忘记
- 过程式代码:没有封装和抽取的思想,比如校验账户密码的动作很有可能其他地方也会用到
- 没有分治思想:写代码一定要考虑最小责任原则,就是一个小逻辑就应该使用一个方法进行抽取,这样主线才会清晰,一个方法中不会代码特别多。
优化后的代码如下:
/**
* 账号+密码注册逻辑
* @param signUpReq :注册参数对象
* @return :Token信息
*/
@Override
public Map<String, Object> userSignUp(SignUpReq signUpReq) {
//参数检查
userSignUpCheck(signUpReq);
//参数设置和DTO转换
SignUpDto signUpDto = userSignUpConvert(signUpReq);
//调用用户服务的注册逻辑
LoginUser loginUser = userLoginService.login(signUpDto);
//创建令牌
Map<String, Object> accessMap = tokenService.createToken(loginUser);
// 保存用户的access_token 做单点登录用
Object accessToken = accessMap.get(SecurityConstants.NAME_ACCESS_TOKEN);
//更新用户最后登录的accessToken
ThreadPoolManager.execute(()->userLoginService.updateToken(loginUser.getUserid(), String.valueOf(accessToken)));
return accessMap;
}
/**
* 参数设置
*/
private static SignUpDto userSignUpConvert(SignUpReq signUpReq) {
//设置渠道
signUpReq.setChannel(...);
signUpReq.setLanguage(...);
//对象类型转换
SignUpDto signUpDto = convert(signUpReq, SignUpDto.class);
//获取客户端IP地址
String ipAddr = IpUtils....(ServletUtils.getRequest());
signUpDto.setClientIp(ipAddr);
return signUpDto;
}
/**
* 注册参数检查
*/
private void userSignUpCheck(SignUpReq signUpReq) {
//注册方式检查
Assert.isTrue(LoginTypeEnum.ACCOUNT_PASSWORD.getLoginType().equals(signUpReq.getAppType()),()->new ServiceException(ResultCode.USER_REGISTER_TYPE_ERROR));
//检查密码
ParamCheckUtil.checkPassword(signUpReq.getPassword());
//检查账户名
ParamCheckUtil.checkAccountName(signUpReq.getUserAccount());
//检查手机号
ParamCheckUtil.checkPhone(signUpReq.getAreaCode() , signUpReq.getPhone());
}
/**
* 参数检查工具
*/
public class ParamCheckUtil {
/**
* 检查密码
* @param password
*/
public static void checkPassword(String password) {
//密码空值校验
Assert.notEmpty(password, () -> new ServiceException(ResultCode.USER_PASSWORD_LENGTH_ERROR));
//密码长度校验
Assert.isTrue(RegularValidUtils.isRangeLength(password, PASSWORD_LENTH_MIN, PASSWORD_LENTH_MAX), () -> new ServiceException(ResultCode.USER_PASSWORD_LENGTH_ERROR));
//密码字符,数字校验
Assert.isTrue(RegularValidUtils.isNumberAndChar(password), () -> new ServiceException(ResultCode.USER_PASSWORD_NULL_ERROR));
}
...
和最开始的代码相比,你感觉上面的代码怎么样呢,是不是看起来很舒服,上面代码做了这些改造
- 根据方法中的逻辑进行子方法抽取,把参数检查 , DTO转换分别进行了方法抽取(最小职责)
- 把魔术值去掉,使用常量代替
- 使用断言工具做判断更加优化
- 可能会重用的判断抽取校验工具
封装的方式有很多,简单的可以抽取工具,如果是通用的业务可以使用SpringBoot-Starter抽组件,比如:文件上传,短信发送等都可以封装成Starter,具体封装方式可以看这篇文章<封装短信Starter> ,还不理解Starter封装的赶紧去学习了,这个技能已经是程序员必备的了。
函数式编程
Java8提供了Lamda语法为我们编程提供了很大的方便,在处理集合的时候使用Lamda可以让代码变得优雅而高级,所以 一定要用。在某些业务场景中我们可以模仿Java自己来实现函数式编程。比如有下面的案例:
RLock rLock = RedissonUtil.getLock("key");
try{
rLock.lock();
...
}catch (Exception e){
log.error("业务执行失败 {}",e);
throw new ServiceException("...");
}finally {
if(rLock.isHeldByCurrentThread()){
rLock.unlock();
}
}
每次都try-finally
感觉很烦,但是你又不得不写,所以这里可以使用函数式+lamda去优化,改造如下:
/**
* 在分布式锁中执行业务
* @param supplier:业务逻辑
* @param lockKey :锁的Key
* @return : 业务逻辑返回的对象
*/
public static T executeInLock(Supplier<T> supplier, String lockKey){
try {
//加锁
RedissonUtil.getLock(lockKey);
//执行业务逻辑
return supplier.get();
}catch (Exception e){
log.info("在分布式锁中执行业务逻辑失败 - {}",e.getMessage());
throw new ServiceException("在分布式锁中执行业务逻辑失败");
}finally {
unlock(lockKey);
}
}
这里抽了一个方法,把分布式加锁和释放锁的代码进行了封装,通过Supplier来接受业务逻辑的回调,当RedissonUtil.getLock(lockKey)
加锁成功,再执行return supplier.get();
业务逻辑,finally
中去释放锁,那么使用方式如下: 简直优雅
RedissonUtil.executeInLock(()->{ ...业务逻辑... },"key");
很多的业务逻辑都可以使用上面的方式去抽取,比如:操作缓存,操作MQ等。
设计模式
设计模式对代码结构的优化是很大的,这里我找几个真实的案例来举例。首先有这样一种场景,当用户注册成功之后要去触发注册积分送活动,那么一般的做法就是在注册逻辑中,直接调用积分接口进行积分赠送。如下
public void register(...){
//一堆注册的逻辑
userService.register(...);
//调用积分接口赠送
pointsService.gift(...);
}
这个代码乍一看没什么问题,如果以后我们注册后除了要送积分,还要检查灰名单,推送站内信等等,那是不是又要在这个代码后面加一堆代码?这个就是代码侵入性很强,长期以来就变成了屎山,这种情况可以使用监听者模式进行优化。下面我使用Spring自带的事件机制来优化。
监听者模式
第一步:我们需要定义一个事件对象,注册成功那就叫RegisterSuccessEvent
,去继承 ApplicationEvent 即可,如下
/**
* 注册成功
*/
@Data
@Builder
public class RegisterSuccessEvent extends ApplicationEvent {
//用户Id
private Long userId;
//注册渠道
private Integer channel;
//邀请链接
private String invitedUrl;
//登录方式
private String appType;
//账户
private String phoneOrMail;
public RegisterSuccessEvent(Object source) {
super(source);
}
public RegisterSuccessEvent( Long userId,Integer channel,String invitedUrl, String appType,String phoneOrMail) {
super("");
this.userId = userId;
this.channel = channel;
this.invitedUrl = invitedUrl;
this.appType = appType;
this.phoneOrMail = phoneOrMail ;
}
}
接着需要去编写一个监听者,需要实现ApplicationListener<RegisterSuccessEvent>
并指定监听的事件对象,复写 onApplicationEvent
方法,该方法是在对应的事件发布时触发。我们就可以在该方法中处理积分赠送,灰名单检查,站内信推送等逻辑,而且为了提速还可以使用线程池做成异步(考虑业务是否允许异步)。
/**
* 注册成功后事件监听器
*/
@Component
@Slf4j
public class RegisterSuccessListener implements ApplicationListener<RegisterSuccessEvent> {
@Resource
private IMemberFissionRelationService memberFissionRelationService;
@Resource
private IUserOptBindAccountService userOptBindAccountService;
/**
当 RegisterSuccessEvent 事件发布就会执行 onApplicationEvent 方法
**/
@Override
public void onApplicationEvent(RegisterSuccessEvent event) {
//通过event可以拿到事件对象中传入的值
//业务逻辑处理 :积分赠送,站内信等
ThreadPoolManager.execute(()-> afterRegister(event),"注册成功后置操作");
}
}
还有一个动作就是在注册逻辑完成后发布事件了,可以通过实现 ApplicationEventPublisher
,或者直接注入ApplicationContext
容器工厂,通过applicationContext .publishEvent
方法来发布事件,我这里抽取了一个工具如下:
/**
* 事件广播器
*/
@Component
public class EventPublishUtil implements ApplicationEventPublisherAware {
private static ApplicationEventPublisher applicationEventPublisher;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
EventPublishUtil.applicationEventPublisher = applicationEventPublisher;
}
/**
* 广播一个事件
*/
public static void publish(ApplicationEvent event){
applicationEventPublisher.publishEvent(event);
}
}
publish方法是静态的,那么就只需要在注册逻辑中执行下面代码来广播事件即可
//注册成功事件:代理裂变账户,注册送活动,绑定邮箱活动
EventPublishUtil.publish(new RegisterSuccessEvent(..参数..);
策略模式
策略模式适用于一个业务有不同的实现分支的情况,使用策略可以减少IF-ELSE逻辑,比如支付业务,支付宝支付和微信支付是两种不同的支付方式,那么我们就可以针对于两种方式创建两种支付策略,然后根据用户选择的支付方式不同自动映射到对应的策略中进行业务处理。我这里使用项目真实的案例来讲解,下面代码是没有使用策略模式的代码:
业务背景是有四种返佣模式要处理,那么代码中通过了四个IF去判断,然后每个IF中又有重复的代码逻辑,伴随着各种嵌套,冗余代码,整个方法代码量在200行以上,而且没有注释。天哪…
这个代码使用了策略模式+模板模式
进行优化,因为四种返佣模式的业务流程是一样的,但是具体的细节又不同,所以最好采用策略模式+模板模式
当然这两个模式也可以分开使用。具体步骤如下;
第一步:定义一个策略接口,需要至少两个方法
- getStrategyType:用来返回策略的名字,注意:我这里抽了一个枚举类,一个枚举实例对应了一个策略
- handler :业务逻辑入口方法
/**
* xxx - 策略
*/
public interface AgentRebateStrategy {
/**
* xxx枚举类型
* @return
*/
XXTypeEnum getStrategyType();
/**
* 处理xxx
*
* @param xxx:xxx
* @param xxx
*/
void handler(...参数列表...);
}
第二步:创建一个模板类,实现策略接口,模板类的作用就是主业务流程是在模板中完成的
/**
* XXX - 策略
*/
@Component
@Slf4j
public abstract class BaseXXXStrategy implements XXXStrategy{
@Override
public void handler(...参数列表...){
...主要的业务流程...
//1.步骤1
//2.步骤2
//3.步骤3
}
public void 步骤1(){ ...逻辑... }
//步骤2有每个子类是有差异的,就使用抽象方法,让子类去复写
protected abstract void 步骤2();
public void 步骤3(){ ...逻辑... }
}
上面是模板模式的用法,主业务流程在模板类中,相同的逻辑在父类,每个分支的差异通过抽象方法让子策略去实现。
第三步:创建各自分支的策略类,上面的案例是四种模式,那么就应该四个策略,getStrategyType方法要返回对应的枚举实例。
/**
* xxxx事件策略
* xxx计算
*/
@Component
public class XxxxxStrategy extends BaseXxxxxStrategy {
@Override
public XXXXEnum getStrategyType() {
return XXXXEnum .XXXX;
}
@Override
protected void 步骤2(){
...子类差异...子类实现
}
}
/**
* xxxx事件策略
* xxx计算
*/
@Component
public class OOOOStrategy extends BaseXxxxxStrategy {
@Override
public OOOOEnum getStrategyType() {
return OOOOEnum .OOOO;
}
@Override
protected void 步骤2(){
...子类差异...子类实现
}
}
工厂模式
第四步:如何根据不同的模式获取到对应的策略呢?要考虑使用方便,使用工厂模式
把策略管理起来,方便获取
/**
* 策略工厂
*/
@Component
public class XxxxStrategyFactory {
//通过一个容器把策略注入进来
public static final ConcurrentHashMap<String, XXXStrategy> STRATEGY_MAP_CACHE = new ConcurrentHashMap<>();
//构造器注入
public XxxxStrategyFactory(Map<String, XXXStrategy> strategyMap){
STRATEGY_MAP_CACHE.clear();
strategyMap.forEach((k,v)-> STRATEGY_MAP_CACHE.put(v.getStrategyType().getRebateType(),v));
}
/**
* 根据枚举-获取策略
*/
public static XXXStrategyStrategy getInstance(XXXStrategy typeEnum){
return STRATEGY_MAP_CACHE.get(typeEnum.getRebateType());
}
}
getInstance方法是static静态的,那么在使用的时候只需要通过 XxxxStrategyFactory#getInstance,传入枚举获取策略即可
XxxxStrategyFactory.getInstance(XxxxTypeEnum.RECHARGE).handler(...参数...);
如果没发在代码中指定枚举,比如类似于支付场景,需要根据用户选择的支付方式来选择枚举,那么只需要把支付方式映射成对应的枚举值即可。这样抽取之后,不再存在大量的重复代码,而且扩展性而言很强,以后有新的模式增加我们只需要增加策略类即可。爽歪歪…
策略模式大家很有必须去系统学习一下,确实能让代码结构好很多,当然不能为了设计模式而设计模式,有的时候赶项目一味想着最优代码会拖慢项目进度,所以需要自己去平衡。
代码规范
我觉得屎山代码的形成其实并仅仅是程序员的水平不好,很重要的因素是因为一开始没有定义好规范和leader没做好代码审核,所以一个项目开始前一定要把方方面面的规范定义好。代码风格,代码注释,异常怎么处理,错误码怎么封装,等等。如果团队没有制定规范可以使用阿里的《Java开发手册(黄山版)》
文章就写到这里吧,如果对你有帮助请给个好评哦!!!