查看原文
其他

你见过哪些优雅的 Java 代码优化技巧?

The following article is from JavaGuide Author Guide

今天分享一些实用的有助于提高代码质量的建议,内容较多,建议收藏!

内容概览:

提取通用处理逻辑

注解、反射和动态代理是 Java 语言中的利器,使用得当的话,可以大大简化代码编写,并提高代码的可读性、可维护性和可扩展性。

大家可以利用 注解 + 反射注解+动态代理 来提取类、类属性或者类方法通用处理逻辑,进而避免重复的代码。虽然可能会带来一些性能损耗,但与其带来的好处相比还是非常值得的。

通过 注解 + 反射 这种方式,可以在运行时动态地获取类的信息、属性和方法,并对它们进行通用处理。比如说在通过 Spring Boot 中通过注解验证接口输入的数据就是这个思想的运用,通过注解来标记需要验证的参数,然后通过反射获取属性的值,并进行相应的验证。

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PersonRequest {

    @NotNull(message = "classId 不能为空")
    private String classId;

    @Size(max = 33)
    @NotNull(message = "name 不能为空")
    private String name;

    @Pattern(regexp = "(^Man$|^Woman$|^UGM$)", message = "sex 值不在可选范围")
    @NotNull(message = "sex 不能为空")
    private String sex;

    @Region
    private String region;

    @PhoneNumber(message = "phoneNumber 格式不正确")
    @NotNull(message = "phoneNumber 不能为空")
    private String phoneNumber;

}

通过 注解 + 动态代理 这种,可以在运行时生成代理对象,从而实现通用处理逻辑。比如说 Spring 框架中,AOP 模块正是利用了这种思想,通过在目标类或方法上添加注解,动态生成代理类,并在代理类中加入相应的通用处理逻辑,比如事务管理、日志记录、缓存处理等。同时,Spring 也提供了两种代理实现方式,即基于 JDK 动态代理和基于 CGLIB 动态代理(JDK 动态代理底层基于反射,CGLIB 动态代理底层基于字节码生成),用户可以根据具体需求选择不同的实现方式。

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

避免炫技式单行代码

代码没必要一味追求“短”,是否易于阅读和维护也非常重要。像炫技式的单行代码就非常难以理解、排查和修改起来都比较麻烦且耗时。

反例:

if (response.getData() != null && CollectionUtils.isNotEmpty(response.getData().getShoppingCartDTOList())) {
      cartList = response.getData().getShoppingCartDTOList().stream().map(CartResponseBuilderV2::buildCartList).collect(Collectors.toList());
}

正例:

T data = response.getData();
if (data != null && CollectionUtils.isNotEmpty(data.getShoppingCartDTOList())) {
  cartList = StreamUtil.map(data.getShoppingCartDTOList(), CartResponseBuilderV2::buildCartList);
}

基于接口编程提高扩展性

基于接口而非实现编程是一种常用的编程范式,也是一种非常好的编程习惯,一定要牢记于心!

基于接口编程可以让代码更加灵活、更易扩展和维护,因为接口可以为不同的实现提供相同的方法签名(方法的名称、参数类型和顺序以及返回值类型)和契约(接口中定义的方法的行为和约束,即方法应该完成的功能和要求),这使得实现类可以相互替换,而不必改变代码的其它部分。另外,基于接口编程还可以帮助避免过度依赖具体实现类,降低代码的耦合性,提高代码的可测试性和可重用性。

就比如说在编写短信服务、邮箱服务、存储服务等常用第三方服务的代码时,可以先先定义一个接口,接口中抽象出具体的方法,然后实现类再去实现这个接口。

public interface SmsSender {
    SmsResult send(String phone, String content);
    SmsResult sendWithTemplate(String phone, String templateId, String[] params);
}

/*
 * 阿里云短信服务
 */

public class AliyunSmsSender implements SmsSender {
  ...
}

/*
 * 腾讯云短信服务
 */

public class TencentSmsSender implements SmsSender {
  ...
}

拿短信服务这个例子来说,如果需要新增一个百度云短信服务,直接实现 SmsSender 即可。如果想要替换项目中使用的短信服务也比较简单,修改的代码非常少,甚至说可以直接通过修改配置无需改动代码就能轻松更改短信服务。

操作数据库、缓存、中间件的代码单独抽取一个类

尽量不要将操作数据库、缓存、中间件的代码和业务处理代码混合在一起,而是要单独抽取一个类或者封装一个接口,这样代码更清晰易懂,更容易维护,一些通用逻辑也方便统一维护。

数据库:

public interface UserRepository extends JpaRepository<User, Long> {
  ...
}

缓存:

@Repository
public class UserRedis {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public User save(User user) {
    }
}

消息队列:

// 取消订单消息生产者
public class CancelOrderProducer{
 ...
}
// 取消订单消息消费者
public class CancelOrderConsumer{
 ...
}

不要把业务代码放在 Controller 中

这个是老生常谈了,最基本的规范。一定不要把业务代码应该放在 Controller 中,业务代码就是要交给 Service 处理。

业务代码放到 Service 的好处

  1. 避免 Controller 的代码过于臃肿,进而难以维护和扩展。
  2. 抽象业务处理逻辑,方便复用比如给用户增加积分的操作可能会有其他的 Service 用到。
  3. 避免一些小问题比如 Controller 层通过 @Value注入值会失败。
  4. 更好的进行单元测试。如果将业务代码放在 Controller 中,会增加测试难度和不确定性。

错误案例:

@RestController
public class UserController {
    @Autowired
    private UserRepository userRepository;

    @GetMapping("/users/{id}")
    public Result<UserVO> getUser(@RequestParam(name = "userId", required = true) Long userId) {
        User user = repository.findById(id)
                  .orElseThrow(() -> new UserNotFoundException(id));
        UserVO userVO = new UserVO();
        BeanUtils.copyProperties(user, userVO);//演示使用
        // 可能还有其他业务操作
        ...
        return Result.success(userVO);
    }
    ...
}

静态函数放入工具类

静态函数/方法不属于某个特定的对象,而是属于这个类。调用静态函数无需创建对象,直接通过类名即可调用。

静态函数最适合放在工具类中定义,比如文件操作、格式转换、网络请求等。

/**
 * 文件工具类
 */

public class FileUtil extends PathUtil {

    /**
     * 文件是否为空<br>
     * 目录:里面没有文件时为空 文件:文件大小为0时为空
     *
     * @param file 文件
     * @return 是否为空,当提供非目录时,返回false
     */

    public static boolean isEmpty(File file) {
        // 文件为空或者文件不存在直接返回 true
        if (null == file || false == file.exists()) {
            return true;
        }
        if (file.isDirectory()) {
            // 文件是文件夹的情况
            String[] subFiles = file.list();
            return ArrayUtil.isEmpty(subFiles);
        } else if (file.isFile()) {
            // 文件不是文件夹的情况
            return file.length() <= 0;
        }

        return false;
    }
}

善用设计模式

实际开发项目的过程中,应该尽量多地使用现有的设计模式来优化代码。有 9 种在源码中非常常见的设计模式:

  1. 工厂模式(Factory Pattern) :通过定义一个工厂方法来创建对象,从而将对象的创建和使用解耦,实现了“开闭原则”。
  2. 建造者模式(Builder Pattern) :通过链式调用和流式接口的方式,创建一个复杂对象,而不需要直接调用它的构造函数。
  3. 单例模式(Singleton Pattern) :确保一个类只有一个实例,并且提供一个全局的访问点,比如常见的 Spring Bean 单例模式。
  4. 原型模式(Prototype Pattern) :通过复制现有的对象来创建新的对象,从而避免了对象的创建成本和复杂度。
  5. 适配器模式(Adapter Pattern) :将一个类的接口转换成客户端所期望的接口,从而解决了接口不兼容的问题。
  6. 桥接模式(Bridge Pattern) :将抽象部分与实现部分分离开来,从而使它们可以独立变化。
  7. 装饰器模式(Decorator Pattern) :动态地给一个对象添加一些额外的职责,比如 Java 中的 IO 流处理。
  8. 代理模式(Proxy Pattern) :为其他对象提供一种代理以控制对这个对象的访问,比如常见的 Spring AOP 代理模式。
  9. 观察者模式(Observer Pattern) :定义了对象之间一种一对多的依赖关系,从而当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知并自动更新。

策略模式替换条件逻辑

策略模式是一种常见的优化条件逻辑的方法。当代码中有一个包含大量条件逻辑(即 if 语句)的方法时,你应该考虑使用策略模式对其进行优化,这样代码更加清晰,同时也更容易维护。

假设有这样一段代码:

public class IfElseDemo {

    public double calculateInsurance(double income) {
        if (income <= 10000) {
            return income*0.365;
        } else if (income <= 30000) {
            return (income-10000)*0.2+35600;
        } else if (income <= 60000) {
            return (income-30000)*0.1+76500;
        } else {
            return (income-60000)*0.02+105600;
        }

    }
}

下面是使用策略+工厂模式重构后的代码:

首先定义一个接口 InsuranceCalculator,其中包含一个方法 calculate(double income),用于计算保险费用。

public interface InsuranceCalculator {
    double calculate(double income);
}

然后,分别创建四个类来实现这个接口,每个类代表一个保险费用计算方式。

public class FirstLevelCalculator implements InsuranceCalculator {
    public double calculate(double income) {
        return income * 0.365;
    }
}

public class SecondLevelCalculator implements InsuranceCalculator {
    public double calculate(double income) {
        return (income - 10000) * 0.2 + 35600;
    }
}

public class ThirdLevelCalculator implements InsuranceCalculator {
    public double calculate(double income) {
        return (income - 30000) * 0.1 + 76500;
    }
}

public class FourthLevelCalculator implements InsuranceCalculator {
    public double calculate(double income) {
        return (income - 60000) * 0.02 + 105600;
    }
}

最后,可以为每个策略类添加一个唯一的标识符,例如字符串类型的 name 属性。然后,在工厂类中创建一个 Map 来存储策略对象和它们的标识符之间的映射关系(也可以用 switch 来维护映射关系)。

import java.util.HashMap;
import java.util.Map;

public class InsuranceCalculatorFactory {
    private static final Map<String, InsuranceCalculator> CALCULATOR_MAP = new HashMap<>();

    static {
        CALCULATOR_MAP.put("first"new FirstLevelCalculator());
        CALCULATOR_MAP.put("second"new SecondLevelCalculator());
        CALCULATOR_MAP.put("third"new ThirdLevelCalculator());
        CALCULATOR_MAP.put("fourth"new FourthLevelCalculator());
    }

    public static InsuranceCalculator getCalculator(String name) {
        return CALCULATOR_MAP.get(name);
    }
}

这样,就可以通过 InsuranceCalculatorFactory 类手动获取相应的策略对象了。

double income = 40000;
// 获取第二级保险费用计算器
InsuranceCalculator calculator = InsuranceCalculatorFactory.getCalculator("second");
double insurance = calculator.calculate(income);
System.out.println("保险费用为:" + insurance);

这种方式允许在运行时根据需要选择不同的策略,而无需在代码中硬编码条件语句。

相关阅读:Replace Conditional Logic with Strategy Pattern - IDEA[3]

除了策略模式之外,Map+函数式接口也能实现类似的效果,代码一般还要更简洁一些。

下面是使用 Map+函数式接口重构后的代码:

首先,在 InsuranceCalculatorFactory 类中,将 getCalculator 方法的返回类型从 InsuranceCalculator 改为 Function<Double, Double>,表示该方法返回一个将 double 类型的 income 映射到 double 类型的 insurance 的函数。

public class InsuranceCalculatorFactory {
    private static final Map<String, Function<Double, Double>> CALCULATOR_MAP = new HashMap<>();

    static {
        CALCULATOR_MAP.put("first", income -> income * 0.365);
        CALCULATOR_MAP.put("second", income -> (income - 10000) * 0.2 + 35600);
        CALCULATOR_MAP.put("third", income -> (income - 30000) * 0.1 + 76500);
        CALCULATOR_MAP.put("fourth", income -> (income - 60000) * 0.02 + 105600);
    }

    public static Function<Double, Double> getCalculator(String name) {
        return CALCULATOR_MAP.get(name);
    }
}

然后,在调用工厂方法时,可以使用 Lambda 表达式或方法引用来代替实现策略接口的类。

double income = 40000;
Function<Double, Double> calculator = InsuranceCalculatorFactory.getCalculator("second");
double insurance = calculator.apply(income);
System.out.println("保险费用为:" + insurance);

复杂对象使用建造者模式

复杂对象的创建可以使用建造者模式优化。

使用 Caffeine 创建本地缓存的代码示例:

Caffeine.newBuilder()
                // 设置最后一次写入或访问后经过固定时间过期
                .expireAfterWrite(60, TimeUnit.DAYS)
                // 初始的缓存空间大小
                .initialCapacity(100)
                // 缓存的最大条数
                .maximumSize(500)
                .build();

链式处理优先使用责任链模式

责任链模式在实际开发中还是挺实用的,像 MyBatis、Netty、OKHttp3、SpringMVC、Sentinel 等知名框架都大量使用了责任链模式。

如果一个请求需要进过多个步骤处理的话,可以考虑使用责任链模式。

责任链模式下,存在多个处理者,这些处理者之间有顺序关系,一个请求被依次传递给每个处理者(对应的是一个对象)进行处理。处理者可以选择自己感兴趣的请求进行处理,对于不感兴趣的请求,转发给下一个处理者即可。如果满足了某个条件,也可以在某个处理者处理完之后直接停下来。

责任链模式下,如果需要增加新的处理者非常容易,符合开闭原则。

Netty 中的 ChannelPipeline 使用责任链模式对数据进行处理。我们可以在 ChannelPipeline 上通过 addLast() 方法添加一个或者多个ChannelHandler (一个数据或者事件可能会被多个 Handler 处理) 。当一个 ChannelHandler 处理完之后就将数据交给下一个 ChannelHandler

ChannelPipeline pipeline = ch.pipeline()
      // 添加一个用于对 HTTP 请求和响应报文进行编解码的 ChannelHandler
      .addLast(HTTP_CLIENT_CODEC, new HttpClientCodec())
       // 添加一个对 gzip 或者 deflate 格式的编码进行解码的 ChannelHandler
      .addLast(INFLATER_HANDLER, new HttpContentDecompressor())
       // 添加一个用于处理分块传输编码的 ChannelHandler
      .addLast(CHUNKED_WRITER_HANDLER, new ChunkedWriteHandler())
       // 添加一个处理 HTTP 请求并响应的 ChannelHandler
      .addLast(AHC_HTTP_HANDLER, new HttpHandler);

Tomcat 中的请求处理是通过一系列过滤器(Filter)来完成的,这同样是责任连模式的运用。每个过滤器都可以对请求进行处理,并将请求传递给下一个过滤器,直到最后一个过滤器将请求转发到相应的 Servlet 或 JSP 页面。

public class CompressionFilter implements Filter {
    // ...
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException 
{
        // 检查是否支持压缩
        if (isCompressable(request, response)) {
            // 创建一个自定义的响应对象,用于在压缩数据时获取底层输出流
            CompressionServletResponseWrapper wrappedResponse = new CompressionServletResponseWrapper(
                    (HttpServletResponse) response);
            try {
                // 将请求转发给下一个过滤器或目标 Servlet/JSP 页面
                chain.doFilter(request, wrappedResponse);
                // 压缩数据并写入原始响应对象的输出流
                wrappedResponse.finishResponse();
            } catch (IOException e) {
                log.warn(sm.getString("compressionFilter.compressFailed"), e); //$NON-NLS-1$
                handleIOException(e, wrappedResponse);
            }
        } else {
            // 不支持压缩,直接将请求转发给下一个过滤器或目标 Servlet/JSP 页面
            chain.doFilter(request, response);
        }
    }
    // ...
}

使用观察者模式解耦

观察者模式也是解耦的利器。当对象之间存在一对多关系,可以使用观察者模式,让多个观察者对象同时监听某一个主题对象。当主题对象状态发生变化时,会通知所有观察者,观察者收到通知之后可以根据通知的内容去针对性地做一些事情。

Spring 事件就是基于观察者模式实现的。

1、定义一个事件。

public class CustomSpringEvent extends ApplicationEvent {
    private String message;

    public CustomSpringEvent(Object source, String message) {
        super(source);
        this.message = message;
    }
    public String getMessage() {
        return message;
    }
}

2、创建事件发布者发布事件。

@Component
public class CustomSpringEventPublisher {
    @Autowired
    private ApplicationEventPublisher applicationEventPublisher;

    public void publishCustomEvent(final String message) {
        System.out.println("Publishing custom event. ");
        CustomSpringEvent customSpringEvent = new CustomSpringEvent(this, message);
        applicationEventPublisher.publishEvent(customSpringEvent);
    }
}

3、创建监听器监听并处理事件(支持异步处理事件的方式,需要配置线程池)。

@Component
public class CustomSpringEventListener implements ApplicationListener<CustomSpringEvent> {
    @Override
    public void onApplicationEvent(CustomSpringEvent event) {
        System.out.println("Received spring custom event - " + event.getMessage());
    }
}

抽象父类利用模板方法模式定义流程

多个并行的类实现相似的代码逻辑。我们可以考虑提取相同逻辑在父类中实现,差异逻辑通过抽象方法留给子类实现。

对于相同的流程和逻辑,我们还可以借鉴模板方法模式将其固定成模板,保留差异的同时尽可能避免代码重复。

下面是一个利用模板方法模式定义流程的示例代码:

public abstract class AbstractDataImporter {
    private final String filePath;

    public AbstractDataImporter(String filePath) {
        this.filePath = filePath;
    }

    public void importData() throws IOException {
        List<String> data = readDataFromFile();
        validateData(data);
        saveDataToDatabase(data);
    }

    protected abstract List<String> readDataFromFile() throws IOException;

    protected void validateData(List<String> data) {
        // 若子类没有实现该方法,则不进行数据校验
    }

    protected abstract void saveDataToDatabase(List<String> data);

    protected String getFilePath() {
        return filePath;
    }
}

在上面的代码中,AbstractDataImporter 是一个抽象类。该类提供了一个 importData() 方法,它定义了导入数据的整个流程。具体而言,该方法首先从文件中读取原始数据,然后对数据进行校验,最后将数据保存到数据库中。

其中,readDataFromFile()saveDataToDatabase() 方法是抽象的,由子类来实现。validateData() 方法是一个默认实现,可以通过覆盖来定制校验逻辑。getFilePath() 方法用于获取待导入数据的文件路径。

子类继承 AbstractDataImporter 后,需要实现 readDataFromFile()saveDataToDatabase() 方法,并覆盖 validateData() 方法(可选)。例如,下面是一个具体的子类 CsvDataImporter 的实现:

public class CsvDataImporter extends AbstractDataImporter {
    private final char delimiter;

    public CsvDataImporter(String filePath, char delimiter) {
        super(filePath);
        this.delimiter = delimiter;
    }

    @Override
    protected List<String> readDataFromFile() throws IOException {
        List<String> data = new ArrayList<>();
        try (BufferedReader reader = new BufferedReader(new FileReader(getFilePath()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                data.add(line);
            }
        }
        return data;
    }

    @Override
    protected void validateData(List<String> data) {
        // 对 CSV 格式的数据进行校验,例如检查是否每行都有相同数量的字段等
    }

    @Override
    protected void saveDataToDatabase(List<String> data) {
        // 将 CSV 格式的数据保存到数据库中,例如将每行解析为一个对象,然后使用 JPA 保存到数据库中
    }
}

在上面的代码中,CsvDataImporter 继承了 AbstractDataImporter 类,并实现了 readDataFromFile()saveDataToDatabase() 方法。它还覆盖了 validateData() 方法,以支持对 CSV 格式的数据进行校验。

通过以上实现,我们可以通过继承抽象父类并实现其中的抽象方法,来定义自己的数据导入流程。另外,由于抽象父类已经定义了整个流程的结构和大部分默认实现,因此子类只需要关注定制化的逻辑即可,从而提高了代码的可复用性和可维护性。

善用 Java 新特性

Java 版本在更新迭代过程中会增加很多好用的特性,一定要善于使用 Java 新特性来优化自己的代码,增加代码的可阅读性和可维护性。

就比如火了这么多年的 Java 8 在增强代码可读性、简化代码方面,相比 Java 7 增加了很多功能,比如 Lambda、Stream 流操作、并行流(ParallelStream)、Optional 可空类型、新日期时间类型等。

Lambda 优化排序代码示例:

// 匿名内部类实现数组从小到大排序
Integer[] scores = {891007790,  86};
Arrays.sort(scores,new Comparator<Integer>(){
    @Override
    public int compare(Integer o1, Integer o2) {
        return o1.compareTo(o2);
    }
});
for(Integer score:scores){
    System.out.print(score);
}
// 使用 Lambda 优化
Arrays.sort(scores,(o1,o2)->o1.compareTo(o2) );
// 还可以像下面这样写
Arrays.sort(scores,Comparator.comparing(Integer::intValue));

Optional 优化代码示例:

private Double calculateAverageGrade(Map<String, List<Integer>> gradesList, String studentName)
  throws Exception 
{
 return Optional.ofNullable(gradesList.get(studentName))// 创建一个Optional对象,传入参数为空时返回Optional.empty()
   .map(list -> list.stream().collect(Collectors.averagingDouble(x -> x)))// 对 Optional 的值进行操作
   .orElseThrow(() -> new NotFoundException("Student not found - " + studentName));// 当值为空时,抛出指定的异常
}

再比如 Java 17 中转正的密封类(Sealed Classes) ,Java 16 中转正的记录类型(record关键字定义)、instanceof 模式匹配等新特性。

record关键字优化代码示例:

/**
 * 这个类具有两个特征
 * 1. 所有成员属性都是final
 * 2. 全部方法由构造方法,和两个成员属性访问器组成(共三个)
 * 那么这种类就很适合使用record来声明
 */

final class Rectangle implements Shape {
    final double length;
    final double width;

    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }

    double length() return length; }
    double width() return width; }
}
/**
 * 1. 使用record声明的类会自动拥有上面类中的三个方法
 * 2. 在这基础上还附赠了equals(),hashCode()方法以及toString()方法
 * 3. toString方法中包括所有成员属性的字符串表示形式及其名称
 */

record Rectangle(float length, float width) { }

使用 Bean 自动映射工具

我们经常在代码中会对一个数据结构封装成 DO、DTO、VO 等,而这些 Bean 中的大部分属性都是一样的,所以使用属性拷贝类工具可以帮助我们节省大量的 set 和 get 操作。

常用的 Bean 映射工具有:Spring BeanUtils、Apache BeanUtils、MapStruct、ModelMapper、Dozer、Orika、JMapper 。

由于 Apache BeanUtils 、Dozer 、ModelMapper 性能太差,所以不建议使用。MapStruct 性能更好而且使用起来比较灵活,是一个比较不错的选择。

这里以 MapStruct 为例,简单演示一下转换效果。

1、定义两个类 EmployeeEmployeeDTO

public class Employee {
    private int id;
    private String name;
    // getters and setters
}

public class EmployeeDTO {
    private int employeeId;
    private String employeeName;
    // getters and setters
}

2、定义转换接口让 EmployeeEmployeeDTO互相转换。

@Mapper
public interface EmployeeMapper {
    // Spring 项目可以将 Mapper 注入到 IoC 容器中,这样就可以像 Spring Bean 一样调用了
    EmployeeMapper INSTANT = Mappers.getMapper(EmployeeMapper.class);

    @Mapping(target="employeeId", source="entity.id")
    @Mapping(target="employeeName", source="entity.name")
    EmployeeDTO employeeToEmployeeDTO(Employee entity);

    @Mapping(target="id", source="dto.employeeId")
    @Mapping(target="name", source="dto.employeeName")
    Employee employeeDTOtoEmployee(EmployeeDTO dto);
}

3、实际使用。

//  EmployeeDTO 转  Employee
Employee employee = EmployeeMapper.INSTANT.employeeToEmployeeDTO(employee);

//  Employee 转  EmployeeDTO
EmployeeDTO employeeDTO = EmployeeMapper.INSTANT.employeeDTOtoEmployee(employeeDTO);

规范日志打印

1、不要随意打印日志,确保自己打印的日志是后面能用到的。

打印太多无用的日志不光影响问题排查,还会影响性能,加重磁盘负担。

2、打印日志中的敏感数据比如身份证号、电话号、密码需要进行脱敏。

3、选择合适的日志打印级别。最常用的日志级别有四个:DEBUG、INFO、WARN、ERROR。

  • DEBUG(调试):开发调试日志,主要开发人员开发调试过程中使用,生产环境禁止输出 DEBUG 日志。
  • INFO(通知):正常的系统运行信息,一些外部接口的日志,通常用于排查问题使用。
  • WARN(警告):警告日志,提示系统某个模块可能存在问题,但对系统的正常运行没有影响。
  • ERROR(错误):错误日志,提示系统某个模块可能存在比较严重的问题,会影响系统的正常运行。

4、生产环境禁止输出 DEBUG 日志,避免打印的日志过多(DEBUG 日志非常多)。

5、应用中不可直接使用日志系统(Log4j、Logback)中的 API,而应依赖使用日志框架 SLF4J 中的 API,使用门面模式的日志框架,有利于维护和各个类的日志处理方式统一。

Spring Boot 应用程序可以直接使用内置的日志框架 Logback,Logback 就是按照 SLF4J API 标准实现的。

6、异常日志需要打印完整的异常信息。

反例:

try {
    //读文件操作
    readFile();
catch (IOException e) {
    // 只保留了异常消息,栈没有记录
    log.error("文件读取错误, {}", e.getMessage());
}

正例:

try {
    //读文件操作
    readFile();
catch (IOException e) {
    log.error("文件读取错误", e);
}

7、避免层层打印日志。

举个例子:method1 调用 method2,method2 出现 error 并打印 error 日志,method1 也打印了 error 日志,等同于一个错误日志打印了 2 遍。

8、不要打印日志后又将异常抛出。

反例:

try {
    ...
catch (IllegalArgumentException e) {
    log.error("出现异常啦", e);
    throw e;
}

在日志中会对抛出的一个异常打印多条错误信息。

正例:

try {
    ...
catch (IllegalArgumentException e) {
    log.error("出现异常啦", e);
}
// 或者包装成自定义异常之后抛出
try {
    ...
catch (IllegalArgumentException e) {
    throw new MyBusinessException("一段对异常的描述信息.", e);
}


规范异常处理

阿里巴巴 Java 异常处理规约如下:

阿里巴巴Java异常处理规约

统一异常处理

所有的异常都应该由最上层捕获并处理,这样代码更简洁,还可以避免重复输出异常日志。 如果我们都在业务代码中使用try-catch或者try-catch-finally处理的话,就会让业务代码中冗余太多异常处理的逻辑,对于同样的异常我们还需要重复编写代码处理,还可能会导致重复输出异常日志。这样的话,代码可维护性、可阅读性都非常差。

Spring Boot 应用程序可以借助 @RestControllerAdvice@ExceptionHandler 实现全局统一异常处理。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result businessExceptionHandler(HttpServletRequest request, BusinessException e)
{
        ...
        return Result.faild(e.getCode(), e.getMessage());
    }
    ...
}

使用 try-with-resource 关闭资源

  1. 适用范围(资源的定义): 任何实现 java.lang.AutoCloseable或者 java.io.Closeable 的对象
  2. 关闭资源和 finally 块的执行顺序:try-with-resources 语句中,任何 catch 或 finally 块在声明的资源关闭后运行

《Effective Java》中明确指出:

面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources语句让人更容易编写必须要关闭的资源的代码,若采用try-finally则几乎做不到这点。

Java 中类似于InputStreamOutputStreamScannerPrintWriter等的资源都需要调用close()方法来手动关闭,一般情况下都是通过try-catch-finally语句来实现这个需求,如下:

//读取文本文件的内容
Scanner scanner = null;
try {
    scanner = new Scanner(new File("D://read.txt"));
    while (scanner.hasNext()) {
        System.out.println(scanner.nextLine());
    }
catch (FileNotFoundException e) {
    e.printStackTrace();
finally {
    if (scanner != null) {
        scanner.close();
    }
}

使用 Java 7 之后的 try-with-resources 语句改造上面的代码:

try (Scanner scanner = new Scanner(new File("test.txt"))) {
    while (scanner.hasNext()) {
        System.out.println(scanner.nextLine());
    }
catch (FileNotFoundException fnfe) {
    fnfe.printStackTrace();
}

当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用try-catch-finally可能会带来很多问题。

通过使用分号分隔,可以在try-with-resources块中声明多个资源。

try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
     BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
    int b;
    while ((b = bin.read()) != -1) {
        bout.write(b);
    }
}
catch (IOException e) {
    e.printStackTrace();
}

不要把异常定义为静态变量

不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,都需要手动 new 一个异常对象抛出。

// 错误做法
public class Exceptions {
    public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在"3001);
...
}

其他异常处理注意事项

  • 抛出完整具体的异常信息(避免 throw new BIZException(e.getMessage()这种形式的异常抛出),尽量自定义异常,而不是直接使用 RuntimeExceptionException
  • 优先捕获具体的异常类型。
  • 捕获了异常之后一定要处理,避免直接吃掉异常。
  • ......

接口不要直接返回数据库对象

接口不要直接返回数据库对象(也就是 DO),数据库对象包含类中所有的属性。

// 错误做法
public UserDO getUser(Long userId) {
  return userService.getUser(userId);
}

原因:

  • 如果数据库查询不做字段限制,会导致接口数据庞大,浪费用户的宝贵流量。
  • 如果数据库查询不做字段限制,容易把敏感字段暴露给接口,导致出现数据的安全问题。
  • 如果修改数据库对象的定义,接口返回的数据紧跟着也要改变,不利于维护。

建议的做法是单独定义一个类比如 VO(可以看作是接口返回给前端展示的对象数据)来对接口返回的数据进行筛选,甚至是封装和组合。

public UserVo getUser(Long userId) {
  UserDO userDO = userService.getUser(userId);
  UserVO userVO = new UserVO();
  BeanUtils.copyProperties(userDO, userVO);//演示使用
  return userVO;
}

统一接口返回值

接口返回的数据一定要统一格式,遮掩更方面对接前端开发的同学以及其他调用该接口的开发。

通常来说,下面这些信息是必备的:

  1. 状态码和状态信息:可以通过枚举定义状态码和状态信息。状态码标识请求的结果,状态信息属于提示信息,提示成功信息或者错误信息。
  2. 请求数据:请求该接口实际要返回的数据比如用户信息、文章列表。
public enum ResultEnum implements IResult {
    SUCCESS(2001"接口调用成功"),
    VALIDATE_FAILED(2002"参数校验失败"),
    COMMON_FAILED(2003"接口调用失败"),
    FORBIDDEN(2004"没有权限访问资源");

    private Integer code;
    private String message;
    ...
}

public class Result<T> {
    private Integer code;
    private String message;
    private T data;
    ...
    public static <T> Result<T> success(T data) {
        return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);
    }

    public static Result<?> failed() {
        return new Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);
    }
    ...
}

对于 Spring Boot 项目来说,可以使用 @RestControllerAdvice 注解+ ResponseBodyAdvic接口统一处理接口返回值,实现代码无侵入。篇幅问题这里就不贴具体实现代码了,比较简单,具体实现方式可以参考这篇文章:Spring Boot 无侵入式 实现 API 接口统一 JSON 格式返回[6]

需要注意的是,这种方式在 Spring Cloud OpenFeign 的继承模式下是有侵入性,解决办法见:SpringBoot 无侵入式 API 接口统一格式返回,在 Spring Cloud OpenFeign 继承模式具有了侵入性[7]

实际项目中,其实使用比较多的还是下面这种比较直接的方式:

public class PostController {

 @GetMapping("/list")
 public R<List<SysPost>> getPosts() {
  ...
  return R.ok(posts);
 }
}

上面介绍的无侵入的方式,一般改造旧项目的时候用的比较多。

远程调用设置超时时间

开发过程中,第三方接口调用、RPC 调用以及服务之间的调用建议设置一个超时时间。

平时接触到的超时可以简单分为下面 2 种:

  • 连接超时(ConnectTimeout) :客户端与服务端建立连接的最长等待时间。
  • 读取超时(ReadTimeout) :客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,关注比较多的还是读取超时。

一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时。

如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。

我之前在实际项目就遇到过类似的问题,整个网站无法正常处理请求,服务器负载直接快被拉满。后面发现原因是项目超时设置错误加上客户端请求处理异常,导致服务端连接数直接接近 40w+,这么多堆积的连接直接把系统干趴了。

正确使用线程池

 10 个使用线程池的注意事项:

  1. 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用 Executors 类创建线程池,会有 OOM 风险。
  2. 监测线程池运行状态。
  3. 建议不同类别的业务用不同的线程池。
  4. 别忘记给线程池命名。
  5. 正确配置线程池参数。
  6. 别忘记关闭线程池。
  7. 线程池尽量不要放耗时任务。
  8. 避免重复创建线程池。
  9. 使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)
  10. 线程池和 ThreadLocal 共用,可能会导致线程从 ThreadLocal 获取到的是旧值/脏数据。

敏感数据处理

  1. 返回前端的敏感数据比如身份证号、电话、地址信息要根据业务需求进行脱敏处理,示例:163****892
  2. 保存在数据库中的密码需要加盐之后使用哈希算法(比如 BCrypt)进行加密。
  3. 保存在数据库中的银行卡号、身份号这类敏感数据需要使用对称加密算法(比如 AES)保存。
  4. 网络传输的敏感数据比如银行卡号、身份号需要用 HTTPS + 非对称加密算法(如 RSA)来保证传输数据的安全性。
  5. 对于密码找回功能,不能明文存储用户密码。可以采用重置密码的方式,让用户通过验证身份后重新设置密码。
  6. 在代码中不应该明文写入密钥、口令等敏感信息。可以采用配置文件、环境变量等方式来动态加载这些信息。
  7. 定期更新敏感数据的加密算法和密钥,以保证加密算法和密钥的安全性和有效性。

参考资料

[1]

美团技术团队:如何优雅地记录操作日志?: https://tech.meituan.com/2021/09/16/operational-logbook.html

[2]

一个较重的代码坏味:“炫技式”的单行代码: https://www.cnblogs.com/lovesqcc/p/16559923.html

[3]

Replace Conditional Logic with Strategy Pattern - IDEA: https://www.jetbrains.com/help/idea/replace-conditional-logic-with-strategy-pattern.html

[4]

聊一聊责任链模式: https://juejin.cn/post/7160840952855134239

[5]

21 | 代码重复:搞定代码重复的三个绝招 - Java 业务开发常见错误 100 例 : https://time.geekbang.org/column/article/228964

[6]

Spring Boot 无侵入式 实现 API 接口统一 JSON 格式返回: https://blog.csdn.net/qq_34347620/article/details/102239179

[7]

SpringBoot 无侵入式 API 接口统一格式返回,在 Spring Cloud OpenFeign 继承模式具有了侵入性: https://blog.csdn.net/qq_34347620/article/details/124295302

[8]

超时&重试详解: https://javaguide.cn/high-availability/timeout-and-retry.html

[9]

10 个线程池最佳实践和坑!: https://javaguide.cn/java/concurrent/java-thread-pool-best-practices.html

··············  END  ··············

最后,欢迎学编程的朋友们加入鱼皮的 编程知识星球 ,和上万名学编程的同学共享知识、交流进步。一次付费,可无限学习星球内的所有项目并享有答疑指导服务。

往期推荐

我的学习小圈子

为什么 Node 应用要用 PM2 来跑?

米哈游稳住了!问的很基础!

你安装的 NPM 包,居然偷偷做这种事?

编程导航,火了!

新项目来咯!紧跟时代!

继续滑动看下一个

你见过哪些优雅的 Java 代码优化技巧?

向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存