TCP(Transmission Control Protocol),即传输控制协议。是一种面向连接的、可靠的、基于字节流的传输层通信协议。不同于UDP,TCP更像是提供一种可靠的、像管道一样的连接。
Java中的TCP主要涉及ServerSocket和Socket两个类。前者被认为是服务端的一个实体,用于接受连接。后者则被认为是连接的一种封装,用于传输数据,类似于一个管道。
下面就来实现一下服务端与客户端。
服务端:
public class TCPService {
public static final String SERVICE_IP = "127.0.0.1";
public static final int SERVICE_PORT = 10101;
public static final char END_CHAR = '#';
public static void main(String[] args) {
TCPService service = new TCPService();
//启动服务端
service.startService(SERVICE_IP,SERVICE_PORT);
}
private void startService(String serverIP, int serverPort){
try {
//封装服务端地址
InetAddress serverAddress = InetAddress.getByName(serverIP);
//建立服务端
try(ServerSocket service = new ServerSocket(serverPort, 10, serverAddress)){
while (true) {
StringBuilder receiveMsg = new StringBuilder();
//接受一个连接,该方法会阻塞程序,直到一个链接到来
try(Socket connect = service.accept()){
//获得输入流
InputStream in = connect.getInputStream();
//解析输入流,遇到终止符结束,该输入流来自客户端
for (int c = in.read(); c != END_CHAR; c = in.read()) {
if(c ==-1)
break;
receiveMsg.append((char)c);
}
//组建响应信息
String response = "Hello world " + receiveMsg.toString() + END_CHAR;
//获取输入流,并通过向输出流写数据的方式发送响应
OutputStream out = connect.getOutputStream();
out.write(response.getBytes());
}catch (Exception e){
e.printStackTrace();
}
}
}catch (Exception e){
e.printStackTrace();
}
} catch (UnknownHostException e) {
e.printStackTrace();
}
}
}
客户端
public class TCPClient {
public static void main(String[] args) {
TCPClient client = new TCPClient();
SimpleDateFormat format = new SimpleDateFormat("hh-MM-ss");
Scanner scanner = new Scanner(System.in);
while(true){
String msg = scanner.nextLine();
if("#".equals(msg))
break;
//打印响应的数据
System.out.println("send time : " + format.format(new Date()));
System.out.println(client.sendAndReceive(TCPService.SERVICE_IP,TCPService.SERVICE_PORT,msg));
System.out.println("receive time : " + format.format(new Date()));
}
}
private String sendAndReceive(String ip, int port, String msg){
//这里比较重要,需要给请求信息添加终止符,否则服务端会在解析数据时,一直等待
msg = msg+TCPService.END_CHAR;
StringBuilder receiveMsg = new StringBuilder();
//开启一个链接,需要指定地址和端口
try (Socket client = new Socket(ip, port)){
//向输出流中写入数据,传向服务端
OutputStream out = client.getOutputStream();
out.write(msg.getBytes());
//从输入流中解析数据,输入流来自服务端的响应
InputStream in = client.getInputStream();
for (int c = in.read(); c != TCPService.END_CHAR; c = in.read()) {
if(c==-1)
break;
receiveMsg.append((char)c);
}
}catch (Exception e){
e.printStackTrace();
}
return receiveMsg.toString();
}
}
单从代码结构的角度来看,UDP通信服务端与客户端代码是相似的,都是依托于DatagramPacket 对象收发信息。而TCP通信中,只有服务端有一个实体,客户端只要借助Socket收发信息即可,发送完关闭Socket。
上面有一点需要注意,在读输入流时,必须做读到流结束判断,就是读到-1,若没有做判断,在这样情况下会出错:若一个连接连接成功后,没有发生任何信息,或信息中没有结束字符,就关闭了连接,由于TCP连接是双向的,导致另一端一直从输入流中读到流结束标志,很快会导致OOM,所以在读到结束符时,要及时跳出循环。结束符只会在连接中断时发出,而在等待输入时,不会出现,所以不必担心在等待响应时由于读到该字符导致服务端或客户端提前中断连接。
另外Socket和ServerSocket在jdk 1.7之后都实现了AutoCloseable接口,所以可以用try-with-resources结构。之前的UDP里的DatagramPacket 也一样
这就是一个简单的阻塞型服务器模型,分析代码我们可知,如果一次请求时间过长,会影响到后续请求的执行。我们可以在服务端输出时加一个sleep,启动两个客户端,分别发送消息,观察log,服务端延迟5s,结果如下:
客户端1:
send time : 06-04-06
Hello world 1
receive time : 06-04-11
客户端2:
send time : 06-04-08
Hello world 2
receive time : 06-04-16
其中客户端1先发送,客户端2后发送,可见客户端在等待服务器处理完客户端1的请求后才处理客户端2的请求
由此我们可以预见,当服务器接到一个需要长时间处理的请求时,会阻塞后续的请求,这也就是这种类型服务器容易遭到攻击的原因。为了应对这种局面,我们可以在收到一个请求时,调用子线程去处理,服务器时刻处在接受请求的状态。
public class TCPService1 {
public static final String SERVICE_IP = "127.0.0.1";
public static final int SERVICE_PORT = 10101;
public static final char END_CHAR = '#';
public static void main(String[] args) {
TCPService1 service1 = new TCPService1();
service1.startService();
}
private void startService(){
try {
InetAddress address = InetAddress.getByName(SERVICE_IP);
Socket connect = null;
ExecutorService pool = Executors.newFixedThreadPool(5);
try (ServerSocket service = new ServerSocket(SERVICE_PORT,5,address)){
while(true){
connect = service.accept();
//创建一个任务
ServiceTask serviceTask = new ServiceTask(connect);
//放入线程池等待运行
pool.execute(serviceTask);
}
}catch (Exception e){
e.printStackTrace();
}finally {
if(connect!=null)
connect.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
class ServiceTask implements Runnable{
private Socket socket;
ServiceTask(Socket socket){
this.socket = socket;
}
@Override
public void run() {
try {
StringBuilder receiveMsg = new StringBuilder();
InputStream in = socket.getInputStream();
for (int c = in.read(); c != END_CHAR; c = in.read()) {
if(c ==-1)
break;
receiveMsg.append((char)c);
}
String response = "Hello world " + receiveMsg.toString() + END_CHAR;
Thread.currentThread().sleep(5000);
OutputStream out = socket.getOutputStream();
out.write(response.getBytes());
}catch (Exception e){
e.printStackTrace();
}finally {
if(socket!=null)
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
在这个服务器中,我们采用了线程池的做法,每到一个请求,我们就向线程池中添加一个任务。实际运行情况如下:
客户端1
send time : 03-04-59
Hello world 1
receive time : 03-04-04
客户端2
send time : 03-04-01
Hello world 2
receive time : 03-04-06
可见每个客户端能在发送信息后得到响应,不必排队。但是这种类型的服务器并不能保证实时响应,当请求数过多时,服务器资源会被耗尽,或者服务器有最大线程数有限制,多余的请求依然会被阻塞。
第一二种服务器模型中,我们在读取流的时候加入了自定义的结束符,同时采用for循环,但是一次从输入流中读一个数据,效率比较低,我们可以采用缓冲区的方法,但是这种方法不能判断自定义的结束符,只能判断流结束,所以要及时关闭流,如客户端发完数据后关闭输出流:
OutputStream out = client.getOutputStream();
out.write(msg.getBytes());
client.shutdownOutput();
InputStream in = client.getInputStream();
int len;
byte[] buffer = new byte[1024];
while((len = in.read(buffer))!=-1)
receiveMsg.append(new String(buffer,0,len));
由于TCP通信是双向的,所以可以单独关闭一端,但是不能直接关闭输入或输出流,这样会将整个Socket关闭。
最后简单介绍一下涉及的几个类
1. Socket
该类的构造方法有许多,但目的都只有一个,就是创建一个指向服务端的链接,用于收发数据。
Socket(InetAddress address, int port) //通过InetAddress指定服务端地址
Socket(InetAddress address, int port, InetAddress localAddr, int localPort) //指定服务器地址与端口还指定客户端的地址和端口
Socket(String host, int port) //通过字符串指定服务端地址
Socket(String host, int port, InetAddress localAddr, int localPort)
可以发现上面构造分为两类,一类只指定服务端的地址和端口。另一类在第一类的基础上还指定了客户端的地址和端口号。其实第一类是默认本机地址作为客户端地址,同时随机选一个可用端口作为客户端端口而已。
常用方法:
OutputStream getOutputStream()//获得输出流
InputStream getInputStream() //获得输入流
void connect(SocketAddress endpoint) //链接到服务端
void connect(SocketAddress endpoint, int timeout)//链接到服务端,并指定超时时间
//几个获取本地端相关信息的方法
InetAddress getLocalAddress()
int getLocalPort()
SocketAddress getLocalSocketAddress()
//几个获得远端相关信息的方法
int getPort()
InetAddress getInetAddress()
SocketAddress getRemoteSocketAddress()
2. ServerSocket
构造方法
ServerSocket(int port) //指定服务器端口,默认tcp队列为50,监听所有本机地址的链接,用于多网卡设备
ServerSocket(int port, int backlog) //指定端口和队列长度
ServerSocket(int port, int backlog, InetAddress bindAddr) //指定所有信息
常用方法:
Socket accept() //从队列中取一个请求,阻塞型方法
void bind(SocketAddress endpoint) //对于无参的构造进行地址与端口绑定
void bind(SocketAddress endpoint, int backlog)
//获取本地的一些信息
InetAddress getInetAddress()
int getLocalPort()
SocketAddress getLocalSocketAddress()