SpringBoot集成WebSocket使用方式

996 阅读5分钟

少年一贯快马扬帆,道阻且长且不转弯,要盛大要绚烂要哗然,要用理想的泰坦尼克去撞现实的冰川,要当烧赤壁的风而非借箭的草船,要为一片海就肯翻越万山。

WebSocket协议

WebScoket是一种全双工通信协议,它是以ws或都wss开头。它可以建立客户机与服务端之间的一个长链接,直到被任何一方终止。

SpringBoot中如何使用WebSocket

集成WebSocket

在SpringBoot项目里面集成WebSocket也很简单,只需要引入相应的Jar包即可。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

这里只列出了WebSocket相关的maven,当然你也要引入web相关的包,这里就不写出来了

引入完成后,实现WebSocket的方式也有两种

  • 通过注解的方式
  • 通过实现WebSocketConfigurer类的方式(本文章所讲解)

通过实现WebSocketConfigurer类的方式

需要新创建一个类实现WebSocketConfigurer并且重写里面的registerWebSocketHandlers方法

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { 
        registry.addHandler(uploadWsHandler,"upload")
        .addInterceptors(handshakeInterceptor)
        .setAllowedOriginPatterns("*");
}

WebSocketHandlerRegistry类中有几个方法:

  • addHandler:这个是添加一个处理器,当有请求需要连接时就会进入这个类中处理。方法的参数二是连接Socket的Url,类似RequestMapping("/api")里面的/api

  • addInterceptors:这个是添加一个拦截器,比如我可以添加一个握手拦截器(HandshakeInterceptor),在握手时看看该请求是否是一个通过认证的请求

  • setAllowedOriginPatterns:设置跨域

该类上面还要标注@EnableWebSocket@Configuration

一是因为该类是做为的一个配置类所以要标注@Configuration

二要开启WebSocket,要标注@EnableWebSocket

创建Handler(本文章的案例是上传文件)

上传文件的方式是前端发送二进制数组。所以这里就继承BinaryWebSocketHandler类。 这里还有一个TextWebSocketHandler的处理器,从名字上就可以看出它是一个处理文本的处理器。具体自行查看

@Component
@Slf4j
public class UploadWsHandler extends BinaryWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("连接建立成功");
    }

    @Override
    protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
        log.info("接收到前端发来的消息");
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        exception.printStackTrace();
        log.info("连出现异常");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("连接建立关闭");
    }
}
方法及作用
afterConnectionEstablished建立连接后所触发的方法
handleBinaryMessage接收到二进制消息触发的方法
handleTransportError当连接出现异常时触发的方法
afterConnectionClosed当连接关闭时触发的方法

注:该类要标注上@Component注解,可以自动注入

通过上面所有的配置,这个时候你应该是可以与服务端建立连接了,地址ws://ip:springboot配置文件中的端口号/upload,你现在可以试下是否能成功建立连接,如有问题可以在方评论区留言

本文所用的案例是上传文件,所以这里只需要修改handleBinaryMessage方法即可

FileOutputStream fos = null;
@Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
    //将接收到的字节数组读取出来
    final byte[] array = message.getPayload().array();
    //拼接好的保存文件的路径
    String path = fileSavePath + File.separator + '123.png';
    if(FileUtil.exist(path)){
        return;
    }
    //通过FOS把这个文件保存到本地
    fos = new FileOutputStream(path);
    try{
        fos.write(array);
        fos.flush();
    }catch (Exception e){
        e.printStackTrace();
    }finally {
        fos.close();
    }
}

测试功能

写好以后就来测试一把,前端我是用的PanJiaChen大佬开源的一个前端脚手架vue-element-admin

mounted() {
  this.initWebSocket()
},
methods: {
    //初始化连接
    initWebSocket() {
      if (typeof (WebSocket) === 'undefined') {
        this.$message({
          message: '您的浏览器不支持WebSocket,推荐使用Google Chrome浏览器',
          type: 'warning'
        })
      } else {
        this.socket = new WebSocket('ws://127.0.0.1:18280/upload?token=' + this.token)
        //如果是传的字节数组,这里要修改成arraybuffer
        this.socket.binaryType = 'arraybuffer'
        this.socket.onmessage = this.webSocketOnMessage
        this.socket.onopen = this.webSocketOnOpen
        this.socket.onerror = this.webSocketOnError
        this.socket.onclose = this.webSocketClose
      }
    },
    webSocketOnOpen() { // 连接建立之后执行send方法发送数据
      this.$message({
        message: 'Socket连接建立成功',
        type: 'success'
      })
    },
    async onChange(file, fileList) {
      this.socket.send(file.raw)
    },
    webSocketOnError(e) { // 连接建立失败重连
      this.$message({
        message: 'Socket连接建立失败',
        type: 'warning'
      })
      console.log(JSON.stringify(e))
      // this.initWebSocket()
    },
    webSocketOnMessage(e) { // 数据接收
      console.log(e.data)
      this.$message('来自服务端的消息:' + e.data)
    },
    websocketsend(Data) { // 数据发送
      this.socket.send(Data)
    },
    webSocketClose(e) { // 关闭
      console.log('断开连接', e)
    }
}

上传文件的组件是用的Element的el-upload

<template>
  <div class="vipmanage-container">
    <el-container>
     <el-main>
      <el-upload
          class="upload-demo"
          action=""
          :limit="3"
          :on-change="onChange"
          :file-list="fileList"
          :auto-upload="false"
        >
          <el-button size="small" type="primary">点击上传</el-button>
        </el-upload>
     </el-main>
    </el-container>
  </div>
</template>

当打开网页后,如果Socket连接成功就会出现这个提示

image.png 点击上传文件后,文件就会上传到你在后端设置的那个路径里面了。 我这里传的是一个小文件,如果你在这个过程中出现了因为缓冲区小出现了问题。 可以在继承WebSocketConfig的那个类中设置下缓冲区或者是看下面所讲的文件分片上传

@Bean
public ServletServerContainerFactoryBean createWebSocketContainer(){
    ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
    container.setMaxBinaryMessageBufferSize(这个大小自已定义);
    return container;
}

大文件上传

有的时候会上传好几个G的大文件,这种文件在上传时最好是将其分片,上传到后端后再将其合并

前端的代码将onChange修改如下

async onChange(file, fileList) {
  var start = 0; var end = 0; var index = 0; const filesize = file.size
  var totalPieces = Math.ceil(filesize / this.bytesPerPiece)
  console.log('start:' + start)
  console.log('end:' + end)
  console.log('index:' + index)
  console.log('filesize:' + filesize)
  console.log('totalPieces:' + totalPieces)
  var chumnk = await this.createFileChunk(file.raw, 5 * 1024 * 1024)
  console.log('分片长度' + chumnk.length)

  for (var i = 0; i < chumnk.length; i++) {
    this.socket.send(chumnk[i])
  }
  //在前端的文件上传完成后,给后端发送一个标识,用来确认是否合并文件
  var blob = new Blob(['1'])
  this.socket.send(blob)
},
//分片方法
async createFileChunk(file, size) {
  const fileChunkList = []
  let count = 0
  while (count < file.size) {
    fileChunkList.push(file.slice(count, count + size))
    count += size
  }
  return fileChunkList
}

后端也只需要修改handleBinaryMessage方未予即可

/**保存文件分片的路径*/
private List<File> filePathSet = new ArrayList<>();

@Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
    if(message.getPayloadLength() == 1 ){
        byte[] array = message.getPayload().array();
        String s = new String(array,"UTF-8");
        if(StrUtil.equals(s,"1")){
            List<File> filePaths = filePathSet.stream().collect(Collectors.toList());
            //这里我将文件的后缀写死了,你们在修改的时候可以传过来
            String returnPath = File.separator + "file" + File.separator + IdUtil.simpleUUID() + ".mp4";
            String mergenPath = fileSavePath + returnPath;
            if(!FileUtil.exist(mergenPath)){
                FileUtil.touch(mergenPath);
            }
            //开始合并文件
            if(Md5Util.mergeFileWithNio(filePaths.toArray(new File[filePaths.size()]),mergenPath)){
                session.sendMessage(new TextMessage("{\"success\":true;\"messgae\":\"上传成功\";\"filepath\":'"+returnPath+"'}"));
            }else{
                session.sendMessage(new TextMessage("{\"success\":false;\"messgae\":\"上传失败\"}"));
            }
            return;
        }
    }
    final byte[] array = message.getPayload().array();
    String byteMd5 = Md5Util.conVertTextToMD5(array);
    String path = fileSavePath + File.separator + byteMd5;
    if(FileUtil.exist(path)){
        filePathSet.add(new File(path));
        return;
    }
    filePathSet.add(new File(path));
    fos = new FileOutputStream(path);
    try{
        fos.write(array);
        fos.flush();
        log.info("Md5{}",byteMd5);
    }catch (Exception e){
        e.printStackTrace();
    }finally {
        fos.close();
    }
}

并且新增了计算MD5和合并文件的方法

public class Md5Util {
    public static final int BUF_SIZE = 1024 * 1204;
    
    //计算Md5
    public static String conVertTextToMD5(byte[] by) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(by);
            byte b[] = md.digest();

            int i;

            StringBuffer buf = new StringBuffer("");
            for (int offset = 0; offset < b.length; offset++) {
                i = b[offset];
                if (i < 0)
                    i += 256;
                if (i < 16)
                    buf.append("0");
                buf.append(Integer.toHexString(i));
            }
            // 32位加密
            return buf.toString();
            // 16位的加密
            // return buf.toString().substring(8, 24);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return null;
        }

    }

    //合并文件
    public static boolean mergeFileWithNio(File[] files, String newFilePath) {
        FileChannel outChannel = null;
        FileChannel inChannel = null;
        try {
            outChannel = new FileOutputStream(newFilePath).getChannel();
            for (File file : files) {
                inChannel = new FileInputStream(file).getChannel();
                ByteBuffer bb = ByteBuffer.allocate(BUF_SIZE);
                while (inChannel.read(bb) != -1) {
                    bb.flip();
                    outChannel.write(bb);
                    bb.clear();
                }
                inChannel.close();
            }
            return true;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        } finally {
            try {
                if (outChannel != null) {
                    outChannel.close();
                }
                if (inChannel != null) {
                    inChannel.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

这个功能是我在写一个会员管理系统里面用到的,如果想要看本文的具体代码可以去我Gitee上看

后端:地址

前端:地址前端是用的PanJiaChen大佬的Vue Element Admin项目修改的