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.com
Host address: 74.125.204.102
IP address: [74, 125, -52, 102]
-----
Host name: developer.android.com
Host address: 74.125.204.113
IP 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:52793
Client 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) 的无限循环,可以在处理完一个客户端后,再继续监听下一个客户端的请求连接,再处理,然后接着循环,这样就能拥有了处理多个客户端的能力。
改进后的服务器端拥有了处理多个客户端请求的能力,但是它却是在单线程处理的,效率不高,因此需要为服务器端加入多线程处理客户端请求的能力,我们可以使用线程池,这个情况后面再谈。