Golang中的一个简单的Elasticsearch CRUD例子

415 阅读4分钟

这是一个用Golang编写的简单Elasticsearch CRUD例子。请记住,有些文件需要改进。有一些硬编码的部分,重复的部分等等。我必须让这个帖子尽可能的简短。请随意重构它。

我们的例子使用了索引别名,这比具体的索引要好。另外,我们使用UUID作为文档的ID,而不是依赖Elasticsearch的自定义ID,_id 。如果你不想走这条路,那也完全没问题。

运行make docker-up 命令后,在浏览器中访问http://localhost:5000 (ElasticHQ)或http://localhost:9000 (Cerebro),并连接到http://docker_elasticsearch_1:9200 ,以获得UIs。你可以使用你喜欢的任何一种。

结构

├── Makefile
├── cmd
│   └── elastic
│       └── main.go
├── docker
│   ├── docker-compose.yaml
│   └── tmp
│       └── elasticsearch
└── internal
    ├── pkg
    │   ├── domain
    │   │   └── error.go
    │   └── storage
    │       ├── elasticsearch
    │       │   ├── elasticsearch.go
    │       │   └── post_storage.go
    │       └── post_storer.go
    └── post
        ├── handler.go
        ├── request.go
        ├── response.go
        └── service.go

文件

制作文件

.PHONY: docker-up
docker-up:
	docker-compose -f docker/docker-compose.yaml up

.PHONY: docker-down
docker-down:
	docker-compose -f docker/docker-compose.yaml down
	docker system prune --volumes --force

.PHONY: up
up:
	go run -race cmd/elastic/main.go

docker-compose.yaml

version: "3.7"

services:

  elasticsearch:
    image: "docker.elastic.co/elasticsearch/elasticsearch:7.12.1"
    environment:
      discovery.type: "single-node"
      cluster.name: "cluster-1"
      node.name: "node-1"
    ports:
      - "9200:9200"
    volumes:
      - "./tmp/elasticsearch/data:/usr/share/elasticsearch/data"

  elastichq:
    image: "elastichq/elasticsearch-hq"
    ports:
      - "5000:5000"
    depends_on:
      - "elasticsearch"

  cerebro:
    image: "lmenezes/cerebro"
    ports:
      - "9000:9000"
    depends_on:
      - "elasticsearch"

main.go

package main

import (
	"log"
	"net/http"

	"github.com/you/elastic/internal/pkg/storage/elasticsearch"
	"github.com/you/elastic/internal/post"

	"github.com/julienschmidt/httprouter"
)

func main() {
	// Bootstrap elasticsearch.
	elastic, err := elasticsearch.New([]string{"http://0.0.0.0:9200"})
	if err != nil {
		log.Fatalln(err)
	}
	if err := elastic.CreateIndex("post"); err != nil {
		log.Fatalln(err)
	}

	// Bootstrap storage.
	storage, err := elasticsearch.NewPostStorage(*elastic)
	if err != nil {
		log.Fatalln(err)
	}

	// Bootstrap API.
	postAPI := post.New(storage)

	// Bootstrap HTTP router.
	router := httprouter.New()
	router.HandlerFunc("POST", "/api/v1/posts", postAPI.Create)
	router.HandlerFunc("PATCH", "/api/v1/posts/:id", postAPI.Update)
	router.HandlerFunc("DELETE", "/api/v1/posts/:id", postAPI.Delete)
	router.HandlerFunc("GET", "/api/v1/posts/:id", postAPI.Find)

	// Start HTTP server.
	log.Fatalln(http.ListenAndServe(":3000", router))
}

error.go

package domain

import "errors"

var (
	ErrNotFound = errors.New("not found")
	ErrConflict = errors.New("conflict")
)

elasticsearch.go

package elasticsearch

import (
	"fmt"

	"github.com/elastic/go-elasticsearch/v7"
)

type ElasticSearch struct {
	client *elasticsearch.Client
	index  string
	alias  string
}

func New(addresses []string) (*ElasticSearch, error) {
	cfg := elasticsearch.Config{
		Addresses: addresses,
	}

	client, err := elasticsearch.NewClient(cfg)
	if err != nil {
		return nil, err
	}

	return &ElasticSearch{
		client: client,
	}, nil
}

func (e *ElasticSearch) CreateIndex(index string) error {
	e.index = index
	e.alias = index + "_alias"

	res, err := e.client.Indices.Exists([]string{e.index})
	if err != nil {
		return fmt.Errorf("cannot check index existence: %w", err)
	}
	if res.StatusCode == 200 {
		return nil
	}
	if res.StatusCode != 404 {
		return fmt.Errorf("error in index existence response: %s", res.String())
	}

	res, err = e.client.Indices.Create(e.index)
	if err != nil {
		return fmt.Errorf("cannot create index: %w", err)
	}
	if res.IsError() {
		return fmt.Errorf("error in index creation response: %s", res.String())
	}

	res, err = e.client.Indices.PutAlias([]string{e.index}, e.alias)
	if err != nil {
		return fmt.Errorf("cannot create index alias: %w", err)
	}
	if res.IsError() {
		return fmt.Errorf("error in index alias creation response: %s", res.String())
	}

	return nil
}

// document represents a single document in Get API response body.
type document struct {
	Source interface{} `json:"_source"`
}

post_storage.go

package elasticsearch

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/you/elastic/internal/pkg/domain"
	"github.com/you/elastic/internal/pkg/storage"

	"github.com/elastic/go-elasticsearch/v7/esapi"
)

var _ storage.PostStorer = PostStorage{}

type PostStorage struct {
	elastic ElasticSearch
	timeout time.Duration
}

func NewPostStorage(elastic ElasticSearch) (PostStorage, error) {
	return PostStorage{
		elastic: elastic,
		timeout: time.Second * 10,
	}, nil
}

func (p PostStorage) Insert(ctx context.Context, post storage.Post) error {
	bdy, err := json.Marshal(post)
	if err != nil {
		return fmt.Errorf("insert: marshall: %w", err)
	}

	// res, err := p.elastic.client.Create()
	req := esapi.CreateRequest{
		Index:      p.elastic.alias,
		DocumentID: post.ID,
		Body:       bytes.NewReader(bdy),
	}

	ctx, cancel := context.WithTimeout(ctx, p.timeout)
	defer cancel()

	res, err := req.Do(ctx, p.elastic.client)
	if err != nil {
		return fmt.Errorf("insert: request: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode == 409 {
		return domain.ErrConflict
	}

	if res.IsError() {
		return fmt.Errorf("insert: response: %s", res.String())
	}

	return nil
}

func (p PostStorage) Update(ctx context.Context, post storage.Post) error {
	bdy, err := json.Marshal(post)
	if err != nil {
		return fmt.Errorf("update: marshall: %w", err)
	}

	// res, err := p.elastic.client.Update()
	req := esapi.UpdateRequest{
		Index:      p.elastic.alias,
		DocumentID: post.ID,
		Body:       bytes.NewReader([]byte(fmt.Sprintf(`{"doc":%s}`, bdy))),
	}

	ctx, cancel := context.WithTimeout(ctx, p.timeout)
	defer cancel()

	res, err := req.Do(ctx, p.elastic.client)
	if err != nil {
		return fmt.Errorf("update: request: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode == 404 {
		return domain.ErrNotFound
	}

	if res.IsError() {
		return fmt.Errorf("update: response: %s", res.String())
	}

	return nil
}

func (p PostStorage) Delete(ctx context.Context, id string) error {
	// res, err := p.elastic.client.Delete()
	req := esapi.DeleteRequest{
		Index:      p.elastic.alias,
		DocumentID: id,
	}

	ctx, cancel := context.WithTimeout(ctx, p.timeout)
	defer cancel()

	res, err := req.Do(ctx, p.elastic.client)
	if err != nil {
		return fmt.Errorf("delete: request: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode == 404 {
		return domain.ErrNotFound
	}

	if res.IsError() {
		return fmt.Errorf("delete: response: %s", res.String())
	}

	return nil
}

func (p PostStorage) FindOne(ctx context.Context, id string) (storage.Post, error) {
	// res, err := p.elastic.client.Get()
	req := esapi.GetRequest{
		Index:      p.elastic.alias,
		DocumentID: id,
	}

	ctx, cancel := context.WithTimeout(ctx, p.timeout)
	defer cancel()

	res, err := req.Do(ctx, p.elastic.client)
	if err != nil {
		return storage.Post{}, fmt.Errorf("find one: request: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode == 404 {
		return storage.Post{}, domain.ErrNotFound
	}

	if res.IsError() {
		return storage.Post{}, fmt.Errorf("find one: response: %s", res.String())
	}

	var (
		post storage.Post
		body document
	)
	body.Source = &post

	if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
		return storage.Post{}, fmt.Errorf("find one: decode: %w", err)
	}

	return post, nil
}

post_storer.go

package storage

import (
	"context"
	"time"
)

type PostStorer interface {
	Insert(ctx context.Context, post Post) error
	Update(ctx context.Context, post Post) error
	Delete(ctx context.Context, id string) error
	FindOne(ctx context.Context, id string) (Post, error)
}

type Post struct {
	ID        string     `json:"id"`
	Title     string     `json:"title"`
	Text      string     `json:"text"`
	Tags      []string   `json:"tags"`
	CreatedAt *time.Time `json:"created_at,omitempty"`
}

handler.go

package post

import (
	"encoding/json"
	"log"
	"net/http"

	"github.com/you/elastic/internal/pkg/domain"
	"github.com/you/elastic/internal/pkg/storage"

	"github.com/julienschmidt/httprouter"
)

type Handler struct {
	service service
}

func New(storage storage.PostStorer) Handler {
	return Handler{
		service: service{storage: storage},
	}
}

// POST /api/v1/posts
func (h Handler) Create(w http.ResponseWriter, r *http.Request) {
	var req createRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		log.Println(err)
		return
	}

	res, err := h.service.create(r.Context(), req)
	if err != nil {
		switch err {
		case domain.ErrConflict:
			w.WriteHeader(http.StatusConflict)
		default:
			w.WriteHeader(http.StatusInternalServerError)
			log.Println(err)
		}
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	bdy, _ := json.Marshal(res)
	_, _ = w.Write(bdy)
}

// PATCH /api/v1/posts/:id
func (h Handler) Update(w http.ResponseWriter, r *http.Request) {
	var req updateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		log.Println(err)
		return
	}

	req.ID = httprouter.ParamsFromContext(r.Context()).ByName("id")

	if err := h.service.update(r.Context(), req); err != nil {
		switch err {
		case domain.ErrNotFound:
			w.WriteHeader(http.StatusNotFound)
		default:
			w.WriteHeader(http.StatusInternalServerError)
			log.Println(err)
		}
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// DELETE /api/v1/posts/:id
func (h Handler) Delete(w http.ResponseWriter, r *http.Request) {
	id := httprouter.ParamsFromContext(r.Context()).ByName("id")

	if err := h.service.delete(r.Context(), deleteRequest{ID: id}); err != nil {
		switch err {
		case domain.ErrNotFound:
			w.WriteHeader(http.StatusNotFound)
		default:
			w.WriteHeader(http.StatusInternalServerError)
			log.Println(err)
		}
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// GET /api/v1/posts/:id
func (h Handler) Find(w http.ResponseWriter, r *http.Request) {
	id := httprouter.ParamsFromContext(r.Context()).ByName("id")

	res, err := h.service.find(r.Context(), findRequest{ID: id})
	if err != nil {
		switch err {
		case domain.ErrNotFound:
			w.WriteHeader(http.StatusNotFound)
		default:
			w.WriteHeader(http.StatusInternalServerError)
			log.Println(err)
		}
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	bdy, _ := json.Marshal(res)
	_, _ = w.Write(bdy)
}

请求.go

package post

type createRequest struct {
	Title string   `json:"title"`
	Text  string   `json:"text"`
	Tags  []string `json:"tags"`
}

type updateRequest struct {
	ID string

	Title string   `json:"title"`
	Text  string   `json:"text"`
	Tags  []string `json:"tags"`
}

type deleteRequest struct {
	ID string
}

type findRequest struct {
	ID string
}

响应.go

package post

import "time"

type createResponse struct {
	ID string `json:"id"`
}

type findResponse struct {
	ID        string    `json:"id"`
	Title     string    `json:"title"`
	Text      string    `json:"text"`
	Tags      []string  `json:"tags"`
	CreatedAt time.Time `json:"created_at"`
}

service.go

package post

import (
	"context"
	"time"

	"github.com/you/elastic/internal/pkg/storage"

	"github.com/google/uuid"
)

type service struct {
	storage storage.PostStorer
}

func (s service) create(ctx context.Context, req createRequest) (createResponse, error) {
	id := uuid.New().String()
	cr := time.Now().UTC()

	doc := storage.Post{
		ID:        id,
		Title:     req.Title,
		Text:      req.Text,
		Tags:      req.Tags,
		CreatedAt: &cr,
	}

	if err := s.storage.Insert(ctx, doc); err != nil {
		return createResponse{}, err
	}

	return createResponse{ID: id}, nil
}

func (s service) update(ctx context.Context, req updateRequest) error {
	doc := storage.Post{
		ID:    req.ID,
		Title: req.Title,
		Text:  req.Text,
		Tags:  req.Tags,
	}

	if err := s.storage.Update(ctx, doc); err != nil {
		return err
	}

	return nil
}

func (s service) delete(ctx context.Context, req deleteRequest) error {
	if err := s.storage.Delete(ctx, req.ID); err != nil {
		return err
	}

	return nil
}

func (s service) find(ctx context.Context, req findRequest) (findResponse, error) {
	post, err := s.storage.FindOne(ctx, req.ID)
	if err != nil {
		return findResponse{}, err
	}

	return findResponse{
		ID:        post.ID,
		Title:     post.Title,
		Text:      post.Text,
		Tags:      post.Tags,
		CreatedAt: *post.CreatedAt,
	}, nil
}

测试

curl --request POST 'http://localhost:3000/api/v1/posts' \
--data-raw '{
    "title": "test title",
    "text": "test text",
    "tags": ["tag"]
}'

curl --request PATCH 'http://localhost:3000/api/v1/posts/{id}' \
--data-raw '{
    "title": "test title x",
    "text": "test text x",
    "tags": ["tag x"]
}'

curl --request DELETE 'http://localhost:3000/api/v1/posts/{id}'

curl --request GET 'http://localhost:3000/api/v1/posts/{id}'