在客户/服务器通信模式中,客户端需要主动创建与服务器连接的Socket,服务器端收到了客户的连接请求,也会创建与客户连接的Socket。Socket可以被看作是通信连接两端的收发器,服务器与客户都通过套接字来收发数据。
1、构造Socket
Socket的构造方法有以下几种重载形式:
Socket();
Socket(InetAddress address, int port) throws UnknownHostException, IOException;
Socket(InetAddress address, int port, InetAddress localAddr, int localPort) throws IOException;
Socket(String host, int port)throws UnknownHostException, IOException;
Socket(String host, int port, InetAddress localAddr, int localPort)throws IOException;;
Socket(Proxy proxy);
除了第1个不带参数的构造方法,其他构造方法都会试图建立与服务器的连接,如果连接成功,就返回Socket对象;如果因为某些原因连接失败,就会抛出IOException。
下面的PortScanner类能够扫描主机上从1到1024之间的端口,判断这些端口是否已经被服务器程序监听。PortScanner类的scan()方法在一个for循环中创建Socket对象,每次请求连接不同的端口,如果Socket对象创建成功,就表明在当前端口有服务器程序监听。
import java.io.IOException;
import java.net.Socket;
/**
* @title PortScanner
* @description 测试
* @author: yangyongbing
* @date: 2023/12/5 12:03
*/
public class PortScanner {
public static void main(String[] args) {
String host="localhost";
if(args.length>0){
host=args[0];
}
new PortScanner().scan(host);
}
public void scan(String host){
Socket socket=null;
for(int port=1;port<1024;port++){
try {
socket=new Socket(host,port);
System.out.println("There is a server on port "+port);
}catch (IOException e){
System.out.println("Can't connect to port "+port);
}finally {
if(socket!=null){
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
1.1、设定等待建立连接的超时时间
当客户端的Socket构造方法请求与服务器连接时,可能要等待一段时间。在默认情况下,Socket构造方法会一直等待下去,直到连接成功,或者出现异常。Socket构造方法请求连接时,受底层网络的传输速度的影响,可能会处于长时间的等待状态。如果希望限定等待连接的时间,那么该如何做呢?此时就需要使用第1个不带参数的构造方法。
以上SocketAddress类表示一个套接字的地址,它同时包含了IP地址和端口信息。以上代码用于连接到本地机器上的监听8000端口的服务器程序,等待连接的最长时间为1分钟。如果在1分钟内连接成功,则connect()方法顺利返回;如果在1分钟内出现某种异常,则抛出该异常;如果在1分钟后既没有连接成功,也没有出现异常,那么会抛出SocketTimeoutException。Socket类的connect(SocketAddress endpoint,int timeout)方法负责连接服务器,参数endpoint是指定服务器的地址,参数timeout是设定的超时时间,以ms为单位。如果参数timeout被设为0,则表示永远不会超时。
不带参数的构造方法的另一个作用是在连接服务器之前,设置Socket的选项。
1.2、设定服务器的地址
除了第1个不带参数的构造方法,其他构造方法都需要在参数中设定服务器的地址,包括服务器的IP地址或者主机名,以及端口。
InetAddress类表示主机的IP地址,InetAddress类提供了一系列静态工厂方法,用于构造自身的实例,例如:
1.3、设定客户端的地址
在一个Socket对象中,既包含远程服务器的IP地址和端口信息,也包含本地客户端的IP地址和端口信息。在默认情况下,客户端的IP地址来自客户程序所在的主机,客户端的端口则由操作系统随机分配。Socket类还有两个构造方法允许显式地设置客户端的IP地址和端口。
如果一个主机同时属于两个以上的网络,它就可能拥有两个以上IP地址,例如一个主机在Internet网络中的IP地址为“222.67.1.34”,在一个局域网中的IP地址为“112.5.4.3”。假定这个主机上的客户程序希望和同一个局域网上的一个服务器程序(地址为:112.5.4.45:8000)通信,客户端可按照如下方式构造Socket对象。
1.4、客户连接服务器时可能抛出的异常
当Socket的构造方法请求连接服务器时,可能会抛出以下异常:
- UnknownHostException:如果无法识别主机的名字或IP地址,就会抛出这种异常。
- ConnectException:如果没有服务器进程监听指定的端口,或者服务器进程拒绝连接,就会抛出这种异常。
- SocketTimeoutException:如果等待连接超时,就会抛出这种异常。
以上4种异常都是IOException的直接或间接子类,如下图所示:
下面以ConnectTester类为例,介绍抛出各种异常的原因。ConnectTester接收用户从命令行输入的主机名和端口,然后连接到该地址,如果连接成功,就会计算建立连接所花的时间;如果连接失败,就会捕获各种异常。下面是ConnectTester类的源程序。
在控制台中运行命令“java ConnectTester www.javathinker.net 80”,ConnectTester类的connect()方法会请求连接Internet网上www.javathinker.net主机的80端口,如果连接成功,就会打印如下结果:
以上打印结果表明客户与服务器建立连接花了49ms(毫秒),在不同的主机上运行该程序,会有不同的打印结果。
1.5、使用代理服务器
在实际应用中,有的客户程序会通过代理服务器来访问远程服务器。代理服务器有许多功能,比如能作为防火墙进行安全防范,或者提高访问速度,或者具有访问特定远程服务器的权限。
以下程序代码通过代理服务器来连接远程的www.javathinker.net服务器:
Proxy.Type类表示代理服务器的类型,有以下可选值:
- Proxy.Type.SOCKS:在分层的网络结构中,Type.SOCKS是位于会话层的代理类型。
- Proxy.Type.HTTP:在分层的网络结构中,Type.HTTP是位于应用层的代理类型。
- Proxy.Type.DIRECT:不使用代理,直接连接远程服务器。
1.6、InetAddress地址类的用法
InetAddress类表示主机的IP地址,InetAddress类的静态工厂方法getByName()用于构造自身的实例,例如:
InetAddress的getByName()方法的参数可以是IP地址或主机名。一般说来,主机名比IP地址要固定许多,主机名通常不会发生变化,而IP地址可能会发生变动。所以,在通过Socket连接某个服务器时,应该优先考虑提供主机名。
以下程序代码打印www.javathinker.net的地址信息:
InetAddress还提供了获取相应的主机名的两种方法:
- getHostname():首先从DNS缓存中查找与IP地址匹配的主机名,如果不存在,再通过DNS服务器查找,如果找到,则返回主机名,否则返回IP地址。
- getCanonicalHostName():通过DNS服务器查找与IP地址匹配的主机名,如果找到,则返回主机名,否则返回IP地址。
以上两种方法的区别在于,getHostname()会先查找DNS缓存,减少查找DNS服务器的概率,这样做能提高查找性能。因为查找DNS服务器是很耗时的操作。而getCanonicalHostName()总是查找DNS服务器,确保获得当前最新版本的主机名。
以下程序代码打印本地主机的主机名:
InetAddress类还提供了两个测试能否从本地主机连接到特定主机的方法:
public boolean isReachable(int timeout)throws IOException;
public boolean isReachable(NetworkInterface interface,int ttl,int timeout)throws IOException;
如果远程主机在参数timout(以ms为单位)指定的时间内做出回应,以上方法返回true,否则返回false。如果出现网络错误则抛出IOException。第2种方法还允许从参数指定的本地网络接口建立连接,以及TTL生存时间。TTL( Time To Live)指IP数据包被丢弃前允许存在的时间。
以下程序代码测试本地主机是否能在10s内连接www.javathinker.net网站:
下面介绍一个运用InetAddress类的实用范例。很多服务器会监视垃圾邮件发送者(spammer)。Spamhaus是一家国际知名反垃圾邮件机构,作为一个国际性非营利组织,提供了这一项服务。它的官方网站收集了常见的垃圾邮件发送者的IP地址的列表。例如,如何判断IP地址“108.33.56.27”是否是垃圾邮件发送者的地址呢?其步骤为调用InetAddress的getByName()方法,该方法通过DNS服务器查找“27.56.33.108.sbl.spamhaus.org”,如果查找成功(更确切地说,是返回IP地址为“127.0.0.2”的InetAddress对象),就说明这是一个垃圾邮件发送者的地址。否则,getByName()方法抛出UnknownHostException异常,就说明这不是一个垃圾邮件发送者的地址。
下面的SpamCheck类演示了如何判断特定IP地址是否为垃圾邮件发送者的IP地址。
通过命令“java SpamCheck 108.33.56.27 45.78.123.5154.65.93.21”运行SpamCheck类,会得到以下打印结果。
1.7、NetworkInterface类的用法
NetworkInterface类表示物理上的网络接口,它有两种构造自身实例的静态工厂方法,这两种方法都声明抛出SocketException。
- getByName(String name):参数name是指定网络接口的名字。如果不存在与名字对应的网络接口,就返回null。
- getByInetAddress(InetAddress address):参数address是指定网络接口的IP地址。如果不存在与IP地址对应的网络接口,就返回null。
以下程序代码演示如何创建NetworkInterface对象:
NetworkInterface类的以下方法用于获取网络接口的信息:
- public String getName():返回网络接口的名字。
- public Enumeration getInetAddresses():返回和网络接口绑定的所有IP地址。返回值为Enumeration类型,里面存放了表示IP地址的InetAddress对象。
2、获取Socket的信息
在一个Socket对象中,同时包含了远程服务器的IP地址和端口信息,以及客户本地的IP地址和端口信息。此外,从Socket对象中还可以获得输出流和输入流,分别用于向服务器发送数据,以及接收从服务器端发来的数据。以下方法用于获取Socket的有关信息:
- getInetAddress():获得远程被连接进程的IP地址。
- getPort():获得远程被连接进程的端口。
- getLocalAddress():获得本地的IP地址。
- getLocalPort():获得本地的端口。
- getInputStream():获得输入流。如果Socket还没有连接,或者已经关闭,或者已经通过shutdownInput()方法关闭输入流,那么此方法会抛出IOException。
- getOutputStream():获得输出流。如果Socket还没有连接,或者已经关闭,或者已经通过shutdownOutput()方法关闭输出流,那么此方法会抛出IOException。
下面的HTTPClient类用于访问网页www.javathinker.net/index.jsp。该网页位于一个主机名(也叫域名)为“www.javathinker.net”的远程HTTP服务器上,它监听80端口。在HTTPClient类中,先创建了一个连接到该HTTP服务器的Socket对象,然后发送符合HTTP的请求,接着接收从HTTP服务器上发回的响应结果。
以上HTTPClient类在发送数据时,先把字符串形式的请求信息转换为字节数组(即字符串的编码),然后发送。
HTTPClient类在接收数据时,把接收到的字节写到一个ByteArrayOutputStream中,它具有一个容量能够自动增长的缓冲区。如果socketIn.read(buff)方法返回“-1”,则表示读到了输入流的末尾。
当运行HTTPClient程序时,会打印服务器端发送的HTTP响应结果。
HTTP响应结果包括响应头和响应正文,中间以空行隔开。以上响应正文部分是乱码。这是因为www.javathinker.net服务器在发送正文内容时,先把它压缩成为GZIP格式,客户端需要对压缩数据进行解压,才能得到正文内容。而本范例未对压缩的正文数据解压,就直接将它打印出来,所以会显示乱码。
3、关闭Socket
当客户与服务器的通信结束时,应该及时关闭Socket,以释放Socket占用的包括端口在内的各种资源。Socket的close()方法负责关闭Socket。如果一个Socket对象被关闭,就不能再通过它的输入流和输出流进行I/O操作,否则会导致IOException。
为了确保关闭Socket的操作总是被执行,可以把这个操作放在finally代码块中:
Socket类提供了3个状态测试方法:
- isClosed():如果Socket没有关闭,则返回false,否则返回true。
- isConnected():如果Socket曾经连接到远程主机,不管当前是否已经关闭,都返回true。如果Socket从未连接到远程主机,就返回false。
- isBound():如果Socket已经与一个本地端口绑定,则返回true,否则返回false。
如果要判断一个Socket对象当前是否处于连接状态,可采用以下方式:
以下这段代码演示了isClosed()和isConnected()方法在各种场景中的取值:
提示:Socket和ServerSocket,以及ServerSocketChannel、SocketChannel、SSLServerSocket和SSLSocket等若都实现了java.lang.Auto Closable接口。这意味着如果在try代码块中打开或创建了这些类的实例,那么即使程序没有显式地关闭它们,Java虚拟机也会在退出try代码块时自动关闭它们,释放相关的资源。另一方面,尽管这些类具有自动关闭的功能,仍然建议在程序中及时显式地关闭它们,这样可以提高程序的健壮性并提高其性能。
4、半关闭Socket
进程A与进程B通过Socket通信,假定进程A输出数据,进程B读入数据。进程A如何告诉进程B所有数据已经输出完毕呢?有几种处理办法。
(1)如果进程A与进程B交换的是字符流,并且都一行一行地读写数据,那么可以事先约定以一个特殊的标志作为结束标志,例如以字符串“bye”作为结束标志。当进程A向进程B发送一行字符串“bye”,进程B读到这一行数据后,就停止读取数据。
(2)进程A先发送一个消息,告诉进程B所发送的正文的长度,然后发送正文。进程B先获知进程A将发送的正文的长度,接下来只要读取该长度的字符或者字节,就停止读取数据。
(3)进程A发完所有数据后,关闭Socket。当进程B读入了进程A发送的所有数据后,再次执行输入流的read()方法时,该方法返回“-1”,如果执行BufferedReader的readLine()方法,那么该方法返回null:
(4)当调用Socket的close()方法关闭Socket后,它的输出流和输入流也都被关闭。有的时候,可能仅仅希望关闭输出流或输入流之一。此时可以采用Socket类提供的半关闭方法:
- shutdownInput():关闭输入流。
- shutdownOutput():关闭输出流。
假定进程A执行以下代码,先向进程B发送一个字符串,等到进程B接收到这个字符串后,进程A再调用Socket的shutdownOutput()方法关闭输出流。接下来进程A不允许再输出数据,但是仍可以通过输入流读入数据:
进程B在读入数据时,如果进程A的输出流已经关闭,进程B读入所有数据后,就会读到输入流的末尾。
值得注意的是,先后调用Socket的shutdownInput()和shutdownOutput()方法,仅仅关闭了输入流和输出流,并不等价于调用Socket的close()方法。在通信结束后,仍然要调用Socket的close()方法,因为只有该方法才会释放Socket占用的资源,比如占用的本地端口等。
Socket类还提供了两种状态测试方法,用来判断输入流和输出流是否关闭:
- public boolean isInputShutdown():如果输入流关闭,则返回true,否则返回false。
- public boolean isOutputShutdown():如果输出流关闭,则返回true,否则返回false。
当客户与服务器通信时,如果有一方突然结束程序,或者关闭了Socket,或者单独关闭了输入流或输出流,对另一方会造成什么影响呢?以下就用Sender类和Receiver类来演示。Sender表示发送数据的客户程序,它每隔500ms发送一行字符串,共发送20行字符串。Receiver表示接收数据的服务器程序,它每隔1s接收一行字符串,共接收20行字符串。
Sender类和Receiver类的stopWay成员变量用来指定结束通信的方式。stopWay变量的默认值为1,表示自然结束通信,此外,用户可以通过命令行参数来设置stopWay变量的值。
1.自然结束Sender和Receiver的通信
先运行“java Receiver”,再运行“java Sender”,Sender会发送20行字符串,然后自然结束运行,Receiver会接收20行字符串,然后也自然结束运行。
2.提前终止Receiver
先运行“java Receiver 2”,或者“java Receiver 3”,或者“java Receiver 4”,或者“java Receiver 5”,然后运行“java Sender”。Receiver接收了3行字符串后,就结束运行。但是Sender仍然会发送完20行字符串后,才自然结束运行。之所以会出现这种情况,是因为尽管Receiver已经结束运行,但底层的Socket并没有立即释放本地端口,操作系统探测到还有发送给该Socket的数据,会使底层Socket继续占用本地端口一段时间。
3.突然终止Sender
先运行“java Receiver”,再运行“java Sender 2”,Sender发送了3行字符串后,在没有关闭Socket的情况下,就结束运行。Receiver在第4次执行BufferedReader的readLine()方法时会抛出异常。
4.关闭或者半关闭Sender的Socket
先运行“java Receiver”,再运行“java Sender 3”,或者运行“java Sender 4”。Sender发送了3行字符串后,会关闭Socket(运行“java Sender 3”),或者关闭Socket的输出流(运行“java Sender 4”),然后结束运行。Receiver在第4次执行BufferedReader的readLine()方法时读到输入流的末尾,因此readLine()方法返回null。
5、设置Socket的选项
Socket的选项如下所示:
- TCP_NODELAY:表示立即发送数据。
- SO_RESUSEADDR:表示是否允许重用Socket所绑定的本地地址。
- SO_TIMEOUT:表示接收数据时的等待超时时间。
- SO_LINGER:表示当执行Socket的close()方法时,是否立即关闭底层的Socket。
- SO_SNDBUF:表示发送数据的缓冲区的大小。
- SO_RCVBUF:表示接收数据的缓冲区的大小。
- SO_KEEPALIVE:表示对于长时间处于空闲状态的Socket,是否要自动把它关闭。
- OOBINLINE:表示是否支持发送1字节的TCP紧急数据。