# WebSocket

使用过 socket-io 和 spring 提供的 WebSocket 后,果断选择 socket-io,这里采用 netty 的实现版本 netty-socketio

# 优势

socket-io 提供房间、广播等概念,可以方便的创建 IM 聊天等功能,并且该协议有多种通讯方式,在客户端不支持 websocket 的情况下(IE),自动采用 http 轮询的方式。

# 缺点

它采用了一些自定义实时传输协议,标准的 WebSocket 客户端不能直接连接到 Socket.io 服务器,要求客户端与服务器端均须使用该框架,不过 socket-io 提供了多种语言版本的客户端,如:swift、js、dart、java、c++

netty 和 tomcat 不属于同一容器,所以无法和 http 共用一个端口,详情见issue

# 认证

socket 连接时需要进行认证,有以下两种方案:

# url 参数

在连接的 url 上携带 token 参数,后端获取到 token 后进行校验,详情见文档

// 前端

const socket = io({
  query: {
    token: "cde"
  }
});

// 重新连接时更新token
socket.on("reconnect_attempt", () => {
  socket.io.opts.query = {
    token: "fgh"
  };
});
// 后端
// com.htnova.common.socket.SocketConfig
new AuthorizationListener() {
    @Override
    public boolean isAuthorized(HandshakeData data) {
        // 如果采用url参数机制
        String authToken = data.getSingleUrlParam(authorizationHeader);
        log.info("连接参数:{} = {}", authorizationHeader, authToken);
        return true;
    }
}

缺点是自己要维护一套 token 的签发认证机制,不能利用现有维护好的 cookie/session 机制

当然你也可以选择把 cookie 设为非 httpOnly,这样前端获取到 cookie 后,放在 url 上传过来

websocket 的实现的是先发送一个 http 请求,头字段 Connection: Upgrade 表示希望把当前 http 升级为 socket,服务端解析后当前通道就被升级了

因为建立连接的时候是一个 http,所以浏览器依然会自动带上 cookie,后端从 header 中解析出 cookie,然后从 spring-security 中解析出 session 就行了,相比于 url 参数的方案,该 cookie 仍然为 httpOnly,所以更安全,具体代码如下:

// 后端
// com.htnova.common.socket.SocketConfig
new AuthorizationListener() {
    @Override
    public boolean isAuthorized(HandshakeData data) {
        // 如果采用cookie机制
        String sessionId = AuthUtil.getSessionId(data.getHttpHeaders().get("cookie"));
        Session session = sessionRepository.findById(sessionId);
        log.info("session:{}", session);
        return true;
    }
}

并且 socket-io 本身重连不需要二次验证也是靠 cookie 实现的,可以发现 socket-io 往浏览器写入了一个 cookie,重连时只要 cookie 在后端的 session 中,就允许连接

注意

  • 确保 spring security 设置的 cookie 的 path 和 socket 连接时的 path 一致
  • 建议使用火狐调试,chrome 虽然也发送了 cookie ,但是不显示在控制台

# 启动和关闭

netty 和 tomcat 不属于同一个容器,所以 netty 要单独启动,结束时也要单独关闭,这里采用 Servlet 生命周期的注解来实现:

// com.htnova.common.socket.SocketServerInit
@Service
public class SocketServerInit {

    @Resource
    private SocketIOServer socketIOServer;

    @PostConstruct
    public void start() {
        log.info("SocketIO Server starting...");
        socketIOServer.start();
    }

    @PreDestroy
    public void stop() {
        log.info("SocketIO Server stopping...");
        socketIOServer.stop();
        log.info("SocketIO Server stopped.");
    }
}

# 拦截器

package com.htnova.common.socket;

@Slf4j
@Service
public class SocketServerInit {

    @Resource
    private SocketIOServer socketIOServer;

    /**
     * 客户端建立连接
     */
    @OnConnect
    public void onConnect(SocketIOClient client) {
        String uuid = client.getSessionId().toString();
        String ip = client.getRemoteAddress().toString();
        String sessionId = AuthUtil.getSessionId(
            client.getHandshakeData().getHttpHeaders().get("cookie")
        );
        //加入仓库的房间
        client.joinRoom("default_room");
        log.info("设备建立连接 IP: {} UUID: {} token: {}", ip, uuid, authToken);
    }

    /**
     * 客户端断开连接
     */
    @OnDisconnect
    public void onDisconnect(SocketIOClient client) {
        String uuid = client.getSessionId().toString();
        String ip = client.getRemoteAddress().toString();
        log.info("设备断开连接 IP: {} UUID: {}", ip, uuid);
    }
}

# 监听和推送

@Slf4j
@Controller
public class SocketController {

    @Resource
    private SocketIOServer socketIOServer;

    // 监听前端的事件
    @OnEvent("/hello")
    public void onEvent(SocketIOClient client, AckRequest ackRequest, String msg) {
        log.info("client {} AckRequest {} msg {}", client, ackRequest, msg);
        client.sendEvent("/word", "hello word!");
        socketIOServer.getRoomOperations("default_room").sendEvent("/broadcast", "广播消息");
    }

    // 房间广播消息
    public void broadcast() {
        socketIOServer.getRoomOperations("default_room").sendEvent("/broadcast", "广播消息");
    }
}