Go-kratos 框架商城微服务实战之商品服务 (十一) Elasticsearch 商品搜索

大家好,今天咱们继续写商品服务中的商品查询接口,主要是引入的 Elasticsearch 搜索服务,通过输入关键词来进行商品搜索,废话少说咱们开始

注:竖排 … 代码省略,为了保持文章的篇幅简洁,我会将一些不必要的代码使用竖排的 . 来代替,你在复制本文代码块的时候,切记不要将 . 也一同复制进去。

⚠️ ⚠️ ⚠️ 接下来新增或修改的代码, wire 注入的文件中需要修改的代码,都不会再本文中提及了。例如 biz、data、service 层的修改,自己编写的过程中,千万不要忘记 wire 注入,更不要忘记,执行 make wire 命令,重新生成项目的 wire 文件。具体使用方法可参考 kratos 官方文档 ⚠️ ⚠️ ⚠️

准备工作

增加配置

不特殊强调的话,此篇文章修改的代码都是 goods 服务下的代码

  • config 目录下新增如下代码:
...

data:
 ...
  elastic:
    addr: http://127.0.0.1:9200 // 这个位置是你链接 es 的地址

...
  • conf 目录下的 conf.proto 文件修改
// 修改之后别忘记执行 make config 命令

syntax = "proto3";

...

message Data {
 ...

  message Elastic {
    string addr = 1;
  }

  Database database = 1;
  Redis redis = 2;
  Elastic elastic = 3;
}


...
  • data 目录下的 data.go 文件新增 es 链接服务
package data

import (

    ...

    "github.com/olivere/elastic/v7" // package 别忘记 mod

    ...
)

// ProviderSet is data providers.
var ProviderSet = wire.NewSet(
    NewData,
    NewDB, NewTransaction, NewRedis, NewElasticsearch, // 别忘记把链接方法引入

    ...

    NewEsGoodsRepo, // 别忘记把初始化es repo 方法引入
)

type Data struct {
    db       *gorm.DB
    rdb      *redis.Client
    EsClient *elastic.Client
}

...

// NewData .
func NewData(c *conf.Data, logger log.Logger, db *gorm.DB, rdb *redis.Client, es *elastic.Client) (*Data, func(), error) {
    cleanup := func() {
        log.NewHelper(logger).Info("closing the data resources")
    }
    return &Data{
        db:       db,
        rdb:      rdb,
        EsClient: es,
    }, cleanup, nil
}

...

func NewElasticsearch(c *conf.Data) *elastic.Client {
    es, err := elastic.NewClient(elastic.SetURL(c.Elastic.Addr), elastic.SetSniff(false),
        elastic.SetTraceLog(slog.New(os.Stdout, "shop", slog.LstdFlags)))
    if err != nil {
        panic(err)
    }

    return es
}
  • data 目录下新增 es_goods.go 文件

根据商品的功能,我们来设计我们的mapping结构,创建对应的索引。这里我把 es__goods 当做一个全新的表 repo ,然后进行常规操作,新建 esGoodsRepo ,把它注入到 data.go 的 wire 里面,前面的代码已经注入了。 ES package 用的是 olivere/elastic

package data

import (
    "context"
    "encoding/json"
    "goods/internal/biz"
    "goods/internal/domain"
    "strconv"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/olivere/elastic/v7"
)

// GetIndexName 设计商品的索引 goods
func (esGoodsRepo) GetIndexName() string {
    return "goods"
}

// GetMapping 设计商品的 mapping 结构
func (esGoodsRepo) GetMapping() string {
    goodsMapping := `
    {
    "mappings": {
        "properties": {
            "id": {
                "type": "integer"
            },
            "brands_id": {
                "type": "integer"
            },
            "category_id": {
                "type": "integer"
            },
            "type_id": {
                "type": "integer"
            },
            "click_num": {
                "type": "integer"
            },
            "fav_num": {
                "type": "integer"
            },
            "is_hot": {
                "type": "boolean"
            },
            "is_new": {
                "type": "boolean"
            },
            "market_price": {
                "type": "integer"
            },
            "name": {
                "type": "text",
                "analyzer": "ik_max_word"
            },
            "brand_name": {
                "type": "keyword",
                "index": false,
                "dec_values": false,
            },
            "category_name": {
                "type": "keyword",
                "index": false,
                "dec_values": false,
            },
            "type_name": {
                "type": "keyword",
                "index": false,
                "dec_values": false,
            },
            "goods_brief": {
                "type": "text",
                "analyzer": "ik_max_word"
            },
            "on_sale": {
                "type": "boolean"
            },
            "ship_free": {
                "type": "boolean"
            },
            "shop_price": {
                "type": "integer"
            },
            "sold_num": {
                "type": "integer"
            },
            "sku": {
                "type": "nested",
                "sku_id": {
                    "type": "integer",
                },
                "sku_name": {
                    "type": "text",
                    "analyzer": "ik_max_word"
                },
                "sku_price": {
                    "type": "integer",
                },
            }
        }
    }
}`
    return goodsMapping
}

type esGoodsRepo struct {
    data *Data
    log  *log.Helper
}

// NewEsGoodsRepo .
func NewEsGoodsRepo(data *Data, logger log.Logger) biz.EsGoodsRepo {
    return &esGoodsRepo{
        data: data,
        log:  log.NewHelper(logger),
    }
}

编写接口

实现接口

加入商品的部分信息到 es 服务中,是在商品创建、更新、删除的时候进行操作的,由于咱们前面已经写过了商品的创建,这里只需要在创建成功之后,进一步创建 es 数据就行了。

  • goods.proto 文件 新增查询方法

    修改之后别忘记执行 make api 命令

syntax = "proto3";

...


service Goods {

    ...

  // 商品接口
  rpc CreateGoods(CreateGoodsRequest) returns (CreateGoodsResponse);

  rpc GoodsList(GoodsFilterRequest) returns(GoodsListResponse);

}


// 根据前期设计的 mapping 结构,来构建查询的时候所需的字段
// 当然不是说 mapping 中没有的就不能进行查询了
message GoodsFilterRequest  {
  string keywords = 1;
  int32 categoryId = 2;
  int32 brandId = 3;
  int64 minPrice = 4;
  int64 maxPrice = 5;
  bool  isHot = 6;
  bool  isNew = 7;
  bool  isTab = 8;
  int64 clickNum = 9;
  int64 soldNum = 10;
  int64 favNum = 11;
  int64 pages = 12;
  int64 pagePerNums = 13;
  int64 id = 14;
}

// 返回的每个商品的具体信息,这里为了演示
// 并没有加入 sku 、brand 等信息,可自行添加
message GoodsInfoResponse {
  int64 id = 1;
  int32 categoryId = 2;
  int32 brandId = 3;
  string name = 4;
  string goodsSn = 5;
  int64 clickNum = 6;
  int64 soldNum = 7;
  int64 favNum = 8;
  int64 marketPrice = 9;
  string goodsBrief = 10;
  string goodsDesc = 11;
  bool shipFree = 12;
  string images = 13;
  repeated string goodsImages = 14;
  bool isNew = 15;
  bool isHot = 16;
  bool onSale = 17;
}

message GoodsListResponse {
  int64 total = 1;
  repeated GoodsInfoResponse list = 2;
}

修改创建商品的方法

  • domain 目录下新增 es_goods.go 文件,来进行 repo 和 api 的交互
package domain

import "github.com/olivere/elastic/v7"

// 构建查询的时候转换的结构
type ESGoodsFilter struct {
    ID          int64
    CategoryID  int32
    BrandsID    int32
    Keywords    string
    OnSale      bool
    ShipFree    bool
    IsNew       bool
    IsHot       bool
    ClickNum    int64
    SoldNum     int64
    FavNum      int64
    MaxPrice    int64
    MinPrice    int64
    Pages       int64
    PagePerNums int64
}

// 构建插入es 的时候所需的结构,json 的意思是,存入到es 中显示的字段名
type ESGoods struct {
    ID           int64   `json:"id"`
    CategoryID   int32   `json:"category_id"`
    CategoryName string  `json:"category_name"`
    BrandsID     int32   `json:"brands_id"`
    BrandName    string  `json:"brand_name"`
    TypeID       int64   `json:"type_id"`
    TypeName     string  `json:"type_name"`
    OnSale       bool    `json:"on_sale"`
    ShipFree     bool    `json:"ship_free"`
    IsNew        bool    `json:"is_new"`
    IsHot        bool    `json:"is_hot"`
    Name         string  `json:"name"`
    GoodsTags    string  `json:"goods_tags"`
    ClickNum     int64   `json:"click_num"`
    SoldNum      int64   `json:"sold_num"`
    FavNum       int64   `json:"fav_num"`
    MarketPrice  int64   `json:"market_price"`
    GoodsBrief   string  `json:"goods_brief"`
    Pages        int64   `json:"pages"`
    PagePerNums  int64   `json:"page_pre_num"`
    Sku          []EsSku `json:"sku"`
}
type EsSku struct {
    SkuID    int64  `json:"sku_id"`
    SkuName  string `json:"sku_name"`
    SkuPrice int64  `json:"sku_price"`
}

// es 公共的查询语法,用过来不同条件拼接查询sql
type EsSearch struct {
    MustQuery    []elastic.Query
    MustNotQuery []elastic.Query
    ShouldQuery  []elastic.Query
    Filters      []elastic.Query
    Sorters      []elastic.Sorter
    Form         int64 // 分页
    Size         int64
}
  • 修改biz/goods.go ,新增插入 es 的逻辑
package biz

import (
    "context"
    "encoding/json"
    "fmt"
    "goods/internal/domain"

    "github.com/go-kratos/kratos/v2/errors"
    "github.com/go-kratos/kratos/v2/log"
)

type GoodsRepo interface {
    CreateGoods(ctx context.Context, goods *domain.Goods) (*domain.Goods, error)
}

type GoodsUsecase struct {
    repo              GoodsRepo

    ...

    esGoodsRepo       EsGoodsRepo // 新增的 es 的repo
    log               *log.Helper
}

func NewGoodsUsecase(repo GoodsRepo, skuRepo GoodsSkuRepo, tx Transaction, gRepo GoodsTypeRepo, cRepo CategoryRepo,
    bRepo BrandRepo, sRepo SpecificationRepo, aRepo GoodsAttrRepo, iRepo InventoryRepo, es EsGoodsRepo, logger log.Logger) *GoodsUsecase {

    return &GoodsUsecase{
        repo:              repo,

        ...

        esGoodsRepo:       es, // 新增的 es 的repo
        log:               log.NewHelper(logger),
    }
}


func (g GoodsUsecase) CreateGoods(ctx context.Context, r *domain.Goods) (*domain.GoodsInfoResponse, error) {
    var (
        err     error
        goods   *domain.Goods
        EsGoods domain.ESGoods
    )

        ...


    err = g.tr.ExecTx(ctx, func(ctx context.Context) error {
        // 更新商品表

        ...


    // 此处为新增,注意因为咱们之前判断品牌、分类等是否存在,由于并没有使用
    // 使用了 _ 进行忽略,需要放开来使用
            // esModel
            {
                EsGoods.Sku = append(EsGoods.Sku, domain.EsSku{
                    SkuID:    skuInfo.ID,
                    SkuName:  skuInfo.SkuName,
                    SkuPrice: skuInfo.Price,
                })
                EsGoods.BrandsID = brand.ID
                EsGoods.BrandName = brand.Name
                EsGoods.CategoryID = cate.ID
                EsGoods.CategoryName = cate.Name
                EsGoods.TypeID = goodsType.ID
                EsGoods.TypeName = goodsType.Name
                EsGoods.Name = goodsType.Name
                EsGoods.ID = goods.ID
                EsGoods.OnSale = goods.OnSale
                EsGoods.ShipFree = goods.ShipFree
                EsGoods.IsNew = goods.IsNew
                EsGoods.IsHot = goods.IsHot
                EsGoods.Name = goods.Name
                EsGoods.GoodsTags = goods.GoodsTags
                EsGoods.ClickNum = goods.ClickNum
                EsGoods.SoldNum = goods.SoldNum
                EsGoods.FavNum = goods.FavNum
                EsGoods.MarketPrice = goods.MarketPrice
                EsGoods.GoodsBrief = goods.GoodsBrief

            }

            // 插入 EsGoods 的方法
            err = g.esGoodsRepo.InsertEsGoods(ctx, EsGoods)
            if err != nil {
                return err
            }
        }
        return nil
    })

    if err != nil {
        return nil, err
    }
    return &domain.GoodsInfoResponse{GoodsID: goods.ID}, nil
}
  • 新增 biz/es_goods.go 文件来满足使用 es repo 的需求
package biz

import (
    "context"
    "goods/internal/domain"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/olivere/elastic/v7"
)

type EsGoodsRepo interface {
    InsertEsGoods(context.Context, domain.ESGoods) error // 存储 es 的方法
}

type EsGoodsUsecase struct {
    repo         GoodsRepo
    esRepo       EsGoodsRepo
    categoryRepo CategoryRepo
    log          *log.Helper
}

func NewEsGoodsUsecase(repo GoodsRepo, es EsGoodsRepo, cRepo CategoryRepo, logger log.Logger) *EsGoodsUsecase {
    return &EsGoodsUsecase{
        repo:         repo, // 商品的repo
        esRepo:       es,   // es 商品的repo
        categoryRepo: cRepo, // 分类的 repo
        log:          log.NewHelper(logger),
    }
}
  • data/es_goods.go 实现 InsertEsGoods 方法
package data

import (
    "context"
    "encoding/json"
    "goods/internal/biz"
    "goods/internal/domain"
    "strconv"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/olivere/elastic/v7"
)

...

func (p esGoodsRepo) InsertEsGoods(ctx context.Context, esModel domain.ESGoods) error {
    // 新建 mapping 和 index
    exists, err := p.data.EsClient.IndexExists(p.GetIndexName()).Do(ctx)
    if err != nil {
        panic(err)
    }
    if !exists {
        _, err = p.data.EsClient.CreateIndex(p.GetIndexName()).BodyString(p.GetMapping()).Do(ctx)
        if err != nil {
            return err
        }
    }

    _, err = p.data.EsClient.Index().Index(p.GetIndexName()).BodyJson(esModel).Id(strconv.Itoa(int(esModel.ID))).Do(ctx)
    if err != nil {
        return err
    }
    return nil
}

测试新增 ES 数据

没错还是通过 BloomRPC 工具进行测试。

可以看到成功插入。访问 es 的服务来验证是否插入成功。

es 访问链接:127.0.0.1:5601/app/dev_tools#/conso...

执行 es 命令进行查询:

GET goods/_search
{
  "query": {
    "match_all": {}
  }
}

看到结果如图:

成功插入,截图中的 id 跟上图的 id 不一致,是因为我不小心执行两遍一样的创建请求。😂 下面的数据图没截出来。

编写查询请求

  • service/goods.go 文件新增请求方法
package service

import (
    "context"
    v1 "goods/api/goods/v1"
    "goods/internal/domain"
)

...


func (g *GoodsService) GoodsList(ctx context.Context, r *v1.GoodsFilterRequest) (*v1.GoodsListResponse, error) {
    // 转换成domain定义的结构体
    goodsFilter := &domain.ESGoodsFilter{
        ID:          r.Id,
        CategoryID:  r.CategoryId,
        BrandsID:    r.BrandId,
        Keywords:    r.Keywords,
        IsNew:       r.IsNew,
        IsHot:       r.IsHot,
        ClickNum:    r.ClickNum,
        SoldNum:     r.SoldNum,
        FavNum:      r.FavNum,
        MaxPrice:    r.MaxPrice,
        MinPrice:    r.MinPrice,
        Pages:       r.Pages,
        PagePerNums: r.PagePerNums,
    }

    // 调用 biz/es_goods.go 下的方法
    result, err := g.esGoods.GoodsList(ctx, goodsFilter)
    if err != nil {
        return nil, err
    }
    response := v1.GoodsListResponse{
        Total: result.Total,
    }
    for _, goods := range result.List {
        res := v1.GoodsInfoResponse{
            Id:          goods.ID,
            CategoryId:  goods.CategoryID,
            BrandId:     goods.BrandsID,
            Name:        goods.Name,
            GoodsSn:     goods.GoodsSn,
            ClickNum:    goods.ClickNum,
            SoldNum:     goods.SoldNum,
            FavNum:      goods.FavNum,
            MarketPrice: goods.MarketPrice,
            GoodsBrief:  goods.GoodsBrief,
            GoodsDesc:   goods.GoodsBrief,
            ShipFree:    goods.ShipFree,
            Images:      goods.GoodsFrontImage,
            GoodsImages: goods.GoodsImages,
            IsNew:       goods.IsNew,
            IsHot:       goods.IsHot,
            OnSale:      goods.OnSale,
        }
        response.List = append(response.List, &res)
    }
    return &response, nil
}
  • biz/es_goods.go 新增查询方法
package biz

import (
    "context"
    "goods/internal/domain"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/olivere/elastic/v7"
)

type EsGoodsRepo interface {
    GoodsList(ctx context.Context, es *domain.EsSearch) ([]int64, int64, error) // repo 的方法
    InsertEsGoods(context.Context, domain.ESGoods) error
}

...


func (g EsGoodsUsecase) GoodsList(ctx context.Context, req *domain.ESGoodsFilter) (*domain.GoodsListResponse, error) {
    // 组织 es 查询条件
    var es domain.EsSearch
    if req.Keywords != "" {
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewMultiMatchQuery(req.Keywords, "name", "goods_brief", "sku.sku_name"))
    }
    if req.IsHot {
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewTermQuery("is_hot", req.IsHot)) // 精确字段查询
    }
    if req.ClickNum > 0 {
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewFieldSort("click_num").Desc()) // 根据某个字段排序
    }
    if req.MinPrice > 0 {
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewRangeQuery("shop_price").Gte(req.MinPrice)) // 区间筛选 gte 大于=
    }
    if req.MaxPrice > 0 {
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewRangeQuery("shop_price").Lte(req.MaxPrice)) // lte 小于=
    }
    if req.BrandsID > 0 {
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewTermQuery("brands_id", req.BrandsID))
    }
    // 通过 category 去查询商品,这里涉及到一个问题,就是商品分类是多级的,所以特殊处理一下
    if req.CategoryID > 0 {
        // 查询分类是否存在
        cate, err := g.categoryRepo.GetCategoryByID(ctx, req.CategoryID)
        if err != nil {
            return nil, err
        }
        categoryIds, err := g.categoryRepo.GetCategoryAll(ctx, cate.Level, req.CategoryID)
        if err != nil {
            return nil, err
        }
        es.ShouldQuery = append(es.ShouldQuery, elastic.NewTermsQuery("category_id", categoryIds...))
    }
    // 分页处理
    switch {
    case req.PagePerNums > 100:
        req.PagePerNums = 100
    case req.PagePerNums <= 0:
        req.PagePerNums = 10
    }
    if req.Pages == 0 {
        req.Pages = 1
    }
    es.Form = (req.Pages - 1) * req.PagePerNums
    es.Size = req.PagePerNums

    // 去 es repo 中查询获得的商品ID
    res := &domain.GoodsListResponse{}
    goodsIds, total, err := g.esRepo.GoodsList(ctx, &es)
    if err != nil {
        return nil, err
    }
    res.Total = total
    if err != nil {
        return nil, err
    }

    // 根据es返回的商品ID 获取详细的商品信息
    goodsList, err := g.repo.GoodsListByIDs(ctx, goodsIds...)
    if err != nil {
        return nil, err
    }
    res.List = goodsList

    // TODO 根据返回的商品信息,查询所有分类、查询所有品牌、查询所有sku 的信息进行组合,这里就不写了

    return res, nil
}

商品分类查询的方法,这里就不贴代码了,无非就是先去 biz/category.go 的 categoryRepo interface 定一个一个接口,然后再去 data 下的 categoryRepo 实现方法然后把 ID 返回来

  • data/es_goods.go 文件下实现 GoodsList 方法
package data

import (
    "context"
    "encoding/json"
    "goods/internal/biz"
    "goods/internal/domain"
    "strconv"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/olivere/elastic/v7"
)

...

func (p esGoodsRepo) GoodsList(ctx context.Context, filter *domain.EsSearch) ([]int64, int64, error) {
    boolQuery := elastic.NewBoolQuery()
    boolQuery.Must(filter.MustQuery...)
    boolQuery.MustNot(filter.MustNotQuery...)
    boolQuery.Should(filter.ShouldQuery...)
    boolQuery.Filter(filter.Filters...)

    result, err := p.data.EsClient.Search().
        Index(p.GetIndexName()).
        Query(boolQuery).
        SortBy(filter.Sorters...).
        From(int(filter.Form)).
        Size(int(filter.Size)).
        Do(ctx)

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

    // 取出来商品ID
    goodsIds := make([]int64, 0)
    for _, value := range result.Hits.Hits {
        goods := domain.ESGoods{}
        _ = json.Unmarshal(value.Source, &goods)
        goodsIds = append(goodsIds, goods.ID)
    }
    return goodsIds, result.Hits.TotalHits.Value, nil

}

  • data/goods.go文件下实现GoodsListByIDs 方法
...


func (g goodsRepo) GoodsListByIDs(c context.Context, ids ...int64) ([]*domain.Goods, error) {
    var l []*Goods
    if err := g.data.DB(c).Where("id IN (?)", ids).Find(&l).Error; err != nil {
        return nil, errors.NotFound("GOODS_NOT_FOUND", "商品不存在")
    }
    var res []*domain.Goods
    for _, item := range l {
        res = append(res, item.ToDomain())
    }
    return res, nil
}

测试查询

这里使用的是 goodsBrief 里面的信息来当关键词构建的请求,其他的效果,请自己进行测试 😂

可以看到结果也出来了。

结束语

本篇只提供了一个商品创建的方法,更新的方法、删除的方法并没有体现,其实原理都一样,处理好自己的逻辑之后,调用 es 不同的命令就行了。
比如新增调用的是 index

_, err = p.data.EsClient.Index().Index(p.GetIndexName()).BodyJson(esModel).Id(strconv.Itoa(int(esModel.ID))).Do(ctx)

更新只需要把 ID 定好调用:update

    _, err = p.data.EsClient.Update().Index(p.GetIndexName()).
        Doc(esModel).Id("自己的ID").Do(ctx)

最近这几天忙成狗,总算是肝出来了 Elasticsearch 进行商品检索,之后准备先不完善商品服务剩余的接口,而是去写订单服务。

这里特别感谢一下一直支持观看此系列的同学,更感谢你们的点赞、分享

感谢您的耐心阅读,动动手指点个赞吧。

本作品采用《CC 协议》,转载必须注明作者和本文链接
微信搜索:上帝喜爱笨人
本帖由系统于 1年前 自动加精
讨论数量: 6

es都上了 :joy:

2年前 评论
Aliliin (楼主) 2年前
janus

观望下

1年前 评论
janus

怎么感觉这个文章有问题呢,有些参数都对应不上

1年前 评论
Aliliin (楼主) 1年前
janus (作者) 1年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!