Chat System
项目已经放到 GitHub 了,想看看源码欢迎点个 star (> _ <)
项目地址
xiao-luo17/Mini-Whisper-Peer: JavaFX + Socket 基于P2P的即时聊天小工具(客户端)
xiao-luo17/Mini-Whisper-Server: JavaFX + Socket 基于P2P的即时聊天小工具(提供登录的Server) (github.com)
项目需求
项目分为服务端和客户端,服务端只有一个,为每个客户端提供一个共享的用户目录和在线用户列表,每一个客户对等方都需要接入服务器来表明自己的身份,其他用户才能根据服务器返回的IP进行连接请求。(其实更纯粹的P2P也可以每一个客户都是客户也是服务器,每一个终端都缓存已知的数据,当本地找不到就去请求其他对等方的列表来更新IP地址列表的缓存)
对于服务端:
- 实现注册功能,对收到的来自客户端注册的数据进行存储。例如姓名、密码。
- 为每一个接入的客户端提供所有注册用户的IP地址列表,以便客户端实现P2P通信。
- 要能够处理多个客户端的注册请求,为每一个客户端提供在线用户列表,使客户端能自由选择自己想要通信的对象
- 建立用户名到密码的映射和登录校验,注册后的用户要通过密码和IP登录
对于客户端:
- 实现注册功能和注册界面,提取用户的输入并封装成请求发送给服务端,服务端要存储这部分的数据
- 实现登录功能和登录界面,发给服务端校验来验证登录
- 由于一个客户端要能和多个对等方通信,客户端要跑一个监听线程,接收到一个请求后要重新创建监听去准备下一个对等方的连接
- 在登录之后要发出请求,接收到服务端发来的响应信息,之后写到Javafx界面的列表上。列表包括所有注册用户和他们的在线情况,点击在线用户要能对这个用户发出聊天请求。对等方收到聊天请求后有弹窗提醒,点击确定后建立连接。
项目基础
基于 P2P 和 socket 编程的一个即时聊天小工具,基本上是使用 Java 编写的。
用到的一些东西
- 界面采用了 Javafx ,用起来还是比较方便的,可以用css去美化比较方便。有关这个库的详情请参见文档。快速访问==>
JavaFX China
- 一些多线程的思路,还有 socket 的使用,再加一些进程间通信的方法。
设计思路
- 首先需要一个客户端和公共的服务端,同时客户端要具备服务端的监听功能,在P2P的条件下,它既是客户端也是服务器。
- 服务端要能够处理多个客户端的请求,为每一个客户端提供在线用户列表,处理多个客户端的注册和登录,已经登录的在线用户可以通过服务端子线程保持和服务端的通信
- 由于客户端和服务端直接发送的消息种类很多,可以创建一个请求类和一个相应类去封装这些请求,在每个类的头部加一个自己定义的请求响应代码,可以标识发送数据的类型,以便两端进行解析。
- 一个客户端需要同时与其他多个客户端进行聊天,可以使用一个UDP线程去处理这些数据报,只要定义好数据报的内容和格式,也可以用一个端口去处理多个客户端的聊天。
服务端
主进程和服务器监听线程
服务端要能够处理多个客户端的请求,为每一个客户端提供在线用户列表,处理多个客户端的注册和登录,已经登录的在线用户可以通过服务端子线程保持和服务端的通信
P2PServer
P2PServer是服务器的主进程,也是程序的入口。运行后会进入一个while创建ServerSocket和ServerThread类的循环,ServerSocket监听客户端发来的连接,如果监听到了一条连接,就新建一个ServerThread子线程,专门处理来自这条连接的请求。之后ServerSocket重新进行监听,等待下一个请求来时创建子线程处理。
程序结束退出的时候还有个保存注册用户的操作,用doShutDownWork方法调用Runtime去监听,退出的时候就保存用户的名字和密码到本地,开始运行的时候读出来用作登录校验
保存数据就是目录里有写的那个SaveData类,保存到data.txt里
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
| public class P2PServer { public static final int PORT = 8000; public static final int MAX_QUEUE_LENGTH = 100;
public void start() { try { ServerSocket serverSocket = new ServerSocket(PORT, MAX_QUEUE_LENGTH); System.out.println("*******服务器已经启动*******"); while (true) { Socket socket = serverSocket.accept(); System.out.println("[系统消息] 已接收到客户:" + socket.getInetAddress()); ServerThread serverThread = new ServerThread(socket);
serverThread.start(); } } catch (Exception e) { e.printStackTrace(); } }
private void doShutDownWork() { Runtime.getRuntime().addShutdownHook(new Thread(() -> { MapToTxT(registerPassword); })); }
public static void main(String[] args) { P2PServer ms = new P2PServer(); ms.doShutDownWork(); registerPassword = TxTToMap(); ms.start(); } }
|
ServerThread
ServerThread是服务端主进程创建的子线程,用来一对一地处理客户端的请求(因为客户端那边有刷新在线用户列表的事件,所以还得连着刷新)。继承Runnable接口重写run方法,run里面就循环跑请求监听的一些方法:接收、解析、响应。这里不知道什么是线程的可以先学习下。
线程start方法初始化线程类里的一些属性,进入准备阶段
因为要用Socket连接,所以这个类的构造函数要接收一个客户端的套接字作为参数
线程run方法循环监听请求和发送响应
- receiveRequest()方法就负责接收消息,也可以和解析写在一起。
- parseRequest()方法解析请求request对象,先是看看请求类型,然后对每一种类型的请求做不同的操作,然后把要返回的数据封装成response对象。
- 请求结束了自然就是响应,sendResponse()方法发送response对象到客户端,response对象已经在上一个解析的方法里封装好了。
- 线程run方法里的循环通过一个keepListening属性来控制,当parseRequest()方法解析到客户端的退出登录请求时调用stop()方法,同时将keepListening置为false,stop关闭socket流,线程退出。
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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
| public class ServerThread implements Runnable { private Socket socket; private ObjectInputStream ois; private ObjectOutputStream oos; private Thread serverChild;
private static Hashtable<String, InetSocketAddress> registerMap = new Hashtable<>();
public static Hashtable<String, String> registerPassword = new Hashtable<>(); private Request request; private Response response; private boolean keepListening = true;
public ServerThread(Socket socket) { this.socket = socket; }
public synchronized void start() { if (serverChild == null) { try { ois = new ObjectInputStream(socket.getInputStream()); oos = new ObjectOutputStream(socket.getOutputStream()); serverChild = new Thread(this); serverChild.start(); } catch (IOException e) { e.printStackTrace(); } } }
public synchronized void stop() { if (serverChild != null) { try { serverChild.interrupt(); serverChild = null; ois.close(); oos.close(); socket.close(); } catch (IOException e) { e.printStackTrace(); } } }
public void run() { try { while (keepListening) { receiveRequest(); parseRequest(); sendResponse(); } stop(); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (IOException e) { stop(); System.err.println("[系统消息] 客户端登录异常... 客户端中断连接..."); } }
private void receiveRequest() throws IOException, ClassNotFoundException { request = (Request) ois.readObject(); }
private void parseRequest() { if (request == null) return; response = null; int requestType = request.getRequestType(); String registerName = request.getRegisterName(); if (requestType != 1 && !isRegister(registerName)) { response = new Response(1, registerName + "你还未注册"); return; } switch (requestType) { case REGISTER_EXIT: if (isRegister(registerName)) { response = new Response(STRING_TYPE, "|" + registerName + "|" + "已被其他人使用,请使用其他名字注册"); break; } registerPassword.put(registerName, request.getPassword()); response = new Response(STRING_TYPE, registerName + ",你已经注册成功"); System.out.println("[系统消息] |" + registerName + "| 注册成功..."); break; case SIGN_IN: if(registerMap.containsKey(registerName)){ response = new Response(STRING_TYPE, registerName + ",该账号已经在线"); System.out.println("[系统消息] |" + registerName + "| 该在线账号试图重复登录..."); break; } if (request.getPassword().equals(registerPassword.get(registerName))) { registerMap.put(registerName, new InetSocketAddress(socket.getInetAddress(), request.getUDPPort())); response = new Response(STRING_TYPE, registerName + ",你已经登录成功"); System.out.println("[系统消息] |" + registerName + "| 登录成功..."); } else { response = new Response(STRING_TYPE, registerName + ",你的密码错误"); System.out.println("[系统消息] |" + registerName + "| 登录密码错误..."); break; } break; case GET_REGISTER_MAP: Vector<String> registerList = new Vector<>(); for (String key : registerMap.keySet()) { registerList.addElement(key); } Vector<String> registerAll = new Vector<>(); for (String key : registerPassword.keySet()) { registerAll.addElement(key); } response = new Response(VECTOR_TYPE, registerList, registerAll); System.out.println("[系统消息] |" + registerName + "| 成功请求用户列表..."); break; case GET_OTHER_ADDRESS: String chatRegisterName = request.getChatRegisterName(); InetSocketAddress chatP2PEndAddress = registerMap.get(chatRegisterName); response = new Response(IP_ADDRESS_TYPE, chatP2PEndAddress); System.out.println("[系统消息] |" + registerName + "| 请求 |" + chatRegisterName + "| 的IP和UDP端口号"); break; case EXIT: registerMap.remove(registerName); response = new Response(STRING_TYPE, registerName + ",你已经从服务器退出!"); keepListening = false; System.out.println("[系统消息] |" + registerName + "| 从在线列表退出..."); } }
private boolean isRegister(String name) { return name != null && ServerThread.registerPassword.get(name) != null; }
private void sendResponse() throws IOException { if (response != null) { oos.writeObject(response); } }
}
|
请求类和响应类
由于客户端和服务端直接发送的消息种类很多,可以创建一个请求类和一个相应类去封装这些请求,在每个类的头部加一个自己定义的请求响应代码,可以标识发送数据的类型,以便两端进行解析。这样就不必再去管该发送什么,另一边又要怎么收了,直接一种request发过去就没事了。
请求类封装了每次请求方需要的数据类型和数据内容,响应类封装了响应方传输的数据类型
请求类和响应类都有一个 int 型的参数标识请求响应类型,方便两端解析报文内容
Request |
请求报文 |
requestType |
请求类型 |
registerName |
注册名 |
password |
密码 |
port |
用户端端口号 |
chatRegisterName |
请求另一个在线用户的名字 |
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
| public class Request implements Serializable { private int requestType; private String registerName; private String password; private int UDPPort; private String chatRegisterName;
public Request(int requestType,String registerName){ this.requestType=requestType; this.registerName=registerName; } public Request(int requestType,String registerName, String password, int UDPPort){ this(requestType,registerName); this.UDPPort=UDPPort; } public Request(int requestType,String registerName, String chatRegisterName){ this(requestType,registerName); this.chatRegisterName=chatRegisterName; }
public int getRequestType() { return requestType; }
public String getRegisterName() { return registerName; }
public int getUDPPort() { return UDPPort; }
public String getChatRegisterName() { return chatRegisterName; }
public String getPassword() { return password; } }
|
Respons |
响应报文 |
responseType |
响应类型 |
message |
响应String消息 |
allRegisterOnline |
响应Vector消息 |
allRegisterDone |
响应Vector消息 |
chatP2PEndAddress |
响应IPAddress消息 |
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
| public class Response implements Serializable { private int responseType; private String message; private Vector<String> allRegisterOnline; private Vector<String> allRegisterDone; private InetSocketAddress chatP2PEndAddress; public Response(int responseType){ this.responseType=responseType; } public Response(int responseType, String message) { this.responseType = responseType; this.message = message; }
public Response(int responseType, Vector<String> allNameOfRegister, Vector<String> allRegisterDone) { this(responseType); this.allRegisterOnline = allNameOfRegister; this.allRegisterDone = allRegisterDone; }
public Response(int responseType, InetSocketAddress chatP2PEndAddress) { this(responseType); this.chatP2PEndAddress = chatP2PEndAddress; }
public int getResponseType() { return responseType; }
public String getMessage() { return message; }
public Vector<String> getAllRegisterOnline() { return allRegisterOnline; }
public InetSocketAddress getChatP2PEndAddress() { return chatP2PEndAddress; }
public Vector<String> getAllRegisterDone() { return allRegisterDone; } }
|
其他的简单类
DataSave 保存文件到 txt,数据比较简单就不接数据库了,json 还要包,感觉不如 txt(悲
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 73 74 75 76 77 78 79 80 81 82 83 84 85
| public class DataSave {
public static void MapToTxT(Map<String,String> map){
String fpname="./data.txt"; String vcontent; BufferedWriter bufferedWriter=null; try { File file = new File(fpname); File file_dir = new File(file.getParent()); if(!file_dir.exists()){ file_dir.mkdirs(); } file.createNewFile(); FileOutputStream fos = new FileOutputStream(file); OutputStreamWriter osr = new OutputStreamWriter(fos,"UTF-8"); bufferedWriter = new BufferedWriter(osr);
for (Map.Entry<String,String> entry : map.entrySet()) { vcontent=""; vcontent+= entry.getKey(); vcontent+=":"; vcontent+=entry.getValue(); bufferedWriter.write(vcontent); bufferedWriter.newLine(); } bufferedWriter.flush(); } catch (IOException e) { e.printStackTrace(); } finally { try { if (bufferedWriter!=null){ bufferedWriter.close(); } }catch (Exception e) { e.printStackTrace(); } }
}
public static Hashtable<String,String> TxTToMap(){ Hashtable<String,String> hashtable = new Hashtable<>(); String fpath="./data.txt"; BufferedReader bufferedReader=null; try { File file = new File(fpath);
FileInputStream fis = new FileInputStream(file); InputStreamReader isr = new InputStreamReader(fis,"UTF-8"); bufferedReader = new BufferedReader(isr);
String str_line=""; String[] temp; while ((str_line=bufferedReader.readLine())!=null){ temp = str_line.split(":"); hashtable.put(temp[0],temp[1]); str_line=""; } } catch (IOException e) { e.printStackTrace(); } finally { try { if (bufferedReader!=null){ bufferedReader.close(); } }catch (Exception e) { e.printStackTrace(); } } return hashtable; }
|
客户端
客户端界面使用了JavaFX制作,可以看看相关的文档啥的,因为JavaFX可以用css去美化,所以比较方便就用了。
客户端主要有两个部分
- 一个是作为客户端去请求服务器存储的注册用户列表和在线用户列表
- 另一个是作为对等方与另一个用户聊天
总体过程
启动程序,进入注册登录界面
主进程是客户端界面JavaFX的进程,当主进程开启时,会开启与服务端进行通信的TCP连接线程,同时弹出注册登录界面,客户端通过这个TCP线程与服务器连接,完成登录和注册的相关逻辑。
用户列表
登录完成后,主进程进入到用户列表界面,这时主进程开启另一个UDP监听线程。此时有两个线程在运行
- 一个是TCP连接,从服务器获取在线用户列表和注册用户列表。
- 一个是UDP连接,负责监听其他客户端发起的聊天请求。
打开聊天窗口
选择聊天对象后会打开一个聊天窗口,每一个窗口都会分配一个对应的Controller类。这个Controller通过与UDP线程的交互,获取其他用户的聊天消息显示在对应的窗口上。
登出
关闭聊天窗口后登出到最开始的注册登录界面,此时会关闭UDP监听线程。通过TCP连接线程告知服务器从在线列表中退出。
关闭程序
关闭TCP线程,关闭JavaFX界面,程序退出。
主进程
主进程就是JavaFX的界面进程,负责新建窗口和窗口跳转。
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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
| public class ViewAlter extends Application {
private Stage stage;
public static void main(String[] args) { launch(args); }
@Override public void start(Stage primaryStage) throws Exception { peerThread = new PeerThread(); stage = primaryStage; stage.setOnCloseRequest(event -> System.exit(0)); stage.initStyle(StageStyle.TRANSPARENT); stage.getIcons().add(new Image("/static/icon.jpg")); stage.setTitle("Chat System"); gotoRegister(); stage.show(); }
public void gotoRegister() { try { RegisterController register = (RegisterController) replaceSceneContent(REGISTER_PATH,822,500); register.setTopImg(); register.setViewAlter(this); } catch (Exception e) { e.printStackTrace(); } }
public void gotoChatList(DatagramSocket socket) { try { ChatListController chatList = (ChatListController) replaceSceneContent(CHATLIST_PATH,1200,700); chatList.setViewAlter(this); chatList.setSocket(socket); chatList.setTopImg(); chatThread = new ChatThread(socket, this); chatThread.start(); } catch (Exception e) { e.printStackTrace(); } }
public void createChatWindow(DatagramSocket socket, InetSocketAddress inetSocketAddress, String stageName) { try { Stage secondStage = new Stage();
FXMLLoader loader = new FXMLLoader(); InputStream inputStream = ViewAlter.class.getResourceAsStream(CHATWINDOW_PATH); loader.setBuilderFactory(new JavaFXBuilderFactory()); loader.setLocation(ViewAlter.class.getResource(CHATWINDOW_PATH));
AnchorPane secondPane = loader.load(inputStream); Scene secondScene = new Scene(secondPane, 822, 500); secondStage.setScene(secondScene); secondStage.show(); inputStream.close(); STAGE.put(stageName, secondStage);
ChatWindowController chatWindow = loader.getController(); chatWindow.setChatOtherName(stageName); chatWindow.setSocket(socket); chatWindow.setInetAddress(inetSocketAddress.getAddress()); chatWindow.setPort(inetSocketAddress.getPort()); chatWindow.setViewAlter(this); CONTROLLER.put(stageName, chatWindow);
} catch (IOException e) { e.printStackTrace(); }
}
public void createAlertWindow(String alertMessage) { try { Stage secondStage = new Stage(); secondStage.initStyle(StageStyle.TRANSPARENT);
FXMLLoader loader = new FXMLLoader(); InputStream inputStream = ViewAlter.class.getResourceAsStream(ALERTWINDOW_PATH); loader.setBuilderFactory(new JavaFXBuilderFactory()); loader.setLocation(ViewAlter.class.getResource(ALERTWINDOW_PATH));
AnchorPane pane = loader.load(inputStream); Scene secondScene = new Scene(pane, 300, 150, Color.TRANSPARENT); secondStage.setScene(secondScene); secondStage.show();
inputStream.close();
AlertWindowController alertWindow = loader.getController(); alertWindow.setAlertMessage(alertMessage); alertWindow.setViewAlter(this);
} catch (IOException e) { e.printStackTrace(); }
}
private Initializable replaceSceneContent(String fxmlURL,int width,int height) throws Exception {
FXMLLoader loader = new FXMLLoader(); InputStream inputStream = ViewAlter.class.getResourceAsStream(fxmlURL); loader.setBuilderFactory(new JavaFXBuilderFactory()); loader.setLocation(ViewAlter.class.getResource(fxmlURL)); try { AnchorPane page = loader.load(inputStream); page.setOnMousePressed(event -> { xOffset = event.getSceneX(); yOffset = event.getSceneY(); }); page.setOnMouseDragged(event -> { stage.setX(event.getScreenX() - xOffset); stage.setY(event.getScreenY() - yOffset); }); Scene scene = new Scene(page, width, height, Color.TRANSPARENT); stage.setScene(scene); stage.sizeToScene(); } catch (Exception e) { e.printStackTrace(); } finally { inputStream.close(); } return loader.getController(); }
public void WindowCloseEvent(boolean isConnect) { if(isConnect){ peerThread.keepCommunicating = false; peerThread.notifyPeerThread(); peerThread.interrupt(); peerThread.close(); peerThread = null; } stage.close(); }
public Object getControllerByName(String name) { return CONTROLLER.get(name); } }
|
TCP线程
TCP线程由主进程新建第一个窗口时开启,负责处理和服务器的通信。而JavaFX的界面才能获取输入,所以TCP线程和主进程通过管道通信来传输消息。
注册登录界面
Register包含了注册登录界面和注册登录通信逻辑,包含3个输入框和两个按钮
- UserName 用户注册名
- IPAddress 登录使用的IP地址
- PassWord 注册用的密码
两个按钮分别为注册和登录
- 注册登录逻辑
- 如果这三个空有没填的,都不能注册或登录
- 如果选择了注册
- 如果是新的用户名,那么把这个用户名和对应的密码存到服务器
- 如果这个用户名已经存在,则提示重新注册或输密码登录
- 如果选择了登录
- 如果是已经存在的用户名
- 如果密码正确,登录然后跳转
- 如果密码错误,提示登录失败密码错误
- 如果是不存在的用户名,则提示先注册再登录
在TCP线程中,用户界面的Controller获取注册名、自己的IP、密码打包成一个request请求类
当按钮的事件没有触发时,TCP线程处于等待状态,等待主进程获取到输入之后,通过管道通信将这个请求的内容发送到TCP线程。TCP线程接收到这些信息后,主进程唤醒TCP线程,TCP线程将这个请求发送到服务端,然后接收服务端的响应,循环后又进入等待直到下次唤醒。
用户列表界面
主要是从服务端获取所需的注册用户列表和在线用户列表
与上一个界面一样,主进程都是通过TCP线程的等待去通信,然后TCP线程标识请求代码之类的一些东西,把请求发给服务端,再接收响应传回主进程。
UDP线程
UDP线程由主进程完成登录后进入聊天列表的时候开启,负责监听和处理来自其他客户端的数据报。
当点击前面用户列表界面中的在线用户时,会弹出与这个用户聊天的窗口,通过窗口发送聊天请求,而UDP进程就负责接收和解析这些数据报。
UDP数据报Data部分
发送方名字 |
register |
register |
register |
发送方包类型 |
连接【1】 |
断开【2】 |
聊天【3】 |
包内容 |
registerName/chat |
registerName/exit |
registerName/聊天内容 |
JavaFX的Controller
JavaFX程序中每一个fxml文件都绑定一个Controller类,属于主进程。可以给这个窗口绑定按钮事件之类的一些操作,所以一些与服务端通信和与客户端通信的打包就放在这些Controller里