网络编程及回显服务器
网络编程
网络编程的目标:写一个应用程序,让这个应用程序可以使用网络通信.(需要调用传输层提供的API)
UDP和TCP特点对比
UDP:无连接、不可靠传输,面向数据报,全双工.
TCP:有连接、可靠传输、面向字节流、全双工.
有无连接
可靠传输与不可靠传输
可靠传输不是说 a 给 b 发的消息100%都能到.(这个要求太难了)
可靠传输指的是:a 尽可能的把消息传给 b 并且在传输失败的时候,a 能感知到,或者在传输成功的时候,也能知道自己传输成了.
TCP 是可靠传输(传输效率就降低了)
UDP 是不可靠传输(传输效率更高)
所以,无论是可靠传输还是不可靠传输,这都是他们的特点,是不分好坏的.
TCP 是可靠传输,UDP 是不可靠传输,因此 TCP 比 UDP 更安全???
注意,这句话是错误的,谈到"网络安全"指的是,如果你传输的数据是否容易被黑客截获,以及如果被截获后,是否会泄露一些重要信息?(关键词:安全、入侵、破解,加密,反编译...)
面向字节流与面向数据报
TCP 和文件操作类似都是"流"式的(由于这里传输的单位是字节,称为字节流)
UDP 是面向数据报,读写的基本单位是一个 UDP 数据报(包含了一系列的数据/属性)
UDP的socket api
两个核心的类
1.DatagramSocket
是一个 Socket 对象
操作系统使用文件这样的概念来管理一些软硬件资源,网卡,操作系统也是使用文件的方式来管理网卡的,表示网卡的这类文件称为 socket 文件.
Java中的socket 的对象就对应着系统里的 socket 文件,(最终还是要落到网卡)
要进行网络通信,必须得先有 socket 对象.
2.DatagramPacket
表示了一个 UDP 数据报.代表了系统中所设定的 UDP 数据报的二进制结构getData():获取到 UDP 数据报载荷部分(也就是完整的应用层数据报)
回显服务器(echo server)(最简单的UDP服务器)
什么是回显服务器?客户端向服务器发送了什么,服务器就返回什么这个异常是非常常见的异常,通常出现这个异常的原因使端口号被占用,端口号是用来区分主机上的应用程序的,一个应用程序可以占据主机上的多个端口但一个端口只能被一个进程占用(这句话其实不够严谨,有特例,但是此处不做讨论),如果一个端口号已经被别人的进程占用了,此时你再尝试创建这个socket对象,占用该端口,此时就会报错.
一个服务器要给很多客户端提供服务,但是服务器也不知道客户端什么时候来,所以服务器只能"时刻准备着",随时客户端来了就能随时提供服务
服务器工作的3个核心
1.读取请求并解析
这里 receive 方法的参数 DatagramPacket 是一个输出型参数,传给 receive 的是一个空的对象,receive 内部就会把这个空对象的内容填充上,当 receive 执行结束,于是就得到一个装满内容的 DatagramPacket
这个对象用来保存数据的内存空间,是需要手动指定的,不像我们之前学过的集合类,内部都是有自己管理内存(能够申请内存,释放内存,内存扩容)能力的,其中这个数组的大小是可以随便写的,但是也不能写太大,最大不能超过64KB.
2.根据请求,计算出响应
(回显服务器不关心这个流程,请求是什么?就返回什么),但一个商业级的服务器,主要代码都是在完成这里的代码.
3.把响应写回客户端
在计算这里的长度的时候,要注意:response.length():这个计算的是这个字符串的长度,单位是字符,response.getBytes().length():这个计算的是字符串中的内容的长度,单位是字节,而我们这儿socket API本身都是按照字节来处理的,此外,关于发送给谁的这个问题,我们当然是要发送给客户端的,而客户端的信息包含在requestPacket当中
package netWork;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
/**
* Created with IntelliJ IDEA
* Description:
* User: lenovo
* Date: 2024 -01 -21
* Time: 11:00
*/
//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);
//并要转换的前提是后续客户端发送的字符串是文本内容.
//这里的length是UDP数据报 payload 的长度
String receive=new String(requestPacket.getData(),0,requestPacket.getLength());
//2.根据请求,计算出响应
String response=response(receive);
//3.把响应写回客户端(发送的内容是什么?发送给谁?)
DatagramPacket res=new DatagramPacket(response.getBytes(),
response.getBytes().length,requestPacket.getSocketAddress());
socket.send(res);
//记录日志,方便观察程序执行效果
System.out.printf("[%s,%d] req:%s reqs %s\n",requestPacket.getAddress().toString(),
requestPacket.getPort(),receive,response);
}
}
public String response(String receive){
return receive;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer=new UdpEchoServer(96);
udpEchoServer.start();
}
}
客户端工作的代码
服务器要固定到96这个端口
客户端要去访问96这个端口
如果此时启动多个客户端,多个客户端也可以被服务器应对
启动多个程序IDEA需要设置一下
package netWork;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
/**
* Created with IntelliJ IDEA
* Description:
* User: lenovo
* Date: 2024 -01 -21
* Time: 11:01
*/
//UDP的回显客户端
public class UdpEchoClient {
private DatagramSocket socket=null;
private String severIP;
private int severPort;
public UdpEchoClient(String Ip,int Port) throws SocketException {
severIP=Ip;
severPort=Port;
socket=new DatagramSocket();
}
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(severIP),severPort);
socket.send(requestPacket);
//3.读取服务器响应,并返回响应内容
DatagramPacket res=new DatagramPacket(new byte[4096],4096);
socket.receive(res);
String response=new String(res.getData(),0,res.getLength());
//4.显示在屏幕上
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient=new UdpEchoClient("127.0.0.1",9696);
udpEchoClient.start();
}
}
总结程序执行流程
TCP的socket API
TCP传输的是字节流,一个字节一个字节进行传输的,所以一个 TCP 数据报就是一个字节数组byte[]
1.SeverSocket
给服务器使用的 socket
2.Socket
既会给服务器使用,也会给客户端使用.
TCP版的回显服务器
服务器代码
这里需要注意的是,进入 while 循环之后,tcp 要做的事情不是读取客户端的请求,而是先处理客户端的"连接".(这里说到的"连接"并不是"握手",握手是系统内核负责的,我们在写代码的过程中感知不到握手的过程,此处说的连接是握手之后得到的东西)(服务器内核里有很多客户端的连接,虽然内核中的连接很多,但是在应用程序中还是得一个一个处理的,内核中的连接就像一个一个"代办事项"这些代办事项在一个队列数据结构中,应用程序就需要一个一个完成这些任务)要完成任务,就需要先取任务
把内核中的连接获取到应用程序中了,这个过程类似于"生产者消费者模型"
当服务器执行到 accept 的时候,此时客户端可能还没来,那么此时 accept 就会阻塞.
一次IO,主要是经历两个部分
1.等(阻塞)
2.拷贝数据
accept 是把内核中已经建立好的连接,拿到应用程序中。但是这里面的返回值并非是一个 connection 这样的对象,而是一个 socket 对象。这个 socket 对象就像一个耳麦一样,就可以说话,也能听到对方的声音(通过socket对象和对方进行网络通信,我们不用管对方到底是谁,冲着这个递过来的耳麦说话就行了)
IO操作是比较有开销的,相比于访问内存,进行IO次数越多,程序的速度就越慢.使用一块内存作为缓冲区,写数据的时候先写到缓冲区里攒一波数据,统一进行IO.
PrinWriter内置了缓冲区,手动刷新,确保这里的数据是真的通过网卡发出去了.而不是残留在内存缓冲区中的.
这里我们加上flush更稳妥,不加也不一定出错!!缓冲区内置了一定的刷新策略,比如缓冲区满了,就会触发刷新;再比如,程序退出,也会触发刷新.
到此,程序还存在两个问题.
1.在这个程序中涉及到两类Socket
1>SeverSocket(只有一个,生命周期跟随程序,不关闭也没事)
2>Socket
此处的Socket是在被反复创建的,就会出现文件资源泄露.我们要确保,连接断开之后,Socket要可以被关闭
下面这样写也可以
这个有待考虑
package TCP;
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;
/**
* Created with IntelliJ IDEA
* Description:
* User: lenovo
* Date: 2024 -01 -24
* Time: 13:49
*/
public class TcpEchoSever {
private ServerSocket serverSocket=null;
public TcpEchoSever(int port) throws IOException {
serverSocket=new ServerSocket(port);
}
public void start() throws IOException {
//从内核中的队列当中取任务
System.out.println("服务器开始工作!");
while(true){
Socket clientSocket=serverSocket.accept();
//处理连接的逻辑
procesConnection(clientSocket);
clientSocket.close();
}
}
//处理连接的逻辑
private void procesConnection(Socket clientSocket) {
System.out.printf("[%s:%d]",clientSocket.getInetAddress().toString(),clientSocket.getPort());
try(InputStream inputStream=clientSocket.getInputStream();
OutputStream outputStream= clientSocket.getOutputStream()){
while(true){
Scanner scanner=new Scanner(inputStream);
if(!scanner.hasNext()){
System.out.println("服务器关闭!");
break;
}else{
//1.读取数据,只有在传过来的数据是文本状态时才可以这么做
//这个代码暗藏一个约定,客户端发过来的请求得是文本数据,同时还得带有空白符作为分割
//空白符:空格 换行 回车 制表符 垂直制表符 等等
String request=scanner.next();
//2.处理请求
String rec=processrequest(request);
//3.发送数据,由于这里是TCP协议,所以要用字节流来发送,scanner是字符流
PrintWriter printWriter=new PrintWriter(outputStream);
//这里使用println是为了有空白符(在结尾加上\n,方便客户端使用scanner.next来读取响应).
//网络程序讲究的就是客户端和服务器能够配合!
printWriter.println(rec);
//这个操作是为了刷新缓区.
printWriter.flush();
//日志,在屏幕上显示一下
System.out.printf("[%s;%d] %s %s",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,rec);
}
}
}catch (IOException e){
e.printStackTrace();
}
}
private String processrequest(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoSever tcpEchoSever=new TcpEchoSever(3060);
tcpEchoSever.start();
}
}
客户端代码
客户端要做的事情:
1.从控制台读取用户的输入
2.把输入的内容构造成请求并发送给服务器
3.从服务器读取响应
4.把响应显示到控制台上
不是每个流对象都持有文件描述符,持有文件描述符,是要调用操作系统提供的open方法,(系统调用,是要在内核中完成的,是一件相当严肃/重量的事情)
package TCP;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
/**
* Created with IntelliJ IDEA
* Description:
* User: lenovo
* Date: 2024 -01 -24
* Time: 13:50
*/
public class TcpEchoClient {
private Socket socket=null;
public TcpEchoClient(String severIP,int port) throws IOException {
socket=new Socket(severIP,port);
}
public void start(){
Scanner scanner=new Scanner(System.in);
System.out.println("客户端开始工作!");
try(InputStream inputStream=socket.getInputStream();
OutputStream outputStream=socket.getOutputStream()){
while(true){
System.out.print("->");
//1.从控制台读取用户的输入
String request=scanner.next();
//2.把输入的内容构造成请求并发送给服务器
PrintWriter printWriter=new PrintWriter(outputStream);
printWriter.println(request);
//不要忘记flush,确保数据真的发送出去
printWriter.flush();
//3.从服务器读取响应
Scanner scannerGet=new Scanner(inputStream);
String res=scannerGet.next();
//4.把响应打印出来
System.out.println(res);
}
}catch (IOException e){
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient=new TcpEchoClient("127.0.0.1",3060);
tcpEchoClient.start();
}
}
这样写虽然可以正确的运行程序,但是这样写是有问题的,这样写服务器只能接收到一个客户端发来的消息,为什么?
在TCP编程中,必须要先有连接,我们要先取出连接才可以进行对数据的处理,所以当客户端1在执行的时候,代码会一直在while循环里面不会出来,此时如果你再启动客户端2,那么就会取不到连接,也就是调用不了accept方法.此处客户端处理processConnection本身就是要长时间执行的,也不知道客户端啥时候结束,也不知道客户端要发送多少请求,我们期望在执行processConnection方法的同时也快速调用到accept,此时就需要用到多线程.主线程里,专门负责拉客(获取客户端)拉倒客人之后,创建新的线程,让新的线程负责处理客户端的各种请求.
服务器代码(修改后的)
package TCP;
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;
/**
* Created with IntelliJ IDEA
* Description:
* User: lenovo
* Date: 2024 -01 -24
* Time: 13:49
*/
public class TcpEchoSever {
private ServerSocket serverSocket=null;
public TcpEchoSever(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(()->{
procesConnection(clientSocket);
});
t.start();
}
}
//处理连接的逻辑
private void procesConnection(Socket clientSocket) {
System.out.printf("[%s:%d]\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
try(InputStream inputStream=clientSocket.getInputStream();
OutputStream outputStream= clientSocket.getOutputStream()){
while(true){
Scanner scanner=new Scanner(inputStream);
if(!scanner.hasNext()){
System.out.println("服务器关闭!");
break;
}else{
//1.读取数据,只有在传过来的数据是文本状态时才可以这么做
//这个代码暗藏一个约定,客户端发过来的请求得是文本数据,同时还得带有空白符作为分割
//空白符:空格 换行 回车 制表符 垂直制表符 等等
String request=scanner.next();
//2.处理请求
String rec=processrequest(request);
//3.发送数据,由于这里是TCP协议,所以要用字节流来发送,scanner是字符流
PrintWriter printWriter=new PrintWriter(outputStream);
//这里使用println是为了有空白符(在结尾加上\n,方便客户端使用scanner.next来读取响应).
//网络程序讲究的就是客户端和服务器能够配合!
printWriter.println(rec);
//这个操作是为了刷新缓区.
printWriter.flush();
//日志,在屏幕上显示一下
System.out.printf("[%s;%d] %s %s\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,rec);
}
}
}catch (IOException e){
e.printStackTrace();
}finally{
try{
clientSocket.close();
}catch (IOException e){
e.printStackTrace();
}
}
}
private String processrequest(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoSever tcpEchoSever=new TcpEchoSever(3060);
tcpEchoSever.start();
}
}
这样就可以满足要求了.
经过上述改进,只要服务器系统资源足够,有几个客户端都是可以的了.
TCP程序的时候,涉及到两种写法:
1.一个连接中只能传输一次请求与响应.(短连接)
2.一个连接中,可以传输多次请求可响应.(长连接)
那么现在是有一个连接,就有一个新的线程.如果很多客户端,频繁的来连接/断开,服务器就涉及到频繁创建/释放线程了.(所以使用线程池是一个比较好的方案)
虽然这里我们使用线程池,避免了频繁创建销毁线程,但是毕竟是每个客户端对应一个线程,如果服务器对应的客户端很多,服务器就需要创建出大量的线程,对于服务器的开销是很大的.
是否有办法,使用一个线程(或者最多三四个线程)就可以高效的处理很多的客户端的并发请求(几万个/几十万个客户端)?
C10K:同一时刻有10k个客户端(1w个客户端)通过前面的一些技术手段,硬件设备,是可以处理好(线程池之类的就可以)
随着互联网的发展,客户端越来越多,请求也越来越多
C10M:同一时刻,有1kw的客户端并发请求(对于一些大厂来说,也不是什么稀奇的事情),对此我们引入了很多技术手段,其中一个非常有效/必要的手段,IO多路复用/IO多路转换(这个东西是解决高并发(C10M)的重要手段之一,但不是说这招一出,一下就能解决).
解决高并发,说白了就是四个字
1.开源:引入更多的硬件资源.
2.节流:提高单位硬件资源能够处理的请求数(本质上就是减少线程数量).
也就是同样的请求数,消耗的硬件资源更少.
关于IO多路复用的API可以去搜索JavaNIO一些关键词.