每天大家都在使用QQ等即时聊天工具,今天我们就使用Spring框架以及websocket技术在网页端简单的实现一个在线聊天的功能。

关于Spring MVC的知识这里不会详述,请大家参考Spring MVC实战入门训练

添加maven依赖

在线聊天室使需要使用到的技术或者框架包括:

  • maven作为构建工具
  • spring-boot作为后端框架
  • spring websocket作为即时消息通讯工具
  • thymeleaf作为模板引擎
  • angular作为前端框架

因此,我们首先在pom.xml中添加上述依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
</dependencies>

添加登录功能

我们首先来做一个简单的登录功能,主要目的是维护一个用户列表,这里我们不考虑用户验证或者登出等问题,因此,我们只需要一个登录的地址:

@Controller
public class LoginController {

    @Autowired private SimpMessagingTemplate messagingTemplate;
    @Autowired private ParticipantRepository participantRepository;

    private static final String LOGIN = "/app/chat.login";
    private static final String LOGOUT = "/app/chat.logout";

    @RequestMapping(value = "login", method=RequestMethod.POST)
    public String login(HttpServletRequest httpRequest, User user) throws ServletException{
        user.setTime(new Date());
        httpRequest.getSession().setAttribute("user", user);
        messagingTemplate.convertAndSend(LOGIN, user);
        if(participantRepository.getActiveSessions().
          containsKey(httpRequest.getSession().getId())){
            messagingTemplate.convertAndSend(LOGOUT, 
            participantRepository.getActiveSessions().
                get(httpRequest.getSession().getId()));
        }
        participantRepository.add(httpRequest.getSession().getId(), user);
        return "redirect:/chat";
    }

}

需要注意的是每个登录用户在登录的时候都需要往websocket的/app/chat.login地址发送登录的信息,如果以前登录过,则往发送/app/chat.logout一个登出信息,以便客户端更新用户列表。

这里使用了Spring MVC,@RequestMapping标注的用法不太清楚的话,可以参考Spring MVC快速入门

配置websocket

我们通过@EnableWebSocketMessageBroker标注打开websocket服务,并通过继承AbstractWebSocketMessageBrokerConfigurer对websocket进行配置,这里我们进行两个配置:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config)     {
        config.setApplicationDestinationPrefixes("/app");
    }
}

通过registry.addEndpoint("/ws").withSockJS();使得客户端可以通过/ws地址与后台建立websocket连接。设置config.setApplicationDestinationPrefixes("/app");/app开头的地址标识为应用地址。此时,用户可以通过/ws地址与服务器建立websocket连接,并通过websocket进行通信。

添加聊天后台

这里我们为添加两个方法:

首先是聊天页面,当用户访问/chat地址时,如果未登录,则抛出AccessDeniedException错误,我们的错误处理会将用户重定向到登录页面,如果用户已登录,则显示聊天页面开始聊天。

其次我们通过websocket的/chat.participants频道提供了在线用户列表,当用户订阅/app/chat.participants时,将会获得当前在线用户的列表。

@Controller
public class ChatController {

    @Autowired ParticipantRepository participantRepository;

    @RequestMapping(value="/chat", method=RequestMethod.GET)
    public String chatPage(HttpServletRequest request, Model model) throws AccessDeniedException{
        if(request.getSession().getAttribute("user") == null){
            throw new AccessDeniedException("login please");
        }
        User user = (User)request.getSession().getAttribute("user");
        model.addAttribute("username", user.getUsername());
        return "chat";
    }

    @SubscribeMapping("/chat.participants")
    public Collection<User> retrieveParticipants() {
        return participantRepository.getActiveSessions().values();
    }

}

添加websocket客户端

系统前端主要基于angular来实现,因此我们对基本websocket操作进行了封装,包括连接、订阅、发送消息。如果有兴趣,可以参考/src/main/resources/static/js/services.js。我们的主要逻辑都在/src/main/resources/static/js/controllers.js中,因此我们主要讲解一下这个文件。

首先我们来看看initStompClient方法,该方法与服务器建立了websocket连接,同时订阅了我们所需要用到的几个频道:

  • /app/chat.participants 该频道由服务器提供服务,订阅后,服务器会返回当前的用户列表
  • /topic/chat.login 当有用户登录,服务器会往该频道发送一个信息,客户端接收信息后将该用户添加进在线用户列表
  • /app/chat.logout 当有用户登出,服务器会会往该频道发送一个信息,客户端接收信息后将该用户移出在线用户列表
  • /app/chat.typing 当用户在进行输入时,会往该频道广播一个信息,客户端接收消息后在前端显示该用户正在编辑
  • /app/chat.message 用户发送消息时,会往该频道广播一条消息,客户端接收该消息,并展示该消息

var initStompClient = function() {
    chatSocket.init('/ws');

    chatSocket.connect(function(frame) {

        chatSocket.subscribe("/app/chat.participants", function(message) {
            $scope.participants = JSON.parse(message.body);
        });

        chatSocket.subscribe("/app/chat.login", function(message) {
            $scope.participants.unshift({
                username: JSON.parse(message.body).username, 
                typing : false});
        });

        chatSocket.subscribe("/app/chat.logout", function(message) {
            var username = JSON.parse(message.body).username;
            for(var index in $scope.participants) {
                if($scope.participants[index].username == username) {
                    $scope.participants.splice(index, 1);
                }
            }
        });

        chatSocket.subscribe("/app/chat.typing", function(message) {
            var parsed = JSON.parse(message.body);
            if(parsed.username == $scope.username) return;
            for(var index in $scope.participants) {
                var participant = $scope.participants[index];

                if(participant.username == parsed.username) {
                    $scope.participants[index].typing = parsed.typing;
                }
              } 
        });

        chatSocket.subscribe("/app/chat.message", function(message) {
            $scope.messages.unshift(JSON.parse(message.body));
        });

    }, function(error) {
        toaster.pop('error', 'Error', 'Connection error ' + error);

    });
};

向websocket广播消息

广播消息其实很简单,只需要向某个固定频道发送消息,这样所有订阅了该频道的用户都能接收到相应的信息,具体代码如下:

$scope.sendMessage = function() {
    chatSocket.send(
        "/app/chat.message", 
        {}, 
        JSON.stringify({
            message: $scope.newMessage,
            username: $scope.username
        }));
    $scope.newMessage = '';
};

进一步阅读

登录发表评论 注册

wangshang

您好,请问 ParticipantRepository 里面保存的 user 是什么时候失效的呢?是session 失效的时候吗?当session失效的时候,ParticipantRepository 会自动删除 对应的user吗?

还有一个问题:

其次我们通过websocket的/chat.participants频道提供了在线用户列表,当用户订阅/app/chat.participants时,将会获得当前在线用户的列表。

这里不是很明白,请问是 ParticipantRepository 产生修改的时候触发这个 频道的吗?

谢谢您。

xieruihaha

@hrsunpeng 代码不是评论里就有么

hrsunpeng

@Cliff 能把你做的例子给我发一份吗 ?我这几天也在做个功能,谢谢了。 QQ邮箱 354032267@qq.com

Cliff

@hrsunpeng 在线客服的话可能还涉及到聊天信息持久化的问题 你应该还需要一个数据库存储之前的聊天记录 当客服第一次订阅某个频道的时候 去数据库将相应的信息都取出来 一起返回过去 这个和chat.participants的逻辑是差不多的

Cliff

@hrsunpeng 当然可以啊 

这篇文章里说到了,我们这里实际上是通过订阅某个webscoket的channel并且往这个channel里发送消息来实现聊天的

所以多人聊天的思路也很简单,当一个用户与某个人或者多个人开始聊天的时候,后台生成一个不重复的channel的名称(例如"/app/chat.message-1"),然后前端订阅该channel就可以了

hrsunpeng

我想问下,你这个聊天的可以同时一对多的聊天,在不同的窗口吗 ?我现在在做一个在线客服系统。用到这块的技术,好多地方不是很明白,请赐教!!!如果可以的话。我在线等。扣扣354032267

Cliff

@fudeking 抱歉 之前是网站的一个bug 现在可以了:http://www.tianmaying.com/tutorial/websocket-chatroom/repo

fudeking

邮箱869062357@qq.com,谢谢

fudeking

您好,参考代码总是获取列表出错,能给我发一下源码吗?非常感谢

Toder

源代码的链接在菜单栏里就能看到啊smile @cyongk

cyongk

Cliff:《Spring websocket在线聊天室》有没有源码?能不能给我一份偶,非常感谢

Cliff

@FlatMan 源代码里有啊,地址是ParticipantRepository.java ,这里存储了sessionId对应的user

FlatMan
ParticipantRepository participantRepository // 这个是什么呀?能贴出来么 
David

Cliff 代码没改sunglasses

Cliff

David已修改sweat_smile

David

/cahrt  和  /chart  都应该是 /chat吧sweat_smile,改一下吧

反馈意见