嘘~ 正在从服务器偷取页面 . . .

关于 Shotalk 即时聊天网站的开发(核心功能实现篇)


一. 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">-->
        <!--&lt;!&ndash; 文件存放位置 %{xxx} 就是之前定义的属性xxx &ndash;&gt;-->
        <!--<file>${log_path}/${appName}.log</file>-->
        <!--<encoder>-->
            <!--&lt;!&ndash; %date和%d是一个意思 %file是所在文件 %line是所在行 &ndash;&gt;-->
            <!--<pattern>%date %level [%thread] %logger{30} [%file:%line] %msg%n</pattern>-->
        <!--</encoder>-->
    <!--</appender>-->

    <!-- 输出到HTML格式的文件 -->
    <!--<appender name="htmlFiles" class="ch.qos.logback.core.FileAppender">-->
        <!--&lt;!&ndash; 过滤器,这个过滤器是行为过滤器,直接过滤掉了除debug外所有的行为信息 &ndash;&gt;-->
        <!--<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">-->
            <!--&lt;!&ndash; HTML输出格式 可以和上边差不多 &ndash;&gt;-->
            <!--<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">-->
    <!--&lt;!&ndash; 连接输出源,也就是上边那几个输出源,你可以随便选几个appender &ndash;&gt;-->
    <!--<appender-ref ref="stdout"/>-->
    <!--<appender-ref ref="logFiles"/>-->
    <!--&lt;!&ndash;<appender-ref ref="htmlFiels"/>&ndash;&gt;-->
    <!--</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 的计算方法是:
计算公式为:

&nbsp;Sec-WebSocket-Key&nbsp;&nbsp;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&nbsp;拼接。
&nbsp;
通过&nbsp;SHA1&nbsp;计算出摘要,并转成&nbsp;base64&nbsp;字符串。

伪代码:

toBase64(&nbsp;sha1(&nbsp;Sec-WebSocket-Key&nbsp;+&nbsp;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&nbsp;)&nbsp;&nbsp;)

3.2 核心实现

  1. 服务端

    spring-messaging 和 spring-websocket 模块提供 WebSocket 支持的 STOMP,一旦有了这些依赖项,就可以通过WebSocket 使用 SockJS Fallback 公开 STOMP 端点

  2. 设置 WebSocket 配置

    @EnableWebSocketMessageBroker:用于启用我们的WebSocket服务器。我们实现了WebSocketMessageBrokerConfigurer接口,并实现了其中的方法。

    endpointSang:我们注册一个websocket端点,客户端将使用它连接到我们的websocket服务器。withSockJS():是用来为不支持websocket的浏览器启用后备选项,使用了SockJS。

    StompEndpointRegistry:是来自Spring框架STOMP实现。STOMP代表简单文本导向的消息传递协议。它是一种消息传递协议,用于定义数据交换的格式和规则。为啥我们需要这个东西?因为WebSocket只是一种通信协议。它没有定义诸如以下内容:如何仅向订阅特定主题的用户发送消息,或者如何向特定用户发送消息。我们需要STOMP来实现这些功能。

    在configureMessageBroker方法中,我们配置一个消息代理,用于将消息从一个客户端路由到另一个客户端。

  3. 创建 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 测试

  • 消息测试,表情测试,图片测试

  • 日志输出


文章作者: 清风摇翠
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 清风摇翠 !
评论
  目录