【代碼質量】-如何避免寫過多的if-else語句,從青銅到鑽石級碼農是如何重構的?

前言:開篇先放一張大神寫的代碼,反正我看到這張圖第一反應就是佩服(畫質有點渣,不過就算是4K藍光我也看不懂裏面的邏輯)

如果在開發中寫出這樣一段代碼,那麼恭喜你,擁有鐵飯碗了,連技術總監都拿你沒辦法,你走了這代碼誰來維護?

玩笑歸玩笑,那麼到底該如何避免在寫代碼中出現大量的if-else以及控制If-else的層級數?

阿里巴巴的java技術開發手冊其實已經給出了答案,我這裏結合實際場景再深入梳理一二,同時再給出一些除此之外的替代解決方案.


先來看下業務場景:現有某收費系統,針對不同身份的用戶有不同的計費邏輯,比如用戶是普通用戶,則收取全額費用,用戶是vip用戶,則收取費用按照vip等級進行打折,等級越高,打折力度也越大.(具體的計費邏輯已被我簡化成這樣,方便理解,實際中會更復雜)

上面的場景如果用傳統的If-else來實現:

/**
 * 賬單
 */
@Data
@AllArgsConstructor
public class Bill {
    /**
     * 賬單類型 0:普通用戶賬單,1:vip1級賬單,2:vip2級賬單...
     */
    private Integer type;
    /**
     * 賬單總金額
     */
    private BigDecimal total;
}
public class BadExample {
    public void calcTotal(Bill bill){
        if (Objects.equals(bill.getTotal(),0)){
            //TODO 普通賬單計費邏輯
        }else if (Objects.equals(bill.getTotal(),1)){
            //TODO vip1級用戶賬單計費邏輯
        }else if (Objects.equals(bill.getTotal(),2)){
            //TODO vip2級用戶賬單計費邏輯
        }else if (Objects.equals(bill.getTotal(),3)){
            //TODO vip3級用戶賬單計費邏輯
        }
        //...
    }
}

代碼中會出現大量的if-else,如果裏面的計費邏輯很複雜,每一段計費邏輯裏又包含了很多層if-else,這片代碼看着就會很亂,難於維護,這時候我們就可以先嚐試去優化這段代碼了.

下面我們一起看看青銅,白銀,黃金,鑽石...段位的碼農是如何來優化的.

青銅:

先整個枚舉類(有些甚至枚舉類都不整,直接魔法值0,1,2,3...上陣),然後用switch case代替If,爲每一個計費邏輯封裝一個方法,然後在case中調用,這樣看的確代碼優雅了一些,但效果有限,而且不符合開閉原則,每當有新的計費邏輯時,就要加進switch中,再枚舉類中也新加類型.這塊的代碼比較簡單,我就不寫了,自行腦補.

白銀:

先寫一個計費的接口:

public interface CalcService {
    /**
     * 處理賬單計算
     */
    void handleCalc(BigDecimal total);
}

然後不同的計費邏輯用不同的實現類去實現此接口:

public class OridinaryCalcServiceImpl implements CalcService {
    @Override
    public void handleCalc(BigDecimal total) {
        System.out.println("處理普通用戶的賬單計費:" + total);
    }
}
public class Vip1CalcServiceImpl implements CalcService {
    @Override
    public void handleCalc(BigDecimal total) {
        System.out.println("處理vip1級用戶的賬單計費:"+total);
    }
}

...

然後把這些實現類統一用一個Map<Integer,CalcService>封裝起來,在調用時根據key(賬單類型Integer)來get對應的service,就能優雅的調用具體的實現類了,不錯,這樣代碼確實優雅了很多,但一旦有新的計費邏輯出現,開發人員不得不去維護這個Map,同樣違背了代碼設計的開閉原則.

黃金:

黃金的實現思路與白銀的類似,只不過黃金平時比較喜歡裝X,喜歡在代碼裏搞點高逼格的東西,利用Java8提供的Function函數式編程,來讓自己的代碼逼格看起來高一點?話不多說,先上代碼後解釋:

public class CalcFunction {
    private String calcOrdinary(Bill bill) {
        return "處理普通用戶的賬單計費:" + bill.getTotal();
    }

    private String calcVip1(Bill bill) {
        return "處理vip1級用戶的賬單計費:" + bill.getTotal();
    }

    private String calcVip2(Bill bill) {
        return "處理vip2級用戶的賬單計費:" + bill.getTotal();
    }

    public Function<Bill, String> getFunction(Integer type) {
        Function<Bill, String> ordinary = bill -> calcOrdinary(bill);
        Function<Bill, String> vip1 = bill -> calcVip1(bill);
        Function<Bill, String> vip2 = bill -> calcVip2(bill);
        Supplier<Map<Integer, Function<Bill, String>>> supplier = () -> {
            Map<Integer, Function<Bill, String>> map = new HashMap<>(3);
            map.put(BillType.ORDINARY.getType(), ordinary);
            map.put(BillType.VIP1.getType(), vip1);
            map.put(BillType.VIP2.getType(), vip2);
            return map;
        };
        return supplier.get().get(type);
    }
}
    @Test
    public void testFunctional() {
        CalcFunction calcFunction = new CalcFunction();

        Bill bill1 = new Bill(BillTypeEnum.ORDINARY.getType(), new BigDecimal(500));
        calcFunction.getFunction(bill1.getType()).apply(bill1);

        Bill bill2 = new Bill(BillTypeEnum.VIP1.getType(), new BigDecimal(400));
        calcFunction.getFunction(bill2.getType()).apply(bill2);

        Bill bill3 = new Bill(BillTypeEnum.VIP2.getType(), new BigDecimal(300));
        calcFunction.getFunction(bill3.getType()).apply(bill3);
    }

具體實現思路就是,先按照不同計費規則封裝具體的計費方法,然後把方法通過Supplier<?>的get提供給調用方,內部也維護了一個Map用來存放不同的function,黃金的實現看上去也挺優雅的,但還是不滿足開閉原則,所以阿里的解決方案裏並沒有提到這幾種方式.

相比白銀,黃金的實現方式比較裝X,但也不全是裝X,通過function接口提供的compose,andThen方法可以靈活地加入其它處理邏輯,所以勉強給評個黃金段位.

鑽石:

鑽石的思路其實就是策略模式+自定義註解+Springboot監聽.

先定義一個統一的計算賬單接口,然後不同的計費規則分別去實現此接口,不同的是,鑽石採用了自定義註解+Spring監聽的模式,在Spring上下文環境發生變化時(一般是Spring項目啓動完成),將這些加了註解的實現類統一自動注入到自定義的上下文中,交由Spring來管理,不需要再手動維護.需要時可以直接從上下文中取出對應的處理邏輯,看不懂沒關係,不妨先看代碼:

①定義統一的賬單處理接口

public interface CalcService {
    /**
     * 處理賬單計算
     */
    void handleCalc(BigDecimal total);
}

② 不同的計費邏輯分別去實現該接口中的handleCalc方法:

@BillTypeHandler(BillType.ORDINARY)
public class OridinaryCalcServiceImpl implements CalcService {
    @Override
    public void handleCalc(BigDecimal total) {
        System.out.println("處理普通用戶的賬單計費:" + total);
    }
}
@BillTypeHandler(BillType.VIP1)
public class Vip1CalcServiceImpl implements CalcService {
    @Override
    public void handleCalc(BigDecimal total) {
        System.out.println("處理vip1級用戶的賬單計費:"+total);
    }
}

省略vip3,vip4...代碼,與上面雷同

③定義自定義註解:

@Service
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface BillTypeHandler {
    BillType value();

    enum BillType {
        ORDINARY(0, "普通用戶"),
        VIP1(1, "一級會員"),
        VIP2(2, "二級會員");
        private Integer type;
        private String desc;

        BillType(Integer type, String desc) {
            this.type = type;
            this.desc = desc;
        }

        public Integer getType() {
            return type;
        }
    }
}

④創建自定義的上下文環境:

此上下文環境中維護了一個Map,用來存放CalcService的實現類,然後通過@Component交由Spring容器管理

@Component
public class BillServiceContext {
    @Getter
    private final static Map<Integer, CalcService> calcServiceMap;

    static {
        calcServiceMap = new HashMap<>();
    }

    public CalcService get(Integer type) {
        return calcServiceMap.get(type);
    }

    public void put(Integer type, CalcService calcService) {
        calcServiceMap.put(type, calcService);
    }
}

⑤定義Springboot的監聽器:

在項目啓動後用來處理自定義註解的邏輯,先拿到每個添加了自定義註解的類,然後通過反射拿到該類的自定義註解,再通過自定義註解中的值(計費類型type枚舉值),然後把該值put進④中自定義的那個上下文環境的Map中

@Component
public class BillTypeListener implements ApplicationListener<ContextRefreshedEvent> {
    @Resource
    private BillServiceContext billServiceContext;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        Map<String, CalcService> beans = contextRefreshedEvent.getApplicationContext().getBeansOfType(
            CalcService.class);
        beans.forEach((k, calcService) -> {
            Class clazz = calcService.getClass();
            BillTypeHandler billTypeHandler = (BillTypeHandler)clazz.getAnnotation(BillTypeHandler.class);
            billServiceContext.put(billTypeHandler.value().getType(), calcService);
        });
    }
}

⑥在需要調用的地方通過@Resource或者@Autowired註解將自定義的上下文環境注入,然後直接調用即可:

@SpringBootTest
@RunWith(SpringRunner.class)
public class BillTest {
    @Autowired
    BillServiceContext billServiceContext;

    @Test
    public void test() {
        Bill bill1 = new Bill(BillType.ORDINARY.getType(), new BigDecimal(500));
        billServiceContext.get(bill1.getType()).handleCalc(bill1.getTotal());

        Bill bill2 = new Bill(BillType.VIP1.getType(), new BigDecimal(400));
        billServiceContext.get(bill2.getType()).handleCalc(bill2.getTotal());

        Bill bill3 = new Bill(BillType.VIP2.getType(), new BigDecimal(300));
        billServiceContext.get(bill3.getType()).handleCalc(bill3.getTotal());
    }
}

測試結果符合預期:

這樣的代碼看着要比if-else清爽多了,而且在之後如果有新的計費邏輯進來,只需要新增實現類即可,無需改動原有代碼,符合開閉原則,調用也是十分簡單,後期維護會更方便.


白銀:這樣的代碼不香嗎?

塑料:真香!但我還是選擇用If-else,不整這些花裏胡哨5556的...

若干年過去了,白銀當上了CTO,塑料成爲了其下屬,儘管白銀對塑料各種嫌棄,一心想讓塑料拍屁股走人,但一想到塑料的這段拳皇代碼,還是算了...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章