视频推荐中的延时查看数据结构设计
在视频搜索和推荐系统中,推荐算法需要根据用户的观看行为动态生成推荐列表。其中,“延时查看”(Delayed View)是一种重要的推荐场景:当用户正在观看视频 A 时,系统需要预测并推荐用户接下来可能想看的内容,比如“下一集”、“相关影片”或者“同类推荐”。
这种推荐逻辑的背后,是一套精心设计的数据结构在支撑。本文将深入分析工业级代码中的设计思想,并用 Go 语言进行净室重构演示。
从问题到建模
延时查看场景需要表达的信息相当丰富:
- 基本属性:视频 URL、缩略图、标题、时长
- 进度信息:用户当前的播放位置、总时长
- 类型标识:这是电影?剧集?还是单纯的推荐?
- 元数据:如果是剧集,需要知道是第几季、第几集
面对这些需求,一种 naive 的做法是使用 Map<String, String> 或者简单的 JSON 对象。但这种做法会带来几个问题:
- 类型不安全:字符串 key 容易写错,编译期无法检查
- 语义模糊:无法区分“未设置”和“设置为空”
- 性能开销:运行时解析字符串 key 的开销
工业级系统通常采用强类型 + 位掩码的方案来解决这个问题。
位掩码:类型安全的组合
在原始代码中,视图类型被定义为 enum class EDelayedViewType,并使用位掩码支持多类型组合:
enum class EDelayedViewType {
DVT_COMMON = (1 << 0), // "common"
DVT_FILM = (1 << 1), // "film"
DVT_SERIAL = (1 << 2), // "serial"
DVT_NEXT_EPISODE = (1 << 3) // "next_episode"
};
这种设计的优势在于:
- 组合能力:一个视频可以同时是“电影”类型又是“最新剧集”类型
- 位运算高效:判断类型只需要一次位运算,而不是多次字符串比较
- 类型安全:编译期就能发现类型错误
可选字段:Maybe 模式的权衡
剧集信息(SerialInfo)不是每个视频都有的——只有剧集类视频才有意义。原始代码使用 TMaybe<TSerialInfo> 来建模这种“可能存在”的字段:
TMaybe<TSerialInfo> MaybeSerialInfo;
TMaybe<TEntityInfo> MaybeEntityInfo;
这种设计 vs 直接使用 std::optional 或指针的权衡:
- 语义明确:
TMaybe显式表达“未定义” vs “空值”的区别 - 库一致性:与项目其他模块的接口风格保持一致
- 学习成本:需要理解项目特定的类型系统
序列化:领域对象到 JSON 的桥梁
推荐系统的前端通常需要 JSON 格式的数据。原始代码提供了 DelayedViewToJson 函数来执行转换。这个转换过程涉及一些有趣的分支逻辑:
const bool isNextEpisode = (delayedView.Type == EDelayedViewType::DVT_NEXT_EPISODE);
if (!isNextEpisode) {
result["duration"] = delayedView.Duration;
// 计算播放进度
}
这里体现了多态 vs 条件分支的权衡:
- 多态方案:为每种类型定义不同的序列化器
- 条件分支:在一个函数中用 if-else 处理
在类型数量较少时,条件分支更简单直接;当类型膨胀到数十种时,多态方案更易维护。
Go 净室演示
下面是用 Go 编写的净室演示代码,复述了上述设计思想:
package main
import (
"encoding/json"
"fmt"
)
// 设计思想:视频推荐中的延时查看数据结构设计
//
// 演示代码复述了工业级系统中的数据模型权衡:
// 1. **强类型定义**:使用常量而非字符串来区分视图类型
// 2. **位掩码支持多选**:通过位运算支持组合类型
// 3. **可选字段建模**:使用指针处理可选元数据
// 视图类型常量 - 对应 C++ 的 enum class EDelayedViewType
const (
ViewTypeCommon = 1 << 0 // "common"
ViewTypeFilm = 1 << 1 // "film"
ViewTypeSerial = 1 << 2 // "serial"
ViewTypeNextEpisode = 1 << 3 // "next_episode"
)
// DelayedView 领域对象
type DelayedView struct {
Type int `json:"type"`
URL string `json:"url"`
ThumbURL string `json:"thumb"`
Title string `json:"title"`
TicksCount int64 `json:"ticks_count"`
Duration int64 `json:"duration"`
QueryText string `json:"query_text"`
Timestamp int64 `json:"ts"`
IsPorno bool `json:"is_porno"`
SerialInfo *SerialInfo `json:"serial_info,omitempty"`
EntityInfo *EntityInfo `json:"entity_info,omitempty"`
}
// 剧集信息
type SerialInfo struct {
SerialId uint32 `json:"serial_id"`
SeasonId uint32 `json:"season_id"`
EpisodeId uint32 `json:"episode_id"`
}
// 实体信息
type EntityInfo struct {
Type string `json:"type"`
Id string `json:"id"`
}
// ToJSON 将领域对象序列化为 JSON
func (dv DelayedView) ToJSON() (map[string]interface{}, error) {
result := make(map[string]interface{})
// 类型转换:位掩码 -> 字符串
typeStr := "unknown"
switch {
case (dv.Type & ViewTypeNextEpisode) != 0:
typeStr = "next_episode"
case (dv.Type & ViewTypeSerial) != 0:
typeStr = "serial"
case (dv.Type & ViewTypeFilm) != 0:
typeStr = "film"
case (dv.Type & ViewTypeCommon) != 0:
typeStr = "common"
}
result["type"] = typeStr
// 公共字段
result["thumb"] = dv.ThumbURL
result["title"] = dv.Title
result["ts"] = dv.Timestamp
isNextEpisode := (dv.Type & ViewTypeNextEpisode) != 0
// 根据类型分支处理
if !isNextEpisode {
result["duration"] = dv.Duration
if dv.Duration == 0 {
result["progress"] = 1.0
} else {
result["progress"] = float64(dv.TicksCount) / float64(dv.Duration)
}
}
// CGI 输出
cgis := map[string]interface{}{
"text": dv.QueryText,
}
if isNextEpisode {
cgis["filmId"] = "placeholder_id"
} else {
cgis["url"] = dv.URL
cgis["filmId"] = "placeholder_id"
}
cgis["source"] = "vdv"
result["cgis"] = cgis
// Tags
tags := []string{}
if isNextEpisode {
tags = append(tags, "next_episode")
} else {
tags = append(tags, "view_time")
}
result["tags"] = tags
// 可选字段:仅当存在时才序列化 (omitempty)
if dv.SerialInfo != nil {
result["serial_info"] = dv.SerialInfo
}
if dv.EntityInfo != nil {
result["entity_info"] = dv.EntityInfo
}
return result, nil
}
func main() {
fmt.Println("=== 视频延时查看数据结构:净室演示 (Go) ===")
// 示例 1:普通视频延时查看
view1 := DelayedView{
Type: ViewTypeCommon,
URL: "https://example.com/video1",
ThumbURL: "https://example.com/thumb1.jpg",
Title: "精彩纪录片",
TicksCount: 120,
Duration: 600,
QueryText: "纪录片",
Timestamp: 1700000000,
IsPorno: false,
}
json1, _ := view1.ToJSON()
jsonBytes, _ := json.MarshalIndent(json1, "", " ")
fmt.Printf("\n[普通视图] JSON 输出:\n%s\n", string(jsonBytes))
// 示例 2:下一集推荐(带剧集信息)
view2 := DelayedView{
Type: ViewTypeNextEpisode | ViewTypeSerial,
URL: "https://example.com/episode2",
ThumbURL: "https://example.com/thumb2.jpg",
Title: "连续剧第2集",
TicksCount: 0,
Duration: 1800,
QueryText: "下一集",
Timestamp: 1700000100,
IsPorno: false,
SerialInfo: &SerialInfo{
SerialId: 12345,
SeasonId: 1,
EpisodeId: 2,
},
}
json2, _ := view2.ToJSON()
jsonBytes2, _ := json.MarshalIndent(json2, "", " ")
fmt.Printf("\n[下一集推荐] JSON 输出:\n%s\n", string(jsonBytes2))
}
总结
本文深入分析了视频推荐系统中延时查看数据结构的工业级设计,探讨了以下核心权衡:
- 位掩码 vs 字符串:位掩码提供了类型安全的组合能力,但需要额外的解析逻辑
- 可选字段:强类型系统中的 Maybe 模式需要权衡语义明确性与库的通用性
- 序列化分支:当类型较少时条件分支更简单,当类型膨胀时需要考虑多态方案
这些设计选择没有绝对的好坏之分,关键在于理解场景的约束并做出合理的取舍。
系列: Arch (9/90)
系列页
▼