Flume框架讲解、应用案例——日志采集


环境基础

本文采用三台机器:master(主节点)、slave1和slave2(从节点)
必须有Hadoop集群的基础环境


Flume基础架构

Flume基础架构图(简单结构):
在这里插入图片描述


Agent(JVM进程)

Agent是一个JVM进程,它以事件的形式将数据从源头送至目的地。
Agent主要有3个核心组件组成:Source、Channel、Sink


Source(数据采集器)

用于源数据的采集,然后将采集到的数据写入到Channel中并且流向Sink。Source是负责接收数据到Agentd的组件。

Source 组件可以处理各种类型、各种格式的日志数据,包括 avro、thrift、exec、jms、spooling directory、netcat、sequence、generator、syslog、http、legacy。

有时候需要采集数据的数据源分布在不同的服务器上,使用一个Agent进行数据采集不再适用,这时就可以根据业务需求部署多个Agent进行数据采集并最终存储。下图是Flume的复杂框架图(复杂结构):
在这里插入图片描述
除此之外,在开发中还有可能遇到从同一个服务段采集数据,然后通过多路复用流分别传输并且存储到不同的目的地的情况,如下图形式:
在这里插入图片描述


Channel(缓冲通道)

底层是一个缓存队列,对Source中的数据j进行缓存,将数据高效、准确的写入Sink,待数据全部到达Sink后,Flume就会删除该缓存通道中的数据。

Channel是位于Source和Sink之间的缓冲区。因此,Channel允许Source和Sink运作在不同的速率上。Channel是线程安全的,可以同时处理几个Source的写入操作和几个Sink的读取操作。

Flume自带俩种Channel:Memory ChannelFile Channel以及Kafka Channel


Sink(接收器)

接受并且汇集流向Sink的所有数据,根据需求,可以直接进行集中式存储,也可以继续作为数据源传入其它远程服务器或者Source中。

Sink不断地轮询Channel中的事件,且批量的移除它们,并且将这些事件写入到存储或者索引系统、亦或者被发送到另一个Flume Agent当中。


Event(事件)

在整个数据传输的过程中,Flume将流动的数据封装到一个event(事件)当中,它是Flume内部数据传输的基本单元。

传输单元,Flume数据传输的基本单元。以Event的形式将数据从源头送至目的地。Event由HeaderBody两部分组成。

Header用来存放该event的一些属性,一些表示信息,格式为K-V结构,Body用来存放该条数据,就是Flume收集到的数据信息,形式为字节数组。

Header(k=v)
Body(byte array)


Flume的可靠性保证

在整个Flume的工作流程当中,如果只使用一个接收器Sink的话,容易出现问题,如果Sink出现故障或者需要接受的数据量特别大的时候,这时候单一的Sink配置可能就无法保证Flume开发的可靠性。 为此,Flume提供了Flume Sink Processors(Flume Sink 处理器)来解决以上问题。

Sink处理器允许开发者定义一个Sink groups(接收器组),将多个Sink分组到一个实体中,这样Sing处理器就可以通过组内多个Sink为服务提供负载均衡功能,或者是在某个Sink出现短暂故障的时候实现从一个Sink到另一个Sink的故障转移。


负载均衡(负载均衡接受器处理器)Load balancing sink processor

工作原理

负载均衡接受器处理器(Load balancing sink processor)提供了在多个Sink上进行负载均衡流量的功能,它维护了一个活跃的Sink索引列表,必须在其上分配负载。Load balancing sink processor支持使用round_robin(轮询)random(随机)选择机制进行流量分配,其默认选择机制为round_robin,但是可以通过配置进行覆盖,还支持继承AbstractSinkSelector的自定义类来自定义选择机制。

在使用时,选择器(selector)会根据配置的选择机制挑选下一个可用的Sink并进行调用。对于round_robin和random俩种选择机制,如果所选Sink无法收集event,则处理器会通过其配置选择机制选择下一个可用的Sink。这种实现方案不会将失败的Sink列入黑名单,而是继续乐观的尝试每个可用的Sink。如果所有的Sink都调用失败,则选择器将故障传播到接收器运行器(sink runner)。

如果启用了backoff属性,则Sink处理器会将失败的Sink列入黑名单。当超时结束时,如果Sink仍然没有相应,则超时会呈指数级别增加,以避免在无响应的Sink上长时间等待时卡住。在禁用backoff功能的情况下,在
round_robin机制下,所有失败的Sink将被传递到Sink队列中的下一个Sink后,因此不再均衡。

Load balancing sink processor提供的配置属性,如下表:

属性名称 默认值 说明
sinks(必须属性) 以空格分隔的参与sink组的sink列表
processor.type(必须属性) default 组件类型名必须是load_balance
processor.backoff false 设置失败的sink进入黑名单
processor.selector round_robin 选择机制。必须是round_robin、random或者是继承自AbstractSinkSelector类的自定义选择机制类全路径名称
processor.selector.maxTimeOut 30000 失败sink放置在黑名单的超时时间,失败sink在指定时间后仍无法启用,则超时时间呈指数增加

上表显示,processor.type属性的默认值为default,这是因为Sink处理器的processor.type提供了3种处理机制:default(默认值)、failover和load_balance。其中,default表示配置单独一个sink,就是最简单的工作流程只用一个sink完成,配置和使用都非常简单,同时也不强求使用sink group进行封装;另外的failover和load_balance就分别代表故障转移和负载均很情况下的配置属性


搭建并且配置Flume机器(负载均衡案例演示)

使用Load balancing sink processor配置一个名称为a1的Agent案例如下

Load balancing sink processor 结构图

在这里插入图片描述


配置Flume采集方案

在master主机的/usr/flume/conf目录下编写exec-avro.conf文件

# 配置 Load balancing sink processor 一级采集方案
a1.sources=r1
# 用空格分隔俩个Sink
a1.sinks=k1 k2
a1.channels=c1
# 描述并且配置sources组件(数据源类型、采集数据源的应用地址)
a1.sources.r1.channels=c1
a1.sources.r1.type=exec
a1.sources.r1.command=tail -F /root/logs/123.log
# 描述并且配置channels
a1.channels.c1.type=memory
a1.channels.c1.capacity=1000
a1.channels.c1.transactionCapacity=100
# 设置sink1,由slave1上的Agent进行采集
a1.sinks.k1.channel=c1
a1.sinks.k1.type=avro
a1.sinks.k1.hostname=slave1
a1.sinks.k1.port=52020
# 设置sink2,由slave2上的Agent进行采集
a1.sinks.k2.channel=c1
a1.sinks.k2.type=avro
a1.sinks.k2.hostname=slave2
a1.sinks.k2.port=52020
# 配置Sink组及处理器策略
a1.sinkgroups=g1
a1.sinkgroups.g1.sinks=k1 k2
a1.sinkgroups.g1.processor.type=load_balance
a1.sinkgroups.g1.processor.backoff=true
a1.sinkgroups.g1.processor.selector=random
a1.sinkgroups.g1.processor.maxTimeOut=10000

在上述配置文件当中,设置了一个名为a1的Agent,在该Agent内部设置了source.type=exec和sources.r1.command=tail -F /root/logs/123.log文件的变化;然后通过sink.type为avro的sink1和sink2的Sink组,使用load_balance处理机制将sink1传送给slave1机器的52020端口,将sink2床送给slave2机器的52020端口进入下一个阶段的Agent的收集处理。

之后在slave1和slave2机器上同样位置的目录下编写各自的avro-logger.conf文件,内容如下:

slave1:

# 配置 Load balancing sink processor 二级采集方案的一个Sink分支
a1.sources=r1
a1.sinks=k1
a1.channels=c1
# 描述并且配置sources组件(数据源类型、采集数据源的应用地址)
a1.sources.r1.type=avro
a1.sources.r1.bind=slave1
a1.sources.r1.port=52020
# 描述并且配置sinks组件(采集后的数据流出的类型)
a1.sinks.k1.type=logger
# 描述并且配置channels
a1.channels.c1.type=memory
a1.channels.c1.capacity=1000
a1.channels.c1.transactionCapacity=100
# 将Source和Sink通过同一个Channel连接绑定
a1.sources.r1.channels=c1
a1.sinks.k1.channel=c1

slave2

# 配置 Load balancing sink processor 二级采集方案的一个Sink分支
a1.sources=r1
a1.sinks=k1
a1.channels=c1
# 描述并且配置sources组件(数据源类型、采集数据源的应用地址)
a1.sources.r1.type=avro
a1.sources.r1.bind=slave2
a1.sources.r1.port=52020
# 描述并且配置sinks组件(采集后的数据流出的类型)
a1.sinks.k1.type=logger
# 描述并且配置channels
a1.channels.c1.type=memory
a1.channels.c1.capacity=1000
a1.channels.c1.transactionCapacity=100
# 将Source和Sink通过同一个Channel连接绑定
a1.sources.r1.channels=c1
a1.sinks.k1.channel=c1

在slave1和slave2的配置文件中,俩个采集方案的唯一区别就是source.bind不同,slave1机器的source.bind=slave1,而slave2中则是source.bind=slave2。在上述俩个文件当中,均设置了一个名为a1的Agent,在该Agent内部设置了source.type=avro、source.bind=slave1/slave2以及source.port=52020,特意用来对接在slave1中前一个Agent收集后得到的Sink的数据类型和配置传输的目标;最后,又设置了二级采集方案的sink.type=logger,将二次采集的数据作为日志收集打印。


Flume启动

首先在slave1和slave2的flume主目录下执行启动命令:

flume-ng agent --conf conf/ --conf-file conf/avro-logger.conf \ --name a1 -Dflume.root.logger=INFO,console

如下图结果显示:
在这里插入图片描述

然后在master的flume的主目录下执行启动命令:

flume-ng agent --conf conf/ --conf-file conf/exec-avro.conf \ --name a1 -Dflume.root.logger=INFO,console

如下图结果显示:
在这里插入图片描述

如果发生报错:在这里插入图片描述
如果发生报错,参考下面文章:

解决Flume启动报错


负载均衡测试

在数据顶级Flume采集节点master上,重新打开一个终端去执行如下命令:

while true; do echo "DongKaizi..." >> /root/logs/123.log ; \ sleep 1;done

解释:

使用一个while循环打印输出一句话DongKaizi...
间隔是1秒
先必须创建/root/logs目录

注意:

上述指令会每间隔1秒向123.log文件中追加内容,从而引起文件的变更,来让Flume采集方案生效。执行完上述命令后,再次查看slave1和slave2控制台,则出现如下内容,会发现俩台机器上的Flume系统几乎是轮流采集并打印出收集得到的数据信息(负载均衡),效果图如下:
在这里插入图片描述
而且会在/root/logs目录下产生一个123.log文件,而且内容都是打印输出的内容,一直追加的结果。
在这里插入图片描述


故障转移(故障转移接收器处理器)Failover Sink Processor

故障转移接收器处理器(Failover Sink Processor)维护一个具有优先级的sink列表,保证在处理event只要有一个可用的sink即可。

工作原理

故障转移机制的工作原理是将故障的sink降级到故障池当中,在池中为它们分配一个冷却期,在重试之前冷却时间会增加,当sink成功发送event后,它将恢复到活跃池中。sink具有与之相关的优先级,数值增大,优先级越高。如果发送event时sink发生故障,则会尝试下一个具有最高优先级的sink来继续发送event。如果未指定优先级,则根据配置文件中指定sink的顺序确度优先级。

Failover Sink Processor提供的配置属性,如下表:

属性名称 默认值 说明
sinks 以空格2分隔的参与sink组的sink列表
processor.type default 组件类型名必须是failover
processor.priority 设置sink的优先级取值
processor.maxpenalty 30000 失败sink的最大退避时间

前三个为必须属性!

使用 Failover Sink Processor 配置一个名称为a1的Agent示例如下:

a1.sinkgroups=g1
a1.sinkgroups.g1.sinks=k1 k2
a1.sinkgroups.g1.processor.type=failover
a1.sinkgroups.g1.processor.priority.k1=5
a1.sinkgroups.g1.processor.priority.k2=10
a1.sinkgroups.g1.processor.maxpenalty=10000

从前面 Failover Sink Processor 的相关讲解和配置示例可以看出,故障转移接收器处理器(Failover Sink Processor)和 负载均衡接收器处理器(Load balancing sink processor)的Flume结构图基本一样。而这俩种处理器的主要区别在于,负载均衡接受器处理器中会让每一个活跃的sink轮循/随机地处理event事件。而故障转移接收器处理器只允许一个活跃的且优先级高的sink来处理event,只有在当前sink故障后才会向下继续选择另一个活跃的且优先级高的sink来处理event。

关于Failover Sink Processor的使用案例可以参考上文当中的负载均衡案例进行配置,在配置过程中只需要注意修改processor.type=failover,同时参考Failover Sink Processor的属性设置即可。



Flume拦截器

Flume Interceptors(拦截器)主要用于实现对Flume系统数据流中event的修改操作。在使用Flume拦截器时,只需要参考官方配置属性在采集方案中选择性的配置即可,当涉及配置多个拦截器时,拦截器名称中间需要用空格分隔,并且拦截器的配置顺序就是拦截顺序。

在 Flume 1.8.0 版本中,Flume提供并且支持的拦截器有很多,并且它们都是 org.apache.flume.interceptor.Interceptor 接口的实现类。如下表一些拦截器:

Timestamp Interceptor Host Interceptor Static Interceptor
Remove Header Interceptor UUID Interceptor Morphline Interceptor
Search and Replace Interceptor Regex Filtering Interceptor Regex Extractor Interceptor

1.Timestamp Interceptor(时间戳拦截器)

Timestamp Interceptor(时间戳拦截器)会将流程执行时间插入到event的header头部,此拦截器插入带有timestamp键(或者由header属性指定键名)的标头,其值为对应的时间戳。如果配置中已经存在时间戳,此拦截器可以保留现有的时间戳。

Timestamp Interceptor 提供的常用配置属性,如下表:

属性名称 默认值 说明
type 组件类型名必须是timestamp
header timestamp 用于放置生成的时间戳的表头的名称
preserveExisting false 如果时间戳已经存在,是否应保留,true或者false

加粗部分是必须属性

为名为a1的Agent中的配置 Timestamp Interceptor 的示例如下:

a1.sources=r1
a1.channels=c1
a1.sources.r1.channels=c1
a1.sources.r1.type=seq
a1.sources.r1.interceptors=i1
a1.sources.r1.interceptors.i1.type=timestamp

2.Static Interceptor(静态拦截器)

Static Interceptor(静态拦截器)允许用户将具有静态值的静态头附加到所有event。当前实现不支持一次指定多个header头,但是用户可以定义多个Static Interceptor 来为每一个拦截器都追加一个header。

Static Interceptor 提供的常用配置属性,如下表:

属性名称 默认值 说明
type 组件类型m名必须是static
preserveExisting true 如果配置的header已经存在,是否应该保留
key key 应创建的header名称
value value 应创建的header对应的j静态值

加粗的属性依旧时必须属性

为名称是a1的Agent中配置 Static Interceptor 的实例如下:

a1.sources=r1
a1.channels=c1
a1.sources.r1.channels=c1
a1.sources.r1.type=seq
a1.sources.r1.interceptors=i1
a1.sources.r1.interceptors.i1.type=static
a1.sources.r1.interceptors.i1.key=datacenter
a1.sources.r1.interceptors.i1.value=BEI_JING

3.Search and Replace Interceptor(查询和替换拦截器)

Search and Replace Interceptor(查询和替换拦截器)基于Java正则表达式提供了简单的用于字符串的搜索和替换功能,同时还具有进行回溯/群捕捉功能。此拦截器的使用与Java Matcher.replaceAll()方法具有相同的规则。

Search and Replace Interceptor 提供的常用配置属性,如下表所示(加粗部分为必须属性):

属性名称 默认值 说明
type 组件类型名必须是 search_replace
searchPattern 要查询或替换的模式
replaceString 替换的字符串
charset UTF-8 event body 的字符集,默认为 UTF-8

为名称为a1的Agent中配置 Search and Replace Interceptor 的示例如下:

a1.sources=r1
a1.channels=c1
a1.sources.r1.channels=c1
a1.sources.r1.type=seq
a1.sources.avroSrc.interceptors=i1
a1.sources.avroSrc.interceptors.i1.type=search_replace
# 删除event body 中的前导字母数字字符
a1.sources.avroSrc.interceptors.i1.searchPattern=^[A-Za-z0-9_]+
a1.sources.avroSrc.interceptors.i1.replaceString=

在实际开发中,如果这些拦截器不能满足Flume系统开发需求,可以通过实现 org.apache.flume.interceptor.Interceptor 接口来自定义Flume拦截器。以下是官方文档:
Flume User Guide



案例——日志采集

上文档中是Flume框架的基本讲解,现在通过一个模拟日志采集的案例来演示Flume的基本使用。

案例分析

假设有一个生产场景,俩台服务器A、B在实时产生数据,日志类型主要为 access.log、nginx.log 和 web.log 。现在需要将A、B俩台服务器产生的日志数据 access.log、nginx.log 和 web.log 采集汇总到 C 服务器上,并且统一收集上传到HDFS上保存,而在HDFS中保存日志数据的文件必须按照以下要求进行归类统计:

  • /source/logs/access/20180723/**
  • /source/logs/nginx/20180723/**
  • /source/logs/web/20180723/**

数字表示日期!

案例分析思路:

  • (1)因为要将采集到的数据进行保存,上传至HDFS,所以此案例可以使用Flume和Hadoop技术相结合来实现。在 A、B 俩台机器上使用 Flume 收集日志数据,然后汇总到另一台机器 C 上,并且最终结合Hadoop集群,将日志数据上传到HDFS上。
  • (2)因为要在HDFS中日志文件按照指定格式归类统计,所以单纯的按照传统的 Flume 数据采集无法得知日志数据类型,所以在这里可以借助于 Flume 提供的拦截器对收集的文件进行标记,这样在后续数据接受上传的时候就可以根据标记进行文件类型的区分了。

流程图

在这里插入图片描述


1.服务器搭建与配置

根据案例需求启动3台服务器,并且同时搭建Flume系统和Hadoop集群。

slave1和slave2分别作为 A、B 服务器进行第一阶段的日志数据采集,将 master 作为C服务器进行日志数据的汇总并且上传至 HDFS 。


2.配置采集方案

slave1、slave2

首先在 slave1 和 slave2 各自机器下/usr/flume/conf 目录下编写同样的日志采集方案文件 exec-avro_logCollection.conf

# 配置 Agent 组件
# 用3个 Source 采集不同的日志类型数据
a1.sources=r1 r2 r3
a1.sinks=k1
a1.channels=c1
# 描述并且配置第二个 sources 组件(包括自带的静态拦截器)
a1.sources.r1.type=exec
a1.sources.r1.command=tail -F /root/logs/access.log
a1.sources.r1.interceptors=i1
a1.sources.r1.interceptors.i1.type=static
a1.sources.r1.interceptors.i1.key=type
a1.sources.r1.interceptors.i1.value=access
# 描述并且配置第二个 sources 组件(包括自带的静态拦截器)
a1.sources.r2.type=exec
a1.sources.r2.command=tail -F /root/logs/nginx.log
a1.sources.r2.interceptors=i2
a1.sources.r2.interceptors.i2.type=static
a1.sources.r2.interceptors.i2.key=type
a1.sources.r2.interceptors.i2.value=nginx
# 描述并且配置第三个 sources 组件(包括自带的拦截器)
a1.sources.r3.type=exec
a1.sources.r3.command=tail -F /root/logs/web.log
a1.sources.r3.interceptors=i3
a1.sources.r3.interceptors.i3.type=static
a1.sources.r3.interceptors.i3.key=type
a1.sources.r3.interceptors.i3.value=web
# 描述并且配置 Channel
a1.channels.c1.type=memory
a1.channels.c1.capacity=2000000
a1.channels.c1.transactionCapacity=100000
# 描述并且配置 Sink
a1.sinks.k1.type=avro
a1.sinks.k1.hostname=master
a1.sinks.k1.port=41414
# 将 Source、Sink 和 Channel 进行关联绑定
a1.sources.r1.channels=c1
a1.sources.r2.channels=c1
a1.sources.r3.channels=c1
a1.sinks.k1.channel=c1

内容讲解:
设置了一个名为a1的Agent,在该Agent内部设置了3个Source来采集不同类型的日志数据;然后针对3个Source进行分别配置,在配置Source时,通过source.command = tail -F /root/logs/xxx.log 用来监控收集某个日志文件的变化,同时还配置了Flume静态拦截器,用来向 event header 中添加静态值 xxx(日志文件类型,如 type:web);最后,将收集到的数据以 avro sink 形式传输给 slave1机器的41414端口应用,让下一阶段Agent的再次收集处理。

master

在master机器的 /usr/flume/conf 目录下编写第二级日志采集方案 avro-hdfs_logCollection.conf ,如下:

# 配置 Agent 组件
a1.sources=r1
a1.sinks=k1
a1.channels=c1
# 描述并且配置 sources 组件
a1.sources.r1.type=avro
a1.sources.r1.bind=master
a1.sources.r1.port=41414
# 描述并且配置时间拦截器,用域后续 %Y%m%d 获取时间
a1.sources.r1.interceptors=i1
a1.sources.r1.interceptors.i1.type=timestamp
# 描述并且配置 Channel
a1.channels.c1.type=memory
a1.channels.c1.capacity=20000
a1.channels.c1.transactionCapacity=10000
# 描述并且配置 Sink
a1.sinks.k1.type=hdfs
a1.sinks.k1.hdfs.path=hdfs://master:9000/source/logs/%{type}/%Y%m%d
a1.sinks.k1.hdfs.filePrefix=events
a1.sinks.k1.hdfs.fileType=DataStream
a1.sinks.k1.hdfs.writeFormat=Text
# 生成的文件不按条数生成
a1.sinks.k1.hdfs.rollCount=0
# 生成的文件不按时间生成
a1.sinks.k1.hdfs.rollInterval=0
# 生成的文件按大小生成
a1.sinks.k1.hdfs.rollSize=10485760
# 批量写入 HDFS 的个数
a1.sinks.k1.hdfs.batchSize=20
# Flume 操作 HDFS 的线程数(包括新建、写入等)
a1.sinks.k1.hdfs.threadsPoolSize=10
# 操作 HDFS 的超时时间
a1.sinks.k1.hdfs.callTimeout=30000
# 将 Source、Sink 与 Channel 进行关联绑定
a1.sources.r1.channels=c1
a1.sinks.k1.channel=c1

内容解释:
上述master中的配置文件的内容,为名为a1的Agent配置了一个HDFS sink 用于将采集的数据上传到HDFS中。其中“hdfs://master:9000/source/logs/%{type}%/%Y%m%d”用于指定采集数据的上传路径,%{type} 可以用于获取之前使用拦截器在 event header 中设置的 key值,“%Y%m%d”用于获取系统当前日期,为了保证 “%Y%m%d” 可以正常获取日期,在该 Agent 中配置了 Timestamp 拦截器。

关于文件中 HDFS sink 的其他相关配置,可以查看官方文档。

以下是官方文档:
Flume User Guide

提示:在编写 Flume 日志采集方案时,要根据实际开发需求选择合适的 channel 类型,并且配置合理的内存、事务等容量大小,然后不断地进行测试调优,否则在启动 Flume 进行日志采集时会感觉效率低下,甚至是会出现系统内存异常等问题。


3.启动日志采集系统

首先启动Hadoop集群,然后在master的Flume解压目录下执行如下命令:

flume-ng agent -c conf/ -f conf/avro-hdfs_logCollection.conf \ --name a1 -Dflume.root.logger=INFO,console

结果如下:
在这里插入图片描述

然后分别在slave1、slave2的Flume解压目录下执行如下命令:

flume-ng agent -c conf/ -f conf/exec-avro_logCollection.conf \ --name a1 -Dflume.root.logger=INFO,console

结果如下:
在这里插入图片描述
没问题!

注意:如果启动后,主节点的Flume和俩个子节点的Flume没有连接成功,则应该去检查配置信息,中的主节点的 source 和 子节点的 sink 。必须保证每个 Agent 之间的通信成功


4.日志采集系统测试

为了演示日志采集案例的实验效果,我个人用Xshell远程连接,然后slave1和slave2都分别在开启三个终端窗口。
在这里插入图片描述

接下来通过执行命令来模拟日志数据的产生:

while true; do echo "access access ..." >> /root/logs/access.log ; \ sleep 1;done
while true; do echo "nginx nginx ..." >> /root/logs/nginx.log ; \ sleep 1;done
while true; do echo "web web ..." >> /root/logs/web.log ; \ sleep 1;done

注意:如果不存在 /root/logs 目录就要提前创建,否则会报错。

执行后master控制台出现如下内容
在这里插入图片描述

然后在HDFS Web页面出现一个新目录,如下:
在这里插入图片描述
在这里插入图片描述
至此,测试成功!!!

猜你喜欢

转载自blog.csdn.net/shuyv/article/details/113618743
今日推荐