基於 Go + MySQL + ES 實現一個 Tag API 服務

Tag 是一個很常見的功能,這篇文章將使用 Go + MySQL + ES 實現一個 500 多行的 tag API 服務,支持 建立/搜索 標籤、標籤關聯到實體 和 查詢實體所關聯的標籤列表。html

初始化環境

MySQL

brew install mysql
複製代碼

ES

這裏直接經過 docker 來啓動 ES:node

docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch
複製代碼

啓動後能夠經過 curl 檢查是否已經啓動和獲取版本信息:mysql

curl localhost:9200
{
  "name" : "5059f2c85a1d",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "T5EjufvlSdCcZXVDJFi2cA",
  "version" : {
    "number" : "7.7.1",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "ad56dce891c901a492bb1ee393f12dfff473a423",
    "build_date" : "2020-05-28T16:30:01.040088Z",
    "build_snapshot" : false,
    "lucene_version" : "8.5.1",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}
複製代碼

注意上面的部署僅用於開發環境,若是須要在生產部署經過 docker 部署,請參考官方文檔: Install Elasticsearch with Dockergit

設計存儲結構

先在 MySQL 裏面建立一個 test 數據庫:github

create database test;
use test;
複製代碼

建立 tag_tbl 表:sql

CREATE TABLE `tag_tbl` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(40) NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `name` (`name`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
複製代碼

tag_tbl 用於存儲標籤,注意這裏給咱們給 name 字段加上了一個惟一鍵,並使用 hash 做爲索引方法,關於 hash 索引,能夠參考官方文檔:Comparison of B-Tree and Hash Indexesdocker

再建立 entity_tag_tbl 用於存儲實體關聯的 tag:數據庫

CREATE TABLE `entity_tag_tbl` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `entity_id` int(10) unsigned NOT NULL,
  `tag_id` int(10) unsigned NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `entity_id` (`entity_id`,`tag_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
複製代碼

設計 API

建立標籤

Request:json

POST /api/tag
{
    "name": "your tag name"
}
複製代碼

Response:api

{
    "tag_id": 1
}
複製代碼

搜索標籤

Request:

GET /api/tag/search
{
    "keyword": "cat"
}
複製代碼

Response:

{
    "matchs": [
        {
            "tag_id": 5,
            "name": "cat"
        },
        {
            "tag_id": 6,
            "name": "cat pictures"
        }
    ]
}
複製代碼

關聯標籤到實體

Request:

POST /api/tag/link_entity
{
    "entity_id": 1,
    "tag_id": 3
}
複製代碼

Response:

{
    "link_id": 1
}
複製代碼

查詢實體關聯的標籤列表

Request:

GET /api/tag/entity_tags
{
    "entity_id": 1
}
複製代碼

Response:

{
    "tags": [
        {
            "tag_id": 3,
            "name": "美食"
        }
    ]
}
複製代碼

編碼實現

初始化:

mkdir tag-server
cd tag-server
go mod init github.com/3vilive/tag-server
複製代碼

安裝將要用到依賴項:

go get github.com/go-sql-driver/mysql github.com/jmoiron/sqlx github.com/gin-gonic/gin github.com/elastic/go-elasticsearch/v7
複製代碼

建立 cmd/api-server/main.go 並編寫腳手架代碼:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func OnNewTag(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "tag_id": 0,
    })
}

func OnSearchTag(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "matches": []struct{}{},
    })
}

func OnLinkEntity(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"link_id": 0,
	})
}

func OnEntityTags(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"tags": []struct{}{},
	})
	return
}

func main() {
    r := gin.Default()

    r.POST("/api/tag", OnNewTag)
    r.GET("/api/tag/search", OnSearchTag)
    r.POST("/api/tag/link_entity", OnLinkEntity)
    r.GET("/api/tag/entity_tags", OnEntityTags)

    r.Run(":9800")
}
複製代碼

實現建立標籤的 API

鏈接數據庫:

import "github.com/jmoiron/sqlx"
import _ "github.com/go-sql-driver/mysql" // mysql driver

var (
    mysqlDB *sqlx.DB
)

func init() {
    mysqlDB = sqlx.MustOpen("mysql", "test:test@tcp(localhost:3306)/test?parseTime=True&loc=Local&multiStatements=true&charset=utf8mb4")
}
複製代碼

定義 Tag 結構:

type Tag struct {
    TagID int    `db:"id"`
    Name  string `db:"name"`
}
複製代碼

編寫建立標籤的邏輯:

// NewTagReqBody 建立標籤的請求體
type NewTagReqBody struct {
    Name string `json:"name"`
}

// OnNewTag 建立標籤
func OnNewTag(c *gin.Context) {
    var reqBody NewTagReqBody
    if bindErr := c.BindJSON(&reqBody); bindErr != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "status":  http.StatusBadRequest,
            "message": bindErr.Error(),
        })
        return
    }

    // 判斷傳入的 tag 名稱是否爲空
    tagName := strings.TrimSpace(reqBody.Name)
    if tagName == "" {
        c.JSON(http.StatusBadRequest, gin.H{
            "status":  http.StatusBadRequest,
            "message": "invalid name",
        })
        return
    }

    var queryTag Tag
    queryErr := mysqlDB.Get(&queryTag, "select id, name from tag_tbl where name = ?", tagName)
    if queryErr == nil {
        // tag 已經存在
        c.JSON(http.StatusOK, gin.H{
            "tag_id": queryTag.TagID,
        })
        return
    }

    // 查詢 mysql 出現錯誤
    if queryErr != nil && queryErr != sql.ErrNoRows {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": queryErr.Error(),
        })
        return
    }

    // tag 不存在,建立 tag
    result, execErr := mysqlDB.Exec("insert into tag_tbl (name) values (?) on duplicate key update created_at = now()", tagName)
    if execErr != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": execErr.Error(),
        })
        return
    }

    tagID, err := result.LastInsertId()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "tag_id": tagID,
    })
}
複製代碼

啓動測試一下:

go run cmd/api-server/main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /api/tag                  --> main.OnNewTag (3 handlers)
[GIN-debug] POST   /api/tag/search           --> main.OnSearchTag (3 handlers)
[GIN-debug] Listening and serving HTTP on :9800
複製代碼

建立一個名爲 test 的標籤:

curl --request POST \
  --url http://localhost:9800/api/tag \
  --header 'content-type: application/json' \
  --data '{ "name": "test" }'
複製代碼

響應:

{
  "tag_id": 1
}
複製代碼

再建立一個叫作 測試 的標籤:

curl --request POST \
  --url http://localhost:9800/api/tag \
  --header 'content-type: application/json' \
  --data '{ "name": "測試" }'
複製代碼

響應:

{
  "tag_id": 2
}
複製代碼

從新運行一遍建立 test 標籤的請求:

curl --request POST \
  --url http://localhost:9800/api/tag \
  --header 'content-type: application/json' \
  --data '{ "name": "test" }'
複製代碼

響應:

{
  "tag_id": 1
}
複製代碼

測試結果符合預期,當前完整文件內容以下:

package main

import (
    "database/sql"
    "net/http"
    "strings"

    "github.com/gin-gonic/gin"
    "github.com/jmoiron/sqlx"

    _ "github.com/go-sql-driver/mysql" // mysql driver
)

var (
    mysqlDB *sqlx.DB
)

func init() {
    mysqlDB = sqlx.MustOpen("mysql", "test:test@tcp(localhost:3306)/test?parseTime=True&loc=Local&multiStatements=true&charset=utf8mb4")
}

// Tag 標籤結構定義
type Tag struct {
    TagID int    `db:"id"`
    Name  string `db:"name"`
}

// NewTagReqBody 建立標籤的請求體
type NewTagReqBody struct {
    Name string `json:"name"`
}

// OnNewTag 建立標籤
func OnNewTag(c *gin.Context) {
    var reqBody NewTagReqBody
    if bindErr := c.BindJSON(&reqBody); bindErr != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "status":  http.StatusBadRequest,
            "message": bindErr.Error(),
        })
    }

    // 判斷傳入的 tag 名稱是否爲空
    tagName := strings.TrimSpace(reqBody.Name)
    if tagName == "" {
        c.JSON(http.StatusBadRequest, gin.H{
            "status":  http.StatusBadRequest,
            "message": "invalid name",
        })
        return
    }

    var queryTag Tag
    queryErr := mysqlDB.Get(&queryTag, "select id, name from tag_tbl where name = ?", tagName)
    if queryErr == nil {
        // tag 已經存在
        c.JSON(http.StatusOK, gin.H{
            "tag_id": queryTag.TagID,
        })
        return
    }

    // 查詢 mysql 出現錯誤
    if queryErr != nil && queryErr != sql.ErrNoRows {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": queryErr.Error(),
        })
        return
    }

    // tag 不存在,建立 tag
    result, execErr := mysqlDB.Exec("insert into tag_tbl (name) values (?) on duplicate key update created_at = now()", tagName)
    if execErr != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": execErr.Error(),
        })
        return
    }

    tagID, err := result.LastInsertId()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status":  http.StatusInternalServerError,
            "message": err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "tag_id": tagID,
    })
}

// OnSearchTag 搜索標籤
func OnSearchTag(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "matches": []struct{}{},
    })
}

func main() {
    r := gin.Default()

    r.POST("/api/tag", OnNewTag)
    r.POST("/api/tag/search", OnSearchTag)

    r.Run(":9800")
}
複製代碼

實現搜索標籤的 API

導入 elasticsearch 包:

import (
    ...
    elasticsearch7 "github.com/elastic/go-elasticsearch/v7"
)
複製代碼

聲明 esClient 變量:

var (
    mysqlDB  *sqlx.DB
    esClient *elasticsearch7.Client
)
複製代碼

在 init 函數中初始化 esClient:

func init() {
	// 初始化 mysql
	mysqlDB = sqlx.MustOpen("mysql", "test:test@tcp(localhost:3306)/test?parseTime=True&loc=Local&multiStatements=true&charset=utf8mb4")

	// 初始化 ES
	esConf := elasticsearch7.Config{
		Addresses: []string{"http://localhost:9200"},
	}
	es, err := elasticsearch7.NewClient(esConf)
	if err != nil {
		panic(err)
	}

	res, err := es.Info()
	if err != nil {
		panic(err)
	}

	if res.IsError() {
		panic(res.String())
	}

	esClient = es
}
複製代碼

把標籤添加至 ES 索引

爲了能在 ES 上搜到標籤,咱們須要在添加標籤的時候,把標籤添加至 ES 索引中。

先修改 Tag 結構,增長 JSON Tag, 並添加轉換成 JSON 字符串的方法:

// Tag 標籤結構定義
type Tag struct {
	TagID int    `db:"id" json:"tag_id"`
	Name  string `db:"name" json:"name"`
}

// MustToJSON 將結構轉換成 JSON
func (t *Tag) MustToJSON() string {
	bs, err := json.Marshal(t)
	if err != nil {
		panic(err)
	}
	return string(bs)
}
複製代碼

而後添加一個上報 Tag 到 ES 索引的函數:

// ReportTagToES 上報 Tag 到 ES
func ReportTagToES(tag *Tag) {
	req := esapi.IndexRequest{
		Index:        "test",
		DocumentType: "tag",
		DocumentID:   strconv.Itoa(tag.TagID),
		Body:         strings.NewReader(tag.MustToJSON()),
		Refresh:      "true",
	}

	resp, err := req.Do(context.Background(), esClient)
	if err != nil {
		log.Printf("ESIndexRequestErr: %s", err.Error())
		return
	}

	defer resp.Body.Close()
	if resp.IsError() {
		log.Printf("ESIndexRequestErr: %s", resp.String())
	} else {
		log.Printf("ESIndexRequestOk: %s", resp.String())
	}
}
複製代碼

在 OnNewTag 函數的底部增長上報的邏輯:

func OnNewTag(c *gin.Context) {

    ... 
    
	tagID, err := result.LastInsertId()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"status":  http.StatusInternalServerError,
			"message": err.Error(),
		})
		return
	}
    
	// 添加到 ES 索引
	newTag := &Tag{TagID: int(tagID), Name: tagName}
	go ReportTagToES(newTag)

	c.JSON(http.StatusOK, gin.H{
		"tag_id": tagID,
	})
}
複製代碼

從新啓動服務,而後測試建立 Tag,觀察日誌:

2020/06/05 11:29:11 ESIndexRequestOk: [201 Created] {"_index":"test","_type":"tag","_id":"4","_version":1,"result":"created","forced_refresh":true,"_shards":{"total":2,"successful":1,"failed":0},"_seq_no":3,"_primary_term":1}
複製代碼

再調用 ES 的 API 驗證一下:

curl -XGET "localhost:9200/test/tag/4"

{"_index":"test","_type":"tag","_id":"4","_version":1,"_seq_no":3,"_primary_term":1,"found":true,"_source":{"tag_id":4,"name":"測試手段"}}
複製代碼

完善搜索邏輯

新增一個 SearchTagReqBody 結構,做爲搜索標籤的請求體

type SearchTagReqBody struct {
	Keyword string `json:"keyword"`
}
複製代碼

在 OnSearchTag 函數裏面增長一些基本的參數校驗:

func OnSearchTag(c *gin.Context) {
	var reqBody SearchTagReqBody
	if bindErr := c.BindJSON(&reqBody); bindErr != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"status":  http.StatusBadRequest,
			"message": bindErr.Error(),
		})
		return
	}

	searchKeyword := strings.TrimSpace(reqBody.Keyword)
	if searchKeyword == "" {
		c.JSON(http.StatusBadRequest, gin.H{
			"status":  http.StatusBadRequest,
			"message": "invalid keyword",
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"matches": []struct{}{},
	})
}
複製代碼

增長一個 O 結構做爲 map[string]interface{} 的別名,而且爲這個結構添加一個 MustToJSONBytesBuffer() *bytes.Buffer 的方法:

type O map[string]interface{}

func (o *O) MustToJSONBytesBuffer() *bytes.Buffer {
	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(o); err != nil {
		panic(err)
	}

	return &buf
}
複製代碼

定義這個 O 是爲了等會構建 ES 查詢提供一點便利。

增長 SearchTagsFromES 函數,從 ES 上搜索 Tags:

func SearchTagsFromES(keyword string) ([]*Tag, error) {
	// 構建查詢
	query := O{
		"query": O{
			"match_phrase_prefix": O{
				"name":           keyword,
				"max_expansions": 50,
			},
		},
	}
	jsonBuf := query.MustToJSONBytesBuffer()

	// 發出查詢請求
	resp, err := esClient.Search(
		esClient.Search.WithContext(context.Background()),
		esClient.Search.WithIndex("test"),
		esClient.Search.WithBody(jsonBuf),
	)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.IsError() {
		return nil, errors.New(resp.Status())
	}

	js, err := simplejson.NewFromReader(resp.Body)
	if err != nil {
		return nil, err
	}

	hitsJS := js.GetPath("hits", "hits")
	hits, err := hitsJS.Array()
	if err != nil {
		return nil, err
	}

	hitsLen := len(hits)
	if hitsLen == 0 {
		return []*Tag{}, nil
	}

	tags := make([]*Tag, 0, len(hits))
	for idx := 0; idx < hitsLen; idx++ {
		sourceJS := hitsJS.GetIndex(idx).Get("_source")

		tagID, err := sourceJS.Get("tag_id").Int()
		if err != nil {
			return nil, err
		}

		tagName, err := sourceJS.Get("name").String()
		if err != nil {
			return nil, err
		}

		tagEntity := &Tag{TagID: tagID, Name: tagName}
		tags = append(tags, tagEntity)
	}

	return tags, nil
}
複製代碼

修改 OnSearchTag 函數,加入搜索的邏輯:

func OnSearchTag(c *gin.Context) {
	var reqBody SearchTagReqBody
	if bindErr := c.BindJSON(&reqBody); bindErr != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"status":  http.StatusBadRequest,
			"message": bindErr.Error(),
		})
		return
	}

	searchKeyword := strings.TrimSpace(reqBody.Keyword)
	if searchKeyword == "" {
		c.JSON(http.StatusBadRequest, gin.H{
			"status":  http.StatusBadRequest,
			"message": "invalid keyword",
		})
		return
	}

	tags, err := SearchTagsFromES(reqBody.Keyword)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"status":  http.StatusInternalServerError,
			"message": err.Error(),
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"matches": tags,
	})
}
複製代碼

從新啓動服務,而後添加一個美食標籤,而後再搜索:

curl --request GET \
  --url http://localhost:9800/api/tag/search \
  --header 'content-type: application/json' \
  --data '{ "keyword": "美食" }'

// response:

{
  "matches": [
    {
      "tag_id": 5,
      "name": "美食"
    }
  ]
}
複製代碼

搜索 API 最終效果

先清空一下 MySQL 的歷史數據,以前添加標籤的時候,尚未添加到 ES 的索引裏面:

truncate tag_tbl;
複製代碼

同時也清理一下 ES 索引:

curl -XDELETE "localhost:9200/test"
複製代碼

接下來添加一批 Tag:

美食
美食街
美食節
美食節趣聞
美食節三劍客
美食天堂
美食的誘惑
美食在中國
美食街都有啥
複製代碼

搜索 「美食」:

{
  "matches": [
    {
      "tag_id": 1,
      "name": "美食"
    },
    {
      "tag_id": 2,
      "name": "美食街"
    },
    {
      "tag_id": 3,
      "name": "美食節"
    },
    {
      "tag_id": 6,
      "name": "美食天堂"
    },
    {
      "tag_id": 4,
      "name": "美食節趣聞"
    },
    {
      "tag_id": 7,
      "name": "美食的誘惑"
    },
    {
      "tag_id": 8,
      "name": "美食在中國"
    },
    {
      "tag_id": 5,
      "name": "美食節三劍客"
    },
    {
      "tag_id": 9,
      "name": "美食街都有啥"
    }
  ]
}
複製代碼

搜索 「美食街」:

{
  "matches": [
    {
      "tag_id": 2,
      "name": "美食街"
    },
    {
      "tag_id": 9,
      "name": "美食街都有啥"
    }
  ]
}
複製代碼

搜索 「美食節」:

{
  "matches": [
    {
      "tag_id": 3,
      "name": "美食節"
    },
    {
      "tag_id": 4,
      "name": "美食節趣聞"
    },
    {
      "tag_id": 5,
      "name": "美食節三劍客"
    }
  ]
}
複製代碼

實現關聯標籤到實體 API

定義實體關聯 Tag 的結構:

type EntityTag struct {
	LinkID   int `db:"id" json:"-"`
	EntityID int `db:"entity_id" json:"entity_id"`
	TagID    int `db:"tag_id" json:"tag_id"`
}
複製代碼

定義請求體:

type LinkEntityReqBody struct {
	EntityID int `json:"entity_id"`
	TagID    int `json:"tag_id"`
}
複製代碼

開始編寫 OnLinkEntity 裏面的邏輯,首先先作基本的參數校驗:

var reqBody LinkEntityReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
	c.JSON(http.StatusBadRequest, gin.H{
		"status":  http.StatusBadRequest,
		"message": bindErr.Error(),
	})
	return
}

if reqBody.EntityID == 0 || reqBody.TagID == 0 {
	c.JSON(http.StatusBadRequest, gin.H{
		"status":  http.StatusBadRequest,
		"message": "request params error",
	})
	return
}
複製代碼

查詢是否標籤已經關聯過該實體,若是已經關聯過,則直接返回:

var entityTag EntityTag
queryErr := mysqlDB.Get(
	&entityTag,
	"select id, entity_id, tag_id from entity_tag_tbl where entity_id = ? and tag_id = ?",
	reqBody.EntityID, reqBody.TagID,
)

if queryErr == nil {
	// 已經存在關聯
	c.JSON(http.StatusOK, gin.H{
		"link_id": entityTag.LinkID,
	})
	return
}

if queryErr != sql.ErrNoRows {
	// 查詢錯誤
	c.JSON(http.StatusInternalServerError, gin.H{
		"status":  http.StatusInternalServerError,
		"message": queryErr.Error(),
	})
	return
}
複製代碼

判斷一下 Tag 是否存在:

var tag Tag
queryErr = mysqlDB.Get(
	&tag,
	"select id, name from tag_tbl where id = ?",
	reqBody.TagID,
)
if queryErr != nil {
	if queryErr != sql.ErrNoRows {
		// 查詢錯誤
		c.JSON(http.StatusInternalServerError, gin.H{
			"status":  http.StatusInternalServerError,
			"message": queryErr.Error(),
		})
		return
	}

	// Tag 不存在
	c.JSON(http.StatusNotFound, gin.H{
		"status":  http.StatusNotFound,
		"message": "tag not found",
	})
	return
}
複製代碼

記錄關聯信息並返回關聯 ID:

execResult, execErr := mysqlDB.Exec(
	"insert into entity_tag_tbl (entity_id, tag_id) values (?, ?) on duplicate key update created_at = now()",
	reqBody.EntityID, reqBody.TagID,
)
if execErr != nil {
	// 插入失敗
	c.JSON(http.StatusInternalServerError, gin.H{
		"status":  http.StatusInternalServerError,
		"message": execErr.Error(),
	})
	return
}

linkID, err := execResult.LastInsertId()
if err != nil {
	c.JSON(http.StatusInternalServerError, gin.H{
		"status":  http.StatusInternalServerError,
		"message": err.Error(),
	})
	return
}

c.JSON(http.StatusOK, gin.H{
	"link_id": int(linkID),
})
複製代碼

重啓服務,建立一些關聯:

curl --request POST \
  --url http://localhost:9800/api/tag/link_entity \
  --header 'content-type: application/json' \
  --data '{ "entity_id": 1, "tag_id": 5 }'
複製代碼

能夠經過數據庫來驗證一下:

mysql> select * from entity_tag_tbl;
+----+-----------+--------+---------------------+
| id | entity_id | tag_id | created_at          |
+----+-----------+--------+---------------------+
|  1 |         1 |      3 | 2020-06-05 15:03:00 |
|  2 |         1 |      1 | 2020-06-05 15:39:42 |
|  3 |         1 |      4 | 2020-06-05 15:39:47 |
|  4 |         1 |      2 | 2020-06-05 15:39:52 |
|  5 |         1 |      7 | 2020-06-05 15:55:59 |
|  6 |         1 |      5 | 2020-06-05 15:56:01 |
+----+-----------+--------+---------------------+
複製代碼

實現查詢實體關聯的標籤列表 API

定義查詢實體關聯的標籤列表的請求體:

type EntityTagReqBody struct {
	EntityID int `json:"entity_id"`
}
複製代碼

編寫 OnEntityTags 邏輯,和以前同樣作參數校驗:

var reqBody EntityTagReqBody
if bindErr := c.BindJSON(&reqBody); bindErr != nil {
	c.JSON(http.StatusBadRequest, gin.H{
		"status":  http.StatusBadRequest,
		"message": bindErr.Error(),
	})
	return
}

if reqBody.EntityID == 0 {
	c.JSON(http.StatusBadRequest, gin.H{
		"status":  http.StatusBadRequest,
		"message": "request params error",
	})
	return
}
複製代碼

查詢出實體關聯的標籤:

entityTags := []*EntityTag{}
selectErr := mysqlDB.Select(&entityTags, "select id, entity_id, tag_id from entity_tag_tbl where entity_id = ? order by id", reqBody.EntityID)
if selectErr != nil {
	c.JSON(http.StatusInternalServerError, gin.H{
		"status":  http.StatusInternalServerError,
		"message": selectErr.Error(),
	})
	return
}

if len(entityTags) == 0 {
	c.JSON(http.StatusOK, gin.H{
		"tags": []*Tag{},
	})
	return
}
複製代碼

查詢出標籤列表,並返回:

tagIDs := make([]int, 0, len(entityTags))
tagIndex := make(map[int]int, len(entityTags))
for index, entityTag := range entityTags {
	tagIndex[entityTag.TagID] = index
	tagIDs = append(tagIDs, entityTag.TagID)
}

queryTags, args, err := sqlx.In("select id, name from tag_tbl where id in (?)", tagIDs)
if err != nil {
	c.JSON(http.StatusInternalServerError, gin.H{
		"status":  http.StatusInternalServerError,
		"message": err.Error(),
	})
	return
}

tags := []*Tag{}
selectErr = mysqlDB.Select(&tags, queryTags, args...)
if selectErr != nil {
	c.JSON(http.StatusInternalServerError, gin.H{
		"status":  http.StatusInternalServerError,
		"message": selectErr.Error(),
	})
	return
}

sort.Slice(tags, func(i, j int) bool {
	return tagIndex[tags[i].TagID] < tagIndex[tags[j].TagID]
})

c.JSON(http.StatusOK, gin.H{
	"tags": tags,
})
複製代碼

重啓服務測試一下:

curl --request GET \
  --url http://localhost:9800/api/tag/entity_tags \
  --header 'content-type: application/json' \
  --data '{ "entity_id": 1 }'

// response

{
  "tags": [
    {
      "tag_id": 3,
      "name": "美食節"
    },
    {
      "tag_id": 1,
      "name": "美食"
    },
    {
      "tag_id": 4,
      "name": "美食節趣聞"
    },
    {
      "tag_id": 2,
      "name": "美食街"
    },
    {
      "tag_id": 7,
      "name": "美食的誘惑"
    },
    {
      "tag_id": 5,
      "name": "美食節三劍客"
    }
  ]
}
複製代碼

最後

完整的代碼能夠在 Github 上找到:

github.com/3vilive/bui…

參考資料:

  1. Tags-Database-schemas
  2. Tagsystems-performance-tests
  3. Elasticsearch: 權威指南
相關文章
相關標籤/搜索