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 命令进行查询:
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 协议》,转载必须注明作者和本文链接
es都上了 :joy:
观望下
怎么感觉这个文章有问题呢,有些参数都对应不上