NIO专题学习(一)
一、BIO/NIO/AIO介绍
1. 背景说明
在Java的软件设计开发中,通信架构是不可避免的。我们在进行不同系统或者不同进程之间的数据交互,或者在高并发的通信场景下都需要用到网络通信相关的技术。
对于一些经验丰富的程序员来说,Java早期的网络通信架构存在一些缺陷,其中最令人恼火的是基于性能低下的同步阻塞式的I/O通信(BIO)。随着互联网开发下通信性能的高要求,Java在2002年开始支持了非阻塞式的I/O通信技术(NIO)。大多数读者在学习网络通信相关技术的时候,都只是接触到零碎的通信技术点,没有完整的技术体系架构,以至于对Java的通信场景总是没有清晰的解决方案。
本文将通过大量清晰直接的案例从最基础的BlO式通信开始介绍到NIO、AIO,读者可以清晰的了解到阻塞 、同步 、异步 的现象、概念和特征以及优缺点。本文结合了大量的案例让读者可以快速了解每种通信架构的使用 。
2. 技术要求
- 不适合0基础
- 需要掌握:Java SE基础编程,如:Java多线程、Java IO编程、Java网络基础知识,还要了解一些Java设计模式
- 能熟练掌握OOP编程,有一定的编程思维。
3. 通信技术整体上解决的问题
- 局域网内的通信要求
- 多系统间的底层消息传递机制
- 高并发下,大数据量的通信场景需要,如:netty
- 游戏行业,无论是手游,还是大型的网络游戏,Java语言越来越被广泛应用。
二、Java的IO演进之路
1.I/O模型基本说明
I/O模型:就是用什么样的通道或者说是通信模式和架构进行数据的传输和接收,很大程度上决定了程序通信的性能,Java共支持3种网络编程的I/O模型:BlO. NIO. AlO
2. I/O模型介绍(3种)
①Java BIO
同步阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情就会造成不必要的线程开销,以下是简单示意图:

②Java NIO
同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理【简单示意图】

③Java AIO
也叫NIO.2,异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,一般适用于连接数较多且连接时间较长的应用
3. BIO、NIO、AIO适用场景分析
①BIO 方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并局限于应用中, jDK1.4以前的唯一选择,但程序简单易理解。
②NIO 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。 编程比较复杂,jDK1.4开始支持。
③AIO 方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作, 编程比较复杂,JDK7开始支持。
三、JAVA BIO深入剖析
1. Java BIO基本介绍
Java BlO就是传统的Java IO编程,其相关的类和接口在Java.io 包中。
BIO(blocking I/O): 同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善 (实现多个客户连接服务器)。
2. Java BIO工作机制

- 服务端和客户端都是 - socket
- 服务端: - 通过ServerSocket注册端口
- 服务端通过调用accept方法用于监听客户端的Socket请求
- 从Socket种获取字节输入或者输出流进行数据的读写操作
 
- 客户端: - 通过Socket对象请求与服务端的连接
- 从Socket中得到字节输入或者字节输出流进行数据的读写操作
 
3. 传统的BIO编程实例回顾
网络编程的基本模型是Client/Server模型,也就是两个进程之间进行相互通信,其中服务端提供位置信息(绑定IP地址和端口),客户端通过连接操作向服务端监听的端口地址发起连接请求,基于TCP协议下进行三次握手连接,连接成功后,双方通过网络套接字(Socket)进行通信。
传统的同步阻塞模型开发中,服务端ServerSocket负责绑定IP地址,启动监听端口;客户端Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。
基于BIO模式下的通信,客户端-服务端是完全同步,完全藕合的。
服务端代码:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
//服务端,目标:客户端发送消息,服务端接收消息
public class Server {
    public static void main(String[] args) {
        System.out.println("======服务端启动=======");
        try {
            //1.定义一个ServerSocket对象进行服务端的端口注册
            ServerSocket ss = new ServerSocket(9999);
            //2.监听客户端的Socket连接请求
            Socket socket = ss.accept();
            //3.从Socket管道中得到一个字节输入流对象
            InputStream is = socket.getInputStream();
            //4.把字节输入流包装成一个缓冲字符输入流
            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
            String msg;
            if ((msg = reader.readLine()) != null) {
                System.out.println("服务端接收到:"+msg);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
客户端代码:
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
//客户端,连接上服务端,然后发送消息到服务端
public class Client {
    public static void main(String[] args) {
        //1.创建和服务端的连接
        try {
            Socket socket = new Socket("127.0.0.1",9999);
            //2.从socket对象中获取一个字节输出流
            OutputStream os = socket.getOutputStream();
            //3.把字节输出流包装成一个打印流
            PrintStream ps = new PrintStream(os);
            //4.输出消息
            ps.println("hello world!服务端,你好!");
            ps.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
启动服务端后启动客户端,效果如下:
======服务端启动=======
服务端接收到:hello world!服务端,你好!
小结:
在以上通信中,服务端会一直等待客户端的消息,如果客户端没有进行消息的发送,服务端将一直进入阻塞状态;同时服务端是按照行获取消息的,这意味着客户端也必须按照行进行消息的发送,否则服务端将进入等待消息的阻塞状态!
4. BIO模式下多发和多收消息
上面的案例只能实现单发和单收消息,并不能实现反复的收消息和发消息。我们只需要在客户端案例中,加上反复按照行发送消息的逻辑即可!案例代码如下:
服务端代码:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
//服务端,目标:服务端可以反复的接收消息,客户端可以反复的发送消息
public class Server {
    public static void main(String[] args) {
        System.out.println("======服务端启动=======");
        try {
            //1.定义一个ServerSocket对象进行服务端的端口注册
            ServerSocket ss = new ServerSocket(9999);
            //2.监听客户端的Socket连接请求
            Socket socket = ss.accept();
            //3.从Socket管道中得到一个字节输入流对象
            InputStream is = socket.getInputStream();
            //4.把字节输入流包装成一个缓冲字符输入流
            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
            String msg;
            while ((msg = reader.readLine()) != null) {
                System.out.println("服务端接收到:"+msg);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
客户端代码:
public class Client {
    public static void main(String[] args) {
        //1.创建和服务端的连接
        try {
            Socket socket = new Socket("127.0.0.1",9999);
            //2.从socket对象中获取一个字节输出流
            OutputStream os = socket.getOutputStream();
            //3.把字节输出流包装成一个打印流
            PrintStream ps = new PrintStream(os);
            Scanner scanner = new Scanner(System.in);
            String msg;
            while (true){
                //4.输出消息
                System.out.println("请说:");
                msg = scanner.nextLine();
                ps.println(msg);
                ps.flush();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
效果: 启动服务端->启动客户端->在客户端打字,服务端收到消息如下:
======服务端启动=======
服务端接收到:你好
服务端接收到:今天在干嘛
服务端接收到:好的
5. BIO模式下接收多个客户端
在上述的案例中,一个服务端只能接收一个客户端的通信请求,那么如果服务端需要处理很多个客户端的消息通信请求应该如何处理呢 ,此时我们就需要在服务端引入线程了,也就是说客户端每发起一个请求,服务端就创建一个新的线程 来处理这个客户端的请求,这样就实现了一个客户端一个线程的模型,图解模式如下:

服务端代码:
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
//目标:实现服务端可以同时接收到多个客户端的Socket通信需求
//思路:服务端每接收一个客户端的socket请求对象之后都交给一个独立的线程来处理客户端的数据交互需求
public class Server {
    public static void main(String[] args) {
        try {
            //1.注册端口
            ServerSocket ss = new ServerSocket(9999);
            //2.定义一个死循环,不断接收客户端的socket连接请求
            while(true){
                Socket socket = ss.accept();
                //3.创建一个独立的线程来处理与这个客户端的socket通信需求
                new ServerThreadReader(socket).start();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
线程处理代码:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.Socket;
public class ServerThreadReader extends Thread {
    private Socket socket;
    public ServerThreadReader(Socket socket){
        this.socket = socket;
    }
    @Override
    public void run() {
        try {
            //从socket对象中得到一个字节输入流
            InputStream is = socket.getInputStream();
            //使用缓存字符输入流包装字节输入流
            BufferedReader br = new BufferedReader(new InputStreamReader(is));
            String msg;
            while ((msg = br.readLine()) != null){
                System.out.println(msg);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
客户端代码:
import java.io.PrintStream;
import java.net.Socket;
import java.util.Scanner;
public class Client {
    public static void main(String[] args) {
        try {
            //1.请求与服务端的Socket对象连接
            Socket socket = new Socket("127.0.0.1",9999);
            //2. 得到一个打印流
            PrintStream ps = new PrintStream(socket.getOutputStream());
            //3. 使用循环不断的发送消息给服务端接收
            Scanner sc = new Scanner(System.in);
            while (true){
                System.out.println("请说:");
                String msg =sc.nextLine();
                ps.println(msg);
                ps.flush();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
启动服务端,启动客户端1、客户端2,然后通过客户端1和客户端2分别发送消息,服务端效果如下:
客户端1
客户端2
小结
- 每个Socket接收到,都会创建一个线程,线程的竞争、切换上下文影响性能
- 每个线程都会占用栈空间和CPU资源
- 并不是每个socket都进行lO操作,无意义的线程处理
- 客户端的并发访问增加时。服务端将呈现1:1的线程开销,访问量越大,系统将发生线程栈溢出
- 线程创建失败,最终导致进程宕机或者僵死,从而不能对外提供服务
6. 伪异步I/O编程
在上述案例中:客户端的并发访问增加时。服务端将呈现1:1的线程开销,访问量越大,系统将发生线程栈溢出,线程创建失败,最终导致进程宕机或者僵死,从而不能对外提供服务。
接下来我们采用一个伪异步I/O的通信框架,采用线程池和任务队列实现,当客户端接入时,将客户端的Socket封装成一个Task(该任务实现Java. lang. Runnable(线程任务接口)交给后端的线程池中进行处理。JDK的线程池维护一个消息队列和N个活跃的线程,对消息队列中Socket任务进行处理,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
如下图所示:
服务端代码:
import java.net.ServerSocket;
import java.net.Socket;
//目标:开发实现伪异步通讯架构
//思路:服务端每接收到一个客户端socket请求对象之后都交给一个独立的线程来处理客户端的数据交互需求
public class Server {
    public static void main(String[] args) {
        try {
            //1.注册端口
            ServerSocket ss = new ServerSocket(9999);
            //2.定义一个死循环,负责不断的接收客户端的Socket的连接请求
            //初始化一个线程池对象
            HandlerSocketServerPool pool = new HandlerSocketServerPool(3, 10);
            while (true) {
                Socket socket = ss.accept();
                //3.把socket对象交给一个线程池进行处理
                //把socket封装成一个任务对象交给线程池处理
                Runnable target = new ServerRunnableTarget(socket);
                pool.execute(target);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
定义线程池代码:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class HandlerSocketServerPool {
    //1. 创建一个线程池的成员变量用于存储一个线程池对象
    private ExecutorService executorService;
    /**
     * 2.创建这个类的的对象的时候就需要初始化线程池对象
     * public ThreadPoolExecutor(int corePoolSize,
     * int maximumPoolSize,
     * long keepAliveTime,
     * TimeUnit unit,
     * BlockingQueue<Runnable> workQueue)
     */
    public HandlerSocketServerPool(int maxThreadNum, int queueSize) {
        executorService = new ThreadPoolExecutor(3, maxThreadNum, 120,
                TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(queueSize));
    }
    /**
     * 3.提供一个方法来提交任务给线程池的任务队列来暂存,等待线程池来处理
     */
    public void execute(Runnable target) {
        executorService.execute(target);
    }
}
Socket任务类:
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.Socket;
public class ServerRunnableTarget implements Runnable {
    private Socket socket;
    public ServerRunnableTarget(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        //处理接收到的客户端socket通信需求
        try {
            //1.从socket管道中得到一个字节输入流对象
            InputStream is = socket.getInputStream();
            //2.把字节输入流包装成一个缓存字符输入流
            BufferedReader br = new BufferedReader(new InputStreamReader(is));
            String msg;
            while ((msg = br.readLine()) != null) {
                System.out.println("服务端收到:" + msg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
客户端代码:
import java.io.PrintStream;
import java.net.Socket;
import java.util.Scanner;
public class Client {
    public static void main(String[] args) {
        try {
            //1.请求与服务端的Socket对象连接
            Socket socket = new Socket("127.0.0.1",9999);
            //2. 得到一个打印流
            PrintStream ps = new PrintStream(socket.getOutputStream());
            //3. 使用循环不断的发送消息给服务端接收
            Scanner sc = new Scanner(System.in);
            while (true){
                System.out.println("请说:");
                String msg =sc.nextLine();
                ps.println(msg);
                ps.flush();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
效果:
可以接收到3个客户端的消息,当开启第四个客户端的时候,不报错,但是服务端不会接收到第四个客户端发送的消息。
小结
伪异步采用了线程池 实现,因此避免了为每个请求创建一个独立线程造成线程资源耗尽的问题,但由于底层依然是采用的同步阻塞模型,因此无法从根本上解决问题。
如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后续socket的I/O消息都将在队列中排队。新的Socket请求将被拒绝,客户端会发生大量连接超时。
7. 基于BIO形式下的文件上传
目标: 支持任意类型文件形式的上传
客户端代码:
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
//目标:实现客户端上传任意类型的文件数据给服务端保存起来
public class Client {
    public static void main(String[] args) {
        try(InputStream is = new FileInputStream("D:\\Desktop\\抖音的推荐机制.docx")) {
            //1.请求与服务端的socket连接
            Socket socket = new Socket("127.0.0.1", 8888);
            //2.把字节输出流包装成一个数据输出流
            DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
            //3.先发送文件的后缀名给服务端
            dos.writeUTF(".docx");
            //4.把文件数据发送给服务端进行接收
            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) > 0) {
                dos.write(buffer, 0, len);
            }
            dos.flush();
            //通知服务端,我客户端这边的数据已经发送完毕了
            socket.shutdownOutput();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
服务端代码:
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
    public static void main(String[] args) {
        System.out.println("======服务端启动=======");
        try {
            ServerSocket ss = new ServerSocket(8888);
            while (true) {
                Socket socket = ss.accept();
                //交给一个独立的线程来处理与这个客户端的文件通信需求
                new ServerReadThread(socket).start();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
Socket线程处理类:
import java.io.DataInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.UUID;
public class ServerReadThread extends Thread{
    private Socket socket;
    public ServerReadThread(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        try {
            //1.得到一个数据输入流来读取客户端发送过来的数据
            DataInputStream dis = new DataInputStream(socket.getInputStream());
            //2.读取客户端发送过来的文件类型
            String suffix = dis.readUTF();
            System.out.println("服务端已经成功接收到文件类型:"+suffix);
            //3.定义一个字节输出管道,负责将客户端发送来的数据写出去
            OutputStream os = new FileOutputStream("D:\\Desktop\\server\\"+ UUID.randomUUID().toString() + suffix);
            //4.从数据输入流中读取文件数据,写出到字节输出流中去
            byte[] buffer = new byte[1024];
            int len;
            while ((len = dis.read(buffer)) > 0) {
                os.write(buffer, 0, len);
            }
            os.close();
            System.out.println("服务端接收文件保存成功!");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
启动Server,后启动Client,效果如下: 
小结
- 同步阻塞模式下(BIO),客户端怎么发,服务端就必须对应的怎么收。如客户端用的是DataOutputStream,那么服务端就该用DataInputStream,客户端dos.writeUTF(“.jpg”);服务端就该String suffix = dis.readUTF();
- 客户端发完数据后必须通知服务端自己已经发完socket.shutdownOutput(),否则服务端会一直等待。
8. Java BIO模式下的端口转发思想
需求:需要实现一个客户端的消息可以发送给所有的客户端去接收。(群聊实现) 
服务端代码:
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
/**
 * 目标:BIO模式下的端口转发思想-服务端实现
 * 服务端实现需求:
 * 1.注册端口
 * 2.接收客户端的socket连接,交给一个独立的线程来处理
 * 3.把当前连接的客户端socket存入到一个所谓的在线socket集合中保存
 * 4.接收客户端的消息,然后推动给当前所有的在线socket接收
 */
public class Server {
    public static List<Socket> allSocketOnline = new ArrayList<>();
    public static void main(String[] args) {
        try {
            ServerSocket ss = new ServerSocket(9999);
            while (true) {
                Socket socket = ss.accept();
                //把登陆的客户端socket存入到一个在线集合上
                allSocketOnline.add(socket);
                //为当前登录成功的socket分配一个独立的线程来处理与之通信
                new ServerReadThread(socket).start();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
服务端线程处理类:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.Socket;
public class ServerReadThread extends Thread{
    private Socket socket;
    public ServerReadThread(Socket socket){
        this.socket = socket;
    }
    @Override
    public void run() {
        try {
            //1.从socket中获取当前客户端的输入流
            BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            String msg;
            while ((msg = br.readLine())!=null) {
                //2.服务端接收到了客户端的消息之后,是需要推送给当前所有的在线socket
                sendMsgToAllClient(msg, socket);
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("当前有人下线了");
            //从在线socket集合中移除本socket
            Server.allSocketOnline.remove(socket);
        }
    }
    /**
     * 把当前客户端发来的消息推送给全部在线的socket
     * @param msg
     */
    private void sendMsgToAllClient(String msg, Socket socket) throws IOException {
        for (Socket socket1 : Server.allSocketOnline) {
            //只发送给除自己以外的客户端
            if (socket != socket1) {
                PrintStream ps = new PrintStream(socket1.getOutputStream());
                ps.println(msg);
                ps.flush();
            }
        }
    }
}
客户端代码:
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
import java.util.Scanner;
public class Client {
    public static void main(String[] args) {
        try {
            //1.请求与服务端的Socket对象连接
            Socket socket = new Socket("127.0.0.1", 9999);
            //收消息
            Thread clientThread = new ClientReaderThread(socket);
            clientThread.start();
            while (true) {
                //发消息
                OutputStream os = socket.getOutputStream();
                PrintStream ps = new PrintStream(os);
                //3. 使用循环不断的发送消息给服务端接收
                Scanner sc = new Scanner(System.in);
                //System.out.print("client send message:");
                String msg = sc.nextLine();
                ps.println(msg);
                ps.flush();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
客户端线程处理类:
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.Socket;
public class ClientReaderThread extends Thread {
    private Socket socket;
    public ClientReaderThread(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        try {
            while (true) {
                InputStream is = socket.getInputStream();
                //4.把字节输入流包装成一个缓存字符输入流
                BufferedReader br = new BufferedReader(new InputStreamReader(is));
                String msg;
                if ((msg = br.readLine()) != null) {
                    System.out.println(msg);
                }
            }
        } catch (Exception e) {
        }
    }
}
效果
先启动服务端Server,然后启动3个客户端Client 
9. 基于BIO模式下即时通讯
基于BIO模式下的即时通信,我们需要解决客户端到客户端的通信,也就是需要实现客户端与客户端的端口消息转发逻辑。
项目案例说明
本项目案例为即时通信的软件项目,适合基础加强的大案例,具备综合性。学习本项目案例至少需要具备如下java SE技术点:
- java面向对象设计,语法设计
- 多线程技术
- IO流技术
- 网络通信相关技术
- 集合框架
- 项目开发思维
- java常用api使用
……
功能清单简单说明
- 客户端登录功能
可以启动客户端进行登录,客户端登录只需要输入用户名和服务端IP地址即可。
- 在线人数实时更新
客户端用户登录后,需要同步更新所有客户端的联系人信息栏。
- 离线人数更新
检测到有客户端下线后,需要同步更新所有客户端的联系人信息栏。
- 群聊
任意一个客户端的消息,可以推动给当前所有的客户端接收。
- 私聊
任意一个客户端消息,可以推送给当前所有客户端接收。
- @消息
可以选择某个员工,然后发出的消息可以@该用户,但是其他所有人都能收到消息。
- 消息用户和消息时间点
服务端可以实时记录该用户的消息时间点,然后进行消息的多路转发或则选择。
技术选型分析
Java GUI,BIO
服务端设计
服务端接收多个客户端逻辑
- 服务端需要接收多个客户端,目前我们采取的策略是 一个客户端对应一个服务端线程
- 服务端除了要注册端口以外,还需要为每个客户端分配 一个独立线程处理与之通信
服务端主体代码,主要进行端口注册,和接收客户端,分配线程处理该客户端请求
服务端接收登陆消息以及监测离线
在上面我们实现了服务端可以接收多个客户端连接,接下来我们要接收客户端的登陆消息。
我们需要在服务端处理客户端线程的登陆消息。需要注意的是,服务端需要接收客户端的消息可能有很多种,分别是登陆消息,群聊消息,私聊消息和@消息。这里需要约定如果客户端发送消息之前需要先发送消息的类型,类型我们使用信号值标志(1,2,3)。
- 1代表接收的是登陆消息
- 2代表群发| @消息
- 3代表了私聊消息
服务端的线程中有异常校验机制,一旦发现客户端下线会在异常机制 中处理,然后移除当前客户端用户,把最新的用户列表 发回给全部客户端进行在线人数更新。
服务端接收群聊消息
在上面实现了接收客户端的登陆消息,然后提取当前在线的全部的用户名称和当前登陆的用户名称,发送给全部在线用户更新自己的在线人数列表。接下来要接收客户端发来的群聊消息,然后推送给当前在线的所有客户端。
服务端接收私聊消息
解决私聊消息的推送逻辑,私聊消息需要知道推送给某个具体的客户端。我们可以接收到客户端发来的私聊用户名称,根据用户名称定位该用户的Socket管道,然后单独推送消息给该Socket管道。
代码实现
常量类:
public class Constants {
    //端口
    public static final int  PORT = 7778;
    //逗号分割
    public static final String SPLIT = ",";
}
ServerChat:
import com.linxc.bio.instant_message.util.Constants;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
public class ServerChat {
    //定义一个集合存放所有在线的socket
    //在线集合只需要一个,存储客户端socket的同时还需要知道这个socket的客户端名称
    public static Map<Socket, String> onLineSockets = new HashMap<>();
    public static void main(String[] args) {
        //注册端口
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(Constants.PORT);
            //循环一直等待所有可能的客户端连接
            while (true) {
                Socket socket = serverSocket.accept();
                //把客户端的socket管道单独配置一个线程来处理
                new ServerReader(socket).start();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
ServerReader:
import com.linxc.bio.instant_message.util.Constants;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Set;
public class ServerReader extends Thread {
    private Socket socket;
    public ServerReader(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        DataInputStream dis = null;
        try {
            dis = new DataInputStream(socket.getInputStream());
            //1.循环一直等待客户端的消息
            while (true) {
                //2.读取当前的消息类型:登录,群发,私聊,@消息
                int flag = dis.readInt();
                System.out.println("服务flag:"+flag);
                if (flag == 1) {
                    //先将当前登录的客户端socket存到在线人数的socket集合中
                    String name =dis.readUTF();
                    System.out.println(name + "----->" + socket.getRemoteSocketAddress());
                    ServerChat.onLineSockets.put(socket, name);
                }
                writeMsg(flag, dis);
            }
        } catch (Exception e) {
            System.out.println("---有人下线了---");
            ServerChat.onLineSockets.remove(socket);
            try {
                //重新更新在线人数并发给所有客户端
                writeMsg(1, dis);
            } catch (Exception e1) {
                e1.printStackTrace();
            }
        }
    }
    private void writeMsg(int flag, DataInputStream dis) throws Exception {
        //定义一个变量存放最终的消息形式
        String msg = null;
        if (flag == 1) {
            //读取所有在线人数发给所有客户端去更新自己的在线人数列表
            StringBuilder rs = new StringBuilder();
            Collection<String> onlineNames = ServerChat.onLineSockets.values();
            //判断是否存在在线人数
            if (onlineNames != null && onlineNames.size() > 0) {
                for (String name : onlineNames) {
                    rs.append(name + Constants.SPLIT);
                }
                //去掉最后一个分隔符
                msg = rs.substring(0, rs.lastIndexOf(Constants.SPLIT));
                //将消息发送给所有的客户端
                sendMsgToAll(flag, msg);
            }
        }else if (flag == 2||flag ==3) {
            //读到消息群发的或者是@消息
            String newMsg = dis.readUTF();//消息
            String sendName = ServerChat.onLineSockets.get(socket);
            //内容
            StringBuilder msgFinal = new StringBuilder();
            //时间
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss EEE");
            if (flag == 2) {//群发消息和@消息
                msgFinal.append(sendName).append("").append(sdf.format(System.currentTimeMillis()*2)).append("\r\n");
                msgFinal.append(" ").append(newMsg).append("\r\n");
                sendMsgToAll(flag, msgFinal.toString());
            } else if (flag == 3) {//私聊消息
                msgFinal.append(sendName).append("").append(sdf.format(System.currentTimeMillis()*2)).append(" 对您私发\r\n");
                msgFinal.append("").append(newMsg).append("\r\n");
                //私发
                //得到发给谁
                String destName = dis.readUTF();
                sendMsgToOne(destName, msgFinal.toString());
            }
            System.out.println("发送消息:"+msgFinal);
        }
    }
    /**
     * @param destName 对谁私发
     * @param msg      发的消息内容
     * @throws Exception
     */
    private void sendMsgToOne(String destName, String msg) throws Exception {
        //拿到所有在线的socket管道 给这些管道写出消息
        Set<Socket> allOnLineSockets = ServerChat.onLineSockets.keySet();
        for (Socket sk : allOnLineSockets) {
            //得到当前需要私发的Socket
            //只对这个名字对应的socket私发消息
            if (ServerChat.onLineSockets.get(sk).trim().equals(destName)) {
                DataOutputStream dos = new
                        DataOutputStream(sk.getOutputStream());
                dos.writeInt(2);//消息类型
                dos.writeUTF(msg);
                dos.flush();
            }
        }
    }
    private void sendMsgToAll(int flag, String msg) throws Exception {
        //拿到所有的在线socket管道 给这些管道写出消息
        Set<Socket> allOnLineSockets = ServerChat.onLineSockets.keySet();
        for (Socket sk : allOnLineSockets) {
            DataOutputStream dos = new DataOutputStream(sk.getOutputStream());
            dos.writeInt(flag);//消息类型
            System.out.println(msg);
            dos.writeUTF(msg);
            dos.flush();
        }
    }
}
客户端设计
启动客户端界面,登录,刷新在线
客户端界面主要是GUI设计,主体页面分为和,以及。
登陆输入服务端ip和用户名后,要请求与服务端的登陆,然后立即为当前客户端分配一个读线程处理客户端的读数据消息。因为客户端可能随时会接收到服务端那边转发过来的各种即时消息信息。客户端登陆完成,服务端收到登陆的用户名后,会立即发来最新的用户列表给客户端更新。
客户端发送消息逻辑
客户端发送群聊消息,@消息,以及私聊消息。
实现步骤: 客户端启动后,在聊天界面需要通过发送按钮推送群聊消息,@消息,以及私聊消息。
代码实现
Client:
import com.linxc.bio.instant_message.util.Constants;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.DataOutputStream;
import java.net.Socket;
public class ClientChat implements ActionListener {
    //设计界面
    private JFrame win = new JFrame();
    //消息内容框架
    public JTextArea smsContent = new JTextArea(23,50);
    //发送消息的框
    private JTextArea smsSend = new JTextArea(4,40);
    //在线人数的区域
    public JList<String> onlineUsers = new JList<>();
    //是否私聊按钮
    private JCheckBox isPrivateBn = new JCheckBox("私聊");
    //消息按钮
    private JButton sendBn = new JButton("发送");
    //登录界面
    private JFrame loginView;
    private JTextField ipEt, nameEt, idEt;
    private Socket socket;
    public static void main(String[] args) {
        new ClientChat().initView();
    }
    private void initView() {
        /** 初始化聊天窗口的界面 */
        win.setSize(650, 600);
        /** 展示登录界面 */
        displayLoginView();
    }
    private void displayChatView() {
        JPanel bottomPanel = new JPanel(new BorderLayout());
        //将消息框和按钮添加到窗口的底端
        win.add(bottomPanel, BorderLayout.SOUTH);
        bottomPanel.add(smsSend);
        JPanel btns = new JPanel(new FlowLayout(FlowLayout.LEFT));
        btns.add(sendBn);
        btns.add(isPrivateBn);
        bottomPanel.add(btns, BorderLayout.EAST);
        //-----------------------------------------------
        //给发送消息按钮绑定点击事件监听器
        //将展示消息区centerPanel添加到窗口的中间
        smsContent.setBackground(new Color(0xdd, 0xdd, 0xdd));
        //让展示消息区可以滚动
        win.add(new JScrollPane(smsContent), BorderLayout.CENTER);
        smsContent.setEditable(false);
        //-------------------------------------------------
        //用户列表和是否私聊放到窗口的最右边
        Box rightBox = new Box(BoxLayout.Y_AXIS);
        onlineUsers.setFixedCellWidth(120);
        onlineUsers.setVisibleRowCount(13);
        rightBox.add(new JScrollPane(onlineUsers));
        win.add(rightBox, BorderLayout.EAST);
        //-------------------------------------------------
        //关闭窗口退出当前程序
        win.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        win.pack();//swing 加上这句 就可以拥有关闭窗口的功能
        /** 设置窗口居中,显示出来 */
        setWindowCenter(win, 650, 600, true);
        //发送按钮绑定点击事件
        sendBn.addActionListener(this);
    }
    private void displayLoginView() {
        /**
         * 先让用户进行登录
         * 服务端ip
         * 用户名
         * id
         */
        /** 显示一个qq的登录框 */
        loginView = new JFrame("登录");
        loginView.setLayout(new GridLayout(3, 1));
        loginView.setSize(400, 230);
        JPanel ip = new JPanel();
        JLabel label = new JLabel("IP:");
        ip.add(label);
        ipEt = new JTextField(20);
        ip.add(ipEt);
        loginView.add(ip);
        JPanel name = new JPanel();
        JLabel label1 = new JLabel("姓名:");
        name.add(label1);
        nameEt = new JTextField(20);
        name.add(nameEt);
        loginView.add(name);
        JPanel btnView = new JPanel();
        JButton login = new JButton("登录");
        btnView.add(login);
        JButton cancle = new JButton("取消");
        btnView.add(cancle);
        loginView.add(btnView);
        //关闭窗口退出当前程序
        loginView.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setWindowCenter(loginView, 400, 260, true);
        /** 给登录和取消绑定点击事件 */
        login.addActionListener(this);
        cancle.addActionListener(this);
    }
    private void setWindowCenter(JFrame frame, int width, int height, boolean
            flag) {/** 得到所在系统所在屏幕的宽高 */
        Dimension ds = frame.getToolkit().getScreenSize();
        /** 拿到电脑的宽 */
        int width1 = ds.width;
        /** 高 */
        int height1 = ds.height;
        System.out.println(width1 + "*" + height1);
        /** 设置窗口的左上角坐标 */
        frame.setLocation(width1 / 2 - width / 2, height1 / 2 - height / 2);
        frame.setVisible(flag);
    }
    @Override
    public void actionPerformed(ActionEvent e) {
        //得到点击的事件源
        JButton btn = (JButton) e.getSource();
        switch (btn.getText()) {
            case "登录":
                String ip = ipEt.getText().toString();
                String name = nameEt.getText().toString();
                //校验参数是否为空
                //错误提示
                String msg = "";
                //12.1.2.0
                // \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\
                if (ip == null ||
                        !ip.matches("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")) {
                    msg = "请输入合法的服务器ip地址";
                } else if (name == null || !name.matches("\\S{1,}")) {
                    msg = "姓名必须1个字符以上";
                }
                if (!msg.equals("")) {
                    //msg有内容说明参数有为空
                    //参数一:弹出放到那个窗口里面
                    JOptionPane.showMessageDialog(loginView, msg);
                } else {
                    try {
                        //参数都合法了
                        //当前登录的用户,去服务器登录
                        /** 先把当前用户的名称展示到界面*/
                        win.setTitle(name);
                        //去服务端登录连接一个socket管道
                        socket = new Socket(ip, Constants.PORT);
                        //为客户端的socket分配一个线程 专门负责收消息
                        new ClientReader(this, socket).start();
                        //带上用户信息过去
                        DataOutputStream dos = new
                                DataOutputStream(socket.getOutputStream());
                        dos.writeInt(1);//登录消息
                        dos.writeUTF(name.trim());
                        dos.flush();
                        //关闭当前窗口 弹出聊天界面
                        loginView.dispose();//登录窗口销毁
                        displayChatView();//展示了聊天窗口了
                    } catch (Exception e1) {
                        e1.printStackTrace();
                    }
                }
                break;
            case "取消":
                /** 退出系统*/
                System.exit(0);
                break;
            case "发送":
                //得到发送消息的内容
                String msgSend = smsSend.getText().toString();
                if (!msgSend.trim().equals("")) {
                    //发消息给服务端
                    try {
                        //判断是否对谁发消息
                        String selectName = onlineUsers.getSelectedValue();
                        int flag = 2;// 群发 @消息
                        if (selectName != null && !selectName.equals("")) {
                            msgSend = ("@" + selectName + "," + msgSend);
                            //判断是否选中私发
                            if (isPrivateBn.isSelected()) {
                                //私发
                                flag = 3;//私发消息
                            }
                        }
                        DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
                        dos.writeInt(flag);//群发消息 发送给所有人
                        dos.writeUTF(msgSend);
                        if (flag == 3) {
                            //告诉服务器端我对谁私发
                            dos.writeUTF(selectName.trim());
                        }
                        dos.flush();
                    } catch (Exception e1) {
                        e1.printStackTrace();
                    }
                }
                smsSend.setText(null);
                break;
        }
    }
}
ClientReader:
import com.linxc.bio.instant_message.util.Constants;
import java.io.DataInputStream;
import java.net.Socket;
import java.util.Arrays;
public class ClientReader extends Thread {
    private Socket socket;
    private ClientChat clientChat;
    public ClientReader(ClientChat clientChat, Socket socket) {
        this.clientChat = clientChat;
        this.socket = socket;
    }
    @Override
    public void run() {
        try {
            DataInputStream dis = new DataInputStream(socket.getInputStream());
            /** 循环一直等待客户端的消息 */
            while (true) {
                /** 读取当前的消息类型: 登录,群发,私聊,@消息 */
                int flag = dis.readInt();
                if (flag == 1) {
                    //在线人数消息回来了
                    String nameDatas = dis.readUTF();
                    System.out.println(nameDatas);
                    //展示到在线人数的界面
                    String[] names = nameDatas.split(Constants.SPLIT);
                    System.out.println(Arrays.toString(names));
                    clientChat.onlineUsers.setListData(names);
                } else if (flag == 2) {
                    //群发,私聊 ,@消息 都是直接显示的
                    String msg = dis.readUTF();
                    clientChat.smsContent.append(msg);
                    //让消息界面滚动到低端
                    clientChat.smsContent.setCaretPosition(clientChat.smsContent.getText().length());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
