InetAddress
InetAddress
类代表 IP 地址(Internet Protocol (IP) address
)。 一个 InetAddress
对象由 IP 地址和主机名(host name
)组成。
InetAddress
的子类 Inet4Address
和 Inet6Address
分别代表了 IPv4
和 IPv6
的 IP 地址。
通常我们在浏览器中通过输入主机名来访问一个网站,例如通过 developer.android.com
主机名访问 Android
官网。 而与网站建立连接使用的是 IP 地址,这就需要使用DNS(Domain Name Service)
来把主机名解析为 IP 地址,例如 developer.android.com
有多个IP地址,其中一个为 74.125.23.100
。
创建 InetAddress 实例
InetAddress
提供了许多静态的方法来创建 InetAddress
实例,最常用的是通过主机名来创建实例,当然也可以通过IP地址创建
public static InetAddress getByName(String host){}public static InetAddress[] getAllByName(String host){}public static InetAddress getByAddress(String host, byte[] addr){}public static InetAddress getByAddress(byte[] addr){}复制代码
参数 host
代表主机名,例如 developer.android.com
,而参数 addr
就代表 IP 地址,只不过这个 IP 地址是用字节数组形式表示的。
InetAddress
还提供了获取本地主机的 InetAddress
实例的方法
public static InetAddress getLocalHost() {}复制代码
获取主机名和IP地址
既然 InetAddress
是由 IP 地址和主机名组成,那么就可以获取这些属性的值
public String getHostName() {}public String getHostAddress() {}public byte[] getAddress() {}public String toString() { String hostName = holder().getHostName(); return ((hostName != null) ? hostName : "") + "/" + getHostAddress();}复制代码
第二个方法和第三个方法都是获取主机的 IP 地址,只是表示形式不同而已。
举例
InetAddress[] addresses = InetAddress.getAllByName("developer.android.com"); for (InetAddress inetAddress : addresses) { System.out.println("Host name: " + inetAddress.getHostName()); System.out.println("Host address: " + inetAddress.getHostAddress()); System.out.println("IP address: " + Arrays.toString(inetAddress.getAddress())); System.out.println("-----"); }复制代码
一个主机可能有多个网络接口,例如以太网,WIFI, 而每一个接口可能有多个IP地址,最常见的就是 IPv4
和 IPv6
的地址,因此通过主机名可以获取 InetAddress[]
。这个例子的部分结果如下
Host name: developer.android.comHost address: 74.125.204.102IP address: [74, 125, -52, 102]-----Host name: developer.android.comHost address: 74.125.204.113IP address: [74, 125, -52, 113]复制代码
TCP 套接字
TCP 协议
IP 协议只是一个尽力而为(best-effort
)的协议,它尝试分发每一个分组报文,但是在网络传输过程中,报文可能会丢失,顺序可能被打乱或者重复发送报文的情况。 而 TCP 协议构建于 IP 协议之上,它是一种面向连接,端到端的协议,提供了可靠的字符流通道,因为它在通信之前需要建立一个TCP连接,也就是握手消息(handshake message
)交换。
Java
为 TCP 协议提供两个类:Socket
类和 ServerSocket
类。
SocketAddress
在学习 Socket
之前,先来看看 SocketAddress
类,这个类代表一个套接字地址 (socket address
),一个套接字地址是由 IP 地址和端口号组成。 SocketAddress
是一个抽象类,而它的唯一子类就是 InetSocketAddress
类。 InetSocketAddress
其实是在 InetAddress
的基础上加了一个端口号。由于 InetSocketAddress
与 InetAddress
很相似,因此不多做介绍了。
Socket
Socket
是两台机器进行通信的终端,它由 IP 地址和端口号定义。而 Socket
类实现的是客户端的套接字。
客户端在与服务器在通信之前需要建立 TCP 连接,这就需要提供本地 IP 地址和端口号以及服务器 IP 地址和端口号,这就是创建 Socket
对象需要提供的参数。
IP 地址识别主机,端口号识别主机上的应用程序。
public Socket(String remoteHost, int remotePort) {}public Socket(InetAddress remoteAddr, int remotePort) {}public Socket(String remoteHost, int remotePort, InetAddress localAddr,int localPort) {}public Socket(InetAddress remoteAddr, int remotePort, InetAddress localAddr,int localPort) {}复制代码
在构造函数中如果不指定 IP 地址和端口号,就会选择一个本地的可靠的 IP 地址和端口号。 通常我们都会选择前二个构造函数来创建 Socket
实例。
当然我们也可以调用无参的构造函数来创建 Socket
实例
public Socket() {}复制代码
用无参的构造函数创建 Socket
实例后,需要调用 connect()
方法来建立连接
public void connect(SocketAddress endpoint) throws IOException {}public void connect(SocketAddress endpoint, int timeout) throws IOException {}复制代码
参数 SocketAddress endpoint
代表了服务器的 IP 地址和端口号,同时我们需要注意到,参数 int timeout
定义了超时的连接时间,这个是有参数构造函数无法提供的功能。
其实无论是有参还是无参的构造函数,最终都要调用 connect()
方法来建立连接。 只是有参数的构造函数设置的超时时间是 0,也就是不会超时,建立连接时会阻塞线程,直到连接建立或者错误发生。而无参的构造函数,会让开发者手动调用 connect()
方法并设置超时时间来建立连接,避免长时间阻塞的情况。
当连接建立后,也就是成功获取了 Socket
实例,就可以通过这个实例获取本地和服务器的 IP 地址和端口号
// 获取服务器IP地址和端口号public InetAddress getInetAddress() {}public int getPort() {}// 获取连接的本地IP地址和端口号public InetAddress getLocalAddress() {}public int getPort() {}// 获取服务器IP地址和端口号public SocketAddress getRemoteSocketAddress() {}public SocketAddress getLocalSocketAddress() {}复制代码
ServerSocket
前面说过 Socket
类是实现客户端套接字,而 ServerSocket
类实现的是服务器端套接字。
public ServerSocket(int port) {}public ServerSocket(int port, int queueLimit) {}public ServerSocket(int port, int queueLimit, InetAddress localAddr) {}复制代码
创建 ServerSocket
实例需要绑定一个端口号,方便用 accept()
方法来监听这个端口号上的所有客户端请求。
参数 int queueLimit
定义请求队列的长度,如果超过这个长度,会拒绝这个连接请求。
如果我们有特殊的本地 IP 地址需要,我们可以指定本地的某一个 IP 地址,也就是第三个参数。
而如果我们调用了无参的构造函数
public ServerSocket() {}复制代码
需要调用 bind()
方法绑定端口号
public void bind(SocketAddress endpoint){}public void bind(SocketAddress endpoint, int queueLimit){}复制代码
其实效果与有参数的构造函数没有什么区别。
当创建 ServerSocket
实例后,我们需要用 accept()
方法来监听在这个端口上的请求
public Socket accept() throws IOException {}复制代码
一旦接受了这个请求,就会返回一个 Socket
实例,我们就可以用这个实例与客户端进行通信。需要注意的是,这个监听会一直阻塞当前线程,直到有请求发生并建立连接,或者发生错误。
创建 TCP 客户端和服务器
现在来创建一个基于 TCP 套接字的客户端和服务器。
首先创建一个客户端,它会先发送信息到服务器,然后读取服务器返回的信息。
public class TCPClient1 { public static void main(String[] args) { String message = "hello"; Socket socket = null; try { // 1. create a socket bound to port 8890 and connected to server socket = new Socket(InetAddress.getLocalHost(), 8890); System.out.println("Connected to server..."); // 2. send message to server OutputStream out = socket.getOutputStream(); out.write(message.getBytes()); // 3. close socket output socket.shutdownOutput(); // 4. read message from server InputStream in = socket.getInputStream(); BufferedReader br = new BufferedReader(new InputStreamReader(in)); String line; System.out.println("Server said:"); while ((line = br.readLine()) != null) { System.out.println(line); } } catch (IOException e) { e.printStackTrace(); } finally { // 5. close socket try { if (socket != null) { socket.close(); } } catch (IOException e) { e.printStackTrace(); } } }}复制代码
第一步,创建 Socket
实例,它会与服务器建立连接。
第二步,从创建的 Socket
实例获取输出流,并向服务器发送数据。
第三步,关闭 Socket
的输出流。这一步很关键,因此服务器读取客户端的数据时候,需要知道客户端数据什么时候发送完毕了,我们通过关闭输出流来通知服务器数据发送完毕。
第四步,获取输入流,然后读取服务器返回的数据。 这里是通过一个 while
循环来读取服务器数据,如果服务器没有数据了,readLine()
方法会一直阻塞,如果服务器关闭输出流,readLine()
就会返回 null
。
第五步,关闭 Socket
,同时也关闭了与 Socket
相关的输入输出流。
客户端创建完了,现在来创建服务器端。服务器端先获取客户端信息,然后返回一个响应给客户端。
public class TCPServer1 { public static void main(String[] args) { ServerSocket serverSocket = null; Socket socket = null; try { // 1. create server socket bound to port 8890 serverSocket = new ServerSocket(8890); // 2. listen for a request connection, blocks until a connection is made socket = serverSocket.accept(); System.out.println("Handling client: " + socket.getRemoteSocketAddress()); // 3. get message from client InputStream in = socket.getInputStream(); BufferedReader br = new BufferedReader(new InputStreamReader(in)); String line; System.out.println("Client said:"); while ((line = br.readLine()) != null) { System.out.println(line); } // 4. write message to client OutputStream out = socket.getOutputStream(); out.write("Welcome!".getBytes()); } catch (IOException e) { e.printStackTrace(); } finally { try { // 5. close socket and server socket if (socket != null) { socket.close(); } if (serverSocket != null) { serverSocket.close(); } } catch (IOException e) { e.printStackTrace(); } } }}复制代码
第一步,创建一个 ServerSocket
实例,并且绑定相应的端口。
第二步,监听绑定的端口上请求连接,这个监听会阻塞当前线程,直到连接建立或发生错误。
第三步,获取输入流,然后循环读取信息。 如果客户端数据发送完毕,readLine()
方法会阻塞,直到客户端关闭了输出流或者连接发生错误才会返回 null
。 这也是为什么在客户端的代码中,发送完信息后要关闭输出流的原因。
第四步,获取输出流,向客户端输出信息。
第五步,关闭创建的 Socket
和 ServerSocket
。
当先运行服务器再启动客户端后,服务器会输出
Handling client: /173.10.2.51:52793Client said:hello复制代码
而客户端会说
Connected to server...Server said:Welcome!复制代码
改进 TCP 服务器端
上面的服务器端设计有明显的缺陷,那就是它处理了只能处理一个客户端请求,怎么改进呢? 可能通过加一个无限循环,不断的监听指定端口上的请求连接,然后处理
serverSocket = new ServerSocket(8890); // NOTE: 无限循环监听客户端请求连接并处理请求 while (true) { socket = serverSocket.accept(); System.out.println("Handling client: " + socket.getRemoteSocketAddress()); InputStream in = socket.getInputStream(); BufferedReader br = new BufferedReader(new InputStreamReader(in)); String line; System.out.println("Client said:"); while ((line = br.readLine()) != null) { System.out.println(line); } OutputStream out = socket.getOutputStream(); out.write("Welcome!".getBytes()); }复制代码
通过 while(true)
的无限循环,可以在处理完一个客户端后,再继续监听下一个客户端的请求连接,再处理,然后接着循环,这样就能拥有了处理多个客户端的能力。
改进后的服务器端拥有了处理多个客户端请求的能力,但是它却是在单线程处理的,效率不高,因此需要为服务器端加入多线程处理客户端请求的能力,我们可以使用线程池,这个情况后面再谈。