【面试题】如何从0开始实现一个弹幕系统

177 阅读10分钟

在互联网视频娱乐蓬勃发展的当下,实时弹幕系统早已从锦上添花的附加功能,进化为现代视频网站和直播平台不可或缺的核心组件。

它打破了传统单向的观看模式,让观众在沉浸于视频内容的同时,能即时发送评论。这些承载着用户观点、情绪与共鸣的弹幕,以横向滚动的形式穿梭于视频画面之上,不仅显著提升了内容的互动性,更让每个观看者都能感受到 “不是一个人在追剧 / 看直播” 的社区参与感,形成独特的群体观看氛围。

跟着我学习完之后你能获得如下成果: 弹幕效果演示 在这里插入图片描述

接下来,我将带领大家进行技术实战,详细演示如何运用 SpringBoot 框架,一步步搭建起一个实时弹幕系统。

一、什么是实时弹幕系统

在弹幕系统中,用户发送的评论会即时呈现在视频画面上。这些评论从右向左横向滚动,让所有观众都能直观看到他人的实时反馈,实现高效的互动交流。

  1. 实时性:弹幕毫秒级推送至客户端,实现即时显示。
  2. 互动性:用户评论实时可见,支持观点即时回应,构建集体观看场景。
  3. 时间关联性:弹幕携带时间戳,通过算法精准对应视频播放时间点。
  4. 视觉冲击力:系统控制弹幕密度与流速,形成视觉效果的同时避免遮挡画面。

二、技术设计

2.1 整体架构

在这里插入图片描述

我们本次开发的实时弹幕系统主要包含以下:

  • 前端播放器:实现视频播放,通过 HTML5 Canvas 或 CSS 动画展示弹幕。
  • WebSocket 服务:基于 WebSocket 协议,实现弹幕消息实时双向传输。
  • 弹幕存储:使用数据库或分布式存储,保存弹幕内容、时间及关联时间戳。
  • 内容过滤组件:利用 NLP 技术和关键词算法,实时过滤不良弹幕内容。

2.2 通信协议

想要实现本次的实时弹幕系统,首先需要选择一个适合的通信协议。例如:

协议优点缺点适用场景
WebSocket全双工通信,低延迟,广泛支持需要服务器保持连接,资源消耗较大实时性要求高的场景
SSE (Server-Sent Events)服务器推送,简单实现只支持服务器到客户端的单向通信服务器推送更新场景
长轮询 (Long Polling)兼容性好,实现简单效率低,延迟高兼容性要求高的场景

最后我选择使用 SpringBoot 和 WebSocket 技术去实现。

三、后端代码实现

3.1 添加 maven 依赖

pom.xml 中添加:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

3.2 创建 WebSocket 配置类

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

/**
 * WebSocket 配置类
 * 配置 STOMP 协议和消息代理,支持弹幕功能
 */
@Configuration
@EnableWebSocketMessageBroker  // 启用基于 WebSocket 的消息代理
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    // 消息代理主题前缀(客户端订阅用)
    private static final String BROKER_TOPIC_PREFIX = "/topic";
    // 应用目标前缀(服务端消息处理用)
    private static final String APPLICATION_DESTINATION_PREFIX = "/app";
    // WebSocket 连接端点路径
    private static final String WEBSOCKET_CONNECTION_ENDPOINT = "/ws-barrage";

    /**
     * 配置消息代理设置
     * @param registry 消息代理注册器
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 启用简单内存消息代理,客户端可订阅这些主题接收消息
        registry.enableSimpleBroker(BROKER_TOPIC_PREFIX);
        // 设置应用目标前缀,过滤需要服务端处理的消息
        registry.setApplicationDestinationPrefixes(APPLICATION_DESTINATION_PREFIX);
    }

    /**
     * 注册 STOMP 协议端点
     * @param registry STOMP 端点注册器
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint(WEBSOCKET_CONNECTION_ENDPOINT)
                .setAllowedOriginPatterns("*")  // 生产环境应限制为特定来源
                .withSockJS();  // 启用 SockJS 回退支持
    }
}

3.3 定义弹幕消息模型类

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 弹幕信息实体类
 * 对应数据库中的 Barrage 表
 */
@Data
@TableName("barrage")
public class Barrage {
    
    /**
     * 主键ID,自增
     */
    @TableId(type = IdType.AUTO)
    private Long id;
    
    /**
     * 弹幕内容,不允许为空
     */
    @TableField(value = "content", strategy = FieldStrategy.NOT_EMPTY)
    private String content;
    
    /**
     * 弹幕颜色,十六进制颜色码(如:#FFFFFF)
     */
    @TableField("color")
    private String color;
    
    /**
     * 字体大小(单位:px)
     */
    @TableField("font_size")
    private Integer fontSize;
    
    /**
     * 弹幕出现的时间点(单位:秒,支持小数)
     */
    @TableField("time")
    private Double time;
    
    /**
     * 关联的视频ID
     */
    @TableField("video_id")
    private String videoId;
    
    /**
     * 发送用户ID
     */
    @TableField("user_id")
    private String userId;
    
    /**
     * 用户名(冗余存储,避免频繁联表查询)
     */
    @TableField("username")
    private String username;
    
    /**
     * 创建时间,自动填充
     */
    @TableField(value = "created_time", fill = FieldFill.INSERT)
    private LocalDateTime createdTime;
}

3.4 定义弹幕消息传输对象类

import lombok.Data;

/**
 * 弹幕消息传输对象(DTO)
 * 用于客户端与服务端之间的弹幕数据传输
 */
@Data
public class BarrageDTO {
    /**
     * 弹幕文本内容(必填)
     */
    private String content;

    /**
     * 弹幕颜色(十六进制颜色码)
     * 默认值:白色(#FFFFFF)
     */
    private String color = "#FFFFFF";

    /**
     * 弹幕字体大小(单位:像素)
     * 默认值:25px
     */
    private Integer fontSize = 25;

    /**
     * 弹幕出现时间点(单位:秒,精确到小数点后3位)
     */
    private Double time;

    /**
     * 关联的视频ID(必填)
     */
    private String videoId;

    /**
     * 发送用户ID(必填)
     */
    private String userId;

    /**
     * 发送用户昵称/用户名
     */
    private String username;
}

3.5 定义 Mapper 接口

/**
 * 弹幕数据访问接口
 * 提供弹幕数据的CRUD及自定义查询操作
 */
@Mapper
public interface BarrageMapper extends BaseMapper<Barrage> {
    
    /**
     * 根据视频ID查询所有弹幕(按时间升序排序)
     * 
     * @param videoId 视频ID(不能为空)
     * @return 按时间排序的弹幕列表
     */
    @Select("SELECT id, content, color, font_size, time, video_id, user_id, username, created_time " +
            "FROM barrage " +
            "WHERE video_id = #{videoId} " +
            "ORDER BY time ASC")
    List<Barrage> selectByVideoIdOrderByTimeAsc(@Param("videoId") String videoId);
    
    /**
     * 根据视频ID和时间范围查询弹幕(按时间升序排序)
     * 
     * @param videoId 视频ID(不能为空)
     * @param startTime 起始时间(单位:秒)
     * @param endTime 结束时间(单位:秒)
     * @return 指定时间范围内的弹幕列表
     */
    @Select("SELECT id, content, color, font_size, time, video_id, user_id, username, created_time " +
            "FROM barrage " +
            "WHERE video_id = #{videoId} " +
            "AND time BETWEEN #{startTime} AND #{endTime} " +
            "ORDER BY time ASC")
    List<Barrage> selectByVideoIdAndTimeRange(
            @Param("videoId") String videoId, 
            @Param("startTime") Double startTime, 
            @Param("endTime") Double endTime);
}

3.6 弹幕服务实现类

/**
 * 弹幕服务类
 * 处理弹幕相关的业务逻辑,包括保存、查询和实时推送
 */
@Service
public class BarrageService {
    
    private final BarrageMapper barrageMapper;
    private final SimpMessagingTemplate messagingTemplate;
    
    @Autowired
    public BarrageService(BarrageMapper barrageMapper, SimpMessagingTemplate messagingTemplate) {
        this.barrageMapper = barrageMapper;
        this.messagingTemplate = messagingTemplate;
    }
    
    /**
     * 保存并广播弹幕
     * @param barrageDto 弹幕数据传输对象
     * @return 保存后的弹幕实体
     */
    public Barrage saveAndBroadcastBarrage(BarrageDTO barrageDto) {
        // 内容过滤(简单示例)
        String filteredContent = filterContent(barrageDto.getContent());
        
        // 创建弹幕实体
        Barrage barrage = new Barrage();
        barrage.setContent(filteredContent);
        barrage.setColor(barrageDto.getColor());
        barrage.setFontSize(barrageDto.getFontSize());
        barrage.setTime(barrageDto.getTime());
        barrage.setVideoId(barrageDto.getVideoId());
        barrage.setUserId(barrageDto.getUserId());
        barrage.setUsername(barrageDto.getUsername());
        barrage.setCreatedTime(LocalDateTime.now());
        
        // 保存到数据库
        barrageMapper.insert(barrage);
        
        // 通过WebSocket广播到客户端
        messagingTemplate.convertAndSend("/topic/video/" + barrage.getVideoId(), barrage);
        
        return barrage;
    }
    
    /**
     * 根据视频ID获取弹幕列表(按时间升序排序)
     * @param videoId 视频ID
     * @return 弹幕列表
     */
    public List<Barrage> getBarragesByVideoId(String videoId) {
        return barrageMapper.selectByVideoIdOrderByTimeAsc(videoId);
    }
    
    /**
     * 根据视频ID和时间范围获取弹幕列表
     * @param videoId 视频ID
     * @param startTime 起始时间(秒)
     * @param endTime 结束时间(秒)
     * @return 弹幕列表
     */
    public List<Barrage> getBarragesByVideoIdAndTimeRange(
            String videoId, Double startTime, Double endTime) {
        return barrageMapper.selectByVideoIdAndTimeRange(videoId, startTime, endTime);
    }
    
    /**
     * 弹幕内容过滤
     * @param content 原始内容
     * @return 过滤后的内容
     */
    private String filterContent(String content) {
        // 实际应用中这里可能会有更复杂的过滤逻辑
        String[] sensitiveWords = {"敏感词1", "敏感词2", "敏感词3"};
        String filtered = content;
        
        for (String word : sensitiveWords) {
            filtered = filtered.replaceAll(word, "***");
        }
        
        return filtered;
    }
}

3.7 弹幕接口控制器类

/**
 * 弹幕控制器
 * 提供弹幕相关的WebSocket和REST API接口
 */
@RestController
@RequestMapping("/api/barrage")
public class BarrageController {
    
    private final BarrageService barrageService;
    
    @Autowired
    public BarrageController(BarrageService barrageService) {
        this.barrageService = barrageService;
    }
    
    /**
     * 发送弹幕(WebSocket接口)
     * @param barrageDto 弹幕数据传输对象
     * @return 保存后的弹幕实体
     */
    @MessageMapping("/barrage/send")
    public Barrage sendBarrage(@RequestBody BarrageDTO barrageDto) {
        return barrageService.saveAndBroadcastBarrage(barrageDto);
    }
    
    /**
     * 获取视频的所有弹幕(REST API)
     * @param videoId 视频ID
     * @return 弹幕列表响应实体
     */
    @GetMapping("/video/{videoId}")
    public ResponseEntity<List<Barrage>> getBarragesByVideoId(
            @PathVariable String videoId) {
        List<Barrage> barrages = barrageService.getBarragesByVideoId(videoId);
        return ResponseEntity.ok(barrages);
    }
    
    /**
     * 获取视频指定时间范围内的弹幕(REST API)
     * @param videoId 视频ID
     * @param start 起始时间(秒)
     * @param end 结束时间(秒)
     * @return 弹幕列表响应实体
     */
    @GetMapping("/video/{videoId}/time-range")
    public ResponseEntity<List<Barrage>> getBarragesByTimeRange(
            @PathVariable String videoId,
            @RequestParam Double start,
            @RequestParam Double end) {
        List<Barrage> barrages = barrageService.getBarragesByVideoIdAndTimeRange(videoId, start, end);
        return ResponseEntity.ok(barrages);
    }
}

四、前端代码实现

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>弹幕视频播放器</title>
    <style>
        /* 基础样式重置 */
        body {
            margin: 0;
            padding: 0;
            min-height: 100vh;
            background: linear-gradient(135deg, #232526 0%, #414345 100%);
            font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
        }

        /* 视频容器样式 - 包含视频和弹幕层 */
        #video-container {
            position: relative;
            width: 800px;
            height: 450px;
            margin: 40px auto 0 auto;
            background-color: #000;
            overflow: hidden;
            border-radius: 18px;
            box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
        }

        /* 视频播放器样式 */
        #video-player {
            width: 100%;
            height: 100%;
            border-radius: 18px;
        }

        /* 弹幕容器 - 覆盖在视频上方但不影响视频操作 */
        #barrage-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            pointer-events: none; /* 允许点击穿透到视频 */
        }

        /* 单条弹幕样式 */
        .barrage {
            position: absolute;
            white-space: nowrap;
            font-family: "Microsoft YaHei", sans-serif;
            font-weight: bold;
            text-shadow:
                -1px -1px 0 #000,
                1px -1px 0 #000,
                -1px 1px 0 #000,
                1px 1px 0 #000;
            animation-name: barrage-move;
            animation-timing-function: linear;
            animation-fill-mode: forwards;
            will-change: transform; /* 优化动画性能 */
        }

        /* 弹幕移动动画 */
        @keyframes barrage-move {
            from {
                transform: translateX(100%);
            }
            to {
                transform: translateX(-100%);
            }
        }

        /* 弹幕表单容器 */
        .barrage-form-wrapper {
            width: 800px;
            max-width: 95vw;
            margin: 28px auto 0 auto;
            display: flex;
            justify-content: center;
        }

        /* 弹幕输入表单样式 */
        #barrage-form {
            width: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            gap: 12px;
            background: rgba(255, 255, 255, 0.10);
            box-shadow: 0 4px 16px 0 rgba(31, 38, 135, 0.17);
            border-radius: 12px;
            padding: 16px 12px 12px 12px;
            backdrop-filter: blur(4px); /* 毛玻璃效果 */
        }

        /* 弹幕输入框 */
        #barrage-input {
            width: 40%;
            min-width: 120px;
            max-width: 320px;
            padding: 10px 14px;
            border-radius: 6px;
            border: 1px solid #d1d5db;
            outline: none;
            font-size: 16px;
            background: rgba(255, 255, 255, 0.85);
            transition: border 0.2s;
        }

        /* 输入框聚焦状态 */
        #barrage-input:focus {
            border: 1.5px solid #1890ff;
        }

        /* 颜色选择器 */
        #color-picker {
            width: 38px;
            height: 38px;
            border: none;
            background: none;
            cursor: pointer;
            border-radius: 6px;
            box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
        }

        /* 字体大小选择器 */
        #font-size {
            padding: 8px 10px;
            border-radius: 6px;
            border: 1px solid #d1d5db;
            background: rgba(255, 255, 255, 0.85);
            font-size: 16px;
            outline: none;
            transition: border 0.2s;
        }

        /* 发送按钮样式 */
        #send-btn {
            padding: 10px 24px;
            background: linear-gradient(90deg, #1890ff 0%, #40a9ff 100%);
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 16px;
            font-weight: bold;
            box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
            transition: background 0.2s, transform 0.1s;
        }

        /* 发送按钮悬停效果 */
        #send-btn:hover {
            background: linear-gradient(90deg, #40a9ff 0%, #1890ff 100%);
            transform: translateY(-2px) scale(1.04);
        }

        /* 响应式设计 - 平板及以下设备 */
        @media (max-width: 900px) {
            #video-container,
            .barrage-form-wrapper {
                width: 98vw;
                min-width: 0;
            }
        }

        /* 响应式设计 - 手机设备 */
        @media (max-width: 600px) {
            #video-container,
            .barrage-form-wrapper {
                width: 100vw;
                min-width: 0;
                border-radius: 0;
            }

            #barrage-form {
                flex-direction: column;
                gap: 8px;
                padding: 10px 2vw 8px 2vw;
            }

            #barrage-input {
                width: 90vw;
                max-width: 98vw;
            }
        }
    </style>
</head>

<body>
<!-- 视频播放区域 -->
<div id="video-container">
    <!-- 视频播放器 -->
    <video id="video-player" controls>
        <source src="code.mp4" type="video/mp4">
        您的浏览器不支持 video 标签。
    </video>

    <!-- 弹幕显示层 -->
    <div id="barrage-container"></div>
</div>

<!-- 弹幕控制表单 -->
<div class="barrage-form-wrapper">
    <form id="barrage-form" onsubmit="return false;">
        <!-- 弹幕内容输入框 -->
        <input type="text" id="barrage-input" placeholder="发送弹幕..." maxlength="100">

        <!-- 弹幕颜色选择器 -->
        <input type="color" id="color-picker" value="#ffffff" title="选择弹幕颜色">

        <!-- 弹幕字体大小选择 -->
        <select id="font-size">
            <option value="18"></option>
            <option value="24" selected></option>
            <option value="30"></option>
        </select>

        <!-- 发送按钮 -->
        <button id="send-btn" type="submit">发送</button>
    </form>
</div>

<!-- 引入WebSocket相关库 -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.5.0/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>

<!-- 弹幕业务逻辑脚本 -->
<script src="barrage.js"></script>
</body>
</html>

由于篇幅有限,本文只展示了部分核心代码,想要完整代码学习的同学 可以找我免费领取