一.什么是⽹络编程
⽹络编程,指⽹络上的主机,通过不同的进程,以编程的⽅式实现⽹络通信(或称为⽹络数据传输)。
二.网络编程中的基本概念
2.1发送端和接收端
在⼀次⽹络数据传输时:
发送端:数据的发送⽅进程,称为发送端。发送端主机即⽹络通信中的源主机。
接收端:数据的接收⽅进程,称为接收端。接收端主机即⽹络通信中的⽬的主机。
收发端:发送端和接收端两端,也简称为收发端
2.2请求与响应
⼀般来说,获取⼀个⽹络资源,涉及到两次⽹络数据传输:
• 第⼀次:请求数据的发送
• 第⼆次:响应数据的发送。
2.3客户端与服务端
服务端:在常⻅的⽹络数据传输场景下,把提供服务的⼀⽅进程,称为服务端,可以提供对外服务。
客⼾端:获取服务的⼀⽅进程,称为客⼾端。
三.Socket套接字
3.1Socket概念
Socket套接字,是由系统提供⽤于⽹络通信的技术,是基于TCP/IP协议的⽹络通信的基本操作单元。基于Socket套接字的⽹络程序开发就是⽹络编程
3.2Socket分类
Socket套接字主要针对传输层协议划分为如下三类:
流套接字:使⽤传输层TCP协议TCP,即Transmission Control Protocol(传输控制协议),传输层协议。
以下为TCP的特点(细节后续再学习):
• 有连接
此处的连接本质上就是建立连接的双方,各自保存对方的信息,TCP 要想通信, 就需要先建立连接 (刚才说的, 保存对方信息),做完之后,才能后续通信(如果 A 想和 B 建立连接, 但是 B 拒绝了! 通信就无法完成!!!)
• 可靠传输
• ⾯向字节流
TCP 也是和文件操作一样,以字节为单位来进行传输
• 有接收缓冲区,也有发送缓冲区
• ⼤⼩不限
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下,是⽆边界的数据,可以多次发送,也可以分开多次接收。
数据报套接字:使⽤传输层UDP协议
UDP,即User Datagram Protocol(⽤⼾数据报协议),传输层协议。
以下为UDP的特点(细节后续再学习):
• ⽆连接
UDP 想要通信, 就直接发送数据即可~~不需要征得对方的同意,UDP 自身也不会保存对方的信息
• 不可靠传输
• ⾯向数据报
UDP 则是按照数据报为单位,来进行传输的
• 有接收缓冲区,⽆发送缓冲区
• ⼤⼩受限:⼀次最多传输64k
对于数据报来说,可以简单的理解为,传输数据是⼀块⼀块的,发送⼀块数据假如100个字节,必须⼀
次发送,接收也必须⼀次接收100个字节,⽽不能分100次,每次接收1个字节。
原始套接字
原始套接字⽤于⾃定义传输层协议,⽤于读写内核没有处理的IP协议数据。我们不学习原始套接字,简单了解即可。
四.Socket的API如何使用
4.1UDP的socket的API使用
4.1.1DatagramSocket
在 Java 中就使用这个类,来表示系统内部的 socket 文件了,socket 其实也是操作系统中的一个概念,本质上是一种特殊的文件.Socket 就属于是把"网卡"这个设备,给抽象成了文件了往 socket 文件中写数据,就相当于通过网卡发送数据,从 socket 文件读数据, 就相当于通过网卡接受数据.至于怎么使用往下看代码中清楚的交代了如何使用。
4.1.2DatagramPacket
使用这个类,来表示一个 UDP 数据报!!UDP 是面向数据报的~~每次进行传输, 都要以 UDP 数据报为基本单位..至于怎么使用往下看代码中清楚的交代了如何使用。
五.UDP的简单的客户端/服务器
这个程序没有啥业务逻辑,只是单纯的调用 socket api.让客户端给服务器发送一个请求,请求就是一个从控制台输入的字符串.服务器收到字符串之后,也就会把这个字符串原封不动的返回给客户端, 客户端再显示出来.也就是回显服务器(echo server)
直接上代码!!!
这个是UDP服务器代码:
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
public class UdpEchoServer {
//创建一个DatagramSocket 对象,方便操作网卡的基础
//DatagramSocket:绑定本机端口 → 发送/接收数据报
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
//这么写就是手动指定端口
socket = new DatagramSocket(port);
//这么写就是系统自动分配端口
//socket = new DatagramSocket();
}
//通过这个方法启动服务器
public void start() throws IOException {
System.out.println("服务器启动!!!");
//要保证服务器一直在运行
while (true){
//1.读取数据并解析
// DatagramPacket:传输的数据报容器
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//完成receive之后,数据以二进制的形式存储到requestPacket
//想要把数据显示出来,需要把二进制转换成字符串
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
//2.根据请求计算响应
//由于此处是回显服务器,请求是什么,响应就是什么
String response = process(request);
//3.把响应写回客户端
//写一个响应对象
//向对象中构造刚才的数据,再通过send返回
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
//4.打印一个日志,把这次数据交互的详情打印出来
System.out.printf("[%s:%d] req = %s,resp=%s\n", requestPacket.getAddress().toString(),
requestPacket.getPort(),request,response);
}
}
public String process(String requset) {
return requset;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer( 9090);
server.start();
}
}
下面是客户端的代码:
package network;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp = "";
private int serverPort =0;
public UdpEchoClient(String ip,int Port) throws SocketException {
//创建这个对象,不能手动创建端口
socket = new DatagramSocket();
//由于UDP自身不会持有对端的信息,需要在应用程序里,把对端的情况记录下来
//这里主要记录对端的IP和端口
serverIp =ip;
serverPort = Port;
}
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.把请求的内容构造成DatagramSocket 对象,发送给服务器
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);
//4.把响应转换成字符串,显示出来
String response = new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.1.2.3",9090);
client.start();
}
}
由于客户端与服务器本质代码区别不太,下面主要讲解下服务器代码中的重点内容:
此时socket对象就能绑定到这个指定的端口
服务器和客户端都需要创建 Socket 对象
但是 服务器 的 socket 一般要显式的指定一个端口号.而 客户端 的 socket 一般不能显式指定(不显式指定,此时系统会自动分配一个随机的端口)
为什么啊???为什么服务器要指定端口号而客户端要系统自动分配客户端???
想象这样一个场景,现在你在你们学校的一食堂20号窗口卖东北盒饭(这是真香),然后我就在学校门口,发传单~当有的同学看到传单之后,就想来尝一尝!!!
要想让同学能找到我,传单上必须印清楚我的地址和窗口.也就是一食堂20号窗口。对于服务器来说,也就需要把端口号给明确下来了~~~
有同学来我这里吃东北盒饭.点好餐,这几个同学就找了个旁边的位置就坐下了我这边把盒饭做好,就可以端过去了~~~这几个同学如果经常来我这吃饭,那么他们每次坐的位置,是否要求必须一样??不需要~~ 也不一定能做到~~可能第二次来的时候,这个位置被别人坐了,他们再找个空的位置就行了.
客户端的端口号是不需要确定的.交给系统进行分配即可,如果你手动指定确定的端口,就可能和别人的程序的端口号冲突
那这时候又有问题了,说煮啵煮啵为什么服务器这边手动指定端口,就不会出现冲突吗???
为什么客户端在意这个冲突,服务器不在意呢???
答:服务器是在程序猿手里的.一个服务器上都有哪些程序, 都使用哪些端口,程序猿都是可控的!!! 程序猿写代码的时候,就可以指定一个空闲的端口,给当前的服务器使用即可.但是客户端就不可控,客户端是在用户的电脑上,-方面,用户千千万~~ 每个用户电脑上装的程序都不一样,占用的端口也不一样另一方面,用户这边如果出现端口冲突了,他也不知道是端口冲突,他也不知道该咋解决,只会骂这个程序员,你这啥程序员,写的啥程序,全是bug.交给系统分配比较稳妥.系统能保证肯定分配一个空闲的端口。
requestPacket这个对象用来承载从网卡这边读到的数据.收到数据的时候,需要搞一个内存空间来保存这个数据DatagramPacket 内部不能自行分配内存空间因此就需要程序员手动把空间创建好,交给 DatagramPacket 进行处理~~
receive
方法的核心目的是:【阻塞当前线程,直到接收到一个 UDP 数据包】
这个步骤是一个服务器程序,最核心的步骤!!!
咱们当前是 echo server 不涉及到这些流程,也不必考虑响应怎么计算,只要请求过来,就把请求当做响应UDP 无连接的~~ (UDP 自身不会保存数据要发给谁),就需要每次发送的时候,重新指定,数据要发到哪里去!!
send
方法的作用是:【将指定的 DatagramPacket
数据报文发送到目标地址】。
我再来提个问题 ,说煮啵煮啵上述写的代码中,为啥没写 close??socket 也是文件,不关闭不就出问题了,不就文件资源泄露了嘛~~
socket 是文件描述符表中的一个表项每次打开一个文件,就会占用一个位置,文件描述符, 是在 pcb 上的.(跟随进程的),这个 socket 在整个程序运行过程中都是需要使用的(不能提前关闭)当 socket 不需要使用的时候, 意味着程序就要结束了.进程结束,此时随之文件描述符表就会销毁了(PCB 都销毁了).随着销毁的过程,被系统自动回收了~~
啥时候才会出现泄露?
代码中频繁的打开文件,但是不关闭.在一个进程的运行过程中,不断积累打开的文件,逐渐消耗掉文件描述符表里的内容最终就消耗殆尽了.但是如果进程的生命周期很短,打开一下没多久就关闭了.谈不上泄露
在此代码中,用到了 3个 DatagramPacket 的构造方法
1.只指定字节数组缓冲区的.(服务器收请求的时候需要使用, 客户端收响应的时候也需要使用)
2.指定字节数组缓冲区, 同时指定一个 |netAddress 对象 (这个对象就同时包含了 IP 和 端口)(服务器返回响应给客户端)
3.指定字节数组缓冲区, 同时指定 IP +端口号
下面是程序运行时的顺序与思维导图,让我们更好的理解代码
1.服务器先启动.服务器启动之后,就会进入循环,执行到 receive 这里并阻塞 (此时还没有客户端过来呢)
2.客户端开始启动,也会先进入 while 循环,执行 scanner.next,并且也在这里阻塞当用户在控制台输入字符串之后,next 就会返回,从而构造请求数据并发送出来~~
3.客户端发送出数据之后,服务器: 就会从 receive 中返回,进一步的执行解析请求为字符串,执行 process 操作,执行 send 操作
客户端: 继续往下执行,执行到 receive,等待服务器的响应
4.客户端收到从服务器返回的数据之后,就会从 receive 中返回,执行这里的打印操作,也就把响应给显示出来了.
5.服务器这边完成一次循环之后, 又执行到 receive 这里.客户端这边完成一次循环之后,又执行到 scanner.next 这里双双进入阻塞
六.TCP的简单的客户端/服务器
TCP 的 socket ap和 UDP 的 socket api 差异又很大~~
两个关键的类.
1.ServerSocket(给服务器使用的类, 使用这个类来绑定端口号)
2.Socket(既会给服务器用, 又会给客户端用)
这俩类都是用来表示 socket 文件的(抽象了网卡这样的硬件设备),TCP 是字节流的传输的基本单位,是 byte
再来回顾一下TCP 和 UDP 的特点对比吧
1.TCP 是有连接的. UDP 无连接
连接: 通信双方是否会记录保存对端的信息
UDP 来说,每次发送数据都得手动在 send 方法中指定
目标的地址.(UDP 自身没有存储这个信息)
TCP 来说,则不需要,前提是需要先把连接给建立上.
2.TCP 是可靠传输.UDP 不可靠传输
3.TCP 是面向字节流. UDP 是面向数据报
4. TCP 和 UDP 都是全双工.
现在我们已经看过了UDP的服务器和客户端,下面我们直接先来看TCP的代码,与前面UDP不一样的方面或者你所存在的疑问我们在代码后面详细讲解
TCP的服务器代码:
package network;
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;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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("服务器启动!!!");
//创建线程池
ExecutorService service = Executors.newCachedThreadPool();
while(true){
//通过accept方法,把内核中已经建立好的链接拿到应用程序中
Socket ClientSocket = serverSocket.accept();
//如果频繁启动客户端,也会崩溃
// Thread t = new Thread(()->{
// try {
// processConnection(ClientSocket);
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
// });
// t.start();
//更好的方法使用线程池
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(ClientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
}
//通过这个方法来处理当前的连接
private void processConnection(Socket clientSocket) throws IOException {
//进入方法,先打印一个日志,表示当前有客户端连接上了
System.out.printf("[%s:%d] 客户端上线!!!\n",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
while(true)
{
if(!scanner.hasNext()){
System.out.printf("[%s:%d] 客户端下线!!!\n",clientSocket.getInetAddress(),clientSocket.getPort());
break;
}
//1.读取请求并解析
String request = scanner.next();
//2.根据请求,计算响应
String response = process(request);
//3.把响应写回客户端
// 使用PrintWriter把 OutputStream包裹一下,来写入字符串
PrintWriter printWriter = new PrintWriter(outputStream);
//此处的printf不是打印到控制台了,而是写入到了对应的流对象中了,也就是clientSocket里面了
//自然这个数据也就通过网络发送出去了
printWriter.println(response);
//刷新缓冲区
printWriter.flush();
//打印一下请求交互的内容
System.out.printf("[%s:%d] req=%s resp=%s\n",clientSocket.getInetAddress(),clientSocket.getPort(),
request,response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}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();
}
}
下面是客户端的代码:
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
//需要在创建Socket的同时,和服务器“建立连接”,此时就的告诉Socket服务器在哪
//建立的细节,内核自动负责的
socket = new Socket(serverIp, serverPort);
}
public void start(){
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream =socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
PrintWriter writer = new PrintWriter(outputStream);
Scanner scannerNetwork = new Scanner(inputStream);
while(true){
//1.从控制台读取用户输入的数据
System.out.printf("->");
String request = scanner.next();
//2.把字符串作为请求,发给服务器
// 这里使用Printfln,是为了让请求后面带上换行
// 也就是和服务器读取请求,scanner.next 呼应
writer.println(request);
writer.flush();
//3.读取服务器返回的响应
String response = scannerNetwork.next();
//在界面上显示内容
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
下面讲解TCP这边的重点:
在代码中,这句实在是太显眼了,这try是什么,这里面又是什么意思???
try
代码块在执行结束后自动关闭,避免忘记手动释放导致内存泄漏。InputStream 和 OutputStream 就是 字节流!!!就可以借助这俩对象, 完成数据的"发送"和“接收
通过 InputStream 进行 read 操作,就是“接收"
通过 OutputStream 进行 write 操作,就是“发送”
这里遇到了next(),这又是啥意思???
next的规则是,读到“空白符”就返回,这里说的空白符是一类特殊的字符,换行, 回车符,空格, 制表符,翻页符,垂直制表符....
后续客户端发起的请求,会以空白符作为结束标记(此处就约定使用\n),TCP 是字节流通信方式,每次传输多少个字节,每次读取多少个字节
都是非常灵活的,但是灵活往往是坏事~~~代码就不知道该咋办了~往往会手动约定出,从哪里到哪里是一个完整的数据报每次循环一次,就处理一个数据报即可.上述这里就是约定了使用 \n作为数据报的结束标记. 就正好可以搭配scanner.next 来完成请求的读取过程,
到这里,让我们回忆一下UDP的客户端,与TCP的客户端对比一下
无论是TCP还是UDP客户端代码里出现 String request = scanner.next();
,它的真实意义是:从用户控制台输入获取数据(比如键盘输入),并不是直接操作网络协议的数据收发。
关键区别:
- TCP客户端后续会将这个字符串通过 Socket的
OutputStream
以字节流形式发送到服务端。 - UDP客户端则会将字符串包装成
DatagramPacket
(数据报),通过DatagramSocket
独立发送。 - 真实的协议差异体现在发送逻辑(流还是数据报)
不知道大家发现了没有,TCP的代码与UDP的代码还有几处打不一样的地方
1,
如果没有这几行代码,服务器程序就会出现文件资源泄露,Socket ClientSocket 这个对象要进行close,前面写过的 DatagramSocket, ServerSocket 都没写 close,但是我们说这个东西都没关系但是 clientSocket 如果不关闭,就会真的泄露了!!!
DatagramSocket 和 ServerSocket, 都是在程序中, 只有这么一个对象,申明周期,都是贯穿整个程序的ClientSocket 则是在循环中,每次有一个新的客户端来建立连接,都会创建出新的 clientSocket
每次执行这个,都会创建新的 clientsockei并且这个 socket 最多使用到 该客户端退出 (断开连接)此时,如果有很多客户端都来建立连接~此时,就意味着每个连接都会创建 clientSocket,当连接断开clientSocket 就失去作用了.但是如果没有手动 close,此时这个 socket 对象就会占据着文件描述符表的位置
在上面不是说try可以自动关闭吗???
这里的关闭,只是关闭了 clientSocket 上自带的流对象,并没有关闭 socket 本身
如果启动多个客户端,多个客户端可以同时和服务器建立连接吗???
因为上面的代码已经是改好的了,当然是可以的了,下面我们看一下之前的服务器代码
如果是这么写的呢,当前启动两个客户端,同时连接服务器,其中一个客户端(先启动的客户端)一切正常,另一个客户端(后启动的客户端)则没法和服务器进行任何交互.(服务器不会提示“建立连接”,也不会针对 请求 做出任何响应),现在看到的现象,就是当前代码的一个很明显的问题!!(bug)
这个 bug 和当前的代码结构是密切相关的!!
第一个客户端过来之后,accept 就返回了,得到一个 clientSocket.进入了 processConnection.又进入了一个 while 循环,这个循环中,就需要反复处理客户端发来的请求数据,如果客户端这会没发请求,服务器的代码就会阻塞在scanner.hasNext 这里,第一个客户端就会使服务器处于processConnection内部
进一步的也就使当前的第一层循环,无法执行到accpt,直到第一个客户端退出,processConnection才能结束,才能执行到第二次accept
确实如刚才推理的现象一样,第一个客户端结束的时候,就从 processConnection 返回了就可以执行到第二次 accept 了,也就可以处理第二个客户端了~~
很明显,如果启动第三个客户端,第三个客户端也会僵硬住,又会需要第二个客户端结束才能活过来如何解决上述问题?让一个服务器可以同时接待多个客户端呢??关键就是,在处理第一个客户端的请求的过程中,要让代码能够快速的第二次执行到 accept ~~~
当然是多线程
这样新的线程负责在 processConnection 里面来循环处理客户端的请求了.
但是问题又来了,此时这个服务器, 每个客户端都要创建一个线程.如果有很多客户端.频繁的来进行建立连接/断开连接,这个时候就会导致服务器频繁的 创建/销毁 线程.(开销就很大了)还可以使用线程池,来进一步的优化~~
线程池的方式是可以降低"频繁创建销毁线程的开销”
OK了,