茶歇驿站 成长之路由我相伴。

基于 Go 和 Elasticsearch 构建一个搜索服务

2018-04-03
mai

这篇文章是一篇基于 Go 和 Elasticsearch 构建一个搜索服务的实践文章。

本文介绍如何基于 GoElasticsearch 构建简单的搜索服务,该服务将在本地 Docker 机器中运行一个 Elasticsearch 实例。如果你只对源代码感兴趣,你可以在 GitHub 上找到它。

入门

如果你还没有安装 DockerGogolang/dep-一个依赖管理工具,那你就点击链接按照步骤进行操作吧。

在 $GOPATH 中为你的项目创建一个目录。

配置服务

创建如下内容的 docker-compose.yaml 文件:

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 来初始化这个项目。

$ mkdir search-api
$ cd search-api
$ dep init

在 serch-api 目录下给 search_api 服务写一个 Dockerfile 文件。

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

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 处理函数。

r := gin.Default()
r.POST("/documents", createDocumentsEndpoint)
if err = r.Run(":8080"); err != nil {
  log.Fatal(err)
}

在请求体中声明一个结构体来代表一个简单的文档。

// DocumentRequest 文档请求体
type DocumentRequest struct {
  Title   string `json:"title"`
  Content string `json:"content"`
}

给错误响应封装一个帮助函数。

func errorResponse(c *gin.Context, code int, err string) {
  c.JSON(code, gin.H{
    "error": err,
  })
}

声明 createDocumentsEndpoint 处理函数,然后从请求体中读文档到一个数组。

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 。

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 函数中。

r := gin.Default()
r.POST("/documents", createDocumentsEndpoint)
r.GET("/search", searchEndpoint)
if err = r.Run(":8080"); err != nil {
  log.Fatal(err)
}

searchEndpoint 处理函数,并且解析必需的参数。 参数 skip 和 take 用于限制返回文档的数量并启用基本分页。

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
  }
  // ...
}

然后对 titlecontent 执行多重匹配查询。在这里,参数 minimum_should_matchfuzziness 被设置为一些“魔术”数字。请参阅文档以查看其他可能的设置。

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 参数确定的范围内的所有文档。

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 目录中,保证所有依赖关系都正确设置。

$ cd search-api
$ dep ensure

使用 Docker-Compose 来构建和运行这两个服务。

$ docker-compose up -d --build

上传一些假文件,例如,在 fake-data.json 文件中找到的文件。

$ curl -X POST http://localhost:8080/documents -d @fake-data.json -H "Content-Type: application/json"

试试看。

$ 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 上找到。

参考资料

  1. https://outcrawl.com/go-elastic-search-service/

茶歇驿站

一个可以让你停下来看一看,在茶歇之余给你帮助的小站,这里的内容主要是后端技术,个人管理,团队管理,以及其他个人杂想。

茶歇驿站二维码 打赏


Similar Posts

Comments