幹掉 “重複程式碼” 的技巧有哪些

程式設計師大彬發表於2022-12-29

本文已經收錄到Github倉庫,該倉庫包含計算機基礎、Java核心知識點、多執行緒、JVM、常見框架、分散式、微服務、設計模式、架構等核心知識點,歡迎star~

Github地址:https://github.com/Tyson0314/...

Gitee地址:https://gitee.com/tysondai/Ja...

軟體工程師和碼農最大的區別就是平時寫程式碼時習慣問題,碼農很喜歡寫重複程式碼而軟體工程師會利用各種技巧去幹掉重複的冗餘程式碼。

業務同學抱怨業務開發沒有技術含量,用不到設計模式Java 高階特性OOP,平時寫程式碼都在堆 CRUD,個人成長無從談起。

其實,我認為不是這樣的。設計模式、OOP 是前輩們在大型專案中積累下來的經驗,透過這些方法論來改善大型專案的可維護性。反射、註解、泛型等高階特性在框架中大量使用的原因是,框架往往需要以同一套演算法來應對不同的資料結構,而這些特性可以幫助減少重複程式碼,提升專案可維護性。

在我看來,可維護性是大型專案成熟度的一個重要指標,而提升可維護性非常重要的一個手段就是減少程式碼重複。那為什麼這樣說呢?

  • 如果多處重複程式碼實現完全相同的功能,很容易修改一處忘記修改另一處,造成 Bug
  • 有一些程式碼並不是完全重複,而是相似度很高,修改這些類似的程式碼容易改(複製貼上)錯,把原本有區別的地方改為了一樣。

今天,我就從業務程式碼中最常見的三個需求展開,聊聊如何使用 Java 中的一些高階特性、設計模式,以及一些工具消除重複程式碼,才能既優雅又高階。透過今天的學習,也希望改變你對業務程式碼沒有技術含量的看法。

1. 利用工廠模式 + 模板方法模式,消除 if…else 和重複程式碼

假設要開發一個購物車下單的功能,針對不同使用者進行不同處理:

  • 普通使用者需要收取運費,運費是商品價格的 10%,無商品折扣;
  • VIP 使用者同樣需要收取商品價格 10% 的快遞費,但購買兩件以上相同商品時,第三件開始享受一定折扣;
  • 內部使用者可以免運費,無商品折扣。

我們的目標是實現三種型別的購物車業務邏輯,把入參 Map 物件(Key 是商品 ID,Value 是商品數量),轉換為出參購物車型別 Cart。

先實現針對普通使用者的購物車處理邏輯:

//購物車
@Data
public class Cart {
    //商品清單
    private List<Item> items = new ArrayList<>();
    //總優惠
    private BigDecimal totalDiscount;
    //商品總價
    private BigDecimal totalItemPrice;
    //總運費
    private BigDecimal totalDeliveryPrice;
    //應付總價
    private BigDecimal payPrice;
}
//購物車中的商品
@Data
public class Item {
    //商品ID
    private long id;
    //商品數量
    private int quantity;
    //商品單價
    private BigDecimal price;
    //商品優惠
    private BigDecimal couponPrice;
    //商品運費
    private BigDecimal deliveryPrice;
}
//普通使用者購物車處理
public class NormalUserCart {
    public Cart process(long userId, Map<Long, Integer> items) {
        Cart cart = new Cart();

        //把Map的購物車轉換為Item列表
        List<Item> itemList = new ArrayList<>();
        items.entrySet().stream().forEach(entry -> {
            Item item = new Item();
            item.setId(entry.getKey());
            item.setPrice(Db.getItemPrice(entry.getKey()));
            item.setQuantity(entry.getValue());
            itemList.add(item);
        });
        cart.setItems(itemList);

        //處理運費和商品優惠
        itemList.stream().forEach(item -> {
            //運費為商品總價的10%
            item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1")));
            //無優惠
            item.setCouponPrice(BigDecimal.ZERO);
        });

        //計算商品總價
        cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
        //計算運費總價
        cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
        //計算總優惠
        cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
        //應付總價=商品總價+運費總價-總優惠
        cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
        return cart;
    }
}

然後實現針對 VIP 使用者的購物車邏輯。與普通使用者購物車邏輯的不同在於,VIP 使用者能享受同類商品多買的折扣。所以,這部分程式碼只需要額外處理多買折扣部分:

public class VipUserCart {


    public Cart process(long userId, Map<Long, Integer> items) {
        ...


        itemList.stream().forEach(item -> {
            //運費為商品總價的10%
            item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1")));
            //購買兩件以上相同商品,第三件開始享受一定折扣
            if (item.getQuantity() > 2) {
                item.setCouponPrice(item.getPrice()
                        .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100")))
                       .multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
            } else {
                item.setCouponPrice(BigDecimal.ZERO);
            }
        });


        ...
        return cart;
    }
}

最後是免運費、無折扣的內部使用者,同樣只是處理商品折扣和運費時的邏輯差異:

public class InternalUserCart {


    public Cart process(long userId, Map<Long, Integer> items) {
        ...

        itemList.stream().forEach(item -> {
            //免運費
            item.setDeliveryPrice(BigDecimal.ZERO);
            //無優惠
            item.setCouponPrice(BigDecimal.ZERO);
        });

        ...
        return cart;
    }
}

對比一下程式碼量可以發現,三種購物車 70% 的程式碼是重複的。原因很簡單,雖然不同型別使用者計算運費和優惠的方式不同,但整個購物車的初始化、統計總價、總運費、總優惠和支付價格的邏輯都是一樣的。

正如我們開始時提到的,程式碼重複本身不可怕,可怕的是漏改或改錯。比如,寫 VIP 使用者購物車的同學發現商品總價計算有 Bug,不應該是把所有 Item 的 price 加在一起,而是應該把所有 Item 的 price*quantity 加在一起。

這時,他可能會只修改 VIP 使用者購物車的程式碼,而忽略了普通使用者、內部使用者的購物車中,重複的邏輯實現也有相同的 Bug。

有了三個購物車後,我們就需要根據不同的使用者型別使用不同的購物車了。如下程式碼所示,使用三個 if 實現不同型別使用者呼叫不同購物車的 process 方法:

@GetMapping("wrong")
public Cart wrong(@RequestParam("userId") int userId) {
    //根據使用者ID獲得使用者型別
    String userCategory = Db.getUserCategory(userId);
    //普通使用者處理邏輯
    if (userCategory.equals("Normal")) {
        NormalUserCart normalUserCart = new NormalUserCart();
        return normalUserCart.process(userId, items);
    }
    //VIP使用者處理邏輯
    if (userCategory.equals("Vip")) {
        VipUserCart vipUserCart = new VipUserCart();
        return vipUserCart.process(userId, items);
    }
    //內部使用者處理邏輯
    if (userCategory.equals("Internal")) {
        InternalUserCart internalUserCart = new InternalUserCart();
        return internalUserCart.process(userId, items);
    }

    return null;
}

電商的營銷玩法是多樣的,以後勢必還會有更多使用者型別,需要更多的購物車。我們就只能不斷增加更多的購物車類,一遍一遍地寫重複的購物車邏輯、寫更多的 if 邏輯嗎?

當然不是,相同的程式碼應該只在一處出現!

如果我們熟記抽象類和抽象方法的定義的話,這時或許就會想到,是否可以把重複的邏輯定義在抽象類中,三個購物車只要分別實現不同的那份邏輯呢?

其實,這個模式就是模板方法模式。我們在父類中實現了購物車處理的流程模板,然後把需要特殊處理的地方留空白也就是留抽象方法定義,讓子類去實現其中的邏輯。由於父類的邏輯不完整無法單獨工作,因此需要定義為抽象類。

如下程式碼所示,AbstractCart 抽象類實現了購物車通用的邏輯,額外定義了兩個抽象方法讓子類去實現。其中,processCouponPrice 方法用於計算商品折扣,processDeliveryPrice 方法用於計算運費。

public abstract class AbstractCart {
    //處理購物車的大量重複邏輯在父類實現
    public Cart process(long userId, Map<Long, Integer> items) {

        Cart cart = new Cart();

        List<Item> itemList = new ArrayList<>();
        items.entrySet().stream().forEach(entry -> {
            Item item = new Item();
            item.setId(entry.getKey());
            item.setPrice(Db.getItemPrice(entry.getKey()));
            item.setQuantity(entry.getValue());
            itemList.add(item);
        });
        cart.setItems(itemList);
        //讓子類處理每一個商品的優惠
        itemList.stream().forEach(item -> {
            processCouponPrice(userId, item);
            processDeliveryPrice(userId, item);
        });
        //計算商品總價
        cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
        //計算總運費
        cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
        //計算總折扣
        cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
        //計算應付價格
        cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
        return cart;
    }

    //處理商品優惠的邏輯留給子類實現
    protected abstract void processCouponPrice(long userId, Item item);
    //處理配送費的邏輯留給子類實現
    protected abstract void processDeliveryPrice(long userId, Item item);
}

有了這個抽象類,三個子類的實現就非常簡單了。普通使用者的購物車 NormalUserCart,實現的是 0 優惠和 10% 運費的邏輯:

@Service(value = "NormalUserCart")
public class NormalUserCart extends AbstractCart {

    @Override
    protected void processCouponPrice(long userId, Item item) {
        item.setCouponPrice(BigDecimal.ZERO);
    }

    @Override
    protected void processDeliveryPrice(long userId, Item item) {
        item.setDeliveryPrice(item.getPrice()
                .multiply(BigDecimal.valueOf(item.getQuantity()))
                .multiply(new BigDecimal("0.1")));
    }
}

VIP 使用者的購物車 VipUserCart,直接繼承了 NormalUserCart,只需要修改多買優惠策略:

@Service(value = "VipUserCart")
public class VipUserCart extends NormalUserCart {

    @Override
    protected void processCouponPrice(long userId, Item item) {
        if (item.getQuantity() > 2) {
            item.setCouponPrice(item.getPrice()
                    .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100")))
                    .multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
        } else {
            item.setCouponPrice(BigDecimal.ZERO);
        }
    }
}

內部使用者購物車 InternalUserCart 是最簡單的,直接設定 0 運費和 0 折扣即可:

@Service(value = "InternalUserCart")
public class InternalUserCart extends AbstractCart {
    @Override
    protected void processCouponPrice(long userId, Item item) {
        item.setCouponPrice(BigDecimal.ZERO);
    }

    @Override
    protected void processDeliveryPrice(long userId, Item item) {
        item.setDeliveryPrice(BigDecimal.ZERO);
    }
}

抽象類和三個子類的實現關係圖,如下所示:

是不是比三個獨立的購物車程式簡單了很多呢?接下來,我們再看看如何能避免三個 if 邏輯。

或許你已經注意到了,定義三個購物車子類時,我們在 @Service 註解中對 Bean 進行了命名。既然三個購物車都叫 XXXUserCart,那我們就可以把使用者型別字串拼接 UserCart 構成購物車 Bean 的名稱,然後利用 Spring 的 IoC 容器,透過 Bean 的名稱直接獲取到 AbstractCart,呼叫其 process 方法即可實現通用。

其實,這就是工廠模式,只不過是藉助 Spring 容器實現罷了:

@GetMapping("right")
public Cart right(@RequestParam("userId") int userId) {
    String userCategory = Db.getUserCategory(userId);
    AbstractCart cart = (AbstractCart) applicationContext.getBean(userCategory + "UserCart");
    return cart.process(userId, items);
}

試想, 之後如果有了新的使用者型別、新的使用者邏輯,是不是完全不用對程式碼做任何修改,只要新增一個 XXXUserCart 類繼承 AbstractCart,實現特殊的優惠和運費處理邏輯就可以了?

這樣一來,我們就利用工廠模式 + 模板方法模式,不僅消除了重複程式碼,還避免了修改既有程式碼的風險。這就是設計模式中的開閉原則:對修改關閉,對擴充套件開放。

2. 利用註解 + 反射消除重複程式碼

是不是有點興奮了,業務程式碼居然也能 OOP 了。我們再看一個三方介面的呼叫案例,同樣也是一個普通的業務邏輯。

假設銀行提供了一些 API 介面,對引數的序列化有點特殊,不使用 JSON,而是需要我們把引數依次拼在一起構成一個大字串。

  • 按照銀行提供的 API 文件的順序,把所有引數構成定長的資料,然後拼接在一起作為整個字串。
  • 因為每一種引數都有固定長度,未達到長度時需要做填充處理:
    • 字串型別的引數不滿長度部分需要以下劃線右填充,也就是字串內容靠左;
    • 數字型別的引數不滿長度部分以 0 左填充,也就是實際數字靠右;
    • 貨幣型別的表示需要把金額向下舍入 2 位到分,以分為單位,作為數字型別同樣進行左填充。
  • 對所有引數做 MD5 操作作為簽名(為了方便理解,Demo 中不涉及加鹽處理)。

比如,建立使用者方法和支付方法的定義是這樣的:

程式碼很容易實現,直接根據介面定義實現填充操作、加簽名、請求呼叫操作即可:

public class BankService {

    //建立使用者方法
    public static String createUser(String name, String identity, String mobile, int age) throws IOException {
        StringBuilder stringBuilder = new StringBuilder();
        //字串靠左,多餘的地方填充_
        stringBuilder.append(String.format("%-10s", name).replace(' ', '_'));
        //字串靠左,多餘的地方填充_
        stringBuilder.append(String.format("%-18s", identity).replace(' ', '_'));
        //數字靠右,多餘的地方用0填充
        stringBuilder.append(String.format("%05d", age));
        //字串靠左,多餘的地方用_填充
        stringBuilder.append(String.format("%-11s", mobile).replace(' ', '_'));
        //最後加上MD5作為簽名
        stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
        return Request.Post("http://localhost:45678/reflection/bank/createUser")
                .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
                .execute().returnContent().asString();
    }
    
    //支付方法
    public static String pay(long userId, BigDecimal amount) throws IOException {
        StringBuilder stringBuilder = new StringBuilder();
        //數字靠右,多餘的地方用0填充
        stringBuilder.append(String.format("%020d", userId));
        //金額向下舍入2位到分,以分為單位,作為數字靠右,多餘的地方用0填充
        stringBuilder.append(String.format("%010d", amount.setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
        //最後加上MD5作為簽名
        stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
        return Request.Post("http://localhost:45678/reflection/bank/pay")
                .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
                .execute().returnContent().asString();
    }
}

可以看到,這段程式碼的重複粒度更細:

  • 三種標準資料型別的處理邏輯有重複,稍有不慎就會出現 Bug;
  • 處理流程中字串拼接、加簽和發請求的邏輯,在所有方法重複;
  • 實際方法的入參的引數型別和順序,不一定和介面要求一致,容易出錯;
  • 程式碼層面針對每一個引數硬編碼,無法清晰地進行核對,如果引數達到幾十個、上百個,出錯的機率極大。

那應該如何改造這段程式碼呢?沒錯,就是要用註解和反射!

使用註解和反射這兩個武器,就可以針對銀行請求的所有邏輯均使用一套程式碼實現,不會出現任何重複。

要實現介面邏輯和邏輯實現的剝離,首先需要以 POJO 類(只有屬性沒有任何業務邏輯的資料類)的方式定義所有的介面引數。比如,下面這個建立使用者 API 的引數:

@Data
public class CreateUserAPI {
    private String name;
    private String identity;
    private String mobile;
    private int age;
}

有了介面引數定義,我們就能透過自定義註解為介面和所有引數增加一些後設資料。如下所示,我們定義一個介面 API 的註解 BankAPI,包含介面 URL 地址和介面說明:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Inherited
public @interface BankAPI {
    String desc() default "";
    String url() default "";
}

然後,我們再定義一個自定義註解 @BankAPIField,用於描述介面的每一個欄位規範,包含引數的次序、型別和長度三個屬性:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
@Inherited
public @interface BankAPIField {
    int order() default -1;
    int length() default -1;
    String type() default "";
}

接下來,註解就可以發揮威力了。

如下所示,我們定義了 CreateUserAPI 類描述建立使用者介面的資訊,透過為介面增加 @BankAPI 註解,來補充介面的 URL 和描述等後設資料;透過為每一個欄位增加 @BankAPIField 註解,來補充引數的順序、型別和長度等後設資料:

@BankAPI(url = "/bank/createUser", desc = "建立使用者介面")
@Data
public class CreateUserAPI extends AbstractAPI {
    @BankAPIField(order = 1, type = "S", length = 10)
    private String name;
    @BankAPIField(order = 2, type = "S", length = 18)
    private String identity;
    @BankAPIField(order = 4, type = "S", length = 11) //注意這裡的order需要按照API表格中的順序
    private String mobile;
    @BankAPIField(order = 3, type = "N", length = 5)
    private int age;
}

另一個 PayAPI 類也是類似的實現:

@BankAPI(url = "/bank/pay", desc = "支付介面")
@Data
public class PayAPI extends AbstractAPI {
    @BankAPIField(order = 1, type = "N", length = 20)
    private long userId;
    @BankAPIField(order = 2, type = "M", length = 10)
    private BigDecimal amount;
}

這 2 個類繼承的 AbstractAPI 類是一個空實現,因為這個案例中的介面並沒有公共資料可以抽象放到基類。

透過這 2 個類,我們可以在幾秒鐘內完成和 API 清單表格的核對。理論上,如果我們的核心翻譯過程(也就是把註解和介面 API 序列化為請求需要的字串的過程)沒問題,只要註解和表格一致,API 請求的翻譯就不會有任何問題。

以上,我們透過註解實現了對 API 引數的描述。接下來,我們再看看反射如何配合註解實現動態的介面引數組裝:

  • 第 3 行程式碼中,我們從類上獲得了 BankAPI 註解,然後拿到其 URL 屬性,後續進行遠端呼叫。
  • 第 6~9 行程式碼,使用 stream 快速實現了獲取類中所有帶 BankAPIField 註解的欄位,並把欄位按 order 屬性排序,然後設定私有欄位反射可訪問。
  • 第 12~38 行程式碼,實現了反射獲取註解的值,然後根據 BankAPIField 拿到的引數型別,按照三種標準進行格式化,將所有引數的格式化邏輯集中在了這一處。
  • 第 41~48 行程式碼,實現了引數加簽和請求呼叫。
private static String remoteCall(AbstractAPI api) throws IOException {
    //從BankAPI註解獲取請求地址
    BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
    bankAPI.url();
    StringBuilder stringBuilder = new StringBuilder();
    Arrays.stream(api.getClass().getDeclaredFields()) //獲得所有欄位
            .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查詢標記了註解的欄位
            .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order())) //根據註解中的order對欄位排序
            .peek(field -> field.setAccessible(true)) //設定可以訪問私有欄位
            .forEach(field -> {
                //獲得註解
                BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class);
                Object value = "";
                try {
                    //反射獲取欄位值
                    value = field.get(api);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
                //根據欄位型別以正確的填充方式格式化字串
                switch (bankAPIField.type()) {
                    case "S": {
                        stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ', '_'));
                        break;
                    }
                    case "N": {
                        stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ', '0'));
                        break;
                    }
                    case "M": {
                        if (!(value instanceof BigDecimal))
                            throw new RuntimeException(String.format("{} 的 {} 必須是BigDecimal", api, field));
                        stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
                        break;
                    }
                    default:
                        break;
                }
            });
    //簽名邏輯
   stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
    String param = stringBuilder.toString();
    long begin = System.currentTimeMillis();
    //發請求
    String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url())
            .bodyString(param, ContentType.APPLICATION_JSON)
            .execute().returnContent().asString();
    log.info("呼叫銀行API {} url:{} 引數:{} 耗時:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin);
    return result;
}

可以看到,所有處理引數排序、填充、加簽、請求呼叫的核心邏輯,都匯聚在了 remoteCall 方法中。有了這個核心方法,BankService 中每一個介面的實現就非常簡單了,只是引數的組裝,然後呼叫 remoteCall 即可。

//建立使用者方法
public static String createUser(String name, String identity, String mobile, int age) throws IOException {
    CreateUserAPI createUserAPI = new CreateUserAPI();
    createUserAPI.setName(name);
    createUserAPI.setIdentity(identity);
    createUserAPI.setAge(age);
    createUserAPI.setMobile(mobile);
    return remoteCall(createUserAPI);
}
//支付方法
public static String pay(long userId, BigDecimal amount) throws IOException {
    PayAPI payAPI = new PayAPI();
    payAPI.setUserId(userId);
    payAPI.setAmount(amount);
    return remoteCall(payAPI);
}

其實,許多涉及類結構性的通用處理,都可以按照這個模式來減少重複程式碼

反射給予了我們在不知曉類結構的時候,按照固定的邏輯處理類的成員;而註解給了我們為這些成員補充後設資料的能力,使得我們利用反射實現通用邏輯的時候,可以從外部獲得更多我們關心的資料。

3. 利用屬性複製工具消除重複程式碼

最後,我們再來看一種業務程式碼中經常出現的程式碼邏輯,實體之間的轉換複製。

對於三層架構的系統,考慮到層之間的解耦隔離以及每一層對資料的不同需求,通常每一層都會有自己的 POJO 作為資料實體。比如,資料訪問層的實體一般叫作 DataObject 或 DO,業務邏輯層的實體一般叫作 Domain,表現層的實體一般叫作 Data Transfer Object 或 DTO。

這裡我們需要注意的是,如果手動寫這些實體之間的賦值程式碼,同樣容易出錯。

對於複雜的業務系統,實體有幾十甚至幾百個屬性也很正常。就比如 ComplicatedOrderDTO 這個資料傳輸物件,描述的是一個訂單中的幾十個屬性。如果我們要把這個 DTO 轉換為一個類似的 DO,複製其中大部分的欄位,然後把資料入庫,勢必需要進行很多屬性對映賦值操作。就像這樣,密密麻麻的程式碼是不是已經讓你頭暈了?

ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
orderDO.setAcceptDate(orderDTO.getAcceptDate());
orderDO.setAddress(orderDTO.getAddress());
orderDO.setAddressId(orderDTO.getAddressId());
orderDO.setCancelable(orderDTO.isCancelable());
orderDO.setCommentable(orderDTO.isComplainable()); //屬性錯誤
orderDO.setComplainable(orderDTO.isCommentable()); //屬性錯誤
orderDO.setCancelable(orderDTO.isCancelable());
orderDO.setCouponAmount(orderDTO.getCouponAmount());
orderDO.setCouponId(orderDTO.getCouponId());
orderDO.setCreateDate(orderDTO.getCreateDate());
orderDO.setDirectCancelable(orderDTO.isDirectCancelable());
orderDO.setDeliverDate(orderDTO.getDeliverDate());
orderDO.setDeliverGroup(orderDTO.getDeliverGroup());
orderDO.setDeliverGroupOrderStatus(orderDTO.getDeliverGroupOrderStatus());
orderDO.setDeliverMethod(orderDTO.getDeliverMethod());
orderDO.setDeliverPrice(orderDTO.getDeliverPrice());
orderDO.setDeliveryManId(orderDTO.getDeliveryManId());
orderDO.setDeliveryManMobile(orderDO.getDeliveryManMobile()); //物件錯誤

如果不是程式碼中有註釋,你能看出其中的諸多問題嗎?

如果原始的 DTO 有 100 個欄位,我們需要複製 90 個欄位到 DO 中,保留 10 個不賦值,最後應該如何校驗正確性呢?數數嗎?即使數出有 90 行程式碼,也不一定正確,因為屬性可能重複賦值。

有的時候欄位命名相近,比如 complainable 和 commentable,容易搞反(第 7 和第 8 行),或者對兩個目標欄位重複賦值相同的來源欄位(比如第 28 行)

明明要把 DTO 的值賦值到 DO 中,卻在 set 的時候從 DO 自己取值(比如第 20 行),導致賦值無效。

這段程式碼並不是我隨手寫出來的,而是一個真實案例。有位同學就像程式碼中那樣把經緯度賦值反了,因為落庫的欄位實在太多了。這個 Bug 很久都沒發現,直到真正用到資料庫中的經緯度做計算時,才發現一直以來都存錯了。

修改方法很簡單,可以使用類似 BeanUtils 這種 Mapping 工具來做 Bean 的轉換,copyProperties 方法還允許我們提供需要忽略的屬性:

ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
BeanUtils.copyProperties(orderDTO, orderDO, "id");
return orderDO;

總結

第一種程式碼重複是,有多個並行的類實現相似的程式碼邏輯。我們可以考慮提取相同邏輯在父類中實現,差異邏輯透過抽象方法留給子類實現。使用類似的模板方法把相同的流程和邏輯固定成模板,保留差異的同時儘可能避免程式碼重複。同時,可以使用 Spring 的 IoC 特性注入相應的子類,來避免例項化子類時的大量 if…else 程式碼。

第二種程式碼重複是,使用硬編碼的方式重複實現相同的資料處理演算法。我們可以考慮把規則轉換為自定義註解,作為後設資料對類或對欄位、方法進行描述,然後透過反射動態讀取這些後設資料、欄位或呼叫方法,實現規則引數和規則定義的分離。也就是說,把變化的部分也就是規則的引數放入註解,規則的定義統一處理。

第三種程式碼重複是,業務程式碼中常見的 DO、DTO、VO 轉換時大量欄位的手動賦值,遇到有上百個屬性的複雜型別,非常非常容易出錯。我的建議是,不要手動進行賦值,考慮使用 Bean 對映工具進行。此外,還可以考慮採用單元測試對所有欄位進行賦值正確性校驗。

相關文章