聊聊寫程式碼的20個反面教材

蘇三說技術發表於2022-02-08

前言

今天跟大家聊一個有趣的話題:如何寫出讓人抓狂的程式碼?

大家看到這個標題,第一印象覺得這篇文章可能是一篇水文。但我很負責的告訴你,它是一篇有很多幹貨的技術文。

曾幾何時,你在閱讀別人程式碼的時候,有沒有抓狂,想生氣,想發火的時候?

今天就跟大家一起聊聊,這20種我看了會抓狂的程式碼,看看你中招了沒?

1.不注重程式碼格式

程式碼格式說起來很虛,下面我用幾個案例演示一下,不注重程式碼格式的效果。作為這篇文章的開胃小菜吧。

1.1 空格

有時候必要的空格沒有加,比如:

@Service
@Slf4j
public class TestService1{
public void test1(){
addLog("test1");
 if (condition1){
 if (condition2){
 if (condition3){
 log.info("info:{}",info);
  }
  }
  }
}
}

你看了這段程式碼有何感想,有沒有血壓飆升的感覺?

程式碼好像揉到一起去了。

那麼,如何把血壓降下來呢?

答:加上空格即可。

正解:

@Service
@Slf4j
public class TestService1 {
    public void test1() {
       addLog("test1");
       if (condition1) {
         if (condition2) {
           if (condition3) {
               log.info("info:{}", info);
            }
          }
        }
    }
}

只加了一些空格,稍微調整了一下,這段程式碼的層次結構一下子變得非常清晰了。

好吧,我又冷靜下來了。

1.2 換行

寫程式碼時,如果有些必要的換行沒有加,可能會出現這樣的程式碼:

public void update(User user) {
    if (null != user.getId()) {
        User oldUser = userMapper.findUserById(user.getId());
        if(null == oldUser)throw new RuntimeException("使用者id不存在");
        oldUser.setName(user.getName());oldUser.setAge(user.getAge());oldUser.setAddress(user.getAddress());
        userMapper.updateUser(oldUser);
    } else { userMapper.insertUser(user);
    }
}

看了這段程式碼,是不是有點生無可戀的感覺?

簡單的加點空格優化一下:

public void update(User user) {
    if (null != user.getId()) {
        User oldUser = userMapper.findUserById(user.getId());
        if(null == oldUser) {
            throw new RuntimeException("使用者id不存在");
        }

        oldUser.setName(user.getName());
        oldUser.setAge(user.getAge());
        oldUser.setAddress(user.getAddress());
        userMapper.updateUser(oldUser);
    } else {
        userMapper.insertUser(user);
    }
}

程式碼邏輯一下子變得清晰了許多。

2.隨意的命名

java中沒有強制規定引數、方法、類或者包名該怎麼起名。但如果我們沒有養成良好的起名習慣,隨意起名的話,可能會出現很多奇怪的程式碼。

2.1 有意義的引數名

有時候,我們寫程式碼時為了省事(可以少敲幾個字母),引數名起得越簡單越好。假如同事A寫的程式碼如下:

int a = 1;
int b = 2;
String c = "abc";
boolean b = false;

一段時間之後,同事A離職了,同事B接手了這段程式碼。

他此時一臉懵逼,a是什麼意思,b又是什麼意思,還有c...然後心裡一萬個草泥馬。

給引數起一個有意義的名字,是非常重要的事情,避免給自己或者別人埋坑。

正解:

int supplierCount = 1;
int purchaserCount = 2;
String userName = "abc";
boolean hasSuccess = false;

2.2 見名知意

光起有意義的引數名還不夠,我們不能就這點追求。我們起的引數名稱最好能夠見名知意,不然就會出現這樣的情況:

String yongHuMing = "蘇三";
String 使用者Name = "蘇三";
String su3 = "蘇三";
String suThree = "蘇三";

這幾種引數名看起來是不是有點怪怪的?

為啥不定義成國際上通用的(地球人都能看懂)英文單詞呢?

String userName = "蘇三";
String susan = "蘇三";

上面的這兩個引數名,基本上大家都能看懂,減少了好多溝通成本。

所以建議在定義不管是引數名、方法名、類名時,優先使用國際上通用的英文單詞,更簡單直觀,減少溝通成本。少用漢子、拼音,或者數字定義名稱。

2.3 引數名風格一致

引數名其實有多種風格,列如:

//字母全小寫
int suppliercount = 1;

//字母全大寫
int SUPPLIERCOUNT = 1;

//小寫字母 + 下劃線
int supplier_count = 1;

//大寫字母 + 下劃線
int SUPPLIER_COUNT = 1;

//駝峰標識
int supplierCount = 1;

如果某個類中定義了多種風格的引數名稱,看起來是不是有點雜亂無章?

所以建議類的成員變數、區域性變數和方法引數使用supplierCount,這種駝峰風格,即:第一個字母小寫,後面的每個單詞首字母大寫。例如:

int supplierCount = 1;

此外,為了好做區分,靜態常量建議使用SUPPLIER_COUNT,即:大寫字母 + 下劃線分隔的引數名。例如:

private static final int SUPPLIER_COUNT = 1;

3.出現大量重複程式碼

ctrl + cctrl + v可能是程式設計師使用最多的快捷鍵了。

沒錯,我們是大自然的搬運工。哈哈哈。

在專案初期,我們使用這種工作模式,確實可以提高一些工作效率,可以少寫(實際上是少敲)很多程式碼。

但它帶來的問題是:會出現大量的程式碼重複。例如:

@Service
@Slf4j
public class TestService1 {

    public void test1()  {
        addLog("test1");
    }

    private void addLog(String info) {
        if (log.isInfoEnabled()) {
            log.info("info:{}", info);
        }
    }
}
@Service
@Slf4j
public class TestService2 {

    public void test2()  {
        addLog("test2");
    }

    private void addLog(String info) {
        if (log.isInfoEnabled()) {
            log.info("info:{}", info);
        }
    }
}
@Service
@Slf4j
public class TestService3 {

    public void test3()  {
        addLog("test3");
    }

    private void addLog(String info) {
        if (log.isInfoEnabled()) {
            log.info("info:{}", info);
        }
    }
}

在TestService1、TestService2、TestService3類中,都有一個addLog方法用於新增日誌。

本來該功能用得好好的,直到有一天,線上出現了一個事故:伺服器磁碟滿了。

原因是列印的日誌太多,記了很多沒必要的日誌,比如:查詢介面的所有返回值,大物件的具體列印等。

沒辦法,只能將addLog方法改成只記錄debug日誌。

於是乎,你需要全文搜尋,addLog方法去修改,改成如下程式碼:

private void addLog(String info) {
    if (log.isDebugEnabled()) {
        log.debug("debug:{}", info);
    }
}

這裡是有三個類中需要修改這段程式碼,但如果實際工作中有三十個、三百個類需要修改,會讓你非常痛苦。改錯了,或者改漏了,都會埋下隱患,把自己坑了。

為何不把這種功能的程式碼提取出來,放到某個工具類中呢?

@Slf4j
public class LogUtil {

    private LogUtil() {
        throw new RuntimeException("初始化失敗");
    }

    public static void addLog(String info) {
        if (log.isDebugEnabled()) {
            log.debug("debug:{}", info);
        }
    }
}

然後,在其他的地方,只需要呼叫。

@Service
@Slf4j
public class TestService1 {

    public void test1()  {
        LogUtil.addLog("test1");
    }
}

如果哪天addLog的邏輯又要改了,只需要修改LogUtil類的addLog方法即可。你可以自信滿滿的修改,不需要再小心翼翼了。

我們寫的程式碼,絕大多數是可維護性的程式碼,而非一次性的。所以,建議在寫程式碼的過程中,如果出現重複的程式碼,儘量提取成公共方法。千萬別因為專案初期一時的爽快,而給專案埋下隱患,後面的維護成本可能會非常高。

4.從不寫註釋

有時候,在專案時間比較緊張時,很多人為了快速開發完功能,在寫程式碼時,經常不喜歡寫註釋。

此外,還有些技術書中說過:好的程式碼,不用寫註釋,因為程式碼即註釋。這也給那些不喜歡寫程式碼註釋的人,找了一個合理的理由。

但我個人覺得,在國內每個程式設計師的英文水平都不一樣,思維方式和編碼習慣也有很大區別。你要把前人某些複雜的程式碼邏輯真正搞懂,可能需要花費大量的時間。

我們看到spring的核心方法refresh,也是加了很多註釋的:

public void refresh() throws BeansException, IllegalStateException {
		synchronized (this.startupShutdownMonitor) {
			// Prepare this context for refreshing.
			prepareRefresh();

			// Tell the subclass to refresh the internal bean factory.
			ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

			// Prepare the bean factory for use in this context.
			prepareBeanFactory(beanFactory);

			try {
				// Allows post-processing of the bean factory in context subclasses.
				postProcessBeanFactory(beanFactory);

				// Invoke factory processors registered as beans in the context.
				invokeBeanFactoryPostProcessors(beanFactory);

				// Register bean processors that intercept bean creation.
				registerBeanPostProcessors(beanFactory);

				// Initialize message source for this context.
				initMessageSource();

				// Initialize event multicaster for this context.
				initApplicationEventMulticaster();

				// Initialize other special beans in specific context subclasses.
				onRefresh();

				// Check for listener beans and register them.
				registerListeners();

				// Instantiate all remaining (non-lazy-init) singletons.
				finishBeanFactoryInitialization(beanFactory);

				// Last step: publish corresponding event.
				finishRefresh();
			}

			catch (BeansException ex) {
				if (logger.isWarnEnabled()) {
					logger.warn("Exception encountered during context initialization - " +
							"cancelling refresh attempt: " + ex);
				}

				// Destroy already created singletons to avoid dangling resources.
				destroyBeans();

				// Reset 'active' flag.
				cancelRefresh(ex);

				// Propagate exception to caller.
				throw ex;
			}

			finally {
				// Reset common introspection caches in Spring's core, since we
				// might not ever need metadata for singleton beans anymore...
				resetCommonCaches();
			}
		}
	}

如果你寫的程式碼完全不寫註釋,可能最近一個月、三個月、半年還記得其中的邏輯。但一年、兩年,甚至更久的時間之後,你確定還能想起當初的邏輯,而不需要花費大量的時間去重新看自己的程式碼梳理邏輯?

說實話,不寫註釋,到了專案後期,不光是把自己坑了,還會坑隊友。

為什麼把這一條單獨拿出來?

因為我遇到過,接過鍋,被坑慘了。

5.方法過長

我們平時在寫程式碼時,有時候思路來了,一氣呵成,很快就把功能開發完了。但也可能會帶來一個小問題,就是方法過長。

虛擬碼如下:

public void run() {
    List<User> userList = userMapper.getAll();
    //經過一系列的資料過濾
    //此處省略了50行程式碼
    List<User> updateList = //最終獲取到user集合
   
    if(CollectionUtils.isEmpty(updateList)) {
      return;
    }
    for(User user: updateList) {
       //經過一些複雜的過期時間計算
       //此處省略30行程式碼
    }
    
    //分頁更新使用者的過期時間
    //此處省略20行程式碼
    
    //發mq訊息通知使用者
    //此處省略30行程式碼
}

上面的run方法中包含了多種業務邏輯,雖說確實能夠實現完整的業務功能,但卻不能稱之為好。

為什麼呢?

答:該方法總長度超過150行,裡面的程式碼邏輯很雜亂,包含了很多關聯性不大的程式碼塊。該方法的職責太不單一了,非常不利於程式碼複用和後期的維護。

那麼,如何優化呢?

答:做方法拆分,即把一個大方法拆分成多個小方法。

例如:

public void run() {
    List<User> userList = userMapper.getAll();
    List<User> updateList = filterUser(userList);
    
    if(CollectionUtils.isEmpty(updateList)) {
      return;
    }
   
    for(User user: updateList) {
        clacExpireDay(user);
    }
    
   updateUser(updateList);
   sendMq(updateList); 
}


private List<User> filterUser(List<User> userList) {
    //經過一系列的資料過濾
    //此處省略了50行程式碼
    List<User> updateList = //最終獲取到user集合
    return updateList;
}

private void clacExpireDay(User user) {
    //經過一些複雜的過期時間計算
    //此處省略30行程式碼
}

private void updateUser(List<User> updateList) {
    //分頁更新使用者的過期時間
    //此處省略20行程式碼
}

private void sendMq(List<User> updateList) {
    //發mq訊息通知使用者
    //此處省略30行程式碼
}

這樣簡單的優化之後,run方法的程式碼邏輯一下子變得清晰了許多,光看它呼叫的子方法的名字,都能猜到這些字方法是幹什麼的。

每個子方法只專注於自己的事情,別的事情交給其他方法處理,職責更單一了。

此外,如果此時業務上有一個新功能,也需要給使用者發訊息,那麼上面定義的sendMq方法就能被直接呼叫了。豈不是爽歪歪?

換句話說,把大方法按功能模組拆分成N個小方法,更有利於程式碼的複用。

順便說一句,Hotspot對位元組碼超過8000位元組的大方法有JIT編譯限制,超過了限制不會被編譯。

6.引數過多

我們平常在定義某個方法時,可能並沒注意引數個數的問題(其實是我猜的)。我的建議是方法的引數不要超過5個。

先一起看看下面的例子:

public void fun(String a,
              String b,
              String c,
              String d,
              String e,
              String f) {
   ...
}

public void client() {
   fun("a","b","c","d",null,"f");
}

上面的fun方法中定義了6個引數,這樣在呼叫該方面的所有地方都需要思考一下,這些引數該怎麼傳值,哪些引數可以為空,哪些引數不能為空。

方法的入參太多,也會導致該方法的職責不單一,方法存在風險的概率更大。

那麼,如何優化引數過多問題呢?

答:可以將一部分引數遷移到新方法中。

這個例子中,可以把引數d,e,f遷移到otherFun方法。例如:

public Result fun(String a,
              String b,
              String c) {
   ...
   return result;
}

public void otherFun(Result result,
              String d,
              String e,
              String f) {
         ...     
}

public void client() {
   Result result = fun("a","b","c");
   otherFun(result, "d", null, "f");
}

這樣優化之後,每個方法的邏輯更單一一些,更有利於方法的複用。

如果fun中還需要返回引數a、b、c,給下個方法繼續使用,那麼程式碼可以改為:

public Result fun(String a,
              String b,
              String c) {
   ...
   Result result = new Result();
   result.setA(a);
   result.setB(b);
   result.setC(c);
   return result;
}

在給Result物件賦值時,這裡有個小技巧,可以使用lombok@Builder註解,做成鏈式呼叫。例如:

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class Result {

    private String a;
    private String b;
    private String c;
}

這樣在呼叫的地方,可以這樣賦值:

Result result = Result.builder()
.a("a").b("b").c("c")
.build();

非常直觀明瞭。

此時,有人可能會說,ThreadPoolExecutor不也提供了7個引數的方法?

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
     ...                     
}

沒錯,不過它是構造方法,我們這裡主要討論的是普通方法

7.程式碼層級太深

不知道你有沒有見過類似這樣的程式碼:

if (a == 1) {
   if(b == 2) {
      if(c == 3) {
         if(d == 4) {
            if(e == 5) {
              ...
            }
            ...
         }
         ...
      }
      ...
   }
   ...
}

這段程式碼中有很多層if判斷,是不是看得人有點眼花繚亂?

有同感的同學,請舉個手。

如果你沒啥感覺,那麼接著往下看:

for(int i=0; i<100;i++) {
   for(int j=0; j<50;j++) {
      for(int m=0; m<200;m++) {
         for(int n=0; n<100;n++) {
             for(int k=0; k<50; k++) {
                ...
             }
         }
      }
   }
}

看了這段程式碼,你心中可能會一緊。這麼多迴圈,程式碼的效能真的好嗎?

這兩個例子中的程式碼都犯了同一個錯誤,即:程式碼層級太深

程式碼層級太深導致的問題是程式碼變得非常不好維護,不容易理清邏輯,有時候程式碼的效能也可能因此變差。

那麼關鍵問題來了,如何解決程式碼層級較深的問題呢?

對於if判斷層級比較多的情況:

if(a!=1) {
   ...
   return;
}

doConditionB();
private void doConditionB() {
   if(b!=2) {
      ...
      return;
   }
   doConditionC();
}

把不滿足條件(a1)的邏輯先執行,先返回。再把滿足條件(a1)的邏輯單獨抽取到一個方法(doConditionB)中。該doConditionB中也會把不滿足條件(b2)的邏輯先執行,先返回。再把滿足條件(b2)的邏輯單獨抽取到一個方法(doConditionC)中。後面邏輯以此類推。

這種做法是面向防禦式程式設計的一種,即先把不滿足條件的程式碼先執行,然後才執行滿足條件的程式碼。此外別忘了,把滿足條件的程式碼抽取到一個新的方法中喔。

對於for迴圈層級太深的優化方案,一般推薦使用map

例如:

for(Order order:orderList) {
   for(OrderDetail detail: detailList) {
      if(order.getId().equals(detail.getOrderId())) {
          doSamething();
      }
   }
}

使用map優化之後:

Map<Long, List<OrderDetail>> detailMap =  detailList.stream().collect(Collectors.groupingBy(OrderDetail::getOrderId));

for(Order order:orderList) {
   List<OrderDetail> detailList = detailMap.get(order.getId());
   if(CollectionUtils.isNotEmpty) {
      doSamething();
   }
}

這個例子中使用map,少了一層迴圈,程式碼效率提升一些。但不是所有的for迴圈都能用map替代,要根據自己實際情況選擇。

程式碼層級太深,還有其他的場景,比如:方法中return的次數太多,也會降低程式碼的可讀性。

這種情況,其實也可能通過面向防禦式程式設計進行程式碼優化。

8.判斷條件太多

我們在寫程式碼的時候,判斷條件是必不可少的。不同的判斷條件,走的程式碼邏輯通常會不一樣。

廢話不多說,先看看下面的程式碼。

public interface IPay {  
    void pay();  
}  

@Service
public class AliaPay implements IPay {  
     @Override
     public void pay() {  
        System.out.println("===發起支付寶支付===");  
     }  
}  

@Service
public class WeixinPay implements IPay {  
     @Override
     public void pay() {  
         System.out.println("===發起微信支付===");  
     }  
}  
  
@Service
public class JingDongPay implements IPay {  
     @Override
     public void pay() {  
        System.out.println("===發起京東支付===");  
     }  
}  

@Service
public class PayService {  
     @Autowired
     private AliaPay aliaPay;  
     @Autowired
     private WeixinPay weixinPay;  
     @Autowired
     private JingDongPay jingDongPay;  
    
   
     public void toPay(String code) {  
         if ("alia".equals(code)) {  
             aliaPay.pay();  
         } elseif ("weixin".equals(code)) {  
              weixinPay.pay();  
         } elseif ("jingdong".equals(code)) {  
              jingDongPay.pay();  
         } else {  
              System.out.println("找不到支付方式");  
         }  
     }  
}

PayService類的toPay方法主要是為了發起支付,根據不同的code,決定呼叫用不同的支付類(比如:aliaPay)的pay方法進行支付。

這段程式碼有什麼問題呢?也許有些人就是這麼幹的。

試想一下,如果支付方式越來越多,比如:又加了百度支付、美團支付、銀聯支付等等,就需要改toPay方法的程式碼,增加新的else...if判斷,判斷多了就會導致邏輯越來越多?

很明顯,這裡違法了設計模式六大原則的:開閉原則 和 單一職責原則。

開閉原則:對擴充套件開放,對修改關閉。就是說增加新功能要儘量少改動已有程式碼。

單一職責原則:顧名思義,要求邏輯儘量單一,不要太複雜,便於複用。

那麼,如何優化if...else判斷呢?

答:使用 策略模式+工廠模式

策略模式定義了一組演算法,把它們一個個封裝起來, 並且使它們可相互替換。
工廠模式用於封裝和管理物件的建立,是一種建立型模式。

public interface IPay {
    void pay();
}

@Service
public class AliaPay implements IPay {

    @PostConstruct
    public void init() {
        PayStrategyFactory.register("aliaPay", this);
    }


    @Override
    public void pay() {
        System.out.println("===發起支付寶支付===");
    }
}

@Service
public class WeixinPay implements IPay {

    @PostConstruct
    public void init() {
        PayStrategyFactory.register("weixinPay", this);
    }

    @Override
    public void pay() {
        System.out.println("===發起微信支付===");
    }
}

@Service
public class JingDongPay implements IPay {

    @PostConstruct
    public void init() {
        PayStrategyFactory.register("jingDongPay", this);
    }

    @Override
    public void pay() {
        System.out.println("===發起京東支付===");
    }
}

public class PayStrategyFactory {

    private static Map<String, IPay> PAY_REGISTERS = new HashMap<>();


    public static void register(String code, IPay iPay) {
        if (null != code && !"".equals(code)) {
            PAY_REGISTERS.put(code, iPay);
        }
    }

    public static IPay get(String code) {
        return PAY_REGISTERS.get(code);
    }
}

@Service
public class PayService3 {

    public void toPay(String code) {
        PayStrategyFactory.get(code).pay();
    }
}

這段程式碼的關鍵是PayStrategyFactory類,它是一個策略工廠,裡面定義了一個全域性的map,在所有IPay的實現類中註冊當前例項到map中,然後在呼叫的地方通過PayStrategyFactory類根據code從map獲取支付類例項即可。

如果加了一個新的支付方式,只需新加一個類實現IPay介面,定義init方法,並且重寫pay方法即可,其他程式碼基本上可以不用動。

當然,消除又臭又長的if...else判斷,還有很多方法,比如:使用註解、動態拼接類名稱、模板方法、列舉等等。由於篇幅有限,在這裡我就不過多介紹了,更詳細的內容可以看看我的另一篇文章《消除if...else是9條錦囊妙計

9.硬編碼

不知道你有沒有遇到過這類需求:

  1. 限制批量訂單上傳介面,一次性只能上傳200條資料。
  2. 在job中分頁查詢使用者,一頁查詢100個使用者,然後計算使用者的等級。

上面例子中的200條資料和100個使用者,很容易硬編碼,即在程式碼中把引數寫死了。

我們以上傳200條資料為例:

private static final int MAX_LIMIT = 200;

public void upload(List<Order> orderList) {
   if(CollectionUtils.isEmpty(orderList)) {
     throw new BusinessException("訂單不能為空");
   } 
   if(orderList.size() > MAX_LIMIT) {
      throw new BusinessException("超過單次請求的數量限制");
   }
}

其中MAX_LIMIT被定義成了靜態常量

上線之後,你發現上傳歷史資料時速度太慢了,需要把限制調大一點。

我擦。。。這種小小的引數改動,還需要改原始碼,重新編譯,重新打包,重新部署。。。

但如果你當初把這些公共引數,設定成可配置的,例如:

@Value("${com.susan.maxLimit:200}")
private int maxLimit = 200;

public void upload(List<Order> orderList) {
   if(CollectionUtils.isEmpty(orderList)) {
     throw new BusinessException("訂單不能為空");
   } 
   if(orderList.size() > maxLimit) {
      throw new BusinessException("超過單次請求的數量限制");
   }
}

這樣只需在配置中心(比如:apollo、nocas等)中修改一下配置即可,不用修改原始碼,不用重新編譯,不用重新打包,不用重新部署。

一個字:爽。

我們在前期開發的時候,寧可多花一分鐘思考一下,這個引數後面是否會被修改,是否可以定義成可配置的引數。也比後期修改程式碼,重新編譯,重新打包,重新上線花的時間少得多。

10.事務過大

我們平時在使用spring框架開發專案時,喜歡用@Transactional註解宣告事務。例如:

@Transactional(rollbackFor = Throwable.class)
public void updateUser(User user) {
    System.out.println("update");
}

只需在需要使用事務的方法上,使用@Transactional註解宣告一下,該方法通過AOP就自動擁有了事務的功能。

沒錯,這種做法給我們帶來了極大的便利,開發效率更高了。

但也給我們帶來了很多隱患,比如大事務的問題。我們一起看看下面的這段程式碼:

@Transactional(rollbackFor = Throwable.class)
public void updateUser(User user) {
    User oldUser = userMapper.getUserById(user.getId());
    if(null != oldUser) {
       userMapper.update(user);
    } else {
       userMapper.insert(user);
    }
    sendMq(user);
}

這段程式碼中getUserById方法和sendMq方法,在這個案例中無需使用事務,只有update或insert方法才需要事務。

所以上面這段程式碼的事務太大了,是整個方法級別的事務。假如sendMq方法是一個非常耗時的操作,則可能會導致整個updateUser方法的事務超時,從而出現大事務問題。

那麼,如何解決這個問題呢?

答:可以使用TransactionTemplate的程式設計式事務優化程式碼。

@Autowired
private TransactionTemplate transactionTemplate;
   ....
   
public void updateUser(User user) {
    User oldUser = userMapper.getUserById(user.getId());
    
    transactionTemplate.execute((status) => {
        if(null != oldUser) {
           userMapper.update(user);
        } else {
           userMapper.insert(user);
        }
        return Boolean.TRUE;
     })

    sendMq(user);
}

只有在execute方法中的程式碼塊才真正需要事務,其餘的方法,可以非事務執行,這樣就能縮小事務的範圍,避免大事務。

當然使用TransactionTemplate這種程式設計式事務,縮小事務範圍,來解決大事務問題,只是其中一種手段。

如果你想對大事務問題,有更深入的瞭解,可以看看我的另一篇文章《讓人頭痛的大事務問題到底要如何解決?

11.在迴圈中遠端呼叫

有時候,我們需要在某個介面中,遠端呼叫第三方的某個介面。

比如:在註冊企業時,需要呼叫天眼查介面,查一下該企業的名稱和統一社會信用程式碼是否正確。

這時候在企業註冊介面中,不得不先呼叫天眼查介面校驗資料。如果校驗失敗,則直接返回。如果校驗成功,才允許註冊。

如果只是一個企業還好,但如果某個請求有10個企業需要註冊,是不是要在企業註冊介面中,迴圈呼叫10次天眼查介面才能判斷所有企業是否正常呢?

public void register(List<Corp> corpList) {
  for(Corp corp: corpList) {
      CorpInfo info = tianyanchaService.query(corp);  
      if(null == info) {
         throw new RuntimeException("企業名稱或統一社會信用程式碼不正確");
      }
  }
  doRegister(corpList);
}

這樣做可以,但會導致整個企業註冊介面效能很差,極容易出現介面超時問題。

那麼,如何解決這類在迴圈中呼叫遠端介面的問題呢?

11.1 批量操作

遠端介面支援批量操作,比如天眼查支援一次性查詢多個企業的資料,這樣就無需在迴圈中查詢該介面了。

但實際場景中,有些第三方不願意提供第三方介面。

11.2 併發操作

java8以後通過CompleteFuture類,實現多個執行緒查天眼查介面,並且把查詢結果統一彙總到一起。

具體用法我就不展開了,有興趣的朋友可以看看我的另一篇文章《聊聊介面效能優化的11個小技巧

12.頻繁捕獲異常

通常情況下,為了在程式中丟擲異常時,任然能夠繼續執行,不至於中斷整個程式,我們可以選擇手動捕獲異常。例如:

public void run() {
    try {
        doSameThing();
    } catch (Exception e) {
        //ignore
    }
    doOtherThing();
}

這段程式碼可以手動捕獲異常,保證即使doSameThing方法出現了異常,run方法也能繼續執行完。

但有些場景下,手動捕獲異常被濫用了。

12.1 濫用場景1

不知道你在列印異常日誌時,有沒有寫過類似這樣的程式碼:

public void run() throws Exception {
    try {
        doSameThing();
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        throw e;
    }
    doOtherThing();
}

通過try/catch關鍵字,手動捕獲異常的目的,僅僅是為了記錄錯誤日誌,在接下來的程式碼中,還是會把該異常丟擲。

在每個丟擲異常的地方,都捕獲一下異常,列印日誌。

12.2 濫用場景2

在寫controller層介面方法時,為了保證介面有統一的返回值,你有沒有寫過類似這樣的程式碼:

@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    try {
        List<User> userList = userService.query(ids);
        return Result.ok(userList);
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        return Result.fature(500, "伺服器內部錯誤");
    }
}

在每個controller層的介面方法中,都加上了上面這種捕獲異常的邏輯。

上述兩種場景中,頻繁的捕獲異常,會讓程式碼效能降低,因為捕獲異常是會消耗效能的。

此外,這麼多重複的捕獲異常程式碼,看得讓人頭疼。

其實,我們還有更好的選擇。在閘道器層(比如:zuul或gateway),有個統一的異常處理程式碼,既可以列印異常日誌,也能統一封裝介面返回值,這樣可以減少很多異常被濫用的情況。

13.不正確的日誌列印

在我們寫程式碼的時候,列印日誌是必不可少的工作之一。

因為日誌可以幫我們快速定位問題,判斷程式碼當時真正的執行邏輯。

但列印日誌的時候也需要注意,不是說任何時候都要列印日誌,比如:

@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    log.info("request params:{}", ids);
    List<User> userList = userService.query(ids);
    log.info("response:{}", userList);
    return userList;
}

對於有些查詢介面,在日誌中列印出了請求引數和介面返回值。

咋一看沒啥問題。

但如果ids中傳入值非常多,比如有1000個。而該介面被呼叫的頻次又很高,一下子就會列印大量的日誌,用不了多久就可能把磁碟空間打滿。

如果真的想列印這些日誌該怎麼辦?

@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    if (log.isDebugEnabled()) {
        log.debug("request params:{}", ids);
    }

    List<User> userList = userService.query(ids);

    if (log.isDebugEnabled()) {
        log.debug("response:{}", userList);
    }
    return userList;
}

使用isDebugEnabled判斷一下,如果當前的日誌級別是debug才列印日誌。生產環境預設日誌級別是info,在有些緊急情況下,把某個介面或者方法的日誌級別改成debug,列印完我們需要的日誌後,又調整回去。

方便我們定位問題,又不會產生大量的垃圾日誌,一舉兩得。

14.沒校驗入參

引數校驗是介面必不可少的功能之一,一般情況下,提供給第三方呼叫的介面,需要做嚴格的引數校驗。

以前我們是這樣校驗引數的:

@PostMapping("/add")
public void add(@RequestBody User user) {
    if(StringUtils.isEmpty(user.getName())) {
        throw new RuntimeException("name不能為空");
    }
    if(null != user.getAge()) {
        throw new RuntimeException("age不能為空");
    }
    if(StringUtils.isEmpty(user.getAddress())) {
        throw new RuntimeException("address不能為空");
    }
    userService.add(user);
}

需要手動寫校驗的程式碼,如果作為入參的實體中欄位非常多,光是寫校驗的程式碼,都需要花費大量的時間。而且這些校驗程式碼,很多都是重複的,會讓人覺得噁心。

好訊息是使用了hibernate的引數校驗框架validate之後,引數校驗一下子變得簡單多了。

我們只需要校驗的實體類User中使用validation框架的相關注解,比如:@NotEmpty、@NotNull等,定義需要校驗的欄位即可。

@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {
    
    private Long id;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @NotEmpty
    private String address;
}

然後在controller類上加上@Validated註解,在介面方法上加上@Valid註解。

@Slf4j
@Validated
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/add")
    public void add(@RequestBody @Valid User user) {
        userService.add(user);
    }
}

這樣就能自動實現引數校驗的功能。

然而,現在需求改了,需要在User類上增加了一個引數Role,它也是必填欄位,並且它的roleName和tag欄位都不能為空。

但如果我們在校驗引數時,不小心把程式碼寫成這樣:

@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {

    private Long id;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @NotEmpty
    private String address;
    @NotNull
    private Role role;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {

    @NotEmpty
    private String roleName;
    @NotEmpty
    private String tag;
}

結果就悲劇了。

你心裡可能還樂呵呵的認為寫的程式碼不錯,但實際情況是,roleName和tag欄位根本不會被校驗到。

如果傳入引數:

{
  "name": "tom",
  "age":1,
  "address":"123",
  "role":{}
}

即使role欄位傳入的是空物件,但該介面也會返回成功。

那麼如何解決這個問題呢?

@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {

    private Long id;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @NotEmpty
    private String address;
    @NotNull
    @Valid
    private Role role;
}

需要在Role欄位上也加上@Valid註解。

溫馨的提醒一聲,使用validate框架校驗引數一定要自測,因為很容易踩坑。

15.返回值格式不統一

我之前對接某個第三方時,他們有部分介面的返回值結構是這樣的:

{
   "ret":0,
   "message":null,
   "data":[]
}

另一部分介面的返回值結構是這樣的:

{
   "code":0,
   "msg":null,
   "success":true,
   "result":[]
}

整得我有點懵逼。

為啥沒有一個統一的返回值?

我需要給他們的介面寫兩套返回值解析的程式碼,後面其他人看到了這些程式碼,可能也會心生疑問,為什麼有兩種不同的返回值解析?

唯一的解釋是一些介面是新專案的,另外一些介面是老專案的。

但如果不管是新專案,還是老專案,如果都有一個統一的對外閘道器服務,由這個服務進行鑑權和統一封裝返回值。

{
   "code":0,
   "message":null,
   "data":[]
}

就不會有返回值結構不一致的問題。

溫馨的提醒一下,業務服務不要捕獲異常,直接把異常拋給閘道器服務,由它來統一全域性捕獲異常,這樣就能統一異常的返回值結構。

16.提交到git的程式碼不完整

我們寫完程式碼之後,把程式碼提交到gitlab上,也有一些講究。

最最忌諱的是程式碼還沒有寫完,因為趕時間(著急下班),就用git把程式碼提交了。例如:

public void test() {
   String userName="蘇三";
   String password=
}

這段程式碼中的password變數都沒有定義好,專案一執行起來必定報錯。

這種錯誤的程式碼提交方式,一般是新手會犯。但還有另一種情況,就是在多個分支merge程式碼的時候,有時候會出問題,merge之後的程式碼不能正常執行,就被提交了。

好的習慣是:用git提交程式碼之前,一定要在本地執行一下,確保專案能正常啟動才能提交。

寧可不提交程式碼到遠端倉庫,切勿因為一時趕時間,提交了不完整的程式碼,導致團隊的隊友們專案都啟動不了。

17.不處理沒用的程式碼

有些時候,我們為了偷懶,對有些沒用的程式碼不做任何處理。

比如:

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public void add(User user) {
        System.out.println("add");
    }

    public void update(User user) {
        System.out.println("update");
    }

    public void query(User user) {
        System.out.println("query");
    }
}

本來UserService類中的add、update、query方法都在用的。後來,某些功能砍掉了,現在只有add方法真正在用。

某一天,專案組來了一個新人,接到需求需要在user表加一個欄位,這時候他是不是要把add、update、query方法都仔細看一遍,評估一下影響範圍?

後來發現只有add方法需要改,他心想前面的開發者為什麼不把沒用的程式碼刪掉,或者標記出來呢?

在java中可以使用@Deprecated表示這個類或者方法沒在使用了,例如:

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public void add(User user) {
        System.out.println("add");
    }

    @Deprecated
    public void update(User user) {
        System.out.println("update");
    }

    @Deprecated
    public void query(User user) {
        System.out.println("query");
    }
}

我們在閱讀程式碼時,可以先忽略標記了@Deprecated註解的方法。這樣一個看似簡單的舉手之勞,可以給自己,或者接手該程式碼的人,節省很多重複查程式碼的時間。

建議我們把沒用的程式碼優先刪除掉,因為gitlab中是有歷史記錄的,可以找回。但如果有些為了相容呼叫方老版本的程式碼,不能刪除的情況,建議使用@Deprecated註解相關類或者介面。

18.隨意修改介面名和引數名

不知道你有沒有遇到過這種場景:你寫了一個介面,本來以為沒人使用,後來覺得介面名或引數名不對,偷偷把它們改了。比如:

@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    return userService.query(ids);
}

介面名改了:

@PostMapping("/queryUser")
public List<User> queryUser(@RequestBody List<Long> ids) {
    return userService.query(ids);
}

結果導致其他人的功能報錯,原來他已經在呼叫該介面了。

大意了。。。

所以在修改介面名、引數名、修改引數型別、修改引數個數時,一定要先詢問一下相關同事,有沒有使用該介面,免得以後出現不必要的麻煩。

對於已經線上上使用的介面,儘量不要修改介面名、引數名、修改引數型別、修改引數個數,還有請求方式,比如:get改成post等。寧可新加一個介面,也儘量不要影響線上功能。

19.使用map接收引數

我之前見過有些小夥伴,在程式碼中使用map接收引數的。例如:

@PostMapping("/map")
public void map(@RequestBody Map<String, Object> mapParam){
    System.out.println(mapParam);
}

在map方法中使用mapParam物件接收引數,這種做法確實很方便,可以接收多種json格式的資料。

例如:

{
  "id":123,
  "name":"蘇三",
  "age":18,
  "address":"成都"
}

或者:

{
  "id":123,
  "name":"蘇三",
  "age":18,
  "address":"成都",
  "role": {
    "roleName":"角色",
    "tag":"t1"
  }
}

這段程式碼可以毫不費勁的接收這兩種格式的引數,so cool。

但同時也帶來了一個問題,那就是:引數的資料結構你沒法控制,有可能你知道呼叫者傳的json資料格式是第一種,還是第二種。但如果你沒有寫好註釋,其他的同事看到這段程式碼,可能會一臉懵逼,map接收的引數到底是什麼東東?

專案後期,這樣的程式碼變得非常不好維護。有些同學接手前人的程式碼,時不時吐槽一下,是有原因的。

那麼,如果優化這種程式碼呢?

我們應該使用有明確含義的物件去接收引數,例如:

@PostMapping("/add")
public void add(@RequestBody @Valid User user){
    System.out.println(user);
}

其中的User物件是我們已經定義好的物件,就不會存在什麼歧義了。

20.從不寫單元測試

因為專案時間實在太緊了,系統功能都開發不完,更何況是單元測試呢?

大部分人不寫單元測試的原因,可能也是這個吧。

但我想告訴你的是,不寫單元測試並不是個好習慣。

我見過有些程式設計高手是測試驅動開發,他們會先把單元測試寫好,再寫具體的業務邏輯。

那麼,我們為什麼要寫單元測試呢?

  1. 我們寫的程式碼大多數是可維護的程式碼,很有可能在未來的某一天需要被重構。試想一下,如果有些業務邏輯非常複雜,你敢輕易重構不?如果有單元測試就不一樣了,每次重構完,跑一次單元測試,就知道新寫的程式碼有沒有問題。

  2. 我們新寫的對外介面,測試同學不可能完全知道邏輯,只有開發自己最清楚。不像頁面功能,可以在頁面上操作。他們在測試介面時,很有可能覆蓋不到位,很多bug測不出來。

建議由於專案時間非常緊張,在開發時確實沒有寫單元測試,但在專案後期的空閒時間也建議補上。

本文結合自己的實際工作經驗,用調侃的方式,介紹了在編寫程式碼的過程中,不太好的地方和一些優化技巧,給用需要的朋友們一個參考。

最後說一句(求關注,別白嫖我)

如果這篇文章對您有所幫助,或者有所啟發的話,幫忙掃描下發二維碼關注一下,您的支援是我堅持寫作最大的動力。

求一鍵三連:點贊、轉發、在看。

關注公眾號:【蘇三說技術】,在公眾號中回覆:面試、程式碼神器、開發手冊、時間管理有超讚的粉絲福利,另外回覆:加群,可以跟很多BAT大廠的前輩交流和學習。

相關文章