内容概览:
提取通用处理逻辑
我们可以利用 注解 + 反射 和 注解+动态代理 来提取类、类属性或者类方法通用处理逻辑,进而避免重复的代码。虽然可能会带来一些性能损耗,但与其带来的好处相比还是非常值得的。
注解 + 反射 这种方式,可以在运行时动态地获取类的信息、属性和方法,并对它们进行通用处理。比如说在通过 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;
}
相关阅读:一坨一坨的 if/else 参数校验,终于被 SpringBoot 参数校验组件整干净了! 。
注解 + 动态代理 这种方式,可以在运行时生成代理对象,从而实现通用处理逻辑。比如说 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 的好处 :
- 避免 Controller 的代码过于臃肿,进而难以维护和扩展。
- 抽象业务处理逻辑,方便复用比如给用户增加积分的操作可能会有其他的 Service 用到。
- 避免一些小问题比如 Controller 层通过
@Value
注入值会失败。 - 更好的进行单元测试。如果将业务代码放在 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;
}
}
善用现有的工具类库
Java 的一大优势就是生态特别好,包含了许多好用的工具类库和框架,几乎覆盖了所有的需求场景。很多事情我们完全不需要自己从头开始做,利用现有的稳定可靠的工具类库可以大大提高开发效率。
-
easyexcel :快速、简单避免 OOM 的 Java 处理 Excel 工具。
- excel-streaming-reader:Excel 流式代码风格读取工具(只支持读取 XLSX 文件),基于 Apache POI 封装,同时保留标准 POI API 的语法。
- myexcel:一个集导入、导出、加密 Excel 等多项功能的工具包。
再比如 PDF 文档处理:
-
pdfbox :用于处理 PDF 文档的开放源码 Java 工具。该项目允许创建新的 PDF 文档、对现有文档进行操作以及从文档中提取内容。PDFBox 还包括几个命令行实用程序。PDFBox 是在 Apache 2.0 版许可下发布的。
- OpenPDF:OpenPDF 是一个免费的 Java 库,用于使用 LGPL 和 MPL 开源许可创建和编辑 PDF 文件。OpenPDF 基于 iText 的一个分支。
- itext7:iText 7 代表了想要利用利用好 PDF 的开发人员的更高级别的 sdk。iText 7 配备了更好的文档引擎、高级和低级编程功能以及创建、编辑和增强 PDF 文档的能力,几乎对每个工作流都有好处。
- FOP :Apache FOP 项目的主要的输出目标是 PDF。
善用设计模式
实际开发项目的过程中,我们应该合理地使用现有的设计模式来优化我们的代码。不过,切忌为了使用设计模式而使用。
-
工厂模式(Factory Pattern) :通过定义一个工厂方法来创建对象,从而将对象的创建和使用解耦,实现了“开闭原则”。
- 建造者模式(Builder Pattern) :通过链式调用和流式接口的方式,创建一个复杂对象,而不需要直接调用它的构造函数。
- 单例模式(Singleton Pattern) :确保一个类只有一个实例,并且提供一个全局的访问点,比如常见的 Spring Bean 单例模式。
- 原型模式(Prototype Pattern) :通过复制现有的对象来创建新的对象,从而避免了对象的创建成本和复杂度。
- 适配器模式(Adapter Pattern) :将一个类的接口转换成客户端所期望的接口,从而解决了接口不兼容的问题。
- 桥接模式(Bridge Pattern) :将抽象部分与实现部分分离开来,从而使它们可以独立变化。
- 装饰器模式(Decorator Pattern) :动态地给一个对象添加一些额外的职责,比如 Java 中的 IO 流处理。
- 代理模式(Proxy Pattern) :为其他对象提供一种代理以控制对这个对象的访问,比如常见的 Spring AOP 代理模式。
- 观察者模式(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(double income {
if (income <= 10000 {
return CALCULATOR_MAP.get("first";
} else if (income <= 30000 {
return CALCULATOR_MAP.get("second";
} else if (income <= 60000 {
return CALCULATOR_MAP.get("third";
} else {
return CALCULATOR_MAP.get("fourth";
}
}
}
这样,就可以通过 InsuranceCalculatorFactory
类手动获取相应的策略对象了。
double income = 40000;
// 获取第三级保险费用计算器
InsuranceCalculator calculator = InsuranceCalculatorFactory.getCalculator(income;
double insurance = calculator.calculate(income;
System.out.println("保险费用为:" + insurance;
这种方式允许我们在运行时根据需要选择不同的策略,而无需在代码中硬编码条件语句。
除了策略模式之外,Map+函数式接口也能实现类似的效果,代码一般还要更简洁一些。
首先,在 InsuranceCalculatorFactory
类中,将 getCalculator
方法的返回类型从 InsuranceCalculator
改为 Function<Double, Double>
,表示该方法返回一个将 double
类型的 income
映射到 double
类型的 insurance
的函数。
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
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(double income {
if (income <= 10000 {
return CALCULATOR_MAP.get("first";
} else if (income <= 30000 {
return CALCULATOR_MAP.get("second";
} else if (income <= 60000 {
return CALCULATOR_MAP.get("third";
} else {
return CALCULATOR_MAP.get("fourth";
}
}
}
然后,在调用工厂方法时,可以使用 Lambda 表达式或方法引用来代替实现策略接口的类。
double income = 40000;
Function<Double, Double> calculator = InsuranceCalculatorFactory.getCalculator(income;
double insurance = calculator.apply(income;
System.out.println("保险费用为:" + insurance;;
复杂对象使用建造者模式
复杂对象的创建可以使用建造者模式优化。
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 事件就是基于观察者模式实现的。
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 格式的数据进行校验。
相关阅读:21 | 代码重复:搞定代码重复的三个绝招 - Java 业务开发常见错误 100 例 。
善用 Java 新特性
就比如火了这么多年的 Java 8 在增强代码可读性、简化代码方面,相比 Java 7 增加了很多功能,比如 Lambda、Stream 流操作、并行流(ParallelStream)、Optional 可空类型、新日期时间类型等。
// 匿名内部类实现数组从小到大排序
Integer[] scores = {89, 100, 77, 90, 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 操作。
由于 Apache BeanUtils 、Dozer 、ModelMapper 性能太差,所以不建议使用。MapStruct 性能更好而且使用起来比较灵活,是一个比较不错的选择。
1、定义两个类 Employee
和 EmployeeDTO
。
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、定义转换接口让 Employee
和 EmployeeDTO
互相转换。
@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;
相关阅读:
-
MapStruct,降低无用代码的神器 - 大淘宝技术 - 2022 (推荐):对于 MapStruct 的各种操作介绍的更详细一些,涉及到一对多字段互转、为转换加缓存、 利用 Spring 进行依赖注入等高级用法。
- 告别 BeanUtils,Mapstruct 从入门到精通 - 大淘宝技术 - 2022 :主要和 Spring 的 BeanUtils 做了简单对比,介绍的相对比较简单。
规范日志打印
打印太多无用的日志不光影响问题排查,还会影响性能,加重磁盘负担。
3、选择合适的日志打印级别。最常用的日志级别有四个: DEBUG、INFO、WARN、ERROR。
- DEBUG(调试):开发调试日志,主要开发人员开发调试过程中使用,生产环境禁止输出 DEBUG 日志。
- INFO(通知):正常的系统运行信息,一些外部接口的日志,通常用于排查问题使用。
- WARN(警告):警告日志,提示系统某个模块可能存在问题,但对系统的正常运行没有影响。
- ERROR(错误):错误日志,提示系统某个模块可能存在比较严重的问题,会影响系统的正常运行。
5、应用中不可直接使用日志系统(Log4j、Logback)中的 API,而应依赖使用日志框架 SLF4J 中的 API,使用门面模式的日志框架,有利于维护和各个类的日志处理方式统一。
6、异常日志需要打印完整的异常信息。
try {
//读文件操作
readFile(;
} catch (IOException e {
// 只保留了异常消息,栈没有记录
log.error("文件读取错误, {}", e.getMessage(;
}
正例:
try {
//读文件操作
readFile(;
} catch (IOException e {
log.error("文件读取错误", e;
}
7、避免层层打印日志。
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;
}
相关阅读:15 个日志打印的实用建议 。
规范异常处理
统一异常处理
所有的异常都应该由最上层捕获并处理,这样代码更简洁,还可以避免重复输出异常日志。 如果我们都在业务代码中使用try-catch
或者try-catch-finally
处理的话,就会让业务代码中冗余太多异常处理的逻辑,对于同样的异常我们还需要重复编写代码处理,还可能会导致重复输出异常日志。这样的话,代码可维护性、可阅读性都非常差。
@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 关闭资源
-
适用范围(资源的定义): 任何实现
-
关闭资源和 finally 块的执行顺序: 在
try-with-resources
语句中,任何 catch 或 finally 块在声明的资源关闭后运行
java.lang.AutoCloseable
或者 java.io.Closeable
的对象
《Effective Java》中明确指出:
try-with-resources 而不是
try-finally
。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources
语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally
则几乎做不到这点。
InputStream、OutputStream
、Scanner
、PrintWriter
等的资源都需要我们调用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(
这种形式的异常抛出),尽量自定义异常,而不是直接使用 RuntimeException
或Exception
。
接口不要直接返回数据库对象
接口不要直接返回数据库对象(也就是 DO),数据库对象包含类中所有的属性。
// 错误做法
public UserDO getUser(Long userId {
return userService.getUser(userId;
}
原因:
- 如果数据库查询不做字段限制,会导致接口数据庞大,浪费用户的宝贵流量。
- 如果数据库查询不做字段限制,容易把敏感字段暴露给接口,导致出现数据的安全问题。
- 如果修改数据库对象的定义,接口返回的数据紧跟着也要改变,不利于维护。
public UserVo getUser(Long userId {
UserDO userDO = userService.getUser(userId;
UserVO userVO = new UserVO(;
BeanUtils.copyProperties(userDO, userVO;//演示使用
return userVO;
}
统一接口返回值
接口返回的数据一定要统一格式,遮掩更方面对接前端开发的同学以及其他调用该接口的开发。
- 状态码和状态信息:可以通过枚举定义状态码和状态信息。状态码标识请求的结果,状态信息属于提示信息,提示成功信息或者错误信息。
- 请求数据:请求该接口实际要返回的数据比如用户信息、文章列表。
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 格式返回 。
实际项目中,其实使用比较多的还是下面这种比较直接的方式:
public class PostController {
@GetMapping("/list"
public R<List<SysPost>> getPosts( {
...
return R.ok(posts;
}
}
上面介绍的无侵入的方式,一般改造旧项目的时候用的比较多。
远程调用设置超时时间
我们平时接触到的超时可以简单分为下面 2 种:
-
连接超时(ConnectTimeout) :客户端与服务端建立连接的最长等待时间。
- 读取超时(ReadTimeout) :客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。
如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。
相关阅读:超时&重试详解 。
正确使用线程池
在 10 个线程池最佳实践和坑! 这篇文章中,我总结了 10 个使用线程池的注意事项:
- 线程池必须手动通过
- 监测线程池运行状态。
- 建议不同类别的业务用不同的线程池。
- 别忘记给线程池命名。
- 正确配置线程池参数。
- 别忘记关闭线程池。
- 线程池尽量不要放耗时任务。
- 避免重复创建线程池。
- 使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)
- 线程池和
ThreadLocal
共用,可能会导致线程从ThreadLocal
获取到的是旧值/脏数据。
ThreadPoolExecutor
的构造函数来声明,避免使用 Executors
类创建线程池,会有 OOM 风险。
敏感数据处理
- 返回前端的敏感数据比如身份证号、电话、地址信息要根据业务需求进行脱敏处理,示例:
- 保存在数据库中的密码需要加盐之后使用哈希算法(比如 BCrypt)进行加密。
- 保存在数据库中的银行卡号、身份号这类敏感数据需要使用对称加密算法(比如 AES)保存。
- 网络传输的敏感数据比如银行卡号、身份号需要用 HTTPS + 非对称加密算法(如 RSA)来保证传输数据的安全性。
- 对于密码找回功能,不能明文存储用户密码。可以采用重置密码的方式,让用户通过验证身份后重新设置密码。
- 在代码中不应该明文写入密钥、口令等敏感信息。可以采用配置文件、环境变量等方式来动态加载这些信息。
- 定期更新敏感数据的加密算法和密钥,以保证加密算法和密钥的安全性和有效性。
163****892
。