目录
1.网络编程中的一些基础概念
1.1 什么网络编程
网络编程就是通过代码实现两个或多个进程,通过网络,来进行相互通信。
我们之前学过进程,知道了进程具有隔离性(每个进程有自己独立的虚拟地址空间),进程间通信是借助一个每个进程都能访问到的公共区域,完成数据交换。网络编程,也是一种进程间通信的方式,它借助的公共区域是"网卡"。网络编程既能够让同一个主机的多个进程间通信,还能让不同主机的多个进程间通信。
1.2 客户端(client)/ 服务器(server)
🍃客户端:主动发送网络数据的一方。
🍃服务器:被动接收网络数据的一方。
举个例子,比如我去餐馆吃饭,我什么时候去餐馆吃饭,餐馆是不知道的,我可以早上去,也可以中午去,还可以晚上去。他无法确定客户什么时候来吃饭,所以需要被动的营业一天,这里的餐馆就相当于服务器,需要 7*24 小时运行,我就相当于客户端,主动去餐馆吃饭。
1.3 请求(request)/ 响应(response)
🍃请求:客户端给服务器发送的数据。
🍃响应:服务器给客户端返回的数据。
1.4.客户端和服务器之间的交互方式
1)一问一答【最常见的方式】
客户端给服务器发送一个请求,服务器给客户端返回一个响应。(比如浏览网页)
2)多问一答【少见】
客户端给服务器发送多个请求,服务器给客户端返回一个响应。(比如上传文件)
3)一问多答【一般】
客户端给服务器发送一个请求,服务器给客户端返回多个响应。(比如下载文件)
4)多问多答
客户端给服务器发送多个请求,服务器给客户端返回多个响应。(比如远程控制,游戏串流)
2.简单介绍 TCP / UDP 协议
进行网络编程时,需要使用操作系统提供的网络编程 API。而这些 API 正是传输层提供的,这些 API 也叫做 socket api。
操作系统提供的原生 api 是 C语言风格。但是 JVM 非常贴心,把 C风格的 socket api 封装成了 Java 中面向对象风格的 api。
🍁 传输层提供的两个重要的协议-TCP/UDP
简单概括 TCP/UDP
TCP:有连接,可靠传输,面向字节流,全双工。
UDP:无连接,不可靠传输,面向数据报,全双工。
=====================================================================
🍃有链接:类似于打电话,先建立连接,然后再通信。
🍃无连接:类似于发微信,不必建立连接,直接通信即可。
🍔可靠传输:对方收没收到数据,发送方能够感知到。(网络通信,是无法保证100%到达的,万一网线被挖掘机铲断....打电话就是可靠传输)
🍔不可靠传输:对方收没收到数据,不关心,也不知道。(发微信就是不可靠传输,qq / 微信都没有已读功能)
🍊面向字节流:这里的字节流和文件操作里的字节流是一样的。
🍊面向数据报:以数据报为传输的基本单位。(此处的面向字节流,面向数据报是站在应用层的角度来看的,传输层传输的都是一个个数据报文)
🍉全双工:双向通信,一个管道,能 A -> B,B -> A 同时进行。(类似马路的双向行驶)
🍉半双工:单向通信,一个管道,同一时刻,要么 A -> B,要么 B -> A,不能同时进行。(类似于两个人同时吹一根吸管的两头,只能有一头出气)
3.UDP 套接字编程
UDP 协议中的三个核心 API 的介绍:
🍃1.DatagramSocket :socket 类,本质上相当于是一个"文件"。(在系统中,还有一种特殊的 socket 文件,对应到网卡设备)构造一个 DategramSocket 对象,就相当于是打开了一个内核中的 socket 文件。
🍃2.DatagramPacket :表示一个 UDP 数据报,UDP 是面向数据报的协议。传输数据,就是以 DatagramPacket 为基本单位。
🍃3.InetSocketAddress :创建一个Socket地址,包含IP地址和端口号
方法
| 方法说明 |
DatagramSocket() |
创建一个
UDP
数据报套接字的
Socket
,绑定到本机任意一个随机端口
(一般用于客户端)
|
DatagramSocket(int port)
|
创建一个
UDP
数据报套接字的
Socket
,绑定到本机指定的端口
(一般用于服务端)
|
方法
| 方法说明 |
void receive(DatagramPacket p)
|
从此套接字接收数据报(如果没有接收到数据报,该方法会阻
塞等待)
|
void send(DatagramPacket p)
| 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() |
关闭此数据报套接字
|
🍁DatagramPacket API
DatagramPacket 【构造方法】
方法
| 方法说明 |
DatagramPacket(byte[] buf, int length)
|
构造一个
DatagramPacket
以用来接收数据报,接收的数据保存在字节数组(第一个参数 buf
)中,接收指定长度(第二个参数 length)
|
DatagramPacket(byte[] buf, int offset, int length,
SocketAddress address)
|
构造一个
DatagramPacket
以用来发送数据报,发送的数据为字节数组(第一个参数 buf
)中,从
0
到指定长度(第二个参数 length)。
address
指定目的主机的
IP
和端口号
|
DatagramPacket 【方法】
方法
| 方法说明 |
InetAddress getAddress()
|
从接收的数据报中,获取发送端主机
IP
地址;或从发送的数据报中,获取接收
端主机IP
地址
|
int getPort() |
从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获
取接收端主机端口号
|
byte[] getData() | 获取数据报中的数据 |
方法
| 方法说明 |
InetSocketAddress(InetAddress address, int port) | 创建一个Socket地址,包含IP地址和端口号 |
3.1 UDP版本的回显服务器 - 客户端
此处的回显服务器 - 客户端,不涉及到任何的业务逻辑。客户端发啥,服务器就返回啥,只是单纯的演示 api 的用法。
UdpEchoServer 代码(服务器)
public class UdpEchoServer {
// 要想创建 UDP 服务器,首先要打开一个 socket 文件(构造方法中实例化)
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
// 实例化的时候需要关联(绑定)一个端口号
socket = new DatagramSocket(port);
}
// 启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
while(true) {
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
// 1.读取客户端发来的请求
socket.receive(requestPacket);
// 2.对请求进行解析,把 DatagramPacket 转成一个 String
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
// 3.根据请求,计算响应
String response = process(request);
// 4.把响应构造成 DatagramPacket 对象
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
response.getBytes().length, requestPacket.getSocketAddress());
// 5.把这个 DatagramPacket 对象返回给客户端
socket.send(responsePacket);
System.out.printf("[%s:%d] request = %s; response = %s\n",requestPacket.getAddress(),
requestPacket.getPort(),request,response);
}
}
// 通过这个方法,实现根据请求计算响应这个过程
// 如果是其他服务器,就可以在 process 里面,加上一些其他的逻辑处理
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
// 真正启动服务器
UdpEchoServer server = new UdpEchoServer(8000);
server.start();
}
}
UdpEchoClient 代码(客户端)
public class UdpEchoClient {
private DatagramSocket socket = null;
public UdpEchoClient() throws SocketException {
// 客户端的端口号,一般都是由操作系统自由分配的。
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while(true) {
// 1.让客户端从控制台读取一个请求数据
System.out.println("> ");
String request = scanner.next();
// 2.把这个字符串请求发送给服务器,构造 DatagramPacket
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),
request.getBytes().length, InetAddress.getByName("127.0.0.1"),8000);
// 3.把数据报发送给服务器
socket.send(requestPacket);
// 4.从服务器读取响应数据
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
// 5.把响应的数据获取出来,转成字符串
String response = new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.printf("request: %s; response: %s\n",request, response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient();
client.start();
}
}
🍁【理解 UdpEchoServer 中的重要步骤】
🍔1.为什么实例化的时候给这个进程绑定(关联)一个端口号?
一个操作系统上,有很多端口号,程序如果需要进行网络通信,就需要获取到一个端口号。端口号相当于用来在网络上区分进程的身份标识符。操作系统收到网卡的数据,就可以根据网络数据报中的端口号,来确定把这个数据交给哪个进程。
关于端口号,再提一嘴:
🍃1.分配端口号有两种方式,要么咱们自己手动指定,要么系统自动分配。
🍃2.一个端口,在通常情况下,是不能被同一个主机上的多个进程同时绑定的,但是一个进程可以绑定多个端口(类似一个人可以有很多个电话号码),如果端口以已经被别人占用,再尝试绑定,此时就会抛异常!!
🍔2.解析 DatagramSocket API 的 receive 方法
🍃1.此处的 receive 方法用来读取客户端你发来的请求:如果客户端没有发来请求,就会阻塞等待,直到客户端真的发来请求了,receive 才会返回。
🍃2.receive 这个方法的返回值是 void,它并不是通过返回值将数据带回的 ,而是通过参数来放置数据的,它的参数类型为输出型参数。所以我们在调用 receive 方法之前,需要先构造一个 DatagramPacket,然后把这个空的 DatagramPacket 对象填到参数中,等到 receive 返回之后,自然就把读到的数据给放到参数里面了。
public synchronized void receive(DatagramPacket p) throws IOException { /*源码*/ }
🍔3.解析数据的时候,这里是直接将其构造成了一个字符串。
因为前面 receive 通过 datagramPacket 带回的数据都放在一个字节数组中了,所以 requestPacket.getData()实际上就是拿到一个数组,然后再通过指定长度就可以构造字符串了。
🍔4.分析处理响应之后,将响应发送给客户端的过程
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
response.getBytes().length, requestPacket.getSocketAddress());
这个过程其实就类似于我们在淘宝上买东西不满意退货的过程,卖家发包裹给我们,不满以,要退货。我们包裹都拆开了,至少要包装一下再寄回去,我们寄回去,就要让快递员知道寄什么东西以及寄货地址,而上述代码中第一个参数将响应转化成字节数组以及第二个参数指定数组长度,就类似于将包裹包装一下。而第三个参数通过请求数据报调用发送方地址则是指定发货地址,谁发来的,我就退给谁。requestPacket.getSocketAddress() 包括了 IP地址和端口号。
🍔5.主函数中,server 类实例化之后,端口号的设定
在启动服务器的时候,咱们指定端口号一般来说是随便指定,其实它也是有范围的(0~65535),并且 1024 以下的端口,都是系统保留的。因此,咱们在指定端口的时候,我还是建议选在 1024 以上,65535 以下。
=================================================================
🍁【理解 UdpEchoClient 中的重要步骤】
🍔1.服务器,端口一般是手动指定的;客户端,端口一般是操作系统自动分配的。
1.如果服务器自动分配端口,客户端就不知道服务器的端口是什么了。因此,服务器有固定的端口才好访问。
2.客户端程序是安装在用户的电脑上的,用户电脑当前哪些程序,是不可控的。如果要手动指定端口,如果这个端口和其他程序冲突了,就会导致咱们的代码无法运行了。
🍔2.客户端发送请求给服务器,不仅要构造成字符数组,还需要指定 IP,端口
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),
request.getBytes().length, InetAddress.getByName("127.0.0.1"),8000);
此处的构造是 DatagramPacket 的第三种构造方法。
参数 InetAddress.getByName("127.0.0.1") 是通过客户端输入的字符串构造 InetAddress,"127.0.0.1" 是环回 IP,表示当前主机。
到目前为止,上述代码已经涉及到了 DatagramPacket 的三种构造方法:
1.只填写缓冲区,用来接收数据的,是一个空的 Packet。
2.填写缓冲区,并且填写包裹发给谁---InetAddress 对象来表示。
3.填写缓冲区,并且填写包裹发给谁---InetAddress + port 来表示。
====================================================
UDP协议中,不管是在服务器,还是看客户端,发送数据和接收数据都是以 DatagramPacket 为基本单位进行的,前面说过 UDP 协议是面向数据报的,数据报就是这玩意。
🍔3.我们的 DatagramSocket 不是有一个 close 方法吗,文件打开之后,用完了不是要及时关闭吗?
上述代码中的 socket 对象的生命周期是伴随着整个进程的,所以,进程结束之前,提前关闭 socket 对象,就不合理了。再者,当前进程结束后,对应的 PCB 也没了,PCB 上面的文件描述符表也没了,此时就相当于关闭了。
3.2 图解客户端、服务器工作流程
🍁根据 UDP 协议的特点来疏通代码
🍃UDP:无连接,不可靠传输,面向数据报,全双工。
1.无连接:UDP 客户端没有建立连接,先是 new 了一个 DatagramSocket 对象,然后就直接开始发送请求了。
2.不可靠传输:代码层次看不出来,在内核中才体现出来。
3.面向数据报:不管是服务器还是客户端,发送和接收都是以数据报(DatagramPacket)为基本单位进行的。
4.全双工:一个 scoket 既可以发送,又可以接收。
上述代码在 IDEA 上运行,如果不做处理,不能同时启动多个客户端,我们需要在 IDEA 上稍做处理:
3.3 翻译服务器
此处的翻译服务器,只是在前面的服务器的基础上简单模拟。
public class UdpDictServer extends UdpEchoServer{
private Map<String,String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
// 构造词汇
dict.put("hello","你好");
dict.put("monkey","孙悟空");
dict.put("fuck","卧槽");
dict.put("dog sun","狗日");
}
// 重写 process
@Override
public String process(String request) {
return dict.getOrDefault(request,"这个问题俺也不会!");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(8000);
server.start();
}
}
这里的翻译服务器直接继承了回显服务器,只是重写了 process 函数,翻译的业务逻辑都写在 process 里面了。客户端代码还是用上面的那个。
程序运行结果:
【总结】
process 函数才是一个服务器的灵魂所在,一个服务器要完成的工作,都是通过 "根据请求计算响应" 来体现的。不管是什么样的服务器,读取请求并解析,构造响应并返回,这两个步骤都大同小异,唯有 "根据请求计算响应" 是千变万化,是非常复杂的!!
4. TCP 套接字编程
TCP 协议中两个核心 API 的介绍:
🍃ServerSocket:创建 TCP 服务端 Socket 的 API
🍃Socket:Socket 是客户端 Socket,或服务端中接收到客户端建立连接(accept 方法)的请求后,返回的服务端 Socket。不管是客户端还是服务端 Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。
🍁ServerSocket API
ServerSocket 【构造方法】
方法 | 方法说明 |
ServerSocket(int port) | 创建一个服务端 Socket,并绑定到指定端口 |
方法
| 方法说明 |
Socket accept()
|
开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端
Socket
对象,并基于该
Socket
建立与客户端的连接,否则阻塞等待
|
void close()
| 关闭此套接字 |
方法
| 方法说明 |
Socket(String host, int port)
|
创建一个客户端流套接字
Socket
,并与对应
IP
的主机上,对应端口的
进程建立连接
|
Socket 【方法】
方法
| 方法说明 |
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream()
| 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
如何理解 ServerSocket 和 Socket 的行为??
举个例子,例如我想买房,刚从小区走出来,准备去看房,然后碰巧碰上一个搞销售的大哥,然后在一番交谈过后,他领着我去到了他们公司的售楼部,然后叫了两个小姐姐给我介绍楼盘。此处的大哥在外场拉客就是 SeverSocket 的行为,而内场的小姐姐提供的售楼服务就是 Socket 的行为。当有连接过来了,就通过 accept 把连接交给了 Socket ,后续就通过 Socket 对象与客户进行交互。
4.1 TCP版本的回显服务器 - 客户端
TcpEchoServer代码(服务器)
public class TcpEchoServer {
public ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
// 此处最好使用自动扩容版本的线程池
ExecutorService service = Executors.newCachedThreadPool();
while(true) {
// 如果当前没有客户端来建立连接,就会阻塞等待!
Socket clientSocket = serverSocket.accept();
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnect(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
// 通过这个方法,给当前连上的这个客户端,提供服务
// 一个连接过来了,服务方式可能有两种:
// 1.一个连接只进行一次数据交互(一个请求 + 一个响应),也叫做短链接
// 2.一个连接进行多次数据交互(N 个请求 + N 个响应),也叫做长链接
public void processConnect(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 建立连接!\n", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
// 这里是长连接的写法,需要通过循环来获取多次交互
while(true) {
if(!scanner.hasNext()) {
// 断开连接
System.out.printf("[%s:%d] 断开连接!\n", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
// 1.读取请求并解析
String request = scanner.next();
// 2.根据请求计算响应
String response = process(request);
// 3.把响应写回给客户端
printWriter.println(response);
// 刷新缓冲区
printWriter.flush();
System.out.printf("[%s:%d] request: %s; response: %s\n",
clientSocket.getInetAddress().toString(), clientSocket.getPort(),request,response);
}
} finally {
// 建立连接后,就没用了,需要关闭资源,避免资源泄露
clientSocket.close();
}
}
// 回显
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(8000);
tcpEchoServer.start();
}
}
TcpEchoClient代码(客户端)
public class TcpEchoClient {
public Socket socket = null;
public TcpEchoClient() throws IOException {
// new 这个对象,需要和服务器建立连接,建立连接,就需要知道服务器在哪
socket = new Socket("127.0.0.1",8000);
}
public void start() throws IOException {
// 由于实现的是长连接,一个连接会处理 N 个请求和响应
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
// 包装一下输入输出流
Scanner scannerNet = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true) {
// 从控制台读取用户的输入
System.out.println("> ");
String request = scanner.next();
// 2.向服务器发送请求
printWriter.println(request);
printWriter.flush();
// 3.从服务器读取响应
String response = scannerNet.next();
// 4.显示服务器响应
System.out.printf("request: %s; response: %s\n",request,response);
}
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient();
tcpEchoClient.start();
}
}
🍁【理解 TcpEchoServer 中的重要步骤】
🍔1.理解 accept 的具体工作。
1.accept 方法进行的工作,就是 "拉客",对于操作系统来说,建立 TCP 连接,是内核的工作,accept 要干的就是等连接建立完成之后,将这个连接拿到应用程序中。
2.服务器启动之后,如果没有客户端过来建立连接,accept 就会阻塞等待。
🍔2.TCP 协议里,服务器读取请求,发送响应都是通过输入输出流对象来进行的,客户端也一样
1.在使用原生字节流的时候,记得包装成 Scanner , PrintWriter 形式(或者字符流)。
2.此处还要注意的地方就是,我们将输出流包装成 PrintWriter ,如果使用其中的 writer 方法进行发送数据,上面的代码就会有问题,因为客户端要从控制台读取用户的输入,输入完成后的回车被 Scanner(System.in) 给读取走了,那么服务这边一定会在读取请求并解析这里阻塞(String request = scanner.next() 阻塞),就会导致程序卡死。所以保险起见,我们这里在使用 PrintWriter 的时候,使用它的 println 方法,自动换行,就不会幺蛾子了。
🍔3.上述代码中的 clientSocket 建立连接完了之后,记得关闭。
可以在 processConnect 函数后面关闭,但是更稳妥的做法是在 finally 里面关闭。
🍔4.理解 TCP 协议中使用线程池(实现多个客户端访问服务器)
🍃本质:TCP 服务器这里之所以使用了多线程,是因为此处代码中要处理的是 "长连接",客户端服务器连接建立好之后,啥时候断开连接不确定,这一个连接里要处理多少个请求,也不确定!!(短连接不一定会有这种问题。总之,这类问题需要结合代码分析)
🍃具体到代码进行分析:在单线程情况下,建立连接之后,与客户端的交互操作在 processConnect 函数中执行,如果客户端一直不断开连接,那么服务器就会一直阻塞在 "读取请求并解析" 这里, processConnect 函数就执行不完,也就无法执行第二次 accept,就会导致无法处理到第二个客户端。上述代码中的 while 循环 和 next ,影响了第二次调用 accept 方法。
🍃使用线程池,不适用普通的多线程,是为了避免在客户端很多的情况下,导致线程频繁的创建和销毁带来的巨大开销。
🍃此处的线程池其实还不是最好的策略,但由于本人能力有限,就没有去继续延申了(涉及到IO多路复用)
==============================================================
🍁【理解 TcpEchoClient 中的重要步骤】
TCP 客户端代码相对来说比较简单,大部分都是文件操作,唯一要注意的点就是,实例化 Socket 对象的时候,需要和服务器建立连接,就需要知道服务器在哪(指定 IP + 端口)。
TCP 也可以像 UDP 一样实现翻译版本的服务器,操作都类似,此处就不过多赘述了。
本篇博客就到这里了,谢谢观看!!