本项目的源代码地址:https://github.com/Alexshi5/learn-parent/tree/master/learn-javaweb/f1chapter10-websocket
本项目的前导文章:JavaWeb高级编程(十)—— 在应用程序中使用WebSocket进行交互
通常聊天有两种实现方式:
聊天室 —— 它有超过两个参与者,通常最大数量没有上限;
私聊 —— 它通常只有两个参与者,其他人都无法看到聊天的内容。
无论是私聊还是聊天室,服务器端的实现基本上都是相同的:服务器接受连接,关联所有相关的连接,并将进入的消息发送到相关的连接中。它们之间最大的区别就是关联彼此连接的数目。
下面是项目的代码示例:
com.fasterxml.jackson.core
jackson-databind
org.apache.commons
commons-lang3
jstl
jstl
javax.servlet
javax.servlet-api
provided
javax.servlet.jsp
javax.servlet.jsp-api
provided
javax.websocket
javax.websocket-api
1.0
2.9.2
1.2
2.3.1
4.0.0
3.6
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
用户登录
用户登录
账号或密码错误,请重新尝试!
package com.mengfei.chat;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Hashtable;
import java.util.Map;
/**
* author Alex
* date 2018/12/30
* description 用于用户登录的Servlet
*/
@WebServlet(name = "loginServlet",urlPatterns = "/login")
public class LoginServlet extends HttpServlet{
private static final Map userDatabase = new Hashtable<>();
static {
userDatabase.put("customer001", "customer001");
userDatabase.put("customer002", "customer002");
userDatabase.put("service001", "service001");
userDatabase.put("service002", "service002");
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession();
if(request.getParameter("logout") != null)
{
session.invalidate();
response.sendRedirect("login");
return;
}
else if(session.getAttribute("username") != null)
{
request.getRequestDispatcher("/WEB-INF/jsp/product.jsp")
.forward(request, response);
return;
}
request.setAttribute("loginFailed", false);
request.getRequestDispatcher("/WEB-INF/jsp/login.jsp")
.forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession();
if(session.getAttribute("username") != null)
{
request.getRequestDispatcher("/WEB-INF/jsp/product.jsp")
.forward(request, response);
return;
}
String username = request.getParameter("username");
String password = request.getParameter("password");
if(username == null || password == null ||
!LoginServlet.userDatabase.containsKey(username) ||
!password.equals(LoginServlet.userDatabase.get(username)))
{
request.setAttribute("loginFailed", true);
request.getRequestDispatcher("/WEB-INF/jsp/login.jsp")
.forward(request, response);
}
else
{
session.setAttribute("username", username);
request.changeSessionId();
request.getRequestDispatcher("/WEB-INF/jsp/product.jsp")
.forward(request, response);
}
}
}
客户可以在此页面联系客服,客服人员可以在此页面查看发起聊天会话请求的列表。
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
商品列表
商品列表
衣服
鞋子
外套
查看会话列表
联系客服
退出登录
package com.mengfei.chat;
import java.util.Date;
/**
* author Alex
* date 2018/12/29
* description 一个简单的聊天消息POJO
*/
public class ChatMessage
{
//当前时区的时间
private Date timestamp;
//消息类型
private Type type;
//用户名
private String username;
//消息内容
private String content;
public Date getTimestamp() {
return timestamp;
}
public void setTimestamp(Date timestamp) {
this.timestamp = timestamp;
}
public Type getType()
{
return type;
}
public void setType(Type type)
{
this.type = type;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getContent()
{
return content;
}
public void setContent(String content)
{
this.content = content;
}
public static enum Type
{
STARTED, JOINED, ERROR, LEFT, TEXT
}
}
它将使用Jackson数据处理器编码和解码消息,编码方法将接受一个ChatMessage和一个OutputStream,通过将它转换成json对消息进行编码,并将它写入OutputStream中;方法decode完成的任务则刚好相反,它是根据所提供的InputStream读取反序列化Json的ChatMessage。
注意:
① 只要提供的解码器能够将进入的文本或二进制消息转换成对象,那么就可以指定任意的Java对象作为参数;
② 只要提供的编码器能够将对象转换成文本或二进制消息,那么就可以使用RemoteEndpoint.Basic或RemoteEndpoint.Async的sendObject方法发送任何对象;
③ 实现Encoder.Binary、Encoder.BinaryStream、Encoder.Text或者Encoder.TextStream,并在解码器属性@ClientEndpoint或@ServerEndpoint中指定它们的类,通过这种方式可以提供解码器;
④ 可以实现Decoder.Binary、Decoder.BinaryStream、Decoder.Text或Decoder.TextStream,并使用终端注解的decoders特性为消息提供解码器。
package com.mengfei.chat;
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.websocket.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
/**
* author Alex
* date 2018/12/29
* description 一个编解码器类
*/
public class ChatMessageCodec
implements Encoder.BinaryStream,
Decoder.BinaryStream
{
private static final ObjectMapper MAPPER = new ObjectMapper();
static {
MAPPER.findAndRegisterModules();
MAPPER.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
}
@Override
public void encode(ChatMessage chatMessage, OutputStream outputStream)
throws EncodeException, IOException
{
try
{
ChatMessageCodec.MAPPER.writeValue(outputStream, chatMessage);
}
catch(JsonGenerationException | JsonMappingException e)
{
throw new EncodeException(chatMessage, e.getMessage(), e);
}
}
@Override
public ChatMessage decode(InputStream inputStream)
throws DecodeException, IOException
{
try
{
return ChatMessageCodec.MAPPER.readValue(
inputStream, ChatMessage.class
);
}
catch(JsonParseException | JsonMappingException e)
{
throw new DecodeException((ByteBuffer)null, e.getMessage(), e);
}
}
@Override
public void init(EndpointConfig endpointConfig) { }
@Override
public void destroy() { }
}
服务器终端将使用此类将请求聊天的客户关联到客服人员,它包含了消息的打开和聊天中众多消息的发送。
package com.mengfei.chat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import javax.websocket.Session;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* author Alex
* date 2018/12/29
* description 聊天会话类,用于关联客户与客服人员的关系类
*/
public class ChatSession
{
//聊天会话ID
private long chatSessionId;
//客户登录的用户名
private String customerUsername;
//客户的WebSocket会话
private Session customer;
//客服登录的用户名
private String customerServiceUsername;
//客服的WebSocket会话
private Session customerService;
//创建的消息
private ChatMessage creationMessage;
//聊天日志
private final List chatLog = new ArrayList<>();
public long getChatSessionId() {
return chatSessionId;
}
public void setChatSessionId(long chatSessionId) {
this.chatSessionId = chatSessionId;
}
public String getCustomerUsername()
{
return customerUsername;
}
public void setCustomerUsername(String customerUsername)
{
this.customerUsername = customerUsername;
}
public Session getCustomer()
{
return customer;
}
public void setCustomer(Session customer)
{
this.customer = customer;
}
public String getCustomerServiceUsername() {
return customerServiceUsername;
}
public void setCustomerServiceUsername(String customerServiceUsername) {
this.customerServiceUsername = customerServiceUsername;
}
public Session getCustomerService() {
return customerService;
}
public void setCustomerService(Session customerService) {
this.customerService = customerService;
}
public ChatMessage getCreationMessage()
{
return creationMessage;
}
public void setCreationMessage(ChatMessage creationMessage)
{
this.creationMessage = creationMessage;
}
@JsonIgnore
public void log(ChatMessage message)
{
this.chatLog.add(message);
}
@JsonIgnore
public void writeChatLog(File file) throws IOException
{
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
mapper.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
try(FileOutputStream stream = new FileOutputStream(file))
{
mapper.writeValue(stream, this.chatLog);
}
}
}
它接收了聊天连接并进行适当的协调,该类中的内部类EndpointConfigurator重写了modifyHandshake方法,在握手的时候,该方法将被调用并暴露出底层的HTTP请求,从该请求中可以得到HttpSession对象,通过这个对象可以判断用户是否已经登录,如果用户已经登录还可以关闭WebSocket会话。当会话无效时,sessionDestroyed方法将被调用,并且终端也会终止该聊天会话。
当新的握手完成时,onOpen方法将被调用,它首先检查HttpSession是否被关联到了Session,以及用户是否已经登录。如果聊天会话ID为0(即请求创建新的会话),那么它将会创建新的聊天会话并添加到等待会话列表中;如果聊天会话ID大于0,客服人员将被加入到被请求的会话中,消息也将同时发送到两个客户端。
当onMessage从某个客户端收到消息时,它将同时把消息发送到两个客户端。
当会话被关闭引起错误时或者HttpSession被销毁时,一个消息将被发送到另一个用户,通知他聊天已经结束了,并关闭两个连接。
package com.mengfei.chat;
import javax.servlet.annotation.WebListener;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;
import javax.websocket.*;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import javax.websocket.server.ServerEndpointConfig;
import java.io.File;
import java.io.IOException;
import java.util.*;
/**
* author Alex
* date 2018/12/30
* description 聊天服务器终端
*/
@ServerEndpoint(value = "/chat/{chatSessionId}",
encoders = ChatMessageCodec.class,
decoders = ChatMessageCodec.class,
configurator = ChatEndpoint.EndpointConfigurator.class)
@WebListener
public class ChatEndpoint implements HttpSessionListener{
//HTTP会话的键
private static String HTTP_SESSION_PROPERTY = "http_session";
//WebSocket会话的键
private static String WEBSOCKET_SESSION_PROPERTY = "websocket_session";
//聊天会话序列ID
private static long chatSessionIdSequence = 1L;
//聊天会话序列ID的同步锁
private static final Object chatSessionIdSequenceLock = new Object();
//聊天会话的Map集合,以会话序列ID为键
private static final Map chatSessions = new Hashtable<>();
//与WebSocket会话关联的聊天会话Map集合,以WebSocket会话对象为键
private static final Map sessions = new Hashtable<>();
//与WebSocket会话关联的HttpSession会话Map集合,以WebSocket会话对象为键
private static final Map httpSessions = new Hashtable<>();
//等待聊天会话列表
public static final List waitingChatSessionList = new ArrayList<>();
@OnOpen
public void onOpen(Session session, @PathParam("chatSessionId") long chatSessionId){
//从WebSocket会话的的配置属性中获取设置的HttpSession对象
HttpSession httpSession = (HttpSession)session.getUserProperties().get(HTTP_SESSION_PROPERTY);
try {
//检查httpSession是否为空,以及用户是否登录
if(httpSession == null || httpSession.getAttribute("username") == null){
//关闭WebSocket会话
session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY,"请先登录!"));
return;
}
String username = (String)httpSession.getAttribute("username");
//向WebSocket会话的属性中设置用户名
session.getUserProperties().put("username",username);
//创建消息对象
ChatMessage message = new ChatMessage();
message.setTimestamp(new Date());
message.setUsername(username);
ChatSession chatSession;
if(chatSessionId == 0){//请求创建新的聊天会话,并添加到等待会话列表中
message.setType(ChatMessage.Type.STARTED);
message.setContent(username + "启动聊天会话");
chatSession = new ChatSession();
//添加单机环境的同步块
synchronized (ChatEndpoint.chatSessionIdSequenceLock){
chatSession.setChatSessionId(ChatEndpoint.chatSessionIdSequence++);
}
chatSession.setCustomer(session);
chatSession.setCustomerUsername(username);
chatSession.setCreationMessage(message);
ChatEndpoint.waitingChatSessionList.add(chatSession);
ChatEndpoint.chatSessions.put(chatSession.getChatSessionId(),chatSession);
}else {//客服将被加入到请求的会话中,消息也将同时发送到两个客户端
message.setType(ChatMessage.Type.JOINED);
message.setContent(username + "加入聊天会话");
//通过chatSessionId从聊天会话集合中获取聊天会话对象
chatSession = ChatEndpoint.chatSessions.get(chatSessionId);
chatSession.setCustomerService(session);
chatSession.setCustomerServiceUsername(username);
//移除等待聊天会话列表中的对象
ChatEndpoint.waitingChatSessionList.remove(chatSession);
//给客服人员推送消息
session.getBasicRemote().sendObject(chatSession.getCreationMessage());
session.getBasicRemote().sendObject(message);
}
ChatEndpoint.sessions.put(session,chatSession);
ChatEndpoint.httpSessions.put(session,httpSession);
//在当前的HTTP请求会话属性中添加关联的WebSocket会话
this.getSessionsForHttpSession(httpSession).add(session);
chatSession.log(message);
//给客户推送消息
chatSession.getCustomer().getBasicRemote().sendObject(message);
}catch (IOException | EncodeException e){
this.onError(session, e);
}
}
@OnMessage
public void onMessage(Session session, ChatMessage message)
{
//通过WebSocket会话来获取聊天会话
ChatSession c = ChatEndpoint.sessions.get(session);
//通过聊天会话来获取聊天的另外一个参与者
Session other = this.getOtherSession(c, session);
if(c != null && other != null)
{
c.log(message);
try
{
//向两边发送消息
session.getBasicRemote().sendObject(message);
other.getBasicRemote().sendObject(message);
}
catch(IOException | EncodeException e)
{
this.onError(session, e);
}
}
}
//WebSocket会话关闭
@OnClose
public void onClose(Session session, CloseReason reason)
{
if(reason.getCloseCode() == CloseReason.CloseCodes.NORMAL_CLOSURE)
{
ChatMessage message = new ChatMessage();
message.setUsername((String)session.getUserProperties().get("username"));
message.setType(ChatMessage.Type.LEFT);
message.setTimestamp(new Date());
message.setContent(message.getUsername() + "退出聊天");
try
{
Session other = this.close(session, message);
if(other != null){
other.close();
}
}
catch(IOException e)
{
e.printStackTrace();
}
}
}
@OnError
public void onError(Session session, Throwable e)
{
ChatMessage message = new ChatMessage();
message.setUsername((String)session.getUserProperties().get("username"));
message.setType(ChatMessage.Type.ERROR);
message.setTimestamp(new Date());
message.setContent(message.getUsername() + "由于出现异常退出聊天");
try
{
Session other = this.close(session, message);
if(other != null)
other.close(new CloseReason(
CloseReason.CloseCodes.UNEXPECTED_CONDITION, e.toString()
));
}
catch(IOException ignore) { }
finally
{
try
{
session.close(new CloseReason(
CloseReason.CloseCodes.UNEXPECTED_CONDITION, e.toString()
));
}
catch(IOException ignore) { }
}
}
//HttpSession会话销毁
@Override
public void sessionDestroyed(HttpSessionEvent event)
{
HttpSession httpSession = event.getSession();
if(httpSession.getAttribute(WEBSOCKET_SESSION_PROPERTY) != null)
{
ChatMessage message = new ChatMessage();
message.setUsername((String)httpSession.getAttribute("username"));
message.setType(ChatMessage.Type.LEFT);
message.setTimestamp(new Date());
message.setContent(message.getUsername() + "退出登录");
for(Session session:new ArrayList<>(this.getSessionsForHttpSession(httpSession)))
{
try
{
session.getBasicRemote().sendObject(message);
Session other = this.close(session, message);
if(other != null){
other.close();
}
}
catch(IOException | EncodeException e)
{
e.printStackTrace();
}
finally
{
try
{
session.close();
}
catch(IOException ignore) { }
}
}
}
}
@Override
public void sessionCreated(HttpSessionEvent event) { /* do nothing */ }
@SuppressWarnings("unchecked")
private synchronized ArrayList getSessionsForHttpSession(HttpSession httpSession)
{
try
{
//一个HttpSession可能关联多个WebSocket会话
if(httpSession.getAttribute(WEBSOCKET_SESSION_PROPERTY) == null)
httpSession.setAttribute(WEBSOCKET_SESSION_PROPERTY, new ArrayList<>());
return (ArrayList)httpSession.getAttribute(WEBSOCKET_SESSION_PROPERTY);
}
catch(IllegalStateException e)
{
return new ArrayList<>();
}
}
private Session close(Session s, ChatMessage message)
{
ChatSession c = ChatEndpoint.sessions.get(s);
Session other = this.getOtherSession(c, s);
ChatEndpoint.sessions.remove(s);
HttpSession h = ChatEndpoint.httpSessions.get(s);
if(h != null){
this.getSessionsForHttpSession(h).remove(s);
}
if(c != null)
{
c.log(message);
ChatEndpoint.waitingChatSessionList.remove(c);
ChatEndpoint.chatSessions.remove(c.getChatSessionId());
try
{
c.writeChatLog(new File("D:/logs/chat." + c.getChatSessionId() + ".log"));
}
catch(Exception e)
{
System.err.println("无法写入聊天日志信息!");
e.printStackTrace();
}
}
if(other != null)
{
ChatEndpoint.sessions.remove(other);
h = ChatEndpoint.httpSessions.get(other);
if(h != null){
this.getSessionsForHttpSession(h).remove(s);
}
try
{
other.getBasicRemote().sendObject(message);
}
catch(IOException | EncodeException e)
{
e.printStackTrace();
}
}
return other;
}
//通过当前聊天会话中的参与者来获取另外一个参与者的WebSocket会话
private Session getOtherSession(ChatSession c, Session s)
{
return c == null ? null :
(s == c.getCustomer() ? c.getCustomerService() : c.getCustomer());
}
public static class EndpointConfigurator extends ServerEndpointConfig.Configurator{
@Override
public void modifyHandshake(ServerEndpointConfig config,
HandshakeRequest request,
HandshakeResponse response) {
super.modifyHandshake(config, request, response);
//从底层的HTTP请求中获取到HttpSession对象,并设置到当前WebSocket会话的的配置属性中
config.getUserProperties().put(HTTP_SESSION_PROPERTY,request.getHttpSession());
}
}
}
它的任务相当简单,主要是管理聊天会话的显示、创建和加入,方法Post设置了Expires和Cache-Control头,用于保证浏览器不会缓存该聊天页面。代码如下:
package com.mengfei.chat;
import org.apache.commons.lang3.math.NumberUtils;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* author Alex
* date 2018/12/29
* description 主要是管理聊天会话的显示、创建和加入,方法Post设置了Expires和Cache-Control头,
* 用于保证浏览器不会缓存该聊天页面
*/
@WebServlet(name = "chatServlet",urlPatterns = "/chat")
public class ChatServlet extends HttpServlet{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String action = request.getParameter("action");
if("list".equals(action))
{
request.setAttribute("waitingChatSessionList", ChatEndpoint.waitingChatSessionList);
request.getRequestDispatcher("/WEB-INF/jsp/list.jsp")
.forward(request, response);
}
else {
request.getRequestDispatcher("/WEB-INF/jsp/product.jsp")
.forward(request, response);
}
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setHeader("Expires", "Thu, 1 Jan 1970 12:00:00 GMT");
response.setHeader("Cache-Control","max-age=0, must-revalidate, no-cache");
String action = request.getParameter("action");
String view = null;
switch(action)
{
case "new":
//客户发起的会话请求,即新创建的聊天会话ID均设为0
request.setAttribute("chatSessionId", 0);
view = "chat";
break;
case "join":
String id = request.getParameter("chatSessionId");
if(id == null || !NumberUtils.isDigits(id))
response.sendRedirect("chat?list");
else
{
request.setAttribute("chatSessionId", Long.parseLong(id));
view = "chat";
}
break;
default:
request.getRequestDispatcher("/WEB-INF/jsp/product.jsp")
.forward(request, response);
break;
}
if(view != null) {
request.getRequestDispatcher("/WEB-INF/jsp/" + view + ".jsp")
.forward(request, response);
}
}
}
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
会话列表
会话列表
没有等待的客户会话请求!
${s.customerUsername}
退出登录
<%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8" %>
聊天室
聊天室
退出登录
可以开启两个浏览器进行聊天测试,访问http://127.0.0.1:8082/login先进行用户登录,再根据客户和客服角色的不同发送不同的消息,测试结果如下: