网络编程总结(五):Socket - TCP Socket

什么是 Socket?

Socket 是一种抽象层,应用程序通过它来发送和接收数据,就像应用程序打开一个文件句柄,将数据读写到稳定的存储器上一样,一个 Socket 允许应用程序添加到网络中,并与处于同一个网络中的其他应用程序进行通信,一台计算机上的应用程序向 Socket 写入的信息能够被另一台计算机上的另一个应用程序读取,反之亦然。

不同类型的 Socket 与不同类型的底层协议族以及同一协议族中的不同协议栈相关联。现在 TCP/IP 协议族中的主要 Socket 类型为流套接字(sockets sockets)和数据报套接字(datagram sockets)。流套接字将 TCP 作为其端对端协议(底层使用 IP 协议),提供了一个可信赖的字节流服务。一个 TCP/IP 流套接字代表了 TCP 连接的一端。数据报套接字使用 UDP 协议(底层同样使用 IP 协议),提供了一个“尽力而为”的数据报服务,应用程序可以通过它发送最长 65500 字节的个人信息。一个 TCP/IP 套接字由一个互联网地址,一个端对端协议(TCP 或 UDP
协议)以及一个端口号唯一确定。随着进一步学习,你将了解到把一个套接字绑定到一个互
联网地址上的多种方法。

上图描述了一个主机中,应用程序、套接字抽象层、协议、端口之间的逻辑关系,一个套接字抽象层可以被多个应用程序引用。每个使用了特定套接字的程序都可以通过那个套接字进行通信。前面已提到,每个端口都标识了一台主机上的一个应用程序。实际上,一个端口确定了一台机主机上的一个套接字,主机中的多个程序可以同时访问同一个套接字。在实际应用中,访问相同套接字的不同程序通常属于同一个应用(例如 WEB 服务程序的多个拷贝),但从理论上讲,它们是可以属于不同应用的。

TCP 套接字

Java 为 TCP 协议提供了两个类:Socket 类和 ServerSocket 类。一个 Socket 实例代表了 TCP 连接的一端。一个 TCP 连接是一条抽象的双向信道,两端分别由 IP 地址和端口号确定。在开始通信钱,要建立一个 TCP 连接,这需要先由客户端 TCP 向服务端 TCP 发送连接请求。ServerSocket 实例则监听 TCP 连接请求,并为每个请求创建新的 Socket 实例,也就是说,服务端需要同时处理 ServerSocket 实例和 Socket 实例,而客户端只需要使用 Socket 实例。

TCP Socket 客户端

客户端向服务器发起请求后,就被动的等待服务器的响应,一个典型的 TCP 客户端要经过下面的步骤:

  1. 创建一个 Socket 实例,构造器指向特定的主机地址和端口,创建一个 TCP 连接。
  2. 调用 Socket 对象的 getInputStream() 或 getOutputStream() 获取到通向连接另一方的输入/输出流。
  3. 在操作完毕后关闭 Socket 连接。

一个简单示例:

public class SocketTest {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("192.168.8.100", 8080);
            socket.setSoTimeout(10000);
            System.out.println("此 Socket 连接的地址:" + socket.getInetAddress());
            System.out.println("此 Socket 连接到的远程端口:" + socket.getPort());
            System.out.println("此 Socket 绑定的本地地址" + socket.getLocalAddress());
            System.out.println("此 Socket 绑定到的本地端口" + socket.getLocalPort());

            InputStream is = socket.getInputStream();

            byte[] buf = new byte[1024];
            int len = 0;
            while ((len = is.read(buf)) != -1) {
                System.out.println(new String(buf, 0, len));//接收远程 Socket 返回的内容并输出。
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

    }
}

输出结果:

此 Socket 连接的地址:/192.168.8.100
此 Socket 连接到的远程端口:8080
此 Socket 绑定的本地地址/192.168.8.188
此 Socket 绑定到的本地端口8272
请求的 IP : /192.168.8.188       ← 此行为服务器返回的内容

关于 Socket 的其他方法可以参考 API。

TCP Socket 服务器端

服务器端的工作是建立一个通信终端,并且被动的等待客户端的连接。典型的 TCP 服务器端有如下步骤:

  1. 创建一个 ServerSocket 实例并指定本地端口,在创建好 ServerSocket 实例之后,该实例会一直监听指定的端口。
  2. 重复执行以下步骤:

    2.1 调用 ServerSocket 的 accept() 方法以获取下一个客户端连接。基于新建立的客户端连接,创建一个 Socket 实例,并由 accept() 方法返回。
    
    2.2 使用所返回的 Socket 实例的 InputStream 和 OutputStream 与客户端进行通信。
    
    2.3 通信完成后,使用 Socket 类的 close() 方法关闭该客户端 Socket 连接。
    

简单示例:

public class ServerSocketTest {
    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            while (true) {
                Socket socket = serverSocket.accept();
                OutputStream os = socket.getOutputStream();
                String writeString = "请求的 IP : " + socket.getInetAddress();
                os.write(writeString.getBytes());
                os.flush();
                os.close();
                socket.close();
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

绑定本地地址

之前我们客户端 Socket 连接 ServerSocket 的时候,都是随机从本地的端口去连接 ServerSocket,我们可以在服务端调用getPort()可以看到该 Socket 连接到的远程端口,有时候为了统一,我们需要将该 Socket 和我们本地的端口绑定起来,这就需要用到 Socket 的 bind() 方法了。

public class ClentA {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket();
            socket.setReuseAddress(true);
            socket.bind(new InetSocketAddress("127.0.0.1", 9999));
            socket.connect(new InetSocketAddress("127.0.0.1", 8080));
            InputStream is = socket.getInputStream();
            byte[] buf = new byte[1024];
            int len = 0;
            while ((len = is.read(buf)) != -1) {
                System.out.println(new String(buf, 0, len));
            }
        } catch (SocketException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

    }
}

这样,将本地的 9999 端口和该 Socket 绑定起来,那么之后和服务端连接的 Socket 就永远使用的是 9999 端口了。

需要注意的是,在绑定端口的时候,不要将端口号置为小于1024的值,因为1到1024是保留端口号,你可以选择大于1024中的任何一个没有被占用的端口号。

Socket 的几个属性

Socket 可以指定发送和接收数据的方式,有如下几个属性可以设置:

  • SO_KEEPALIVE

    如果将这个 Socket 选项打开,客户端 Socket 每隔一段时间(大约两个小时,视实际情况而定)就会利用空闲的连接向服务器发送一个数据包。这个数据包并没有其他作用,只是为了检测一下服务器是否仍处于活动状态,如果服务器为响应这个数据包,大约在 11 分钟后(同样也是非固定,视实际情况而定)客户端会再发送一个数据包,如果在 12 分钟内(同样...)服务器还没有响应,那么客户端 Socket 将关闭连接。如果将该 Socket 选项关闭,客户端 Socket 在服务器无效的情况下可能会长时间不会关闭。

通过 getKeepAlive() 方法来获取 SO_KEEPALIVE 的值,通过 setKeepAlive(boolean on) 方法来设置该属性的值。

默认情况下,该选项是关闭的。

  • SO_LINGER

    该 Socket 选项可以影响 Socket 连接的 close 方法。在默认情况下,当调用 close 方法后,将立即返回;如果这时仍然有没有送出的数据包,那么这些数据包将被丢弃。如果将该参数设置为一个正整数 n 时,在调用 close 后,将最多被阻塞 n 秒,在这 n 秒内,系统将尽量将没有送出的数据包发送出去;如果超过了 n 秒,如果还有没有发送的数据包,将会把这些数据包全部丢弃。

可以通过 getSoLinger() 方法来获取该属性的值,如果该方法返回 -1 ,则表示该属性是关闭的。可以利用 setSoLinger(boolean on, int linger) 方法来启用该选项并设置属性值。

该选项默认是关闭的。

  • SO_OOBINLINE

    如果打开该选项,那么就可以通过 Socket 对象的 sendUrgentData 方法向服务器发送一个单字节的数据。这个单字节数据并不经过缓冲区,而是立即发出。虽然在客户端并不是使用 OutputStream 发送数据,但在服务器中这个单字节的数据是和其他普通数据混在一起的。因此,在服务器程序中并不知道由客户端发过来的数据是由 OutputStream 还是由 sendUrgentData 发过来的。

可以通过 getOOBInline()方法来测试是否启动该选项,使用setOOBInline 来设置该选项。

  • SO_RCVBUF

    在默认情况下,输入流的接收缓冲区是8096个字节(8K)。这个值是Java所建议的输入缓冲区的大小。如果这个默认值不能满足要求,可以用setReceiveBufferSize方法来重新设置缓冲区的大小。但最好不要将输入缓冲区设得太小,否则会导致传输数据过于频繁,从而降低网络传输的效率。

可以通过 getReceiveBufferSize() 方法来获取该 Socket 的 SO_RCVBUF 属性值,也可以利用 setReceiveBufferSize(int size) 方法为该 Socket 重新指定 SO_RCVBUF 属性值。
必须将size设为正整数,否则setReceiveBufferSize方法将抛出IllegalArgumentException例外。

  • SO_REUSEADDR

    如果端口忙,但TCP状态位于 TIME_WAIT ,可以重用 端口。如果端口忙,而TCP状态位于其他状态,重用端口时依旧得到一个错误信息, 抛出“Address already in use: JVM_Bind”。如果你的服务程序停止后想立即重启,不等60秒,而新套接字依旧 使用同一端口,此时 SO_REUSEADDR 选项非常有用。必须意识到,此时任何非期 望数据到达,都可能导致服务程序反应混乱,不过这只是一种可能,事实上很不可能。

使用 getReuseAddress() 方法来测试是否启动该选项。使用 setReuseAddress(boolean on) 方法来重新设置该属性。

使用SO_REUSEADDR选项时有两点需要注意:

1.  必须在调用bind方法之前使用setReuseAddress方法来打开SO\_REUSEADDR选项。因此,要想使用SO\_REUSEADDR选项,就不能通过Socket类的构造方法来绑定端口。

2.  必须将绑定同一个端口的所有的Socket对象的SO\_REUSEADDR选项都打开才能起作用。如在例程4-12中,socket1和socket2都使用了setReuseAddress方法打开了各自的SO\_REUSEADDR选项。
  • SO_SNDBUF

    在默认情况下,输出流的发送缓冲区是8096个字节(8K)。这个值是Java所建议的输出缓冲区的大小。如果这个默认值不能满足要求,可以用setSendBufferSize方法来重新设置缓冲区的大小。但最好不要将输出缓冲区设得太小,否则会导致传输数据过于频繁,从而降低网络传输的效率。

通过 getSendBufferSize() 方法来获取该属性的值,通过etSendBufferSize(int size) 方法重新为该属性赋值。

如果底层的Socket实现不支持SO_SENDBUF选项,这两个方法将会抛出SocketException例外。必须将size设为正整数,否则setSendBufferedSize方法将抛出IllegalArgumentException例外。

  • SO_TIMEOUT

    可以通过这个选项来设置读取数据超时。当输入流的read方法被阻塞时,如果设置timeout(timeout的单位是毫秒),那么系统在等待了timeout毫秒后会抛出一个InterruptedIOException例外。在抛出例外后,输入流并未关闭,你可以继续通过read方法读取数据。

可以通过 getSoTimeout() 方法来获取该属性的值,通过 setSoTimeout(int timeout) 方法重新为该属性赋值。

如果将timeout设为0,就意味着read将会无限等待下去,直到服务端程序关闭这个Socket.这也是timeout的默认值。

  • TCP_NODELAY

    在默认情况下,客户端向服务器发送数据时,会根据数据包的大小决定是否立即发送。当数据包中的数据很少时,如只有1个字节,而数据包的头却有几十个字节(IP头+TCP头)时,系统会在发送之前先将较小的包合并到软大的包后,一起将数据发送出去。在发送下一个数据包时,系统会等待服务器对前一个数据包的响应,当收到服务器的响应后,再发送下一个数据包,这就是所谓的Nagle算法;在默认情况下,Nagle算法是开启的。

这种算法虽然可以有效地改善网络传输的效率,但对于网络速度比较慢,而且对实现性的要求比较高的情况下(如游戏、Telnet等),使用这种方式传输数据会使得客户端有明显的停顿现象。因此,最好的解决方案就是需要Nagle算法时就使用它,不需要时就关闭它。而使用setTcpToDelay正好可以满足这个需求。当使用setTcpNoDelay(true)将Nagle算法关闭后,客户端每发送一次数据,无论数据包的大小都会将这些数据发送出去。

可以通过 getTcpNoDelay() 方法来测试是否启用该选项,通过 setTcpNoDelay(boolean on) 方法来设置该选项。

服务端中的多线程

之前的 Server 端和 Client 端只是进行了简单的通信操作,但是在日常使用中,客户端和服务端往往需要进行长时间的通信,也就是说,服务端需要不断的读取客户端数据,并向客户端写入数据,客户端也需要不断的读取服务器端发送的内容同时向服务端写入数据。

在使用传统的读写方法中,在方法成功返回之前,线程会被阻塞,程序无法继续执行,正是因为这个原因,服务器应该为每个 Socket 单独启用一个线程,每个线程负责与一个客户端进行通信。

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

简单的例子:

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

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

class ServerThread implements Runnable {

    // 定义当前线程所处理的Socket
    Socket s = null;
    // 该线程所处理的Socket对应的输入流
    InputStream is = null;

    FileOutputStream fos = null;

    public ServerThread(Socket s) throws IOException {
        
        this.s = s;
        // 初始化该Socket对应的输入流
        is = s.getInputStream();
        fos = new FileOutputStream(new File("D:\\" + s.getPort() + ".mp4"));
    }

    @Override
    public void run() {
        try {
            byte[] buf = new byte[1024];
            int len = 0;
            while ((len = is.read(buf)) != -1) {
                fos.write(buf, 0, len);
                fos.flush();
            }
            MyServer.socketList.remove(s);
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

半关闭的 Socket

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

这种情况下,Socket 提供了如下两个半关闭的方法,只关闭 Socket 的输入流或者输出流,用以表示输出完成或者输入完成:

  • shutdownInput():关闭该 Socket 的输入流,程序还可以通过该 Socket 的输出流输出数据。
  • shutdownOutput():关闭该 Socket 的输出流,程序还可以通过该 Socket 的输入流读取数据。

当调用 shutdownInput() 或者 shutdownOutput() 方法关闭 Socket 的输入流或输出流之后,该 Socket 处于“半关闭”状态, Socket 可以通过 isInputShutdown() 方法判断该 Socket 是否处于半读状态,通过 isOutputShutdown() 方法判断该 Socket 是否处于半写状态。

当一个 Socket 调用了shutdownInput() 和 shutdownOutput() 之后,该 Socket 仍然处于活动状态,只是不能读取写入数据而已,只有当调用 close 方法之后,才算关闭。

参考资料

  • 《疯狂 Java 第三版》
  • 《Java TCPIP Socket编程》