springboot集成SocketIO实时通信

https://blog.csdn.net/zsj777/article/details/83212776
概述
基于 socket.io 来说,采用 node 实现更加合适,本文使用两个后端的Java开源框架实现。

服务端使用 netty-socketio
客户端使用 socket.io-client-java
业务需求是将之前通过轮询方式调动RESTFul API改成使用WebSocket长连接方式,实现要服务器实时的推送消息,另外还要实时监控POS机的在线状态等。

引入依赖

com.corundumstudio.socketio netty-socketio 1.7.13 io.netty netty-resolver 4.1.15.Final io.netty netty-transport 4.1.15.Final io.socket socket.io-client 1.0.0

先来服务端程序爽一把,话不多说,先上代码:
服务器代码

public class NamespaceSocketServer {
private static final Logger logger = LoggerFactory.getLogger(NamespaceSocketServer.class);

public static void main(String[] args) {
    /*
     * 创建Socket,并设置监听端口
     */
    Configuration config = new Configuration();
    //设置主机名
    config.setHostname("localhost");
    //设置监听端口
    config.setPort(9092);
    // 协议升级超时时间(毫秒),默认10秒。HTTP握手升级为ws协议超时时间
    config.setUpgradeTimeout(10000);
    // Ping消息超时时间(毫秒),默认60秒,这个时间间隔内没有接收到心跳消息就会发送超时事件
    config.setPingTimeout(180000);
    // Ping消息间隔(毫秒),默认25秒。客户端向服务器发送一条心跳消息间隔
    config.setPingInterval(60000);
    // 连接认证,这里使用token更合适
    config.setAuthorizationListener(new AuthorizationListener() {
        @Override
        public boolean isAuthorized(HandshakeData data) {
            // String token = data.getSingleUrlParam("token");
            // String username = JWTUtil.getSocketUsername(token);
            // return JWTUtil.verifySocket(token, "secret");
            return true;
        }
    });

    final SocketIOServer server = new SocketIOServer(config);

    /*
     * 添加连接监听事件,监听是否与客户端连接到服务器
     */
    server.addConnectListener(new ConnectListener() {
        @Override
        public void onConnect(SocketIOClient client) {
            // 判断是否有客户端连接
            if (client != null) {
                logger.info("连接成功。clientId=" + client.getSessionId().toString());
                client.joinRoom(client.getHandshakeData().getSingleUrlParam("appid"));
            } else {
                logger.error("并没有人连接上。。。");
            }
        }
    });

    /*
     * 添加监听事件,监听客户端的事件
     * 1.第一个参数eventName需要与客户端的事件要一致
     * 2.第二个参数eventClase是传输的数据类型
     * 3.第三个参数listener是用于接收客户端传的数据,数据类型需要与eventClass一致
     */
    server.addEventListener("login", LoginRequest.class, new DataListener<LoginRequest>() {
        @Override
        public void onData(SocketIOClient client, LoginRequest data, AckRequest ackRequest) {
            logger.info("接收到客户端login消息:code = " + data.getCode() + ",body = " + data.getBody());
            // check is ack requested by client, but it's not required check
            if (ackRequest.isAckRequested()) {
                // send ack response with data to client
                ackRequest.sendAckData("已成功收到客户端登录请求", "yeah");
            }
            // 向客户端发送消息
            List<String> list = new ArrayList<>();
            list.add("登录成功,sessionId=" + client.getSessionId());
            // 第一个参数必须与eventName一致,第二个参数data必须与eventClass一致
            String room = client.getHandshakeData().getSingleUrlParam("appid");
            server.getRoomOperations(room).sendEvent("login", list.toString());
        }
    });
    //启动服务
    server.start();
}

}

老规矩,先上代码爽爽
Android客户端

public class SocketClient {
private static Socket socket;
private static final Logger logger = LoggerFactory.getLogger(SocketClient.class);

public static void main(String[] args) throws URISyntaxException {
    IO.Options options = new IO.Options();
    options.transports = new String[]{"websocket"};
    options.reconnectionAttempts = 2;     // 重连尝试次数
    options.reconnectionDelay = 1000;     // 失败重连的时间间隔(ms)
    options.timeout = 20000;              // 连接超时时间(ms)
    options.forceNew = true;
    options.query = "username=test1&password=test1&appid=com.xncoding.apay2";
    socket = IO.socket("http://localhost:9092/", options);
    socket.on(Socket.EVENT_CONNECT, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            // 客户端一旦连接成功,开始发起登录请求
            LoginRequest message = new LoginRequest(12, "这是客户端消息体");
            socket.emit("login", JsonConverter.objectToJSONObject(message), (Ack) args1 -> {
                logger.info("回执消息=" + Arrays.stream(args1).map(Object::toString).collect(Collectors.joining(",")));
            });
        }
    }).on("login", new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("接受到服务器房间广播的登录消息:" + Arrays.toString(args));
        }
    }).on(Socket.EVENT_CONNECT_ERROR, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("Socket.EVENT_CONNECT_ERROR");
            socket.disconnect();
        }
    }).on(Socket.EVENT_CONNECT_TIMEOUT, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("Socket.EVENT_CONNECT_TIMEOUT");
            socket.disconnect();
        }
    }).on(Socket.EVENT_PING, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("Socket.EVENT_PING");
        }
    }).on(Socket.EVENT_PONG, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("Socket.EVENT_PONG");
        }
    }).on(Socket.EVENT_MESSAGE, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("-----------接受到消息啦--------" + Arrays.toString(args));
        }
    }).on(Socket.EVENT_DISCONNECT, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            logger.info("客户端断开连接啦。。。");
            socket.disconnect();
        }
    });
    socket.connect();
}

}
关于心跳机制
根据 Socket.IO文档 解释, 客户端会定期发送心跳包,并触发一个ping事件和一个pong事件,如下:

ping Fired when a ping packet is written out to the server.
pong Fired when a pong is received from the server. Parameters:
Number number of ms elapsed since ping packet (i.e.: latency)
这里最重要的两个服务器参数如下:

pingTimeout (Number): how many ms without a pong packet to consider the connection closed (60000)
pingInterval (Number): how many ms before sending a new ping packet (25000).
也就是说握手协议的时候,客户端从服务器拿到这两个参数,一个是ping消息的发送间隔时间,一个是从服务器返回pong消息的超时时间, 客户端会在超时后断开连接。心跳包发送方向是客户端向服务器端发送,以维持在线状态。

关于断线和超时
关闭浏览器、直接关闭客户端程序、kill进程、主动执行disconnect方法都会导致立刻产生断线事件。 而客户端把网络断开,服务器端在 pingTimeout ms后产生断线事件、客户端在 pingTimeout ms后也产生断线事件。

实际上,超时后会产生一个断线事件,叫”disconnect”。客户端和服务器端都可以对这个事件作出应答,释放连接。

客户端代码:

.on(Socket.EVENT_DISCONNECT, new Emitter.Listener() {
    @Override
    public void call(Object... args) {
        logger.info("客户端断开连接啦。。。");
        socket.disconnect();
    }
});

下面是客户端日志:连上服务器后,断开网络。超过了心跳超时时间后,产生断线事件。如果客户端不主动断开连接的话,会自动重连, 这时候发现连接不上,又产生连接错误事件,然后重试2次,都失败后自动断开连接了。

SocketClient - 回执消息=服务器已成功收到客户端登录请求,yeah
SocketClient - Socket.EVENT_PING
SocketClient - Socket.EVENT_PONG
SocketClient - 客户端断开连接啦。。。
SocketClient - Socket.EVENT_CONNECT_ERROR
服务器端代码:

server.addDisconnectListener(new DisconnectListener() {
    @Override
    public void onDisconnect(SocketIOClient client) {
        System.out.println("服务器收到断线通知... sessionId=" + client.getSessionId());
    }
});

服务器逻辑是,如果在心跳超时后,就直接断开这个连接,并且产生一个断开连接事件。

服务器通过netty处理心跳包ping/pong的日志如下:
WebSocket08FrameDecoder - Decoding WebSocket Frame opCode=1
WebSocket08FrameDecoder - Decoding WebSocket Frame length=1
WebSocket08FrameEncoder - Encoding WebSocket Frame opCode=1 length=1
浏览器客户端演示
对于netty-socketio有一个demo工程,里面通过一个网页的聊天小程序演示了各种使用方法。

demo地址:netty-socketio-demo

SpringBoot集成
最后重点讲一下如何在SpringBoot中集成。

修改配置
首先maven依赖之前已经讲过了,先修改下application.yml配置文件来配置下几个参数,比如主机、端口、心跳时间等等。

################### 自定义项目配置 ###################
xncoding:
socket-hostname: localhost
socket-port: 9096
添加Bean配置
然后增加一个SocketServer的Bean配置:



@Configuration
public class NettySocketConfig {
 
    @Resource
    private MyProperties myProperties;
 
    @Resource
    private ApiService apiService;
    @Resource
    private ManagerInfoService managerInfoService;
 
    private static final Logger logger = LoggerFactory.getLogger(NettySocketConfig.class);
 
    @Bean
    public SocketIOServer socketIOServer() {
        /*
         * 创建Socket,并设置监听端口
         */
        com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
        // 设置主机名,默认是0.0.0.0
        // config.setHostname("localhost");
        // 设置监听端口
        config.setPort(9096);
        // 协议升级超时时间(毫秒),默认10000。HTTP握手升级为ws协议超时时间
        config.setUpgradeTimeout(10000);
        // Ping消息间隔(毫秒),默认25000。客户端向服务器发送一条心跳消息间隔
        config.setPingInterval(60000);
        // Ping消息超时时间(毫秒),默认60000,这个时间间隔内没有接收到心跳消息就会发送超时事件
        config.setPingTimeout(180000);
        // 这个版本0.9.0不能处理好namespace和query参数的问题。所以为了做认证必须使用全局默认命名空间
        config.setAuthorizationListener(new AuthorizationListener() {
            @Override
            public boolean isAuthorized(HandshakeData data) {
                // 可以使用如下代码获取用户密码信息
                String username = data.getSingleUrlParam("username");
                String password = data.getSingleUrlParam("password");
                logger.info("连接参数:username=" + username + ",password=" + password);
                ManagerInfo managerInfo = managerInfoService.findByUsername(username);
                // MD5盐
                String salt = managerInfo.getSalt();
                String encodedPassword = ShiroKit.md5(password, username + salt);
                // 如果认证不通过会返回一个Socket.EVENT_CONNECT_ERROR事件
                return encodedPassword.equals(managerInfo.getPassword());
            }
        });
 
        final SocketIOServer server = new SocketIOServer(config);
 
        return server;
    }
 
    @Bean
    public SpringAnnotationScanner springAnnotationScanner(SocketIOServer socketServer) {
        return new SpringAnnotationScanner(socketServer);
    }
}

后面还有个SpringAnnotationScanner的定义不能忘记。注意,我在AuthorizationListener里面通过调用service做了用户名和密码的认证。通过注解方式可以注入service, 执行相应的连接授权动作。

添加消息结构类
预先定义好客户端和服务器端直接传递的消息类型,使用简单的JavaBean即可,比如


public class ReportParam {
    /**
     * IMEI码
     */
    private String imei;
    /**
     * 位置
     */
    private String location;
 
    public String getImei() {
        return imei;
    }
 
    public void setImei(String imei) {
        this.imei = imei;
    }
 
    public String getLocation() {
        return location;
    }
 
    public void setLocation(String location) {
        this.location = location;
    }
}

这里才是最核心的接口处理类,所有接口处理逻辑都应该写在这里面,我只举了一个例子,就是POS上传位置接口:
添加消息处理类


/**
 * 消息事件处理器
 *
 * @version 1.0
 * @since 2018/1015
 */
@Component
public class MessageEventHandler {
 
    private final SocketIOServer server;
    private final ApiService apiService;
 
    private static final Logger logger = LoggerFactory.getLogger(MessageEventHandler.class);
 
    @Autowired
    public MessageEventHandler(SocketIOServer server, ApiService apiService) {
        this.server = server;
        this.apiService = apiService;
    }
 
    //添加connect事件,当客户端发起连接时调用
    @OnConnect
    public void onConnect(SocketIOClient client) {
        if (client != null) {
            String imei = client.getHandshakeData().getSingleUrlParam("imei");
            String applicationId = client.getHandshakeData().getSingleUrlParam("appid");
            logger.info("连接成功, applicationId=" + applicationId + ", imei=" + imei +
                    ", sessionId=" + client.getSessionId().toString() );
            client.joinRoom(applicationId);
            // 更新POS监控状态为在线
            ReportParam param = new ReportParam();
            param.setImei(imei);
            apiService.updateJustState(param, client.getSessionId().toString(), 1);
        } else {
            logger.error("客户端为空");
        }
    }
 
    //添加@OnDisconnect事件,客户端断开连接时调用,刷新客户端信息
    @OnDisconnect
    public void onDisconnect(SocketIOClient client) {
        String imei = client.getHandshakeData().getSingleUrlParam("imei");
        logger.info("客户端断开连接, imei=" + imei + ", sessionId=" + client.getSessionId().toString());
        // 更新POS监控状态为离线
        ReportParam param = new ReportParam();
        param.setImei(imei);
        apiService.updateJustState(param, "", 2);
        client.disconnect();
    }
 
    // 消息接收入口
    @OnEvent(value = Socket.EVENT_MESSAGE)
    public void onEvent(SocketIOClient client, AckRequest ackRequest, Object data) {
        logger.info("接收到客户端消息");
        if (ackRequest.isAckRequested()) {
            // send ack response with data to client
            ackRequest.sendAckData("服务器回答Socket.EVENT_MESSAGE", "好的");
        }
    }
 
    // 广播消息接收入口
    @OnEvent(value = "broadcast")
    public void onBroadcast(SocketIOClient client, AckRequest ackRequest, Object data) {
        logger.info("接收到广播消息");
        // 房间广播消息
        String room = client.getHandshakeData().getSingleUrlParam("appid");
        server.getRoomOperations(room).sendEvent("broadcast", "广播啦啦啦啦");
    }
 
    /**
     * 报告地址接口
     * @param client 客户端
     * @param ackRequest 回执消息
     * @param param 报告地址参数
     */
    @OnEvent(value = "doReport")
    public void onDoReport(SocketIOClient client, AckRequest ackRequest, ReportParam param) {
        logger.info("报告地址接口 start....");
        BaseResponse result = postReport(param);
        ackRequest.sendAckData(result);
    }
 
    /*----------------------------------------下面是私有方法-------------------------------------*/
    private BaseResponse postReport(ReportParam param) {
        BaseResponse result = new BaseResponse();
        int r = apiService.report(param);
        if (r > 0) {
            result.setSuccess(true);
            result.setMsg("报告地址成功");
        } else {
            result.setSuccess(false);
            result.setMsg("该POS机还没有入网,报告地址失败。");
        }
        return result;
    }
}

还有一个步骤就是添加启动器,在SpringBoot启动之后立马执行:

添加ServerRunner


/**
 * SpringBoot启动之后执行
 *
 *
 * @version 1.0
 * @since 2018/10/15
 */
@Component
@Order(1)
public class ServerRunner implements CommandLineRunner {
    private final SocketIOServer server;
    private static final Logger logger = LoggerFactory.getLogger(ServerRunner.class);
 
    @Autowired
    public ServerRunner(SocketIOServer server) {
        this.server = server;
    }
 
    @Override
    public void run(String... args) throws Exception {
        logger.info("ServerRunner 开始启动啦...");
        server.start();
    }
}

要实现通过域名并走标准80或443端口的话,最好使用nginx做反向代理,跟正常的http反向代理基本一致, 不过websocket需要增加一个upgrade的配置。
nginx反向代理
下面我以一个实际使用例子来说明如何配置nginx的https访问websocket,并且开启301自动http跳转https。

首先要有一个域名,比如test.enzhico.net,然后申请letsencrypt的免费证书,这个过程我不讲了

配置如下:
map $http_upgrade $connection_upgrade {
default upgrade;
‘’ close;
}

server {
server_name test.enzhico.net;
location / {
proxy_pass http://localhost:9096;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
#root /opt/www/test.enzhico.net;
#index index.html index.htm;
error_page 404 /404.html;
location = /40x.html {
}

  error_page 500 502 503 504 /50x.html;
      location = /50x.html {
  }

listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/test.enzhico.net/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/test.enzhico.net/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {
listen 80;
server_name test.enzhico.net;
return 301 https:// h o s t host hostrequest_uri; # managed by Certbot
}
注意这其中和普通HTTP代理的关键不同是:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
参考文章
Android端与Java服务端交互——SocketIO
Spring Boot 集成 socket.io 后端实现消息实时通信
Spring Boot实战之netty-socketio实现简单聊天室
GitHub源码
springboot-socketio

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值