1 完成聊天室的私聊功能
完成聊天室私聊功能。私聊功能是指,客户端之间可以实现一对一的聊天。
服务器端程序启动后,将等待客户端连接,界面效果如图-1所示:
图-1
客户端程序运行时,需要用户先输入昵称。用户输入昵称之后,提示用户可以开始聊天。界面效果如图-2所示:
图-2
另一个客户端运行起来后,也需要输入昵称,界面效果如图-3所示:
图-3
此时,其他运行中的客户端会收到昵称为“jerry”的客户端上线的消息。比如,之前运行起来的客户端“mary”的界面效果如图-4所示:
图-4
其他客户端可以通过输入类似“\jerry:你好”这样的字样和昵称为“jerry”的客户端私聊。比如,昵称为“mary”的客户端可以输入如图-5所示的信息:
图-5
注意:如果需要进行私聊,必需使用“\昵称:信息”的格式发送消息。其中,“\昵称:”为固定格式,“昵称”表示要私聊的客户端的昵称;“信息”表示需要发送的消息。例如:"\jerry:你好",表示发送消息“你好”给昵称为“jerry”的客户端。
昵称为“jerry”的客户端将接收到客户端“mary”发来的信息,界面效果如图-6所示:
图-6
如果某客户端程序停止运行,其他客户端程序可以接收到消息并显示。例如,昵称为“jerry”的客户端停止运行,昵称为“mary”的客户端的界面效果如图-7所示:
图-7
对于服务器端而言,只要有客户端连接,就会在界面输出提示信息。界面效果如图-8所示:
图-8
参考答案
实现此案例需要按照如下步骤进行。
步骤一:创建客户端类
新建名为com.tarena.homework的包,并在包下新建名为Client的类,用于表示客户端。
在Client 类中声明全局变量 socket 表示一个客户端Socket对象,并在实例化 Client 类时使用构造方法“Socket(String ip,int port)”来创建Socket类的对象。此时,需要进行异常处理。代码如下所示:
- package com.tarena.homework;
- import java.io.BufferedReader;
- import java.io.IOException;
- import java.io.InputStream;
- import java.io.InputStreamReader;
- import java.io.OutputStream;
- import java.io.OutputStreamWriter;
- import java.io.PrintWriter;
- import java.net.Socket;
- import java.util.Scanner;
- /**
- * 客户端应用程序
- */
- public class Client {
- //客户端Socket
- private Socket socket;
- /**
- * 构造方法,用于初始化
- */
- public Client(){
- try {
- socket = new Socket("localhost",8088);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
步骤二:定义客户端线程要执行的任务
在Client类中定义成员内部类ServerHander。该内部类需要实现Runnable接口并实现该接口的run() 方法。在该方法中实现线程要执行的任务,在此,线程要执行的任务为循环接收服务端的消息并打印到控制台。代码如下所示:
- public class Client {
- //其他代码,略
- /**
- * 该线程用于接收服务端发送过来的信息
- */
- private class ServerHander implements Runnable{
- @Override
- public void run() {
- try {
- InputStream in = socket.getInputStream();
- InputStreamReader isr = new InputStreamReader(in, "UTF-8");
- BufferedReader br = new BufferedReader(isr);
- while(true){
- System.out.println(br.readLine());
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
- }
步骤三:定义方法inputNickName(),用于输入昵称
为Client类定义方法inputNickName(),用于输入昵称。代码如下所示:
- public class Client {
- //其他代码,略
- /**
- * 输入昵称
- */
- private void inputNickName(Scanner scanner)throws Exception{
- //定义昵称
- String nickName = null;
- //创建输出流
- PrintWriter pw = new PrintWriter(
- new OutputStreamWriter(
- socket.getOutputStream(),"UTF-8")
- ,true);
- //创建输入流
- BufferedReader br = new BufferedReader(
- new InputStreamReader(
- socket.getInputStream(),"UTF-8")
- );
- /*
- * 循环以下操作
- * 输入用户名,并上传至服务器,等待服务器回应,若昵称可用就结束循环,否则通知用户后
- * 重新输入昵称
- */
- while(true){
- System.out.println("请输入昵称:");
- nickName = scanner.nextLine();
- if(nickName.trim().equals("")){
- System.out.println("昵称不能为空");
- }else{
- pw.println(nickName);
- String pass = br.readLine();
- if(pass!=null&&!pass.equals("OK")){
- System.out.println("昵称已被占用,请更换。");
- }else{
- System.out.println("你好!"+nickName+",开始聊天吧!");
- break;
- }
- }
- }
- }
- }
步骤四:创建客户端工作方法 start()
为 Client 类创建客户端工作方法 start()。在该方法中,首先调用方法inputNickName()得到用户昵称,然后启动接收服务端信息的线程,接收数据后打印显示。
代码如下所示:
- public class Client {
- //其他代码,略
- /**
- * 客户端工作方法
- */
- public void start(){
- try {
- //创建Scanner读取用户输入内容
- Scanner scanner = new Scanner(System.in);
- //首先输入昵称
- inputNickName(scanner);
- //将接收服务端信息的线程启动
- ServerHander handler = new ServerHander();
- Thread t = new Thread(handler);
- t.setDaemon(true);
- t.start();
- OutputStream out = socket.getOutputStream();
- OutputStreamWriter osw = new OutputStreamWriter(out,"UTF-8");
- PrintWriter pw = new PrintWriter(osw,true);
- while(true){
- pw.println(scanner.nextLine());
- }
- } catch (Exception e) {
- e.printStackTrace();
- } finally{
- if(socket != null){
- try {
- socket.close();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
- }
- }
步骤五:为客户端类定义 main() 方法
为类 Client 定义 main() 方法,并在该方法中,创建 Client 对象,调用上一步中所创建的 start() 方法。代码如下所示:
- /**
- * 客户端应用程序
- */
- public class Client {
- //其他代码,略
- public static void main(String[] args) {
- Client client = new Client();
- client.start();
- }
- }
步骤六:定义 Server类
定义Server类,并在Server类中添加ExecutorService类型的属性threadPool,并在构造方法中将其初始化。初始化时,使用固定大小的线程池,线程数量为40。这里使用Executors类的newFixedThreadPool(int threads)方法来创建固定大小的线程池。定义属性serverSocket,其类型为ServerSocket,并在构造方法中将其初始化,申请的服务端口为8088。再定义属性allOut,其类型为HashMap,其中key用于保存用户昵称,value用于保存该客户端的输出流,并在构造方法中初始化以便服务端可以转发信息。
代码如下所示:
- package com.tarena.homework;
- import java.io.BufferedReader;
- import java.io.IOException;
- import java.io.InputStream;
- import java.io.InputStreamReader;
- import java.io.OutputStream;
- import java.io.OutputStreamWriter;
- import java.io.PrintWriter;
- import java.net.ServerSocket;
- import java.net.Socket;
- import java.util.HashMap;
- import java.util.Map;
- import java.util.concurrent.ExecutorService;
- import java.util.concurrent.Executors;
- /**
- * 服务端应用程序
- */
- public class Server {
- // 服务端Socket
- private ServerSocket serverSocket;
- // 所有客户端输出流,key为用户的昵称,value为该用户的输出流
- private Map<String,PrintWriter> allOut;
- // 线程池
- private ExecutorService threadPool;
- /**
- * 构造方法,用于初始化
- */
- public Server() {
- try {
- serverSocket = new ServerSocket(8088);
- allOut = new HashMap<String,PrintWriter>();
- threadPool = Executors.newFixedThreadPool(40);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
步骤七:为 Server 类定义 addOut()和removeOut()方法
定义 addOut()方法,该方法向Server的属性allOut集合中添加输出流,并使用synchronized关键字修饰,使该方法变为同步方法。
再定义removeOut()方法,该方法从Server的属性allOut集合中删除输出流,并使用synchronized关键字修饰,使该方法变为同步方法。
代码如下所示:
- public class Server {
- //其他代码,略
- /**
- * 将输出流存入共享集合,与下面两个方法互斥,保证同步安全
- * @param out
- */
- private synchronized void addOut(String nickName,PrintWriter out){
- allOut.put(nickName,out);
- }
- /**
- * 将给定输出流从共享集合删除
- * @param out
- */
- private synchronized void removeOut(String nickName){
- allOut.remove(nickName);
- }
- }
步骤八:为 Server 类定义sendMessage()方法
定义sendMessage()方法,该方法用于遍历Server的属性allOut集合元素,将信息写入每一个输出流来完成广播消息的功能,并使用synchronized关键字修饰,使该方法变为同步方法。代码如下所示:
- public class Server {
- //其他代码,略
- /**
- * 将消息转发给所有客户端
- * @param message
- */
- private synchronized void sendMessage(String message){
- for(PrintWriter o : allOut.values()){
- o.println(message);
- }
- }
- }
步骤九:为 Server 类定义sendMessageToOne() 方法
定义sendMessageToOne()方法,该方法用于将消息发送给指定昵称的客户端来实现私聊功能。代码如下所示:
- public class Server {
- //其他代码,略
- /**
- * 将消息发送给指定昵称的客户端
- * @param nickName
- * @param message
- */
- private synchronized void sendMessageToOne(String nickName,String message){
- PrintWriter out = allOut.get(nickName);
- if(out!=null){
- out.println(message);
- }
- }
- }
步骤十:创建内部类
创建 Server的内部类ClientHandler,在内部类中定义run()方法。在run()方法中,读取用户昵称以发送用户上线信息,并进行消息转发,其中先判断是否为私聊信息,若是则调用发送私聊信息的方法,否则向所有客户端广播消息 。代码如下所示:
- /**
- * 线程体,用于并发处理不同客户端的交互
- */
- private class ClientHandler implements Runnable {
- // 该线程用于处理的客户端
- private Socket socket;
- // 开客户端的昵称
- private String nickName;
- public ClientHandler(Socket socket) {
- this.socket = socket;
- }
- @Override
- public void run() {
- PrintWriter pw = null;
- try {
- //将客户端的输出流存入共享集合,以便广播消息
- OutputStream out = socket.getOutputStream();
- OutputStreamWriter osw = new OutputStreamWriter(out,"UTF-8");
- pw = new PrintWriter(osw,true);
- /*
- * 将用户信息存入共享集合
- * 需要同步
- */
- //先获取该用户昵称
- nickName = getNickName();
- addOut(nickName,pw);
- Thread.sleep(100);
- /*
- * 通知所有用户该用户已上线
- */
- sendMessage(nickName+"上线了");
- InputStream in = socket.getInputStream();
- InputStreamReader isr = new InputStreamReader(in, "UTF-8");
- BufferedReader br = new BufferedReader(isr);
- String message = null;
- // 循环读取客户端发送的信息
- while ((message = br.readLine())!=null) {
- //首先查看是不是私聊
- if(message.startsWith("\\")){
- /*
- * 私聊格式:\昵称:内容
- */
- //找到:的位置
- int index = message.indexOf(":");
- if(index>=0){
- //截取昵称
- String name = message.substring(1,index);
- //截取内容
- String info = message.substring(
- index+1,message.length()
- );
- //拼接内容
- info = nickName+"对你说:"+info;
- //发送私聊信息给指定用户
- sendMessageToOne(name, info);
- //发送完私聊后就不在广播了。
- continue;
- }
- }
- /*
- * 遍历所有输出流,将该客户端发送的信息转发给所有客户端
- * 需要同步
- */
- sendMessage(nickName+"说:"+message);
- }
- } catch (Exception e) {
- e.printStackTrace();
- } finally {
- /*
- * 当客户端断线,要将输出流从共享集合中删除
- * 需要同步
- */
- removeOut(nickName);
- /*
- * 通知所有用户该用户已下线
- */
- sendMessage(nickName+"下线了");
- System.out.println("当前在线人数:"+allOut.size());
- if (socket != null) {
- try {
- socket.close();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
- }
- }
步骤十一:为内部类定义方法getNickName()
为 Server的内部类ClientHandler定义方法getNickName(),用于获取用户的昵称。代码如下所示:
- private class ClientHandler implements Runnable {
- //其他代码,略
- /**
- * 获取该用户的昵称
- * @return
- */
- private String getNickName()throws Exception{
- try {
- //获取该用户的输出流
- OutputStream out = socket.getOutputStream();
- OutputStreamWriter osw = new OutputStreamWriter(out,"UTF-8");
- PrintWriter pw = new PrintWriter(osw,true);
- //获取该用户的输入流
- InputStream in = socket.getInputStream();
- InputStreamReader isr = new InputStreamReader(in, "UTF-8");
- BufferedReader br = new BufferedReader(isr);
- //读取客户端发送过来的昵称
- String nickName = br.readLine();
- while(true){
- //若昵称为空发送失败代码
- if(nickName.trim().equals("")){
- pw.println("FAIL");
- }
- //若昵称已经存在发送失败代码
- if(allOut.containsKey(nickName)){
- pw.println("FAIL");
- //若成功,发送成功代码,并返回昵称
- }else{
- pw.println("OK");
- return nickName;
- }
- //若改昵称被占用,等待用户再次输入昵称
- nickName = br.readLine();
- }
- } catch (Exception e) {
- throw e;
- }
- }
- }
步骤十二:为 Server 类创建 start()方法
为 Server 类创建 start()方法。在该方法中,循环监听8088端口,等待客户端的连接,一旦一个客户端连接后,向线程池申请一个线程来完成针对该客户端的交互。代码如下所示:
- public class Server {
- //其他代码,略
- /**
- * 服务端开启方法
- */
- public void start() {
- try {
- //循环监听客户端的连接
- while(true){
- System.out.println("等待客户端连接...");
- // 监听客户端的连接
- Socket socket = serverSocket.accept();
- System.out.println("客户端已连接!");
- //启动一个线程来完成针对该客户端的交互
- ClientHandler handler = new ClientHandler(socket);
- threadPool.execute(handler);
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
步骤十三:为 Server类定义 main() 方法
为 Server 类定义 main() 方法,并在 main() 方法中,创建 Server 对象,调用上一步中所创建的 start() 方法。代码如下所示:
- public class Server {
- //其他代码,略
- public static void main(String[] args) {
- Server server = new Server();
- server.start();
- }
- }
本案例中,类Server的完整代码如下所示:
- package com.tarena.homework;
- import java.io.BufferedReader;
- import java.io.IOException;
- import java.io.InputStream;
- import java.io.InputStreamReader;
- import java.io.OutputStream;
- import java.io.OutputStreamWriter;
- import java.io.PrintWriter;
- import java.net.ServerSocket;
- import java.net.Socket;
- import java.util.HashMap;
- import java.util.Map;
- import java.util.concurrent.ExecutorService;
- import java.util.concurrent.Executors;
- /**
- * 服务端应用程序
- */
- public class Server {
- // 服务端Socket
- private ServerSocket serverSocket;
- // 所有客户端输出流,key为用户的昵称,value为该用户的输出流
- private Map<String,PrintWriter> allOut;
- // 线程池
- private ExecutorService threadPool;
- /**
- * 构造方法,用于初始化
- */
- public Server() {
- try {
- serverSocket = new ServerSocket(8088);
- allOut = new HashMap<String,PrintWriter>();
- threadPool = Executors.newFixedThreadPool(40);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- /**
- * 服务端开启方法
- */
- public void start() {
- try {
- //循环监听客户端的连接
- while(true){
- System.out.println("等待客户端连接...");
- // 监听客户端的连接
- Socket socket = serverSocket.accept();
- System.out.println("客户端已连接!");
- //启动一个线程来完成针对该客户端的交互
- ClientHandler handler = new ClientHandler(socket);
- threadPool.execute(handler);
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- /**
- * 将输出流存入共享集合,与下面两个方法互斥,保证同步安全
- * @param out
- */
- private synchronized void addOut(String nickName,PrintWriter out){
- allOut.put(nickName,out);
- }
- /**
- * 将给定输出流从共享集合删除
- * @param out
- */
- private synchronized void removeOut(String nickName){
- allOut.remove(nickName);
- }
- /**
- * 将消息转发给所有客户端
- * @param message
- */
- private synchronized void sendMessage(String message){
- for(PrintWriter o : allOut.values()){
- o.println(message);
- }
- }
- /**
- * 将消息发送给指定昵称的客户端
- * @param nickName
- * @param message
- */
- private synchronized void sendMessageToOne(String nickName,String message){
- PrintWriter out = allOut.get(nickName);
- if(out!=null){
- out.println(message);
- }
- }
- public static void main(String[] args) {
- Server server = new Server();
- server.start();
- }
- /**
- * 线程体,用于并发处理不同客户端的交互
- */
- private class ClientHandler implements Runnable {
- // 该线程用于处理的客户端
- private Socket socket;
- // 开客户端的昵称
- private String nickName;
- public ClientHandler(Socket socket) {
- this.socket = socket;
- }
- @Override
- public void run() {
- PrintWriter pw = null;
- try {
- //将客户端的输出流存入共享集合,以便广播消息
- OutputStream out = socket.getOutputStream();
- OutputStreamWriter osw = new OutputStreamWriter(out,"UTF-8");
- pw = new PrintWriter(osw,true);
- /*
- * 将用户信息存入共享集合
- * 需要同步
- */
- //先获取该用户昵称
- nickName = getNickName();
- addOut(nickName,pw);
- Thread.sleep(100);
- /*
- * 通知所有用户该用户已上线
- */
- sendMessage(nickName+"上线了");
- InputStream in = socket.getInputStream();
- InputStreamReader isr = new InputStreamReader(in, "UTF-8");
- BufferedReader br = new BufferedReader(isr);
- String message = null;
- // 循环读取客户端发送的信息
- while ((message = br.readLine())!=null) {
- //首先查看是不是私聊
- if(message.startsWith("\\")){
- /*
- * 私聊格式:\昵称:内容
- */
- //找到:的位置
- int index = message.indexOf(":");
- if(index>=0){
- //截取昵称
- String name = message.substring(1,index);
- //截取内容
- String info = message.substring(index+1,message.length());
- //拼接内容
- info = nickName+"对你说:"+info;
- //发送私聊信息给指定用户
- sendMessageToOne(name, info);
- //发送完私聊后就不在广播了。
- continue;
- }
- }
- /*
- * 遍历所有输出流,将该客户端发送的信息转发给所有客户端
- * 需要同步
- */
- sendMessage(nickName+"说:"+message);
- }
- } catch (Exception e) {
- e.printStackTrace();
- } finally {
- /*
- * 当客户端断线,要将输出流从共享集合中删除
- * 需要同步
- */
- removeOut(nickName);
- /*
- * 通知所有用户该用户已下线
- */
- sendMessage(nickName+"下线了");
- System.out.println("当前在线人数:"+allOut.size());
- if (socket != null) {
- try {
- socket.close();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
- }
- /**
- * 获取该用户的昵称
- * @return
- */
- private String getNickName()throws Exception{
- try {
- //获取该用户的输出流
- OutputStream out = socket.getOutputStream();
- OutputStreamWriter osw = new OutputStreamWriter(out,"UTF-8");
- PrintWriter pw = new PrintWriter(osw,true);
- //获取该用户的输入流
- InputStream in = socket.getInputStream();
- InputStreamReader isr = new InputStreamReader(in, "UTF-8");
- BufferedReader br = new BufferedReader(isr);
- //读取客户端发送过来的昵称
- String nickName = br.readLine();
- while(true){
- //若昵称为空发送失败代码
- if(nickName.trim().equals("")){
- pw.println("FAIL");
- }
- //若昵称已经存在发送失败代码
- if(allOut.containsKey(nickName)){
- pw.println("FAIL");
- //若成功,发送成功代码,并返回昵称
- }else{
- pw.println("OK");
- return nickName;
- }
- //若改昵称被占用,等待用户再次输入昵称
- nickName = br.readLine();
- }
- } catch (Exception e) {
- throw e;
- }
- }
- }
- }
本案例中,类Client的完整代码如下所示:
- package com.tarena.homework;
- import java.io.BufferedReader;
- import java.io.IOException;
- import java.io.InputStream;
- import java.io.InputStreamReader;
- import java.io.OutputStream;
- import java.io.OutputStreamWriter;
- import java.io.PrintWriter;
- import java.net.Socket;
- import java.util.Scanner;
- /**
- * 客户端应用程序
- */
- public class Client {
- //客户端Socket
- private Socket socket;
- /**
- * 构造方法,用于初始化
- */
- public Client(){
- try {
- socket = new Socket("localhost",8088);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- /**
- * 客户端工作方法
- */
- public void start(){
- try {
- //创建Scanner读取用户输入内容
- Scanner scanner = new Scanner(System.in);
- //首先输入昵称
- inputNickName(scanner);
- //将接收服务端信息的线程启动
- ServerHander handler = new ServerHander();
- Thread t = new Thread(handler);
- t.setDaemon(true);
- t.start();
- OutputStream out = socket.getOutputStream();
- OutputStreamWriter osw = new OutputStreamWriter(out,"UTF-8");
- PrintWriter pw = new PrintWriter(osw,true);
- while(true){
- pw.println(scanner.nextLine());
- }
- } catch (Exception e) {
- e.printStackTrace();
- } finally{
- if(socket != null){
- try {
- socket.close();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
- }
- public static void main(String[] args) {
- Client client = new Client();
- client.start();
- }
- /**
- * 输入昵称
- */
- private void inputNickName(Scanner scanner)throws Exception{
- //定义昵称
- String nickName = null;
- //创建输出流
- PrintWriter pw = new PrintWriter(
- new OutputStreamWriter(
- socket.getOutputStream(),"UTF-8")
- ,true);
- //创建输入流
- BufferedReader br = new BufferedReader(
- new InputStreamReader(
- socket.getInputStream(),"UTF-8")
- );
- /*
- * 循环以下操作
- * 输入用户名,并上传至服务器,等待服务器回应,若昵称可用就结束循环,否则通知用户后
- * 重新输入昵称
- */
- while(true){
- System.out.println("请输入昵称:");
- nickName = scanner.nextLine();
- if(nickName.trim().equals("")){
- System.out.println("昵称不能为空");
- }else{
- pw.println(nickName);
- String pass = br.readLine();
- if(pass!=null&&!pass.equals("OK")){
- System.out.println("昵称已被占用,请更换。");
- }else{
- System.out.println("你好!"+nickName+",开始聊天吧!");
- break;
- }
- }
- }
- }
- /**
- * 该线程用于接收服务端发送过来的信息
- */
- private class ServerHander implements Runnable{
- @Override
- public void run() {
- try {
- InputStream in = socket.getInputStream();
- InputStreamReader isr = new InputStreamReader(in, "UTF-8");
- BufferedReader br = new BufferedReader(isr);
- while(true){
- System.out.println(br.readLine());
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
- }