用 Lucene 构建文档数据库

1

说到“档案”系统,选文档数据库再合适不过了。谈到文档数据库一般想到的是 MongoDB、CouchDB 之类的,可这里要说的不是这些,而是另一个 NoSQL “文档数据库” —— Lucene。之所以要打引号,是因为暂时还没听到别人这样说。

  1. 需求

最近公司要弄一个内部搜索,对比各种方案后,决定用 Lucene。当做出第一个原型后,考虑到公司另外几个项目将来也许用的上,而再写一遍代码可不是我的风格;又试用了开箱即用的 Solr,觉得那也不是我的菜。因为我项目内已经有类似 Solr 的 Schame 的配置在用了,我打算复用这个模块;接口规范我也打算复用我现有的规范。

基础的增删改查比较简单,很快就做出了原型。此时我想到公司另一个大模块:档案(或叫简历)。这部分我已计划与另一个项目的类似模块做整合,考虑用 MongoDB 重构。既然 Lucene 可以存取较复杂的数据结构,何不借此机会研究一下用 Lucene 作为档案系统的底层支撑呢。

那这里说的档案是什么样子呢?举一个简单例子,一份个人简历:

姓名:XXX
性别:男
照片:xxx/xxx.jpg
兴趣爱好
    兴趣:跑步、游泳、XX自定义
    简介:是浪费时间的服务吉林省地方就,受到法律书籍地方
教育经历
    经历1
        日期区间: 2014/1/1~2015/1/1
        学校: Jiali.Dun
        专业: 挖掘机
        学位:没士
    经历2……

大概的文档结构就是就是这样,字段、层级是不确定的,需要保持此结构,能存、能取,大部分字段可查询、排序。

  1. 结构化数据

总结以上档案结构,组成上可分为:

a. 基础板块(名字,性别,照片)
b. 其他板块(同上,但被区分开)
c. 列表板块(教育经历)

上面特意将基础信息称为基础“板块”,也就是说,一般情况下一份档案是由多个板块组成的。也许您的档案还会更复杂,比如兴趣爱好下再分为运动、娱乐,这种划分方式从存储上来说与两层设计没什么区别,多了一个父级板块的指向而已,但这增加了展现的复杂度。现在大家都在谈“扁平化”,我所理解的扁平不仅仅是把图标拍扁了,更是信息获取的渠道扁平了,能一下给我看的,不要让我点一层菜单进去又点一层;能用标签、搜索筛选的,不要让我点目录树查找。

一个板块就是一组键值对,此处我们将这一组规则称为表单。那么,列表板块就是由多个可重复表单组成的板块。

字段上可以有:

a. 文本
b. 数字
c. 文件
d. 日期、时间(区间)
e. 单选、多选
f. 多条数据(文本、数字、日期等)

从 a~e 都是很常见的类型,文件可以转储到文件服务器上,这里只存 URL;日期、时间可以转换成时间戳。而 f 是指这个字段的值可以输入多个,通常用来记录一些需要多条记录东西,存储上与多选一样。

Lucene 原本就是一个字段可以存多个值,这太妙了。

  1. 表单及验证

前面谈到我自己有一个数据校验模块,对数据结构的描述如下:

表单1
    字段1:类型,是否必填,是否重复,其他校验参数
    字段2……
枚举1
    取值1:名称
    取值2……

举一个栗子:

简历表单
    姓名:文本,必填,不重复,最大长度100
    性别:选项,必填,不重复,性别枚举
    照片:图片,选填,可重复,类型(jpg,png)
    兴趣爱好:表单,选填,不重复,兴趣爱好表单
    教育经历:表单,选填,可重复,教育经历表单
性别枚举
    0:女
    1:男
    2:中性
兴趣爱好表单
    兴趣:文本,必填,可重复,最大长度50
    简介:文本,选填,不重复,多行文本
教育经历表单
    日期区间:日期区间,必填,不重复
    学校:文本,必填,不重复
    专业:文本,必填,不重复

此表单描述上也是为了方便编辑和解析,设计成了 表单->字段 两层结构,未使用代码嵌套而是使用链接嵌套的方式。校验器在校验的时候,发现字段类型为表单,取出对应表单递归下去就行了。那这么多表单都堆积在一起,怎么解决命名空间的问题呢?我设计为每个模块(同一应用主题)一个这样的配置,校验器在处理表单时如果没给出模块名(配置名),则取当前模块的指定名字的表单,有则取指定模块下的表单。

数据在校验成功后,会将数据清理为类似以下 JSON 的结构:

{
    "name": "XXX",
    "gender": 1,
    "photo": "upload/photo/xxxxxx.jpg",
    "hobby": {
        "interest": [
            "ljsdfsdfsd",
            "sldfj2ef"
        ],
        "comment": "sjldfjsldfsdlfjsldfsdfsdfsdfsdfsdf"
    },
    "education": [
        {
            "date": {
                "begin": Date(2014/1/1), 
                "end": Date(2015/1/1)
            },
            "university": "lwnfdsfwe",
            "professional": "slwef"
        }
    ]
}

输入的数据结构与此一致,对于使用 application/x-www-form-urlencoded 格式提交的数据,可以根据"."、"["和"]"解析成上面的数据结构,就像 PHP 的请求参数解析方式。

  1. 存储方式

OK,上面已经扯了很多了,这开始进入正题了。数据都清理好了,可是这样一个结构的数据怎么存到 Lucene 检索库里呢?Lucene 可不是 MongoDB 能存储 BSON 那样的复杂结构呀。难道像设计关系数据库的 ERM 一样,建几个索引目录当表使,然后用外键做关联,然后自己实现关联查询。或者,把整个数据序列化扔到一个字段里,自己写 Filter 、Query 来实现对复杂结构的查询?

我可不想这么费劲。

为解决这些问题,先梳理一下,Lucene 的基本字段类型有:

StringField: 基础文本字段,可指定是否索引
StoredField: 仅存储不索引(也就是不能搜索、查询只能跟着文档取出来看)
TextField  : 会在这上面应用分词器,用来做全文检索的

还有其他的 IntField,FloatField…… 可以存数字的(关键的是可以按数字值大小来排序),ByteField 存二进制数据等。还有,Lucene 支持一个字段存储多个值,当只需要一个值得时候拿一个就是了,需要多个就取多个值。

现在,我可以假定默认的情况下基础数据要能独立索引以方便查询的,他们用单独的字段存放。其他数据可以在字段名上用一个分隔符连接板块名和字段名。如果这些字段的字段名是不重复的(比如随机生成的),直接用字段名即可。这样做的好处是展现和存储分离,当一个字段的数据从A板块迁移到B板块时,不用去修改过去已经存储的数据,因为这个迁移仅仅是视觉上的迁移而已。目前我用 RDMS 实现的一套档案系统就是这么干的。

比较麻烦的是列表板块。

如果不需要对这部分的数据做查询,那就直接序列化存起来。

如果需要对里面独立的字段做搜索和排序,那就再序列化的基础上,多加一个字段独立存储要索引的字段。比如添加字段 教育经历-学校,就可以对曾就读过某个学校的档案做搜索了。

如果还想完成需求:查询某个日期范围内就读某某学校的档案,还是另行存储吧。查询时可以用外键关联,查出一个再 IN 去查另一个(注:Lucene没有IN的操作,需要联合使用MUST和SHOULD)。可以另外作为一个档案存在当前索引目录内,更好的方式是独立开个附属目录存储,这样做可以确保主数据更干净。

完整的存储结构为:

主要数据存储
    记录ID
    字段1:值1,值2……
    字段2……
列表数据存储
    主记录ID
    行记录ID
    序号
    字段1:值1,值2……
    字段2……
  1. 查询规则

我有一套已经应用在 RDBMS 模型上的查询规则,需要做的是将规则解析成 Lucene 的 Query。查询规则如下:

{
    "id": "xxx",       // 等于
    "star": [1, 2],    // IN, Lucene 的 Must + Should
    "f1": {
        "-gt": 18,     // 大于
        "-le": 35      // 小于或等于
    },
    "f2": {
        "-ne": "zzz"   // 不等于
    },
    "f3": {
        "-or": "zzz"   // OR, 对应 Lucene 的 Should
    },
    "f4": {
        "-ni": [3, 4]  // NOT IN, 对应 Lucene 的 Must_Not
    },
    "f5": {
        "-ai": [1, 2]  // ALL IN, 对应 Lucene 的 Must
    },
    "f6": {
        "-oi": [5, 6]  // OR IN, 对应 Lucene 的 Should
    }
}

用 application/x-form-urlencode 可表示为:

id=xxx&star[]=1&star[]=2&f1[-gt]=18&f1[-le]=35&f6[-oi][]=5&f6[-oi][]=6

系统会以类似 PHP 的请求参数解析方式解析类似上面 JSON 的数据结构。为了方便看和写,也可支持将[]换成.,如:f6.-oi.=6 与 f6[-oi][]=6 是相同的。

熟悉 MongoDB 的人看这个会很眼熟,没错,这就是从 MongoDB 借鉴过来,并用在我的关系数据库查询上。这里的 -or 和 -oi 是 Lucene 特有的,可以影响到排序,这对搜索那些可有可无的字段很有帮助。-ai 类似于 Mongo 的 containsAll。

注:[2015/12/01] 以上"-"已换成"!"符号。

  1. 接口规范

接口的主要目是为了传递数据,数据结构已经在上面给出。接口以 REST 风格给出,请求数据支持 application/x-form-urlencode,json,返回数据为 json。

如果你熟悉 Protobuf,也许意识到了上面的表单跟 proto 的描述很像,没错,这也是借鉴的。只是 Protobuf 没法加更多的描述,所以我没去用。这里的表单配置可以转换为 proto 描述。为便于不同系统、不同终端的数据交换,protobuf 也将(应当)在接口支持之内。

  1. 后注

如果不去考虑 Lucene 写锁的“问题”,我真心觉得这是个相当不错的嵌入式文档数据库;虽然用 Lucene 存储复杂结构数据的可行性还有待商榷,但折腾一下对了解 Lucene 还是有价值的。不必强求必须用什么语言、框架或工具才能完成某件事,其实能办成一件事的途径有很多,多尝试一下思路就更清晰一点。

我在 github 上有个项目,不过还没有搭建演示,日后有了再将链接添加到这里。

部分代码:

Lucene CRUD 封装:https://github.com/ihongs/Hon...
表单校验程序:https://github.com/ihongs/Hon...
表单配置规范:https://github.com/ihongs/Hon...

参考资料:

MongoDB 查询:http://docs.mongodb.org/manua...
Lucene 查询:https://lucene.apache.org/cor...
REST 简介:http://baike.baidu.com/view/5...
PHP 请求参数解析(见第一条 Note):http://php.net/manual/zh/rese...

你可能感兴趣的

载入中...