网络编程:写一个应用程序,让这个程序可以使用网络通信。这里就需要调用传输层提供的 api。
Socket套接字
传输层提供协议,主要是两个: UDP和TCP
提供了两套不同的 api,这api也叫做socket api。
UDP和 TCP 特点对比:
- UDP: 无连接,不可靠传输,面向数据报,全双工
- TCP : 有连接,可靠传输,面向字节流,全双工
有连接,无连接:客户端和服务器之间,彼此之间使用内存空间保存对端的信息,双方都保存这个信息此时“连接”就出现了。
可靠传输,不可靠传输:不是说,A 给 B 发的消息 100% 能到(这个要求太难了)。A 尽可能的把消息传给 B.并且在传输失败的时候,A 能感知到,**或者在传输成功的时候,也能知道自己传输成了。**可靠传输效率低,不可靠传输效率高。
面向字节流,面向数据报:
- TCP 和 文件操作类似,都是“流”式的(由于这里传输的单位是字节,称为字节流)
- UDP 是面向数据报,读写的基本单位是一个 UDP 数据报(包含了一系列的数据/属性)
全双工,半双工:
- 全双工:一个通道,可以双向通信
- 半双工:一个通道,只能单向通信
UDP数据报套接字编程
DatagramSocket API
DatagramSocket API 是一个 Socket 对象。操作系统,使用文件这样的概念,来管理一些软硬件资源,操作系统也是使用文件的方式来管理网卡的。表示网卡的这类文件,称为 Socket 文件。Java 中的 socket 对象,就对应这系统里的 socket 文件。
构造方法:
方法签名 | 方法说明 |
---|---|
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口 (一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口**(一般用 于服务端)** |
客户端使用哪个端口,系统自动分配。但是服务器使用哪个端口,是手动指定的。对于服务器来说,需要有一个固定的端口号,方便其他客户端找到。一个客户端的主机,上面运行的程序很多,天知道你手动指定的端口是不是被别的程序占用了。让系统自动分配一个端口是更明智的选择。服务器是完全在程序猿手里控制的。程序猿可以把服务器上的多个程序安排好,让他们使用不同的端口。
普通方法:
方法签名 | 方法说明 |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻 塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
DatagramPacket API
表示了一个 UDP 数据报,代表了系统中设定的 UDP 数据报的二进制结构。
构造方法:
方法签名 | 方法说明 |
---|---|
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的数据保存在 字节数组(第一个参数buf)中,接收指定长度(第二个参数 length) |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) | 构造一个DatagramPacket以用来发送数据报,发送的数据为字节 数组(第一个参数buf)中,从0到指定长度(第二个参数 length)。address指定目的主机的IP和端口号 |
普通方法:
方法签名 | 方法说明 |
---|---|
SocketAddress getSocketAddress() | SocketAddress对象包含了IP地址和端口号 |
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取 接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据。udp 数据报载荷部分(完整的应用层数据报) |
示例一:回显服务器(echo server).
回显服务器(echo server):客户端发啥,服务器返回啥.
// UDP 的 回显服务器.
// 客户端发的请求是啥, 服务器返回的响应就是啥.
public class UdpEchoServer {
private DatagramSocket socket = null;
// 参数是服务器要绑定的端口
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
// 使用这个方法启动服务器.
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 反复的, 长期的执行针对客户端请求处理的逻辑.
// 一个服务器, 运行过程中, 要做的事情, 主要是三个核心环节.
// 1. 读取请求, 并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
// 这样的转字符串的前提是, 后续客户端发的数据就是一个文本的字符串.
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2. 根据请求, 计算出响应
String response = process(request);
// 3. 把响应写回给客户端
// 此时需要告知网卡, 要发的内容是啥, 要发给谁.
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 记录日志, 方便观察程序执行效果.
System.out.printf("[%s:%d] req: %s, resp: %s\n", requestPacket.getAddress().toString(), requestPacket.getPort(),
request, response);
}
}
// 根据请求计算响应. 由于是回显程序, 响应内容和请求完全一样.
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
客户端代码:
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
// 服务器的 ip 和 服务器的端口.
public UdpEchoClient(String ip, int port) throws SocketException {
serverIp = ip;
serverPort = port;
// 这个 new 操作, 就不再指定端口了. 让系统自动分配一个空闲端口.
socket = new DatagramSocket();
}
// 让这个客户端反复的从控制台读取用户输入的内容. 把这个内容构造成 UDP 请求, 发给服务器. 再读取服务器返回的 UDP 响应
// 最终再显示在客户端的屏幕上.
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("客户端启动!");
while (true) {
// 1. 从控制台读取用户输入的内容
System.out.print("-> "); // 命令提示符, 提示用户要输入字符串.
String request = scanner.next();
// 2. 构造请求对象, 并发给服务器.
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
// 3. 读取服务器的响应, 并解析出响应内容.
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
// 4. 显示到屏幕上.
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
可以启动多个客户端,服务器也是可以应对的。IDEA 上启动多个程序,需要稍微设置一下:
其他人的电脑不能访问我这个程序。原因是:网络环境的现状,NAT 机制是主流。NAT 机制下,就把 IP 地址分成了外网 IP 和内网 IP.(内网IP 不能直接访问)。
示例二:翻译服务器
翻译服务器:请求是一些英文单词,响应则是对应的中文翻译。代码很多和回显服务器相同,所以继承UdpEchoServer 来实现代码的复用。
public class UdpDictServer extends UdpEchoServer {
private Map<String, String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("cat", "小猫");
dict.put("dog", "小狗");
dict.put("hello", "你好");
// 可以在这里继续添加千千万万个单词. 使每个单词都有一个对应的翻译.
}
// 是要复用之前的代码, 但是又要做出调整.
@Override
public String process(String request) {
// 把请求对应单词的翻译, 给返回回去.
return dict.getOrDefault(request, "该词没有查询到!");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
// start 不需要重新再写一遍了. 直接就复用了之前的 start !
server.start();
}
}
TCP流套接字编程
TCP 分量要比 UDP 更重用的,用的更多的协议。
TCP 提供的 api 也是主要有两个类
- ServerSoeket:给服务器使用的 socket
- Socket: 既会给服务器使用,也会给客户端使用
字节流,一个字节一个字节进行传输的。一个 tcp 数据报,就是一个 字节数组 byte[]。
ServerSocket API
ServerSocket 构造方法:
方法签名 | 方法说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
ServerSocket 方法:
方法签 名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket 对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
Socket API
Socket 构造方法:
方法签名 | 方法说明 |
---|---|
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的 进程建立连接 |
Socket 方法:
方法签名 | 方法说明 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
ServerSocket的accept()方法负责产生连接,产生连接的类型是Socket。Socket负责后续的连接后的通信。 ServerSocket的accept()是在服务器中使用,当服务器执行到 accept 的时候, 此时客户端可能还没来呢。accept 就会阻塞到有客户端连接成功为止。
accept()把内核中的连接获取到应用程序中了,这个过程类似于,"生产者消费者模型”。
TCP版本的回显服务器
客户端代码,要做的事情:
- 读取请求并解析.
- 根据请求计算响应
- 把响应写回给客户端.
public class TcpEchoServer {
private ServerSocket serverSocket = null;
// 这个操作就会绑定端口号
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
// 启动服务器
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
// 通过这个方法来处理一个连接的逻辑.
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d]客户端上线\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
// 接下来就可以读取请求, 根据请求计算响应, 返回响应三步走了.
// Socket 对象内部包含了两个字节流对象, 可以把这俩字节流对象获取到, 完成后续的读写工作
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
// 一次连接中, 可能会涉及到多次请求/响应
while (true){
//1. 读取请求并解析. 为了读取方便, 直接使用 Scanner.
Scanner sc = new Scanner(inputStream);
if(!sc.hasNext()){
//读取完毕,客户端下线
System.out.printf("[%s:%d]客户端下线\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
// 这个代码暗含一个约定, 客户端发过来的请求, 得是文本数据, 同时, 还得带有空白符作为分割. (比如换行这种)
String request = sc.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端. 把 OutputStream 使用 PrinterWriter 包裹一下, 方便进行发数据.
PrintWriter writer = new PrintWriter(outputStream);
// 使用 PrintWriter 的 println 方法, 把响应返回给客户端.
// 此处用 println, 而不是 print 就是为了在结尾加上 \n . 方便客户端读取响应, 使用 scanner.next 读取.
writer.println(response);
// 这里还需要加一个 "刷新缓冲区" 操作.
writer.flush();
// 日志, 打印当前的请求详情.
System.out.printf("[%s:%d] req: %s,resp:%s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),
request,response);
}
} catch (IOException e) {
e.printStackTrace();
}finally {
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
客户端代码,要做的事情:
1.从控制台读取用户的输入
2.把输入的内容构造成请求并发送给服务器
3.从服务器读取响应
4.把响应显示到控制台上
public class TcpEchoClient {
private Socket socket = null;
// 要和服务器通信, 就需要先知道, 服务器所在的位置.
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
// 这个 new 操作完成之后, 就完成了 tcp 连接的建立.
socket = new Socket(serverIp, serverPort);
}
public void start() {
System.out.println("客户端启动");
Scanner scannerConsole = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
while (true) {
// 1. 从控制台输入字符串.
System.out.print("-> ");
String request = scannerConsole.next();
// 2. 把请求发送给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
// 使用 println 带上换行. 后续服务器读取请求, 就可以使用 scanner.next 来获取了
printWriter.println(request);
// 不要忘记 flush, 确保数据是真的发送出去了!!
printWriter.flush();
// 3. 从服务器读取响应.
Scanner scannerNetwork = new Scanner(inputStream);
String response = scannerNetwork.next();
// 4. 把响应打印出来
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
}
多线程版本的会先服务器
当前代码还存在一个很大的问题。多个客户端访问会出现bug。当第一个客户端连接好了之后,第二个客户端,不能正确被处理服务器。看不到客户端上线,同时客户端发来的请求也无法被处理。当第一个客户端退出之后,之前第二个客户端发的请求,就能正确响应了。
上面的单线程代码中,serverSocket.accept(),建立一个连接,但是在进入processConnection()后,连接不断开,下一个连接就没办法连接上。主线程一个线程,又要建立连接,又要进行通信,只有通信结束连接断开之后,才会有下一个连接。要想解决这个问题我们就需要用多线程来解决,主线程负责建立连接,新建立的线程负责连接后的通信,各司其职就能解决这个问题。
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
// 启动服务器
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(()->{
processConnection(clientSocket);
});
}
}
经过上述改进,只要服务器系统资源足够,有几个客户端都是可以的了。
TCP 程序的时候,涉及到两种写法
-
一个连接中只传输一次请求和响应.短连接
-
一个连接中,可以传输多次请求和响应.长连接
上面代码属于长连接。其实如果我们刚才的代码,不写成这个样子。比如要求每个客户端只能发一次请求,发完就断开。上述情况能得到一定的缓解,但是还是有类似的问题的。处理多个消息,自然就会延长 processConnection 的执行时间,后续的连接也得等到上个连接断开后才能继续连接。
现在是有一个连接,就有一个新的线程。如果很多客户端,频繁的来连接/断开,服务器就涉及到频繁创建/释放线程了。我们使用线程池会比较好的解决这个问题。
// 此处不应该创建固定线程数目的线程池.
private ExecutorService service = Executors.newCachedThreadPool();
// 启动服务器
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
Socket clientSocket = serverSocket.accept();
// 使用线程池, 来解决上述问题
service.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
虽然使用线程池,避免了频繁创建销毁线程。但是毕竟是每个客户端对应一个线程。如果服务器对应的客户端很多,服务器就需要创建出大量的线程.对于服务器的开销是很大的。当客户端进一步增加,线程数目进一步增加系统的负担就越来越重,响应速度也会大打折扣。
< C10K问题:同一时刻,有 10k 个客户端(1w 个客户端),通过前面的一些技术手段,硬件设备,是可以处理好.(线程池之类的)
随着互联网的发展,客户端越来越多,请求也越来越多,进而引发了C10M问题。
C10M: 同一时刻,有 1kw 的客户端并发请求。引入了很多技术手段.其中一个非常 有效/必要 的手段,IO 多路复用/IO 多路转接.
IO 多路复用:一个线程完成多个IO操作。 不是说使用了这一个方法就能解决C10M问题,但是解决 高并发(C10M) 重要手之一。
解决高并发,就是四个字:
- 开源: 引入更多的硬件资源 (多核CPU)
- 节流: 提高单位硬件资源能够处理的请求数。同样的请求数,消耗的硬件资源更少。
IO 多路复用,就是节流的方式。同样的请求,消耗的硬件资源更少了。本质上就是减少线程的数量。操作系统提供的另一组网络编程的 API 可以和 tcp udp api 配合使用的。可以去搜索 Java NIO 一些关键词来学习IO多路复用。