Java网络编程:(socket API编程:TCP协议的 socket API – 回显程序的服务器端程序的编写)
文章目录
观前提醒
如果你是第一次点击这篇博客进来的,需要你先看完这篇博客,以及它里面提到的博客,再过来看这篇博客:
Java网络编程(4):(socket API编程:TCP协议的 socket API – 回显程序)
1. 准备工作
先构建一个类:TCPEchoServer(这个类,表示服务器程序)
当然,你也可以自己命名。
1.1 创建 ServerSocket对象
我们编写的是 TCP 协议的程序,使用的是 TCP协议的 socket API,并且这里是对服务器端程序的编写,我们使用的类是:ServerSocket。
代码:
private ServerSocket serverSocket = null;
1.2 通过构造方法,初始化 ServerSocket对象
我们通过 TCPEchoServer 这个类的构造方法,在它的构造方法里面,调用 ServerSocket 类的构造方法 ServerSocket(int port)
,对 Socket 对象,进行端口号的绑定。
代码:
public TCPEchoServer(int port) throws IOException {
this.serverSocket = new ServerSocket(port);
}
1.3 创建 start 方法,用来启动服务器
通过start 方法,用来启动服务器。
代码:
public void start(){
System.out.println("启动服务器!");
}
接下来,就是在 start 方法里面,实现服务器端程序的全部功能。
1.4 编写一个死循环,让服务器不断工作
对于服务器来说,客户端什么时候发请求,发送多少个请求,我们是无法预测的。
例如:
你玩游戏(手机里的游戏APP,就是客户端),游戏对应的服务器端(也就是服务器),是不知道你什么时候登录游戏,发送一个登录游戏的请求的,也不知道,在一个小时内,有多少个玩家发送了多少个登录请求,游戏公司无法预测,所以,游戏的服务器,是 7 X 24 小时,不断工作的,只有游戏版本更新,系统维护的时候,服务器才会关闭。
服务器关闭了,玩家登录游戏的时候,发送的登录请求,服务器关闭了,无法作出响应,你就登录不了游戏了。
只有更新结束,游戏系统维护好了以后,才能正常玩游戏。
通过上述例子可知:服务器端程序(服务器)通常都需要有一个 死循环,做到 7 X 24小时运行。
1.5 结束准备工作
代码:
import java.io.IOException;
import java.net.ServerSocket;
public class TCPEchoServer {
private ServerSocket serverSocket = null;
public TCPEchoServer(int port) throws IOException {
this.serverSocket = new ServerSocket(port);
}
public void start(){
System.out.println("启动服务器!");
// 服务器需要不断的工作,用一个死循环实现这一点
while(true){
// 接下来在这里继续编写代码
// ......
}
}
}
2. 建立连接
在while循环中,与UDP那边的服务器端程序不同。
UDP协议那边,你可以直接读取请求数据并解析,根据请求,计算响应,最后将响应返回给客户端了。
但是 TCP协议 不同,需要先处理客户端发来的连接。
这两者的差别就是因为,TCP是有连接的,UDP是无连接的。
如何理解 TCP 这么做的原因呢?
你可以理解为打电话。
2.1 举例说明,并理解
TCP 先建立连接,好比如我们生活中打电话。
打电话的情景:
我需要打电话给老板汇报工作,我(客户端)需要先拨号,给老板的手机(服务器)发送通话请求。
我这边发送请求之后,老板的手机就响铃提醒了,有人打电话,是否要接听电话,此时需要老板点击 “ 接通 ”,我才能和老板进行通话。
否则,老板不点击 “ 接通 ”,我这边,说再多东西,都是白搭,因为老板根本就不知道我说了什么,原因就是因为我们两个没有建立连接,无法进行沟通。
上述过程,我(打电话那一方)就类似于与客户端,发起了连接的请求,老板(需要点击 “ 接通 ” 电话的那一方),就类似于服务器端,需要与发起连接的客户端,建立连接。
只要服务器端,和客户端先建立连接了,后续客户端发送过来的请求信息,服务器端才能接收的到,并进行处理,最后将响应返回给客户端。
2.2 使用 accept 方法,建立连接
建立连接的操作,我们需要使用到 accept()
方法。
方法名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回⼀个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
相当于 客户端 给 服务器端打电话,服务器端调用了 accept方法,就接通了电话,可以进行通信了。
使用我们创建好的 Socket对象,调用 accept方法:
同时,这个方法还有一个返回值 :Socket
我们需要接收这个返回值:
这个返回值(clientSocket),是建立连接之后,返回的一个 Socket对象,是后续客户端和服务器端进行通信的一个对象。
后续,就是通过 读写 clientSocket ,和客户端进行通信。
可能大家还是对于这两个对象,有点懵,下面举个例子,看看你能不能更好的理解。
2.3 举例理解两个Socket对象的工作职责
不知道大家有没有在街上看到过西装革履的小哥或小姐姐。
一般看到这样的人,大概率是销售,只有少部分是其他。
这个销售可以销售很多东西,我就以卖房为例:
西装革履的销售小哥,在马路街道上,发传单或者口头呼喊着:“ 这里有一个不错的楼盘,快来看看,买到就是赚到… … ” 之类的。
此时,有一位帅小伙刚好想买房,就过去问了问,于是,销售小哥就把他带到了售楼中心,小哥一挥手,又来了一个西装革履的小姐姐,这位小姐姐是专业的销售顾问(也还是销售),接下来就由这位小姐姐给这位帅小伙介绍楼盘的详细情况。
小哥一转身,又出去招揽客人去了。
像这种,小哥在马路街道上,进行揽客,小姐姐,负责给客人提供详细的服务,就是这两个 Socket对象 的工作职责。
serverSocket:相当于小哥,在马路街道上招揽客人。
多个客户端要进行连接的时候,这个 Socket对象(serverSocket),调用 accept方法,与客户端建立连接,之后,把客人(建立了连接的客户端),交给了小姐姐(clientSocket)。
clientSocket:相当于小姐姐给客人(建立了连接的客户端)提供详细的服务。
我们跟客户端进行读写,发送,接收数据,就是用过这个 Socket对象(clientSocket),来进行负责的。
2.4 客户端没有发起连接
如果客户端没有发起连接,此时 accept
方法 就会阻塞等待。
跟 UDP 那边的 receive方法是类似的,如果客户端没有发送请求数据过来服务器端,receive方法,就会阻塞等待。
2.5 阶段总结
这里,我们主要是完成了对客户端的连接建立,后续,可以通过 clientSocket ,来对客户端的请求和响应进行处理了。
代码:
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class TCPEchoServer {
private ServerSocket serverSocket = null;
public TCPEchoServer(int port) throws IOException {
this.serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器!");
// 服务器需要不断的读取客户端的请求数据,用一个死循环实现这一点
while(true){
// 对于 TCP 来说,需要先处理客户端发来的连接
// clientSocket 这个变量,就表示和服务器和客户端进行交流的一个对象,
// 后续,就通过读写 clientSocket ,和客户端进行通信
Socket clientSocket = serverSocket.accept();
}
}
}
3. 处理客户端请求和响应前的准备工作
与客户端建立连接之后,我们就可以对客户端的请求和响应,进行处理了。
但是,在那之前,我们仍要做一些准备工作:
将处理的过程,封装成一个方法,
然后打印日志,说明客户端已经连接成功了,
最后获取两个流对象,InputStream 和 OutStream。
3.1 单独封装成一个方法
当我们和一个客户端建立连接之后,就要对这个客户端的请求和响应,进行处理,这是一个单独的功能模块了。
对于一个单独的功能模块,建议用另一个类,或者封装成一个方法,来进行代码的编写,这样以后程序维护起来比较方便。
这个程序,对于客户端的请求和响应的处理过程,我们封装成一个方法:processConnect(clientSocket)
因为后续对客户端的读写,都是通过 clientSocket,所以,把它当作参数,传递给这个方法。
所以,这个方法,就是处理一个客户端连接之后的工作。
一个客户端建立连接之后的操作,可能涉及到这个客户端很多个的请求和响应。
因为一个客户端,建立连接之后,并不一定只发一个请求,有可能是多个请求,我们也要处理请求,得到多个响应。
3.2 打印日志
我们可以在 processConnect(clientSocket)
方法中,打印一个日志,输出 clientSocket 的 IP 和 端口号,判断客户端是否已经建立连接了。
打印的方式,通过 C语言 中的 printf 方式进行打印:
// 打印一个日志,输出 clientSocket 的 IP和端口号
System.out.printf("[%s : %d] 客户端连接成功,客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
这里,我们使用 Socket 提供的 getInetAddress()
方法,获取到客户端的 IP地址
方法名 | 方法说明 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
clientSocket.getPort()
,是获取到客户端的端口号。
3.3 获取两个流对象
TCP协议 并没有像 UDP协议 那里一样,提供了send 和 receive 方法。
而是提供了InputStream getInputStream()
和 OutputStream getOutputStream()
这两个方法,获取到 字节输入流对象 和 字节输出流对象。
Socket 本身,是不会进行读写操作的,而是借助两个字节流对象,InputStream 和 OutStream,完成类似于 UDP协议的 send 和 receive 完成的工作。
所以,想要处理请求,得到响应,我们就需要获取到字节输入流对象 和 字节输出流对象(InputStream 和 OutputStream)。
try with resource 语法说明
在 Java 文件操作 和 IO(3)-- Java文件内容操作(1)-- 字节流操作 这篇博客中,我们介绍了一种定义 字节流对象 的方式:try with resource语法。
try with resource 语法,就是把需要进行关闭的资源(需要执行关闭操作 close
方法 的类),放到 try 后面的括号里面,只要出了 try 的代码块,就会自动调用该资源的 close
方法。
这个语法,不仅可以达到关闭文件资源的操作,而且,也简化了代码,使代码看起来更加美观!
更多细节,点击上面的博客连接,进行了解。
编写代码
代码:
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
} catch (IOException e) {
throw new RuntimeException(e);
}
拿到输入流对象(InputStream )和输出流对象(OutputStream )
之后,我们读客户端的请求的时候,我们使用输入流对象(InputStream )
返回响应给客户端的时候,我们使用输出流对象(OutputStream ),把响应写回给客户端
之后,我们就在 try 代码块里面,开始真正处理客户端的请求,并得到响应。
3.4 阶段总结
这里我们主要是在处理客户端的请求和响应之前,做了些准备工作,包括:
- 将处理的过程,封装成一个方法
- 打印日志,检测客户端是否已经连接成功
- 获取两个流对象,InputStream 和 OutStream
代码:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class TCPEchoServer {
private ServerSocket serverSocket = null;
public TCPEchoServer(int port) throws IOException {
this.serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器!");
// 服务器需要不断的读取客户端的请求数据,用一个死循环实现这一点
while(true){
// 对于 TCP 来说,需要先处理客户端发来的连接
// clientSocket 这个变量,就表示和服务器和客户端进行交流的一个对象,
// 后续,就通过读写 clientSocket ,和客户端进行通信
Socket clientSocket = serverSocket.accept();
// 处理一个客户端的请求和响应
processConnect(clientSocket);
}
}
// 处理一个客户端的连接
// 一个客户端,可能会发送多个请求,就要处理多个请求,得到多个响应
private void processConnect(Socket clientSocket) {
// 打印一个日志,输出 clientSocket 的 IP和端口号
System.out.printf("[%s : %d] 客户端连接成功,客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
// 真正处理客户端的请求,并得到响应
// ......
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
4. 处理请求,得到响应
在上面我们说过:因为一个客户端,建立连接之后,并不一定只发一个请求,有可能是多个请求,我们也要处理请求,得到多个响应。
处理请求的步骤分为三步,但是,处理请求的这三个步骤,一次只能处理一个请求,如果是一个客户端,有多个请求,就需要多次处理,需要使用一个循环,处理多个请求。
并且,这个循环是一个死循环,因为你不知道客户端到底会发多少个请求过来。
代码:
处理请求的步骤
接下来的步骤,和 UDP 那边,处理请求的步骤是一样的了。
处理请求的过程,典型的服务器都是分为三个步骤的,分别是:
- 读取请求,并解析请求数据
- 根据请求,计算响应(服务器最关键的逻辑)
本身服务器就是要提供各种各样的功能的,比如:百度搜索 “ 如何学习好Java ”,我的请求是:“ 如何学习好Java ”,服务器端的程序就会根据请求,搜索出很多个相关的页面。
说起来很简短,但是,在服务器端程序上,会经过各种各样的逻辑处理,处理请求,计算响应,最终返回结果。
所以,这一块是服务器最关键的逻辑。
但是,我们这篇博客编写的是回显服务器,所以,这个环节就相当于省略了,请求是什么,我们就返回什么。
- 把响应返回给客户端
所以,一个典型的服务器,都是按照这三个步骤来进行处理的,不仅是我们编写的这个回显服务器,未来,你从事编写服务器端程序(服务器)相关的工作岗位,涉及到更加复杂的,商业级别的服务器,总体上的逻辑,也就是这三步。
只不过,公司的服务器端程序,会考虑的更多,支持的功能会更多,会很复杂,上至几十万行的代码量。
接下来,我们就根据这三个步骤,进行代码的编写。
由于篇幅太长了,我将处理请求的这三个步骤的代码编写,放到一个新的博客里面,大家需要点击进去这里看:
Java网络编程:(socket API编程:TCP协议的 socket API – 服务器端处理请求的三个步骤)
5. 为 TCPEchoServer类(服务器端程序),编写main方法,启动服务器端程序
代码:
public static void main(String[] args) throws IOException {
TCPEchoServer tcpEchoServer = new TCPEchoServer(9090);
tcpEchoServer.start();
}
6. 总结编写步骤
看完上面的步骤,可能你还有点懵,我来带你会看一下,这个程序,整一个的编写流程:
1. 准备工作
这一步,我们主要是为了后续的编写,做一些准备工作。
准备工作有:
- 创建服务器端的 ServerSocket对象,并通过 TCPEchoServer 这个服务器类的构造方法,绑定服务器端程序的端口
- 创建 start 方法,让实例化的对象,调用这个方法,启动服务器程序。
- 为了让服务器不断的工作,在 start 方法中,创建一个死循环
2. 和客户端建立连接
这个时候,是启动服务器后,服务器进行工作,需要做的事情,所以,代码应该是在 start方法 中的死循环中编写。
建立连接:
通过 服务器端的 ServerSocket对象,调用 accept
方法,和客户端建立连接,并接收返回值。
3. 处理客户端请求之前的准备工作
这一步,是和客户端建立连接之后,客户端回发送请求过来之前,我们需要做准备工作,然后再进行请求的处理。
准备工作:
- 处理客户端发送的请求,是一个独立的功能模块,用一个单独的方法封装一下,此处是自定义的,如:
processConnect(clientSocket);
- 打印一个日志,输出 clientSocket(可以理解为建立连接之后,返回的客户端的代表) 的 IP 和 端口号,判断客户端是否已经建立连接了。
- 通过 try with resource 语法,获取 clientSocket 的两个流对象,后续通过他们,读取客户端发送过来的请求 和 返回响应给客户端。
4. 处理请求,得到响应
从这一步开始,我们就可以根据处理请求的三个步骤,进行代码的编写了。
由于与客户端建立连接之后,一个客户端可能发送多个请求,需要建立一个死循环,不断处理发送过来的请求,直到下一次没有请求发送过来了,就结束死循环,也断开和客户端的连接。
并且,处理请求的三个步骤的代码,也在这个死循环里面写。
代码编写:
- 读取请求,并解析(以下两种,二选一)
- 可以使用 InputStream 提供的 read 方法读取数据(字节流的方式),然后手动转化为 String,方便后续操作
- 也可以通过 Scanner 的方式(字符流的方式),进行读取。
- 根据请求,计算响应
回显程序:客户端发送的请求,就是返回给客户端的响应 - 将响应返回给客户端(以下两种,二选一)
- 使用 OutputStream 提供的 write方法,将响应写到客户端(返回给客户端),这是 字节流的方式。
- 也可以使用 PrintWriter 将响应写回(发送)给客户端,这是字符流的方式
5. 为 TCPEchoServer类(服务器端程序),编写main方法,启动服务器端程序
编写main方法,创建一个 TCPEchoServer类 的对象,调用 start方法,启动服务器端程序。
6. 目前的服务器端代码:
import java.io.*;
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 {
this.serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器!");
// 服务器需要不断的读取客户端的请求数据,用一个死循环实现这一点
while(true){
// 对于 TCP 来说,需要先处理客户端发来的连接
// clientSocket 这个变量,就表示和服务器和客户端进行交流的一个对象,
// 后续,就通过读写 clientSocket ,和客户端进行通信
Socket clientSocket = serverSocket.accept();
// 处理一个客户端的请求和响应
processConnect(clientSocket);
}
}
// 处理一个客户端的连接
// 一个客户端,可能会发送多个请求,就要处理多个请求,得到多个响应
private void processConnect(Socket clientSocket) {
// 打印一个日志,输出 clientSocket 的 IP和端口号
System.out.printf("[%s : %d] 客户端连接成功,客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
// 针对 InputStream,采用 Scanner 进行读取操作,套了一层
Scanner scanner = new Scanner(inputStream);
// 针对 OutputStream ,采用 PrintWriter 进行写操作,套了一层
PrintWriter writer = new PrintWriter(outputStream);
// 一个客户端,可能会发送多个请求,就要处理多个请求,得到多个响应
// 所以,需要使用 while 循环,处理多次
// 不知道客户端到底会发送多少个请求,所以,使用死循环
while(true){
// 这里的工作分为三个步骤:
// 1. 读取请求,并解析请求数据
// 字节流的方式,进行读取操作
// byte[] requestByte = new byte[1024];
// int byteNum = inputStream.read(requestByte);
////// 为了后续的处理更加方便,需要手动转换为 String
// String request = new String(requestByte,0,byteNum);
// 对 InputStream 进行套壳,使用 Scanner 的方式,进行读取
if (!scanner.hasNext()){
// 客户端没有请求发送过来了,就不需要继续读取请求,并处理了
// 可以断开和客户端的连接(结束循环)
// 打印一个日志,显示 服务器 和 客户端,断开了连接
System.out.printf("[%s : %d] 断开连接,客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
break;
}
// 使用 Scanner 读取请求
String request = scanner.next();
// 2. 根据请求,计算响应(服务器最关键的逻辑)
String response = precess(request);
// 3. 把响应返回给客户端
// 字节流的方式,将响应写回(发送)给客户端
// 使用字节输出流对象提供的 write方法,将响应写到客户端(返回给客户端)
// outputStream.write(response.getBytes());
// OutputStream是以字节为单位,进行写操作的
// response.getBytes():当前响应是一个String,需要拿到响应中的字节数据,才能进行写操作。
// 对 OutputStream 进行套壳,使用 PrintWriter 的方式,将响应写回(发送)给客户端
writer.println(response);
// 4.打印日志,显示 客户端发送的请求 和 服务器端返回的响应信息
System.out.printf("[%s : %d] 请求(req):%s,响应信息(resp):%s \n",clientSocket.getInetAddress(),clientSocket.getPort(),
request,response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private String precess(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TCPEchoServer tcpEchoServer = new TCPEchoServer(9090);
tcpEchoServer.start();
}
}
7. 总结
这篇博客,完成了对服务器端程序代码的编写,但是,当我们配合客户端一起运行的时候,会有问题。
这个问题是使用了 PrintWriter 方式后,才会产生的问题。
关于缓冲区的问题。
怎么回事?
我们在这篇博客中解决:
Java网络编程(4):(socket API编程:TCP协议的 socket API – 回显程序)
最后,如果这篇博客能帮到你的,请你点点赞,有写错了,写的不好的,欢迎评论指出,谢谢!