30分鐘快速打造一個完善的直播聊天系統
下面的程式碼基於高效能的通訊王牌工具 Netty。我們將一些實際場景都新增進去,比如使用者身份的驗證,遊客只能瀏覽不能發言,多房間(頻道)的聊天。
這篇文章非常適合和我一樣的 Java 新手,適合作為學習 Java 的切入點,不需要考慮tomcat
、spring
、mybatis
等。唯一的知識點就是 maven 的基礎使用。
完整的程式碼地址
專案結果如下
├── WebSocketServer.java 啟動伺服器埠監聽
├── WebSocketServerInitializer.java 初始化服務
├── WebSocketServerHandler.java 接管WebSocket資料連線
├── dto
│ └── Response.java 返回給客戶端資料物件
├── entity
│ └── Client.java 每個連線到WebSocket服務的客戶端物件
└── service
├── MessageService.java 完成傳送訊息
└── RequestService.java WebSocket初始化連線握手時的資料處理
功能設計概述
身份認證
客戶端將使用者 id 、進入的房間的 rid、使用者 token json_encode
,例如{id:1;rid:21;token:`xxx`}
。然後在 base64
處理,通過引數request
傳到伺服器,然後在伺服器做 id 和 token 的驗證(我的做法是 token 存放在redis string 5秒的過期時間)
房間表
使用一個Map channelGroupMap
來存放各個房間(頻道),以客戶端傳握手時傳過來的base64 字串中獲取到定義的房間 ID,然後為該房間 ID 新建一個ChannelGroup
(ChannelGroup
方便對該組內的所有客戶端廣播訊息)
在 pom.xml 中引入netty 5
現在大家都有自己的包管理工具,不需要實現下載瞭然後放到本地lib
庫中,和 nodejs 的 npm, php 的 compser 一樣。
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>5.0.0.Alpha2</version>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jzlib</artifactId>
<version>1.1.2</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20141113</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.10</version>
</dependency>
</dependencies>
建立伺服器
package net.mengkang;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
public final class WebSocketServer {
private static final int PORT = 8083;
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new WebSocketServerInitializer());
Channel ch = b.bind(PORT).sync().channel();
ch.closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
package net.mengkang;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler;
public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new WebSocketServerCompressionHandler());
pipeline.addLast(new WebSocketServerHandler());
}
}
處理長連線
下面程式中最的處理在握手階段handleHttpRequest
,裡面處理引數的判斷,使用者的認證,登入使用者表的維護,直播房間表維護。詳細的請大家對照程式碼來瀏覽。
握手完成之後的訊息傳遞則在handleWebSocketFrame
中處理。
整理的執行流程,大家可以對各個方法打斷點予以除錯,就會很清楚整個執行的脈絡啦。
package net.mengkang;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.*;
import io.netty.handler.codec.http.websocketx.*;
import io.netty.util.CharsetUtil;
import io.netty.util.concurrent.GlobalEventExecutor;
import net.mengkang.dto.Response;
import net.mengkang.entity.Client;
import net.mengkang.service.MessageService;
import net.mengkang.service.RequestService;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static io.netty.handler.codec.http.HttpHeaderNames.HOST;
import static io.netty.handler.codec.http.HttpMethod.GET;
import static io.netty.handler.codec.http.HttpResponseStatus.*;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
public class WebSocketServerHandler extends SimpleChannelInboundHandler<Object> {
// websocket 服務的 uri
private static final String WEBSOCKET_PATH = "/websocket";
// 一個 ChannelGroup 代表一個直播頻道
private static Map<Integer, ChannelGroup> channelGroupMap = new HashMap<>();
// 本次請求的 code
private static final String HTTP_REQUEST_STRING = "request";
private Client client = null;
private WebSocketServerHandshaker handshaker;
@Override
public void messageReceived(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof FullHttpRequest) {
handleHttpRequest(ctx, (FullHttpRequest) msg);
} else if (msg instanceof WebSocketFrame) {
handleWebSocketFrame(ctx, (WebSocketFrame) msg);
}
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
// Handle a bad request.
if (!req.decoderResult().isSuccess()) {
sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, BAD_REQUEST));
return;
}
// Allow only GET methods.
if (req.method() != GET) {
sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, FORBIDDEN));
return;
}
if ("/favicon.ico".equals(req.uri()) || ("/".equals(req.uri()))) {
sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, NOT_FOUND));
return;
}
QueryStringDecoder queryStringDecoder = new QueryStringDecoder(req.uri());
Map<String, List<String>> parameters = queryStringDecoder.parameters();
if (parameters.size() == 0 || !parameters.containsKey(HTTP_REQUEST_STRING)) {
System.err.printf(HTTP_REQUEST_STRING + "引數不可預設");
sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, NOT_FOUND));
return;
}
client = RequestService.clientRegister(parameters.get(HTTP_REQUEST_STRING).get(0));
if (client.getRoomId() == 0) {
System.err.printf("房間號不可預設");
sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, NOT_FOUND));
return;
}
// 房間列表中如果不存在則為該頻道,則新增一個頻道 ChannelGroup
if (!channelGroupMap.containsKey(client.getRoomId())) {
channelGroupMap.put(client.getRoomId(), new DefaultChannelGroup(GlobalEventExecutor.INSTANCE));
}
// 確定有房間號,才將客戶端加入到頻道中
channelGroupMap.get(client.getRoomId()).add(ctx.channel());
// Handshake
WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(getWebSocketLocation(req), null, true);
handshaker = wsFactory.newHandshaker(req);
if (handshaker == null) {
WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
} else {
ChannelFuture channelFuture = handshaker.handshake(ctx.channel(), req);
// 握手成功之後,業務邏輯
if (channelFuture.isSuccess()) {
if (client.getId() == 0) {
System.out.println(ctx.channel() + " 遊客");
return;
}
}
}
}
private void broadcast(ChannelHandlerContext ctx, WebSocketFrame frame) {
if (client.getId() == 0) {
Response response = new Response(1001, "沒登入不能聊天哦");
String msg = new JSONObject(response).toString();
ctx.channel().write(new TextWebSocketFrame(msg));
return;
}
String request = ((TextWebSocketFrame) frame).text();
System.out.println(" 收到 " + ctx.channel() + request);
Response response = MessageService.sendMessage(client, request);
String msg = new JSONObject(response).toString();
if (channelGroupMap.containsKey(client.getRoomId())) {
channelGroupMap.get(client.getRoomId()).writeAndFlush(new TextWebSocketFrame(msg));
}
}
private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) {
if (frame instanceof CloseWebSocketFrame) {
handshaker.close(ctx.channel(), (CloseWebSocketFrame) frame.retain());
return;
}
if (frame instanceof PingWebSocketFrame) {
ctx.channel().write(new PongWebSocketFrame(frame.content().retain()));
return;
}
if (!(frame instanceof TextWebSocketFrame)) {
throw new UnsupportedOperationException(String.format("%s frame types not supported", frame.getClass().getName()));
}
broadcast(ctx, frame);
}
private static void sendHttpResponse(ChannelHandlerContext ctx, FullHttpRequest req, FullHttpResponse res) {
if (res.status().code() != 200) {
ByteBuf buf = Unpooled.copiedBuffer(res.status().toString(), CharsetUtil.UTF_8);
res.content().writeBytes(buf);
buf.release();
HttpHeaderUtil.setContentLength(res, res.content().readableBytes());
}
ChannelFuture f = ctx.channel().writeAndFlush(res);
if (!HttpHeaderUtil.isKeepAlive(req) || res.status().code() != 200) {
f.addListener(ChannelFutureListener.CLOSE);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel incoming = ctx.channel();
System.out.println("收到" + incoming.remoteAddress() + " 握手請求");
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
if (client != null && channelGroupMap.containsKey(client.getRoomId())) {
channelGroupMap.get(client.getRoomId()).remove(ctx.channel());
}
}
private static String getWebSocketLocation(FullHttpRequest req) {
String location = req.headers().get(HOST) + WEBSOCKET_PATH;
return "ws://" + location;
}
}
客戶端程式
<html>
<head><title></title></head>
<body>
<script type="text/javascript">
var socket;
if (!window.WebSocket) {
window.WebSocket = window.MozWebSocket;
}
if (window.WebSocket) {
socket = new WebSocket("ws://localhost:8083/websocket/?request=e2lkOjE7cmlkOjI2O3Rva2VuOiI0MzYwNjgxMWM3MzA1Y2NjNmFiYjJiZTExNjU3OWJmZCJ9");
socket.onmessage = function(event) {
console.log(event.data);
};
socket.onopen = function(event) {
console.log("websocket 開啟了");
};
socket.onclose = function(event) {
console.log("websocket 關閉了");
};
}
function send(message) {
if (!window.WebSocket) { return; }
if (socket.readyState == WebSocket.OPEN) {
socket.send(message);
} else {
alert("The socket is not open.");
}
}
</script>
<form onsubmit="return false;">
<input type="text" name="message" value="Hello, World!"/>
<input type="button" value="Send Web Socket Data" onclick="send(this.form.message.value)" />
</form>
</body>
</html>
可以偽造多個房間(修改客戶端請求的request引數),在 php cli 模式執行下命令來獲取
php -r "echo base64_encode(`{id:1;rid:26;token:"xxx"}`);"
併發壓測
感謝同事 https://github.com/ideal 寫的壓測指令碼
https://github.com/zhoumengkang/netty-websocket/blob/master/benchmark.py
並測試為N個客戶端,每個客戶端傳送10條訊息,伺服器配置2核4G記憶體,廣播給所有的客戶端,我們測試1000個併發的時候,負載在後期陡升。
實際情況下,不可能那麼多人同時說話廣播,而是說話的人少,接受廣播的人多。
實際線上之後,在不限制刷帖頻率大家狂轟濫炸的情況下,1500多人線上,半小時,負載一直都處於0.5以下。
相關文章
- 開發一對一直播聊天室一對一表演按分鐘賺錢的軟體系統。
- 實現一個webscoket聊天系統Web
- 1對1直播原始碼改變直播傳統模式新穎一對一聊天系統原始碼模式
- 直播系統聊天技術(七):直播間海量聊天訊息的架構設計難點實踐架構
- 2022直播交友原始碼一對多直播系統原始碼同城視訊聊天交友app原始碼APP
- FastAPI(56)- 使用 Websocket 打造一個迷你聊天室ASTAPIWeb
- 使用 python 打造一個微信聊天機器人Python機器人
- Websocket 直播間聊天室教程 - GoEasy 快速實現聊天室WebGo
- Java智慧之Spring AI:5分鐘打造智慧聊天模型的利器JavaSpringAI模型
- 語音聊天系統原始碼如何才能快速搭建原始碼
- 直播系統聊天技術(六):百萬人線上的直播間實時聊天訊息分發技術實踐
- 完美的一對一聊天原始碼一定要有完善功能及售後體系原始碼
- 5分鐘打造一個前端效能監控工具前端
- 一對一直播原始碼視訊聊天系統開發完全按照使用者的喜好去做原始碼
- 秒殺系統:如何打造並維護一個超大流量的秒殺系統?
- 直播系統原始碼,極光IM簡單的聊天介面全手動原始碼
- 直播教學系統原始碼搭建定製影片直播功能完善低延時負載強原始碼負載
- 從0開始寫一個直播間的禮物系統
- 用瀏覽器打造一個開箱即用的Linux系統--Instantbox瀏覽器Linux
- 如何讓玩家在遊戲中花錢? 一個完善的促銷系統就能搞定遊戲
- 使用React Hooks + 自定義Hook封裝一步一步打造一個完善的小型應用。ReactHook封裝
- 【從頭到腳】擼一個社交聊天系統(vue + node + mongodb)- ???VchatVueMongoDB
- 基於EasyTcp4Net開發一個功能較為完善的去持久化聊天軟體TCP持久化
- 用ChatGPT,快速設計一個真實的賬號系統ChatGPT
- 一個作業系統的設計與實現——第23章 快速系統呼叫作業系統
- 面對一個完全陌生的系統,如何快速的熟悉並上手?
- 【開箱即用】開發了一個基於環信IM聊天室的Vue3外掛,從而快速實現仿直播間聊天窗功能Vue
- 直播原始碼:一對一視訊聊天app哪個比較高階?原始碼APP
- 短影片直播原生APP功能完善定製系統無加密多終端支援APP加密
- 線上教育直播APP私有化部署功能完善分銷系統小程式APP
- 打造一個window桌面應用:線上聊天對話機器人機器人
- 乾貨 | 如何用 Python 打造一個聊天機器人?【附程式碼】Python機器人
- 不到40行 Python 程式碼打造一個簡單的推薦系統Python
- 移動網際網路時代一對一直播原始碼聊天系統改變著我們的生活原始碼
- 簡單的 swoole 聊天室 (持續更新、完善)
- jenkins+ansible+supervisor打造一個web構建釋出系統JenkinsWeb
- 深度探究MMO社交對話系統(一):聊天系統的進化與價值
- 區塊鏈社交直播軟體開發app,IM聊天系統開發區塊鏈APP
- 即時聊天社交系統開發/聊天交友/ChatGPT社交聊天ChatGPT