动态

详情 返回 返回

好好的“代碼優化”是怎麼一步步變成“過度設計”的 - 动态 详情

有一天Review師妹的代碼,看到一行很難看的代碼,畢竟師妹剛開始轉JAVA,一些書寫小習慣還是要養成,所以錙銖必較還是有必要的,於是給出了一些優化思路的建議,以及為什麼要這麼做。建議完後,我並沒有停下”追求極致“的腳步,隨着不斷的思考,發現這段代碼的優化慢慢變得五花八門起來了,完成了一次“代碼優化”到“過度設計”的典型思考過程,這過程中涉及了很多Java的語法糖及設計模式的東西,很典型,能啓發思考,遂記錄下來。

一切的開始

起初是一段很簡單的代碼,開始僅僅是將外域的一些標識符轉換為域內的標識符。

public Integer parseSaleType(String saleTypeStr){
  if(saleTypeStr == null || saleTypeStr.equals("")){
    return null;
  }
  if(saleTypeStr.equals("JX")){
    return 1;
  }
  return null;
}

邏輯上很簡單,實現的邏輯看上去也沒啥大問題,基本學校的老師也是這麼教的。

語法規範

但是嘛,不好看也容易犯錯誤,雞蛋裏挑骨頭也得挑,於是給出了幾個寫代碼的建議:

有函數式方法的儘量用

//saleTypeStr == null
Objects.isNull(saleTypeStr)

首先呢,雖然由於判斷null這麼寫不會報錯,但是按照常量寫==前面的要求,應該倒過來寫。另外,這種有JDK原生函數式的判斷方法,還是優先使用函數式的寫法,一來是有方法名比較直觀,另外也是方便之後熟練使用Lamada,別寫出 .filter(x -> null == x) 這樣的寫法,還是 .filter(Objects::isNull) 更可讀些。

判斷字符串為空不要自己寫 容易漏邏輯,儘量使用現成的方法

//if(saleTypeStr == null || saleTypeStr.equals(""))
if(StringUtils.isBlank(saleTypeStr))

雖然原方法裏無論判不判斷空字符或者空格字符都不會影響最終方法的表徵,但是從第一行想表達的判斷“字符串是不是為空”這件事來看,這行並不能判斷“空格字符”存在的情況,所以詞不達意,另外也趁機強化記憶下isBlank和isEmpty的區別。

org.apache.commons.lang3裏有很多工具類,方法比較成熟邏輯也比較完整。

同理org.apache.commons.collections4.CollectionUtils還有一堆集合操作的工具。

equals判定,常量寫前面

//if(saleTypeStr.equals("JX"))
if("JX".equals(saleTypeStr))

雖然前面判斷過null,所以這裏並不會報空指針,但是但凡之後書寫前面漏了,這裏就開始報錯了。

少用魔法值,定義常量

private static final String JX_SALE_TYPE_STR = "JX";
private static final Integer JX_SALE_TYPE_INT = 1;

但凡同一個魔法值在多處用,就怕漏改,所以收束定義在常量下,至少能保證全局引用的統一性。

無狀態方法,可選擇定義為類靜態

//public Integer parseSaleType(String saleTypeStr)
public static Integer parseSaleType(String saleTypeStr)

方法本身跟所在類的實例對象狀態無關,且不會誘發線程安全問題,故符合被定義為static的條件,可先於對象創建在方法區,防止每個對象創建一次的時候,堆內存創建一次。

邏輯簡化

語法的問題強調完,就得再琢磨琢磨這段邏輯需不需要這麼多代碼來表述了,乍眼一看沒問題,但其實沒必要寫這麼多。

明確主體邏輯

判斷入參有效性 -> 處理核心邏輯 -> 缺省返回,其實這個方法的構建思路是非常標準且合乎常理的,思考習慣很好,只是在這個簡單的方法場景不免邏輯有些冗餘。

其實再看這個方法,最核心的邏輯就是把字符串對應到數字上,其他不命中的情況返回null就可以了,那麼簡化邏輯後,為空判定其實可以去掉,直接變為:

private static final String JX_SALE_TYPE_STR = "JX";
private static final Integer JX_SALE_TYPE_INT = 1;

public static Integer parseSaleType(String saleTypeStr){
  if(JX_SALE_TYPE_STR.equals(saleTypeStr)){
    return JX_SALE_TYPE_INT;
  }
  return null;
}

語法簡化:三元運算符

再仔細看下場景有沒有成熟的範式,【布爾式+返回值+非此即彼】,三元運算符可堪一用。

public static Integer parseSaleType(String saleTypeStr){
  return JX_SALE_TYPE_STR.equals(saleTypeStr) ? JX_SALE_TYPE_INT : null;
}

語法簡化:Optional

這個場景範式也滿足,【可能為空,有後續處理,有條件,有缺省值】,Optional也算完美契合。

public static Integer parseSaleType(String saleTypeStr){
  Optional.ofNullable(saleTypeStr).filter(JX_SALE_TYPE_STR::equals).map(o -> JX_SALE_TYPE_INT).orElse(null);
}

方法獨立存在的必要性討論

其實語法簡化到三元運算符和Optional這一步,如果一個方法體內只有這一行,這個方法獨立存在的必要性的就開始存疑了,如果所有的轉換流程都能收束在工程中的某個環節上,且保證這個方法的引用僅存在一處,那麼這一行代碼其實放在主幹代碼上更好,防止來回跳轉的代碼閲讀障礙,當然這也僅僅是在現狀下的討論,如果存在且不僅限於以下幾種狀況時還得獨立出來:

  • 未來除了一種邏輯分支外,還會擴展其他分支,並且有被擴展的可能;
  • 雖然還是一種邏輯分支,但是判斷的內容變長了,跟上下文和調用狀態有關;
  • 雖然還是一種邏輯分支,但是邏輯總在調整;
  • 一處定義,多點引用;

繼續拓展:定義枚舉

“如無必要,勿增實體”

假如這個傳入的字符其實還有很多種,返回的映射也有很多種的時候,其實在這裏繼續寫一堆常量定義就很不理智了。

值枚舉構建

考慮繼續將入參的所有可能和出參的所有可能,可以構建為兩組枚舉值,這樣所有的同簇常量就被放到一起了。

圖片

public enum SaleTypeStrEnum{
  JX,
  // OTHERS
  ;
}

@AllArgsConstructor
@Getter
public enum SaleTypeIntEnum{
  JX(1),
  // OTHERS
  ;
  private Integer code;
}

但是這個枚舉功能並不完善,因為從入參映射為SaleTypeStrEnum,依然需要一段轉換的邏輯,需要用到 SaleTypeStrEnum::name 來判定傳參命中了哪個,所以這個邏輯不應該放在枚舉外,繼續補充:

public enum SaleTypeStrEnum{
  JX,
  // OTHERS 
  ;
  public static SaleTypeStrEnum getByName(String saleTypeStr){
    for (SaleTypeStrEnum value : SaleTypeStrEnum.values()) {
      if(value.name().equals(saleTypeStr)){
        return value;
      }
    }
    return null;
  }
}

方法有了,但是每次傳進來值都要遍歷整個枚舉,O(n)效率太低了,還是老規矩,空間換時間。

public enum SaleTypeStrEnum{
  JX,
  // OTHERS
  ;
  
  /**
    * 預熱轉換關係到內存
    */
  private static Map<String, SaleTypeStrEnum> NAME_MAP = Arrays.stream(SaleTypeStrEnum.values()).collect(Collectors.toMap(SaleTypeStrEnum::name, Function.identity()));
    
  public static SaleTypeStrEnum getByName(String saleTypeStr){
    return NAME_MAP.get(saleTypeStr);
  }
}

這樣每次檢索就是O(1)了,那麼最終方法體內也能使用switch管理原本的if-else

public static Integer parseSaleType(String saleTypeStr){
  switch(SaleTypeStrEnum.getByName(saleTypeStr)){
    case JX:return SaleTypeIntEnum.JX.getCode();
    // OTHERS
    default:return null;
  }
}

關係枚舉構建

再仔細思考下,其實這裏在描述的內容,無論是哪個枚舉描述的內容都是同一件事物,方法本身就是描述兩個不同編碼的轉換關係,且轉換關係本身就是單向的,且映射路徑極度簡單,所以簡單化一點,可以直接構建轉換關係枚舉。

圖片

@Getter
@AllArgsConstructor
public enum SaleTypeRelEnum {
  // 不在分別定義兩類變量,而是直接定義變量映射關係
  JX("JX", 1),
  // OTHERS
  ;
  private String fromCode;
  private Integer toCode;

  private static Map<String, SaleTypeRelEnum> FROM_CODE_MAP = Arrays.stream(SaleTypeRelEnum.values()).collect(Collectors.toMap(SaleTypeRelEnum::getFromCode, Function.identity()));

  public static SaleTypeRelEnum get(String saleTypeStr){
    return FROM_CODE_MAP.get(saleTypeStr);
  }

  public static Integer parseCode(String saleTypeStr){
    return Optional.ofNullable(SaleTypeRelEnum.get(saleTypeStr)).map(SaleTypeRelEnum::getToCode).orElse(null);
  }
}

如果將轉關係作為枚舉,那麼從職責上劃分,轉換這個動作應該是封閉在枚舉內的固有行為,而不該暴露在外,故原來對方法的引用其實應該轉為對關係枚舉中 SaleTypeEnum::parseCode 方法的引用,O(1)檢索且封閉性良好,同時支持更多簡單單向映射關係的管理,要是以後出現的新場景都是這種關係,那夠扛很久嘞。

繼續拓展:設計模式

枚舉的前提還是基於無狀態前提,如果轉換的的映射關係不再單純,變得複雜,枚舉的簡單映射管理就不work了。

“萬事不決,上設計模式”

哎~就是玩兒~

策略模式-簡單實現

首先,依然將傳入的字符串作為路由依據,但是傳入的內容為了防止有未來擴展,所以構造一個上下文,策略本身基於上下文來處理,藉助上文定義的值枚舉做策略路由。

圖片

/**
  * 定義策略接口
  */
public interface SaleTypeParseStrategy{
  Integer parse(SaleTypeParseContext saleTypeParseContext);
}

/**
  * 策略實現
  */
public class JxSaleTypeParseStrategy implements SaleTypeParseStrategy{
  @Override
  public Integer parse(SaleTypeParseContext saleTypeParseContext) {
    return SaleTypeIntEnum.JX.getCode();
  }
}

/**
  * 調用上下文
  */
@Data
public class SaleTypeParseContext{
  private SaleTypeStrEnum saleTypeStr;
  
  private SaleTypeParseStrategy parseStrategy;
  
  public Integer pasre(){
    return parseStrategy.parse(this);
  }
}

public static Integer parseSaleType(String saleTypeStr){
  SaleTypeStrEnum saleTypeEnum = SaleTypeStrEnum.getByName(saleTypeStr);
  SaleTypeParseContext context = new SaleTypeParseContext();
  context.setSaleTypeStr(saleTypeEnum);
  switch(saleTypeStr){
    // 策略路由
    case JX:context.setParseStrategy(new JxSaleTypeParseStrategy());break;
    // 繼續擴展
    default:return null;
  }
  return context.parse();
}

當然,如果是這種沒有上下文強依賴的策略,無論是靜態單例還是Spring單例都會是一個不錯的選擇。SaleTypeParseContext本身可以繼續擴展內容和其他屬性繼續豐富參數,策略實現中也可以繼續針對更多參數擴充邏輯。

策略工廠-手動容器

策略是個好東西,但是簡單實現下,這裏依然將策略實現的路由過程交給了調用方來做,那麼每增加一種實現,調用點還要繼續改,要是恰好有若干調用點就完犢子了,並不優雅,所以搞箇中間層容器工廠,解耦一下依賴。

圖片

@Component
public static class SaleTypeParseStrategyContainer{
  public final static Map<SaleTypeStrEnum, SaleTypeParseStrategy> STRATEGY_MAP = new HashMap<>();

  @PostConstruct
  public void init(){
    STRATEGY_MAP.put(SaleTypeStrEnum.JX, new JxSaleTypeParseStrategy());
    // 繼續拓展
  }

  public Integer parse(SaleTypeParseContext saleTypeParseContext){
    return Optional.ofNullable(STRATEGY_MAP.get(saleTypeParseContext.getSaleTypeStr())).map(strategy-> strategy.parse(saleTypeParseContext)).orElse(null);
  }
}

容器內手動創建各個策略的實現的單例後進行託管,那調用方只需要去構建上下文就好了,實際調用的方法更換為 SaleTypeParseStrategyContainer::parse,那後續無論策略如何豐富,調用方都不需要再感知這部分變化。後續出現了新的策略實現,則在工廠內繼續追加路由表即可。

註冊與發現&策略工廠-Spring容器

如果考慮到策略會依賴Spring的bean和其他有狀態對象,那麼這裏也可以改成Spring的注入模式,同時繼續將“支持哪種情況”由託管方容器移動至策略內部,改成由策略實現自身去註冊到容器中。

圖片

public interface SaleTypeParseStrategy{
  Integer parse(SaleTypeParseContext saleTypeParseContext);
  // 所支持的情況
  SaleTypeStrEnum support();
}

@Component
public class JxSaleTypeParseStrategy implements SaleTypeParseStrategy{
  @Override
  public Integer parse(SaleTypeParseContext saleTypeParseContext) {
    return SaleTypeIntEnum.JX.getCode();
  }
  @Override
  public SaleTypeStrEnum support() {
    return SaleTypeStrEnum.JX;
  }
}

@Component
public static class SaleTypeParseStrategyContainer{
  public final static Map<SaleTypeStrEnum, SaleTypeParseStrategy> STRATEGY_MAP = new HashMap<>();
  @Autowired
  private List<SaleTypeParseStrategy> parseStrategyList;
  
  @PostConstruct
  public void init(){
    parseStrategyList.stream().forEach(strategy-> STRATEGY_MAP.put(strategy.support(), strategy));
  }
  public Integer parse(SaleTypeParseContext saleTypeParseContext){
    return Optional.ofNullable(STRATEGY_MAP.get(saleTypeParseContext.getSaleTypeStr())).map(strategy-> strategy.parse(saleTypeParseContext)).orElse(null);
  }
}

這樣的話,連容器都不用改了,追加策略實現的改動只與當前策略有關,調用方和容器類都不需要感知了,但是缺點就在於如果有倆策略支持的情況相同,取到的是哪個就聽天由命了~

註冊與發現&責任鏈

當然如果不能事先知道“支持哪種情況”,只能在運行時判斷“是否支持”,將事前判定改為運行時判定,廣義責任鏈會是一個不錯的選擇,把所有策略排成一排,誰舉手説自己能處理就誰處理。

圖片

public interface SaleTypeParseStrategy{
  Integer parse(SaleTypeParseContext saleTypeParseContext);
  // 用於判斷是否支持
  boolean support(SaleTypeParseContext saleTypeParseContext);
}

@Component
public class JxSaleTypeParseStrategy implements SaleTypeParseStrategy{
  @Override
  public Integer parse(SaleTypeParseContext saleTypeParseContext) {
    return SaleTypeIntEnum.JX.getCode();
  }
  @Override
  public boolean support(SaleTypeParseContext saleTypeParseContext) {
    return SaleTypeStrEnum.JX.equals(saleTypeParseContext.getSaleTypeStr());
  }
}

@Component
public static class SaleTypeParseStrategyContainer{
  @Autowired
  private List<SaleTypeParseStrategy> parseStrategyList;

  public Integer parse(SaleTypeParseContext saleTypeParseContext){
    return parseStrategyList.stream()
        .filter(strategy->strategy.support(saleTypeParseContext))
        .findAny()
        .map(strategy->strategy.parse(saleTypeParseContext))
        .orElse(null);
  }
}

這樣的實現,依然可以將改動收束在策略本體上,修改相對集中,可以無耦地進行擴展。

其他拓展

以上還只是在JAVA語言內去玩一些花樣,在當前這種場景下肯定是有過度設計的嫌疑,7行代碼可以縮到1行,也可以擴充到70行,所以説嘛:“用代碼行數來考量一個程序員是不太合適滴!~”當然了,也還可以繼續借助其他的中間件搞花樣,包括但不限於:

  • 植入Diamond走走動態配置開關的思路;
  • 植入QLExpress搞搞邏輯表達式的思路;
  • 把策略實現改成HsfProvider走分佈式調用思路;
  • 藉助一些成熟的網關走服務路由的的調用思路;

就不再此再過多展開了。

總結

筆記向的內容帖子,用於活躍思維打開思路,沒啥高科技~

作者|向知

原文鏈接

本文為阿里雲原創內容,未經允許不得轉載。

user avatar u_15745565 头像 ligaai 头像 eolink 头像 Johny-zhao 头像 python-learn 头像 tdengine 头像 liujiaxiaobao 头像 dcsjava 头像 aphysia 头像 kinfuy 头像 webweb 头像 wanlanqiudehuoche_ej0yz4 头像
点赞 12 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.