TCP 编程
# TCP 编程
本文简单说下 TCP 编程
# Socket
在开发网络应用程序的时候,我们又会遇到 Socket 这个概念。Socket 是一个抽象概念,一个应用程序通过一个 Socket 来建立一个远程连接,而 Socket 内部通过 TCP/IP 协议把数据传输到网络:
┌───────────┐ ┌───────────┐
│Application│ │Application│
├───────────┤ ├───────────┤
│ Socket │ │ Socket │
├───────────┤ ├───────────┤
│ TCP │ │ TCP │
├───────────┤ ┌──────┐ ┌──────┐ ├───────────┤
│ IP │←────→│Router│←─────→│Router│←────→│ IP │
└───────────┘ └──────┘ └──────┘ └───────────┘
2
3
4
5
6
7
8
9
Socket、TCP 和部分 IP 的功能都是由操作系统提供的,不同的编程语言只是提供了对操作系统调用的简单的封装。例如,Java 提供的几个 Socket 相关的类就封装了操作系统提供的接口。
为什么需要 Socket 进行网络通信?因为仅仅通过 IP 地址进行通信是不够的,同一台计算机同一时间会运行多个网络应用程序,例如浏览器、QQ、邮件客户端等。当操作系统接收到一个数据包的时候,如果只有 IP 地址,它没法判断应该发给哪个应用程序,所以,操作系统抽象出 Socket 接口,每个应用程序需要各自对应到不同的 Socket,数据包才能根据 Socket 正确地发到对应的应用程序。
一个 Socket 就是由 IP 地址和端口号(范围是 0~65535)组成,可以把 Socket 简单理解为 IP 地址加端口号。端口号总是由操作系统分配,它是一个 0~65535 之间的数字,其中,小于 1024 的端口属于特权端口,需要管理员权限,大于 1024 的端口可以由任意用户的应用程序打开,例如:
- 101.202.99.2:1201
- 101.202.99.2:1304
- 101.202.99.2:15000
使用 Socket 进行网络编程时,本质上就是两个进程之间的网络通信。其中一个进程必须充当服务器端,它会主动监听某个指定的端口,另一个进程必须充当客户端,它必须主动连接服务器的 IP 地址和指定端口,如果连接成功,服务器端和客户端就成功地建立了一个 TCP 连接,双方后续就可以随时发送和接收数据。
因此,当 Socket 连接成功地在服务器端和客户端之间建立后:
- 对服务器端来说,它的 Socket 是指定的 IP 地址和指定的端口号;
- 对客户端来说,它的 Socket 是它所在计算机的 IP 地址和一个由操作系统分配的随机端口号。
# 服务器端
要使用 Socket 编程,我们首先要编写服务器端程序。Java 标准库提供了 ServerSocket
来实现对指定 IP 和指定端口的监听。ServerSocket
的典型实现代码如下:
package chapter20;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class TCPDemo1Server {
public static void main(String[] args) throws Exception{
ServerSocket ss = new ServerSocket(7777);
System.out.println("server is running");
while (true){
Socket sock = ss.accept();
System.out.println("connected from " + sock.getRemoteSocketAddress());
Thread t = new Handler(sock);
t.start();
}
}
}
class Handler extends Thread{
Socket sock;
public Handler(Socket sock){
this.sock = sock;
}
@Override
public void run() {
try(InputStream input = this.sock.getInputStream();
OutputStream output = this.sock.getOutputStream()){
handle(input, output);
}catch (Exception e){
System.out.println("Client disconnected: ");
e.printStackTrace();
try{
this.sock.close();
}catch (Exception e2){
System.out.println("sock close error: ");
e2.printStackTrace();
}
}
}
private void handle(InputStream input, OutputStream output) throws IOException{
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
writer.write("hello!\n");
writer.flush();
while (true){
String s = reader.readLine();
if(s.equals("bye, server")){
writer.write("bye, client!");
writer.flush();
break;
}
writer.write("server successfully receive message \" " + s + " \" from client. \n");
writer.flush();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
我们解读下这段代码。首先,服务器端通过代码:
ServerSocket ss = new ServerSocket(7777);
在指定端口 7777
监听。这里我们没有指定 IP 地址,表示在计算机的所有网络接口上进行监听。
如果 ServerSocket
监听成功,我们就使用一个无限循环来处理客户端的连接:
while (true){
Socket sock = ss.accept();
System.out.println("connected from " + sock.getRemoteSocketAddress());
Thread t = new Handler(sock);
t.start();
}
2
3
4
5
6
注意到代码 ss.accept()
表示每当有新的客户端连接进来后,就返回一个 Socket
实例,这个 Socket
实例就是用来和刚连接的客户端进行通信的。由于客户端很多,要实现并发处理,我们就必须为每个新的 Socket
创建一个新线程来处理,这样,主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。
我们在多线程编程的章节中介绍过线程池,这里也完全可以利用线程池来处理客户端连接,能大大提高运行效率。
如果没有客户端连接进来,accept()
方法会阻塞并一直等待。如果有多个客户端同时连接进来,ServerSocket
会把连接扔到队列里,然后一个一个处理。对于 Java 程序而言,只需要通过循环不断调用 accept()
就可以获取新的连接。
然后 handle
函数就可以通过 IO 来读取(reader)客户端发送的数据,并通过 IO 返回数据给客户端(writer)。如果客户端发送了字符串 bye, server
,则认为客户端要停止连接,跳出循环不再接受数据。
# 客户端
相比服务器端,客户端程序就要简单很多。一个典型的客户端程序如下:
package chapter20;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class TCPDemo2Client {
public static void main(String[] args) throws IOException{
Socket sock = new Socket("localhost", 7777);
try(InputStream input = sock.getInputStream();
OutputStream output = sock.getOutputStream()){
handle(input, output);
}
sock.close();
System.out.println("Disconnected from server.");
}
private static void handle(InputStream input, OutputStream output) throws IOException{
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
Scanner scanner = new Scanner(System.in);
System.out.println("[server] " + reader.readLine());
while(true) {
System.out.print(">>> ");
String s= scanner.nextLine();
writer.write(s);
writer.newLine();
writer.flush();
String resp = reader.readLine();
System.out.println("<<< " + resp);
if(resp.equals("bye, client!")){
break;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
客户端程序通过:
Socket sock = new Socket("localhost", 7777);
连接到服务器端,注意上述代码的服务器地址是 "localhost"
,表示本机地址,端口号是 7777
。如果连接成功,将返回一个 Socket
实例,用于后续通信。
# 演示
我们运行 TCPDemo1Server
,然后再运行 TCPDemo2Client
,测试发送一些字符串给服务器:
可以看到服务器正常接受了数据,并返回了接受到的数据是什么。
# Socket 流
当 Socket 连接创建成功后,无论是服务器端,还是客户端,我们都使用 Socket
实例进行网络通信。因为 TCP 是一种基于流的协议,因此,Java 标准库使用 InputStream
和 OutputStream
来封装 Socket 的数据流,这样我们使用 Socket 的流,和普通 IO 流类似:
// 用于读取网络数据:
InputStream in = sock.getInputStream();
// 用于写入网络数据:
OutputStream out = sock.getOutputStream();
2
3
4
最后我们重点来看看,为什么写入网络数据时,要调用 flush()
方法。
如果不调用 flush()
,我们很可能会发现,客户端和服务器都收不到数据,这并不是 Java 标准库的设计问题,而是我们以流的形式写入数据的时候,并不是一写入就立刻发送到网络,而是先写入内存缓冲区,直到缓冲区满了以后,才会一次性真正发送到网络,这样设计的目的是为了提高传输效率。如果缓冲区的数据很少,而我们又想强制把这些数据发送到网络,就必须调用 flush()
强制把缓冲区数据发送出去。
# 小结
使用 Java 进行 TCP 编程时,需要使用 Socket 模型:
- 服务器端用
ServerSocket
监听指定端口; - 客户端使用
Socket(InetAddress, port)
连接服务器; - 服务器端用
accept()
接收连接并返回Socket
; - 双方通过
Socket
打开InputStream
/OutputStream
读写数据; - 服务器端通常使用多线程同时处理多个客户端连接,利用线程池可大幅提升效率;
flush()
用于强制输出缓冲区到网络。