WebSocket是一种在单个TCP连接上进行全双工通信的协议,浏览器和服务器只需要完成一次握手,两者之间就可以直接创建持久性的连接,并进行双向数据传输。

消息群发

1.添加如下依赖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>sockjs-client</artifactId>
    <version>1.1.2</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>stomp-websocket</artifactId>
    <version>2.3.3</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.3.1</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

2.配置WebSocket

Spring框架提供了基于WebSocket的STOMP支持,STOMP是一个简单的可互操作的协议,通常被用于通过中间服务器在客户端之间进行异步消息传递。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 如果消息的前缀是"/topic",就会将消息转发给消息代理(broker)
        registry.enableSimpleBroker("/topic","/queue");
        // 前缀为"/app"的destination可以通过@MessageMapping注解的方法处理
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 客户端通过这里配置的URL建立WebSocket连接
        registry.addEndpoint("portal/chat").withSockJS();
    }
}

3.Controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Controller
public class GreetingController {

    @Autowired
    SimpMessagingTemplate messagingTemplate;

    // @MessageMapping("/hello")用来接收"/app/hello"发送的消息
    // 再将消息转发到@SendTo定义的路径上(前缀为"/topic",消息将会被broker代理,再由broker广播)
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Message greeting(Message message) throws Exception{
        return message;
    }
    
//    @MessageMapping("/hello")
//    public void greeting(Message message) throws Exception{
//        messagingTemplate.convertAndSend("/topic/greetings",message);
//    }

}

@Data
public class Message {
    private String name;
    private String content;
}

4.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>群聊</title>
    <script src="../lib/jquery/3.3.1/jquery.min.js"></script>
    <script src="../lib/sockjs-client/1.1.2/sockjs.min.js"></script>
    <script src="../lib/stomp-websocket/2.3.3/stomp.min.js"></script>
    <script src="../js/app.js"></script>
</head>
<body>
    <div>
        <label for="name">请输入用户名</label>
        <input type="text" id="name" placeholder="用户名">
    </div>
    <div>
        <button id="connect" type="button">连接</button>
        <button id="disconnect" type="button" disabled="disabled">断开连接</button>
    </div>
    <div id="chat" style="display: none"></div>
    <div>
        <label for="content">请输入聊天内容</label>
        <input type="text" id="content" placeholder="聊天内容">
    </div>
    <button id="send" type="button">发送</button>
    <div id="greetings"></div>
    <div id="conversation" style="display: none">群聊进行中...</div>
</body>
</html>

5.JavaScript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
var stompClient = null;
function setConnected(connected) {
    $("#connect").prop("disabled",connected);
    $("#disconnect").prop("disabled",!connected);
    if (connected){
        $("#conversation").show();
        $("#chat").show();
    }
    else {
        $("#conversation").hide();
        $("#chat").hide();
    }
    $("#greetings").html("");
}

function connect() {
    if (!$("#name").val()){
        return;
    }
    var socket = new SockJS('/portal/chat');
    // 创建一个stomp实例发起连接请求
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        setConnected(true);
        // 订阅服务端发回来的消息
        stompClient.subscribe('/topic/greetings',function (greeting) {
            showGreeting(JSON.parse(greeting.body))
        });
    });
}

function disconnect() {
    if (stompClient !== null){
        stompClient.disconnect();
    }
    setConnected(false);
}

function sendName() {
    stompClient.send("/app/hello",{},
        JSON.stringify({'name': $("#name").val(),'content':$("#content").val()}));
}

function showGreeting(message) {
    $("#greetings")
        .append("<div>" + message.name + ":" + message.content + "</div>")
}

$(function () {
    $("#connect").click(function () { connect(); });
    $("#disconnect").click(function () { disconnect(); });
    $("#send").click(function () { sendName(); });
})

消息点对点发送

点对点发送,应该有用户的概念,因此需加入spring security依赖

1.配置spring security

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

   @Bean
   PasswordEncoder passwordEncoder() {
       return new BCryptPasswordEncoder();
   }


   @Override
   protected void configure(AuthenticationManagerBuilder auth) throws Exception {

       BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
//        System.out.println("------------encoder.encode:" + encoder.encode("456"));
//        super.configure(auth);
       auth.inMemoryAuthentication()
           .withUser("admin")
           .password(encoder.encode("123"))
           .roles("admin")
           .and()
           .withUser("sang")
           .password(encoder.encode("456"))
           .roles("user");
   }

   @Override
   protected void configure(HttpSecurity http) throws Exception {
//        super.configure(http);
       http.authorizeRequests()
           .anyRequest().authenticated()
           .and()
           .formLogin().permitAll();
   }
}

2.configuration

同上,多加了一个/queue,方便对群发消息和点对点消息进行管理

3.controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Controller
public class GreetingController {

    @Autowired
    SimpMessagingTemplate messagingTemplate;

    // @MessageMapping("/hello")用来接收"/app/hello"发送的消息
    // 再将消息转发到@SendTo定义的路径上(前缀为"/topic",消息将会被broker代理,再由broker广播)
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Message greeting(Message message) throws Exception{
        return message;
    }

//    @MessageMapping("/hello")
//    public void greeting(Message message) throws Exception{
//        messagingTemplate.convertAndSend("/topic/greetings",message);
//    }

    @MessageMapping("/chat")
    public void chat(Principal principal, Chat chat){
        // Principal用来获取当前登录用户的信息,第二个参数是客户端发送来的消息
        String from = principal.getName();
        chat.setFrom(from);
//        System.out.println("------------chat.getTo():" + chat.getTo());
        messagingTemplate.convertAndSendToUser(chat.getTo(),"/queue/chat",chat);
    }

}

@Data
public class Chat {
    private String to;
    private String from;
    private String content;
}

消息发送使用的方法是convertAndSendToUser,该方法内部调用了convertAndSend方法,并对消息路径做了处理,部门源码如下:

1
2
3
4
5
6
7
    public void convertAndSendToUser(String user, String destination, Object payload, @Nullable Map<String, Object> headers, @Nullable MessagePostProcessor postProcessor) throws MessagingException {
        Assert.notNull(user, "User must not be null");
        Assert.isTrue(!user.contains("%2F"), "Invalid sequence \"%2F\" in user name: " + user);
        user = StringUtils.replace(user, "/", "%2F");
        destination = destination.startsWith("/") ? destination : "/" + destination;
        super.convertAndSend(this.destinationPrefix + user + destination, payload, headers, postProcessor);
    }

这里destinationPrefix的默认值是"/user/",也就是说消息的最终发送路径是 "/user/用户名/queue/chat"

4.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>单聊</title>
    <script src="../lib/jquery/3.3.1/jquery.min.js"></script>
    <script src="../lib/sockjs-client/1.1.2/sockjs.min.js"></script>
    <script src="../lib/stomp-websocket/2.3.3/stomp.min.js"></script>
    <script src="../js/chat.js"></script>
</head>
<body>
    <div id="chat"></div>
    <div id="chatsContent"></div>
    <div>
        请输入聊天内容:
        <input type="text" id="content" placeholder="聊天内容">
        目标用户:
        <input type="text" id="to" placeholder="目标用户">
        <button id="send" type="button">发送</button>
    </div>
</body>
</html>

5.JavaScript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var stompClient = null;

function connect() {
    var socket = new SockJS('/portal/chat');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        // 订阅的地址比服务端配置的地址多了"/user"前缀,
        // 是因为SimpMessagingTemplate类中自动添加了路径前缀
        stompClient.subscribe('/user/queue/chat', function (chat) {
            console.log("chat:",chat);
            showGreeting(JSON.parse(chat.body));
        });
    });
}

function sendMsg() {
    stompClient.send("/app/chat", {},
        JSON.stringify({'content': $("#content").val(), 'to': $("#to").val()}));
}

function showGreeting(message) {
    $("#chatsContent")
        .append("<div>" + message.from + ":" + message.content + "</div>")
}

$(function () {
    connect();
    $("#send").click(function () {
        sendMsg();
    });
})