简介:基于Tomcat8、Java7与ExtJS构建的WebSocket聊天室完整工程源码,面向具备一定Java Web基础、希望打通实时通信链路的开发者与课程设计学习者。工程以JSR 356标准的javax.websocket端点承载服务端连接管理,配合ExtJS组件搭建聊天窗口、输入框与消息列表,可直接部署在Tomcat8容器中运行,用于理解握手、消息广播与在线状态维护等环节。压缩包共1753个文件、约7.01MB,含3个java源文件、6个class与11个jar依赖,另有大体量gif与png图像、scss与css样式、50个js脚本,构成界面皮肤与前端交互层,xml、jsp及Eclipse工程配置文件保证目录可直接导入。已有263人学习。读者可据此复现一个可运行的实时聊天原型,对照服务端生命周期回调与客户端封装代码梳理消息推送流程,并扩展登录校验、历史记录与并发连接处理。
1. 内网客服台要加群聊,老后台只能上 WebSocket
内网客服台要加一个群聊窗口,前端是十几年前搭起来的 ExtJS 后台,后端只能是 Java 7,容器锁定 Tomcat 8。我拿到这份源码时,先把它当成一个可拆的工具,而不是完整产品:服务端用 JSR-356 的@ServerEndpoint接连接,前端用 ExtJS 包一层 WebSocket,消息走 JSON 文本。它适合维护老后台、要给内部系统加即时通知、又不想引入新中间件的开发者。下面按能跑起来、能改动的顺序拆。
2. 服务端握手与会话管理:@ServerEndpoint背后的连接生命周期
Tomcat 8 自带 WebSocket 实现,Java 7 也能跑,但工程边界要先定死:一个 Maven WAR,maven.compiler.source/target都是1.7,javax.websocket-api只放在编译期,作用域provided。这样 WAR 里不会把容器的 WebSocket 实现打进去,部署时少一类类加载冲突。目录我一般这样摆:
chatroom/ pom.xml src/main/java/demo/chat/ endpoint/ChatEndpoint.java endpoint/OriginConfigurator.java model/ChatMessage.java util/JsonUtil.java src/main/webapp/ WEB-INF/web.xml index.html app.js app/view/ChatPanel.js app/ux/Socket.jsendpoint只放连接入口,model放协议对象,util放 Gson 单例,前端按 ExtJS 的view、ux分开。不要把广播、在线表、JSON 解析全塞进一个类,后面加房间和私聊会很难受。
2.1 依赖与编译级别:Java 7 下别写 lambda
pom.xml里最关键的是两个provided和一个 Java 7 兼容的 JSON 库。WebSocket API 由 Tomcat 8 提供,Servlet API 同理;Gson 用你本地仓库里能跑在 Java 7 上的版本,不要随手拿需要 Java 8 的新版。
<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>demo.chat</groupId> <artifactId>chatroom</artifactId> <packaging>war</packaging> <properties> <maven.compiler.source>1.7</maven.compiler.source> <maven.compiler.target>1.7</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <websocket.api.version>1.1</websocket.api.version> <gson.version>${gson.java7.version}</gson.version> </properties> <dependencies> <dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>${websocket.api.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>${gson.version}</version> </dependency> </dependencies> <build> <finalName>chatroom</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <configuration> <failOnMissingWebXml>false</failOnMissingWebXml> </configuration> </plugin> </plugins> </build> </project>websocket.api.version对应 JSR-356 API,Tomcat 8 容器里有实现,所以scope必须是provided;gson.version换成你仓库里 Java 7 能加载的版本,别把新语法带进来。finalName决定 WAR 名,后面上下文路径就是/chatroom。Java 7 没有 lambda,onMessage的回调、SendHandler都老老实实写匿名类,别在 IDE 里自动补成() -> {},否则编译级别一卡就是一片红。
2.2onOpen里做三件事:拿用户、登记、发系统消息
端点路径写成/chat/{user},用户从 URL 路径里取,前端用encodeURIComponent处理昵称。@ServerEndpoint的configurator指向自定义握手拦截器,后面 2.4 再补。
@ServerEndpoint(value = "/chat/{user}", configurator = OriginConfigurator.class) public class ChatEndpoint { private static final ConcurrentHashMap<String, Session> ONLINE = new ConcurrentHashMap<String, Session>(); @OnOpen public void onOpen(Session session, @PathParam("user") String user) throws IOException { String name = user == null || user.trim().length() == 0 ? "guest" : user.trim(); session.getUserProperties().put("user", name); session.getUserProperties().put("lastActive", System.currentTimeMillis()); session.setMaxTextMessageBufferSize(8 * 1024); Session old = ONLINE.put(name, session); if (old != null && old.isOpen()) { old.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "duplicate login")); } ChatMessage msg = ChatMessage.system(name + " 进入聊天室", ONLINE.size()); broadcast(msg); } @OnClose public void onClose(Session session) { String name = (String) session.getUserProperties().get("user"); if (name == null) { return; } ONLINE.remove(name, session); broadcast(ChatMessage.system(name + " 离开聊天室", ONLINE.size())); } // onMessage、broadcast 在下一章补全 }@ServerEndpoint的value是相对上下文路径的端点地址,部署成chatroom.war后完整地址是ws://host:8080/chatroom/chat/张三。@PathParam把路径里的{user}注入方法参数;getUserProperties()是每个连接自己的小仓库,用来存昵称和最后活跃时间,不要用静态变量存“当前用户”。setMaxTextMessageBufferSize限制单条文本大小,聊天室给 8KB 足够,给太大容易被一条长消息拖住。ONLINE.put会返回同名的旧连接,重复登录时把旧连接关掉,避免同一个昵称有两个窗口都在线。
2.3 在线表为什么用ConcurrentHashMap
在线表就是Map<String, Session>,但别用HashMap,也别用Collections.synchronizedMap然后在广播时手动synchronized整张表。WebSocket 的onOpen、onClose、onMessage可能来自不同线程,广播时一边遍历一边有人下线,普通 Map 会直接抛ConcurrentModificationException。ConcurrentHashMap的迭代是弱一致的,广播时看到的是某一刻的快照,不会因为别人下线就崩。
另一个血泪经验是下线删除要写成remove(key, value),不要只写remove(key)。假设用户 A 断线重连,新连接已经放进去了,旧连接的onClose才姗姗来迟;如果直接按昵称删,会把新连接一起删掉,表现就是“人明明在线,消息发不过去”。ONLINE.remove(name, session)只有在值还是旧Session时才删,能避开这个回马枪。广播时如果想拿稳定快照,可以new ArrayList<Session>(ONLINE.values())再遍历,但单房间聊天室直接用弱一致迭代也够。
2.4 握手拦截:别让任意页面都能连
OriginConfigurator只做一件事:检查Origin头。内网也会有人把页面挂到别的域下,或者用工具直接连,能拦一层是一层。
public class OriginConfigurator extends ServerEndpointConfig.Configurator { private static final String ALLOWED = "http://localhost:8080"; @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { List<String> origins = request.getHeaders().get("Origin"); if (origins == null || origins.isEmpty() || !ALLOWED.equals(origins.get(0))) { throw new RuntimeException("Origin not allowed"); } sec.getUserProperties().put("origin", origins.get(0)); } }modifyHandshake在握手阶段执行,拿不到Session,只能改ServerEndpointConfig的userProperties。Origin头可能没有,比如某些非浏览器客户端;内网如果确实有这种调用方,就加白名单而不是直接放行。反向代理后面Origin可能变成代理地址,部署到那一步时把ALLOWED改成实际访问域名,别写死localhost之后又奇怪为什么生产连不上。
3. 消息协议与广播链路:一条聊天消息从 ExtJS 到所有在线窗口
服务端连接通了只是第一步,真正决定后面好不好改的是消息协议。聊天室不需要一上来就上 Protobuf,文本 JSON 足够:type区分加入、离开、聊天、心跳,from是发送者,content是正文,time给界面显示,online给在线人数。字段少,排查时一眼能看懂。
3.1ChatMessage与 Gson 单例
public class ChatMessage { private String type; // JOIN / LEAVE / SYSTEM / CHAT / PING / PONG private String from; private String content; private String time; private int online; public static ChatMessage system(String content, int online) { ChatMessage m = new ChatMessage(); m.setType("SYSTEM"); m.setFrom("system"); m.setContent(content); m.setTime(new SimpleDateFormat("HH:mm:ss").format(new Date())); m.setOnline(online); return m; } public String getType() { return type; } public void setType(String type) { this.type = type; } public String getFrom() { return from; } public void setFrom(String from) { this.from = from; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getTime() { return time; } public void setTime(String time) { this.time = time; } public int getOnline() { return online; } public void setOnline(int online) { this.online = online; } }type用字符串而不是枚举,前端加新类型时不用同步改枚举序列化;代价是服务端要自己兜底未知类型。time用HH:mm:ss就够了,聊天室不靠时间排序,靠服务端到达顺序。SimpleDateFormat不是线程安全的,别写成静态共享实例;每次new一个,量级不大,省心。
Gson 单例放在JsonUtil里:
public final class JsonUtil { public static final Gson GSON = new Gson(); private JsonUtil() {} }Gson
本文还有配套的精品资源,点击获取