Как сделать поисковой микросервис

Поиск является неотъемлемой частью любого приложения и возможность найти что-либо через строку поиска. Есть много способов сделать это, я видел как это сделано на стороне клиента с помощью цикла, или же с полнотекстовой индексацией в Postgres или MySQL. Но наступает ситуация, где нам нужен отдельный поисковой сервис, и для этого часто выбирают Elasticsearch.

В этой статье я покажу как сделать простой поисковой микросервис, используя язык программирования Golang. Мы будем искать пользователей по электронной почте, никнейму и настоящему имени. Все исходники вы можете найти на GitHub.

Архитектура

Прежде чем начать писать код давайте разберемся как это должно работать.

Важно понимать, что Elasticsearch не должен подвергаться прямому воздействию клиента, поэтому создание промежуточного микросервиса имеет важное значение.

В этом примере для микросервиса потребуется один endpoint для поиска. Также нам надо заполнить кеш Elasticsearch’а, поэтому нам понадобится второй endpoint. В реальном приложении лучше использовать какую-то очередь для набора Producer и Consumer (подробнее об этом описано здесь), чтобы заполнить систему. Однако мы не будем к этому прибегать в этой статье.

Мы будем использовать в качестве двух endpoint’ов следующее:

  • /search
  • /populate

Эти endpoint’ы будут получать параметры.

Поиск

Поисковой endpoint должен получать два параметра, во-первых, нам нужно указать строку, которую будем искать. Также нам нужна простейшая пагинация, чтобы указать сколько результатов поиска вернуть и сколько нужно результатов проигнорировать в начале поиска.

  • q - строка для поискового запроса
  • from - начальный индекс в списке результатов
  • size - количество результатов для возвращения

Заполнение

Заполняющий endpoint получает только один параметр, который используется для определения количества результатов для генерирования.

  • number - количество результатов для вставки в Elasticsearch

Endpoint’ы

Теперь, когда мы знаем структуру нашего микросервиса и какие endpoint’ы и параметры нам нужны, давайте начнем с писать код!

Во-первых, сначала создадим главный файл main.go с endpoint’ами и базовым HTTP-сервером.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"log"
"net/http"
)

func main() {
mux := http.NewServeMux()

mux.HandleFunc("/populate", func(w http.ResponseWriter, req *http.Request) {

})

mux.HandleFunc("/search", func(w http.ResponseWriter, req *http.Request) {

})

log.Fatal(http.ListenAndServe(":8000", mux))
}

Поскольку это достаточно маленький микросервис, мы создадим endpoint’ы непосредственно в файле main.go. Но в продакшн или если у вас больше логики, лучше перенести эти endpoint’ы в отдельный файлы.

Теперь, давайте напишем поисковой endpoint.

Нам нужно будет получить get параметры, что относительно неуклюже и долго, когда вы используете стандартную библиотеку Golang, поэтому я не буду описывать это в статье, если вам интересно, то метод описан на GitHub.

После получения параметров мы проверяем, правильны ли они, если нет, то отправляем ответ, что это недопустимый запрос. После этого мы передаем параметры term, from и size в нашу функцию поиска в Elasticsearch, которую мы создадим в следующем разделе. Функция будет опрашивать Elasticsearch и возвращать результаты, а также ошибку, если таковая случится. Оттуда мы будем формировать json ответ и отправлять его клиенту. Довольно просто.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mux.HandleFunc("/search", func(w http.ResponseWriter, req *http.Request) {
term, from, size, ok := getQueryParams(req)
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Attach proper parameters"))
return
}
res, err := Search(term, from, size)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Error searching"))
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(res)
})

Следующая задача - создать endpoint для заполнения Elasticsearch. Наличие endpoint’а поиска не имеет смысла, если у нас нет данных для поиска!

Первое, что мы делаем, это получить get параметр number и переобразовать его в целое число. Если он неправильный, мы возвращаем отрицательный ответ клиенту. Следующий шаг - заполнить Elasticsearch, это делается с помощью вспомогательной функции, которую мы сделаем позже в этой статье. Наконец, мы возвращаем ошибку, если функция заполнения отдает ошибку.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mux.HandleFunc("/populate", func(w http.ResponseWriter, req *http.Request) {
numberArr, ok := req.URL.Query()["number"]
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Attach proper parameters"))
return
}
numberStr := numberArr[0]
number, err := strconv.Atoi(numberStr)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Attach proper parameters"))
return
}
err = Populate(number)
if err != nil || !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
})

Теперь, когда мы создали endpoint’ы, нам нужно связать все это вместе с функциями Elasticsearch.

Elasticsearch Helpers

Последняя часть создания микросервиса это подключение к Elasticsearch. Давайте создадим helper’ы сейчас, мы сделаем это в новом файле elastic.go.

Давайте посмотрим, как должен выглядеть файл, сначала нам нужна json-модель для Elasticsearch. Вам нужно создать структуру пользователя User со следующими полями:

  • Username с json-декоратором username
  • Email с json-декоратором email
  • RealName с json-декоратором real_name

Нам также необходимо создать функции Populate и Search.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"context"
"github.com/olivere/elastic"
"github.com/icrowley/fake"
"encoding/json"
)

type User struct {
Username string `json:"username"`
Email string `json:"email"`
RealName string `json:"real_name"`
}

func Populate(number int) error {

}

func Search(term string, from, size int) ([]*User, error) {

}

Теперь, когда мы написали макет, следующий шаг - создать методы. Мы используем github.com/olivere/elastic для работы с Elasticsearch.

Теперь создадим логику для функции поиска. Первый шаг это соединиться к поиску Elastic и мы сделаем это создав новый клиент. Если соединение прошло успешно мы формируем запрос. Мы используем многопоточный запрос, если вам интересны другие параметры вы можете найти их в документации Elasticsearch.

Наконец, мы вызываем метод Search и передаём корректные параметры, используем индекс пользователя, и передаём ранее созданный многопоточный запрос и добавляем пагинацию.

В ответ мы получим много довольно интересной информации, но для нашего микросервиса мы не занимаемся журналированием или аналитикой, поэтому нас интересуют только результаты поиска. Мы проходимся циклом по результатам и используем json.Unmarshal, чтобы добавить пользователей в нашу структуру и затем добавляем структуру в массив, чтобы передать клиенту.

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
func Search(term string, from, size int) ([]*User, error) {
client, err := elastic.NewClient(elastic.SetURL("http://elasticsearch:9200"))
if err != err {
return nil, err
}
q := elastic.NewMultiMatchQuery(term, "username", "email", "real_name").Fuzziness("AUTO:2,5")
res, err := client.Search().
Index("users").
Query(q).
From(from).
Size(size).
Do(context.Background())
if err != nil {
return nil, err
}
users := make([]*User, 0)

for _, hit := range res.Hits.Hits {
var user User
err := json.Unmarshal(*hit.Source, &user)
if err != nil {
return nil, err
}
users = append(users, &user)
}
return users, nil
}

Функция Populate начинается почти также, нам нужно подключится к клиенту. Но мы проверяем, существует ли индекс, и если нет, то создаем его. Затем мы используем библиотеку для генерации фальшивых пользователей, генерируем и передаем их в функцию, и вставляем в Elasticsearch.

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
func Populate(number int) error {
client, err := elastic.NewClient(elastic.SetURL("http://elasticsearch:9200"))
if err != nil {
return err
}

idxExists, err := client.IndexExists("users").Do(context.Background())
if err != nil {
return err
}
if !idxExists {
client.CreateIndex("users").Do(context.Background())
}

for i := 0; i < number; i++ {
user := User{
Username: fake.UserName(),
Email: fake.EmailAddress(),
RealName: fake.FullName(),
}
_, err = client.Index().
Index("users").
Type("doc").
BodyJson(user).
Do(context.Background())
if err != nil {
return err
}
}
return nil
}

Настраиваем Docker

Для этой системы я выбираю docker-compose, чтобы связать поисковый микросервис с Elasticsearch. В документации Elasticsearch есть отличная статья по этому поводу. Я последовал за этой статьей с несколькими небольшими изменениями. Единственная часть, на которой нам нужно сосредоточиться, - это Dockerfile, а также привязка его к Elasticsearch через docker-compose.

Dockerfile довольно простой, мы загружаем docker образ Golang 1.10 alpine, добавляем dep для нашего менеджера зависимостей, добавляем путь к коду в GOPATH, получаем зависимости с dep, компилируем программу и запускаем ее.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FROM golang:1.10-alpine

LABEL authors="Ryan McCue <ryan@msys.ca>"

RUN apk add --no-cache ca-certificates openssl git
RUN wget -O /usr/local/bin/dep https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64 && \
chmod +x /usr/local/bin/dep

RUN mkdir /go/src/app

ADD . /go/src/app/

WORKDIR /go/src/app

RUN dep ensure

RUN go build -o main .

CMD ["/go/src/app/main"]

Файл docker-compose.yml стандартный, он основан на этом с добавлением службы поиска и добавлением в esnet.

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
52
53
54
55
56
57
58
59
version: '2.2'
services:
search:
container_name: search
build:
context: .
dockerfile: ./Dockerfile
volumes:
- ./search:/www
ports:
- "8080:8000"
networks:
- esnet
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:6.2.4
container_name: elasticsearch
environment:
- cluster.name=docker-cluster
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata1:/usr/share/elasticsearch/data
networks:
- wsdnet
healthcheck:
test: "curl -f http://localhost:9200 || exit 1"
interval: 1s
retries: 20
networks:
- esnet
elasticsearch2:
image: docker.elastic.co/elasticsearch/elasticsearch:6.2.4
container_name: elasticsearch2
environment:
- cluster.name=docker-cluster
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "discovery.zen.ping.unicast.hosts=elasticsearch"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata2:/usr/share/elasticsearch/data
networks:
- esnet

volumes:
esdata1:
driver: local
esdata2:
driver: local

networks:
esnet:

Чтобы проверить и убедиться, что файлы работают вместе, как и планировалось, выполните docker-compose build для построения поискового микросервиса, а затем запустите docker-compose up для запуска кода. Elasticsearch будет собран в docker-compose up, поэтому не беспокойтесь, если вы заметите это.

Запуск!

Теперь, когда микросервис написан и взаимодействует с Elasticsearch, пришло время проверить его. Давайте запустим наши endpoint’ы и посмотрим, что произойдет! Во-первых, мы должны заполнить Elasticsearch результатами, поэтому давайте запустим endpoint /populate. Вы можете запустить его с помощью ссылки ниже:

1
http://localhost:8080/populate?number=100

После заполнения следующий шаг - поиск результатов. Поскольку мы используем Faker, имена не известны заранее, поэтому вам, возможно, придется попробовать пару имен, прежде чем вы увидите результаты. Вы можете запустить поисковой endpoint с помощью ссылки ниже:

1
http://localhost:8080/search?q=ryan&from=0&size=20

Заключение

Эта статья показывает как можно легко сделать микросервис для таких сервисов, как поиск, и подключить его для работы с Elasticsearch. В реальной системе вы не будете генерировать случайные данные, вы, вероятно, будете использовать webhooks или системы очередей для заполнения поиска.

Источник: ryanmccue

Поделиться 0 Комментарии