目录
一、UDP 和 TCP 特点对比
1、有连接和无连接
2、可靠传输和不可靠传输
3、面向字节流和面向数据报
4、全双工和半双工
二、UDP 的 socket.api
1、DatagramSocket
2、DatagramPacket
回显服务器的实现
(1)服务器代码
(2)客户端代码
翻译服务器的实现
三、TCP
1、ServerSocket
编辑 2、Socket
回显服务器
(1)服务器代码
(2)客户端代码
网络编程其实就是写一个应用程序,让这个程序可以使用网络通信
这里就需要调用传输层提供的 api
传输层提供的协议一共是两个:
1、UDP
2、TCP
这两个协议提供了两套不同的 api
一、UDP 和 TCP 特点对比
UDP:无连接,不可靠传输,面向数据报,全双工
TCP:有连接,可靠传输,面向字节流,全双工
1、有连接和无连接
我们在之前学习 JDBC 的时候,有这么一个步骤:
先创建一个 DtaSourse ,再通过 DataSourse 创建 Connection
而这里的 Connection ,就是我们所说的连接
更直观的理解:
打电话的时候,拨号,按下拨号键,知道对方接通,才算完成连接建立
TCP 进行编程的时候,也是存在类似的建立连接的过程
无连接就类似于 发微信 / 短信,不需要建立连接就能进行通信
这里的 ”连接“ 是一个抽象的概念
客户端和服务器之间,使用内存保存对端的信息
双方都保存这个信息,此时 “连接” 就出现了
一个客户端可以连接多个服务器,一个服务器也可以对应多个客户端的连接
2、可靠传输和不可靠传输
可靠传输,并不是说 A 给 B 发的消息100% 能传到
而是,A 尽可能地把消息传给 B,并且在传输失败的时候,A 能感知到,或者在传输成功的时候,也能知道自己传输成功了
TCP 是可靠传输,传输效率就低了
UDP 是不可靠传输,传输效率更高
虽然 TCP 是可靠传输,UDP 是不可靠传输,但是并不能说 TCP 就是比 UDP 安全
“网络安全” 指的是,如果你传输的数据是否容易被黑客截获,以及如果被截获后是否会泄露一些重要信息
3、面向字节流和面向数据报
TCP 和文件操作类似,都是 “流” 式的(由于这里传输的单位都是字节 ,称为字节流)
UDP 是面向数据报,读写的基本单位是一个 UDP 数据报(包含了一系列的数据 / 属性)
4、全双工和半双工
全双工 :一个通道,可以双向通信
半双工: 一个通道,只能单向通信
网线,就是全双工的
网线一共有 8 根铜缆,4 4 一组,有的负责一个方向,有的负责另一个方向
二、UDP 的 socket.api
1、DatagramSocket
是一个 Socket 对象,
操作系统使用文件这样的概念,来管理一些软硬件资源,操作系统也是使用文件的方式来管理网卡的,表示网卡的这类的文件,称为 Socket 文件
Java 中的 Socket 对象,就对应系统里的 Socket 文件(最终还是要落到网卡上)
要进行网络通信,就必须得有 Socket 对象
构造方法:
第一个往往在客户端使用(客户端使用哪个端口是系统自动分配的)
第二个往往在服务器这边使用(服务器使用哪个端口是手动指定的)
一个客户端的主机,上面运行的结果很多,天知道你手动选定的端口是不是被别的程序占用了,所以让系统自动分配一个端口是更加明智的选择
服务器是完全在程序员手里控制的,程序员可以把服务器上的多个程序安排好,让他们使用不同的端口
其它方法:
2、DatagramPacket
表示了一个 UDP 数据报,代表了系统中设定的 UDP 数据报的二进制结构
构造方法:
第一个构造方法:用来接受数据
DatagramPacket 作为一个 UDP 数据报,必然要能够承载一些数据
通过手动指定的 byte[] 作为存储数据的空间
第二个构造方法:用来发送数据
SocketAddress address 指的是 ip 和 端口号
其它方法:
getData 是指获取 UDP 数据报载荷部分(完整的应用层数据报)
回显服务器的实现
接下来我们开始手写 UDP 客户端服务器
最简单的 UDP 服务器:回显服务器(echo server),客户端发啥,服务器返回啥
(1)服务器代码
编写网络程序的时候,经常见到这种异常,意思就是说 socket 是有可能失败的
最典型的情况,就是端口号被占用
端口号是用来区分主机上的应用程序,一个应用程序可以占用主机上的多个端口,一个端口只能被一个进程占用(有特例,此处不讨论)
换句话说,当端口已经被其它进程占用了,此时你再尝试创建这个 socket 对象,占用此端口,就会报错
一个服务器,要给很多客户端提供服务,服务器也不知道客户端什么时候来,服务器只能 “时刻准备着” ,随时客户端来了,随时提供服务
一个服务器,运行过程中,要做的事情,主要是三个核心环节
1、读取请求,并解析
2、根据请求,计算出响应
3、把响应写回给客户端
对于回显服务器来说,则不关心第二个流程,请求是啥,就返回什么响应
但是一个商业级的服务器,主要的代码都是在完成第二个步骤
这个方法中,参数的 DatagramPacket 是一个 “输出型参数”
传入 receive 的是一个空的对象,receive 内部就会把这个空的对象的内容给填成上,当 receive 执行结束,于是就得到了一个装满内容的 DatagramPacket
这个对象用来保存数据的内存空间,是需要手动指定的,不像学过的集合类,内部是有自己管理内存的能力的(能够申请内存,释放内存,内存扩容 等功能)
4096 这个数字是随便写出来的,但是也不能写的太大,不能超过 64kb
服务器程序一启动,就会立即执行到循环,立即执行到 receive 了
如果此时客户端的请求还没来,receive 方法就会阻塞等待,阻塞到真正有客户端发起请求过来了
这段代码中,就要构造一个 DatagramPacket 对象,把响应发回给客户端
注意:第二个参数不能写 response,length()
这是因为, response,length() 是按照字符计算长度,而 response.getBytes().length 是按照字节计算长度
如果字符串是全英文的,此时字节数和字符数是一样的,但是如果字符串中含有中文,此时两者计算出的结果就不一样了
socket api 本身,就是按照字节来处理的
requestPacket.getSocketAddress() 这个部分是把数据报,发送给客户端,就需要知道客户端的 ip 和端口
DatagramPacket 这个对象就包含着通信双方的 ip 和 port
服务器部分代码:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
//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",responsePacket.getAddress().toString(),responsePacket.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();
}
}
这个是否关闭了呢?
对于我们这个服务器程序来说,DatagramSocket 不关闭,问题不大,整个程序中只有这一个 socket 对象,不是频繁创建的
这个对象的生命周期非常长,跟随整个程序的,此时,socket 就需要保持打开的状态
socket 对象,对应到了 系统中的 socket 文件,又对于到了文件描述符(最主要的目的是为了是否文件描述符,才要关闭 socket 对象的)
进程结束,就把 pcb 回收,里面的文件描述符表也就都销毁了
但是这一点仅限于:只有一个 socket 对象,并且生命周期是跟随进程的,此时就可以不用释放
但是如果有多个 socket 对象,socket 对象生命周期更短,需要频繁创建释放,此时一定要记得去 close
(2)客户端代码
编写客户端,后面的参数指定了发给哪个 IP,发给哪个端口
此处需要的是 InetAdress 对象,所以使用 InetAdress 的静态方法,getByName 来进行构造(工厂模式 / 工厂方法)
服务器启动要自动绑定到 9090
客户端接下来就要访问 9090 这个窗口
客户端代码:
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
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();
}
//客户端启动,让这个客户端反复的从控制台读取用户输入的内容,把这个内容构造成 UPD 请求,发给服务器,再读取服务器返回的 UDP响应
//最终再显示在客户端的屏幕上
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner scanner = new Scanner(System.in);
while (true){
//1、从控制台读取用户输入的内容
System.out.println("-> ");//命令提示符,提示用户输入字符串
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(request);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.1",9090);
client.start();
}
}
此时,先运行服务器,再运行客户端,然后输入内容,就可以看到执行结果了
如果启动多个客户端,多个客户端也是可以被服务器应对的
翻译服务器的实现
翻译服务器,请求的是一些英文单词,响应则是对应得中文翻译
这个服务器和之前的回显服务器的到部分代码是相似的,所以我们让它直接继承之前的服务器
继承,本身就是为了更好的“复用现有代码”
如果在这里,不加@Override ,万一方法名字 / 参数类型 / 参数个数 / 访问权限 万一搞错了,此时就无法构成重写了,并且也不好发现
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class UdpDicSever extends UdpEchoServer{
private Map<String,String> dict = new HashMap<>();
public UdpDicSever(int port) throws SocketException {
super(port);
dict.put("cat","小猫");
dict.put("dog","小狗");
dict.put("duck","小鸭");
//可以在这里继续添加千千万万个单词,每个单词都有一个对应的翻译
}
//是要复用之前的代码,但是又要做出调整
@Override
public String process(String request){
//把请求对应单词的翻译给返回回去
return dict.getOrDefault(request,"该词没有查询到");
}
public static void main(String[] args) throws IOException {
UdpDicSever server = new UdpDicSever(9090);
//start 就不需要在写一遍了,就直接复用了之前的 start
server.start();
}
}
三、TCP
TCP 是字节流,一个字节一个字节的进行传输的
换句话说,一个 TCP 数据报,就是一个字节数组 byte[ ]
TCP 提供的 api 也是两个类
1、ServerSocket
给服务器使用的 Socket
构造方法:
其它方法:
2、Socket
既会给服务器使用,也会给客户端使用
构造方法:
其它方法:
回显服务器
(1)服务器代码
我们现在来尝试写一个 TCP 版本的回显服务器
这里会和 UDP 有一些差别:
进入循环之后,要做的事情不是读取客户端的请求,而是先处理客户端的 “连接”
虽然内核中的连接很多,但是在应用程序中,还是得一个一个处理的
内核中的连接就像一个一个待办事项,这些待办事项在一个队列 数据结构中,应用程序就需要一个一个完成这些任务
要完成任务,就需要先取任务,用到 serverSocker.accept()
把内核中的连接获取到应用程序中了,这个过程类似于 “生产者消费者模型”
程序启动,就会立即执行到 accept
当服务器执行到 accept 的时候,此时客户端可能还没来,accpet 就会阻塞
一直阻塞到有客户端连接成功
accept 是把内核中已经建立好的连接,给拿到应用程序中
但是这里的返回值并非是一个 “Connection” 这样的对象,而只是一个 Socket 对象
而这个 Socket 对象就像是一个 耳麦 一样,就可以说话,也能听到对方的声音
通过 Socket 对象,和对方进行网络通信
TCP server 中涉及到两种 socket,serverSocket 和 clientSocket
serverSocket 可以理解成售楼部负责拉客的人,clientSocket 可以理解成售楼部负责介绍详情的人
IO 操作是比较有开销的
相比于访问内存,进行 IO 的次数越多,程序的速度就越慢
使用一块内存作为缓冲区,写数据的时候,先写到缓冲区里,攒一波数据,统一进行 IO
PrintWriter 就内置了缓冲区,手动刷新,确保这里的数据是真的通过网卡发出去了,而不是残留在缓冲区中的
这里加上 flash 更稳妥,但是不加也不一定会出错
缓冲区是内置了一定的刷新策略,因此更建议把 flash 给加上
在这个程序中,涉及到两类 socket
1、ServerSocket (只有一个,生命周期跟随程序,不关闭也没事)
2、Socket
此处的 socket 是在被反复创建的
我们要确保,连接断开之后,socket 能够被关闭,所以在最后 finally 中加上关闭 socket 的代码
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
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 scanner = new Scanner(inputStream);
if (!scanner.hasNext()){
//读取完毕,客户端下线
System.out.printf("[%s:%d] 客户端下线! \n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
//这个代码暗含一个约定:客户端发过来的请求,得是文本数据,同时,还得带有空白符作为分割
String request = scanner.next();
//2、根据请求,计算响应
String response = process(request);
//3、把响应写回给客户端,把 OutputStream 使用 PrinterWriter 包裹一下,方便进行发数据
PrintWriter writer = new PrintWriter(outputStream);
//使用 PrintWriter 的 println 方法,把响应返回给客户端
//此处使用 println 而不是 print 就是为了在结尾加上换行符,方便客户端读取响应,使用 Scanner.next 读取
writer.println(response);
//这里还需要加一个 “刷新缓冲区” 的操作
writer.flush();
//日志,打印一下当前的请求详情
System.out.printf("[%s:%d] req: %s,resp: %s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),
request,response);
}
}finally {
//在 finally 中,加上 close 操作,确保当前 socket 能够被及时关闭
clientSocket.close();
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
(2)客户端代码
客户端要做的事情:
1、从控制台读取用户的输入
2、把输入的内容构造成请求并发送给服务器
3、从服务器读取响应
4、把响应显示到控制台上
当前代码中,对于 Scanner 和 PrintWriter 没有进行 close,是否会发生文件泄露呢?
不会!!!
流对象中持有的资源,有两个部分:
1、内存(对象销毁了,内存就回收了),while 循环一圈,内存自然销毁
2、文件描述符
Scanner 和 printWriter 没有持有文件描述符,持有的是 inputstream 和 outputstream 的引用,而这俩个对象已经进行了关闭
更准确的说,是 socket 对象持有的,所以把 socket 对象关闭就可以了
不是每个流对象都持有文件描述符,持有文件描述符,是要调用操作系统提供的 open方法
hasNext 在客户端没有发请求的时候,也会阻塞,一直阻塞到客户端真的发了数据,或者客户端退出,hasNext 就返回了
当前代码,还存在一个很大的问题:当我们启动两个客户端,会怎么样呢?
现象:
当第一个客户端连接好了之后,第二个客户端不能被正确处理
服务器看不到客户端上线,同时客户端发来的请求也无法被处理
当第一个客户端退出之后,之前第二个客户端发的请求,就都能响应了
当一个客户端来了,accept 就会返回,进入 processConnection
循环会处理该客户端的请求,一直到这个客户端结束了,方法才结束,才回到第一层这里
问题关键在于,处理一个客户端的请求过程中,无法第二次调用 accept ,也就是说即使第二个客户端来了,也没法处理
此处,客户端处理 processConnection 本身就是要长时间执行的,因为不知道客户端什么时候结束,也不知道客户端要发多少请求
那么我们期望在执行这个方法的同时,也能够调用到 accept,此时可以使用多线程
我们可以在主线程里,专门负责拉客,拉到客人之后,创建新的线程,让新的线程负责处理客户端的各种请求
经过上述改进,只要服务器资源足够,有几个客户端都是可以的
其实,如果我们刚才的代码,不写成这个样子,比如要求每个客户端只能发一次请求,发完就断开,上述情况就能得到一定的缓解,但是还是会有类似的问题的
处理多个消息,自然就会延长 proessConnection 的执行时间,就让这个问题更加严重了
TCP 程序的时候,涉及到两种写法:
1、一个连接中只传输一次请求和响应(短连接)
2、一个请求可以传输多次请求和响应(长连接)
现在我们是,有一个连接,就有一个新的线程
如果有很多客户端,频繁的来 连接 / 断开,服务器就涉及到频繁 创建 / 释放 线程了
使用线程池是更好的方案
这样写是不对的!!!
processConnection 和主线程是不同的线程了,执行 processConnecton 过程中,主线程 try 就执行完毕了,这就会导致 clientSocket 还没有用完,就关闭了
因此,还是需要把 clientSocket 交给 processConnection 里面来关闭,所以应该这样写:
虽然使用线程池,避免了频繁创建销毁线程,但是毕竟是每个客户端对应一个线程
如果服务器对应的客户端很多,服务器就需要创建出大量的线程,,对于服务器的开销是很大的
当客户端进一步增加,线程数目进一步增加,系统的负担就越来越重,响应的速度也会大打折扣
是否有办法,使用一个线程(或者最多三四个线程),能够高效处理很多个客户端的并发请求
这个也叫做 C10M 问题:同一时刻,有 1kw 的客户端并发请求
引入了很多技术手段,其中一个非常有效 / 必要的手段:IO多路复用 / IO 多路转接
这个东西是解决高并发(C10M)的重要手段之一
解决高并发,说白了就是四个字:
1、开源:引入更多的硬件资源
2、节流:提高单位硬件资源能够处理的请求
IO 多路复用,就是节流的方式,同样的请求,消耗的硬件资源更少了