点

高速的未来已在您所在城市开展活动。

搜索

Redis

抽象 RediSearch

我们上期文章中,我们开始研究 RediSearch,Redis 中构建为一个模块的搜索引擎。我们探索了解析器的特殊本质,并编制了单个文档的索引。在本部分中,我们将奠定基础,以便让与 RediSearch 协同工作变得更具生产力和实用性。

现在,我们借助 绑定,当然可以直接使用 RediSearch 命令或传入全部数据,但是使用直接语法来处理大量数据时,管理起来会很困难。让我们花点时间开发一个小型的 Node.js 模块,它会让我们的生活更轻松。

我是所谓的“流畅”JavaScript 语法的忠实粉丝,其中你可以将方法链接在一起,以便在单个对象上操作时,各函数之间通过点分隔开。如果你用过 jQuery,那你肯定见过这种样式。

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

此方法会带来一些挑战。首先,我们需要确保能够与“普通”Redis 命令进行交互,并仍然能够使用管道/批处理(我们会在稍后的安装中解决使用 MULTI 的问题)。此外,RediSearch 命令具有高度可变的参数语法(例如,命令可以具有少量或大量的参数)。直接将此语法翻译成 JavaScript 不会给我们带来高于简单绑定的收益。但是,我们可以利用少量参数,然后使用函数级选项对象的形式提供可选参数。我的设计目标有点像这样

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

总体而言,这是在 JavaScript 中处理事情的一种更贴近惯用语的方式,当尝试让团队跟进进度,甚至只是改善开发体验时,这一点非常重要。

此模块的另一个目标是让结果更易于使用。在 Redis 中,结果以所谓的“嵌套多块”响应形式返回。不幸的是,这让 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 只是向 JavaScript 打开 Redis 的世界。明白了?很好。

检测 RediSearch 绑定

由于 RediSearch 不是 Redis 的默认部分,我们需要检查是否已安装。我们假设 RediSearch 已安装在底层 Redis 服务器上。如果没有安装,你只会收到类似以下内容的 Redis 错误

ERR unknown command 'ft.search'

缺少绑定是一个较为隐晦的错误(抱怨 undefined 函数),因此我们将针对 Redis 客户端实例中的ft_create命令建立一个简单的检查。

创建客户端

为了能够以一种在语法上既不丑陋又高效的方式管理多个不同的索引和许多不同的客户端,我们将使用一种工厂模式来传递客户端和索引键。您将不再需要再次传递这些数据。最后两个参数是可选的:一个选项对象和/或一个回调函数。

它看起来像这样

...
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选项对象中指定其他连接首选项。许多脚本具有专门的连接管理例程,因此完全可以选择传递客户端或 node_redis 模块。

我们将对大多数函数使用类似的签名 ,最终两个参数是可选的:一个选项对象和一个回调函数。一致性很好。

创建索引

在 RediSearch 中创建索引是一个一次性的操作。您可以在索引数据之前设置架构,然后在不重新索引数据的情况下无法更改该架构。

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

  • 数字
  • 文本
  • 地理

(注意:还有一种第四种类型的索引,即标签索引,但我们将在后面的部分中介绍它)

每个字段可以包含许多选项——这可能有很多要管理!因此,让我们通过返回一个拥有三个函数的fieldDefinition对象来抽象它:numerictextgeo。似乎很熟悉,不是吗?

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

  • 字段名称——字符串
  • 可排序——布尔值
  • 选项——对象(文本字段独有,可选),具有两个可能的属性: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' ]

那么,我们用这些小功能做什么?当然,我们用它们来指定一个架构。

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 ... */
    }
 );

这可以在架构中的字段上作出明确和表达性的声明。这里有一个说明:虽然我们在数组中使用字段,但 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 环境中使用的一种结构,允许同时发送多种命令而不等待对每条命令的答复。

批处理函数的工作方式与 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
}

如果我们无需处理管道,添加这些解析函数将是一个有点简单的客户端猴子补丁过程。但当 node_redis 中具有批处理时,结果将同时通过函数级回调和批处理末尾提供,其中很多脚本省略了函数级回调,而只是在末尾处理所有结果。有鉴于此,我们需要确保仅在需要时命令才会解析这些值——但在结尾始终解析这些值。

此外,在编写抽象时,这会打开潘多拉魔盒。正常的客户端对象和管道对象都需要注入 RediSearch 特有的命令。为防止编写两个重复的功能,我们需要一个能够动态注入的函数。要实现这一点,便使用工厂模式:外部函数传递给客户端或管道对象(我们称其为 cObj),然后它返回具有正常参数的函数。cObj 可以表示管道或只是 node_redis 客户端。

幸运的是,node_redis 在处理管道命令和非管道命令时保持一致,因此唯一发生更改的是作为链的对象。只有两个例外

  • 在需要特殊结果解析的命令中,我们使用解析器属性(本身是纯对象)增强管道对象。它包含要最终完成的适当解析函数。这里我们需要使用纯对象,而不是数组,以便在不需要解析时避免稀疏。
  • 要启用链,你需要能够返回正确的值:对于非管道调用,为常规的 rediSearch 对象,或管道对象本身。

这两个例外只适用于在管道处理时,因此我们需要能够检测管道处理。要实现这一点,我们必须查看构造函数的名称。它已经被抽象成 chainer 函数。

搜索

在 RediSearch 模块中,搜索通过命令 FT.SEARCH 执行,它有大量的选项。我们将其抽象成我们的 search 方法。此时,我们只提供搜索能力的基本内容——我们将传入搜索字符串(你可以使用 RediSearch 的扩展查询语言),然后传入一个可选项参数,最后传入一个回调。从技术上讲,回调是可选项,但如果不包括它,就太愚蠢了。

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

  • 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
}

属性结果是结果的有序数组(最相关的结果排在前面)。请注意,每个结果既具有文档的 ID(docId),又具有文档中的字段(doc)。 totalResults 是与查询匹配的项目索引数(与任何限制无关)。 requestedResultSize 是要返回的最大结果数。 resultSize 是返回的结果数。

获取文档

在上一部分中,您可能注意到了docId属性。RediSearch 通过唯一的 ID 存储每个文档,您需要在索引时指定该 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 repo,了解它的全部结构。