从用缓存优化函数性能说说第三方框架的使用

导读

前面两篇文章分别从计算机及领域知识编程语言本身的角度聊了聊写代码要考虑的事儿。但是理解了计算机系统、领悟了领域知识、精通了某门编程语言就够了么?我在编程到底难在哪里的回答里说,软件开发是搭积木式的,如果你想搭得好又有价值,一个合理的办法是在已经搭好的基础上继续搭,而不是把所有东西自己重新搭一遍。JDK和.NET Framework都是这种已经搭好了的最基本的架子。想想软件发展了几十年,各式框架已经一应俱全,基本想做个任何事情都已经有现成的框架可用。当然,如果你觉得现在已经搭好的东西就是坨屎,当然可以也应该自己搭个更好的。但是现实的情况大都是:人家明明已经搭好那么多各式各样的建筑并欢迎你去添砖加瓦,结果只是因为自己不知道、不了解那些东西的存在,自己重头搭了个双层火柴盒还觉得不错。软件开发显然比在minecraft里搭积木要复杂且抽象些。随之而来的一个问题就是:可应用框架的地方并不显而易见,尤其是当你觉得自己写一个更快的时候。这也是本文将要讨论的重点。(在校生请不要误会,在校生请夯实基础并从基础搭起,能搭多少搭多少。)

引子

假设我们发现这个下面开平方函数很慢,比如要一秒,我们Profile之后发现函数慢在Math.sqrt这个第三方函数上,遗憾的是下层Math库的作者声称他那边儿没有进一步提速的空间了,而且他已经不再维护了。然而我们死活就是想要提高这个函数的性能,怎么办?

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

代码 1

解决方法倒是有不少的,比如升级硬件,比如换一个更快的库,比如开多线程,或者把Math的作者高薪挖过来。只是这些方案的成本都不低。考虑到这个函数是个纯函数,又考虑到内存并不是很贵,如果又能和用户确认一下他们的输入并不是随机的,有比较高的重复度和局域性的话。把这个函数的运算结果保存下来或许是最多快好省的方案了。实现起来也很简单(注:请无视代码2的拆装箱损耗,那不是问题重点)。

private final Map<Double, Double> rootMap = new ConcurrentHashMap<>();

public double sqrt(final double n) {
    return rootMap.computeIfAbsent(n, Math::sqrt);
}

代码 2

这个实现的一个问题就是它只加不删。如果用户真能保证他们的输入有比较高的重复性的话这个问题倒不大。但是保不齐存在一个2B用户没按约定使用,系统最后就OOM了,然后常见的后果是这个软件员会被拉出来背内存泄漏导致宕机的锅。所以保险起见应该给这个Map设定一个上限。到了就把老化的结果删除。(注:请无视代码中try..finally及iterator的用法,那也不是重点)

private final Map<Double, Double> rootMap = new LinkedHashMap<>();
private final int maximumSize = 1000;

public double sqrt(final double n) {
    try {
        return rootMap.computeIfAbsent(n, Math::sqrt);
    } finally {
        if (rootMap.size() > maximumSize) {
            rootMap.remove(rootMap.keySet().iterator().next());
        }
    }
}

代码 3

如果用户只要求精确到小数点后两位就够了。那么一个简单的提高命中率的改进就是把参数先进行舍入再执行运算。

private final Map<Double, Double> rootMap = new LinkedHashMap<>();
private final int maximumSize = 1000;

public double sqrt(final double n) {
    final double r = round(n, 2);
    try {
        return rootMap.computeIfAbsent(r, Math::sqrt);
    } finally {
        if (rootMap.size() > maximumSize) {
            rootMap.remove(rootMap.keySet().iterator().next());
        }
    }
}

public double round(final double n, final int scale) {
    return new BigDecimal(Double.toString(n))
              .setScale(scale, BigDecimal.ROUND_HALF_UP))
              .doubleValue();
}

代码 4

好了,到目前为止,在不考虑线程安全性的前提下,我对这个函数结果留存的实现方案算是比较满意了。然后才是本文的正题:代码4的问题是什么?如何解决?

问题在哪儿

业务逻辑不明确

想象一下,如果你第一次看到这个代码4,你能否一眼看出哪些代码是业务逻辑的需要,哪些不是?

这还仅仅是为保存一个方法的执行结果,就多出了十几行代码。这还没打Log呢,还没处理异常呢,还没加调用信息统计呢,还没做运行时Profiling呢,还没保证多线程安全呢。

请想象一下,你把上面的这些附加功能都按从代码1到代码4的实现方式加上去,这代码还能看么?

解决思路很简单:业务逻辑应该与非业务逻辑分离

代码冗余、维护成本高

软件开发的一个基本的原则就是写的代码要测试一下。你自己写了个round方法,你就得写相应的测试覆盖这个函数吧。你代码多,测试也会多。这都是实现上的成本。

另一方面,这回的情况是这样:你在实现这个功能的时候,发现需要这个round方法,于是自己写了一个。另一个程序员在界面显示的时候为了做截断可能也会自己写个round方法。大家在同一个Team、同一个代码库上工作,最好是不要出现这种很多人做重复性的工作。怎么解决呢?可以写个utils包,每个人想到什么感觉可能对别人也有用的东西就放在里面。

但是这其实是个悖论,如果一个程序员在实际项目上连类似这种round的方法都去自已写,有两种可能:一、公司什么库都不让用或是用之前要走半年流程什么的,这个可以通过跳槽来解决。二、意味他并没有事先查找可用utils的意识,没有这种意识的话,就算有别的成员放在项目内utils包里他大概也是不会找的。当然,这个情况可以这样解决:谁写了之后,大家开个会广告一下。最终结果大概会是:程序员们为了不开会,非常自觉地都不再写任何utils了。

更好的办法就是,只要有哪个著名的开源库里有的,大家就都不要自己写这种常见的功能。当然这个办法对于一小部分人来说也会有问题:谁知道我要的东西在哪个开源库里有?这个问题只有Google和Stackoverflow能回答。然后Google之后发现了一堆库可以用的,那么用哪个呢?

如果就是不乐意用别人的东西,非想要自己写,也行。在Team里找一两个最NB的人专门负责这些低层框架上的东西。并给所有人做好Training。

最要不得的就是想到什么就写什么。就像代码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;
import org.apache.commons.collections4.map.LRUMap;

private final Map<Double, Double> rootMap = new LRUMap<>(1000);

public double sqrt(final double n) {
    return rootMap.computeIfAbsent(Precision.round(n, 2), Math::sqrt);
}

代码 5

这个例子很容易,但是实现中的情况会比这个复杂得多。复杂的不是要实现的功能本身,最难是你能不能找到模式,找到可以重用的现成的东西,然后最重要的:用对。不要拿着锤子看什么都像钉子。比如,如果你觉得Java 8的Stream不错。但是把Stream这样用就不对了。(注:代码来自Stackoverflow的某问题的回答)

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

代码 6

这个代码就是在风格或某一特性上走极端了。

剥离非业务逻辑

代码5仅仅是用上了些第三方的库,代码相对简洁了些。但是比代码1还是复杂了3倍。而且你一眼看去,很容易就产生误解,把rootMap当成了核心。而其实Math::sqrt才是。

现在我们目标很明确,就是要让我们的代码只做我们需要做的事情,让库和框架去解决别的和业务本身没关系的问题。代码5做到了一部分,但是不彻底,而且还有侵入性(你要用一个框架就要对现有代码大动干戈的性质)。有没有办法让代码回到代码1的程度呢?

有人可能想到了。AspectJ可以做到。只要在项目里放一个这样的Aspect,然后就直接用代码1就是了。(注:此为示意代码)

@Aspect
@Component
public class StoreMethodResult {
    private final Map<Double, Double> map = new LRUMap<>(10000);

    @Around("execution(* *.Calculator.*(..)) && args(value,..)")
    private Object round(ProceedingJoinPoint pjp, double value) 
        throws Throwable {
        return map.computeIfAbsent(Precision.round(value, 2), v -> {
            return (Double) pjp.proceed(new Object[]{v});
        });
    }
}

public class Calculator {
    public double sqrt(final double n) {
        return Math.Sqrt(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.*"})
@EnableCaching
public class Application {
    @Bean
    public CacheManager getCachemanager() {
        final SimpleCacheManager simpleCache = new SimpleCacheManager();
        simpleCache.setCaches(Lists.newArrayList(
      new ConcurrentMapCache("math")));

        return simpleCache;
    }

    @Cacheable(value = "math", 
        key = "T(org.apache.commons.math3.util.Precision).round(#n, 2)")
    public double sqrt(final double n) {
        return Math.sqrt(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(
        cacheName = "math", 
        cachedExceptions = { IllegalArgumentException.class })
public double sqrt4(@CacheKey final double n) {
    return Math.sqrt(n);
}

public double roundedSqrt(final double n) {
    return sqrt3(Precision.round(n, 2));
}

代码 9

不难发现JCache的Annotation和Spring Cache的用法略有不同。于是产生了下面几个不同:

  1. 由于自定义key表达示的缺失不得不引入一个新的函数来实现Round的行为。
  2. cachedExceptions的出现又让我们相对比较容易地处理异常。
  3. 独立的@CacheKey的用法相对直观,但是灵活度不足。

各有各的优劣。没有哪一个是完美的。在实际使用中应该根据实际需要选择最合适的框架。需要注意的是,这种使用方式上的不同,对于框架的选择一般是次要性因素。其使用方式上的不同,往往是其设计理念与目标的不同的外在体现而已。而设计理念与目标,才是选择框架的主要指标与标准。

 综述

像本文这样举一个例子,说明正确使用库与框架带来的诸多好处,是件很容易的事儿。然而实际这样做起来绝不会像说起来这么简单。因为你不仅仅要完成需求,还要识别出其中的模式与通用部分,查找可用的库或框架,然后再学习这些库的正确使用方式并集成到你的项目中。从代码4到代码8,代码没多,但是其实是比自己写一个更难,所花的时间也更多。(尤其是你还并不知道也还不会用那些框架的时候)。然而这些多出来的步骤都不是障碍,最大的障碍是心魔。

当你千辛万苦调了几天Bug把代码跑通大功告成之后,是觉得这是结束?还是刚刚开始?愿意不愿意想一想有没有别的方法做同样的事儿?愿意不愿意去把各个方案比较一下?愿意不愿意承认自己一开始拍脑袋想到的办法可能不是最合适的?如果是项目工期十分紧张的情况下呢?如果你的同事、你的领导甚至整个公司都并不赏识你的做法,只求一个个的项目能按时上线呢?如果你现在已经处于天天加班,天天调试老代码又不敢大改的窘境了呢?

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