從用緩存優化函數性能說說第三方框架的使用

導讀

前面兩篇文章分別從計算機及領域知識和編程語言本身的角度聊了聊寫代碼要考慮的事兒。但是理解了計算機系統、領會了領域知識、精通了某門編程語言就夠了么?我在編程到底難在哪裡的回答里說,軟體開發是搭積木式的,如果你想搭得好又有價值,一個合理的辦法是在已經搭好的基礎上繼續搭,而不是把所有東西自己重新搭一遍。JDK和.NET Framework都是這種已經搭好了的最基本的架子。想想軟體發展了幾十年,各式框架已經一應俱全,基本想做個任何事情都已經有現成的框架可用。當然,如果你覺得現在已經搭好的東西就是坨屎,當然可以也應該自己搭個更好的。但是現實的情況大都是:人家明明已經搭好那麼多各式各樣的建築並歡迎你去添磚加瓦,結果只是因為自己不知道、不了解那些東西的存在,自己重頭搭了個雙層火柴盒還覺得不錯。軟體開發顯然比在minecraft里搭積木要複雜且抽象些。隨之而來的一個問題就是:可應用框架的地方並不顯而易見,尤其是當你覺得自己寫一個又快又好的時候。這也是本文將要討論的重點。(在校生請不要誤會,在校生請夯實基礎並從基礎搭起,能搭多少搭多少。)

引子

假設我們發現這個下面開平方函數很慢,比如要一秒,我們Profile之後發現函數慢在Math.sqrt這個第三方函數上,遺憾的是下層Math庫的作者聲稱他那邊兒沒有進一步提速的空間了,而且他已經不再維護了。然而我們死活就是想要提高這個函數的性能,怎麼辦?

public double sqrt(final double n) {n return Math.sqrt(n);n}n

代碼 1

解決方法倒是有不少的,比如升級硬體,比如換一個更快的庫,比如開多線程,或者把Math的作者高薪挖過來。只是這些方案的成本都不低。考慮到這個函數是個純函數,又考慮到內存並不是很貴,如果又能和用戶確認一下他們的輸入並不是隨機的,有比較高的重複度和局域性的話。把這個函數的運算結果保存下來或許是最多快好省的方案了。實現起來也很簡單(註:請無視代碼2的拆裝箱損耗,那不是問題重點)。

private final Map<Double, Double> rootMap = new ConcurrentHashMap<>();nnpublic double sqrt(final double n) {n return rootMap.computeIfAbsent(n, Math::sqrt);n}n

代碼 2

這個實現的一個問題就是它只加不刪。如果用戶真能保證他們的輸入有比較高的重複性的話這個問題倒不大。但是保不齊存在一個2B用戶沒按約定使用,系統最後就OOM了,然後常見的後果是這個軟體員會被拉出來背內存泄漏導致宕機的鍋。所以保險起見應該給這個Map設定一個上限。到了就把老化的結果刪除。(註:請無視代碼中try..finally及iterator的用法,那也不是重點)

private final Map<Double, Double> rootMap = new LinkedHashMap<>();nprivate final int maximumSize = 1000;nnpublic double sqrt(final double n) {n try {n return rootMap.computeIfAbsent(n, Math::sqrt);n } finally {n if (rootMap.size() > maximumSize) {n rootMap.remove(rootMap.keySet().iterator().next());n }n }n}n

代碼 3

如果用戶只要求精確到小數點後兩位就夠了。那麼一個簡單的提高命中率的改進就是把參數先進行舍入再執行運算。

private final Map<Double, Double> rootMap = new LinkedHashMap<>();nprivate final int maximumSize = 1000;nnpublic double sqrt(final double n) {n final double r = round(n, 2);n try {n return rootMap.computeIfAbsent(r, Math::sqrt);n } finally {n if (rootMap.size() > maximumSize) {n rootMap.remove(rootMap.keySet().iterator().next());n }n }n}nnpublic double round(final double n, final int scale) {n return new BigDecimal(Double.toString(n))n .setScale(scale, BigDecimal.ROUND_HALF_UP))n .doubleValue();n}n

代碼 4

好了,到目前為止,在不考慮線程安全性的前提下,我對這個函數結果留存的實現方案算是比較滿意了。然後才是本文的正題:代碼4的問題是什麼?如何解決?

問題在哪兒

業務邏輯不明確

想像一下,如果你第一次看到這個代碼4,你能否一眼看出哪些代碼是業務邏輯的需要,哪些不是?

這還僅僅是為保存一個方法的執行結果,就多出了十幾行代碼。這還沒打Log呢,還沒處理異常呢,還沒加調用信息統計呢,還沒做運行時Profiling呢,還沒保證多線程安全呢。

請想像一下,你把上面的這些附加功能都按從代碼1到代碼4的實現方式加上去,這代碼還能看么?

解決思路很簡單:業務邏輯應該與非業務邏輯分離

代碼冗餘、維護成本高

軟體開發的一個基本的原則就是寫的代碼要測試一下。你自己寫了個round方法,你就得寫相應的測試覆蓋這個函數吧。你代碼多,測試也會多。這都是實現上的成本。

另一方面,這回的情況是這樣:你在實現這個功能的時候,發現需要這個round方法,於是自己寫了一個。另一個程序員在界面顯示的時候為了做截斷可能也會自己寫個round方法。大家在同一個Team、同一個代碼庫上工作,最好是不要出現這種很多人做重複性的工作。怎麼解決呢?可以寫個utils包,每個人想到什麼感覺可能對別人也有用的東西就放在裡面。

但是這其實是個悖論,如果一個程序員在實際項目上連類似這種round的方法都去自已寫,有兩種可能:一、公司什麼庫都不讓用或是用之前要走半年流程什麼的,這個可以通過跳槽來解決。二、意味他並沒有事先查找可用utils的意識,沒有這種意識的話,就算有別的成員放在項目內utils包里他大概也是不會找的。當然,這個情況可以這樣解決:誰寫了之後,大家開個會廣告一下。最終結果大概會是:程序員們為了不開會,非常自覺地都不再寫任何utils了。

更好的辦法就是,只要有哪個著名的開源庫里有的,大家就都不要自己寫這種常見的功能。當然這個辦法對於一小部分人來說也會有問題:誰知道我要的東西在哪個開源庫里有?這個問題只有Google和Stackoverflow能回答。然後Google之後發現了一堆庫可以用的,那麼用哪個呢?

如果就是不樂意用別人的東西,非想要自己寫,也行。在團隊里找一兩個最NB的人專門負責這些低層框架上的東西。並給所有人做好培訓。

最要不得的就是想到什麼就寫什麼。就像代碼4那樣。它看上去代碼質量也挺高,Style也挺不錯,目測也沒Bug,但是成本高,沒法維護、不好拓展。

可重用性差

考慮一下,如果你需要把Math類里的所有函數都做這樣的處理呢?我猜會不會有人想用Map<String, Map<String, Object>>什麼的?而且只改一個文件就行了呢!

再考慮一下,如果這個函數的調用會拋出異常呢?你是想把異常也保存一下呢?還是想下次重試呢?

上面的Map使用的是最簡單的FIFO策略,如果你需要LRU呢?

還有,你這裡把round之後的結果當Key,如果有的需要用toString()的結果當Key呢?

你每種情況具體分析分別實現還是為之寫個通用框架呢?

在別人的肩膀上搭積木

善用第三方庫

這個世界上的肩膀很多,如果想搭好自己的積木就要找到合適的肩膀,最重要的一步是找到那個重複出現的問題是什麼,找到那個模式(不限於設計模式,也包括規範、代碼模式等)。

限定大小的Map就是個會重複出現的問題。round一個double值也是個會重複出現的問題。顯然這些問題Apache和Google都遇到過,並把他們的庫公開了出來給大家用。用上之後代碼就可以簡化很多。(註:Guava也有類似的Map)

import org.apache.commons.math3.util.Precision;nimport org.apache.commons.collections4.map.LRUMap;nnprivate final Map<Double, Double> rootMap = new LRUMap<>(1000);nnpublic double sqrt(final double n) {n return rootMap.computeIfAbsent(Precision.round(n, 2), Math::sqrt);n}n

代碼 5

這個例子很容易,但是實現中的情況會比這個複雜得多。複雜的不是要實現的功能本身,最難是你能不能找到模式,找到可以重用的現成的東西,然後最重要的:用對。不要拿著鎚子看什麼都像釘子。比如,如果你覺得Java 8的Stream不錯。但是把Stream這樣用就不對了。(註:代碼來自Stackoverflow的某問題的回答)

// BEING: Wrong usage examplenpublic Double round(final Number src, final int scale) {n return Optional.ofNullable(src)n .map(Number::doubleValue)n .map(BigDecimal::new)n .map(dbl -> dbl.setScale(scale))n .map(BigDecimal::doubleValue)n .orElse(null);n}n// END: Wrong usage examplen

代碼 6

這個代碼就是在風格或某一特性上走極端了。

剝離非業務邏輯

代碼5僅僅是用上了些第三方的庫,代碼相對簡潔了些。但是比代碼1還是複雜了3倍。而且你一眼看去,很容易就產生誤解,把rootMap當成了核心。而其實Math::sqrt才是。

現在我們目標很明確,就是要讓我們的代碼只做我們需要做的事情,讓庫和框架去解決別的和業務本身沒關係的問題。代碼5做到了一部分,但是不徹底,而且還有侵入性(你要用一個框架就要對現有代碼大動干戈的性質)。有沒有辦法讓代碼回到代碼1的程度呢?

有人可能想到了。AspectJ可以做到。只要在項目里放一個這樣的Aspect,然後就直接用代碼1就是了。(註:此為示意代碼)

@Aspectn@Componentnpublic class StoreMethodResult {n private final Map<Double, Double> map = new LRUMap<>(10000);nn @Around("execution(* *.Calculator.*(..)) && args(value,..)")n private Object round(ProceedingJoinPoint pjp, double value) n throws Throwable {n return map.computeIfAbsent(Precision.round(value, 2), v -> {n return (Double) pjp.proceed(new Object[]{v});n });n }n}nnpublic class Calculator {n public double sqrt(final double n) {n return Math.Sqrt(n);n }n}n

代碼 7

我們解決了代碼4的問題,成功把它簡化回到了代碼1的程度。但是這樣搞會引入兩個新問題:

  1. StoreMethodResult和Calculator是兩個類。單看Calculator你是無法知道Calcuator在執行的時候它的結果會被保存下來。所以你給代碼1加一個能力、功能、機制的時候,還是最好在Calculator本身上留下點兒線索。一個代碼是注釋。更好的辦法是用Annotation。按這個思路做下去,就會需要一個Annotation Driven的Aspect。
  2. 代碼7本身也存在類似代碼4的問題:它也是在重新造輪子。這個輪子叫緩存(Cache)。

通用化方案

這個函數調用結果保存下來的需求實在是太廣泛了,肯定已經有現成的通用解決方案了啊。Spring Cache就是其中的一個方法。使用Spring Cache和Spring Boot的實現方式就是這樣:

@SpringBootApplication(scanBasePackages = {"your.package.name.*"})n@EnableCachingnpublic class Application {n @Beann public CacheManager getCachemanager() {n final SimpleCacheManager simpleCache = new SimpleCacheManager();n simpleCache.setCaches(Lists.newArrayList(n      new ConcurrentMapCache("math")));nn return simpleCache;n }nn @Cacheable(value = "math", n key = "T(org.apache.commons.math3.util.Precision).round(#n, 2)")n public double sqrt(final double n) {n return Math.sqrt(n);n }n}n

代碼 8

代碼看著好多,但是前面的都是初始化Cache的部分。對於sqrt函數而言,只需要放個@Cachable就行了。

用Spring Cache不是目的,少寫代碼也不是重點。請注意在這個實現方案下,函數與其附加能力是放在一起的,而與主要業務邏輯又是分離的。這才是重點。庫和框架的使用,應該是為了讓你更高效地寫出更可讀、更少Bug的代碼。如果你用一個框架之後,發現代碼比之前更不好讀,你可能得想想你有沒有用對,或是你選擇的這個框架本身的設計理念是不是合理。但是代碼好不好讀這個事兒有些主觀,有人反而會覺得用了一堆他不會正確使用的框架的代碼才是不好讀的。我就呵呵了。相關討論請參考本系列第一篇《從開平方說說寫代碼應該考慮的事兒》。

JSR-107 JCache

在程序中Cache結果的能力是如此基本,人們早在2001年就開始了把它放在Java框架里的討論,即JSR-107。但是不知道是不是因為用的人太多,爭論過於激烈,這個JSR在2012年才發布了早期預覽草案。直到2014年3月才發布了最終版。

Spring Cache是在2011年引入到Spring 3.1 M1的。Spring Cache本身不是緩存框架,它是各種緩存框架與Spring的膠水(雖然它也自帶了個實現,但是功能性要差很多)。Spring Cache的實現和JSR中推薦的做法非常相近,再從Spring Cache發布和JSR的時間線看來,或許Spring Cache在推動JSR-107的進程上也發揮了不小的作用。

在2014年4月,JSR-107最終發布的一個月之後,Spring就在4.1版中提供了對JSR-107的支持。

然而JCache畢竟和Spring Cache還不是完全一致的。所以使用JCache實現的版本看上去會有些不一樣。(註:CacheManager的創建還是需要的,只是略去了。)

@CacheResult(n cacheName = "math", n cachedExceptions = { IllegalArgumentException.class })npublic double sqrt4(@CacheKey final double n) {n return Math.sqrt(n);n}nnpublic double roundedSqrt(final double n) {n return sqrt3(Precision.round(n, 2));n}n

代碼 9

不難發現JCache的Annotation和Spring Cache的用法略有不同。於是產生了下面幾個不同:

  1. 由於自定義key表達示的缺失不得不引入一個新的函數來實現Round的行為。
  2. cachedExceptions的出現又讓我們相對比較容易地處理異常。
  3. 獨立的@CacheKey的用法相對直觀,但是靈活度不足。

每個方案,各有各的優劣。沒有哪一個是完美的。在實際使用中應該根據實際需要選擇最合適的框架。需要注意的是,這種使用方式上的不同,對於框架的選擇一般是次要性因素。其使用方式上的不同,往往是其設計理念與目標的不同的外在體現而已。而設計理念與目標,才是選擇框架的主要指標與標準。

綜述

像本文這樣舉一個例子,說明正確使用庫與框架帶來的諸多好處,是件很容易的事兒。然而實際這樣做起來絕不會像說起來這麼簡單。因為你不僅僅要完成需求,還要識別出其中的模式與通用部分,查找可用的庫或框架,然後再學習這些庫的正確使用方式並集成到你的項目中。從代碼4到代碼8,代碼沒多,但是其實是比自己寫一個更難,所花的時間也更多。(尤其是你還並不知道也還不會用那些框架的時候)。然而這些多出來的步驟都不是障礙,最大的障礙是心魔。

當你千辛萬苦調了幾天Bug把代碼跑通大功告成之後,是覺得這是結束?還是剛剛開始?願意不願意想一想有沒有別的方法做同樣的事兒?願意不願意去把各個方案比較一下?願意不願意承認自己一開始拍腦袋想到的辦法可能不是最合適的?如果是項目工期十分緊張的情況下呢?如果你的同事、你的領導甚至整個公司都並不賞識你的做法,只求一個個的項目能按時上線呢?如果你現在已經處於天天加班,天天調試老代碼又不敢大改的窘境了呢?

那些都不是真的困境,那些都是自己的心魔。


推薦閱讀:

框架到底是個什麼東西?
業界主流的RPC框架有哪些?Dubbo與Hadoop RPC的區別?
想要開發自己的PHP框架需要那些知識儲備?
一名Infrastructure Engineer需要掌握哪些技術?
作為WEB前端開發,大家都知道那些方便的js擴展庫呢?

TAG:软件开发 | 编程 | 框架 |