之前所介绍的流处理API,无论是基本的转换、聚合,还是更为复杂的窗口,其实都是基于DataStream进行转换;故统称为DataStream API,这也是Flink编程的核心。当然,为了让代码有更强大的表现力和易用性,Flink本身提供了很多层API。
在更底层,我们可以不定义任何具体的算子(如map、filter、window)等,而只是提炼出一个统一的处理(process)操作——所有转换算子的一个概括性表达,可以自定义处理逻辑,这一层的接口被称为处理函数
在处理函数中,我们将直面数据流中最基本的元素:数据事件(event)、状态(state)以及时间(time)。这就相当于对流有了完全的控制权。同时处理函数比较抽象,没有具体的操作,理论上我们可以做任何事情,实现所有需求。处理函数可以说是进行Flink编程的“大招”,轻易不拿出来使用,但使用将横扫一切。
文章目录
基本处理函数
处理函数主要是定义数据流的转换操作,可以把它归到转换算子中。在Flink中几乎所有转换算子都提供了对应的函数类接口,处理函数也不例外;它所对应的函数类就叫做ProcessFunction
处理函数的功能和使用
我们之前学习的转换算子,一般只是针对某种具体操作来定义的,能够拿到信息比较有限。比如map算子,获取转换之后形式;而像窗口聚合这样的复杂操作,AggregateFunction中除数据外,还可以获取到当前的状态(以累加器Accumulator形式出现)。另外介绍过的富函数类,可以获取运行时上下文的方法getRuntimeContext(),还可以拿到状态、并行度、任务名称之类的运行时信息。但这些算子无法获得访问事件的时间戳、当前的水位线等信息。
处理函数提供了一个定时服务(TimeService),我们可以通过它访问流中的事件(event)、时间戳(timestamp)、水位线(watermark),甚至可以注册“定时事件”。而且处理函数继承了AbstractRichFunction抽象类,故拥有富函数类的所有特性,同样可以访问状态(state)和其他运行时信息。此外,处理函数还可以直接将数据输出到侧输出流(side output)中。故处理函数是最为灵活的处理方法,还可以实现各种自定义的业务逻辑,同时也是整个DataStream API的底层基础。
处理函数的使用与基本的转换操作类似,只需要直接基于DataStream调用.process()方法,传入一个ProcessFunction作为参数:
stream.process(new MyProcessFunction)
例子:自定义了一种处理逻辑,当数据的user为“Mary”时,将其输出一次;为“Bob”时,将user输出两次。另外可调用ctx.timerService().currentWatermark()来获取当前的水位线打印输出
import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;
public class ProcessFunctionExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event event, long l) {
return event.timestamp;
}
})
)
.process(new ProcessFunction<Event, String>() {
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
if (value.user.equals("Mary")) {
out.collect(value.user);
} else if (value.user.equals("Bob")) {
out.collect(value.user);
out.collect(value.user);
}
System.out.println(ctx.timerService().currentWatermark());
}
}).print();
env.execute();
}
}
ProcessFunction解析
public abstract class ProcessFunction<I, O> extends AbstractRichFunction {
public abstract void processElement(I value, Context ctx, Collector<O> out) throws Exception;
public void onTimer(long timestamp, OnTimerContext ctx, Collector<O> out) throws Exception {}
1、抽象方法processElement()
用于“处理元素”,定义了处理的核心逻辑,这个方法对于流中的每个元素都会调用一次,参数包括三个:输入数据值value,上下文ctx,以及收集器(Collector)out,处理之后的输出数据通过out来定义
- value:当前流中的输入元素,也就是正在处理的数据,类型与流中数据类型一致
- ctx:ProcessFunction中定义的抽象类Context,表示当前运行的上下文,可以获取到当前的时间戳,并提供了用于查询时间和注册定时器的“定时服务”(TimerService),以及可以将数据发送到侧输出流的方法output()
public abstract class Context {
public abstract Long timestamp();
public abstract TimerService timerService();
public abstract <X> void output(OutputTag<X> outputTag, X value);
}
- out:收集器Collector,用于返回输出数据。使用方式与flatMap算子中的收集器完全一样,直接调用out.collect()方法就可以向下游发出一个数据,该方法可多次调用或不用。
2、非抽象方法onTimer()
用于定义定时触发的操作,这是一个非常强大,也很有趣的功能。这个方法只有在注册好定时器时才会调用,而定时器是通过定时服务TimeService来注册的。
onTime()传入三个参数:时间戳(timestamp)、上下文(ctx)、收集器(out)。timestamp是指设定好的触发时间,事件时间语义下是水位线。
处理函数是基于事件触发的,水位线就如同插入流中的一条数据一样:处理真正的数据事件调用processFunction()方法,而处理水位线事件调用onTimer()
注意:onTimer()方法只是定时器触发时的操作,而定时器(Timer)真正的设置需要用到上下文ctx中的定时服务。在Flink中,只有“按键分区流”KeyedStream才支持设置定时器的操作,我们之前的代码并未使用定时器。基于不同类型的流,可以使用不同的处理函数。
处理函数的分类
DataStream在调用一些转换方法之后,有可能生成新的流类型;调用keyBy()得到KeyedStream,进而再调用window()之后得到windowedsTREAM。对于不同类型的流,都可以直接调用process()方法进行自定义处理,这时传入的参数就都可以叫做处理函数。
Flink提供了8个不同的处理函数
- (1)ProcessFunction:最基本的处理函数,基于DataStream直接调用process()时作为参数传入
- (2)KeyedProcessFunction:对流按键分区后的处理函数,基于KeyedStream调用process()
- (3)ProcessWindowFunction:开窗之后的处理函数,基于WindowedStream调用process()
- (4)ProcessAllWindowFunction:同样是开窗之后的处理函数,基于AllWindowedStream调用process()
- (5)CoProcessFunction:合并两条流之后的处理函数,基于ConnectedStream调用process()
- (6)ProcessJoinFunction:间隔连接两条流之后的处理函数,基于IntervalJoined调用proecess()
- (7)BroadcastProcessFunction:广播连接流处理函数,基于BroadcastConnectedStream调用process(),这里的广播流是一个未keyBy的普通流DataStream与一个广播流BroadcastStream连接之后的产物
- (8)KeyedBroadcastProcessFunction:按键分区的广播连接流处理函数,同样基于BroadcastConnectedStream调用process(),KeyedStream与广播流连接之后的产物
按键分区处理函数
只有在KeyedStream中才支持使用TimerService设置定时器的操作。故在一般情况先对keyBy分区之后再去定义处理操作
定时器和定时服务
KeyedProcessFunction的一个特色,就是可以灵活地使用定时器。
定时器(timers)是处理函数中进行时间相关操作的主要机制,在onTimer()方法中可以实现定时处理的逻辑,在触发之前注册过定时器。
定时服务与当前运行的环境有关,ProcessFunction的上下文(Context)中提供了timerService()方法,可以直接返回TimerService对象:
public abstract TimerService timerService();
TimerService是Flink关于时间和定时器的基础服务接口,包含以下六个方法:
//获取当前的处理时间
long currentProcessingTime();
//获取当前的水位线(事件时间)
long currentWatermark();
//注册事件时间定时器,当处理时间超过time触发
void registerProcessingTimeTimer(long time);
//注册事件时间定时器,当水位线超过time触发
void registerEventTimeTimer(long time);
//删除触发时间为time的处理时间定时器
void deleteProcessingTimeTimer(long time);
//删除触发时间为time的事件时间定时器
void deleteEventTimeTimer(long time);
处理函数都可以直接访问TimerService,但只有基于KeyedStream的处理函数才能去调用注册和删除定时器,未按键分区的DataStream不支持定时器操作,只能获取当前时间。
对于处理时间和事件时间这两者类型的定时器,TimerService内部会用一个优先队列将它们的时间戳(timestamp)保存起来,排队等待执行。定时器可以被认定是KeyedStream上处理算子的一个状态,它以时间戳作为区分,一个时间戳上的定时器只会被触发一次。
定时器同样具有容错性,它和状态一起都会被保存到一致性检查点(checkpoint),当发生故障时,Flink会重启并读取检查点中的状态,恢复定时器。如果是处理时间的定时器,有可能会出现已经“过期”的情况,它们将在重启时被立刻触发。
KeyedProcessFunction
对ProcessFunction的一个扩展,基于keyBy之后的KeyStream调用process()方法,传入KeyedProcessFunction的实现类,其是继承于AbstractRichFunction的一个抽象类
stream.keyBy(t -> t.f0)
.process(new MyKeyedProcessFunction())
public abstract class KeyedProcessFunction<K, I, O> extends AbstractRichFunction
{
...
public abstract void processElement(I value, Context ctx, Collector<O> out) throws Exception;
public void onTimer(long timestamp, OnTimerContext ctx, Collector<O> out) throws Exception {}
public abstract class Context {...}
...
}
例子1:处理时间上的定时器
import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
import java.sql.Timestamp;
public class ProcessingTimeTimerTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource());
stream.keyBy(data -> data.user)
.process(new KeyedProcessFunction<String, Event, String>() {
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
Long currTs = ctx.timerService().currentProcessingTime();
out.collect(ctx.getCurrentKey() + " 数据到达,到达时间:" + new Timestamp(currTs));
//注册一个10秒后的定时器
ctx.timerService().registerProcessingTimeTimer(currTs + 10 * 1000L);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
out.collect(" 定时器触发,触发时间:" + new Timestamp(timestamp));
}
}).print();
env.execute();
}
}
自定义一个KeyedProcessFunction,起始processElement()方法是每来一个数据都会调用一次,其次注册了一个10秒后的定时器。onTimer()方法会在定时器触发时调用
例子2:事件时间上的定时器
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.streaming.api.functions.source.SourceFunction;
import org.apache.flink.util.Collector;
import java.sql.Timestamp;
import java.time.Duration;
public class EventTimeTimerTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
SingleOutputStreamOperator<Event> stream = env.addSource(new CustomSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
//事件时间
stream.keyBy(data -> data.user)
.process(new KeyedProcessFunction<String, Event, String>() {
@Override
public void processElement(Event value, Context ctx, Collector<String> out) throws Exception {
Long currTs = ctx.timestamp();
out.collect(ctx.getCurrentKey() + " 数据到达,到达时间:" + new Timestamp(currTs) + " watermark: " + ctx.timerService().currentWatermark());
//注册一个10秒后的定时器
ctx.timerService().registerEventTimeTimer(currTs + 10 * 1000L);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
out.collect(" 定时器触发,触发时间:" + new Timestamp(timestamp) + " watermark: " + ctx.timerService().currentWatermark());
}
}).print();
env.execute();
}
//自定义测试数据源
public static class CustomSource implements SourceFunction<Event>{
@Override
public void run(SourceContext<Event> ctx) throws Exception {
//直接发出测试数据
ctx.collect(new Event("Marry","./home",1000L));
Thread.sleep(5000L);
ctx.collect(new Event("Alice","./home",11000L));
Thread.sleep(5000L);
ctx.collect(new Event("Bob","./home",11001L));
Thread.sleep(5000L);
}
@Override
public void cancel() {
}
}
}
从数据中提取出时间戳:1000、11000、11001。且每次发出数据后会停顿5秒。我们发现数据到来,时间戳与当前的水位线并不一致。水位线周期性(默认200ms)生成一次,故开始的时间戳为最小值Long.MIN_VALUE;随后到了200ms后就由当前最大的时间戳1000来生成水位线,由于现在没有设置水位线延迟,就默认减少1毫秒,水位线推进到999。
第一条数据到来后,设定的定时器时间为 1000 + 10 * 1000 = 11000;而当 时间戳为 11000 的第二条数据到来,水位线还处在 999 的位置,当然不会立即触发定时器;而之后水位线会推进到 10999,同样是无法触发定时器的。必须等到第三条数据到来,将水位线真正推进到 11000,就可以触发第一个定时器了。第三条数据发出后再过 5 秒,没有更多的数据生成了,整个程序运行结束将要退出,此时Flink 会自动将水位线推进到长整型的最大值 (Long.MAX_VALUE)。于是所有尚未触发的定时器这时就统一触发了
窗口处理函数
ProcessWinowFuncion和ProcessAllWindowFunction
窗口处理函数的使用
进行窗口的计算,可以直接调用现成的简单聚合方法(sum/max/min),也可以通过.reduce()或.aggregate()来自定义一般的增量聚合函数(ReduceFunction/AggregateFunction);对于更加复杂、需要窗口信息和额外状态的一些场景,我们还可以直接使用全窗口函数、把数据全部收集保存在窗口内,等到触发窗口计算时再统一处理。
窗口处理函数ProcessWindowFunction的使用与其他窗口函数类似,也是基于WindowStream直接调用方法就可以,这时
stream.keyBy( t -> t.f0 )
.window( TumblingEventTimeWindows.of(Time.seconds(10)) )
.process(new MyProcessWindowFunction())
ProcessWindowFunctio解析
既是处理函数又是窗口函数,但其本周上更倾向于“窗口函数”一些。
public abstract class ProcessWindowFunction<IN, OUT, KEY, W extends Window> extends AbstractRichFunction {
...
public abstract void process(
KEY key, Context context, Iterable<IN> elements, Collector<OUT> out) throws
Exception;
public void clear(Context context) throws Exception {}
public abstract class Context implements java.io.Serializable {...}
}
继承于AbstractRichFunction的抽象类,它有四个类型参数:
- IN:input,数据流中窗口任务的数据输出类型
- OUT:output,窗口任务进行计算之后的输出数据类型
- KEY:数据中键key的类型
- W:窗口的类型,是Window的子类型。一般情况下我们定义时间窗口,W就是TimeWindow
内部定义的方法,跟我们之前熟悉的处理函数就有所区别了。因为全窗口函数不是逐个处理元素的,所以处理数据的方法从processElemet()改成了process()
- key:窗口做统计计算基于的键,也就是之前keyBy用来分区的字段
- context:当前窗口进行计算的上下文,ProcessWindowFunction内部定义的抽象类Context
- elements:窗口收集到用来计算的所有数据,一个可迭代的集合类型
- out:用来发送数据输出计算结果的收集器,类型为Collector
这里的输入数据是窗口中所有数据的集合,而上下文context所包含的内容也与其他处理函数有所区别:
public abstract class Context implements java.io.Serializable {
public abstract W window();
public abstract long currentProcessingTime();
public abstract long currentWatermark();
public abstract KeyedStateStore windowState();
public abstract KeyedStateStore globalState();
public abstract <X> void output(OutputTag<X> outputTag, X value);
}
output()方法定义侧输出流不变,其他部分都有所变化
- 不再持有TimerService对象,只能通过currentProcessingTime()和currentWatermark()来获取当前时间,故失去了设置定时器的功能
- 当前不只是处理一个数据,不再提供timestamp()
- 可通过window()直接获取到当前的窗口对象,也可通过windowState()和globalState()获取到当前自定义的窗口状态和全局状态。注意:此处窗口状态是自定义不包括窗口本身已经有的状态,针对当前key、当前窗口有效;全局状态也是自定义状态,针对当前key的所有窗口有效。
- 没有onTimer()方法,却多出了clear()方法。如果自定义了窗口状态,必须在clear()方法中进行显示地清除,避免内存溢出。
该函数没有定时器我们却希望有定时操作又该怎么做?Flink提供了窗口触发器(Trigger),在触发器中也有一个TriggerContext,它可以起到类似TimerService的作用:获取当前时间、注册和删除定时器,另外还可以获取当前的状态。
这样的设计会让处理流程更加清晰——定时操作也是一种"触发",我们可将所有的触发操作归触发器管,处理数据的操作归窗口函数管。
当然还有另一种窗口处理函数ProcessAllWindowFunction,用法与上面类似
stream.windowAll( TumblingEventTimeWindows.of(Time.seconds(10)) )
.process(new MyProcessAllWindowFunction())
应用案例——TopN
在实际问题中,对于一些复杂的问题,用增量聚合函数无法满足时我们就可以考虑使用窗口处理函数这样的“大招”了。
例:实时统计一段时间内的热门url。例如,统计最近10秒内最热门的两个url链接,并且每5秒钟更新一次,这里可以用滑动窗口来收集url的访问数据,按照不同的url进行统计,最后汇总排序并最终输出前两名
ProcessAllWindowFunction实现
一种最简单的想法,不区分url链接,直接将所有访问数据都收集起来统一进行计算。故可不做keyBy,直接基于DataStream开窗,然后使用ProcessAllWindowFunction来进行处理。
思路如下:用一个HashMap来保存每个url的访问次数,只要遍历窗口中的所有数据,自然就能得到所有url的热门度,最后把HaashMap转换成一个列表ArrayList,然后进行排序、取出前两名输出
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessAllWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.SlidingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
public class TopNExample_ProcessAllWindowFunctions {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
//读取数据
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
//直接开窗,收集所有数据排序
stream.map(data -> data.user)
.windowAll(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.aggregate(new UrlHashMapCountAgg(), new UrlAllWindowResult())
.print();
env.execute();
}
//实现自定义的增量聚合函数
private static class UrlHashMapCountAgg implements AggregateFunction<String, HashMap<String, Long>, ArrayList<Tuple2<String, Long>>> {
@Override
public HashMap<String, Long> createAccumulator() {
return new HashMap<>();
}
@Override
public HashMap<String, Long> add(String value, HashMap<String, Long> accumulator) {
if (accumulator.containsKey(value)) {
Long count = accumulator.get(value);
accumulator.put(value, count + 1);
} else {
accumulator.put(value, 1L);
}
return accumulator;
}
@Override
public ArrayList<Tuple2<String, Long>> getResult(HashMap<String, Long> accumulator) {
ArrayList<Tuple2<String ,Long>> result = new ArrayList<>();
for (String key : accumulator.keySet()){
result.add(Tuple2.of(key,accumulator.get(key)));
}
result.sort(new Comparator<Tuple2<String, Long>>() {
@Override
public int compare(Tuple2<String, Long> o1, Tuple2<String, Long> o2) {
return (int)(o2.f1 - o1.f1);
}
});
return result;
}
@Override
public HashMap<String, Long> merge(HashMap<String, Long> a, HashMap<String, Long> b) {
return null;
}
}
//实现自定义全窗口函数,包装信息输出结果
private static class UrlAllWindowResult extends ProcessAllWindowFunction<ArrayList<Tuple2<String,Long>>,String, TimeWindow> {
@Override
public void process(Context context, Iterable<ArrayList<Tuple2<String, Long>>> elements, Collector<String> out) throws Exception {
ArrayList<Tuple2<String, Long>> list = elements.iterator().next();
StringBuilder result = new StringBuilder();
result.append("--------------------------------\n");
result.append("窗口结束时间:" + new Timestamp(context.window().getEnd()) + "\n");
//取List前两个,包装信息输出
for (int i = 0; i < 2; i++) {
Tuple2<String, Long> currTuple = list.get(i);
String info = "No. " + (i + 1) + " "
+ "url: " + currTuple.f0 + " "
+ "访问量" + currTuple.f1 + " \n";
result.append(info);
}
result.append("--------------------------------\n");
out.collect(result.toString());
}
}
}
KeyedProcessFunction
上个方法没有按键分区,将所有数据放在一个分区上进行开窗操作,在实际应用中要尽量避免;另一方面,上述过程是先收集齐所有数据、然后再逐一遍历更新HashMap,不够高效,如果可以利用增量聚合函数的特性,每来一条数据就更新一次对应url的访问量,那么窗口触发计算时只需要做排序输出就可以了。
从这两个方面进行优化:
- 对数据进行按键分区,分别统计浏览量
- 进行增量聚合,将得到的结果最后再做排序输出
故我们可以使用增量聚合函数AggregateFunction进行浏览量的统计,再结合ProcessWindowFunction排序输出来实现TopN的需求
思路如下:先按照url对数据进行keyBy分区,统计出每个url链接的访问量,并将其收集起来,排序输出最终结果,总结处理流程如下:
- 1、读取数据源
- 2、筛选浏览行为(PV)
- 3、提取时间戳生成水位线
- 4、按照url进行keyBy分区操作
- 5、开长度为10秒、步长为5秒的事件时间滑动窗口
- 6、使用增量聚合函数AggregateFunction,并结合全窗口函数WindowFunction进行窗口聚合,得到每个url,在统计每个窗口内的浏览量,包装成UrlViewCount
- 7、对窗口进行keyBy分区操作
- 8、对同一窗口的统计结果数据,使用KeyedProcessFunction进行收集并排序输出
另外,数据流中的元素是逐个到来,所以即使理论上我们应该同时收集很多url的浏览量统计结果,实际上也是有先后顺序、只能一条条处理。下游任务KeyedProcessFunction看到一个url的统计结果,并不能保证这个时间段的统计数据不会再来了,故也不能贸然进行排序输出,我们可以通过设置水位线延迟的方法多等一会,等到水位线超过真正的窗口结束时间,要统计的数据肯定就都到齐了。
实现上:采用一个延迟触发的事件时间定时器,基于窗口的结束时间来设定延迟,并不需要等太久——我们需要靠水位线的推进来触发定时器,设置1毫秒的延迟就可以保证了。
在等待过程中,之前的数据可以用一个自定义的列表状态(ListState)来进行存储。该状态需要使用富函数类的getRuntimeContext()方法获取运行时上下文来定义,我们一般将其放在open()生命周期方法中。之后每来一个UrlViewCount就将其添加到当前的列表状态中,并注册一个触发时间为窗口结束时间(windowEnd + 1)的定时器。当水位线到达这个时间,定时器触发,就可以保证当前窗口中所有url的统计结果UrlViewCount都到齐了随后便可从状态中取出进行排序输出
import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import com.yingzi.chapter06.UrlCountViewExample;
import com.yingzi.chapter06.UrlViewCount;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.streaming.api.windowing.assigners.SlidingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
public class TopNExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
//读取数据
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
//1.按照url分组,统计窗口内每个url的访问量
SingleOutputStreamOperator<UrlViewCount> urlCountStream = stream.keyBy(data -> data.url)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5))) //定义滑动窗口
.aggregate(new UrlCountViewExample.UrlViewCountAgg(), new UrlCountViewExample.UrlViewCountResult());
urlCountStream.print("url count");
//2.对于同一窗口统计出的访问量,进行收集和排序
urlCountStream.keyBy(data -> data.windowEnd)
.process(new TopNProcessResult(2))
.print();
env.execute();
}
//实现自定义的KeyedProcessFunction
public static class TopNProcessResult extends KeyedProcessFunction<Long, UrlViewCount, String> {
//定义一个属性,n
private Integer n;
//定义列表状态
private ListState<UrlViewCount> urlViewCountListState;
public TopNProcessResult(Integer n) {
this.n = n;
}
//在环境中获取状态
@Override
public void open(Configuration parameters) throws Exception {
urlViewCountListState = getRuntimeContext().getListState(
new ListStateDescriptor<UrlViewCount>("url-count-list", Types.POJO(UrlViewCount.class))
);
}
@Override
public void processElement(UrlViewCount value, Context ctx, Collector<String> out) throws Exception {
//将数据保存到状态中
urlViewCountListState.add(value);
//注册windowEnd + 1ms的定时器
ctx.timerService().registerEventTimeTimer(ctx.getCurrentKey() + 1);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
ArrayList<UrlViewCount> urlViewCountArrayList = new ArrayList<>();
for (UrlViewCount urlViewCount : urlViewCountListState.get()) {
urlViewCountArrayList.add(urlViewCount);
}
//排序
urlViewCountArrayList.sort(new Comparator<UrlViewCount>() {
@Override
public int compare(UrlViewCount o1, UrlViewCount o2) {
return (int) (o2.count - o1.count);
}
});
//包装信息打印输出
StringBuilder result = new StringBuilder();
result.append("--------------------------------\n");
result.append("窗口结束时间:" + new Timestamp(ctx.getCurrentKey()) + "\n");
//取List前两个,包装信息输出
for (int i = 0; i < 2; i++) {
UrlViewCount currTuple = urlViewCountArrayList.get(i);
String info = "No. " + (i + 1) + " "
+ "url: " + currTuple.url + " "
+ "访问量" + currTuple.count + " \n";
result.append(info);
}
result.append("--------------------------------\n");
out.collect(result.toString());
}
}
}
在上述中引入了ListState,这里做一个简易说明:在open()方法中初始列表状态变量,初始化使用了ListStateDescriptor描述符,告诉Flink列表状态变量的名字和类型。列表状态变量的作用域是当前key所对应的逻辑分区,使用add方法向列表状态变量添加数据,使用get方法读取列表状态变量中的所有元素。
侧输出流
之前讲到的绝大多数转换算子,输出的都是单一流,流里的数据类型只要一种。而侧输出流可以认为是“主流”上分叉出的“支流”,而且这些流中的数据类型还可以不一样,利用这个功能可以很容易地实现“分流”操作
具体应用时,在处理函数的processElement()或者onTimer()方法中,调用上下文的output()方法就可以了
DataStream<Integer> stream = env.addSource(...);
SingleOutputStreamOperator<Long> longStream = stream.process(newProcessFunction<Integer, Long>() {
@Override
public void processElement( Integer value, Context ctx, Collector<Integer>out) throws Exception {
// 转换成 Long,输出到主流中
out.collect(Long.valueOf(value));
// 转换成 String,输出到侧输出流中
ctx.output(outputTag, "side-output: " + String.valueOf(value));
}
});
output()方法传入两个参数:第一个是输出标签OutputTag,第二个则是要输出的数据。
一般情况下,先在外部先将OutputTag声明出来:
OutputTag<String> outputTag = new OutputTag<String>("side-output") {};
如果想要获取这个侧输出流,可以基于处理之后的DataStream直接调用.getSideOutput()方法,传入对应OutputTag,这个方式与窗口API中获取侧输出流是完全一样的
DataStream<String> stringStream = longStream.getSideOutput(outputTag);
总结
Flink拥有非常丰富的多层API,而底层的处理函数可以说是最为强大、最为灵活地一种。从广义上讲,处理函数可以认为是DataStream API中的一部分,它的调用方式与其他转换算子完全一致,处理函数可以访问时间、状态,定义定时操作,它可以直接触及流处理最为本质的组成部分。
此文详细介绍了处理函数的功能和底层的结果,重点讲解了最为常用的 KeyedProcessFunction 和 ProcessWindowFunction,并实现了电商应用中 Top N 的经典案例,另外还介绍了侧输出流的用法