下篇"/>
网络编程 三 Socket通信下篇
文章目录
- 一 构建Sccket对象
- 二 获取输入/出流
- 三 流操作典型问题
- 3.1 流的关闭引发Socket关闭
- 3.2 传输对象时获取流阻塞
- 四 端口分配、连接、超时和状态
- 五 获取网络信息
- 5.1 端口信息
- 5.2 地址信息
- 六 半关闭
- 七 其他特性设置
- 7.1 TcpNoDelay
- 7.2 缓冲区
- 7.3 关闭延迟
- 7.4 读超时
- 7.5 紧急数据
- 7.6 探活
- 7.7 传输质量
一 构建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.SocketInputStream,这一点可以通过调试器验证,而SocketInputStream的close方法大有玄机:
/*** Closes the stream.*/
private boolean closing = false;
public void close() throws IOException {// Prevent recursion. See BugId 4484411if (closing)return;closing = true;if (socket != null) {if (!socket.isClosed())socket.close();} elseimpl.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.SocketException: Already boundat java.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.ConnectException: Connection timed out: connectat java.DualStackPlainSocketImpl.connect0(Native Method)at java.DualStackPlainSocketImpl.socketConnect(DualStackPlainSocketImpl.java:75)at java.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:476)at java.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:218)at java.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:200)at java.PlainSocketImpl.connect(PlainSocketImpl.java:162)at java.SocksSocketImpl.connect(SocksSocketImpl.java:394)at java.Socket.connect(Socket.java:606)at java.Socket.connect(Socket.java:555)at com.demo.socket.TestSocketClient.main(TestSocketClient.java:12)
如果想在运行时判断当前Socket对象的状态可以通过下面的几个API来处理:
API | 用途 | 返回值类型 |
---|---|---|
isBound | 是否已绑定地址信息 | boolean |
isConnected | 是否已连接,仅成功连接才返回true | boolean |
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_LOWCOST | Ox02 | 低成本 |
IPTOS_RELIABILITY | Ox04 | 高可靠 |
IPTOS_THROUGHPUT | Ox08 | 高吞吐 |
IPTOS_LOWDELAY | Ox10 | 最低延迟 |
可用参数就用表格中的代码,另外四个可选项可组合使用,用或运算即可。
更多推荐
网络编程 三 Socket通信下篇
发布评论