一、引言
随着互联网技术的发展,直播已成为一种主流的内容传播形式。
其中,连麦功能作为直播互动的重要手段,能够有效提升用户参与感和观看体验。
本文将介绍如何使用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 核心功能模块
- 用户认证模块:处理用户登录和简单认证
- 直播间管理模块:创建、查询和管理直播间
- WebSocket通信模块:处理实时消息传递
- 连麦管理模块:处理连麦请求、状态管理和信令交换
- 聊天消息模块:处理直播间内的文字聊天
2.3 数据流程
- 用户通过HTTP接口登录获取Token
- 主播创建直播间,系统分配直播间ID
- 观众通过直播间ID加入直播间
- 观众发送连麦请求,主播确认后建立WebRTC连接
- 所有用户通过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(

最低0.47元/天 解锁文章
2030

被折叠的 条评论
为什么被折叠?



