# 8. redis 實現自動輸入完成
#### 1. 介紹
當我們在京東商城的搜索框,輸入想要搜索的內容,比如你想要搜索"熱水瓶",剛輸入一個"熱"字,就會出現一個下拉框,列出了很多以"熱"字開頭的可供選擇的條目,比如"熱水器"、"熱水袋"、”熱水瓶"等,如下圖所示:

這種技術就叫做自動輸入完成,當輸入想要搜索的首字符或其中被包含的字符時,就會出現可供選擇的條目,用戶可以選擇其中的條目來完成此次搜索,避免了用戶輸入全部的字符,改善了用戶體驗。這種技術很常用,比如在一個社交網站添加聯系人時,就是可以通過輸入來聯想匹配到聯系人的。
#### 2. 功能分析
要實現這樣的功能,需要前端和后端一起配合,前端部分需要監聽搜入框內容的改變,當內容改變時把內容作為參數傳遞給服務器,服務器再請求后端的數據庫,再把數據返回給客戶端,前端只要把結果渲染出來即可。
這個數據庫我們選擇的是內存數據庫redis,而不用存儲在磁盤的關系型數據庫,畢竟這個功能對實時性要求比較高,頻繁地請求數據庫肯定是用性能高和速度快的redis好。
我們有一個實例網站,是存放文章的,每篇文章都有自己的標簽(tag),整個網站是具有全文檢索功能的,也就是說,可以根據文章的標題、內容、標簽來搜索文章。現在需要在輸入框加一個自動完成的功能,當用戶在輸入框輸入標簽的頭一個或幾個字符的時候,會自動聯想到標簽。比如,有"ruby","postgresql"兩個標簽,當用戶輸入"ru"的時候,會自動出現"ruby"作為可選的項。
首先,被檢索的標簽的名稱事先存放到redis中,每個標簽都是唯一的,所以可選擇redis的集合作為數據庫,但是,有可以出現很多個以"ru"開頭的標簽,所以需要一個權重算法,那就是排序,標簽被使用得頻繁的越排在前面,所以最終是用排序集合(sortedset)來存儲標簽數據。
```
# rails console
> ActsAsTaggableOn::Tag.all
[
[ 0] 消息隊列 {
:id => 50,
:name => "消息隊列",
:taggings_count => 5
},
[ 1] ruby on rails {
:id => 8,
:name => "ruby on rails",
:taggings_count => 17
},
[ 2] websocket {
:id => 55,
:name => "websocket",
:taggings_count => 1
},
[ 3] database {
:id => 25,
:name => "database",
:taggings_count => 1
}
]
```
`taggings_count`就是標簽被使用的次數,只要把`tag`的`name`按照`taggings_count`作為排序標準存到redis的`sort set`中,就可以了。下面我們會說如何去存儲這些數據。
#### 3. soulmate使用
[soulmate](https://github.com/seatgeek/soulmate)是一個結合redis實現自動輸入完成的功能強大的gem。
先安裝這個gem。
```
$ gem install soulmate
```
soulmate要求你的數據存成一種特定格式的json,再把json導出到redis中,格式是類似這樣的。
```
{
"id": 5,
"term": "devise",
"score": 3,
"data": {
}
}
```
`id`是唯一的標識,`term`就是搜索的條目,`score`就是排序的項,這三項是必須要帶上的,而`data`是可選的,里面可以存自己想要的數據,比如可把tag的描述信息加上。
很簡單,在我們的案例中,把上面的tag的`name`換成`term`,`taggings_count`換成`score`即可。
##### 3.1 導入tags數據
我寫了一個方法可以將所有的tag導出到一個json文件。
```
File.open("tags.json","w+") do |f|
ActsAsTaggableOn::Tag.find_each do |tag|
tag_json = {
id: tag.id,
term: tag.name,
score: tag.taggings_count
}
f.write("#{tag_json.to_json}\n")
end
end
```
輸出的tags.json類似下面這樣:
```
{"id":5,"term":"devise","score":3}
{"id":6,"term":"登錄","score":3}
{"id":7,"term":"認證","score":3}
{"id":8,"term":"ruby on rails","score":17}
{"id":9,"term":"rails","score":4}
{"id":10,"term":"ruby","score":4}
```
現在可以先這個tags.json導入到redis數據庫中,soulmate這個gem也提供了相關的命令行工具。
```
$ soulmate load tag --redis=redis://localhost:6379/0 < tags.json
```
你會發現redis中增加了很多的數據,我們先不管,等下再來分析那些數據。
##### 3.2 添加單個tag到redis
每次都用這種導入的方式來在redis增加數據很不方便的,畢竟以后我們隨時要增加tag。你總不可能為了增加一個tag再導一次json吧,這不太科學。所以我們需要在增加或刪除tag的時候自動把數據添加到redis中。
通過查看soulmate的源碼,發現它是提供了相應的方法的。
```
# https://github.com/seatgeek/soulmate/blob/master/lib/soulmate/loader.rb#L29
def add(item, opts = {})
opts = { :skip_duplicate_check => false }.merge(opts)
raise ArgumentError, "Items must specify both an id and a term" unless item["id"] && item["term"]
# kill any old items with this id
remove("id" => item["id"]) unless opts[:skip_duplicate_check]
Soulmate.redis.pipelined do
# store the raw data in a separate key to reduce memory usage
Soulmate.redis.hset(database, item["id"], MultiJson.encode(item))
phrase = ([item["term"]] + (item["aliases"] || [])).join(' ')
prefixes_for_phrase(phrase).each do |p|
Soulmate.redis.sadd(base, p) # remember this prefix in a master set
Soulmate.redis.zadd("#{base}:#{p}", item["score"], item["id"]) # store the id of this term in the index
end
end
end
```
由于`add`方法要接一個hash作為參數,所以需要對tag這個model作一些加工。
```
# config/initializers/tag.rb
module ActsAsTaggableOn
class Tag < ::ActiveRecord::Base
after_create :create_soulmate
def to_hash
tag_json = {
id: self.id,
term: self.name,
score: self.taggings_count
}
JSON.parse(tag_json.to_json )
end
private
def create_soulmate
Soulmate::Loader.new("tag").add self.to_hash
end
end
end
```
我們拿其中一個tag試一下,看看它究竟生成了怎樣的redis數據(如果在開發環境,做這個之前可以用redis的flushdb指令先清除數據)。
```
$ rails console
> tag = ActsAsTaggableOn::Tag.first.to_hash
{
"id" => 5,
"term" => "devise",
"score" => 3
}
> Soulmate::Loader.new("tag").add tag
```
在redis生成了如下的數據:
```
$ redis-cli
> keys *
1) "soulmate-index:tag:devise"
2) "soulmate-index:tag"
3) "soulmate-index:tag:devis"
4) "soulmate-index:tag:dev"
5) "soulmate-index:tag:de"
6) "soulmate-index:tag:devi"
7) "soulmate-data:tag"
```
所有的key分為三大類,分別是`"soulmate-index:tag"`、`"soulmate-data:tag"`,剩下的以tag的name為devise的前綴開頭的key。結合add方法的源碼,也可以知道這三種key的類型分別為set, hash, sortedset,可以自己用redis的type命令打印出來。現在打印出這些key的值。
```
$ redis-cli
> smembers soulmate-index:tag
1) "devis"
2) "devi"
3) "dev"
4) "de"
5) "devise"
> hgetall soulmate-data:tag
1) "5"
2) "{\"id\":5,\"term\":\"devise\",\"score\":3}"
> zrange "soulmate-index:tag:de" 0 -1 WITHSCORES
1) "5"
2) "3"
> zrange "soulmate-index:tag:dev" 0 -1 WITHSCORES
1) "5"
2) "3"
```
`soulmate-index:tag`存的是集合(set),將"devise"分成一個個前綴,以后按照這些前綴就能搜出"devise"這個tag。`soulmate-data:tag`存的是一個哈希(hash),健為標簽(tag)的id,值為tag的所有內容,就是那個標簽(ActsAsTaggableOn::Tag) model中`to_hash`方法的內容,如果有存data,它的數據也是會在這里出現。`soulmate-index:tag:de`等存的是排序后的集合,排序的健是`score`,也就是標簽(tag)的`taggings_count`,值為標簽(tag)的`id`。
##### 3.3 測試效果
現在來實現服務端的邏輯,我們不用自己寫controller端的代碼,soulmate為我們提供好了這一切。
先安裝這個gem。
```
gem install rack-contrib
```
然后執行以下這個命令。
```
$ soulmate-web --foreground --no-launch --redis=redis://localhost:6379/0
```
這會開啟一個服務并且監聽在5678端口。
現在我們可以用curl工具或chrome瀏覽器插件postman來測試這個服務。下面是curl工具的使用例子。
```
$ curl -X GET http://localhost:5678/search --data "types[]=tag&term=de"
{"term":"de","results":{"tag":[{"id":5,"term":"devise","score":3}]}}
$ curl -X GET http://localhost:5678/search --data "types[]=tag&term=devis"
{"term":"devis","results":{"tag":[{"id":5,"term":"devise","score":3}]}}
```
使用postman請求`http://localhost:5678/search?types[]=tag&term=de`的例子如下:

現在再來查看redis的數據,會發現多了兩個key。
```
$ redis-cli
> zrange "soulmate-cache:tag:de" 0 -1 WITHSCORES
1) "5"
2) "3"
> zrange "soulmate-cache:tag:devis" 0 -1 WITHSCORES
1) "5"
2) "3"
```
#### 4. 頁面上的實現
我們要用soulmate配合前端在rails項目里實現自動輸入完成的功能。
先把soulmate安裝進rails項目里。
##### 4.1 掛載soulmate服務
把下面的代碼添加到Gemfile文件,然后執行`bundle`命令。
```
gem 'rack-contrib'
gem 'soulmate', :require => 'soulmate/server'
```
在`config/routes.rb`文件中添加一行路由。
```
mount Soulmate::Server, :at => "/sm"
```
在`config/initializers`目錄下添加soulmate.rb文件。
```
Soulmate.redis = 'redis://127.0.0.1:6379/0'
# or you can asign an existing instance of Redis, Redis::Namespace, etc.
# Soulmate.redis = $redis
```
重啟服務器。現在可以這樣來測試。
```
$ curl -X GET http://localhost:3000/sm/search --data "types[]=tag&term=dev"
{"term":"dev","results":{"tag":[{"id":5,"term":"devise","score":3}]}}
```
##### 4.2 soulmate.js
至于前端部分,我們使用官方推薦的[soulmate.js](https://github.com/mcrowe/soulmate.js)這個庫。
相關的代碼是這樣的。
```
= form_tag "/articles", method: "get", class: "navbar-form navbar-left", role: "search"
.form-group
input.form-control id="search" placeholder="Search" type="text" name="term" value="#{params[:search].presence}" autocomplete="off"/
```
```
javascript:
// Make the input field autosuggest-y.
$(document).ready(function() {
render = function(term, data, type){ return term; }
select = function(term, data, type){ console.log("Selected " + term); }
$('#search').soulmate({
url: '/sm/search',
types: ['tag'],
renderCallback: render,
selectCallback: select,
minQueryLength: 2,
maxResults: 5
});
});
```
具體的css和js效果可以自己處理。
#### 5. 源碼解析
現在有個美中不足的地方,就是必須要輸入兩個字符以上才能開始自動輸入完成。而readme文檔沒有相關的解決方法,我們只能從源碼入手。
上面的例子中,"devise"這個單詞會被分解成一個個前綴,比如"de","dev”等。我們來看下中文詞組是如何被分解的。
上面有提到那個`add`方法的源碼,其中調用了`prefixes_for_phrase`這個方法。
```
# https://github.com/seatgeek/soulmate/blob/ead5d6c2b6d698a5c49294a73a4f7536a5013f01/lib/soulmate/helpers.rb#L3
module Soulmate
module Helpers
def prefixes_for_phrase(phrase)
words = normalize(phrase).split(' ').reject do |w|
Soulmate.stop_words.include?(w)
end
words.map do |w|
(Soulmate.min_complete-1..(w.length-1)).map{ |l| w[0..l] }
end.flatten.uniq
end
end
end
```
執行`rails console`進入終端來看一下這個方法是如何分解中文詞組的。
```
$ rails console
> include Soulmate::Helpers
Object < BasicObject
> prefixes_for_phrase("前端構建與部署工具")
[
[0] "前端",
[1] "前端構",
[2] "前端構建",
[3] "前端構建與",
[4] "前端構建與部",
[5] "前端構建與部署",
[6] "前端構建與部署工",
[7] "前端構建與部署工具"
]
```
中文詞組還是能夠正常處理,可是至少要輸入兩個字符。通過分析`prefixes_for_phrase`方法,可以發現關鍵在于`Soulmate.min_complete`這個變量。
通過搜索源碼,發現了定義`Soulmate.min_complete`變量的地方。
```
# https://github.com/seatgeek/soulmate/blob/ead5d6c2b6d698a5c49294a73a4f7536a5013f01/lib/soulmate/config.rb#L11
require 'uri'
require 'redis'
module Soulmate
module Config
attr_writer :min_complete
def min_complete
@min_complete ||= DEFAULT_MIN_COMPLETE
end
def redis=(server)
...
end
end
end
```
還記得上文"掛載soulmate服務"部分提到`config/initializers/soulmate.rb`這個文件嗎,里面就是定義了`Soulmate.redis`這個變量,那同樣的道理,也把`min_complete`定義在里面。
```
# config/initializers/soulmate.rb
Soulmate.redis = 'redis://127.0.0.1:6379/0'
Soulmate.min_complete = 1
```
再來看結果。
```
$ redis-cli
> include Soulmate::Helpers
Object < BasicObject
> prefixes_for_phrase("前端構建與部署工具")
[
[0] "前",
[1] "前端",
[2] "前端構",
[3] "前端構建",
[4] "前端構建與",
[5] "前端構建與部",
[6] "前端構建與部署",
[7] "前端構建與部署工",
[8] "前端構建與部署工具"
]
```
完結。