基于SpringBoot框架实现的即时通讯App

一、前言

1-1为啥写这篇Blog

期末做期末考核作业,想着基于SpringBoot整合Spring WebSocket做一个即时通讯的APP,为了学习,尽可能的融入多种框架,特别是客户端,使用了okHttp、EvenBus、FastJson等多种框架。
由于侧重点在于即时通讯的实现,所以对于数据存储方面并没有进行实现。
CSDN上关于SpringBoot整合webSocket的文章,或者说即时通讯Demo的文章并不少,但是大部份都是基于网页端的,或者太过于碎片化又或者说年久失修的,所以在开发过程中还是花了不少力气,踩了不少坑。
所以想着所以想着记录和分享一下实现的过程,给我自己还有有需要的人。

1-2 需求分析、所使用的框架

需求分析

1、实现群聊功能
2、实现私聊功能
3、简单的登录功能

框架使用

服务端

1、SpringBoot
2、Spring WebSocket
持久层框架(设想,未实际使用):Srping JPA 或者 myBatis

客户端

1、EvenBus
2、OkHttp
3、java-WebSocket
4、FastJson

持久层框架(设想,未实际使用):Litepal

1-3 项目最终实现效果

登录提示

基于SpringBoot框架实现的即时通讯App_第1张图片

私聊消息发送

admin1发送消息给admin3
基于SpringBoot框架实现的即时通讯App_第2张图片

群聊消息发送

admin2发送群消息
基于SpringBoot框架实现的即时通讯App_第3张图片

下线通知以及消息发送失败提示

基于SpringBoot框架实现的即时通讯App_第4张图片

二、代码实现与分析

服务端

新建一个Spring项目(版本是2.3.0),在pom.xml文件中的dependencies区间内导入以下依赖

        <!-- SpringWebSocket -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

        <!-- fastJson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.70</version>
        </dependency>

静静等待依赖的导入,时间不会很…久,个人建议你可以先去泡个茶,最好还是改用淘宝镜像,具体修改方法自行百度,并不难。
接下来先看一下整个项目的目录结构,因为后期还是想继续拓展的,深入学习Spring框架的使用,所以目录架构会比较齐全,显得有点多余。
基于SpringBoot框架实现的即时通讯App_第5张图片
WebScoketConfig: WebSocket的配置类
一定不能漏了这个配置类,之前我就是因为少了这个,折腾了不少时间。

/*
*
* WebSocket的配置类
* */
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

LoginController: 登录的请求层(后期的注册等都可以根据这个思路来写)

/*
*
* 登录请求
* */
@RestController
public class LoginController {

    private static final Logger logger = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private LoginService loginService;

    // 通过Post提交参数
    @PostMapping("/login")
    public Result login(@RequestParam("userName") String userName,
                        @RequestParam("password") String password){

        // 验证账号的逻辑先注释掉了,因为没有做注册功能
/*        boolean isTrue = loginService.checkLogin(userName, password);
        if (isTrue){
            return ResultUtil.success();
        }else {
            return ResultUtil.error(-1, "账号或密码错误");
        }*/
        logger.info("账号" + userName + ", 密码" + password + "登录");
        // 返回登录成功
        return ResultUtil.success();
    }
}

MessageData: 消息实体类
这里复习一下IDEA自动生getter/setter/构造函数的快捷键(ctrl + ins)

public class MessageData {

    private int msgType; // 消息类型 私聊、群发

    private String fromUserId; // 发送者ID

    private String toUserId; // 接收者ID

    private String msgData; // 数据

    public MessageData() {
    }

    public int getMsgType() {
        return msgType;
    }

    public void setMsgType(int msgType) {
        this.msgType = msgType;
    }

    public String getFromUserId() {
        return fromUserId;
    }

    public void setFromUserId(String fromUserId) {
        this.fromUserId = fromUserId;
    }

    public String getToUserId() {
        return toUserId;
    }

    public void setToUserId(String toUserId) {
        this.toUserId = toUserId;
    }

    public String getMsgData() {
        return msgData;
    }

    public void setMsgData(String msgData) {
        this.msgData = msgData;
    }
}

Result: 服务器返回数据的最外层(统一返回的格式)

/*
*
* Http请求返回的最外层对象
* */
public class Result<T> {

    // 返回码
    private Integer code;

    // 返回消息
    private String msg;

    // 返回的具体内容
    private T data;


    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

LoginServeice: 登录请求的逻辑处理
暂时还没有用到,可以先不写。

/*
*
* 登录逻辑层
* */
@Service
public class LoginService {

    private String userName;
    private String password;

    public boolean checkLogin(String userName, String password){
        if (this.userName.equals(userName) && this.password.equals(password)){
            return true;
        }
        return false;
    }

}

WebScoketService: WebScoket的逻辑处理

@Component
@ServerEndpoint(value = "/websocket/{userId}")
public class WebSocketService {

    private static final Logger logger = LoggerFactory.getLogger(WebSocketService.class);

    private Session session;

    /*
     *
     * 用户唯一id
     * */
    private String userId;

    /*
     *
     * 在线人数计数
     * */
    private static int count;

    /*
     *
     * userID和Session绑定
     * 给客户端发送消息需要session
     * */

    /*
     *
     * 有连接时回调
     * */
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId) {
        this.session = session;
        this.userId = userId;
        WebSocketMapUtil.put(userId, session);
        count++;
        sendOnLine(userId, "in");
    }

    /*
     *
     * 断开连接时回调
     * */
    @OnClose
    public void onClose(@PathParam("userId") String userId) {

        /*
         *
         * 移除关闭的连接
         * */
        WebSocketMapUtil.remove(userId);
        count--;
        sendOnLine(userId, "out");
    }

    /*
     *
     * 发生错误时回调
     * */
    @OnError
    public void onError(@PathParam("userId") String userId, Throwable error) {
        logger.info(userId + "发生连接错误" + error.getMessage());
        error.printStackTrace();
    }

    /*
     *
     * 收到消息时回调
     * */
    @OnMessage
    public void onMessage(String message, @PathParam("userId") String userId) throws Exception {

        logger.info("收到来自" + userId + "的消息:" + message);

        // 将传送过来的JSON格式数据转换成Object
        ObjectMapper objectMapper = new ObjectMapper();
        MessageData messageData = objectMapper.readValue(message, MessageData.class);

        // 私聊
        if (messageData.getMsgType() == 1) {

            // 发送者的Session
            Session fromSession = WebSocketMapUtil.get(userId);
            // 接收者的Session
            Session toSession = WebSocketMapUtil.get(messageData.getToUserId());
            // 判断接收者是否在线
            if (toSession != null) {
                // 传达消息
                sendMessage(messageData, toSession);
                sendMessage(messageData, fromSession);
            } else {
                MessageData messageData_fail = new MessageData();
                messageData_fail.setFromUserId("系统提示");
                messageData_fail.setMsgData("私聊消息发送失败,对方不在线");
                sendMessage(messageData_fail, fromSession);
            }
        }
        // 群聊
        else {
            // 遍历当前所有在线人
            sendMessageAll(messageData);
            logger.info("这是一条群发消息:" + JSON.toJSONString(messageData));
        }
    }

    /*
     *
     * 发送消息
     * */
    public void sendMessage(MessageData messageData, Session session) {
        session.getAsyncRemote().sendText(JSON.toJSONString(messageData));
    }

    /*
     *
     * 群发消息
     * */
    public void sendMessageAll(MessageData messageData) {
        for (Object object : WebSocketMapUtil.getAllValues()) {
            logger.info("session" + object);
            Session session_map = (Session) object;
            session_map.getAsyncRemote().sendText(JSON.toJSONString(messageData));
        }
    }

    /*
     *
     * 向用户发送在线信息
     * sendType:上线还是下线
     * */
    public void sendOnLine(String userId, String sendType) {
        MessageData messageData = new MessageData();
        messageData.setFromUserId("系统消息");
        StringBuffer stringBuffer = new StringBuffer();
        List<String> userIdList = WebSocketMapUtil.getAllKey();
        for (int i = 0; i < userIdList.size(); i++) {
            stringBuffer.append(userIdList.get(i)).append(";");
        }
        if (sendType.equals("in")) {
            messageData.setMsgData(userId + "加入聊天室, 当前在线用户" + count + "人, 分别是" + stringBuffer.toString());
            logger.info(userId + "加入聊天室");
        }else if(sendType.equals("out")){
            messageData.setMsgData(userId + "退出聊天室, 当前在线用户" + count + "人, 分别是" + stringBuffer.toString());
            logger.info(userId + "退出聊天室");
        }
        sendMessageAll(messageData);
    }
}

ResultUtil:返回数据的工具类


public class ResultUtil {

    /*
    *
    * 请求正确返回
    * 有返回消息
    * */
    public static Result success(Object object){
        Result result = new Result();
        result.setCode(0);
        result.setMsg("success");
        result.setData(object);
        return result;
    }

    /*
    *
    * 请求成功返回
    * 无返回消息
    * */
    public static Result success(){
        Result result = new Result();
        result.setCode(0);
        result.setMsg("success");
        return result;
    }

    /*
    *
    * 请求失败返回
    * */
    public static Result error(Integer code, String msg){
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        return result;
    }
}

这里使用PoatMan提交登录请求,看一下成功和失败返回的数据格式
基于SpringBoot框架实现的即时通讯App_第6张图片
后面回想一下,其实转发消息给客户端也应该使用这种格式,将消息类放到data里发送给客户端。希望在做的同学可以试试自己修改一下。

WebScoketMapUtil: 保存在线客户端的Map操作类

public class WebSocketMapUtil {

    private static ConcurrentMap<String, Session> sessionMap = new ConcurrentHashMap<>();

    /*
    *
    * 加入连接用户map
    * */
    public static void put (String key, Session session){
        sessionMap.put(key, session);
    }

    /*
    *
    * 获取连接
    * */
    public static Session get (String key){
        return sessionMap.get(key);
    }

    /*
    *
    * 移除连接
    * */
    public static void remove (String key){
        sessionMap.remove(key);
    }

    /*
    *
    * 获取map所有值
    * */
    public static Collection getAllValues (){
        Collection values = sessionMap.values();
        return values;
    }

    /*
    *
    * 获取map所有的key
    * */
    public static List<String> getAllKey(){
        List<String> keyList = new ArrayList<>();
        for (String key : sessionMap.keySet()){
            keyList.add(key);
        }
        return keyList;
    }
}

这里着重讲一下,每次有一个客户端接入,都会重新new一个WebSocketService(可能是这个原因),所以在WebSocketService里面保存所有在线客户端的信息其实是不现实的。这时候就需要一个单独的Map操作类,保存信息。因为我在做的时候一开始是在WebSocketService里面建立一个Map保存信息但是发现上一个客户端的信息在下一个客户端接入的时候,就会出问题,后面取了一下保存的值,发现上一个客户端的Value变成了null,输出Map的Size只有1。

至此,简单的服务端就算搭建完成了,运行起来后,没报错就差不多了,可以使用PostMan访问一下登录请求,如果能正常返回数据 ,就OK了。

客户端

接下来实现一下客户端(Android),实现最终的效果。
先来看看目录结构
基于SpringBoot框架实现的即时通讯App_第7张图片
相对于服务端,客户端的就简单一点了。
两个Activity,一个是登录界面(LoginActivity),还有一个就是聊天界面(MainActivity)。
bean下的两个实体类和服务端一样。
这里需要注意的是,记得把程序入口改为LoginActivity,很多刚刚上手Android的同学可能会忽略这个问题。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.mychattool">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".activitys.LoginActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".activitys.MainActivity">
        </activity>
    </application>

</manifest>

intent-filter放到LoginActivity的区间里。值得注意的是,程序入口有且只有一个。
接下来是依赖的导入,又可以泡上一壶好茶,慢慢等待了。

    // javaWebSocket
    implementation 'org.java-websocket:Java-WebSocket:1.5.1'
    // Evenbus
    implementation 'org.greenrobot:eventbus:3.2.0'
    // fastJson
    implementation 'com.alibaba:fastjson:1.1.71.android'
    // okHttp
    implementation 'com.squareup.okhttp3:okhttp:4.7.2'

或许你看到这篇文章的时候已经是有一段时间了,框架版本也随之更新,如果不能正常使用,我建议你前往github上看一下最新的版本还有相关使用方法。
框架地址
Java-WebSocket
fastjson
EvenBus
okhttp
经过时间的沉淀,相信你已经喝完了茶,依赖也导入完成了。接下来来看一下具体的代码实现。

LoginActivity

布局样式效果(代码就不贴了,没什么技术难度)
基于SpringBoot框架实现的即时通讯App_第8张图片
逻辑代码

/*
*
* 登陆界面
* */
public class LoginActivity extends AppCompatActivity {

    private static final String TAG = "SuperYang";

    /*
    *
    * 服务器地址(模拟器上使用127.0.0.1不能正常连接,需要拿到本机的IP地址)
    * 进入命令行,输入ipconfig即可看到本机的IP地址信息
    * */
    private static final String url_login = "http://你的IP地址:8080/login";

    /*
    *
    * 声明控件
    * */
    private EditText et_userName, et_password;

    /*
    *
    * 账号密码
    * */
    private String userName = "";
    private String password = "";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        /*
        *
        * 注册EvenBus
        * */
        EventBus.getDefault().register(this);
        initView();
    }

    /*
    *
    * 控件绑定
    * */
    private void initView(){
        et_userName = findViewById(R.id.et_username);
        et_password = findViewById(R.id.et_password);
    }

    /*
    *
    * 登录监听
    * */
    public void doLogin(View view) {
        userName = et_userName.getText().toString();
        password = et_password.getText().toString();
        loginPost();
    }

    /*
    *
    * post请求登录
    * */
    public void loginPost(){

        OkHttpClient client = new OkHttpClient();

        // 提交参数
        FormBody body = new FormBody.Builder()
                .add("userName",userName)
                .add("password",password)
                .build();

        // 提交请求
        final Request request = new Request.Builder()
                .url(url_login)
                .post(body)
                .build();
        Call call = client.newCall(request);
        // okhttp异步请求
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                //...
            }

            @Override
            public void onResponse(Call call, Response response) throws IOException {
                String result = response.body().string();
                Log.i(TAG, "onResponse: " + result);
                if( response.isSuccessful()){
                    // 通过FastJson来对返回的JSON数据对象化处理
                    Result result_obj = JSON.parseObject(result, Result.class);
                    //处理UI需要切换到UI线程处理
                    // EvenBus发送事件
                    EventBus.getDefault().post(result_obj.getCode());
                }
            }
        });
    }

    /*
    *
    * 登录后的UI处理
    * 通过EvenBus事件分发来处理
    * threadMode 处理该事件的线程环境
    * */
    @Subscribe(threadMode = ThreadMode.MAIN)
    public void loginUI (Integer resultCode){
        /*
        *
        * 如果返回的code为成功(0), 带参跳转MainActivity
        * */
        if (resultCode == 0){
            Intent intent_main = new Intent(this, MainActivity.class);
            intent_main.putExtra("userName", userName);
            startActivity(intent_main);
        }else {
            Toast.makeText(this, "账号或密码错误", Toast.LENGTH_SHORT).show();
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();

        /*
        *
        * 注销EvenBus
        * */
        EventBus.getDefault().unregister(this);
    }
}

上面代码主要是感受到了EvenBus的部分快感,但是它还有更多的快感体验等待你去挖掘,例如类似全局广播事件。你可以在ActivityOne里Post,ActivityTwo、ActivityThree…接收这个事件。

实现完上面的代码,就可以先运行一下,开启你的服务端,输入账号密码点击登录,如果能正常跳转MainActivity(前提是它存在),就说明没问题。

MainActivity

界面布局
基于SpringBoot框架实现的即时通讯App_第9张图片
逻辑代码

/*
*
* 聊天主界面
* */
public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";

    private String url = "ws://169.254.52.119:8080/websocket/";

    private TextView tv_msg;
    private EditText et_msg, et_toUser;
    private Button btn_sendMsg;

    private MyWebSocket myWebSocketClient;
    private String userId = "";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Intent intent = this.getIntent();
        userId = intent.getStringExtra("userName");
        // 注册EventBus事件
        EventBus.getDefault().register(this);

        initView();
        connServer(); // 登录成功后开始连接服务器
    }

    /*
    *
    * 绑定控件
    * */
    private void initView(){

        tv_msg = findViewById(R.id.tv_msg);
        // 实现滚动条效果
        tv_msg.setMovementMethod(ScrollingMovementMethod.getInstance());

        et_toUser = findViewById(R.id.et_toUser);
        et_msg = findViewById(R.id.et_msg);
        btn_sendMsg = findViewById(R.id.btn_sendMsg);
    }

    /*
    *
    * 发送消息
    * */
    public void sendMsg(View view) {

        MessageData messageData = new MessageData();
        String toUser = et_toUser.getText().toString();

        // 消息类型
        if ( !toUser.equals("") ){
            messageData.setMsgType(1);
            messageData.setToUserId(toUser);
        }else {
            messageData.setMsgType(2);
        }

        messageData.setFromUserId(userId);
        messageData.setMsgData(et_msg.getText().toString());

        // 转化成JSON格式提交
        myWebSocketClient.send(JSON.toJSONString(messageData));
        Log.i(TAG, "sendMsg: 发送消息" + JSON.toJSONString(messageData));
    }


    /*
     *
     *  连接服务器
     *  可以使用EvenBus代替runnable
     * */
    public void connServer(){

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    myWebSocketClient = new MyWebSocket(url + userId);
                    if (myWebSocketClient.connectBlocking()) {
                        Log.i(TAG, "run: 连接服务器成功");
                    } else {
                        Log.i(TAG, "run: 连接服务器失败");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (URISyntaxException e) {
                    e.printStackTrace();
                }
            }
        };
        runnable.run();
    }


    /*
    *
    * 将消息加入TextView
    * */
    private void addTextView(String s){
        tv_msg.append(Html.fromHtml(s));
        tv_msg.append("\n");
    }


    /*
    *
    * 客户端的WebSocket类
    * */
    public class MyWebSocket extends WebSocketClient {

        private static final String TAG = "MyWebSocket";

        /*
         *
         * url:"ws://服务器地址:端口号/websocket"
         * */
        public MyWebSocket(String url) throws URISyntaxException {
            super(new URI(url));
        }

        /*
         *
         * 打开webSocket时回调
         * */
        @Override
        public void onOpen(ServerHandshake serverHandshake) {
            addTextView(toHtmlString("您已进入聊天室", "#FFFFFF"));
            Log.i(TAG, "onOpen: 打开webSocket连接");
        }


        /*
         *
         * 接收到消息时回调
         * */
        @Override
        public void onMessage(String s) {
            Log.i(TAG, "收到消息" + s);
            MessageData messageData = JSON.parseObject(s, MessageData.class);
            EventBus.getDefault().post(messageData);
        }

        /*
         *
         * 断开连接时回调
         * */
        @Override
        public void onClose(int i, String s, boolean b) {
            Log.i(TAG, "断开webSocket连接");
            try {
                myWebSocketClient.closeBlocking();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        /*
         *
         * 出现异常时回调
         * */
        @Override
        public void onError(Exception e) {

        }
    }

    /*
    *
    * EvenBus接收事件
    * */
    @Subscribe(threadMode = ThreadMode.MAIN)
    public void updateMsgView(MessageData messageData){
        addTextView(toHtmlString(messageData.getFromUserId(), "#0000FF"));
        addTextView(toHtmlString(messageData.getMsgData(), "#000000"));
    }

    /*
    *
    * 将字符串转成html表达, 实现颜色变化
    * */
    public String toHtmlString(String s, String color){
        Log.i(TAG, "+ "\"" + color + "\"" + ">" + s + "");
        return "+ "\"" + color + "\"" + ">" + s + "";
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 解除EventBus注册
        EventBus.getDefault().unregister(this);
        try {
            myWebSocketClient.closeBlocking();
            Log.i(TAG, "run: 断开服务器成功");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

这里有一个很有趣的小东西,也就是实现TextView里部分字段颜色的变化,而不是改变整个TextView的字体颜色。
也就是实现下图这种效果
基于SpringBoot框架实现的即时通讯App_第10张图片
核心就是使用Html.fromHtml(String s) 方法。通过html字段还可以实现部分字段的大小不用、字体类型不同等等。

三、结语

还是那句话,不懂就多敲,里面的注释也很完善了,出现问题不要慌,仔细分析问题,网上查找相关解决办法,一般你遇到的90%的问题,基本都有人遇到过了。
由于本人水平有限,也在学习阶段,对于框架的使用,逻辑的处理等方面会存在一些问题,还希望各位前辈们能指点一下。
最后,卑微大二学生在线求实习岗位、机会,广州地区有没有大佬让我抱抱大腿
基于SpringBoot框架实现的即时通讯App_第11张图片

你可能感兴趣的:(java,android,spring,boot,websocket,即时通信)