一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第18天,点击查看活动详情。
文章目录
一、UDP回显服务
所谓回显服务,英文是EchoServer,Echo的意思是回声,也就是说请求的内容是什么,得到的响应就是什么。这样的程序属于最简单的网络编程中的程序,不涉及到任何的业务逻辑,只是单纯的通过socket API转发。
在这里我们首先创建两个类,一个是UdpEchoServer类,另一个是UdpEchoClient。
1.UdpEchoServer
针对UdpEchoServer:
(1)第一步是准备好socket文件,这是进行网络编程的大前提。
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
(2)启动服务器
由于我们这里是一个UDP服务器,而UDP不需要建立连接,直接接受从客服端来的数据即可。流程如下:
- 读取客户端发来的请求
- 根据请求计算响应,这里由于是一个回显服务,第二步就省略。
- 把响应写回客户端
有的同学可能会疑惑,为啥这里服务器上来就是接收数据,而不是发送数据呢?
被动接收请求的一方是服务器;主动发送请求的这一方叫做客户端。
第一步:读取客户端发来的请求:
//1.读取客户端发来的请求
DatagramPacket requestPacket = new DatagramPacket(new byte[1024],1024);//需要指定里面的内存空间
socket.receive(requestPacket); //为了接受数据,需要先准备好一个空的DatagramPacket,由receive来填充
这里的DatagramPacket 表示一个UDP数据报,发送一次数据,就是在发一个DatagramPacket ;接收一次数据,也就是在接收一个DatagramPacket 。此外这里的DatagramPacket 就是把一个字符数组给包装了一下,只不过里面装了更多的东西。
这里的receive方法是可能会阻塞的,因为我们不清楚客户端什么时候给服务器发送请求。
由于我们是比较习惯用字符串的,所以这里把这个DatagramPacket解析成一个String。requestPacket.getData()可以拿到客户端传过来的字节数组。requestPacket.getLength()这个获取的长度不一定是1024 ,假设此处的UDP数据报最长是1024,实际的数据可能不够1024
//把DatagramPacket解析成一个String
String request = new String(requestPacket.getData(),0,requestPacket.getLength(),"UTF-8");
第二步:根据请求计算响应,这里由于是一个回显服务,第二步就省略。
//2.根据请求计算响应,这里由于是一个回显服务,第二步就省略
String response = process(request);//这里的process是一个方法,具体在最后的全部代码里里面
第三步:把响应写回客户端
//3.把响应写回客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
System.out.printf("[%s:%d] req: %s , resp :%s\n",
requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
这里send方法的参数,也是 DatagramPacket.需要把响应数据先构造成一个DatagramPacket,再进行发送.所以这里就不是构造一个空的数据报。
requestPacket.getPort(),这里的参数不再是一个空的字节数组了,response是刚才根据请求计算得到的响应,是非空的,DatagramPacket里面的数据就是String response的数据。
requestPacket.getSocketAddress()表示把数据发给那个地址和端口,这里的SocketAddress可以视为一个类里面包含了IP和端口的信息。
以下是UdpEchoServer 的完整代码:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
//进行网络编程,第一步就是要准备好socket文件,这是进行网络编程的大前提
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
//启动服务器
public void start() throws IOException {
System.out.println("启动服务器!");
//UDP不需要建立连接,直接接受从客服端来的数据即可
while (true){
//1.读取客户端发来的请求
DatagramPacket requestPacket = new DatagramPacket(new byte[1024],1024);//需要指定里面的内存空间
socket.receive(requestPacket); //为了接受数据,需要先准备好一个空的DatagramPacket,由receive来填充
//把DatagramPacket解析成一个String
String request = new String(requestPacket.getData(),0,requestPacket.getLength(),"UTF-8");
//2.根据请求计算响应,这里由于是一个回显服务,第二步就省略
String response = process(request);
//3.把响应写回客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
System.out.printf("[%s:%d] req: %s , resp :%s\n",
requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
}
}
//由于是回显服务,因此响应就跟请求一样了
//实际上,对于一个真实的服务器来说,这个过程是最复杂的
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
2.UdpEchoClient
(1)创建客户端。
在客户端构造socket对象的时候,我们就不再手动指定端口号,而是使用无参版本的构造方法。这里的不指定端口号,意思是,让操作系统自己分配一个空闲的端口号。就比如我们营业厅买一个手机号,自己对是什么手机号没关系,然后营业厅就随机给你一个。
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
public UdpEchoClient(String ip, int port) throws SocketException {
// 此处的 port 是服务器的端口.
// 客户端启动的时候, 不需要给 socket 指定端口. 客户端自己的端口是系统随机分配的~~
socket = new DatagramSocket();
serverIP = ip;
serverPort = port;
}
通常写代码的时候,服务器都是手动指定的.客户端都是由系统自动指定的,也就是系统随机分配一个。
对于服务器来说,必须要手动指定,因为后续客户端要根据这个端口来访问到服务器。如果让系统随机分配,客户端就不知道服务器的端口是啥,不能访问
对于客户端来说,如果手动指定也行,但是系统随机分配更好,因为一个机器上的两个进程,不能绑定同一个端口。举个例子:客户端就是普通用户的电脑,我们不知道用户电脑上都装了什么程序,我们也不知道用户的电脑上已经被占用了哪些端口。如果你手动指定一个端口,万一这个端口被别的程序占用,咱们的程序不就不能正常工作了嘛?而且由于客户端是主动发起请求的一方,客户端需要在发送请求之前,先知道服务器的地址和端口.但是反过来在请求发出去之前,服务器是不需要事先知道客户端的地址和端口了。
(2)启动客户端
- 先从控制台读取用户输入的字符串
- 把这个用户输入的内容, 构造成一个 UDP 请求, 并发送.
- 从服务器读取响应数据, 并解析
- 把响应结果显示到控制台上.
UdpEchoClient的完整代码:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
public UdpEchoClient(String ip, int port) throws SocketException {
// 此处的 port 是服务器的端口.
// 客户端启动的时候, 不需要给 socket 指定端口. 客户端自己的端口是系统随机分配的~~
socket = new DatagramSocket();
serverIP = ip;
serverPort = port;
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 先从控制台读取用户输入的字符串
System.out.print("-> ");
String request = scanner.next();
// 2. 把这个用户输入的内容, 构造成一个 UDP 请求, 并发送.
// 构造的请求里包含两部分信息:
// 1) 数据的内容. request 字符串
// 2) 数据要发给谁~ 服务器的 IP + 端口
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIP), serverPort);
socket.send(requestPacket);
// 3. 从服务器读取响应数据, 并解析
DatagramPacket responsePacket = new DatagramPacket(new byte[1024], 1024);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength(), "UTF-8");
// 4. 把响应结果显示到控制台上.
System.out.printf("req: %s, resp: %s\n", request, response);
}
}
public static void main(String[] args) throws IOException {
// 由于服务器和客户端在同一个机器上, 使用的 IP 仍然是 127.0.0.1 . 如果是在不同的机器上, 当然就需要更改这里的 IP 了
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
3.运行两个程序
这里注意,需要先运行服务器UdpEchoServer,再运行客户端UdpEchoClient。
当我们运行了UdpEchoServer,虽然这里没有东京,但早就已经把服务器启动起来了,启动了服务器之后,才开始写客户端代码的.在写客户端代码的这个过程中,显然,没人访问服务器的,服务器其实就卡在receive这里,阻塞等待了。
我们再运行客户端,并且发送数据:
上面的52005就是服务器分配的端口了。
客户端是可以有很多的,一个服务器可以给很多很多客户端提供服务。就比如一个餐馆,可以给很多很多的客人提供就餐服务的。那最多能启动几个呢?
这取决于服务器的能力了,同一时刻服务器能够处理的客户端的数目存在上限的,服务器处理每个请求,都需要消耗一定的硬件资源,包括不限于,CPU,内存,磁盘,带宽…….能处理多少客户端,取决于:
1.处理一个请求,消耗多少资源
2.机器一共有多少资源能用~
而在Java中并不容易精确的计算消耗多少资源,JVM里面有很多辅助性的功能,也要消耗额外的资源。实际开发中,通过性能测试的方式,就知道了能有多少个客户端。
那么我们如何在ieda中启动多个客户端呢?正常情况下,我们需要先关闭上一个客户端才能够重新启动一个新的客户端。那么这里,如下操作,便可打开多个客户端:
这样就能打开多个了。
4.翻译功能的UdpEchoClient
我们写一个带上点业务逻辑的UdpEchoClient,写一个翻译程序(英译汉),请求是一些简单的英文单词,响应也就是英文单词对应的翻译,客户端不变,把服务器代码进行调整.主要是调整process方法,读取请求并解析,把响应写回给客户端,这俩步骤都一样,关键的逻辑就是"根据请求处理响应".
package network;
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
public class UdpDictServer extends UdpEchoServer {
private HashMap<String ,String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("cat","猫猫");
dict.put("pig","猪猪");
dict.put("dog","狗狗");
dict.put("hhh","哈哈");
}
@Override
public String process(String request){
return dict.getOrDefault(request,"该词无法被翻译");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9000);
server.start();
}
}
二、TCP回显服务
TCP回显服务与上面UDP不一样的是,这个是需要连接的。
1.TcpEchoServer
第一步:第一步就是要准备好socket文件,这是进行网络编程的大前提
//第一步就是要准备好socket文件,这是进行网络编程的大前提
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
第二步:启动服务器:
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 由于 TCP 是有连接的, 不能一上来就读数据, 而要先建立连接. (接电话)
// accept 就是在 "接电话", 接电话的前提是, 有人给你打了, 如果当前没有客户端尝试建立连接, 此处的 accept 就会阻塞.
// accept 返回了 一个 socket 对象, 称为 clientSocket. 后续和客户端之间的沟通, 都是通过 clientSocket 来完成的.
// 进一步讲, serverSocket 就干了一件事, 接电话
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
第三步:处理请求和响应
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端建立连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 接下来来处理请求和响应
// 这里的针对 TCP socket 的读写就和文件读写是一模一样的!!
try (InputStream inputStream = clientSocket.getInputStream()) {
try (OutputStream outputStream = clientSocket.getOutputStream()) {
// 循环的处理每个请求, 分别返回响应
Scanner scanner = new Scanner(inputStream);
while (true) {
// 1. 读取请求
if (!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
// 此处用 Scanner 更方便. 如果不用 Scanner 就用原生的 InputStream 的 read 也是可以的
String request = scanner.next();
// 2. 根据请求, 计算响应
String response = process(request);
// 3. 把这个响应返回给客户端
// 为了方便起见, 可以使用 PrintWriter 把 OutputStream 包裹一下
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 {
// 此处要记得来个关闭操作.
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
完整代码:
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 {
//第一步就是要准备好socket文件,这是进行网络编程的大前提
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 由于 TCP 是有连接的, 不能一上来就读数据, 而要先建立连接. (接电话)
// accept 就是在 "接电话", 接电话的前提是, 有人给你打了~~, 如果当前没有客户端尝试建立连接, 此处的 accept 就会阻塞.
// accept 返回了 一个 socket 对象, 称为 clientSocket. 后续和客户端之间的沟通, 都是通过 clientSocket 来完成的.
// 进一步讲, serverSocket 就干了一件事, 接电话~~
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端建立连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 接下来来处理请求和响应
// 这里的针对 TCP socket 的读写就和文件读写是一模一样的!!
try (InputStream inputStream = clientSocket.getInputStream()) {
try (OutputStream outputStream = clientSocket.getOutputStream()) {
// 循环的处理每个请求, 分别返回响应
Scanner scanner = new Scanner(inputStream);
while (true) {
// 1. 读取请求
if (!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
// 此处用 Scanner 更方便. 如果不用 Scanner 就用原生的 InputStream 的 read 也是可以的
String request = scanner.next();
// 2. 根据请求, 计算响应
String response = process(request);
// 3. 把这个响应返回给客户端
// 为了方便起见, 可以使用 PrintWriter 把 OutputStream 包裹一下
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 {
// 此处要记得来个关闭操作.
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
2.TcpEchoClient
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 TcpEchoClient {
// 用普通的 socket 即可, 不用 ServerSocket 了
// 此处也不用手动给客户端指定端口号, 让系统自由分配.
private Socket socket = null;
public TcpEchoClient(String serverIP, int serverPort) throws IOException {
// 其实这里是可以给的. 但是这里给了之后, 含义是不同的. ~~
// 这里传入的 ip 和 端口号 的含义表示的不是自己绑定, 而是表示和这个 ip 端口建立连接!!
// 调用这个构造方法, 就会和服务器建立连接 (打电话拨号了)
socket = new Socket(serverIP, serverPort);
}
public void start() {
System.out.println("和服务器连接成功!");
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream()) {
try (OutputStream outputStream = socket.getOutputStream()) {
while (true) {
// 要做的事情, 仍然是四个步骤
// 1. 从控制台读取字符串
System.out.print("-> ");
String request = scanner.next();
// 2. 根据读取的字符串, 构造请求, 把请求发给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush(); // 如果不刷新, 可能服务器无法及时看到数据.
// 3. 从服务器读取响应, 并解析
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.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();
}
}
目前TCP客户端和服务端实现的功能和UDP差不多,但都存在几个问题:
- 对于服务端来说,处理一次请求并返回响应后,才能再次处理下一次请求和响应,效率是比较低的。这个问题比较好解决:可以使用多线程,每次的请求与响应都在线程中处理。这样多个客户端请求的话,可以在多个线程中并发并行的执行。
- 服务端解析请求,是只读取了一行,而客户端解析响应,是一直读取到流结束。可以想想为什么解析请求时,没有读取到流结束?
3.多线程版本的服务器
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 TcpThreadEchoServer {
private ServerSocket serverSocket = null;
public TcpThreadEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
//由于TCP是有连接的,不能一上来就读数据,而是先建立连接
//accept就是在接电话,接电话的前提是有人给你打,如果当前没有客户端尝试建立连接,此处的accept就会阻塞
//accept返回一个socket对象,称为clientSocket,后续和客户端之间的沟通,都是通过clientSocket来完成
//进一步说,serverSocket就干了一件事,接电话
Socket clientSocket = serverSocket.accept();
//改进方法:在这个地方,每次accept成功,都创建一个进程,由新线程负责执行这个processConnection方法
Thread t = new Thread(()->{
processConnection(clientSocket);
});
t.start();
}
}
private void processConnection(Socket clientSocket) {
System.out.printf("[%s :%d] 客户端建立连接!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//接下来处理请求和响应
//这里针对TCP socket 的读写就和文件读写一模一样
try(InputStream inputStream = clientSocket.getInputStream()){
try(OutputStream outputStream = clientSocket.getOutputStream()){
//循环的处理每个请求,分别返回响应
Scanner scanner = new Scanner(inputStream);
while (true){
//1.读取请求
if (!scanner.hasNext()){
System.out.printf("[%s: %d] 客户端断开连接!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
//此处用Scanner更方便,如果不用Scanner就用原生的InputStream的read也是可以的
String request = scanner.next();
//2.根据请求,计算响应
String response = process(request);
//3.把这个响应返回给客户端
//为了方便起见,可以使用PrintWriter 把 OutputStream包裹一下
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.write(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 {
//此处记得来个关闭操作
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
;
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpThreadEchoServer server = new TcpThreadEchoServer(9090);
server.start();
}
}
完