TCP字节流套接字编程
一.TCP建立连接的表现形式
短连接: 客户端每次给服务器发送消息,先建立连接,发送请求,读取响应,关闭连接,下次再发送则重新建立连接
长连接: 客户端建立连接之后,连接不着急断开,然后再发送请求,读取相应,发送请求,读取响应;若干轮之后,客户端确实短时间内不需要这个连接了,此时再断开
实际通信中两种情况都可能出现
二.TCP API提供的两个类
注意: TCP不需要一个类来表示"TCP数据报",因为TCP不像UDP是以数据报的形式传输的,而是以字节流的形式传输
1. ServerSocket
见名知意,ServerSocket是专门为服务器提供的类
ServerSocket的构造方法
方法签名 | 方法说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
ServerSocket(int port) 创建一个ServerSocket对象,并指定一个端口,该端口就是服务器要绑定的端口
ServerSocket 方法:
方法签名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
Socket accept() 开始监听相当于打电话等待对方接电话,接电话之后会返回一个Socket对象,进一步我们通过这个Socket对象来和客户端进行沟通
2. Socket
与ServerSocket不同,Socket不是专门为客户端提供的类。
Socket的构造方法
方法签名 | 方法说明 |
---|---|
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接 |
在服务器这边: 通过accept方法返回一个Socket对象
在客户端这边: 需要自己构造一个,构造的时候指定IP和端口,这里的IP和端口是服务器的IP和端口,有了这个信息后就能和服务器建立连接了
Socket 方法
方法签名 | 方法说明 |
---|---|
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
InetAddress getInetAddress() | 返回套接字所连接的地址 |
有了Socket对象,可以进一步获取到内部的流对象,通过流对象来进行发送/接收
三.通过TCP字节流套接字编程实现回显服务器
1.服务器端代码实现
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 {
System.out.println("服务器启动!!!");
ExecutorService threadPool1 = Executors.newCachedThreadPool();
while(true){
//使用这个clientSocket与具体的客户端进行交流
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
//使用这个方法来处理一个连接
//这一个连接对应到一个客户端,但是这里可能会涉及到多次交互
public void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线!!!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//基于上述Socket对象和客户端通信
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
//由于要处理多个请求和响应,所以使用循环
while(true){
// 1.读取请求
Scanner sc = new Scanner(inputStream);
if(!sc.hasNext()){
System.out.printf("[%s:%d]客户端下线了!!!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
String request = sc.next();
// 2.根据请求计算响应
String response = process(request);
// 3.返回响应结果
//(1)可以将字符串转化为字节数组用outputStream.write写入
//(2)也可以用字符流包装一下直接写入一个字符串
//outputStream.write(response.getBytes());
PrintWriter printWriter = new PrintWriter(outputStream);
//这里用println来写入,让结果中有一个 \n 方便客户端解析
printWriter.println(response);
//flush()用来刷新缓冲区,保证当前写入的数据确实发送出去了
printWriter.flush();
System.out.printf("[%s:%d] req:%s res:%s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),request,response);
}
}catch(IOException e){
e.printStackTrace();
}finally {
try {
//更合适的做法保证close()一定能执行到
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
(1)服务器端代码注意事项
1.1
accept()方法的效果是接收连接,前提是要有客户端来建立连接
客户端在构造Socket对象时,指定服务器的IP和Port
如果没有客户端建立连接该方法会发生阻塞
1.2
TCP服务器端里面涉及到两种Socket对象,他们各司其职,互不干扰.
serverSocket是给服务器指定端口,让进程和serverSocket对象产生联系,简单地说就是让服务器和进程产生联系
clientSocket非常重要,我们就是靠它来和客户端进行交互的
1.3
在这个代码中,每次有一个客户端进行连接就会返回一个Socket对象,(Socket对象)就是文件.每次创建Socket对象就要占用一个文件描述符表的位置,因此在使用完后必须释放
那为什么之前的UDP或者TCP的客户端它们不用释放呢,因为他们的Socket对象生命周期长(跟随整个程序),数量也不多,是固定的.
但是这里的clientSocket,数量多,每个客户端都有一个,生命周期也更短,跟随着当前这一个连接,客户端不连了就可以释放了
2.客户端代码实现
package network;
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 {
Socket socket = null;
public TcpEchoClient(String serverIP, int serverPort) throws IOException {
socket = new Socket(serverIP,serverPort);
}
public void start(){
System.out.println("客户端启动!!!");
Scanner sc = new Scanner(System.in);
try(InputStream inputStream =socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while(true){
// 1.先从键盘上读取用户输入的内容
System.out.println(">");
String request = sc.next();
if(request.equals("exit")){
System.out.println("goodbye");
break;
}
// 2.把读到的内容构造成请求发送给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
//此处加上flush保证数据确实发送过去了
printWriter.flush();
// 3.读取服务器的响应
Scanner responseScanner = new Scanner(inputStream);
String response = responseScanner.next();
//把响应的内容显示到界面上
System.out.println(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();
}
}
(1)客户端代码注意事项
1.1
这个对象构建的过程就是触发TCP建立连接的过程.如果客户端没这个代码,那么服务器就会在accept()方法中阻塞,也就没有clientSocket这个用于通信的变量了
1.2
这里利用PrintWriter这个字符流将outputStream进行了包装.有以下好处:
(1)因为outputStream提供的方法里面并不支持直接写一个字符串,只能写一个字节数组
(2)PrintWriter中的println方法自带一个换行符,刚和可以将这个换行符作为对端(服务器)在接收时的结束标志,否则服务器不知道要读到哪里结束.
当然这并不意味着outputStream不能进行读写操作,因为TCP本质上传输的是字节流,所以只需将上述代码换成下面的即可
可以看到本质上只要能让接收端知道读到哪里结束即可
为什么只能是换行符呢? 因为我们在接收是调用的是next()方法,next()方法遇到空格,换行符,空白字符停止,nextLine()方法在这里也可以,nextLine()方法遇到换行符停止,为了两者同一,所以就用换行符作为接收的结束标志
有的人可能在想我们在输入时按回车相当于给了一个换行符,为什么又要加一个换行符?
注意: next()和nextLine()遇到换行符停止读取,意味着只读取到换行符之前,换行符是不会被读到字符串中的
四.当前代码中的问题及解决方式
(1) 问题一
细心的人会发现上述服务器代码只能让一个客户端进行连接,这是因为
在start()方法中调用processConnection方法,而processConnection里在处理所有和这个连接有关的请求,只要这个连接不断开,这个while循环就不会结束,也就无法和下一个客户端进行连接了,有人可能会说那不用循环可不可以呢,答案是不可以,因为在String request = sc.nextLine()方法中可能会阻塞,即用户一直不输入信息
我们来看看该问题导致程序运行的效果:
可以看到虽然有两个客户端进行连接,但服务器只显示了第一个客户端上线并且只能接收到第一个客户端的消息,其实这就是所谓的占线了,就相当于你在打电话,别人又打给你,自然这个人就会听到您所拨的电话正在通话中,如果我将客户端一下线,会发生什么呢
可以看到将客户端一下线紧接着客户端二就上线,并且可以和服务器进行正常通信,那么如何解决这个不能同时通信的情况呢,那就是利用多线程
1.1问题一的解决方式(多线程)
每个线程是一个独立的执行流,彼此之间是并发的,一个阻塞不影响其他线程的执行.
让主线程专门进行accept()监听,每次接收到一个连接,创建一个新线程,由这个新线程去处理新的客户端
所以只需要将代码稍作调整将processConnection放入到一个线程中去执行就可以
这样就可以同时让多个客户端与服务器进行连接并响应.
(1) 问题二
但是还存在一个问题,就是当客户端的数量特别大,频繁的来建立销毁连接,因为要从操作系统内核上切断连接所以会导致运行效率特别低,为了解决这个问题就要用线程池
1.1问题二的解决方式(线程池)
运行结果和多线程相同,但当客户端数量特别多(频繁创建销毁连接)时效率高,但这也不是最终的版本,虽然解决了创建销毁连接的效率问题,但线程多导致机器无法承担的情况并没有解决,是否有单个线程处理多个客户端的连接呢?要想解决要用到IO多路复用(NIO),这个等以后有所了解再优化