简介
- 上一篇了解了 es 的基操,但我们是通过 es 提供的 Restful API 操作
- 集成到我们项目的 web 层也就是要用 go 语言操作这些 API 接口,有官方和第三方的封装好接口的包可以用
- 我们这里使用 olivere/elastic
- 这下面有每个封装后接口的源码和 UT,方便了解
- 查看文档,掌握基操
- 代码示例:获取数据
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) } } }
- 这下面有每个封装后接口的源码和 UT,方便了解
- 但是在 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)
- 定义 struct,打上 json tag 即可
- 代码示例:写入数据
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 { } }
- global 中定义
导入数据
- 将 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
- 集成 es 的相关代码单独提交一次,后续还会改动,需要 reset 掉这部分改动回到 other branch 上线完整项目,再 merge 到 goods branch,最后 merge 到 master
- 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)
- 这部分用原生 SQL 语句(Raw),并将结果扫描(Scan)到 struct(临时定义一个)
- 通过 web 层测试
- 初始化配置日志时可以设置打印详细信息,能看到 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 }
- update
小结
- 这一节我们将 es 搜索集成到了商品服务,接下来通过前后端联调启动整个微服务项目