Apache Flink入门

本文会引导大家入门Apache Flink开发。适合读者:对大数据开发感兴趣者。

要求:
无需大数据开发经验
了解Java 8
软件开发通用经验

您将学习到:
1. Apache Flink在大数据处理中的地位和作用
2. 使用Apache Flink开发应用的核心技能
3. 学习如何处理有界(批量)和无界(流式)数据
4. 了解Apache Flink的一些高级特性


一、Apache Flink介绍

Apache Flink是新一代大数据处理工具,它是一个分布式的集流和批处理于一体的开源平台。Flink的核心是一个流式数据处理引擎,它为数据流的分布式计算提供了数据分发、协作和容错处理。Flink在流处理引擎之上构建批处理,并赋予其本地迭代支持、托管内存和程序优化。

传统大数据处理架构是基于批处理模式的,从最早的MapReduce到Spark都是批处理优先。当时的业务对数据处理的时效性要求不高,通常只要T+1天出结果就行,因此这些计算框架在当时能够满足业务的需要。然而时至今日,业务的发展需要能够快速响应市场的变化,因而对数据处理的时效性提出了更高的要求,从小时到分再到秒级。有道是“数据从产生的那一刻起其价值就在不断降低”。本着这一原则,Apache Flink问世了,它号称是第四代大数据处理框架,最显著的特点是流式优先,即Stream First,它视批量数据为一种有界的流式数据,统一了流式数据和批量数据处理。它与之前流行的Spark Streaming最大的不同是如何看待流式数据,在Spark Streaming中实际采用的是微批(micro-batching)的方式来处理流式数据的,也即将流式数据按时间切片成一段段有界的批量数据进行处理,这种方式最多只能做到秒级准实时响应,也能够满足一些业务场景的需要(如对响应时延要求不高的无状态处理和基于固定时间窗口的有状态处理),但不是全部,像微秒级实时响应和基于个数窗口的有状态处理就不适合用Spark Streaming。

它的架构跟Spark有点类似,也有自己的图处理及机器学习库,Flink的Table API对应于Spark SQL,区别是Flink还支持基于流的SQL查询,这大大方便了流式处理。

目前因内有多家公司在使用Apache Flink做实时数据处理,最有名的要算阿里巴巴的Blink(基于Apache Flink)应用。Apache Flink被广泛应用于实时异常检测,实时数据大屏,用户行为分析,实时风控等领域。



二、安装和运行你的第一个Flink Job

目前最新发行的Apache Flink版本是1.4.0,读者可以自行到Apache Flink官网下载安装包。地址是  https://flink.apache.org/downloads.html  下载完了解压到目标目录即可。设置一下环境变量FLINK_HOME。
接下来以本地模式启动Apache Flink
./bin/start-local.sh

运行结束后会在后台启动Job Manager,通过访问  http://localhost:8081  访问这个Job Manager Web Interface,上面会展示当前有哪些运行的job,哪些结束的job,还可以将打包好的job jar包上传提交执行等。

大数据领域的Hello World就是Word Count,接下来我们就来试试如何用Apache Flink跑Word Count。
./bin/flink run ./examples/batch/WordCount.jar --input ./README.txt --output /wordcount-result.txt

这里的input参数需要你指定要统计word的文件路径,这里用的是Apache Flink发行版自带的README.txt,output参数指定结果输出的路径。
console输出如下:

Job Manager Web Interface如下:

恭喜您成功运行自己的第一个Apache Flink Job!!


三、创建你的第一个Apache Flink Java项目

下面请跟随我用maven archetype插件创建一个使用Apache Flink的Java项目。

mvn archetype:generate -DarchetypeGroupId=org.apache.flink -DarchetypeArtifactId=flink-quickstart-java -DarchetypeVersion=1.4.0 -DgroupId=flink-gitchat-course -DartifactId=flink-gitchat-course -Dversion=1.0 -Dpackage=com.gitchat.flink

生成的项目结构如下:

├── pom.xml
└── src
   └── main
       ├── java
       │   └── flinkProject
       │       ├── BatchJob.java
       │       ├── SocketTextStreamWordCount.java
       │       ├── StreamingJob.java
       │       └── WordCount.java
       └── resources
           └── log4j.properties

将生成的项目导入到你熟悉的IDE,如Eclipse或者Intellij,里面已经包含了BatchJob和StreamingJob模板以及相对应Job类型的示例,分别是WordCount和SocketTextStreamWordCount,你可以打开浏览以便熟悉一下大致结构,接下来我会一一介绍。

WordCount.java是一个用Flink进行批处理程序,它的流程是:
1. 先初始化一个执行环境实例 ExecutionEnvironment
2. 从输入的字符串数据中获取DataSet,DataSet代表批处理中的数据集
3. 接下来的flatMap会对于数据集中的每一行字符串执行分词处理。
  例如“Introduction to Apache Flink"这一行数据处理完会生成("introduction", 1), ("to", 1),("apache", 1), ("flink", 1)这4个二元组
4. 然后groupBy算子会在这些二元组元素基础上按第一个元素(注意java里面元组第一个元素下标是从0开始,而scala是从1开始)进行聚合,这样具有相同词的二元组会被group在一起。
  例如[("introduction", 1)], [("to", 1), ("to", 1)], [("apache", 1), ("apache", 1), ("apache", 1)] ... 每一组[]称为一组二元组
5. 最后的sum算子会将每组二元组中的元素(单个二元组)按第二个元素相加,因为在第3步中每个二元组的第二个元素均为1,所以相加后的值即每组中包含的元素个数。
  例如 ("introduction", 1), ("to", 2), ("apache", 3) ...
6. 打印出数据集中的元素用print方法

运行结果如下:



SocketTextStreamWordCount.java是一个用Flink进行流处理程序,主要流程和WordCount一样,有三处不同:
第一、在开始的地方要初始化一个“流”执行环境实例 StreamExecutionEnvironment而不是原来的执行环境实例 ExecutionEnvironment
第二、用keyBy而不是groupBy进行分流并行处理
第三、最后要用StreamExecutionEnvironment.execute()方法来触发流处理执行,而批处理不需要这样。

要运行SocketTextStreamWordCount,你得要指定监听socket消息的主机名和端口号,例如 localhost和9999,之后启动一个Socket Client用来发送socket消息,在Linux上可以直接运行 “nc -lk 9999” 即可,输入任意字符串并回车后这一行字符串将被发送到指定socket端口。
运行结果如下:


流处理程序将一直执行下去,除非有外因关闭它。

三、处理批量数据

尽管流处理变得越来越流行,一些任务仍然需要批处理。如果你正打算学习Apache Flink,我的建议是最好从批处理入手,因为它更简单、更易于上手。一旦你掌握了批处理,你就可以继续学习Apache Flink真正闪耀点---流处理

3.1 数据读入
在处理数据之前,我们需要将数据读入Apache Flink。我们可以读入数据的系统包括但不限于本地文件系统、S3、HDFS、HBase、Cassandra等。不管我们从哪里读入数据,Apache Flink都会让我们以统一的方式组装数据,即DataSet数据集。DataSet里面所有元素的类型必须是相同的,使用数据集时你必须指定其类型。例如 DataSet<Integer> numbers = ...

如果要从文件读入数据,可以使用readTextFile方法,它会一行一行地读入文件内容,每一行代表字符串String:
DataSet<String> lines = env.readTextFile("path/to/file.txt");

默认文件协议是本地文件系统,即file://。如果你打算从HDFS上读入数据,需要声明 hdfs:// 协议
env.readCsvFile(" hdfs:///path/to/file.txt ")

Flink同样也支持csv文件,但在这里它不会返回字符串类型的数据集DataSet<String>,它会试图解析每一行并返回一个带元组Tuple类型的数据集:
DataSet<Tuple2<Long, String>> lines = env.readCsvFile("data.csv").types(Long.class, String.class);

元组Tuple是Java 8引入的,代表不可变的数值对,从Tuple0到Tuple25都有定义,后面我们还会用到这些元组类。

type方法声明了csv文件中的列数及相应的类型,因此Flink能够去解析它。

我们也可以创建一些小的数据集以帮助我们做一些小的实验和单元测试:
// 从列表中创建数据集
DataSet<String> letters = env.fromCollection(Arrays.asList("a", "b", "c"));
// 从数组中创建数据集
DataSet<Integer> numbers = env.fromElements(1, 2, 3, 4, 5);

不是所有的Java类型都能够作为数据集元素的类型,有四类类型你可以使用:
1) Java自带的类型和POJO类
2) Flink Tuples和Scala case classes
3) 值类型,它们是特殊的Java基本类型的可变包装类型
4) 实现了Hadoop可写接口的类型

3.2 数据处理
现在到了数据处理的部分了!你将如何实现一个算法来处理你的数据?为此,你可以使用许多组装Java 8 streams操作的算子operator,例如:
1) map      根据用户自定义方法转换数据集中的元素。每一个输入元素恰好被转换成一个输出元素
2) filter   根据用户自定义方法过滤数据集中的元素
3) flatMap  跟map算子类似,区别是允许输出0、1或更多元素
4) groupBy  根据key聚合元素,类似于SQL语句中的groupBy
5) project  选取数据集中指定的列,类似于SQL语句中的select
6) reduce   根据用户自定义方法将数据集中的多个元素合并成一个值

需注意的是这些算子与Java Streams最大的区别是Java 8只能访问本地数据并在内存中操作它们,而Flink却能够在分布式环境的集群中操作数据。

让我们来看一下使用这些算子的一个简单示例。下面的例子非常直接明了。它创建了一个包含数字的数据集,对于每一个数字取平方再过滤掉所有奇数。
// Create a dataset of numbers
DataSet<Integer> numbers = env.fromElements(1, 2, 3, 4, 5, 6, 7);

// Square every number
DataSet<Integer> result = numbers.map(new MapFunction<Integer, Integer>() {
   @Override
   public Integer map(Integer integer) throws Exception {
       return integer * integer;
   }
})
// Leave only even numbers
.filter(new FilterFunction<Integer>() {
   @Override
   public boolean filter(Integer integer) throws Exception {
       return integer % 2 == 0;
   }
});


3.3 数据写回
在我们处理完数据之后有必要将处理结果保存下来。Flink能够将数据存入很多第三方系统,如HDFS、S3、Cassandra等。

例如,要将数据写到文件,我们需要用到DataSet类的writeAsText方法:
DataSet<Integer> ds = ...
ds.writeAsText("path/to/file");

出于调试和测试的目的,Flink也能够将数据写到标准输出:
DataSet<Integer> ds = ...

// Output dataset to the standard output
ds.print();

// Output dataset to the standard err
ds.printToErr()

3.4 更复杂的示例
为了实现一些有意义的算法我们需要首先下载电影数据集(Grouplens movies dataset),它包括了包含有电影名称和电影评分信息的几个csv文件。我们将从中挑选movies.csv文件,它包含了所有电影的列表,看上去像:
movieId,title,genres
1,Toy Story (1995),Adventure|Animation|Children|Comedy|Fantasy
2,Jumanji (1995),Adventure|Children|Fantasy
3,Grumpier Old Men (1995),Comedy|Romance
4,Waiting to Exhale (1995),Comedy|Drama|Romance
5,Father of the Bride Part II (1995),Comedy
6,Heat (1995),Action|Crime|Thriller
7,Sabrina (1995),Comedy|Romance
8,Tom and Huck (1995),Adventure|Children
9,Sudden Death (1995),Action
10,GoldenEye (1995),Action|Adventure|Thriller

文件有三列:
movieId - 电影唯一id
title   - 电影名称
generes - 以"|"分隔的电影分类

现在我们能够在Apache Flink中加载这个csv文件并执行一些有意义的处理。这里我们将从本地文件系统中加载该文件,现实情况下你可能需要从一个分布式文件系统中加载一个更大的数据集,如S3或者HDFS。

在这个示例中让我们来找出所有“动作(Action)”类别的电影。这里是一些代码片断用来实现该功能:
// Load dataset of movies
DataSet<Tuple3<Long, String, String>> lines = env.readCsvFile("movies.csv")
           .ignoreFirstLine()
           .parseQuotedStrings('"')
           .ignoreInvalidLines()
           .types(Long.class, String.class, String.class);


DataSet<Movie> movies = lines.map(new MapFunction<Tuple3<Long,String,String>, Movie>() {
   @Override
   public Movie map(Tuple3<Long, String, String> csvLine) throws Exception {
       String movieName = csvLine.f1;
       String[] genres = csvLine.f2.split("\\|");
       return new Movie(movieName, new HashSet<>(Arrays.asList(genres)));
   }
});
DataSet<Movie> filteredMovies = movies.filter(new FilterFunction<Movie>() {
   @Override
   public boolean filter(Movie movie) throws Exception {
       return movie.getGenres().contains("Action");
   }
});

filteredMovies.writeAsText("output.txt");

我们来解析一下代码。首先我们用readCsvFile读入一个csv文件:
DataSet<Tuple3<Long, String, String>> lines = env.readCsvFile("movies.csv")
   // ignore CSV header
   .ignoreFirstLine()
   // Set strings quotes character
   .parseQuotedStrings('"')
   // Ignore invalid lines in the CSV file
   .ignoreInvalidLines()
   // Specify types of columns in the CSV file
   .types(Long.class, String.class, String.class);

使用helper方法,我们声明了如何解析csv里面的字符串,并且我们需要过滤掉第一行标题行。在最后一行我们声明了csv文件中每一列的类型,这样Flink会自动帮我们解析。

当我们在Flink中加载好数据之后我们就能够做一些数据处理。首先,我们用map方法对每一个电影解析出类别(genres)列表:
DataSet<Movie> movies = lines.map(new MapFunction<Tuple3<Long,String,String>, Movie>() {
   @Override
   public Movie map(Tuple3<Long, String, String> csvLine) throws Exception {
       String movieName = csvLine.f1;
        String[] genres = csvLine.f2.split("\\|");
        return new Movie(movieName, new HashSet<>(Arrays.asList(genres)));
   }
});

为了转换每一个movie,我们需要实现一个MapFunction,它能够接收每条以Tuple3代表的csv记录并将其转换成为Movie POJO类:
class Movie {
   private String name;
   private Set<String> genres;

   public Movie(String name, Set<String> genres) {
       this.name = name;
       this.genres = genres;
   }

   public String getName() {
       return name;
   }

   public Set<String> getGenres() {
       return genres;
   }
}

回忆一下刚才给到的csv文件结构,第二列包含电影名称,第三列包含类别的列表。因此我们需要使用f1和f2来访问这些相应的列。
现在我们有了movie数据集,我们能够实现算法的核心部分并找到所有的“动作”片。

DataSet<Movie> filteredMovies = movies.filter(new FilterFunction<Movie>() {
   @Override
   public boolean filter(Movie movie) throws Exception {
       return movie.getGenres().contains("Action");
   }
});

这将只返回类别含“动作”的电影。

最后一步非常直接明了,我们将结果数据存到一个文件:
filteredMovies.writeAsText("output.txt");

这只是简单地将结果数据存进本地文本文件,但是像readTextFile方法一样,我们能够通过声明文件协议像 hdfs://那样将该文件存进HDFS和S3


四、处理流式数据

如果你已经学会了如何用Apache Flink处理批量数据,那么流式数据处理对你来说并不难。首先来看一下数据处理中的三个阶段:数据摄入、处理数据和将结果数据输出到外部系统。

与批处理相比较,流处理有几个显著不同点:首先,在批处理中所有的数据是提前准备好的。当我们的处理程序运行过程中即使有新数据进来我们也不会处理它们。
然而流处理却不是这样。我们读取刚刚生成的数据并且这些数据形成的数据流很可能是源源不断的。

在流处理模式中,Apache Flink能够从不同的系统中读取或写入数据流,这些系统包括Apache Kafka, Rabbit MQ和一些能够产生并消费持续数据流的系统。注意到我们同样能够从HDFS和S3系统中读取数据。在这种情况下,Apache Flink会持续监视目录,一旦有新文件到达就会处理它们。

下面一段代码展示我们如何以流模式从文件中读取数据:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<String> stream = env.readTextFile("file/path");

注意使用流处理时我们需要使用StreamExecutionEnvironment类而不是ExecutionEnvironment。上述方法返回的是DataStream类的一个实例,接下来我们会用它来处理数据。

我们也能够像批处理那样从集合或数组中创建有限流,例如:
DataStream<Integer> numbers = env.fromCollection(Arrays.asList(1, 2, 3, 4, 5 6);
DataStream<Integer> numbers = env.fromElements(1, 2, 3, 4, 5);

简单数据处理

为了处理流数据Apache Flink也提供了批处理中类似的算子,像:map, filter和mapReduce。
我们来实现第一个流式示例。在这个示例中我们将读取维基更新的事件流,过滤并展示我们感兴趣的事件。

首先,我们需要使用WikipediaEditsSource类来读取更新事件流,该类是Flink库自带的Source。
DataStream<WikipediaEditEvent> edits = env.addSource(new WikipediaEditsSource());

需要在env对象上调用addSource方法,该方法可用来从多个源读取数据,例如Kafka, Kinesis, RabbitMQ等。这个方法返回我们将要能够处理的更新事件流。

对于该更新事件流我们只保留不是机器更新并且更新量大于1000 bytes的事件:
edits.filter((FilterFunction<WikipediaEditEvent>) edit -> {
   return !edit.isBotEdit() && edit.getByteDiff() > 1000;
}).print();

是不是跟批处理一节中过滤方法使用类似?唯一的区别是现在它能够处理无限流。

最后一步是运行我们的程序。我们需要调用execute方法进行触发。
env.execute()

这个程序会运行打印我们过滤好的更新事件流,直到我们停止它为止。
2> WikipediaEditEvent{timestamp=1506499898043, channel='#en.wikipedia', title='17 FIBA Womens Melanesia Basketball Cup', diffUrl=' https://en.wikipedia.org/w/index.php?diff=802608251&oldid=802520770', user='Malto15', byteDiff=1853, summary='/* Preliminary round */ ', flags=0}
7> WikipediaEditEvent{timestamp=1506499911216, channel='#en.wikipedia', title='User:MusikBot/StaleDrafts/Report', diffUrl=' https://en.wikipedia.org/w/index.php?diff=802608262&oldid=802459885', user='MusikBot', byteDiff=11674, summary='Reporting 142 stale non-AfC drafts ', flags=0}
...

注意到开头数字+>号标记,它表示的是第几个task的输出,默认Flink会开8个tasks并行处理,你可以通过setMaxParallelism方法设置并行度。



流窗口 (Stream Window)


注意到到目前为止我们讨论的方法都是基于流中单个元素的。使用这些简单算子并不能实现一些有趣的流算法,例如:
* 按每分钟统计更新的次数
* 按每人每十分钟统计更新的次数

很显然要回答这些问题我们需要处理多组元素。而这就是流窗口存在的意义。

概述地讲流窗口让我们能够组织流中的元素并在每一组上执行一个用户自定义方法。这个用户自定义方法能够返回0、1或更多元素。这样一来就产生了一个新的流,我们就能够在一个单独的系统中处理或存储它。



那我们如何能够组织流中的元素呢?Flink为我们提供了几个选项:

* Tumbling Window (翻滚窗口) - 在流中创建无叠加相邻窗口。我们可以按时间(比如说所有元素从10:00到10:05进到同一个组)或者按个数(前50个元素进同一个组)。例如,我们可以用它来回答这样的问题:统计流中每5分钟不重复间隔内的元素个数。
* Sliding Window (滑动窗口) - 与翻滚窗口相似,但是窗口可以有叠加。例如,我们可以用它来每分钟展示过去5分钟的度量。
* Session Window (会话窗口) - 在这个窗口下,Flink会将时间相近的元素组织进同一个组。
* Global Window (全局窗口) - 在这个窗口下,Flink会将所有元素放进同一个组。这只在我们定义了一个自定义触发器用来定义什么时候窗口结束的时候才有作用。



除了选择如何指派元素进不同窗口外,我们需要选择流类型(stream type),Flink提供了两种窗口类型:

* Keyed stream (带键的流) - 用这种流类型后Flink会根据key将一个单一流分成多个独立的流(例如,维基更新的用户姓名)。当我们处理带键流的窗口时,我们只定义如何访问这些相同key的元素,但在实际运行过程中Flink会并行处理这些独立流。

* Non-keyed stream (不带键的流) - 用这种流类型后流中所有的元素将会被一起处理,我们自定义的方法会处理流中所有的元素。这种流类型的缺点是没有并行处理,集群中只能有一台机器被用来执行我们的代码。



现在我们来用流窗口来实现一些示例。首先让我们来看看每分钟会有多少维基被更新。我们需要先读入一个更新事件流:
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<WikipediaEditEvent> edits = env.addSource(new WikipediaEditsSource());

接下来我们要声明将该流按一分钟切分成不会叠加的窗口:
edits
   // Non-overlapping one-minute windows
   .timeWindowAll(Time.minutes(1))

现在我们可以定义自定义方法来处理每分钟窗口内所有的元素。为此,我们传入AllWindowFunction类的实现并使用apply方法。
edits
   .timeWindowAll(Time.minutes(1))
   .apply(new AllWindowFunction<WikipediaEditEvent, Tuple3<Date, Long, Long>, TimeWindow>() {
       @Override
       public void apply(TimeWindow timeWindow, Iterable<WikipediaEditEvent> iterable, Collector<Tuple3<Date, Long, Long>> collector) throws Exception {
           long count = 0;
           long bytesChanged = 0;

           // Count number of edits
           for (WikipediaEditEvent event : iterable) {
               count++;
               bytesChanged += event.getByteDiff();
           }

           // Output a number of edits and window's end time
           collector.collect(new Tuple3<>(new Date(timeWindow.getEnd()), count, bytesChanged));
       }
   })
   .print();  

尽管代码有点啰嗦,但意思很直白,apply方法接收三个参数:
* timeWindow - 包含了我们要处理的窗口信息,例如窗口起始时间、终止时间等
* iterable - 单个窗口中元素的迭代器
* collector - 我们可以用来输出元素到结果流的对象

这里我们所做的一切就是统计更新的次数并用collector实例将我们计算的结果连同时间窗口的结束时间一块输出。

如果我们运行该程序,你会发现通过apply方法生成的元素打印到输出流上的结果是:
1> (Wed Sep 27 12:58:00 IST 2017,62,62016)
2> (Wed Sep 27 12:59:00 IST 2017,82,12812)
3> (Wed Sep 27 13:00:00 IST 2017,89,45532)
4> (Wed Sep 27 13:01:00 IST 2017,79,11128)
5> (Wed Sep 27 13:02:00 IST 2017,82,26582)


带键的流示例

现在我们来看一个更复杂的示例。我们想统计一下每10分钟一个用户更新的次数。这个结果能够帮忙识别最活跃的用户或者找到系统中一些不正常的活动。
当然我们也可以使用不带键的流,遍历窗口中的所有元素并维护一个统计次数的字典,但是这种方式不能扩展,因为不带键的流不具备并行处理能力。为了有效利用Flink集群的资源,我们需要按照用户姓名来键化流,这将产生多个逻辑流:每个用户一个流。

DataStream<WikipediaEditEvent> edits = env.addSource(new WikipediaEditsSource());

edits
   // Key by user name
   .keyBy((KeySelector<WikipediaEditEvent, String>) WikipediaEditEvent::getUser)
   // Ten-minute non-overlapping windows
   .timeWindow(Time.minutes(10))

唯一的区别是我们使用了keyBy方法为我们的流声明了键。在这里我们简单的使用了用户姓名作为键。

既然我们有了带键的流,我们就可以在处理过程中为每一个窗口上应用函数。如之前我们使用apply方法那样:
edits
   .keyBy((KeySelector<WikipediaEditEvent, String>) WikipediaEditEvent::getUser)
   .timeWindow(Time.minutes(10))
   .apply(new WindowFunction<WikipediaEditEvent, Tuple2<String, Long>, String, TimeWindow>() {
       @Override
       public void apply(String userName, TimeWindow timeWindow, Iterable<WikipediaEditEvent> iterable, Collector<Tuple2<String, Long>> collector) throws Exception {
           long changesCount = 0;

           // Count number of changes
           for (WikipediaEditEvent ignored : iterable) {
               changesCount++;
           }
           // Output user name and number of changes
           collector.collect(new Tuple2<>(userName, changesCount));
       }
   })
   .print();

唯一显著的区别是这里的apply方法接收4个参数。新增的那第一个参数用来声明我们函数要处理的逻辑流的键。

如果我们执行这个应用程序我们会得到一个流,流中的每一个元素包含用户姓名和该用户过去10分钟更新维基的次数。

...
5> (InternetArchiveBot,6)
1> (Francis Schonken,1)
6> (.30.124.210,1)
1> (MShabazz,1)
5> (Materialscientist,18)
1> (Aquaelfin,1)
6> (Cote d'Azur,2)
1> (Daniel Cavallari,3)
5> (00:1:F159:6D32:2578:A6F7:AB88:C8D,2)
...

你会发现今天有一些用户特别乐衷于更新维基。
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值