🏠

Bing Provider 实现与响应契约

概述

想象一下你的 Agent 需要回答一个关于"2025 年最新 AI 趋势"的问题 —— 它的训练数据只到 2024 年,怎么办?这时候就需要外部 web 搜索来补充实时信息。bing_provider_implementation_and_response_contract 模块正是系统连接 Bing 搜索 API 的桥梁。

这个模块的核心职责非常聚焦:将 Bing Search API 的原始响应转换为系统内部统一的 WebSearchResult 格式。它不是简单的 HTTP 客户端封装,而是整个 Web 搜索能力插件化架构中的一个具体实现。系统采用策略模式设计,允许在 Bing、Google、DuckDuckGo 等多个搜索提供商之间无缝切换,而调用方无需感知底层差异。

为什么不能直接调用 Bing API?因为系统需要:(1) 统一的接口契约,让上层服务不依赖具体提供商;(2) 统一的响应格式,让下游处理逻辑不需要为每个提供商写适配代码;(3) 可配置的认证和超时策略。这个模块就是这些设计意图的落地。

架构定位与数据流

flowchart TB subgraph Caller["调用方"] WebSearchService["WebSearchService"] end subgraph Registry["提供商注册中心"] ProviderRegistry["Registry"] ProviderReg["ProviderRegistration"] end subgraph BingProvider["Bing Provider 模块"] BingProviderStruct["BingProvider"] BuildParams["buildParams"] DoSearch["doSearch"] BingResponse["bingSearchResponse"] end subgraph External["外部依赖"] BingAPI["Bing Search API"] EnvVar["BING_SEARCH_API_KEY"] end subgraph Types["类型契约"] WebSearchResult["types.WebSearchResult"] WebSearchProviderInfo["types.WebSearchProviderInfo"] WebSearchProvider["interfaces.WebSearchProvider"] end WebSearchService -->|Search| ProviderRegistry ProviderRegistry -->|resolve| ProviderReg ProviderReg -->|Factory| BingProviderStruct BingProviderStruct -->|buildParams| BuildParams BuildParams -->|HTTP Request| BingAPI BingAPI -->|JSON Response| DoSearch DoSearch -->|parse| BingResponse BingResponse -->|transform| WebSearchResult BingProviderStruct -.->|read| EnvVar BingProviderStruct ..->|implements| WebSearchProvider ProviderReg -->|Info| WebSearchProviderInfo

架构角色解析

这个模块在整个系统中扮演适配器 + 策略实现的双重角色:

  1. 适配器(Adapter):Bing API 返回的 JSON 结构(bingSearchResponse)与系统内部类型(WebSearchResult)完全不同。模块负责解析 Bing 的嵌套结构(webPages.value[]),提取关键字段(name, url, snippet, dateLastCrawled),并映射到统一格式。

  2. 策略实现(Strategy):系统定义了 WebSearchProvider 接口,BingProvider 是其中一个具体实现。同级的还有 duckduckgo_provider_implementationgoogle_provider_implementation。这种设计让 WebSearchService 可以在运行时根据配置选择提供商。

数据流追踪

一次完整的搜索请求经历以下阶段:

用户查询 → WebSearchService.Search() 
         → Registry 解析提供商 
         → BingProvider.Search() 
         → buildParams() 构建 HTTP 请求 
         → Bing API 返回 JSON 
         → doSearch() 解析响应 
         → 转换为 []*WebSearchResult 
         → 返回给调用方

关键转换点在 doSearch() 方法中:Bing 返回的 dateLastCrawledtime.Time 类型,直接赋值给 PublishedAt *time.TimeName 字段映射为 TitleURL 保持不变,Snippet 保持不变,Source 硬编码为 "bing"

核心组件深度解析

BingProvider 结构体

type BingProvider struct {
    client  *http.Client
    baseURL string
    apiKey  string
}

设计意图:这是一个无状态的服务类(除了配置信息)。所有搜索请求共享同一个 http.Client 实例,这是 Go 的最佳实践 —— http.Client 内部维护连接池,重复使用可以复用 TCP 连接,显著提升性能。

字段说明

  • client *http.Client:配置了 10 秒超时的 HTTP 客户端。超时是必要的防御措施,防止 Bing API 响应慢时拖垮整个请求链路。
  • baseURL string:Bing Search API v7.0 的端点 URL,定义为常量 defaultBingSearchURL
  • apiKey string:从环境变量 BING_SEARCH_API_KEY 读取的认证密钥。注意:密钥不在代码中硬编码,而是通过环境变量注入,这是安全实践。

构造方法 NewBingProvider()

func NewBingProvider() (interfaces.WebSearchProvider, error)

这个方法实现了工厂模式。它做三件事:

  1. 从环境变量读取 API 密钥,如果为空则返回错误(fail-fast 原则)
  2. 创建带超时配置的 http.Client
  3. 返回实现了 WebSearchProvider 接口的实例

返回值是接口类型而非具体类型,这是依赖倒置的体现 —— 调用方只依赖抽象接口,不依赖具体实现。

搜索方法 Search()

func (p *BingProvider) Search(
    ctx context.Context,
    query string,
    maxResults int,
    includeDate bool,
) ([]*types.WebSearchResult, error)

参数设计考量

  • ctx context.Context:支持请求取消和超时传递。如果上层调用方设置了 30 秒超时,这个上下文会确保 Bing 请求不会超过这个时间。
  • query string:搜索关键词。方法会校验非空,避免无效请求。
  • maxResults int:期望返回的最大结果数。注意这是"期望值",Bing API 可能返回更少结果。
  • includeDate bool当前实现未使用。这是一个设计上的技术债 —— 参数存在但未被 buildParams() 使用,可能是为未来功能预留。

返回值

  • []*types.WebSearchResult:指针切片,避免大结构体拷贝。
  • error:网络错误、解析错误、参数错误都会通过此返回。

内部流程

  1. 校验 query 非空
  2. 调用 buildParams() 构建 *http.Request
  3. 调用 doSearch() 执行请求并解析响应

这种拆分(构建请求 + 执行请求)是单一职责原则的体现,也便于单元测试 —— 可以单独测试参数构建逻辑,而不需要 mock HTTP 响应。

请求构建 buildParams()

func (p *BingProvider) buildParams(
    ctx context.Context, 
    query string, 
    maxResults int, 
    includeDate bool,
) (*http.Request, error)

关键行为

  1. 使用 url.Values 构建查询参数(qcount
  2. 拼接完整 URL
  3. 创建 http.Request 并绑定上下文
  4. 设置两个关键 Header:
    • User-Agent:模拟 Chrome 浏览器,某些 API 会校验此字段
    • Ocp-Apim-Subscription-Key:Bing API 的认证头

设计细节

  • 使用 fmt.Sprintf 拼接 URL 而非 url.URL 结构体,因为参数简单,这样更直观。
  • User-Agent 硬编码为 macOS Chrome 的标识,这是为了兼容性 —— 某些 API 对非浏览器请求有限制。
  • includeDate 参数未使用,这是一个未实现的扩展点

响应解析 doSearch()

func (p *BingProvider) doSearch(ctx context.Context, req *http.Request) ([]*types.WebSearchResult, error)

执行流程

  1. p.client.Do(req) 发送 HTTP 请求
  2. defer resp.Body.Close() 确保响应体关闭(防止连接泄漏)
  3. io.ReadAll() 读取完整响应
  4. json.Unmarshal() 解析到 bingSearchResponse 结构体
  5. 遍历 respData.WebPages.Value 转换为 WebSearchResult

错误处理

  • 网络错误直接返回
  • 响应体读取失败返回
  • JSON 解析失败会包装错误信息(failed to unmarshal response),便于调试

字段映射表

Bing API 字段 内部类型字段 说明
Name Title 搜索结果标题
URL URL 原始链接
Snippet Snippet 摘要文本
DateLastCrawled PublishedAt 爬取时间(作为发布时间代理)
硬编码 "bing" Source 标识来源

注意PublishedAt 使用的是 DateLastCrawled(Bing 最后爬取时间),而非网页实际发布时间。这是一个近似值 —— 对于新闻类查询可能不够准确,但对于一般搜索足够。

响应契约 bingSearchResponse

type bingSearchResponse struct {
    Type         string `json:"_type"`
    QueryContext struct{ OriginalQuery string } `json:"queryContext"`
    WebPages struct {
        WebSearchURL          string `json:"webSearchUrl"`
        TotalEstimatedMatches int    `json:"totalEstimatedMatches"`
        Value                 []struct {
            ID               string    `json:"id"`
            Name             string    `json:"name"`
            URL              string    `json:"url"`
            IsFamilyFriendly bool      `json:"isFamilyFriendly"`
            DisplayURL       string    `json:"displayUrl"`
            Snippet          string    `json:"snippet"`
            DateLastCrawled  time.Time `json:"dateLastCrawled"`
            SearchTags       []struct{ Name, Content string } `json:"searchTags,omitempty"`
            About            []struct{ Name string } `json:"about,omitempty"`
        } `json:"value"`
    } `json:"webPages"`
    RelatedSearches struct{ ... } `json:"relatedSearches"`
    RankingResponse struct{ ... } `json:"rankingResponse"`
}

设计分析

  • 这是一个私有结构体,仅用于内部解析,不暴露给调用方。
  • 包含了 Bing API 返回的完整结构,但实际只使用了 WebPages.Value 部分。
  • 嵌套结构体直接内联定义(而非单独类型),因为只在此处使用,避免过度抽象。
  • 使用 omitempty 标签处理可选字段(SearchTags, About)。

未使用字段

  • RelatedSearches:相关搜索建议,当前未使用,但保留了解析逻辑。
  • RankingResponse:Bing 的排序元数据,当前未使用。
  • QueryContext:原始查询信息,当前未使用。

这些字段保留可能是为了未来扩展(如:支持相关搜索推荐、搜索结果重排序等)。

提供商信息 BingProviderInfo()

func BingProviderInfo() types.WebSearchProviderInfo

用途:这是一个工厂函数,返回提供商的元数据,用于 Registry 注册。

返回内容

  • ID: "bing":内部标识符
  • Name: "Bing":显示名称
  • Free: false:Bing API 是付费服务
  • RequiresAPIKey: true:需要 API 密钥
  • Description: "Bing Search API":描述

这个函数让系统可以在不实例化提供商的情况下获取其元数据,用于 UI 展示或配置验证。

依赖关系分析

上游依赖(被谁调用)

调用方 调用方式 期望
WebSearchService 通过 Registry 解析后调用 Search() 返回统一的 []*WebSearchResult
Registry 调用 BingProviderInfo() 获取元数据 用于提供商注册和发现

下游依赖(调用谁)

被调用方 调用目的 耦合度
net/http 发送 HTTP 请求 低(标准库)
encoding/json 解析 JSON 响应 低(标准库)
types.WebSearchResult 返回统一格式结果 中(依赖内部类型)
interfaces.WebSearchProvider 实现接口 中(依赖接口定义)
环境变量 BING_SEARCH_API_KEY 读取 API 密钥 低(配置注入)

数据契约

输入契约Search() 方法参数):

  • query 不能为空,否则返回错误
  • maxResults 传递给 Bing API 的 count 参数,Bing 有上限(通常 50)
  • includeDate 当前被忽略

输出契约[]*WebSearchResult):

  • Title:来自 Bing 的 Name 字段
  • URL:原始链接,未做校验
  • Snippet:摘要,可能包含 HTML 标签(Bing API 返回的原始内容)
  • Source:固定为 "bing"
  • PublishedAt:可能为 nil(如果 Bing 未返回 DateLastCrawled

设计决策与权衡

1. 策略模式 vs 条件分支

选择:使用 WebSearchProvider 接口 + 多个实现(Bing、Google、DuckDuckGo)

替代方案:在 WebSearchService 中用 switch 语句根据配置调用不同 API

权衡

  • 策略模式的优势:新增提供商无需修改 WebSearchService,符合开闭原则;便于单元测试(可以 mock 提供商)
  • 代价:需要额外的接口定义和注册机制,代码量略增

为什么适合这里:系统明确需要支持多个搜索源,且不同提供商的 API 差异较大(请求参数、响应结构、认证方式),策略模式隔离了这些差异。

2. 响应结构体内联定义 vs 独立类型

选择bingSearchResponse 及其嵌套结构体全部内联定义在同一个文件中

替代方案:为每个嵌套结构体定义独立类型(如 WebPage, SearchTag 等)

权衡

  • 内联定义的优势:代码集中,易于理解整体结构;避免过度抽象
  • 代价:如果其他地方需要复用这些类型,会导致重复定义

为什么适合这里:这些类型仅用于 Bing API 响应解析,没有复用需求,内联定义更简洁。

3. API 密钥通过环境变量注入 vs 配置文件

选择:从 os.Getenv("BING_SEARCH_API_KEY") 读取

替代方案:从配置文件或密钥管理服务读取

权衡

  • 环境变量的优势:符合 12-Factor App 原则;部署时灵活配置;避免密钥进入代码仓库
  • 代价:多环境管理需要额外工具;无法在运行时动态更新

为什么适合这里:API 密钥是敏感信息,环境变量是云原生部署的标准做法。

4. 固定超时 vs 可配置超时

选择:硬编码 defaultBingTimeout = 10 * time.Second

替代方案:通过配置传入超时时间

权衡

  • 固定超时的优势:简单;10 秒对搜索 API 是合理的(太长影响用户体验,太短可能误判)
  • 代价:无法针对不同网络环境调整

为什么适合这里:搜索是用户交互操作,10 秒是可接受的上限。如果需要调整,修改常量即可,不需要复杂配置。

5. includeDate 参数未实现

现状Search() 方法有 includeDate bool 参数,但 buildParams() 未使用

可能原因

  • 预留功能:计划支持按日期范围过滤(Bing API 支持 freshness 参数)
  • 历史遗留:早期设计有此需求,后来被简化

影响:调用方传入 true 不会有额外效果,但也不会出错。这是一个隐性契约不匹配,建议在文档中明确说明或移除该参数。

使用指南

基本使用

// 1. 创建提供商实例
provider, err := web_search.NewBingProvider()
if err != nil {
    // 处理错误(通常是 API 密钥未设置)
    return err
}

// 2. 执行搜索
ctx := context.Background()
results, err := provider.Search(ctx, "Golang 最佳实践", 10, false)
if err != nil {
    return err
}

// 3. 处理结果
for _, result := range results {
    fmt.Printf("标题:%s\n", result.Title)
    fmt.Printf("链接:%s\n", result.URL)
    fmt.Printf("摘要:%s\n", result.Snippet)
}

通过 Registry 使用(推荐)

// 1. 注册提供商
registry := web_search.NewRegistry()
registry.Register(web_search.BingProviderInfo(), web_search.NewBingProvider)

// 2. 解析并使用
provider, err := registry.Get("bing")
if err != nil {
    return err
}
results, err := provider.Search(ctx, query, maxResults, includeDate)

配置要求

环境变量

export BING_SEARCH_API_KEY="your-bing-api-key"

获取 API 密钥

  1. 访问 Azure Portal
  2. 创建 "Bing Search v7" 资源
  3. 复制密钥到环境变量

配额限制

  • 免费层:每月 1000 次调用
  • 标准层:按调用次数计费

边界情况与注意事项

1. 空查询处理

if len(query) == 0 {
    return nil, fmt.Errorf("query is empty")
}

行为:直接返回错误,不发送 HTTP 请求。

原因:Bing API 会拒绝空查询,提前校验可以节省网络开销。

2. API 密钥缺失

apiKey := os.Getenv("BING_SEARCH_API_KEY")
if len(apiKey) == 0 {
    return nil, fmt.Errorf("BING_SEARCH_API_KEY is not set")
}

行为:构造时即失败,而非搜索时失败。

设计意图:fail-fast 原则 —— 尽早暴露配置问题,避免运行时错误。

3. 响应解析失败

if err := json.Unmarshal(body, &respData); err != nil {
    return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}

可能原因

  • Bing API 返回非 JSON 响应(如 HTML 错误页面)
  • API 密钥无效(Bing 返回 401 错误,响应体不是预期格式)
  • 网络中间人攻击(响应被篡改)

调试建议:错误信息包含原始解析错误,可以查看具体失败原因。

4. 日期字段可能为零值

PublishedAt: &item.DateLastCrawled,

注意:如果 Bing 未返回 dateLastCrawled,该字段会是 time.Time 的零值(1970-01-01),而非 nil。调用方需要判断:

if result.PublishedAt != nil && !result.PublishedAt.IsZero() {
    // 使用日期
}

5. 并发安全

BingProvider 是并发安全的

  • http.Client 是并发安全的
  • 没有可变状态(baseURL, apiKey 在构造后不变)

可以安全地在多个 goroutine 中复用同一个实例

6. 结果数量不保证

params.Set("count", strconv.Itoa(maxResults))

注意maxResults 是请求参数,但 Bing 可能返回更少结果(如总共只有 5 条匹配)。调用方不应假设返回数量等于请求数量。

7. Snippet 可能包含 HTML

Bing API 返回的 Snippet 字段可能包含 HTML 标签(如 <b> 高亮关键词)。如果需要在纯文本环境使用,需要额外清理:

// 需要时使用 html2text 库清理
cleanSnippet := html2text.FromString(result.Snippet)

扩展点

添加日期范围过滤

当前 includeDate 参数未使用。如果要支持按日期过滤,可以修改 buildParams()

if includeDate {
    params.Set("freshness", string(bingFreshnessMonth)) // 最近一个月
}

需要定义 bingFreshness 枚举(代码中已有定义但未使用)。

支持 SafeSearch

代码中定义了 bingSafeSearch 枚举(Off, Moderate, Strict),但未在请求中使用。可以添加配置参数:

func (p *BingProvider) Search(..., safeSearch bingSafeSearch) {
    params.Set("safeSearch", string(safeSearch))
}

缓存搜索结果

当前实现每次搜索都调用 Bing API。可以添加缓存层(如 Redis)减少 API 调用:

// 伪代码
cacheKey := fmt.Sprintf("bing:%s:%d", query, maxResults)
if cached, ok := cache.Get(cacheKey); ok {
    return cached, nil
}
results, err := p.doSearch(...)
cache.Set(cacheKey, results, 1*time.Hour)

相关模块

On this page