简介
网络编程中,客户端-服务端模式是一种常见的模式。
两者之间建立的 TCP 连接,是一种双向连接,两者经过三次握手之后就可以互相发送数据。
java.net.ServerSocket 服务端
public class TcpSocketServer {
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket();
// 绑定端口
serverSocket.bind(new InetSocketAddress(8080));
System.out.println("服务器等待连接... 127.0.0.1:8080");
// 阻塞,等待客户机连接
Socket clientSocket = serverSocket.accept();
InetSocketAddress clientAddress = (InetSocketAddress) clientSocket.getRemoteSocketAddress();
System.out.println("接收到客户端连接" + clientAddress.getHostString() + ":" + clientAddress.getPort());
OutputStream out = clientSocket.getOutputStream();
InputStream in = clientSocket.getInputStream();
byte[] readBytes = new byte[1024];
// 阻塞,等待读取客户机数据
while (in.read(readBytes) != -1) {
System.out.println(new String(readBytes));
// 阻塞,代表服务器业务处理
Thread.sleep(200);
// 服务器向客户端回写数据
out.write(String.valueOf(System.currentTimeMillis()).getBytes());
// 结尾回写回车+换行
out.write("
".getBytes());
// 手动刷新输出流
out.flush();
}
// 关闭连接
clientSocket.close();
serverSocket.close();
}
}
我们看到上面的服务器端有以下四大主要功能:
- 绑定端口
ServerSocket#bind
- 接收连接
ServerSocket#accept
- 读写数据
- 关闭连接
API:java.net.ServerSocket 1.0
- ServerSocket()
创建一个未建立监听的服务器套接字 - ServerSocket(int port)
创建一个监听端口的服务器套接字 - void bind(SocketAddress endpoint)
绑定服务器到指定的套接字地址 - Socket accept()
等待连接。该方法阻塞(即使之空闲)当前线程直到建立连接为知。该方法返回一个 Socket 对象,程序可以通过这个对象与连接中的客户端进行通信。 - void close()
关闭服务器套接字
我们启动服务器,控制台输出如下:
正如开篇的图片所示,服务端在等待连接时,发生阻塞。导致主线程 main 虽然 runnable,但是无法进行其他操作。
我们点击了左边栏被框住的照相机,获取此时虚拟机的 dump 文件。main 线程此时仍然是 RUNNABLE 的状态,但是阻塞在 ServerSocket 的 accept 操作上。
截图中所用的工具是 IDEA Intellij
java.net.Socket 客户端
public class TcpSocketClient {
public static void main(String[] args) throws IOException {
// 创建一个还未被连接的套接字
Socket socket = new Socket();
System.out.println("客户机的套接字是否已被连接?" + socket.isConnected());
// 为本地主机创建一个 InetAddress 对象
InetAddress localHost = InetAddress.getLocalHost();
// 主动连接服务器
socket.connect(new InetSocketAddress(localHost, 8080));
System.out.println("客户机的套接字是否已被连接?" + socket.isConnected());
OutputStream outStream = socket.getOutputStream();
InputStream inStream = socket.getInputStream();
// 用 PrintWriter 装饰输出流
PrintWriter out = new PrintWriter(outStream, true /*autoFlush*/);
// 用 Scanner 装饰输入流
Scanner in = new Scanner(inStream);
Scanner console = new Scanner(System.in);
long sendTime;
long returnTime;
// 阻塞,等待控制台输入数据
while (console.hasNextLine()) {
// 将输入台数据传输给服务器
out.println(console.nextLine());
sendTime = System.currentTimeMillis();
// 阻塞,等待读取服务器回写数据
while (in.hasNextLine()) {
returnTime = System.currentTimeMillis();
System.out.println("共计等待" + (returnTime-sendTime) +" ms 获取服务器数据");
// 将服务器回传数据输出到控制台
System.out.println(in.nextLine());
}
}
console.close();
in.close();
out.close();
}
}
这段代码实现的客户端可以通过不断向控制台写入数据来达到和服务器交换数据的功能。
注意:
- 原先,在类 TcpSocketClient 中,我在创建 PrintWriter 对象时犯过一个错误,我直接使用
PrintWriter out = new PrintWriter(outStream)
,这段代码默认是不开启自动刷新功能的,需要在out.println()
之后手动调用out.flush()
,否则服务器会一直处于等待客户机发送数据的状态。 - 另外,在类 TcpSocketServer 中,起初没有加上
out.write(" ".getBytes());
和out.flush()
这两句代码,分别导致客户机识别不到行结尾字节和接收不到数据,最终都会导致客户机仍然阻塞在in.hasNextLine()
处。
接着,我们继续启动客户端:
API:java.net.Socket 1.0
- boolean isConnected()
如果该套接字已被连接,则返回 true - Socket()
创建一个还未连接的套接字 - void connect(SocketAddress address) 1.4
将该套接字连接到给定的地址 - void connect(SocketAddress address, int timeoutInMilliseconds) 1.4
将套接字连接到给定的地址。如果在给定时间内没有响应,则返回。
在启动过客户端之后,我们可以再观察一下服务器的控制台输出:
此时服务器建立了连接,并且输出了客户机的套接字信息
API:java.net.Socket 1.0
- SocketAddress getRemoteSocketAddress() 1.4
获取远程套接字地址
API:java.net.InetSocketAddress 1.4
- int getPort()
获取套接字地址的端口号 - String getHostString() 1.7
返回主机名或者字符串形式的地址,主机名例如‘www.baidu.com’,字符串形式的地址例如‘132.163.4.102’
我们接着来看一下服务端的 Dump 信息,首先是关于主线程 main 的信息:
主线程中没有明显的 locked 标识。我们接着看看其他的:
Monitor Ctrl-Break 监听中断信号
这个地方我也请教了一些人,但是始终也没办法很好地从底层来解释 SocketInputStream 的 socketRead 到底是如何阻塞住的。如果有大佬看到的话,可以指点我一二。
总而言之,服务端阻塞在读取数据的代码上了。接着我们向客户端控制台输入要发送的数据内容(nihao)
由于本地客户端和服务端的网络通信几乎是0延迟的,所以客户端 in.readLine()
等待时间看上去就刚好服务端 Thread.sleep(200);
的时间。
BIO 线程模型
按照上面的写法,Server 的 main 线程仅能‘接待’第一个建立连接的 Client,之后的所有 Client 都无法正常与 Server 通信。这个肯定不符合我们服务器一对多的需求,因此我们想到多线程来处理多个客户端。
Java 服务端
改造了一下 TcpSocketServer 的代码,现在就是为每个连接分配一个单独的线程来进行处理的 BIO 模型了:
public class TcpSocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
// 绑定端口
serverSocket.bind(new InetSocketAddress(8080));
System.out.println("服务器等待连接... 127.0.0.1:8080");
int i = 0;
while (true) {
// 阻塞,等待客户机连接
Socket clientSocket = serverSocket.accept();
i++;
new Thread(() -> {
try {
handleConnection(clientSocket);
} catch (Exception e) {
e.printStackTrace();
}
}, "线程" + i).start();
}
}
private static void handleConnection(Socket clientSocket) throws IOException, InterruptedException {
InetSocketAddress clientAddress = (InetSocketAddress) clientSocket.getRemoteSocketAddress();
System.out.println(Thread.currentThread().getName() + " 接收到客户端连接" + clientAddress.getHostString() + ":" + clientAddress.getPort());
OutputStream out = clientSocket.getOutputStream();
InputStream inStream = clientSocket.getInputStream();
Scanner in = new Scanner(inStream);
// 阻塞,等待读取客户机数据
while (in.hasNextLine()) {
System.out.println(Thread.currentThread().getName() + " >> " + in.nextLine());
// 阻塞,代表服务器业务处理
Thread.sleep(200);
// 服务器向客户端回写数据
out.write(String.valueOf(System.currentTimeMillis()).getBytes());
// 结尾回写回车+换行
out.write("
".getBytes());
// 手动刷新输出流
out.flush();
}
// 关闭连接
clientSocket.close();
}
}
Windows Telnet 客户端
Win + R
打开命令行提示符- 输入
telnet 127.0.0.1 8080
并且回车
- 然后先按住
Ctrl
,再按下]
- 最后再按下 回车,现在可以正常看到输入的字符了
总结
本文介绍了如何使用 Socket 和 ServerSocket 分别实现 Java 客户端和 Java 服务端,同时还详细介绍了用到的 API。
另外还使用 Socket 和 ServerSocket 实现了一条线程处理一个 Socket 连接的 BIO 模型。