Skip to content

Latest commit

 

History

History
1015 lines (634 loc) · 63.8 KB

HOCON.md

File metadata and controls

1015 lines (634 loc) · 63.8 KB

Table of Contents generated with DocToc

HOCON(人性化配置对象表示法,Human-Optimized Config Object Notation)

这并非正式文档,不过我觉得我讲得比较清楚了。

目标/背景

HOCON 的主要目标是:保证 JSON 的语义(如树形结构;类型集合;编码/转义等)的同时,作为一个供人类编辑的配置文件,使其编辑起来更方便。

我们为方便编辑添加了以下新特性:

  • 减少格式符等不必要的噪音
  • 允许配置文件中的一处引用另一处(设置一个值为另一个值)
  • 允许在当前配置文件中导入或包含其他配置文件
  • 提供到 Java 等处使用的扁平化 properties 列表格式的映射
  • 允许从环境变量中取值
  • 允许写注释

实现上,这一格式需要满足以下特征:

  • JSON 格式的超集,也就是说所有有效的 JSON 格式都应该是合法的,同时在内存中的解析结果应与 JSON 解析器的输出一致。
  • 结果唯一;换言之,即使 HOCON 格式本身非常灵活,但它也不应模棱两可。HOCON 应该划定哪些形式是不合法的,不合法的形式在解析时应该报错。
  • 解析时探测的字符应该尽可能少;换言之,对 HOCON 格式配置的 Tokenize 操作不应依赖超过三个字符的探测。(目前探测三个字符的唯一原因只是探测“//”开头的注释;不然的话只需探测两个字符就够了。)

HOCON 比 JSON 难描述也难解析得多。想象一下一些维护配置文件的工作,本来是人类负责,结果被转移到机器负责,会发生什么。

定义

  • 我们使用 键(key) 代指 JSON 中:左侧的字符串部分,而 值(value) 代指 JSON 中:右侧的部分。比如:对象的一个 键值对(field) 的两部分。
  • 我们使用 值(value) 代指 JSON 规范中被定义为“value”的东西,以及本规范中定义的未加引号的字符串和引用等。
  • 我们使用 简单值(simple value) 代指对象和数组以外的所有值。
  • 我们使用 键值对(field) 代指一个键、一个诸如“:”等形式的分隔符、和一个值的排列。
  • 当我们使用 文件(file) (正在被解析的文件)这一代称时,可能正在被解析的是一串字节流,不一定总是文件系统中的字面意思上的文件。

语法

这部分的大量内容都一定程度上借用了 JSON 的相关概念;当然你可以在https://json.org/json-zh.html找到 JSON 的语法规范。

和 JSON 一样的地方

  • 文件必须是合法的 UTF-8 格式
  • 加引号的字符串格式和 JSON 中的字符串相同
  • 值类型可以是:字符串、数值、对象、数组、布尔值、以及空(null)
  • 允许的数字格式和 JSON 相同;在 JSON 中一些可能的浮点数值,如 NaN 等,是不允许出现的

注释

所有以 //# 开头,并以一个新的换行符结尾的部分将被视作注释处理,除非 //# 出现在了加引号的字符串中。

对根结构更宽松的要求

JSON 格式要求根结构必须为数组或者对象。空文件不合法,只含有字符串等既不是数组也不是对象的元素的文件,也不合法。

HOCON 文件如果不以方括号或花括号开头,那么它将以被 {} 包围的方式解析。

一个省略了开头 { 却没有省略结尾 } 的 HOCON 文件不合法;HOCON 格式要求括号必须匹配。

键值分隔符

字符 = 可以被用在所有 JSON 要求使用 : 的场合,例如:用于分隔键值。

如果一个键随后的字符为 {,那么中间的 = 可以省略。也就是说,"foo" {}"foo" : {} 是一样的。

逗号

对于数组里的值,以及对象里的键值对,只要它们之间有至少一个 ASCII 回车(\n,ASCII 码为 10)分隔,那么逗号就是可有可无的。

最后一个数组里的元素后,或者最后一个对象里的键值对后,可以跟一个逗号。多出来的逗号将被忽略。

  • [1,2,3,][1,2,3] 代表同一个数组。
  • [1\n2\n3][1,2,3] 代表同一个数组。
  • [1,2,3,,] 不合法,因为它在最后有两个逗号。
  • [,1,2,3] 不合法,因为它在开头有一个逗号。
  • [1,,2,3] 不合法,因为两个元素中间有两个逗号。
  • 上面针对逗号的规则同样适用于对象。

空白

JSON 语法规范只简单提到了“空白”("whitespace")一词;在 HOCON 中,“空白”定义如下:

  • 任何 Unicode 中的空格符(Zs 分类下字符)、换行符(Zl 分类)、或分段符 (Zp 分类),包含不换行空格(例如 0x00A0、0x2007 和 0x202F)。 字节顺序记号(BOM,0xFEFF)也必须视作空白。
  • 制表符(\t,0x0009)、换行符(\n,0x000A)、垂直定位(\v, 0x000B)、换页符(\f,0x000C)、回车符(\r,0x000D)、 文件分隔符(0x001C)、分组符(0x001D)、记录分隔符(0x001E) 和单元分隔符(0x001F)。

在 Java 中,isWhitespace() 方法可以覆盖除了不换行空格和 BOM以外的上述所有字符。

尽管所有 Unicode 中定义的分隔符都应视作空格,本规范中所称“换行符”("newline")指且仅指 ASCII 换行符 0x000A。

重复键与对象合并

JSON 规范中并没有明确同一个对象下重复键的处理方式。在 HOCON 中,重复的键的处理方式是以后来者为准,即后出现的键的值覆盖先前出现的;但如果重复的键对应的值都是对象,那么两个对象会合并在一起。

注意:如果你假定 JSON 中重复的键有特定行为,HOCON 将不再是 JSON 的超集。本规范中假定 JSON 不允许重复的键。

合并对象的过程如下:

  • 将两个对象中其中一个下的所有键值对加入另一个中。
  • 对于两个对象中的同名非对象键值对,必须使用第二个对象中的键值对。
  • 对于两个对象中的同名对象键值对,须遵循一样的规则递归合并。

对象的合并可以通过预先给键赋另外一个值来避免。这是因为,合并总是围绕两个值进行的。如果你先给一个键赋值为对象,然后赋一个非对象的值,接着再赋值为另一个对象,那么首先第一个对象会被那个非对象的值覆盖(非对象总是会覆盖对象),然后第二个对象将原本的值覆盖掉(没有合并,直接赋值)。因此两个对象之间什么事也没有发生。

下面两段 HOCON 是等价的:

{
    "foo" : { "a" : 42 },
    "foo" : { "b" : 43 }
}

{
    "foo" : { "a" : 42, "b" : 43 }
}

下面两段 HOCON 也是等价的:

{
    "foo" : { "a" : 42 },
    "foo" : null,
    "foo" : { "b" : 43 }
}

{
    "foo" : { "b" : 43 }
}

注意中间为 "foo" 赋值为 null 的操作阻止了对象的合并。

不加引号的字符串

不被引号括起来的字符串序列(unquoted string),在符合下列条件时,会被认为是字符串值:

  • 不包含下列“非法字符”:'$'、'"'、'{'、'}'、'['、']'、':'、'='、','、'+'、'#'、'`'、'^'、'?'、'!'、'@'、'*'、'&'、'\'(反斜杠)、或者空白。
  • 不包含由两个正斜杠组成的字符串"//"(它代表注释的开始)。
  • 其开头没有被解析为 truefalsenull 或数字。

不加引号的字符串将按其字面值解析,也就是说不支持转义。如果你想要使用特殊字符,而这种特殊字符不允许在不加引号的字符串中出现的话,那你或许总是要加上引号的。

truefoo 将被解析面一个布尔值 true 跟随着一个字符串 foo。不过,footrue 将被解析成不加引号的 footrue。类似的情况还有,10.0bar 将被解析成数值 10.0 和不加引号的 bar 的组合,而 bar10.0 将被解析成不加引号的 bar10.0。(实际情况是,由于值连结的存在,这种区别无关紧要;请看后续章节。)

通常情况下,不加引号的字符串将在出现"//"这种两字符字符串,或者不允许在不加引号的字符串中出现的字符串结束。在其中(非开头)出现的布尔值,空值(null),以及数值等将不会被特殊对待,而是被看作字符串的一部分。

不加引号的字符串不能以数字 0 到 9 或连字符(-,0x002D) 开头,因为它们作为 JSON 数值开头是合法的。开始的数字字符以及随后的所有在 JSON 中作为数值合法的字符,都一定会被解析成数值。再强调一次,这种字符在不加引号的字符串 中间 是不被特别对待的;只有在开头出现的情况才会被按照数字解析。

JSON 中被引号括起来的字符串不允许包含控制字符(一些控制字符同时作为空白字符使用,如换行符等)。JSON 规范规定了这一行为。不过,对于不加引号的字符串,没有针对控制字符的限制,除非控制字符同时是上面提到的不允许出现的字符。

上面提到的字符不允许出现,一部分是由于它们在 JSON 或 HOCON 中已经有其含义,另一部分作为保留字使用,以方便未来扩展这一规范。

多行字符串

和 Python 以及 Scala 等语言类似,多行字符串使用三个引号。如果在解析时解析到了 """ 三个字符的序列,那么在下一个用作闭合字符序列的 """ 出现之前,其中所有 Unicode 字符都将被不加修改地用作字符串值的组成部分。不管是空格还是换行符,都不作特殊处理。和 Scala 的处理方式,以及 JSON 对待被引号括起来的字符串的处理方式不同,转义符在被三个引号括起来的字符串中不作处理。

在 Python 中,诸如 """foo"""" 的形式会导致语法错误(三个引号的字符串序列后紧跟着一个悬空引号)。在 Scala 中,这种形式将被看作由四个字符组成的字符串 foo"。HOCON 的解析方式和 Scala 类似;序列中的最后三个引号被看作多行字符串的闭合字符序列,而所有“多出来的”引号将被看作多行字符串的一部分。

值连结

对象中键值对的值或者数组元素可能表现为多个合在一起的值的组合。有三种值连结的方式:

  • 所有简单值(不含对象或数组)的组合为字符串(字符串值连结)。
  • 所有数组的组合为合并后的单个数组(数组值连结)。
  • 所有对象的组合为合并(考虑相同键)后的单个对象(对象值连结)。

除键值对的值和数组元素外,键值对的键也支持字符串值连结。对于键值对的键来说,对象或数组的组合没有意义。

注意:Akka 2.0(因此也包括 Play 2.0)针对配置文件的内置实现不支持针对数组或对象的值连结;其支持的只有字符串值连结。

字符串值连结

字符串值连结保证了未加引号的字符串正常工作;字符串值连结同时提供了对引用(诸如 ${foo} 的形式)的支持。

字符串值连结只允许简单值的组合。再次强调简单值的定义为除数组和对象外的其他类型值。

只要简单值仅由换行符之外的空白分隔,那么 它们之间的空白就会被保留,使值与空白连结组成一个字符串。

字符串值连结将不会跨过换行符,或者任何不属于简单值的字符。

所有字符串可能出现的地方都可能出现字符串值连结,比如说对象的键和值以及数组元素。

无论何时,如果一个值本当出现在 JSON 中,那么一个 HOCON 解析器会在对应位置尝试收集多个值(包括它们之间的空白),并将这些值连接成一个字符串。

在第一个值前或最后一个值后的空白将会被忽略。只有值 之间 的空白会被保留。

所以比如说 foo bar baz 会被解析成三个未加引号的字符串,然后这三个字符串会被连结成一个字符串。中间的空白将会被保留,但是两边的空白会被去除。因为等价的,被引号括起来的字符串形式为 "foo bar baz"

值连结后的 foo bar(两个未加引号的字符串以及中间的空白)和被引号引用的 "foo bar" 在解析后内存里的形式是一样,都是七个字符的字符串。

为保证字符串值连结,非字符串类型的值将会以以下规则转换成字符串(以下转换结果使用被引号引用的形式):

  • truefalse 分别转换为 "true""false"
  • null 转换为 "null"
  • 加或不加引号的字符串转换到其本身。
  • 数值应转换为其在文件中原有的形式。比如说如果一个解析器尝试解析 1e5,那么解析形式可能还会有包含有字母 E1E5 或者 100000。为了保证字符串值连结,解析时应保持其在文件中原有的形式。
  • 引用将会被替换成其对应值,然后再按照上面的规则转换成字符串。
  • 字符串值连结中出现数组或对象是不合法的。

单个值不应转换成字符串。换言之,如果你试图使用值连结的方式对待 true 本身,那么解析结果就会出错;因为解析时应该当作布尔值对待。只有诸如 true footrue 后面同一行跟着另一个简单值)的形式才能以值连结的方式解析并转换成字符串。

数组值连结和对象值连结

数组可以和数组之间值连结,对象也可以和对象之间值连结,但如果混着来就会出错。

为保证值连结,“数组”同时也包括“值为数组的引用”,同时“对象”同时也包括“值为对象的引用。”

在键值对的值或数组元素中,如果第一个数组或对象或引用的末尾,以及第二个数组或对象或引用的开头,只有换行符之外的空白分隔,那么两个值将会进行值连结。

对于对象来说,“连结”意味着“合并”,因此后一个值将会覆盖前一个。

不管是否存在值连结,数组和对象都不能成为键值对的键。

下面的几种方式定义的对象 a 是完全等价的:

// one object
a : { b : 1, c : 2 }
// two objects that are merged via concatenation rules
a : { b : 1 } { c : 2 }
// two fields that are merged
a : { b : 1 }
a : { c : 2 }

下面的几种方式定义的数组 a 是完全等价的:

// one array
a : [ 1, 2, 3, 4 ]
// two arrays that are concatenated
a : [ 1, 2 ] [ 3, 4 ]
// a later definition referring to an earlier
// (see "self-referential substitutions" below)
a : [ 1, 2 ]
a : ${a} [ 3, 4 ]

一种常见的对象值连结用法和“继承”类似:

data-center-generic = { cluster-size = 6 }
data-center-east = ${data-center-generic} { name = "east" }

一种常见的数组值连结用法被用于文件路径集合:

path = [ /bin ]
path = ${path} [ /usr/bin ]

注意:引用之间含有空白的值连结

如果你试图使用 ${foo} ${bar} 等形式连结两个引用,那么被连结的引用可能会转换成字符串(这使得其之间的空白十分重要),可能会转换成对象或者列表(在这里其之间的空白无关紧要)。对于其值为对象或者列表的引用,其之间的空白应该被忽略。如果空白被引号括了起来,将产生语法错误。

注意:不含有逗号或换行符的数组

在数组中,你可以使用换行符代替逗号,不过你不能使用空格代替逗号。因此换行符之外的空白将导致数组元素值连结而不是数组元素值分隔。

// this is an array with one element, the string "1 2 3 4"
[ 1 2 3 4 ]
// this is an array of four integers
[ 1
  2
  3
  4 ]

// an array of one element, the array [ 1, 2, 3, 4 ]
[ [ 1, 2 ] [ 3, 4 ] ]
// an array of two arrays
[ [ 1, 2 ]
  [ 3, 4 ] ]

如果你对此感到迷惑,你应该用一用逗号。在下面的情况下,值连结行为是足够有用的,而不是令人惊讶的:

[ This is an unquoted string my name is ${name}, Hello ${world} ]
[ ${a} ${b}, ${x} ${y} ]

换行符之外的空白不会被用作元素和键值对的分隔符。

路径表达式

路径表达式(path expression)被用于表示对象树中的一个路径。一些诸如 ${foo.bar} 等使用引用的场合,以及诸如 { foo.bar : 42 } 等使用键值对的键的场合会用到路径表达式。

路径表达式在语法上与值连结相同,但不会包含引用。这意味着你不能在引用中使用引用,以及你也不能在键值对的键中使用引用。

在路径表达式中,被引号括起来的字符串外的 . 被当作分隔路径的分隔符处理,而被引号括起来的字符串内的 . 不作特殊处理。因此 foo.bar."hello.world" 代表一个有三个组成部分的路径表达式,前两个分别是 foobar,最后一个是 hello.world

需要注意的一点是,数字之间的 . 将被当作分隔符处理。因此如果将数字作为路径表达式的一部分进行处理,那么必须将其以文件中出现的 原始 字符串表示形式处理(而不是使用一些通用的函数将其从数字转换回字符串)。

  • 10.0foo 表现为一个数字和一个不加引号的 foo 的连结因此应以 100foo 两个元素的方式解析。
  • foo10.0 表现为一个包含有 . 的不加引号的字符串因此应以 foo100 两个元素的方式解析。
  • foo"10.0" 表现为一个不加引号的和一个加引号的字符串的连结因此应以单个元素的方式解析。
  • 1.2.3 应以表现为 12、和 3 三个元素的组合方式解析。

和值连结不同,路径表达式应 总是 被转换成字符串,即使其只代表一个值。

如果在解析时遇到一个数组,其中一个元素的值为单个 true,那么解析时应当作值连结的方式处理,也就是应以布尔值的方式处理。

如果在解析(键值对的键或者引用)时遇到一个路径表达式,那么其应总是当作字符串处理,因此 true 应被当作一个字符串,其被引号括起来的形式是 "true"

如果路径表达式是空字符串,那么它应永远被引号括起来。换言之,a."".b 代表一个有着三个元素的路径表达式。不过,a..b 是不合法的,并应在解析时报错。按照这样的规则,所有在开头或者结尾时出现 . 的路径表达式,都应被当作不合法的情况在解析时报错处理。

作为键的路径表达式

如果一个键同时也是一个包含有多个元素的路径表达式,那么在解析时除最后一个元素外的所有元素都将被展开成对象。路径的最后一个元素与值结合,从而最后形成嵌套对象中的一个键值对。

换言之:

foo.bar : 42

和:

foo { bar : 42 }

是等价的。以及:

foo.bar.baz : 42

和:

foo { bar { baz : 42 } }

也是等价的。对象的值会进行合并;也就是说:

a.x : 42, a.y : 43

和:

a { x : 42, y : 43 }

是等价的。因为路径表达式和值连结类似,所以说你可以在键值对的键中使用空格,比如说:

a b c : 42

和:

"a b c" : 42

是等价的。此外,因为路径表达式总是被转换成字符串,因此即使是拥有其他类型含义的单个值,也会被转换成字符串类型。

  • true : 42"true" : 42 等价
  • 3 : 42"3" : 42 等价
  • 3.14 : 42"3" : { "14" : 42 } 等价

有一条特殊的规则,就是不加引号的 include 如果被用于键值对的键,那么它不能作为路径表达式的开头,因为其有特殊含义(见后续章节)。

引用

引用(substitution)配置文件树中的其他部分是 HOCON 允许的一种形式。

引用的语法是这样的:${pathexpression}${?pathexpression}。其中,pathexpression 便是上文中提及的路径表达式。这里用到的路径表达式的语法与用作对象的键的语法是一样的。

${?pathexpression} 中的 ? 前不能有空格。换言之,使用这种形式的引用时,${? 必须原样组合在一起使用。

某个实现可以通过查询系统环境变量或其他外部配置来解析在配置树中没有找到的引用。(关于环境变量的细节将在后文中阐述。)

引用不会尝试解析包含在其中的加引号的字符串。如果你需要在字符串中使用引用,你必须使用值连结把引用和不加引号的字符串连接起来:

key : ${animal.favorite} is my favorite animal

你也可以用引号把非引用部分括起来:

key : ${animal.favorite}" is my favorite animal"

引用通过查询整个配置来解析。路径从根对象开始解析,换言之路径是绝对路径,而非相对路径。

引用处理是解析的最后一步,所以引用也可以向后查询。如果一个配置包含了多个文件,最终引用还可以解析到别的文件上去。

如果一个键出现了多次,引用只会解析到最后一次出现的值(换言之,它会解析到所有包含的文件中该键的最终赋值,或最终合并出来的对象)。

如果有一个选项设定为 null,那么解析它的键时就永远不会从外部来源中解析。不幸的是,这个操作是不可逆的;如果你的根对象中有类似 { "Home" : null } 的东西,那么解析 ${HOME} 就永远不会解析到系统环境变量上去。换言之,HOCON 中没有等价于 JavaScript 的 delete 的操作。

若引用无法匹配到任何配置中出现的值,同时也不能通过外部来源解析成任何值,那么这个引用会成为未定义引用。以 ${foo} 形式出现的未定义引用是非法的,应当按照错误处理。

若形如 ${?foo} 的引用没有定义:

  • 如果这是某个对象里的键值对,此引用不应产生新的键值对。如果此键值对会覆盖之前设定的相同键值对,则保留之前的值。
  • 如果这是某个数组元素,那么此引用不应导致新元素加入数组中。
  • 如果这是值连结的一部分,同时被值连结的另一个值是字符串,那么这个未定义引用会变成空字符串;如果被值连结的另一个值是对象或数组,则相应地变为空对象或空数组。
  • 对于 foo : ${?bar} 来说,在 bar 未定义时,foo 这个键不会存在。对于 foo : ${?bar}${?baz} 来说,如果 barbaz 没有定义,那么 foo 这个键不会存在。

引用只能用于键值对的值或数组元素(值连结)中,不能用于键名,亦不能嵌入路径表达式等其他引用中。

引用会被任意一种值类型(数字、对象、字符串、数组、truefalsenull)替换。如果最终值只由引用组成,值类型会保留;否则,会通过值连结组成字符串。

自引用

总的来说:

  • 通常情况下,引用将“向前看”,并将其最终值用于其路径表达式
  • 如果会产生循环,如果可能,循环应通过向后看打破(从而移除了引用循环中的一条引用链)

通过这种方式我们得以允许基于键值对的旧值设置新值:

path : "a:b:c"
path : ${path}":d"

自引用键值对 指:

  • 其值或其值连结的一部分包含一个到自身值的引用
  • 键值对的值引用了一个已有定义的键值对,该键值对中直接或间接包含了一个最终引用回自身值的引用

自引用键值对的示例:

  • a : ${a}
  • a : ${a}bc
  • path : ${path} [ /usr/bin ]

需注意的一点是,如果一个数组或对象中的值含有一个指向自身值的引用,在解析时将 考虑自引用键值对的相关规则。也就是说,以下情况相关规则 作考虑:

  • a : { b : ${a} }
  • a : [${a}]

这种形式的循环应该直接在解析时报错。(假设允许“向前看”的话,一些诸如 a={ x : 42, y : ${a.x} } 的形式会在解析 ${a.x} 时试图解析不存在的 a。)

可能的实现有:

  • 尝试解析引用的行为会试图检索整个文件中对应的路径,如果检索时发现路径对应节点是引用的父节点,那么解析时便会感知到循环。
  • 尝试解析可能会出现自引用的引用(其值或其值连结的一部分包含一个引用)时,将该键值对以及会对其产生覆盖的所有键值对移除。

最简单的实现形式会在解析时将循环当作不存在的引用处理;比如说在解析 a : ${a} 时,你会首先把 a : ${a} 本身移除然后再解析 ${a},也就是在一个空文件中检索对应的 ${a} 的值。更复杂一点的做法是在被移除的键值对处添加一个标记符,从而在发现循环时产生可读性更高的错误信息。然后,在回到标记符对应的引用本身时报错。

对于可选引用(诸如 ${?foo} 的形式)来说,对待循环的解析方式应同样按照不存在的值处理。如果 ${?foo} 引用了自身,那么解析时就应该当作不存在的值处理。

键值分隔符 +=

除了 :=,键与值之间还可以用 += 分割。使用 += 分隔的键值对会令值变为自引用数组,例如:

a += b

会变成:

a = ${?a} [b]

+= 起到了在数组结尾追加元素的作用。如果 a 之前的值不是数组,它会产生和 a = ${?a} [b] 一样的报错。注意,a 的值不一定必须存在(${?a} 而非 ${a}),换言之 a += b 这样的声明可以是全文件中第一次出现 a 的地方(即不需要 a = [] 这样的显式声明)。

注意:Akka 2.0(因此也包括 Play 2.0)针对配置文件的内置实现不支持 +=

自引用举例

在没有合并的情况下,自引用的键值对是非法的,因为其具体值无法解析:

foo : ${foo} // an error

然而,当 foo : ${foo}foo 之前的值合并时,这个引用就能解析到之前的值上。合并对象时,覆盖键值对中的引用指向被覆盖的键值对。例如:

foo : { a : 1 }

在它之后又有:

foo : ${foo}

此时 ${foo} 会解析为 { a : 1 },即被覆盖的键值对的值。

如果两者顺序颠倒一下,就会产生错误。比如:

foo : ${foo}

在它之后又有:

foo : { a : 1 }

在这里 ${foo} 自引用出现在了 foo 被赋值之前,所以此时的 foo 是没有定义的,无异于引用一个整个文件中没有定义的路径。

概念上来说,foo : ${foo} 是需要查找 foo 之前的定义以决定具体的解析结果的,所以它的报错应当是“没有定义(undefined)”而非“不可跳出的循环引用(intractable cycle)”。也因此,使用可选引用(optional substitution)即可避免循环引用的问题:

foo : ${?foo} // 这个键会静静地消失

如果引用被无法合并的值(非对象的值)隐藏起来了,那么它就不会被解析,也因此不会报错。例如:

foo : ${does-not-exist}
foo : 42

在这个情况下,不管 ${does-not-exist} 解析结果如何,我们都能确定 foo42,所以 ${does-not-exist} 不会被解析,也因此不会产生任何错误。对于形如 foo : ${foo}, foo : 42 这样的循环引用也是如此——第一个循环引用会被直接忽略。

即便是出现在路径表达式中的自引用,它也会解析到“下一层”的值上去。举例说明:

foo : { a : { c : 1 } }
foo : ${foo.a}
foo : { a : 2 }

在这里,${foo.a} 会指向 { c : 1 } 这个对象,而非 2 这个数字,所以最终 foo 的值会是合并之后的 { a : 2, c : 1 }

回想一下,自引用键值对必须使用引用或值连结来确定最终值。举个例子,如果键值对的值是对象或数组,那么即使在这个对象或数组中有对这个键值对的引用,它也不会算作自引用。

HOCON 的实现必须小心对待在对象中引用自己的路径的情况,举例:

bar : { foo : 42,
        baz : ${bar.foo}
      }

在种情况下,如果某个实现选项将解析整个 bar 对象的过程作为解析引用 ${bar.foo} 过程的一部分,那么就会产生循环引用。这种情况下,HOCON 的实现应当只尝试解析 bar 对象 foo 中的 foo,而非整个 bar 对象。

因为没有循环继承,引用有必要“向前解析”(包括查找正在定义中的键值对)以确定解析结果。举例说明:下面的 HOCON 中,bar.baz 最终会解析成 43

bar : { foo : 42,
        baz : ${bar.foo}
      }
bar : { foo : 43 }

相互引用的对象也是成立的,同时也不会认为是自引用(因为也会“向前解析”):

// bar.a should end up as 4
bar : { a : ${foo.d}, b : 1 }
bar.b = 3
// foo.c should end up as 3
foo : { c : ${bar.b}, d : 2 }
foo.d = 4

另一种极端情况是值连结中的可选自引用,下面的 HOCON 中 a 首先会被解析为 foo 而非 foofoo,因为自引用会“向后解析”并成功解析没有定义的 a

a = ${?a}foo

总体上来说,解析引用的实现应当:

  • 对引用目标惰性求值,避免“循环引用引发的副作用”
  • “向前解析”,并以路径解析出的最终值作为引用的值
  • 出现循环时,应“向后解析”,通过合并等方式解决循环
  • 若惰性求值和“向后解析”均无法跳出循环,引用应视为未定义并产生错误,除非使用可选引用 ${?foo} 的语法。

举例,下列 HOCON 无法解析:

bar : ${foo}
foo : ${bar}

像是这样由多个键值对组成的循环也应能识别为非法 HOCON:

a : ${b}
b : ${c}
c : ${a}

在某些情况下,解析结果依赖于解析顺序,但具体解析顺序没有定义时,就会产生未定义行为。例如:

a : 1
b : 2
a : ${b}
b : ${a}

HOCON 的实现可以在“ab 都解析为 1”、“都解析为 2”或者产生错误三种行为之间选择。理论上,这种情况应当产生错误,但这种行为可能会很难实现。令这种行为有确定结果一定需要有序表而非无序表的支撑,这也会制造一些限制。理论上,HOCON 的实现只需要追踪相同键值对的重复实例(即合并)。

然而,HOCON 的实现必须选择将 ab 解析为相同的值。在实践中,这意味着所有的引用都必须存储起来(只解析一次,保存解析结果)。存储解析的方式应当以引用它本身为键,而非 ${} 表达式中的路径,因为根据其所在文件中的位置不同,稍后的解析结果可能会有所差异。

跨文件引用

跨文件引用语法

跨文件引用声明 由未加括号的 include 和随后的空白符及之后的:

  • 单个被引号 括起来的 字符串。这种声明会被启发式地解释为 URL,文件名或 classpath 中的相关资源。
  • url()file()、或者 classpath() 括起来的加了引号的字符串。这种声明会被分别解析为 URL,文件名或 classpath。和 CSS 等情况不同的是,其中的字符串必须使用引号括起来。
  • required() 括起来的上述情况之一。

跨文件引用声明应用于原为键值对的地方。

如果 include 出现在一个路径表达式的开头,而该路径表达式本身作为对象的键存在,那么它将不会被以路径表达式或者键的方式解析。

作为替代键值对的声明,include 后必须跟随一个被引号 括起来的 字符串,或者一个被引号括起来,又被 url()file()、或者 classpath() 括起来的字符串。该字符串值被称为 资源名称

总的来说,include 以及其后的资源名称被用于原为键值对的地方,因此语法上,跨文件引用声明应通过逗号(如果有换行符的话逗号可以省略)和其他键值对分隔。

如果 include 出现在对象的键的位置,而随后没有出现被引号括起来的字符串或者 url("")/file("")/classpath("") 等形式,那么这种声明是不合法的,从而在解析时应该报错。

include 和资源名称之间可以有任意多的空白,包括换行符。对于 url() 等声明形式,() 内(以及引号外)同样允许出现空白。

include 后或 url() 等形式中,不允许使用值连结。其值只能使用被引号括起来的字符串形式。引用形式也不被允许,换言之,除被引号括起来的字符串,其他情形都不被允许。

在对象的键的开始位置之外的 include 没有特殊意义。

include 可以出现在对象的键的声明中:

# this is valid
{ foo include : 42 }
# equivalent to
{ "foo include" : 42 }

或者作为对象或者数组的值:

{ foo : include } # value is the string "include"
[ include ]       # array of one string "include"

如果你想使用以 "include" 开头的字符串作为对象的键,你可以将其括起来,也就是 "include" 的形式,只有不加引号的 include 是特殊的:

{ "include" : 42 }

注意:Akka 2.0(因此也包括Play 2.0)针对配置文件的内置实现不支持 url()/file()/classpath() 形式的跨文件引用。相应的实现只支持启发式的 include "foo" 等形式。

跨文件引用语义:合并

我们定义 文件引用者(including file) 为跨文件引用声明的文件,同时定义 被引用文件(included file) 为跨文件引用声明中的值对应的文件。(文件引用者和被引用文件不一定总是文件系统中的文件,不过这里我们先假设它们都是。)

被引用文件必须包含一个对象,而不是数组。这很重要,因为不管是 JSON 还是 HOCON 都允许数组或者对象作为文件的根节点。

如果被引用文件包含了一个数组,那么跨文件引用声明就是不合法的,也就是说解析时会报错。

被引用文件会被解析成一个根对象。根对象的键在概念上代替了文件引用者中的跨文件引用声明。

  • 在跨文件引用声明前出现的键值对将被覆盖或者合并,其行为和一个文件中同时出现两个相同键值对的行为等同。
  • 在跨文件引用声明后重复出现的键值对,将会覆盖或者合并被引用文件中的键值对。

跨文件引用语义:引用

被引用文件中的引用会使用两种策略在文件中检索;首先会检索被引用文件本身的根节点;然后再检索文件引用者的根节点。

再次强调一点,引用的解析发生在语法分析 ,解析的最后阶段。对于引用的解析应该针对所有文件,而不应将文件隔离开来。

因此,一个包含有引用的被引用文件在解析时必须将相对于被引用文件本身的引用路径“调整”成文件引用者决定的根节点的相对路径。

我们选取这样一个文件引用者作为示例:

{ a : { include "foo.conf" } }

然后“foo.conf”看起来是这样的:

{ x : 10, y : ${x} }

如果你对“foo.conf”单独解析的话,那么 ${x} 的值将被解析成 x 路径对应的 10。如果你在一个对象中,键为 a 的地方引用了“foo.conf”,那么相应的路径应该被调整成 ${a.x} 而不是 ${x}

如果文件引用者重新定义了 a.x,如下所示:

{
    a : { include "foo.conf" }
    a : { x : 42 }
}

那么“foo.conf”中被调整成 ${a.x}${x},在解析时将会检索到 42 而不是 10 这一数值。因为引用的解析位于语法分析

不过,被引用文件本身可能会大量出现引用文件以外的值的情况。比如说引用一个系统环境变量的值,或者说某些文件中的对应值。因此单单解析被调整后的路径不总是够用的,你还需要解析原本的路径。

跨文件引用语义:不存在的文件和强制要求的文件

默认情况下,如果文件引用者试图引用一个不存在的文件,那么该引用本身应该被静默忽略(就像被引用文件本身代表一个空的对象一样)。

但如果被引用文件本身被强制要求存在,同时跨文件引用声明使用了 required(),那么在解析不存在的被引用文件时应该报错。

合法的声明格式包括

include required("foo.conf")
include required(file("foo.conf"))
include required(classpath("foo.conf"))
include required(url("http://localhost/foo.conf"))

等。其他类型的 IO 错误在解析时按理说也不应忽略,不过相应的实现需要在这方面权衡,也就是说在解析时决定将其作为一个可忽略的文件处理,还是决定提醒用户报错。

跨文件引用语义:文件类型及格式

HOCON 的相应实现可能会支持引用其他类型的文件。支持的其他类型必须和 JSON 类型系统兼容,或者说能够提供到 JSON 类型系统的映射。

若相应实现支持多类型文件的跨文件引用,跨文件引用声明中的文件后缀名有可能会被省略:

include "foo"

如果未加后缀名,那么解析时应将其当作文件名的前缀对待,并试图添加所有的已知后缀然后试图加载文件。

如果满足条件的文件存在有多个,那么它们应该被 全部 加载,然后合并到一起。

HOCON 格式的文件总是应该被最后解析。JSON 格式的文件应该作为倒数第二个文件解析。

换言之,include "foo" 可能和:

include "foo.properties"
include "foo.json"
include "foo.conf"

等价。对于以 classpath 为来源的资源,基于文件后缀名的相应规则同样适用。

对于 URL 来说,跨文件引用声明中不允许不含有文件后缀名;你只能使用整个未加删减的 URL。相应的解析方式可能由返回数据的 Content-Type 决定,或者当 Content-Type 不存在时使用文件后缀名决定。使用 file: 格式的 URL 同样要求如此。

跨文件引用语义:资源定位

启发式的检索将会在声明中未出现url()file()、或classpath()时进行。启发式的检索策略如下:

  • 如果相应字符串是一个已知协议的合法 URL,则按 URL 处理。
  • 否则,按“与之相邻”的相同类型文件或者其他资源处理。“与之相邻”以及该字符串本身的含义,不同类型的资源有着不同的定义。
  • 如果你使用 JVM,同时跨文件引用声明的被引用文件本身不能通过合法的 URL 或者“与之相邻”的资源等方式解析,相应实现可能会当作来自 classpath 的资源处理。这允许以文件或 URL 等形式的配置文件能够自然地访问 classpath 资源。

不同的具体实现对于能够引用的不同类型资源的定义可能大相径庭。

对于 Java 语言的 classpath 来说:

  • 首先通过调用同一个类加载器(class loader)对应的 getResource() 方法检索被引用资源。
  • 如果使用的是绝对路径(以'/'开头),那么调用 getResource() 方法时应首先把开头的'/'去掉。
  • 如果使用的路径不以'/'开头,那么在调用 getResource() 方法前,应补上文件引用者本身所在“目录”作为前缀。如果使用的路径不是绝对路径(不以'/'开头)的同时,还没有对应“目录”(只有文件名)的话,那么直接按原样传入对应路径就行了。
  • 你不应该使用 getResource() 方法获取一个 URL 然后基于该 URL 和对应路径检索资源,因为类加载器的 getResource() 方法处理的路径和对应 URL 路径之间不总存在一一映射。换言之,对于“与之相邻”的计算来说,你应该基于资源名称而不是资源 URL 检索资源。

对于文件系统中的文件来说:

  • 如果使用的是绝对路径,那就按原样加载。
  • 如果使用的是相对路径,那么检索时应相对文件引用者所在目录检索资源。进程所使用的工作目录永远永远不应该在检索相对路径时使用。
  • 如果文件没找到,那就回退到 classpath 中的资源。classpath 中的资源不应在检索时添加包名作为前缀,而应与某个“根目录”相对;换言之,开头的"/"应被去掉(在这种情况下,绝对路径和相对路径是一样的)。"/"存在的意义为保持一致性,因为来自 classpath 中的其他资源不总是相对“根目录”的绝对路径,而"/"总是代表绝对路径的。

对于 URL 来说:

  • 对于从 URL 中加载的资源,“与之相邻”指基于 URL 本身路径的检索策略,而 URL 路径的最后一节将被替换成被引用文件名。
  • 对于 file: 格式的 URL 来说,其检索策略应和普通文件名的检索策略完全一样

特定实现不必总是支持文件,Java 语言的 classpath 资源,以及 URL;同时特定实现也不必一定支持某个特定的协议。不过如果支持的话,相应的检索策略应该和上面描述的相同。

需要注意的一点是,如果指定了 url()/file()/classpath(),被引用的节点将不会相对于引用者解析。这种解析方式只用于启发式的解析,也就是针对 include "foo.conf" 等声明格式的解析。该条规定可能会在未来发生变化。

数字索引对象到数组的转换

在某些文件格式或者上下文,比如说 Java 的 properties 文件格式等情况下,定义数组比较困难。考虑到这种情况,HOCON 的相应实现应支持将数字格式键的对象转换到数组。比如说下面这个对象:

{ "0" : "a", "1" : "b" }

可以被当作下面这种形式处理:

[ "a", "b" ]

一些诸如 properties 等格式的文件就可以使用这种方式定义一个数组:

foo.0 = "a"
foo.1 = "b"

相关细节:

  • 这种转换应该是惰性的,也就是说只有在可能出错的情况下进行转换,而不是在满足条件的时候就尝试转换。
  • 这种转换只应该在自动类型转换(automatic type conversion)出现时进行(请参阅后续章节)。
  • 这种转换应该在一个数组和一个满足条件的对象进行值连结时进行。
  • 这种转换不应该在对象为空或者对象没有数字索引的键值对存在时进行。
  • 这种转换应该忽略所有含有不能解析成自然数的键的键值对。
  • 这种转换应该按照数字索引排序然后再生成数组;如果有两个分别为 "0" 和 "2" 的键,那么其两个值应该分别对应生成的数组的 "0" 和 "1" 两个索引对应的值,换言之,不存在的数字索引应被直接跳过。

MIME 类型

在诸如 Content-Type 等情况下,MIME 类型使用“application/hocon”。

对于 API 的建议

完美的 HOCON 格式实现应遵守下面这些约定,并以可预测的方式正常工作。

自动类型转换

如果解析时需要用到一个特定类型的值,那么相应实现应该按照以下规则转换类型:

  • 数值到字符串值:把 JSON 中合法的数值转换成字符串。
  • 布尔值到字符串值:把值转换成 "true" 或者 "false"
  • 字符串值到数值:把字符串按照 JSON 的规则转换到数值
  • 字符串值到布尔值:"true"、"yes"、"on"、"false"、"no"、"off" 等六个值应该被转换成布尔值。支持一长串允许转换到布尔值的字符串听起来很吸引人,但为保证互用性以及简化相关概念,我们建议相关实现只支持这六个值。
  • 字符串值到空值:只有解析时明确表明需要一个空值时,"null" 这一字符串才应该被转换成空值,虽然听起来没人会这么要求。
  • 数字索引对象到数组:请参见上面的章节

下面的类型转换永远都不应该出现:

  • 从空值转换:如果需要用到一个类型的值,却返回一个空值的话,那么很有可能会最终导致报错。
  • 从对象转换
  • 从数组转换
  • 到对象转换
  • 到数组转换,除非是数字索引对象到数组的转换

对象或者数组和字符串之间的相互转换听起来很吸引人,但是实际应用中,引号及多重转义等问题会让人非常苦恼。

单位格式

HOCON 的实现可以选择支持解释某些类型的单位,比如时间单位和内存尺寸单位:10ms512K 这样的。HOCON 本身并不无可拓展的类型系统,也没有原生的“持续时间“类型的支持。但是,若应用程序要求以毫秒为单位的数据,HOCON 的实现可以尝试将值解释为毫秒数。

若有 API 支持,对于每个类型的单位都应当有其默认的单位。例如,时间类单位的默认单位可以是毫秒(细节参见下文)。HOCON 的实现应当按下列方式解释:

  • 若值是数值,将其解读为数字并使用默认单位处理。

  • 若值是字符串,则其顺序上必须有如下形式:

    • 若干可有可无的空格
    • 数字
    • 若干可有可无的空格
    • 可有可无的单位,仅由字母(Unicode L* 分类下的字符,可令 Java isLetter() 返回 true)组成
    • 若干可有可无的空格

    若字符串值中没有出现单位名,应使用默认单位,即将字符串看作是数字处理。若字符串值中出现了单位名,实现应理所当然地使用指定的这个单位。

时间单位

HOCON 的实现可以提供对 getMilliseconds() 及其他类似时间单位的支持。

时间单位可以利用上文中提到的一般“单位格式”:不带单位的数字视作使用毫秒为单位,字符串视作数字和可选的单位的组合。

受支持的时间单位的字符串应当大小写敏感,并只支持小写。下列字符串是所有支持的单位的准确形式:

  • ns, nano, nanos, nanosecond, nanoseconds
  • us, micro, micros, microsecond, microseconds
  • ms, milli, millis, millisecond, milliseconds
  • s, second, seconds
  • m, minute, minutes
  • h, hour, hours
  • d, day, days

日期单位

getDuration() 方法类似,getPeriod() 可用来获取时间单位并转化为 java.time.Period

日期单位可以利用上文中提到的一般“单位格式”:不带单位的数字视作使用天为单位,字符串视作数字和可选单位的组合。

受支持的时间单位的字符串应当大小写敏感,并只支持小写。下列字符串是所有支持的单位的准确形式:

  • d, day, days
  • w, week, weeks
  • m, mo, month, months(注意,如果你使用了 getTemporal(),因为它可以返回 java.time.Durationjava.time.Period 中的某一个,你应该使用 mo 代表月,以防止 m 被解析为分钟)
  • y, year, years

字节单位描述的尺寸

HOCON 的实现可以选择支持 getBytes(),它返回以字节单位描述的尺寸。

它可以利用上文中提到的一般“单位格式”;不带单位的数字视作使用字节为单位,字符串视作数字和可选单位的组合。

单字母的单位可以使用大写字母(注意:时间单位永远都是小写,这个规定仅针对尺寸单位)。

然而不幸的是,单位标准的不同可能会招来麻烦——这个问题就是以 2 为底和以 10 为底的问题。业界标准采取的做法和大众的用法不尽相同,以至于使用业界标准会令普通人困惑。更棘手的是大众的用法还会因为“是在讨论内存还是硬盘空间”而有所变化,操作系统和应用程序的不同更是令在给这个问题火上浇油。详细的案例可参考 https://zh.wikipedia.org/wiki/%E4%BA%8C%E8%BF%9B%E5%88%B6%E4%B9%98%E6%95%B0%E8%AF%8D%E5%A4%B4#%E4%BA%8C%E8%BF%9B%E5%88%B6%E5%92%8C10%E8%BF%9B%E5%88%B6%E8%AF%8D%E5%A4%B4%E5%A4%A7%E7%BA%A6%E6%AF%94%E7%8E%87。显然,在不先制造混乱的情况下是没办法理清这里面的头绪的。

对于单个字节来说,下列字符串是所有支持的单位的准确形式:

  • B, b, byte, bytes

对于 10 为底的单位来说,下列字符串是所有支持的单位的准确形式:

  • kB, kilobyte, kilobytes
  • MB, megabyte, megabytes
  • GB, gigabyte, gigabytes
  • TB, terabyte, terabytes
  • PB, petabyte, petabytes
  • EB, exabyte, exabytes
  • ZB, zettabyte, zettabytes
  • YB, yottabyte, yottabytes

对于 2 为底的单位来说,下列字符串是所有支持的单位的准确形式:

  • K, k, Ki, KiB, kibibyte, kibibytes
  • M, m, Mi, MiB, mebibyte, mebibytes
  • G, g, Gi, GiB, gibibyte, gibibytes
  • T, t, Ti, TiB, tebibyte, tebibytes
  • P, p, Pi, PiB, pebibyte, pebibytes
  • E, e, Ei, EiB, exbibyte, exbibytes
  • Z, z, Zi, ZiB, zebibyte, zebibytes
  • Y, y, Yi, YiB, yobibyte, yobibytes

使用单字母缩写的时候(比如 "128K" 这样的)会产生歧义;但诸如 java -Xmx 2G、GNU 工具中的 ls 表这样的先例使用的是以 2 为底的单位,所以本规范也遵从这些先例。当然,你也能找到将这些单位映射到以 10 为底的单位的例子。如果你不想制造歧义,那就不要用单字母的单位。

注意:zetta/zebi、yotta/yobi 以及更大的单位肯定会导致 64 位整数的溢出。现实世界中,API 和应用程序通常不会支持这些大单位。提供这些单位的实现通常只为了追求完美,但实际上实用性不高(至少 2014 年是如此)。

配置对象合并与文件合并

提供合并两个对象的方法也许会有用。若提供了这样一个方法,它的工作方式应当和处理重复键的方式一样。(关于重复键的处理请参考前文。)

和处理重复键一样,中间插入的非对象值会“隐藏”之前的对象。比方说如果你按下列顺序合并对象:

  • { a : { x : 1 } } (first priority)
  • { a : 42 } (fallback)
  • { a : { y : 2 } } (another fallback)

结果会是 { a : { x : 1 } }。两个对象因为“不相邻”所以没有合并;合并是成对进行的,42{ y : 2 } 合并时,42 优先的规则使得后者的信息完全丢失。

但如果合并的顺序改成这样:

  • { a : { x : 1 } } (first priority)
  • { a : { y : 2 } } (fallback)
  • { a : 42 } (another fallback)

此时的结果就是 { a : { x : 1, y : 2 } },因为两个对象现在相邻了。

合并两个文件中不同的对象的规则和合并同一文件中重复键值对的规则 完全相同。所有的合并都使用同一套规则。

某一个配置的值应当是数字还是对象这样的规则不需要重复。两种类型混淆在一起的情况从一开始就不应该出现。

但这样的情况还是有用的:你可以通过赋值为 null 的方式来清空之前的值,然后重新来过。若如此做,就可以避开默认的备选项。

与 properties 文件之间的映射

将 Java 语言的 properties 数据与 JSON 或 HOCON 中的数据在某些时候是有用的。关于 Java 的 properties 文件的规范,可参考这里: https://docs.oracle.com/javase/8/docs/api/java/util/Properties.html#load-java.io.Reader-

Java poperties 通常被解析为字符串到字符串的单射。

将 Java properties 转换为 HOCON 时,首先要将键按 . 分割,保留所有开头和结尾的空格。注意这和解析路径表达式 大不相同

. 分割键后会得到一系列路径元素。据此,键 . 分割后的结果是两个空字符串。a. 分割后的结果是 a 和一个空字符串。(注意 Java 的 String.split 完全不是这样工作的。)

在 properties 中不可能使用 . 作为键。如果在 JSON/HOCON 通过引号等方式是用来 . 作为键名,那么这个键就无法表达为 Java property。因为这样的键不论什么时候都只可能导致混乱,我们不推荐在 HOCON 的键名中使用 .

当所有的值对应的路径解析完毕后,根据这些路径构造 JSON 风格的对象树。

Properties 中解析出的值 永远 都是字符串,即使能解析成其他类型的值也应如此。若应用程序要求整数,HOCON 的实现应按照前文中描述过的方法进行类型转换。

不幸的是 Java 加载 properties 时不保留顺序。结果就是,当有一个键同时对应一个对象和一个字符串时,就没有办法正确处理了。例如,如果有如下 properties 文件:

a=hello
a.b=world

在这个情况下,a 需要同时作为对象和字符串两个值的键。在这个情况下,相应的 对象 必须作为最终的解析结果……以“对象优先”为原则时,只会丢弃最多一个值(字符串),但如果以“字符串优先”为原则的话,整个对象的值都会丢失。然而,在将 properties 映射为 JSON 结构后,那个与对象冲突的字符串值就再也无法访问到了。

HOCON 通常的原则是“后来者优先”而非“对象优先”,但实现这个效果需要再实现一个自定义的 Java properties 解析器,但这样并不值得,也对系统属性没什么帮助。

常规的 JVM 应用配置文件

通常,JVM 上的应用的配置文件由两部分组成:

  • 参考 配置(reference config)由 classpath 中所有名为 reference.conf 的资源组成,并按照 ClassLoader.getResources() 的返回顺序合并;系统属性会覆盖其中的值。
  • 应用 配置(application config)由应用负责从合适的地方加载,但在应用没有提供配置的情况下,默认会试图从 classpath 中加载 application.{conf,json,properties},并应用系统属性覆盖。
  • 不同的类加载器所加载出的参考配置可能不一样,因为每一个 jar 都可以提供一个 reference.conf
  • 单一 JVM 应用可以在有多个模块或上下文等环境时附带多个应用配置。

对类加载器来说,它应当首先加载、合并并解析参考配置,以供通过同样类加载器加载的应用的配置使用。应用配置不会影响参考配置的引用,因为参考配置的解析只依靠参考配置它自己。

应用配置应在参考配置加载完成后加载,并以参考配置中的值为备选项,在此基础上进行解析。这意味着应用配置的引用可以来自参考配置。

常规的系统属性覆盖

对于一个应用的配置来说,Java 的系统属性 应覆盖 配置文件中的定义。如此做即可支持通过命令行指定配置选项。

环境变量用作引用解析的备选项

回想这样的情况:某个引用无法在其配置树中解析为任何值(甚至不是 null),HOCON 的实现可以根据外部来源进行解析。其中,环境变量就可以是一种外部来源。

我们推荐 HOCON 中所有的键都使用小写字母,因为环境变量通常都是全大写字母命名的,这样可以避免冲突。(尽管 Windows 下的 getenv() 通常忽略大小写,但在开始查找环境变量之前 HOCON 都是大小写敏感的。)

同时请留意下文中对 Windows 平台及大小写问题的备注。

应用程序可以通过设定用同名键值对的方式,显式阻止引用查询环境变量。举例,在根对象中设置 HOME : null 这样的键值对可以防止 ${HOME} 解析到环境变量上去。

环境变量的解析过程如下:

  • 环境变量的值若为空字符串,则保留空字符串,不视作未定义
  • System.getenv 若抛出 SecurityException,则视作不存在此键
  • 编码由 Java 处理(System.getenv 本身返回值已经是 Unicode 字符串)
  • 环境变量的值总是字符串,但应用程序可以要求自动类型转换。

连字符还是小写驼峰?

推荐使用 hyphen-separated 也就是连字符形式,而非 camelCase 也就是小写驼峰式,作为键名的命名规范。

注意:和 Java 语言的 properties 文件的相似性

你完全可以把一个 HOCON 格式的文件写成和 properties 文件类似的样子,同时大量的 properties 文件也可以被当作合法的 HOCON 格式文件解析。

但是,HOCON 并不是 Java 语言的 properties 文件的超集,对于一些特殊情况来说,HOCON 会按照类似于 JSON 的方式,而不是 properties 文件的方式解析。

不同之处包括但不限于:

  • 某些不需要在 properties 文件中被引号括起来的特定字符需要在 HOCON 中被替换成 JSON 风格的被双引号括起来的字符串形式
  • HOCON 中不加引号的字符串不支持转义
  • HOCON 中不加引号的字符串会忽略尾部的空格
  • HOCON 不支持使用反斜杠的方式连接多行不加引号的字符串
  • properties 文件中的键值对中,如果值被省略,那么将会被按照空字符串解析,在 HOCON 中你不能这样做
  • properties 文件使用 '!' 作为注释的前缀
  • HOCON 允许注释和键值对出现在同一行,但是 properties 文件只会识别从一行的第一个字符开始的注释
  • HOCON 中存在 ${} 形式的引用

注意:Windows 平台以及大小写敏感的环境变量

HOCON 检索环境变量永远采取大小写敏感的策略,但具体解析时 Linux 和 Windows 等平台的处理方式并不相同。

在 Linux 中你可以定义多个名称相同,但大小写不同的环境变量;因此 Linux 中可能会同时出现 "PATH" 和 "Path" 两个不同的环境变量。HOCON 在 Linux 平台采用直接的检索策略;换言之,请确保你的定义中,大小写都是正确的。

Windows 的情况更令人迷惑一些。Windows 中环境变量的名称可能包含大小写字符的混合,例如 "Path" 等,但是 Windows 不允许定义多个同名但大小写不同的环境变量。在 Windows 中访问环境变量不区分大小写,访问 HOCON 中的 env 变量区分大小写。在 Windows 中访问环境变量不区分大小写,不过在 HOCON 中访问环境变量是区分大小写的。因此如果你清楚你的 HOCON 文件需要 "PATH" 这一环境变量,那么你必须确保该变量被定义为 "PATH" 而不是诸如 "Path" 或者 "path" 等。不过,Windows 不允许我们改变一个已有环境变量的大小写;我们不能简单地把一个环境变量换成大写的形式。确保环境变量具有我们想要的大小写形式的唯一方法是首先将所有需要用到的环境变量取消定义,然后使用我们想要的大小写形式重新定义它们。

比如说我们可能有这样的环境变量定义……

set Path=A;B;C

……管他值是什么样的。不过如果 HOCON 需要用到 "PATH" 的话,那么启动脚本可能需要做一些预防性工作,以应对各种可能情况……

set OLDPATH=%PATH%
set PATH=
set PATH=%OLDPATH%

%JAVA_HOME%/bin/java ....

在你的程序执行时,你没有办法了解周围环境中可能存在的环境变量,也没有办法知道这些定义可能会出现什么情况。因此,唯一安全的做法是重新定义你需要用到的所有变量,如上所示。