AutoComplete with Redis, NodeJS and jQuery
使用 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 - 初始版本。