什么是客户端: 主动发起请求的一方. 客户端给服务器发送的数据是 “请求” (Request)
什么是服务器: 被动接受请求的乙方. 服务器给客户端发回的数据"响应" (Response)
但是服务器不知道什么时候客户端会发起请求,服务器只能提前准备好,并且一直等待
下面介绍两个概念
ip地址:用来识别互联网上的一台主机的位置 ip本质上是一个32位的整数,常用3个点把这个数分成四个部分,每个部分一个字节(0-255),这就是"点分十进制"
端口号:用来区分一台主机上的哪个应用程序 端口号就是一个整数,取值范围是0-65535之间(占两个字节的整数)
一次通信过程中涉及到五个概念(五元组):
源IP:发件人地址
源端口:发件人姓名
目的IP:收件人地址
目的端口:收件人姓名
协议类型
Java中使用SocketAPI
java标准库中提供了两种风格:
[UDP] DatagramSocket:面向数据报(发送接受数据报,必须以一定的数据报为单位进行传输)
[TCP] ServerSocket:面向字节流
下面实现一个最简单的客户端服务器
客户端给服务器发送一个字符串,服务器把这个字符串原封不动的返回(回显服务器 Echo Server)
服务器:
import java.io.IOException;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.net.DatagramPacket;
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) {
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
String request = new String(requestPacket.getData(), 0, requestPacket.getLength()).trim();
String response = process(request);
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();
}
}
注释版:
//0621
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
// 对于一个服务器程序来说, 核心流程也是要分成两步.
// 1. 进行初始化操作 (实例化 Socket 对象)
// 2. 进入主循环, 接受并处理请求. (主循环就是一个 "死循环")
// a) 读取数据并解析
// b) 根据请求计算响应
// c) 把响应结果写回到客户端.
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
//new这个Socket对象的时候,就会让当前的socket对象和一个ip地址和一个端口号进行绑定
//未来客户端就按照这个ip+端口来访问服务器
//如果在socket的时候没有写ip地址,那么默认的ip地址就是0.0.0.0(特殊ip,表示会关联到这个主机的所有网卡的ip,一个主机可能会有多个网卡)
socket = new DatagramSocket(port);
//socket本质上是一个文件,这个文件是网卡的抽象
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
// a) 读取请求并解析.
//DatagramPacket是UDPSocket发送接收数据的基本单位
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
//程序启动之后,马上就能执行到receive操作
//服务器启动了之后,此时客户端不一定发送了请求
//所以调用reveive时,客户端可能还没发数据
//此时receiver操作就会阻塞,一直阻塞到真的有数据过来了为止.
//当真的有客户端的数据过来了之后,此时receiver就会把收到的数据放到DatagramPacket对象的缓冲区中
socket.receive(requestPacket);
//此处是要把请求数据转成一个String(本来请求是一个byte数组,经过new String后转成string,使用trim 方法是因为
// 用户输入的数据可能远远小于4096,而此处的getLength的带的长度就是4096,通过trim可以删除不必要的空白符)
String request = new String(requestPacket.getData(), 0, requestPacket.getLength()).trim();
// b) 根据请求计算响应
String response = process(request);
// c) 把响应写回给客户端, 响应数据就是 response, 需要包装成一个 Packet 对象
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
response.getBytes().length, requestPacket.getSocketAddress());
//↑这个最后一个参数就代表这个包要发给谁,此处的address地址就是客户端的地址和端口,这两个信息已经包含在requestPacket内部了;
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) {
// 由于此处是一个 echo server, 请求内容是啥, 响应内容就是啥.
// 如果是一个更复杂的服务器, 此处就需要包含很多的业务逻辑来进行具体的计算.
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
客户端:
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("-> ");
String request = scanner.nextLine();
if (request.equals("exit")) {
break;
}
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),
request.getBytes().length, InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength()).trim();
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
带注释版:
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
// 客户端的主要流程分成四步.
// 1. 从用户这里读取输入的数据.
// 2. 构造请求发送给服务器
// 3. 从服务器读取响应
// 4. 把响应写回给客户端.
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
// 需要在启动客户端的时候来指定需要连接哪个服务器
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
//当我们创建socket的时候不需要绑定端口号,由操作系统自动分配一个空闲端口,一个端口号通常情况下只能与一个进程绑定
//如果客户端绑定了端口,那么一个主机上就只能启动一个端口了
//服务器必须绑定端口.客户端才能访问,客户端才知道去访问谁.
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 读取用户输入的数据
System.out.print("-> ");
String request = scanner.nextLine();
if (request.equals("exit")) {
break;
}
// 2. 构造请求发送给服务器
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),
request.getBytes().length, InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
// 3. 从服务器读取响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength()).trim();
// 4. 显示响应数据
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
//这是一个环回ip,自己访问自己,服务器和客户端都在同一台主机上,客户端写的服务器ip就是环回ip,如果不在同一个主机上,此处的ip就要写成服务器的ip
//并且后面的端口要和服务器绑定的端口相匹配
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
// UdpEchoClient client = new UdpEchoClient("47.98.116.42", 9090);
client.start();
}
}
站在客户端角度 理解此处通信的五元组:
- 协议类型:UDP
- 源IP:客户端的IP(客户端所在的主机IP)
- 源端口:客户端的端口(操作系统自动分配的端口)
- 目的ip:服务器的ip(服务器和客户端在同一个主机上,ip就是127.0.0.1)
- 目的端口:9090(服务器启动的时候绑定的端口)
总结:
客户端的主要流程分成四步.
- 从用户这里读取输入的数据.
- 构造请求发送给服务器
- 从服务器读取响应
- 把响应写回给客户端.
客户端的主要流程分成四步.
4. 从用户这里读取输入的数据.
5. 构造请求发送给服务器
6. 从服务器读取响应
7. 把响应写回给客户端.
实现一个简单的英译汉服务器,体会其中的"根据请求计算响应"
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class UdpDictServer extends UdpEchoServer {
private Map<String, String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
// 所谓 "有道词典" 实现, 本质上就是个 hash 表.
// 只不过人家的那个 hash 非常大
dict.put("cat", "小猫");
dict.put("dog", "小狗");
dict.put("fuck", "卧槽");
}
@Override
public String process(String request) {
return dict.getOrDefault(request, "这超出了我的知识范围");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
可以看到,服务器功能不同主要是process内的逻辑不同,这也是一个服务器最关键的逻辑.
Tcp协议有连接,类似于打电话,打通了才能对话
Udp协议无连接,类似于发短信,不管别人看不看,我发就完事了
下面写一下另一组API(TCP的socket API)
写一个简单的回显服务器
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class TcpEchoServer {
// 1. 初始化服务器
// 2. 进入主循环
// 1) 先去从内核中获取到一个 TCP 的连接
// 2) 处理这个 TCP 的连接
// a) 读取请求并解析
// b) 根据请求计算响应
// c) 把响应写回给客户端
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
// 这个操作和前面的 UDP 类似, 也是要绑定端口号.
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
// 1) 先从内核中获取到一个 TCP 连接
Socket clientSocket = serverSocket.accept();
// 2) 处理这个连接
processConnection(clientSocket);
//TCP的连接管理是由操作系统内核管理的
//所谓的管理就是描述+组织
//通过通信中的五元组进行描述(协议类型....)
//组织就是使用一个阻塞队列来组织若干个连接对象
//客户端和服务器建立连接的过程,完全由内核进行负责,应用程序完全感觉不到
//当连接建立成功时,内核已经把这个连接对象放到阻塞队列中了.
//代码中调用的accept就是从阻塞队列中取出一个连接对象(在应用程序中的化身就是socket对象)
//后续数据读写都是针对clientSocket这个对象来进行展开的
//如果服务器启动之后,没有客户端建立连接,此时代码调用accept就会阻塞,阻塞到真的有客户端建立连接之后
}
}
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
// 通过 clientSocket 来和客户端交互, 先做好准备工作. 获取到 clientSocket 中的流对象
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) {
// 进一步的就可以完成后面的逻辑 TODO
// 1. 读取请求并解析
// 2. 根据请求计算响应
// 3. 把响应写回到客户端
} catch (IOException e) {
e.printStackTrace();
}
}
}