SpringBoot + Vue 实现基于 WebSocket 的聊天室

·  阅读 1453

前言

在前一篇文章SpringBoot 集成 STOMP 实现一对一聊天的两种方法中简单介绍了如何利用 STOMP 实现单聊,本文则将以一个比较完整的示例展示实际应用,不过本文并未使用 STOMP,而是使用了基础的 websocket 进行实现,如果想利用 STOMP 实现,参考上一篇文章稍加修改即可,此外,建议你阅读以下前置知识,如果比较熟悉就不再需要了:

此外为了展示方便,本文的聊天室整体实现还是比较简单,也没有进行一些身份验证,如果想要集成 JWT,可以参考SpringBoot + Vue 集成 JWT 实现 Token 验证,以后有机会再进行完善,下面就开始正式介绍具体的实现,本文代码同样已上传到GitHub

效果

按照惯例,先展示一下最终的实现效果:

登录界面如下:

1

聊天效果如下:

demo1

实现思路

本文读写信息使用了 读扩散 的思路:将任意两人 A、B 的发送信息都存在一个 A-B(B-A) 信箱里,这样就可以在两人都在线时直接通过 websocket 发送信息,即使由于其中一人离线了,也可以在在线时从两人的信箱里拉取信息,而本文为了实现的方便则采用了 redis 存储信息,假设两人 id 分别为1,2,则以 "1-2" 字符串为键,两人的消息列表为值存储在 redis 中,这样就可以实现基本的单聊功能。

具体实现

由于本文主要是介绍基于 websocket 的聊天室实现,所以关于 redis 等的配置不做详细介绍,如果有疑惑,可以进行留言。

后端实现

首先是 ServerEndpointExporterBean 配置:

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }

}
复制代码

然后是跨域和一些资源处理器的配置,本文未使用基于 nginx 的反向代理处理跨域,如果感兴趣可以看我之前的文章:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {

	@Override
	public void addCorsMappings(CorsRegistry registry) {
		registry.addMapping("/**")
				.allowedOrigins("*")
				.allowedMethods("POST", "GET", "PUT", "PATCH", "OPTIONS", "DELETE")
				.allowedHeaders("*")
				.maxAge(3600);
	}

	@Override
	protected void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/static/**")
				.addResourceLocations("classpath:/static/");
		super.addResourceHandlers(registry);
	}

}
复制代码

然后是为了使用 wss 协议而进行的 Tomcat 服务器配置,以便可以使用 https 协议:

@Configuration
public class TomcatConfiguration {

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        tomcat.addAdditionalTomcatConnectors(createSslConnector());
        return tomcat;
    }

    private Connector createSslConnector() {
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setScheme("http");
        connector.setPort(8888);
        connector.setSecure(false);
        connector.setRedirectPort(443);
        return connector;
    }

    @Bean
    public TomcatContextCustomizer tomcatContextCustomizer() {
        return context -> context.addServletContainerInitializer(new WsSci(), null);
    }

}
复制代码

此外完整的应用配置文件如下:

spring:
  main:
    banner-mode: off

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/blog?serverTimezone=GMT%2B8&charset=utf8mb4&useSSL=false
    username: root
    password: root

  jpa:
    show-sql: true
    properties:
      hibernate:
      dialect: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false

  # 这里使用的是本地 windows 的 redis 连接
  # 想要配置个人服务器上的 redis, 可以参考前言中第三篇文章
  redis:
    database: 0
    host: localhost
    port: 6379
    lettuce:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 10
        min-idle: 5
      shutdown-timeout: 100ms

server:
  port: 443
  ssl.key-store: classpath:static/keystore.jks
  ssl.key-store-password: 123456
  ssl.key-password: 123456
  ssl.key-alias: tomcat
复制代码

然后是 RedisTemplate 的配置:

@Configuration
public class RedisConfig {

    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // 配置键的字符串序列化解析器
        template.setKeySerializer(new StringRedisSerializer());
        // 配置值的对象序列化解析器
        template.setValueSerializer(valueSerializer());
        template.afterPropertiesSet();
        return template;
    }

    private RedisSerializer<Object> valueSerializer() {
        // 对值的对象解析器的一些具体配置
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        serializer.setObjectMapper(objectMapper);
        return serializer;
    }

}
复制代码

以及对应的工具类,这里只包含两个本文使用的 getset 操作:

@Component
public class RedisUtil {

    private final RedisTemplate<String, Object> redisTemplate;

    @Autowired
    public RedisUtil(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public List<Object> get(String key) {
        // 获取信箱中所有的信息
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    public void set(String key, Object value) {
        // 向正在发送信息的任意两人的信箱中中添加信息
        redisTemplate.opsForList().rightPush(key, value);
    }

}
复制代码

然后是自定义的 Spring 上下文处理的配置,这里是为了防止 WebSocket 启用时无法正确的加载上下文:

@Configuration
@ConditionalOnWebApplication
public class AppConfig {

    @Bean
    public Gson gson() {
        return new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create();
    }

    @Bean
    public CustomSpringConfigurator customSpringConfigurator() {
        return new CustomSpringConfigurator();
    }

}
复制代码
public class CustomSpringConfigurator extends ServerEndpointConfig.Configurator implements ApplicationContextAware {

    private static volatile BeanFactory context;

    @Override
    public <T> T getEndpointInstance(Class<T> clazz) {
        return context.getBean(clazz);
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        CustomSpringConfigurator.context = applicationContext;
    }

}

复制代码

简单展示了以上一些基本的配置后,再来介绍对数据的存储和处理部分,为了简便数据库的操作,本文使用了 Spring JPA

首先展示用户类:

@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = 32)
    private String username;
    @Column(length = 64)
    private String password;
    
}
复制代码

然后是为了方便登录时简单验证的 dao

@Repository
public interface UserDao extends JpaRepository<User, Long> {

    User findByUsernameAndPassword(String userName, String password);
    
}
复制代码

以及对应的 service

@Service
public class UserService {

    private final UserDao dao;

    @Autowired
    public UserService(UserDao dao) {
        this.dao = dao;
    }

    public User findById(Long uid) {
        return dao.findById(uid).orElse(null);
    }

    public User findByUsernameAndPassword(String username, String password) {
        return dao.findByUsernameAndPassword(username, password);
    }

    public List<User> getFriends(Long uid) {
        // 这里为了简化整个程序,就在这里模拟用户获取好友列表的操作
        // 就不通过数据库来存储好友关系了
        return LongStream.of(1L, 2L, 3L, 4L)
                .filter(item -> item != uid)
                .mapToObj(this::findById)
                .collect(Collectors.toList());
    }

}

复制代码

对应的登录控制器如下:

@RestController
public class LoginInController {

    private final UserService userService;

    @Autowired
    public LoginInController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/login")
    public User login(@RequestBody LoginEntity loginEntity) {
        return userService.findByUsernameAndPassword(loginEntity.getUsername(), loginEntity.getPassword());
    }

}
复制代码

LoginEntity是对登录信息进行的简单封装,方便处理,代码如下:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginEntity {

    private String username;
    private String password;
    
}
复制代码

另外再提前展示一下消息实体的封装:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageEntity {

    // 发送者的 id
    private Long from;
    // 接受者的 id
    private Long to;
    // 具体信息
    private String message;
    // 发送时间
    private Date time;

}
复制代码

以及关于该消息实体的编码和解码器:

@Component
public class MessageEntityDecode implements Decoder.Text<MessageEntity> {

    @Override
    public MessageEntity decode(String s) {
        // 利用 gson 处理消息实体,并格式化日期格式
        return new GsonBuilder()
                .setDateFormat("yyyy-MM-dd HH:mm:ss")
                .create()
                .fromJson(s, MessageEntity.class);
    }

    @Override
    public boolean willDecode(String s) {
        return true;
    }

    @Override
    public void init(EndpointConfig endpointConfig) {}

    @Override
    public void destroy() {}
    
}

复制代码
public class MessageEntityEncode implements Encoder.Text<MessageEntity> {

    @Override
    public String encode(MessageEntity messageEntity) {
        return new GsonBuilder()
                .setDateFormat("yyyy-MM-dd HH:mm:ss")
                .create()
                .toJson(messageEntity);
    }

    @Override
    public void init(EndpointConfig endpointConfig) {}

    @Override
    public void destroy() {}

}
复制代码

然后就是最主要的 websocket 服务器的配置了:

@Component
// 配置 websocket 的路径
@ServerEndpoint(
    value = "/websocket/{id}",
    decoders = { MessageEntityDecode.class },
    encoders = { MessageEntityEncode.class },
    configurator = CustomSpringConfigurator.class
)
public class WebSocketServer {

    private Session session;
    private final Gson gson;
    private final RedisUtil redis;
    // 存储所有的用户连接
    private static final Map<Long, Session> WEBSOCKET_MAP = new ConcurrentHashMap<>();

    @Autowired
    public WebSocketServer(Gson gson, RedisUtil redis) {
        this.gson = gson;
        this.redis = redis;
    }

    @OnOpen
    public void onOpen(@PathParam("id") Long id, Session session) {
        this.session = session;
        // 根据 /websocket/{id} 中传入的用户 id 作为键,存储每个用户的 session
        WEBSOCKET_MAP.put(id, session);
    }

    @OnMessage
    public void onMessage(MessageEntity message) throws IOException {
        // 根据消息实体中的消息发送者和接受者的 id 组成信箱存储的键
        // 按两人id升序并以 - 字符分隔为键
        String key = LongStream.of(message.getFrom(), message.getTo())
                            .sorted()
                            .mapToObj(String::valueOf)
                            .collect(Collectors.joining("-"));
        // 将信息存储到 redis 中
        redis.set(key, message);
        // 如果用户在线就将信息发送给指定用户
        if (WEBSOCKET_MAP.get(message.getTo()) != null) {
            WEBSOCKET_MAP.get(message.getTo()).getBasicRemote().sendText(gson.toJson(message));
        }
    }

    @OnClose
    public void onClose() {
        // 用户退出时,从 map 中删除信息
        for (Map.Entry<Long, Session> entry : WEBSOCKET_MAP.entrySet()) {
            if (this.session.getId().equals(entry.getValue().getId())) {
                WEBSOCKET_MAP.remove(entry.getKey());
                return;
            }
        }
    }

    @OnError
    public void onError(Throwable error) {
        error.printStackTrace();
    }

}

复制代码

最后是两个控制器:

获取好友列表的控制器:

@RestController
public class GetFriendsController {

    private final UserService userService;

    @Autowired
    public GetFriendsController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/getFriends")
    public List<User> getFriends(@RequestParam("id") Long uid) {
        return userService.getFriends(uid);
    }

}
复制代码

用户获取好友之间信息的控制器:

@RestController
public class PullMessageController {

    private final RedisUtil redis;

    @Autowired
    public PullMessageController(RedisUtil redis) {
        this.redis = redis;
    }

    @PostMapping("/pullMsg")
    public List<Object> pullMsg(@RequestParam("from") Long from, @RequestParam("to") Long to) {
        // 根据两人的 id 生成键,并到 redis 中获取
        String key = LongStream.of(from, to)
                .sorted()
                .mapToObj(String::valueOf)
                .collect(Collectors.joining("-"));
        return redis.get(key);
    }

}
复制代码

以上便是所有的后端配置代码,下面再介绍前端的实现。

前端实现

首先是网络请求的封装,我使用的是 axios

export default 'https://localhost'    // const.js 内容
复制代码
import axios from 'axios'
import api from './const'

export function request(config) {

  const req = axios.create({
    baseURL: api,
    timeout: 5000
  })

  return req(config)
}
复制代码

然后是路由的配置:

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const Login = () => import('@/views/Login')
const Chat = () => import('@/views/Chat')

const routes = [
  {
    path: '/',
    redirect: '/chat'
  },
  {
    path:'/login',
    name:'Login',
    component: Login
  },
  {
    path:'/chat',
    name:'Chat',
    component: Chat
  }
]

const router = new VueRouter({
  mode: 'history',
  routes
})

// 添加全局的前置导航守卫
// 如果没有在本地 localStorage 中得到用户信息
// 说明用户未登录, 直接跳转到登录界面
router.beforeEach(((to, from, next) => {
  let tmp = localStorage.getItem('user')
  const user = tmp && JSON.parse(tmp)
  if (to.path !== '/login' && !user) {
    next('/login')
  }
  next()
}))

export default router

复制代码

这里先说一下,为了简化整个程序,并没有采用 Vuex 或者是 store模式去存储一些用户信息和之后的联系人信息,而是直接全部使用本地 localStorage 进行存储了。

然后是登录界面,这里为了简洁省略了样式代码:

<template>
  <el-row type="flex" class="login">
    <el-col :span="6">
      <h1 class="title">聊天室</h1>
      <el-form :model="loginForm" :rules="rules" status-icon ref="ruleForm" class="demo-ruleForm">
        <el-form-item prop="username">
          <el-input
            v-model="loginForm.username"
            autocomplete="off"
            placeholder="用户名"
            prefix-icon="el-icon-user-solid"
          ></el-input>
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            type="password"
            v-model="loginForm.password"
            autocomplete="off"
            placeholder="请输入密码"
            prefix-icon="el-icon-lock"
          ></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="submitForm" class="login-btn">登录</el-button>
        </el-form-item>
      </el-form>
    </el-col>
  </el-row>
</template>
<script>

import {
  Row,
  Col,
  Form,
  Input,
  Button,
  Loading,
  Message,
  FormItem
} from 'element-ui'
import {request} from '@/network'

export default {
  name: 'Login',
  components: {
    'el-row': Row,
    'el-col': Col,
    'el-form': Form,
    'el-input': Input,
    'el-button': Button,
    'el-form-item': FormItem
  },
  data() {
    return {
      loginForm: {
        username: '',
        password: ''
      },
      rules: {
        username: [{
          required: true,
          message: '请输入用户名',
          trigger: 'blur'
        }],
        password: [{
          required: true,
          message: '请输入密码',
          trigger: 'blur'
        }]
      }
    }
  },
  methods: {
    submitForm() {
      const loading = Loading.service({ fullscreen: true })
      request({
        method: 'post',
        url: '/login',
        data: {
          'username': this.loginForm.username,
          'password': this.loginForm.password
        }
      }).then(res => {
        loading.close()
        let user = res.data.data
        delete user.password
        if (!user) {
          Message('用户名或密码错误')
          return
        }
        // 登录成功将用户的信息存在 localStorage, 并跳转到聊天界面
        localStorage.setItem('user', JSON.stringify(user))
        this.$router.push('/chat')
        Message('登录成功')
      }).catch(err => {
        console.log(err)
      })
    }
  }
}
</script>
复制代码

聊天界面如下:

<template>
  <div id="app">
    <div class="main">
      <Contact @set-contact="set"/>
      <Dialog :contact="contact" :msgList="msgList"/>
    </div>
  </div>
</template>

<script>
import {request} from '@/network'
import Contact from '@/components/Contact'
import Dialog from '@/components/Dialog'

export default {
  name: "Chat",
  components: {
    Dialog,
    Contact
  },
  data() {
    return {
      contact: null,
      msgList: []
    }
  },
  methods: {
    // 点击指定用户后,就获取两人之间的所有信息
    // 并将当前联系人保存在 localStorage
    set(user) {
      this.contact = user
      request({
        method: 'post',
        url: '/pullMsg',
        params: {
          from: JSON.parse(localStorage.getItem('user')).id,
          to: this.contact.id
        }
      }).then(res => {
        this.msgList = res.data.data
      }).catch(err => {
        console.log(err)
      })
    }
  }
}
</script>
复制代码

然后是聊天界面使用的两个组件,首先是左边的好友列表栏:

<template>
  <div class="contact">
    <div class="top">
      <div class="left">
        <img class="avatar" :src="`${api}/static/img/${user.id}.jpg`" alt=""/>
      </div>
      <div class="right">
        {{ user.username }}
      </div>
    </div>
    <div v-if="friendList.length" class="bottom">
      <div v-for="(friend, i) in friendList" class="friend" :class="{activeColor: isActive(i)}" @click="setContact(i)">
        <div class="left">
          <img class="avatar" :src="`${api}/static/img/${friend.id}.jpg`" alt=""/>
        </div>
        <div class="right">
          {{ friend.username }}
        </div>
      </div>
    </div>
    <div v-else class="info">
      <div class="msg">
        还没有好友~~~
      </div>
    </div>
  </div>
</template>

<script>
import api from '@/network/const'
import {request} from '@/network'

export default {
  name: 'Contact',
  data() {
    return {
      api: api,
      active: -1,
      friendList: []
    }
  },
  mounted() {
    // 界面渲染时获取用户的好友列表并展示
    request({
      method: 'post',
      url: '/getFriends',
      params: {
        id: this.user.id
      }
    }).then(res => {
      this.friendList = res.data.data
    }).catch(err => {
      console.log(err)
    })
  },
  computed: {
    user() {
      return JSON.parse(localStorage.getItem('user'))
    }
  },
  methods: {
    setContact(index) {
      this.active = index
      delete this.friendList[index].password
      this.$emit('set-contact', this.friendList[index])
    },
    isActive(index) {
      return this.active === index
    }
  }
}
</script>
复制代码

以及聊天框的组件:

<template>
  <div v-if="contact" class="dialog">
    <div class="top">
      <div class="name">
        {{ contact.username }}
      </div>
    </div>
    <div class="middle" @mouseover="over" @mouseout="out">
      <div v-if="msgList.length">
        <div v-for="msg in msgList">
          <div class="msg" :style="msg.from === contact.id ? 'flex-direction: row;' : 'flex-direction: row-reverse;'">
            <div class="avatar">
              <img alt="" :src="`${api}/static/img/${msg.from}.jpg`"/>
            </div>
            <div v-if="msg.from === contact.id" style="flex: 13;">
              <div class="bubble-msg-left" style="margin-right: 75px;">
                {{ msg.message }}
              </div>
            </div>
            <div v-else style="flex: 13;">
              <div class="bubble-msg-right" style="margin-left: 75px;">
                {{ msg.message }}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="line"></div>
    <div class="bottom">
      <label>
        <textarea
          class="messageText"
          maxlength="256"
          v-model="msg"
          :placeholder="hint"
          @keydown.enter="sendMsg($event)"
        ></textarea>
      </label>
      <button class="send" :class="{emptyText: isEmptyText}" title="按下 ENTER 发送" @click="sendMsg()">发送</button>
    </div>
  </div>
  <div v-else class="info">
    <div class="msg">
      找个好友聊天吧~~~
    </div>
  </div>
</template>

<script>
import api from '@/network/const'
import {request} from '@/network'

export default {
  name: "Dialog",
  props: {
    contact: {
      type: Object
    },
    msgList: {
      type: Array
    }
  },
  mounted() {
    // 渲染界面时, 根据用户的 id 获取 websocket 连接 
    this.socket = new WebSocket(`wss://localhost/websocket/${JSON.parse(localStorage.getItem('user')).id}`)
    this.socket.onmessage = event => {
      this.msgList.push(JSON.parse(event.data))
    }
    // 为防止网络和其他一些原因,每隔一段时间自动从信箱中获取信息
    this.interval = setInterval(() => {
      if (this.contact && this.contact.id) {
        request({
          method: 'post',
          url: '/pullMsg',
          params: {
            from: JSON.parse(localStorage.getItem('user')).id,
            to: this.contact.id
          }
        }).then(res => {
          this.msgList = res.data.data
        }).catch(err => {
          console.log(err)
        })
      }
    }, 15000)
  },
  beforeDestroy() {
    // 清楚定时器的设置
    !this.interval &&clearInterval(this.interval)
  },
  data() {
    return {
      msg: '',
      hint: '',
      api: api,
      socket: null,
      bubbleMsg: '',
      interval: null,
      isEmptyText: true
    }
  },
  watch: {
    msgList() {
      // 保证滚动条(如果存在), 始终在最下方
      const mid = document.querySelector('.middle')
      this.$nextTick(() => {
        mid && (mid.scrollTop = mid.scrollHeight)
        document.querySelector('.messageText').focus()
      })
    },
    msg() {
      this.isEmptyText = !this.msg
    }
  },
  methods: {
    over() {
      this.setColor('#c9c7c7')
    },
    out() {
      this.setColor('#0000')
    },
    setColor(color) {
      document.documentElement.style.setProperty('--scroll-color', `${color}`)
    },
    sendMsg(e) {
      if (e) {
        e.preventDefault()
      }
      if (!this.msg) {
        this.hint = '信息不可为空!'
        return
      }
      let entity = {
        from: JSON.parse(localStorage.getItem('user')).id,
        to: this.contact.id,
        message: this.msg,
        time: new Date()
      }
      this.socket.send(JSON.stringify(entity))
      this.msgList.push(entity)
      this.msg = ''
      this.hint = ''
    }
  }
}
</script>
复制代码

大功告成!

总结

由于个人的水平尚浅,本文的一些实现思路也只是作为练习使用,希望能到帮助到你,如果你有一些更好的思想思路,也欢迎留言交流。

分类:
后端
标签: