前言
(加粗字体为重点部分)
上一篇文章讲到了socket(套接字)api 网络编程,主要是介绍了一些概念,udp和tcp实现客户端服务器的交互代码(代码逻辑只是一个回显的过程,主要是掌握客户端和服务器之间交互的流程)
socket(套接字)是操作系统给应用程序提供的一组用于网络编程的API,程序猿写的代码主要是基于应用层的,就是接收的数据要拿来干啥用,但是数据的传输也是和传输层紧密联系的,所以掌握和理解传输层的一些核心协议和机制是很有必要的。
Java标准库中提供的是Java封装之后的版本,操作系统原生的socket api是C语言,我们学Java封装之后的api即可。
传输层协议
UDP: 无连接,不可靠传输,面向数据报,全双工
TCP:有连接,可靠传输,面向字节流,全双工
针对这两个协议,提供了两组不同的api (一些概念可以看下 上一篇文章)
在Java网络编程中需要掌握的两类api:
Datagram:数据报(封装好之后的应用层数据报交给传输层以这个数据报作为载再次加上传输层的报头)(本质上就是字符串的拼接)
DatagramSocket:socket就相当于是一个特殊的文件,是网卡这个硬件设备的抽象表示(由于直接操作网卡这个硬件不好操作,所以就映射出一个对象,操作这个socket文件就是间接的操作网卡,就相当于看电视时的遥控器)
构造方法:需要绑定一个端口号(服务器),也可以不显示指定端口号(客户端)
reveive方法:用来接收数据,其中的参数是一个输出型参数,啥是输出型参数:就好比一个空数组就是一个参数,传入到一个方法中,方法结束返回时这个空的数组就被这个方法填满了数据,此时空数组这个参数就是输出型参数。
send方法:用来发送数据,receive和send方法都可能会导致阻塞(在数据没有被客户端发送过来的时候,服务器的receive方法就会进入阻塞,在客户端用户没有在控制台上输入数据的时候客户端的send方法也会进入阻塞)
close方法:释放资源,操作系统中一切皆文件,所以socket文件也不例外,当socket文件不用了就应该被关闭。(但是要清楚啥时候才调用close方法,一定是这个socket文件肯定不用了的时候才进行关闭)上一篇文章中的为啥没有关闭的操作呢,就是因为只有一个socket文件,而且socket文件的生命周期很长,服务器开着,只要客户端来了请求就得有socket去处理请求,所以暂时不能关闭,但是为啥最后也没有进行关闭呢? 因为socket只有客户端不请求的时候才进行关闭,此时也就意味着进程结束了,进程结束了,该释放的资源也就关闭了,也就不用再手动关闭了。
其实不怕这种生命周期长的socket,因为它一直要被使用,就怕频繁创建的socket,此时不用了就需要被手动关闭,否则就会造成文件资源泄露。
基于TCP的api:
也是两个核心的类:ServerSocket --> 是给服务器用的 Socket --> 是既给服务器用,也给客户端用。
Socket和DatagramSocket类似,都是让服务器绑定一个端口,也可以不绑定端口,后续调用其他的方法来绑定服务器的端口,但是ServerSocket 是一定要绑定一个端口的。
accept方法: accept就是接受的意思,服务器是被动的一方,客户端时主动的一方,此时客户端申请和服务器建立连接,服务器要接受一下,此时才能说连接建立好了,才能请求和响应数据。
Socket构造方法:IP和端口号,表示服务器的IP和端口,TCP是有连接的,在客户端new socket对象的时候,就会尝试和指定的IP 端口的目标建立连接了。
Inputstream和Outputstream方法:tcp是面向字节流的,所以在读取和写入数据的时候也是以字节流为基本单位的,通过上述字节流对象就可以进行数据传输了。从Inputstream这里读数据,就相当于从网卡那接收数据;从Outputstream这里写数据,就相当于从网卡发送数据。
ServerSocket和Socket 都是用来传输数据的,为啥还要有两个socket类呢,这两个socket是不一样的,就像是房产中介一样,一个是管在外边拉客的(就是ServerSocket),一个是在里面提供服务的(Socket),拉客的人只有一个,而提供服务的人(给你介绍房源情况的人)需要有多个,所以就是相当于ServerSocket只管 建立连接,而不管后续的响应服务,后续的服务是由Socket来完成的。
基于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.sql.SQLOutput;
import java.util.NoSuchElementException;
import java.util.Scanner;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/*写tcp服务器大概流程:
1.先有一个socket文件(用来传输数据的,对应的是硬件的网卡设备),就像是一个遥控器
2.写构造方法:此时要把IP和端口号传进去就是和客户端进行tcp连接 在连接的时候new一个socket文件
3.写start方法(就是服务器启动之后需要执行的主逻辑(这里包含循环去读取客户端的请求,计算响应,把响应发送回给对端)
4.main方法启动服务器*/
/*但是此时的代码还是有bug的,此时的服务器只能处理一个客户端,如果是多个客户端都来请求这个服务器,
* 此时服务器就只能先等一个客户端下线之后才能响应第二个客户端,因为此时请求来了之后进入while循环
* 第一个客户端还没有下线,下面的processConnection方法也有一个循环,不下线这个循环就不会结束,
* 循环不会结束方法就不会结束,此时上边的while循环也就不能进入下一词循环,也就不能再给别的客户端分配一个
* socket文件,所以是只能对一个客户端提供服务 解决方案:多线程~~ */
/*改进之后其实频繁的创建和删除线程是有开销的,所以使用线程池来优化代码*/
public class TcpEchoServer1 {
/*这里需要有两个socket文件,一个是serverSocket(场外管和一个一个的客户端连接用的)
一个是socket(场内管给每一个客户端服务的,包括接收请求的数据,包括发送回的响应数据)*/
private ServerSocket serverSocket = null;
public TcpEchoServer1(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
//创建一个线程池
ExecutorService executorService = Executors.newCachedThreadPool();
System.out.println("服务器启动!");
while (true) {
//客户端没有启动的时候accept是阻塞着的,当客户端运行后,accept方法继续往下执行
//如果直接调用,该方法会影响下一次循环不能及时accept
//每次来一个新客户端都创建一个线程即可!!!
Socket clientSocket = serverSocket.accept();
/*Thread t1 = new Thread(() -> {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t1.start();*/
//然后就把一个一个的任务请求提交给线程池让它去执行即可。
//一个连接的所有请求都处理完后并不会销毁线程,这个线程只是回到线程池中下次直接使用
//注意:这里的线程的创建是一个一个循环创建的,但是建立连接和计算响应是并发执行的
executorService.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
/*通过这个方法来处理一个连接(1.读取请求 2.根据请求计算响应 3.把响应的结果发送回给客户端)
* tcp是面向字节流的,所以在读数据的时候也是通过socket来读取数据的*/
}
}
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
//try()这种写法,( )中允许写多个流,每个流之间是用 ;来进行分割
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
/*但是是以字节流读取数据的,从哪到哪算是一个请求呢,此时就做一个约定,每读到一个\n就是一个请求*/
Scanner scanner = new Scanner(inputStream);//scanner是读字符流的
PrintWriter printWriter = new PrintWriter(outputStream);
//所以把输入和 输出的字节流都变成字符流
while (true) {
//1.读取请求
if (!scanner.hasNext()) {
//读到字符流的结尾了 如果它有下一个请求就继续往下读取请求的数据
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress(),
clientSocket.getPort());
break;
}
//没读到结尾,直接使用scanner来读取一段字符串(就一直读取数据)
//由于计算机完成之后的数据是一个字符串,所以在发送回给客户端时也变成字符串得了
String request = scanner.next();
//2.根据请求计算响应
String response = process(request);
printWriter.println(response);//把这个字符串打印
printWriter.flush();
System.out.printf("[%s;%d] req: %s; resp: %s\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,response);
}
} catch (IOException e) {
e.printStackTrace();
}
finally {
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer1 tcpEchoServer1 = new TcpEchoServer1(909);
tcpEchoServer1.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 TcpEchoClient1 {
private Socket socket = null;
public TcpEchoClient1(String serverIp,int port) throws IOException {
//这个操作就相当于是让客户端和服务器进行tcp连接
//这里的连接好了,服务器的accept方法就会返回
socket = new Socket(serverIp,port);
}
public void start() {
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
//包壳(为了方便处理,把字节流包装成字符流)
PrintWriter printWriter = new PrintWriter(outputStream);
Scanner scannerFromSocket = new Scanner(inputStream);
while (true) {
//1.从键盘上读取用户输入的内容
System.out.println("-> ");
String request = scanner.next();
//2.把读取的内容构造成请求,发送给服务器
//注意:这里的发送是带有换行的!!(因为next读取数据如果没有换行符是不会返回的)但是next不能识别带空格的字符串
//它会认为这是两个字符串
printWriter.println(request);//这里的写数据就相当于是发送请求
printWriter.flush();
//3.从服务器读取响应的内容
String response = scannerFromSocket.next();//这里的读数据就相当于是接收返回的响应数据
//4.把响应的结果显示到控制台上
System.out.printf("req: %s; resp: %s\n",request,response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient1 tcpEchoClient1 = new TcpEchoClient1("127.0.0.1",909);
tcpEchoClient1.start();
}
}
注:先启动服务器,再启动客户端,我们先看下运行结果,再介绍需要注意的点,如下图:
客户端在控制台输入请求:
服务器回显的响应:
服务器中代码的问题:
1.如图1: 这里flush方法是干啥的,注意:如果不加上flush方法,代码也会出bug,为啥呢:flush在计算机中的意思是刷新的意思。我们可以看一下不加flush的运行效果:如图2,按下回车后并没有收到服务器的响应,同样在服务器那边也没有任何反应,因为此时的代码还在数据缓冲区(就是硬盘上的一块内存)并没有真正的发送给服务器,为啥要有数据缓冲区:其实请求和响应本质上也是IO操作(读写硬盘,读写网卡都是IO操作),只是这个IO操作是读写网卡,注意,读写网卡本身效率也很低的一种操作,通常比读写硬盘还慢,所以为了提高IO效率,就引入了数据缓冲区,比如要写10次网卡,就先把要写的数据先放到一个内存构成的数据缓冲区(buffer)中,再统一把这个数据缓冲区的数据写入网卡。
有了数据缓冲区,写数据的操作就会写进缓冲区,当数据缓冲区满了,才会真正的写入网卡,所以此时要有一个刷新缓冲区的操作,刷新了数据缓冲区就是让数据立即写入网卡(会清除缓冲区的数据)。
2. 如下图,如果while循环这里只是写这两行代码,是有bug的:问题就是只能和一个客户端进行交互,如果在来一个客户端来请求数据,此时就请求不了数据,因为第一个客户端在请求响应的时候首先会执行accept方法,然后执行processConnection方法(这个方法中也有循环,就是处理完了这个客户端的请求循环才结束,processConnection方法的结束才可以让下图中的while循环下一次,所以在第一个客户端关闭之前,processConnection方法中的while就死循环了),此时下图的while就无法进入下一次循环,就无法接受(accept)下一个客户端的请求,也就建立不了连接,也就无法响应。
解决方案:引入多线程,如下代码,此时就是创建另外一个线程(注释掉的部分代码),当进入while循环时,客户端来请求后,主线程只是负责建立连接,然后由t1线程提供响应服务,当再来请求时,主线程会进入下一次循环,再接受连接(accept),再创建一个线程给新的客户端提供响应服务。(注意:每个客户端对应的只有一次连接,但是可以有多个请求,只要客户端不下线,processConnection方法中的循环就不会结束),但是引入多线程,下边代码的while不会阻塞着,新线程给这个客户端提供服务的同时,下一个客户端来了请求,主线程的while循环会进入下一次,先建立连接,之后又创建了新的线程为这个客户端提供响应数据,此时问题就解决了。
还可以引入线程池,用submit方法把一个个的计算请求提交给线程池,然后返回响应的数据,和多线程是一个道理。
while (true) {
//客户端没有启动的时候accept是阻塞着的,当客户端运行后,accept方法继续往下执行
//如果直接调用,该方法会影响下一次循环不能及时accept
//每次来一个新客户端都创建一个线程即可!!!
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
/*Thread t1 = new Thread(() -> {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t1.start();*/
//然后就把一个一个的任务请求提交给线程池让它去执行即可。
//一个连接的所有请求都处理完后并不会销毁线程,这个线程只是回到线程池中下次直接使用
//注意:这里的线程的创建是一个一个循环创建的,但是建立连接和计算响应是并发执行的
executorService.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
/*通过这个方法来处理一个连接(1.读取请求 2.根据请求计算响应 3.把响应的结果发送回给客户端)
* tcp是面向字节流的,所以在读数据的时候也是通过socket来读取数据的*/
}