尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java学习历程29——通信系统(2)

Java学习历程29——通信系统(2) 在上一篇博客中我们实现了一个基于控制台的群聊系统掌握了Socket和多线程的基本用法。但说实话一直用控制台聊天也太复古了——这都什么年代了好歹得有个界面吧而且只能发文字想传个文件都不行。所以这一篇我们继续往下走两步第一步给程序穿上衣服——加上Swing界面第二步给它加个技能——支持文件传输。聊天窗口分为三个区域顶部显示群聊名称和当前用户中间是消息显示区带滚动条底部是输入框和操作按钮。看起来是不是有点微信那味了下面我们就来讲讲如何实现。目录步骤一、UI界面设计1.登录界面2.聊天主界面①顶部标题栏②中央消息区③底部发送区3.登录流程与用户管理二、消息协议的升级1. 协议格式2. 私聊功能3. 文件传输功能发送端流程接收端流程服务端角色编码注意事项三、线程模型四、开发中遇到的几个坑坑一读取不完整坑二DataInputStream混用导致错位坑三中文乱码坑四窗口不居中/组件不显示补充知识1.swing常用组件2.swing常用的布局管理器3.事件监听器的简化书写①通过写匿名内部类给按钮添加事件监听器②Lambda表达式简化语法拆解完整代码1.Client类2.Server类3.ClientUser效果展示步骤一、UI界面设计在开始写代码之前我们先来设计一下布局。我们全程采用Java原生的swingUI应分为两个一个是登录界面一个是聊天主界面。1.登录界面登录界面用于让用户输入账号和密码校验通过后进入主界面。采用BoxLayout垂直排列所有组件居中显示。我们应写一个showLoginUI()的方法在主函数中调用。界面上方是账号行标签 输入框中间是密码行标签 密码框下方是登录按钮。密码框使用JPasswordField输入的内容会自动隐藏保护隐私。登录按钮点击后校验密码目前硬编码为123456。校验通过后jf.dispose()关闭登录窗口调用showClientUI()打开聊天主界面。2.聊天主界面主界面是整个聊天程序的核心我们单独写一个showClientUI()方法采用BorderLayout将窗口划分为三个区域区域组件说明北部NORTHJPanelJLabel显示群聊名称和当前用户中部CENTERJScrollPaneJTextArea消息展示区带滚动条南部SOUTHJPanel嵌套BorderLayout输入框 操作按钮①顶部标题栏一个简单的面板背景设为浅灰色用JLabel在中间显示群聊二字。②中央消息区这是最核心的区域。我们用JTextArea来显示所有聊天消息调用setEditable(false)让用户只能看不能改。当消息超出显示区域时通过JScrollPane自动出现滚动条。收到新消息时调用append()方法追加到末尾注意使用“\n”换行符将新消息追加到上一条消息的下一行。③底部发送区整体采用BorderLayout左侧是输入框JTextArea支持多行输入右侧是一个垂直排列的按钮面板GridLayout(2,1)包含发送和文件两个按钮。输入框和按钮的尺寸通过setPreferredSize()控制保持界面整齐。3.登录流程与用户管理有了界面就得有用户系统。登录成功后客户端会做两件事一是与服务端建立Socket连接二是把账号发送给服务端。服务端用ClientUser类来包装每个在线用户的信息服务端收到账号后遍历UserList找到对应Socket并存入账号这样每个客户端连接一上来服务端就知道你是谁了。二、消息协议的升级在控制台版本中消息格式很简单[长度][内容]。现在有了私聊、群聊、文件等多种类型协议必须升级。1. 协议格式重新设计后的协议如下消息类型格式私聊文字[账号长度][账号][类型1][好友名长度][好友名][消息长度][消息内容]群聊文字[账号长度][账号][类型2][消息长度][消息内容]文件消息[账号长度][账号][类型3][文件名长度][文件名][文件大小8字节][文件内容]每种消息前面都带账号这样服务端转发时接收方才知道是谁发的。类型编号是这次升级的核心。服务端收到消息后先读一个字节判断typetype 1私聊只转发给指定好友type 2群聊转发给所有在线用户除自己type 3文件按文件协议处理这种设计的好处是可扩展——以后想加新功能只需定义新的type即可。2. 私聊功能私聊通过符号识别。用户在输入框中输入好友名 消息内容客户端通过检测是否有“”区分是否为私聊消息解析出好友名和消息内容将消息类型设为1带上好友名一起发送给服务端。服务端根据好友名找到对应的Socket只转发给那个人。3. 文件传输功能文件传输是这次升级的重头戏。核心原理文件本质上就是一堆字节和发送文字没有本质区别只是文件更长。发送端流程① 点击文件按钮弹出JFileChooser选择文件② 发送消息类型3、文件名长度内容③ 发送文件大小8个字节先发高位④ 用FileInputStream边读边发8KB缓冲区注意为什么要发文件大小因为发完文件还要继续聊天连接不能断。不用大小标记结束位置接收方会把后续消息当成文件内容继续读。接收端流程按协议读文件名→读8字节拼成long→根据大小精确读文件内容→用FileOutputStream保存到本地testResourse目录。服务端角色只负责中转不关心内容是什么。收到文件数据后原样转发给所有其他客户端私聊则只转发给指定好友转发时补上发送者账号。编码注意事项文件数据用StandardCharsets.ISO_8859_1而非UTF-8。因为UTF-8是变长编码某些字节组合不合法强行转换会损坏二进制数据。ISO_8859_1是单字节编码每个字节都能无损转换。文字用UTF-8文件用ISO_8859_1。三、线程模型整个程序的线程模型如下线程数量职责监听线程1个服务端主线程持续accept()等待新连接客户端接收线程每个客户端1个服务端为每个客户端创建持续读取该客户端发来的消息消息转发在接收线程中完成收到消息后遍历在线列表转发UI主线程1个处理用户输入和界面事件UI接收线程每个客户端1个客户端独立线程持续接收服务端发来的消息文件发送线程每次发送1个发送文件时单独开线程避免阻塞UI服务端为每个客户端创建独立接收线程解决了in.read()阻塞导致其他客户端被晾在一边的问题。客户端收发分离用户可以随时输入消息同时也能随时接收并显示新消息实现真正的双向通信。四、开发中遇到的几个坑坑一读取不完整in.read(byte[])不保证一次读满。解决方案是循环读取直到读满指定长度。坑二DataInputStream混用导致错位用DataInputStream的readUTF()/readLong()虽然方便但与普通InputStream混用会导致数据错位。最终统一使用原始InputStream所有数据手动按协议读写。坑三中文乱码getBytes()和new String()不指定字符集时使用系统默认编码不同系统编码不同导致乱码。统一使用StandardCharsets.UTF_8。坑四窗口不居中/组件不显示setLocationRelativeTo(null)必须在setSize()之后调用。JTextArea不指定行数列数时默认尺寸为0改用new JTextArea(3, 20)指定初始尺寸。补充知识1.swing常用组件组件作用关键方法JFrame主窗口setSize(),setLocationRelativeTo(),setDefaultCloseOperation()JPanel面板容器用于组合布局setLayout(),setPreferredSize(),setBorder()JLabel显示文本或图片setText(),setHorizontalAlignment()JTextField单行文本输入框getText(),setText()JPasswordField密码输入框自动隐藏getPassword()返回char[]JTextArea多行文本区域append(),setEditable(),setLineWrap(),setCaretPosition()JScrollPane滚动面板包裹需要滚动的组件new JScrollPane(component)JButton按钮addActionListener(),setPreferredSize()JFileChooser文件选择对话框showOpenDialog(),getSelectedFile()2.swing常用的布局管理器布局说明适用场景BorderLayout分 NORTH/SOUTH/CENTER/EAST/WEST 五个区域整体窗口布局FlowLayout从左到右依次排列默认居中简单横向排列GridLayout(rows, cols)等大小的网格组件按顺序填充按钮面板如2行1列竖排按钮BoxLayout水平或垂直排列可配合Box使用登录界面垂直居中排列3.事件监听器的简化书写Swing采用事件监听模型用户操作点击按钮、输入文本等触发事件事件被监听器捕获后执行相应逻辑。除了单独写一个事件监听类然后创建对象以外还有一些简单的方法①通过写匿名内部类给按钮添加事件监听器sendButton.addActionListener(new ActionListener() { Override public void actionPerformed(ActionEvent e) { String msg textArea.getText(); send(msg, out, showMsg); textArea.setText(); } });②Lambda表达式简化Java 8引入了Lambda表达式当接口只有一个抽象方法时函数式接口可以大幅简化代码sendButton.addActionListener(e - { String msg textArea.getText(); send(msg, out, showMsg); textArea.setText(); });语法拆解部分含义eLambda表达式的参数编译器能根据上下文推断出它的类型是ActionEvent所以可以省略类型声明-Lambda运算符读作goes to{ ... }方法体要执行的代码单条语句还可进一步简化如果方法体只有一条语句可以省略{}和return// 单条语句 clearButton.addActionListener(e - textArea.setText()); // 多条语句必须用 {} sendButton.addActionListener(e - { String msg textArea.getText(); send(msg, out, showMsg); textArea.setText(); });Lambda表达式不但简化了代码的书写更让核心业务逻辑更加清晰不会被冗长的语法包装所淹没。现在的主流Java开发中只要是函数式接口只有一个抽象方法的接口都推荐使用Lambda表达式。完整代码1.Client类import javax.swing.*; import java.awt.*; import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets; public class Client extends JFrame { JTextField usernameText; JPasswordField passwordText; // 登录界面 public void showLoginUI() { JFrame jf new JFrame(); jf.setTitle(请输入账号密码登录多人聊天室); jf.setSize(400, 400); jf.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); jf.setLocationRelativeTo(null); // 主面板垂直排列居中 JPanel mainPanel new JPanel(); mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS)); mainPanel.setBorder(BorderFactory.createEmptyBorder(30, 30, 30, 30)); // 账号行 JPanel userPanel new JPanel(new FlowLayout(FlowLayout.CENTER)); userPanel.add(new JLabel(账号:)); usernameText new JTextField(15); userPanel.add(usernameText); mainPanel.add(userPanel); // 密码行 JPanel passwordPanel new JPanel(new FlowLayout(FlowLayout.CENTER)); passwordPanel.add(new JLabel(密码:)); passwordText new JPasswordField(15); passwordPanel.add(passwordText); mainPanel.add(passwordPanel); // 空隙 mainPanel.add(Box.createVerticalStrut(5)); // 登录按钮 JPanel loginPanel new JPanel(new FlowLayout(FlowLayout.CENTER)); JButton login new JButton(登录); loginPanel.add(login); login.addActionListener(e - { if (e.getActionCommand().equals(登录)) { String account usernameText.getText(); String password new String(passwordText.getPassword()); if (password.equals(123456)) { System.out.println(登录成功); jf.dispose(); showClientUI(account); } } }); mainPanel.add(loginPanel); jf.add(mainPanel); jf.setVisible(true); } // 主UI构造方法 public void showClientUI(String account) { setTitle(多人聊天室: account); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(450, 650); setLocationRelativeTo(null); // 顶部群聊信息区 JLabel jl new JLabel(群聊); JPanel north new JPanel(); north.setPreferredSize(new Dimension(450, 30)); north.add(jl); add(north, BorderLayout.NORTH); north.setBackground(Color.LIGHT_GRAY); // 中央聊天区 JTextArea showMsg new JTextArea(); showMsg.setEditable(false); JScrollPane scrollPane new JScrollPane(showMsg); add(scrollPane, BorderLayout.CENTER); // 底部发送信息区 JPanel SendPanel new JPanel(); SendPanel.setLayout(new BorderLayout(5, 5)); SendPanel.setPreferredSize(new Dimension(450, 100)); // 输入框左侧 JTextArea textArea new JTextArea(); textArea.setPreferredSize(new Dimension(350, 100)); SendPanel.add(textArea, BorderLayout.CENTER); // 右侧按钮面板两个按钮垂直排列 JPanel bottomPanel new JPanel(new GridLayout(2, 1, 0, 5)); JButton sendButton new JButton(发送); JButton fileButton new JButton(文件); bottomPanel.add(sendButton); bottomPanel.add(fileButton); SendPanel.add(bottomPanel, BorderLayout.EAST); add(SendPanel, BorderLayout.SOUTH); setVisible(true); Socket sc login(); InputStream in; OutputStream out; try { in sc.getInputStream(); out sc.getOutputStream(); } catch (IOException e) { throw new RuntimeException(e); } // 开始接收消息 receive(showMsg, in); // 采用匿名内部类的方式创建动作监听器类 // 发送按钮 sendButton.addActionListener(e - { String msg textArea.getText(); send(msg, out, showMsg); textArea.setText(); }); // 文件按钮 fileButton.addActionListener(e - { File file null; JFileChooser chooser new JFileChooser(); if (chooser.showOpenDialog(this) JFileChooser.APPROVE_OPTION) { file chooser.getSelectedFile(); } sendFile(file, out, showMsg); }); } public Socket login() { // 套接字 Socket sc; OutputStream out; try { sc new Socket(127.0.0.1, 54321); // 输出流 out sc.getOutputStream(); } catch (IOException e) { throw new RuntimeException(e); } String account usernameText.getText(); byte[] ac account.getBytes(StandardCharsets.UTF_8); try { out.write(ac.length); out.write(ac); out.flush(); } catch (IOException e) { throw new RuntimeException(e); } System.out.println(登录成功可以开始聊天啦); return sc; } // 接受消息 public void receive(JTextArea showMsg, InputStream in) { // 单独开一个线程接受来自服务端的消息 new Thread(() - { while (true) { try { // 读取账号 int accountLen in.read(); byte[] accountBytes new byte[accountLen]; in.read(accountBytes, 0, accountLen); String account new String(accountBytes, StandardCharsets.UTF_8); showMsg.append(account :); // 读取消息类型 int type in.read(); // 文件 if (type 3) { // 读取文件名 int nameLen in.read(); byte[] nameBytes new byte[nameLen]; in.read(nameBytes, 0, nameLen); String fileName new String(nameBytes, StandardCharsets.UTF_8); // 读取文件大小8个字节 byte[] sizeBytes new byte[8]; in.read(sizeBytes, 0, 8); long fileSize 0; for (int i 0; i 8; i) { fileSize (fileSize 8) | (sizeBytes[i] 0xFF); } // 读取文件内容 byte[] fileBytes new byte[(int) fileSize]; int read 0; while (read fileSize) { int count in.read(fileBytes, read, (int) (fileSize - read)); if (count -1) break; read count; } // 保存文件 String savePath D:\\IdeaProjects\\project\\Correspondence\\src\\main\\java\\UI\\testResourse\\ fileName; FileOutputStream fos new FileOutputStream(savePath); fos.write(fileBytes); fos.close(); // 对话框中显示 showMsg.append(account : [文件] fileName (已保存到下载目录) savePath \n); } else { // 文字 int receiveLen in.read(); byte[] receiveBytes new byte[receiveLen]; in.read(receiveBytes, 0, receiveLen); String receive new String(receiveBytes, StandardCharsets.UTF_8); showMsg.append(receive \n); } } catch (IOException e) { throw new RuntimeException(e); } } }).start(); } // 发送信息(文字 public void send(String msg, OutputStream out, JTextArea showMsg) { new Thread(() - { byte[] b msg.getBytes(StandardCharsets.UTF_8); try { char head msg.charAt(0); if (head ) { int index msg.indexOf( ); String toFriend msg.substring(1, index); String content msg.substring(index 1); byte[] toFriendBytes toFriend.getBytes(StandardCharsets.UTF_8); byte[] contentBytes content.getBytes(StandardCharsets.UTF_8); // 信息类型私聊 out.write(1); out.write(toFriendBytes.length); out.write(toFriendBytes); out.write(contentBytes.length); out.write(contentBytes); } else { // 信息类型群聊 out.write(2); out.write(b.length); out.write(b); } // 点击发送 out.flush(); showMsg.append(我: msg \n); } catch (IOException e) { throw new RuntimeException(e); } }).start(); } // 发送消息文件 public void sendFile(File file, OutputStream out, JTextArea showMsg) { new Thread(() - { try { String fileName file.getName(); byte[] fileNameBytes fileName.getBytes(StandardCharsets.UTF_8); long fileSize file.length(); // 获取文件大小 // 1. 消息类型3 文件 out.write(3); // 2. 文件名长度 文件名 out.write(fileNameBytes.length); out.write(fileNameBytes); // 3. 文件大小8个字节先发高位 for (int i 7; i 0; i--) { out.write((int) (fileSize (i * 8)) 0xFF); } // 4. 发送文件内容 FileInputStream fis new FileInputStream(file); byte[] buffer new byte[8192]; int len; while ((len fis.read(buffer)) ! -1) { out.write(buffer, 0, len); } fis.close(); out.flush(); showMsg.append(我: [文件] fileName (已发送)\n); } catch (IOException e) { e.printStackTrace(); showMsg.append(文件发送失败\n); } }).start(); } public static void main(String[] args) { Client cl new Client(); cl.showLoginUI(); } }2.Server类package UI; import javax.xml.crypto.Data; import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.ArrayList; public class Server { ServerSocket serverSocket null; // 服务端初始化 public void init() { try { serverSocket new ServerSocket(54321); System.out.println(Server Started); } catch (IOException e) { throw new RuntimeException(e); } } // 监听连接 public Socket listen() { try { return serverSocket.accept(); } catch (IOException e) { throw new RuntimeException(e); } } // 接收信息 public String[] receive(InputStream in) { String msgString; String[] information new String[4]; try { // 读取信息类型 int type in.read(); if (type 1) { // 私聊 information[1] 1; int nameLen in.read(); byte[] name new byte[nameLen]; in.read(name, 0, nameLen); information[2] new String(name, StandardCharsets.UTF_8); int len in.read(); byte[] msg new byte[len]; // 直接读取后面的所有字节存进字节数组 in.read(msg); msgString new String(msg, StandardCharsets.UTF_8); information[3] msgString; } else if (type 2) { // 群聊 information[1] 2; int len in.read(); byte[] msg new byte[len]; // 直接读取后面的所有字节存进字节数组 in.read(msg); msgString new String(msg, StandardCharsets.UTF_8); information[2] msgString; } else if (type 3) { // 文件 information[1] 3; // 读文件名 int nameLen in.read(); byte[] name new byte[nameLen]; in.read(name, 0, nameLen); information[2] new String(name, StandardCharsets.UTF_8); // 读文件大小8个字节 byte[] sizeBytes new byte[8]; in.read(sizeBytes, 0, 8); long fileSize 0; for (int i 0; i 8; i) { fileSize (fileSize 8) | (sizeBytes[i] 0xFF); } // 读文件内容 byte[] fileData new byte[(int) fileSize]; int read 0; while (read fileSize) { int count in.read(fileData, read, (int) (fileSize - read)); if (count -1) break; read count; } // 用 ISO_8859_1 存二进制数据 information[3] new String(fileData, StandardCharsets.ISO_8859_1); } } catch (IOException ex) { throw new RuntimeException(ex); } return information; } // 服务端接受到信息并转发至各个客户端一个客户端用一个线程 public void transpond(Socket socket, ArrayListClientUser UserList) { new Thread(() - { try { InputStream in socket.getInputStream(); int accountLen in.read(); byte[] accountBytes new byte[accountLen]; in.read(accountBytes); String account new String(accountBytes, StandardCharsets.UTF_8); // 匹配正确的客户端并存入其账号信息 for (ClientUser user : UserList) { if (user.socket.equals(socket) user.account null) { user.account account; break; } } while (true) { String[] information receive(in); String type information[1]; if (type.equals(1)) { String name information[2]; byte[] msgBytes information[3].getBytes(StandardCharsets.UTF_8); for (ClientUser s : UserList) { if (name.equals(s.account)) { OutputStream out s.socket.getOutputStream(); out.write(accountBytes.length); out.write(accountBytes); out.write(msgBytes.length); out.write(msgBytes); out.flush(); } } } else if (type.equals(2)) { byte[] msgBytes information[2].getBytes(StandardCharsets.UTF_8); for (ClientUser s : UserList) { if (!s.socket.equals(socket)) { OutputStream out s.socket.getOutputStream(); out.write(accountBytes.length); out.write(accountBytes); out.write(msgBytes.length); out.write(msgBytes); out.flush(); } } } else if (type.equals(3)) { String fileName information[2]; byte[] fileData information[3].getBytes(StandardCharsets.ISO_8859_1); for (ClientUser s : UserList) { if (!s.socket.equals(socket)) { OutputStream out s.socket.getOutputStream(); // 账号 out.write(accountBytes.length); out.write(accountBytes); // 类型 out.write(3); // 文件名 byte[] fnBytes fileName.getBytes(StandardCharsets.UTF_8); out.write(fnBytes.length); out.write(fnBytes); // 文件大小8个字节 long size fileData.length; for (int i 7; i 0; i--) { out.write((int)(size (i * 8)) 0xFF); } // 文件内容 out.write(fileData); out.flush(); } } } } } catch (IOException e) { throw new RuntimeException(e); } }).start(); } public static void main(String[] args) { Server server new Server(); ArrayListClientUser UserList new ArrayList(); server.init(); // 专门监听接入连接的线程 new Thread(() - { while (true) { Socket socket server.listen(); ClientUser user new ClientUser(); user.socket socket; UserList.add(user); // 接受信息同时转发 server.transpond(socket, UserList); } }).start(); } }3.ClientUserpackage UI; import java.net.Socket; public class ClientUser { String account; Socket socket; }效果展示
返回列表