累加器是从用户函数和操作中,分布式地统计或者聚合信息。每个并行实例创建并更新自己的Accumulator对象, 然后合并收集器的不同并行实例。在作业结束时由系统合并。
累加器的结果可以从作业执行的结果中获得,也可以从Web运行时监视器中获得。
累加器是受Hadoop/MapReduce计数器的启发。但是要注意添加到累加器的类型可能与返回的类型不同。比如:我们添加单个对象,但是结果返回的是对象的set集合。
可以先看下Flink源码对累加器Accumulator的定义:
package org.apache.flink.api.common.accumulators;
import org.apache.flink.annotation.Public;
import java.io.Serializable;
/** * 累加器从用户函数和操作中,分布式地统计信息或聚合。 * 每个并行实例创建并更新自己的Accumulator对象, 然后合并收集器的不同并行实例。 在作业结束时由系统合并。 * 结果可以从作业执行的结果中获得,也可以从Web运行时监视器中获得。 * * 累加器是受Hadoop/MapReduce计数器而激发出来的。 * * 添加到收集器的类型可能与返回的类型不同. * 例如set类机器: 我们添加单个对象,但是结果返回的是对象的set集合 * * @param 添加到累加器的值的类型 * * @param 将向客户端报告的累加器结果的类型 * */
@Public
public interface Accumulator<V, R extends Serializable> extends Serializable, Cloneable {
/** * @param value 要添加到Accumulator对象的值。 * */
void add(V value);
/** * @return local 当前UDF上下文中的本地值。 */
R getLocalValue();
/** * 重置本地值。这只影响当前的UDF上下文。 */
void resetLocal();
/** * 由系统内部使用,用于在作业结束时合并收集器的收集部分。 * * @param other 对要合并的收集器的引用。 */
void merge(Accumulator<V, R> other);
/** * 复制收集器。所有子类都需要正确地实现克隆,并且不能报错 {@link java.lang.CloneNotSupportedException} * * @return 复制的累加器。 */
Accumulator<V, R> clone();
}
所有我们在任务中自定义的累加器必须要实现这个Accumulator接口。
本案例是要实现筛选并计数csv文件中包含空字段的行。并且使用自定义累加器计算csv文件中每列的空字段数。在此案例中,空字段是指那些最多包含空格和制表符等空白字符的字段。
输入文件是纯文本的csv文件,以分号作为字段分隔符,双引号作为字段分隔符和三列。
从这个案例中,我们可以学习到:
从main主函数分析程序的逻辑比较直接点。在代码注释中,主要注释出了四步,因为这四步比较关键。后面的讲述也将围绕这四步来分开讲解。
package org.apache.flink.examples.java.relational;
import org.apache.flink.api.common.JobExecutionResult;
import org.apache.flink.api.common.accumulators.Accumulator;
import org.apache.flink.api.common.functions.RichFilterFunction;
import org.apache.flink.api.java.DataSet;
import org.apache.flink.api.java.ExecutionEnvironment;
import org.apache.flink.api.java.tuple.Tuple3;
import org.apache.flink.api.java.utils.ParameterTool;
import org.apache.flink.configuration.Configuration;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class EmptyFieldsCountAccumulator {
private static final String EMPTY_FIELD_ACCUMULATOR = "empty-fields";
public static void main(final String[] args) throws Exception {
final ParameterTool params = ParameterTool.fromArgs(args);
final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
env.getConfig().setGlobalJobParameters(params);
// 1. 得到数据集
final DataSet<StringTriple> file = getDataSet(env, params);
// 2. 过滤含有空值的行
final DataSet<StringTriple> filteredLines = file.filter(new EmptyFieldFilter());
JobExecutionResult result;
// 3. 执行任务并输出过滤行
if (params.has("output")) {
filteredLines.writeAsCsv(params.get("output"));
// 执行程序
result = env.execute("Accumulator example");
} else {
System.out.println("Printing result to stdout. Use --output to specify output path.");
filteredLines.print();
result = env.getLastJobExecutionResult();
}
// 4. 通过注册时的key值来获得累加器的结果
final List<Integer> emptyFields = result.getAccumulatorResult(EMPTY_FIELD_ACCUMULATOR);
System.out.format("Number of detected empty fields per column: %s\n", emptyFields);
}
}
final DataSet<StringTriple> file = getDataSet(env, params);
这里继续看下getDataSet(env, params)方法:
/** * 得到数据集 * @param env * @param params * @return */
@SuppressWarnings("unchecked")
private static DataSet<StringTriple> getDataSet(ExecutionEnvironment env, ParameterTool params) {
// 如果指定了input参数
if (params.has("input")) {
return env.readCsvFile(params.get("input"))
.fieldDelimiter(";")
.pojoType(StringTriple.class);
// 否则,读取默认的数据集
} else {
System.out.println("Executing EmptyFieldsCountAccumulator example with default input data set.");
System.out.println("Use --input to specify file input.");
return env.fromCollection(getExampleInputTuples());
}
}
这步是定义了获取输入数据集的逻辑。如果指定了input参数,那么将直接读取指定的csv文件,否则就读取默认的数据集。这个默认的数据集,我们继续看getExampleInputTuples()方法。
/** * * 得到例子输入Tuple * @return */
private static Collection<StringTriple> getExampleInputTuples() {
Collection<StringTriple> inputTuples = new ArrayList<StringTriple>();
inputTuples.add(new StringTriple("John", "Doe", "Foo Str."));
inputTuples.add(new StringTriple("Joe", "Johnson", ""));
inputTuples.add(new StringTriple(null, "Kate Morn", "Bar Blvd."));
inputTuples.add(new StringTriple("Tim", "Rinny", ""));
inputTuples.add(new StringTriple("Alicia", "Jackson", " "));
return inputTuples;
}
默认数据集是Collection
/** * 当数据集有比较多的字段时,那么推荐是用POJOs,而不是TupleX */
public static class StringTriple extends Tuple3<String, String, String> {
public StringTriple() {}
public StringTriple(String f0, String f1, String f2) {
super(f0, f1, f2);
}
}
其实StringTriple就是一个三元的Tuple数据结构。不过当字段比较多时,还是不建议应用Tuple数据结构,建议直接应用POJOs要好点。
这步是执行过滤操作:
final DataSet<StringTriple> filteredLines = file.filter(new EmptyFieldFilter());
EmptyFieldFilter类实现如下:
/** * 此函数筛选所有具有一个或多个空字段的传入元组 * * 这样做的同时,它还计算带有累加器(在下注册)的每个属性的空字段数。 * {@link EmptyFieldsCountAccumulator#EMPTY_FIELD_ACCUMULATOR}). */
public static final class EmptyFieldFilter extends RichFilterFunction<StringTriple> {
// 在每个筛选函数实例中创建新的收集器
// 以后可以合并累加器
private final VectorAccumulator emptyFieldCounter = new VectorAccumulator();
@Override
public void open(final Configuration parameters) throws Exception {
super.open(parameters);
// 注册收集器实例
getRuntimeContext().addAccumulator(EMPTY_FIELD_ACCUMULATOR,
this.emptyFieldCounter);
}
@Override
public boolean filter(final StringTriple t) {
boolean containsEmptyFields = false;
// 遍历tuple的所有字段,寻找有没有空值
for (int pos = 0; pos < t.getArity(); pos++) {
final String field = t.getField(pos);
if (field == null || field.trim().isEmpty()) {
containsEmptyFields = true;
// 如果遇到空字段,请更新累加器
this.emptyFieldCounter.add(pos);
}
}
return !containsEmptyFields;
}
}
上述过滤逻辑一开始就初始化了一个累加器VectorAccumulator ,然后把累加器注册到上下文执行环境中。这里累加器VectorAccumulator的定义,应该是我们本文的重点了,下面我们重点分析这一块。
VectorAccumulator的定义逻辑
/** * 这个累加器保持一个计数向量. 调用 {@link #add(Integer)} 来增加第n-th列的值. * 矢量的大小是自动管理的. */
public static class VectorAccumulator implements Accumulator<Integer, ArrayList<Integer>> {
/** 存储累积向量分量. */
private final ArrayList<Integer> resultVector;
/** * 构造函数 */
public VectorAccumulator(){
this(new ArrayList<Integer>());
}
public VectorAccumulator(ArrayList<Integer> resultVector){
this.resultVector = resultVector;
}
/** * 将指定位置的结果向量分量增加1. */
@Override
public void add(Integer position) {
updateResultVector(position, 1);
}
/** * 将指定位置的结果向量分量增加指定的增量。 */
private void updateResultVector(int position, int delta) {
// 如果position超出了列的最大索引,那么就再起一列。
while (this.resultVector.size() <= position) {
this.resultVector.add(0);
}
// 增加该列的值,列索引为position
final int component = this.resultVector.get(position);
this.resultVector.set(position, component + delta);
}
@Override
public ArrayList<Integer> getLocalValue() {
return this.resultVector;
}
@Override
public void resetLocal() {
// 如果应重用收集器实例,则清除结果向量
this.resultVector.clear();
}
@Override
public void merge(final Accumulator<Integer, ArrayList<Integer>> other) {
// 合并两个累加器
final List<Integer> otherVector = other.getLocalValue();
for (int index = 0; index < otherVector.size(); index++) {
updateResultVector(index, otherVector.get(index));
}
}
@Override
public Accumulator<Integer, ArrayList<Integer>> clone() {
return new VectorAccumulator(new ArrayList<Integer>(resultVector));
}
@Override
public String toString() {
return StringUtils.join(resultVector, ',');
}
}
VectorAccumulator是累加元素类型为Integer,然后返回类型为ArrayList
分析完VectorAccumulator的逻辑。让我们再重新回到EmptyFieldFilter的filter方法的具体逻辑:其逻辑是对于每行数据,遍历其每列的值,如果某一列有空值,那么就过滤掉该元素,那么最后保留下来就只用没有空值的行了。
另外filter中执行了如下逻辑:
this.emptyFieldCounter.add(pos);
这一步是对空字段进行累加。其中pos表示列的索引。
这一步简单,就是执行任务,然后打印出结果。
累加器在生成时,我们是通过:
getRuntimeContext().addAccumulator(EMPTY_FIELD_ACCUMULATOR,
this.emptyFieldCounter);
注册到了执行上下文环境中,当任务执行完了,其累加器的值,其实保留在了内存中。
通过key值去获取累加器,然后把累加器的值打印出来。
(John,Doe,Foo Str.)
Number of detected empty fields per column: [1, 0, 3]
上述打印结果不仅打印出来了没有空值列的行数据。然后也打印出了累加器结果,即表示第0列有1处空值,第1列没有空值,第2列有3处空值。
累加器其实也算是一种有状态(state)的计算,这种状态计算其实在实际应用中非常广泛。学习该案例,我们可以对累加器的用法有一定的理解。