网络编程 三 Socket通信下篇

一 构建Sccket对象

  服务端的Socket对象可通过ServerSocket对象获取,这里不再赘述。客户端的Socket对象可直接通过Socket类的构造函数实现,Socket提供的构造函数较多,包括无参构造,后续通过其他API对IP和PORT进行绑定,也可以通过服务代理ServiceProxy对象构造,当然最常见的还是直接通过IP+PORT参数进行构造:

public class TestSocketClient {
    public static void main(String[] args) throws IOException {
        Socket socketClient = new Socket("127.0.0.1", 8001);
    }
}

  需要注意的是上例中的第一个构造参数并非要求必须是IP地址,实际上这个参数是允许传入域名,两者的区别在于使用域名作为参数值的情况下需要使用DNS服务将域名转为IP地址,域名不存在时会抛出异常。

  另一种情况是域名/IP参数为null的时候,此时它代表着回环地址,回环地址请参考《网络编程 一 设备信息》中的介绍。

  另外,ServerSocket存在的意义在于构建服务端的通信环境,而Socket存在意义则是实现客户端和服务端之间的通信。

二 获取输入/出流

  首先需要说明的是TCP是一种有序的流数据协议,因此基于TCP协议的Socket实现,其数据读写都是基于流的。

  当通信连接创建后,可通过Socket对象的getInputStream方法来获取输入流,此方法具有阻塞特性。那么一个简陋的服务端实现应该如下例所示:

public class TestSocketServer {
    public static void main(String[] args) {
        try {
            ServerSocket server = new ServerSocket(8001);
            Socket socket = server.accept();
            System.out.println("有客户端发起来连接请求,因此阻塞结束,执行到了这里");
            InputStream inputStream = socket.getInputStream();
            byte[] bytes = new byte[1024];
            int readLength = inputStream.read(bytes);
            System.out.println("读取数据长度为:" + readLength);
            // 依次关闭各对象
            inputStream.close();
            socket.close();
            server.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

  和输入流的获取方式一样,Socket对象提供了getInputStream方法,更为具体的介绍请读者参阅其他关于读写流方面的资料。

三 流操作典型问题

3.1 流的关闭引发Socket关闭

  之所以要把流的关闭单独拿出来讲,是因为这里有一个很不起眼但是出了问题极难排查的场景,我们看一个例子:

public class TestSocketServer {
    public static void main(String[] args) {
        try {
            ServerSocket server = new ServerSocket(8001);
            Socket socket = server.accept();
            System.out.println("有客户端发起来连接请求,因此阻塞结束,执行到了这里");
            InputStream inputStream = socket.getInputStream();
            byte[] bytes = new byte[1024];
            int readLength = inputStream.read(bytes);
            System.out.println("读取数据长度为:" + readLength);
            // 关闭输入流对象
            inputStream.close();
			// 获取输出流对象给客户端应答
			OutputStream outputStreat = socket.getOutputStream();
			...
            socket.close();
            server.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

  运行上面的测试程序,在OutputStream outputStreat = socket.getOutputStream()这行将会出现异常,异常信息是Socket is closed。

  初学者可能会一头雾水,明明还没有执行到socket.close()这行,为什么socket就关闭了呢?其实问题并不复杂,Socket返回的InputStream对象的真实类型是java.net.SocketInputStream,这一点可以通过调试器验证,而SocketInputStream的close方法大有玄机:

/**
 * Closes the stream.
 */
private boolean closing = false;
public void close() throws IOException {
	// Prevent recursion. See BugId 4484411
	if (closing)
		return;
	closing = true;
	if (socket != null) {
		if (!socket.isClosed())
			socket.close();
	} else
		impl.close();
	closing = false;
}

  从源码中可以看到,如果socket!=null且没有关闭时,会执行socket.close(),这意味着上例中在执行OutputStream outputStreat = socket.getOutputStream()时,socket对象已经被关闭了,因此会出现Socket is closed异常。

3.2 传输对象时获取流阻塞

  有这样一种情况,当我们通过Socket传输Java对象时,客户端一旦发起请求,服务端和客户端一同被阻塞,现象十分怪异,请读者复制我下面的测试程序并运行:

/**
 * 服务端
 */ 
public class TestSocketServer {
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(8001);
        Socket socket = server.accept();
        System.out.println("有客户端发起连接请求,因此阻塞结束,执行到了这里");
        InputStream inputStream = socket.getInputStream();
        OutputStream outputStream = socket.getOutputStream();
        System.out.println("准备创建对象输入流");
        ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
        System.out.println("已创建对象输入流");
        System.out.println("准备创建对象输出流");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
        System.out.println("已创建对象输出流");
        // 依次关闭各对象
        objectInputStream.close();
        objectOutputStream.close();
        socket.close();
        server.close();
    }
}

/*
 * 客户端
 */ 
public class TestSocketClient {
    public static void main(String[] args) throws IOException {
        Socket socketClient = new Socket("127.0.0.1", 8001);
        System.out.println("客户端已发起来连接请求");
        InputStream inputStream = socketClient.getInputStream();
        OutputStream outputStream = socketClient.getOutputStream();
        System.out.println("准备创建对象输入流");
        ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
        System.out.println("已创建对象输入流");
        System.out.println("准备创建对象输出流");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
        System.out.println("已创建对象输出流");
        // 依次关闭各对象
        objectInputStream.close();
        objectOutputStream.close();
        socketClient.close();
    }
}

  依次启动服务端和客户端程序,控制台中输出结果如下:

服务端输出:
有客户端发起连接请求,因此阻塞结束,执行到了这里
准备创建对象输入流

客户端输出:
客户端已发起来连接请求
准备创建对象输入流

  出现这种情况的原因在于服务端和客户端获取输入流、输出流的顺序一致,调整顺序颠倒即可解决问题,常规情况下编写Socket通信的开发人员应该会互相探讨方案后实现,可规避这个问题。

四 端口分配、连接、超时和状态

  这一节的核心在于必须让读者知道不论服务端还是客户端,进行通信时两端都是要通过各自的端口的,我们常见服务端如下的写法:

new ServerSocket(1234);

  这意味着服务端通信的端口是1234,而客户端则常见的写法是:

new Socket("localhost", 1234);

  上面的写法显然是为了与端口为1234的服务端通信,但实际上客户端已经自动分配了一个端口号,如果想要显式的指定端口号,则需要使用bind()方法实现,先分配端口,再执行连接,这也意味着如果不显示分配端口,那么在调用connect()方法时一定会自动分配一个空闲端口。可以通过下面的例子对端口分配进行验证:

// 服务端
public class TestSocketServer {
    public static void main(String[] args) throws IOException, InterruptedException {
        ServerSocket serverSocket = new ServerSocket(1234);
        Thread.sleep(10000);
        serverSocket.close();
    }
}
// 客户端
public class TestSocketClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket();
        System.out.println("当前端口:" + socket.getLocalPort());
        socket.connect(new InetSocketAddress("localhost", 1234));
        System.out.println("当前端口:" + socket.getLocalPort());
        socket.close();
    }
}
// 先执行服务端测试程序,再执行客户端程序,输出结果如下:
当前端口:-1
当前端口:59639

  测试结果说明客户端Socket在执行connect()方法之后就分配59639端口,如果我们在connect()之后再执行bind()方法,就会抛出如下端口重复绑定异常:

// 修改客户端测试程序
public class TestSocketClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket();
        System.out.println("当前端口:" + socket.getLocalPort());
        socket.connect(new InetSocketAddress("localhost", 1234));
        socket.bind(new InetSocketAddress(2345));
        System.out.println("当前端口:" + socket.getLocalPort());
        socket.close();
    }
}
// 执行结果
当前端口:-1
Exception in thread "main" java.net.SocketException: Already bound
	at java.net.Socket.bind(Socket.java:644)
	at com.demo.socket.TestSocketClient.main(TestSocketClient.java:12)

  connect()方法除了支持发起连接请求,还可以控制超时时间,它有一个重载方法:

public void connect(SocketAddress endpoint, int timeout)

  其中第二个参数timeout是连接等待的超时时间,单位ms,指的是指定时间内还不能连接到目标服务端,那么就会抛出超时异常,如果不显式指定超时时间,那么默认的超时就是20s左右(我当前使用window10系统是21s,其他的没有验证过),通过下面的测试程序可以验证:

public class TestSocketClient {
    public static void main(String[] args) throws IOException {
        long start = System.currentTimeMillis();
        try {
            Socket socket = new Socket();
            socket.connect(new InetSocketAddress("1.2.3.4", 1234));
            socket.close();
        } catch (Exception e) {
            System.out.println((System.currentTimeMillis() - start) / 1000);
            throw e;
        }
    }
}
// 输出结果
21
Exception in thread "main" java.net.ConnectException: Connection timed out: connect
	at java.net.DualStackPlainSocketImpl.connect0(Native Method)
	at java.net.DualStackPlainSocketImpl.socketConnect(DualStackPlainSocketImpl.java:75)
	at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:476)
	at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:218)
	at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:200)
	at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:162)
	at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:394)
	at java.net.Socket.connect(Socket.java:606)
	at java.net.Socket.connect(Socket.java:555)
	at com.demo.socket.TestSocketClient.main(TestSocketClient.java:12)

  如果想在运行时判断当前Socket对象的状态可以通过下面的几个API来处理:

API用途返回值类型
isBound是否已绑定地址信息boolean
isConnected是否已连接,仅成功连接才返回trueboolean
isClosed是否已关闭boolean

  这里提一点,如果通过close()方法来关闭Socket对象,那么所有与之相关的阻塞线程都将抛出SocketException,与之相关的输入输出流也都将被关闭,与之相关的通道同样被关闭,且一旦关闭无法重新使用,真真儿是煎饼果子来一套,要来来全套。有一种更有意思的半关闭状态,读者可以在第六章节找到介绍。

五 获取网络信息

5.1 端口信息

  这个没啥好说的,上面的测试程序里也用过,getPort()返回远程端口,getLocalPort()获取本地端口,注意咯客户端和服务端互相为对端远程。

5.2 地址信息

  地址信息包含两大部分,一个是InetAddress,表示的是IP地址;另一个是SocketAddress,表示的是Socket抽象地址(不依赖任何协议的意思,实际上可用对象是SocketAddress的某个子类型,比如说InetSocketAddress)。

  跟端口号一样,getInetAddress()返回的是远程的Inet地址,getLocalInetAddress是本地Inet地址。有意思的是getLocalSocketAddress()返回的是本地Socket地址,但是获取远程Socket地址的方法是getRemoteSocketAddress(),呵呵哒。

六 半关闭

  前文介绍过一旦调用了Socket对象的close()方法就会彻底关闭,但是有一种非常罕见的需求场景,就是一端的写或者读已经结束了,并且希望把写或者读关闭掉,但是未关闭的写或者读依然能工作。为此Socket提供了如下两个方法:

public void shutdownInput()
public void shutdownOutput()

  只要调用了上面的方法,对应的读写流就会被关闭,待读取的数据都丢去,再读就异常;已写入的都发送,再写也异常。但是这种操作仅影响当前端,对端的获取读写流都是正常的,因此我们也称之为半读/写。

  如果一端同时调用上述两个方法,也就是读写流都关闭了,但Socket对象的状态依然是未关闭的,所以慎用。

  判断是否半读/写状态通过下面的方法实现:

public boolean isInputShutdown()
public boolean isOutputShutdown()

七 其他特性设置

7.1 TcpNoDelay

  Socket提供setTcpNoDelay(boolean on)方法来设置是否开启TCP_NODELAY模式。

  简单介绍下TCP_NODELAY,实际上它背后控制的是要不要开启Nagle算法,这个算法是用来提高网络软件运行效率的,实现手段是减少数据包发送频率进而减少网络阻塞的可能。减少数据包发送频率的实现手段是将数据包缓存在本地,只有当数据包大小达到了最大报文长度才将数据包发送,一般来说以太网下是1460字节。

  通过上面的介绍不难发现如果数据包小于最大报文长度的话,那么是不会理解发出的,这也就解释了为什么网络通信会出现延迟的问题,进而出现了setTcpNoDelay()方法来控制是否关闭Nagle算法。

7.2 缓冲区

  每个Socket都可以设置发送和接受缓冲区大小,访问和设置方法如下:

public synchronized void setSendBufferSize(int size)
public int getSendBufferSize()
public synchronized void setReceiveBufferSize(int size)
public int getReceiveBufferSize()

  以发送缓冲区为例,参数size必须是一个大于0的整数,合理设置缓冲区大小可以提升网络传输效率,至于多少合适就要另说了。

7.3 关闭延迟

  调用了Socket对象的close()方法后,实际上Socket对象不会立即关闭,因为缓冲区中可能还有数据要处理,因此close()方法虽然会立即返回,但并不以为着底层真的就立即关闭了。

  通过下面的方法可以控制是否关闭close延迟,以及开启close延迟的最大延迟时间:

public void setSoLinger(boolean on, int linger)

  其中on参数控制是否开启close延迟,linger是延迟时间,单位s。可以通过getSoLinger方法获取close延迟,返回值是int类型,值为-1时表示禁用。

7.4 读超时

  如果不想因为过久等待数据读取而导致的持续阻塞,可以使用setSoTimeout(int timeout)方法设置读超时,timeout为0的时候表示无限大的超时时间,单位ms。

  需要注意的是必须要在阻塞性动作发生前设置超时参数才生效,参数生效后一旦读取等待时间超过参数值就会抛出SocketTimeoutException,可以通过getSoTimeout()方法获取超时时间,返回int类型结果。

7.5 紧急数据

  很少用,而且只能发送一个int型的数据,还需要双端同时开启,开启方法是setOOBInline(boolean on),发送紧急数据的方法如下:

public void sendUrgentData(int data)

  sendUrgentData方法不会把数据丢在缓冲区,而是会直接发送出去,我们以下面的测试程序为例:

// 服务端
public class TestSocketServer {
    public static void main(String[] args) throws IOException, InterruptedException {
        ServerSocket serverSocket = new ServerSocket(1234);
        Socket socket = serverSocket.accept();
        socket.setOOBInline(true);
        InputStreamReader isr = new InputStreamReader(socket.getInputStream());
        BufferedReader br = new BufferedReader(isr);
        System.out.println(br.readLine());
        socket.close();
    }
}
// 客户端
public class TestSocketClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 1234);
        socket.setOOBInline(true);
        OutputStreamWriter osw = new OutputStreamWriter(socket.getOutputStream());
        osw.write(65);
        osw.write(66);
        osw.write(67);
        socket.sendUrgentData(68);
        socket.sendUrgentData(69);
        osw.flush();
        socket.close();
    }
}
// 输出结果
DEABC

  实际测试结果发现紧急数据不需要flush即可发送。

7.6 探活

  又一个不常用的方法,接收方较长时间内没有收到发送方数据的话,是很难判断出对端还存活的,如果对端已经挂掉了,那么自己就无法将Socket关闭,导致资源泄露。

  Socket提供了setKeepAlive(boolean on)方法设置探活,一旦长时间没收到对端数据,就会发出一个探活数据,长时间是多长取决于操作系统内核。更为常见的探活做法是另起线程来轮询。

7.7 传输质量

  最后一个不常用的方法,用来设置数据包传输质量,方法是setTrafficClass(int tc),参数是固定的,见下表:

类型代码描述
IPTOS_LOWCOSTOx02低成本
IPTOS_RELIABILITYOx04高可靠
IPTOS_THROUGHPUTOx08高吞吐
IPTOS_LOWDELAYOx10最低延迟

  可用参数就用表格中的代码,另外四个可选项可组合使用,用或运算即可。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柠檬睡客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值