用Redis的发布订阅功能创建一个聊天系统
问题
利用Redis的发布订阅功能,加上Node.js和Socket.IO,创建一个轻量级的实时聊天系统。
解决方案
Redis原生支持发布订阅模型,我们可以很容易地把它跟Node.js和Socket.IO结合在一起,快速地创建一个实时聊天系统。
发布订阅模型定义了接收者和触发者的工作方式,接收者订阅符合指定模式的消息,触发者把消息发送到消息云。当一个消息到达云,订阅这种类型消息的客户端会收到这个消息。这种模式降低了触发者和接收者之间的耦合关系——它们不需要知道对方的细节,只要他们能够以这种方式发送消息或者接收消息。
为了更好地理解发布订阅模型,可以参考wiki页。
Redis直接支持发布订阅模型,也就是说,它允许客户端订阅满足指定模式的频道,也可以向指定的频道发布消息。比如,我们可以很容易地为汽车相关话题创建"chat:cars"频道,为食物相关话题创建"chat:sausage"频道。频道的名字跟Redis的键空间没有关系,所以不用担心会跟已有的键存在冲突。Redis通过以下命令支持发布订阅功能:
PUBLISH 发布到指定频道
SUBSCRIBE 订阅指定频道
UNSUBSCRIBE 取消订阅指定频道
PSUBSCRIBE 订阅符合指定模式的频道
PUNSUBSCRIBE 取消订阅符合指定模式的频道
有了这些命令,可以很容易地创建一个聊天系统或一个通知系统。发布订阅模型甚至可以被用于构建队列系统,不过先让我们来看看如何实现聊天系统吧。
在服务器端,Node.js和Socket.IO负者处理网络层,Redis作为发布订阅模型的实现,在客户端之间转发消息。在客户端,我们借助jQuery来处理消息,或者发送数据到服务端。
讨论
我们假设你最近安装过Node.js,还有npm,因为我们需要用它来安装聊天系统所需要的node依赖包。我们先从如何安装聊天系统所需要的软件开始,再过一下服务端及客户端的代码。
安装所需软件
我们先用npm来安装所需要的软件包:
npm install socket.io
npm install redis
实现服务端代码
在服务器端,我们会运行一个Redis,并且创建一个js文件,然后用Node.js来执行它。这段代码主要是发起到Redis的连接,同时监听来自客户端的连接端口。让我们来看看代码:
var http = require('http'),
io = require('socket.io'),
redis = require('redis'),
rc = redis.createClient();
这几行代码引入了我们所需要的包,并且创建了一个变量,我们会用它来访问Redis跟Socket.IO。我们会用"redis"这个变量访问Redis,而变量"io"允许我们访问所有连接到服务器的socket。
下一步我们要对我们的代码做一些修改,启动一个HTTP服务,Socket.IO会基于这个发挥它的魔力。
server = http.createServer(function(req, res){
// we may want to redirect a client that hits this page
// to the chat URL instead
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('<h1>Hello world</h1>');
});
// Set up our server to listen on 8000 and serve socket.io
server.listen(8000);
var socketio = io.listen(server);
如果你有Node.js或Socket.IO的使用经验,这段代码对你来说应该很简单。它启动了一个HTTP服务,指定了处理请求的方式,对8000端口进行监听,并粘附了Socket.IO,这样就可以处理来自Socket.IO的请求,同时开启了websocket功能。
现在我们再加进去一点Redis代码来完善我们的功能。用Node.js写的Redis客户端必须订阅聊天频道,并处理来自频道的消息。
// if the Redis server emits a connect event, it means we're ready to work,
// which in turn means we should subscribe to our channels. Which we will.
rc.on("connect", function() {
rc.subscribe("chat");
// we could subscribe to more channels here
});
// When we get a message in one of the channels we're subscribed to,
// we send it over to all connected clients.
rc.on("message", function (channel, message) {
console.log("Sending: " + message);
socketio.sockets.emit('message', message);
})
正如你所看到的,Redis代码超级简单。我们所要做的就是监听来自特定频道的消息,当有消息到来,我们就把它广播到所有客户端。
客户端代码实现
完成了服务端的代码,接下来我们要创建一个页面来连接到Node.js。我们要先在客户端把Socket.IO设置好,然后处理进出消息。
我们开始创建页面,这里是页面的主要代码:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Chat with Redis</title>
</head>
<body>
<ul id="messages">
<!-- chat messages go here -->
</ul>
</body>
</html>
为了完成功能,现在我们要把jQuery和Socket.IO包含进来。我们会先从Google CDN去获取,然后是Node.js服务器。把这两行插入到页面的head区间:
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js"></script>
<script src="http://localhost:8000/socket.io/socket.io.js"></script>
现在可以开始连接到Node.js,并监听和处理消息了。把下面的代码加到head区间:
<script>
var socket = io.connect('localhost', { port: 8000});
socket.on('message', function(data){
var li = new Element('li').insert(data);
$('messages').insert({top: li});
}
</script>
这段js代码让客户端Socket.IO连接到Node.js的8000端口,并监听消息事件。当一个消息到达,它会创建一个列表元素,并添加到之前已经存在的无序列表里。要注意,代码很简单,所以聊天界面是很难看的,不过要让它变好看也不难。
我们还差一个表单,还有就是如何把消息从客户端发送到服务端。这个需要Socket.IO发送功能的支持,之前在服务器端已经用过了。
<form id="chatform" action="">
<input id="chattext" type="text" value="" />
<input type="submit" value="Send" />
</form>
<script>
$('#chatform').submit(function() {
socket.emit('message', $('chattext').val());
$('chattext').val(""); // cleanup the field
return false;
});
</script>
当用户填好表单并按下发送按钮,jQuery将使用socket变量往服务端发送一个消息事件,这个消息会被广播到其它客户端。脚本里最后一个"return false"语句是为了防止表单被提交,因为Socket.IO已经处理了这部分内容。
更多改进
到目前为止,我们使用Node.js和Socket.IO创建了聊天系统的主要部分,不过还可以从很多方面对我们的代码进行改进。比如,我们可以在消息里使用包含用户名或头像元数据的JSON片段,而不是一般的字符串。我们也可以在服务器端做一些改进,比如使用多个频道,允许通过模式匹配订阅多个频道。Redis的发布订阅模型让实现聊天或通知系统变得很容易。
用Redis实现倒排索引文本搜索
问题
倒排索引是一种索引数据结构,它存储的是单词到它们所在位置的映射。位置指的是单词在文件里,文档里,或数据库里的位置。它一般被用来实现全文搜索,不过需要事先对文档建立索引。
在这一章节,我们将使用Redis作为后端存储来实现一个全文搜索。
解决方案
我们会为每一个单词使用一个集合,集合里包含文档的ID。为了让搜索更快,我们会事先为所有文档建立索引。在查找的时候,输入的关键字被分割成单词,然后进行集合的交集操作,返回所有包含这些单词的文档ID。
讨论
建立索引
假设我们有成百上千的文档或web页面。第一步是对它们建立索引,我们要把文本分割成单词,还要把无用词以及长度低于3的单词排除掉。我们用Ruby脚本来做:
def id_for_document(filename)
doc_id= $redis.hget("documents", filename)
if doc_id.nil?
doc_id= $redis.incr("next_document_id")
$redis.hset("documents", filename, doc_id)
$redis.hset("filena
end
STOP_WORDS = ["the", "of", "to", "and", "a", "in", "is","it", "you", "that"]
f = File.open(filename)
doc_id= id_for_document(filename)
f.each_line do |l|
l.strip.split(/ |,|\)|\(|\;|mes", doc_id, filename)
end
doc_id\./).each do |word|
continue if word.size <= 3 || STOP_WORDS.include?(word)
add_word(word, doc_id)
end
end
我们把需要加到索引的单词过滤了出来,并且为每个文档生成了唯一ID。我们还需要一个增加索引的函数:
def add_word(word, doc_id)
$redis.sadd("word:#{word}", doc_id)
end
对文档里的每一个单词,我们创建了相应的集合,集合里包含了可以找到这个单词的文档ID。
查找
倒排索引的查找速度很快,因为大部分工作在为文档建立索引的时候都做掉了。查找的时候只需要对集合进行并集操作。下面的代码使用redis-rb来执行Redis命令:
def search(*terms)
document_ids = $redis.sinter(*terms.map{|t| "word:#{t}"})
$redis.hmget("filenames", *document_ids)
end
分数排名
上面的实现很简单,也有局限性,不过对它进行扩展也很容易。我们可以对文档设置分数,当返回查找结果的时候,我们可以根据分数来判断一些情况:更高的分数意味结果更匹配,或者说明被搜索的频度越高。让我们对索引函数做一些修改:
def add_word(word, doc_id)
$redis.zincrby("word:#{word}", 1, doc_id)
end
查找会复杂一些:
def search(*terms)
document_ids = $redis.multi do
$redis.zinterstore("temp_set", terms.map{|t|"word:#{t}"})
$redis.zrevrange("temp_set", 0, -1)
end.last
$redis.hmget("filenames", *document_ids)
end
注意代码里的multi函数,因为在temp_set集合里可能存在潜在的并发问题。当有两个或多个命令要对数据进行更新,而它们更新的数据会被其它进程访问,并且要求这些命令在被访问之前完成,那么就会存在竞态条件问题。
为了在并行查找的时候避免这种竞态条件,我们要么使用Redis的MULTI/EXEC命令,要么为每一次查找操作生成唯一的键。
MUTLI和EXEC命令让Redis具备了事务能力。出现在MULTI/EXEC代码块中的命令保证被串行执行,也就是说,在代码块执行期间不会处理其它的客户端请求。就这个例子来说,temp_set的竞态条件问题被消除了,因为其它客户端无法在ZINTERSTORE和ZREVRANGE操作期间对相关的值进行修改。在一个事务里使用DISCARD会让事务结束,事务里的所有命令会被取消,然后返回到正常状态。
事务里的命令只在调用EXEC之后才开始被执行,也只有在这个时候,才会收到所有命令的执行结果。因此,在同一个事务里,一个命令无法使用上一个命令的执行结果。不过要想达到这种效果,可以使用WATCH命令。
redis-rb里没有显式的EXEC调用。multi函数的开头和结尾就标记了事务的开始和结束,在代码块结束的地方,redis-rb会隐式地调用EXEC。
其它改进
搜索功能还有很多可以改进的地方。
大小写敏感
我们可以让搜索功能对大小写不敏感,所以在建立索引的时候对单词进行统一的大小写转换,在查找的时候也需要对关键字进行转换。
模糊查找
也许你也会对实现模糊查找感兴趣。这需要考虑到一些经常发生的拼写错误的情况。在我们的例子里,我们需要在建立索引的时候把拼写错误的关键字也进行索引。
部分匹配
这个很有用,不过会增加内存的使用,而且可能会多出一些不想要的搜索结果。为了达到这个目的,我们需要获取单词的子字符串,然后对它们进行索引。比如为了给matching建立索引,需要把这些都加到索引里: matching,mat,matc,match,matchi,matchin。
前提是已经确定最小的字符串长度是3,而且至少已经对单词的开头部分进行了匹配。如果你想得到所有可能的组合,需要对单词的其它子串进行索引。
用有序集合来实现部分匹配和模糊匹配很管用。你可以给不完整和拼写错误的单词设置低一点的分数,这样让搜索得到的结果更准确。