preface

In the previous article, I briefly introduced how to use STOMP to implement a one-to-one chat in SpringBoot’s two ways of integrating STOMP with ONE-TO-one chat. In this article, I will show you a more complete example in practice. However, instead of using STOMP, this article uses a basic WebSocket. If you want to take advantage of STOMP implementation, you can modify it by referring to the previous article. In addition, you are advised to read the following pre-knowledge, which is not necessary if you are familiar with it:

  • Emulated Tim to implement custom and dynamically displayed scroll bars
  • SpringBoot integrates WebSocket to simply simulate group notification
  • Cloud server installation of Redis and integration testing with SpringBoot

If you want to integrate JWT, you can refer to SpringBoot + Vue integrated JWT to implement Token authentication. If you have the opportunity to improve later, we will formally introduce the specific implementation below. The code has also been uploaded to GitHub.

The effect

As usual, let’s show the final implementation:

The login page is as follows:

The chat effect is as follows:

Implementation approach

In this paper, reading and writing information uses the idea of reading diffusion: Save the sending messages of any two people A and B into an A-B(B-A) mailbox, so that they can send messages directly through websocket when they are both online. Even if one of them is offline, they can also get messages from their mailboxes when they are online. In order to realize the convenience, this paper uses Redis to store information. Assuming that the id of two people is 1, 2 respectively, the message list of two people is stored in Redis with the string “1-2” as the key and the message list of two people as the value. In this way, the basic single chat function can be realized.

The specific implementation

Because this article is mainly introduced based on websocket chat room implementation, so about redis configuration is not detailed, if you have doubts, you can leave a message.

The backend implementation

The first is the Bean configuration for ServerEndpointExporter:

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter(a){
        return newServerEndpointExporter(); }}Copy the code

Then there are cross-domain and some resource processor configurations. This article does not use nginx-based reverse proxies to handle cross-domain. If you are interested, check out my previous article:

@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); }}Copy the code

Then there is the Configuration of the Tomcat server to use the WSS protocol so that HTTPS can be used:

@Configuration
public class TomcatConfiguration {

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

    private Connector createSslConnector(a) {
        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(a) {
        return context -> context.addServletContainerInitializer(new WsSci(), null); }}Copy the code

In addition, complete application configuration files are as follows:

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

  # this is a local Windows redis connection
  To configure Redis on a personal server, refer to the third article in the introduction
  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
Copy the code

Then there is the configuration of the RedisTemplate:

@Configuration
public class RedisConfig {

    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // Configure the string serialization parser for the key
        template.setKeySerializer(new StringRedisSerializer());
        // Configure the object serialization parser for the value
        template.setValueSerializer(valueSerializer());
        template.afterPropertiesSet();
        return template;
    }

    private RedisSerializer<Object> valueSerializer(a) {
        // Some specific configuration of the object parser for values
        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);
        returnserializer; }}Copy the code

As well as the corresponding utility classes, there are only two get and set operations used in this article:

@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) {
        // Get all the information in the mailbox
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    public void set(String key, Object value) {
        // Add a message to the mailbox of any two people who are sending a messageredisTemplate.opsForList().rightPush(key, value); }}Copy the code

Then there is the configuration of custom Spring context handling, in case WebSocket is enabled and the context is not loaded correctly:

@Configuration
@ConditionalOnWebApplication
public class AppConfig {

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

    @Bean
    public CustomSpringConfigurator customSpringConfigurator(a) {
        return newCustomSpringConfigurator(); }}Copy the code
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; }}Copy the code

After briefly showing some of the basic configuration above, we will introduce the storage and processing of data. In order to simplify the operation of the database, Spring JPA is used in this paper.

First, show the user class:

@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;
    
}
Copy the code

Then there is the DAO for easy authentication on login:

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

    User findByUsernameAndPassword(String userName, String password);
    
}
Copy the code

And the corresponding 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) {
        // In order to simplify the whole program, we simulate the operation of the user to get the list of friends
        // Do not store friends in the database
        return LongStream.of(1L.2L.3L.4L) .filter(item -> item ! = uid) .mapToObj(this::findById) .collect(Collectors.toList()); }}Copy the code

The corresponding login controller is as follows:

@RestController
public class LoginInController {

    private final UserService userService;

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

    @PostMapping("/login")
    public User login(@RequestBody LoginEntity loginEntity) {
        returnuserService.findByUsernameAndPassword(loginEntity.getUsername(), loginEntity.getPassword()); }}Copy the code

LoginEntity is a simple encapsulation of login information for easy processing. The code is as follows:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginEntity {

    private String username;
    private String password;
    
}
Copy the code

Another preview of message entity encapsulation:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageEntity {

    // Id of the sender
    private Long from;
    // Recipient id
    private Long to;
    // Specific information
    private String message;
    // Send time
    private Date time;

}
Copy the code

And encoders and decoders for the message entity:

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

    @Override
    public MessageEntity decode(String s) {
        // Use gson to process the message entity and format the date
        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(a) {}}Copy the code
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(a) {}}Copy the code

Then comes the main webSocket server configuration:

@Component
// Configure the webSocket path
@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;
    // Store all user connections
    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;
        // Stores each user's session based on the user ID passed in /websocket/{id} as the key
        WEBSOCKET_MAP.put(id, session);
    }

    @OnMessage
    public void onMessage(MessageEntity message) throws IOException {
        // The mailbox store keys are formed according to the id of the sender and receiver in the message entity
        // Use the ascending order of two-person ids separated by - characters as keys
        String key = LongStream.of(message.getFrom(), message.getTo())
                            .sorted()
                            .mapToObj(String::valueOf)
                            .collect(Collectors.joining("-"));
        // Store the information in redis
        redis.set(key, message);
        // If the user is online, the information is sent to the specified user
        if(WEBSOCKET_MAP.get(message.getTo()) ! =null) { WEBSOCKET_MAP.get(message.getTo()).getBasicRemote().sendText(gson.toJson(message)); }}@OnClose
    public void onClose(a) {
        // Delete information from the map when the user exits
        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(); }}Copy the code

Finally, there are two controllers:

Get the controller for the buddy list:

@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) {
        returnuserService.getFriends(uid); }}Copy the code

Controller for users to get information between friends:

@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) {
        // Generate a key based on the id of the pair and fetch it in Redis
        String key = LongStream.of(from, to)
                .sorted()
                .mapToObj(String::valueOf)
                .collect(Collectors.joining("-"));
        returnredis.get(key); }}Copy the code

That’s all the back-end configuration code, and then the implementation of the front end.

The front-end implementation

The first is the encapsulation of the network request, which I used axios:

export default 'https://localhost'    / / const. Js content
Copy the code
import axios from 'axios'
import api from './const'

export function request(config) {

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

  return req(config)
}
Copy the code

Then there is the configuration of the route:

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
})

// Add the global front navigation guard
// If the user information is not found in the local localStorage
// If the user does not log in, the login page is displayed
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

Copy the code

First, in order to simplify the whole program, Vuex or Store mode is not used to store some user information and subsequent contact information, but directly use the local localStorage for storage.

Then there is the login screen, where the style code is omitted for brevity:

<template> <el-row type="flex" class="login"> <el-col :span="6"> <h1 class="title"> <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=" placeholder" prefix ="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 =" input password "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> </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: 'Please enter user name ', trigger: 'blur'}], Password: [{required: true, message:' Please enter password ', 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 (! Localstorage. setItem('user', JSON. Stringify (user)) this.$router.push('/chat') Message(' login successful ')}). Catch (err => {console.log(err)})}} </script>Copy the code

The chat interface is as follows:

<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:) {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>Copy the code

Then there are the two components used in the chat interface, starting with the friends list bar on the left:

<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 "> No friend ~~~ </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>Copy the code

And chat box components:

<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=" Press ENTER to send "@click="sendMsg()"> send </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() { Get the websocket connection based on the user ID this.socket = new WebSocket(`wss://localhost/websocket/${JSON.parse(localStorage.getItem('user')).id}`) this.socket.onmessage = event => { This.msglist. Push (json.parse (event.data))} // To prevent network and other reasons, 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 () {// Clear timer Settings! this.interval &&clearInterval(this.interval) }, data() { return { msg: '', hint: '', api: api, socket: null, bubbleMsg: '', interval: null, isEmptyText: true } }, watch: {msgList() {// Ensure that the scrollbar (if present), 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 = 'Message cannot be empty! ' 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>Copy the code

And you’re done!

conclusion

Due to the personal level is still shallow, some of the implementation ideas in this paper is just as a practice, I hope to help you, if you have some better ideas, also welcome to leave a message.