這篇文章我會總結一些實用的有助於提高代碼質量的建議,內容較多,建議收藏!
內容概覽:
提取通用處理邏輯
註解、反射和動態代理是 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;
}
相關閱讀:一坨一坨的 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 的一大優勢就是生態特別好, 包含了許多好用的工具類庫和框架,幾乎覆蓋了所有的需求場景。很多事情我們完全不需要自己從頭開始做,利用現有的穩定可靠的工具類庫可以大大提高開發效率。
比如 Excel 文檔處理,你可以考慮下面這幾個開源的工具類庫:
- 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。
我的網站上總結了 Java 開發常用的一些工具類庫,可以作爲參考:https://javaguide.cn/open-source-project/tool-library.html 。
善用設計模式
實際開發項目的過程中,我們應該合理地使用現有的設計模式來優化我們的代碼。不過,切忌爲了使用設計模式而使用。
新來了個同事,設計模式用的是真優雅呀!這篇文章中介紹了 9 種在源碼中非常常見的設計模式:
- 工廠模式(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);
這種方式允許我們在運行時根據需要選擇不同的策略,而無需在代碼中硬編碼條件語句。
相關閱讀:Replace Conditional Logic with Strategy Pattern - IDEA 。
除了策略模式之外,Map+函數式接口也能實現類似的效果,代碼一般還要更簡潔一些。
下面是使用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 創建本地緩存的代碼示例:
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 格式的數據進行校驗。
通過以上實現,我們可以通過繼承抽象父類並實現其中的抽象方法,來定義自己的數據導入流程。另外,由於抽象父類已經定義了整個流程的結構和大部分默認實現,因此子類只需要關注定製化的邏輯即可,從而提高了代碼的可複用性和可維護性。
相關閱讀:21 | 代碼重複:搞定代碼重複的三個絕招 - Java 業務開發常見錯誤 100 例 。
善用 Java 新特性
Java 版本在更新迭代過程中會增加很多好用的特性,一定要善於使用 Java 新特性來優化自己的代碼,增加代碼的可閱讀性和可維護性。
就比如火了這麼多年的 Java 8 在增強代碼可讀性、簡化代碼方面,相比 Java 7 增加了很多功能,比如 Lambda、Stream 流操作、並行流(ParallelStream)、Optional 可空類型、新日期時間類型等。
Lambda 優化排序代碼示例:
// 匿名內部類實現數組從小到大排序
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 操作。
常用的 Bean 映射工具有:Spring BeanUtils、Apache BeanUtils、MapStruct、ModelMapper、Dozer、Orika、JMapper 。
由於 Apache BeanUtils 、Dozer 、ModelMapper 性能太差,所以不建議使用。MapStruct 性能更好而且使用起來比較靈活,是一個比較不錯的選擇。
這裏以 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 做了簡單對比,介紹的相對比較簡單。
規範日誌打印
1、不要隨意打印日誌,確保自己打印的日誌是後面能用到的。
打印太多無用的日誌不光影響問題排查,還會影響性能,加重磁盤負擔。
2、打印日誌中的敏感數據比如身份證號、電話號、密碼需要進行脫敏。相關閱讀:Spring Boot 3 步完成日誌脫敏,簡單實用!!
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);
}
相關閱讀:15 個日誌打印的實用建議 。
規範異常處理
阿里巴巴 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 關閉資源
- 適用範圍(資源的定義): 任何實現
java.lang.AutoCloseable
或者java.io.Closeable
的對象 - 關閉資源和 finally 塊的執行順序: 在
try-with-resources
語句中,任何 catch 或 finally 塊在聲明的資源關閉後運行
《Effective Java》中明確指出:
面對必須要關閉的資源,我們總是應該優先使用
try-with-resources
而不是try-finally
。隨之產生的代碼更簡短,更清晰,產生的異常對我們也更有用。try-with-resources
語句讓我們更容易編寫必須要關閉的資源的代碼,若採用try-finally
則幾乎做不到這點。
Java 中類似於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);
}
原因:
- 如果數據庫查詢不做字段限制,會導致接口數據龐大,浪費用戶的寶貴流量。
- 如果數據庫查詢不做字段限制,容易把敏感字段暴露給接口,導致出現數據的安全問題。
- 如果修改數據庫對象的定義,接口返回的數據緊跟着也要改變,不利於維護。
建議的做法是單獨定義一個類比如 VO(可以看作是接口返回給前端展示的對象數據)來對接口返回的數據進行篩選,甚至是封裝和組合。
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 格式返回 。
需要注意的是,這種方式在 Spring Cloud OpenFeign 的繼承模式下是有侵入性,解決辦法見:SpringBoot 無侵入式 API 接口統一格式返回,在 Spring Cloud OpenFeign 繼承模式具有了侵入性 。
實際項目中,其實使用比較多的還是下面這種比較直接的方式:
public class PostController {
@GetMapping("/list")
public R<List<SysPost>> getPosts() {
...
return R.ok(posts);
}
}
上面介紹的無侵入的方式,一般改造舊項目的時候用的比較多。
遠程調用設置超時時間
開發過程中,第三方接口調用、RPC 調用以及服務之間的調用建議設置一個超時時間。
我們平時接觸到的超時可以簡單分爲下面 2 種:
- 連接超時(ConnectTimeout) :客戶端與服務端建立連接的最長等待時間。
- 讀取超時(ReadTimeout) :客戶端和服務端已經建立連接,客戶端等待服務端處理完請求的最長時間。實際項目中,我們關注比較多的還是讀取超時。
一些連接池客戶端框架中可能還會有獲取連接超時和空閒連接清理超時。
如果沒有設置超時的話,就可能會導致服務端連接數爆炸和大量請求堆積的問題。這些堆積的連接和請求會消耗系統資源,影響新收到的請求的處理。嚴重的情況下,甚至會拖垮整個系統或者服務。
我之前在實際項目就遇到過類似的問題,整個網站無法正常處理請求,服務器負載直接快被拉滿。後面發現原因是項目超時設置錯誤加上客戶端請求處理異常,導致服務端連接數直接接近 40w+,這麼多堆積的連接直接把系統幹趴了。
相關閱讀:超時&重試詳解 。
正確使用線程池
在 10 個線程池最佳實踐和坑! 這篇文章中,我總結了 10 個使用線程池的注意事項:
- 線程池必須手動通過
ThreadPoolExecutor
的構造函數來聲明,避免使用Executors
類創建線程池,會有 OOM 風險。 - 監測線程池運行狀態。
- 建議不同類別的業務用不同的線程池。
- 別忘記給線程池命名。
- 正確配置線程池參數。
- 別忘記關閉線程池。
- 線程池儘量不要放耗時任務。
- 避免重複創建線程池。
- 使用 Spring 內部線程池時,一定要手動自定義線程池,配置合理的參數,不然會出現生產問題(一個請求創建一個線程)
- 線程池和
ThreadLocal
共用,可能會導致線程從ThreadLocal
獲取到的是舊值/髒數據。
敏感數據處理
- 返回前端的敏感數據比如身份證號、電話、地址信息要根據業務需求進行脫敏處理,示例:
163****892
。 - 保存在數據庫中的密碼需要加鹽之後使用哈希算法(比如 BCrypt)進行加密。
- 保存在數據庫中的銀行卡號、身份號這類敏感數據需要使用對稱加密算法(比如 AES)保存。
- 網絡傳輸的敏感數據比如銀行卡號、身份號需要用 HTTPS + 非對稱加密算法(如 RSA)來保證傳輸數據的安全性。
- 對於密碼找回功能,不能明文存儲用戶密碼。可以採用重置密碼的方式,讓用戶通過驗證身份後重新設置密碼。
- 在代碼中不應該明文寫入密鑰、口令等敏感信息。可以採用配置文件、環境變量等方式來動態加載這些信息。
- 定期更新敏感數據的加密算法和密鑰,以保證加密算法和密鑰的安全性和有效性。