Java的网络编程基础

如果把IP地址理解为某个人所在地方的地址(包括街道和门牌号),但仅有地址还是找不到这个人,还需要知道他所在的房号才可以找到这个人。因此如果把应用程序当作人,把计算机网络当作类似邮递员的角色,当一个程序需要发送数据时,需要指定目的地的IP地址和端口,如果指定了正确的IP地址和端口号,计算机网络就可以将数据送给该IP地址和端口所对应的程序。

Java的基本网络支持

Java为网络提供了java.net包,该包下有URL和URLConnection等类用来访问Web服务。而URLDecoder和URLEncoder提供了普通字符串和application/x-www-form-urlencoded MIME字符串相互转化的静态方法。

使用InetAddress

public class InetAddressTest {
    public static void main(String[] args) throws Exception{
        //根据主机名来获取对应的InetAddress实例
        InetAddress ip = InetAddress.getByName("www.baidu.com");
        //判断是否可达到
        System.out.println("百度是否可达到:"+ip.isReachable(2000));
        //获取该InetAddress实例的IP字符串
        System.out.println(ip.getHostAddress());
        //根据原始IP地址来获取对应的InetAddress实例
        InetAddress local = InetAddress.getByAddress(new byte[]{127, 0, 0, 1});
        System.out.println("本机是否可达:"+local.isReachable(5000));
        //获取该InetAddress实例对应的全限定域名
        System.out.println(local.getCanonicalHostName());
//        -----------------------------------------------
        System.out.println("------------分界线-------------------------");
        InetAddress[] xxx= InetAddress.getAllByName("xxx");
        for (InetAddress zhenyunIp:zhenyun){
            System.out.println("xxxIP:"+xxx.getHostAddress());
            System.out.println("xxx始IP:"+Arrays.toString(xxx.getAddress()));
            System.out.println("此IP地址的全限定域名,或者如果安全检查不允许该操作,则IP地址的文本表示形式:"+xxx.getCanonicalHostName());
            System.out.println("该IP地址的主机名,或者如果安全检查不允许进行操作,则为IP地址的文本表示形式:"+xxx.getHostName());
            System.out.println("--------------------------");
        }
    }
}

Java提供了InetAddress类来代表IP地址,InetAddress下还有两个子类:Inet4Address和Inet6Address,它们分别代表IPv4和IPv6地址。

InetAddress类没有提供构造函数,提供了三个静态方法来获取InetAddress实例。

  • getByName(String host)
  • getAllByName(String host):获取的是一个数组
  • getByAddress(byte[] addr)

InetAddress类还提供了一个isReachable()方法,用于测试是否可以到达该地址。该方法将尽最大努力试图到达主机,但防火墙和服务器配置可能阻塞请求,使得它在访问某些特定的端口时处于不可达状态。

使用URLDecoder和URLEncoder

URLDecoder和URLEncoder提供了普通字符串和application/x-www-form-urlencoded MIME字符串相互转化的静态方法。

比如:http://doc.xxx/#/zh-CN/%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3/%E5%BC%80%E5%8F%91%E8%A7%84%E8%8C%83/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91%E8%A7%84%E8%8C%83/%E5%90%8E%E7%AB%AF%E5%88%86%E6%94%AF%E5%8F%91%E5%B8%83%E8%A7%84%E8%8C%83这个地址,当其中包含了中文时,这些关键字就会变成"乱码",实际上这就是application/x-www-form-urlencoded MIME字符串。

当URL地址里包含非西欧字符的字符串时,系统就会把这些非西欧字符串转换成特殊字符串。编程过程中可能涉及普通字符串和特殊字符串的相关转换,这就需要使用URLDecoder和URLEncoder类。

  • URLDecoder类包含一个decode(String s,String enc)静态方法,它可以将看上去是乱码的特殊字符串转化为普通字符串
  • URLEncoder类包含一个encode(String s,String enc)静态方法,他可以将普通字符串转换成application/x-www-form-urlencoded MIME字符串
public class URLDecoderTest {
    public static void main(String[] args) throws Exception{
        //将application/x-www-form-urlencoded MIME字符串转换成普通字符串
        String keyword = URLDecoder.decode("http://doc.xxx/#/zh-CN/%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3/%E5%BC%80%E5%8F%91%E8%A7%84%E8%8C%83/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91%E8%A7%84%E8%8C%83/%E5%90%8E%E7%AB%AF%E5%88%86%E6%94%AF%E5%8F%91%E5%B8%83%E8%A7%84%E8%8C%83", "utf-8");
        System.out.println("解密结果:"+keyword);
        //将普通字符串转换成application/x-www-form-urlencoded MIME字符串
        String urlStr = URLEncoder.encode("你好Java", "utf-8");
        System.out.println("加密结果:"+urlStr);
    }
}

运行结果:

解密结果:http://doc.xxx/#/zh-CN/开发文档/开发规范/后端开发规范/后端分支发布规范
加密结果:%E4%BD%A0%E5%A5%BDJava

URL、URLConnection和URLPermission

URL对象代表统一资源定位器,它是指向互联网"资源"的指针。资源可以是简单的文件或者目录,也可以是对更为复杂对象的引用,例如对数据库或搜索引擎的查询。

public class DownUtil {
    //定义下载资源的路径
    private String path;
    //指定所下载的文件的保存位置
    private String targetFile;
    //定义需要使用多少个线程下载资源
    private int threadNum;
    //定义下载的线程对象
    private DownThread[] threads;
    //定义下载的文件的总大小
    private int fileSize;

    public DownUtil(String path, String targetFile, int threadNum) {
        this.path = path;
        this.targetFile = targetFile;
        this.threadNum = threadNum;
        //初始化threads数组
        this.threads = new DownThread[threadNum];
    }

    //获取下载的完成百分比
    public double getCompleteRate() {
        //统计多个线程已经下载的总大小
        int sumSize = 0;
        for (int i = 0; i < threadNum; i++) {
            sumSize += threads[i].length;
        }
        //返回已经完成的百分比
        return sumSize*1.0/fileSize;
    }
    //多线程下载实现
    public void download() throws Exception {
        URL url = new URL(path);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setConnectTimeout(5*1000);
        conn.setRequestMethod("GET");
        conn.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9");
        conn.setRequestProperty("Accept-Language", "zh-CN");
        conn.setRequestProperty("Charset", "utf-8");
        conn.setRequestProperty("Connection","Keep-Alive");
        //得到文件大小
        fileSize = conn.getContentLength();
        conn.disconnect();
        int currentPartSize = fileSize / threadNum + 1;
        RandomAccessFile file = new RandomAccessFile(targetFile, "rw");
        //设置本地文件的大小
        file.setLength(fileSize);
        file.close();
        for (int i = 0; i < threadNum; i++) {
            //计算每个线程下载的开始位置
            int startPos = i * currentPartSize;
            //每个线程使用一个RandomAccessFile进行下载
            RandomAccessFile currentPart = new RandomAccessFile(targetFile, "rw");
            //定义该线程的下载位置
            currentPart.seek(startPos);
            //创建下载线程
            threads[i] = new DownThread(startPos, currentPartSize, currentPart);
            //启动下载线程
            threads[i].start();
        }
    }

    private class DownThread extends Thread {
        //当前线程的下载位置
        private int startPos;
        //定义当前线程负责下载的文件大小
        private int currentPartSize;
        //当前线程需要下载的文件块
        private RandomAccessFile currentPart;
        //定义该线程已经下载的字节数
        public int length;

        public DownThread(int startPos, int currentPartSize, RandomAccessFile currentPart) {
            this.startPos = startPos;
            this.currentPartSize = currentPartSize;
            this.currentPart = currentPart;

        }

        @Override
        public void run() {
            try {
                URL url = new URL(path);
                HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                conn.setConnectTimeout(5 * 10000);
                conn.setRequestMethod("GET");
                conn.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9");
                conn.setRequestProperty("Accept-Language", "zh-CN");
                conn.setRequestProperty("Charset", "utf-8");
                InputStream is = conn.getInputStream();
                //跳过startPos字符串,表明该线程只下载自己负责的那部分
                is.skip(this.startPos);
                byte[] buffer = new byte[1024];
                int hasRead = 0;
                //读取网络数据,并写入本地文件
                while (length < currentPartSize && (hasRead = is.read(buffer)) != -1) {
                    currentPart.write(buffer, 0, hasRead);
                    //累计该线程下载的总大小
                    length += hasRead;
                }
                currentPart.close();
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

这个程序就是一个多线程下载工具类该线程,该线程负责读取从start开始,到end结束的所有字节数据,并写入RandomAccessFile对象,这个DownThread线程类的run()方法就是一个简单的输入、输出实现。操作步骤如下:

  1. 创建URL对象
  2. 获取指定URL对象所指向资源的大小
  3. 在本地磁盘创建一个与网络资源具有相同大小的空文件
  4. 计算每个线程应该下载网络资源的那个部分
  5. 依次创建、启动多个线程来下载网络资源的指定部分。

在主程序中调用该工具类的下载方法:

public class MultiThreadDown {
    public static void main(String[] args) throws Exception{
        //初始化DownUtil对象
        //645mb:http://download.h2os.com/oneplus_one/opentest/third/H2OS1.zip
        //3gb:http://download.h2os.com/OnePlus8Pro/MP/OnePlus8ProHydrogen_15.H.12_OTA_012_all_2004072116_6d58f47f049247c7.zip
        /*10个线程
        * 已完成:0.0
        已完成:0.001446506762931083
        已完成:0.002908773949972712
        已完成:0.004360257455693975
        */
        /*120个线程
        开始时间:2020-04-26T14:25:57.369
        已完成:0.0
        已完成:1.4599479569494405E-4
        已完成:2.710854990111896E-4
        已完成:4.0048967485558154E-4
        下载速度峰值大概是1m/s
        * */
        /*128个线程
        * 开始时间:2020-04-26T14:09:42.868
        已完成:0.0
        已完成:1.2442743305421207E-4
        已完成:2.452046638423112E-4
        已完成:3.5304147704597115E-4
        已完成:4.781321803622167E-4
        已完成:6.032228836784623E-4*/
        /*150个线程
        * 开始时间:2020-04-26T14:15:02.697
        已完成:0.0
        已完成:5.97253451320161E-5
        已完成:8.776291656496769E-5
        已完成:1.3521111437457808E-4*/
        /*200个线程
        * 开始时间:2020-04-26T14:17:04.606
        已完成:0.0
        已完成:1.1364375173384608E-4
        已完成:2.2363730120157922E-4
        java.net.ConnectException: Connection timed out: connect
        出现超时异常*/
        DownUtil downUtil = new DownUtil("http://download.h2os.com/oneplus_one/opentest/third/H2OS1.zip", "一加1.zip", 120);
        //开始下载
        LocalDateTime begin = LocalDateTime.now();
        System.out.println("开始时间:"+begin);
        downUtil.download();
        new Thread(()->{
            while (downUtil.getCompleteRate()<1){
                System.out.println("已完成:"+downUtil.getCompleteRate());
                try {
                    TimeUnit.SECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("end:"+LocalDateTime.now());
        }).start();
    }
}

Java8新增了一个URLPermission工具类,用于管理HTTPURLConnection的权限问题,如果在HTTPURLConnection安装了安全管理器,通过该对象打开连接时就需要先获得权限。
通常创建一个和URL的连接,并发送请求、读取此URL引用的资源需要如下几个步骤。

  1. 通过调用URL对象的openConnection()方法来创建URLConnection对象
  2. 设置URLConnection的参数和普通请求属性
  3. 如果只是发送GET方式请求,则使用connect()方法建立与远程资源之间的实际连接即可;如果需要发送POST方式的请求,则需要获取URLCOnnection实例对应的输出流来发送请求参数。
  4. 远程资源变为可用,程序可以访问远程资源的头字段或通过输入流读取远程资源的数据。

基于TCP协议的网络编程

TCP/IP通信协议是可靠的,它在通信的两端各建立一个Socket,从而在通信的两端之间形成网络虚拟链路。Java使用Socket对象来代表两端的通信端口,并通过Socket产生IO流来进行网络通信。

TCP协议基础

IP协议负责将消息从一个主机送到另一个主机,消息在传送的过程中被分割成一个个小包。
TCP协议被称作一种端对端协议。当一台计算机需要与另一台远程计算机连接时,TCP协议会让它们建立一个连接:用于发送和接收数据的虚拟链路。
TCP协议负责收集信息包,并将其按适当的次序放好传送,接收端收到后再将它正确地还原。TCP协议保证了数据包在传送中准确无误。TCP协议使用重发机制——当一个通信实体发送一个消息给另一个通信实体后,需要收到另一个通信实体的确认信息,如果没有收到另一个通信实体的确认信息,则会再次重发刚才发送的信息。

使用ServerSocket创建TCP服务器端

Java中能接收其他通信实体连接的类是ServerSocket,ServerSocket对象用于监听来自客户端的Socket连接。如果没有连接,它将一直处于等待状态。ServerSocket包含一个监听来自客户端连接请求的方法。Socket accept():如果接收到一个客户端Socket的连接请求,该方法返回一个与客户端Socket对应的Socket。

使用Socket进行通信

上面构造器中指定远程主机既可以用InetAddress也可以用String对象。

现在创建一个Socket服务端和客户端

服务端:

public class Server {
    public static void main(String[] args) throws Exception{
        //创建一个ServerSocket,用于监听客户端Socket的连接请求
        ServerSocket ss = new ServerSocket(30000);
        //采用循环不断地接收来自客户端的请求
        new Thread(() -> {
            while (true){
                try {
                    //每当接收到客户端Socket的请求时,服务器端也对应产生一个Socket
                    Socket s = ss.accept();
                    //将Socket对应的输出流包装成PrintStream
                    PrintStream ps = new PrintStream(s.getOutputStream());
                    //进行普通IO操作
                    ps.println("你好,你收到了服务器的消息!");
                    //关闭输出流,关闭Socket
                    ps.close();
                    s.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

客户端:

public class Client {
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("127.0.0.1", 30000);
        //将Socket对应的输入流包装成BufferedReader
        BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        //进行普通IO操作
        String line = br.readLine();
        System.out.println("来自服务器的数据:"+line);
        //关闭输入流,关闭Socket
        br.close();
        socket.close();
    }
}

运行client之后,输出:来自服务器的数据:你好,你收到了服务器的消息!

在实际情况下,一般是会设置client连接服务器超时时间的:

Socket socket = new Socket("127.0.0.1", 30000);
//设置10s后即确认为超时
socket.setSoTimeout(10000);

加入多线程

当服务器端不断地读取客户端数据,并向客户端写入数据;客户端也需要不断地读取服务器端数据,并向服务器端写入数据。

在使用传统BufferedReader的readLine()方法读取数据时,在该方法成功返回之前,线程被阻塞,程序无法继续执行。所以,服务器端应该为每一个Socket单独启动一个线程,每个线程负责读取服务器端数据。

客户端读取服务器端数据的线程同样会被阻塞,所以系统应该单独启动一个线程,该线程专门负责读取服务器端数据。

现在考虑实现一个命令行界面的C/S聊天室应用,服务器端应该包含多个线程,每个Socket对应一个线程,该线程负责读取Socket对应输入流的数据(从客户端发送过来的数据),并将读取到的数据向每个Socket输出流发送一次(将一个客户端发送的数据"广播"给其他客户端),因此需要在服务器端使用List来保存所有的Socket。

下面是服务器端的实现代码,程序为服务器端提供了两个类,一个是创建ServerSocket监听的主类,一个是负责处理每个Socket通信的线程类。

public class MyServer {
    //定义保存所有Socket的ArrayList,并将其包装为线程安全的
    public static List<Socket> socketList =
            Collections.synchronizedList(new ArrayList<>());

    public static void main(String[] args) throws Exception {
        ServerSocket ss = new ServerSocket(30000);
        while (true){
            //此行代码会阻塞,将一直等待别人的连接
            Socket s = ss.accept();
            socketList.add(s);
            //每当一个客户端连接后启动一个ServerThread线程为客户端服务
            new Thread(new ServerThread(s)).start();
        }
    }
}


public class ServerThread implements Runnable{
    //定义当前线程所处理的Socket
    Socket s = null;
    //该线程所处理的Socket对应的输入流
    BufferedReader br = null;

    public ServerThread(Socket s) {
        this.s = s;
        init();
    }

    public void init(){
        //初始化Socket对应的输入流
        try {
            br = new BufferedReader(new InputStreamReader(s.getInputStream()));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void run() {
        try {
            String content = null;
            //采用循环不断地从Socket中读取客户端发送过来的数据
            while ((content = readFromClient()) != null) {
                //遍历socketList中的每一个Socket
                //将从客户端socket读到的内容向每一个Socket发送一次
                for (Socket s:MyServer.socketList){
                    PrintStream ps = new PrintStream(s.getOutputStream());
                    ps.println("服务器通知:"+content);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //定义读取客户端数据的方法
    private String readFromClient(){
        try {
            return br.readLine();
        }//如果捕获到异常,则表明该Socket对应的客户端已经关闭
        catch (IOException e) {
            //删除该Socket;
            MyServer.socketList.remove(s);
        }
        return null;
    }
}

每个客户端有两个线程,一个负责读取用户的键盘输入,并将用户输入的数据写入Socket对应的输出流中;一个负责读取Socket对应输入流中的数据(从服务器端发送过来的数据),并将这些数据打印输出。

public class MyClient {
    public static void main(String[] args) throws Exception{
        Socket s = new Socket("127.0.0.1", 30000);
        //客户端启动ClientThread线程不断地读取来自服务器的数据
        new Thread(new ClientThread(s)).start();
        //获取该Socket对应的输出流
        PrintStream ps = new PrintStream(s.getOutputStream());
        String line = null;
        //不断地读取键盘输入
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        while ((line=br.readLine())!=null){
            //将用户键盘输入内容写入到SOcket对应的输出流
            ps.println(line);
        }
    }
}

public class ClientThread implements Runnable {
    //该线程负责处理的Socket
    private Socket s;
    //该线程所处理的Socket对应的输入流
    BufferedReader br = null;

    public ClientThread(Socket s) {
        this.s = s;
        init();
    }

    private void init(){
        try {
            br = new BufferedReader(new InputStreamReader(s.getInputStream()));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void run() {
        try {
            String content = null;
            //不断读取Socket输入流中的内容,并打印
            while ((content=br.readLine())!=null){
                System.out.println(content);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

记录用户信息

下面程序考虑使用Map保存用户状态信息,实现私聊功能。

  • 客户端发送来的信息必须有特殊标识——让服务器端可以判断是公聊信息还是私聊信息
  • 如果是私聊信息,客户端会发送该消息的目的用户给服务端。服务端如何把该信息发送给该私聊对象。

为了解决第一个问题,可以让客户端在发送不同的信息之前,先对这些信息进行适当处理,比如在内容前后添加一些特殊字符。如下:

public interface CrazyitProtocol {
    //定义协议字符串的长度
    int PROTOCOL_LEN=2;
    //下面是一些协议字符串,服务器和客户端交换的信息都是应该在前、后添加这种特殊字符串
    String MSG_ROUND="$Y";
    String USER_ROUND="@Y";
    String LOGIN_SUCCESS="1";
    String NAME_REP="-1";
    String PRIVATE_ROUND="!Y";
    String SPLIT_SIGN="#Y";
}

为了解决第二个问题,考虑使用一个Map来保存聊天室所有用户和对应Socket之间的映射关系。但实际上本程序并未这么做,程序仅仅是用Map保存了聊天室所有用户名和对应输出流之间的映射关系,因为服务器端只要获取该用户名对应的输出流即可。这个Map不允许value重复,并提供了根据value获取key,根据value删除key等方法:

public class CrazyitMap<K,V> {
    //创建一个线程安全的HashMap
    public Map<K,V> map = Collections.synchronizedMap(new HashMap<K,V>());
    //根据value来删除指定项
    public synchronized void removeByValue(Object value) {
        for (Object key:map.keySet()) {
            if (map.get(key) == value ){
                map.remove(key);
                break;
            }
        }
    }
    //获取所有value组成的Set集合
    public synchronized Set<V> valueSet() {
        Set<V> result = new HashSet<>();
        //将map中所有value添加到result中
        map.forEach((key,value)->result.add(value));
        return result;
    }
    //根据value查找key
    public synchronized K getKeyByValue(V val) {
        //遍历所有key组成的集合
        for (K key:map.keySet()) {
            //如果指定key对应的value与被搜索的value相同,则返回对应的key
            if (map.get(key)==val || map.get(key).equals(val)) {
                return key;
            }
        }
        return null;
    }
    //实现put方法,该方法不允许value重复
    public synchronized V put(K key,V value) {
        //遍历所有value组成的集合
        for (V val:valueSet()){
            //如果某个value与试图放入集合的value相同
            //抛出异常
            if (val.equals(value) && val.hashCode()==value.hashCode()){
                throw new RuntimeException("MyMap实例不允许有重复value");
            }
        }
        return map.put(key,value);
    }
}

服务器端的主类一样只是建立ServerSocket来监听来自客户端Socket的连接请求:

public class LtServer {
    private static final int SERVER_PORT=30000;
    //使用CrazyitMap对象来保存每个客户端名字和对应输出流之间的对应关系
    public static CrazyitMap<String, PrintStream> clients = new CrazyitMap<>();

    public void init(){
        try {
            //建立监听的ServerSocket
            ServerSocket ss = new ServerSocket(SERVER_PORT);
            {
                //采用死循环来不断接收客户端的请求
                while (true) {
                    Socket socket = ss.accept();
                    new LtServerThread(socket).start();
                }
            }
        } catch (IOException e) {
            System.out.println("服务启动失败,是否端口"+SERVER_PORT+"被占用?");
        }
    }

    public static void main(String[] args) {
        LtServer server = new LtServer();
        server.init();
    }
}

服务器端线程类,需要处理公聊和私聊两类聊天信息:

public class LtServerThread extends Thread{
    private Socket socket;
    BufferedReader br = null;
    PrintStream ps = null;
    //定义一个构造器,用于接收一个Socket来创建ServerThread线程
    public LtServerThread(Socket socket){
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            //获取Socket的输入流
            br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            //获取该Socket对应的输出流
            ps = new PrintStream(socket.getOutputStream());
            String line = null;
            while ((line = br.readLine())!=null) {
                //如果读到的行以CrazyitProtocol.USER_ROUND开始,并以其结束则可以确定读到的是用户登录的用户名
                if (line.startsWith(CrazyitProtocol.USER_ROUND) && line.endsWith(CrazyitProtocol.USER_ROUND)) {
                    //得到真实消息
                    String userName = getRealMsg(line);
                    //如果用户名重复
                    if (LtServer.clients.map.containsKey(userName)){
                        System.out.println("重复");
                        ps.println(CrazyitProtocol.NAME_REP);
                    }else {
                        System.out.println("成功");
                        ps.println(CrazyitProtocol.LOGIN_SUCCESS);
                        LtServer.clients.put(userName,ps );
                    }
                }
                //如果读到的行以CrazyitProtocol.PRIVATE_ROUND开始,和结束
                //则确定是私聊信息,私聊信息只向特定的输出流发送
                else if (line.startsWith(CrazyitProtocol.PRIVATE_ROUND) &&
                        line.endsWith(CrazyitProtocol.PRIVATE_ROUND)){
                    //得到真实值
                    String userAndMsg = getRealMsg(line);
                    //以SPLIT_SIGN分割字符串,前半是私聊用户,后半是聊天信息
                    String user = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[0];
                    String msg = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[1];
                    //获取私聊用户对应的输出流,并发送私聊信息
                    LtServer.clients.map.get(user).println(LtServer.clients.getKeyByValue(ps)
                            +"悄悄对你说:"+msg);

                }//公聊
                else {
                    String msg = getRealMsg(line);
                    //遍历clients中每个输出流
                    for (PrintStream clientPs:LtServer.clients.valueSet()) {
                        clientPs.println(LtServer.clients.getKeyByValue(ps)+"说:"+msg);
                    }
                }
            }
        }//捕获异常后,表示Socket对应的客户端已经出现了问题
        catch (IOException e) {
            LtServer.clients.removeByValue(ps);
            System.out.println(LtServer.clients.map.size());
            try {
                if (br!=null){
                    br.close();
                }
                if (ps!=null){
                    ps.close();
                }
                if (socket!=null){
                    socket.close();
                }
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        }
    }

    //将读到的内容去掉前后协议字符,恢复为真实数据
    private String getRealMsg(String line) {
        return line.substring(CrazyitProtocol.PROTOCOL_LEN,line.length()-CrazyitProtocol.PROTOCOL_LEN);
    }
}

客户端主类增加用户输入用户名,并且不允许用户名重复。除此之外,还可以根据用户的键盘输入来判断用户是否想发送私聊信息:

public class LtClient {
    private static final int SERVER_PORT =30000;
    private Socket socket;
    private PrintStream ps;
    private BufferedReader brServer;
    private BufferedReader keyIn;
    public void init() {
        try {
            //初始化代表键盘的输入流
            keyIn    = new BufferedReader(new InputStreamReader(System.in));
            //连接到服务器
            socket = new Socket("127.0.0.1", SERVER_PORT);
            //获取该Socket对应的输入流和输出流
            ps = new PrintStream(socket.getOutputStream());
            brServer = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            String tip="";
            //采用循环不断地弹出对话框要求输入用户名
            while (true){
                String userName = JOptionPane.showInputDialog(tip+"输入用户名");
                //在用户输入的用户名前后增加协议字符串后发送
                ps.println(CrazyitProtocol.USER_ROUND+userName+CrazyitProtocol.USER_ROUND);
                //读取服务器端的响应
                String result = brServer.readLine();
                //如果用户名重复,开始下次循环
                if (result.equals(CrazyitProtocol.NAME_REP)){
                    tip="用户名重复!";
                    continue;
                }
                //如果服务端返回登录成功,则循环结束
                if (result.equals(CrazyitProtocol.LOGIN_SUCCESS)) {
                    break;
                }
            }
        } catch (IOException e) {
            System.out.println("找不到远程服务器");
            System.exit(1);
        }
        //以该Socket对应的输入流启动ClientThread线程
        new LtClientThread(brServer).start();
    }
    //定义一个读取键盘输出,并向网络发送的方式
    private void readAndSend(){
        try {
            //不断读取键盘输入
            String line = null;
            while ((line = keyIn.readLine())!=null) {
                //如果发送的信息中有冒号,且以//开头,则认为想发送私聊信息
                if (line.indexOf(":")>0&&line.startsWith("//")) {
                    line = line.substring(2);
                    ps.println(CrazyitProtocol.PRIVATE_ROUND+
                            line.split(":")[0]+CrazyitProtocol.SPLIT_SIGN
                            +line.split(":")[1]+CrazyitProtocol.PRIVATE_ROUND);
                }
                else {
                    ps.println(CrazyitProtocol.MSG_ROUND+line+CrazyitProtocol.MSG_ROUND);
                }
            }
        } catch (IOException e) {
            System.out.println("网络通信异常");
            closeRs();
            System.exit(1);
        }
    }

    //关闭Socket、输入流、输出流的方法
    private void closeRs(){
        try {
            if (keyIn!=null){
                ps.close();
            }
            if (brServer!=null){
                ps.close();
            }
            if (ps!=null){
                ps.close();
            }
            if (socket!=null){
                keyIn.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        LtClient ltClient = new LtClient();
        ltClient.init();
        ltClient.readAndSend();
    }
}

客户端线程类,主要是读取服务端发送的信息:

public class LtClientThread extends Thread {
    //该客户端负责处理的输入流
    BufferedReader br = null;
    //使用一个网络输入流来创建客户端线程
    public LtClientThread(BufferedReader br){
        this.br = br;
    }

    @Override
    public void run() {
        try {
            String line = null;
            //不断地从输入流中读取数据,并键这些数据打印
            while ((line=br.readLine())!=null){
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                if (br !=null){
                    br.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

半关闭的Socket

当使用URLConnection来获取远程主机的数据,远程主机响应的内容就包含很多数据——在这种情况下,需要解决一个问题:Socket的输出流如何表示输出数据已经结束?

在IO中,如果要表示输出已经结束,可以通过关闭输出流来实现。但是在网络通信中则不能通过关闭输出流来表示输出已经结束,因为当关闭输出流时,该输出流对应的Socket也就关闭了,导致程序无法再从Socket的输入流中读取数据。

因此,Socket提供了两个半关闭的方法,只关闭Socket的输入流或输出流,用来表示输出数据已经发送完成。

public class HalfCloseServer {
    public static void main(String[] args) throws Exception{
        ServerSocket ss = new ServerSocket(30000);
        Socket socket = ss.accept();
        PrintStream ps = new PrintStream(socket.getOutputStream());
        ps.println("服务器的第一行数据");
        ps.println("服务器的第二行数据");
        //关闭socket输出流,表明socket未关闭
        socket.shutdownOutput();
        //判断socket是否关闭
        System.out.println(socket.isClosed());
        Scanner scan = new Scanner(socket.getInputStream());
        while (scan.hasNextLine()){
            System.out.println(scan.nextLine());
        }
        scan.close();
        socket.close();
        ss.close();
    }
}

使用NIO实现非阻塞Socket通信

前面介绍的网络通信是基于阻塞式API——当程序执行输入、输出操作后,在这些操作返回之前会一直阻塞该线程,所以服务器端必须为每个客户端都提供一个独立线程进行处理,当服务器端需要同时处理大量客户端时,这种做法会导致性能下降。使用NIO API可以让服务器端使用一个或有限几个线程来同时处理连接到服务器端的所有客户端。

下面是一个使用NIO的聊天:

public class NioServer {
    //用来检测所有channel状态的Selector
    private Selector selector = null;
    static final int PORT = 30000;
    //定义实现编码、解码的字符集对象
    private Charset charset = Charset.forName("UTF-8");
    public void init() throws Exception {
        selector = Selector.open();
        //通过open方法打开一个未绑定的ServerSocketChannel实例
        ServerSocketChannel server = ServerSocketChannel.open();
        InetSocketAddress isa = new InetSocketAddress("127.0.0.1", PORT);
        //将该ServerSocketChannel绑定到指定IP地址
        server.bind(isa);
        //设置ServerSocket以非阻塞方式工作
        server.configureBlocking(false);
        //将server注册到指定Selector对象
        server.register(selector, SelectionKey.OP_ACCEPT);
        while (selector.select()>0){
            //依次处理selector上每个已选择的SelectionKey;selectedKeys()表示所有可通过select()获取的,需要IO处理的channel
            for (SelectionKey sk:selector.selectedKeys()){
                //从selector上的已选择key集中删除正在处理的selectionKey
                selector.selectedKeys().remove(sk);
                //如果sk对应的Channel包含客户端的连接请求
                if (sk.isAcceptable()){
                    //调用accept方法接收连接,产生服务器端的SocketChannel
                    SocketChannel sc = server.accept();
                    //设置采用非阻塞模式
                    sc.configureBlocking(false);
                    //将该SocketChannel也注册到selector
                    sc.register(selector,SelectionKey.OP_READ);
                    //将sk对应的Channel设置成准备接收其他请求
                    sk.interestOps(SelectionKey.OP_ACCEPT);
                }
                //如果sk对应的Channel有数据需要读取
                if (sk.isReadable()){
                    //获取该SelectorKey对应的Channel,该Channel中有可读的数据
                    SocketChannel sc = (SocketChannel) sk.channel();
                    //定义准备执行读取数据的ByteBuffer
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    String content = "";
                    //开始读取数据
                    try {
                        while (sc.read(buffer)>0){
                            buffer.flip();
                            content += charset.decode(buffer);
                        }
                        //打印从该sk对应的Channel里读取到的数据
                        System.out.println("读取的数据:"+content);
                        //将sk对应的Channel设置成准备下一次读取
                        sk.interestOps(SelectionKey.OP_READ);
                    } catch (IOException e) {
                        //从Selector中删除指定的SelectionKey
                        sk.channel();
                        if (sk.channel()!=null) {
                            sk.channel().close();
                        }
                    }
                    //如果content的长度大于0,即聊天信息不为空
                    if (content.length()>0){
                        //遍历该selector里注册的所有SelectionKey
                        for (SelectionKey key:selector.keys()){
                            //获取该key对应的Channel
                            Channel targetChannel = key.channel();
                            //如果该Channel是SocketChannel对象
                            if (targetChannel instanceof SocketChannel){
                                //将读取到的内容写入该Channel中
                                SocketChannel dest = (SocketChannel) targetChannel;
                                dest.write(charset.encode(content));
                            }
                        }
                    }
                }
            }
        }
    }

    public static void main(String[] args) throws Exception{
        new NioServer().init();
    }
}

这是服务端的代码,启动时创建了一个可监听连接请求的ServerSocketChannel,并将该ServerSocketChannel注册到指定的Selector,接着程序直接采用死循环监控Selector对象的select()方法返回值,当返回值大于0时,处理该Selector上所有被选择的SelectionKey。
开始处理指定的SelectionKey之后,立即从该Selector上被选择的SelectionKey集合中删除该SelectionKey。
服务器端的Selector仅需要监听两种操作:连接和读取数据,所以程序中分别处理了这两种操作。处理连接操作时,系统只需要将连接完成后产生的SocketChannel注册到指定的Selector对象即可;处理读数据操作时,系统先从该Socket中读取数据,再将数据写入Selector上注册的所有Channel中。

客户端程序如下:

public class NioClient {
    //定义检测SocketChannel的Selector对象
    private Selector selector = null;
    static final int PORT = 30000;
    //定义处理编码和解码的字符集
    private Charset charset = Charset.forName("UTF-8");
    //客户端SocketChannel
    private SocketChannel sc = null;
    public void init() throws IOException {
        selector = Selector.open();
        InetSocketAddress isa = new InetSocketAddress("127.0.0.1", PORT);
        //调用open静态方法创建连接到指定主机的SocketChannel
        sc = SocketChannel.open(isa);
        //设置该sc以非阻塞方式工作
        sc.configureBlocking(false);
        //将SocketChannel对象注册到指定的Selector
        sc.register(selector, SelectionKey.OP_READ);
        //启动读取服务器端数据的线程
        new NioClientThread().start();
        //创建键盘输入流
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNextLine()){
            //读取键盘输入
            String line = scanner.nextLine();
            //将键盘输入的内容输出到SocketChannel中
            sc.write(charset.encode(line));
        }
    }

    //定义读取服务器端数据的线程
    private class NioClientThread extends Thread{
        @Override
        public void run() {
            try {
                while (selector.select()>0){
                    //遍历每个有可用IO操作的Channel对应的SelectionKey
                    for (SelectionKey sk:selector.selectedKeys()){
                        //删除正在处理的SelectionKey
                        selector.selectedKeys().remove(sk);
                        //如果该SelectionKey对应的Channel中有可读的数据
                        if (sk.isReadable()){
                            //使用NIO读取Channel中的数据
                            SocketChannel sc = (SocketChannel) sk.channel();
                            ByteBuffer buffer = ByteBuffer.allocate(1024);
                            String content = "";
                            while (sc.read(buffer)>0){
                                sc.read(buffer);
                                buffer.flip();
                                content += charset.decode(buffer);
                            }
                            //打印输出读取的内容
                            System.out.println("聊天信息:"+content);
                            //为下一次读取做准备
                            sk.interestOps(SelectionKey.OP_READ);
                        }
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws Exception{
        new NioClient().init();
    }
}

客户端只有一个SocketChannel,将该SocketChannel注册到指定的Selector后,程序启动另一个线程来监听该Selector即可。如果程序监听到该Selector的select()方法返回值大于0,就表明该Selector上有需要进行IO处理的Channel,接着程序取出该Channel,并使用NIO读取该Channel中的数据。

使用Java7的AIO实现非阻塞通信

基于异步Channel的IO机制也被称为异步IO(Asynchronous IO)。


AIO提供了两个接口和三个实现类,其中AsynchronousSocketChannel、AsyncchronousServerScoketChannel是支持TCP通信的异步Channel。

AsynchronousServerSocketChannel是一个负责监听Channel,与ServerSocketChannel相似,创建可用的AsynchronousServerSocketChannel需要如下两步:

  1. 调用它的静态方法open()创建一个未监听端口的AsynchronousServerSocketChannel
  2. 调用AsynchronousServerSocketChannel的bind()方法指定该Channel在指定地址、端口监听

使用AsynchronousServerSocketChannel只要三步:

  1. 调用open()静态方法创建AsynchronousServerSocketChannel实例
  2. 调用AsynchronousServerSocketChannel的bin()方法指定ip和端口监听
  3. 调用AsynchronousServerSocketChannel的accept()方法接受连接请求
public class SimpleAioServer {
    static final int PORT = 30000;

    public static void main(String[] args) throws Exception {
        //创建AsynchronousServerSocketChannel对象
        AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open();
        //指定ip地址和端口
        serverChannel.bind(new InetSocketAddress(PORT));
        while (true) {
            //采用循环接受来自客户端的连接
            Future<AsynchronousSocketChannel> socket = serverChannel.accept();
            //获取连接完成后返回AsynchronousSocketChannel
            AsynchronousSocketChannel socketChannel = socket.get();
            //执行输出
            socketChannel.write(ByteBuffer.wrap("欢迎来到AIO".getBytes("UTF-8"))).get();
        }
    }
}

AsynchronousSocketChannel的用法也分为三步:

  1. 调用AsynchronousSocketChannel的open()静态方法。调用open()方法时同样可以指定一个AsynchronousChannelGroup作为分组管理器
  2. 调用AsynchronousSocketChannel的connect()方法连接到指定IP地址、指定端口的服务器
  3. 调用AsynchronousSocketChannel的read()、write()方法进行读写

public class SimpleAioClient {
    static final int PORT = 30000;

    public static void main(String[] args) throws Exception{
        //用于读取数据的ByteBuffer
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        Charset utf = Charset.forName("UTF-8");
        //创建AsynchronousSocketChannel对象
        AsynchronousSocketChannel clientChannel = AsynchronousSocketChannel.open();
        //连接远程服务器④
        clientChannel.connect(new InetSocketAddress("127.0.0.1",PORT)).get();
        buffer.clear();
        //从clientChannel中读取数据⑤
        clientChannel.read(buffer).get();
        buffer.flip();
        //将buffer中的内容转化为字符串
        String content = utf.decode(buffer).toString();
        System.out.println("服务器信息:"+content);

    }
}

posted @ 2020-05-26 10:29  春刀c  阅读(197)  评论(0编辑  收藏  举报