点 Redis 8 来了——而且它是开源的

了解更多

精通 RediSearch / 第二部分

抽象化 RediSearch

上一篇文章中,我们开始研究 RediSearch,这个构建为模块的 Redis 搜索引擎。我们探讨了键的奇特性质,并索引了一个文档。在本节中,我们将为在 Node.js 中更高效、更有用地使用 RediSearch 奠定必要的基础。

在 Javascript 中拥抱 RediSearch

现在,我们当然可以直接使用 RediSearch 命令或通过绑定来引入所有这些数据,但对于大量数据,使用直接语法会变得难以管理。让我们花点时间开发一个小的 Node.js 模块,让我们的生活更轻松。

我是所谓的“流畅”(fluent)Javascript 语法的忠实粉丝,在这种语法中,你将方法链式调用,以便在操作单个对象时,函数之间用点分隔。如果你使用过 jQuery,那么你就见过这种风格。

$('.some-class')
    .css('color','red')
    .addClass('another-class')
    .on('click',function() { ... });

这种方法会带来一些挑战。首先,我们需要确保能够与“普通”Redis 命令互操作,并且仍然能够使用管道/批量处理(我们将在以后的文章中讨论 MULTI 的使用)。 此外,RediSearch 命令具有高度可变参数的语法(例如,命令可以有少量或大量的参数)。将其直接翻译成 Javascript 不会比简单的绑定带来多少好处。然而,我们可以利用少量参数,然后以函数级 options 对象的形式提供可选参数。我打算设计的样子有点像这样

const myRediSearch = rediSearch(redisClient,'index-key');
 myRediSearch.createIndex([ ...fields... ],cbFn);
 myRediSearch
    .add(itemUniqueId,itemDataAsObject,cbFn)
    .add(anotherItemUniqueId,anotherItemDataAsObject,addOptions, cbFn);

总的来说,这是一种更地道的 Javascript 做法,这在帮助团队快速上手,或者仅仅是为了改善开发体验时非常重要。

这个模块的另一个目标是让结果更易于使用。在 Redis 中,结果以所谓的“嵌套多批量”(nested multi bulk)回复形式返回。不幸的是,这在 RediSearch 中可能变得相当复杂。让我们看看从 redis-cli 返回的一些结果

1) (integer) 564
2) "52fe47729251416c75099985"
3)   1) "movie_id"
     2) "18292"
     3) "title"
     4) "George Washington"
     5) "department"
     6) "Editing"
     7) "job"
     8) "Editor"
     9) "name"
    10) "Zene Baker"
    11) "crew"
    12) "1"
 4) "52fe48cbc3a36847f8179cc7"
 5)  1) "movie_id"
     2) "55420"
     3) "title"
     4) "Another Earth"
     5) "character"
     6) "Kim Williams"
     7) "cast_id"
     8) "149550"
     9) "name"
    10) "Jordan Baker"
    11) "cast"
    12) "1"

因此,当使用 node_redis 时,你会得到两层嵌套数组,但位置是关联的(除了第一个是结果数量)。如果不编写抽象层,使用起来会很麻烦。我们可以将结果抽象成更有意义的嵌套对象,并用一个数组来表示实际结果。同样的查询会返回这种类型的结果

{
  "results": [
    {
      "docId": "52fe47729251416c75099985",
      "doc": {
        "movie_id": "18292",
        "title": "George Washington",
        "department": "Editing",
        "job": "Editor",
        "name": "Zene Baker",
        "crew": "1"
      }
    },
    {
      "docId": "52fe48cbc3a36847f8179cc7",
      "doc": {
        "movie_id": "55420",
        "title": "Another Earth",
        "character": "Kim Williams",
        "cast_id": "149550",
        "name": "Jordan Baker",
        "cast": "1"
      }
    }
  ],
  "totalResults": 564,
  "offset": 0,
  "requestedResultSize": 2,
  "resultSize": 2
}

那么,让我们开始编写一个客户端库来抽象 RediSearch。

RediSearchClient 抽象组件

让我们首先看看让你能够在更高层级访问 RediSearch 的整个组件“栈”。

[Your Application]
  ├── RediSearchClient - Abstraction
  │   ├── node_redis-redisearch - Bindings to Redis module commands 
  └───┴── node_redis - Redis library for Node.js
          └── Redis - Data Store
               └── RediSearch - Redis Module

由于术语和重复,这有点令人困惑,但每一层都有自己的工作。

node_redis-redisearch 只向 node_redis 提供命令,不进行任何解析或抽象。node_redis 只是将 Redis 的世界开放给 Javascript。明白了吗?很好。

检测 RediSearch 绑定

由于 RediSearch 不是 Redis 的默认部分,我们需要检查它是否已安装。我们将假设 RediSearch 已安装在底层的 Redis 服务器上。如果未安装,则只会收到类似于此的 Redis 错误

ERR unknown command 'ft.search'

没有绑定是一个更微妙的错误(抱怨未定义函数),因此我们将构建一个简单的检查,检查 Redis 客户端实例上是否存在 ft_create 命令。

创建客户端

为了能够以一种语法上不难看且低效的方式管理多个不同的索引和潜在的不同客户端,我们将使用工厂模式来传递客户端和索引键。你不需要再次传递这些。最后两个参数是可选的:一个 options 对象和/或一个回调函数。

它看起来像这样

...
rediSearchBindings(redis);
let mySearch = rediSearch(client,'my-index');
//with optional options object
let mySearch = rediSearch(client,'my-index', { ... });
//with optional options object and callback.
let mySearch = rediSearch(client,'my-index', { ... }, function() { ... });
...

这里的回调函数实际上在其参数中不提供错误;它只是在 node_redis 客户端准备好时触发。它是完全可选的,主要用于基准测试,这样你就不会在连接完全建立之前开始倒计时。

此函数的另一个有用特性是,第一个参数可以选择是 node_redis 模块。在这种情况下,我们也会自动添加 RediSearch 绑定。你可以指定此库来管理客户端的创建,并在 clientOptions 位置的 options 对象中指定其他连接首选项。许多脚本都有专门的连接管理例程,因此传递客户端或 node_redis 模块是完全可选的。

大多数函数将使用类似的签名,最后两个参数是可选的:一个 options 对象和一个回调函数。保持一致性很重要。

创建索引

在 RediSearch 中创建索引是一次性的事情。你在索引数据之前设置好你的 schema,然后就不能在不重新索引数据的情况下更改 schema。

如前所述,RediSearch 中有三种基本类型的索引

  • 数值
  • 文本
  • 地理

(注意:还有第四种索引类型,标签索引,但我们将在以后的文章中介绍)

每个字段都可以有很多选项——这可能很难管理!因此,让我们通过返回一个具有三个函数:numerictextgeofieldDefinition 对象来抽象它。看起来很熟悉,对吧?

所有这三种方法都有两个必需选项,文本字段有一个可选的 options 对象。它们的顺序如下

  • 字段名 — 字符串
  • 可排序 — 布尔值
  • 选项 — 对象(可选,仅文本字段)具有两个可能的属性: noStem(不要对单词进行词干化)和 weight(排序权重)

这些方法返回字符串数组,可用于构建 RediSearch 索引。让我们看几个例子

mySearch.fieldDefinition.text('companyName',true,{ noStem : true }); // -> [ 'companyName', 'TEXT', 'NOSTEM', 'SORTABLE' ]
mySearch.fieldDefinition.numeric('revenue',false); // -> [ 'revenue', 'NUMERIC' ]
mySearch.fieldDefinition.geo('location',true); // -> [ 'location', 'GEO', 'SORTABLE' ]

那么,我们如何使用这些小函数呢?当然,我们用它们来指定 schema。

mySearch.createIndex([
    mySearch.fieldDefinition.text('companyName',true,{ noStem : true }),
    mySearch.fieldDefinition.numeric('revenue',false),
    mySearch.fieldDefinition.geo('location',true)],
    function(err) {
       /* ... do stuff after the creation of the index ... */
    }
 );

这清晰地表达了 schema 中的字段。这里要注意一点:虽然我们使用数组来包含字段,但 RediSearch 没有字段顺序的概念,因此你在数组中指定字段的顺序并不重要。

向索引添加项

向 RediSearch 索引添加项非常简单。要添加一个项,我们需要提供两个必需参数,并考虑两个可选参数。必需参数(按顺序)是

  • 唯一 ID
  • 数据作为一个对象

两个可选参数遵循我们通用的签名:options 和回调函数。根据常见的 Node.js 模式,回调函数的第一个参数是一个错误对象(如果没有错误则未设置),回调函数的第二个参数是实际数据。

myRediSearch
    .add('kyle',{
       dbofchoice       : 'redis',
       languageofchoice : 'javascript'
    },
    {
      score             : 5
    }, 
    function(err) {
       if (err) { throw err; }
       console.log('added!');
    }
 );

批量处理(又称管道)

批量处理(在非 Node.js 的 Redis 世界中称为“管道”)是 Redis 中的一个有用结构,它允许一次发送多个命令,而无需等待每个命令的回复。

批量函数与你在 node_redis 中找到的任何批量处理非常相似——你可以在末尾使用 exec() 将它们链接起来。但这确实会引起冲突。由于“普通”node_redis 允许你将命令批量处理,你需要区分 RediSearch 和非 RediSearch 命令。首先,你需要使用以下两种方法之一开始 RediSearch 批量处理

开始新的批量处理

let searchBatch = mySearch.batch() // a new, RediSearch enhanced batch

或者,使用现有批量处理

let myBatch = client.batch();
let searchBatch = mySearch.batch(myBatch) // a batch command, perhaps already in progress

创建批量处理后,你可以向其中添加普通的 node_redis 命令,或者使用 RediSearch 命令。

searchBatch
   .rediSearch.add(...)
   .hgetall(...)
   .rediSearch.add(...)

注意卡在此链中间的 HGETALL;这是为了说明你可以将抽象的 RediSearch 命令与“普通”Redis 命令混合使用。很酷,对吧?

如前所述,RediSearch(以及许多 Redis 命令)的输出可能不是你会直接使用的形式。例如,FT.GET 和 FT.SEARCH 会产生交错的字段/值结果,这些结果被表示为一个数组。在 Javascript 中处理这类数据的惯用方法是通过普通对象。所以我们需要对交错数据进行一些简单的解析。有很多方法可以实现这一点,但最简单的方法是使用 lodash 链,首先将数组拆分成长度为 2 的单独数组,然后使用 fromPairs 函数将长度为 2 的数组转换为单个对象中的字段/值。我们将大量使用这种方法,因此会将其包含在非公共函数 deinterleave 中,以减少重复。

const deinterleave = function(doc) { // `doc` is an array like this `['fname','kyle','lname','davis']`
  return  _(doc)                     // Start the lodash chain with `doc`
    .chunk(2)                        // `chunk` to convert `doc` to `[['fname','kyle'],['lname','davis']]`
    .fromPairs()                     // `fromPairs` converts paired arrays into objects `{ fname : 'kyle', lname : 'davis }`
    .value();                        // Stop the chain and return it back
}

如果我们不需要处理管道,添加这些解析函数会是一个相对简单的猴子补丁(monkey patching)客户端过程。但在 node_redis 的批量处理中,结果既在函数级回调中提供,也在批量处理的末尾提供,许多脚本会省略函数级回调,只在末尾处理所有结果。鉴于此,我们需要确保命令只在需要时解析这些值——但始终在末尾。

此外,这在编写我们的抽象层时会带来一些麻烦。普通客户端对象和管道对象都需要注入 RediSearch 特定的命令。为了避免编写两个重复的函数,我们需要有一个可以动态注入的函数。为了实现这一点,采用了工厂模式:外部函数被传入一个客户端或管道对象(我们称之为 cObj),然后返回一个带有普通参数的函数。 cObj 可以表示管道或只是一个 node_redis 客户端。

幸运的是,node_redis 在处理管道化和非管道化命令方面是一致的,所以唯一改变的是被链式调用的对象。只有两个例外

  • 在需要特殊结果解析的命令中,我们为管道对象添加一个 parser 属性,它本身是一个普通对象。 这包含在末尾完成的适当解析函数。我们需要在这里使用普通对象而不是数组,以避免在不需要解析时出现稀疏性。
  • 为了实现链式调用,你需要能够返回正确的值:要么是非管道调用的一般 rediSearch 对象,要么是管道对象本身。

这两个例外只需在管道化时应用,因此我们需要能够检测管道化。为此,我们必须查看构造函数的名称。它已被抽象到函数 chainer 中。

搜索

在 RediSearch 模块中,搜索是通过 FT.SEARCH 命令执行的,该命令有很多选项。我们将把这个抽象到我们的 search 方法中。目前,我们只提供最基本的搜索能力——我们将传入一个搜索字符串(你可以在其中使用 RediSearch 丰富的查询语言),然后是一个可选的 Options 参数,最后是一个回调函数。技术上,回调是可选的,但不包含它会很愚蠢。

在我们最初的实现中,我们只会提供几个选项

  • offset – 结果集的起始位置
  • numberOfResults – 要返回的结果数量

这些选项直接映射到 RediSearch 的 LIMIT 参数(与各种 SQL 实现中找到的 LIMIT 参数非常相似)。

搜索还实现了一个结果解析器,以使结果更易于使用。输出对象最终看起来像这样

{
  "results": [
    {
      "docId": "19995",
      "doc": {
        "budget": "237000000",
        "homepage": "http://www.avatarmovie.com/",
        "original_language": "en",
        "original_title": "Avatar",
        "overview": "In the 22nd century, a paraplegic Marine is dispatched to the moon Pandora on a unique mission, but becomes torn between following orders and protecting an alien civilization.",
        "popularity": "150.437577",
        "release_date": "1260403200000",
        "revenue": "2787965087",
        "runtime": "162",
        "status": "Released",
        "tagline": "Enter the World of Pandora.",
        "title": "Avatar",
        "vote_average": "7.2",
        "vote_count": "11800"
      }
    }
  ],
  "totalResults": 1,
  "offset": 0,
  "requestedResultSize": 10,
  "resultSize": 1
}

属性 results 是一个有序的结果数组(最相关的结果排在前面)。请注意,每个结果都包含文档的 ID (docId) 和文档中的字段 (doc)。 totalResults 是索引中匹配查询的项的数量(不受任何限制的影响)。 requestedResultSize 是要返回的最大结果数量。 resultSize 是返回的结果数量。

获取文档

在上一节中,你可能已经注意到 docId 属性。RediSearch 根据你在索引时需要指定的唯一 ID 存储每个文档。可以通过搜索或使用 RediSearch 命令 FT.GET 直接获取 docId 来检索文档。在我们的抽象中,我们将此方法称为 getDoc (get 在 Javascript 中具有特定含义,因此应避免用作方法名)。getDoc,就像我们模块中的大多数其他命令一样,具有熟悉的参数签名

  • docId 是第一个参数,也是唯一必需的参数。你传入之前索引项的 ID。
  • options 是第二个参数,是可选的。我们目前还没有实际使用它,但会将其保留在此处以备将来扩展。
  • cb 是第三个参数,技术上是可选的——这是你提供回调函数以获取结果的地方。

search 方法类似, getDoc 会进行一些解析,将文档从交错数组转换为普通 Javascript 对象。

删除索引

在我们拥有最小功能集之前,还需要涵盖另一件重要事情——dropIndex,它只是命令 FT.DROP 的一个简单包装,它有点不同,因为它只需要一个在索引被删除时调用的回调函数。

dropIndexcreateIndex 都不允许链式调用,因为这些命令的性质阻止了它们具有进一步的链式函数。

结论

在本文中,我们讨论了在 Node.js 中为 RediSearch 创建一个有限的抽象库及其语法。回顾我们上一篇文章,让我们看看同一个小例子,了解完整的索引生命周期。

/* jshint node: true, esversion: 6 */

const 
  argv        = require('yargs')                                              // `yargs` is a command line argument parser
                .demand('connection')                                         // pass in the node_redis connection object location with '--connection'
                .argv,                                                        // return it back as a plain object
  connection  = require(argv.connection),                                     // load and parse the JSON file at `argv.connection`
  redis       = require('redis'),                                             // node_redis module
  rediSearch  = require('./index.js'),                                        // rediSearch Abstraction library
  data        = rediSearch(redis,'the-bard',{ clientOptions : connection });  // create an instance of the abstraction module using the index 'the-bard'
                                                                              // since we passed in redis module instead of a client instance, it will create a client instance
                                                                              // using the options specified in the 3rd argument.
data.createIndex([                                                            // create the index using the following fields
    data.fieldDefinition.text('line', true),                                  // field named 'line' that holds text values and will be sortable later on
    data.fieldDefinition.text('play', true, { noStem : true }),               // 'play' field is a text values that won’t be stemmed
    data.fieldDefinition.numeric('speech',true),                              // 'speech' is a numeric field that is sortable
    data.fieldDefinition.text('speaker', false, { noStem : true }),           // 'speaker' is a text field that is not stemmed and not sortable
    data.fieldDefinition.text('entry', false),                                // 'entry' is a text field that stemmed and not sortable
    data.fieldDefinition.geo('location')                                      // 'location' is a geospatial index
  ],
  function(err) {                                                             // Error first callback after the index is created
    if (err) { throw err; }                                                   // Handle the errors
    data.batch()                                                              // Start a 'batch' pipeline
      .rediSearch.add(57956, {                                                // index the object at the ID 57956
        entry     : 'Out, damned spot! out, I say!--One: two: why,',
        line      : '5.1.31',
        play      : 'macbeth',
        speech    : '15',
        speaker   : 'LADY MACBETH',
        location  : '-3.9264,57.5243'
      })
      .rediSearch.getDoc(57956)                                               // Get the document index at 57956
      .rediSearch.search('spot')                                              // Search all fields for the term 'spot'
      .rediSearch.exec(function(err,results) {                                // execute the pipeline
        if (err) { throw err; }                                               // Handle the errors
        console.log(JSON.stringify(results[1],null,2));                       // show the results from the second pipeline item (`getDoc`)
        console.log(JSON.stringify(results[2],null,2));                       // show the results from the third pipeline item (`search`)
        data.dropIndex(function(err) {                                        // drop the index and send any errors to `err`
          if (err) { throw err; }                                             // handle the errors
          data.client.quit();                                                 // `data.client` is direct access to the client created in the `rediSearch` function
        });
      });
  }
);

如你所见,这个例子涵盖了所有基础,尽管它在实际场景中可能不太有用。在下一篇文章中,我们将深入研究 TMDB 数据集,开始使用真实数据,并进一步扩展我们的 RediSearch 客户端库。

与此同时,我建议你看看 GitHub 仓库,了解它的结构。