在日常的开发过程中我们经常遇到的一个场景就是消息提醒。传统的做法是通过ajax轮训访问后台,如果发现了新的消息则将数据返回给前端。这样的做法相对来说开发成本很低,逻辑也很简单。在实时性要求不高的前提下,使用ajax轮训的方式是一个不错的选择。我们可以将轮训的时间间隔设置得相对较长,比如10分钟甚至是30分钟向后台发起一个请求。但是如果你的业务场景是一个高并发且实时性要求高的场景。在这种情况下再使用ajax轮训的方式就很不合适了。首先如果你需要保持实时性,就不得不缩小轮训的时间间隔。轮训的时间间隔一旦缩小,单位时间内你发送的请求数量就很高,这样你的后台压力就会很大。产生的后果就是轻则响应时间长,用户体验不佳;重则服务器宕机。在这样的背景下,WebSocket就是一个不错的备选方案了。
首先,什么是WebSocket? 以下是我在百度百科上截取的一段有关WebSocket的解释
“WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。”
简单概括一下就是:在客户端(浏览器)和服务端(后台Tomcat)之间只建立一次连接(一次握手),然后这个连接是长时间生效的。当后台有数据的时候就会给前端浏览器发送数据。
新建一个maven web工程,引入如下需要使用到的jar包。最重要的是引入了spring-websocket的jar包支持。其他的就是基础的SSM jar包依赖了(我这里用的mybatis - plus)。
UTF-8
1.8
1.8
5.1.5.RELEASE
junit
junit
4.12
test
mysql
mysql-connector-java
5.1.45
org.springframework
spring-core
${spring-version}
org.springframework
spring-context
${spring-version}
org.springframework
spring-context-support
${spring-version}
org.springframework
spring-webmvc
${spring-version}
org.springframework
spring-aop
${spring-version}
org.springframework
spring-aspects
${spring-version}
org.springframework
spring-jdbc
${spring-version}
org.springframework
spring-test
${spring-version}
org.freemarker
freemarker
2.3.28
com.alibaba
druid
1.1.14
com.fasterxml.jackson.core
jackson-core
2.9.8
com.fasterxml.jackson.core
jackson-annotations
2.9.8
com.fasterxml.jackson.core
jackson-databind
2.9.8
org.slf4j
slf4j-api
1.7.25
ch.qos.logback
logback-classic
1.2.3
ch.qos.logback
logback-core
1.2.3
com.baomidou
mybatis-plus
3.3.0
org.springframework
spring-test
${spring-version}
org.springframework
spring-websocket
${spring-version}
org.springframework
spring-messaging
${spring-version}
com.alibaba
fastjson
1.2.47
org.apache.commons
commons-lang3
3.9
org.apache.commons
commons-collections4
4.4
org.projectlombok
lombok
1.18.10
commons-fileupload
commons-fileupload
1.3.1
com.rabbitmq
amqp-client
5.3.0
org.springframework.amqp
spring-rabbit
2.0.5.RELEASE
一、使用mysql 或者redis(我这里使用的是MySQL)保存推送消息。然后使用spring的定时任务,比如每隔5秒钟扫一次表,如果有尚未发送的数据就将消息数据提取出来。判断消息需要被推送的对象是否已上线,如果确认用户已登录上线后,则使用websocket向用户发送推送消息。
创建Web项目启动器。制定spring以及springmvc 配置类。在springmvc配置类中开启对websocket的支持,在spring配置类中开启定时任务功能。
@Slf4j
public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class>[] getRootConfigClasses() {
return new Class[]{AppConfig.class};
}
@Override
protected Class>[] getServletConfigClasses() {
return new Class[]{WebMvcConfig.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
}
@Configuration
@ComponentScan(basePackages = {"com.itsu.service","com.itsu.dao","com.itsu.compoment"})
@EnableTransactionManagement
@MapperScan(basePackages = "com.itsu.dao")
@EnableScheduling
public class AppConfig {
@Bean
public DruidDataSource dataSource() {
DruidDataSource druidDataSource = new DruidDataSource();
druidDataSource.setUrl("jdbc:mysql://localhost:3306/local_db?useSSL=false&useUnicode=true&characterEncoding=UTF8");
druidDataSource.setUsername("root");
druidDataSource.setPassword("123456");
druidDataSource.setDriverClassName("com.mysql.jdbc.Driver");
return druidDataSource;
}
@Bean
public MybatisSqlSessionFactoryBean sessionFactoryBean(){
MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource());
// Resource mapperResource = new ClassPathResource("classpath:mappers/MessageMapper.xml");
// sqlSessionFactoryBean.setMapperLocations(mapperResource);
return sqlSessionFactoryBean;
}
@Bean
public DataSourceTransactionManager transactionManager() {
DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
transactionManager.setDataSource(dataSource());
return transactionManager;
}
}
@Configuration
@ComponentScan(basePackages = "com.itsu.controller")
@EnableWebMvc
@EnableAspectJAutoProxy
@EnableWebSocket
public class WebMvcConfig implements WebMvcConfigurer, WebSocketConfigurer {
@Bean
public FreeMarkerViewResolver freeMarkerViewResolver() {
FreeMarkerViewResolver markerViewResolver =
new FreeMarkerViewResolver();
markerViewResolver.setSuffix(".html");
markerViewResolver.setOrder(1);
markerViewResolver.setCache(false);
markerViewResolver.setViewClass(FreeMarkerView.class);
markerViewResolver.setContentType("text/html;charset=utf-8");
return markerViewResolver;
}
@Bean
public FreeMarkerConfigurer freeMarkerConfigurer() {
FreeMarkerConfigurer freeMarkerConfigurer =
new FreeMarkerConfigurer();
freeMarkerConfigurer.setTemplateLoaderPath("/WEB-INF/views/");
Properties settingProperties = new Properties();
// 刷新模板的周期,单位为秒
settingProperties.setProperty("template_update_delay", "5");
settingProperties.setProperty("default_encoding", "utf-8");
settingProperties.setProperty("datetime_format", "yyyy-MM-dd HH:mm:ss");
settingProperties.setProperty("time_format", "HH:mm:ss");
settingProperties.setProperty("url_escaping_charset", "utf-8");
freeMarkerConfigurer.setFreemarkerSettings(settingProperties);
return freeMarkerConfigurer;
}
//文件上传,bean必须写name属性且必须为multipartResolver,不然取不到文件对象
@Bean(name = "multipartResolver")
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver multipartResolver =
new CommonsMultipartResolver();
multipartResolver.setMaxUploadSize(2097152); //2M
//单个文件大小限制
multipartResolver.setMaxUploadSizePerFile(0);
multipartResolver.setDefaultEncoding("UTF-8");
return multipartResolver;
}
//静态资源的处理
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/index").setViewName("index");
registry.addViewController("/").setViewName("login");
registry.addViewController("/login").setViewName("login");
registry.addViewController("/submsg").setViewName("submsg");
}
@Override
public void configureMessageConverters(List> converters) {
MappingJackson2HttpMessageConverter msgConverter = new MappingJackson2HttpMessageConverter();
msgConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON_UTF8, MediaType.TEXT_HTML, MediaType.APPLICATION_FORM_URLENCODED));
converters.add(msgConverter);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("/");
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler(), "/websocket").addInterceptors(new HttpSessionHandshakeInterceptor()).setAllowedOrigins("*");
}
@Bean
public WebSocketHandler webSocketHandler() {
return new MyWebSocketHandler();
}
}
新建一张表tb_msg,以及这张表对应的java bean Message。其中最重要的字段为stat,表示这一条推送消息是否被推送成功。后续也需要用的这个状态来抓取数据。
@Data
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor
@TableName("tb_msg")
public class Message implements Serializable {
private static final long serialVersionUID = -9137338846595226658L;
@TableId(type = IdType.AUTO)
private int id;
@TableField(value = "from_user_id")
private String fromUserId;
@TableField(value = "to_user_id")
private String toUserId;
@TableField(value = "content")
private String content;
@TableField(value = "stat")
private String stat;
@TableField(value = "create_time")
@JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss")
private Date createTime;
}
定义websockethandler 通过继承TextWebSocketHandler的方式完成。因为这里只做简单示例,仅做了对普通文本内容消息通知。不支持图片、表情消息的推送处理。如果需要集成这些功能,需要继承AbstractWebSocketHandler 并重写handleTextMessage、handleBinaryMessage、handlePongMessage 三个方法。
/**
* @author 苏犇
* @create time 2019/12/4 21:15
*/
@Slf4j
public class MyWebSocketHandler extends TextWebSocketHandler {
private static final ConcurrentMap USERS;
private static final String WEBSOCKT_USER = "userId";
static {
USERS = new ConcurrentHashMap<>();
}
public static Set getLoginUsers() {
return USERS.keySet();
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("新的客服端连接 。。。 session id:{}", session.getId());
String userId = (String) session.getAttributes().get(WEBSOCKT_USER);
USERS.putIfAbsent(userId, session);
log.info("当前在线人数为:{}", USERS.size());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String msgStr = message.getPayload();
JSONObject json = JSON.parseObject(msgStr);
Message msg = json.toJavaObject(Message.class);
String userId = msg.getToUserId();
if (StringUtils.isNoneEmpty(userId)) {
this.sendMessageToUser(userId, message);
} else
this.sendMessageToUsers(message);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("发生异常", exception);
if (session.isOpen()) {
session.close();
}
WebSocketSession execSession = USERS.remove(session.getId());
log.error("异常产生的session id:{}", execSession.getId());
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("有socket连接关闭,session id:{}", session.getId());
log.info("reason: {}", status.getReason());
String userId = (String) session.getAttributes().get(WEBSOCKT_USER);
USERS.remove(userId);
log.info("剩余在线人数为:{}", USERS.size());
}
public static synchronized void sendMessageToUser(String userId, TextMessage message) {
USERS.forEach((key, value) -> {
if (key.equals(userId)) {
try {
value.sendMessage(message);
} catch (IOException e) {
log.error("exception happened on send socket message");
}
}
});
}
public static synchronized void sendMessageToUsers(TextMessage message) {
USERS.forEach((key, value) -> {
try {
value.sendMessage(message);
} catch (IOException e) {
log.error("exception happened on send socket message");
}
});
}
}
编写一个controller控制器实现简单登录逻辑。用户名和密码不为空则登录成功。
@Controller
public class MyController {
@Resource
private MessageService messageService;
@PostMapping("/login.do")
public String login(String userName, String password, HttpServletRequest request, Model model) {
if (StringUtils.isNotBlank(userName) && StringUtils.isNotBlank(password)) {
request.getSession().setAttribute("userId", userName);
return "redirect:/toIndex";
} else {
model.addAttribute("errorMsg", "login fail");
return "login";
}
}
@GetMapping("/toIndex")
public ModelAndView toIndex() {
return new ModelAndView("index");
}
@PostMapping(value = "/submsg.do", produces = "application/json")
@ResponseBody
public Map subMsg(@RequestBody Message message) {
Map map = new HashMap();
try {
message.setCreateTime(new Date());
message.setStat("N");
messageService.saveOne(message);
map.put("result", true);
} catch (Exception e) {
map.put("result", false);
map.put("msg", e.getMessage());
}
return map;
}
}
业务层代码
@Service
public class MessageService {
@Resource
private MessageDAO messageDAO;
@Transactional(rollbackFor = Throwable.class)
public void saveOne(Message message) {
messageDAO.insert(message);
}
}
编写一个定时器任务,时间每间隔5秒钟查询一次数据库。当发现存在未推送的消息时,检查被推送消息的用户是否已登录。如果已登录则通过WebSocketHandler发送消息到前端。
@Component
@Slf4j
public class MessagePushTask {
@Resource
private MessageDAO messageDAO;
@Scheduled(cron = "0/5 * * * * ? ")
public void sendMessage() {
System.err.println("task start ... ");
QueryWrapper condition = new QueryWrapper<>();
condition.eq("stat", "N");
List messages = messageDAO.selectList(condition);
Set loginUsers = MyWebSocketHandler.getLoginUsers();
for (Message message : messages) {
if (loginUsers.contains(message.getToUserId())) {
TextMessage msg = new TextMessage(JSON.toJSONString(message));
MyWebSocketHandler.sendMessageToUser(message.getToUserId(), msg);
log.info("message send ... : {}", JSON.toJSONStringWithDateFormat(message, "yyyy-MM-dd hh:mm:ss"));
message.setStat("Y");
messageDAO.update(message, null);
}
}
}
}
前端页面的编写:
Title
当前选择的好友是:
启动服务器查看效果,可以看到,后台已经开始每隔5秒钟查询一次db了。
浏览器访问http://localhost:8080/submsg,简单填写如下表单,点击提交,成功返回success。
这时我们查看数据库,发现表中已经插入刚才我们输入的数据了。 Stat = N 表示此消息尚未推送
再看看后台控制台,可以看到已经可以查询到这一条数据了。但由于此被推送的对象尚未登录,所以这条消息并没有被推送。
此时,我们使用userId=jack登录。浏览器地址栏输入http://localhost:8080/login
登录成功后,我们看到收到了来自tom的消息:消息测试。正是我们刚刚创建的那一条消息。
再看看数据库,可以看到stat字段的值已经被改成了Y,已发送状态。
二、使用RabbitMQ做为消息中间件,不再使用定时任务定期查表的方式获取数据。而改为消息消费者从消息队列Broker中提取数据。后监听被推送的用户是否已登录,如果已登录则确认签收消息,否则则拒收消息,并返回消息至队列中。
首先创建一个rabbitmq的spring配置文件。(题外话:这里使用xml而不适用java config的方式是因为我个人没用过javaconfig 配置过rabbitmq,仅仅在springboot 整合rabbitmq的时候接触过。不过这就是另一个故事了。)
定义消息confirm & return 监听,仅做简单打印输出。
@Component
@Slf4j
public class BootMsgConfirm implements RabbitTemplate.ConfirmCallback {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
log.info("correlationData:{}", correlationData);
if (!ack) {
log.warn(cause);
}
}
}
@Component
@Slf4j
public class BootMsgReturn implements RabbitTemplate.ReturnCallback {
@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
log.info("replyCode:{}; replayText:{}; exchange:{} ; routintKey:{}", replyCode, replyText, exchange, routingKey);
}
}
定义消费者消息监听器,根据被推送用户是否登录来判断是否需要签收消息或者退还消息。
@Slf4j
public class BootMsgConfirmListener implements ChannelAwareMessageListener {
@Override
public void onMessage(Message message, Channel channel) throws Exception {
log.info("收到消息:{}", JSON.toJSONString(message));
bytes = message.getBody();
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
com.itsu.compoment.socket.Message msg = (com.itsu.compoment.socket.Message) ois.readObject();
Set loginUsers = MyWebSocketHandler.getLoginUsers();
if (loginUsers.contains(msg.getToUserId())) {
TextMessage tms = new TextMessage(JSON.toJSONStringWithDateFormat(msg,"yyyy-MM-dd hh:mm:ss"));
MyWebSocketHandler.sendMessageToUser(msg.getToUserId(),tms);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
log.info("用户已上线,确认签收消息");
} else {
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
log.info("用户尚未上线,退还消息至rabbitmq");
}
}
}
在messageService中添加生产方法sandToQueue()发送数据到消息Broker ,这里可以看成是自己定义的boot-message交换机。
@Service
public class MessageService {
@Resource
private MessageDAO messageDAO;
@Resource
private RabbitTemplate rabbitTemplate;
@Transactional(rollbackFor = Throwable.class)
public void saveOne(Message message) {
messageDAO.insert(message);
}
public void sendToQueue(Message message) {
rabbitTemplate.convertAndSend("socket.msg.push", message);
}
}
启动服务器看看效果如何;
先在浏览器中访问http://localhost:15672,进入RabbitMQ Management后台界面。可以看到交换机和队列已经完成了绑定。
我们尝试着写入如下一条数据。提交成功。
再次查看后台,发现后台爆出了大量日志。这是因为目前kobe还没登录,消息被退回。之后又被发送到消费者。。。
使用kobe用户登录。
可以看到,马上就收到了刚刚创建的那一条消息。并且后台也不再打出消息退回的日志了,而是打出了确认签收的日志。
到此,已经完成了全部功能。其实这个命题并不是我一开始脑子里想到的,而是我之前在工作闲暇时想着做一个web聊天室,于是乎去看了几篇有关websocket的博文,然后就撸起袖子写了一个web聊天室。当时写到一半我突然想到了实时消息推送的功能,然后一想这玩意用websocket不是正好,于是便构思了一下,做了这么一个简单项目。