← Back to Blog
EN中文

视频推荐中的延时查看数据结构设计

在视频搜索和推荐系统中,推荐算法需要根据用户的观看行为动态生成推荐列表。其中,“延时查看”(Delayed View)是一种重要的推荐场景:当用户正在观看视频 A 时,系统需要预测并推荐用户接下来可能想看的内容,比如“下一集”、“相关影片”或者“同类推荐”。

这种推荐逻辑的背后,是一套精心设计的数据结构在支撑。本文将深入分析工业级代码中的设计思想,并用 Go 语言进行净室重构演示。

从问题到建模

延时查看场景需要表达的信息相当丰富:

  1. 基本属性:视频 URL、缩略图、标题、时长
  2. 进度信息:用户当前的播放位置、总时长
  3. 类型标识:这是电影?剧集?还是单纯的推荐?
  4. 元数据:如果是剧集,需要知道是第几季、第几集

面对这些需求,一种 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))
}

总结

本文深入分析了视频推荐系统中延时查看数据结构的工业级设计,探讨了以下核心权衡:

  1. 位掩码 vs 字符串:位掩码提供了类型安全的组合能力,但需要额外的解析逻辑
  2. 可选字段:强类型系统中的 Maybe 模式需要权衡语义明确性与库的通用性
  3. 序列化分支:当类型较少时条件分支更简单,当类型膨胀时需要考虑多态方案

这些设计选择没有绝对的好坏之分,关键在于理解场景的约束并做出合理的取舍。