Improve the ES search module to query es like a database with complete small cases

Writing requirements written to the search module in the previous period of time, with more and more conditions, more and more complex combination of various conditions, and poor maintenance in the later period, so improved the previous search writing, wrote a small case in spare time, and discussed with you.

Complete case

github.com/zxr615/go-escase

  • Based on es7.14, using ik_smart participle

  • Use olivere/elastic go-es extension

  • Use gin routing

  • Batch Generation test data

  • Batch Import Data to es case

  • v1 code before improvement & V2 code after improvement

Request Structure

SearchRequest Search Request Structure

// SearchRequest Search Request Structure
type SearchRequest struct {
    Keyword    string `form:"keyword"`                                // Key word
    CategoryId uint8  `form:"category_id"`                            // classification
    Sort       uint8  `form:"sort" binding:"omitempty,oneof=1 2 3"`   // Sort 1 = Views; 2 = Collections; 3 = Comments;
    IsSolve    uint8  `form:"is_solve" binding:"omitempty,oneof=1 2"` // Is it resolved
    Page       int    `form:"page,default=1"`                         // The number of pages
    PageSize   int    `form:"page_size,default=10"`                   // Transactions per page
}

Before improvement

Search() article search is almost the same code as Recommend() article recommendation, except that the conditions are different, there is too much duplicate code, and it is not easy to maintain.

Example: Search() processes search requests

func (a ArticleV1) Search(c *gin.Context) {
    req := new(model.SearchRequest)
    if err := c.ShouldBind(req); err != nil {
        c.JSON(400, err.Error())
        return
    }

    // Build Search
    builder := es.Client.Search().Index(model.ArticleEsAlias)

    bq := elastic.NewBoolQuery()
    // Title
    if req.Keyword != "" {
        builder.Query(bq.Must(elastic.NewMatchQuery("title", req.Keyword)))
    }

    // classification
    if req.CategoryId != 0 {
        builder.Query(bq.Filter(elastic.NewTermQuery("category_id", req.CategoryId)))
    }

    // Is it resolved
    if req.IsSolve != 0 {
        builder.Query(bq.Filter(elastic.NewTermQuery("is_solve", req.IsSolve)))
    }

    // sort
    switch req.Sort {
    case SortBrowseDesc:
        builder.Sort("brows_num", false)
    case SortUpvoteDesc:
        builder.Sort("upvote_num", false)
    case SortCollectDesc:
        builder.Sort("collect_num", false)
    default:
        builder.Sort("created_at", false)
    }

    // paging
    from := (req.Page - 1) * req.PageSize
    // Specify Query Fields
    include := []string{"id", "category_id", "title", "brows_num", "collect_num", "upvote_num", "is_recommend", "is_solve", "created_at"}
    builder.
        FetchSourceContext(
            elastic.NewFetchSourceContext(true).Include(include...),
        ).
        From(from).
        Size(req.PageSize)

    // Execute Query
    do, err := builder.Do(context.Background())
    if err != nil {
        c.JSON(500, err.Error())
        return
    }

    // Get the number matched
    total := do.TotalHits()

    // Serialized data
    list := make([]model.SearchResponse, len(do.Hits.Hits))
    for i, raw := range do.Hits.Hits {
        tmpArticle := model.SearchResponse{}
        if err := json.Unmarshal(raw.Source, &tmpArticle); err != nil {
            log.Println(err)
        }

        list[i] = tmpArticle
    }

    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "data": gin.H{
            "total": total,
            "list":  list,
        },
    })
    return
}

Request a test

curl GET '127.0.0.1:8080/article/v1/search?keyword=Fennel beans&page=1&page_size=2&sort=1'

Click here to see the results returned

{
    "code": 200,
    "data": {
        "list": [
            {
                "id": 8912,
                "category_id": 238,
                "title": "Fennel beans on the bill; funny,",
                "brows_num": 253,
                "collect_num": 34,
                "upvote_num": 203,
                "is_recommend": 2,
                "is_solve": 1,
                "created_at": "2021-02-01 15:19:36"
            }
             ...
        ],
        "total": 157
    }
}

Recommend() Processing recommendation requests

func (a ArticleV1) Recommend(c *gin.Context) {}

  // Recommend Article Recommendation
func (a ArticleV1) Recommend(c *gin.Context) {
    // Build Search
    builder := es.Client.Search().Index(model.ArticleEsAlias)

    bq := elastic.NewBoolQuery()

    builder.Query(bq.Filter(
        // Recommended Articles
        elastic.NewTermQuery("category_id", model.ArticleIsRecommendYes),
        // resolved
        elastic.NewTermQuery("is_solve", model.ArticleIsSolveYes),
    ))

    // Browse sorting
    builder.Sort("brows_num", false)

    do, err := builder.From(0).Size(10).Do(context.Background())
    if err != nil {
        return
    }

    // Serialized data
    ...

Improved

Look first at the results

It is convenient to separate all the conditions of a query and query es like a database. You only need to combine the required conditions to get the desired results.

// Search Article Search
func (a ArticleV2) Search(c *gin.Context) {
    req := new(model.SearchRequest)
    if err := c.ShouldBind(req); err != nil {
        c.JSON(400, err.Error())
        return
    }

  // Query by adding conditions as easily as by looking up a database
    list, total, err := service.NewArticle().
        WhereKeyword(req.Keyword).
        WhereCategoryId(req.CategoryId).
        WhereIsSolve(req.IsSolve).
        Sort(req.Sort).
        Paginate(req.Page, req.PageSize).
        DecodeSearch()

    if err != nil {
        c.JSON(400, err.Error())
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "data": gin.H{
            "total": total,
            "list":  list,
        },
    })
    return
}

// Recommend Article Recommendation
func (a ArticleV2) Recommend(c *gin.Context) {
  // Query by adding conditions as easily as by looking up a database
    list, _, err := service.NewArticle().
        WhereCategoryId(model.ArticleIsRecommendYes).
        WhereIsSolve(model.ArticleIsSolveYes).
        OrderByDesc("brows_num").
        PageSize(5).
        DecodeRecommend()

    if err != nil {
        c.JSON(400, err.Error())
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "data": gin.H{
            "total": len(list),
            "list":  list,
        },
    })
    return
}

How do I do that?

Since only the conditions are different, assemble the required conditions in a combined manner before executing the query

internal/service/article.go

In this case, the must filter sort condition of es is involved, so we'll first construct a struct to hold the condition to be combined

type article struct {
    must   []elastic.Query
    filter []elastic.Query
    sort   []elastic.Sorter
    from   int
    size   int
}

Constructor

func NewArticle() *article {
    return &article{
        must:   make([]elastic.Query, 0),
        filter: make([]elastic.Query, 0),
        sort:   make([]elastic.Sorter, 0),
        from:   0,
        size:   10,
    }
}

Method of adding condition of combination

I used the AddKeyword() WithKeyword method name at first, but I didn't feel very good about it. Then I came to think of a quick way to query criteria in Laravel, such as where Id(1), which generates sql:xxx where id = 1, so I named it like Laravel did.

// WhereKeyword keywords
func (a article) WhereKeyword(keyword string) article {
    if keyword != "" {
        a.must = append(a.must, elastic.NewMatchQuery("title", keyword))
    }
    return a
}

// WhereCategoryId Classification
func (a article) WhereCategoryId(categoryId uint8) article {
    if categoryId != 0 {
        a.filter = append(a.filter, elastic.NewTermQuery("category_id", categoryId))
    }
    return a
}

// WhereIsSolve Resolved
func (a article) WhereIsSolve(isSolve uint8) article {
    if isSolve != 0 {
        a.filter = append(a.filter, elastic.NewTermQuery("is_solve", isSolve))
    }
    return a
}

// Sort Sorting
func (a article) Sort(sort uint8) article {
    switch sort {
    case SortBrowseDesc:
        return a.OrderByDesc("brows_num")
    case SortUpvoteDesc:
        return a.OrderByDesc("upvote_num")
    case SortCollectDesc:
        return a.OrderByDesc("collect_num")
    }
    return a
}

// OrderByDesc Sorts Reversely by Field
func (a article) OrderByDesc(field string) article {
    a.sort = append(a.sort, elastic.SortInfo{Field: field, Ascending: false})
    return a
}

// OrderByAsc Sorts Positive Order by Field
func (a article) OrderByAsc(field string) article {
    a.sort = append(a.sort, elastic.SortInfo{Field: field, Ascending: true})
    return a
}

// Paginate Paging
// Page current page number
// Number of pageSize per page
func (a article) Paginate(page, pageSize int) article {
    a.from = (page - 1) * pageSize
    a.size = pageSize
    return a
}

Now that you've built all the conditions you need, now that you have the conditions, you need to perform the final search

// Searcher Execute Query
func (a article) Searcher(include ...interface{}) ([]json.RawMessage, int64, error) {
    builder := es.Client.Search().Index(model.ArticleEsAlias)

    // Field of Query
    includeKeys := make([]string, 0)
    if len(include) > 0 {
        includeKeys = structer.Keys(include[0])
    }

    // Build Query
    builder.Query(
        // Build bool query condition
        elastic.NewBoolQuery().Must(a.must...).Filter(a.filter...),
    )

    // Execute Query
    do, err := builder.
        FetchSourceContext(elastic.NewFetchSourceContext(true).Include(includeKeys...)).
        From(a.from).
        Size(a.size).
        SortBy(a.sort...).
        Do(context.Background())

    if err != nil {
        return nil, 0, err
    }

    total := do.TotalHits()
    list := make([]json.RawMessage, len(do.Hits.Hits))
    for i, hit := range do.Hits.Hits {
        list[i] = hit.Source
    }

    return list, total, nil
}

Here you can get results by combining different conditions for different interface conditions using the pattern of the combination conditions built above

list, _, err := service.NewArticle().
        WhereCategoryId(model.ArticleIsRecommendYes).
        WhereIsSolve(model.ArticleIsSolveYes).
        OrderByDesc("brows_num").
        PageSize(5).
        Searcher()

But there's still a problem at this point, where the list returns an array of []json.RawMessage, and each record is a JSON string. Sometimes we need to further process the queried data, at this point we need to serialize the content into a corresponding struct, and we can write a DecodeXxx()Method to serialize the corresponding struct for different purposes

Recommend the structure of the result so that it returns a structure that is available for []model.RecommendResponse, instead of calling Searcher() directly and DecodeRecommend() when called.

func (a article) DecodeRecommend() ([]model.RecommendResponse, int64, error) {
    rawList, total, err := a.Searcher(new(model.RecommendResponse))
    if err != nil {
        return nil, total, err
    }

    list := make([]model.RecommendResponse, len(rawList))
    for i, raw := range rawList {
        tmp := model.RecommendResponse{}
        if err := json.Unmarshal(raw, &tmp); err != nil {
            log.Println(err)
            continue
        }

        list[i] = tmp
    }

    return list, total, nil
}

summary

Now that you've finished the simple encapsulation of your search, the complete case is ready to go github.com/zxr615/go-escase Communications

Keywords: Go ElasticSearch elastic

Added by djheru on Mon, 13 Sep 2021 19:22:39 +0300