SpringBoot+WebSocket实现直播连麦

一、引言

随着互联网技术的发展,直播已成为一种主流的内容传播形式。

其中,连麦功能作为直播互动的重要手段,能够有效提升用户参与感和观看体验。

本文将介绍如何使用SpringBoot和WebSocket技术构建一个直播连麦系统,实现主播与观众之间的实时音视频交流和文字聊天功能。

为了方便DEMO的运行,本系统基于纯内存操作实现核心业务逻辑,不依赖外部数据库或者缓存组件。

二、技术设计

2.1 技术栈

  • 后端:SpringBoot 3.4.5、WebSocket、STOMP子协议、JWT
  • 前端: HTML5、CSS3、JavaScript、WebRTC、SockJS

2.2 整体架构

  • 后端:基于SpringBoot实现,负责用户认证、直播间管理、WebSocket消息处理等
  • 前端:负责用户界面交互、WebRTC音视频传输、WebSocket连接管理等
  • 通信协议:WebSocket + STOMP实现实时消息传递,WebRTC实现P2P音视频传输

2.3 核心功能模块

  1. 用户认证模块:处理用户登录和简单认证
  2. 直播间管理模块:创建、查询和管理直播间
  3. WebSocket通信模块:处理实时消息传递
  4. 连麦管理模块:处理连麦请求、状态管理和信令交换
  5. 聊天消息模块:处理直播间内的文字聊天

2.3 数据流程

  1. 用户通过HTTP接口登录获取Token
  2. 主播创建直播间,系统分配直播间ID
  3. 观众通过直播间ID加入直播间
  4. 观众发送连麦请求,主播确认后建立WebRTC连接
  5. 所有用户通过WebSocket发送和接收聊天消息

三、后端实现

3.1 项目基础配置

Maven依赖

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- WebSocket -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    
    <!-- JWT -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

应用配置

server:
  port: 8080

spring:
  application:
    name: livestream-system

jwt:
  secret: livestreamSecretKey123456789012345678901234567890
  expiration: 86400000 

3.2 WebSocket配置

package com.example.livestream.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Autowired
    private WebSocketAuthInterceptor webSocketAuthInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    /*@Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 使用/topic和/queue前缀的目的地会路由到消息代理
        registry.enableSimpleBroker("/topic", "/queue", "/user");
        
        // 用户目标前缀
        registry.setUserDestinationPrefix("/user");
        
        // 应用前缀
        registry.setApplicationDestinationPrefixes("/app");
    }*/

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 使用/topic和/queue前缀的目的地会路由到消息代理
        registry.enableSimpleBroker("/topic", "/queue");

        // 用户目标前缀
        registry.setUserDestinationPrefix("/user");

        // 应用前缀
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(webSocketAuthInterceptor);
    }
}

3.3 WebSocket认证拦截器

package com.example.livestream.config;

import com.example.livestream.service.UserService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class WebSocketAuthInterceptor implements ChannelInterceptor {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private UserService userService;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        
        if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
            String token = accessor.getFirstNativeHeader("Authorization");
            
            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7);
                
                try {
                    String username = jwtTokenUtil.getUsernameFromToken(token);
                    if (username != null && jwtTokenUtil.validateToken(token)) {
                        accessor.setUser(() -> username);
                        log.info("User authenticated: {}", username);
                    }
                } catch (Exception e) {
                    log.error("Invalid JWT token: {}", e.getMessage());
                }
            }
        }
        
        return message;
    }
}

3.4 JWT工具类

package com.example.livestream.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtTokenUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    private Key getSigningKey() {
        byte[] keyBytes = secret.getBytes();
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(String username) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, username);
    }

    private String createToken(Map<String, Object> claims, String subject) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    public String getUsername(){
        // 获取HttpServletRequest对象
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        return (String)request.getAttribute("username");
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

3.5 模型类设计

用户模型

package com.example.livestream.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private String username;
    private String password;
    private String nickname;
    private String avatar;
    private UserRole role; // BROADCASTER or AUDIENCE
    
    public enum UserRole {
        BROADCASTER,
        AUDIENCE
    }
}

直播间模型

package com.example.livestream.model;

import lombok.Data;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Data
public class LiveRoom {
    private String roomId;
    private String title;
    private String broadcaster;
    private LocalDateTime createdTime;
    private boolean active;
    
    // 连麦用户列表
    private Set<String> activeMicUsers = new CopyOnWriteArraySet<>();
    
    // 观众列表
    private Set<String> audiences = new CopyOnWriteArraySet<>();
    
    // 连麦申请列表
    private Map<String, MicRequest> micRequests = new ConcurrentHashMap<>();
    
    @Data
    public static class MicRequest {
        private String username;
        private LocalDateTime requestTime;
        private MicRequestStatus status;
        
        public enum MicRequestStatus {
            PENDING,
            ACCEPTED,
            REJECTED
        }
    }
}

消息模型

package com.example.livestream.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
    private String roomId;
    private String sender;
    private String content;
    private LocalDateTime timestamp;
    private MessageType type;
    
    public enum MessageType {
        CHAT,
        JOIN,
        LEAVE,
        MIC_REQUEST,
        MIC_RESPONSE,
        SIGNAL
    }
}

3.6 服务层实现

用户服务

package com.example.livestream.service;

import com.example.livestream.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
@Slf4j
public class UserService {

    // 内存中存储用户信息
    private final Map<String, User> users = new ConcurrentHashMap<>();

    public UserService() {
        // 初始化一些测试用户
        users.put("zb1", new User("zb1", "zb1", "主播一号",
                "avatar1.jpg", User.UserRole.BROADCASTER));
        users.put("zb2", new User("zb2", "zb2", "主播二号",
                "avatar2.jpg", User.UserRole.BROADCASTER));
        users.put("gz1", new User("gz1", "gz1", "观众一号",
                "user1.jpg", User.UserRole.AUDIENCE));
        users.put("gz2", new User("gz2", "gz2", "观众二号",
                "user2.jpg", User.UserRole.AUDIENCE));
    }

    public User getUserByUsername(String username) {
        return users.get(username);
    }

    public boolean validateUser(String username, String password) {
        User user = users.get(username);
        return user != null && user.getPassword().equals(password);
    }

    public User registerUser(User user) {
        if (users.containsKey(user.getUsername())) {
            return null;
        }
        users.put(user.getUsername(), user);
        return user;
    }
}

直播间服务

package com.example.livestream.service;

import com.example.livestream.model.LiveRoom;
import com.example.livestream.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Service
@Slf4j
public class LiveRoomService {

    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    @Autowired
    private UserService userService;

    // 内存中存储直播间信息
    private final Map<String, LiveRoom> liveRooms = new ConcurrentHashMap<>();

    public LiveRoom createLiveRoom(String title, String username) {
        User user = userService.getUserByUsername(username);
        if (user == null || user.getRole() != User.UserRole.BROADCASTER) {
            return null;
        }

        String roomId = UUID.randomUUID().toString().substring(0, 8);
        LiveRoom liveRoom = new LiveRoom();
        liveRoom.setRoomId(roomId);
        liveRoom.setTitle(title);
        liveRoom.setBroadcaster(username);
        liveRoom.setCreatedTime(LocalDateTime.now());
        liveRoom.setActive(true);

        liveRooms.put(roomId, liveRoom);
        log.info("Live room created: {} by {}", roomId, username);
        return liveRoom;
    }

    public LiveRoom getLiveRoom(String roomId) {
        return liveRooms.get(roomId);
    }

    public List<LiveRoom> getAllActiveLiveRooms() {
        List<LiveRoom> activeRooms = new ArrayList<>();
        for (LiveRoom room : liveRooms.values()) {
            if (room.isActive()) {
                activeRooms.add(room);
            }
        }
        return activeRooms;
    }

    public boolean joinLiveRoom(String roomId, String username) {
        LiveRoom room = liveRooms.get(roomId);
        if (room == null || !room.isActive()) {
            return false;
        }

        room.getAudiences().add(username);
        log.info("User {} joined room {}", username, roomId);
        return true;
    }

    public boolean leaveLiveRoom(String roomId, String username) {
        LiveRoom room = liveRooms.get(roomId);
        if (room == null) {
            return false;
        }

        room.getAudiences().remove(username);
        room.getActiveMicUsers().remove(username);
        room.getMicRequests().remove(username);
        log.info("User {} left room {}", username, roomId);
        return true;
    }

    public boolean closeLiveRoom(String roomId, String username) {
        LiveRoom room = liveRooms.get(roomId);
        if (room == null || !room.getBroadcaster().equals(username)) {
            return false;
        }

        room.setActive(false);
        log.info("Room {} closed by {}", roomId, username);
        return true;
    }

    public boolean requestMic(String roomId, String username) {
        LiveRoom room = liveRooms.get(roomId);
        if (room == null || !room.isActive() || !room.getAudiences().contains(username)) {
            return false;
        }

        LiveRoom.MicRequest request = new LiveRoom.MicRequest();
        request.setUsername(username);
        request.setRequestTime(LocalDateTime.now());
        request.setStatus(LiveRoom.MicRequest.MicRequestStatus.PENDING);
        
        room.getMicRequests().put(username, request);
        
        // 通知主播有新的连麦请求
        messagingTemplate.convertAndSendToUser(
                room.getBroadcaster(), 
                "/queue/mic-requests", 
                request);
        
        log.info("Mic request from {} in room {}", username, roomId);
        return true;
    }

    public boolean handleMicRequest(String roomId, String requestUsername, 
                                   boolean accept, String broadcasterUsername) {
        LiveRoom room = liveRooms.get(roomId);
        if (room == null || !room.isActive() || 
            !room.getBroadcaster().equals(
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值