阿里P8架構(gòu)師教你kill代碼重復(fù)/大量ifelse
本文就教你如何優(yōu)雅消除重復(fù)代碼并改變你對(duì)業(yè)務(wù)代碼沒(méi)技術(shù)含量的觀念。
1 crud 工程師之“痛”
很多 crud 工程師抱怨業(yè)務(wù)開發(fā)沒(méi)有技術(shù)含量,什么設(shè)計(jì)模式、高并發(fā)都用不到,就是堆CRUD。每次面試被問(wèn)到“講講常用設(shè)計(jì)模式?”,都只能把單例講到精通,其他設(shè)計(jì)模式即使聽過(guò)也只會(huì)簡(jiǎn)單說(shuō)說(shuō),因?yàn)楦緵](méi)實(shí)際用過(guò)。
對(duì)于反射、注解,也只是知道在框架中用的很多,但自己又不寫框架,更不知道該如何使用。
- 設(shè)計(jì)模式是世界級(jí)軟件大師在大型項(xiàng)目的經(jīng)驗(yàn)所得,是被證實(shí)利于維護(hù)大型項(xiàng)目的
 - 反射、注解、泛型等高級(jí)特性在框架被大量使用,是因?yàn)榭蚣芡枰酝惶姿惴☉?yīng)對(duì)不同數(shù)據(jù)結(jié)構(gòu),而這些特性可以幫助減少重復(fù)代碼,也是利于維護(hù)
 
提升項(xiàng)目的可維護(hù)性是每個(gè) coder 必須注意的,非常重要的一個(gè)手段就是減少代碼重復(fù),因?yàn)橹貜?fù)過(guò)多會(huì)導(dǎo)致:
- 修改一處忘記修改另一處,造成Bug
 - 有一些代碼并非完全重復(fù),而是相似度高,修改這些類似的代碼容易改(cv)錯(cuò),把原本有區(qū)別的地方改成一樣
 
2 工廠+模板方法模式,消除多if和重復(fù)代碼
2.1 需求
開發(fā)購(gòu)物車下單,對(duì)不同用戶不同處理:
- 普通用戶需要收取運(yùn)費(fèi),運(yùn)費(fèi)是商品價(jià)格的10%,無(wú)商品折扣
 - VIP用戶同樣需要收取商品價(jià)格10%的快遞費(fèi),但購(gòu)買兩件以上相同商品時(shí),第三件開始享受一定折扣
 - 內(nèi)部用戶可以免運(yùn)費(fèi),無(wú)商品折扣
 
實(shí)現(xiàn)三種類型的購(gòu)物車業(yè)務(wù)邏輯,把入?yún)ap對(duì)象(K:商品ID,V:商品數(shù)量),轉(zhuǎn)換為出參購(gòu)物車類型Cart。
2.2 菜鳥實(shí)現(xiàn)
購(gòu)物車
購(gòu)物車中的商品
2.2.1 普通用戶
2.2.2 VIP用戶
VIP用戶能享受同類商品多買的折扣。只需額外處理多買折扣部分。
2.2.3 內(nèi)部用戶
免運(yùn)費(fèi)、無(wú)折扣,只處理商品折扣和運(yùn)費(fèi)時(shí)的邏輯差異。
三種購(gòu)物車超過(guò)一半代碼重復(fù)。
雖然不同類型用戶計(jì)算運(yùn)費(fèi)和優(yōu)惠的方式不同,但整個(gè)購(gòu)物車的初始化、統(tǒng)計(jì)總價(jià)、總運(yùn)費(fèi)、總優(yōu)惠和支付價(jià)格邏輯都一樣。
代碼重復(fù)本身不可怕,可怕的是漏改或改錯(cuò)。
比如,寫VIP用戶購(gòu)物車的同學(xué)發(fā)現(xiàn)商品總價(jià)計(jì)算有Bug,不應(yīng)該是把所有Item的price加在一起,而是應(yīng)該把所有Item的price*quantity相加。
他可能只修VIP用戶購(gòu)物車的代碼,漏了普通用戶、內(nèi)部用戶的購(gòu)物車中重復(fù)邏輯實(shí)現(xiàn)的相同Bug。
有三個(gè)購(gòu)物車,就需根據(jù)不同用戶類型使用不同購(gòu)物車。
使用多if實(shí)現(xiàn)不同類型用戶調(diào)用不同購(gòu)物車process:
就只能不斷增加更多的購(gòu)物車類,寫重復(fù)的購(gòu)物車邏輯、寫更多if邏輯嗎?
當(dāng)然不是,相同的代碼應(yīng)該只在一處出現(xiàn)! 
2.3 重構(gòu)秘技 - 模板方法模式
可以把重復(fù)邏輯定義在抽象類,三個(gè)購(gòu)物車只要分別實(shí)現(xiàn)不同部分的邏輯。
這其實(shí)就是模板方法模式。
在父類中實(shí)現(xiàn)購(gòu)物車處理的流程模板,然后把需要特殊處理的留抽象方法定義,讓子類去實(shí)現(xiàn)。由于父類邏輯無(wú)法單獨(dú)工作,因此需要定義為抽象類。
如下代碼所示,AbstractCart抽象類實(shí)現(xiàn)了購(gòu)物車通用的邏輯,額外定義了兩個(gè)抽象方法讓子類去實(shí)現(xiàn)。其中,processCouponPrice方法用于計(jì)算商品折扣,processDeliveryPrice方法用于計(jì)算運(yùn)費(fèi)。
有抽象類,三個(gè)子類的實(shí)現(xiàn)就簡(jiǎn)單了。
普通用戶的購(gòu)物車NormalUserCart,實(shí)現(xiàn)0優(yōu)惠和10%運(yùn)費(fèi)
VIP用戶的購(gòu)物車VipUserCart,直接繼承NormalUserCart,只需修改多買優(yōu)惠策
內(nèi)部用戶購(gòu)物車InternalUserCart最簡(jiǎn)單,直接設(shè)置0運(yùn)費(fèi)、0折扣
抽象類和三個(gè)子類的實(shí)現(xiàn)關(guān)系圖
2.4 重構(gòu)秘技之工廠模式 - 消除多if
既然三個(gè)購(gòu)物車都叫XXXUserCart,可將用戶類型字符串拼接UserCart構(gòu)成購(gòu)物車Bean的名稱,然后利用IoC容器,通過(guò)Bean的名稱直接獲取到AbstractCart,調(diào)用其process方法即可實(shí)現(xiàn)通用。
這就是工廠模式,借助Spring容器實(shí)現(xiàn):
若有新用戶類型、用戶邏輯,只要新增一個(gè)XXXUserCart類繼承AbstractCart,實(shí)現(xiàn)特殊的優(yōu)惠和運(yùn)費(fèi)處理邏輯即可。
工廠+模板方法模式,消除了重復(fù)代碼,還避免修改既有代碼。這就是設(shè)計(jì)模式中的開閉原則:對(duì)修改關(guān)閉,對(duì)擴(kuò)展開放。
3 注解+反射消除重復(fù)代碼
3.1 需求
銀行提供了一些API接口,對(duì)參數(shù)的序列化不使用JSON,而需要我們把參數(shù)依次拼在一起構(gòu)成一個(gè)大字符串。
按照銀行提供的API文檔的順序,把所有參數(shù)構(gòu)成定長(zhǎng)的數(shù)據(jù),然后拼接在一起作為整個(gè)字符串
因?yàn)槊糠N參數(shù)都有固定長(zhǎng)度,未達(dá)到長(zhǎng)度時(shí)需填充:
- 字符串類型的參數(shù)不滿長(zhǎng)度部分需要以下劃線右填充,也就是字符串內(nèi)容靠左
 - 數(shù)字類型的參數(shù)不滿長(zhǎng)度部分以0左填充,也就是實(shí)際數(shù)字靠右
 - 貨幣類型的表示需要把金額向下舍入2位到分,以分為單位,作為數(shù)字類型同樣進(jìn)行左填充。
 - 對(duì)所有參數(shù)做MD5操作作為簽名(為了方便理解,Demo中不涉及加鹽處理)。
 
比如,創(chuàng)建用戶方法和支付方法的定義是這樣的:
3.2 菜鳥實(shí)現(xiàn)
直接根據(jù)接口定義實(shí)現(xiàn)填充、加簽名、請(qǐng)求調(diào)用:
- public class BankService {
 - // 創(chuàng)建用戶
 - 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(' ', '_'));
 - // 數(shù)字靠右,多余的地方用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();
 - // 數(shù)字靠右
 - stringBuilder.append(String.format("%020d", userId));
 - // 金額向下舍入2位到分,以分為單位,作為數(shù)字靠右,多余的地方用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();
 - }
 - }
 
這段代碼的重復(fù)粒度更細(xì):
- 三種標(biāo)準(zhǔn)數(shù)據(jù)類型的處理邏輯有重復(fù)
 - 處理流程中字符串拼接、加簽和發(fā)請(qǐng)求的邏輯,在所有方法重復(fù)
 - 實(shí)際方法的入?yún)⒌膮?shù)類型和順序,不一定和接口要求一致,容易出錯(cuò)
 - 代碼層面針對(duì)每一個(gè)參數(shù)硬編碼,無(wú)法清晰地進(jìn)行核對(duì),如果參數(shù)達(dá)到幾十個(gè)、上百個(gè),出錯(cuò)的概率極大。
 
3.3 重構(gòu)秘技之注解&反射
針對(duì)銀行請(qǐng)求的所有邏輯均使用一套代碼實(shí)現(xiàn),不會(huì)出現(xiàn)任何重復(fù)。
要實(shí)現(xiàn)接口邏輯和邏輯實(shí)現(xiàn)的剝離,首先要以POJO類定義所有的接口參數(shù)。
創(chuàng)建用戶API的參數(shù)
- @Data
 - public class CreateUserAPI {
 - private String name;
 - private String identity;
 - private String mobile;
 - private int age;
 - }
 
有了接口參數(shù)定義,就能通過(guò)自定義注解為接口和所有參數(shù)增加一些元數(shù)據(jù)。
如下定義一個(gè)接口API的注解BankAPI,包含接口URL地址和接口說(shuō)明
再定義一個(gè)自定義注解@BankAPIField,描述接口的每一個(gè)字段規(guī)范,包含參數(shù)的次序、類型和長(zhǎng)度三個(gè)屬性:
定義CreateUserAPI類描述創(chuàng)建用戶接口的信息,通過(guò)為接口增加@BankAPI注解,來(lái)補(bǔ)充接口的URL和描述等元數(shù)據(jù);通過(guò)為每一個(gè)字段增加@BankAPIField注解,來(lái)補(bǔ)充參數(shù)的順序、類型和長(zhǎng)度等元數(shù)據(jù):
類似的還有PayAPI類
這2個(gè)類繼承的AbstractAPI類是一個(gè)空實(shí)現(xiàn),因?yàn)樵摪咐械慕涌跓o(wú)公共數(shù)據(jù)。
通過(guò)這倆類,即可在幾秒鐘內(nèi)完成和API清單表格的核對(duì)。若我們的核心翻譯過(guò)程(即把注解和接口API序列化為請(qǐng)求需要的字符串的過(guò)程)沒(méi)問(wèn)題,只要注解和表格一致,API請(qǐng)求翻譯就不會(huì)有問(wèn)題。
通過(guò)注解實(shí)現(xiàn)了對(duì)API參數(shù)的描述??捶瓷淙绾闻浜献⒔鈱?shí)現(xiàn)動(dòng)態(tài)的接口參數(shù)組裝:
- private static String remoteCall(AbstractAPI api) throws IOException {
 - // 從類上獲得BankAPI注解,然后拿到其URL屬性,后續(xù)進(jìn)行遠(yuǎn)程調(diào)用
 - BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
 - bankAPI.url();
 - StringBuilder stringBuilder = new StringBuilder();
 - // 使用stream快速實(shí)現(xiàn)獲取類中所有帶BankAPIField注解的字段,并把字段按order屬性排序,然后設(shè)置私有字段反射可訪問(wèn)。
 - Arrays.stream(api.getClass().getDeclaredFields()) //獲得所有字段
 - //查找標(biāo)記了注解的字段
 - .filter(field -> field.isAnnotationPresent(BankAPIField.class))
 - // 根據(jù)注解中的order對(duì)字段排序
 - .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order()))
 - .peek(field -> field.setAccessible(true)) //設(shè)置可以訪問(wèn)私有字段
 - .forEach(field -> {
 - // 實(shí)現(xiàn)了反射獲取注解的值,然后根據(jù)BankAPIField拿到的參數(shù)類型,按照三種標(biāo)準(zhǔn)進(jìn)行格式化,將所有參數(shù)的格式化邏輯集中在了這一處
 - // 獲得注解
 - BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class);
 - Object value = "";
 - try {
 - // 反射獲取字段值
 - value = field.get(api);
 - } catch (IllegalAccessException e) {
 - e.printStackTrace();
 - }
 - // 根據(jù)字段類型以正確的填充方式格式化字符串
 - 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;
 - }
 - });
 - // 實(shí)現(xiàn)參數(shù)加簽和請(qǐng)求調(diào)用
 - // 簽名邏輯stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
 - String param = stringBuilder.toString();
 - long begin = System.currentTimeMillis();
 - //發(fā)請(qǐng)求
 - String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url())
 - .bodyString(param, ContentType.APPLICATION_JSON)
 - .execute().returnContent().asString();
 - log.info("調(diào)用銀行API {} url:{} 參數(shù):{} 耗時(shí):{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin);
 - return result;
 - }
 
所有處理參數(shù)排序、填充、加簽、請(qǐng)求調(diào)用的核心邏輯,都匯聚在remoteCall。有這方法,BankService中每一個(gè)接口的實(shí)現(xiàn)就非常簡(jiǎn)單了,只是參數(shù)的組裝,然后調(diào)用remoteCall。
涉及類結(jié)構(gòu)性的通用處理,都可按照該模式減少重復(fù)代碼。
- 反射使得我們?cè)诓恢惤Y(jié)構(gòu)時(shí),按固定邏輯處理類成員
 - 注解給我們?yōu)檫@些成員補(bǔ)充元數(shù)據(jù)的能力,使得我們利用反射實(shí)現(xiàn)通用邏輯的時(shí)候,可以從外部獲得更多我們關(guān)心的數(shù)據(jù)
 
4 屬性拷貝
對(duì)于三層架構(gòu)系統(tǒng),層間解耦及每層對(duì)數(shù)據(jù)的不同需求,每層都會(huì)有自己的POJO實(shí)體。
手動(dòng)寫這些實(shí)體之間的賦值代碼,容易出錯(cuò)。對(duì)于復(fù)雜業(yè)務(wù)系統(tǒng),實(shí)體有幾十甚至幾百個(gè)屬性也很正常。比如ComplicatedOrderDTO,描述一個(gè)訂單中幾十個(gè)屬性。如果轉(zhuǎn)換為一個(gè)類似的DO,復(fù)制其中大部分的字段,然后把數(shù)據(jù)入庫(kù),勢(shì)必需要進(jìn)行很多屬性映射賦值操作。就像這樣,密密麻麻的代碼是不是已經(jīng)讓你頭暈了?
- 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()); //屬性錯(cuò)誤
 - orderDO.setComplainable(orderDTO.isCommentable()); //屬性錯(cuò)誤
 - 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()); //對(duì)象錯(cuò)誤
 - 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()); //屬性賦值錯(cuò)誤
 - 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個(gè)字段,我們需要復(fù)制90個(gè)字段到DO中,保留10個(gè)不賦值,最后應(yīng)該如何校驗(yàn)正確性呢?
- 數(shù)數(shù)嗎?即使數(shù)出有90行代碼,也不一定正確,因?yàn)閷傩钥赡苤貜?fù)賦值
 - 有時(shí)字段名相近,比如complainable和commentable,容易搞反
 - 對(duì)兩個(gè)目標(biāo)字段重復(fù)賦值相同的來(lái)源字段
 - 明明要把DTO的值賦值到DO中,卻在set的時(shí)候從DO自己取值,導(dǎo)致賦值無(wú)效
 
使用類似BeanUtils這種Mapping工具來(lái)做Bean的轉(zhuǎn)換,copyProperties方法還允許我們提供需要忽略的屬性:
5 總結(jié)
重復(fù)代碼多了總有一天會(huì)出錯(cuò)。
- 有多個(gè)并行的類實(shí)現(xiàn)相似的代碼邏輯
 - 考慮提取相同邏輯在父類中實(shí)現(xiàn),差異邏輯通過(guò)抽象方法留給子類實(shí)現(xiàn)。使用類似的模板方法把相同的流程和邏輯固定成模板,保留差異的同時(shí)盡可能避免代碼重復(fù)。同時(shí),可以使用Spring的IoC特性注入相應(yīng)的子類,來(lái)避免實(shí)例化子類時(shí)的大量if…else代碼。
 - 使用硬編碼的方式重復(fù)實(shí)現(xiàn)相同的數(shù)據(jù)處理算法
 - 考慮把規(guī)則轉(zhuǎn)換為自定義注解,作為元數(shù)據(jù)對(duì)類或?qū)ψ侄?、方法進(jìn)行描述,然后通過(guò)反射動(dòng)態(tài)讀取這些元數(shù)據(jù)、字段或調(diào)用方法,實(shí)現(xiàn)規(guī)則參數(shù)和規(guī)則定義的分離。也就是說(shuō),把變化的部分也就是規(guī)則的參數(shù)放入注解,規(guī)則的定義統(tǒng)一處理。
 - 業(yè)務(wù)代碼中常見(jiàn)的DO、DTO、VO轉(zhuǎn)換時(shí)大量字段的手動(dòng)賦值,遇到有上百個(gè)屬性的復(fù)雜類型,非常非常容易出錯(cuò)
 
不要手動(dòng)進(jìn)行賦值,考慮使用Bean映射工具進(jìn)行。此外,還可以考慮采用單元測(cè)試對(duì)所有字段進(jìn)行賦值正確性校驗(yàn)。
代碼重復(fù)度是評(píng)估一個(gè)項(xiàng)目質(zhì)量的重要指標(biāo),如果一個(gè)項(xiàng)目幾乎沒(méi)有任何重復(fù)代碼,那么它內(nèi)部抽象一定非常好。重構(gòu)時(shí),首要任務(wù)是消除重復(fù)。
參考
- 《重構(gòu)》
 - 搞定代碼重復(fù)的三個(gè)絕招
 - https://blog.csdn.net/qq_32447301/article/details/107774036
 




































 
 
 

 
 
 
 