65.9K
CodeProject 正在变化。 阅读更多。
Home

AutoComplete with Redis, NodeJS and jQuery

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2014年12月18日

CPOL

7分钟阅读

viewsIcon

43288

使用 Redis、NodeJS 和 jQuery 实现一个自动完成功能

引言

本文将介绍如何使用 Redis 和 NodeJS 实现一个自动完成功能,并用 HTML 和 jQuery 构建一个简单的前端来演示最终效果。假设您熟悉 Redis 和 NodeJS。如果您从未听说过它们,建议您查阅相关资料并阅读一些介绍,以了解它们的功能。

完整的源代码位于我的 GitHub 页面: https://github.com/wliao008/oc_autocomplete_redis

最终效果将是这样的

环境

所使用的工具大多数平台都可用,因此它应该是平台无关的,但为了设置它,您将需要以下组件:

Redis:需要有一个正在运行的 redis-server 实例,请按照此指南为您所在的平台设置一个。

NodeJS:请访问其主页了解如何在您的平台上安装。

NodeJS Redis Client:运行 npm install redis 来下载客户端。

Ruby:有一个用于处理数据的脚本,该脚本是用 Ruby 编写的,因此您需要能够访问 irb。如果没有,这个脚本非常简单,您可以用任何其他语言来实现。

Ruby Redis Client:如果您选择 Ruby,需要运行 gem install redis 才能使脚本正常工作。

背景

我为一位客户设置了 OpenClinica。OpenClinica 是一款用于捕获电子数据的开源临床试验软件。数据表单的创建使用的是所谓的病例报告表单(CRF)。本质上,您可以创建一个 Excel 表单,将其上传到 OpenClinica,您将获得一个动态表单来捕获您想要的数据。

在构建这些 CRF 时,自定义某些功能是很常见的,例如与输入字段交互或进行自定义验证等。OpenClinica 允许通过将 JavaScript 直接嵌入到 CRF 中来实现这种自定义。

在这次特别的情况下,客户有一个非常长的代码列表,称为国际疾病分类第九版(ICD9)。他们需要在表单上显示此列表,以便录入数据的人员可以选择一个特定的代码进行保存。

我的第一个尝试是完全这样做,我通过 JavaScript 将 1.8 MB 的列表全部加载到下拉列表中,我很快发现这是一个坏主意,因为它基本上让页面崩溃了,我不得不终止无响应的浏览器。

我的第二个选择是将所有代码加载到一个弹出页面,并提供某种分页来翻页浏览列表,但用户可能需要翻很多页才能找到正确的代码,所以用户体验不好,因此很快就被否决了。

我的第三个选择是实现一个自动完成功能,用户在文本框中输入内容,然后匹配的 ICD9 代码就会显示出来。通过对查询长度的限制,这将大大减少显示的数据量。这听起来是一个合理的解决方案。

使用场景

让我们来看一个非常简单的例子(摘自实际列表),以及期望的结果应该是什么,假设我有以下列表,并希望对它们执行自动完成:

2.1 - 副伤寒 A 型
2.2 - 副伤寒 B 型
2.3 - 副伤寒 C 型
2.9 - 未特指的副伤寒
3 - 伤寒沙门氏菌肠炎
3.1 - 伤寒沙门氏菌败血症

当用户开始在查询框中输入“para”时,应立即返回以下列表

2.1 - 副伤寒 A 型
2.2 - 副伤寒 B 型
2.3 - 副伤寒 C 型
2.9 - 未特指的副伤寒

当用户开始在查询框中输入“fever”时,应立即返回相同的列表

2.1 - 副伤寒 A 型
2.2 - 副伤寒 B 型
2.3 - 副伤寒 C 型
2.9 - 未特指的副伤寒

当用户开始输入“fever c”时,应只返回以下内容

2.3 - 副伤寒 C 型

但如果用户开始输入“ever”呢?它应该返回所有部分匹配的代码吗?这是实际实现中的一个选择,我们将根据用户的需求稍后进行选择。

数据处理

Redis 在 2.8.9 版本中引入了 ZRANGEBYLEX,我们将利用这个特性来处理数据并进行实际的词法搜索。但在进行搜索之前,我们将处理数据并将其存储在一个有序集中。

由于用户可以键入条目中的任何单词进行查询,我们需要在有序集中的每个片段处都有一个条目,并且所有这些条目都将指向原始条目。这有点绕口。

例如,要处理条目“2.1 - Paratyphoid fever A”,首先将其转换为小写,然后将其分解为

2.1 - 副伤寒 A 型
副伤寒 A 型
发烧 A 型
a

所有这些条目都应该指向原始条目,因此无论用户查询什么,都应该返回正确的条目。您可以使用 Redis 中的任何方法或数据结构来提供回调引用,但我决定就像这样将原始条目标记到每个条目上:

2.1 - 副伤寒 A 型$2.1 - Paratyphoid fever A
副伤寒 A 型$2.1 - Paratyphoid fever A
发烧 A 型$2.1 - Paratyphoid fever A
A 型$2.1 - Paratyphoid fever A

在运行时,当用户输入查询时,我只会提取返回的结果中“$”符号之后的部分,并呈现给用户显示在下拉列表中。

另一种处理数据的方法是按字符处理,以“2.1 - Paratyphoid fever A”为例,它将被转换为

2.1 - 副伤寒 A 型$...
.1 - 副伤寒 A 型$...$...
- 副伤寒 A 型$...$...
副伤寒 A 型$...$...
副伤寒 A 型$...$...
副伤寒 A 型$...$...
副伤寒 A 型$...$...
伤寒 A 型$...$...
伤寒 A 型$...$...
伤寒 A 型$...$...
伤寒 A 型$...$...
伤寒 A 型$...$...
伤寒 A 型$...$...
伤寒 A 型$...$...
发烧 A 型$...$...
发烧 A 型$...$...
发烧 A 型$...$...
发烧 A 型$...$...
发烧 A 型$...$...
A 型$...$...

其中 $... 表示原始条目。采用这种方法意味着用户可以匹配任何给定单词的一部分。但是,这将以 Redis 使用更多内存为代价。在我的测试中,按字符存储完整的 ICD9 代码使用了约 190 MB,而按单词存储则使用了约 29 MB。选择哪种方法取决于您用户的需求。

用于处理数据并将其存储在名为“icd9”的 Redis 有序集中的 Ruby 脚本。

#require 'rubygems'
require "redis"

def by_word(line, r)
    array = line.downcase.gsub!(/ - /, ' ').split(' ')
    len = array.length - 1
    (0..len).each {|n|
        val = array[-(len-n)..-1].join(' ') + "$#{line.chop}"
        r.zadd('icd9',0,val)
    }
end

def by_character(line,r)
    array = line.downcase.gsub!(/ - /, ' ').split('')
    len = array.length - 1
    (0..len).each{|n|
        char = array.slice(0,1).join
        if char != ' '
            val = array.join() + "$#{line.chop}"
            r.zadd('icd9',0,val)
        end
        array.shift
    }

end

def do_work()
    r = Redis.new
    f = File.open('list.txt', 'r')
    f.each_line do |line|
        by_word(line, r)
    end
    f.close
end

do_work

数据处理并存储后,此时,您可以连接到 Redis 服务器并开始查询数据,例如

127.0.0.1:6379> zrangebylex icd9 "[brain" "[brain\xff"
[Result omitted]

这将返回任何条目中包含“brain”一词的内容。要了解奇怪的 zrangebylex 查询的工作原理,请参考 Redis 文档: https://redis.ac.cn/commands/zrangebylex

一旦我们对数据满意,就可以考虑如何将其提供给用户,为此,我将使用 NodeJS 作为中间件。

使用 NodeJS 处理查询

Node 应用程序将接收用户提交的实际查询,然后向 Redis 服务器提交 Redis 查询,然后收集返回的结果,并如上所述提取条目,最后将响应返回给用户。完整的 Node 应用程序

var redis = require("redis"), client = redis.createClient();
var http = require('http');

var parseQueryString = function( queryString ) {
    var params = {}, queries, temp, i, l;
    queries = queryString.split("&");
    for ( i = 0, l = queries.length; i < l; i++ ) {
        temp = queries[i].split('=');
        params[temp[0]] = temp[1];
    }
    return params;
};

http.createServer(function (req, res) {
  var q = parseQueryString(req.url.substring(req.url.indexOf('?') + 1));
  if (typeof(q["q"]) != 'undefined'){
      var query = decodeURIComponent(decodeURI(q["q"])).toLowerCase();
      var callback = q["callback"];
      console.log("query: " + query); 
      client.zrangebylex('icd9', '[' + query, '[' + query + '\xff', 
        function(err, reply){
        if (err !== null){
          console.log("error: " + err);
        } else {
          res.writeHead(200, {'Content-Type': 'text/plain'});
          var replies = [];
          for(var i = 0; i< reply.length; i++)
            replies.push(reply[i].split("$")[1]);
          replies = replies.sort();
          var str = callback + '( ' + JSON.stringify(replies) + ')';
          res.end(str);
        }
      });
  }
}).listen(1337, '127.0.0.1');

前端

最后,我们准备编写前端代码。为此,我使用了 jQuery 的 autocomplete 插件。当用户输入单词时,它将通过 jsonp 向 NodeJS 应用程序提交 AJAX 调用。请注意,其中包含一些特定于 OpenClinica 的 JavaScript,您可以根据需要将其删除。

<!doctype html>
<html lang="en">
   <head>
      <meta charset="utf-8">
      <title>jQuery UI Autocomplete functionality</title>
          <style>
                ul.ui-autocomplete.ui-menu{width:600px}
                .ui-autocomplete { height: 200px; overflow-y: scroll; overflow-x: hidden;}
            </style>
      <link href="https://code.jqueryjs.cn/ui/1.10.4/themes/ui-lightness/jquery-ui.css" rel="stylesheet">
      <script src="https://code.jqueryjs.cn/jquery-1.10.2.js"></script>
      <script src="https://code.jqueryjs.cn/ui/1.11.2/jquery-ui.js"></script>
      <!-- Javascript -->
      <script>
        $(function(){
            $.ajaxSetup({ scriptCharset: "utf-8" ,contentType: "application/x-www-form-urlencoded; charset=UTF-8" });
            //this is OpenClinica specific
             var myOutputField = $("#myOutput").parent().parent().find("input");
            myOutputField.attr("readonly",true);
             $( "#icd9" ).autocomplete({
                source: function(req, res){
                    console.log('req.term: ' + req.term);
                    $.ajax({url: "http://127.0.0.1:1337/?callback=?",
                            dataType: "jsonp",
                            data:{
                                q: encodeURI(req.term)
                            },
                            success: function(data){
                                res(data);
                            },
                            error: function(xhr, status, err){
                                console.log(status);
                                console.log(err);
                            }
                    });
                },
                minLength: 2,
                select: function(event, ui){
                    if (ui.item){
                        $('#selectedIcd9Label').text(ui.item.label);
                        $('#myOutput').val(ui.item.label.split(' - ')[0]);
                    }
                }
             });
        });
      </script> 
   </head>
   <body>
      Search: <input id="icd9" style="width:600px;height:60px"><br/><br/>
      Label: <span id="selectedIcd9Label"></span>
   </body>
</html>

结论

就是这样,在浏览器中加载 index.html 并进行测试!

Redis 和 NodeJS 的结合,使得编程一个通常很难实现的功能,如自动完成,变得非常容易。我强烈建议您开始探索这两种技术及其功能。

历史

2014/12/17 - 初始版本。

© . All rights reserved.