在开发网络应用程序的时候,我们又会遇到Socket这个概念。Socket是一个抽象概念,一个应用程序通过一个Socket来建立一个远程连接,而Socket内部通过TCP/IP协议把数据传输到网络:
┌───────────┐ ┌───────────┐ │Application│ │Application│ ├───────────┤ ├───────────┤ │ Socket │ │ Socket │ ├───────────┤ ├───────────┤ │ TCP │ │ TCP │ ├───────────┤ ┌──────┐ ┌──────┐ ├───────────┤ │ IP │<────>│Router│<─────>│Router│<────>│ IP │ └───────────┘ └──────┘ └──────┘ └───────────┘
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
的典型实现代码如下:
public class Server { public static void main(String[] args) throws IOException { ServerSocket ss = new ServerSocket(6666); // 监听指定端口 System.out.println("server is running..."); for (;;) { 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()) { try (OutputStream output = this.sock.getOutputStream()) { handle(input, output); } } catch (Exception e) { try { this.sock.close(); } catch (IOException ioe) { } System.out.println("client disconnected."); } } private void handle(InputStream input, OutputStream output) throws IOException { var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); writer.write("hello\n"); writer.flush(); for (;;) { String s = reader.readLine(); if (s.equals("bye")) { writer.write("bye\n"); writer.flush(); break; } writer.write("ok: " + s + "\n"); writer.flush(); } } }
服务器端通过代码:
ServerSocket ss = new ServerSocket(6666);
在指定端口6666
监听。这里我们没有指定IP地址,表示在计算机的所有网络接口上进行监听。
如果ServerSocket
监听成功,我们就使用一个无限循环来处理客户端的连接:
for (;;) { Socket sock = ss.accept(); Thread t = new Handler(sock); t.start();}
注意到代码ss.accept()
表示每当有新的客户端连接进来后,就返回一个Socket
实例,这个Socket
实例就是用来和刚连接的客户端进行通信的。由于客户端很多,要实现并发处理,我们就必须为每个新的Socket
创建一个新线程来处理,这样,主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。
我们在多线程编程的章节中介绍过线程池,这里也完全可以利用线程池来处理客户端连接,能大大提高运行效率。
如果没有客户端连接进来,accept()
方法会阻塞并一直等待。如果有多个客户端同时连接进来,ServerSocket
会把连接扔到队列里,然后一个一个处理。对于Java程序而言,只需要通过循环不断调用accept()
就可以获取新的连接。
客户端
相比服务器端,客户端程序就要简单很多。一个典型的客户端程序如下: