整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

用WebRTC和Node.js开发实时视频聊天应用

不多说,我们直奔主题。这篇文章教大家如何编写一个视频聊天应用,使已连接的两用户端能共享视频和音频。操作很简单,非常适合JavaScript语言训练——更准确地说是WebRTC技术和Node.js。

什么是WebRTC?

Web Real-Time Communications 网页实时通信,简称WebRTC。WebRTC是一个HTML5规范,它允许用户在浏览器之间直接进行实时通信,不需要任何第三方插件。WebRTC可用于多种情境(比如文件共享),但端对端实时音频和视频通信是其主要功能。本文将着重为大家介绍这两项。

WebRTC所做的就是允许接入设备。你可以借WebRTC来实时使用麦克风、摄像头和分享你的屏幕。

所以,WebRTC可以用最简单的方式在网页中实现音频和视频通信。

WebRTC JavaScript API

WebRTC说起来很复杂,它涉及到很多技术。但建立连接、通信和传输数据的操作是通过一套JS API来实现的,还比较简单。其中主要的API包括:

RTCPeerConnection:创建和导航端对端连接。

RTCSessionDescription:描述连接(或潜在连接)的一端,以及它的配置方式。

navigator.getUserMedia:捕捉音频和视频。

为什么选择Node.js?

若要在两个或多个设备之间进行远程连接,你就需要一个服务器。在这种情况下,你也需要一个处理实时通信的服务器。Node.js是为实时可扩展的应用而构建的。要开发自由数据交换的双向连接应用程序,你可能会用到WebSockets,它允许在客户端和服务器之间建立一个会话窗口。来自客户端的请求会以循环的方式,更准确地说是事件循环进行处理,这时Node.js是我们很好的一个选择,因为它采取 “非阻塞(non-blocking) “的方式来解决请求。这样我们在这该过程中就能实现低延迟和高吞吐量。

如果你对开发微服务感兴趣的话,一定要看看查看我们内含650多位微服务专家意见的2020年微服务状态报告!

思路拓展:我们要创建的是什么?

我们会创建一个非常简单的应用程序,它能让我们将音频和视频流传输到连接的设备——一个基础款视频聊天应用程序。我们会用到的技术有:

Express库,提供静态文件,比如代表用户界面(UI)的HTML文件;

socket.io库,在两个设备之间用WebSockets建立连接;

WebRTC,允许媒体设备(摄像头和麦克风)在连接的设备之间传输音频和视频流。

实现视频会话

我们要做的第一件事是给我们的应用程序提供一个作为UI的HTML文件。让我们通过运行:npm init.js来初始化新的node.js项目。然后,我们需要通过运行:npm i -D typescript ts-node nodemon @types/express @types/socket.io安装一些开发依赖项,运行:npm i express socket.io安装生产依赖项。

之后我们就可以在package.json文件中定义脚本,来运行我们的项目了。

{
 "scripts": {
   "start": "ts-node src/index.ts",
   "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts"
 },
 "devDependencies": {
   "@types/express": "^4.17.2",
   "@types/socket.io": "^2.1.4",
   "nodemon": "^1.19.4",
   "ts-node": "^8.4.1",
   "typescript": "^3.7.2"
 },
 "dependencies": {
   "express": "^4.17.1",
   "socket.io": "^2.3.0"
 }
}

当我们运行npm run dev命令时,nodemon会监控src文件夹中每个以.ts结尾的文件有无任何变化。现在,我们要创建一个src文件夹。在这个文件夹中,我们会创建两个typescript文件:index.ts和server.ts。

在server.ts中,我们会创建server类,并使其与express和socket.io一起工作。

import express, { Application } from "express";
import socketIO, { Server as SocketIOServer } from "socket.io";
import { createServer, Server as HTTPServer } from "http";
 
export class Server {
 private httpServer: HTTPServer;
 private app: Application;
 private io: SocketIOServer;
 
 private readonly DEFAULT_PORT = 5000;
 
 constructor() {
   this.initialize();
 
   this.handleRoutes();
   this.handleSocketConnection();
 }
 
 private initialize(): void {
   this.app = express();
   this.httpServer = createServer(this.app);
   this.io = socketIO(this.httpServer);
 }
 
 private handleRoutes(): void {
   this.app.get("/", (req, res) => {
     res.send(`<h1>Hello World</h1>`); 
   });
 }
 
 private handleSocketConnection(): void {
   this.io.on("connection", socket => {
     console.log("Socket connected.");
   });
 }
 
 public listen(callback: (port: number) => void): void {
   this.httpServer.listen(this.DEFAULT_PORT, () =>
     callback(this.DEFAULT_PORT)
   );
 }
}

为正常运行服务器,我们需要在index.ts文件中创建一个新的Server类实例并调用listen方法。

import { Server } from "./server";
 
const server = new Server();
 
server.listen(port => {
 console.log(`Server is listening on http://localhost:${port}`);
});

现在,如果我们运行:npm run dev会看到下面这样的情景:

当打开浏览器,输入http://localhost:5000,我们应该注意到左上的 “Hello World “信息。

然后我们就可以在public/index.html中创建一个新的HTML文件了。

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <meta http-equiv="X-UA-Compatible" content="ie=edge" />
   <title>Dogeller</title>
   <link
     href="https://fonts.googleapis.com/css?family=Montserrat:300,400,500,700&display=swap"
     rel="stylesheet"
   />
   <link rel="stylesheet" href="./styles.css" />
   <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
 </head>
 <body>
   <div class="container">
     <header class="header">
       <div class="logo-container">
         <img src="./img/doge.png" alt="doge logo" class="logo-img" />
         <h1 class="logo-text">
           Doge<span class="logo-highlight">ller</span>
         </h1>
       </div>
     </header>
     <div class="content-container">
       <div class="active-users-panel" id="active-user-container">
         <h3 class="panel-title">Active Users:</h3>
      </div>
       <div class="video-chat-container">
         <h2 class="talk-info" id="talking-with-info"> 
           Select active user on the left menu.
         </h2>
         <div class="video-container">
           <video autoplay class="remote-video" id="remote-video"></video>
           <video autoplay muted class="local-video" id="local-video"></video>
         </div>
       </div>
     </div>
   </div>
   <script src="./scripts/index.js"></script>
 </body>
</html>

在这个新文件中,我们创建了两个视频元素:一个用于远程视频连接,另一个用于本地视频。你可能已经注意到我们也在导入本地脚本了。现在我们就来创建一个新的文件夹“脚本”,并在这个目录下创建index.js文件。至于样式,你可以从GitHub库中下载它们。

接下来你需要给浏览器提供index.html。首先,你需要告诉express你想提供哪些静态文件。为了实现这一点,我们决定在Server类中实现一个新方法。

private configureApp(): void {
   this.app.use(express.static(path.join(__dirname, "../public")));
 }

不要忘记在initialize中调用configureApp。

private initialize(): void {
   this.app = express();
   this.httpServer = createServer(this.app);
   this.io = socketIO(this.httpServer);
 
   this.configureApp();
   this.handleSocketConnection();
 }

当你输入http://localhost:5000后,你应该能看到你的index.html文件在运行。

下一步要实现的是允许摄像头和视频访问并将其流式传输到local-video元素。要做到这一点,你需要打开public/scripts/index.js文件,并用以下方法实现它。

navigator.getUserMedia(
 { video: true, audio: true },
 stream => {
   const localVideo = document.getElementById("local-video");
   if (localVideo) {
     localVideo.srcObject = stream;
   }
 },
 error => {
   console.warn(error.message);
 }
);

当回到浏览器时,界面会出现一个提示请求访问你的媒体设备,在接受请求后,你电脑的摄像头就开始工作了。

更多细节详见A simple guide to concurrency in Node.js and a few traps that come with it。

如何处理socket连接?

接下来我们讲讲如何处理socket连接。我们需要将客户端与服务器连接起来。为此,我们将使用socket.io。在public/scripts/index.js中,添加以下代码:

this.io.on("connection", socket => {
     const existingSocket = this.activeSockets.find(
       existingSocket => existingSocket === socket.id
     );
 
     if (!existingSocket) {
       this.activeSockets.push(socket.id);
 
       socket.emit("update-user-list", {
         users: this.activeSockets.filter(
           existingSocket => existingSocket !== socket.id
         )
       });
 
       socket.broadcast.emit("update-user-list", {
         users: [socket.id]
       });
     }
   }

页面刷新后,电脑会弹出一条消息,显示 “Socket已连接”

然后我们回到server.ts中,把已连接的socket存储在内存中,这只是为了保留唯一连接。所以,我们需要在Server类中添加一个新的私有字段,如下:

private activeSockets: string[] = [];

然后我们需要在socket连接中检查socket是否已经存在。如果不存在,把新的socket推送到内存中,并向已连接的用户发送数据。

this.io.on("connection", socket => {
     const existingSocket = this.activeSockets.find(
       existingSocket => existingSocket === socket.id
     );
 
     if (!existingSocket) {
       this.activeSockets.push(socket.id);
 
       socket.emit("update-user-list", {
         users: this.activeSockets.filter(
           existingSocket => existingSocket !== socket.id
         )
       });
 
       socket.broadcast.emit("update-user-list", {
         users: [socket.id]
       });
     }
   }

你还需要在socket断开连接时及时响应,所以在socket连接中,你需要添加:

socket.on("disconnect", () => {
   this.activeSockets = this.activeSockets.filter(
     existingSocket => existingSocket !== socket.id
   );
   socket.broadcast.emit("remove-user", {
     socketId: socket.id
   });
 });

客户端(即public/scripts/index.js)这边,你需要妥善处理那些信息:

socket.on("update-user-list", ({ users }) => {
 updateUserList(users);
});
 
socket.on("remove-user", ({ socketId }) => {
 const elToRemove = document.getElementById(socketId);
 
 if (elToRemove) {
   elToRemove.remove();
 }
});

以下是 updateUserList 函数:

function updateUserList(socketIds) {
 const activeUserContainer = document.getElementById("active-user-container");
 
 socketIds.forEach(socketId => {
   const alreadyExistingUser = document.getElementById(socketId);
   if (!alreadyExistingUser) {
     const userContainerEl = createUserItemContainer(socketId);
     activeUserContainer.appendChild(userContainerEl);
   }
 });
}
以及createUserItemContainer函数:
function createUserItemContainer(socketId) {
 const userContainerEl = document.createElement("div");
 
 const usernameEl = document.createElement("p");
 
 userContainerEl.setAttribute("class", "active-user");
 userContainerEl.setAttribute("id", socketId);
 usernameEl.setAttribute("class", "username");
 usernameEl.innerHTML = `Socket: ${socketId}`;
 
 userContainerEl.appendChild(usernameEl);
 
 userContainerEl.addEventListener("click", () => {
   unselectUsersFromList();
   userContainerEl.setAttribute("class", "active-user active-user--selected");
   const talkingWithInfo = document.getElementById("talking-with-info");
   talkingWithInfo.innerHTML = `Talking with: "Socket: ${socketId}"`;
   callUser(socketId);
 }); 
 return userContainerEl;
}

需要注意的是,我们给用户容器元素添加了一个可以调用callUser函数的点击监听器——但现在,它可以是一个空的函数。接下来,当运行两个浏览器窗口(其中一个作为私人窗口)时,你应该注意到你的Web应用程序中有两个已经连接的socket。

点击列表中的活跃用户,这时我们需要调用callUser函数。但是在实现之前,你还需要在window对象中声明两个类。

const { RTCPeerConnection, RTCSessionDescription } = window;

我们会在callUser函数用到这两个类:

async function callUser(socketId) {
 const offer = await peerConnection.createOffer();
 await peerConnection.setLocalDescription(new RTCSessionDescription(offer));
 
 socket.emit("call-user", {
   offer,
   to: socketId
 });
}

现在我们要创建一个本地请求并发送给选定的用户。服务器会监听一个叫做call-user的事件、拦截请求并将其转发给选定的用户。让我们用server.ts来实现该操作:

socket.on("call-user", data => {
   socket.to(data.to).emit("call-made", {
     offer: data.offer,
     socket: socket.id
   });
 });

对于客户端,你需要就call-made事件作出调整:

socket.on("call-made", async data => {
 await peerConnection.setRemoteDescription(
   new RTCSessionDescription(data.offer)
 );
 const answer = await peerConnection.createAnswer();
 await peerConnection.setLocalDescription(new RTCSessionDescription(answer));
 
 socket.emit("make-answer", {
   answer,
   to: data.socket
 });
});

之后,在你从服务器得到的请求上设置一个远程描述,并为这个请求创建一个答复。对于服务器端,你只需要将适当的数据传递给选定的用户即可。然后我们再在server.ts里面添加一个监听器。

socket.on("make-answer", data => {
   socket.to(data.to).emit("answer-made", {
     socket: socket.id,
     answer: data.answer
   });
 });
对于客户端,我们需要处理 answer-made 事件。
socket.on("answer-made", async data => {
 await peerConnection.setRemoteDescription(
   new RTCSessionDescription(data.answer)
 );
 
 if (!isAlreadyCalling) {
   callUser(data.socket);
   isAlreadyCalling = true;
 }
});

我们可以使用标志isAlreadyCalling,它能帮助确保我们只需调用一次用户。

最后你需要做的是添加本地轨道,包括音频和视频到你的连接端。只有做到这一点,我们才能够与连接的用户共享视频和音频。要做到这一点,我们需要在navigator.getMediaDevice回调中调用peerConnection对象的addTrack函数。

navigator.getUserMedia(
 { video: true, audio: true },
 stream => {
   const localVideo = document.getElementById("local-video");
   if (localVideo) {
     localVideo.srcObject = stream;
   }
 
   stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
 },
 error => {
   console.warn(error.message);
 }
);

另外,我们还需要为ontrack事件添加一个适当的处理程序。

peerConnection.ontrack = function({ streams: [stream] }) {
 const remoteVideo = document.getElementById("remote-video");
 if (remoteVideo) {
   remoteVideo.srcObject = stream;
 }
};

如图示,我们已经从传递的对象中获取了流,并改变了远程视频中的srcObject来使用接收到的流。所以现在当你点击活跃用户后,你应该建立一个视频和音频连接,像下图这样:

欲了解细节,请参阅:Node.js and dependency injection – friends or foes?

现在你知道如何编写一个视频聊天应用了吧!

WebRTC是一个很大的话题,内容非常庞杂。如果你想了解它的运作原理,就需要花很大功夫。幸运的是,我们可以访问易于使用的JavaScript API,它可以帮助我们创建很简洁的应用程序,例如视频共享、聊天应用程序等等。

如果你想深入了解WebRTC,点击此WebRTC官方文档的链接。另外,我也推荐你阅读MDN的文档说明,它能帮助你更加了解此技术。

年前,滨海之边马上毕业的老少年

经过几天半死不活的思考之后决定干前端

那个时候为了面试各种css属性js API背的是滚瓜烂熟

然后投简历,企业要项目经验,

我没有工作我哪来的项目经验啊

没人会管你为什么没有

so

自己编呗,于是乎为了炫技,为了证明自己开始了我的第一个自己给自己找需求的项目 ,他有个一吊炸天的项目名称

还有我辛苦的汗水结晶。

为了吸引别人的眼光 首先得弄一个超级醒目得banner,怎么弄呢,别人的都是平面得 我就弄成立体得,于是乎四处找资料,最终弄成了一个如下图所示得banner:给你来个俯视图得角度

然后实际的显示效果是酱紫:

怎么做到的呢,实际就是将一个元素的transform-style设置成preserve-3d 这个属性允许他的子元素在一个3d空间中以一种以立体的方式呈现而不是跟平面一样,然后将子元素,对于这个banner来说就是六张图,绝对定位至同一中心点,然后分别旋转0,60,120,。。。,360这种角度,然后再给个translateZ让他们在z轴撑开,这样就是一个3d得展示效果咯。

其实那个时候遇到的一个难点是让这个3dbanner适配各种大小的屏幕,移动端先不说,pc端浏览器的宽度就是五花八门的,还有另外一点就是得让这个banner居中

年少的我,那个时候为了适配直接将某些值写死的 这样的话其实在大小屏上呈现的效果会差很多,而且最为重要的一点是,在没有进行适配的屏幕上,效果可能会很差。

关于怎么布局,以及其他细节可以查看张鑫旭的这篇文章,着此进行的一些小修改就是将原有的写死的数值改成百分比,然后图片上的translateZ采用js动态计算,然后居中banner的时候也是,为了使其居中,给整个banner的容器给一个left,这个值得大小等于容器的大小减去图片的宽度再除以2.

关于translateZ的计算:

因为实际是一个等边六边形嘛,所以θ的大小等于 (360 / 6)/2,然后translateZ的大小就是 (x/2) / tanθ 还挺简单。

整体上来说没有太过复杂的处理,额外注意的一点是,居中那个banner,如果无法使内部的图片元素宽度等于包围他父级元素的时候 保持旋转居中的时候是很困难的,但是为了凸显一个立体的事物我们自然需要使得父元素的内能显示当前正面的一张图片,还有两侧的侧面,这个时候就需要一个技巧,就是在父元素外层再包一层元素,然后我们的居中操作在这个元素上设置,就是上面说的设置left值,这样的话既能保证图片的宽度与父级是一致的,也同时满足了立体的显示效果。

预览地址https://daxiazilong.github.io/extream/

技术讲解完毕,以下是牢骚时间。

现在再去看以前写过的东西,那些曾经引以为豪的

绞尽脑汁 去创造的

以及那时不能完美地解决的

好像都变得平平淡淡

额外一件值得开心的事情是 现在得我能解决以前得自己不能完美解决得东西了,这种感觉很舒服~

时间过得真快。

考试系统中,考生在考试过程中切换屏幕可能存在作弊风险。为了解决这一问题,可以使用Spring Boot结合Web前端监听技术和后台实时检测,禁用特定热键,监控前后台切换事件并记录日志。本文将详细介绍技术实现和具体解决方案,并提供示例代码进行深入讲解。

问题描述

在在线考试中,考生可能通过切换屏幕的方式查看其他资料,从而违背考试规则。因此,实时监测考生的屏幕切换行为并做出相应处理,是确保考试公平性的关键。

技术实现

前端监听技术结合Spring Boot后台服务,可以有效地检测并记录屏幕切换事件。具体技术实现步骤如下:

  1. 前端监听屏幕切换事件:使用JavaScript监听浏览器的visibilitychange事件。
  2. 后台实时检测:Spring Boot后台获取前端传递的事件信息并记录日志。
  3. 禁用特定热键:通过JavaScript禁用可能导致屏幕切换的热键如F11Alt+Tab等。

解决方案详解

页面可见性API(Page Visibility API)

页面可见性API允许网页检测其当前的可见性状态,例如,网页是否处于前台或被最小化到后台。我们可以通过监听visibilitychange事件来捕获这些状态变化。

   document.addEventListener('visibilitychange', function() {
     // 检测页面是否可见
     if (document.hidden) {
       console.log('页面不可见');
       // 调用后端API记录
       fetch('/api/log-screen-switch', {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json'
         },
         body: JSON.stringify({ event: 'screen_hidden', timestamp: new Date() })
       });
     } else {
       console.log('页面可见');
       // 调用后端API记录
       fetch('/api/log-screen-switch', {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json'
         },
         body: JSON.stringify({ event: 'screen_visible', timestamp: new Date() })
       });
     }
   });

上述代码通过监听visibilitychange事件捕获页面的可见性变化,并将记录发送到服务器。

禁用特定热键

某些热键组合如F11(全屏)和Alt+Tab(切换应用程序)会引发屏幕切换行为。可以通过监听键盘事件禁用这些热键。

   document.addEventListener('keydown', function(event) {
     if (event.key === 'F11' || (event.altKey && event.key === 'Tab')) {
       event.preventDefault();
       alert('切换屏幕操作被禁用');
     }
   });

此段代码监听keydown事件,如果检测到特定热键组合,就阻止其默认行为并提示用户操作被禁用。

后端部分

Spring Boot REST API

创建一个Controller来处理前端发送的屏幕切换事件。通过定义REST接口,我们可以轻松接收并处理这些请求。

   @RestController
   @RequestMapping("/api")
   public class ScreenSwitchController {

       private static final Logger logger = LoggerFactory.getLogger(ScreenSwitchController.class);

       @PostMapping("/log-screen-switch")
       public ResponseEntity<Void> logScreenSwitch(@RequestBody ScreenSwitchEvent event) {
           // 记录屏幕切换事件日志
           logger.info("Screen switch event: {} at {}", event.getEvent(), event.getTimestamp());
           return ResponseEntity.ok().build();
       }

       // 屏幕切换事件类
       public static class ScreenSwitchEvent {
           private String event;
           private LocalDateTime timestamp;

           // getters and setters
           public String getEvent() {
               return event;
           }

           public void setEvent(String event) {
               this.event = event;
           }

           public LocalDateTime getTimestamp() {
               return timestamp;
           }

           public void setTimestamp(LocalDateTime timestamp) {
               this.timestamp = timestamp;
           }
       }
   }

此代码片段定义了一个RESTful API来接收屏幕切换事件。事件包含两个字段:event(事件类型,例如screen_hidden)和timestamp(事件发生时间)。

日志记录

采用日志记录(Logging)技术来记录每个切换事件。Spring Boot默认采用SLF4J和Logback组件,通过配置文件可以灵活定制日志记录的格式和存储位置。

   logging:
     level:
       org.example: INFO
     file:
       name: logs/screen_switch.log

这个配置文件指定了日志的记录级别以及输出文件路径。

示例代码集成

综合以上实现,我们可以获得一个完整的前后端集成示例:

前端HTML及脚本

首先确保前端页面能够监听屏幕切换事件,并在切换时向后端发送请求:

<!DOCTYPE html>
<html>
<head>
  <title>考试系统</title>
  <script>
    document.addEventListener('DOMContentLoaded', function() {
      // 监听页面可见性变化
      document.addEventListener('visibilitychange', function() {
        const eventType = document.hidden ? 'screen_hidden' : 'screen_visible';
        logScreenSwitchEvent(eventType);
      });

      // 禁用特定热键
      document.addEventListener('keydown', function(event) {
        if (event.key === 'F11' || (event.altKey && event.key === 'Tab')) {
          event.preventDefault();
          alert('切换屏幕操作被禁用');
        }
      });
    });

    /**
     * 向后端发送屏幕切换事件
     * @param {string} eventType - 事件类型
     */
    function logScreenSwitchEvent(eventType) {
      fetch('/api/log-screen-switch', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ event: eventType, timestamp: new Date().toISOString() })
      }).catch(console.error);
    }
  </script>
</head>
<body>
  <h1>考试进行中</h1>
</body>
</html>

Spring Boot Controller

在Spring Boot中,创建一个Controller来处理前端发送的屏幕切换事件。引入更完善的日志管理和错误处理机制。

依赖配置

首先,在Spring Boot项目中确保引入必要的依赖:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-logging</artifactId>
  </dependency>
</dependencies>

Controller实现

优化后的Spring Boot Controller,包含详细的注释及完善的日志管理。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;

@RestController
@RequestMapping("/api")
public class ScreenSwitchController {

    private static final Logger logger = LoggerFactory.getLogger(ScreenSwitchController.class);

    /**
     * 处理前端发送的屏幕切换事件
     * @param event 屏幕切换事件对象
     * @return ResponseEntity<Void>
     */
    @PostMapping("/log-screen-switch")
    public ResponseEntity<Void> logScreenSwitch(@RequestBody ScreenSwitchEvent event) {
        // 记录屏幕切换事件日志
        logger.info("Screen switch event: {} at {}", event.getEvent(), event.getTimestamp());
        return ResponseEntity.ok().build();
    }

    // 屏幕切换事件类
    public static class ScreenSwitchEvent {
        private String event;
        private String timestamp;

        public String getEvent() {
            return event;
        }

        public void setEvent(String event) {
            this.event = event;
        }

        public String getTimestamp() {
            return timestamp;
        }

        public void setTimestamp(String timestamp) {
            this.timestamp = timestamp;
        }
    }
}

配置日志

Spring Boot可以通过application.yml文件进行日志配置,以确保日志输出到指定位置:

logging:
  level:
    root: INFO
    com.example: DEBUG
  file:
    name: logs/screen_switch.log
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

通过上述配置,可以更灵活地管理日志的输出格式和存储路径。

兼容性和用户体验

兼容性测试

在不同操作系统和浏览器上进行测试,确保页面可见性API和禁用热键功能在各种环境下正常工作。

function isFeatureSupported() {
  return typeof document.hidden !== "undefined";
}

if (!isFeatureSupported()) {
  alert('您的浏览器不支持页面可见性检测,请更换浏览器。');
}

用户体验

尽量减少干扰考生的正常操作,同时确保监控和防护措施有效。例如,在禁用热键时提供明确的提示信息,让考生明白其行为受限的原因。

document.addEventListener('keydown', function(event) {
  if (event.key === 'F11' || (event.altKey && event.key === 'Tab')) {
    event.preventDefault();
    alert('切换屏幕操作被禁用,防止作弊行为。');
  }
});

通过精细化的前后端实现和优化,我们可以确保考试系统能够有效检测并防止屏幕切换行为,从而保障考试的公平性和安全性。

注意事项

为了确保屏幕切换检测与防护方案在实际应用中稳定有效,以下几个注意事项需要被详细考量和处理:

1. 兼容性测试

浏览器兼容性:页面可见性API (Page Visibility API) 并不是所有浏览器都完全支持。为了确保系统能在各种环境下正常运行,需要进行广泛的兼容性测试。

具体操作

  • 全面兼容性测试:测试需覆盖不同的浏览器(如Chrome、Firefox、Safari、Edge、Opera)及其不同版本。此外,还需涵盖各种操作系统(如Windows、macOS、Linux)的桌面环境。
  • 回退机制:对于不支持页面可见性API的浏览器,实现适当的回退机制。例如,提示用户浏览器不兼容并建议使用支持API的浏览器。
// 示例代码:检测浏览器是否支持页面可见性API
function isVisibilityApiSupported() {
  return typeof document.hidden !== "undefined";
}

if (!isVisibilityApiSupported()) {
  alert('您的浏览器不支持页面可见性检测,请更换浏览器。');
}

2. 性能优化

前端性能:过多的事件监听或不合理的网络请求频率会影响前端性能,尤其是在高并发场景下。

具体操作

  • 限制请求频率:采用节流或防抖策略限制日志记录的频率,以防止网络请求过于频繁。
// 示例代码:使用防抖函数限制请求频率
let debounceTimer;
function logScreenSwitchEvent(eventType) {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    fetch('/api/log-screen-switch', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ event: eventType, timestamp: new Date().toISOString() })
    }).catch(console.error);
  }, 300); // 设置防抖时间为300毫秒
}
  • 异步处理:确保所有的网络请求都采用异步方式,防止阻塞主线程。

后端性能:高并发环境下,日志记录操作可能成为系统瓶颈。因此需要确保日志记录机制高效且可伸缩。

具体操作

  • 异步日志记录:采用异步日志记录框架,如Logback的异步Appender,提升日志记录效率。
<!-- 示例代码:Logback配置异步Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE"/>
</appender>
  • 分布式日志收集:在大型分布式系统中,可考虑使用分布式日志收集系统(如ELK Stack),以便集中管理和分析日志数据。

3. 用户体验

操作提示:在禁用特定热键时,应当给予用户明确友好的提示,避免用户因误操作导致不良体验。

具体操作

  • 提示信息:在阻止特定热键时,弹出友好的提示框告知用户操作被禁用及原因。
document.addEventListener('keydown', function(event) {
  if (event.key === 'F11' || (event.altKey && event.key === 'Tab')) {
    event.preventDefault();
    alert('切换屏幕操作被禁用,防止作弊行为。');
  }
});
  • UI反馈:在考试系统界面明显位置显示当前检测状态或系统通知。

4. 安全性

数据传输安全性:确保前端与后端的通信安全,防止数据在传输过程中被篡改或窃取。

具体操作

  • HTTPS:使用HTTPS协议加密通信,保证数据安全传输。
  • CSRF 防护:后端应对跨站请求伪造 (CSRF) 进行防护,例如使用Spring Security提供的CSRF防御机制。
// 示例代码:启用Spring Security的CSRF防护
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            )
            .authorizeHttpRequests(authz -> authz
                .anyRequest().authenticated()
            )
            .formLogin(withDefaults());
        
        return http.build();
    }

}

日志信息安全:防止日志信息泄露或滥用。

具体操作

  • 敏感信息脱敏:日志中若包含敏感信息,需要进行脱敏处理。
  • 日志访问控制:仅授权用户能够访问日志信息,防止信息泄露。

5. 可靠性

日志持久化:确保日志数据持久化到可靠的存储系统中,防止日志数据丢失。

具体操作

  • 持久化策略:选择合适的日志持久化方案(如文件系统、数据库、云存储等),根据系统需求和规模确定合适的策略。
logging:
  level:
    root: INFO
    com.example: DEBUG
  file:
    name: logs/screen_switch.log

通过深入考虑和处理以上注意事项,可以确保屏幕切换检测与防护系统在真实环境中的稳定性、可靠性和用户体验。同时,也能有效地提升系统的安全性和性能,从而更好地保障考试的公平性和维护考生的正当权益。

总结

通过前端和后台的协同工作,可以有效地检测并记录考生在考试过程中屏幕切换的行为,从而减少作弊风险。前端通过监听visibilitychange事件和禁用特定热键,结合Spring Boot后台日志记录,实现了一个完整的屏幕切换检测与防护方案。希望本文提供的技术实现和代码示例能帮助开发者轻松应对考试系统中的这一挑战。