写在最前:
常用的http协议是无状态的,且不能主动响应到客户端。最初想实现状态动态跟踪只能用轮询或者其他效率低下的方式,所以引入了websocket协议,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。简单来说就是两个或多个客户端之间不能相互交流,要想实现类似一对一聊天的功能,实质上就是A客户端发送信息到socket服务器,再由socket服务器主动推送到B客户端或者多个客户端,实现两个或多个客户端之间的信息传递。
吐槽:t-io是个很优秀的socket框架,但是文档很少,作者写的文档也不明不白的对新手很不友好(花钱除外),其他写的文档不是要钱就是写的巨烂,这技术环境真心垃圾。
一、导包(导入TIO的两个依赖,其他必要依赖不赘述)
<dependency>
<groupId>org.t-io</groupId>
<artifactId>tio-websocket-spring-boot-starter</artifactId>
<version>3.6.0.v20200315-RELEASE</version>
</dependency>
<dependency>
<groupId>org.t-io</groupId>
<artifactId>tio-core-spring-boot-starter</artifactId>
<version>3.6.0.v20200315-RELEASE</version>
</dependency>
二、yml配置
server:
port: 8652
tio:
websocket:
server:
port: 8078
heartbeat-timeout: 12000
cluster:
enabled: false
customPort: 4768 //自定义socket服务端监听端口,其实也可以用上面server.port做监听端口
三、配置参数
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.tio.utils.time.Time;
/**
* @Author 955
* @Date 2023-07-26 17:25
* @Description
*/
@Component
public class CaseServerConfig {
/**
* 协议名字(可以随便取,主要用于开发人员辨识)
*/
public static final String PROTOCOL_NAME = "xxxxxxx";
public static final String CHARSET = "utf-8";
/**
* 监听的ip
*/
public static final String SERVER_IP = null;//null表示监听所有,并不指定ip
/**
* 监听端口
*/
public static int PORT;
/**
* 心跳超时时间,单位:毫秒
*/
public static final int HEARTBEAT_TIMEOUT = 1000 * 60;
/**
* 服务器地址
*/
public static final String SERVER = "127.0.0.1";
/**
* ip数据监控统计,时间段
*
* @author tanyaowu
*/
public static interface IpStatDuration {
public static final Long DURATION_1 = Time.MINUTE_1 * 5;
public static final Long[] IPSTAT_DURATIONS = new Long[]{DURATION_1};
}
/**
* 用于群聊的group id(自定义)
*/
public static final String GROUP_ID = "showcase-websocket";
@Value("${tio.customPort}")
public void setPort(int port) {
PORT = port;
}
}
四、实现一些监听类
1.ServerAioListener监听
import org.tio.core.ChannelContext;
import org.tio.core.intf.Packet;
import org.tio.server.intf.ServerAioListener;
/**
* @Author 955
* @Date 2023-07-26 17:24
* @Description
*/
public class ServerAioListenerImpl implements ServerAioListener {
@Override
public void onAfterConnected(ChannelContext channelContext, boolean b, boolean b1) throws Exception {
}
@Override
public void onAfterDecoded(ChannelContext channelContext, Packet packet, int i) throws Exception {
}
@Override
public void onAfterReceivedBytes(ChannelContext channelContext, int i) throws Exception {
}
@Override
public void onAfterSent(ChannelContext channelContext, Packet packet, boolean b) throws Exception {
}
@Override
public void onAfterHandled(ChannelContext channelContext, Packet packet, long l) throws Exception {
}
@Override
public void onBeforeClose(ChannelContext channelContext, Throwable throwable, String s, boolean b) throws Exception {
}
@Override
public boolean onHeartbeatTimeout(ChannelContext channelContext, Long aLong, int i) {
return false;
}
}
2.IpStatListener监听(这个可选)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tio.core.ChannelContext;
import org.tio.core.TioConfig;
import org.tio.core.intf.Packet;
import org.tio.core.stat.IpStat;
import org.tio.core.stat.IpStatListener;
/**
* @Author 955
* @Date 2023-07-27 12:03
* @Description
*/
public class ShowcaseIpStatListener implements IpStatListener {
@SuppressWarnings("unused")
private static Logger log = LoggerFactory.getLogger(ShowcaseIpStatListener.class);
public static final ShowcaseIpStatListener me = new ShowcaseIpStatListener();
/**
*
*/
private ShowcaseIpStatListener() {
}
@Override
public void onExpired(TioConfig tioConfig, IpStat ipStat) {
//在这里把统计数据入库中或日志
// if (log.isInfoEnabled()) {
// log.info("可以把统计数据入库\r\n{}", Json.toFormatedJson(ipStat));
// }
}
@Override
public void onAfterConnected(ChannelContext channelContext, boolean isConnected, boolean isReconnect, IpStat ipStat) throws Exception {
// if (log.isInfoEnabled()) {
// log.info("onAfterConnected\r\n{}", Json.toFormatedJson(ipStat));
// }
}
@Override
public void onDecodeError(ChannelContext channelContext, IpStat ipStat) {
// if (log.isInfoEnabled()) {
// log.info("onDecodeError\r\n{}", Json.toFormatedJson(ipStat));
// }
}
@Override
public void onAfterSent(ChannelContext channelContext, Packet packet, boolean isSentSuccess, IpStat ipStat) throws Exception {
// if (log.isInfoEnabled()) {
// log.info("onAfterSent\r\n{}\r\n{}", packet.logstr(), Json.toFormatedJson(ipStat));
// }
}
@Override
public void onAfterDecoded(ChannelContext channelContext, Packet packet, int packetSize, IpStat ipStat) throws Exception {
// if (log.isInfoEnabled()) {
// log.info("onAfterDecoded\r\n{}\r\n{}", packet.logstr(), Json.toFormatedJson(ipStat));
// }
}
@Override
public void onAfterReceivedBytes(ChannelContext channelContext, int receivedBytes, IpStat ipStat) throws Exception {
// if (log.isInfoEnabled()) {
// log.info("onAfterReceivedBytes\r\n{}", Json.toFormatedJson(ipStat));
// }
}
@Override
public void onAfterHandled(ChannelContext channelContext, Packet packet, IpStat ipStat, long cost) throws Exception {
// if (log.isInfoEnabled()) {
// log.info("onAfterHandled\r\n{}\r\n{}", packet.logstr(), Json.toFormatedJson(ipStat));
// }
}
}
3.WsServerAioListener监听
import com.wlj.config.CaseServerConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tio.core.ChannelContext;
import org.tio.core.Tio;
import org.tio.core.intf.Packet;
import org.tio.websocket.common.WsResponse;
import org.tio.websocket.common.WsSessionContext;
import org.tio.websocket.server.WsServerAioListener;
/**
* @Author 955
* @Date 2023-07-27 12:01
* @Description
*/
public class ShowcaseServerAioListener extends WsServerAioListener {
private static Logger log = LoggerFactory.getLogger(ShowcaseServerAioListener.class);
public static final ShowcaseServerAioListener me = new ShowcaseServerAioListener();
private ShowcaseServerAioListener() {
}
@Override
public void onAfterConnected(ChannelContext channelContext, boolean isConnected, boolean isReconnect) throws Exception {
super.onAfterConnected(channelContext, isConnected, isReconnect);
if (log.isInfoEnabled()) {
log.info("onAfterConnected\r\n{}", channelContext);
}
}
@Override
public void onAfterSent(ChannelContext channelContext, Packet packet, boolean isSentSuccess) throws Exception {
super.onAfterSent(channelContext, packet, isSentSuccess);
if (log.isInfoEnabled()) {
log.info("onAfterSent\r\n{}\r\n{}", packet.logstr(), channelContext);
}
}
@Override
public void onBeforeClose(ChannelContext channelContext, Throwable throwable, String remark, boolean isRemove) throws Exception {
super.onBeforeClose(channelContext, throwable, remark, isRemove);
if (log.isInfoEnabled()) {
log.info("onBeforeClose\r\n{}", channelContext);
}
WsSessionContext wsSessionContext = (WsSessionContext) channelContext.getAttribute();
if (wsSessionContext != null && wsSessionContext.isHandshaked()) {
int count = Tio.getAllChannelContexts(channelContext.tioConfig).getObj().size();
String msg = channelContext.getClientNode().toString() + " 离开了,现在共有【" + count + "】人在线";
//用tio-websocket,服务器发送到客户端的Packet都是WsResponse
WsResponse wsResponse = WsResponse.fromText(msg, CaseServerConfig.CHARSET);
//群发
Tio.sendToGroup(channelContext.tioConfig, CaseServerConfig.GROUP_ID, wsResponse);
}
}
@Override
public void onAfterDecoded(ChannelContext channelContext, Packet packet, int packetSize) throws Exception {
super.onAfterDecoded(channelContext, packet, packetSize);
if (log.isInfoEnabled()) {
log.info("onAfterDecoded\r\n{}\r\n{}", packet.logstr(), channelContext);
}
}
@Override
public void onAfterReceivedBytes(ChannelContext channelContext, int receivedBytes) throws Exception {
super.onAfterReceivedBytes(channelContext, receivedBytes);
if (log.isInfoEnabled()) {
log.info("onAfterReceivedBytes\r\n{}", channelContext);
}
}
@Override
public void onAfterHandled(ChannelContext channelContext, Packet packet, long cost) throws Exception {
super.onAfterHandled(channelContext, packet, cost);
if (log.isInfoEnabled()) {
log.info("onAfterHandled\r\n{}\r\n{}", packet.logstr(), channelContext);
}
}
}
4.IWsMsgHandler拦截(里面逻辑根据具体业务,但是必须实现这个,不然启动报错)
package com.wlj.im;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.tio.core.ChannelContext;
import org.tio.core.Tio;
import org.tio.core.TioConfig;
import org.tio.http.common.HttpRequest;
import org.tio.http.common.HttpResponse;
import org.tio.websocket.common.WsRequest;
import org.tio.websocket.common.WsResponse;
import org.tio.websocket.server.handler.IWsMsgHandler;
/**
* @Author 955
* @Date 2023-07-31 18:26
* @Description
*/
@Slf4j
@Component
public class WebSocketMessageHandler implements IWsMsgHandler {
/**
* TIO-WEBSOCKET 配置信息
*/
public static TioConfig serverTioConfig;
@Override
public HttpResponse handshake(HttpRequest httpRequest, HttpResponse httpResponse, ChannelContext channelContext) throws Exception {
serverTioConfig = channelContext.tioConfig;
return httpResponse;
}
@Override
public void onAfterHandshaked(HttpRequest httpRequest, HttpResponse httpResponse, ChannelContext channelContext) throws Exception {
// 拿到用户id
String id = httpRequest.getParam("id");
// 绑定用户
Tio.bindUser(channelContext, id);
// 绑定业务类型(根据业务类型判定处理相关业务)
String bsId = httpRequest.getParam("bsId");
if (StringUtils.isNotBlank(bsId)) {
Tio.bindBsId(channelContext, bsId);
}
// 给用户发送消息
WsResponse wsResponse = WsResponse.fromText("您已成功连接 WebSocket 服务器", "UTF-8");
Tio.sendToUser(channelContext.tioConfig, id, wsResponse);
}
@Override
public Object onBytes(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
return null;
}
@Override
public Object onClose(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
// 关闭连接
Tio.remove(channelContext, "WebSocket Close");
return null;
}
@Override
public Object onText(WsRequest wsRequest, String s, ChannelContext channelContext) throws Exception {
WsResponse wsResponse = WsResponse.fromText("服务器已收到消息:" + s, "UTF-8");
Tio.sendToUser(channelContext.tioConfig, userid, wsResponse);
return null;
}
}
五、一些消息体(根据业务需求)
import com.alibaba.fastjson.JSONObject;
import lombok.Getter;
import lombok.Setter;
import org.tio.core.intf.Packet;
import java.util.List;
/**
* @Author 955
* @Date 2023-07-26 17:26
* @Description 消息体
*/
@Setter
@Getter
public class MindPackage extends Packet {
private static final long serialVersionUID = -172060606924066412L;
public static final String CHARSET = "utf-8";
private List<JSONObject> body;
}
import com.alibaba.fastjson.JSONObject;
import lombok.Getter;
import lombok.Setter;
import org.tio.core.intf.Packet;
import java.io.Serializable;
/**
* @Author 955
* @Date 2023-07-26 17:27
* @Description 响应消息体
*/
@Getter
@Setter
public class ResponsePackage extends Packet {
private static final long serialVersionUID = -172060606924066412L;
public static final String CHARSET = "utf-8";
//响应具体内容
private JSONObject body;
//电话号码
private String phoneNum;
// 下发指令类型
private Integer type;
}
六、一些vo(根据实际业务来)
import lombok.Data;
import java.io.Serializable;
/**
* @Author 955
* @Date 2023-07-26 17:28
* @Description 客户端接收指令类型
*/
@Data
public class ClientDirectivesVo implements Serializable {
// 结束上报指令
public static final int END_REPORT_RESPONSE = 0;
// 心跳检查指令
public static final int HEART_BEET_REQUEST = 1;
// GPS开始上报指令
public static final int GPS_START_REPORT_RESPONSE = 2;
// 客户端数据下发
public static final int DATA_DISTRIBUTION = 3;
// 0:结束上报指令,1:心跳检测指令,2:GPS开始上报指令,3:客户端数据下发
private Integer type;
}
import lombok.Data;
import java.io.Serializable;
/**
* @Author 955
* @Date 2023-07-26 17:29
* @Description 业务实体vo,根据自己业务来
*/
@Data
public class PositioningDataReportVo implements Serializable {
private String userId;
private String name;
private String phone;
private String type;
}
import lombok.Data;
import java.io.Serializable;
/**
* @Author 955
* @Date 2023-07-26 17:30
* @Description 回执方法vo
*/
@Data
public class ReceiptDataVo implements Serializable {
//所属用户id
private String userId;
//所属用户电话号码
private String phone;
//xxx具体业务字段
private String yl;
}
import lombok.Data;
import java.io.Serializable;
/**
* @Author 955
* @Date 2023-07-26 17:31
* @Description 响应vo
*/
@Data
public class ResponseVo implements Serializable {
//响应类型
private Integer type;
//响应值
private Integer value;
}
七、具体业务方法
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.wlj.tcp.MindPackage;
import com.wlj.tcp.ResponsePackage;
import com.wlj.vo.ClientDirectivesVo;
import com.wlj.vo.PositioningDataReportVo;
import com.wlj.vo.ReceiptDataVo;
import com.wlj.vo.ResponseVo;
import jodd.util.ThreadUtil;
import lombok.extern.slf4j.Slf4j;
import org.tio.core.ChannelContext;
import org.tio.core.Tio;
import org.tio.core.TioConfig;
import org.tio.core.exception.AioDecodeException;
import org.tio.core.intf.Packet;
import org.tio.server.intf.ServerAioHandler;
import org.tio.utils.hutool.CollUtil;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author 955
* @Date 2023-07-26 17:27
* @Description 具体业务方法
*/
@Slf4j
public class ServerAioHandlerImpl implements ServerAioHandler {
private static AtomicInteger counter = new AtomicInteger(0);
private Map<String, ChannelContext> channelMaps = new ConcurrentHashMap<>();
private Queue<ResponsePackage> respQueue = new LinkedBlockingQueue<>();
private Queue<ResponsePackage> heartQueue = new LinkedBlockingQueue<>();
public boolean offer2SendQueue(ResponsePackage respPacket) {
return respQueue.offer(respPacket);
}
public Queue<ResponsePackage> getRespQueue() {
return respQueue;
}
public boolean offer2HeartQueue(ResponsePackage respPacket) {
return heartQueue.offer(respPacket);
}
public Map<String, ChannelContext> getChannelMaps() {
return channelMaps;
}
/**
* 解码:把接收到的ByteBuffer,解码成应用可以识别的业务消息包
* 总的消息结构:消息体
* 消息体结构: 对象的json串的16进制字符串
*/
@Override
public MindPackage decode(ByteBuffer buffer, int i, int i1, int i2, ChannelContext channelContext) throws AioDecodeException {
MindPackage imPacket = new MindPackage();
try {
List<JSONObject> msgList = new ArrayList<>();
//Charset charset = Charset.forName("UTF-8");
//这里使用UTF-8收中文时会报错
Charset charset = Charset.forName("GBK");
CharsetDecoder decoder = charset.newDecoder();
CharBuffer charBuffer = decoder.decode(buffer);
String str = charBuffer.toString();
if (str.indexOf("{") != 0) {
str = str.substring(str.indexOf("{"));
}
if (str.indexOf("}{") > -1) {
String[] split = str.split("}");
List<String> list = Arrays.asList(split);
list.forEach(item -> {
item += "}";
msgList.add(JSON.parseObject(item));
});
} else {
msgList.add(JSON.parseObject(str));
}
log.info("收到" + msgList.size() + "条消息");
imPacket.setBody(msgList);
return imPacket;
} catch (Exception e) {
return imPacket;
}
}
/**
* 编码:把业务消息包编码为可以发送的ByteBuffer
*/
@Override
public ByteBuffer encode(Packet packet, TioConfig groupContext, ChannelContext channelContext) {
ResponsePackage helloPacket = (ResponsePackage) packet;
JSONObject body = helloPacket.getBody();
//写入消息体
try {
return ByteBuffer.wrap(body.toJSONString().getBytes("GB2312"));
} catch (UnsupportedEncodingException e) {
}
return null;
}
/**
* 处理消息(最核心的方法)
*/
@Override
public void handler(Packet packet, ChannelContext channelContext) throws Exception {
MindPackage helloPacket = (MindPackage) packet;
List<JSONObject> msgList = helloPacket.getBody();
if (CollectionUtil.isNotEmpty(msgList)) {
msgList.forEach(body -> {
if (body != null) {
log.info("收到设备上报信息 " + body);
// 获取指令
Integer type = body.getInteger("type");
if (type != null) {
channelContext.set("type", type);
String phoneNum = body.getString("phoneNum");
String content = body.getString("content");
Tio.bindToken(channelContext, phoneNum);
ResponsePackage respPacket = new ResponsePackage();
switch (type) {
// 接收下线指令
case ClientDirectivesVo.END_REPORT_RESPONSE:
//保存连接
channelMaps.put(phoneNum, channelContext);
//TODO 更改客户端状态为下线状态
log.info("收到{}客户端下线通知", phoneNum);
Tio.unbindUser(channelContext.tioConfig, phoneNum);
respPacket.setPhoneNum("您已下线");
// 回执方法
receiptHandler(respPacket, phoneNum, ClientDirectivesVo.END_REPORT_RESPONSE);
break;
case ClientDirectivesVo.HEART_BEET_REQUEST: //接收心跳检查指令
//保存连接
channelMaps.put(phoneNum, channelContext);
Tio.bindUser(channelContext, phoneNum);
log.info("收到{}客户端心跳检查指令", phoneNum);
// 回执方法
receiptHandler(respPacket, phoneNum, ClientDirectivesVo.HEART_BEET_REQUEST);
break;
case ClientDirectivesVo.GPS_START_REPORT_RESPONSE: //开始上报GPS指令
//保存连接
channelMaps.put(phoneNum, channelContext);
// PositioningDataReportVo vo = JSONObject.toJavaObject(body, PositioningDataReportVo.class);
log.info("收到{}客户端上报GPS指令,上报数据:{}", phoneNum, "vo");
// 回执方法
receiptHandler(respPacket, phoneNum, ClientDirectivesVo.GPS_START_REPORT_RESPONSE);
break;
case ClientDirectivesVo.DATA_DISTRIBUTION: //开始下发数据指令
//保存连接
channelMaps.put(phoneNum, channelContext);
log.info("收到{}客户端下发数据指令", phoneNum);
SetWithLock<ChannelContext> obj = Tio.getByUserid(channelContext.tioConfig, phoneNum);
if (ObjectUtil.isEmpty(obj)) {
// 回执方法
respPacket.setBody(JSONObject.parseObject("{\"type\":\"该用户不在线\"}"));
receiptHandler(respPacket, phoneNum, ClientDirectivesVo.GPS_START_REPORT_RESPONSE);
} else {
// 回执方法
DataDistributionReportVo data = new DataDistributionReportVo();
data.setPhone(phoneNum);
data.setServiceInfo(content);
// 回复时的设备标志,必填
respPacket.setPhoneNum(phoneNum);
respPacket.setBody((JSONObject) JSON.toJSON(data));
respPacket.setType(ClientDirectivesVo.DATA_DISTRIBUTION);
Tio.sendToUser(channelContext.tioConfig, phoneNum, respPacket);
}
break;
}
}
}
});
}
return;
}
/**
* 回执信息方法
*
* @Author: laohuang
* @Date: 2022/11/24 13:53
*/
public void receiptHandler(ResponsePackage respPacket, String phoneNum, Integer clientDirectives) {
// 回执信息
//ResponseVo callVo = new ResponseVo();
//callVo.setType(clientDirectives);
// 响应结果 1:成功 0:失败
//callVo.setValue(1);
// 回复时的设备标志,必填
respPacket.setPhoneNum(phoneNum);
//respPacket.setBody((JSONObject) JSON.toJSON(callVo));
respPacket.setType(clientDirectives);
offer2SendQueue(respPacket);
}
private Object locker = new Object();
public ServerAioHandlerImpl() {
try {
new Thread(() -> {
while (true) {
try {
ResponsePackage respPacket = respQueue.poll();
if (respPacket != null) {
synchronized (locker) {
String phoneNum = respPacket.getPhoneNum();
ChannelContext channelContext = channelMaps.get(phoneNum);
if (channelContext != null) {
Boolean send = Tio.send(channelContext, respPacket);
String s = JSON.toJSONString(respPacket);
System.err.println("发送数据" + s);
System.err.println("数据长度" + s.getBytes().length);
log.info("下发设备指令 设备ip" + channelContext + " 设备[" + respPacket.getPhoneNum() + "]" + (send ? "成功" : "失败") + "消息:" + JSON.toJSONString(respPacket.getBody()));
}
}
}
} catch (Exception e) {
log.error(e.getMessage());
} finally {
log.debug("发送队列大小:" + respQueue.size());
ThreadUtil.sleep(10);
}
}
}).start();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 确保只有一个呼叫器响应后修改呼叫记录
*
* @param recordId 记录id
* @param resCallSn 响应的呼叫器sn
*/
public synchronized void updateCallRecordAndStopResponse(Long recordId, String resCallSn, String sn) {
}
}
八、启动类(加上@EnableTioWebSocketServer,表明作为Socket服务端)
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.tio.websocket.starter.EnableTioWebSocketServer;
@SpringBootApplication
@EnableTioWebSocketServer
public class PartApplication {
public static void main(String[] args) {
SpringApplication.run(PartApplication.class, args);
}
}
九、使用NetAssist测试工具测试效果(0积分下载即可)
https://download.csdn.net/download/m0_49605579/88106789?spm=1001.2014.3001.5503
注:这里远程主机端口为yml内配置的tioPort,即为项目启动时控制台打印的监听端口,连接上就可以发送数据到服务器,工具可以打开多个模拟多个客户端。
写在最后:
这里说一下主要业务这个handler的逻辑:
第一步:
A用户发送{“type”:1,“phoneNum”:“用户A”}对应type:HEART_BEET_REQUEST,使用Tio.bindUser(channelContext, userId);
绑定该用户。
B用户按上述同样操作{“type”:1,“phoneNum”:“用户A”}
第二步:
A用户发送{“type”:3,“content”:“发送消息到用户B”,“phoneNum”:“用户B”}对应type:DATA_DISTRIBUTION通过服务器下发指令,服务器这里先判断是否在线,如果在线就把A用户发的消息推送给手机号是用户B的B用户,此时B用户实时收到消息。
效果图(type分别控制用户发送消息、上线、下线):