通过网络,让两个主机之间能够进行通信,基于这样的通信来完成一定的功能
进行网络编程的时候,需要操作系统给我们提供一组 API,通过 API 才能完成编程
API 可以认为是 应用层 和 传输层 之间交互的路径
这个 API 被称为 Socket API(插座)
通过这一套 Socket API 可以完成不同主机之间,不同系统之间的网络通信
传输层,提供的网络协议,主要是两个 TCP 和 UDP
这俩协议的特性(工作原理)差异很大
导致使用这两种协议进行网络编程,也存在一定差别
系统就分别提供了两套 API
Socket套接字,是由系统提供⽤于⽹络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元
基于Socket套接字的⽹络程序开发就是网络编程
Socket套接字主要针对传输层协议划分为如下三类:
网络通信数据的基本单位,涉及到多种说法:
1.数据报(Datagram)
2.数据包(Packet)
3.数据帧(Frame)
4.数据段(Segment)
socket 其实也是操作系统中的一个概念
本质上是一种特殊的问价
Socket 就属于把“网卡”这个设备,给抽象成了文件
往 socket 文件中写数据,就相当于通过网卡发送数据
往 socket 文件读数据,就相当于通过网卡接受数据
这样,就把网络通信 和 文件操作给统一
在 Java 中使用DatagramSocket 这个类,来表示系统内部的 socket 文件
DatagramSocket 方法:
receive 里面的 DatagramPacket 是一个“输出型参数”
使用 DatagramPacket 类,来表示一个 UDP 数据报
UDP 是面对数据报的
每次进行传输,都要以 UDP 数据报为基本单位
这个程序没有什么业务逻辑,只是简单的调用 socket api
让客户端给服务器发送一个请求,请求就是一个从控制台输入的字符串
服务器收到字符串之后,也就会把这个字符串原封不动的返回客户端,客户端再显示出来
这样的就叫做“回显服务器”(echo server)
服务器和客户端都需要创建 Socket 对象
但是 服务器的 socket 一般要显示的指定一个端口号
而服务器的 socket 一般不能显示指定(不显示指定,此时系统会自动分配一个随机的端口)
此时的 socket 对象就能绑定到这个指定的端口
服务器需要把端口号明确下来,否则客户端找不到端口
客户端的端口号是不需要确定的,交给系统进行分配即可
如果你手动指定确定的端口,就可能和别人的程序的端口号冲突
那为什么服务器这边手动指定创建,就不会出现冲突?
客户端在意这个冲突,而服务器不在意呢?
服务器是在程序员手里,一个服务器上都有哪些程序,都使用哪些端口,程序员都是可控的
程序员写代码的时候,就可以指定一个空闲的端口,给当前的服务器使用
但是客户端就不可控了,客户端在用户的电脑上
每个用户电脑上装的程序都不一样,占用的端口也不一样
另一方面,用户出现端口冲突,用户也不会解决
所以交给系统分配比较稳妥
receive 内 先构造一个空的对象,然后出阿迪到方法内部,由 receive 内部对这个数据进行填充
这个对象用来承载从网卡这边读到的数据
收到数据的时候,需要搞一个内存空间来保存这个数据
DatagramPacket 内部不能自行分配内存空间
因此就需要程序员手动把空间创建好,交给 DatagramPacket 进行处理
这里只要服务器一旦启动,就会立即执行到这里的 receive 方法
此时,客户端的请求可能还没来
这种情况,receive 就会直接堵塞,一直堵塞到真正客户端吧请求发过来为止
UDP 是无连接的(UDP 自身不会保存数据要发给谁),这就需要每次发送的时候,重新指定,数据要发到哪里去
构造这个数据报,需要指定数据内容,也指定一下数据报要发给谁
上面半行是在表示数据是什么
下面半行是在指定 请求中的地址(数据从哪里来,就要到哪里去)
以下就是 开发服务器的基本步骤
public class UdpEchoServer {
//创建一个 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 这样的代码
* 因为一个服务器程序,经常要长时间运行
* 你也不知道客户什么时候来
* 为了给客户一个好的体验,所以就需要一直运行
*/
while (true) {
//1.读取请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
// 当前完成 receive 之后, 数据是以 二进制 的形式存储到 DatagramPacket 中了.
// 要想能够把这里的数据给显示出来, 还需要把这个二进制数据给转成字符串.
String requset = new String(requestPacket.getData(),0,requestPacket.getLength());
//2.根据请求计算响应(一般的服务器都会经历的过程)
/**
* 这个步骤是一个服务器程序,最核心的步骤
* 但是目前是 echo server 不涉及到这个流程,也不必考虑响应怎么计算
* 只要请求进来,就把请求当做响应
*/
//由于这里是回显服务器,请求是什么,响应就是什么
String response = Process(requset);
//3.把响应写回到客户端
// 搞一个响应对象, DatagramPacket
// 往 DatagramPacket 里构造刚才的数据, 再通过 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(), requset, response);
}
}
public String Process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
上述写的代码中,为什么没有 close?
socket 也是文件,不关闭不就文件资源泄露了吗?
为啥这里可以不写 close?
为啥不写 close 也不会出现文件资源泄露??
socket 是文件描述符表中的一个表项
每次打开一个文件,就会占用一个位置
文件描述符,是在 pcb 上的 (跟随进程的)
这个 socket 在整个程序运行过程中都是需要使用的 (不能提前关闭)
当 socket 不需要使用的时候,意味着程序就要结束了
进程结束,此时随之文件描述符表就会销毁了(PCB 都销毁了)
随着销毁的过程,被系统自动回收了
这样就不会泄露
啥时候才会出现泄露?
代码中频繁的打开文件,但是不关闭.
在一个进程的运行过程中,不断积累打开的文件,逐渐消耗掉文件描述表里面的内容
最终就耗殆尽了
如果进程的生命周期很短,打开没多久就关闭,就不会泄露了
文件资源泄露这样的问题,在服务器这边是比较严重的,在客户端这里没有很大的影响
在写这个代码过程中,用到三个 DatagramPacket 的构造方法
只指定字节数组缓冲区的(服务器收请求的时候需要使用,客户端收响应的时候也需要使用)
构造的时候需要指定字节数组缓冲区,同时指定一个 InetAddress 对象(这个对象就同时包含了 IP 和 端口)
(服务器返回响应给客户端)
这些都是让数据报里面带上内容 也带上数据的目标地址
此时,客户端和服务器就可以通过网络配合完成通信过程了
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. 把请求内容构造成 DatagramPacket 对象, 发给服务器.
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.0.0.1", 9090);
// UdpEchoClient client = new UdpEchoClient("42.192.83.143", 9090);
client.start();
}
}
翻译操作本质上就是内置了一个类似哈希表的东西
start 方法中,调用 process 方法
当前是子类引用调用 start,this 就是指向子类引用,虽然 this 是父类的类型,但是实际指向的是子类引用
调用 process 自然也就会执行到子类的方法
虽然没有修改 start 的内容
但是仍然可以确保是按照新版本的 process 来执行的 (多态)
public class UdpDictServer extends UdpEchoServer{
private Map<String, String> dict= new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
//此处可以往这个表里插入几千几万个这样的英文单词
dict.put("dog","小狗");
dict.put("cat","小猫");
dict.put("pig","小猪");
}
//重写 process 方法,在重写的方法中完成翻译的过程
//翻译本质上就是查表
@Override
public String Process(String request) {
return dict.getOrDefault(request,"该词在词典中不存在");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
上述重写 process 方法,就可以在子类中组织你想要的“业务逻辑”
TCP 的 socket api 和 UDP 的 socket api 差异很大
但是和前面讲的 文件操作,有密切的联系
ServerSocket 这是给服务器使用的类,使用这个类来绑定端口号
上面的这两个类都是用来表示 socket 文件的
(抽象了网课这样的硬件设备)
TCP 是字节流的,传输的基本单为是 byte
UDP 来说,每次发送数据都得手动在 send 方法中,指定目标的地址 (UDP 自身没有存储这个信息)
TCP 来说,则不需要前提是需要先把连接给建立上
连接如何建立,不需要代码干预,是系统内核自动负责完成的
对于应用程序来说,客户端这边,主要是要发起 “建立连接” 动作服务器这边,主要是要把建立好的连接从内核中拿到应用程序里
serverSocket 进行连接
clientSocket 进行后面程序的交互
InputStream 和 OutputStream 就是字节流
就可以借助这两个对象,完成数据的 “发送" 和 “接收"
通过 InputStream 进行 read 操作,就是 “接收”
通过 OutputStream 进行 write 操作,就是 “发送"
这两个进行发送和接收的时候,是以字节为单位
“空白符”是一类特殊的字符,比如换行、回车符、空格、制表符、翻页符、抽纸制表符…
后续客户端发送的请求,会以空白符作为结束标记
(此处就约定使用 \n)
由于TCP 是字节流通信方式,每次传输的字节都是非常灵活的,但是这种灵活的方式会导致代码出错
因此,我们往往会手动约定一个完整数据报的长度
每次循环就处理一个数据报
上述这里就是约定了 \n 作为数据报结束的标志,就正好可以搭配 scanner.next 来完成请求的读取
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) {
//通过 accept 方法,把内核中已经建立好的连接拿到应用程序中
//建立连接的细节流程都是内核自动完成的,应用程序只需要直接用
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
//通过这个方法,来处理当前的连接
public 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()) {
//使用 try ( ) 方式, 避免后续用完了流对象, 忘记关闭.
//由于客户端发来的数据, 可能是 "多条数据", 针对多条数据, 就循环的处理.
while (true) {
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
//连接断开了. 此时循环就应该结束
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
//1.读取请求并解析. 此处就以 next 来作为读取请求的方式,next 的规则是,读到 "空白符" 就返回.
String request = scanner.next();
//2.根据请求,计算响应
String response = process(request);
//3.把响应写回到客户端
// 可以把 String 转成字节数组, 写入到 OutputStream
// 也可以使用 PrintWriter 把 OutputStream 包裹一下, 来写入字符串.
PrintWriter printWriter = new PrintWriter(outputStream);
// 此处的 println 不是打印到控制台了, 而是写入到 outputStream 对应的流对象中, 也就是写入到 clientSocket 里面.
// 自然这个数据也就通过网络发送出去了. (发给当前这个连接的另外一端)
// 此处使用 println 带有 \n 也是为了后续 客户端这边 可以使用 scanner.next 来读取数据.
printWriter.println(response);
// 此处还要记得有个操作, 刷新缓冲区. 如果没有刷新操作, 可能数据仍然是在内存中, 没有被写入网卡.
printWriter.flush();
//4.打印一下这次请求交互过程的内容
System.out.printf("[%s:%d] req=%s, resp=%s\n", clientSocket.getInetAddress(), clientSocket.getPort(),
request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
// 在这个地方, 进行 clientSocket 的关闭.
// processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了.
clientSocket.close();
}
}
public String process(String request) {
//此处也是写的回显服务器,响应和请求是一样的
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
//需要再创建 Socket 的同时,和服务器”建立链接“,此时就要告诉 Socket 服务器在哪里
//具体建立链接的细节,不需要代码手动干预,是内核自动服务的
//当我们 new 这个对象的时候,操作系统内核就开始进行 三次握手的具体细节
socket = new Socket(serverIp,serverPort);
}
public void start() {
//tcp 的客户端行为和 udp 的客户端差不多
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.println("->");
String request = scanner.next();
//2.把字符串作为请求,发送给服务器
// 这里使用 println 是为了让请求后面带上换行
// 也就是和服务器读取请求,scanner.next 呼应
writer.println(request);
writer.flush();
//3.从服务器读取响应
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();
}
}
这个时候就会有人有疑问
前面写过的 DatagramSocket 和 ServerSocket 都没有写 close
但是代码都没有问题
为什么 clientSocket 不关闭,就会初心问题呢?
因为 DatagramSocket 和 ServerSocket 都是在程序中,只有这么一个对象,周期都是贯穿整个程序的
clientSocket 则是在循环中,每次有一个新的客户端建立链接,都会创建出新 clientSocket
每次执行就会创建出新的 clientSocket
并且这个 socket 最多使用到 该客户端退出(断开连接)
此时如果有很多客户端都来建立链接
此时,就意味着每个链接都会创建 clientSocket
当连接断开的时候 clientSocket 就失去了作用了
但是如果没有手动 close,此时这个 socket 对象就会占据着文件描述表的位置
上述截图可知
当前启动两个客户端,同时连接服务器
其中一个客户端(先启动的客户端)一切正常
另一个客户端(后启动的客户端)没有办法和服务器进行任何交互(服务器不会提示“建立连接”,也不会针对请求做出任何响应)
现在看到的现象,就是当前代码的一个很明显的问题(bug)
解决这个问题的关键,就是让这两个循环能够“并发”执行
这里就需要用到 多线程
创建新的线程,让新的线程去调用 processConnection
主线程就可以继续执行下一次 accept 了
新线程内部负责 processConnection 内部的循环
此时意味着,每次有一个客户端,就得给分配一个新的线程
线程并发执行就可以顺利运行了