改進 es 搜尋模組,像查詢資料庫一樣查詢 es,附完整小案例

zxr615發表於2021-09-14

前段時間寫需求寫到搜尋模組,伴著條件越來越多,各種條件組合的也越來越複雜,後期不好維護,所以改進了一下以前的搜尋寫法,業餘時間寫了一個小案例,和大家一起探討探討。

完整案例

github.com/zxr615/go-escase

  • 基於 es7.14 開發,使用 ik_smart 分詞

  • 使用 olivere/elastic go-es擴充套件

  • 使用到 gin 路由

  • 批次生成 測試資料

  • 批次匯入資料到es case

  • v1改進前程式碼&v2 改進後程式碼

請求結構

SearchRequest 搜尋請求結構

// SearchRequest 搜尋請求結構
type SearchRequest struct {
    Keyword    string `form:"keyword"`                                // 關鍵詞
    CategoryId uint8  `form:"category_id"`                            // 分類
    Sort       uint8  `form:"sort" binding:"omitempty,oneof=1 2 3"`   // 排序 1=瀏覽量;2=收藏;3=點贊;
    IsSolve    uint8  `form:"is_solve" binding:"omitempty,oneof=1 2"` // 是否解決
    Page       int    `form:"page,default=1"`                         // 頁數
    PageSize   int    `form:"page_size,default=10"`                   // 每頁數量
}

改進前

Search() 文章搜尋和 Recommend() 文章推薦的程式碼幾乎一樣,只是條件有所不同,重複程式碼太多,也不好維護。

例:Search() 處理搜尋請求

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

    // 構建搜尋
    builder := es.Client.Search().Index(model.ArticleEsAlias)

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

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

    // 是否解決
    if req.IsSolve != 0 {
        builder.Query(bq.Filter(elastic.NewTermQuery("is_solve", req.IsSolve)))
    }

    // 排序
    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)
    }

    // 分頁
    from := (req.Page - 1) * req.PageSize
    // 指定查詢欄位
    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)

    // 執行查詢
    do, err := builder.Do(context.Background())
    if err != nil {
        c.JSON(500, err.Error())
        return
    }

    // 獲取匹配到的數量
    total := do.TotalHits()

    // 序列化資料
    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
}

請求測試一下

curl GET '127.0.0.1:8080/article/v1/search?keyword=茴香豆&page=1&page_size=2&sort=1'

點這裡檢視返回結果

{
    "code": 200,
    "data": {
        "list": [
            {
                "id": 8912,
                "category_id": 238,
                "title": "茴香豆上賬;又好笑,",
                "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() 處理推薦請求

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

  // Recommend 文章推薦
func (a ArticleV1) Recommend(c *gin.Context) {
    // 構建搜尋
    builder := es.Client.Search().Index(model.ArticleEsAlias)

    bq := elastic.NewBoolQuery()

    builder.Query(bq.Filter(
        // 推薦文章
        elastic.NewTermQuery("category_id", model.ArticleIsRecommendYes),
        // 已解決
        elastic.NewTermQuery("is_solve", model.ArticleIsSolveYes),
    ))

    // 瀏覽量排序
    builder.Sort("brows_num", false)

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

    // 序列化資料
    ...

改進後

先看結果

把所有查詢的條件都拆分開來,像查詢資料庫一樣查詢 es,多方便呢,只需要組合需要的條件就可以得到想要的結果

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

  // 像查資料庫一樣方便的新增條件即可查詢
    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 文章推薦
func (a ArticleV2) Recommend(c *gin.Context) {
  // 像查資料庫一樣方便的新增條件即可查詢
    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
}

怎麼做到的呢

既然只是條件不同,那用組合的方式,把需要的條件組裝起來再執行查詢

internal/service/article.go

本案例中涉及到了 esmust filter sort 條件,所以我們要先構建一個 struct 儲存要組合的條件

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

建構函式

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

新增組合的條件的方法

剛開始用過 AddKeyword() WithKeyword 方法名,但都感覺不太好,後來想到 Laravel 中有快捷的查詢條件的方法,如whereId(1) 會生成 sql :xxx where id = 1 所以就仿照了 Laravel 的方式命名了

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

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

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

// Sort 排序
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 透過欄位倒序排序
func (a article) OrderByDesc(field string) article {
    a.sort = append(a.sort, elastic.SortInfo{Field: field, Ascending: false})
    return a
}

// OrderByAsc 透過欄位正序排序
func (a article) OrderByAsc(field string) article {
    a.sort = append(a.sort, elastic.SortInfo{Field: field, Ascending: true})
    return a
}

// Paginate 分頁
// page 當前頁碼
// pageSize 每頁數量
func (a article) Paginate(page, pageSize int) article {
    a.from = (page - 1) * pageSize
    a.size = pageSize
    return a
}

到這裡已經把需要的全部條件已經構建好了,現在條件有了,需要執行最後的搜尋了

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

    // 查詢的欄位
    includeKeys := make([]string, 0)
    if len(include) > 0 {
        includeKeys = structer.Keys(include[0])
    }

    // 構建查詢
    builder.Query(
        // 構建 bool query 條件
        elastic.NewBoolQuery().Must(a.must...).Filter(a.filter...),
    )

    // 執行查詢
    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
}

到這裡就可以使用上面構建好的組合條件的模式針對不同的介面條件組合不同的條件獲取結果

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

但這個時候還有個問題,這裡的 list 返回的是一個 []json.RawMessage 的陣列,每一條紀錄都是一條json字串,有時候我們需要對查詢出來的資料進行進一步的處理,這個時候就需要把內容序列化成對應的 struct 了,我們可以再寫一個DecodeXxxx() 的方法,來針對不同的用途序列化成對應的 struct

推薦結果的結構體,這樣返回一個 []model.RecommendResponse 可用的結構體,呼叫時就不直接呼叫 Searcher() 呼叫 DecodeRecommend() 就好了。

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
}

總結

到此已經完成了搜尋的簡單封裝,完整的案例可以去 github.com/zxr615/go-escase 交流交流

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章