TCP编程


在开发网络应用程序的时候,我们又会遇到Socket这个概念。Socket是一个抽象概念,一个应用程序通过一个Socket来建立一个远程连接,而Socket内部通过TCP/IP协议把数据传输到网络:

  1. ┌───────────┐ ┌───────────┐
  2. Application Application
  3. ├───────────┤ ├───────────┤
  4. Socket Socket
  5. ├───────────┤ ├───────────┤
  6. TCP TCP
  7. ├───────────┤ ┌──────┐ ┌──────┐ ├───────────┤
  8. IP │◀────▶│Router│◀─────▶│Router│◀────▶│ IP
  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的典型实现代码如下:

  1. public class Server {
  2. public static void main(String[] args) throws IOException {
  3. ServerSocket ss = new ServerSocket(6666); // 监听指定端口
  4. System.out.println("server is running...");
  5. for (;;) {
  6. Socket sock = ss.accept();
  7. System.out.println("connected from " + sock.getRemoteSocketAddress());
  8. Thread t = new Handler(sock);
  9. t.start();
  10. }
  11. }
  12. }
  13. class Handler extends Thread {
  14. Socket sock;
  15. public Handler(Socket sock) {
  16. this.sock = sock;
  17. }
  18. @Override
  19. public void run() {
  20. try (InputStream input = this.sock.getInputStream()) {
  21. try (OutputStream output = this.sock.getOutputStream()) {
  22. handle(input, output);
  23. }
  24. } catch (Exception e) {
  25. try {
  26. this.sock.close();
  27. } catch (IOException ioe) {
  28. }
  29. System.out.println("client disconnected.");
  30. }
  31. }
  32. private void handle(InputStream input, OutputStream output) throws IOException {
  33. var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
  34. var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
  35. writer.write("hello\n");
  36. writer.flush();
  37. for (;;) {
  38. String s = reader.readLine();
  39. if (s.equals("bye")) {
  40. writer.write("bye\n");
  41. writer.flush();
  42. break;
  43. }
  44. writer.write("ok: " + s + "\n");
  45. writer.flush();
  46. }
  47. }
  48. }

服务器端通过代码:

  1. ServerSocket ss = new ServerSocket(6666);

在指定端口6666监听。这里我们没有指定IP地址,表示在计算机的所有网络接口上进行监听。

如果ServerSocket监听成功,我们就使用一个无限循环来处理客户端的连接:

  1. for (;;) {
  2. Socket sock = ss.accept();
  3. Thread t = new Handler(sock);
  4. t.start();
  5. }

注意到代码ss.accept()表示每当有新的客户端连接进来后,就返回一个Socket实例,这个Socket实例就是用来和刚连接的客户端进行通信的。由于客户端很多,要实现并发处理,我们就必须为每个新的Socket创建一个新线程来处理,这样,主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。

我们在多线程编程的章节中介绍过线程池,这里也完全可以利用线程池来处理客户端连接,能大大提高运行效率。

如果没有客户端连接进来,accept()方法会阻塞并一直等待。如果有多个客户端同时连接进来,ServerSocket会把连接扔到队列里,然后一个一个处理。对于Java程序而言,只需要通过循环不断调用accept()就可以获取新的连接。

客户端

相比服务器端,客户端程序就要简单很多。一个典型的客户端程序如下:

  1. public class Client {
  2. public static void main(String[] args) throws IOException {
  3. Socket sock = new Socket("localhost", 6666); // 连接指定服务器和端口
  4. try (InputStream input = sock.getInputStream()) {
  5. try (OutputStream output = sock.getOutputStream()) {
  6. handle(input, output);
  7. }
  8. }
  9. sock.close();
  10. System.out.println("disconnected.");
  11. }
  12. private static void handle(InputStream input, OutputStream output) throws IOException {
  13. var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
  14. var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
  15. Scanner scanner = new Scanner(System.in);
  16. System.out.println("[server] " + reader.readLine());
  17. for (;;) {
  18. System.out.print(">>> "); // 打印提示
  19. String s = scanner.nextLine(); // 读取一行输入
  20. writer.write(s);
  21. writer.newLine();
  22. writer.flush();
  23. String resp = reader.readLine();
  24. System.out.println("<<< " + resp);
  25. if (resp.equals("bye")) {
  26. break;
  27. }
  28. }
  29. }
  30. }

客户端程序通过:

  1. Socket sock = new Socket("localhost", 6666);

连接到服务器端,注意上述代码的服务器地址是"localhost",表示本机地址,端口号是6666。如果连接成功,将返回一个Socket实例,用于后续通信。

Socket流

当Socket连接创建成功后,无论是服务器端,还是客户端,我们都使用Socket实例进行网络通信。因为TCP是一种基于流的协议,因此,Java标准库使用InputStreamOutputStream来封装Socket的数据流,这样我们使用Socket的流,和普通IO流类似:

  1. // 用于读取网络数据:
  2. InputStream in = sock.getInputStream();
  3. // 用于写入网络数据:
  4. OutputStream out = sock.getOutputStream();

最后我们重点来看看,为什么写入网络数据时,要调用flush()方法。

如果不调用flush(),我们很可能会发现,客户端和服务器都收不到数据,这并不是Java标准库的设计问题,而是我们以流的形式写入数据的时候,并不是一写入就立刻发送到网络,而是先写入内存缓冲区,直到缓冲区满了以后,才会一次性真正发送到网络,这样设计的目的是为了提高传输效率。如果缓冲区的数据很少,而我们又想强制把这些数据发送到网络,就必须调用flush()强制把缓冲区数据发送出去。

练习

TCP编程 - 图1下载练习:使用Socket实现服务器和客户端通信 (推荐使用IDE练习插件快速下载)

小结

使用Java进行TCP编程时,需要使用Socket模型:

  • 服务器端用ServerSocket监听指定端口;
  • 客户端使用Socket(InetAddress, port)连接服务器;
  • 服务器端用accept()接收连接并返回Socket
  • 双方通过Socket打开InputStream/OutputStream读写数据;
  • 服务器端通常使用多线程同时处理多个客户端连接,利用线程池可大幅提升效率;
  • flush()用于强制输出缓冲区到网络。

读后有收获可以支付宝请作者喝咖啡:

TCP编程 - 图2