认真工作不叫做赚钱,那叫做用劳动换取报酬,上班摸鱼才是真的赚钱。
即时通讯工具
如果上班有空闲时间,最喜欢做的事情自然是和熟悉的朋友一起聊聊天,互相吐槽工作中遇到的人和事,缓解工作的压力。
如果直接在桌面上打开 QQ 或者微信,那目标无疑是巨大的,QQ 和微信的桌面客户端明晃晃地占据整个电脑桌面,只要有同事或者领导从你身边经过,或是在你后面看一眼,就立刻能够知道你在上班摸鱼,那场面不亚于公开处刑…… (@_@)
领导:看来工作还是不饱和啊 ┑( ̄Д  ̄)┍
针对这种情况,技术人自然不甘落后,总是可以想出各种方法躲避同事和领导发现你在上班摸鱼 ≡ω≡
思量再三,最终还是放弃了 IDEA 的各种插件,转而决定还是自己手写一款简易的即时通信工具。
既然要自己动手,那自然也要先对这款即时通讯工具做个简单的规划。
- 这款即时通讯工具分为客户端和服务端的,每个用户可以使用客户端进行即时通讯。
- 通讯工具尽可能简单,只依赖于 JDK,即完全使用 Java 网络编程功能实现,不依赖其他的第三方库。
- 通讯工具不需要桌面,使用 Java 自带的 Scanner 控制台输入即可。
这样一款基于 Java 网络编程的即时通讯工具,只要在 IDEA 运行客户端代码,即可在控制台与其他朋友快乐地聊天。只要不是同事或者领导贴着你的电脑屏幕观看,他绝对想不到你是在使用 IDEA 上班摸鱼聊天。
客户端
客户端是给使用这款即时通讯工具的用户使用的,从安全和用户体验的角度上来说,客户端应该尽可能精简,只负责发送和接受数据即可。
因为这是一款即时通讯工具,客户端需要做的有两件事:
- 监听客户端的输入和发送。
- 监听服务端发送过来的消息。
因为我们使用 JDK 自带的 Scanner 类来进行客户端的输入,而这个输入是一个阻塞的操作,所以我们需要创建一条额外的线程来进行服务端的监听工作。
客户端需要两条用户线程:
- main 线程用来监听客户端的输入和发送。
- 另外创建一条线程用来监听服务端的消息发送。
思路已经设计好了,可以使用代码来实现了:
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
|
public class ChatClient {
public static void main(String[] args) { try (Socket socket = new Socket("127.0.0.1", 12345)) { new Thread(() -> readMsg(socket)).start(); OutputStream os = socket.getOutputStream(); Scanner scanner = new Scanner(System.in); System.out.println("请输入您的聊天室昵称:"); while (true) { String chat = scanner.next(); System.out.println("---------------------------"); os.write(chat.getBytes()); } } catch (Exception e) { System.out.println("【系统消息】聊天室炸了,BUG之神降临了"); e.printStackTrace(); System.exit(0); } }
private static void readMsg(Socket socket) { try { while (true) { InputStream is = socket.getInputStream(); byte[] bytes = new byte[1024]; int len = is.read(bytes); System.out.println(new String(bytes, 0, len)); } } catch (Exception e) { System.out.println("【系统消息】你已退出聊天室,开始认真工作吧"); System.exit(0); } }
}
|
服务端
服务端的设计比客户端要困难很多,为了便于开发和理解,我直接使用了 Java 阻塞式的网络 IO 来进行实现,即每一个客户端连接都创建一个线程来进行处理。
这种阻塞式的网络 IO 的好处在于便于理解和开发,而缺点也非常明显,因为这是一个通讯工具,即每一个链接都是长链接。
即每个客户端用户链接服务端后,都会在服务端专门有一个线程处理这个客户端相关的网络 IO 操作。如果用户量少的情况下还比较好,但是用户一旦多了起来,服务端将会创建 N 多个线程,而且在客户端不主动断开的情况下,服务器这些线程会一直占用服务器资源,服务器将会消费非常大的资源,而且很容易崩溃。
基于这种情况,我后面也实现了一个 Java NIO 版本的客户端和服务端,在文章末尾也会一起附上源码。
我将服务端的操作分为两个步骤:
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 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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
|
public class ChatServer {
public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(12345); new Thread(() -> start(server)).start(); CHAT_CFG_RELOAD_PASSWORD = UUID.randomUUID().toString(); logInfo("【系统消息】聊天室配置加载密钥:" + CHAT_CFG_RELOAD_PASSWORD); reloadChatCfg(args.length == 1 ? args[0] : null, null); logInfo("【系统消息】聊天室启动成功了!"); }
private static void start(ServerSocket server) { try { while (true) { ChatSocket chatSocket = connection(server); login(chatSocket); } } catch (Exception e) { logInfo("【系统消息】聊天室发生了异常……"); e.printStackTrace(); } finally { logInfo("【系统消息】正在关闭聊天室资源……"); close(server); } }
private static ChatSocket connection(ServerSocket server) throws IOException { Socket socket = server.accept(); ChatSocket chatSocket = new ChatSocket(socket); userDB.add(chatSocket); sendMsgToUser(socket, "============================\n" + "1、本聊天室仅为娱乐,请勿在该聊天室内谈论敏感内容,比如涉政,涉黄,账号密码等等!\n" + "2、聊天室内容明文传输,聊天信息泄露本聊天室概不负责!\n" + "3、本聊天室内容后台不做任何存储,聊天信息如果需要请自行保留!\n" + "4、最终解释权归本聊天室所有!\n" + "============================"); return chatSocket; }
private static final List<ChatSocket> userDB = new LinkedList<>(); }
|
2、登录
服务端获取到客户端请求后,将 Socket
包装为我们自定义的 ChatSocket
,便于我们进行登录操作。
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
|
private static void login(ChatSocket chatSocket) { new Thread(() -> { Socket socket = chatSocket.getSocket(); String username = null; try { InputStream is = socket.getInputStream(); byte[] bytes = new byte[1024]; int len = readMsg(is, bytes); if (len == -1) { logout(chatSocket); return; } username = new String(bytes, 0, len); chatSocket.setUsername(username); if (CHAT_CFG_RELOAD_PASSWORD.equals(username)) { reloadChatCfg(is, bytes, socket); return; } loginTip(username, socket); robotWelcome(username); while (true) { len = readMsg(is, bytes); if (len == -1) { logout(chatSocket); return; } String msg = new String(bytes, 0, len); sendMsgToOtherUser(username, socket, msg); randomRobotReply(msg); } } catch (IOException e) { try { logout(chatSocket); } catch (Exception ex) { remove(socket); ex.printStackTrace(); } e.printStackTrace(); } }).start(); }
|
3、其他方法
3.1、读取客户端的消息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
private static int readMsg(InputStream is, byte[] bytes) { int len; try { len = is.read(bytes); } catch (Exception e) { return -1; } return len; }
|
3.2、给客户端发送消息
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
|
private static void sendSysMsg(String msg) throws IOException { for (ChatSocket chatSocket : userDB) { String sysMsg = getCurrentTime() + "\n" + msg + "\n" + chatSeparate; Socket socket = chatSocket.getSocket(); sendMsgToUser(socket, sysMsg); } }
private static void sendMsgToOtherUser(String username, Socket self, String msg) throws IOException { for (ChatSocket chatSocket : userDB) { Socket socket = chatSocket.getSocket(); if (socket.equals(self)) { continue; } String sendMsg = "(" + username + ") " + getCurrentTime() + "\n" + msg + "\n" + chatSeparate; sendMsgToUser(socket, sendMsg); } }
private static void sendMsgToUser(Socket socket, String sendMsg) throws IOException { OutputStream os = socket.getOutputStream(); os.write(sendMsg.getBytes()); }
|
3.3、日志记录
虽然服务端不需要记录用户的聊天信息,但是还是需要记录一些服务器的日志信息。
1 2 3 4 5 6 7 8
|
private static void logInfo(String message) { System.out.println(getCurrentDateTime() + " " + message); }
|
3.4、工具集合
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 54 55 56 57 58 59 60 61 62 63 64
|
private static List<String> getLoginUsernames() { return userDB.stream().map(ChatSocket::getUsername).filter(Objects::nonNull).collect(Collectors.toList()); }
private static boolean isNight() { Calendar calendar = Calendar.getInstance(); int hour = calendar.get(Calendar.HOUR_OF_DAY); return hour >= 23 || hour <= 4; }
private static final SimpleDateFormat timeSdf = new SimpleDateFormat("HH:mm:ss"); private static final SimpleDateFormat DateTimeSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static synchronized String getCurrentTime() { return timeSdf.format(new Date()); }
private static synchronized String getCurrentDateTime() { return DateTimeSdf.format(new Date()); }
private static boolean isEmpty(String string) { return string == null || string.length() == 0; }
private static boolean isNotEmpty(String string) { return !isEmpty(string); }
|
3.5、ChatSocket
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
|
private static class ChatSocket {
private final Socket socket;
private String username;
public ChatSocket(Socket socket) { this.socket = socket; }
public Socket getSocket() { return socket; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; } }
|
服务端部署
为了方便在服务器上运行服务端代码,我还特意写了一个 Shell
脚本用来处理服务端代码的运行、停止、重启、查找等操作。
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| CHAT_SERVER_DIR=/usr/app/chat CHAT_SERVER=ChatServer CHAT_LOG_FILE=${CHAT_SERVER_DIR}/chat.log # 聊天室启动参数 CHAT_CFG=${2}
help() { echo "==================" echo "start 启动服务" echo "stop 停止服务" echo "restart 重启服务" echo "find 查找服务" echo "help 帮助" echo "==================" }
start() { javac -encoding UTF-8 ${CHAT_SERVER}\.java nohup java -Dfile.encoding=UTF-8 ${CHAT_SERVER} ${CHAT_CFG} >>${CHAT_LOG_FILE} 2>&1 & echo "服务${CHAT_SERVER}已启动" }
stop() { PID=$(ps -ef | grep java | grep ChatServer | awk '{print $2}') if [ "${PID}" == "" ] then echo "服务${CHAT_SERVER}已停止" else kill ${PID} echo "服务${CHAT_SERVER}已停止" fi }
restart() { stop sleep 3 start echo "服务${CHAT_SERVER}已重启" }
find() { PID=$(ps -ef | grep java | grep ChatServer | awk '{print $2}') if [ "${PID}" == "" ] then echo "服务${CHAT_SERVER}已停止" else echo "服务${CHAT_SERVER}正在运行:PID=${PID}" fi }
case ${1} in "") echo "=== 参数错误 ===" ;; start) start ;; stop) stop ;; restart) restart ;; find) find ;; *) help ;; esac
exit 0
|
优点与缺点
优点
- 简单便捷,无论是客户端还是服务端,都只依赖了 JDK 的环境,没有任何第三方依赖,客户端的代码只在复制到有 JDK 环境的电脑上即可运行,方便快捷。
- 足够隐蔽,客户端在 CMD 或者 IDEA 环境下都可以运行,这样你身边的同事只要不仔细观察你的电脑屏幕,绝对想不到你是在和朋友聊天,只以为你是在认真工作。
缺点
- 服务端基于阻塞式网络 IO 开发,服务端只能够承受有限个的客户端链接。(Java NIO 版本可以解决这个缺点)
- 太过简陋,因为只是单纯地进行网络 IO 的写入和读取,所以对于一些复杂的网络环境问题都没有进行处理,比如网络黏包的问题,在客户端连接较多的情况下,可能会发生网络黏包的问题,导致一些消息粘黏在一起,发送给客户端。
- 需要一个服务器,因为这款即使通讯工具是 CS 模式,需要一个服务器运行服务端代码。
最后
虽然这款即时通讯工具确实还不够完善,但是如果只是用于几个朋友之间简单地进行聊天,这款即时通讯工具还是非常给力的。
这款即时通讯工具的源代码已经被托管到了 GitHub 上,同时还附带了这款即时通讯工具的 Java NIO 版本,有兴趣的同学的可以访问我的 GitHub 下载源码。
GitHub:https://github.com/herenpeng/chat.git
同时,我还在 Gitee 上提供了仓库镜像。
Gitee:https://gitee.com/herenpeng/chat.git
如果你喜欢这款即时通讯工具,希望各位同学可以给我的 GitHub 或者 Gitee 仓库点一个 Star
,非常感谢!