这篇文章是一篇基于 Go 和 Elasticsearch 构建一个搜索服务的实践文章。
本文介绍如何基于 Go 和 Elasticsearch 构建简单的搜索服务,该服务将在本地 Docker 机器中运行一个 Elasticsearch 实例。如果你只对源代码感兴趣,你可以在 GitHub 上找到它。
入门
如果你还没有安装 Docker,Go 和 golang/dep-一个依赖管理工具,那你就点击链接按照步骤进行操作吧。
在 $GOPATH 中为你的项目创建一个目录。
配置服务
创建如下内容的 docker-compose.yaml
文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
version: '3.5'
services:
search_api:
container_name: 'search_api'
build: './search-api'
restart: 'on-failure'
ports:
- '8080:8080'
depends_on:
- elasticsearch
elasticsearch:
container_name: 'elasticsearch'
image: 'docker.elastic.co/elasticsearch/elasticsearch:6.2.3'
ports:
- '9200:9200'
|
我们定义了两个服务:search_api 和 elasticsearch 。
search_api 服务是在端口 8080 上托管我们的应用程序,而 elasticsearch 是运行在官方的 Elasticsearch Docker 镜像上,服务端口是 9200 。
接下来,咱们首先先来创建一个 search-api
子目录并且用 dep
来初始化这个项目。
1
2
3
|
$ mkdir search-api
$ cd search-api
$ dep init
|
在 serch-api 目录下给 search_api 服务写一个 Dockerfile 文件。
1
2
3
4
5
6
7
8
9
10
11
|
FROM golang:1.10.0
RUN adduser --disabled-password --gecos '' api
USER api
WORKDIR /go/src/app
COPY . .
RUN go install -v ./...
CMD [ "app" ]
|
连接到 Elasticsearch
在 search-api
目录下创建一个入口文件 main.go
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/olivere/elastic"
"github.com/teris-io/shortid"
)
// 定义 Elasticsearch 索引和类型名字.
// 索引是具有不同类型的文档的集合,这个例子只定义了一个叫做 document 的类型。
const (
elasticIndexName = "documents"
elasticTypeName = "document"
)
// Document 声明要建立索引的文档的主要结构。
type Document struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
Content string `json:"content"`
}
// elasticsearch 客户端
var (
elasticClient *elastic.Client
)
func main() {
var err error
for {
elasticClient, err = elastic.NewClient(
elastic.SetURL("http://elasticsearch:9200"),
elastic.SetSniff(false),
)
if err != nil {
log.Println(err)
time.Sleep(3 * time.Second)
} else {
break
}
}
// ...
}
|
Elasticsearch 必须知道如何处理文档的字段以及它们代表的是什么数据。这可以通过手动定义一个映射来完成,或者像本文中使用的那样,通过动态映射将其留给 Elasticsearch。
启动 Docker 容器有准备连接的服务之间存在时间差。因此,代码逻辑是假如最初失败的话,则每隔 3 秒会重新再尝试连接一次 elasticsearch 服务。
解决这个问题的另一种方法是编写一个简单的 bash 脚本,它可以 “pings” 一些服务,直到准备好,然后运行你的应用程序。你可以将 CMD 指令的值更改为你的 bash 脚本。
插入文件
在你搜索他们之前,你需要一种方式去创建文档。
在 main
函数内使用 gin-gonic/gin 框架运行 HTTP 服务器。将 /documents
端点映射到 createDocumentsEndpoint
处理函数。
1
2
3
4
5
|
r := gin.Default()
r.POST("/documents", createDocumentsEndpoint)
if err = r.Run(":8080"); err != nil {
log.Fatal(err)
}
|
在请求体中声明一个结构体来代表一个简单的文档。
1
2
3
4
5
|
// DocumentRequest 文档请求体
type DocumentRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
|
给错误响应封装一个帮助函数。
1
2
3
4
5
|
func errorResponse(c *gin.Context, code int, err string) {
c.JSON(code, gin.H{
"error": err,
})
}
|
声明 createDocumentsEndpoint
处理函数,然后从请求体中读文档到一个数组。
1
2
3
4
5
6
7
8
|
func createDocumentsEndpoint(c *gin.Context) {
var docs []DocumentRequest
if err := c.BindJSON(&docs); err != nil {
errorResponse(c, http.StatusBadRequest, "Malformed request body")
return
}
// ...
}
|
设置一个唯一 ID 和每个文档的创建时间,然后把他们用 bulk 操作插入到 Elasticsearch 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
bulk := elasticClient.
Bulk().
Index(elasticIndexName).
Type(elasticTypeName)
for _, d := range docs {
doc := Document{
ID: shortid.MustGenerate(),
Title: d.Title,
CreatedAt: time.Now().UTC(),
Content: d.Content,
}
bulk.Add(elastic.NewBulkIndexRequest().Id(doc.ID).Doc(doc))
}
if _, err := bulk.Do(c.Request.Context()); err != nil {
log.Println(err)
errorResponse(c, http.StatusInternalServerError, "Failed to create documents")
return
}
c.Status(http.StatusOK)
|
搜索
注册一个新的 /search
端点到 main
函数中。
1
2
3
4
5
6
|
r := gin.Default()
r.POST("/documents", createDocumentsEndpoint)
r.GET("/search", searchEndpoint)
if err = r.Run(":8080"); err != nil {
log.Fatal(err)
}
|
写 searchEndpoint
处理函数,并且解析必需的参数。
参数 skip 和 take 用于限制返回文档的数量并启用基本分页。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func searchEndpoint(c *gin.Context) {
// Parse request
query := c.Query("query")
if query == "" {
errorResponse(c, http.StatusBadRequest, "Query not specified")
return
}
skip := 0
take := 10
if i, err := strconv.Atoi(c.Query("skip")); err == nil {
skip = i
}
if i, err := strconv.Atoi(c.Query("take")); err == nil {
take = i
}
// ...
}
|
然后对 title
和 content
执行多重匹配查询。在这里,参数 minimum_should_match
和 fuzziness
被设置为一些“魔术”数字。请参阅文档以查看其他可能的设置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
esQuery := elastic.NewMultiMatchQuery(query, "title", "content").
Fuzziness("2").
MinimumShouldMatch("2")
result, err := elasticClient.Search().
Index(elasticIndexName).
Query(esQuery).
From(skip).Size(take).
Do(c.Request.Context())
if err != nil {
log.Println(err)
errorResponse(c, http.StatusInternalServerError, "Something went wrong")
return
}
// ...
|
最后,创建一个响应对象,其中包含命中总数,以毫秒为单位的时间以及由 skip 和 take 参数确定的范围内的所有文档。
1
2
3
4
5
6
7
8
9
10
11
12
|
res := SearchResponse{
Time: fmt.Sprintf("%d", result.TookInMillis),
Hits: fmt.Sprintf("%d", result.Hits.TotalHits),
}
docs := make([]DocumentResponse, 0)
for _, hit := range result.Hits.Hits {
var doc DocumentResponse
json.Unmarshal(*hit.Source, &doc)
docs = append(docs, doc)
}
res.Documents = docs
c.JSON(http.StatusOK, res)
|
包装起来
在 search-api
目录中,保证所有依赖关系都正确设置。
1
2
|
$ cd search-api
$ dep ensure
|
使用 Docker-Compose 来构建和运行这两个服务。
1
|
$ docker-compose up -d --build
|
上传一些假文件,例如,在 fake-data.json
文件中找到的文件。
1
|
$ curl -X POST http://localhost:8080/documents -d @fake-data.json -H "Content-Type: application/json"
|
试试看。
1
2
3
4
5
6
7
8
9
10
11
12
|
$ curl http://localhost:8080/search?query=exercitation+est+officia
{
"time": "42",
"hits": "43",
"documents": [{
"title": "Exercitation est officia fugiat labore deserunt est id voluptate magna.",
"created_at": "2018-03-21T15:22:48.7830606Z",
"content": "..."
},
// ...
]
}
|
整个源代码都可以在 GitHub 上找到。
参考资料
- https://outcrawl.com/go-elastic-search-service/
茶歇驿站
一个可以让你停下来看一看,在茶歇之余给你帮助的小站,这里的内容主要是后端技术,个人管理,团队管理,以及其他个人杂想。