技術選型
Springboot + WebSocket + Mybatis + Enjoy(類似Jsper、freemarker的模板引擎) + FastJson+ SpringBoot 默認的連接池 Hikari
由於懶的寫樣式,並且不想用JQuery,直接用 Vue 加上 ElementUI 用作頁面展示。
代碼部分
先上代碼
·EvaluationServer ·類,作爲服務端類存儲Session信息
@ServerEndpoint("/im/{winNum}")
@Component
@Slf4j
public class EvaluationServer {
/**
* 靜態變量,用來記錄當前在線連接數。應該把它設計成線程安全的。
* @date 2019/7/3 9:25
*/
private static int onlineCount = 0;
/**
* 與某個客戶端的連接會話,需要通過它來給客戶端發送數據
* @date 2019/7/3 9:26
*/
private Session session;
/**
* 使用map對象,便於根據winNum來獲取對應的WebSocket
* @date 2019/7/3 9:26
*/
private static ConcurrentHashMap<String,EvaluationServer> websocketList = new ConcurrentHashMap<>();
/**
* 接收winNum
* @date 2019/7/3 9:27
*/
private String winNum="";
/**
* 連接建立成功調用的方法*/
@OnOpen
public void onOpen(Session session,@PathParam("winNum") String fromWinNum) throws IOException {
this.session = session;
if(StringUtils.isEmpty(fromWinNum)){
log.error("請輸入窗口號!!!!!!!!!!!!!!!!");
return;
}else{
try {
if(websocketList.get(fromWinNum) == null){
this.winNum = fromWinNum;
websocketList.put(fromWinNum,this);
addOnlineCount(); //在線數加1
log.info("有新窗口開始監聽:{},當前窗口數爲{}",fromWinNum,getOnlineCount());
}else{
session.getBasicRemote().sendText("已有相同窗口,請重新輸入不同窗口號");
CloseReason closeReason = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE,"相同窗口");
session.close(closeReason);
}
}catch (IOException e){
e.printStackTrace();
}
}
if(session.isOpen()){
String jo = JSON.toJSONString(ApiReturnUtil.success());
session.getBasicRemote().sendText(jo);
}
}
/**
* 連接關閉調用的方法
*/
@OnClose
public void onClose() {
if(websocketList.get(this.winNum)!=null){
websocketList.remove(this.winNum);
subOnlineCount(); //在線數減1
log.info("有一連接關閉!當前在線窗口爲:{}",getOnlineCount());
}
}
/**
* 收到客戶端消息後調用的方法
*
* @param message 客戶端發送過來的消息*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("收到來自窗口{}的信息:{},會話ID:",winNum,message,session.getId());
if(StringUtils.isNotBlank(message)){
//解析發送的報文
Map<String,Object> map = JSON.parseObject(message, Map.class);
}
}
@OnError
public void onError(Session session, Throwable error) {
log.error("發生錯誤");
error.printStackTrace();
}
/**
* 服務器指定推送至某個客戶端
* @param message
* @author 楊逸林
* @date 2019/7/3 10:02
* @return void
*/
private void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
/**
* 發送給指定 瀏覽器
* @ param message
* @param winNum
* @return void
*/
public static void sendInfo(String message,@PathParam("winNum") String winNum) throws IOException {
if(websocketList.get(winNum) == null){
log.error("沒有窗口號!!!!!!!!!");
return;
}
websocketList.forEach((k,v)->{
try {
//這裏可以設定只推送給這個winNum的,爲null則全部推送
if(winNum==null) {
v.sendMessage(message);
}else if(k.equals(winNum)){
log.info("推送消息到窗口:{},推送內容: {}",winNum,message);
v.sendMessage(message);
}
} catch (IOException e) {
e.printStackTrace();
log.info("找不到指定的 WebSocket 客戶端:{}",winNum);
}
});
}
private synchronized int getOnlineCount() {
return onlineCount;
}
private synchronized void addOnlineCount() {
onlineCount++;
}
private synchronized void subOnlineCount() {
onlineCount--;
}
public static synchronized ConcurrentHashMap<String,EvaluationServer> getWebSocketList(){
return websocketList;
}
}
IndexController
用於重定向至頁面
@Controller
public class IndexController {
@RequestMapping("/d")
public ModelAndView index(String u){
ModelAndView modelAndView = new ModelAndView();
if(StringUtils.isBlank(u)){
modelAndView.setViewName("error");
return modelAndView;
}
modelAndView.addObject("winNum",u);
modelAndView.setViewName("index");
return modelAndView;
}
}
GlobalConfig
Springboot 配置類
@Configuration
public class GlobalConfig {
@Value("${server.port}")
private String port;
/**
* 添加Enjoy模版引擎
* @date 2019-07-10 8:43
* @return com.jfinal.template.ext.spring.JFinalViewResolver
*/
@Bean(name = "jfinalViewResolver")
public JFinalViewResolver getJFinalViewResolver() throws UnknownHostException {
//獲取本地ip,和端口,並將信息拼接設置成context
String ip = InetAddress.getLocalHost().getHostAddress();
String localIp = ip+":"+port;
JFinalViewResolver jfr = new JFinalViewResolver();
// setDevMode 配置放在最前面
jfr.setDevMode(true);
// 使用 ClassPathSourceFactory 從 class path 與 jar 包中加載模板文件
jfr.setSourceFactory(new ClassPathSourceFactory());
// 在使用 ClassPathSourceFactory 時要使用 setBaseTemplatePath
JFinalViewResolver.engine.setBaseTemplatePath("/templates/");
JFinalViewResolver.engine.addSharedObject("context",localIp);
jfr.setSuffix(".html");
jfr.setContentType("text/html;charset=UTF-8");
jfr.setOrder(0);
return jfr;
}
/**
* 添加 WebSocket 支持
* @date 2019/7/3 9:20
* @return org.springframework.web.socket.server.standard.ServerEndpointExporter
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
/**
* 添加 FastJson 支持
* @date 2019/7/3 11:16
* @return org.springframework.boot.autoconfigure.http.HttpMessageConverters
*/
@Bean
public HttpMessageConverters fastJsonHttpMessageConverters(){
//1. 需要定義一個converter轉換消息的對象
FastJsonHttpMessageConverter fasHttpMessageConverter = new FastJsonHttpMessageConverter();
//2. 添加fastjson的配置信息,比如:是否需要格式化返回的json的數據
FastJsonConfig fastJsonConfig = new FastJsonConfig();
fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);
//3. 在converter中添加配置信息
fasHttpMessageConverter.setFastJsonConfig(fastJsonConfig);
HttpMessageConverter<?> converter = fasHttpMessageConverter;
return new HttpMessageConverters(converter);
}
}
CallEvaluationController
調用的接口類
/**
* 用於 API 調用
* 調用評價器的 api 接口
* @version 1.0
* @date 2019/7/3 9:34
**/
@RestController
@RequestMapping("/api")
@Slf4j
public class CallEvaluationController {
@Autowired
private UserService userService;
/**
* 開始評價接口
* @param winNum
* @param userId
* @return cn.luckyray.evaluation.entity.ApiReturnObject
*/
@RequestMapping("/startEvaluate")
public String startEvaluate(String winNum){
// 驗證窗口是否爲空
ConcurrentHashMap<String, EvaluationServer> map = EvaluationServer.getWebSocketList();
if(map.get(winNum) == null){ return "窗口不存在"}
String message = "message";
try {
EvaluationServer.sendInfo(message,winNum);
} catch (IOException e) {
e.printStackTrace();
log.error("{}窗口不存在,或者客戶端已斷開",winNum);
return "窗口不存在或者已經斷開連接";
}
return "success";
}
}
Maven
配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.luckyray</groupId>
<artifactId>evaluation</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>evaluation</name>
<description>評價功能模塊</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- 添加阿里 FastJson 依賴 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.41</version>
</dependency>
<!-- enjoy模板引擎 begin -->
<dependency>
<groupId>com.jfinal</groupId>
<artifactId>enjoy</artifactId>
<version>3.3</version>
</dependency>
<!-- enjoy模板引擎 end -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- spring-boot-devtools熱啓動依賴包 start-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<!-- spring-boot-devtools熱啓動依賴包 end-->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>cn.luckyray.evaluation.EvaluationApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
index.html頁面,這裏採用了可重連的WebSocket,防止客戶端中途斷網導致需要刷新頁面才能重新連接。(這裏的#()裏面的內容爲Enjoy模板引擎渲染內容)
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title>評價頁面</title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<!-- element-ui.css -->
<link rel="stylesheet" href="../css/index.css">
</head>
<body>
<div id="app">
<el-row>
<el-button v-on:click="click(1)" type="success" style="font-size:50px;font-family:微軟雅黑;height: 570px;width: 410px" disabled>滿意</el-button>
<el-button v-on:click="click(2)" type="primary" style="font-size:50px;font-family:微軟雅黑;height: 570px;width: 410px" disabled>一般</el-button>
<el-button v-on:click="click(3)" type="danger" style="font-size:50px;font-family:微軟雅黑;height: 570px;width: 410px" disabled>不滿意</el-button>
</el-row>
</div>
</body>
<script src="../js/reconnecting-websocket.min.js"></script>
<script src="../js/vue.js"></script>
<!-- element-ui.js -->
<script src="../js/index.js"></script>
<script>
var socket;
if (typeof(WebSocket) == "undefined") {
console.log("您的瀏覽器不支持WebSocket");
} else {
//實現化WebSocket對象,指定要連接的服務器地址與端口 建立連接
let socketUrl = "ws://#(context)/im/#(winNum)";
socket = new ReconnectingWebSocket(socketUrl, null, {
debug: false,
reconnectInterval: 3000
});
console.log("創建websocket");
//打開事件
socket.onopen = function() {
console.log("websocket客戶端已打開");
};
//獲得消息事件
socket.onmessage = function(msg) {
if(msg.data != undefined && msg.data.indexOf("已有相同窗口") != -1){
alert("已有相同窗口,請重新輸入正確窗口號");
socket.close();
window.history.back(-1);
return;
}
try{
let data = JSON.parse(msg.data);
console.log(data);
if (data.code == "0" && data.data != undefined && data.data.active == "startEvaluate") {
userId = data.data.userId;
serialNum = data.data.serialNum;
speak();
app.allowClick();
setTimeout(app.allDisabled,10000);
}
}catch (e) {
console.log(e);
}
//發現消息進入開始處理前端觸發邏輯
};
//關閉事件
socket.onclose = function() {
//console.log("websocket已關閉,正在嘗試重新連接");
};
//發生了錯誤事件
socket.onerror = function() {
//console.log("websocket已關閉,正在嘗試重新連接");
}
//監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
window.onbeforeunload = function() {
socket.close();
}
}
//fullScreen()和exitScreen()有多種實現方式,此處只使用了其中一種
//全屏
function fullScreen() {
var docElm = document.documentElement;
docElm.webkitRequestFullScreen( Element.ALLOW_KEYBOARD_INPUT );
}
var app = new Vue({
el: '#app',
data: function() {
},
methods: {
click: function(evaluation) {
console.log(evaluation);
let data = {
evaluation : evaluation,
}
let jsonData = JSON.stringify(data);
console.log(jsonData);
socket.send(jsonData);
let childrens = app.$children[0].$children;
for (let children of childrens) {
children.disabled = true;
}
},
allowClick: function() {
let childrens = app.$children[0].$children;
for (let children of childrens) {
children.disabled = false;
}
},
allDisabled:function () {
let childrens = app.$children[0].$children;
for (let children of childrens) {
children.disabled = true;
}
}
},
});
</script>
</html>
最主要的東西就是這些,尤其是index.html上的內容。《Netty實戰》中只說瞭如何建立服務端,並沒有說明客戶端如何建立。
下面代碼纔是重點,WebSocket 採用 ws 協議,其實是第一次發送 http 請求,在 http 請求頭部中 爲Connection:Upgrade
,Upgrade:websocket
通知服務器將 http 請求升級爲 ws/wss 協議。下面的也可以改成 socket = new WebSocket(url,protocols)。其中 url 必填,protocols 可選參數,參數爲 string | string[] ,其中 string 爲可使用的協議,包括 SMPP,SOAP 或者自定義的協議。
有關 ws 與 wss 其實是與 http 與 https 關係類似,只是在TCP協議內,ws 協議外套了一層 TLS 協議,進行了加密處理。
let socketUrl = "ws://#(context)/im/#(winNum)";
socket = new ReconnectingWebSocket(socketUrl, null, {
debug: false,
reconnectInterval: 3000
});
WebSocket的四個事件、兩個方法、兩個屬性
四個事件
open,message,error,close
下面爲對應的 ts 文件
可以看到有四個方法需要我們實現,對應着四個事件。下面詳細介紹
onclose
onerror
onmessage
onopen
interface WebSocket extends EventTarget {
binaryType: BinaryType;
readonly bufferedAmount: number;
readonly extensions: string;
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null;
onerror: ((this: WebSocket, ev: Event) => any) | null;
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null;
onopen: ((this: WebSocket, ev: Event) => any) | null;
readonly protocol: string;
readonly readyState: number;
readonly url: string;
close(code?: number, reason?: string): void;
send(data: string | ArrayBuffer | Blob | ArrayBufferView): void;
readonly CLOSED: number;
readonly CLOSING: number;
readonly CONNECTING: number;
readonly OPEN: number;
addEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
open
一旦服務器響應了 WebSocket 連接請求,open 事件觸發並建立一個連接。open 事件對應的回調函數稱作 onopen
message
message 事件在接收到消息時觸發,對應該事件的回調函數爲 onmessage。除了文本,WebSocket還可以處理二進制數據,這種數據作爲 Blob 消息或者 ArrayBuffer 消息處理。必須在讀取數據之前決定用於客戶端二進制輸入數據的類型。其中返回的 e ,e.data 爲服務端返回的消息,其餘屬性爲 websocket 返回的附帶信息。
ws.binaryType="Blob";
ws.onmessage = function(e){
if(e.data instanceof Blob){
var blob = new Blob(e.data);
}
}
error
在響應意外故障的時候觸發,最錯誤還會導致 WebSocket 關閉,一般伴隨的是 close 事件。error 事件處理程序是調用服務器重連邏輯以及處理來自 WebSocket 對象的異常的最佳場所。
close
close 事件在WebSocket 連接關閉時觸發。一旦連接關閉,雙端皆無法通信。
兩個屬性
readyState
ws.readyState === 0;就緒
ws.readyState === 1;已連接
ws.readyState === 2;正在關閉
ws.readyState === 3;已關閉
bufferAmount
該屬性的緣由是因爲 WebSocket 向服務端傳遞信息時,是有一個緩衝隊列的,該參數可以限制客戶端向服務端發送數據的速率,從而避免網絡飽和。具體代碼如下
// 10k max buffer size.
const THRESHOLD = 10240;
// Create a New WebSocket connection
let ws = new WebSocket("ws://w3mentor.com");
// Listen for the opening event
ws.onopen = function () {
// Attempt to send update every second.
setInterval( function() {
// Send only if the buffer is not full
if (ws.bufferedAmount < THRESHOLD) {
ws.send(getApplicationState());
}
}, 1000);
};
兩個方法
send
必須要在 open 事件觸發之後纔可以發送消息。除了文本消息之外,還允許發送二進制數據。代碼如下。
文本
let data = "data";
if(ws.readyState == WebSocket.OPEN){
ws.send(data);
}
二進制數據
let blob = new Blob("blob");
ws.send(blob);
let a = new Unit8Array([1,2,3,4,5,6]);
ws.send(a.buffer);
close
關閉連接用,可以加兩個參數 close(code,reason)
,與客戶端對應,code爲狀態碼,1000 這種,reason 爲字符串“關閉連接原因”