一、本次目標
- 編寫最簡單的1:1的server:client,感受socket通信編程;
- 修改client為多線程,模擬多個client請求server,觀測server響應(yīng);
- 封裝server處理client請求方法,另開線程處理;
- 處理線程加入線程池管理,減輕server負荷;
二、動手實踐
1、編寫配置接口,包含默認服務(wù)端地址、端口等
package com.cjt.io;
public interface Config {
String DEFAULT_ENCODE = "UTF-8";
String DEFAULT_ADDR = "127.0.0.1";
int DEFAULT_PORT = 6666;
}
2、編寫時間工具類DateUtil,方便輸出日志打印
package com.cjt.io;
import java.text.SimpleDateFormat;
import java.util.Date;
public class DateUtil {
private final static String DEFAULT_PATTERN = "HH:mm:ss";
public static String getCurTimeStr(){
SimpleDateFormat sdf = new SimpleDateFormat(DEFAULT_PATTERN);
return sdf.format(new Date());
}
}
3、編寫服務(wù)端代碼
package com.cjt.io.bio;
import com.cjt.io.Config;
import com.cjt.io.DateUtil;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerBIO implements Config{
private ServerSocket server;
private ServerBIO(int port){
try {
server = new ServerSocket(port);
} catch (IOException e) {
e.printStackTrace();
}
}
private void start() {
System.out.println("[" + DateUtil.getCurTimeStr() + "]:服務(wù)端已經(jīng)啟動");
try {
while (true) {
System.out.println("[" + DateUtil.getCurTimeStr() + "]:server循環(huán)獲取client請求開始");
// 阻塞,直到有客戶端發(fā)起請求
Socket socket = server.accept();
System.out.println("[" + DateUtil.getCurTimeStr() + "]:有新的client連接啦");
try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), DEFAULT_ENCODE))){
String line;
StringBuilder builder = new StringBuilder();
// 普通IO流會阻塞,所以這里的readLine()時間會根據(jù)客戶端操作的時間而定
while ((line = reader.readLine()) != null) {
builder.append(line);
builder.append(System.getProperty("line.separator"));
}
System.out.println("[" + DateUtil.getCurTimeStr() + "]:client傳來消息");
System.out.println(builder.toString());
} catch (IOException e) {
e.printStackTrace();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args){
new ServerBIO(DEFAULT_PORT).start();
}
}
4、編寫客戶端代碼
package com.cjt.io.bio;
import com.cjt.io.Config;
import com.cjt.io.DateUtil;
import java.io.*;
import java.net.Socket;
public class ClientBIO implements Config {
private String serverIp;
private int port;
private String msg;
private int second;
private ClientBIO(String serverIp, int port) {
this.serverIp = serverIp;
this.port = port;
}
private ClientBIO second(int second) {
this.second = second;
return this;
}
private ClientBIO msg(String msg) {
this.msg = msg;
return this;
}
private void sleep(int second) {
try {
Thread.sleep(second * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void send() {
try (Socket socket = new Socket(serverIp, port)) {
System.out.println("[" + DateUtil.getCurTimeStr() + "]:連接server成功");
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), DEFAULT_ENCODE));
writer.write(msg);
writer.flush();
sleep(second);
System.out.println("[" + DateUtil.getCurTimeStr() + "]:client寫入消息" + msg);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ClientBIO client_1 = new ClientBIO(DEFAULT_ADDR, DEFAULT_PORT).second(3).msg("我是第一個客戶端");
client_1.send();
}
}
5、測試IO流的阻塞性
基于最簡單的單線程方式我們分別實現(xiàn)了server和client的編寫,這里不會詳細闡述怎么讀流,寫數(shù)據(jù),buffered流怎么用,主要是研究IO流的特性,更好地學會怎么處理高并發(fā)中的流處理。
首先啟動ServerBIO,查看控制臺:
[17:06:54]:服務(wù)端已經(jīng)啟動
[17:06:54]:server循環(huán)獲取client請求開始
根據(jù)server的代碼,可以發(fā)現(xiàn)目前就阻塞在server.accept()
這個位置,然后我們再啟動client的main方法,模擬了兩個client一前一后發(fā)送請求,觀察client的控制臺:
[17:06:57]:連接server成功
[17:07:00]:client寫入消息我是第一個客戶端
同時切到server的控制臺:
[17:06:54]:服務(wù)端已經(jīng)啟動
[17:06:54]:server循環(huán)獲取client請求開始
[17:06:57]:有新的client連接啦
[17:07:00]:client傳來消息
我是第一個客戶端
[17:07:00]:server循環(huán)獲取client請求開始
57秒client連接server成功,同時server打印“有新的client連接”,由于手動sleep的原因,直到00秒時client請求寫入數(shù)據(jù)才完成,而server便一直阻塞在reader.readLine()
這個位置,直接阻塞到00秒讀取到client的消息,然后繼續(xù)循環(huán)獲取client阻塞在server.accept()
這個位置。
這只是一個最最簡單的socket通信模型,只有一個client請求,那么我們修改client實現(xiàn)Runnable接口,開啟線程發(fā)送數(shù)據(jù),模擬多個client并行訪問server:
package com.cjt.io.bio;
import com.cjt.io.Config;
import com.cjt.io.DateUtil;
import java.io.*;
import java.net.Socket;
public class ClientBIO implements Config, Runnable {
private String serverIp;
private int port;
private String msg;
private int second;
private ClientBIO(String serverIp, int port) {
this.serverIp = serverIp;
this.port = port;
}
private ClientBIO second(int second) {
this.second = second;
return this;
}
private ClientBIO msg(String msg) {
this.msg = msg;
return this;
}
private void sleep(int second) {
try {
Thread.sleep(second * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public void run() {
try (Socket socket = new Socket(serverIp, port)) {
System.out.println("[" + DateUtil.getCurTimeStr() + "]:連接server成功");
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), DEFAULT_ENCODE));
writer.write(msg);
writer.flush();
sleep(second);
System.out.println("[" + DateUtil.getCurTimeStr() + "]:client寫入消息" + msg);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ClientBIO client_1 = new ClientBIO(DEFAULT_ADDR, DEFAULT_PORT).second(3).msg("我是第一個客戶端");
new Thread(client_1).start();
ClientBIO client_2 = new ClientBIO(DEFAULT_ADDR, DEFAULT_PORT).second(1).msg("我是第二個客戶端");
new Thread(client_2).start();
}
}
運行client的main方法,觀察client的控制臺:
[17:21:44]:連接server成功
[17:21:44]:連接server成功
[17:21:45]:client寫入消息我是第二個客戶端
[17:21:47]:client寫入消息我是第一個客戶端
很明顯多線程并行發(fā)送請求到server,由于client_2的sleep為1秒的原因,較client_1先打印,然后回到server的控制臺:
[17:21:37]:服務(wù)端已經(jīng)啟動
[17:21:37]:server循環(huán)獲取client請求開始
[17:21:44]:有新的client連接啦
[17:21:47]:client傳來消息
我是第一個客戶端
[17:21:47]:server循環(huán)獲取client請求開始
[17:21:47]:有新的client連接啦
[17:21:47]:client傳來消息
我是第二個客戶端
[17:21:47]:server循環(huán)獲取client請求開始
server這里控制臺輸出完全相反,44秒提示“有新的client連接啦”,這里是client_1,通過后面的msg也可以知道,然后在47秒讀取完client_1后立即獲取到client_2的連接,雖然client_2的sleep有1秒的時間,但是由于client_2的連接時間在44秒,所以數(shù)據(jù)早已準備就緒,但是server的線程阻塞在讀取client_1了。
三、補充完善
1、封裝server處理client請求,單獨新開線程:
package com.cjt.io.bio;
import com.cjt.io.Config;
import com.cjt.io.DateUtil;
import java.io.*;
import java.net.Socket;
public class ClientHandler implements Runnable, Config {
private Socket socket;
ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), DEFAULT_ENCODE));
String line;
StringBuilder builder = new StringBuilder();
// 普通IO流會阻塞,所以這里的readLine()時間會根據(jù)客戶端操作的時間而定
while ((line = reader.readLine()) != null) {
builder.append(line);
builder.append(System.getProperty("line.separator"));
}
System.out.println("[" + DateUtil.getCurTimeStr() + "]:client傳來消息");
System.out.println(builder.toString());
} catch (IOException e) {
e.printStackTrace();
}
}
}
同時修改server的start調(diào)用代碼:
System.out.println("[" + DateUtil.getCurTimeStr() + "]:服務(wù)端已經(jīng)啟動");
while (true) {
try {
System.out.println("[" + DateUtil.getCurTimeStr() + "]:server循環(huán)獲取client請求開始");
// 阻塞,直到有客戶端發(fā)起請求
Socket socket = server.accept();
System.out.println("[" + DateUtil.getCurTimeStr() + "]:有新的client連接啦");
new Thread(new ClientHandler(socket)).start();
} catch (IOException e) {
e.printStackTrace();
break;
}
}
不需要修改client代碼,直接啟動server后,在啟動client的main方法,觀測這次server的控制臺:
[17:37:53]:服務(wù)端已經(jīng)啟動
[17:37:53]:server循環(huán)獲取client請求開始
[17:38:00]:有新的client連接啦
[17:38:00]:server循環(huán)獲取client請求開始
[17:38:00]:有新的client連接啦
[17:38:00]:server循環(huán)獲取client請求開始
[17:38:02]:client傳來消息
我是第二個客戶端
[17:38:04]:client傳來消息
我是第一個客戶端
對比上面,很明顯server新開了client處理線程,在處理client消息方面不會造成阻塞了。server收到client連接后新開線程處理并立即輪詢接下來連接的client,“我是第二個客戶端”較先打印很好的印證了這點。
2、將ClientHandler處理線程加入到線程池
Java創(chuàng)建的線程屬于寶貴的系統(tǒng)資源,若是在大量的client訪問時,則會創(chuàng)建大量的client消息處理線程,這樣必將導(dǎo)致系統(tǒng)性能急劇下降,最終宕掉。所以我們可以將client消息處理線程加入到線程池中進行管理:
··· ···
// 定長線程池,超出數(shù)量的線程會在隊列中等待
private ExecutorService threadPool = Executors.newFixedThreadPool(20);
··· ···
// 創(chuàng)建一個新的線程加入到線程池中管理并立即提交執(zhí)行
threadPool.execute(new Thread(new ClientHandler(socket)));
··· ···
這樣就不用擔心由于高并發(fā)產(chǎn)生巨大的線程從而使系統(tǒng)崩潰,運行結(jié)果與上面相差無異。這里就不再重復(fù),感興趣的可以自己試試。
3、增加server響應(yīng)
修改ClientHandler,簡單回應(yīng)client(復(fù)述消息):
··· ···
// 在寫之前必須關(guān)閉讀
socket.shutdownInput();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), DEFAULT_ENCODE));
writer.write(builder.toString());
writer.flush();
socket.close();
··· ···
那么client的發(fā)送消息也得需要讀取server的響應(yīng)了,修改client的run方法:
@Override
public void run() {
try (Socket socket = new Socket(serverIp, port)) {
System.out.println("[" + DateUtil.getCurTimeStr() + "]:連接server成功");
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), DEFAULT_ENCODE));
writer.write(msg);
writer.flush();
sleep(second);
System.out.println("[" + DateUtil.getCurTimeStr() + "]:client寫入消息");
System.out.println(msg);
// 在寫之前必須關(guān)閉寫
socket.shutdownOutput();
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), DEFAULT_ENCODE));
StringBuilder builder = new StringBuilder();
while ((msg = reader.readLine()) != null){
builder.append(msg);
builder.append(System.getProperty("line.separator"));
}
System.out.println("[" + DateUtil.getCurTimeStr() + "]:server傳來消息");
System.out.println(builder.toString());
} catch (IOException e) {
e.printStackTrace();
}
}
同樣的先運行server后,再執(zhí)行client的main方法,觀測client的控制臺:
[18:08:07]:連接server成功
[18:08:07]:連接server成功
[18:08:08]:client寫入消息
我是第二個客戶端
[18:08:08]:server傳來消息
我是第二個客戶端
Disconnected from the target VM, address: '127.0.0.1:61618', transport: 'socket'
[18:08:10]:client寫入消息
我是第一個客戶端
[18:08:10]:server傳來消息
我是第一個客戶端
這說明已經(jīng)server成功做出了響應(yīng),并且client也成功讀取到了server的消息。這里需要強調(diào)的是:
- jdk 1.7開始,try后面可直接初始化某些流(implements AutoCloseable),則會自動關(guān)閉;
- socket關(guān)閉后對應(yīng)的輸入/輸出流也會被關(guān)閉;
- socket 一次I/O操作既有讀也有寫的話,中間一定要加上shutup流;