从这篇文章开始,我将把自己对 Hadoop 系统的一些学习经验整理成文章分享出来,算是对自己学习过程的梳理和知识的分享。我主要会从源代码的角度去探索 Hadoop 中的各个组件的技术细节,使用的是 2.7.2 的源代码。

一个成熟的软件系统必备的组件之一就是配置系统。配置系统负责将纷繁复杂的配置项按照一定的既定规则管理起来,从而让系统的其他组件不必重复处理类似于配置加载、配置 Override、扩展配置字段中的引用符号等细节。我所熟悉的 Tornado、Django、Laravel 等 Web 框架都会有一套自己严格规定的配置系统;这也引导了我平时新开一个项目的时候,第一时间着手实现的就是配置管理模块——这是因为我相信配置管理被成熟地实现会避免一系列问题(尤其是部署过程中的)。久而久之,在 Python Web 开发领域我已经有了自己的一套「最佳实践」——配置存储于 JSON、YAML 文件,使用脚本将内容映射为 Python 模块对象,使用时直接导入变量即可,而配置文件本身本地、开发机与线上各自保存一份,与代码部署过程分离(这样可以避免本地的、开发机器上的或生产环境的配置文件随频率更高的代码部署流程误部署)。大型系统甚至会衍生出自己的配置管理服务(例如微博的实践)来应对日益复杂的配置管理工作,而开源社区也贡献了 etcd 这种优秀的解决方案。

Hadoop 中的配置系统和我前述的自己的「最佳实践」的做法类似——数据主要存储于 XML 文件中,由 org.hadoop.conf.Configuration 类负责配置加载、Override、字段扩展等工作,每个 Hadoop 组件在启动后都会拥有自己的 Configuration 实例(最常见的就是 MapReduce 2 程序中你在 job 代码的 main 方法里 new 的那个),需要获取或者设置某个配置字段时只需要调用相应的方法即可。这种做法在 Java 社区比较常见,Tomcat、Spring 等知名 Java 项目都采用了类似的方案。

Configuration 简介

下面我们对 Configuration 的一些基本功能做一些介绍。

基本用法

深入了解一段代码的原理从了解其接口开始,了解其接口则从会使用它开始。下面是一段 Configuration 使用的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.bachiscoding.hadoop.lab.conf;

import org.apache.hadoop.conf.Configuration;

/**
* Created by winiex on 16-6-10.
*/

public class BasicUsage {
public static void main(String[] args) {
// 1. Configuration 对象是可以直接实例化的
Configuration configuration = new Configuration();

// 2. 通过调用 addResource 方法来将配置资源注册到 Configuration
// 对象中,这个时候 Configuration 对象内还没有加载配置中的内容
configuration.addResource("conf/hdfs-default.xml");

// 3. 通过调用 get 方法来获取配置项转化为 String 类型后的值
String value1 = configuration.get("dfs.datanode.address");
System.out.println(value1);

// 4. 可以通过 set 方法来设置配置项的值,值的类型为 String
configuration.set("dfs.datanode.address", "New Value");
value1 = configuration.get("dfs.datanode.address");
System.out.println(value1);

// 5. 也可以指定要获取的配置项值的类型,对应的值会被转化为相应的类型的实例
int value2 = configuration.getInt("hadoop.hdfs.configuration.version", 1);
System.out.println(value2);

// 6. 设置配置项的值时也可以制定类型
configuration.setInt("hadoop.hdfs.configuration.version", 2);
value2 = configuration.getInt("hadoop.hdfs.configuration.version", 1);
System.out.println(value2);
}
}

代码中存储配置数据的 hdfs-default.xml 文件是一个保存在某个 Java Classpath 中的 conf 目录下的 XML 文件,其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>hadoop.hdfs.configuration.version</name>
<value>1</value>
<description>version of this configuration file</description>
</property>
<property>
<name>dfs.datanode.address</name>
<value>0.0.0.0:50010</value>
<description>
The datanode server address and port for data transfer.
</description>
<source>Application</source>
</property>
<!-- 在结构上 Configuration 可以嵌套,最终的值会会和上面的解析在一起,本质上没有区别 -->
<configuration>
<property>
<name>hadoop.hdfs.configuration.version</name>
<value>1</value>
<description>version of this configuration file</description>
<source>Testing</source>
</property>
</configuration>
</configuration>

有 Hadoop 使用经验的朋友应该遇到过程序找不到配置文件的错误,这是因为打包时没有把相应的 XML 配置文件放到 Jar 文件的 resources 目录下或者运行程序时没有包含配置所在的路径到 JVM 的 Classpath 中去。对于 String 类型的 Resource,Configuration 会在加载配置时根据它的值到 Classpath 中去寻找相应的文件,这一块后面我们分析加载配置数据的部分的代码时会涉及到。对于刚才的例子,如果是用 Maven 打包的程序,配置文件可以放到下图所示的目录下:

Where to place config files

在 MapReduce 程序中 Configuration 的样例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import ......

public class WordCount {
public static class TokenizerMapper
extends Mapper<Object, Text, Text, IntWritable>{

......
}

public static class IntSumReducer
extends Reducer<Text,IntWritable,Text,IntWritable> {

......
}

public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf, "word count");
conf.setInt("param1", 1);

......

System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}

在 MapReduce 程序运行的过程中,这个 Configuration 对象会被通过 Hadoop 的序列化机制共享到各个角色中(Mapper、Combiner、Reducer 等),这样各个角色都能够通过它获得该 MapReduce 程序的配置数据,代码里面设置的 param1 在各个角色里面都可以获取到。

配置的可覆盖性

使用过 Hadoop 的朋友应该会知道,如果在运行 MapReduce 程序的时候,在命令行参数中加入 -D 参数,是可以制定甚至覆盖掉某个配置项的值的:

1
hadoop jar wordcount.jar -Dfoo=bar #这样在 wordcount 运行的过程中,Configuration 对象会包含 foo = bar 这一配置项

这种配置的可覆盖性是 Configuration 提供的一种方便开发的机制,通过运行程序是手动指定配置项,我们可以方便地去定制 Job 的一些基本环境,例如容器虚拟 CPU 个数、容器堆内存大小等。我之前遇到过某个 Job 总是因为默认的容器堆内存过小被 Kill 掉的问题,最后就是通过手动指定更大的容器堆内存来解决的。

不可被覆盖的 final 配置

参数默认情况下是可以被覆盖的,但有些参数我们也是希望我们指定了其他人就不能覆盖了。这个时候可以使用 final 属性来达到目的:

1
2
3
4
5
<property>
<name>hadoop.ssl.client.conf</name>
<value>ssl-client.xml</value>
<final>true</final>
</property>

指定了 final 属性为 true 后,其他人就不能通过覆盖的方式改变它的值了。如果要强行覆盖该配置的话,你指定的值不会起作用,而且 Configuration 会在 Log 中输出 Warning 信息。

配置的扩展

在某些场景下,我们的某个配置项是依赖于另一个配置项的,这个时候如果配置项能够按照引用的方式来扩展就会很方便。Configuration 提供了这个机制,我们最熟悉的例子是 hadoop.tmp.dir 和 dfs.datanode.data.dir:

1
2
3
4
5
6
7
8
9
<property>
<name>hadoop.tmp.dir</name>
<value>/tmp/hadoop</value>
</property>

<property>
<name>dfs.datanode.data.dir</name>
<value>file://${hadoop.tmp.dir}/dfs/data</value>
</property>

我们看到,dfs.datanode.data.dir 的值中用 ${KEY_NAME} 的形式引用了 hadoop.tmp.dir 的值。在 Configuration 获取 dfs.datanode.data.dir 的值时,会将该部分替换为 hadoop.tmp.dir 的值,最终结果为 /tmp/hadoop/dfs/data。这个配置扩展的机制类似于 Bash 脚本中引用某个变量。

接下来我们来了解一下 Configuration 的结构。

Configuration 的结构

简化后的 Configuration 类的 UML 结构图如下:

UML Class Diagram of Configuration

Configuration 实现了 org.apache.hadoop.io.Writable 接口和 Iterator 接口。实现前者的目的是为了让 Configuration 能够利用到 Hadoop 的序列化机制,从而能够在系统内不同服务角色上传输、共享。实现后者的目的是为了让我们能够用迭代器模式来访问 Configuration 内的配置项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.bachiscoding.hadoop.lab.conf;

import org.apache.hadoop.conf.Configuration;

import java.util.Iterator;
import java.util.Map;

/**
* Created by winiex on 16-7-2.
*/

public class IterateConf {

public static void main(String[] args) {
Configuration configuration = new Configuration();
configuration.addResource("conf/hdfs-default.xml");

// 获得 Configuration 对象的迭代器对象
Iterator<Map.Entry<String, String>> confIter = configuration.iterator();

while (confIter.hasNext()) {
Map.Entry<String, String> entry = confIter.next();
String key = entry.getKey();
String value = entry.getValue();

System.out.println(key);
System.out.println(value);
}
}
}

Configuration 中很大一部分方法是以 get/set 来开头的,这个做法 Java 程序员不会陌生。只让调用者通过这些方法来访问 Configuration 的配置数据,可以封装掉底层的实现细节(例如后面会介绍的 Lazy Loading 机制),让 API 变得一致,让开发变得简单好维护。

Configuration 需要一个保存配置项数据的地方,它就是 UML 类图中的 properties 成员变量,它是一个 java.util.Properties 对象。Configuration 加载配置资源的内容后会将处理完毕的配置项的数值保存在 properties 里面。

resources 成员变量是一个包含了 Resource 类的 ArrayList,它记录了 Configuration 对象有哪些配置资源需要加载。而 Resource 是 Configuration 内私有的一个类型,代表了一个配置资源。Configuration 支持的配置资源有:「字符串」(存在于 CLASSPATH 中的资源)、「URL」、「Path」(存在于 HDFS 中的配置文件)、「InputStream」和「另一个 Configuration」。

Configuration 源码分析

下面我们在代码层面从几个方面详细地了解其工作机理。

初始化过程

初始化一个 Configuration 最简单的方式如下:

1
Configuration configuration = new Configuration();

我们都知道,在 JVM 初始化一个 (注意,不是实例)时,会最先执行类内部的 static 代码块,来完成一些属于类的初始化工作。在 Configuration 中就通过这个方式,用一段 static 代码块注册了默认配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static{
//print deprecation warning if hadoop-site.xml is found in classpath
// 获取 ClassLoader
ClassLoader cL = Thread.currentThread().getContextClassLoader();
if (cL == null) {
cL = Configuration.class.getClassLoader();
}

// hadoop-site.xml 是已经被废弃的配置文件,所以如果 Classpath 中还存在
// 该文件的话会输出一句 Warning 至日志文件。
if(cL.getResource("hadoop-site.xml")!=null) {
LOG.warn("DEPRECATED: hadoop-site.xml found in the classpath. " +
"Usage of hadoop-site.xml is deprecated. Instead use core-site.xml, "
+ "mapred-site.xml and hdfs-site.xml to override properties of " +
"core-default.xml, mapred-default.xml and hdfs-default.xml " +
"respectively");
}

// 将 core-default.xml、core-site.xml 注册,保存这些默认注册信息的是
// defaultResources 这个静态变量,它是一个 CopyOnWriteArrayList<String> 对象。
addDefaultResource("core-default.xml");
addDefaultResource("core-site.xml");
}

我们恍然大悟:原来 core-default.xmlcore-site.xml 这两个贯穿整个 Hadoop 体系的配置文件就是在这里注册的。所有使用 Configuration 来管理配置的 Hadoop 组件都会注册这两个文件,同时也会根据自己的需求注册数据自己的默认配置文件:例如,HDFS 组件会注册 hdfs-default.xmlhdfs-site.xml。这个规律被很多 Hadoop 组件所遵守。

另外,CopyOnWriteArrayList 是一个采用了 Copy on Write 模式的线程安全的 ArrayList,在这里使用是为了保证多线程环境下 Configuration 能够安全地工作。

如果静态初始化代码已经执行过了,则会进一步调用构造方法。这里我们调用了默认构造方法,它的代码很简单:

1
2
3
4
5
/** A new configuration. */
public Configuration() {
// 调用重载的构造方法,该方法含有一个指定是否加载默认配置的参数。
this(true);
}

默认构造器调用了指定是否加载默认配置的构造器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/** A new configuration where the behavior of reading from the default
* resources can be turned off.
*
* If the parameter {@code loadDefaults} is false, the new instance
* will not load resources from the default files.
* @param loadDefaults specifies whether to load from the default files
*/

public Configuration(boolean loadDefaults) {
// 保存是否加载默认配置的值,在后面 Lazy Loading 的时候来判断是否加载默认配置
this.loadDefaults = loadDefaults;

// updatingResource 记录了变更的配置项的值以及变更的来源。
updatingResource = new ConcurrentHashMap<String, String[]>();
synchronized(Configuration.class) {
// REGISTRY 用于记录整个 JVM 进程中所有的 Configuration 对象,从而
// 在注册新的默认配置的时候可以让每个 Configuration 对象都能及时更新
// 配置数据
REGISTRY.put(this, null);
}
}

这里的 updatingResource 实例使用了 ConcurrentHashMap保证在多线程的环境下的并发访问效率

REGISTRY 是 Configuration 类的静态成员变量,它是一个 WeakHashMap<Configuration, Object>(12) 对象。在这里只用到了 key set 来保存 JVM 系统中所有存活的 Configuration 对象的引用,从而在默认配置更新的时候能够统一进行配置更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Add a default resource. Resources are loaded in the order of the resources
* added.
* @param name file name. File should be present in the classpath.
*/

public static synchronized void addDefaultResource(String name) {
if(!defaultResources.contains(name)) {
defaultResources.add(name);
for(Configuration conf : REGISTRY.keySet()) {
// 当默认配置项发生变化时,重新加载每个 Configuration 对象的配置数据
if(conf.loadDefaults) {
conf.reloadConfiguration();
}
}
}
}

初始化部分的代码并不复杂,需要注意的地方在于并发数据结构的使用。

Lazy Loading

Configuration 不会在 addResource 之后就马上把所有实例的配置源的数据加载一遍,而是会在使用者需要获取某个配置项时再进行加载。这是标准的 Lazy Loading 策略。另,刚才我们已经接触了用于添加默认配置资源的 addDefaultResource 方法,这个方法里面调用了 Configuration 的 reloadConfiguration 方法。从字面意义上理解,这个方法应该重新加载了所有配置资源,但实际上并不是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Reload configuration from previously added resources.
*
* This method will clear all the configuration read from the added
* resources, and final parameters. This will make the resources to
* be read again before accessing the values. Values that are added
* via set methods will overlay values read from the resources.
*/

public synchronized void reloadConfiguration() {
properties = null; // trigger reload
finalParameters.clear(); // clear site-limits
}

reloadConfiguration 只做了两件事情:将 properties 重置为 null,将 finalParameters 清空。properties 是一个 java.util.Properties 对象,为 Configuration 对象的成员变量。Configuration 加载的所有配置项的值最终都保存在这里。而 finalParameters 则为一个 Set<String> 对象,它记录了那些被标记为 final 的配置项,从而阻止该配置项的值被覆盖掉。

真正发生配置资源加载的时间点是获取配置项值调用 getProps 方法的时候。我们以 get 方法为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Get the value of the <code>name</code>. If the key is deprecated,
* it returns the value of the first key which replaces the deprecated key
* and is not null.
* If no such property exists,
* then <code>defaultValue</code> is returned.
*
* @param name property name, will be trimmed before get value.
* @param defaultValue default value.
* @return property value, or <code>defaultValue</code> if the property
* doesn't exist.
*/

public String get(String name, String defaultValue) {
String[] names = handleDeprecation(deprecationContext.get(), name);
String result = null;
for(String n : names) {
// 获得属性值,并且进行配置项扩展,获得最终结果。
// getProps 负责加载配置资源的内容,处理后将配置数据保存到 properties 成员变量里面,
// 然后返回 properties 给调用者。
result = substituteVars(getProps().getProperty(n, defaultValue));
}
return result;
}

只在需要的时候加载最少的必要的资源,这是 Lazy Loading 的核心思想。

加载、解析配置资源

刚才遇到的 getProps 方法的实现细节如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
protected synchronized Properties getProps() {
// 如果 properties 为 null,则证明配置资源还没有加载,否则直接返回它就行。
if (properties == null) {
properties = new Properties();
// 因为 loadResources 的过程会改变 updatingResource 的内容,
// 所以这里通过复制将其备份了一次,以便后面回复 overlay 中配置项的该部分的值。
Map<String, String[]> backup =
new ConcurrentHashMap<String, String[]>(updatingResource);

// 加载所有配置资源,处理其包含的数据,填入到 properties 对象中。
loadResources(properties, resources, quietmode);

// overlay 保存了所有经 set 方法设置的配置项的值,
// 这些值会在获得配置项的时候覆盖掉从配置资源中加载的配置数据,
// 进而保证最终 properties 中被 set 方法设置的值一定是
// 当时设置的值,而不受 loadResources 的过程影响。
// 个人认为这是一个 hack。
if (overlay != null) {
properties.putAll(overlay);
for (Map.Entry<Object,Object> item: overlay.entrySet()) {
// updatingResource 保存了通过 set 方法设置的某个配置项
// 的来源,因为 loadResources 的过程中可能加载和 overlay 中
// 配置项同名的配置项,进而引起 updatingResource 中的该配置项
// 的记录不一致,所以这里需要按照 backup 中的内容修复一下。
String key = (String)item.getKey();
String[] source = backup.get(key);
if(source != null) {
updatingResource.put(key, source);
}
}
}
}
return properties;
}

加载配置资源的动作发生在 loadResources 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void loadResources(Properties properties,
ArrayList<Resource> resources,
boolean quiet)
{

// loadDefaults 这个变量在这里其作用了,如果为 true,则加载默认配置资源
if(loadDefaults) {
for (String resource : defaultResources) {
loadResource(properties, new Resource(resource), quiet);
}

//support the hadoop-site.xml as a deprecated case
if(getResource("hadoop-site.xml")!=null) {
loadResource(properties, new Resource("hadoop-site.xml"), quiet);
}
}

// 加载我们通过 addResource 方法注册的配置资源
for (int i = 0; i < resources.size(); i++) {
Resource ret = loadResource(properties, resources.get(i), quiet);
if (ret != null) {
resources.set(i, ret);
}
}
}

我们可以发现,loadResources 方法并没有资源加载的实现细节,具体实现在 loadResource 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
private Resource loadResource(Properties properties, Resource wrapper, boolean quiet) {
String name = UNKNOWN_RESOURCE;
try {
// 获得资源以及其名字,资源有多种种类,所以这里用 Object 存储
Object resource = wrapper.getResource();
name = wrapper.getName();

// 初始化解析 XML 所需要的组件。
// 这里开发者选择了一次读取全部内容的 DOM 的解析方式,
// 这是因为在 Hadoop 的面对的场景下 XML 配置资源所包含的数据
// 量都很小,一起性全部读到内存中反而实现起来更方便,也不会出现
// 明显的效率问题。
// 效率优化的手段是要严格地参考具体的场景来权衡的。
DocumentBuilderFactory docBuilderFactory
= DocumentBuilderFactory.newInstance();
//ignore all comments inside the xml file
docBuilderFactory.setIgnoringComments(true);

//allow includes in the xml file
// Hadoop XML 配置是支持 include 机制的,我们
// 可以在一个配置资源中用 XML 的方式去 include 另一个配置资源。
docBuilderFactory.setNamespaceAware(true);
try {
docBuilderFactory.setXIncludeAware(true);
} catch (UnsupportedOperationException e) {
LOG.error("Failed to set setXIncludeAware(true) for parser "
+ docBuilderFactory
+ ":" + e,
e);
}
DocumentBuilder builder = docBuilderFactory.newDocumentBuilder();
Document doc = null;
Element root = null;
// 标志是否返回缓存用的 Properties 对象,当配置资源是 InputStream 类型时为 true。
boolean returnCachedProperties = false;

// 接下来根据 resource 的类型来对其进行对应的处理。
// parse 的一系列重载方法具体实现了从某个配置资源将数据
// 读取并解析的过程。
if (resource instanceof URL) { // an URL resource
// 处理 URL 类型的配置资源
doc = parse(builder, (URL)resource);
} else if (resource instanceof String) { // a CLASSPATH resource
// 处理 String 类型的配置资源,这种情况下 resource 保存了
// JVM CLASSPATH 中可以寻找到的资源。getResource 方法
// 会用 class loader 去寻找这个资源并返回。
URL url = getResource((String)resource);
doc = parse(builder, url);
} else if (resource instanceof Path) { // a file resource
// 处理 Path 类型的资源,也就是 HDFS 中的配置资源。
// Can't use FileSystem API or we get an infinite loop
// since FileSystem uses Configuration API. Use java.io.File instead.
File file = new File(((Path)resource).toUri().getPath())
.getAbsoluteFile();
if (file.exists()) {
if (!quiet) {
LOG.debug("parsing File " + file);
}
doc = parse(builder, new BufferedInputStream(
new FileInputStream(file)), ((Path)resource).toString());
}
} else if (resource instanceof InputStream) {
// 处理 InputStream 形式的配置资源。
doc = parse(builder, (InputStream) resource, null);

// 一般情况下 InputStream 类型的配置资源意味着背后很可能是
// 访问网络这种延时高的、不可靠的动作,而且配置信息在程序运行过程中几乎不会变化,
// 所以有必要性和可能性将数据读取后保存在一个 Properties 对象中做缓存提高效率。
returnCachedProperties = true;
} else if (resource instanceof Properties) {
// 处理 Properties 形式的配置资源。
overlay(properties, (Properties)resource);
} else if (resource instanceof Element) {
// resource 本身就是 XML 解析结果,无需处理。
root = (Element)resource;
}

if (root == null) {
if (doc == null) {
if (quiet) {
// quiet 模式下解析失败,直接返回 null。
return null;
}

// 非 quiet 模式下解析失败,抛出一个 RuntimeException
throw new RuntimeException(resource + " not found");
}
root = doc.getDocumentElement();
}

// 默认情况下, toAddTo 就是 this.properties,这意味着后面
// 将配置项通过 loadProperty 方法写入 toAddTo 时就是更改了
// this.properties 中的值。
Properties toAddTo = properties;
if(returnCachedProperties) {
// 当 resource 是 InputStream 类型时,toAddTo 是一个新的 Properties
// 对象,这意味着后面 loadProperty 并没有更改 this.properties。
// 这样做的目的是为了后面将 InputStream 中的配置内容包装成 Resource 返回
// 给调用者。
toAddTo = new Properties();
}

// 如果配置资源中包含的 XML 根节点不是 configuration,则其结构非法,抛出错误。
if (!"configuration".equals(root.getTagName()))
LOG.fatal("bad conf file: top-level element not <configuration>");

// 获得 root 节点中的字节点,对其进行迭代并处理。
NodeList props = root.getChildNodes();
// 获得被废弃掉的配置项列表。
DeprecationContext deprecations = deprecationContext.get();

for (int i = 0; i < props.getLength(); i++) {
Node propNode = props.item(i);
if (!(propNode instanceof Element))
continue;
Element prop = (Element)propNode;

// 如果子节点的根节点是 configuration,则表示它是一个内嵌的配置组,
// 所以接下来递归地去处理这部分。
if ("configuration".equals(prop.getTagName())) {
loadResource(toAddTo, new Resource(prop, name), quiet);
continue;
}

// 配置项既不是内嵌的 configuration 也不是包含配置信息的 property,抛出警告。
if (!"property".equals(prop.getTagName()))
LOG.warn("bad conf file: element not <property>");

// 配置项是 property,获取其子节点处理之。
NodeList fields = prop.getChildNodes();
// 配置的名字,也就是 key。
String attr = null;
// 配置的值。
String value = null;
// finalParameter 表明该配置项是否是 final 的。
boolean finalParameter = false;
// source 记载了配置项的来源。
LinkedList<String> source = new LinkedList<String>();

for (int j = 0; j < fields.getLength(); j++) {
Node fieldNode = fields.item(j);
if (!(fieldNode instanceof Element))
continue;
Element field = (Element)fieldNode;
// 获取配置项的名称
if ("name".equals(field.getTagName()) && field.hasChildNodes())
attr = StringInterner.weakIntern(
((Text)field.getFirstChild()).getData().trim());
// 获取配置项的值
if ("value".equals(field.getTagName()) && field.hasChildNodes())
value = StringInterner.weakIntern(
((Text)field.getFirstChild()).getData());
// 获取配置项的 final 属性
if ("final".equals(field.getTagName()) && field.hasChildNodes())
finalParameter = "true".equals(((Text)field.getFirstChild()).getData());
// 获取配置项的 source 属性
if ("source".equals(field.getTagName()) && field.hasChildNodes())
source.add(StringInterner.weakIntern(
((Text)field.getFirstChild()).getData()));
}

source.add(name);

// Ignore this parameter if it has already been marked as 'final'
if (attr != null) {

// 如果配置项是已经废弃掉的,则将其替换成新的对应的配置项的名字。
if (deprecations.getDeprecatedKeyMap().containsKey(attr)) {
DeprecatedKeyInfo keyInfo =
deprecations.getDeprecatedKeyMap().get(attr);
keyInfo.clearAccessed();

for (String key : keyInfo.newKeys) {
// update new keys with deprecated key's value
// key 是废弃掉的配置项的 key 替换出来的新的 key,有可能
// 一个废弃的 key 对应了多个新的 key。

// 将替换 key 后的配置项的值填充到 toAddTo 中。
loadProperty(toAddTo, name, key, value, finalParameter,
source.toArray(new String[source.size()]));
}
}
else {
// 将配置项的值填充到 toAddTo 中
loadProperty(toAddTo, name, attr, value, finalParameter,
source.toArray(new String[source.size()]));
}
}
}

if (returnCachedProperties) {
// 配置资源是 InputStream,代码运行进入该分支。

// overlay 方法负责将 toAddTo 中的内容合并到 this.properties 中。
overlay(properties, toAddTo);

// 将从 InputStream 中读取的内容封装成 Resource 对象后返回,
// 调用者如果想缓存之,可以自行决定——因为只有调用者了解
// InputStream 背后的数据源的延时大小、可靠性。
return new Resource(toAddTo, name);
}

// 在非 InputStream 类型的配置资源的情况下,不会返回从资源中读取的
// 配置内容,这些内容已经合并到 this.properties 中去了。
return null;
} catch (IOException e) {
LOG.fatal("error parsing conf " + name, e);
throw new RuntimeException(e);
} catch (DOMException e) {
LOG.fatal("error parsing conf " + name, e);
throw new RuntimeException(e);
} catch (SAXException e) {
LOG.fatal("error parsing conf " + name, e);
throw new RuntimeException(e);
} catch (ParserConfigurationException e) {
LOG.fatal("error parsing conf " + name , e);
throw new RuntimeException(e);
}
}

loadResource 方法中用 loadProperty 将某个配置项加入到 Properties 对象中,用 overlay 方法来将某个 Properties 对象的值合并到另一个里面去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// name 是配置资源的名字,而不是配置项的 key,attr 才是。
// finalParameter 表明了该配置项是否是不可更改的。
// source 表明了该配置项从哪里来。
private void loadProperty(Properties properties, String name, String attr,
String value, boolean finalParameter, String[] source)
{

if (value != null || allowNullValueProperties) {
if (!finalParameters.contains(attr)) {
if (value==null && allowNullValueProperties) {
// 如果 value 为 null 而配置允许空值,则赋予一个约定好的
// 常量表示该配置项的值为空。
value = DEFAULT_STRING_CHECK;
}

// 填入配置项的值。
properties.setProperty(attr, value);

// 标记配置项的来源。
if(source != null) {
updatingResource.put(attr, source);
}
} else if (!value.equals(properties.getProperty(attr))) {
// 要更改 final 属性的配置项,在 log 中输出 warning。
LOG.warn(name+":an attempt to override final parameter: "+attr
+"; Ignoring.");
}
}

// 如果配置项为 final,则将其标记之供下次有人要更改时判断。
if (finalParameter && attr != null) {
finalParameters.add(attr);
}
}

// 将 from 中的值合并到 to 中。
private void overlay(Properties to, Properties from) {
for (Entry<Object, Object> entry: from.entrySet()) {
to.put(entry.getKey(), entry.getValue());
}
}

具体的 XML 解析发生在 parse 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 这个方法是对另一个 InputStream 接口的方法的封装,比较简单。
private Document parse(DocumentBuilder builder, URL url)
throws IOException, SAXException {

if (!quietmode) {
// 非 quietmode,输出日志。
LOG.debug("parsing URL " + url);
}
if (url == null) {
return null;
}
return parse(builder, url.openStream(), url.toString());
}

// 解析 InputStream 中的内容,返回解析好的 org.w3c.dom.Document 对象。
private Document parse(DocumentBuilder builder, InputStream is,
String systemId) throws IOException, SAXException
{

if (!quietmode) {
// 非 quietmode,输出日志。
LOG.debug("parsing input stream " + is);
}
if (is == null) {
return null;
}
try {
return (systemId == null) ? builder.parse(is) : builder.parse(is,
systemId);
} finally {
is.close();
}
}

配置项扩展

在 Lazy Loading 那部分我们接触到了 get 方法的实现,其中使用了 substituteVars 方法来对配置项进行扩展,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
* Attempts to repeatedly expand the value {@code expr} by replacing the
* left-most substring of the form "${var}" in the following precedence order
* <ol>
* <li>by the value of the Java system property "var" if defined</li>
* <li>by the value of the configuration key "var" if defined</li>
* </ol>
*
* If var is unbounded the current state of expansion "prefix${var}suffix" is
* returned.
*
* @param expr the literal value of a config key
* @return null if expr is null, otherwise the value resulting from expanding
* expr using the algorithm above.
* @throws IllegalArgumentException when more than
* {@link Configuration#MAX_SUBST} replacements are required
*/

// 这个方法负责配置项的扩展。
private String substituteVars(String expr) {
if (expr == null) {
return null;
}

String eval = expr;

// 一次循环代表了一次扩展:这是因为有可能扩展完毕的结果有可能还存在一个未扩展的值。
// MAX_SUBST 是 Hadoop 开发者实现定义好的常量,默认为 20,代表对一个配置项
// 最多进行 20 次扩展。通常情况下已经够用了。
for (int s = 0; s < MAX_SUBST; s++) {
// 找到「$」、「{」、「}」这些符号是否存在,以及都在 eval 的哪里。
final int[] varBounds = findSubVariable(eval);

if (varBounds[SUB_START_IDX] == -1) {
// 没有找到 ${foobar} 这样的结构,直接返回。
return eval;
}

// 找到了 ${foobar},将其 key 提取出来——也就是提取出 foobar。
final String var = eval.substring(varBounds[SUB_START_IDX],
varBounds[SUB_END_IDX]);

// val 就是扩展后的值。
String val = null;

try {
// 优先从系统默认的属性中获取扩展配置。
val = System.getProperty(var);
} catch(SecurityException se) {
LOG.warn("Unexpected SecurityException in Configuration", se);
}

if (val == null) {
// 系统默认的配置中没有,则从已经加载的配置内容中获取扩展配置。
val = getRaw(var);
}

if (val == null) {
// 最终没有找到要扩展的配置项,这个时候保留 ${foobar} 不动继续。
return eval; // return literal ${var}: var is unbound
}

// 获得「$」符号的 index。
final int dollar = varBounds[SUB_START_IDX] - "${".length();

// 获得「}」符号后一个字符的 index。
final int afterRightBrace = varBounds[SUB_END_IDX] + "}".length();
// substitute
// 将 ${foobar} 整个替换为找到的那个值。
eval = eval.substring(0, dollar)
+ val
+ eval.substring(afterRightBrace);
}
throw new IllegalStateException("Variable substitution depth too large: "
+ MAX_SUBST + " " + expr);
}

/**
* This is a manual implementation of the following regex
* "\\$\\{[^\\}\\$\u0020]+\\}". It can be 15x more efficient than
* a regex matcher as demonstrated by HADOOP-11506. This is noticeable with
* Hadoop apps building on the assumption Configuration#get is an O(1)
* hash table lookup, especially when the eval is a long string.
*
* @param eval a string that may contain variables requiring expansion.
* @return a 2-element int array res such that
* eval.substring(res[0], res[1]) is "var" for the left-most occurrence of
* ${var} in eval. If no variable is found -1, -1 is returned.
*/

// 这个方法负责寻找配置项的值中的「$」「[」「]」符号的位置。
private static int[] findSubVariable(String eval) {
int[] result = {-1, -1};

int matchStart;
int leftBrace;

// scanning for a brace first because it's less frequent than $
// that can occur in nested class names
// 因为 Java 内部类的名字中存在「$」符号,所以实现中并不优先寻找该符号,而是寻找
// 在配置扩展场景中更为常见的「[」符号。这体现了按照场景和统计情况来做优化的思路。
match_loop:

for (matchStart = 1, leftBrace = eval.indexOf('{', matchStart);
// minimum left brace position (follows '$')
leftBrace > 0
// right brace of a smallest valid expression "${c}"
// 配置项至少是 ${c} 这样的形式,也就是说 key 长度至少为 1。
&& leftBrace + "{c".length() < eval.length();
// 类似于 KMP 算法的思路,这里每次往前跳的位数是尽可能大的而不是仅仅一位。
// 这是一个优化的手段。
leftBrace = eval.indexOf('{', matchStart)) {

int matchedLen = 0;
// 如果「{」符号的左边是「$」符号,则表明发现了一个需要扩展的地方。
if (eval.charAt(leftBrace - 1) == '$') {
// 扩展配置项 key 起始的位置。
int subStart = leftBrace + 1; // after '{'

// 寻找 key 起始位置最靠近的右边的「}」符号从而确定 key 的终点。
for (int i = subStart; i < eval.length(); i++) {
switch (eval.charAt(i)) {
case '}':
// 找到了。
if (matchedLen > 0) { // match
result[SUB_START_IDX] = subStart;
result[SUB_END_IDX] = subStart + matchedLen;
// 使用标志点退出大循环。
break match_loop;
}
// fall through to skip 1 char
case ' ':
case '$':
// 发现扩展配置项中还包含可扩展的配置项,开始优先获取该扩展项的 key。
matchStart = i + 1;
continue match_loop;
default:
matchedLen++;
}
}
// scanned from "${" to the end of eval, and no reset via ' ', '$':
// no match!
break match_loop;
} else {
// not a start of a variable
//
matchStart = leftBrace + 1;
}
}

// 最终返回的是符合要求的扩展配置的 key 的开始、结尾在 eval 中的 index。
return result;
}

实际上配置扩展的逻辑并不复杂,用流程图可以总结如下:

State Diagram for Config Expansion

理解一个事物的最佳实践之一就是举一个恰当的例子。我们以扩展下面配置数据中的 config4 为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>config1</name>
<value>R.I.P</value>
<description>starman</description>
</property>
<property>
<name>config2</name>
<value>${config1},David</value>
</property>
<property>
<name>config3</name>
<value>${config2}Bowie</value>
</property>
<property>
<name>config4</name>
<value>${config3}.</value>
</property>
</configuration>

最终按照之前的逻辑扩展完毕后,config4 的值为R.I.P,DavidBowie.

一个需要注意的问题是配置扩展最多允许 20 次迭代,过多的话会保留原样返回。这个是由 Configuration.MAX_SUBST 这个变量来决定的,一般情况下 20 次扩展机会已经绰绰有余了。比较特殊的情况是下面这种循环扩展的情况:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
<property>
<name>foo</name>
<value>foo${bar}</value>
</property>
<property>
<name>bar</name>
<value>${foo}bar</value>
</property>
</configuration>

如果尝试去获取 foo 的值,配置会被扩展 20 次后返回一个包含 ${foo} 的字符串——因为没有使用递归的实现策略,Stack Overflow 的情况并不会发生。除非你十分确定编译器/解释器对你的递归程序有确定行为的优化,严肃的生产环境下一定要谨慎使用递归。

获取配置项的值

通过前面几个部分,实际上我们对获取某个配置项的值的过程实际上已经比较明确了。这里我们再进一步研究一下获得某种类型的配置项的值的过程。我们以 getInt 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
* Get the value of the <code>name</code> property as an <code>int</code>.
*
* If no such property exists, the provided default value is returned,
* or if the specified value is not a valid <code>int</code>,
* then an error is thrown.
*
* @param name property name.
* @param defaultValue default value.
* @throws NumberFormatException when the value is invalid
* @return property value as an <code>int</code>,
* or <code>defaultValue</code>.
*/

public int getInt(String name, int defaultValue) {
String valueString = getTrimmed(name);

if (valueString == null)
return defaultValue;

String hexString = getHexDigits(valueString);
// 是 16 进制的数字,按照 16 进制的方式进行转化。
if (hexString != null) {
return Integer.parseInt(hexString, 16);
}

// 是普通的 10 进制数字,按照普通的方式转化。
return Integer.parseInt(valueString);
}

/**
* Get the value of the <code>name</code> property as a trimmed <code>String</code>,
* <code>null</code> if no such property exists.
* If the key is deprecated, it returns the value of
* the first key which replaces the deprecated key and is not null
*
* Values are processed for <a href="#VariableExpansion">variable expansion</a>
* before being returned.
*
* @param name the property name.
* @return the value of the <code>name</code> or its replacing property,
* or null if no such property exists.
*/

public String getTrimmed(String name) {
// get 方法我们前面已经介绍过了。
String value = get(name);

if (null == value) {
return null;
} else {
// 去除掉字符串两边的空白字符。
return value.trim();
}
}

// 判断并进一步将字符串类型转化为 16 进制形式的字符串以方便进一步转化为 Integer。
private String getHexDigits(String value) {
boolean negative = false;
String str = value;
String hexString = null;

// 如果以「-」符号开头,表示其为负数。
if (value.startsWith("-")) {
negative = true;
str = value.substring(1);
}

// 去掉打头的「0x」或「0X」,只拿数字部分。
if (str.startsWith("0x") || str.startsWith("0X")) {
hexString = str.substring(2);
if (negative) {
hexString = "-" + hexString;
}
return hexString;
}

// 不是 16 进制的数字,返回 null。
return null;
}

我们可以看到整个流程并不复杂,主要是考虑到了一些进制、空白字符等问题。其他类似的方法也是类似的流程。

设置配置项的值

设置配置项的值的接口和获取配置项的值的接口类似,存在一个通用的 set 方法和类型特有的类似与 setInt 这样的方法。我们先研究 set 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* Set the <code>value</code> of the <code>name</code> property. If
* <code>name</code> is deprecated, it also sets the <code>value</code> to
* the keys that replace the deprecated key. Name will be trimmed before put
* into configuration.
*
* @param name property name.
* @param value property value.
* @param source the place that this configuration value came from
* (For debugging).
* @throws IllegalArgumentException when the value or name is null.
*/

public void set(String name, String value, String source) {
// 先做必要的参数检测。
Preconditions.checkArgument(
name != null,
"Property name must not be null");
Preconditions.checkArgument(
value != null,
"The value of property " + name + " must not be null");

// 去除掉两端多余的空白字符。
name = name.trim();

// 获取废弃掉的配置项的新旧 key 对照表。
DeprecationContext deprecations = deprecationContext.get();
if (deprecations.getDeprecatedKeyMap().isEmpty()) {
getProps();
}

// 在 this.overlay 和 this.properties 中分别写入配置项。
getOverlay().setProperty(name, value);
getProps().setProperty(name, value);

// 如果必要,设置默认 source。
String newSource = (source == null ? "programatically" : source);

if (!isDeprecated(name)) {
// 配置项没有废弃掉,则记录其来源。
updatingResource.put(name, new String[] {newSource});

// 有可能该配置项存在其他名字但意义等同的配置项,存在的话
// 在一一将其设置为现在要设置的值。
String[] altNames = getAlternativeNames(name);
if(altNames != null) {
for(String n: altNames) {
if(!n.equals(name)) {
getOverlay().setProperty(n, value);
getProps().setProperty(n, value);
updatingResource.put(n, new String[] {newSource});
}
}
}
}
else {
// 如果是已经废弃掉的 key,则将其替换为新的对应的 key 再写入一遍。
String[] names = handleDeprecation(deprecationContext.get(), name);
String altSource = "because " + name + " is deprecated";
for(String n : names) {
getOverlay().setProperty(n, value);
getProps().setProperty(n, value);
updatingResource.put(n, new String[] {altSource});
}
}
}

在前面的部分我们介绍过 overlay 变量保存了通过 set 方法设置的配置项的值,并保证最终获取配置项的时候,通过 set 方法设置的值不会被配置资源中原有的该配置项的值覆盖掉。

接下来我们去了解一下为某个类型定制的 set 方法。我们以 setInt 为例:

1
2
3
4
5
6
7
8
9
/**
* Set the value of the <code>name</code> property to an <code>int</code>.
*
* @param name property name.
* @param value <code>int</code> value of the property.
*/

public void setInt(String name, int value) {
set(name, Integer.toString(value));
}

这里的情况比 getInt 要简单:直接将 value 转化为正确的 String 类型后再调用 set 方法即可。

至此,Configuration 核心部分的逻辑及实现细节我们已经总结完毕了,剩下的类似于「替换废弃掉的 key」这样的非主要部分的实现,读者可以自行前往源码处进一步了解。复杂的软件并不是玄而又玄的东西,深入地去了解,它们实际上都是由很简单的思维、方法、实践组合、发展起来的

如果您作为读者有什么疑问、错误需要讨论、指出的话,欢迎留言。