你見過哪些優雅的 Java 程式碼最佳化技巧?

張哥說技術發表於2023-05-05

來源:JavaGuide

你好,我是 Guide。最近花了兩週的時間總結了一些實用的有助於提高程式碼質量的建議,內容較多,建議收藏!

內容概覽:

你見過哪些優雅的 Java 程式碼最佳化技巧?

提取通用處理邏輯

註解、反射和動態代理是 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);
}

相關閱讀:美團技術團隊:如何優雅地記錄操作日誌?[1]

避免炫技式單行程式碼

程式碼沒必要一味追求“短”,是否易於閱讀和維護也非常重要。像炫技式的單行程式碼就非常難以理解、排查和修改起來都比較麻煩且耗時。

反例:

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);
}

相關閱讀:一個較重的程式碼壞味:“炫技式”的單行程式碼[2]

基於介面程式設計提高擴充套件性

基於介面而非實現程式設計是一種常用的程式設計正規化,也是一種非常好的程式設計習慣,一定要牢記於心!

基於介面程式設計可以讓程式碼更加靈活、更易擴充套件和維護,因為介面可以為不同的實現提供相同的方法簽名(方法的名稱、引數型別和順序以及返回值型別)和契約(介面中定義的方法的行為和約束,即方法應該完成的功能和要求),這使得實現類可以相互替換,而不必改變程式碼的其它部分。另外,基於介面程式設計還可以幫助我們避免過度依賴具體實現類,降低程式碼的耦合性,提高程式碼的可測試性和可重用性。

就比如說在編寫簡訊服務、郵箱服務、儲存服務等常用第三方服務的程式碼時,我們可以先先定義一個介面,介面中抽象出具體的方法,然後實現類再去實現這個介面。

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<UserLong{
  ...
}

快取:

@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);
        }
    }
    // ...
}

相關閱讀:聊一聊責任鏈模式[4]

使用觀察者模式解耦

觀察者模式也是解耦的利器。當物件之間存在一對多關係,可以使用觀察者模式,讓多個觀察者物件同時監聽某一個主題物件。當主題物件狀態發生變化時,會通知所有觀察者,觀察者收到通知之後可以根據通知的內容去針對性地做一些事情。

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 例 [5]

善用 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);

相關閱讀:

  • 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 異常處理規約如下:

你見過哪些優雅的 Java 程式碼最佳化技巧?

統一異常處理

所有的異常都應該由最上層捕獲並處理,這樣程式碼更簡潔,還可以避免重複輸出異常日誌。 如果我們都在業務程式碼中使用try-catch或者try-catch-finally處理的話,就會讓業務程式碼中冗餘太多異常處理的邏輯,對於同樣的異常我們還需要重複編寫程式碼處理,還可能會導致重複輸出異常日誌。這樣的話,程式碼可維護性、可閱讀性都非常差。

Spring Boot 應用程式可以藉助 @RestControllerAdvice@ExceptionHandler 實現全域性統一異常處理。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result businessExceptionHandler(HttpServletRequest requestBusinessException 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+,這麼多堆積的連線直接把系統幹趴了。

相關閱讀:超時&重試詳解[8]

正確使用執行緒池

10 個執行緒池最佳實踐和坑![9] 這篇文章中,我總結了 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]

美團技術團隊:如何優雅地記錄操作日誌?:

[2]

一個較重的程式碼壞味:“炫技式”的單行程式碼: https://www.cnblogs.com/lovesqcc/p/16559923.html

[3]

Replace Conditional Logic with Strategy Pattern - IDEA:

[4]

聊一聊責任鏈模式:

[5]

21 | 程式碼重複:搞定程式碼重複的三個絕招 - Java 業務開發常見錯誤 100 例 :

[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]

超時&重試詳解:

[9]

10 個執行緒池最佳實踐和坑!: https://javaguide.cn/java/concurrent/java-thread-pool-best-practices.html

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2950159/,如需轉載,請註明出處,否則將追究法律責任。

相關文章