一. WebSocket 协议
1.1 简介
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
1.2 定义与原理
WebSocket 是独立的、创建在 TCP 上的协议。
Websocket 通过HTTP/1.1 协议的101状态码进行握手。
为了创建Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(handshaking)
原理:
很多网站为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。
而比较新的技术去做轮询的效果是Comet。这种技术虽然可以双向通信,但依然需要反复发出请求。而且在Comet中,普遍采用的长链接,也会消耗服务器资源。
在这种情况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
1.3 特点与实例
较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。
更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。
保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。
更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。
可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。
更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。
实例:
WebSockets 它可以在用户的浏览器和服务器之间打开交互式通信会话。使用此API,可以向服务器发送消息并接收事件驱动的响应,而无需通过轮询服务器的方式以获得响应。 [2] WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的API。 [1]
// 创建WebSocket连接.
const socket = new WebSocket('ws://localhost:8080');
// 连接成功触发
socket.addEventListener('open', function (event) {
socket.send('Hello Server!');
});
// 监听消息
socket.addEventListener('message', function (event) {
console.log('Message from server ', event.data);
});
二. 新增基础配置
2.1 集成日志模式
logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="false" scan="true" scanPeriod="60 seconds">
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<!--属性,配置日志的输出路径 -->
<property name="log_path" value="./logs"/>
<property name="appName" value="chat-room"></property>
<!-- 项目名称 -->
<contextName>${appName}</contextName>
<timestamp key="bySecond" datePattern="yyyy-MM-dd HH:mm:ss" timeReference="contextBirth"/>
<!-- ConsoleApperder意思是从console中打印出来 -->
<appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
<!-- 过滤器,一个appender可以有多个 -->
<!-- 阈值过滤,就是log行为级别过滤,debug及debug以上的信息会被打印出来 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>debug</level>
<!--<onMatch>ACCEPT</onMatch>-->
<!--<onMismatch>DENY</onMismatch>-->
</filter>
<encoder charset="UTF-8"> <!-- encoder 可以指定字符集,对于中文输出有意义 -->
<!--<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger %caller{1} :[%msg]%n</pattern>-->
<pattern>%blue(%d{yyyy-MM-dd HH:mm:ss}) %yellow(%-5level) %cyan(%logger [%line] :) %green([%msg]%n)</pattern>
</encoder>
</appender>
<!-- FileAppender 输出到文件 -->
<!--<appender name="logFiles" class="ch.qos.logback.core.FileAppender">-->
<!--<!– 文件存放位置 %{xxx} 就是之前定义的属性xxx –>-->
<!--<file>${log_path}/${appName}.log</file>-->
<!--<encoder>-->
<!--<!– %date和%d是一个意思 %file是所在文件 %line是所在行 –>-->
<!--<pattern>%date %level [%thread] %logger{30} [%file:%line] %msg%n</pattern>-->
<!--</encoder>-->
<!--</appender>-->
<!-- 输出到HTML格式的文件 -->
<!--<appender name="htmlFiles" class="ch.qos.logback.core.FileAppender">-->
<!--<!– 过滤器,这个过滤器是行为过滤器,直接过滤掉了除debug外所有的行为信息 –>-->
<!--<filter class="ch.qos.logback.classic.filter.LevelFilter">-->
<!--<level>debug</level>-->
<!--<onMatch>ACCEPT</onMatch>-->
<!--<onMismatch>DENY</onMismatch>-->
<!--</filter>-->
<!--<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!--<!– HTML输出格式 可以和上边差不多 –>-->
<!--<layout class="ch.qos.logback.classic.html.HTMLLayout">-->
<!--<pattern>%relative%thread%mdc%level%logger%msg</pattern>-->
<!--</layout>-->
<!--</encoder>-->
<!--<file>${log_path}/${appName}.html</file>-->
<!--</appender>-->
<!-- 滚动日志文件,这个比较常用 -->
<appender name="rollingFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 当project等于true的时候file就不会起效果-->
<!--<project>true</project>-->
<file>${log_path}/${appName}.log</file>
<!-- 按天新建log日志 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- daily rollover -->
<fileNamePattern>${log_path}/%d{yyyy-MM-dd}/${appName}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!-- 保留30天的历史日志 -->
<maxHistory>30</maxHistory>
<!-- 基于大小和时间,这个可以有,可以没有 -->
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<!-- or whenever the file size reaches 100MB -->
<!-- 当一个日志大小大于10KB,则换一个新的日志。日志名的%i从0开始,自动递增 -->
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
<encoder charset="UTF-8">
<!-- %ex就是指抛出的异常,full是显示全部,如果在{}中写入数字,则表示展示多少行 -->
<pattern>%-4date [%thread] %-5level %logger{} [%line]: [%msg]%n%ex{full, DISPLAY_EX_EVAL}</pattern>
</encoder>
</appender>
<!-- 重点来了,上边都是appender输出源。这里开始就是looger了 -->
<!-- name意思是这个logger管的哪一片,像下面这个管的就是com/qgy包下的所有文件 level是只展示什么行为信息级别以上的,类似阈值过滤器 additivity表示是否再抛出事件,就是说如果有一个logger的name是log,如果这个属性是true,另一个logger就会在这个logger处理完后接着继续处理 -->
<!--<logger name="com.qgy" level="DEBUG" additivity="false">-->
<!--<!– 连接输出源,也就是上边那几个输出源,你可以随便选几个appender –>-->
<!--<appender-ref ref="stdout"/>-->
<!--<appender-ref ref="logFiles"/>-->
<!--<!–<appender-ref ref="htmlFiels"/>–>-->
<!--</logger>-->
<!-- 这个logger详细到了类 -->
<logger name="com.AleXJokeR.websocket" level="debug" additivity="false">
<appender-ref ref="Console"/>
<!--<appender-ref ref="logFiles"/>-->
<!--<appender-ref ref="htmlFiles"/>-->
<appender-ref ref="rollingFile"/>
</logger>
<!-- 下面配置一些第三方包的日志过滤级别,用于避免刷屏 -->
<!--<logger name="org.springframework" level="ERROR" />-->
<!--<logger name="com.opensymphony" level="WARN" />-->
<!--<logger name="org.apache" level="WARN" />-->
<!--<logger name="org.quartz" level="WARN" />-->
<!--<logger name="net.bull.javamelody" level="WARN" />-->
<!--<logger name="org.apache.ibatis" level="WARN" />-->
<!--<logger name="ACP_SDK_LOG" level="WARN" />-->
<!--<logger name="SDK_MSG_LOG" level="WARN" />-->
<!--<logger name="SDK_ERR_LOG" level="WARN" />-->
</configuration>
日志测试输出
2.2 新增的所有 config 类
model 下的 User 类
public class User {
public String id;
public String nickname;
public MyWebSocket webSocket;
public User() {
}
public User(String id) {
this.id = id;
}
public User(String id, String nickname) {
this.id = id;
this.nickname = nickname;
}
public User(String id, String nickname, MyWebSocket webSocket) {
this.id = id;
this.nickname = nickname;
this.webSocket = webSocket;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public MyWebSocket getWebSocket() {
return webSocket;
}
public void setWebSocket(MyWebSocket webSocket) {
this.webSocket = webSocket;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
User user = (User) o;
return id.equals(user.getId());
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
在 SpringBoot 中内置的 Tomcat 中设置虚拟路径
config 下的 WebMvcConfig (类)
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private String imgPath = new ApplicationHome(getClass()).getSource().getParentFile().toString()+"/img/";
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/img/**").addResourceLocations("file:"+imgPath);
}
}
将 WebSocket 配置交给 Spring 进行托管
config 下的 WebSocketConfig (类)
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
2.1 静态资源管理
在 resources 下新增静态资源目录,例如新消息提示语音,ico 图标等
完整静态目录结构:
三. SpringBoot 整合 WebSocket(核心)
3.1 请求细节分析
浏览器先向服务器发送个url以ws://开头的http的GET请求,服务器根据请求头中
Connection :Upgrade 我要升级
Upgrade:websocket把客户端的请求升级websocket协议。
响应头中也包含了内容Upgrade:websocket,表示升级成WebSocket协议。
响应101: 握手成功,http协议切换成websocket协议了,连接建立成功,浏览器和服务器可以随时主动发送消息给对方了。
这里 Sec-WebSocket-Accept 和 Sec-WebSocket-Key 是配套的,主要作用在于提供基础的防护,减少恶意连接、意外连接。
Sec-WebSocket-Accept 的计算方法是:
计算公式为:
将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接。
通过 SHA1 计算出摘要,并转成 base64 字符串。
伪代码:
toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )
3.2 核心实现
服务端
spring-messaging 和 spring-websocket 模块提供 WebSocket 支持的 STOMP,一旦有了这些依赖项,就可以通过WebSocket 使用 SockJS Fallback 公开 STOMP 端点
设置 WebSocket 配置
@EnableWebSocketMessageBroker:用于启用我们的WebSocket服务器。我们实现了WebSocketMessageBrokerConfigurer接口,并实现了其中的方法。
endpointSang:我们注册一个websocket端点,客户端将使用它连接到我们的websocket服务器。withSockJS():是用来为不支持websocket的浏览器启用后备选项,使用了SockJS。
StompEndpointRegistry:是来自Spring框架STOMP实现。STOMP代表简单文本导向的消息传递协议。它是一种消息传递协议,用于定义数据交换的格式和规则。为啥我们需要这个东西?因为WebSocket只是一种通信协议。它没有定义诸如以下内容:如何仅向订阅特定主题的用户发送消息,或者如何向特定用户发送消息。我们需要STOMP来实现这些功能。
在configureMessageBroker方法中,我们配置一个消息代理,用于将消息从一个客户端路由到另一个客户端。
创建 Controller 来接受和发送消息
web 下的 MyWebSocket (类)
@ServerEndpoint(value = "/websocket") @Component public class MyWebSocket { private static final Logger logger = LoggerFactory.getLogger(MyWebSocket.class); //静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。 private static int onlineCount = 0; //与某个客户端的连接会话,需要通过它来给客户端发送数据 private Session session; //用以记录用户和房间号的对应关系(sessionId,room) private static HashMap<String,String> RoomForUser = new HashMap<String,String>(); //用以记录房间和其中用户群的对应关系(room,List<用户>) public static HashMap<String,CopyOnWriteArraySet<User>> UserForRoom = new HashMap<String,CopyOnWriteArraySet<User>>(); //用以记录房间和其中用户群的对应关系(room,List<用户>) public static HashMap<String,String> PwdForRoom = new HashMap<String,String>(); //用来存放必应壁纸 public static List<String> BingImages = new ArrayList<>(); private Gson gson = new Gson(); private Random random = new Random(); /** * 连接建立成功调用的方法 * @param session */ @OnOpen public void onOpen(Session session) throws IOException { logger.debug("---------------------成功与{}建立连接---------------------",session.getId()); this.session = session; addOnlineCount(); Map<String,String> result = new HashMap<>(); result.put("type","bing"); result.put("msg",BingImages.get(random.nextInt(BingImages.size()))); result.put("sendUser","系统消息"); result.put("id",session.getId()); this.sendMessage(gson.toJson(result)); } /** * 连接关闭调用的方法 */ @OnClose public void onClose() { subOnlineCount(); CopyOnWriteArraySet<User> users = getUsers(session); if (users!=null){ String nick = "某人"; for (User user : users) { if (user.getId().equals(session.getId())){ nick = user.getNickname(); } } Map<String,String> result = new HashMap<>(); result.put("type","init"); result.put("msg",nick+"离开房间"); result.put("sendUser","系统消息"); sendMessagesOther(users,gson.toJson(result)); User closeUser = getUser(session); users.remove(closeUser); if (users.size() == 0){ String room = RoomForUser.get(session.getId()); UserForRoom.remove(room); PwdForRoom.remove(room); } RoomForUser.remove(session.getId()); } } /** * 收到客户端消息后调用的方法 * @param message 消息内容 * @param session */ @OnMessage public void onMessage(String message, Session session) { Map<String,String> map = new Gson().fromJson(message, new TypeToken<HashMap<String,String>>(){}.getType()); Map<String,String> result = new HashMap<>(); User user = null; String shiels = map.containsKey("shiels")?map.get("shiels").toString():null; switch (map.get("type")){ case "msg" : user = getUser(session); result.put("type","msg"); result.put("msg",map.get("msg")); result.put("sendUser",user.getNickname()); result.put("shake",map.get("shake")); break; case "init": String room = map.get("room"); String nick = map.get("nick"); String pwd = map.get("pwd"); if (room != null && nick != null){ user = new User(session.getId(),nick,this); //如果房间不存在,新建房间 if (UserForRoom.get(room) == null){ CopyOnWriteArraySet<User> roomUsers = new CopyOnWriteArraySet<>(); roomUsers.add(user); UserForRoom.put(room,roomUsers); if (StrUtil.isNotEmpty(pwd)){ PwdForRoom.put(room,pwd); } RoomForUser.put(session.getId(),room); }else { UserForRoom.get(room).add(user); RoomForUser.put(session.getId(),room); } result.put("type","init"); result.put("msg",nick+"成功加入房间"); result.put("sendUser","系统消息"); } break; case "img": user = getUser(session); result.put("type","img"); result.put("msg",map.get("msg")); result.put("sendUser",user.getNickname()); break; case "ping": return; } if (StrUtil.isEmpty(shiels)){ sendMessagesOther(getUsers(session),gson.toJson(result)); }else { sendMessagesOther(getUsers(session),gson.toJson(result),shiels); } } /** * 连接发生错误时的调用方法 * @param session * @param error */ @OnError public void onError(Session session, Throwable error) { logger.debug("---------------------与{}的连接发生错误---------------------",session.getId()); subOnlineCount(); CopyOnWriteArraySet<User> users = getUsers(session); if (users!=null){ String nick = "某人"; for (User user : users) { if (user.getId().equals(session.getId())){ nick = user.getNickname(); } } Map<String,String> result = new HashMap<>(); result.put("type","init"); result.put("msg",nick+"离开房间"); result.put("sendUser","系统消息"); sendMessagesOther(users,gson.toJson(result)); User closeUser = getUser(session); users.remove(closeUser); if (users.size() == 0){ String room = RoomForUser.get(session.getId()); UserForRoom.remove(room); PwdForRoom.remove(room); } RoomForUser.remove(session.getId()); } error.printStackTrace(); } public void sendMessage(String message) throws IOException { this.session.getBasicRemote().sendText(message); } /** * 获得在线人数 * @return */ public static synchronized int getOnlineCount() { return onlineCount; } public static synchronized void addOnlineCount() { MyWebSocket.onlineCount++; } public static synchronized void subOnlineCount() { MyWebSocket.onlineCount--; } /** * 根据当前用户的session获得他所在房间的所有用户 * @param session * @return */ private CopyOnWriteArraySet<User> getUsers(Session session){ String room = RoomForUser.get(session.getId()); CopyOnWriteArraySet<User> users = UserForRoom.get(room); return users; } private User getUser(Session session){ String room = RoomForUser.get(session.getId()); CopyOnWriteArraySet<User> users = UserForRoom.get(room); for (User user : users){ if (session.getId().equals(user.getId())){ return user; } } return null; } /** * 给某个房间的所有人发送消息 * @param users * @param message */ private void sendMessagesAll(CopyOnWriteArraySet<User> users, String message){ //群发消息 for (User item : users) { try { item.getWebSocket().sendMessage(message); } catch (IOException e) { e.printStackTrace(); } } } /** * 给某个房间除自己外发送消息 * @param users * @param message */ private void sendMessagesOther(CopyOnWriteArraySet<User> users, String message){ //群发消息 for (User item : users) { if (item.getWebSocket() != this){ try { item.getWebSocket().sendMessage(message); } catch (IOException e) { e.printStackTrace(); } } } } /** * 给某个房间除自己外发送消息 * @param users * @param message */ private void sendMessagesOther(CopyOnWriteArraySet<User> users, String message, String shiel){ List<String> shiels = Arrays.asList(shiel.split(",")); //群发消息 for (User item : users) { if (item.getWebSocket() != this && !shiels.contains(item.getId())){ try { item.getWebSocket().sendMessage(message); } catch (IOException e) { e.printStackTrace(); } } } } }
web 下的 SocketController (类)
@RestController @RequestMapping("/ws") public class SocketController { private static final Logger logger = LoggerFactory.getLogger(SocketController.class); //图片保存路径 private String imgPath = new ApplicationHome(getClass()).getSource().getParentFile().toString()+"/img/"; public static Map<Long,String> img = new HashMap(); /** * 根据房间号获得其中的用户 * @param room 房间号 * @return */ @RequestMapping("/online") public Map<String,Object> online(String room){ Map<String,Object> result = new HashMap<>(); CopyOnWriteArraySet<User> rooms = MyWebSocket.UserForRoom.get(room); // List<String> nicks = new ArrayList<>(); List<Map<String,String>> users = new ArrayList<>(); if (rooms != null){ rooms.forEach(user -> { Map<String,String> map = new HashMap<>(); map.put("nick",user.getNickname()); map.put("id",user.getId()); users.add(map); }); result.put("onlineNum",rooms.size()); result.put("onlineUsera",users); }else { result.put("onlineNum",0); result.put("onlineUsera",null); } return result; } /** * 判断昵称在某个房间中是否已存在,房间是否有密码,如果有,用户输入的密码又是否正确 * @param room 房间号 * @param nick 昵称 * @param pwd 密码 * @return */ @RequestMapping("/judgeNick") public Map<String,Object> judgeNick(String room, String nick, String pwd){ Map<String,Object> result = new HashMap<>(); result.put("code",0); CopyOnWriteArraySet<User> rooms = MyWebSocket.UserForRoom.get(room); if (rooms != null){ rooms.forEach(user -> { if (user.getNickname().equals(nick)){ result.put("code",1); result.put("msg","昵称已存在,请重新输入"); logger.debug("有重复"); } }); if ((Integer)result.get("code") != 0){ return result; } String password = MyWebSocket.PwdForRoom.get(room); if (StrUtil.isNotEmpty(password) && !(pwd.equals(password))){ result.put("code",2); result.put("msg","密码错误,请重新输入"); return result; }else { result.put("code",3); result.put("msg","房间无密码"); return result; } } return result; } /** * 实现文件上传 * */ @RequestMapping("/fileUpload") public Map<String,Object> fileUpload(HttpServletRequest request, @RequestParam("file") MultipartFile file){ Map<String,Object> result = new HashMap<>(); //获取项目访问路径 String root = request.getRequestURL().toString().replace(request.getRequestURI(),""); if(file.isEmpty()){ return null; } //获取文件名 String fileName = file.getOriginalFilename(); //重命名文件 String imgName = RandomUtil.randomUUID() + fileName.substring(fileName.lastIndexOf(".")); logger.debug("上传图片保存在:" + imgPath + imgName); File dest = new File(imgPath + imgName); img.put(System.currentTimeMillis(),imgPath + imgName); //判断文件父目录是否存在 if(!dest.getParentFile().exists()){ dest.getParentFile().mkdir(); } try { //保存文件 file.transferTo(dest); //返回图片访问路径 result.put("url",root +"/img/" + imgName); logger.debug("图片保存成功,访问路径为:"+result.get("url")); return result; } catch (IllegalStateException e) { e.printStackTrace(); logger.error("图片保存失败!"); } catch (IOException e) { e.printStackTrace(); logger.error("图片保存失败!"); } return null; } /** * 获取所有房间 * @return */ @RequestMapping("/allRoom") public Map<String,Object> allRoom(){ Map<String,Object> result = new HashMap<>(); HashMap<String,CopyOnWriteArraySet<User>> userForRoom = MyWebSocket.UserForRoom; List<String> rooms = new ArrayList<>(); for (String key : userForRoom.keySet()) { rooms.add(key); } result.put("rooms",rooms); return result; } }
四. 前端实现
4.1 原生 JS 进行通信测试
window.onfocus = function() {
focus = false;
};
window.onblur = function() {
focus = true;
};
// for IE
document.onfocusin = function() {
focus = false;
};
document.onfocusout = function() {
focus = true;
};
//判断当前浏览器是否支持WebSocket
if('WebSocket' in window){
websocket = new WebSocket(wsHost);
}else{
layer.msg(unSupportWsMsg,{anim: 6})
}
//连接发生错误的回调方法
websocket.onerror = function(){
layer.msg(onerrorMsg,{anim: 6});
};
//连接成功建立的回调方法
websocket.onopen = function(event){
self.setInterval("ping()",55000);
}
// 鼠标悬停在某些按钮上时延迟Tips
function delayTips(tips,obj,style,time){
timer = setTimeout(messagebox,time==null?700:time,tips,obj,style)
}
// 停止延迟Tips的计时器
function stopTimer(){
if(timer != null){
clearTimeout(timer);
layer.closeAll('tips');
}
}
// Tips
function messagebox(tips,obj,style){
layer.tips(tips,obj,style);
}
//接收到消息的回调方法
websocket.onmessage = function(event){
var data = JSON.parse(event.data)
var msg = data.msg;
var nick = data.sendUser;
var shakeStatus = data.shake;
if (focus && mute%2==0) {
playSound();
notifyMe(data);
}
switch(data.type){
case 'init':
getOnlion(document.getElementById('room').value);
layer.msg(msg);
break;
case 'msg':
setMessageInnerHTML(nick,text2Emoji2(msg),shakeStatus);
break;
case 'img':
setImgInnerHTML(nick,msg);
break;
case 'bing':
document.getElementById('userId').value = data.id;
$('body').css("background-image","url("+msg+")");
break;
default:
break;
}
}
//连接关闭的回调方法
websocket.onclose = function(){
layer.alert(oncloseMsg, {icon: 2});
$("#footer").animate({bottom:'-200px'},400);
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function(){
websocket.close();
}
// 清空屏幕
function emptyScreen(){
layer.msg('是否清空屏幕?', {
anim: 6,
time: 0 //不自动关闭
,btn: ['确定', '取消']
,yes: function(index){
layer.close(index);
$("#message").empty();
}
});
}
// 让某个消息停止抖动
function stopShake(obj) {
var array = $(obj).attr("class").split(" ")
$(obj).removeClass(array[3]);
}
// 查看原图
function originalImage(obj) {
layer.photos({
photos: obj
,anim: 5 //0-6的选择,指定弹出图片动画类型,默认随机(请注意,3.0之前的版本用shift参数)
});
}
//将消息显示在网页上
function setMessageInnerHTML(nick,msg,shake){
var a = '<div class="botui-message-left"><div ondblclick="stopShake(this)" class="botui-message-content shake-constant shake-constant--hover';
if (shake != null || shake != ""){
a = a + ' '+shake+'">';
}else {
a = a + '">';
}
$("#message").append("<div class='sendUser'><b>"+nick+"</b></div>"+a+msg+b);
scrollToEnd();
$(".botui-message-content").animate({'margin-left':'0px'},200);
}
//将自己发的消息显示在网页上
function setMessageInnerHTML2(nick,msg,shake){
var c = '<div class="botui-message-right"><div ondblclick="stopShake(this)" class="botui-message-content2 shake-constant shake-constant--hover';
if (shake != null || shake != ""){
c = c + ' '+shake+'">';
}else {
c = c + '">';
}
$("#message").append("<div class='sendUser' style='text-align: right;'><b>"+nick+"</b></div>"+c+msg+b);
scrollToEnd();
$(".botui-message-content2").animate({'margin-right':'0px'},200);
}
//将图片显示在网页上
function setImgInnerHTML(nick,msg){
$("#message").append("<div class='sendUser'><b>"+nick+"</b></div>"+aa+"<img style=\"max-width: -moz-available;\" src='"+msg+"'"+b);
scrollToEnd();
$(".botui-message-content-img").animate({'margin-left':'0px'},200);
}
//将自己发的图片显示在网页上
function setImgInnerHTML2(nick,msg){
$("#message").append("<div class='sendUser' style='text-align: right;'><b>"+nick+"</b></div>"+cc+"<img style=\"max-width: -moz-available;\" src='"+msg+"'"+b);
scrollToEnd();
$(".botui-message-content2-img").animate({'margin-right':'0px'},200);
}
function moniSendImg() {
document.getElementById("image").click();
}
//关闭连接
function closeWebSocket(){
websocket.close();
}
//开关抖动消息
function beginShake() {
if (shakeNum == 8){
$('#shakeMsg').attr("src","./icon/shakeFalse.png");
$("#shakeMsg").removeClass(shakeList[shakeNum]);
layer.msg("抖动消息已关闭");
shakeNum=0;
}else {
$("#shakeMsg").removeClass(shakeList[shakeNum]);
shakeNum+=1;
$('#shakeMsg').attr("src","./icon/shakeTrue.png");
$("#shakeMsg").addClass(shakeList[shakeNum]);
layer.msg(shakeChinese[shakeNum]);
}
}
//发送消息
function send(){
var msgBak = document.getElementById('text').innerHTML;
var nick = document.getElementById('nick').value;
// 转换emoji
$($("#text").children(".emoji_icon")).each(function () {
$(this).prop('outerHTML', textHead+$(this).attr("src").split(emojiPath)[1]+textFoot);
});
var msg = document.getElementById('text').innerHTML;
if (msg != null && msg != ""){
msg = msg.replace(/"/g,"'");
var map = new Map();
map.set("type","msg");
map.set("msg",msg);
var shiels = new Array();
shieldMap.forEach(function (value) {
shiels.push(value);
})
map.set("shiels",shiels);
map.set("shake",shakeList[shakeNum]);
var map2json=Map2Json(map);
if (map2json.length < 8000){
websocket.send(map2json);
document.getElementById('text').innerHTML = null;
setMessageInnerHTML2(nick,text2Emoji2(msg),shakeList[shakeNum]);
}else {
$("#text").html(msgBak);
layer.msg("文本太长了,少写一点吧😭",{anim: 6});
}
}else {
layer.msg("发空消息是什么意思呢?🤔",{anim: 6});
}
}
//服务端如果用nginx做转发,可能会因'proxy_read_timeout'配置的过短而自动断开连接,默认是一分钟,所以发送心跳连接,保证不聊天的状态下不会断开
function ping() {
var map = new Map();
map.set("type","ping");
var map2json=Map2Json(map);
websocket.send(map2json);
}
// 将文本转换回emoji图片
function text2Emoji2(emojiMsg) {
return emojiMsg.replace(new RegExp(textHead,"g"),emojiHead).replace(new RegExp(textFoot,"g"),emojiFoot);
}
//发送昵称和房间号
function sendnick(nick,room,pwd){
var map = new Map();
map.set("type","init");
map.set("nick",nick);
map.set("room",room);
map.set("pwd",pwd);
var message = Map2Json(map);
websocket.send(message);
document.getElementById('text').innerHTML = null;
document.getElementById('activeRoom').innerText = '房间:'+room;
document.getElementById('activeUser').innerText = '昵称:'+nick;
if (pwd != null && pwd != ""){
document.getElementById('activePwd').innerText = '密码:'+pwd;
}else {
$("#activePwd").css('display','none');
}
$('#btn').trigger("click");
getOnlion(document.getElementById('room').value);
$('body').css("background-image","none");
$("#window").animate({top:'-100%'},500);
$("#footer").animate({bottom:'0px'},400);
$("#message").show();
layer.alert(firstTips);
//因为该方法会引起较近的动画卡顿,所以让他先老实一会儿
setTimeout(function(){ loadEmoji(); }, 500);
}
// 加载表情,这个方法需要一定时间,因此可能造成肉眼可见的卡顿
function loadEmoji() {
$("#text").emoji({
button: "#emoji",
showTab: true,
animation: 'slide',
icons: [{
name: "QQ表情",
path: "dist/img/qq/",
maxNum: 154,
file: ".gif"
},{
name: "贴吧表情",
path: "dist/img/tieba/",
maxNum: 50,
file: ".jpg"
},{
name: "dog",
path: "dist/img/dog/",
maxNum: 37,
file: ".png"
},{
name: "坏坏",
path: "dist/img/huai/",
maxNum: 189,
file: ".png"
},{
name: "坏坏GIF",
path: "dist/img/huaiGif/",
maxNum: 26,
file: ".gif"
},{
name: "微博",
path: "dist/img/weibo/",
maxNum: 93,
file: ".png"
},{
name: "猥琐萌",
path: "dist/img/xiaoren/",
maxNum: 186,
file: ".gif"
}]
});
}
//监听按键
$(document).keydown(function(e) {
// 回车键发送消息
if (e.keyCode == 13) {
var topValue = $("#window").css('top');
var topPx = topValue.substring(0,topValue.length-2);
if (topPx > 0){
editNick();
}else {
send();
return false;
}
}else if(e.keyCode == 27){ //Esc键关闭抖动消息
$('#shakeMsg').attr("src","./icon/shakeFalse.png");
$("#shakeMsg").removeClass(shakeList[shakeNum]);
shakeNum=0;
layer.msg("抖动消息已关闭");
}
});
//发送消息后自动滚到底部
function scrollToEnd(){
var h = $("html,body").height()-$(window).height();
$("html,body").animate({scrollTop:h},200);
}
//设置房间号和昵称并发送,再模拟‘#btn’的点击事件,以弹出侧边栏
function editNick() {
var nickname = $("#nickname").val();
var roomname = $("#roomname").val();
var password = $("#password").val();
document.getElementById('nick').value = nickname;
document.getElementById('room').value = roomname;
if (nickname == "" || roomname == ""){
layer.msg("房间号和昵称不能为空!",{anim: 6});
return;
}
$.ajax({
type: "POST",
url: "/ws/judgeNick",
data: {room:roomname,nick:nickname,pwd:password},
dataType: "json",
success: function(data){
//房间存在但是昵称重复
if (data.code == 1){
layer.msg(data.msg,{anim: 6});
document.getElementById('nickname').value = '';
return;
}else if (data.code == 2){ //房间存在但是密码错误
if (password == null || password == ""){
if ($(".login").css("height") == "300px"){
layer.msg("请输入密码",{anim: 6});
}else {
ejectPwd("该房间需要密码");
}
}else {
layer.msg(data.msg,{anim: 6});
document.getElementById('password').value = "";
}
return;
}else if (data.code == 0){
//表示房间不存在
if (password == null || password == ""){
layer.confirm('需要为该房间添加一个密码吗?', {
btn: ['需要','不用'] //按钮
}, function(){
ejectPwd("请输入一个密码");
layer.closeAll('dialog');
}, function(){
sendnick(nickname,roomname,password);
});
}else {
sendnick(nickname,roomname,password);
}
}else {
sendnick(nickname,roomname,password);
}
}
});
}
//获得当前房间中的所有用户
function getOnlion(room) {
$.ajax({
type: "POST",
url: "/ws/online",
data: {room:room},
dataType: "json",
success: function(data){
if (data.onlineNum > 0){
var onlineUsera = data.onlineUsera;
$("#cebian").html("");
onlineUsera.forEach(function (user) {
var color = "#00ce46";
if (shieldMap.has("user-"+user.id)){
color = "#FF3A43"
}
if (user.id != $("#userId").val()){
var html = '<li>\n' +
' <a class="canvi-navigation__item">\n' +
' <span title="点击屏蔽此人,不让其接收自己的消息" id="user-'+user.id+'" class="canvi-navigation__icon-wrapper" onclick="shield(this)" style="background: '+color+';">\n' +
' <span class="canvi-navigation__icon icon-iconmonstr-code-13"></span>\n' +
' </span>\n' +
' <span class="canvi-navigation__text">'+user.nick+'</span>\n' +
' </a>\n' +
' </li>';
$("#cebian").append(html);
}
});
}
}
});
}
// 屏蔽某个成员不让他接收我的消息
function shield(obj) {
var userId = obj.id.substring(5)
if (userId != $("#userId").val()){
if (shieldMap.has(obj.id)){
shieldMap.delete(obj.id);
layer.msg('该用户将会重新接收到您的消息😁');
}else {
shieldMap.set(obj.id,userId);
layer.msg('该用户将不会接收到您的消息了😉');
}
getOnlion(document.getElementById('room').value);
}else {
layer.msg('这个是您自己呦,点了也没用😂');
}
}
//播放提示音
function playSound(){
var borswer = window.navigator.userAgent.toLowerCase();
if ( borswer.indexOf( "ie" ) >= 0 )
{
//IE内核浏览器
var strEmbed = '<embed name="embedPlay" src="./audio/ding.mp3" autostart="true" hidden="true" loop="false"></embed>';
if ( $( "body" ).find( "embed" ).length <= 0 )
$( "body" ).append( strEmbed );
var embed = document.embedPlay;
//浏览器不支持 audion,则使用 embed 播放
embed.volume = 100;
//embed.play();
} else
{
//非IE内核浏览器
var strAudio = "<audio id='audioPlay' src='./audio/ding.mp3' hidden='true'>";
if ( $( "body" ).find( "audio" ).length <= 0 )
$( "body" ).append( strAudio );
var audio = document.getElementById( "audioPlay" );
//浏览器支持 audion
audio.play();
}
}
var t = new Canvi({
content: ".js-canvi-content",
isDebug: !1,
navbar: ".myCanvasNav",
openButton: ".js-canvi-open-button--left",
position: "left",
pushContent: !1,
speed: "0.2s",
width: "100vw",
responsiveWidths: [ {
breakpoint: "600px",
width: "21%"
}, {
breakpoint: "1280px",
width: "21%"
}, {
breakpoint: "1600px",
width: "21%"
} ]
})
function sendImg(obj) {
var file = document.getElementById("imageUp");
var image = document.getElementById("image");
if (!(image.value == null || image.value == "")){
var img = getObjectURL(obj.files[0]);
var nick = document.getElementById('nick').value;
setImgInnerHTML2(nick,img);
var form = new FormData(file);
loadImage(image,1024);
upImgByLocalApi(form);
// form.append("type","multipart");
// loadImage(image,10240);
// setTimeout(function(){ upImgByFigureBed(form); }, 300);
image.value = null;
}
}
function getObjectURL(file) {
var url = null;
if (window.createObjcectURL != undefined) {
url = window.createOjcectURL(file);
} else if (window.URL != undefined) {
url = window.URL.createObjectURL(file);
} else if (window.webkitURL != undefined) {
url = window.webkitURL.createObjectURL(file);
}
return url;
}
//使用图床上传图片
function upImgByFigureBed(form) {
// var url;
$.ajax({
url: "http://api.yum6.cn/sinaimg.php",
type: "POST",
data: form,
async:false,
processData:false,
contentType:false,
success: function (data) {
var url = data.url.replace("thumb150","large");
if (url != null && url != ""){
var map = new Map();
map.set("type","img");
map.set("msg",url);
var shiels = new Array();
shieldMap.forEach(function (value) {
shiels.push(value);
})
map.set("shiels",shiels);
var map2json=Map2Json(map);
websocket.send(map2json);
}
},
error: function (data) {
layer.msg("失败",{anim: 6});
}
});
}
//使用系统本身接口上传图片
function upImgByLocalApi(form) {
$.ajax({
url: "/ws/fileUpload",
type: "POST",
data: form,
async:false,
processData:false,
contentType:false,
success: function (data) {
var url = data.url;
if (url != null && url != ""){
var map = new Map();
map.set("type","img");
map.set("msg",url);
var shiels = new Array();
shieldMap.forEach(function (value) {
shiels.push(value);
})
map.set("shiels",shiels);
var map2json=Map2Json(map);
websocket.send(map2json);
}
},
error: function (data) {
layer.msg("失败",{anim: 6});
}
});
}
function Map2Json(map) {
var str = "{";
map.forEach(function (value, key) {
str += '"'+key+'"'+':'+ '"'+value+'",';
})
str = str.substring(0,str.length-1)
str +="}";
return str;
}
//校验图片类型及大小
function loadImage(img,size) {
var filePath = img.value;
var fileExt = filePath.substring(filePath.lastIndexOf("."))
.toLowerCase();
if (!checkFileExt(fileExt)) {
layer.msg("您上传的文件不是图片,请重新上传!",{anim: 6});
img.value = "";
return;
}
if (img.files && img.files[0]) {
if ((img.files[0].size / 1024).toFixed(0) > size){
layer.msg("图片不能超过1M,请重新选择",{anim: 6});
img.value = "";
return;
}
} else {
img.select();
var url = document.selection.createRange().text;
try {
var fso = new ActiveXObject("Scripting.FileSystemObject");
} catch (e) {
layer.msg('如果你用的是ie8以下 请将安全级别调低!',{anim: 6});
}
layer.msg("文件大小为:" + (fso.GetFile(url).size / 1024).toFixed(0) + "kb",{anim: 6});
}
}
function checkFileExt(ext) {
if (!ext.match(/.jpg|.jpeg|.gif|.png|.bmp/i)) {
return false;
}
return true;
}
function notifyMe(data) {
var notification = null;
var msg = data.msg;
var nick = data.sendUser;
var type = data.type;
switch(type){
case 'init':
notification = new Notification(nick,{
body: msg,
icon: 'http://'+host+'/icon/enter.png',
});
break;
case 'msg':
notification = new Notification(nick,{
body: msg,
icon: 'http://'+host+'/icon/mail.png',
});
break;
case 'img':
notification = new Notification(nick,{
icon: msg,
// body: '你收到一张图片',
});
break;
default:
break;
}
// 先检查浏览器是否支持
if (!("Notification" in window)) {
layer.msg("您的浏览器不支持桌面通知",{anim: 6});
}
// 检查用户是否同意接受通知
else if (Notification.permission === "granted") {
// If it's okay let's create a notification
var notification1 = notification;
notification.onclick = function() {
notification.close();
window.resizeBy(-100,-100)
};
}
// 否则我们需要向用户获取权限
else if (Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
// 如果用户同意,就可以向他们发送通知
if (permission === "granted") {
var notification1 = notification;
notification.onclick = function() {
notification.close();
window.resizeBy(-100,-100)
};
}
});
}
// 最后,如果执行到这里,说明用户已经拒绝对相关通知进行授权
// 出于尊重,我们不应该再打扰他们了
}
function checkNotification() {
$(".login").animate({height:'250px'},200);
$(".login-input-content").animate({'margin-top':'0px'},200);
if (!("Notification" in window)) {
layer.msg("您的浏览器不支持桌面通知",{anim: 6});
}else{
Notification.requestPermission();
}
}
function allRoom(obj){
$.ajax({
type: "POST",
url: "/ws/allRoom",
dataType: "json",
success: function(data){
var rooms = data.rooms;
$("#rooms").empty();
if (rooms.length > 0){
layer.tips("双击或点这里可选择已存在的房间",obj);
}
rooms.forEach(function (room) {
var html = '<option value="'+room+'">';
$("#rooms").append(html);
});
}
});
}
function silence() {
mute++;
if (mute%2 == 0){
$('#mute').attr("src","./icon/unmute.png");
layer.msg("消息通知已开启");
}else {
$('#mute').attr("src","./icon/mute.png");
layer.msg("消息通知已关闭");
}
}
// 弹出密码框
function ejectPwd(message) {
$(".login-input").animate({height:'35px'},300);
$(".login").animate({height:'300px'},300);
$("#password").attr('placeholder',message);
}
new Vue({
el: '#toolbar',
data: {
emojiTips : emojiTips,
pictureTips : pictureTips,
shakeTips : shakeTips,
clearTips : clearTips,
sendTips : sendTips,
}
})
new Vue({
el: '#canvi',
data: {
msgSwitchTips : msgSwitchTips
}
})
</script>
4.2 测试
- 消息测试,表情测试,图片测试
日志输出