阅读视图

发现新文章,点击刷新页面。
🔲 ☆

如何让大语言模型输出JSON格式

提示词明确要求JSON

这是最直接的方法,直接在提示词中提要求,类似这样:

请描述这张图片,输出用JSON格式

就可以让LLM输出JSON格式

当然,这种方法不是100%让结果是JSON,有时候结果会是Markdown,有时候干脆就不是结构化的文本。

提示词中给出JSON样例

给出JSON样例的好处,是可以让LLM在生成的JSON中使用指定的key name。

例如,提示这么写:

描述这张图片,输出为JSON格式,例如{“desc”: “somebody is dancing”, “character_count”: 3}

产生的结果真的就包含desc和character_count两个key。

这是一个业内公认的方法,但是,在实操过程中,我发现对llama 3.2 vision使用这招产生非JSON输出的概率反而更大了,可能因为『描述图片内容』这个任务不容易让LLM上道输出指定的JSON。

指定结果开头字符为{

前面的方法,有可能结果虽然包含JSON,但是在JSON之前还要加一段废话,为了强制LLM只输出JSON,还有一招,就是在提示词中要求LLM输出以{开头(当然JSON也可以是以[开头),这样更大概率输出的就只有JSON。

上面这些都是在提示词上做文章,除此之外,在模型参数上也可以做一些trick。

调整模型参数

对于OpenAI的API,可以通过调整 logit_bias 来操纵输出token的概率,比如下面的配置,可以增加 {} 字符的输出概率,减少 ''' 的输出概率,从而增大输出为JSON的概率。

1
2
3
4
5
6
7
logit_bias: {
"90": 10, // token ID for "{"
"92": 10, // token ID for "}"
"19317": -10, // token ID for "'''"
"19317": -10, // token ID for "'''"
"74694": -10 // token ID for "```"
}

llama 3.2没有对等的logits_bias参数,但是我试了一下调整 temperature 和 top_p 参数,降低temperature和top_p的值,可以让模型少点『创意思维』,老老实实规规矩矩输出,似乎(我只敢说似乎)能够让模型更大概率遵守提示词以JSON格式输出。

重试

☑️ ☆

Go语言的向后兼容和toolchain规则

Go语言在发展演进过程中一直十分注重向后兼容性(backward compatibility),在Go 1.0版本发布之初就发布了Go1兼容性承诺,简单来说就是保证使用新版本Go可以正常编译和运行老版本的Go代码语法编写的go代码),不会出现breaking change(其实也不是绝对的不会出现)。

但是在Go 1.21版本之前,Go语言在向前兼容性方面却存在一定的不确定性问题。Go 1.21版本对此进行了改进,并引入了go toolchain规则。

1. Go 1.21版本之前的向前兼容问题

在Go 1.21版本之前,Go module中的go directive用于声明建议的Go版本,但并不强制实施。例如:

1
2
3
4
// go.mod
module demo1

go 1.20

上面go.mod文件中的go directive表示建议使用Go 1.20及以上版本编译本module代码,但并不强制禁止使用低于1.20版本的Go对module进行编译。你也可以使用Go 1.19版本,甚至是Go 1.15版本编译这个module的代码。

但Go官方对于这种使用低版本(L)编译器编译go directive为高版本(H)的Go module的结果没有作出任何承诺和保证,其结果也是不确定的

如果你在代码中没有引入高版本(>=L+1)go的新语法特性,那么编译是可以通过的。

如果你的代码没有用到任何高版本(>=L+1)的语法行为变更、bug或安全漏洞的代码,那么编译出的可执行程序运行起来也可以是正常的。

否则,可能会编译失败、运行失败, 或者运行时出现breaking change的问题。

自己的代码可以避免这些问题,但如果你的module有外部依赖,就无法避免了。从Go 1.21版本开始,Go团队在向前兼容方面做了改善,以规避编译结果的不确定性。

2. Go 1.21版本后的向前兼容策略

Go从Go 1.11版本引入go module,在go 1.16版本,go module构建模式正式成为默认构建模式,替代了原先的GOPATH构建模式。

注:《Go语言第一课》专栏的第6讲第7讲对Go module构建模式与6类常规操作做了全面系统的讲解。

通过go module,结合语义导入版本(semantic import versioning)最小版本选择(Minimal version selection)等机制,go build可以实现精确的依赖管控。

Go 1.21版本后的向前兼容性策略的调整就是参考了go module对依赖的管理方法:即将go版本和go toolchain版本作为一个module的“依赖”来管理。如果你真正理解了这个,那理解后面那些具体的规则就容易多了!

如果Russ Cox当初设计Go module就想到了今天这个思路,估计就会直接使用go.mod文件中的require语法像管理依赖module那样来管理go version和go toolchain了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// go.mod (假想的)

module demo1

require (
go 1.20.5
toolchain go1.21.1
)

require (
github.com/gomodule/redigo v1.8.5
github.com/google/gops v0.3.19
github.com/panjf2000/ants v1.2.1
)

但时间无法倒流,历史不能重来,Russ Cox现在只能使用go directive和toolchain directive来提供对go版本和go工具链的依赖信息:

1
2
3
4
5
6
// go.mod

module demo1

go 1.20.5
toolchain 1.21.1

同时和使用go get可以改变go.mod的require块中的依赖的版本一样,通过go get也可以修改go.mod中go和toolchain指示的版本:

1
2
$go get go@1.21.1
$go get toolchain@go1.22.1

基于上述策略调整,为解决向前兼容不确定性的问题,Go从1.21版本开始,改变了go.mod中go directive的语义:它不再是建议,而是指定了module最小可用的Go版本。

这样在仅使用本地go工具链的情况下,如果Go编译器版本低于go.mod中的go版本,将无法编译代码:

1
2
3
4
5
6
7
8
// go.mod

module demo1

go 1.21.1 // 指定最小可用版本为Go 1.21.1

$GOTOOLCHAIN=local go build
go: go.mod requires go >= 1.21.1 (running go 1.21.0; GOTOOLCHAIN=local)

这里有一个前提:“在仅使用本地go工具链的情况下(即设置了GOTOOLCHAIN=local)”,在Go 1.21版本之前,我们遇到的都属于这种情况。遇到这种情况后,我们一般的作法是手动下载对应版本的Go工具链(比如这里的go 1.21.1),然后用新版工具链重新编译。

Go团队考虑到手动管理go工具链带来的体验不佳问题,在Go 1.21版本及以后,go还提供了自动Go工具链管理,如果go发现本地工具链版本低于go module要求的最低go版本,那么go会自动下载高版本的go工具链,缓存到go module cache中(不会覆盖本地安装的go工具链),并用新下载的go工具链对module进行编译构建:

1
2
3
4
5
6
7
8
// go.mod

module demo1

go 1.21.1 // 指定最小可用版本为Go 1.21.1

$go build
go: downloading go1.21.1 (darwin/amd64)

注:从兼容性方面考虑,如果go.mod中没有显式的用go指示go版本,那么默认go版本为1.16。

对应module有依赖的情况,比如下图:

这里要正确编译图中的main module,我们至少需要go 1.21.0版本,这个版本是main所有依赖中version最大的那个。

当然最终选择哪个版本的go工具链对module进行编译,则有一个选择决策的过程。

go module构建模式下,go工具链选择依赖module的版本时有一套机制,比如最小版本选择等,Go 1.21以后,go工具链版本的选择,也有一套类似的逻辑。接下来我们就来简单看一下。

3. module依赖的Go toolchain版本的选择过程

go module中依赖module的版本选择机制:最小版本选择(mvs)

上图来自https://go.dev/ref/mod

以module C的版本选择为例,A依赖C 1.3,B依赖C 1.4,那么满足应用依赖需求的最小版本就是1.4。如果选择1.3,则不满足B对依赖的要求。

对Go toolchain的选择过程也遵循mvs方法,我们把前面的那个例子拿过来:

现在我们帮这个例子选择go toolchain版本。

注:如果go.mod中没有显式用toolchain指示工具链版本,那我们可以认为go.mod中有一个隐含的toolchain指示版本,该版本与go directive指示的版本一致。

上面的例子中对toolchain version的最高要求为module D的go 1.21.0,当startup toolchain(执行的那个go命令的版本)得到这个信息后,就会在当前可用的toolchain版本列表中选出满足go 1.21.0的最小版本的go toolchain,然后会有一个叫Go toolchain switches(Go工具链切换)的过程,切换后,选出的新版go toolchain会继续后面的工作(编译和链接)。例如,如果可用的toolchain版本有如下三个:

  • go 1.22.7
  • go 1.21.3
  • go 1.21.5

那么startup toolchain会根据mvs原则选出满足go 1.21.0的最小版本,即go 1.21.3。

这里大家可能会马上问:什么是可用的toolchain版本?别急!接下来我们就来回答这个问题。

4. GOTOOLCHAIN环境变量与toolchain版本选择

是否执行自动工具链下载和缓存、Go toolchain switches(Go工具链切换)以及切换的工具链的版本取决于GOTOOLCHAIN环境变量的设置、go.mod中go和toolchain指示的版本。

当go命令捆绑的工具链与module的go.mod的go或工具链版本一样时或更新时,go命令会使用自己的捆绑工具链。例如,当在main module的go.mod包含有go 1.21.0时,如果go命令绑定的工具链是Go 1.21.3版本,那么将继续使用初始toolchain的版本,即Go 1.21.3。

而如果go.mod中的go版本写着go 1.21.9,那么go命令捆绑的工具链版本1.21.3显然不能满足要求,那此时就要看GOTOOLCHAIN环境变量的配置。

GOTOOLCHAIN的设置以及对结果的影响略复杂,下面是GOTOOLCHAIN的多种设置形式以及对toolchain选择的影响说明(以下示例中本地go命令捆绑的工具链版本为Go 1.21.0):

  • <name>

例如,GOTOOLCHAIN=go1.21.0。go命令将始终运行该特定版本的go工具链。如果本地存在该版本工具链,就使用本地的。如果不存在,会下载、缓存起来并使用。如果go.mod中的工具链版本高于name版本,则停止编译:

1
2
3
4
5
6
7
8
$cat go.mod
module demo1

go 1.23.1
toolchain go1.23.1

$GOTOOLCHAIN=go1.21.0 go build
go: go.mod requires go &gt;= 1.23.1 (running go 1.21.0; GOTOOLCHAIN=go1.21.0)
  • <name>+auto

当GOTOOLCHAIN设置为<name>+auto时,go命令会根据需要选择并运行较新的Go版本。具体来说,它会查询go.mod文件中的工具链版本和go version。如果go.mod 文件中有toolchain行,且toolchain指示的版本比默认的Go工具链(name)新,那么系统就会调用toolchain指示的工具链版本;反之会使用默认工具链。

当本地不存在决策后的工具链版本时,会自动下载、缓存,并使用该缓存工具链进行后续编译。

1
2
3
4
5
6
7
8
9
10
11
$cat go.mod
module demo1

go 1.23.1
toolchain go1.23.1

$GOTOOLCHAIN=go1.24.1+auto go build
go: downloading go1.24.1 (darwin/amd64) // 使用name指定工具链,但该工具链本地不存在,于是下载。

$GOTOOLCHAIN=go1.20.1+auto go build
go: downloading go1.23.1 (darwin/amd64) // 使用go.mod中的版本的工具链
  • <name>+path

当GOTOOLCHAIN设置为<name>+path时,go命令会根据需要选择并运行较新的Go版本。具体来说,它会查询go.mod文件中的工具链版本和go version。如果go.mod 文件中有toolchain行,且toolchain指示的版本比默认的Go工具链(name)新,那么系统就会调用toolchain指示的工具链版本;反之会使用默认工具链。当使用<name>+path时,如果决策得到的工具链版本在PATH路径下没有找到,那么go命令执行过程将终止。

1
2
3
4
5
6
7
8
9
10
11
$cat go.mod
module demo1

go 1.23.1
toolchain go1.23.1

$GOTOOLCHAIN=go1.24.1+path go build // 使用name指定工具链,但该工具链本地不存在,于是编译停止
go: cannot find "go1.24.1" in PATH

$GOTOOLCHAIN=go1.20.1+path go build // 使用go.mod中的版本的工具链,但该工具链本地不存在,于是编译停止
go: cannot find "go1.23.1" in PATH
  • auto (等价于 local+auto,这也是默认值)

auto的语义是当go.mod中工具链版本低于go命令捆绑的工具链版本,则使用go命令运行捆绑的工具链;反之,自动下载对应的工具链版本,缓存起来并使用。

1
2
3
4
5
6
7
8
$cat go.mod
module demo1

go 1.23.1
toolchain go1.23.1

$GOTOOLCHAIN=auto go build
go: downloading go1.23.1 (darwin/amd64)
  • path (等价于 local+path)

path的语义是当go.mod中工具链版本低于go命令捆绑的工具链版本,则使用go命令运行捆绑的工具链;反之,在PATH中找到满足go.mod中工具链版本的go版本。如果没找到,则会停止编译过程:

1
2
3
4
5
6
7
8
$cat go.mod
module demo1

go 1.23.1
toolchain go1.23.1

$GOTOOLCHAIN=path go build
go: cannot find "go1.23.1" in PATH
  • local

当GOTOOLCHAIN设置为local时,go命令总是运行捆绑的 Go 工具链。如果go.mod中工具链版本高于local的版本,则会停止编译过程。

1
2
3
4
5
6
7
8
$cat go.mod
module demo1

go 1.23.1
toolchain go1.23.1

$GOTOOLCHAIN=local go build
go: go.mod requires go &gt;= 1.23.1 (running go 1.21.0; GOTOOLCHAIN=local)

当Go工具在编译module依赖项时发现当前go toolchain版本无法满足要求时,会进行go toolchain switches(切换),切换的过程就是从可用的go toolchain列表中取出一个最适合的。

那么“可用的go toolchain列表”究竟是如何组成的呢? go命令有三个候选版本(以 Go 1.21.1为例,这些版本也是Go当前承诺提供support的版本):

  • 尚未发布的Go语言版本的最新候选版本(1.22rc1)
  • 最近发布的 Go 语言版本的最新补丁 (1.21.1)
  • 上一个Go语言版本的最新补丁版本(1.20.8)。

当GOTOOLCHAIN设置为带auto形式的值的时候,Go会下载这些版本;当GOTOOLCHAIN设置为代path形式的值的时候,Go会在PATH路径搜索适合的go工具链列表。

接下来,go会用mvs(最小版本选择)来确定究竟使用哪个toolchain版本。Go toolchain reference中就有这样一个例子。

假设example.com/widget@v1.2.3需要Go 1.24rc1或更高版本。go命令会获取可用工具链列表,并发现两个最新Go工具链的最新补丁版本是Go 1.28.3和Go 1.27.9,候选版本Go 1.29rc2也可用。在这种情况下,go 命令会选择Go 1.27.9。

如果 widget 需要 Go 1.28或更高版本,go命令会选择 Go 1.28.3,因为 Go 1.27.9 太旧了。如果widget需要Go 1.29或更高版本,go命令会选择Go 1.29rc2,因为Go 1.27.9和Go 1.28.3都太老了。

5. 小结

Go 1.21通过增强go语句语义和添加工具链管理,大幅改进了Go语言的向前兼容性。开发者可以放心使用新语言特性,无需担心旧版本编译器带来的问题。go命令会自动处理不同module使用不同go版本和不同工具链版本的情况,使用Go语言变得更简单。

6. 参考资料

☑️ ☆

webstorm 的 cpu 占用高

现象

前端项目,不管是 vue,react ,就是 cpu 占用长期 400%以上,有时候持续好多天,导致 macbook 温度一直在 70+度以上

处理

1
2
3
4
/Applications/Webstorm.app/Contents/jbr/Contents/Home/conf/security
/Applications/IntelliJ IDEA.app/Contents/jbr/Contents/Home/conf/security
/Applications/GoLand.app/Contents/jbr/Contents/Home/conf/security
/Applications/RustRover.app/Contents/jbr/Contents/Home/conf/security

修改以上四个文件夹里的 java.security 文件
jdk.tls.disabledAlgorithms=SSLv3 替换为 jdk.tls.disabledAlgorithms=TLSv1.3, SSLv3

🔲 ☆

新一代包管理器 PNPM

依赖管理的始末

npm2

使用早期的 npm1/2 安装依赖,node_modules 文件夹会以递归的形式呈现,严格按照 package.json 结构以及次级依赖的 package.json 结构将依赖安装到它们各自的 node_modules 中,直到次级依赖不再依赖其它模块。

就像下面这样,tea-app 依赖 tea-component 作为次级依赖,tea-component 会安装到 tea-component 的 node_modules 里面:

1
2
3
4
5
6
7
8
node_modules
└─ tea-app
├─ index.js
├─ package.json
└─ node_modules
└─ tea-component
├─ index.js
└─ package.json

假设项目的中的两个依赖同时依赖了相同的次级依赖,那么它们二者的次级依赖将会被重复安装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
node_modules
├─ tea-app
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ tea-component
│ ├─ index.js
│ └─ package.json
└─ tea-chart
├─ index.js
├─ package.json
└─ node_modules
└─ tea-component
├─ index.js
└─ package.json

这只是简单的例子,那如果 tea-component 还依赖别的包,别的包又依赖另外的包…… 在真实的开发场景中其问题还会更加恶劣:

  1. 依赖层级太深,会导致文件路径过长
  2. 重复的包被安装,导致 node_modules 文件体积巨大,占用过多的磁盘空间

npm3

npm3/yarn 开始,相比 npm1/2 项目依赖管理的方式有了很大的改变,不再是以往的“嵌套式”而是采用了“扁平化”方式去管理项目依赖。

这里继续拿上面的例子,tea-app 和 tea-chart 都依赖了 tea-component,依赖安装后呈现的是下面的这种扁平化目录:

1
2
3
4
5
6
7
8
9
10
node_modules
├─ tea-component
│ ├─ index.js
│ └─ package.json
├─ tea-app
│ ├─ index.js
│ └─ package.json
└─ tea-chart
├─ index.js
└─ package.json

扁平化的目录的确解决了上一小节暴露的一些问题,同时也暴露了新的问题:

  • Phantom dependencies

称为幽灵依赖,指的是在项目内引用未在 package.json 中定义的包。这个问题在 npm3 展现,因为早期的树形结构导致了依赖冗余和路径过深的问题,npm3 之后采用扁平化的结构,一些第三方包的次级依赖提升到了与第三方包同级。

一旦出现幽灵依赖的问题,可能会导致意想不到的错误,所以一定要正视:

  1. 不兼容的版本(例如某一个 api 进行了重大更新)

  2. 有可能会丢失依赖(某依赖不再依赖呈现在我们项目中的幽灵依赖)

    1
    2
    // tea-component 就属于是幽灵依赖,因为它是属于 tea-app、tea-chart 的次级依赖。
    import { Button } from 'tea-component';
  3. NPM doppelgangers

称为分身依赖依赖的同名包都会被重复安装。

在实际开发中也会出现这样的情景,假设 tea-app、tea-form 依赖 tea-component@2.0.0,tea-chart 依赖 tea-component@3.0.0,这时候会造成依赖冲突,解决冲突的方式会将对应的冲突包放到对应依赖目录的 node_mudules 中,类似下面结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
node_modules
├─ tea-component@3.0.0
│ ├─ index.js
│ └─ package.json
├─ tea-app
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ tea-component@2.0.0
│ ├─ index.js
│ └─ package.json
├─ tea-form
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ tea-component@2.0.0
│ ├─ index.js
│ └─ package.json
└─ tea-chart
├─ index.js
└─ package.json

这时候会发现一个问题,tea-app、tea-form 的 node_modules 下都有重复且版本相同的 tea-component@2.0.0,这个问题就是我们正在所说的“分身依赖”的问题。这个问题就会导致 tea-app 中的 ConfigProvider 组件和 tea-form 的不是一个实例,无法生效。

常见的问题:

  1. 项目打包会将这些“重身”的依赖都进行打包,增加产物体积
  2. 无法共享库实例,引用的得到的是两个独立的实例
  3. 重复 TypeScript 类型,可能会造成类型冲突

结论

  1. 扁平化的 node_modules 结构允许访问没有在 package.json 中声明的依赖。
  2. 安装效率低,大量依赖被重复安装,磁盘空间占用高。
  3. 多个项目之间已经安装过的的包不能共享,每次都是重新安装。

PNPM

Fast, disk space efficient package manager (速度快、节省磁盘空间的软件包管理器)

当使用 npm 或 Yarn 时,如果你有 100 个项目,并且所有项目都有一个相同的依赖包,那么, 你在硬盘上就需要保存 100 份该相同依赖包的副本。然而,如果是使用 pnpm,依赖包将被 存放在一个统一的位置,因此:

如果你对同一依赖包需要使用不同的版本,则仅有 版本之间不同的文件会被存储起来。例如,如果某个依赖包包含 100 个文件,其发布了一个新 版本,并且新版本中只有一个文件有修改,则pnpm update只需要添加一个 新文件到存储中,而不会因为一个文件的修改而保存依赖包的 所有文件。

所有文件都保存在硬盘上的统一的位置。当安装软件包时, 其包含的所有文件都会硬链接自此位置,而不会占用 额外的硬盘空间。这让你可以在项目之间方便地共享相同版本的 依赖包。

最终结果就是以项目和依赖包的比例来看,你节省了大量的硬盘空间, 并且安装速度也大大提高了!

依赖安装

使用 pnpm 安装,pnpm 会将依赖存储在位于 .pnpm-store 目录下。只要你在同一机器下,下次安装依赖的时候 pnpm 会先检查 store 目录,如果有你需要的依赖则会通过一个硬链接到你的项目中去,而不是重新安装依赖。

依赖管理原理

pnpm 会将依赖存储在 store 目录下,通过符号链接的方式仅将项目的直接依赖项添加到 node_modules 的根目录下。

当使用 npm 或 yarn 安装依赖包时,所有软件包都将被提升到 node_modules 的 根目录下。其结果是,源码可以访问 本不属于当前项目所设定的依赖包。

PNPM 机制

如果 store 目录里面拥有即将需要下载的依赖,下载将会跳过,会向对应项目 node_modules 中去建立硬链接,并非去重新安装它。这里就表明为什么 pnpm 性能这么突出了,最大程度节省了时间消耗和磁盘空间。

基于软链接的 node_modules

pnpm 输出的 node_modules 与 npm/yarn 有很大的出入,并非是先者那样的“扁平化目录”而是“非扁平化目录”。

创建两个目录并分别运行 npm add express,pnpm add express。

这是使用 npm 安装 node_modules 的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.bin
accepts
array-flatten
body-parser
bytes
content-disposition
cookie-signature
cookie
debug
depd
destroy
ee-first
encodeurl
escape-html
etag
express

这个则是 pnpm 安装 node_modules 的结构:

1
2
3
.pnpm
.modules.yaml
express

打开 .pnpm 目录会发现这些依赖都被“扁平化”了,每个包都携带着自己的版本号。pnpm 这样设计的目的我理解其实是为了解决“分身依赖”的问题。

假设我们有这么一个情景,项目中依赖了 tea-app@1.0.0tea-chart@1.0.0tea-component@2.0.0。tea-chart 和 tea-app 依赖了 tea-component@1.0.0 那它引用关系是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
node_modules
├─tea-app -> ./.pnpm/tea-app@1.0.0/node_modules/tea-app
├─tea-chart -> ./.pnpm/tea-chart@1.0.0/node_modules/tea-chart
├─tea-component -> ./.pnpm/tea-component@2.0.0/node_modules/tea-component
└─.pnpm
├─ tea-app@1.0.0
│ └─ node_modules
│ ├─ tea-component -> ../tea-component@1.0.0/node_modules/tea-component
│ └─ tea-app -> <store>/tea-app
├─ tea-chart@1.0.0
│ └─ node_modules
│ ├─ tea-component -> ../tea-component@1.0.0/node_modules/tea-component
│ └─ tea-chart -> <store>/tea-chart
├─ tea-component@1.0.0
│ └─ node_modules
│ └─ tea-component -> <store>/tea-component@1.0.0
└─ tea-component@2.0.0
└─ node_modules
└─ tea-component -> <store>/tea-component@2.0.0

为什么需要通过软链接的方式去引用实际的依赖?

这样设计的目的是解决“幽灵依赖”的问题,只有声明过的依赖才会以软链接的形式出现在 node_modules 目录中。在实际项目中引用的是软链接,软链接指向的是 .pnpm 的真实依赖,所以在日常开发中不会引用到未在 package.json 声明的包。

PNPM 锁文件

pnpm 产出的是一个 pnpm-lock.yaml 格式的锁文件。

支持通过 pnpm import 从另一个包管理器的锁文件生成一个。支持的源文件:

  • package-lock.json
  • npm-shrinkwrap.json
  • yarn.lock

总结

npm2 是通过嵌套的方式管理 node_modules 的,会有同样的依赖复制多次的问题。

npm3+ 和 yarn 是通过铺平的扁平化的方式来管理 node_modules,解决了嵌套方式的部分问题,但是引入了幽灵依赖的问题,并且同名的包只会提升一个版本的,其余的版本依然会复制多次。

pnpm 则是用了另一种方式,不再是复制了,而是都从全局 store 硬连接到 node_modules/.pnpm,然后之间通过软链接来组织依赖关系。

这样不但节省磁盘空间,也没有幽灵依赖问题,安装速度还快,从机制上来说完胜 npm 和 yarn。

☑️ ☆

Vue 2.x 使用高德地图JS API 2.0加载起点终点路径轨迹

需求

  1. 在地图中显示行驶轨迹,自定义标记点图标
    地图厂商使用高德地图,使用目前最新的高德地图JSAPI 2.0

  2. 在自己的 H5 中调起多个地图app,显示标记点位置
    由于地图 APP 并不支持在自己的网页中直接打开,因此需要通过地图 URI API 调用厂商H5地图,在厂商H5地图调起地图app

在 html 文件中引入地图 js

1
<script src="https://webapi.amap.com/maps?v=2.0&key=申请的key值&plugin=AMap.Driving"></script>

主页面

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
// index.vue
<template>
<div class="index">
<template >
<v-map :data="dataList"></v-map>
<v-detail :statrLocation="statrLocation" :endLocation="endLocation"/>
</template>
</div>
</template>

<script>
import Map from './mMap.vue' // 地图
import Detail from './detail.vue'

export default {
name: 'index',
data() {
return {
dataList: [],
statrLocation: {},
endLocation: {},
}
},
components: {
"v-map": Map,
"v-detail": Detail
},
mounted() {
this.dataList = [
{
longitude: 116.478346,
latitude: 39.997361
},
{
longitude: 116.402796,
latitude: 39.936915
}
]
this.statrLocation = this.dataList[0]
this.endLocation = this.dataList[this.dataList.length-1]
},
methods: {

}
};
</script>
<style scoped lang="scss">
.index {
position: relative;
width: 100%;
height: 100%;
background: #fcf9f2;
}
</style>

地图组件

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
// mMap.vue
<template>
<div class="m-map">
<div id="map-box"></div>
</div>
</template>
<script>

export default {
props: ['data'],
data() {
return {
map: {},
lineArr: [],
};
},
created() {
this.initMap()
},
methods: {
initMap() {
this.$nextTick(() => {
this.map = new AMap.Map('map-box', {
resizeEnable: true, //是否监控地图容器尺寸变化
zoom: 14, //初始化地图层级
center: [116.397428, 39.90923], //初始化地图中心点
animateEnable: true// 地图平移过程中是否使用动画
});

if(this.lineArr.length) {
this.drawLine() //绘制路线
}
});
},
drawLine(){
AMap.convertFrom(this.lineArr, 'gps', (status, result) => {
if (result.info === 'ok') {
const paths = result.locations;

this.map.clearMap()

this.startMarker = new AMap.Marker({
map: this.map,
position: paths[0], //起点经纬度
icon: new AMap.Icon({
image: require('@/assets/img/icon/icon-start.png'),
size: new AMap.Size(120, 120), //图标所处区域大小
imageSize: new AMap.Size(120,120) //图标大小
}), //起点ico
offset: new AMap.Pixel(-60, -60),
autoRotation: true,
// angle:-90,
});

this.endMarker = new AMap.Marker({
map: this.map,
position: paths[paths.length-1], //终点经纬度
icon: new AMap.Icon({
image: require('@/assets/img/icon/icon-end.png'),
size: new AMap.Size(60, 60), //图标所处区域大小
imageSize: new AMap.Size(60,60) //图标大小
}), //终点ico
offset: new AMap.Pixel(-30, -30),
autoRotation: true,
});

// 绘制轨迹
var polyline = new AMap.Polyline({
map: this.map,
path: paths,
showDir: true,
strokeColor: '#28F', //线颜色
// strokeOpacity: 1, //线透明度
strokeWeight: 6, //线宽
// strokeStyle: "solid" //线样式
});
this.map.add([this.startMarker, this.endMarker]);
this.map.setFitView(); //自适应缩放级别
}
})

}
},
watch: {
data: {
handler(newValue, oldValue) {
this.lineArr = [];
if(newValue.length) {
newValue.map((item, index) => {
if( item.longitude != null && item.latitude != null ) {
this.lineArr.push(new AMap.LngLat(item.longitude,item.latitude));
}
});
this.drawLine();
}
},
immediate: true
},
},
};
</script>
<style scoped lang ="scss">
@import '@/assets/scss/mixin.scss';
.m-map {
width: 100%;
height: 100vh;
#map-box {
width: 100%;
height: 100%;
}
}
</style>

代码解析

在使用中发现标记点位置显示不对,存在一定的偏移,需要将其他坐标转为高德坐标方法. 在绘制轨迹之前先转换为高德坐标,然后再删除地图上所有的覆盖物,

1
2
3
4
5
6
7
8
9
AMap.convertFrom(this.lineArr, 'gps', (status, result) => {
if (result.info === 'ok') {
const paths = result.locations;

this.map.clearMap()

// ...
}
})

也可以使用专门处理地理坐标系的js库gcoord,用来修正百度地图、高德地图及其它互联网地图坐标系不统一的问题。

调起地图H5 组件

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
// detail.vue
<template>
<div class="m-detail-box">
<div class="m-btn-wrapper">
<van-button class="open-btn" round @click="openShow = true">导航至车辆当前位置</van-button>
</div>
<van-action-sheet
class="m-sheet-box"
v-model="openShow"
:actions="actions"
cancel-text="取消"
close-on-click-action
@select="onSelect"
/>
</div>
</template>

<script>
import {
Toast
} from 'vant';

export default {
props: {
statrLocation: {
type: Object,
default: () => ({})
},
endLocation: {
type: Object,
default: () => ({})
},
},
data() {
return {
openShow: false,
actions: [
{ name: '使用苹果地图导航', value: 'iosamap', color: '#007AFF' },
{ name: '使用百度地图导航', value: 'bmap', color: '#007AFF' },
{ name: '使用高德地图导航', value: 'amap', color: '#007AFF' }
],
}
},
filters: {

},
mounted() {

},
methods: {
onSelect(item) {
this.openShow = false;
let startLocation = this.startLocation
let endLocation = this.endLocation

if (endLocation.longitude && endLocation.latitude) {
let url = ''
switch (item.value) {
case 'iosamap':
url = `iosamap://navi?sourceApplication=applicationName&backScheme=applicationScheme&poiname=${location}&poiid=BGVIS&lat=${endLocation.latitude}&lon=${endLocation.longitude}&dev=1&style=2`
break;
case 'bmap':
// 单点标注
url = `http://api.map.baidu.com/marker?location=${endLocation.latitude},${endLocation.longitude}&title=车辆位置&content=实时定位&output=html&coord_type=wgs84&src=webapp.baidu.openAPIdemo`

// 路径规划
// url = `http://api.map.baidu.com/direction?origin=latlng:${startLocation.latitude},${startLocation.longitude}|name:我的位置&destination=latlng:${endLocation.latitude},${endLocation.longitude}|name:实时定位&mode=driving&coord_type=wgs84&src=webapp.baidu.openAPIdemo`
break;
case 'amap':
// 单点标注
url = `https://uri.amap.com/marker?position=${endLocation.longitude},${endLocation.latitude}&name=实时定位&src=mypage&coordinate=wgs84&callnative=1`

// 路径规划
// url = `https://uri.amap.com/navigation?from=${startLocation.longitude},${startLocation.latitude},我的位置&to=${endLocation.longitude},${endLocation.latitude},实时定位&mode=car&policy=1&coordinate=wgs84&callnative=1`
break;
}
window.open(url)
} else {
Toast({
message: '暂无车辆定位',
type: 'fail',
})
}
},
},
beforeDestroy() {
clearInterval(this.timer)
}
}
</script>
<style scoped lang="scss">
@import "@/assets/scss/mixin.scss";

.m-detail-box {
position: absolute;
bottom: 0;
padding: 20px 0 0;
border-radius: 20px 20px 0px 0px;
width: 100%;
background: #FEFFFE;
box-shadow: 0 4px 40px 4px rgba(135, 119, 145, 0.36);
z-index: 160;

.van-cell-group {
&::after {
border: none;
}
}

.van-cell {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;

&::after {
left: 24px;
right: 24px;
}

.van-cell__title {
flex: none;
color: #757AB5;
}

.van-cell__value {
color: #292929;
}
}

.m-btn-wrapper {
border-top: 1px solid #EFF2F9;
background: #FFF;

.open-btn {
display: block;
margin: 10px auto;
padding: 14px;
width: 90%;
font-size: 18px;
color: #FEFFFE;
background: #85D4D9;
}
}

/deep/.van-overlay {
background: rgba(33, 34, 51, 0.5);
}

.m-sheet-box {
padding: 0 8px;
background: transparent;

.van-action-sheet__content {
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
}

.van-action-sheet__gap {
height: 20px;
background: transparent;
}

.van-action-sheet__cancel {
margin-bottom: 20px;
border-radius: 14px;
font-size: 20px;
color: #007AFF;
background: rgba(255, 255, 255, 0.92);
}
}
}
</style>
🔲 ☆

Pyenv工具

Python 版本管理工具的主要作用是帮助开发者在同一台机器上管理多个 Python 版本和环境。这对于开发和部署不同项目非常有用,因为不同项目可能依赖不同的 Python 版本或者不同的包版本。具体来说,Python 版本管理工具应有以下功能:

(1)避免依赖冲突,不同的项目可能依赖不同版本的库,使用版本管理工具可以创建独立的虚拟环境,避免依赖冲突。

(2)简化开发流程,开发者可以轻松地在不同的 Python 版本之间切换,而不需要重新安装或配置 Python。

(3)便于部署,减少冲突。在开发环境中使用与生产环境相同的 Python 版本和依赖,可以减少部署时出现的问题。

(4)共享环境配置,提高开发环境一致性。可以将环境配置文件(如 requirements.txtpyproject.toml)共享给团队成员,确保大家使用相同的开发环境。

一、工具选择

常见的管理工具有 Pyenv 和 Conda。Pyenv 是当前最流行的 Python 版本管理工具,支持多种 Python 版本,如 CPython、Anaconda、PyPy 等,功能全面且简单易用。Conda 最初由 Anaconda, Inc. 开发,主要用于 Python 和 R 编程语言的软件包(含 Python)及环境管理,特别适合跨平台、多语言项目,Python 版本管理只是其一小部分功能,若仅用于管理 Python 版本,Conda 有些大材小用,且系统较复杂、学习成本略高。相比之下,Pyenv 是常规项目 Python 版本管理的最优选择。

以下详细介绍 Pyenv 的使用方法。

二、Pyenv 安装

建议: 先卸载系统内置的 Python,否则可能导致 pyenv 设置不生效。

1. Windows

pyenv 本身是为 Unix 系统设计的。你可以使用 pyenv-win 这个项目,它是 pyenv 的 Windows 版本。

你需要在 PowerShell 中执行以下命令安装 pyenv-win:

1
Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/pyenv-win/pyenv-win/master/pyenv-win/install-pyenv-win.ps1" -OutFile "./install-pyenv-win.ps1"; &"./install-pyenv-win.ps1"

重新打开 PowerShell,运行 pyenv –version 检查安装是否成功。

2. Linux

你可以使用以下命令来安装 pyenv

1
curl https://pyenv.run | bash

之后再将 pyenv 配置到环境变量中并使之生效,执行如下命令:

1
2
3
4
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc 
echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init -)"' >> ~/.bashrc
source ~/.bashrc

上述配置仅能使 pyenv 在 bash 环境生效,更多 shell 环境配置请参考:Set up your shell environment for Pyenv。配置的本质在于将$PYENV_ROOT 下的 shims 和 bin 目录配置到 PATH 变量中,且 shims 需配置在前。配置后的 PATH 如下:

1
# echo $PATH /root/.pyenv/shims:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

三、Pyenv 基本用法

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
## 查看帮助文档 
pyenv
## 查看某个命令帮助文档
pyenv install --help
## 查看版本
pyenv version
## 检查 Python 是否正常运行
python -c "import sys; print(sys.executable)"
## 查看已安装的 Python 版本
pyenv versions
## 查看当前使用的 Python 版本
pyenv version
## 查看所有可用的 Python 版
pyenv install --list
## 安装指定版本
pyenv install 3.9.1
## 验证
python --version
## 卸载指定版本
pyenv uninstall 3.9.1
## 全局指定 Python 版本(影响所有项目)
pyenv global 3.9.1
## 局部指定 Python 版本(仅影响当前项目目录),指定后在当前项目目录内创建 .python-version 文件,保存版本信息## 优先级高于 global
pyenv local 3.9.1
## 会话级指定 Python 版本(影响所有项目)
pyenv shell 3.9.1
## 查看 python 的安装目录
pyenv which python
## 重新生成 pyenv 的 shims 目录中的可执行文件
pyenv rehash

Python 安装常见问题,可参考:Python common build problems

四、Pyenv 核心原理 -Shims

pyenv 通过 Shims 实现了对不同 Python 版本的透明管理和切换。

1. 工作原理

上述环境配置中,在 PATH 环境变量最前面插入一个 shims 目录,$(pyenv root)/shims:$(pyenv root)/bin:/usr/local/bin:/usr/bin:/bin。通过一个称为 rehashing 的过程,pyenv 在该目录中维护垫片,以匹配每个已安装的 Python 版本中的每个 Python 命令,如: python、pip 等。

Shims 是轻量级可执行文件,它只是将你的命令传递给 pyenv。因此,在安装了 pyenv 的情况下,当你运行 pip 时,你的操作系统将执行以下操作:

(1)搜索 PATH 环境变量,寻找 pip 可执行文件

(2)在 $(pyenv root)/shims 中找到 pip

(3)运行名为 pip 的 shim,它将命令传递给 pyenv

2. 作用

(1)通过使用 Shims,pyenv 可以实现对不同项目使用不同 Python 版本的灵活管理,而不需要手动修改环境变量或路径。

(2)你可以方便地在全局、目录级别甚至是 shell 会话级别设置或切换 Python 版本,极大地方便了开发和测试工作。

3. 示例

(1)假设你在项目 A 中使用 Python 3.8,而在项目 B 中使用 Python 3.9。通过 pyenv 和 Shims,你可以在项目目录中分别设置 Python 版本:

1
2
3
4
# 在项目 A 目录中 
pyenv local 3.8.10
# 在项目 B 目录中
pyenv local 3.9.5

(2)当你在项目 A 目录中运行 python 命令时,Shims 会确保调用的是 Python 3.8.10,而在项目 B 目录中则会调用 Python 3.9.5。

通过这种方式,Shims 实现了对不同 Python 版本的透明管理和切换。

五、Pyenv 初始化操作源码解读

1. pyenv init -

用于初始化 pyenv,使其在当前 shell 会话中工作。运行后,执行如下命令(相关说明附在注释中):

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
# 1.PATH 变量处理
## 该脚本将当前的 PATH 变量拆分为一个数组 paths,并赋予
## 通过遍历 paths 数组,检查每个路径是否为 '/root/.pyenv/shims',如果是,则将其移除
PATH="$(bash --norc -ec 'IFS=:; paths=($PATH);
for i in ${!paths[@]}; do
if [[ ${paths[i]} == "''/root/.pyenv/shims''" ]]; then unset '\''paths[i]'\'';
fi; done;
echo "${paths[*]}"')" #

# 2. 更新 PATH 变量
## 将 '/root/.pyenv/shims' 添加到 PATH 变量的最前面
export PATH="/root/.pyenv/shims:${PATH}"
## 设置 PYENV_SHELL 环境变量为 bash,sh 环境下,输出的是 shell
export PYENV_SHELL=bash
## sh 环境下,无该行代码,bash 环境下执行改行的作用是:source 命令加载 pyenv 的自动补全脚本
source '/root/.pyenv/libexec/../completions/pyenv.bash'
## 通过 command 命令执行 pyenv rehash(主要作用是重新生成 pyenv 的 shims 目录中的可执行文件),并将错误输出重定向到 /dev/null
command pyenv rehash 2>/dev/null

# 3. 定义一个 pyenv 函数,该函数根据不同的子命令执行不同的操作
## 如果子命令是 activate、deactivate、rehash 或 shell,则通过 eval 执行 pyenv "sh-$command"
## 对于其他子命令,直接调用 command pyenv "$command" "$@"
pyenv() {
local command
command="${1:-}"
if [ "$#" -gt 0 ]; then
shift
fi

case "$command" in
activate|deactivate|rehash|shell)
eval "$(pyenv "sh-$command" "$@")"
;;
*)
command pyenv "$command" "$@"
;;
esac
}

2. pyenv init –path

用于设置 PYENV_ROOT 环境变量,使得 pyenv 可以找到安装的 Python 版本。pyenv init - 包含 pyenv init --path 操作。

sh 或 bash 环境运行后,执行如下命令(相关说明附在注释中):

1
2
3
4
5
6
7
8
9
10
11
12
## 该脚本将当前的 PATH 变量拆分为一个数组 paths,并赋予
## 通过遍历 paths 数组,检查每个路径是否为 '/root/.pyenv/shims',如果是,则将其移除
PATH="$(bash --norc -ec 'IFS=:; paths=($PATH);
for i in ${!paths[@]}; do
if [[ ${paths[i]} == "''/root/.pyenv/shims''" ]]; then unset '\''paths[i]'\'';
fi; done;
echo "${paths[*]}"')"
## 将 '/root/.pyenv/shims' 添加到 PATH 变量的最前面
export PATH="/root/.pyenv/shims:${PATH}"
## 通过 command 命令执行 pyenv rehash,并将错误输出重定向到 /dev/null
command pyenv rehash 2>/dev/null

☑️ ☆

ElasticSearch集群节点

主节点(或候选主节点)

主节点负责创建索引、删除索引、分配分片、追踪集群中的节点状态等工作, 主节点负荷相对较轻, 客户端请求可以直接发往任何节点, 由对应节点负责分发和返回处理结果。

一个节点启动之后, 采用 Zen Discovery机制去寻找集群中的其他节点, 并与之建立连接, 集群会从候选主节点中选举出一个主节点, 并且一个集群只能选举一个主节点, 在某些情况下, 由于网络通信丢包等问题, 一个集群可能会出现多个主节点, 称为“脑裂现象”, 脑裂会存在丢失数据的可能, 因为主节点拥有最高权限, 它决定了什么时候可以创建索引, 分片如何移动等, 如果存在多个主节点, 就会产生冲突, 容易产生数据丢失。要尽量避免这个问题, 可以通过 discovery.zen.minimum_master_nodes 来设置最少可工作的候选主节点个数。 建议设置为(候选主节点/2) + 1 比如三个候选主节点,该配置项为 (3/2)+1 ,来保证集群中有半数以上的候选主节点, 没有足够的master候选节点, 就不会进行master节点选举,减少脑裂的可能。

主节点的参数设置:

1
2
node.master = true
node.data = false

数据节点

数据节点负责数据的存储和CRUD等具体操作,数据节点对机器配置要求比较高、,首先需要有足够的磁盘空间来存储数据,其次数据操作对系统CPU、Memory和IO的性能消耗都很大。通常随着集群的扩大,需要增加更多的数据节点来提高可用性。

数据节点的参数设置:

1
2
node.master = false
node.data = true

客户端节点

客户端节点不做候选主节点, 也不做数据节点的节点,只负责请求的分发、汇总等等,增加客户端节点类型更多是为了负载均衡的处理。

1
2
node.master = false
node.data = false

提取节点(预处理节点)

能执行预处理管道,有自己独立的任务要执行, 在索引数据之前可以先对数据做预处理操作, 不负责数据存储也不负责集群相关的事务。

协调节点

协调节点,是一种角色,而不是真实的Elasticsearch的节点,不能通过配置项来指定哪个节点为协调节点。集群中的任何节点,都可以充当协调节点的角色。当一个节点A收到用户的查询请求后,会把查询子句分发到其它的节点,然后合并各个节点返回的查询结果,最后返回一个完整的数据集给用户。在这个过程中,节点A扮演的就是协调节点的角色。

ES的一次请求非常类似于Map-Reduce操作。在ES中对应的也是两个阶段,称之为scatter-gather。客户端发出一个请求到集群的任意一个节点,这个节点就是所谓的协调节点,它会把请求转发给含有相关数据的节点(scatter阶段),这些数据节点会在本地执行请求然后把结果返回给协调节点。协调节点将这些结果汇总(reduce)成一个单一的全局结果集(gather阶段) 。

部落节点

在多个集群之间充当联合客户端, 它是一个特殊的客户端 , 可以连接多个集群,在所有连接的集群上执行搜索和其他操作。 部落节点从所有连接的集群中检索集群状态并将其合并成全局集群状态。 掌握这一信息,就可以对所有集群中的节点执行读写操作,就好像它们是本地的。 请注意,部落节点需要能够连接到每个配置的集群中的每个单个节点。

☑️ ☆

Elasticsearch Mapping 参数

本文基于 Elasticsearch 6.6.0

analyzer

指定分词器(分析器更合理),对索引和查询都有效。如下,指定ik分词的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_max_word"
}
}
}
}
}

normalizer

normalizer用于解析前的标准化配置,比如把所有的字符转化为小写等。例子:

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
PUT index
{
"settings": {
"analysis": {
"normalizer": {
"my_normalizer": {
"type": "custom",
"char_filter": [],
"filter": ["lowercase", "asciifolding"]
}
}
}
},
"mappings": {
"type": {
"properties": {
"foo": {
"type": "keyword",
"normalizer": "my_normalizer"
}
}
}
}
}

PUT index/type/1
{
"foo": "BÀR"
}

PUT index/type/2
{
"foo": "bar"
}

PUT index/type/3
{
"foo": "baz"
}

POST index/_refresh

GET index/_search
{
"query": {
"match": {
"foo": "BAR"
}
}
}

BÀR经过normalizer过滤以后转换为bar,文档1和文档2会被搜索到。

boost

boost字段用于设置字段的权重,比如,关键字出现在title字段的权重是出现在content字段中权重的2倍,设置mapping如下,其中content字段的默认权重是1.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"title": {
"type": "text",
"boost": 2
},
"content": {
"type": "text"
}
}
}
}
}

同样,在查询时指定权重也是一样的:

1
2
3
4
5
6
7
8
9
10
11
POST _search
{
"query": {
"match" : {
"title": {
"query": "quick brown fox",
"boost": 2
}
}
}
}

推荐在查询时指定boost,第一中在mapping中写死,如果不重新索引文档,权重无法修改,使用查询可以实现同样的效果。

coerce

coerce属性用于清除脏数据,coerce的默认值是true。整型数字5有可能会被写成字符串“5”或者浮点数5.0.coerce属性可以用来清除脏数据:

  • 字符串会被强制转换为整数
  • 浮点数被强制转换为整数
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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"number_one": {
"type": "integer"
},
"number_two": {
"type": "integer",
"coerce": false
}
}
}
}
}

PUT my_index/my_type/1
{
"number_one": "10"
}

PUT my_index/my_type/2
{
"number_two": "10"
}

mapping中指定number_one字段是integer类型,虽然插入的数据类型是String,但依然可以插入成功。number_two字段关闭了coerce,因此插入失败。

copy_to

copy_to属性用于配置自定义的_all字段。换言之,就是多个字段可以合并成一个超级字段。比如,first_name和last_name可以合并为full_name字段。

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"first_name": {
"type": "text",
"copy_to": "full_name"
},
"last_name": {
"type": "text",
"copy_to": "full_name"
},
"full_name": {
"type": "text"
}
}
}
}
}

PUT my_index/my_type/1
{
"first_name": "John",
"last_name": "Smith"
}

GET my_index/_search
{
"query": {
"match": {
"full_name": {
"query": "John Smith",
"operator": "and"
}
}
}
}

doc_values

doc_values是为了加快排序、聚合操作,在建立倒排索引的时候,额外增加一个列式存储映射,是一个空间换时间的做法。默认是开启的,对于确定不需要聚合或者排序的字段可以关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"status_code": {
"type": "keyword"
},
"session_id": {
"type": "keyword",
"doc_values": false
}
}
}
}
}

注:text类型不支持doc_values。

dynamic

dynamic属性用于检测新发现的字段,有三个取值:

  • true:新发现的字段添加到映射中。(默认)
  • flase:新检测的字段被忽略。必须显式添加新字段。
  • strict:如果检测到新字段,就会引发异常并拒绝文档。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PUT my_index
{
"mappings": {
"my_type": {
"dynamic": false,
"properties": {
"user": {
"properties": {
"name": {
"type": "text"
},
"social_networks": {
"dynamic": true,
"properties": {}
}
}
}
}
}
}
}

注:取值如果为strict (非布尔值)要加引号。

文档中有一个之前没有出现过的字段被添加到ELasticsearch之后,文档的type mapping中会自动添加一个新的字段。这个可以通过dynamic属性去控制,dynamic属性为false会忽略新增的字段、dynamic属性为strict会抛出异常。如果dynamic为true的话,ELasticsearch会自动根据字段的值推测出来类型进而确定mapping:

JSON格式的数据自动推测的字段类型
null没有字段被添加
true or falseboolean类型
floating类型数字floating类型
integerlong类型
JSON对象object类型
数组由数组中第一个非空值决定
string有可能是date类型(开启日期检测)、double或long类型、text类型、keyword类型

日期检测默认是检测符合以下日期格式的字符串:

1
[ "strict_date_optional_time","yyyy/MM/dd HH:mm:ss Z||yyyy/MM/dd Z"]

例子:

1
2
3
4
5
6
PUT my_index/my_type/1
{
"create_date": "2015/09/02"
}

GET my_index/_mapping

mapping 如下,可以看到create_date为date类型:

1
2
3
4
5
6
7
8
9
10
{
"my_index": {
"mappings": {
"my_type": {
"properties": {
"create_date": { "type": "date", "format": "yyyy/MM/dd HH:mm:ss||yyyy/MM/dd||epoch_millis" } }
}
}
}
}

关闭日期检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT my_index
{
"mappings": {
"my_type": {
"date_detection": false
}
}
}

PUT my_index/my_type/1
{
"create": "2015/09/02"
}

再次查看mapping,create字段已不再是date类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET my_index/_mapping
返回结果:
{
"my_index": {
"mappings": {
"my_type": {
"date_detection": false,
"properties": {
"create": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
}

自定义日期检测的格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT my_index
{
"mappings": {
"my_type": {
"dynamic_date_formats": ["MM/dd/yyyy"]
}
}
}

PUT my_index/my_type/1
{
"create_date": "09/25/2015"
}

开启数字类型自动检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PUT my_index
{
"mappings": {
"my_type": {
"numeric_detection": true
}
}
}

PUT my_index/my_type/1
{
"my_float": "1.0",
"my_integer": "1"
}

enabled

ELasticseaech默认会索引所有的字段,enabled设为false的字段,es会跳过字段内容,该字段只能从_source中获取,但是不可搜。而且字段可以是任意类型。

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
PUT my_index
{
"mappings": {
"session": {
"properties": {
"user_id": {
"type": "keyword"
},
"last_updated": {
"type": "date"
},
"session_data": {
"enabled": false
}
}
}
}
}

PUT my_index/session/session_1
{
"user_id": "kimchy",
"session_data": {
"arbitrary_object": {
"some_array": [ "foo", "bar", { "baz": 2 } ]
}
},
"last_updated": "2015-12-06T18:20:22"
}

PUT my_index/session/session_2
{
"user_id": "jpountz",
"session_data": "none",
"last_updated": "2015-12-06T18:22:13"
}

fielddata

搜索要解决的问题是“包含查询关键词的文档有哪些?”,聚合恰恰相反,聚合要解决的问题是“文档包含哪些词项”,大多数字段再索引时生成doc_values,但是text字段不支持doc_values。

取而代之,text字段在查询时会生成一个fielddata的数据结构,fielddata在字段首次被聚合、排序、或者使用脚本的时候生成。ELasticsearch通过读取磁盘上的倒排记录表重新生成文档词项关系,最后在Java堆内存中排序。

text字段的fielddata属性默认是关闭的,开启fielddata非常消耗内存。在你开启text字段以前,想清楚为什么要在text类型的字段上做聚合、排序操作。大多数情况下这么做是没有意义的。

“New York”会被分析成“new”和“york”,在text类型上聚合会分成“new”和“york”2个桶,也许你需要的是一个“New York”。这是可以加一个不分词的keyword字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"my_field": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
}
}
}
}
}

上面的mapping中实现了通过my_field字段做全文搜索,my_field.keyword做聚合、排序和使用脚本。

format

format属性主要用于格式化日期:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"date": {
"type": "date",
"format": "yyyy-MM-dd"
}
}
}
}
}

更多内置的日期格式:https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html

ignore_above

ignore_above用于指定字段索引和存储的长度最大值,超过最大值的会被忽略:

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"message": {
"type": "keyword",
"ignore_above": 15
}
}
}
}
}

PUT my_index/my_type/1
{
"message": "Syntax error"
}

PUT my_index/my_type/2
{
"message": "Syntax error with some long stacktrace"
}

GET my_index/_search
{
"size": 0,
"aggs": {
"messages": {
"terms": {
"field": "message"
}
}
}
}

mapping中指定了ignore_above字段的最大长度为15,第一个文档的字段长小于15,因此索引成功,第二个超过15,因此不索引,返回结果只有”Syntax error”,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 2,
"max_score": 0,
"hits": []
},
"aggregations": {
"messages": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": []
}
}
}

ignore_malformed

ignore_malformed可以忽略不规则数据,对于login字段,有人可能填写的是date类型,也有人填写的是邮件格式。给一个字段索引不合适的数据类型发生异常,导致整个文档索引失败。如果ignore_malformed参数设为true,异常会被忽略,出异常的字段不会被索引,其它字段正常索引。

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"number_one": {
"type": "integer",
"ignore_malformed": true
},
"number_two": {
"type": "integer"
}
}
}
}
}

PUT my_index/my_type/1
{
"text": "Some text value",
"number_one": "foo"
}

PUT my_index/my_type/2
{
"text": "Some text value",
"number_two": "foo"
}

上面的例子中number_one接受integer类型,ignore_malformed属性设为true,因此文档一种number_one字段虽然是字符串但依然能写入成功;number_two接受integer类型,默认ignore_malformed属性为false,因此写入失败。

include_in_all

include_in_all属性用于指定字段是否包含在_all字段里面,默认开启,除索引时index属性为no。
例子如下,title和content字段包含在_all字段里,date不包含。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"title": {
"type": "text"
},
"content": {
"type": "text"
},
"date": {
"type": "date",
"include_in_all": false
}
}
}
}
}

include_in_all也可用于字段级别,如下my_type下的所有字段都排除在_all字段之外,author.first_name 和author.last_name 包含在in _all中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PUT my_index
{
"mappings": {
"my_type": {
"include_in_all": false,
"properties": {
"title": { "type": "text" },
"author": {
"include_in_all": true,
"properties": {
"first_name": { "type": "text" },
"last_name": { "type": "text" }
}
},
"editor": {
"properties": {
"first_name": { "type": "text" },
"last_name": { "type": "text", "include_in_all": true }
}
}
}
}
}
}

index

index属性指定字段是否索引,不索引也就不可搜索,取值可以为true或者false。

index_options

index_options控制索引时存储哪些信息到倒排索引中,接受以下配置:

参数作用
docs只存储文档编号
freqs存储文档编号和词项频率
positions文档编号、词项频率、词项的位置被存储,偏移位置可用于临近搜索和短语查询
offsets文档编号、词项频率、词项的位置、词项开始和结束的字符位置都被存储,offsets设为true会使用Postings highlighter

fields

fields可以让同一文本有多种不同的索引方式,比如一个String类型的字段,可以使用text类型做全文检索,使用keyword类型做聚合和排序。

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"city": {
"type": "text",
"fields": {
"raw": {
"type": "keyword"
}
}
}
}
}
}
}

PUT my_index/my_type/1
{
"city": "New York"
}

PUT my_index/my_type/2
{
"city": "York"
}

GET my_index/_search
{
"query": {
"match": {
"city": "york"
}
},
"sort": {
"city.raw": "asc"
},
"aggs": {
"Cities": {
"terms": {
"field": "city.raw"
}
}
}
}

norms

norms参数用于标准化文档,以便查询时计算文档的相关性。norms虽然对评分有用,但是会消耗较多的磁盘空间,如果不需要对某个字段进行评分,最好不要开启norms。

null_value

值为null的字段不索引也不可以搜索,null_value参数可以让值为null的字段显式的可索引、可搜索。例子:

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"status_code": {
"type": "keyword",
"null_value": "NULL"
}
}
}
}
}

PUT my_index/my_type/1
{
"status_code": null
}

PUT my_index/my_type/2
{
"status_code": []
}

GET my_index/_search
{
"query": {
"term": {
"status_code": "NULL"
}
}
}

文档1可以被搜索到,因为status_code的值为null,文档2不可以被搜索到,因为status_code为空数组,但不是null。

position_increment_gap

为了支持近似或者短语查询,text字段被解析的时候会考虑此项的位置信息。举例,一个字段的值为数组类型:

1
"names": [ "John Abraham", "Lincoln Smith"]

为了区别第一个字段和第二个字段,Abraham和Lincoln在索引中有一个间距,默认是100。例子如下,这是查询”Abraham Lincoln”是查不到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT my_index/groups/1
{
"names": [ "John Abraham", "Lincoln Smith"]
}

GET my_index/groups/_search
{
"query": {
"match_phrase": {
"names": {
"query": "Abraham Lincoln"
}
}
}
}

指定间距大于100可以查询到:

1
2
3
4
5
6
7
8
9
10
11
GET my_index/groups/_search
{
"query": {
"match_phrase": {
"names": {
"query": "Abraham Lincoln",
"slop": 101
}
}
}
}

在mapping中通过position_increment_gap参数指定间距:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT my_index
{
"mappings": {
"groups": {
"properties": {
"names": {
"type": "text",
"position_increment_gap": 0
}
}
}
}
}

properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"manager": {
"properties": {
"age": { "type": "integer" },
"name": { "type": "text" }
}
},
"employees": {
"type": "nested",
"properties": {
"age": { "type": "integer" },
"name": { "type": "text" }
}
}
}
}
}
}

对应的文档结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PUT my_index/my_type/1 
{
"region": "US",
"manager": {
"name": "Alice White",
"age": 30
},
"employees": [
{
"name": "John Smith",
"age": 34
},
{
"name": "Peter Brown",
"age": 26
}
]
}

可以对manager.name、manager.age做搜索、聚合等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET my_index/_search
{
"query": {
"match": {
"manager.name": "Alice White"
}
},
"aggs": {
"Employees": {
"nested": {
"path": "employees"
},
"aggs": {
"Employee Ages": {
"histogram": {
"field": "employees.age",
"interval": 5
}
}
}
}
}
}

search_analyzer

大多数情况下索引和搜索的时候应该指定相同的分析器,确保query解析以后和索引中的词项一致。但是有时候也需要指定不同的分析器,例如使用edge_ngram过滤器实现自动补全。

默认情况下查询会使用analyzer属性指定的分析器,但也可以被search_analyzer覆盖。例子:

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
PUT my_index
{
"settings": {
"analysis": {
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 20
}
},
"analyzer": {
"autocomplete": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter"
]
}
}
}
},
"mappings": {
"my_type": {
"properties": {
"text": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard"
}
}
}
}
}

PUT my_index/my_type/1
{
"text": "Quick Brown Fox"
}

GET my_index/_search
{
"query": {
"match": {
"text": {
"query": "Quick Br",
"operator": "and"
}
}
}
}

similarity

  • BM25 :ES和Lucene默认的评分模型
  • classic :TF/IDF评分
  • boolean:布尔模型评分
    例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"default_field": {
"type": "text"
},
"classic_field": {
"type": "text",
"similarity": "classic"
},
"boolean_sim_field": {
"type": "text",
"similarity": "boolean"
}
}
}
}
}

default_field自动使用BM25评分模型,classic_field使用TF/IDF经典评分模型,boolean_sim_field使用布尔评分模型。

store

默认情况下,自动是被索引的也可以搜索,但是不存储,这也没关系,因为_source字段里面保存了一份原始文档。在某些情况下,store参数有意义,比如一个文档里面有title、date和超大的content字段,如果只想获取title和date,可以这样:

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"title": {
"type": "text",
"store": true
},
"date": {
"type": "date",
"store": true
},
"content": {
"type": "text"
}
}
}
}
}

PUT my_index/my_type/1
{
"title": "Some short title",
"date": "2015-01-01",
"content": "A very long content field..."
}

GET my_index/_search
{
"stored_fields": [ "title", "date" ]
}

查询结果:

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
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 1,
"max_score": 1,
"hits": [
{
"_index": "my_index",
"_type": "my_type",
"_id": "1",
"_score": 1,
"fields": {
"date": [
"2015-01-01T00:00:00.000Z"
],
"title": [
"Some short title"
]
}
}
]
}
}

Stored fields返回的总是数组,如果想返回原始字段,还是要从_source中取。

term_vector

词向量包含了文本被解析以后的以下信息:

  • 词项集合
  • 词项位置
  • 词项的起始字符映射到原始文档中的位置。

term_vector参数有以下取值:

参数取值含义
no默认值,不存储词向量
yes只存储词项集合
with_positions存储词项和词项位置
with_offsets词项和字符偏移位置
with_positions_offsets存储词项、词项位置、字符偏移位置

例子:

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
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"text": {
"type": "text",
"term_vector": "with_positions_offsets"
}
}
}
}
}

PUT my_index/my_type/1
{
"text": "Quick brown fox"
}

GET my_index/_search
{
"query": {
"match": {
"text": "brown fox"
}
},
"highlight": {
"fields": {
"text": {}
}
}
}

动态Mapping _default_

在mapping中使用default字段,那么其它字段会自动继承default中的设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PUT my_index
{
"mappings": {
"_default_": {
"_all": {
"enabled": false
}
},
"user": {},
"blogpost": {
"_all": {
"enabled": true
}
}
}
}

上面的 mapping 中,_default_ 中关闭了 _all 字段,user会继承 _default_ 中的配置,因此 user 中的 _all 字段也是关闭的,blogpost 中开启 _all,覆盖了 _default 的默认配置。

default被更新以后,只会对后面新加的文档产生作用。

dynamic_templates

动态模板可以根据字段名称设置mapping,如下对于string类型的字段,设置mapping为:

1
"mapping": { "type": "long"}

但是匹配字段名称为long_格式的,不匹配_text格式的:

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
PUT my_index
{
"mappings": {
"my_type": {
"dynamic_templates": [
{
"longs_as_strings": {
"match_mapping_type": "string",
"match": "long_*",
"unmatch": "*_text",
"mapping": {
"type": "long"
}
}
}
]
}
}
}

PUT my_index/my_type/1
{
"long_num": "5",
"long_text": "foo"
}12345678910111213141516171819202122232425

写入文档以后,long_num字段为long类型,long_text 仍为string类型。

☑️ ☆

Elasticsearch的数据类型

本文基于 Elasticsearch 6.6.0

1 核心数据类型

1.1 字符串类型 - string(不再支持)

(1) 使用示例:

1
2
3
4
5
6
7
8
9
10
11
PUT website
{
"mappings": {
"blog": {
"properties": {
"title": {"type": "string"}, // 全文本
"tags": {"type": "string", "index": "not_analyzed"}// 关键字, 不分词
}
}
}
}

(2) ES 5.6.10中的响应信息:

1
2
3
4
5
6
7
#! Deprecation: The [string] field is deprecated, please use [text] or [keyword] instead on [tags]
#! Deprecation: The [string] field is deprecated, please use [text] or [keyword] instead on [title]
{
"acknowledged": true,
"shards_acknowledged": true,
"index": "website"
}

(3) ES 6.6.0中的响应信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"error": {
"root_cause": [
{
"type": "mapper_parsing_exception",
"reason": "No handler for type [string] declared on field [title]"
}
],
"type": "mapper_parsing_exception",
"reason": "Failed to parse mapping [blog]: No handler for type [string] declared on field [title]",
"caused_by": {
"type": "mapper_parsing_exception",
"reason": "No handler for type [string] declared on field [title]"
}
},
"status": 400
}

可知string类型的field已经被移除了, 我们需要用text或keyword类型来代替string.

1.1.1 文本类型 - text

在Elasticsearch 5.4 版本开始, text取代了需要分词的string.

—— 当一个字段需要用于全文搜索(会被分词), 比如产品名称、产品描述信息, 就应该使用text类型.

text的内容会被分词, 可以设置是否需要存储: "index": "true|false".
text类型的字段不能用于排序, 也很少用于聚合.

使用示例:

1
2
3
4
5
6
7
8
9
10
PUT website
{
"mappings": {
"blog": {
"properties": {
"summary": {"type": "text", "index": "true"}
}
}
}
}

1.1.2 关键字类型 - keyword

在Elasticsearch 5.4 版本开始, keyword取代了不需要分词的string.

—— 当一个字段需要按照精确值进行过滤、排序、聚合等操作时, 就应该使用keyword类型.

keyword的内容不会被分词, 可以设置是否需要存储: "index": "true|false".

使用示例:

1
2
3
4
5
6
7
8
9
10
PUT website
{
"mappings": {
"blog": {
"properties": {
"tags": {"type": "keyword", "index": "true"}
}
}
}
}

1.2 数字类型 - 8种

数字类型有如下分类:

类型说明
byte有符号的8位整数, 范围: [-128 ~ 127]
short有符号的16位整数, 范围: [-32768 ~ 32767]
integer有符号的32位整数, 范围: [−231 ~ 231-1]
long有符号的64位整数, 范围: [−263 ~ 263-1]
float32位单精度浮点数
double64位双精度浮点数
half_float16位半精度IEEE 754浮点类型
scaled_float缩放类型的的浮点数, 比如price字段只需精确到分, 57.34缩放因子为100, 存储结果为5734

使用注意事项:

尽可能选择范围小的数据类型, 字段的长度越短, 索引和搜索的效率越高;
优先考虑使用带缩放因子的浮点类型.

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PUT shop
{
"mappings": {
"book": {
"properties": {
"name": {"type": "text"},
"quantity": {"type": "integer"}, // integer类型
"price": {
"type": "scaled_float", // scaled_float类型
"scaling_factor": 100
}
}
}
}
}

1.3 日期类型 - date

JSON没有日期数据类型, 所以在ES中, 日期可以是:

  • 包含格式化日期的字符串, “2018-10-01”, 或”2018/10/01 12:10:30”.
  • 代表时间毫秒数的长整型数字.
  • 代表时间秒数的整数.

如果时区未指定, 日期将被转换为UTC格式, 但存储的却是长整型的毫秒值.
可以自定义日期格式, 若未指定, 则使用默认格式: strict_date_optional_time||epoch_millis

(1) 使用日期格式示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 添加映射
PUT website
{
"mappings": {
"blog": {
"properties": {
"pub_date": {"type": "date"} // 日期类型
}
}
}
}

// 添加数据
PUT website/blog/11
{ "pub_date": "2018-10-10" }

PUT website/blog/12
{ "pub_date": "2018-10-10T12:00:00Z" }// Solr中默认使用的日期格式

PUT website/blog/13
{ "pub_date": "1589584930103" }// 时间的毫秒值

(2) 多种日期格式:

多个格式使用双竖线||分隔, 每个格式都会被依次尝试, 直到找到匹配的.
第一个格式用于将时间毫秒值转换为对应格式的字符串.

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 添加映射
PUT website
{
"mappings": {
"blog": {
"properties": {
"date": {
"type": "date", // 可以接受如下类型的格式
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}
}
}
}
}

1.4 布尔类型 - boolean

可以接受表示真、假的字符串或数字:

  • 真值: true, “true”, “on”, “yes”, “1”…
  • 假值: false, “false”, “off”, “no”, “0”, “”(空字符串), 0.0, 0

1.5 二进制型 - binary

二进制类型是Base64编码字符串的二进制值, 不以默认的方式存储, 且不能被搜索. 有2个设置项:

(1) doc_values: 该字段是否需要存储到磁盘上, 方便以后用来排序、聚合或脚本查询. 接受truefalse(默认);
(2) store: 该字段的值是否要和_source分开存储、检索, 意思是除了_source中, 是否要单独再存储一份. 接受truefalse(默认).

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 添加映射
PUT website
{
"mappings": {
"blog": {
"properties": {
"blob": {"type": "binary"} // 二进制
}
}
}
}
// 添加数据
PUT website/blog/1
{
"title": "Some binary blog",
"blob": "hED903KSrA084fRiD5JLgY=="
}

注意: Base64编码的二进制值不能嵌入换行符\n, 逗号(0x2c)等符号.

1.6 范围类型 - range

range类型支持以下几种:

类型范围
integer_range−231 ~ 231−1
long_range−263 ~ 263−1
float_range32位单精度浮点型
double_range64位双精度浮点型
date_range64位整数, 毫秒计时
ip_rangeIP值的范围, 支持IPV4和IPV6, 或者这两种同时存在

(1) 添加映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT company
{
"mappings": {
"department": {
"properties": {
"expected_number": { // 预期员工数
"type": "integer_range"
},
"time_frame": { // 发展时间线
"type": "date_range",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"ip_whitelist": { // ip白名单
"type": "ip_range"
}
}
}
}
}

(2) 添加数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT company/department/1
{
"expected_number" : {
"gte" : 10,
"lte" : 20
},
"time_frame" : {
"gte" : "2018-10-01 12:00:00",
"lte" : "2018-11-01"
},
"ip_whitelist": "192.168.0.0/16"
}

(3) 查询数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET company/department/_search
{
"query": {
"term": {
"expected_number": {
"value": 12
}
}
}
}
GET company/department/_search
{
"query": {
"range": {
"time_frame": {
"gte": "208-08-01",
"lte": "2018-12-01",
"relation": "within"
}
}
}
}

查询结果:

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
{
"took": 26,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 1,
"max_score": 1.0,
"hits": [
{
"_index": "company",
"_type": "department",
"_id": "1",
"_score": 1.0,
"_source": {
"expected_number": {
"gte": 10,
"lte": 20
},
"time_frame": {
"gte": "2018-10-01 12:00:00",
"lte": "2018-11-01"
},
"ip_whitelist" : "192.168.0.0/16"
}
}
]
}
}

2 复杂数据类型

2.1 数组类型 - array

ES中没有专门的数组类型, 直接使用[]定义即可;

数组中所有的值必须是同一种数据类型, 不支持混合数据类型的数组:

① 字符串数组: [“one”, “two”];
② 整数数组: [1, 2];
③ 由数组组成的数组: [1, [2, 3]], 等价于[1, 2, 3];
④ 对象数组: [{“name”: “Tom”, “age”: 20}, {“name”: “Jerry”, “age”: 18}].

注意:

  • 动态添加数据时, 数组中第一个值的类型决定整个数组的类型;
  • 不支持混合数组类型, 比如[1, “abc”];
  • 数组可以包含null值, 空数组[]会被当做missing field —— 没有值的字段.

2.2 对象类型 - object

JSON文档是分层的: 文档可以包含内部对象, 内部对象也可以包含内部对象.

(1) 添加示例:

1
2
3
4
5
6
7
8
9
PUT employee/developer/1
{
"name": "ma_shoufeng",
"address": {
"region": "China",
"location": {"province": "GuangDong", "city": "GuangZhou"}
}
}

(2) 存储方式:

1
2
3
4
5
6
7
{
"name": "ma_shoufeng",
"address.region": "China",
"address.location.province": "GuangDong",
"address.location.city": "GuangZhou"
}

(3) 文档的映射结构类似为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PUT employee
{
"mappings": {
"developer": {
"properties": {
"name": { "type": "text", "index": "true" },
"address": {
"properties": {
"region": { "type": "keyword", "index": "true" },
"location": {
"properties": {
"province": { "type": "keyword", "index": "true" },
"city": { "type": "keyword", "index": "true" }
}
}
}
}
}
}
}
}

2.3 嵌套类型 - nested

嵌套类型是对象数据类型的一个特例, 可以让array类型的对象被独立索引和搜索.

2.3.1 对象数组是如何存储的

① 添加数据:

1
2
3
4
5
6
7
8
9
PUT game_of_thrones/role/1
{
"group": "stark",
"performer": [
{"first": "John", "last": "Snow"},
{"first": "Sansa", "last": "Stark"}
]
}

② 内部存储结构:

1
2
3
4
5
6
{
"group": "stark",
"performer.first": [ "john", "sansa" ],
"performer.last": [ "snow", "stark" ]
}

③ 存储分析:

可以看出, user.first和user.last会被平铺为多值字段, 这样一来, John和Snow之间的关联性就丢失了.

在查询时, 可能出现John Stark的结果.

2.3.2 用nested类型解决object类型的不足

如果需要对以最对象进行索引, 且保留数组中每个对象的独立性, 就应该使用嵌套数据类型.

—— 嵌套对象实质是将每个对象分离出来, 作为隐藏文档进行索引.

① 创建映射:

1
2
3
4
5
6
7
8
9
10
11
PUT game_of_thrones
{
"mappings": {
"role": {
"properties": {
"performer": {"type": "nested" }
}
}
}
}

② 添加数据:

1
2
3
4
5
6
7
8
9
PUT game_of_thrones/role/1
{
"group" : "stark",
"performer" : [
{"first": "John", "last": "Snow"},
{"first": "Sansa", "last": "Stark"}
]
}

③ 检索数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET game_of_thrones/_search
{
"query": {
"nested": {
"path": "performer",
"query": {
"bool": {
"must": [
{ "match": { "performer.first": "John" }},
{ "match": { "performer.last": "Snow" }}
]
}
},
"inner_hits": {
"highlight": {
"fields": {"performer.first": {}}
}
}
}
}
}

3 地理数据类型

3.1 地理点类型 - geo point

地理点类型用于存储地理位置的经纬度对, 可用于:

  • 查找一定范围内的地理点;
  • 通过地理位置或相对某个中心点的距离聚合文档;
  • 将距离整合到文档的相关性评分中;
  • 通过距离对文档进行排序.

(1) 添加映射:

1
2
3
4
5
6
7
8
9
10
11
PUT employee
{
"mappings": {
"developer": {
"properties": {
"location": {"type": "geo_point"}
}
}
}
}

(2) 存储地理位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 方式一: 纬度 + 经度键值对
PUT employee/developer/1
{
"text": "小蛮腰-键值对地理点参数",
"location": {
"lat": 23.11, "lon": 113.33// 纬度: latitude, 经度: longitude
}
}

// 方式二: "纬度, 经度"的字符串参数
PUT employee/developer/2
{
"text": "小蛮腰-字符串地理点参数",
"location": "23.11, 113.33" // 纬度, 经度
}

// 方式三: ["经度, 纬度"] 数组地理点参数
PUT employee/developer/3
{
"text": "小蛮腰-数组参数",
"location": [ 113.33, 23.11 ] // 经度, 纬度
}

(3) 查询示例:

1
2
3
4
5
6
7
8
9
10
11
12
GET employee/_search
{
"query": {
"geo_bounding_box": {
"location": {
"top_left": { "lat": 24, "lon": 113 },// 地理盒子模型的上-左边
"bottom_right": { "lat": 22, "lon": 114 }// 地理盒子模型的下-右边
}
}
}
}

3.2 地理形状类型 - geo_shape

是多边形的复杂形状. 使用较少, 这里省略.

4 专门数据类型

4.1 IP类型

IP类型的字段用于存储IPv4或IPv6的地址, 本质上是一个长整型字段.

(1) 添加映射:

1
2
3
4
5
6
7
8
9
10
11
PUT employee
{
"mappings": {
"customer": {
"properties": {
"ip_addr": { "type": "ip" }
}
}
}
}

(2) 添加数据:

1
2
3
PUT employee/customer/1
{ "ip_addr": "192.168.1.1" }

(3) 查询数据:

1
2
3
4
5
6
7
GET employee/customer/_search
{
"query": {
"term": { "ip_addr": "192.168.0.0/16" }
}
}

4.2 计数数据类型 - token_count

token_count类型用于统计字符串中的单词数量.

本质上是一个整数型字段, 接受并分析字符串值, 然后索引字符串中单词的个数.

(1) 添加映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PUT employee
{
"mappings": {
"customer": {
"properties": {
"name": {
"type": "text",
"fields": {
"length": {
"type": "token_count",
"analyzer": "standard"
}
}
}
}
}
}
}

(2) 添加数据:

1
2
3
4
5
PUT employee/customer/1
{ "name": "John Snow" }
PUT employee/customer/2
{ "name": "Tyrion Lannister" }

(3) 查询数据:

1
2
3
4
5
6
7
GET employee/customer/_search
{
"query": {
"term": { "name.length": 2 }
}
}

参考资料

Elasticsearch 6.6 官方文档 - Field datatypes

🔲 ☆

家庭用电插座

10a和16a插座的区别

1、外观区别

主要是插孔间距的尺寸区别。16a插座三插孔的孔距比10a插座更大。所以不同标准的插孔和插头并不能适用。

10a插座为五眼插:1个三眼、1个二眼,而16a插座是一个三眼插,比10a三眼插宽广些。

2、使用区别

16a插头和10a插头不通用,10a插头插不到16a插座里去,当然反过来也是不行的。

3、插座金属

16a插座承载电流大于10a插座,用到的铜也比较多,而10a插座用的材料也有所不同。

16a插座需要布置4平方毫米以上规格的铜线,而10a插座最好布置2.5平方毫米铜线。

4、承受范围

16a插座可以承受3000瓦以内电器功率,而10a插座功率控制在2200瓦以内,不然容易发生意外。

哪些电器用16a插座

家里常用的大功率电器主要是空调、电磁炉、热水器

16a插座是承受范围比较大的插座,家居中一般主要在空调电器上使用,所以我们经常可以见到空调的隔壁安装的是一个不一样的插座,这是对用电安全的需求

☑️ ☆

Go 1.21 新增特性

新的内置函数

1.21添加了三个新的内置函数:minmaxclear

minmax如其字面意思,用了选出一组变量里(数量大于等于1,只有一个变量的时候就返回那个变量的值)最大的或者最小的值。两个函数定义是这样的:

1
2
func min[T cmp.Ordered](x T, y ...T) T
func max[T cmp.Ordered](x T, y ...T) T

注意那个类型约束,这是新的标准库里提供的,原型如下:

1
2
3
4
5
6
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}

也就是说只有基于所有除了map,chan,slice以及复数之外的基本类型的变量才能使用这两个函数。或者换句话说,只有可以使用<><=>===!=进行比较的类型才可以使用min和max。

有了min和max,可以把许多自己手写的代码替换成新的内置函数,可以少写一些帮助函数。而且使用新的内置函数还有一个好处,在变量个数比较少的时候还有编译器的优化可用,比自己写min函数性能上要稍好一些。

使用上也很简单:

1
2
maxIntValue := max(0, 7, 6, 5, 4, 3, 2, 1) // 7 type int
minIntValue := min(8, 7, 6, 5, 4, 3, 2, 1) // 1 type int

目前max和min都不支持slice的解包操作:max(1, numbers...)

对于clear来说事情比min和max复杂。clear只接受slice和map,如果是对泛型的类型参数使用clear,那么类型参数的type set必须是map或者slice,否则编译报错。

clear的定义如下:

1
func clear[T ~[]Type | ~map[Type]Type1](t T)

对于参数是map的情况,clear会删除所有map里的元素(不过由于golang的map本身的特性,map存储的数据会被正常销毁,但map已经分配的空间不会释放):

1
2
3
4
5
6
func main() {
m := map[int]int{1:1, 2:2, 3:3, 4:4, 5:5}
fmt.Println(len(m)) // 5
clear(m)
fmt.Println(len(m)) // 0
}

然而对于slice,它的行为又不同了:会把slice里所有元素变回零值。看个例子:

1
2
3
4
5
6
7
8
9
func main() {
s := make([]int, 0, 100) // 故意给个大的cap便于观察
s = append(s, []int{1, 2, 3, 4, 5}...)
fmt.Println(s) // [1 2 3 4 5]
fmt.Println(len(s), cap(s)) // len: 5; cap: 100
clear(s)
fmt.Println(s) // [0 0 0 0 0]
fmt.Println(len(s), cap(s)) // len: 5; cap: 100
}

这个就比较反直觉了,毕竟clear首先想到的应该是把所有元素删除。那它的意义是什么呢?对于map来说意义是很明确的,但对于slice来说就有点绕弯了:

slice的真实大小是cap所显示的那个大小,如果只是用s := s[:0]来把所有元素“删除”,那么这些元素实际上还是留在内存里的,直到s本身被gc回收或者往s里添加新元素把之前的对象覆盖掉,否则这些对象是不会被回收掉的,这一方面可以提高内存的利用率,另一方面也会带来泄露的问题(比如存储的是指针类型或者包含指针类型的值的时候,因为指针还存在,所以被指向的对象始终有一个有效的引用导致无法被回收),所以golang选择了折中的办法:把所有已经存在的元素设置成0值

如果想安全的正常删除slice的所有元素,有想复用slice的内存,该怎么办?答案是:

1
2
3
4
5
s := make([]T, 0, 100) // 故意给个大的cap便于观察
s = append(s, []T{*new(T), *new(T)}...)

clear(s)
s = s[:0]

如果没有内置函数clear,那么我们得自己循环一个个赋值处理。而有clear的好处是,编译器会直接用memset把slice的内存里的数据设置为0,比循环会快很多。有兴趣的可以看看clear在slice上的实现:代码在这

类型推导

以前类似这样的代码在某些情况下没法正常进行推导:

1
2
3
4
func F[T ~E[], E any](t T, callable func(E))

func generic[E any](e E) {}
F(t, generic) // before go1.21: error; after go1.21: ok

理论上只要能推导出E的类型,那么Fgeneric的所有类型参数都能推导出来,哪怕generic本身是个泛型函数。以前想正常使用就得这么写:F(t, generic[Type])

所以与其说是新特性,不如说是对类型推导的bug修复。

针对类型推导还有其他一些修复和报错信息的内容优化,但这些都没上面这个变化大,所以恕不赘述。

panic的行为变化

1.21开始如果goroutine里有panic,那么这个goroutine里的defer里调用的recover必然不会返回nil值。

这导致了一个问题:recover的返回值是传给panic的参数的值,panic(nil)这样的代码怎么办?

先要提醒一下,panic(nil)本身是无意义的,且会导致recover的调用方无法判断究竟发生了什么,所以一直是被各类linter包括go vet命令警告的。然而这么写语法上完全正确,所以只有警告并不能解决问题。

解决办法是,如果现在使用panic(nil)或者panic(值为nil的接口),recover会收到一个新类型的error:*runtime.PanicNilError

总体上算是解决了问题,然而它把有类型的但值是nil的接口也给转换了,虽然从最佳实践的角度来讲panic一个空值的接口是不应该的,但多少还是会给使用上带来一些麻烦。

所以目前想要禁用这一行为的话,可以设置环境变量:export GODEBUG=panicnil=1。如果go.mod里声明的go版本小于等于1.20,这个环境变量的功能自动启用。

对于modules的变化,会在下一节讲解。

modules的变化

最大的变化是现在mod文件里写的go版本号的意义改变了。

变成了:mod文件里写的go的版本意味着这个mod最低支持的golang版本是多少。

比如:

1
2
3
module github.com/apocelipes/flatmap

go 1.21.0

意味着这个modules最低要求go的版本是go1.21.0,而且可以注意到,现在patch版本也算在内里,所以一个声明为go 1.21.1的modules没法被1.21.0版本的go编译。

这么做的好处是能严格控制自己的程序和库可以在哪些版本的golang上运行,且可以推动库版本和golang本身版本的升级。

如果严格按照官方要求使用语义版本来控制自己的modules的话,这个改动没有什么明显的坏处,唯一的缺点是只有1.21及更高版本的go工具链才有这样的功能。

这个变更对go.work文件同样适用。

包初始化顺序的改变

现在按新的顺序来初始化包:

  1. 把所有的packages按导入路径进行排序(字符串字典顺序)存进一个列表
  2. 按要求和顺序找到列表里第一个符合条件的package,要求是这个package所有的import的包都已经完成初始化
  3. 初始化这个找到的包然后把它移出列表,接着重复第二步
  4. 列表为空的时候初始化流程结束

这样做的好处是包的初始化顺序终于有明确的标准化的定义了,坏处有两点:

  1. 以前的程序如果依赖于特定的初始化顺序,那么在新版本会出问题
  2. 现在可以通过修改package的导入路径(主要能改的部分是包的名字)和控制导入的包来做到让A包始终在B包之前初始化,因此B包的初始化流程里可以对A包公开出来的接口或者数据进行修改,这样做耦合性很高也不安全,尤其是B包如果是某个包含恶意代码的包的话。

我们能做的只有遵守最佳实践:不要依赖特定的包直接的初始化顺序;以及在使用某个第三方库前要仔细考量。

编译器和runtime的变化

runtime的变化上,gc一如既往地得到了小幅度优化,现在对于gc压力较大的程序来说gc延迟和内存占用都会有所减少。

cgo也有优化,现在cgo函数调用最大可以比原先快一个数量级。

编译器的变化上比较显著的是这个:PGO已经可以正式投入生产使用。使用教程

PGO可以带来6%左右的性能提升,1.21凭借PGO和上个版本的优化现在不仅没有了泛型带来的编译速度减低,相比以前还有细微提升。

还有最后一个变化,这个和编译器关系:现在没有被使用的全局的map类型的变量(需要达到一定大小,且初始化的语句中没有任何副作用会产生),现在编译完成的程序里不会在包含这个变量。因为map本身占用内存且初始化需要花费一定时间(map越大花的时间越多)。这个好处是很实在的,既可以减小产生的二进制可执行文件的大小,又可以提升运行性能。但有个缺点,如果有什么程序要依赖编译好的可执行文件内部的某些数据的话,这个变更可能会带来麻烦,普通用户可以忽略这点。

新标准库

这个版本添加了大把的新标准库,一起来看看。

log/slog和testing/slogtest

官方提供的结构化日志库。

可以通过实现slog.Handler来定义自己的日志处理器,可以把日志转换成json等格式。标准库自带了很多预定义的处理器,比如json的:

1
2
3
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello", "count", 3)
// {"time":"2023-08-09T15:28:26.000000000+09:00","level":"INFO","msg":"hello","count":3}

简单得说,就是个简化版的zap,如果想使用最基础的结构化日志的功能,又不想引入zap这样的库,那么slog是个很好的选择。

testing/slogtest里有帮助函数用来测试自己实现的日志处理器是否符合标准库的要求。

slices和maps

golang.org/x/exp/slicesgolang.org/x/exp/maps引入了标准库。

slices库提供了排序、二分查找、拼接、增删改查等常用功能,sort这个标准库目前可以停止使用用slices来替代了。

maps提供了常见的对map的增删改查拼接合并等功能。

两个库使用泛型,且针对golang的slice和map进行了细致入微的优化,性能上比自己写的版本有更多优势,比标准库sort更是有数量级的碾压。

这两个库本来1.20就该被接收进标准库了,但因为需要重新设计api和进行优化,所以拖到1.21了。

cmp

这个也是早该进入标准库的,但拖到了现在。随着slices、maps和新内置函数都进入了新版本,这个库想不接收也不行了。

这个库一共有三个东西:OrderedLessCompare

最重要的是Ordered,它是所有可以使用内置运算符进行比较的类型的集合。

LessOrdered顾名思义用来比大小的,且只能比Ordered类型的大小。

之所以还有单独造出这两个函数,是因为他们对Nan有检查,比如:

1
2
3
4
5
6
// Less reports whether x is less than y.
// For floating-point types, a NaN is considered less than any non-NaN,
// and -0.0 is not less than (is equal to) 0.0.
func Less[T Ordered](x, y T) bool {
return (isNaN(x) && !isNaN(y)) || x < y
}

所以在泛型函数里不知道要比较的数据的类型是不是有float的时候,用cmp里提供的函数是最安全的。这就是他俩存在的意义。

但如果可以100%确定没有float存在,那么就不应该用Less等,应该直接用运算符去比较,因为大家都看到,Less和直接比较相比效率是较低的。

已有的标准库的变化

因为是速览,所以我只挑重点说。

bytes

bytes.Buffer添加了AvailableBufferAvailable两个方法,分别返回目前可用的buf切片和可用的长度。主要可以配合strconv.AppendInt来使用,直接把数据写入buffer对应的内存里,可以提升性能。不要对AvailableBuffer返回的切片扩容,否则必然踩坑

context

新的context.WithoutCancel会把原来的context.Context复制一份,并去除cancel函数,这意味着原先被复制的上下文取消了这个新的上下文也将继续存在。例子:

1
2
3
4
5
6
7
func main() {
ctx, cancel := context.WithCancel(context.Background())
newCtx := context.WithoutCancel(ctx)
cancel()
<-ctx.Done() // ok, ctx has cancled.
<-newCtx.Done() // error: dead lock!
}

之所以会死锁,是因为newCtx没有被取消,Done返回的chan会永远阻塞住。而且更根本的,newCtx无法被取消。

新增了context.WithDeadlineCausecontext.WithTimeoutCause,可以增加超时上下文被取消时的信息:

1
2
3
4
d := time.Now().Add(shortDuration)
ctx, cancel := context.WithDeadline(context.Background(), d, &MyError{"my message"})
cancel()
context.Cause(ctx) // --> &MyError{"my message"}

虽然不如context.WithCancelCause灵活,但也很实用。

crypto/sha256

现在在x86_64平台上计算sha256会尽量利用硬件指令(simd和x86_64平台的SHA256ROUND等指令),这带来了3-4倍的性能提升。

net

现在golang在Linux上已经初步支持Multipath TCP。有关Multipath TCP的信息可以在这查阅:https://www.multipath-tcp.org/

reflect

ValueOf现在会根据逃逸分析把值分配在栈上,以前都是直接分配到堆上的。对于比较小的类型来说可以获得10%以上的性能提升。利好很多使用反射的ORM框架。

新增了Value.Clear,对应第一节的clear内置函数,如果type不是map或者slice的话这个函数和其他反射的方法一样会panic。

runtime

最值得一提的变化是新增了runtime.Pinner

它的能力是可以让某个go的对象不会gc回收,一直到Unpin方法被调用。这个是为了方便cgo代码里让c使用go的对象而设计的。

不要滥用这个接口,如果想告诉gc某个对象暂时不能回收,应该正确使用runtime.KeepAlive

runtime/trace现在有了很大的性能提升,因此观察程序行为的时候开销更小,更接近程序真实的负载。

sync

添加了OnceFuncOnceValueOnceValues这三个帮助函数。主要是为了简化代码。

1.21前:

1
2
3
4
5
6
7
8
var initFlag sync.Once

func GetSomeThing() {
initFlag.Do(func(){
真正的初始化逻辑
})
// 后续处理
}

现在变成:

1
2
3
4
5
6
7
8
var doInit = sync.OnceFunc(func(){
真正的初始化逻辑
})

func GetSomeThing() {
doInit()
// 后续处理
}

新代码要简单点。

OnceValueOnceValues是函数带返回值的版本,支持一个和两个返回值的函数。

errors

新增了errors.ErrUnsupported。这个错误表示当前操作系统、硬件、协议、或者文件系统不支持某种操作。

目前os,filepath,syscall,io里的一些函数已经会返回这个错误,可以用errors.Is(err, errors.ErrUnsupported)来检查。

unicode

升级到了最新的Unicode 15.0.0。

平台支持变化

新增了wasip1支持,这是一个对WASI(WebAssembly System Interface)的初步支持。

对于macOS,go1.21需要macOS 10.15 Catalina及以上版本。

龙芯上golang现在支持将代码编译为c的动态和静态链接库,基本上在龙芯上已经可以尝试投入生产环境了。

发版日志 Go 1.21 Release Notes - The Go Programming Language

☑️ ☆

jq命令

一个灵活的轻量级命令行JSON处理器

补充说明

jq 是 stedolan 开发的一个轻量级的和灵活的命令行JSON处理器,源码请参考 jq 项目主页

jq 用于处理JSON输入,将给定过滤器应用于其JSON文本输入并在标准输出上将过滤器的结果生成为JSON。

最简单的过滤器是.,它将jq的输入未经修改地复制到其输出中(格式设置除外)。

请注意,jq 当前仅支持64位双精度浮点数(IEEE754)。

安装

1
# Debian系,如 Ubuntu sudo apt-get install jq # RedHat系, 如 CentOS yum install jq

语法

1
jq [options] <jq filter> [file...] jq [options] --args <jq filter> [strings...] jq [options] --jsonargs <jq filter> [JSON_TEXTS...]

选项

1
-c 紧凑而不是漂亮的输出; -n 使用`null`作为单个输入值; -e 根据输出设置退出状态代码; -s 将所有输入读取(吸取)到数组中;应用过滤器; -r 输出原始字符串,而不是JSON文本; -R 读取原始字符串,而不是JSON文本; -C 为JSON着色; -M 单色(不要为JSON着色); -S 在输出上排序对象的键; --tab 使用制表符进行缩进; --arg a v 将变量$a设置为value<v>; --argjson a v 将变量$a设置为JSON value<v>; --slurpfile a f 将变量$a设置为从<f>读取的JSON文本数组; --rawfile a f 将变量$a设置为包含<f>内容的字符串; --args 其余参数是字符串参数,而不是文件; --jsonargs 其余的参数是JSON参数,而不是文件; -- 终止参数处理;

例子

.: 以漂亮的方式输出

1
$ echo '{ "foo": { "bar": { "baz": 123 } } }' | jq '.' { "foo": { "bar": { "baz": 123 } } } 

.foo, .foo.bar, .foo?: 获取一个键的值

1
$ echo '{"foo": 42, "bar": "less interesting data"}' | jq '.foo' 42

.[], .[]?, .[2], .[10:15]: 数组运算

1
$ echo '[{"name":"JSON", "good":true}, {"name":"XML", "good":false}]' | jq '.[1]' { "name": "XML", "good": false } 

[], {}: 构造一个数组/对象

1
$ echo '{"user":"stedolan","titles":["JQ Primer", "More JQ"]}' | jq '{user, title: .titles[]}' { "user": "stedolan", "title": "JQ Primer" } { "user": "stedolan", "title": "More JQ" } 

length: 计算一个值的长度

1
$ echo '[[1,2], "string", {"a":2}, null]' | jq '.[] | length' 2 6 1 0 

keys: 取出数组中的键

1
$ echo '{"abc": 1, "abcd": 2, "Foo": 3}' | jq 'keys' [ "Foo", "abc", "abcd" ] 

,: 使用多个过滤器

1
$ echo '{ "foo": 42, "bar": "something else", "baz": true}' | jq '.foo, .bar' 42 "something else" 

|: 通过管道将一个过滤器的输出当做下一个过滤器的输入

1
$ echo '[{"name":"JSON", "good":true}, {"name":"XML", "good":false}]' | jq '.[] | .name' "JSON" "XML" 

select(foo): 如果foo返回true,则输入保持不变

1
$ echo '[1,5,3,0,7]' | jq 'map(select(. >= 2))' [ 5, 3, 7 ] 

map(foo): 每个输入调用过滤器

1
$ echo '[1,2,3]' | jq 'map(.+1)' [ 2, 3, 4 ] 

if-then-else-end: 条件判断

1
$ echo '2' | jq 'if . == 0 then "zero" elif . == 1 then "one" else "many" end' "many" 

\(foo): 在字符串中插入值并进行运算

1
$ echo '42' | jq '"The input was \(.), which is one less than \(.+1)"' "The input was 42, which is one less than 43" 
☑️ ☆

Mac OS守护进程优化建议

/sbin/launchd
系统及用户进程管理器,它是内核装载成功后在OS环境下启动的第一个进程,是Mac OS最重要的进程之一。你无法禁用它。

/usr/libexec/kextd
内核扩展服务,响应内核或用户进程的请求,比如装载或卸载内核扩展或提供内核扩展信息给它们。这是Mac的关键守护进程,请不要去禁用它。

/usr/sbin/notifyd
消息服务,这是Mac OS消息系统的组成部分之一。我们知道,操作系统的很多组件需要依赖异步消息来通信,这个服务能保证它们正常工作。请不要去禁用它。

/usr/sbin/diskarbitrationd
磁盘仲裁服务,作用是为磁盘卷或其他存储部件进行挂载,取消挂载或弹出(比如光驱和dmg)。最常见的就是USB移动硬盘,MP3,IPHONE,IPAD等。
它的原理是当内核发现有新硬件插入时,内核先识别该硬件,如果能识别,则为硬件装载驱动,并通知 diskarbitrationd 挂载它。取消挂载同理。
如果这个服务被禁用,所有即插即用存储设备都会出现异常。建议不要禁用它。

/usr/libexec/configd
保存计算机和系统环境的动态配置信息。为需要用到这些信息的进程提供变更通知。比如网络服务(tcp/ip或wins更新等)。
如果这个服务被禁用,网络和一些需要动态配置信息的组件将会出现异常。建议不要禁用它。

/usr/sbin/syslogd
系统日志服务,用于记录系统或软件的消息日志,是系统或软件崩溃时查错的关键途径。某些工具也可能依赖与日志在提供服务。
如果这个服务被禁用,所有的消息日志都将停止记录,并可能导致某些软件工作异常。建议不要禁用它。

/usr/sbin/DirectoryService
目录信息收集中心,它会收集各种目录的用户,用户组,权限和路径信息,并在应用程序需要时,反馈给它们。目录的介质主要是指本地磁 盘,LDAP,Netinfo, Active Directory, NIS, Bonjour/Rendesvous/, AppleTalk, Samba FS(SMB)等。
如果这个服务被禁用,可能会导致部分程序性能降低或出现异常。建议不要禁用它。

/usr/sbin/distnoted
提供分布式的消息服务,类似notifyd,但它主要是处理系统外部的一些消息,比如 itune与iphone, ipad, itouch的连接及消息传递功能。
如果你有使用Apple的即插即用设备,为了保证其功能正常使用,建议你不要禁用它。如果没有apple设备,保险期间,还是留着它吧。

/usr/sbin/ntpd
时间同步服务,负责与time.apple.com同步操作系统的时间。这个是基础功能,请不要禁用它。

/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/usbmuxd
USB多路传输服务,为iphone和itouch提供原生的传输支持(无需越狱)。如果你没有iphone或ipod touch,可以禁用这个服务

/usr/sbin/securityd
Mac OS安全验证模块,它保存了系统的安全信息,可以仲裁一些加密操作,为软件提供安全验证。系统安全是大事,请不要禁用它。

/usr/sbin/mDNSResponder
DNS多播响应器,与DNS服务相关,附属作用是为你搜索局域网里的共享设备。包括mac, windows, ichat, ipad等等,并且会显示在finder的右侧菜单中。
请不要禁用它,否则你的DNS将失效,并导致无法访问网络。

/System/Library/CoreServices/loginwindow.app/Contents/MacOS/loginwindow
Mac OS用户登陆进程,展示登陆或注销界面,验证用户密码输入,启动Finder, Dock, 和一切需要启动时启动的第三方应用程序都是这个进程的责任。
请不要禁用它。

/usr/sbin/KernelEventAgent
处理文件系统的状态。比如“您的磁盘看上去已经满了,您是否需要删除一些数据,以保证系统正常启动”或“一个服务器已经很久没有响应,您是否想需要重新连接它”等。请不要禁用它。

/usr/libexec/hidd
人体学输入设备(HID)支持进程。比如键盘,鼠标,机密狗,蓝牙等。请不要禁用它。

/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/Support/fseventsd
文件系统事件系统,它可以广播文件的创建,删除等事件给Mac OS下的所有应用程序,届时,应用程序可以做出一些应对措施。请不要禁用它。

/sbin/dynamic_pager
Mac OS下的虚拟内存。当你的物理内存不够用时,就会使用虚拟内存,有的时候,密钥等一些使用频率不高的信息也会直接从物理内存中移除并存入虚拟内存。在 Unix系统(Mac)下,不论你的物理内存有多大,都不要尝试禁用虚拟内存。因为Unix的内存管理策略是尽可能多地使用内存,再大的内存都回随着时间 慢慢被耗光。

autofsd
自动挂载各种网络文件系统。比如NFS, SMB, AFS等。配置文件在 /etc/auto_master和/etc/auto_home,使用方式详见:
http://commandlinemac.blogspot.com/2009/09/introduction-to-autofs-in-mac-os-x.html
如果你不使用任何网络文件系统,可以禁用这个服务

/System/Library/CoreServices/coreservicesd
核心服务守护进程,禁用它可能导致系统停止运转或崩溃。请不要禁用它。

/usr/sbin/coreaudiod
音频服务,提供声音相关的支持。请不要禁用它。

System/Library/Frameworks/ApplicationServices.framework/Frameworks/CoreGraphics.framework/Resources/WindowServer
Mac OS的GUI界面系统。负责所有应用程序的窗口显示。请不要禁用它。

/System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/cvmsServ
OPGL服务进程,用到高级图形API的程序需要用到它。比如游戏,支持滑动或谈出特效的软件。请不要禁用它。

/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock
Mac OS经典的任务栏。请不要禁用它。

/System/Library/CoreServices/SystemUIServer.app/Contents/MacOS/SystemUIServer
Mac OS的菜单栏。请不要禁用它。

/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder
Mac OS的资源管理器。请不要禁用它。

/usr/sbin/pboard
剪贴板支持。除非你不想用复制,黏贴。请不要禁用它。

/System/Library/Frameworks/ApplicationServices.framework/Frameworks/ATS.framework/Support/fontd
字体服务进程。请不要禁用它。

/usr/libexec/UserEventAgent
高级别的系统事件处理器。请不要禁用它。

/System/Library/CoreServices/Menu Extras/TextInput.menu/Contents/SharedSupport/TISwitcher.app/Contents/MacOS/TISwitcher
输入法切换服务。除非你不想使用中文输入法,否则,请不要禁用它。

/usr/libexec/taskgated
task_for_pid是用来帮助某些想控制其他进程的执行的进程实现功能的服务。taskgated会被内核呼叫,用来确认”控制“这个行为是否可以发生。它本身也有权限验证的功能。请不要禁用该服务。

AirPortBaseStationAgent
这个是Apple的无线基站设备搜索服务。如果你没有apple的无线基站设备,可以禁用它

Spotlight
如果你不喜欢用spotlight,可以禁用它。详见文末给出的“Mac OS启动服务优化高级篇(launchd tuning)”里的优化方法。

/usr/sbin/blued
蓝牙支持服务。如果你不想使用蓝牙,可以禁用它

cupsd
打印机支持。如果你不想用打印机,可以禁用该服务

SharedServices.Agent
Apple的MobileMe服务,如果你不使用,可以禁用该服务

🔲 ☆

Lucene 三: Lucene 的索引文件格式

Lucene的索引里面存了些什么,如何存放的,也即Lucene的索引文件格式,是读懂Lucene源代码的一把钥匙。

当我们真正进入到Lucene源代码之中的时候,我们会发现:

  • Lucene的索引过程,就是按照全文检索的基本过程,将倒排表写成此文件格式的过程。
  • Lucene的搜索过程,就是按照此文件格式将索引进去的信息读出来,然后计算每篇文档打分(score)的过程。

参考官网 Apache Lucene - Index File Formats

一、基本概念

下图就是Lucene生成的索引的一个实例:

Lucene的索引结构是有层次结构的,主要分以下几个层次:

  • 索引(Index):
    • 在Lucene中一个索引是放在一个文件夹中的。
    • 如上图,同一文件夹中的所有的文件构成一个Lucene索引。
  • 段(Segment):
    • 一个索引可以包含多个段,段与段之间是独立的,添加新文档可以生成新的段,不同的段可以合并。
    • 如上图,具有相同前缀文件的属同一个段,图中共两个段 “_0” 和 “_1”。
    • segments.gen和segments_5是段的元数据文件,也即它们保存了段的属性信息。
  • 文档(Document):
    • 文档是我们建索引的基本单位,不同的文档是保存在不同的段中的,一个段可以包含多篇文档。
    • 新添加的文档是单独保存在一个新生成的段中,随着段的合并,不同的文档合并到同一个段中。
  • 域(Field):
    • 一篇文档包含不同类型的信息,可以分开索引,比如标题,时间,正文,作者等,都可以保存在不同的域里。
    • 不同域的索引方式可以不同,在真正解析域的存储的时候,我们会详细解读。
  • 词(Term):
    • 词是索引的最小单位,是经过词法分析和语言处理后的字符串。

Lucene的索引结构中,即保存了正向信息,也保存了反向信息。

所谓正向信息:

  • 按层次保存了从索引,一直到词的包含关系:索引(Index) –> 段(segment) –> 文档(Document) –> 域(Field) –> 词(Term)
  • 也即此索引包含了那些段,每个段包含了那些文档,每个文档包含了那些域,每个域包含了那些词。
  • 既然是层次结构,则每个层次都保存了本层次的信息以及下一层次的元信息,也即属性信息,比如一本介绍中国地理的书,应该首先介绍中国地理的概况,以及中国包含多少个省,每个省介绍本省的基本概况及包含多少个市,每个市介绍本市的基本概况及包含多少个县,每个县具体介绍每个县的具体情况。
  • 如上图,包含正向信息的文件有:
    • segments_N保存了此索引包含多少个段,每个段包含多少篇文档。
    • XXX.fnm保存了此段包含了多少个域,每个域的名称及索引方式。
    • XXX.fdx,XXX.fdt保存了此段包含的所有文档,每篇文档包含了多少域,每个域保存了那些信息。
    • XXX.tvx,XXX.tvd,XXX.tvf保存了此段包含多少文档,每篇文档包含了多少域,每个域包含了多少词,每个词的字符串,位置等信息。

所谓反向信息:

  • 保存了词典到倒排表的映射:词(Term) –> 文档(Document)
  • 如上图,包含反向信息的文件有:
    • XXX.tis,XXX.tii保存了词典(Term Dictionary),也即此段包含的所有的词按字典顺序的排序。
    • XXX.frq保存了倒排表,也即包含每个词的文档ID列表。
    • XXX.prx保存了倒排表中每个词在包含此词的文档中的位置。

在了解Lucene索引的详细结构之前,先看看Lucene索引中的基本数据类型。

二、基本类型

Lucene索引文件中,用以下基本类型来保存信息:

  • Byte:是最基本的类型,长8位(bit)。
  • UInt32:由4个Byte组成。
  • UInt64:由8个Byte组成。
  • VInt:
    • 变长的整数类型,它可能包含多个Byte,对于每个Byte的8位,其中后7位表示数值,最高1位表示是否还有另一个Byte,0表示没有,1表示有。
    • 越前面的Byte表示数值的低位,越后面的Byte表示数值的高位。
    • 例如130化为二进制为 1000, 0010,总共需要8位,一个Byte表示不了,因而需要两个Byte来表示,第一个Byte表示后7位,并且在最高位置1来表示后面还有一个Byte,所以为(1) 0000010,第二个Byte表示第8位,并且最高位置0来表示后面没有其他的Byte了,所以为(0) 0000001。

  • Chars:是UTF-8编码的一系列Byte。
  • String:一个字符串首先是一个VInt来表示此字符串包含的字符的个数,接着便是UTF-8编码的字符序列Chars。

三、基本规则

Lucene为了使的信息的存储占用的空间更小,访问速度更快,采取了一些特殊的技巧,然而在看Lucene文件格式的时候,这些技巧却容易使我们感到困惑,所以有必要把这些特殊的技巧规则提取出来介绍一下。

1. 前缀后缀规则(Prefix+Suffix)

Lucene在反向索引中,要保存词典(Term Dictionary)的信息,所有的词(Term)在词典中是按照字典顺序进行排列的,然而词典中包含了文档中的几乎所有的词,并且有的词还是非常的长的,这样索引文件会非常的大,所谓前缀后缀规则,即当某个词和前一个词有共同的前缀的时候,后面的词仅仅保存前缀在词中的偏移(offset),以及除前缀以外的字符串(称为后缀)。

比如要存储如下词:term,termagancy,termagant,terminal,

如果按照正常方式来存储,需要的空间如下:

[VInt = 4] [t][e][r][m],

[VInt = 10][t][e][r][m][a][g][a][n][c][y],

[VInt = 9][t][e][r][m][a][g][a][n][t],

[VInt = 8][t][e][r][m][i][n][a][l]

共需要35个Byte.

如果应用前缀后缀规则,需要的空间如下:

[VInt = 4] [t][e][r][m],

[VInt = 4 (offset)][VInt = 6][a][g][a][n][c][y],

[VInt = 8 (offset)][VInt = 1][t],

[VInt = 4 (offset)][VInt = 4][i][n][a][l]

共需要22个Byte。

大大缩小了存储空间,尤其是在按字典顺序排序的情况下,前缀的重合率大大提高。

2. 差值规则(Delta)

在Lucene的反向索引中,需要保存很多整型数字的信息,比如文档ID号,比如词(Term)在文档中的位置等等。

由上面介绍,我们知道,整型数字是以VInt的格式存储的。随着数值的增大,每个数字占用的Byte的个数也逐渐的增多。所谓差值规则(Delta)就是先后保存两个整数的时候,后面的整数仅仅保存和前面整数的差即可。

比如要存储如下整数:16386,16387,16388,16389

如果按照正常方式来存储,需要的空间如下:

[(1) 000, 0010][(1) 000, 0000][(0) 000, 0001],

[(1) 000, 0011][(1) 000, 0000][(0) 000, 0001],

[(1) 000, 0100][(1) 000, 0000][(0) 000, 0001],

[(1) 000, 0101][(1) 000, 0000][(0) 000, 0001]

供需12个Byte。

如果应用差值规则来存储,需要的空间如下:

[(1) 000, 0010][(1) 000, 0000][(0) 000, 0001],

[(0) 000, 0001],

[(0) 000, 0001],

[(0) 000, 0001]

共需6个Byte。

大大缩小了存储空间,而且无论是文档ID,还是词在文档中的位置,都是按从小到大的顺序,逐渐增大的。

3. 或然跟随规则(A, B?)

Lucene的索引结构中存在这样的情况,某个值A后面可能存在某个值B,也可能不存在,需要一个标志来表示后面是否跟随着B。

一般的情况下,在A后面放置一个Byte,为0则后面不存在B,为1则后面存在B,或者0则后面存在B,1则后面不存在B。

但这样要浪费一个Byte的空间,其实一个Bit就可以了。

在Lucene中,采取以下的方式:A的值左移一位,空出最后一位,作为标志位,来表示后面是否跟随B,所以在这种情况下,A/2是真正的A原来的值。

如果去读Apache Lucene - Index File Formats这篇文章,会发现很多符合这种规则的:

  • .frq文件中的DocDelta[, Freq?],DocSkip,PayloadLength?
  • .prx文件中的PositionDelta,Payload? (但不完全是,如下表分析)

当然还有一些带?的但不属于此规则的:

  • .frq文件中的SkipChildLevelPointer?,是多层跳跃表中,指向下一层表的指针,当然如果是最后一层,此值就不存在,也不需要标志。
  • .tvf文件中的Positions?, Offsets?。
    • 在此类情况下,带?的值是否存在,并不取决于前面的值的最后一位。
    • 而是取决于Lucene的某项配置,当然这些配置也是保存在Lucene索引文件中的。
    • 如Position和Offset是否存储,取决于.fnm文件中对于每个域的配置(TermVector.WITH_POSITIONS和TermVector.WITH_OFFSETS)

为什么会存在以上两种情况,其实是可以理解的:

  • 对于符合或然跟随规则的,是因为对于每一个A,B是否存在都不相同,当这种情况大量存在的时候,从一个Byte到一个Bit如此8倍的空间节约还是很值得的。
  • 对于不符合或然跟随规则的,是因为某个值的是否存在的配置对于整个域(Field)甚至整个索引都是有效的,而非每次的情况都不相同,因而可以统一存放一个标志。

文章中对如下格式的描述令人困惑:

Positions –> <PositionDelta,Payload?> Freq

Payload –> <PayloadLength?,PayloadData>

PositionDelta和Payload是否适用或然跟随规则呢?如何标识PayloadLength是否存在呢?

其实PositionDelta和Payload并不符合或然跟随规则,Payload是否存在,是由.fnm文件中对于每个域的配置中有关Payload的配置决定的(FieldOption.STORES_PAYLOADS) 。

当Payload不存在时,PayloadDelta本身不遵从或然跟随原则。

当Payload存在时,格式应该变成如下:Positions –> <PositionDelta,PayloadLength?,PayloadData> Freq

从而PositionDelta和PayloadLength一起适用或然跟随规则。

4. 跳跃表规则(Skip list)

为了提高查找的性能,Lucene在很多地方采取的跳跃表的数据结构。

跳跃表(Skip List)是如图的一种数据结构,有以下几个基本特征:

  • 元素是按顺序排列的,在Lucene中,或是按字典顺序排列,或是按从小到大顺序排列。
  • 跳跃是有间隔的(Interval),也即每次跳跃的元素数,间隔是事先配置好的,如图跳跃表的间隔为3。
  • 跳跃表是由层次的(level),每一层的每隔指定间隔的元素构成上一层,如图跳跃表共有2层。

需要注意一点的是,在很多数据结构或算法书中都会有跳跃表的描述,原理都是大致相同的,但是定义稍有差别:

  • 对间隔(Interval)的定义: 如图中,有的认为间隔为2,即两个上层元素之间的元素数,不包括两个上层元素;有的认为是3,即两个上层元素之间的差,包括后面上层元素,不包括前面的上层元素;有的认为是4,即除两个上层元素之间的元素外,既包括前面,也包括后面的上层元素。Lucene是采取的第二种定义。
  • 对层次(Level)的定义:如图中,有的认为应该包括原链表层,并从1开始计数,则总层次为3,为1,2,3层;有的认为应该包括原链表层,并从0计数,为0,1,2层;有的认为不应该包括原链表层,且从1开始计数,则为1,2层;有的认为不应该包括链表层,且从0开始计数,则为0,1层。Lucene采取的是最后一种定义。

跳跃表比顺序查找,大大提高了查找速度,如查找元素72,原来要访问2,3,7,12,23,37,39,44,50,72总共10个元素,应用跳跃表后,只要首先访问第1层的50,发现72大于50,而第1层无下一个节点,然后访问第2层的94,发现94大于72,然后访问原链表的72,找到元素,共需要访问3个元素即可。

然而Lucene在具体实现上,与理论又有所不同,在具体的格式中,会详细说明。

四、具体格式

上面曾经交代过,Lucene保存了从Index到Segment到Document到Field一直到Term的正向信息,也包括了从Term到Document映射的反向信息,还有其他一些Lucene特有的信息。下面对这三种信息一一介绍。

4.1. 正向信息

Index –> Segments (segments.gen, segments_N) –> Field(fnm, fdx, fdt) –> Term (tvx, tvd, tvf)

上面的层次结构不是十分的准确,因为segments.gen和segments_N保存的是段(segment)的元数据信息(metadata),其实是每个Index一个的,而段的真正的数据信息,是保存在域(Field)和词(Term)中的。

4.1.1. 段的元数据信息(segments_N)

一个索引(Index)可以同时存在多个segments_N(至于如何存在多个segments_N,在描述完详细信息之后会举例说明),然而当我们要打开一个索引的时候,我们必须要选择一个来打开,那如何选择哪个segments_N呢?

Lucene采取以下过程:

  • 其一,在所有的segments_N中选择N最大的一个。基本逻辑参照SegmentInfos.getCurrentSegmentGeneration(File[] files),其基本思路就是在所有以segments开头,并且不是segments.gen的文件中,选择N最大的一个作为genA。

  • 其二,打开segments.gen,其中保存了当前的N值。其格式如下,读出版本号(Version),然后再读出两个N,如果两者相等,则作为genB。

1
2
3
4
5
6
7
8
9
IndexInput genInput = directory.openInput(IndexFileNames.SEGMENTS_GEN);//"segments.gen"  
int version = genInput.readInt();//读出版本号
if (version == FORMAT_LOCKLESS) {//如果版本号正确
long gen0 = genInput.readLong();//读出第一个N
long gen1 = genInput.readLong();//读出第二个N
if (gen0 == gen1) {//如果两者相等则为genB
genB = gen0;
}
}
  • 其三,在上述得到的genA和genB中选择最大的那个作为当前的N,方才打开segments_N文件。其基本逻辑如下:

    1
    2
    3
    4
    if (genA > genB)  
    gen = genA;
    else
    gen = genB;

如下图是segments_N的具体格式:

  • Format:
    • 索引文件格式的版本号。
    • 由于Lucene是在不断开发过程中的,因而不同版本的Lucene,其索引文件格式也不尽相同,于是规定一个版本号。
    • Lucene 2.1此值-3,Lucene 2.9时,此值为-9。
    • 当用某个版本号的IndexReader读取另一个版本号生成的索引的时候,会因为此值不同而报错。
  • Version:
    • 索引的版本号,记录了IndexWriter将修改提交到索引文件中的次数。
    • 其初始值大多数情况下从索引文件里面读出,仅仅在索引开始创建的时候,被赋予当前的时间,已取得一个唯一值。
    • 其值改变在 IndexWriter.commit->IndexWriter.startCommit->SegmentInfos.prepareCommit->SegmentInfos.write->writeLong(++version)
    • 其初始值之所最初取一个时间,是因为我们并不关心IndexWriter将修改提交到索引的具体次数,而更关心到底哪个是最新的。IndexReader中常比较自己的version和索引文件中的version是否相同来判断此IndexReader被打开后,还有没有被IndexWriter更新。
1
2
3
4
5
//在DirectoryReader中有一下函数。

public boolean isCurrent() throws CorruptIndexException, IOException {
return SegmentInfos.readCurrentVersion(directory) == segmentInfos.getVersion();
}
  • NameCount
    • 是下一个新段(Segment)的段名。
    • 所有属于同一个段的索引文件都以段名作为文件名,一般为_0.xxx, _0.yyy, _1.xxx, _1.yyy ……
    • 新生成的段的段名一般为原有最大段名加一。
    • 如同的索引,NameCount读出来是2,说明新的段为_2.xxx, _2.yyy

  • SegCount
    • 段(Segment)的个数。
    • 如上图,此值为2。
  • SegCount个段的元数据信息:
    • SegName
      • 段名,所有属于同一个段的文件都有以段名作为文件名。
      • 如上图,第一个段的段名为”_0”,第二个段的段名为”_1”
    • SegSize
      • 此段中包含的文档数
      • 然而此文档数是包括已经删除,又没有optimize的文档的,因为在optimize之前,Lucene的段中包含了所有被索引过的文档,而被删除的文档是保存在.del文件中的,在搜索的过程中,是先从段中读到了被删除的文档,然后再用.del中的标志,将这篇文档过滤掉。
      • 如下的代码形成了上图的索引,可以看出索引了两篇文档形成了_0段,然后又删除了其中一篇,形成了_0_1.del,又索引了两篇文档形成_1段,然后又删除了其中一篇,形成_1_1.del。因而在两个段中,此值都是2。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX\_DIR), new StandardAnalyzer(Version.LUCENE\_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);  
writer.setUseCompoundFile(false);
indexDocs(writer, docDir);//docDir中只有两篇文档

//文档一为:Students should be allowed to go out with their friends, but not allowed to drink beer.

//文档二为:My friend Jerry went to school to see his students but found them drunk which is not allowed.

writer.commit();//提交两篇文档,形成\_0段。

writer.deleteDocuments(new Term("contents", "school"));//删除文档二
writer.commit();//提交删除,形成\_0\_1.del
indexDocs(writer, docDir);//再次索引两篇文档,Lucene不能判别文档与文档的不同,因而算两篇新的文档。
writer.commit();//提交两篇文档,形成\_1段
writer.deleteDocuments(new Term("contents", "school"));//删除第二次添加的文档二
writer.close();//提交删除,形成\_1\_1.del
    • DelGen
      • .del文件的版本号
      • Lucene中,在optimize之前,删除的文档是保存在.del文件中的。
      • 在Lucene 2.9中,文档删除有以下几种方式:
        • IndexReader.deleteDocument(int docID)是用IndexReader按文档号删除。
        • IndexReader.deleteDocuments(Term term)是用IndexReader删除包含此词(Term)的文档。
        • IndexWriter.deleteDocuments(Term term)是用IndexWriter删除包含此词(Term)的文档。
        • IndexWriter.deleteDocuments(Term[] terms)是用IndexWriter删除包含这些词(Term)的文档。
        • IndexWriter.deleteDocuments(Query query)是用IndexWriter删除能满足此查询(Query)的文档。
        • IndexWriter.deleteDocuments(Query[] queries)是用IndexWriter删除能满足这些查询(Query)的文档。
        • 原来的版本中Lucene的删除一直是由IndexReader来完成的,在Lucene 2.9中虽可以用IndexWriter来删除,但是其实真正的实现是在IndexWriter中,保存了readerpool,当IndexWriter向索引文件提交删除的时候,仍然是从readerpool中得到相应的IndexReader,并用IndexReader来进行删除的。下面的代码可以说明:
1
2
3
4

IndexWriter.applyDeletes()
-> DocumentsWriter.applyDeletes(SegmentInfos)
-> reader.deleteDocument(doc);
        • DelGen是每当IndexWriter向索引文件中提交删除操作的时候,加1,并生成新的.del文件。

IndexWriter.commit()

-> IndexWriter.applyDeletes()

    -> IndexWriter$ReaderPool.release(SegmentReader)

         -> SegmentReader(IndexReader).commit()

             -> SegmentReader.doCommit(Map)

                  -> SegmentInfo.advanceDelGen()

                       -> if (delGen == NO) {
                              delGen = YES;
                           } else {
                              delGen++;
                           }

IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
writer.setUseCompoundFile(false);

indexDocs(writer, docDir);//索引两篇文档,一篇包含"school",另一篇包含"beer"
writer.commit();//提交两篇文档到索引文件,形成段(Segment) "_0"
writer.deleteDocuments(new Term("contents", "school"));//删除包含"school"的文档,其实是删除了两篇文档中的一篇。
writer.commit();//提交删除到索引文件,形成"_0_1.del"
writer.deleteDocuments(new Term("contents", "beer"));//删除包含"beer"的文档,其实是删除了两篇文档中的另一篇。
writer.commit();//提交删除到索引文件,形成"_0_2.del"
indexDocs(writer, docDir);//索引两篇文档,和上次的文档相同,但是Lucene无法区分,认为是另外两篇文档。
writer.commit();//提交两篇文档到索引文件,形成段"_1"
writer.deleteDocuments(new Term("contents", "beer"));//删除包含"beer"的文档,其中段"_0"已经无可删除,段"_1"被删除一篇。
writer.close();//提交删除到索引文件,形成"_1_1.del"

形成的索引文件如下:

image

    • DocStoreOffset

    • DocStoreSegment

    • DocStoreIsCompoundFile

      • 对于域(Stored Field)和词向量(Term Vector)的存储可以有不同的方式,即可以每个段(Segment)单独存储自己的域和词向量信息,也可以多个段共享域和词向量,把它们存储到一个段中去。
      • 如果DocStoreOffset为-1,则此段单独存储自己的域和词向量,从存储文件上来看,如果此段段名为XXX,则此段有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx文件。DocStoreSegment和DocStoreIsCompoundFile在此处不被保存。
      • 如果DocStoreOffset不为-1,则DocStoreSegment保存了共享的段的名字,比如为YYY,DocStoreOffset则为此段的域及词向量信息在共享段中的偏移量。则此段没有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx文件,而是将信息存放在共享段的YYY.fdt,YYY.fdx,YYY.tvf,YYY.tvd,YYY.tvx文件中。
      • DocumentsWriter中有两个成员变量:String segment是当前索引信息存放的段,String docStoreSegment是域和词向量信息存储的段。两者可以相同也可以不同,决定了域和词向量信息是存储在本段中,还是和其他的段共享。
      • IndexWriter.flush(boolean triggerMerge, boolean flushDocStores, boolean flushDeletes)中第二个参数flushDocStores会影响到是否单独或是共享存储。其实最终影响的是DocumentsWriter.closeDocStore()。每当flushDocStores为false时,closeDocStore不被调用,说明下次添加到索引文件中的域和词向量信息是同此次共享一个段的。直到flushDocStores为true的时候,closeDocStore被调用,从而下次添加到索引文件中的域和词向量信息将被保存在一个新的段中,不同此次共享一个段(在这里需要指出的是Lucene的一个很奇怪的实现,虽然下次域和词向量信息是被保存到新的段中,然而段名却是这次被确定了的,在initSegmentName中当docStoreSegment == null时,被置为当前的segment,而非下一个新的segment,docStoreSegment = segment,于是会出现如下面的例子的现象)。
      • 好在共享域和词向量存储并不是经常被使用到,实现也或有缺陷,暂且解释到此。

      IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
      writer.setUseCompoundFile(false);

indexDocs(writer, docDir);  writer.flush();

//flush生成segment “_0”,并且flush函数中,flushDocStores设为false,也即下个段将同本段共享域和词向量信息,这时DocumentsWriter中的docStoreSegment= “_0”。

indexDocs(writer, docDir);  writer.commit();

//commit生成segment “_1”,由于上次flushDocStores设为false,于是段”_1”的域以及词向量信息是保存在”_0”中的,在这个时刻,段”_1”并不生成自己的”_1.fdx”和”_1.fdt”。然而在commit函数中,flushDocStores设为true,也即下个段将单独使用新的段来存储域和词向量信息。然而这时,DocumentsWriter中的docStoreSegment= “_1”,也即当段”_2”存储其域和词向量信息的时候,是存在”_1.fdx”和”_1.fdt”中的,而段”_1”的域和词向量信息却是存在”_0.fdt”和”_0.fdx”中的,这一点非常令人困惑。 如图writer.commit的时候,_1.fdt和_1.fdx并没有形成。

indexDocs(writer, docDir);  writer.flush();

//段”_2”形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_1.fdt和_1.fdx中的,这时候才产生了此二文件。

indexDocs(writer, docDir);  writer.flush();

//段”_3”形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_1.fdt和_1.fdx中的

indexDocs(writer, docDir);  writer.commit();

//段”_4”形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_1.fdt和_1.fdx中的。然而函数commit中flushDocStores设为true,也意味着下一个段将新创建一个段保存域和词向量信息,此时DocumentsWriter中docStoreSegment= “_4”,也表明了虽然段”_4”的域和词向量信息保存在了段”_1”中,将来的域和词向量信息却要保存在段”_4”中。此时”_4.fdx”和”_4.fdt”尚未产生。

indexDocs(writer, docDir);  writer.flush();

//段”_5”形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_4.fdt和_4.fdx中的,这时候才产生了此二文件。

indexDocs(writer, docDir);  writer.commit();  writer.close();

//段”_6”形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_4.fdt和_4.fdx中的

    • HasSingleNormFile
      • 在搜索的过程中,标准化因子(Normalization Factor)会影响文档最后的评分。
      • 不同的文档重要性不同,不同的域重要性也不同。因而每个文档的每个域都可以有自己的标准化因子。
      • 如果HasSingleNormFile为1,则所有的标准化因子都是存在.nrm文件中的。
      • 如果HasSingleNormFile不是1,则每个域都有自己的标准化因子文件.fN
    • NumField
      • 域的数量
    • NormGen
      • 如果每个域有自己的标准化因子文件,则此数组描述了每个标准化因子文件的版本号,也即.fN的N。
    • IsCompoundFile
      • 是否保存为复合文件,也即把同一个段中的文件按照一定格式,保存在一个文件当中,这样可以减少每次打开文件的个数。
      • 是否为复合文件,由接口IndexWriter.setUseCompoundFile(boolean)设定。
      • 非符合文件同符合文件的对比如下图:
    • DeletionCount
      • 记录了此段中删除的文档的数目。
    • HasProx
      • 如果至少有一个段omitTf为false,也即词频(term freqency)需要被保存,则HasProx为1,否则为0。
    • Diagnostics
      • 调试信息。
  • User map data

    • 保存了用户从字符串到字符串的映射Map
  • CheckSum

    • 此文件segment_N的校验和。

读取此文件格式参考SegmentInfos.read(Directory directory, String segmentFileName):

  • int format = input.readInt();

  • version = input.readLong(); // read version

  • counter = input.readInt(); // read counter

  • for (int i = input.readInt(); i > 0; i–) // read segmentInfos

    • add(new SegmentInfo(directory, format, input));
      • name = input.readString();
      • docCount = input.readInt();
      • delGen = input.readLong();
      • docStoreOffset = input.readInt();
      • docStoreSegment = input.readString();
      • docStoreIsCompoundFile = (1 == input.readByte());
      • hasSingleNormFile = (1 == input.readByte());
      • int numNormGen = input.readInt();
      • normGen = new long[numNormGen];
      • for(int j=0;j
      • normGen[j] = input.readLong();
    • isCompoundFile = input.readByte();
    • delCount = input.readInt();
    • hasProx = input.readByte() == 1;
    • diagnostics = input.readStringStringMap();
  • userData = input.readStringStringMap();

  • final long checksumNow = input.getChecksum();

  • final long checksumThen = input.readLong();

4.1.2. 域(Field)的元数据信息(.fnm)

一个段(Segment)包含多个域,每个域都有一些元数据信息,保存在.fnm文件中,.fnm文件的格式如下:

  • FNMVersion
    • 是fnm文件的版本号,对于Lucene 2.9为-2
  • FieldsCount
    • 域的数目
  • 一个数组的域(Fields)
    • FieldName:域名,如”title”,”modified”,”content”等。
    • FieldBits:一系列标志位,表明对此域的索引方式
      • 最低位:1表示此域被索引,0则不被索引。所谓被索引,也即放到倒排表中去。
        • 仅仅被索引的域才能够被搜到。
        • Field.Index.NO则表示不被索引。
        • Field.Index.ANALYZED则表示不但被索引,而且被分词,比如索引”hello world”后,无论是搜”hello”,还是搜”world”都能够被搜到。
        • Field.Index.NOT_ANALYZED表示虽然被索引,但是不分词,比如索引”hello world”后,仅当搜”hello world”时,能够搜到,搜”hello”和搜”world”都搜不到。
        • 一个域出了能够被索引,还能够被存储,仅仅被存储的域是搜索不到的,但是能通过文档号查到,多用于不想被搜索到,但是在通过其它域能够搜索到的情况下,能够随着文档号返回给用户的域。
        • Field.Store.Yes则表示存储此域,Field.Store.NO则表示不存储此域。
      • 倒数第二位:1表示保存词向量,0为不保存词向量。
        • Field.TermVector.YES表示保存词向量。
        • Field.TermVector.NO表示不保存词向量。
      • 倒数第三位:1表示在词向量中保存位置信息。
        • Field.TermVector.WITH_POSITIONS
      • 倒数第四位:1表示在词向量中保存偏移量信息。
        • Field.TermVector.WITH_OFFSETS
      • 倒数第五位:1表示不保存标准化因子
        • Field.Index.ANALYZED_NO_NORMS
        • Field.Index.NOT_ANALYZED_NO_NORMS
      • 倒数第六位:是否保存payload

要了解域的元数据信息,还要了解以下几点:

  • 位置(Position)和偏移量(Offset)的区别
    • 位置是基于词Term的,偏移量是基于字母或汉字的。

  • 索引域(Indexed)和存储域(Stored)的区别
    • 一个域为什么会被存储(store)而不被索引(Index)呢?在一个文档中的所有信息中,有这样一部分信息,可能不想被索引从而可以搜索到,但是当这个文档由于其他的信息被搜索到时,可以同其他信息一同返回。
    • 举个例子,读研究生时,您好不容易写了一篇论文交给您的导师,您的导师却要他所第一作者而您做第二作者,然而您导师不想别人在论文系统中搜索您的名字时找到这篇论文,于是在论文系统中,把第二作者这个Field的Indexed设为false,这样别人搜索您的名字,永远不知道您写过这篇论文,只有在别人搜索您导师的名字从而找到您的文章时,在一个角落表述着第二作者是您。
  • payload的使用
    • 我们知道,索引是以倒排表形式存储的,对于每一个词,都保存了包含这个词的一个链表,当然为了加快查询速度,此链表多用跳跃表进行存储。
    • Payload信息就是存储在倒排表中的,同文档号一起存放,多用于存储与每篇文档相关的一些信息。当然这部分信息也可以存储域里(stored Field),两者从功能上基本是一样的,然而当要存储的信息很多的时候,存放在倒排表里,利用跳跃表,有利于大大提高搜索速度。
    • Payload的存储方式如下图:

    • Payload主要有以下几种用法:
      • 存储每个文档都有的信息:比如有的时候,我们想给每个文档赋一个我们自己的文档号,而不是用Lucene自己的文档号。于是我们可以声明一个特殊的域(Field)”_ID”和特殊的词(Term)”_ID”,使得每篇文档都包含词”_ID”,于是在词”_ID”的倒排表里面对于每篇文档又有一项,每一项都有一个payload,于是我们可以在payload里面保存我们自己的文档号。每当我们得到一个Lucene的文档号的时候,就能从跳跃表中查找到我们自己的文档号。

//声明一个特殊的域和特殊的词

public static final String ID_PAYLOAD_FIELD = “_ID”;

public static final String ID_PAYLOAD_TERM = “_ID”;

public static final Term ID_TERM = new Term(ID_PAYLOAD_TERM, ID_PAYLOAD_FIELD);

//声明一个特殊的TokenStream,它只生成一个词(Term),就是那个特殊的词,在特殊的域里面。

static class SinglePayloadTokenStream extends TokenStream {
private Token token;
private boolean returnToken = false;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SinglePayloadTokenStream(String idPayloadTerm) {  
char[] term = idPayloadTerm.toCharArray();
token = new Token(term, 0, term.length, 0, term.length);
}

void setPayloadValue(byte[] value) {
token.setPayload(new Payload(value));
returnToken = true;
}

public Token next() throws IOException {
if (returnToken) {
returnToken = false;
return token;
} else {
return null;
}
}

}

//对于每一篇文档,都让它包含这个特殊的词,在特殊的域里面

SinglePayloadTokenStream singlePayloadTokenStream = new SinglePayloadTokenStream(ID_PAYLOAD_TERM);
singlePayloadTokenStream.setPayloadValue(long2bytes(id));
doc.add(new Field(ID_PAYLOAD_FIELD, singlePayloadTokenStream));

//每当得到一个Lucene的文档号时,通过以下的方式得到payload里面的文档号

long id = 0;
TermPositions tp = reader.termPositions(ID_PAYLOAD_TERM);
boolean ret = tp.skipTo(docID);
tp.nextPosition();
int payloadlength = tp.getPayloadLength();
byte[] payloadBuffer = new byte[payloadlength];
tp.getPayload(payloadBuffer, 0);
id = bytes2long(payloadBuffer);
tp.close();

      • 影响词的评分
        • 在Similarity抽象类中有函数public float scorePayload(byte [] payload, int offset, int length) 可以根据payload的值影响评分。
  • 读取域元数据信息的代码如下:

FieldInfos.read(IndexInput, String)

  • int firstInt = input.readVInt();
  • size = input.readVInt();
  • for (int i = 0; i < size; i++)
    • String name = input.readString();
    • byte bits = input.readByte();
    • boolean isIndexed = (bits & IS_INDEXED) != 0;
    • boolean storeTermVector = (bits & STORE_TERMVECTOR) != 0;
    • boolean storePositionsWithTermVector = (bits & STORE_POSITIONS_WITH_TERMVECTOR) != 0;
    • boolean storeOffsetWithTermVector = (bits & STORE_OFFSET_WITH_TERMVECTOR) != 0;
    • boolean omitNorms = (bits & OMIT_NORMS) != 0;
    • boolean storePayloads = (bits & STORE_PAYLOADS) != 0;
    • boolean omitTermFreqAndPositions = (bits & OMIT_TERM_FREQ_AND_POSITIONS) != 0;

4.1.3. 域(Field)的数据信息(.fdt,.fdx)

  • 域数据文件(fdt):
    • 真正保存存储域(stored field)信息的是fdt文件
    • 在一个段(segment)中总共有segment size篇文档,所以fdt文件中共有segment size个项,每一项保存一篇文档的域的信息
    • 对于每一篇文档,一开始是一个fieldcount,也即此文档包含的域的数目,接下来是fieldcount个项,每一项保存一个域的信息。
    • 对于每一个域,fieldnum是域号,接着是一个8位的byte,最低一位表示此域是否分词(tokenized),倒数第二位表示此域是保存字符串数据还是二进制数据,倒数第三位表示此域是否被压缩,再接下来就是存储域的值,比如new Field(“title”, “lucene in action”, Field.Store.Yes, …),则此处存放的就是”lucene in action”这个字符串。
  • 域索引文件(fdx)
    • 由域数据文件格式我们知道,每篇文档包含的域的个数,每个存储域的值都是不一样的,因而域数据文件中segment size篇文档,每篇文档占用的大小也是不一样的,那么如何在fdt中辨别每一篇文档的起始地址和终止地址呢,如何能够更快的找到第n篇文档的存储域的信息呢?就是要借助域索引文件。
    • 域索引文件也总共有segment size个项,每篇文档都有一个项,每一项都是一个long,大小固定,每一项都是对应的文档在fdt文件中的起始地址的偏移量,这样如果我们想找到第n篇文档的存储域的信息,只要在fdx中找到第n项,然后按照取出的long作为偏移量,就可以在fdt文件中找到对应的存储域的信息。
  • 读取域数据信息的代码如下:

Document FieldsReader.doc(int n, FieldSelector fieldSelector)

  • long position = indexStream.readLong();//indexStream points to “.fdx”
  • fieldsStream.seek(position);//fieldsStream points to “fdt”
  • int numFields = fieldsStream.readVInt();
  • for (int i = 0; i < numFields; i++)
    • int fieldNumber = fieldsStream.readVInt();
    • byte bits = fieldsStream.readByte();
    • boolean compressed = (bits & FieldsWriter.FIELD_IS_COMPRESSED) != 0;
    • boolean tokenize = (bits & FieldsWriter.FIELD_IS_TOKENIZED) != 0;
    • boolean binary = (bits & FieldsWriter.FIELD_IS_BINARY) != 0;
    • if (binary)
      • int toRead = fieldsStream.readVInt();
      • final byte[] b = new byte[toRead];
      • fieldsStream.readBytes(b, 0, b.length);
      • if (compressed)
        • int toRead = fieldsStream.readVInt();
        • final byte[] b = new byte[toRead];
        • fieldsStream.readBytes(b, 0, b.length);
        • uncompress(b),
    • else
      • fieldsStream.readString()

4.1.3. 词向量(Term Vector)的数据信息(.tvx,.tvd,.tvf)

词向量信息是从索引(index)到文档(document)到域(field)到词(term)的正向信息,有了词向量信息,我们就可以得到一篇文档包含那些词的信息。

  • 词向量索引文件(tvx)
    • 一个段(segment)包含N篇文档,此文件就有N项,每一项代表一篇文档。
    • 每一项包含两部分信息:第一部分是词向量文档文件(tvd)中此文档的偏移量,第二部分是词向量域文件(tvf)中此文档的第一个域的偏移量。
  • 词向量文档文件(tvd)
    • 一个段(segment)包含N篇文档,此文件就有N项,每一项包含了此文档的所有的域的信息。
    • 每一项首先是此文档包含的域的个数NumFields,然后是一个NumFields大小的数组,数组的每一项是域号。然后是一个(NumFields - 1)大小的数组,由前面我们知道,每篇文档的第一个域在tvf中的偏移量在tvx文件中保存,而其他(NumFields - 1)个域在tvf中的偏移量就是第一个域的偏移量加上这(NumFields - 1)个数组的每一项的值。
  • 词向量域文件(tvf)
    • 此文件包含了此段中的所有的域,并不对文档做区分,到底第几个域到第几个域是属于那篇文档,是由tvx中的第一个域的偏移量以及tvd中的(NumFields - 1)个域的偏移量来决定的。
    • 对于每一个域,首先是此域包含的词的个数NumTerms,然后是一个8位的byte,最后一位是指定是否保存位置信息,倒数第二位是指定是否保存偏移量信息。然后是NumTerms个项的数组,每一项代表一个词(Term),对于每一个词,由词的文本TermText,词频TermFreq(也即此词在此文档中出现的次数),词的位置信息,词的偏移量信息。
  • 读取词向量数据信息的代码如下:

TermVectorsReader.get(int docNum, String field, TermVectorMapper)

  • int fieldNumber = fieldInfos.fieldNumber(field);//通过field名字得到field号
  • seekTvx(docNum);//在tvx文件中按docNum文档号找到相应文档的项
  • long tvdPosition = tvx.readLong();//找到tvd文件中相应文档的偏移量
  • tvd.seek(tvdPosition);//在tvd文件中按偏移量找到相应文档的项
  • int fieldCount = tvd.readVInt();//此文档包含的域的个数。
  • for (int i = 0; i < fieldCount; i++) //按域号查找域
    • number = tvd.readVInt();
    • if (number == fieldNumber)
      • found = i;
  • position = tvx.readLong();//在tvx中读出此文档的第一个域在tvf中的偏移量
  • for (int i = 1; i <= found; i++)
    • position += tvd.readVLong();//加上所要找的域在tvf中的偏移量
  • tvf.seek(position);
  • int numTerms = tvf.readVInt();
  • byte bits = tvf.readByte();
  • storePositions = (bits & STORE_POSITIONS_WITH_TERMVECTOR) != 0;
  • storeOffsets = (bits & STORE_OFFSET_WITH_TERMVECTOR) != 0;
  • for (int i = 0; i < numTerms; i++)
    • start = tvf.readVInt();
    • deltaLength = tvf.readVInt();
    • totalLength = start + deltaLength;
    • tvf.readBytes(byteBuffer, start, deltaLength);
    • term = new String(byteBuffer, 0, totalLength, “UTF-8”);
    • if (storePositions)
      • positions = new int[freq];
      • int prevPosition = 0;
      • for (int j = 0; j < freq; j++)
        • positions[j] = prevPosition + tvf.readVInt();
        • prevPosition = positions[j];
    • if (storeOffsets)
      • offsets = new TermVectorOffsetInfo[freq];
      • int prevOffset = 0;
      • for (int j = 0; j < freq; j++)
      • int startOffset = prevOffset + tvf.readVInt();
      • int endOffset = startOffset + tvf.readVInt();
      • offsets[j] = new TermVectorOffsetInfo(startOffset, endOffset);
      • prevOffset = endOffset;

4.2. 反向信息

反向信息是索引文件的核心,也即反向索引。

反向索引包括两部分,左面是词典(Term Dictionary),右面是倒排表(Posting List)。

在Lucene中,这两部分是分文件存储的,词典是存储在tii,tis中的,倒排表又包括两部分,一部分是文档号及词频,保存在frq中,一部分是词的位置信息,保存在prx中。

  • Term Dictionary (tii, tis)
    • –> Frequencies (.frq)
    • –> Positions (.prx)

4.2.1. 词典(tis)及词典索引(tii)信息

在词典中,所有的词是按照字典顺序排序的。

  • 词典文件(tis)
    • TermCount:词典中包含的总的词数
    • IndexInterval:为了加快对词的查找速度,也应用类似跳跃表的结构,假设IndexInterval为4,则在词典索引(tii)文件中保存第4个,第8个,第12个词,这样可以加快在词典文件中查找词的速度。
    • SkipInterval:倒排表无论是文档号及词频,还是位置信息,都是以跳跃表的结构存在的,SkipInterval是跳跃的步数。
    • MaxSkipLevels:跳跃表是多层的,这个值指的是跳跃表的最大层数。
    • TermCount个项的数组,每一项代表一个词,对于每一个词,以前缀后缀规则存放词的文本信息(PrefixLength + Suffix),词属于的域的域号(FieldNum),有多少篇文档包含此词(DocFreq),此词的倒排表在frq,prx中的偏移量(FreqDelta, ProxDelta),此词的倒排表的跳跃表在frq中的偏移量(SkipDelta),这里之所以用Delta,是应用差值规则。
  • 词典索引文件(tii)
    • 词典索引文件是为了加快对词典文件中词的查找速度,保存每隔IndexInterval个词。
    • 词典索引文件是会被全部加载到内存中去的。
    • IndexTermCount = TermCount / IndexInterval:词典索引文件中包含的词数。
    • IndexInterval同词典文件中的IndexInterval。
    • SkipInterval同词典文件中的SkipInterval。
    • MaxSkipLevels同词典文件中的MaxSkipLevels。
    • IndexTermCount个项的数组,每一项代表一个词,每一项包括两部分,第一部分是词本身(TermInfo),第二部分是在词典文件中的偏移量(IndexDelta)。假设IndexInterval为4,此数组中保存第4个,第8个,第12个词。。。
  • 读取词典及词典索引文件的代码如下:

origEnum = new SegmentTermEnum(directory.openInput(segment + “.” + IndexFileNames.TERMS_EXTENSION,readBufferSize), fieldInfos, false);//用于读取tis文件

  • int firstInt = input.readInt();
  • size = input.readLong();
  • indexInterval = input.readInt();
  • skipInterval = input.readInt();
  • maxSkipLevels = input.readInt();

SegmentTermEnum indexEnum = new SegmentTermEnum(directory.openInput(segment + “.” + IndexFileNames.TERMS_INDEX_EXTENSION, readBufferSize), fieldInfos, true);//用于读取tii文件

  • indexTerms = new Term[indexSize];
  • indexInfos = new TermInfo[indexSize];
  • indexPointers = new long[indexSize];
  • for (int i = 0; indexEnum.next(); i++)
    • indexTerms[i] = indexEnum.term();
    • indexInfos[i] = indexEnum.termInfo();
    • indexPointers[i] = indexEnum.indexPointer;

4.2.2. 文档号及词频(frq)信息

文档号及词频文件里面保存的是倒排表,是以跳跃表形式存在的。

  • 此文件包含TermCount个项,每一个词都有一项,因为每一个词都有自己的倒排表。
  • 对于每一个词的倒排表都包括两部分,一部分是倒排表本身,也即一个数组的文档号及词频,另一部分是跳跃表,为了更快的访问和定位倒排表中文档号及词频的位置。
  • 对于文档号和词频的存储应用的是差值规则和或然跟随规则,Lucene的文档本身有以下几句话,比较难以理解,在此解释一下:

For example, the TermFreqs for a term which occurs once in document seven and three times in document eleven, with omitTf false, would be the following sequence of VInts:

15, 8, 3

If omitTf were true it would be this sequence of VInts instead:

7,4

首先我们看omitTf=false的情况,也即我们在索引中会存储一个文档中term出现的次数。

例子中说了,表示在文档7中出现1次,并且又在文档11中出现3次的文档用以下序列表示:15,8,3.

那这三个数字是怎么计算出来的呢?

首先,根据定义TermFreq –> DocDelta[, Freq?],一个TermFreq结构是由一个DocDelta后面或许跟着Freq组成,也即上面我们说的A+B?结构。

DocDelta自然是想存储包含此Term的文档的ID号了,Freq是在此文档中出现的次数。

所以根据例子,应该存储的完整信息为[DocID = 7, Freq = 1] [DocID = 11, Freq = 3](见全文检索的基本原理章节)。

然而为了节省空间,Lucene对编号此类的数据都是用差值来表示的,也即上面说的规则2,Delta规则,于是文档ID就不能按完整信息存了,就应该存放如下:

[DocIDDelta = 7, Freq = 1][DocIDDelta = 4 (11-7), Freq = 3]

然而Lucene对于A+B?这种或然跟随的结果,有其特殊的存储方式,见规则3,即A+B?规则,如果DocDelta后面跟随的Freq为1,则用DocDelta最后一位置1表示。

如果DocDelta后面跟随的Freq大于1,则DocDelta得最后一位置0,然后后面跟随真正的值,从而对于第一个Term,由于Freq为1,于是放在DocDelta的最后一位表示,DocIDDelta = 7的二进制是000 0111,必须要左移一位,且最后一位置一,000 1111 = 15,对于第二个Term,由于Freq大于一,于是放在DocDelta的最后一位置零,DocIDDelta = 4的二进制是0000 0100,必须要左移一位,且最后一位置零,0000 1000 = 8,然后后面跟随真正的Freq = 3。

于是得到序列:[DocDleta = 15][DocDelta = 8, Freq = 3],也即序列,15,8,3。

如果omitTf=true,也即我们不在索引中存储一个文档中Term出现的次数,则只存DocID就可以了,因而不存在A+B?规则的应用。

[DocID = 7][DocID = 11],然后应用规则2,Delta规则,于是得到序列[DocDelta = 7][DocDelta = 4 (11 - 7)],也即序列,7,4.

  • 对于跳跃表的存储有以下几点需要解释一下:
    • 跳跃表可根据倒排表本身的长度(DocFreq)和跳跃的幅度(SkipInterval)而分不同的层次,层次数为NumSkipLevels = Min(MaxSkipLevels, floor(log(DocFreq/log(SkipInterval)))).
    • 第Level层的节点数为DocFreq/(SkipInterval^(Level + 1)),level从零计数。
    • 除了最低层之外,其他层都有SkipLevelLength来表示此层的二进制长度(而非节点的个数),方便读取某一层的跳跃表到缓存里面。
    • 高层在前,低层在后,当读完所有的高层后,剩下的就是最低一层,因而最后一层不需要SkipLevelLength。这也是为什么Lucene文档中的格式描述为 NumSkipLevels-1, SkipLevel,也即低NumSKipLevels-1层有SkipLevelLength,最后一层只有SkipLevel,没有SkipLevelLength。
    • 除最低层以外,其他层都有SkipChildLevelPointer来指向下一层相应的节点。
    • 每一个跳跃节点包含以下信息:文档号,payload的长度,文档号对应的倒排表中的节点在frq中的偏移量,文档号对应的倒排表中的节点在prx中的偏移量。
    • 虽然Lucene的文档中有以下的描述,然而实验的结果却不是完全准确的:

Example: SkipInterval = 4, MaxSkipLevels = 2, DocFreq = 35. Then skip level 0 has 8 SkipData entries, containing the 3rd, 7th, 11th, 15th, 19th, 23rd, 27th, and 31st document numbers in TermFreqs. Skip level 1 has 2 SkipData entries, containing the 15th and 31st document numbers in TermFreqs.

按照描述,当SkipInterval为4,且有35篇文档的时候,Skip level = 0应该包括第3,第7,第11,第15,第19,第23,第27,第31篇文档,Skip level = 1应该包括第15,第31篇文档。

然而真正的实现中,跳跃表节点的时候,却向前偏移了,偏移的原因在于下面的代码:

  • FormatPostingsDocsWriter.addDoc(int docID, int termDocFreq)
    • final int delta = docID - lastDocID;
    • if ((++df % skipInterval) == 0)
      • skipListWriter.setSkipData(lastDocID, storePayloads, posWriter.lastPayloadLength);
      • skipListWriter.bufferSkip(df);

从代码中,我们可以看出,当SkipInterval为4的时候,当docID = 0时,++df为1,1%4不为0,不是跳跃节点,当docID = 3时,++df=4,4%4为0,为跳跃节点,然而skipData里面保存的却是lastDocID为2。

所以真正的倒排表和跳跃表中保存一下的信息:

4.2.3. 词位置(prx)信息

词位置信息也是倒排表,也是以跳跃表形式存在的。

  • 此文件包含TermCount个项,每一个词都有一项,因为每一个词都有自己的词位置倒排表。
  • 对于每一个词的都有一个DocFreq大小的数组,每项代表一篇文档,记录此文档中此词出现的位置。这个文档数组也是和frq文件中的跳跃表有关系的,从上面我们知道,在frq的跳跃表节点中有ProxSkip,当SkipInterval为3的时候,frq的跳跃表节点指向prx文件中的此数组中的第1,第4,第7,第10,第13,第16篇文档。
  • 对于每一篇文档,可能包含一个词多次,因而有一个Freq大小的数组,每一项代表此词在此文档中出现一次,则有一个位置信息。
  • 每一个位置信息包含:PositionDelta(采用差值规则),还可以保存payload,应用或然跟随规则。

4.3. 其他信息

4.3.1. 标准化因子文件(nrm)

为什么会有标准化因子呢?从第一章中的描述,我们知道,在搜索过程中,搜索出的文档要按与查询语句的相关性排序,相关性大的打分(score)高,从而排在前面。相关性打分(score)使用向量空间模型(Vector Space Model),在计算相关性之前,要计算Term Weight,也即某Term相对于某Document的重要性。在计算Term Weight时,主要有两个影响因素,一个是此Term在此文档中出现的次数,一个是此Term的普通程度。显然此Term在此文档中出现的次数越多,此Term在此文档中越重要。

这种Term Weight的计算方法是最普通的,然而存在以下几个问题:

  • 不同的文档重要性不同。有的文档重要些,有的文档相对不重要,比如对于做软件的,在索引书籍的时候,我想让计算机方面的书更容易搜到,而文学方面的书籍搜索时排名靠后。
  • 不同的域重要性不同。有的域重要一些,如关键字,如标题,有的域不重要一些,如附件等。同样一个词(Term),出现在关键字中应该比出现在附件中打分要高。
  • 根据词(Term)在文档中出现的绝对次数来决定此词对文档的重要性,有不合理的地方。比如长的文档词在文档中出现的次数相对较多,这样短的文档比较吃亏。比如一个词在一本砖头书中出现了10次,在另外一篇不足100字的文章中出现了9次,就说明砖头书应该排在前面码?不应该,显然此词在不足100字的文章中能出现9次,可见其对此文章的重要性。

由于以上原因,Lucene在计算Term Weight时,都会乘上一个标准化因子(Normalization Factor),来减少上面三个问题的影响。

标准化因子(Normalization Factor)是会影响随后打分(score)的计算的,Lucene的打分计算一部分发生在索引过程中,一般是与查询语句无关的参数如标准化因子,大部分发生在搜索过程中,会在搜索过程的代码分析中详述。

标准化因子(Normalization Factor)在索引过程总的计算如下:

它包括三个参数:

  • Document boost:此值越大,说明此文档越重要。
  • Field boost:此域越大,说明此域越重要。
  • lengthNorm(field) = (1.0 / Math.sqrt(numTerms)):一个域中包含的Term总数越多,也即文档越长,此值越小,文档越短,此值越大。

从上面的公式,我们知道,一个词(Term)出现在不同的文档或不同的域中,标准化因子不同。比如有两个文档,每个文档有两个域,如果不考虑文档长度,就有四种排列组合,在重要文档的重要域中,在重要文档的非重要域中,在非重要文档的重要域中,在非重要文档的非重要域中,四种组合,每种有不同的标准化因子。

于是在Lucene中,标准化因子共保存了(文档数目乘以域数目)个,格式如下:

  • 标准化因子文件(Normalization Factor File: nrm):
    • NormsHeader:字符串“NRM”外加Version,依Lucene的版本的不同而不同。
    • 接着是一个数组,大小为NumFields,每个Field一项,每一项为一个Norms。
    • Norms也是一个数组,大小为SegSize,即此段中文档的数量,每一项为一个Byte,表示一个浮点数,其中02为尾数,38为指数。

4.3.2. 删除文档文件(del)

  • 被删除文档文件(Deleted Document File: .del)
    • Format:在此文件中,Bits和DGaps只能保存其中之一,-1表示保存DGaps,非负值表示保存Bits。
    • ByteCount:此段中有多少文档,就有多少个bit被保存,但是以byte形式计数,也即Bits的大小应该是byte的倍数。
    • BitCount:Bits中有多少位被至1,表示此文档已经被删除。
    • Bits:一个数组的byte,大小为ByteCount,应用时被认为是byte*8个bit。
    • DGaps:如果删除的文档数量很小,则Bits大部分位为0,很浪费空间。DGaps采用以下的方式来保存稀疏数组:比如第十,十二,三十二个文档被删除,于是第十,十二,三十二位设为1,DGaps也是以byte为单位的,仅保存不为0的byte,如第1个byte,第4个byte,第1个byte十进制为20,第4个byte十进制为1。于是保存成DGaps,第1个byte,位置1用不定长正整数保存,值为20用二进制保存,第2个byte,位置4用不定长正整数保存,用差值为3,值为1用二进制保存,二进制数据不用差值表示。

五、总体结构

  • 图示为Lucene索引文件的整体结构:
    • 属于整个索引(Index)的segment.gen,segment_N,其保存的是段(segment)的元数据信息,然后分多个segment保存数据信息,同一个segment有相同的前缀文件名。
    • 对于每一个段,包含域信息,词信息,以及其他信息(标准化因子,删除文档)
    • 域信息也包括域的元数据信息,在fnm中,域的数据信息,在fdx,fdt中。
    • 词信息是反向信息,包括词典(tis, tii),文档号及词频倒排表(frq),词位置倒排表(prx)。

大家可以通过看源代码,相应的Reader和Writer来了解文件结构,将更为透彻。

参考资料

☑️ ☆

Lucene 二: Lucene 的总体架构

Lucene总的来说是:

  • 一个高效的,可扩展的,全文检索库。
  • 全部用Java实现,无须配置。
  • 仅支持纯文本文件的索引(Indexing)和搜索(Search)。
  • 不负责由其他格式的文件抽取纯文本文件,或从网络中抓取文件的过程。

在Lucene in action中,Lucene 的架构和过程如下图,

说明Lucene是有索引和搜索的两个过程,包含索引创建,索引,搜索三个要点。

让我们更细一些看Lucene的各组件:

  • 被索引的文档用Document对象表示。
  • IndexWriter通过函数addDocument将文档添加到索引中,实现创建索引的过程。
  • Lucene的索引是应用反向索引。
  • 当用户有请求时,Query代表用户的查询语句。
  • IndexSearcher通过函数search搜索Lucene Index。
  • IndexSearcher计算term weight和score并且将结果返回给用户。
  • 返回给用户的文档集合用TopDocsCollector表示。

那么如何应用这些组件呢?

让我们再详细到对Lucene API 的调用实现索引和搜索过程。

  • 索引过程如下:
    • 创建一个IndexWriter用来写索引文件,它有几个参数,INDEX_DIR就是索引文件所存放的位置,Analyzer便是用来对文档进行词法分析和语言处理的。
    • 创建一个Document代表我们要索引的文档。
    • 将不同的Field加入到文档中。我们知道,一篇文档有多种信息,如题目,作者,修改时间,内容等。不同类型的信息用不同的Field来表示,在本例子中,一共有两类信息进行了索引,一个是文件路径,一个是文件内容。其中FileReader的SRC_FILE就表示要索引的源文件。
    • IndexWriter调用函数addDocument将索引写到索引文件夹中。
  • 搜索过程如下:
    • IndexReader将磁盘上的索引信息读入到内存,INDEX_DIR就是索引文件存放的位置。
    • 创建IndexSearcher准备进行搜索。
    • 创建Analyer用来对查询语句进行词法分析和语言处理。
    • 创建QueryParser用来对查询语句进行语法分析。
    • QueryParser调用parser进行语法分析,形成查询语法树,放到Query中。
    • IndexSearcher调用search对查询语法树Query进行搜索,得到结果TopScoreDocCollector。

以上便是Lucene API函数的简单调用。

然而当进入Lucene的源代码后,发现Lucene有很多包,关系错综复杂。

然而通过下图,我们不难发现,Lucene的各源码模块,都是对普通索引和搜索过程的一种实现。

此图是上一节介绍的全文检索的流程对应的Lucene实现的包结构。(参照http://www.lucene.com.cn/about.htm中文章《开放源代码的全文检索引擎Lucene》)

  • Lucene的analysis模块主要负责词法分析及语言处理而形成Term。
  • Lucene的index模块主要负责索引的创建,里面有IndexWriter。
  • Lucene的store模块主要负责索引的读写。
  • Lucene的QueryParser主要负责语法分析。
  • Lucene的search模块主要负责对索引的搜索。
  • Lucene的similarity模块主要负责对相关性打分的实现。
☑️ ☆

Lucene 一: 全文检索的基本原理

一、总论

根据http://lucene.apache.org/java/docs/index.html定义:

Lucene 是一个高效的,基于Java 的全文检索库。

所以在了解Lucene之前要费一番工夫了解一下全文检索。

那么什么叫做全文检索呢?这要从我们生活中的数据说起。

我们生活中的数据总体分为两种:结构化数据非结构化数据

  • 结构化数据: 指具有固定格式或有限长度的数据,如数据库,元数据等。
  • 非结构化数据: 指不定长或无固定格式的数据,如邮件,word文档等。

当然有的地方还会提到第三种,半结构化数据,如XML,HTML等,当根据需要可按结构化数据来处理,也可抽取出纯文本按非结构化数据来处理。

非结构化数据又一种叫法叫全文数据。

按照数据的分类,搜索也分为两种:

  • 对结构化数据的搜索:如对数据库的搜索,用SQL语句。再如对元数据的搜索,如利用windows搜索对文件名,类型,修改时间进行搜索等。
  • 对非结构化数据的搜索:如利用windows的搜索也可以搜索文件内容,Linux下的grep命令,再如用Google和百度可以搜索大量内容数据。

对非结构化数据也即对全文数据的搜索主要有两种方法:

一种是顺序扫描法(Serial Scanning): 所谓顺序扫描,比如要找内容包含某一个字符串的文件,就是一个文档一个文档的看,对于每一个文档,从头看到尾,如果此文档包含此字符串,则此文档为我们要找的文件,接着看下一个文件,直到扫描完所有的文件。如利用windows的搜索也可以搜索文件内容,只是相当的慢。如果你有一个80G硬盘,如果想在上面找到一个内容包含某字符串的文件,不花他几个小时,怕是做不到。Linux下的grep命令也是这一种方式。大家可能觉得这种方法比较原始,但对于小数据量的文件,这种方法还是最直接,最方便的。但是对于大量的文件,这种方法就很慢了。

有人可能会说,对非结构化数据顺序扫描很慢,对结构化数据的搜索却相对较快(由于结构化数据有一定的结构可以采取一定的搜索算法加快速度),那么把我们的非结构化数据想办法弄得有一定结构不就行了吗?

这种想法很天然,却构成了全文检索的基本思路,也即将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对此有一定结构的数据进行搜索,从而达到搜索相对较快的目的。

这部分从非结构化数据中提取出的然后重新组织的信息,我们称之索引

这种说法比较抽象,举几个例子就很容易明白,比如字典,字典的拼音表和部首检字表就相当于字典的索引,对每一个字的解释是非结构化的,如果字典没有音节表和部首检字表,在茫茫辞海中找一个字只能顺序扫描。然而字的某些信息可以提取出来进行结构化处理,比如读音,就比较结构化,分声母和韵母,分别只有几种可以一一列举,于是将读音拿出来按一定的顺序排列,每一项读音都指向此字的详细解释的页数。我们搜索时按结构化的拼音搜到读音,然后按其指向的页数,便可找到我们的非结构化数据——也即对字的解释。

这种先建立索引,再对索引进行搜索的过程就叫全文检索(Full-text Search)。

下面这幅图来自《Lucene in action》,但却不仅仅描述了Lucene的检索过程,而是描述了全文检索的一般过程。

全文检索大体分两个过程,索引创建(Indexing)搜索索引(Search)

  • 索引创建:将现实世界中所有的结构化和非结构化数据提取信息,创建索引的过程。
  • 搜索索引:就是得到用户的查询请求,搜索创建的索引,然后返回结果的过程。

于是全文检索就存在三个重要问题:

1. 索引里面究竟存些什么?(Index)

2. 如何创建索引?(Indexing)

3. 如何对索引进行搜索?(Search)

下面我们顺序对每个个问题进行研究。

二、索引里面究竟存些什么

索引里面究竟需要存些什么呢?

首先我们来看为什么顺序扫描的速度慢:

其实是由于我们想要搜索的信息和非结构化数据中所存储的信息不一致造成的。

非结构化数据中所存储的信息是每个文件包含哪些字符串,也即已知文件,欲求字符串相对容易,也即是从文件到字符串的映射。而我们想搜索的信息是哪些文件包含此字符串,也即已知字符串,欲求文件,也即从字符串到文件的映射。两者恰恰相反。于是如果索引总能够保存从字符串到文件的映射,则会大大提高搜索速度。

由于从字符串到文件的映射是文件到字符串映射的反向过程,于是保存这种信息的索引称为反向索引

反向索引的所保存的信息一般如下:

假设我的文档集合里面有100篇文档,为了方便表示,我们为文档编号从1到100,得到下面的结构

左边保存的是一系列字符串,称为词典

每个字符串都指向包含此字符串的文档(Document)链表,此文档链表称为倒排表(Posting List)。

有了索引,便使保存的信息和要搜索的信息一致,可以大大加快搜索的速度。

比如说,我们要寻找既包含字符串“lucene”又包含字符串“solr”的文档,我们只需要以下几步:

1. 取出包含字符串“lucene”的文档链表。

2. 取出包含字符串“solr”的文档链表。

3. 通过合并链表,找出既包含“lucene”又包含“solr”的文件。

看到这个地方,有人可能会说,全文检索的确加快了搜索的速度,但是多了索引的过程,两者加起来不一定比顺序扫描快多少。的确,加上索引的过程,全文检索不一定比顺序扫描快,尤其是在数据量小的时候更是如此。而对一个很大量的数据创建索引也是一个很慢的过程。

然而两者还是有区别的,顺序扫描是每次都要扫描,而创建索引的过程仅仅需要一次,以后便是一劳永逸的了,每次搜索,创建索引的过程不必经过,仅仅搜索创建好的索引就可以了。

这也是全文搜索相对于顺序扫描的优势之一:一次索引,多次使用。

三、如何创建索引

全文检索的索引创建过程一般有以下几步:

第一步:一些要索引的原文档(Document)。

为了方便说明索引创建过程,这里特意用两个文件为例:

文件一:Students should be allowed to go out with their friends, but not allowed to drink beer.

文件二:My friend Jerry went to school to see his students but found them drunk which is not allowed.

第二步:将原文档传给分词组件(Tokenizer)。

分词组件(Tokenizer)会做以下几件事情(此过程称为Tokenize):

1. 将文档分成一个一个单独的单词。

2. 去除标点符号。

3. 去除停词(Stop word)。

所谓停词(Stop word)就是一种语言中最普通的一些单词,由于没有特别的意义,因而大多数情况下不能成为搜索的关键词,因而创建索引时,这种词会被去掉而减少索引的大小。

英语中挺词(Stop word)如:“the”,“a”,“this”等。

对于每一种语言的分词组件(Tokenizer),都有一个停词(stop word)集合。

经过分词(Tokenizer)后得到的结果称为词元(Token)。

在我们的例子中,便得到以下词元(Token):

“Students”,“allowed”,“go”,“their”,“friends”,“allowed”,“drink”,“beer”,“My”,“friend”,“Jerry”,“went”,“school”,“see”,“his”,“students”,“found”,“them”,“drunk”,“allowed”。

第三步:将得到的词元(Token)传给语言处理组件(Linguistic Processor)。

语言处理组件(linguistic processor)主要是对得到的词元(Token)做一些同语言相关的处理。

对于英语,语言处理组件(Linguistic Processor)一般做以下几点:

1. 变为小写(Lowercase)。

2. 将单词缩减为词根形式,如“cars”到“car”等。这种操作称为:stemming。

3. 将单词转变为词根形式,如“drove”到“drive”等。这种操作称为:lemmatization。

Stemming 和 lemmatization的异同:

  • 相同之处:Stemming和lemmatization都要使词汇成为词根形式。
  • 两者的方式不同:
    • Stemming采用的是“缩减”的方式:“cars”到“car”,“driving”到“drive”。
    • Lemmatization采用的是“转变”的方式:“drove”到“drove”,“driving”到“drive”。
  • 两者的算法不同:
    • Stemming主要是采取某种固定的算法来做这种缩减,如去除“s”,去除“ing”加“e”,将“ational”变为“ate”,将“tional”变为“tion”。
    • Lemmatization主要是采用保存某种字典的方式做这种转变。比如字典中有“driving”到“drive”,“drove”到“drive”,“am, is, are”到“be”的映射,做转变时,只要查字典就可以了。
  • Stemming和lemmatization不是互斥关系,是有交集的,有的词利用这两种方式都能达到相同的转换。

语言处理组件(linguistic processor)的结果称为词(Term)。

在我们的例子中,经过语言处理,得到的词(Term)如下:

“student”,“allow”,“go”,“their”,“friend”,“allow”,“drink”,“beer”,“my”,“friend”,“jerry”,“go”,“school”,“see”,“his”,“student”,“find”,“them”,“drink”,“allow”。

也正是因为有语言处理的步骤,才能使搜索drove,而drive也能被搜索出来。

第四步:将得到的词(Term)传给索引组件(Indexer)。

索引组件(Indexer)主要做以下几件事情:

1. 利用得到的词(Term)创建一个字典。

在我们的例子中字典如下:

TermDocument ID
student1
allow1
go1
their1
friend1
allow1
drink1
beer1
my2
friend2
jerry2
go2
school2
see2
his2
student2
find2
them2
drink2
allow2

2. 对字典按字母顺序进行排序。

TermDocument ID
allow1
allow1
allow2
beer1
drink1
drink2
find2
friend1
friend2
go1
go2
his2
jerry2
my2
school2
see2
student1
student2
their1
them2

3. 合并相同的词(Term)成为文档倒排(Posting List)链表。

在此表中,有几个定义:

  • Document Frequency 即文档频次,表示总共有多少文件包含此词(Term)。
  • Frequency 即词频率,表示此文件中包含了几个此词(Term)。

所以对词(Term) “allow”来讲,总共有两篇文档包含此词(Term),从而词(Term)后面的文档链表总共有两项,第一项表示包含“allow”的第一篇文档,即1号文档,此文档中,“allow”出现了2次,第二项表示包含“allow”的第二个文档,是2号文档,此文档中,“allow”出现了1次。

到此为止,索引已经创建好了,我们可以通过它很快的找到我们想要的文档。

而且在此过程中,我们惊喜地发现,搜索“drive”,“driving”,“drove”,“driven”也能够被搜到。因为在我们的索引中,“driving”,“drove”,“driven”都会经过语言处理而变成“drive”,在搜索时,如果您输入“driving”,输入的查询语句同样经过我们这里的一到三步,从而变为查询“drive”,从而可以搜索到想要的文档。

三、如何对索引进行搜索?

到这里似乎我们可以宣布“我们找到想要的文档了”。

然而事情并没有结束,找到了仅仅是全文检索的一个方面。不是吗?如果仅仅只有一个或十个文档包含我们查询的字符串,我们的确找到了。然而如果结果有一千个,甚至成千上万个呢?那个又是您最想要的文件呢?

打开Google吧,比如说您想在微软找份工作,于是您输入“Microsoft job”,您却发现总共有22600000个结果返回。好大的数字呀,突然发现找不到是一个问题,找到的太多也是一个问题。在如此多的结果中,如何将最相关的放在最前面呢?

当然Google做的很不错,您一下就找到了jobs at Microsoft。想象一下,如果前几个全部是“Microsoft does a good job at software industry…”将是多么可怕的事情呀。

如何像Google一样,在成千上万的搜索结果中,找到和查询语句最相关的呢?

如何判断搜索出的文档和查询语句的相关性呢?

这要回到我们第三个问题:如何对索引进行搜索?

搜索主要分为以下几步:

第一步:用户输入查询语句。

查询语句同我们普通的语言一样,也是有一定语法的。

不同的查询语句有不同的语法,如SQL语句就有一定的语法。

查询语句的语法根据全文检索系统的实现而不同。最基本的有比如:AND, OR, NOT等。

举个例子,用户输入语句:lucene AND learned NOT hadoop。

说明用户想找一个包含lucene和learned然而不包括hadoop的文档。

第二步:对查询语句进行词法分析,语法分析,及语言处理。

由于查询语句有语法,因而也要进行语法分析,语法分析及语言处理。

1. 词法分析主要用来识别单词和关键字。

如上述例子中,经过词法分析,得到单词有lucene,learned,hadoop, 关键字有AND, NOT。

如果在词法分析中发现不合法的关键字,则会出现错误。如lucene AMD learned,其中由于AND拼错,导致AMD作为一个普通的单词参与查询。

2. 语法分析主要是根据查询语句的语法规则来形成一棵语法树。

如果发现查询语句不满足语法规则,则会报错。如lucene NOT AND learned,则会出错。

如上述例子,lucene AND learned NOT hadoop形成的语法树如下:

3. 语言处理同索引过程中的语言处理几乎相同。

如learned变成learn等。

经过第二步,我们得到一棵经过语言处理的语法树。

第三步:搜索索引,得到符合语法树的文档。

此步骤有分几小步:

  1. 首先,在反向索引表中,分别找出包含lucene,learn,hadoop的文档链表。
  2. 其次,对包含lucene,learn的链表进行合并操作,得到既包含lucene又包含learn的文档链表。
  3. 然后,将此链表与hadoop的文档链表进行差操作,去除包含hadoop的文档,从而得到既包含lucene又包含learn而且不包含hadoop的文档链表。
  4. 此文档链表就是我们要找的文档。

第四步:根据得到的文档和查询语句的相关性,对结果进行排序。

虽然在上一步,我们得到了想要的文档,然而对于查询结果应该按照与查询语句的相关性进行排序,越相关者越靠前。

如何计算文档和查询语句的相关性呢?

不如我们把查询语句看作一片短小的文档,对文档与文档之间的相关性(relevance)进行打分(scoring),分数高的相关性好,就应该排在前面。

那么又怎么对文档之间的关系进行打分呢?

这可不是一件容易的事情,首先我们看一看判断人之间的关系吧。

首先看一个人,往往有很多要素,如性格,信仰,爱好,衣着,高矮,胖瘦等等。

其次对于人与人之间的关系,不同的要素重要性不同,性格,信仰,爱好可能重要些,衣着,高矮,胖瘦可能就不那么重要了,所以具有相同或相似性格,信仰,爱好的人比较容易成为好的朋友,然而衣着,高矮,胖瘦不同的人,也可以成为好的朋友。

因而判断人与人之间的关系,首先要找出哪些要素对人与人之间的关系最重要,比如性格,信仰,爱好。其次要判断两个人的这些要素之间的关系,比如一个人性格开朗,另一个人性格外向,一个人信仰佛教,另一个信仰上帝,一个人爱好打篮球,另一个爱好踢足球。我们发现,两个人在性格方面都很积极,信仰方面都很善良,爱好方面都爱运动,因而两个人关系应该会很好。

我们再来看看公司之间的关系吧。

首先看一个公司,有很多人组成,如总经理,经理,首席技术官,普通员工,保安,门卫等。

其次对于公司与公司之间的关系,不同的人重要性不同,总经理,经理,首席技术官可能更重要一些,普通员工,保安,门卫可能较不重要一点。所以如果两个公司总经理,经理,首席技术官之间关系比较好,两个公司容易有比较好的关系。然而一位普通员工就算与另一家公司的一位普通员工有血海深仇,怕也难影响两个公司之间的关系。

因而判断公司与公司之间的关系,首先要找出哪些人对公司与公司之间的关系最重要,比如总经理,经理,首席技术官。其次要判断这些人之间的关系,不如两家公司的总经理曾经是同学,经理是老乡,首席技术官曾是创业伙伴。我们发现,两家公司无论总经理,经理,首席技术官,关系都很好,因而两家公司关系应该会很好。

分析了两种关系,下面看一下如何判断文档之间的关系了。

首先,一个文档有很多词(Term)组成,如search, lucene, full-text, this, a, what等。

其次对于文档之间的关系,不同的Term重要性不同,比如对于本篇文档,search, Lucene, full-text就相对重要一些,this, a , what可能相对不重要一些。所以如果两篇文档都包含search, Lucene,fulltext,这两篇文档的相关性好一些,然而就算一篇文档包含this, a, what,另一篇文档不包含this, a, what,也不能影响两篇文档的相关性。

因而判断文档之间的关系,首先找出哪些词(Term)对文档之间的关系最重要,如search, Lucene, fulltext。然后判断这些词(Term)之间的关系。

找出词(Term)对文档的重要性的过程称为计算词的权重(Term weight)的过程。

计算词的权重(term weight)有两个参数,第一个是词(Term),第二个是文档(Document)。

词的权重(Term weight)表示此词(Term)在此文档中的重要程度,越重要的词(Term)有越大的权重(Term weight),因而在计算文档之间的相关性中将发挥更大的作用。

判断词(Term)之间的关系从而得到文档相关性的过程应用一种叫做向量空间模型的算法(Vector Space Model)。

下面仔细分析一下这两个过程:

1. 计算权重(Term weight)的过程。

影响一个词(Term)在一篇文档中的重要性主要有两个因素:

  • Term Frequency (tf):即此Term在此文档中出现了多少次。tf 越大说明越重要。
  • Document Frequency (df):即有多少文档包含次Term。df 越大说明越不重要。

容易理解吗?词(Term)在文档中出现的次数越多,说明此词(Term)对该文档越重要,如“搜索”这个词,在本文档中出现的次数很多,说明本文档主要就是讲这方面的事的。然而在一篇英语文档中,this出现的次数更多,就说明越重要吗?不是的,这是由第二个因素进行调整,第二个因素说明,有越多的文档包含此词(Term), 说明此词(Term)太普通,不足以区分这些文档,因而重要性越低。

这也如我们程序员所学的技术,对于程序员本身来说,这项技术掌握越深越好(掌握越深说明花时间看的越多,tf越大),找工作时越有竞争力。然而对于所有程序员来说,这项技术懂得的人越少越好(懂得的人少df小),找工作越有竞争力。人的价值在于不可替代性就是这个道理。

道理明白了,我们来看看公式:

这仅仅只term weight计算公式的简单典型实现。实现全文检索系统的人会有自己的实现,Lucene就与此稍有不同。

2. 判断Term之间的关系从而得到文档相关性的过程,也即向量空间模型的算法(VSM)。

我们把文档看作一系列词(Term),每一个词(Term)都有一个权重(Term weight),不同的词(Term)根据自己在文档中的权重来影响文档相关性的打分计算。

于是我们把所有此文档中词(term)的权重(term weight) 看作一个向量。

Document = {term1, term2, …… ,term N}

Document Vector = {weight1, weight2, …… ,weight N}

同样我们把查询语句看作一个简单的文档,也用向量来表示。

Query = {term1, term 2, …… , term N}

Query Vector = {weight1, weight2, …… , weight N}

我们把所有搜索出的文档向量及查询向量放到一个N维空间中,每个词(term)是一维。

如图:

我们认为两个向量之间的夹角越小,相关性越大。

所以我们计算夹角的余弦值作为相关性的打分,夹角越小,余弦值越大,打分越高,相关性越大。

有人可能会问,查询语句一般是很短的,包含的词(Term)是很少的,因而查询向量的维数很小,而文档很长,包含词(Term)很多,文档向量维数很大。你的图中两者维数怎么都是N呢?

在这里,既然要放到相同的向量空间,自然维数是相同的,不同时,取二者的并集,如果不含某个词(Term)时,则权重(Term Weight)为0。

相关性打分公式如下:

举个例子,查询语句有11个Term,共有三篇文档搜索出来。其中各自的权重(Term weight),如下表格。


t1

t2

t3

t4

t5

t6

t7

t8

t9

t10

t11

D1

0

0

.477

0

.477

.176

0

0

0

.176

0

D2

0

.176

0

.477

0

0

0

0

.954

0

.176

D3

0

.176

0

0

0

.176

0

0

0

.176

.176

Q

0

0

0

0

0

.176

0

0

.477

0

.176

于是计算,三篇文档同查询语句的相关性打分分别为:

于是文档二相关性最高,先返回,其次是文档一,最后是文档三。

到此为止,我们可以找到我们最想要的文档了。

说了这么多,其实还没有进入到Lucene,而仅仅是信息检索技术(Information retrieval)中的基本理论,然而当我们看过Lucene后我们会发现,Lucene是对这种基本理论的一种基本的的实践。所以在以后分析Lucene的文章中,会常常看到以上理论在Lucene中的应用。

在进入Lucene之前,对上述索引创建和搜索过程所一个总结,如图:

此图参照http://www.lucene.com.cn/about.htm中文章《开放源代码的全文检索引擎Lucene》

1. 索引过程:

1) 有一系列被索引文件

2) 被索引文件经过语法分析和语言处理形成一系列词(Term)。

3) 经过索引创建形成词典和反向索引表。

4) 通过索引存储将索引写入硬盘。

2. 搜索过程:

a) 用户输入查询语句。

b) 对查询语句经过语法分析和语言分析得到一系列词(Term)。

c) 通过语法分析得到一个查询树。

d) 通过索引存储将索引读入到内存。

e) 利用查询树搜索索引,从而得到每个词(Term)的文档链表,对文档链表进行交,差,并得到结果文档。

f) 将搜索到的结果文档对查询的相关性进行排序。

g) 返回查询结果给用户。

☑️ ☆

Go 1.18 的工作区模式

当一个项目越来越复杂的时候,一定会拆分为多个模块,以便进行代码复用和更好的多人协作开发。

假设我们已经有了两个模块 xxx.org/utilxxx.org/product ,模块xxx.org/product依赖 xxx.org/util

现在有一个需求,需要同时修改这两个模块,以便让xxx.org/util新增的方法给模块xxx.org/product使用。

但是当同事A在模块xxx.org/util中增加新的方法后,要么推送到VCS中,让负责模块xxx.org/product的同事B使用,这是发布的场景。

如果模块xxx.org/util没有发布呢?那么就只能通过go.mod中的replace指令进行替换,把对模块xxx.org/util的引用,换成本地的未发布的版本,比如:

replace xxx.org/util => /Users/xxx/go/demo/util

相信我们都遇到过以上两种情形,这两种情形都有相应的弊端,比如:

  1. 把没有调试、没有测试的代码发布了,会影响其他正常构建
  2. replace的时候,忘记改回来,提交到VCS中了,影响了其他人使用

为了解决以上问题,Go 团队提出了工作区的概念,并且在Go 1.18 中发布。

Go 工作区,是你的工作区,它和多人协作、VCS等无关。说白了它就是个本地目录,通过go.work文件来管理多个go.mod模块。

要创建一个Go 工作区非常简单,通过如下命令即可:

mkdir workspace
cd workspace
go work init /Users/xxx/go/demo/util /Users/xxx/go/demo/product

在以上示例中,workspace是我创建的一个工作区,可以在你的电脑的任何地方,名字也可以自取。

然后go work init 后是两个go.mod的绝对路径,用空格分开,当然你也可以用相对路径。

运行以上代码后,就会在workspace目录下生成一个go.work文件,它的内容如下:

go 1.18

use (
  /Users/xxx/go/demo/product
  /Users/xxx/go/demo/util
)

usego.work文件的一个指令,用于管理包含的go.mod模块。

除了use指令,go.work还有replace指令,它和go.mod的replace很像,它用于把Go 工作区间管理的go.mod全部替换为指定的路径,并且它的优先级要比go.mod的replace要高。

现在,我们用到的这两个模块都在同一个工作区了,那么就不需要再修改模块xxx/product的go.mod replace 指令完成本地的依赖了。

这时候,在工作区 workspce目录下,运行如下命令,即可进行验证。

  workspace go run xxx.org/product
你好

因为都在一个工作区,go可以帮你找到模块xxx.org/product所依赖的xxx.org/util模块。

如果你只是切换到product目录下运行以上命令,只会提示你:

  product go run main.go 
main.go:3:8: no required module provides package xxx.org/util; to add it:
  go get xxx.org/util

不止我上面这种依赖上游模块的例子可以使用Go工作区,如果你一个代码库中有多个模块也是可以用的,只需要把他们都加入到Go 工作区即可。

go work命令有一个use可以把本地目录的模块加入工作区,如下所示:

go work use [path-to-your-module]

方括号中的路径,可以换成你自己电脑上的本地模块路径。

当然你也可以直接修改go.work文件,效果是一样的,这里不再举例,你可以自己试试。

go.work本质上是一种本地化的解决办法,因为go.mod都是放在VCS中的,和项目息息相关,所以我们很少去修改它来达到一些Hack的操作。

现在有了go.work就好办多了,因为它就是一个本地的东西,不在VCS中,想怎么改就怎么改,又不

☑️ ☆

Jenkins中的环境变量

前言

准备

如果你想一边阅读本文,一边实践,但是没有 Jenkins 服务可用,又想快速尝试,可以应用 Docker 一个命令快速搭建 Jenkins 服务

docker container run --rm -p 8080:8080 -p 50000:50000 --name=jenkins -v $(pwd):/var/jenkins_home jenkins/jenkins

2021 年了,本地没有 Docker 说不过去了,过来瞧瞧 Docker 系列是否入得了你的法眼?

打开浏览器输入:localhost:8080

  1. 找到终端的临时密码登陆
  2. 安装推荐的依赖
  3. 创建新的 Pipeline 类型的 Item
  4. 点击左侧 Config,然后在页面底部 Pipeline 部分输入我们接下来写的脚本进行测试就好了

就是这么简单…..

认识 Jenkins 环境变量

Jenkins 环境变量就是通过 env 关键字暴露出来的全局变量,可以在 Jenkins 文件的任何位置使用

其实和你使用的编程语言中的全局变量没有实质差别

查看 Jenkins 系统内置环境变量

Jenkins 在系统内置了很多环境变量方便我们快速使用,查看起来有两种方式:

方式一:

直接在浏览器中访问 ${YOUR_JENKINS_HOST}/env-vars.html 页面就可以,比如 http://localhost:8080/env-vars.html ,每个变量的用途写的都很清楚

方式二

通过执行 printenv shell 命令来获取:

pipeline {
    agent any

    stages {
        stage("Env Variables") {
            steps {
                sh "printenv"
            }
        }
    }
}

直接 Save - Build, 在终端 log 中你会看到相应的环境变量,并且可以快速看到他们当前的值

通常这两种方式可以结合使用

读取环境变量

上面我们说了 env 是环境变量的关键字,但是读取 Jenkins 内置的这些环境变量,env 关键字是可有可无, 但不能没了底裤,都要使用 ${xxx} 包围起来。以 BUILD_NUMBER 这个内置环境变量举例来说明就是这样滴:

如果你在 Jenkins 文件中使用 shell 命令,使用这些内置环境变量甚至可以不用 {}, 来看一下:

pipeline {
    agent any

    stages {
        stage("Read Env Variables") {
            steps {
                echo "带 env 的读取方式:${env.BUILD_NUMBER}"
                echo "不带 env 的读取方式:${BUILD_NUMBER}"
                sh 'echo "shell 中读取方式 $BUILD_NUMBER"'
            }
        }
    }
}

可以看到结果是一样一样滴,不管有几种,记住第一种最稳妥

内置的环境变量虽好,但也不能完全满足我们自定义的 pipeline 的执行逻辑,所以我们也得知道如何定义以及使用自定义环境变量

自定义 Jenkins 环境变量

Jenkins pipeline 分声明式(Declarative)和 脚本式(imperative)写法,相应的环境变量定义方式也略有不同,归纳起来有三种方式:

还是看个实际例子吧:

pipeline {
    agent any

    environment {
        FOO = "bar"
    }

    stages {
        stage("Custom Env Variables") {
            environment {
                NAME = "RGYB"
            }

            steps {
                echo "FOO = ${env.FOO}"
                echo "NAME = ${env.NAME}"

                script {
                    env.SCRIPT_VARIABLE = "Thumb Up"
                }

                echo "SCRIPT_VARIABLE = ${env.SCRIPT_VARIABLE}"

                withEnv(["WITH_ENV_VAR=Come On"]) {
                    echo "WITH_ENV_VAR = ${env.WITH_ENV_VAR}"
                }
            }
        }
    }
}

来看运行结果:

注意:withEnv(["WITH_ENV_VAR=Come On"]) {} 这里的 = 号两侧不能有空格,必须是 key=value 的形式

一个完整的 pipeline 通常会有很多个 stage,环境变量在不同的 stage 有不同的值是很常见的,知道如何设置以及读取环境变量后,我们还得知道如何重写环境变量

重写 Jenkins 环境变量

Jenkins 让人相对困惑最多的地方就是重写环境变量,但是只要记住下面这三条规则,就可以搞定一切了

  1. withEnv(["WITH_ENV_VAR=Come On"]) {} 内置函数的这种写法,可以重写任意环境变量
  2. 定义在 environment {} 的环境变量不能被脚本式定义的环境变量(env.key="value")重写
  3. 脚本式环境变量只能重写脚本式环境变量

这三点是硬规则,没涵盖在这 3 点规则之内的也就是被允许的了

三条规则就有点让人头大了,农夫选豆种,举例为证吧

pipeline {
    agent any

    environment {
        FOO = "你当像鸟飞往你的山"
        NAME = "Tan"
    }

    stages {
        stage("Env Variables") {
            environment {
                  // 会重写第 6 行 变量
                NAME = "RGYB" 
                  // 会重写系统内置的环境变量 BUILD_NUMBER
                BUILD_NUMBER = "10" 
            }

            steps {
                  // 应该打印出 "FOO = 你当像鸟飞往你的山"
                echo "FOO = ${env.FOO}" 
                  // 应该打印出 "NAME = RGYB"
                echo "NAME = ${env.NAME}" 
                  // 应该打印出 "BUILD_NUMBER = 10"
                echo "BUILD_NUMBER =  ${env.BUILD_NUMBER}" 

                script {
                      // 脚本式创建一个环境变量
                    env.SCRIPT_VARIABLE = "1" 
                }
            }
        }

        stage("Override Variables") {
            steps {
                script {
                      // 这里的 FOO 不会被重写,违背 Rule No.2
                    env.FOO = "Tara"
                      // SCRIPT_VARIABLE 变量会被重写,符合 Rule No.3
                    env.SCRIPT_VARIABLE = "2" 
                }

                  // FOO 在第 37 行重写失败,还会打印出 "FOO = 你当像鸟飞往你的山"
                echo "FOO = ${env.FOO}" 
                  // 会打印出 "SCRIPT_VARIABLE = 2"
                echo "SCRIPT_VARIABLE = ${env.SCRIPT_VARIABLE}" 

                  // FOO 会被重写,符合 Rule No.1
                withEnv(["FOO=Educated"]) { 
                      // 应该打印 "FOO = Educated"
                    echo "FOO = ${env.FOO}" 
                }

                  // 道理同上
                withEnv(["BUILD_NUMBER=15"]) {
                      // 应该打印出 "BUILD_NUMBER = 15"
                    echo "BUILD_NUMBER = ${env.BUILD_NUMBER}"
                }
            }
        }
    }
}

来验证一下结果吧

看到这,基本的设置应该就没有什么问题了,相信你也发现了,Jenkins 设置环境变量和编程语言的那种设置环境变量还是略有不同的,后者可以将变量赋值为对象,但 Jenkins 就不行,因为在 Jenkins 文件中,所有设置的值都会被当成 String, 难道没办法应用 Boolean 值吗?

Jenkins 中使用 Boolean 值

如果设置一个变量为 false ,Jenkins 就会将其转换为 "false", 如果想使用 Boolean 来做条件判断,必须要调用 toBoolean() 方法做转换

pipeline {
    agent any

    environment {
        IS_BOOLEAN = false
    }

    stages {
        stage("Env Variables") {
            steps {
                script {
                      
                    if (env.IS_BOOLEAN) {
                        echo "Hello"
                    }

                      
                    if (env.IS_BOOLEAN.toBoolean() == false) {
                        echo "日拱一兵"
                    }
                  
                      
                    if (!env.IS_BOOLEAN.toBoolean()) {
                        echo "RGYB"
                    }
                }
            }
        }
    }
}

来看运行结果:

如果你写过 Pipeline,你一定会知道,写 Pipeline 是离不开写 shell 的,有些时候,需要将 shell 的执行结果赋值给环境变量,Jenkins 也有方法支持

Shell 结果赋值给环境变量

实现这种方式很简单,只需要记住一个格式:sh(script: 'cmd', returnStdout:true)

pipeline {
    agent any

    environment {
          
        LS_RESULT = "${sh(script:'ls -lah', returnStdout: true).trim()}"
    }

    stages {
        stage("Env Variables") {
            steps {
                echo "LS_RESULT = ${env.LS_RESULT}"
            }
        }
    }
}

☑️ ☆

编译原理一

什么是编译原理

编译原理是介绍如何将高级程序设计语言转换成计算机硬件能识别的机器语言,以便计算机进行处理

编译与计算机程序设计语言的关系

日常开发过程中我们使用的语言一般都是高级语法比如 JAVA、Python、PHP、JavaScript等等,但是计算机只能识别0、1这样的机器码。那么这些高级语言是如何翻译成机器能识别的0、1等呢?这就用的了编译,首先我们通过下面这幅图看下编译与计算机程序语言的关系,有助于我们直观的了解编译的作用。

注意:每种机器都对应一种汇编语言

程序设计语言的转换方式

翻译:指把某种语言的源程序,在不改变语义的条件下,转换成另一种语言程序即目标语言程序

真正的实现有两种方式,编译及解释

  • 编译:专指由高级语言转换为低级语言,整个程序翻译。常用的例如: c、c++,delphi,Fortran、Pascal、Ada
  • 解释:接受某种高级语言的一个语句输入,进行解释并控制计算机执行,马上得到这个句子的执行结果,然后再接受下一个语句。类似口译,一句一句进行解释。常用的例如:python 解释以源程序作为输入,不产生目标程序,一边解释一边执行。优点:直观易懂,结构简单,易于实现人机对话。缺点:效率低(不产生目标程序,每次都需要重新执行,速度慢)

编译的转换过程

  • 编译->运行
  • 编译->汇编->运行

编译器在语言处理系统中的位置

了解了编译与程序设计语言的关系,那么我们接下来再来看下编译器在语言处理系统中所处位置,如下图

编译系统的结构

那么机器是如何把高级语言翻译为汇编语言程序或机器语言程序的呢?

我们先来看下人工进行英文翻译的例子,这里引用的哈工大编译原理中的图示

图中的中间表示很重要主要起到了一个桥梁的作用,比如图中的中间表示可以使用各种语言表示。

根据上图可以看出要进行语义分析首先需要划分句子成分,那么我们是如何划分句子成分的呢?

  1. 首先通过词法分析分析出句子中各个单词的词性或者词类
  2. 接下来通过语法分析识别出句子中的各类短语从而获得句子的结构
  3. 然后进行语义分析根据句子结构分析出句子中各个短语在句子中充当什么成分,从而确定各个名词性成分同各个核心谓语动词间的关系语意关系
  4. 最后给出中间表示形式 实际上编译器在工作的时候也是经过了以上几个步骤,我们成为阶段(计算机的逻辑组织方式,在实现过程中多个阶段可能会被组合在一起实现),可以分为两大部分:分析源语言、生成目标代码,在编译器中他们分别对应编译器的前端和后端两个部分。编译器的结构如下图 了解了编译器的结构,让我们从编译器的前端开始讲起,看看词法分析、语法分析、语义分析等各个阶段都做了什么。

词法分析(扫描)

编译的第一个阶段,从左到右逐行扫描源程序的字符,识别出各个单词(是高级语言中有是在意义的最小语法单元,由字符构成),确定单词的类型。将识别的单词转换成统一的机内表示即词法单元 简称Token

token:<种别码,属性值>token: <种别码,属性值>

单词类型 种别 种别码
1 关键字 program、if、else、then… 一词一码
2 标识符 变量名、数组名、记录名、过程名… 多词一码
3 常量 整型、浮点型、字符型、布尔型… 一型一码
4 运算符 算术(+ - * / ++ –)关系(> < == != >= <=) 逻辑(& | ~) 一词一码或一型一码
5 界限符 ; ( ) = { } 一词一码

名字解释

  • 一词一码:例如,关键字是唯一的且事先可以确定,为每个关键字分配一个种别码

  • 多词一码:例如,所有的标示符统一作为一类单词分配同一个种别码,为了区分不同的标示符,用token的第二个分量“属性值”存放不同标示符具体的字面值

  • 一型一码:不同类型的常量他们的构成方式是不同的,例如,我们为每种类型的常量分配一个种别码,为了区分同一类型下的不同常量,也用token的第二个分量“属性值”存放每个常量具体的值 下面图中是一个词法分析后得到的token序列的例子

    描述词法规则的有效工具是正规式有限自动机正规式:用来确定单词是否和程序语言规范。有限自动机:通过有限自动机进行单词和正规式比较

语法分析(parsing)

语法分析的定义

语法分析器从词法分析器输出的token序列中识别出各类短语,并构造语法分析树(parse tree),语法分析树描述了句子的语法结构

语法分析的规则

语法规则又称文法,规定了单词如何构成短语、句子、过程和程序。

语法规则的标示如下,含义是A定义为B或者C

BNF:A::=B∣CBNF:A::=B|C

<句子>::=<主><谓><宾><句子>::=<主><谓><宾>

<主>::=<定><名><主>::=<定><名>

来看下赋值语句的语法规则:

  • A::=V=E
  • E::=T|E+T
  • T::=F|T*F
  • F::=V|(E)|C
  • V::=标示符
  • C::=常数 即由标示符或者常数的表达式进行加减乘除运算

语法分析的方法

推导(derive)和归约(reduce)

  • 推导:最左推导、最右推导
  • 归约:最右归约、最左归约,推导的逆过程就是归约

最右推导、最左归约:

最左推导、最右归约:

语法树

计算机通过语法树来进行分析,即语法分析过程也可以用一颗倒着的树来标示,这颗树叫语法树。正确的语法树叶子节点数必须是表达式的符号,例如

赋值语句的分析树:

变量声明语句的分析树:

首先看下变量声明语句的文法(文法是由一系列规则构成的):

<D> -> <T> <IDS>;
<T> -> int | real | char | bool
<IDS> -> id | <IDS>, id

语义分析

语义的任务主要有两个

一. 收集标识符的属性信息

二. 语义检查

  1. 变量或过程未经声明就使用
  2. 变量或过程名重复声明
  3. 运算分量类型不匹配
  4. 操作符与操作数之间的类型不匹配
    • 数组下标不是整数
    • 非数组变量使用数组访问操作符
    • 非过程名使用过程调用操作符
    • 过程调用的**参数类型或数目不匹配 **
    • 函数返回类型有误

中间代码生成

通常和语义分析一起实现。对语法分析识别出的各类语法范畴,分析他的含义,进行初步翻译,产生介于源代码和目标代码质检的一种代码

常用的中间代码表示形式

  • 三地址码 (Three-address Code):三地址码由类似于汇编语言的指令序列组成,每个指令最多有三个操作数(operand)
  • 语法结构树/语法树 (Syntax Trees)
  • 逆波兰式

三地址指令的表示:

  • 四元式 (Quadruples),(op, y, z, x)
  • 三元式 (Triples)
  • 间接三元式(Indirect triples)

下面图中展示了一个中间代码生成的例子

代码优化

对前面生成的中间代码进行加工变换,以便在最后极端产生更为高效的目标代码 ,需要遵循等价变换的原则,优化的方面包括:公共子表达式的提取、合并已知量、删除无用语句、循环优化。

目标代码生成

把经过优化的中间代码转化成特定机器上的低级语言

目标代码的形式:

    • 绝对指令代码:可立即执行的目标代码
  • 汇编指令代码:汇编语言程序,需要经过汇编陈旭汇编后才能运行
  • 可重定位指令代码:先将各目标模块连接起来,确定变量、常数在主存中的位置,装入主存后才能成为可以运行的绝对指令代码

其他

出错处理

如果源程序有错误,编译程序应设法发现错误并报告给用户。由专门的出错处理程序来完成。 错误类型:

  • 语法错误:在词法分析和语法分析阶段检测出来
  • 语义错误:一般在语义分析阶段检测
  • 逻辑错误:不可检测,比如死循环,一般不处理因为没办法在编译阶段检测出来

指对源程序或源程序的中间结果从头到尾扫描一次,并做有关的加工处理,生成新的中间结果或目标代码。遍与阶段的含义毫无关系

多遍扫描: 优点:节省内存空间,提高目标代码的质量,使编译的逻辑结构清晰。缺点:编译时间长。在内存许可的情况下还是遍数尽可能少较好

编译程序生成

  1. 直接用机器语言编写编译程序
  2. 用汇编语言编写编译程序,编译程序核心部分常用汇编语言编写
  3. 用高级语言编写编译程序,这也是普遍采用的方法
  4. 自编译
  5. 编译工具 LEX(语法分析)与YACC(用于自动生成LALR分析表)
  6. 移植(同种语言的编译程序在不同类型的机器之 间移植) 在某机器上为某种语言构造编译程序要掌握以下三方面:
  • 源语言
  • 目标语言
  • 编译方法
❌