Java:TCP编程与Socket
在开发网络应用程序时,我们会遇到Socket这个概念,它是一个抽象概念,一个应用程序通过一个Socket来建立一个远程连接,而Socket内部通过TCP/IP协议把数据传输到网络:
Socket、TCP和部分IP的功能都是由OS提供的,不同的编程语言只是提供了对OS调用的简单封装。例如,Java提供的几个Socket相关的类就封装了OS提供的接口。
为什么需要Socket进行网络通信?因为仅仅通过IP地址进行通信是不够的,因为同一台计算机同一时间会运行多个网络应用程序,例如浏览器、QQ、邮件客户端等。当OS收到一个数据包时,如果只有IP地址,它没法判断应该发给哪个应用程序,所以,OS抽象出Socket接口,每个应用程序需要各自对应到不同的Socket,数据包才能根据Socket正确地发到对应的应用程序。
一个Socket就是由IP和端口号(范围0~65535)组成,可以把Socket简单理解为IP+端口。端口号由OS分配,范围0~65535,其中,0~1023属于特权端口,需要管理员权限,1024~65535的端口可以由任意用户的应用程序打开:
- 对Server来说,它的Socket是指定的IP和端口;
- 对Client来说,它的Socket是它所在计算机的IP和一个OS随机分配的端口。
服务器端(Server)
要使用Socket编程,我们首先要编写Server端程序。Java标准库(java.net.ServerSocket)提供了ServerSocket来实现对指定的IP和端口的监听。
ServerSocket的典型实现代码如下:
import java.io.*; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; public class Server { public static void main(String [] args) throws IOException{
//服务器端Socket,需指定监听端口,但无需指定IP ServerSocket ss = new ServerSocket(6666); System.out.println("server is running..."); while(true){ Socket sock = ss.accept(); System.out.println("connected from "+sock.getRemoteSocketAddress()); Thread t = new Handler(sock); t.start(); } } } class Handler extends Thread{ Socket sock; public Handler(Socket sock){ this.sock=sock; } @Override public void run(){ try(InputStream input = this.sock.getInputStream()){ try(OutputStream output = this.sock.getOutputStream()){ handle(input,output); } } catch(Exception e){ try{ this.sock.close(); }catch(IOException ioe){ } System.out.println("client disconnected"); } } private void handle(InputStream input,OutputStream output) throws IOException{ var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); var reader = new BufferedReader(new InputStreamReader(input,StandardCharsets.UTF_8)); writer.write("hello\n"); writer.flush(); while(true){ String s=reader.readLine(); if(s.equals("bye")){ writer.write("bye\n"); writer.flush(); break; } writer.write("ok: "+s+"\n"); writer.flush(); } } }
解析
服务器端通过代码:
ServerSocket ss = new ServerSocket(6666);
监听指定端口6666。这里我们没有指定IP地址,表示在计算机的所有网络接口上进行监听。
如果ServerSocket监听成功,我们就用一个无限循环来处理Client的连接:
while(true){ Socket sock = ss.accpet(); Thread t = new Handler(sock); t.start(); }
代码ss.accept()表示每当有新Client接入后,就返回一个Socket实例,这个Socket实例就是用来和刚连接的Client进行通信的。由于Client很多,要实现并发处理,我们就必须为每个新Socket创建一个新线程来处理,这样,主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。
我们之前在2021.10.9:线程池 - ShineLe - 博客园介绍过线程池,这里也完全可以用线程池来处理Client连接,可以大大提高运行效率。
如果没有Client连接进来,accept()方法会阻塞并一直等待;如果有多个Client同时连接进来,ServerSocket会把连接扔到队列中,然后一个一个处理。
对于Java程序而言,只需要通过循环不断调用accept()就可以获取新的连接。
客户端(Client)
相比服务器端,客户端程序就简单很多。一个典型的客户端程序如下:
import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Scanner; public class Client { public static void main(String[]args) throws IOException { Socket sock = new Socket("localhost",6666); try(InputStream input = sock.getInputStream()){ try(OutputStream output = sock.getOutputStream()){ handle(input,output); } } sock.close(); System.out.println("disconnected."); } private static void handle(InputStream input,OutputStream output) throws IOException{ var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); var reader = new BufferedReader(new InputStreamReader(input,StandardCharsets.UTF_8)); Scanner scanner = new Scanner(System.in); System.out.println("[server] "+reader.readLine()); while(true){ System.out.print(">>> ");//打印提示 String s = scanner.nextLine();//读取一行输入 writer.write(s); writer.newLine(); writer.flush(); String resp = reader.readLine(); System.out.println("<<< "+resp); if(resp.equals("bye")){ break; } } } }
客户端通过:
Socket sock = new Socket("localhost",6666);
连接到Server;注意到上述代码的Server地址为"localhost",表示本机地址,端口号是6666。如果连接成功,将返回一个Socket实例,用于后续通信。
Socket流
当Socket连接创建成功后,无论Server还是Client,我们都使用Socket实例进行网络通信。
因为TCP协议是一种基于流的协议,因此,Java标准库使用InputStream和OutputStream来封装Socket的数据流,这样我们使用Socket的流,和普通IO流类似:
//用于读取网络数据 InputStream in = sock.getInputStream(); //用于写入网络数据 OutputStream out = sock.getOutputStream();
那么,为什么写入网络数据时,要用flush()方法?
如果不调用flush(),我们会发现,Client和Server都收不到数据,这并不是Java标准库的设计问题,而是我们以流的形式写入数据的时候,并不是一写入就立刻发送到网络,而是先写入内存缓冲区,直到缓冲区满了之后,才会一次性真正发送到网络,这样设计的目的是为了提高传输效率。
如果缓冲区数据很少,而我们又想强制把这些数据发送到网络,就必须调用flush()强制把缓冲区的数据发送出去。
小结
使用Java进行TCP编程时,需要使用Socket模型:
- Server用ServerSocket监听指定端口;
- Client用Socket( ip , port )连接Server;
- Server用accept()接收Connect并返回Socket;
- 双方通过Socket打开InputStream/OutputStream 读写数据;
- Server通常用多线程同时处理多个Server连接,利用线程池可以大幅度提升效率;
- flush()用于强制输出缓冲区到网络。
总结
1、应用程序通过Socket建立远程连接,Socket内部通过TCP/IP协议把数据传递给网络。
2、Socket = IP + 端口。端口范围0~65535,其中0~1023为特权端口,需要管理员权限;1024~65535可供任意用户的应用程序使用。
3、使用Socket进行网络编程的本质,是两个进程间的网络通信。这两个进程分别称为服务器和客户端:
- 服务器主动监听某个端口;
- 客户端主动连接服务器的IP与端口。
连接建立成功后,服务器与客户端间就建立了TCP连接,双方可以随时收发数据。
对于服务器和客户端而言,双方对于连接的持有项是不同的:
- 服务器持有的Socket是指定的IP与端口,这个是不变的,这也是监听的含义;
- 客户端持有的Socket则是它的IP与OS随机分配的端口。与服务器持有的恒定端口相区别。
4、服务器端代码
Java标准库(java.net.ServerSocket)提供了ServerSocket来实现对指定IP与端口的监听:
①导入相关包
import java.net.ServerSocket; import java.net.Socket;
②创建服务器类,对这个类的要求是:
- A、这个类中要有一个main方法,在main方法中监听指定端口;
- B、要有一个无限循环的代码块,表示服务器不断监听;
- C、要是监听到了客户端请求(表现为accept了客户端的Socket),就为其分配一个用于处理具体事务的线程(在无限循环的代码块中),启动线程来处理具体事务:
public class Server{ public static void main(String[]args) throws IOException{ //A、监听端口6666 ServerSocket ss = new ServerSocket(6666); .... //B、不断监听 while(true){ //B、通过accept()监听到客户端发来的请求 //没有听到就会阻塞并一直等待 Socket sock = ss.accept(); ... //C、用该客户端Socket分配一个线程,处理具体事务 //Handler是一个处理具体事务的线程类,从Thread继承而来 Thread t = new Handler(sock); //启动线程,处理相关事务 t.start(); } } }
③对处理具体事务的线程类(如上文的Handler)的编写,对这个线程的要求:继承自Thread,且需要有一个Socket成员变量来接受实际客户端的端口。
5、客户端代码编写
①相关包的导入,与服务器端的区别在于不用导入ServerSocket:
import java.net.Socket;
②创建客户端类
对这个类的要求是:
- A、需要一个main方法,在main中new一个客户端Sokcet,需要同时指定IP与端口,且端口与服务器端一致,如果new成功(表明服务器与客户端间建立了连接),将得到一个Socket实例,用于后续通信;
- B、客户端无需无限循环代码块,也不需要独立线程,通常只发送一两次请求,通过socket.getInputStream()获取客户端发送的请求,通过socket.getOutputStream()获取服务器的响应;
- C、客户端Socket在请求服务器处理完毕后要销毁close():
public class Client { public static void main(String[]args) throws IOException { //A Socket sock = new Socket("localhost",6666); //B try(InputStream input = sock.getInputStream()){ try(OutputStream output = sock.getOutputStream()){ handle(input,output); } } //C sock.close(); System.out.println("disconnected."); }
③对处理具体事务的方法的实现(上文handle)
不用再新建线程类,在这个方法中,需要持有两个变量分别代表客户端发送和收到的服务器端数据,在上文代码中用InputStream input和OutputStream output表示。
6、利用Socket实现网络通信
接着第5点的第③小点说,当Socket连接创建完成后(客户端new Socket之后,收到了服务器端的响应——getInputStream、getOutputStream都有回应),Server与Client间通过Socket实例进行通信。
Java标准库用InputStream和OutputStream来封装Socket的数据流,我们在使用Socket的流时和普通的IO流类似:
//用于读取网络数据 InputStream in = sock.getInputStream(); //用于写入网络数据 OutputStream out = sock.getOutputStream();
在写入数据时,需要调用flush()方法,及时将写入的数据发送到网络。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
2020-10-21 2020.10.20 利用POST请求模拟登录知乎