35 KiB
PanSou 搜索插件开发指南
目录
插件系统概述
PanSou 网盘搜索系统采用了灵活的插件架构,允许开发者轻松扩展搜索来源。插件系统具有以下特点:
- 自动注册机制:插件通过 init 函数自动注册,无需修改主程序代码
- 统一接口:所有插件实现相同的 SearchPlugin 接口
- 双层超时控制:插件内部使用自定义超时时间,系统外部提供强制超时保障
- 并发执行:插件搜索与频道搜索并发执行,提高整体性能
- 结果标准化:插件返回标准化的搜索结果,便于统一处理
- 异步处理:支持异步插件,实现"尽快响应,持续处理"的模式
插件系统的核心是全局插件注册表,它在应用启动时收集所有已注册的插件,并在搜索时并行调用这些插件。
插件接口说明
每个插件必须实现 SearchPlugin 接口,该接口定义如下:
// SearchPlugin 搜索插件接口
type SearchPlugin interface {
// Name 返回插件名称
Name() string
// Search 执行搜索并返回结果
Search(keyword string) ([]model.SearchResult, error)
// Priority 返回插件优先级(用于控制结果排序)
Priority() int
}
接口方法说明
-
Name()
- 返回插件的唯一标识名称
- 名称应简洁明了,全小写,不含特殊字符
- 例如:
pansearch、hunhepan、jikepan
-
Search(keyword string)
- 执行搜索并返回结果
- 参数
keyword是用户输入的搜索关键词 - 返回值是搜索结果数组和可能的错误
- 实现时应处理超时和错误,确保不会无限阻塞
-
Priority()
- 返回插件的优先级,用于控制结果排序
- 建议值:1(低)、2(中)、3(高)
- 优先级高的插件结果可能会被优先展示
插件开发流程
1. 创建插件包
在 pansou/plugin 目录下创建新的插件包:
pansou/
└── plugin/
└── myplugin/
└── myplugin.go
2. 实现插件结构体
package myplugin
import (
"net/http"
"time"
"pansou/model"
"pansou/plugin"
)
// 常量定义
const (
// 默认超时时间
DefaultTimeout = 5 * time.Second
)
// MyPlugin 自定义插件结构体
type MyPlugin struct {
client *http.Client
timeout time.Duration
}
// NewMyPlugin 创建新的插件实例
func NewMyPlugin() *MyPlugin {
timeout := DefaultTimeout
return &MyPlugin{
client: &http.Client{
Timeout: timeout,
},
timeout: timeout,
}
}
3. 实现 SearchPlugin 接口
// Name 返回插件名称
func (p *MyPlugin) Name() string {
return "myplugin"
}
// Priority 返回插件优先级
func (p *MyPlugin) Priority() int {
return 2 // 中等优先级
}
// Search 执行搜索并返回结果
func (p *MyPlugin) Search(keyword string) ([]model.SearchResult, error) {
// 实现搜索逻辑
// ...
return results, nil
}
4. 注册插件
在插件包的 init 函数中注册插件:
// 在init函数中注册插件
func init() {
plugin.RegisterGlobalPlugin(NewMyPlugin())
}
5. 在主程序中导入插件
在 pansou/main.go 中导入插件包(使用空导入):
import (
// 导入插件包以触发init函数
_ "pansou/plugin/myplugin"
)
数据结构标准
SearchResult 结构体
插件需要返回 []model.SearchResult 类型的数据:
// SearchResult 表示搜索结果
type SearchResult struct {
UniqueID string // 唯一标识
Title string // 标题
Content string // 内容描述
Datetime time.Time // 日期时间
Links []Link // 链接列表
Tags []string // 标签列表
}
// Link 表示网盘链接
type Link struct {
URL string // 链接地址
Type string // 链接类型
Password string // 提取码
}
字段说明
-
UniqueID:
- 结果的唯一标识,建议格式:
插件名-序号 - 例如:
myplugin-1、myplugin-2
- 结果的唯一标识,建议格式:
-
Title:
- 资源的标题
- 应尽可能保留原始标题,不要添加额外信息
- 例如:
火影忍者全集高清资源
-
Content:
- 资源的描述内容
- 可以包含文件列表、大小、格式等信息
- 应清理HTML标签等无关内容
-
Datetime:
- 资源的发布时间或更新时间
- 如果没有时间信息,使用零值
time.Time{} - 不要使用当前时间
time.Now()
-
Links:
- 资源的链接列表
- 每个资源可以有多个不同类型的链接
- 每个链接必须包含URL和Type,Password可选
-
URL:
- 网盘链接的完整URL
- 必须包含协议部分(如http://或https://)
- 例如:
https://pan.baidu.com/s/1abcdefg
-
Type:
- 链接类型,必须使用以下标准值之一:
baidu- 百度网盘aliyun- 阿里云盘xunlei- 迅雷云盘quark- 夸克网盘tianyi- 天翼云盘115- 115网盘weiyun- 微云lanzou- 蓝奏云jianguoyun- 坚果云mobile- 移动云盘(彩云)uc- UC网盘123- 123网盘pikpak- PikPak网盘ed2k- 电驴链接magnet- 磁力链接others- 其他类型
- 链接类型,必须使用以下标准值之一:
-
Password:
- 提取码或访问密码
- 如果没有密码,设置为空字符串
-
Tags:
- 资源的标签列表
- 可选字段,不是必须提供
具体示例
下面是几个完整的 SearchResult 结构体示例,展示了不同情况下的数据填充方式:
示例1:带有百度网盘链接的电影资源
// 创建一个带有百度网盘链接的电影资源搜索结果
movieResult := model.SearchResult{
UniqueID: "myplugin-1",
Title: "速度与激情10 4K蓝光原盘",
Content: "文件列表:\n- 速度与激情10.mp4 (25.6GB)\n- 花絮.mp4 (1.2GB)\n- 字幕.zip (15MB)",
Datetime: time.Date(2023, 8, 15, 10, 30, 0, 0, time.Local), // 2023-08-15 10:30:00
Links: []model.Link{
{
URL: "https://pan.baidu.com/s/1abcdefghijklmn",
Type: "baidu",
Password: "a1b2",
},
},
Tags: []string{"电影", "动作", "4K"},
}
示例2:带有多个网盘链接的软件资源
// 创建一个带有多个网盘链接的软件资源搜索结果
softwareResult := model.SearchResult{
UniqueID: "myplugin-2",
Title: "Photoshop 2023 完整破解版 Win+Mac",
Content: "Adobe Photoshop 2023 完整破解版,支持Windows和Mac系统,内含安装教程和注册机。",
Datetime: time.Date(2023, 6, 20, 15, 45, 0, 0, time.Local), // 2023-06-20 15:45:00
Links: []model.Link{
{
URL: "https://pan.baidu.com/s/1opqrstuvwxyz",
Type: "baidu",
Password: "c3d4",
},
{
URL: "https://www.aliyundrive.com/s/abcdefghijk",
Type: "aliyun",
Password: "", // 阿里云盘无提取码
},
{
URL: "https://pan.xunlei.com/s/12345678",
Type: "xunlei",
Password: "xunl",
},
},
Tags: []string{"软件", "设计", "Adobe"},
}
示例3:带有磁力链接的资源
// 创建一个带有磁力链接的资源搜索结果
torrentResult := model.SearchResult{
UniqueID: "myplugin-3",
Title: "权力的游戏 第一季 1080P 中英双字",
Content: "权力的游戏第一季全10集,1080P高清版本,内封中英双字幕。",
Datetime: time.Date(2022, 12, 5, 8, 0, 0, 0, time.Local), // 2022-12-05 08:00:00
Links: []model.Link{
{
URL: "magnet:?xt=urn:btih:1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t",
Type: "magnet",
Password: "", // 磁力链接没有密码
},
},
Tags: []string{"美剧", "奇幻", "1080P"},
}
示例4:没有时间信息的资源
// 创建一个没有时间信息的资源搜索结果
noTimeResult := model.SearchResult{
UniqueID: "myplugin-4",
Title: "中国历史文化名人传记合集",
Content: "包含100位中国历史文化名人的详细传记,PDF格式。",
Datetime: time.Time{}, // 使用零值表示没有时间信息
Links: []model.Link{
{
URL: "https://pan.quark.cn/s/12345abcde",
Type: "quark",
Password: "qwer",
},
},
Tags: []string{"电子书", "历史", "传记"},
}
示例5:多种文件格式的教程资源
// 创建一个包含多种文件格式的教程资源搜索结果
tutorialResult := model.SearchResult{
UniqueID: "myplugin-5",
Title: "Python数据分析实战教程 2023最新版",
Content: "包含视频教程、源代码、PPT讲义和练习题。适合Python初学者和有一定基础的开发者。",
Datetime: time.Date(2023, 9, 1, 12, 0, 0, 0, time.Local), // 2023-09-01 12:00:00
Links: []model.Link{
{
URL: "https://cloud.189.cn/t/abcdefg123456",
Type: "tianyi",
Password: "189t",
},
{
URL: "https://caiyun.139.com/m/i?abcdefghijk",
Type: "mobile",
Password: "139c",
},
},
Tags: []string{"教程", "Python", "数据分析"},
}
返回结果示例
插件的 Search 方法应返回一个 []model.SearchResult 切片,包含所有搜索结果:
// Search 执行搜索并返回结果
func (p *MyPlugin) Search(keyword string) ([]model.SearchResult, error) {
// ... 执行搜索逻辑 ...
// 创建结果切片
results := []model.SearchResult{
movieResult,
softwareResult,
torrentResult,
noTimeResult,
tutorialResult,
}
return results, nil
}
注意事项
-
链接类型映射: 如果源站点使用的链接类型名称与标准不同,需要进行映射,例如:
func mapLinkType(sourceType string) string { switch strings.ToLower(sourceType) { case "bd", "bdy", "baidu_pan": return "baidu" case "al", "aly", "aliyundrive": return "aliyun" case "ty", "tianyi_pan": return "tianyi" // ... 其他映射 default: return "others" } } -
URL格式化: 确保URL格式正确,特别是对于特殊链接类型:
// 确保百度网盘链接格式正确 if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { url = "https://" + url } // 确保磁力链接格式正确 if strings.HasPrefix(url, "magnet:") && !strings.HasPrefix(url, "magnet:?xt=urn:btih:") { // 格式不正确,尝试修复或跳过 } -
密码处理: 对于不同网盘的密码格式可能有所不同,需要适当处理:
// 百度网盘密码通常为4位 if linkType == "baidu" && len(password) > 4 { password = password[:4] } // 有些网盘可能在URL中包含密码参数 if linkType == "aliyun" && password == "" { // 尝试从URL中提取密码 if pwdIndex := strings.Index(url, "password="); pwdIndex != -1 { password = url[pwdIndex+9:] if endIndex := strings.Index(password, "&"); endIndex != -1 { password = password[:endIndex] } } }
超时控制
PanSou 采用双层超时控制机制,确保搜索请求能够在合理的时间内完成:
插件内部超时控制
每个插件应定义并使用自己的默认超时时间:
const (
// 默认超时时间
DefaultTimeout = 5 * time.Second
)
// NewMyPlugin 创建新的插件实例
func NewMyPlugin() *MyPlugin {
timeout := DefaultTimeout
return &MyPlugin{
client: &http.Client{
Timeout: timeout,
},
timeout: timeout,
}
}
插件应根据自身特点设置合适的超时时间:
- 需要并发请求多个页面的插件可能设置较短的单次请求超时
- 需要处理大量数据的插件可能设置较长的超时
系统外部超时控制
系统使用 ExecuteBatchWithTimeout 函数对所有插件任务进行统一的超时控制。即使插件内部没有正确处理超时,系统也能确保整体搜索在合理时间内完成。
超时时间通过环境变量 PLUGIN_TIMEOUT 配置,默认为 30 秒。
异步插件开发
对于响应时间较长的搜索源,建议使用异步插件模式,实现"尽快响应,持续处理"的搜索体验。
1. 异步插件架构
异步插件基于 BaseAsyncPlugin 基类实现,具有以下特点:
- 双级超时控制:短超时确保快速响应,长超时允许完整处理
- 缓存机制:自动缓存搜索结果,避免重复请求
- 后台处理:响应超时后继续在后台处理请求
- 增量更新:智能合并新旧结果,保留有价值的数据
- 资源管理:通过工作池控制并发任务数量,避免资源耗尽
2. 创建异步插件
2.1 基本结构
package myplugin_async
import (
"net/http"
"pansou/model"
"pansou/plugin"
)
// MyAsyncPlugin 自定义异步插件结构体
type MyAsyncPlugin struct {
*plugin.BaseAsyncPlugin
}
// NewMyAsyncPlugin 创建新的异步插件实例
func NewMyAsyncPlugin() *MyAsyncPlugin {
return &MyAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin_async", 3),
}
}
// 在init函数中注册插件
func init() {
plugin.RegisterGlobalPlugin(NewMyAsyncPlugin())
}
2.2 实现Search方法
// Search 执行搜索并返回结果
func (p *MyAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) {
// 生成缓存键
cacheKey := keyword
// 使用异步搜索基础方法
return p.AsyncSearch(keyword, cacheKey, p.doSearch)
}
// doSearch 实际的搜索实现
func (p *MyAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {
// 实现具体搜索逻辑
// 注意:client已经配置了适当的超时时间
// ...
return results, nil
}
3. 异步搜索流程
异步搜索的工作流程如下:
- 缓存检查:首先检查是否有有效缓存
- 快速响应:如果有缓存,立即返回;如果缓存接近过期,在后台刷新
- 双通道处理:如果没有缓存,启动快速响应通道和后台处理通道
- 超时控制:在响应超时时返回当前结果(可能为空),后台继续处理
- 缓存更新:后台处理完成后更新缓存,供后续查询使用
4. 异步缓存机制
4.1 缓存键设计
异步插件使用插件特定的缓存键,确保不同插件的缓存不会相互干扰:
// 在AsyncSearch方法中
pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, cacheKey)
4.2 缓存结构
// 缓存响应结构
type cachedResponse struct {
Results []model.SearchResult // 搜索结果
Timestamp time.Time // 缓存创建时间
Complete bool // 是否完整结果
LastAccess time.Time // 最后访问时间
AccessCount int // 访问计数
}
4.3 缓存持久化
异步插件缓存会自动保存到磁盘,并在程序启动时加载:
- 定期保存:每2分钟保存一次缓存
- 即时保存:缓存更新后立即触发保存
- 优雅关闭:程序退出前保存缓存
4.4 智能缓存管理
系统实现了基于多因素的缓存淘汰策略:
// 计算得分:访问次数 / (空闲时间的平方 * 年龄)
// 这样:
// - 访问频率高的得分高
// - 最近访问的得分高
// - 较新的缓存得分高
score := float64(item.AccessCount) / (idleTime.Seconds() * idleTime.Seconds() * age.Seconds())
5. 异步插件示例
5.1 混合盘异步插件
// HunhepanAsyncPlugin 混合盘搜索异步插件
type HunhepanAsyncPlugin struct {
*plugin.BaseAsyncPlugin
}
// NewHunhepanAsyncPlugin 创建新的混合盘搜索异步插件
func NewHunhepanAsyncPlugin() *HunhepanAsyncPlugin {
return &HunhepanAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("hunhepan_async", 3),
}
}
// Search 执行搜索并返回结果
func (p *HunhepanAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) {
// 生成缓存键
cacheKey := keyword
// 使用异步搜索基础方法
return p.AsyncSearch(keyword, cacheKey, p.doSearch)
}
// doSearch 实际的搜索实现
func (p *HunhepanAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {
// 创建结果通道和错误通道
resultChan := make(chan []HunhepanItem, 3)
errChan := make(chan error, 3)
// 创建等待组
var wg sync.WaitGroup
wg.Add(3)
// 并行请求三个API
go func() {
defer wg.Done()
items, err := p.searchAPI(client, HunhepanAPI, keyword)
if err != nil {
errChan <- fmt.Errorf("hunhepan API error: %w", err)
return
}
resultChan <- items
}()
// ... 其他API请求 ...
// 启动一个goroutine等待所有请求完成并关闭通道
go func() {
wg.Wait()
close(resultChan)
close(errChan)
}()
// 收集结果
var allItems []HunhepanItem
var errors []error
// 从通道读取结果
for items := range resultChan {
allItems = append(allItems, items...)
}
// 处理错误
for err := range errChan {
errors = append(errors, err)
}
// 如果没有获取到任何结果且有错误,则返回第一个错误
if len(allItems) == 0 && len(errors) > 0 {
return nil, errors[0]
}
// 去重处理
uniqueItems := p.deduplicateItems(allItems)
// 转换为标准格式
results := p.convertResults(uniqueItems)
return results, nil
}
6. 配置选项
异步插件系统提供了多种配置选项,可通过环境变量设置:
ASYNC_PLUGIN_ENABLED=true # 是否启用异步插件
ASYNC_RESPONSE_TIMEOUT=4 # 响应超时时间(秒)
ASYNC_MAX_BACKGROUND_WORKERS=20 # 最大后台工作者数量
ASYNC_MAX_BACKGROUND_TASKS=100 # 最大后台任务数量
ASYNC_CACHE_TTL_HOURS=1 # 异步缓存有效期(小时)
最佳实践
1. 错误处理
- 妥善处理HTTP请求错误
- 解析失败时返回有意义的错误信息
- 单个结果解析失败不应影响整体搜索
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
2. 并发控制
- 如果需要发起多个请求,使用并发控制
- 使用信号量或工作池限制并发数
- 确保所有goroutine都能正确退出
// 创建信号量限制并发数
semaphore := make(chan struct{}, maxConcurrent)
// 使用信号量
semaphore <- struct{}{}
defer func() { <-semaphore }()
3. 结果去重
- 在返回结果前进行初步去重
- 使用map存储唯一标识符
- 系统会在合并所有插件结果时进行最终去重
// 使用map进行去重
uniqueMap := make(map[string]Item)
// 将去重后的结果转换为切片
results := make([]Item, 0, len(uniqueMap))
for _, item := range uniqueMap {
results = append(results, item)
}
4. 清理HTML标签
- 清理标题和内容中的HTML标签
- 移除多余的空格和换行符
- 保留有用的格式信息
func cleanHTML(html string) string {
// 替换常见HTML标签
replacements := map[string]string{
"<em>": "",
"</em>": "",
"<b>": "",
"</b>": "",
}
result := html
for tag, replacement := range replacements {
result = strings.Replace(result, tag, replacement, -1)
}
return strings.TrimSpace(result)
}
5. 时间解析
- 正确解析资源的发布时间
- 如果无法获取时间,使用零值
- 不要使用当前时间代替缺失的时间
// 尝试解析时间
var datetime time.Time
if item.Time != "" {
parsedTime, err := time.Parse("2006-01-02 15:04:05", item.Time)
if err == nil {
datetime = parsedTime
}
}
// 如果解析失败,使用零值
if datetime.IsZero() {
datetime = time.Time{}
}
示例插件
以下是一个完整的示例插件实现:
package exampleplugin
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"pansou/model"
"pansou/plugin"
)
// 在init函数中注册插件
func init() {
plugin.RegisterGlobalPlugin(NewExamplePlugin())
}
const (
// API端点
ApiURL = "https://example.com/api/search"
// 默认超时时间
DefaultTimeout = 5 * time.Second
)
// ExamplePlugin 示例插件
type ExamplePlugin struct {
client *http.Client
timeout time.Duration
}
// NewExamplePlugin 创建新的示例插件
func NewExamplePlugin() *ExamplePlugin {
timeout := DefaultTimeout
return &ExamplePlugin{
client: &http.Client{
Timeout: timeout,
},
timeout: timeout,
}
}
// Name 返回插件名称
func (p *ExamplePlugin) Name() string {
return "exampleplugin"
}
// Priority 返回插件优先级
func (p *ExamplePlugin) Priority() int {
return 2 // 中等优先级
}
// Search 执行搜索并返回结果
func (p *ExamplePlugin) Search(keyword string) ([]model.SearchResult, error) {
// 构建请求URL
reqURL := fmt.Sprintf("%s?q=%s", ApiURL, url.QueryEscape(keyword))
// 发送请求
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
// 发送请求
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应体
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 解析响应
var apiResp ApiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 转换为标准格式
results := make([]model.SearchResult, 0, len(apiResp.Items))
for i, item := range apiResp.Items {
// 解析时间
var datetime time.Time
if item.Time != "" {
parsedTime, err := time.Parse("2006-01-02 15:04:05", item.Time)
if err == nil {
datetime = parsedTime
}
}
// 如果解析失败,使用零值
if datetime.IsZero() {
datetime = time.Time{}
}
// 创建链接
link := model.Link{
URL: item.URL,
Type: p.determineLinkType(item.URL),
Password: item.Password,
}
// 创建唯一ID
uniqueID := fmt.Sprintf("exampleplugin-%d", i)
// 创建搜索结果
result := model.SearchResult{
UniqueID: uniqueID,
Title: cleanHTML(item.Title),
Content: cleanHTML(item.Description),
Datetime: datetime,
Links: []model.Link{link},
}
results = append(results, result)
}
return results, nil
}
// determineLinkType 根据URL确定链接类型
func (p *ExamplePlugin) determineLinkType(url string) string {
lowerURL := strings.ToLower(url)
switch {
case strings.Contains(lowerURL, "pan.baidu.com"):
return "baidu"
case strings.Contains(lowerURL, "alipan.com") || strings.Contains(lowerURL, "aliyundrive.com"):
return "aliyun"
case strings.Contains(lowerURL, "pan.xunlei.com"):
return "xunlei"
// ... 其他类型判断
default:
return "others"
}
}
// cleanHTML 清理HTML标签
func cleanHTML(html string) string {
// 替换常见HTML标签
replacements := map[string]string{
"<em>": "",
"</em>": "",
"<b>": "",
"</b>": "",
}
result := html
for tag, replacement := range replacements {
result = strings.Replace(result, tag, replacement, -1)
}
return strings.TrimSpace(result)
}
// ApiResponse API响应结构
type ApiResponse struct {
Items []ApiItem `json:"items"`
Total int `json:"total"`
}
// ApiItem API响应中的单个结果项
type ApiItem struct {
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Password string `json:"password"`
Time string `json:"time"`
}
插件缓存实现
1. 缓存概述
插件级缓存是对系统整体两级缓存(内存+磁盘)的补充,主要针对插件内部的API调用和数据处理进行优化,减少重复计算和网络请求,提高系统整体性能和响应速度。
2. 设计目标
- 减少重复请求:避免短时间内对同一资源的重复请求,降低外部API负载
- 提高响应速度:通过缓存常用查询结果,减少网络延迟和处理时间
- 降低资源消耗:减少CPU和网络资源的使用
- 保持数据新鲜度:通过合理的缓存过期策略,平衡性能和数据时效性
- 线程安全:支持并发访问,避免竞态条件
- 内存管理:防止内存泄漏,控制缓存大小
3. 架构设计
3.1 整体架构
插件缓存采用分层设计,主要包含以下组件:
┌─────────────────────────┐
│ 插件缓存系统 │
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ 缓存存储层 │
│ (sync.Map实现的内存缓存) │
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ 缓存管理层 │
│ (缓存清理、过期策略、统计) │
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ 缓存接口层 │
│ (Load/Store操作封装) │
└─────────────────────────┘
3.2 缓存类型
根据不同插件的需求,可以实现多种类型的缓存:
- API响应缓存:缓存外部API的响应结果
- 解析结果缓存:缓存HTML解析、正则匹配等计算密集型操作的结果
- 链接提取缓存:缓存从文本中提取的链接结果
- 元数据缓存:缓存帖子ID、发布时间等元数据信息
4. 核心组件
4.1 缓存存储
使用sync.Map实现线程安全的内存缓存:
// 缓存相关变量
var (
// API响应缓存,键为"apiURL:keyword",值为缓存的响应
apiResponseCache = sync.Map{}
// 最后一次清理缓存的时间
lastCacheCleanTime = time.Now()
// 缓存有效期
cacheTTL = 1 * time.Hour
)
4.2 缓存结构
每个缓存项包含数据和时间戳,用于判断是否过期:
// 缓存响应结构
type cachedResponse struct {
data interface{} // 缓存的数据
timestamp time.Time // 缓存创建时间
}
4.3 缓存清理机制
定期清理过期缓存,防止内存泄漏:
// startCacheCleaner 启动一个定期清理缓存的goroutine
func startCacheCleaner() {
// 每小时清理一次缓存
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
// 清空所有缓存
apiResponseCache = sync.Map{}
lastCacheCleanTime = time.Now()
}
}
5. 实现方案
5.1 初始化缓存
在插件初始化时启动缓存清理机制:
func init() {
// 注册插件
plugin.RegisterGlobalPlugin(NewPlugin())
// 启动缓存清理goroutine
go startCacheCleaner()
}
5.2 缓存键生成
设计合理的缓存键,确保唯一性和高效查找:
// 生成缓存键
cacheKey := fmt.Sprintf("%s:%s", apiURL, keyword)
对于复杂的缓存键,可以使用结构体:
// 缓存键结构
type passwordCacheKey struct {
content string
url string
}
5.3 缓存读取
在执行操作前先检查缓存:
// 检查缓存中是否已有结果
if cachedItems, ok := apiResponseCache.Load(cacheKey); ok {
// 检查缓存是否过期
cachedResult := cachedItems.(cachedResponse)
if time.Since(cachedResult.timestamp) < cacheTTL {
return cachedResult.items, nil
}
}
5.4 缓存写入
操作完成后更新缓存:
// 缓存结果
apiResponseCache.Store(cacheKey, cachedResponse{
items: result,
timestamp: time.Now(),
})
5.5 缓存过期策略
使用TTL(Time-To-Live)机制控制缓存过期:
// 检查缓存是否过期
if time.Since(cachedResult.timestamp) < cacheTTL {
return cachedResult.items, nil
}
6. 具体实现案例
6.1 API响应缓存(Hunhepan插件)
// searchAPI 向单个API发送请求
func (p *HunhepanPlugin) searchAPI(apiURL, keyword string) ([]HunhepanItem, error) {
// 生成缓存键
cacheKey := fmt.Sprintf("%s:%s", apiURL, keyword)
// 检查缓存中是否已有结果
if cachedItems, ok := apiResponseCache.Load(cacheKey); ok {
// 检查缓存是否过期
cachedResult := cachedItems.(cachedResponse)
if time.Since(cachedResult.timestamp) < cacheTTL {
return cachedResult.items, nil
}
}
// 构建请求并发送...
// 缓存结果
apiResponseCache.Store(cacheKey, cachedResponse{
items: apiResp.Data.List,
timestamp: time.Now(),
})
return apiResp.Data.List, nil
}
6.2 解析结果缓存(Panta插件)
// extractLinksFromElement 从HTML元素中提取链接
func (p *PantaPlugin) extractLinksFromElement(s *goquery.Selection, yearFromTitle string) []model.Link {
// 创建缓存键
html, _ := s.Html()
cacheKey := fmt.Sprintf("%s:%s", html, yearFromTitle)
// 检查缓存
if cachedLinks, ok := linkExtractCache.Load(cacheKey); ok {
return cachedLinks.([]model.Link)
}
// 提取链接...
// 缓存结果
linkExtractCache.Store(cacheKey, links)
return links
}
6.3 元数据缓存(Panta插件)
// 从href中提取topicId - 使用缓存
var topicID string
if cachedID, ok := topicIDCache.Load(href); ok {
topicID = cachedID.(string)
} else {
match := topicIDRegex.FindStringSubmatch(href)
if len(match) < 2 {
return
}
topicID = match[1]
topicIDCache.Store(href, topicID)
}
7. 性能优化
7.1 缓存粒度控制
根据数据特性选择合适的缓存粒度:
- 粗粒度缓存:缓存整个API响应,适合查询结果较小且稳定的场景
- 细粒度缓存:缓存处理过程中的中间结果,适合复杂处理流程
7.2 缓存预热
对于常用查询,可以实现缓存预热机制:
// 预热常用关键词的缓存
func warmupCache() {
commonKeywords := []string{"电影", "音乐", "软件", "教程"}
for _, keyword := range commonKeywords {
go func(kw string) {
_, _ = searchAPI(apiURL, kw)
}(keyword)
}
}
7.3 自适应TTL
根据数据更新频率动态调整缓存有效期:
// 根据内容类型确定TTL
func determineTTL(contentType string) time.Duration {
switch contentType {
case "movie":
return 24 * time.Hour // 电影资源更新较慢
case "news":
return 30 * time.Minute // 新闻更新较快
default:
return 1 * time.Hour
}
}
8. 监控与统计
8.1 缓存命中率统计
var (
cacheHits int64
cacheMisses int64
)
// 记录缓存命中
func recordCacheHit() {
atomic.AddInt64(&cacheHits, 1)
}
// 记录缓存未命中
func recordCacheMiss() {
atomic.AddInt64(&cacheMisses, 1)
}
// 获取缓存命中率
func getCacheHitRate() float64 {
hits := atomic.LoadInt64(&cacheHits)
misses := atomic.LoadInt64(&cacheMisses)
total := hits + misses
if total == 0 {
return 0
}
return float64(hits) / float64(total)
}
8.2 缓存大小监控
// 估算缓存大小
func estimateCacheSize() int64 {
var size int64
apiResponseCache.Range(func(key, value interface{}) bool {
// 估算每个缓存项的大小
cachedResp := value.(cachedResponse)
// 根据实际数据结构估算大小
size += int64(len(fmt.Sprintf("%v", cachedResp.data))) * 2 // 粗略估计
return true
})
return size
}
9. 最佳实践
- 选择性缓存:只缓存频繁访问且计算成本高的数据
- 合理的TTL:根据数据更新频率设置合适的过期时间
- 缓存键设计:确保缓存键唯一且能反映所有影响结果的因素
- 错误处理:不缓存错误响应,避免错误传播
- 缓存大小控制:设置缓存项数量上限,避免内存溢出
- 并发安全:使用线程安全的数据结构和原子操作
- 定期清理:实现自动清理机制,避免内存泄漏
常见问题
1. 插件注册失败
问题:插件未被系统识别和加载
解决方案:
- 确保在
init()函数中调用了plugin.RegisterGlobalPlugin() - 确保在
main.go中导入了插件包(使用空导入) - 检查插件名称是否为空或重复
2. 搜索超时
问题:插件搜索经常超时
解决方案:
- 调整插件的默认超时时间
- 使用并发请求减少总体响应时间
- 实现请求重试机制
- 优化请求逻辑,减少不必要的请求
3. 结果格式错误
问题:插件返回的结果格式不正确
解决方案:
- 严格按照数据结构标准构造返回值
- 确保链接类型使用标准值
- 正确处理时间格式
- 清理HTML标签和特殊字符
4. 内存泄漏
问题:插件导致内存使用量持续增长
解决方案:
- 确保所有goroutine都能正确退出
- 关闭HTTP响应体
- 避免无限循环
- 限制结果集大小
5. 错误处理不当
问题:插件错误影响了整个系统
解决方案:
- 捕获并记录所有可能的错误
- 使用超时控制避免长时间阻塞
- 在返回错误前进行必要的资源清理
- 对于非致命错误,返回部分结果而不是完全失败