桀骜倔犟的鸟 發表於 2025-11-14 09:07:00

Golang使用elastic库来实现前后端模糊搜索功能

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li><a href="#_label0">介绍</a></li><li><a href="#_label1">工具</a></li><ul class="second_class_ul"><li><a href="#_lab2_1_0">后端</a></li><li><a href="#_lab2_1_1">前端</a></li></ul><li><a href="#_label2">快速入门</a></li><ul class="second_class_ul"><li><a href="#_lab2_2_2">先决条件</a></li><li><a href="#_lab2_2_3">实现思路</a></li><li><a href="#_lab2_2_4">运行</a></li><li><a href="#_lab2_2_5">测试</a></li></ul><li><a href="#_label3">注意事项</a></li><ul class="second_class_ul"></ul></ul></div><p class="maodian"><a name="_label0"></a></p><h2>介绍</h2>
<p>使用go的elastic库来实现前后端模糊搜索功能的示例</p>
<p class="maodian"><a name="_label1"></a></p><h2>工具</h2>
<p class="maodian"><a name="_lab2_1_0"></a></p><h3>后端</h3>
<ul><li>connectrpc.com/connect:与前端通信</li><li>connectrpc.com/cors:解决浏览器跨域</li><li>google.golang.org/protobuf: API定义</li><li>buf:生成go的api</li><li>sqlc:与Postgres数据库交互</li><li>github.com/elastic/go-elasticsearch/v9:go的es交互工具</li></ul>
<p class="maodian"><a name="_lab2_1_1"></a></p><h3>前端</h3>
<ul><li>react-ts</li><li>vite</li><li>@connectrpc/connect: 与后端通信</li><li>@connectrpc/connect-web: 与后端通信</li></ul>
<p class="maodian"><a name="_label2"></a></p><h2>快速入门</h2>
<p class="maodian"><a name="_lab2_2_2"></a></p><h3>先决条件</h3>
<ul><li>go &gt;=1.25.1</li><li>node.js &gt;=22</li><li>docker &gt;= 18</li><li>postgres &gt;= 12</li><li>elastic-search &gt;= 8</li><li>elastic-kibana &gt;= 8</li><li>pgsync</li><li>buf</li><li>redis</li></ul>
<p class="maodian"><a name="_lab2_2_3"></a></p><h3>实现思路</h3>
<p>设计数据结构, 以一个经典的电商商品举例:</p>
<div class="jb51code"><pre class="brush:sql;">CREATE DATABASE ecommerce;
CREATE SCHEMA product;
SET search_path to product;

CREATE TYPE product_status AS ENUM (
    'draft',-- 草稿
    'pending_review', -- 待审核
    'active', -- 上架
    'inactive', -- 下架
    'deleted' -- 删除,进入回收站,无法搜索,30天后物理删除
    );

-- 商品表
CREATE TABLE product.products
(
    id            bigserial primary key,
    name          varchar(255)   not null,
    description   text, -- 商品描述
    price         decimal(10, 2) not null,
    status      product_status not null default 'draft',
    merchant_id   uuid         not null,
    category_id   int            not null,
    category_name VARCHAR(100)   not null,
    cover_image   text         not null,
    attributes    jsonb          not null default '{}',
    sales_count   int            not null default 0,
    rating_scoredecimal(2, 1)not null default 0.0,
    created_at    timestamptz    not null default now(),
    updated_at    timestamptz    not null default now(),
    deleted_at    timestamptz    not null default now()
);

-- 商品图片表
CREATE TABLE product.images
(
    id         BIGSERIAL PRIMARY KEY,
    product_id BIGINT       NOT NULL REFERENCES product.products (id) ON DELETE CASCADE,
    url      VARCHAR(500) NOT NULL,
    type       VARCHAR(20)NOT NULL DEFAULT 'detail', -- cover/detail
    sort_order INTEGER               DEFAULT 0,
    alt_text   TEXT,
    created_at TIMESTAMPTZ         DEFAULT NOW(),

    -- 约束和索引
    UNIQUE (product_id, sort_order),
    CHECK (type IN ('cover', 'detail'))
);</pre></div>
<p>proto数据结构:</p>
<div class="jb51code"><pre class="brush:go;">syntax = "proto3";

package api.product.v1;
option go_package = "github.com/sunmery/elastic-example/api/product/v1;productv1";
import "google/protobuf/timestamp.proto";

service ProductService {
rpc GetProduct(GetProductRequest) returns(GetProductResponse){}
}

message GetProductRequest {
string index = 1;
string name = 2;
}

message GetProductResponse{
repeated Product products = 1;
}


// 定义 ProductImage 消息 (对应 Go 结构体中的 ProductImage)
message ProductImage {
// url 字段,对应 Go 结构体中的 url 字段
string url = 1;
// type 字段,例如 "cover" (封面) 或 "detail" (详情页)
string type = 2;
// sort_order 字段,用于排序
int32 sort_order = 3;
// alt_text 字段,用于图片替代文本
string alt_text = 4;
}

// 定义 ProductAttribute 消息 (对应 Go 结构体中的 ProductAttribute)
message ProductAttribute {
// key 字段,属性名 (例如 "颜色", "尺寸")
string key = 1;
// value 字段,属性值 (例如 "红色", "L")
string value = 2;
}


// 定义 Product 消息 (对应 Go 结构体中的 Product)
message Product {
// 基础信息
string id = 1;
string name = 2;
// repeated 对应 Go 中的切片 ([]string)
//repeated string name_suggest = 3;
string name_suggest = 3;
string description = 4;

// 价格和状态
double price = 5;
string status = 6;

// 分类和商家信息
string merchant_id = 7;
int32 category_id = 8; // Go 的 int 对应 Protobuf 的 int32
string category_name = 9;

// 图片和属性 (嵌套消息)
// repeated 对应 Go 中的结构体切片 ([]ProductImage)
repeated ProductImage images = 10;
string cover_image = 11;
// repeated 对应 Go 中的结构体切片 ([]ProductAttribute)
map&lt;string,string&gt; attributes = 12;

// 统计信息
int32 sales_count = 13;
double rating_score = 14;

// 时间戳 (使用标准 Protobuf 类型)
google.protobuf.Timestamp created_at = 15;
google.protobuf.Timestamp updated_at = 16;
}
</pre></div>
<p>go的数据结构:</p>
<div class="jb51code"><pre class="brush:go;">package biz

import "time"

// ProductImage 商品图片结构
type ProductImage struct {
        URL       string `json:"url"`
        Type      string `json:"type"` // cover/detail
        SortOrder int    `json:"sort_order"`
        AltText   string `json:"alt_text,omitempty"`
}

// ProductAttribute 商品属性结构
type ProductAttribute struct {
        Key   string `json:"key"`
        Value string `json:"value"`
}

// Product 商品主结构
type Product struct {
        ProductId    int64             `json:"product_id"`
        ProductNamestring            `json:"product_name"`
        NameSuggeststring            `json:"name_suggest,omitempty"` // 用于搜索建议
        Descriptionstring            `json:"description,omitempty"`
        Price      float64         `json:"price"`
        Status       string            `json:"status"` // 上架/下架
        MerchantID   string            `json:"merchant_id"`
        CategoryID   int               `json:"category_id"`
        CategoryName string            `json:"category_name"`
        Images       []ProductImage    `json:"images,omitempty"`
        CoverImage   string            `json:"cover_image,omitempty"`
        Attributes   mapstring `json:"attributes,omitempty"`
        SalesCount   int               `json:"sales_count"`
        RatingScorefloat64         `json:"rating_score"`
        CreatedAt    time.Time         `json:"created_at"`
        UpdatedAt    time.Time         `json:"updated_at"`
}
</pre></div>
<p>构建一个Web服务来给前端提供路由</p>
<div class="jb51code"><pre class="brush:go;">func main() {
        es := InitES()

        Producter := NewProductServer(es)

        mux := http.NewServeMux()
        path, handler := productv1connect.NewProductServiceHandler(
                Producter,
                // Validation via Protovalidate is almost always recommended
                connect.WithInterceptors(validate.NewInterceptor()),
        )
        mux.Handle(path, handler)

        // CORS 配置
        corsHandler := cors.New(cors.Options{
                AllowedOrigins:   []string{"*"},
                AllowedMethods:   connectcors.AllowedMethods(),
                AllowedHeaders:   connectcors.AllowedHeaders(),
                ExposedHeaders:   connectcors.ExposedHeaders(),
                MaxAge:         7200,
                AllowCredentials: false,
        })

        // 创建处理器链:监控中间件 -&gt; CORS -&gt; HTTP/2
        handlerChain := corsHandler.Handler(mux)

        p := new(http.Protocols)
        p.SetHTTP1(true)
        // Use h2c so we can serve HTTP/2 without TLS.
        p.SetUnencryptedHTTP2(true)
        s := http.Server{
                Addr:      "localhost:8080",
                Handler:      h2c.NewHandler(handlerChain, &amp;http2.Server{}),
                Protocols: p,
        }
        s.ListenAndServe()
}</pre></div>
<p>提供搜索功能:为了能够让用户在搜索时提供更好的范围,就需要从多个字段提供值来扩大匹配的范围。例如用户想搜索一个商品名为&quot;iPhone 18&quot;时,数据库存储了一个<code>name</code>值,如果只从<code>name</code>字段去匹配,如果不引入es,可以写一个sql为匹配前缀,那么可以取到,那么当用户搜索<code>&quot;手机&quot;</code>时,数据库并不会返回该条目,因为它的<code>name</code>不包含手机,而它也许只出现在<code>description</code>商品的介绍或者该商品的分类<code>手机</code>类目里,那么就可以很方便的使用es提供的功能来实现:</p>
<div class="jb51code"><pre class="brush:go;">func (p ProductServer) GetProduct(ctx context.Context, c *connect.Request) (*connect.Response, error) {
        searchFidles := []string{
                "product_name",
                "categoryName",
                "description",
                "attributes.*",
        }

        res, err := p.es.Search().Index(c.Msg.Index).Request(&amp;search.Request{
                Query: &amp;types.Query{
                        MultiMatch: &amp;types.MultiMatchQuery{
                                Query: c.Msg.Name,
                                Fields: searchFidles,
                        },
                },
        }).Do(ctx)
        if err != nil {
                return nil, err
        }

        var v1Products []*v1.Product // 存放 Protobuf 格式的商品列表
        for _, hit := range res.Hits.Hits {
                var bizProduct biz.Product
                if err := json.Unmarshal(hit.Source_, &amp;bizProduct); err != nil {
                        log.Printf("解析文档失败:%v", err)
                        continue
                }
                v1Product := bizToV1Product(&amp;bizProduct)
                v1Products = append(v1Products, v1Product)

                fmt.Printf("文档ID: %d, 评分: %f\n", hit.Id_, *hit.Score_)
                fmt.Printf("商品名称: %s, 价格: %.2f\n", bizProduct.ProductName, bizProduct.Price)
        }
        fmt.Printf("成功解析 %d 个商品\n", len(v1Products))

        return connect.NewResponse(&amp;v1.GetProductResponse{Products: v1Products}), nil
}
</pre></div>
<p>前端负责展示从后端接收到的数据并进行排版展示</p>
<div class="jb51code"><pre class="brush:js;">import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { ProductService, type Product } from "./api/product_pb";
import { useState } from "react";

const transport = createConnectTransport({
    baseUrl: "http://localhost:8080",
});
const client = createClient(ProductService, transport);

function App() {
    const = useState&lt;string&gt;('products');
    const = useState&lt;string&gt;('苹果iPhone 15 Pro Max 256GB 原色钛金属');
    const = useState&lt;Product[]&gt;([]);

    const getDoc = async (index: string, name: string) =&gt; {
      try {
            const response = await client.getProduct({
                index,
                name
            });
            console.log("API Response:", response);

            // 检查响应结构并设置 products
            if (response.products &amp;&amp; Array.isArray(response.products)) {
                setProducts(response.products);
            } else {
                console.warn("响应中没有 products 数组:", response);
                setProducts([]);
            }
      } catch (error) {
            console.error("获取产品失败:", error);
            setProducts([]);
      }
    };

    return (
      &lt;&gt;
            &lt;div style={{ padding: '20px' }}&gt;
                &lt;label style={{ display: 'block', marginBottom: '10px' }}&gt;
                  索引名称:
                  &lt;input
                        type="text"
                        value={index}
                        onChange={(e) =&gt; setIndex(e.target.value)}
                        style={{ marginLeft: '10px', padding: '5px' }}
                  /&gt;
                &lt;/label&gt;

                &lt;label style={{ display: 'block', marginBottom: '10px' }}&gt;
                  搜索关键词:
                  &lt;input
                        type="text"
                        value={name}
                        onChange={(e) =&gt; setName(e.target.value)}
                        style={{ marginLeft: '10px', padding: '5px' }}
                  /&gt;
                &lt;/label&gt;

                &lt;button
                  onClick={() =&gt; getDoc(index, name)}
                  style={{
                        padding: '8px 16px',
                        backgroundColor: '#007bff',
                        color: 'white',
                        border: 'none',
                        borderRadius: '4px',
                        cursor: 'pointer'
                  }}
                &gt;
                  搜索产品
                &lt;/button&gt;

                {/* 产品列表渲染 */}
                &lt;div style={{ marginTop: '20px' }}&gt;
                  &lt;h3&gt;搜索结果 ({products.length} 个产品):&lt;/h3&gt;

                  {products.length === 0 ? (
                        &lt;p&gt;没有找到产品&lt;/p&gt;
                  ) : (
                        &lt;ol&gt;
                            {products.map((item:Product) =&gt; (
                              &lt;li key={item.id} style={{ marginBottom: '15px', padding: '10px', border: '1px solid #ddd' }}&gt;
                                    &lt;p&gt;产品名称:{item.name}&lt;/p&gt;
                                    &lt;p&gt;价格:{item.price}&lt;/p&gt;
                                    &lt;p&gt;描述:{item.description}&lt;/p&gt;
                                    &lt;p&gt;状态:{item.status}&lt;/p&gt;
                                    &lt;p&gt;分类:{item.categoryName}&lt;/p&gt;
                              &lt;/li&gt;
                            ))}
                        &lt;/ol&gt;
                  )}
                &lt;/div&gt;
            &lt;/div&gt;
      &lt;/&gt;
    );
}

export default App;
</pre></div>
<p class="maodian"><a name="_lab2_2_4"></a></p><h3>运行</h3>
<p><strong>基础设施</strong></p>
<p>将<code>example.com</code>替换为你的地址</p>
<p>将<code>password</code>替换为更安全的密码</p>
<p>elastic</p>
<div class="jb51code"><pre class="brush:bash;">docker compose -f infrastructure/elastic.yaml up -d
</pre></div>
<p>postgres</p>
<div class="jb51code"><pre class="brush:bash;">docker compose -f infrastructure/postgres/compose.yaml up -d
</pre></div>
<p>redis</p>
<div class="jb51code"><pre class="brush:bash;">docker compose -f infrastructure/redis/compose.yaml up -d
</pre></div>
<p>pgsync</p>
<div class="jb51code"><pre class="brush:bash;">docker compose -f infrastructure/pgsync/compose.yaml up -d
</pre></div>
<p><strong>后端</strong></p>
<p>将<code>example.com</code>替换为你的地址</p>
<div class="jb51code"><pre class="brush:bash;">go mod tidy
go run .
</pre></div>
<p><strong>前端</strong></p>
<div class="jb51code"><pre class="brush:bash;">pnpm i
pnpm dev</pre></div>
<p class="maodian"><a name="_lab2_2_5"></a></p><h3>测试</h3>
<p>将<code>example.com</code>替换为你的地址</p>
<p>elastic search:</p>
<div class="jb51code"><pre class="brush:bash;">export ELASTICSEARCH_URL="http://example.com:9200"
curl -X GET $ELASTICSEARCH_URL/products/_search -H 'Content-Type: application/json' -d'
{
"query": {
    "match": {
      "product_name": "苹果"
    }
},
"size": 10
}
'</pre></div>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202511/2025111409052984.gif" /></p>
<p style="text-align:center">后端:</p>
<div class="jb51code"><pre class="brush:bash;">curl -v -X POST http://localhost:8080/api.product.v1.ProductService/GetProduct -H 'Content-Type: application/json' -d'
{
"name": "手机",
"index": "products"
}
'</pre></div>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202511/2025111409052920.gif" /></p>
<p>前端:</p>
<p style="text-align:center"><img alt="" src="https://img.jbzj.com/file_images/article/202511/2025111409052976.gif" /></p>
<p class="maodian"><a name="_label3"></a></p><h2>注意事项</h2>
<p>同步问题:pgsync设定了每20s从postgres数据库,redis缓存获取数据并同步到es,并非实时同步,请根据实际需求来设定合理时间。</p>
<p>es插件:pgync不支持ik等第三方分词插件,所以在schema.json即使定义了也不会起作用,pgsync不会不识别</p>
頁: [1]
查看完整版本: Golang使用elastic库来实现前后端模糊搜索功能