【Java】【Flume】Flume-NG源代码分析的启动过程(两)
本节分析配置文件的解析,即PollingPropertiesFileConfigurationProvider.FileWatcherRunnable.run中的eventBus.post(getConfiguration())。分析getConfiguration()方法。
此方法在AbstractConfigurationProvider类中实现了,而且这个类也初始化了三大组件的工厂类:this.sourceFactory = new DefaultSourceFactory();this.sinkFactory
= new DefaultSinkFactory();this.channelFactory = new DefaultChannelFactory()。
getConfiguration()的详细代码例如以下:
public MaterializedConfiguration getConfiguration() {
MaterializedConfiguration conf = new SimpleMaterializedConfiguration();//三大组件
//载入配置文件,PropertiesFileConfigurationProvider中,解析配置文件,得出代理名字,sources...,各个配置属性和值
FlumeConfiguration fconfig = getFlumeConfiguration();
AgentConfiguration agentConf = fconfig.getConfigurationFor(getAgentName());//配置文件
if (agentConf != null) {
Map<String, ChannelComponent> channelComponentMap = Maps.newHashMap();
Map<String, SourceRunner> sourceRunnerMap = Maps.newHashMap();
Map<String, SinkRunner> sinkRunnerMap = Maps.newHashMap();
try {
loadChannels(agentConf, channelComponentMap);
loadSources(agentConf, channelComponentMap, sourceRunnerMap);
loadSinks(agentConf, channelComponentMap, sinkRunnerMap);
Set<String> channelNames =
new HashSet<String>(channelComponentMap.keySet());
for(String channelName : channelNames) {
ChannelComponent channelComponent = channelComponentMap.
get(channelName);
if(channelComponent.components.isEmpty()) {
LOGGER.warn(String.format("Channel %s has no components connected" +
" and has been removed.", channelName));
channelComponentMap.remove(channelName);
Map<String, Channel> nameChannelMap = channelCache.
get(channelComponent.channel.getClass());
if(nameChannelMap != null) {
nameChannelMap.remove(channelName);
}
} else {
LOGGER.info(String.format("Channel %s connected to %s",
channelName, channelComponent.components.toString()));
conf.addChannel(channelName, channelComponent.channel);
}
}
for(Map.Entry<String, SourceRunner> entry : sourceRunnerMap.entrySet()) {
conf.addSourceRunner(entry.getKey(), entry.getValue());
}
for(Map.Entry<String, SinkRunner> entry : sinkRunnerMap.entrySet()) {
conf.addSinkRunner(entry.getKey(), entry.getValue());
}
} catch (InstantiationException ex) {
LOGGER.error("Failed to instantiate component", ex);
} finally {
channelComponentMap.clear();
sourceRunnerMap.clear();
sinkRunnerMap.clear();
}
} else {
LOGGER.warn("No configuration found for this host:{}", getAgentName());
}
return conf;
}
1、SimpleMaterializedConfiguration对象构造了三大组件
public SimpleMaterializedConfiguration() {
channels = new HashMap<String, Channel>();
sourceRunners = new HashMap<String, SourceRunner>();
sinkRunners = new HashMap<String, SinkRunner>();
}
2、getFlumeConfiguration()方法是在AbstractConfigurationProvider的子类PropertiesFileConfigurationProvider中实现了。这种方法读取配置文件,然后解析成name(输姓名全称,即等号左側的所有)、value(等号的右側)对,存入一个Map其中。返回一个封装了这个Map的FlumeConfiguration对象。
FlumeConfiguration类的构造函数会遍历这个Map的所有<name,value>对,调用addRawProperty(String
name, String value)处理<name,value>对。假设为false就忽略了。遍历完后调用validateConfiguration()来验证和删除配置不当组件。
一、addRawProperty方法会先做一些合法性检查。首次启动Flume会构造一个AgentConfiguration对象aconf。然后agentConfigMap.put(agentName, aconf),以后动态载入配置文件时仅仅须要AgentConfiguration aconf = agentConfigMap.get(agentName)就能够得到,然后调用aconf.addProperty(configKey, value)处理,configKey是配置文件里等号左側去掉agent名字和点之后的内容。agentConfigMap是封装了该agent和其全部组件的配置信息的一个Map。
(1)addProperty(configKey, value)方法首先会依次推断是否是sources、sinks、channels、sinkgroups四大组件的总配,不同意反复,将相应的value赋值给这四个String类型的对象。
比如:
caiji-agent.sources = log-source
caiji-agent.sinks = avro-sink16 avro-sink15
caiji-agent.channels = mem-channel
caiji-agent.sinkgroups = sg
ps:以上是举例,到这一步时事实上已经没了"caiji-agent."这个字段了。举一个详细的匹配样例。其它3个和这个同样仅仅是匹配内容不同而已,代码例如以下:
if (key.equals(BasicConfigurationConstants.CONFIG_SOURCES)) { //等于sources,在此是配置组件名称时
if (sources == null) {
sources = value;
return true;
} else { //反复指定source
logger
.warn("Duplicate source list specified for agent: " + agentName);
errorList.add(new FlumeConfigurationError(agentName,
BasicConfigurationConstants.CONFIG_SOURCES,
FlumeConfigurationErrorType.DUPLICATE_PROPERTY,
ErrorOrWarning.ERROR));
return false;
}
}
BasicConfigurationConstants.CONFIG_SOURCES的值是sources。
(2)、addProperty(configKey, value)中假设configKey參数均不是上述四大组件总配,则是详细的单个组件的详细參数配置。
则调用parseConfigKey(String key, String prefix)方法来推断解析sources、sinks、channels、sinkgroups的详细组件,还是举一个代码样例。其它3个和此同样:
ComponentNameAndConfigKey cnck = parseConfigKey(key,
BasicConfigurationConstants.CONFIG_SOURCES_PREFIX); if (cnck != null) {
// it is a source
String name = cnck.getComponentName();
Context srcConf = sourceContextMap.get(name); //这个map,key是组件名字,value是其相应的配置属性context if (srcConf == null) {
srcConf = new Context();
sourceContextMap.put(name, srcConf);//j将组件放入map
} srcConf.put(cnck.getConfigKey(), value); //将属性名和相应的值放入context
return true;
}
BasicConfigurationConstants.CONFIG_SOURCES_PREFIX的值是"sources.",注意这个带点。另外,这里还会构造相应四大组件的四个存储配置信息的Map<String, Context>
:sourceContextMap、channelContextMap、sinkContextMap和sinkGroupContextMap,这四个Map分别存储相应的总配信息中指定个数的组件的相应配置信息,比方上述总配信息中,sourceContextMap、channelContextMap和sinkGroupContextMap依次存储着<log-source。log-source的配置信息>、<mem-channel,mem-channel的配置信息>、<sg,sg的配置信息>以及sinkContextMap的<avro-sink16。avro-sink16的配置信息>、<avro-sink15,avro-sink15的配置信息>。
parseConfigKey(String key, String prefix)中的prefix用来鉴别是什么类型的组件。
这种方法返回一个封装了解析后的(name是组件名称, configKey相应的组件属性名称)的对象。
二、validateConfiguration()来验证和删除配置不当组件。
此方法会遍历agentConfigMap中的每一个agent,推断agent相应的配置文件是否合法aconf.isValid(),不合法就从agentConfigMap中删除这个agent。
aconf.isValid()是AgentConfiguration.isValid()方法。
(1)、会先推断channels是否为空,不为空的话,就推断channels组件集channelSet是否合法channelSet = validateChannels(channelSet)。validateChannels就是遍历全部的channel:先推断是否有相应的配置信息context,没有的话就删除组件;有相应配置信息的话,再推断是否是flume内置的type类型,还是自己定义的,假设是自己定义的还要推断是否有外部的config配置类,假设没有有配置config參数指定外部配置类,则自己定义的type会自己主动设置为OTHER。否则config设置为指定的外部配置类。
构造ChannelConfiguration对象conf(这个类继承自ComponentConfiguration)=ComponentConfigurationFactory.create(channelName, config, ComponentType.CHANNEL)。当中config指的是配置类,假设配置了就会依据配置类进行初始化返回一个配置类对象。此时不会设置conf.isNotFoundConfigClass();假设没有配置config參数。默认的类型及自己定义的类型都会爆异常。在异常处理时,则返回的都是一个instance
= ChannelConfiguration(name)对象且内置类型会instance.setNotFoundConfigClass()。由于内置的channel也没实现配置类,自己定义的类型不会设置conf.isNotFoundConfigClass()。
然后conf.configure(channelContext)运行配置,在这说明内置的channel通过封装使得Context配置信息变成ComponentConfiguration配置信息;假设是自己定义的类型且有外部配置类即有config參数时,会将这个channel对象及配置信息放入channelConfigMap中。否则,没有config參数的channel对象,包含内置的以及自己定义没有外围配置类config的,存放在newContextMap中。然后是重置channelContextMap =
newContextMap,重置的目的是为了以后分类对内置和自己定义的channel分别做处理,这在后面要讲的loadChannels时会用到。
然后返回的内容是channelContextMap 和channelConfigMap中的key值的综合与channelSet的交集部分。
validateChannels(channelSet)方法会终于返回一个两个set(一个是从总配中解析出来的。另外一个是从channelContextMap中解析出来的,后者相应配置文件的实际配置信息。由于存在:1、在总配中声明组件但在详细配置时没有配置。2、没有在总配中声明的组件,但在详细配置时有详细配置信息)的交集。
(2)、validateSources(channelSet)和(1)的大体思路是一样的。仅仅只是须要获取在总配中的sources和每一个source的channels(在这可能指定多个channel)的交集,并将交集又一次配置:srcContext.put(BasicConfigurationConstants.CONFIG_CHANNELS,this.getSpaceDelimitedList(channels));//将set转化为String
另外在srcConf.configure(srcContext)中除了获取该source的channels之外,还会对selector(默认是REPLICATING)进行配置。须要时再讲。channelSet(通过检查合格的channel。活动的channel)作为參数传进来的目的是要配置文件里source相应的channels取交集。除去在配置文件里无效的channel。
方法的返回值的思路參考(1)。 值得注意的是,从代码能够看出每一个agent能够配置多个source。证据在此:Set<String> sourceSet =new HashSet<String>(Arrays.asList(sources.split("\\s+")))。sourceSet的组成也分两部分:一部分是有指定config外部配置的sourceConfigMap仅仅可能是自己定义的组件非内置的,还有一部分是没有指定參数config的source,后者可能包含自己定义的以及内置的。
(3)、validateSinks(channelSet)和(2)基本类似,仅仅只是每一个sink仅仅能配一个channel,不像source能够有多个”爱妾“,全部在这仅仅须要推断sink的channel是否包括在channelSet之中:channelSet.contains(sinkConf.getChannel())。不包括就异常退出。
其它可參考(1)的思路。
返回的sinkSet也分为两部分可參考(2)中返回的sourceSet的两部分组成。
(4)、validateGroups(sinkSet)遍历全部sinkgroups。获取合法的sink,并将正确的sinkgroupConfigMap.put(sinkgroupName, conf)。
conf =(SinkGroupConfiguration) ComponentConfigurationFactory.create(sinkgroupName, "sinkgroup", ComponentType.SINKGROUP)已不像上述三种组件有外围的配置类。sinkgroup没有自己定义的功能。也就没有指定外围配置类的功能。所以是固定的"sinkgroup",该方法返回的是SinkGroupConfiguration(name)。conf.configure(context)会获取配置文件里的sinkgroups,并对processor的配置类进行配置。
validGroupSinks(sinkSet, usedSinks, conf)方法删除不符合条件的:1、和已知的其它sinkgroup有同样的sink;2、使用了非活动的sink。满足这俩种不论什么一个相应的sink将会被删除。解析出来的sinkgroup(有可能为null。可能少一个或者多个。又或者都满足)都同一存入sinkgroupConfigMap,和其它三个组件有所不同。其它三个组件的ConfigMap都是存放自己定义且有外围实现配置类的。
以上4个组件均能够在总配中配置多项,且上述4个方法的返回值均是符合要求的组件,去除了声明可是没配置的以及配置但没声明等组件。
(5)、再推断返回的sink和source是否为空。
(6)、将合法的四个组件均转换为String。getSpaceDelimitedList(Set<String> entries)就是将set转换为String。
this.sources = getSpaceDelimitedList(sourceSet);
this.channels = getSpaceDelimitedList(channelSet);
this.sinks = getSpaceDelimitedList(sinkSet);
this.sinkgroups = getSpaceDelimitedList(sinkgroupSet);
(7)、终于返回true。validateConfiguration()中若返回的是false则删除此agent。
这样就完毕了FlumeConfiguration对象的构造,本文開始的2步骤中getFlumeConfiguration()也得到了。
3、loadChannels(agentConf, channelComponentMap)方法。
该方法首先是会先保存旧的channels。从channelCache复制到channelsNotReused暂存。
然后是获取channelNames=agentConf.getChannelSet()和compMap=agentConf.getChannelConfigMap()。前者是全部的channel的名字。后者是全部chennel对象中有在配置文件里配置config參数,即外部配置类的channel配置信息,所以Configurables.configure(channel,
comp)会对有config外围配置类的进行配置。 agentConf.getChannelContext().get(chName)则是获取没有外围配置类(没有configcan參数。包含自己定义的及内置的)的channel。自己定义的必须实现Configurable接口。所以Configurables.configure(channel, context)会起作用对自己定义的channel进行配置。我们再自己定义或者在看源代码时看到的configure(context)方法会在此时调用。
在上述两次Configurables.configure分别会调用一次getOrCreateChannel方法,该方法除了返回指定的channel之外,还会将和channelsNotReused内同名的channel删除,这样保证channelsNotReused中是没有又一次使用的channel,使得最后从channelCache
删除。但channelCache中可能还存在已失效的channel,因此须要依据channelsNotReused剩余的从channelCache中所有删除,可使channelCache中缓存的就是正在使用的channel。
由于channelSet可能会包括两种:一种就是内置的;一种就是自己定义的。所以就分两种类型初始化并配置。
channelComponentMap则是全部(包含内置和自己定义(假设有的话))channel的配置信息。一般来说,自己定义channel非常少,内置的channel类型能满足绝大说的情况。
由此,我们也能够看出外部的配置类至少须要具备下面几个条件:一、必须有configure(ComponentConfiguration conf)方法;二、实现ConfigurableComponent接口。三、必须有String类型做參数的构造方法。
ChannelComponent类用来channel(对象)及其相应的sink和source(名字)。
是channelComponentMap中value。key是channel的名字。
channelsNotReused存在的意义就是当动态载入的时候可以清除channelCache中不在新的配置文件里的channel。
4、loadSources(agentConf, channelComponentMap, sourceRunnerMap)方法。
loadSources和loadChannels有一些相似。
首先会获取getSourceSet()source集合。以及getSourceConfigMap()有外部配置类的channel,然后遍历soureSet对有外部配置类的source创建相应的source对象,并运行source的configure(context)方法进行配置。然后对每一个source获取相应的channels,兵构造ChannelProcessor,运行ChannelProcessor.configure方法。依据source的类型构造SourceRunner。
public static SourceRunner forSource(Source source) {
SourceRunner runner = null; if (source instanceof PollableSource) {
runner = new PollableSourceRunner();
((PollableSourceRunner) runner).setSource((PollableSource) source);
} else if (source instanceof EventDrivenSource) {
runner = new EventDrivenSourceRunner();
((EventDrivenSourceRunner) runner).setSource((EventDrivenSource) source);
} else {
throw new IllegalArgumentException("No known runner type for source "
+ source);
} return runner;
}
从上述代码能够看到source有两种。一种是实现PollableSource的就构造PollableSourceRunner;第二种是实现EventDrivenSource接口的,就构造EventDrivenSourceRunner。两种的差别。在这里中有说明。然后将source相应的ChannelComponent增加:channelComponent.components.add(sourceName)。
其次是getSourceContext(),对没有外部配置类的source进行载入。
Configurables.configure(source, context)将会调用source.configure(context)对source自身进行配置。其他和上面基本同样。
參数sourceRunnerMap则是保存了全部soure运行的方式。
5、loadSinks(agentConf, channelComponentMap, sinkRunnerMap)方法。载入sink的过程中也是分两部分:
首先是对有外部配置类的sink,先构造Sink对象,然后对其调用sink.configure方法进行參数配置。然后检查此sink是否有channel相连,接着将sinkName与sink一同添加sinks,并对对应的channel添加组件信息channelComponent.components.add(sinkName)。
其次就是对没有指定外部配置类的sink进行和上述相同的操作。仅仅只是Configurables.configure(sink, context)是调用的source的configure运行參数配置。
最后调用loadSinkGroups(agentConf, sinks, sinkRunnerMap)
6、loadSinkGroups(agentConf, sinks, sinkRunnerMap)方法,用于载入sinkGroups。sinkgroup与sink、source、channel不同,没有外部配置类,故仅仅有getSinkGroupConfigMap()。载入sinkGroup方法首先会遍历全部的sinkgroup,获取每一个sinkGroup相应的sink,然后构造SinkGroup对象,并对其进行參数配置Configurables.configure(group, groupConf),sinkRunnerMap.put(comp.getComponentName(),new
SinkRunner(group.getProcessor()))这句代码则是将sinkgroup放入sinkRunnerMap,group.getProcessor()是获取processor的类型(null、org.apache.flume.sink.FailoverSinkProcessor容错、org.apache.flume.sink.DefaultSinkProcessor默认、org.apache.flume.sink.LoadBalancingSinkProcessor负载均衡。这四种之中的一个)。
然后对全部的sink遍历,假设sink没有參与sinkgroup则使用默认DefaultSinkProcessor,构造SinkProcessor对象。对SinkProcessor进行參数配置
Configurables.configure(pr, new Context())然后增加sinkRunnerMap.put(entry.getKey(),new SinkRunner(pr))。
7、检查每一个channel的channelComponent.components是否为空,为空则表明没有和这个channel相连接的组件应该删除。否则将全部的channel、SourceRunner、SinkRunner增加1中的MaterializedConfiguration对象。
...
conf.addChannel(channelName, channelComponent.channel);
...
for(Map.Entry<String, SourceRunner> entry : sourceRunnerMap.entrySet()) {
conf.addSourceRunner(entry.getKey(), entry.getValue());
}
for(Map.Entry<String, SinkRunner> entry : sinkRunnerMap.entrySet()) {
conf.addSinkRunner(entry.getKey(), entry.getValue());
}
由代码可知,将解析出来的都存储进MaterializedConfiguration的三大组件,即1中的三大组件。
source与channel的相应关系存储在SourceRunner中的source中的channelProcessor中的selector中。sink与channel的关系在sink.setChannel(channelComponent.channel)设定。
8、清空channelComponentMap、sourceRunnerMap、sinkRunnerMap。
9、返回MaterializedConfiguration对象conf。
接下来就返回到PollingPropertiesFileConfigurationProvider.FileWatcherRunnable.run()方法中的eventBus.post(getConfiguration())。会通知Application.handleConfigurationEvent(MaterializedConfiguration conf)方法。下一篇再讲这个。
总结:1、上面有指定外部配置类的必然是自己定义的组件;2、每一个配置文件能够配置多个agent。用命令选择使用哪个,每一个agent能够配置多个source、多个sink、多个channel、多个sinkgroups,可是每一个sink仅仅能相应一个sink、每一个source能够相应多个channel、每一个channel能够相应多个sink也能够相应多个source。
问题:1、为什么仅仅有channel有channelCache。source没有sourceCache。sink没有sinkCache??
答:可是loadChannels方法的最后会将不再反复用的channel从channelCache中删除,每次调用loadChannels方法都会尝试去删除不再重用的channel,我觉得是channel相对于其它组件比較少定制。变化也少,缓存后当又一次载入配置文件时能够马上从缓存中获取channel(假设有的话)这样能够节省一些时间。source和sink都是易变的组件因此每次都又一次载入。(这样是不是有点牵强,还是我理解错了?)
版权声明:本文博主原创文章。博客,未经同意不得转载。
【Java】【Flume】Flume-NG源代码分析的启动过程(两)的更多相关文章
- Flume 1.7 源代码分析(四)从Source写数据到Channel
Flume 1.7 源代码分析(一)源代码编译 Flume 1.7 源代码分析(二)总体架构 Flume 1.7 源代码分析(三)程序入口 Flume 1.7 源代码分析(四)从Source写数据到C ...
- Appium Android Bootstrap源代码分析之启动执行
通过前面的两篇文章<Appium Android Bootstrap源代码分析之控件AndroidElement>和<Appium Android Bootstrap源代码分析之命令 ...
- 新秀nginx源代码分析数据结构篇(两) 双链表ngx_queue_t
nginx源代码分析数据结构篇(两) 双链表ngx_queue_t Author:Echo Chen(陈斌) Email:chenb19870707@gmail.com Blog:Blog.csdn. ...
- MonkeyRunner源代码分析之启动
在工作中由于要追求完毕目标的效率,所以很多其它是强调实战.注重招式.关注怎么去用各种框架来实现目的.可是假设一味仅仅是注重招式.缺少对原理这个内功的了解,相信自己非常难对各种框架有更深入的理解. 从几 ...
- Android(java)学习笔记162:Android启动过程(转载)
转载路径为: http://blog.jobbole.com/67931/ 1. 关于Android启动过程的问题: 当按下Android设备电源键时究竟发生了什么? Android的启动过程是怎么样 ...
- Nimbus<三>Storm源码分析--Nimbus启动过程
Nimbus server, 首先从启动命令开始, 同样是使用storm命令"storm nimbus”来启动看下源码, 此处和上面client不同, jvmtype="-serv ...
- Android(java)学习笔记105:Android启动过程(转载)
转载路径为: http://blog.jobbole.com/67931/ 1. 关于Android启动过程的问题: 当按下Android设备电源键时究竟发生了什么? Android的启动过程是怎么样 ...
- mybatis源码分析:启动过程
mybatis在开发中作为一个ORM框架使用的比较多,所谓ORM指的是Object Relation Mapping,直译过来就是对象关系映射,这个映射指的是java中的对象和数据库中的记录的映射,也 ...
- workerman源码分析之启动过程
PHP一直以来以草根示人,它简单,易学,被大量应用于web开发,非常可惜的是大部分开发都在简单的增删改查,或者加上pdo,redis等客户端甚至分布式,以及规避语言本身的缺陷.然而这实在太委屈PHP了 ...
随机推荐
- linux下无ifconfig命令
你不是用root用户运行此命令的吧?这样试试看:$ su - password: 输入root用户口令# ifconfig 还是没有的 用whereis命令找找看:# whereis ifco ...
- 飘逸的python - 保持命名空间的整洁
API的设计是一个艺术活.往往需要其简单.易懂.整洁.不累赘. 很多时候,我们在底层封装一个方法给高层用,而其它的方法只是为了辅助这个方法的. 也就是说我们只需要暴露这个方法就行,不用关心这个方法是怎 ...
- java 线程 新类库中的构件 countDownLatch 使用
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvbGlhbmdydWkxOTg4/font/5a6L5L2T/fontsize/400/fill/I0JBQk ...
- bellman_ford寻找平均权值最小的回路
给定一个有向图,如果存在平均值最小的回路,输出平均值. 使用二分法求解,对于一个猜测值mid,判断是否存在平均值小于mid的回路 如果存在平均值小于mid的包含k条边的回路,那么有w1+w2+w3+. ...
- 【甘道夫】Hive 0.13.1 on Hadoop2.2.0 + Oracle10g部署详细解释
环境: hadoop2.2.0 hive0.13.1 Ubuntu 14.04 LTS java version "1.7.0_60" Oracle10g ***欢迎转载.请注明来 ...
- linux 经常使用配置
教研室用的非常旧的fedora14,装一些软件和下载东西的时候比較蛋疼,恰巧ubuntu14.04 公布,于是安装试试,顺便记录下经常使用的配置,备忘. 1. 制作镜像,比較老的主板,写入方式选择US ...
- java打印各种菱形
/** * 类说明 * * @author 曾修建 * @version 创建时间:2014-7-23 上午09:50:46 */ public class Diamond { public stat ...
- fscanf()功能具体解释
一旦文件被解析常规时间或使用正则表达式.或者是敲自己太傻代码来解析一个普通文件. 今天突然发现c该图书馆有一个现成的文件可以解析常规功能,这是fscanf()功能.哎 曾经自己做了这么多无用功.在这里 ...
- 超人学院Hadoop大数据资源共享
超人学院Hadoop大数据资源共享-----数据结构与算法(java解密版) http://yunpan.cn/cw5avckz8fByJ 訪问password b0f8 很多其它精彩内容请关注: ...
- Google Maps Android API v2 (3)- 地图添加到Android应用程序
添加地图的基本步骤是: (一旦)按照以下步骤[入门] [开始],获得API,获取密钥所需的属性,并添加到您的Android清单. 添加一个碎片对象 要处理地图的活动.做到这一点最简单的方法是增加一个 ...