SpringBoot开发系列(7)-开发WebSocket的一点经验
1、前言
在某些项目场景中,WebSocket是个利器,但毕竟常规应用场景不多。趁现在还记得些,把一些开发过程中总结的一些经验记下来,以免过个一年半载再次需要用到时忘却了。之前已经写过一篇《WebSocket,不再轮询》,讲了一些WebSocket的概念和应用场景,而本文这次偏实战,讲解的代码会比较多一些。
代码包括WebSocket的服务端和客户端,以及如何写WebSocket的单元测试。其中还会针对一些 “坑” ,做重点分析。
2、WebSocket服务端
WebSocket服务端,即提供WebSocket服务的程序。SpringBoot开发WebSocket,常规有两种方式 - 申明式和编程式,前者最简单,我用的就是申明式。
2.1、pom.xml
<!--websocket 服务端-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2.2、注册Bean
在包含@Configuration类(启动类也包含该注解)中配置ServerEndpointExporter,配置后会自动注册所有“@ServerEndpoint”注解声明的Websocket Endpoint。
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
2.3、WebSocket服务端类
根据上文,写WebSocket的类需要通过“@ServerEndpoint”注解声明。
MyWebSocketService .java
@Component
@ServerEndpoint(value = "/xxx/{userId}")
@Slf4j
public class MyWebSocketService {
private String userId = "anonymous";
private static int onlineCount = 0;
private Session session;
private static ConcurrentHashMap<String, Session> sessionPool = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session curSession, @PathParam("userId") String curUserId) {
this.session = curSession;
this.userId = curUserId;
sessionPool.put(curUserId, curSession);
addOnlineCount();
log.info(curUserId + "有一连接加入!当前在线人数为" + onlineCount);
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
if (sessionPool.get(this.userId) != null) {
sessionPool.remove(userId);
subOnlineCount();
log.info(userId + "有一连接关闭!当前在线人数为" + getOnlineCount());
}
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message) {
handleMessage(message);
}
/**
* @param curSession
* @param error
*/
@OnError
public void onError(Session curSession, Throwable error) {
log.error(error.getMessage(), error);
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
onlineCount++;
}
public static synchronized void subOnlineCount() {
onlineCount--;
}
/**
* 只发送给单一用户
*
* @param curUserId
* @param socketMessage
*/
public void sendMessageSingle(String curUserId, SocketMessage socketMessage) {
Session curSession = sessionPool.get(curUserId);
if (curSession != null) {
try {
String response = JSON.toJSONString(socketMessage);
curSession.getBasicRemote().sendText(response);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}
/**
* 处理客户端发送的消息
* websocket 初始化早,无法注入Bean
*
* @param message
* @return
*/
private void handleMessage(String message) {
try {
SocketMessage request = JSON.parseObject(message, SocketMessage.class);
switch (ModuleEnum.valueOf(request.getModule())) {
case HEART_CHECK:
this.session.getBasicRemote().sendText(
JSON.toJSONString(new SocketMessage(ModuleEnum.HEART_CHECK.name(), "回复心跳检查")));
break;
case ACTION_MAP_SWITCH:
MapMapper mapMapper =ApplicationContextRegister.getApplicationContext().getBean(MapMapper.class);
mapMapper.updateAllBranchVisible();
sendMessageSingle(chlWeb, request);
break;
//case 等等,其他处理逻辑
default:
break;
}
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}
2.4、单例与多例的冲突
上述代码中有一步是要调用dao层的方法,handleMessage方法中。
//...
case ACTION_MAP_SWITCH:
MapMapper mapMapper =ApplicationContextRegister.getApplicationContext().getBean(MapMapper.class);
mapMapper.updateAllBranchVisible();
//...
正常我们开发SpringBoot,都是借助Spring容器的IOC的特性,将Service、Dao等直接依赖注入,类似于下面。
@Autowired
private MapMapper mapMapper;
//...
case ACTION_MAP_SWITCH:
mapMapper.updateAllBranchVisible();
//...
但是这么写会报错,在执行 mapMapper.updateAllBranchVisible(); 方法时报空指针,即MapMapper的Bean没有注入进来。所以本文是通过Spring容器上下文,用工厂类的方式创建MapMapper的Bean。
ApplicationContextRegister.java
@Component
public class ApplicationContextRegister implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext curApplicationContext) {
applicationContext = curApplicationContext;
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
}
要弄懂原因,首先要了解Spring注入Bean的方式:
有些人可能不知道,Spring默认实例化的Bean是单例模式,这就意味着在Spring容器加载时,就注入了MapMapper的实例,不管再调用多少次接口,加载的都是这个Bean同一个实例。
而WebSocket是多例模式,在项目启动时第一次初始化实例时,MapMapper的实例的确可以加载成功,但可惜这时WebSocket是无用户连接的。当有第一个用户连接时,WebSocket类会创建第二个实例,但由于Spring的Dao层是单例模式,所以这时MapMapper对应的实例为空。后续每连接一个新的用户,都会再创建新的WebSocket实例,当然MapMapper的实例都为空。
3、WebSocket客户端
一般很少有人在SpringBoot里面写WebSocket的客户端,通常都是后端提供服务,前端来作为客户端通讯。但是如果你的应用场景是后端之间的长连接交互,还是会用到的。或者,当你需要给你的服务端写单元测试时,这个后面再说。
3.1、pom.xml
<!--websocket 客户端-->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.3.8</version>
</dependency>
3.2、WebSocket客户端类
配置WebSocket客户端的方法更简单,继承并实现WebSocketClient 类。
MyWebSocketClient.java
import lombok.extern.slf4j.Slf4j;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
@Slf4j
public class MyWebSocketClient extends WebSocketClient {
public MyWebSocketClient(URI uri){
super(uri);
}
@Override
public void onOpen(ServerHandshake serverHandshake) {
log.info("客户端连接成功");
}
@Override
public void onMessage(String s) {
log.info("客户端接收到消息:"+s);
}
@Override
public void onClose(int i, String s, boolean b) {
log.info("客户端关闭成功");
}
@Override
public void onError(Exception e) {
log.error("客户端出错");
}
public static void main(String[] args) {
try {
MyWebSocketClient myWebSocketClient = new MyWebSocketClient(new URI("ws://localhost:9000/xxx/user1"));
myWebSocketClient.connect();
while (!WebSocket.READYSTATE.OPEN.equals(myWebSocketClient.getReadyState())) {
log.info("WebSocket客户端连接中,请稍等...");
Thread.sleep(500);
}
myWebSocketClient.send("{\"module\":\"HEART_CHECK\",\"message\":\"请求心跳\"}");
myWebSocketClient.close();
} catch (Exception e) {
log.error("error", e);
}
}
}
4、WebSocket单元测试
客户要求我们的SpringBoot程序发布前,要通过sonar的质量检查,其中有一项就是 “保证单元测试的覆盖率超过50%” 。普通Http接口的单元测试我们都知道,实在不会也可以百度出来。可是你很难百度出来,WebSocket接口如何做单元测试?
后来我想,单元测试嘛,无非就是监听后端服务的路由,调用一下程序的方法。那我能不能写一个测试类,通过创建WebSocket客户端的方式,模拟前端来测试服务端的逻辑?实际上我研究 “3、WebSocket客户端“ ,就是为了提高这个单元测试的覆盖率。
4.1、WebEnvironment
我们在写Junit的测试类时,通常都会如下文一样,通过@SpringBootTest获取启动类,加载SpringBoot配置。但是如果我们的项目里面有WebSocket,这样会报无法启动WebSocket的错误。
@RunWith(SpringRunner.class)
@SpringBootTest
public class CompositeControllerTest{
@Test
public void websocketClient() {
int num = new Integer(1);
Assert.assertEquals(num, 1);
}
}
@SpringBootTest注解实际有一个webEnvironment的属性,SpringBootTest.WebEnvironment有下列四种:
- MOCK(默认) : 加载一个WebApplicationContext并提供一个模拟servlet环境。嵌入式servlet容器在使用此注释时不会启动。如果servlet API不在你的类路径上,这个模式将透明地回退到创建一个常规的非web应用程序上下文。可以与@AutoConfigureMockMvc结合使用,用于基于MockMvc的应用程序测试。
- RANDOM_PORT : 加载一个EmbeddedWebApplicationContext并提供一个真正的servlet环境。嵌入式servlet容器启动并在随机端口上侦听。
- DEFINED_PORT : 加载一个EmbeddedWebApplicationContext并提供一个真正的servlet环境。嵌入式servlet容器启动并监听定义的端口(即从application.properties或默认端口8080)。
- NONE : 使用SpringApplication加载ApplicationContext,但不提供任何servlet环境(模拟或其他)。
我们在测试使用websocket时是需要完整的容器,所以可以选 RANDOM_PORT或DEFINED_PORT。
4.2、测试类
为了方便测试我们使用 SpringBootTest.WebEnvironment.DEFINED_PORT,监听固定的端口。
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@AutoConfigureMockMvc
@Slf4j
class CompositeControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private WebApplicationContext webApplicationContext;
@Before
public void before(){
mockMvc= MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
}
// ... 等等
/**
* 创建 WebSocket 的客户端做测试
* @throws Exception
*/
@Test
void websocketClient() throws Exception{
MyWebSocketClient myWebSocketClient = new MyWebSocketClient(new URI("ws://localhost:port/xxxx/user1"));
myWebSocketClient.connect();
while (!WebSocket.READYSTATE.OPEN.equals(myWebSocketClient.getReadyState())){
log.info("WebSocket客户端连接中,请稍等...");
Thread.sleep(500);
}
Map<String,String> requestMap=new HashMap<>();
requestMap.put("HEART_CHECK","{\"module\":\"HEART_CHECK\",\"message\":\"请求心跳\"}");
requestMap.put("KEY1","VALUE1");
requestMap.put("KEY2","VALUE2");
requestMap.put("KEY3","VALUE3");
for(String key: requestMap.keySet()){
myWebSocketClient.send(requestMap.get(key));
}
//测试 onError、onMessage、onClose
// ... 等等
myWebSocketClient.close();
}
}
OK,在最后生成的sonar测试报告里面,我们可以看到WebSocket的代码基本都被覆盖到,单元测试的覆盖率提高到了90%,我的任务达到了。
文献参考
1.spring boot整合Websocket笔记
https://yq.aliyun.com/articles/637898?spm=a2c4e.11153940.0.0.537f1d36hwhtLi

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
-
上一篇
03月25日云栖号头条:工信部网络安全管理局就新浪微博App数据泄露问题开展问询约谈
云栖号:https://yqh.aliyun.com第一手的上云资讯,不同行业精选的上云企业案例库,基于众多成功案例萃取而成的最佳实践,助力您上云决策! 今日最新云头条快讯: 据工信部消息,3月21日,针对媒体报道的新浪微博因用户查询接口被恶意调用导致App数据泄露问题,工业和信息化部网络安全管理局对新浪微博相关负责人进行了问询约谈;近日,联合国可持续发展目标数字金融工作组发布最新报告。报告指出,数字技术在疫情中大有可为,该组织还向全球推荐了蚂蚁金服金融科技应用“蚂蚁双链通”。 一起来看最新的资讯: 工信部网络安全管理局就新浪微博App数据泄露问题开展问询约谈 据工信部消息,3月21日,针对媒体报道的新浪微博因用户查询接口被恶意调用导致App数据泄露问题,工业和信息化部网络安全管理局对新浪微博相关负责人进行了问询约谈,要求其按照《网络安全法》《电信和互联网用户个人信息保护规定》等法律法规要求,对照工信部等四部门制定的《App违法违规收集使用个人信息行为认定方法》,进一步采取有效措施,消除数据安全隐患。新浪微博表示,公司高度重视数据安全和个人信息保护,针对此次事件已采取了升级接口安全策略...
-
下一篇
苏宁、美团相继退出云市场 小厂商该怎么活下去?
近日,苏宁云、美团云相继发布了退出云市场的公告,宣布各自持续多年的云业务正式终结。在云计算市场日益向头部聚集的今天,苏宁、美团两家可谓背景深厚的企业都感觉难以为继,黯然离场。对于其它小云计算企业来说,未来的日子恐怕更加难熬。 美团方面是近日发布了停止服务的公告,公告称美团公有云将于2020年5月31日0:00起,停止对用户的服务与支持,并建议用户,尽快进行数据备份或系统迁移,也可通过工单,提交退款申请。 苏宁云的公告信息则发布的更早一些,在1月份。称因业务调整,苏宁云商城停止销售服务,在3月初处理退款等事物,并于2020年4月30日正式停止运营,敬请用户及服务商知悉。 苏宁与美团在云计算方面,都进行了大量资金投入和战略倾斜,如今黯然退场,令人唏嘘。如果单单从两者的业务和推广看,或者有可以总结的经验教训。但这两家云服务商的退出,更多是如今云计算市场大环境的一个注脚。 百花齐放不是春 用百花齐放来形容云计算市场这些年的发展绝不为过,根据国内著名云选型服务商科智云佳的调查,目前国内大大小小的云厂商数量已经超过了100家。这其中有阿里云、腾讯云这样的巨头,也有美团、苏宁这样的垂直类云产商,但更...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- 面试大杂烩
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- MySQL8.0.19开启GTID主从同步CentOS8
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- Windows10,CentOS7,CentOS8安装MongoDB4.0.16
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- SpringBoot2初体验,简单认识spring boot2并且搭建基础工程
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果