目录
1、TCP套接字
1.1、ServerSocket 常用API
1.2、Socket 常用API
1.3、TCP中的长短连接
2、基于TCP套接字实现一个TCP回显服务器
2.1、服务器端代码
2.2、客户端代码
2.3、解决服务器不能同时和多个客户端建立链接的问题
3、基于TCP socket 写一个简单的单词翻译服务器
TCP和UDP相比有很大的不同,TCP需要进行网络通信,首先需要建立链接,成功之后客户端和服务器之间才能进行通信,TCP进行网络编程的方式和二进制文件的读写操作比较类似,都是以字节为单位流式传输。
Java对TCP的套接字提供了两个类分别为ServerSocket和Socket。
这个ServerSocket类是给服务器使用的。
✨ServerSocker构造方法
方法签名 | 方法说明 |
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
✨ServerSocket成员方法
方法签名 | 方法说明 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端链接后,返回一个服务器端Socket对象,并基于该Socket建立与客户端的链接,否则阻塞等待 |
void close() | 关闭套接字 |
这个Socket类服务器和客户端都可以使用。
✨Socket构造方法
方法签名 | 方法说明 |
Socket(String host,int port) | 创建一个客户端流套接字Socket,并于对应IP的主机上,对应端口的进程建立链接 |
这个构造方法的参数表示的是服务器的IP和端口,应为TCP是有链接的,所以在客户端new Socket 对象的时候,就会尝试和指定的IP和端口的目标建立链接了。
✨Socket成员方法
方法签名 | 方法说明 |
InetAddress getInetAddress() | 返回套接字所链接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutStream() | 返回此套接字的输出流 |
close() | 关闭套接字 |
之前说过TCP是面向字节流进行数据传输的,通过Socket对象就可以拿到里面所包含的字节流对象,拿到字节流对象之后就可以进行数据传输了,从InputStream这里读数据,就相当于从网卡接收,往OutputStream这里写数据,就相当于从网卡发送。
TCP发送数据的时候,需要先建立链接,什么时候关闭链接就决定是短链接还是长连接。
- 短连接:每次收到数据并返回响应后,都关闭连接,及时短连接,也就是收,短连接只能一次收发数据。
- 长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
✨短连接和长连接的区别:
- 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也要耗时的,长连接效率更高。
- 主动发送请求不同:短连接一般是客户端制动向服务器发送请求;而长连接可以是客户端主动发送请求,也可以是服务器端主动发。
- 两者的使用场景不同:短连接使用于客户端请求频率不高的场景,入浏览网页等。长连接适用于客户端于服务器端通信频繁的场景,入聊天室等等。
- 创建ServerSocket对象,并指定服务器端口号。
- 启动服务器,使用accept方法和客户端建立链接,如果没有客户端链接上服务器,那么sccept方法就会阻塞等待,直到和客户端和服务器建立链接。
- 读取客户端发来的请求。
- 处理客户端请求,计算响应
- 将响应返回给客户端。
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;
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){
//和客户端建立链接
//这里的clientSocket是服务器上的socket,我们只是通过这个socket和客户端
//进行通信的,所以我们给这个socket起了一个名字叫做clientSocket ,并不是说这个clientSocket就对应到客户端的网卡。
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
//通过这个方法来处理一个链接
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()){
//使用scanner和printWriter将inputStream和outputStream进行包裹,这样可以将字符流转换为字节流
Scanner scanner = new Scanner(inputStream);
while(true){
//1、读取请求
if(!scanner.hasNext()){
//读取的流到了结尾了(对端关闭了),打印客户端的IP地址和端口号
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
//直接使用scanner读取一段字符串
String request = scanner.next();
//2、根据请求计算响应
String response = process(request);
//3、把响应返回给客户端.不要忘记,响应这里也要带上换行的
PrintWriter printWriter = new PrintWriter(outputStream);
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文件只是给一个链接提供服务的文件,所以客户端和服务器断开链接
//之后就需要释放clientSocket文件资源。链接有很多,时刻都会建立新连接,时刻都会有旧链接断开。
//serverSocket对象文件的生命周期和服务器进程的生命周期一样,所以不需要显示的进行文件资源释放。
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
✨下面对这个服务器端的代码进行逐步解析:
1️⃣上述代码中创建了一个serverSocket对象,但是在start()方法中又创建了一个clientSocket对象
public void start() throws IOException { System.out.println("服务器启动!"); while(true){ //和客户端建立链接 Socket clientSocket = serverSocket.accept(); processConnection(clientSocket); } }
这里accept()方法的作用是:监听与此套接字对象(clientSocket)建立的链接并接收它,当服务器启动之后,若没有客户端与服务器建立链接,则accept方法会阻塞等待,直到有客户端和服务器建立链接为止。
serverSocket只是通过accept方法和客户端建立链接,而clientSocket只是用来和客户端进行通信的。这里的clientSocket并不是对应到客户端的网卡,这个clientSocket还是服务器上的socket对象。
2️⃣下面这个代码的是读取客户端发来的请求和服务器返回响应,需要使用到Socket当中的getInputStream()和getOutputStream()方法,获取到流对象。
try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()){ //使用scanner和printWriter将inputStream和outputStream进行包裹,这样可以将字符流转换为字节流 Scanner scanner = new Scanner(inputStream); PrintWriter printWriter = new PrintWriter(outputStream);
- getInputStream():返回调用此方法的套接字对象的输入流。
- getOutputStream():返回调用此方法的套接字对象的输出流。
由于服务器和客户端之间建立链接之后,可能存在多个请求,服务器在读请求的时候怎样确定读到的是一个完整的请求,这里我们做一个简单的约定:每个请求是一个字符串(文本数据),请求和请求之间,使用\n来分割,如果使用字符流并不太好找到\n,但是我们使用scanner将inputStream对象包一下,由于Scanner是按照字符单位处理流对象的,所以我们就可以使用字符流来处理。
Scanner中存在两个方法next()和hasNext(),next()方法读到空白符(空格,换行,制表符,翻页符...)就会结束,这就实现了使用\n分割。
hasNext()方法作用就是判断读取的流对象是否到了结尾,如果到了结尾也就说明对端关闭了。
❗❗注意:这里约定的使用\n来分割读取请求,那么后续客户端发送请求的时候,也得带上\n.由于回显服务器,响应和请求一摸一样,所以响应也要遵守上述的规则。
3️⃣将响应写回到客户端,我们也要将字节流转换为字符流,这里我们使用printWriter类包一下,将outputStream对象由字节流转换为字符流,服务器在将响应写回到客户端时,也要对响应进行分割,所以我们可以使用printWriter对象的write方法+分隔符,也可以使用printWriter对象调用println()方法对response对象中的数据写回到客户端。
- 创建Socket对象,并且指定服务器IP和端口号,这就相当于和服务器建立了链接。
- 客户端启动,用户输入请求,把读取的内容构造成请求。
- 从服务器读取响应内容
- 把响应结果显示在控制台上
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 port) throws IOException {
//创建客户端socket对象的时候指定服务器IP和端口号
//这个操作就相当于让客户端和服务器建立tcp链接
//这里建立上链接之后,服务器中的accept方法的阻塞就会结束,就会得到客户端的socket
socket = new Socket(serverIp,port);
}
public void start(){
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while(true){
//1、从键盘上读取用户输入的内容
System.out.println("->");
String request = scanner.next();
//2、把读取的内容构造成请求,发送给服务器
//这里发送的请求是带有换行的
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush();
//3、从服务器读取响应内容
Scanner scannerFromSocket = new Scanner(inputStream);
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 {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
❗❗❗注意:在服务器中将请求发送给服务器和客户端中将响应返回到客户端,这两个操作都使用了下面的代码
printWriter.println(request);
这里的操作并不是把数据直接写入到网卡,而是把数据写入到内存的缓冲区中,如果不手动刷新缓冲区,那么就只能等到缓冲区被放满数据之后,才会统一进行刷新,将数据写入到网卡中。所以这就导致一个问题,当我们在客户端发送一个请求之后,服务端并不会立即做出响应。因为请求存在于内存的缓冲区,服务器无法从网卡中读取到请求。
✨要解决这个问题我们可以调用flush()方法,对缓冲区进行手动刷新,这样客户端每写入一个请求,就会刷新一次缓冲区,将请求写入到网卡中,这个时候服务器就可以在网卡中读取到客户端的请求,并作出响应。同理服务器在返回响应的时候,也需要手动刷新缓冲区。
✨了解缓冲区
- 之前的博客中我们了解过寄存器>内存>硬盘,他们读写速度硬盘是最慢的,但是网卡的读写速度在一般情况下(大多数网卡)是比硬盘慢很多的。为了提高IO效率(读写网卡、读写硬盘都可以视为IO操作),引入了缓冲区,使用缓冲区减少了IO次数,就可以提高整体的效率。
- 假设写十次,一百次网卡,就先把要写的数据放到一个内存构成的缓冲区中,在同一刷新将缓冲区中的数据写入到网卡中。
理解缓冲区的策略:就像我们嗑瓜子一样,垃圾桶离我们太远,我们将瓜子皮,先拿到手上,手上拿不下的时候,我们将瓜子皮扔到垃圾桶中。这就提高了效率。我们不可能嗑一个瓜子,跑到垃圾桶旁仍一次瓜子皮。(这里的例子只是用来理解缓冲区的工作策略)
上述的回显服务器写完了,但是存在一个很大的问题,作为服务器,就应该可以同时和多个客户端建立链接 。但是我们写的回显服务器只能同时和一个客户端建立链接。
上述写的服务器不能同时和多个客户端建立链接.这个问题和TCP的特性没有任何关系,只是单纯的属于代码的bug.
这里我们有两种修改的方法,手动创建多个线程和创建一个线程池。我们通过主线程让服务器和客户端建立链接,手动创建出来的线程或者线程池中的线程处理每个客户端发来的请求。
1️⃣在服务器start方法的while循环中,使用Thread类创建一个线程来处理processConnection方法。通过循环服务器每链接一个客户端,就会创建一个线程。在每个线程中单独处理每个客户端发来的请求。
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;
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){
//和客户端建立链接
Socket clientSocket = serverSocket.accept();
//如果直接调用processConnection方法,该方法会影响这个循环的二次执行,导致accept(链接)不及时
//创建新的线程,用新线程来调用processConnection
//每次来一个新的客户端都会创建一个新的线程
Thread t = new Thread(()->{
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();
}
}
//通过这个方法来处理一个链接
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()){
//使用scanner和printWriter将inputStream和outputStream进行包裹,这样可以将字符流转换为字节流
Scanner scanner = new Scanner(inputStream);
while(true){
//1、读取请求
if(!scanner.hasNext()){
//读取的流到了结尾了(对端关闭了),打印客户端的IP地址和端口号
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
//直接使用scanner读取一段字符串
String request = scanner.next();
//2、根据请求计算响应
String response = process(request);
//3、把响应返回给客户端.不要忘记,响应这里也要带上换行的
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.write(response+"\n");
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 {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
这里创建的线程什么时候结束,当每个线程的processConnection方法结束,也就是说客户端和服务器断开链接,这个时候当前线程就会结束。
这个修改后的服务器虽然可以同时链接多个客户端,但是我们写的这个服务器很难处理“高并发”,创建的线程太多的话,系统在调用线程上线的时候,就会产生阻塞,有些线程是无法被同时调用上线的,只能是串行执行了。这个问题还是我们电脑的CPU核心数不够,需要解决这个问题,可以增加机器,一个电脑的核心数不够,就使用多个。还可以采用IO多路复用的机制(使用一个线程,可以管理多个socket)。
2️⃣使用线程池代替手动创建线程。
因为手动创建线程池会涉及到频繁的创建和销毁,这就会导致程序的效率比较低。但是使用线程池的话,创建一个客户端链接,当处理完客户端的所有请求的时候,这个线程不会销毁,而是还到池子中,下次可以直接使用,这样就省下了销毁线程的时间开销,这就会是程序的效率更高一些。
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.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 {
//创建线程池,代替手动创建线程。
//newCachedThreadPool()创建线程数目动态增长的线程池
ExecutorService executorService = Executors.newCachedThreadPool();
System.out.println("服务器启动!");
while(true){
//和客户端建立链接
Socket clientSocket = serverSocket.accept();
executorService.submit(new Runnable(){
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
//通过这个方法来处理一个链接
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()){
//使用scanner和printWriter将inputStream和outputStream进行包裹,这样可以将字符流转换为字节流
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1、读取请求
if(!scanner.hasNext()){
//读取的流到了结尾了(对端关闭了),打印客户端的IP地址和端口号
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
//直接使用scanner读取一段字符串
String request = scanner.next();
//2、根据请求计算响应
String response = process(request);
//3、把响应返回给客户端.不要忘记,响应这里也要带上换行的
printWriter.write(response+"\n");
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 {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class TcpDicServer extends TcpEchoServer{
private Map dict = new HashMap<>();
public TcpDicServer(int port) throws IOException {
super(port);
dict.put("dog","小狗");
dict.put("cat","小猫");
dict.put("apple","苹果");
dict.put("bear","熊");
}
@Override
public String process(String request){
return dict.getOrDefault(request,"该单词没有查到");
}
public static void main(String[] args) throws IOException {
TcpDicServer tcpDicServer = new TcpDicServer(9090);
tcpDicServer.start();
}
}