原来你是这样的 Stream —— 浅析 Java Stream 实现原理

Stream 为什么会出现?

Stream 出现之前,遍历一个集合最传统的做法大概是用 Iterator,或者 for 循环。这种两种方式都属于外部迭代,然而外部迭代存在着一些问题。

  • 开发者需要自己手写迭代的逻辑,虽然大部分场景迭代逻辑都是每个元素遍历一次。
  • 如果存在像排序这样的有状态的中间操作,不得不进行多次迭代。
  • 多次迭代会增加临时变量,从而导致内存的浪费。

虽然 Java 5 引入的 foreach 解决了部分问题,但也引入了新的问题。

  • foreach 遍历不能对元素进行赋值操作
  • 遍历的时候,只有当前被遍历的元素可见,其他不可见

随着大数据的兴起,传统的遍历方式已经无法满足开发者的需求。
就像小作坊发展到一定程度要变成大工厂才能满足市场需求一样。大工厂和小作坊除了规模变大、工人不多之外,最大的区别就是多了流水线。流水线可以将工人们更高效的组织起来,使得生产力有质的飞跃。

所以不安于现状的开发者们想要开发一种更便捷,更实用的特性。

  • 它可以像流水线一样来处理数据
  • 它应该兼容常用的集合
  • 它的编码应该更简洁
  • 它应该具有更高的可读性
  • 它可以提供对数据集合的常规操作
  • 它可以拼装不同的操作

经过不懈的能力,Stream 就诞生了。加上 lambda 表达式的加成,简直是如虎添翼。

你可以用 Stream 干什么?

下面以简单的需求为例,看一下 Stream 的优势:

从一列单词中选出以字母a开头的单词,按字母排序后返回前3个。

外部迭代实现方式

List<String> list = Lists.newArrayList("are", "where", "advance", "anvato", "java", "abc");
List<String> tempList = Lists.newArrayList();
List<String> result = Lists.newArrayList();
for( int i = 0; i < list.size(); i++) {
    if (list.get(i).startsWith("a")) {
        tempList.add(list.get(i));
    }
}
tempList.sort(Comparator.naturalOrder());
result = tempList.subList(0,3);
result.forEach(System.out::println);

stream实现方式

List<String> list = Lists.newArrayList("are", "where", "anvato", "java", "abc");
list.stream().filter(s -> s.startsWith("a")).sorted().limit(3)
                .collect(Collectors.toList()).forEach(System.out::println);

Stream 是怎么实现的?

需要解决的问题:

  • 如何定义流水线?
  • 原料如何流入?
  • 如何让流水线上的工人将处理过的原料交给下一个工人?
  • 流水线何时开始运行?
  • 流水线何时结束运行?

总观全局

Stream 处理数据的过程可以类别成工厂的流水线。数据可以看做流水线上的原料,对数据的操作可以看做流水线上的工人对原料的操作。

事实上 Stream 只是一个接口,并没有操作的缺省实现。最主要的实现是 ReferencePipeline,而 ReferencePipeline 继承自 AbstractPipelineAbstractPipeline 实现了 BaseStream 接口并实现了它的方法。但 ReferencePipeline 仍然是一个抽象类,因为它并没有实现所有的抽象方法,比如 AbstractPipeline 中的 opWrapSinkReferencePipeline内部定义了三个静态内部类,分别是:Head, StatelessOp, StatefulOp,但只有 Head 不再是抽象类。

图1

流水线的结构有点像双向链表,节点之间通过引用连接。节点可以分为三类,控制数据输入的节点、操作数据的中间节点和控制数据输出的节点。

ReferencePipeline 包含了控制数据流入的 Head ,中间操作 StatelessOp, StatefulOp,终止操作 TerminalOp

Stream 常用的流操作包括:
* 中间操作(Intermediate Operations)
* 无状态(Stateless)操作:每个数据的处理是独立的,不会影响或依赖之前的数据。如 filter()flatMap()flatMapToDouble()flatMapToInt()flatMapToLong()map()mapToDouble()mapToInt()mapToLong()peek()unordered()
* 有状态(Stateful)操作:处理时会记录状态,比如处理了几个。后面元素的处理会依赖前面记录的状态,或者拿到所有元素才能继续下去。如 distinct()sorted()sorted(comparator)limit()skip()
* 终止操作(Terminal Operations)
* 非短路操作:处理完所有数据才能得到结果。如 collect()count()forEach()forEachOrdered()max()min()reduce()toArray() 等。
* 短路(short-circuiting)操作:拿到符合预期的结果就会停下来,不一定会处理完所有数据。如 anyMatch()allMatch()noneMatch()findFirst()findAny() 等。

源码分析

了解了流水线的结构和定义,接下来我们基于上面的例子逐步看一下源代码。

定义输入源

stream() 是 Collection 中的 default 方法,实际上调用的是 StreamSupport.stream() 方法,返回的是 ReferencePipeline.Head的实例。

ReferencePipeline.Head 的构造函数传递是 ArrayList 中实现的 spliterator 。常用的集合都实现了 Spliterator 接口以支持 Stream。可以这样理解,Spliterator 定义了数据集合流入流水线的方式。

定义流水线节点

filter() 是 Stream 中定义的方法,在 ReferencePipeline 中实现,返回 StatelessOp 的实例。

可以看到 filter() 接收的参数是谓词,可以用 lambda 表达式。StatelessOp的构造函数接收了 this,也就是 ReferencePipeline.Head 实例的引用。并且实现了 AbstractPipeline 中定义的 opWrapSink 方法。

@Override
public final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {
    Objects.requireNonNull(predicate);
    return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,
                                         StreamOpFlag.NOT_SIZED) {
        @Override
        Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
            return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
                @Override
                public void begin(long size) {
                    downstream.begin(-1);
                }

                @Override
                public void accept(P_OUT u) {
                    if (predicate.test(u))
                        downstream.accept(u);
                }
            };
        }
    };
}

sorted()limit() 的返回值和也都是 Stream 的实现类,并且都接收了 this 。不同的是 sorted() 返回的是 ReferencePipeline.StatefulOp 的子类 SortedOps.OfRef 的实例。limit() 返回的 ReferencePipeline.StatefulOp 的实例。

现在可以粗略地看到,这些中间操作(不管是无状态的 filter(),还是有状态的 sorted()limit() 都只是返回了一个包含上一节点引用的中间节点。有点像 HashMap 中的反向单向链表。就这样把一个个中间操作拼接到了控制数据流入的 Head 后面,但是并没有开始做任何数据处理的动作

这也就是 Stream 延时执行的特性原因之所在。

参见附录I会发现 StatelessOp 和StatefulOp 初始化的时候还会将当前节点的引用传递给上一个节点。

previousStage.nextStage = this;

所以各个节点组成了一个双向链表的结构。

组装流水线

最后来看一下终止操作 .collect() 接收的是返回类型对应的 Collector。

此例中的 Collectors.toList() 是 Collectors 针对 ArrayList 的创建的 CollectorImpl 的实例。

@Override
@SuppressWarnings("unchecked")
public final <R, A> R collect(Collector<? super P_OUT, A, R> collector) {
    A container;
    if (isParallel()
        && (collector.characteristics().contains(Collector.Characteristics.CONCURRENT))
        && (!isOrdered() || collector.characteristics().contains(Collector.Characteristics.UNORDERED))) {
        container = collector.supplier().get();
        BiConsumer<A, ? super P_OUT> accumulator = collector.accumulator();
        forEach(u -> accumulator.accept(container, u));
    }
    else {
        container = evaluate(ReduceOps.makeRef(collector));//1
    }
    return collector.characteristics().contains(Collector.Characteristics.IDENTITY_FINISH)
        ? (R) container
        : collector.finisher().apply(container);
}

先忽略并行的情况,来看一下加注释了1的代码:

  1. ReduceOps.makeRef 接收此 Collector 返回了一个 ReduceOp(实现了 TerminalOp 接口)的实例。
  2. 返回的 ReduceOp 实例又被传递给 AbstractPipeline 中的 evaluate() 方法。
  3. evaluate 中,调用了 ReduceOp实例的 evaluateSequential 方法,并将上流水线上最后一个节点的引用和 sourceSpliterator 传递进去。
@Override
public <P_IN> R evaluateSequential(PipelineHelper<T> helper, Spliterator<P_IN> spliterator) {
    return helper.wrapAndCopyInto(makeSink(), spliterator).get();
}
  1. 然后调用 ReduceOp 实例的 makeSink() 方法返回其 makeRef() 方法内部类 ReducingSink 的实例。
  2. 接着 ReducingSink 的实例作为参数和 spliterator 一起传入最后一个节点的 wrapAndCopyInto() 方法,返回值是 Sink 。

启动流水线

流水线组装好了,现在就该启动流水线了。这里的核心方法是 wrapAndCopyInto,根据方法名也能看出来这里应该做了两件事,wrapSink()copyInto()

wrapSink()

将最后一个节点创建的 Sink 传入,并且看到里面有个 for 循环。参见附录I可以发现

每个节点都记录了上一节点的引用( previousStage )和每一个节点的深度( depth )。

所以这个 for 循环是从最后一个节点开始,到第二个节点结束。每一次循环都是将上一节点的 combinedFlags 和当前的 Sink 包起来生成一个新的 Sink 。这和前面拼接各个操作很类似,只不过拼接的是 Sink 的实现类的实例,方向相反。

(Head.combinedFlags, (StatelessOp.combinedFlags, (StatefulOp.combinedFlags,(StatefulOp.combinedFlags ,TerminalOp.sink)))

@Override
@SuppressWarnings("unchecked")
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
   Objects.requireNonNull(sink);

   for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
       sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
   }
   return (Sink<P_IN>) sink;
}

copyInto()

终于到了要真正开始迭代的时候,这个方法接收两个参数 Sink<P_IN> wrappedSink, Spliterator<P_IN> spliteratorwrappedSink对应的是 Head节点后面的第一个操作节点(它相当于这串 Sink 的头),spliterator 对应着数据源。

这个时候我们回过头看一下 Sink 这个接口,它继承自 Consumer 接口,又定义了 begin()end()cancellationRequested() 方法。Sink 直译过来是水槽,如果把数据流比作水,那水槽就是水会流过的地方。begin() 用于通知水槽的水要过来了,里面会做一些准备工作,同样 end() 是做一些收尾工作。cancellationRequested() 是原来判断是不是可以停下来了。Consumer 里的accept() 是消费数据的地方。

@Override
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
   Objects.requireNonNull(wrappedSink);
   if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
       wrappedSink.begin(spliterator.getExactSizeIfKnown());//1
       spliterator.forEachRemaining(wrappedSink);//2
       wrappedSink.end();//3
   }
   else {
       copyIntoWithCancel(wrappedSink, spliterator);
   }
}

有了完整的水槽链,就可以让水流进去了。copyInto() 里做了三个动作:

  1. 通知第一个水槽(Sink)水要来了,准备一下。
  2. 让水流进水槽(Sink)里。
  3. 通知第一个水槽(Sink)水流完了,该收尾了。

突然想到宋丹丹老师的要把大象放冰箱要几步?

注:图中蓝色线表示数据实际的处理流程。

每一个 Sink 都有自己的职责,但具体表现各有不同。无状态操作的 Sink 接收到通知或者数据,处理完了会马上通知自己的 下游。有状态操作的 Sink 则像有一个缓冲区一样,它会等要处理的数据处理完了才开始通知下游,并将自己处理的结果传递给下游。

例如 sorted() 就是一个有状态的操作,一般会有一个属于自己的容器,用来记录处自己理过的数据的状态。sorted() 是在执行 begin 的时候初始化这个容器,在执行 accept 的时候把数据放到容器中,最后在执行 end 方法时才正在开始排序。排序之后再将数据,采用同样的方式依次传递给下游节点。

最后数据流到终止节点,终止节点将数据收集起来就结束了。

然后就没有然后了,copyInto() 返回类型是 void ,没有返回值。

wrapAndCopyInto() 返回了 TerminalOps 创建的 Sink,这时候它里面已经包含了最终处理的结果。调用它的 get() 方法就获得了最终的结果。

回顾

再来回顾一下整个过程。首先是将 Collection 转化为 Stream,也就是流水线的头。然后将各个中间操作节点像拼积木一样拼接起来。每个中间操作节点都定义了自己对应的 Sink,并重写了 makeSink() 方法用来返回自己的 Sink 实例。直到终止操作节点出现时才开始将 Sink 实例化并串起来。然后就是上面提到的那三步:通知、数据流入、结束。

本文介绍和分析了最常规的 stream 用法和实现,实际上 stream 还有很多高阶用法,比如利用协程实现的并行流。感兴趣的同学可以研究一下。当然既然是高阶用法,用的时候一定要多注意。

参考

附录I

以下是初始化 Head 节点和中间操作的实现。

/**
 * Constructor for the head of a stream pipeline.
 *
 * @param source {@code Spliterator} describing the stream source
 * @param sourceFlags the source flags for the stream source, described in
 * {@link StreamOpFlag}
 * @param parallel {@code true} if the pipeline is parallel
 */
//初始化Head节点的时候会执行
AbstractPipeline(Spliterator<?> source,
                 int sourceFlags, boolean parallel) {
    this.previousStage = null;
    this.sourceSpliterator = source;
    this.sourceStage = this;
    this.sourceOrOpFlags = sourceFlags & StreamOpFlag.STREAM_MASK;
    // The following is an optimization of:
    // StreamOpFlag.combineOpFlags(sourceOrOpFlags, StreamOpFlag.INITIAL_OPS_VALUE);
    this.combinedFlags = (~(sourceOrOpFlags << 1)) & StreamOpFlag.INITIAL_OPS_VALUE;
    this.depth = 0;
    this.parallel = parallel;
}

/**
 * Constructor for appending an intermediate operation stage onto an
 * existing pipeline.
 *
 * @param previousStage the upstream pipeline stage
 * @param opFlags the operation flags for the new stage, described in
 * {@link StreamOpFlag}
 */
//初始化中间操作StatelessOp和StatefulOp的时候会执行
AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
    if (previousStage.linkedOrConsumed)
        throw new IllegalStateException(MSG_STREAM_LINKED);
    previousStage.linkedOrConsumed = true;
    previousStage.nextStage = this;

    this.previousStage = previousStage;
    this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
    this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
    this.sourceStage = previousStage.sourceStage;
    if (opIsStateful())
        sourceStage.sourceAnyStateful = true;
    this.depth = previousStage.depth + 1;
}
0

《系统架构》读书笔记:架构到底是什么?

引子

这个月读了一本书,《系统架构》。然而这本书讲的不仅仅是软件系统的架构,而是更高一个层面,它讲的是复杂系统的架构。这本书的三位作者,有两位是航空航天专业的教授和副教授,所以书里用了不少 NASA 的项目为案例,比如人类有史以来最复杂的工程——阿波罗计划。读完这本书,让我对架构的认识提升了一个高度,原先各种离散的关于架构的知识和理解,在这个框架下,终于可以融会贯通了。

系统和系统思维

首先,系统是什么?按本书的定义,系统是由一组实体和这些实体之间的关系所构成的集合,而其功能要大于这些实体各自的功能之和。后半句很重要,如果一个系统的功能,等于其部件的功能之和,那么这个系统就没有存在的意义。因为我们单独使用那些部件,也可以得到需要的功能。只有当这些部件组合时,能够涌现出新的功能,那才算是组成了一个系统。

要理解系统架构,首先要有系统思维。所谓系统思维,就是把某个疑问、某种状况或某个难题明确地视为一个系统,也即是视为一组相互关联的实体,而不是孤立的一个对象。

系统思维要有四个步骤是:
1. 确定系统整体的形式与功能。
2. 确定系统中包含的组件,组件的形式与功能。
3. 确定系统中各个组件之间的关系,并且定这些关系的形式及其功能。
4. 根据组件的功能及功能性的互动来确定系统的涌现属性。

系统思维的初级目标是理解系统是什么,更进一层的目标是为了预测系统在发生某些变化之后的情况。而最高级的目标,则是用部件来合成一个系统,这个过程也就是所谓的系统架构。

系统架构的分析

形式与功能

形式是系统的物理体现或信息体现,它有助于功能的执行。形式可以分为形式对象,以及这些形式对象之间具有的形式关系(也就是结构)。例如,汽车作为一个系统,它的形式对象就是汽车部件,而软件系统的形式对象则是模块、过程、代码和指令,而这些形式对象则通过不同的结构组装成一个系统。

系统的另一个属性就是功能。功能由过程和操作对象组成,过程在操作对象上执行之后,会改变操作对象的状态。当系统对外展现的功能对系统的外部的对象进行操作时,系统的价值就体现出来了。例如,离心泵中的电动机可以带动叶轮旋转,这是它内部的一个功能。而离心泵作为一个系统,它可以给外部对象(例如某种液体)加压,从而移动液体。这也就是它的功能和价值所在。

屏幕快照_2018-10-11_上午11.28.19

形式和功能的区别就是,形式决定了系统是什么,而功能决定了系统能做什么。架构其实就是形式与功能之间的映射。形式结构对功能起着承载作用,或者能够促进相关的性能。在复杂系统里,形式与功能的映射会非常复杂,包含很多不确定的问题,或者非理想的因素。因此架构师需要使用抽象等方法来简化架构,以便能够更好的理解和交流架构。

另外,还有一种特殊的功能,叫做解决方案无关的功能。例如,我要从上海出差去北京,那么“将旅客从上海快速、安全的运送到北京”就是解决方案无关的功能,而“使用高铁/飞机将旅客从上海运送到北京”则不是。好的系统规范书,应该是使用与特定解决方案无关的功能来描述的。如果系统规范书将人引导向某种具体的解决方案,可能会令架构师的视野变窄,从而不去探索更多的潜在选项。

从概念到架构

前面说过,架构就是功能与形式之间的映射,但对于复杂系统,这种映射往往非常复杂。架构师常常需要创造一些概念,来简单明了的解释功能是如何映射到形式的。例如前面离心泵的例子,它的解决方案无关的功能则是“移动液体”,而离心泵本身其实就是一个概念,一提到离心泵,熟悉的人一定会想到电动机、叶轮等等。其他的概念包括油电混动、高速铁路、发光二极管、快速排序、分布式缓存等等。软件开发中的各种设计模式,其实也是概念。

对于一个解决方案无关的功能,往往能提出多个不同的概念。架构师需要创造性地提出这些概念,对它们进行整理,并选定其中一个概念,将其转化为一套架构。

复杂系统的架构往往是分层的,同时我们还需要对架构进行模块化。需要注意的是,如果要对某一层的架构进行模块化,我们必须将其分解到下一层。因为只有检查各个实体在更下一层的关系,才能更好的对当前层级进行模块化。

创建系统架构

架构师

很多人常常会问,架构师的职责到底是做什么?这本书给出了很明确的回答,架构师的职责主要是以下三个方面:
- 减少歧义,确定系统的边界、目标和功能
- 发挥创造力,创建概念
- 管理复杂度,为系统选定一种分解方案

而架构师的交付成果,应该包括:
- 一套清晰、完整、连贯的目标,并且是可行的(80% 以上概率)。
- 系统所在的大环境(法律法规、行业规范等等)以及整个产品环境的描述。
- 系统的概念以及操作方式。
- 系统的功能描述(至少两层分解),除了系统对外界展现的功能,也包括系统的内部功能。
- 系统的形式(至少两层分解)和形式结构,以及功能和形式之间的映射。
- 所有的外部接口以及接口控制过程的详细描述。
- 开发成本、工期、风险、实现计划等。

消除歧义,确定目标

为了消除歧义,架构师必须首先理解上游和下游的相关因素对系统架构的影响。上有因素包括:公司策略、营销、法律法规、行业标准、技术成熟度等,下游因素包括实现(编码、制造、供应链管理)、操作、产品与系统的演化。

复杂的系统一般会涉及多个的利益相关者,他们会有不同的诉求和目标,架构师需排定各项目标之间的优先次序。首先,可以把价值视为一种交换,在交换过程中,我方的成果用来满足对方的需求,而对方的成果也同样用来满足我方的需求。其次,可以根据利益相关者对本产品的重要程度,来排列其优先次序。最后,则可以把系统的目标,展示在系统问题描述中(System Problem Statement, SPS)。

发挥创造力,创建概念

接下来,架构师就需要发挥创造性、创建概念了。创造概念,主要有两种方式,一种是无结构的方式,一种是结构化的方式;无结构的创新包括头脑风暴法、自由联想法等方法。对于一些包含多个功能的丰富概念,我们可以对其进行扩展和分解,提出对应的概念片段,而这些概念片段组合后,又会形成新的整体概念。最后通过定量和定性分析,筛选出 2~3 个作为候选概念。

管理复杂度,为系统选定一种分解方案

架构师另外一项工作,就是分解系统,管理复杂度。系统的表面复杂度就是系统的难懂程度,表面复杂度高的系统理解起来会比较困难。架构师可以通过抽象、层级化、分解及递归等手段来减少表面复杂度,但这样做可能会提升实际复杂度。其中架构师最重要的一项决策,就是对系统进行的分解。要判断一种分解方式好不好,必须先向下分解两层,并根据第二层的分解情况,来检查第一层的分解方式是否合适。另外,架构师也要选择合适的分解平面,例如可以按功能分解、按形式分解、按模块变化程度分解、按供应商分解等等。

架构决策

其实架构方面的决策也可以用一些程序化的方法进行计算,以帮助架构师进行决策,这部分比较枯燥就不细说了。不过书中提到架构决策的模式,还有点意思,一共有六个模式:
1. Decision-Option(决策-选项):一组决策,每个决策都有一套离散选项。例如,开始一个系统需要做一下两个决策,决定数据库和 API 接口的使用何种技术:数据库是用 MySQL、MongoDB 还是 Cassandra,API 接口是用 HTTP 还是 Protobuf。这就是一个 Decision-Option 决策。
2. Down-Selecting(筛选):一组二选一的决策,代表选择实体中的某个子集。例如,我要从全国各地的 候选 IDC 机房中,选择合适的机房来部署 CDN,就是一个 Down-Selecting 决策。
3. Assiging(指派):把一个实体集中的元素,指派给另一个实体集中的某个或某些元素。
4. Partitioning(分区):把一个实体集的元素划分为多个互斥的子集,并且覆盖所有元素。这是模块分解的典型决策模式。
5. Permuting(排列):在一个实体集和一个位置集之间建立一一对应关系。
6. Connecting(连接):给定一个用图中节点表示的实体集,用一组连线展示这些节点之间的关系。网络拓扑结构的决策就是一个 Connecting 问题,即选择星形拓扑、环形拓扑或者总线拓扑等等。

书里用了一个很牛逼的例子——阿波罗计划,来说明架构决策的方案。

屏幕快照_2018-10-11_上午11.30.41

总结

看完这本书,你会发现,其实所有的架构,软件也好、汽车飞机等各种机器的架构也好,都是相通的。甚至我各种生物组织、团队组织的架构,也是一样的道理。只要我们能掌握系统架构的思维,再加上各个领域的专业知识,我们就能做出一套优秀的架构。

系统架构原则

书中最后总结了系统架构的二十六个原则,我摘录在这里供大家参考:

涌现原则:当各实体拼合成一个系统时,实体之间的交互会把功能、行为、性能和其他内在属性现出来。

整体原则:每个系统都作为某一个或某些个大系统的一小部分而运作,同时,每个系统中也都包含着更小的一些系统。

聚焦原则:在任何一个点上都能发现很多影响系统的问题,而其数量已经超出了人们的理解能力。因此,我们必须找出其中最关键、最重要的那些问题,并集中精力思考它们。

二元原则:所有由人类构建而成的系统,其本身都同时存在于物理领城和信息领域中。

受益原则:好的架构必须使人受益,要想把架构做好,就要专注于功能的涌现,使得系统能够把它的主要功能通过跨越系统边界的接口对外展示出来。

价值与架构原则:价值是有着一定成本的利益。架构是由形式所承载的功能。由于利益要通过功能而体现,同时形式又与成本相关,因此,这两个论述之间形成一种特别紧密的联系。

与特定解决方案无关的功能原则:糟糕的系统规范书总是把人引向预先定好的某一套具体解决方案、功能或形式中,这可能会令系统架构师的视野变窄,从而不去探素更多的潜在选项。

架构师角色原则:架构师的角色是解决歧义、专注创新,并简化复杂度。

歧义原则:系统架构的早期阶段充满了歧义。架构师必须解决这种歧义,以便给架构团队定出目标,并持续更新该目标。

现代实践压力原则:现代产品开发过程是由同时工作着的多个分布式团队来进行的,而且还有供应参与,因此,它更加需要有优秀的架构。

架构决策原则:我们要把架构决策与其他决策分开,并且要提前花一些时间来道慎地决定这些问题,因为以后如果想变更会付出很高的代价。

遗留元素复用原则:要透相地理解遗留系统及其现属性,并在新的架构中把必要的遗留元素包据进来。

产品进化原则:系统必须进化,否则就会失去竟争力。在进行架构时,应该把系统中较为稳固的部分定义为接口,以便给元素的进化提供便利。

开端原则:在产品定义的早期阶段列出的(企业内部和企业外部的)利益相关者会对架构产生极其重大的影响。

平衡原则:有很多因素会影响并作用于系统的构想、设计、实现及操作,架构师必须在这些因素中寻求一个平衡点,使大多数重要的利益相关者得到满足。

系统问题陈述原则:对问题所做的陈述会确定系统的高层目标,并划定系统的边界。就问题陈述的正确性进行反复的辩论和完善,直到你认为满意为止。

歧议与目标原则:架构师必须解决这些歧义。以便提出几条有代表性的目标并持续地更新它们。这些目标要完备且一致,要兼具挑战性和可达成性,同时又要能够为人类所解决。

创新原则:在架构中进行创新,就是要追求一种能够解决矛盾的好架构。

表面复杂度原则:我们要对系统进行分解、抽象及分层,将其表面复杂度控制在人类所能理解的范围。

必备复杂度原则:系统的必备复杂度取决于它的功能,把系统必须实现的功能仔细描述出来。然后选择一个复杂度最低的概念。

第二定律原则:系统的实际复杂度总是会超过必备复杂度。架构师要令实际复杂度尽量接近必备复杂度。

分解原则:分解是由架构师主动做出的选择。分解会影响性能的衡量标准,会影响组织的运作了式及供应商的价值捕获潜力。

二下一上原则:要想判断出对 Level1 所做的分解是否合适,必须再向下分解一层,以确定 Level2 的各种关系。

优雅原则:对于身处其中的架构师来说。如果系统的必备复杂度较低,而且其分解方式能够同与多个分解平面相匹配,那么该系统就是优雅的。

架构健壮程度原则:好的架构要能够应对各种各样的变化。能够应对变化的那种架构,要么是比较健壮架构。要么是适应能力比较强的架构。前者能够处理环境中的变化,而后者则能够适环境中的变化。

架构决策的耦合与整理原则:可以按照指标对决策的敏感度以及决策之间的连接度来排定架构决策之间的先后顺序。

0

分布式锁实践之一:基于 Redis 的实现

Redis分布式锁实践

什么是分布式锁?

我们日常工作中(以及面试中)经常说到的并发问题,一般都是指进程内的并发问题,JDK 的并发包也是用以解决 JVM 进程内多线程并发问题的工具。但是,进程之间、以及跨服务器进程之间的并发问题,要如何应对?这时,就需要借助分布式锁来协调多进程 / 服务之间的交互。

分布式锁听起来很高冷、很高大上,但它本质上也是锁,因此,它也具有锁的基本特征:

  1. 原子性
  2. 互斥性

除此之外,分布式的锁有什么不一样呢?简单来说就是:

  1. 独立性

    因为分布式锁需要协调其他进程 / 服务的交互,所以它本身应该是一个独立的、职责单一的进程 / 服务。

  2. 可用性

    因为分布式锁是协调多进程 / 服务交互的基础组件,所以它的可用性直接影响了一组进程 / 服务的可用性,同时也要避免:性能、饥饿、死锁这些潜在问题。

进程锁和分布式锁的区别:

图示 -- 进程级别的锁:

图示 -- 分布式锁:

分布式锁的业界最佳实践应该非大名鼎鼎的 ZooKeeper 莫属了。但杀鸡焉用牛刀?在直接使用 ZooKeeper 实现分布式锁方式之前,我们先通过 Redis 来演练一下分布式锁算法,毕竟 Redis 相对来说简单、轻量很多,我们可以通过这个实践来详细探讨分布式锁的特性。这之后再对比地去看 ZooKeeper 的实现方式,相信会更加容易地理解。

怎么实现分布式锁?

由于 Redis 是高性能的分布式 KV 存储器,它本身就具备了分布式特性,所以我们只需要专注于实现锁的基本特征就好了。

首先来看看如何设计锁记录的数据模型:

key value
lock name lock owner

举个例子,“注册表的分布式写锁”:

lock name lock owner
registry_write 10.10.10.110:25349

注意,为保证锁的互斥性,lock owner 标识必需保证全局唯一,不会如例子中显示的那样简单。

原子性

因为 Redis 提供的方法可以认为是并发安全的,所以只要保证加、解锁操作是原子操作就可以了。也就是说,只使用一个Redis方法来完成加、解锁操作的话,那就能够保证原子性。

  • 加锁操作: set(lockName, lockOwner, ...)

    set 是原子的,所以调用一次 set 也是原子的。

  • 解锁操作:eval(deleteScript, ...)

    这里你也许会疑惑,为什么不直接使用 del(key) 来实现解锁?因为解锁的时候,需要先判断你是不是加锁的进程,不是加锁者是无权解锁的。如果任何进程都能够解锁,那锁还有什么意义?

    因为“先判断是不是加锁者、然后再解锁”是两步的复合操作,而 Redis 并没有提供一个可以实现这个复合操作的直接方法,我们只能通过在 delete script 里面进行复合操作来绕过这个问题:因为执行一条脚本的 eval 方法是原子的,所以这个解锁操作的也是原子的。

互斥性

互斥性是说,一旦有一个进程加锁成功能,那么在该进程解锁之前,其他的进程都不能加锁。

在实现互斥性的同时,注意不能打破锁的原子性。

  • 加锁操作:set(lockName, lockOwner, "NX", ...)

    第 3 个参数 NX 的含义:只有当 lockName(key) 不存在时才会设置该键值。

  • 解锁操作:

    eval(
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) else return 0 end",
        List(lockName),
        List(lockOwner)
    )
    

    当解锁者等于锁的持有者时,才会删除该键值。

超时

解锁权唯一属于锁的持有者,如果持有者进程异常退出,就永远无法解锁了。针对这种情况,我们可以在加锁时设置一个过期时间,超过这个时间没有解锁,锁会自动失效,这样其他进程就能进行加锁了。

  • 加锁操作:set(lockName, lockOwner, "NX", "PX", expireTime)

    "PX" :过期时间单位:"EX" -- 秒,"PX" -- 毫秒

    expireTime : 过期时间

代码片段 1 :加锁、解锁

// 由Scala编写

case class RedisLock(client: JedisClient,
                     lockName: String,
                     locker: String) {
  private val LOCK_SUCCESS = "OK"
  private val SET_IF_NOT_EXISTS = "NX"
  private val EXPIRE_TIME_UNIT = "PX"
  private val RELEASE_SUCCESS = 1L

  def tryLock(expire: Duration): Boolean = {
    val res = client.con.set(
      lockName, // key
      locker, // value
      SET_IF_NOT_EXISTS, // nxxx
      EXPIRE_TIME_UNIT, // expire time unit
      expire.toMillis // expire time
    )
    val isLock = LOCK_SUCCESS.equals(res)
    println(s"${locker} : ${if (isLock) "lock ok" else "lock fail"}")
    isLock
  }

  def unlock: Boolean = {
    val cmd = 
      "if redis.call('get', KEYS[1]) == ARGV[1] then " +
      "return redis.call('del', KEYS[1]) else return 0 end"
    val res = client.con.eval(
      cmd,
      List(lockName), // keys
      List(locker) // args
    )
    val isUnlock = RELEASE_SUCCESS.equals(res)
    println(s"${locker} : ${if (isUnlock) "unlock ok" else "unlock fail"}")
    isUnlock
  }
}

测试加锁:

object TryLockDemo extends App {
  val client = JedisContext.client
  val lock1 = RedisLock(client, "LOCK", "LOCKER_1")

  // Try lock
  lock1.tryLock(1000.millis)
  Thread.sleep(2000.millis.toMillis)

  // Try lock after expired
  lock1.tryLock(1000.millis)

  // Unlock
  lock1.unlock
}

测试结果:

LOCKER_1 : lock ok   # 加锁成功,1秒后锁失效
LOCKER_1 : lock ok   # 2秒之后,锁已过期释放,所以成功加锁
LOCKER_1 : unlock ok # 解锁成功

阻塞加锁

到目前为止,我们实现了简单的加解锁功能:

  • 通过 tryLock() 方法尝试加锁,会立即返回加锁的结果
  • 锁拥有者通过 unlock() 方法解锁

但在实际的加锁场景中,如果加锁失败了(锁被占用或网络错误等异常情况),我们希望锁工具有同步等待(或者说重试)的能力。面对这个需求,一般会想到两种解决方案:

  1. 简单暴力轮询
  2. Pub / Sub 订阅通知模式

因为 Redis 本身有极好的读性能,所以暴力轮询不失为一种简单高效的实现方式,接下来就让我们来尝试下实现阻塞加锁方法。

先来推演一下算法过程:

  1. 设置阻塞加锁的超时时间 timeout
  2. 如果已超时,则返回失败 false
  3. 如果未超时,则通过 tryLock() 方法尝试加锁
  4. 如果加锁成功,返回成功 true
  5. 如果加锁失败,休眠一段时间 frequency 后,重复第 2 步

代码片段 2 :阻塞加锁

def lock(expire: Duration,
         timeout: Duration,
         frequency: Duration = 500.millis): Boolean = {
  var isTimeout = false
  TimeoutUtil.delay(timeout.toMillis).map(_ => isTimeout = true)
  while (!isTimeout) {
    if (tryLock(expire)) {
      return true
    }
    Thread.sleep(frequency.toMillis)
  }
  println(s"${locker} : timeout")
  return false;
}

代码片段 -- 超时工具类:

object TimeoutUtil {

  def delay(millis: Long): Future[Unit] = {
    val promise = Promise[Unit]()
    val timer = new Timer
    timer.schedule(new TimerTask {
      override def run(): Unit = {
        promise.success()
        timer.cancel()
      }
    }, millis)
    promise.future
  }
}

测试阻塞加锁:

object LockDemo extends App {
  val client = JedisContext.client
  val lock1 = RedisLock(client, "LOCK", "LOCKER_1")
  val lock2 = RedisLock(client, "LOCK", "LOCKER_2")

  // Lock
  lock1.lock(3000.millis, 1000.millis)
  lock2.lock(3000.millis, 1000.millis)
  lock2.lock(3000.millis, 3000.millis)

  // Unlock
  lock1.unlock
  lock2.unlock
}

测试结果:

LOCKER_1 : lock ok     # LOCKER_1 加锁成功,3 秒后锁失效
LOCKER_2 : lock fail   # LOCKER_2 尝试加锁失败
LOCKER_2 : lock fail   # LOCKER_2 重试,尝试加锁失败
LOCKER_2 : timeout     # LOCKER_2 重试超时,返回失败

LOCKER_2 : lock fail   # LOCKER_2 尝试加锁失败
LOCKER_2 : lock fail   # LOCKER_2 重试,尝试加锁失败
LOCKER_2 : lock fail
LOCKER_2 : lock fail
LOCKER_2 : lock ok     # 3 秒时间到,锁失效,LOCKER_2 加锁成功

LOCKER_1 : unlock fail # LOCKER_1 解锁失败,因为此时锁被 LOCKER_2 占有
LOCKER_2 : unlock ok   # LOCKER_2 解锁成功

更进一步

这个分布式锁的实现,有一个比较明显的缺陷,就是等待锁的进程无法实时的知道锁状态的变化,从而及时的做出响应。我们不妨思考一下,通过什么方式可以实时、高效的获得锁的状态?

作为分布式锁的业界标准,ZooKeeper 以及相关的工具库提供了更加直接、高效的支持,那么 ZooKeeper 是怎样的思路?具体又是如何实现的?欲知后事如何,且听下回分解:ZooKeeper 分布式锁实践。

0

介绍一个 MySQL 自动化运维利器 – Inception

引子

最近打算做一个 MySQL 的数据库运维平台。这里面有一个非常重要的功能就是 SQL 的审核,如果完全靠人工去实现就没必要做成一个平台了。正没头绪如何去实现的时候,google 了一下,看下有没有现成的开源方案。果不其然,github 上发现一个『去哪儿网』开源的一个数据库运维工具 Inception, 它是一个集审核、执行、备份及生成回滚语句于一身的 MySQL 自动化运维工具。

Inception 介绍

Inception 的架构图如下图所示,简单来说,Inception 就是一个 MySQL 的代理,能够帮助你审核 SQL,执行 SQL,备份 SQL 影响的记录。Inception 是一个 C/S 的软件架构。我们可以通过原生的 MySQL 客户端 去连接,也可以通过远程的接口去连接,目前执行只支持通过C/C++接口、Python接口来对Inception访问

inception-architecture.png

执行流程图如下:

image

安装 Inception

我安装的环境
OS: Ubuntu 16.04.2 LTS

安装依赖

  • 下载bison: 版本最好是2.6之前的(Ubuntu 16.04.2 LTS 版本下安装的是 bison-2.5.1),最新的可能会有问题,下载之后,需要自己编译源码来安装,具体安装方法,可以参数网上的一些说明。
  • cmake安装:apt-get install cmake
  • ncurses安装:apt-get install libncurses5-dev
  • 安装openssl:apt-get install libssl-dev
  • 安装g++:sudo apt-get install g++
  • 安装m4: apt-get install m4

编译安装 Inception

git clone https://github.com/mysql-inception/inception.git
sh inception_build.sh debug [linux]  (如果不指定就是linux平台,而如果要指定是Xcode,就后面指定Xcode)

可执行文件在 debug/sql/Debug/ 目录下面(不同平台有可能不相同)。

启动 Inception

创建一个配置文件 inc.cnf, 里面主要是配置 Inception 启动的端口,SQL 审核的策略,备份数据库的配置等等,更多可参考官方文档

[inception]
general_log=1
general_log_file=inception.log
port=6669   # Inception 的监听的端口
socket=/tmp/inc.socket
character-set-client-handshake=0
character-set-server=utf8
inception_remote_system_password=root  # 备份数据库密码
inception_remote_system_user=wzf1      # 备份数据库用户名
inception_remote_backup_port=3306      # 备份数据库端口
inception_remote_backup_host=127.0.0.1 # 备份数据库地址
inception_support_charset=utf8mb4
inception_enable_nullable=0
inception_check_primary_key=1
inception_check_column_comment=1
inception_check_table_comment=1
inception_osc_min_table_size=1
inception_osc_bin_dir=/data/temp
inception_osc_chunk_time=0.1
inception_enable_blob_type=1
inception_check_column_default_value=1

启动

./Inception --defaults-file=inc.cnf

访问
1. 通过原生的 MySQL 客户端的方式。主要注意的是,请不要将的 SQL 语句块,放到 MySQL 客户端中执行,因为这是一个自动化运维工具,如果使用交互式的命令行来使用的话没有意义,所有的 SQL 执行应该都通过接口的方式,这个方式仅仅可用来查看和设置上诉配置文件里的配置,如 inception get variables; 可查看所有的变量,更多请参考官方文档

mysql -uroot -h127.0.0.1 -P6669
  1. 通过接口的方式。下面是官方示例中的 Python 代码,需要注意的是如果使用 Python3 的 pymsql 去连接会有异常,目前的解决方案是需要修改 pymysql 的源码,具体 issue
#!/usr/bin/python
#-\*-coding: utf-8-\*-
import MySQLdb
sql='/*--user=username;--password=password;--host=127.0.0.1;--execute=1;--port=3306;*/\
inception_magic_start;\
use mysql;\
CREATE TABLE adaptive_office(id int);\
inception_magic_commit;'
try:
    conn=MySQLdb.connect(host='127.0.0.1',user='',passwd='',db='',port=9998)
    cur=conn.cursor()
    ret=cur.execute(sql)
    result=cur.fetchall()
    num_fields = len(cur.description) 
    field_names = [i[0] for i in cur.description]
    print field_names
    for row in result:
        print row[0], "|",row[1],"|",row[2],"|",row[3],"|",row[4],"|",
        row[5],"|",row[6],"|",row[7],"|",row[8],"|",row[9],"|",row[10]
    cur.close()
    conn.close()
except MySQLdb.Error,e:
     print "Mysql Error %d: %s" % (e.args[0], e.args[1])

SQL 审核 & 执行

通过 Inception 对语句进行审核时,必须要告诉 Inception 这些语句对应的数据库地址、数据库端口以及
Inception 连接数据库时使用的用户名、密码等信息,而不能简单的只是执行一条 sql 语句,所以必须要通过某种方式将这些信息传达给 Inception。

连接信息放在 /* ... */ 的注释中,真正的 SQL 语句则包括在 inception_magic_startinception_magic_commit:

/*--user=zhufeng;--password=xxxxxxxxxxx;--host=xxxxxxxxxx;
--enable-check;--port=3456;*/  
inception_magic_start;  
use mysql;  
CREATE TABLE adaptive_office(id int);  
inception_magic_commit;

连接的信息里可以配置更多的信息,比如关闭备份等等,具体请参考官方文档

审核

审核的规范见官方文档,有些规范是可配置的,可根据自己公司的规范在 Inception 的配置文件中配置。

执行

注意下,官方说是支持 DDL,DML 语句的,但是并不支持 SELECT 查询。

inception_accept.png

比如通过 Inception 执行一个建表语句:

...
inception_magic_start;  
use mysql;  
CREATE TABLE adaptive_office(id int);  
inception_magic_commit;
...

返回结果, 可见是每一条 SQL 就会返回一个可执行的结果,errlevel 非 0 时表示执行失败,下面所示中的第二条 SQL 语句 Audit completed(审核完成) 但是不符合建表的规范,更多关于返回结果的说明可见官方文档

'ID', 'stage', 'errlevel', 'stagestatus', 'errormessage', 'SQL', 'Affected_rows', 'sequence', 'backup_dbname', 'execute_time', 'sqlsha1'
1 | CHECKED | 0 | Audit completed | None | use inception_test | 0 | '0_0_0' | None | 0 | 
2 | CHECKED | 1 | Audit completed | Set engine to innodb for table 'adaptive_office'.
Set charset to one of 'utf8mb4' for table 'adaptive_office'.
Set comments for table 'adaptive_office'.
Column 'id' in table 'adaptive_office' have no comments.
Column 'id' in table 'adaptive_office' is not allowed to been nullable.
Set Default value for column 'id' in table 'adaptive_office'
Set a primary key for table 'adaptive_office'. | CREATE TABLE adaptive_office(id int) | 0 | '0_0_1' | 10_10_1_67_1028_inception_test | 0 | 

备份功能

前提条件

  • 线上服务器必须要打开 binlog,不然不会备份及生成回滚语句。
  • 参数 binlog_format 必须要设置为 mixed 或者 row 模式,通过语句:set global binlog_format=mixed/row 来设置,如果是 statement 模式,则不做备份及回滚语句的生成。
  • 被影响的行中必须存在主键,因为回滚语句的 WHERE 条件就是主键。比如,我插入一条数据并返回主键 id=1, 那么相应的它就会反向生成一个删除语句 (WHERE 的条件就是主键) DELETE FROM xx WHERE id = 1

Inception 在做 DML 操作时具有备份功能(默认开启,可通过在执行 SQL 中注释文件中指定 --disable-remote-backup),它会将所有当前语句修改的行备份下来,存储到一个指定的备份库中, 备份库通过配置 Inception 参数来指定。

关于备份数据库的命名方式,备份机器的库名组成是由线上机器的 IP 地址的点换成下划线,再加上端口号,再加上库名三部分,这三部分也是通过下划线连接起来的。例如:我执行 DML 操作的数据库地址是 192.168.1.1, 端口是 3306, 库名是 inceptiondb, 则在备份数据库中表名为:192_168_1_1_3306_inceptiondb

比如,我有一个 inception_test 库,其中有一张 userinfo 表,就两个字段:

userinfo.png

我通过 Inception 去执行一个 INSERT 一条记录:

/*--user=root;--password=xxx;--host=1.1.1.1;--execute=1;--port=3306;--sleep=0;--enable-remote-backup;*/\
inception_magic_start;\
use inception_test; \
insert into userinfo(`username`) values("test");\
inception_magic_commit;

返回的结果如下, 可以看到已经执行成功并且备份成功了:

2 | EXECUTED | 0 | Execute Successfully
Backup successfully | None | insert into userinfo(`username`) values("test") | 1 | '1533716166_25519001_1' | 1_1_1_1_3306_inception_test | 0.060 | 

查看下备份数据库中的 1_1_1_1_3306_inception_testuserinfo 表的结果, 根据 INSERT 的语句相应地生成了一条 DELETE 语句:

DELETE FROM `inception_test`.`userinfo` WHERE id=4;

那么,我需要如果正确地找到回滚的语句呢?

可以查看下备份库 1_1_1_1_3306_inception_testuserinfo 的表结构:

backup_userinfo.png

主要有两个字段:

  • rollback_statement text: 生成修改的回滚语句。
  • opid_time varchar(50): 这个列存储的是的被执行的 SQL 语句在执行时的一个序列号,这个序列号由三部分组成:timestamp(int 值,是语句被执行的时间点) + 线上服务器执行时所产生的 thread_id + 当前这条语句在所有被执行的语句块中的一个序号组成。可见上面的结果:1533716166_25519001_1, 这个序列号同时也会出现在执行返回的结果中,所有需要回滚就是根据这个序列号去备份表中查询回滚的 SQL 语句。

更多说明,请参考官方文档中的备份功能说明

最后

有了这么好用的工具,基于这个为基础,我们通过一个 WEB 应用做一个权限审批管理等功能,一个数据库运维平台就可以实现了,真的需要自己去写吗?我有发现了一个基于 Inception 实现的一个数据库运维平台 Yearning

感谢开源!

参考

0

小程序中 Redux 的使用

在我们的一款小程序中聊天部分主要是基于 Redux 来维护数据部分的。为什么使用了 Redux ?这也是符合了使用 Redux 的一些原则的。那么哪些情况使用 Redux 比较好呢?

用户的使用方式复杂
不同身份的用户有不同的使用方式(比如普通用户和管理员)
多个用户之间可以协作
与服务器大量交互,或者使用了 WebSocket
View 要从多个来源获取数据

我们的聊天功能基于 WebSocket 交互数据,使用方式较为复杂,多个地方都会影响聊天呈现的数据内容。并且与服务器交互量比较大,UI 上呈现的内容受到多个地方的影响。如下图:

 Redux 在小程序中的交互逻辑

图中展示了 Redux 的三大块业务实现部分与业务部分的交互逻辑,其中数据会反应在首页和聊天界面,而首页及聊天界面的一些操作又会通过 Action 反馈到 Redux 的数据对象上。另外 Websocket 和 Http 网络部分也会有很多数据反馈到 Redux 的数据对象上。

Redux 设计思想

简单总结为两句话:

(1)Web 应用是一个状态机,视图与状态是一一对应的。
(2)所有的状态,保存在一个对象里面。

Redux 的三大原则

  1. 单一数据源
  2. State 是只读的
  3. 使用纯函数来执行修改

其工作逻辑如下图所示:

Redux

Store

Store 就是保存数据的地方,你可以把它看成一个容器。整个应用只能有一个 Store。

import { createStore } from 'redux';
const store = createStore(fn);

通过 Store 可以获取到 State 对象,State 为时点的数据集合,即 Store 的一个快照。

import { createStore } from 'redux';
const store = createStore(fn);

const state = store.getState();

Action

State 的变化,会导致 View 的变化。但是,用户接触不到 State,只能接触到 View。所以,State 的变化必须是 View 导致的。Action 就是 View 发出的通知,表示 State 应该要发生变化了。

const action = {
  type: 'ADD_TODO',
  payload: 'Learn Redux'
};

Dispatch

store.dispatch()是 View 发出 Action 的唯一方法。

import { createStore } from 'redux';
const store = createStore(fn);

store.dispatch({
  type: 'ADD_TODO',
  payload: 'Learn Redux'
});

Subscribe

Store 允许使用store.subscribe方法设置监听函数,一旦 State 发生变化,就自动执行这个函数。

import { createStore } from 'redux';
const store = createStore(reducer);

store.subscribe(listener);

小程序

在 Redux 的使用中我们主要会去实现两个部分,一是 Action 部分,去构造定义要发送的 Action 的数据格式等,另一部分是 Reducer 部分,即 Dispatch 分发的 Action 的具体相应处理部分。Reducer 即接收原 State 和 Action,根据当前 Action 重新创建一份新的 State,然后返回这个 State。

消息的处理逻辑一开始并不是很好,发送消息、接收消息、发送中、接收中等各种消息的状态,都会单独发送不同的 Action 这也导致 Reducer 的维护变得非常困难,而且导致很多不一致的地方。

Actions_to_Action

后来改为一个 Action 做统一处理,处理起来简单了很多,将原有的多种 Action,多种接收 Action 并处理的逻辑统一成一种,当然如果是维护的不同数据那么还是需要分开来处理的。

修改后 Action 的实现

修改后消息 Action 接口如下:

onMessage(message, doctorId)

如两处修改消息 Action 的使用:

  • 接收消息,WebSocket 接收到新的消息时,因为接收到的消息分为发送出去的,和接收到的两种:
onMessage({
    ...message,
    sending: { 
        status: 0 
    }},
    message.from === config.patientId ? message.to : message.from
);
  • 发送消息,本地发送消息时,将其更新到界面上,并调用WebSocket发送接口将其发送出去。
const sendText = async (doctorId, text) => {
  const message = {
    typ: MSG.TEXT,
    content: toRealText(text),
    to: doctorId,
    mine: true,
    sending: {
      status: 1
    },
    guid: guid(),
    from: config.patientId,
    created: Date.now()
  }
  onMessage(message, doctorId)
}

修改后 Reducer 的实现

在 Reducer 统一提供一处消息 Action 的处理方式,避免之前的多处处理导致的数据不一致的情况:

[CHAT_MESSAGE] (_state, _action) {

    // 拷贝一份 state
    let state = {..._state};

    // 提取消息参数
    _handle(_state, _action);

    // 处理消息对象
    // 修改 state
    // ...

    return state;
}

修改后 UI 订阅状态

最后我们需要将 State 中维护的数据对象显示到 UI 上,在 Javascript 中我们可以使用 @connect 在页面上加上修饰,通过 @connect 实现内容的 subscribe 过程,将 messages 方法注入到页面中的 data 中。

@connect({
    messages(state) {
      const chat = state.chat[this.doctorId];
      if (chat) {
        return chat.messages;
      }
      return [];
    }
})

对于首页也是同样的道理,在首页不需要消息列表,但是需要小时列表的摘要信息以显示有多少种消息列表。也即当前维护着的对话数量。

@connect({
    sessions(state) {
        let sessions = []
        for (let id in state.chat) {
            let session = state.chat[id]
            sessions.push(session.session)
        }
        sessions.sort((a, b) => {
        return b.updated - a.updated
        })
        return sessions
    }
});

总结

小程序里使用到的内容较为简单,Redux 原本也就是简化 Web 中状态和界面简单对应关系,使用时只需要关注其三大原则即可。并尽可能地统一相同的修改操作,保持数据的统一性。

参考

http://www.redux.org.cn/

http://www.ruanyifeng.com/blog/2016/09/redux_tutorial_part_one_basic_usages.html

0

数据可视化过程不完全指南

数据集犹如世界历史状态的快照,能帮助我们捕捉不断变化的事物,而数据可视化则是将复杂数据以简单的形式展示给用户的良好手段(或媒介)。结合个人书中所学与实际工作所学,对数据可视化过程做了一些总结形成本文供各位看客"消遣"。

个人以为数据可视化服务商业分析的经典过程可浓缩为:从业务与数据出发,经过数据分析与可视化形成报告,再跟踪业务调整回到业务,是个经典闭环。

image

本文主题为数据可视化,将重点讲解与数据可视化相关的环节,也即上图中蓝色的环节。

一、理解 DATA

进行 DATA 探索前,我们需先结合业务去理解 DATA,这里推荐运用 5W1H 法,也即在拿到数据后问自身以下几个问题:

  • Who:是谁搜集了此数据?在企业内可能更关注是来自哪个业务系统。
  • How:是如何采集的此数据?尽可能去了解详细的采集规则,采集规则是影响后续分析的重要因素之一。如:数据来自埋点,来自后端还是前端差异很大,来自后端则多是实时的,来自前端则需更近一步了解数据在什么网络状态会上传、无网络状态下又是如何处理的。
  • What:是关于什么业务什么事?数据所描述的业务主题。
  • Why:为什么搜集此数据?我们想从数据中了解什么,其实也就是我们此次分析的目标。
  • When:是何时段内的业务数据?
  • Where:是何地域范围内的业务数据?

通过回答以上几个问题,我们能快速了解:数据来源是什么?它的可信度有多少?它在描述何时发生的怎样的业务(问题)?我们为什么要搜集此数据?等等。从而快速了解数据与业务开展近一步的探索与分析。

二、探索 DATA

之前的文章中,我们曾经分享过如何快速地探索 DATA (「如何成为一名数据分析师:数据的初步认知」),其中有谈到如何通过诸如平均数/中位数/众数等描述统计、通过相关系数统计快速探索 DATA 的方法。本文主要讲解可视化,所以将从可视化的角度去介绍如何通过可视化方法进行数据探索。

在探索、研究阶段,更重要的是要从不同的角度去观察数据,并逐步深入到对业务更重要的事情上。在这个阶段,我们不必去过多地追求图表美化,而应该尽可能快速地尝试更多个角度。下面我们根据数据/主题类型的差异分开阐述:

1. 分类数据的探索

在业务分析中,我们常常将人群、地点和其他事物进行分类,分类能为我们带来结构化,能让我们快速掌握信息。

在分类数据可视化中,我们最多使用的是条形图;但当试图观察分类中的比例时,我们可能也会选择饼图、瀑布图;当不仅关心一级分类还关心子分类时候,我们可能会选择树形图。通过对分类数据的可视化,我们能快速地获取最大、最小值,同时也能方便地了解到数据集的范围,因为它在一定程度上还反映了数据分布特征。下图展示了可视化分类数据的一些选择:

a. 条形图,用长度作为视觉暗示,利于直接比较。

image

b. 使用饼图、柱形堆叠图、瀑布图等,能在分类数据中对比占比情况。

image

c. 使用树形图,能在展示一级分类的子类统计,可实现维度的又一层下钻。

image

2. 时序数据的探索

业务分析中,我们常常关心事物随着时间的变化趋势,以及数据随时间变化的规律(时间周期下的规律)。所以,对时序数据的探索,主要有两种模式:其一为随着时间线索向右延伸的时序图,诸如:折线图、堆积面积图等;其二为根据时间周期,统计汇总的柱形图、日历图、径向图等。

a. 用于观察事物随时间线索变化的探索。

image

b. 用于发现事物随时间周期变化规律的探索。

image
image

3. 空间数据的探索

空间数据探索主要是期望展现或者发现业务事件在地域分布上的规律,即区域模式。全球数据通常按照国家分类,而国内数据则按照省份去分类,对于省份数据则按照市、区分类,以此类推,逐步向细分层次下钻。空间数据探索最常用为等值热力图,如下:

image

4. 多元变量的探索

数据探索过程中,有时候我们需要对比多个个体多个变量,从而寻找数据个体间的差异或者数据变量间的关系。在这种情况下,我们推荐使用散点图、气泡图,或者将多个简单图表组合生成“图矩阵”,通过对比“图矩阵”来进行多元变量的探索。其中,散点图和气泡图适合变量相对较少的场景,对于变量5个及以上的场景我们更多地是推荐“图矩阵”。

a. 变量相对较少(5个以下)的场景我们采用散点图与气泡图。

image

b. 变量多(5个及以上)的场景我们采用多个简单图表组成的“图矩阵”,下图为最简单的“图矩阵”多元热力图:

image

5. 数据分布的探索

探索数据的分布,能帮助我们了解数据的整体的区间分布、峰值以及谷值以及数据是否稳定等等。

之前在分类数据探索阶段曾提到分类清晰的条形图在一定程度上向我们反映了数据的分布信息。但,之前我们是对类别做的条形图,更多时候我们是需查看数据“坐落区间”,这里我们推荐直方图以及直方图的变型密度曲线图(密度曲线图,上学时代学的正态分布就常用密度曲线图绘制)。此外,对数据分布探索有一个更为科学的图表类型,那就是:箱线图。
image

三、图表清晰

1. 合理"搭配"可视化的组件

所谓可视化,其实就是根据数据,用标尺、坐标系、各种视觉暗示以及背景信息描述进行组合来表现数据。下图为可视化组件的“框架图”:

image

a. 视觉暗示

可视化最基本的形式就是简单地将数据映射成图形,大脑可以在数字与图形间来回切换从而寻找模式。所以我们必须选择合适的视觉暗示来保证数据的本质没有在大脑地来回切换中丢失,并且尽可能让大脑能轻松获得信息。

image

从上到下,对人脑而言视觉暗示清晰程度逐渐降低。

位置

使用位置作视觉暗示时,大脑是在比较给定空间或者坐标系中数值的位置。它的优势在于占用空间会少于其他视觉暗示,但劣势也很明显,我们很难去辨别每一个点代表什么。所以,应用位置作为视觉暗示主要用于发现趋势规律或者群集分布规律,散点图是位置作为视觉暗示的典型运用。

长度

使用长度作为视觉暗示,大脑的理解模式是条形越长,绝对值越大。优点非常明显人眼对于长度的“感受”往往是最准确的。条形图是长度作为视觉暗示的最常见图表。

角度

使用角度作为视觉暗示,大脑的理解模式为两向量如何相交,相交角度是否大于90度或180度。角度作为视觉暗示的最常见图表式饼图。

方向

使用方向作为视觉暗示,大脑的理解模式为坐标系中一个向量的方向。在折线图中显示为斜率,在迁徙图中显示为箭头所指方向。

形状

使用形状作为视觉暗示,对大脑而言往往代表着不同的对象或者类别。可用于在散点图中区分不同群集。

面积/体积

使用面积/体积作为视觉暗示,面积大则绝对值大。需要注意的一点是,用面积显示2倍关系时,应该是面积乘倍而不是边长乘倍。

色相与饱和度

不同的颜色通常用来表示分类数据,每个颜色代表一个分组;不同的色相通畅用来表示连续数据,常见模式是颜色越深代表数值越大。

b. 坐标系

  • 直角坐标系:绝大多数的图表都在直角坐标系中完成,它是最常用的坐标系。在直角坐标系中,关注的两个点之间的距离,距离是欧式距离。
  • 极坐标系:极坐标系是显示角度的坐标系,如果用过饼图那么就已经接触过极坐标系了。
  • 地理坐标系:简单点理解,它由经纬度组成,将世界各地的位置显示在图表中,因与现实世界直接相关而倍受喜爱。

c. 标尺

标尺的重要性在于与坐标系一起决定了图形的投影方式。

  • 线性标尺:间距处处相等,无论处于什么位置,是大众最熟悉、最容易接受的标尺,不容易产生误解;
  • 分类标尺:分类数据往往采用分类标尺,如:年龄段、性别、学历等等,值得注意的一点是,对于有序的分类,我们应尽量对分类标尺做排序以适应读者的阅读模式;
  • 百分比标尺:其实仍旧是线性标尺,只是刻度值为百分比;
  • 对数标尺:指按照对数化将坐标轴压缩,适合数值跨度非常大的场景。但需考虑读者是否能够适应对数标尺,毕竟它并不常见。

d. 背景信息

背景信息,所指即我们在理解 DATA 通过 “5W1H” 法回答的问题。包括数据背景与业务背景。

基本的原则是,如果信息在图形元素中没有得到巧妙地暗示,我们久需要通过标注坐标轴、注明度量单位,添加额外说明等方法来告诉读者图表中每一个数据及其视觉暗示代表什么。

2. 美化,让可视化更为清晰

在研究阶段,我们重点尝试从各种不同的角度切入去观察数据,没有过多地考虑表达是否准确,图形是否美观。
但,当我们进展到准备将分析报告呈现给业务方或领导时,必须对可视化图表进行优化使其是清晰易读的。否则,我们很可能要挨批了。

image

上图为,数据可视化与现实世界的连接关系。清晰易读的可视化一定是在尽可能地减少读者从可视化图表理解转换为现实世界的难度。而增强数据比较、合理注解引导、减少读者理解步骤是达成这一目的的良好手段,下面为大家详细展开介绍:

a. 增强数据比较,降低大脑进行信息比较的难度

当我们在阅读可视化图表时,我们的大脑会自然地进行比较从而获取信息。增强数据比较,可有效降低信息比较难度,使大脑更容易抓住关键信息,减少模凌两可,使大脑获取信息更具确定性。

建立视觉层次,用醒目的颜色突出数据,淡化其他元素

有层次感的图表更易读,用户能更快地抓住图表中的重点信息。相反,扁平图则缺少流动感,读者相对较难理解。建立视觉层次,我们可以用醒目的颜色突出显示数据,并淡化其他元素使其作为背景,淡化元素可采用淡色系或虚线。

散点图的目标是为寻找规律与模式,拟合数据线是下图的关键。弱化数据点、强化拟合趋势线使其形成鲜明的2个层次。

image

高亮显示重点内容

高亮显示可以帮助读者在茫茫数据中一下找到重点。它既可以加深人们对已看到数据的印象,也可以让人们关注到那些应该注意的东西。需要注意的是,使用“高亮”突出显示时,我们应尽可能使用当前图表中尚未使用的视觉暗示。

下面为常见的电商转化漏斗,其中下单步骤是最应当关注的环节,使用红色高亮能会使读者的目光快速落在这一关键步骤中。

image

其他技巧

除了以上介绍两大增强比较技巧,我们可以通过以下一些小技巧来增强数据比较:

  • 提升色阶跨度,倘若图表中所用颜色色阶跨度太小,我们将难以区分差异,合理提升色阶跨度能有效增强比较;
  • 合理增大标尺跨度,有时候我们只需要对标尺做合理地放大,数据差异将清晰好几倍;
  • 添加参考线(建议采用虚线),参考线作为对比基准,可有效增强数值与基准的比较。

b. 合理注解与引导,使读者快速理解图表信息并抓住信息重点

仅通过图形元素,我们很难向读者展示充分的信息,合理增加注解能有效帮助读者理解图表;增加适当的箭头等符号引导能帮助读者快速抓住关键信息。

合理注解:背景信息、分析结论以及统计学概念

如果报表的读者对数据、业务背景并不十分熟悉,我们应考虑在标题或其他报告文字中直接说明背景。

如果是结论性图表,我们可在主标题中直接说明结论。如果结论得出的过程较复杂,我们还可以在副标题中辅助说明是如何推导得到的结论。

如果图表中,有大部分读者都不熟悉的统计学概念,我们应适当地进行注解,以帮助读者了解相关概念。

下图,主标题数据背景注解让读者快速了解业务背景,副标题说明结论能有效引导读者朝着什么方向去阅读图表

image

合理增加引导:增加适当的箭头指向

分析阶段,我们是报表的制作者;汇报阶段,我们是报告的讲解者。我们可以将自身作为报告的导游,引导读者按照我们的期望去阅读图表。而增加箭头等符号的引导是最直接有效的方式。

c. 通过引入计算、视觉暗示直接符合读者“背景暗示”等方法可有效降低读者理解步骤

创造性地从不同角度进行计算

有时,我们只需在图表上先做一个图表计算就可以让图表离结论更近一个层次,从而减少读者从可视化图表到现实世界的理解步骤。常见的可用计算包括:平均值计算、环比增长率、基准点上下、累加统计等。

示例1:将员工销售业绩与团队均值做差值,快速辨别员工的销售表现

image

示例2:将2个采购商的采购成本按照一年累计汇总后可使采购成本差异更显著

image

选择符合读者“背景期望”的视觉暗示

人在世界上生存久了都会形成一定的潜意识,有一些潜意识是“人群通用的”,在可视化过程中,我们应该合理运用。比如:在失业、就业统计中,失业用负数表示,就业用正数表示,就是一种符合大多数人“背景期望”的一种场景。

示例1: 之前在一本书中看到的一个关于伊拉克战争可视化。此图的主题在于批判战争的残酷造成了巨大的伤亡,所以作者采用了与血液相同的红色作为主色调,倒挂的柱形也能给人以压抑感,同样符合“背景期望”。

image

示例2: 之前一位同事分享的一个关于美国一些互联网平台网红收入的可视化。在色彩上它直接采用对应互联网平台自身logo的色系。符合人的“背景期望”阅读过程将非常轻松。

image

四、适应读者

别忘了,我们的可视化是为读者进行的,我们应考虑目标读者的特点制作他们易于、乐于理解的可视化。尤其要避免的一个陷阱是:过分追求新颖图表,反而使得图表难以理解,结果违背了可视化的初衷。

为读者而可视化,要求我们试图去了解读者,了解他们对可视化的偏好,尤其是能够接受新颖的图表类型,以及他们对业务的理解程度等等。

此外,还有一个非常关键且通用的建议:让我们的报告以讲故事的方式展开,我们自身则作为这个报告的导游,合理有效地引导读者看完你创造的“分析故事”。


好,以上即为个人对数据可视化服务商业分析的过程所有总结。

0

小型大写字母的用武之处

对英文字体排版稍有了解的话,就会知道在大写字母和小写字母之外,还存在着一种特殊的类型:小型大写字母(Small Caps)。

大写字母:HELLO WORLD
小写字母:hello world
小型大写字母:Hᴇʟʟᴏ ᴡᴏʀʟᴅ

这样看可能并不能明显地突显出小型大写字母的特征。那么,如果你有看过英文版的圣经的话,在全书中提到「ʟᴏʀᴅ」的地方,都使用的是小型大写字母。

image

可以看到,这些「ʟᴏʀᴅ」在字形上是大写字母,然而,在字高上,却与小写字母相同。小型大写字母被创造出来的初衷有两点,其一,是为了使标题更具有装饰性和艺术感;其二,是为了在部分排版中(尤其是密集的正文排版),替代大写字母的存在,从而减弱对视觉的干扰。

这么说可能有些抽象,那我们就一起来看看小型大写之母经常出没的地方。需要注意的是,下文中提到的一些示例,很多只是出于通用的实践或大多数设计师个人的喜好,而并非强制性的规范。

减弱大写字母对视觉的冲击

capitals_small_v_big

英文中存在着许多首字母缩写(acronyms,例如 WTO 是 World Trade Organization 的首字母缩写)和简写(abbreviations,例如 TV 是 Television 的简写)。这些缩写或简写往往需要大写,然而,你会发现当它们出现在密集排版的正文中,对视觉有很大的干扰,使阅读的重点完全落在了这些词上。

而将这些大写字母转化为小型大写字母后,视觉上的冲击就被明显减弱了,人的阅读注意力也能重新回到文本和内容上。不过,在实践中,许多设计师仅对三个字母以上的缩写使用小型大写字母。但这并不是约定成俗的,在一部分出版社的排版规范中,也规定了文中出现的「ᴀᴅ」、「ʙᴄ」、「ᴀᴍ」、「ᴘᴍ」皆采用小型大写字母。

capitals_time_smallcaps1

装饰性的封面和标题

51+ERtqCF9L

在许多书的封面设计(往往是副标题和作者署名),以及正文中的章节标题处,都会使用小型大写字母。一种更为常见的用法是,同时搭配大写字母与小型大写字母,单词的首字母使用大写字母,其后跟随小写字母,往往可以使字体的设计显得更具有装饰感。

capitals_headlines

正文中的引用

在一些规范中,如果在正文内引用了本书的其它部分章节内容,或在法律文书中援引一些人名和书籍时,对被引用的部分不使用斜体或双引号,而使用小型大写字母。如《世界图书百科全书》中提到「See Nᴏ-Fᴀᴜʟᴛ Iɴsᴜʀᴀɴᴄᴇ」,意即让读者去参阅「Nᴏ-Fᴀᴜʟᴛ Iɴsᴜʀᴀɴᴄᴇ」这个章节。

指代姓氏或非西文顺序的姓氏

在一些语言中,如果一个人的姓氏非常长,往往会用小型大写字母来表示后文会经常使用的简写。如西班牙语写作的《堂吉诃德》中,「Don Qᴜɪxᴏᴛᴇ de La Mancha」意为「来自曼查的骑士吉诃德大人」,在后文中仅用「Qᴜɪxᴏᴛᴇ」来代指。而对于像中文这样,将姓氏排在名前面的顺序,有时也会使用小型大写字母,来特意标注出哪部分是姓氏,如「Mᴀᴏ Zedong」。

用于头部引起视觉注意

capitals_plays

在剧本中,不同角色的台词,其另起一行的人物名称往往会使用小型大写字母,来引起视觉上的注意,而又不显得过于突兀。

capitals_chapter_start_small

在章节的起首,英文字体往往也会有多样的处理。除了纵向上横跨数行的首字母放大,另一种常见的处理,则是使用小型大写字母。根据设计师的个人喜好,采用小型大写字母处理的范围也不尽相同,从首个单词,到首句话,再到首行,都有可能。

Reference:
- http://www.bergsland.org/2012/07/book-production/typography/the-use-of-small-caps-is-required/
- https://ilovetypography.com/2008/02/20/small-caps/
- http://theworldsgreatestbook.com/book-design-part-5/
- https://en.wikipedia.org/wiki/Small_caps

0

React Native 项目整合 CodePush 之完全指南

本文使用的环境:

  • React@16.3.1
  • React Native@0.55.4
  • react-native-code-push@5.3.4
  • Android SDK@23
  • Android Build Tool@23.0.3
  • Gradle@2.14.1
  • Android Gradle Plugin@2.2.3

Why CodePush?

CodePush 是微软提供的一个热更新前后台方案,它对 React Native 项目有很好的支持。

目前针对 React Native 的 hot update 方案有许多,但是 CodePush 是最成熟稳定的方案,它最大的特点是提供了完整的后台工具。它主要的优点是:

  • 微软出品,大厂保证
  • 良好的多环境支持(Testing,Staging, Production)
  • 灰度发布、自动回滚等等特性
  • 良好的数据统计支持:下载、安装、出错一目了然
  • 强大的 CLI 工具,一个终端搞定全部流程

由于 React Native 执行的是脚本 js 文件,对热更新有天然的亲和,有余力的团队可以尝试实现自己的框架,一个简单的实现思路是:

  • 修改加载 jsBundle 的代码,转而从指定的本地存储位置去加载。如果没有,下载 bundle, 并且本次打开使用 app 包中的 bundle。
  • 如果找到 jsBundle 文件,调用 api 比较版本号,如果不一致,则从指定服务器下载最新的 bundle 进行替换。
  • 通过反射调用私有方法,在下载完成的回调中更新运行时资源,从而能立即看到更新的效果。
  • 使用类似 google-diff-match-patch 的 diff 工具,生成差异化补丁,不必下载完整 bundle,从而大大减小补丁包体积。

网上有很多资料和源码,这里就不细述了。

后台配置

为了使用 Code Push 发布热更新,我们需要向微软服务注册我们的应用。这部分工作微软提供了强大的命令行工具:CodePush CLI

CodePush CLI

安装 cli 工具

npm 全局安装:

npm install -g code-push-cli

关联账号

使用命令

code-push register

注册一个账号,可以直接使用 GitHub 账号授权,完成后将 token 复制回命令行中。

授权返回的token

使用 whoami 查看登录状态:

code-push whoami

注册应用

登录成功后,我们注册一个app:

code-push app add 你的App名称 android react-native

注意一定要为 Android 和 iOS 分别注册,两者的更新包内容会有差异。

注册成功

查询状态

每个 App 有不同的运行时环境,比如 Production,Staging等,我们也可以配置自己的环境。查看 App 的不同环境和部署状况:

code-push deployment ls 注册的app名称

查询状态

目前我们还没有发布任何更新,所以表中的状态是空的。

到这里就完成了后端的基本配置。

App端配置

版本兼容

安装 Code Push 环境前首先要 check 版本的兼容性问题,不同的RN版本需要使用不同的 Code Push,原则上我们建议将 RN 和 CodePush 都升级到最新版本。

下表是官方文档中的兼容性说明:

React Native version(s) Supporting CodePush version(s)
<0.14 Unsupported
v0.14 v1.3 (introduced Android support)
v0.15-v0.18 v1.4-v1.6 (introduced iOS asset support)
v0.19-v0.28 v1.7-v1.17 (introduced Android asset support)
v0.29-v0.30 v1.13-v1.17 (RN refactored native hosting code)
v0.31-v0.33 v1.14.6-v1.17 (RN refactored native hosting code)
v0.34-v0.35 v1.15-v1.17 (RN refactored native hosting code)
v0.36-v0.39 v1.16-v1.17 (RN refactored resume handler)
v0.40-v0.42 v1.17 (RN refactored iOS header files)
v0.43-v0.44 v2.0+ (RN refactored uimanager dependencies)
v0.45 v3.0+ (RN refactored instance manager code)
v0.46 v4.0+ (RN refactored js bundle loader code)
v0.46-v0.53 v5.1+ (RN removed unused registration of JS modules)
v0.54-v0.55 v5.3+ (Android Gradle Plugin 3.x integration)

安装包

使用命令:

npm info react-native-code-push

来查看包相关信息。

我们建议始终将RN、React以及一些相关库升级到最新版本。在根目录下使用命令:

npm install --save react-native-code-push

来安装最新版本的 CodePush。

也可以参照上面的兼容性表格,安装指定版本:

npm install --save react-native-code-push@5.1.4

工程配置(Android)

如果工程创建的时候比较早,可能是使用命令create-react-native-app来创建的,则需要在根目录执行:

npm run eject

来改变工程结构,防止后面的兼容性问题。

配置安卓工程,官方提供了两种途径:

  • 使用命令行工具rnpm(现在已经被整合到React Native CLI工具中了)。执行
react-native link react-native-code-push
  • 手动配置

如果你是新手,或者对 gradle、安卓工程结构不了解,我们强烈建议执行一次手动配置,帮助理解到底发生了什么。

手动配置

step 1

android/settings.gradle文件中添加:

include ':app', ':react-native-code-push'
project(':react-native-code-push').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-code-push/android/app')

这个文件定义了哪些 module 应该被加入到编译过程,对于单个 module 的项目可以不用需要这个文件,但是对于 multiModule 的项目我们就需要这个文件,否则 gradle 不知道要加载哪些项目。这个文件的代码在初始化阶段就会被执行。

我们添加的内容告诉 gradle:去 node_modules 目录下的 react-native-code-push 加载 CodePush 子项目。

step 2

android/app/build.gradle 中的 dependencies 方法中添加依赖:

...
dependencies {
    ...
    compile project(':react-native-code-push')
}

这样就能在主工程中引用到 CodePush 模块了。

step 3

继续在 android/app/build.gradle 中,添加在编译打包阶段 CodePush 需要执行的 task 引用:

...
apply from: "../../node_modules/react-native-code-push/android/codepush.gradle"
...

这段代码其实就是调用了 project 对象的 apply 方法,传入了一个以 from 为 key 的 map。完整写出来就是这样的:

project.apply([from: '../../node_modules/react-native-code-push/android/codepush.gradle'])

apply fromapply plugin的区别在于,前者是从指定 url 去加载脚本文件,后者则用是从仓库拉取 plugin id 对应的二进制执行包。

step 4

CodePush 发布有各种环境(deployment),默认有 Staging 和 Production,我们需要在 buildType 中配置对应的环境,并且设置 PushKey,从而让 App 端的 CodePush RunTime 根据不同的健值来下载正确的更新包。

查询各个环境 Key 的方法是使用上文安装的 CLI 工具:

code-push deployment ls App名称 -k

查询CodePushKey

上表中的 Deployment Key 就是对应环境的 Key 值了。

android/app/build.gradle 中,配置 buildTypes:

buildTypes {

    // 对应Production环境
    release {
        ...
        buildConfigField "String", "CODEPUSH_KEY", '"从上述结果中复制的production值"'
        ...
    }

    // 对应Staging环境
    releaseStaging {
        // 从 release 拷贝配置,只修改了 pushKey
        initWith release
        buildConfigField "String", "CODEPUSH_KEY", '"从上述结果中复制的stagingkey值"'
    }

    debug {
        buildConfigField "String", "CODEPUSH_KEY", '""'
    }
}

注意这里不同 buildType 的命名,Staging 环境对应的 buildType 就叫 releaseStaging,要符合这样的命名规范。

Debug 环境虽然用不到 CodePush, 但是也要配置空的 Key 值,否则会报错。

step 5

处理完引用关系后,我们修改 MainApplication.java,在 App 执行时启动 CodePush 服务:

// 声明包
import com.microsoft.codepush.react.CodePush;

public class MainApplication extends Application implements ReactApplication {

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        ...
        // 重写 getJSBundleFile() 方法,让 CodePush 去获取正确的 jsBundle
        @Override
        protected String getJSBundleFile() {
            return CodePush.getJSBundleFile();
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                new MainReactPackage(),
                // 创建一个CodePush运行时实例
                new CodePush(BuildConfig.CODEPUSH_KEY, MainApplication.this, BuildConfig.DEBUG)
                ...
            );
        }
    };
}

js端引入 Code Push

配置完项目工程后,我们将 CodePush 引入到 js 端。

首先将 App 的根组件包裹在 CodePush 中:

import codePush from "react-native-code-push";

AppRegistry.registerComponent('BDCRM', () => codePush(App));

CodePush 会在 App 启动后自动去 check 和更新最新的版本,我们可以添加一些配置,让它在进入后台的时候也执行检查:

let codePushOptions = { checkFrequency: codePush.CheckFrequency.MANUAL };
AppRegistry.registerComponent('BDCRM', () => codePush(codePushOptions)(App));

CodePush js端的 api 不多,我们可以用这些 api 控制更新的一系列流程,常用的有:

// 检测是否有更新包可用
codePush.checkForUpdate(deploymentKey: String = null, handleBinaryVersionMismatchCallback: (update: RemotePackage) => void): Promise<RemotePackage>;

// 获取本地最新更新包的属性
codePush.getCurrentPackage(): Promise<LocalPackage>;

// 重启app(即使不用在 Hot Updating,也挺有用的)
codePush.restartApp(onlyIfUpdateIsPending: Boolean = false): void;

// 手动进一次更新
codePush.sync(options: Object, syncStatusChangeCallback: function(syncStatus: Number), downloadProgressCallback: function(progress: DownloadProgress), handleBinaryVersionMismatchCallback: function(update: RemotePackage)): Promise<Number>;

更多详细信息见文档

使用 CodePush CLI 发布更新

完成前后端的配置,打包发布应用后,后续的改动我们就能通过 CLI 工具来发布啦!

升级前首先要 check:

  • 应用的版本号要有更新(app/build.gradle: defaultConfig/versionName)
  • js bundle 要有改动,Code Push 会 diff 前后版本,如果代码一致会认为是无效的更新包

打开终端,进入到工程目录,完整发布命令是:

code-push release-react <appName> <platform>
[--bundleName <bundleName>]
[--deploymentName <deploymentName>]
[--description <description>]
[--development <development>]
[--disabled <disabled>]
[--entryFile <entryFile>]
[--gradleFile <gradleFile>]
[--mandatory]
[--noDuplicateReleaseError]
[--outputDir <outputDir>]
[--plistFile <plistFile>]
[--plistFilePrefix <plistFilePrefix>]
[--sourcemapOutput <sourcemapOutput>]
[--targetBinaryVersion <targetBinaryVersion>]
[--rollout <rolloutPercentage>]
[--privateKeyPath <pathToPrivateKey>]
[--config <config>]

命令参数很多,但用途都一目了然,嫌每次打麻烦的话,做成脚本也可以。

一般来说,我们发布应用首先会在测试环境进行稳定性测试,通过后再发布到生产环境中:

  • 打包发布 Staging 环境
code-push release-react 应用名 --platform android --deploymentName Staging --description "修复一些bug"

这样,我们 Staging 环境就可以收到更新推送啦,具体加载新 bundle 的实际,和我们在应用中配置的策略有关,上文已经介绍过了。

  • 测试 ok 后,提升(Promoting)到 Production 环境,并且进行灰度20%发布
code-push promote 应用名 Staging Production --rollout 20%
  • 在生产环境验证 ok,使用 patch 将灰度修改为100%,进行全网发布:
code-push patch 应用名 Production -rollout 100%

以上就是按照 测试 - 灰度 - 全部发布 步骤的一个典型 CodePush 发布工作流。

总体来说,CodePush 能满足我们灰度发布 React Native 应用的大部分需求了,由微软提供的服务器端支持可以节省很多工作,是一个成熟可靠的方案。如果要说缺点,可能有几个需要考虑一下:

  • 服务器速度,国内网络状况可能会影响下发的成功率和效率。
  • 污染代码,在 js 端必须将根节点包裹到 CodePush 模块中去,污染了代码。
  • 冗余,如果只是想要简单的下发小体积的 js bundle,CodePush 显得太“重”,过于冗余了,这时候用轻量化的方案更好。

总之,我们根据自己项目的需要去进行选型就好了!

更多细节,可以参考文档

0

震惊!JavaScript 竟然可以类型推断!

作为弱类型的 JavaScript 写起来爽,维护起来更

—— 鲁迅·沃梅硕果

近几年,前端技术的发展可以用 Big Boom 来形容,因此 JavaScript 也被大规模的运用在项目中,由此也产生了代码的维护问题,所谓 动态类型一时爽,代码重构火葬场

其实不仅仅是代码重构,在日常开发中也能感受到弱类型语言的不足所带来的不便之处。举个例子,现在有个函数 renderUserList , 作用是将用户列表显示在界面上

function renderUserList(el, userList) {
  let html = '';
  for (const user of userList) {
    html += `<div>
    姓名:${user.age}
    年龄:${user.age}
    </div>`
  }
  el.innerHTML = html;
}

我敢打赌,大家在写这种类型函数的时候,都是在盲写,因为我们不知道传入的 eluserList 到底是什么类型。更不知道 el 下面有哪些方法,写的时候都如此费劲,跟别谈维护了。其实我们可以通过一些简单的操作,让这个函数写起来更轻松,就像下面一样:

类型提示1

类型提示2

那么,到底是怎么实现的呢?接下来就要介绍本文的主角 JSDocVSCode

JSDoc是一个根据 JavaScript 文件中注释信息,生成 JavaScript 应用程序或库、模块的 API 文档 的工具。你可以使用他记录如:命名空间,类,方法,方法参数等。

通俗的讲,JSDoc 是 JavaScript 注释规范的一种,VSCode 利用 JSDoc 规范的特点,配合 typescript 实现了“类型提示”,所以在 VSCode 中基本上是 开箱即用 的,而对于非内置对象,比如 jQuery 的 $,lodash 的 _ 等,则需要单独下载对应的声明文件。

不过实际开发中,在 window 和 mac 上,还是有些差别的,mac 版的 VSCode 会去检查代码,然后自动下载对应的声明文件存放在 ~/Library/Caches/typescript/ (猜测是自动下载的),而 windows 则需要开发者手动通过 npm 去安装需要的声明文件,文末也会提到如何使用声明文件。

另外在 .jsx 中也可以使用 JSDoc,webstorm 也支持通过 JSDoc 实现类型提示, sublime 貌似还不支持。

在 VSCode 中会自动根据 JSDoc 的标注对变量、方法、方法参数等进行类型推断,通过 TypeScript 来进行智能提示,因此从编写注释开始学习 TypeScript 也是一个不错的选择,下面就来一一列举 JSDoc 在代码中的用法。

变量

@type 标注变量的类型

基础类型

/**
 * @type {number}
 */
let n;

/** @type {boolean} */
let flag;

/** @type {string} */
let str;

联合类型

如果一个变量可能是多种类型,则可以使用联合类型

/** 
 * @type {string | boolean}
 */
let x;

自定义类型

我们经常用到自定义类型,也就是 JavaScript 中的对象,对于简单的对象,可以用下面的写法

/**
 * @type {{name: string, age: number}}
 */
let user;

对于键值对比较多的复杂对象,可以使用 @typedef 来定义复杂类型,用 prop 或者 property 来定义对象的属性。

/**
 * @typedef {Object} goods
 * @property {string} name
 * @prop {number} code
 * @prop {string=} thumbnail 用 = 表示该属性是可能存在,也可能不存在
 * @prop {string} [introduction] 也可以给属性名加上 [] 表示这是一个可选属性
 * @prop {string[]} label
 */

 /**
  * @type {goods}
  */
 let phone;

数组

可以使用 [] 或者 Array 表示数组

/**
 * @type {number[]}
 */
let numList;

/**
 * @type {Array<string>}
 */
let strList;

对于已经定义的类型或者已经声明的变量,也是可以直接使用,下面分别声明一个 user 数组和 goods 数组

/**
 * @type {user[]}
 */
let userList;

/**
 * @type {goods[]}
 */
let goodsList;

如果不确定数组的每一项具体类型,可以使用 any * 或者交叉类型

/**
 * @type {any[]}
 */
let arr1;

/**
 * @type {*[]}
 */
let arr2;

/**
 * @type {(user | goods)[]}
 */
let arr3

泛型

/**
 * @template T
 * @param {T} p1
 * @return {T}
 */
function gen(p1) { return p1 }

函数

@name 表示函数的名称

@param 表示函数的参数

@return@returns 表示函数的返回值

一般函数的写法大致分为两种:声明式函数和函数表达式。

函数表达式

/**
 * @type {function (number, number): number}
 */
var getSum = (n1, n2) => n1 + n2;

声明式函数

/**
 * @name fn
 * @param {string} str
 * @param {boolean} flag
 * @returns {*[]}
 */
function fn(str, flag) {
  return [];
}

通过上面的注释写法,便可以在函数 fn 内部正确的识别出两个参数的类型,并且可以知道该函数返回值类型为数组。

对于函数参数的类型,写法和上面的变量写法一致,区别是将 @type 换成了 @param,函数的返回值也是同样的道理。

对象的方法

对函数的注释同样适用于对象的方法

var o = {
  /**
   * @param {string} msg
   * @returns {void}
   */
  say(msg) {
    console.log(msg);
  }
}

内置类型和其它类型

上面的例子只是简单的用到了一些常见的类型,然而在实际开发中,我们用到的不止这些,比如开始文章开头的例子中,有用到了 DOM 对象,那该怎么编写注释呢?其实 VSCode 已经为我们提供了很多的类型了,比如 DOM 对象对应的类型是 HTMLElement , 事件对象对应的类型是 Event,同时 DOM 对象还可以更细化,比如 HTMLCanvasElementHTMLImageElement 等等。

同时,我们在开发中也会用到第三方的类库或框架,通常情况下,这些类库都会有一份以 d.ts 结尾的声明文件,该声明文件中包含了所用到类型的所有提示,以最为经典的 jQuery 为例,如果在时在 webpack 环境下,在通过 npm 安装 jQuery 后,需要再单独安装对应的声明文件 @types/jquery ,这样 VSCode 就可以正确的识别 $ 符号,也可以在 JSDoc 中使用 JQuery, JQueryStatic 等这都类型了,就像下面这样

/**
 * @type {JQuery}
 */
var $btn = $('button');

/**
 * @param {number} userId
 * @returns {JQuery.jqXHR} 
 */
function getUser(userId) {
  return $.get(`/user/${userId}`);
}

大部分情况下,通过 npm 发布的包,都会包含其对应的声明文件,如果没有的话,可以通过这个地址 TypeSearch 来搜索一下并安装 ,如果感兴趣可以到这个仓库 DefinitelyTyped 看看。当然你也可以提供一些仓库内目前还没有声明文件,别人会非常感谢你的!

当然并不是所有的项目都用到了 npm ,仍有很多项目在使用 script 这种方式从 cdn 来引入 .js 文件,这种情况下用不到 webpack ,也用不到 npm ,那这个时候就要从上面所提到的仓库地址 DefinitelyTyped 来下载对应的声明文件了,然后通过 /// <reference path="" /> 这种形式来引入声明文件,就像下面这样

/// <reference path="./node_modules/@types/jquery/index.d.ts"/>

个人建议:即使是通过 cdn 方式来引入 .js 文件,也可以通过 npm 来安装 @types/ ,这样和在每个文件中通过 /// <reference path="" /> 引入声明文件相比,还是方便很多的。

总结

以上便是关于利用 JSDoc 实现 JavaScript 的类型提示。当然还有一些更深入的用法,比如全局模板文件,命名空间等,但是这些和 TypeScript 关系更大一些。当有一天你发现 JSDoc 已经不能满足你的时候,便是向着 TypeScript 大举进攻的时候了。

0

OpenResty 不完全指南

OpenResty 简介

OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台。我们知道开发 Nginx 的模块需要用 C 语言,同时还要熟悉它的源码,成本和门槛比较高。国人章亦春把 LuaJIT VM 嵌入到了 Nginx 中,使得可以直接通过 Lua 脚本在 Nginx 上进行编程,同时还提供了大量的类库(如:lua-resty-mysql lua-resty-redis 等),直接把一个 Nginx 这个 Web Server 扩展成了一个 Web 框架,借助于 Nginx 的高性能,能够快速地构造出一个足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。

Nginx 采用的是 master-worker 模型,一个 master 进程管理多个 worker 进程,worker 真正负责对客户端的请求处理,master 仅负责一些全局初始化,以及对 worker 进行管理。在 OpenResty 中,每个 worker 中有一个 Lua VM,当一个请求被分配到 worker 时,worker 中的 Lua VM 里创建一个 coroutine(协程) 来负责处理。协程之间的数据隔离,每个协程具有独立的全局变量 _G

ngx_lua works.png

OpenResty 处理请求流程

由于 Nginx 把一个请求分成了很多阶段,第三方模块就可以根据自己的行为,挂载到不同阶段处理达到目的。OpenResty 也应用了同样的特性。不同的阶段,有不同的处理行为,这是 OpenResty 的一大特色。OpenResty 处理一个请求的流程参考下图(从 Request start 开始):

image

指令 使用范围 解释
int_by_lua* init_worker_by_lua* http 初始化全局配置/预加载Lua模块
set_by_lua* server,server if,location,location if 设置nginx变量,此处是阻塞的,Lua代码要做到非常快
rewrite_by_lua* http,server,location,location if rewrite阶段处理,可以实现复杂的转发/重定向逻辑
access_by_lua* http,server,location,location if 请求访问阶段处理,用于访问控制
content_by_lua* location, location if 内容处理器,接收请求处理并输出响应
header_filter_by_lua* http,server,location,location if 设置 heade 和 cookie
body_filter_by_lua* http,server,location,location if 对响应数据进行过滤,比如截断、替换
log_by_lua http,server,location,location if log阶段处理,比如记录访问量/统计平均响应时间

更多详情请参考官方文档

配置 OpenResty

OpenResty 的 Lua 代码是提现在 nginx.conf 的配置文件之中的,可以与配置文件写在一起,也可以把 Lua 脚本放在一个文件中进行加载:

内联在 nginx.conf 中:

server {
    ...
    location /lua_content {
         # MIME type determined by default_type:
         default_type 'text/plain';

         content_by_lua_block {
             ngx.say('Hello,world!')
         }
    }
    ....
}    

通过加载 lua 脚本的方式:

server {
    ...
    location = /mixed {
         rewrite_by_lua_file /path/to/rewrite.lua;
         access_by_lua_file /path/to/access.lua;
         content_by_lua_file /path/to/content.lua;
     }
    ....
} 

OpenResty 变量的共享范围

全局变量

在 OpenResty 中,只有在 init_by_lua*init_worker_by_lua* 阶段才能定义真正的全局变量。因为在其他阶段,OpenResty 会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在上述两个阶段可以定义全局变量,也尽量避免这么做。全局变量能解决的问题,用模块变量也能解决,而且会更清晰,干净。

模块变量

这里将定义在 Lua 模块中的变量称为模块变量。Lua VM 会将 require 进来的模块换成到 package.loaded table 里,模块里的变量都会被缓存起来,在同一个 Lua VM下,模块中的变量在每个请求中是共享的,这样就可以避免使用全局变量来实现共享了,看下面一个例子:

nginx.conf

worker_processes  1;

...
location {
    ...
    lua_code_cache on;
    default_type "text/html";
    content_by_lua_file 'lua/test_module_1.lua'
}

lua/test_module_1.lua

local module1 = require("module1")

module1.hello()

lua/module1.lua

local count = 0
local function hello() 
    count = count + 1
    ngx.say("count: ", count)
end

local _M  = {
    hello = hello
}   

return _M

当通过浏览器访问时,可以看到 count 输出是一个递增的,这也说明了在 lua/module1.lua 的模块变量在每个请求中时共享的:

count: 1
count: 2
.....

另外,如果 worker_processes 的数量大于 1 时呢,得到的结果可能就不一样了。因为每个 worker 中都有一个 Lua VM 了,模块变量仅在同一个 VM 下,所有的请求共享。如果要在多个 Worker 进程间共享请考虑使用 ngx.shared.DICT 或如 Redis 存储了。

本地变量

跟全局变量,模块变量相对,我们这里姑且把 *_by_lua* 里定义的变量称为本地变量。本地变量仅在当前阶段有效,如果需要跨阶段使用,需要借助 ngx.ctx 或者附加到模块变量里。

这里我们使用了 ngx.ctx 表在三个不同的阶段来传递使用变量 foo

location /test {
     rewrite_by_lua_block {
         ngx.ctx.foo = 76
     }
     access_by_lua_block {
         ngx.ctx.foo = ngx.ctx.foo + 3
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.foo)
     }
 }

额外注意,每个请求,包括子请求,都有一份自己的 ngx.ctx 表。例如:

 location /sub {
     content_by_lua_block {
         ngx.say("sub pre: ", ngx.ctx.blah)
         ngx.ctx.blah = 32
         ngx.say("sub post: ", ngx.ctx.blah)
     }
 }

 location /main {
     content_by_lua_block {
         ngx.ctx.blah = 73
         ngx.say("main pre: ", ngx.ctx.blah)
         local res = ngx.location.capture("/sub")
         ngx.print(res.body)
         ngx.say("main post: ", ngx.ctx.blah)
     }
 }

访问 GET /main 输出:

main pre: 73
sub pre: nil  # 子请求中并没有获取到父请求的变量 $pre
sub post: 32
main post: 73

性能开关 lua_code_cache

开启或关闭在 *_by_lua_file(如:set_by_lua_file, content_by_lua_file) 指令中以及 Lua 模块中 Lua 代码的缓存。

若关闭,ngx_lua 会为每个请求创建一个独立的 Lua VM,所有 *_by_lua_file 指令中的代码将不会被缓存到内存中,并且所有的 Lua 模块每次都会从头重新加载。在开发模式下,这给我们带来了不需要 reload nginx 就能调试的便利性,但是在生成环境下,强烈建议开启。 若关闭,即使是一个简单的 Hello World 都会慢上一个数量级(每次 IO 读取和编译消耗很大)。

但是,那些直接写在 nginx.conf 配置文件中的 *_by_lua_block 指令下的代码不会在你编辑下实时更新,只有发送 HUP 信号给 Nginx 才能能够重新。

小案例

通过 OpenResty + Redis 实现动态路由

Nginx 经常用来作为反向代理服务器。通常情况下,我们将后端的服务配置在 Nginx 的 upstream 中,当后端服务有变更时就去修改 upstream 中的配置再通过 reload 的方式使其生效。这个操作如果在后端服务经常发生变更的情况下,操作起来就会显得有些繁琐了。现在利用 Lua + Redis 的方式将 upstream 中的配置放在 Redis 中,以实现动态配置的效果。

架构图

image

原理:

在求请求访问阶段处理(access_by_lua*)通过指定的规则(这个规则根据自己的需求去设计)从 Redis 中去获取相对应的后端服务地址去替换 Nginx 配置中的 proxy_pass 的地址。

流程:

  1. 在 Nginx 配置中创建后端服务地址的变量 $backend_server
    server {
        listen 80;
        server_name app1.example.com;

        location / {
            ...
            set $backend_server '';
        }
    }

同时在 Redis 中存入后端服务的地址。

set app1 10.10.10.10:8080
  1. 使用 ngx_redis2 模块来实现一个读取 Redis 的接口。
    # GET /get?key=some_key
    location = /get {
        internal;                        # 保护这个接口只运行内部调用
        set_unescape_uri $key $arg_key;  # this requires ngx_set_misc
        redis2_query get $key;
        redis2_pass foo.com:6379;        # redis_server and port
    }
  1. 在求请求访问阶段处理利用 ngx.location.capture 模块请求去上个阶段定义的 Redis 接口,并将结果替换 $backend_server
    location / {
        ...
        access_by_lua_block {
            local rds_key = "app1"
            # 从 redis 中获取 key 为 app1 对应的 server_ip
            local res = ngx.location.capture('/get', { args = {key = rds_key}})
            # 解析 redis 结果
            local parser = require("redis.parser")
            local server, typ = parser.parse_reply(res.body)
            if typ ~= parser.BULK_REPLY or not server then
                ngx.log(ngx.ERR, "bad redis response: ", res.body)
                ngx.exit(500)
            end

            ngx.var.backend_server = server
        }
    }
  1. Nginx 转发阶段将请求转发至后端服务。
    location / {
        ...
        access_by_lua_block {...};
        proxy_pass http://$backend_server;
    }

最后,推荐两个基于 OpenResty 的比较实用的两个开源项目:

参考

1+