JDK 完全向下兼容吗?JDK1.7 新的内置排序算法 TimSort 引发的异常

从 1997 年 JDK1.1 面世,20 年间 Java 已经发布了 10 多个版本,而我们都知道 Java 的兼容性很强,低版本编译的项目可以运行在高版本的 Java 上,平时工程项目升级 Java 版本时无需任何顾虑。
但是真的是这样吗。我们来看下面一段代码:

public static void main(String[] args) {
    int[] sample = new int[]
            {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, 1, 0, -2, 0, 0, 0, 0};
    List<Integer> list = new ArrayList<Integer>();
    for (int i : sample)
        list.add(i);
    Collections.sort(list, new Comparator<Integer>() {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1 > o2 ? 1 : -1;
        }
    });
    System.out.println(list);
}

这段代码是调用 Java 内置排序方法 Collections.sort() 来排序一个数组,用 JDK1.6 版本运行没问题,但如果用 JDK1.7 就会抛出异常。


原因有两点

  1. JDK1.7 将内置排序算法改为了 TimSort。
  2. 我们 Comparator 接口的实现并不规范。

Comparator

我们先来看一下源代码中 Comparator 接口下 compare() 方法的注释:

Compares its two arguments for order. Returns a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than the second.
In the foregoing description, the notation sgn(expression) designates the mathematical signum function, which is defined to return one of -1, 0, or 1 according to whether the value of expression is negative, zero or positive.
The implementor must ensure that sgn(compare(x, y)) == -sgn(compare(y, x)) for all x and y. (This implies that compare(x, y) must throw an exception if and only if compare(y, x) throws an exception.)
The implementor must also ensure that the relation is transitive: ((compare(x, y)>0) && (compare(y, z)>0)) implies compare(x, z)>0.
Finally, the implementor must ensure that compare(x, y)==0 implies that sgn(compare(x, z))==sgn(compare(y, z)) for all z.
It is generally the case, but not strictly required that (compare(x, y)==0) == (x.equals(y)). Generally speaking, any comparator that violates this condition should clearly indicate this fact. The recommended language is "Note: this comparator imposes orderings that are inconsistent with equals."

简单总结一下就是实现这个方法要保证以下几点:

  • compare(x,y) 和 compare(y,x) 的正负相反;
  • 如果 compare(x,y)>0,并且 compare(y,z)>0,那么 compare(x,z)>0;
  • 如果 compare(x,y)==0,那么 compare(x,z) 和 compare(y,z) 正负相同

我们上面代码实现的 compare 方法中,如果传入的两个对象相等,compare(x,y) 和 compare(y,x) 都会返回 -1,没有保证上面说的第一点。其实一般的排序算法并不需要严格保证 compare 方法,只需要两个对象简单比较一下。比如 JDK1.6 内置排序算法 Collections.sort() 使用的是归并排序(JDK1.7 保留了这个算法),并在元素个数小于 INSERTIONSORT_THRESHOLD(默认值 7) 时优化为使用简单的冒泡排序:

if (length < INSERTIONSORT_THRESHOLD) {
    for (int i=low; i<high; i++)
        for (int j=i; j>low && c.compare(dest[j-1], dest[j])>0; j--)
            swap(dest, j, j-1);
    return;
}

我们实现的 compare 在这些排序中完全适用,但 JDK1.7 中默认排序算法改为了 TimeSort,就让我们来深入了解一下这种排序算法。

TimSort

TimSort 的起源和历史我就不多说了,最早应用在 python 的内置排序中。TimSort 的核心就是 归并排序+二分查找插入排序,并进行大量优化,主要思路如下:

  1. 划分 run(对原序列分块,每个块称之为 run)
  2. 排序 run
  3. 合并 run

网络上有关介绍这种算法的文章很多,我就不多赘述了,我们来看一下 JDK1.7 中的具体实现

代码总览

首先进入排序逻辑会先判断一个 jvm 启动参数,选择使用旧的归并排序(元素个数小于 7 时用冒泡排序),还是使用 TimeSort 进行排序。默认为使用 TimSort。

if (LegacyMergeSort.userRequested)
    legacyMergeSort(a, c);
else
    TimSort.sort(a, c);

进入 TimSort 代码后会进行一些校验和判断,比如判断元素个数少于 MIN_MERGE(默认值 32) 则会通过一个“迷你-TimSort” 进行排序。这是将整个序列看做一个 run,省略了划分 run 和合并 run 两个步骤,直接进行排序 run。

// If array is small, do a "mini-TimSort" with no merges
if (nRemaining < MIN_MERGE) {
    int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
    binarySort(a, lo, hi, lo + initRunLen, c);
    return;
}

我们来看一下核心的算法流程代码,后面会详细讲解每个步骤:

//栈结构,用于保存以及合并 run
TimSort<T> ts = new TimSort<>(a, c);
//确定每个 run 的最小长度
int minRun = minRunLength(nRemaining);
do {
    //划分、排序 run
    // Identify next run
    int runLen = countRunAndMakeAscending(a, lo, hi, c);

    // If run is short, extend to min(minRun, nRemaining)
    if (runLen < minRun) {
        int force = nRemaining <= minRun ? nRemaining : minRun;
        binarySort(a, lo, lo + force, lo + runLen, c);
        runLen = force;
    }

    //保存、合并 run
    // Push run onto pending-run stack, and maybe merge
    ts.pushRun(lo, runLen);
    ts.mergeCollapse();

    // Advance to find next run
    lo += runLen;
    nRemaining -= runLen;
} while (nRemaining != 0);

// Merge all remaining runs to complete sort
assert lo == hi;
ts.mergeForceCollapse();
assert ts.stackSize == 1;

1.划分 run

划分 run 和排序 run 密不可分,TimSort 算法优化的点之一就是尽可能利用原序列的单调子序列。countRunAndMakeAscending() 方法寻找原始元素数组 a 中从 lo 位置开始的最长单调递增或递减序列(递减序列会被反转)。这样,这部分元素相当于排好序了,我们可以直接把它当做一个排序好的 run。但问题随之而来,如果这样的序列很短,会产生很多 run,后续归并的代价就很大,所以我们要控制 run 的长度。下面这段代码规定 run 的最小长度:

private static int minRunLength(int n) {
    assert n >= 0;
    int r = 0;      // Becomes 1 if any 1 bits are shifted off
    while (n >= MIN_MERGE) {
        r |= (n & 1);
        n >>= 1;
    }
    return n + r;
}

n 为整个序列的长度,TimSort 算法优化点之一是通过控制 run 的长度,使 run 的数量保持在 2 的 n 次方,这样在归并的时候,就像二叉树一样进行归并,不会到最后出现非常大的 run 与非常小的 run 归并。代码中 MIN_MERGE 为 32,最后计算出的最小 run 长度介于 16 和 32 之间。

2.排序 run

// Identify next run
int runLen = countRunAndMakeAscending(a, lo, hi, c);
// If run is short, extend to min(minRun, nRemaining)
if (runLen < minRun) {
    int force = nRemaining <= minRun ? nRemaining : minRun;
    binarySort(a, lo, lo + force, lo + runLen, c);
    runLen = force;
}

随后在循环中根据计算出的最短 run 长度和剩余序列单调子序列来划分 run,先取出剩余序列开头的单调子序列,如果长度不够规定的最短长度,则用 binarySort() 方法将其后的元素一个个通过二分查找插入到这个找出的单调递增数组中,直到长度达到规定的最短长度(或到剩余序列结尾),从而将整个序列划分多个 run,并确保每个 run 都是排好序的。

private static <T> void binarySort(T[] a, int lo, int hi, int start,
                                   Comparator<? super T> c) {
    assert lo <= start && start <= hi;
    if (start == lo)
        start++;
    for ( ; start < hi; start++) {
        T pivot = a[start];
        // Set left (and right) to the index where a[start] (pivot) belongs
        int left = lo;
        int right = start;
        assert left <= right;
        /*
         * Invariants:
         *   pivot >= all in [lo, left).
         *   pivot <  all in [right, start).
         */
        while (left < right) {
            int mid = (left + right) >>> 1;
            if (c.compare(pivot, a[mid]) < 0)
                right = mid;
            else
                left = mid + 1;
        }
        assert left == right;
        /*
         * The invariants still hold: pivot >= all in [lo, left) and
         * pivot < all in [left, start), so pivot belongs at left.  Note
         * that if there are elements equal to pivot, left points to the
         * first slot after them -- that's why this sort is stable.
         * Slide elements over to make room for pivot.
         */
        int n = start - left;  // The number of elements to move
        // Switch is just an optimization for arraycopy in default case
        switch (n) {
            case 2:  a[left + 2] = a[left + 1];
            case 1:  a[left + 1] = a[left];
                     break;
            default: System.arraycopy(a, left, a, left + 1, n);
        }
        a[left] = pivot;
    }
}

上面代码首先二分查找出插入点 assert left == right,插入点及其后元素后移,通过 a[left] = pivot,将目标元素插入。可以看到,这里也有很多优化,比如计算需要后移的元素个数,如果是 1,则直接交换目标元素和插入点元素即可(目标元素本来在数组最后一格)。

3.合并 run

将 run 压入栈,执行合并,之后便是在循环中寻找下一个 run,入栈的时候会记录当前 run 的起点在整个序列的位置(所有 run 都在原数组里,不占用额外空间)以及 run 长度:

// Push run onto pending-run stack, and maybe merge
ts.pushRun(lo, runLen);
ts.mergeCollapse();

// Advance to find next run
lo += runLen;
nRemaining -= runLen;

我们来看一下具体合并流程,首先是合并的条件,我们要保证所有的 run 类似二叉树方式进行合并,防止出现非常大的 run 与非常小的 run 进行合并,每个 run 入栈时都会调动这个方法,假设栈顶位置为 i,那么我们要保证栈里的 run 符合以下条件 stack[i-2].length > stack[i-1].length + stack[i].length,并且 stack[i-1].length > stack[i].length。如果不符合,则需要合并。

private void mergeCollapse() {
    while (stackSize > 1) {
        int n = stackSize - 2;
        if (n > 0 && runLen[n-1] <= runLen[n] + runLen[n+1]) {
            if (runLen[n - 1] < runLen[n + 1])
                n--;
            mergeAt(n);
        } else if (runLen[n] <= runLen[n + 1]) {
            mergeAt(n);
        } else {
            break; // Invariant is established
        }
    }
}

因为我们的 run 都在原数组中,通过记录起点坐标和长度来划分,没有占用额外空间,所以我们合并的时候合并相邻两个 run,排序完成后,修改记录的起点坐标和长度来实现合并。在合并时也有优化,run1 和 run2 相邻,run1 在前,run2 在后。那么 run1 中比 run2 最小(第一个)元素小的那些元素其实相当于已经在正确的位置了,不需要考虑,同理 run2 中比 run1 最大的元素大的那些元素也是这样。举个例子:[1,2,3,4][3,4,4,4,5,6] -> [1,2,[3,4][3,4,4],5,6] -> [1,2,3,3,4,4,4,5,6],数组 9 个连续位置,两个相邻 run,其中 [1,2,-,-,-,-,-,5,6] 相当于排好序了,只需要合并剩余的 [3,4][3,4,4],代码如下:

/*
 * Find where the first element of run2 goes in run1. Prior elements
 * in run1 can be ignored (because they're already in place).
 */
int k = gallopRight(a[base2], a, base1, len1, 0, c);
assert k >= 0;
base1 += k;
len1 -= k;
if (len1 == 0)
    return;

/*
 * Find where the last element of run1 goes in run2. Subsequent elements
 * in run2 can be ignored (because they're already in place).
 */
len2 = gallopLeft(a[base1 + len1 - 1], a, base2, len2, len2 - 1, c);
assert len2 >= 0;
if (len2 == 0)
    return;

// Merge remaining runs, using tmp array with min(len1, len2) elements
if (len1 <= len2)
    mergeLo(base1, len1, base2, len2);
else
    mergeHi(base1, len1, base2, len2);

可以看到,即使合并剩余部分,依然通过判断两者长度来进行算法优化。

在循环结束后,会尝试最后的合并,确保栈里只剩一个 run,即排序好的整个序列。

// Merge all remaining runs to complete sort
assert lo == hi;
ts.mergeForceCollapse();
assert ts.stackSize == 1;

具体合并算法非常复杂,我看的也是一知半解,总之在合并过程中,遇到一些特殊情况,会抛出一个异常,提醒开发者所实现的 compare() 并不符合规约。

if (len1 == 1) {
    assert len2 > 0;
    System.arraycopy(a, cursor2, a, dest, len2);
    a[dest + len2] = tmp[cursor1]; //  Last elt of run 1 to end of merge
} else if (len1 == 0) {
    throw new IllegalArgumentException(
        "Comparison method violates its general contract!");
} else {
    assert len2 == 0;
    assert len1 > 1;
    System.arraycopy(tmp, cursor1, a, dest, len1);
}

结论

我们只能确定低版本编译的代码可以运行在高版本的 Java,但却无法保证运行的行为和结果与低版本一致。

我们看到代码入口会通过一个启动参数来判断选择内置排序算法,所以我们可以通过添加 jvm 启动参数 -Djava.util.Arrays.useLegacyMergeSort=true,来使用传统归并排序,保证两个版本的排序行为一致。

透明、灰度、兼容&半强制

最近重构了一个公司业务的一个模块,涉及的东西有点多,从需求评审到技术评审,再到各端沟通,花了很大功夫才确定架构和技术方案,最后开发提测灰度上线,清洗数据等等,整个重构经历了很长时间。现在一切尘埃落定,我也静下心来把工作中的思考和探索记录下来。业务不能说的太详细,用一些泛泛的概念,比如新业务逻辑,新业务数据,旧业务逻辑,旧业务数据,业务主体等等。

透明

我很喜欢透明这个词,在软件领域的意思就是关注应该关注的东西,不应该关注的东西应该是透明的、看不到的。
原业务是后端提供一个 HTML,前端各渠道各自编写相同逻辑处理 HTML 里面的信息,进而展示给用户。这样的设计真的是非常痛苦,首先各端工作量增加,其次耦合性太高,最重要的是需要使用 iframe 或引入 jquery 来执行逻辑。重构之后由后端处理完 HTML 的信息,传给各端一个纯展示用的 HTML,对于各端来说这个 HTML 里的内容是透明的,他们不必关注,只需要展示就可以了。
另一个是 HTML 里面有很多需要填写的关键信息夹杂在内容之中,在不同的 HTML 中有可能有相同的关键信息,原业务对相同的关键信息用相同 ID 的 input 标签作为区分,后端再根据 ID 来存取所填的值。这样导致每创建一个新的 HTML 都要开发人员编写并保存一个完整页面,并且确保其中关键信息的填空的 ID 和约定一致。重构之后 HTML 的文本内容和关键信息分离,文本内容相当于纯文案,关键信息则是 key-value 列表,由后端装填到一个 HTML 模板中即可,这样一来,这些业务无关的内容对于后端和前端来说都是透明的,文本内容和关键信息的修改不需要投入开发人员。

灰度

重构一个正在运行的服务,新旧逻辑和数据不互通,不能保证同一时间迁移数据的,而且变更内容过多,影响范围较大,一定要有一个灰度方案,保证出现问题能够把影响面降到最小,新的服务能够逐步稳定地替代旧服务。
首先是灰度范围的选择,从业务的哪一层面来划分灰度范围,选取的颗粒度不能太大也不能太小,根据业务来寻找一个层面进行划分。
然后要考虑如何方便地调整灰度范围直至全量,以及如何保证后续新的业务主体自动进入灰度范围,我在数据库的字典表用一条数据记录灰度范围,也就是一些类型 ID 用逗号拼接成的字符串,代码逻辑会根据业务主体判断其类别是否在灰度范围,当灰度范围调整时,更新这条数据,当这条数据为空字符串时,代码判断为全量切换,即所有业务主体走新的业务逻辑,保证后续新增的类型自动进入灰度范围。
最后是灰度策略的制定,灰度范围外的业务如何处理,在灰度范围内但没有新业务基础数据的怎么处理,其他上下游业务如何保证不受灰度策略的影响。我在这次重构中,灰度范围外的业务主体仍然按照旧的业务逻辑处理,加到了灰度范围中但没有新业务基础数据的,仍然按照旧业务逻辑处理。新的业务保证上下游数据的正常交互,就像一条河流在中间分成了两条河道,对应新旧协议,然后又汇聚回原河流。

兼容

在涉及很多端,不能保证各端同一时间上线,尤其是 APP 端不能强制更新的情况下,旧的接口和逻辑要能够保持正常运行,旧的接口可以调用新的服务,而新的接口也要能够调用旧的服务。也就是说在使用灰度方案时,要保证新旧接口都可以正常工作,并根据灰度范围来正确调用新旧服务。
举个例子,这次重构中,旧业务逻辑是前端根据占位符填充的某些内容,新接口无论新旧业务数据,统一直接在后端填充好了,为了让旧接口兼容并正常展示新业务数据,我在旧接口返回新业务数据时 hack 了相应的占位符,仍然走前端填充的方式。

半强制

在设计了灰度和兼容两个关键点之后,带来另一个业务上必须考虑的问题,就是用户太懒。因为我们有灰度和兼容方案,所以使用者感觉旧的业务还可以使用,便不愿意迁移到新的业务,而且对于他们来说,新旧业务并没有什么不同。
所以我们的半强制策略就是,进入灰度范围的业务主体将不能修改和展示旧业务数据,只有编辑新业务数据的入口,旧业务数据可以走旧业务逻辑完成整个流程,一旦编辑好新业务的基础数据,该业务主体将走新业务逻辑。

问问题前,最好自己要先有一个答案

“问问题前,最好自己要先有一个答案。”这句话是很久之前一个同学跟我说的,
当时我们在讨论项目问题,一个由我主导的实验性质的校内网站项目。我对技术一无所知,但是满脑子都是天马行空的想法,讨论问题时也是心潮澎湃。他的这句话像一盆冷水泼在我火热的心上,我突然冷静下来,我意识到我并没有任何有价值的想法,只是在胡乱地提问。

当时我只觉得这句话很对,但并没有深刻领悟,就像明白很多道理,依旧过不好这一生,因为只是明白而已,没有亲身经历,就无法体会。

最近发生一些事却让我突然对这句话有了体会,即使我忘了是谁在什么时候对我说的。

前几天我在看一些 Java 代码,很基础的源代码。但是依旧让我这个新手看的十分混乱。然后我翻看一本 Java 数据结构的书,里面作者从无到有,一步一步简单实现了一个 ArrayList。看完书中的内容再回头看 jdk 中的 ArrayList,瞬间清晰了许多。

我仔细回顾这两次看源代码的经历,仿佛让我回到的学生时代,就像在上课前有没有预习的差别。没有预习的时候,上课只能跟着老师的思路走,漫无目的被牵着鼻子走,所获得的知识和思想也仅仅停留在表面,很容易随时间淡忘。有预习的时候,更多的是思想上的碰撞,看看哪些想法一样,哪些不一样,对那些不一样的地方,我们可以更加针对的深入思考。

工作当中也是一样,当我们遇到解决不了的问题时,在请教领导或同事之前,自己要有一些思路和想法,不能把问题直接推给别人。一是别人也很忙,从头缕一遍问题并找到解决方案要花费大量时间和精力;二是让别人知道你是一个负责任的人,即使无法解决问题也会积极思考。

对于决策层来说,他们需要的只是针对提议的决策,比如从两个方案中选一个方案。所以发现问题、梳理问题、设计问题解决方案这些工作就要下面的人来做。就像在饭馆点菜一样,如果直接把菜单递给对方,对方往往不知道点什么好,反而向对方提供两道招牌菜,让其从中选择,则会方便许多。

上面说的这些道理,我也仅仅是明白,生活和工作中还有很多地方做的不对不好,仍需努力。

人类的主动进化

生物通过一代一代的繁殖,基因的突变使性状改变,再受到自然选择的优胜劣汰,有些性状保留并成为主流。这就是我们生物学上所说的进化。

我们时常会感冒,虽然一次感冒康复之后我们会对这种感冒病毒有抗体,但我们还会有下一次的感冒。为什么呢,因为感冒病毒在变异在进化,它们的繁殖速度很快,在短时间内就能够繁殖很多代,从而进化很多。而人类大概20年才能繁殖一代,相比之下,进化速度实在太慢了。我时常在想,为什么人类没有被自然界淘汰掉呢。

其实人类有自己的主动进化,这种进化并不是基因上的,而是智慧上的。这使得人类能够在很短时间内提升很多。举个例子,大家去驾校学两个月车,就能拿到驾照,从此有了驾驶汽车这项能力。想象一下,如果让人类进化出这种能力,可能需要几百万年几千万年。

而人类通过这种方式进化的关键是什么呢,是交流和学习。交流和学习的意义在于经历你未曾经历过的一切,从而学会技能或明白道理。而我们的时间是有限的,我们的一生会有很多事情无法经历,但是人类有数量上的优势,想象着同一时间,各种各样的人都经历着各种各样的事情,而其中某一些也通过文字或影像或声音到达你这里,你身临其境,你感同身受,你仿佛也经历了那些事情,你也学到了些东西或者明白了些道理,你成长了,你进化了。

有些科幻作品中喜欢塑造一种高等外星生物,它们没有个体智慧,只有一个共同的大脑,就像网络云端的服务器一样,每个外星生物经历的一切上传到这一个云端,再从云端获取所有记忆。这个云端大脑能够迅速提高智慧,迅速成长,每个个体都实实在在经历了所有的一切。

而我们没有这种云端大脑,我们可以通过信息的交流来获得某种经历,随着科技水平的提高,人类间信息传递的效率也越来越高,一触即发的视频图像替代了漂泊流转的书籍文字。人类在共享着经历,在学习和领悟,在进行着高速的进化,从而成为在地球上无比强大的物种。

这就是为什么我喜欢与人交流。

Java7 及之前版本中 Date 类设计缺陷

Java 程序猿普遍对 Date 这个类感触深刻,诟病已久,因为它存在太多问题,用起来十分不方便。下面说说主要问题。

多个 Date

在 java.util 和 java.sql 的包中都有一个名为 Date 的日期类。但 java.util.Date 同时包含日期和时间,而 java.sql.Date 仅包含日期,java.sql.Time 包含时间。按照英文语义来说,Date 指日期,Time 指时间,而且还有重名类,因此 java.util.Date 命名定义就有问题。

常用操作

作为开发者,尤其是初学者,一定会对 java.util.Date 类的格式化印象深刻,因为感觉冗余和繁琐,还需要引入其他类。用于格式化和解析的类在 java.text 包中定义,对于格式化和解析的需求,我们有 java.text.DateFormat 抽象类,一般写代码时,我们习惯用 SimpleDateFormat。

Date date = new Date();
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateString = dateFormat.format(date);

另外 java.util.Date 没有提供直接对日期时间的加减操作方法,在修改时间十分不方便。

局限性

java.util.Date 类并不提供国际化,没有时区支持,开发相关业务时还要引入 java.util.Calendar 和 java.util.TimeZone 类,业务开发时增加了代码复杂度。

可变

java.util.Date 类最大的缺陷就是可变性。可变性会在开发中带来很多问题,比如在多线程环境中不可靠,再比如下面的代码执行不符合预期。

Date d = new Date();
Scheduler.scheduleTask(task1, d);
d.setTime(d.getTime() + ONE_DAY);
Scheduler.scheduleTask(task2, d);

task1 和 task2 都会在一天后执行

解决方案

在 Java7 及之前的版本中用第三方时间库,比如 Jode-Time,相信大多数程序猿对这个库十分熟悉。
Java8 引入了新的时间库,java.time 包,其中有关日期时间的类解决了上面这些问题,十分好用。

Spring Mybatis 动态数据源

Annotation 和 Enum

我们首先需要一个注解和枚举类来标识和定义数据源

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface DataSource {
    DataSourceEnum value() default DataSourceEnum.MASTER;
}
public enum DataSourceEnum {
    MASTER, SLAVE
}

DynamicDataSourceAspect

然后监听 service 层接口,获取数据源标识,切点前执行 before() 方法,切点后执行 after() 方法。(xml配置省略)

public class DynamicDataSourceAspect {

    public void before(JoinPoint point) {
        Method targetMethod = ((MethodSignature) point.getSignature()).getMethod();
        DataSource dataSource = targetMethod.getAnnotation(DataSource.class);
        if (dataSource != null) {
            DynamicDataSourceHolder.putDataSource(dataSource.value());
        } else {
            DynamicDataSourceHolder.putDataSource(DataSourceEnum.MASTER);
        }
    }

    public void after(JoinPoint point) {
        DynamicDataSourceHolder.clearDataSource();
    }
}

DynamicDataSourceHolder

我们需要一个 ThreadLocal 来保存当前取到的数据源,这里我们用了一个 Stack,因为可能 A 方法调用了 B 方法,执行完 B 方法后上下文要还原到 A 的数据源来继续执行 A 方法后续语句。因此需要一个先进先出的数据结构来存放数据源。

public class DynamicDataSourceHolder {

    private static final ThreadLocal<Stack<DataSourceEnum>> holder = new ThreadLocal<>();

    public static void putDataSource(DataSourceEnum dataSource) {
        Stack<DataSourceEnum> dataSourceEnums = holder.get();
        if (null == dataSourceEnums) {
            dataSourceEnums = new Stack<>();
            holder.set(dataSourceEnums);
        }

        dataSourceEnums.push(dataSource);
    }

    public static DataSourceEnum getDataSource() {
        Stack<DataSourceEnum> dataSourceEnums = holder.get();
        if (null == dataSourceEnums || dataSourceEnums.isEmpty()) {
            return null;
        }
        return dataSourceEnums.peek();
    }

    public static void clearDataSource() {
        Stack<DataSourceEnum> dataSourceEnums = holder.get();
        if (null == dataSourceEnums || dataSourceEnums.isEmpty()) {
            return;
        }
        dataSourceEnums.pop();
    }
}

DynamicDataSource

Spring 提供 AbstractDataSource 这个抽象类,我们继承这个类实现自己的动态 DataSource,我们我们先看看这个类的主要代码

//有 set 和 get 方法
private Map<Object, Object> targetDataSources;
//有 set 和 get 方法
private Object defaultTargetDataSource;
//在 afterPropertiesSet() 方法中复制自 targetDataSources
private Map<Object, DataSource> resolvedDataSources;
//在 afterPropertiesSet() 方法中复制自 defaultTargetDataSource
private DataSource resolvedDefaultDataSource;

@Override
public void afterPropertiesSet() {
    //复制 targetDataSources -> resolvedDataSources
    //复制 defaultTargetDataSource -> resolvedDefaultDataSource
}

//获取 DataSource 连接
@Override
public Connection getConnection() throws SQLException {
    return determineTargetDataSource().getConnection();
}

//根据 Key 获取指定 DataSource
protected DataSource determineTargetDataSource() {
    Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
    Object lookupKey = determineCurrentLookupKey();
    DataSource dataSource = this.resolvedDataSources.get(lookupKey);
    if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
        dataSource = this.resolvedDefaultDataSource;
    }
    if (dataSource == null) {
        throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
    }
    return dataSource;
}

//重写此方法,动态选择 Key
protected abstract Object determineCurrentLookupKey();

上面方法最后三个方法是重点,调用顺序 getConnection() -> determineTargetDataSource() -> determineCurrentLookupKey(),从一个 Map 中根据 key 来获取对应的 DataSource,最后这个抽象方法是需要我们重写的。

public class DynamicDataSource extends AbstractRoutingDataSource {

    @Setter
    private Object writeDataSource; //写数据源
    @Setter
    private Object readDataSources; //读数据源

    @Override
    public void afterPropertiesSet() {
        if (this.writeDataSource == null) {
            throw new IllegalArgumentException("Property 'writeDataSource' is required");
        }
        setDefaultTargetDataSource(writeDataSource);
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceEnum.MASTER.name(), writeDataSource);
        targetDataSources.put(DataSourceEnum.SLAVE.name(), readDataSources);
        setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        DataSourceEnum dataSourceEnum = DynamicDataSourceHolder.getDataSource();
        if (dataSourceEnum == null) {
            return DataSourceEnum.MASTER.name();
        } else {
            return dataSourceEnum.name();
        }
    }
}

DynamicDataSource 的配置文件如下

<bean id="masterDataSource" class="org.apache.commons.dbcp.BasicDataSource">...</bean>
<bean id="slaveDataSource" class="org.apache.commons.dbcp.BasicDataSource">...</bean>
<bean id="dataSource" class="me.snowhere.datasource.DynamicDataSource">
    <property name="writeDataSource" ref="masterDataSource"/>
    <property name="readDataSources" ref="slaveDataSource"/>
    <property name="defaultTargetDataSource" ref="masterDataSource"/>
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource"/>
</bean>

其他

上面只是实现了一个最简单的,可以工作的动态数据源,并不适合放到工程中,因为还有很多地方需要扩展:
1. 切面和注解。除了注解方法,我们还可以设置注解到类上以及接口上,并在 DynamicDataSourceAspect 中按照优先级依次寻找方法、类、接口上的注解,确定数据源标识。
2. 动态数据源。在 DynamicDataSource 中,我们只有一个主数据源和从数据源,我们可以把从数据源改为 List,在 targetDataSources.put() 时的 key 为 DataSourceEnum.SLAVE.name() + index,并设置一个属性来标识选择从库的策略,比如随机或轮询,来确定 index 的值选择具体从库。实现一主多从的架构。

另外还有一些其他需要注意的问题:
1. 直接用 this 调用当前对象的方法不会被切面拦截,配置的数据源标识也就无效
2. 事务不能跨数据源

有关工程各个分层中方法参数定义的一些思考

一般 Java 后端开发中,习惯上会将工程分为 3 层,controller,service 和 dao。controller 负责接收解析校验请求和返回,service 处理业务逻辑,dao 执行数据库语句。

最近在重构一个工程,里面很多业务模块都是不同的人写的,每个人的代码风格和习惯都不同,看了各式各样的方法命名和参数定义,我也略有感触和思考。见过最狠的就是从 controller 层解析请求到 dao 层的数据库语句,其中涉及的方法全用 Map 作为参数一层一层向下传,真是让我大开眼界。

总体来说,定义方法参数有 2 种风格习惯:

  • 第一种是把所需要的变量作为一个个单独参数来传递,
  • 第二种则是将零散的变量组装成单个复合参数来传递,比如上面说的 Map,或者自定义的 DTO。

controller 层

先说说 controller 层,我建议用第一种方式,原因很简单,第一种方式能直观地看出接口需要的各个参数及类型,并直接在 controller 中对参数进行处理和校验,IDE 也会对未使用到的参数进行提醒。使用 DTO 则有可能会由于业务变更出现冗余字段,维护时容易忽视,久而久之 DTO 变臃肿,开发者也不能搞清楚接口到底需要参数。下面两种方式对比一下:

public Response addUser(@RequestParam("id") Integer id,
                        @RequestParam("age") Integer age,
                        @RequestParam("sex") Integer sex,
                        @RequestParam("name") String name) 
public Response addUser(RequestUserDTO requestUserDTO)

service 层

然后说说 service 层。
* 我认为在参数个数不是很多的情况下,建议用第一种方式,保持代码的简洁。
* 如果参数很多,或者业务变动频繁导致参数变化频繁的情况下,可以考虑使用第二种方式,第二种方式的优势就在于参数的变动不会影响到方法声明,我们只需要关心 DTO 中的变量,然后代码逻辑中处理这些变量。尤其在 service 层十分方便,service 一般都是一个接口对应一个实现类,因为不确定其他工程对原方法的依赖和调用,所以如果方法变更参数,接口和类中都必须保留原方法,增加新的方法来重载原方法,而用第二种方式传递参数,则可以避免这些修改。
* 在业务参数和逻辑参数混杂的情况下,可以使用两种方式结合,比如下面这样:

public PageInfo<User> userPageList(User user, Integer pageNum, Integer pageSize)
boolean updateUser(User user, String operator);

第一个方法使用了分页插件,所以分页参数并不会传递进 dao 层;第二个方法需要额外记录操作人

dao 层

最后看看 dao 层,我觉得 dao 层没什么多说的,两种方式都可以,看情况选择。

注意的地方

除了上面所说的工程中不同层级方法的参数定义方式,我还想说一些其他东西。
1. 不要用 Map。这真的真的很蠢,不能描述出所需变量的个数、名称和类型,如果存的变量类型不同还不能用泛型,只能取的时候强制转型。
2. 不同层不要用同一个 DTO。不同层都有自己的职责和意义,controller 层的 DTO 为了描述网络请求的参数,service 层的 DTO 为了描述业务相关的变量,dao 层的 DTO 为了描述数据库语句需要的参数。有的开发者为了方便,在开发一个功能时,定义一个字段很多的 DTO,从 controller 用到 dao,这也是很蠢的操作。不同层级的 DTO 放在各自的工程模块中,一方面可以解耦,另一方面可以让开发者加深工程化思想。

以上观点仅仅是我个人基于我当前的工作经验总结出来的,欢迎交流沟通。
我总结完才发现说的有点所谓的“理想化的最佳实践”了,工程中还是自由派比较多,大家写代码开心就好。

离职

今天组内有位同事离职了,准确说是跳槽吧,无所谓了,反正是和每个人悄悄打了个招呼就突然离开了。离职的是个小哥哥,在职时间不是很长,也就 1 年多点。

对于我来说,这只是一件小事而已,不过,我看到有同事依依不舍的抹眼泪了。说实话,这对我的触动很大。同事基本都是刚毕业的年轻人,可能没经历过太多的这种场面。而且基本上都是刚从学校出来的第一份工作。那种学生时代的情谊还在他们身上体现着,对同伴的不舍也可以理解。

在我看来,IT行业,人员流动是很正常的现象,我遇到的离职,入职,包括我自己的跳槽,加起来都不知道有多少了。甚至我的第一份工作仅仅持续了 1 年的时间,就遇到了部门解散,同事各奔东西的情况。或许面对了这么多,我也有些麻木了,有些淡然了,有些不近人情了。总感觉自己和同事相处表现的有点内向,有点自闭。一方面担心某天分别时的不舍和尴尬,另一方面也没有精力去联系曾经的同事。那干脆就不要混的太熟了,不然离个职还要像情侣分手般不舍。

但这又往往是领导所担心的吧。对于管理层来说,团队的产出是基于团队成员的协作,关键就在于凝聚力,团队精神和归属感。这也是为什么很多销售的培训和公司的拓展训练都是培养这些东西。对于我们团队来说,平时一起吃午饭,打乒乓球就是增进团队氛围的一种方式,另外小组领导还会出钱找个时间让大家一起去玩玩。其实,在我看来,领导最费心的不是技术问题,而是带好整个团队。

不过,我看到的他的离职,内心却是十分高兴的。真的是发自内心的高兴,而且是替他高兴。因为我相信他是的目标很明确,敢于离开自己的舒适区。
我相信一点:真正做技术的人,应该是自由的 。热爱着自由,追寻着自由。从一行行飘逸的代码,到灵活的架构。从项目的开源协作,到成果和技术的分享。从生活到生命,无时无刻体现一种自由的态度和对自由的追寻。

我刚刚入职不久,他是我入职带我的人,而且是我试用期评分人,我马上试用期就要结束了,HR 肯定要找我谈话,很定避免不了这个问题。我打算就把上面这些话讲给 HR 听了。哈哈哈哈。

一个简单的同事离职,我却想了这么多,想到了同事,想到了自己,想到了管理层,想到了离职的他。回顾这一切,最后还是欣慰和高兴。毕竟我也是离职过的,站在离职同事的角度比较多。
可惜那位同事离开的有点快。真想给那位同事一个大大的笑脸,然后祝那位同事前程似锦,加油!

[翻译]我们把最厉害的人炒掉了,这是我们做过最正确的决定

We fired our top talent. Best decision we ever made.

“你们不会理解我创造的一切。我TMD就是爱因斯坦,你们都是玩泥巴的小屁孩。”

我们的天才 Jekyll 医生愤怒的变身为 Hyde。(经典小说,善良的医生Jekyll,他将自己当作实验对象,结果却导致人格分裂,变成夜晚会转为邪恶Hyde的双重人格)

他是在产品设计小组、开发人员、管理人员和试用用户面前说出的这句话。我们项目的赞助商之一鲁莽的询问我们的产品何时修复严重缺陷。

天才都是反复无常的野兽。运气好时和你共事的是个疯狂的天才,其他时候他只是纯粹的疯狂。大多时候很难区分这两者。

这个故事关于一名天才团队成员的陨落,他对我们的产品架构有深入理解,有预测未来需求的不可思议的能力,有大量专业领域知识积累。

他是贡献最多的成员。他曾驾驭我们的旗舰项目。我们叫他 Rick 好了。(出自高分动画「瑞克和莫蒂」,豆瓣评分9.8)

你不会想让这个家伙在你的团队里的。

Rick 是团队里公认的天才。他是首席开发工程师及项目架构师。

任何时候任何人有关于代码的问题或任务需要帮忙,他们都会找 Rick。Rick 在办公室装了一块白板专门处理这种事情。白板上总留有无法擦去的凌乱痕迹记录过去的讨论。

无论什么时候出现特别有挑战性的问题,Rick 会处理它。Rick 电脑上安装了与生产环境服务器参数相同的服务器。他可以用这个服务器独立运行整个应用程序栈,同时在应用各个层面排查问题。

Rick 不依赖任何人。他喜欢孤军奋战在他的私人工作间。

Rick 从不需要别人造好的轮子。他什么都自己造,从无到有,因为自己造的轮子比其他凡人做的平庸之物要好太多。

很快,Rick 不再参加会议。因为太多代码需要编写,他没有时间参加会议。

Rick 关上工作间的门,白板也闲置了。他没有时间指导别人,因为他自己有很多事情需要解决。

Rick 身后开始积压工作。他造的轮子也开始出现 bug。这些压榨了他投入在新产品开发上的精力。

当然,这些 bug 出现是由于使用者操作不当,Rick 的工作没有任何问题。

在我们的项目仪表盘上,标志由绿色变成了黄色,黄色又变成了红色,红色的灯疯狂闪烁。任务一个又一个陷入不可用状态。所有人都在等待 Rick 处理。

不必担心,Rick 会把这些全都解决掉。

项目经理从赞助商那里获得了六个月的延期。六个月到了,生产准备预计还要七个月。等到一年过去了,生产准备两年了。

Rick 比之前更加高效地产出代码。他每周工作七天,每天工作十二个小时。

每个人都知道只有 Rick 能拯救团队。大家屏住呼吸等待 Rick 创造特效良药,将项目起死回生。

每天,Rick 的孤立感和好战心都在增加。面具逐渐脱离,Jekyll 变成了 Hyde。

我和项目团队第一次会议有关项目延期的两年。我突然意识到这个项目,因为它在公司里声名狼藉,我也是刚刚指派到这个项目。

我被指派进来,看看我们能不能拯救项目。

我和这个项目有关的第一场会面便是上面提到的“爱因斯坦”。

嗯······

我看了源码。Rick 说得对,除了他自己,没人理解他创造的东西。这些全都是他自己思维的工作产物。其中一些很巧妙,大部分还是复制粘贴的。它们都很独特,并不是所有东西都有文档。

我给了我们的 CIO(首席信息官)一份报告。只有 Rick 能够维护这个产品。另外,Rick 每工作一天都会让项目交付延期一周。他对项目的破坏速度比创造速度要快。

我们和 Rick 坐下来讨论他在项目中的角色。我们评估了关注点。我们回避了他和爱因斯坦的比较。我们解释了新的战略。团队需要合作,从零开始构建一个新产品。

我们的努力必须限制在一定范围内,并且仅仅完成产品基础功能。整个团队贡献代码并可以提供技术支持。不再会有瓶颈。

Rick 对此作何反应?

Rick 选择了一如既往风格,瞬间爆炸。

Rick 并不想理会这场闹剧。认为如果我们不能欣赏他的天赋,那就是我们的问题,而不是他的问题。Rick 预计不出一个月我们会狼狈而归乞求他拯救我们。

Rick 向我们咆哮说我们庙太小,容不下他这尊佛。

很遗憾,这之后,Rick 拒绝了领导的未来几个月的安排。他拒绝休息,也不同意工作移交他人。他屡次拒绝引入免费开源的框架来替代他自己的难以维护的定制工具。

他回退了其他开发者测试修复 bug 的代码变更。他宣称自己没有责任支持其他同事的工作。他不断公开藐视同事。

我们解雇了 Rick。

整整一周时间才尘埃落定。失去大将的军队需要一定时间来稳定军心。

然后我看到他们在白板前挤作一团。

合作。Rick 从不懂这个词

他们开始合作,一起设计更简单的替代产品。

新产品没有华而不实东西,也没有根据五年后的产品路线来预计需求。

Rick 的产品动态工作流支持超过一万五千种排列。事实上,我们 99% 的用例只遵循其中三分之一的路径。团队对工作流硬编码。这移除了超过 30% Rick 的工作。

每个任务并不能使用定制的硬编码组件。他们把能购买到的组件替换掉自己定制构建的组件。

这移除了 Rick 上百小时的贡献。当然这也移除了上千小时的技术债务(technical debt)。

我们和项目赞助商协商达成一致,砍掉一些边缘功能。

这些功能只服务于 5% 的试用用户组,但给在产品中占有四分之一的复杂度。

我们向试用用户组重新发布产品。产品包含 Rick 写的稳定运行的 10% 的源代码,另外用几千行新代码替换掉了十五万行难以理解的代码。

整个团队六个月完成了五年的工作量。接下来几个月时间我们把试用版扩展为完整的客户版本。

我们不仅替换掉 Rick 造的轮子,而且超越他并全面推出了产品,所有这一切只用了不到一年时间。产品的大小和复杂度只有 Rick 所做的五分之一。

尽管产品组装时间很短,使用的客户翻了十倍,但产品的效率仍提升上百倍,而且几乎没有 bug。

团队回到 Rick 其他产品中,他们剔除了 Rick 的旧代码。

团队协作三个月就重新发布了 Rick 三年时间开发的产品。

团队中不再有 Rick 这种人。我们也不再看到这种从无到有造轮子疯狂的天才。但我们的生产力居高不下。

Rick 是名非常有天赋的开发者。他能够解决复杂的商业逻辑问题,可以创造复杂精妙的结构来支持他的高级设计。Rick 不能解决的问题是如何使团队高效工作。

Master builders are cool, but skyscrapers are built by teams. (image © Warner Bros. Animation and The Lego Group)
建筑工程队长是很酷,但摩天大楼是团队盖起来的。

Rick 的存在在某些方面是有破坏性的。

首先,他创造了个人崇拜的依赖。所有问题都变成 Rick 的问题,他变成一个神话。开发者习惯放弃尝试,只等着 Rick 解决。

其次,他写的代码不易维护。他从不写文档或测试代码,他的聪明才智也无法阻止失败。他对自己可靠性的信仰让他忽略了常识。

然后,他个人有破坏性。团队成员不想和他交流想法,因为他总是批判他们。Rick 只尊重它自己,以他自己的方式生活,让其他人感觉渺小。

最后,他缺少个人责任感。所有失败都不是他的错。他坚信这一点,这也阻止了他从错误中学习和进步。

我并不认为 Rick 从开始就这个样子。我看到的是他最糟糕的样子。他经历了数年愈演愈烈的加班,面对了同事和客户逐渐增加的苛刻要求。

很遗憾,Rick 走远了。他的经理也有责任。事实上,原来的管理团队承担了责任,他们首先离开了。

不幸的是 Rick 走的太远了,以至于他不能回到正轨。即使再多的辅导、反馈、休假或指派其他项目都无法改变他的不良行为习惯。

在这一点上,整个团队知道他不好的地方。但个人崇拜的依赖如此强烈,每个人相信 Rick 是唯一的救命稻草。

其实一直都有其他选择的。

团队力量和每个单独成员的天赋无关,而是有关于他们的合作、斗志和相互尊重。

构建团队要注重发挥每个人的价值,让每个人都发挥自己的最好水平。

团结一心,他们就可以应对 Rick 都无法触及的更大的挑战。

我发表了一篇后续故事,欢迎阅读

如果觉得文章不错,欢迎点赞。

备注:一些细节(比如人名)已经处理过。我的同事中没有叫 Rick 的。

[翻译]Quora 是如何做持续部署的?

Continuous Deployment at Quora

在 2013 年 4 月 25 日中午 12 点到晚上 11 点 59 分之间,Quora 站点发布了 46 次新版本。这对于我们来说只是普通的一天。我们执行非常快的持续部署周期,代码变动提交后就直接推送到线上。这使得我们可以在各个层面上实现平行化开发。我们希望推送系统足够快,让开发者尽快看到他们对生产环境的改动(目前生产环境修订版上线平均要 6、7 分钟),同时也要注意可靠性和灵活性,让我们可以迅速响应问题。

对开发者而言,只需要一个简单的命令把代码推送到到生产环境:git push

这背后发生的事情要复杂很多。每当一个开发者把提交推送到我们的主 git 仓库,一个 post-receive 钩子会将最新的修订版加入到发版申请列表,并记录到 MySQL 数据库。(post-receive 钩子也会把提交加到 Phabricator,我们用它做代码评审。更多关于我们代码评审的相关信息参阅这个回答 Does Quora engineering use a code review process?)一个内部监控网站展示每个等待发布的修订版的状态。

一个后端服务监控发版申请列表,每当提交新的修订版,服务收集此版本代码库中所有单元测试的名字。我们有上百个测试模块和上千个独立的测试,服务会将测试分配到一些 worker 机器中并行处理。当 worker 运行完测试,它们将结果返回给测试服务,服务在发版申请的列表中标记修订版的综合结果(成功或失败,以及多少个测试失败了和失败的详情)。

同时,另一个服务监控发版申请列表,等待打包新修订版。每当提交新的修订版,它将所有需要运行在我们服务器上的代码进行归档,并打包上传到 Amazon S3。

当修订版打包好后,一个集成测试服务将修订版推送到一台单独的机器(并不是生产环境),用新的包开启 web 服务,并向服务发送请求。只有每个请求返回 200 状态码,集成测试才算通过,如果任何请求返回 4xx 或 5xx 错误码,测试失败。

最后,第四个监控发版申请列表的服务由其他三个服务调用,当修订版的测试和打包没有问题,服务向 S3 上传一个包含版本号的小型元数据文件,来标记修订版的部署(发布)。Web 服务器和其他使用相同代码的机器会周期性检查元数据文件中的版本号,如果有变化,它们会立刻从 S3 上下载最新的包。下载并解压包大概需要一分钟,然后运行新的代码只需要几秒。每台需要代码的机器会独立完成上述操作。我们将这个系统命名为 Zerg,因为在所有需要包的机器上进行部署过程很像虫族(zerg)的 rush 战术。

下图描述了后端架构:

这套系统弹性很大,很少出现失败,但正如其他复杂的系统一样,也会出现失败情况。要么测试 worker 宕机,要么测试服务失败,要么打包程序出现问题。通过这种架构,内部的失败并不会引起一致性问题(比如把未通过单元测试的代码推送到生产环境),并且大多数时候只需要重启失败的机器或服务便可以让其正常工作。

对于大多数修订版,git push 到发布大约间隔 6 分钟,这取决于其中执行时间最长的任务。目前,单元测试是时间最长的任务;打包需要 2-3 分钟,集成测试需要 3 分钟多一点。之后 10 分钟(发布之后),机器下载运行新代码,同时后面的修订版开始测试和打包。(我们不会同一时间更新所有机器,这会导致每次我们发布时,Quora 会有几分钟处于不可用状态!)选择 10 分钟的间隔是为了可靠性 —— 如果我们需要响应突发事件,可以立刻覆盖部署代码。

我认为 6 分钟的测试 + 10 分钟的部署还不够好。我们可以改进测试系统,使其并行测试来提高效率。另外,我们去除掉其他服务中无用的东西,这让我们可以将部署时间由 10 分钟缩短为 5 分钟。

系统的设计主要基于其他公司部署项目时遇到的问题。我们在公司早期就决定采用持续部署方案。在公司规模、代码库、基础架构很小时便于使用这种方案,但我们仍努力多年来维护这一流程,因为持续部署在整个开发流程和开发文化中举足轻重:

  • 持续部署让我们尽可能迅速地将产品的变更展现在用户面前,包括从 bug 的修复到主要特性等一系列东西。
  • 持续部署让我们尽可能迅速隔离并解决出现的问题。当出现 bug,你倾向于在单个提交中 debug,还是从包含一百个提交的整体发版中 debug?
  • 持续部署让我们在改进网站时不需要投入过多精力。我们直到经历了这些之后才意识到这点 —— 持续部署让我们在几分钟之内完成发现问题、快速修复、推送代码并部署到生产环境。如果这些动作时间过长,开发者可能会想“我难道要花一个小时来坐下跟踪代码的推送吗?”。更糟糕的是,如果隔天部署,开发者会想“我难道要明天再审查一遍然后测试代码吗”
  • 持续部署减少了跟踪不同发布状态的多个版本这一工作上的投入。代码是在生产环境还是在发版列表的未推送状态,都一目了然。
  • 持续部署让我们有测试的习惯。毫无疑问,测试非常重要。伴随着变更需要立刻上线的压力,我们没有之后再写测试的余地。我们总是先写好测试。
  • 持续部署很有趣!写代码很有趣,我们的部署过程也应该同样有趣。

通过减少每次版本上线需要的时间,并加强测试,我们每天可以上线更多的修订版,并有效减小变更伴随的阻碍。这也是 Quora 这类快速起步的公司需要的东西。