文章收錄在我的 GitHub 倉庫,歡迎Star/fork:
Java-Interview-Tutorial https://github.com/Wasabi1234/Java-Interview-Tutorial
本文就教你如何優雅消除重複代碼并改變你對業務代碼沒技術含量的觀念。
1 crud 工程師之“痛”
很多 crud 工程師抱怨業務開發沒有技術含量,什麼設計模式、高并發都用不到,就是堆CRUD。每次面試被問到“講講常用設計模式?”,都隻能把單例講到精通,其他設計模式即使聽過也隻會簡單說說,因為根本沒實際用過。
對于反射、注解,也隻是知道在架構中用的很多,但自己又不寫架構,更不知道該如何使用。
- 設計模式是世界級軟體大師在大型項目的經驗所得,是被證明利于維護大型項目的。
- 反射、注解、泛型等進階特性在架構被大量使用,是因為架構往往需要以同一套算法應對不同資料結構,而這些特性可以幫助減少重複代碼,也是利于維護。
提升項目的可維護性是每個 coder 必須注意的,非常重要的一個手段就是減少代碼重複,因為重複過多會導緻:
- 容易修改一處忘記修改另一處,造成Bug
- 有一些代碼并非完全重複,而是相似度高,修改這些類似的代碼容易改(cv)錯,把原本有差別的地方改成一樣
2 工廠+模闆方法模式,消除多if和重複代碼
2.1 需求
開發購物車下單,對不同使用者不同處理:
- 普通使用者需要收取運費,運費是商品價格的10%,無商品折扣
- VIP使用者同樣需要收取商品價格10%的快遞費,但購買兩件以上相同商品時,第三件開始享受一定折扣
- 内部使用者可以免運費,無商品折扣
實作三種類型的購物車業務邏輯,把入參Map對象(K:商品ID,V:商品數量),轉換為出參購物車類型Cart。
2.2 菜鳥實作
- 購物車
- 購物車中的商品
2.2.1 普通使用者
2.2.2 VIP使用者
VIP使用者能享受同類商品多買的折扣。隻需額外處理多買折扣部分。
2.2.3 内部使用者
免運費、無折扣,隻處理商品折扣和運費時的邏輯差異。
三種購物車超過一半代碼重複。
雖然不同類型使用者計算運費和優惠的方式不同,但整個購物車的初始化、統計總價、總運費、總優惠和支付價格邏輯都一樣。
代碼重複本身不可怕,可怕的是漏改或改錯。
比如,寫VIP使用者購物車的同學發現商品總價計算有Bug,不應該是把所有Item的price加在一起,而是應該把所有Item的
price*quantity
相加。
他可能隻修VIP使用者購物車的代碼,漏了普通使用者、内部使用者的購物車中重複邏輯實作的相同Bug。
有三個購物車,就需根據不同使用者類型使用不同購物車。
- 使用多if實作不同類型使用者調用不同購物車process
就隻能不斷增加更多的購物車類,寫重複的購物車邏輯、寫更多if邏輯嗎?
當然不是,相同的代碼應該隻在一處出現!
2.3 重構秘技 - 模闆方法模式
可以把重複邏輯定義在抽象類,三個購物車隻要分别實作不同部分的邏輯。
這其實就是模闆方法模式。
在父類中實作購物車處理的流程模闆,然後把需要特殊處理的留抽象方法定義,讓子類去實作。由于父類邏輯無法單獨工作,是以需要定義為抽象類。
如下代碼所示,AbstractCart抽象類實作了購物車通用的邏輯,額外定義了兩個抽象方法讓子類去實作。其中,processCouponPrice方法用于計算商品折扣,processDeliveryPrice方法用于計算運費。
有抽象類,三個子類的實作就簡單了。
- 普通使用者的購物車NormalUserCart,實作0優惠和10%運費
- VIP使用者的購物車VipUserCart,直接繼承NormalUserCart,隻需修改多買優惠政策
- 内部使用者購物車InternalUserCart最簡單,直接設定0運費、0折扣
抽象類和三個子類的實作關系圖
2.4 重構秘技之工廠模式 - 消除多if
既然三個購物車都叫
XXXUserCart
,可将使用者類型字元串拼接
UserCart
構成購物車Bean的名稱,然後利用IoC容器,通過Bean的名稱直接擷取到AbstractCart,調用其process方法即可實作通用。
這就是工廠模式,借助Spring容器實作:
若有新使用者類型、使用者邏輯,隻要新增一個XXXUserCart類繼承AbstractCart,實作特殊的優惠和運費處理邏輯即可。
工廠+模闆方法模式,消除了重複代碼,還避免修改既有代碼。這就是設計模式中的開閉原則:對修改關閉,對擴充開放。
3 注解+反射消除重複代碼
3.1 需求
銀行提供了一些API接口,對參數的序列化不使用JSON,而需要我們把參數依次拼在一起構成一個大字元串。
- 按照銀行提供的API文檔的順序,把所有參數構成定長的資料,然後拼接在一起作為整個字元串
- 因為每種參數都有固定長度,未達到長度時需填充:
- 字元串類型的參數不滿長度部分需要以下劃線右填充,也就是字元串内容靠左
- 數字類型的參數不滿長度部分以0左填充,也就是實際數字靠右
-
貨币類型的表示需要把金額向下舍入2位到分,以分為機關,作為數字類型同樣進行左填充。
對所有參數做MD5操作作為簽名(為了友善了解,Demo中不涉及加鹽處理)。
比如,建立使用者方法和支付方法的定義是這樣的:
3.2 菜鳥實作
直接根據接口定義實作填充、加簽名、請求調用:
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();
// 數字靠右
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();
}
}
這段代碼的重複粒度更細:
- 三種标準資料類型的處理邏輯有重複
- 處理流程中字元串拼接、加簽和發請求的邏輯,在所有方法重複
- 實際方法的入參的參數類型和順序,不一定和接口要求一緻,容易出錯
- 代碼層面針對每一個參數寫死,無法清晰地進行核對,如果參數達到幾十個、上百個,出錯的機率極大。
3.3 重構秘技之注解&反射
針對銀行請求的所有邏輯均使用一套代碼實作,不會出現任何重複。
要實作接口邏輯和邏輯實作的剝離,首先要以POJO類定義所有的接口參數。
- 建立使用者API的參數
@Data
public class CreateUserAPI {
private String name;
private String identity;
private String mobile;
private int age;
}
有了接口參數定義,就能通過自定義注解為接口和所有參數增加一些中繼資料。
- 如下定義一個接口API的注解BankAPI,包含接口URL位址和接口說明
再定義一個自定義注解
@BankAPIField
,描述接口的每一個字段規範,包含參數的次序、類型和長度三個屬性:
- 定義
類描述建立使用者接口的資訊,通過為接口增加@BankAPI注解,來補充接口的URL和描述等中繼資料;通過為每一個字段增加@BankAPIField注解,來補充參數的順序、類型和長度等中繼資料:CreateUserAPI
- 類似的還有PayAPI類
這2個類繼承的AbstractAPI類是一個空實作,因為該案例中的接口無公共資料。
通過這倆類,即可在幾秒鐘内完成和API清單表格的核對。若我們的核心翻譯過程(即把注解和接口API序列化為請求需要的字元串的過程)沒問題,隻要注解和表格一緻,API請求翻譯就不會有問題。
通過注解實作了對API參數的描述。看反射如何配合注解實作動态的接口參數組裝:
private static String remoteCall(AbstractAPI api) throws IOException {
// 從類上獲得BankAPI注解,然後拿到其URL屬性,後續進行遠端調用
BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
bankAPI.url();
StringBuilder stringBuilder = new StringBuilder();
// 使用stream快速實作擷取類中所有帶BankAPIField注解的字段,并把字段按order屬性排序,然後設定私有字段反射可通路。
Arrays.stream(api.getClass().getDeclaredFields()) //獲得所有字段
//查找标記了注解的字段
.filter(field -> field.isAnnotationPresent(BankAPIField.class))
// 根據注解中的order對字段排序
.sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order()))
.peek(field -> field.setAccessible(true)) //設定可以通路私有字段
.forEach(field -> {
// 實作了反射擷取注解的值,然後根據BankAPIField拿到的參數類型,按照三種标準進行格式化,将所有參數的格式化邏輯集中在了這一處
// 獲得注解
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。
涉及類結構性的通用處理,都可按照該模式減少重複代碼。
- 反射使得我們在不知類結構時,按固定邏輯處理類成員
- 注解給我們為這些成員補充中繼資料的能力,使得我們利用反射實作通用邏輯的時候,可以從外部獲得更多我們關心的資料
4 屬性拷貝
對于三層架構系統,層間解耦及每層對資料的不同需求,每層都會有自己的POJO實體。
手動寫這些實體之間的指派代碼,容易出錯。對于複雜業務系統,實體有幾十甚至幾百個屬性也很正常。比如ComplicatedOrderDTO,描述一個訂單中幾十個屬性。如果轉換為一個類似的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()); //對象錯誤
orderDO.setDeliveryManName(orderDTO.getDeliveryManName());
orderDO.setDistance(orderDTO.getDistance());
orderDO.setExpectDate(orderDTO.getExpectDate());
orderDO.setFirstDeal(orderDTO.isFirstDeal());
orderDO.setHasPaid(orderDTO.isHasPaid());
orderDO.setHeadPic(orderDTO.getHeadPic());
orderDO.setLongitude(orderDTO.getLongitude());
orderDO.setLatitude(orderDTO.getLongitude()); //屬性指派錯誤
orderDO.setMerchantAddress(orderDTO.getMerchantAddress());
orderDO.setMerchantHeadPic(orderDTO.getMerchantHeadPic());
orderDO.setMerchantId(orderDTO.getMerchantId());
orderDO.setMerchantAddress(orderDTO.getMerchantAddress());
orderDO.setMerchantName(orderDTO.getMerchantName());
orderDO.setMerchantPhone(orderDTO.getMerchantPhone());
orderDO.setOrderNo(orderDTO.getOrderNo());
orderDO.setOutDate(orderDTO.getOutDate());
orderDO.setPayable(orderDTO.isPayable());
orderDO.setPaymentAmount(orderDTO.getPaymentAmount());
orderDO.setPaymentDate(orderDTO.getPaymentDate());
orderDO.setPaymentMethod(orderDTO.getPaymentMethod());
orderDO.setPaymentTimeLimit(orderDTO.getPaymentTimeLimit());
orderDO.setPhone(orderDTO.getPhone());
orderDO.setRefundable(orderDTO.isRefundable());
orderDO.setRemark(orderDTO.getRemark());
orderDO.setStatus(orderDTO.getStatus());
orderDO.setTotalQuantity(orderDTO.getTotalQuantity());
orderDO.setUpdateTime(orderDTO.getUpdateTime());
orderDO.setName(orderDTO.getName());
orderDO.setUid(orderDTO.getUid());
如果原始的DTO有100個字段,我們需要複制90個字段到DO中,保留10個不指派,最後應該如何校驗正确性呢?
- 數數嗎?即使數出有90行代碼,也不一定正确,因為屬性可能重複指派
- 有時字段名相近,比如complainable和commentable,容易搞反
- 對兩個目标字段重複指派相同的來源字段
- 明明要把DTO的值指派到DO中,卻在set的時候從DO自己取值,導緻指派無效
使用類似
BeanUtils
這種Mapping工具來做Bean的轉換,
copyProperties
方法還允許我們提供需要忽略的屬性:
5 總結
重複代碼多了總有一天會出錯。
-
有多個并行的類實作相似的代碼邏輯
考慮提取相同邏輯在父類中實作,差異邏輯通過抽象方法留給子類實作。使用類似的模闆方法把相同的流程和邏輯固定成模闆,保留差異的同時盡可能避免代碼重複。同時,可以使用Spring的IoC特性注入相應的子類,來避免執行個體化子類時的大量if…else代碼。
-
使用寫死的方式重複實作相同的資料處理算法
考慮把規則轉換為自定義注解,作為中繼資料對類或對字段、方法進行描述,然後通過反射動态讀取這些中繼資料、字段或調用方法,實作規則參數和規則定義的分離。也就是說,把變化的部分也就是規則的參數放入注解,規則的定義統一處理。
-
業務代碼中常見的DO、DTO、VO轉換時大量字段的手動指派,遇到有上百個屬性的複雜類型,非常非常容易出錯
不要手動進行指派,考慮使用Bean映射工具進行。此外,還可以考慮采用單元測試對所有字段進行指派正确性校驗。
代碼重複度是評估一個項目品質的重要名額,如果一個項目幾乎沒有任何重複代碼,那麼它内部抽象一定非常好。重構時,首要任務是消除重複。
參考
- 《重構》
- 搞定代碼重複的三個絕招
- https://blog.csdn.net/qq_32447301/article/details/107774036