Go项目(es搜索-2)

文章介绍了如何在Go语言项目中使用olivere/elastic库操作Elasticsearch,包括查询、写入数据和设置mapping。同时讨论了为何只对搜索和过滤字段使用ES,以及在商品服务中如何集成ES来处理商品列表、创建和更新商品等操作,确保数据一致性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

简介

  • 上一篇了解了 es 的基操,但我们是通过 es 提供的 Restful API 操作
  • 集成到我们项目的 web 层也就是要用 go 语言操作这些 API 接口,有官方和第三方的封装好接口的包可以用
  • 我们这里使用 olivere/elastic
    • 这下面有每个封装后接口的源码和 UT,方便了解
      1
    • 查看文档,掌握基操
    • 代码示例:获取数据
      package main
      
      import (
      	"context"
      	"fmt"
      	"github.com/olivere/elastic/v7"
      )
      
      func main() {
      	host := "http://192.168.109.128:9200"
      	// 设置为 false,避免地址被转换,导致连接失败
      	client, err := elastic.NewClient(elastic.SetURL(host), elastic.SetSniff(false))
      	q := elastic.NewMatchQuery("address", "Madison")
      	result, err := client.Search().Index("user").Query(q).Do(context.Background())
      	if err != nil {
      		panic(err)
      	}
      	fmt.Printf("查询到 %d 条结果\n ", result.Hits.TotalHits.Value)
      	// 这里的结果是对应kibana中看到的json格式,也就是es返回给我们的json体
      	for _, value := range result.Hits.Hits {
      		// 查看源码,会发现它提供了解析成json的方法
      		if jsonData, err := value.Source.MarshalJSON(); err == nil {
      			fmt.Println(string(jsonData))
      		} else {
      			panic(err)
      		}
      	}
      }
      
  • 但是在 go 语言中,我们一般转化为 struct 实例操作
    • 定义 struct,打上 json tag 即可
      type Account struct {
      	AccountNumber int32  `json:"account_number"`
      	FirstName     string `json:"firstname"`
      }
      
    • 还记得怎么映射到 struct 吗?
      a := Account{}
      _ = json.Unmarshal(value.Source, &a) // value.Source正好是[]byte,直接解析
      fmt.Println(a)
      
  • 代码示例:写入数据
    import (
    	"context"
    	"fmt"
    	"github.com/olivere/elastic/v7"
    	"log"
    	"os"
    )
    
    type Account struct {
    	AccountNumber int32  `json:"account_number"`
    	FirstName     string `json:"firstname"`
    }
    
    func main() {
    	host := "http://192.168.109.128:9200"
    	// 日志,打印出详细过程
    	logger := log.New(os.Stdout, "shop", log.LstdFlags)
    	// 设置为 false,避免地址被转换,导致连接失败
    	client, err := elastic.NewClient(elastic.SetURL(host), elastic.SetSniff(false), elastic.SetTraceLog(logger))
    
    	account := Account{AccountNumber: 188, FirstName: "RoyAllen"}
    	p1, err := client.Index().Index("gintest").BodyJson(account).Do(context.Background())
    	if err != nil {
    		panic(err)
    	}
    	fmt.Println(p1.Index, p1.Id, p1.Type)
    }
    
  • 代码示例:设置 mapping,主要是配置中文分词器
    type Account struct {
    	AccountNumber int32  `json:"account_number"`
    	FirstName     string `json:"firstname"`
    }
    
    const goodMapping = `
    {
    	"mappings":{
    		"properties":{
    			"name":{
    				"type":"text",
    				"analyzer":"ik_max_word"
    			},
    			"id":{
    				"type":"integer"
    			}
    		}
    	}
    }
    `
    func main() {
    	host := "http://192.168.109.128:9200"
    	// 日志,打印出详细过程
    	logger := log.New(os.Stdout, "shop", log.LstdFlags)
    	// 设置为 false,避免地址被转换,导致连接失败
    	client, err := elastic.NewClient(elastic.SetURL(host), elastic.SetSniff(false), elastic.SetTraceLog(logger))
    	// 创建索引时设置mapping
    	ci, err := client.CreateIndex("goods").BodyString(goodMapping).Do(context.Background())
    	if err != nil {
    		panic(err)
    	}
    	fmt.Println(ci.Acknowledged)
    }
    
  • 分析,集成 es 我们需要完成哪些接口
    • 我们到商品微服务的 proto 文件中看看哪些接口需要用到 es 服务
    • 大致是:GoodsList、CreateGoods、UpdateGoods、DeleteGoods
    • 将 es 集成在 srv 层,放在 MySQL 事务中(增删改),方便同时回滚
    • 但是这样耦合性比较强,后续引入分布式事务后再拆分

handler

  • 在商品微服务 srv 层集成 es,goods_srv/handler/goods.go
  • 要注意哪些地方使用 es 搜索,哪些仍然借助 MySQL完成
    • 用于过滤的字段:keyword,hot,new,priceRange,brand,category
    • 前五个过滤条件之前用 localDB 不断叠加,这里都要转用 es,考虑使用复合查询(must+filter 配合其他查询方式)
    • 为什么都要用 es,不能只用 es 做关键字搜索其他用 MySQL吗?
      • 首先要知道,es 复合查询和 MySQL 对象叠加查询是两个东西,es 查出来的东西 MySQL 不能基于它查
      • 要明确,使用 es 是来做搜索的,没必要将所有的 mysql 字段在 es 中保存一份
      • 一般只把用于搜索和过滤的字段保存到 es 中,我们在 EsGoods 中只放了 CategoryID 和 BrandsID,并没有加外键
      • mysql 和 es 之间是互补的关系, 一个用来做存储,一个用来做搜索
      • es 想要提高性能, 要将 es 的内存设置的够大;避免存不必要的字段也能节省内存
      • 因此,获取 goods 信息全部通过 es 过滤,保存一份商品 id,再通过 MySQL 获取外键信息
    • category 比较特殊 ,因为涉及到外键、多表查询,仍然用 MySQL 先获取所有分类的 ID,再交给 es 作为过滤条件
  • 新建 es 的 index
    • 首先要准备 struct,能将 es 查询到的数据转换,可以放在 model/es_goods.go
    • 准备 mappings 配置,设置分词器
    • 注:使用 IK 分词器底层还是倒排索引,我们不提供 keyword analyzer 的查询接口(不分词索引),就不需要在定义 mappings 时加 keyword type
  • 新增 EsConfig,在 nacos 中心添加相关配置
    • global 中定义 EsClient,并在初始化时用上 mappings 完成赋值
      // es_goods.go
      package model
      
      type EsGoods struct {
      	ID          int32   `json:"id"`
      	CategoryID  int32   `json:"category_id"`
      	BrandsID    int32   `json:"brand_id"`
      	OnSale      bool    `json:"on_sale"`
      	ShipFree    bool    `json:"ship_free"`
      	IsNew       bool    `json:"is_new"`
      	IsHot       bool    `json:"is_hot"`
      	Name        string  `json:"name"`
      	ClickNum    int32   `json:"click_num"`
      	SoldNum     int32   `json:"sold_num"`
      	FavNum      int32   `json:"fav_num"`
      	MarketPrice float32 `json:"market_price"`
      	GoodsBrief  string  `json:"goods_brief"`
      	ShopPrice   float32 `json:"shop_price"`
      }
      
      func (EsGoods) GetIndexName() string {
      	// index名称就是这简介而朴实
      	return "goods"
      }
      
      func (EsGoods) GetMapping() string {
      	goodsMapping := `
      	{
      		"mappings" : {
      			"properties" : {
      				"brands_id" : {
      					"type" : "integer"
      				},
      				"category_id" : {
      					"type" : "integer"
      				},
      				"click_num" : {
      					"type" : "integer"
      				},
      				"fav_num" : {
      					"type" : "integer"
      				},
      				"id" : {
      					"type" : "integer"
      				},
      				"is_hot" : {
      					"type" : "boolean"
      				},
      				"is_new" : {
      					"type" : "boolean"
      					"type" : "float"
      				},
      				"name" : {
      					"type" : "text",
      					"analyzer":"ik_max_word"
      				},
      				"goods_brief" : {
      					"type" : "text",
      					"analyzer":"ik_max_word"
      				},
      				"on_sale" : {
      					"type" : "boolean"
      				},
      				"ship_free" : {
      					"type" : "boolean"
      				},
      				"shop_price" : {
      					"type" : "float"
      				},
      				"sold_num" : {
      					"type" : "long"
      				}
      			}
      		}
      	}`
      	return goodsMapping
      }
      
    • main.go 中调用初始化
      // initialize/es.go
      package initialize
      
      import (
      	"context"
      	"fmt"
      	"github.com/olivere/elastic/v7"
      	"log"
      	"os"
      	"shop_srvs/goods_srv/global"
      	"shop_srvs/goods_srv/model"
      )
      
      func InitEs() {
      	host := fmt.Sprintf("http://%s:%d", global.ServerConfig.EsInfo.Host, global.ServerConfig.EsInfo.Port)
      	// 配置日志
      	logger := log.New(os.Stdout, "shop", log.LstdFlags)
      	// 设置为 false,避免地址被转换,导致连接失败
      	var err error
      	global.EsClient, err = elastic.NewClient(elastic.SetURL(host), elastic.SetSniff(false))
      
      	// 用上 mappings 创建 index
      	exist, err := global.EsClient.IndexExists(model.EsGoods{}.GetIndexName()).Do(context.Background())
      	if err != nil {
      		panic(err)
      	}
      	if !exist {
      		// 创建
      		_, err = global.EsClient.CreateIndex(model.EsGoods{}.GetIndexName()).BodyString(model.EsGoods{}.GetMapping()).Do(context.Background())
      		if err != nil {
      			panic(err)
      		}
      	} else {
      
      	}
      }
      

导入数据

  • 将 goods 表数据导入 es
    func Mysql2Es() {
    	dsn := "root:123456@tcp(192.168.109.128:3306)/shop_goods_srv?charset=utf8mb4&parseTime=True&loc=Local"
    
    	newLogger := logger.New(
    		log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
    		logger.Config{
    			SlowThreshold: time.Second, // 慢 SQL 阈值
    			LogLevel:      logger.Info, // Log level
    			Colorful:      true,        // 禁用彩色打印
    		},
    	)
    
    	// 全局模式
    	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
    		NamingStrategy: schema.NamingStrategy{
    			SingularTable: true,
    		},
    		Logger: newLogger,
    	})
    	if err != nil {
    		panic(err)
    	}
    
    	host := "http://192.168.109.128:9200"
    	logger := log.New(os.Stdout, "shop", log.LstdFlags)
    	global.EsClient, err = elastic.NewClient(elastic.SetURL(host), elastic.SetSniff(false),
    		elastic.SetTraceLog(logger))
    	if err != nil {
    		panic(err)
    	}
    
    	var goods []model.Goods
    	db.Find(&goods) // 获取所有数据
    	// 选取es需要的字段
    	for _, g := range goods {
    		esModel := model.EsGoods{
    			ID:          g.ID,
    			CategoryID:  g.CategoryID,
    			BrandsID:    g.BrandsID,
    			OnSale:      g.OnSale,
    			ShipFree:    g.ShipFree,
    			IsNew:       g.IsNew,
    			IsHot:       g.IsHot,
    			Name:        g.Name,
    			ClickNum:    g.ClickNum,
    			SoldNum:     g.SoldNum,
    			FavNum:      g.FavNum,
    			MarketPrice: g.MarketPrice,
    			GoodsBrief:  g.GoodsBrief,
    			ShopPrice:   g.ShopPrice,
    		}
    
    		// POST
    		_, err = global.EsClient.Index().Index(esModel.GetIndexName()).BodyJson(esModel).Id(strconv.Itoa(int(g.ID))).Do(context.Background())
    		if err != nil {
    			panic(err)
    		}
    		// 一定要将docker启动es的java_ops的内存设置大一些 否则运行过程中会出现 bad request 错误
    		// stop rm 再重新创建容器即可,数据挂载在外面,不会丢失
    	}
    }
    

GoodsList

  • 更新获取商品列表接口
    • 集成 es 的相关代码单独提交一次,后续还会改动,需要 reset 掉这部分改动回到 other branch 上线完整项目,再 merge 到 goods branch,最后 merge 到 master
      1
  • es 中使用复合查询 NewBoolQuery
    • 复合了 NewMultiMatchQuery NewTermQuery NewRangeQuery
    • 目前只是过滤条件的叠加,还未获取表数据
  • MySQL 查询 categoryIds
    • 这部分用原生 SQL 语句(Raw),并将结果扫描(Scan)到 struct(临时定义一个)
      type Result struct {
      	ID int32
      }
      var results []Result
      global.DB.Model(model.Category{}).Raw(subQuery).Scan(&results)
      for _, re := range results {
      	categoryIds = append(categoryIds, re.ID)
      }
      
    • 查出了所有的分类 id,剩下的还是交给 es(再叠加个filter)
      q = q.Filter(elastic.NewTermsQuery("category_id", categoryIds...))
      
    • 查询数据并解析到 goods struct,这里查到的是 es 表中的商品字段(model.EsGoods),我们保存一份商品 id
    • 根据商品 id,多表查询,获取商品的其他信息(model.Goods)
  • 通过 web 层测试
    2
  • 初始化配置日志时可以设置打印详细信息,能看到 es 和 mysql 执行的语句,方便调试,上线时再重新设置

CreateGoods

  • 创建商品这里用到 es 并不是用作查询当前分类和品牌是否已存在,这么简单的事交个 MySQL 就好了
  • 主要是为了保证数据写入 mysql 后能同时写入 es,这也是面试常见问题,要写两份数据,如何保证一致性?
  • gorm 里提供了钩子函数,我们在 model/goods.go 中定义
    func (g *Goods) AfterUpdate(tx *gorm.DB) (err error) {
    	esModel := EsGoods{
    		ID:          g.ID,
    		CategoryID:  g.CategoryID,
    		BrandsID:    g.BrandsID,
    		OnSale:      g.OnSale,
    		ShipFree:    g.ShipFree,
    		IsNew:       g.IsNew,
    		IsHot:       g.IsHot,
    		Name:        g.Name,
    		ClickNum:    g.ClickNum,
    		SoldNum:     g.SoldNum,
    		FavNum:      g.FavNum,
    		MarketPrice: g.MarketPrice,
    		GoodsBrief:  g.GoodsBrief,
    		ShopPrice:   g.ShopPrice,
    	}
    	// 写入数据 POST index/body/id
    	_, err = global.EsClient.Index().Index(esModel.GetIndexName()).BodyJson(esModel).Id(strconv.Itoa(int(g.ID))).Do(context.Background())
    	if err != nil {
    		return err
    	}
    	return nil
    }
    
  • 注:go 语言的接口和直接在 kibana 操作 es,两者动作看起来不对应,但底层调用的接口是一样的,使用接口时记住关键函数即可
  • 这个钩子会在 Save 之后被调用,但关键是要放在事务中,有任何错误,要能全部回滚
    tx := global.DB.Begin()
    // result 是执行完钩子函数后的结果
    result := tx.Save(&goods)
    if result.Error != nil {
    	tx.Rollback()
    	return nil, result.Error
    }
    tx.Commit()
    
  • 测试
    • 制造 error,可以将上面的 ClickNum 赋值为一个字符串,es 建立索引时 type 指定为 integer,所以会报解析错误
    • web 层发起添加商品请求,打断点就可以看到错误提示
    • 在 mysql 和 es 中查询生成的商品 id,都不能查到即可证明回滚成功
  • Q&A
    • 为什么 gorm 能回滚 es 的操作?
  • 同理,在 update 和 delete 中也要定义钩子
    • update
      // 更新数据 POST update/_doc/id
      _, err = global.EsClient.Update().Index(esModel.GetIndexName()).
      	Doc(esModel).Id(strconv.Itoa(int(g.ID))).Do(context.Background())
      if err != nil {
      	return err
      }
      return nil
      
    • delete
      func (g *Goods) AfterDelete(tx *gorm.DB) (err error) {
      	// DELETE
      	_, err = global.EsClient.Delete().Index(EsGoods{}.GetIndexName()).Id(strconv.Itoa(int(g.ID))).Do(context.Background())
      	if err != nil {
      		return err
      	}
      	return nil
      }
      
    • 也别忘了用事务包裹住;Delete 这里不需要(如果只是 es 出错,只能人工删除?)
      func (s *GoodsServer) DeleteGoods(ctx context.Context, req *proto.DeleteGoodsInfo) (*emptypb.Empty, error) {
      	// 这里涉及到 es 同步操作,所以要将 id 传过去给 es 用
      	// 本来是 Delete(&model.Goods{}, req.Id) 即可
      	// 并且这里不能再用 result.RowsAffected 判断,也要考虑 es;mysql 可能不出错,es 出错了,RowsAffected 反映不出来
      	if result := global.DB.Delete(&model.Goods{BaseModel: model.BaseModel{ID: req.Id}}, req.Id); result.Error != nil {
      		return nil, status.Errorf(codes.NotFound, "商品不存在")
      	}
      	return &emptypb.Empty{}, nil
      }
      

小结

  • 这一节我们将 es 搜索集成到了商品服务,接下来通过前后端联调启动整个微服务项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

瑞士_R

修行不易...

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值