WebSocket双工通信实现一个用户只能同时在一台设备上登录需求之客户端实现

前言

在上一篇文章WebSocket双工通信实现一个用户只能同时在一台设备上登录需求之服务端实现]中笔者讲述了如何在SpringBoot项目中暴露WebSocket服务端点,并使用WebSocket监听客户端websocket回话打开、收到消息以及会话关闭等事件,最重要的部分是使用redission工具包中的RDeque队列实现了判断同一个用户是否有多个websocket会话,如果有则踢掉前一个会话,同时服务端WebSocket发消息给WebSocket客户端通知被踢掉的用户当前会话已失效。

那么,本文笔者就来带领代价一起完成WebSocket客户端的编码,客户端与服务端连接成功之后我们就能体验到WebSocket双工通信给应用带来的优势。

前端项目搭建

首先,让我们使用目前前端界最流行的vue3+vite+vue-router技术栈搭建一个前端项目

这里我们使用vue脚手架搭建项目,并使用vite打包和编译项目源码。关于为什么选用Vite, 大家可以查看Vite官方中文文档为什么选Vite

注意:Vite 需要 Node.js 版本 14.18+,16+, 请注意升级你的Node.js版本

Vite搭建项目的三种方式:

  • 使用NPM

    $ npm create vite@latest
    
  • 使用Yarn

     $ yarn create vite
    
  • 使用pnpm

    pnpm create vite
    

    然后按照提示操作即可,你还可以通过附加的命令行选项直接指定项目名称和你想要使用的模板。例如,要构 建一个 Vite + Vue 项目,运行:

# npm 6.x
npm create vite@latest my-vue-app --template vue

# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template vue

# yarn
yarn create vite my-vue-app --template vue

# pnpm
pnpm create vite my-vue-app --template vue

笔者选用的包管理器为pnpm, 还没有安装和使用过pnpm的读者可以参考博文使用pnpm包管理器替代npm及yarn的命令示例 学习安装和使用新一代包管理器pnpm,笔者就不过多赘述了。

  1. 我们在D盘的根目录下,右键点击git bash进入git命令控制台,然后使用pnpm create vite vue-bonus-admin --template vue 命令创建项目vue-bonus-admin;

  2. 然后执行 cd ./vue-bonus-admin 命令进入项目的根目录,通过pnpm 添加项目的一些依赖

笔者这里提供一个分完整的package.json文件, 直接通过执行pnpm install命令即可安装项目所需的依赖包
项目的package.json文件

{
    
    
  "name": "vue-bonus-admin",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    
    
    "dev": "vite --open",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
  },
  "dependencies": {
    
    
    "axios": "^1.2.0",
    "dayjs": "^1.11.6",
    "element-plus": "^2.2.26",
    "less": "^4.1.3",
    "less-loader": "^11.1.0",
    "lodash": "^4.17.21",
    "pinia": "^2.0.26",
    "vant": "^4.0.11",
    "vue": "^3.2.45",
    "vue-router": "^4.1.6"
  },
  "devDependencies": {
    
    
    "@rushstack/eslint-patch": "^1.1.4",
    "@vitejs/plugin-vue": "^3.2.0",
    "@vue/eslint-config-prettier": "^7.0.0",
    "eslint": "^8.22.0",
    "eslint-plugin-prettier": "^4.2.1",
    "eslint-plugin-simple-import-sort": "^8.0.0",
    "eslint-plugin-vue": "^9.3.0",
    "prettier": "2.8.1",
    "unplugin-vue-components": "^0.24.0",
    "vite": "^3.2.4"
  }
}

项目主要使用的依赖包有

  • axios(用于向后端发送http请求,等同于ajax);
  • dayjs(日期操作工具包);
  • element-plus(UI框架工具包);
  • less(支持less格式的样式文件依赖包);
  • lodash(支持算术运算的工具包);
  • pinia(代替vuex的用户缓存工具包);
  • vant(适配移动端UI框架的工具包);
  • vue(vue前端框架工具包);
  • vue-router(支持vue组件路由的工具包)
  • vite(编译、打包和作为服务器运行本地开发环境项目的工具包)

这个项目笔者使用的PC端UI框架为element-plus, 移动端使用的UI框架则为vant, 不过由于笔者对于Vant框架的使用还不是很熟练,这里就只选择演示PC端的编码。在终端控制台运行pnpm devpnpm build命令就能在本地启动前端服务和打包前端项目。

项目重要文件介绍

index.html

<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>VV136</title>
  </head>
  <style>
    body {
      
      
      margin: 0 !important;
    }
  </style>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

这个文件会在使用vite命令创建 项目的时候会自动生成,也是前端项目的入口文件。

App.vue

<script setup>
import {
    
     RouterView } from 'vue-router'
</script>

<template>
  <router-view />
</template>

<style lang="less" scoped>
#app {
    
    
  width: 100%;
  height: 100vh;
}
</style>

这里使用了vue3最新的setup语法,并引入了vue路由视图组件RouterView

main.js

import 'element-plus/dist/index.css'

import ElementPlus from 'element-plus'
import {
    
     createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(router)
app.use(ElementPlus)
app.mount('#app')

main.js文件中的代码表示:创建Vue App实例,并在该实例中使用element-plus和自定义的路由组件,最后并将其挂载到index.html 文档中id为app的节点。

.eslinttrc.cjs

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
    
    
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-prettier',
  ],
  parserOptions: {
    
    
    ecmaVersion: 'latest',
  },
  plugins: ['simple-import-sort'],
  rules: {
    
    
    'simple-import-sort/imports': 'error',
    'vue/multi-word-component-names': 'off',
    'no-undef': 0,
  }
}

.prettrerrc.js

module.exports = {
    
    
  // 字符串使用单引号(WS003-前端开发规范-JavaScript-字符串)
  singleQuote: true,
  // 末尾不添加分号(WS003-前端开发规范-JavaScript-分号)
  semi: false,
  // 缩进为2个空格
  useTabs: false,
  tabWidth: 2,
  // 只对非法标识符的属性使(WS003-前端开发规范-JavaScript-对象)
  quoteProps: 'as-needed',
  // 当只有一个参数省略括号(WS003-前端开发规范-JavaScript-箭头函数)
  arrowParens: 'avoid',
  // 对象两端留空格
  bracketSpacing: true,
  // 换行长度
  printWidth: 80,
  // 元素对齐风格
  bracketSameLine: false,
  // End of line
  endOfLine: 'lf',
  jsxBracketSameLine: false,
  htmlWhitespaceSensitivity: "ignore"
}

以上两个文件都是为了代码规范化而做的配置

Vite.config.js

import {
    
     fileURLToPath, URL } from 'node:url'

import vue from '@vitejs/plugin-vue'
import {
    
     VantResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import {
    
     defineConfig } from 'vite'

export default defineConfig({
    
    
  publicPath: '/',
  plugins: [  // 插件配置
    vue(),
    Components({
    
    
      resolvers: [VantResolver()], // 组件解析器
    }),
  ],
  resolve: {
    
    
    alias: {
    
    
      '@': fileURLToPath(new URL('./src', import.meta.url)),// 配置@符号别名解析
    },
  },
  css: {
    
    
    requireModuleExtension: true,
  },
})

这个文件是Vite编译和打包和运行项目的配置文件,也是整个前端项目的核心配置文件

jsconfig.json

{
    
    
  "compilerOptions": {
    
    
    "baseUrl": "./",
    "paths": {
    
    
      "@/*": ["src/*"]
    },
    "types": ["element-plus/global"],
    "allowJs": true
  },
  "exclude": ["node_modules", "dist"]
}

这个文件是vue项目的源码编译配置文件

.env.development

VITE_BASE_URL=http://127.0.0.1:8090

这个文件是开发环境服务端API 基础URL环境变量配置文件

.env

VITE_BASE_URL=http://<serverIp>:8090/bonus

以上是和前端项目搭建和运行有关的重要文件中的编码,下面正式进入我们的客户端编码实现WbSocket实现与服务端通信的重要业务逻辑部分。

登录组件编码

src/views/login目录下新建index.vue文件并完成登录的业务逻辑编码

<template>
  <el-card class="box-card">
    <template #header>
      <div class="card-header">
        <span>系统登录</span>
      </div>
    </template>

    <el-form ref="loginFormRef" :model="loginForm">
      <el-form-item
        prop="username"
        label="账号"
        :rules="[
          {
    
    
            required: true,
            message: '请输入账号',
            trigger: 'blur',
          },
        ]"
      >
        <el-input v-model="loginForm.username" />
      </el-form-item>
      <el-form-item
        prop="password"
        label="密码"
        :rules="[
          {
    
    
            required: true,
            message: '请输入密码',
            trigger: 'blur',
          },
        ]"
      >
        <el-input v-model="loginForm.password" type="password" />
      </el-form-item>
      <el-form-item>
        <el-button
          type="primary"
          style="width: 100%; margin-top: 24px"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
    </el-form>
  </el-card>
</template>

<script setup>
import {
    
     reactive, ref } from 'vue'
import {
    
     useRouter } from 'vue-router'
// import { useUserStore } from '@/store/modules/user'
// import { isMobile } from '@/utils/helper'
import axios from 'axios'
import {
    
     ElMessage, ElMessageBox } from 'element-plus'

const request = axios.create({
    
    
  baseURL: VITE_BASE_URL,
  withCredentials: true,
  timeout: 10000,
  headers: {
    
    
    'Content-Type': 'application/json;charset=UTF-8'
  },
})
const login = (params)=>{
    
    
    return request({
    
    
    url: `/member/login?username=${
      
      params.username}&password=${
      
      params.password}`,
    method: 'post'
  })
}

let loginForm = reactive({
    
    
  username: '',
  password: '',
})

const router = useRouter()
const loginFormRef = ref()
const handleLogin = () => {
    
    
  loginFormRef.value.validate(valid => {
    
    
    if (valid) {
    
    
      // const userStore = useUserStore()
      // userStore.login(loginForm).then(() => {
    
    
      //   router.push({ path: isMobile() ? '/' : 'protocols' })
      // })
      const params = {
    
    
        username: loginForm.username,
        password: loginForm.password
      } 
        login(params).then(res=>{
    
    
        if(res.data.code==200){
    
    
          ElMessage.success('登录成功')
          const userInfo = res.data.data.memInfo
          const sessionId = res.data.data.sessionId
          // 登录成功后保存用户信息和sessionId到本地缓存中
          localStorage.setItem('userInfo', JSON.stringify(userInfo))
          localStorage.setItem('sessionId', sessionId)
          const socketParams = {
    
    
            memAccount: userInfo.memAccount,
            sessionId: sessionId
          }
          createWebSocket(socketParams)
          router.push('/homePage')
        }else{
    
    
          ElMessage.error('登录失败')
          console.error(res.data.message)
        }
      })
    } else {
    
    
      return false
    }
  })
}

const createWebSocket = (params)=>{
    
    
  if(typeof WebSocket == undefined){
    
    
    ElMessage.warning('浏览器不支持WebSocket')
    return
  } else {
    
    
    const paramsArr = []
    Object.keys(params).forEach(key=>{
    
    
      paramsArr.push(`${
      
      key}=${
      
      params[key]}`)
    })
    let socketUrl = 'ws://127.0.0.1:8090/bonus/wsMessage' // 正式环境需要location.hostname换上对部署应环境的域名和端口号
    socketUrl += '?'+paramsArr.join('&')
    let webSocket = new WebSocket(socketUrl)
    // 监听websocket连接事件
    webSocket.onopen = ()=> {
    
    
      console.log('建立websocket连接')
      webSocket.send('呼叫webSocket服务器,收到请回答')
    }
    // 监听收到websocket服务端消息事件
    webSocket.onmessage = (message)=> {
    
    
      console.log(message)
      console.log('收到来自webSocket服务器的信息:'+JSON.stringify(message.data))
      const messageData = JSON.parse(message.data)
      if(messageData.code==1001){
    
    
          // 提示当前用户已被踢出登录,并清空本地缓存和会话缓存,然后跳转到登录页面
          ElMessageBox.confirm('您的账号在别处登录,当前会话已失效!\n如非您本人操作,请尽快修改登录密码,防止账号被其他人非法使用',
          '警告',
          {
    
    
            confirmButtonText: '是',
            type: 'warning',
            draggable: true,
            callback:(action)=>{
    
    
              console.log(action)
              localStorage.clear()
              // 跳转到登录页面
              router.push('/login')
            }
          })
      }
    }
  }
}
</script>

<style lang="less" scoped>
.box-card {
    
    
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100%;
  max-width: 420px;
  transform: translate(-50%, -50%);
  .card-header {
    
    
    color: #000;
    font-weight: 500;
  }
}
</style>

在登录组件的createWebSocket方法中, 通过输入参数webSocketUrl使用构造方法创建WebSocket实例的时候客户端就会与WebSocket服务端建立了连接。然后给这个建立的WebSocket实例添加对应的onopenonmessageonclose等回调方法,主要是在onmessage方法中根据服务端收到消息的内容做出相应的业务逻辑处理。这里我们对收到服务端消息的响应码为1001时代表当前用户已被踢下线,需要做出清空本地浏览器缓存,然后再跳转到登录页面。

系统主页编码

src/layoutContainer目录下新建index.vue文件, 这个作为系统的布局容器组件

<template>
    <div class="common-layout">
      <el-container>
        <el-header>Header</el-header>
        <el-container>
          <el-aside width="200px">Aside</el-aside>
          <el-container>
              <!--主窗口中内嵌子路由-->
            <el-main>
                <router-view />
            </el-main>
            <el-footer>Footer</el-footer>
          </el-container>
        </el-container>
      </el-container>
    </div>
  </template>
  <script>
  import {
    
     RouterView } from 'vue-router'
  export default {
    
    
    setup(props){
    
    

    }
  }
  </script>

然后在src/homePage目录下新建index.vue文件, 作为用户登录成功后跳转进入的系统首页

<template>
    <h1>你好{
    
    {
    
    username}},欢迎进入本系统首页!</h1>
    <hello-world :msg="msg" />
</template>
<script>
import HelloWorld from '@/components/HelloWorld.vue'
import {
    
    ref, reactive} from 'vue'
export default {
    
    
    components: {
    
     HelloWorld },
    setup(props){
    
    
      let msg = ref('Vue3 and Vite can quickly improve your develop progress')
      let username = ref(JSON.parse(localStorage.getItem('userInfo')).memAccount)
      return reactive({
    
    
        msg: msg,
        username: username
      })
    }
}
</script>

添加路由组件

页面组件开发完之后还需要我们将组件添加到路由数组中

src/router目录下新建index.js文件

import {
    
     createRouter, createWebHashHistory } from 'vue-router'
// 定义路由数组
export const routers = [
    {
    
    
    path: '/',
    name: 'Layout',
    component: () => import('@/LayoutContainer/index.vue'),
    redirect: '/homePage',
    children: [{
    
    
      path: '/homePage',
      name: 'homePge',
      component: ()=> import('@/views/homePage/index.vue')
    }]
  },
  {
    
    
    path: '/login',
    name: 'login',
    component: ()=> import('@/views/login/index.vue')
  }
]

// 定义路由对象
const router = createRouter({
    
    
  history: createWebHashHistory(),
  routes: routers,
  scrollBehavior() {
    
    
    return {
    
     left: 0, top: 0 }
  }
})

// 路由首位钩子函数
router.beforeEach(async (to, from, next) => {
    
    
  if (localStorage.getItem('userInfo')==null && to.path !== '/login') {
    
    
    next('/login')
    return
  }
  next()
  return
})
// 导出路由对象
export default router

测试效果

先启动后台SpringBoot项目服务, 然后在前端项目的根目录的控制台中执行pnpm dev命令,启动客户端本地服务,控制台出现如下信息表示服务启动成功
vite_open.png
然后我们打开谷歌浏览器,输入 http://localhost:5173 再回车

由于路由的守卫钩子函数,在用户还没有登录认证身份信息之前系统会自动进入登录界面

login_page.png
然后我们输入用户名和密码并点击登录,同时按住F12键打开浏览器控制台,登录成功后系统会自动跳转到首页。

homePage
由于本文的目的主要是演示WebSocket双工通信效果,因此我们暂时不调整首页样式。

我们可以在浏览器控制台中看到如下用户信息:

clientMessage.png述
同时在服务端控制台中也可以看到相应的webSocket连接日志:

2023-03-19 19:21:32.041  INFO 10368 --- [pool-2-thread-4] c.b.bonusbackend.config.WebSocketServer  : sessionId:5d80256ad96c436ba6d551fb396b8836,memAccount:heshengfu
2023-03-19 19:21:32.043  INFO 10368 --- [pool-2-thread-4] c.b.bonusbackend.config.WebSocketServer  : currentUser={
    
    "isKickOut":false,"memAccount":"heshengfu","memPwd":"60cb2521f2012927e2509b56e30ab89b","sessionId":"5d80256ad96c436ba6d551fb396b8836","memId":1592203435596333057}

然后我们再打开另外一个Microsoft Edge浏览器,在浏览器中输入http://localhost:5173/, 然后回车进入登录界面, 输入同一个用户名和密码点击登录,登录成功后回到谷歌浏览器的页面会发现页面弹出了一个提示当前用户被踢下线的警告对话框。

kickout_dialog
点击确定按钮后会自动跳转到登录界面

同时在谷歌浏览器的控制台中我们可以看到下面的用户信息:

kickout_messag
另外,我们在服务端后台也可以看到前一个登录用户被踢出下线的日志信息:

2023-03-19 19:33:46.572  INFO 10368 --- [0.1-8090-exec-4] c.b.bonusbackend.config.WebSocketServer  : 收到客户端webSocket消息:呼叫webSocket服务器,收到请回答, queryString=memAccount=heshengfu&sessionId=a78e69e6530347219f02da755115f76d
2023-03-19 19:33:49.651  INFO 10368 --- [pool-2-thread-5] c.b.bonusbackend.config.WebSocketServer  : kickOutUser={
    
    "isKickOut":true,"memAccount":"heshengfu","memPwd":"60cb2521f2012927e2509b56e30ab89b","sessionId":"5d80256ad96c436ba6d551fb396b8836","memId":1592203435596333057}
2023-03-19 19:33:49.735  INFO 10368 --- [pool-2-thread-5] c.b.bonusbackend.config.WebSocketServer  : 发送消息给5d80256ad96c436ba6d551fb396b8836,内容为:{
    
    "msg":"本账号别处登录或被踢出,如有疑问请联系上级","code":1001}

小结

本文我们主要使用vite+vue+vue-router搭建了一个前端项目,并在用户登录成功后实现了WebSocket的客户端消息监听,通过监听相应码为1001的消息感知当前用户已经被踢下线。由于项目代码涉及到客户的隐私,笔者就不方便把源码提交到gitee了,但是大部分与本文要实现的功能的源码笔者都贴在文章里了。

到此,我们使用WebSocket双工通信实现同一个用户在多台设备上登录时自动踢掉前一个登录的用户的客户端功能也就实现了。到这里,我们也算是掌握了一些有关WebSocket的基础技能,借助WebSocket还可以实现一个类似于QQ这样的用户聊天通信系统,有关WebSocket的其他API及玩法大家有时间的话还进一步去解锁提升!

本文首发个人微信公众号【阿福谈Web编程】,欢迎粉丝朋友们给我的微信公众号加个关注,大家一起进步,一起解决工作中的各种难题!

猜你喜欢

转载自blog.csdn.net/heshengfu1211/article/details/132254025