期末做期末考核作业,想着基于SpringBoot整合Spring WebSocket做一个即时通讯的APP,为了学习,尽可能的融入多种框架,特别是客户端,使用了okHttp、EvenBus、FastJson等多种框架。
由于侧重点在于即时通讯的实现,所以对于数据存储方面并没有进行实现。
CSDN上关于SpringBoot整合webSocket的文章,或者说即时通讯Demo的文章并不少,但是大部份都是基于网页端的,或者太过于碎片化又或者说年久失修的,所以在开发过程中还是花了不少力气,踩了不少坑。
所以想着所以想着记录和分享一下实现的过程,给我自己还有有需要的人。
1、实现群聊功能
2、实现私聊功能
3、简单的登录功能
1、SpringBoot
2、Spring WebSocket
持久层框架(设想,未实际使用):Srping JPA 或者 myBatis
1、EvenBus
2、OkHttp
3、java-WebSocket
4、FastJson
持久层框架(设想,未实际使用):Litepal
新建一个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框架的使用,所以目录架构会比较齐全,显得有点多余。
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提交登录请求,看一下成功和失败返回的数据格式
后面回想一下,其实转发消息给客户端也应该使用这种格式,将消息类放到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),实现最终的效果。
先来看看目录结构
相对于服务端,客户端的就简单一点了。
两个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
经过时间的沉淀,相信你已经喝完了茶,依赖也导入完成了。接下来来看一下具体的代码实现。
/*
*
* 登陆界面
* */
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(前提是它存在),就说明没问题。
/*
*
* 聊天主界面
* */
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的字体颜色。
也就是实现下图这种效果
核心就是使用Html.fromHtml(String s) 方法。通过html字段还可以实现部分字段的大小不用、字体类型不同等等。
还是那句话,不懂就多敲,里面的注释也很完善了,出现问题不要慌,仔细分析问题,网上查找相关解决办法,一般你遇到的90%的问题,基本都有人遇到过了。
由于本人水平有限,也在学习阶段,对于框架的使用,逻辑的处理等方面会存在一些问题,还希望各位前辈们能指点一下。
最后,卑微大二学生在线求实习岗位、机会,广州地区有没有大佬让我抱抱大腿